devseop08 님의 블로그

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

Architecture/객체지향설계

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

devseop08 2025. 6. 8. 08:56

1.영화 예매 시스템

  • 온라인 영화 예매 시스템 시나리오
  • '영화'와 '상영'을 구분
    • 영화는 영화에 대한 기본정보를 표현(제목, 상영 시간, 가격)
    • 상영은 실제로 관람객들이 관람하는 사건을 표현(상영 일자, 시간, 순번)
    • 영화는 하루 중 다양한 시간대에 걸쳐 한 번 이상 상영될 수 있다.
  • 실제로 사람들이 예매하는 대상은 영화가 아니라 상영이다.
  • 특정한 조건을 만족하는 예매자는 요금을 할인받을 수 있다.
  • 할인액을 결정하는 두 가지 규칙
    • 할인 조건
      • 순서 조건: 상영 순번을 이용해 할인 여부 결정
      • 기간 조건: 영화 상영 시작 시간을 이용해 할인 여부 결정, 영화 상영 시간이 특정 기간에 포함되면 요금을 할인
    • 할인 정책(할인 방식)
      • 금액 할인 정책
      • 비율 할인 정책
    • 영화 별로 하나의 할인 정책만 적용 가능하고 그 하나의 할인 정책에 대해서 다수의 할인 조건을 섞을 수 있다.

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

협력, 객체, 클래스

  • 진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞출 때 가능하다
  • 이를 위해서는 두 가지에 집중해야 한다.
    • 클래스가 필요한지 고민하지 말고 어떤 객체들이 필요한지 먼저 고민하라
      • 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결정해야 한다.
    • 객체를 협력하는 공동체의 일원으로 봐야한다.
      • 객체들 간의 관계를 고민하고 파악하라
      • 객체를 고립된 존재로 바라보지 말고 협력에 참여하는 협력자로 바라봐야한다.
      • 객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류, 이 타입을 기반으로 클래스를 구현하라

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

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

  • 일반적으로 클래스의 이름은 대응된 도메인 개념의 이름과 동일하거나 적어도 유사하게 지어야 한다.

클래스 구현하기

  • 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();
    }
}
  • 주목할 점은 인스턴스 변수의 가시성은 private이고, 인스턴스 메서드의 가시성은 public이라는 것이다.
  • 클래스를 구현하거나 다른 개발자에 의해 개발된 클래스를 사용할 때 가장 중요한 것은 클래스의 경계를 구분짓는 것이다.
  • 클래스는 외부와 내부로 구분, 클래스 설계 시 중요한 것은 어떤 부분을 외부에 공개하고 어떤 부분을 내부에 감출 지를 결정하는 것이다.
  • 클래스의 외부와 내부를 구분해야 하는 이유는 경계의 명확성이 객체의 자율성을 보장하기 때문, 그리고 더 중요한 것은 프로그래머에게 구현의 자유를 제공하기 때문
자율적인 객체
  • 데이터와 기능을 객체 내부로 함께 묶는 것을 캡슐화라고 한다.
  • 대부분의 객체지향 프로그래밍 언어는 상태와 행동을 캡슐화하는 것에 한 걸음 더 나아가,
    외부에서의 접근을 통제할 수 있는 접근 제어 메커니즘도 함께 제공한다.(접근 수정자)
  • 객체 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위해서
  • 캡슐화와 접근 제어는 객체를 두 부분으로 나눈다.
    • 퍼블릭 인터페이스: 외부에서 접근 가능한 부분
    • 구현: 외부에서 접근 불가능, 오직 내부에서만 접근 가능한 부분
    • 인터페이스와 구현의 분리 원칙은 객체지향 프로그래밍의 핵심 원칙
프로그래머의 자유
  • 프로그래머의 역할을 클래스 작성자와, 클라이언트 프로그래머로 구분하는 것이 유용하다.
  • 클래스 작성자는 프로그램에 새로운 데이터 타입을 추가하고 클라이언트 프로그래머는 클래스 작성자가 추가한 데이터 타입을 사용한다.
  • 클래스 작성자: 클라이언트 프로그래머에게 필요한 부분만 공개하고 나머지는 꽁꽁 숨긴다.
  • 클라이언트 프로그래머가 숨겨 놓은 부분에 마음대로 접근할 수 없도록 방지 -> 클라이언트 프로그래머에 대한 영향을 걱정하지 않고 내부 구현을 마음대로 변경
  • 이를 구현 은닉이라고 부른다.
  • 인터페이스와 구현을 깔끔하게 분리하기 위해 노력하라
  • 객체의 변경을 관리할 수 있는 기법 중에서 가장 대표적인 것이 접근 제어이다.

