2장. 객체지향 프로그래밍
1. 영화 예매 시스템
요구사항
앞으로의 설명을 위해 '영화'와 '상영'이라는 용어를 구분할 필요가 있다.
'영화'는 영화에 대한 기본 정보를 의미한다. 제목, 상영시간, 가격정보 등을 가지고 있다.
'상영'은 실제로 관객들이 영화를 관람하는 사건을 표현한다. 상영일자, 시간, 순번 등을 말한다.
영화는 하루 중 다양한 시간대에 걸쳐 한 번 이상 상영될 수 있다.
두 용어의 차이가 중요한 이유는 사용자가 실제로 예매하는 대상은 영화가 아니라 상영이다!
사람들은 영화를 예매한다고 표현하지만 실제로는 특정 시간에 상영되는 영화를 관람할 수 있는 권리를 구매하기 위해 돈을 지불한다.
특정한 두가지 조건을 만족하는 예매자는 요금을 할인받을 수 있다.
- 할인 조건 (discount condition)
- 할인 정책 (dicsount policy)
할인 조건
'할인 조건'은 가격의 할인 여부를 결정하며 '순서 조건'과 '기간 조건'의 두 종류로 나눌 수 있다.
- '순서 조건'은 상영 순번을 이용해 할인 여부를 결정하는 규칙이다. (ex. 순서조건의 순번이 10번이면 매일 10번째로 상영되는 영화를 예매하면 할인 혜택 제공)
- '기간 조건'은 영화 상영 시작 시간을 이용해 할인 여부를 결정한다. (ex. 월요일 오전10시 ~ 오후 1시 까지 모든 영화에 대해 할인 혜택 제공)
할인 정책
'할인 정책'은 할인 조건에 부합할 때, 할인 요금을 어떤 방식으로 책정할지 결정한다.
할인 정책은 '금액 할인 정책'과 '비율 할인 정책'이 있다.
- '금액 할인 정책'은 예매 요금에서 일정 금액을 할인해준다.
- '비율 할인 정책'은 정가에서 일정 비율의 요금을 할인해준다.
영화별로 하나의 할인 정책만 할당할 수 있다. 물론 할인 정책을 지정하지 않는 것도 가능하다.
이와 달리 할인 조건은 다수의 할인 조건을 함께 지정할 수 있다.
할인을 적용하기 위해서는 할인 조건과 할인 정책을 함께 조합해서 사용한다.
2. 객체지향 프로그래밍을 향해
협력, 객체 , 클래스
우리는 보통 객체지향 프로그래밍을 작성할 때 어떤 클래스가 필요한지를 먼저 떠올린다.
진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞출 때에만 얻을 수 있다.
어떤 클래스가 필요할지를 어떤 객체들이 필요한지 고민하라.
클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결정해야 한다.
객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야한다.
객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현하라.
도메인의 구조를 따르는 프로그램 구조
도메인(domain) : 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야
객체지향 패러다임이 강력한 이유는 요구사항을 분석하는 초기 단계부터 프로그램을 구현하는 마지막 단계까지 객체라는 동일한 추상화 기법을 사용할 수 있기 때문이다. 일반적으로 클래스의 이름은 대응되는 도메인 개념의 이름과 동일하거나 적어도 유사하게 지어야 한다. 클래스 사이의 관계도 또한 마찬가지다. 그렇게 도메인과 클래스를 아래와 같이 만들 수 있다.
<영화 예매 도메인을 구성하는 타입들의 구조>
<도메인 개념의 구조를 따르는 클래스 구조>
클래스 구현하기
- Screening 클래스
public class Screening {
// 상영할 영화
private Movie movie;
// 하루동안 상영하는 영화 중 몇번째로 상영하는지
private int sequence;
// 상영 시간
private LocalDateTime whenScreened;
public Screening(Movie movie, int sequence, LocalDateTime whenScreened) {
this.movie = movie;
this.sequence = sequence;
this.whenScreened = whenScreened;
}
public LocalDateTime getStartTime() {
return whenScreened;
}
public boolean isSequence(int sequence) {
return this.sequence == sequence;
}
public Money getMovieFee() {
return movie.getFee();
}
// 예매한 후 예매 정보를 담고 있는 Reservation의 인스턴스를 생성해서 반환함
public Reservation reserve(Customer customer, int audienceCount) {
return new Reservation(customer, this, calculateFee(audienceCount),
audienceCount);
}
// 요금을 계산해줌
private Money calculateFee(int audienceCount) {
return movie.calculateMovieFee(this).times(audienceCount);
}
}
위의 코드를 보면 인스턴스 변수의 가시성은 private이고 메서드의 가시성은 public이다. 이와 같이 클래스의 내부와 외부를 구분해야 하는 이유는
- 경계의 명확성이 객체의 자율성을 보장하고
- 구현의 자유를 제공하기 때문이다.
자율적인 객체
객체 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위해서다. 객체가 자율적인 존재로 우뚝 서기 위해서는 외부의 간섭을 최소화해야 한다. 외부에서 객체에게 원하는것을 요청하면, 객체가 스스로 최선의 방법을 결정할 수 있을 것이라는 점을 믿고 기다려야 한다.
데이터와 기능을 객체 내부로 함께 묶는것 을 캡슐화 라고 한다.
그리고 캡슐화에서 한걸음 더 나아가 외부에서의 접근을 통제할 수 있게 하는 것이 접근 제어 이다.
캡슐화와 접근제어는 객체를 두 부분으로 나눈다.
- 외부에서 접근 가능한 퍼블릭 인터페이스(public interface)
- 내부에서만 접근 가능한 부분인 구현(implementation)
이것들은 객체지향 프로그래밍의 핵심 원칙인 **인터페이스와 구현의 분리(separation of interface and implementation)**에서 중요한 역할을 한다.
프로그래밍 언어가 public이나 private 키워드를 지공한다면 클래스의 속성은 private로 선언해서 감추고 외부에 제공해야 하는 일부 메서드만 public으로 선언해야 한다.
이때 퍼블릭 인터페이스에는 public으로 지정된 메서드만 포함된다. 그 밖의 private, protected 메서드, 속성은 구현에 포함된다.
프로그래머의 자유
프로그래머의 역할을 **클래스 작성자(class creator)**와 **클라이언트 프로그래머(client programmer)**로 구분하는 것이 유용하다.
클래스 작성자 : 새로운 데이터 타입을 추가한다. 클래스 작성자는 클라이언트 프로그래머가 필요한 부분만 공개해야 한다. 이처럼 숨겨 놓은 부분에 접근할 수 없도록 방지하는 것을 **구현 은닉(implementation hiding)**이라고 부른다.
클라이언트 프로그래머 : 필요한 클래스들을 엮어서 애플리케이션을 빠르고 안정적으로 구축하는 것이다.
접근 제어 메커니즘
- 클래스의 내부와 외부를 명확하게 경계를 지어줌으로써, 클래스 작성자가 내부 구현을 은닉할 수 있게 해준다.
- 클라이언트 프로그래머가 실수로 숨겨진 부분에 접근하는 것을 막아준다.
구현 은닉
- 내부의 구현은 무시한 채 인터페이스만 알고 있어도 클래스를 사용할 수 있다.
- 클래스 작성자가 인터페이스를 바꾸지 않는 한 내부 구현을 마음대로 변경할 수 있다.
협력하는 객체들의 공동체
Money 클래스에 대해 살펴보자. Money는 금액과 관련된 다양한 계산을 구현하는 간단한 클래스다.
public class Money {
public static final Money ZERO = Money.wons(0);
private final BigDecimal amount;
public static Money wons(long amount) {
return new Money(BigDecimal.valueOf(amount));
}
public static Money wons(double amount) {
return new Money(BigDecimal.valueOf(amount));
}
Money(BigDecimal amount) {
this.amount = amount;
}
public Money plus(Money amount) {
return new Money(this.amount.add(amount.amount));
}
public Money minus(Money amount) {
return new Money(this.amount.subtract(amount.amount));
}
public Money times(double percent) {
return new Money(this.amount.multiply(BigDecimal.valueOf(percent)));
}
public boolean isLessThan(Money other) {
return amount.compareTo(other.amount) < 0;
}
public boolean isGreaterThanOrEqual(Money other) {
return amount.compareTo(other.amount) >= 0;
}
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof Money)) {
return false;
}
Money other = (Money)object;
return Objects.equals(amount.doubleValue(), other.amount.doubleValue());
}
public int hashCode() {
return Objects.hashCode(amount);
}
public String toString() {
return amount.toString() + "원";
}
}
1장에서는 금액을 구현하기 위해 Long 타입을 사용했었다. Long 타입은 변수의 크기같이 구현 관점의 제약은 표현할 수 있지만 Money 타입처럼 저장하는 값이 금액과 관련돼 있다는 의미를 전달할 수는 없다. 또한 금액과 관련된 로직이 서로 다른 곳에 중복되어 구현되는것을 막을 수 없다.
객체지향의 장점은 Money처럼 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것이다.
이제 예매 정보를 담게 되는 Reservation 클래스에 대해 알아보자
public class Reservation {
private Customer customer;
private Screening Screening;
private Money fee;
private int audienceCount;
public Reservation(Customer customer, Screening Screening, Money fee, int audienceCount) {
this.customer = customer;
this.Screening = Screening;
this.fee = fee;
this.audienceCount = audienceCount;
}
}
영화를 예매하기 위해 Screening, Movie, Reservation 인스턴스들은 서로의 메서드를 호출하며 상호작용한다. 이처럼 시스템의 어떤 기능을 구현하기 위해 객체들 사이에 이뤄지는 상호작용을 **협력(Collaboration)**이라고 부른다.
< Screening, Reservation, Movie 사이의 협력 >
협력에 관한 짧은 이야기
객체간에 상호작용할 수 있는 유일한 방법은 메시지를 전송(send a message)하는 것과 메시지를 수신(receive a message) 받는것 뿐이다. 이처럼 수신된 메시지를 처리하기 위한 자신만의 방법을 **메서드(method)**라고 부른다.
뒤에서 살펴보겠지만 메시지와 메서드를 명확히 구분하는것으로부터 **다형성(polymorphism)**의 개념이 출발한다.
지금까지는 Screening이 Movie의 calculateMovieFee라는 '메서드를 호출한다'고 말했지만 사실은 Screening이 Movie에게 calculateMovieFee '메시지를 전송한다'라고 말하는 것이 더 적절하다.
사실 Screening은 Movie안에 calculateMovieFee 메서드가 존재하는지 모른다. 단지 Movie가 calculateMovieFee 메시지에 응답할 수 있다고 믿고 메시지를 전송할 뿐이다.
3. 할인 요금 구하기
할인 정책과 할인 조건
할인 정책은 금액 할인 정책과 비율 할인 정책으로 구분된다.
부모 클래스인 DiscountPolicy 안에 중복 코드를 두고 AmountDiscountPolicy, PercentDiscountPolicy가 이 클래스를 상속받게 할 것이다. 실제 애플리케이션에서는 DiscountPolicy의 인스턴스를 생성할 필요가 없기 때문에 **추상 클래스(abstract class)**로 구현했다.
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);
}
이처럼 부모 클래스에 공통적인 알고리즘을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴이라고 한다.
4. 상속과 다형성
영화 할인 정책이 금액과 비율 어떤것을 기준으로 하는지 알려면 AmountDiscountPolicy, PercentDiscontPolicy 중 하나랑 연결되어야한다.
(어떤 클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나, 해당 클래스의 객체의 메서드를 호출할 경우 두 클래스 사이에 의존성이 존재한다고 말한다.)
Movie가 영화 요금을 계산하기 위해서는 DiscountPolicy가 아니라 그 자식 클래스가 필요하다.
따라서 Movie는 DiscountPolicy의 자식 클래스에 의존해야 한다.
하지만 코드 수준에서 Movie는 두 클래스 중 어떤 것에도 의존하지 않는다.
하지만 DiscountPolicy를 통해서 두 클래스 중 어떤것이든 될 수 있기 때문에 Movie 인스턴스를 생성해줄 때 DiscountPolicy의 자리에 자식 클래스를 사용하면 된다. 그렇게 되면 Movie는 DiscountPolicy의 자식클래스를 의존하게 될 것이다.
UML로 따졌을때는 Movie가 DiscountPolicy를 의존하고 있지만, 객체를 만들고나면 자식클래스를 의존하게 되므로 달라진다. 즉, 클래스 사이의 의존성과 객체 사이의 의존성은 동일하지 않을 수 있다.
코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 (= 설계가 유연해질수록)
- 코드를 이해하기 어려워진다. (단점)
- 코드는 더 유연해지고 확장 가능해진다. (장점)
설계가 유연해질수록
- 디버깅 하기 어려워진다.
- 재사용성과 확장 가능성이 높아진다.
차이에 의한 프로그래밍(programming by difference) : 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법
상속과 인터페이스
자식 클래스는 상속을 통해 부모 클래스의 인터페이스를 물려받기 때문에 부모 클래스 대신 사용될 수 있다. 이와 같은 이유 덕분에 Movie의 생성자에서 인자의 타입이 DiscountPolicy더라도, 그것의 자식 클래스의 인스턴스를 전달할 수 있게 된다.
이처럼 자식 클래스가 부모 클래스를 대신하는 것을 **업캐스팅(upcasting)**이라고 부른다.
다형성
다시 한번 강조하지만 메시지와 메서드는 다른 개념이다.
Movie는 DiscountPolicy의 인스턴스에게 calculateDiscountAmount 메시지를 전송한다.
저 메시지에 의해 실행되는 메서드는 AmountDiscountPolicy, PercentDiscountPolicy 둘 중 오버라이딩한 메서드가 실행될 것이다.
다시 말해서 Movie는 동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다. 이를 다형성이라고 부른다.
다형성은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반으로 한다.
컴파일 의존 시간 의존성은 Movie → DiscountPolicy이지만,
실행 시간 의존성은 Movie → AmountDiscountPolicy or PercentDiscountPolicy로 향한다.
이와 같이 다형성은 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력을의미한다. 따라서 다형적인 협력에 참여하는 객체들은 인터페이스가 동일해야 한다.
다형성을 구현하는 방법은 매우 다양하지만 메시지에 응답하기 위해 실행될 메서드가 실행 시점에 결정한다는 공통점이 있다. 이를 지연 바인딩(lazy binding) 또는 **동적 바인딩(dynamic binding)**이라고 부른다. 그에 반에 전통적인 함수 호출처럼 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것을 초기 바인딩(early binding) 또는 **정적 바인딩(static binding)**이라고 부른다.
객체지향이 컴파일 시점과 실행 시점의 의존성을 분리하고,다형성을 구현할 수 있는 이유가 바로 지연 바인딩이라는 메커니즘을 사용하기 때문이다.
그러나 클래스를 상속 받는 것만이 다형성을 구현할 수 있는 유일한 방법은 아니다. 이에 관한 내용은 추후에 다뤄볼 것이다.
구현 상속과 인터페이스 상속
- 구현 상속(implementation inheritance) = 서브클래싱(subclassing)
- 인터페이스 상속(interface inheritance) = 서브타이핑(subtyping) 구현 상속 → 코드를 재사용 하기 위한 목적 인터페이스 상속 → 부모와 자식이 인터페이스를 공유할 수 있도록 상속을 이용함
상속은 인터페이스 상속을 위해 사용해야 한다. 구현을 재사용할 목적으로 상속을 사용하면 변경에 취약한 코드를 낳게 될 확률이 높다.
인터페이스와 다형성
앞에서는 DiscountPolicy를 추상 클래스로 구현하여 자식 클래스들이 인터페이스와 내부 구현을 함께 상속받았었다.
그러나 인터페이스만 공유하고싶을때도 있다. 그럴때 자바의 인터페이스를 ****이용하면 된다.
자바의 인터페이스는 구현에 대한 고려 없이 다형적인 협력에 참여하는 클래스들이 공유 가능한 외부 인터페이스를 정의한 것이다. 이를 이용하면 아래와 같이 정의할 수 있다.
SequenceCondition과 PeriodContidion은 isSatisfiedBy 메시지를 이해할 수 있기 때문에 클라이언트인 DiscountPolicy의 입장에서 이 둘은 DiscountCondition과 아무 차이도 없다. DisCountCondition을 실체화하는 클래스들은 동일한 인터페이스를 공유하며 대신해서 사용될 수 있다. 이 경우에도 업캐스팅이 적용되며 협력은 다형적이다.
5. 추상화와 유연성
추상화의 힘
할인 정책은 금액할인정책, 비율할인정책을 포괄하는 추상적인 개념이다.
할인 조건 역시 더 구체적인 순번조건, 기간조건을 포괄하는 추상적인 개념이다.
둘 다 같은 계층에 속하는 클래스들이 공통으로 가질 수 있는 인터페이스를 정의하며 구현의 일부(추상클래스인 경우) 또는 전체(자바 인터페이스인 경우)를 자식 클래스가 결정할 수 있도록 결정권을 위임한다.
<추상화의 장점>
- 추상화의 계층만 따로 살펴보면 요구사항의 정책을 높은 수준에서 서술 가능하다.
- 설계가 좀 더 유연해진다.
아래의 그림은 자식클래스를 생략한 코드 구조를 그림으로 표현한 것이다.
장점을 좀 더 자세히 서술해보자.
위의 그림을 하나의 문장으로 정리하면 "영화 예매 요금은 최대 하나의 '할인 정책'과 다수의 '할인 조건'을 이용해 계산할 수 있다."로 표현할 수 있다
위의 문장에서 '두개의 순서조건, 한개의 기간조건'이라는 조건을 좀 더 추상적인 개념들을 사용해서 문장을 작성했다는 사실이 중요하다.
추상화를 사용하면 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있다. 이러한 특징은 상위개념만으로도 도메인의 중요한 개념을 설명할 수 있게 해준다. 추상화를 이용한 설계는 필요에 따라 표현의 수준을 조정하는 것을 가능하게 해준다.
추상화를 이용해 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 협력 흐름을 기술한다는것을 의미한다.
예매 가격을 계산하기 위한 흐름은 항상 Movie → DiscountPolicy로 흐르고, 다시 DiscountCondition을 향해 흐른다.
할인 정책이나 할인 조건의 새로운 자식 클래스들은 추상화를 이용해서 정의한 상위 협력 흐름을 그대로 따르게 된다. 이 개념은 매우 중요한데, 재사용 가능한 설계의 기본을 이루는 **디자인 패턴(design pattern)**이나 프레임워크(framework) 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 메커니즘을 활용하고 있기 때문이다.
두 번째 특징은 첫 번째 특징으로부터 유추할 수 있다.
추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가하고 확장할 수 있다. 다시 말해 설계를 유연하게 만들 수 있다.
유연한 설계
우린 아직 할인 정책이 없는 경우를 생각해보지 않았다. 사실 할인 요금을 계산할 필요 없이 영화에 설정된 기본 금액을 그대로 사용하면 된다.
public class Movie {
public Money calculateMovieFee(Screening screening) {
if (discountPolicy == null) {
return fee;
}
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
이 방식의 문제점은 할인 정책이 없는 경우를 예외 케이스로 취급하기 때문에 지금까지 일관성 있던 협력 방식이 무너지게 된다. 왜냐하면 위의 코드를 보면 할인 금액이 0원이라는 사실을 결정하는 책임이 DiscountPolicy가 아닌 Movie 쪽에 있기 때문이다. 따라서 저러한 코드는 설계 측면에서 대부분의 경우 좋지 않은 선택이다. 항상 예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택하라.
이 경우에 일관성을 지킬 수 있는 방법은 0원이라는 할인 요금을 계산할 책임을 그대로 DiscountPolicy 계층에 유지시키는 것이다. NoneDiscountPolicy 클래스를 추가하자.
public class NoneDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
이제 Movie의 인스턴스에 NoneDiscountPolicy의 인스턴스를 연결해서 할인되지 않는 영화를 생성할 수 있다.
Movie starWars = new Movie(
"스타워즈",
Duration.ofMinutes(210),
Money.wons(10000),
new NoneDiscountPolicy()
);
중요한 것은 기존의 movie와 DiscountPolicy는 수정하지 않고 새로운 클래스를 추가하는 것만으로 애플리케이션의 기능을 확장했다는 것이다. 이처럼 추상화를 중심으로 코드의 구조를 설계하며 ㄴ유연하고 확장 가능한 설계를 만들 수 있다.
추상화가 유연할 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는 것을 방지하기 때문이다. Movie는 특정한 할인 정책에 묶이지 않는다. 할인 정책을 구현한 클래스가 DiscountPolicy를 상속 받고 있다면 어떤 클래스와도 협력이 가능하다.
DiscountPolicy 또한 특정한 할인 조건에 묶여있지 않다. DiscountCondition을 상속받은 어떤 클래스와도 협력이 가능하다. 이것은 두 클래스들이 추상적이기 때문에 가능한 것이다. 8장에서 살펴보겠지만 **컨텍스트 독립성(context independency)**라고 불리는 이 개념은 프레임워크와 같은 유연한 설계가 필수적인 분야에서 그 진가를 발휘한다.
결론은 간단하다. 유연성이 필요한 곳에 추상화를 사용하라.
추상 클래스와 인터페이스 트레이드오프
// DiscountPolicy 클래스
private List<DiscountCondition> conditions = new ArrayList<>();
// 할인 조건이 없으면 Money.ZERO를 리턴한다.
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);
DiscountPolicy의 코드를 자세히 살펴보면
할인 조건이 없다면 if문을 만족하지 못하여 getDiscountAmount() 메서드를 호출하지 않는다.
이것은 부모클래스인 DiscountPolicy와 NoneDiscountPolicy를 개념적으로 결합시킨다.
NoneDiscountPolicy의 개발자는 getDiscountAMount()가 호출되지 않을 경우 DiscountPolicy가 0원을 반환할 것이라는 사실을 가정하고 있기 때문이다.
이 문제를 해결하는 방법은 DiscountPolicy를 인터페이스로 새로 추가하고 NoneDiscountPolicy가 getDiscountAmount() 메서드가 아닌 calculateDiscountAmount() 오퍼레이션을 오버라이딩하도록 변경하는 것이다.
public interface DiscountPolicy {
Money calculateDiscountAmount(Screening screening);
}
원래 DiscountPolicy는 아래와 같이 변경한다.
public abstract class DefaultDiscountPolicy implements DiscountPolicy {
...
}
NoneDiscountPolicy는 getDiscountAmount()가 필요없으니 DiscountPolicy 인터페이스를 구현하도록 변경하면 된다.
public class NoneDiscountPolicy implements DiscountPolicy {
@Override
public Money calculateDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
어떤 설계가 더 좋을까? 이상적으로는 인터페이스를 사용한 설계가 더 좋을 것이다.
하지만 현실적으로는 NoneDiscountPolicy만을 위해 인터페이스를 추가하는것이 과하다고 느껴질 수도 있다. 어쨋든 변경 전의 NoneDiscountPolicy 또한 할인금액이 0원이라는 사실을 전달하기 때문이다.
일단은 설명을 단순화하기 위해 인터페이스를 사용하지 않는 원래의 설계에 기반해서 설명을 이어갈 것이다.
여기서 이야기하고 싶은 사실은 구현과 관련된 모든 것들이 트레이드오프의 대상이 될 수 있다는 사실이다. 비록 아주 사소한 결정이더라고 트레이드오프를 통해 얻어진 결론과 그렇지 않은 결론 사이의 차이는 크다. 고민하고 트레이드오프하라.
상속
상속은 객체지향에서 코드를 재사용하기 위해 널리 사용되는 기법이다.
하지만 상속은 캡슐화를 위반한다는 가장 큰 단점이 있다.
상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알고 있어야 한다.
결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약해진다.
캡슐화의 약화는 자식클래스가 부모클래스에게 강하게 결합되도록 만들기 때문에 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다.
상속의 두번째 단점은 설계가 유연하지 않다는 것이다.
상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정한다. 따라서 실행 시점에 객체의 종류를 변경하는 것이 불가능하다.
그렇기 때문에 아래와 같이 changeDiscountPolicy라는 메서드를 추가해주면 된다.
public class Movie {
private DiscountPolicy discountPolicy;
public void changeDiscountPolicy(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
이렇게 하면 금액할인에서 비율할인으로 변경하려면 간단해진다.
합성
Movie는 요금을 계산하기 위해 DiscountPolicy의 코드를 재사용한다.
합성은 인터페이스에 정의된 메시지를 통해서만 코드를 재사용하기때문에 효과적으로 캡슐화할 수 있다. 또한 의존하는 인터페이스를 교체하기 쉽기 때문에 설계를 유연하게 만든다.
그렇다고 해서 상속을 사용하지 말라는 것이 아니다.
동일한 코드를 재사용하는 경우에는 상속보다 합성을 선호하는 것이 옳지만
다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 둘 다 사용하는 것이 좋다.
Movie avater = new Movie(
"아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(Money.wons(800), ...)
);
avatar.changeDiscountPolicy(new PercentDiscountPolicy(0.1, ...));
이 예제 역시 상속처럼 코드를 재사용하는 방법이라는 점을 눈여겨보기 바란다.
이러한 방법을 합성이라고 한다.