1. 영화 애매 시스템

요구사항

사용자는 영화 예매 시스템을 이용함.
‘영화’는 영화에 대한 기본 정보를 표현한다. (제목, 상영시간, 가격 정보 등의 기본적인 영화 정보)
‘상영’ 실제로 관객들이 영화를 관람하는 사건. (상영 일자, 시간, 순번 등)
두 용어의 차이는 사용자가 실제로 예매하는 대상은 상영이기 때문.
특정한 조건을 만족하는 예매자는 요금을 할인받음. 
  - 할인 조건(discount condition): 가격의 할인 여부를 결정.
    - 순서 조건: 상영 순번을 이용해 할인 여부를 결정하는 규칙.
    - 기간 조건: 영화 상영 시작 시간을 이용해 할인 여부를 결정.
  - 할인 정책(discount policy): 할인 요금을 결정
    - 금액 할인 정책: 예매 요금에서 일정 금액을 할인해주는 방식.
    - 비율 할인 정책: 정가에서 일정 비율의 요금을 할인해 주는 방식.
영화별로 하나의 할인 정책만 할당할 수 있음. (물론 지정하지 않는 것도 가능)
할인 조건은 다수의 할인 조건을 함께 지정 가능.
순서 조건과 기간 조건을 섞는 것도 가능.
사용자가 예매를 완료하면 시스템은 예매 정보를 생성.
예매 정보에는 제목, 상영정보, 인원, 정가, 결제금액이 포함됨.

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

협력, 객체, 클래스

객체지향은 객체를 지향하는 것. 클래스를 결정한 후에 클래스에 어떤 속성과 메서드가 필요한지 고민하는 것은 객체지향의 본질과는 거리가 멈. 객체지향 패러다임은 객체에 초점을 맞춰야함. 이를 위해 두 가지에 집중하자.

  1. 어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민. 클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것. 클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결정. 객체를 중심에 두는 접근 방법은 설계를 단순하고 깔끔하게 만듬.

  2. 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 함. 객체는 다른 객체에게 도움을 주거나 의존하면서 살아가는 협력적인 존재. 객체를 협력하는 공동체의 일원으로 바라보는 것은 설계를 유연하고 확장 가능하게 만듬. 객체지향적으로 생각하기 위해서는 객체를 협력에 참여하는 협력자로 바라보자. 객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현.

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

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

도메인(Domain): 소프트웨어는 사용자가 원하는 어떤 문제를 해결하기 위해 만들어짐. 예를 들어 영화 예매 시스템의 목적은 영화를 좀 더 쉽고 빠르게 예매하려는 사용자의 문제를 해결하는 것. 이처럼 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야를 도메인이라고 함.

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

2.1.png

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

2.2.png

클래스 구현하기

도메인 개념들의 구조를 반영한 후 적절한 언어를 이용해 구조를 구현하자.

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() {
    return this.sequence == sequence;
  }

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

클래스를 구현하거나 다른 개발자에 의해 개발된 클래스를 사용할 때 가장 중요한 것은 클래스의 경계를 구분 짓는 것. 클래스는 내부와 외부로 구분. 훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 공개하고 어떤 부분을 감출지 결정하는 것.

내부와 외부를 구분하는 이유는 경계의 명확성이 객체의 자율성을 보장하기 때문. 더 중요한 이유는 프로그래머에게 구현의 자유를 제공하기 때문이다.

자율적인 객체

객체의 중요한 사실 두 가지

  1. 상태(state)와 행동(behavior)을 함께 가지는 복합적인 존재
  2. 스스로 판단하고 행동하는 자율적인 존재

객체지향 이전의 패러다임에서는 데이터와 기능이라는 독립적인 존재를 서로 엮어 프로그램을 구성.
객체지향은 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶음으로써 문제 영역의 아이디어를 적절하게 표현할 수 있게 했음. 이를 캡슐화라고 함.

대부분의 객체지향 언어들은 상태와 행동을 캡슐화하는 것에서 외부에서 접근을 통제할 수 있는 접근 제어(access control) 메커니즘을 제공함. public, protected, private과 같은 접근 수정자(access modifier)를 제공.

