[오브젝트] #11. 합성과 유연한 설계

bien·2024년 11월 19일
0

오브젝트

목록 보기
11/13

상속과 합성

정의

  • 상속
    • 부모-자식 클래스를 연결. 부모 클래스의 코드를 재사용.
    • 의존성이 컴파일 타입에 해결된다.
    • is-a 관계
  • 합성
    • 전체 객체가 부분 객체를 포함. 부분 객체의 코드를 재사용.
    • 런타임에 해결된다.
    • has-a 관계

코드 재사용

  • 상속
    • 부모 클래스 이름을 붙이는 것 만으로 간단하게 부모 코드를 재사용할 수 있다.
    • 상속을 활용하기 위해 부모 클래스에 대한 상세한 지식이 요구된다.
      • 사용하기는 쉬울 수 있어도, 우아한 재사용이 아닐 수 있다.
  • 합성
    • 부분 객체의 내부 구현이 아닌 퍼블릭 인터페이스에 의존한다.
    • 내부 구현에 의한 영향이 적은, 보다 변경에 안정적인 코드를 얻을 수 있다.

상속은 클라스 사이의 정적인 관계인 것에 비해, 합성은 객체 사이의 동적인 관계다.
따라서, 상속은 변경이 불가능하지만, 합성은 실행 시점에 동적으로 변경할 수 있다.

사용의 용이함을 가진 상속보다, 변경에 유연한 대처를 가진 합성을 선택해라.

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

객체지향 시스템에서 기능을 재사용할 수 있는 가장 대표적인 기법은 클래스 상속(class inheritance)과 객체 합성(object composition)이다. ... 클래스 상속은 다른 클래스를 이용해서 한 클래스의 구현을 정의하는 것이다. 서브 클래싱에 의한 재사용을 화이트박스 재사용(white-box reuse)이라고 부른다. 화이트박스라는 말은 가시성때문에 나온 말이다. 상속을 받으면 부모 클래스의 내부가 자식 클래스에 공개되기 때문에 화이트박스인 셈이다.
객체 합성은 클래스 상속의 대안이다. 새로운 기능을 위해 객체들을 합성한다. 객체를 합성하려면 합성할 객체들의 인터페이스를 명확하게 정의해야만 한다. 이런 스타일의 재사용을 블랙박스 재사용(balck-box reuse)이라고 하는데, 객체의 내부는 공개되지 않고 인터페이스를 통해서만 재사용되기 때문이다.[GOF67].

01. 상속을 합성으로 변경하기 🛠️

상속 남용의 문제점

  1. 불필요한 인터페이스 상속 문제
    • 부적합한 부모 클래스의 오퍼레이션 상속은, 자식 클래스 인스턴스의 상태 불안정을 초래한다.
    • 예시) java.util.Properties, java.util.Stack
  2. 메서드 오버라이딩의 문제
    • 자식 클래스가 부모 클래스의 메서드 호출에 영향을 받는 문제.
    • 예시) java.util.HashSet을 상속받은 InstrumentHashSet
  3. 부모 클래스와 자식 클래스 동시 수정 문제
    • 부모-자식 클래스간의 개념적 결합으로, 부모 클래스 변경 시 자식 클래스를 함께 변경해야 하는 문제
    • 예시) Playlist를 상속받은 PersonalPlaylist

합성을 사용하면 상속이 초래한 문제점들을 해결할 수 있다.
상속을 합성으로 변경해 문제를 해결해보자!

상속을 합성으로 변경하는 방법
1. 자식 클래스에 선언된 상속 관계를 제거
2. 부모 클래스의 인스턴스를 자식 클래스의 인스턴스 변수로 선언

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

1. java.util.Properties

public class Properties { // 1. 상속 제거
	// 2. 부모 클래스 인스턴스 변수 선언
	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 (자식)클래스의 퍼블릭 인터페이스를 오염시키지
    않는다.
    • 클라이언트는 오직 Properties(자식)클래스에 정의된 오퍼레이션만 사용할 수 있다.
  • 합성으로 구현된 클래스는 내부 구현에 관해 알지 못하며, 오퍼레이션을 통해 협력할 뿐이다.

2. java.util.Stack

public class Stack<E> {
	private Vector<E> elements = new Vector<>();
    
    public E push(E item) {
    	elements.addElement(item);
        return item;
    }
    
