Computer Science/객체지향

객체지향 프로그래밍 - 코드로 이해하는 객체지향 설계

sh1mj1 2023. 7. 20. 22:17

이전 글 -  https://sh1mj1-log.tistory.com/128 : 프로그래밍 패러다임과 객체, 설계 - 코드로 이해하는 객체지향 설계

에서 이어집니다.

 

객체지향 프로그래밍

책에서는 가상의 영화 예매 시스템을 예로 들어서 설명합니다.

영화 예매 시스템

요구사항 살펴보기

사용자는 영화 예매 시스템을 이용해서 쉽고 빠르게 영화 예매할 수 있다.

 

영화 : 영화에 대한 기본 정보(제목, 상영 시간(영화 런타임), 가격 정보)

상영: 실제로 관람하는 사건(상영 일자, 시간, 순번)

사용자가 실제로 예매하는 대상은 영화가 아니라 상영이다.

 

특정 조건을 만족하는 예매자는 요금을 할인받을 수 있다. 할인액은 할인 조건(discount condition)과 할인 정책(discount policy) 으로 결정된다. 

할인 조건은 순서조건(sequence condition - 어떤 영화의 순번이 10인 경우 매일 10번째로 상영되는 영화 할인)과 기간조건(period condition - 특정 요일에 할인 이벤트 시작 시간과 종료 시간 사이에 시작하는 영화는 할인) 으로 나뉩니다. 할인 조건은 한 영화에 여러 개가 적용될 수 있습니다. 하나라도 만족하면 할인 조건에 충족되는 것입니다.

할인 정책은 금액 할인 정책(amount discount policy - 예매 요금에서 일정 금액 할인)과 비율 할인 정책(percent discount policy -  정가에서 일정 비율 할인)으로 나뉩니다. 영화별로 하나의 할인 정책만 할당할 수 있습니다. 할인 정책이 없는 영화도 있습니다.

 

사용자가 예매를 완료하면 시스템은 예매정보(제목, 상영정보, 인원, 정가, 결제 금액)를 생성한다.

 

위 요구사항을 객체 지향 프로그래밍 언어를 이용해 구현해봅니다.

 

협력, 객체, 클래스

진정한 객체지향 패러다임은 클래스가 아닌 객체에 초점을 맞추는 것입니다. 우리는 아래 두가지에 집중해야 합니다.

 

  • 어떤 클래스가 필요한지보다 어떤 객체가 필요한지를 먼저 고민하자.

클래스는 공통적인 상태와 행동을 공유하는 객체를 추상화한 것입니다. 즉, 객체가 어떤 상태와 행동을 가지는지부터 결정해야 합니다.

 

  • 객체는 독립적인 존재가 아니라 기능을 구현하기 위한 부품이다. 

객체는 다른 객체에게 도움을 주거나 의존하는 협력자입니다. 객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현해야 합니다.

 

도메인

소프트웨어는 사용자의 문제를 해결하기 위해 만들어집니다. 영화 예매 시스템의 목적은 영화를 더 쉽게 빠르게 예매하려는 사용자의 문제를 해결하는 것입니다.

도메인(domain)은 이처럼 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야입니다.

 

객체지향 패러다임에서는 요구사항을 분석하는 초기 단계부터 프로그램 구현의 마지막 단계까지 객체라는 동일한 추상화 기법을 사용할 수 있습니다. 요구사항과 프로그램을 객체로 보기 때문에 도메인 개념들이 프로그램의 객체와 클래스로 부드럽게 이어집니다.

클래스 이름은 대응되는 도메인 개념의 이름과 동일하거나 유사하게 지어야 합니다. 관계도 마찬가지죠. 그렇게 프로그램의 구조를 이해하고 예상하기 쉽게 만들어야 합니다.

도메인 개념의 구조를 따르는 클래스 구조

클래스 구현

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 입니다. 클래스 설계의 핵심은 어떤 부분은 외부에 공개하고 어떤 부분은 감출지를 결정하는 것입니다.

경계를 확고히 해야 하는 이유는 경계의 명확성이 객체의 자율성을 보장하기 때문입니다.

 

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

대부분의 객체지향 프로그래밍 언어는 상태, 행동을 캡슐화(데이터와 기능을 객체 내부로 묶음)하는 것 뿐만 아니라 외부에서의 접근을 통제할 수 있는 접근 제어 매커니즘, 접근 제어를 위해 접근 수정자(access modifier)도 제공합니다. public, protected, private 같은 것들 말이죠.

