devseop08 님의 블로그

[오브젝트] 책임 할당하기 본문

Architecture/객체지향설계

[오브젝트] 책임 할당하기

devseop08 2025. 6. 27. 22:33
  • 책임에 초점을 맞춰서 설계할 때 직면하는 가장 큰 어려움은 어떤 객체에게 어떤 책임을 할당할지를 결정하기가 쉽지 않다는 것이다.
  • GRASP 패턴은 책임 할당의 어려움을 해결하기 위한 답을 제시해 줄 것이다.
  • 객체에 책임을 할당하는 기본적인 원리를 살펴보자

1. 책임 주도 설계를 향해

데이터보다 행동을 먼저 결정하라

  • 너무 이른 시기에 데이터에 초점을 맞추면 캡슐화가 약화되어 낮은 응집도와 높은 결합도를 가진 객체들로 넘쳐나 변경에 취약한 설계를 얻게 된다.
  • 객체의 데이터에서 행동으로 무게 중심을 옮기기 위한 기법이 필요하다.
  • 객체를 설계하기 위한 질문의 순서를 바꿔야 한다.
  • "이 객체가 수행해야 하는 책임은 무엇인가"를 결정한 후에 "이 책임을 수행하는 데 필요한 데이터는 무엇인가"를 결정
  • 책임을 먼저 결정한 후 객체의 상태를 결정
  • 협력이라는 문맥 안에서 책임을 결정하라
  • 객체에게 어떤 책임을 할당해야 하는가?에 대한 해결의 실마리는 협력에서 찾을 수 있다.
  • 객체에게 할당된 책임의 품질은 협력에 적합한 정도로 결정된다.
  • 책임은 객체의 입장이 아니라 객체가 참여하는 협력에 적합해야 한다.
  • 협력에 적합한 책임이란 메시지 수신자가 아니라 메시지 전송자에게 적합한 책임을 의미한다.
  • 메시지를 전송하는 클라이언트의 의도에 적합한 책임을 할당해야 한다.
  • 메시지를 결정한 후, 메시지가 객체를 선택하게 해야 한다.
  • 클라이언트의 의도에 맞게 메시지를 결정하고 해당 메시지를 수신하기로 결정된 객체는 메시지를 처리할 '책임'을 할당받게 된다.

책임 주도 설계

  • 책임 주도 설계의 핵심은 책임을 결정한 후에 책임을 수행할 객체를 결정하는 것이다.
  • 협력에 참여하는 객체들의 책임이 어느정도 정리될 때까지는 객체의 내부 상태에 관심을 가지지 않는다.

2. 책임 할당을 위한 GRASP 패턴

  • "General Responsibility Assignment Sotfware Pattern"(일반적인 책임 할당을 위한 소프트웨어 패턴)
  • 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합을 패턴 형식으로 정리한 것
  • 설계 과정은 도메인 안에 존재하는 개념들을 정리하는 것으로 시작

도메인 개념에서 출발하기

  • 설계를 시작하기 전에 도메인에 대한 개략적인 모습을 그려보는 것이 유용하다.
  • 도메인 개념들을 책임 할당의 대상으로 사용하면 코드에 도메인의 모습을 투영하기가 수월해진다.
  • 어떤 책임을 할당해야 할 때 가장 먼저 고민해야 하는 유력한 후보는 바로 도메인 개념이다.

  • 설계를 시작하는 단계에서는 개념들의 의미와 관계가 정확하거나 완벽할 필요가 없다.
  • 올바른 도메인 모델이란 존재하지 않는다.
  • 도메인 모델은 도메인을 개념적으로 표현한 것이지만 그 안에 포함된 개념과 관계는 구현의 기반이 돼야 한다.
  • 반대로 코드의 구조가 도메인을 바라보는 관점을 바꾸는 것도 가능해야 한다.
  • 필요한 것은 도메인을 있는 그대로 투영한 모델이 아니라 구현에 도움이 되는 모델이다.