객제 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위해서임. 객체가 자율적인 존재가 되기 위해서는 외부에서는 객체가 어떤 상태인지, 어떤 생각을 하는지 알아서 안되며, 결정에 직접적으로 개입하려고 해서도 안됨. 단지, 객체에게 원하는 것을 요청하고 객체가 스스로 최선의 방법을 경정할 수 있을 것이라고 믿어야한다.

캡슐화와 접근 제어는 객체를 두 부분으로 나눔. (인터페이스와 구현의 분리 원칙, separation of interface and implementation)

  • public interface: 외부에서 접근 가능한 부분.
  • implementation: 외부에서는 접근 불가능하고 오직 내부에서만 접근 가능한 부분.

일반적으로 객체의 상태는 숨기고 행동만 외부에 공개해야 함. 이때 public interface에는 public으로 지정된 메서드만 포함. 그 밖의 private, protected, 속성은 구현(implementation)에 포함된다.

프로그래머의 자유

프로그래머의 역활은 클래스 작성자(class creator), 클라이언트 프로그래머(client programmer)로 구분함.

  • 클래스 작성자(class creator)
    • 새로운 데이터 타입을 프로그램에 추가
    • 클라이언트 프로그래머에게 필요한 부분만 공개하고 나머지는 숨김
  • 클라이언트 프로그래머(client programmer)
    • 클래스 작성자가 추가한 데이터 타입을 사용
    • 필요한 클래스들을 엮어서 애플리케이션을 구축

클라이언트 프로그래머에게 숨겨 놓은 부분을 접근할 수 없도록 방지함으로써 클래이언트 프로그래머에 대한 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있음. 이를 구현 은닉(implementation hiding)이라고 함. 접근 제어 메커니즘은 클래스의 내부와 외부를 명확하게 경계 지을수 있으며 작성자가 내부 구현을 은닉할 수 있게 해줌.

클라이언트 프로그래머는 인터페이스만 알고 있다면 내부 구현을 몰라도 클래스를 사용할 수 있기 때문에 머리속의 해당 클래스의 지식의 양을 줄일 수 있음. 클래스 작성자는 인터페이스를 바꾸지 않는 한 외부에 미치는 영향을 걱정하지 않고 내부 구현을 마음대로 변경 가능함. 객체의 변경을 관리할 수 있는 기법중 가장 대표적인 것이 접근 제어. 즉, 객체의 외부와 내부를 구분한다면 객체의 변경을 보다 쉽게할 수 있음.

협력하는 객체들의 공동체

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

reserve 메서드는 calculateFee라는 private 메서드를 호출해 요금을 계산한 후 그 결과를 Reservation 클래스의 생성자에 전달한다.

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.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을 사용) 금액을 구현하기 위해 Money라는 클래스를 만들어 줬다. 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)한다.

2.3.png

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

협력에 관한 짧은 이야기

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

객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메세지를 전송(send a message)하는 것. 다른 객체에게서 요청이 도착할 때 해당 객체가 메세지를 수신(receive a message)했다고 함. 메세지를 수신한 객체는 자신만의 방법(method라고 함)으로 처리.

Screening이 Movie에게 calculateMovieFee ‘메세지를 전송한다’라고 말하는 것이 더 적절한 표현.

메세지와 메서드를 구분하는 것은 중요함. 메세지와 메서드의 구분에서부터 다형성(polymorphism)의 개념이 시작된다.

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

calculateMovieFee 메서드는 discountPolicy에 calculateDiscountAmount 메세지를 전송해 할인 요금을 반환 받아 기본 요금인 fee에 반환된 할인 요금을 차감한다. 어떤 할인 정책을 사용할 것인지 결정하는 코드가 어디도 존재하지 않음. 도메인을 설명할때 분명 두 가지의 할인 정책(discount policy)가 있다고 설명했다.

  • 일정한 금액을 할인해 주는 금액 할인 정책
  • 일정 비율을 할인해 주는 비율 할인 정책

하지만 코드는 단지 discountPolicy에 메세지를 전송할 뿐이다. 이 코드에는 객체지향에서 중요한 개념 두 가지가 숨겨져 있다. 상속(inheritance)다형성(polymorphism)이다. 그리고 그 기반은 추상화(abstraction)라는 원리가 숨겨져 있음.

할인 정책과 할인 조건

