Post

합성과 유연한 설게

11. 합성과 유연한 설계

상속이 부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용하는 데 비해 합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용

상속에서 부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결되지만 합성에서 두 객체 사이의 의존성은 런타임에 해결된다.

상속 관계는 is-a관계, 합성 관계는 has-a관계

상속을 이용하면 자식 클래스의 정의에 부모 클래스의 이름을 덧붙이는 것만으로 부모 클래스의 코드를 재사용할 수 있게 된다.

기존 코드를 쉽게 확장 가능

상속을 제대로 활용하기 위해서는 부모 클래스의 내부 구현에 대해 상세하게 알아야 하기 때문에 자식 클래스와 부모 클래스 사이의 결합도가 높아질 수 밖에 없다.

합성은 구현에 의존하지 않는다는 점에서 상속과 다르다

합성은 내부에 포함되는 객체의 구현이 아닌 퍼블릭 인터페이스에 의존

따라서 합성을 이용하면 포함된 객체의 내부 구현이 변경되더라도 영향을 최소화할 수 있기 때문에 변경에 더 안정적인 코드를 얻을 수 있게 된다.

상속 관계는 클래스 사이의 정적인 관계인 데 비해 합성 관계는 객체 사이의 동적인 관계

코드 작성 시점에 결정한 상속 관계는 변경이 불가능하지만 합성 관계는 실행 시점에 동적으로 변경할 수 있음

따라서 상속 대신 합성을 사용하면 변경하기 쉽고 유연한 설계를 얻을 수 있음

코드 재사용을 위해서는 객체 합성이 클래스 상속보다 더 좋은 방법이다.

상속은 부모 클래스 안에 구현된 코드 자체를 재사용하지만 합성은 포함되는 객체의 퍼블릭 인터페이스를 재사용

따라서 상속 대신 합성을 사용하면 구현에 대한 의존성을 인터페이스에 대한 의존성으로 변경 가능

다시 말해서 클래스 사이의 높은 결합도를 객체 사이의 낮은 결합도로 대체 가능

11.1 상속을 합성으로 변경하기

상속을 남용했을 때 발견할 수 있는 3가지 문제점

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

    자식 클래스에게는 부적합한 부모 클래스의 오퍼레이션이 상속되기 때문에 자식 클래스 인스턴스의 상태가 불안정해지는 문제(stack, property)

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

    자식 클래스가 부모 클래스의 메서드를 오버라이딩할 때 자식 클래스가 부모 클래스의 메서드 호출 방법에 영향을 받는 문제

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

    부모 클래스와 자식 클래스 사이의 개념적인 결합으로 인해 부모 클래스를 변경할 때 자식 클래스도 함께 변경해야 하는 문제

자식 클래스에 선언된 상속 관계를 제거하고 부모 클래스의 인스턴스를 자식 클래스의 인스턴스 변수로 선언하여 상속을 합성으로 변환

불필요한 인터페이스 상속 문제: java.util.properties와 java.util.stack

1
2
3
4
5
6
7
8
9
10
11
public class Properties {
  private Hashtable<String, String> properties = new Hashtable<>();
  
  public String setProperty(String key, String value) {
    return properties.put(key, value);
  }
  
  public String getProperty(String key) {
    return properties.get(key);
  }
}

불필요한 Hashtable의 오퍼레이션들이 Properties 클래스의 퍼블릭 인터페이스를 오염시키지 않음

String 타입의 키와 값만 허용하는 규칙을 어길 위험성은 사라짐

Vector도 동일

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

set 인터페이스를 상속하여 해결

1
2
3
4
5
6
public class InstrumentedHashSet implements Set<E> {
  private int addCount = 0;
  private Set<E> set;
  
  ...
}

Set의 오퍼레이션을 오버라이딩한 인스턴스 메서드에서 내부의 HashSet 인스턴스에게 동일한 메서드 호출을 그대로 전달한다 -> 포워딩(forwarding)

동일한 메서드를 호출하기 위해 추가된 메서드 -> 포워딩 메서드(forwarding method)

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

합성으로 변경해도 해결되지 않음 ㅜㅜ

그렇다고 하더라고 향후에 내부 구현으로 인한 파급효과를 캡슐화 가능

몽키패치

현재 실행 중인 환경에만 영향을 미치도록 지역적으로 코드를 수정하거나 확장하는 것을 가리킨다.

