티켓 판매 애플리케이션 구현하기
이벤트에 당첨됀 관람객:초대장을 티켓으로 교환한 후에 입장
이벤트에 당첨돼지 않은 관람객:티켓을 구매해야만 입장
- 초대장이라는 개념을 구현한 Invitation은 공연을 관람할 수 있는 초대일자(when)를 인스턴스 변수로 포함하는 간단한 클래스이다.
public class Invitation { private LocalDateTime when; }
- 공연을 관람하기 원하는 모든 사람들은 티켓을 소지하고 있어야만 한다.
public class Ticket { private Long fee; public Long getFee(){ return fee; } }
- 관람객이 소지품을 보관할 Bag클래스를 추가하자 Bag 클래스는 초대장(ticket), 티켓(invitation), 현금(amount)을 인스턴스 변수로 포함한다.
public class Bag {
private Long amount;
private Invitation invitation;
private Ticket ticket;
public boolean hasInvitation(){ //초대장의 보유 여부를 판단
return invitation != null;
}
public boolean hasTicket(){ //티켓의 소유 여부를 판단하는 hasTicket
return ticket != null;
}
public void setTicket(Ticket ticket){ //초대장을 티켓으로 교환하는 setTicket
this.ticket = ticket;
}
//현금을 증가시키거나 감소시키는
public void minusAmount(Long amount) {
this.amount -= amount;
}
public void plusAmount(Long amount) {
this.amount += amount;
}
}
- Bag 인스턴스의 상태는 현금과 초대장을 함께 보관하거나, 초대장 없이 현금만 보관하는 두 가지 중 하나이다.
Bag의 인스턴스를 생성하는 시점에 이 제약을 강제할 수 있도록 생성자 추가
public Bag(long amount) {
this(null, amount);
}
public Bag(Invitation invitation, long amount) {
this.invitation = invitation;
this.amount = amount;
}
- 관람객은 소지품을 보관하기 위해 가방을 소지할 수 있다.
public class Audience { private Bag bag; public Audience(Bag bag) { this.bag = bag; } public Bag getBag() { return bag; } }
- 매표소에는 관람객에게 판매할 티켓과 티켓의 판매 금액이 보관돼 있어야 한다.
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 minusAmount(Long amount) { this.amount -= amount; } public void plusAmount(Long amount) { this.amount += amount; } }
- 판매원은 매표소에서 초대장을 티켓으로 교환해 주거나 티켓을 판매하는 역할을 수행한다. 판매원을 구현한 TicketSeller 클래스는 자신이 일하는 ticketOffice를 알고 있어야 한다.
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
public TicketOffice getTicketOffice() {
return ticketOffice;
}
}
- 소극장을 구현하는 클래스는 Theater다. Theater 클래스가 관람객을 맞이할 수 있도록 enter 메서드를 구현하자소극장은 먼저 관람객의 가방 안에 초대장이 들어 있는지 확인한다. 만약 초대장이 들어 있다면 이벤트에 당첨된 관광객이므로 판매원에게서 받은 티켓을 관람객의 가방 안에 넣어준다. 가방 안에 초대장이 없다면 티켓을 판매해야 한다. 이 경우 소극장은 관람객의 가방에서 티켓 금액만큼을 차감한 후 매표소에 금액을 증가시킨다. 마지막으로 소극장은 관람객의 가방 안에 티켓을 넣어줌으로써 관람객의 입장 절차를 끝낸다.
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); } } }
무엇이 문제인가
소프트웨어 모듈의 세 가지 목적
1) 실행 중에 제대로 동작하는 것
2) 목적은 변경을 위해 존재하는 것
3) 코드를 읽는 사람과 의사소통하는 것
모든 모듈은 제대로 실행돼야 하고, 변경이 용이해야 하며, 이해하기 쉬워야 한다.
예상을 빗나가는 코드
-> 관람객과 판매원이 소극장의 통제를 받는 수동적인 존재이다.
-> 이해 가능한 코드란 그 동작이 우리의 예상에서 크게 벗어나지 않는 코드다.
변경에 취약한 코드
->객체 사이의 의존성과 관련된 문제이다. 문제는 의존성이 변경과 관련돼 있다는 점이다. 의존성은 변경에 대한 영향을 암시한다. 의존성이라는 말 속에는 어떤 객체가 변경됄 때 그 객체에 의존하는 다른 객체도 함께 변경될 수 있다는 사실이 내포돼 있다.
-> 최소한의 의존성만 유지하고 불필요한 의존성을 제거해야한다.
-> 객체 사이의 의존성이 과한 경우를 가리켜 결합도가 높다고 말한다. 두 객체 사이의 결합도가 높으면 높을수록 함께 변경될 확률도 높아지기 때문에 변경하기 어려워진다. 따라서 설계의 목표는 객체 사이의 결합도를 낮춰 변경이 용이한 설계를 만드는 것이다.
설계 개선하기
코드를 이해하기 어려운 이유는 Theater가 관람객의 가방과 판매원이 매표소에 직접 접근하기 때문이다. 이것은 관람객과 판매원이 자신의 일을 스스로 처리해야 한다는 우리의 직관에서 벗어난다.
Theater가 Audience와 TicketSeller에 관해 너무 세세한 부분까지 알지 못하도록 정보를 차단하면 된다.
Theater가 원하는 것은 관람객이 소극장에 입장하는 것뿐이다. 따라서 관람객이 스스로 가방 안의 현금과 초대장을 처리하고 판매원이 스스로 매표소의 티켓과 판매 요금을 다루게 한다면 이 모든 문제를 해결할 수 있다
-> 관람객과 판매원을 자율적인 존재로 만들면 됀다.
자율성을 높이자
설계를 변경하기 어려운 이유는 Threater가 Audience와 TicketSeller뿐만 아니라 Audience 소유의 Bag과 TicketSeller가 근무하는 TicketOffice까지 마음대로 접근할 수 있기 때문이다.
해결 방법은 Audience와 TicketSeller가 직접 Bag과 TicketOffice를 처리하는 자율적인 존재가 되도록 설계를 변경하는 것이다.
첫 번째 단계는 Theater의 enter 메서드에서 TicketOffice에 접근하는 모든 코드를 TicketSeller 내부로 숨기는 것이다.
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
public TicketOffice getTicketOffice() {
return 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);
}
}
}
TicketSeller에서 getTicketOffice 매서드가 제거됐다는 사실에 주목하라. ticketOffice의 가시성이 private이고 접근 가능한 퍼블릭 메서드가 더 이상 존재하지 않기 때문에 외부에서는 ticketOffice에 직접 접근할 수 없다. 결과적으로 ticketOffice에 대한 접근은 오직 TicketSeller 안에만 존재한다.
이처럼 개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 것을 캡슐화라고 부른다.
캡슐화를 통해 객체 내부로의 접근을 제한하면 객체와 객체 사이의 결합도를 낮출 수 있기 때문에 설계를 좀 더 쉽게 변경할 수 있게 된다.
Threat의 enter 메서드는 sellTo 메서드를 호출하는 간단한 코드로 바뀐다.
public class Theater {
private TicketSeller ticketSeller;
public Theater(TicketSeller ticketSeller) {
this.ticketSeller = ticketSeller;
}
public void enter(Audience audience) {
ticketSeller.sellTo(audience);
}
}
Theater는 단지 ticketSeller가 sellTo 메시지를 이해하고 응답할 수 있다는 사실만 알고 있을 뿐이다.
Theater는 오직 TicketSeller의 인터페이스(interface)에만 의존한다.
TicketSeller가 내부에 TicketOffice 인스턴스를 포함하고 있다는 사실은 구현의 영역에 속한다. 객체를 인터페이스와 구현으로 나누고 인터페이스만을 공개하는 것은 객체 사이의 결합도를 낮추고 변경하기 쉬운 코드를 작성하기 위해 따라야 하는 가장 기본적인 설계 원칙이다.
Theater의 로직을 TicketSeller로 이동시킨 결과, Theater에서 TicketOffice로의 의존성이 제거됐다는 사실을 알 수 있다.
TicketOffice와 협력하는 TicketSeller의 내부 구현이 성공적으로 캡슐화된 것이다.
Audience의 캡슐화를 개선하자. TicketSeller는 Audience의 getBag매서드를 호출해서 Audience내부의 Bag 인스턴스에 직접 접근한다. Bag 인스턴스에 접근하는 객체가 Theater에서 TicketSeller로 바뀌었을 뿐 Audience는 여전히 자율적인 존재가 아닌 것이다.
public class Audience {
private Bag bag;
public Audience(Bag bag) {
this.bag = 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는 자신의 가방 안에 초대장이 들어있는지를 스스로 확인한다. 외부의 제3자가 자신의 가방을 열어보도록 허용하지 않는다.
TicketSeller가 Audience의 인터페이스만 의존하도록 수정하자. TicketSeller가 buy메서드를 호출하도록 코드를 변경하면 된다.
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와 TicketSeller 내부만으로 제한되며 따라서 수정된 코드는 변경 용이성의 측면에서도 확실히 개선됐다.
어떻게 한 것이가
자기 자신의 문제를 스스로 해결하도록 코드를 변경하였다.
수정 전: Theater가 Audience와 TicketSeller의 상세한 내부 구현까지 알고 있어야 했다.따라서 Theater는 Audience와 TicketSeller에 강하게 결합돼 있었다.
수정 후: Theater는 Audience나 TicketSeller의 내부에 직접 접근하지 않는다. 객체의 자율성을 높이는 방향으로 설계를 개선했다. 그 결과, 이해하기 쉽고 유연한 설계를 얻을 수 있었다.
캡슐화와 응집도
- 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다.
- 밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜 응집도가 높다고 말한다. 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도를 낮출 수 있을뿐더러 응집도를 높일 수 있다.
- 외부의 간섭을 최대한 배제하고 메시지를 통해서만 협력하는 자율적인 객체들의 공동체를 만드는 것이 휼륭한 객체지향 설계를 얻을 수 있는 지름길이다.
절차지향과 객체지향
- Theater의 enter 메서드는 프로세스(Process)이며 Audience,TicketSeller,Bag,TicketOffice는 데이터(Data)이다. 이처럼 프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍이라고 부른다.
- Theater가 TicketSeller, TicketOffice, Audience, Bag 모두에 의존하고 있다. TicketSeller, TicketOffice, Audience, Bag 가운데 하나라도 변경될 경우 Threater도 함께 변경해야 한다.
- 절차적 프로그래밍은 프로세스가 필요한 모든 데이터에 의존해야 한다는 근본적인 문제점 때문에 변경에 취약할 수 밖에 없다.
- 자신의 데이터를 스스로 처리하도록 프로세스의 적절한 단계를 Audience와 TicketSeller로 이동시키는 것이다. 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식을 객체지향 프로그래밍이라고 부른다.
- 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것이다.
책임의 이동
두 방식 사이의 근본적인 차이를 만드는 것은 책임의 이동이다.
작업 흐름이 주로 Theater에 의해 제어된다. 객체지향 세계의 용어를 사용해서 표현하면 책임이 Theater에 집중돼 있는 것이다.
Theater에 몰려 있던 책임이 개별 객체로 이동 -> 책임의 이동
객체는 자신을 스스로 책임진다.
객체지향 설계의 핵심: 적절한 객체에 적절한 책임을 할당해야 한다.
- 불필요한 세부사항을 캡슐화하여 자율적인 객체들이 낮은 결합도와 높은 응집도를 가지고 협력하도록 최소한의 의존성만을 남기는 것이 휼륭한 객체지향 설계다.
더 개선할 수 있다
public class Audience {
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은 과거의 Audience처럼 스스로 자기 자신을 책임지지 않고 Audience에 의해 끌려다니는 수동적인 존재다.
Bag을 자율적인 존재로 바꿔보자 Bag의 내부 상태에 접근하는 모든 로직을 Bag 안으로 캡슐화해서 결합도를 낮추면 된다.
public class Bag {
private Long amount;
private Ticket ticket;
private Invitation invitation;
public Long hold(Ticket ticket) {
if (hasInvitation()) {
setTicket(ticket);
return 0L;
} else {
setTicket(ticket);
minusAmount(ticket.getFee());
return ticket.getFee();
}
}
private void setTicket(Ticket ticket) {
this.ticket = ticket;
}
private boolean hasInvitation() {
return invitation != null;
}
private void minusAmount(Long amount) {
this.amount -= amount;
}
}
public 매서드였던 hasInvitation,minusAmount,setTicket 매서드들은 더 이상 외부에서 사용되지 않고 내부에서만 사용되기 때문에 가시성을 private으로 변경했다.
Bag의 구현을 캡슐화시켰으니 이제 Audience를 Bag의 구현이 아닌 인터페이스에만 의존하도록 수정하자
public class Audience {
private Bag bag;
public Audience(Bag bag) {
this.bag = bag;
}
public Long buy(Ticket ticket) {
return bag.hold(ticket);
}
}
TicketSeller 역시 TicketOffice의 자율권을 침해한다. TicketSeller는 TicketOffice에 있는 Ticket을 마음대로 꺼내서는 자기 멋대로 Audience에게 팔고 Audience에게 받은 돈을 마음대로 TicketOffice에 넣어버린다.
- 변경 전
public class TicketSeller { private TicketOffice ticketOffice; public TicketSeller(TicketOffice ticketOffice) { this.ticketOffice = ticketOffice; } public void sellTo(Audience audience) { ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket())); } }
- 변경 후
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)); } // 아래 코드는 책에서 설명한 것처럼 트레이드오프 후에 원래의 step02의 구현으로 복구해야 합니다. public void sellTicketTo(Audience audience) { plusAmount(audience.buy(getTicket())); } private Ticket getTicket() { return tickets.remove(0); } private void plusAmount(Long amount) { this.amount += amount; } }
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
// 아래 코드는 책에서 설명한 것처럼 트레이드오프 후에 원래의 step02의 구현으로 복구해야 합니다.
public void sellTo(Audience audience) {
ticketOffice.sellTicketTo(audience);
}
}
```
- 변경 전에는 TicketOffice가 Audience에 대해 알지 못했었다는 사실을 기억하라. 변경 후에는 TicketOffice가 Audience에게 직접 티켓을 판매하기 때문에 Audience에 관해 알고 있어야 한다.
변경 전에는 존재하지 않았던 새로운 의존성이 추가된 것이며 의존성의 추가는 높은 결합도를 의미하고, 높은 결합도는 변경하기 어려운 설계를 의미한다.
트레이드오프의 결과로 TicketOffice의 자율성보다는 Audience에 대한 결합도를 낮추는 것이 더 중요하다는 결론에 도달한다.
1) 어떤 기능을 설계하는 방법은 한 가지 이상일 수 있다.
2) 동일한 기능을 한 가지 이상의 방법으로 설계할 수 있기 때문에 결국 설계는 트레이드오프의 산물이다.
그래, 거짓말이다!
- Theater,Bag,TicketOffice 이들은 실세계에서는 자율적인 존재가 아니다. 그럼에도 우리는 이들을 관람객이나 판매원과 같은 생물처럼 다뤘다. 무생물 역시 스스로 행동하고 자기 자신을 책임지는 자율적인 존재로 취급한 것이다.
- 현실에서 수동적인 존재라고 하더라도 일단 객체지향의 세계에 들어오면 모든 것이 능동적으로 변한다. 능동적이고 자율적인 소프트웨어 객체를 설계하는 원칙을 가르쳐 의인화라고 부른다.
설계가 왜 필요한가
설계란 코드를 배치하는 것이다.
좋은 설계를 짜기 위해서는
오늘 완성해야 하는 기능을 구현하는 코드를 짜야 하는 동시에 내일 쉽게 변경할 수 있는 코드를 짜야 한다.
객체지향 설계
휼륭한 객체지향 설계란 협력하는 객체 사이의 의존성을 적절하게 관리하는 설계다.
협력하는 객체들 사이의 의존성을 적절하게 조절함으로써 변경에 용이한 설계를 만드는 것이다
'그룹 스터디 공부(IT 서적) > 오브젝트' 카테고리의 다른 글
05 책임 할당하기_02 (0) | 2023.08.09 |
---|---|
05 책임 할당하기_01 (0) | 2023.08.07 |
04 설계 품질과 트레이드 오프 (0) | 2023.07.31 |
03 역할,책임,협력 (0) | 2023.07.24 |
02 객체지향 프로그래밍 (0) | 2023.07.23 |