Chap 10. 상속과 코드 재사용

Minjae An·2023년 12월 8일

오브젝트

목록 보기
10/15

객체지향에서 코드는 일반적으로 클래스 내에 작성이 되기 때문에 객체지향에서 클래스를
재사용하는 전통적인 방법은 새로운 클래스를 추가하는 것이다. 객체지향에는 상속
합성이라는 대표적인 클래스 재사용 기법이 존재한다. 이들의 이점과 차이를 비교해보자.

👨‍🍼 상속과 중복 코드

DRY 원칙

중복 코드는 변경을 방해한다. 이것이 중복 코드를 제거해야 하는 가장 큰 이유이다. 프로그램의
본질은 비즈니스 관련 지식을 코드로 변환하는 것이다. 지식은 항상 변하고 그것을 표현하는
코드 역시 변경되어야 한다.

중복 코드가 가지는 가장 큰 문제는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킨다는
것이다. 중복 코드를 발견했다면 모든 코드를 일괄적으로 수정해야 한다. 중복 여부를 판단하는
기준은 코드가 변경에 반응하는 방식이다.

신뢰할 수 있고 수정하기 쉬운 소프트웨어를 만드는 가장 효과적인 방법 중 하나는 중복을 제거하는 것이다. DRYDon’t Repeat Yourself의 첫 글자를 모아 만든 용어로 동일한 지식을 중복하지 말라는 의미이다.

모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을 만한
표현 양식을 가져야 한다.

DRY 원칙은 Once and Only Once(한 번, 단 한번), Single-Point Control(단일 지점 제어)라고도 불린다.

중복과 변경

중복 코드 살펴보기

중복 코드의 문제점을 이해하기 위해 한 달에 한 번씩 가입자별 전화 요금을 계산하는 간단한
애플리케이션을 개발해 보자. 전화 요금을 계산하는 규칙은 통화 시간을 단위 시간당 요금으로
나눠주면 된다.

먼저 개별 통화 기간을 저장하는 Call 클래스가 필요하다.

public class Call {
    private LocalDateTime from;
    private LocalDateTime to;

    public Call(LocalDateTime from, LocalDateTime to) {
        this.from = from;
        this.to = to;
    }

    public Duration getDuration() {
        return Duration.between(from, to);
    }

    public LocalDateTime getFrom() {
        return from;
    }
}

이제 전체 통화 목록을 알고 있는 정보 전문가에게 요금을 계산할 책임을 할당해야 한다. 일반적으로 통화 목록은 전화기 안에 보관되고, 따라서 Call 의 목록을 관리할 정보 전문가는 Phone 이다.

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 Money getAmount() {
        return amount;
    }

    public Duration getSeconds() {
        return seconds;
    }

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

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

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

다음은 Phone 을 이용해 ‘10초에 5원’씩 부과되는 요금제에 가입한 사용자가 각각 1분 동안
두 번 통화를 한 경우 요금을 계산하는 방법을 코드로 나타낸 것이다.

Phone phone = new Phone(Money.wons(5), Duration.ofSeconds(10));
phone.call(new Call(LocalDateTime.of(2018, 1, 1, 12, 10, 0),
        LocalDateTime.of(2018, 1, 1, 12, 11, 0)));
phone.call(new Call(LocalDateTime.of(2018, 1, 2, 12, 10, 0),
        LocalDateTime.of(2018, 1, 2, 12, 11, 0)));

phone.calculateFee(); // =Money.wons(60)

시간이 흘러 ‘심야 할인 요금제’라는 새로운 요금 방식을 추가해야 한다는 요구사항이 발생했다. 이 요구사항을 해결할 수 있는 가장 빠른 방법은 Phone 의 코드를 복사해서 NightlyDiscountPhone 이라는 새로운 클래스를 만든 후 수정하는 것이다.

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

    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = 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;
    }
}

중복 코드 수정하기

중복 코드가 코드 수정에 미치는 영향을 살펴보기 위해 새 요구사항을 추가해보자. 이번에
추가할 기능은 통화 요금에 부과할 세금을 계산하는 것이다. 부과되는 세율은 가입자의 핸드폰마다
다르다고 가정한다. 세금을 추가하기 위해서는 PhoneNightlyDiscountPhone 두 클래스 모두를 수정해야 한다.

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

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

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

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

        return result.plus(result.times(taxRate));
    }
}
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<>();
    private double taxRate;

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

    // ...

    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.minus(result.times(taxRate));
    }
}

