위임 패턴 — by 키워드로 상속 없이 기능을 재사용하는 방법
"상속은 강력하지만, 클래스 하나를 확장하기 위해 전체 계층을 끌어와야 할 때가 있습니다. 상속 없이 기능을 재사용하는 방법은 없을까요?"
코틀린의 by 키워드는 위임(Delegation) 패턴을 언어 차원에서 지원합니다. 보일러플레이트 없이 조합(Composition)을 구현할 수 있어서, "상속보다 조합을 선호하라"는 원칙을 자연스럽게 따를 수 있습니다.
클래스 위임 — by로 인터페이스 구현을 넘기기
클래스 위임은 인터페이스의 구현을 다른 객체에게 위임하는 패턴입니다.
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의 메서드를 직접 호출 가능
}
}
UserService는 Logger를 상속하지 않고, 생성자로 받은 logger 객체에게 구현을 맡깁니다. 컴파일러가 내부적으로 log()와 error()를 위임 객체로 전달하는 코드를 생성합니다.
일부 메서드만 오버라이드하기
위임하면서도 특정 메서드는 직접 구현할 수 있습니다.
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 — 지연 초기화
가장 많이 사용하는 프로퍼티 위임입니다.
class DatabaseConnection {
// 처음 접근할 때 한 번만 초기화
val connection by lazy {
println("DB 연결 생성")
DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb")
}
}
by lazy의 스레드 안전 모드는 세 가지입니다.
SYNCHRONIZED(기본값): 하나의 스레드만 초기화, 나머지는 대기PUBLICATION: 여러 스레드가 동시에 초기화 시도 가능, 먼저 완료된 값 사용NONE: 스레드 안전 보장 없음 — 단일 스레드 환경에서 사용
// 단일 스레드 환경이라면 NONE으로 오버헤드 제거
val config by lazy(LazyThreadSafetyMode.NONE) {
loadConfig()
}
Delegates.observable — 변경 감지
프로퍼티 값이 변경될 때마다 콜백을 받을 수 있습니다.
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를 반환하면 변경을 거부합니다.
var age: Int by Delegates.vetoable(0) { _, _, newValue ->
newValue >= 0 // 음수는 거부
}
age = 25 // 성공
age = -1 // 거부 — age는 여전히 25
커스텀 위임 만들기
직접 위임 클래스를 만들려면 ReadOnlyProperty 또는 ReadWriteProperty 인터페이스를 구현합니다.
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 연산자를 구현하면 위임 객체가 생성되는 시점에 검증 로직을 넣을 수 있습니다.
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의 키를 프로퍼티처럼 접근할 수 있습니다.
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 프로퍼티도 가능합니다.
class MutableUser(map: MutableMap<String, Any?>) {
var name: String by map
var age: Int by map
}
이 패턴은 JSON 응답을 파싱하거나, 동적 설정값을 다룰 때 유용합니다.
실무에서 위임을 사용하는 대표적인 패턴
1. 로깅 위임
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)
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-a | has-a |
| 유연성 | 컴파일 타임에 고정 | 런타임에 교체 가능 |
| 다중 구현 | 단일 상속만 가능 | 여러 인터페이스 위임 가능 |
| 내부 접근 | protected 멤버 접근 가능 | public 인터페이스만 접근 |
| 보일러플레이트 | 적음 | by 키워드로 거의 없음 |
정리
- 클래스 위임:
by키워드로 인터페이스 구현을 다른 객체에 넘기며, 필요한 메서드만 오버라이드할 수 있습니다 - 프로퍼티 위임:
by lazy,Delegates.observable,Delegates.vetoable등으로 프로퍼티 동작을 커스터마이징합니다 - 커스텀 위임:
ReadWriteProperty를 구현하면 재사용 가능한 프로퍼티 로직을 만들 수 있습니다 - Map 위임: JSON이나 동적 데이터를 프로퍼티처럼 접근할 때 유용합니다
위임은 "상속보다 조합"이라는 원칙을 코틀린에서 가장 깔끔하게 구현하는 방법입니다. 보일러플레이트 없이 기능을 재사용할 수 있으니, 상속이 과하다고 느껴질 때 위임을 고려해보면 좋겠습니다.