Chapter 5. 책임 할당하기

Minjae An·2023년 11월 24일

오브젝트

목록 보기
5/15

⛳ 책임 주도 설계를 향해

데이터 중심 설계에서 책임 중심 설계로 전환하기 위해서는 다음 두 원칙을
따라야 한다.

  • 데이터보다 행동을 먼저 결정하라
  • 협력이라는 문맥 안에서 책임을 결정하라
    핵심은 데이터가 아니라 객체의 책임과 협력에 초점을 맞추라는 것이다.

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

객체에게 중요한 것은 데이터가 아니라 외부에 제공하는 행동이다. 클라이언트
관점에서 객체가 수행하는 행동이란 곧 객체의 책임을 의미한다. 데이터는
객체가 책임을 수행하는 데 필요한 재료를 제공할 뿐이다.

이 객체가 수행해야하는 책임이 무엇인가? 를 결정한 후에 이 책임을
수행하는 데 필요한 데이터는 무엇인가
를 결정해야 한다.

협력이라는 문맥 안에서 책임을 결정하라

객체에게 할당된 책임의 품질은 협력에 적당한 정도로 결정된다. 책임은
객체의 입장이 아니라 객체가 참여하는 협력에 적합해야 한다.

협력에 적합한 책임이란 메시지 수신자가 아니라 메시지 전송자에게 적합한
책임을 의미한다. 다시 말해 메시지를 전송하는 클라이언트의 의도에 적합한
책임을 할당해야 한다.

협력에 적합한 책임을 수확하기 위해서는 메시지를 결정한 후에 객체를
선택해야 한다. 메시지가 객체를 선택하게 해야 한다.

메시지를 먼저 결정하기 때문에 메시지 송신자는 수신자에 대한 어떤 가정도
할 수 없고, 전송자의 관점에서 수신자가 깔끔히 캡슐화된다.

🧩 책임 할당을 위한 GRASP 패턴

책임 할당 기법중 가장 널리 알려진 것은 크레이그 라만이 패턴 형식으로 제안한
GRASP(General Responsibility Assignment Software Pattern)이다.
책임 할당시 지침으로 삼을 수 있는 원칙들의 집합을 패턴으로 정리한 것이다.

이제 영화 예매 시스템을 책임 중심으로 설계하는 과정을 살펴보자.

도메인 개념에서 출발하기


설계를 시작하기 전 도메인에 대한 개략적인 모습을 그려보는 것이 유용하다.
도메인 개념을 책임 할당의 대상으로 사용하면 코드에 도메인을 투영하기가 더
수월해진다.

설계를 시작하는 단계에서는 개념들의 의미와 관계가 정확하거나 완벽할 필요가
없다. 중요한 것은 설계를 시작하는 것이다.

올바른 도메인 모델이란 존재하지 않는다
도메인 모델은 구현을 염두에 두고 구조화되는 것이 바람직하다. 코드의 구조가
도메인을 바라보는 관점을 바꾸기도 한다. 필요한 것은 구현에 도움이 되는
모델이다. 한마디로, 실용적이며 유용한 모델이 답이다.

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

책임 주도 설계에서는 애플리케이션의 기능을 책임으로 생각하는 것이다.
책임을 애플리케이션에 전송된 메시지로 간주하고 이 메시지를 책임질
첫 객체를 선택하는 것으로 설계를 시작한다. 사용자에게 제공하는 기능은
영화를 예매하는 것이다.

메시지를 전송할 객체는 무엇을 원하는가?

메시지를 전송할 객체의 의도를 반영해서 첫 메시지를 정했다.

메시지를 수신할 적합한 객체는 누구인가?

객체에게 책임을 할당하는 첫 원칙은 책임을 수행할 정보를 알고 있는 객체에게
책임을 할당하는 것이다. GRASP에서는 이를 INFORMATION EXPERT
패턴이라고 부른다. 여기서 얘기하는 정보는 데이터와 다르다. 책임을 수행하는
객체가 정보를 '알고' 있다고 해서 그 정보를 '저장'하고 있을 필요는 없다.

