개발자는 기록이 답이다
오브젝트 5장 - 책임 할당하기(2) 본문

3. 구현을 통한 검증
Screening을 구현하는 것으로 시작하자. Screening은 영화를 예매할 책임을 맡으며 그결과로 Reservation 인스턴스를 생성할 책임을 수행해야 한다. 다시 말해 Screening은 예매에 대한 정보 전문가인 동시에 Reservation의 창조자이다.
협력의 관점에서 예매하라 메시지에 응답할 수 있어야 한다.
package chapter5.movie;
public class Screening {
public Reservation reserve(Customer customer, int audiencCount) {}
}
책임이 결정됐으므로 책임을 수행하는데 필요한 인스턴스 변수를 결정해야 한다. 상영 시간, 상연 순번, Movie에 가격을 계산하라 메시지를 전송해야 하기 때문에 Movie에 대한 참조도 포함해야 한다.
package chapter5.movie;
import java.time.LocalDateTime;
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime whenScreened;
public Reservation reserve(Customer customer, int audiencCount) {}
}
영화를 예매하기 위해서는 movie에게 가격을 계산하라 메시지를 전송해서 계산된 영화 요금을 반환받아야 한다. calculateFee메서드는 이렇게 반환된 요금에 예매 인원수를 곱해서 전체 요금을 계산한 후 Reservation을 생성해서 반환한다.
package chapter5.movie;
import java.time.LocalDateTime;
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);
}
// 총 금액 계산
private Money calculateFee(int audienceCount) {
return movie.calculateMovieFee(this).times(audienceCount);
}
}
Screening을 구현하는 과정에서 Movie에 전송하는 메시지의 시그니처를 calculateMovieFee(Screening screening)으로 선언했다는 사실에 주목하라. 이 메시지는 수신자인 Movie가 아니라 송신자인 Screening의 의도를 표현한다. 여기서 중요한 것은 Screening이 Movie내부 구현에 대한 어떠한 지식도 없이 전송할 메시지를 결정했다는 것이다. 이처럼 Movie의 구현을 고려하지 않고 필요한 메시지를 결정하면 Movie의 내부 구현을 깔끔하게 캡슐화 할 수 있다.
이제 Screening과 Moive를 연결하는 유일한 연결고리는 메시지뿐이다. 따라서 메시지가 변경되지 않는 한 Movie에 어떤 수정을 가하더라도 Screening에는 영향을 미치지 않는다. 메시지를 기반으로 협력을 구성하
면 Screening과 Movie사이의 결합도를 느슨하게 유지할 수 있다. 이처럼 메시지가 객체를 선택하도록 책임 주도 설계 방식을 따르면 캡슐화와 낮은 결합도라는 목표를 비교적 손쉽게 달성할 수 있다.
Screening은 Movie와 협력하기 위해 calculateMovieFee메시지를 전송하고, Movie는 해당 메세지에 응답하기 위해 메서드를 구현한다.
package chapter5.movie;
public class Movie {
public Money calculateMovieFee(Screening screening) {}
}
요금을 계산하기 위해 기본금액, 할인 조건, 할인정책등의 정보를 알아야 한다. 현재 설꼐에서 할인 정책을 Movie의 일부로 구현하고 있기 때문에 할인 정책을 구성하는 할인 금액과 할인 비율을 Movie의 인스턴스 변수로 선언했다. 그리고 현재의 Movie가 어떤 할인 정책이 적용된 영화인지를 나타내기 위한 영화 종류를 인스턴스 변수로 포함한다.
package chapter5.movie;
import java.time.Duration;
import java.util.List;
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountCondition;
private MovieType movieType;
private Money discountAmount;
private Money discountPercent;
public Money calculateMovieFee(Screening screening) {
}
}
package chapter5.movie;
public enum MovieType {
AMOUNT_DISCOUNT, // 금액 할인 정책
PERCENT_DISCOUNT, // 비율 할인 정책
NONE_DISCOUNT // 미적용
}
Movie는 먼저 discountCondition의 원소를 차례대로 순회하면서 DiscountCondition 인스턴스에게 IsSatisfiedBy메시지를 전송해서 할인 여부를 판단하도록 요청한다. 만약 할인 조건을 만족하는 DiscountCondition인스턴스가 존재한다면 할인 요금을 계산하기 위해 calculateDiscountAmount메시지를 호출한다. 만약 만족하는 할인 조건이 존재하지 않을 경우 기본 금액인 fee를 반환한다.
package chapter5.movie;
import java.time.Duration;
import java.util.List;
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountCondition;
private MovieType movieType;
private Money discountAmount;
private Money discountPercent;
public Money calculateMovieFee(Screening screening) {
if (isDiscountable(screening)) {
return fee.minus(calculateDiscountAmount());
}
return fee;
}
private boolean isDiscountable(Screening screening) {
return discountCondition.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
}
실제로 할인 요금을 계산하는 calculateDiscountAmount메서드는 movieType의 값에 따라 적절한 메서드를 호출한다.
package chapter5.movie;
import java.time.Duration;
import java.util.List;
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountCondition;
private MovieType movieType;
private Money discountAmount;
private Money discountPercent;
public Money calculateMovieFee(Screening screening) {
if (isDiscountable(screening)) {
return fee.minus(calculateDiscountAmount());
}
return fee;
}
private boolean isDiscountable(Screening screening) {
return discountCondition.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
private Money calculateDiscountAmount() {
switch (movieType) {
case AMOUNT_DISCOUNT:
return calculateAmountDiscountAmount();
case PERCENT_DISCOUNT:
return calculatePercentDiscountAmount();
case NONE_DISCOUNT:
return calculateANoneDiscountAmount();
}
throw new IllegalStateException();
}
private Money calculateAmountDiscountAmount() {
return discountAmount;
}
private Money calculatePercentDiscountAmount() {
return fee.times(discountPercent);
}
private Money calculateNoneDiscountAmount() {
return Money.ZERO;
}
}
Movie는 각 DiscountCondition에 할인 여부를 판단하라 메시지를 전송하고, DiscountCondition은 이 메시지를 처리하기 위해 isSatisfiedBy메서드를 구현한다.
package chapter5.movie;
public class DiscountCondition {
public boolean isSatisfiedBy(Screening screening){
}
}
DiscountCondition은 기간 조건을 위한 요일, 시작 시간, 종료시간, 순번 조건으 ㄹ위한 상영 순번을 인스턴스 변수로 포한한다. 추가적으로 할인 조건의 종류를 인스턴스 변수로 포함한다. isSatisfiedBy메서드는 type값에 따라 적절한 메서드를 호출한다.
package chapter5.movie;
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 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();
}
}
package chapter5.movie;
public enum DiscountConditionType {
SEQUENCE, // 순번 조건
PERIOD // 기간 조건
}
이제 구현이 완료되었다. 하지만 해당 코드에는 문제점이 존재한다.
DiscountCondition 개선하기
가장 큰 문제점은 변경에 취약한 클래스를 포함하고 있다는 것이다. 변경에 취약한 클래스란 코드를 수정해야 하는 이유를 하나 이상 가지는 클래스이다. 그렇다면 현재의 코드에서 변경의 이유가 다양한 클래슨느 무엇일까? 바로 DiscountCondition이다.
1. 새로운 할인 조건 추가
isSatisfiedBy메서드 안의 if ~ else 구문을 수정해야 한다. 물론 새로운 할인 조건이 새로운 데이터를 요구한다면 DiscountCondition에 속성을 추가하는 작업도 필요하다.
2. 순번 조건을 판단하는 로직 변경
isSatisfiedBySequence 메서드 내부 구현을 수정해야 한다. 물론 순번 조건을 판단하는데 필요한 데이터가 변경된다면 DiscountCondition의 sequence 속성 역시 변경 해야 할 것이다.
3. 기간 조건을 판단하는 로직이 변경될 경우
isSatisfiedByPeriod 메서드 내부 구현을 수정해야 한다. 물론 기간 조건을 판단하는 데 필요한 데이터가 변경된다면 DiscountCondition의 dayOfWeek, startTime, endTime 속성 역시 변경해야 한다.
DiscountCondition은 하나 이상의 변경 이유를 가지기 때문에 응집도가 낮다. 응집도가 낮다는 것은 서로 연관성이 없는 기능이나 데이터가 하나의 클래스 안에 뭉쳐져 있다는 것을 의미한다. 따라서 낮은 응집도가 초래하는 문제를 해결하기 위해서 변경의 이유에 따라 클래스를 분리해야 한다.
DiscountCondition안에 구현된 isSatisfiedBySequence메서드와 isSatisfiedByPeriod메서드는 서로 다른 이류로 변경된다.
isSatisfiedBySequence는 순번 조건이 달라질 경우, isSatisfiedByPeriod는 기간 조건에 대한 요구 사항이 달라질 경우에 구현이 변경된다.
두 가지 변경이 코드에 영향을 미치는 시점이 서로 다를 수 있다. 다시 말해 DiscountCondition은 서로 다른 이유로 서로 다른 시점에 변경될 확률이 높다. 서로 다른 이유로 변경되는 두 개의 메서드를 가진 DiscountCondition 클래스의 응집도는 낮아질 수밖에 없다.
지금까지 살펴본 것처럼 일바적으로 설계를 개선하는 작업은 변경의 이유가 하나 이상인 클래스를 찾아내는 것으로부터 시작하는것이 좋다.
문제는 객체지향 설계에 갓 입문한 개발자들은 클래스 안에서 변경의 이유를 찾는 것이 생각보다 어렵다는 것이다.
"변경의 이유"가 하나 이상인 클래스에는 위험 징후를 또렷하게 드러내는 몇 가지 패턴이 존재한다.
1. 인스턴스 변수가 초기화되는 시점
응집도가 높은 클래스는 인스턴스를 생성할때 모든 속성을 함께 초기화한다. 반면에 응집도가 낮은 클래스는 객체의 속성 중 일부만 초기화 하고 일부는 초기화되지 않은 상태로 남겨진다.
DiscountCondition 클래스를 보면 DiscountCondition이 순번 조건을 표현하는 경우 sequence는 초기화 되지만, dayOfWeek, startTime, endTime은 초기화 되지 않는다. 반면에 기간 족너을 표현하는 경우 dayOfWeek, startTime, endTime은 초기화 되지만 sequence는 초기화되지 않는다. 클래스의 속성이 서로 다른 시점에 초기화되거나 일부만 초기화 된다는 것은 응집도가 낮다는 증거이다. 따라서 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.
2. 메서드들이 인스턴스 변수를 사용하는 방식
모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다고 볼 수 있지만, 메서드들이 사용하는 속성에 따라 그룹이 나뉜다면 클래스의 응집도가 낮다고 볼 수 있다. isSatisfiedBySequence메서드는 sequence만 사용하고, isSatisfiedByPeriod메서드 dayOfWeek, startTime, endTime만ㄴ 사용한다. 이 경우 클래스의 응집도를 높이기 위해서는 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리 해야 한다.
🚩 클래스 응집도 판단하기
클래스가 아래와 같은 징후로 몸살을 앓고 있다면 응집도가 낮은 것이다.
☞ 클래스가 하나 이상의 이유로 변경되어야 한다면 응집도가 낮은것이다. 변경의 이유를 기준으로 클래스를 분리하라
☞ 클래스의 인스턴스를 초기화하는 시점에 경우에 따라 서로 다른 속성을 초기화 하고 있다면 응집도가 낮은것이다. 초기화되는 속성의 그룹을 기준으로 클래스를 분리하라
☞ 메서드 그룹이 속성 그룹을 사용하는지 여부로 나뉜다면 응집도가 낮은 것이다, 이들 그룹을 기준으로 클래슬르 분리하라
타입 분리하기
DiscountCondition의 가장 큰 문제는 순번 조건과 기간 조건이라는 두 개의 독립적인 타입이 하나의 클래스 안에 공존하고 있다는 점이다. 가장 먼저 떠오르는 해결 방법은 두 타입을 SeqeunceCondition과 PeriodCondition이라는 두 개의 클래스로 분리하는 것이다.
package chapter5.movie;
import java.time.DayOfWeek;
import java.time.LocalTime;
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;
}
}
package chapter5.movie;
public class SequenceCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
public boolean isSatisfiedBy(Screening screening) {
return sequence == screening.getSequence();
}
}
클래스를 분리하면 앞에서 언급했던 모든 문제점들이 모두 해결된다. SeqeunceCondition과 PeriodCondition은 자신의 모든 인스턴스 변수를 함께 초기화 할 수 있다.
하지만 안타깝게도 클래스를 분리한 후에 새로운 문제가 발생했다. 수정 전에는 Movie와 협력하는 클래스는 DiscountCondition 하나 뿐이었다. 그러나 수정 후에 Movie의 인스턴스는 SeqeunceCondition과 PeriodCondition이라는 두 개의 서로 다른 클래스의 인스턴스 모두와 협력할 수 있어야 한다.