정보 전문가에게 책임을 할당하라

  • 책임 주도 설계 방식의 첫 단계는 애플리케이션이 제공해야 하는 궁극적인 기능을 애플리케이션의 책임으로 생각하는 것이다.
  • 이 책임을 애플리케이션에 대해 전송된 메시지로 간주하고 이 메시지를 책임질 첫 번째 객체를 선택하는 것으로 설계를 시작한다.
  • 첫 번째 질문 : "메시지를 전송할 객체는 무엇을 원하는가?"
  • 협력을 시작하는 객체는 미정이지만 이 객체가 원하는 것은 분명하다. 바로 영화를 예매하는 것

  • 메시지에 적합한 객체를 선택해야 한다.
  • 두 번째 질문 : "메시지를 수신할 적합한 객체는 누구인가?"
  • 객체에게 책임을 할당하는 첫 번째 원칙은 책임을 수행할 정보를 알고 있는 객체에게 책임을 할당하는 것
  • GRASP에서는 이를 정보 전문가 패턴이라고 부른다.
  • 정보 전문가 패턴을 따르면 정보와 행동을 최대한 가까운 곳에 위치시키기 때문에 캡슐화를 유지할 수 있다. => 높은 응집도, 낮은 결합도
  • 책임을 수행하는 객체가 정보를 '알고' 있다고 해서 그 정보를 '저장'하고 있을 필요는 없다.
  • 객체는 해당 정보를 제공할 수 있는 다른 객체를 알고 있거나 필요한 정보를 계산해서 제공할 수도 있다.
  • 어떤 방식이건 정보 전문가가 데이터를 반드시 저장하고 있을 필요는 없다.
  • 정보 전문가 패턴에 따르면 예매하는 데 필요한 정보를 가장 많이 알고 있는 객체에게 예매하라 메시지를 처리할 책임을 할당해야 한다.
  • '상영'이라는 도메인 개념이 적합할 것이다.

  • 예매하라 메시지를 수신했을 때 Screening이 수행해야 하는 작업의 흐름을 생각해본다
  • 외부의 인터페이스가 아닌 Screening의 내부로 들어가 메시지를 처리하기 위해 필요한 절차와 구현을 고민해본다.
  • Screening 스스로 처리할 수 없는 작업이 있다면 외부에 도움을 요청해야 한다.
  • 이 요청이 외부로 전송해야 하는 새로운 메시지가 되고, 최종적으로 이 메시지가 새로운 객체의 책임으로 할당된다.
  • 이와 같은 연쇄적인 메시지 전송과 수신을 통해 협력 공동체가 구성되는 것이다.
  • 예매하라 메시지를 완료하기 위해서는 예매 가격을 계산하는 작업이 필요한데, Screening은 이를 위해 필요한 정보를 모르기 때문에 외부의 객체에게 도움을 요청해서 가격을 얻어야 한다.
  • 새로운 메시지의 이름으로 '가격을 계산하라'가 적절할 것

  • 가격을 계산하라 메시지를 책임질 객체로는 영화 가격을 계산하는 데 필요한 정보를 알고 있는 전문가인 영화가 적절하다.
  • Movie가 영화 가격을 계산할 책임을 지게 된다.

  • 이제 가격을 계산하기 위해 Movie가 어떤 작업을 해야 하는지 생각해본다.
  • 요금을 계산하기 위해서는 먼저 영화가 할인 가능한 지를 판단한 후 할인 정책에 따라 할인 요금을 제외한 금액을 계산하면 된다.
  • 할인 조건에 따라 영화가 할인 가능한 지를 판단하는 것은 영화가 스스로 처리할 수 없는 일이다.
  • Movie는 할인 여부를 판단하라 메시지를 전송해서 외부의 도움을 요청해야 한다.

  • 할인 여부를 판단하는 데 필요한 정보를 가장 많이 알고 있는 객체는 이 정보에 대한 전문가인 할인 조건(DiscountCondition)이다.

  • DiscountCondition은 자체적으로 할인 여부를 판단하는 데 필요한 모든 정보를 알고 있기 때문에 외부의 도움 없이도 스스로 할인 여부를 판단할 수 있으므로 DiscountCondition은 외부에 메시지를 전송하지 않는다.
  • 정보 전문가 패턴은 객체란 상태와 행동을 함께 가지는 단위라는 객체지향의 가장 기본적인 원리를 책임 할당의 관점에서 표현한다.
  • 정보 전문가 패턴을 따르는 것만으로도 자율성이 높은 객체들로 구성된 협력 공동체를 구축할 가능성이 높아진다.

