티스토리 뷰

이번 장에서는 클래스를 재사용하기 위해 새로운 클래스를 추가하는 가장 대표적인 기법인 상속합성 중 상속과 잘못된 상속으로 인해 발생하는 문제들과 해결방안에 대해 알아보겠습니다.

상속과 중복 코드


DRY원칙

중복 코드는 변경을 방해한다. 중복 코드가 가지는 가장 큰 문제는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킨다는 것이다. 중복 코드는 수정과 테스트에 드는 비용을 증가시킨다. 만약 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이다. 함께 수정할 필요가 없다면 중복이 아니다. 모양이 유사하다는 것은 단지 중복의 징후일 뿐이다.

**DRY는 “반복하지 마라”**라는 뜻의 Don’t Repeat Yourself의 첫 글자를 모아 만든 용어로 간단히 말해 동일한 지식을 중복하지 말라는 것이다. DRY 원칙은 한번, 단 한번(Once and Only One) 원칙 또는 단일 지점 제어(Single-Point Control) 원칙이라고도 부른다.

잘못된 상속에 의한 문제들


상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다. 이것은 자식 클래스의 작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다는 것을 의미한다. 따라서 상속은 결합도를 높인다고 이야기할 수 있다. 아래에는 상속에 의한 강한 결합도로 생기는 몇 가지 문제에 대해 살펴보도록 하겠다.

강하게 결합된 코드

부모 클래스와 자식 클래스 사이의 결합이 문제인 이유를 살펴보자. 자식 클래스는 부모 클래스인 Phone의 calculateFee 메서드를 오버라이딩한다. 만약 세금을 부과하는 요구사항이 추가된다면 어떻게 될까?

public class Phone {
    private double taxRate; // 세금 부과 요구사항으로 인한 변수 추가
    public Phone(Money amount, Duration seconds, double taxRate) {
        ...
        this.taxTate = taxRate;
    }

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

    public double getTaxRate() {
        return this.taxRate;
    }
}

Phone에서 taxRate를 사용하기 위해 자식 클래스 또한 taxRate를 받아 생성자로 부모 클래스인 Phone에 전달해야 한다. 또 Phone과 동일하게 값을 반환할 때 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()));
    }
}

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

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

위의 예시는 자식 클래스가 부모 클래스의 구현에 강하게 결합될 경우 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는다는 사실을 잘 보여준다.

이처럼 상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 가리켜 취약한 기반 클래스 문제라고 한다.

취약한 기반 클래스 문제

상속은 자식 클래스와 부모 클래스의 결합도를 높인다. 강한 결합도로 인해 자식 클래스는 불필요한 세부사항에 엮이게 된다. 이처럼 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제(Fragile Base Class Problem, Brittle Base Class Problem)라고 부른다. 취약한 기반 클래스 문제는 상속이라는 문맥 안에서 결합도가 초래하는 문제점을 가리키는 용어이다.

  • 상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는 데는 용이하지만 부모 클래스를 점진적으로 개선하는 것은 어렵게 만든다.
  • 상속은 자식 클래스가 부모 클래스의 구현 세부사항에 의존하도록 만들기 때문에 캡슐화를 약화시킨다.

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

java.util.Stack은 대표적인 잘못된 상속의 예이다. 부모 클래스에서 상속받은 메서드를 사용할 경우 자식 클래스의 규칙이 위반될 수 있다는 것이다. 자바는 초창기 Stack을 개발할 때 요소의 추가, 삭제 오퍼레이션을 제공하는 Vector를 재사용하기 위해 Stack을 Vector의 자식 클래스로 구현했다. 이것이 어떤 문제를 가지게 되었을까?

위의 그림을 보면 Vector는 요소를 조회, 추가, 삭제하는 get, add, remove 오퍼레이션을 제공한다. 이에 비해 Stack은 맨 마지막 위치에서만 요소를 추가하거나 제거할 수 있는 push, pop 오레이션을 제공한다.

하지만 Stack이 Vector를 상속받았기 때문에 Stack의 퍼블릭 인터페이스에 Vector의 퍼블릭 인터페이스가 합쳐진다.

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

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

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

Stack에 마지막으로 추가한 값은 “4th” 지만 pop 메서드의 반환값은 “3rd”이다. 그 이유는 Vector의 add 메서드를 이용해서 스택의 맨 앞에 “4th”를 추가했기 때문이다.

물론, Stack을 사용하는 개발자들이 Vector에서 상속받은 add 메서드를 사용하지 않으면 된다고 생각할 수 있다. 명심하자.

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

객체지향의 핵심은 객체들의 협력이다. 단순히 코드를 재사용하기 위해 불필요한 오퍼레이션이 인터페이스에 스며들도록 방치해서는 안된다.

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

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

오버라이딩 오작용 문제를 설명하기 위해, HashSet의 구현에 강하게 결합된 InstrimentedHashSet 클래스를 알아보자.

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

InstrimentedHashSet은 add메서드와 addAll 메서드를 오버라이딩 한다. 두 메서드는 먼저 addCount를 증가시킨 후 super 참조를 이용해 부모 클래스의 메서드를 호출해서 요소를 추가한다.

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