이 예제는 중복 코드가 가지는 단점을 잘 보여준다. 중복 코드는 항상 함께 수정되어야 하기 때문에버그가 발생될 확률이 높다. 또한 위 코드에서는 Phone 에서 구현이 plus 로 잘못 구현된 것을 눈치채기가 어렵다.

타입 코드 사용하기

두 클래스 사이 중복 코드를 제거하는 한 방법은 클래스를 하나로 합치는 것이다. 타입 코드를
추가하고 타입 코드의 값에 따라 로직을 분기시켜 앞선 두 클래스를 하나로 합칠 수 있다.
하지만 타입 코드를 사용하는 클래스는 낮은 응집도와 높은 결합도 문제에 시달리게 된다.

public class Phone {
    private static final int LATE_NIGHT_HOUR = 22;

    enum PhoneType {REGULAR, NIGHTLY}

    private PhoneType type;

    private Money amount;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

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

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

    public Phone(PhoneType type, Money amount, Money nightlyAmount,
                 Money regularAmount, Duration seconds) {
        this.type = type;
        this.amount = amount;
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
    }

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

        for (Call call : calls) {
            if (type == PhoneType.REGULAR) {
                result = result.plus(
                        amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            } else {
                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;
    }
}

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

상속의 기본 아이디어는 이미 존재하는 클래스와 유사한 클래스가 필요하다면 코드를 복사하지
않고 상속을 이용해 코드를 재사용하라는 것이다. 따라서 NightlyDiscountPhone 을 다음과 같이 재구성할 수 있다.

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

위 코드에서 calculateFee 메서드를 보면, 오버라이딩한 부모 클래스의 동일한 메서드를
호출하여 일반 요금제에 따라 통화 요금을 계산한 후 이 값에서 통화 시작 시간이 10시 이후인
통화의 요금을 빼주는 형식이다.

이렇게 구현한 이유를 유추해보면 심야 할인 요금제는 10시를 기준으로 분리된 2개의 요금제로 구성되어 있고, 따라서 10시 이전의 요금은 일반 요금제로 10시 이후의 통화 요금은 심야 할인 요금제로 계산하기 위함임을 알 수 있다. 문제는 위 방식이 직관적인 10시 이전의 요금과 10시 이후의 요금을 더하는 기대했던 방식과 달라 직관적으로 파악하기가 어렵다는 점이다.

예제 코드는 비현실적이며 그나마 문제를 이해하기 쉬우나 실제 개발 환경에서는 더 복잡한 형태로 상속 관련된 문제가 발생할 확률이 높다. 상속을 이용해 코드를 재사용하기 위해서는
부모 클래스의 개발자가 세운 가정이나 추론 과정을 정확히 이해해야 한다. 이는 자식 클래스의
작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다는 것을 의미한다. 따라서
상속은 강한 결합을 초래하고 코드 수정을 어렵게 만든다.

강하게 결합된 Phone과 NightlyDiscountPhone

앞선 상속을 이용한 코드에서 세금을 부과하는 요구사항이 추가되면 어떻게 될까?

public class Phone {
    // ...
		private double taxRate;

    public Phone(Money amount, Duration seconds, double taxRate) {
        this.amount = amount;
        this.seconds = seconds;
        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);
        this.nightlyAmount = nightlyAmount;
    }

    @Override
    public Money calculateFee() {
       // ...

        return result.minus(nightlyFee.plus(nightlyFee.times(getTaxRate())));
    }
}

Phone 의 코드를 재사용하고 중복 코드를 제거하기 위해 상속을 사용했으나, 세금을 부과하는
로직을 추가하기 위해 Phone 을 수정시 유사 코드를 NightlyDiscountPhone 에도
추가해야 한다.

다시 말해 중복을 제거하기 위해 상속을 사용했으나 세금 계산 로직을 추가하기 위해 새로운 중복
코드를 만들어야 하는 것이다.

이것은 자식 클래스가 부모 클래스의 구현에 너무 강하게 결합되어 있기 때문에 발생하는 문제다.

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

이처럼 상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을
취약한 기반 클래스 문제라고 부른다. 코드 재사용을 목적으로 상속을 사용시 발생하는
가장 대표적인 문제다.

😅 취약한 기반 클래스 문제

부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제(Fragile Base Class Problem, Brittle Class Problem) 라고 부른다.

상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는 데는 용이하지만 높은 결합도로 인해 부모 클래스를 점진적으로 개선하는 것은 어렵게 만든다. 최악의 경우 모든 자식 클래스를
동시에 수정하고 테스트해야 할 수도 있다.

취약한 기반 클래스 문제는 캡슐화를 약화시키고 결합도를 높인다. 상속은 자식 클래스가 부모
클래스의 세부사항에 의존하도록 만들기 때문에 캡슐화를 약화시킨다. 객체를 사용하는 이유는
세부사항을 퍼블릭 인터페이스 뒤로 캡슐화하여 파급효과를 제어할 수 있기 때문이다.

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

자바의 초기 버전에서 상속을 잘못 사용한 대표적인 사례는 java.util.Propertiesjava.util.Stack 이다. 자바의 초기 컬렉션 프레임웍 개발자들은 요소의 추가, 삭제 오퍼레이션을 제공하는 Vector 를 재사용하기 위해 StackVector 의 자식 클래스로 구현했다.

안타깝게도 StackVector 를 상속 받기 때문에 Stack 의 퍼블릭 인터페이스에 Vector 의 퍼블릭 인터페이스가 합쳐진다. 따라서 Vector 의 퍼블릭 인터페이스를
이용하면 임의의 위치에서 요소를 추가하거나 삭제할 수 있다. 따라서 Stack 의 LIFO 규칙을 쉽게 위반할 수 있다.

Stack<String> stack = new Stack<>();
stack.push("1st");
stack.push("2nd");
stack.push("3rd");

stack.add(0, "4th");

assertEquals("4th", stack.pop()); // error!

인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 만들어야 한다.

java.util.Properties 클래스는 잘못된 유산을 물려받는 또 다른 클래스다. 키와 값의 쌍을
보관한다는 점에서 Map 과 유사하지만 다양한 타입을 저장할 수 있는 Map 과 달리 키와 값의
타입으로 오직 String 만을 가질 수 있다.

이 클래스는 Map 의 조상인 Hashtable 을 상속받는데 자바에 제내릭이 도입되기 이전에
만들어졌기 때문에 컴파일러가 키와 값의 타입이 String 인지 여부를 체크할 수 있는 방법이
없었다.

Properties properties = new Properties();
properties.setProperty("Bjarne Stroustrup", "C++");
properties.setProperty("James Gosling", "Java");

properties.put("Dennis Ritchie", 67);

assertEquals("C", properties.getProperty("Dennis Ritchie")); // error

위 코드를 실행해보면 Dennis Ritcie 를 키로 검색할 경우 null 이 반환된다는 사실을 알 수 있다. 그 이유는 getProperty 메서드가 반환할 값의 타입이 String 이 아닐 경우 null 을 반환하도록 구현돼 있기 때문이다.

앞선 예시들을 통해서 퍼블릭 인터페이스에 대한 고려 없이 단순히 코드 재사용을 위해 상속을 이용하는 것이 얼마나 위험한지를 잘 보여준다. 단순히 코드를 재사용하기 위해 불필요한 오퍼레이션이 인터페이스에 스며들도록 방치해서는 안 된다.

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

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

조슈아 블로치는 <이펙티브 자바>에서 HashSet 의 구현에 강하게 결합된 InstrumentedHashSet 클래스를 소개한다. HashSet 의 내부에 저장된 요소의 수를
셀 수 있는 기능을 추가할 클래스로서 HashSet 의 자식 클래스로 구현돼 있다.

public class InstrumentHashSet<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 은 요소를 추가한 횟수를 기록하기 위해 addCount 라는 인스턴스
변수를 포함한다. 로직 내에서 super 참조를 통해 부모 클래스의 메서드를 호출해 요소를
추가한다는 것을 알 수 있다. 이 구현에는 별 문제가 없어 보인다.

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

대부분의 사람들은 위 코드 실행 후 addCount 의 값이 3이 될 거라고 예상할 것이다. 하지만 실제 값은 6이다. 그 이유는 부모 클래스인 HashSetaddAll 메서드 안에서 add 를 호출하기 때문이다.

이 문제를 해결할 수 있는 방법은 InstrumentedHashSetaddAll 메서드를 제거하는 것이다. 이러면 HashSetaddAll 이 호출될 것이다. 하지만 이 방법 역시 문제가 될 수
있다. 나중에 HashSetaddAlladd 메시지를 전송하지 않도록 수정된다면 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 의 것과 동일하다는 문제가 있다. 발생할 수 있는 위험을 방지하기 위해 코드를 중복시킨 것이다.

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

상속은 코드 재사용을 위해 캡슐화를 희생한다. 완벽한 캡슐화를 원한다면 코드 재사용을
포기하거나 상속 외 다른 방법을 사용해야 한다.

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

음악 목록을 추가할 수 있는 플레이리스트를 구현한다고 가정하자.

public class Song {
    private String singer;
    private String title;