협력하는 객체들의 공동체

  • 영화 예매 기능 구현
  • Screening의 reserve 메서드가 영화를 예매한 후 예매 정보를 담고있는 Reservation의 인스턴스를 생성해서 반환한다.
public class Screening {
    public Reservation reserve(Customer customer, int audienceCount){
        return new Reservation
                    (customer, this, calculateFee(audienceCount), audienceCount);
    }
}
  • Screening의 reserve 메서드는 calculateFee라는 private 메서드를 호출해서 요금을 계산한다.
  • calculateFee 메서드는 요금 계산을 위해 다시 Movie의 calculateMovieFee 메서드를 호출한다.
public class Screening {
    private Money calculateFee(int audienceCount){
        return movie.calculateMovieFee(this).times(audienceCount);
    }
}
  • 금액과 관련된 다양한 계산을 구현하는 클래스 Money
public class Money {
    public static final Money ZERO = Money.wons(0);

    private final BigDecimal amount;

    public Money wons(long amount){
        return new Money(BigDecial.valueOf(amount));
    }

    public Money wons(double amount){
        return new Money(BigDecial.valueOf(amount));
    }

    Money(BigDecimal amount){
        this.amount = amount;
    }

    publc Money plus(Money amount){
        return new Money(this.amount.add(amount.amount));
    }

    publc Money plus(Money amount){
        return new Money(this.amount.substract(amount.amount));
    }

    public Money times(double percent) {
        return new Money(this.amount.multiply(BigDecial.valueOf(percent)));
    }
}
  • 요금을 Long으로 다루지 않고 Money라는 새로운 데이터 타입으로 다루는 것은 객체지향의 장점을 드러낸다.
  • 바로 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것이다.
  • 의미를 좀 더 명시적이고 분명하게 표현할 수 있다면 객체를 이용해서 해당 개념을 구현하라.
public class Reservation {
    private Customer customer;
    private Screening screening;
    private Money fee; 

    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 인스턴스들은 서로의 메서드를 호출하며 상호작용한다.
  • 이러한 상호 작용을 협력이라고 부른다.

협력에 관한 짧은 이야기

  • 객체는 다른 객체의 인터페이스에 공개된 행동을 수행하도록 요청할 수 있다.
  • 요청을 받은 객체는 자율적인 방법에 따라 요청을 처리한 후 응답한다.
  • 객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송하는 것뿐이다.
  • 다른 객체에게 요청이 도착할 대 해당 객체가 메시지를 수신했다고 한다.
  • 수신된 메시지를 처리하기 위한 객체 자신만의 방법을 메서드라고 부른다.
  • 메시지와 메서드를 구분하는 것은 매우 중요하다.
  • 메시지와 메서드의 구분에서부터 다형성의 개념이 출발한다.

3. 할인 요금 구하기

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

  • 예매 요금을 계산하는 협력을 살펴보자
  • 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 Movie getFee(){
        return fee;
    }

    public calculateMovieFee(Screening screening){
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}
  • 코드 어디에도 할인 정책을 특정하는 코드는 존재하지 않는다.
  • 단지 discountPolicy에게 메시지를 전송할 뿐이다.
  • 이 코드에는 상속과 다형성 개념이 적용되어 있다. 그리고 그 기반에는 추상화라는 원리가 숨겨져 있다.

할인 정책과 할인 조건

