Computer Science/객체지향

상속과 코드 재사용. 상속을 사용할 때 조심해야 할 점을 중심으로 - 코드로 이해하는 객체지향

sh1mj1 2023. 8. 28. 16:01

재사용 관점에서 상속 은 클래스 안에 정의된 인스턴스 변수와 메서드를 자동으로 새로운 클래스에 추가하는 구현 기법입니다. 

코드 재사용의 다른 방법으로는 합성이 있습니다. 새로운 클래스의 인스턴스 안에 기존 클래스의 인스턴스를 포함시키는 방법입니다. 합성은 다음 글에서 알아보고 이번에는 상속에 대해 자세히 알아봅니다.

 

상속과 중복 코드

DRY 원칙

중복 코드는 변경을 방해합니다. 중복 코드는 코드를 수정하는데 드는 비용을 더 증가시킵니다. 테스트를 할 때도 그렇죠.

 

중복 여부를 판단하는 기준은 모양이 아니라 변경입니다. 요구사항이 변경되었을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복인 것입니다. 중복 여부를 결정하는 기준은 코드가 변경에 반응하는 방식입니다. 코드의 모양이 유사하다는 것은 단지 중복의 징후일 뿐이죠.

 

DRY 원칙Don't Repeat Yourself 의 첫글자를 모아서 만든 용어입니다. 동일한 지식을 중복하지 말라 는 것입니다.

모든 지식은 시스템 내에서 단일하고, 애매하지 않고 믿을 만한 표현 양식을 가져야 합니다. Once, and Only Once 원칙, 단일 지점 제어(Single-Point Control) 원칙이라고도 합니다.

 

중복과 변경

중복코드

중복 코드는 요구사항이 추가/수정 되었을 때 새로운 중복 코드를 부릅니다. 새로운 중복 코드를 추가하는 과정에서 코드의 일관성이 무너질 위험도 항상 존재하지요. 중복 코드의 양이 많아질수록 버그 수는 증가하고 코드 변경에 대한 시간도 늘어납니다.

 

상속을 이용해서 중복 코드 제거

이 때 주의할 점이 있습니다. 처음부터 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해서 재용하는 것은 생각처럼 쉽지 않습니다.

개발자는 재사용을 위해서 상속 계층 사이에 무수히 많은 가정을 세웠을 수도 있고 그랬다면 코드를 굉장히 이해햐기 어려우며 직관에도 어긋납니다. 

 

아래 Phone 클래스는 22시 이전 통화에 대해 NightlyDiscountPhone 은 22시 이후에 한 통화들에 대한 요금을 계산하는 클래스입니다. 코드는 없지만 Call 클래스는 통화 시작 시간과 종료 시간을 인스턴스 변수로 가지고, 그 차이를 Duration 으로 리턴하는 getDuration 함수를 가지고 있습니다.

public class Phone {
    private Money amount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

    public Phone(Money amount, Duration seconds) {
        this.amount = amount;
        this.seconds = seconds;
    }

    public void call(Call call) {
        calls.add(call);
    }

    public List<Call> getCalls() {
        return calls;
    }

    public Money getAmount() {
        return amount;
    }

    public Duration getSeconds() {
        return seconds;
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
        }

        return result;
    }
}
public class NightlyDiscountPhone extends Phone {
    private static final int LATE_NIGHT_HOUR = 22;

    private Money nightlyAmount;

    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
        super(regularAmount, seconds);
        this.nightlyAmount = nightlyAmount;
    }

    @Override
    public Money calculateFee() {
        // 부모클래스의 calculateFee 호출
        Money result = super.calculateFee();

        Money nightlyFee = Money.ZERO;
        for(Call call : getCalls()) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                nightlyFee = nightlyFee.plus(
                        getAmount().minus(nightlyAmount).times(
                                call.getDuration().getSeconds() / getSeconds().getSeconds()));
            }
        }

        return result.minus(nightlyFee);
    }
}

 

NightlyDiscountPhone 에서는 모든 통화 중 22 시 이후에 한 통화에 대해서 (기본 요금 - 심야 할인 요금) 을 이용해서 nightlyFee 을 구한 뒤에 Phone 에서 구한 총 요금에서 nightFee 을 빼서 결과 요금 result 을 구하고 있습니다.

 