이 문제를 해결하기 위해 생각할 수 있는 첫 번째 방법은 Movie클래스 안에서 SequenceCondtion의 목록과 PeriodCondition목록을 따로 유지하는 것이다.
package chapter5.movie;
import java.util.List;
public class Movie {
private List<PeriodCondition> periodConditions;
private List<SequenceCondition> sequenceConditions;
private boolean isDiscountable(Screening screening) {
return checkPeriodConditions(screening) || checkSequenceConditions(screening);
}
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));
}
}
하지만 이 방법은 새로운 문제를 야기한다.
1. Movie클래스가 PeriodCondition과 SequenceCondition클래스 양쪽에 모두 결합된다는 것이다. 수정 전에는 Movie가 DiscountCondition이라는 하나의 클래스에만 결합돼있었다. 클래스를 분리 한 후 설계 관점에서 전체적인 결합도가 높아진 것이다.
2. 수정 후에 새로운 할인 조건을 추가하기가 더 어려워졌다. 먼저 새로운 할인 조건 클래스를 담기 위한 List를 Movie의 인스턴스 변수로 추가해야 한다. 그리고 이 List를 이용해 할인 조건을 만족하는지 여부를 판단하느나 메소드도 추가해야 한다. 마지막으로 이 메서드를 호출하도록 isDiscountable 메서드를 수정 해야 한다.
3. 클래스를 분리하기 전에는 DiscountCondition 내부 구현만 수정하면 Movie에는 아무런 영향도 미치지 않았다. 하지만 수정 후에는 할인 조건을 추가하려면 Movie도 함께 수정해야 한다. DiscountCondition의 입장에서 보면 응집도가 높아졌지만 변경과 캡슐화 관점에서 보면 전체적으로 설계의 품질이 나빠졌다.
다형성을 통해 분리하기
사실 Movie입장에서 보면 PeriodCondition과 SequenceCondition는 아무 차이도 없다. 둘 모두 할인 여부를 판단하는 동일한 책임을 수행하고 있을 뿐이다. 두 클래스가 할인 여부를 판단하기 위해 사용하는 방법이 서로 다르다는 사실은 Movie입장에서는 그다지 중요하지 않다. 할인 가능 여부를 반환해 주기만 하면 Movie는 객체가 PeriodCondition인지 SequenceCondition인지 상관없다.
이 시점이 되면 자연스럽게 역할의 개념이 등장한다. 역할은 협력 안에서 대체 가능성을 의미하기 때문에 PeriodCondition과 SequenceCondition에 역할의 개념을 적용하면 Movie가 구체적인 클래스는 알지 못한 채 오직 역할에 대해서만 결합되도록 의존성을 제한 할 수 있다.