예매하라는 메시지를 수행할 수 있는 적합한 정보 전문가는 '상영'으로 볼 수 있다.

예매하라 메시지를 완료하기 위해선 예매 가격을 계산하는 작업이 필요하다.
Screening은 가격을 계산하는 데 필요한 정보를 모르기 때문에 외부의 객체에
도움을 요청해서 가격을 얻어야 한다. 이 요청이 새로운 메시지가 된다.

영화 가격을 계산하는 데 필요한 정보를 알고 있는 전문가는 Movie이다.

요금을 계산하기 위해 영화가 할인 가능한지를 판단해야 한다. 이는 Movie
수행할 수 없어 외부에 메시지를 요청해야 한다. 흐름에 따라 책임질 객체까지
정해보면 아래와 같다.

DiscountCondition은 외부의 도움 없이도 스스로 할인 여부를 판단할
수 있다.

INFORMATION EXPERT패턴을 따르는 것만으로 자율성이 높은 객체들로
구성된 협력 공동체를 구축할 가능성이 높아진다.

높은 응집도와 낮은 결합도

설계는 트레이드-오프 활동이기 때문에 올바른 책임 할당을 위해 다른 책임 할당
패턴들을 함께 고려할 필요가 있다. 만약 할인 요금을 계산하기 위해 Screening
DiscountCondition과 협력하게 하는 것은 어떨까?

설계를 수정할 경우 기능적으로는 앞선 경우와 별 차이가 없어보인다. 하지만
MovieDiscountCondition과 협력하는 방향을 선택한 이유는 바로
높은 응집도와 낮은 결합도를 얻을 수 있음에 있다.

GRASP에서는 이를 LOW COUPLING패턴과 HIGH COHESION패턴이라고
부른다.

도메인 개념에서 살펴보면 MovieDiscountCondition은 이미 결합되어
있기에 이 둘을 협력하게 하면 전체적으로 결합도를 추가할 필요가 없다.
하지만, ScreeningDiscountCondition을 협력케 하면 결합도가 추가된다.
따라서 LOW COUPLING 관점에서는 Movie&DiscountCondition이 나은
설계다.

한편, Screening의 가장 중요한 책임은 예매를 생성하는 것이다. 만약
ScreeningDiscountCondition과 협력해야 한다면 영화 요금 계산과
관련된 책임 일부를 떠안아야 할 것이다. 이 경우 Screening이 알아야 할
것이 늘어난다.

반면 Movie의 주된 책임은 영화 요금을 계산하는 것이기 때문에
DiscountCondition과 협력시 응집도에 아무런 영향이 없다.
HIGH COHESION 관점에서 이 협력 관계가 더 나은 설계 대안이다.

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

영화 예매 협력의 최종 결과물은 Reservation 인스턴스를 형성하는 것이다.
GRASP의 CREATOR패턴은 이 같은 경우에 사용할 수 있는 책임 할당
패턴으로서 객체를 생성할 책임을 할당할 때 지침을 제공한다.

CREATOR 패턴

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

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

생성될 객체에 대해 잘 알고 있어야 하거나 그 객체를 사용해야 하는 객체는
어떤 방식으로든 생성될 객체와 결합된다. CREATOR 패턴은 이런 이미 존재하는
객체 사이의 관계를 이용하기 때문에 설계가 낮은 결합도를 유지할 수 있게 한다.

Screening은 예매 정보를 생성하는 데 필요한 모든 정보를 알고 요금을
계산하는 데 필수적인 Movie도 알고 있다. 따라서 Reservation
CREATOR로 적합해 보인다.

🧗 구현을 통한 검증

Screening을 구현하는 것으로 시작하자. 협력의 관점에서 예매하라
메시지에 응답할 수 있어야 한다.

public class Screening {
    public Reservation reserve(Customer customer, int audienceCount){
        
    }
}

