GYUD-TECH

[스터디: 오브젝트] 합성과 믹스인 본문

스터디

[스터디: 오브젝트] 합성과 믹스인

GYUD 2024. 3. 4. 17:00

 

앞선 장의 마지막에 상속보다는 합성을 사용하자고 강조했다.

이번 장에서는 상속과 합성의 차이를 비교하고 왜 상속보다 합성을 사용해야 하는지에 초점을 맞추어서 공부하였다.

 

또한 중복 코드를 제거하는 또다른 방법인 믹스인의 개념도 함께 살펴보았다.

이번 장에서는 상속과 관련된 내용은 깊이 다루지 않기 때문에 이전 글을 한번 읽고 이번 장을 읽으면 더 이해하기 쉬울 것 같다.

https://gyuwon-tech.tistory.com/37

 

[스터디: 오브젝트] 상속과 코드 재사용

이번 장에서는 상속과 코드의 재사용에 대해 다룬다. 프로그래밍을 공부하면, 중복코드를 줄이라는 말을 쉽게 들을 수 있는데, 왜 중복코드를 줄여야 하는지에 대해서 깊게 생각하진 않았던 것

gyuwon-tech.tistory.com

https://gyuwon-tech.tistory.com/29

 

[스터디: 객체지향의 사실과 오해] 추상화기법, 상속, 합성

추상화 기법 추상화를 사용하는 이유는 도메인의 복잡성을 단순화하고, 직관적인 모델을 만들 수 있기 때문이다. 이러한 추상화의 기법에는 3가지가 있다. 분류와 인스턴스화 일반화와 특수화

gyuwon-tech.tistory.com

 


합성

상속의 문제를 해결하는 합성

상속과 합성은 코드 재사용이라는 목적은 같지만, 의존관계가 확정되는 시점에 가장 큰 차이가 있다.

 

상속은 부모와 자식간의 의존성이 컴파일 타임에 정적으로 결정되는 반면, 합성은 의존관계를 런타임에 결정해준다.

따라서 동적으로 의존관계가 변경 가능하기 때문에 상속을 사용했을때 발생하여 객체들이 강하게 결합되는 문제점을 해결할 수 있다.

 

앞서 상속으로 인해 발생하는 문제점으로 아래 4가지를 언급했다.

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

 

합성은 부모 객체의 모든 구현을 상속받는것이 아니라 인스턴스 변수로 해당 객체를 가지고 있는 것이기 때문에 필요한 인터페이스만을 골라서 사용할 수 있다.

 

 

합성을 사용했을 때 의존하는 클래스가 변경되면, 다른 클래스들은 영향을 아예 받지 않는 것은 아니지만, 캡슐화를 통해서 그 영향을 최소화 할 수도 있다.

 

 

또한 super의 호출로 인해서 오버라이딩한 메서드의 실행결과가 예상치 못하게 나오는것을 방지할 수 있다.

이전 장에서 언급한 InstrumentHashSet의 예시에서는 발생하는 메서드 오버라이딩 오작용의 원인은 자식 객체도 부모 객체로 인정되기 때문에 개발자가 예상하지 못한 메서드가 호출된다는 것이었다.

public class InstrumentHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    @Override
    public boolean add(E e) {
        addCouunt++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }
}

 

위 코드에서 addAll() 메서드는 상위 객체인 HashSet의 addAll() 메서드를 호출하였기 때문에 예상치 못한 add() 메서드도 호출되어 addCount가 중복으로 집계되었다.

 

우리의 목적은 InstrumentHashSet의 add() 메서드가 아닌 HashSet의 add() 메서드를 호출하는 것이기 때문에 합성을 통해서 아래와 같이 코드를 작성해주면 문제를 해결할 수 있다.

public class InstrumentedHashSet<E> implements Set<E> {
    private int addCount = 0;
    private Set<E> set;

    public InstrumentedHashSet(Set<E> set) {
        this.set = set;
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return set.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return set.addAll(c);
    }

    ...
}

