5장 : 책임 할당하기

4장 복습

데이터 중심 설계는 행동보다 데이터를 먼저 결정하고 협력이라는 문맥을 벗어나 고립된 객체의 상태에 초점을 맞추기 때문에 캡슐화를 위반하기 쉽고, 요소들 사이의 결합도가 높아지며, 코드를 변경하기 어려워진다.

객체에 초점을 맞춰서 설계할 때 직면하는 가장 큰 어려움은 어떤 객체에게 어떤 책임을 할당할지를 결정하기가 쉽지 않다는 것이다.

1. 책임 주도 설계를 향해

데이터 중심 설계 → 책임 중심의 설계로 전환

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

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

두 원칙의 핵심은 설계를 진행하는 동안 데이터가 아니라 객체의 책임과 협력에 초점을 맞추라는 것이다.

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

객체에게 중요한 것은 데이터가 아니라 외부에 제공하는 행동이다. 클라이언트의 관점에서 객체가 수행하는 행동이란 곧 객체의 책임을 의미한다. 객체는 협력에 참여하기 위해 존재한다.

데이터는 객체가 책임을 수행하는 데 필요한 재료를 제공할 뿐이다.

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

객체에게 할당된 책임의 품질은 협력에 적합한 정도로 결정된다.

클래스를 결정하고 그 클래스의 책임을 찾아 나서는 대신 메시지를 결정하고,
 이 메시지를 누구에게 전송할지 찾아보게 되었다. 

메시지가 클라이언트의 의도를 표현한다는 사실에 주목하라. 클라이언트는 단지 임의의 객체가 메시지를 수신할 것이라는 사실을 믿고 자신의 의도를 표현한 메시지를 전송할 뿐이다. 그리고 메시지를 수신하기로 결정된 객체는 메시지를 처리할 ‘책임’을 할당 받게 된다.

객체에게 적절한 책임을 할당하기 위해서는 협력이라는 문맥을 고려해야 한다. 협력이라는 문맥에서 적절한 책임이란 곧 클라이언트의 관점에서 적절한 책임을 의미한다. 올바른 객체지향 설계는 클라이언트가 전송할 메시지를 결정한 후에야 비로소 객체의 상태를 저장하는 데 필요한 내부 데이터에 관해 고민하기 시작한다.

✔️ 책임 주도 설계

책임 주도 설계의 흐름

  • 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악한다.

  • 시스템 책임을 더 작은 책임으로 분할한다.

  • 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당한다.

  • 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다.

  • 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력하게 한다.

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

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

  • GRASP은 “General Responsibility Assignment Software Pattern(일반적인 책임 할당을 위한 소프트웨어 패턴)”의 약자로 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합을 패턴 형식으로 정리한 것이다.

✔️ 도메인 개념에서 출발하기

설계를 시작하기 전에 도메인에 대한 개략적인 모습을 그려 보는 것이 유용하다.

중요한 것은 설계를 시작하는 것이지 도메인 개념들을 완벽하게 정리하는 것이 아니다. 도메인 개념을 정리하는 데 너무 많은 시간을 들이지 말고 빠르게 설계와 구현을 진행하라.

도메인 모델은 도메인을 개념적으로 표현한 것이지만 그 안에 포함된 개념과 관계는 구현의 기반이 돼야 한다. 

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

책임 주도 설계 방식의 첫 단계는 애플리케이션이 제공해야 하는 기능을 애플리케이션의 책임으로 생각하는 것이다. 이 책임을 애플리케이션에 대해 전송된 메시지로 간주하고 이 메시지를 책임질 첫 번째 객체를 선택하는 것으로 설계를 시작한다.

메시지는 메시지를 수신할 객체가 아니라 메시지를 전송할 객체의 의도를 반영해서 결정해야 한다. 따라서 첫 번째 질문은 다음과 같다.

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

    • ex. 예매하라

메시지를 결정하면 메시지에 적합한 객체를 선택해야 한다. 두 번 째 질문은 다음과 같다.

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

