이번 장에서 살펴볼 GRASP 패턴은 책임 할당의 어려움을 해결하기 위한 답을 제시해줄 것이다.
데이터 중심 설계에서 책임 중심의 설계로 전환하기 위해 다음 두 가지 원칙을 따라야 한다.
책임 중심 설계에서는 "이 객체가 수행해야 하는 책임은 무엇인가"를 결정한 후에 "이 책임을 수행하는 데 필요한 데이터는 무엇인가"를 결정한다.
객체에게 할당된 책임의 품질은 협력에 적합한 정도로 결정된다.
객체에게 적절한 책임을 할당하기 위해서는 협력이라는 문맥을 고려해야 한다. 협력이라는 문맥에서 적절한 채임이란 곧 클라이언트의 관점에서 적절한 책임을 의미한다.
다음은 3장에서 설명한 책임 주도 설계의 흐름을 다시 나열한 것이다.
그림 5.1은 영화 예매 시스템을 구성하는 도메인 개념과 개념 사이의 관계를 대략적으로 표현한 것이다.
책임 주도 설계 방식의 첫 단계는 애플리케이션이 제공해야 하는 기능을 애플리케이션의 책임으로 생각하는 것이다. 이 책임을 애플리케이션에 대해 전송된 메시지로 간주하고 이 메시지를 책임질 첫 번째 객체를 선택하는 것으로 설계를 시작한다.
사용자에게 제공해야 하는 기능은 영화를 예매하는 것이다. 이를 책임으로 간주하면 애플리케이션은 영화를 예매할 책임이 있다고 말할 수 있다. 이제 이 책임을 수행하는 데 필요한 메시지를 결정해야 한다. 메시지는 메시지를 수신할 객체가 아니라 메시지를 전송할 객체의 의도를 반영해서 결정해야 한다.
따라서 첫 번째 질문은 다음과 같다.
협력을 시작하는 객체는 미정이지만 이 객체가 원하는 것은 분명해 보인다. 바로 영화를 예매하는 것이다. 따라서 메시지의 이름으로는 예매하라가 절절한 것 같다.
메시지를 결정했으므로 메시지에 적합한 객체를 선택해야 한다. 두 번째 질문은 다음과 같다.
이 질문에 답하기 위해서는 객체가 상태와 행동을 통합한 캡슐화의 단위라는 사실에 집중해야 한다. 따라서 객체에게 책임을 할당하는 첫 번째 원칙은 책임을 수행할 정보를 알고 있는 객체에게 책임을 할당하는 것이다. GRASP에서는 이를 INFORMATION EXPERT(정보 전문가) 패턴이라고 부른다.
위 설계는 기능적인 측면에서만 놓고 보면 Movie
와 DiscountCondition
이 직접 상호작용하는 앞의 설계와 동일하다. 차이점이라면 DiscountCondition
과 협력하는 객체가 Movie
가 아니라 Screening
이라는 것뿐이다.
우리는 그럼 왜 Movie
와 협력하는 방법을 선택한 것일까?
그 이유는 응집도와 결합도에 있다. 높은 응집도와 낮은 결합도는 객체에 책임을 할당할 때 항상 고려해야 하는 기본 원리다. 책임을 할당할 수 있는 다양한 대안들이 존재한다면 응집도와 결합도의 측면에서 더 나은 대안을 선택하는 것이 좋다.
영화 예매 협력의 최종 결과물은 Reservation
인스턴스를 생성하는 것이다. 이것은 협력에 참여하는 어떤 객체에게는 Reservation
인스턴스를 생성할 책임을 할당해야 한다는 것을 의미한다. CREATOR(창조자) 패턴은 이 같은 경우에 사용할 수 있는 책임 할당 패턴으로서 객체를 생성할 책임을 어떤 객체에게 할당할지에 대한 지침을 제공한다.
Reservation
을 잘 알고 있거나, 긴밀하게 사용하거나, 초기화에 필요한 데이터를 가지고 있는 객체는 무엇인가? 바로 Screening
이다.
public class Screening {
public Reservation reserve(Customer customer, int audienceCount) {
}
}
책임이 결정됐으므로 책임을 수행하는 데 필요한 인스턴스 변수를 결정해야 한다. Screening
상영 시간(whenScreend)과 상영 순번(sequence)을 인스턴스 변수로 포함한다. 또한 Movie
에 가격을 계산하라 메시지를 전송해야 하기 때문에 영화(movie)에 대한 참조도 포함해야 한다.
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 Reservation reserve(Customer customer, int audienceCount) {
}
}
영화를 예매하기 위해서는 movie에게 가격을 계산하라 메시지를 전송해서 계산된 영화 요금을 반환받아야 한다. calculateFee
메서드는 이렇게 반환된 요금에 예매 인원 수를 곱해서 전체 예매 요금을 계산한 후 Reservation
을 생성해서 반환한다.
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime whenScreened;
public Reservation reserve(Customer customer, int audienceCount) {
return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
}
public Money calculateFee(int audienceCount) {
switch (movie.getMovieType()) {
case AMOUNT_DISCOUNT -> {
if (movie.isDiscountable(whenScreened, sequence)) {
return movie.calculateAmountDiscountedFee().times(audienceCount);
}
}
case PERCENT_DISCOUNT -> {
if (movie.isDiscountable(whenScreened, sequence)) {
return movie.calculatePercentDiscountedFee().times(audienceCount);
}
}
case NONE_DISCOUNT -> {
return movie.calculateNoneDiscountFee();
}
}
return movie.calculateNoneDiscountFee();
}
Screening
을 구현하는 과정에서 Movie
에 전송하는 메시지의 시그니처를 calculateMovieFee(Screening screening)
으로 선언했다는 사실에 주목하라. 이 메시지는 수신자인 Movie
의 내부 구현에 대한 어떤 지식도 없이 전송할 메시지를 결정했다는 것이다. 이처럼 Movie
의 구현을 고려하지 않고 필요한 메시지를 결정하면 Movie
의 내부 구현을 깔끔하게 캡슐화할 수 있다.
Screening
은 Movie
와 협력하기 위해 calculateMovieFee
메시지를 전송한다. Movie
는 이 메시지에 응답하기 위해 calculateMovieFee
메서드를 구현해야 한다.
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) {
}
}
public enum MovieType {
AMOUNT_DISCOUNT,
PERCENT_DISCOUNT,
NONE_DISCOUNT
}
public class Move {
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;
}
}
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.getWhenScreened().toLocalTime()) <= 0 &&
endTime.isAfter(screening.getWhenScreened().toLocalTime());
}
private boolean isSatisfiedBySequence(Screening screening) {
return sequence == screening.getSequence();
}
}
DiscountCondition
은 할인 조건을 판단하기 위해 Screening
의 상영 시간과 상영 순번을 알아야 한다. 두 정보를 제공하는 메서드를 Screening
에 추가하자.
public class Screening {
public LocalDateTime getWhenScreened() {
return whenScreened;
}
public int getSequence() {
return sequence;
}
}
DiscountConditionType
은 할인 조건의 종류를 나열하는 단순한 열거형 타입이다.
public enum DiscountConditionType {
SEQUENCE, // 순번 조건
PERIOD // 기간 조건
}
가장 큰 문제점은 변경에 취약한 클래스를 포함하고 있다는 것이다. 변경에 취약한 클래스란 코드를 수정해야 하는 이유를 하나 이상 가지는 클래스다. 그렇다면 현재의 코드에서 변경의 이유가 다양한 클래스는 무엇인가? 바로 DiscountCondition
이다. DiscountCondtion
은 다음과 같이 서로 다른 세 가지 이유로 변경될 수 있다.
DiscountCondition
의 문제점인 순번, 기간 조건을 분리해보자.
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;
}
}
public class SequenceCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
public boolean isSatisfiedBy(Screening screening) {
return sequence == screening.getSequence();
}
}
새로운 문제점은 Movie
인스턴스가 새롭게 생긴 두 가지의 조건 클래스와 협력해야 한다.
이 문제를 해결하기 위해 생각할 수 있는 방법은 Movie
클래스 안에 두 가지의 컨디션 목록을 유지하는 것이다.
public class Movie {
private List<PeriodCondition> periodConditions;
private List<SequenceCondition> sequenceConditions;
private boolean checkPeriodConditions(Screening screening) {
return periodConditions.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
private boolean checkSequenceConditions(Screening screening) {
return sequenceConditions.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
}
이 문제는 새로운 문제를 야기한다. Movie
클래스와 두 가지의 클래스에 결합된다. 두 번째 문제는 수정 후 새로운 할인 조건을 추가하기 어렵다.
Movie
입장에서는 두 가지 할인조건 클래스는 아무 차이가 없다. 둘 다 할인 여부를 판단하는 동일한 책임을 수행할 뿐이다. 동일한 책임을 수행한다는 건 동일한 역할을 수행한다는 것을 의미한다. 역할은 협력 안에서 대체 가능성을 의미한다. 즉 Movie
가 구체 클래스는 알지 못한 채 역할에 대해서만 결합되도록 하는 것이다.
public class PeriodCondition implements DiscountCondition {
// ...
}
public class SequenceCondition implements DiscountCondition {
// ...
}
@Getter
@Setter
public class Movie {
private List<DiscountCondition> conditions;
private boolean checkPeriodConditions(Screening screening) {
return conditions.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
private boolean checkSequenceConditions(Screening screening) {
return conditions.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
}
이제 Movie
는 협력하는 객체의 구체적인 타입을 몰라도 된다.
Movie
가 전송한 메시지를 수신한 객체의 구체적인 클래스가 무엇인가에 따라 적절한 메서드가 실행된다.
객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당하라는 것이다. 이를 POLYMORPHISM(다형성) 패턴이라고 부른다.
새로운 할인 조건을 추가하더라도 Movie
가 영향을 받지 않는다. Movie
에 대한 어떤 수정도 필요 없다. 이처럼 변경을 캡슐화 하도록 책임을 할당하는 것을 GRASP 에서는 PROTECTED VARIATIONS(변경 보호) 패턴이라고 부른다.
Movie
역시 DiscountCondition
과 동일하게 금액 할인 정책 영화와 비율 할인 정책 영화라는 두 가지 타입을 하나의 클래스 안에 구현하고 있다.
다형성과 변경 보호를 통해 인터페이스 뒤로 캡슐화할 수 있다.
코드를 개선하자.
@Getter
@Setter
public abstract 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 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();
}
변경 전의 Movie
클래스와 비교해서 discountAmount, discountPercent와 이 인스턴스 변수들을 사용하는 메서드들이 삭제됐다.
금액 할인 정책과 관련된 인스턴스 변수와 메서드를 AmountDiscountMovie
클래스로 옮기자.
public class AmountDiscountMovie extends Movie {
private Money discountAmount;
public AmountDiscountMovie(String title, Duration runningTime, Money fee, DiscountCondition... discountConditions) {
super(title, runningTime, fee, discountConditions);
}
@Override
protected Money calculateDiscountAmount() {
return discountAmount;
}
}
비율 할인 정책은 PercentDiscountMovie
클래스에서 구현한다.
public class PercentDiscountMovie extends Movie {
private double percent;
public PercentDiscountMovie(String title, Duration runningTime, Money fee, DiscountCondition... discountConditions) {
super(title, runningTime, fee, discountConditions);
}
@Override
protected Money calculateDiscountAmount() {
return getFee().times(percent);
}
}
할인 요금을 계산하기 위해서는 영화의 기본 금액이 필요하다. 이를 위해 Movie
에서 금액을 반환하는 getFee()
메서드를 추가하자.
@Getter
@Setter
public abstract class Movie {
protected Money getFee() {
return fee;
}
}
할인 정책을 적용하지 않기 위해서는 NoneDiscountMovie
클래스를 사용하면 된다. 이 경우 calculateDiscountAmount
메서드는 0원 반환하다.
public class NoneDiscountMovie extends Movie {
public NoneDiscountMovie(String title, Duration runningTime, Money fee) {
super(title, runningTime, fee);
}
@Override
protected Money calculateDiscountAmount() {
return Money.ZERO;
}
}
이제 모든 구현이 끝났다. 그림 5.7은 지금까지 구현된 영화 시스템의 구조를 나타낸 것이다. 모든 클래스의 내부 구현은 캡슐화 되어 있고 모든 클래스는 변경의 이유를 오직 하나씩만 가진다. 각 클래스는 응집도가 높고 다른 클래스와 최대한 느슨하게 결합돼 있다. 클래스는 작고 오직 한 가지 일만 수행한다. 책임은 적절하게 분배돼 있다. 이것은 책임을 중심으로 협력을 설계할 때 얻을 수 있는 혜택이다.
데이터 중심의 설계는 정반대 길을 걷는다. 데이터 중심의 설계는 데이터와 관련된 클래스의 내부 구현이 인터페이스에 여과 없이 노출되기 때문에 캡슐화를 지키기 어렵다. 이로 인해 응집도가 낮고 결합도가 높으며 변경에 취약한 코드가 만들어질 가능성이 높다.
설계를 주도하는 것은 변경이다. 개발자로서 변경에 대비할 수 있는 방법은 두 가지다. 하나의 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계하는 것이다. 다른 하나는 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것이다.
현재 설계에서는 할인 정책을 구현하기 위해 상속을 이용하고 있기 때문에 실행 중에 영화의 할인 정책을 변경하기 위해서는 새로운 인스턴스를 생성한 후 필요한 정보를 복사해야 한다.
새로운 할인 정책이 추가될 때마다 인스턴스를 생성하고, 상태를 복사하고, 식별자를 관리하는 코드를 추가하는 일은 번거로울뿐만 아니라 오류가 발생하기도 쉽다. 이 경우 코드의 복잡성이 높아지더라도 할인 정책의 변경을 쉽게 할 수 있게 코드를 유연하게 만드는 것이 더 좋은 방법이다.
해결 방법은 상속 대신 합성을 사용하는 것이다. 그림 5.8과 같이 Movie
의 상속 계층 안에 구현된 할인 정책을 독립적인 DiscountPolicy
로 분리한 후 Movie
에 합성시키면 유연한 설계가 완성된다. 이것이 바로 2장에서 살펴본 영화 예매 시스템의 전체 구조다.
여기서는 4장 초반에 개발한 데이터 중심 설계를 리팩터링하는 과정을 통해 이 방법의 장점을 설명하겠다.
객체로 책임을 분배할 때 가장 먼저 할 일은 메서드를 응집도 있는 수준으로 분해하는 것이다.
어떤 메서드를 어떤 클래스로 이동시켜야 할까? 메서드가 사용하는 데이터를 저장하고 있는 클래스로 메서드를 이동시키면 된다.