높은 응집도와 낮은 결합도

  • 설계는 트레이드오프 활동
  • 실제 설계를 진행하다 보면 몇 가지 설계 중에서 한 가지를 선택해야 하는 경우가 빈번하게 발생한다.
  • 이 경우에는 올바른 책임 할당을 위해 정보 전문가 패턴 이외의 다른 책임 할당 패턴들을 함께 고려해야 한다.
  • 현재 설계의 대안으로 Movie 대신 Screening이 직접 DiscountCondition과 협력하게 해보자

  • 이 설계는 기능적인 측면에서만 놓고 보면 Movie와 DiscountCondition이 직접 상호작용하는 앞의 설계와 동일한데 Movie가 DiscountCondition과 협력하는 방법을 선택하게 되는 걸까?
  • 책임을 할당할 수 있는 다양한 대안들이 존재한다면 응집도와 결합도의 측면에서 더 나은 대안을 선택하는 것이 좋다
  • 여러 가능한 협력 패턴 중 더 높은 응집도와 더 낮은 결합도를 얻을 수 있는 설계를 선택해야 한다.
  • GRASP 패턴에서는 이를 낮은 결합도 패턴과 높은 응집도 패턴이라고 부른다
낮은 결합도 패턴
  • 도메인 모델에서부터 Movie는 DiscountCondition의 목록을 속성으로 포함
  • Movie와 DiscountCondition은 이미 결합돼있기 때문에 Movie를 DiscountCondition과 협력하게 하면 설계 전체적으로 결합도를 추가하지 않고도 협력을 완성
  • Screening이 DiscountCondition과 협력할 경우에는 Screening과 DiscountCondition 사이에 새로운 결합도가 추가
  • 낮은 결합도 패턴의 관점에서는 Screening이 DiscountCondition과 협력하는 것보다는 Movie가 DiscountCondition과 협력하는 것이 더 나은 설계 대안이다.
높은 응집도 패턴
  • Screening의 가장 중요한 책임은 예매를 생성하는 것인데 Screening이 DiscountCondition과 협력해야 한다면 Screening은 영화 요금 계산과 관련된 책임 일부를 떠안아야 한다.
  • 예매 요금을 계산하는 방식이 변경될 경우 Screening도 함께 변경, 결과적으로 Screening과 DiscountCondition이 협력하게 되면 Screening은 서로 다른 이유로 변경되는 책임을 짊어진다 => 응집도가 낮아진다. (클래스 변경되는 이유는 단 한 가지여야 한다.)
  • 책임을 할당하고 코드를 작성하는 매순간마다 낮은 결합도와 높은 응집도의 관점에서 전체적인 설계 품질을 검토하면 단순하면서도 재사용 가능하고 유연한 설계를 얻을 수 있다.

창조자에게 객체 생성 책임을 할당하라

  • 영화 예매 협력은 최종적으로 Reservation 인스턴스를 생성해내야 한다.
  • 객체를 생성할 책임을 어떤 객체에게 할당할지에 대한 지침을 제공하는 창조자 패턴
  • 창조자 패턴

객체 A를 생성한다고 할 때 아래 조건을 최대한 많이 만족하는 객체 B에게 객체 생성 책임을 할당

  • B가 A 객체를 포함하거나 참조
  • B가 A 객체를 기록
  • B가 A 객체를 긴밀하게 사용
  • B가 A 객체를 초기화하는 데 필요한 데이터 포함(A에 대한 정보 전문가 B)

