[오브젝트] 11장 합성과 유연한 설계

ppparkta·2025년 6월 7일
0

오브젝트

목록 보기
11/14

합성과 유연한 설계

  • 상속
    • is-a 관계
    • 부모 구현에 의존한다
    • 정적인 관계, 클래스에 의존함
  • 합성
    • has-a 관계
    • 퍼블릭 인터페이스에 의존한다
    • 동적인 관계, 객체에 의존함

→ 변경에 유연한 코드가 좋다는 관점 하에 객체 합성이 클래스 상속보다 좋은 방법이다.

상속과 합성은 코드를 재사용한다는 점에서 같으나 재사용의 대상이 다르다.

상속을 합성으로 변경하면 구현에 대한 의존인터페이스에 대한 의존으로 변경 가능하다.

→ 즉, 클래스 간의 높은 결합도를 객체 간의 낮은 결합도로 대체 가능하다.

💡 상속은 부모의 내부구조를 알아야 하므로 화이트박스 재사용에 해당하며, 합성은 인터페이스만 알면 되므로 블랙박스 재사용에 해당함.

상속을 합성으로 변경하기

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

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

10장에서 등장한 Properties, Stack의 불필요한 인터페이스 상속 문제를 해결하기 위해서

→ 상속 관계를 합성 관계로 변경한다.

클라이언트는 Hashtable 오퍼레이션을 직접 사용하지 않게 된다.

Vector, Stack도 마찬가지이다.

즉, 합성을 통해 불필요한 오퍼레이션이 퍼블릭 오퍼레이션에 스며드는 것을 방지한다.

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

Set, InstrumentedHashSet의 메서드 오버라이딩 오작용 문제를 해결하기 위해서…

HashSet에 대한 구현 결합도는 제거하면서도 퍼블릭 인터페이스는 그대로 상속받을 수 있는 방법은?

→ 상위 인터페이스를 구현하여 내부에선 합성으로 노출시킬 행위를 정의한다.

  • 포워딩
    • Set의 오퍼레이션을 오버라이딩한 인스턴스 메서드에서 내부의 HashSet 인스턴스에게 동일한 메서드 호출을 그대로 전달하는 것
  • 포워딩 메서드
    • 동일한 메서드를 호출하기 위해서 추가된 메서드 (별도의 기능 추가 x)

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

10장의 PersonalPlaylist 동시 수정 문제를 합성으로 대체해도 동시 수정 문제를 곧바로 해결할 수 없다.

그러나, 상속보다는 합성이 좋다. 그 이유는 파급효과를 캡슐화할 수 있기 때문이다.

  • 몽키패치
    • 현재 실행중인 환경에만 영향을 미치도록 지역적으로 코드를 수정하거나 확장한다
    • 자바는 언어 차원에서 몽키패치를 지원하지 않아서 바이트코드나 AOP를 주로 사용한다.

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

상속으로 인해 결합도가 높아지면 코드 수정에 필요한 작업량이 과도하게 늘어나는 경우가 있다.

예를 들어 작은 기능을 조합하여 더 큰 기능을 수행할 시

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

→ 합성 사용 시 상속으로 인한 클래스 증가 및 중복 코드 문제를 간단히 해결할 수 있다.

기본 정책과 부가 정책 조합하기

부가 정책이라는 요구사항이 추가됐다.

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

상속으로 기본 정책 구현하기

상속을 이용해서 기본 정책과 부가 정책을 구현한다.

기본 정책은 Phone 추상 클래스를 루트로 삼는 기존의 상속 계층을 그대로 이용할 것이다.

기본 정책에 세금 정책 조합하기

결합도를 낮추기 위해서 자식 클래스가 부모 클래스의 메서드를 호출하지 않도록 부모 클래스에 추상 메서드를 제공한다.

  • 추상 메서드
    • 자식 클래스에서 오버라이딩할 의도로 추가한 메서드
  • 훅 메서드
    • 자식 클래스에서 오버라이딩할 의도로 추가했으나 편의를 위해 기본 구현을 제공하는 메서드

그러나 추상 메서드를 제공하더라도 단일 상속으로 인한 코드 중복을 막기는 어렵다.

아래와 같은 코드에서 TaxableRegularPhoneTaxableNightlyDiscountPhone은 부모는 다르지만 내부 구현은 거의 같다.

기본 정책에 기본 요금 할인정책 조합하기

기본 요금 할인정책을 추가한 뒤에도 RateDiscountableRegularPhoneRateDiscountableNightlyDiscountPhone은 마찬가지로 상속받는 부모의 이름만 다를 뿐 코드 자체는 거의 같다.

중복 코드의 덫

부가 정책은 자유롭게 조합할 수 있어야 하며 적용 순서 역시 임의로 결정할 수 있어야 한다.

상속을 이용한 해결 방법은 모든 가능한 조합 별로 자식 클래스를 하나씩 추가하는 것이다.

이는 복잡성보다도 큰 문제가 있다. 새로운 정책을 추가하기 위해서 불필요하게 많은 수의 클래스를 상속 계층 안에 추가해야 하기 때문이다.

