Computer Science/객체지향

프로그래밍 패러다임과 객체, 설계 - 코드로 이해하는 객체지향 설계

sh1mj1 2023. 7. 18. 20:59

지난 1년 간 스프링을 공부하고 안드로이드 앱 개발 프로젝트를 하면서 dependency(의존성)을 관리하는 것이 굉장히 중요하다는 이야기를 계속해서 접했고 공부했습니다. Spring boot 에서 bean 과 @Autowired  라는 어노테이션을 통해 의존성을 주입하고, 안드로이드 코트린에서는 hilt 라는 의존성 관리 라이브러리를 사용하여 의존성을 관리했습니다.

이 의존성은 결국 객체지향의 핵심일 것이다. 실무적인 관점에서 의존성을 공부하고 다루어 보니 객체지향을 더 깊게 공부해야 할 때가 온 것 같습니다. 

 

그래서 '오브젝트 : 코드로 이해하는 객체지향 설계 - 조영호' 책을 읽으면서 정리해보고자 합니다. 책의 머릿말에서 이 책은 실무 경험이 있으며 설계에 대한 고민을 충분히 한 사람들이 읽으면 좋을 것 같다고 써있습니다. (저는 아직 둘다 아닌 것 같네요)

그리고 이 책에서는 실제로 많은 양의 코드를 통해서 설명을 이어나갑니다. 당연히 저는 공부한 내용을 정리하고 자기 전에 보려고 쓰는 거라 모든 코드를 적지 않을 것입니다. 

만약 이 책을 구매하고자 하시면 참고해서 구매하시면 좋을 것 같습니다. (저는 그래서 '객체지향의 사실과 오해 - 조영호' 책을 먼저 읽었습니다. ㅎ)

 

프로그램 패러다임

 

현대인들이 사용하는 '패러다임' 이라는 단어의 의미는 '한 시대의 사회 전체가 공유하는 이론이나 방법, 문제의식 등의 체계'입니다.

패러다임 전환은 과거의 패러다임이 새로운 패러다임에 의해 대체됨으로써 정상 과학의 방향과 성격이 변하는 것입니다. 이 책에서는 패러다임의 전환을 절차형 패러다임에서 객체지향 패러다임으로의 변화를 말하고 있습니다.

 

프로그래밍 패러타임은 특정 시대의 어느 성숙한 개발자 공동체에 의해 수용된 프로그래밍 방법과 문제 해결 방법, 프로그래밍 스타일이라고 할 수 있습니다. 즉, 우리가 어떤 프로그래밍 패러다임을 사용하느냐에 따라 우리가 해결할 문제를 바라보는 방식과 프로그램을 작성하는 방법이 달라집니다.

 

프로그래밍 패러다임은 개발자 공동체가 동일한 프로그래밍 스타일과 모델을 공유하여 불필요한 부분의 의견 충돌을 방지합니다. 또한 동일한 규칙과 방법을 공유하는 개발자로 성장할 수 있도록 준비시킬 수 있습니다.

객체지향 패러다임 공부는 객체지향 프로그래밍을 하는 개발자들이 어느 정도 유사한 그림을 머릿속에 그릴 수 있는 기반을 가지고, 동일한 규칙과 표준에 따라 작성함을 위함입니다.

 

프로그래밍 패러다임은 과학계의 패러다임(천동설과 지동설)처럼 서로 다른 패러다임이 양립할 수 없는 것이 아닙니다. 절차형 패러다임에서 객체지향 패러다임으로 전환되었다고 해서 두 패러다임이 함께 존재할 수 없는 것은 아닙니다. 오히려 하나의 언어에 서로 다른 패러다임이 공존하면서 서로의 장단점을 보완하는 모습을 보입니다. 예를 들어 C++ 은 절차형 + 객체지향 패러다임을 접목시킨 언어입니다.  또한 패러다임이 바뀐다고 해서 프로그래머가 바라보는 세상이 완전히 달라지지도 않습니다.

 

객체지향 패러다임이 만능인 것은 아닙니다. 적합하지 않은 상황에서는 언제라도 다른 패러다임을 적용할 수 있는 시야와 지식을 길러야 하겠죠...

 

 

객체 설계

'어떤 분야를 막론하고 이론을 정립할 수 없는 초기에는 실무가 먼저 급속한 발전을 이루고 그 후에야 실무의 실용성을 입증할 수 있는 여러 이론이 모습을 갖추며 실무를 추월한다.' - 로버트 L. 글래스

