본문 바로가기
도서/오브젝트

Chapter 2. 객체지향 프로그래밍

by iskull 2022. 1. 3.
728x90

영화 예매 시스템 예제

  영화 예매 시스템을 사용해 사용자는 영화를 예매할 수 있다. 예제에 들어가기 전 용어에 대해 설명하겠다. 영화는 영화 자체에 대한 제목, 상영시간, 가격 같은 영화 하나가 가지고 있는 기본적인 정보이다. 상영은 실제로 관객들이 관람하는 사건이다. 상영은 상영일자, 시간, 순번등을 가지고 있다. 이 용어의 구분이 중요한 이유는 사용자가 실제로 예매하는 대상은 영화가 아니라 상영이기 때문이다. 사람들은 영화를 예매한다고 표현하지만 실제로는 특정 시간에 상영되는 영화를 관람할 수 있는 권리를 구매하는 거다.

  특정 조건을 만족하는 예매자는 요금을 할인 받을 수 있다. 할인을 받지 않는다면 영화의 기본 요금이 책정된다. 할인은 1명을 기준으로 하기 때문에 n명이 할인 조건에 만족하는 상영을 예매한다면 n*할인금액 만큼 할인받는다.

  할인을 받을 수 있는 조건은 할인 조건(discount condition)과 할인 정책(discount policy)으로 나뉘어져 있다. Discount condition과 discount policy는 다음과 같은 세부 조건으로 나뉘어져 있다.

  Discount condition:

    Discount condition은 다수의 할인 조건을 중복 선택할 수 있고 sequence condition과 period condition을 함께 섞는 것도 가능하다.

    1. 순서 조건(sequence condition): 상영 순번을 이용한 할인이다. n번째로 상영하는 영화가 할인된다.

    2. 기간 조건(period condition): 영화 상영 시간을 이용한 할인이다. period condition은 3가지 세부 조건으로 나뉜다.

      2-1: 3가지 세부 조건은 상영 시작 시간, 종료 시간, 상영 요일이다. x요일에 상영하는 시작시간이 y시, 종료가 z시인 영                화가 할인된다.

  Discount policy:

    Discount policy는 하나의 정책만 할당하거나 아예 할당하지 않을 수 도 있다.

    1. 금액 할인 정책(amount discount policy): 예매 요금에서 일정 금액을 할인해 준다.

    2. 비율 할인 정책(percent discount policy): 정가에서 일정 비율의 요금을 할인해 주는 방식

  할인을 하기 위해서는 다음과 같은 로직을 사용한다.

    1. 예매 정보가 discount condition 중 하나라도 만족하는 지 검사한다.

    2. Discount condition에 만족하느 것이 있다면 discount policy를 이용해 할인 요금을 계산한다. 

    3. Discount policy는 적용돼 있지만 Discount condition을 만족하지 못하면 할인을 적용하지 않는다.

    4. Discount condition을 만족하지 않는 경우에도 할인을 적용하지 않는다.

 

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

  사람들은 객체지향 패러다임을 사용해 설계를 할때 우선 어떤 클래스가 필요할지를 결정하고 클래스에 어떤 속성과 메서드가 필요한지를 고민한다.

  하지만 이는 객체지향의 본질과는 거리가 멀다. 객체지향은 말 그대로 객체를 지향하는 것이다. 진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞출 때에만 얻을 수 있다. 따라서 다음 두 가지에 초점을 맞추어 프로그래밍을 해야 한다.

  1. 어떤 클래스를 만들지를 고민하기 전에 어떤 객체들이 필요한지 고민하라.

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

  2. 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다.

    객체는 홀로 존재하지 않는다. 다른 객체에게 도움을 주거나 의존하면서 살아가는 협력적인 존재다. 객체를 협력하는 공동체의 일원으로    바라보는 것은 설계를 유연하고 확장 가능하게 만든다. 따라서 객체를 고립된 존재가 아닌 협력에 참여하는 협력자로 바라봐야 한다. 객체    들의 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현하라. 훌율한 협력이 훌륭한 객체를 낳고 훌륭한 객체가 훌륭한 클래스를 낳는다.

 

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

  소프트웨어를 만드는 목적은 사용자가 어떤 문제를 해결하는 것을 이루어 주는 것이다. 영화 예매 시스템은 사용자가 영화 예매를 좀 더 쉽고 빠르게 수행할 수 있도록 해결한다. 이처럼 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야를 도메인(domain)이라 한다.

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

