Study/오브젝트

[오브젝트02] 객체지향 프로그래밍

voider 2021. 4. 1. 18:06

02 객치지향 프로그래밍_영화 예매 시스템 만들기

요구사항

2장에서는 영화 예매 시스템을 만든다. 주의할 점은 '영화'와 '상영'을 구분해야 한다. 관객은 영화를 예매하는 것이 아니라 상영을 예매한다.

특정한 조건을 만족하는 예매자는 할인을 받을 수 있다. 하나는 할인 조건이라고 부르고 하나는 할인 정책이라고 부른다.

할인 조건

  1. 순서조건
    (예를 들어) 매일 10번째로 상영되는 영화를 예매하는 사용자들에게 할인 혜택을 제공한다.
  2. 기간 조건
    특정 상영 시간대의 영화(상영)를 예매하는 사용자들에게 할인혜택 제공

할인 정책

  1. 금액 할인
  2. 비율 할인

영화별로 하나의 할인 정책만 할당할 수 있다. 할인 조건은 다수의 할인 조건을 함께 지정할 수 있다.

객체지향 프로그래밍

객체지향 프로그래밍을 하려면 이 두 가지에 집중해야 한다.

  1. 어떤 클래스가 필요한지 고민하기 전에 어떤 객체가 필요한지 고민해야 한다.
  2. 객체를 독립적인 존재가 아니라 협력하는 공동체의 일원으로 봐야 한다..

어떤 객체가 필요한가? 도메인이라는 용어를 살펴보자. 도메인이란 어떤 문제를 해결하기 위해 상요자가 애플리케이션을 사용하는 분야를 말한다. 도메인을 구성하는 개념은 객체와 클래스로 매끄럽게 연결할 수 있다.

앞으로 만들게 될 도메인을 보자.

조영호, 오브젝트(위키북스) 42p

이제 차례대로 도메인을 만들 것이다.

Screening class

  public class Screening {
      private Movie movie; 
      private int sequence; //순번
      private LocalDateTime whenScreened; //시작 시간

      public Screening(Movie moive, int sequence, LocalDateTime whenScreened) {
          this.movie = movie;
          this.sequence = sequence;
          this.whenScreened = whenScreened;
      }

    public LocalDateTime getWhenScreened() {
        return whenScreened;
    }

    public boolean isSequence(int sequence) {
        return this.sequence == sequence;
    }

    public Money getMovieFee() {
        return movie.getFee();
    }
  }

변수는 private. 메서드는 public으로 설정함으로써 public메서드를 통해서만 내부 상태를 변경할 수 있게 구분했다. 왜 번거롭게 할까? 경계의 명확성이 객체의 자율성을 보장하기 때문이다.

객체
객체는 상태(state)와 행동(behavior)을 함께 가지는 복합적인 존재다. 객체라는 단위 안에 데이터와 기능을 묶는 것을 캡슐화라고 부른다.
캡슐화와 접근 제어는 객체를 두 부분으로 나눈다. 하나는 외부에서 접근 가능한 퍼블릭 인터페이스. 다른 하나는 구현이다. 인터페이스와 구현의 분리 원칙(seperation of interface and implementation)은 객체지향의 중요한 원칙 중 하나다.

Screening class에 추가된 메서드

    public Reservation reserve(Customer customer, int audienceCount) {
        return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
    }

    private Money calculateFee(int audienceCount) {
        return movie.calculateMovieFee(this).times(audienceCount);
    }

reserve()메서드는 영화를 예매한 후 예매 정보를 담고 있는 Reservation인스턴스를 반환한다. 인자인 customer는 예매자 정보를 담고 있다.

calculateFee()는 요금을 계산하기 위해 다시 MoviecalculateMovieFee()를 호출한다. calculateMovieFee()는 1인당 예매 요금을 반환한다. Screening은 전체 요금을 구하기 위해 calculateMovieFee()의 반환 값에 인원 수인 audienceCount를 곱한다.

