상속에 대한 오해를 해소하기 위해 상속이 두 가지 용도로 사용된다는 사실을 이해하자.
상속의 첫번째 용도는 타입 계층을 구현하는 것이다. 타입 계층의 관점에서 부모 클래스는 자식
클래스의 일반화이고 자식 클래스는 부모 클래스의 특수화이다.
상속의 두번째 용도는 코드 재사용이다. 하지만 재사용을 위해 상속을 사용하는 경우 부모 클래스와 자식 클래스가 강하게 결합되기 때문에 변경하기 어려운 코드를 얻게 될 수 있다.
상속을 사용하는 목표는 코드 재사용이 아니라 타입 계층을 구현하는 것이어야 한다. 이를 통해
다형적으로 동작하는 객체들의 관계에 기반해 확장 가능하고 유연한 설계를 얻을 수 있게 된다.
동일한 메시지에 대해 서로 다르게 행동할 수 있는 다형적인 객체를 구현하기 위해서는 객체의 행동을 기반으로 타입 계층을 구성해야 한다.
이 장에서는 올바른 타입 계층을 구성하는 원칙을 알아본다.
객체지향 프로그래밍과 객체기반 프로그래밍
객체기반 프로그래밍이란 상태와 행동을 캡슐화한 객체를 조합해서 프로그램을 구성하는 방식을
가리킨다. 이 정의에 따르면 OOP 역시 객체기반 프로그래밍의 한 종류다. 하지만 상속과 다형성을 지원한다는 면에서 차별화된다.
종종 객체기반 프로그래밍이 클래스가 존재하지 않는 프로토타입 기반 언어를 사용한 프로그래밍 방식을 가리키기 위해 사용되는 경우가 있어 오해가 발생하기도 한다. 클래스를 사용하지 않는
언어를 지칭하는 맥락에서 사용되는 것으로 판단할 수 있다.
개념 관점에서 타입이란 우리가 인지하는 세상의 사물의 종류를 의미한다. 타입은 사물을 분류하기 위한 틀로 사용된다. 어떤 대상이 타입으로 분류될 때 그 대상을 타입의 인스턴스라고 부른다.
일반적으로 타입의 인스턴스를 객체라고 부른다. 이 설명으로 보았을 때 타입은 세 요소로 구성된다.
언어의 관점에서 타입은 비트 묶음에 의미를 부여하기 위해 정의된 제약과 규칙을 가리킨다.
언어에서 타입은 두 가지 목적을 위해 사용된다.
타입에 수행될 수 있는 유효한 오퍼레이션의 집합을 정의한다.
자바에서 ‘+’ 연산자는 정수, 문자열에만 사용될 수 있다. 하지만 C++에서는 연산자 오버로딩을 통해 다른 타입에도 ‘+’연산자를 사용할 수 있다. 모든 객체지향 언어들은 객체 타입에 따라 가능한
연산자의 종류를 제한하여 프로그래머의 실수를 막아준다.
타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공한다.
a+b 에서 두 변수의 타입 정수라면 덧셈 연산을 수행된다. 문자열 타입이라면 하나의 문자열로
합치는 연산이 이뤄진다. 타입이 연산자의 문맥을 제공한다.
정리하면 타입은 오퍼레이션의 종류와 의미를 정의하여 코드의 의미를 명확하게 전달하고 개발자의 실수를 방지하기 위해 사용된다.
객체지향 프로그래밍 관점에서 타입을 다음과 같이 정의할 수 있다.
객체의 퍼블릭 인터페이스가 객체의 타입을 결정한다. 따라서 동일한 퍼블릭 인터페이스를
제공하는 객체들은 동일한 타입으로 분류된다.
객체를 바라볼 때는 항상 외부에 제공하는 행동에 초점을 두어야 한다. 내부 상태는 다르지만 동일한 퍼블릭 인터페이스를 공유한다면 이들은 동일 타입으로 분류된다.
수학에서 집합은 다른 집합을 포함할 수 있다. 타입 역시 객체들의 집합이기 때문에 다른 타입을
포함하는 것이 가능하다. 타입 내에 포함된 객체들을 좀 더 상세한 기준으로 묶어 새로운 타입을
정의하면 이 새 타입은 자연스럽게 기존 타입의 부분집합이 된다.
타입이 다른 타입에 포함될 수 있기 때문에 동일한 인스턴스가 하나 이상의 타입으로 분류되는 것도 가능하다. 다른 타입을 포함하는 타입은 좀 더 일반화된 의미를 표현할 수 있다. 포함하는 타입은
외연 관점에서는 더 크고 내연 관점에서는 더 일반적이다. 이는 포함 관계로 연결된 타입 사이에
개념적으로 일반화와 특수화 관계가 존재한다는 것을 의미한다.
타입 계층을 구성하는 두 타입 간 관계에서 더 일반적인 타입을 슈퍼타입이라고 부르고 더 특수한
타입을 서브타입이라고 부른다. 종합하여 일반화와 특수화를 아래와 같이 정의할 수 있다.
일반화는 다른 타입을 완전히 포함하거나 내포하는 타입을 식별하는 행위 또는 그 행위의
결과를 가리킨다. 특수화는 다른 타입 안에 전체적으로 포함되거나 완전히 내포되는 타입을
식별하는 행위 또는 그 행위의 결과를 가리킨다.
내연과 외연 관점에서 서브타입과 슈퍼타입을 다음과 같이 정의할 수 있다.
슈퍼타입은 다음과 같은 특징을 가지는 타입을 가리킨다.
- 집합이 다른 집합의 모든 멤버를 포함한다.
- 타입 정의가 다른 타입보다 좀 더 일반적이다.
서브타입은 다음과 같은 특징을 가지는 타입을 가리킨다.
OOP 관점에서 객체의 타입을 결정하는 것은 퍼블릭 인터페이스이다. 따라서 슈퍼타입과
서브타입을 다음과 같이 정의할 수 있다.
슈퍼타입이란 서브타입이 정의한 퍼블릭 인터페이스를 일반화시켜 상대적으로 범용적이고
넓은 의미로 정의한 것이다.
서브타입이란 슈퍼타입이 정의한 퍼블릭 인터페이스를 특수화시켜 상대적으로 구체적이고
좁은 의미로 정의한 것이다.
객체지향 프로그래밍 언어에서 타입을 구현하는 일반적인 방법은 클래스를 이용하는 것이다. 그리고 타입 계층을 구현하는 일반적인 방법은 상속을 이용하는 것이다. 타입 계층을 구현할 때 지켜야 하는 제약사항을 클래스와 상속의 관점에서 알아보자.
마틴 오더스키는 다음과 같은 질문들에 모두 ‘예’라고 답할 수 있는 경우에만 상속을 사용하라고
조언한다.
설계 관점에서 상속을 적용 여부를 결정하기 위해선 두번째 질문에 초점을 맞추어야 한다.
is-a 관계는 생각처럼 직관적이지 명쾌하지 않다. 이펙티브 C++에서 등장하는 새와 펭귄의 예를
통해 이 관계가 직관을 배신할 수 있다는 사실을 알 수 있다. 다음 사실을 바탕으로 알아보자.
public class Bird {
public void fly() { ... }
...
}
public class Pengiun extends Bird {
...
}
사실을 바탕으로 한 코드에서 펭귄은 날 수 없는 새지만, 펭귄을 날 수 있는 새로 표현했다는 오점을 발견할 수 있다. 이 예는 어휘적인 정의가 아니라 행동에 따라 타입 계층을 구성해야 한다는 사실을 보여준다. 따라서 타입 계층의 의미는 행동이라는 문맥에 따라 달라질 수 있으며, 슈퍼타입과
서브타입 관계에서는 is-a보다 행동 호환성이 더 중요하다.
타입의 이름 사이에 개념적으로 어떤 연관성이 있다고 하더라도 행동에 연관성이 없다면 is-a 관계를 사용하지 말아야 한다. 여기서 중요한 것은 행동의 호환 여부를 판단하는 기준은 클라이언트의 관점이라는 것이다. 클라이언트가 두 타입이 동일하게 행동할 것이라고 기대한다면 두 타입을 타입
계층으로 묶을 수 있다.
다음과 같이 클라이언트가 날 수 있는 새만을 원한다고 가정해보자
public void flyBird(Bird bird) {
// 인자로 전달된 모든 bird는 날 수 있어야 한다.
bird.fly();
}
하지만 현재 Penguin 은 Bird 의 자식 클래스이기 때문에, Penguin 이 위 메서드로 전달될 경우
클라이언트가 기대하는 대로 작동이 이뤄지지 않는다.
이를 해결할 수 있는 첫 방법은 Penguin 의 fly 메서드를 오버라이딩하여 내부 구현을 비워두는
것이다.
public class Penguin extends Bird {
...
@Override
public void fly() {}
}
하지만 이 방법은 어떤 행동도 수행하지 않기에 모든 bird 가 날 수 있다는 클라이언트의 기대를
충족시키지 못한다. 따라서 올바른 설계라고 할 수 없다.
두 번째 방법은 Penguin 의 fly 메서드를 오버라이딩한 후 예외를 던지게 한 것이다.
public class Penguin extends Bird {
...
@Override
public void fly() {
throw new UnsupportedOperationException();
}
}
하지만 이 경우 flyBird 메서드에 전달되는 인자의 타입에 따라 메서드가 실패하거나 성공하게
된다. 따라서 이 방법 역시 클라이언트의 기대를 저버린다.
세번째 방법은 flyBird메서드를 수정해서 인자로 전달된 Bird 타입이 Penguin 이 아닐 경우에만 fly 메시지를 전송하도록 하는 것이다.
public void flyBird(Bird bird) {
// 인자로 전돨된 모든 bird가 Penguin의 인스턴스가 아닐 경우에만
// fly() 메시지를 전송한다
if(!bird instanceof Penguin) {
bird.fly();
}
}
하지만 이 방법은 또 다른 날 수 없는 새가 상속 계층에 추가된다면 OCP를 위반하게 된다.
문제를 해결하는 방법은 클라이언트의 기대에 맞게 상속 계층을 분리하는 것뿐이다. 따라서 날 수
있는 새와 날 수 없는 새를 명확하게 구분할 수 있게 상속 계층을 분리하면 서로 다른 요구사항을
가진 클라이언트를 만족시킬 수 있을 것이다.
public class Bird {
...
}
public class FlyingBird extends Bird {
public void fly() { ... }
...
}
public class Penguin extends Bird {
...
}
이제 flyBird 메서드는 FlyingBird 타입을 이용해 날 수 있는 새만 인자로 전달돼야 한다는 사실을 코드에 명시할 수 있다.
public void flyBird(FlyingBird bird) {
bird.fly();
}
아래 그림은 상속 계층을 분리한 후의 클래스 구조를 나타낸 것이다. 변경 후에는 모든 클래스들이 행동 호환성을 만족시킨다는 사실을 알 수 있다.

