다형성을 통해 분리하기
할인 가능 여부를 반환해 주기만 하면 Movie는 객체가 sequenceCondition의 인스턴스인지, periodCondition의 인스턴스인지는 상관하지 않는다.
이 시점이 되면 자연스럽게 역할의 개념이 무대 위로 등장한다. Movie의 입장에서 sequenceCondition과 periodCondition이 동일한 책임을 수행한다는 것은 동일한 역할을 수행한다는 것을 의미한다.
역할을 사용하면 객체의 구체적인 타입을 추상화할 수 있다. 자바에서는 일반적으로 역할을 구현하기 위해 추상 클래스나 인터페이스를 사용한다.
역할을 대체할 클래스들 사이에서 구현을 공유해야 할 필요가 있다면 추상 클래스를 사용하면 된다.
구현을 공유할 필요 없이 역할을 대체하는 객체들의 책임만 정의하고 싶다면 인터페이스를 사용하면 된다.
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
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;
}
}
public class SequenceCondition implements DiscountCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
public boolean isSatisfiedBy(Screening screening) {
return sequence == screening.getSequence();
}
}
DiscountCondition의 경우에서 알 수 있듯이 객체의 암시적인 타입에 따라 행동을 분기해야 한다면 암시적인 타입을 명시적인 클래스로 정의하고 행동을 나눔으로써 응집도 문제를 해결할 수 있다.
객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당하라는 것이다
GRASP에서는 이를 다형성 패턴이라고 부른다.
변경으로부터 보호하기
그림 5.6을 보면 DiscountCondition의 두 서브클래스는 서로 다른 이유로 변경된다는 사실을 알 수 있다. SequenceCondition은 순번 조건의 구현 방법이 변경될 경우에만 수정된다. PeriodCondition은 기간 조건의 구현이 변경될 경우에만 수정된다. 두 개의 서로 다른 변경이 두 개의 서로 다른 클래스 안으로 캡슐화된다.
새로운 할인 조건이 추가되는 경우에도 오직 DiscountCondition 인터페이스를 실체화하는 클래스를 추가하는 것으로 할인 조건의 종류를 확장할 수 있다.
이처럼 변경을 캡슐화하도록 책임을 할당하는 것을 GRASP에서는 변경 보호 패턴이라고 부른다.
Movie 클래스 개선하기
Movie에서도 금액 할인 정책 영화와 비율 할인 정책 영화라는 두 가지 타입을 하나의 클래스 안에 구현하고 있기 때문에 하나 이상의 이유로 변경될 수 있다. 응집도가 낮다
코드를 개선하자. 금액 할인 정책과 관련된 인스턴스 변수와 메서드를 옮길 클래스의 이름으로는 AmountDiscountMovie가 적합할 것 같다. 비율 할인 정책과 관련된 인스턴스 변수와 메서드를 옮겨 담을 클래스는 PercentDiscountMovie로 명명하자. 할인 정책을 적용하지 않는 경우는 NoneDiscountMovie 클래스가 처리하게 할 것이다.
DiscountCondition의 경우에는 역할을 수행할 클래스들 사이에 구현을 공유할 필요가 없었기 때문에 인터페이스를 이용해 구현했다. Movie의 경우에는 구현을 공유할 필요가 있다. 따라서 추상 클래스를 이용해 역할을 구현하자
public abstract class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions;
public Movie(String title, Duration runningTime, Money fee, DiscountCondition... discountConditions) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountConditions = Arrays.asList(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));
}
protected Money getFee() {
return fee;
}
abstract protected Money calculateDiscountAmount();
}
할인 정책의 종류에 따라 할인 금액을 계산하는 로직이 달라져야 한다. 이를 위해 calculateDiscountAmount 메서드를 추상 메서드로 선언함으로써 서브클래스들이 할인 금액을 계산하는 방식을 원하는데로 오버라이딩할 수 있게 했다.
금액 할인 정책(AmountDiscountMovie)
public class AmountDiscountMovie extends Movie {
private Money discountAmount;
public AmountDiscountMovie(String title, Duration runningTime, Money fee, Money discountAmount,
DiscountCondition... discountConditions) {
super(title, runningTime, fee, discountConditions);
this.discountAmount = discountAmount;
}
@Override
protected Money calculateDiscountAmount() {
return discountAmount;
}
}
비율 할인 정책(PercentDiscountAmount)
public class PercentDiscountMovie extends Movie {
private double percent;
public PercentDiscountMovie(String title, Duration runningTime, Money fee, double percent,
DiscountCondition... discountConditions) {
super(title, runningTime, fee, discountConditions);
this.percent = percent;
}
@Override
protected Money calculateDiscountAmount() {
return getFee().times(percent);
}
}
할인 요금을 계산하기 위해서는 영화의 기본 금액이 필요하다.
public abstract class Movie {
protected Money getFee() {
return fee;
}
}
할인 정책을 적용하지 않기 위해서는 NoneDiscountMovie 클래스를 사용하면 된다.
public class NoneDiscountMovie extends Movie {
public NoneDiscountMovie(String title, Duration runningTime, Money fee) {
super(title, runningTime, fee);
}
@Override
protected Money calculateDiscountAmount() {
return Money.ZERO;
}
}
모든 클래스의 내부 구현은 캡슐화돼 있고 모든 클래스는 변경의 이유를 오직 하나씩만 가진다. 각 클래스는 응집도가 높고 고 다른 클래스와 최대한 느슨하게 결합돼 있다.
데이터가 아닌 책임을 중심으로 설계를 해야한다. 객체에게 중요한 것은 상태가 아니라 행동이다. 객체지향 설계의 기본은 책임과 협력에 초점을 맞추는 것이다.
변경과 유연성
설계를 주도하는 것은 변경이다.
개발자로서 변경에 대비할 수 있는 두 가지 방법이 있다.
1) 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계하는 것
2) 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것
상속 대신 합성을 사용한다. Movie의 상속 계층 안에 구현된 할인정책을 독립적인 DiscountPolicy로 분리한 후 Movie에 합성시키면 유연한 설계가 왼성된다.
04 책임 주도 설계의 대안
책임과 객체 사이에서 방황할 때 돌파구를 찾기 위해 선택하는 방법은 최대한 빠르게 목적한 기능을 수행하는 코드를 작성하는 것이다. 일단 실행되는 코드를 얻고 난 후에 코드 상에 명확하게 드러나는 책임들을 올바른 위치로 이동시키는 것이다.
캡슐화를 향상시키고, 응집도를 높이고, 결합도를 낮춰야 하지만 동작은 그대로 유지해야 한다. 이처럼 이해하기 쉽고 수정하기 쉬운 소프트웨어로 개선하기 위해 겉으로 보이는 동작은 바꾸지 않은 채 내부 구조를 변경하는 것을 리팩터링이라고 부른다
매서드 응집도
데이터 중심으로 설계된 영화 예매 시스템에서 도메인 객체들은 단지 데이터의 집합일 뿐이며 영화 예매를 처리하는 모든 절차는 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);
}
}
reserve 메서드는 길이가 너무 길고 이해하기도 어렵다.
긴 메서드는 응집도가 낮기 때문에 이해하기도 어렵고 재사용하기도 어려우며 변경하기도 어렵다.
응집도 높은 메서드는 변경돼는 이유가 단 하나여야 한다. 클래스가 작고, 목적이 명확한 메서드들로 구성돼 있다면 변경을 처리하기 위해 어떤 메서드를 수정해야 하는지를 쉽게 판단할 수 있다.
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 boolean checkDiscountable(Screening screening) {
return screening.getMovie().getDiscountConditions().stream()
.anyMatch(condition -> isDiscountable(condition, screening);
}
public boolean isDiscountable(Screening screening) {
if (type == DiscountConditionType.PERIOD) {
return isSatisfiedByPeriod(screening);
}
return isSatisfiedBySequence(screening);
}
private boolean isSatisfiedByPeriod(DiscountCondition condition, Screening screening) {
return screening.getWhenScreened().getDayOfWeek().equlas(condition.getDayOfWeek()) &&
condition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
condition.getEndTime().compareTo(screening.getWhenScreended().toLocalTime()) >= 0;
}
private boolean isSatisfiedBySequence(DiscountCondition condition, Screening screening) {
return condition.getSequence() == screening.getSequence();
}
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);
}
}
ReservationAgency 클래스는 오직 하나의 작업만 수행하고, 하나의 변경 이유만 가지는 작고, 명확하고, 응집도가 높은 메서드들로 구성돼 있다.
public class ReservationAgency {
public boolean isDiscountable(Screening screening) {
if (type == DiscountConditionType.PERIOD) {
return isSatisfiedByPeriod(screening);
}
return isSatisfiedBySequence(screening);
}
}
할인 조건 중에서 기간 조건을 판단하는 규칙이 변경된다면 isSatisfiedByPeriod 메서드를 수정하면 된다.
할인 규칙 중에서 금액 할인 규칙이 변경된다면 calculateDiscountedFee 메서드를 수정하면 된다. 예매요금을 계산하는 규칙이 변경된다면 calculateFee 메서드를 수정하면 된다.
객체를 자율적으로 만들자
자신이 소유하고 있는 데이터를 자기 스스로 처리하도록 만드는 것이 자율적인 객체를 만드는 지름길이다.
public class ReservationAgency {
public boolean isDiscountable(Screening screening) {
if (type == DiscountConditionType.PERIOD) {
return isSatisfiedByPeriod(screening);
}
return isSatisfiedBySequence(screening);
}
private boolean isSatisfiedByPeriod(DiscountCondition condition, Screening screening) {
return screening.getWhenScreened().getDayOfWeek().equlas(condition.getDayOfWeek()) &&
condition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
condition.getEndTime().compareTo(screening.getWhenScreended().toLocalTime()) >= 0;
}
private boolean isSatisfiedBySequence(DiscountCondition condition, Screening screening) {
return condition.getSequence() == screening.getSequence();
}
}
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public DiscountCondition(int sequence){
this.type = DiscountConditionType.SEQUENCE;
this.sequence = sequence;
}
public DiscountCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime){
this.type = DiscountConditionType.PERIOD;
this.dayOfWeek= dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
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.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
}
private boolean isSatisfiedBySequence(Screening screening) {
return sequence == screening.getSequence();
}
}
public class ReservationAgency {
private boolean checkDiscountable(Screening screening) {
return screening.getMovie().getDiscountConditions().stream()
.anyMatch(condition -> condition.isDiscountable(screening));
}
}
'그룹 스터디 공부(IT 서적) > 오브젝트' 카테고리의 다른 글
07 객체 분해 (0) | 2023.08.21 |
---|---|
06 메시지와 인터페이스 (0) | 2023.08.18 |
05 책임 할당하기_01 (0) | 2023.08.07 |
04 설계 품질과 트레이드 오프 (0) | 2023.07.31 |
03 역할,책임,협력 (0) | 2023.07.24 |