Money class

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.muliply(
        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타입을 썼다. 하지만 Money타입처럼 저장하는 값이 금액과 관련되어 있다는 의미를 전달하기 어려웠고, 금액과 관련된 로직이 곳곳에서 중복되는 것을 막기 어렵다. Money에 금액과 관련된 것을 모아서 처리하면 문제를 해결할 수 있다. 의미를 좀 더 명시적이고 분명하게 표현할 수 있다면 객체를 사용해서 해당 개념을 구현해야 한다.

Reservation class

public class Reservation {
    private Customer customer;
    private Screening screening;
    private Money money;
    private int audienceCount;

    public Reservation(Customer customer, Screening screening, Money money, int audienceCount) {
        this.customer = customer;
        this.screening = screening;
        this.money = money;
        this.audienceCount = audienceCount;
    }

영화를 예매하기 위해 Screening, Movie, Reservation 사이에서 일어나는 상호작용이 협력이다. 객체지향 설계는 협력의 관점에서 어떤 객체가 필요한 지 결정하고, 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성해야 한다.

협력
객체와 객체가 상호작용하는 유일한 방법은 메세지를 전송(send a message)이다. 객체는 다른 객체의 인터페이스를 통해 어떤 행동을 수행하도록 요청할 수 있다. 요청 받은 객체는 요청받은 행동을 한 다음, 적절한 결과를 응답한다. 이 요청과 응답 모두 메세지를 전송한다고 표현할 수 있다. 메세지를 보내면, 메세지를 수신한 객체는 수신 받은 일을 처리하는 데, 이 처리하는 방법을 메서드라고 한다.

Movie class

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()discountPolicycalculateDiscountAmount메세지를 전송해서 할인 요금을 반환받는다. Movie는 기본 요금인 fee에서 할인 요금을 차감한다.

여기서 이상한 점은 어떤 할인 정책을 사용할 것인지 결정하는 코드가 없다는 것이다. 이것을 이해하기 ㅜ이해 상속과 다형성. 그리고 그 기반인 추상화에 대해 알아야 한다.
분명히 처음에 우리는 금액 또는 비율로 할인하는 두 가지 정책을 정했다. 그런데 왜 PercentDiscountPolicy나 AmountDiscountPolicy가 아니라 discontPolicy라는 메세지를 전송했을까? 일단 두 가지 할인 정책을 모두 만들 것이다. 그리고 Percent와 Amount의 중복되는 부분은 DiscountPolicy에서 보관할 것이다.

DiscountPolicy 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()를 보자. 루프를 돌면서 조건에 만족한다면 추상메서드인 getDiscountAmount()를 호출한다. 이 추상메서드가 실제로 실행될 때는 하위클래스에서 오버라이딩한 메서드가 실행될 것이다. 이처럼 상위클래스에 기본적인 흐름을 구현하고 상황에 따라 변경되어야 하는 동작을 하위클래스에게 위임하는 디자인 패턴을 템플릿 메서드 패턴(Template Method Pattern)이라고 한다.

DiscountCondition
DiscountCondtion은 인터페이스로 선언되어 있다.

public interface DiscountCondition {
    boolean isSatisfied(Screening screening);
}

isStatisfied 오퍼레이션은 인자로 전달된 screening이 할인 가능한 경우 true를, 불가능한 경우 false를 반환한다.

우리 시스템에는 순번 조건과 기간 조건, 두 가지 할인 조건이 있다고 위에서 말했다. 이 인터페이스를 구현해서 두 가지 조건을 만들어야 한다.

SequenceCondition class

public class SequenceCondition implements DiscountCondition {
    private int sequence;

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

    @Override
    public boolean isSatisfied(Screening screening) {
        return screening.isSequence(sequence);
    }
}

PeriodCondition class

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 isSatisfied(Screening screening) {
        return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
                startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
                endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
    }
}

PeriodConditionisStatisfied는 조건에 사용할 요일, 시작 시간, 종료 시간을 인스턴스 변수로 포함하고 있다. isSatisfied의 인자로 전달된 Screening의 상영 요일, 시작시간, 종료시간과 비교해서 조건에 포함되면 true를 아니면 false를 반환한다.

이제 DiscountPolicy를 상속하는 클래스 두 개를 만든다.

PercentDiscountCondition class

public class PercentDiscountCondition extends DiscountPolicy {
    private double percent;

    public PercentDiscountCondition(double percent, DiscountCondition... conditions) {
        super(conditions);
        this.percent = percent;
    }

    @Override
    protected Money getDiscountAmount(Screening screening) {
        return screening.getMovieFee().times(percent);
    }
}

AmountDiscountPolicy class

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

 

조영호, 오브젝트(위키북스) 54p

지금까지 만든 클래스 사이의 관계 다이어그램이다.

상속과 다형성

Movie클래스 어디에도 할인 정책이 금액 할인인지, 비율 할인인지 판단하는 코드가 없다. 이것을 이해하기 위해 상속과 다형성을 알아야 한다.

조영호, 오브젝트(위키북스) 57p

MoviePercentDiscountPolicy 또는 AmountDiscountPolicy 같은 구체적인 할인 정책에 의존하지 않고, 오직 DiscountPolicy에 의존한다. 하지만 Movie가 영화 요금을 계산하기 위해서는 Amount 또는 Percent가 필요하다. 이렇게 구체적인 클래스에 의존하지 않고, 좀더 추상화된 상위타입에 의존함으로써 실제로 Movie가 생성될 때 의존성을 결정할 수 있다. 컴파일 시간 의존성과 실행 시간 의존성이 달라질 수 있다는 뜻이다. 이것이 확장 가능한 객체지향 설계의 특징이다.

Movie의 생성자를 보자. 여기서는 DiscountPolicy에 의존하고 있다.

public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {...}

하지만 실행할 때, 실제로 Movie를 생성하는 시간에는 의존성이 달라진다.

//AmountDiscountPolicy에 의존
Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(1000), new AmountDiscountPolicy(Money.won(800), ...));