  • 할인 정책은 금액 할인 정책과 비율 할인 정책으로 구분
  • 두 가지 할인 정책을 AmountDiscountPolicy 클래스와 PercentDiscountPolicy 클래스로 구현하는데 두 클래스는 대부분의 코드가 유사하고 할인 요금 계산 방식만 다르기 때문에 두 클래스 사이의 중복 코드를 제거하기 위해 공통 코드를 보관할 장소가 필요
  • DiscountPolicy 클래스를 부모 클래스로 하여 이 클래스 안에 중복 코드를 두고 AmountDiscountPolicy 클래스와 PercentDiscountPolicy 클래스가 이 클래스를 상속받게 한다.
  • 실제 애플리케이션에서는 DiscountPolicy의 인스턴스를 생성할 필요가 없기 때문에 DiscountPolicy 클래스를 추상 클래스로 구현한다.
public abstract class DiscountPolicy {
    private List<DiscountCondition> conditions = new ArrayList<>();

    public DiscountPolicy(DiscountContition ... 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 getDiscountAmount(Screening screening);
}
  • DiscountPolicy는 할인 여부와 요금 계산에 필요한 전체적인 흐름은 정의하지만 실제로 요금을 계산하는 부분은 추상 메서드인 getDiscountAmount 메서드에게 위임한다.
  • 결국 실제로는 DiscountPolicy 클래스를 상속받은 자식 클래스에서 오버라이딩한 메서드가 실행될 것이다.
  • 이처럼 부모 클래스에 기본적인 알고리즘 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 템플릿 메서드 패턴이라고 한다.
  • 할인 조건 클래스인 DiscountCondition 클래스는 인터페이스를 이용해 선언(구현)했다
  • SequenceCondition과 PeriodCondition은 할인 조건을 만족하는지 확인하는 기능을 한다는 것만 같고, 공통적으로 갖는 부분이 없기 때문에 인터페이스를 사용하는 것이 낫다.
public interface DiscountCondition {
    boolean isSatisfied(Screening screening);
}
  • DiscountCondition 인터페이스를 구현한 SequenceContidion 클래스와 PeriodCondition 클래스
public class SequenceCondition implements DiscountCondition {
    private int sequence;

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

    @Override
    pubilc boolean isSatisfied(Screening screenig){
        return screening.isSequence(sequence);
    }
}
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.getEndTime().toLocalTime()) >= 0;
    }
}
  • 이제 할인 정책을 구현하자
public class AmountDiscountPolicy extends DiscountPolicy {
    private Money discountAmount;

    public AmountDiscountPolicy(Money discountAmount, 
                                        DiscountContion ... conditions){
        super(conditons);
        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)
    }
}

할인 정책 구성하기

  • 생성자 파리미터 목록을 이용하여 초기화에 필요한 정보를 전달하도록 강제하면 올바른 상태를 가진 객체의 생성을 보장할 수 있다.
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 abstract class DiscountPolicy {
    private List<DiscountCondition> conditions = new ArrayList<>();

    public DiscountPolicy(DiscountContition ... 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(20,59))
        )); 

4. 상속과 다형성

  • 의존성의 개념을 살펴보고 상속과 다형성을 이용해 특정 조건을 선택적으로 실행하는 방법을 알아보자

컴파일 시간 의존성과 실행 시간 의존성

  • Movie 클래스가 DiscountPolicy 클래스와 연결돼있다.
  • 근데 문제는 영화 요금을 계산하기 위해서는 추상 클래스인 DiscountPolicy가 아니라 AmountDiscountPolicy와 PercentDiscountPolicy 인스턴스가 필요하다는 것이다.
  • Movie의 인스턴스는 실행 시에 AmountDiscountPolicy나 PercentDiscountPolicy 인스턴스에 의존해야 한다.
  • 그러나 코드 수준에서 Movie 클래스는 이 두 클래스 중 어떤 것에도 의존하지 않는다. 오직 추상 클래스인 DiscountPolicy에만 의존하고 있다.
  • Movie의 인스턴스가 코드 작성 시점에서는 그 존재조차 몰랐던 AmountDiscountPolicy와 PercentDiscountPolicy의 인스턴스와 협력이 가능한 이유는 무엇인가?
  • Movie의 인스턴스를 생성하는 코드를 살펴봐야 한다.
