스터디/오브젝트

오브젝트 - 11장 합성과 유연한 설계

realizers 2022. 10. 28. 21:08
728x90
반응형

서론


  • 상속은 부모 클래스와 자식 클래스 사이의 의존성이 컴파일 시점에 고정되어 높은 결합도를 가지지만 합성은 두 객체 사이의 의존성이 런타임시점에 고정되어 낮은 결합도를 가지게 됩니다.
  • 상속과 합성은 코드 재사용이라는 동일한 목적을 가지지만 구현 방법부터 변경을 위해 다루는 방식 모두에서 차이점을 가집니다.
  • 상속을 사용하면 자식 클래스가 부모 클래스의 내부 구현까지 자세히 알아야하기 때문에 결합도가 높아지고 캡슐화에 약해집니다.
    반면 합성을 사용하면 오직 퍼블릭 인터페이스에만 의존하므로 내부 구현이 변경되더라도 영향을 최소화할 수 있으며 캡슐화를 지킬 수 있습니다.

 

상속과 합성은 재사용의 대상이 다르다

  • 상속과 합성은 재사용의 대상이 다릅니다. 상속은 부모 클래스의 내부 구현을 재사용하지만 합성은 퍼블릭 인터페이스를 재사용합니다. 따라서 상속 대신 합성을 사용하면 구현에 대한 의존성을 퍼블릭 인터페이스에 대한 의존성으로 변경할 수 있으며 이는 캡슐화와
    낮은 결합도를 유지할 수 있습니다.

화이트 박스 재사용

  • 화이트 박스 재사용은 상속의 관점으로부터 나온 말입니다. 상속은 부모 클래스를 자식 클래스에서 재정의할 수 있으므로 화이트 박스 재사용이라 불리는데 그 이유는 가시성 때문입니다. 상속을 받게 되면 부모 클래스의 내부를 자식 클래스에게 공개해야 하기 때문입니다.

블랙 박스 재사용

  • 블랙 박스 재사용은 합성의 관점으로부터 나온 말입니다. 객체를 합성하기 위해서는 추상화를 이용하며 추상화에 선언된 퍼블릭 인터페이스를 통해 각 구현체마다 내부 구현이 다를 테고 이로 인해 객체의 내부는 공개하지 않고 오직 퍼블릭 인터페이스를 통해서만 재사용할 수 있기 때문입니다.

 

상속으로 인한 클래스 폭발


💡 상황 가정

  • 요금을 계산하기 위해서 기본 정책과 부가 정책을 적용할 수 있습니다. 
    1. 기본 정책의 계산 결과에 적용됩니다. 이는 기본 정책 이후에 부과 정책을 적용할 수 있음을 나타냅니다.
    2. 선택적으로 적용할 수 있습니다. 기본 정책의 계산 결과에 부가 정책을 적용할수도 있고 적용하지 않을 수도 있습니다.
    3. 조합이 가능합니다. 기본 정책의 계산 결과에 세금 정책만 적용할 수도 있고 모두 적용할 수도 있습니다.
    4. 부가 정책은 임의의 순서로 적용할 수 있습니다. 이는 세금 정책 적용 후 기본 할인 정책을 할수있고 또는 반대로 할 수 있습니다.

기본 정책과 부가 정책의 종류

 

💡 예제 코드

  • 아래 예제 코드에서는 Phone 이라는 추상 클래스를 만들어 변하는 부분과 변하지 않는 부분을 파악하여 문맥에 따라 변하는 부분을
    자식 클래스에서 구현하도록 강요하고 있습니다. 이렇게만 봤을때 문제될게 없습니다. 
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;
    }
    
    // 문맥에 따라 변하는 부분
    abstract protected Money calculateCallFee(Call call);
}

// 일반 요금제
@AllArgsConstructor
public class RegularPhone extends Phone {

    private Money amount;
    private Duration seconds;

    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

// 심야 할인 요금제
@AllArgsConstructor
public class NightlyDiscountPhone extends Phone {

    private static final int LATE_NIGHT_HOUR = 22;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;

