티스토리 뷰

영화 예매 시스템

  • 영화는 영화에 대한 기본 정보를 의미한다. - 제목, 상영시간, 할인정책, 할인조건
  • 상영은 관람객이 영화를 관람하는 정보를 의미한다. (실제 관람객이 예매하는 대상)
  • 할인 조건에 따른 할인 정책이 존재한다.
할인 조건 순서 조건 상영 순번을 이용해 할인 여부를 결정.
기간 조건 상영 시작시간이 특정 기간인 경우 할인 여부 결정.
할인 정책 금액 할인 정책 예매 금액에서 일정 금액을 할인해 주는 방식.
비율 할인 정책 예매 금액에서 일정 비율의 금액을 할인해 주는 방식.

객체 지향 설계의 초점

  1. 클래스를 고민하기전에 어떤 객체가 필요한지부터 고민하라. (도메인 결정을 먼저하라는 의미인듯?)
  2. 객체를 독립적인 존재가 아닌 협력하는 공동체의 일원으로 생각하라. (각 객체는 자율적인 존재)

영화 예매 객체 구조

책에서 아주 자세하게 구조도가 그려져있다.
너무 상세히 넣어놓으면 안될거 같아 궁금한 사람은 서적을 읽기 바란다.
  • 영화는 상영을 여러개 할수 있다.
  • 하나의 상영에 여러개의 예매가 가능하다.
  • 하나의 영화에는 최대 하나의 할인 정책이 존재하며 없을 수도있다.
  • 하나의 할인 정책에는 여러개의 할인 조건이 존재할수 있으며 없을수도 있다.

소스

영화 상영 정보

변수의 가시성은 private, 메서드의 가시성은 public. (일반적으로 객체의 상태는 숨기고 행동은 공개한다.)
클래스를 설계할때는 어떤 부분을 감추고 어떤부분을 공개할지를 판단해야한다. (캡슐화, 외부/내부 분리)

  • 클래스의 외부/내부 분리시 이점
    • 경계의 명확성이 객체의 자율성을 보장한다.
    • 클래스를 사용하는 쪽은 알아야할 부분이 줄어들고 만드는 쪽은 변경할수 있는 폭이 넓어짐.

상영 정보 클래스에서 예매 생성(reserve)도 담당하며 이때 전체 예매 금액을 계산한다.

/**
 * 영화 상영 정보 도메인 클래스
 */
@RequiredArgsConstructor
public class Screening {
    /** * 상영될 영화 정보 */
    private final Movie movie;
    /** * 상영 회차 정보 */
    private final int sequence;
    /** * 상영 시작 시간 */
    private final LocalDateTime whenScreened;

    /**
     * 영화 시작 시간을 반환한다.
     * @return 영화 시작 시간
     */
    public LocalDateTime getStartTime() {
        return whenScreened;
    }

    /**
     * 인자로 들어온 회차와 같은지 판단한다.
     * @param sequence 회차 정보
     * @return 인자로 들어온 회차와 같은지 여부
     */
    public boolean isSequence(int sequence) {
        return this.sequence == sequence;
    }

    /**
     * 상영되는 영화의 관람 가격을 반환한다.
     * @return 영화 관람 가격
     */
    public Money getMovieFee() {
        return this.movie.getFee();
    }

    /**
     * 상영 예약 정보를 만들어 반환한다.
     * @param customer 예약 고객정보
     * @param audienceCount 예약 인원수
     * @return 영화 예약 정보
     */
    public Reservation reserve(Customer customer, int audienceCount) {
        return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
    }

    /**
     * 1인당 관람 비용을 구한 뒤 인원수대로 곱한 후 반한환다.
     * @param audienceCount 관람객 인원수
     * @return 총 관람 금액
     */
    private Money calculateFee(int audienceCount) {
        return this.movie.calculateMovieFee(this).times(audienceCount);
    }
}

금액 정보

int나 long으로 표현 할수 있지만 Money 도메인을 사용하여 명확한 의미전달을 할 수 있다.

/**
 * 금액을 나타내는 도메인 오브젝트
 */