창조자 패턴의 의도는 어떤 식으로든 생성되는 객체와 연결되거나 관련될 필요가 있는 객체에 해당 객체를 생성할 책임을 맡기는 것이다.
=> 창조자 패턴은 이미 결합돼 있는 객체 사이의 관계를 이용하기 때문에 설계가 낮은 결합도를 유지할 수 있다.

  • Screening은 Reservation을 잘 알고 있고 긴밀하게 하게 사용하거나, 초기화에 필요한 데이터를 가지고 있는 객체다.
  • Screening을 Reservation의 창조자로선택하는 것이 적절

  • 현재까지의 책임 분배는 설계를 시작하기 위한 대략적인 스케치에 불과
  • 실제 설계는 코드를 작성하는 동안 이뤄진다.
  • 협력과 책임이 제대로 동작하고 있는지 확인할 수 있는 유일한 방법은 코드를 작성하고 실행해보고 확인하는 것 뿐이다.

3. 구현을 통한 검증

  • Screening을 구현하는 것으로 시작
  • Screening: 영화를 예매할 책임과 Reservation 인스턴스를 생성할 책임을 수행해야 한다.
  • Screening은 예매에 대한 정보 전문가인 동시에 Reservation 객체의 창조자다.
public class Screening {
    public Reservation reserve(Customer customer, int audienceCount){
    }
}
  • 책임이 결정됐으므로 책임을 수행하는 데 필요한 인스턴스 변수를 결정
  • 상영 시간과 순번, 그리고 Movie에 가격을 계산하라는 메시지를 전송하기 위한 영화에 대한 참조를 포함해야 한다.
public class Screening {
    private int sequence;
    private LocalDateTime whenScreened;
    private Movie movie;

    public Reservation reserve(Customer customer, int audienceCount){
    }
}
  • 영화 예매를 위해선 movie에게 가격을 계산하라는 메시지를 전송하여 영화 요금을 반환받아야 하고 반환된 요금에 인원 수를 곱해서 전체 예매 요금을 계산하여 Reservation 인스턴스를 생성해서 반환하도록 한다.
public class Screening {
    private int sequence;
    private LocalDateTime whenScreened;
    private Movie movie;

    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);
    }
}
  • Screening을 구현하는 과정 중에 Movie에 전송하는 메시지의 시그니처calculateMovieFee(Screening screening)으로 선언했다는 것이 중요하다.
  • 해당 메시지는 수신자인 Movie가 아니라 송신자인 Screening의 의도를 표현한다.
  • Screening은 Movie의 내부 구현에 대한 어떤 지식도 없이 전송할 메시지를 결정하고 이는 수신자인 Movie의 내부 구현을 깔끔하게 캡슐화할 수 있게 한다.
  • 메시지가 변경되지 않는 한 Movie에 대한 어떤 수정을 가하더라도 Screening에는 영향이 없다.
  • 메시지가 객체를 선택 => 책임 주도 설계, 캡슐화와 낮은 결합도 달성
  • Movie는 calculateMovieFee 메시지에 응답하기 위해 calcultateMovieFee 메서드를 구현해야 한다.
public calss Movie {
    public Money calculateMovieFee(Screeing screening) {
    }
}
  • 요금을 계산하기 위해서 Movie는 기본 금액(fee), 할인 정책, 할인 조건(discountConditions) 등의 정보를 알아야 한다.
  • 현재 설계에서는 할인 정책을 Movie의 일부로 구현하고 있기 때문에 할인 정책을 구성하는 할인 금액(discountAmount)과 할인 비율(discountPercent)을 인스턴스 변수로 선언
  • 영화의 현재 적용된 할인 정책을 나타내기 위한 영화 종류(movieType)를 인스턴스 변수로 포함