객체가 자율적인 존재이기 위해서는 외부의 간섭을 최소화해야 합니다.

 

캡슐화와 접근제어는 객체를 두 부분으로 나눕니다.

  • public interface: 외부에서 접근 가능한 부분
  • 구현(implementation): 오직 내부에서만 접근 가능한 부분

인터페이스와 구현의 분리(Separation of interface and implementation) 원칙은 훌륭한 객체지향 프로그램을 만들기 위해 따라야 하는 핵심 원칙입니다. 일반적으로 객체의 상태는 숨기고 행동만 외부에 공개해야 합니다.

 

클래스 작성자(class creator): 새로운 데이터 타입을 프로그램에 추가

클라이언트 프로그래머(client programmer): 클래스 작성자가 추가한 데이터 타입을 사용.

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

객체와 외부와 내부를 구분하면 클라이언트 프로그래머가 알아야 할 부분이 줄어들고 클래스 작성자가 자유롭게 구현을 변경할 수 있게 됩니다.  다시 한번 되새겨야 할 부분은 설계가 필요한 이유는 변경을 관리하기 위함이라는 것입니다.

 

public class Reservation{
	...
    public Reservation reserve(Customer customer, int audienceCount){
        return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
    }
    private Money calculateFee(int audinceCount){
        return movie.calculateMovieFee(this).times(audinceCount);
    }
}
import java.math.BigDecimal;

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


}

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) 이라고 합니다.

Screening, Reservation, Movie 사이의 협력

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

 

메세지와 메소드, 다형성

객체의 내부 상태는 외부에서 접근하지 못하도록 감추고. 대신 퍼블릭 인터페이스를 통해 내부 상태에 접근할 수 있도록 허용합니다.  객체는 다른 객체에게 행동을 수행하도록 요청(request) 하고 요청받은 객체는 자율적인 방법으로 요청을 처리하고 응답(response)합니다.

객체는 메시지를 전송(send a msg) 하여 다른 객체와 상호 작용합니다. 요청이 도착하면 메시지를 수신(receive a msg) 했다고 합니다. 수신한 객체는 자율적인 방법으로 메시지 처리 방법을 결정하는데 이러한 자신만의 방법을 메소드(method)라고 합니다

 

메시지와 메소드는 같아 보이지만 사실 이 둘을 구분하는 것이 중요합니다. 이 메시지와 메소드를 구분하는 것에서 다형성(polymorphism) 개념이 나왔습니다.

 

앞에서 ScreeningMoviecalculateMovieFee '메서드를 호출'하는 것이 아니라 사실은 '메시지를 전송'하는 것이 더 적절한 표현입니다. Screening 은 Movie 안에 calculateMovieFee 메소드가 존재하는지 조차 모릅니다. 단지 calculateMovieFee 메시지에 응답할 수 있다고 믿으면서 메시지를 전송할 뿐입니다.

메시지를 수신한 Movie 는 스스로 적절한 메소드를 선택합니다. 정적 타입 언어인 자바는 불가능하지만 루비(Ruby) 나 스몰토크(Smalltalk) 같은 동적 타입 언어에서는 calculateMovieFee 가 아닌 다른 시그니처를 가진 메소드를 통해서도 해당 메시지에 응답할 수 있습니다. 즉, 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));
    }

}

현재 이 코드에는 할인 정책을 판단하는 코드는 존재하지 않습니다. 단지 discountPolicy 에게 메시지를 전송할 뿐입니다.

 

이 코드에는 객체지향에서 중요한 개념인 상속(inheritance) 와 다형성 개념이 숨어 있습니다. 그 기반에는 추상화(abstraction) 이라는 원리가 숨겨져 있습니다. 자바나 스프링 부트에 익숙하다면 무슨 이야기를 할지 벌써 감이 올 것입니다. 

 

할인 정책, 할인 조건

두가지 할인 정책은 AmountDiscountPolicy 와 PercentDiscountPolicy 라는 클래스로 구현될 것입니다. 두 클래스는 대부분의 코드가 유사하고 할인 요금 계산 방식만 다릅니다. 따라서 중복 코드를 제거하기 위해 공통 코드를 보관할 장소가 필요합니다.

 