@RequiredArgsConstructor
public class Money {
    public static final Money ZERO = Money.wons(0L);

    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 plus(Money anotherAmount) {
        return new Money(this.amount.add(anotherAmount.amount));
    }

    public Money minus(Money anotherAmount) {
        return new Money(this.amount.subtract(anotherAmount.amount));
    }

    public Money times(double percent) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(percent)));
    }

    public boolean isLessThan(Money other) {
        return this.amount.compareTo(other.amount) < 0;
    }

    public boolean isGreaterThanOrEqual(Money other) {
        return this.amount.compareTo(other.amount) >= 0;
    }

    @Override
    public String toString() {
        final StringBuilder sb = new StringBuilder("Money{");
        sb.append("amount=").append(amount);
        sb.append('}');
        return sb.toString();
    }
}

예약 정보

영화를 예매하기위해 여러 객체의 협력이 발생한다.

상영 정보(reserve) -> 영화 정보에서 예매 금액 계산(calculateMovieFee) -> 예약 정보 생성

/**
 * 예약 정보 도메인 클래스
 */
@RequiredArgsConstructor
public class Reservation {
    /** * 예약 고객정보 */
    private final Customer customer;
    /** * 예약 상영 정보 */
    private final Screening screening;
    /** * 예약 금액 */
    private final Money fee;
    /** * 예약 인원 수 */
    private final int audienceCount;

    @Override
    public String toString() {
        final StringBuilder sb = new StringBuilder("Reservation{");
        sb.append("fee=").append(fee);
        sb.append(", audienceCount=").append(audienceCount);
        sb.append('}');
        return sb.toString();
    }
}

영화 정보

calculateMoveFee에서는 discountPolicy(할인정책)의 calculateDiscountAmount에 메시지를 보내어 할인 금액을 반환받는다.
하지만 아래의 소스에서는 구체적인 할인 정책(금액, 비율)이 나타나있지않다.

/**
 * 영화 관련 정보 도메인 클래스
 */
@RequiredArgsConstructor
public class Movie {
    /** * 영화 제목 */
    private final String title;
    /** * 상영 시간 */
    private final Duration runningTime;
    /** * 영화 관람 금액 */
    @Getter private final Money fee;
    /** * 영화 할인 정책 정보 */
    private final DiscountPolicy discountPolicy;

    /**
     * 상영 정보에 따른 할인 정책을 고려해 영화 관람 가격을 계산후 반환한다.
     * @param screening 영화 상영 정보
     * @return
     */
    public Money calculateMovieFee(Screening screening) {
        return this.fee.minus(this.discountPolicy.calculateDiscountAmount(screening));
    }
}

할인 정책

할인 정책에는 금액 할인과 비율 할인 정책이 존재한다.

두 클래스의 코드가 대부분 비슷하므로 추상클래스(DiscountPolicy)로 부모를 만든뒤
자식클래스에서 구체적인 할인 정책(getDiscountAmount)을 정한다. (템플릿 메서드 패턴)

calculateDiscountAmount에서는 여러 할인 정책(DiscountCondition)중 만족하는게 있으면 할인금액을 계산하고
만족하는게 없다면 할인금액을 0원으로 반환한다.

/**
 * 영화 상영별 할인 정책 도메인 클래스
 * 상영 정보가 할인 조건에 맞는지 확인후
 * 맞다면 자식 클래스의 getDiscountAmount를 호출하여 할인금액을 계산해 반환한다.
 */
public abstract class DiscountPolicy {

    /** * 영화 할인 정책(조건) 목록 */
    private final List<DiscountCondition> conditions;

    public DiscountPolicy(DiscountCondition... discountConditions) {
        this.conditions = Arrays.asList(discountConditions);
    }

    /**
     * 영화 할인 정책에 맞는지 판단후 할인 금액을 계산하여 반환한다.
     * @param screening 영화 상영 정보
     * @return 영화 관람 할인 금액
     */
    public Money calculateDiscountAmount(Screening screening) {
        for (DiscountCondition condition : conditions) {
            if (condition.isSatisfiedBy(screening)) {
                return this.getDiscountAmount(screening);
            }
        }

        return Money.ZERO;
    }