public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;    // 기본 요금
    private List<DiscountCondition> discountCoditions;  // 할인 조건 목록

    private MovieType movieType;
    private Money discountAmount;
    private double dicountPercent;

    public Money calculateMovieFee(Screeing screening) {
    }
}
public enum MovieType {
    AMOUNT_DISCOUNT,   // 금액 할인 정책
    PERCENT_DISCOUNT,  // 비율 할인 정책
    NONE_DISCOUNT      // 미적용
}
  • Movie는 DiscountCondition 인스턴스에게 isSatisfiedBy 메시지를 전송해서 할인 여부를 판단하도록 요청해야 하는데 이는 discountConditions의 원소를 차례대로 순회하면서 이뤄진다.
  • 할인 조건을 만족하는 DiscountCondition 인스턴스가 존재한다면 할인 요금을 계산하기 위해 calculateDiscountAmount 메서드를 호출하고 만족하는 할인 조건이 없으면 기본 요금(fee)를 반환
public class Movie {
    public Money calculateMovieFee(Screening screening) {
        if(isDiscountable(screening)){
            return fee.minus(calculateDiscountAmount());
        }

        return fee;
    }

    private boolean isDiscountable(Screening screening) {
        return discountConditions.stream()
                                 .anyMatch(condition ->  
                                           condition.isSatisfiedBy(screening));
                                           // 외부의 객체에게 메시지 전송
    }
}
  • 할인 요금을 계산하는 calculateDiscountAmount 메서드는 movieType의 값에 따라 적절한 메서드를 호출
public class Movie {

    public Money calculateDiscountAmount() {
        switch(movieType) {
            case AMOUNT_DISCOUNT: 
                return calculateAmountDiscountAmount();
            case PERCENT_DISCOUNT:
                return calculatePercentDiscountAmount();
            case NONE_DISCOUNT:
                return calculateNoneDiscountAmount();
        }

        throw new IllegalStateException();
    }

    private Money calculateAmountDiscountAmount() {
        return discountAmount;
    }

    private Money caculatePercentDiscountAmount() {
        return fee.times(dicountPercent);
    }

    private Money calculateNoneDiscountAmount() {
        return Money.ZERO;
    }
}
  • DiscountCondition은 Movie의 할인 여부를 판단하라는 메시지를 처리하기 위해 isSatisfiedBy 메서드를 구현해야 한다.
public class DiscountCondition {
    public boolean isSatisfiedBy(Screening screening){
    }
}
  • DiscountCondition은 할인 조건의 종류(type), 기간 조건을 위한 요일, 시작 시간, 종료 시간과 순번 조건을 위한 상영 순번을 인스턴스 변수로 포함한다.
  • isSatisfiedBy 메서드는 type의 값에 따라 적절한 메서드를 호출한다.
public class DiscountCondition {
    private DiscountConditionType type;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;
    private int sequence;

    public boolean isSatisfiedBy(Screening screening) {
        if(type == DiscountConditionType.PERIOD){
            return isSatisfiedByPeriod(screening);
        }

        return isSatisfiedBySequence(screeing);
    }

    private boolean isSatisfiedByPeriod(Screening screeing){
        return dayOfWeek.equals(screeing.getWhenScreened().getDayOfWeek()) 
                &&
               startTime.compareTo(screening.getWhenScreened().toLocalTime())<=0
                &&
               endTime.compareTo(screening.getWhenScreened().toLocalTime())>=0;
    }

    private isSatisfiedBySequence(Screening screening) {
        return sequence == screening.getSequence();
    }
}
public class Screening {
    public LocalDateTime getWhenScreened(){
        return whenScreened;
    }

    public int getSequence(){
        return sequence;
    }
}
public enum DiscountConditionType {
    SEQEUNCE,     // 순번 조건
    PERIOD        // 기간 조건
}

