개발자는 기록이 답이다

오브젝트 2장 - 객체지향 프로그래밍(1) 본문

기술 서적/OOP

오브젝트 2장 - 객체지향 프로그래밍(1)

slow-walker 2023. 11. 25. 12:04

 

1. 영화 예매 시스템

 

요구사항 살펴보기

 

사용자는 온라인 영화 예매 시스템을 이용해 쉽고 빠르게 보고 싶은 영화를 예매할 수 있다.

 

  • 영화 : 영화에 대한 기본 정보를 표현한다. 제목, 상영시간, 가격 정보와 같이 영화가 가지고 있는 기본적인 정보를 가진다
  • 상영 : 실제로 관객들이 영화를 관람하는 사건을 표현한다. 상영 일자 , 시간, 순번 등을 가리킨다.

 

특정한 조건을 만족하는 예매자는요금을 할인받을 수 있다, 할인액을 결정하는 두 가지 규칙이 존재하는데,

하나는 할인 조건(discount condition)이라고 부르고, 다른 하나는 할인 정책(discount policy)이라고 부른다.

'할인 조건'은 가격의 할인 여부를 결정한다.

다수의 할인 조건을 함께 지정할 수 있으며, 순서 조건과 기간 조건을 섞는 것도 가능하다.

  • 순서 조건(sequence condition) : 상영 순번을 이용해 할인 여부를 결정
    • e.g. 순서 조건의 순번이 10인 경우 매일 10번째로 상영되는 영화를 예매한 사용자들에게 할인 혜택을 제공한다
  • 기간 조건(period condition) : 영화 상영 시작 시간을 이용해 할인 여부를 결정
    • 기간 조건은 요일, 시작 시작, 종료 시간의 세 부분으로 구성되며 영화 시작 시간이 해당 기간 안에 포함될 경우 요굼을 할인한다.
    • e.g.  요일이 월요일, 시작시간이 오전 10시, 종료 시간이 오후 1시인 기간 조건을 사용하면 매주 월요일 오전 10시부터 오후 1시 사이에 상영되는 모든 영화에 대해 할인 혜택을 적용할 수 있다.

'할인 정책'은 할인 요금을 결정한다.

영화별로 하나의 할인 정책만 할당하거나 지정하지 않을 수도 있다.

  • 금액 할인 정책(amount discount policy) : 예매 요금에서 일정 금액을 할인
    • e.g. 특정 영화의 가격이 9,000원이고 금액 할인 정책이 800원일 경우 일인당 예매 가격은 8,200원이다.
  • 비율 할인 정책(percent discount policy) : 예매 요금에서 일정 비율의 요금을 할인해주는 방식
    • e.g. 특정 영화의 가격이 9,000원이고 비율 할인 정책이 10%라면 9000원에서 900원을 할인받아서 에 일인당 예매 가격은 8,100원이다.

할인을 적용하기 위해서는 할인 조건과 할인 정책을 함께 조합해서 사용한다. 먼저 사용자의 예매 정보가 할인 조건 중 하나라도 만족하는지 검사한다. 할인 조건을 만족할 경우 할인 정책을 이용해 할인 요금을 계산한다.

 

할인 정책은 적용돼있지만 할인 조건을 만족하지 못하는 경우나 아예 할인 정책이 적용돼 있지 않은 경우에는 요금을 할인하지 않는다.

 

사용자가 가격이 10,000원인 '아바타'를 예매한다고 가정하자. 이 영화에는 800원의 금액 할인 정책이 적용돼 있다. 따라서 사용자의 예매 정보가 할인 조건을 만족할 경우 1인당 800원의 요금을 할인해줘야 한다. 따라서 사용자의 예매 정보가 할인 조건을 만족할 경우 1인당 800원의  요금을 할인해줘야 한다. 아바타의 할인 조건을 두 개의 순번 조건(조조, 10번째)와 두 개의 기간 조건(월요일 10시에서 12시 사이에 시작, 목요일 18시에서 21시 사이에 시작)으로 구성돼 있다.

 

