Theme:

"상속은 강력하지만, 클래스 하나를 확장하기 위해 전체 계층을 끌어와야 할 때가 있습니다. 상속 없이 기능을 재사용하는 방법은 없을까요?"

코틀린의 by 키워드는 위임(Delegation) 패턴을 언어 차원에서 지원합니다. 보일러플레이트 없이 조합(Composition)을 구현할 수 있어서, "상속보다 조합을 선호하라"는 원칙을 자연스럽게 따를 수 있습니다.

클래스 위임 — by로 인터페이스 구현을 넘기기

클래스 위임은 인터페이스의 구현을 다른 객체에게 위임하는 패턴입니다.

KOTLIN
interface Logger {
    fun log(message: String)
    fun error(message: String)
}

class ConsoleLogger : Logger {
    override fun log(message: String) = println("[LOG] $message")
    override fun error(message: String) = println("[ERROR] $message")
}

// ConsoleLogger에게 Logger 구현을 위임
class UserService(logger: Logger) : Logger by logger {
    fun createUser(name: String) {
        log("사용자 생성: $name") // Logger의 메서드를 직접 호출 가능
    }
}

UserServiceLogger를 상속하지 않고, 생성자로 받은 logger 객체에게 구현을 맡깁니다. 컴파일러가 내부적으로 log()error()를 위임 객체로 전달하는 코드를 생성합니다.

일부 메서드만 오버라이드하기

위임하면서도 특정 메서드는 직접 구현할 수 있습니다.

KOTLIN
class FilteredLogger(private val delegate: Logger) : Logger by delegate {
    // error만 직접 구현, log는 위임
    override fun error(message: String) {
        delegate.error("[FILTERED] $message")
        // 추가 로직: 알림 전송 등
    }
}

이 방식이 상속보다 유연한 이유는 위임 객체를 생성자에서 주입받기 때문에, 런타임에 다른 구현체로 교체할 수 있다는 점입니다.

프로퍼티 위임 — by로 프로퍼티 동작을 커스터마이징하기

프로퍼티 위임은 프로퍼티의 getter/setter 동작을 별도 객체에게 맡기는 기능입니다.

by lazy — 지연 초기화

가장 많이 사용하는 프로퍼티 위임입니다.

KOTLIN
class DatabaseConnection {
    // 처음 접근할 때 한 번만 초기화
    val connection by lazy {
        println("DB 연결 생성")
        DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb")
    }
}

by lazy의 스레드 안전 모드는 세 가지입니다.

  • SYNCHRONIZED (기본값): 하나의 스레드만 초기화, 나머지는 대기
  • PUBLICATION: 여러 스레드가 동시에 초기화 시도 가능, 먼저 완료된 값 사용
  • NONE: 스레드 안전 보장 없음 — 단일 스레드 환경에서 사용
KOTLIN
// 단일 스레드 환경이라면 NONE으로 오버헤드 제거
val config by lazy(LazyThreadSafetyMode.NONE) {
    loadConfig()
}

Delegates.observable — 변경 감지

프로퍼티 값이 변경될 때마다 콜백을 받을 수 있습니다.

KOTLIN
import kotlin.properties.Delegates

class User {
    var name: String by Delegates.observable("초기값") { property, oldValue, newValue ->
        println("${property.name}: $oldValue$newValue")
    }
}

val user = User()
user.name = "김코틀린" // name: 초기값 → 김코틀린

Delegates.vetoable — 변경 거부

observable과 비슷하지만, 콜백에서 false를 반환하면 변경을 거부합니다.

KOTLIN
var age: Int by Delegates.vetoable(0) { _, _, newValue ->
    newValue >= 0 // 음수는 거부
}

age = 25  // 성공
age = -1  // 거부 — age는 여전히 25

커스텀 위임 만들기

직접 위임 클래스를 만들려면 ReadOnlyProperty 또는 ReadWriteProperty 인터페이스를 구현합니다.

KOTLIN
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

// 값 변경 시 자동으로 trim 적용하는 위임
class TrimmedString(private var value: String = "") : ReadWriteProperty<Any?, String> {

    override fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return value
    }

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        this.value = value.trim()
    }
}

