본문 바로가기
스터디/오브젝트

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

by 디토20 2024. 3. 26.
반응형

 

 

 

 

 

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

 

  • 상속은 부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용한다.
  • 합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용한다.
  • 상속에서의 의존성은 컴파일타임에 해결된다.
  • 합성에서의 의존성은 런타임에 해결된다.

 

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

  • 코드 재사용을 위해 상속을 남용하면 아래와 같은 세가지 문제점에 직면한다.
    • 불필요한 인터페이스 상속 문제 - 자식 클래스에게는 부적합한 부모 클래스의 오퍼레이션이 상속된다.
    • 메서드 오버라이딩의 오작용 문제 - 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 때 자식 클래스가 부모 클래스의 메서드 호출 방법에 영향을 받는다.
    • 부모 클래스와 자식 클래스의 동시 수정 문제 - 부모 클래스와 자식 클래스 사이의 개념적인 결합으로 인해 부모 클래스를 변경할 때 자식 클래스도 함께 변경해야 하는 문제
  • 합성을 사용하면 상속이 초래하는 세가지 문제점을 해결 할 수 있다.
  • 상속 관계를 제거하고 부모 클래스의 인스턴스를 자식 클래스의 인스턴스 변수로 선언하면 된다.

 

11.1.1 불필요한 인터페이스 상속 문제 해결

  • Hashtable 클래스와 Properties 클래스를 합성 관계로 바꿔보자
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의 오퍼레이션들이 Properties 클래스의 퍼블릭 인터페이스를 오염시키지 않는다.

 

  • Vetor를 상속받는 Stack 역시 Vector의 인스턴스 변수를 Stack 클래스의 인스턴스 변수로 선언함으로써 합성 할 수 있다.
public class Stack<E> {
    private Vector<E> elements = new Vector<>();
    
    public Epush(Eitem) {
    	elements.addElement(item);
    	return item;
    }
    
    public E pop() {
        if (elements.isEmpty()) {
            throw new EmptyStackException();
        }
        return elements.remove(elements.size() - 1);
    }
}

 

 

 

11.1.2 메서드 오버라이딩의 오작용 문제 해결

public class InstrumentedHashSet<E> {
    private int addCount = 0;
    private Set<E> set;
    
    public InstrumentedHashSet(Set<E> set) {
    	this.set = set;
    }
    public boolean add(E e) {
    	addCount++;
    	return set.add(e);
    }
    public boolean addAll(Collection<? extends E> C) {
    	addCount += c.size();
    	return set.addAll(c);
    }
    public int getAddCount() {
    	return addCount;
    }
}

 

 

 

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

  • Playlist는 합성으로 변경하더라도 가수별 노래 목록을 유지하기 위해 Playlist와 PersonalPlaylist를 함께 수정해야하는 문제는 해결되지 않는다.
  • 그럼에도 여전히 상속보다는 합성을 사용하는 것이 좋다.

 

 

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

  • 상속으로 인해 결합도가 높아지면 코드를 수정하는데 필요한 작업의 양이 과도하게 늘어나는 경향이 있다.
    • 하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
    • 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다.
  • 합성을 사용하면 상속으로 인해 발생하는 클래스 증가와 중복 코드 문제를 간단하게 해결할 수 있다.

 

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

  • 핸드폰 요금제가 '기본 정책'과 '부가 정책'을 조합해서 구성된다고 가정하자

 

  • 현재의 기본 정책과 부가 정책을 조합해서 만들 수 있는 모든 요금 정책의 종류는 아래와 같다.
  • 설계는 다양한 조합을 수용할 수 있도록 유연해야 한다.

 

 

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

  • 기본 정책은 Phone 추상 클래스를 루트로 삼고, 일반 요금제 RegularPhone과 심야 할인 요금제 NightlyDiscountPhone은 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);
}

public class RegularPhone extends Phone {
    private Money amount;
    private Duration seconds;
    
    public RegularPhone(Money amount, Duration seconds) {
    	this.amount = amount;
    	this.seconds = seconds;
    }
    