그런데 이렇게 10 시 이전의 요금에서 10 시 이후의 요금을 차감하는 것보다 그냥 10 시 이전의 요금과 10 시 이후의 요금을 더하는 것이 훨씬 쉽고, 직관적인 방법처럼 보입니다. 요구사항과 구현 사이의 차이가 클수록 코드를 이해하기가 어려워집니다. 상속을 잘못 사용하면 이 차이가 커져서 더 이해하기 어려워지지요. 위 예시는 단지 두 클래스 사이의 상속 관계만 있었지만 실제 프로젝트에서는 클래스의 상속 계층은 매우 깊을 것입니다.

 

상속을 이용해서 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세운 가정, 추론 과정을 정확히 이해해야 합니다. 즉, 자식 클래스 작성자가 부모 클래스의 구현 방법에 대해서 정확한 지식을 가져야 한다는 것입니다.

어떤 경우 상속은 결합도를 높이며 상속에 의한 강한 결합이 코드를 수정하기 어렵게 합니다. 

 

강하게 결합된 Phone 과 NightlyDiscountPhone

만약 위 상태에서 결과 요금 result 에 세금을 추가한다는 요구사항이 생겼다고 해봅시다. 그렇다면 코드는 아래 부분이 추가될 것입니다.

public class Phone {
    ...
    private double taxRate;

    public Phone(Money amount, Duration seconds, double taxRate) {
        this.taxRate = taxRate;
    }

    public Money calculateFee() {
        ...
        return result.plus(result.times(taxRate));
    }

    public double getTaxRate() {
        return taxRate;
    }
}
public class NightlyDiscountPhone extends Phone {
    ...
    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, double taxRate) {
        super(regularAmount, seconds, taxRate);
        ...
    }

    @Override
    public Money calculateFee() {
        ...
        return result.minus(nightlyFee.plus(nightlyFee.times(getTaxRate())));
    }
}

 

분명히 우리는 상속을 이용한 이유가 코드 중복을 제거하기 위함이었습니다. 그런데도 세금을 추가하기 위해서 중복 코드를 또 다시 만들게 되었습니다. 이 원인은 NightlyDiscountPhonePhone 의 구현에 너무 강하게 결합되어 있기 때문에 발생한 문제입니다. 

 

상속 이용 시 주의해야 할 점 1

자식 클래스의 메서드 안에서 super 참조를 이용해서 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다.     super 호출을 제거할 수 있는 방법을 찾아서 결합도를 제거하자.

 

 

취약한 기반 클래스 문제

위 예시처럼 상속 관계의 자식 클래스가 부모 클래스의 변경에 취약해지는 현상 취약한 기반 클래스 문제(Fragile Base Class Problem, Brittle Base Class Problem) 이라고 합니다. 이 문제는 코드 재사용을 목적으로 상속을 사용할 때 발생하는 가장 대표적인 문제입니다.

 

객체지향의 기반은 캡슐화를 통한 변경의 통제입니다. 하지만 취약한 기반 클래스 문제는 캡슐화를 약화시키고 결합도를 높입니다. 자식 클래스가 부모 클래스의 구현 세부사항에 의존하게 만들기 때문에 캡슐화가 약해지지요.

 

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

자바 초기 버전에서도 상속을 잘못 사용한 사례가 있습니다. java.util.Propertiesjava.util.Stack 입니다.

 

 

Stack 을 잘못 상속한 사례

Stack 은 LIFO 자료 구조인 스택의 구현체,  Vector 는 리스트 자료 구조의 구현체(java.util.List 의 초기 버전)입니다. 초기에는 Vector 의 추가, 삭제 오퍼레이션을 재사용하기 위해 StackVector 의 자식 클래스로 구현했습니다.

 

Stack 의 규칙은 맨 마지막 위치에서만 요소를 추가하고 제거할 수 있는 것인데 Vector 을 상속받아서 이 규칙이 쉽게 위반될 수 있습니다. 

 

Properties 를 잘못 상속한 사례

java.util.Properties 클래스는 키 - 값을 쌍으로 보관한다는 점에서 Map 과 비슷하지만 Map 은 다양한 타입을 저장할 수 있고 Properties 는 오직 String 만 가질 수 있습니다.

 