2.4.png

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 condition : conditions) {
      if (condition.isSatisfiedBy(screening)) {
        return getDicountAmount(screening);
      }
    }

    return Money.ZERO;
  }

  protected abstract Money getDiscountAmount(Screening screening);
}

할인 정책(DiscountPolicy)은 금액 할인 정책(AmountDiscountPolicy)과 비율 할인 정책(PresentDiscountPolicy)으로 구분된다. 두 가지 할인 정책은 대부분의 코드가 유사하고 할인 요금을 계산하는 방식만 조금 다름. 두 클래스의 중복을 제거하기 위해 DiscountPolicy라는 부모 클래스에 중복 코드를 둬 중복을 제거하고 두 클래스가 상속 받게 한다. 실제 애플리케이션에서는 DiscountPolicy 인스턴스는 필요 없기 때문에 추상 클래스(abstract class)로 구현.

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

calculateDiscountAmount 메서드는 전체 할인 조건에 대해 차례대로 DiscountCondition의 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 disconutAmount;
  }
}

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

영화 예메 시스템의 할인 조건은 두 가지가 존재함.

  • 순번 조건(SequenceCondition)
  • 기간 조건(PeriodCondition)

DiscountCondition은 인터페이스를 사용해 선언한다.

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

public class SequenceCondition implements DiscountCondition {
  private int sequence;

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

  public boolean isSatisfiedBy(Screening screening) {
    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;
  }

  public boolean isSatisfiedBy(Screening screening) {
    return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
      startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
      endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
  }
}

할인 정책 구성하기

하나의 영화에 대해 하나의 할인 정책, 여러 개의 할인 조건이 적용 가능함. Movie와 DiscountPolicy의 생성자는 이런 제약을 강제함.

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

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

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

4. 상속과 다형성

Movie 클래스는 할인 정책이 어떤 정책인지 판단하지 않는다. 그럼 어떻게 할인 정책을 선택할 수 있을까? 답을 알기 위해 상속다형성을 알아야 한다.

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

클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나 해당 클래스의 인스턴스의 메서드를 호출할 경우 두 클래스 사이에 의존성이 존재한다고 함.

2.5.png

Movic는 DiscountPolicy와 연결되어 있으며 AmountDiscountPolicy와 PercentDiscountPolicy는 추상클래스인 DiscountPolicy를 상속 받음. 영화 요금을 계산하기 위해서는 추상클래스인 DiscountPolicy가 아니라 AmountDiscountPolicy와 PercentDiscountPolicy의 인스턴스가 필요하다. 하지만 코드에서 Movie는 두 클래스 중 어떤 것에도 의존하지 않는다.

코드 작성 시점에는 서로 알지도 못했던 클래스들이 협력하는 방법은 Movie의 인스턴스를 생성하는 코드를 살펴봐야한다.

Movie avatar = new Movie(“아바타”,
    Duration.ofMinutes(120),
    Money.wons(10000),
    new AmountDiscountPolicy(Money.wons(800), ...));

영화 요금을 계산하기 위해 요금 할인 정책을 사용하고 싶으면 위와 같이 Movie의 인스턴스를 생성할 때 AmountDiscountPolicy의 인스턴스를 전달하면 된다.

2.6.png

이제 실행시 Movie 인스턴스는 AmountDiscountPolicy에 의존하게 된다.

코드의 의존성과 실행 시점의 의존성은 서로 다를 수 있다. 클래스의 사이의 의존성과 객체 사이의 의존 성은 동일하지 않을 수 있다. 유연하고 쉽게 재 사용할 수 있으며, 확장 가능한 객체지향 설계가 가지는 특징은 코드의 의존성과 실행 시점의 의존성이 다르다는 것.

하지만 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드를 이해하기 어려워진다. 코드를 이해하기 위해서는 코드뿐만 아니라 객체를 생성하고 연결해주는 부분을 찾아야 하기 때문이다. 반면 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드는 더 유연해지고 확장 가능해진다.

설계가 유연해질수록 코드를 이해하고 디버깅하기는 점점 어려워진다. 반면 유연성을 억제하면 코드를 이해하고 디버깅하기는 쉬워지지만 재사용성과 확장성은 낮아진다. 항상 유연성과 가독성 사이에서 고민하자. 객체지향 설계는 정답이 없다.