    public E pop() {
    	if (elements.isEmpty()) {
        	throw new EmptyStackException();
        }
        return elements.remove(elements.size() - 1);
    }
}
  • Stack의 퍼블릭 인터페이스에 불필요한 Vector의 오퍼레이션이 포함되지 않는다.
    • 합성을 통해 클라이언트가 Vector를 통해 Stack의 규칙을 어기고 잘못 사용할 수 있을 가능성을 깔끔하게 제거했다.

메서드 오버라이딩의 오작동 문제

InstrumentedHashSet

InstrumentedHashSet은 앞서 2개의 예시와 달리, HashSet(부모)가 제공하는 퍼블릭 인터페이스를 그대로 제공해야 한다.

  • HashSet에 대한 구현 결합도는 제거하면서, 퍼블릭 인터페이스는 그대로 상속받을 수는 없을까?
    1. HashSet이 제공하는 Set인터페이스를 실체화 한다. (퍼블릭 인터페이스 상속)
    2. 동시에 HashSet의 인스턴스를 합성한다. (구현 결합도 제거)
public class InstrumentedHashSet<E> implements Set<E> {
	private int addCount = 0;
    private Set<E> set;
    
    public InstrumentedHashSet(Set<E> set) {
    	this.set = set;
    }
    
    @Override
    public boolean add(E e) {
    	addCount++;
        return set.add(e);
    }
    
    @Override
    public boolean addAll(Collection<? extends E> c) {
    	addCount += c.size();
        return set.addAll(c);
    }
    
    public int getAddCount() {
    	return addCount;
    }
    
    @Override public boolean remove(Object e) {return set.remove(o);}
    @Override public void clear() {set.clear();}
    @Override public boolean equals(Object o) {return set.equals(o);}
    ...
    
}        

Set의 오퍼레이션을 오버라이딩한 인스턴스 메서드에서 내부의 HashSet 인스턴스에게 동일한 메서드 호출을 그대로 전달한다. 이를 포워딩(forwarding)이라고 한다.

  • 포워딩(forwarding)
    • 한 객체가 받은 요청을 내부에 포함된 다른 객체에게 동일한 작업을 위임하는 것
    • 이때 위임을 처리하는 메서드를 포워딩 메서드(forwarding method)라고 한다.
    • 기존 클래스의 인터페이스는 그대로 유지하면서, 내부 구현을 변경하거나 확장할 수 있다.

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

PersonalPlaylist

public class PersonalPlaylist {
	private Playlist playlist = new Palylist();
    
    public void append(Song song) {
    	playlist.append(song);
    }
    
    public void remove(Song song) {
    	playlist.getTrack().remove(song);
        playlist.getSingers().remove(Song.getSinger());
    }

}
  • 아쉽게도 합성으로 변경한 이후에도 가수별 노래목록 유지를 위해 PlaylistPersonalPlaylist를 함께 변경해야 한다는 문제는 해결되지 않는다.
  • 그럼에도 여전히 상속보다 합성이 권장된다.
    • 향후 Playlist의 내부 구현 변경에 의한 파급효과를 PersonalPlaylist 내부로 캡슐화할 수 있기 때문이다.
      • (현재, 합성을 기반으로 playlist의 퍼블릭 인터페이스만 참고하고 있으므로, 캡슐화가 되어있다.)

몽키 패치(Monkey Patch)

  • 현재 실행 중인 환경에만 영향을 미치도록 지역적으로 코드를 수정하거나 확장하는 것
    • ex) 루비의 열린 클래스(Open Class), C#의 확장 메서드(Extension mehtod), 스칼라의 암시적 변환(implicit conversion)
  • 직접적으로 Playlist를 수정할 권한이 없거나 소스코드가 존재하지 않더라도, 몽키 패치가 지원되는 환경이라면 Playlist에 직접 remove 메서드를 추가하는 것이 가능하다.
  • 자바에서는 언어적으로 몽키 패치를 지원하지 않으므로, 바이트 코드를 직접 변환하거나 AOP(Aspect-Oriented Programming)을 이용해 몽키 패치를 구현하고 있다.

02. "상속"으로 인한 조합의 폭발적 증가

상속으로 인한 결합도 증가는, 코드 수정에 요구되는 작업 양이 과도하게 늘어나는 경향이 있다.
작은 기능들을 조합해 더 큰 기능을 수행하는 객체를 만들어야 하는 경우에 빈번하게 발생한다.

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

합성을 이용하면 상속으로 인해 발생하는 문제를 간단하게 해결할 수 있다.

