사용자는 영화 예매 시스템을 이용해 쉽고 빠르게 보고 싶은 영화를 예매할 수 있다.
영화
- 영화에 대한 기본 정보
- 제목, 상영시간, 가격 정보와 같이 영화가 가지고 있는 기본 정보
상영
- 실제로 관객들이 영화를 관람하는 사건
- 상영 일자, 시간, 순번을 가리킴
사용자는 특정 시간에 상영되는 영화를 관람할 수 있는 권리를 구매하기 위해 돈을 지불함.
사용자는 예매를 완료하면 제목, 상영정보, 인원, 정가, 결제 금액이 포함된 예매 정보를 생성.
영화 별로 하나의 할인 정책만 할당될 수 있고, 지정하지 않는 것도 가능.
할인 조건은 다수의 할인 조건을 함께 지정할 수 있음.
할인 조건
가격의 할인 여부를 결정
-
순서 조건
: 상영 순번을 이용해 할인 여부를 결정하는 규칙
→ 순서 조건의 순번이 10일 경우, 매일 10번째로 상영되는 영화를 예매한 사용자들에게 할인 혜택을 제공
-
기간 조건
: 영화 상영 시작 시간을 이용해 할인 여부를 결정
→ 월, 오전 10시 ~ 오후 1시인 기간조건일 경우, 그 사이에 상영되는 영화에 대해 할인 혜택 제공
할인 정책
할인 요금을 결정, 1인을 기준으로 책정됨.
-
금액 할인 정책
: 예매 요금에서 일정 금액을 할인해주는 방식
-
비율 할인 정책
: 정가에서 일정 비율의 요금을 할인해주는 방식
-
어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민해라.
클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것임.
-
객체를 독립적인 존재가 아닌 기능 구현을 위해 협력하는 공동체의 일원으로 봐라.
객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고, 이 타입을 기반으로 클래스를 구현해라.
도메인
문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야
요구사항과 프로그램을 객체라는 동일한 관점에서 바라볼 수 있기에 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 연결될 수 있다.
영화 예매 도메인을 구성하는 개념과 관계
영화 → Movie
상영 → Screening
클래스에서 가장 중요한 것은 클래스의 경계를 구분짓는 것.
내부와 외부를 구분해야 하는 이유?
→ 경계의 명확성이 객체의 자율성을 보장하고, 프로그래머에게 구현의 자유를 제공하므로.
자율적인 객체
-
객체는 상태와 행동을 함께 가지는 복합적인 존재
-
객체는 스스로 판단하고 행동하는 자율적인 존재
→ 외부의 간섭을 최소화하기 위해 접근을 통제함. 접근 제어 매커니즘으로 이를 가능하게 했음.
객체지향은 데이터와 기능을 객체 내부로 함께 묶어 표현할 수 있게 함 → 캡슐화
Screening의 reserve는 영화를 예매한 후 예매 정보를 담고 있는 Reservation의 인스턴스를 생성해서 반환함.
객체지향의 장점은 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다.
→ Money 타입처럼 저장하는 값이 금액과 관련돼있다는 의미를 전달 할 수 있음.
명시적으로 표현함으로써 전체적인 설계의 명확성과 유연성을 높이는 첫걸음임.
객체지향 프로그램을 작성할 때
- 먼저 협력의 관점에서 어떤 객체가 필요한지 결정
- 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성
협력에 관한 짧은 이야기
객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송(공개된 행동을 수행하도록 요청) 하는 것임.
수신된 메시지를 처리하기 위한 자신만의 방법 → 메서드
메시지와 메서드를 구분하는 것은 매우 중요함 → 다형성의 개념이 출발함.
할인 정책과 할인 조건
두 클래스는 대부분의 코드가 유사하고, 할인 요금을 계산하는 방식만 조금 다르다.
부모 클래스인 DiscountPolicy 안에 중복 코드를 두고, AmountDiscountPolicy 와 PercentDiscountPolicy가 이 클래스를 상속받게 할 것임.
이처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에 위임하는 디자인패턴을 template method 패턴이라고 한다.
할인 정책 구성하기
하나의 영화에 대해 단 하나의 할인 정책을 설정할 수 있지만 할인 조건은 여러개를 적용할 수 있음.
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는 AmountDiscountPolicy나 PercentDiscountPolicy에 의존하는 것이 아닌 DiscountPolicy에 의존한다.
그러나, 실행 시점에는 해당 인스턴스에 의존하게 된다.
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new PercentDiscountPolicy(0,1,...));
이처럼 코드의 의존성과 실행 시점의 의존성은 서로 다를 수 있다.
유연하고, 쉽게 재사용할 수 있으며 확장 가능한 객체지향 설계가 가지는 특징은 코드의 의존성과 실행 시점의 의존성이 다르다.
→ 코드를 이해하기가 어려움. 이와 같은 의존성의 양면성은 설계가 트레이드오프의 산물이라는 사실을 잘 보여줌.
차이에 의한 프로그래밍
상속을 이용함으로써 기존 클래스가 가지고 있는 모든 속성과 행동을 새로운 클래스에 포함시킬 수 있다.
AmountDiscountPolicy와 PercentDiscountPolicy의 경우 DiscountPolicy에서 정의한 추상 메서드인
getDiscountAmount 메서드를 오버라이딩해서 DiscountPolicy의 행동을 수정한다는 것을 알 수 있다.
상속과 인터페이스
인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의하는데, 상속을 통해 자식 클래스는 자신의 인터페이스에 부모 클래스의 인터페이스를 포함하게 된다.
자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다.
public class Movie {
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
- Movie가 DiscountPolicy의 인터페이스에 정의된 calculateDiscountAmount 메시지를 전송하고 있다.
- 상속을 통해 자식 클래스는 자신의 인터페이스에 부모 클래스의 인터페이스를 포함하기 때문에, DiscountPolicy를 상속받는 AmountDiscountPolicy와 PercentDiscountPolicy 모두 DiscountPolicy를 대신하여 Movie와 협력할 수 있다.
- 이처럼 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅이라 부른다.
- 구현 상속: 코드를 재사용하기 위한 목적으로 상속하는 것
- 인터페이스 상속: 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록 상속을 이용하는 것
- 인터페이스를 재사용할 목적이 아닌, 구현을 재사용할 목적으로 상속을 사용하면 변경에 취약한 코드를 낳게 될 확률이 높음.
다형성
- 코드 상에서 Movie 클래스는 DiscountPolicy 클래스에게 메시지를 전송하지만 실행 시점에 실제로 실행되는 메서드는 Movie와 협력하는 객체의 실제 클래스가 무엇인지에 따라 달라진다.
- 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반으로 한다.
- 동적(지연) 바인딩 : 메시지와 메서드를 실행 시점에 바인딩
- 정적(초기) 바인딩: 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것
추상화의 힘- 두가지 장점
- 추상화의 계층만 따로 떼어놓고 살펴보면 요구 사항의 정책을 높은 수준에서 서술할 수 있음
-
영화 예매 요금은 최대 하나의 '할인 정책'과 다수의 '할인 조건'을 이용해 계산할 수 있다.
→ 영화의 예매 요금은 '금액 할인 정책'과 '두개의 순서 조건, 한개의 기간 조건'을 이용해서 계산할 수 있다 를 포괄함.
-
세부 사항에 억눌리지 않고 상위 개념만으로 도메인의 중요한 개념을 설명할 수 있게 한다.
-
- 설계가 좀 더 유연해짐
- 추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉬벡 추가하고 확장할 수 있다.
유연한 설계
- 항상 예외 케이스를 최소화 하고 일관성을 유지할 수 있는 방법을 선택해라.
Movie 클래스에 할인 정책이 없는 경우를 예외 케이스로 취급한다면, 기존 할인 정책에서 할인할 금액을 계산하는 책임이 DiscountPolicy에 있었는데 설계를 깨버린다.
따라서 일관성을 지키기 위해 DiscountPolicy를 상속하는 NoneDiscountPolicy를 추가한다.
- 중요한 것은 기존의 것은 수정하지 않고 새로운 클래스 추가로 기능을 확장했단 것이다.
- 추상화가 유연한 설계를 가능하게 하는 것은 설계가 구체적인 상황에 결합되는 것을 방지하기 때문이다.
추상클래스와 인터페이스 트레이드오프
- 실은 부모 클래스인 DiscountPolicy에서 할인 조건이 없을 경우 getDiscountAmount() 메서드를 호출시키지 않기 때문에 NoneDiscountPolicy 클래스의 getDiscountAmount()메서드가 어떤 값을 반환하더라도 상관이 없다.
- 이 문제를 해결하는 방법은 DiscountPolicy를 인터페이스로 바꾸고 NoneDiscountPolicy가 DiscountPolicy의 getDiscountAmount() 메서드가 아닌 calculateDiscountAmount()을 오버라이딩 하도록 바꾸는 것이다.
- 현실적으로는 NoneDiscountPolicy만을 위해 인터페이스를 추가하는 것이 과하다는 생각이 들 수도 있을 것.
- 이처럼 구현과 관련된 모든 것들이 트레이드오프의 대상이 될 수도 있다. 비록 아주 사소한 결정이더라도 트레이드 오프를 통해 얻어진 결론과 그렇지 않은 결론 사이의 차이는 크다.
처음부터 현명하게 설계해야할 듯 한데 그게 쉽지 않다
코드 재사용
Movie가 DiscountPolicy의 코드를 재사용하는 것이 합성이다.
이 설계를 상속으로 변경할 수도 있다.
Movie를 직접 상속받아 AmountDiscountMovie, PercentDiscountMovie로 합성을 사용한 기존 방법과 기능적인 관점에서 완벽히 동일하다.
그렇지만 많은 사람들은 합성을 선호하는데 그 이유는 뭘까?
상속
상속은 객체지향에서 코드를 재사용하기 위해 널리 사용되는 기법이다.
그렇지만 설계에 있어 안좋은 영향을 미칠 수 있다.
-
캡슐화를 위반한다.
- AmountDiscountMovie와 PercentDiscountMovie를 구현하는 개발자는 부모클래스의 calculateMovieFee 메서드 안에서 getDiscountAmount메서드를 호출한다는 사실을 알고 있어야 한다.
- 자식과 부모 클래스가 강하게 결합되도록 만드므로 부모를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다. 변경하기 어려워진다.
-
설계를 유연하지 못하게 한다.
- 상속은 부모와 자식 클래스 사이의 관계를 컴파일 시점에 결정하여 실행 시점에 객체 종류를 변경하는 것을 불가능하게 한다.
인터페이스를 재사용할 목적이 아닌, 구현을 재사용할 목적으로 상속을 사용하면 변경에 취약한 코드를 낳게 될 확률이 높음.
합성
Movie는 요금을 계산하기 위해 DiscountPolicy의 코드를 재사용한다.
Movie는 DiscountPolicy가 외부에 calculateDiscountAmount 메서드를 제공한다는 사실만 알고 내부 구현에 대해서는 전혀 알지 못한다.
이처럼 인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성이라 한다.
위에서 언급한 상속의 문제점들을 모두 해결할 수 있다.