지난 파트에서는 영화 예매 시스템을 만들면서 추상화를 이용한 설계를 실습했다. 이번 파트에서는 상속과 다형성에 대해서 다뤄본다.
이 파트에서 다뤄볼 상속과 다형성은 영화 예매 시스템을 풀어보는 것으로 시작한다.
Movie를 보면 실제로 연결된 것은 DiscountPolicy이다. 실제로 할인 정책을 결정하는 것은 DiscountPolicy가 아니라 AmountDiscountPolicy 혹은 PercentDiscountPolicy이다.
그러나 Movie 내부에는 어떤 정책인지를 판단하는 로직이 전혀 포함되어 있지 않다.
그렇다면 Movie는 어떻게 DiscountPolicy가 아니라 AmountDiscountPolicy 혹은 PercentDiscountPolicy인 것을 알고 결정을 내릴 수 있는걸까?
위에서 살펴 본 Main class를 살펴보면 정답을 알 수 있다.
public static void main(String[] args) {
Movie avatar = new Movie("아바타",
Duration.ofHours(2),
Money.wons(1000),
new AmountDiscountPolicy(Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10),
new PeriodCondition(DayOfWeek.MONDAY, LocalTime.of(10, 0), LocalTime.of(11, 59)),
new PeriodCondition(DayOfWeek.THURSDAY, LocalTime.of(10, 0), LocalTime.of(20, 59)
)));
}
그렇다면 Movie는 어떻게 DiscountPolicy가 아니라 AmountDiscountPolicy 혹은 PercentDiscountPolicy인 것을 알고 결정을 내릴 수 있는걸까? 상속과 다형성을 알아보면 이에 대해 답을 내릴 수 있다.
위에서 살펴 본 Main class를 다시 보자.
public static void main(String[] args) {
Movie avatar = new Movie("아바타",
Duration.ofHours(2),
Money.wons(1000),
new AmountDiscountPolicy(Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10),
new PeriodCondition(DayOfWeek.MONDAY, LocalTime.of(10, 0), LocalTime.of(11, 59)),
new PeriodCondition(DayOfWeek.THURSDAY, LocalTime.of(10, 0), LocalTime.of(20, 59)
)));
}
클래스와 달리 객체는 생성 시점에 선언한 것과는 다른 객체를 집어넣을 수 있다.
여기서 중요한 것은 코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다는 것이다.
다르게 말하면 클래스 간의 의존성과 객체 간의 의존성은 서로 다를 수 있다.
코드 의존성과 실행 시점의 의존성이 다르다는 것은 양면성을 가진다.
즉, 설계가 유연해질수록 디버깅하기는 점점 더 어려워진다. 반면 유연성을 억제하면 코드 이해 및 디버깅은 쉽지만 재사용성과 확장 가능성은 낮아진다. 이러한 트레이드오프를 고민해야 한다. 정답은 없기 때문이다.
상속은 객체지향에서 코드를 재사용하기 위해서 가장 널리 사용되는 방법이다. 상속을 이용하면 클래스 사이에 관계를 설정하는 것만으로 기존 클래스가 갖고 있는 모든 속성과 행동을 새로운 클래스에 포함시킬 수 있다.
특히 앞서 살펴본 DiscountPolicy가 그에 대한 구체적인 예시이다.
DiscountPolicy과 그에 대한 구현체인 PercentDiscountPolicy와 AmountDiscountPolicy처럼 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍이라고 한다.
상속이 가치있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문이다.
자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅이라고 한다. LSP(리스코프 치환 법칙)에 의하면 자식 클래스는 항상 부모 클래스를 대체할 수 있어야 한다. 인터페이스를 물려받아 업캐스팅이 가능하면 이러한 원칙을 지킬 수 있게 된다.
따라서 상속은 메서드나 인스턴스 변수를 재사용하는 것보다도 인터페이스를 물려받을 수 있다는 점에서 가치있다.
Movie는 DiscountPolicy에게 메시지를 전송한다. 그러나 실행 시점에 실제로 실행하는 메서드는 Movie와 협력하는 객체의 실제 클래스가 무엇인지에 따라 달라진다.
동일한 메시지를 전송하지만 실제로 어떤 메시지가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇인지에 따라 달라진다. 이것을 다형성이라고 한다.
추상화를 사용하면 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있다.
세부사항에 억눌리지 않고 상위 개념만으로 도메인의 주요 개념을 설명할 수 있게 한다.
추상화로 상위 정책을 기술하는 것은 기본적인 애플리케이션의 흐름을 기술하는 것이다.
재사용 가능한 설계의 기본을 이루는 디자인 패턴, 프레임워크 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 매커니즘을 활용하고 있다. 따라서 객체지향에 대한 충분한 이해가 필요하다.
어떤 Movie 객체는 할인 정책이 아무것도 적용되지 않는다. 이럴 때는 어떻게 처리해야 할까?
Movie 클래스 내부에 다음과 같은 코드를 추가했다.
public Money calculateMovieFee(Screening screening) {
if (discountPolicy == null) {
return fee;
}
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
좋은 설계라고 볼 수 있을까? 기존 할인 정책은 모두 DiscountPolicy를 통해서 이루어졌다.
이런식으로 예외를 두는 것은 좋은 설계라고 보기 어렵다. 일관성있는 협력 방식을 무너뜨리게 되기 때문이다.
이러한 코드 추가는 책임이 DiscountPolicy가 아닌 Movie에 있기 때문이다. 책임의 위치를 결정하기 위해서 조건문을 사용하는 것은 협력의 설계 측면에서 대부분 좋지 않다. 예외케이스는 최소화하고 일관성을 유지할 수 있도록 코드를 짜는 것이 좋다.
일관성을 유지하기 위해 할인 정책이 없는 경우에도 NoneDiscountPolicy를 추가하여 관리했다.
public class NoneDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
실제로 생성 시 다음과 같이 객체를 생성하면 된다.
Movie starWars = new Movie("스타워즈",
Duration.ofHours(2),
Money.wons(500),
new NoneDiscountPolicy());
결론은 유연성이 필요한 곳에 추상화를 사용하라.
위에서 추가한 NoneDiscountPolicy를 살펴보면 calculateDiscountAmount 메서드 내부에서 이미 할인 조건이 없을 때 ZERO를 반환한다. 이는 NoneDiscountPolicy와 개념적으로 같은 기능을 한다.
이런 개념적인 결합 문제를 해결하는 방법은 DiscountPolicy를 인터페이스로 바꾸고 기존의 DiscountPolicy는 DefaultDiscountPolicy라는 추상클래스로 변경하여 NoneDiscountPolicy에 한해 추상클래스를 구현하는 것이 아니라 DiscountPolicy 인터페이스 자체를 구현하는 것으로 수정하는 것이다.
이렇게 인터페이스 하나만 추가해주는 것으로 개념적인 혼란과 결합을 제거할 수 있다.
public interface DiscountPolicy {
Money calculateDiscountAmount(Screening screening);
}
public abstract class DefaultDiscountPolicy implements DiscountPolicy {
...
}
이전에 생성했던 Policy 클래스는 그대로 두고 NoneDiscountPolicy만 인터페이스를 오버라이딩하도록 수정했다.
public class NoneDiscountPolicy implements DiscountPolicy {
@Override
public Money calculateDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
이렇게 완성된 설계는 아래와 같은 형상을 띈다.
수정된 설계가 더 좋은 설계일까? 사실 내 눈에는 NoneDiscountPolicy 처리를 위해서 인터페이스를 추가하는 과정이 과하게 느껴진다.
책에서는 유연성과 가독성을 두고 항상 생각하라고 한다. 이후에 DiscouontPolicy의 구현체가 늘어나게 된다면 이러한 구현이 더 유연할 것이지만 당장은 가독성이 안 좋아보인다. interface에 추상 클래스까지 겹치니 코드를 이해하기가 더 어려워졌다.
이러한 수정 과정이 의미하는 것은 구현과 관련된 모든 것이 트레이드오프의 대상이 될 수 있다는 사실이다. 내가 작성하는 코드에는 합당한 이유가 있어야 한다. 사소한 결정이더라도 트레이드오프를 통해 얻어진 결론과 그렇지 않은 결론 사이의 차이는 크다.
고민하자!
이번엔 과정을 보여주고자 인터페이스를 추가했지만, 뒤의 내용은 인터페이스 없이 추상클래스만으로 설계된 버전으로 진행한다.
상속은 코드 재사용을 위해 널리사용되는 방법이다. 그러나 상속 말고도 합성이라는 방법이 있다.
합성
인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성이라고 한다.
합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법을 말한다. (Movie 안에 DiscountPolicy가 인스턴스로 포함된 것이 합성이다.)
흔히 상속보다 합성을 선호한다. 합성을 선호하는 이유는 무엇일까?
상속은 두가지 관점에서 설계에 안 좋은 영향을 미친다
특히 캡슐화를 위반하는 것이 가장 크다. 상속을 사용하기 위해서는 부모 클래스 내부 구조를 잘 알고있어야 한다. 부모클래스의 구현이 자식 클래스에게 노출되므로 캡슐화가 약화된다.
결과적으로 상속을 과도하게 사용하면 변경하기도 어려워진다. 좋은 코드 원칙 중 하나인 내일 당장 수정하기 쉬운 코드라는 원칙을 위반하게 된다.
상속을 사용하지 말라는 것이 아니다.
상속과 합성을 같이 써야한다. 코드를 재사용하는 경우에는 상속보다 합성을 선호한다. 그러나 다형성을 위해서 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용해야 한다.
⇒ 객체지향 설계의 핵심은 적절한 협력을 식별하고 협력에 필요한 역할을 정의한 후에 역할을 수행할 수 있는 적절한 객체에게 적절한 책임을 할당하는 것이다.
캡슐화
상속
다형성
합성
설계의 유연성과 가독성 사이에서 항상 고민하라