위의 코드를 실행한 후 addCount의 값이 3이 될 거라고 예상할 것이다. 하지만 실제로 실행한 후의 결과는 6이다. 그 이유는 부모 클래스인 HashSet의 addAll 메서드 안에서 add를 호출하기 때문이다. 이 문제의 해결법은 InstrimentedHashSet의 addAll 메서드를 제거하는 것이다. 하지만 나중에 HashSet의 addAll 메서드가 add 메시지를 전송하지 않도록 수정된다면 addAll 메서드를 이용해 추가되는 요소들에 대한 카운트가 누락될 가능성이 있기 때문에 이 또한 좋은 수정이 아니다.

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

조슈아 블로치는 클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화해야 하며, 그렇지 않은 경우에는 상속을 금지시켜야 한다고 주장한다.

그러나 잘된 API 문서는 메서드가 무슨 일(what)을 하는지를 기술해야 하고, 어떻게 하는지(how)를 설명해서는 안 된다는 통념을 어기는 것은 아닐까? 그렇다. 어기는 것이다! 이것은 결국 상속이 캡슐화를 위반함으로써 초래된 불행인 것이다.

설계는 트레이드오프 활동이라는 사실을 기억하라. 상속은 코드 재사용을 위해 캡슐화를 희생한다. 완벽한 캡슐화를 원한다면 코드 재사용을 포기하거나 상속 이외의 다른 방법을 사용해야 한다.

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

서브 클래스는 올바른 기능을 위해 슈퍼 클래스의 세부적인 구현에 의존한다. 슈퍼 클래스의 구현은 릴리스를 거치면서 변경될 수 있고, 그에 따라 서브클래스의 코드를 변경하지 않더라도 깨질 수 있다. 결과적으로, 슈퍼 클래스의 작성자가 확장될 목적으로 특별히 그 클래스를 설계하지 않았다면 서브 클래스는 슈퍼 클래스와 보조를 맞춰서 진화해야 한다.(항상 함께 수정되어야 한다.)
💡 상속을 위한 경고 4 클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.

문제 해결을 위한 방법


추상화에 의존하자

이 문제를 해결하는 가장 일반적인 방법은 자식 클래스가 부모 클래스의 구현이 아닌 추상화에 의존하도록 만드는 것이다. 정확하게 말하면 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정해야 한다. 필자는 코드 중복을 제거하기 위해 상속을 도입할 때 따르는 두 가지 원칙을 제시한다.

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

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

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

자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성할 수 있다. 설계가 추상화에 의존한다는 말이다.

추상화가 핵심이다

공통 코드를 이동시킨 후 각 클래스는 서로 다른 변경의 이유를 가진다. Phone은 전체 통화 목록을 계산하는 방법이 바뀔 경우에만 변경된다. RegularPhone은 일반 요금제의 통화 한 건을 계산하는 방식이 바뀔 때, NightlyDiscountPhone은 심야 할인 요금제의 통화 한 건을 계산하는 방식이 바뀔 때 변경된다. 이 클래스 들은 단일 책임 원칙을 준수하기 때문에 응집도가 높다.

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

새로운 요금제를 추가하기도 쉬워졌다. 새로운 요금제가 필요하다면 Phone을 상속받는 새로운 클래스를 추가한 후 calculateCallFee 메서드만 오버라이딩하면 된다. 이는 확장에 열려있고 수정에는 닫혀 있기 때문에 개방-폐쇄 원칙 역시 준수한다.

💡 추상화가 핵심이다! 지금까지 살펴본 모든 장점은 클래스들이 추상화에 의존하기 때문에 얻어지는 장점이다. 상속에 의해 클래스들 간 결합도가 너무 높아진다면 추상화를 찾아내고 상속 계층 안의 클래스들이 그 추상화에 의존하도록 코드를 리팩터링하라. 차이점을 메서드로 추출하고 공통적인 부분은 부모 클래스로 이동하라!

차이에 의한 프로그래밍


상속을 사용하면 이미 존재하는 클래스의 코드를 기반으로 다른 부분을 구현 함으로써 새로운 기능을 쉽고 빠르게 추가할 수 있다. 상속이 강력한 이유는 익숙한 개념을 이용해서 새로운 개념을 쉽고 빠르게 추가할 수 있기 때문이다.

이처럼 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법을 차이에 의한 프로그래밍(Programming by difference)이라고 부른다. 차이에 의한 프로그래밍의 목표는 중복 코드를 제거하고 코드를 재사용하는 것이다. 코드를 재사용하는 것은 단순히 문자를 타이핑하는 수고를 덜어주는 수준의 문제가 아니다. 재사용 가능한 코드란 심각한 버그가 존재하지 않는 코드다. 따라서 코드를 재사용하면 코드의 품질은 유지하면서도 코드를 작성하는 노력과 테스트는 줄일 수 있다.

상속은 강력한 도구다. 하지만 재사용과 관련된 대부분의 경우를 해결할 수 있는 방법은 아니다. 잘못된 상속은 결합도를 높여 코드를 중복시키고 애플리케이션을 이해하고 확장하기 어렵게 만든다는 치명적인 단점을 초례할 수 있다. 객체지향에 능숙한 개발자들은 상속의 단점을 피하면서도 코드를 재사용할 수 있는 더 좋은 방법이 있다는 사실을 알고 있다. 바로 합성이다.

 

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday