BottleH Blog

Object - 11장 합성과 유연한 설계

    Tags

  • java
Object - 11장 합성과 유연한 설계 thumbnail

상속

  • 부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결
  • is-a 관계
  • 클래스 사이의 정적 관계

합성

  • 부모 클래스와 자식 클래스 사이의 의존성은 런타임에 해결
  • has-a 관계
  • 객체 사이의 동적 관계

코드 재사용을 위해서는 객체 합성이 클래스 상속보다 더 좋은 방법이다.

📖 11.1 상속을 합성으로 변경하기

상속을 남용할 때 직면하는 세 가지 문제

  1. 불필요한 인터페이스 상속 문제
  2. 메서드 오버라이딩의 오작용 문제
  3. 부모 클래스와 자식 클래스의 동시 수정 문제

🔖 11.1.1 불필요한 인터페이스 상속 문제: java.util.Properties와 java.util.Stack

public class Properties { private Hashtable<String, String> properties = new Hashtable<>(); public String setProperty(String key, String value) { return properties.put(key, value); } public String getProperty(String key) { return properties.get(key); } }
  • 더 이상 불필요한 Hashtable의 오퍼레이션들이 퍼블릭 인터페이스를 오염시키지 않는다.
  • Stack 또한 Vector의 인스턴스 변수를 Stack 클래스의 인스턴스 변수로 선언함으로써 합성 관계로 변경할 수 있다.

🔖 11.1.2 메서드 오버라이딩의 오작용 문제: InstrumentedHashSet

@RequiredArgsConstructor public class InstrumentedHashSet<E> implements Set<E> { private static final int addCount = 0; private final Set<E> set; ... // Overriding }
  • HashSet에 대한 구현 결합도는 제거하면서 퍼블릭 인터페이스는 그대로 상속

포워딩

  • Set의 오버레이션을 오버라이딩한 인스턴스 메서드에서 내부의 HashSet 인스턴스에게 동일한 메서드 호출을 그대로 전달
  • 동일한 메서드를 호출하기 위해 추가된 메서드를 포워딩 메서드라고 부른다.
  • 기존 클래스의 인터페이스를 그대로 외부에 제공하면서 구현에 대한 결합 없이 일부 작동 방식을 변경하고 싶은 경우에 유용한 기법

🔖 11.1.3 부모 클래스와 자식 클래스의 동시 수정 문제: PersonalPlaylist

public class PersonalPlaylist { private Playlist playlist = new Playlist(); public void append(Song song) { playlist.append(song); } public void remove(Song song) { playlist.getTracks().remove(song); playlist.getSingers().remove(song.getSinger()); } }
  • 향후에 Playlist의 내부 구현을 변경하더라도 파급효과를 최대한 PersonalPlaylist 내부로 캡슐화할 수 있다.

몽키 패치

  • 현재 실행 중인 환경에만 영향을 미치도록 지역적으로 코드를 수정하거나 확장하는 것

📖 11.2 상속으로 인한 조합의 폭발적인 증가

  1. 하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
  2. 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다.

🔖 11.2.1 기본 정책과 부가 정책 조합하기

기본 정책

  • 가입자의 통화 정보 기반
  • 일반 요금제, 심야 할인 요금제

부가 정책

  • 통화량과 무관하게 기본 정책에 선택적으로 추가할 수 있는 요금 방식
  • 세금 정책, 기본 요금 할인 정책
  • 기본 정책의 계산 결과에 적용
  • 선택적으로 적용 가능
  • 조합 가능
  • 임의의 순서로 적용 가능

🔖 11.2.2 상속을 이용해서 기본 정책 구현하기

@RequiredArgsConstructor public abstract class Phone { private List<Call> calls = new ArrayList<>(); public Money calculateFee() { Money result = Money.ZERO; for (Call call : calls) { result = result.plus(calculateCallFee(call)); } return result; } protected abstract Money calculateCallFee(Call call); } @RequiredArgsConstructor public class RegularPhone extends Phone { private final Money amount; private final Duration seconds; @Override protected Money calculateCallFee(Call call) { return amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } } @RequiredArgsConstructor public class NightlyDiscountPhone extends Phone { private static final int LATE_NIGHT_HOUR = 22; private final Money nightlyAmount; private final Money regularAmount; private final Duration seconds; @Override protected Money calculateCallFee(Call call) { if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) { return nightlyAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } return regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } }
  • 기본 정책으로만 요금 계산

🔖 11.2.3 기본 정책에 세금 정책 조합하기

public class TaxableRegularPhone extends RegularPhone { private final double taxRate; public TaxableRegularPhone(Money amount, Duration seconds, double taxRate) { super(amount, seconds); this.taxRate = taxRate; } @Override public Money calculateFee() { Money fee = super.calculateFee(); return fee.plus(fee.times(taxRate)); } }
  • 결합도가 높아진다.