이 질문에 답하기 위해서는 객체가 상태와 행동을 통합한 캡슐화의 단위라는 사실에 집중해야 한다. 객체의 책임과 책임을 수행하는 데 필요한 상태는 동일한 객체 안에 존재해야 한다. 따라서 객체에게 책임을 할당하는 첫 번째 원칙은 책임을 수행할 정보를 알고 있는 객체에게 핵임을 할당하는 것이다. GRASP에서 이를 INFORMATION EXPERT(정보 전문가) 패턴이라고 부른다. 즉, 자신이 소유하고 있는 정보와 관련된 작업을 수행한다는 일반적인 직관을 표현한 것이다. 여기서 이야기하는 정보는 데이터와 다르다.

✔️ 높은 응집도와 낮은 결합도

동일한 기능을 구현할 수 있는 무수히 많은 걸계가 존재한다.

높은 응집도와 낮은 결합도는 객체에 책임을 할당할 때 항상 고려해야 하는 기본 원리다. 책임을 할당할 수 있는 다양한 대안들이 존재한다면 응집도와 결합도의 측면에서 더 나은 대안을 선택하는 것이 좋다.

GRASP에서는 이를 LOW COUPLING(낮은 결합도) 패턴과 HIGH CONHESION(높은 응집도) 패턴이라고 부른다.

  • LOW COUPOING 패턴

    • 설계의 전체적인 결합도가 낮게 유지되도록 책임을 할당하라

  • HIGH CONHESION 패턴

    • 높은 응집도를 유지할 수 있게 책임을 할당하라

책임을 할당하고 코드를 작성하는 매순간마다 LOW COUPLING과 HIGH COHESION의 관점에서 전체적인 설계 품질을 검토하면 단순하면서도 재사용 가능하고 유연한 설계를 얻을 수 있을 것이다.

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

  • CREATOR 패턴

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

      • B가 A 객체를 포함하거나 참조한다.

      • B가 A 객체를 기록한다.

      • B가 A 객체를 긴밀하게 사용한다.

      • B가 A 객체를 초기화하는 데 필요한 데이터를 가지고 있다(이 경우 B는 A에 대한 정보 전문가다)

CREATOR 패턴의 의도는 어떤 방식으로든 생성되는 개체와 연결되거나 관련될 필요가 있는 객체에 해당 객체를 생성할 책임을 맡기는 것이다. 이미 결합괘 있는 객체에게 생성 책임을 할당하는 것은 설계의 전체적인 결합도에 영향을 미치지 않는다. 결과적으로 CREATOR 패턴은 이미 존재하는 객체 사이의 관계를 이용하기 떄문에 설계가 낮은 결합도를 유지할 수 있게 한다.

3. 구현을 통한 검증

Screening을 구현하는 것으로 시작하자. Screening은 영화를 예매할 책임을 맡으며 그 결과로 Reservation 인스턴스를 생성할 책임을 수행해야 한다. 다시 말해 Screening은 예매에 대한 정보 전문가인 동시에 Reservation의 창조자다.

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

	}
}

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

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

	public Reservation reserve(Customer customer, int sudienceCount) {

	}
}

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

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

	public Reservation reserve(Customer customer, int sudienceCount) {

	}

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

Screening을 구현하는 과정에서 Movie에 전송하는 메시지의 시그니처를 calculateMovieFee(Screening screening)을 통해, 이 메시지가 수신자인 Movie가 아니라 송신자인 Screening의 의도를 표현했다는 것을 알 수 있다. 여기서 중요한 것은 Screening이 Movie의 내부 구현에 대한 어떤 지식도 없이 전송할 메시지를 결정했다는 것이다.

Screening과 Movie를 연결하는 유일한 연결 고리는 메시지 뿐이다. 따라서 메시지가 변경되지 않는 한 Movie에 어떤 수정을 가하더라도 Screening에는 영향을 미치지 않는다.

Screening은 Movie와 협력하기 위해 calculateMovieFee 메시지를 전송한다. Movie는 이 메시지에 응답하기 위해 calculateMovieFee 메서드를 구현해야 한다.

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

	}
}

요금을 계산하기 위해 Movie는 기본 금액(fee), 할인 조건(discountConditions), 할인 정책 등의 정보를 알아야 한다. 현재의 설계에서 할인 정책을 Movie의 일부로 구현하고 있기 때문에 할인 정책을 구성하는 할인 금액(discountAmount)과 할인 비율(discountPercent)을 Movie의 인스턴스 변수로 선언했다. 그리고 현재의 Movie가 어떤 할인 정책이 적용된 영화인지를 나타내기 위한 영화 종류(movieType)를 인스턴스 변수로 포함했다.

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

	private MoneyType movieType;
	private Money discountAmount;
	private double discountPercent;

	public Money calculateMovieFee(Screening screening) {

	}
}

