[오브젝트] chap10을 읽고

객체지향 프로그래밍의 장점 중 하나는 코드를 재사용하기가 용이하다는 것이다. 전통적인 패더라임에서 코드를 재사용하는 방법은 코드를 복사한 후 수정하는 것이다. 객체지향은 조금 다른 방법을 취한다. 객체지향에서는 코드를 재사용하기 위해 ‘새로운’ 코드를 추가한다.

이번 장에서는 클래스를 재사용하기 위해 클래스를 추가하는 대표적인 기법인 상속에 관해 살펴보기로 한다. 재사용 관점에서 상속이란 클래스 안에 정의된 인스턴스 변수와 메서드를 자동으로 새로운 클래스에 추가하는 구현 기법이다.

코드를 재사용하려는 강력한 동기 이면에는 중복된 코드를 제거하려는 욕망이 숨어 있다. 따라서 상속에 대해 살펴보기 전에 중복 코드가 초래하는 문제점을 살펴보자.

상속과 중복 코드

DRY 원칙

중복 코드는 변경을 방해한다. 이것이 중복 코드를 제거해야 하는 가장 큰 이유다. 요구 사항은 항상 변하기 때문에 일단 새로운 코드를 추가하고 나면 언젠가는 변경될 것이라고 생각하는 것이 현명하다.

중복 코드가 가지는 가장 큰 문제는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킨다는 것이다. 우선 어떤 코드가 중복인지를 찾아내야 하고, 중복 코드들을 일관되게 수정해야 한다. 또 모든 중복 코드를 테스트해 동일한 결과를 내놓는지 확인해야만 한다.

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

신뢰할 수 있고 수정하기 쉬운 소프트웨어를 만들기 위해서는 중복을 제거해야 한다. 이는 DRY(Don’t Repeat Yourself) 원칙이라고 한다. 모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을 만한 표현 양식을 가져야 한다. 즉, 핵심은 코드 안에 중복이 존재해서는 안 된다는 것이다.

중복과 변경

A 클래스가 존재하고 A 클래스와 비슷한 기능을 하는 B 클래스에 대한 요구사항이 생겼다. 우리는 A의 코드를 B로 복사한 후 B의 기능에 맞게 기능을 수정할 수 있다. 우리는 아주 짧은 시간 안에 B 클래스를 구현했다. 하지만 구현 시간을 절약한 대가로 지불해야 하는 비용은 예상보다 크다. A와 B 사이에는 중복 코드가 존재하기 때문에 언제 터질지 모르는 시한폭탄을 안고 있는 것과 같다.

만약 A와 B에 새로운 공통 기능이라는 요구 사항이 추가되었다고 가정해보자.

중복 코드는 항상 함께 수정돼야 하기 때문에 수정할 때 하나라도 빠트린다면 버그로 이어질 것이다. 또한, 중복 코드는 새로운 요구 사항에 대해 새로운 중복 코드를 생성한다. 중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것 뿐이기 때문이다.

더 큰 문제는 중복 코드가 늘어날수록 애플리케이션은 변경에 취약해지고 버그가 발생할 가능성이 높아진다는 것이다. 중복 코드의 양이 많아질수록 버그의 수는 증가하며 그에 비례해 코드를 변경하는 속도는 점점 더 느려진다.

중복 코드가 나쁜 것은 알았다. 그럼 우리는 이 문제를 어떻게 해결할 수 있을까?

타입 코드 사용하기

두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다. 요금제를 구분하는 Enum클래스(타입)를 클래스의 인스턴스 변수로 추가하고 타입 코드의 값에 따라 로직을 분기시키는 것이다. 하지만 타입에 따라 조건을 분기하는 것은 chap04에서 알아본 것처럼 응집도를 떨어뜨린다. 만약 클래스에 새로운 요구사항이 생겨 외부에서 타입에 따라 다른 메시지를 호출해야 한다면 클라이언트에서 해당 클래스가 어떤 타입인지 알아야 하기 때문에 chap04에서 알아본 것 처럼 높은 결합도 문제가 생긴다.

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

객체지향 프로그래밍 언어는 타입 코드를 사용하지 않고도 중복 코드를 관리할 수 있는 효과적인 방법을 제공한다. 바로 상속이다.

상속의 기본 아이디어는 매우 간단하다. 이미 존재하는 클래스와 유사한 클래스가 필요하다면 코드를 복사하지 말고 상속을 이용해 코드를 재사용하라는 것이다. B 클래스가 A 클래스를 상속하도록 하면 코드를 중복시키지 않고도 A 클래스의 코드 대부분을 재사용할 수 있다.