    /**
     * 영화 상영 정보를 토대로 할인 금액을 계산해 반환한다.
     * @param screening 영화 상영 정보
     * @return 할인 금액
     */
    abstract Money getDiscountAmount(Screening screening);
}

가격 할인 정책

할인 금액을 생성자로 받아 해당 할인 금액을 고정적으로 반환한다.

/**
 * 할인 정책으로 특정 금액을 사용한다.
 */
public class AmountDiscountPolicy extends DiscountPolicy {

    /** * 할인 금액 정보 */
    private final Money discountAmount;

    public AmountDiscountPolicy(Money discountAmount, DiscountCondition... discountConditions) {
        super(discountConditions);
        this.discountAmount = discountAmount;
    }

    @Override
    Money getDiscountAmount(Screening screening) {
        return this.discountAmount;
    }
}

비율 할인 정책

비율을 생성자로 받아 상영 정보의 금액에서 비율만큼의 금액을 할인 금액으로 반환한다.

/**
 * 할인 정책으로 비율을 사용
 */
public class PercentDiscountPolicy extends DiscountPolicy {

    private final double percent;

    public PercentDiscountPolicy(double percent, DiscountCondition... discountConditions) {
        super(discountConditions);
        this.percent = percent;
    }

    /**
     * 영화 가격에서 특정 비율만큼을 할인금액으로 계산해 반환한다.
     * @param screening 영화 상영 정보
     * @return 할인 금액
     */
    @Override
    Money getDiscountAmount(Screening screening) {
        return screening.getMovieFee().times(this.percent);
    }
}

할인조건

영화 예매 할인 조건으로 상영 순번 및 기간이 존재한다.

/**
 * 영화 할인 조건 정보 도메인 클래스
 */
public interface DiscountCondition {

    /**
     * 영화 상영 정보를 참고로 영화 할인 조건에 부합하는지 판단하여 반환한다.
     * @param screening 영화 상영 정보
     * @return 할인조건 부합여부
     */
    boolean isSatisfiedBy(Screening screening);
}
@RequiredArgsConstructor
public class SequenceCondition implements DiscountCondition {

    /** * 영화 관람금액을 할인해줄 회차 */
    private final int sequence;

    /**
     * 영화 상영정보의 회차를 가지고 할인 여부를 판단한다.
     */
    @Override
    public boolean isSatisfiedBy(Screening screening) {
        return screening.isSequence(this.sequence);
    }
}
@RequiredArgsConstructor
public class PeriodCondition implements DiscountCondition {
    /** * 할인 해줄 요일 */
    private final DayOfWeek dayOfWeek;
    /** * 할인이 적용 될 시작 시간 */
    private final LocalTime startTime;
    /** * 할인이 적용 될 종료 시간 */
    private final LocalTime endTime;

    /**
     * 영화 상영이 특정 요일, 시간내에 상영되는지 판단하여 반환한다.
     * @param screening 영화 상영 정보
     * @return 특정 기간 여부
     */
    @Override
    public boolean isSatisfiedBy(Screening screening) {
        return screening.getStartTime().getDayOfWeek().equals(this.dayOfWeek)
            && startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0
            && endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
    }
}

영화 예매 구성 및 실행

아래의 예제에서 나홀로집의 경우 1회차의 경우 2000원을 할인해주며
X맨의경우 금액의 10%를 할인해주며 월요일 아침 7시~10시, 저녁 9시~11시에 할인을 해준다.

아래처럼 할인 정책과 할인 조건을 자유롭게 설정할 수 있다.

public class MovieTicketingMain {

