이 책을 관통하는 첫 번째 예제, 영화 예매 시스템이 여기서 등장한다. 상영(Screening)을 예매하면 예매 정보(Reservation)가 나온다. 요금은 영화의 기본 가격에서 할인을 뺀 값인데, 할인 방식이 여럿이다 — 특정 금액을 깎거나(금액 할인), 비율로 깎거나(비율 할인), 아예 할인이 없거나. 이 “여러 방식 중 하나”를 어떻게 코드에 담느냐가 이 장의 주제다. 조영호는 추상화와 다형성이 왜 절차적 분기보다 나은지를 코드가 진화하는 과정으로 보여준다.
클래스가 아니라 협력이 먼저다
객체지향을 “클래스를 잘 나누는 기술”로 이해하면 출발점부터 어긋난다. 조영호는 이 장을 클래스가 아니라 협력에서 시작하라고 못박는다. 중요한 것은 어떤 클래스가 필요한가가 아니라, 어떤 객체들이 필요하며 그 객체들이 어떤 메시지를 주고받아야 하는가다. 클래스는 그 객체들을 구현하는 도구일 뿐, 목적이 아니다.
그래서 이 장의 코드도 “Movie 클래스를 만들자”가 아니라 “영화 요금을 계산하려면 누가 누구에게 무엇을 요청해야 하는가”에서 나온다. Screening이 Movie에게 요금을 묻고, Movie가 DiscountPolicy에게 할인을 묻는다. 이 요청과 응답의 흐름 — 협력 — 을 먼저 그리고, 각 참여자를 클래스로 옮긴다.
판단 기준: 설계를 시작할 때 “무슨 클래스?”가 아니라 “어떤 객체들이 어떤 메시지로 협력하는가?”를 먼저 묻는다. 클래스는 그 답을 담는 그릇이다. 함정: 클래스의 필드부터 채우기 시작하면 데이터 중심 사고로 미끄러진다(4장이 그 파탄을 보여준다) — 협력이 요구하지 않은 데이터와 메서드가 클래스에 쌓인다.
자율적인 객체 — 캡슐화와 접근 제어
좋은 객체는 자율적이다. 자율적이라는 말은, 자기 상태를 스스로 관리하고 그 상태를 어떻게 다룰지도 스스로 결정한다는 뜻이다. 이를 가능하게 하는 두 축이 캡슐화와 접근 제어다.
캡슐화는 객체 내부의 상태와 그것을 다루는 구현을 바깥으로부터 감추는 것이다. 접근 제어는 그 감춤을 언어 차원에서 강제하는 장치다 — private으로 감춘 것과 public으로 연 것을 나눈다. 이 경계가 객체를 두 부분으로 가른다. 바깥에서 접근할 수 있는 퍼블릭 인터페이스와, 오직 내부에서만 접근하는 구현이다.
DiscountPolicy를 보면 calculateDiscountAmount는 public이고 getDiscountAmount는 protected다. 바깥은 “할인 금액을 계산하라”는 퍼블릭 인터페이스만 알고, 실제로 어떻게 계산하는지는 구현 뒤에 숨는다. 인터페이스와 구현을 이렇게 분리하면, 구현이 바뀌어도 인터페이스가 그대로인 한 바깥은 영향받지 않는다. 이것이 자율성이 변경 용이성으로 이어지는 경로다.
판단 기준: 어떤 필드나 메서드를 public으로 열기 전에 “바깥이 이것을 정말 알아야 하는가, 아니면 내 사정인가”를 묻는다. 내 사정이면 감춘다. 함정: 모든 필드에 getter/setter를 기계적으로 다는 관행은 캡슐화를 흉내만 낸다 — 내부 상태가 퍼블릭 인터페이스로 새어 나가면 자율성은 사라진다.
협력에 관한 짧은 이야기 — 메시지와 메서드
자율적인 객체들은 어떻게 함께 일하는가. 유일한 방법은 메시지 전송이다. 한 객체가 다른 객체에게 무언가를 요청할 때, 그 객체의 내부를 직접 건드리는 게 아니라 메시지를 보낸다. Screening은 Movie에게 calculateMovieFee라는 메시지를 보낼 뿐, Movie가 그 요청을 어떻게 처리하는지는 모른다.
메시지를 받은 객체는 스스로 그 메시지를 처리할 방법을 결정한다. 이때 실제로 실행되는 것이 메서드다. 즉 메시지는 “무엇을 해달라”는 요청이고, 메서드는 “그것을 어떻게 하는가”에 대한 그 객체만의 답이다. 이 둘의 분리가 핵심이다 — 보내는 쪽은 메시지만 알고, 받는 쪽이 메서드를 자율적으로 고른다. 같은 메시지에 서로 다른 메서드가 응답할 수 있다는 이 성질이 곧 다형성의 뿌리다.
판단 기준: 객체 사이의 소통은 오직 메시지(퍼블릭 인터페이스 호출)로만 이뤄져야 한다. 상대의 필드를 직접 읽거나 쓰면 그건 소통이 아니라 침범이다. 함정: getter로 데이터를 꺼내 와 호출자가 대신 로직을 돌리는 것은 메시지 전송처럼 보이지만 아니다 — 요청이 아니라 “네 데이터를 내놔라”이고, 실제 일은 여전히 남이 한다.
할인 로직을 어디에 둘 것인가
가장 곧은 길은 Movie가 자기 요금을 계산하면서 할인까지 직접 처리하는 것이다. 하지만 그러면 할인 종류가 늘 때마다 Movie 안의 if가 늘고, 금액 할인과 비율 할인의 계산이 한 클래스에 뒤섞인다. Movie는 “영화”인데 “할인 정책”의 세부까지 떠안는다.
핵심 통찰은 할인 정책이 영화와 독립적으로 변한다는 것이다. 변하는 축이 다르면 클래스도 나눈다. 할인 정책을 DiscountPolicy라는 추상 클래스로 뽑아내고, Movie는 구체적인 정책이 아니라 그 추상 타입에만 의존하게 만든다. 그러면 Movie는 “어떤” 할인인지 몰라도 “할인을 계산하라”고 요청할 수 있다.
판단 기준: 함께 바뀌는 것은 모으고, 다른 이유로 바뀌는 것은 분리한다. 영화의 정보와 할인 계산 방식은 변경 이유가 다르므로 갈라야 한다. 함정: if (type == AMOUNT) ... else if (type == PERCENT) ... 식 분기는 당장은 짧지만, 정책이 하나 늘 때마다 모든 분기 지점을 찾아 고쳐야 하는 산탄총 수술로 자란다.
추상화에 기대고, 구체는 갈아 끼운다
아래 스텝은 Movie에 직접 박힌 할인 로직을 DiscountPolicy 추상 클래스로 끌어내고, 금액/비율 두 구현을 다형성으로 꽂는 과정이다.
public class Movie { private Money fee; private MovieType movieType; private Money discountAmount; private double discountPercent; public Money calculateMovieFee(Screening screening) { if (movieType == MovieType.AMOUNT_DISCOUNT) { return fee.minus(discountAmount); } else if (movieType == MovieType.PERCENT_DISCOUNT) { return fee.minus(fee.times(discountPercent)); } return fee; }}// 할인 종류가 늘 때마다 이 if-else가 늘고,// 금액 할인과 비율 할인의 계산 방식이 Movie 안에 뒤섞인다.
Movie의 calculateMovieFee는 2단계 이후로 한 글자도 바뀌지 않는다. 금액 할인을 쓰든 비율 할인을 쓰든, 새 정책이 추가되든 Movie는 그대로다. 새 할인은 DiscountPolicy를 상속한 클래스를 하나 더 만들어 생성 시점에 꽂아주면 끝이다 — 기존 코드를 열지 않고 기능을 넓히는 개방-폐쇄 원칙이 여기서 나온다.
할인 요금을 구하는 협력의 흐름
코드만 보면 정적이지만, 실행되는 순간 이 객체들은 메시지의 사슬로 협력한다. 예매 요청이 들어와 요금이 정해지기까지의 흐름을 따라가 보자.
sequenceDiagram participant S as Screening participant M as Movie participant D as DiscountPolicy participant C as DiscountCondition S->>M: calculateMovieFee(this) M->>D: calculateDiscountAmount(screening) D->>C: isSatisfiedBy(screening) C-->>D: true / false D-->>M: 할인 금액 (또는 0) M-->>S: fee.minus(할인 금액)
주목할 것은 각 객체가 자기가 아는 것만 처리하고 나머지는 다음 객체에게 넘긴다는 점이다. Screening은 할인이 어떻게 계산되는지 모른 채 Movie에게 요금을 묻는다. Movie는 할인 방식을 모른 채 DiscountPolicy에게 할인을 묻는다. DiscountPolicy는 조건 판별을 DiscountCondition에게 맡긴다. 아무도 남의 일에 끼어들지 않고, 각자 자기 책임만큼만 알고 있다. 이 사슬이 바로 1장에서 말한 “자율적인 존재들의 정중한 대화”가 더 큰 규모로 구현된 모습이다.
판단 기준: 협력 흐름을 그렸을 때 각 화살표가 “요청”이고 각 객체가 자기 정보로 응답한다면 책임 배치가 건강하다. 함정: 한 객체가 여러 단계 아래 객체의 내부까지 직접 아는 화살표(예: Screening이 DiscountCondition을 직접 호출)가 그려지면, 협력의 층이 무너지고 결합이 깊어진 것이다.
컴파일 타임 의존성과 런타임 의존성
이 설계의 묘미는 의존성이 두 겹이라는 데 있다. Movie의 코드는 DiscountPolicy라는 추상 타입에만 의존한다 — 이것이 컴파일 타임 의존성이다. 하지만 실제로 실행될 때 Movie 안의 discountPolicy에는 AmountDiscountPolicy나 PercentDiscountPolicy 같은 구체 인스턴스가 들어 있다 — 이것이 런타임 의존성이다.
classDiagram class Movie { -Money fee -DiscountPolicy discountPolicy +calculateMovieFee(Screening) } class DiscountPolicy { <<abstract>> +calculateDiscountAmount(Screening) #getDiscountAmount(Screening)* } class AmountDiscountPolicy { -Money discountAmount #getDiscountAmount(Screening) } class PercentDiscountPolicy { -double percent #getDiscountAmount(Screening) } Movie --> DiscountPolicy : 컴파일 타임 의존 DiscountPolicy <|-- AmountDiscountPolicy DiscountPolicy <|-- PercentDiscountPolicy Movie ..> AmountDiscountPolicy : 런타임 의존 Movie ..> PercentDiscountPolicy : 런타임 의존
두 의존성이 다를수록 설계는 유연해진다. 코드는 추상에만 묶여 있으니 읽는 사람은 구체를 몰라도 Movie를 이해할 수 있고, 실행은 상황에 맞는 구체를 갈아 끼운다. 대신 그 대가로 코드만 봐서는 실제 어떤 정책이 도는지 알 수 없다 — 실행 흐름을 좇으려면 생성 지점까지 따라가야 한다.
판단 기준: 유연함이 필요한 지점(변경이 잦고 방식이 여럿인 곳)에만 이 분리를 적용한다. 함정: 유연성과 가독성은 반비례한다. 변할 일 없는 곳까지 추상 타입으로 감싸면, 이해하려 클래스 사이를 헤매는 비용만 늘고 얻는 유연함은 쓰이지 않는다.
상속과 다형성, 그리고 그 너머
이 장은 상속으로 추상화를 구현했다. DiscountPolicy를 상속한 자식들이 getDiscountAmount를 저마다 재정의(오버라이딩)하고, Movie는 어느 자식이 왔는지 모른 채 같은 메시지를 보낸다 — 이것이 다형성이다. 다만 상속은 부모-자식을 강하게 묶는 도구이기도 하다. 조영호는 뒤 장(10, 11장)에서 상속의 한계를 짚고 합성으로 갈아타는데, 그 복선이 여기서 시작된다.
업캐스팅 — 자식을 부모의 타입으로 다룬다
다형성이 성립하려면 Movie가 AmountDiscountPolicy 인스턴스를 DiscountPolicy 타입 변수에 담을 수 있어야 한다. 자식 클래스의 인스턴스를 부모 클래스 타입으로 취급하는 이 변환을 업캐스팅이라 한다.
public class Movie {
private DiscountPolicy discountPolicy;
public Movie(DiscountPolicy discountPolicy) { // 부모 타입으로 받는다
this.discountPolicy = discountPolicy;
}
}
// 생성 시점에 어떤 자식이든 꽂을 수 있다 — 모두 DiscountPolicy로 업캐스팅된다.
Movie avatar = new Movie(new AmountDiscountPolicy(Money.wons(800), ...));
Movie titanic = new Movie(new PercentDiscountPolicy(0.1, ...));Movie의 코드는 DiscountPolicy라는 부모 타입만 알기에, 생성자에 어떤 자식이 오든 받아들인다. 컴파일러는 자식이 부모의 인터페이스를 모두 갖췄음을 보장하므로 이 대입이 안전하다. 업캐스팅과 그 반대편의 동적 바인딩(메시지 수신 객체의 실제 타입에 맞는 메서드가 실행 시점에 선택되는 것)이 함께 맞물려 다형성이 완성된다.
판단 기준: 협력하는 객체를 받을 때 구체 타입이 아니라 그 객체가 수행할 역할에 해당하는 추상 타입(부모/인터페이스)으로 받으면, 업캐스팅 덕에 어떤 구현이든 갈아 끼울 수 있다. 함정: 받아 놓고 내부에서 instanceof로 실제 타입을 되묻어 분기하면, 애써 얻은 다형성을 다시 조건문으로 되돌리는 셈이다.
구현 상속과 인터페이스 상속
상속을 쓸 때 두 목적을 구분해야 한다. 구현 상속은 부모의 코드를 물려받아 재사용하는 것이 목적이고, 인터페이스 상속은 부모의 타입을 물려받아 다형성의 대상이 되는 것이 목적이다.
DiscountPolicy를 상속하는 진짜 이유는 코드 재사용이 아니라 — 물론 calculateDiscountAmount의 공통 골격을 물려받긴 하지만 — Movie가 자식들을 DiscountPolicy라는 하나의 타입으로 다룰 수 있게 하기 위해서다. 즉 인터페이스 상속이 본질이다. 조영호는 상속을 코드 재사용의 수단으로만 여기는 태도를 경계한다. 구현을 물려받으려고 상속하면 부모-자식이 구현 수준에서 강하게 묶여, 부모의 사소한 변경이 자식을 깨뜨리는 취약한 결합이 생기기 때문이다.
판단 기준: 상속을 결정할 때 “타입을 공유해 다형적으로 다루려는 것인가(인터페이스 상속), 코드를 재사용하려는 것인가(구현 상속)“를 구분한다. 다형성이 목적이라면 상속이 정당하다. 함정: 순전히 코드 재사용만을 위한 상속은 대개 합성으로 대체하는 편이 낫다 — 이 복선이 10·11장에서 본격적으로 회수된다.
추상화의 힘 — 정책을 높은 수준에서 서술한다
Movie와 DiscountPolicy로 이뤄진 구조가 강력한 이유는, 구체적인 계산을 몰라도 요구사항의 정책을 높은 수준에서 그대로 서술하기 때문이다. “영화 요금은 기본 요금에서 할인 정책이 정한 금액을 뺀 값이다” — 이 문장이 fee.minus(discountPolicy.calculateDiscountAmount(screening)) 한 줄에 그대로 담긴다. 금액이냐 비율이냐는 세부는 추상화 뒤로 숨어 이 문장을 오염시키지 않는다.
추상화가 이렇게 정책의 골격만 남기면 두 가지 이득이 있다. 첫째, 코드가 요구사항을 읽는 언어와 같은 층위에서 읽혀 이해가 쉽다. 둘째, 세부(구체 정책)가 바뀌어도 골격(협력 구조)은 그대로라 유연하다. 추상화는 단지 코드를 줄이는 기술이 아니라, 변하지 않는 것과 변하는 것을 갈라 변하지 않는 것만 코드의 뼈대로 남기는 사고 도구다.
판단 기준: 추상화가 잘 됐다면, 코드의 상위 골격이 요구사항 문장과 거의 일대일로 대응해야 한다. 함정: 추상화 수준을 섞으면(높은 수준의 정책과 낮은 수준의 세부가 한 메서드에 공존) 그 문장이 세부에 파묻혀 읽히지 않는다.
유연한 설계 — 할인이 없는 영화
추상화가 만든 유연함을 실감하는 시나리오 하나. “할인 정책이 아예 없는 영화”라는 요구가 새로 들어왔다고 하자. 데이터 중심 코드라면 요금 계산부의 분기에 NONE_DISCOUNT 케이스를 하나 더 뚫어야 한다. 하지만 이 설계에서는 DiscountPolicy를 상속한 클래스를 하나 더 추가하기만 하면 된다.
public class NoneDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
// 사용하는 쪽은 특별한 처리 없이 그냥 꽂는다.
Movie starWars = new Movie(new NoneDiscountPolicy());Movie는 물론이고 기존 어떤 코드도 열지 않는다. “할인 없음”조차 null이나 특별 케이스가 아니라, 다른 정책들과 똑같이 취급되는 하나의 정책 객체로 표현된다. 특수한 경우를 조건문 대신 객체로 흡수하는 이 방식(널 객체 패턴의 사례)이 개방-폐쇄 원칙이 실제로 작동하는 모습이다. 새 요구가 기존 코드의 수정이 아니라 새 코드의 추가로 흡수되는 것 — 이것이 유연한 설계의 정의다.
판단 기준: 새 요구가 왔을 때 기존 코드를 열지 않고 새 클래스 추가만으로 흡수된다면, 그 지점의 추상화가 제대로 작동한 것이다. 함정: “할인 없음”을 null로 처리하면 사용하는 쪽마다 null 검사 분기가 번식한다 — 특수 케이스도 객체로 만들면 분기가 사라진다.
상속을 넘어 — 합성이라는 예고
이 장의 유연함은 상속과 다형성으로 얻었지만, 상속은 부모-자식을 컴파일 시점에 강하게 묶는다는 대가가 있다. 그 대안이 합성 — 필요한 기능을 가진 객체를 필드로 품고 메시지로 위임하는 방식이다. 사실 Movie가 DiscountPolicy를 필드로 품고 calculateDiscountAmount 메시지를 위임하는 것 자체가 이미 합성이다. Movie와 할인 정책의 관계는 상속이 아니라 합성으로 맺어져 있고, 이 덕분에 실행 중에 정책을 갈아 끼우는 유연함까지 가능해진다.
즉 이 설계는 이미 상속(정책들 사이의 다형성)과 합성(Movie와 정책의 관계)을 함께 쓰고 있다. 조영호가 뒤에서 상속을 합성으로 대체하라고 말할 때, 그 씨앗이 여기 이미 뿌려져 있는 셈이다.
판단 기준: “이 관계는 is-a(상속)인가, has-a(합성)인가”를 물어 관계의 성격에 맞는 도구를 고른다. Movie는 할인 정책의 한 종류가 아니라 할인 정책을 “가진” 것이므로 합성이 맞다. 함정: 합성으로 충분한 관계를 상속으로 표현하면, 유연성(실행 중 교체)을 잃고 강한 결합만 얻는다.
- 변하는 것(할인 정책)을 추상 타입 뒤로 숨기고, 변하지 않는 협력 구조(
Movie→DiscountPolicy)만 코드에 남긴다. - 코드의 의존성(컴파일 타임)과 실행의 의존성(런타임)을 일부러 어긋나게 만들면 유연해진다. 그 유연함이 필요한 곳에만 쓴다.
- 다형성은 조건 분기를 객체 선택으로 대체한다.
if가 사라진 자리를 “어떤 객체를 꽂았는가”가 대신한다. - 상속의 진짜 목적은 코드 재사용이 아니라 타입 공유(인터페이스 상속)이며, 관계의 성격이 has-a라면 상속보다 합성이 맞다.
다음장으로 3장