@RequiredArgsConstructor public abstract class Phone { private List<Call> calls = new ArrayList<>(); public Money calculateFee() { Money result = Money.ZERO; for (Call call : calls) { result = result.plus(calculateCallFee(call)); } return afterCalculated(result); } protected abstract Money calculateCallFee(Call call); protected abstract Money afterCalculated(Money fee); } @RequiredArgsConstructor public class RegularPhone extends Phone { private final Money amount; private final Duration seconds; @Override protected Money calculateCallFee(Call call) { return amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } @Override protected Money afterCalculated(Money fee) { return fee; } } @RequiredArgsConstructor public class NightlyDiscountPhone extends Phone { private static final int LATE_NIGHT_HOUR = 22; private final Money nightlyAmount; private final Money regularAmount; private final Duration seconds; @Override protected Money calculateCallFee(Call call) { if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) { return nightlyAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } return regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } @Override protected Money afterCalculated(Money fee) { return fee; } }
  • 부모 클래스에 추상 메서드를 추가하면 꽤나 번거로워진다.
@RequiredArgsConstructor public abstract class Phone { private List<Call> calls = new ArrayList<>(); public Money calculateFee() { Money result = Money.ZERO; for (Call call : calls) { result = result.plus(calculateCallFee(call)); } return afterCalculated(result); } protected Money afterCalculated(Money fee) { return fee; } protected abstract Money calculateCallFee(Call call); }
  • 기본 구현을 함께 제공하면 오버라이딩할 필요가 없다.
  • 훅 메서드(hook method)
  • 추상 메서드와 동일하게 자식 클래스에서 오버라이딩할 의도로 메서드를 추가했지만 편의를 위해 기본 구현을 제공하는 메서드
public class TaxableRegularPhone extends RegularPhone { private final double taxRate; public TaxableRegularPhone(Money amount, Duration seconds, double taxRate) { super(amount, seconds); this.taxRate = taxRate; } @Override protected Money afterCalculated(Money fee) { return fee.plus(fee.times(taxRate)); } } public class TaxableNightlyDiscountPhone extends NightlyDiscountPhone { private final double taxRate; public TaxableNightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, double taxRate) { super(nightlyAmount, regularAmount, seconds); this.taxRate = taxRate; } @Override protected Money afterCalculated(Money fee) { return fee.plus(fee.times(taxRate)); } }
  • 부모 클래스의 이름을 제외하면 대부분의 코드가 거의 동일

🔖 11.2.4 기본 정책에 기본 요금 할인 정책 조합하기

public class RateDiscountableRegularPhone extends RegularPhone { private final Money discountAmount; public RateDiscountableRegularPhone(Money amount, Duration seconds, Money discountAmount) { super(amount, seconds); this.discountAmount = discountAmount; } @Override protected Money afterCalculated(Money fee) { return fee.minus(discountAmount); } } public class RateDiscountableNightlyDiscountPhone extends NightlyDiscountPhone { private final Money discountAmount; public RateDiscountableNightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, Money discountAmount) { super(nightlyAmount, regularAmount, seconds); this.discountAmount = discountAmount; } @Override protected Money afterCalculated(Money fee) { return fee.minus(discountAmount); } }
  • 또, 중복 코드가 추가되었다.

🔖 11.2.5 중복 코드의 덫에 걸리다

상속을 이용한 해결 방법은 모든 가능한 조합별로 자식 클래스를 하나씩 추가하는 것이다.

public class TaxableAndRateDiscountableRegularPhone extends TaxableRegularPhone { private final Money discountAmount; public TaxableAndRateDiscountableRegularPhone(Money amount, Duration seconds, double taxRate, Money discountAmount) { super(amount, seconds, taxRate); this.discountAmount = discountAmount; } @Override protected Money afterCalculated(Money fee) { return super.afterCalculated(fee).minus(discountAmount); } } public class RateDiscountableAndTaxableRegularPhone extends RateDiscountableRegularPhone { private final double taxRate; public RateDiscountableAndTaxableRegularPhone(Money amount, Duration seconds, Money discountAmount, double taxRate) { super(amount, seconds, discountAmount); this.taxRate = taxRate; } @Override protected Money afterCalculated(Money fee) { return super.afterCalculated(fee).plus(fee.times(taxRate)); } } public class TaxableAndDiscountableNightlyDiscountPhone extends TaxableNightlyDiscountPhone { private final Money discountAmount; public TaxableAndDiscountableNightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, double taxRate, Money discountAmount) { super(nightlyAmount, regularAmount, seconds, taxRate); this.discountAmount = discountAmount; } @Override protected Money afterCalculated(Money fee) { return super.afterCalculated(fee).minus(discountAmount); } } public class RateDiscountableAndTaxableNightlyDiscountPhone extends RateDiscountableNightlyDiscountPhone { private final double taxRate; public RateDiscountableAndTaxableNightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, Money discountAmount, double taxRate) { super(nightlyAmount, regularAmount, seconds, discountAmount); this.taxRate = taxRate; } @Override protected Money afterCalculated(Money fee) { return super.afterCalculated(fee).plus(fee.times(taxRate)); } }
  • 클래스 폭발(class explosion) 또는 조합의 폭발(combinational explosion)
  • 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생
  • 기능을 추가할 때뿐만 아니라 기능을 수정할 때도 문제가 됨.