위 코드에서 set.addAll() 을 통해 호출된 객체의 내부에서는 instrumentedHashSet의 add() 메서드가 호출되지 않고, set에 들어가있는 구현체인 HashSet의 add() 메서드가 호출되기 때문에 addCount가 중복되어서 counting 되지 않을 것이다.

 

 

또한 상속으로 발생하는 클래스 폭발 문제를 합성을 사용하면 효율적으로 해결할 수 있다.

책에서는 휴대폰 요금 계산 예제에 부가 정책을 조합하여 요금을 계산하는 요구사항을 제시하며 합성을 활용한 코드의 예시를 보여준다.

 

부가 정책에는 세금 할인 정책과 비율 할인 정책이 있다고 가정하자.

부가 정책은 선택한 요금에 따라서 하나도 적용되지 않을 수도 있고, 하나만 적용될 수도 있고 모두 적용될 수도 있다.

 

만약 상속을 사용한다면, 각 부가정책과 요금 정책이 결합된 모든 조합을 클래스로 작성해주어야 한다.

이로 인해 중복 코드가 발생하여 객체를 수정하는게 매우 어려워진다.

 

하지만, 합성을 사용한다면, 개별 정책만을 클래스로 구현하고, 런타임에 이를 조합하여 객체를 바꾸어줄 수 있다.

부가정책이 추가되기 전에는 아래와 같이 객체들이 협력하고 있다.

각각의 통화에 대한 요금을 합하여 전체 휴대전화의 요금을 계산하는 calculateFee() 메서드는 공통적인 기능이기 때문에 일반 메서드로 작성하였다.

 

반면 각 전화에 대해 요금을 계산하는 것은 두 클래스마다의 정책이 다르기 때문에 추상 메서드로 선언하여 자식클래스에서 오버라이딩하여 구현하도록 하였다.

 

이제 이 구조에 세금 할인 정책과 일정 금액할인 부가 정책을 추가해보자.

할인 요금을 계산하는 과정을 살펴보면 RegularPolicy에서 기본요금을 먼저 계산하고, RateDiscountablePolicy에서 일정 할인 금액을 할인한 후에, 세금을 계산하는 TaxablePolicy 순서대로 적용해야한다.

 

처음에는 아래와 같은 순서로 협력하는 구조를 떠올렸다.

 

하지만 이렇게 구조를 설계하면, RegularPolicy에서도 RateDiscountablePolicy나 TaxablePolicy 객체에 메시지를 보내야 한다.

 

따라서 아무런 부가 정책이 적용되지 않는다면 문제가 발생할 수 있다.

물론 NoneAdditionalPolicy라는 클래스를 추가하여 메시지를 전송하는 방법도 있지만, 이렇게 하면 RegularPolicy에서는 불필요한 객체를 하나 더 참조하는 것이기 때문에 올바르지 못한 설계이다.

 

따라서 위 설계 보다는 RegularPolicy를 가장 마지막에 둬서 먼저 요금을 계산하고, 메서드를 빠져나오면서 할인을 적용하도록 하는 것이 더 효과적이다.

TaxablePolicy와 RateDiscountablePolicy는 모든 정책을 포함하는 추상화 객체를 인스턴스 변수로 가짐으로써, 해당 변수에 메시지를 보낸 후 리턴되는 값에 자신의 할인 금액 계산법을 적용하면 된다.

 

따라서 TaxablePolicy와 RateDiscountable 타입을 포함하는 추상화된 클래스가 하나 필요하고, 모든 Policy를 포함하는 추상화 클래스도 하나 필요하다.

 

이를 다이어그램으로 나타내면 아래와 같이 표현할 수 있다.

BasicRatePolicy에서는 calls를 순회하며 통화별 요금을 합산하는 calculateFee() 메서드가 공통으로 필요하기 때문에 일반 메서드로 선언하였고, 각 통화당 금액을 계산하는 메서드는 구현체마다 구현 방식이 다르기 때문에 추상 메서드로 정의하였다.

 

