책 오브젝트를 기반으로 하는 코드스피츠 강의 오브젝트 - 6회차 를 정리한 내용입니다.
합성과 의존성
디자인 패턴으로 가장 쉽게 합성과 상속의 차이점을 알아보는 방법은
Template method, strategy 각 패턴이 문제를 해결하는 방식을 알아보는 것이다.
객체망을 구성하면서 객체간의 의존성이 생긴다는 것이다.
의존성을 적절하게 맺는 방법은 양방향 참조가 되지 않도록 하는 것이다.
Template method pattern
일반적인 상속의 단점
상속의 단점은 부모의 수정 여파가 자식에게 영향을 미친다.
Template method pattern 은 부모 자식간의 의존성 방향 역전하게 되면 자식이 부모를 가르키게 된다.
protected abstract Money calculateFee(Money fee); 추상 메소드를 이용해서 부모가 자식을 가르키게 된다.
abstract class DiscountPolicy {
private Set<DiscountCondition> conditions = new HashSet<>();
public void addCondition(DiscountCondition condition) {
conditions.add(condition);
}
// 부모의 함수를 수정하면 모든 자식의 여파가 미치게 된다.
public Money calculateFee(Screening screening, int count, Money fee) {
for (DiscountCondition condition : conditions) {
if (condition.isSatisfiedBy(screening, count)) return calculateFee(fee);
}
return fee;
}
// 템플릿메소드에서 사용하는 Hook 이다.
// 추상 메소드를 이용해서 부모가 자식을 가르키게 된다.
protected abstract Money calculateFee(Money fee);
}
책에서는 상속이 무조건 나쁜것은 아니지만 부모의 변화가 자식에게 여파를 일으키지 않도록 스킬을 사용해야 한다라고 소개하면서 Template method pattern 은 상속의 좋은 점이라고 이야기한다.
Template method 자식
오브젝트 이전 강의에서 말했듯이 부모 - 자식간에서 헐리우드 원칙을 지켜야 한다고 말한적이 있다.
아래 예제에서는 getter 로 이용해서 어떤 동작을 하지 않는다.
자식에서 private 가 아닌 protected 로 선언된 부모의 내부 속성이나 getter, setter 를 쓰면 그것은 좋지않다.
public class AmountPolicy extends DiscountPolicy {
public final Money amount;
public AmountPolicy(Money amount) {
this.amount = amount;
}
@Override
// 부모 자식 간 헐리우드 원칙
protected Money calculateFee(Money fee) {
return fee.minus(amount);
}
}
코드스피츠 오브젝트 4회차에서는 Generic 편에서는
Element 가 Collector 를 아는 편보다 Collector 가 Element 를 아는 것은 여러 원소를 알아야 하므로 무게가 무거워진다고 설명한 적이 있다.
Element(extends DiscountPolicy) Collector(DiscountPolicy) DiscountPolicy 가 수많은 자식을 아는 것보다는
Element(abstract calculateFee) Collector(DiscountPolicy) DiscountPolicy 가 abstract calculateFee 단일 포인트를 아는 것이 무거가 훨씬 가볍다.
Strategy Pattern
안정화되고 상속층으로 만들어도 될 거 같다면 Template method pattern 을 사용하고,
유연하게 더 연결될 수 있을 것 같다면 Strategy pattern 을 사용한다.
보통은 Strategy pattern 으로 시작해서 유연성을 충분히 확보하고 안정화 됐다고 생각하면 Template method pattern 으로 바꾼다.
public class DiscountPolicy {
private final Set<DiscountCondition> conditions = new HashSet<>();
// 합성을 사용하기 때문에 calculator 소유하게 된다.
private final Calculator calculator;
// Calculator 라는 외부의 객체 도움을 받게 된다.
public DiscountPolicy(Calculator calculator) {
this.calculator = calculator;
}
public void addCondition(DiscountCondition condition) {
conditions.add(condition);
}
public Money calculateFee(Screening screening, int count, Money fee) {
for (DiscountCondition condition : conditions) {
// 외부 객체 calculator.calculateFee 사용한다.
if (condition.isSatisfiedBy(screening, count)) return calculator.calculateFee(fee);
}
return fee;
}
}
Strategy pattern 은 상속을 사용하지 않고 합성을 사용한다.
합성을 사용하면서 외부 객체의 도움을 받는 Calculator 소유하게 된다.
Template method VS Strategy
구현된 자식 클래스를 보면 차이점은 빨간색 박스 밖에 없다.
이걸로 DiscountPolicy 의 부모는 Calculator 의 인터페이스를 수행하고 있었다는 걸 알 수 있다.
즉, 상속을 사용하더라도 부모처럼 사용하지 말고 Interface 처럼 사용해야 한다.
is-a 관계
Template method pattern을 사용할 때 장점은 의존성 관계가 단순해진다.
부모 DiscountPolicy - 자식 AmountPolicy 사이의 의존성이 역전된다.
has-a 관계
DiscountPolicy 가 AmountCalculator 의 직접 참조를 피하기 위해서 중간에 Calculator 를 끼워 넣어 단방향 의존성을 해결한다.
DiscountPolicy 의 무거운 역할이 Calculator 에게 옮겨지면서 변경에 취약해진다.
Strategy pattern을 사용할 때 주의점은 interface 에 대한 확신이 없다면 함부로 Calculator 변경을 하면 모든 곳에 여파가 미치기 때문에 굉장히 힘든일이 된다.
우리의 전략은?
오브젝트 5회차에서 시간이 지남에 따라 새로운 요구사항이 필요하다는 것을 깨닫는 과정을 살펴보았다.
처음부터 모든 도메인을 알 수 없기 때문에 Strategy 의 Calculator 를 사용하므로 충격을 흡수하도록 설계하면 "변화를 수용할 수 여지"가 생긴다.
어느정도 도메인이 확정되었다면 Template method pattern을 사용하여 제한된 의존성을 단순화시키도록 한다.
Client 관점
Template method
Client 는 if 를 통해서 원하는 class 를 생성해야 한다.
template method pattern 는 Pointer of Pointer 가 아니고 Pointer 전략이다!
즉, template method pattern은 Runtime 에 형 자체를 변경해야 한다.
Strategy
DiscountPolicy를 생성하고 합성해야 할 객체를 if 를 통해서 생성해야 한다.
Strategy pattern은 Pointer of Pointer 전략이다.
즉 대체 가능성을 실현하기 위해서 Runtime 에 Calculator 를 바꿀 수 있다.
Template method 단점
상속 계층이 깊어지거나 중첩 구조인 경우에 조합할 수 있는 경우의 수가 계속 늘어나는 것을 조합 폭발이라고 한다.
계층이 깊어지거나 중첩 구조가 늘어날 수록 폭발적으로 조합이 일어난다.
2 * 2 * 2 * 2 * 2
Strategy 단점
필요한 것들만 합성하면 되기 때문에 조합 폭발이 일어나지 않는다.
하지만 의존성이 폭발이 일어난다.
관계에 대해 알아야 되는 경우의 수만큼 의존성이 생기기 때문에 의존성 폭발이 발생한다.
Template method pattern 조합 폭발은 해결할 방법이 없다.
Strategy pattern 의존성 폭발은 해결할 수 있다.
생성 사용 패턴과 팩토리
생성 사용 패턴은 객체를 생성하기 위한 코드와 사용하는 코드가 있다.
생성하기 위한 코드와 사용하는 코드의 생명주기가 다르며 관리하기 힘들어 병행해서 사용하면 유지보수가 힘들어 진다.
Service 에서 생성하는 코드를 Client 쪽으로 밀어낸다.
Client 에서 Service 쪽으로 객체를 주입하도록 설계된다.
✌️ 우리는 코드스피츠 오브젝트 4회차에서 안쪽의 Service 코드로부터 생성하는 코드를 Client 코드로 밀어내는 과정을 학습했다.
Pushed 란 표현은 해당 객체가 원하지 않은대도 불구하고 뭔가를 계속 밀어넣는 것을 의미한다.
때문에 독립된 책임과 역할을 가진 DiscountPolicy 는 제어권을 상실한다.
interface CalculatorFactory {
Calculator getCalculator();
}
public class AmountCalculatorFactory implements CalculatorFactory {
public final Money money;
private AmountCalculator cache;
public AmountCalculatorFactory(Money amount) {
this.money = amount;
}
@Override
synchronized public Calculator getCalculator() {
if (cache == null) cache = new AmountCalculator(money);
return cache;
}
}
DiscountPolicy 의 제어권을 회복하기 위해선 Pushed 가 아닌 Pulled 전략을 취해야 한다.
이제 DiscountPolicy 는 자신이 원할때 객체를 생성할 수 있게 되었다.
디미터법칙 위반
supplier.getCalculator().caculateFee(fee) 이 코드는 디미터 법칙을 위반하고 있다.
일반적으로 단순하게 객체를 반환하는 Factory는 무조건 디미터의 법칙을 위반한다.
디미터 법칙 위반 해결 방법:
1. DiscounPolicy 가 factory, calculator 둘 다 알도록 함
하지만 이 방법은 좋지 않다.
그 이유는 추상 클래스와 구상 클래스들간에 순환 참조가 생기기 때문이다.
✌️ 우리는 코드스피츠 오브젝트 3회차에서 순환참조는 무조건 좋지 않다라는 걸 배웠다.
2. DiscounPolicy 가 factory 만 알도록 함
Factory 만 알도록 하기 위해선 caculateFee 자체를 위임해야 한다.
public interface CalculatorFactory {
// Factory 만 알도록 하기 위해선 caculateFee 자체를 위임해야 한다.
Money calculateFee(Money fee);
}
public class AmountCalculatorFactory implements CalculatorFactory {
public final Money money;
private AmountCalculator cache;
public AmountCalculatorFactory(Money money) {
this.money = money;
}
synchronized public Calculator getCalculator() {
if (cache == null) cache = new AmountCalculator(money);
return cache;
}
@Override
public Money calculateFee(Money fee) {
return getCalculator().calculateFee(fee);
}
}
여기서 우리가 중요하게 생각할 점은 "위임은 선택이 아니다" 이다.
일반적인 Factory 에서 순환참조를 발생시키지 않기 위해선 위임은 필수이다.
위임된 팩토리의 정체는 원래 구상 Calculator 그 자체이구나! 라는 걸 깨닫게 된다.
따라서 Factory 는 처음부터 존재하지 않았던 것이다. 😨
따라서 CalculatorFactory 는 Calculator 이었다. 😨😨
단지 위임된 팩토리는 Factory 가 보이지 않았을 뿐이다.
위 그림은 순환 참조하는 것 처럼 보이지만 AmountCalculator 의 역할이 AmountCalculatorFactory 로 역할을 겸하면서 AmountCalculatorFactory 가 사라지게 된다.
추상 팩토리 메소드 패턴
public interface PolicyFactory extends Calculator {
Set<DiscountCondition> getConditions();
}
public class AmountCalculatorFactory implements PolicyFactory {
public final Money money;
private AmountCalculator cache;
// DiscountPolicy -> AmountCalculatorFactory 이사했다.
private final Set<DiscountCondition> conditions = new HashSet<>();
public AmountCalculatorFactory(Money money) {
this.money = money;
}
synchronized public Calculator getCalculator() {
if (cache == null) cache = new AmountCalculator(money);
return cache;
}
public void addCondition(DiscountCondition condition) {
conditions.add(condition);
}
public void removeCondition(DiscountCondition condition) {
conditions.remove(condition);
}
@Override
// Calculator 는 직접 주지 않고 위임해서 준다.
public Money calculateFee(Money fee) {
return getCalculator().calculateFee(fee);
}
@Override
// DiscountCondition 은 직접 반환하고 있다.
public Set<DiscountCondition> getConditions() {
return conditions;
}
}
AmountCalculatorFactory는
DiscountCondition 은 직접 주고있다.
Calculator 는 직접 주지 않고 위임해서 준다.
AmountCalculatorFactory 를 사용하는 이유는 DiscountPolicy 의존성을 하나로 심플하게 만들기 위함이다.
Factory 패턴의 진정한 의미는 나의 코드에 알고리즘으로 되어 있는 부분을 Interface 로 바꿔서 외부에서 생성을 하도록 하는 것이다.
for (: factory.getConditions()) 는 어떤 condition 을 반환할지 모르기 때문에 디미터의 법칙을 위반한다.
DiscountPolicy 는 DiscountCondition 존재를 모르기 때문에 또 다시 디미터의 법칙을 위반하고 있다.
위에 두 디미터 법칙을 해소하기 위해선 아래 파란색 박스만큼 위임을 해야한다.
public interface PolicyFactory extends Calculator {
default Money calculatorFee(Screening screening, int count, Money fee) {
for (DiscountCondition condition : getConditions()) {
if (condition.isSatisfiedBy(screening, count)) return calculateFee(fee);
}
return fee;
}
Set<DiscountCondition> getConditions();
}
원래부터 Factory 는 DiscountCondition 을 알고 있었기 때문에 디미터의 원칙을 만족할 수 있다.
PolicyFactory 는 2개의 Hook 를 가지고 있다.
- calculateFee(fee:)
- getConditions()
위에서 Hook 은 최대한 1개만 사용하는 것을 권장하고 있다.
그러면서 2개의 Hook 을 사용하고 Factory에 조합 폭발을 PolicyFactory 로 미뤄버렸다.
public class DiscountPolicy {
private final PolicyFactory factory;
public DiscountPolicy(PolicyFactory factory) {
this.factory = factory;
}
public Money calculateFee(Screening screening, int count, Money fee) {
return factory.calculateFee(screening, count, fee);
}
}
이제 DiscountPolicy 는 생성코드가 사라지고 사용코드만 남게된다.
PolicyFactory 는 DiscountCondition, DiscountPolicy 의 의존성만 가져야 하지만 Screnning 의 의존성도 가지게 된다.
결국에 Abstract factory method pattern 조차 위임된 factory pattern 형태를 띄게된다.
그에 반해 DiscountPolicy 는 PolicyFactory만의 의존성을 가지고 DiscountCondition, DiscountPolicy, Screnning 의 의존성을 갖지 않도록 보호할 수 있다.
또, DiscountPolicy 를 의존하고 있는 다른 객체들도 안전해진다.
즉, 비지니스 시스템이 안전해진다.
따라서 아키텍처 상에서 가장 먼저 알아야 할 것은 변화에 가장 먼저 대응하도록 보호해야할 객체를 알아내는 것이다.
마무리
책에서 설명하고 있는 책임별로 역할을 부여하는 것은 능숙해지기까지 오래걸린다.
손쉽게 알수있는 방법은 가장 변화율이 많은 부분을 찾아내고 그 부분들을 전략패턴에 의해 합성 객체로 만들어버린다.
따라서 모든 의존성 문제가 나타날때마다 알고리즘을 우선시하지 말고 의존해야할 객체들을 확정한다면 자연스럽게 설계가 이루어진다.
이번장의 내용은 상당히 어려웠다.
객체를 설계하면서 왜 by pass 식의 함수가 많은지 깨닫지 못했지만 지금은 조금 알것 같다.
이제 객체지향 설계의 입장권을 끊은거다....
'객체지향설계' 카테고리의 다른 글
코드스피츠 2강 오브젝트 - 1회차 (0) | 2019.10.28 |
---|---|
코드스피츠 2강 오브젝트 - 2회차(1) (0) | 2019.10.28 |
코드스피츠 1강 오브젝트 5회차 (0) | 2019.10.25 |
코드스피츠 1강 오브젝트 4회차 (0) | 2019.10.24 |
코드스피츠 1강 오브젝트 3회차 (0) | 2019.10.22 |