책임할당은 일종의 트레이드 오프 활동이다.
어떤 할당이 최선인지는 문맥과 상황에 따라 달라진다.
이번장에서 다룰 GRASP 패턴은 응집도, 결합도, 캡슐화 등의 기준에 따라 책임을 할당하고
결과를 트레이드 오프하는 기준을 배울 수 있게 도와줄 것이다.
1. 책임 주도 설계를 향해
책임 중심 설계의 2원칙
1. 데이터보다 행동을 우선하라
2. 협력이라는 문맥 안에서 책임을 결정하라
데이터보다 행동을 우선하라
- 데이터의 행동 = 객체의 책임이다
- 데이터란 책임을 수행하는데 필요한 재료일 뿐이다
- 먼저 책임을 떠올리고, 그 책임에 필요한 데이터를 생각하라
협력이라는 문맥 안에서 책임을 결정하라
- 책임의 품질 == 협력에 적합한 정도
- 메세지를 선택한 후, 그 메세지를 처리할 객체를 선택하라(메세지를 전송해야 하는데 누구에게 전송해야 하지?)
>> 메세지를 먼저 결정하는 것의 의의
- 메세지 송신자는 수신자에 대해 어떠한 가정도 할 수 없게 된다
=> 메세지 전송자 관점에서 수신자가 깔끔히 캡슐화
=> 캡슐화로 부터 오는 낮은 결합도와 높은 응집도
2. GRASP 패턴
: 책임 할당의 지침을 패턴으로 정리한 것
도메인 개념에서 출발하기
- 설계를 시작 하기 전 도메인의 개략적 모습을 그려보는 것이 유용하다
- 시작하는 단계에서는 개념들의 의미와 관계가 정확할 필요가 없다
- 도메인 모델은 구현을 염두에 두고 구조화하는 것이 바람직하다
=> 우리는 도메인을 그대로 투영한 모델이 아니라 구현의 기반이 되는 모델이 필요하다
정보 전문가에게 책임을 할당하라
정보 전문가 패턴 : 책임을 수행할 정보를 알고 있는 객체에게 책임을 할당
- 정보를 알고 있다 != 정보를 저장한다
=> 정보를 제공하는 다른 객체를 알고 있거나 필요한 정보를 계산할 수도 있다
2가지를 반복해야 한다
- 어떤 메세지가 필요한가?
- 메세지를 수신할 객체는 누구인가?
영화 예매 시스템으로 돌아가 다음 과정을 반복해보자
1. 예매하라는 메세지 => Screening에게 할당
- 어떤 메세지가 필요한가? : 예매하라
- 메세지를 수신할 객체는 누구인가? : 상영(상영순번, 영화 정보, 상영 시간 등을 알고 있음)
그러나, Screening은 할인 정보를 모르기 때문에 1인당 예매 가격을 계산할 수 없다.
이는 외부에 도움을 요청해야 함을 의미한다. 여기서 새로운 메세지가 결정되고 이 메세지가 새로운 객체의 책임이 된다.
2. 가격을 계산하라는 메세지 => Movie에게 할당
- 어떤 메세지가 필요한가? : 가격을 계산하라
- 메세지를 수신할 객체는 누구인가? : 영화(기본요금, 할인 정책을 알고 있음)
영화는 할인 조건에 따라 영화가 할인 가능한지 판단할 수 없다
따라서, 할인 여부를 판단하는 메세지를 전송해야 한다
3. 할인 여부를 판단하라는 메세지 => DiscountCondition에게 할당
- 어떤 메세지가 필요한가? : 할인 여부를 판단하라
- 메세지를 수신할 객체는 누구인가? : 할인 조건(어떤 조건이 만족되어야 할인이 되는지 알고 있음)
=> 순번이나 기간 조건 중에 하나라도 만족한다면 Movie가 할인된 요금을 계산하고, Screening이 예약을 진행한다
높은 응집도와 낮은 결합도
만약 Screening이 직접 DiscountCondition에게 할인 여부를 판단하라고 전송하면 어떨까?
- Screneing이 DiscountCondition으로부터 할인여부를 판단받는다
- Screening이 할인여부에 따라 Movie에게 가격을 계산하라고 한다
그러나 다음 설계는 낮은 결합도와 높은 응집도 패턴에 있어 어긋나는 부분이 있다
비교
Movie와 DiscountCondition의 협력 | Screening이 직접 DiscountCondition과 협력 |
![]() |
![]() |
결합도 측면
- Movie와 DiscountCondition은 이미 결합되어 있어 설계 전체적으로 결합도를 추가하지 않아도 됨
- 그러나, Screening이 직접 DiscountCondition과 협력하게 하려면, 새로운 결합도가 추가됨
응집도 측면
- Screening이 직접 협력하면 영화 요금 관련 책임을 Screening이 지게 됨
- 즉, Screening이 할인여부를 판단할 수 있어야 하며, Movie가 매개변수로 할인여부를 필요로 하게 됨
=> 이는 예매요금을 계산하는 방식이 변경된다면 Screening도 변경 필요성이 생기는 것임
=> 따라서 서로 다른 이유로 변경되는 책임을 짊어지게 되므로 응집도가 낮아짐
CREATOR 패턴 : 이미 결합되어 있는 객체에게 생성 책임 이전
- 객체 baby를 생성해야 할때 어떤 객체에게 책임을 할당해야 하는가?
- 아래 조건을 최대한 많이 만족하는 creator에게 할당하라
- Creator가 baby 객체를 포함하거나 참조한다
- Creator가 baby 객체를 기록한다
- Creator가 baby 객체를 긴밀히 사용한다
- Creator가 baby 객체를 초기화하는데 필요한 정보를 가지고 있다
=> 어떤 방식으로든 결합될 필요성이 있는 객체에게 생성책임을 맡기는 것이다
ex) Screening의 Reservation 생성
=> 예약에 필요한 영화, 상영시간, 상영 순번에 대한 정보 전문가이기에
Screening이 Reservation 초기화에 필요한 정보를 가장 많이 가지고 있다
3. 구현을 통한 검증
이제 차근차근 책임 주도 설계를 시작해보자
- Screening 객체는 예매하라라는 책임을 맡고 있다
public class Screening {
public Reservation reserve(Customer customer, int audienceCount) {
}
}
- 예매를 위해서는 상영시간(whenScreened) / 상영 순번(sequence)을 포함해야 한다
- Movie에게 가격을 계산해라 라는 메시지 전송을 위해 Movie 참조도 포함하자
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime 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);
}
private Money calculateFee(int audienceCount) {
return movie.calculateMovieFee(this).times(audienceCount);
}
}
- moive의 메서드 명을 calculateMovieFee로 설정했음에 주목하자
-이는 수신자가 아니라 송신자 Screening의 의도를 표현한다
- Movie의 구현을 고려하지 않고 필요한 메세지를 전한 것이기도 하다
-이제 Movie의 calculateMovieFee를 구현하자
- 영화 가격 계산을 위해서는 기본 금액, 할인 조건, 할인 정책 등의 정보를 알아야 한다
Movie {
private String title; // 제목
private Duration runningTime; // 상영 시간
private Money fee; // 금액
private List<DiscountCondition> discountConditions; // 할인 조건들
private MovieType movieType; // 할인 정책의 종류 : 금액/ 비율/ 없음
private Money discountAmount; // 할인 금액
private double discountPercent; // 할인 비율
}
- Movie는 할인 조건을 순회하면서 만족하는 조건이 있는지 확인한다
- 만약 있다면, 할인 정책에 따라 요금을 계산하여 반환한다
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;
}
}
- 이제 할인 조건인 discountCondition으로 넘어오자
- 각 할인 조건을 만족하는지 살펴보기 위해서 isSatisfiedBy 메서드를 구현해야 한다
public class DiscountCondition {
public boolean isSatisfiedBy(Screening screening) {...}
}
- DiscountCondition은 요일(dayOfWeek), 시작시간(startTime), 종료시간(endTime) 과 순번 조건을 위한 상영순번(sequence)을 포함한다
- 할인 조건의 종류(type)또한 인스턴스 변수로 포함한다
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 개선하기
위의 코드는 변경에 취약하다는 문제를 포함한다
1) 새로운 할인 조건 추가
- 할인 조건을 만족하는지 판단하는 isSatisFiedBy에 새로운 if문이 들어가야 한다
2) 순번 조건을 판단하는 로직 변경
- DiscountCondition의 sequence 속성 변경
3) 기간 조건을 판단하는 로직 변경
- dayOfWeek, startTime, endTime 변경 필요
=> 하나 이상의 변경 이유를 가진다
낮은 응집도의 문제를 해결하기 위해서는 변경 이유에 따라 클래스를 분리해야 한다
일반적으로 설계를 개선하는 작업은 변경의 이유가 하나 이상인 클래스를 찾는 것이다
이를 발견하는 방법은 다음과 같다
1) 인스턴스 변수가 초기화되는 시점을 살펴본다
- 응집도가 높은 인스턴스는 모든 속성을 한번에 초기화한다
- 응집도가 낮다면 초기화되지 않는 속성을 포함한다 ex) DiscountCondition이 기간조건이면 sequence가 초기화 x
=> 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다
2) 메서드들이 인스턴스 변수를 사용하는 방식
- 모든 메서드가 객체의 모든 속성을 사용한다면 응집도가 높다
- 메서드들이 사용하는 방식에 따라 그룹이 나뉜다면 클래스의 응집도가 낮다
ex) isSatisFiedBySequence : sequence만 사용한다
ex) isSatisFiedbyPeriod : dayOfWeek, starttime, endTime만 사용한다
정리 : 낮은 응집도 판단하기
1) 변경 이유가 한 개 이상일 때
2) 인스턴스 변수 초기화 시점에 초기화되지 않는 속성이 있을 때
3) 메서드에 따라 사용하는 속성 그룹이 나뉠 때 / isSatisFiedBySequence - isSatisFiedByPeriod
타입 분리하기
DiscountCondition의 문제는 순번조건과 기간조건이 하나의 클래스에 합쳐져 있다는 것이다
따라서, 각각 PeriodCondition , SequenceCondition으로 분리할 필요가 있다
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;
}
public boolean isSatisfiedBy(Screening screening) {
return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
}
}
이제 위의 문제가 해결되었다
- 변경의 이유가 한개 이상인가? => x
- 초기화시 사용하지 않는 속성이 있는가? => x
- 메서드에 따라 사용하는 속성이 나뉘는가? => x
다형성을 통해 분리하기
- Movie입장에서 기간조건/순번조건은 할인 여부를 판단하는 동일한 책임을 수행할 뿐이다
- DiscontCondition으로 역할에 대해서만 결합되도록 의존성을 제한할 수 있다
- 구현을 공유할 필요가 있다면 추상클래스로, 책임만 정의하고 싶다면 인터페이스를 사용하자
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
public class PeriodCondition implements DiscountCondition
public class SequenceCondition implements DiscountCondition
이제 Movie는 할인 조건이 순번조건인지 기간조건인지 몰라도 된다.
객체의 타입에 따라 변하는 행동에 대해 타입을 분리하고
변화하는 행동을 각 타입의 책임으로 할당했기 때문이다.
이를 다형성 패턴이라고 부른다
4. 책임 주도 설계의 대안
그러나, 우리가 한번에 객체지향적 사고를 하고 완벽한 코드를 작성하는 것은 불가능에 가깝다
따라서, 이런 방법도 있다
1) 돌아가는 쓰레기를 만든다
2) 코드 상에 드러나는 책임을 올바른 위치로 옮긴다 => 리팩터링
리팩터링 체험하기
- 데이터 중심으로 정의된 ReservationAgency을 리팩터링해보자
우선 ReservationAgency를 다시 살펴보면 다음과 같다
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);
}
}
이 메서드는 다음 문제를 포함한다
1) 가독성이 구리다
2) 수정 포인트 탐색이 어렵다
3) 버그 확률이 높다
4) 재사용이 불가하다
5) 코드 중복을 초래하기 쉽다
이런 메서드를 몬스터 메서드라고 부른다
이렇게 응집도가 낮은 메서드는 주석이 필요한 경우가 대부분이다
따라서, 메서드를 작게 분해해서 각 메서드의 응집도를 높일 필요가 있다
>> 짧고 이해하기 쉬운 메서드의 장점
1) 재사용성이 높아진다
2) 고수준 메서드를 볼 때 일련의 주석을 읽는 느낌을 줄 수 있다
3) 오버라이딩이 쉬워진다
다음은 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);
}
}
클래스의 길이가 길어졌지만 각 메서드의 명확성의 가치가 길이보다 중요하다
이제 고수준의 reserve 메서드가 어떻게 변경되었는지 보자
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);
}
}
=> 가독성이 좋아졌다
=> 변경하기도 더 쉬워졌다
즉, 메서드를 잘게 쪼개어 응집도 높은 클래스가 되도록 리팩터링 하는 일은 중요하다
'기술 서적' 카테고리의 다른 글
[오브젝트] ch4. 설계 품질과 트레이드 오프 / 데이터 중심 설계의 문제점 (0) | 2024.04.11 |
---|---|
[오브젝트] ch3. 역할, 책임, 협력 (0) | 2024.04.11 |
[오브젝트] ch2. 객체지향 프로그래밍 (0) | 2024.04.09 |
[오브젝트] ch1. 객체, 설계 (0) | 2024.04.07 |
[스터디] 이펙티브 자바 - item4. 인스턴스화를 막으려거든 private 생성자를 사용하라 (0) | 2024.03.12 |