📋 요구사항 추가: 기본 정책, 부가 정책 조합

  • 핸드폰 과금 시스템 (10장)
    • 핸드폰 요금제는 기본 정책부과 정책조합해 구성된다.
    • 기본 정책: 일반 요금제 & 심야 할인 요금제
      • 가입자의 통화 정보를 기반으로 한다.
      • 가입자의 한달 통화량을 기준으로 부과할 요금을 계산.
    • 부가 정책 추가: 세금 정책 & 기본 요금 할인 정책
      • 통화량과 무관하게 선택적으로 추가할 수 있는 요금 방식
      • 앞서 살펴본 세금 부과 정책 = '세금 정책'
      • 최종 계산된 요금에서 일정 금액 할인해주는 '기본 요금 할인 정책'

  • 부가 정책의 특성
    1. 기본 정책의 계산 결과에 적용된다.
    2. 선택적으로 적용할 수 있다.
      • 적용하지 않을 수도 있다.
    3. 조합 가능하다
      • 기본정책만, 부가 정책만 적용하는 것이 가능하며, 두 정책을 함께 사용하는 것도 가능하다.
  • 조합 가능한 모든 요금 계산 순서

기본 정책 구현. by상속

  • RegularPhoneNightlyDiscountPhonePhone을 상속받는다.
  • RegularPhoneNightlyDiscountPhone 인스턴스만 단독으로 생성하는 것은 부가 정책은 적용하지 않고 기본 정책만으로 요금을 계산함을 의미한다.

Phone.java

public abstarct class Phone {
	private List<Call> calls = new ArrayList<>();
    
    public Money calcualteFee() {
    	Money result = Money.ZERO;
        
        for(Call call : calls) {
           result = result.plus(calculateCallFee(call));
       }
       
       return result;
   }
   
   abstract protected Money calculateCallFee(Call call);

}

RegularPhone.java

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

NightlyDiscountPhone.java

public class NightlyDiscountPhone extends Phone {
	prviate static final int LATE_NIGHT_HOUR = 22;
    
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
    
    public NgithlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
    	this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
    }
    
    @Overrid
    protected Money caculateCallFee(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());
    }
    
}   

기본 정책 + 세금 정책. by 상속

  • 일반 요금제에 세금 정책 조합
    • TaxableRegularPhone: RegularPhone 상속
      • 부모 클래스의 calculateFee를 오버라이딩 한 후 super 호출을 통해 일반요금제 규칙으로 계산된 요금을 구하고, 거기에 추가적으로 세금을 부과한다.

TaxableRegularPhone.java

public class TaxableRegularPhone extends RegularPhone {
	private 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)); // 세금 부과기능 추가
    }
}

🔨 기본 요금제(부모 클래스) 리팩토링

  • 문제점
    • 부모 클래스의 메서드를 재사용을 위한 super 사용은 부모-자식간의 결합도를 높인다.
  • 해결방법
    1. 부모 클래스가 추상 메서드를 제공하며, 자신이 정의한 추상 메서드를 호출한다.
    2. 자식 클래스가 이 메서드를 오버라이딩한다.
      • 이 방법은 자식 클래스가 부모 클래스의 구체적 구현이 아니라 필요한 동작의 명세를 기술하는 추상화에 의존하도록 만든다.

Phone.java

  • 추상 메서드인 afterCalculated를 추가한다.
    • 자식 클래스에게, 전체 요금 계산 후 수행할 로직을 추가할 기회를 제공한다.
  • 자식 클래스는 afterCalculated를 오버라이딩 해 계산된 요금에 적용할 작업을 추가한다.
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);
}

RegularPhone.java

  • 요금을 수정할 필요가 없으므로 그대로 반환한다.
public class RegularPhone extends Phone {
	private Money amount;
    private Duration seconds;
    
    public RegularPhone(Money amount, Duration seconds) {
    	this.amount = amount;
        this.seconds = seconds;
    }
    
    @Override
    purotected Money calculateCallFee(Call call) {
    	return amount.times(call.getDuration().getSeconds() / seconds.getSEconds());
    }
    
    @Override
    protected Money afterCalculated(Money fee) {
    	return fee;
    }

}    

NgithlyDiscountPhone.java

  • 요금을 수정할 필요가 없으므로 그대로 반환한다.
public class NightlyDiscountPhone extends Phone {
	privat static final int LATE_NIGHT_HOUR = 22;
    
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
    
    public NgithlyDiscountPhone(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());
        } else {
        	return regularAmount.times(call.getDuration().getSeconds / seconds.getSeconds());
        }
    }
    
    @Override
    protected Money afterCalculated(Money fee) {
    	return fee;
    }
}