💡 클래스 폭발 문제 or 조합의 폭발 문제
상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우
-> 이 문제를 해결할 수 있는 최선의 방법은 상속을 포기하는 것이다.

합성 관계로 변경하기

상속 관계는 컴파일타임에 결정되고 고정되므로 코드 실행 중 변경할 수 없다. 따라서 여러 기능을 조합해야 하는 설계에 상속을 이용하면 모든 조합 가능한 경우 별로 클래스를 추가한다 (클래스 폭발 문제)

→ 합성을 사용하면 컴파일타임 관계를 런타임 관계로 변경하여 문제를 해결할 수 있다.

합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며 정책들의 관계를 유연하게 변경할 수 있다.

상속이 조합 결과를 개별 클래스 안으로 밀어 넣는 방법이라면 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법을 사용하는 것이다.

컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성할 수 있다는 것이 합성이 제공하는 가장 커다란 장점이다.

기본 정책 합성하기

가장 먼저, 각 정책을 별도의 클래스로 구현한다. 분리된 정책들을 연결할 수 있도록 합성 관계를 이용해서 구조를 개선한다.

아래의 RatePolicy는 기본 정책과 부가 정책을 포괄하는 인터페이스이다.

public interface RatePolicy {
		Money calculateFee(Phone phone);
}

기본 정책을 구성하는 일반 요금제, 심야 할인 요금제는 개별 요금을 계산하는 방식을 제외한 전체 처리 로직이 거의 동일하다. 이를 담을 추상클래스를 추가한다.

public abstract class BasicRatePolicy implements RatePolicy {
		
		@Overried
		public Money calculateFee(Phone phone) {
				Money result = Money.ZERO;
				
				for(Call call : phone.getCalls()) {
						result.plus(calculateCallFee(call));
				}	
				return result;
		}
		
		protected abstract Money calculateCallFee(Call call);
}

(이후 코드 생략)

Phone의 경우처럼 다양한 종류의 객체와 협력하기 위해 합성 관계를 사용하는 경우 합성하는 객체의 타입을 인터페이스나 추상 클래스로 선언하고 의존성 주입을 사용해 런타임에 필요한 객체를 설정할 수 있도록 구현하는 것이 일반적이다.

public class Phone {
		
		private **RatePolicy ratePolicy;** // 이게 합성
		private List<Call> calls = new ArrayList<>();
		
		...
}

다음과 같이 사용하고 싶은 요금제 규칙에 따라 인스턴스를 합성하면 된다

new Phone(new RegularPolicy(Money.wons(10), Duration.ofSeconds(10));
new Phone(new NightlyDiscoountPolicy(Money.wons(10), Money.wons(10), Duration.ofSeconds(10));

부가 정책 적용하기

부가 정책은 다음 두가지 제약을 기반으로 구현해야 한다.

  • 부가 정책은 기본 정책 or 부가 정책의 인스턴스를 참조할 수 있어야 한다.
  • phone 입장에서 누구에게 메시지를 보내는지 몰라야 한다. 기본 정책과 부가 정책은 협력 안에서 동일한 역할을 수행한다.
public abstact class AdditionalRatePolicy implements RatePolicy {
		
		private RatePolicy next;
		
		public AdditionalRatePolicy(RatePolicy next) {
				this.next = next;
		}
		
		@Override
		public Money calculateFee(Phone phone) {
				Money fee = next.calculateFee(phone);
				return afterCalculated(fee);
		}
		
		abstract protected Money afterCalculated(Money fee);
}

기본 정책과 부가 정책 합성하기

앞서 기본 정책과 부가 정책 클래스의 구현이 완료되었으니 구현된 정책들을 합성해본다.

원하는 정책의 인스턴스를 생성한 뒤에 의존성 주입을 통해서 다른 정책의 인스턴스에 전달하기만 하면 된다.

합성 방식의 구현에서 순서를 바꾸거나 동일한 정책을 다른 기본 정책에 적용하는 것은 굉장히 간단하다.

Phone phone = new Phone(
					new TaxablePolicy(0.05, 
						new RateDiscoountablePolicy(Money.wons(1000),
							new RegularPolicy(...)));

합성의 진가는 새로운 클래스를 추가하거나 수정하는 시점이 되면 비로소 알 수 있다.

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

상속은 코드 재사용을 위한 우아한 해결책은 아니다.

상속은 부모 클래스의 세부적인 구현에 자식 클래스를 강하게 결합시키기 때문에 코드 진화를 방해한다.

합성은 코드를 재사용하면서도 건전한 결합도를 유지할 수 있다. 상속이 구현을 재사용하는 데 비해 합성은 객체의 인터페이스를 재사용한다.

그렇다면 상속은 사용하면 안 되는 것인가? 상속을 사용해야 하는 경우는 언제인가?

→ 상속을 구현 상속과 인터페이스 상속 두 가지로 나눠야 한다. (이번 장은 구현 상속에만 해당한다)

profile
겉촉속촉

0개의 댓글