역할을 사용하면 객체의 구체적인 타입을 추상화할 수 있다. 3장에서 언급했던 것 처럼 일반적으로 자바에서는 역할을 구현하기 위해 추상 클래스나 인터페이스를 사용한다. 역할을 대체할 클래스들 사이에서 구현을 공유해야 할 필요가 있다면 추상클래스, 구현을 공유해야 할 필요가 있다면 인터페이스를 사용하면 된다.
할인 조건의 경우에는 PeriodCondition과 SequenceCondition클래스가 구현을 공유할 필요가 없다 따라서 인터페이스를 이용한다.
package chapter5.movie;
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
package chapter5.movie;
import java.time.DayOfWeek;
import java.time.LocalTime;
public class PeriodCondition implements DiscountCondition {...}
package chapter5.movie;
public class SequenceCondition implements DiscountCondition {...}
이제 Movie는 협력하는 객체의 구체적인 타입을 몰라도 상관없다. 협력하는 객체가 DiscountCondition역할을 수행할 수 있고 isSatisfiedBy메시지를 이해할 수 있다는 사실만 알고 있어도 충분하다.
package chapter5.movie;
import java.time.Duration;
import java.util.List;
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions;
private MovieType movieType;
private Money discountAmount;
private Money discountPercent;
private 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));
}
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가 전송한 메시지를 수신한 객체의 구체적인 클래스가 무엇인가에 따라 적절한 메서드가 실행된다. 만약 메시지를 수신한 객체가 SequeneCondtiion의 인스턴스라면 SequenceCondtiion의 isSatisfiedBy메서드가 실행될 것이다. 만약 메시지를 수신한 객체가 PeriodCondition클래스의 인스턴스라면 PeriodCondition의 isSatisfiedBy메서드가 실행될 것이다.
즉, Movie와 DiscountCondition 사이의 협력은 다형적이다.
객체의 암시적인 타입에 따라 행동을 분기해야 한다면 암시적인 타입을 명시적인 클래스로 정의하고 행동을 나눔으로써 응집도 문제를 해결할 수 있다. 다시 말해 객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당하라는 것이다.
GRASP에서는 이를 POLYMORPHISM(다형성)패턴이라고 부른다.

