반응형
오브젝트 05 - 책임 할당하기
5.1 책임 주도 설계를 향해
5.1.1 협력이라는 문맥 안에서 책임을 결정하라
- 객체에게 할당된 책임이 협력에 어울리지 않는다면 그 책임은 나쁜 것이다.
- 객체의 입장에서는 책임이 조금 어색해 보이더라도 협력에 적합 하다면 그 책임은 좋은것이다.
- 협력을 시작하는 주체는 메시지 전송자이기 때문에 좋은 책임이란 메시지 전송자에게 적합한 책임을 의미한다.
-
메시지를 먼저 결정하면 메시지 수신자에 대한 정보가 없기 때문에 전송자의 관점에서 메시지 수신자가 깔끔하게 캡슐화된다.
5.2 책임 할당을 위한 GRASP 패턴
- 대중적으로 가장 널리 알려진 객체지향 패턴
5.2.1 도메인 개념에서 출발하기
- 설계를 시작하기 전 도메인에 대한 모습을 대략적으로 그려 보는것은 유용하다.
- 도메인 개념들을 책임 할당의 대상으로 사용하면 코드에 도메인의 모습을 투영하기가 좀 더 수월해진다.
- 설계를 시작하는 단계에서는 개념들의 의미와 관계가 정확하거나 완벽할 필요가 없다.
- 중요한 것은 설계를 시작하는 것이지 도메인 개념들을 완벽하게 정리하는 것이 아니므로 너무 많은 시간을 들이지 말고 빠르게 설계와 구현을 진행하라.
- 올바른 구현을 이끌어낼 수만 있다면 그것은 올바른 도메인 모델이다.
5.2.2 정보 전문가에게 책임을 할당하라
- 책임 주도 설계 방식의 첫 단계는 애플리케이션이 제공해야 하는 기능을 애플리케이션의 책임으로 생각하는 것이다.
- 메시지는 메시지를 수신할 객체가 아니라 메시지를 전송할 객체의 의도를 반영해서 결정해야 한다.
- 책임 주도 설계를 하기 위해서는 두가지 질문을 반복해서 해야한다.
- 메시지를 전송할 객체는 무엇을 원하는가?
- 메시지를 수신할 적합한 객체는 누구인가?
5.2.2 높은 응집도와 낮은 결합도
- 설계는 트레이드오프 활동이다.
- 동일한 기능은 수많은 방법으로 구현될 수 있어, 몇 가지 설계 중에서 한 가지를 선택해야 하는 경우가 빈번하다.
- 예를 들어, 앞서 설계한 시스템에서는 할인 요금을 계산하기 위해 Movie가 DiscountCondition에 할인 여부를 판단을 요청한다.
- 그렇다면 이 설계의 대안으로 Movie 대신 Screening이 직접 DiscountCondition과 협력하게 하는 것은 어떨까?
- 위 설계는 기능적인 측면에서만 놓고 보면 앞의 설계와 동일하다.
- 그렇다면 왜 우리는 이 설계 대신 Movie가 DiscountCondition과 협력하는 방법을 선택한 것일까?
- 그 이유는 결합도와 응집도에 있다.
결합도 측면
- 도메인 상으로 Movie는 DiscountCondition의 목록을 속성으로 포함하고 있어 두 객체는 이미 결합되어 있다.
- 하지만 Screening이 DiscountCondition과 협력할 경우에는 Screening과 DiscountCondition 사이에 새로운 결합도가 추가된다.
- 따라서 결합도 관점에서는 DiscountCondition이 Screening 과 협력하는 것보다는 Movie와 협력하는 것이 더 낫다.
응집도 측면
- Screening의 핵심 책임은 예매하는 것이나, DiscountCondition과 협력 한다면 Screening은 요금 계산 책임의 일부를 지게 된다.
- 이 경우 Screening 은 DiscountCondition이 할인 여부를 판단할 수 있고 Movie가 이 할인 여부를 필요로 한다는 사실 역시 알고있어야한다.
- 즉, 예매 요금을 계산하는 방식이 변경될 경우 Screening도 함께 변경해야 하는 것이다.
- 결과 적으로 Screening과 DiscountCondition이 협력하게 되면 Screening은 서로 다른 이유로 변경되는 책임을 짊어지게 되므로 응집도가 낮아진다.
- 반면 Movie의 주된 책임은 영화 요금을 계산하는 것이다. 따라서 영화 요금을 계산하는 데 필요한 할인 조건을 판단하기 위해 Movie가 DiscountCondition과 협력하는 것은 응집도에 아무런 불이익이 없다.
5.2.3 창조자에게 객체 생성 책임을 할당하라
- 객체를 A 생성해야 할 때 어떤 객체에게 객체 생성 책임을 할당해야 하는가?
- 객체가 A 객체를 포함하거나 참조한다.
- 객체가 A 객체를 기록한다.
- 객체가 A 객체를 긴밀하게 사용한다.
- 객체가A 객체를 초기화하는데 필요한 데이터를 가지고있다.
- 즉, 생성될 객체에 대해 잘 알고 있어야 하거나 그 객체를 사용해야 하는 객체는 어떤 방식으로든 생성될 객체와 결합되므로, 해당 객체를 생성할 책임을 맡기는 것이다.
- 이미 결합돼 있는 객체에게 생성 책임을 할당하는 것은 설계의 전체적인 결합도에 영향을 마치지 않으므로 이미 존재하는 객체 사이의 관계를 이용하기 때문에 설계가 낮은 결합도를 유지할 수 있게 한다.
5.3 구현을 통한 검증
Screening을 구현하기
- Screening은 영화를 예매할 책임을 맡으며 그 결과로 Reservation 인스턴스를 생성할 책임을 수행해야 한다.
- 다시 말해 Screening은 예매에 대한 정보 전문가인 동시에 Reservation의 창조자다.
- 협력의 관점에서 Screening은 예매하라 메시지에 응답할 수 있어야 한다. 따라서 이 메시지를 처리할 수 있는 메서드를 구현하자.
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);
}
}
- Movie에 전송하는 메시지인 calculateMovieFee는 수신자가 아닌 송신자인 Screening의 의도를 표현한다.
- Screening이 Movie의 내부 구현에 대한 어떤 지식도 없이 전송할 메시지를 결정했다는 것이 중요하다.
- 이처럼 Movie의 구현을 고려하지 않고 필요한 메시지를 결정하면 Movie의 내부 구현을 깔끔하게 캡슐화할 수 있다.
- 메시지가 변경되지 않는 한 Movie에 어떤 수정을 가하더라도 Screening에는 영향을 미치지 않는다.
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;
}
}
- 요금을 계산하기 위해 Movie는 기본 금액(fee), 할인 조건(discountConditions), 할인 정책 등의 정보를 알아야 한다.
- Movie는 먼저 discountConditions의 원소를 차례대로 순회하면서 할인 여부를 판단하도록 요청한다.
- 만약 할인 조건을 만족한다면 할인 요금을 계산하기 위해 calculateDiscountAmount 메서드를 호출한다.
DiscountCondition 구현하기
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.isAfter(screening.getWhenScreened().toLocalTime()) >=0;
}
private boolean isSatisfiedBySequence(Screening screening) {
return sequence == screening.getSequence();
}
}
- DiscountCondition은 기간 조건을 위한 변수와 순번 조건을 위한 변수를 인스턴스 변수로 포함한다.
- DiscountCondition은 할인 조건을 판단하기 위해 Screening의 상영 시간과 상영 순번을 알아야 한다.
5.3.1 코드 개선하기
- 변경에 취약한 클래스를 포함하는 것은 큰 문제다.
- 하나 이상의 변경 이유를 가진다면 응집도가 낮아지므로, 변경의 이유에 따라 클래스를 분리해야 한다.
- 변경의 이유가 하나 이상인 클래스를 찾아내는 두가지 방법은 아래와 같다.
인스턴스 변수가 초기화되는 시점
- 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화한다.
- 응집도가 낮은 클래스는 객체의 속성 중 일부만 초기화하고 일부는 초기화하지 않는다.
- 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.
- ex) sequence(순번 조건) / dayOfWeek, startTime, endTime(기간 조건)
메서드들이 인스턴스 변수를 사용하는 방식
- 모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다.
- 반면 메서드들이 사용하는 속성에 따라 그룹이 나뉜다면 클래스의 응집도가 낮다.
- 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 한다.
5.3.2 타입 분리하기
- DiscountCondition의 가장 큰 문제는 두 개의 독립적인 타입이 하나의 클 래스 안에 공존하고 있다는 점이다.
- 우선, 두 타입을 SequenceCondition과 PeriodCondition이라는 두 개의 클래스로 분리해보자.
public class PeriodCondition {
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime 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 boolean isSatisfiedBy(Screening screening) {
return sequence == screening.getSequence();
}
}
- 이렇게 하면, 앞에서 언급한 두가지 문제점이 해결되어 응집도가 향상된다.
- 그러나 위의 코드를 사용하면 Movie의 인스턴스는 두 객체와 결합하게 되어 결합도가 높아진다.
5.3.3 다형성을 통해 분리하기
- Movie의 객체는 클래스의 구현은 중요하지 않고 할인 여부 판단만이 중요한 요소다.
- 역할을 사용해 객체의 구체적인 타입을 추상화 하자.
- 이처럼 객체의 타입에 따라 행동을 분기해야 한다면 타입을 클래스로 정의하고 행동을 나누어 응집도 문제를 해결할 수 있다.
- 이를 다형성(POLYMORPHISM) 패턴이라고 부른다.
5.3.4 변경으로부터 보호하기
- DiscountCondition이라는 추상화가 구체적인 타입을 캡슐화한다.
- 이는 기존 코드의 구현이 변경되거나 새로운 할인 조건이 추가되어도 Movie는 영향을 받지 않는것을 의미한다.
- 오직 구현 클래스를 추가하는 것으로 할인 조건을 확장할 수 있다.
- 이처럼 변경을 캡슐화하도록 책임을 할당하는 것을 변경 보호(PROTECTED VARIATIONS) 패턴이라고 부른다.
- 하나의 클래스가 여러 타입의 행동을 구현한다면, 클래스를 분해핳고 책임을 분산시켜라
5.4 책임 주도 설계의 대안
- 책임 주도 설계가 어렵다면, 최대한 빠르게 코드를 작성한 후 책임을 올바른 위치로 이동시키자.
- 주의할 점은 코드를 수정한 후에 겉으로 드러나는 동작이 바뀌어서는 안된다.
- 캡슐화를 향상시키고, 응집도를 높이고, 결합도를 낮춰야 하지만 동작은 그대로 유지해야 한다.
- 이처럼 노출되는 동작은 바꾸지 않은 채 내부 구조를 변경하는 것을 리팩터링이라고 부른다.
5.4.1 메서드 응집도
- 데이터 중심으로 설계의 도메인 객체들은 데이터의 집합일 뿐, 영화 예매의 모든 절차는 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 = movie.getFee() ;
break;
}
fee = movie.getFee().minus(discountAmount).times(audienceCount);
} else {
fee = movie.getFee();
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
문제점 - 몬스터 메서드
- 코드를 한눈에 파악하기 어려워 이해하는데 너무 많은 시간이 걸린다
- 하나의 메서드 안에서 너무 많은 작업을 처리해 변경이 필요할 때 수정할 부분을 찾기 어렵다.
- 메서드 내부의 일부 로직만 수정해도 나머지 부분에서 버그 발생 확률이 높다.
- 로직의 일부만 재사용하는 것이 불가능하다.
- 코드를 재사용하기 위해서는 복붙해야 하므로 코드 중복을 초래한다.
해결방법
- 메서드를 작게 분해해서 각 메서드의 응집도를 높여라.
- 응집도 높은 메서드는 변경되는 이유가 단 하나여야한다.
- 목적이 명확한 메서드들로 구성돼 있다면 변경을 처리하기 위해 어떤 메서드를 수정해야 하는지 쉽게 판단할 수 있다.
- 메서드의 크기가 작고 목적이 분명해 재사용하기 쉽다.
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);
}
.
.
.
}
- 이렇게 할 경우, 비록 클래스의 길이는 더 길어지지만 작고 명확하고 응집도 높은 메서드들로 구성된다.
- 전체적인 흐름을 이해하기 쉬워진다.
- 코드가 변경하기 쉬워진다.
728x90
반응형
'스터디 > 오브젝트' 카테고리의 다른 글
오브젝트 07 - 객체 분해 (1) | 2024.03.05 |
---|---|
오브젝트 06 - 메시지와 인터페이스 (0) | 2024.02.26 |
오브젝트 04 - 설계 품질과 트레이드오프 (0) | 2024.02.19 |
오브젝트 03 - 역할, 책임, 협력 (0) | 2024.02.13 |
오브젝트 02 - 객체지향 프로그래밍 (0) | 2024.02.06 |
댓글