우아한테크코스/Level 1 | 정리

클래스 위임(Class Delegation): by 키워드 사용

Krrong 2023. 2. 23. 13:28

💡 Intro

Kotlin in Action의 말을 인용하면 시스템을 취약하게 만드는 문제는 보통 구현 상속에 의해 발생한다. 하위 클래스가 상위 클래스의 메서드 중 일부를 오버라이드하면 하위 클래스는 상위 클래스의 세부 구현 사항에 의존하게 되는데, 상위 클래스의 구현이 바뀌거나 상위 클래스에 새로운 메서드가 추가되면 하위클래스의 동작이 잘못될 수 있다.

즉, 하위 클래스가 상위 클래스에 대해 갖고있던 가정이 깨져서 코드가 정상적으로 동작하지 못하는 경우가 발생할 수 있다는 것이다.

 

코틀린은 이런 문제를 인식하고 기본적으로 클래스를 final로 취급하고 상속을 위해서는 open 키워드를 통해 열어두어야 한다. 열린 상위 클래스의 소스코드를 변경할 때는 open 키워드를 보고 "다른 클래스가 이 클래스를 상속받겠구나!"를 생각할 수 있고, 소스코드를 변경할 때 더 신중을 기울일 수 있다.

 

상속을 허용하지 않는 클래스에 새로운 동작을 추가할 때 사용하는 일반적인 방법이 데코레이터(Decorator)패턴이다. 이 패턴의 핵심은 상속을 허용하지 않는 클래스(기존 클래스) 대신 사용할 수 있는 새로운 클래스(데코레이터)를 만들되 기존 클래스와 같은 인터페이스를 데코레이터가 제공하게 만들고, 기존 클래스를 데코레이터 내부에 필드로 유지하는 것이다. 이 때 새로 정의해야 하는 기능은 데코레이터의 메서드에 새로 정의하고 기존 기능이 그대로 필요한 부분은 데코레이터의 메서드가 기존 클래스의 메서드에게 요청을 전달한다.

 

는데 책의 내용을 한 번에 읽고 이해하기란 정말 어려운 일이다..

 

 

🚀 클래스 위임

class Lotto(val numbers: Set<LottoNumber>)

위와 같은 코드가 있다면 Lotto클래스 내에 있는 numbers에 대해 연산을 하기 위해서는 (numbers의 사이즈를 가져오거나 어떤 값을 포함하는지 여부를 검사 또는 map, forEach의 사용) lotto.numbers.size 혹은 lotto.numbers.map 과 같이 사용해야 한다. 굉장히 불편하게 느껴질 수 있는데 이를 클래스 위임 방식을 통해 해결할 수 있다.

 

 

Delegation | Kotlin

 

kotlinlang.org

레퍼런스에 따르면 클래스 위임이란 하나의 클래스를 다른 클래스에 위임하도록 선언하여 위임된 클래스가 가지는 인터페이스 메소드를 참조 없이 호출할 수 있도록 생성해주는 기능이다. 이게 무슨 말일까?

 

interface A { ... }
class B : A { }
val b = B()

// C를 생성하고, A에서 정의하는 B의 모든 메서드를 C에 위임한다.
class C : A by b

인터페이스 A와 A를 구현한 B가 있다고 하자. class C : A by b의 의미는 A에서 정의하는 모든 B의 모든 메서드를 C에 위임한다는 것이다. 이 때 b는 A타입이여야 하며 C에 저장되어 있는 프로퍼티여야 한다. B에서 구현된 모든 A의 메서드는 이를 참조하는 형태의 정적 메서드로 생성된다.

 

즉, C는 B가 갖고있는 모든 A의 메서드를 구현하지 않아도 가질 수 있게 되는 것이고 이것을 클래스 위임이라고 하는 것이다.

 

코드를 조금 더 자세하게 작성해보자.

interface A {
    fun printX()
}

class B(val x: Int) : A {
    override fun printX() {
        println(x)
    }

    fun printHello() {
        println("hello")
    }
}

class C(b: B) : A by b

fun main() {
    val b = B(3)
    val c = C(b)
    c.printX()
}