영화 예매 도메인을 구성하는 타입들의 구조

    객체지향 패러다임에서 도메인 개념들을 구현하기 위해 클래스를 사용한다. 일반적으로 클래스의 이름은 대응되는 도메인 개념의 이름과 동일하거나 유사하다. 클래스 사이의 관계도 최대한 도메인 개념 사이에 맺어진 관계와 유사하게 만들어서 프로그램 구조를 이해하고 예상하기 쉽게 만들어야 한다. 따라서 위의 '영화 예매 도메인을 구성하는 타입들의 구조'를 다음과 같은 클래스 구조로 바꿀 수 있다.  

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

클래스 구현하기

  이제 위에서 설계한 '도메인 개념의 구조를 따르는 클래스 구조'를 사용해 실제 클래스를 구현해 보자.

  Screening클래스의 구현은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/** 사용자들이 예매하는 대상인 '상영'을 구현한다 */
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 Movie getMovie() {
        return movie;
    }
 
    public boolean isSequence(int sequence) {
        return this.sequence == sequence;
    }
 
    public LocalDateTime getWhenScreened() {
        return whenScreened;
    }
}
 
cs

  이 클래스에서 변수의 가시성은 private이고 메서드의 가시성은 public이라는 것에 주목하자. 클래스의 경계를 구분짓는 것은 클래스를 구현하거나 다른 개발자가 클래스를 사용할 때 아주 중요하다. 클래스는 내부와 외부로 구분되고 훌륭한 클래스의 설계는 외부에 공개할 부분과 내부에 감출 부분이 잘 설계되 있다. 

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

  자율적인 객체

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

    객체지향은 객체라는 단위 안에 데이터와 기능을 한 덩이로 묶어서 문제 영역의 아이디어를 적절하게 표현할 수 있게 했다. 이처럼 데이터  와 기능을 객체 내부에 함께 묶는 것을 캡슐화라 한다.

    대부분의 객체지향 언어는 상태와 행동을 캡슐화하는 것에서 더 나아가 외부에서의 접근을 통제할 수 있는 접근 제거(access control)매커니즘도 함께 제공한다. 많은 프로그래민 언어들은 접근 제어를 위해 public과 같은 접근 수정자(access modifier)를 제공한다.

    객체가 내부 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위해서이다. 객체지향의 핵심은 객체 스스로가 자신의 상태를 관리하고, 판단하고, 행동하는 자율적인 객체들의 공동체를 구성하는 것이다. 이를 위해서는 외부의 간섭을 최소화 해야 한다. 따라서 외부에서는 객체가 어떤 상태인지, 어떤 생각을 하는지, 결정에 직접적인 간섭을 해서는 안된다. 객체에게 요청을 하고 객체 스스로가 최선의 결정을  한다는 것을 믿는 것이 최선이다.

    캡슐화와 접근제어는 객체를 퍼블릭 인터페이스(public interface)와 구현(implementation) 두 부분으로 나눈다. Public interface는 외부에서 접근이 가능한 부분이고 implementation은 내부에서만 접근이 가능한 부분이다. 인터페이스와 구현의 분리(seperation of  interface and implementation) 원칙은 훌륭한 프로그램을 만들기 위해 따라야 한다.

    접근 수정자를 제공하는 객체지향 언어를 사용한다면 클래스의 속성은 private, 메서드는 public으로 선언해 속성은 감추고, 일부 메서드는 공개해야 한다. 만약 어떤 메서드가 서브 클래스에서만 사용된다면 가시성을 protected나 private으로 지정해야 한다. 이때 public interface에는 public으로 지정된 메서드만 포함된다. private method, protected method, 속성은 구현에 포함된다.

  프로그래머의 자유

    프로그래머의 역할을 다음과 같이 두 개로 나누는 것이 유용하다.

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

        클라이언트 프로그래머는 필요한 클래스들을 엮어서 애플리케이션을 빠르고 안정적으로 구축해야 한다.

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

         클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만 공개하고 나머지는 숨겨야 한다. 클라이언트 프로그래머        가 숨겨 놓은 부분에 마음대로 접근 할 수 없게 해서 클라이언트 프로그래머에 대한 영향을 걱정하지 않고 내부 로직을          변경할 수 있어야 한다. 이를 구현 은닉(implmentation hiding)이라 한다.

    구현은닉은 클라이언트 프로그래머가 내부 구현을 무시하고 인터페이스만 알고 있어도 클래스를 사용할 수 있기 때문에 클라이언트 프로그래머가 알아야 하는 코드의 양이 감소한다. 또 한 클래스 작성자가 구현을 변경할 수 있는 폭이 넓어진다.

    객체지향 언어는 객체 사이의 의존성을 적절히 관리해 변경에 대해 파급효과를 제어할 수 있는 다양한 방법을 제공한다. 이 방법들 중 가장 대표적인 것이 접근 제어이다.

 

