오브젝트 : 코드로 이해하는 객체지향 설계 - 조영호 저
📄 추첨을 통해 선정된 관람객에게 공연을 무료로 관람할 수 있는 초대장을 발송하려고 한다.
public class Invitation {
// 공연을 관람할 수 있는 초대일자 when
private LocalDateTime when;
}
public class Ticket {
// 관람비 fee
private Long fee;
// 관람비를 가져온다.
public Long getFee() {
return fee;
}
}
이벤트 당첨자는 초대장을, 이벤트에 당첨되지 않은 관람객은 티켓을 사기 위해 현금을 보유하고 있을 것이다.
관람객은 소지품을 보관할 용도로 가방을 들고 올 수 있다고 가정
- Bag
클래스 추가
Bag
인스턴스의 상태는 현금과 초대장을 함께 보관하거나, 초대장 없이 현금만 보관하는 두 가지 중 하나일 것이다.
- Bag
의 인스턴스를 생성하는 시점에 이 제약을 강제할 수 있도록 생성자를 추가
public class Bag {
// 초대장 없이 현금만 보관
public Bag(long amount) {
this(null, amount);
}
// 현금과 초대장을 함께 보관
public Bag(Invitation invitation, long amount) {
this.invitation = invitation;
this.amount = amount;
}
// 현금 amount
private Long amount;
// 초대장 invitation
private Invitation invitation;
// 티켓 ticket
private Ticket ticket;
// 초대장 보유 여부 판단
public boolean hasInvitation() {
return invitation != null;
}
// 티켓 소유 여부
public boolean hasTicket() {
return ticket != null;
}
// 초대장을 티켓으로 교환
public void setTicket(Ticket ticket) {
this.ticket = ticket;
}
// 현금 증가
public void plusAmount(Long amount) {
this.amount += amount;
}
// 현금 감소
public void minusAmount(Long amount) {
this.amount -= amount;
}
}
Audience
클래스public class Audience {
// 가방 Bag
private Bag bag;
// 관람객이 소지한 가방
public Audience(Bag bag) {
this.bag = bag;
}
// 가방의 상태를 가져온다.
public Bag getBag() {
return bag;
}
}
public class TicketOffice {
// 판매 금액 amount
private Long amount;
// 판매하거나 교환해 줄 티켓의 목록 tickets
private List<Ticket> tickets = new ArrayList<>();
// 판매 금액, 티켓의 목록 보관
public TicketOffice(Long amount, Ticket ... tickets) {
this.amount = amount;
this.tickets.addAll(Arrays.asList(tickets));
}
// 티켓 판매
// 여기서는 tickets 컬렉션에서 맨 첫 번째 위치에 저장된 ticket 반환
public Ticket getTicket() {
return tickets.remove(0);
}
// 판매금액 추가
public void plusAmount(Long amount) {
this.amount += amount;
}
// 판매금액 차감
public void minusAmount(Long amount) {
this.amount -= amount;
}
}
public class TicketSeller {
private TicketOffice ticketOffice;
// 판매원은 자신이 일하는 매표소를 알고 있어야 한다.
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
// 자신이 일하는 매표소 반환
public TicketOffice getTicketOffice() {
return ticketOffice;
}
}
Theater
클래스public class Theater {
private TicketSeller ticketSeller;
public Theater(TicketSeller ticketSeller) {
this.ticketSeller = ticketSeller;
}
// 관람객을 맞이하는 메소드
public void enter(Audience audience) {
// 관람객의 가방 안에 초대장이 들어 있는지 확인
if (audience.getBag().hasInvitation()) {
// true면 이벤트 당첨된 관람객
Ticket ticket = ticketSeller.getTicketOffice().getTicket();
// 판매원에게서 받은 티켓을 관람객의 가방 안에 넣어준다.
audience.getBag().setTicket(ticket);
} else {
// false면 티켓을 판매해야 한다.
Ticket ticket = ticketSeller.getTicketOffice().getTicket();
// 관람객의 가방 안에 티켓 금액만큼 차감
audience.getBag().minusAmount(ticket.getFee());
// 매표소에 금액을 증가
ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
// 관람객의 가방 안에 티켓을 넣어준다.
audience.getBag().setTicket(ticket);
}
}
}
모든 소프트웨어 모듈에는 세 가지 목적이 있다.
첫 번째 목적은 실행 중에 제대로 동작하는 것이다. 이것은 모듈의 존재 이유라고 할 수 있다.
두 번째 목적은 변경을 위해 존재하는 것이다. 대부분의 모듈은 생명주기 동안 변경되기 때문에 간단한 작업만으로도 변경이 가능해야 한다.
세 번째 목적은 코드를 읽는 사람과 의사소통하는 것이다. 모듈은 특별한 훈련 없이도 개발자가 쉽게 읽고 이해할 수 있어야 한다. 읽는 사람과 의사소통할 수 없는 모듈은 개선해야 한다. - 로버트 마틴
모듈 : 크기와 상관 없이 클래스나 패키지, 라이브러리와 같이 프로그램을 구성하는 임의의 요소
작성된 프로그램은 첫 번째 목적은 만족하지만 두 번째, 세 번째 목적은 만족시키지 못한다.
Audience
와 TicketSeller
를 변경할 경우 Theater
도 함께 변경해야 한다는 사실이다.더 큰 문제는 변경에 취약하다는 것이다.
Theater
도 함께 변경해야 한다.Audience
의 내부에 대해 더 많이 알면 알수록 Audience
를 변경하기 어려워진다.객체 사이의 의존성(dependency)과 관련된 문제다.
그렇다고 해서 객체 사이의 의존성을 완전히 없애는 것이 정답은 아니다.
객체 사이의 의존성이 과한 경우를 가리켜 결합도(coupling)가 높다고 말한다.
[너무 많은 클래스에 의존하는 Theater]
Theater
가 관람객의 가방과 판매원의 매표소에 직접 접근하기 때문이다.Theater
가 Audience
와 TicketSeller
에 관해 너무 세세한 부분까지 알지 못하도록 정보를 차단하면 된다.Theater
의 enter
메서드에서 TicketOffice
에 접근하는 모든 코드를 TicketSeller
내부로 숨기는 것이다.public class TicketSeller {
private TicketOffice ticketOffice;
// 판매원은 자신이 일하는 매표소를 알고 있어야 한다.
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
// // 자신이 일하는 매표소 반환
// public TicketOffice getTicketOffice() {
// return ticketOffice;
// }
/*
* Theater의 enter 메서드를 TicketSeller 내부로 이동
*/
public void sellTo(Audience audience) {
// 관람객의 가방 안에 초대장이 들어 있는지 확인
if (audience.getBag().hasInvitation()) {
// true면 이벤트 당첨된 관람객
Ticket ticket = ticketOffice.getTicket();
// 판매원에게서 받은 티켓을 관람객의 가방 안에 넣어준다.
audience.getBag().setTicket(ticket);
} else {
// false면 티켓을 판매해야 한다.
Ticket ticket = ticketOffice.getTicket();
// 관람객의 가방 안에 티켓 금액만큼 차감
audience.getBag().minusAmount(ticket.getFee());
// 매표소에 금액을 증가
ticketOffice.plusAmount(ticket.getFee());
// 관람객의 가방 안에 티켓을 넣어준다.
audience.getBag().setTicket(ticket);
}
}
}
TicketSeller
에서 getTicketOffice
메서드가 제거됐기 때문에 ticketOffice
에 대한 접근은 오직 TicketSeller
안에만 존재하게 된다.TicketSeller
는 ticketOffice
에서 티켓을 꺼내거나 판매 요금을 적립하는 일을 스스로 수행할 수밖에 없다.public class Theater {
private TicketSeller ticketSeller;
public Theater(TicketSeller ticketSeller) {
this.ticketSeller = ticketSeller;
}
// 관람객을 맞이하는 메소드
public void enter(Audience audience) {
ticketSeller.sellTo(audience);
}
}
Theater
는 ticketOffice
가 TicketSeller
내부에 존재한다는 사실을 알지 못한다.
ticketSeller
가 sellTo
메시지를 이해하고 응답할 수 있다는 사실만 알고 있을 뿐이다.Theater
는 오직 TicketSeller
의 인터페이스(interface)에만 의존한다.
TicketSeller
가 내부에 TicketOffice
인스턴스를 포함하고 있다는 사실은 구현(implementation)의 영역에 속한다.객체를 인터페이스와 구현으로 나누고 인터페이스만을 공개하는 것은 객체 사이의 결합도를 낮추고 변경하기 쉬운 코드를 작성하기 위해 따라야 하는 가장 기본적인 설계 원칙이다.
[Theater의 결합도를 낮춘 설계]
Theater
의 로직을 TicketSeller
로 이동시킨 결과, Theater
에서 TicketOffice
로의 의존성이 제거됐다는 사실을 알 수 있다.TicketOffice
와 협력하는 TicketSeller
의 내부 구현이 성공적으로 캡슐화된 것이다.Bag
인스턴스에 접근하는 객체가 Theater
에서 TicketSeller
로 바뀌었을 뿐 Audience
는 여전히 자율적인 존재가 아닌 것이다.Bag
에 접근하는 모든 로직을 Audience
내부로 감추기 위해 Audience
에 buy
메서드를 추가하고 TicketSeller
의 sellTo
메서드에서 setBag
메서드에 접근하는 부분을 buy
메서드로 옮기자.public class Audience {
// 가방 Bag
private Bag bag;
// 관람객이 소지한 가방
public Audience(Bag bag) {
this.bag = bag;
}
// Ticket을 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();
}
}
}
Audience
는 자신의 가방 안에 초대장이 들어있는지를 스스로 확인한다.Bag
의 존재를 내부로 캡슐화할 수 있게 됐다.TicketSeller
가 Audience
의 인터페이스에만 의존하도록 수정하자.TicketSeller
가 buy
메서드를 호출하도록 코드를 변경하면 된다.[자율적인 Audience와 TicketSeller로 구성된 설계]
TicketSeller
와 Audience
사이의 결합도가 낮아졌다.Audience
의 구현을 수정하더라도 TicketSeller
에는 영향을 미치지 않는다.Audience
와 TicketSeller
가 내부 구현을 외부에 노출하지 않고 자신의 문제를 스스로 책임지고 해결하는 자율적인 존재가 됐다.수정된 예제 역시 관람객들을 입장시키는데 필요한 기능을 오류 없이 수행한다.
수정된 Audience
와 TicketSeller
는 자신이 가지고 있는 소지품을 스스로 관리한다.
Audience
나 TicketSeller
의 내부 구현을 변경하더라도 Theater
를 함께 변경할 필요가 없어졌다.
판매자가 티켓을 판매하기 위해 TicketOffice
를 사용하는 모든 부분을 TicketSeller
내부로 옮기고, 관람객이 티켓을 구매하기 위해 Bag
을 사용하는 모든 부분을 Audience
내부로 옮긴 것이다.
핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다.
밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜 응집도(cohesion)가 높다고 말한다.
외부의 간섭을 최대한 배제하고 메시지를 통해서만 협력하는 자율적인 객체들의 공동체를 만드는 것이 훌륭한 객체지향 설계를 얻을 수 있는 지름길인 것이다.
Audience
, TicketSeller
, Bag
, TicketOffice
는 관람객을 입장시키는 데 필요한 정보를 제공하고 모든 처리는 Theater
의 enter
메서드 안에 존재했었다는 점에 주목하라.
Theater
의 enter
메서드는 프로세스(Process)이며 Audience
, TicketSeller
, Bag
, TicketOffice
는 데이터(Data)다.프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍(Procedural Programming)이라고 부른다.
맨 처음의 Theater
는 절차적 프로그래밍 방식으로 작성된 코드의 전형적인 의존성 구조를 보여준다.
Theater
가 Audience
, TicketSeller
, Bag
, TicketOffice
모두에 의존하고 있음에 주목하라.일반적으로 절차적 프로그래밍은 우리의 직관에 위배된다.
더 큰 문제는 절차적 프로그래밍의 세상에서는 데이터의 변경으로 인한 영향을 지역적으로 고립시키기 어렵다는 것이다.
Audience
, TicketSeller
, Bag
, TicketOffice
가운데 하나라도 변경될 경우 Theater
도 함께 변경해야 한다.해결 방법은 자신의 데이터를 스스로 처리하도록 프로세스의 적절한 단계를 Audience
와 TicketSeller
로 이동시키는 것이다.
데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식을 객체지향 프로그래밍(Object-Oriented Programming)이라고 부른다.
훌륭한 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것이다.
두 방식 사이의 근본적인 차이를 만드는 것은 책임의 이동(shift of responsibility)이다.
두 방식의 차이점을 가장 쉽게 이해할 수 있는 방법은 기능을 처리하는 방법을 살펴보는 것이다.
처음에는 책임이 Theater
에 집중되어 있었다.
마지막에는 하나의 기능을 완성하는 데 필요한 책임이 여러 객체에 걸쳐 분산되어 있다.
Theater
에 몰려 있던 책임이 개별 객체로 이동하여 책임의 이동이 일어났다.객체가 어떤 데이터를 가지느냐보다는 객체에 어떤 책임을 할당할 것이냐에 초점을 맞춰야 한다.
설계를 어렵게 만드는 것은 의존성이라는 것을 기억하라.
public class Audience {
// 가방 Bag
private Bag bag;
// 관람객이 소지한 가방
public Audience(Bag bag) {
this.bag = bag;
}
// Ticket을 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();
}
}
}
Bag
은 과거의 Audience
처럼 스스로 자기 자신을 책임지지 않고 Audience
에 의해 끌려다니는 수동적인 존재다.Bag
의 내부 상태에 접근하는 모든 로직을 Bag
안으로 캡슐화해서 결합도를 낮춰서 자율적인 존재로 바꿔보자.public class Bag {
// 현금 amount
private Long amount;
// 초대장 invitation
private Invitation invitation;
// 티켓 ticket
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;
}
}
Bag
의 구현을 캡슐화시켰으니 이제 Audience
를 Bag
의 구현이 아닌 인터페이스에만 의존하도록 수정하자.public class Audience {
// 가방 Bag
private Bag bag;
// 관람객이 소지한 가방
public Audience(Bag bag) {
this.bag = bag;
}
public Long buy(Ticket ticket) {
return bag.hold(ticket);
}
}
TicketSeller
는 TicketOffice
에 있는 Ticket
을 마음대로 꺼내서는 자기 멋대로 Audience
에게 팔고 Audience
에게 받은 돈을 마음대로 TicketOffice
에 넣어버린다.public class TicketSeller {
private TicketOffice ticketOffice;
public void sellTo(Audience audience) {
ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
}
}
TicketOffice
의 자율권을 찾아주자TicketOffice
에 sellTicketTo
메서드를 추가하고 TicketSeller
의 sellTo
메서드의 내부 코드를 이 메서드로 옮기자.public class TicketOffice {
// 판매 금액 amount
private Long amount;
// 판매하거나 교환해 줄 티켓의 목록 tickets
private List<Ticket> tickets = new ArrayList<>();
// 판매 금액, 티켓의 목록 보관
public TicketOffice(Long amount, Ticket ... tickets) {
this.amount = amount;
this.tickets.addAll(Arrays.asList(tickets));
}
public void sellTicketTo(Audience audience) {
plusAmount(audience.buy(getTicket()));
}
// 티켓 판매
// 여기서는 tickets 컬렉션에서 맨 첫 번째 위치에 저장된 ticket 반환
private Ticket getTicket() {
return tickets.remove(0);
}
// 판매금액 추가
private void plusAmount(Long amount) {
this.amount += amount;
}
}
TicketSeller
가 TicketOffice
의 구현이 아닌 인터페이스에만 의존하게 됐다는 점이다.public class TicketSeller {
private TicketOffice ticketOffice;
public void sellTo(Audience audience) {
ticketOffice.sellTicketTo(audience);
}
}
TicketOffice
와 Audience
사이에 의존성이 추가됐기 때문이다.TicketOffice
가 Audience
에게 직접 티켓을 판매하기 때문에 Audience
에 관해 알고 있어야 한다.무생물 역시 스스로 행동하고 자기 자신을 책임지는 자율적인 존재로 취급했다.
능동적이고 자율적인 존재로 소프트웨어 객체를 설계하는 원칙을 가리켜 의인화(anthropomorphism)라고 부른다.
설계란 코드를 배치하는 것이다.
설계는 코드를 작성하는 매 순간 코드를 어떻게 배치할 것인지를 결정하는 과정에서 나온다.
우리는 오늘 완성해야 하는 기능을 구현하는 코드를 짜야 하는 동시에 내일 쉽게 변경할 수 있는 코드를 짜야 한다.
객체지향 프로그래밍은 의존성을 효율적으로 통제할 수 있는 다양한 방법을 제공함으로써 요구사항 변경에 좀 더 수월하게 대응할 수 있는 가능성을 보여준다.
변경 가능한 코드란 이해하기 쉬운 코드다.
세상에 존재하는 모든 자율적인 존재처럼 객체 역시 자신의 데이터를 스스로 책임지는 자율적인 존재다.
객체지향의 세계에서 애플리케이션은 객체들로 구성되며 애플리케이션의 기능은 객체들 간의 상호작용을 통해 구현된다.
애플리케이션의 기능을 구현하기 위해 객체들이 협력하는 과정 속에서 객체들은 다른 객체에 의존하게 된다.
훌륭한 객체지향 설계란 협력하는 객체 사이의 의존성을 적절하게 관리하는 설계다.