부모 클래스인 DIscountPolicy 안에 중복 코드를 두고 AmountDiscountPolicy 와 PercentDiscountPolicy 가 이 클래스를 상속받습니다. DiscountPolicy 는 추상 클래스(abstract) 클래스로 구현합니다.

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

DiscountPolicy DiscountCondition 의 리스트인 conditions 을 인스턴스 변수로 가지기 때문에 할인 정책은 여러 개의 할인 조건을 포함할 수 있습니다. 할인 조건을 만족하는 DiscountCondition 이 하나 이상 있다면 추상 메소드(abstract method) 인 getDiscountAmount 메소드를 호출하여 할인 요금을 계산합니다.

할인 여부와 요금 계산의 전체적인 흐름을 정의하고 있지만 실제로 요금을 계산하는 부분은 getDiscountAmount 메소드에게 위임하고 있습니다. 실제로는 DiscountPolicy 을 상속받은 자식 클래스에서 오버라이딩한 메소드가 실행될 것입니다.

 

이렇게 부모 클래스에서 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴 이라고 합니다.

 

DiscountCondition 은 자바의 인터페이스로 선언됩니다.

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

 

두가지 할인 조건은 SequenceCondition 과 PeriodCondition 이라는 클래스로 구현됩니다.

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

 

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

PeriodCondition 의 경우 할인 요일이 영화 상영 요일과 같고 할인 시작 시간과 할인 종료 시간 안에 영화 상영 시작 시간과 종료 시간이 있어야 할인 조건이 만족됩니다.

 

AmountDiscountPolicy, PercentDiscountPolicy 는 DiscountPolicy 의 자식 클래스로서 금액 할인 정책을 구현합니다.

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

생성자에서 super 예약어로 부모 클래스 DiscountPolicy 의 생성자를 호출하고, 각각 discountAmount, percent 을 초기화해주고 있네요.

 

영화 가격 계산에 참여하는 모든 클래스 사이의 관계 다이어그램입니다.

할인 정책과 할인 조건 클래스 다이어그램

 

오버라이딩(overriding):  부모 클래스에 정의된 같은 이름, 같은 파라미터 목록을 가진 메소드를 자식 클래스에서 재정의.
오버로딩(overloading):
이름은 같지만 파라미터의 목록이 다른 여러 메소드를 만드는 것.

 

할인 정책 구성하기

할인 조건과 달리 할인 정책은 단 하나만 적용할 수 있습니다. MovieDiscountPolicy 의 생성자가 이런 제약을 강제하고 있습니다.

위의 Movie 클래스와 DiscountPolicy 클래스를 다시 보면 오직 하나의 DiscountPolicy 의 인스턴스만 받을 수 있도록 선언되어 있습니다. 반면에 DiscountPolicy 의 생성자는 여러 DiscountCondition 인스턴스를 허용합니다.

 

영화 객체를 두 개를 아래처럼 생성할 수 있습니다.

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, LoalTime.of(10,0), LocalTime.of(11, 59),
        new PeriodCondition(DayOfWeek.THURSDAY, LocalTime.of(18, 0), LocalTime.of(20, 59))));
        