🚩 POLYMORPHISM패턴
객체의 타입에 따라 변하는 로직이 있을 때 변하는 로직을 담당할 책임을 어더헥 할당해야 하는가? 타입을 명시적으로 정의하고 각 타입에 다형적으로 행동하는 책임을 할당하라.
조건에 따른 변화는 프로그램의 기본 논리이다. 프로그램을 If ~ else 또는 switch ~ case 등의 조건 논리를 사용하여 설계한다면 새로운 변화가 일어난 경우 조건 논리를 수정해야 한다. 이것은 프로그램을 수정하기 어렵고 변경에 취약하게 만든다.
POLYMORPHISM 패턴은 객체의 타입을 검사해서 타입에 따라 여러 대안들을 수행하는 조건적인 논리를 사용하지 말라고 경고한다. 대신 다형성을 이용해 새로운 변화를 다루기 쉽게 확장하라고 권고한다.
변경으로 부터 보호하기
위의 5.6그림을 보면 DiscountCondition의 두 서브 클래스는 서로 다른 이유로 변경된다는 사실을 알 수 없다. 두 개의 서로 다른 변경이 두개의 서로 다른 클래스 안으로 캡슐화 된다. 새로운 할인 조건을 추가할 경우에도 DiscountCondition이라는 추상화가 구체적인 타입을 캡슐화하기 때문에 Movie가 영향을 받지 않는다. 오직 DiscountCondition 인터페이스를 실제화하는 클래스를 추가하는 것만으로 할인 조건의 종류를 확장할 수 있다. 이처럼 변경을 캡슐화하도록 책임을 할당하는 것을 GRASP에서 PROTECTED VARIATIONS(변경 보호)패턴 이라고 부른다.
🚩 PROTECTED VARIATIONS패턴
객체, 서브 시스템, 그리고 시스템을 어떻게 설계해야 변화와 불안정성이 다른 요소에 나쁜 영향을 미치지 않도록 방지할 수 있을까? 변화가 예상되는 불안정한 지점들을 식별하고 그 주위에 안정된 인터페이스를 형성하도록 책임을 할당하라.
PROTECTED VARIATIONS패턴은 책임 할당 관점에서 캡슐화를 설명한 것이다. "설계에서 변하는 것이 무엇인지 고려하고 변하는 개념을 캡슐화하라[GOF94]"라는 객체지향의 오랜 격언은 PROTECTED VARIATIONS패턴의 본질을 잘 설명해준다. 우리가 캡슐화해야 하는 것은 변경이다. 변경이 될 가느성이 높은가? 그렇다면 캡슐화하라
클래스를 변경에 따라 분리하고 인터페이스를 이용해 변경을 캡슐화하는 것은 설계이 결합도와 응집도를 향상시키는 매우 강력한 방법이다. 하나의 클래스가 여러 타입의 행동을 구현하고 있는 것 처럼 보인다면 클래스를 분해하고 POLYMORPHISM 패턴에 따라 책임을 분산시켜라. 예측 가능한 변경으로 인해 여러 클래스들이 불안정하다면 PROTECTED VARIATIONS패턴에 따라 안정적인 인터페이스 뒤로 변경을 캡슐화하라. 적절한 상황에서 두 패턴을 조합하면 코드 수정의 파급 효과를 조절할 수 있고 변경과 확장에 유연하게 대처할 수 있는 설계를 얻을 수 있다.
Movie 클래스 개선하기
Movie 역시 금액 할인 정책 영화와 비율 할인 정책 영화라는 두 가지 타입을 하나의 클래스 안에 구현하고 있기 때문에 하나 이상의 이유로 변경될 수 있다. 한마디로 응집도가 낮은 것이다.
해결 방법은 동일하게 역할의 개념을 도입해서 협력을 다형적으로 만들면 된다. POLYMORPHISM패턴을 사용해 서로 다른 행동을 타입별로 분리하면 다형성의 혜택을 누릴 수 있다. 이렇게 하면 Screening과 Movie가 메시지를 통해서만 다형적으로 협력하기 때문에 Movie을 추가하더라도 Screening에 영향을 미치지 않게 할 수 있다. 이것은 PROTECTED VARIATIONS패턴을 이용해 타입의 종류를 안정적인 인터페이스 뒤로 캡슐화할 수 있다는 것을 의미한다.
Movie의 경우 구현을 공유할 필요가 있기 때문에 추상 클래스를 이용해 역할을 구현한다.
package chapter5.movie;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
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);
}
private 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();
protected Money getFee() {
return fee;
}
}
변경전과 비교해서 discountAmount, discountPercent와 해당 인스턴스 변수를 사용하는 메서드들이 삭제된것을 알 수 있다.
인스턴스 변수들과 메서드들은 Movie역할을 수행하는 적절한 자식 클래스로 옮길 것이다.
할인 정책의 종류에 따라 할인 금액을 계산하는 로직이 달라져야 한다. 이를 위해 calculateDiscountAmount 메서드를 추상 메서드로 선언함으로써 서브 클래스들이 할인 금액을 계산하는 방식을 원하는대로 오버라이딩할 수 있게 되었다.
package chapter5.movie;
import java.time.Duration;
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;
}
}
package chapter5.movie;
import java.time.Duration;
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);
}
}
package chapter5.movie;
import java.time.Duration;
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에서 금액을 반환하는 getFee()매서드를 추가했다. 이 메서드는 서브 클래스에서만 사용해야 하므로 가시성을 public이 아닌 protected로 제한해야 한다.
변경된 구현은 모든 클랫의 내부 구현이 캡슐화 되어있고, 모든 클래스는 변경의 이유를 오직 하나씩만 가진다.
각 클래스는 응집도가 높고 다른 클래스와 최대한 느슨하게 결합돼있다. 클래스는 작고 오직 한가지 일만 수행한다. 책임은 적절하게 분배돼 있다. 이것이 책임을 중심으로 협력을 설계할 때 얻을 수 있는 혜택이다.