MovieType은 할인 정책의 종류를 나열하는 단순한 열거형 타입이다.

public enum MovieType {
	AMOUT_DISCOUNT,    // 금액 할인 정책 
	PERCENT_DISCOUNT,    // 비율 할인 정책
	NONE_DISCOUNT    // 미적용
}

Movie는 먼저 discountConditions의 원소를 차례대로 순회하면서 DiscountCondition 인스턴스에게 isSatisfiedBy 메시지를 전송해서 할인 여부를 판단하도록 요청한다. 만약 할인 조건을 만족하는 DiscountCondition 인스턴스가 존재한다면 할인 요금을 계산하기 위해 calculateDiscountAmount 메서드를 호출한다. 만약 만족하는 할인 조건이 존재하지 않을 경우 기본 금액 fee를 반환한다.

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

	private MoneyType movieType;
	private Money discountAmount;
	private double discountPercent;

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

	private Money calculatePercentDiscountAmount() {
		return fee.times(discountPercent);
	}

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

Movie는 각 DiscountCondition에 할인 여부를 판단하라 메시지를 전송한다. DiscountCondition은 이 메시지를 처리하기 위해 isSatisfiedBy 메서드를 구현해야 한다.

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

	}
}

DiscountCondition은 기간 조건을 위한 요일(dayOfWeek), 시작 시간(startTime), 종료 시간(endTime)과 순번 조건을 위한 상영 순번(sequence)을 인스턴스 변수로 포함한다. 추가적으로 할인 조건의 종류(type)를 인스턴스 변수로 포함한다. isSatisfiedBy 메서드는 type의 값에 따라 적절한 메서드를 호출한다.

public class DiscountCondition {
	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.getWhenScreend().toLocalTime()) <= 0 &&
			endTime.isAfter(screening.getWhenScreened().toLocalTime()) >= 0;
	}

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

DiscountCondition은 할인 조건을 판단하기 위해 Screning의 상영 시간과 상영 순번을 알아야 한다.

public class Screening {
	public LocalDateTime getWhenScreened() {
		return whenScreended;
	}

	public int getSequence() {
		return sequence;
	}
}

DiscountConditionType은 할인 조건의 종류를 나열하는 단순한 열거형 타입이다.

public enum DiscountConditionType {
	SEQUENCE, // 순번 조건
	PERIOD, // 기간 조건
}

✔️ DiscountCondition 개선하기

가장 큰 문제점은 변경에 취약한 클래스를 포함하고 있다는 것이다. 변경에 취약한 클래스란 코드를 수정해야 하는 이유를 하나 이상 가지는 클래스다.

  • 새로운 할인 조건 추가

  • 순번 조건을 판단하는 로직 변경

  • 기간 조건을 판단하는 로직이 변경되는 경우

DiscountCondition은 하나 이상의 변경 이유를 가지기 때문에 응집도가 낮다. 응집도가 낮다는 것은 서로 연관성이 없는 기능이나 데이터가 하나의 클래스 안에 뭉쳐져 있다는 것을 의미한다. 따라서 낮은 응집도가 초래하는 문제를 해결하기 위해서는 변경의 이유에 따라 클래스를 분리해야 한다.

변경 이유가 하나 이상인 클래스에 드러나느 몇 가지 패턴

  • 인스턴스 변수가 초기화되는 시점을 살펴본다. 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화한다. 따라서 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.

  • 메서드들이 인스턴스 변수를 사용하는 방식을 살펴본다. 모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다고 볼 수 있다. 클래스의 응집도를 높이기 위해서는 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 한다.

클래스 응집도 판단하기

  • 클래스가 하나 이상의 이유로 변경돼야 한다면 응집도가 낮은 것이다. 변경의 이유를 기준으로 클래스를 분리하라.

  • 클래스의 인스턴스를 초기화하는 시점에 경우에 따라 서로 다른 속성들을 초기화하고 있다면 응집도가 낮은 것이다. 초기화되는 속성의 그룹을 기준으로 클래스를 분리하라.

  • 메서드 그룹이 속성 그룹을 사용하는지 여부로 나뉜다면 응집도가 낮은 것이다. 이들 그룹을 기준으로 클래스를 분리하라.