책임이 결정됐으므로 책임을 수행하는 데 필요한 인스턴스 변수를 결정해야 한다.

package chap5;

import java.time.LocalDateTime;

public class Screening {
    private Movie movie;
    private int sequence;
    private LocalDateTime whenScreened;
    
    public Reservation reserve(Customer customer, int audienceCount){

    }
}

영화를 예매하기 위해선 movie에게 가격을 계산하라는 메시지를 전송해서
계산된 영화 요금을 받아야 한다.

public class Screening {
    private Movie movie;
    private int sequence;
    private LocalDateTime whenScreened;

    public Reservation reserve(Customer customer, int audienceCount) {

    }

    private Money calculateFee(int audienceCount) {
        return movie.calculateMovieFee(this).times(audienceCount);
    }
}

전송하는 메시지의 시그니처를 calculateMovieFee(Screening screening)으로
선언했다는 사실에 주목하라. 이 메시지는 송신자인 Screening의 의도를 표현한다.
이를 통해 Movie의 내부 구현을 깔끔하게 캡슐화할 수 있다.

public class Movie {
    public Money calculateMovieFee(Screening screening) {
    }
}

요금을 계산하는 데 필요한 정보를 인스턴스 변수로 포함한다.

public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private List<DiscountCondition> discountConditions;

    private MovieType movieType;
    private Money discountAmount;
    private double discountPercent;

    public Money calculateMovieFee(Screening screening) {
    }
}

Movie는 먼저 discountConditions를 순회하며 DiscountCondition
인스턴스에 isSatisfiedBy메시지를 전송해서 할인 여부를 판단토록 요청한다.
할인 조건을 만족하는 지에 따라 caculateDiscountAmount 메서드를 호출하거나
기본 금액인 fee를 반환한다.

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

        	return fee;
        }

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

calculateDiscountAmount 메서드는 movieType의 값에 따라 적절한
메서드를 호출한다.

public class Movie {
    private 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 calculatePercentDiscountAmount() {
        return fee.times(discountPercent);
    }

    private Money calculateNoneDiscountAmount() {
        return Money.ZERO;
    }
}

Movie는 각 DiscountCondition할인 여부를 판단하라는 메시지를
전송한다.

public class DiscountCondition {
    public boolean isSatisfiedBy(Screening screening) {
    }
}

할인 조건을 판단하기 위해 필요한 인스턴스 변수와 메서드를 추가로 설정한다.

public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public boolean isSatisfiedBy(Screening screening) {
        if(type == DiscountConditionType.PERIOD){
            return isSatisfiedByPeriod(screening);
        }
        
        return isSatisfiedBySequence(screening);
    }
    
    private boolean isSatisfiedByPeriod(Screening screening){
        return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
                startTime.compareTo(screening.getWhenScreened().toLocalTime()) <=0 &&
                endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
    }
    
    private boolean isSatisfiedBySequence(Screening screening){
        return sequence == screening.getSequence();
    }
}

구현이 완료되었다. 하지만 몇 가지 문제점이 숨어 있다.

DiscountCondition 개선하기

가장 큰 문제점은 DiscountCondition이 변경에 취약한 형태라는 것이다.
변경에 취약한 클래스란 수정의 이유를 하나 이상 가지는 클래스다.
DiscountConditon은 다음 세 이유로 변경될 수 있다.

새로운 할인 조건 추가
isSatisfiedBy 내의 if~else 구문을 수정해야 한다. 새 할인 조건이
새 데이터를 요구한다면 DiscountConditon에 속성을 추가하는 작업도
필요하다.

순번 조건을 판단하는 로직 변경
isSatisfiedBySequence 메서드의 내부 구현을 수정해야 한다.
데이터 변경에 따라 속성도 변경될 수 있다.

기간 조건을 판단하는 로직이 변경되는 경우
isSatisfiedByPeriod 메서드의 내부 구현을 수정해야 한다. 물론 기간
조건을 판단하는 데 필요한 데이터가 변경된다면 DiscountCondition
관련 속성도 변경되어야 한다.