A 클래스와 B 클래스는 유사하지만 서로 다른 기능을 가지고 있기 때문에 B 클래스는 A 클래스의 calculateFee 메서드를 오버라이딩하고 있고 코드를 최대한 재사용하기 위해서 super.calculateFee()를 내부에서 호출하고 있다고 가정한다.

1
2
3
4
5
6
7
8
9
10
class B extends A {
    // ...

    @Override
    public calculateFee() {
        Money result = super.calculateFee();
        // calculate discountBFee;
        return result.minus(discountBFee);
    }
}

위의 코드가 무슨 일을 하는지 이해하기 쉽지 않을 것이다. B는 A가 계산한 요금에서 내부적으로 할인 금액을 계산해 기존 A 요금에서 빼야 한다.

위의 문장을 읽고 코드를 다시 보면 어렴풋이 이해가 될 것이다. 이해가 안 되더라도 상관은 없다. 중요한 것은 개발자의 가정을 이해하기 전에는 코드를 이해하기 어렵다는 점이다.

위에서 숨겨진 가정은 A 클래스의 calcalateFee 메서드는 기본 요금을 반환하고 있다는 사실이다.

이 예를 통해 알 수 있는 것 처럼 상속을 염두해 두고 설계되지 않은 클래스를 상속에 애용해 재사용 하는 것은 생각처럼 쉽지 않다. 개발자는 재사용을 위해 상속 계층 사이에 무수히 많은 가정을 세웠을지도 모른다. 그리고 그 가정은 코드를 이해하기 어렵게 만들 뿐만 아니라 직관에도 어긋날 수 있다.

실제 프로젝트에서 마주치게 될 코드는 여기의 예시보다 훨씬 더 엉망일 확률이 높다. 깊고 깊은 상속 계층의 계단을 하나 내려올 때마다 이해하기 어려운 가정과 마주하게 된다고 생각해보라.

결합도는 하나의 모듈이 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 정도로 정의했다. 상속을 이용해 코드를 재사용하기 위해서ㅑ는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해하야 한다. 자식 클래스의 작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다는 것을 의미한다.

따라서 상속을 결합도를 높인다. 그리고 상속이 초래하는 부모 클래스의 자식 클래스 사이의 강한 결합이 코드를 수정하기 어렵게 만든다.

강하게 결합된 상속의 문제점

만약 A 클래스와 B 클래스에 모두 세율을 적용해야 한다는 요구사항이 추가되었다. B 클래스 내부에서 호출하는 super.calculateFee는 세율이 적용된 요금을 반환할 것이다. 따라서 B 클래스에서 할인 요금에 세율을 곱해서 result에 빼주어야 할 것이다. 그래야 B에서 계산한 세율이 맞기 때문이다.

우리는 중복을 제거하기 위해 상속을 사용했다. 하지만 세율 적용이라는 새로운 요구 사항이 들어 왔을 때, A와 B 클래스에 모두 비슷한 코드를 추가해야했다. 즉, 중복을 제거하기 위해 상속을 사용했지만 새로운 중복 코드를 만들어야 하는 문제다 생겼다.

이는 상속을 사용하는 A와 B 클래스가 구현에 너무 강하게 결합되어 있기 때문에 발생하는 문제다. 따라서 우리는 상속을 사용할 때 다음과 같은 경고에 귀 귀울여야 한다.

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

취약한 기반 클래스 문제

지금 까지 살펴본 것처럼 상속은 자식 클래스와 부모 클래스의 결합도를 높인다. 이 강한 결합도로 인해 자식 클래스는 부모 클래스의 불필요한 세부사항에 엮이게 된다.

이처럼 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제라고 부른다. 이 문제는 상속을 사용한다면 피할 수 없는 객체지향 프로그래밍의 근본적인 취약성이다.

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

취약한 기반 클래스 문제는 캡슐화를 악화 시키고 결합도를 높인다. 상속은 자식 클래스가 부모 클래스의 구현 세부사항에 의존하도록 만들기 때문에 캡슐화를 악화시킨다.

객체는 캡슐화를 통해 변경될지도 모르는 불안정한 요소를 퍼블릭 인터페이스 뒤로 숨겨 파급효과를 걱정하지 않고도 자유롭게 내부를 변경할 수 있다. 하지만 상속을 이용하면 부모 클래스의 구현을 변경하더라도 자식 클래스가 영향을 받기 쉬워진다.

상속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향이 가진 강력함을 반감시킨다. 예제를 통해 상속이 가지는 문제점을 구체적으로 알아보자.

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