PropertiesMap 의 조상인 Hashtable 을 상속받아 Hashtable 의 인터페이스에 포함되어 있는 put 메서드를 이용하면 String 타입 이외의 키와 값이라도 Properties 에 저장할 수 있게 됩니다. 이것도 Properties 의 근본적인 규칙이 위반되는 것이죠.

 

이렇게 퍼블릭 인터페이스에 대한 고려 없이 단순 코드 재사용을 위해서 상속을 이용하는 것은 위험합니다. 객체지향의 핵심은 객체들의 협력이기 때문에 단순 코드 재사용을 위해서 불필요한 오퍼레이션이 인터페이스에 스며들도록 하면 안 됩니다.

 

상속 이용 시 주의해야 할 점 2

상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.

 

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

InstrumentedHashSetHashSet 의 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 클래스입니다. HashSet 의 자식 클래스이죠.

public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;
    
    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }
    
    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }
}

InstrumentedHashSet 의 구현에는 아무런 문제가 없어보입니다. 하지만 아래와 같은 코드를 실행한다고 합시다.

 

InstrumentedHashSet<String> language = new InstrumentedHashSet<>();
languages.addAll(Arrays.asList("Java", "Ruby", "Scala"));

아마 addCount 의 값이 3 이 될 거라고 생각할 것입니다. 하지만 실제로는 6 이 됩니다. 부모 클래스인 HashSetaddAll 메서드 아에서 add 메서드를 호출하기 때문입니다.

위 코드에 적힌 대로 먼저 InstrumentedHashSetaddAll 이 호출되어 addCount 의 값이 3이 됩니다. 그리고 super.addAll() 에 의해서  HashSet 의  addAll 메서드를 실행하고, HashSetadd 메서드가 총 세 번 호출, 결과적으로 InstrumentedHashSetadd 메서드가 세 번 호출되어서 addCount 에 3 이 추가로 더해져서 최종 결과는 6 이 됩니다.

 

만약 이 문제를 해결하기 위해서 InstrumentedHashSetaddAll 메서드를 제거한다면 자동으로 HashSetaddAll 메서드가 호출되고 예상했던 결과가 나올 것입니다. 하지만 이렇게 한다면 나중에 HashSetaddAdll 메서드가 add 메시지를 전송하지 않도록 수정된다면 addAll 메서드를 이용해서 추가되는 요소들에 대한 카운트가 누락될 것입니다.

 

수정까지 감안한 더 좋은 해결책은 InstrumentedHashSetaddAll 메서드를 오버라이딩하고 추가되는 각 요소에 대해서 한 번씩 add 메시지를 호출하는 것입니다.

 

public class InstrumentedHashSet<E> extends HashSet<E> {
    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }
    
    @Override
    public boolean addAll(Collection<? extends E> c) {
        boolean modified = false;
        for(E e: c) {
            if (add(e)) {
                modified = true;
            }
        }
        return modified;
    }
}

 

하지만 이 방법에는 오버라이딩된 addAll 메서드의 구현이 HashSet 과 동일합니다. 미래에 발생할지 모르는 위험을 방지하기 위해 코드를 중복시킨 것이죠.

 

상속 이용 시 주의해야 할 점 3

자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.

 

조슈아 블로치: '클래스가 상속되려면 클래스가 처음부터 상속을 위해서 설계되고, 문서화되어야 하며, 그렇지 않다면 상속을 금지해야 한다.' 상속이 초래하는 문제를 보완하면서 코드 재사용의 장점을 극대화하려면 이를 따르는 게 좋습니다.

그런데 '문서화 하는 것'은 결국 내부 구현을 드러내는 것입니다. 객체지향적인 설계를 위해서는 내부 구현을 캡슐화하는 것인데 말이죠. 결국 이것은 trade-off 입니다. 

 

상속은 코드 재사용을 위해서 캡슐화를 희생합니다. 완벽한 캡슐화를 위해서라면 코드 재사용을 포기하거나 상속이 아닌 다른 방법을 사용해야 합니다.

 

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

만약 음악 목록을 추가할 수 있는 플레이리스트를 Song 클래스와 Playlist 클래스로 구현한다고 합시다.