💣 주의사항

  • 부모 클래스에 추상 메서드를 추가하는 경우, 모든 자식 클래스가 추상 메서드를 오버라이딩 해야한다는 문제점이 발생한다.
  • 모든 추상 메서드의 구현이 동일하다.
    • 중복 코드를 제거하기 위해, Phone에서 afterCalculatd에 대한 기본 구현을 함께 제공할 수 있다.
    • 이제 RegularPhoneNightlyDiscountPhone에서는 afterCalculated 메서드를 오버라이딩 하지 않아도 된다.
      • afterCalculatd와 같은 메서드를 훅 메서드라고 부른다.
public abstract class Phone {
	...
    protected Money afterCalculated(Money fee) {
    	return fee;
    }
    
    protected abstract Monoey calculateCallFee(Call call);
}

📌 추상 메서드와 훅 메서드

  • 추상메서드 (개방-폐쇄 원칙을 만족하는 설계 방법)
    • 부모 클래스에 새로운 추상 메서드를 추가하고, 부모 클래스의 다른 메서드 안에서 호출
    • 자식 클래스는 추상 메서드를 오버라이딩하고 자신만의 로직을 구현함으로써, 부모 클래스에서 정의한 플로우에 개입할 수 있게 된다.
    • 예시에서 calcualtedFee, afterCalculated
  • 추상 메서드의 단점: 모든 자식 클래스가 추상 메서드를 오버라이딩해야 한다.
    • 대부분의 자식 클래스가 동일한 방식으로 추상메서드를 구현하는 경우, 상속 계층 전반에 걸쳐 중복 코드가 존재하게 된다.
    • 이런 경우, 편의를 위해 기본 구현을 제공할 수 있는데, 이러한 메서드를 훅 메서드(hook method)라고 부른다.

🔨 세금 정책 (자식 클래스) 리팩토링

부모 클래스의 이름을 제외한 TaxableRegularPhoneTaxableNightlyDiscountPhone 사이의 코드 대부분이 중복된다.
자바를 비롯한 대부분의 객체지향 언어는 단일 상속만 지원하므로, 상속으로 인해 발생하는 중복 코드 문제 해결이 쉽지 않다.

TaxableRegularPhone.java

public class TaxableRegularPhone extends RegularPhone {
	private double taxRate;
    
    public TaxaleRegularPhone(Money amount, Duration seconds, double taxRate) {
    	super(amount, seconds);
        this.taxRate = taxRate;
    }
    
    @Override
    protected Money afterCalculated(Money fee) {
    	return fee.plus(fee.times(taxRate));
    }   
}

TaxableNightlyDiscountPhone.java

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));
   }
}
  • 요금제에 세금 정책을 추가한 상속 계층
    • 일반 요금제 단독 사용 = RegularPhone 인스턴스 생성
    • 일반 요금제 + 세금 정책 조합 = TaxableRegularPhone 인스턴스 생성
    • 심야 할인 요금제 단독 사용 = NightlyDiscountPhone 인스턴스 생성
    • 심야 할인 요금제 + 세금 정책 조합 = TaxableNightlyDiscountPhone 인스턴스 생성

기본 정책 + 기본 요금 할인 정책. by 상속

  • 일반 요금제에 기본 요금 할인 정책을 추가.
    • 기본 요금 할인 정책: 고정된 요구 금액을 차감하는 부가 정책
  • 이번에도 부가 정책을 구현한 두 클래스 사이에 중복 코드가 추가되었다.

RateDiscountableRegularPhone.java

  • 일반 요금제 + 기본 요금 할인 정책
    • RegularPhone 상속
public class RateDiscountableRegularPhone extends RegularPhone {
	private Money discountAmount;
    
    public RateDiscountableRegularPhone(Money amount, Duration seconds, Money discountAmount) {
    	super(amound, seconds);
    }
    
    @Override
    protected Money afterCalculated(Money fee) {
    	return fee.minus(discountAmount);
    }

}

RateDiscountableNightlyDiscountPhone.java

  • 심야 할인 요금제 + 기본 요금 할인 정책
public class RateDiscountableNightlyDiscountPhone extends NightlyDiscountPhone {
	private Money discountAmount;
    
    public RateDiscountableNightlyDiscountPhone(
    	Money regularAmount,
        Duration seconds,
        Money discountAmount
    ) {
    	super(nightlyAmount, regularAmount, seconds);
        this.discountAmount = discountAmount;
    }
    