DiscountCondition 개선하기

  • 변경에 취약한 클래스 : DiscountCondition
  • 코드를 수정해야 되는 이유를 하나 이상 갖기 때문에 DiscountCondition 클래스는 변경에 취약한 클래스다.
  • DiscountCondition 클래스의 다양한 코드 변경 이유
      1. 새로운 할인 조건 추가
      1. 기간 조건을 판단하는 로직이 변경
      1. 순번 조건을 판단하는 로직이 변경
  • DiscountCondition은 하나 이상의 변경 이유를 갖기 때문에 응집도가 낮다.
  • 낮은 응집도가 초래하는 문제를 해결하기 위해서는 변경의 이유에 따라 클래스를 분리해야 한다.
  • 변경의 이유가 하나 이상인 클래스에는 위험 징후를 나타내는 몇 가지 패턴이 존재한다.
  • 코드를 통해 변경의 이유를 파악할 수 있는 첫 번째 방법: 인스턴스 변수가 초기화되는 시점을 살펴보기
  • 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화한다.
  • 응집도가 낮은 클래스는 객체의 속성 중 일부만 초기화하고 일부는 초기화되지 않은 상태로 남겨짐
  • 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.
  • 메서드들이 인스턴스 변수를 사용하는 방식을 살펴봄으로써 코드 변경의 이유를 파악 가능
  • 메서드들이 사용하는 속성에 따라 그룹이 나뉜다면 클래스의 응집도가 낮다
  • 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 한다.
  • 타입 분리하기
  • DiscountCondition의 낮은 응집도 문제를 해결하기 위해 초기화되는 속성그룹과 메서드의 속성 그룹에 따라 DiscountCondition을 SequenceCondition과 PeriodCondition이라는 두 개의 클래스로 분리한다.
public class PeriodCondition {
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public PeriodCondtion(DayOfWeek dayOfWeek, 
                            LocalTime startTime, LocalTime endTime) {
        this.dayOfWeek = dayOfWeek;
        this.startTime = startTime;
        this.endTime = endTime;
    }

    public isSatisfiedByPeriod(Screening screeing){
        return dayOfWeek.equals(screeing.getWhenScreened().getDayOfWeek()) 
                &&
               startTime.compareTo(screening.getWhenScreened().toLocalTime())<=0
                &&
               endTime.compareTo(screening.getWhenScreened().toLocalTime())>=0;
    }
}
public class SequenceCondition {

    private int sequence;

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

    public isSatisfiedBySequence(Screening screening) {
        return sequence == screening.getSequence();
    }
}
  • 클래스를 분리한 후에 나타난 새로운 문제: Movie의 인스턴스가 두 개의 서로 다른 클래스의 인스턴스 모두와 협력할 수 있어야만 한다.

  • 문제를 해결하기 위해 Movie 클래스 안에 SequenceCondtion의 목록과 PeriodCondition의 목록을 따로 유지하는 방법을 떠올릴 수 있지만 이것은 두 가지 문제점을 갖는다.
  • 첫 번째 문제: Movie가 SequenceCondition과 PeriodCondition 클래스 양쪽 모두에게 결합되어 설계의 관점에서 전체적인 결합도가 상승
  • 두 번째 문제: 새로운 할인 조건을 추가하기가 더 어려워졌다. 클래스를 분리하기 전에는 Movie의 내부 구현까지 바꿀 필요가 없었지만 클래스를 분리한 후 할인 조건을 추가하려면 Movie의 내부 구현까지 수정해야 한다.
  • DiscountCondition 입장에서는 응집도가 높아졌지만 전체적인 설계의 품질은 더 나빠졌다.
  • 다형성을 통해서 분리하기
  • 할인 가능 여부를 반환해 주기만 하면 Movie는 객체가 SequenceCondition의 인스턴스인지, PeriodCondition의 인스턴스인지는 상관하지 않는다.
  • Movie의 입장에서 SequenceCondition과 PeriodCondition이 동일한 책임을 수행한다는 것은 동일한 역할을 수행한다는 것이다.
  • 역할은 협력 안에서 대체 가능성을 의미한다.
  • SequenceCondition과 PeriodCondition에 역할의 개념을 적용하면 Movie가 구체적인 클래스는 알지 못한 채 오직 역할에 대해서만 결합되도록 의존성을 제한할 수 있다.

  • 역할을 사용하면 객체의 구체적인 타입을 추상화할 수 있다. 역할은 곧 추상화다
  • 자바에서는 추상 클래스나 인터페이스를 사용해 역할을 구현할 수 있다.
  • 구현시킬 역할을 대체할 클래스들 사이에서 구현을 공유해야 할 필요가 있다면 추상 클래스를 사용한다.
  • 구현을 공유할 필요 없이 역할을 대체하는 객체들의 책임만 선언하고 싶다면 인터페이스를 사용
