해당 글은 객체지향의 사실과 오해에 이어서 코드로 살펴보는 오브젝트 책이다.
기본적인 내용에 대한 기반은 객체지향의 사실과 오해를 기반으로 설명을 하고 있기 때문에 혹시 어느 방향인지 잘 이해가 안된다면 객체지향의 사실과 오해라는 책을 먼저 읽는 것을 권장한다!
아니 본인이 객체지향언어를 사용하는 개발자라면 꼭 읽어봐야 한다고 생각한다.
개요
잘못된 방식의 애플리케이션 설계는 무엇인지, 객체지향적인 방식의 설계는 무엇인지, 객체지향적으로 설계를 하면서 사고해야하는 것은 무엇인지에 대해 알아보자.
티켓 판매 애플리케이션 구현
요구사항
- 관객은 티켓 판매점에서 초대장 보유 여부에 따라 구매 하거나 교환 할 수 있다.
- 관객은 가방을 가지고 있으며, 가방에는 현금, 초대권, 티켓이 들어있을 수 있다.
- 티켓 판매점에서 일하는 티켓 판매원은 관객의 초대장 보유 여부를 확인하고 티켓을 판매 할 수 있다.
잘못된 코드 설계
먼저 각각 도메인에 해당하는 코드부터 살펴보자. 아직 자세히 나오지 않았지만, 각 객체에 대해서 주체적인 흐름으로 따라가보자.
예를 들자면 다음과 같이 말이다.
티켓은 관객이 구매한다. -> 관객은 가방을 가지고 있다. -> 관객이 티켓 판매점에 가서 티켓 판매원에게 초대권 보유 여부를 얘기하고 -> 티켓 판매원이 해당 초대권 보유 여부를 확인해서 티켓을 판매하거나 교환한다.
관객부터 살펴보자.
관객
관객은 가방을 가지고 있어야 한다.
public class Audience {
private Bag bag;
public Audience(Bag bag) {
this.bag = bag;
}
public Bag getBag() {
return bag;
}
}
간단한 코드이다. 이렇게 관객이 어떠한 다른 객체를 포함하는 것을 Composite 관계라고 얘기한다. 이는 다음 챕터에서 자세하게 나온다.
가방
가방은 현금, 초대권, 티켓을 가지고 있어야 하고, 가방은 현금이 얼마나 있는지, 티켓은 있는지, 초대권은 있는지 확인 할 수 있어야 한다.
이는 나중에 다시 설명하겠지만 현실 세계에서의 가방은 위의 내용처럼 주체적으로 행동하지 않는다. 하지만 가방이 객체지향 세계로 들어오게 되면 사람처럼 행동하게 되고 이를 의인화 한다고 표현한다.
public class Bag {
private Long amount;
private Invitation invitation;
private Ticket ticket;
public Bag(Long amount) {
this(null,amount);
}
public Bag(Invitation invitation, long amount) {
this.amount = amount;
this.invitation = invitation;
}
public boolean hasInvitation() {
return invitation != null;
}
public boolean hasTicket() {
return ticket != null;
}
public void setTicket(Ticket ticket) {
this.ticket = ticket;
}
public void minusAmount(Long amount) {
this.amount -= amount;
}
public void plusAmount(Long amount) {
this.amount += amount;
}
}
관객은 가방에게 의존하게 되는데, 가방을 생성할 때 이 사람이 초대권을 가지고 있을 수도 없을 수도 있으므로 생성자 오버로딩을 통해 다르게 생성할 수 있도록 구현해놓은것을 볼 수 있다. 물론 여기서 이런 생각이 들 수도 있다.
초대권을 주는 것도 어떠한 이벤트성이니까 공연을 담당하는 회사에서 관객에게 초대권을 보내주고 초대권이 관객의 가방에 추가되어야 하지 않나요?
좋은 생각이다. 만들고자 하는 애플리케이션의 목적에 따르면 위와 같은 생각이 들 수 밖에 없다. 그리고 틀린 방법도 아니다. 하지만 간단하게 객체지향적인 개발이 무엇인지 알아가는 과정이기 때문에 저자도 단순하게 설명한다. 더 나아가 위처럼 코드를 만들고 싶다면 옵저버 패턴을 사용해서 구현해보는 것도 좋은 방법이라고 생각한다.
초대장
public class Invitation {
private LocalDateTime when;
}
초대장 코드는 너무나도 간단하다. 어느 날짜의 시간대에 해당하는 초대장인지 확인을 할 수 있는 코드이다. 만약 각각에 공연에 대한 초대권과 어느 공연장에 대한 티켓인지도 추가한다면 추가적으로 더 많은 정보를 추가 할 수 있을 것이다.
티켓
public class Ticket {
private Long fee;
public Long getFee() {
return fee;
}
}
다음으로 티켓 관련 코드이다. 티켓이 공연 관련된 정보도 있어야 하지 않나 생각할 수 있지만! 위에서 얘기했듯이, 그건 더 확장된 이야기이다. 간단하게 해당 공연에 필요한 티켓 비용만 있다고 가정하자.
티켓 판매점
public class TicketOffice {
private Long amount;
private List<Ticket> tickets = new ArrayList<>();
public TicketOffice(Long amount, Ticket ... tickets) {
this.amount = amount;
this.tickets.addAll(Arrays.asList(tickets));
}
public Ticket getTicket() {
return tickets.remove(0);
}
public void plusAmount(Long amount) {
this.amount += amount;
}
}
티켓 판매점에는 티켓 판매점이 보유하고 있는 현금과 티켓들을 보유할 수 있다. 참고로 교재에서는 ArrayList를 사용하고 remove(0); 를 사용해서 티켓의 개수를 줄여나가는데, ArrayList는 앞쪽에 있는 데이터를 삭제 시 뒤에 있는 데이터를 다시 재 배치하는데에 대한 비용이 추가로 발생해서 다음과 같이 수정했다.
public class TicketOffice {
private Long amount; // 티켓 판매점이 가진 금액
// 교재에서는 ArrayList 였으나, 티켓 판매 시 0번 인덱스를 지울 때 ArrayList 라면 배열의 이동이 발생하여 LinkedList 로 수정
private List<Ticket> tickets = new LinkedList<>();
public TicketOffice(Long amount, Ticket ... tickets) {
this.amount = amount;
this.tickets.addAll(Arrays.asList(tickets));
}
public Ticket getTicket() {
if(tickets.size() == 0) {
return null;
}
return tickets.remove(0);
}
public void plusAmount(Long amount) {
this.amount += amount;
}
}
null 을 반환하는 것보다 예외를 발생시키는 것이 더 좋은 방법이지만, 지금은 간단하게 null을 반환하도록 하였다.
판매원
판매원은 티켓 판매점에 전적으로 의존하면서 일을 수행한다.
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
public TicketOffice getTicketOffice() {
return ticketOffice;
}
}
이걸 봤을 때 위화감이 들면 좋겠다. 바로 티켓 판매원이 주체적으로 하는 일이 없다는 것이다. 마지막으로 해당 프로그램에서 제일 중요한 공연장 코드를 살펴보자.
공연장
한 공연장에는 티켓 판매원이 있으며, 공연장은 관객이 들어올 수 있도록 판단하는 일들을 각각의 도메인에서 데이터를 가져와 처리한다.
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);
}
}
}
enter 메서드가 바로 관객이 입장할 때 티켓을 교환해주고 관객을 들여보내는 역할을 하는 메서드이다.
위 코드의 문제점
위 코드에는 여러가지 문제점이 있다. 하나 하나 살펴보자.
1. 코드를 이해하기가 어렵다.
공연장의 enter() 메서드를 살펴보자. 해당 코드를 이해하기 위해서는 해당 코드에서 의존하는 모든 객체들에 대해서 각 객체들이 무엇을 해야하는지 모든 기능들을 동시에 기억하고 있어야 한다. 본인이 작성한 코드가 아니라 다른 사람이 위 처럼 코드를 작성했을 때를 생각해보자 만약 본인이라면 위의 코드를 바로 이해 할 수 있는가?
2. 변경에 취약하다.
사실 위의 문제는 어느정도 시간을 소모하면 그래도 이해할 수는 있다. 따라서 그렇게 커다란 문제는 아니지만 진짜 문제는 변경에 취약하다는 문제이다. 이것은 정말 심각하다. 만약 관람객이 Bag을 가지는 것이 아니고, 관람객은 지갑만 가진다고 해서 Wallet으로 변경한다고 가정해보자. 아니면 혹은 추상화를 해서 ItemBox라는 인터페이스로 바꾸고 다르게 가질 수 있다고 생각을 해도 좋다.
이런 경우 공연장의 enter() 메서드도 수정을 해야한다. 즉, 이 부분은 공연장 클래스가 관객에 대해서 너무나 많은 내부적인 부분을 알 고 있기 때문에 발생한 것이다. 즉, 의존성 때문에 발생한 것이다. 이렇게 의존성이 과한 경우를 결합도(Coupling)가 높다고 얘기한다. 반대로 합리적으로 의존할 경우를 결합도가 낮다고 얘기한다. 여기서 객체지향 설계에 첫번째 목적이 등장한다.
객체지향 설계는 서로 의존하면서 협력하는 객체들의 공동체를 구축하는 것
여기서 주의해야 할 부분은 의존성을 완전히 제거하라는 것이 아니다. 기능을 구현하는데 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거하는 것이다. 결론적으로 위처럼 문제가 발생한 이유는 각각의 객체들이 자율적으로 행동하지 않았기 때문에 발생한 문제이다. 그럼 앞으로 우리가 이 코드를 개선하기 위해서는 각각의 객체를 자율적인 존재로 보고 자율적으로 행동하도록 변경하면 되는 것이다.
지금은 티켓 보유 여부를 확인하고, 교환하고 계산하는 일을 모두 공연장이 도맡아서 하고 있다. 이를 분리해보자.
올바른 코드 설계
다시 관객부터 하나하나 살펴보자.
관객
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 Bag(long amount) {
this(null, amount);
}
public Bag(Invitation invitation, long amount) {
this.invitation = invitation;
this.amount = amount;
}
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 boolean hasTicket() {
return ticket != null;
}
private void minusAmount(Long amount) {
this.amount -= amount;
}
private void plusAmount(Long amount) {
this.amount += amount;
}
// 티켓을 얻은 경우, 초대권을 잃어야 함.
private void setTicket(Ticket ticket) {
this.ticket = ticket;
if(invitation != null) {
invitation = null;
}
}
}
먼저, 놀라운 부분은 메서드의 접근제어자가 대부분 private로 변경되었다는 점을 볼 수 있다. 그리고 hold 메서드에서는 private로 구현된 메서드를 통해서 초대장이 있으면 무료로 교환하고, 초대장이 없으면 티켓을 구매하는 로직이 담겨있는 것을 볼 수 있다.
여기서 객체지향설계 용어적으로 우리가 기존에 알고 있던 개념과 조금 헷갈릴 수 있는 개념이 나온다. 저자는 접근제어자의 관점에서 외부에서 public을 통해 접근할 수 있는 부분을 퍼블릭 인터페이스(Public Interface) 라고 얘기하고, 내부에서 private를 통해 접근 가능한 부분을 구현(Implementation)으로 바라본다. 그리고 이를 인터페이스와 구현의 분리 원칙으로 객체지향 프로그램을 만들기 위해 따라야하는 핵심 원칙을 소개한다.
추가적으로 의인화에 대한 설명 또한 등장한다. 사실 가방이라는 객체는 현실 세계에서는 위처럼 초대장이 있는지 확인하거나, 현재 가방에 있는 현금 보유량을 줄일 수 있거나 하는 것이 불가능하다. 하지만 객체지향 세계에서는 각각의 무형들도 생명을 부여해서 행동할 수 있게끔 하는데, 이처럼 능동적이고 자율적인 존재로 소프트웨어 객체를 설계하는 원칙을 의인화 라고 부른다고 한다.
이어서 그럼 티켓 판매점과 티켓 판매원, 그리고 공연장이 어떻게 각각이 자율적으로 행동할 수 있는지 살펴보자.
공연장
public class Theater {
private TicketSeller ticketSeller;
public Theater(TicketSeller ticketSeller) {
this.ticketSeller = ticketSeller;
}
public void enter(Audience audience) {
ticketSeller.sellTo(audience);
}
}
이제 공연장에서 일하는 티켓 판매원이 sellTo라는 간단한 메서드로 관객에게 티켓을 파는 모습을 볼 수 있다. 그럼 티켓 판매원은 어떻게 판매하는지 따라가보자.
티켓 판매원
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
public void sellTo(Audience audience) {
ticketOffice.sellTicketTo(audience);
}
}
티켓 판매원 또한 티켓을 판매하기 위해서는 티켓 판매점에서 티켓을 꺼내야 하므로, 해당 일을 티켓 판매점에게 일임하는 것을 볼 수 있다.
이어서 티켓 판매점까지 찾아가보자.
public class TicketOffice {
private Long amount;
private List<Ticket> tickets = new LinkedList<>();
public TicketOffice(Long amount, Ticket ... tickets) {
this.amount = amount;
this.tickets.addAll(Arrays.asList(tickets));
}
public void sellTicketTo(Audience audience) {
plusAmount(audience.buy(getTicket()));
}
private Ticket getTicket() {
if(tickets.size() == 0) {
return null;
}
return tickets.remove(0);
}
private void minusAmount(Long amount) {
this.amount -= amount;
}
private void plusAmount(Long amount) {
this.amount += amount;
}
}
동일하게 많은 부분이 private 접근제어자로 변경됐고, 관객은 티켓 판매점에 와서 티켓 판매원에게 티켓을 요구하고, 티켓 판매원은 티켓 판매점에서 해당 일을 처리할 수 있도록 하는 점을 볼 수 있다. 결론적으로 각 객체들이 자율적으로 행동하는 모습을 볼 수 있다.
하지만 여기서도 모든 객체들이 의존성을 벗어난것인가? 에 대한 질문을 한다면 그 부분 아니다. 티켓을 판매하는 시점에서 TicketSeller는 결국 자신에게 티켓을 구매하기로 요청한 Audience에 대해서 알아야 한다. 즉 Audience에게 의존하고 있는 것이다.
하지만 이전에도 얘기했듯이 의존성을 무조건 제거하라는 것이 아닌 적절하게 의존성을 유지하는 것이 올바른 객체지향 설계이다.
느낀점
이 부분을 읽으면서 크게 느꼈던 부분은 먼저 클래스 다이어그램에 대한 이해도 부족이였다. 클래스 다이어그램만 바라보고 무슨 일을 하는지 감을 잡을 수가 없었고, 코드를 살펴봐야 한다는 문제가 있었다. 따라서 바로 클래스 다이어그램에 대해서 공부했고 클래스 다이어그램에 대한 포스팅을 작성할 예정이다.
두번째로 접근제어자의 목적이다. 사실 접근 제어자에 대해서 깊게 생각해보지를 않았다. 그저 캡슐화를 위해 외부에서 자율적인 객체에 접근해서 마음대로 상태를 변화하지 않도록 해야한다. 라는 관점으로만 바라보았는데 객체지향적인 관점으로 바라봤을 때 접근 제어자가 어떠한 행동을 하는지 크게 살펴볼 수 있는 대목이였다.
'도서 > 오브젝트' 카테고리의 다른 글
4. 설계 품질과 트레이드 오프 (0) | 2023.06.04 |
---|---|
3. 역할, 책임, 협력 (0) | 2023.05.26 |
2. 객체지향 프로그래밍 (0) | 2023.05.24 |