|

코틀린 delegation 쉽게 이해하기

코틀린 delegation 쉽게 이해하기
delegation은 조합 기반 협력을 짧게 표현하게 도와주는 코틀린 도구에 가깝다

코틀린 delegation을 이해할 때 가장 먼저 보게 되는 장면이 class Derived(b: Base) : Base by b 같은 문장입니다.

처음 보면 문법이 짧아서 편해 보이지만, 왜 이 문법이 필요한지 감이 바로 오지는 않습니다.

핵심은 간단합니다.

delegation은 상속을 더 화려하게 만드는 문법이 아니라, 조합으로 협력시키는 코드를 덜 장황하게 만드는 문법입니다.

이번 글에서는 by 문법이 실제로 무엇을 대신해 주는지, 상속보다 위임이 더 읽기 쉬워지는 순간은 언제인지, 그리고 override를 섞을 때 어디서 많이 헷갈리는지까지 단계적으로 정리하겠습니다.


코틀린 delegation을 한 줄로 먼저 이해하기

코틀린 delegation 핵심 요약 카드
delegation의 핵심을 먼저 잡는 카드
상속 조합 delegation 비교 카드
종류 분류와 역할 결합, 전달 코드 축소를 한눈에 비교한 카드

Kotlin 공식 문서는 delegation pattern이 implementation inheritance의 좋은 대안이 될 수 있다고 설명합니다.

이 말의 뜻은 “상속을 다 버리자”가 아닙니다.

더 정확히 말하면, 인터페이스 구현을 다른 객체에 맡기는 구조를 언어 차원에서 짧게 쓸 수 있다는 뜻입니다.

보통 위임 구조를 직접 쓰면 이런 전달 코드가 반복됩니다.

interface Printer {
    fun print(message: String)
}

class ConsolePrinter : Printer {
    override fun print(message: String) {
        println(message)
    }
}

class LoggingPrinter(
    private val delegate: Printer,
) : Printer {
    override fun print(message: String) {
        println("[log] start")
        delegate.print(message)
        println("[log] end")
    }
}

이 구조 자체는 나쁘지 않습니다.

문제는 메서드가 많아질수록 전달 코드가 계속 늘어난다는 점입니다.

delegation은 바로 이 반복을 줄여 줍니다.


by 문법은 무엇을 대신해 줄까

코틀린의 interface delegation은 컴파일러가 전달 메서드를 대신 만들어 줍니다.

interface Printer {
    fun print(message: String)
    fun close()
}

class ConsolePrinter : Printer {
    override fun print(message: String) {
        println(message)
    }

    override fun close() {
        println("closed")
    }
}

class LoggingPrinter(
    private val inner: Printer,
) : Printer by inner {
    override fun print(message: String) {
        println("[log] before")
        inner.print(message)
        println("[log] after")
    }
}

여기서 close()는 직접 구현하지 않았지만 Printer by inner 덕분에 자동으로 inner.close()에 전달됩니다.

즉 by 문법은 내가 굳이 건드리지 않을 기본 전달 메서드를 자동 생성해 주고, 내가 바꾸고 싶은 일부만 override하게 도와줍니다.


상속보다 위임이 더 읽기 쉬워지는 순간

상속은 “이 타입은 저 타입의 한 종류인가”를 표현할 때 잘 맞습니다.

반면 delegation은 “이 객체는 저 객체의 기능을 빌려서 일부만 덧붙이는가”를 표현할 때 더 잘 맞습니다.

예를 들어 로깅, 권한 검사, 캐시, 측정, 포맷 변환 같은 기능은 원래 기능 위에 얹히는 경우가 많습니다.

이런 상황에서 상속으로 계속 파생 클래스를 늘리면 종류 분류와 부가 기능 확장이 섞여서 코드가 무거워집니다.

delegation을 쓰면 관심사가 더 잘 보입니다.

  • 기본 기능은 원래 구현체가 담당한다
  • 부가 기능은 래퍼 객체가 담당한다
  • 둘의 협력은 인터페이스로 맞춘다

이 구조는 Decorator, Proxy, Bridge 같은 패턴을 이해할 때도 매우 유용합니다.


delegation이 특히 잘 맞는 장면

여기서 하나 더 구분해야 할 것이 있습니다. 코틀린에서 delegation이라고 할 때 많은 사람이 by lazy, Delegates.observable 같은 property delegation과 지금 설명하는 interface delegation을 한 덩어리로 기억합니다. 둘 다 by를 쓰지만, 하나는 인터페이스 구현 전달이고 다른 하나는 프로퍼티 접근 제어라는 점에서 목적이 다릅니다.

즉 이번 글의 중심은 “행위를 다른 객체에 맡기는 interface delegation”입니다. 같은 by 문법이라도 역할이 다르다는 점을 분리해 두면 훨씬 덜 헷갈립니다.

delegation이 자주 빛나는 장면은 아래와 같습니다.

  1. 인터페이스 메서드가 많은데 일부만 덧붙이고 싶을 때
  2. 상속 계층을 늘리기보다 래퍼 조합으로 기능을 붙이고 싶을 때
  3. 구현 재사용은 하고 싶지만 “is-a” 관계는 어색할 때
  4. 테스트에서 가짜 구현이나 감시 래퍼를 빠르게 만들고 싶을 때

예를 들어 결제 SDK 호출기, 메시지 전송기, 저장소 래퍼, 네트워크 클라이언트 래퍼 같은 곳에서 자주 잘 맞습니다.


override를 섞을 때 가장 많이 헷갈리는 부분