이 조건을 만족하는 영화를 예매할 경우 원래 가격인 10,000원에서 할인 요금인 800원만큼을 할인 받을 수있기 때문에 사용자는 9,200원에 영화를 예매할 수 있다. 할인 정책은 1인을 기준으로 책정되기 때문에 예약원이 두 명이라면 1,600원의 요금을 할인받을 수 있다.

 

2. 객체지향 프로그래밍을 향해

 

협력, 객체, 클래스

 

클래스 기반의 객체지향 언어에 익숙한 사람이라면 가장 먼저 어떤 클래스가 필요한지 고민할 것이다.

대부분의 사람들은 클래스를 결정한 후에 클래스에 어떤 속성과 메서드가 필요한지 고민한다. 안타깝게도 이것은 객체지향의 본질과 거리가 멀다.

 

객체지향은 말 그대로 객체를 지향하는 것이다. 진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞출 때에만 얻을 수 있다.

 

1. 어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민하라

클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것이다.

클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결졍해야 한다.

객체를 중심에 두는 접근 방법은 설계를 단순하고 깔끔하게 만든다.

 

2. 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다.

객체는 홀로 존재하는 것이 아니다. 다른 객체에게 도움을 주거나 의존하면서 살아가는 협력적인 존재다.

객체를 협력하는 공동체의 일원으로 바라보는 것은 설계를 유연하고 확장 가능하게 만든다.

객체지향적으로 생각하고 싶다면 객체를 고립된 존재로 바라보지 말고 협력에 참여하는 협력자로 바라보기 바란다.

객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현하라.

 

"훌륭한 협력이 훌륭한 객체를 낳고, 훌륭한 객체가 훌륭한 클래스를 낳는다."

 

🤔 궁금한점

1장에서는 결합도가 높은 객체들간의 의존성을 줄여 자율적인 객체가 되도록 하도록 권장했다.
2장에서는 객체를 독립적인 존재가 아니라 협력자로서 바라보라고 하는데, 의미적으로 어떤 차이일까?

1. 자율적인 객체 (Make Objects Autonomous)
이 개념은 객체가 자신의 상태와 행위를 책임지며 자율적으로 동작할 수 있어야 한다는 것을 강조합니다.
객체는 자신의 데이터(상태)와 그 데이터를 다루는 메서드(행위)를 포함하고 있어야 합니다.
다른 객체에게 직접적으로 상태를 노출하지 않고, 메서드를 통해 상태를 다루고 외부와의 상호작용을 추상화합니다.

"객체 간의 결합도가 높으면 유지보수가 어렵다"는 개념은 객체 지향 설계의 한 중요한 원칙인 "의존성 역전 원칙(Dependency Inversion Principle)"과 관련이 있습니다.
이 원칙은 고수준 모듈이 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다는 원칙입니다.
이를 통해 시스템의 유연성을 향상시키고 의존성을 최소화하여 변경에 민감하지 않게 만듭니다.

즉, 어떤 클래스나 모듈이 다른 클래스나 모듈에 직접 의존하는 것이 아니라, 추상화된 인터페이스나 추상 클래스에 의존하도록 설계하면 해당 클래스나 모듈이 변경될 때 다른 부분에 미치는 영향을 최소화할 수 있습니다.

2. 독립적인 존재가 아니라 협력자로 만들어라 (Objects as Collaborators)
이 개념은 객체가 다른 객체와 협력하며 시스템을 구성해야 한다는 것을 강조합니다.
객체는 다른 객체와 메시지를 주고받아 협력을 통해 기능을 수행합니다.
객체는 단순히 자신의 내부 상태를 다루는 것이 아니라, 다른 객체와의 인터페이스를 통해 외부와 소통하며 기능을 제공합니다.

