- 객체, 설계
- 객체지향 프로그래밍
- 역할, 책임, 협력
- 설계 품질과 트레이드 오프
- 책임 할당하기
- 메시지와 인터페이스
- 객체 분해
- 의존성 관리하기
- 유연한 설계
- 상속과 코드 재사용
- 합성과 유연한 설계
- 다형성
- 서브클래싱과 서브타이핑
- 일관성 있는 협력
- 디자인 패턴과 프레임워크
- 부록 A-계약에 의한 설계
- 부록 B-타입 계층의 구현
- 부록 C-동적인 협력, 정적인 코드
객체지향으로 향하는 첫걸음은 클래스가 아니라 객체를 바라보는 것에서부터 시작합니다. 객체지향으로 향하는 두 번째 걸음은 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 존재로 바라보는 것입니다. 세 번째 걸음을 내디딜 수 있는지 여부는 협력에 참여하는 객체들에게 얼마나 적절한 역할과 책임을 부여할 수 있느냐에 달려 있습니다. 객체지향의 마지막 걸음은 앞에서 설명한 개념들을 여러분이 사용하는 프로그래밍 언어라는 틀에 흐트러짐 없이 담아낼 수 있는 기술을 익히는 것입니다.
☞ 객체지향의 사실과 오해, 서문 - 조영호
이 책을 읽고 나면 객체에게 적절한 역할과 책임을 부여하는 방법과 유연하면서도 요구사항에 적절한 협력을 설계하는 방법을 알게 될 것입니다. 나아가 프로그래밍 언어라는 도구를 이용해 객체지향의 개념과 원칙들을 오롯이 표현할 수 있는 방법을 익힐 수 있을 것입니다.
한 가지 염두에 둬야 할 점은 이 책에서 제시하는 방법이 객체지향을 구현하기 위한 유일한 방법은 아니라는 점입니다. 객체지향 애플리케이션을 설계할 수 있는 다양한 접근방법이 있으며 이 책은 그중에서 제가 오랜 시간 실무에 적용하고 실험하면서 유용하다고 생각하는 기법과 원칙들을 하나의 얼개로 통합한 것일 뿐입니다.
패러다임(paradigm)이라는 말은 '모델(model)', '패턴(pattern)', 또는 '전형적인 예(example)'를 의미하는 그리스어인 '파라데이그마(paradeigma)'에서 유래했다. 과거에는 표준적인 모델을 따르거나 모방하는 상황을 가리키는 매우 제한적인 상황에서만 패러다임이라는 단어를 사용했다.
현대인들은 패러다임이라는 단어를 전혀 다른 의미로 사용한다. 우리가 사용하는 패러다임은 '한 시대의 사회 전체가 공유하는 이론이나 방법, 문제의식 등의 체계'를 의미한다.
이렇게 바뀐 이유는 토마스 쿤의 과학혁명의 구조라는 한 권의 책 때문임.
프로그래밍 패러다임은 특정 시대의 어느 성숙한 개발자 공동체에 의해 수용된 프로그래밍 방법과 문제 해결 방법, 프로그래밍 스타일이라고 할 수 있다. 간단히 말해서 우리가 어떤 프로그래밍 패러다임을 사용하느냐에 따라 우리가 해결할 문제를 바라보는 방식과 프로그램을 작성하는 방법이 달라진다.
프로그래밍 패러다임이 중요한 이유는 무엇일까?
프로그래밍 패러다임은 개발자 공동체가 동일한 프로그래밍 스타일과 모델을 공유할 수 있게 함으로써 불필요한 부분에 대한 의견 충돌을 방지한다. 또한 프로그래밍 패러다임을 교육시킴으로써 동일한 규칙과 방법을 공유하는 개발자로 성장할 수 있도록 준비시킬 수 있다.
플로이드가 언급한 것처럼 각 프로그래밍 언어가 제공하는 특징과 프로그래밍 스타일은 해당 언어가 채택하는 프로그래밍 패러다임에 따라 달라진다. 각 패러다임과 패러다임을 채용하는 언어는 특정한 종류의 문제를 해결하는 데 필요한 일련의 개념들을 지원한다.
하나 이상의 패러다임을 수용하는 언어를 다중패러다임 언어(Multiparadigm Language)라고 부른다.
'은총알은 없다'는 프레디 브룩스의 말을 기억하라, 객체지향 패러다임은 은총알이 아니다. 객체지향이 적합하지 않은 상황에서는 언제라도 다른 패러다임을 적용할 수 있는 시야를 기르고 지식을 갈고 닦아야 한다.
이론이 먼저일까, 실무가 먼저일까?
☞ 소프트웨어 크리에이티비티 2.0, 이론 대 실무 - 로버트 L. 글래스
대부분의 사람들은 이론이 먼저 정립된 후에 실무가 그 뒤를 따라 발전한다고 생각한다. 글래스는 그 반대라고 주장한다. 글래스에 따르면 어떤 분야를 막론하고 이론을 정립할 수 없는 초기에는 실무가 먼저 급속한 발전을 이룬다고 한다. 실무가 어느 정도 발전하고 난 다음에야 비로소 실무의 실용성을 입증할 수 있는 이론이 서서히 그 모습을 갖춰가기 시작하고, 해당 분야가 충분히 성숙해지는 시점에 이르러서야 이론이 실무를 추월하게 된다는 것이다.
저자의 생각은 소프트웨어 분야는 다른 공학 분야에 비해서 역사가 짧기 때문에 이론보다 실무가 더 앞서 있으며 실무가 더 중요하다고 하심.
소프트웨어 설계분야에서 실무는 이론을 압도한다. 설계에 관해 설명할 때 가장 유용한 도구는 '코드' 그 자체다.
조건, 1. 이벤트에 당첨된 관람객과 그렇지 못한 관람객은 다른 방식으로 입장시켜야 한다.
- 이벤트에 당첨된 관람객은 초대장을 티켓으로 교환한 후에 입장 가능하다.
- 이벤트에 당첨되지 않은 관람객은 티켓을 구매해야만 입장할 수 있다.
따라서 관람객을 입장시키기전 1) 이벤트 당첨 여부를 확인해야하고 2) 이벤트 당첨자가 아닌 경우에는 티켓을 판매한 후에 입장시켜야 한다.
초대장 구현
public class Invitation {
private LocalDateTime when; // 초대일자
}
티켓 구현(입장하는 모든 사람이 소유해야 한다.)
public class Ticket {
private Long fee;
public Long getFee() {
return fee;
}
}
관람객(티켓으로 교환할 초대장<이벤트 당첨자>, 티켓을 구매할 현금<당첨되지 않은 사람>)
소지품을 보관할 용도로 가방을 들고 올 수 있다.
가방 구현
public class Bag {
private Long amount;
private Invitation invitation;
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 minusAmount(Long amount) {
this.amount -= amount;
}
public void plusAmount(Long amount) {
this.amount += amount;
}
}
이벤트에 당첨된 관람객의 가방에는 현금과 초대장이 들어있지만, 당첨되지 않은 사람은 초대장이 들어있지 않을 것이다. 따라서 인스턴스 생성시점에 이 제약을 강제하도록 생성자를 추가한다. 1) 현금과 초대장을 함께 보관 2) 초대장 없이 현금만 보관
public class 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;
}
}
판매원은 매표소에서 초대장을 티켓으로 교환해주거나 티켓을 판매하는 역할. 판매원은 자신이 일하는 매표소를 알고 있어야 한다.
왜 매표소가 판매원을 가지고 있지 않고 판매원이 매표소를 알고 있도록 했을까?
판매원 구현
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);
}
}
}
코드 쓰면서 고치고 싶은점이 한 두개가 아닌데...
이 작은 프로그램은 몇 가지 문제점을 가지고 있다.
로버트 마틴에 따르면 모든 모듈은 제대로 실행돼야 하고, 변경이 용이해야 하며, 이해하기 쉬워야 한다.
즉, 앞에서 작성한 프로그램은 제대로 실행되기는 하지만, 변경 용이성과 이해하기 쉽다는 조건을 만족시키지 못한다.
앞에서 작성했던 Theater 클래스의 enter 메소드가 하는 일을 말로 풀어보자.
소극장은 관람객의 가방을 열어 그 안에 초대장이 들어 있는지 살펴본다. 가방 안에 초대장이 들어 있으면 판매원은 매표소에 보관돼 있는 티켓을 관람객의 가방 안으로 옮긴다. 가방 안에 초대장이 들어 있지 않다면 관람객의 가방에서 티켓 금액만큼의 현금을 꺼내 매표소에 적립한 후에 매표소에 보관돼 있는 티켓을 관람객의 가방 안으로 옮긴다.
문제는 관람객과 판매원이 소극장의 통제를 받는 수동적인 존재라는 점이다.
관람객의 입장에서 소극장이라는 제3자가 초대장을 확인하기 위해 관람객의 가방을 마음대로 열어 본다는 데 있다.
판매원도 동일한 문제가 발생한다. 누군가가 허락도 없이 매표소에서 판매중인 티켓과 현금에 마음대로 접근할 수 있기 때문이다. 더 큰 문제는 티켓을 꺼내 관람객의 가방에 집어넣고 관람객에게서 받은 돈을 매표소에 적립하는 일은 판매원이 아니라 소극장이 수행한다는 점이다. 즉 판매원은 매표소에 가만히 앉아서 티켓이 저절로 사라지고 돈이 쌓이는 광경을 쳐다만 보고 있게 된다는 것이다.
이해 가능한 코드란 그 동작이 우리의 예상에서 크게 벗어나지 않는 코드다. 현재의 코드는 우리의 상식과는 너무나도 다르게 동작하기 때문에 코드를 읽는 사람과 제대로 의사소통하지 못한다.
코드를 이해하기 어렵게 만드는 또 다른 이유는 이 코드를 이해하기 위해서는 여러 가지 세부적인 사항들을 한꺼번에 기억하고 있어야 한다는 점이다.
enter 메서드를 이해하기 위해 세부사항을 많이 알아야 하기 때문에 코드를 작성하는 사람 뿐만 아니라 읽고 이해해야 하는 사람 모두에게 큰 부담을 준다.
가장 심각한 문제는 Audience와 TicketSeller를 변경할 경우 Theater도 함께 변경해야 한다는 사실이다.
가장 심각한 문제는 바로 변경에 취약하다는 것이다. enter 메소드는 관람객이 항상 현금과 초대장을 보관하기 위해 가방을 들고 다닌다고 가정한다. 또한 판매원이 매표소에서만 티켓을 판매한다고 가정한다. 가정이 깨지는 순간 모든 코드가 일시에 뒤흔들리게 된다.
Theater는 관람객이 가방을 들고 있고 판매원이 매표소에서만 티켓을 판매한다는 지나치게 세부적인 사실에 의존해서 동작한다. 이러한 세부적인 사실 중 한 가지라도 바뀌면 해당 클래스뿐만 아니라 이 클래스에 의존하는 Theater도 함께 변경해야 한다.
이것은 객체 사이의 **의존성(dependency)**과 관련된 문제다. 문제는 의존성이 변경과 관련돼 있다는 점이다. 의존성은 변경에 대한 영향을 암시한다. 의존성이라는 말 속에는 어떤 객체가 변경될 때 그 객체에게 의존하는 다른 객체도 함께 변경될 수 있다는 사실이 내포돼 있다.
그렇다고 해서 객체 사이의 의존성을 완전히 없애는 것이 정답은 아니다. 객체지향 설계는 서로 의존하면서 협력하는 객체들의 공동체를 유지하는 것이다. 따라서 우리의 목표는 애플리케이션의 기능을 구현하는 데 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거하는 것이다.
객체 사이의 의존성이 과한 경우를 가리켜 **결합도(coupling)**가 높다고 말한다. 반대로 객체들이 합리적인 수준으로 의존할 경우에는 결합도가 낮다고 말한다. 결합도는 의존성과 관련돼 있기 때문에 결합도 역시 변경과 관련이 있다.
따라서 설계의 목표는 객체 사이의 결합도를 낮춰 변경이 용이한 설계를 만드는 것이어야 한다.
코드를 이해하기 어려운 이유는 Theater가 관람객의 가방과 판매원의 매표소에 직접 접근하기 때문이다. 이것은 관람객과 판매원이 자신의 일을 스스로 처리해야 한다는 우리의 직관을 벗어나기 때문이다.
또한, Theater가 관람객의 가방과 판매원의 매표소에 직접 접근한다는 것은 Theater가 Audience와 TicketSeller에 결합된다는 것을 의미한다. 따라서 Audience와 TicketSeller를 변경할 때 Theater도 함께 변경해야 하기 때문에 전체적으로 코드를 변경하기도 어려워진다.
해결방법은 Theater가 Audience와 TicketSeller에 관해 너무 세세한 부분까지 알지 못하도록 정보를 차단하면 된다. Theater가 원하는 것은 관람객이 소극장에 입장하는 것 뿐이다. 따라서 관람객이 스스로 가방 안의 현금과 초대장을 처리하고 판매원이 스스로 매표소의 티켓과 판매 요금을 다루게 한다면 이 모든 문제를 한 번에 해결할 수 있을 것이다.
다시 말해서 관람객과 판매원을 자율적인 존재로 만들면 되는 것이다.
첫 번째 단계는 Theater의 enter 메서드에서 TicketOffice에 접근하는 모든 코드를 TicketSeller 내부로 숨기는 것이다. TicketSeller에 sellTo 메서드를 추가하고 Theater에 있던 로직을 이 메서드로 옮기자
public class Theater {
private TicketSeller ticketSeller;
public Theater(TicketSeller ticketSeller) {
this.ticketSeller = ticketSeller;
}
public void enter(Audience audience) {
ticketSeller.sellTo(audience); // 여기 있던 로직을 ticketSeller로 옮긴다.
}
}
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.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);
}
}
}
TicketSeller에서 getTicketOffice 메서드가 제거됐다는 사실에 주목하라. Theater는 ticketOffice가 TicketSeller 내부에 존재한다는 사실을 알지 못한다.
Theater는 오직 TicketSeller의 **인터페이스(interface)**에만 의존한다.TicketSeller가 내부에 TicketOffice 인스턴스를 포함하고 있다는 사실은 **구현(implementation)**의 영역에 속한다. 객체를 인터페이스와 구현으로 나누고 인터페이스만을 공개하는 것은 객체 사이의 결합도를 낮추고 변경하기 쉬운 코드를 작성하기 위해 따라야 하는 가장 기본적인 설계 원칙이다.
인터페이스는 여기서 사용하는 메소드 시그니처들을 공개하는 것을 말한다.
Theater에서 TicketOffice로의 의존성이 제거되어서, TicketOffice와 협력하는 TicketSeller의 내부 구현이 캡슐화 되었다.
동일한 방법으로 Audience의 캡슐화를 개선할 수 있다. Bag에 접근하는 모든 로직을 Audience 내부로 감추는 것이다.
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 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();
}
}
}
buy()
메서드는 인자로 전달된 Ticket을 Bag에 넣은 후 지불된 금액을 반환한다.
변경된 코드에서 Audience는 자신의 가방 안에 초대장이 들어있는지를 스스로 확인한다. Audience가 Bag을 직접 처리하기 때문에, 외부에서는 더이상 Audience가 Bag을 소유하고 있다는 사실을 알 필요가 없다. 즉 Bag의 존재를 캡슐화한 것이다.
TicketSeller는 Audience의 인터페이스에만 의존한다. TicketSeller가 buy()
메서드를 호출하도록 변경한다.
코드를 수정한 결과, TicketSeller와 Audience 사이의 결합도가 낮아졌다. 또한 내부 구현이 캡슐화됐으므로 Audience의 구현을 수정하더라도 TicketSeller에는 영향을 미치지 않는다.
변경된 코드는 우리의 예상과 정확히 일치하게된다. 따라서 코드를 읽는 사람과의 의사소통이라는 관점에서 이 코드는 확실히 개선된 것으로 보인다. 더 중요한 점은 이 이후로 Audience나 TicketSeller의 내부 구현을 변경하더라도 Theater를 함께 변경할 필요가 없어졌다는 것이다. 따라서 변경 용이성의 측면에서도 확실히 개선됐다고 말할 수 있다.
자기 자신의 문제를 스스로 해결하도록 코드를 변경한 것이다. 수정한 후의 Theater는 Audience나 TicketSeller의 내부에 직접 접근하지 않는다.
우리는 객체의 자율성을 높이는 방법으로 설계를 개선했다. 그 결과, 이해하기 쉽고 유연한 설계를 얻을 수 있었다.
핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다.
밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜 **응집도(cohesion)**가 높다고 말한다. 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도를 낮출 수 있을뿐더러 응집도를 높일 수 있다.
객체의 응집도를 높이기 위해서는 객체 스스로 자신의 데이터를 책임져야 한다. 객체는 자신의 데이터를 스스로 처리하는 자율적인 존재여야 한다. 그것이 객체의 응집도를 높이는 첫걸음이다.