public class Song {
    private String singer;
    private String title;
    
    // 생성자 , getter 메서드 코드 ...
}

 

public class Playlist {
    private List<Song> tracks = new ArrayList<>();
    
    public void append(Song song) {
        getTracks().add(song);
    }
    
    // getter 메서드 코드 ...
}

 

추후에 요구사항이 추가되어 플레이리스트에서 노래를 삭제할 수 있는 기능이 추가된 PersonalPlayList 가 필요해졌습니다. 빠르게 구현하기 위해 상속을 사용해서 Playlist 의 코드를 재사용한다고 합시다.

public class PersonalPlaylist extends Playlist {
    public void remove(Song song) {
        getTracks().remove(song);
    }
}

 

그리고 다시 요구사항이 변경되서 Playlist 에서 노래 목록 뿐 아니라 가수 별 노래의 제목도 함께 관리해야 한다고 합시다. 노래를 추가하고 나서 가수의 이름을 key 로 노래의 제목을 추가하도록 append 메서드가 수정됩니다.

public class Playlist {
    private List<Song> tracks = new ArrayList<>();
    private Map<String, String> singers = new HashMap<>();
    
    public void append(Song song) {
        tracks.add(song);
        singers.put(song.getSinger(), song.getTitle());   
    }
    
    public List<Song> getTracks() {
        return tracks;
    }
    
    public Map<String, String> getSingers() {
        return singers;
    }
}

 

그렇다면 PersonalPlaylistremove 메서드도 수정해야 합니다. 만약 수정하지 않는다면 PlayListtracks 에서는 노래가 제거되지만 singers 에는 여전히 노래가 남아있을 것입니다.

public class PersonalPlaylist extends Playlist {
    public void remove(Song song) {
        getTracks().remove(song);
        getSingers().remove(song.getSinger());
    }
}

 

위 예시에서는 자식 클래스 PersonalPlaylist 가 부모 클래스인 Playlist 의 메서드를 오버라이딩하고 있지 않으며 불필요한 인터페이스도 상속받고 있지 않습니다. 그럼에도 불구하고 부모 클래스를 수정할 때 자식 클래스를 함께 수정해야 하는 상황이지요. 

 

상속은 자식 클래스가 부모 클래스의 구현을 재사용하는 것이 기본 전제입니다. 그래서 자식 클래스가 부모 클래스의 내부에 대해 알도록 강요합니다. 즉, 서로의 결합도가 커집니다.

 

상속 이용 시 주의해야 할 점 4

클래스를 상속하면 결합도로 인해서 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않기 혹은 자식 클래스와 부모 클래스를 동시에 변경하기 둘 중 하나를 선택할 수 밖에 없습니다.

 

 

이렇게 상속으로 생기는 취약한 기반 클래스 문제의 예를 살펴보았습니다. 이제 다시 PhoneNightlyDiscountPhone 의 문제로 돌아와서 상속으로 인한 피해를 어떻게 최소화할 수 있는지를 찾아봅시다.

Phone 으로 돌아가서

취약한 기반 클래스 문제를 어느정도 완화시키는 방법은 추상화에서 힌트를 얻을 수 있습니다.

 

추상화에 의존하기

부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정해야 합니다.

 

코드 중복을 제거하기 위해 상속을 도입할 때는 

 

  • 두 메서드가 유사하다면 차이점을 메서드로 추출하자. 메서드 추출을 통해서 두 메서드를 동일한 형태로 만들 수 있다.
  • 부모 클래스를 하위로 내리지 말고 자식 클래스를 상위로 올리자. 자식 클래스의 추상적인 메서드를 부모 클래스 레벨로 올리는 것이 재사용성과 응집도 측면에서 더 좋다.

 

차이를 메서드로 추출

변하는 것으로부터 변하지 않는 것을 분리하자.
변하는 부분을 찾고 이를 캡슐화하자.

 

이 원칙을 메서드 수준에 적용하는 것입니다.

 

현재 PhoneNightlyDiscountPhone 클래스의 코드 (taxRate 없을 때)

public class Phone {
    private Money amount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();
    
    // amount, seconds 가 인자인 생성자
    