자바 초기 버전의 상속을 잘못 사용한 대표적인 사례는 PropertiesStack이다. Stack의 예를 살펴보겠다. StackVector를 상속한 클래스다. StackLIFO구조로 요소의 추가와 삭제가 데이터 끝 부분에서만 일어나야 한다. 하지만 Vector를 상속했기 때문에 Vector의 임의의 위치에서 요소를 추가할 수 있는 인터페이스를 상속해 Stack의 규칙을 쉽게 깨고 사용자에게 혼란을 준다.

문제는 자식 클래스에 불필요한 퍼블릭 인터페이스의 상속이다. 자식 클래스의 퍼블릭 인터페이스에 대한 고려 없이 단순히 코드 재사용을 위해 상속을 이용하는 것이 얼마나 위험한지를 잘 보여준다. 객체지향의 핵심은 객체들의 협력이다. 단순히 코드를 재사용하기 위해 불필요한 오퍼레이션이 인터페이스에 스며들도록 방치해서는 안된다.

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

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

HashSet의 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 HashSet의 자식 클래스인 InstrumetedHashSet을 구현해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
    }
}

위의 구현에는 아무 문제가 없어 보인다. 하지만 c.size()가 3인 상태에서 addAll메서드를 실행하면 addCount는 6이된다.

그 이유는 super.addAll메서드가 내부적으로 add메서드를 호출하기 때문이다. 우리는 이 사실을 알고 addAll에 대한 오버라이드를 삭제할 수 있다. 하지만 이것도 문제가 된다. 나중에 HashSetaddAll메서드가 내부적으로 add를 호출하지 않게 되면 카운트가 누락될 것이기 때문이다.

두 번째 해결 방법으로 addAll메서드에서 내부적으로 루프를 돌며 add메서드를 호출하는 것이다. 하지만 이 방법은 오버라이딩된 HashSetaddAll의 메서드 구현과 중복이 일어난다. 미래에 발생할지 모르는 위험을 방지하기 위해 코드를 중복시킨 것이다.

클래스 상속을 이용할 것이라면 위와 같은 문제를 방지하기 위해 상속을 위해 클래스를 설계하고 내부 부현을 문서화 해야한다. 객체지향의 핵심은 캡슐화 이지만 상속을 통한 코드 재사용을 위한 희생이다.

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

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

자식 클래스가 부모 크래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않았음에도 부모 클래스를 수정할 때 자식 클래스를 함께 수정해야 할 수도 있다.

예를들어, 음악 목록을 추가하는 부모 클래스에 음악을 삭제하는 기능을 추가한 자식 클래스를 구현했다. 그런데 부모 클래스에 가수별 노래의 제목을 함꼐 관리해야 한다고 하자. 그러면 새로 추가된 요구사항에 의해 자식 클래스에서 노래가 삭제될 때, 가수별 노래를 함께 삭제해주어야 한다.

상속은 기본적으로 자식 클래스가 부모 클래스의 내부에 대해 속속들이 알도록 강요한다. 따라서 코드 재사용을 위한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께 수정해야 하는 상황 역시 빈번하게 발생할 수 밖에 없는 것이다.

슈퍼클래스의 구현은 릴리스를 거치면서 변경될 수 있고, 그게 따라 서브클래스의 코드를 변경하지 않더라도 깨질 수 있다. 결국, 슈퍼클래스의 작성자가 확장될 목적으로 특별히 그 클래스를 설계하지 않았다면 서브클래스는 슈퍼클래스와 보조를 맞춰 진화해야 한다.

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

취약한 기반 클래스 문제 해결

상속으로 인해 발생하는 취약한 기반 클래스 문ㄴ제의 다양한 예를 살펴봤다. 이제 상속으로 인한 피해를 최소화할 수 있는 방법을 찾아보자. 취약한 기반 클래스 문제를 완전히 없앨 수는 없지만 어느 정도까지 위험을 완화시키는 것은 가능하다. 문제 해결의 열쇠는 바로 추상화다.

추상화에 의존하자

부모 클래스와 자식 클래스는 강하게 결합되어 있기 때문에 부모 클래스가 변경될 경우 자식 클래스가 함께 변경될 가능성이 높다. 이 문제를 해결하는 가장 일반적인 방법은 자식 클래스가 부모 클래스의 구현이 아닌 추상화에 의존하도록 만드는 것이다. 즉, 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정하는 것이다.

코드 중복을 제거하기 위한 상속을 도입할 때 따르는 두 가지 원칙을 알아보자.(저자가 선정한)

  • 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라.
  • 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라.

가장 먼저 할 일은 중복 코드 안에서 차이점을 별도의 메서드로 추출하는 것이다. 메서드에서 다른 부분을 별도의 메서드로 추출하자. 여기에 적당한 이름을 붙인다. 이것이 차이를 메서드로 추출하는 방법이다.

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