    public Song(String singer, String title) {
        this.singer = singer;
        this.title = title;
    }

    public String getSinger() {
        return singer;
    }

    public String getTitle() {
        return title;
    }
}
public class Playlist {
    private List<Song> tracks = new ArrayList<>();

    public void append(Song song) {
        getTracks().add(song);
    }

    public List<Song> getTracks() {
        return tracks;
    }
}

이제 플레이리스트에서 노래를 삭제할 수 있는 기능이 추가된 PersonalPlaylist
필요하다고 가정해보자.

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

요구사항이 변경돼서 Playlist 에서 노래의 목록뿐만 아니라 가수별 노래의 제목도 함께 관리해야 한다고 가정하자. 다음과 같이 노래를 추가한 후에 가수의 이름을 키로 노래의 제목을 추가하도록 Playlistappend 메서드를 수정해야 할 것이다.

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 메서드도 함께 수정해야 한다.

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

이 예시는 상속을 사용하면 자식 클래스가 부모 클래스의 구현에 강하게 결합되기 때문에 부모
클래스의 수정에 자식 클래스의 수정이 동반됨을 보여준다. 따라서 코드 재사용을 위한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께 수정해야 하는 상황 역시 빈번하게 발생할 수 밖에 없다.

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

♻ Phone 다시 살펴보기

추상화에 의존하자

NightlyDiscountPhone 의 가장 큰 문제점은 Phone 에 강하게 결합되어 있기 때문에 Phone 이 변경될 경우 함께 변경될 가능성이 높다는 것이다. 이 문제의 가장 일반적인 해결법은 자식 클래스가 부모 클래스의 구현이 아닌 추상화에 의존하도록 만드는 것이다. 코드 중복을
제거하기 위해 상속을 도입할 때 다르는 두 가지 원칙이 있다.