데이터 중심의 설계는 정반대의 길을 걷는다. 데이터 중심의 설계는 데이터와 관련된 클래스의 내부 구현이 인터페이스에 여과 없이 노출되기 때문에 캡슐화를 지키기 어렵다. 이로 인해 응집도가 낮고 결합도가 높으며 변경에 취약한 코드가 만들어질 가능성이 높다.
결론은 데이터가 아닌 책임을 중심으로 설계하라는 것이다. 객체에게 중요한 것은 상태가 아니라 행동이다. 객체 지향 설계의 기본은 책임과 협력에 초점을 맞추는 것이다.
🚩 도메인의 구조가 코드의 구조를 이끈다.
그림 5.7의 구조가 이번 장을 처음 시작할 때 소개했던 그림 5.1의 도메인 모델의 구조와 유사하다. 앞에서 설명한 것 처럼 도메인 모델은 단순히 설계에 필요한 용어를 제공하는 것을 넘어 코드의 구조에도 영향을 미친다.
여서 강조하고 싶은 것은 변경 역시 도메인 모델의 일부라는 것이다. 도메인 모델에는 도메인 안에서 변하는 개념과 이들 사이의 관계까 투영돼 있어야 한다. 그림 5.1의 도메인 모델에는 할인 정책과 할인 조건이 변경될 수 있따는 도메인에 대한 직관이 반영돼 있다. 그리고 이 직관이 우리 설계가 가져야 하는 유연성을 이끌었다.
다시 강조하지만 구현을 가이드할 수 있든 모데인 모델을 선태갛라. 객체지향은 도메인의 개념과 구조를 반영한 코드를 가능하게 만들기 때문에 도메인의 구조가 코드의 구조를 이끌어 내는 것은 자연스러울뿐만 아니라 바람직한 것이다.
변경과 유연성
설계를 주도하는 것은 변경이다. 개발자로서 변경에 대비할 수 있는 2가지 방법이 있다.
1. 코드를 이해하고 수정하기 쉽도록 최대한 단순히 설계하는 것
2. 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것
대부분의 경우에 전자가 더 좋은 방법이지만 유사한 변경이 반복적으로 발생하고 있다면 복잡성이 상승하더라도 유연성을 추가하는 2번째 방법이 더 좋다.
예를 들어, 영화에 설정된 할인 정책을 실행 중에 변경할 수 있어야 한다는 요구사항이 추가됐다고 가정하자.
현재의 설계에서는 할인 정책을 구현하기 위해 상속을 이용하고 있기 때문에 실행 중에 영화의 할인 정책을 변경하기 위해서는 새로운 인스턴스를 생성한 후 필요한 정보를 복사해야한다. 또한 변경 전 후의 인스턴스가 개념적으로는 동일한 객체를 가리키지만 물리적으로 서로 다른 객체이기 때문에 식별자의 관점에서 혼란스러울 수 있다.
새로운 할인 정책이 추가될 때마다 인스턴스를 생성하고, 상태를 복사하고, 식별자를 관리하는 코드를 추가하는 일은 번거로울뿐만 아니라 오류가 발생하기도 쉽다. 이 경우 코드의 복잡성이 높아지더라도 할인 정책의 변경을 쉽게 수용할 수 있게 코드를 유연하게 만드는 것이 더 좋은 방법이다.
해결 방법은 상속 대신 합성을 사용하는 것이다. 아래 그림과 같이 Movie의 상속 계층안에 구현된 할인 정책을 독립적인 DiscountPolicy로 분리한 후 Movie에 합성시키면 유연한 설계가 완성된다. 이것이 바로 2장에서 살펴본 영화 예매 시스템의 전체 구조이다.