AdditionalRatePolicy에서는 다음 요금 정책에 메시지를 보내는 calculateFee() 메서드와, calculateFee() 메서드를 통해 받은 계산 결과 값에 자신의 할인 정책을 적용하는 afterCaculated() 메서드를 추상 메서드로 정의하여 하위 객체에서 구현하도록 하였다.

 

위 구조로 시스템을 설계하면, 가능한 모든 조합마다 해당하는 클래스를 생성할 필요 없이, 생성자를 통해서 다양한 조합의 인스턴스를 생성할 수 있다.

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

상속을 사용했을 떄보다 객체를 생성하는 과정은 복잡해졌지만, 중복된 코드가 줄어들고, 할인 정책의 변경에도 유연하게 대처할 수 있기 때문에 훨씬 객체지향스러운 코드라 만들어 졌다.

 


믹스인

상속과 합성 모두 중복된 코드를 제거하기 위한 기법이다.

중복을 제거하기 위한 또다른 기법으로는 믹스인이라는 기법을 소개한다.

 

믹스인은 객체를 생성할 때 코드 일부를 클래스안에 넣어서 계층구조를 동적으로 구현하는 방법이다.

 

스칼라 언어에서 제공하는 트레이트가 믹스인의 개념을 구현하고 있어서 책에서 간단하게 소개해주었다.

앞선 휴대폰 요금 부가정책과 관련된 기능에서 RegularPolicy와 NightlyDiscountPolicy와 섞을 수 있는 부가정책에 해당하는 클래스는 TaxablePolicy와 RateDiscountablePolicy이다.

 

따라서 이 두 클래스를 trait로 구현해준다.

trait TaxablePolicy extends BasicRatePolicy {
    def taxRate: Double

    override def calculateFee(calls: List<Call>): Money = {
        val fee = super.calculateFee(calls)
        return fee + fee * taxRate;
    }
}

위에서는 extends 키워드를 사용했지만, 이는 상속의 개념이 아니라 문맥을 제한하는 의미이다.

TaxablePolicy 트레이트는 BasicRatePolicy 나 BasicRatePolicy의 자손 객체들과만 믹스인 될 수 있다.

 

이렇게 trait 와 extends 키워드를 통해서 TaxablePolicy는 BasicRatePolicy나 그 하위 객체들과 섞일 수 있는 객체임을 명시적으로 표현하였다.

TaxablePolicy는 BasicRatePolicy의 하위객체이기만 하면 누구든지 결합할 수 있기 때문에 변경에 용이하다.

이렇게 선언한 객체들의 계층관계는 특정 규칙에 의해서 런타임에 결정된다.

 

세금 할인 정책이 적용된 일반 요금 정책을 구현하기 위해서는 아래와 같이 extends와 with 키워드를 사용하여 클래스를 생성해준다.

class TaxableRegularPolicy(
    amount: Money,
    seconds: Duration,
    val taxRate: Double)
extends RegularPolicy(amount, seconds)
with TaxablePolicy

믹스인 하려는 대상 클래스의 부모클래스를 extends 키워드로 상속받고, 트레이트는 with로 믹스인 하면 계층구조를 개발자가 자유롭게 쌓을 수 있다.

 

트레이트가 포함된 모든 객체는 자기 자신 - 트레이트의 가장 오른쪽부터 왼쪽까지 - 부모객체 순으로 계층 구조를 형성한다.

TaxableRegularPolicy 객체에 calculateFee() 라는 메시지가 전달된다면, 가장 먼저 TaxableReularPolicy 내에 calculateFee() 메서드가 존재하는지 확인한다.

 

메서드가 없다면 다음 순서인 TaxablePolicy 트레이트에서 calculateFee() 메서드를 찾아서 실행한다.

 

TaxablePolicy의 calculateFee() 메서드 내부에서는 super 키워드로 상위 객체를 호출하고 있기 때문에, 계층 구조의 상위 객체인 RegularPolicy 클래스로 calculateFee() 메시지가 전달된다.

 