public interface DiscountCodition {
    boolean isSatisfiedBy(Screening screening);
}
public class SequenceCondtion implements DiscountCondition { ... }

public class PeriodCondition implements DiscountCondition { ... }
  • 이제 Movie는 협력하는 객체의 구체적인 타입을 몰라도 상관없다.
public class Movie {
    private List<DiscountCondition> discountCondtions;

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

        return fee;
    }

    private boolean isDiscountable(Screening screening){
        return discountConditions.stream()
                                 .anyMatch(condition -> 
                                             condition.isSatisfiedBy(screening));
    }
}
  • Movie와 DiscountCondition 사이의 협력은 다형적이다.
  • 객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당 => GRASP에서는 이를 다형성 패턴이라고 부른다.

변경으로부터 보호하기

  • 변경 보호 패턴: 변화가 예상되는 예상되는 불안정한 지점들을 식별하고 그 주위에 안정된 인터페이스를 형성하도록 책임을 할당
  • 클래스를 변경에 따라 분리하고 인터페이스를 이용해 변경을 캡슐화 => 설계의 결합도와 응집도를 향상시킨다.
  • 하나의 클래스가 여러 타입의 행동을 구현하고 있는 것처럼 보인다면 클래스를 분리하고 다형성 패턴에 따라 책임을 분산
  • 예측 가능한 변경으로 인해 여러 클래스들이 불안정해진다면 변경 보호 패턴에 따라 안정적인 인터페이스 뒤로 변경을 캡슐화
  • 새로운 할인 조건을 추가하는 경우, 오직 DiscountCondition 인터페이스를 실체화하는 클래스를 추가하는 것으로 할인 조건의 종류를 확장할 수 있다.

Movie 클래스 개선하기

  • Movie 역시 DiscountCondition와 마찬가지로 두 가지 타입을 하나의 클래스 안에 구현하고 있기 때문에 하나 이상의 이유로 변경될 수 있다. 즉, 응집도가 낮다.
  • DiscountCondition과 동일하게 역할의 개념을 도입해서 협력을 다형적으로 만들어 해결
  • 다형성 패턴을 사용해 서로 다른 행동을 타입 별로 분리
  • 변경 보호 패턴을 이용해 타입의 종류를 안정적인 인터페이스 뒤 캡슐화 가능 => 새로운 Movie 타입을 추가하더라도 Screening에는 영향을 주지 않는다.
  • Movie의 경우에는 구현을 공유할 필요가 있기 때문에 추상 클래스를 이용해 역할을 구현
public abstract class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private List<DiscountCondition> discountCondtions;

    public Movie(String title,Duration runningTime, Money fee, 
                    DiscountCondition... discountConditions) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountConditions = Arrays.asList(discountConditions);
    }

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

        return fee;
    }

    private boolean isDiscountable(Screening screening){
        return discountConditions.stream()
                                 .anyMatch(condition -> 
                                             condition.isSatisfiedBy(screening));
    }

    abstract protected Money calculateDiscountAmount();


}
public class AmountDiscountMovie extends Movie {
    private Money discountAmount;

    public AmountDiscountMovie(String title,Duration runningTime, Money fee, 
                    DiscountCondition... discountConditions, 
                        Money discountAmount) {
        super(title, runningTime, fee, discountConditions);
        this.discountAmount = discountAmount;                    
    }