협력하는 객체들의 공동체

  이제 진짜 구현을 해보자. Screening 객체는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import java.time.LocalDateTime;
 
/** 사용자들이 예매하는 대상인 '상영'을 구현한다 */
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;
    }
 
    private Money calculateFee(int audienceCount) {
        /* Movie 객체의 calculateMovieFee()는 한명에 대한 비용만 계산하기 때문에 인원수를 곱해야 한다 */
        return movie.calculateMovieFee(this).times(audienceCount);
    }
 
    /**
     * 영화를 예매 후 예매 정보를 담고 있는 Reservation의 인스턴스를 생성해 반환한다
     * @param customer 예매자에 대한 정보
     * @param audienceCount 인원수
     */
    public Reservation reserve(Customer customer, int audienceCount) {
        return new Reservation(custormer, this, calculateFee(audienceCount),
            audienceCount);
    }
 
    public Movie getMovie() {
        return movie;
    }
 
    public boolean isSequence(int sequence) {
        return this.sequence == sequence;
    }
 
    public LocalDateTime getWhenScreened() {
        return whenScreened;
    }
}
 
cs

  Money 클래스는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/** 금액과 관련된 다양한 계산을 한다 */
public class Money {
    public static final Money ZERO = Money.won(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;
    }
}
 
cs

    primitive type이나 primitive type의 wrapper class대신 Money객체 처럼 하나의 특정 값을 담당하는 클래스를 만들어 사용하면 해당 요소가 어떤 의미를 가지는지 좀 더 명확해 진다. 또 한 금액과 관련된 로직이 서로 다른 곳에 중복외 구현되는 것을 막을 수 있다. 이처럼 객체지향은 객체를 사용해 도메인의 의미를 더 풍부하게 할 수 있다. 의미를 더 명시적으로 표현할 수 있다면 인스턴스 변수만 포함하더라도 개념을 명시적으로 표현하는 것이 전체적인 설계의 명확성과 유연성을 높인다.

  Reservation 클래스는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Reservation {
    private Custormer custormer;
    private Screening screening;
    private Money fee;
    private int audienceCount;
 
    public Reservation(Customer customer, Screening screening, Money fee
            , int audienceCount) {
        this.custormer = customer;
        this.screening = screening;
        this.fee = fee;
        this.audienceCount = audienceCount;
    }
}
 