    @Override
    protected Money afterCalculated(money fee) {
    	return fee.minus(discountAmount);
    }
  • 요금제에 기본 요금 할인 정책을 추가한 상속 계층
    • 어떤 클래스를 선택하느냐에 따라 적용하는 요금제의 조합이 결정된다.

중복 코드의 덫에 걸리다.

  • 요구사항:
    • 부가 정책은 자유롭게 조합 가능해야 하고, 적용되는 순서 역시 임의로 결정할 수 있어야 한다.
  • 상속을 이용한 해결방법:
    • 모든 가능한 조합별로 자식 클래스를 하나씩 추가하는 것.
// 일반요금제 + 세금 정책 + 기본요금 할인정책
public class TaxableAndRateDiscountableRegularPhone extends TaxableRegularPhone {
	privte Money discountAmount;
    ...
    
    @Override
    protected Money afterCalculated(Money fee) {
    	return super.afterCalculated(fee).minus(discountAmount);
    }
}

// 일반요금제 + 기본요금 할인정책 + 세금 정책
public class RateDiscountableAndTaxableRegularPhone extends RateDiscountableRegularPhone {
	private Money taxRate;
    ...
    
    @Override
    protected Money afterCalculated(Money fee) {
    	return super.afterCalculated(fee).plust(fee.times(taxRate));
    }
}    

// 심야할인 요금제 + 세금 정책 + 기본요금 할인 정책
public class TaxableAndDiscountableNightlyDiscountPhone extends TaxableNightlyDiscountPhone {
	private Money discountAmount;
    ...
    
    @Override
    protected Money afterCalculated(Money fee) {
    	return super.afterClauclated(fee).minus(discountAmount);
    }
}

// 심야할인 요금제 + 기본요금 할인 정책 + 세금 정책
public class RateDiscountalbeAndTaxableNightlyDiscountPhone extends RateDiscountableNightlyDiscountPhone {
	private double taxRate;
    ...
    
    @Override
    protected Money afterClauclated(Money fee) {
    	return super.afterCalculated(fee).plus(fee.times(taxRate));
    }
}
  • 현재까지 구현된 상속 계층
    • 매우 복잡하며, 새로운 정책을 추가하기 위해 불필요하게 많은 수의 클래스를 상속 계층에 추가해야 한다.
  • 현재까지 구현된 상속 계층에 고정 요금제(FixedRatePhone) 기본 정책이 추가되는 경우

클래스 폭발

  • 클래스 폭발(class explosiong) = 조합의 폭발(combinational explosion)
    • 상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가하는 경우를 말함
    • 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생
    • 컴파일 타임에 결정된 부모-자식 간의 관계는 변경될 수 없으므로, 다양한 조합이 필요한 상황에서의 유일한 해결방법은, 조합의 수 만큼 새로운 클래스를 추가하는 것 뿐이다.

03. 합성 관계로 변경하기

  • 상속 관계: 컴파일 타임에 결정 && 고정
    • 코드를 실행하는 도중에 변경할 수 없다.
      • 여러 기능을 조합해야 하는 경우, 가능한 모든 경우의 수를 나열해야 한다.
  • 합성 관계: 컴파일타임 관계를 런타임으로 변경하여 이 문제를 해결한다.
    • 구현이 아닌 퍼블릭 인터페이스에 대해서만 의존하므로, 런타임에 객체의 관계를 변경할 수 있다.

합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며, 실행 시점에 정책들의 관계를 유연하게 변경할 수 있게 된다.
합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법이라고 할 수 있다.
합성의 가장 큰 장점은 컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성할 수 있다는 점이다.

컴파일타임 의존성과 런타임 의존성 사이의 거리가 멀면 멀수록 설계의 복잡도가 상승하므로, 코드를 이해하는 것은 어려워질 수 있다.
대부분의 경우 단순한 설계가 정답이지만, 변경에 따르는 고통이 복잡성으로 인한 혼란을 넘어서고 있다면 유연성을 선택하는 것이 현명한 판단일 수 있다.

기본 정책 합성하기

1. 각 정책을 별도 클래스로 구현

  • 핸드폰이라는 개념으로부터 요금 계산 방법이라는 개념을 분리해야 한다.

RatePolicy.java

  • 기본 정책과 부과 정책을 포괄
  • Phone을 인자로 받아 계산된 요금을 반환하는 calculateFee 오퍼레이션을 포함
public interface RatePolicy {
	Money calculateFee(Phone phone);
}

2. 기본정책 추상화 정의

BasicRatePolic.java