    public Money calculateFee() {
        Money result = Money.ZERO;
        
        for(Call call: calls) {
            result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
        }
        return result;
    }
    
    // 여러 getter 메서드 코드...
}
public class NightlyDiscountPhone {
    private static final int LATE_NIGHT_HOUR = 22;
    
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();
    
    // nightlyAmount, regularAmount, seconds 를 인자로 갖는 생성자 코드 ...
    
    public Money calculateFee() {
        Money result = Money.ZERO;
        
        for(Call call: calls) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
            result = result.plus(
                nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            } else {
                result = result.plus(
                    regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            }
        }
        return result;
    }
    
    // 여러 getter 메서드 코드 ...
}

두 클래스의 calculateFeefor 문 안에 구현된 요금 계산 로직이 서로 다릅니다. 이 부분을 동일한 이름의 메서드 calculateCallFee 로 추출합니다.

 

public class Phone {
    ...
    public Money calculateFee() {
        ...
        for(Call call: calls) {
            result = result.plus(calculateCallFee(call));
        }
        return result;
    }
    
    private Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}
public class NightlyDiscountPhone {
    ...
    public Money calculateFee() {
        ...
        for(Call call: Calls) {
            result = result.plus(calculateCallFee(call));
        }
        return result;
    }
    
    private Money calculateCallFee(Call call) {
        if(call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
            return nightlyAmount.tiems(call.getDuration().getSeconds() / seconds.getSeconds());
        } else {
            return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }
    ...
}

중복 코드를 부모 클래스로 올려라

이 상태에서 부모 클래스를 추가합니다.목표는 PhoneNightlyDiscountPhone 모두 추상화에 의존하도록 만드는 것이므로 추상 클래스 AbstractPhone 을 만들고 PhoneNightlyDiscountPhone 의 공통 부분을 부모 클래스로 이동시킵시다. 이 때 팁은 인스턴스 변수보다 메서드를 먼저 옮기고 컴파일 에러를 확인해 가면서 인스턴스 변수를 옮기는 것이 좋습니다.

 

public abstract class AbstractPhone {
    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 Phone extends AbstractPhone {
    private Money amount;
    private Duration seconds;
    
    // 생성자 코드 ...
    
    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}
public class NightlyDiscountPhone extends AbstractPhone {
    private static final int LATE_NIGHT_HOUR = 22;
    
    private Money nightlyAmount;
    private Moeny discountAmount;
    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());
    }
}

리팩토링 후 상속 계층.

이제 설계는 추상화에 의존하게 되었습니다.

 

공통 코드를 이동시킨 후 이제 각 클래스는 서로 다른 변경의 이유를 갖습니다.

 

  • AbstractPhone 은 전체 통화 목록을 계산하는 방법이 바뀌었을 때,
  • Phone 은 일반 요금제의 통화 한건을 계산하는 방식이 바뀌었을 때
  • NightlyDiscountPhone 은 심야 할인 요금제의 통화 한 건을 계산하는 방식이 바뀌었을 때

변경됩니다.

 

각 클래스가 각각 하나의 변경 이유만을 가지는 것이죠.

단일 책임 원칙(Single Responsibility Principle, SRP) 을 준수하기 때문에 응집도가 높습니다.

 

calculateCallFee 메서드의 시그니처가 변경되지 않는다면 부모 클래스의 내부 구현이 변경되어도 자식 클래스는 영향을 받지 않습니다. 결합도가 낮지요.

 

추상화이며 요금 계산 관련 상위 수준 정책인 AbstractPhone 이 구현인 PhoneNightlyDiscountPhone 에 의존하지 않고 구현이 추상화에 의존하고 있기 때문에 의존성 역전 원칙(DIP)도 준수하고 있습니다.

 

또한 새로운 요금제 추가도 쉽습니다. 새로운 요금제를 위해서는 AbstractPhone 을 확장하는 클래스를 추가하고 calculateCallFee 메서드만 오버라이딩하면 됩니다. 개방-폐쇄 원칙(OCP)도 준수하고 있습니다.

 

의도를 드러내는 이름 선택