cs

  Reservation클래스는 영화를 예매하기 위해 여러 인스턴스들이 서로의 메서드를 호출하며 상호작용한다. 이처럼 어떤 기능 구현을 위해 객체들이 상호작용 하는 것을 협력(collaboration)이라 한다. 

Reservation내의 인스턴스들의 협력

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

 

객체들 간의 협력

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

  객체가 다른 객체와 상호작용할 수 있는 유일한 수단은 메시지를 전송(send a message)하는 거다. 다른 객체에게 요청이 도착할 때 해당 객체가 메시지를 수진(receive a message)했다고 한다. 메시지를 수신한 객체는 스스로 결정에 따라 자율적으로 메시지를 처리할 방법을 결정한다. 이처럼 수신된 메시지를 처리하기 위한 자신만의 방법을 메서드(method)라 한다.

  메시지와 메서드를 구분하는 것은 객체지향 패러다임에서 아주 중요하다. 이 둘을 구분하는 것에서 부터 다형성(polymorphism)의 개념이 출발한다.

1
2
3
4
5
6
7
8
9
10
11
public class Screening {
    /* ... */
 
    private Money calculateFee(int audienceCount) {
        /* Movie 객체의 calculateMovieFee()는 한명에 대한 비용만 계산하기 때문에 인원수를 곱해야 한다 */
        return movie.calculateMovieFee(this).times(audienceCount);
    }
 
    /* ... */
}
 
cs

    Screening의 calculateFee에서 내부 로직을 Screening이 Movie의 calculateMovieFee() 메서드를 호출한다 라고 할 수 있다. 하지만 Screening이 Movie에게 calculateMovieFee 메시지를 전송한다 라고 말하는 것이 더 적절하다. Screening은 Movie 안에 calculateMovieFee메서드가 존재하고 있는지도 모르고 단시 Movie가 calculateMovieFee 메시지에 응답할 수 있다고 믿고 있을 뿐이다.

 

할인 요금 구하기

  Movie 클래스는 다음과 같은 구조를 하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/** 영화 정보를 담는다 */
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));
    }
}
 
cs

  이 클래스에서 calculateMoiveFee 메서드를 보면 discount policy가 두 가지 종류가 있음에도 그를 구분하지 않고 calculateDiscountAmount 메서드를 호출하고 있다. 이는 추상화(abstraction)를 기반으로 상속(inheritance)과 다형성을 사용했기 때문이다.

  discount policy는 amount discount policy와 percent discount policy 두 가지로 나뉘어져 있다. 또 한 이들은 할인을 하는 방식만 조금 다르기 때문에 부모 클래스인 DiscountPolicy 안에 중복된 코드를 두고 AmountDiscountPolicy와 PercentDiscountPolicy가 이 클래스를 상속받게 할 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* discount policy 구현을 위한 추상 클래스 */
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);
}
 
cs

  DiscountPolicy는 할인 여부와 요금 계산에 필요한 전체적인 흐름을 정의하지만 실제 요금을 계산하는 부분은 추상 메서드인 getDiscountAmount메서드에게 위임한다. 따라서 실제로는 DiscountPolicy를 상속받은 자식 클래스에서 오버라이딩한 메서드가 실행된다. 이처럼 부모 클래스에서 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 Teamplate Method Pattern이라 한다.

  DiscountCondition은 sequence condition과 period condition 두 가지로 나뉘어져 있고 다음과 같이 DiscountCondition interface를 implements해서 구현되었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public interface DiscountCondition {
    boolean isSatisfiedBy(Screening screening);
}
 
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;
    }
}
 