  • 2가지 기본 정책인 일반요금제심야할인 요금제는 개별 요금을 계산하는 방식을 제외한 전체 로직이 거의 동일한데, 이 중복 코드를 담을 추상 클래스.
  • 상속 버전의 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);
}     

3. 기본정책 구현

RegularPolicy.java

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

NightlyDiscountPolicy.java

public class NgithlyDiscountPolicy extends BasicRatePolicy {
	private static final int LATE_NIGHT_HOUR = 22;
    
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
    
    public NgithlyDiscountPhone(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());
    }
}

4. Phone에 기본정책 합성

  • 합성: 내부에 RatePolicy에 대한 참조자가 포함.
    • RatePolicy : 다양한 요금 정책과 협력가능하도록 요금 정책의 타입이 인터페이스로 정의되어 있다.
    • Phone 생성자: 컴파일타임 의존성을 구체적인 런타임 의존성으로 대체하기 위해 생성자를 통해 RatePolicy 인스턴스에 대한 의존성을 주입받는다.
public calss Phone {
	private RatePlicy ratePolicy; // 합성
    private List<Call> calls = new ArrayList<>();
    
    public Phone(RatePlolicy ratePolicy) { // 외부 의존성 주입
    	this.ratePolicy = ratePolicy;
    }
    
    public List<Call> getCall() {
    	return Collections.unmodifiableList(calls);
    }
    
    public Money calculateFee() {
    	return ratePolicy.calculateFee(this);
    }
}

통화요금 계산 코드

합성을 사용하면 Phone과 연결되는 RatePolicy의 구현 클래스가 어떤 타입인지에 따라 요금을 계산하는 방식이 달라진다.

// 일반 요금제 규칙으로 통화 요금을 계산하는 경우
Phone phone = new Phone(new RegularPolicy(Money.wons(10), Duration.ofSeconds(10)));

// 심야할인 요금제에 따라 통화요금을 계산하는 경우
Phone phone = new Phone(new NightlyDiscountPolicy(Money.wons(5), Money.wons(10), Duration.ofSeconds(10)));
  • 합성 관계를 사용한 기본정책의 전체적인 구조

부가 정책 적용하기

기본 요금제의 인스턴스 관계

부가정책 요구사항 파악

  • 부가 정책은 기본 정책에 대한 계산이 끝난 후 적용된다.
    • 세금 정책을 추가한다면, 세금 정책은 RegularPolicy의 계산이 끝나고 Phone에게 반환되기 전에 적용되어야 한다.
    • 기본 요금제에 세금 정책을 부과한 인스턴스 관계
    • 일반 요금제에 기본 요금 할인 정책을 적용한 후 세금 정책을 부과한 인스턴스 관계

부가 정책의 2가지 제약사항

  1. 부가 정책은 기본 정책이나 다른 부가 정책의 인스턴스를 참조할 수 있어야 한다.
    다시 말해서 부가 정책의 인스턴스는 어떤 종류의 정책과도 합성될 수 있어야 한다.
  2. Phone의 입장에서는 자신이 기본 정책의 인스턴스에게 메시지를 전송하고 있는지, 부가 정책의 인스턴스에게 메시지를 전송하고 있는지를 몰라야 한다. 다시 말해서 기본 정책과 부가 정책은 협력 안에서 동일한 '역할'을 수행해야 한다.
    • 이것은 부가정책이 기본정책과 동일한 RatePolicy 인터페이스를 구현해야 함을 의미한다.
  • 요약
    1. 부가정책은 RatePolicy 인터페이스를 구현해야 함.
    2. 내부에 또다른 RatePolicy를 합성할 수 있어야 함.

AdditionalRatePolicy.java; 부가정책

Phone의 입장에서 AdditionalRatePolicyRatePolicy의 역할을 수행하므로 인터페이스를 구현한다.
또한 또 다른 요금 정책과 조합될 수 있도록 RatePolicy 타입의 next라는 이름을 가진 인스턴스 변수를 내부에 포함한다.

public abstract class AdditionalRatePolicy implements RatePolicy { // 1.
	private RatePolicy next; // 2.
    
    public AdditionalRatePolicy(RatePolicy next) {
    	this.next = next;
    }
    
    @Override
    public Money calculateFee(Phone phone) {
    	Money fee = next.calculateFee(phone);
        return afterCalcualted(fee);
    }
    
    abstract protected Money afterCalculated(Money fee);
}

TaxablePolicy.java; 세금정책

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

RateDiscountablePolicy.java; 기본요금 할인정책

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

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

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