차이에 의한 프로그래밍

상속은 기존의 클래스 사이에 관계를 성정하는 것만으로 기존의 클래스가 가지고 있는 모든 속성과 행동을 새로운 클래스에 포함 시킬 수 있게 해준다. 기존 클래스를 기반으로 새로운 클래스를 쉽고 빠르게 추가할 수 있다. 또한 부모 클래스의 구현은 공유하면서도 행동이 다른 자식 클래스를 쉽게 추가할 수 있다. 이처럼 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍(programming by difference)라고 한다.

상속과 인터페이스

상속은 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있다. 상속의 목적은 메서드나 인스턴스 변수를 재사용하는 것이 아니다.

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

이처럼 자식 클래스가 부모 클래스를 대신 하는 것을 업캐스팅(upcasting)이라고 한다.

다형성 (polymorphism)

메세지와 메서드는 다른 개념이다. Movie는 DiscountPolicy의 인스턴스에게 calculateDicsountAmount 메세지를 전송한다. 그렇다면 실행되는 메서드는 무엇일까? Movie와 상호작용하기 위해 연결된 객체의 클래스가 무엇인가에 따라 달라진다. 코드 상에서 Movie 클래스는 DiscountPolicy 클래스에게 메세지를 보내지만 실행 시점에 실제로 실행되는 메서드는 Movie와 협력하는 객체의 실제 클래스가 무엇인지에 따라 달라진다. Movie는 동일한 메세지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메세지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다. 이를 다형성(polymorphism)이라 한다.

  • 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반
  • 동일한 메세지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력을 의미
    • 즉, 다형적인 협력에 참여하는 객체들은 모두 같은 메세지를 이해할 수 있어야 함.
  • 구현하는 방법은 다양하지만 메세지에 응답하기 위해 실행될 메서드를 컴파일 시점이 아닌 실행 시점에 결정한다는 공통점이 있음.
    • 메세지와 메서드를 실행 시점에 바인딩. 이를 지연 바인딩(lazy binding) 또는 동적 바인딩(dynamic binding)이라고 함.
  • 동일한 인터페이스를 공유하는 클래스들을 하나의 타입 계층올 묶을 수 있음.
    • 이러한 이유로 다형성을 이야기할 때, 상속과 함께 언급. 하지만 클래스를 상속받는 것이 다형성을 구현할 수 있는 유일한 방법은 아님.

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

구현 상속: 코드를 재사용하는 목적으로 상속하는 것.
인터페이스 상속 : 다형적인 협력을 위해 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록 상속

상속은 구현 상속이 아닌 인터페이스 상속을 위해 사용하자. 구현을 재사용할 목적으로 상속을 사용하면 변경에 취약한 코드를 낳게 될 확율이 높다.

인터페이스와 다형성

구현은 공유할 필요가 없고 순수하게 인터페이스만 공유하고 싶다면 자바에서 제공하는 인터페이스(interface)를 사용하자. 말 그대로 구현에 대한 고려 없이 다형적인 협력에 참여하는 클래스들이 공유 가능한 외부 인터페이스를 정의한 것.

5. 추상화와 유연성

추상화의 힘

할인 정책(DiscountPolicy)은 구체적인 금액 할인 정책(AmountDiscountPolicy)과 비율 할인 정책(PercentDiscountPolicy)을 포괄하는 추상적인 개념이다.

언어 측면에서 DiscountPolicy가 더 추상적인 이유는 인터페이스에 초점을 맞추기 때문. DiscountPolicy는 모든 할인 정책이 수신할 수 있는 calculateDiscountAmount 메세지를 정의한다. 같은 계층에 속하는 클래스들이 공통으로 가질 수 있는 인터페이스를 정의하며 구현의 일부 또는 전체를 자식 클래스가 결정할 수 있도록 위임한다.

2.7.png

추상화를 사용할 경우의 두 가지 장점

  1. 추상화의 계층만 따로 떼어 놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술할 수 있다.

    • 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현 가능
    • 상위 개념만으로도 도메인의 중요한 개념을 설명할 수 있게 함.
    • 필요에 따라 표현의 수준을 조정하는 것이 가능하게 해줌.
    • 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 흐름을 기술한다는 것과 같음.
    • 자식 클래스들은 추상화를 이용해서 정의한 상위의 협력 흐름을 그대로 따르게 됨.
  2. 추상화를 이용하면 설계가 좀 더 유연해진다.