cs

  이제 discount policy를 구현하면 된다. AmountDiscountPolicy와 PercentDiscountPolicy는 DicountPolicy를 상속해 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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);
    }
}
 
 
cs

  위 예제 설명에서 있었지만 하나의 영화에 대해 단 하나의 할인 정책만 설정할 수 있지만 할인 조건의 경우에는 여러개를 적용할 수 있다. Movie와 DiscountPolicy의 생성자는 이런 제약을 강제한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public abstract class DiscountPolicy {
    private List<DiscountCondition> conditions = new ArrayList<>();
 
    public DiscountPolicy(DiscountCondition ... conditions) {
        this.conditions = Arrays.asList(conditions);
    }
 
    /** ... */
}
 
import java.time.Duration;
 
/** 영화 정보를 담는다 */
public class Movie {
 
    /** ... */
    private DiscountPolicy discountPolicy;
 
    public Movie(String title, Duration runningTime, Money fee,
            DiscountPolicy discountPolicy) {
        /** ... */
        this.discountPolicy = discountPolicy;
    }
 
    /** ... */
}
 
cs

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

 

상속

  위의 Movie 클래스를 보면 discount policy의 종류를 판단하는 코드가 없다. 이는 상속과 다형성 때문이다.

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

     Movie 클래스와 DiscountPolicy의 의존 관계는 다음과 같다.

      Movie클래스가 영화 요금을 계산하기 위해서는 DiscountPolicy의 서브 클래스의 인스턴스가 필요하지만                       DiscountPolicy 클래스와 연결되 있다. 따라서 코드 수준에서는 Movie 클래스가 DiscountPolicy의 서브 클래스 중 어       느  것에도 의존하고 있지 않다.

     이처럼 코드 작성 시점에서 Movie 인스턴스가 존재 조차 알지 못했던 인스턴스들을 실행 시점에 협력할 수 있는 이유는     Movie의 인스턴스를 생성할 때 인자로 해당 인스턴스를 넘겨 주었기 때문이다.

1
2
3
4
Movie avatar = new Movie("avatar",
    Duration.ofMinutes(120),
    Money.wons(10000),
    new AmountDiscountPolicy(Money.wons(800), ...));
cs

      이런 방식을 취하면 코드만 봤을때는 불가능해 보였던 인스턴스의 사용이 실행 시점에는 사용 가능해 진다.

      코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다. 이는 유연하고, 쉽게 재사용할 수 있으며, 확장 가능한 객체        지향 설계가 가지는 특징이다. 하지만 시점에 따라 의존성이 다른것은 코드를 이해하기 어렵게 만든다. 코드를 이해하기        위해서는 코드뿐만 아니라 객체를 생성하고 연결하는 부분을 찾아야 하기 때문이다. 이런 의존성의 양면성은 설계가 트레      이드오프의 산물이라는 증거다.

       설계가 유연해 지면 코드를 이해하고 디버깅하기는 더 어려워진다. 반대로 유연성을 억제하면 코드를 이해하고 디버깅      이 쉬워지지만 재사용성과 확장 가능성은 낮아진다.

  차이에 의한 프로그래밍

    클래스를 추가하는데 그 클래스가 기존 클래스와 유사하다면 그 클래스를 약간만 수정해 새로운 클래스를 만들면 된다.      이  를 가능하게 하는 것이 상속이다.

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

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

  상속과 인터페이스

    상속이 가치있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문이다.

    인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의한다. 상속을 통해 자식 클래스는 자신의 인터페이스에 부모      클  래스의 인터페이스를 포함하게 된다. 따라서 자식 클래스는 부모 클래스가 수신할 수 있느 모든 메시지를 수신할 수 있    기 떄문에 외부 객체는 자식 크래스를 부모 클래스와 동일한 타입으로 간주한다.

    자식 클래스는 상속을 통해 부모 클래스의 인터페이스를 물려받기 때문에 부모 클래스 대신 사용될 수 있다. 컴파일러는      코드 상에서 부모 크래스가 나오는 모든 장소에서 자식 클래스를 사용하는 것을 허용한다. 이처럼 자식 클래스가 부모 클래    스를 대신하는 것을 업캐스팅(upcasting)이라 한다.

    

다형성