위와 같이 낮은 응집도가 초래하는 문제를 해결하기 위해서는 변경의 이유에 따라 클래스를
분리해야 한다.
DiscountCondition 내부의 isSatisfied 메서드들이 서로 다른 조건을 판별하기 위해 동작하기에 서로 다른 이유를 변경될 수 있다.

일반적으로 설계를 개선하는 방법은 변경의 이유가 하나 이상인 클래스를 찾는
것으로부터 시작하는 것이 좋다.

인스턴스 변수가 초기화되는 시점을 살펴보자

코드를 통해 변경의 이유를 파악할 수 있는 첫 방법은 인스턴스 변수가
초기화되는 시점
을 살펴보는 것이다. 응집도가 높은 클래스는 인스턴스
생성 시 모든 속성을 함께 초기화한다. 따라서 함께 초기화되는 속성을
기준으로 코드를 분리해야 한다.

메서드들이 인스턴스 변수를 사용하는 방식을 살펴보자

모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다고
볼 수 있다. 반면 그룹이 나뉜다면 클래스가 응집도가 낮다고 볼 수 있다.
클래스의 응집도를 높이기 위해서는 속성 그룹과 해당 그룹에 접근하는
메서드 그룹을 기준으로 코드를 분리해야 한다.

클래스 응집도 판단

  • 클래스가 하나의 이상의 이유로 변경되어야 한다.
  • 클래스의 인스턴스를 초기화하는 시점에 경우에 따라 서로 다른 속성들을
    초기화하고 있다면 응집도가 낮은 것이다.
  • 메서드 그룹이 속성 그룹을 사용하는지 여부로 나뉜다면 응집도가 낮은 것이다.

일반적으로 응집도 낮은 클래스는 이 세 가지 문제를 동시에 가진다

타입 분리하기

가장 먼저 떠오르는 해결 방법은 DiscountConditionSequenceConditon
PeriodCondition이라는 두 개의 클래스로 분리하는 것이다.

public class PeriodCondition {
    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 dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
                startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
                endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
    }
}

SequenceCondition은 간단히 아래와 같이 분리될 수 있다.

public class SequenceCondition {
    private int sequence;

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

    public boolean isSatisfiedBy(Screening screening) {
        return sequence == screening.getSequence();
    }
}

클래스를 분리하면 앞서 언급했던 문제들이 모두 해결된다. 다만 Movie
분리된 서로 다른 클래스의 인스턴스가 모두 협력할 수 있게 설계를 수정해야 한다.

간단히 Movie내부에 SequenceConditionPeriodCondition
목록을 따로 유지하는 방식을 생각할 수 있다. 하지만 이 방법은 Movie 클래스가
두 클래스 양쪽 모두에게 결합된다는 문제와 새로운 할인 조건을 추가하기가
어려워진다는 문제를 야기한다.

DiscountCondition 입장에서 볼 땐 응집도가 높아졌지만 변경과 캡슐화의
관점에서 전체 설계 품질이 저하되었다.

다형성을 통해 분리하기

사실 Movie 입장에서 보면 두 할인 조건 클래스가 할인 여부를 판단하는
동일한 책임을 수행하고 있을 뿐이다. 따라서 역할을 사용하여 객체의
구체적인 타입을 추상화할 수 있다.

구현을 공유할 필요가 있다면 추상 클래스를 사용해야 하지만, 할인 조건의
경우 구현을 공유할 필요가 없기 때문에 인터페이스를 사용하면 된다.

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

인터페이스를 실체화하도록 구현체들을 수정하자.

public class PeriodConditon implements DiscountCondition{ ... }

public class SequenceCondition implements DiscountConditon{ ... }

이제는 Movie는 협력하는 객체의 구체적 타입을 몰라도 상관없다.
이에 따라 MovieDiscountCondition의 협력은 다형적인 형태를 띈다.
객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을
각 타입의 책임으로 할당할 수 있다. GRASP에서는 이를 POLYMORPHISM 패턴
이라고 부른다.