    public static void main(String[] args) {
        // 고객 정보
        Customer customer = new Customer();
        // 1회차에 2000원 할인
        Movie aloneHomeMovie = new Movie(
            "나홀로집에",
            Duration.ofMinutes(120),
            Money.wons(12000L),
            new AmountDiscountPolicy(Money.wons(2000L), new SequenceCondition(1))
        );

        // 나홀로집에 1회차 상영정보
        Screening aloneHomeScreening_1 = new Screening(aloneHomeMovie, 1, LocalDateTime.now());
        // 나홀로집에 2회차 상영정보
        Screening aloneHomeScreening_2 = new Screening(aloneHomeMovie, 2, LocalDateTime.now());

        Reservation aloneHomeReserve_1 = aloneHomeScreening_1.reserve(customer, 2);
        System.out.println("aloneHomeReserve_1 :: " + aloneHomeReserve_1); // 20000원

        Reservation aloneHomeReserve_2 = aloneHomeScreening_2.reserve(customer, 2);
        System.out.println("aloneHomeReserve_2 :: " + aloneHomeReserve_2); // 24000원

        // 월요일 오전 7~10시, 저녁 9시~11시 10%할인
        Movie xManMovie = new Movie(
            "XMan",
            Duration.ofMinutes(120),
            Money.wons(12000L),
            new PercentDiscountPolicy(
                10,
                new PeriodCondition(DayOfWeek.MONDAY, LocalTime.of(7, 0), LocalTime.of(10, 0)),
                new PeriodCondition(DayOfWeek.MONDAY, LocalTime.of(21, 0), LocalTime.of(23, 0))
            )
        );

        // 월요일 오전 8시
        LocalDateTime discountDay = LocalDateTime.of(2020, 1, 6, 8, 0);
        // ￿화요일 오전 8시
        LocalDateTime noDiscountDay = LocalDateTime.of(2020, 1, 7, 8, 0);

        Screening discountScreen = new Screening(xManMovie, 1, discountDay);
        Screening noDiscountScreen = new Screening(xManMovie, 1, noDiscountDay);

        Reservation discountReserve = discountScreen.reserve(customer, 2);
        Reservation noDiscountReserve = noDiscountScreen.reserve(customer, 2);

        System.out.println("discountReserve :: " + discountReserve); // 216000원
        System.out.println("noDiscountReserve :: " + noDiscountReserve); // 24000원
    }
}

컴파일 타임 의존성과 런타임 의존성

소스 코드상에서는 Movie는 DiscountPolicy와 의존성을 가지고 있다.

public class Movie {
    ...
    
    /** * 영화 할인 정책 정보 */
    private final DiscountPolicy discountPolicy;

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

실제 실행시에는 구체적인 클래스를 생성자로 넘겨 업캐스팅되어 사용되어진다.

Movie aloneHomeMovie = new Movie(
    "나홀로집에",
    Duration.ofMinutes(120),
    Money.wons(12000L),
    new AmountDiscountPolicy(Money.wons(2000L), new SequenceCondition(1))
);

이처럼 코드의 의존성과 런타임 의존성은 서로 다를 수 있다.

코드 의존성과 런타임 의존성이 다를경우 실제 이어주는 코드까지 파악해야하므로 이해하기 어려워 지지만 반대로 좀더 유연한 프로그래밍을 가능하게 해준다. (의존성의 양면성)

다형성

위의 코드상에서 Movie는 DisCountPolicy에게 메시지를 전송하지만 실제 어떤 메서드가 실행될지는 객체의 클래스가 무엇이냐에 따라 달라진다. 이를 다형성이라 한다.

다시말해 메시지와 메서드를 컴파일 시점이 아닌 실행 시점에 정하며 이를 동적 바인딩 또는 지연 바인딩이라고 한다.

종종 구현은 공유 할 필요가 없으며 순수하게 인터페이스(명세)만 공유하고 싶은때 자바에서는 인터페이스를 사용한다.

구현 상속 vs 인터페이스 상속

구현상속: 코드를 재사용하기 위한 목적
인터페이스 상속: 다형적인 협력을 위해 인터페이스를 상속하는 방식

상속은 구현상속이 아닌 인터페이스 상속을 지향해야한다.

추상화

추상화 계층만 보면 요구사항을 높은 수준에서 서술할 수 있으며 좀더 유연할 설계가 가능하다.

추상화를 이용해서 상위 정책을 기술하면 기본적인 어플리케이션의 흐름을 기술하는 것을 의미한다.


유연한 설계

할인 정책이 없는 경우는 다음과 같이 처리할 수도있다.

@RequiredArgsConstructor
public class Movie {

    ....
    
    /** * 영화 할인 정책 정보 */
    private final DiscountPolicy discountPolicy;