✔️ 타입 분리하기

DiscountCondition의 가장 큰 문제는 순번 조건과 기간 조건이라는 두 개의 독립적인 타입이 하나의 클래스 안에 공존하고 있다는 점이다. 가장 먼저 떠오르는 해결 방법은 두 타입을 SequenceConditio과 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.getWhenScreeded().getDayOfWeek()) &&
			startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
			endTime.compareTo(screening.getWhenScreened().toLocalTime() >= 0);
	}
}

SequnceCondition은 하나의 인스턴스 변수만을 포함하는 간단한 클래스로 분리될 수 있다.

public class SequenceCondition {
	private int sequence;

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

	public boolean isSatidfiedBy(Screening screening) {
		return sequece == screening.getSequence();
	}
}

클래스를 분리한 후에 새로운 문제가 나타났다. 수정 전에는 Movie와 협력하는 클래스는 DiscountCondition 하나뿐이었지만, 수정 후에 Movie의 인스턴스는 SequeceCondition과 PeriodConditio이라는 두 개의 서로 다른 클래스의 인스턴스 모두와 협력할 수 있어야 한다.

✔️ 다형성을 통해 분리하기

Movie 입장에서 보면 SequenceCondition과 PeriodCondition은 아무 차이도 없다.

이 시점이 되면 자연스럽게 역할의 개념이 무대 위로 등장한다. Movie 입장에서 SequenceCondition과 PeriodCondition이 동일한 책임을 수행한다는 것은 동일한 역할을 수행한다는 것을 의미한다.

역할을 사용하면 객체의 구체적인 타입을 추상화할 수 있다. 자바에서는 일반적으로 역할을 구현하기 위해 추상 클래스나 인터페이스를 사용한다.

  • 역할을 대체할 클래스들 사이에서 구현을 공유해야 할 필요가 있따면 **추상 클래스**를 사용하면 된다.

  • 구현을 공유할 필요 없이 역할을 대체하는 개체들의 책임만 정의하고 싶다면 **인터페이스**를 사용하면 된다.

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

SequenceCondition과 PeriodCondition의 인스턴스가 DiscountCondition 인터페이스를 실체화하도록 수정하자.

public class PeriodCondition implements DiscountCondition { ... }

public class SequenceCondition implements DiscountCondition { ... }

이제 Movie는 협력하는 객체의 구체적인 타입을 몰라도 상관없다. 협력하는 객체가 DiscountCondition 역할을 수행할 수 있고 isSatisfiedBy 메시지를 이해할 수 있다는 사실만 알고 있어도 충분하다.

public class Movie {
	private List<DiscountCondition> 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));
	}
}

이처럼 객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당하는 것을 GRASP에서 POLYMORPHISM(다형성) 패턴이라고 부른다.

  • 객체의 타입에 따라 변하는 로직이 있을 때 변하는 로직을 담당할 때, 타입을 명시적으로 정의하고 각 타입에 다형적으로 행동하는 책임을 할당한다.

✔️ 변경으로부터 보호하기

DiscountCondition의 두 서브클래스는 서로 다른 이유로 변경된다. SequenceCondition은 순번 조건의 구현 방법이 변경될 경우에만 수정된다. PeriodCondition은 기간 조건의 구현 방법이 변경될 경우에만 수정된다. 두 개의 서로 다른 변경이 두 개의 서로 다른 클래스 안으로 캡슐화된다.

새로운 할인 조건을 추가하더라도 Movie가 영향을 받지 않는다.

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

  • 변화가 예상되는 불안정한 지점들을 식별하고 그 주위에 안정된 인터페이스를 형성하도록 책임을 할당하라

클래스를 변경에 따라 분리하고 인터페이스를 이용해 변경을 캡슐화하는 것은 설계의 결합도와 응집도를 향상시키는 강력한 방법이다.

  • 하나의 클래스가 여러 타입의 행동을 구현하고 있는 것처럼 보인다면 클래스를 분해하고 POLYMORPHISM 패턴에 따라 책임을 분산시켜라

✔️ Movie 클래스 개선하기