Movie avatar = new Movie("아바타", 
    Duration.ofMinute(120),
    Money.won(10000),
    new AmountDiscountPolicy(Money.wons(800), ...)
);
  • 이런 식으로 Movie 클래스의 인스턴스를 생성하도록 하면, 실행 시에 Movie의 인스턴스가 AmountDiscouontPolicy 클래스의 인스턴스에 의존하게 될 것이다.
  • 영화 요금을 계산하기 위해 비율 할인 정책을 적용하고 싶다면 AmountDiscountPolicy 대신 PercentDiscountPolicy의 인스턴스를 전달하면 된다.
Movie avatar = new Movie("아바타", 
    Duration.ofMinute(120),
    Money.won(10000),
    new PercentDiscountPolicy(0.1, ...)
);
  • 코드 상에서는 Movie가 DiscountPolicy에 의존한다.
  • 하지만 실행 시점에서는 AmountDiscountPolicy나 PercentDiscountPolicy의 인스턴스에 의존하게 된다. => 코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다.
  • 클래스 사이의 의존성과 객체 사이의 의존성은 다를 수 있다.
  • 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드를 이해하기 어려워진다.
  • 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드는 더욱 유연해지고 확장 가능해진다.
  • 의존성의 양면성은 설계가 트레이드오프의 산물이라는 사실을 잘 보여준다.
  • 설계가 유연해질수록 코드를 이해하고 디버깅하기는 점점 더 어려워진다.
  • 항상 유연성과 가독성 사이에서 고민해야 한다.
  • 클래스로의 의존성이 어떻게 실행 시점에는 인스턴스에 대한 의존성으로 바뀔 수 있는 것인지 알기 위해 상속을 살펴보자

차이에 의한 프로그래밍

  • 상속은 기존 클래스와 매우 흡사한 클래스를 만들 때 기존 클래스의 코드를 가져와 약간만 추가하거나 수정해서 새로운 클래스를 만들 수 있도록 해준다.
  • 상속은 객체지향에서 코드를 재사용하기 위해 가장 널리 사용되는 방법이다
  • 상속을 이용하면 클래스 사이에 관계를 성정하는 것만으로 기존 클래스가 가지고 있는 모든 속성과 행동을 새로운 클래스에 포함시킬 수 있다.
  • 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍이라고 부른다.

상속과 인터페이스

  • 상속이 가치 있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문이다.
  • 인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의한다.
  • 상속을 통해 자식 클래스는 자신의 인터페이스에 부모 클래스의 인터페이스를 포함하게 된다.
  • 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다.
  • 자식 클래스는 상속을 통해 부모 클래스의 인터페이스를 물려받기 때문에 부모 클래스 대신 사용될 수 있다.
  • 컴파일러는 코드 상에서 부모 클래스가 나오는 모든 장소에서 자식 클래스를 사용하는 것을 허용한다.
  • 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅이라고 부른다.

다형성

  • 메시지와 메서드는 다른 개념이다.
  • 코드 상에서 Movie 클래스는 DiscountPolicy 클래스에게 메시지를 전송하지만 실행 시점에 실제로 실행되는 메서드는 Movie와 실제 협력하는 실제 클래스가 무엇인지에 따라 달라진다.
  • Movie는 동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 실제 객체의 타입이 무엇이냐에 따라 달라진다. => 이를 다형성이라고 부른다.
  • 다형성은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반으로 한다.
  • 다형성은 컴파일 시간 의존성과 실행 시간 의존성을 다르게 만들 수 있는 객체지향의 특성을 이용해 서로 다른 메서드를 실행할 수 있게 한다.
  • 다형성이란 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력을 의미하기 때문에 다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 한다. 즉, 인터페이스가 동일해야 한다.
  • 지연 바인딩, 동적 바인딩: 다형성을 구현하는 방법으로, 메시지와 메서드를 실행 시점에 바인딩하는 것
  • 전통적인 함수 호출처럼 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것을 초기 바인딩 또는 정적 바인딩이라고 부른다.
  • 상속을 이용하면 동일한 인터페이스를 공유하는 클래스들을 하나의 타입 계층으로 묶을 수 있기 때문에 다형성을 이야기할 때 상속이 많이 언급되지만 클래스 상속만이 다형성을 구현할 수 있는 유일한 방법은 아니다.