📖 11.3 합성 관계로 변경하기

합성을 사용하면 구현이 아닌 퍼블릭 인터페이스에 대해서만 의존할 수 있기 때문에 런타임에 객체의 관계를 변경할 수 있다.

🔖 11.3.1 기본 정책 합성하기

public interface RatePolicy { Money calculateFee(Phone phone); } public abstract class BasicRatePolicy implements RatePolicy{ @Override public Money calculateFee(Phone phone) { Money result = Money.ZERO; for (Call call : phone.getCalls()) { result.plus(calculateCallFee(call)); } return result; } protected abstract Money calculateCallFee(Call call); } @RequiredArgsConstructor public class RegularPolicy extends BasicRatePolicy { private final Money amount; private final Duration seconds; @Override protected Money calculateCallFee(Call call) { return amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } } @RequiredArgsConstructor public class NightlyDiscountPolicy extends BasicRatePolicy { private static final int LATE_NIGHT_HOUR = 22; private final Money nightlyAmount; private final Money regularAmount; private final Duration seconds; @Override protected Money calculateCallFee(Call call) { if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) { return nightlyAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } return regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } } @RequiredArgsConstructor public class Phone { private final RatePolicy ratePolicy; @Getter private List<Call> calls = new ArrayList<>(); public Money calculateFee() { return ratePolicy.calculateFee(this); } }
  • 다양한 종류의 객체와 협력하기 위해 합성 관계를 사용하는 경우에는 합성하는 객체의 타입을 인터페이스나 추상 클래스로 선언하고 의존성 주입을 사용해 런타임에 필요한 객체를 설정할 수 있도록 구현하는 것이 일반적이다.

🔖 11.3.2 부가 정책 적용하기

@RequiredArgsConstructor public abstract class AdditionalRatePolicy implements RatePolicy { private final RatePolicy next; @Override public Money calculateFee(Phone phone) { Money fee = next.calculateFee(phone); return afterCalculated(fee); } protected abstract Money afterCalculated(Money fee); } public class TaxablePolicy extends AdditionalRatePolicy { private final double taxRatio; public TaxablePolicy(RatePolicy next, double taxRatio) { super(next); this.taxRatio = taxRatio; } @Override protected Money afterCalculated(Money fee) { return fee.plus(fee.times(taxRatio)); } } public class RateDiscountablePolicy extends AdditionalRatePolicy { private final Money discountAmount; public RateDiscountablePolicy(RatePolicy next, Money discountAmount) { super(next); this.discountAmount = discountAmount; } @Override protected Money afterCalculated(Money fee) { return fee.minus(discountAmount); } }

🔖 11.3.3 기본 정책과 부가 정책 합성하기

Phone phone = new Phone(new taxablePolicy(0.05, new RegularPolicy(...)))

  • 객체를 조합하고 사용하는 방식이 상속을 사용한 방식보다 더 예측 가능하고 일관성 있다.

🔖 11.3.4 새로운 정책 추가하기

  • 오직 하나의 클래스만 추가하고 런타임에 필요한 정책들을 조합해서 원하는 기능을 얻을 수 있다.
  • 요구사항을 변경할 때 오직 하나의 클래스만 수정해도 된다.
    • 변경 후의 설계는 단일 책임 원칙을 준수하고 있다.

🔖 11.3.5 객체 합성이 클래스 상속보다 더 좋은 방법이다

  • 상속은 부모 클래스의 세부적인 구현에 자식 클래스를 강하게 결합시키기 때문에 코드의 진화를 방해한다.
  • 상속이 구현을 재사용하는 데 비해 합성은 객체의 인터페이스를 재사용한다.
  • 상속의 종류: 구현 상속 / 인터페이스 상속
    • 이번 장에서 살펴본 상속의 단점은 구현 상속

📖 11.4 믹스인

  • 객체를 생성할 때 코드 일부를 클래스 안에 섞어 넣어 재사용하는 기법을 가리키는 용어
  • 코드를 다른 코드 안에 섞어 넣기 위한 방법
  • 상속이 클래스와 클래스 사이의 관계를 고정시키는 데 비해 믹스인은 유연하게 관계를 재구성할 수 있다.
  • 이펙티브 자바 아이템20
  • java에서 인터페이스는 믹스인 정의에 안성맞춤이지만, 추상 클래스는 믹스인을 정의할 수 없다.

📖 11.4.1 쌓을 수 있는 변경

  • 믹스인은 대상 클래스의 자식 클래스처럼 사용될 용도로 만들어지는 것
  • 믹스인을 추상 서브클래스(abstract subclass)라고 부르기도 한다.
  • 쌓을 수 있는 변경: 믹스인을 사용하면 특정한 클래스에 대한 변경 또는 확장을 독립적으로 구현한 후 필요한 시점에 차례대로 추가할 수 있다.
Written by@BottleH
Back-End Developer

GitHub