class Form {
    var username: String by TrimmedString()
    var email: String by TrimmedString()
}

val form = Form()
form.username = "  김코틀린  "
println(form.username) // "김코틀린" — 자동 trim

provideDelegate — 위임 생성 시점에 검증하기

provideDelegate 연산자를 구현하면 위임 객체가 생성되는 시점에 검증 로직을 넣을 수 있습니다.

KOTLIN
class ValidatedProperty(private val pattern: Regex) {
    operator fun provideDelegate(
        thisRef: Any?,
        property: KProperty<*>
    ): ReadWriteProperty<Any?, String> {
        // 프로퍼티 이름 검증 등
        println("${property.name} 프로퍼티에 위임 등록")
        return object : ReadWriteProperty<Any?, String> {
            private var value = ""
            override fun getValue(thisRef: Any?, property: KProperty<*>) = value
            override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
                require(value.matches(pattern)) { "패턴 불일치: $value" }
                this.value = value
            }
        }
    }
}

class Config {
    var port: String by ValidatedProperty(Regex("\\d+"))
}

Map 위임 — JSON 파싱에 유용한 패턴

코틀린의 Map을 프로퍼티 위임에 사용하면, Map의 키를 프로퍼티처럼 접근할 수 있습니다.

KOTLIN
class User(map: Map<String, Any?>) {
    val name: String by map
    val age: Int by map
}

val user = User(mapOf("name" to "김코틀린", "age" to 28))
println(user.name) // 김코틀린
println(user.age)  // 28

MutableMap을 사용하면 var 프로퍼티도 가능합니다.

KOTLIN
class MutableUser(map: MutableMap<String, Any?>) {
    var name: String by map
    var age: Int by map
}

이 패턴은 JSON 응답을 파싱하거나, 동적 설정값을 다룰 때 유용합니다.

실무에서 위임을 사용하는 대표적인 패턴

1. 로깅 위임

KOTLIN
interface HasLogger {
    val logger: org.slf4j.Logger
}

class LoggerDelegate : HasLogger {
    override val logger by lazy {
        org.slf4j.LoggerFactory.getLogger(this::class.java)
    }
}

class OrderService : HasLogger by LoggerDelegate() {
    fun placeOrder() {
        logger.info("주문 생성")
    }
}

2. SharedPreferences 위임 (Android)

KOTLIN
class PreferenceProperty<T>(
    private val prefs: SharedPreferences,
    private val key: String,
    private val defaultValue: T
) : ReadWriteProperty<Any?, T> {
    @Suppress("UNCHECKED_CAST")
    override fun getValue(thisRef: Any?, property: KProperty<*>): T {
        return prefs.all[key] as? T ?: defaultValue
    }

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        prefs.edit().apply {
            when (value) {
                is String -> putString(key, value)
                is Int -> putInt(key, value)
                is Boolean -> putBoolean(key, value)
                else -> throw IllegalArgumentException("지원하지 않는 타입")
            }
            apply()
        }
    }
}

클래스 위임 vs 상속 — 언제 무엇을 쓸까

기준상속클래스 위임
관계is-ahas-a
유연성컴파일 타임에 고정런타임에 교체 가능
다중 구현단일 상속만 가능여러 인터페이스 위임 가능
내부 접근protected 멤버 접근 가능public 인터페이스만 접근
보일러플레이트적음by 키워드로 거의 없음

정리

  • 클래스 위임: by 키워드로 인터페이스 구현을 다른 객체에 넘기며, 필요한 메서드만 오버라이드할 수 있습니다
  • 프로퍼티 위임: by lazy, Delegates.observable, Delegates.vetoable 등으로 프로퍼티 동작을 커스터마이징합니다
  • 커스텀 위임: ReadWriteProperty를 구현하면 재사용 가능한 프로퍼티 로직을 만들 수 있습니다
  • Map 위임: JSON이나 동적 데이터를 프로퍼티처럼 접근할 때 유용합니다

위임은 "상속보다 조합"이라는 원칙을 코틀린에서 가장 깔끔하게 구현하는 방법입니다. 보일러플레이트 없이 기능을 재사용할 수 있으니, 상속이 과하다고 느껴질 때 위임을 고려해보면 좋겠습니다.

댓글 로딩 중...