  • 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다.
  • 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를
    부모 클래스로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어난 결과를 얻을 수 있다.

차이를 메서드로 추출하라

가장 먼저 할 일은 중복 코드 내에서 차이점을 별도의 메서드로 추출하는 것이다. 이것은 흔히
말하는 “변하는 것으로부터 변하지 않는 것을 분리하라”라는 조언을 메서드 수준에서 적용한 것이다.

PhoneNightlyDiscountPhonecalculateFee 메서드의 구현 일부와 인스턴스 변수의 목록이 조금 다르다. 자세히 calculateFeefor 문 안에 구현된 요금 계산 로직이 서로 다르다는 사실을 알 수 있다. 이 부분을 동일한 이름인 calculateCallFee 로 추출하자.

public class Phone {
    // ...

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

        for (Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }

        return result;
    }

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

NightlyDiscountPhone 의 경우도 동일하게 수정한다.

public class NightlyDiscountPhone {
    // ...

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

        for (Call call : calls) {
            result = calculateCallFee(call);
        }

        return result;
    }

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

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

부모 클래스를 추가하자. 목표는 모든 클래스들이 추상화에 의존하도록 만드는 것이기 때문에
이 클래스는 추상 클래스로 구현하는 것이 적합할 것이다.

public abstract class AbstractPhone {}

public class Phone extends AbstractPhone { ... }

public class NightlyDiscountPhone extends AbstractPhone { ... }

이제 두 자식 클래스의 공통 부분을 부모 클래스로 옮기자. 공통 코드를 옮길 때 메서드를 먼저
이동시키면 그 메서드에 필요한 메서드나 인스턴스 변수가 무엇인지를 컴파일 에러를 통해
자동으로 알 수 있어 편리하다.

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

calculateCallFee의 경우 자식 클래스에서 내부 구현이 다르다. 따라서 구현은 그대로 두고 공통 부분인 시그니처만 부모 클래스로 이동시켜야 한다.

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;