    /**
     * 상영 정보에 따른 할인 정책을 고려해 영화 관람 가격을 계산후 반환한다.
     * @param screening 영화 상영 정보
     * @return
     */
    public Money calculateMovieFee(Screening screening) {
        // 할인 정책이 없는 특수한 상황시 기존 금액을 반환한다.
        if (this.discountPolicy == null) {
            return this.fee;
        }

        return this.fee.minus(this.discountPolicy.calculateDiscountAmount(screening));
    }
}

위와 같이 해도 정상적으로 동작하나 기존 할인 정책의 경우 DiscountPolicy에 위임 하였으나 할인 정책이 없을 경우만 Movie에서 예외 적으로 처리하고 있음.

조건문(if)를 사용하여 책임의 위치를 변경하는 것은 좋지않다.

예외 케이스를 최소화하고 일관성을 가져야한다.

미할인 정책 추가 및 사용

0원 할인 정책을 그대로 수행할 NoneDiscountPolicy클래스를 추가한다.

/**
 * 할인 정책이 없을시 사용
 */
public class NoneDiscountPolicy implements DiscountPolicy {
    /**
     * 할인 정책이 없으므로 할인 금액 0원을 반환한다.
     * @param screening 영화 상영 정보
     * @return 0원 반환
     */
    @Override
    public Money calculateDiscountAmount(Screening screening) {
        return Money.ZERO;
    }
}
// 할인 정책 없음
Movie starWarsMovie = new Movie(
    "StarWars",
    Duration.ofMinutes(120),
    Money.wons(12000L),
    new NoneDiscountPolicy()
);

Screening starWarsScreening = new Screening(starWarsMovie, 1, LocalDateTime.now());
Reservation starWarsReserve = starWarsScreening.reserve(customer, 1);

System.out.println("starWarsReserve :: " + starWarsReserve); // 12000원

Movie와 DiscountPolicy를 수정하지않고 NoneDiscountPolicy를 추가하여 어플리케이션을 확장하였다.

추상화가 유연한 설계를 가능케하는 이유는 설계가 구체적인 상황에 결합되는것을 방지하기 때문이다.

유연성이 필요한곳에 추상화를 사용하자!


명확한 의미 전달 - 추상클래스와 인터페이스의 트레이드 오프

위의 코드에서 실제 NoneDiscountPolicy는 어떤값을 반환해도 상관이 없다.
NoneDiscountPolicy는 아무런 할인 조건을 가지고 있지 않기 떄문에 부모 클래스에서 getDiscountAmount를 호출하지 않고 0원을 반환하기 때문이다.

public Money calculateDiscountAmount(Screening screening) {
    for (DiscountCondition condition : conditions) {
        if (condition.isSatisfiedBy(screening)) {
            return this.getDiscountAmount(screening);
        }
    }
    // 할인 조건이 없으면 0원을 반환한다.
    return Money.ZERO;
}

동작은 정확히 하나 NoneDiscountPolicy의 의미가 명확하지 않다.

DiscountPolicy를 추상클래스에서 인터페이스로 변경한다.

public interface DiscountPolicy {
    Money calculateDiscountAmount(Screening screening);
}

기존의 추상클래스 였던 DiscountPolicy를 DefaultDiscountPolicy로 변경한다.

public abstract class DefaultDiscountPolicy implements DiscountPolicy {

    /** * 영화 할인 정책(조건) 목록 */
    private final List<DiscountCondition> conditions;

    public DefaultDiscountPolicy(DiscountCondition... discountConditions) {
        this.conditions = Arrays.asList(discountConditions);
    }

    /**
     * 영화 할인 정책에 맞는지 판단후 할인 금액을 계산하여 반환한다.
     * @param screening 영화 상영 정보
     * @return 영화 관람 할인 금액
     */
    public Money calculateDiscountAmount(Screening screening) {
        for (DiscountCondition condition : conditions) {
            if (condition.isSatisfiedBy(screening)) {
                return this.getDiscountAmount(screening);
            }
        }

        return Money.ZERO;
    }

    /**
     * 영화 상영 정보를 토대로 할인 금액을 계산해 반환한다.
     * @param screening 영화 상영 정보
     * @return 할인 금액
     */
    abstract Money getDiscountAmount(Screening screening);
}

나머지 원래 할인 정책들은 DiscountPolicy가 아닌 DefaultDiscountPolicy를 구현한다.

public class AmountDiscountPolicy extends DefaultDiscountPolicy {