A라는 인터페이스는 printX()를 갖고있고, B는 A를 구현한 클래스다. 때문에 printX()를 오버라이딩 해야한다. C는 B가 갖고있는 모든 A의 메서드를 가질 수 있다. 하지만 B만 가지고 있는 메서드는 가질 수 없다. 즉, C는 printX()는 사용할 수 있지만 printHello()는 사용할 수 없다는 말이다.

 

위 코드를 디컴파일해보자.

public interface A {
    void printX();
}

public final class B implements A {
    private final int x;

    public void printX() {
        int var1 = this.x;
        System.out.println(var1);
    }

    public final void printHello() {
        String var1 = "hello";
        System.out.println(var1);
    }

    public final int getX() {
        return this.x;
    }

    public B(int x) {
        this.x = x;
    }
}

public final class C implements A {
    // $FF: synthetic field
    private final B $$delegate_0;

    public C(@NotNull B b) {
    Intrinsics.checkNotNullParameter(b, "b");
        super();
        this.$$delegate_0 = b;
    }

    public void printX() {
        this.$$delegate_0.printX();
    }
}

A와 B의 디컴파일 결과는 예상할 수 있을 것이다. 그러니 C에 주목해보자.

C는 $$delegate\_0가 B타입의 본래 인스턴스를 참조할 수 있도록 생성되며 printX()를 갖고 있는데 $$delegate\_0을 통해 호출하는 것을 볼 수 있다. 즉 B에 구현된 printX()를 사용하는 것이다.

 

그럼 이 것을 적용한 코드를 예시로 들어보자.

package domain

class Lotto(val numbers: Set<LottoNumber>) {
    constructor(vararg numbers: Int) : this(numbers.map(::LottoNumber).toSet())

    init {
        require(numbers.size == LOTTO_SIZE) { ERROR_LOTTO_SIZE }
    }

    fun toList(): List<Int> {
        return numbers.map { LottoNumber ->
            LottoNumber.toInt()
        }
    }

    fun contains(number: LottoNumber): Boolean {
        return numbers.contains(number)
    }

    companion object {
        private const val LOTTO_SIZE = 6
        private const val ERROR_LOTTO_SIZE = "로또 번호는 6개여야 합니다."
    }
}

Lotto 클래스에서 특정 로또 번호를 가지고 있는지 확인하기 위해 contains()라는 메서드를 만들었다. Lotto 를 잘 보면 프로퍼티가 Set<>인 것을 볼 수 있다. Set<>은 인터페이스이며 이미 contains() 메서드를 가지고있다. 굳이 함수를 만들지 않아도 Set<>에 있는 메서드를 가져와 사용할 수 있는 것이다.

 

바로 다음과 같이 말이다.

package domain

import java.util.Spliterator

class Lotto(val numbers: Set<LottoNumber>):Set<LottoNumber> {
    constructor(vararg numbers: Int) : this(numbers.map(::LottoNumber).toSet())

    init {
        require(numbers.size == LOTTO_SIZE) { ERROR_LOTTO_SIZE }
    }

    fun contains(number: LottoNumber): Boolean {
        return numbers.contains(number)
    }

    companion object {
        private const val LOTTO_SIZE = 6
        private const val ERROR_LOTTO_SIZE = "로또 번호는 6개여야 합니다."
    }

    override val size: Int
        get() = TODO("Not yet implemented")

    override fun isEmpty(): Boolean {
        TODO("Not yet implemented")
    }

    override fun iterator(): Iterator<LottoNumber> {
        TODO("Not yet implemented")
    }

    override fun spliterator(): Spliterator<LottoNumber> {
        return super.spliterator()
    }

    override fun containsAll(elements: Collection<LottoNumber>): Boolean {
        TODO("Not yet implemented")
    }
}

LottoSet을 구현한 클래스라고 명시하면 Set<>에 있는 contains()를 사용할 수 있게 된다.

하지만 이렇게 하는 경우 이외에도 필수적으로 오버라이드 해야하는 메서드들을 적어줘야 한다. 단지 contains()를 사용하기 위해서 추가해야 하는 코드들이 너무 많아지지 않았는가?

 

