이번 장은 뒤에서 다룰 다양한 주제들을 얕게 살펴보는 장이다.
온라인 영화 예매 시스템 예제를 다뤄보자.
1. 영화 예매 시스템
요구사항
- 영화 (movie) : 영화가 가지고 있는 기본적인 정보
- 상영 (screening) : 관객들이 영화를 관람하는 사건
- 할인 조건 (discount condition) : 할인 여부를 결정
- 순서 조건: 상영 순번을 이용해 할인 여부를 결정
(ex. 매일 10번째로 상영되는 영화에 대해 할인) - 기간 조건: 상영 시작 시간을 이용해 할인 여부 결정
(ex. 매주 월요일 오전 10시부터 오후 1시 사이에 상용되는 영화에 대해 할인)
- 순서 조건: 상영 순번을 이용해 할인 여부를 결정
- 할인 정책 (discount policy) : 할인 요금을 결정
- 금액 할인 정책: 일정 금액을 할인
- 비율 할인 정책: 일정 비율의 요금을 할인
영화별로 최대 하나의 할인 정책만 할당할 수 있는 반면,
할인 조건은 다수의 할인 조건을 함께 지정할 수 있으며, 순서 조건과 기간 조건을 섞을 수도 있다.
할인을 적용하기 위해서는 할인 조건과 할인 정책을 함께 조합해서 사용한다.
사용자가 예메를 완료하면 시스템은 예매 정보를 생성한다.
예매 정보에는 제목, 상영정보, 인원, 정가, 결제금액이 포함된다.
2. 객체지향 프로그래밍을 향해
협력, 객체, 클래스
객체지향은 객체를 지향하는 것이다. 클래스가 아닌 객체에 초점을 맞춰야 한다.
1. 어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민하라.
클래스는 공통적인 객체들을 추상화한 것이다.
2. 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다.
훌륭한 협력이 훌륭한 객체를 낳고 훌륭한 객체가 훌륭한 클래스를 낳는다.
도메인의 구조를 따르는 프로그램 구조
도메인: 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야
객체지향 패러다임이 강력한 이유는 요구사항과 프로그램을 객체라는 동일한 관점에서 바라볼 수 있기 때문이다.
따라서 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결될 수 있다.
클래스의 구조는 도메인의 구조와 유사한 형태를 띤다.
클래스 구현하기
상영을 구현하는 Screening 클래스를 구현해보자.
class Screening {
private movie: Movie; // 상영할 영화
private sequence: number; // 순번
private whenScreened: number; // 상영 시작 시간
constructor(movie: Movie, sequence: number, whenScreened: number) {
this.movie = movie;
this.sequence = sequence;
this.whenScreened = whenScreened;
}
// 상영 시작 시간 반환
public getStartTime(): number {
return this.whenScreened;
}
// 순번의 일치 여부 검사
public isSequence(sequence: number): boolean {
return this.sequence === sequence;
}
// 기본 요금 반환
public getMovieFee(): Money {
return this.movie.getFee();
}
}
인스턴스 변수는 private, 메서드는 public이다.
클래스를 구현할 때 가장 중요한 것은 클래스의 경계를 구분 짓는 것이다.
외부에서는 객체의 속성에 직접 접근할 수 없도록 막고, 적절한 public 메서드를 통해서만 내부 상태를 변경할 수 있게 해야 한다.
경계의 명확성이 객체의 자율성을 보장하며, 프로그래머에게 구현의 자유를 제공한다.
자율적인 객체
- 객체는 상태와 행동을 함께 가지는 복합적인 존재
- 객체는 스스로 판단하고 행동하는 자율적인 존재
객체지향은 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶어 캡슐화한다.
대부분의 객체지향 언어들은 캡슐화에서 더 나아가 외부에서의 접근을 통제할 수 있는 접근 제어 메커니즘도 함께 제공한다.
(public, protected, private과 같은 접근 수정자)
캡슐화의 접근 제어는 객체를 두 부분으로 나눈다.
- 퍼블릭 인터페이스: 외부에서 접근 가능한 부분
- 구현: 외부에서는 접근 불가능하고 오직 내부에서만 접근 가능한 부분
인터페이스와 구현의 분리 원칙은 객체지향의 핵심 원칙이다.
객체의 상태는 숨기고 행동만 외부에 공개해야 한다.
클래스의 속성은 private으로, 외부에 제공해야 하는 일부 메서드만 public으로 선언해야 한다.
서브클래스나 내부에서만 접근 가능해야 할 메서드들은 protected나 private로 지정해야 한다.
- 퍼블릭 인터페이스: public으로 지정된 메서드
- 구현: private 또는 protected 메서드와 속성
프로그래머의 자유
프로그래머의 역할
- 클래스 작성자: 새로운 데이터 타입을 프로그램에 추가
- 클라이언트 프로그래머: 클래스 작성자가 추가한 데이터 타입을 사용
클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만 공개하고 나머지는 숨김으로써,
클라이언트 프로그래머에 대한 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있다. (구현 은닉)
접근 제어 메커니즘
- 클래스의 내부와 외부를 명확하게 경계 지을 수 있게 함
- 클래스 작성자가 내부 구현을 은닉할 수 있게 함
- 클라이언트 프로그래머가 실수로 숨겨진 부분에 접근하는 것을 막아줌
설계가 필요한 이유는 변경을 관리하기 위해서다.
객체의 변경을 관리하는 기법 중 대표적인 것이 접근 제어다.
협력하는 객체들의 공동체
Screening 클래스에 영화를 얘메하는 기능을 넣어보자.
reserve 메서드는 영화를 예매한 후 예매 정보를 담고 있는 Reservation의 인스턴스를 생성해서 반환한다.
class Screening {
...
// 영화를 예매한 후 예매 정보를 담고 있는 인스턴스를 생성해서 반환
public reserve(
customer: CustomElementRegistry, // 예매자 정보
audienceCount: number // 인원수
): Reservation {
return new Reservation(
customer,
this,
this.calculateFee(audienceCount), //요금 계산 결과
audienceCount
);
}
private calculateFee(audienceCount: number): Money {
// 1인 당 예매 요금 * 인원 수
return this.movie.calculateMovieFee(this).times(audienceCount);
}
}
Money는 금액과 관련된 다양한 계산을 구현하는 클래스다.
class Money {
public ZERO: Money = Money.wons(0);
private amount: number;
constructor(amount: number) {
this.amount = amount;
}
public static wons(amount: number): Money {
return new Money(amount);
}
public plus(amount: Money): Money {
return new Money(this.amount + amount.amount);
}
public minus(amount: Money): Money {
return new Money(this.amount - amount.amount);
}
public times(percent: number): Money {
return new Money(this.amount * (percent / 100));
}
public isLessThan(other: Money): boolean {
return this.amount < other.amount;
}
public isGreaterThanOrEqual(other: Money): boolean {
return this.amount >= other.amount;
}
}
Money 타입은 일반 number 타입과 달리, 저장하는 값이 금액과 관련돼 있다는 의미를 전달한다.
또한 금액과 관련된 로직이 서로 다른 곳에 중복되어 구현되는 것을 막을 수 있다.
객체지향의 장점은 객체로 도메인의 의미를 풍부하게 표현할 수 있다는 것이다.
Reservation 클래스도 구현해보자.
class Reservation {
private customer: CustomElementRegistry;
private screening: Screening;
private fee: Money;
private audienceCount: number;
constructor(
customer: CustomElementRegistry,
screening: Screening,
fee: Money,
audienceCount: number
) {
this.customer = customer;
this.screening = screening;
this.fee = fee;
this.audienceCount = audienceCount;
}
}
Screening, Movie, Reservation 인스터들은 서로의 메서드를 호출하며 협력한다.
협력의 관점에서 어떤 객체가 필요한지 결정한 후, 클래스를 작성해야 한다.
객체는 다른 객체의 인터페이스에 공개된 행동을 구행하도록 요청할 수 있다.
요청을 받은 객체는 자율적인 방법에 따라 요청을 처리한 후 응답한다.
객체끼리 상호작용할 수 있는 유일한 벙법은 메시지를 전송하는 것뿐이다.
수신된 메시지를 처리하기 위한 자신만의 방법을 메서드라고 한다.
메시지와 메서드를 구분해야 한다. 여기에서 다형성의 개념이 출발한다.
Screening이 Movie의 calculateMovieFee 메서드를 호출한다는 표현보다,
calculateMovieFee 메시지를 전송한다는 표현이 더 적절하다.
사실 Screening은 Movie 안에 calculateMovieFee 메서드의 존재도 알지 못한다.
메시지에 응답할 수 있다고 믿고 메시지를 전송할 뿐이다.
3. 할인 요금 구하기
Movie 클래스를 구현해보자.
class Movie {
private title: string;
private runningTime: number;
private fee: Money;
private discountPolicy?: DiscountPolicy;
constructor(
title: string,
runningTime: number,
fee: Money,
discountPolicy?: DiscountPolicy
) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountPolicy = discountPolicy;
}
public getFee(): Money {
return this.fee;
}
public calculateMovieFee(screening: Screening): Money {
if (!this.discountPolicy) {
return this.fee;
}
return this.fee.minus(
this.discountPolicy.calculateDiscountAmount(screening)
);
}
}
calculateMovieFee 메서드에는 어떤 할인 정책을 사용할 것인지 결정하는 코드가 없다.
할인 정책과 할인 조건
금액 할인 정책과 비율 할인 정책을 각각 AmountDiscountPolicy와 PercentDiscountPolicy라는 클래스로 구현하자.
두 클래스는 대부분의 코드가 유사하다. 중복을 제거하기 위해 부모 클래스인 DiscountPolicy를 두자.
실제 애플리케이션에서는 DiscountPolicy의 인스턴스를 생성할 필요가 없으므로 추상 클래스로 구현하자.
interface DiscountCondition {
isSatisfiedBy(screening: Screening): boolean;
}
abstract class DiscountPolicy {
private conditions: DiscountCondition[]; // 하나의 할인 정책은 여러 개의 할인 조건 포함 가능
constructor(conditions: DiscountCondition[]) {
this.conditions = conditions;
}
public calculateDiscountAmount(screening: Screening): Money {
this.conditions.forEach((condition) => {
if (condition.isSatisfiedBy(screening)) {
// 할인 조건을 만족하면 추상 메서드 호출
return this.getDiscountAmount(screening);
}
});
return new Money(0).ZERO;
}
protected abstract getDiscountAmount(screening: Screening): Money;
}
할인 여부와 요금 계산에 필요한 전체적인 흐름은 정의하지만, 실제로 요금을 계산하는 부분은 추상 메서드에게 위임한다.
실제로는 DiscountPolicy를 상속받은 자식 클래스에서 오버라이딩한 메서드가 실행될 것이다.
부모 클래스에 기본적인 알고리즘의 흐름을 구현하고,
중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴이라고 한다.
순번 조건과 기간 조건의 두 가지 할인 조건은 각각 SequenceCondition과 PeriodCondition이라는 클래스로 구현하자.
class SequenceCondition implements DiscountCondition {
private sequence: number; // 할인 여부를 판단하기 위해 사용할 순번
constructor(sequence: number) {
this.sequence = sequence;
}
public isSatisfiedBy(screening: Screening): boolean {
return screening.isSequence(this.sequence); // 상영 순번과 일치할 경우 true
}
}
class PeriodCondition implements DiscountCondition {
private dayOfWeek: number; // 요일
private startTime: number; // 시작 시간
private endTime: number; // 종료 시간
constructor(dayOfWeek: number, startTime: number, endTime: number) {
this.dayOfWeek = dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
public isSatisfiedBy(screening: Screening): boolean {
return (
// 상영 요일이 같고 시작 시간이 startTime과 endTime 사이에 있을 경우 true
screening.getStartTime() === this.dayOfWeek &&
this.startTime <= screening.getStartTime() &&
this.endTime >= screening.getStartTime()
);
}
}
이제 할인 정책을 구현하자.
class AmountDiscountPolicy extends DiscountPolicy {
private discountAmount: Money;
constructor(discountAmount: Money, conditions: DiscountCondition[]) {
super(conditions);
this.discountAmount = discountAmount;
}
protected getDiscountAmount(screening: Screening) {
return this.discountAmount;
}
}
class PercentDiscountPolicy extends DiscountPolicy {
private percent: number;
constructor(percent: number, conditions: DiscountCondition[]) {
super(conditions);
this.percent = percent;
}
protected getDiscountAmount(screening: Screening) {
return screening.getMovieFee().times(this.percent);
}
}
- 오버라이딩: 부모 클래스에 정의된 메서드를 자식 클래스에서 재정의하는 것
- 오버로딩: 메서드의 이름은 같지만 파라미터가 다른 것
할인 정책은 하나만, 할인 조건은 여러 개 적용할 수 있다. Movie와 DiscountPolicy의 생성자는 이런 제약을 강제한다.
생성자의 파라미터 목록을 이용해 초기화에 필요한 정보를 전달하도록 강제하면 올바른 상태를 가진 객체의 생성을 보장할 수 있다.
const avatar = new Movie(
"아바타",
120,
Money.wons(10000),
new AmountDiscountPolicy(
Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10),
new PeriodCondition(1, 10, 12),
new PeriodCondition(2, 10, 20)
)
);
const titanic = new Movie(
"타이타닉",
180,
Money.wons(11000),
new PercentDiscountPolicy(
0.1,
new PeriodCondition(2, 14, 16),
new SequenceCondition(2),
new PeriodCondition(2, 10, 13)
)
);
4. 상속과 다형성
컴파일 시간 의존성과 실행 시간 의존성
Movie 클래스는 코드 수준에서 DiscountPolicy 클래스에 의존하고 있다.
그러나 영화 요금을 계산하기 위해서는 Movie의 인스턴스는 실행 시에 AmountDiscountPolicy나 PercentDiscountPolicy의 인스턴스에 의존해야 한다.
Movie의 인스턴스를 생성할 때, 생성자에서 AmountDiscountPolicy나 PercentDiscountPolicy의 인스턴스를 전달받기 때문에 가능한 것이다.
코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다.
즉, 클래스 사이의 의존성과 객체 사이의 의존성은 서로 다를 수 있다.
코드의 의존성과 시행 시점의 의존성이 다를수록 코드는 유연해지고 확장 가능해지지만, 이해하기 어려워진다.
설계야 유연해질수록 코드를 이해하고 디버깅하기는 어려워진다.
차이에 의한 프로그래밍
클래스의 코드를 수정하지 않고도 재사용하게 해주는 방법이 상속이다.
AmountDiscountPolicy와 PercentDiscountPolicy 클래스는 DiscountPolicy에 정의된 모든 속성과 메서드를 그대로 물려받는다.
- 기존 클래스를 기반으로 새로운 클래스를 쉽고 빠르게 추가할 수 있다.
- 부모 클래스의 구현은 공유하면서도 행동이 다른 자식 클래스를 쉽게 추가할 수 있다.
부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 만드는 것을 차이에 의한 프로그래밍이라고 한다.
상속과 인터페이스
상속의 목적: 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받게 하기 위함
따라서 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다.
AmountDiscountPolicy와 PercentDiscountPolicy 모두 DiscountPolicy를 대신해서 Movie와 협력할 수 있다.
자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅이라고 한다.
- 구현 상속 (서브클래싱)
- 순수하게 코드를 재사용하기 위한 목적으로 상속을 사용
- 인터페이스 상속 (서브타이핑)
- 다형적인 협력을 위해 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록 상속을 사용
인터페이스 상속을 사용해야 한다.
다형성
다형성: 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력
코드 상에서 Movie는 DiscountPolicy에게 메시지를 전송하지만, 실행 시점에 실제로 실행되는 메서드는 달라질 수 있다.
다형성은 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반으로 한다.
이 특성을 이용해 서로 다른 메서드를 실행할 수 있게 한다.
다형적인 협력에 참여하는 객체들은 인터페이스가 동일해야한다.
AmountDiscountPolicy와 PercentDiscountPolicy는 DiscountPolicy로부터 상속을 통해 동일한 인터페이스를 물려받았다.
다형성을 구현하는 방법은 메시지와 메서드를 실행 시점에 바인딩하는 것이다. (지연 바인딩/동적 바인딩)
전통적인 방식은 컴파일 시점에 실행될 함수나 프로시저를 결정한다. (초기 바인딩/정적 바인딩)
클래스를 상속받는 것만이 다형성을 구현하는 유일한 방법은 아니다.
다형성이란 추상적인 개념이며 이를 구현할 수 있는 방법은 다양하다.
인터페이스와 다형성
DiscountPolicy의 경우 추상 클래스로 구현함으로써 자식 클래스가 인터페이스와 내부 구현을 함께 상속받도록 했다.
구현은 공유할 필요 없이 인터페이스만 공유하고 싶을 때는 DiscountCondition처럼 인터페이스를 사용하면 된다.
5. 추상화와 유연성
할인 정책은 금액 할인 정책과 비율 할인 정책을 포괄하는 추상적인 개념이다.
할인 조건은 순번 조건과 기간 조건을 포괄하는 추상적인 개념이다.
DiscountPolicy와 DiscountCondition는 같은 계층의 클래스가 공통으로 가질 수 있는 인터페이스를 정의하므로 더 추상적이다.
추상화의 장점
- 추상화의 계층만 따로 떼어 놓고 보면 요구사항의 정책을 높은 수준에서 서술할 수 있다.
- 추상화를 이용하면 설계가 좀 더 유연해진다.
추상화를 사용하면 세부적인 내용을 무시하고 상위 개념으로 도메인을 설명할 수 있다.
재사용 가능한 설계의 기본을 이루는 디자인 패턴이나 프레임워크 모두 추상화를 이용해 상위 정책을 정의한다.
유연한 설계
할인 정책이 적용돼 있지 않은 영화의 경우, 예외 케이스로 취급되기 때문에 일관성 있던 협력 방식이 무너지게 된다.
class Movie {
...
public calculateMovieFee(screening: Screening): Money {
if (!this.discountPolicy) {
return this.fee;
}
return this.fee.minus(
this.discountPolicy.calculateDiscountAmount(screening)
);
}
}
할인 정책이 있는 경우, 할인 금액을 계산하는 책임은 DiscountPolicy의 자식 클래스에 있지만
할인 정책이 없는 경우, 할인 금액을 0원으로 결정하는 책임이 Movie에 있다.
책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 좋지 않다.
NoneDiscountPolicy 클래스를 추가하자.
class NoneDiscountPolicy extends DiscountPolicy {
protected getDiscountAmount(screening: Screening) {
return new Money(0).ZERO;
}
}
const starWars = new Movie(
"스타워즈",
210,
Money.wons(10000),
new NoneDiscountPolicy()
);
기존의 Movie와 DiscountPolicy는 수정하지 않고 NoneDiscountPolicy라는 새로운 클래스를 추가하는 것만으로 앱 기능을 확장했다.
추상화는 설계가 구체적인 상황에 결합되는 것을 방지한다.
Movie는 DiscountPolicy를 상속받는 어떤 클래스와도 협력이 가능하다.
DiscountPolicy는 DiscountCondition을 상속받는 어떤 클래스와도 협력이 가능하다.
추상 클래스와 인터페이스 트레이드오프
할인 조건이 없을 경우 DiscountPolicy에서 getDiscountAmount()를 호출하지 않기 때문에,
NoneDiscountPolicy의 getDiscountAmount()가 어떤 값을 반환하더라도 상관없게 된다.
DiscountPolicy를 인터페이스를 바꾸고, NoneDiscountPolicy가 calculateDiscountAmount()를 오버라이딩하도록 변경하자.
interface DiscountPolicy {
calculateDiscountAmount(screening: Screening): Money;
}
abstract class DefaultDiscountPolicy implements DiscountPolicy {
...
}
class AmountDiscountPolicy extends DefaultDiscountPolicy {
...
}
class PercentDiscountPolicy extends DefaultDiscountPolicy {
...
}
class NoneDiscountPolicy implements DiscountPolicy {
public calculateDiscountAmount(screening: Screening): Money {
return new Money(0).ZERO;
}
}
이상적으로는 수정한 설계가 더 좋지만, 현실적으로는 과할 수도 있다.
구현과 관련된 모든 것들은 트레이드오프의 대상이 될 수 있다.
코드 재사용과 상속
코드 재사용을 위해서는 상속보다는 합성이 더 좋은 방법이다.
합성: 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법
Movie가 DiscountPolicy의 코드를 재사용하는 방법이 바로 합성이다.
Movie를 직접 상속받는 AmountDiscountMovie와 PercentDescountMovie를 구현할 수도 있다.
상속은 두 가지 관점에서 설계에 안 좋은 영향을 미친다.
1. 상속은 캡슐화를 위반한다.
상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알고 있어야 한다.
부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다.
캡슐화의 약화는 자식 클래스가 부모 클래스에 강하게 결합되도록 만든다.
2. 상속은 설계를 유연하지 못하게 만든다.
상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정한다.
실행 시점에 객체의 종류를 변경하는 것이 불가능하다.
실행 시점에 금액 할인 정책인 영화를 비율 할인 정책으로 변경한다면?
상속을 사용한 경우 AmountDiscountMovie의 인스턴스를 PercentDescountMovie의 인스턴스로 변경해야 한다.
이미 생성된 객체의 클래스를 변경할 수는 없다.
최선의 방법은 PercentDescountMovie의 인스턴스 생성 후 AmountDiscountMovie의 상태를 복사하는 것뿐이다.
반면 합성의 경우는 간단하다.
class Movie {
...
public changeDiscountPolicy(discountPolicy: DiscountPolicy): void {
this.discountPolicy = discountPolicy;
}
}
const starWars = new Movie(
"스타워즈",
210,
Money.wons(10000),
new NoneDiscountPolicy()
);
starWars.changeDiscountPolicy(new PercentDiscountPolicy(0.1));
상속보다 인스턴스 변수로 관계를 연결한 설계가 더 유연하다.
Movie가 DiscountPolicy를 포함하는 방법이 합성이며, 역시 코드를 재사용하는 방법이다.
합성
상속이 부모와 자식의 코드를 컴파일 시점에 하나의 단위로 강하게 결합하는 반면,
상속은 DiscountPolicy의 인터페이스를 통해 약하게 결합된다.
Movie는 DiscountPolicy의 내부 구현에 대해서는 알지 못한다.
합성은 인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법이다.
상속은 캡슐화를 위반하지만, 합성은 구현을 효과적으로 캡슐화할 수 있다.
상속은 설계를 유연하지 못하게 만들지만, 합성은 의존하는 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만든다.
- 코드를 재사용하는 경우: 상속보다는 합성
- 다형성을 위해 인터페이스를 재사용하는 경우: 상속과 합성을 함께 조합
다형성은 지연 바인딩 메커니즘으로 구현된다!
상속은 코드를 재사용할 수 있는 대표적인 방법이지만, 캡슐화의 측면에서는 합성이 더 좋다!
유연한 객체지향 프로그램을 위해서는 컴파일 시간 의존성과 실행 시간 의존성이 달라야 한다!