Movie titanic = new Movie("타이타닉", Duration.ofMinutes(180), Money.wons(11000),
	new PercentDiscountPolicy(0.1, 
    	new SequenceCondition(2),
        new PeriodCondition(DayOfWeek.TUESDAY, LoalTime.of(14,0), LocalTime.of(16, 59),
        new PeriodCondition(DayOfWeek.THURSDAY, LocalTime.of(10, 0), LocalTime.of(13, 59))));

 

2시간 상영하는 10,000 원짜리 영화 아바타는 할인 정책으로 800원을 할인해주는 금액 할인 정책을 갖습니다. 할인 순번 조건으로 첫번째 상영과 10번째 상영을 갖고 할인 기간 조건으로 월요일 10시 ~ 12시 사이와 목요일 18시 ~ 21 시 를 갖습니다.

3시간 상영하는 11,000 원짜리 영화 타이타닉는 할인 정책으로 10퍼센트를 할인해주는 금액 할인 정책을 갖습니다. 할인 순번 조건으로 두번째 상영을 갖고 할인 기간 조건으로 화요일 14시 ~ 15시 사이와 목요일 10시 ~ 14 시 를 갖습니다.

 

상속과 다형성

Movie 클래스 내부에는 할인 정책이 금액 할인인지, 비율 할인인지를 판단하지 않는다고 했습니다. 어떻게 할인 정책을 선택할까요? 먼저 의존성, 상속, 그리고 다형성의 개념을 살펴봅시다. 그리고 상속과 다형성을 이용해서 특정 조건을 선택 실행하는 방법을 알아봅시다.

 

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

DiscountPolicy  상속 계층

Movie 클래스와 DiscountPolicy 클래스처럼 어떤 클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나 해당 클래스의 객체의 메소드를 호출할 경우 두 클래스 사이에 의존성이 존재한다고 합니다.

 

Movie 의 인스턴스는 실행 시에 AmountDiscountPolicy 나 PercentDiscountPolicy 의 인스턴스에 의존해야 합니다. 하지만 코드 수준에서 Movie 클래스는 오직 추상 클래스 DiscountPolicy 에만 의존하고 있습니다. 

 

우리가 위에서 Movie 아바타, 타이타닉 객체를 생성할 때의 코드를 살펴보면 Movie 의 생성자에서 DiscountPolicy 타입의 객체를 인자로 받았습니다. 할인 정책이 금액 할인이라면 Movie 의 인스턴스를 생성할 때 인자로 AmountDiscountPolicy 의 인스턴스를 전달하면 됩니다. 그러면 실행 시에 Movie 의 인스턴스는 AmountDiscountPolicy 클래스의 인스턴스에 의존하게 될 것 입니다.

실행 시에 Movie 는 AmountDiscountPolicy 에 의존

할인 정책을 비율 할인 정책으로 하고 싶다면 같은 방식으로 하면 됩니다.

 

즉, 코드의 의존성과 실행 시점의 의존성은 서로 다를 수 있습니다. 즉, 클래스 사이 의존성과 객체 사이의 의존성은 다를 수 있습니다. 이로 인해 유연성과 재사용성, 확장 가능성을 가질 수 있습니다. 물론 코드의 의존성과 실행시점의 의존성이 다를수록 코드는 이해하기 어려워집니다. 의존하고 있는 객체의 정확한 타입을 알기 위해서는 의존성을 연결하는 부분을 찾아야 합니다. TradeOff 가 있는 것이지요. 

 

훌륭한 객체지향 설계자로 성장하기 위해서는 항상 유연성과 가독성 사이에서 고민해야 합니다. 

 

차이에 의한 프로그래밍

상속을 이용하면 클래스 사이에 관계를 설정하는 것만으로 기존 클래스가 가지고 있는 모든 속성과 행동을 새로운 클래스에 포함시킬 수 있습니다. 또한 부모 클래스의 구현은 공유하면서도 행동이 다른 자식 클래스를 쉽게 추가할 수 있습니다.

부모 클래스와 다른 부분만을 추가해 새로운 클래스를 쉽고 빠르게 만드는 방법차이에 의한 프로그래밍(programming by differecne) 라고 합니다.

 

상속과 인터페이스

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

인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의한다고 했습니다. 자식 클래스는 자신의 인터페이스에 부모의 인터페이스를 포함하여 부모가 수신할 수 있는 모든 메시지를 수신할 수 있게 됩니다. 그래서 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있습니다.

 

Movie 의 메소드 calculateMovieFee 을 다시 살펴봅시다.

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

Movie 입장에서는 자신과 협력하는 객체가 어떤 클래스의 인스턴스인지가 중요한 것이 아닌, calculateDiscountAmount 메시지를 수신할 수 있다는 사실이 중요합니다. 즉, 협력 객체가 메시지를 이해할 수 만 있다면 그 객체가 어떤 클래스의 인스턴스인지는 상관하지 않는다는 의미입니다. AmountDiscountPolicy , PercentDiscountPolicy 모두 DiscountPolicy 을 대신해서 Movie 와 협력할 수 있습니다.

 

자식 클래스는 상속을 통해 부모 클래스의 인터페이스를 물려받기 때문에 부모 클래스 대신 사용될 수 있습니다. 컴파일러는 코드 상에서 부모 클래스가 나오는 모든 장소에서 자식 클래스를 사용하는 것을 허용합니다.

 

자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅(upcasting) 이라고 합니다. 다이어그램 상에서 아래에 위치한 자식 클래스가 위의 부모 클래스로 자동적으로 타입 캐스팅되는 것처럼 보입니다.

자식 클래스가 부모 클래스의 타입으로 변환되는 업캐스팅

다형성

메시지와 메소드는 다른 개념이라고 했습니다. Movie 는 DiscountPolicy 의 인스턴스에게 calculateDiscountAmount 메시지를 전송합니다. 그렇다면 어떤 메소드가 실행되는 걸까요? Movie 와 협력하는 객체가 AmountDiscountPolicy 의 인스턴스라면 AmountDiscountPolicy 에서 오버라이딩한 메서드 가 실행되고 PercentDiscountPolicy 의 인스턴스라면 PercentDiscountPolicy 에서 오버라이딩한 메소드가 실행됩니다.

 

Movie 는 동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라서 달라집니다. 이를 다형성이라고 합니다. 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력이지요. 

다형성은 객체지향에서 컴파일 타임 의존성과 런타임 의존성이 다를 수 있다는 것을 기반으로 합니다. 위에서는 컴파일 타임 의존성은 Movie 에서 DiscountPolicy 로, 런타임 의존성은 Movie 에서 AmountDiscountPolicy, 혹은 PercentDiscountPolicy 로 향합니다.

 

다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 합니다. 즉, 인터페이스가 동일해야 합니다. AmountDiscountPolicy, PercentDiscountPolicy 는 동일한 인터페이스를 물려받았y 습니다. 이 두 클래스의 인터페이스를 통일하기 위해 사용한 구현 방법이 상속이었던 겁니다.

다형성은 메시지와 메소드를 지연 바인딩(lazy binding) 또는 동적 바인딩(dynamic binding) 합니다. 런타임에 바인딩한다는 것을 의미합니다. 반면에 전통적인 함수 호출처럼 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것을 초기 바인딩(early binding), 정적 바인딩(static binding) 이라고 합니다.

 

상속으로 동일한 인터페이스를 공유하는 클래스들을 하나의 타입 계층으로 묶을 수 있습니다. 하지만 클래스를 상속받는 것으로만 다형성을 구현할 수 있는 것은 아닙니다. 꽤 다양한 방법을 찾아볼 수 있을 겁니다.

 

구현 상속(implementation inheritance): 일명 서브클래싱(subclassing). 순수하게 코드를 재사용하기 위해 상속을 사용하는 것.
인터페이스 상속(interface inheritance): 일명 서브타이핑(subtyping). 다형적인 협력을 위해 부모 클래스와 자식이 인터페이스를 공유할 수 있게 상속을 사용하는 것.
우리는 당연히 인터페이스 상속에 집중해야 합니다. 인터페이스를 재사용할 목적이 아닌 구현을 재사용할 목적으로 상속을 사용하면 변경에 취약한 코드를 낳게 될 수 있습니다.

 

인터페이스, 다형성

앞서 DiscountPolicy 을 추상 클래스로 구현하여 자식 클래스들이 인터페이스와 내부 구현을 함께 상속받록 했습니다. 그러나 순수 인터페이스만 공유하고 싶을 때가 있습니다. C# 과 자바는 interface 라는 , C++ 에서는 추상 기반 클래스(Abstract Base Class, ABC)라는 프로그래밍 요소를를 제공합니다. 

 

할인 조건은 할인 정책과 달리 구현을 공유할 필요가 없어 자바의 인터페이스로 타입 계층을 구현했습니다. DiscountCondition 인터페이스를 실체화하는 SequenceCondition, PreiodCondition 은 동일한 인터페이스를 공유하며 다형적인 협력에 참여할 수 있습니다.

 

추상화와 유연성

추상화

DiscountPolicy는 AmountDiscountPolicy , PercentDiscountPolicy 보다 추상적이고, DiscountConditionSequenceCondition, PeriodConditon  보다 추상적이다. 이 둘은 인터페이스에 초점을 맞춘다.

 

자식 클래스를 생략한 코드 구조를 그림으로 보면서 추상화의 장점을 살펴봅시다.

1. 추상화의 계층만 따로 보면 요구사항의 정책을 높은 수준에서 서술할 수 있습니다.

'영화 요금은 최대 하나의 할인 정책과 다수의 할인 조건을 이용해 계산할 수 있다.' 라는 문장은 '영화 요금은 금액 할인 정책과 두개의 순서 조건, 한개의 기간 조건을 이용해서 계산할 수 있다' 라는 문장을 포함할 수 있습니다. 이것이 중요합니다. 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있습니다. 도메인의 중요한 개념을 설명, 기본적인 애플리케이션의 협력 흐름을 기술합니다.

재사용 가능한 설계의 기본인 디자인 패턴(design pattern), 프레임워크(framework) 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 매커니즘을 활용하고 있습니다.

 

2. 추상화를 이용하면 설계가 더 유연해집니다. 

이 장점은 첫번째 장점으로부터 유추할 수 있습니다. 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가하고 확장할 수 있습니다.

 

유연한 설계

영화 '스타워즈' 는 할인 정책이 없다고 합시다. 이 때는 할인 요금 계산 없이 기본 금액을 그대로 사용하면 됩니다. 이 경우 할인 정책이 없는 경우가 예외 케이스가 되어서 지금까지 일관성 있던 협력 방식이 무너지게 됩니다.

기존에는 할인 금액을 계산하는 책임이 DiscountPolicy 의 자식 클래스에 있었지만 할인 정책이 없을 때, 할인 금액이 0원이 되어야 하므로 이를 결정하는 책임이 Movie 쪽에 있습니다. 이는 협력의 설계 측면에서 좋지 않습니다. 항상 예외 케이스를 최소화하고 일관성을 유지해야 합니다.

 

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

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

이렇게 하면 Movie 의 인스턴스에 NoneDiscountPolicy 의 인스턴스를 연결해서 할인되지 않는 영화를 생성할 수 있습니다.

Movie startWars = new Movie("스타워즈", Duration.ofMinutes(210),
	Money.wons(10000),
    new NoneDiscountPolicy());

중요한 것은 기존의 Movie 와 DiscountPolicy 는 수정하지 않고 NoneDiscountPolicy 라는 새로운 클래스를 추가하는 것만으로 애플리케이션의 기능을 확장했다는 것입니다! 유연하고 확장 가능한 설계이죠!

 

추상화는 설계가 구체적인 상황에 결합되는 것을 막아서 유연성을 지킵니다. 후의 컨텍스트 독립성(context independency) 라는 개념은 프레임워크와 같은 유연한 설계가 필수적인 분야에서 더욱 중요합니다.

 

 

결론은 유연성이 필요한 곳에 추상화를 사용해야 합니다!

 

추상 클래스와 인터페이스 TradeOff

부모 클래스인 DiscountPolicy 의 calculateDiscountAmount 를 보면 할인 조건이 없으면 getDiscountAmount() 메서드를 호출하지 않습니다. 그래서 NoneDiscountPolicy 클래스의 getDiscountAmount() 메소드가 어떤 값을 리턴해도 상관이 없습니다. 즉, 부모 클래스인 DiscountPolicy 와 NoneDiscountPolicy 을 개념적으로 결합합니다. 어차피 getDiscountAmount() 가 호출되지 않으면 DiscountPolicy 가 0원을 반환할 것입니다.

 

이 문제를 해결하려면 DiscountPolicy 를 인터페이스로 바꾸고 NoneDiscountPolicy 가 DiscountPolicy 의 getDiscountAmount() 메소드가 아닌 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);

}