여기서 부모 클래스와 자식 클래스의 이름을 고려해 볼 필요가 있다. 부모 클래스의 이름은 좀 더 일반적이어야 하고 자식 클래스의 이름은 좀 더 구체적인 것으로 고려할 수 있다. 기존 자식 클래스들의 이름이 Phone, NightPhone이라면 부모 클래스의 이름을 Phone으로 변경하고, 자식 클래스의 이름을 RegularPhone, NightPhone으로 변경하는 것이 추상화 정도를 나타내는데 더 적절할 것이다.

이제 두 클래스의 공통 부분을 부모 클래스로 이동시키자. 공통 코드를 옮길 때 인스턴스 변수보다 메서드를 먼저 이동 시키면 IDE의 도움과 컴파일 에러를 통해 인스턴스 변수를 이동시키면 불필요한 부분은 자식 클래스에 둔 채 부모 클래스에 꼭 필요한 코드만 이동시킬 수 있다.

이제 추출한 메서드의 시그니처만 부모 클래스로 옮긴다. 추상 메서드로 선언하고 자식 클래스에서 오버라이딩 할 수 있도록 protected로 선언한다.

자식 클래스의 공통점을 부모 클래스로 옮김으로써 상속 계층을 구성할 수 있다. 이제 설계는 추상화에 의존하게 된다.

추상화가 핵심이다

공통 코드를 상위로 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가지게 된다. 세 클래스는 하나의 변경 이유만을 가져 응집도가 높다.

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

의존성 역전 원칙도 준수하는데, 하위 수준의 정책이 상위 수준의 정책에 의존하기 때문이다. 상위 수준의 정책, 부모는 자식의 존재를 모른다.

새로운 정책을 추가하기도 쉽다. 다른 클래스를 수정할 필요 없이 새로운 클래스만 추가하면 다른 컨텍스트로 확장이 가능하다. 확장에는 열려있고 수정에는 닫혀있기 때문에 개방-폐쇄 원칙도 준수한다.

상속 계층이 코드를 진화시키는데 걸림돌이 된다면 추상화를 찾아내고 상속 계층 안의 클래스들이 그 추상화에 의존하도록 코드를 리팩터링하라. 차이점을 메서드로 추출하고 공통적인 부분은 부모 클래스로 이동하라.

결합을 피할 수는 없다

위의 방법대로 차이를 메서드로 호출하고 자식 클래스의 메서드를 상위로 올려 모두 추상화에 의존했다면 부모의 변경이 자식에게 미치는 영향에서 자유로울 수 있다. 하지만 부모 클래스의 인스턴스 변수의 목록이 달라진다면 이야기는 달라진다.

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

클래스 사이의 상속은 자식 클래스가 ‘부모가 구현한 행동’뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만들기 때문이다. chap08에서 구체 클래스의 new는 해롭고 생성 로직이 변경되었을 때 영향받는 부분을 최소화해야 함을 배웠다. 하지만 초기화 로직 변경이 부모와 자식의 강한 결합으로 인해 중복 코드를 낳는 것보다는 낫다.

따라서 객체 생성 로직에 대한 변경을 막기 보다는 핵심 로직의 중복을 막아라. 그리고 공통적인 핵심 로직은 최대한 추상화해야한다.

결국, 상속으로 인한 결합을 피할 수 있는 방법은 없다. 우리가 원하는 것은 행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 계층 전체에 걸쳐 부작용이 퍼지지 않게 막는 것이다.

차이에 의한 프로그래밍

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

이처럼 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법을 차이에 의한 프로그래밍이라고 한다.

차이에 의한 프로그래밍의 궁극적인 목표는 중복 코드를 제거하고 코드를 재사용하는 것이다. 사실 중복 코드 제거와 코드 재사용은 같은 의미를 내포하고 있다. 코드를 재사용하기 위해서는 중복 코드를 제거해 하나의 모듈로 모아야 하기 때문이다.

코드를 재사용함으로써 코드를 작성하는 노력과 테스트를 줄이고 버그가 존재하지 않는 코드를 짤 수 있다. 객체지향에서 중복 코드를 제거하고 코드를 재사용할 수 있는 가장 유명한 방법은 상속이지만 맹목적으로 사용하다가는 피해 역시 크다는 사실을 뼈져리게 느껴야 한다. 상속의 오용과 남용은 애플리케이션을 이해기도 확장하기도 어렵게 만든다. 정말로 필요한 경우에만 상속을 사용하라.

태그:

카테고리:

업데이트:

댓글남기기