관람객(Audience)은 가방(Bag) 안에 현금과 초대장(Invitation), 티켓(Ticket)을 소지한다.
그리고 판매원(TicketSeller)은 매표소(TicketOffice)에서 관람객에게 티켓을 판매하며, 관람객은 티켓을 구매한 후 극장(Theater)에 입장한다.
이러한 일련의 과정을 바탕으로 아래와 같이 Theater 클래스를 작성할 수 있다.
public class Theater {
private TicketSeller ticketSeller;
public Theater(TicketSeller ticketSeller) {
this.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);
}
}
}
Audience.getBag()으로 관람객의 가방을 가져온 후, hasInvitation()을 호출하여 초대장이 있는지 판단한다.
만약 초대장이 있다면, TicketSeller는 getTicketOffice().getTicket()을 호출하여 매표소에 있는 티켓 하나를 가져온다. 그리고나서 가져온 티켓을 setTicket(ticket)을 통해 관람객의 가방에 넣는다.
반면에 초대장이 없다면, 관람객의 가방에서 minusAmount(ticket.getFee())을 호출하여 현금을 티켓의 요금만큼 감소시킨다. 그리고 plusAmount(ticket.getFee())를 호출하여 매표소의 판매 금액을 티켓의 요금만큼 증가시킨다. 마지막으로, 관람객의 가방에 티켓을 넣으면서 티켓 구매 과정이 끝난다.
로버트 마틴의 “클린 소프트웨어: 애자일 원칙과 패턴, 그리고 실천 방법”에서 소프트웨어 모듈은 아래 세 가지 목적을 가지고 있어야 한다고 설명한다.
즉, 모든 모듈은 제대로 실행돼야 하고, 변경에 용이해야 하며, 이해하기 쉬워야 한다.
Theater 클래스의 enter 메서드가 수행하는 일을 말로 풀어보면 다음과 같다.
소극장은 관람객의 가방을 열어 그 안에 초대장이 들어 있는지 살펴본다.
가방 안에 초대장이 들어 있으면 판매원은 매표소에 보관되어 있는 티켓을 관람객의 가방 안으로 넣는다.
가방 안에 초대장이 들어 있지 않으면 판매원은 매표소에 보관되어 있는 티켓을 꺼내고,
관람객의 가방 안에서 그 티켓 금액만큼의 현금을 꺼내 매표소에 적립한다.
그리고나서 매표소에 보관되어 있는 티켓을 관람객의 가방 안으로 넣는다.
소극장이 가방을 열어서 살펴보거나 티켓을 직접 넣는 행위는 우리의 예상과 크게 다르다. 또한, 이는 판매원과 관람객은 소극장에 의해 수동적으로 행동한다는 것을 보여준다.
이해 가능한 코드란, 그 동작이 우리의 예상에서 크게 벗어나지 않는 코드를 말한다. 현실에서는 관람객이 직접 현금을 꺼내 판매원에게 지불하여 티켓을 얻는다. 하지만 위에서 본 예제는 그렇지 않다. 현재의 코드는 우리의 상식과 너무나도 다르게 동작하기 때문에 코드를 읽는 사람과 제대로 의사소통하지 못한다.
코드를 이해하기 어렵게 만드는 또 다른 이유는, 코드를 읽는 사람이 그 코드를 이해하기 위해서 많은 것을 기억하고 있어야 한다는 점이다.
Theater 클래스의 enter 메서드를 이해하기 위해서는 Audience가 Bag을 가지고 있고, Bag 안에는 현금과 티켓, 그리고 초대장이 들어 있는 등 많은 사실을 동시에 기억해야 한다.
이 코드는 하나의 클래스나 메서드에서 너무 많은 세부 사항을 다루기 때문에 코드를 읽고 이해해야 하는 사람에게 큰 부담을 준다.
앞서 이야기한 예상을 빗나가는 문제보다 더 큰 문제는 바로 변경에 취약하다는 것이다.
위와 같이 기존 상황에서 조금이라도 변경되는 순간 모든 코드가 동시에 흔들리게 된다.
만약 관람객이 더이상 가방을 들고 다니지 않게 된다면, Audience 클래스에서 Bag을 제거해야 할 것이다. 이때 Theater 클래스의 enter 메서드도 수정해야 한다.
이처럼 다른 객체가 Audience의 내부에 대해 많이 알수록 Audience를 변경하기 어려워진다.
이러한 객체 사이의 의존성은 변경에 대한 영향을 암시한다.
그렇다면 객체 사이의 의존성을 완전히 없애야 할까?
이에 대한 답은 No다. 객체지향 설계는 서로 의존하면서 협력하는 객체들의 공동체를 구축하는 것이다. 이 말은 곧 의존성은 필연적으로 존재하게 된다. 그저 이를 최소화해야 할 뿐이다.
따라서 우리의 목표는 애플리케이션의 기능을 구현하는 데 필요한 최소한의 의존성만을 유지하고 불필요한 의존성을 제거하는 것이다.
다시 말해서, 객체 사이의 결합도를 낮춰 변경에 용이한 설계를 만드는 것이 우리의 목표다.
변경에 취약하고 이해하기 어려운 코드를 개선하기 위해 관람객과 판매원을 자율적인 존재로 만들자.
기존에는 Theater 객체가 매표소에 직접 접근해서 티켓을 가져오고, 그 티켓을 관람객의 가방에 직접 넣었다.
이 모든 과정을 다음과 같이 TicketSeller 내부로 이동시키면서, 판매원은 자율적으로 티켓을 판매할 수 있게 되었다.
public class TicketSeller {
...
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);
}
}
}
public class Theater {
...
public void enter(Audience audience) {
ticketSeller.sellTo(audience);
}
}
TicketOffice와 협력하는 TicketSeller의 내부 구현을 캡슐화함으로써, Theater가 불필요한 의존성을 갖는 것을 피하면서 훨씬 간결해졌다.
다음으로, 관람객과 판매원 사이의 코드를 개선해보자.
판매원은 관람객의 가방을 직접 열어 초대장이 있는지 확인하며, 직접 가방에 티켓을 넣는다.
이러한 TicketSeller의 로직을 Audience 내부로 이동시키면서, 관람객을 스스로 티켓을 구매하는 자율적인 존재로 만든다. 이 또한 캡슐화를 개선했다고 볼 수 있다.
public class Audience {
...
public Long buy(Ticket ticket) {
if (bag.hasInvitation()) {
bag.setTicket(ticket);
return 0L;
} else {
bag.minusAmount(ticket.getFee());
bag.setTicket(ticket);
return ticket.getFee();
}
}
}
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
public void sellTo(Audience audience) {
ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
}
}
위와 같이 코드를 수정함으로써, 변경 용이성과 의사소통 두 가지 문제가 개선되었다.
이제는 관람객과 판매원이 스스로 티켓을 사고 팔 수 있게 되었고, 이는 우리의 예상과 정확하게 일치한다. 따라서 의사소통 측면에서 확실히 개선되었다고 볼 수 있다.
관람객이 현금과 초대장을 가방이 아닌 지갑에 넣고 다니도록 코드를 변경하더라도, 외부에 영향을 미치지 않는다. 그저 Audience 내부만 변경하면 된다. 따라서 수정된 코드는 변경에 용이해졌다고도 볼 수 있다.
판매원이 티켓을 판매하기 위해 TicketOffice를 사용하는 모든 부분을 TicketSeller 내부로 옮기고, 관람객이 티켓을 구매하기 위해 Bag을 사용하는 모든 부분을 Audience 내부로 옮겼을 뿐이다.
우리는 그저 객체의 자율성을 높이는 방향으로 설계를 개선했다. 그 결과, 이해하기 쉽고 유연한 설계를 얻을 수 있었다.
핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다.
Theater는 TicketSeller에게 sellTo라는 메시지만 보낼 뿐, TicketSeller의 내부에 대해서는 전혀 모른다.
TicketSeller도 Audience에게 buy라는 메시지만 보낼 뿐, 그저 Audience가 이 메시지를 이해하고 처리할 수 있길 믿는다.
이처럼 밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜 응집도가 높다고 말한다.
객체 스스로 자신의 데이터를 처리할 수 있도록 캡슐화하면, 결합도는 낮아지고 응집도는 높아진다.
위 예제를 통해 절차지향과 객체지향의 차이를 알 수 있다.
처음에 작성한 코드를 보면, Audience, TicketSeller, Bag, TicketOffice는 관람객을 입장시키는 데 필요한 정보를 제공하고, 모든 처리는 Theater의 enter 메서드 안에서 수행한다.
즉, Theater의 enter 메서드는 프로세스(Process)이며, Audience, TicketSeller, Bag, TicketOffice는 데이터(Data)라고 볼 수 있다.
이처럼 프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차지향 프로그래밍이라고 한다.
아래 그림은 절차지향 프로그래밍 방식으로 작성된 코드의 전형적인 의존성 구조를 보여준다.

