3. 구현을 통한 검증
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); } public LocalDateTime getWhenScreened() { return whenScreened; } public int getSequence() { return sequence; } }
- calculateFee는 수신자인 moive가 아니라 송신자인 Screening의 의도를 표현한것
- moive에 내부구현에 대한 어떤 지식도 없이 전송할 메시지를 결정했다는 것이다.
- 이렇게 movie의 구현을 고려하지 않고 필요한 메시지를 결정하면 Moive의 내부 구현을 깔끔하게 캡슐화 할수 있다.
- Moive와 Screening의 연결관계는 메시지 뿐이다. 따라서 moive의 내부구현에 어떠한 수정을 가하더라도 Screening에는 영향을 미치지 않는다.
- 메시지를 기반으로 협력을 구성하면 Screening와 Moive 사이의 결합도를 느슨하게 유지할수있다.
- Movie를 구현
- Screening에서 보낸 메시지를 응답하는 메서드를 구현해야 한다.public class Movie { private String title; private Duration runningTime; private Money fee; private MovieType movieType; private Money discountAmount; private double discountPercent; private List<PeriodCondition> periodConditions; private List<SequenceCondition> sequenceConditions; public Movie(String title, Duration runningTime, Money fee, List<PeriodCondition> periodConditions, List<SequenceCondition> sequenceConditions) { this.title = title; this.runningTime = runningTime; this.fee = fee; this.periodConditions = periodConditions; this.sequenceConditions = sequenceConditions; } public Money calculateMovieFee(Screening screening) { if (isDiscountable(screening)) { return fee.minus(calculateDiscountAmount()); } return fee; } 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)); } 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; } }
public enum MovieType { AMOUNT_DISCOUNT, // 금액 할인 정책 PERCENT_DISCOUNT, // 비율 할인 정책 NONE_DISCOUNT // 미적용 } 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(); } }
DiscountCondition 개선하기
- 변경에 취약한 클래스를 포함하고 있다.
- 그것은 바로 DiscountCondition이다. 메시지를 보내는것이아닌 값을 받아와서 처리하고 있기 때문이다.
- 3가지 이유로 변경될수 있다.
- 새로운 할인 조건 추가
- isSatisfiedBy 메소드 안의 if else 구문을 수정해야 한다.
- 새로운 할인조건이 새로운 데이터를 요구한다면 DiscountCondition에 속성을 추가하는 작업도 필요
- 순번 조건을 판단하는 로직
- isSatisfiedBySequence 메소드의 내부 구현을 수정해야한다.
- 순번조건을 판단하는데 필요한 데이터가 변경된다면 DIscountCondition의 Sequence 속성 역시 변경해야 할것이다.
- 기간 조건을 판단하는 로직이 변경되는 경우
- isSatisfiedByPeriod 메소드의 내부 구현을 수정해야 한다.
- 기간조건을 판단하는데 필요한 데이터가 변경되면 DiscountCondition의 속성도 변경해야하기 떄문이다.
- DiscountCondition은 하나 이상의 변경 이유를 가지기 때문에 응집도가 낮다.
- 응집도가 낮다는것은 서로 연관성이 없는 기능이나 데이터가 하나의 클래스안에 뭉쳐져있다는것을 의미한다.
- 낮은 응집도가 유발하는 문제를 해결하기 위해서는 변경의 이유에 따라 클래스를 분리해야 한다
- 코드를 통해 변경의 이유를 파악할 수 있는 첫번째 방법은 인스턴스 변수가 초기화 되는 시점 을 살펴보는 것이다
- 응집도가 높은 클래스는 인스턴스를 생성할때 모든 속성이 함께 초기화 된다.
- 반면 응집도가 낮은 클래스는 객체의 속성 중 일부만 초기화하고 일부는 초기화 되지 않은 상태로 남겨진다.
- DiscountCondition을 살펴보면 조건에 따라 특정 인스턴스변수만 초기화된다.
- 클래스의 속성이 서로 다른시점에 초기화되거나 일부만 초기화된다는 것은 응집도가 낮다는 증거다.
- 따라서 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다
- 코드를 통해 변경의 이유를 파악할수 있는 두번째 방법은 메서드들이 인스턴스 변수를 사용하는 방식 을 살펴보는 것이다.
- 모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다고 볼수 있다.
- 반면 메소드를 사용하는 속성에 따라 그룹이 나뉜다면 클래스의 응집도가 낮다고 볼수 있다.
- 특정메서드는 특정 필드만 사용한다
- 이럴경우 응집도를 높이기 위해서 속성 그룹과 해당 그룹에 접근하는 메소드 그룹을 기준으로 코드를 분리해야 한다
클래스 응집도 판단하기
- 클래스가 하나 이상의 이유로 변경돼야 한다면 응집도가 낮은것이다. 변경의 이유를 기준으로 클래스를 분리하라.
- 클래스의 인스턴스를 초기화하는 시점에 경우에 따라 서로 다른 속성들을 초기화하고 있다면 응집도가 낮은것이다. 초기화되는 속성의 그룹을 기준으로 클래스를 분리하라.
- 메서드 그룹이 속성 그룹을 사용하는지 여부로 나뉜다면 응집도가 낮은것이다. 이들 그룹을 기준으로 클래스를 분리하라.
타입 분리하기
- DiscountCount는 순번 조건과 기간조건이라는 두개의 독립적인 타입이 하나의 클래스안에 공존하고 있다는 것이다.
- 두 타입을 SequenceCondition과 PeriodCondition이라는 두개의 클래스로 분리하자.
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.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 && endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0; } } public class SequenceCondition { private int sequence; public SequenceCondition(int sequence) { this.sequence = sequence; } public boolean isSatisfiedBy(Screening screening) { return sequence == screening.getSequence(); } }
- 분리하면 위에 언급한 문제들이 모두 사라진다.
- 하지만 문제가 발생 Moive와 협력하는 클래스는 DiscountCondition 하나뿐이였는데 두개로 늘어나버렸다.
- 첫번째로 해결하는 방법은 두가지 목록을 각각 필드로 가지고 있는것이다.
- 하지만 이문제는 DiscountCondition을 구현한 클래스 양쪽 모두에게 결합된다는 것이다.
- 클래스를 분리한 후에 설계의 관점에서 결합도가 높아졌다.
- 또한 새로운 할인조건을 추가하기가 더 어려워졌다는 것이다.
- 할인조건을 추가하려면 새로운 조건리스트를 또 추가해야된다.
- 응집도는 높아졌지만 변경과 캡슐화라는 관점에서 보면 전체적으로 설계의 품질이 나빠졌다.
다형성을 통해 분리하기
- Moive의 입장에선 SequenceCondition이나 PeriodCondition은 아무 차이가 없다.
- 이때 역할 이라는 개념이 등장한다.
- Moive의 입장에서 SequenceCondition과 PeriodCondition이 동일한 책임을 수행한다는 것은 동일한 역할을 수행한다는것을 의미한다.
- 역할은 협력안에서 대체 가능성을 의미
- 역할을 구현하기 위해 추상클래스나 인터페이스를 사용하고 Moive에는 그 인터페이스만 바라보게해서 역할이 어떤건지만 알게하자.
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(); } } 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)); } }
- Moive가 가진 discountConditions에 인스턴스가 sequenceCondition이라면 이 클래스의 메소드를 실행할 것이고 periodCondition이라면 이 클래스의 메소드를 실행할 것이다.
- 즉 Moive와 DiscountCondition 사이의 협력은 다형적이다
- DiscountCondition에서 알수 있듯이 객체의 암시적인 타입에 따라 행동을 분기해야한다면 암시적인 타입을 명시적인 클래스로 정의하고 행동을 나눔으로써 응집도를 해결할수 있다.
- 다시 말해 객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당하라는 것이다.
- 이를 GRASP에서는 POLYMORPHISM ( 다형성 ) 패턴이라고 한다.
그림출처 https://songii00.github.io/2020/01/27/2020-01-27-OBJECTS Item 02/
다형성 패턴
- 조건에 따른 변화 if else 또는 switch case등의 조건논리를 사용해서 설계한다면 새로운 조건이 추가될 경우 논리도 수정해야한다.
- 이것은 변경에 취약해진다.
- 하지만 다형성 패턴은 조건적인 논리를 사용하지말라고 경고하는대신 다형성을 사용해 새로운 변화를 다루기 쉽게 확장하라고 권고한다.
변경으로 부터 보호하기.
- 위의 구조에서 DiscountCondition이 새로운 할인조건을 추가한다고 했을경우
- DiscountCOndition이 Moive로 부터 Sequence와 period의 존재를 감춘다
- 결국 Moive는 새로운 할인조건이 추가되더라도 영향을 받지 않는다.
- 이처럼 변경을 캡슐화하도록 책임을 할당하는것을 GRASP에서는 PROTECTED VARIATIONS ( 변경 보호 ) 라고 한다.
Protected Variations 패턴
- 변화가 예상되는 불안정한 지점들을 식별하고 그 주위에 안정된 인터페이스를 형성하도록 책임을 할당하라.
- Protected Variations패턴은 책임 할당의 관점에서 캡슐화를 설명한 것이다.
- 우리가 캡슐화해야하는것은 변경이다. 변경이 될 가능성이 높은가? 그렇다면 캡슐화하라.
- 하나의 클래스가 여러 타입의 행동을 구현하고 있는것 같다면 ? 클래스를 분해하고 다형성패턴에 따라 책임을 분산시키자.
- 예측 가능한 변경으로 인해 여러 클래스들이 불안정해진다면 Protected Variations 패턴에 따라 안정적인 인터페이스 뒤로 변경을 캡슐화하라.
- 두 패턴을 조합하면 변경과 확정에 유연하게 대처할수 있는 설계를 얻을것이다.
Moive 클래스 개선하기
- Moive도 할인정책으로 여러 타입의 행동을 구현하고있다
- 위와 같이 다형성 패턴을 이용해 분리하자.
- 그리고 Protected Variations 패턴을 이용해 타입의 종류를 안정적인 인터페이스 뒤로 캡슐화할수 있다는것을 의미한다.
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(); } 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; } } 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); } }
- 이제 클래스의 모든 내부 구현을 끝냈다.
- 모든 내부구현은 캡슐화 돼있고 모든 클래스는 변경의 이유를 오직 하나씩만 가진다.
- 각 클래스는 응집도가 높고 다른 클래스와 최대한 느슨하게 결합돼 있다.
- 객체지향설계의 기본은 책임과 협력에 초점을 맞추는 것이다.
도메인 구조가 코드의 구조를 이끈다.
- 변경 역시 도메인모델의 일부다.
- 도메인 모델에는 도메인 안에서 변하는 개념과 이들 사이의 관계가 투영돼 있어야 한다.
- 도메인모델에는 할인정책과 할인조건이 변경될수 있다는 도메인에 대한 직관이 반영돼있다.
- 이 직관이 우리가 설계를 가져야 하는 유연성을 이끌었다.
- 강조하지만 구현을 가이드할 수 있는 도메인 모델을 선택하라. 객체지향은 도메인의 개념과 구조를 반영한 코드를 가능하게 만들기 때문에 도메인의 구조가 코드의 구조를 이끌어내는 것은 자연스러울뿐만 아니라 바람직한 것이다.
변경과 유연성
- 설계를 주도하는 것은 변경이다.
- 변경에 대비하는 두가지 방법은 하나는 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계하는 것이다.
- 다른 하나는 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것이다.
- 유사한 변경이 반복적으로 발생하고 있다면 복잡성이 상승하더라도 유연성을 추가하는 두번째 방법이 더 좋다.
- 예를 들어 영화에 설정된 할인 정책을 실행중에 변경할수 있어야 한다는 요구사항이 추가 됬다고 하면
- 현재 설계에서는 할인정책을 구현하기 위해 상속을 사용하고 있기떄문에 실행중에 영화의 할인정책을 변경하기위해서는 새로운 인스턴스를 생성한후 필요한 정보를 복사해야한다.
- 또한 변경 전후의 인스턴스가 개념적으로는 동일한 객체를 가리키지만 물리적으로는 서로 다른 객체이기 때문에 식별자의 관점에서 혼란스러울 수 있다.
- 런타임시에 변경하기에 좋은 방법은 아니다 이럴때 합성이 좋은 해결방안이 된다.
- Moive의 상속 계층안에 구현된 할인정책을 DiscountPolicy로 독립적으로 분리한후 Moive에 합성시키면 유연한 설계가 완성된다.
- 합성을 사용하면 간단하게 Moive에 연결된 DiscountPolicy 인스턴스를 교체하면 끝이다.
- 이 예는 유연성에 대한 압박이 설계에 어떤 영향을 미치는지를 잘 보여준다.
- 실제로 유연성은 의존성 관리의 문제다.
- 유연성의 정도에 따라 결합도를 조절할수 있는 능력은 객체지향 개발자가 갖춰야하는 중요한 기술중 하나다.
코드의 구조가 도메인의 구조에 대한 새로운 통찰력을 제공한다.
- 코드의 구조가 바뀌면 도메인에 대한 관점도 함께 바뀐다.
- 도메인 모델은 도메인에 포함된 개념과 관계뿐만 아니라 도메인이 요구하는 유연성도 정확하게 반영해야한다.
- 도메인 모델은 단순히 도메인의 개념과 관계를 모아놓은 것이 아니다.
- 도메인 모델은 구현과 밀접한 관계를 맺어야 한다.
- 도메인 모델은 코드에 대한 가이드를 제공할 수 있어야 하며 코드의 변화에 발맞춰 함께 변화해야한다.
- 도메인 모델을 코드와 분리된 막연한 무엇으로 생각하지 말자.