"독립적인 존재가 아니라 협력자로 바라봐야 한다"는 개념은 객체 지향 설계에서 객체가 완전히 독립적인 존재가 아니라 다른 객체와 협력하며 시스템을 구성한다는 아이디어입니다.
즉, 한 객체가 다른 객체에게 어떤 일을 요청하고, 그에 대한 응답을 받는 방식으로 이루어집니다.

두 개념은 다르게 강조되지만, 자율적인 객체가 독립적으로 동작할 수 있어야 협력에서도 유용하게 사용될 수 있습니다. 즉, 객체가 자신의 책임을 수행하면서 다른 객체와의 협력을 통해 더 큰 기능을 수행하는 것이 중요합니다. 따라서 이 두 가지 개념은 서로 보완적이며, 객체 지향 설계에서 유연하고 효과적인 시스템을 구축하기 위해 함께 고려되어야 합니다.

 

도메인의 구조를 따르는 프로그램 구조

 

도메인이란 ?

사용자가 원하는 어떤 문제를 해결하기 위해 사용하는 프로그램의 분야이다.

이번 영화 예매 시스템의 목적은 영화를 좀 더 쉽고 빠르게 예매하려는 사용자의 문제를 해결하는 것이다.

 

객체지향 패러다임이 강력한 이유는 요구사항을 분석하는 초기 단계부터 프로그램을 구현하는 마지막 단계까지 객체라는 동일한 추상화 기법을 사용할 수 있기 때문이다. 요구 사항과 프로그램을 객체라는 동일한 관점에서 바라볼 수 있기 때문에 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결될 수있다.

 

 

그림2.3은 영화 예매 도메인을 구성하는 개념과 관계를 표현한 것이다.

 

영화는 여러번 상영될 수 있고 상영은 여러 번 예매 될 수 있다는 것을 알 수 있다. 영화에는 할인 정책을 할당하지 않거나 할당하더라도 오직 하나만 할당할 수 있고, 할인 정책이 존재하는 경우에는 하나 이상의 할인 조건이 반드시 존재한다는 것을 알 수 있다.

 

일반적으로 클래스의 이름은 대응되는 도메인 개념의 이름과 동일하거나 적어도 유사하게 지어야 한다. 클래스 사이의 관계도 최대한 도메인 개념 사이에 맺어진 관계와 유사하게 만들어서 프로그램의 구조를 이해하고 예상하기 쉽게 만들어야 한다.

 

클래스 구현하기

 

도메인 개념들이 구조를 반영한 적절한 클래스 구조를 만들었으면 프로그래밍 언어를 이용해 이 구조를 구현하는 것이다.

 

import java.time.LocalDateTime;

public class Screening { // 상영
    private Movice 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 getStartTime() {
        return whenScreened;
    }
    
    public boolean isSequence(int sequence) {
        return this.sequence == sequence;
    }
    
    public Money getMovieFee() {
        return movie.getFee();
    }
}

 

여기서 주목할 점은 인스턴스 변수의 가시성은 private이고, 메서드의 가시성은 public이라는 것이다.

클래스를 구현하거나 다른 개발자에 의해 개발된 클래스를 사용할 때 가장 중요한 것은 클래스의 경계를 구분 짓는 것이다.

 

클래스는 내부와 외부로 구분되며, 훌륭한 크래스를 설계하기 위한 핵심은 어떤 부분을 외부에 공개하고 감출지 결정하는 것이다.

외부에서는 객체의 속성에 직접 접근할 수 없도록 막고, 적절한 public 메서드를 통해서만 내부 상태를 변경할 수 있게 해야한다.

 

클래스의 내부와 외부를 구분해야 하는 이유는 무엇일까? 

  • 경계의 명확성이 객체의 자율성을 보장하기 때문이다.
  • 프로그래머에게 구현의 자유를 제공하기 때문이다.
🚩 자율적인 객체

 

1. 객체가 상태(state)행동(behavior)을 함께 가지는 복합적인 존재라는 것이다.

