객체지향 프로그래밍

Ehigh·2025년 2월 10일

Object

목록 보기
2/2

오브젝트 책을 공부하고 정리한 글입니다.

2장의 실습 예제(영화 예매 시스템) 을 구현하며 공부한 내용을 정리했다.

주요 포인트는 다음과 같다.
1. 객체의 책임을 고려한 예외처리의 위치(방식)
2. 트레이드 오프 - 확장성 vs 설계의 간소화
3. 상속(is-a)과 합성(has-a)의 차이점 사례

프로그램 구조

구조는 위와 같다. 예매, 상영, 영화 가 기본적으로 필요하고, 할인 구현사항이 추가됨에 따라 할인 정책(=방식)할인 조건이 추가된다.

  • 한 영화 당, N번의 상영이 있을 수 있다.
  • 한번의 상영 당 N번의 예매가 발생할 수 있다.
  • 영화는 1개의 할인 정책만 갖는다. 이는 1개의 할인 방식과, 여러 개의 할인 조건으로 구성된다.
  • 할인 방식에는 금액(정량) 할인, 비율 할인이 있다.
  • 할인 조건에는 순번 조건(일마다 n번째 상영되는 영화 할인)과 기간 조건(특정 일, 시간 안에 상영되는 영화 할인)이 있다.

클래스 코드 설명

돈(Money)

주요 클래스는 아니지만, Long타입보다 의미를 더 잘 전달하기 위해 생성했다.

public class Money {  
    public static final Money ZERO = Money.wons(0);  
  
    private final BigDecimal amount;  
  
    private 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;  
    }
    ...

amount속성을 가지며, Money.wons(1000) 또는 Money.ZERO와 같이 사용한다.
이외에 Money간 더하기, 빼기, 곱하기, 비교연산 등이 있다.

영화(Movie)

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

제목, 러닝타임, 관람료, 할인정책 속성을 갖는다.
추가로 단순 관람료를 반환하는 getFee()와,
할인이 반영된 요금을 반환하는 calculateMovieFee()를 갖는다.

상영(Screening)

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 getStartTime() {
        return whenScreened;
    }

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

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

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

영화, 상영순번, 시작시간 속성을 갖는다.
그 외에 Reservation을 반환하는 예매매(reserve()),
예매인원, 할인까지 반영한 요금을 반환하는 calculateFee() 를 갖는다.

예매(Reservation)

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

예매정보만을 가지며, 별다른 기능은 없다.
예매자, 상영 정보, 요금, 예매인원 등을 속성으로 갖는다.

할인 정책(DiscountPolicy)

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

금액 할인, 비율 할인이 있다.
할인 요금을 구하는 getDiscountAmount() 외에 다른 부분은 공통이므로, 추상 클래스로 정의한다.
여러 할인 조건을 가질 수 있으며, 하나라도 만족하면 정해진 방식에 따라 할인액을 반환한다.

할인 조건(DiscountCondition)

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

상영순번 조건, 기간 조건건이 있다.
공통 부분이 없어서, 인터페이스로 구현한다.
구현체들은 각각 조건을 필드로 갖고, 조건을 만족하는지 여부를 반환하는 isSatisfiedBy()를 구현한다.

상영순번 조건(SequenceCondition)

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

인자로 받은 Screening의 sequence와 조건의 sequence가 일치하는지 검사한다.

기간 조건(PeriodCondition)

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

상영이 특정 시간 안에 이뤄지는지를 검사한다.

로직 흐름

예매하기 기능을 호출했을 때, 동작 흐름을 보면

  1. 특정 상영에, n명을 예매(Screening.reserve())
  2. Screening은 Movie에게 요금 계산 요청
  3. Movie는 계산 과정에서, DiscountPolicy에게 할인금액 계산 요청
  4. DiscountPolicy는 가지고 있는 조건들을 검사하고, 하나라도 일치하면 지정된 만큼의 할인액을 구해서 반환
  5. 받은 요금 정보와 Screening의 상영 정보를 통해 Reservation 객체 생성

이 된다.

개선

여기서 몇몇 부분을 개선해보았다.

1. 할인이 없는 경우의 처리

아무런 할인 이벤트도 없는 경우가 있을 수 있다.
기존 코드에선, 이런 경우 예외가 발생한다.

이에 대해서 다음과 같이 분기를 추가해서 처리할 수 있다.

public class Movie {

    ...