Kotlin 공식 문서가 강조하는 중요한 포인트가 하나 있습니다.

delegate 객체 내부에서 자기 메서드를 호출할 때는, 바깥 래퍼에서 override한 구현이 자동으로 다시 끼어들지 않습니다.

이 부분이 처음에는 꽤 헷갈립니다.

interface Base {
    val message: String
    fun print()
}

class BaseImpl(x: Int) : Base {
    override val message = "BaseImpl: x = $x"

    override fun print() {
        println(message)
    }
}

class Derived(b: Base) : Base by b {
    override val message: String = "Message of Derived"
}

Derived.print()를 호출해도 BaseImpl.print() 안에서는 BaseImpl 자신의 message를 봅니다.

즉 “밖에서 override했으니 delegate 내부 호출도 바뀌겠지”라고 기대하면 틀릴 수 있습니다.

이 점은 delegation을 마법처럼 생각하지 않게 해 주는 중요한 경계입니다.


delegation이 설계를 대신해 주는 것은 아니다

by 문법이 편리하다고 해서 자동으로 좋은 구조가 되는 것은 아닙니다.

예를 들어 아래 질문은 여전히 사람이 판단해야 합니다.

  • 정말 위임이 맞는 상황인가
  • 인터페이스 경계가 자연스러운가
  • 래퍼가 너무 많은 책임을 먹고 있지 않은가
  • 단순 전달만 하는 껍데기가 과하게 늘어나고 있지 않은가

delegation은 좋은 구조를 더 짧게 적는 도구이지, 구조 판단 자체를 대신해 주는 도구는 아닙니다.


상속, 조합, delegation을 어떻게 구분하면 좋을까

실무에서는 이렇게 생각하면 판단이 빨라집니다.

상속이 더 자연스러운 경우

  • 진짜로 하위 타입 관계가 분명할 때
  • 부모 타입의 개념을 자식이 그대로 이어받을 때
  • 행위 확장이 아니라 타입 분류가 중심일 때

조합이 더 자연스러운 경우

  • 기능을 붙였다 뗐다 해야 할 때
  • 역할 교체 가능성이 있을 때
  • 구현 재사용은 필요하지만 계층 상속은 어색할 때

delegation이 특히 좋은 경우

  • 조합을 쓰기로 이미 결정했는데 전달 메서드가 너무 많을 때
  • 인터페이스를 그대로 노출하면서 일부 동작만 바꾸고 싶을 때
  • 래퍼 객체를 여러 개 붙이는 구조를 Kotlin답게 간결하게 쓰고 싶을 때

작은 실전 예시로 감각 잡기

저장소 인터페이스가 있다고 해보겠습니다.

interface UserRepository {
    fun findById(id: Long): String?
    fun save(name: String)
}

class DefaultUserRepository : UserRepository {
    private val store = mutableMapOf<Long, String>()

    override fun findById(id: Long): String? = store[id]

    override fun save(name: String) {
        store[store.size.toLong() + 1] = name
    }
}

class LoggingUserRepository(
    private val inner: UserRepository,
) : UserRepository by inner {
    override fun findById(id: Long): String? {
        println("findById: $id")
        return inner.findById(id)
    }
}

여기서 save()는 기본 동작을 그대로 쓰고, findById()만 가볍게 감시하고 싶다면 delegation이 아주 잘 맞습니다.

직접 모든 메서드를 포워딩할 필요가 없고, 독자도 “이 클래스는 저장소를 대체하는 새 종류라기보다 감싼 래퍼구나”라고 바로 읽을 수 있습니다.


언제 오히려 과할까

모든 상황에 delegation을 넣으면 오히려 복잡해질 수 있습니다.

특히 아래 경우는 과할 수 있습니다.

  • 인터페이스 메서드가 1~2개뿐이라 전달 코드가 거의 없을 때
  • 래퍼가 하나도 아니고 둘도 아니고 계속 중첩되어 디버깅이 불편해질 때
  • 실제로는 위임보다 명확한 별도 구현이 더 읽기 쉬울 때

짧게 말하면, delegation은 보일러플레이트가 문제일 때 강하고, 구조 자체가 불명확할 때는 해답이 아닙니다.


정리

코틀린 delegation을 이해할 때 가장 중요한 포인트는 세 가지입니다.

  1. by 문법은 인터페이스 구현 전달 코드를 컴파일러가 대신 만들어 준다
  2. 상속보다 위임이 읽기 쉬운 장면은 기능 덧붙이기와 조합 중심 확장이다
  3. delegate 내부 호출과 override의 경계는 자동으로 마법처럼 합쳐지지 않는다

그래서 delegation은 “상속을 없애는 코틀린 신기능”보다, 조합 중심 설계를 Kotlin답게 짧게 쓰게 해 주는 실용 도구로 이해하는 편이 훨씬 오래 남습니다.

같이 보면 좋은 글은 상속보다 조합이 더 나은 순간, 코틀린 Decorator 패턴, 코틀린 Bridge 패턴입니다.

delegation 호출 흐름 다이어그램
일부 override와 자동 전달이 어떻게 함께 동작하는지 보여주는 흐름도

관련 맥락으로는 상속보다 조합이 더 나은 순간, 코틀린 Decorator 패턴 글을 함께 보면 delegation의 위치가 더 또렷해집니다.


공식 참고 자료로는 Kotlin Delegation Documentation, Kotlin Interfaces Documentation, Refactoring.Guru – Decorator를 함께 보면 좋습니다.

함께보면 좋은 글