2. 객체가 스스로 판단하고 행동하는 자율적인 객체라는 것이다.

 

많은 사람들은 객체를 상태와 행동을 함께 포함하는 식별 가능한 단위로 정의한다. 객체지향 이전의 패러다임에서는 데이터와 기능이라는 독립적인 존재를 서로 엮어 프로그램을 구성했다. 이와 달리 객체지향은 객체라는 단위 안에서 데이터와 기능을 한 덩어리로 묶음으로써 문제 영역의 아이디어를 적절하게 표현할 수 있게 했다. 데이터와 기능을 객체 내부로 함께 묶는 것캡슐화라고 한다.

🤔 궁금한점
'데이터와 기능을 객체 내부로 함께 묶는 것이 캡슐화'라고 써져있는데, 추상화랑 개념이 조금 헷갈린다.
추상화가 공통된 부분을 변수와 메서드로 만들어서 하나의 클래스로 만드는거고, 캡슐화가 외부에서 접근하지 못하도록 하는것인데 정확히 어떤 차이일까?

1. 캡슐화 (Encapsulation)
캡슐화는 객체 내부의 상태와 행동을 하나의 단위로 묶는 것을 의미합니다.
객체 내부의 데이터를 외부에서 직접 접근하지 못하도록 은닉하고, 메서드를 통해 데이터에 접근하도록 제어합니다.
캡슐화는 정보 은닉을 통해 객체의 내부 구현을 감추고, 외부에는 필요한 인터페이스만 노출함으로써 객체의 안전성과 유지보수성을 높입니다.

2. 추상화 (Abstraction)
추상화는 객체의 복잡한 내부 구현을 감추고 중요한 부분에만 집중하도록 하는 개념입니다.
추상화는 공통된 특성을 추출하여 하나의 개념이나 클래스로 표현하는 것을 의미합니다.
즉, 추상화는 공통된 속성이나 행동을 추출하여 이를 일반화된 클래스로 정의하고, 실제 객체는 이를 상속받아 구체화합니다.

문제 해결을 위해 객체를 정의할 때, 캡슐화는 객체 내부의 상태와 행동을 안전하게 묶고 외부에서의 접근을 제한함으로써 객체의 캡슐을 유지합니다. 반면에 추상화는 객체의 복잡성을 다루기 쉽게 만들어주고, 공통된 특성을 추출하여 일반적인 개념으로 표현함으로써 객체의 추상적인 측면을 강조합니다.

"캡슐화는 데이터와 기능을 객체 내부로 함께 묶는 것"이라고 설명한 것은 객체 내부의 데이터와 기능을 하나의 단위로 묶어 안전하게 유지하면서도 외부에서 필요한 부분에 대한 접근을 제어한다는 캡슐화의 측면을 강조한 것일 수 있습니다.

 

대대분의 객체지향 프로그래밍 언어들은 상태와 행동을 캡슐화하는 것에서 한 걸음 더 나아가 외부에서 접근을 통제할 수 있는 접근 제어(access control)매커니즘도 함께 제공한다. 많은 프로그래밍 언어들은 접근 제어를 위해 public, protected, private과 같은 접근 수정자(access modifier)를 제공한다.

 

객체 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위해서다.

객체지향의 핵심은 스스로 상태를 관리하고, 판단하고, 행동하는 자율적인 객체들의 공동체를 구성하는 것이다.

객체가 자율적인 존재로 우뚝 서기 위해서는 외부의 간섭을 최소화해야 한다.

외부에서는 객체가 어떤 상태에 놓여 있는지, 어떤 생각을 하고 있는지 알아서는 안되며, 결정에 직접적으로 개입하려고 해서도 안된다.

객체에게 원하는 것을 요청하고는 객체가 스스로 최선의 방법을 결정할 수 있을 것이라는 점을 믿고 기다려야 한다.

 

캡슐화와 접근 제어는 객체를 두 부분으로 나눈다.

 