    @Override
    protected Money calculateCallFee(Call call) {
        if (call.getStartTime().getHour() >= LATE_NIGHT_HOUR) {
            return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
        return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

 

🧨 기본 정책에 부가 정책 적용

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 Money afterCalculated(Money fee) {
        return fee;
    }

    abstract protected Money calculateCallFee(Call call);
}

// 일반 요금제
@AllArgsConstructor
public class RegularPhone extends Phone {

    private Money amount;
    private Duration seconds;

    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

// 심야 할인 요금제
@AllArgsConstructor
public class NightlyDiscountPhone extends Phone {

    private static final int LATE_NIGHT_HOUR = 22;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;

    @Override
    protected Money calculateCallFee(Call call) {
        if (call.getStartTime().getHour() >= LATE_NIGHT_HOUR) {
            return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
        return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

// 일반 요금제 + 세금 정책
public class TaxableRegularPhone extends RegularPhone {

    private 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 RateDiscountableRegularPhone extends RegularPhone {

    private 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 TaxableNightlyDiscountPhone extends NightlyDiscountPhone {

    private 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));
    }
}

// 심야 할인 요금제 + 기본 요금 할인 정책
public class RateDiscountableNightlyDiscountPhone extends NightlyDiscountPhone{

    private 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);
    }
}

상속 관계 구조

 

🧨 상속으로 인한 문제점

  • 자식 클래스의 생성자에서 super를 호출하기 때문에 결합도가 높아집니다.
  • 훅 메서드를 사용하여 중복을 줄였지만 그래도 어느정도의 afterCalculated(훅 메서드) 메서드에서 중복이 발생할 수 밖에 없습니다.
  • 부가 정책은 임의의 순서로 조합할 수 있으므로 어떻게 조합하느냐에따라 클래스의 수가 증가할 수 있습니다. 이는 클래스 폭발 문제
    또는 조합의 폭발 문제를 야기시킬 수 있습니다.
  • 클래스 폭발 문제는 자식 클래스가 부모 클래스의 내부 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계로 인해 발생하는 문제점입니다. 컴파일타임에 고정된 관계는 변경될 수 없기 때문에 다양한 조합이 필요한 상황에서 유일한 해결책은 필요한 다양한 조합만큼 클래스를 만드는 것뿐입니다.

 

합성관계로 리펙토링


  • 합성은 컴파일타임 관계를 런타임 관계로 변경함으로써 문제를 해결할 수 있습니다. 합성을 사용하면 퍼블릭 인터페이스에만 의존할 수 있기 때문에 런타임시점에 유동적으로 객체를 변경할 수 있습니다.

 

💡 기본 정책 적용

  • RatePolicy 인터페이스를 만들어 변하지 않는 부분을 파악하여 퍼블릭 인터페이스를 선언합니다. 
  • BasicRatePolicy 추상 클래스의 역할은 변하지 않는 부분을 기본적으로 제공함으로써 중복 코드를 줄이고 변하는 부분은 자식 클래스에서 구현할 수 있도록 만듭니다.
  • RegularPolicy, NightlyDiscountPolicy 클래스는 일반 요금제와 심야 할인 요금제의 책임을 수행하고 있습니다.
  • Phone 클래스에서 추상화에 의존할 수 있도록 RatePolicy 속성을 추가합니다. Phone 클래스는 생성자 주입을 통해 유동적으로 일반 요금제 또는 심야 할인 요금제를 선택할 수 있습니다.
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);
}

@AllArgsConstructor
public class RegularPolicy extends BasicRatePolicy {

    private Money amount;
    private Duration seconds;

    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

@AllArgsConstructor
public class NightlyDiscountPolicy extends BasicRatePolicy {

    private static final int LATE_NIGHT_HOUR = 22;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;


    @Override
    protected Money calculateCallFee(Call call) {
        if (call.getStartTime().getHour() >= LATE_NIGHT_HOUR) {
            return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
        return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

@Getter
public class Phone {

    private RatePolicy ratePolicy; // 추상화에 의존
    private List<Call> calls = new ArrayList<>();

    public Phone(RatePolicy ratePolicy) {
        this.ratePolicy = ratePolicy;
    }

    public Money calculateFee() {
        return ratePolicy.calculateFee(this);
    }
}

구조

 

💡 부가 정책 적용

public abstract class AdditionalRatePolicy implements RatePolicy {

    private RatePolicy next;

    public AdditionalRatePolicy(RatePolicy next) {
        this.next = next;
    }

    @Override
    public Money calculateFee(Phone phone) {
        Money fee = next.calculateFee(phone);
        return afterCalculated(fee);
    }

    abstract protected Money afterCalculated(Money fee);
}

public class TaxablePolicy extends AdditionalRatePolicy {

    private double taxRate;

    public TaxablePolicy(double taxRate, RatePolicy next) {
        super(next);
        this.taxRate = taxRate;
    }

    @Override
    protected Money afterCalculated(Money fee) {
        return fee.plus(fee.times(taxRate));
    }
}

public class RateDiscountPolicy extends AdditionalRatePolicy {

    private Money discountAmount;

    public RateDiscountPolicy(Money discountAmount, RatePolicy next) {
        super(next);
        this.discountAmount = discountAmount;
    }

    @Override
    protected Money afterCalculated(Money fee) {
        return fee.minus(discountAmount);
    }
}

public class Main {

    public static void main(String[] args) {
        Phone phoneA = new Phone(new TaxablePolicy(..., new RegularPolicy(...)));
        Phone phoneB = new Phone(new RateDiscountPolicy(..., new TaxablePolicy(..., new RegularPolicy(...))));
        Phone phoneC = new Phone(new RateDiscountPolicy(..., new TaxablePolicy(..., new NightlyDiscountPolicy(...))));
    }
}

 

믹스인


  • 믹스인은 객체를 생성할 때 코드 일부를 클래스의 안에 섞어 넣어 재사용하는 기법을 말합니다.
  • 합성이 런타임 시점에 객체를 조합해서 재사용하는 방법이라면 믹스인은 컴파일 시점에 필요한 코드를 조합하는 재사용 방법입니다.
  • 상속이 부모 클래스와 자식 클래스 사이의 관계를 고정시키는데 비해 믹스인은 유연하게 관계를 재구성할 수 있습니다.

합성과 믹스인

  • 합성과 믹스인은 유사한 면이 있는데, 합성은 독립적으로 작성된 객체들을 런타임 시점에 조합해서 더 큰 기능을 만들어내고 
    믹스인은 독립적으로 작성된 트레이트와 클래스를 코드 컴파일 시점에 조합해 더 큰 기능을 만들어 낼 수 있습니다.

 

 

 

 

 

 

728x90
반응형