이것을 by를 통해 해결할 수 있다.

 

 

by 키워드

package domain

class Lotto(val numbers: Set<LottoNumber>):Set<LottoNumber> by numbers {
    constructor(vararg numbers: Int) : this(numbers.map(::LottoNumber).toSet())

    init {
        require(numbers.size == LOTTO_SIZE) { ERROR_LOTTO_SIZE }
    }

    fun toList(): List<Int> {
        return numbers.map { LottoNumber ->
            LottoNumber.toInt()
        }
    }

    companion object {
        private const val LOTTO_SIZE = 6
        private const val ERROR_LOTTO_SIZE = "로또 번호는 6개여야 합니다."
    }
}

class Lotto(val numbers: Set):Set by numbers 와 같이 작성해주면 numbers가 갖고있는 메서드 중 Set<>에 있는 메서드를 Lotto가 사용할 수 있게 되는 것이다. 오버라이드하지 않아도.

 

위 코드를 디컴파일해보자.

...
    public int getSize() {
        return this.numbers.size();
    }

    // $FF: bridge method
    public final int size() {
        return this.getSize();
    }

    public boolean contains(@NotNull LottoNumber element) {
        Intrinsics.checkNotNullParameter(element, "element");
        return this.numbers.contains(element);
    }

    // $FF: bridge method
    public final boolean contains(Object var1) {
        return !(var1 instanceof LottoNumber) ? false : this.contains((LottoNumber)var1);
    }

    public boolean containsAll(@NotNull Collection elements) {
        Intrinsics.checkNotNullParameter(elements, "elements");
        return this.numbers.containsAll(elements);
    }
...

실제로 구현하지 않은 size(), contains() 등의 함수가 구현되어 있는 것을 볼 수 있다.

 

📋 여담

by에 관련하여 찾아보다가 아무 생각없이 사용하던 by lazy를 보게됐다. 여기서 사용하는 by 역시 위임을 하는 것이고 코트린에서 제공하는 위임 표현 중 가장 유명한 표현이라고 볼 수 있다.

 

그런데 by를 사용하여 클래스 위임을 하는 것이 항상 좋다고 볼 수 있을까? 에 대해서도 고민을 해봐야한다. 

 

컬렉션 타입을 위임으로 사용하면, 컬렉션처럼 사용할 수 있다는 장점이 있다. 
하지만 그렇게 되면 필요한 기능 외에도 컬렉션의 모든 기능을 사용할 수 있기 때문에 프로퍼티만 접근하지 않을 뿐이지 결국 비즈니스 로직을 뺏어서 작성했다고 볼 수 있다. 

로또 미션을 진행하면서 클래스 위임 전략을 사용했었는데, 이것이 어떤 부분에서 문제가 될 수 있고 어떤 단점이 있는지 위와 같은 리뷰어님의 말씀을 통해서 약간 깨닫게 된 것 같다.

 

위임 전략을 사용하면 비즈니스 로직을 외부에서 작성하기 쉬워진다. 원하는 기능을 여기저기서 구현할 수 있다. 하지만 그만큼 도메인 로직을 명확하게 분리할 수 없을 수 있고 점점 MVC패턴을 지키기 어려워지면서 여러 코드가 한 파일안에 혼재될 수 있지 않을까?

 

이러한 이유 때문인지는 모르겠지만 리뷰어님은 위임전략 보다는 객체에게 메시지를 던져서 원하는 결과를 반환 받는 방법으로 유도해주셨다. 두 방법을 모두 구현해보면서 느꼈던 것은 필요한 정보를 얻기 위해 객체에 메시지를 던지는 방식으로 구현하는 것이 딱 필요한 기능만을 구현함으로써 제한을 둘 수 있고, 해당 클래스가 어떤 상태로 구현되어 있는지 모르고 사용하는 쪽에서도 헷갈리지 않고 사용할 수 있지 않을까 하는 생각을 했다.

 

앞으로 조금 더 생각이 필요한 부분은 어느 곳에서 위임 전략을 사용하는 것이 유리한 것인가 이다.