// 일반 요금제 + 세금 정책
Phone phone = new Phone(
					new TaxablePolicy(0.05,
                    	new RegularPolicy(...));


// 일반 요금제 + 기본요금 할인정책 + 세금 정책
Phone phone = new Phone(
					new TaxablePolicy(0.05,
                    	new RegularDiscountPolicy(Money.wons(1000),
                        	new RegularPolicy(...)));

// 일반 요금제 + 세금 정책 + 기본요금 할인정책
Phone phone = new Phone(
					new RateDiscountPolicy(Money.wons(1000),
                     	new TaxablePolicy(0.05,
                        	new RegularPolicy(...)));
// 심야할인 요금제 + 세금 정책 + 기본요금 할인정책
Phone phone = new Phone(
					new RateDiscountPolicy(Money.wons(1000),
                     	new TaxablePolicy(0.05,
                        	new NightlyDiscountPolicy(...)));

상속을 이용한 설계보다 복잡하고, 정해진 규칙에 따라 객체를 생성하고 조합해야 하므로, 처음에는 코드를 이해하기 어려울 수 있다.
그러나 객체를 조립해 사용하는 방식이, 상속을 사용한 방식보다 더 예측 가능하고 일관성있다.

합성의 진정한 진가는 새로운 클래스의 추가나 수정의 시점에 비로소 알 수 있다.

새로운 정책 추가하기

  • 새로운 기본 정책 추가하기; 고정요금제(FixedRatePolicy)
  • 새로운 부가 정책 추가하기; 약정 할인 정책(AggrementDiscountablePolicy)

오직 하나의 클래스를 추가하고, 런타임에 필요한 정책들을 조합해 원하는 기능을 얻을 수 있다.

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

  • 상속: 부모 클래스의 세부적인 구현에 자식 클래스를 강하게 결합시켜 코드의 진화를 방해한다.
  • 합성: 객체의 인터페이스를 재사용한다.

그렇다면 상속은 사용하면 안 되는 것일까?
상속에는 2가지 상속이 있다.

  1. 구현상속
  2. 인터페이스 상속

이번 장에서 살펴본 상속에 대한 모든 단점들은 구현 단점에 국한되며, 인터페이스 상속을 사용해야 한다.


04. 믹스인

코드를 재사용하면서도 납득할만한 결합도를 유지하는 것.
합성은 객체의 구체적인 구현이 아니라 추상적인 인터페이스에 의존한다.

상속과 클래스를 기반으로 하는 재사용 방법을 사용하면 클래스의 확장과 수정을 일관성있게 표현할 수 있는 추상화의 부족으로 인해 변경하기 어려운 코드를 얻게 된다.

  • 믹스인(mixin)
    • 객체를 생성할 때 코드 일부를 클래스 안에 섞어 재사용하는 기법
      • 합성: 실행 시점에 객체를 조합하는 재사용 방법
      • 믹스인: 컴파일 시점에 필요한 코드 조각을 조합하는 재사용 방법

상속 vs 믹스인

  • 상속: 자식 클래스를 부모 클래스와 동일한 개념적인 범주로 묶어 is-a 관계를 만들기 위함.
    • 클래스와 클래스 사이의 관계를 고정.
  • 믹스인: 말 그대로 코드를 다른 코드 안에 섞어넣기 위함.
    • 유연하게 관계를 재구성할 수 있음.
    • 코드 재사용에 특화된 방법이며, 상속과 같은 결합도 문제를 초래하지 않는다.

코드를 다른 코드 안에 유연하게 섞어 넣을 수 있다면 믹스인이라고 부를 수 있다.
스칼라 언어에서 제공하는 트레이트(trait)를 이용해 믹스인을 구현해보자.

기본 정책 구현하기

BasicRatePolicy

abtract class BasicRatePolicy {

	def calculateFee(phone : Pone): Money = 
    	phone.calls.map(calcualteCallFEe(_)).reduce(_ + _)
        
    protected def calcualteCallFee(call: Call): Money;

}          

RegularPolicy

class RegularPolicy(val amount: Money, val seconds: Duration) extends BasicRatePolicy {
	
    override protected def calculateCallFee(call: Call): Money =
    	amount * (call.duration.getSceconds / seconds.getSeconds)

}

NightlyDiscountPolicy

class NightlyDiscountPolicy {
	val nightlyAmount: Money,
    val regularAmount: Money,
    val seconds: Duration) extends BasicRatePolicy {
    
    override protected def calcualteCallFee(call: Call): Money =
    	if (call.from.getHour >= NightlyDiscountPolicy.LateNightHour) {
        	nightlyAmount * (call.duration.getSeconds / seconds.getSeconds)
        } else {
        	regularAmount * (call.duration.getSeconds / seconds.getSeconds)
        }
    }
}    
 
object NightlyDiscountPolicy {
 	val LateNightHour: Integer = 22
}

트레이드로 부가 정책 합성하기

스칼라에서는 다른 코드와 조합해서 확장하는 기능을 트레이트로 구현할 수 있다.

TaxablePolicy.trait

trait TaxablePolicy extends BasicRatePolicy {
	def taxRate: Double
    
    override def calculateFee(phone: Phone): Money = {
    	val fee = super.calculateFee(phone)
        return fee + fee * taxRate
    }

}

RateDiscountablePolicy

trait RateDiscountablePolicy extends BasicRatePolicy {
	val discountAmount: Money
    
    override def calcualteFee(phone: Phone): Money = {
    	val fee = super.calculateFee(phone)
        fee - discountAmount
    }

}
  • BasicRatePolicy를 확장하고 있다.
    • 상속의 개념이 아니라❌, BasicRatePolicy나 그의 자손에 해당하는 경우에만 믹스인 가능함⭕️을 의미한다.
      • 장점1. 기본 정책의 기능에 대해서만 부가 정책 적용을 원한다는 제약을 코드로 표현해 명확히 의미전달할 수 있다.
      • 장점2. TaxablePolicy 트레이트를 사용하는 개발자의 실수를 막을 수 있다.
  • 믹스인은 제약을 둘 뿐, 실제로 어떤 코드에 믹스인될 것인지를 결정하지 않는다.
    • extends 코드는 단순히 TaxablePolicy사용될 수 있는 문맥을 제한할 뿐이다.
    • 실제로 트레이트를 믹스인하는 시점에서야 믹스인할 대상을 결정할 수 있다.
  • super 참조가 가리키는 대상 역시 컴파일 시점이 아닌 실행 시점에 결정된다.
    • 스칼라의 트레이트에서 super 참조는 동적으로 결정된다.
  • 상속은 재사용 가능한 문맥을 고정시키지만, 트레이트는 문맥을 확장 가능하도록 열어둔다.
    • 믹스인은 상속보다는 합성과 유사하다.
    • 믹스인은 독립적으로 작성된 트레으트와 클래스를 코드 작성시점에 조합해 더 큰 기능을 만들 수 있다.

부가 정책 트레이드 믹스인하기

  • 트레이트 조합(trait composition)
    • 부모 클래스가 존재하는 경우: extends로 상속받기
    • 트레이트를 믹스인하는 경우: with를 이용해 믹스인

예시) 표준 요금제 + 세금 정책

class TaxableRegularPolicy {
	amount: Money,
    seconds: Duration,
    val taxRate: Double)
  extends RegularPolicy(amount, seconds)
  with TaxablePolicy
  • 선형화(linearization); 믹스인한 클래스와 트레이트 중 어떤 메서드를 호출할지 결정한다.
    1. 항상 맨 앞에는 구현한 클래스 자기 자신이 위치한다.
    2. 오른쪽에 선언된 트레이트를 그 다음 자리에 위치시킨다.
    3. 왼쪽 방향으로 가면서 순서대로 그 자리에 위치시킨다.
  • 예시코드는 TaxableRegularPolicy > TaxablePolicy > RegularPolicy > BasicRatePolicy 순서로 진행된다.

인스턴스 생성시 트레이트 믹스인

인스턴스가 오직 한 곳에서만 필요한 경우 사용가능하다.
코드 여러 곳에서 동일한 트레이트를 믹스인해서 사용해야 한다면 명시적으로 클래스를 정의하는 것이 좋다.

new RegularPolicy(Money(100), Duration.ofSeconds(10))
	with RateDiscountablePolicy
    with TaxablePolicy {
  val discountAmount = Money(100)
  val taxRate = 0.02
}

쌓을 수 있는 변경

  • 추상 서브클래스(abstract subclass)
    • 믹스인은 상속 계층 안에서 확장한 클래스보다 더 하위에 위치하게 된다.
    • 믹스인은 대상 클래스의 자식 클래스처럼 사용될 용도로 만들어진다.
  • 쌓을 수 있는 변경(stackable modification)
    • 믹스인 사용 시 특정한 클래스에 대한 변경 또는 확장을 독립적으로 구현한 후 필요한 시점에 추가할 수 있다.

Reference

  • 오브젝트 | 조영호
profile
Good Luck!

0개의 댓글