RegularPolicy에도 메서드가 없다면, 그 상위 객체인 BasicRatePolicy에 메시지가 전달되고, BasicRatePolicy의 calculateFee() 메서드가 실행되어서 기본 요금이 계산된다.

 

이후 다시 TaxablePolicy 트레이트에 구현된 세금 할인 정책이 적용되고, 이 값이 리턴된다.

 

만약 트레이트가 여러개라서 순서를 지정하고 싶다면 가장 아래쪽 계층에 위치시킬 트레이트를 가장 오른쪽에 적어주면 된다.

class RateDiscountableAndTaxableRegularPolicy {
    amount: Money,
    seconds: Duration,
    val discountAmount: Money,
    val taxRate: Double)
extends RegularPolicy(amount, seconds)
with TaxablePolicy
with RateDiscountablePolicy

위와 같이 코드를 작성하면 가장 먼저 RateDiscountablePolicy 트레이트가 호출된다.

RateDiscountablePolicy 내부의 super 키워드에 의해 TaxablePolicy가 호출되고, 다시 내부의 super 키워드에 의해 BasicRatePolicy의 메서드가 호출된다.

이후 메서드를 빠져나오면서 세금 할인 정책이 먼저 적용되고, 비율 할인 정책이 적용된다.

 

반대로 비율 할인 정책을 먼저 적용하고, 세금 할인 정책을 적용하고 싶다면 아래와 같이 코드를 작성해주면 될 것이다.

class RateDiscountableAndTaxableRegularPolicy {
    amount: Money,
    seconds: Duration,
    val discountAmount: Money,
    val taxRate: Double)
extends RegularPolicy(amount, seconds)
with RateDiscountablePolicy
with TaxablePolicy

 

합성런타임시점에 객체를 조합해서 재사용 하지만, 믹스인컴파일 시점에 필요한 코드 조각을 재사용한다는 차이점이 있다.

하지만 컴파일 시점에 클래스간 관계를 정의하는 상속과는 달리 협력하는 객체를 유연하게 관계를 재구성 할 수 있다.

 

처음에는 믹스인을 사용해도 재사용을 위해서는 위와 같이 모든 조합에 대한 클래스를 생성해 줘야 해서 상속의 클래스 폭발 문제를 해결하지 못한다고 생각했다.

하지만 클래스 폭발의 진짜 문제점은 클래스가 많아지는 것이 아니라 많아진 클래스의 중복된 코드로 인해 변경이 어려워진다는 점이었다.

 

하지만 위의 경우에는 중복된 코드 없이, 단순히 객체들을 레고블럭처럼 조립해주기만 하면 되기 때문에 중복코드로 인한 문제가 발생하지 않는다.

오히려 클래스로 선언함으로써, 객체의 협력관계를 명시적으로 표현한다는 장점을 가지고 있다.

 

결국 믹스인은 상속 계층 사이에서 구체적인 상속관계를 명시하지 않고 의존성을 끼워넣을 때 사용한다.

필요한 객체만 끼워넣어서 의존관계를 만들어 주기 때문에 동적으로 협력관계를 생성할 수 있는 것이다.

 


자바에서는 스칼라와 같이 믹스인을 직접적으로 제공하는 개념은 없지만 디폴트 인터페이스를 통해서 어느정도 구현이 가능하다.

자바 8 이후로 인터페이스에도 메서드를 구현하는 것이 가능하기 때문에 여러 클래스를 미리 정의하고, 다중 상속을 통해서 필요한 것만 가져와서 협력관계를 구성할 수 있다.

 

스칼라의 믹스인과는 유연성에서 차이가 있긴 하지만 책에서 강조하고 싶은 내용은 믹스인을 구현하는 방법이 아닌 중복코드를 줄이는 방법으로써 믹스인의 개념이라고 생각한다.

 

중복코드를 제거하는 방법에는 상속, 합성, 믹스인이 있으며,
각각의 장단점이 있기 때문에 이를 고려하여
시스템에 가장 잘 맞는 기법을 선택하는 것이 중요하다.