그리고 NoneDiscountPolicy 가 DiscountPolicy 인터페이스를 구현하도록 변경하면 개념적인 혼란, 결합을 제거할 수 있습니다.

public class NonDiscountPolicy implements DiscountPolicy {

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

DiscountPolicy 의 구현체 클래스들도 이에 따라 DefaultDiscountPolicy 을 상속받도록 해주면 됩니다.

 

인터페이스를 이용해서 구현한 DiscountPolicy 계층

 

이상적으로는 인터페이스를 사용하도록 변경한 설계가 더 좋을 것입니다. 하지만 현실적으로는 NoneDiscountPolicy 만을 위해서 인터페이스를 추가하는 것이 과할 수도 있습니다. 왜냐하면 어차피 원래 NoneDiscountPolicy 도 충분히 할인 금액이 0원이라는 사실을 효과적으로 전달하고 있기 때문입니다.

이 책에서는 설명을 단순화하기 위해 원래의 설계로 이어나갑니다.

 

구현과 관련된 모든 것들은 사실 TradeOff 의 대상이 될 수 있습니다. 정답은 없습니다. 중요한 것은 어떤 방법을 선택하든지 의도와 이유가 있어야 한다는 것입니다. 

 

 

코드 재사용

위에서 잠시 구현상속, 인터페이스 상속에 대해 다뤘습니다. 사실 코드 재사용을 위해서는 상속보다는 합성(composition) 이 더 좋은 방법입니다. 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법입니다. Movie 클래스에서 DiscountPolicy 객체의 인스턴스를 인스턴스 변수로 갖고 있습니다.

public class Movie {
    private DiscountPolicy discountPolicy;
    ...
}

 

그런데 아래 그림처럼 Movie 을 직접 상속받아서 AmountDiscountMovie 와 PercentDiscountMovie 라는 두 개의 클래스를 추가하면 합성의 방식과 기능적으로는 완벽히 동일합니다.

상속으로 구현한 할인 정책

그런데 왜 합성이 더 좋은 방법일까요?

 

상속

1. 상속은 캡슐화를 위반한다.

이것이 상속의 가장 큰 문제점입니다. 상속을 이용하려면 부모 클래스의 내부 구조를 잘 알고 있어야 합니다. AmountDiscountMovie 와 PercentDiscountMovie 를 구현하는 개발자는 부모인 Movie 의 calculateMovieFee 메서드 안에서 추상 메서드인 getDiscountAmount 메서드를 호출한다는 사실을 알고 있어야 합니다. 즉, 부모의 구현이 자식에게 노출되어 캡슐화가 약화됩니다.

캡슐화의 약화는 결합도를 늘려서 부모를 변경할 때 자식도 변경될 확률을 높입니다. 이것은 두번째 단점인 유연성이 떨어지는 문제로 직결됩니다.

 

2. 상속은 설계를 유연하지 못하게 한다.

상속은 부모 클래스와 자식 사이의 관계를 컴파일 시점에 결정합니다. 런타임에 객체의 종류를 변경할 수 없습니다.

 

만약 런타임에 금액 할인 정책인 영화를 비율 할인으로 바꾼다고 합시다. AmountDiscountMovie 의 인스턴스를 PercentDiscountMovie 의 인스턴스로 바꾸어야 합니다. 이미 생성된 객체의 클래스를 변경할 수 없기 때문에 최선의 방법은 PercentDiscountMovie 인스턴스를 생성하고 AmountDiscountMovie 의 상태를 복사해야 합니다. 

 

하지만 기존의 인스턴스 변수로 연결한 방법에서는 런타임에 할인 정책을 간단히 바꿀 수 있습니다. 

 

public class Movie {
    ...
    private DiscountPolicy discountPolicy;

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

새롭게 changeDiscountPolicy 메서드를 추가했습니다. 이제 할인 정책의 변경이 새로운 DiscountPolicy 인스턴스를 연결하는 간단한 작업으로 바뀝니다.

 

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

avatar.changeDiscountPolicy(new PercentDiscountPolicy(0.1, ...));

 

상속보다 인스턴스 변수로 관계를 연결한 원래의 설계가 더 유연합니다.

이렇게 Movie 가 DiscountPolicy 을 포함하여 코드를 재사용하는 방법은 매우 효율적인 '합성' 방법입니다.

 

합성

이 방법에서는 상속과 다른 점은 상속은 부모의 코드와 자식의 코드를 컴파일 시점에 하나의 단위로 강하게 결합하는 반면, Movie 가 DiscountPolicy 의 인터페이스를 통해 약하게 결합된다는 것입니다. Movie 는 DiscountPolicy 가 외부에 calculateDiscountAmount 메소드를 제공한다는 사실만 알고 내부 구현에 대해서는 모릅니다. 

 

합성인터페이스에 정의된 메시지를 통해서만 코드를 재사용합니다.

 

합성을 통해 인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화할 수 있습니다. 

인스턴스를 교체하는 것이 쉬워서 설계를 유연하게 만듭니다.

상속은 클래스를 통해 강한 결합을 가지지만 합성은 메시지를 통해 약한 결합을 가집니다. 코드 재사용에는 상속보다는 합성을 쓰는 것이 좋습니다.

물론 다형성을 위해서 인터페이스를 재사용하는 경우에는 상속과 합성을 적절하게 함께 조합해서 사용해야 합니다.

 

프로그래밍 관점에서 클래스와 상속은 매우 중요하지만 이는 객체지향의 도구일 뿐이지 객체지향의 본질은 아닙니다. 객체 지향의 중심은 객체입니다. 기능 구현을 위한 협력에 참여하는 객체들의 상호작용이 중요합니다. 

우리는 적절한 협력을 식별하고 협력에 필요한 역할을 정의한 후, 역할을 수행할 수 있는 적절한 객체에게 적절한 책임을 할당해야 합니다.