    public Money calculateMovieFee(Screening screening) {
        if (discountPolicy == null) {
            return fee;
        }

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

문제점 : Movie가 할인 정책에 대한 책임을 가진다.

할인 정책과 관련된 부분은, 이미 해당 책임을 갖고 있는 DiscountPolicy에서 처리되어야 좋은 설계일 것이다.
그러나 위와 같은 방식에서는, 할인이 없는 경우에 대한 처리를 Movie에서 한다.
이러면 할인정책을 변경했을 때, Movie도 수정해야 한다거나.. 하는 상황이 발생할 수 있다.

따라서 이 방식 대신, 별도의 DiscountPolicy 클래스를 만들어 해결할 수 있다.

public class NoneDiscountPolicy extends DiscountPolicy {
    @Override
    protected Money getDiscountAmount(Screening screening) {
        return Money.ZERO;
    }
}

Movie 객체 생성 시, discountPolicy 자리에 null이 아닌 NoneDiscountPolicy를 넣는다.

이렇게 함으로써 할인 정책에 대한 책임은 DiscountPolicy가 온전히 지고, Movie와의 결합도를 낮출 수 있다.

2. NoneDiscountPolicy가 추가되며 생긴 문제점

현재 DiscountPolicy.calculateDiscountAmount()와 NoneDiscountPolicy.getDiscountAmount()를 보자.
getDiscounyPolicy()는 호출될 일이 없다. NoneDiscountPolicy는 할인 조건(conditions)을 갖지 않기 때문이다.

즉, 현재 상황에서 NoneDiscountPolicy 클래스를 만들고 메소드를 오버라이딩한 건 크게 의미가 없으며, 단지 condition이 없기 때문에 로직이 맞게 수행되고 있는 것이다.
차라리 할인 조건을 체크하는 단계에서 반복문을 돌지 않고 바로 끝내야 더 맞을 것이다.

이런 의미상의 문제 외에 또 하나의 문제점은, 앞으로 DiscountPolicy에 코드를 추가할 때 DiscountCondition이 없는 경우를 항상 고려해야 한다는 것이다.
예시로, 만약 어딘가에서 conditions.get(0)을 추가한다면, NoneDiscounyPolicy를 쓸 땐 예외가 터진다.

이런 문제를 막기 위해, 다음과 같이 DiscountPolicy를 2단계로 분리할 수 있다.

단일 메소드만 갖는 DiscountPolicy 인터페이스를 정의했고, 기존 추상 클래스는 이를 상속받게 만들었다. 그리고 NoneDiscountPolicy가 기존 추상 클래스가 아닌 새로운 인터페이스를 상속하게 해서 calculateDiscountAmount() 자체를 오버라이딩했다.

public interface DiscountPolicy {
    Money calculateDiscountAmount(Screening screening);
}
public abstract class DefaultDiscountPolicy implements DiscountPolicy {
    private List<DiscountCondition> conditions = new ArrayList<>();

    public DefaultDiscountPolicy(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);
}
public class NoneDiscountPolicy implements DiscountPolicy {

    @Override
    public Money calculateDiscountAmount(Screening screening) {
        return Money.ZERO;
    }
}

다만 이렇게 했을 때, 설계가 꽤 복잡해진 것을 볼 수 있다.
그래서 이런 선택은 트레이드 오프이고, 상황에 맞게 선택해야 한다.

3. 만약 Movie와 할인 정책의 관계를 상속으로 구현했다면

현재는, Movie와 DiscountPolicy가 has-a 관계(한쪽이 다른 쪽을 소유하는 관계)로 구현되어 있다. 이를 합성이라고 한다.

합성으로 구현하면, 상속으로 구현할 때보다 유연하다. 캡슐화가 깨지지 않으며, 변경도 간단하게 할 수 있다.

Movie에 할인 정책을 변경하는 changeDiscountPolicy를 추가했다. 이를 통해 금액 할인을 비율 할인으로 변경하거나, 할인을 없앨 수도 있다.

public class Movie {

	...

    public void changeDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}
public class Main {
	public static void main(String[] args) {
		// 스타워즈  
		Movie starWars = new Movie("스타워즈",  
        Duration.ofMinutes(210),  
        Money.wons(10000),  
        new NoneDiscountPolicy());  
  
		// 할인 정책 변경  
		starWars.changeDiscountPolicy(new PercentDiscountPolicy(0.1, new SequenceCondition(1)));
	}
}

보다시피 간단하게 사용할 수 있다.

같은 기능을 상속 관계일 때 구현한다면, 어떻게 될까?
추상 클래스인 MovieWithInheritance를 바탕으로, 서로 다른 할인 방식을 갖는
AmountDiscoutMovie, PercentDiscountMovie 클래스를 만들었다.

public class Main {
	public static void main(String[] args) {
		// 금액 할인 정책을 갖는 영화 생성  
		MovieWithInheritance avatar2 = new AmountDiscountMovie("아바타2",  
        Duration.ofMinutes(180),  
        Money.wons(11000),  
        Money.wons(1000),  
        true);  
  
		// 비율 할인으로 변경 => 아예 다른 클래스를 생성해야 하므로, 기존 값을 복사해서 새로 생성하는 방법밖에 없음  
		avatar2 = new PercentDiscountMovie(avatar2.getTitle(),  
        avatar2.getRunningTime(),  
        avatar2.getFee(),  
        0.1,  
        true);
	}
}

위와 같이, 기존 Movie의 상태값을 복사해와서 새로 생성하는 방법밖에 없게 된다.

참고 자료

0개의 댓글