//PercentDiscountPolicy에 의존
Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(1000), new AmountDiscountPolicy(0.2, ...));

한 가지 기억해야 하는 사실은 컴파일 시간 의존성과 실행 시간 의존성이 다르면 다를수록 코드를 이해하기 힘들어진다는 것이다. 적절한 트레이드오프가 필요하다.

이처럼 상속은 객체지향에서 코드를 재사용하기 위해 가장 많이 사용되는 방법이다. 만약 또다른 할인 정책이 생기더라도 Movie를 건드리지 않으면서 쉽게 확장할 수 있을 것이다. 상속이 가치있는 이유는 상위클래스가 제공하는 모든 인터페이스를 하위 클래스가 물려받을 수 있기 때문이지, 메서드나 인스턴스 변수를 재사용하기 위한 목적이 아니다.

MoviecalculateMovieFee()를 보자.

public Money calculateMovieFee(Screening screening) {
    return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}

MoviecalculateDiscountAmount라는 메세지를 이해할 수 있는 객체라면 실제로 그걸 처리하는 것이 DiscountPolicy가 아니어도 상관없다. 이처럼 calculateDiscountAmount라는 동일한 메세지를 전송하지만, 실행 시점에 어떤 메서드가 실행될 것인지는 메세지를 수신하는 클래스가 어느 클래스냐에 따라 달라진다. 이게 다형성이다. 다형성은 컴파일 시간 의존성과 실행시간 의존성을 다르게 만들 수 있는 객체지향의 특성을 이용해서 같은 메세지에 대해 다른 메서드를 실행할 수 있게 한다.

상속과 합성

상속은 코드를 재사용할 때 많이 사용되는 방법이지만, 그보다 합성(composition)이 더 좋은 방법이다. 상속은 상위클래스를 매우 구체적으로 잘 알고 있으니 캡슐화를 위반하고 상위클래스의 변경에 하위 클래스가 영향을 받으니 유연하지 못하다. 이것은 모두 슈퍼 클래스와 서브클래스가 강하게 결합되어 있기 때문에 발생하는 문제다.

상속 구조일 때, 실행시점에 Movie의 할인정책을 Amount에서 Percent로 바꾸려면 다시 인스턴스를 생성해야 한다. 하지만 합성을 사용하면 간단하게 변경할 수 있다.

 public class Movie {
     private DiscountPolicy discountPolicy;

     public void changeDiscountPolicy(DiscountPolicy discountPolicy) {
         this.discountPolicy = discountPolicy;
     }
 }

 public class Client {
     public static void main(String[] args) {
         Movie avater = new Movie(new PercentDiscountPolicy(0.2)...);

         avatar.changeDiscountPolicy(new AmountDiscountPolicy(Money.wons(1000)));
     }
 }

MovieDiscountPolicycalculateDiscountAmount()메서드를 제공한다는 사실만 알고 어떻게 구현되어 있는지는 모른다. 이처럼 인터페이스에 정의된 메세지로 코드를 재사용하는 방법을 합성이라고 한다. 합성은 구현을 캡슐화하고 의존하는 인스턴스를 변경하는 방법이 쉽다. 따라서 코드 재사용을 위해서는 상속보다 합성을 선호하는 것이 더 좋은 방법이다.