1
2
3
4
5
6
7
8
9
/** 영화 정보를 담는다 */
public class Movie {
 
    /** ... */
 
    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}
cs

  위 구조를 통해 메시지와 메서드의 차이를 보자. Movie는 DiscountPolicy 인스턴스에게 calculateDiscountAmount메시지를 보낸다. 실행되는 메서드는 실제로 Movie와 협력하는 객체가 무엇이냐에 따라 달라진다. 위 예제에서 실제로 협력하는 객체가 AmountDiscountPolicy의 객체리면 해당 클래스에서 구현된 DIscountPolicy 객체로부터 오버라이딩한 메서드가 실행된다. 따라서 Movie는 동일한 메시지르 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스에 따라 달라진다. 이를 다형성이라 한다.

  다형성은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다. 위의 Movie객체와 DiscountPolicy를 예로 들자면 컴파일 시간 내에서의 Movie객체는 DiscountPolicy에 대한 의존성을 갖는다. 하지만 실행 시간에서는 AmountDiscountPolicy 또는 PercentDiscountPolicy에 대한 의존성을 갖는다.

  다형성은 동일한 메시지를 수신할 때 객체의 타입에 따라 다르게 응답하는 능력이다. 따라서 다형성을 통해 협력하는 객체들은 모두 같은 인터페이스를 가져야 한다. 그리고 이런 같은 인터페스를 구하기 위한 방법이 상속이다. 상속을 사용하면 동일 인터페이스를 공유하는 클래스들은 하나의 타입 계층으로 묶을 수 있다. 따라서 대부분의 사람들은 다형성을 언급할 때 상속을 같이 언급한다. 하지만 다형성은 상속 뿐만 아니라 다른 여러 방식으로도 구현할 수 있다.

  다형성은 실행될 메서드를 컴파일 시점이 아닌 실행 시점에 바인딩 한다. 이를 지연 바인딩(lazy binding) 또는 동적 바인딩(dynamic binding)이라 한다. 반대로 전통적인 함수 호출 처럼 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것을 초기 바인딩(early binding) 또는 정적 바인딩(static binding)이라 한다.

 

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

  상속은 크게 다음과 같은 두 종류로 나뉘어져 있다.

  1. 구현 상속(implementation inheritance, subclassing): 순수하게 코드 재사용을 위해 사용하는 상속.

 2.  인터페이스 상속(interface inheritance, subtyping): 다형적인 협력을 위해 부모 클래스와 자식 클래스가 인터페이.          스.  를 공유할 수 있게 상속을 이용하는 것.

  

인터페이스와 다형성

  구현의 공유 없이 순수하게 인터페이스만 공유하기 위해서는 자바의 인터페이스를 사용하면 된다. 자바의 인터페이스는 구현에 대한 고려 없이 다형적인 협력에 참여하는 클래스들이 공유 가능한 외부 인터페이스를 정의한 것이다.

 

추상화와 유연성

  위의 영화 예메 애플리케이션에서 할인 파트를 보면 DiscountPolicy와 DiscountCondition은 서브 클래스를 가진다. 따라서 프로그래밍 언어 측면에서 DiscountPolicy와 DiscountCondition은 이들의 서브 클래스들에 비해 더 추상적이다. 이는 이 둘이 인터페이스에 초점을 맞췄기 때문이다. DiscountPolicyu는 calculateDiscountAmount 메시지를, DiscountCondition은 모든 할인 조건들이 수신할 수 있는 isSatisfiedBy 메시지를 정의한다. 둘 다 같은 계층에 속하는 클래스들이 공통으로 가질 수 있는 인터페이스를 정의하며 구현의 일부 또는 전체를 자식 클래스가 결정하게 결정권을 위임한다.

  추상화를 이용하면 두 가지 장점이 있다.

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

    위의 class diagram을 말로 표현해 보면 "영화 예매 요금은 최대 하나의 '할인 정책'과 다수의 '할인 조건'을 이용해 계산      할  수 있다"가 된다. 이는 "영화 예매 요금은 '금액 할인 정책'과 '두개의 순서 조건, 한 개의 기간 조건'을 이용해 계산할 수      있다"를 포괄한다.

    추상화를 이용하면 세부적인 내용을 무시한 채로 상위 정책을 쉽고 간단히 표현할 수 있다. 그때문에 세부 사항에 대한 고    려 없이 상위 개념만을 사용해 도메인의 중요한 개념을 설명할 수 있다.

    추상화를 이용해 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 협력 흐름을 기술한다는 것이다. 재사용 가능한    설계의 기본을 이류는 디자인 패턴과 프레임워크는 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 매커니즘을 활    용하고 있다.

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

    추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가하고 확장할 수 있다.

 

