"상속의 목적은 코드 재사용이 아니다. 상속은 타입 계층을 구조화하기 위해 사용해야 한다."
이 장에서는 올바른 상속의 조건을 다룹니다. 상속이 단순히 코드 재사용이 아닌, 타입 계층 구조화를 위한 도구임을 이해하고, 리스코프 치환 원칙을 통해 올바른 상속 관계를 구축하는 방법을 학습합니다.
- 타입과 타입 계층의 개념 이해하기
- 서브클래싱과 서브타이핑의 차이점 파악하기
- 행동 호환성의 중요성 이해하기
- 리스코프 치환 원칙(LSP) 완벽히 이해하기
- 계약에 의한 설계와 서브타이핑의 관계 파악하기
- 클라이언트 관점에서 상속 관계 판단하기
📂 코드: Movie 예약 시스템 전반
정의:
우리가 인지하는 세상의 사물의 종류
예시:
- 자바, 루비, C → 프로그래밍 언어라는 타입
- 사과, 바나나, 오렌지 → 과일이라는 타입
구성 요소:
1. 심볼 (Symbol)
- 타입의 이름
2. 내연 (Intension)
- 타입의 정의
- 타입에 속하는 객체들이 가지는 공통 속성
3. 외연 (Extension)
- 타입에 속하는 객체들의 집합
핵심:
타입은 공통의 특징을 공유하는 대상들의 분류
정의:
비트 묶음에 의미를 부여하기 위해 정의된 제약과 규칙
목적:
1. 타입에 수행될 수 있는 유효한 오퍼레이션의 집합을 정의
2. 타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공
예시:
// 1. 유효한 오퍼레이션 정의
int a = 10;
String b = "Hello";
a + 20; // ✅ int 타입은 + 연산 가능
b + " World"; // ✅ String 타입도 + 연산 가능
a.substring(0, 1); // ❌ int 타입은 substring 연산 불가능// 2. 문맥 제공
int x = 1 + 2; // 덧셈 연산 = 3
String y = "A" + "B"; // 문자열 연결 = "AB"
// 같은 + 연산자지만
// 타입에 따라 다른 의미!핵심:
타입 = 동일한 오퍼레이션을 적용할 수 있는 인스턴스들의 집합
정의:
객체의 퍼블릭 인터페이스가 객체의 타입을 결정한다.
동일한 퍼블릭 인터페이스를 제공하는 객체들은
동일한 타입으로 분류된다.
핵심 원칙:
객체의 타입을 결정하는 것은
내부 속성이 아니라
외부에 제공하는 행동이다!
예시:
// 퍼블릭 인터페이스로 타입 정의
public interface DiscountPolicy {
Money calculateDiscountAmount(Screening screening);
}
// 동일한 퍼블릭 인터페이스 = 동일한 타입
public class AmountDiscountPolicy implements DiscountPolicy {
private Money discountAmount; // 내부 속성은 다름
@Override
public Money calculateDiscountAmount(Screening screening) {
return discountAmount;
}
}
public class PercentDiscountPolicy implements DiscountPolicy {
private double percent; // 내부 속성은 다름
@Override
public Money calculateDiscountAmount(Screening screening) {
return screening.getMovieFee().times(percent);
}
}중요:
내부 구현은 완전히 다르지만
동일한 퍼블릭 인터페이스를 제공하므로
두 클래스는 동일한 타입!
Movie 클라이언트는 둘을 구분하지 못함
(구분할 필요도 없음!)
| 관점 | 타입의 의미 | 결정 요소 |
|---|---|---|
| 개념 | 사물의 분류 | 공통 특징 |
| 프로그래밍 언어 | 오퍼레이션 집합 | 적용 가능한 연산 |
| 객체지향 | 퍼블릭 인터페이스 | 수신 가능한 메시지 |
정의:
슈퍼타입 (Supertype):
- 더 일반적인 타입
- 집합이 다른 집합의 모든 멤버를 포함
- 타입 정의가 더 범용적이고 넓은 의미
서브타입 (Subtype):
- 더 특수한 타입
- 집합이 다른 집합에 포함됨
- 타입 정의가 더 구체적이고 좁은 의미
시각화:
┌────────────────────────────────────┐
│ 프로그래밍 언어 │ ← 슈퍼타입
│ (더 일반적, 더 넓은 범위) │
│ │
│ ┌──────────────────────────────┐ │
│ │ 객체지향 언어 │ │ ← 서브타입
│ │ (더 특수한, 더 좁은 범위) │ │
│ │ │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ 클래스 기반 언어 │ │ │ ← 더 구체적인 서브타입
│ │ │ │ │ │
│ │ │ Java, C++, C# │ │ │
│ │ └────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ 프로토타입 기반 언어 │ │ │
│ │ │ │ │ │
│ │ │ JavaScript │ │ │
│ │ └────────────────────────┘ │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────┘
일반화 (Generalization):
다른 타입을 완전히 포함하거나 내포하는 타입을
식별하는 행위 또는 그 행위의 결과
방향: 서브타입 → 슈퍼타입
특수화 (Specialization):
다른 타입 안에 전체적으로 포함되거나
완전히 내포되는 타입을 식별하는 행위 또는 그 행위의 결과
방향: 슈퍼타입 → 서브타입
예시:
일반화:
자바, C++ → 클래스 기반 언어 → 객체지향 언어 → 프로그래밍 언어
특수화:
프로그래밍 언어 → 객체지향 언어 → 클래스 기반 언어 → 자바, C++
슈퍼타입:
서브타입이 정의한 퍼블릭 인터페이스를
일반화시켜
상대적으로 범용적이고 넓은 의미로 정의한 것
서브타입:
슈퍼타입이 정의한 퍼블릭 인터페이스를
특수화시켜
상대적으로 구체적이고 좁은 의미로 정의한 것
┌─────────────────────────────────────────────────────┐
│ │
│ 서브타입의 인스턴스는 │
│ 슈퍼타입의 인스턴스로 간주될 수 있다! │
│ │
│ 이것이 타입 계층의 핵심! │
│ │
└─────────────────────────────────────────────────────┘
예시:
// 슈퍼타입
public abstract class DiscountPolicy {
// 범용적인 인터페이스
public abstract Money calculateDiscountAmount(Screening screening);
}
// 서브타입 1
public class AmountDiscountPolicy extends DiscountPolicy {
// 구체적인 구현 (금액 할인)
@Override
public Money calculateDiscountAmount(Screening screening) {
return discountAmount;
}
}
// 서브타입 2
public class PercentDiscountPolicy extends DiscountPolicy {
// 구체적인 구현 (비율 할인)
@Override
public Money calculateDiscountAmount(Screening screening) {
return screening.getMovieFee().times(percent);
}
}사용:
// 서브타입을 슈퍼타입으로 간주!
DiscountPolicy policy1 = new AmountDiscountPolicy(...);
DiscountPolicy policy2 = new PercentDiscountPolicy(...);
// 클라이언트는 동일하게 사용
policy1.calculateDiscountAmount(screening);
policy2.calculateDiscountAmount(screening);📂 코드: Bird, Penguin 예시 / Rectangle, Square 예시
┌────────────────────────────────────────────┐
│ 상속의 두 가지 용도 │
├────────────────────────────────────────────┤
│ │
│ 1. 타입 계층 구현 │
│ - 부모: 일반적 개념 (슈퍼타입) │
│ - 자식: 특수한 개념 (서브타입) │
│ ✅ 올바른 용도 │
│ │
│ 2. 코드 재사용 │
│ - 부모-자식 강한 결합 │
│ - 변경하기 어려운 코드 │
│ ❌ 위험한 용도 │
│ │
└────────────────────────────────────────────┘
두 질문에 모두 "예"라고 답할 수 있는 경우에만 상속을 사용하라:
질문 1:
상속 관계가 is-a 관계를 모델링하는가?
질문 2:
클라이언트 입장에서
부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가?
(행동 호환성)
직관적 판단:
1. 펭귄은 새다. (Penguin is-a Bird) ✓
2. 새는 날 수 있다. ✓
코드로 옮기기:
public class Bird {
public void fly() {
// 날다
}
}
public class Penguin extends Bird {
// 펭귄은 새를 상속
}문제 발생:
Penguin penguin = new Penguin();
penguin.fly(); // ??? 펭귄은 날 수 없는데!어휘적 정의:
"펭귄은 새다" ✓ (생물학적으로 맞음)
기대되는 행동:
"새는 날 수 있다"
"펭귄은 날 수 없다"
→ 행동 불일치! ✗
결론:
어휘적 정의가 아니라
기대되는 행동에 따라 타입 계층을 구성해야 한다!
행동의 호환 여부를 판단하는 기준:
→ 클라이언트의 관점!
클라이언트가 두 타입이 동일하게 행동할 것이라고 기대한다면
→ 두 타입을 타입 계층으로 묶을 수 있다
클라이언트가 두 타입이 다르게 행동할 것이라고 기대한다면
→ 두 타입을 타입 계층으로 묶으면 안 된다
public void flyBird(Bird bird) {
// 클라이언트의 기대:
// 인자로 전달된 모든 bird는 날 수 있어야 한다!
bird.fly();
}문제 상황:
flyBird(new Sparrow()); // ✅ 참새는 날 수 있음
flyBird(new Eagle()); // ✅ 독수리는 날 수 있음
flyBird(new Penguin()); // ❌ 펭귄은 날 수 없음!public class Penguin extends Bird {
@Override
public void fly() {
// 아무것도 하지 않음
}
}문제:
펭귄은 날지 못한다. ✓
하지만:
어떤 행동도 수행하지 않기 때문에
"모든 bird가 날 수 있다"는 클라이언트의 기대를 만족시키지 못함
→ 올바른 설계가 아님
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("펭귄은 날 수 없습니다");
}
}문제:
flyBird() 메서드에 전달되는 인자의 타입에 따라
메서드가 실패하거나 성공한다
"모든 bird가 날 수 있다"는 클라이언트의 기대를 만족시키지 못함
→ 올바른 설계가 아님
public void flyBird(Bird bird) {
if (!(bird instanceof Penguin)) {
bird.fly();
}
}문제:
1. 새로운 타입 추가 시 코드 수정 필요
- Ostrich(타조) 추가 시 → flyBird 수정
- 결합도 증가!
2. 개방-폐쇄 원칙(OCP) 위반
- 확장에는 열려있지만 수정에는 닫혀있어야 함
- instanceof는 수정을 강제함
→ 최악의 해결책!
// 최상위: 새 (걷는 행동만)
public class Bird {
public void walk() {
// 걷다
}
}
// 날 수 있는 새
public class FlyingBird extends Bird {
public void fly() {
// 날다
}
}
// 펭귄 (날 수 없는 새)
public class Penguin extends Bird {
// fly() 없음
}사용:
// 날 수 있는 새만 받음 (명확한 계약!)
public void flyBird(FlyingBird bird) {
bird.fly(); // 항상 성공!
}
flyBird(new Sparrow()); // ✅
flyBird(new Eagle()); // ✅
flyBird(new Penguin()); // ❌ 컴파일 에러! (의도한 대로)계층 구조:
Bird
↑
┌────┴────┐
│ │
FlyingBird Penguin
↑
┌───┴───┐
│ │
Sparrow Eagle
// 날 수 있는 인터페이스
public interface Flyer {
void fly();
}
// 걸을 수 있는 인터페이스
public interface Walker {
void walk();
}
// 참새: 날 수 있고 걸을 수 있음
public class Sparrow implements Flyer, Walker {
@Override
public void fly() { ... }
@Override
public void walk() { ... }
}
// 펭귄: 걸을 수만 있음
public class Penguin implements Walker {
@Override
public void walk() { ... }
}클라이언트:
// Client 1: 날 수 있는 것만 필요
public class FlyingClient {
public void fly(Flyer flyer) {
flyer.fly();
}
}
// Client 2: 걸을 수 있는 것만 필요
public class WalkingClient {
public void walk(Walker walker) {
walker.walk();
}
}시각화:
Flyer Walker
↑ ↑
│ │
└──┬────┬──────┘
│ │
Sparrow Penguin
클라이언트에 따라 인터페이스를 분리하면
변경에 대한 영향을 더 세밀하게 제어할 수 있다!
예시:
Client1의 기대가 바뀌어 Flyer 인터페이스가 변경되어도
→ Flyer에 의존하는 객체만 영향받음
→ Walker만 사용하는 Penguin은 영향 없음!
이것이 인터페이스 분리 원칙 (ISP)
public interface Flyer {
void fly();
}
public interface Walker {
void walk();
}
// Bird는 Walker를 합성
public class Bird {
private Walker walker;
public Bird(Walker walker) {
this.walker = walker;
}
public void walk() {
walker.walk();
}
}
// Penguin은 Bird를 합성하고 Walker 구현
public class Penguin implements Walker {
private Bird bird;
public Penguin() {
this.bird = new Bird(this);
}
@Override
public void walk() {
// 걷기 구현
}
public void swim() {
// 펭귄 특화 행동
}
}
// Sparrow는 Flyer와 Walker 모두 구현
public class Sparrow implements Flyer, Walker {
private Bird bird;
public Sparrow() {
this.bird = new Bird(this);
}
@Override
public void fly() {
// 날기 구현
}
@Override
public void walk() {
// 걷기 구현
}
}장점:
1. 상속보다 유연함
- 런타임에 행동 교체 가능
2. 인터페이스 재사용
- Bird 코드 재사용하면서도
- Penguin은 fly 인터페이스 없음
3. 단일 책임 원칙 준수
- 각 클래스가 명확한 책임
서브클래싱 (Subclassing):
= 구현 상속 (Implementation Inheritance)
= 클래스 상속 (Class Inheritance)
목적: 코드 재사용
특징:
- 자식과 부모의 행동이 호환되지 않음
- 자식이 부모를 대체할 수 없음
- 결합도 높음
- 변경 어려움
예시: Stack extends Vector
서브타이핑 (Subtyping):
= 인터페이스 상속 (Interface Inheritance)
목적: 타입 계층 구성
특징:
- 자식과 부모의 행동이 호환됨
- 자식이 부모를 대체 가능 (LSP)
- 다형성 구현
- 유연한 설계
예시: AmountDiscountPolicy extends DiscountPolicy
| 측면 | 서브클래싱 | 서브타이핑 |
|---|---|---|
| 목적 | 코드 재사용 | 타입 계층 |
| 행동 호환성 | 없음 | 있음 |
| 대체 가능성 | 불가능 | 가능 |
| 결합도 | 높음 | 낮음 |
| 다형성 | 불가능 | 가능 |
| 변경 용이성 | 어려움 | 쉬움 |
| 예시 | Stack/Vector | DiscountPolicy 계층 |
자식 클래스가 부모 클래스의 코드를 재사용할 목적으로 상속을 사용했다면
→ 서브클래싱
부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용할 목적으로 상속을 사용했다면
→ 서브타이핑
┌─────────────────────────────────────────────────────┐
│ │
│ 서브타입 관계가 유지되기 위해서는 │
│ │
│ 서브타입이 슈퍼타입이 하는 모든 행동을 │
│ 동일하게 할 수 있어야 한다 │
│ │
│ = 행동 대체성 (Behavioral Substitution) │
│ │
└─────────────────────────────────────────────────────┘
📂 코드: [
Rectangle.java] / [Square.java] / [BrokenDiscountPolicy.java]
S형의 각 객체 o1에 대해 T형의 객체 o2가 하나 있고,
T에 의해 정의된 모든 프로그램 P에서
T가 S로 치환될 때,
P의 동작이 변하지 않으면
S는 T의 서브타입이다.
서브타입은 그것의 기반 타입에 대해 대체 가능해야 한다.
더 간단히:
클라이언트가 차이점을 인식하지 못한 채
기반 클래스의 인터페이스를 통해
서브클래스를 사용할 수 있어야 한다.
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;
}
}직관적 판단:
정사각형은 직사각형이다. (Square is-a Rectangle)
→ 상속을 사용하자!
구현:
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);
}
}클라이언트 코드:
public void resize(Rectangle rectangle, int width, int height) {
rectangle.setWidth(width);
rectangle.setHeight(height);
// Rectangle에 대한 클라이언트의 기대:
// 너비와 높이를 독립적으로 변경할 수 있다
assert rectangle.getWidth() == width &&
rectangle.getHeight() == height;
}Rectangle 사용:
Rectangle rect = new Rectangle(10, 10, 20, 30);
resize(rect, 50, 100); // ✅ 성공!
// rect.width = 50
// rect.height = 100Square 사용:
Square square = new Square(10, 10, 10);
resize(square, 50, 100); // ❌ 실패!
// square.width = 100 (마지막에 설정된 값)
// square.height = 100
// assert 실패!Rectangle 클라이언트의 기대:
- 너비와 높이를 독립적으로 변경 가능
- setWidth()는 너비만 변경
- setHeight()는 높이만 변경
Square의 실제 행동:
- 너비와 높이가 항상 같아야 함
- setWidth()는 너비와 높이 모두 변경
- setHeight()는 너비와 높이 모두 변경
결론:
Rectangle 대신 Square를 사용할 수 없다!
→ Square는 Rectangle의 서브타입이 아니다!
→ LSP 위반!
"정사각형은 직사각형이다"
이 문장은 수학적/어휘적으로는 맞지만
객체지향 설계에서는 틀렸다!
왜?
클라이언트 관점에서
Rectangle과 Square의 행동이 호환되지 않기 때문!
올바른 설계:
// 공통 인터페이스
public interface Shape {
int getArea();
}
// Rectangle과 Square를 독립적으로
public class Rectangle implements Shape {
private int width, height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
}
public class Square implements Shape {
private int size;
public void setSize(int size) {
this.size = size;
}
@Override
public int getArea() {
return size * size;
}
}┌─────────────────────────────────────────────────────┐
│ │
│ "클라이언트와 격리한 채로 본 모델은 │
│ 의미 있게 검증하는 것이 불가능하다" │
│ │
│ - Barbara Liskov │
│ │
│ → 대체 가능성을 결정하는 것은 클라이언트! │
│ │
└─────────────────────────────────────────────────────┘
클라이언트 (Movie):
public class Movie {
private DiscountPolicy discountPolicy;
public Money calculateMovieFee(Screening screening) {
// Movie는 DiscountPolicy에 대해 기대:
// - null이 아닌 유효한 할인 금액 반환
// - 할인 금액은 0 이상
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}서브타입들:
// ✅ AmountDiscountPolicy
public class AmountDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return discountAmount; // 항상 유효한 값 반환
}
}
// ✅ PercentDiscountPolicy
public class PercentDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return screening.getMovieFee().times(percent); // 항상 유효한 값 반환
}
}
// ✅ NoneDiscountPolicy
public class NoneDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO; // 항상 유효한 값 반환
}
}대체 가능:
// Movie 입장에서 모두 동일하게 사용 가능!
Movie movie1 = new Movie(..., new AmountDiscountPolicy(...));
Movie movie2 = new Movie(..., new PercentDiscountPolicy(...));
Movie movie3 = new Movie(..., new NoneDiscountPolicy());
// 모두 정상 동작!
movie1.calculateMovieFee(screening);
movie2.calculateMovieFee(screening);
movie3.calculateMovieFee(screening);is-a 관계로 표현된 문장을 볼 때마다
문장 앞에 "클라이언트 입장에서"라는 말이 빠져있다고 생각하라!
✗ "정사각형은 직사각형이다"
✓ "(클라이언트 입장에서) 정사각형은 직사각형이다"
✗ "펭귄은 새다"
✓ "(클라이언트 입장에서) 펭귄은 새다"
이름이 is-a로 연결 가능하다고 해서
상속 관계로 연결하지 마라!
슈퍼타입과 서브타입이
클라이언트 입장에서 행동이 호환된다면
두 타입을 is-a로 연결해
문장을 만들어도 어색하지 않을 단어로
타입의 이름을 정하라
이름이 아니라 행동이 먼저다!
// 중복 할인 정책 (여러 할인 정책을 중첩 적용)
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;
}
}사용:
Movie avatar = new Movie(
"아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new OverlappedDiscountPolicy(
new AmountDiscountPolicy(Money.wons(800), ...),
new PercentDiscountPolicy(0.1, ...)
)
);이 설계에는 세 가지 설계 원칙이 완벽하게 조화를 이룬다:
1. 의존성 역전 원칙 (DIP)
- Movie와 OverlappedDiscountPolicy 모두
- 추상 클래스 DiscountPolicy에 의존
2. 리스코프 치환 원칙 (LSP)
- Movie 관점에서 DiscountPolicy 대신
- OverlappedDiscountPolicy 사용 가능
- 클라이언트에 영향 없이 대체 가능
3. 개방-폐쇄 원칙 (OCP)
- 새로운 할인 정책 추가
- Movie 코드 수정 불필요
- 기능 확장, 코드 수정 없음
시각화:
┌──────────┐
│ Movie │ (상위 수준 모듈)
└────┬─────┘
│ depends on
▼
┌───────────────┐
│DiscountPolicy │ (추상)
└───────┬───────┘
▲
┌───────┴──────────────────────┐
│ │
┌─────┴────────┐ ┌──────────┴────────┐
│AmountDiscount│ │OverlappedDiscount │
└──────────────┘ └───────────────────┘
(새로 추가해도 Movie 변경 없음)
📂 코드: [
BrokenDiscountPolicy.java]
사전조건 (Precondition):
클라이언트가 정상적으로 메서드를 실행하기 위해
만족시켜야 하는 조건
책임: 클라이언트
사후조건 (Postcondition):
메서드 실행 후
서버가 클라이언트에게 보장해야 하는 조건
책임: 서버
클래스 불변식 (Class Invariant):
메서드 실행 전과 실행 후에
인스턴스가 만족시켜야 하는 조건
책임: 서버
public abstract class DiscountPolicy {
public Money calculateDiscountAmount(Screening screening) {
checkPrecondition(screening); // 사전조건 체크
// ... 로직
}
protected void checkPrecondition(Screening screening) {
// 사전조건 1: screening이 null이 아님
assert screening != null;
// 사전조건 2: 상영 시작 시간이 현재보다 미래
assert screening.getStartTime().isAfter(LocalDateTime.now());
}
}의미:
클라이언트는 다음을 보장해야 함:
1. null이 아닌 Screening 전달
2. 상영 시작 시간이 미래인 Screening 전달
이는 클라이언트의 책임!
public abstract class DiscountPolicy {
public Money calculateDiscountAmount(Screening screening) {
// ... 로직
checkPostcondition(amount); // 사후조건 체크
return amount;
}
protected void checkPostcondition(Money amount) {
// 사후조건 1: 반환값이 null이 아님
assert amount != null;
// 사후조건 2: 반환값이 0 이상
assert amount.isGreaterThanOrEqual(Money.ZERO);
}
}의미:
DiscountPolicy는 다음을 보장해야 함:
1. null이 아닌 Money 반환
2. 0 이상의 금액 반환
이는 서버의 책임!
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));
}
}계약 관점에서 상속이 초래하는 가장 큰 문제:
자식 클래스가 부모 클래스의 메서드를
오버라이딩할 수 있다는 것!
→ 계약 위반 가능성!
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); // 기존 사후조건
checkStrongerPostcondition(amount); // ❌ 더 강한 사후조건!
return amount;
}
// ❌ 추가 사전조건: 상영 종료 시간이 자정 이전
private void checkStrongerPrecondition(Screening screening) {
assert screening.getEndTime().toLocalTime()
.isBefore(LocalTime.MIDNIGHT);
}
// ❌ 추가 사후조건: 할인 금액이 1000원 이상
private void checkStrongerPostcondition(Money amount) {
assert amount.isGreaterThanOrEqual(Money.wons(1000));
}
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO;
}
}클라이언트 코드 (Movie):
// Movie는 DiscountPolicy와의 계약만 알고 있음
DiscountPolicy policy = new BrokenDiscountPolicy(...);
// 사전조건 만족 (DiscountPolicy의 계약)
Screening screening = new Screening(...); // null 아님, 미래 시각
// 하지만 BrokenDiscountPolicy는 추가 사전조건 요구!
// "상영 종료 시간이 자정 이전"
// → Movie는 이를 모름!
// → 협력 실패!┌─────────────────────────────────────────────────────┐
│ │
│ 서브타입에 더 강력한 사전조건을 정의할 수 없다! │
│ │
│ 서브타입에 슈퍼타입과 같거나 │
│ 더 약한 사전조건을 정의할 수 있다 │
│ │
└─────────────────────────────────────────────────────┘
이유:
클라이언트는 슈퍼타입의 계약만 알고 있다.
더 강한 사전조건을 추가하면:
→ 클라이언트가 모르는 조건 추가
→ 협력 실패!
같거나 더 약한 사전조건은:
→ 클라이언트가 이미 만족시키고 있음
→ 협력 성공!
예시:
// ✅ 올바른 서브타입: 사전조건 제거 (더 약함)
public class WeakerPreconditionPolicy extends DiscountPolicy {
@Override
public Money calculateDiscountAmount(Screening screening) {
// checkPrecondition(screening); // 사전조건 제거!
Money amount = Money.ZERO;
// ... 로직
checkPostcondition(amount);
return amount;
}
}
// ❌ 잘못된 서브타입: 사전조건 추가 (더 강함)
public class StrongerPreconditionPolicy extends DiscountPolicy {
@Override
public Money calculateDiscountAmount(Screening screening) {
checkPrecondition(screening);
checkAdditionalPrecondition(screening); // ❌ 추가!
// ...
}
}┌─────────────────────────────────────────────────────┐
│ │
│ 서브타입에 더 약한 사후조건을 정의할 수 없다! │
│ │
│ 서브타입에 슈퍼타입과 같거나 │
│ 더 강한 사후조건을 정의할 수 있다 │
│ │
└─────────────────────────────────────────────────────┘
이유:
클라이언트는 슈퍼타입의 계약을 기대한다.
더 약한 사후조건을 정의하면:
→ 클라이언트의 기대를 만족시키지 못함
→ 협력 실패!
같거나 더 강한 사후조건은:
→ 클라이언트의 기대를 충족하거나 초과
→ 협력 성공!
예시:
// ✅ 올바른 서브타입: 사후조건 강화
public class StrongerPostconditionPolicy extends DiscountPolicy {
@Override
public Money calculateDiscountAmount(Screening screening) {
checkPrecondition(screening);
Money amount = Money.wons(1000); // 최소 1000원 보장
checkPostcondition(amount); // 기존: amount >= 0
checkStrongerPostcondition(amount); // 추가: amount >= 1000
return amount;
}
private void checkStrongerPostcondition(Money amount) {
assert amount.isGreaterThanOrEqual(Money.wons(1000));
}
}
// ❌ 잘못된 서브타입: 사후조건 약화
public class WeakerPostconditionPolicy extends DiscountPolicy {
@Override
public Money calculateDiscountAmount(Screening screening) {
checkPrecondition(screening);
Money amount = Money.wons(-100); // ❌ 음수 가능!
// checkPostcondition(amount); // 사후조건 제거!
return amount;
}
}| 조건 | 서브타입 제약 | 이유 |
|---|---|---|
| 사전조건 | 같거나 더 약해야 함 | 클라이언트가 모르는 조건 추가 불가 |
| 사후조건 | 같거나 더 강해야 함 | 클라이언트 기대를 만족해야 함 |
| 불변식 | 부모의 불변식 유지 | 클래스 상태 일관성 보장 |
계약에 의한 설계는
리스코프 치환 원칙을 코드 수준에서 구체화한 것!
LSP를 만족하려면:
1. 서브타입의 사전조건을 강화하지 말 것
2. 서브타입의 사후조건을 약화하지 말 것
3. 슈퍼타입의 불변식을 유지할 것
┌─────────────────────────────────────────────────────┐
│ │
│ 상속의 목적은 코드 재사용이 아니다! │
│ │
│ 상속의 목적은 타입 계층을 구조화하는 것이다! │
│ │
│ 동일한 메시지에 대해 서로 다르게 행동할 수 있는 │
│ 다형적인 객체를 구현하기 위해서는 │
│ 객체의 행동을 기반으로 타입 계층을 구성해야 한다 │
│ │
└─────────────────────────────────────────────────────┘
상속을 사용하기 전에 다음 질문에 답하라:
□ (클라이언트 입장에서) is-a 관계가 성립하는가?
□ 클라이언트 입장에서 자식이 부모를 대체할 수 있는가?
(행동 호환성)
□ 자식 클래스가 부모 클래스의 사전조건을 강화하지 않는가?
□ 자식 클래스가 부모 클래스의 사후조건을 약화하지 않는가?
□ 자식 클래스가 부모 클래스의 불변식을 유지하는가?
모두 YES → 상속 사용 가능
하나라도 NO → 합성 또는 인터페이스 고려
객체지향에서 타입 = 퍼블릭 인터페이스
동일한 퍼블릭 인터페이스 = 동일한 타입
타입을 결정하는 것은 내부 속성이 아니라 외부 행동!
슈퍼타입: 더 일반적, 더 넓은 의미
서브타입: 더 특수한, 더 좁은 의미
서브타입의 인스턴스는 슈퍼타입의 인스턴스로 간주 가능
서브클래싱:
- 목적: 코드 재사용
- 행동 호환성: 없음
- 대체 가능성: 불가능
- 결과: 강한 결합, 변경 어려움
서브타이핑:
- 목적: 타입 계층 구성
- 행동 호환성: 있음
- 대체 가능성: 가능 (LSP)
- 결과: 유연한 설계, 다형성
서브타입은 슈퍼타입을 대체할 수 있어야 한다.
클라이언트가 차이를 인식하지 못한 채
슈퍼타입 대신 서브타입을 사용할 수 있어야 한다.
대체 가능성을 결정하는 것은 클라이언트!
사전조건: 클라이언트의 책임
사후조건: 서버의 책임
불변식: 서버의 책임
서브타입 규칙:
- 사전조건: 강화 불가 (같거나 약화)
- 사후조건: 약화 불가 (같거나 강화)
- 불변식: 유지
올바른 상속 (서브타이핑)은
여러 설계 원칙이 조화를 이룬다:
1. 리스코프 치환 원칙 (LSP)
- 서브타입의 대체 가능성
2. 의존성 역전 원칙 (DIP)
- 추상화에 의존
3. 개방-폐쇄 원칙 (OCP)
- 확장에 열림, 수정에 닫힘
4. 인터페이스 분리 원칙 (ISP)
- 클라이언트별 인터페이스 분리
5. 단일 책임 원칙 (SRP)
- 하나의 변경 이유
| 예시 | 문제 | 원칙 위반 |
|---|---|---|
| Penguin extends Bird | fly() 행동 불일치 | LSP |
| Square extends Rectangle | 너비/높이 독립성 파괴 | LSP |
| Stack extends Vector | 불필요한 인터페이스 노출 | LSP, ISP |
| BrokenDiscountPolicy | 사전조건 강화 | LSP, DbC |
1. 어휘적 is-a가 아니라 행동 호환성으로 판단하라
- "펭귄은 새다"는 어휘적으로 맞지만
- 클라이언트 관점에서 행동이 다르면 상속 X
2. 클라이언트 관점에서 생각하라
- is-a 관계 앞에 "클라이언트 입장에서" 추가
- 대체 가능성은 클라이언트가 결정
3. 이름이 아니라 행동이 먼저다
- 행동이 호환되면 적절한 이름 부여
- 이름만 보고 상속 관계 결정 X
4. 상속이 어려우면 합성을 고려하라
- 인터페이스로 분리
- 합성으로 재사용
- 유연성 확보
5. 계약을 지켜라
- 사전조건 강화 금지
- 사후조건 약화 금지
- 불변식 유지
- Chapter 11: 합성이 상속보다 나은 이유
- Chapter 12: 다형성의 메커니즘 → 올바른 상속의 조건
- 상속의 문제점 → 서브타이핑으로 해결
- Chapter 14: 일관성 있는 협력
- 유사한 협력 패턴의 일관성
- 설계의 재사용
- 핵심 도메인 개념 파악