데이터 중심 설계는 객체들 간의 협력을 고려하기 보다는 상태를 우선적으로 고려하기 때문에 제대로된 캡슐화를 하지 못한다. 이는 결국 높은 결합도를 야기하고 코드 변경이 어려워진다. 따라서 객체지향 설계에서는 책임 중심 설계가 적절하다.
책임 중심 설계에서 가장 어려운 부분은 어떤 객체에게 어떤 책임을 할당할 것인가 이다. 책임 할당 과정은 일종의 트레이드오프이다. 동일한 문제를 해결할 수 있는 다양한 책임 할당 방법이 존재하며 상황에 따라 최적의 책임 할당 방식이 다르다. 따라서 최적의 책임 할당을 위해서 다양한 관점에서 설계를 평가할 수 있어야 한다.
GRASP 패턴은 책임 할당의 어려움을 해결해 준다. GRASP 패턴을 이해하면 응집도, 결합도, 캡슐화 같은 다양한 기준에 따라 책임을 할당하고 결과를 트레이드오프할 수 있는 기준을 알게 된다.
책임 주도 설계를 향해
책임 중심 설계를 하기 위해서는 다음과 같은 두 가지 원칙을 따라야 한다.
1. 데이터보다 행동을 먼저 결정하라.
2. 협력이라는 문맥 안에서 책임을 결정하라.
데이터 보다는 행동을 먼저 결정하라.
클라이언트 관점에서 객체가 수행하는 행동은 객체의 책임을 의미한다. 객체는 협력에 참여하기 위해 존재하며 협력 안에서 수행하는 책임이 객체의 존재가치를 증명한다. 상태는 오직 객체가 책임을 수행하는데 필요한 재료를 제공하는 역할만 한다.
객체지향에서 적절한 책임을 할당하는 가장 기본적인 방법은 우선적으로 "이 객체가 수행해야 하는 책임은 무엇인가?"라는 질문을 하는것이다. 이 질문이 객체가 책임을 수행하는데 필요한 상태를 결정한다.
협력이라는 문맥 안에서 책임을 결정하라.
"객체에게 어떤 책임을 할당해야 하나?"라는 질문의 실마리는 협력에 있다.
객체에게 할당된 책임의 품질은 협력에 적합한 정도로 평가한다. 즉, 책임은 객체의 입장에서 객체에게 적절한지를 보고 결정하는 것이 아닌 객체가 참여하는 협력에 적합한지를 보고 결정해야 한다. 이 사실은 객체의 책임을 어떻게 식별해야 하는가에 대한 힌트를 제공한다. 협력을 시작하는 주체는 메시지 전송자이기 때문에 협력에 적합한 책임이란 메시지가 수신자가 아니라 메시지 전송자에게 적합한 책임을 의미한다. 즉, 메시지를 전송하는 클라이언트의 의도에 적합한 책임을 할당해야 한다.
객체간의 협력에서 메시지 전송은 객체가 존재하기 때문에 메시지를 전송하는 것이 아닌 메시지가 존재하기 때문에 그 메시지를 처리할 객체에게 메시지를 전송하는 것이다. 즉, 메시지를 결정한 후 객체를 선택해야 적합한 책임을 할당할 수 있다.
클라이언트는 어떤 객체가 메시지를 수신할지 알지 못한다. 단지 임의의 객체가 메시지를 수신할 것이라는 사실을 믿고 자신의 의도를 표현한 메시지를 전송할 뿐이다. 그리고 메시지를 수신하고 결정된 객체는 메시지를 처리할 '책임'을 할당받게 된다. 이때문에 메시지를 먼저 결정하면 메시지 전송자는 메시지 수신자에 대한 정보를 알 수 없고 이는 올바른 캡슐화가 이루어 졌는 의미이다.
책임 할당을 위한 GRASP 패턴(General Responsibility Assignment Software Pattern - 일반적인 책임 할당을 위한 소프트웨어 패턴)
GRASP 패턴은 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합을 패턴 형식으로 정리한 것이다.
2장에서 다뤘던 영화 예매 시스템을 통해 GRASP 패턴을 살펴보자.
도메인 개념에서 출발하기
설계를 시작하기 전에 도메인에 대한 개략적인 모습을 그리면 설계가 수월해 진다. 도메인 안에는 무수히 많은 개념들이 존재한다. 이런 도메인 개념들을 책임 할당의 대상으로 사용하면 코드에 도메인의 모습을 투영하기 쉬워진다.
영화 예매 시스템의 도메인 개념은 다음과 같은 구조를 가지고 있다.
설계를 시작하는 단계에서 개념들의 의미와 관계가 정확하거나 완벽할 필요는 없다. 이 단계에서는 오직 객체들의 종류와 관계에 대한 유용한 정보를 제공할 수 있으면 된다. 도메인 개념을 정리하는 데 너무 많은 시간을 들이지 말고 빠르게 설계와 구현으로 넘어가라.
올바른 도메인 모델은 존재하지 않는다. 그저 도메인 모델에서 올바른 구현을 이끌어 낼 수만 있으면 된다. 도메인 모델은 구현과 관련있다. 도메일 모델 안에 포함된 개념과 관계는 구현의 기반이 된다. 따라서 도메인 모델이 구현을 염두에 두고 구조화되야 한다. 반대로 코드의 구조가 도메인을 바라보는 관점을 바꾸기도 한다.
정보 전문가에게 책임을 할당하라
책임 주도 설계 방식의 첫 단계는 애플리케이션이 제공해야 하는 기능을 애플리케이션의 책임으로 생각하는 것이다. 그 후 이 책임을 애플리케이션에 대해 전송된 메시지로 간주하고 이 메시지를 책임질 첫 번째 객체를 선택하는 것으로 설계를 시작한다.
영화 예매 시스템에서 사용자에게 제공해야 하는 기능은 영화를 예매하는 것이다. 따라서 이 시스템은 영화를 예매할 책 임이 있다. 이제 이 책임을 수행할 메시지를 결정해야 하는데 메시지는 메시지를 전송할 객체의 의도를 반영해 결정해야 한 다. 따라서 "메시지를 전송할 객체는 무엇을 원하는가?"라는 질문을 내려야 한다. 협력을 시작하는 객체는 미정이지만 객체 가 원하는 것은 영화를 예매하는 것임을 알 수 있다. 따라서 메시지를 "예매하라"라고 하자.
이제 "메시지를 수신할 적합한 객체는 누구인가?"라는 질문에 답해보자. 우선 객체는 상태와 행동을 통합한 캡슐화 단위이 다. 따라서 객체의 책임과 책임을 수행하는데 필요한 상태가 동일 객체에 있어야 한다. 따라서 객체에게 책임을 할당하는 첫번째 원칙은 책임을 수행할 정보를 알고 있는 객체에게 할당하는 것이다. GRASP에서는 이를 Information Expert(정 보전문가)패턴 이라 한다. 정보 전문가 패턴을 따르면 정보와 행동을 최대한 가까운 곳에 위치시키기 때문에 캡슐화를 유지 할 수 있다.
정보 전문가 패턴은 객체가 자신이 소유하고 있는 정보와 관련된 작업을 수행한다는 것이다. 여기서 말하는 정보는 데이 터와 다르다. 책임을 수행하는 객체가 정보를 알고 있다고 해서 그 정보를 저장하고 있지는 않다. 객체는 해당 정보를 제공 하는 다른 객체를 알거나 정보를 계산해야 제공할 수 있다.
정보 전문가 패턴에 따르면 "예매하라"라는 메시지를 예매에 대한 정보를 가장 잘 알고 있는 객체가 처리하게 해야한다. 따라서 영화 정보, 상영 시간, 상영 순번 등 영화 예매에 대해 가장 잘 아는 '상영'이라는 도메인에게 예매를 위한 책임을 할 당하자.
이제 Screening의 내부로 들어가 메시지를 처리하기 위해 필요한 절차와 구현을 고민해 보자. 지금은 개략적인 수준에서 객체들의 책임을 결정하는 단계이기 때문에 그저 책임을 수행하는 데 필요한 작업을 구상해보고 스스로 처리할 수 없는 작 업이 무엇인지를 가리면 된다.
만약 스스로 처리할 수 없는 작업이 존재한다면 외부에 도움을 요청해야 한다. 이 도움은 새로운 메시지가 되고 최종적으 로 이 메시지가 새로운 객체의 책임으로 할당된다. 이런 연쇄적인 메시지의 전송과 수신을 통해 객체 공동체가 만들어진다.
영화 예매를 완료하기 위해서는 예매 가격을 계산해야 한다. 이때 영화 한 편의 가격, 예매 인원수가 필요한데 Screening 은 이를 알지 못한다. 따라서 영화 가격을 계산하는데 필요한 정보를 알고 있는 '영화'(Movie)객체에게 책임을 할당하자.
Movie가 금액을 계산하기 위해서는 할인 가능 여부와 할인 정책에 따른 할인 적용 후의 가격을 알아야 한다. 하지만 Movie는 이에 대한 정보가 없기 때문에 이책임을 할인 조건(DiscountCondition)에게 위임하자.
높은 응집도와 낮은 결합도
설계는 트레이드 오프이며 여러 개의 설계 중 하나를 선택해야 하는 경우가 빈번히 발생한다. 따라서 Information Expert pattern 외에 다른 책임 할당 패턴들을 함께 고려해야 한다.
만약 설계를 위와 같이 하지 않고 Screening이 직접 DiscountCondition과 협력해 할인 여부를 판단하고 반환받은 할인 여부를 Movie에 전송하는 메시지의 인자로 사용한다면 어떨까?
이런 설계를 하게 되면 결합도가 높아진다. 도메인 설계를 보면 Movie는 DiscountCondition의 목록을 속성을 포함하고 있다. 따라서 위와 같은 설계는 또 다른 의존성을 추가하기 때문에 기존 설계보다 결합도가 높아진다. 따라서 Movie가 DiscountCondition과 상호작용 하는 것이 더 좋은 설계이다.
위 예제에서 볼 수 있듯이 여러개의 설계가 존재한다면 응집도와 결합도를 기준으로 더 나은 선택을 해야 한다. GRASP에 서는 이를 Low Coupling pattern, High Cohesion pattern이라 한다.
Low coupling 패턴 관점에서 위의 설계는 새로운 결합도가 추가되기 때문에 좋지 않다.
High Cohesion 패턴 관점에서도 이 설계를 봐보자. Screening은 예매를 생성하는 책임이 씨다. 이때 Screening이 DisocuntCodition과 협력해야 한다면 Screening은 영화 요금 계산과 관련된 책임 일부를 떠안아야 한다. 이러면 Screeening은 DiscountCondition이 할인 여부를 판단할 수 있고 Movie가 이 할인 여부를 필요로 한다는 사실 역시 알 아야 한다. 즉, 요금을 계산하는 방식이 변경되면 Screening역시 변경되야 한다.
반면 Movie는 영화 요금을 계산하는 책임을 가지고 있기 때문에 영화 요금을 계산하는 데 필요한 할인 조건을 판단하기 위해 Movie가 DiscountCodition과 협력하는 것은 응집도에 대한 영향을 주지 않는다.
결론적으로 책임을 할당하고 코드를 작성하는 매순간마다 Low Coupling pattern과 High Cohesion pattern 관점에 서 전체적인 설계 품질을 검토해야 단순하고 재사용 가능한 설계를 얻을 수 있다.
창조자에게 객체 생성 책임을 할당하라
영화 예매 협력의 최종 결과물은 Resevation 인스턴스를 생성하는 거다. 즉, 협력에 참여하는 어떤 객체는 Reservation 인스턴스를 생성할 책임을 가져야 한다. 이렇게 객체를 생성할 책임을 어떤 객체에게 할당할지를 정할때는 GRASP의 Creator(창조자)패턴을 사용하면 된다.
Creator pattern
객체 A를 생성해야 할 때 아래의 조건을 최대한 많이 만족하는 객체 B에게 객체 생성 책임을 할당하라.
1. B가 A객체를 포함하거나 참조한다.
2. B가 A객체를 기록한다.
3. B가 A객체를 긴밀하게 사용한다.
4. B가 A객체를 초기화하는 데 필요한 데이터를 가지고 있다(이 경우 B는 A에 대한 정보 전문가다.
Creator pattern의 의도는 어떤 방식으로든 생성되는 객쳉와 연결되거나 관련될 필요가 있는 객체에 해당 객체를 생성 할 책임을 맏기는 거다. 즉, 두 객체는 서로 결합된다. 이미 결합돼 있는 객체에게 생성 책임을 할당하는 것은 설계의 전체 적인 결합도에 영향을 미치지 않는다. 결과적으로 Creator pattern은 이미 존재하는 객체 사이의 관계를 이용하기 때문. 에 설계가 낮은 결합도를 유지할 수 있게 한다.
Reservation을 잘 알고 있거나, 긴밀하게 사용하거나, 초기화에 필요한 데이터를 가지고 있는 객체는 Screening이다. Screening은 예매에 필요한 정보와 예매 요금을 계산하는 데 필요한 Movie 역시 알고있다. 따라서 Screening을 Reservation의 Creator로 선택하는 것이 좋다.
현재까지의 책임 분배는 설계를 시작하기 위한 대략적인 스케치이다. 실제 설계는 코드를 작성하는 동안 이뤄진다. 이제 코드를 작성해 보자.
구현을 통한 검증
Screening을 구현해 보자. Screening은 영화를 예매할 책임과 Reservation 인스턴스를 생성할 책임을 수행해야 한다. 따라서 Screening은 예매하라 라는 메시지에 응답할 수 있어야 하므로 이에 대한 메서드를 만들자.
책임이 결정됐으므로 책임을 수행하는 데 필요한 인스턴스 변수를 결정하자. Screening은 상영 시간, 상영 순번을 인스턴스 변수로 포함한다. 또 한 Movie에 가격 계산에 대한 메시지를 보내야 하므로 Movie의 인스턴스를 가지고 있어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime whenScreened;
public Reservation reserve(Custormer customer, int audienceCount) {
return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
}
private Money calculateFee(int audienceCount) {
// Movie에 전송하느 메시지의 시그니처를 calculateMovieFee(Screening screening)으로 선언했다.
// 이 메시지는 수신자인 Movie가 아니라 송신자인 Screening의 의도를 표현한다.
return movie.calculateMovieFee(this);
}
}
|
cs |
이와 같이 두 객체의 연결고리를 메시지로만 설정하면 결합도나 느슨해진다.
이제 Movie가 Screening이 요청한 메시지에 응답하기 위한 메서드를 구현하자. Movie가 요금을 계산하기 위해서는 기본 금액, 할인 조건, 할인 정책 등의 정보를 알아야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
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) {
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;
}
}
|
cs |
Movie는 isDiscountable()메서드에서 DiscountCondition에게 할인 가능 여부를 판단하라는 메시지를 보낸다. 따라서 isSatisfiedBy()메서드를 DiscountCondition에 구현하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
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 == DisocuntConditionType.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()) >= 0;
}
private boolean isSatisfiedBySequence(Screening screening) {
return sequence == screening.getSequence();
}
}
|
cs |
DiscountCondition 개선하기
여러개의 이유로 변경이 가능한 클래스는 변경에 취약하다. DiscountCondition은 다음과 같은 이유들로 변경될 수 있다.
1. 새로운 할인 조건 추가
2. 순번 조건을 판단하는 로직 변경
3. 기간 조건을 판단하는 로직 변경
이렇게 변경 이유가 많다는 의미는 낮은 응집도를 가지고 있다는 의미이다. 응집도가 낮다는 것은 서로 연관성 없는 기능이나 데이터가 하나의 클래스 안에 뭉쳐져 있다는 것을 의미한다. 따라서 변경의 이유에 따라 클래스를 분리해야 한다.
이렇게 변경에 대한 이유가 많으면 DiscountCondition은 서로 다른 이유로, 서로 다른 시점에 변경될 확률이 높다.
일반적으로 설계를 개선하는 작업은 변경의 이유가 여러개인 클래스를 찾는 것 부터 시작해야 한다. 클래스에 존재하는 여러 변경 사유를 찾기 위해선 다음과 같은 몇 가지 패턴을 이해하면 된다.
인스턴스 변수가 초기화 되는 시점. 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화 한다. DiscountCondition의 경우 조건이 순번이면 seuqence가, 조건이 기간이면 dayOfWeek, startTime, endTime이 초기화 된다. 따라서 함께 초기화 되는 속성을 기준으로 코드를 분리해야 한다.
메서드들이 인스턴스 변수를 사용하는 방식. 모든 메서드가 객체의 모든 속성을 사용하면 클래스의 응집도는 높다. 반면 메서드들이 사용하는 속성에 따라 그룹이 나뉘면 클래스의 응집도가 낮다. DiscountCondition에서 isSatisfiedByPeriod는 dayOfWeek, startTime, endTime을 사용하지만 sequence를 사용하지 않는다. 따라서 응집도를 높이기 위해서는 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 한다.
종합하지면 다음과 같은 케이스가 클래스에 존재한다면 클래스의 응집도가 낮은 것이다.
1. 클래스가 하나 이상의 이유로 변경돼야 한다면 응집도가 낮은 것이다. 변경의 이유를 기준으로 클래스를 분리하라.
2. 클래스의 인스턴스를 초기화 한느 시점에 경우에 따라 서로 다른 속성들을 초기과하고 있다면 응집도가 낮다. 초기화 되는 속성의 그룹을 기준으로 클래스를 분리하라.
3. 메서드 그룹이 속성 그룹을 사용하는지 여부로 나뉜다면 응집도가 낮다. 이들 그룹을 기준으로 클래스를 분리하라.
타입 분리하기
이제 위에서 설명한 방식을 바탕으로 DiscountCondition을 개선해 보자. 우선 순번 조건과 기간 조건을 두 개의 서로 다른 클래스로 분리하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
public class SequenceCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
public boolean isSatisfiedBy(Screening screening) {
return sequence == screening.getSequence();
}
}
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;
}
private boolean isSatisfiedByPeriod(Screening screening) {
return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek())
&& startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0
&& endTime.isAfter(screening.getWhenScreened().toLocalTime()) >= 0;
}
}
|
cs |
DiscountCondition을 두 개의 클래스로 불리해서 개별 클래스들의 응집도가 상승했다. 하지만 클래스를 나눈 덕분에 Movie 인스턴스는 SequenceCondition, PeriodCondition 총 두 개의 인스턴스를 알아야 한다. 즉, 개별적인 응집도가 상승함에 따라 전체적인 결합도 역시 상승했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
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));
}
}
|
cs |
다형성을 통해 분리하기
Movie의 입장에서 PeriodCondition과 SequenceCondition은 모두 할인 여부 판단을 위해 사용된다. 따라서 Movie는 할인 여부에 두 조건이 존재한다는 사실을 알 필요가 없다. 이럴때 역할을 상요하면 된다. 역할은 협력 안에서 대체 가능성을 의미한다. 이 개념을 적용하면 타입을 추상화해 Movie가 오직 역할에 대해서만 결합되게 할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
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));
}
}
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
public class SequenceCondition implements DiscountCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
@Override
public boolean isSatisfiedBy(Screening screening) {
return sequence == screening.getSequence();
}
}
public class PeriodCondition implements DiscountCondition {
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;
}
@Override
public boolean isSatisfiedBy(Screening screening) {
return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek())
&& startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0
&& endTime.isAfter(screening.getWhenScreened().toLocalTime()) >= 0;
}
}
|
cs |
이 방식은 객체의 암시적인 타입에 따라 생동을 분기해야 한다면 암시적인 타입을 명시적인 클래스로 정의하고 행동을 나눔으로써 응집도 문제를 해결하는 방식이다. GRASP에서는 이를 Polymorphism 패턴이라 한다.
변경으로부터 보호하기
DiscountCondition을 두 서브클래스로 나누었기 때문에 SequenceCondition과 PeriodCondition은 모두 하나의 변경 사유만 가지고 있다. 그떄문에 Move 관점에서 새로운 DiscountCondition 타입이 추가되어도 영향을 받지 않는다. 또 한 SequenceCondition과 PeriodCondition에 변경 사유가 생겨도 하나의 클래스 안에서만 변경이 이루어진다. 이처럼 변경을 캡슐화하도록 책임을 할당하는 것을 GRASP에서 Protected Variations Pattern이라 한다.
Protected Viarations Pattern은 설계에서 변하는 것이 무엇인지를 고려하고 개념을 캡슐화 한다. 클래스를 변경에 따라 분리하고 인터페이스를 이용해 변경을 캡슐화하는 것은 설계의 결합도와 응집도를 향상시키는 좋은 방법이다.
하나의 클래스가 여러 타입의 행동을 구현하고 있다면 Polymorphism pattern에 따라 책임을 분산하라. 예측 가능한 변경으로 인해 클래스가 불안정하다면 Protected Variations pattern으로 인터페이스 뒤고 변경을 캡슐화 하라.
Movie 클래스 개선
Movie 클래스 역시 DiscountCondition과 같은 문제가 있다. 따라서 위에서 설명한 Polymorphism pattern과 Protected Variations pattern으로 Movie 클래스를 개선하자.
우선 MovieType별로 클래스를 생성해 Movie 타입 추가로 인해 Screening을 수정하는 일이 없도록 하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
|
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, List<DiscountCondition> discountConditions) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountConditions = 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();
/* 삭제
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 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;
}
}
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 class NoneDiscountMovie extends Movie {
public NoneDiscountMovie(String title, Duration runningTime, Money fee) {
super(title, runningTime, fee);
}
@Override
protected Money calculateDiscountAmount() {
return Money.ZERO;
}
}
|
cs |
최종적으로 개선한 코드는 다음과 같은 구조를 갖게 된다.
위 구조를 보면 맨 처음 설계했던 도메인 보델과 상당히 유사하다는 것을 알 수 있다. 따라서 도메인 모델은 단순 설계에 필요한 용어를 제공하는 것을 넘어 코드의 구조에도 영향을 미친다. 따라서 도메인 모델에는 도메인 안에서 변하는 개념과 이들 사이의 관계가 투영되 있어야 한다. 영화 예매 시스템의 도메인 모델을 보면 discount condition과 discount policy 조건이 변경될 수 있다는 직관이 반영되 있다.
변경과 유연성
설계를 주도하는 것은 변경이다. 개발자가 변경에 대비할 수 있는 방법으로는 두 가지가 존재한다.
1. 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계한다.
2. 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만든다.
대부분의 경우 전자가 유용하지만 반복적으로 유사한 변경이 발생한다면 복잡도 상승을 감안하고 유연성을 추가하는 후자를 선택하는 것이 좋다.
위 영화 설계 시스템에서 영화에 설정된 할인 정책을 실행 중에 변경할 수 있어야 한다는 요구사항이 추가됐다고 해보자. 현재의 설계에서는 할인 정책을 구현하기 위해 상속을 이용하고 있다. 따라서 새로운 할인 정책이 추가될 때마다 인스턴스를 생성하고, 상태를 복사하고, 식별자를 관리하는 코드를 추가해야 하기에 매우 번거롭고 오류 발생 확률이 크다.
이 경우 상속 대신 합성을 상요하는 편이 좋다. 코드의 복잡성이 높아지더라도 할인 정책의 변경을 쉽게 수용할 수 있게 코드를 유연하게 만들어야 한다.
이제 금액 할인 정책이 적용된 영화를 비율할인 정책으로 바꾸는 것이 쉬워졌다.
1
2
3
4
5
|
Movie movie = new Movie("movieTitle",
Duration.ofMinute(120),
Money.wons(10000),
new AmountDisocuntPolicy(...));
movie.changeDiscountPolicy(new PercentDiscountPolicy(...));
|
cs |
이 예시는 요소들 사이의 의존성의 정도가 유연성의 정도를 결정함을 시사한다.
코드의 구조가 도메인의 구조에 대한 새로운 통찰력을 제공한다.
위에서 언급했듯이 도메인 모델은 구현과 밀접한 관련이 있다. 도메인 모델은 코드에 대한 가이드를 제공해야 하며 코드의 변화에 따라 함께 변해야 한다. 따라서 위 처럼 유연성을 위해 코드가 바뀌었다면 도메인 모델 역시 다음과 같이 바뀌어야 한다.
책임 주도 설계의 대안
만약 위의 설명을 읽었음에도 책임을 할당하는 일이 어렵다면 우선 작동되는 코드를 만들고 리팩토링 하는 것도 좋은 선택이다. 즉, 절차형 코드를 작성한 후 완성된 코드를 객체지향 코드로 바꾸는 거다. 이러면 코드상에 책임이 명확하게 드러나고 이런 책임들을 올바른 위치에 위치시키면 된다.
이 방법을 사용할때는 코드를 수정한 후에 겉으로 드러나는 동작이 바뀌면 안된다는 점을 주의해야 한다. 캡슐화를 향상시키고, 응집도를 높이고, 결합도를 낮춰야 하지만 동작은 유지되야 한다.
이 방식을 설명하기 위해 4장에서 처음 작성했던 ReservationAgency 코드를 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
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);
} else {
fee = movie.getFee();
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
|
cs |
이런 긴 코드는 다음과 같은 부정정인 영향을 미친다.
1. 어떤 일을 수행하는지 한눈에 파악하기 어려워서 코드를 전체적으로 이해하는 데 너무 많은 시간이 투자된다.
2. 하나의 메서드 안에서 너무 많은 작업을 처리하기 때문에 변경이 필요할 때 수정해야 할 부분을 찾기 어렵다.
3. 메서드 내부의 일부 로직만 수정해도 메서드의 나머지 부분에서 버그가 발생할 확률이 높다.
4. 로직의 일부만 재사용하는 것은 불가능 하다.
5. 코드를 재사용하는 유일한 방법은 원하는 코드를 복사해서 붙여넣는 것뿐이라 코드 중복을 초래하기 쉽다.
이런 응집도가 낮은 코드는 흐름을 이해하기 위해 주석이 필요하다. 주석을 다는 행위는 꼭 필요한 경우가 아니라면 핑계에 불과하다. 따라서 주석을 다는 대신 메서드를 작게 분해해서 각 메서드의 응집도를 높여라.
메서드의 응집도를 높이는 이유는 클래스의 응집도를 높여야 하는 이유와 같다. 메서드가 짧고 이애하기 쉬우면 다음과 같은 장점이 생긴다.
1. 짧은 메서드는 다른 메서드에서 사용될 확률이 높다.
2. 고수준의 메서드를 보는 것은 일련의 주석을 보는 것과 같다.
3. 오버라이딩이 쉽다.
객체로 책임을 분배할 때 가장 먼저 할 일은 메서드를 응집도 있는 수준으로 분해해야 한다. 긴 메서드를 작고 응집도 높은 메서드로 분리해야 각 메서드를 적절한 클래스로 이동하기 쉬워진다.
이제 위에서 설명한 것들을 이용해 ReservationAgency의 reserve메서드를 분할해 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
|
public class ReservationAgency {
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
boolean discountable = checkDiscountable(screening);
Money fee = calculateFee(screening, discoutable, audienceCount);
return createReservation(screening, customer, audienceCount, fee);
}
private boolean checkDiscountable(Screening screening) {
return screening.getMovie().getDiscountConditions().stream()
.anyMatch(condition -> disDiscountable(condition, screening));
}
private boolean isDiscountable(DiscountCondition condition, Screening screening) {
if(condition.getType() == DiscountConditionType.PERIOD) {
return isSatisfiedByPeriod(condition, screening);
}
return isSatisfiedBySequence(condition, screening);
}
private boolean isSatisfiedByPeriod(DiscountCondition discountCondition, Screening screening) {
return screening.getWhenScreened()
.getDayOfWeek()
.equals(condition.getDayOfWeek())
&& condition.getStartTime()
.compareTo(screening.getWhenScreened().toLocalTime()) <= 0
&& condition.getEndTime()
.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
}
private boolean isSatisfiedBySequence(DiscountCondition discountCondition, Screening screening) {
return condition.getSequence() == screening.getSequence();
}
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().times(audienceCount);
}
private Money calculateDiscountedFee(Movie movie) {
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;
}
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 Money.ZERO;
}
private Reservation createReservation(Screening screening, Customer customer,
int audienceCount, Money fee) {
return new Reservation(customer, screening, fee, audienceCount);
}
}
|
cs |
이렇게 reseve메서드를 여러 메서드로 분할하고 나니 메서드들이 각자 어떤 역할을 하는지 한눈에 들어온다. 코드를 작은 메서드들로 분할하면 전체적인 흐름을 쉽게 이해할 수 있고 한 번에 기억해야 하는 정보의 양을 줄일 수 있다. 더 세부적인 내용을 봐야 한다면 해당 메서드를 확인하면 된다.
작고, 명확하며, 한 가지 일에 집중하는 응집도 높은 메서드는 변경 가능한 설계를 이끌어 내는 기반이 된다. 이런 메서드들이 하나의 변경 이유를 가지도록 개선될 떄 결과적으로 응집도 높은 클래스가 만들어진다.
메서드들 간의 응집도가 높아졌음에도 클래스 자체의 응집도는 여전히 낮다. 따라서 변경 이유에 따라 메서드들을 적절한 위치로 분배하자.
객체를 자율적으로 만들자
객체는 자율적인 존재여야 한다. 객체가 자율적이기 위해서는 자신이 소유하고 있는 데이터를 자기 스스로 처리해야 한다. 이를 기준으로 객체를 나눠보자.
ReservationAgency의 isDiscountable, isSatisfiedByPeriod, isSatisfiedBySequence메서드들은 모두 DiscountCondition에 속한 데이터를 주로 이용한다. 따라서 이들을 DiscountCondition으로 이동시키자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime 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)
&& condition.getStartTime()
.compareTo(screening.getWhenScreened().toLocalTime()) <= 0
&& condition.getEndTime()
.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
}
private boolean isSatisfiedBySequence(Screening screening) {
return sequence == screening.getSequence();
}
}
|
cs |
이 분할을 통해 DiscountCondition의 내부 구현을 캡슐화 했고 응집도가 높아졌다. ReservationAgency는 내부 구현을 노출하는 접근자를 사용하지 않고 메시지를 통해서만 DiscountCodition과 협력하기 때문에 낮은 결합도를 유지한다.
위의 DIsocuntCondition은 책임 주도 설계 방법을 적용해 구현한 DiscountCondition 클래스의 초기 모습 처럼 되었다. 이제 여기서 POLYMORPHISM pattern과 PROTECTED pattern을 사용하면 책임 주도 설계의 최종 모습과 유사해질 것이다.
출처 - 오브젝트
'도서 > 오브젝트' 카테고리의 다른 글
Chapter 7. 객체 분해 (0) | 2022.02.02 |
---|---|
Chapter 6. 메시지와 인터페이스 (0) | 2022.01.22 |
Chapter 4. 설계 품질과 트레이드오프 (0) | 2022.01.10 |
Chapter 3. 역할, 책임, 협력 (0) | 2022.01.06 |
Chapter 2. 객체지향 프로그래밍 (0) | 2022.01.03 |