자바는 언어 차원에서 몽키패치를 지원하지 않기 떄문에 바이트코드를 직접 변환하거나 API를 이용해 몽키 패치를 구현

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

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

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

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

핸드폰 요금 예시

부가 정책의 특성

  • 기본 정책의 계산 결과에 적용됨
  • 선택적으로 적용 가능
  • 조합 가능
  • 부가 정책은 임의의 순서로 적용 가능

상속을 이용해서 기본 정책 구현하기

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

부모 클래스에 추상 메서드를 추가하면 모든 자식 클래스들이 추상 메서드를 오버라이딩해야 하는 문제 발생

유연성을 유지하면서도 중복 코드를 제거할 수 있는 방법은 Phone에서 afterCalculated 메서드에 대한 기본 구현을 함께 제공하는 것

추상 메서드와 훅 메서드

개방-폐쇄 원칙을 만족하는 설계를 만들 수 있는 한 가지 방법은 부모 클래스에 새로운 추상 메서드를 추가하고 부모 클래스의 다른 메서드 안에서 호출하는 것

추상 메서드의 단점은 상속 계층에 속하는 모든 자식 클래스가 추상 메서드를 오버라이딩해야 한다는 것

해결 방법은 메서드에 기본 구현을 제공하는 것

추상 메서드와 동일하게 자식 클래스에서 오버라이딩할 의도로 메서드를 추가했지만 편의를 위해 기본 구현을 제공하는 메서드 -> 훅 메서드(hook method)

자바를 비롯한 대부분의 객체지향 언어는 단일 상속만 지원하기 때문에 상속으로 인해 발생하는 중복 코드 문제를 해결하기가 쉽지 않다.

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

중복 코드 발생

중복 코드의 덫에 걸리다

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

복잡해짐 + 새로운 정책을 추가하기 어려워짐

상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우 -> 클래스 폭발(class explosion) 또는 조합의 폭발(combinational exploision)

클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생하는 문제

컴파일타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 자식 클래스와 부모 클래스의 다양한 조합이 필요한 상황에서 유일한 해결 방법은 조합의 수만큼 새로운 클래스를 추가하는 것뿐

클래스 폭발 문제는 새로운 기능을 추가할 때뿐만 아니라 기능을 수정할 때도 문제가 됨 -> 모든 클래스를 찾아 동일한 방식으로 수정해야 함

하나라도 누락한다면 세금이 부과되지 않는 버그 발생

11.3 합성 관계로 변경

상속 관계는 컴파일타임에 결정되고 고정되기 때문에 코드를 실행하는 도중에는 변경할 수 없다.

합성은 컴파일타임 관계를 런타임 관계로 변경함으로써 이 문제를 해결

합성을 사용하면 구현이 아닌 퍼블릭 인터페이스에 대해서만 의존할 수 있기 때문에 런타임 객체의 관계를 변경 가능

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

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

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

물론 컴파일타임 의존성과 런타임 의존성의 거리가 멀면 멀수록 설계의 복잡도가 상승하기 때문에 코드를 이해하기 어려워진다

기본 정책 합성하기

기본 정책과 부가 정책을 포괄하는 인터페이스 추가

1
2
3
4
public interface RatePolicy {
    Money calculateFee(Phone phone);
}

기본 정책

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public abstract class BasicRatePolicy implements RatePolicy {
    @Override
    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 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Phone {
    private RatePolicy ratePolicy;
    private List<Call> calls = new ArrayList<>();

    public Phone(RatePolicy ratePolicy) {
        this.ratePolicy = ratePolicy;
    }

    public List<Call> getCalls() {
        return Collections.unmodifiableList(calls);
    }

    public Money calculateFee() {
        return ratePolicy.calculateFee(this);
    }
}

생성자를 통해 인스턴스에 대한 의존성 주입

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

부가 정책 적용하기

부가 정책을 구현할 때 제약사항

  • 부가 정책은 기본 정책이나 다른 부가 정책의 인스턴스를 참조할 수 있어야 한다. 부가 정책의 인스턴스는 어떤 종류의 정책과도 합성될 수 있어야 한다.
  • 기본 정책과 부가 정책은 협력 안에서 동일한 ‘역할’을 수행해야 한다. -> 부가 정책이 기본 정책과 동일한 인터페이스를 구현해야 함
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public abstract 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);
}

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