1. 외부에서 접근 가능한 부분 : 퍼블릭 인터페이스(public interface)

2. 외부에서 접근 불가능하고 오직 내부에서만 접근 가능한 부분 : 구현(implementation)

 

인터페이스와 구현의 분리(seperation of interface and implementation)은 훌륭한 객체지향 프로그램을 만들기 위해 따라야 하는 핵심 원칙이다.

 

일반적으로 객체의 상태는 숨기고 행동만 외부에 공개햐아 한다. 여러분이 사용하는 프로그래밍 언어가 public이나 private이라는 키워드를 제공한다면 클래스의 속성은 private으로 선언해서 감추고 외부에 제공해야하는 일부 메서드만 public으로 선언해야 한다.

어떤 메서드들이 서브클래스나 내부에서만 접근해야 한다면 가시성을 protected나 private으로 지정해야 한다.

 

이때 퍼블릭 인터페이스에는 public으로 지정된 메서드만 포함된다.

그 밖의 private메서드나 protected메서드, 속성은 구현에 포함된다.

 

🚩 프로그래머의 자유

 

프로그래머의 역할을 클래스 작성자(class creator)클라이언트 프로그래머(client programmer)로 구분하는 것이 유용하다.

클래스 작성자는 새로운 데이터 타입을 프로그램에 추가하고, 클라이언트 프로그래머는 클래스 작성자가 추가한 데이터 타입을 사용한다.

 

  • 클라이언트 프로그래머의 목표는 필요한 클래스들을 엮어서 애플리케이션을 빠르고 안정적으로 구축하는 것이다
  • 클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만 공개하고 나머지는 꽁꽁 숨겨야 한다.

클라이언트 프로그래머가 숨겨 놓은 부분에 마음대로 접근할 수 없도록 방지함으로써 클라이언트 프로그래머에 대한 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있다. 이를 구현은닉(implemetation hiding)이라고 부른다.

 

구현은닉은 클래스 작성자와 클라이언트 프로그래머 모두에게 유용한 개념이다.

클라이언트 프로그래머는 내부 구현은 무시한 채 인터페이스만 알고 있어도 클래스를 사용할 수 있기 때문에 머릿속에 담아둬야 하는 지식의 양을 줄일 수 있다. 클래스 작성자는 인터페이스를 바꾸지 않는 한 외부에 미치는 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있다. 다시 말해 public영역을 변경하지 않는다면 코드를 자유롭게 수정할 수 있다는 것이다.

 

따라서 클래스를 개발할 때마다 인터페이스와 구현을 깔끔하게 분리하기 위해 노력해야 한다.

 

설계가 필요한 이유는 변경을 관리하기 위해서라는 것을 기억하라.

객체 지향 언어는 객체 사이의 의존성을 적절히 간리함으로써 변경에 대한 파급효과를 제어할 수 있는 다양한 방법을 제공한다.

객체의 변경을 관리할 수 있는 기법 중에서 가장 대표적인 것이 바로 접근 제어다.

 

여러분은 변경될 가능성이 있는 세부적인 구현 내용을 private영역 안에 감춤으로써 변경으로 인한 혼란을 최소화할 수 있다.

 

public class Screening {
	...
    // 영화 예매 기능
    // Reservation : 영화 예매 후 예매 정보
    // customer : 예매자 정보, audienceCount : 인원 수
    public Reservation reservce(Customer customer, int audienceCount) {
        return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
    }
	// 위에서 private calculateFee매소드 호출
    // 반환값 : 1인당 예매 요금, 전체 예매 요금을 위해 인원 수 곱하기
    private Money calculateFee(int audienceCount) {
        return movie.calculateFee(this).times(audienceCount);
    }
    ...
}

 

원시값 포장

영화의 금액을 구현할때, Long타입 대신 Money타입으로 만들어서 구현했다.

 

Java 원시값 포장 참고 링크

import java.math.BigDecimal;

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));
    }

    public 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)이라고 부른다.