유연한 설계

  위 코드에서는 할인이 적용되지 않을 경우에 대한 로직이 없다. 따라서 다음과 같이 Movie 클래스의 calculateMovieFee메서드를 수정해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
/** 영화 정보를 담는다 */
public class Movie {
    /** ... */
 
    public Money calculateMovieFee(Screening screening) {
        if(discountPolicy == null) {
            return fee;
        }
 
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}
 
cs

    위 코드의 문제는 일관성 있던 협력 방식을 무너지게 했다는 것이다. 이전까지는 할인을 discountPolicy에서 담당했다. 하지만 이 코드를 보면 할인 금액이 0원인지를 Movie클래스에서 판단한다. 따라서 대부분의 경우 책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 좋은 판단은 아니다. 항상 예외 케이스를 최소화 하고 일관성을 유지할 수 있는 방법을 선택하라.

  이 경우 일관성을 유지하기 위해 0원 이라는 할인 요금을 계산할 책임을 DiscountPolicy 계층에 유지하면 된다. 이를 위해 NoneDiscountPolicy 클래스를 추가하자.

1
2
3
4
5
6
7
public class NoneDiscountPolicy extends DiscountPolicy{
    @Override
    protected Money getDiscountAmount(Screening screening) {
        return Money.ZEOR;
    }
}
 
cs

  이러면 Movie의 인스턴스를 생성할 때 생성자 인자로 NoneDiscountPolicy인스턴스를 넘기면 된다. 

  이 예시는 추상화를 중심으로 하는 코드 구조 설계가 유연하고 확장 가능하다는 것을 보여준다. 추상화가 이를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는 것을 방지하기 때문이다. 이는 컨텍스트 독립성(context independency)이라 불리는 프레임워크와 같은 유연한 설계가 필수적인 분야에서 그 진가를 발휘한다.

 

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

  DiscountPolicy의 calculateDiscountAmount 메서드를 보면 NoneDiscountPolicy의 getDiscountAmount 메서드가 어떤 값을 반환해도 상관 없다는 것을 알 수 있다. 이는 calculateDiscountAmount 메서드에서 할인 조건이 없을 경우 getDiscountAmount 메서드를 호출하지 않기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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);
}
 
cs

  이는 NoneDiscountPolicy의 개발자는 getDiscountAmount()가 호출되지 않을 경우 DiscountPolicy가 0원을 반환할 것이라는 사실을 가정하고 있다는 의미이다. 따라서 부모 클래스인 DisoucntPolicy와 NoneDiscountPolicy를 개념적으로 결합시킨다.

  이를 해결하기 위해서 DiscountPolicy를 인터페이스로 바꾸고 NoneDiscountPolicy가 DiscountPolicy의 getDiscountAmount 메서드가 아닌 calcualteDiscountAmount 오퍼레이션을 오버라이딩 하게 변경하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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);
    }
 
    @Override
    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 NoneDefaultDiscountPolicy implements DiscountPolicy {
    @Override
    public Money calculateDiscountAmount(Screening screening) {
        return Money.ZERO;
    }
}
 