    public Phone(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 AbstractPhone {
    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(chap10.step2.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 의 경우 각 요금제의 계산 방식이 바뀔 경우에만 변경된다. 세 클래스가 각각 하나의 변경 이유만을 가진다. 이 클래스들은 SRP를 준수하기 때문에 응집도가 높다.

이 구조에서 자식 클래스들은 부모 클래스에서 정의한 추상 메서드에만 의존하기 때문에 부모
클래스의 내부 구현이 변경되더라도 자식 클래스는 영향을 받지 않는다.
이 설계는 낮은 결합도를 유지하고 있다.

부모 클래스 역시 자신의 내부에 구현된 추상 메서드를 호출하기 때문에 추상화에 의존한다고 말할 수 있다. DIP도 준수하는데, PhoneNightlyDiscountPhone 이 추상화인 AbstractPhone 에 의존하기 때문이다.

새로운 요금제를 추가하기 쉽다는 사실 역시 주목하라. 새 요금제가 필요하다면 AbstractPhone 을 상속받는 새로운 클래스를 추가한 후 calculateCallFee 메서드만 오버라이딩하면 된다.
다른 클래스를 수정할 필요가 없기 때문에, OCP 역시 준수한다.

의도를 드러내는 이름 선택하기

클래스 이름과 관련하여 아쉬운 부분이 있다. Phone 은 일반 요금제와 관련된 내용을
구현한다는 사실을 명시적으로 전달하지 못한다. AbstractPhone 이라는 이름은 전화기를
포괄한다는 의미를 명확하게 전달하지 못한다. 따라서 다음과 같이 변경하는 것이 좋다.

public abstract class Phone { ... }

public class RegularPhone extends Phone { ... }

public class NightlyDiscountPhone extends 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));
    }

    abstract protected Money calculateCallFee(Call call);
}

자식 클래스에서는 부모 클래스에 새로운 인스턴스 변수가 추가되었기에 생성자를
수정해주어야 한다.

public class RegularPhone extends Phone {
    // ...
    public RegularPhone(double taxRate, Money amount, Duration seconds) {
        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;
    }

    // ...
}

인스턴스 변수의 목록이 변하지 않는 상황에서 객체의 행동만 변경된다면 상속 계층에 속한 각
클래스들을 독립적으로 진화시킬 수 있다. 하지만 부모 클래스에 인스턴스 변수가 추가될 경우
자식 클래스의 초기화 로직에 영향을 미치게 된다. 결과적으로 책임을 잘 분리하더라도
상속 계층 전반에 걸친 변경이 발생한다.
하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 세금 계산 코드를
중복시키는 것 보다는 현명한 선택이다.

지금까지 살펴본 것처럼 상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없다. 우리가
궁극적으로 원하는 것은 행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 계층 전체에
걸쳐 부작용이 퍼지지 않게 막는 것이다.

🔛 차이에 의한 프로그래밍

상속이 강력한 이유는 익숙한 개념을 이용해서 새로운 개념을 쉽고 빠르게 추가할 수 있기
때문이다. 이처럼 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법을 차이에 의한 프로그래밍(programming by difference) 이라고 부른다.

차이에 의한 프로그래밍의 목표는 중복 코드를 제거하고 코드를 재사용하는 것이다. 사실 중복 코드 제거와 코드 재사용은 동일한 행동을 가라키는 다른 용어다. 중복 코드는 악의 근원이다. 중복 코드를 제거하기 위해 최대한 코드를 재사용해야 한다.

OOP 프로그래밍에 갓 입문한 프로그래머들은 강력한 상속의 위력에 도취되어 모든 설계에 상속을 적용하려고 시도한다. 하지만, 코드 재사용과 관련된 대부분의 경우에 상속은 우아한
해결 방법이 아니다. OOP에 능숙한 개발자들은 상속의 단점을 극복하고 코드를 재사용할 수 있는 합성이라는 더 좋은 방법을 알고 있다.

profile
도전을 성과로

0개의 댓글