[오브젝트] chap11을 읽고

상속과 합성은 객체지향 프로그래밍에서 가장 널리 사용되는 코드 재사용 기법이다. 상속과 합성을 비교해보자.

  • 상속이 부모 클래스와 자식 클래스를 연결해 코드를 재사용하는데 비해, 합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용한다.
  • 상속에서 부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결되지만, 합성에서 두 객체 사이의 의존성은 런타임에 해결된다.
  • 상속은 is-a 관계라고 부르고 상속은 has-a 관계라고 부른다.

상속은 코드를 재사용할 수 있는 쉬운 방법이지만 우아한 방법이라고는 할 수 없다. 자식 클래스가 부모 클래스의 내부 구현에 강하게 엮기기 때문에 결합도가 상승해 유연하지 못한 설계로 이어진다.

합성은 구현에 의존하지 않는 점에서 상속과 다르다. 합성은 내부에 포함되는 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다. 따라서 합성을 이용하면 포함된 객체의 내부 구현이 변경되더라도 영향을 최소화 할 수 있기 때문에 변경에 더 안정적인 코드를 얻을 수 있게 된다.

또한 컴파일타임에 관계가 확정되는 상속과는 다르게 합성은 실행 시점에 관계를 동적으로 변경할 수 있다. 따라서 상속 대신 합성을 사용하면 변경하기 쉽고 유연한 설계를 얻을 수 있다. 즉 상속의 클래스 사이의 높은 결합도를 합성을 사용하면 객체 사이의 낮은 결합도로 대체할 수 있다는 것이다.

상속을 합성으로 변경하기

chap10에서 상속을 남용했을 때 직면할 수 있는 세 가지 문제점을 살펴보았다.

  • 불필요한 인터페이스 상속 문제
  • 메서드 오버라이딩의 오작용 문제
  • 부모 클래스와 자식 클래스의 동시 수정 문제

상속으로 인해 생긴 위의 문제점들을 합성으로 변경해 문제를 해결해보자.

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

chap10에서 자바 초기 버전의 Stack이 어떤 문제를 가지고 있는지 살펴 보았다. Stack의 부모 클래스인 Vector의 인터페이스를 그대로 물려 받으면서 Stack에서 일어나면 안되는 인터페이스까지 상속해왔다.

하지만 상속 대신 합성을 사용하면 문제는 해결된다. 상속 관계를 제거하고 Stack 클래스에 인스턴스 변수로 Vector를 가지게 한다.

그리고 Stack에만 필요한 메서드만을 선언해 내부에서 Vector의 메서드를 호출한다. 이제 Stack에서 정의한 오퍼레이션만 사용할 수 있다. Vector의 불필요한 오퍼레이션들이 포함되지 않게 때문에 클라이언트는 더 이상 임의의 위치에 요소를 추가하거나 삭제할 수 없다.

합성 관계로 변경함으로써 클라이언트가 Stack을 잘못 사용할 수 있는 가능성을 제거할 수 있다.

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

chap10에서 HashSet의 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 InstrumentedHashSet을 구현했다.

HashSet의 내부 동작 구조를 정확히 모른 상태에서 메서드를 오버라이딩하여 super메서드를 내부에서 호출한다면 부작용이 생겼다.

이를 합성 관계로 변경해 보자. InstrumentedHashSet은 내부에 인스턴스 변수로 HashSet을 가지고 있으며 addaddAll메서드에서 입력 요소의 갯수에 따라 내부의 카운트를 증가시킨다.

이렇게 하면 HashSetaddaddAll메서드의 내부 구현과 관계 없이 인터페이스에만 의존하여 잘 작동하는 코드를 만들 수 있다.

하지만 아직 부족한 것이 있다. InstrumentedHashSetHashSet이 제공하는 모든 퍼블릭 인터페이스를 제공해야 한다. 다행스럽게도 자바의 인터페이스를 사용하면 이 문제를 해결할 수 있다. HashSetSet 인터페이스의 구현체이므로 InstrumentedHashSetSet인터페이스를 실체화하면서 내부에 HashSet의 인스턴스를 합성하면 HashSet에 대한 구현 결합도는 제거하면서도 퍼블릭 인터페이스는 그대로 유지할 수 있다.

카운팅 목적이 있는 메서드 외에 Set의 오퍼레이션을 오버라이딩한 인스턴스 메서드의 내부에는 HashSet인스턴스의 메서드를 호출하는 코드 밖에 존재하지 않는다. 이를 포워딩 메서드라고 부른다.

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

chap10에서 음악 목록 추가 기능이 있는 부모 클래스와 음악 목록 삭제 기능을 더한 자식클래스를 예를 들어 설명했다. 만약 가수 별 노래 제목을 함께 관리해야 한다는 기능이 추가 되었을 때, 추가와 삭제에 대해 모두 코드 수정을 해야하므로 부모와 자식을 동시에 수정해야 하는 문제가 생겼다.

합성을 적용한다면 어떨까? 음악 추가 클래스를 인스턴스 변수로 두는 음악 삭제 클래스를 상상해 보자. 아쉽게도 동시에 두 클래스를 수정해야 하는 문제는 해결되지 않는다.