cs

 

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

  이상적으로는 위와 같이 인터페이스를 사용하는 것이 좋다. 하지만 현실적으로 NoneDiscountPolicy 만을 위해 인터페이스를 추가하는 것이 과하다고 생각할 수 있다. 따라서 구현과 관련된 모든 것들은 트레이드오프의 대상이 될 수 있다. 작성한 코드에는 합당한 이유가 있어야 한다. 비록 사소한 결정이라도 트레이드오프를 통해 얻어진 결론과 그렇지 않은 결론은 차이가 크다.

 

코드 재사용

  상속은 코드를 재사용하기 위해 사용되는 하나의 방식이다. 다른 방식으로는 객체의 인스턴스를 자신의 인스턴스 변수로 포함해 재사용하는 함성(composition)이 있다.

  Movie 클래스가 DiscountPolicy를 사용한 방식이 합성이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Movie {
    /** ... */
    private DefaultDiscountPolicy defaultDiscountPolicy;
 
    public Movie(String title, Duration runningTime, Money fee,
            DefaultDiscountPolicy defaultDiscountPolicy) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.defaultDiscountPolicy = defaultDiscountPolicy;
    }
 
    public Money getFee() {
        return fee;
    }
 
    public Money calculateMovieFee(Screening screening) {
        return fee.minus(defaultDiscountPolicy.calculateDiscountAmount(screening));
    }
}
 
cs

  이 방식은 아래와 같이 Movie를 직접 상속받는 것과 기능적 관점에서 동일하다.

  그럼에도 불구하고 많은 개발자들이 상속 대신 합성을 선호하는 이유는 뭘까?

 

상속의 단점

  상속은 두 가지 관점에서 설계에 안좋은 영향을 미친다.

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

    상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알아야 한다. 즉, 부모 클래스의 구현이 자식 클래스에게 노출되    기 때문에 자식 클래스와 부모 클래스가 강하게 결합되게 만든다. 결과적으로 과도한 상속을 사용한 코드는 코드를 변경하    기 어렵게 한다.

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

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

    실행 시점에 AmountDiscountMovie의 인스턴스를 PercentDiscountMovie의 인스턴스로 변경해야 한다고 해보자.      대  부분의 언어는 이미 생성된 객체의 클래스를 변경하는 기능을 지원하지 않기 때문에 이 문제를 해결할 수 있는 최선의      방법은 PercentDiscountMovie의 인스턴스를 생성한 후 AmountDiscountMovie의 상태를 복사하는 것뿐이다.

    그에 반해 합성을 사용하면 다음과 같이 간단하게 바꿀 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
public class Movie {
 
    /** ... */
    private DiscountPolicy discountPolicy;
 
    /** ... */    
 
    public void changeDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}
 
cs

    따라서 상속 보다는 합성이 원래의 설계를 더 유연하게 한다는 사실을 알 수 있다.

 

합성(composition)

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

  상속이 부모 클래스의 코드와 자식 클래스의 코드를 컴파일 시점에 하나의 단위로 강하게 결합하는 데 비해 합성은 인터페이스를 통해 약하게 결합한다.

  합성은 다음과 같은 장점을 가진다.

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

  2. 의존하는 인스턴스를 교체하는 것이 비교적 쉬워서 설계를 유연하게 만든다.

  그럼에도 상속과 합성을 둘 다 사용하는 것이 바람직하다. 코드 재사용을 위해서라면 합성을 사용하고 다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용해야한다. 

 

출처 - 오브젝트

'도서 > 오브젝트' 카테고리의 다른 글

Chapter 6. 메시지와 인터페이스  (0) 2022.01.22
Chapter 5. 책임 할당하기  (0) 2022.01.17
Chapter 4. 설계 품질과 트레이드오프  (0) 2022.01.10
Chapter 3. 역할, 책임, 협력  (0) 2022.01.06
Chapter 1. 객체, 설계  (0) 2022.01.01