즉, 실무가 이론보다 먼저입니다. 실무를 관찰한 결과를 바탕으로 이론을 정립하는 것이 효과적입니다. 소프트웨어 분야는 아직 걸음마 단계에 머물러 있기 때문에 이론보다 실무가 더 앞서 있으며 실무가 더 중요하다고 합니다.

 

모든 소프트웨어 모듈에는 아래 세가지 목적이 있습니다.

  1. 실행 중에 제대로 동작하는 것
  2. 변경을 위해 존재해야 함.
  3. 코드를 읽는 사람과 의사소통하는 것.

 

객체 사이의 의존성 문제

문제는 의존성이 변경과 관련되어 있다는 점입니다. 의존성은 곧 변경에 대한 영향을 가집니다. 어떤 객체가 변경될 때 그 객체에게 의존하는 다른 객체도 함께 변경될 수 있다는 사실이 내포되어 있죠.

목표는 애플리케이션의 기능을 구현하는데 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거하는 것입니다.

 

객체 사이의 의존성이 너무 과한 경우에는 결합도(coupling) 이 높다고 합니다. 반대로 객체들이 합리적인 수준으로 의존할 경우 결합도가 낮다고 합니다. 결합도는 의존성과 관련되어 있어서 변경과도 관련이 있습니다. 결합도를 낮춰서 변경이 용이한 설계를 만들어야 합니다.

 

소프트웨어 모듈의 목적 중 2번과 3번은 서로 엮여 있습니다. 과한 결합도 ,즉, 과한 의존성은 보통 어떠한 객체가 처리해야 할 일을 다른 객체가 다루고 있는 등의 모습을 가지고 있습니다. 우리의 직관과는 다르게 설계되어 있는 것입니다.

 

예를 들어 관람객(Audience) 가 가방(Bag)에 있는  티켓 교환권인 초대장(Invitation)을 티켓(Ticket) 으로 바꾸기 위해서 티켓 판매원(TicketSeller)에게 요청하여 교환을 하거나 혹은 돈을 지불하고 티켓을 구매한다고 합시다. 그리고 극장(Theater)은 그러한 관람객을 출입시켜야 합니다. 이 때 극장은 단지 티켓 판매원이 관람객을 들여보내게끔 하면 됩니다. 다른 정보는 알 필요가 없습니다. 그런데 극장이 티켓 구매, 교환 등에 모두 관여하고 있다면, 즉, 의존하고 있다면 각 객체들이 자율적이지 않은 것입니다.

 

책에서 설명하는 코드 중 일부분을 발췌. 각 클래스가 너무 많은 부분에 의존하고 있습니다.

public class Theater {
    private TicketSeller ticketSeller;
    