그렇다고 하더라도 여전히 상속보다는 합성을 사용하는게 더 좋다. 향후에 음악 추가 클래스의 내부 구현이 변경되어도 파급효과를 최대한 음악 삭제 클래스 안에 가둘 수 있기 때문이다.

대부분의 경우 구현에 대한 결합보다는 인터페이스에 대한 결합이 더 좋다는 사실을 기억하라.

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

chap10에서 설명했듯이 차이를 메서드로 호출하고 자식 클래스의 메서드를 상위로 올려 모두 추상화에 의존하는 상속을 했더라도 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경우가 있다.

가장 일반적인 상황은 작은 기능들을 조합해서 더 큰 기능을 수행하는 객체를 만들어야 하는 경우다. 일반적으로 다음과 같은 두 가지 문제점이 발생한다.

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

핸드폰 과금 시스템을 생각해보자. 핸드폰 요금제는 ‘기본 정책’과 ‘부가 정책’을 조합해서 구성된다고 가정해보자.

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

  • 기본 정책의 계산 결과에 적용된다.
  • 선택적으로 적용될 수 있다.
  • 조합 가능하다.
  • 부가 정책은 임의의 순서로 적용 가능하다.

부가 정책이 위와 같은 특성을 가지고 있을 때, 이 요구사항을 구현하는데 가장 큰 장벽은 기본 정책과 부가 정책의 조합 가능한 수가 매우 많다는 것이다.

위와 같은 조합 가능한 경우의 수를 모두 상속을 통해 구현한다고 해보자.

위와 같은 상속 계층이 만들어 질 것이다. 상속을 이용한 해결 방법은 모든 가능한 조합별로 자식 클래스를 하나씩 추가하는 것이다. 현재의 설계에 새로운 정책을 추가하기 위해서는 불필요하게 많은 수의 클래스를 상속 계층 안에 추가해야 한다. 그러면서 같은 부가 정책을 가지는 클래스들 사이의 중복코드가 늘어나게 된다.

만약 새로운 기본 정책을 추가한다면 그에 따라 조합 가능한 부가 정책의 수만큼 새로운 클래스를 추가해야 한다.

이처럼 상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 가리켜 클래스 폭발 문제 또는 조합의 폭발 문제라고 부른다.

클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생한다. 컴파일타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 다양한 조합이 필요한 상황에서 유일한 해결 방법은 조합의 수만큼 새로운 클래스를 추가하는 것뿐이다.

이 문제를 해결할 수 있는 최선의 방법은 상속을 포기하는 것이다.

합성 관계로 변경하기

합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며 실행 시점에 정책들의 관계를 유연하게 변경할 수 있게 된다. 상속이 조합의 결과를 개별 클래스 안으로 밀어 넣는 방법이라면 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법을 사용하는 것이라고 할 수있다. 컴파일 의존성에 속박되지 않고다양한 방식의 런타임 의존성을 구성할 수 있다는 것이 합성이 제공하는 가장 커다란 장점인 것이다.

Phone이 인스턴스 변수로 RatePolicy를 가지도록 하고, RatePolicy를 구현하는 정책들을 만들면 된다.

일반 요금제에 기본 요금 할인 정책을 조합한 결과에 세금 정책을 조합하고 싶다면 다음과같이 Phone을 생성하면 된다.

1
2
3
4
5
Phone phone = new Phone(
                    new TexablePolicy(0.0.5,
                        new RateDiscountPolicy(Money.wons(1000),
                            new RegularPolicy(...)));
)

상속을 기반으로 한 설게에서는 새로운 부가 정책을 추가하기 위해서는 상속 계층에 불필요할 정도로 많은 클래스를 추가해야 했다. 하지만 변경된 설계에서 새로운 정책이 추가 된다면 해당 클래스 ‘하나’만 추가한 후 원하는 방식으로 조합하면 된다.

중요한 것은 요구사항이 변경될 때 오직 하나의 클래스만 수정해도 된다는 것이다. 상속 계층에 여기저기 중복되있던 코드를 수정하려면 여러 클래스를 수정해야 했다. 하지만 합성의 경우에는 클래스 하나를 변경시키는 것으로 정책을 변경시킬 수 있다. 즉, 변경 후의 설게는 단일 책임 원칙을 순주하고 있는 것이다.

객체 합성이 클래스 상속보다 더 좋은 방법이다

객체지향에서 코드를 재사용하기 위해 가장 널리 사용되는 방법은 상속이다. 하지만 상속은 코드 재사용을 위한 우아한 해결책은 아니다. 상속은 부모 클래스의 세부적인 구현에 자식 클래스를 강하게 결합시키기 때문에 코드의 진화를 방해한다.

코드를 재사용하면서도 건잔한 결합도를 유지할 수 있는 더 좋은 방법은 합성을 이용하는 것이다. 상속은 구현을 재사용하는 데 비해 합성은 인터페이스를 재사용 하기 때문이다.

그렇다면 상속은 사용해서는 안되는 것인가? 상속을 사용해야 하는 경우는 언제인가? 우리는 인터페이스 상속은 이용하지만 구현 상속은 피해야 한다.

태그:

카테고리:

업데이트:

댓글남기기