객체지향 프로그램을 작성할 때는 먼저 협력의 관점에서 어떤 객체가 필요한지를 결정하고, 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성한다.

 

🚩 협력에 관한 짧은 이야기

 

  • 객체는 다른 객체의 인터페이스에 공개된 행동을 수행하도록 요청(request)할 수 있다.
  • 요청을 받은 객체는 자율적인 방법에 따라 요청을 처리한 후 응답(response)한다.

 

객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메세지를 전송(send a message)하는 것 뿐이다.

다른 객체에게 요청이 도착할 때 해당 객체가 메시지를 수신(receive a message)했다고 이야기 한다.

 

메세지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메세지를 처리할 방법을 결정한다. 이처럼 수신된 메세지를 처리하기 위한 자신만의 방법을 메서드(method)라고 부른다.

 

 

메세지와 메서드를 구분하는 것은 매우 중요하다. 객체지향 패러다임이 유연하고, 확장 가능하며, 재사용 가능한 설계를 낳는다는 명성을 얻게 된 배경에는 메시지와 메서드를 명확하게 구분한 것도 단단히 한 몫한다. 뒤에서 살펴보겠지만 메세지와 메서드의 구분에서부터 다형성(polymorphsim)의 개념이 출발한다.

 

지금까지는 Screening이 Movie의 calculateMovieFee '메서드를 호출한다'라고 말했지만, 사실은  Screening이 Movie에게 calculateMovieFee '메세지를 전송한다'라고 말하는것이 더 적절한 표현이다. 사실 Screening이 Movie안에 calculateMovieFee메서드가 존재하고 있는지 조차 알지 못한다. 단지calculateMovieFee메세지에 응답할 수 있다고 믿고 메세지를 전송할 뿐이다.

 

메시지를 수신한 Movie는 스스로 적절한 메서드를 선택한다. 결국 메시지를 처리하는 방법을 결정하는 것은 Movie 스스로의 문제인 것이다. 이것이 객체가 메시지를 처리하는 방법을 자율적으로 결졍할 수 있다고 말했던 이유다.

 

3. 할인 요금 구하기

 

할인 요금 계산을 위한 협력 시작하기

 

예매 요금을 계산하는 협력을 살펴보자.

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));
    }
}

 

caculateMovieFee메서드는 discountPolicy에 calculateDiscountAmount 메세지를 전송해 할인 요금을 반환받는다.

Movie는 기본 요금인 fee에서 반환된 할인 요금을 차감한다.

 

❓ 어떤 할인 정책을 사용할 것인지 결정하는 코드가 어디에도 존재하지 않는다.

도메인을 설명할 때 언급했던 것 처럼 영화 예메 시스템에는 2가지 할인 정책(금액,비율)이 존재한다.

따라서 예매 요금을 계산하기 위해 현재 영화에 적용돼 있는 할인 저책의 종류를 판단할 수 있어야 한다.

하지만 코등 어디에도 할인 정책을 판단하는 코드는 존재하지 않는다. 단지 discountPolicy에게 메세지를 전송할 뿐이다.

 

이 코드에는 상속(inheritance)다형성, 그 기반에는 추상화(abstraction)이라는 원리가 숨겨져 있다.

 

할인 정책과 할인 조건

 

금액 할인 정책과 비율 할인 정책은 대부분의 코드가 유사하고 할인 요금을 계산하는 방식만 조금 다르다.

따라서 두 클래스 사이의 중복 코드를 제거하기 위해 공통 코드를 보관할 장소가 필요하다.

 

부모 클래스인 DiscountPolicy안에 중복 코드를 두고, AmountDiscountPolicy와 PercentDiscountPolicy가 해당 클래스를 상속받게 할 것이다. 실제 애플리케이션에서는 DiscountPolicy의 인스턴스를 생성할 필요가 없기 때문에 추상 클래스(abstract class)로 구현했다.

