프로그래밍 패러다임은 특정 시대의 어느 성숙한 개발자 공동체에 의해 수용된 프로그래밍 방법과 문제 해결 방법, 프로그래밍 스타일이다.
이 책은 객체지향 패러다임에 관한 책이다. 이 책의 패러다임 전환이란 절차형 패러다임에서 객체지향 패러다임으로의 변화이다.
- C: 절차형 패러다임
- Java: 객체지향 패러다임
- LISP: 함수형 패러다임
- PROLOG: 논리형 패러다임
각 패러다임과 패러다임을 채용하는 언어는 특정한 종류의 문제를 해결하는 데 필요한 일련의 개념들을 지원한다.
프로그래밍 패러다임에서는 두 패러다임이 공존할 수 있다. 이러한 언어를 다중패러다임 언어라 한다.
- C++: 절차형 패러다임 + 객체지향 패러다임
- Scala: 함수형 패러다임 + 객체지향 패러다임
프로그래밍 패러다임은 혁명적이 아니라 발전적이다.
객체지향 패러다임은 절차형 패러다임의 단점을 보완하면서도 그 기반 위에서 구축됐다.
1. 티켓 판매 애플리케이션 구현하기
추첨을 통해 선정된 관람객에게 공연을 무료로 관람할 수 있는 초대장을 발송하는 이벤트를 열었다고 하자.
이벤트에 당첨된 관람객과 그렇지 못한 관람객을 따로 입장시켜야 한다.
- 이벤트에 당첨된 관람객은 초대장을 티켓으로 교환한 후에 입장
- 이벤트에 당첨되지 않은 관람객은 티켓을 구매하여 입장
1. 초대장 구현
class Invitation {
private when: Date;
constructor(when: Date) {
this.when = when;
}
}
2. 티켓 구현
class Ticket {
private fee: number;
constructor(fee: number) {
this.fee = fee;
}
public getFee(): number {
return this.fee;
}
}
3. 소지품을 보관하는 가방 구현 (소지품: 초대장, 현금, 티켓)
Bag 인스턴스의 상태는 현금과 초대장을 함께 봐관하거나, 초대장 없이 현금만 보관하는 두 가지 중 하나일 것이다.
class Bag {
private amount: number; // 티켓을 구매할 수 있는 현금
private invitation: Invitation | null; // 초대장
private ticket: Ticket | null; // 티켓
constructor(invitation: Invitation | null, amount: number) {
this.amount = amount;
this.invitation = invitation;
this.ticket = null;
}
// 초대장 소지 여부
public hasInvitation(): boolean {
return this.invitation !== null;
}
// 티켓 소지 여부
public hasTicket(): boolean {
return this.ticket !== null;
}
// 티켓 구매/교환
public setTicket(ticket: Ticket): void {
this.ticket = ticket;
}
// 티켓 구매 시 현금 감소
public minusAmount(amount: number): void {
this.amount -= amount;
}
}
4. 관람객 구현
class Audience {
private bag: Bag;
constructor(bag: Bag) {
this.bag = bag;
}
public getBag(): Bag {
return this.bag;
}
}
5. 매표소 구현
class TicketOffice {
private amount: number; // 티켓의 판매 금액
private tickets: Ticket[]; // 판매할 티켓
constructor(amount: number, tickets: Ticket[]) {
this.amount = amount;
this.tickets = tickets;
}
public getTicket(): Ticket | null {
return this.tickets.shift() ?? null;
}
// 티켓 판매 시 돈 현금 증가
public plusAmount(amount: number): void {
// 티켓 판매 시 +돈
this.amount += amount;
}
}
6. 판매원 구현
class TicketSeller {
private ticketOffice: TicketOffice; // 자신이 일하는 매표소
constructor(ticketOffice: TicketOffice) {
this.ticketOffice = ticketOffice;
}
public getTicketOffice(): TicketOffice {
return this.ticketOffice;
}
}
7. 소극장 구현
class Theater {
private ticketSeller: TicketSeller;
constructor(ticketSeller: TicketSeller) {
this.ticketSeller = ticketSeller;
}
// 관람객 맞이
public enter(audience: Audience): void {
if (audience.getBag().hasInvitation()) {
// 초대장이 있으면
const ticket: Ticket = this.ticketSeller.getTicketOffice().getTicket();
audience.getBag().setTicket(ticket); // 티켓 교환
} else {
// 초대장이 없으면
const ticket: Ticket = this.ticketSeller.getTicketOffice().getTicket();
audience.getBag().minusAmount(ticket.getFee()); // 티켓 비용 지불
this.ticketSeller.getTicketOffice().plusAmount(ticket.getFee()); // 티켓 판매
audience.getBag().setTicket(ticket); // 티켓 구매
}
}
}
하지만 이 프로그램은 몇 가지 문제점을 가지고 있다.
2. 무엇이 문제인가
로머트 마틴에 따르면 소프트웨어 모듈은 제대로 실행돼야 하고, 변겨잉 용이해야 하며, 이해하기 쉬워야 한다.
위의 코드는 변경 용이성과 의사소통이라는 목적은 만족시키지 못한다.
의사소통의 문제1 - 현실과 다르게 동작함
문제는 관람객과 판매원이 소극장의 통제를 받는 수동적인 존재라는 점이다.
소극장이 관람객의 가방을 마음대로 열어 보고, 소극장이 티켓과 현금에 마음대로 접근할 수 있는 것이다.
관람객은 직접 초대장이나 돈을 꺼내야 하고, 판매원은 직접 태켓을 꺼내고 돈을 보관해야 한다.
의사소통의 문제2 - 여러 세부적인 내용들을 한꺼번에 기억해야 함
Theater의 enter를 이해하기 위해 알아야 하는 것
- 관람객이 가방을 가지고 있음
- 가방 안에는 현금과 티켓이 있음
- 판매원이 매표소에서 티켓을 판매함
- 매표소에 돈과 티켓이 있음
하나의 클래스/메서드에서 너무 많은 세부사항을 다루고 있다.
변경에 취약한 코드
제일 큰 문제는 변경에 취약하다는 것이다.
Audience나 TicketSeller를 변경할 경우 Theater도 변경해야 한다.
이는 객체 사이의 의존성과 관련된 문제다. 의존성은 변경에 대한 영향을 암시한다.
우리는 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거해야 한다.
객체 사이의 의존성이 과한 경우를 가리켜 결합도가 높다고 한다.
설계의 목표는 객체 사이의 결합도를 낮춰 변경이 용이한 설계를 만드는 것이다.
3. 설계 개선하기
관람객과 판매원을 자율적인 존재로 만들면 된다.
Audience와 TicketSeller가 직접 Bag과 TicketOffice를 처리하도록 설계를 변경하자.
1. Theater의 enter에서 TicketOffice에 접근하는 모든 코드를 TicketSeller 내부로 숨기기
TicketSeller에 sellTo 메서드를 추가하고 로직을 옮겨오자.
class TicketSeller {
private ticketOffice: TicketOffice;
constructor(ticketOffice: TicketOffice) {
this.ticketOffice = ticketOffice;
}
public sellTo(audience: Audience): void {
if (audience.getBag().hasInvitation()) {
const ticket: Ticket = this.ticketOffice.getTicket();
audience.getBag().setTicket(ticket);
} else {
const ticket: Ticket = this.ticketOffice.getTicket();
audience.getBag().minusAmount(ticket.getFee());
this.ticketOffice.plusAmount(ticket.getFee());
audience.getBag().setTicket(ticket);
}
}
}
class Theater {
private ticketSeller: TicketSeller;
constructor(ticketSeller: TicketSeller) {
this.ticketSeller = ticketSeller;
}
public enter(audience: Audience): void {
this.ticketSeller.sellTo(audience);
}
}
이제 외부에서는 ticketOffice에 직접 접근할 수 없다. ticketOffice에 대한 접근은 오직 TicketSeller 안에만 존재하게 된다.
Theater는 ticketOffice의 존재를 알지 못하며, ticketSeller가 sellTo 메시지를 이해하고 응답할 수 있다는 사실만 알 뿐이다.
이처럼 개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 것을 캡슐화라고 부른다.
캡슐화의 목적은 결합도를 낮춰 변경하기 쉬운 객체를 만드는 것이다.
Theater는 오직 TicketSeller의 인터페이스에만 의존한다.
TicketOffice 인스턴스를 내부에 포함하고 있다는 사실은 구현의 영역에 속한다.
객체를 인터페이스와 구현으로 나누고 인터페이스만을 공개하는 것은 기본적인 설계 원칙이다.
2. Bag에 접근하는 모든 코드를 Audience 내부로 숨기기
Audience에 buy 메서드를 추가하고 로직을 옮겨오자.
class Audience {
private bag: Bag;
constructor(bag: Bag) {
this.bag = bag;
}
public buy(ticket: Ticket): number {
if (this.bag.hasInvitation()) {
this.bag.setTicket(ticket);
return 0;
} else {
this.bag.setTicket(ticket);
this.bag.minusAmount(ticket.getFee());
return ticket.getFee();
}
}
}
class TicketSeller {
private ticketOffice: TicketOffice;
constructor(ticketOffice: TicketOffice) {
this.ticketOffice = ticketOffice;
}
public sellTo(audience: Audience): void {
this.ticketOffice.plusAmount(audience.buy(this.ticketOffice.getTicket()));
}
}
TicketSeller와 Audience 사이의 결합도가 낮아졌다.
무엇이 개선됐는가?
- 의사소통의 측면: 관람객이 가방을 스스로 관리하고 판매원이 티켓을 스스로 관리함
- 변경 용이성의 측면: 관람객이나 판매원의 내부 구현을 변경해도 소극장을 변경할 필요가 없음
캡슐화와 응집도
핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다.
밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체는 응집도가 높은 객체다.
자율적인 객체를 만들면 결합도를 낮추고 응집도를 높일 수 있다.
절차지향과 객체지향
수정 전 코드: Theater의 enter 안에서 모든 처리에 대한 절차를 구현함
절차적 프로그래밍: 프로세스와 데이터를 별도의 모듈에 위치시키는 방식
- 프로세스: Theater의 enter 메서드
- 데이터: Audience, TicketSeller, Bag, TicketOffice
절차적 프로그래밍은 의사소통을 원활하지 못하게 하며, 변경하기 어려운 코드를 양산하는 경향이 있다.
수정 후 코드: 데이터를 사용하는 프로세스가 데이터를 소유하고 있는 Audience와 TicketSeller 내부에 존재함
객체지향 프로그래밍: 프로세스와 데이터를 동일한 모듈 내부에 위치시키는 방식
훌륭한 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 간 결합도를 낮추는 것
책임의 이동
절차지향과 객체지향의 근본적인 차이는 책임이다.
- 절차지향: 책임이 Theater에 집중돼 있음
- 객체지향: 책임이 개별 객체로 이동함
객체지향에서 각 객체는 자신을 스스로 책임진다.
객체지향의 핵심은 적절한 객체에 적절한 책임을 할당하는 것
객체가 어떤 데이터를 가지느냐 < 객체에 어떤 책임을 할당할 것이냐
설계를 어렵게 만드는 것은 의존성이다. 불필요한 의존성을 제거함으로써 결합도를 낮추다.
캡슐화는 객체의 자율성을 높이고 응집도 높은 객체들의 공동체를 창조할 수 있게 한다.
더 개선할 수 있다
Bag을 자율적인 존재로 바꾸자.
Bag의 내부 상태에 접근하는 모든 로직을 Bag의 hold 메서드 안으로 캡슐화해서 결합도를 낮추자.
class Bag {
private amount: number;
private invitation: Invitation | null;
private ticket: Ticket | null;
constructor(invitation: Invitation | null, amount: number) {
this.amount = amount;
this.invitation = invitation;
this.ticket = null;
}
public hold(ticket: Ticket): number {
if (this.hasInvitation()) {
this.setTicket(ticket);
return 0;
} else {
this.setTicket(ticket);
this.minusAmount(ticket.getFee());
return ticket.getFee();
}
}
private hasInvitation(): boolean {
return this.invitation !== null;
}
private setTicket(ticket: Ticket): void {
this.ticket = ticket;
}
private minusAmount(amount: number): void {
this.amount -= amount;
}
}
class Audience {
private bag: Bag;
constructor(bag: Bag) {
this.bag = bag;
}
public buy(ticket: Ticket): number {
return this.bag.hold(ticket);
}
}
TicketOffice도 자율적인 존재로 바꾸자.
TicketOffice의 sellTicketTo 메서드 내로 로직을 옮겨오자.
class TicketOffice {
private amount: number;
private tickets: Ticket[];
constructor(amount: number, tickets: Ticket[]) {
this.amount = amount;
this.tickets = tickets;
}
public sellTicketTo(audience: Audience): void {
this.plusAmount(audience.buy(this.getTicket()));
}
private getTicket(): Ticket {
const ticket = this.tickets.shift();
if (!ticket) {
throw new Error("티켓 매진");
}
return ticket;
}
private plusAmount(amount: number): void {
this.amount += amount;
}
}
class TicketSeller {
private ticketOffice: TicketOffice; // 자신이 일하는 매표소
constructor(ticketOffice: TicketOffice) {
this.ticketOffice = ticketOffice;
}
public sellTo(audience: Audience): void {
this.ticketOffice.sellTicketTo(audience);
}
}
하지만 TicketOffice와 Audience에 의존성이 추가되었다.
Ticketoffice의 자율성은 높였지만 전체 설계의 관점에서는 결합도가 상승했다. 어떤 것을 우선해야 하는가?
1. 어떤 기능을 설계하는 방법은 한 가지 이상일 수 있다.
2. 그렇기에 결국 설계는 트레이드오프의 산물이다.
Theater, Bag, TicketOffice는 현실에서는 자율적인 존재가 아니다.
능동적이고 자율적인 존재로 소프트웨어 객체를 설계하는 원칙을 의인화라고 한다.
4. 객체지향 설계
설계란 코드를 배치하는 것이다.
좋은 설계란 오늘 요구하는 기능을 온전히 수행하면서 내일의 변경을 매끄럽게 수용할 수 있는 설계다.
객체지향 프로그래밍은 의존성을 효율적으로 통제할 수 있는 다양한 방법을 제공함으로써 변경에 좀 더 수월하게 대응할 수 있게 해준다.
변경 가능한 코드란 이해하기 쉬운 코드다.
객체지향 패러다임은 예상하는 방식대로 객체가 행동하리라는 것을 보장함으로써 코드를 좀 더 쉽게 이해할 수 있게 한다.
애플리케이션은 객체들로 구성되며 애플리케이션의 기능은 객체들 간의 상호작용을 통해 구현된다.
이 상호작용은 객체 사이에 주고 받는 메시지로 표현된다.
메시지를 전송하기 위해 알고 있어야 하는 것들이 두 객체를 결합시켜 의존성을 만든다.
데이터와 프로세스를 하나의 덩어리로 모으는 것은 객체지향 설계의 첫걸음이다.
훌륭한 객체지향 설계란 협력하는 객체 사이의 의존성을 적절하게 관리하는 설계다.