이번 장에서 소개할 예제는 온라인 예매 시스템이다.
'영화'와 '상영'이라는 용어를 구분할 필요가 있을 것 같다.
'영화'는 영화에 대한 기본 정보를 표현한다. 제목, 상영시간, 가격 정보와 같이 영화가 가지고 있는 기본적인 정보를 가리킬 때 사용할 것이다.
'상영'은 실제로 관객들이 영화를 관람하는 사건을 표현한다. 상영 일자, 시간, 순번 등을 가리키기 위해 '상영'이라는 용어를 사용할 것이다. 그림 2.1과 같이 하나의 영화는 하루 중 다양한 시간대에 걸쳐 한 번 이상 상영될 수 있다.
두 용어의 차이가 중요한 이유는 사용자가 실제로 예매하는 대상은 영화가 아니라 상영이기 때문이다.
특정 조건을 만족하는 예매자는 요금을 할인 받을 수 있다. 할인액을 결정하는 두 가지 규칙이 존재하는데, 하나는 할인 조건(discount condition)이라고 부르고 다른 하나는 할인 정책(discount policy)이라고 부른다.
표 2.1은 영화에 할인 정책과 할인 조건을 설정한 몇 가지 예를 정리한 것이다. 영화별로 하나의 할인 정책만 적용한 데 비해 할인 조건을 여러 개를 적용했음을 알 수 있다. 할인 조건의 경우에는 순번 조건과 기간 조건을 함께 혼합할 수 있으며 할인 정책은 아예 적용하지 않을 수 있다는 사실도 알 수 있다.
할인 정책을 적용하지 않은 경우에는 영화의 기본 가격이 판매 요금이 된다.
할인을 적용하기 위해서는 할인 조건과 할인 정책을 함께 조합해서 사용한다. 먼저 사용자의 예매 정보가 할인 조건 중 하나라도 만족하는지 검사한다. 할인 조건을 만족할 경우 할인 정책을 이용해 할인 요금을 계산한다. 할인 정책은 적용돼 있지만 할인 조건을 만족하지 못하는 경우나 아예 할인 정책이 적용돼 있지 않은 경우에는 요금을 할인하지 않는다.
객체지향은 객체를 지향하는 것이다. 클래스 기반의 객체지향 언어에 익숙한 사람이라면 가장 먼저 어떤 클래스(class)가 필요한지 고민할 것이다. 대부분의 사람들은 클래스를 결정한 후에 클래스에 어떤 속성과 메서드가 필요한지 고민한다.
진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞출 때에만 얻을 수 있다. 이를 위해서는 두 가지에 집중해야 한다.
첫째, 어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민하라. 클래스는 공통적인 상태와 행동을 공유하는 객체를 추상화한 것이다. 따라서 클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지 결정해야 한다.
둘째, 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다. 객체는 홀로 존재하는 것이 아니다. 다른 객체에게 도움을 주거나 의존하면서 살아가는 협력적인 존재다.
영화 예매 시스템의 목적은 영화를 좀 더 쉽고 빠르게 예매하려는 사용자의 문제를 해결하는 것이다. 이처럼 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야를 도메인이라고 부른다.
객체지향 패러다임이 강력한 이유는 요구사항을 분석하는 초기 단계부터 프로그램을 구현하는 마지막 단계까지 객체라는 동일한 추상화 기법을 사용할 수 있기 때문이다. 요구사항과 프로그램을 객체라는 동일한 관점에서 바라볼 수 있기 때문에 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결 될 수 있다.
그림 2.3은 영화 예매 도메인을 구성하는 개념과 관계를 표현한 것이다. 영화는 여러 번 상영될 수 있고 상영은 여러 번 예매될 수 있다는 것을 알 수 있다. 영화에는 할인 정책을 할당하지 않거나 할당하더라도 오직 하나만 할당할 수 있고 할인 정책이 존재하는 경우에는 하나 이상의 할인 조건이 반드시 존재한다는 것을 알 수 있다. 할인 정책의 종류로는 금액 할인 정책과 비율 할인 정책이 있고, 할인 조건의 종류로는 순번 조건과 기간 조건이 있다는 사실 역시 확인할 수 있다.
도메인의 개념과 관계를 반영하도록 프로그램을 구조화해야 하기 때문에 그림 2.4와 같이 클래스의 구조는 도메인의 구조와 유사한 형태를 띠어야 한다.
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 MonetaryAmountFormatter getMovieFee() {
return movie.getFee();
}
}
여기서 주목할 점은 인스턴스 변수의 가시성은 private이고 메서드의 가시성은 public이라는 것이다. 클래스를 구현하거나 다른 개발자에 의해 개발된 클래스를 사용할 때 가장 중요한 것은 클래스의 경계를 구분 짓는 것이다. 클래스는 내부와 외부로 구분되며 훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 외부에 공개하고 어떤 부분을 감출지 결정하는 것이다.
그 이유는 경계의 명확성이 객체의 자율성을 보장하기 때문이다. 더 중요한 이유로 프로그래머에게 구현의 자유를 제공하기 때문이다.
두 가지 중요한 사실을 알아야 한다. 첫 번째 사실은 객체가 상태(state)와 행동(behavior)을 함께 가지는 복합적인 존재라는 것이다. 두 번째 사실은 객체가 스스로 판단하고 행동하는 자율적인 존재라는 것이다. 두 가지 사실은 서로 깊이 연관돼 있다.
많은 사람들은 객체를 상태와 행동을 함께 포함하는 식별 가능한 단위로 정의한다. 객체지향 이전의 패러다임에서는 데이터와 기능이라는 독립적인 존재를 서로 엮어 프로그램을 구성했다. 이와 달리 객체지향은 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶음으로써 문제 영역의 아이디어를 적절하게 표현할 수 있게 했다. 이처럼 데이터와 기능을 객체 내부로 함께 묶는 것을 캡슐화라고 부른다.
대부분의 객체지향 프로그래밍 언어들은 상태와 행동을 캡슐화하는 것에 한 걸음 더 나아가 외부에서 접근을 통제할 수 있는 접근 제어(access control) 메커니즘도 함께 제공한다. 많은 프로그래밍 언어들은 접근 제어를 위해 public, protected, private과 같은 접근 수정자(access modifier)를 제공한다.
객체 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위해서다. 객체지향의 핵심은 스스로 상태를 관리하고, 판단하고, 행동하는 자율적인 객체들의 공동체를 구성하는 것이다. 객체가 자율적인 존재로 서기 위해서는 외부의 간섭을 최소화해야 한다.
캡슐화와 접근 제어는 객체를 두 부분으로 나눈다. 하나는 외부에서 접근 가능한 부분으로 이를 퍼블릭 인터페이스(public interface)라고 부른다. 다른 하나는 외부에서는 접근 불가능하고 오직 내부에서만 접근 가능한 부분으로 이를 구현(implementation)이라고 부른다. 뒤에서 살펴봤지만 인터페이스와 구현의 분리(separation of interface and implementation) 원칙은 훌륭한 객체지향 프로그램을 만들기 위해 따라야 하는 핵심 원칙이다.
프로그래머의 역할을 클래스 작성자(class creator)와 클라이언트 프로그래머(client programmer)로 구분하는 것이 ㅇ유용하다. 클래스 작성자는 새로운 테이터 타입을 프로그램에 추가하고, 클라이언트 프로그래머는 클래스 작성자가 축하나 데이터 타입을 사용한다.
클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만 공개하고 나머지는 숨겨야 한다. 클라이언트 프로그래머가 숨겨놓은 부분에 마음대로 접근할 수 없도록 방지함으로써 클라이언트 프로그래머에 대한 영향을 걱정하지 않아도 내부 구현을 마음대로 변경할 수 있다. 이를 구현 은닉(implementation hiding)이라고 부른다.
구현 은닉은 클라이언트 프로그래머에게 내부의 구현은 무시한 채 인터페이스만 알고 있어도 클래스를 사용할 수 있기 때문에 편하다.
이제 영화를 예매하는 기능을 구현하는 메서드를 살펴보자.
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 Reservation reserve(Customer customer, int audienceCount) {
return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
}
public LocalDateTime getStartTime() {
return whenScreened;
}
public boolean isSequence(int sequence) {
return this.sequence == sequence;
}
public MonetaryAmountFormatter getMovieFee() {
return movie.getFee();
}
private Money calculateFee(int audienceCount) {
return movie.calculateMovieFee(this).times(audienceCount);
}
}
public class Money {
public static final Money ZERO = Money.wons(0);
private final BigDecimal amount;
public static Money wons(BigDecimal amount) {
return new Money(BigDecimal.valueOf(amount));
}
public static Money wons(double amount) {
return new Money(BigDecimal.valueOf(amount));
}
public 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(
new BigDecimal(percent)));
}
public boolean isLessThan(Money other) {
return amount.compareTo(other.amount) < 0;
}
public boolean isGreaterThanOrEqual(Money other) {
return amount.compareTo(other.amount) >= 0;
}
}
1장에서는 금액을 구현하기 위해 Long 타입을 사용했던 것을 기억하라. Long 타입은 변수의 크기나 연산자의 종류와 관련된 구현 관점의 제약은 표현할 수 있지만 Money
타입처럼 저장하는 값이 금액과 관련돼 있다는 의미를 전달할 수는 없다. 또한 금액과 관련된 로직이 서로 다른 곳에 중복되어 구현되는 것을 막을 수 없다. 객체지향의 장점은 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것이다. 따라서 의미를 명시적이고 분명하게 표현할 수 있다면 객체를 사용해서 해당 개념을 구현하라. 그 개념이 비록 하나의 인스턴스 변수만 포함하더라도 개념을 명시적으로 표현하는 것은 전체적인 설계와 명확성과 유연성을 높이는 첫걸음이다.
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;
}
}
영화를 예매하기 위해 Screeing
, Movie
, Reservation
인스턴스들은 서로의 메서드를 호출하며 상호작용한다. 이처럼 시스템의 어떤 기능을 구현하기 위해 객체들 사이에 이뤄지는 상호작용을 협력(Collaboration)이라고 부른다.
객체지향 프로그램을 작성할 때는 먼저 협력의 관점에서 어떤 객체가 필요한지를 결정하고, 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성한다. 따라서 협력에 대한 개념을 간략하게라도 살펴보는 것이 이후의 이야기를 이해하는 데 도움이 될 것이다.
객체는 다른 객체의 인터페이스에 공개된 행동을 수행하도록 요청(request)할 수 있다. 요청을 받은 객체는 자율적인 방법에 따라 요청을 처리한 후 응답(response)한다.
객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송(send a message)하는 것뿐이다. 다른 객체에게 요청이 도착할 때 해당 객체가 메시지를 수신(receive a message)했다고 이야기한다. 메시지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메시지를 처리할 방법을 결정한다. 이처럼 수신된 메시지를 처리하기 위한 자신만의 방법을 메서드(method)라고 부른다.
public class Movie {
private String title;
private Duration duration;
private Money fee;
private DiscountPolicy discountPolicy;
public Movie(String title, Duration duration, Money money, DiscountPolicy discountPolicy) {
this.title = title;
this.duration = duration;
this.money = money;
this.discountPolicy = discountPolicy;
}
public Money getFee() {
return fee;
}
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
calculateMovieFee
안에는 어떤 할인 정책을 사용할 것인지 결정하는 코드가 어디에도 존재하지 않는다.
할인 정책은 금액 할인 정책, 비율 할인 정책으로 구분된다.
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);
}
DiscountPolicy
는 DiscountCondition
의 리스트인 conditions를 인스턴스 변수로 가지기 때문에 하나의 할인 정책은 여러 개의 할인 조건을 포함할 수 있다.
할인 조건을 만족하는 DiscountCondition
이 하나라도 존재하는 경우에는 추상 메서드(abstract method)인 getDiscountAmount 메서드를 호출해 할인 요금을 계산한다. 만족하는 할인 조건이 하나도 존재하지 않는다면 할인 요금을 0원으로 반환한다.
DiscountPolicy
는 할인 여부와 요금 계산에 필요한 전체적인 흐름은 정의하지만 실제로 요금을 계산하는 부분은 추상 메서드인 getDiscountAmount 메서드에게 위임한다. 실제로는 DiscountPolicy
를 상속 받은 자식 클래스에서 오버라이딩한 메서드가 실행될 것이다. 이처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴이라고 부른다.
public class SequenceCondition implements DiscountCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
@Override
public boolean isSatisfiedBy(Screening screening) {
return screening.isSequence(sequence);
}
}
public class PeriodCondition implements DiscountCondition {
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
this.dayOfWeek = dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
@Override
public boolean isSatisfiedBy(Screening screening) {
return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
}
}
public class AmountDiscountPolicy extends DiscountPolicy {
private Money discountAmount;
public AmountDiscountPolicy(Money discountAmount, DiscountCondition... conditions) {
super(conditions);
this.discountAmount = discountAmount;
}
@Override
protected Money getDiscountAmount(Screening screening) {
return null;
}
}
public class PercentDiscountPolicy extends DiscountPolicy {
private double percent;
public PercentDiscountPolicy(double percent, DiscountCondition... conditions) {
super(conditions);
this.percent = percent;
}
@Override
protected Money getDiscountAmount(Screening screening) {
return screening.getMovieFee().times(percent);
}
}
오버라이딩과 오버로딩
많은 사람들이 오버라이딩(overriding)과 오버로딩(overloading)의 개념을 혼동한다.
오버라이딩은 부모 클래스에 정의된 같은 이름. 같은 파라미터 목록을 가진 메서드를 자식 클래스에서 재정의하는 경우를 가리킨다. 자식 클래스의 메서드는 오버라이딩한 부모 클래스의 메서드를 가리기 때문에 외부에서는 부모 클래스의 메서드가 보이지 않는다.
오버로딩은 메서드의 이름은 같지만 제공되는 파라미터의 목록이 다르다. 오버로딩한 메서드는 원래의 메서드를 가라지 않기 때문에 이 메서들은 사이 좋게 공존한다.
public class Money { public Money plus(Money amount) { return new Money(this.amount.add(amount.amount)); } public Money plus(long amount) { return new Money(this.amount.add(BigDecimal.valueOf(amount))); } }
Movie
의 생성자는 오직 하나의 DiscountPolicy
인스턴스만 받을 수 있도록 선언돼 있다.
public class Movie {
public Movie(String title, Duration duration, Money fee, DiscountPolicy discountPolicy) {
this.title = title;
this.duration = duration;
this.fee = fee;
this.discountPolicy = discountPolicy;
}
반면 DiscountPolicy
의 생성자는 여러 개의 DiscountCondition
인스턴스를 허용한다.
public abstract class DiscountPolicy {
public DiscountPolicy(DiscountCondition ... conditions) {
this.conditions = Arrays.asList(conditions);
}
}
이처럼 생성자의 파라미터 목록을 이용해 초기화에 필요한 정보를 전달하도록 강제하면 올바른 상태를 가진 객체의 생성을 보장할 수 있다.
의존성의 개념을 살펴보고 상속과 다형성을 이용해 특정한 조건을 선택적으로 실행하는 방법을 알아보자.
그림 2.7처럼 어떤 클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나 해당 클래스의 객체의 메서드를 호출할 경우 두 클래스 사이에 의존성이 존재한다고 말한다.
Movie
클래스는 DiscountPolicy
추상 클래스를 의존하고 있다. 하지만 영화 요금을 계산하기 위해서는 금액 할인 정책인지 비율 할인 정책인지 알아야 한다. 만약 금액 할인 정책을 적용하고 싶다면 Movie
의 인스턴스를 생성할 때 인자로 AmountDiscountPolicy
의 인스턴스를 전달하면 된다.
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(Money.wons(800), ...);
비율 할인 정책을 적용하고 싶다면 PercentDiscountPolicy
의 인스턴스를 전달하면 된다.
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new PercentDiscountPolicy(0.1, ...);
코드 상에서 Movie
는 DiscountPolicy
에 의존한다. 그러나 실행 시점에는 Movie
인스턴스는 AmountDiscountPolicy
나 PercentDiscountPolicy
의 인스턴스에 의존하게 된다.
여기서 이야기하고 싶은 것은 코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다는 것이다. 다시 말해 클래스 사이의 의존성과 객체 사이의 의존성은 동일하지 않을 수 있다.
클래스 하나를 추가하고 싶은데 그 클래스가 기존의 어떤 클래스와 매우 흡사하다고 가정해보자. 그 클래스의 코드를 가져와 약간만 추가하거나 수정해서 새로운 클래스를 만들 수 있다면 좋을 것이다. 더 좋은 방법은 그 클래스의 코드를 전혀 수정하지 않고도 재사용하는 것일 것이다. 이를 가능하게 해주는 방법이 상속이다.
public class Movie {
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
Movie
가 DiscountPolicy
의 인터페이스에 정의된 calculateDiscountAmount
메시지를 전송하고 있다. DiscountPolicy
를 상속받는 AmountDiscountPolicy
와 PercentDiscountPolicy
의 인터페이스에도 이 오퍼레이션이 포함돼 있다는 사실에 주목하라.
Movie
는 자신과 협력하는 객체가 어떤 클래스의 인스턴스인지 중요한 것이 아니라 calculateDiscountAmount
메시지를 수신할 수 있다는 사실이 중요하다.
정리하면 자식 클래스는 상속을 통해 부모 클래스의 인터페이스를 물려받기 때문에 부모 클래스 대신 사용될 수 있다.
이 처럼 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅(upcasting)이라고 부른다.
다시 한번 강조하지만 메시지와 메서드는 다른 개념이다. Moive
는 DiscountPolicy
의 인스턴스에게 calculateDiscountAmount
메시지를 전송한다. 그렇다면 실행되는 메서드는 무엇인가? Moive
와 상호작용하기 위해 연결된 객체의 클래스가 무엇인가에 따라 달라진다. 다시 말해 Movie
는 동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다. 이를 다형성이라 부른다.
다형성이란 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력을 의미한다.
앞에서는 DiscountPolicy
를 추상 클래스로 구현함으로써 자식 클래스들이 인터페이스와 내부 구현을 함께 상속받도록 만들었다. 그러나 종종 구현은 공유할 필요가 없고 순수하게 인터페이스만 공유하고 싶을 때가 있다. 이를 위해 C#과 자바에서는 인터페이스라는 프로그래밍 요소를 제공한다.
그림 2.13은 자식 클래스를 생략한 코드 구조를 그림으로 표현한 것이다. 이 그림은 추싱화를 사용할 경우의 두 가지 장점을 보여준다. 첫 번째 장점은 추상화의 계층만 따로 떼어 놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술할 수 있다는 것이다. 두 번째 장점은 추상화를 이용하면 설계가 좀 더 유연해진다는 것이다.
우리는 아직 '스타워즈'의 할인 정책은 해결하지 않았다. 사실 '스타워즈'에는 할인 정책이 적용돼 있지 않다. 즉, 할인 요금을 계산할 필요 없이 영화에 설정된 기본 금액을 그대로 사용하면 된다.
public Money calculateMovieFee(Screening screening) {
if (discountPolicy == null) {
return fee;
}
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
이 방식의 문제점은 할인 정책이 없는 경우를 예외 케이스로 취급하기 때문에 지금까지 일관성 있던 협력 방식이 무너지게 된다는 것이다. 할인 정책이 없는 경우에는 할인 금액이 0원이라는 사실을 결정하는 책임이 DiscountPolicy
가 아닌 Movie
쪽에 있기 때문이다. 따라서 책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분의 경우 좋지 않은 선택이다. 항상 예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택하라.
이 경우에 일관성을 지킬 수 있는 방법은 0원이라는 할인 요금 계산을 책임을 그대로 DiscountPolicy 계층에 유지시키는 것이다.
public class NonDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
이제 Movie
의 인스턴스에 NonDiscountPolicy
의 인스턴스를 연결해서 할인되지 않는 영화를 생성할 수 있다.
Moive starWars = new Movie("스타워즈",
Duration.ofMinutes(210),
Money.wons(10000),
new NonDiscountPolicy());
결론은 간단하다. 유연성이 필요한 곳에 추상화를 사용하라.
앞의 NoneDiscountPolicy
클래스의 코드를 자세히 살펴보면 getDiscountAmount()
메서드가 어떤 값을 반환하더라도 상관이 없다는 사실을 알 수 있다. 부모 클래스인 DiscountPolicy
에서 할인 조건이 없을 경우에는 getDiscountAmount()
메서드를 호출하지 않기 때문이다. 이것은 부모 클래스인 DiscountPolicy
와 NoneDiscountPolicy
를 개념적으로 결합시킨다. NoneDiscountPolicy
의 개발자는 getDiscountAmount()
가 호출되지 않을 경우 DiscountPolicy
가 0원을 반환할 것이라는 사실을 가정하고 있기 때문이다.
이 문제를 해결하는 방법은 DiscountPolicy
를 인터페이스로 바꾸고 NoneDiscountPolicy
가 DiscountPolicy
의 getDiscountAmount()
가 아닌 calculateDiscountAmount()
오퍼레이션을 오버라이딩하도록 변경하는 것이다.
public interface DiscountPolicy {
Money calculateDiscountAmount(Screening screening);
}
public class DefaultDiscountPolicy implements DiscountPolicy {
...
}
이제 NoneDiscountPolicy
가 DiscountPolicy
인터페이스를 구현하도록 변경하면 개념적인 혼란과 결합을 제거할 수 있다.
public class NonDiscountPolicy implements DiscountPolicy {
@Override
public Money calculateDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
상속은 코드를 재사용하기 위해 널리 사용되는 방법이다. 그러나 널리 사용되는 방법이라고 해서 가장 좋은 방법은 아니다. 객체지향 설계와 관련된 자료를 조금이라도 본 사람들은 코드를 재사용을 위해 상속보다는 합성(composition)이 더 좋은 방법이라는 이야기를 들었을 것이다. 합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법을 말한다.
Movie
가 DiscountPolicy
의 코드를 재사용하는 방법이 바로 합성이다. 이 설계를 상속을 사용하도록 변경할 수도 있다. 그림 2.16과 같이 Movie
를 직접 상속받아 AmountDiscountMovie
와 PercentDiscountMoive
라는 두 개의 클래스를 추가하면 합성을 사용한 기존 방법과 기능적인 관점에서 완벽히 동일하다.
상속은 객체지향에서 코드를 재사용하기 위해 널리 사용되는 기법이다. 하지만 두 가지 관점에서 설계에 안 좋은 영향을 미친다. 하나는 상속이 캡슐화를 위반한다는 것이고, 다른 하나는 설계를 유연하지 못하게 만든다는 것이다.
상속의 가장 큰 문제점은 캡슐화를 위반한다는 것이다. 상속을 이용하기 위해서 부모 클래스의 내부 구조를 잘 알고 있어야 한다. AmountDiscountMoive
와 PercentDiscountMoive
메서드를 호출한다는 사실을 알고 있어야 한다.
결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다. 캡슐화의 약화는 자식 클래스와 부모클래스에 강하게 결합되도록 만들기 때문에 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다. 결과적으로 상속을 과도하게 사용한 코드는 변경하기도 어려워진다.
상속의 두 번째 단점은 설계가 유연하지 않다는 것이다. 상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정한다. 따라서 실행 시점에 객체의 종류를 변경하는 것이 불가능하다.
예를 들어, 실행 시점에 금액 할인 정책인 영화를 비율 할인 정책으로 변경한다고 가정하자. 상속을 사용한 설계에서는 AmountDiscountMoive
의 인스턴스를 PercentDiscountMoive
의 인스턴스로 변경해야 한다. 대부분의 언어는 이미 생성된 객체의 클래스를 변경하는 기능을 지원하지 않기 때문에 이 문제를 해결할 수 있는 최선의 방법은 PercentDiscountMoive
의 인스턴스를 생성한 후 AmountDiscountMoive
의 상태를 복사하는 것뿐이다. 이것은 부모 클래스와 자식 클래스가 강하게 결합돼 있기 때문에 발생하는 문제다.
반면 인스턴스 변수로 연결한 기존 방법을 사용하면 실행 시점에 할인 정책을 간단하게 변경할 수 있다.
public class Movie {
private DiscountPolicy discountPolicy;
public void changeDiscountPolicy(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(Money.wons(800), ...);
avatar.changeDiscountPolicy(new PercentDiscountPolicy(0.1, ...));
Movie
는 요금을 계싼하기 위해 DiscountPolicy
의 코드를 재사용한다. 이 방법이 상속과 다른 점은 상속이 부모 클래스의 코드와 자식 클래스의 코드를 컴파일 시점에 하나의 단위로 강하게 결합시하는 데 비해 Movie
가 DiscountPolicy
의 인터페이스를 통해 약하게 결합된다는 것이다. 이처럼 인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성이라고 부른다.
합성은 상속이 가지는 두 가지 문제점을 모두 해결한다. 인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화할 수 있다. 또한 의존하는 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만든다. 상속은 클래스를 통해 강하게 결합되는 데 비해 합성은 메시지를 통해 느슨하게 결합된다. 따라서 코드 재사용을 위해서는 상속보다는 합성을 선호나는 것이 더 좋은 방법이다.