POLYMORPHISM 패턴

프로그램을 if~else 또는 switch~case등의 조건 논리를 사용해서
설계한다면 새로운 변화 발생시 조건 논리를 수정해야 한다. 이는 수정을 어렵게 하고
변경에 취약하게 만든다.

POLYMORPHISM 패턴은 객체의 타입을 검사해 타입에 따라 여러 대안들을 수행하는
조건적 논리를 사용하지 말고, 다형성을 이용해 새로운 변화를 다루기 쉽게 확장하라
권장한다.

변경으로 보호하기

DiscountCondition을 조건에 따라 분리한 결과 두 개의 서로 다른 변경이
두 개의 서로 다른 클래스 안으로 캡슐화된다. 이에 따라 새로운 할인 조건을
추가할 경우 Movie가 변경의 영향을 받지 않는다.

이처럼 변경을 캡슐화하도록 책임을 할당하는 것을 GRASP에서는 PROTECTED
VARIATIONS 패턴
이라고 부른다.

PROTECTED VARIATIONS 패턴

이 패턴은 책임 할당의 관점에서 캡슐화를 설명한 것으로 변화가 예상되는
지점들을 식별하여 그 주위에 안정된 인터페이스를 형성하도록 책임을
할당하는 것을 의미한다.

정리하면 POLYMORPHISM 패턴에 따라 클래스 분해하고 책임을
분산시키며 예측 가능한 변경으로 인해 여러 클래스들이 불안정해진다면
PROTECTED VARIATIONS 패턴에 따라 안정적인 인터페이스 뒤로
변경을 캡슐화하라.

Movie 클래스 개선하기

Movie 역시도 금액 할인 정책 영화와 비율 할인 정책 영화라는 두 타입을
하나의 클래스 내에 구현하고 있어 응집도가 낮다. 따라서 POLYMORPHISM 패턴을
이용하여 행동에 따라 타입별로 분리하고 PROTECTED VARIATIONS 패턴을 통해
Screening에 영향을 미치지 않게 캡슐화할 수 있다.

Movie의 경우에는 구현을 공유할 필요가 있어 추상 클래스를 사용한다.

public abstract class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private List<DiscountCondition> discountConditions;

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

금액 할인 정책과 관련된 인스턴스 변수와 메서드는 AmountDiscountMovie
이동시키자

public class AmountDiscountMovie extends Movie {
    private Money discountAmount;

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

    @Override
    protected Money calculateDiscountAmount() {
        return discountAmount;
    }
}

비율 할인 정책은 PercentDiscountMovie 클래스에서 구현한다.

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

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

할인 요금을 계산하기 위해 MoviegetFee() 메서드를 추가해준다. 서브
클래스에서만 사용하기에 protected 로 가시성을 제한한다.

할인 정책을 적용하지 않는 경우 NoneDiscountMovie를 사용한다.