    public void enter(Audience audience) {
        if (audience.getBag().hasInvitation()) {
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().setTicket(ticket);
        } else {
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}
public class TicketSeller {
    private TicketOffice ticketOffice;

    public void sellTo(Audience audience) {
        if (audience.getBag().hasInvitation()) {
            Ticket ticket = ticketOffice.getTicket();
            audience.getBag().setTicket(ticket);
        } else {
            Ticket ticket = ticketOffice.getTicket();
            audience.getBag().minusAmount(ticket.getFee());
            ticketOffice.plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
        }
    }
}

 

캡슐화

개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 것을 캡슐화(encapsulation)이라고 합니다. 위의 경우 카페가 캐셔가 있는 캐싱머신에 접근해서 돈을 받도록 구현되어 있는 모습을 캐셔가 하도록 만드는 경우입니다. 캡슐화의 목적은 변경하기 쉬운 객체를 만드는 것입니다. 객체 내부로의 접근을 제한하여 객체 사이의 결합도를 낮출 수 있습니다. 이는 쉬운 설계 변경으로 이어집니다.

 

책에서 설명하는 코드 중 일부분을 발췌

public class Theater {
    private TicketSeller ticketSeller;

	public void enter(Audience audience) {
        ticketSeller.sellTo(audience);
    }
}
public class TicketSeller {
    private TicketOffice ticketOffice;

    public void sellTo(Audience audience) {
        ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
    }
}
public class Audience {
    private Bag bag;
    
    public Bag getBag() {
        return bag;
    }

    public Long buy(Ticket ticket) {
        if (bag.hasInvitation()) {
            bag.setTicket(ticket);
            return 0L;
        } else {
            bag.setTicket(ticket);
            bag.minusAmount(ticket.getFee());
            return ticket.getFee();
        }
    }
}

 

핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다.

캡슐화를 적용한 후에는 Theater TicketSeller 내부에 대해서는 전혀 알지 못합니다. 단지 TicketSellersellTo 메시지를 이해하고 응답할 수 있다는 사실만 알고 있을 뿐입니다. TicketSeller 역시 Audience 의 내부에 대해서 모릅니다. Audience 가 buy 메시지에 응답할 수 있고 자신이 원하는 결과를 반환할 것이라는 사실만 알고 있습니다.

 

밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜 응집도(cohension) 이 높다고 합니다. 자율성을 가진, 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도를 낮추면서 응집도를 높일 수 있습니다. 

 

"객체는 자신의 데이터를 스스로 처리하는 자율적인 존재여야 한다."  

외부의 간섭을 최대한 배제하고 메시지를 통해서만 협력하는 자율적인 객체들의 공동체를 만드는 것이 훌륭한 객체지향 설계입니다.

 

절차지향과 객체지향

위에 수정하기 전의 코드에서는 Audience, TicketSeller, Bag, TicketOffice 에 관람객을 입장시키는 데 필요한 모든 정보가 들엉 ㅣㅆ다.  Theater 의 enter 메소드가 모든 처리를 하고 있습니다.

이 관점에서 enter 메소드는 프로세스(Process) 이고 Audience, TicketSeller, Bag, TicketOffice 는 데이터(Data) 입니다. 이렇게 프로세스와 데이터를 별도의 모듈에 위치시키는 방법을 절차적 프로그래밍(Procedural Programming) 이라고 합니다.

 

절차적 프로그래밍은 우리의 직관에서 크게 벗어납니다. 당연히 우리는 관람객이 직접 가방에서 초대장 혹은 티켓, 현금을 꺼내는 것을 알고 있습니다. 하지만 절차적 프로그래밍에서는 이 모든 행위가 단지 데이터로 존재하고 이 행위들이 Theater 객체에 모여서 처리됩니다. 마치 타인이 관람객의 가방을 마음대로 하는 모양새가 되는 것이죠. 우리의 직관에서 벗어나기 때문에 코드를 읽는 사람과 원활하게 의사소통하지 못합니다. 

 

또한 절차적 프로그래밍에서는 데이터의 변경으로 인한 영향을 고립시키기 어렵습니다. 그러므로 변경, 유지, 보수하기도 어렵죠. 변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계입니다. 절차적 프로그래밍에서는 프로세스가 필요한 모든 데이터에 의존해야 한다는 근본적인 문제점 때문에 변경에 취약할 수 밖에 없습니다.

 

변경된 코드에서는 자신의 데이터를 스스로 처리하도록 프로세스의 단계가 Audience 와 TicketSeller 에게 이동되었습니다.

이처럼 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식객체지향 프로그래밍(Object-Oriendted Programming) 이라고 부릅니다.다.

 

결론적으로 훌륭한 객체지향 설계는 "캡슐화를 이용하여 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것"!!!

 

책임의 이동

절차적, 객체지향 프로그래밍 사이의 근본적인 차이는 책임의 이동(Shift of Responsibility) 입니다.

 

절차적 프로그래밍에서는 책임이 Theater 에 집중되어 있습니다.

Theater 객체에 책임이 집중됨. 책임이 중앙집중된 절차적 프로그래밍

 

그에 반해서 객체지향 설계에서는 제어 흐름이 각 객체에 적절하게 분산되어 있습니다. 즉, 하나의 기능을 완성하는데 필요한 책임이 여러 객체에 걸쳐 분산되어 있습니다. 이전에 읽었던 책 '객체지향의 사실과 오해' 에서 객체지향 프로그래밍의 핵심은 자율성을 가지는 여려 객체들이 적절한 책임을 수행하면서 협동하여 프로세스를 수행하는 것 이라고 한 것과 같은 내용이네요.

책임이 분산된 객체지향 프로그래밍

Theater 에 몰려 있던 책임이 개별 객체로 이동했습니다.

객체지향 설계에서는 각 객체에 책임이 적절하게 분배됩니다. 따라서 각 객체는 자신을 스스로 책임집니다. 이러한 이유로 구현 관점에서 객체지향 프로그래밍은 흔히 데이터와 프로세스를 하나의 단위로 통합한다고도 합니다.

 

객체지향 프로그래밍에서는 적절한 객체에 적절한 책임을 할당해야 합니다! 즉, 객체가 어떤 데이터를 가지냐보다 객체에 어떤 책임을 할당할 것이냐에 더 초점을 맞춰야 합니다.

불필요한 세부사항을 캡슐화하는 자율적인 객체들이 낮은 결합도와 높은 응집도를 가지고 협력하도록 최소한의 의존성만을 남겨야 합니다.

 

결합도와 자율성의 Tradeoff

앞에서 객체의 자율성과 객체 사이의 낮은 결합도를 강조했습니다. 하지만 설계를 하다보면 이 둘 사이에 tradeoff 가 일어날 수 있습니다. 자율성을 높이기 위해서 책임을 분리하는 와중에 결합도가 높아질 수도 있는 것입니다.

 

public class TicketSeller {
	...
    public void sellTo(Audience audience) {
            ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
        }
}

기존에 위와 같던 코드에서 아래 코드로 변경했다고 봅시다.

public class TicketSeller {
    ...

    public void sellTo(Audience audience) {
        ticketOffice.sellTicketTo(audience);
    }
}
public class TicketOffice {
	...
	public void sellTicketTo(Audience audience) {
        plusAmount(audience.buy(getTicket()));
    }
}

ticketSellerticketOffice 의 책임인 계산을 수행하고 있어서 ticketOffice 의 자율성을 높이기 위하여 책임을 분리했습니다. 그런데 TicketOffice 가 Audience 을 새롭게 의존하게 되었습니다. 기존에는 분명 의존하지 않았는데 말이죠.

어떻게 고민을 해보아도 ticketOffice 의 자율성을 높이면서 Audience 에 대한 결합도를 줄이는 것이 불가능해보입니다. 이렇게 TradeOff 가 발생할 수 있어요. 

 

사실 설계에서의 옳은 방향은 있지만 완벽한 정답은 없습니다. 동일한 기능을 여러 방법으로 설계할 수 있기 때문에 결국에는 서비스의 특성에 따라서, 팀원들 간의 회의를 통해 혹은 미리 정해둔 컨벤션(약속)에 따라서 결정해야 하는 부분이 있을 수 밖에 없습니다. 결국 훌륭한 설계는 적절한 TradeOff 의 결과물입니다.

 

 

의인화

그런데 무언가 이상한 점이 있습니다. 결합도를 낮추고 각 객체가 자율성을 갖고 동작하도록 설계하다보니 사람이 아닌, 애초에 생물도 아닌 객체들이 마치 자율성을 가진 존재처럼 책임을 가지고 동작하고 있습니다. Theater 은 사실 극장일 뿐이고 관람객을 입장시키는 역할을 하지 않습니다. 아마 이것은 극장 직원이 하겠죠. TicketOffice 도 실제로 관람객에서 티켓을 판매하지 않아요. 장소일 뿐, 실제로는 판매원이 수행할 것입니다. 그렇다면 이래도 괜찮은 걸까요??

 

비록 현실에서는 수동적인 존재라고 하더라도 일단 객체지향의 세계에 들어오면 모든 것이 능동적이고 자율적인 존재로 바뀝니다. 이렇게 소프트웨어를 설계하는 원칙을 의인화(anthropomorphism) 이라고 합니다. 이 내용도 '객체지향의 사실과 오해' 라는 책에서 다루었는데 객체지향은 현실의 사물을 객체라는 이름으로 그대로 옮겨와서 프로그램으로 구현하는 것이 아니라고 했습니다. 물론 그렇게 생각한다면 객체지향을 공부하는 초기에는 도움이 되겠지만 실제로는 이러한 의인화가 포함된 적절한 변형이 이루어진다는 것을 기억해야 합니다.

 

객체지향 설계

객체지향 프로그래밍은 의존성을 효율적으로 통제할 수 있는 다양한 방법을 제공함으로써 요구사항 변경에 더 수월하게 대응할 수 있는 가능성을 높입니다. 

애플리케이션의 기능을 구현하기 위해 객체들이 협력하는 과정 속에서 객체들은 다른 객체에 의존하게 됩니다. 객체 사이에서 메시지를 전송하면서 객체를 결합시키고 이 결합이 객체 사이의 의존성을 만듭니다.

이 의존성을 잘 관리하는 것이 훌륭한 설계입다.

 

데이터와 프로세스를 하나의 덩어리로 모으는 것은 훌륭한 객체지향 설계로 가는 첫번째 단계입니다.