프로세스를 담당하는 Theater가 데이터를 담당하는 모든 클래스에 의존하는 것을 보면, 절차지향 프로그래밍이 변경에 취약한 이유를 알 수 있다.
이러한 문제를 해결하기 위해서 프로세스와 데이터를 동일한 모뉼 내부에 위치하도록 프로그래밍하는 방식을 객체지향 프로그래밍이라고 부른다.
객체지향 프로그래밍 방식으로 구현된 구조는 다음과 같다.

위 그림을 보면, 의존성은 적절히 통제되고 있으며 하나의 변경으로 인한 여파가 여러 클래스로 전파되는 것을 효율적으로 억제한다. 이것이 객체지향이 절차지향에 비해 변경에 좀 더 유연하다고 말하는 이유다.
변경 전의 절차적 설계에서는 Theater가 모든 일을 도맡아 처리했다. 변경 후의 객체지향 설계에서는 각 객체가 자신이 맡은 일을 스스로 처리했다. 즉, Theater에 몰려 있던 책임이 개별 객체로 분산되었다. 이것을 책임의 이동이라고 한다.
설계를 어렵게 만드는 것은 의존성이라는 것을 기억하자. 불필요한 의존성을 제거함으로써 객체 사이의 결합도를 낮출 수 있다. 이번 예제에서 결합도를 낮추기 위해서 선택한 방법은 Theater가 몰라도 되는 세부 사항을 다른 객체의 내부로 캡슐화하는 것이다. 캡슐화를 통해 객체의 자율성을 높이고, 자율적인 객체들은 높은 응집도를 가진다.
Bag도 이전의 Audience처럼 수동적인 존재이므로, 자율적인 존재로 변경해보자.
public class Audience {
private Bag bag;
public Audience(Bag bag) {
this.bag = bag;
}
public Long buy(Ticket ticket) {
return bag.hold(ticket);
}
}
public class Bag {
private Long amount;
private Invitation invitation;
private Ticket ticket;
...
public Long hold(Ticket ticket) {
if (hasInvitation()) {
setTicket(ticket);
return 0L;
} else {
setTicket(ticket);
minusAmount(ticket.getFee());
return ticket.getFee();
}
}
private boolean hasInvitation() {
return invitation != null;
}
private void setTicket(Ticket ticket) {
this.ticket = ticket;
}
private void minusAmount(Long amount) {
this.amount -= amount;
}
}
public 메서드였던 hasInvitation, minusAmount, setTicket 메서드들은 더 이상 외부에서 사용되지 않고 내부에서만 사용되므로 private으로 변경한다.
TicketSeller의 sellTo를 보면, TicketOffice도 아직 수동적인 존재로 보인다.
public class TicketSeller {
...
public void sellTo(Audience audience) {
ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
}
}
TicketOffice 내부로 옮기면 이 부분도 개선될 것이라 생각되지만, 이는 오히려 TicketOffice가 Audience를 의존하게 된다는 문제를 야기한다.
보는 관점마다 다르겠지만, 우선 나는 불필요한 의존성을 갖는 것보다는 지금처럼 수동적인 존재로 두는 것이 그나마 나은 것 같다.
이를 통해 우리는 다음과 같은 사실을 알 수 있다.
좋은 설계란 무엇일까?
우리가 짜는 프로그램은 두 가지 요구사항을 만족시켜야 한다.
“오늘 완성해야 하는 기능을 구현하는 코드를 짜야하는 동시에 내일 쉽게 변경할 수 있는 코드를 짜야 한다.”
변경 가능한 코드란 이해하기 쉬운 코드를 말한다.
만약 내가 어떤 코드를 변경해야 하는데 그 코드를 이해할 수 없다면, 아마 코드를 수정하겠다는 마음이 선뜻 들지 않을 것이다.
훌륭한 객체지향 설계란?
⇒ 협력하는 객체 사이의 의존성을 적절하게 관리하는 설계
세상에 엮인 것이 많은 사람일수록 변하기 어려운 것처럼 객체가 실행되는 주변 환경에 강하게 결합될수록 변경하기 어려워진다. 객체 간의 의존성은 애플리케이션을 수정하기 어렵게 만드는 주범이다.
진정한 객체지향 설계로 나아가는 길은 협력하는 객체들 사이의 의존성을 적절하게 조절함으로써 변경에 용이한 설계를 만드는 것이다.
이번 장에서는 객체지향 프로그래밍에 대한 전반적인 이해를 돕는 것 같다는 느낌을 받았다.
이번 장의 핵심이자 가장 기억에 남는 말은 "소프트웨어 모듈은 제대로 동작하고 변경에 용이해야 하며, 이해하기 쉬워야 한다."인 듯 하다.
변경에 취약한 코드를 개선하기 위해서는 캡슐화를 통해 객체를 자율적인 존재로 만들어야 한다. 즉, 불필요한 의존성을 제거함으로써, 객체 사이의 결합도를 낮춰야 한다. 객체의 자율성이 높아질수록 결합도가 낮아지고, 응집도는 높아진다.
우리는 제대로 동작하는 코드를 작성하는 동시에, 내일 쉽게 변경할 수 있는 코드를 작성해야 한다.