상속을 사용한 설계보다 복잡하고 정해진 규칙에 따라 객체를 생성하고 조합해야 하기 때문에 처음에는 코드를 이해하기 어려울 수도 있다.

하지만 일단 익숙해지면, 더 예측 가능하고 일관성이 있다.

새로운 정책 추가하기

새로운 정책을 추가하려면 클래스 하나 추가하면 됨

그리고 요구사항을 변경할 때 오직 하나의 클래스만 수정해도 됨

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

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

코드를 재사용하면서도 건전한 결합도를 유지할 수 있는 더 좋은 방법은 합성을 이용하는 것

상속을 사용해야 하는 경우는 언제인가?? -> 구현 상속과 인터페이스 상속에 대해 얄아야 함

11.4 믹스인

구체적인 코드를 재사용하면서도 낮은 결합도를 유지할 수 있는 유일한 방법은 재사용에 적합한 추상화를 도입하는 것

믹스인(mixin)은 객체를 생성할 때 코드 일부를 클래스 안에 섞어 넣어 재사용하는 기법

합성이 실행 시점에 객체를 조합하는 재사용 방법이라면 믹스인은 컴파일 시점에 필요한 코드 조각을 재사용하는 방법

상속의 진정한 목적은 자식 클래스를 부모 클래스와 동일한 개념적인 범주로 묶어 is-a 관계를 만들기 위한 것

믹스인은 말 그대로 코드를 다른 코드 안에 섞어 넣기 위한 방법

믹스인을 접하면 개념을 이해하기가 다소 어려울수도 있는데 코드를 섞어 넣는다는 기본 개념을 구현하는 방법이 언어마다 다르기 때문

스칼라 언어에서 제공하는 트레이트(trait)

기본 정책 구현하기

자바와 유사

추상 클래스로 구현

트레이트로 부가 정책 구현하기

trait와 함께 쓰이는 extends는 상속의 개념이 아니라 자손에 해당하는 경우에만 믹스인될 수 있다는 것을 의미

trait는 상속하도록 구현했지만 실제로 자식 트레이트가 되는 것이 아님

믹스인 가능한 문맥을 제한하는 것 뿐

추가될 새로운 자손에게도 믹스인 될 수 있지만 다른 클래스나 트레이트에는 믹스인될 수 없음

상속은 정적이지만 믹스인은 동적

상속은 부모 클래스와 자식 클래스의 관계를 코드를 작성하는 시점에 고정시켜 버리지만 믹스인은 제약을 둘 뿐 실제로 어떤 코드에 믹스인될 것인지를 결정하지 않음

트레이트를 믹스인하는 시점에 가서야 믹스인할 대상을 결정할 수 있다.

super로 참조되는 코드 역시 고정되지 않음

super 참조가 가리키는 대상이 컴파일 시점이 아닌 실행 시점에 결정됨

트레이트의 경우 this 호출뿐만 아니라 super 호출 역시 실행 시점에 바인딩됨

부가 정책 트레이트 믹스인하기

스칼라는 트레이트를 클래스나 다른 트레이트에 믹스인 할 수 있도록 extends와 with 키워드를 제공

믹스인하려는 대상 클래스의 부모 클래스가 존재하는 경우 부모 클래스는 extends를 이용해 상속받고 트레이트는 with를 이용해 믹스인 -> 트레이트 조합(trait composition)

스칼라는 특정 클래스에 믹스인한 클래스와 트레이트를 선형화(linearization)해서 어떤 메서드를 호출할지 결정

믹스인은 재사용 가능한 코드를 독립적으로 작성한 후 필요한 곳에서 쉽게 조합 가능

쌓을 수 있는 변경

전통적으로 믹스인은 특정한 클래스의 메서드를 재사용하고 기능을 확장하기 위해 사용됨

믹스인은 상속 계층 안에서 확장한 클래스보다 더 하위에 위치

믹스인을 추상 서브클래스(abstract subclass)라고 부르기도 함

믹스인을 사용하면 특정한 클래스에 대한 변경 또는 확장을 독립적으로 구현한 후 필요한 시점에 차례대로 추가 가능 -> 쌓을 수 있는 변경(stackable modification)

참조

  1. 오브젝트
This post is licensed under CC BY 4.0 by the author.