Movie 역시 금액 할인 정책 영화와 비율 할인 정책 영화라는 두 가지 타입을 하나의 클래스 안에 구현하고 있기 때문에 하나 이상의 이유로 변경될 수 있다. 즉 응집도가 낮다.

마찬가지로 POLYMORPHISM 패턴을 사용해 서로 다른 행동을 타입별로 분리하면 다형성의 혜택을 누릴 수 있다. 이렇게 하면 Screening과 Movie가 메시지를 통해서만 다형적으로 협력하기 때문에 Movie의 타입을 추가하더라도 Screening에 영향을 미치지 않게 할 수 있다. 이것은 PROTECTED VARIATIONS 패턴을 이용해 타입의 종류를 안정적인 인터페이스 뒤로 캡슐화할 수 있다는 것을 의미한다.

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

		return fee;
	}

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

	abstract protected Money calculateDiscountAmount();

}

할인 정책의 종류에 따라 할인 금액을 계산하는 로직이 달라져야 한다. 이를 위해 calculateDiscountAmount 메서드를 추상 메서드로 선언함으로써 서브클래스들이 할인 금액을 계산하는 방식을 원하는대로 오버라이딩할 수 있게 했다.

public class AmountDiscountMovie extends Movie {
	private Money discountAmount;

	public AmountDiscountMovie(String title, Duratoin dunningTime,
		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);
	}

}

할인 요금을 계산하기 위해서는 영화의 기본 금액이 필요하다.

public abstract class Movie {
	protected Money getFee() {
		return fee;
	}
}

할인 정책을 적용하지 않기 위해서는 NoneDiscountMovie 클래스를 사용하면 된다.

public class NoneDiscountMovie extends Movie {
	public NoneDiscountMovie(String title, Duration runningTime, MOney fee) {
		super(title, runningTime, fee);
	}

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

모든 클래스의 내부 구현은 캡슐화돼 있고 모든 클래스는 변경의 이유를 오직 하나씩만 가진다. 각 클래스는 응집도가 높고 다른 클래스와 최대한 느슨하게 결합돼 있다. 클래스는 작고 오직 한 가지 일만 수행한다. 책임은 적절하게 분배돼 있다. 이것이 책임을 중심으로 협력을 설계할 때 얻을 수 있는 혜택이다.

✔️ 변경과 유연성

설계를 주도하는 것은 변경이다. 개발자로서 변경에 대비할 수 있는 두 가지 방법이 있다.

  • 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계하는 것

  • 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것

대부분의 경우에 전자가 더 좋은 방법이지만 유사한 변경이 반복적으로 발생하고 있다면 복잡성이 상승하더라도 유연성을 추가하는 두 번째 방법이 더 좋다.

  • 합성

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

4. 책임 주도 설계의 대안

책임과 객체 사이에서 방황할 때 돌파구를 찾기 위해 선택하는 방법은 최대한 빠르게 목적한 기능을 수행하는 코드를 작성하는 것이다. 아무것도 없는 상태에서 책임과 협력에 관해 고민하기 보다는 일단 실행되는 코드를 얻고 난 후에 코드 상에 명확하게 드러나는 책임들을 올바른 위치로 이동시키는 것이다.

주의할 점은 코드를 수정한 후에 겉으로 드러나는 동작이 바뀌어서는 안 된다는 것이다. 캡슐화를 향상시키고, 응집도를 높이고, 결합도를 낮춰야 하지만 동작은 그대로 유지해야 한다. 이처럼 이해하기 쉽고 수정하기 쉬운 소프트웨어로 개선하기 위해 겉으로 보이는 동작은 바꾸지 않은 채 내부 구조를 변경하는 것을 리팩터링(Refactoring)이라고 부른다.

✔️ 메서드 응집도

  • 긴 메서드는 다양한 측면에서 코드의 유지보수에 부정적인 영향을 미친다.

다음은 ReservationAgency를 응집도 높은 메서드들로 잘게 분해한 것이다.

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의 응집도는 여전히 낮다. ReservationAgency의 응집도를 높이기 위해서는 변경의 이유가 다른 메서드들을 적절한 위치로 분배해야 한다. 적절한 위치란 바로 각 메서드가 사용하는 데이터를 정의하고 있는 클래스를 의미한다.

✔️ 객체를 자율적으로 만들자

Last updated