오브젝트; 상속의 문제

🗓️

객체지향 프로그래밍에서 코드를 재사용하기 위한 기법으로 상속과 합성은 가장 널리 사용된다.

상속

  • 부모클래스와 자식클래스를 연결해 부모클래스의 코드를 재사용한다.
  • 클래스 사이의 의존성은 컴파일 타임에 결정된다.
  • is-a 관계
  • 상속을 제대로 사용하기 위해서는 부모의 내부 구현을 상세하게 알아야 하기 때문에 결합도가 높음
  • 정적인 관계

합성

  • 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해 부분 객체의 코드를 재사용한다.
  • 객체 사이의 의존성은 런타임에 결정된다.
  • has-a 관계
  • 퍼블릭 인터페이스에 의존하기 때문에 내부 구현이 변경되더라도 영향을 최소화 할 수 있음
  • 동적인 관계

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

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

  • 상속 : 부모 클래스 내 구현된 코드 자체를 재사용함
  • 합성 : 객체의 퍼블릭 인터페이스를 재사용함.

객체 합성은 클래스 상속의 대안이다. 새로운 기능을 위해 객체들을 합성한다. 객체를 합성하려면 합성할 객체들의 인터페이스를 명확하게 정의해야만 한다. 이런 스타일의 재사용을 블랙박스 재사용이라고 하는데, 객체의 내부는 공개되지 않고 인터페이스를 통해서만 재사용되기 때문이다.

상속을 합성으로 변경하기

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

각 상속 관계를 합성 관계로 변경해보자.

java.util.Perperties

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 오퍼레이션들이 퍼블릭 인터페이스를 오염시키지 않는다. 특히 모든 타입의 키와 값을 저장할 수 있는 Hashtable의 오퍼레이션을 사용할 수 없기 때문에 String만 사용하는 Properties의 규칙을 지킬 수 있다. PropertiesHashtable과 협력하기 위해서는 getter 와 setter 를 통해야만 한다.

java.util.Stack

Vector가 필요한 부분에 대해서 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);
    }
}

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

InstrumentedHashSet

마찬가지로 합성 관계로 변경할 수 있다. HashSet 인스턴스 내부에 포함한 다음 HashSet의 퍼블링 인터페이스로 제공하면 된다.

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

PropertiesStack을 합성으로 변경한 것은 불필요한 오버레이션들이 퍼블릭 인터페이스에 스며드는 것을 방지하기 위해서다.

HashSet에 대한 구현 결합도는 제거하면서도 퍼블릭 인터페이스는 그대로 상속받을 수 있는 방법은 자바의 Interface를 사용하면 된다. HashSetSet의 구현체다. InstrumentedHashSet이 제공해야 하는 오퍼레이션은 Set 인터페이스에 정의되어 있다. 그말은 InstrumentedSet 인터페이스를 구현하면서 내부에 HashSet의 인터페이스를 합성하면 구현 결합도는 제거하면서도 퍼블릭 인터페이스는 그대로 유지할 수 있다

Set<>의 구현

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


    // Set의 나머지 인터페이스를 구현한다..
    // remove, clear, equals, hashCode, spliterator...
}

포워딩 Forwarding : Set의 오퍼레이션을 오버라이딩 한 인스턴스 메서드에서 내부의 HashSet 인스턴스에게 동일한 메서드 호출을 그대로 전달한다. 동일한 메서드를 호출하기 위해 추가된 메서드를 포워딩 메서드 Forwarding Method 라고 부른다.

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

Playlist의 경우엔 합성으로 변경해도 가수별 트랙을 유지하기 위해 함께 수정해야 하는 문제는 여전하다. 그러나 상속보다 나은점은 캡슐화를 통해 변경에 대한 파급효과를 상쇄할 수 있기 때문이다.

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

몽키 패치

현재 실행중인 환경에만 영향을 미치도록 지역적으로 코드를 수정하거나 확장하는 것을 가리킨다. 코드를 수정할 권한이 없거나 소스코드가 존재하지 않는다고 하더라도 몽키 패치가 지원되는 환경이라면 메서드를 추가하는 것이 가능하다.
루비같은 동적 타입 언어에서는 이미 완성된 클래스에도 기능을 추가할 수 있는 열린 클래스 라는 개념을 제공한다. C# 의 확장 메서드와 스칼라의 암시적 변환 역시 몽키 패치다. 자바는 언어 차원에서 몽키 패치를 지원하지 않기 때문에 바이트코드를 직접 변환하거나 AOP 를 이용해 몽키패치를 구현한다.

상속과 비교해서 합성은 안정성과 유연성이라는 장점이 있다.

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

상속으로 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경향이 있다. 다음과 같은 문제가 있다.

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