    ....
    
}
public class PercentDiscountPolicy extends DefaultDiscountPolicy {

    ....
    
}

NoneDiscountPolicy가 DiscountPolicy 인터페이스를 구현하게 하면 의미가 명확하게 전달된다.

public class NoneDiscountPolicy implements DiscountPolicy {
    /**
     * 할인 정책이 없으므로 할인 금액 0원을 반환한다.
     * @param screening 영화 상영 정보
     * @return 0원 반환
     */
    @Override
    public Money calculateDiscountAmount(Screening screening) {
        return Money.ZERO;
    }
}

그러나 미할인 정책을 위해 하나의 레이어를 더 두었으므로 그만큼 복잡도는 상승하였다.
(기존 추상 클래스 -> 구체 클래스에서 인터페이스 -> 추상 클래스 -> 구체 클래스)


상속의 문제점

  1. 캡슐화 위반 - 상속을 하기 위해서는 자식 클래스는 부모클래스 내부를 알고 있어야한다.
  2. 유연하지 않은 설계 - 부모와 자식 클래의 관계를 컴파일 타임에 정하므로 유연하게 사용하지 못한다.

만약 Movie를 상속한 AmountDiscountMovie와 PercentDiscountMovie가 존재할시 할인 정책을 변경하려면 인스턴스의 정보를 복제하여 인스턴스를 교체해야한다.

 

인스턴스 변수로 변경시에 아래와같이 간단하게 변경 가능하다.

(전체의 행동을 변경(상속)하긴 힘들지만 전체의 틀은 두고 프로세스의 일부분(합성)을 변경할 경우 유연하게 설정 가능하다는 느낌인거 같다.)

public class Movie {
    /** * 영화 할인 정책 정보 */
    private DiscountPolicy discountPolicy;

    // 영화 할인 정책 변경
    public void changeDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

합성

상속의 경우 컴파일 시점에 강하게 연결되어 있으나 합성의 경우 인터페이스를 통해 약하게 연결된다.

이를 통해 인터페이스에 정의된 메시지를 통서만 재사용이 가능하기때문에 효과적으로 캡슐화 가능하며 의존하는 인스턴스를 쉽게 교체하여 유연하게 사용 가능하다.


GIT

깃의 소스를 diff해서 보면 더 유용할것이다!

https://github.com/oodmumc3/object-book-movie-ticketing

 

oodmumc3/object-book-movie-ticketing

오브젝트 도서 - 영화 예매 시스템 예제. Contribute to oodmumc3/object-book-movie-ticketing development by creating an account on GitHub.

github.com


느낀점

이번장에서 개인적으로 느낀점은 추상화를 통해 애플리케이션 전체 구조의 밑그림을 그리고 구체적인 기능은 구체적인 클래스들을 만들어 사용/개발하라는 의미를 강하게 받았다.

이번장에서는 추상 클래스와 인터페이스, 다형성, 상속과 합성이 주요 키워드였던거 같다.

추상클래스는 코드를 공유하고 특정 부분만 다를 경우(템플릿 메서드 패턴)에 사용하고 인터페이스는 이와 달리 공유 할 코드가 없을때 사용한다.

상속의 경우는 일반적으로 알고있는 코드 및 메서드 재사용 측면에서 사용하는 것은 지양하고, 다형성을 목표로 인터페이스 공유를 위해 사용하는것을 지향해야한다.

상속의 경우는 부모와 자식의 관계가 컴파일 시점에 정해져 결합도가 높으므로 캡슐화 및 유연성이 떨어진다. 합성을 이용하면 인터페이스를 통해 협력관계가 이어지므로 캡슐화로 결합도가 낮아지며 유연성이 높아진다.

이전에도 썼다시피 자바 서적에서 각자의 의미를 사전적으로 적어놓고 나도 암기적으로 외우고만 있었지 실제로 이런 예제를 통해서 참된 의미를 느낀건 처음이다.

뒤의 내용들도 너무 기대된다.

댓글