구현 상속과 인터페이스 상속
  • 상속을 구현 상속과 인터페이스 상속으로 분류할 수 있는데, 구현 상속을 서브클래싱이라고 부르고 인터페이스 상속을 서브타이핑이라고 부른다.
  • 구현 상속은 순수하게 코드를 재사용할 목적으로 상속을 사용하는 것이고 다형적인 협력을 위해 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록 상속을 이용하는 것을 인터페이스 상속이라고 부른다.
  • 상속은 구현 상속이 아닌 인터페이스 상속을 위해 사용해야 한다.

인터페이스와 다형성

  • 구현은 공유할 필요가 없고 순수하게 인터페이스만 공유하고 싶을 때가 있는데 이를 위해 C#과 자바에서는 인터페이스라는 프로그래밍 요소를 제공한다.
  • C++의 경우에는 추상 기반 클래스를 통해 자바의 인터페이스 개념을 구현할 수 있다.
  • 자바의 인터페이스는 구현에 대한 고려 없이 다형적인 협력에 참여하는 클래스들이 공유 가능한 외부 인터페이스를 선언한 것이다.
  • 할인 조건은 구현을 공유할 필요가 없기 때문에 자바의 인터페이스를 이용해 타입 계층을 구현했다.
  • DiscountCondition을 실체화하는 클래스들은 동일한 인터페이스를 공유하며 DiscountCondition을 대신해서 사용될 수 있으며, 업캐스팅이 적용되고 협력은 다형적이다.

5. 추상화와 유연성

추상화의 힘

  • 프로그래밍 언어 측면에서 DiscountPoliciy와 DiscountCondition이 더 추상적인 이유는 인터페이스에 초점을 맞추기 때문이다.
  • 둘 다 같은 계층에 속하는 클래스들이 공통으로 가질 수 있는 인터페이스를 정의하며 구현의 일부(추상 클래스의 경우) 또는 전체(인터페이스의 경우)를 자식 클래스가 결정할 수 있도록 결정권을 위임한다.
  • 추상화를 사용할 경우의 두 가지 장점
      1. 추상화의 계층만 따로 떼어 놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술할 수 있다.
      1. 추상화를 이용하면 설계가 좀 더 유연해진다.

유연한 설계

  • 추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가하고 확장할 수 있다. => 유연한 설계가 가능하다.
  • 할인 금액이 적용되지 않는는, 즉 할인 금액이 0원인 경우를 위한 할인 정책 클래스를 추가 구현해보자
public class NoneDiscountPolicy extends DiscountPolicy {
    @Override
    protected Money getDiscountAmount(Screening screening){
        return Money.ZERO;
    }
}
Movie statWars = new Movie("스타워즈", 
                    Duration.ofMinutes(210),
                    Money.wons(10000),
                    new NoneDiscountPolicy());
  • 중요한 것은 기존의 Movie와 DiscouontPolicy는 수정하지 않고 NoneDiscountPolicy라는 새로운 클래스를 추가하는 것만으로 애플리케이션의 기능을 확장했다는 것이다.
  • 추상화를 중심으로 코드의 구조를 설계하면 유연하고 확장 가능한 설계를 만들 수 있다.
  • 추상화가 유연한 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결함되는 것을 방지하기 때문이다.

추상 클래스와 인터페이스 트레이드오프

  • NoneDiscountPolicy 클래스의 코드를 자세히 살펴보면 getDiscountAmount() 메서드가 어떤 값을 반환하더라도 상관이 없다는 사실을 알 수 있다.
  • 부모 클래스인 추상 클래스 DiscountPolicy에서 할인 조건이 없는 경우에는 getDiscountAmount() 메서드를 호출하지 않기 때문이다.
public abstract class DiscountPolicy {
    private List<DiscountCondition> conditions = new ArrayList<>();

    public DiscountPolicy(DiscountContition ... 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 getDiscountAmount(Screening screening);
}
  • 이것은 부모 클래스인 DiscountPolicy와 NoneDiscountPolicy를 개념적으로 결합시킨다.
  • 이 문제를 해결하는 방법은 DiscountPoliciy를 인터페이스로 바꾸고 NoneDiscountPolicy가 DiscountPolicy의 getDiscountAmount() 메서드가 아닌 calculateDiscountAmount() 오퍼레이션을 오버라이딩하도록 변경하는 것이다.
public interface DiscountPolicy {
    public Money calculateDiscountAmount(Screening screening);
}
  • 원래의 DiscountPolicy 클래스의 이름을 DefaultDiscountPolicy로 변경하고 DiscountPolicy 인터페이스를 구현하도록 선언
public abstract class DefaultDiscountPolicy implements DiscountPolicy{
    private List<DiscountCondition> conditions = new ArrayList<>();

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