마지막으로 한 가지 아쉬운 점만 고치면 될 것 같습니다. NightlyDiscountPhone 이라는 이름은 심야 할인 요금제와 관련된 내용을 구현한다는 사실을 잘 드러내고 있지만 Phone 은 일반 요금제 관련 내용을 구현한다는 사실을 명시적으로 전달하지 못합니다. 또한 NightlyDIscountPhonePhone 은 전화기의 한 종류이지만 AbstractPhone 이라는 이름은 이 둘을 포함한다는 의미도 드러내지 않습니다.

 

현재에서 아래처럼 이름을 변경하는 것이 더 의도를 드러냅니다.

 

 

세금 추가하기

한참 위에서 taxRate 라는 인스턴스 변수를 추가해서 총 요금을 계산하는 데 사용했던 것었죠? taxRate 는 모든 요금제에 공통으로 적용되는 요구사항이었습니다. 그러므로 공통 코드를 담고 있는 추상 클래스 Phone 을 수정하면 될 것 같습니다.

 

public abstract class Phone {
    private double taxRate;
    private List<Call> calls = new ArrayList<>();
    
    public Phone(double taxRate) {
        this.taxRate = taxRate;
    }
    
    public Money calculateFee() {
        Money result = Money.ZERO;
        
        for(Call call: calls) {
            result = result.plus(calculateCallFee(call));
        }
        return result.plus(result.times(taxRate));
    }
    
    protected abstract Moeny calculateCallFee(Call call);
}

하지만 이것으로 다 끝난 게 아닙니다.. Phone 에 인스턴스 변수인 taxRate 을 추가했고 두 인스턴스 변수의 값을 초기화하는 생성자를 추가했기 때문에 Phone 의 자식 클래스인 RegularPhoneNightlyDiscountPhone 의 생성자 역시 taxRate 을 초기화하기 위해서 수정해야 합니다.

 

public class RegularPhone extends Phone {
    ....
    public RegularPhone(Money amount, Duration seconds, double taxRate) {
        super(taxRate);
        this.amount = amount;
        this.seconds = seconds;
    }
    ...
}
public class NightlyDiscountPhone extends Phone {
    ....
    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, double taxRate) {
        super(taxRate);
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
    }
    ...
}

 

 

자식 클래스는 자신의 인스턴스를 생성할 때 부모 클래스에 정의된 인스턴스 변수를 초기화해야 하기 때문에 부모 클래스에 추가된 인스턴스 변수는 자식 클래스의 초기화 로직에 영향을 미칩니다. 아무리 책임을 잘 분리해도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발합니다.

 

하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 세금 계산 코드를 중복시키는 것보다는 훨씬 나은 선택입니다.

객체 생성 로직에 대한 변경을 막는 것보다는 핵심 로직의 중복을 막는 것이 중요합니다. 객체 생성 로직의 변경은 여러 '의존성 해결' 방법을 통해서 어느 정도 컨트롤할 수 있습니다. 

 

 

결론적으로 상속으로 인한 클래스 사이의 결합을 완전히 피할 수는 없습니다. 메서드 구현에 대한 결합은 추상 메서드를 추가함으로써 어느정도 완화할 수 있지만 인스턴스 변수에 대한 잠재적인 결합은 제거할 수 없습니다.

 

(물론 세금에 대한 계산을 Phone 클래스에서 하는 것이 아닌 따로 Tax 라는 클래스를 만들어서 해결할 수도 있을 것입니다. 세율(taxRate) 라던지, 절세 정책(taxPolicy)이 여러 개가 된다면 그렇게 해결하는 것 또한 괜찮은 방법이겠지요.  아래처럼 말이죠. )

 

차이에 의한 프로그래밍

차이에 의한 프로그래밍(Programming by difference) 는 이처럼 기존 코드와 다른 부분만을 추가함으로써 앱의 기능을 확장하는 방법입니다.

 

상속을 이용하면 이미 존재하는 클래스의 코드를 쉽게 재사용할 수 있어 앱의 점진적인 정의(incremental definition) 이 가능해집니다.

 

코드를 재사용하는 것은 코드의 품질도 유지하면서 코드 작성과 테스트의 수고를 덜 수 있습니다. 

 

상속은 코드 재사용이라는 측면에서 매우 강력한 도구이지만 상속의 오남용은 앱의 이해와 확장을 어렵게 합니다.