- 올바른 객체가 올바른 책임을 한다
- 책임은 낮은 결합도와 높은 응집도, 즉 설계 품질과 관련되어 있다
⇒ 이번 장에서는 데이터 중심의 설계를 통해 왜 상태 중심의 설계가 문제되는지 확인한다.
- 상태를 분할의 중심축으로 두기
- 구현 위주 → 변경 가능성 높음 (캡슐화 깨짐)
- 책임을 분할의 중심축으로 두기
- 인터페이스 → 변경에 안정적 (캡슐화)
데이터를 중심으로 Movie 객체를 다시 작성한다.
이번에는 DiscountPolicy가 빠지고 그 자리를 MovieType이 대체했다.
데이터로 놓고 본다면 DiscountPolicy는 객체에 딱 하나만 필요하기 때문이다.
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 enum MovieType {
AMOUNT_DISCOUNT, // 금액 할인 정책
PERCENT_DISCOUNT, // 비율 할인 정책
NONE_DISCOUNT // 미적용
}
위와 같이 한 객체 내에
(1)객체의 종류를 저장하는 데이터(movieType)와
(2)인스턴스에 따라 배타적으로 사용될 변수(discountAmount, discountPercent)를 함께 저장하는 것은
데이터 중심 설계에서 흔히 볼 수 있는 사례다.
정의한 데이터를 캡슐화하기 위해서 접근자와 제어자 (getter & setter)를 추가한다.
이제 할인 조건을 구한다. 할인 조건을 구하는 로직은 크게 두 부분으로 나뉜다 (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.getWhenScreend().getDayOfWeek().equals(condition.getDayOfWeek()) &&
condition.getStartTime().compareTo(screening.getWhenScreend().toLocalTime()) <= 0 &&
condition.getEndTime().compareTo(screening.getWhenScreend().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);
}
}
설계 트레이드오프는 두가지 관점으로 확인할 수 있다.
여기서 말하는 응집도란 변경의 관점에서 어떤 변경에 대해 얼마나 유연하게 처리할 수 있느냐임. 하나의 변경에 대해 하나의 모듈만 수정하면 된다면 응집도가 높은 것임.
변경의 관점
참고 결합도가 높아도 상관 없는 경우도 있다. 일반적으로 변경될 확률이 매우 적은 안정적인 모듈에 의존하는 것은 아무런 문제도 되지 않는다.
ex) 자바의 String, ArrayList
책임 중심의 영화 예매 시스템에서 다루게 될 구현은 같지만 캡슐화를 다루는 방식에 큰 차이가 있다.
책임 중심 설계는 자주 변경되는 구현부를 객체 내부에 숨기고 인터페이스만 공개함으로서 캡슐화한다.
캡슐화는 객체의 응집도와 결합도를 결정한다. 데이터 중심 설계의 문제점은 아래와 같다.
단일 책임 원칙 (Single Responsibility Principle)
로버트 마틴이 모듈의 응집도가 변경과 연관이 있다는 사실을 강조하기 위해서 제시한 설계 원칙
→ 클래스는 단 한 가지의 변경 이유만 가져야 한다
여기서 책임은 변경의 이유라는 의미로 사용된다. 단일 책임 원칙에서의 책임은 지금까지의 역할, 책임, 협력과는 다르며 변경과 관련된 더 큰 개념이다.
책임의 주체를 자기 자신으로 이동시킨다. 즉, 책임을 이동시킨다. 객체가 자기 스스로를 책임진다는 말의 의미는 이와 같다.
아래와 같이 사각형을 나타내는 클래스가 있을 때, 너비와 높이를 증가시키는 코드가 필요하다면 getter와 setter를 사용해서 꺼내쓰는 것이 아니라 객체 내부에서 계산한다.
public class Rectangle {
private int left;
private int top;
private int right;
private int bottom;
public Rectangle(int left, int top, int right, int bottom) {
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
}
}
public void enlarge(int multiple) {
right *= multiple;
bottom *= multiple;
}
이제 객체를 설계할 때 이 객체에 어떤 데이터를 포함해야 하는가? 에 대한 질문은 두개로 나눠서 생각해야 한다.
이 문제를 고려하며 최종적으로 위에서 정의한 ReservationAgency를 다시 설계해본다.
public class ReservationAgency {
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
Money fee = screening.calculateFee(audienceCount);
return new Reservation(customer, screening, fee, audienceCount);
}
}
첫번째 설계보다 두번째 설계가 내부 구현을 더 면밀하게 캡슐화한다. 객체 스스로 구현을 하고 있다. 따라서 이 객체들은 스스로를 책임진다고 말할 수 있다.
코드를 더 개선할 수 있다. 사실, 첫번째 코드에서 발생했던 문제(캡슐화 위반, 높은 결합도, 낮은 응집도)는 두번째 코드에서도 똑같이 드러난다.
DiscountCondition의 메서드를 보자.
매개변수를 통해 객체 내부에 DayOfWeek 타입 요일과 LocalTime 타입 시간 정보가 인스턴스 변수로 포함된 사실을 알 수 있다.
public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime time) {...}
public boolean isDiscountable(int sequence) {...}
만약 DiscountCondition의 속성을 변경해야 한다면? isDiscountable 메서드는 파라미터를 수정하고 이를 사용하는 모든 클라이언트도 함께 수정해야 한다.
→ 내부 구현의 변경이 외부로 퍼져나가는 파급 효과는 캡슐화가 부족하다는 명백한 증거다.
첫번째 설계에 비해 자기 자신을 스스로 처리한다는 점에서 개선되었으나, 여전히 캡슐화에는 실패했다.
캡슐화의 진정한 의미
캡슐화는 단순히 객체 내부의 데이터를 외부로부터 감추는 것 이상의 의미를 가진다. 캡슐화는 변경될 수 있는 어떤 것이라도 감추는 것을 의미한다.
속성의 타입, 할인 정책의 종류 상관 없이 내부 구현 변경으로 인해 외부 객체가 영향을 받는다면 캡슐화 위반이다.
캡슐화 위반은 결합도 상승과 응집도 하락을 초래한다.
DiscountCondition 내부 구현이 외부로 노출되었으므로 Movie와 DiscountCondition 사이 결합도는 높을 수밖에 없다.
Movie의 isDiscountable() 메서드를 보면 캡슐화 위반으로 인해 높은 결합도가 생긴 것을 확인할 수 있다.
위에서 언급한 부분은 DiscountCondition의 구현에 속한다. 인터페이스가 아니라 구현을 변경할 때에도 이에 의존하는 Movie를 변경해야 한다면 두 객체 사이의 결합도가 높다.
public boolean isDiscountable(LocalDateTime whenScreened, int sequence) {
for (DiscountCondition condition : discountConditions) {
if (condition.getType() == DiscountConditionType.PERIOD) {
if (condition.isDiscountable(whenScreened.getDayOfWeek(), whenScreened.toLocalTime())) {
return true;
}
} else {
if (condition.isDiscountable(sequence)) {
return true;
}
}
}
return false;
}
이러한 문제의 근본적인 원인은 캡슐화 위반이다.
이번에는 Screening이다.
DiscountCondition이 할인 여부 판단하는 데 필요한 정보가 변경된다면 Movie의 isDiscountable 메서드로 전달해야 하는 파라미터의 종류를 변경해야 한다.
→ 이로 인해 Screening에서 Movie에 의존하는 부분도 함께 변경해야 한다.
public Money calculateFee(int audienceCount) {
switch (movie.getMovieType()) {
case AMOUNT_DISCOUNT:
if (movie.isDiscountable(whenScreend, sequence)) {
return movie.calculateAmountDiscountedFee().times(audienceCount);
}
break;
case PERCENT_DISCOUNT:
if (movie.isDiscountable(whenScreend, sequence)) {
return movie.calculatePercentDiscountedFee().times(audienceCount);
}
}
return movie.calculateNoneDiscountedFee().times(audienceCount);
}
하나의 변경을 수용하기 위해서 코드 여러 곳을 동시에 변경해야 한다는 것은 설계 응집도가 낮다는 증거다.
→ 서로 다른 책임을 동일한 인터페이스에서 수행하고 있을 확률이 높다
응집도가 낮은 이유 역시 캡슐화 위반이다.
두번째 설계가 변경에 유연하지 못한 이유는 결국 캡슐화를 위반했기 때문이다.
- 데이터 중심 설계는 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요함
- 데이터 중심 설계에서 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정함
데이터도 구현의 일부일 뿐이다.