간단한 온라인 영화 예매 시스템을 통해 객체지향 프로그래밍을 접해보자
영화 예매 시스템
용어
- 영화 : 영화에 대한 기본 정보 (제목, 상영시간, 가격 정보)
- 상영 : 실제로 관객들이 영화를 관람하는 사건
사용자가 실제로 예매하는 대상은 영화가 아니라 상영 !
요구사항
- 특정 조건을 만족하는 예매자는 요금 할인받기 가능
- 할인액을 결정하는 두 가지 규칙
- 할인 조건
- 순서 조건 : 상영 순번을 이용해 할인 여부 결정
예시) 순번이 10인 경우 매일 10번째 상영 영화 할인 - 기간 조건 : 상영 시작 시간을 이용해 할인 여부 결정
예시) 월요일, 시작시간 오전 10시, 종료시간 오후 1시인 모든 영화 할인
- 순서 조건 : 상영 순번을 이용해 할인 여부 결정
- 할인 정책
- 금액 할인 정책 : 예매 요금에서 일정 금액 할인
예시) 영화 9000원, 금액할인정책 800원 → 8200원 - 비율 할인 정책 : 정가에서 일정 비율 요금 할인
예시) 영화 9000원, 비율할인정책 10% → 8100원
- 금액 할인 정책 : 예매 요금에서 일정 금액 할인
- 영화별로 하나의 할인 정책만 할당 가능 (할인 정책 지정하지 않는 것도 가능)
- 할인 조건은 다수의 할인 조건 함께 지정 가능 (순서, 기간 조건 섞기도 가능)
- 사용자의 예매 정보가 할인 조건 중 하나라도 만족하는지 검사
- 만족할 경우, 할인 정책 이용해서 할인 요금 계산
- 할인 정책은 적용돼 있지만 할인 조건을 만족하지 못하는 경우나
아예 할인 정책이 적용돼 있지 않는 경우는 요금 할인 X
객체지향 프로그래밍을 향해
협력, 객체, 클래스
객체지향 프로그램을 작성할 때 가장 먼저 무엇을 고려할까?
🙋♂️ : 클래스요 !
💁🏻♂️ : 안타깝게도 정답은 아닙니다.
🙋♂️ : ???
객체지향은 말 그대로 객체를 지향하는 것 !
진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점 을 맞출 때만 얻을 수 있다.
다음 두 가지에 집중하자
1. 어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민하라
클래스는 공통 상태와 행동을 공유하는 객체들을 추상화한 것 !
따라서 클래스의 윤곽을 잡으려면 어떤 객체들이 어떤 상태와 행동을 가지는지 먼저 결정해야 한다.
2. 객체를 독립적인 존재가 아닌, 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐라
객체는 다른 객체에게 도움을 주거나 의존하면서 살아가는 협력적 존재 !
객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고
이 타입을 기반으로 클래스를 구현하자.
도메인의 구조를 따르는 프로그램 구조
도메인(domain) : 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야
일반적으로 클래스 이름은 대응되는 도메인 개념의 이름과 동일 또는 유사해야 한다.
클래스 사이 관계도 최대한 도메인 개념 사이의 관계와 유사하게 만들어서
프로그램의 구조를 이해하고 예상하기 쉽게 만들어야 한다 !
이 원칙에 따라 클래스 이름을 명명해보자.
- 영화 :
Movie
- 상영 :
Screening
- 할인 정책 :
Discount
- 금액 할인 정책 :
AmountDiscountPolicy
- 비율 할인 정책 :
PercentDiscountPolicy
- 할인 조건 :
DiscountCondition
- 순번 조건 :
SequenceCondition
- 기간 조건 :
PeriodCondition
- 예매 :
Reservation
도메인의 개념과 관계를 반영하도록 프로그램을 구조화해야 하기 때문에
클래스의 구조는 도메인의 구조와 유사한 형태 를 띠어야 한다 !
클래스 구현하기
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 getStrartTime() {
return whenScreened;
}
public boolean isSequence(int sequence) {
return this.sequence == sequence;
}
public Money getMovieFee() {
return movie.getFee();
}
}
중요한 점은 인스턴스 변수의 가시성은 private
이고,
메소드의 가시성은 public
이라는 것 !
클래스를 구현하거나 다른 개발자가 만든 클래스를 사용할 때 가장 중요한 것은
클래스의 경계를 구분 짓는 것 이다.
훌륭한 클래스를 설계하기 위한 핵심은,
어떤 부분을 외부에 공개하고 어떤 부분을 감출지 결정하는 것 !
경계의 명확성 이, 객체의 자율성 을 보장한다.
프로그래머에게 구현의 자유 를 제공 !
자율적인 객체
- 객체는 상태(state)와 행동(behavior)을 함께 가지는 복합적 존재
- 객체는 스스로 판단하고 행동하는 자율적인 존재
데이터와 기능을 객체 내부로 함께 묵는 것을 캡슐화 라고 함
대부분의 객체지향 프로그래밍 언어들은 캡슐화에서 한걸음 더 나아가
외부에서의 접근을 통제하는 접근 제어(access control) 메커니즘도 함께 제공.
public
, protected
, private
과 같은 접근 수정자(access modifier)를 제공.
객체 내부 접근을 통제하는 이유는 → 객체를 자율적인 존재 로 만들기 위함 !
객체를 자율적인 존재로 만들기 위해서는 외부의 간섭을 최소화해야 한다.
캡슐화와 접근 제어는 객체를 두 부분으로 나눔.
- 외부에서 접근 가능한 public interface
- 내부에서만 접근 가능한 implementation
인터페이스와 구현의 분리 원칙은 훌륭한 객체지향 프로그램을 만들기 위해 따라야 하는 핵심 원칙 !
객체의 상태는 숨기고 행동만 외부에 공개해라 !
프로그래머의 자유
프로그래머의 역할을
- 클래스 작성자
- 클라이언트 프로그래머
두 가지로 구분하면 유용하다.
클래스 작성자는 새로운 데이터 타입을 프로그램에 추가하고,
클라이언트 프로그래머는 추가된 데이터 타입을 사용한다.
클래스 작성자는 숨겨놓은 부분에 클라이언트 프로그래머가 마음대로 접근할 수 없도록
방지함으로써 클라이언트 프로그래머에 대한 영향을 걱정하지 않고 내부 구현을 마음대로 변경할 수 있다.
→ 이를 구현 은닉 (implementation hiding) 이라 함 !
협력하는 객체들의 공동체
public class Screening {
public Reservation reserve(Customer customer, int audienceCount) {
return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
}
private Monew calculateFee(int audienceCount) {
return movie.calculateMovieFee(this).times(audienceCount);
}
}
reserve
메소드를 보면 calculateFee
라는 private
메소드를 호출해서 요금을 계산하고
결과를 Reservation
생성자에 전달한다.
calculateFee
메소드는 요금을 계산하기 위해 다시 Movie
의 calculateMovieFee
메소드 호출.
calculateMovieFee 의 반환 값은 1인당 예매 요금
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;
}
}
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;
}
}
영화를 예매하기 위해 Screening
, Movie
, Reservation
인스턴스들은 서로의 메소드를 호출하며
상호작용한다. 이 상호작용을 협력(Collaboration) 이라고 함 !
협력에 관한 짧은 이야기
객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송 하는 것 뿐이다.
다른 객체에게 요청이 도착할 때 해당 객체가 메시지를 수신 했다고 이야기한다.
메시지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메시지를 처리할 방법을 결정.
수신된 메시지를 처리하기 위한 자신만의 방법이 바로 메소드(method) !
할인 요금 구하기
할인 요금 계산을 위한 협력 시작하기
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountPolicy = discountPolicy;
}
public Money getFee() {
return fee;
}
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
calculateMovieFee
메소드는 discountPolicy
에 calculateDiscountAmount
메시지를 전송해
할인 요금을 반환받는다.
Money
는 기본 요금 fee
에서 반환된 할인 요금을 차감한다.
이 메소드에는 이상한 점이 있다 !
→ 어떤 할인 정책을 사용할 것인지 결정하는 코드가 어디에도 없다는 것.
할인 정책을 판단하는 코드는 없고 단지 discountPolicy
에게 메시지를 전송할 뿐 !
여기는 두가지 개념이 숨어있다.
바로 상속(inheritance) 과 다형성 !
할인 정책과 할인 조건
금액 할인 정책과 비율 할인 정책을 각각
AmountDiscountPolicy
와 PercentDiscountPolicy
라는 클래스로 구현할 것이다.
두 클래스는 대부분의 코드가 유사하고 할인 요금을 계산하는 방식만 다르다.
그래서 두 클래스의 공통 코드를 보관할 장소가 필요하다.
여기서는 DiscountPolicy
를 부모 클래스로 하여 중복 코드를 두고
두 할인 정책 클래스가 이를 상속받게 한다 !
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);
}
하나의 할인 정책은 여러 개의 할인 조건을 포함할 수 있다.
calculateDiscountAmount
메소드는 전체 할인 조건에 대해 차례대로 DiscountCondition
의
isSatisfiedBy
메소드를 호출한다.
isSatisfiedBy
메소드는 인자로 전달된 Screening
이 할인 조건을 만족시킬 경우 true
,
아니면 false
를 반환한다.
할인 조건을 만족하는 DiscountCondition
이 하나라도 존재하는 경우,
추상 메소드인 getDiscountAmount
를 호출해 할인 요금을 계산,
만족하는 할인 조건이 하나라도 존재하지 않는다면 할인 요금으로 0원을 반환한다.
DiscountPolicy 는 전체적인 흐름은 정의하지만, 실제 요금 계산 부분은
추상 메소드인 getDiscountAmount
메소드에게 위임한다.
실제로는 자식 클래스의 오버라이딩된 메소드가 실행될 것 !
이처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를
자식 클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴 이라고 부름 !
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
할인 조건은 순번 조건과 기간 조건, 두 가지가 있다.
각각 SequenceCondition
과 PeriodCondition
이라는 클래스로 구현할 것이다.
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);
}
}
isSatisfiedBy
메소드는 Screening
의 상영 순번과 일치할 경우
할인 가능한 것으로 판단해서 true
를, 아니면 false
를 반환한다.
public class PeriodCondition implements DiscountCondition {
private DayOfWeek dayOfWeek;
private LocalDateTime startTime;
private LocalDateTime endTime;
public PeriodCondition(DayOfWeek dayOfWeek, LocalDateTime startTime, LocalDateTime endTime) {
this.dayOfWeek = dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
@Override
public boolean isSatisfiedBy(Screening screening) {
return screening.getStrartTime().getDayOfWeek().equals(dayOfWeek) &&
startTime.compareTo(screening.getStrartTime().toLocalTime()) <= 0 &&
endTime.compareTo(screening.getStrartTime().toLocalTime()) >= 0;
}
}
PeriodCondition
은 상영 시작 시간이 특정한 기간 안에 포함되는지 여부를 판단해 할인 여부를 결정한다.
이제 할인 정책을 구현하자.
AmountDiscountPolicy
는 DiscountPolicy
의 자식 클래스로서
할인 조건을 만족할 경우 일정한 금액을 할인해주는 할인 정책을 구현한다.
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 discountAmount;
}
}
PercentDiscountPolicy
역시 DiscountPolicy
의 자식 클래스.
다른 점은 고정 금액이 아닌 일정 비율을 차감한다는 것이다.
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);
}
}
할인 정책 구성하기
하나의 영화에 대해 단 하나의 할인 정책만 설정할 수 있지만,
할인 조건의 경우에는 여러 개를 적용할 수 있다.
Movie
와 DiscountPolicy
의 생성자는 이런 제약을 강제한다.
Movie
의 생성자는 오직 하나의 DiscountPolicy
인스턴스만 받을 수 있도록 선언돼있다.
public class Movie {
...
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
...
this.discountPolicy = discountPolicy;
}
반면 DiscountPolicy
의 생성자는 여러 개의 DiscountCondition
인스턴스를 허용한다.
public abstract class DiscountPolicy {
public DiscountPolicy(DiscountCondition ... conditions) {
this.conditions = Arrays.asList(conditions);
}
이처럼 생성자의 파라미터 목록을 이용해 초기화에 필요한 정보를 전달하도록 강제하면
올바른 상태를 가진 객체의 생성을 보장할 수 있다.
이제 다음과 같이 할인 정책과 할인 조건을 설정해 영화 객체를 만들 수 있다.
Movie avatar = new Movie(
"아바타2",
Duration.ofMinutes(120),
Money.wons(10000),
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
클래스 어디에서도 할인 정책이 금액 할인 정책인지,
비율 할인 정책인지를 판단하지 않는다.
Movie
내부에 할인 정책을 결정하는 조건문이 없는데 어떻게 영화요금 계산 시에
금액 할인 정책과 비율 할인 정책을 선택할 수 있을까?
💁🏻♂️ : 상속과 다형성을 살펴보세요 !
🙋♂️ : ?!!
컴파일 시간 의존성과 실행 시간 의존성
Movie
는 DiscountPolicy
와 연결돼 있으며,
AmountDiscountPolicy
와 PercentDiscountPolicy
는
추상 클래스인 DiscountPolicy
를 상속받는다.
이처럼 어떤 클래스가 다른 클래스에 접근할 수 있는 경로 를 가지거나
해당 클래스 객체의 메소드를 호출 할 경우 두 클래스 사이에 의존성이 존재한다 고 말한다.
여기서 눈여겨봐야 할 부분은 Movie
클래스가 DiscountPolicy
클래스와 연결돼 있다는 것 !
문제는 실제로 영화 요금을 계산하기 위해서는 추상 클래스인 DiscountPolicy
가 아니라
AmountDiscountPolicy
와 PercentDiscountPolicy
인스턴스가 필요하다는 것이다.
따라서 Movie
인스턴스는 실행시에 AmountDiscountPolicy
나 PercentDiscountPolicy
인스턴스에
의존해야 한다. 하지만 코드에서는 오직 추상 클래스인 DiscountPolicy
에만 의존하고 있다.
그렇다면 Movie 인스턴스가 코드 작성 시점에는 존재조차 알지 못했던
AmountDiscountPolicy
와 PercentDiscountPolicy
인스턴스와
실행 시점에 협력이 가능한 이유는 무엇일까 ?
이전 Movie
의 생성자에서 DiscountPolicy
타입의 객체를 인자로 받는데,
만약 금액 할인 정책을 적용하고 싶다면 Movie
인스턴스 생성 시에
인자로 AmountDiscountPolicy
인스턴스를 전달하면 된다 !
Movie avatar = new Movie(
"아바타2",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(Money.wons(800), ...));
이제 실행 시에 Movie
의 인스턴스는 AmountDiscountPolicy
인스턴스를 의존하게 된다.
비율 할인 정책을 적용하고 싶다면 AmountDiscountPolicy
대신
PercentDiscountPolicy
인스턴스를 전달하면 된다 !
Movie avatar = new Movie(
"아바타2",
Duration.ofMinutes(120),
Money.wons(10000),
new PercentDiscountPolicy(0.1, ...));
여기서 중요한 점 !
코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다는 것 !
다시 말해 클래스 사이 의존성과 객체 사이 의존성은 동일하지 않을 수 있다.
코드의 의존성과 실행 시점 의존성이 다르면 다를수록 코드를 이해하기 어려워진다.
반면 코드의 의존성과 실행 시점 의존성이 다르면 다를수록 코드는 유연해지고 확장 가능해진다.
의존성의 양면성은 설계가 트레이드오프의 산물 이라는 것을 잘 보여줌 !
훌륭한 객체지향 설계자로 성장하려면, 항상 유연성과 가독성 사이에서 고민 해야 한다 !
차이에 의한 프로그래밍
상속은 기존 클래스를 기반으로 새로운 클래스를 쉽고 빠르게 추가할 수 있다.
또 부모 클래스의 구현은 공유하면서, 행동이 다른 자식 클래스를 쉽게 추가할 수 있다.
AmountDiscountPolicy 와 PercentDiscountPolicy 가
DiscountPolicy 의 추상 메소드 getDiscountAmount 메소드를 오버라이딩해서 행동을 수정 !
이처럼 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을
차이에 의한 프로그래밍 (programming by difference) 라고 부른다 !
상속과 인터페이스
상속이 가치있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문 !
인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의 한다.
상속을 통해 자식 클래스는 자신의 인터페이스에 부모 클래스의 인터페이스를 포함.
결과적으로 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기 때문에
외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다.
public class Movie {
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
Movie
입장에서는 자신과 협력하는 객체가 어떤 클래스의 인스턴스인지가 중요한 것이 아니라
calculateDiscountAmount
메시지를 수신할 수 있다는 사실이 중요하다 !
따라서 calculateDiscountAmount
메시지를 수신할 수 있는
AmountDiscountPolicy
와 PercentDiscountPolicy
모두 DiscountPolicy
를 대신해서
Movie
와 협력할 수 있다.
이처럼 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅(upcasting) 이라고 부른다.
다형성
메시지와 메소드는 다른 개념 !
Movie
는 DiscountPolicy
인스턴스에게 calculateDiscountAmount
메시지를 전송한다.
그에 반해 실행되는 메소드는 Movie
와 협력하는 객체가 무엇인가에 따라서 달라진다.
Movie
는 동일한 메시지를 전송하지만 실제로 어떤 메소드가 실행될 것인지는
메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다.
이를 다형성 이라고 부른다 !
다형성은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실에 기반한다.
다형성이란 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력 !
따라서 다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 한다.
다시 말해, 인터페이스가 동일해야 한다는 뜻 !
그리고 인터페이스를 통일하기 위해 사용한 구현 방법이 바로 상속 이다 !
다형성을 구현하는 방법은 매우 다양하지만 메시지에 응답하기 위해 실행될 메소드를
컴파일 시점이 실행 시점에 결정한다는 공통점이 있다.
메시지와 메소드를 실행 시점에 바인딩한다는 뜻 !
이를 지연 바인딩 (lazy binding) 또는 동적 바인딩 (dynamic binding) 이라고 부른다.
그에 반해 전통적인 함수 호출처럼 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것을
초기 바인딩 (early binding) 또는 정적 바인딩 (static binding) 이라고 부른다.
객체지향이 컴파일 시점의 의존성과 실행 시점 의존성을 분리하고,
하나의 메시지를 선택적으로 서로 다른 메소드에 연결할 수 있는 이유가 바로
지연 바인딩 이라는 메커니즘을 사용하기 때문 !
대부분의 사람들이 다형성을 이야기할 때 상속을 함께 언급하는데,
클래스를 상속받는 것만이 다형성을 구현할 수 있는 유일한 방법은 아니다 !
구현 상속과 인터페이스 상속
상속을 구현 상속(implementation inheritance) 과
인터페이스 상속(interface inheritance) 으로 분류할 수 있다.
구현 상속을 서브클래싱(subclassing) 이라고 부르고,
인터페이스 상속을 서브타이핑(subtyping) 이라고 부름.
순수 코드 재사용 목적으로 상속을 사용하는 것은 구현 상속,
다형적 협력을 위해 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록
상속을 이용하는 것을 인터페이스 상속 이라고 부름.
상속은 구현 상속이 아니라 인터페이스 상속을 위해 사용해야 함.
대부분 코드 재사용을 상속의 주 목적으로 생각하지만 이것은 오해 !
인터페이스를 재사용할 목적이 아니라 구현을 재사용할 목적으로 상속을 사용하면
변경에 취약한 코드를 낳게 될 확률이 높다 !
인터페이스와 다형성
추상 클래스를 이용해 다형성을 구현했던 할인 정책과 달리
할인 조건은 구현을 공유할 필요가 없기 때문에 자바의 인터페이스를 이용해 타입 계층을 구현.
추상화와 유연성
추상화의 힘
할인 정책은 구체적인 금액 할인 정책과 비율 할인 정책을 포괄하는 추상적인 개념.
할인 조건 역시 더 구체적인 순번 조건과 기간 조건을 포괄하는 추상적인 개념.
DiscountPolicy 는 모든 할인 정책들이 수신할 수 있는 calculateDiscountAmount
메시지를 정의,
DiscountCondition 은 모든 할인 조건들이 수신할 수 있는 isSatisfiedBy
메시지를 정의한다.
둘 다 같은 계층에 속하는 클래스들이 공통으로 가질 수 있는 인터페이스를 정의,
구현의 일부(추상 클래스인 경우) 또는 전체(자바 인터페이스인 경우)를 자식 클래스가 결정할 수 있도록
결정권을 위임한다 !
추상화를 사용하면 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현 가능 !
상위 개념만으로도 도메인의 중요한 개념을 설명할 수 있게 한다.
추상화를 이용해 상위 정책을 기술한다는 것은,
기본적인 어플리케이션의 협력 흐름을 기술한다는 것을 의미한다.
영화의 예매 가격을 계산하기 위해 흐름은 항상 Movie 에서 DiscountPolicy 로,
그리고 다시 DiscountCondition 을 향해 흐른다 !
디자인 패턴 이나 프레임워크 모두 추상화를 이용해 상위 정책을 정의하는
객체지향의 메커니즘을 활용하고 있다.
또한 추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가, 확장 가능.
설계를 유연하게 만들 수 있다 !
유연한 설계
아직 할인 정책이 적용돼 있지 않은 경우는 해결되지 않았다.
이 경우 할인 요금 계산할 필요 없이 영화에 설정된 기본 금액 그대로 사용하면 된다.
public class Movie {
public Money calculateMovieFee(Screening screening) {
if (discountPolicy == null) {
return fee;
}
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
이 방식의 문제점은 할인 정책이 없는 경우를 예외 케이스로 취급하기 때문에
일관성 있던 협력 방식이 무너진다는 것이다.
기존은 할인할 금액을 계산하는 책임이 DiscountPolicy 의 자식 클래스에 있었지만,
할인 정책이 없는 경우는 할인 금액이 0원이라는 사실을 결정하는 책임이 Movie 쪽에 있기 때문 !
책임의 위치를 결정하기 위해 조건문을 사용하는 것은
협력의 설계 측면에서 대부분 좋지 않은 선택이 될 수 있다.
항상 예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택하자 !
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 를 수정하지 않고 NoneDiscountPolicy 라는
새로운 클래스를 추가하는 것만으로 어플리케이션 기능을 확장했다는 것 !
이렇게 추상화를 중심으로 코드 구조를 설계하면
유연하고 확장 가능한 설계를 만들 수 있다 !
유연성이 필요한 곳에 추상화를 사용하자 !
추상 클래스와 인터페이스 트레이드오프
앞의 NoneDiscountPolicy
코드를 보면 getDiscountAmount
메소드가
어떤 값을 반환하더라도 상관이 없다는 사실을 알 수 있다.
부모 클래스인 DiscountPolicy
에서 할인 조건이 없을 경우
getDiscountAmount()
메소드를 호출하지 않기 때문 !
따라서 DiscountPolicy
를 인터페이스로 바꾸고
NoneDiscountPolicy
가 DiscountPolicy
의 getDiscountAmount()
메소드가 아닌
calculateDiscountAmount()
오퍼레이션을 오바리이딩하도록 변경하자.
DiscountPolicy
클래스를 인터페이스로 변경
public interface DiscountPolicy {
Money calculateDiscountAmount(Screening screening);
}
- 원래
DiscountPolicy
클래스의 이름을DefaultDiscountPolicy
로 변경,
인터페이스를 구현하도록 수정
public abstract class DefaultDiscountPolicy implements DiscountPolicy {
...
}
NoneDiscountPolicy
가DiscountPolicy
인터페이스를 구현하도록 변경
public class NoneDiscountPolicy implements DiscountPolicy {
@Override
public Money calculateDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
어떤 설계가 더 좋을까?
NoneDiscountPolicy
만을 위해 인터페이스를 추가하는 것이 과하다는 생각이 들 수도 있다.
답은 없다. 구현과 관련된 모든 것들이 트레이드오프의 대상이 될 수 있음 !
코드 재사용
상속은 코드를 재사용하기 위해 널리 사용되는 방법.
널리 사용된다고 가장 좋은 방법인 것은 아니다.
코드 재사용을 위해서는 상속보다 합성(composition) 이 더 좋은 방법이라는 이야기도 있다.
Movie
가 DiscountPolicy
의 코드를 재사용하는 방법이 바로 합성 !
Movie
를 직접 상속받아 AmountDiscountMovie
와 PercentDiscountMovie
라는
두 개의 클래스를 추가하면 합성을 사용한 기존 방법과 기능적으로 완벽히 동일하다 !
하지만 많은 사람들이 상속 대신 합성을 선호하는 이유는 ?
상속
상속은 객체지향에서 코드를 재사용하기 위해 널리 사용된다.
하지만 두 가지 관점에서 설계에 안 좋은 영향을 미친다.
- 상속이 캡슐화를 위반
- 설계를 유연하지 못하게 만듬
상속을 이용하기 위해 부모 클래스의 내부 구조를 잘 알고 있어야 한다.
부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다.
상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정한다.
따라서 실행 시점에 객체의 종류를 변경하는 것이 불가능하다.
예를 들어, 실행 시점에 금액 할인 정책인 영화를 비율 할인 정책으로 변경한다면 ?
PercentDiscountMovie
인스턴스를 생성하고 AmountDiscountMovie
의 상태를 복사해야 한다.
반면 인스턴스 변수로 연결된 기존 방법을 사용하면 실행 시점에 할인 정책을 간단히 변경할 수 있다.
public class Movie {
private DiscountPolicy discountPolicy;
public void changeDiscountPolicy(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
금액 할인 정책이 적용된 영화에 비율 할인 정책이 적용되도록 변경하려면
새로운 DiscountPolicy
인스턴스를 연결하는 간단한 작업으로 바뀐다.
Movie avatar = new Movie(
"아바타2",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(Money.wons(800), ...));
avatar.changeDiscountPolicy(new PercentDiscountPolicy(0.1, ...));
합성
Movie
는 요금을 계산하기 위해 DiscountPolicy
의 코드를 재사용한다.
상속과 다른 점은 Movie
가 DiscountPolicy
의 인터페이스를 통해 약하게 결합된다는 것 !
이처럼 인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성 이라고 부른다.
합성은 상속이 가지는 두 가지 문제점을 모두 해결한다.
- 인터페이스에 정의된 메시지를 통해서만 재사용, 구현을 효과적으로 캡슐화
- 의존하는 인스턴스를 교체하는 것이 비교적 쉬움, 설계를 유연하게
정리
- 어떤 클래스가 필요하진보다 어떤 객체들이 필요한지를 먼저 고민하자 !
- 메소드와 메시지는 다르다.
- 다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 한다.
- 객체들은 협력하는 공동체이다.
- 코드를 재사용하기 위해 합성과 상속을 적절히 조합하자 !