유연한 설계

할인 정책이 적용되지 않은 영화는 어떻게 할 것인가? 할인 요금을 계산할 필요 없이 영화에 설정된 기본 금액을 사용하면 된다.

public class Movie {
  ...
  public Money calculateMovieFee(Screening screenig) {
    if (discountPolicy == null) {
      return fee;
    }
    return fee.minus(discountPolicy.calculateDiscountAmount(screening));
  }
}

이 방버의 문제점은 할인 정책이 없는 경우를 예외 케이스로 취급하기 때문에 지금까지 일관성 있던 협력 방식이 무너지게 된다는 것. 기존 할인 정책의 경우 할인할 금액이 계산하는 책임이 DiscountPolicy의 자식 클래스에 있었지만 할인 정책이 없는 경우에는 할인 금액이 0원이라는 사실을 결정하는 책임이 DiscountPolicy가 아닌 Movie 쪽에 있기 때문이다. 따라서 책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 좋지 않은 선택이다. 항상 예외 케이스를 최소화하고 일관싱얼 우지할 수 있는 방법을 선택하자.

일관성을 지킬 수 있는 방법은 0원이라는 할인 요금을 계산할 책임을 그대로 DiscountPolicy 계층에 유지시키는 것이다.

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

여기서 중요한 것은 기존의 코드를 수정하지 않고 NoneDiscountPolicy라는 새로운 클래스를 추가하는 것만으로 애플리케이션의 기능을 확장했다는 것. 이처럼 추상화를 중심으로 코드의 구조를 설계하면 유연하고 확장 가능한 설계를 만들 수 있다.

추상화가 유연한 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는 것을 방지하기 때문. Movie는 특정한 할인 정책에 묶이지 않는다. 할인 정책을 구현한 클래스가 DiscountPolicy를 상속 받고 있다면 어떤 클래스와도 협력이 가능하다.

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

코드 재사용

코드를 재사용하기 위해 많이 쓰는 방식들 중 상속과 합성이 있다. 흔히들 코드 재사용을 위해서는 상속보다는 합성(composition)이 더 좋은 방법이라고 한다.

합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법. Movie가 DiscountPolicy의 코드를 재사용하는 방법이 합성.

상속

상속은 객체지향에서 코드를 재사용하기 위해 널리 사용되는 기법. 하지만 두 가지 관점에서 설계에 안 좋은 영향을 미침.

  1. 캡슐화 위반

    상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알고 있어야함. 결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화 된다. 캡슐화의 약화는 자식 클래스가 부모 클래스에 강하게 결합되도록 만듬. 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다. 상속을 과도하게 사용한 코드는 변경하기도 어려워진다.

  2. 설계를 유연하지 못하게 만듬.

    상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정. 따라서 실행 시점에 객체의 종류를 변경하는 것이 불가능.

합성

Movie는 요금을 계산하기 위해 DiscountPolicy의 코드를 재사용. 이 방법이 상속과 다른 점은 상속이 부모 클래스의 코드와 자식 클래스의 코드를 컴파일 시점에 하나의 단위로 강하게 결합하는데 비해 Movie가 DiscountPolicy의 인터페이스를 통해 약하게 결합된다는 것. Movie는 DiscountPolicy가 외부에 calculateDiscountAmount 메서드를 제공한다는 사실만 알고 내부 구현은 알지 못함. 이처럼 인테페이스에 정의된 메세지를 통해서만 코드를 재사용하는 방법을 합성이라고 함.

합성은 상속이 가지는 두 가지 문제점을 모두 해결한다. 인테페이스에 정의된 메세지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화 가능. 또한 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만듬.

상속은 클래스를 통해 강하게 결합되지만 합성은 메세지를 통해 느슨하게 결합된다. 따라서 코드 재사용을 위해서는 상속보다는 합성을 선호하자.

그렇다고 상속을 사용하지 말라는 것은 아님. 대부분의 설계는 상속과 합성이 함께 사용해야 한다.