책임 할당 - 코드로 이해하는 객체지향 프로그래밍
https://sh1mj1-log.tistory.com/131 이 글에서는 책임 중심 설계의 객체 지향 코드의 대략적인 모양,
https://sh1mj1-log.tistory.com/133 이 글에서는 역할, 책임, 협력이 객체지향적인 코드를 작성하기 위한 핵심이라는 사실,
https://sh1mj1-log.tistory.com/137 이 글에서는 데이터에 초점을 맞출 때 생기는 문제점에 대해 알아보았습니다.
이번에는 책임 중심 설계의 설계 과정을 하나씩 따라가보면서 책임 할당 기본 원리를 살펴봅시다.
책임 할당
책임 주도 설계를 향해서
두가지 원칙을 지켜야 합니다.
- 데이터보다 행동을 먼저 결정하자
- 협력이라는 문맥 안에서 책임을 결정하자
데이터보다 행동을 먼저 결정하자
먼저 '이 객체가 수행해야 하는 책임은 무엇인가?' 를 결정한 후에 '데이터를 처리하는 데 필요한 오퍼레이션은 무엇인가?' 을 결정합시다. 객체의 책임을 먼저 결정한 후에 객체의 상태를 결정합시다.
객체 지향 설계에서는 적절한 객체에게 적절한 책임을 할당해야 합니다. 그렇다면 객체에게 어떤 책임을 할당해야 할까요?
협력이라는 문맥 안에서 책임을 결정하자
객체에게 할당된 책임의 품질은 협력에 적합한 정도로 결정됩니다. 협력을 시작하는 주체는 메시지 전송자입니다. 메시지를 전송하는 클라이언트의 의도에 적합한 책임을 할당해야 합니다.
메시지는 클라이언트의 의도를 표현하고 객체를 결정하기 전, 객체가 수신할 메시지를 먼저 결정합니다. 클라이언트는 어떤 객체가 메시지를 수신할지 알지 못합니다. 단지, 임의의 객체가 메시지를 수신할 것이라고 믿고 자신의 의도를 표현한 메시지를 전송할 뿐입니다. 그리고 그 메시지를 수신하기로 결정된 객체는 메시지를 처리할 '책임'을 할당받습니다.
메시지를 먼저 결정하니 메시지 송신자는 메시지 수신자를 모릅니다. 캡슐화되는 것이지요. 협력이라는 문맥에서 메시지에 집중하는 책임 중심 설계는 캡슐화 원리를 지키기 쉽습니다.
책임 중심 설계는 협력이라는 문맥 안에서 객체가 수행할 책임에 초점을 맞춥니다.
책임 주도 설계
책임 주도 설계의 흐름입니다.
- 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악함.
- 시스템을 더 작은 책임으로 분할
- 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임 할당
- 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요하면 이를 책임질 적절한 객체 또는 역할을 찾음.
- 해당 객체 또는 역할에게 책임을 할당하여 두 객체가 협력하게 함.
책임 관점에서 영화 예매 시스템이 완성되는 과정을 살펴봅시다.
책임 할당을 위한 GRASP 패턴
GRASP 패턴 ('General Responsibility Assignment Software Pattern' 일반적인 책임 할당을 위한 소프트웨어 패턴).
객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합.
도메인 개념에서 출발하기
설계 전 도메인에 대한 개략적인 모습을 그려봅니다. 어떤 책임을 할당해야 할 때 가장 먼저 고민해야 하는 것이 도메인 개념이기 때문입니다.
도메인 설계를 너무 완벽히 정리할 필요는 없습니다.
정보 전문가에게 책임을 할당하자
애플리케이션(이하 앱)이 제공해야 하는 기능을 앱의 책임으로 보고, 이 책임을 앱에 대해 전송된 메시지로 간주합니다. 그리고 이 메시지를 책임질 첫번째 객체를 선택하면서 설계를 시작하면 됩니다.
메세지를 전송할 객체는 무엇을 원하는가?
앱의 기능은 영화 예매이므로 객체가 보낼 메시지는 예매하라 가 될 것입니다.
메시지를 수신할 적합한 객체는 무엇인가?
객체는 상태와 행동을 통합한 캡슐화의 단위입니다. 객체는 책임을 수행할 정보를 알고 있는 정보 전문가에게 책임을 할당하는 것이 좋습니다. 이 패턴을 INFORMATION EXPERT(정보 전문가) 패턴 이라고 합니다. (책임을 수행하는 데 필요한 정보를 가지고 있는 객체에게 책임을 할당하라)
여기서 정보는 데이터와 같은 것이 아닙니다. 객체가 정보를 '알고' 있는 것이 꼭 그 정보를 '저장'하고 있는 것은 아닙니다.
영화 예매하는 데 필요한 정보를 가장 많이 알고 있는 객체는 영화에 대한 정보, 상영 시간, 순번같은 정보를 알고 있는 '상영' 이라는 도메인 개념입니다.
그리고 나서 Screening 이 수행해야 하는 작업, 내부에서 메시지를 처리하기 위한 구현을 고민해보는 것입니다. Screening 이 책임을 수행하는 데 필요한 작업과 스스로 처리할 수 없는 작업이 무엇인지만 대략적으로 생각해내면 됩니다. 스스로 처리할 수 없는 작업은 외부에 도움을 요청해야지요.
예매하라 작업을 수행하려면 예매 가격을 알아야 합니다. Screening 은 이를 모르기 때문에 예매 가격을 계산해달라는 도움을 외부에 요청합니다. 메시지 가격을 계산하라 를 외부에 보내는 것이지요.
가격을 계산하는 작업에 필요한 정보에 대해서는 Movie 가 전문가입니다.
가격을 계산하기 위해서 Movie 는 할인 정책에 따라 금액 계산을 해야 합니다. 이는 영화가 스스로 처리할 수 없는 일입니다. 외부에 할인 여부에 대한 도움을 요청합니다. 메시지 할인 여부를 판단하라 을 전송해서요.
이 메시지를 책임질 객체는 할인 여부 판단에 대해 전문가인 할인 조건(DiscountCondition)입니다.
이렇게 INFORMATION EXPERT 패턴은 객체에게 책임을 할당할 때 가장 기본이 되는 책임 할당 원칙입니다. 이 패턴은 객체를 상태와 행동을 함께 가지는 단위라는 객체지향의 기본 원리를 책임 할당의 관점에서 표현합니다. 이 패턴을 따르는 것만으로도 자율성이 높은 객체들로 구성된 협력 공동체를 구축하기 쉬워집니다.
높은 응집도 & 낮은 결합도
동일한 기능을 구현할 수 있는 설계는 무수히 많이 존재합니다. 예를 들어 아래와 같은 설계가 있을 수 있습니다.
Screening 이 DiscountCondition 에게 DiscountCondition 판단 메시지를 전송하고 반환받은 결과를 가지고 Movie에게 가격을 계산하도록 요청하는 설계입니다.
이렇게 실제 설계를 진행하다 보면 여러 설계 중 하나만 선택해야 하는 경우가 발생합니다. 이 경우에는 다른 책임 할당 패턴을 사용해서 선택할 수 있습니다.
이 때 LOW COUPLING(낮은 결합도) 패턴, HIGH COHESION(높은 응집도) 패턴을 사용할 수 있습니다.
LOW COUPLING(낮은 결합도) 패턴
의존성을 낮추고 변화의 영향을 줄이며 재사용성을 증가시키려면 설계의 전체적인 결합도가 낮게 유지되도록 책임을 할당하자. 낮은 결합도는 모든 설계 결정에서 염두에 두어야 하는 원리입니다.
도메인 상에서 DiscountCondition 은 Movie 가 DiscountCondition 의 목록을 속성으로 포함하고 있습니다. 이미 결합되어 있는 것이지요. DiscountCondition 이 Screening 과 협력하지 않고 Movie 와 협력하면 설계 전체적으로 결합도를 추가하지 않고도 협력을 완성할 수 있습니다.
HIGH COHESION(높은 응집도) 패턴
어떻게 복잡성을 관리할 수 있는 수준으로 유지할까? 높은 응집도를 유지할 수 있게 책임을 할당하자. 높은 응집도도 모든 설계 결정에서 염두에 두어야 할 원리입니다.
새로 소개한 설계에서 예매 요금을 계산하는 방식이 변경될 경우 Screening 도 함께 변경해야 합니다. 즉, Screening 과 DiscoutCondition 이 협력하게 되면 Screening 은 서로 다른 이유로 변경되는 책임을 짊어지게 되어 응집도가 떨어집니다.
반면에 기존의 설계에서는 Movie 의 주된 책임은 영화 요금 계산이므로 이에 필요한 할인 조건을 판단하기 위해 Movie 가 DiscountCondition 과 협력하는 것은 응집도에 아무런 해도 끼치지 않습니다.
창조자에게 객체 생성 책임을 할당하라
영화 예매 협력의 최종 결과물은 Reservation 인스턴스를 생성하는 것입니다. 어떤 객체는 Reservation 인스턴스를 생성할 책임을 가져야 하지요. GRASP 의 CREATOR(창조자) 패턴은 이 같은 경우에 사용할 수 있는 책임 할당 패턴으로서 객체를 생성할 책임을 어떤 객체에게 할당할지 안내합니다.
CREATOR 패턴
객체 A 를 생성해야 할 때 어떤 객체에게 객체 생성 책임을 할당해야 할까? 아래 조건을 최대한 많이 만족하는 B 에게 할당합니다.
- B 가 A 객체를 포함하거나 참조
- B 가 A 객체를 기록
- B 가 A 객체를 긴밀히 사용
- B 가 A 객체를 초기화하는데 필요한 데이터를 가짐.(B가 A 에 대한 정보 전문가임)
생성될 객체에 대해 잘 알거나 그 객체를 사용해야 하는 객체는 어떻게된 생성될 객체와 연결될 것입니다. 즉, 두 객체는 서로 결합되지요. 이미 결합되어 있는 객체에게 생성 책임을 할당하는 것은 시스템의 전체적인 결합도에 영향을 끼치지 않습니다.
여기서 Reservation 을 잘 알거나, 긴밀히 사용하거나 초기화에 필요한 데이터를 가진 객체는 Screening 입니다. 예매 정보를 생성하는데 필요한 영화, 상영시간, 순번 정보에 대해 전문가이고, 예매 요금을 계산하는 데 필수적인 Movie 도 알고 있습니다. 즉, Screening 이 Reservation 의 CREATOR 가 되는 것이 좋아 보입니다.
대략적으로 영화 예매에 필요한 책임을 객체들에게 할당했습니다. 현재까지의 책임 분배는 설계에서의 대략적인 스케치입니다. 실제 설계는 코드를 작성하는 동안 이뤄집니다.
구현을 통한 검증
Screening 은 예매하라 메시지에 응답할 수 있어야 합니다. 이 메시지를 처리할 메서드는
public class Screening {
public Reservation reserve(Customer customer, int audienceCount) {
return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
}
}
책임이 결정되었으니 책임을 수행하는 데 필요한 인스턴스 변수로 상영시간 whenScreened, 상영 순번 sequence, Movie 에 가격을 계산하라는 메시지를 전송해야 하기 때문에 영화 movie 에 대한 참조를 가집니다.
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);
}
}
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);
}
}
Movie 에 전송하는 메시지의 시그니처를 'calculateMovieFee(Screening screening)' 으로 선언했다. 수신자는 Movie가 아니라 송신자인 Screening 의 의도로 표현됩니다.이렇게 Screening 이 Movie 의 내부 구현에 대한 지식 없이 전송할 메시지를 결정한 것입니다. Movie 의 내부 구현 고려 없이 필요한 메시지를 결정해서 내부 구현을 깔끔하게 캡슐화했습니다.
Screening 과 Movie 을 연결하는 유일한 연결 고리는 메시지 뿐입니다. 메시지가 변경되지 않으면 Movie 에 수정을 해도 Screening 에는 영향이 없습니다. 결합도를 낮게 만들었습니다.
Movie 는 Screening 의 calculateMovieFee 메시지에 응답하기 위해 calculateMovieFee 메서드를 구현해야 합니다.
public class Movie {
public Money calculateMovieFee(Screening screening) {
}
}
요금 계산을 위한 기본 금액 fee, 할인 조건 discountConditions, 할인 정책 discountAmount 혹은 discountPercent 등 정보를 알아야 합니다. 영화가 어떤 할인 정책이 적용된 영화인지 나타내는 영화 종류 movieType 을 인스턴스 변수로 포함합니다.
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 // 미적용
}
discountConditions 을 순회하면서 isSatisfiedBy 메시지를 전송해서 할인 여부를 판단하도록 요청합니다. 만약 할인 조건을 만족하는 DiscountCondition 인스턴스가 존재한다면 할인 요금 계산 calculateDiscountAmount 메서드를 호출합니다. 할인조건이 없으면 기본금액을 반환합니다.
실제로 할인 요금을 계산하는 calculateDiscountAmount 메서드는 movieType 의 값에 따라 적절한 메서드를 호출합니다.
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() {
return switch (movieType) {
case AMOUNT_DISCOUNT -> calculateAmountDiscountAmount();
case PERCENT_DISCOUNT -> calculatePercentDiscountAmount();
case NONE_DISCOUNT -> calculateNoneDiscountAmount();
};
}
private Money calculateAmountDiscountAmount() {
return discountAmount;
}
private Money calculatePercentDiscountAmount() {
return fee.times(discountPercent);
}
private Money calculateNoneDiscountAmount() {
return Money.ZERO;
}
}
Movie 는 각 DiscountCondition 에 할인 여부를 판단하라 메시지를 전송합니다. DiscountCondition 은 이 메시지를 처리하기 위해 isSatisfiedBy 메서드를 구현해야 합니다.
public class DiscountCondition {
public boolean isSatisfiedBy(Screening screening) {
}
}
필요한 인스턴스 변수들 요일 dayOfWeek, 시작 시간 startTime, 종료시간 endTme, 상영 순번 sequence, 할인 조건의 종류 type 을 포함합시다. isSatisfiedBy 메서드는 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.isAfter(screening.getWhenScreened().toLocalTime()) &&
!endTime.isBefore(screening.getWhenScreened().toLocalTime());
}
private boolean isSatisfiedBySequence(Screening screening) {
return sequence == screening.getSequence();
}
}
public enum DiscountConditionType {
SEQUENCE, // 순번 조건
PERIOD // 기간 조건
}
DiscountCondition 은 할인 조건을 판단하기 위해 Screening의 상영 시간, 상영 순번을 알아야 합니다. 두 정보를 제공하는 getter 를 Screening 에 추가해야 겠네요.
이제 구현이 완료되었습니다. 몇가지 문제점이 있는 코드가 되었습니다.
DiscountCondition 개선하기
가장 큰 문제점은 변경에 취약한 클래스를 포함하고 있다는 것입니다. 코드를 수정해야 하는 이유를 둘 이상 가지는 클래스입니다. DiscountCondition 은 서로 다른 세가지 이유로 변경될 수 있습니다.
1. 새로운 할인 조건 추가되면
isSatisfiedBy 메서드 안에 if ~ else 문 수정해야 함. 새로운 할인 조건이 새로운 데이터를 요구하면 DiscountCondition 에 속성도 추가해야 함.
2. 순번 조건을 판단하는 로직이 변경되면
isSatisfiedBySequence 메서드의 내부 구현을 수정해야 함. 순번 조건을 판단하는 데 있어 필요한 데이터가 변경되면 DiscountCondition 의 sequence 속성 역시 변경해야 함.
3. 기간 조건을 판단하는 로직이 변경되면
isSatisfiedByPeriod 메서드 내부 구현을 수정해야 함. 순번 조건을 판단하는 데 있어 필요한 데이터가 변경되면 DiscountCondition 의 dayOfWeek, startTime, endTime 속성 역시 변경해야 함.
그래서 DiscountCondition 은 응집도가 낮습니다. 연관성이 없는 기능이나 데이터가 하나의 클래스 안에 뭉쳐 있는 것입니다. 우리는 변경의 이유에 따라 클래스를 분리해야 합니다.
그런데 이런 클래스를 어떻게 찾아낼까요? 변경의 이유가 둘 이상인 클래스는 몇가지 패턴을 보여줍니다.
1. 인스턴스 변수가 초기화되는 시점을 살펴보자!
응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 초기화합니다.
DiscountCondition 이 순번 조건을 표현하는 경우 sequence 는 초기화되지만 dayOfWeek, startTime, endTime 은 초기화되지 않습니다. 기간 조건을 표현하는 경우 그 반대이지요. 우리는 함께 초기화 되는 속성을 기준으로 코드를 분리해야 합니다.
2. 메서드들이 인스턴스 변수를 사용하는 방식을 살펴보자!
응집도가 높은 클래스는 모든 메서드가 객체의 모든 속성을 사용합니다.
DiscountCondition 의 isSatisfiedBySequence 메서드는 sequence 는 사용하지만 dayOfWeek, startTime, endTime 은 사용하지 않습니다. isSatisfiedByPeriod 메서드는 그 반대입니다. 한 속성 그룹과 다른 속성 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 합니다.
타입 분리하기
DiscountCondition 의 가장 큰 문제는 순번 조건과 기간 조건이라는 두 개의 독립적인 타입이 하나의 클래스 안에 공존하고 있다는 점입니다. 두 타입을 SequenceCondition 과 PeriodCondition 이라는 두 클래스로 분리해 봅시다.
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.isAfter(screening.getWhenScreened().toLocalTime()) &&
!endTime.isBefore(screening.getWhenScreened().toLocalTime());
}
}
클래스를 분리하면 앞의 문제들이 모두 해결됩니다. 두 클래스 모두 자신의 모든 인스턴스 변수를 함께 초기화할 수 있습니다. 또 모든 동일한 인스턴스 변수 그룹을 사용하므로 응집도가 높아졌습니다.
하지만 클래스를 분리하니 새로운 문제가 나타났습니다. 수정 전에는 Movie 와 협력한는 클래스가 DiscountConditon 하나였는데 수정 후 Movie의 인스턴스는 두 개의 서로 다른 클래스의 인스턴스 모두와 협력해야 합니다.
Movie 클래스 안에서 SequenceCondition 의 목록과 PeriodCondition 목록을 따로 유지하도록 해볼까요?
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));
}
}
이 방법은 Movie 클래스가 PeriodCondition 과 SequenceCondition 클래스 양쪽에 결합되게 됩니다. 코드 수정 전에는 Movie 가 DiscountCondition 하나의 클래스에만 결합되어 있었는데 말이죠. 이렇게 하면 설계의 관점에서 전체적인 결합도가 높아집니다.
그리고 수정 후에 새로운 할인 조건을 추가하기 어려워졌습니다. 만약 새로운 할인 조건이 추가가되면 클래스도 만들고 새 할인 조건을 담는 List 를 Movie 의 인스턴스 변수로 추가해야 합니다. 또 이 리스트를 이용해서 할인 조건을 만족하는지 여부를 판단하는 메소드도 추가하고 isDiscountable 메서드를 수정해야 합니다.
클래스를 분리하기 전에는 Discountable 의 내부 구현만 수정하면 Movie 에는 영향이 없었습니다. 하지만 수정 후에는 그렇지 않죠. 응집도는 높아졌지만 변경과 캡슐화라는 관점에서 보면 전체적으로 설계의 품질이 나빠졌습니다.
다형성을 통해 분리하기
Movie 입장에서 SequenceCondition 과 PeriodCondition 은 할인 여부 판단을 위한 방법이 다를 뿐 동일한 '역할'을 가집니다.
'역할'은 협력 안에서 대체 가능성을 의미합니다. 즉, Movie 가 구체적인 클래스는 알지 못한 채 오직 역할에만 결합되도록 의존성을 제한할 수 있습니다.
역할은 객체의 구체적인 타입을 추상화할 수 있습니다. 자바에서는 일반적으로 역할을 구현하기 위해서 추상 클래스나 인터페이스를 사용하지요. (클래스들 사이에서 구현을 공유할 필요가 있다면 추상 클래스, 없다면 인터페이스)
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
public class SequenceCondition implements DiscountCondition{...}
public class SequenceCondition implements DiscountCondition{ ... }
Movie 는 협력하는 객체의 구체적인 타입을 몰라도 상관없습니다. 그저 협력하는 객체가 Discountcondition 의 역할을 수행할 수 있고 isSatisfiedBy 메시지를 이해할 수 있다는 사실만 알아도 됩니다.
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));
}
}
DiscountCondtion 의 경우에 알 수 있듯이 객체의 암시적인 타입에 따라 행동을 분기해야 한다면 암시적인 타입을 명시적인 클래스로 정의하고 행동을 나눠서 응집도 문제를 해결할 수 있습니다. 객체의 타입에 따라 변하는 행동이 있으면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당하는 것입니다. GRASP 에서는 이를 POLYMORPHISM(다형성) 패턴 이라고 합니다.
변경으로부터 보호하기
현재 DiscountCondition 의 두 서브 클래스는 서로 다른 이유로 변경됩니다. 그리고 새로운 할인 조건이 추가되는 경우에도 Movie 에 어떤 수정도 필요없이 DiscountCondition 인터페이스를 실체화하는 클래스를 추가하는 것으로 할인 조건의 종류를 확장할 수 있습니다.
이러한 변경을 캡슐화하도록 책임을 할당하는 것을 GRASP 에서는 PROTECTED VARIATIONS(변경 보호) 패턴이라고 합니다.
PROTECTED VARIATIONS(변경 보호) 패턴
객체, 서브시스템, 시스템을 어떻게 설계해야 변화와 불안정성이 다른 요소에 나쁜 영향을 미치지 않도록 방지하려면 변화가 예상되는 불안정한 지점들을 식별하고 그 주위에 안정된 인터페이스를 형성하도록 책임을 할당합시다.
한 클래스가 여러 타입의 행동을 구현하고 있는 것처럼 보이면 클래스를 분해하고 POLYMORPHISM 패턴에 따라 책임을 분산시키고, 예측 가능한 변경으로 여러 클래스들이 불안정해진다면 PROTECTED VARIATIONS 패턴에 따라 안정적인 인터페이스 뒤로 변경을 캡슐화하자.
Movie 클래스 개션
Movie 도 비슷한 문제를 가집니다. 금액 할인 정책 영화와 비율 할인 정책 영화라는 두 타입을 하나의 클래스 안에 구현하고 있습니다. 응집도가 낮은 것이죠.
역할의 개념을 도입해서 협력을 다형적으로 만들면 됩니다.
public abstract class Movie {
private String title;
private Duration runningTime;
private Money fee;
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));
}
abstract protected Money calculateDiscountAmount();
public Money getFee() {
return fee;
}
}
public class AmountDiscountMovie extends Movie {
private Money discountAmount;
public AmountDiscountMovie(Money discountAmount) {
super();
this.discountAmount = discountAmount;
}
@Override
protected Money calculateDiscountAmount() {
return discountAmount;
}
}
public class PercentDiscountMovie extends Movie {
private double percent;
public PercentDiscountMovie(double percent) {
super();
this.percent = percent;
}
@Override
protected Money calculateDiscountAmount() {
return getFee().times(percent);
}
}
public class NoneDiscountMovie extends Movie {
public NoneDiscountMovie() {
super();
}
@Override
protected Money calculateDiscountAmount() {
return Money.ZERO;
}
}
이렇게 구현이 끝났습니다.
모든 클래스의 내부 구현은 캡슐화되어 있고 모든 클래스는 변경의 이유를 오직 하나씩만 가집니다. 각 클래스의 응집도가 높고 다른 클래스와 최대한 느슨히 결합되어 있습니다. 클래스는 작고 오직 한 가지 일만 수행합니다. 책임은 적절히 분배되어 있습니다. 이것이 책임을 중심으로 협력을 설계할 때 얻을 수 있는 이득입니다.
변경과 유연성
개발자로서 변경에 대비하려면
코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계하거나 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만들어야 합니다.
만약 영화에 설정된 '할인 정책을 실행 중에 변경할 수 있어야 한다는 요구사항이 추가되었다'고 합시다. 현 설계에서는 할인 정책을 구현하기 위해 '상속'을 이용하고 있기 때문에 실행 중 할인 정책을 변경하려면 새로운 인스턴스를 생성한 후에 필요한 정보를 복사해야 합니다. 또 변경 전 후의 인스턴스가 개념적으로는 동일한 객체를 가리키지만, 물리적으로 서로 다른 객체이기 때문에 식별자의 관점에서 혼란스러울 수 있습니다. 이는 꽤 번거롭고 오류가 발생하기 쉽습니다. 이 경우에는 코드의 복잡성을 높이더라도 할인 정책의 변경을 쉽게 수용할 수 있게 코드를 유연하게 만드는 것이 더 좋습니다.
해결 방법은 상속 대신 '합성' 을 이용하는 것입니다. 아래처럼 말이죠.
Movie 의 상속 계층 안에 구현된 할인 정책을 독립적인 DiscountPolicy 로 분리한 후 Movie 에 합성시키면 유연한 설계가 완성됩니다.
이제 금액 할인 정책이 적용된 영화를 비율 할인 정책으로 바꾸는 일은 Movie 에 연결된 DiscountPolicy 의 인스턴스를 교체하는 단순한 작업으로 바뀝니다.
Movie movie = new Movie("타이타닉",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(...));
movie.changeDiscountPolicy(new PercentDiscountPolicy(...));
이렇게 하면 새로운 할인 정책이 추가되도라고 할인 정책을 변경하는 데 필요한 추가적인 코드를 작성할 필요가 없습니다. 새로운 클래스를 추가하고 클래스의 인스턴스를 Movie 의 changeDiscountPolicy 메서드에 전달하면 됩니다.
유연성은 의존성 관리의 문제입니다. 요소들 사이의 의존성의 정도가 유연성의 정도를 결정합니다. 유연성의 정도에 따라 결합도를 조절할 수 있는 능력은 객체지향 개발자가 갖춰야 하는 중요한 기술입니다.
도메인 모델은 단순 도메인 개념과 관계를 모은 게 아닙니다. 구현과 밀접한 관계를 맺어야 합니다. 코드의 변화에 맞춰 함께 변해야 합니다.
이렇게 마침내 이 글에서 책임 주도 설계로 만든 코드와 동일한 구조가 되었습니다.
책임 주도 설계의 대안
책임 주도 설계에 익숙해지기 위해서는 많은 노력과 시간이 필요합니다.
책임 주도 설계에서 헤매게 될 때 하나의 좋은 방법은 최대한 빨리 목적한 기능을 수행하는 코드를 작성하는 것입니다. 생각보다 훌륭한 설계를 얻을 때가 많습니다. 물론 코드 수정 후 시스템의 기능이 바뀌면 안됩니다. 캡슐화를 향상시키고, 응집도를 높이고, 결합도를 낮추지만 동작은 그대로 유지해야 합니다. 이렇게 이해하기 쉽고 수정하기 쉬운 소프트 웨어로 개선하기 위해 시스템 전체 기능은 바꾸지 않고 내부 구조만 변경하는 것을 리팩터링(Refactoring) 이라고 합니다.
이전의 데이터 중심 설계 를 리팩터링 하는 과정을 통해 이 방법의 장점을 알아봅시다.
메서드 응집도
모든 예매 프로세스는 ReservationAgency 에 있고 나머지는 단지 데이터의 집합입니다. 즉, ReservationAgency 에 포함된 로직들을 적절한 객체의 책임으로 분배하면 책임 주도 설계와 거의 유사한 결과를 얻을 수 있습니다.
ReserveAgency 의 reserve 메서드를 봅시다.
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();
}
case PERCENT_DISCOUNT -> {
discountAmount = movie.getFee().times(movie.getDiscpuntPercent());
}
case NONE_DISCOUNT -> {
discountAmount = Money.ZERO;
}
}
fee = movie.getFee().minus(discountAmount).times(audienceCount);
} else {
fee = movie.getFee().times(audienceCount);
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
reserve 메서드는 너무 길고 이해하기 어렵습니다. 이런 메서드를 몬스터 메서드(monster method) 라고 합니다.
- 어떤 일을 수행하는지 한눈에 파악하기 어렵기 때문에 코드를 전체적으로 이해하는 데 너무 많은 시간이 걸림.
- 한 메서드 안에서 너무 많은 작업을 해서 변경이 필요할 때 수정해야 할 부분을 찾기 어려움.
- 메서드 내부의 일부 로직만 수정해도 메서드의 나머지 부분에서 버그 발생하기 쉬움.
- 로직의 일부만 재사용하는 것이 불가능.
- 코드를 재사용하는 유일한 방법은 코드를 복사, 붙여넣기 하는 것이어서 코드 중복을 초래함.
클래스의 응집도와 비슷하게 높은 응집도의 메서드는 변경되는 이유가 단 하나이면서 클래스가 작고, 목적이 명확합니다. 이 메서드는 응집도가 낮습니다. 메서드를 작게 분해합시다.
public class ReservationAgencyDivi {
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 -> isDiscountable(screening, condition));
}
private static boolean isDiscountable(Screening screening, DiscountCondition condition) {
if (condition.getType() == DiscountConditionType.PERIOD) {
return isSatisfiedByPeriod(screening, condition);
} else {
return isSatisfiedBySequence(screening, condition);
}
}
private static boolean isSatisfiedByPeriod(Screening screening, DiscountCondition condition) {
return screening.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek()) &&
!condition.getStartTime().isAfter(screening.getWhenScreened().toLocalTime()) &&
!condition.getEndTime().isBefore(screening.getWhenScreened().toLocalTime());
}
private static boolean isSatisfiedBySequence(Screening screening, DiscountCondition condition) {
return discountable = condition.getSequence() == screening.getSequence();
}
private static Money calculateFee(Screening screening, boolean discountable, int audienceCount) {
if (discountable) {
return screening.getMovie().getFee()
.minus(calculatedDiscountedFee(screening.getMovie()))
.times(audienceCount);
} else {
return screening.getMovie().getFee().times(audienceCount);
}
}
private static Money calculatedDiscountedFee(Movie movie) {
Money discountAmount = calculateNoneDiscountedFee();
switch (movie.getMovieType()) {
case AMOUNT_DISCOUNT -> {
discountAmount = calculateAmountDiscountedFee(movie);
}
case PERCENT_DISCOUNT -> {
discountAmount = calculatePercentDiscountedFee(movie);
}
case NONE_DISCOUNT -> {
discountAmount = calculateNoneDiscountedFee();
}
}
return discountAmount;
}
private static Reservation createReservation(Screening screening, Customer customer, int audienceCount, Money fee) {
return new Reservation(customer, screening, fee, audienceCount);
}
private static Money calculatePercentDiscountedFee(Movie movie) {
return movie.getFee().times(movie.getDiscpuntPercent());
}
private static Money calculateAmountDiscountedFee(Movie movie) {
return movie.getDiscountAmount();
}
private static Money calculateNoneDiscountedFee() {
return Money.ZERO;
}
}
이제 ReservationAgency 클래스는 오직 하나의 작업만 하고 하나의 변경 이유만 가지는 작고, 명확하고, 응집도가 높은 메서드로 구성되어 있습니다. reserve 메서드만 보면 확실히 상위 수준의 명세를 읽는 느낌이 듭니다.
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);
}
메서드들의 응집도 자체는 높아졌지만 이 메서들을 담고 있는 ReservationAgency 의 응집도는 여전히 낮습니다. ReservationAgency 의 응집도를 높이려면 변경의 이유가 다른 메서드들을 적절한 위치(사용하는 데이터를 정의하고 있는 클래스)로 분배해야 합니다.
객체를 자율적으로 만들자
자신이 소유하고 있는 데이터를 자기 스스로 처리하도록 만들어야 합니다.
어떤 데이터를 사용하는지 아는 가장 쉬운 방법은 메서드 안에서 어떤 클래스의 접근자 메서드를 사용하는지 파악하는 것입니다.
isDiscountable 메서드를 봅시다.
private static boolean isDiscountable(Screening screening, DiscountCondition condition) {
if (condition.getType() == DiscountConditionType.PERIOD) {
return isSatisfiedByPeriod(screening, condition);
} else {
return isSatisfiedBySequence(screening, condition);
}
}
private static boolean isSatisfiedByPeriod(Screening screening, DiscountCondition condition) {
return screening.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek()) &&
!condition.getStartTime().isAfter(screening.getWhenScreened().toLocalTime()) &&
!condition.getEndTime().isBefore(screening.getWhenScreened().toLocalTime());
}
private static boolean isSatisfiedBySequence(Screening screening, DiscountCondition condition) {
return condition.getSequence() == screening.getSequence();
}
isDiscountable 메서드는 DiscountCondition 의 getType 메서드를 호출해서 할인 조건의 타입을 알아낸 후 타입에 따라 isSatisfiedBySequence 메서드나 isSatisfiedByPeriod 메서드를 호출합니다. 내부 구현 역시 할인 여부를 판단하기 위해서 DiscountCondition 의 접근자 메서드를 이용해 데이터를 가져옵니다. 따라서 이 메서드들이 DiscountCondition 에 속한 데이터를 주로 이용한다는 것을 알 수 있습니다. 두 메서드를 데이터가 존재하는 DiscountCondition 으로 옮깁시다.
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) &&
startTime.isAfter(screening.getWhenScreened().toLocalTime()) &&
endTime.isBefore(screening.getWhenScreened().toLocalTime());
}
private boolean isSatisfiedBySequence(Screening screening) {
return sequence == screening.getSequence();
}
DiscountCondition 의 isDiscontable 메서드는 외부에서 호출가능해야 하므로 가시성을 public 으로 설정했씁니다. 이제 isDiscountable 메서드가 ReservationAgency 에 속할 때는 구현의 일부였지만 DiscountCondition 으로 이동한 후에는 퍼블릭 인터페이스의 일부가 된 것입니다. 기존의 isDiscountable 메서드는 DiscountCondition 의 인스턴스를 인자로 받아야 했지만 이제 DiscountCondition 의 일부가 되었기 때문에 인자로 전달받을 필요가 없어졌습니다. 이렇게 메서드를 다른 클래스로 이동시킬 때는 인자에 정의된 클래스 중 하나로 이동하는 경우가 일반적입니다.
DiscountCondition 내부에서만 DiscountCondition 의 인스턴스 변수에 접근하므로 모든 접근자 메서드를 제거할 수 있고 이를 통해 DiscountCondition 의 내부 구현을 캡슐화할 수 있습니다. 또 할인 조건을 계산하는 데 필요한 모든 로직이 DiscountConditon 에 모여있기 때문에 응집도 역시 높아졌습니다. ReservationAgency 는 내부 구현을 노출하는 접근자 메서드를 사용하지 않고 메시지를 통해서만 DiscountCondition 과 협력합니다. 따라서 낮은 결합도를 유지합니다.
이렇게 데이터를 사용하는 메서드를 데이터를 가진 클래스로 이동시키고 나면 캡슐화와 낮은 응집도, 낮은 결합도를 가지는 설계를 얻게됩니다.
이제 ReservationAgency 는 할인 여부를 판단하기 위해 DiscountCondition 의 isDiscountable 메서드를 호출하도록 변경됩니다.
public class ReservationAgencyDivi {
private boolean checkDiscountable(Screening screening) {
return screening.getMovie().getDiscountConditions().stream()
.anyMatch(condition -> isDiscountable(screening, condition));
}
}
변경 후의 코드는 책임 주도 설계 방법을 적용해서 구현했던 DiscountCondtion 클래스의 쵝 모습과 유사해졌습니다.
여기서 POLYMORPHISM 패턴과 PROTECTED VARIATIONS 패턴을 차례대로 적용하면 우리의 최종 설계와 유사한 모습의 코드를 얻을 수 있습니다.
책임 주도 설계 방법에 익숙하지 않다면 일단 데이터 중심으로 구현한 후 이를 리팩터링하더라도 유사한 결과를 얻을 수 있을 것입니다. 처음부터 책임 주도 설계 방법을 따르는 것보다 동작하는 코드를 작성한 후에 리팩터링하는 것이 더 훌륭한 결과물을 얻을 수도 있습니다.
캡슐화, 결합도, 응집도를 이해하고 훌륭한 객체지향 원칙을 적용하기 위해 노력한다면 책임 주도 설계 방법을 단계적으로 따르지 않더라도 유연하고 깔끔한 코드를 얻을 수 있을 것입니다.