    @Override
    public Money calculateDiscountAmount(Screening screening) {
        for(DiscountCondition each : conditions){
            if(each.isSatisfiedBy(screening)){
                return getDiscountAmount(screening);
            }
        }

        return Money.ZERO;
    }

    abstract protected getDiscountAmount(Screening screening);
}
  • NoneDiscountPolicy가 DiscountPolicy 인터페이스를 구현하도록 선언
public class NoneDiscountPolicy implements DiscountPolicy {
    @Override
    public Money calculateDiscountAmount(Screening screening){
        return Money.ZERO;
    }
}

  • DiscountPolicy를 추상 클래스로 설계하는 것과 인터페이스로 설계하는 것 중 이상적으로는 인터페이스를 사용하도록 변경한 설계가 더 좋은 설계라 할 수 있지만 현실적으로 NoneDiscountPolicy만을 위해 인퍼테이스를 추가하는 것은 과하다고 생각할 수도 있다.
  • 구현과 관련된 모든 것들이 트레이드오프의 대상이 될 수 있다.

코드 재사용

  • 상속은 코드를 재사용하기 위해 널리 사용되는 방법이지만 가장 좋은 방법이라고 할 수는 없다.
  • 코드 재사용을 위해서는 상속보다는 합성이 더 좋은 방법이다.
  • 합성은 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법을 말한다.
  • 상속을 사용해 클래스를 추가하는 것은 합성을 사용하는 방법과 기능적인 관점에서 완벽히 동일한데 왜 상속대신 합성을 코드 재사용 시 선호하는 것일까?

상속

  • 상속의 두 가지 단점
      1. 상속은 캡슐화를 위반한다.
      1. 상속은 설계를 유연하지 못하게 만든다.
  • 상속을 이용하기 위해서는 부모 클래스의 구조를 잘 알고 있어야 하기 때문에 결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다.
  • 캡슐화의 약화는 자식 클래스가 부모 클래스에 강하게 결합되도록 만들기 때문에 부모 클래스를 변경할 때 자식 클래스도 함께 변경된 확률을 높인다.
  • 상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정한다. => 실행 시점에 객체의 종류를 변경하는 것이 불가능하다. 변경하고자 하는 객체를 생성해서 상태를 복사하는 것이 최선이다.
  • 인스턴스 변수로 연결하는 방법을 사용하면 실행 시점에 객체의 종류를 간단하게 변경할 수 있다.
public class Movie {
    private DiscountPolicy discountPolicy;

    public void changeDiscountPolicy(DiscountPolicy discountPolicy){
        this.discountPolicy = discountPolicy;
    }
}
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(20,59))
        ));

avatar.changeDiscountPolicy(new PercentDiscountPolicy(0.1, ...));
  • Movie가 DiscountPolicy를 포함하는 방법 역시 코드를 재사용하는 방법이다.

합성

  • 인스턴스 변수로 연결하는 방법이 상속과 다른 점은 상속은 부모 클래스의 코드와 자식 클래스의 코드를 컴파일 시점에 하나의 단위로 강하게 결합시키는 데 반해 Movie가 DiscountPolicy의 인터페이스를 통해 약하게 결합된다는 것이다.
  • 인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성이라고 부른다.
  • 합성은 상속이 가지는 두 가지 문제점을 모두 해결한다.
  • 구현을 효과적으로 캡슐화할 수 있고, 의존하는 인스턴스를 비교적 쉽게 교체할 수 있기 때문에 설계를 유연하게 만든다.
  • 상속은 클래스를 통해 강하게 결합되는데 반해 합성은 메시지를 통해 느슨하게 결합된다.
  • 코드 재사용을 위해서는 상속보다는 합성을 선호하는 것이 더 좋은 방법이다.
  • 다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용할 수 밖에 없다.