public class NoneDiscountMovie extends Movie {
    public NoneDiscountMovie(String title, Duration runningTime, Money fee,
                             DiscountCondition... discountConditions) {
        super(title, runningTime, fee, discountConditions);
    }

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

이와 같이 구현하면 책임과 협력에 중점에 두어 모든 클래스의 내부 구현이 캡슐화되어
있고 변경의 이유를 하나씩만 가지도록 설계할 수 있다.

변경과 유연성

설계를 주도하는 것은 변경이며 개발자가 변경에 대비할 수 있는 방법은
두 가지가 있다. 하나는 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계하는
것이고, 다른 하나는 코드를 수정하기 않고 변경을 수용할 수 있게 코드를 더 유연하게
만드는 것이다. 대부분의 경우 전자가 좋지만 유사한 변경이 반복적으로 발생할 경우
복잡성이 상승하더라도 후자를 도입하는 것이 좋다.

코드를 더 유연하게 만들고자 할 때는 상속 대신 합성을 사용하는 것이 좋다.
현재의 설계에서 Movie 내부의 할인 정책을 DiscountPolicy로 분리한 후
Movie에 합성시키면 유연한 설계가 완성된다.

이제 금액 할인 정책이 적용된 영화를 비율 할인 정책으로 바꾸는 일은
DiscountPolicy 인스턴스를 교체하는 단순한 작업으로 바뀐다.

Moive movie = new Movie("타이타닉",
						Duration.ofMinutes(120),
                        Money.wons(10000),
                        new AmountDiscountPolicy(...));
movie.changeDiscountPolicy(new PercentDiscountPolicy(...));

또한 새 할인 정책이 추가되더라도 할인 정책을 변경하는 데 필요한 추가적인
코드 작성이 필요없다.

👻 책임 주도 설계의 대안

책임과 객체 사이에서 방황할 때 돌파를 위한 방법으로 최대한 빠르게 목적한
기능을 수행하는 코드를 작성하고 리팩터링을 수행하는 방법이 있다.

앞선 데이터 중심 설계를 리팩터링하여 이 방법의 장점을 살펴보자.

메서드 응집도

ReservationAgencyreserve 메서드를 다시 살펴보자

public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer,
                               int audienceCount) {
        Movie movie = screening.getMovie();

        boolean discountable = false;
        for(DiscountCondition condition : movie.getDiscountConditions()) {
            if (condition.getType() == DiscountConditionType.PERIOD) {
                discountable = screening.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek()) &&
                        condition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
                        condition.getEndTime().compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
            } else {
                discountable = condition.getSequence() == screening.getSequence();
            }

            if (discountable) {
                break;
            }
        }

        Money fee;
        if (discountable) {
            Money discountAmount = Money.ZERO;
            switch(movie.getMovieType()) {
                case AMOUNT_DISCOUNT:
                    discountAmount = movie.getDiscountAmount();
                    break;
                case PERCENT_DISCOUNT:
                    discountAmount = movie.getFee().times(movie.getDiscountPercent());
                    break;
                case NONE_DISCOUNT:
                    discountAmount = Money.ZERO;
                    break;
            }

            fee = movie.getFee().minus(discountAmount).times(audienceCount);
        } else {
            fee = movie.getFee().times(audienceCount);
        }

        return new Reservation(customer, screening, fee, audienceCount);
    }
}

메서드의 길이가 너무 길고 이해하기도 어렵다. 긴 메서드는 다음과 같은 부정적
영향을 미친다.

  • 한눈에 어떤 일을 하는 지 파악하기 어려워 코드의 전체적 이해에 시간이
    오래 걸린다.
  • 한 메서드 내에서 너무 많은 작업을 수행해 변경시 수정이 어렵다.
  • 메서드 내부 일부 로직만 수정하더라도 나머지 부분에서 버그가 발생할
    확률이 높다.
  • 로직의 일부만 재사용하는 것이 불가능하다.
  • 코드를 재사용하는 유일한 방법은 코드를 복붙하는 것이므로 코드 중복을
    초래하기 쉽다.

이런 메서드를 몬스터 메서드라고 부르기도 한다.

응집도가 높은 메서드도 클래스와 마찬가지로 변경의 이유가 하나여야 한다.
이런 메서드는 크기가 작고 목적이 명확해 쉽게 재사용할 수 있다. 또한 변경도
용이하다.