이제 금액 할인 정책이 적용된 영화를 비율 할인 정책으로 바꾸는 일은 Movie에 연결된 DiscountPolicy의 인스턴스를 교체하는 단순한 작업으로 바뀐다.
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10),
new PeriodCondition(DayOfWeek.MONDAY, LocalTime.of(10,0), LocalTime.of(11,59)),
new PeriodCondition(DayOfWeek.THURSDAY, LocalTime.of(10,0), LocalTime.of(20,59))));
avatar.changeDiscountPolicy(new PercentDiscountPolicy(0.1));
합성을 사용한 예제의 경우 새로운 할인 정책이 추가되더라도 할인 정책을 변경하는데 필요한 추가적인 코드를 작성할 필요가 없다. 새로운 클래스를 추가하고 클래스의 인스턴스를 Movie의 changeDiscountPolicy메서드에 전달하면 된다.
이러한 예시는 유연성에 대한 압박이 설계에 어떤 영향을 미치는지 잘 보여준다. 실제로 유연성은 의존성 관리의 문제다.
요소들 사이의 의존성의 정도가 유연성의 정도를 결정한다. 유연성의 정도에 따라 결합도를 조절할 수 있는 능력은 객체지향 개발자가 갖춰야 하는 중요한 기술 중 하나다.
'기술 서적 > OOP' 카테고리의 다른 글
오브젝트 7장 - 객체 분해 (0) | 2024.01.05 |
---|---|
오브젝트 4장 - 설계 품질과 트레이드오프(2) (0) | 2023.12.12 |
오브젝트 5장 - 책임 할당하기(1) (1) | 2023.12.09 |
오브젝트 4장 - 설계 품질과 트레이드오프(1) (1) | 2023.12.02 |
오브젝트 3장 - 역할, 책임, 협력(2) (0) | 2023.11.28 |