BookReview [오브젝트: 합성과 유연한 설계]
11장 합성과 유연한 설계
상속과 합성은 객체지향 프로그래밍에서 가장 널리 사용되는 코드 재사용 기법이다. 상속이 부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용하는 데 비해 합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용한다.
상속에서 부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결되지만 합성에서 두 객체 사이의 의존성은 런타임에 해결된다. 상속은 흔히 Is-a
관계라고 부르며 합성은 has-a
관계라고 부른다. 상속과 합성은 코드 재사용이라는 동일한 목적을 가진다는 점을 제외하면 구현 방법과 다루는 방법까지 다양한 차이를 보인다.
상속과 합성이 다른 점은 합성은 구현에 의존하지 않는다는 점에서 다르다. 합성은 내부에 포함되는 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다. 따라서 합성을 이용하면 포함된 객체의 내부 구현이 변경되더라도 영향을 최소화할 수 있기 때문에 더 안정적인 코드를 얻을 수 있다.
객체지향의 사실과 오해에 나온 메시지와 메서드의 차이와 같다고 생각한다. 메서드에 의존한 정적인 관계는 상속구조에 가깝고 메시지에 의존하는 동적인 관계는 합성에 가깝다.
상속을 합성으로 변경하기
10장에서 본 상속의 문제점은 다음과 같다.
- 불필요한 인터페이스 상속 문제
- 메서드 오버라이딩의 오작용 문제
- 부모 클래스와 자식 클래스의 동시 수정 문제
이는 합성을 사용하면 모두 해결이 가능하다. 상속을 합성으로 바꾸는 방법은 자식 클래스에 선언된 상속 관계를 제거하고 부모 클래스의 인스턴스를 자식 클래스의 인스턴스 변수로 선언하는 것이다.
불필요한 상속 문제
자바 Stack
의 문제점을 Vector
을 상속받는 것이 아닌 자신의 인스턴스 변수로 들고 있게 된다면 문제는 간단하게 해결된다. 메서드 또한 오용될 필요 없고, 재사용성도 높다.
메서드 오버라이딩 오작용 문제
이 문제도 또한 합성으로 해결 가능하지만, 불필요한 오퍼레이션이 퍼블릭 인터페이스에 쉽게 스며들 수 있기에 인터페이스 기능을 활용하여 한 단계 더 추상화하여 사용하는 것이 좋다.
그대로 오버라이딩한 인터페이스의 메서드들을 합성된 인스턴스 변수에게 위임하는 방법을 사용하면 된다. 이를 포워딩(forwarding)이라고 부른다.
부모 클래스와 자식 클래스의 동시 수정 문제
플레이리스트의 예제에서 합성으로 변경하더라도 가수별 노래 목록의 추가는 플레이 리스트를 함께 수정해야 하는 문제가 발생한다.
그렇다고 하더라도 합성을 사용하는 것이 좋은데 이는 내부 구현을 변경하더라도 파급효과를 최대한 캡슐화할 수 있기 때문이다.
PlayList자체를 추상 데이터로 만들면 더 유연해지지 않을까?
상속으로 인한 조합의 폭발적인 증가
상속으로 인해 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경향이 있다. 가장 일반적으로 작은 기능들을 조합하여 더 큰 기능을 수행하는 경우에 그렇다.
- 하나의 기능을 추가하거나 수정하기 위해서 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
- 단일 속성만을 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다.
기본 정책과 부가 정책 조합하기
기존에 활용한 예제인 기본 정책(일반 요금제, 심야 할인 요금제)에 대해서 부가 정책(세금 정책, 할인 정책)을 추가하는 예제이다.
부가정책은 다음과 같은 특성을 지닌다.
- 기본 정책의 계산 결과에 적용된다
- 선택적으로 적용할 수 있다
- 조합 가능하다
- 부가 정책은 임의의 순서로 적용 가능하다
기본 정책에 세금 정책 조합하기
기존 상속 구조에서 세금 정책을 추가한다면 가장 간단한 방법은 RegularPhone
클래스를 상속받은 TaxableRegularPhone
클래스를 만드는 것이다. 해당 코드에서 부모를 재사용하기 위해서 Super를 호출하면 쉽게 결과를 얻을 수 있지만 이는 결국 10장에서 다룬 문제점으로 이어지게 된다.
그렇다면 또 다시 RegularPhone
에 대한 추상화 수준을 올려야 하는 문제에 직면하게 되는데, 이를 회피하고 상속 구조로 가게 된다면 모든 클래스를 수정해야 한다.
예제에서는 훅 메서드를 사용하여 전처리 후처리를 수행하도록 하여 상속을 사용하지 않고 구현하였다.
기본 정책에 기본 요금 할인 정책 조합하기
이번엔 두 번째 부가 정책인 기본 요금 할인 정책을 Phone
계층에 추가해본다. 마찬가지로 상속 구조이기 때문에 다시 해당 부모 클래스와 강한 의존성을 바탕으로 클래스를 만든다.
중복 코드의 덫에 걸리다
상속을 이용한 해결 방법은 모든 가능한 조합별로 자식 클래스를 하나씩 추가하는 것이다.
그림과 같이 새로운 정책이 추가된다면, 의존성을 줄이기 위해 위와 같은 아주 복잡한 상속 구조를 가지게 된다. 이처럼 상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 가리켜 클래스 폭발(class explosion) 또는 조합의 폭발(combinatorial explosion)이라고 부른다.
컴파일타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 자식 클래스와 부모 클래스의 다양한 조합이 필요한 상황에서 유일한 해결책은 조합의 수만큼 클래스를 추가하는 것뿐이다. (정적인 구조의 한계)
합성 관계로 변경하기
상속 관계는 컴파일타임에 결정되고 고정되기 때문에 코드를 실행하는 도중에는 변경할 수 없다. 따라서 여러 기능을 조합해야 하는 설계에 상속을 이용하면 모든 조합 가능한 경우별로 클래스를 추가해야 한다.
반면, 합성은 컴파일 타임 관계를 런타임으로 변경하면서 이 문제를 해결한다. 합성을 사용하면 구현이 아닌 퍼블릭 인터페이스에만 의존하기 때문에 런타임에 객체의 관계를 변경할 수 있다.
물론 컴파일타임 의존성과 런타임 의존성의 거리가 멀면 멀수록 설계의 복잡도가 상승하기 때문에 코드를 이해하기 어려워지는 것은 사실이다. 하지만 설계는 변경과 유지보수를 위해 존재한다는 사실을 기억하자. 설계는 트레이드 오프의 산물이다.
대부분의 경우에 단순한 설계가 정답이지만 변경에 고통과 복잡성이 느껴진다면 유연성의 손을 들어주는 것이 좋다.
기본 정책 합성하기
상속구조에서 벗어나기 위해 정책이라는 RatePolicy
라는 인터페이스를 만들어서 CalculateFee
라는 행위를 강제한다. 이를 Phone에서 구현하도록 하고, 각 객체마다 계산하는 자신만의 방식으로 연결한다.
Phone
은 해당 인터페이스를 합성으로 들고 있고 이를 DI를 통해 런타임에 실제 사용할 객체를 받으며 각 객체는 해당 인터페이스를 상속받아 자체적으로 구현한다.
부가 정책 적용하기
이제 시퀀스에 맞게 기본 정책의 계산이 끝난 뒤 부가 정책을 적용해야 한다. 현재 인터페이스의 구조이기 때문에 부가 정책은 다른 부가 정책의 인스턴스를 참조할 수 있어야 한다는 것을 의미한다.
이를 위해 해당 인터페이스를 상속 받은 추상 클래스를 만들어서 내부에 다른 정책을 참조하도록 한다. 이후 후킹 메서드를 통해 전처리 단계를 넘겨준다.
기본 정책과 부가 정책 합성하기
DI과정에서 조금은 복잡하게 느껴질 수 있지만, 이는 모든 단계적인 사항들이 한 단계로써 대체됨을 잘 보여준다. 각각 객체끼리의 상호작용이 보장되니 사용자 입장에선 선택만하여 사용하면 된다.
새로운 정책 추가하기
합성의 가장 큰 이점은 새로운 객체를 협력과정에 포함시킬 때 나타난다. 이는 현재 구현된 코드에 전형 영향을 주지 않는다. 그 이유는 바로 런타임 즉, 동적인 협력 구조에 합류이기 때문이다.
객체 합성이 클래스 상속보다 더 좋은 방법이다
객체지향에서 코드를 재사용하기 위해 가장 널리 사용되는 방법은 상속이지만, 이는 우아한 해결책이 아니다. 상속은 부모 클래스의 세부적인 구현에 자식 클래스를 강하게 결합시키기 때문에 코드의 진화를 방해한다.
코드를 재사용하면서도 건전한 결합도를 유지할 수 있는 방법은 합성을 이용하는 것이다. 상속이 구현을 재사용하는 데 비해 합성은 객체의 인터페이스를 재사용한다.
그렇다면 상속을 사용하는 것은 문제가 될까? 이는 상속을 구현 상속과 인터페이스 상속의 두 가지로 나눠야 한다는 사실을 알아야 한다. 이번 장에서 살펴본 상속에 대한 모든 단점들은 구현 상속에 국한된다는 점 또한 이해해야 한다.
믹스인
앞에서 살펴본 것처럼 상속을 사용하면 다른 클래스를 간편하게 재사용하고 점진적으로 확장할 수 있지만 부모 클래스와 자식 클래스가 강하게 결합되기 때문에 수정과 확장에 취약한 설계를 낳게 된다. 우리가 원하는 것은 코드를 재사용하면서도 납득할 만한 결합도를 유지하는 것이다.
상속과 클래스를 기반으로 하는 재사용 방법을 사용하면 클래스의 확장과 수정을 일관성 있게 표현할 수 있는 추상화의 부족으로 인해 변경하기 어려운 코드를 얻게 된다. 따라서 구체적인 코드를 재사용하면서도 낮은 결합도를 유지할 수 있는 유일한 방법은 재사용에 적합한 추상화를 도입하는 것이다.
믹스인(mixin)은 객체를 생성할 때 코드 일부를 클래스 안에 섞어 넣어 재사용하는 기법을 가리키는 용어다. 합성이 실행 시점에 객체를 조합하는 재사용이라면 믹스인은 컴파일 시점에 필요한 코드 조각을 조합하는 재사용 방법이다.
믹스인이 상속과 비슷하게 느껴질 수 있지만, 상속과는 다르다. 상속의 결과로 부모 클래스의 코드를 재사용할 수 있기는 하지만 상속의 진정한 목적은 자식 클래스를 부모 클래스와 동일한 개념으로 묶어 is-a
관계를 만들기 위한 것이다. 반면 믹스인은 말 그대로 코드를 다른 코드 안에 섞어 넣기 위한 방법이다.
하지만 상속이 클래스와 클래스 사이의 관계를 고정시키는 데 비해 믹스인은 유연하게 관계를 재구성할 수 있다.
언어마다 조금씩 다른 개념으로 사용되어서 이해하기 어려울 수 있지만, 코드를 섞어 넣는다는 기본 개념은 동일하다.
기본 정책 구현하기
기본 정책은 자바로 만든 상속 구조와 동일하다.
트레이트로 부가 정책 구현하기
스칼라라는 언어에서 다른 코드와 조합해서 확장할 수 있는 기능을 트레이트로 구현하였다. 여기서 기본 정책에 조합하려는 코드는 부가 정책을 구현하는 코드들이다. 트레이트로 구현된 기능들을 섞어 넣게 될 대상은 기본 정책에 해당되는 코드이다.
믹스인은 상속과 비슷한 구조를 가지지만 동적이라는 점이 아주 큰 차이점이다. 인터페이스와 같이 제한점을 두고, 상속과 같이 코드의 재사용성을 줄여주고 결국 두 재사용성에 대한 이점을 합친 형태라고 생각된다.
부가 정책 트레이트 믹스인하기
스칼라의 특정 클래스에 믹스인한 클래스와 트레이트를 선현화해서 어떤 메서드를 호출할지 결정한다. (시퀀스적인 디자인이 가해짐)
유니티, C#에선 지원하지 않아서 확장 메서드와 인터페이스를 통해서 흉내낸다.
쌓을 수 있는 변경
믹스인은 상속 계층 안에서 확장한 클래스보다 더 하위에 위치하기 된다. 다시 말해서 믹스인은 대상 클래스의 자식 클래스 처럼 사용될 용도로 만들어지는 것이다. 따라서 믹스인을 추상 서브 클래스라고 부르기도 한다.
느낀점
인터페이스의 합성 구조가 유리한 구조를 제대로 구현해보고 싶은 마음가짐이 생기는 장인 것 같다. 게임에서 다양하게 볼 수 있는 사례들이 있는데 아직은 내가 접해보지 못한 것 같아서 아쉽다.
믹스인은 멘토님이 저번에 말씀하신 기억이 있는데, 실제 활용해보고 싶은데 과연 그런 날이 올까?
논의사항
- 합성 구조에서 조심해야 할 점에 대해서 논의 해보면 좋을 것 같습니다.
제가 생각하는 인터페이스 합성에서 조심해야 할 부분은 인터페이스의 역할이 아닌 추상 클래스에 가까워질 때 생기는 문제점인 것 같습니다. C#언어 특성상 인터페이스 내에 필드 구현을 못하게 되어 있지만, 프로퍼티의 형태는 추가할 수 있어서 이를 통해 추상 클래스와 같이 활용할 수 있다는 점이 취약하게 작용할 것 같습니다.
댓글남기기