public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer,
                               int audienceCount) {
        boolean discountable = checkDiscountable(screening);
        Money fee = calculateFee(screening, discountable, audienceCount);
        return createReservation(screening, customer, audienceCount, fee);
    }

    private boolean checkDiscountable(Screening screening) {
        return screening.getMovie().getDiscountConditions().stream()
                .anyMatch(condition -> condition.isDiscountable(screening));
    }

    private Money calculateFee(Screening screening, boolean discountable,
                               int audienceCount) {
        if (discountable) {
            return screening.getMovie().getFee()
                    .minus(calculateDiscountedFee(screening.getMovie()))
                    .times(audienceCount);
        }

        return  screening.getMovie().getFee();
    }

    private Money calculateDiscountedFee(Movie movie) {
        switch(movie.getMovieType()) {
            case AMOUNT_DISCOUNT:
                return calculateAmountDiscountedFee(movie);
            case PERCENT_DISCOUNT:
                return calculatePercentDiscountedFee(movie);
            case NONE_DISCOUNT:
                return calculateNoneDiscountedFee(movie);
        }

        throw new IllegalArgumentException();
    }

    private Money calculateAmountDiscountedFee(Movie movie) {
        return movie.getDiscountAmount();
    }

    private Money calculatePercentDiscountedFee(Movie movie) {
        return movie.getFee().times(movie.getDiscountPercent());
    }

    private Money calculateNoneDiscountedFee(Movie movie) {
        return movie.getFee();
    }

    private Reservation createReservation(Screening screening,
                                          Customer customer, int audienceCount, Money fee) {
        return new Reservation(customer, screening, fee, audienceCount);
    }
}

응집도 높게 메서드들을 분리하였다. 클래스의 길이는 길어졌지만 일반적으로 명확성의
가치가 클래스의 길이보다 더 중요하다.

안타깝게도 메서드들의 응집도는 높아졌지만 ReservationAgency의 응집도는
여전히 높다. 변경의 이유가 다른 메서드들을 적절한 위치로 분배해야 한다.

객체를 자율적으로 만들자

객체가 자율적인 존재이기 때문에 메서드가 사용하는 데이터를 저장하고 있는
클래스로 메서드를 이동시키는 것이 좋다.

ReservationAgencyisDiscountable 메서드들은 DiscountConditoin
속하는 데이터를 주로 이용한다. 따라서 두 메서드를 데이터가 존재하는 해당 클래스로
이동하고 ReservationAgency에서는 제거하자.

package org.eternity.movie.step05;

import java.time.DayOfWeek;
import java.time.LocalTime;

public class DiscountCondition {
    private DiscountConditionType type;

    private int sequence;

    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public DiscountCondition(int sequence){
        this.type = DiscountConditionType.SEQUENCE;
        this.sequence = sequence;
    }

    public DiscountCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime){
        this.type = DiscountConditionType.PERIOD;
        this.dayOfWeek= dayOfWeek;
        this.startTime = startTime;
        this.endTime = endTime;
    }

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

        return isSatisfiedBySequence(screening);
    }

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

    private boolean isSatisfiedBySequence(Screening screening) {
        return sequence == screening.getSequence();
    }
}

isDiscountable 메서드는 ReservationAgency에 속할 때는 구현의 일부였지만
DiscountCondition으로 이동하며 퍼블릭 인터페이스의 일부가 되었다. 이처럼
메서드를 다른 클래스로 이동시킬 때는 인자에 정의된 클래스 중 하나로 이동하는
경우가 일반적이다.

이제 DiscountCondition 내부에서만 해당 클래스 인스턴스 변수에 접근하므로
모든 접근자 메서드를 제거할 수 있다. 이를 통해 내부 구현을 캡슐화할 수 있다.
이제 ReservationAgency는 할인 여부를 판단하기 위해 DiscountConditon
isDiscountable 메서드를 호출하도록 변경된다.

public class ReservationAgency {
	private boolean checkDiscountable(Screening screening) {
        return screening.getMovie().getDiscountConditions().stream()
                .anyMatch(condition -> condition.isDiscountable(screening));
    }
}

여기에 부가적으로 POLYMORPHISM 패턴과 PROTECTED VARIATIONS 패턴을 차례대로
적용하면 최종 설계와 유사한 모습의 코드를 얻게 될 것이다.

결론적으로 책임 주도 설계에 익숙하지 않다만 우선 데이터 중심으로 구현한 후
이를 리팩토링하더라도 유사한 결과를 얻을 수 있다는 것이다.

profile
도전을 성과로

0개의 댓글