데이터를 중심으로 하여 설계를 하면 캡슐화가 지켜지지 않아 응집도가 낮아지고 결합도가 높아진다. 이러한 코드는 변경에 유연하게 대처하기 힘들다.
데이터가 아닌 책임에 초점을 둬야 한다. 이를 통해 앞서 언급한 단점들을 모두 해결하여 유연한 코드를 작성할 수 있다.
그러나, 책임에 초점을 두는 설계는 객체마다 어떤 책임을 할당해야 하는지 결정하는게 매우 어렵다. 상황마다 다양한 선택지가 있을텐데 우리는 이러한 트레이드 오프 속에서 최선의 선택을 해야된다.
책임 주도 설계는 단어 그대로 책임을 토대로 객체의 상태를 결정하는 설계 방식이다. 이 방식에서 반드시 알아둬야 하는 2가지에 대해 살펴보자.
설계 시 아래 순서로 고민을 해보자.
객체에게 중요한 것은 외부에 제공하는 행동이다. 데이터는 객체가 책임을 수행하는데 필요한 재료에 불과하다. 책임이 어느정도 정리 될 때 비로소 내부 상태에 관심을 갖게 된다. 이처럼 행동은 데이터를 결정하는 흐름이 되어야 한다.
객체 지향에서 가장 중요한 것은 객체에 적당한 책임을 부여하는 것이다. 또한, 객체는 협력을 통해 서로 상호작용하는 존재임을 꼭 기억하자.
현 객체가 속한 협력 안에서 수행하는 책임이 적합한가? 아무리 객체 입장에서 어색하더라도 협력 내에서 적합하다면 이는 적절한 책임이라 볼 수 있다.
이처럼 협력은 객체의 책임을 할당하는데 엄청난 힌트를 제공한다.
외부 객체는 자신이 보낸 메시지를 누가 수신하는지 모른다. 단지, 어떤 식으로 메시지를 전송하는 누군가가 수신을 한다고 굳게 믿고 있다. 이 누군가는 해당 메시지를 처리하는 책임을 부여받는다.
이러한 구조 덕분에 캡슐화가 이루어진다. 따라서, 응집도가 높아지고 결합도가 낮아진다.
GRASP란 일반적인 책임 할당을 위한 소프트웨어 패턴란 뜻이며 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합이다. 이 원칙들에 대해 알아보자.
어떤 책임을 할당할 때 가장 유용한 정보는 바로 도메인 개념이다.
e.g. 영화 예매 도메인
이처럼 도메인 개념은 설계 시 필요한 많은 정보를 제공한다. 단, 도메인 개념을 너무 많이 고민하면 안된다. 도메인 개념에 정답은 없으며 설계를 구현하면서 많이 바뀐다.
따라서, 적당히 틀이 잡힌 것 같다면 설계와 구현을 진행하자.
중요
도메인 모델에 정답은 없다. 처음 구현을 염두해두고 도메인을 설계하겠지만 추후 이를 토대로 작성한 코드가 도메인을 바라보는 관점을 다르게 한다.설계 시 필요한 도메인 모델은 도메인을 모두 투영한 것이 아닌 구현에 도움이 되는 모델임을 명시하자.
앞서 정보 전문가 패턴은 아래와 같이 진행된다.
이처럼 책임을 수행할 때 필요한 정보를 알고 있는 객체를 찾으면 된다. 정보와 행동을 최대한 가까운 곳에 위치시키므로 캡슐화를 유지할 수 있어 자율성이 높은 객체들로 구성된 협력 구성체를 구축할 수 있다.
중요) 정보 != 데이터
책임을 수행하는 객체는 정보를 "알고"있다고 해서 그 정보를 "저장"할 필요는 없다.
정보는 이를 저장할 수 있는 다른 객체를 알고있거나 필요한 정보를 계산해서 제공할 수 있다.
책임 할당에 여러 경우의 수가 있는 경우 낮은 결합도와 높은 응집도를 유지할 수 있는 경우를 선택하자. 이들은 설계를 평가할 때 적용할 수 있는 평가 원리라 볼 수 있다.
e.g. 영화 예매 시 할인 여부를 판단하는 책임을 부여하는 방법들
Screening
-> Movie
-> DiscountCondition
Screening
Movie
DiscountCondition
2번째의 경우 Screening
이 가격 계산, 할인 여부 판단 모두에 관여하여 응집도가 낮아진다. 따라서, 1번째를 선택하는게 더 현명하다 볼 수 있다.
이처럼 책임을 할당할 수 있는 여러 대안이 존재한다? 응집도/결합도를 고려하자.
협력을 통해 클라이언트의 메시지에 응답을 한다. 응답에 필요한 객체를 만드는 책임을 누가 가질까?
B 객체를 통해 A 객체를 만드는 경우 아래 4가지를 만족해야 한다.
영화 예매의 경우 최종적으로 Reservation
객체를 생성해야 한다.
Reservation
에 필요한 정보는 다음과 같다.
이를 가장 잘 알고 있는 객체는 Screening
이다. 따라서, 최종적으로 Screening
이 Reservation
객체를 생성하여 응답하는 건 적합하다고 볼 수 있다.
협력을 기반으로 객체마다 책임을 부여 했다. 하지만 이를 구현하면서 예상치 못한 문제들이 발생할 수 있다. 이를 직면할 때 마다 최선의 선택을 해야 한다.
설계대로 구현한 코드가 지금 당장 문제가 없어보여도 확장, 유지보수를 고려하여 문제가 발생할 우려가 있는 지점을 잘 찾아내야 한다.
DiscountCondition.java
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();
}
}
Movie.java
public class Movie {
private MovieType movieType;
...
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;
}
}
영화마다 적용되는 할인 조건이 다르므로 이를 MovieType
을 통해 표현했다. 이 필드에 따라 예매 금액 계산한다.
만약, 할인 조건이 추가되거나 변경되면 어떨까? 이 코드는 변경에 유연하게 대처할 수 있을까?
정답은 아니다. 새로운 할인 조건이 추가되야 된다면 DiscountCondition
에 필드 및 메서드 수정이 요구된다. 기존 할인 조건 수정된다면 Movie
까지 수정해야 된다.
원인은 한 객체(DiscountCondition
)에 하나 이상의 변경 이유가 있기 때문이다.
sequence
dayOfWeek
, startTime
, endTime
순번/기간 2가지의 변경 이유가 존재한다.
이는 한 곳의 변경이 다른 객체까지 전파가 되므로 응집도가 낮다고 볼 수 있다. 이처럼 하나의 클래스에 인스턴스들의 초기화/사용 시점이 다르다면 이에 따라 클래스를 분리하자.
DiscountCondition
을 인터페이스화 하여 Movie
와 느슨하게 결합되도록 수정하자.
DiscountCondition.java
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
순번/날짜 조건은 이를 상속받아 메서드를 구현하자.
PeriodCondition.java
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;
}
public boolean isSatisfiedBy(Screening screening) {
return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0&&
endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
}
}
SequenceCondition.java
public class SequenceCondition implements DiscountCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
public boolean isSatisfiedBy(Screening screening) {
return sequence == screening.getSequence();
}
}
다형성을 활용하여 기존 if/else
를 사용할 필요가 없어졌다. 이러한 패턴을 다형성 패턴(Polymorphism Pattern) 이라 한다.
덕분에 DiscountCondition
의 계층이 구분되어 이와 협력하는 객체들은 느슨하게 결합되어 캡슐화도 강해지고 변경 포인트가 분리되어 확장 및 유지보수에 유리해졌다.
아직 Movie
에는 DiscountCondition
의 변경의 전파 가능성이 존재한다. 마찬가지로 변경 포인트에 따라 객체를 분리할 수 있다.
그러나, Movie
는 DiscountCondition
과 달리 다양한 책임을 담당하므로 상속을 활용하기엔 리스크가 존재한다. 이 경우 합성을 사용하자.
DiscountPolicy.java
public interface DiscountPolicy {
Money calculateDiscountAmount(Screening screening);
}
PeriodDiscountPolicy.java
public class PercentDiscountPolicy extends DefaultDiscountPolicy {
private double percent;
public PercentDiscountPolicy(double percent, DiscountCondition... conditions) {
super(conditions);
this.percent = percent;
}
@Override
protected Money getDiscountAmount(Screening screening) {
return screening.getMovieFee().times(percent);
}
}
Movie.java
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountPolicy = discountPolicy;
}
public Money getFee() {
return fee;
}
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
할인 정책이랑 인터페이스를 도입하여 보다 유연해진 걸 볼 수 있다. 또한, 합성은 상속에 비해 할인 정책이 추가하는데 드는 비용이 적다.
이처럼 변경 가능성이 큰 부분을 인터페이스를 통해 책임을 할당하는 방법을 변경 보호(Protected Variations)라 한다. 덕분에 캡슐화가 강해졌다. 앞서 언급한 "변경될 수 있는 부분까지 캡슐화하자" 가 실현됐다.
이는 기존 도메인 설계와 다르단 걸 알 수 있다. 그러나, 앞서 언급한 것 처럼 도메인 개념은 코드의 흐름에 따라 달라지므로 이러한 변경을 적극 수용하자.
아래 코드를 살펴보자.
ReservationAgency.java
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);
}
}
reserve()
메서드 본문이 너무 길어보인다. 이는 소프트웨어의 목적 3가지를 2개를 만족하지 못한다.
이 외에 재사용성이 떨어진다는 단점도 있다.
참고
이러한 메서드를 몬스터 메서드(Monster Method)라고 한다.
메서드를 최대한 가볍게 설계하자. 작은 메서드로 구성시 위에서 언급한 단점을 모두 해소할 수 있다.
변경된 ReservationAgency.java
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);
}
}
만약, 메서드를 추출했을 때 총 코드의 길이가 더 길어진다면 어떻게 해야할까? 전혀 상관없다. 메서드를 추출하는 이유는 코드 길이에서 이득을 보기 위함이 아니다. 재사용성, 직관성을 얻기 위함이다. 코드 길이가 길어질까 망설인다면 주저하지 말고 메서드를 기능별로 추출하자.