이것을 합성으로 해결할 수 있다.

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

부가정책은 다음과 같은 특성을 가진다.

  • 기본 정책의 계산 결과에 적용된다
    세금 정책은 기본 정책인 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) {
        // constructor...
    }

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

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

만약 일반 요금제에 세금 정책을 조합해야 한다면 RegularPhone 클래스를 상속받은 TaxableRegularPhone 클래스를 추가한다. RegularPhone.calculateFee()는 일반 요금제 규칙에 따라 계산된 요금을 반환하므로 이 반환값에 세금을 부과해 반환하면 일반 요금제와 세금 정책을 조합한 요금을 계산할 수 있다.

TaxableRegularPhone

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로 호출하는 것은 원하는 결과를 쉽게는 얻을수 있지만 결합도가 증가한다. 이를 방지하는 것은 부모 클래스에 추상 메서드를 제공하는 것이다.

Phone.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);

    //...
}

자식 클래스 구현 반영

public class RegularPhone extends Phone {
    private Money amount;
    private Duration seconds;

    public RegularPhone(Money amount, Duration seconds) {
        // constructor..
    }

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

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

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) {
        // constructor... 
    }

    @Override
    protected Money calculateCallFee(Call call) {
        //...
    }

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

부모 클래스에 추상 메서드를 추가하면 모든 자식 클래스들이 추상 메서드를 오버라이딩 해아한다. 모든 추상 메서드의 구현이 동일하다면 부모에서 구현을 제공하자.

추상 메서드와 훅 메서드

개방-폐쇄 원칙을 만족하는 설계를 만들 수 있는 한 가지 방법은 부모 클래스에 새로운 추상 메서드를 추가하고 부모 클래스의 다른 메서드 안에서 호출하는 것이다. 자식 클래스는 추상 메서드를 오버라이딩하고 자신만의 로직을 구현해서 부모 클래스에서 정의한 플로우에 개입할 수 있게 된다. 처음에 Phone 클래스에서 추상 메서드인 calculateCallFee() 와 afterCalculated()를 선언하고 자식 클래스에서 두 메서드를 오버라이딩 한 것 역시 이 방식을 응용한 것이다.
추상 메서드의 단점은 상속 계층에 속하는 모든 자식 클래스가 추상 메서드를 오버라이딩해야 한다는 것이다. 대부분의 자식 클래스가 추상 메서드를 동일한 방식으로 구현한다면 상속 계층 정반에 걸쳐 중복 코드가 존재하게 될 것이다. 해결 방법은 메서드에 기본 구현을 제공하는 것이다. 이처럼 추상 메서드와 동일하게 자식 클래스에서 오버라이딩할 의도로 메서드를 추가했지만 편의를 위해 기본 구현을 제공하는 메서드를 훅 메서드라고 부른다. 예제에서 기본 구현을 가지도록 수정된 afterCalculated() 가 훅 메서드다.

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

Phone의 상속 계층에 세금 정책을 추가한 상속 계층을 다이어그램으로 표현.

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

일반 요금제와 기본 요금 할인 정책을 조합하고 싶다면 RegularPhone을 상속 받는 RateDiscountableRegularPhone 클래스를 추가하면 된다.

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 RateDiscountableNightlyDiscountPhone extends NightlyDiscountPhone {
    private Money taxRate;

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

기본 요금 할인 정책을 추가한 후의 상속 계층을 표현.

중복 코드의 덫에 걸리다

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

public class TaxableAndRateDiscountableRegularPhone extends TaxableRegularPhone {
    private Money discountAmount;

    // constructor...

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

public class RateDiscountableAndTaxableRegularPhone extends RateDiscountableRegularPhone {
    private double taxRate;

    // constructor...

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

public class TaxableAndDiscountableNightlyDiscountPhone extends TaxableNightlyDiscountPhone {
    private Money discountAmount;

    // constructor...

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

public class RateDiscountableAndTaxableNightlyDiscountPhone extends RateDiscountableNightlyDiscountPhone {
    private double taxRate;

    // constructor...

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

아래 상속계층은 복잡성보다 더 큰문제가 있는데 새로운 정책을 추가하기 어렵다는 것이다. 새로운 정책을 추가하려면 불필요하게 많은 클래스를 상속 계층안에 추가해야한다.

상속 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 클래스 폭발 또는 조합의 폭발 이라고 한다. 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생하는 문제다. 컴파일 타임에 결정된 자식 클래스와 부모 클래스의 관계는 변경될 수 없다. 때문에 조합이 필요할때 조합만큼 새로운 클래스를 추가해야한다. 이 문제는 상속을 포기함으로써 최선의 해결책이 될 수 있다.