이제 잘못된 객체와 협력하여 기대했던 행동이 수행되지 않거나 예외가 던져지는 일은 일어나지
않을 것이다.
이 문제를 해결하는 다른 방법은 클라이언트에 따라 인터페이스를 분리하는 것이다.

이제 Bird 와 Penguin 은 자신이 수행할 수 있는 인터페이스만 구현할 수 있다.
만약 Penguin 이 Bird 의 코드를 재사용해야 한다면 어떻게 해야 할까? 가장 좋은 방법은 합성을 사용하는 것이다. 불안정한 상속보다 Bird 의 퍼블릭 인터페이스를 조금 수정하더라도 합성을
사용하는 것이 나은 방법이다.

클라이언트에 따라 인터페이스를 분리하면 각 클라이언트의 요구가 바뀌더라도 영향의 파급 효과를효과적으로 제어할 수 있게 된다. 이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써
변경에 의한 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(ISP) 라고 부른다.
여기서 주의할 점은 설계가 꼭 현실 세계를 반영할 필요는 없다는 점이다. 중요한 것은 설계가
반영할 도메인의 요구사항이고 그 안에서 클라이언트가 객체에게 요구하는 행동이다. 요구사항을
실용적으로 수용하는 것을 목표로 삼아야 한다. 요점은 자연어에 현혹되지 말고 요구사항 속에서
클라이언트가 기대하는 행동에 집중하라는 것이다.
서브클래싱
다른 클래스의 코드를 재사용할 목적으로 상속을 사용하는 경우를 가리킨다. 자식 클래스와
부모 클래스의 행동이 호환되지 않기 때문에 자식 클래스 인스턴스가 부모 클래스의
인스턴스를 대체할 수 없다. 서브클래싱을 구현 상속 또는 클래스 상속 이라고 부르기도 한다.
서브타이핑
타입 계층을 구성하기 위해 상속을 사용하는 경우를 가리킨다. 서브타이핑에서는 자식
클래스와 부모 클래스의 행동이 호환되기 때문에 자식 클래스의 인스턴스가 부모 클래스의
인스턴스를 대체할 수 있다. 서브타이핑을 인터페이스 상속 이라고 부르기도 한다.
두 개념은 나누는 기준은 상속을 사용하는 목적이다. 슈퍼타입과 서브타입 사이의 관계에서 가장
중요한 것은 퍼블릭 인터페이스다. 즉, 어떤 타입이 다른 타입의 서브타입이 되기 위해서는
행동 호환성 을 만족시켜야 한다. 자식 클래스와 부모 클래스 사이의 행동 호환성은 부모 클래스에 대한 자식 클래스의 대체 가능성 을 포함한다.
행동 호환성과 대체 가능성은 올바른 상속 관계를 구축하기 위해 따라야 할 지침이라고 할 수 있으며 리스코프 치환 원칙이라는 이름으로 소개되어 왔다.
1988년 바바라 리스코프는 올바른 상속 관계의 특징을 정의하기 위해 리스코프 치환 법칙(LSP) 을
발표했다. 리스코프에 따르면 서브타이핑 관계가 만족되기 위해서는 다음 조건을 만족시켜야 한다.
형의 각 객체
o1에 대해 형의 객체o2가 하나 있고, 에 의해 정의된 모든 프로그램 에서 가 로 치환될 때, 의 동작이 변하지 않으면 는 의 서브타입이다.
정리하면 서브타입은 그것의 기반 타입에 대해 대체 가능해야 한다 는 것으로 클라이언트가
“차이점을 인식하지 못한 채 파생 클래스의 인터페이스를 사용할 수 있어야 한다”는 것이다.
이 원칙에 따르면 자식 클래스가 부모 클래스와 행동 호환성을 유지함으로써 부모 클래스를
대체할 수 있도록 구현된 상속 관계만을 서브타이핑이라고 볼러야 한다.
이번 예시는 ‘직사각형과 정사각형의 상속 관계’로 전형적인 LSP 위반 고전 사례 중 하나다.
public class Rectangle {
private int x, y, width, height;
public Rectangle(int x, int y, int width, int height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
개념적으로 정사각형은 사각형의 특수한 경우이고 사각형은 정사각형의 일반적인 경우이기 때문에 정사각형과 사각형 사이에 어휘적으로 is-a 관계가 성립한다. 이를 구현하는 가장 간단한 방법은
상속을 이용하는 것이다.
정사각형은 너비와 높이가 동일해야 한다. 따라서 제약 사항을 강제할 수 있도록 다음과 같이 코드를 구성한다.
public class Square extends Rectangle {
public Square(int x, int y, int size) {
super(x, y, size, size);
}
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setWidth(height);
super.setHeight(height);
}
}
문제는 Rectangle 을 사용하는 클라이언트는 사각형의 너비와 높이가 다를 수 있다고 가정하기
때문에 다음과 같은 코드를 작성할 수 있다.
public void resize(Rectangle rectangle, int width, int height) {
rectangle.setWidth(width);
rectangle.setHeight(height);
assert rectangle.getWidth() == width && rectangle.getHeight() == height;
다음과 같이 resize 를 사용할 경우 메서드 실행이 실패하게 된다.
Square square = new Square(10, 10, 10);
resize(square, 50, 100);
Rectangle 의 자리에 Square 를 전달할 경우 사각형의 너비와 높이를 독립적으로 변경할 수 있다는 가정은 무너지고 만다. 두 클래스는 LSP를 위반하기 때문에 서브클래싱 관계이며 중요한 것은
클라이언트 관점에서 행동이 호환되는지 여부다.
Square 가 Rectangle 을 대체할 수 없는 이유는 클라이언트 관점에서 Square 와 Rectangle 이
다르기 때문이다. LSP는 자식 클래스가 부모 클래스를 대체하기 위해서는 부모 클래스에 대한
클라이언트의 가정을 준수해야 한다는 것을 강조한다. 이는 다음 중요한 결론을 이끈다.
클라이언트와 격리한 채로 본 모델을 의미 있게 검증하는 것이 불가능하다.
특정 모델의 유효성은 클라이언트의 관점에서만 검증 가능하다는 것이다. 대체 가능성을 결정하는 것은 클라이언트다.
클라이언트 관점에서 자식 클래스의 행동이 부모 클래스의 행동과 호환되지 않고 그로 인해 대체가 불가능하다면 어휘적으로 is-a라고 말할 수 있다고 하더라도 그 관계를 is-a 관계라고 할 수 없다.
is-a는 클라이언트 관점에서 is-a일 때만 참이다. is-a 관계로 표현된 문장을 볼 때마다 “클라이언트
입장에서”라는 말이 빠져 있다고 생각하라.
결론적으로 상속이 서브타이핑을 위해 사용될 경우에만 is-a 관계다.
LSP는 클라이언트가 어떤 자식 클래스와도 안정적으로 협력할 수 있는 상속 구조를 구현할 수 있는 가이드라인을 제공한다. 클라이언트 입장에서 퍼블릭 인터페이스의 행동 양식이 변경되지 않는다면 클라이언트의 코드를 변경하지 않고도 새로운 자식 클래스와 협력할 수 있게 된다는 것이다.
8장에서 중복 할인 정책 구현을 위해 OverlappedDiscountPolicy 를 추가하더라도 클라이언트를
수정할 필요가 없던 것을 기억하는가?
public class OverlappedDiscountPolicy extends DiscountPolicy {
private List<DiscountPolicy> discountPolicies = new ArrayList<>();
public OverlappedDiscountPolicy(DiscountPolicy...discountPolicies) {
this.discountPolicies = Arrays.asList(discountPolicies);
}
@Override
protected Money getDiscountAmount(Screening screening) {
Money result = Money.ZERO;
for(DiscountPolicy each : discountPolicies) {
result = result.plus(each.calculateDiscountAmount(screening));
}
return result;
}
}
위 설계는 DIP, OCP, LSP이 한데 어우러져 설계를 확장 가능하게 만든 대표적 예다.
Movie 와 OverlappedDiscountPolicy 모두 추상 클래스인 DiscountPolicy 에Movie 와 하위 수준의 모듈인 OverlappedDiscountPolicy 는 모두 추상 클래스인 DiscountPolicy 에 의존한다. 따라서 DIP를 만족한다.Movie 의 관점에서 DiscountPolicy 대신 OverlappedDiscountPolicy 와 협력하더라도 아무런 문제가 없다. 다시 말해서 OverlappedDiscountPolicy 는 클라이언트에 대한 영향 없이도 DiscountPolicy 를 대체할 수 있다. 따라서 이 설계는 LSP를 만족한다.DiscountPolicy 의 자식 클래스인 OverlappedDiscountPolicy 를 추가하더라도 Movie 에는 영향을 끼치지 않는다. 다시 말해서자식 클래스가 클라이언트 관점에서 부모 클래스를 대체할 수 있다면 기능 확장에 따라 코드를
수정할 필요가 없어진다. 따라서 LSP는 OCP를 만족하는 설계를 위한 전제 조건이다.
한 가지 기억해야 할 사실은 클래스 상속은 타입 계층을 구현할 수 있는 다양한 방법 중 하나일
뿐이라는 것이다. 자바의 인터페이스, 스칼라의 트레이트 등을 이용하면 클래스 사이의 상속을
이용하지 않고 서브타이핑 관계를 구현할 수 있다. 핵심은 구현 방법에 상관 없이 클라이언트
관점에서 슈퍼타입에 대해 기대하는 모든 것이 서브타입에게도 적용돼야 한다는 것이다.
클라이언트와 서버 사이의 협력의 의무(obligation)와 이익으로 구성된 계약의 관점에서 표현하는
것을 계약에 의한 설계(Design By Contract, DBC)라고 부른다. DBC는 클라이언트가 정상적으로
메서드를 실행시키기 위해 만족해야 하는 사전조건과 메서드가 실행된 후에 서버가 클라이언트에게 보장해야 하는 사후조건, 메서드 실행 전과 실행 후에 인스턴스가 만족시켜야 하는 클래스 불변식의 세 가지 요소로 구성된다.
계약에 의한 설계를 이용하면 LSP가 강제하는 조건을 계약의 개념을 통해 좀 더 명확히 설명할 수
있다. 이 관계는 다음 한 문장으로 요약할 수 있다.
서브타입이 LSP를 만족시키기 위해서는 클라이언트와 슈퍼타입 간에 체결된 ‘계약’을 준수해야 한다.
public class Movie {
...
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
DiscountPolicy 는 calculateMovieFee 메시지를 수신해 할인 가격을 계산해서 반환한다.
public abstract class DiscountPolicy {
public Money calculateDiscountAmount(Screening screening) {
for(DiscountCondition each : conditions) {
if(each.isSatisfiedBy(screening)) {
return getDiscountAmount(screening);
}
}
return screening.getMovieFee();
}
abstract protected Money getDiscountAmount(Screening screening);
}
DBC와 LSP의 관계에서 서브타입이 슈퍼타입처럼 보일 수 있는 유일한 방법은 클라이언트가
슈퍼타입과 맺은 계약을 서브타입이 준수하는 것뿐이다. 한편, 코드를 자세히 살펴보면 암묵적인
사전조건과 사후조건이 존재한다는 사실을 알 수 있다.
먼저 사건조건부터 살펴보자. DiscountPolicy 의 calculateDiscountAmount 메서드는 인자로
전달된 screening 이 null 인지 여부를 확인하지 않는다. 또한 screening 의 영화 시작 시간이
아직 자나지 않았다고 가정할 것이다. 따라서 사전조건을 다음과 같이 표현할 수 있다.
assert screening != null && screening.getStartTime().isAfter(LocalDateTime.now());
Movie 의 calculateMovieFee 메서드를 보면 calculateDiscountAmount 메서드의 반환값에 어떤
처리도 하지 않고 fee 에서 차감하고 있음을 알 수 있다. 따라서 calculateDiscountAmoutn 의
반환값은 항상 null 이 아니어야 하며, 반환되는 값은 청구되는 요금이기 때문에 0원보다는 커야
한다.
assert amount != null && amount.isGreaterThanOrEqual(Money.ZERO);
calculateDiscountAmount 메서드에 사전조건과 사후조건을 추가하면 다음과 같다.
public abstract class DiscountPolicy {
private List<DiscountCondition> conditions = new ArrayList<>();
public DiscountPolicy(DiscountCondition ... conditions) {
this.conditions = Arrays.asList(conditions);
}
public Money calculateDiscountAmount(Screening screening) {
checkPrecondition(screening);
Money amount = Money.ZERO;
for(DiscountCondition each : conditions) {
if (each.isSatisfiedBy(screening)) {
amount = getDiscountAmount(screening);
checkPostcondition(amount);
return amount;
}
}
amount = screening.getMovieFee();
checkPostcondition(amount);
return amount;
}
protected void checkPrecondition(Screening screening) {
assert screening != null &&
screening.getStartTime().isAfter(LocalDateTime.now());
}
protected void checkPostcondition(Money amount) {
assert amount != null && amount.isGreaterThanOrEqual(Money.ZERO);
}
abstract protected Money getDiscountAmount(Screening Screening);
}
calculateDiscountAmount 메서드가 정의한 사전조건을 만족시키는 것은 Movie 의 책임이다.
따라서 Movie 는 사전조건을 위반하는 screening 을 전달해서는 안 된다.
public class Movie {
// ...
public Money calculateMovieFee(Screening screening) {
if (screening == null ||
screening.getStartTime().isBefore(LocalDateTime.now())) {
throw new InvalidScreeningException();
}
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
DiscountPolicy 를 구현하는 구체 클래스들은 Movie 와의 계약을 변경하지 않기 때문에 Movie 의 입장에서 이 클래스들은 서브타이핑 관계라고 할 수 있다.
계약의 관점에서 상속이 초래하는 가장 큰 문제는 자식 클래스가 부모 클래스의 메서드를
오버라이딩할 수 있다는 점이다.
예를 들어 BrokenDiscountPolicy 는 calculateDiscountAmount 메서드를 오버라이딩한 후 종료
시간이 자정을 넘는 영화를 예매할 수 없다는 강화된 사전조건을 추가한다.
public class BrokenDiscountPolicy extends DiscountPolicy {
public BrokenDiscountPolicy(DiscountCondition... conditions) {
super(conditions);
}
@Override
public Money calculateDiscountAmount(Screening screening) {
checkPrecondition(screening); // 기존의 사전조건
checkStrongerPrecondition(screening); // 더 강력한 사전조건
Money amount = screening.getMovieFee();
checkPostcondition(amount); // 기존의 사후조건
return amount;
}
private void checkStrongerPrecondition(Screening screening) {
assert screening.getEndTime().toLocalTime()
.isBefore(LocalTime.MIDNIGHT);
}
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
문제는 Movie 는 DiscountPolicy 의 사전조건만 알고 있다는 것이다. 따라서 자정이 지난 후에
종료되는 Screening 을 전달하더라도 문제가 없다고 가정할 것이다.
하지만 BrokenDiscountPolicy 의 사전조건은 이를 허용치 않기 때문에 협력은 실패하고 만다.
클라이언트 관점에서 이 새 클래스는 DiscountPolicy 를 대체할 수 없기 때문에 서브타입이 아니다.
따라서 서브타입이 되기 위해 다음 조건을 만족해야 한다.
서브타입에 더 강력한 사전조건을 정의할 수 없다.
그 반대로 사전조건을 제거해서 약화시킨다면 어떻게 될까?
public class BrokenDiscountPolicy extends DiscountPolicy {
// ...
@Override
public Money calculateDiscountAmount(Screening screening) {
// checkPrecondition(screening); // 기존의 사전조건 제거
Money amount = screening.getMovieFee();
checkPostcondition(amount); // 기존의 사후조건
return amount;
}
}
Movie 는 여전히 DiscountPolicy 가 정의한 사전조건을 준수할 것이다. 따라서 기존 협력에 아무 문제를 미치지 않는다.
서브타입에 슈퍼타입과 같거나 더 약한 사전조건을 정의할 수 있다.
만약 사후조건을 강화한다면 어떨까?
public class BrokenDiscountPolicy extends DiscountPolicy {
public BrokenDiscountPolicy(DiscountCondition... conditions) {
super(conditions);
}
@Override
public Money calculateDiscountAmount(Screening screening) {
checkPrecondition(screening); // 기존의 사전조건
Money amount = screening.getMovieFee();
checkPostcondition(amount); // 기존의 사후조건
checkStrongerPostCondition(amount); // 더 강력한 사후조건
return amount;
}
private void checkStrongerPostcondition(Money amount) {
assert amount.isGreaterThanOrEqual(Money.wons(1000));
}
}
기존 제약에 최소 1000원 이상은 돼야 한다는 새 사후조건을 추가한다. 사후조건을 강화하고 있다.
Movie 는 최소한 0원보다 큰 금액을 반환받기만 하면 협력이 정상적으로 수행되었다고 가정한다. 따라서 이미 체결된 계약을 위반하지 않는다.
서브타입에 슈퍼타입과 같거나 더 강한 사후조건을 정의할 수 있다.
사후조건을 약하게 하면 어떻게 될까?
public class BrokenDiscountPolicy extends DiscountPolicy {
public BrokenDiscountPolicy(DiscountCondition... conditions) {
super(conditions);
}
@Override
public Money calculateDiscountAmount(Screening screening) {
checkPrecondition(screening); // 기존의 사전조건
Money amount = screening.getMovieFee();
// checkPostcondition(amount); // 기존의 사후조건 제거
checkWeakerPostCondition(amount); // 더 약한 사후조건
return amount;
}
private void checkWeakerPostCondition(Money amount) {
assert amount != null;
}
}
변경 코드에서는 요금 계산 결과가 마이너스라도 그대로 반환한다. 따라서 기존의 0원보다 크다고 믿는 Movie 의 계약 조건을 충족하지 않는다.
서브타입에 더 약한 사후조건을 정의할 수 없다.
따라서 DBC를 이용하여 LSP를 설명하면 다음과 같이 정리할 수 있다.
슈퍼타입에서 정의한 사전조건보다 더 약한 사전조건은 정의할 수 있다.
슈퍼타입에서 정의한 사후조건보다 더 강한 사후조건을 정의할 수 있다.