import java.sql.Array;
import java.util.ArrayList;
import java.util.Arrays;

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);
}

 

DiscountPolicy는 DiscountCondition의 리스트인 conditions를 인스턴스 변수로 가지기 때문에 하나의 할인 정책은 여러 개의 할인 조건을 포함할 수 있다.

calculateDiscountAmount메서드는 전체 할인 조건에 대해 차례대로 DiscountCondition의 isSatisfiedBy메서드를 호출한다.

isSatisfiedBy 메서드는 인자로 전달된 Screening이 할인 조건을 만족 시킬 경우에는 true, 아닐 경우에는 false를 반환한다.

 

할인 조건을 만족하는 DiscountCondition이 하나라도 존재하는 경우에는 추상메서드(abstract method)인 getDiscountAmount메서드를 호출해 할인 요금을 계산한다. 만족하는 할인 조건이 존재하지 않는다면 할인 요금으로 0원을 반환한다.

 

DiscountPolicy는 할인 여부와 요금 계산에 필요한 전체적인 흐름은 정의하지만 실제로 요금을 계산하는 부분은 추상 메서드인 getDiscountAmount메서드에게 위임한다. 실제로는 DiscountPolicy를 상속받은 자식 클래스에서 오버라이딩한 메서드가 실행될 것이다. 이처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴이라고 부른다.

 

// 할인 정책
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;
    }
}
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);
    }
}

DiscountCondition는 자바의 인터페이스를 이용해 선언돼 있다. isSatisfiedBy 오퍼레이션은 인자로 전달된 screening이 할인이 가능한 경우 true를 반환하고 할인이 불가능한 경우에는 false를 반환한다.

// 할인 조건
public interface DiscountCondition {
    boolean isSatisfiedBy(Screening screening);
}
public class SequenceCondition implements DiscountCondition {
    private int sequence;

    public SequenceCondition(int sequence) {
        this.sequence = sequence;
    }

    @Override
    public boolean isSatisfiedBy(Screening screening) {
    	// 순번이 같을 경우 true
        return screening.isSequence(sequence);
    }
}
import java.time.DayOfWeek;
import java.time.LocalTime;

public class PeriodCondition implements DiscountCondition {
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;

    private LocalTime endTime;

    public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
        this.dayOfWeek = dayOfWeek;
        this.startTime = startTime;
        this.endTime = endTime;
    }

    @Override
    public boolean isSatisfiedBy(Screening screening) {
        // 상영 요일이 같고, 상영 시작 시간이 startTime과 endTime사이에 있을 경우 true
        return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
                startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
                endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
    }
}

 

 

할인 정책 구성하기

 

하나의 영화에 대해 단 하나의 할인 정책만 설정할 수 있지만, 할인 조건의 경우 여러 개를 적용할 수 있는데, Movie와 DiscountPolicy생성자는 이런 설정을 강제한다.

 

Movie의 생성자는 오직 하나의 DiscountPolicy인스턴스만 받을 수 있도록 선언돼 있고,  DiscountPolicy의 생성자는 여러 개의 DiscountCondition 인스턴스를 허용한다.

    public Movie(String title, Duration runningTime, Money fee, DiscountPolicy disCountPolicy) {
		...
        this.disCountPolicy = disCountPolicy;
    }
    public DiscountPolicy(DiscountCondition ... conditions) {
        this.conditions = Arrays.asList(conditions);
    }

 

이처럼 생성자의 파라미터 목록을 이용해 초기화에 필요한 정보를 전달하도록 강제하면 올바른 상태를 가진 객체의 생성을 보장할 수 있다.

Movie avatar = new Movie("아바타,"
    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))));

 

 

코드 확인할 수 있는 Github 링크

 

GitHub - codesejin/OOP-Object: [오브젝트] - 코드로 이해하는 객체지향 설계

[오브젝트] - 코드로 이해하는 객체지향 설계. Contribute to codesejin/OOP-Object development by creating an account on GitHub.

github.com