    @Override
    protected Money calculateDiscountAmount() {
        return discountAmount;
    }
}
public class PercentDiscountMovie extends Movie {
    private double percent;

    public PercentDiscountMovie(String title,Duration runningTime, Money fee, 
                    DiscountCondition... discountConditions, 
                        double percent) {
        super(title, runningTime, fee, discountConditions);
        this.percent = percent;                    
    }

    @Override
    protected Money calculateDiscountAmount() {
        return getFee().times(percent);
    }

}
public abstract class Movie {
    protected Money getFee() {
        return fee;
    }
}
public class NoneDiscountMovie extends Movie {
    public NoneDiscountMovie(String title, Duration runningTime, Money fee){
        super(title, runningTime, fee);
    } 

    @Override
    protected Money calculateDiscountAmount() {
        return Money.ZERO;
    }
}

변경과 유연성

  • 영화에 설정된 할인 정책을 실행 중에 변경할 수 있어야 한다는 요구사항이 추가된 경우
  • 현재의 설계에서 할인 정책을 구현하기 위해 상속을 이용하므로 실행 중에 할인 정책을 변경해보자
  • 새로운 인스턴스를 생성하고 필요한 정보를 복사하고 새로운 인스턴스에 대한 식별자를 관리해야 하는 번거로움이 있다.
  • 코드의 복잡성이 높아지더라도 변경을 쉽게 수용할 수 있게 코드를 유연하게 만들자: 상속 대신 합성을 사용하자
public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private DiscountPolicy discountPolicy;

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

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

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

}
public abstract class DiscountPolicy {
    private List<DiscountCondition> discountCondtions;

    public DiscountPolicy(DiscountCondition... discountConditions) {
        this.discountCondtions = Arrays.asList(discountConditions);
    }

    public Money calculateDiscountAmount(Screening screening) {

        if(isDiscountable(screening)){
            return fee.minus(getDiscountAmount());
        }

        return Money.ZERO;
    }

    private boolean isDiscountable(Screening screening){
        return discountConditions.stream()
                                 .anyMatch(condition -> 
                                             condition.isSatisfiedBy(screening));
    }

    abstract protected Money getDiscountAmount();
}
public class AmountDiscountPolicy extends DiscountPolicy {
    private Money discountAmount;

    public AmountDiscountPolicy(DiscountCondition... discountConditions){
        super(discountConditions);
        this.discountAmount = discountAmount;
    }

    @Override 
    protected Money getDiscountAmount(){
        return discountAmount;
    }
}
public class PercentDiscountPolicy extends DiscountPolicy {
    private double percent;
    private Money basicFee;

    public PercentDiscountPolicy(DiscountCondition... discountConditions,
                                            double percent, Money basicFee){
        super(discountConditions);
        this.percent = percent;
        this.basicFee = basicFee;
    }

    @Override 
    protected Money getDiscountAmount(){
        return basicFee.times(percent);
    }
}
public class NoneDiscountPolicy extends DiscountPolicy {
    @Override 
    protected Money getDiscountAmount() {
        return Money.ZERO;
    }
}

Movie movie = new Movie("타이타닉",
                        Duration.ofMinutes(120),
                        Money.wons(10000),
                        new AmountDiscountPolicy(...));
movie.changeDiscountPolicy(new PercentDiscountPolicy(...));
  • 코드의 유연성은 의존성 관리에 대한 문제이다.
  • 요소들 사이의 의존성의 정도가 유연성의 정도를 결정한다.
  • 합성을 이용하면 상속보다 의존성의 정도를 낮춰 유연성을 높일 수 있다.

코드의 구조가 도메인의 구조에 대한 새로운 통찰력 제공

  • 코드의 구조가 바뀌면 도메인에 대한 관점도 함께 바뀐다.
  • 도메인 모델은 단순히 도메인의 개념과 관계를 모아 놓은 것에 그치지 않고 구현과 밀접한 관계를 맺어야 한다.