    @Override
    protected Money calculateCallFee(Call call) {
    	return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

public class NightlyDiscountPhone extends Phone {
    private static final int LATE_NIGHT_HOUR = 22;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;

    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
    	this.nightlyAmount = nightlyAmount;
    	this.regularAmount = regularAmount;
    	this.seconds = seconds;
    }
    
    @Override
    protected Money calculateCallFee(Call call) {
    	if (call.getFrom().getHour())= LATE_NIGHT_HOUR) {
    		return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    	}
    	return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

 

 

 

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

  • 일반 요금제에 세금 정책을 조합하는 가장 간단한 방법은 RegularPhone 클래스를 상속받은 TaxableRegularPhone 클래스를 추가하는 것이다.
  • 심야 할인 요금제인 NightlyDiscountPhone 에도 세금을  부과할 수 있게 TaxableNightlyDiscountPhone 자식 클래스를 추가하자

요금제에 세금 정책을 추가한 상속 계층

 

 

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

  • 이번에는 두번째 부가 정책인 기본 요금 할인 정책을 적용해보자.
  • 일반 요금제에 기본 요금 할인 정책을 조합하는 가장 간단한 방법은 RegularPhone 클래스를 상속받은 RateDiscountableRegularPhone 클래스를 추가하는 것이다.
  • 심야 할인 요금제인 NightlyDiscountPhone 에도 세금을 부과할 수 있게 RateDiscountableNightlyDiscountPhone 자식 클래스를 추가하자

요금제에 기본 요금 할인 정책을 추가한 상속 계층

 

 

11.2.5 중복 코드의 덫에 걸리다

  • 부가 정책은 자유롭게 조합할 수 있어야 하고 순서 역시 임의로 결정할 수 있어야 한다.
  • 상속을 이용한 해결 방법은 모든 가능한 조합별로 자식 클래스를 하나씩 추가하는 것이다.

요금과 관련된 모든 기본 정책과 부가 정책의 조합이 가능한 상속 계층

 

 

  • 위의 상속 계층에 새로운 기본 정책을 추가해야 한다고 가정해보자.
  • 추가할 기본 정책은 '고정 요금제'로 구현할 것이다.

고정 요금제를 추가한 상속 계층

 

  • 여기에 새로운 부가 정책을 추가하는 경우를 생각해보자...그만 생각하자
  • 이처럼 상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 클래스 폭발 문제 또는 조합의 폭발 문제라고 부른다.
  • 클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계때문에 발생하는 문제다.
  • 클래스 폭발 문제는 새로운 기능을 추가할 때뿐만 아니라 기능을 수정할 때도 문제가 된다.
  • 중복된 코드를 모두 찾아 동일한 방식으로 수정해야 한다.

 

 

11.3 합성 관계로 변경하기

  • 컴파일시에 결정되고 고정되는 상속 관계를 런타임 관계로 변경함으로써 이 문제를 해결한다.
  • 합성을 사용하면 구현이 아닌 퍼블릭 인터페이스에 대해서만 의존할 수 있기 때문에 런타임 객체의 관계를 변경할 수 있다.
  • 상속을 합성으로 변경해보자

 

11.3.1 기본 정책 합성하기

  • 가장 먼저 할일은 각 정책을 별도의 클래스로 구현하는 것이다.
  • 먼저 기본 정책과 부과 정책을 포괄하는 RatePolicy 인터페이스를 추가하고 기본 정책을 구현하자.
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);
}

 

 

  • BasicRatePolicy를 상속받아 일반 요금제와 심야 할인 요금제를 구현하자.
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());
    }
}

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.getFrom().getHour() >= LATE_NIGHT_HOUR) {
        	return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
        return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

 

  • 이제 기본 정책을 이용해 요금을 계산할 수 있도록 Phone을 수정하자
public class Phone {
    private RatePolicy ratePolicy;
    private List<Call> calls = new ArrayList<>();
    
    public List<Call> getCalls() {
    	return Collections.unmodifiablelist(calls);
    }
    public Money calculateFee() {
    	return ratePolicy.calculateFee(this);
    }
}

 

  • Phone의 내부에 RatePolicy가 있는것이 바로 합성이다.
  • Phone은 다양한 요금 정책과 협력할 수 있어야 하므로 요금 정책의 타입이 RatePolicy라는 인터페이스로 정의되어있다.
  • 합성은 합성하는 객체의 타입을 인터페이스나 추상클래스로 선언하고 의존성 주입을 통해 런타임에 필요한 객체를 주입한다.

 

 

 

11.3.2 부가 정책 적용하기

  • 부가 정책은 기본 정책이나 다른 부가 정책의 인스턴스를 참조할 수 있어야 한다.
  • 기본 정책과 부가 정책은 협력 안에서 동일한 '역할'을 수행해야 한다.
  • 즉, 부가 정책은 RatePolicy 인터페이스를 구현해야 하며, 내부에 또 다른 RatePolicy 인스턴스를 합성할 수 있어야 한다.
  • 부가 정책을 AdditionalRatePolicy 추상 클래스로 구현하자
public abstract class AdditionalRatePolicy implements RatePolicy {
    private RatePolicy 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 taxRatio;
    
    public TaxablePolicy(double taxRatio, RatePolicy next) {
        super(next);
        this.taxRatio = taxRatio;
    }
    
    @Override
    protected Money afterCalculated(Money fee) {
    	return fee.plus(fee.times(taxRatio));
    }
}

public class RateDiscountablePolicy extends AdditionalRatePolicy {
    private Money discountAmount;
    public RateDiscountablePolicy(Money discountAmount, RatePolicy next) {
    	super(next);
    	this.discountAmount = discountAmount;
    }
    
    @Override
    protected Money afterCalculated(Money fee) {
    	return fee.minus(discountAmount);
    }
}

 

기본 정책과 부가 정책을 조합할 수 있는 상속 구조

 

 

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

// 1. 일반 요금제에 세금 정책을 조합할 경우
Phone phone = new Phone(new TaxablePolicy(0.05,new RegularPolicy(...));

// 2. 일반 요금제에 기본 요금 할인 정책 조합 후 세금 정책을 조합할 경우
Phone phone = new Phone(new TaxablePolicy(0.05,new RateDiscountablePolicy(Money.wons(1000),new RegularPolicy(...)));

// 3. 일반 요금제에 세금 정책 조합 후 기본 요금 할인 정책을 조합할 경우
Phone phone = new Phone(new RateDiscountablePolicy(Money.wons(1000),new TaxablePolicy(0.05,new RegularPolicy(...)));

// 4. 심야 할인 요금제에 세금 정책 조합 후 기본 요금 할인 정책을 조합할 경우
Phone phone = new Phone(new RateDiscountablePolicy(Money.wons(1000),new TaxablePolicy(0.05,new NightDiscountPolicy(...)));

 

  • 객체를 조합하고 사용하는 방식이 상속을 사용한 방식보다 더 예측 가능하고 일관성 있다.
  • 합성의 진가는 새로운 클래스를 추가하거나 수정하는 시점이 되어서야 진가가 나타난다.

 

11.3.4 새로운 정책 추가하기

  • 상속과 달리 고정 요금제가 필요하다면 고정 요금제를 구현한 클래스 '하나'만 추가한 후 원하는 대로 조합하면 된다.
  • 새로운 부가 정책 역시 클래스 '하나'만 추가하면 된다.

 

 

 

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

  • 상속은 부모의 클래스의 세부적인 구현에 자식 클래스가 강하게 결합되어 코드의 진화를 방해한다.
  • 코드를 재사용하면서도 건전한 결합도를 유지할 수 있는 더 좋은 방법은 합성을 이용하는 것이다.
  • 그렇다면 상속은 사용해서는 안되는것인가?
    • 지금까지 살펴본 상속에 대한 모든 단점들은 구현 상속에 국한된다.
    • 구현 상속을 피하고 인터페이스 상속을 사용해야 한다.

 

 

 

 

 

728x90
반응형

댓글