오브젝트 Ch.2 — 객체지향 프로그래밍
메시지와 메서드, 컴파일·실행 시점 의존성의 차이, 상속과 합성, 추상화를 영화 예매 예제로 정리한다.
시리즈 · BackEnd 서적10 / 17
- SQL 레벨업 Ch.1 — DBMS 아키텍처
- SQL 레벨업 Ch.3 — SQL의 조건 분기
- SQL 레벨업 Ch.4~5 — 집약과 자르기, 반복문
- SQL 레벨업 Ch.6 — 결합
- SQL 레벨업 Ch.7 — 서브쿼리
- SQL 레벨업 Ch.8 — SQL의 순서
- SQL 레벨업 Ch.9 — 갱신과 데이터 모델
- SQL 레벨업 Ch.10 — 인덱스 사용
- 오브젝트 Ch.1 — 객체, 설계
- 오브젝트 Ch.2 — 객체지향 프로그래밍
- 오브젝트 Ch.3~4 — 역할, 책임, 협력 / 설계 품질과 트레이드오프
- 오브젝트 Ch.5 — 책임 할당하기
- 오브젝트 Ch.6 — 메시지와 인터페이스
- 오브젝트 Ch.8 — 의존성 관리하기
- 오브젝트 Ch.9 — 유연한 설계
- 오브젝트 Ch.10, 12 — 상속과 코드 재사용, 다형성
- 오브젝트 Ch.13 — 서브클래싱과 서브타이핑
2장의 주제는 세 가지다. 의존성의 양면성, 상속과 합성, 그리고 추상화. 예제 코드는 저자의 저장소에서 볼 수 있다.
객체지향은 객체를 지향하는 것이다
객체지향 프로그래밍은 클래스를 먼저 정하고 속성과 메서드를 고민하는 것이 아니다. 클래스가 아닌 객체에 초점을 맞춰야 한다.
- 어떤 클래스가 필요한지 고민하기 전에 어떤 객체들이 필요한지 고민한다. 클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것이다.
- 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 본다.
클래스를 구현할 때 가장 중요한 것은 공개하는 내용과 감추는 내용의 경계를 구분 짓는 것이다(public, private). 경계가 명확해야 객체의 자율성이 보장되고, 프로그래머는 구현의 자유를 얻는다.
메시지와 메서드
- 메시지: 객체가 다른 객체에게 행동을 요청하거나 응답하는 수단
- 메서드: 수신된 메시지를 처리하기 위한 자신만의 방법
A가 B에 의존할 때 A는 B가 특정 메시지에 응답한다는 것만 알면 되고, B의 메서드 내용까지 알 필요는 없다. 메시지와 메서드의 구분에서 다형성의 개념이 출발한다.
TEMPLATE METHOD 패턴
public abstract class DiscountPolicy {
private List<DiscountCondition> conditions = new ArrayList<>();
public DiscountPolicy(DiscountCondition... conditions) {
this.conditions = Arrays.asList(conditions);
}
public Money calculateDiscountAmount(Screening screening) {
for (DiscountCondition each : conditions) {
if (each.isSatisfiedBy(screening)) {
return getDiscountAmount(screening);
}
}
return Money.ZERO;
}
abstract protected Money getDiscountAmount(Screening screening);
}
할인 조건을 만족하는지는 부모 클래스의 calculateDiscountAmount가 판단하고, 할인 금액을 계산하는 책임은 추상 메서드 getDiscountAmount를 통해 자식 클래스에게 넘긴다. 이처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 것이 TEMPLATE METHOD 패턴이다.
왜 인터페이스가 아닌 추상 클래스일까
- 추상 클래스의 목적: 상속받아 기능을 이용하고 확장한다.
- 인터페이스의 목적: 구현을 강제해 객체의 같은 동작을 보장한다.
AmountDiscountPolicy와 PercentDiscountPolicy는 calculateDiscountAmount가 완전히 동일하고 getDiscountAmount만 다르다. 공통 구현을 공유해야 하므로 추상 클래스로 정의한 것이다.
의존성의 양면성
코드의 의존성과 실행 시점의 의존성은 서로 다를 수 있다.
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
// ...
this.discountPolicy = discountPolicy;
}
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
Movie는 코드 상으로 DiscountPolicy에만 의존하지만, 실행 시점에는 그 자식 클래스의 인스턴스에 의존한다. 실제로 어떤 타입에 의존하는지 알려면 의존성을 연결하는 부분, 즉 Movie 인스턴스를 생성하는 코드를 찾아봐야 한다.
두 의존성이 다를수록 코드는 유연하고 확장 가능해지지만 그만큼 이해하기 어려워진다. 무조건 유연한 설계도, 무조건 읽기 쉬운 코드도 정답이 아니다.
다형성과 바인딩
다형성을 구현하는 방법들은 실행할 메서드를 컴파일 시점이 아닌 실행 시점에 결정한다는 공통점이 있다.
- 지연 바인딩(동적 바인딩): 메시지와 메서드를 실행 시점에 바인딩한다.
- 초기 바인딩(정적 바인딩): 컴파일 시점에 실행될 함수나 프로시저를 결정한다.
상속과 합성
상속의 가장 큰 목적은 인터페이스의 재사용이다. 동일한 인터페이스를 공유하는 클래스들을 하나의 타입 계층으로 묶을 수 있다. 반면 코드 재사용 수단으로서의 상속에는 단점이 있다.
- 캡슐화를 위반한다. 자식 클래스가 부모 클래스의 내부 구조를 알아야 하고, 그 결과 부모를 변경할 때 자식도 함께 변경될 확률이 높아진다.
- 설계가 유연하지 못하다. 실행 시점에 객체의 종류를 변경할 수 없다.
합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법이다. 상속이 클래스를 통해 강하게 결합되는 데 비해 합성은 메시지를 통해 느슨하게 결합된다.
- 인터페이스에 정의된 메시지를 통해서만 재사용하므로 구현을 캡슐화할 수 있다.
- 실행 시점에 객체의 종류를 변경할 수 있다.
코드 재사용에는 합성을, 인터페이스 재사용에는 상속을 사용한다. 대부분의 설계에서는 둘을 조합하게 된다.
추상화
“영화 예매 요금은 최대 하나의 ’할인 정책’과 다수의 ’할인 조건’을 이용해 계산할 수 있다”라는 문장은 “’금액 할인 정책’과 ’두 개의 순서 조건, 한 개의 기간 조건’을 이용해 계산할 수 있다”라는 문장을 포괄한다. 추상화를 사용하면 세부사항에 억눌리지 않고 상위 정책을 쉽고 간단하게 표현할 수 있다.
1장에서 설계한 코드 수정
1장에서는 Office를 인터페이스로만 두었는데, minusAmount와 plusAmount는 어떤 구현체에서든 동일하게 동작할 것이므로 추상 클래스로 올렸다. 또한 TicketSeller가 Office를 직접 만들지 않고 생성자로 전달받도록 바꿨다.
public abstract class Office {
private Long amount;
public abstract Ticket getTicket();
public void minusAmount(Long amount) {
this.amount -= amount;
}
public void plusAmount(Long amount) {
this.amount += amount;
}
}
public class TicketOffice extends Office {
private List<Ticket> tickets = new ArrayList<>();
@Override
public Ticket getTicket() {
return tickets.remove(0);
}
}
public class TicketSeller {
private Office office;
public TicketSeller(Office office) {
this.office = office;
}
public void sellTo(Audience audience) {
office.plusAmount(audience.buy(office.getTicket()));
}
}
마무리
모든 코드에는 합당한 이유가 있어야 한다. 아주 사소한 결정이더라도 트레이드오프를 통해 얻은 결론과 그렇지 않은 결론 사이의 차이는 크다.