본문으로 건너뛰기
AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
Kotlin 위임 패턴 개념을 표현한 일러스트
Kotlin

Kotlin 위임 패턴 완전 정복: by 키워드 실전 활용법

By AICosmus
2026년 07월 18일 14 Min Read
0

객체지향 프로그래밍을 배울 때 가장 먼저 익히는 개념이 ‘상속’입니다. 부모 클래스의 기능을 자식 클래스가 물려받아 재사용하는 방식이죠. 그런데 실무에서 프로젝트가 커지다 보면, 상속 계층이 깊어지면서 코드가 점점 뒤엉키는 경험을 한 번쯤 해보셨을 겁니다. 부모 클래스를 하나 수정했을 뿐인데 자식 클래스 서너 개가 동시에 깨지고, 다중 상속이 필요한 상황에서는 난감해지기도 합니다. 이런 문제를 해결하기 위해 GoF 디자인 패턴에서 제시하는 대안이 바로 위임 패턴이며, Kotlin은 이를 언어 차원에서 by 키워드로 지원합니다.

GoF(Gang of Four)의 디자인 패턴 책에서 이미 수십 년 전에 제시한 해법이 있습니다. 바로 “상속보다 합성을 선호하라(Favor composition over inheritance)”라는 원칙이죠. Java에서 이 원칙을 따르려면 인터페이스를 구현하고, 내부에 위임 객체를 두고, 모든 메서드를 하나하나 포워딩하는 보일러플레이트 코드를 작성해야 합니다. 메서드가 열 개면 열 줄, 스무 개면 스무 줄의 단순 반복 코드가 생깁니다.

Kotlin은 이 문제를 언어 차원에서 해결합니다. by라는 단 하나의 키워드로 클래스 위임과 프로퍼티 위임을 모두 지원하죠. 컴파일러가 보일러플레이트를 자동 생성해주기 때문에, 개발자는 진짜 중요한 비즈니스 로직에만 집중할 수 있습니다. 이번 글에서는 Kotlin 위임 패턴의 기본 개념부터 커스텀 위임 구현, 그리고 현업에서 바로 쓸 수 있는 실전 패턴까지 체계적으로 정리해보겠습니다.

Kotlin 클래스 위임 동작 흐름도 - 위임 패턴

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

기본 문법과 동작 원리

클래스 위임의 핵심은 간단합니다. 인터페이스를 구현할 때, 실제 구현을 다른 객체에게 위임하는 것이죠. Java에서는 모든 메서드를 직접 위임 코드로 채워야 하지만, Kotlin에서는 by 키워드 하나로 끝납니다.

먼저 간단한 예시를 살펴보겠습니다. 로깅 기능을 가진 인터페이스가 있다고 가정합니다.

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

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

이제 이 Logger를 기반으로 타임스탬프를 자동으로 붙여주는 향상된 로거를 만들고 싶습니다. Java 스타일이라면 이렇게 작성해야 합니다.

// Java 스타일 - 모든 메서드를 수동으로 위임
class TimestampLogger(private val delegate: Logger) : Logger {
    override fun log(message: String) {
        delegate.log("[${timestamp()}] $message")
    }
    override fun error(message: String) {
        delegate.error("[${timestamp()}] $message")
    }
    override fun debug(message: String) {
        delegate.debug("[${timestamp()}] $message")
    }
    private fun timestamp(): String = 
        java.time.LocalDateTime.now().toString()
}

메서드가 세 개뿐이라 그나마 낫지만, 인터페이스에 메서드가 열 개, 스무 개가 있다면 어떨까요? 위임 코드만으로도 한 화면을 가득 채울 것입니다. Kotlin에서는 이렇게 작성할 수 있습니다.

// Kotlin 클래스 위임 - 필요한 메서드만 오버라이드
class TimestampLogger(
    private val delegate: Logger
) : Logger by delegate {
    // log()와 error()만 커스터마이즈, debug()는 자동 위임
    override fun log(message: String) {
        delegate.log("[${timestamp()}] $message")
    }
    override fun error(message: String) {
        delegate.error("[${timestamp()}] $message")
    }
    private fun timestamp(): String = 
        java.time.LocalDateTime.now().toString()
}

Logger by delegate라고 선언하면, Logger 인터페이스의 모든 메서드 구현이 delegate 객체로 자동 전달됩니다. 개발자는 변경이 필요한 메서드만 override하면 됩니다. 위 코드에서 debug() 메서드는 별도의 코드 없이 delegate의 debug()가 그대로 호출됩니다.

컴파일러 레벨에서 실제로 어떤 일이 일어나는지 보면, Kotlin 컴파일러가 바이트코드 생성 시점에 위임 대상의 모든 인터페이스 메서드에 대한 포워딩 코드를 자동으로 만들어줍니다. 런타임 리플렉션이나 프록시가 아니라 컴파일 타임에 코드가 생성되므로, 성능 오버헤드가 전혀 없습니다.

데코레이터 패턴의 우아한 구현

클래스 위임이 빛을 발하는 대표적인 사례가 바로 데코레이터 패턴입니다. 기존 객체의 기능을 감싸서 새로운 기능을 덧붙이는 패턴이죠. 실무에서 자주 만나는 시나리오로 살펴보겠습니다.

데이터베이스 접근을 담당하는 Repository 인터페이스가 있고, 여기에 캐싱 기능을 추가하고 싶은 상황입니다.

interface UserRepository {
    fun findById(id: Long): User?
    fun findAll(): List<User>
    fun save(user: User): User
    fun delete(id: Long)
    fun count(): Long
    fun existsById(id: Long): Boolean
}

class DatabaseUserRepository : UserRepository {
    // 실제 DB 접근 구현...
    override fun findById(id: Long): User? = TODO()
    override fun findAll(): List<User> = TODO()
    override fun save(user: User): User = TODO()
    override fun delete(id: Long) = TODO()
    override fun count(): Long = TODO()
    override fun existsById(id: Long): Boolean = TODO()
}

캐싱 레이어를 추가할 때, 위임 없이는 여섯 개 메서드를 모두 수동으로 전달해야 합니다. by 키워드를 사용하면 캐싱이 필요한 조회 메서드만 오버라이드하면 됩니다.

class CachingUserRepository(
    private val delegate: UserRepository
) : UserRepository by delegate {
    
    private val cache = mutableMapOf<Long, User>()
    
    override fun findById(id: Long): User? {
        return cache.getOrPut(id) {
            delegate.findById(id) ?: return null
        }
    }
    
    override fun save(user: User): User {
        val saved = delegate.save(user)
        cache[saved.id] = saved  // 캐시 갱신
        return saved
    }
    
    override fun delete(id: Long) {
        delegate.delete(id)
        cache.remove(id)  // 캐시 무효화
    }
    // findAll(), count(), existsById()는 자동 위임
}

이 패턴의 장점은 관심사의 분리가 자연스럽게 이루어진다는 것입니다. DatabaseUserRepository는 데이터베이스 접근만 책임지고, CachingUserRepository는 캐시 관리만 책임집니다. 나중에 로깅을 추가하고 싶다면 LoggingUserRepository를 같은 방식으로 만들어 감싸면 됩니다.

// 위임을 체이닝하여 기능을 조합
val repository: UserRepository = 
    LoggingUserRepository(
        CachingUserRepository(
            DatabaseUserRepository()
        )
    )

상속으로는 이런 유연한 조합이 불가능합니다. 캐싱+로깅, 캐싱만, 로깅만 등 조합의 수만큼 클래스를 만들어야 하니까요. 위임 패턴은 이 조합을 런타임에 자유롭게 결정할 수 있게 해줍니다.

클래스 위임의 한계와 주의점

클래스 위임에도 알아둬야 할 제약이 있습니다. 가장 중요한 것은 인터페이스에 대해서만 위임이 가능하다는 점입니다. 추상 클래스나 일반 클래스에는 by 키워드를 사용할 수 없습니다. 이는 Kotlin이 의도적으로 설계한 것으로, 상속 계층의 복잡성을 인터페이스 수준에서 관리하겠다는 철학이 반영되어 있습니다.

또 하나 주의할 점은, 위임 객체가 생성자 파라미터로 전달되는 시점에 고정된다는 것입니다. 위임 대상을 나중에 교체하는 것은 기본적으로 지원되지 않습니다. 동적으로 위임 대상을 변경해야 한다면, 프로퍼티 위임이나 전략 패턴을 함께 활용하는 것이 적절합니다.

Kotlin 표준 프로퍼티 위임 4종 비교

프로퍼티 위임: 프로퍼티의 getter/setter를 위임하기

프로퍼티 위임의 개념

클래스 위임이 인터페이스 메서드의 구현을 위임하는 것이라면, 프로퍼티 위임은 프로퍼티의 getter(읽기)와 setter(쓰기) 동작을 다른 객체에게 위임하는 것입니다. 역시 by 키워드를 사용하지만, 적용 대상이 클래스 선언이 아니라 프로퍼티 선언입니다.

// 프로퍼티 위임의 기본 형태
val someValue: String by SomeDelegate()

이렇게 선언하면 someValue를 읽을 때(getter) SomeDelegate의 getValue() 메서드가 호출되고, var로 선언했다면 쓸 때(setter) setValue() 메서드가 호출됩니다. Kotlin 표준 라이브러리는 자주 사용되는 위임 패턴 몇 가지를 기본으로 제공합니다. 하나씩 살펴보겠습니다.

by lazy: 지연 초기화

가장 널리 사용되는 프로퍼티 위임이 바로 lazy입니다. 프로퍼티가 처음 접근될 때 한 번만 초기화하고, 이후에는 캐싱된 값을 반환합니다.

class DatabaseConnection {
    // 실제 접근 시점까지 연결을 미룸
    val connection: Connection by lazy {
        println("데이터베이스 연결 생성 중...")
        DriverManager.getConnection("jdbc:postgresql://localhost/mydb")
    }
}

fun main() {
    val db = DatabaseConnection()
    println("객체 생성 완료")  // 이 시점에는 연결 안 됨
    db.connection.use { conn ->  // 이 시점에 비로소 연결
        println("쿼리 실행")
    }
}

출력 결과를 보면 순서가 명확합니다. 객체가 생성될 때는 아무 일도 일어나지 않다가, connection 프로퍼티에 처음 접근하는 시점에 초기화 블록이 실행됩니다. 이미 한 번 초기화된 후에는 다시 접근해도 블록이 재실행되지 않고 저장된 값을 바로 돌려줍니다.

lazy는 기본적으로 스레드 안전(thread-safe)합니다. 내부적으로 동기화 잠금을 사용하기 때문이죠. 단일 스레드 환경에서만 사용한다면 동기화 오버헤드를 제거할 수 있습니다.

// 스레드 안전 모드 선택
val value1: String by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
    "기본값과 동일 - 스레드 안전"
}

val value2: String by lazy(LazyThreadSafetyMode.PUBLICATION) {
    "여러 스레드가 동시에 초기화할 수 있지만, 첫 번째 결과만 사용"
}

val value3: String by lazy(LazyThreadSafetyMode.NONE) {
    "동기화 없음 - 단일 스레드에서만 사용할 것"
}

SYNCHRONIZED는 기본값으로 하나의 스레드만 초기화 블록을 실행합니다. PUBLICATION은 여러 스레드가 동시에 초기화를 시도할 수 있지만 최종적으로 하나의 값만 채택됩니다. NONE은 동기화를 완전히 생략하므로 단일 스레드 환경에서 최고 성능을 냅니다. Android UI 스레드에서만 접근하는 뷰 바인딩 같은 경우 NONE이 적합합니다.

Delegates.observable과 vetoable: 변경 감시와 거부

observable은 프로퍼티 값이 변경될 때마다 콜백을 실행합니다. UI 상태 관리나 로깅에 매우 유용합니다.

import kotlin.properties.Delegates

class UserProfile {
    var name: String by Delegates.observable("미설정") { property, oldValue, newValue ->
        println("이름 변경: '$oldValue' → '$newValue'")
    }
    
    var age: Int by Delegates.observable(0) { _, old, new ->
        println("나이 변경: $old → $new")
        if (new >= 20) println("성인 사용자입니다.")
    }
}

fun main() {
    val profile = UserProfile()
    profile.name = "김철수"   // 이름 변경: '미설정' → '김철수'
    profile.name = "이영희"   // 이름 변경: '김철수' → '이영희'
    profile.age = 25          // 나이 변경: 0 → 25 / 성인 사용자입니다.
}

콜백의 세 파라미터는 각각 프로퍼티 메타데이터(KProperty), 이전 값, 새 값입니다. 프로퍼티 메타데이터를 사용하지 않을 때는 언더스코어(_)로 무시할 수 있습니다.

vetoable은 observable과 비슷하지만, 콜백이 Boolean을 반환하여 변경을 거부할 수 있다는 차이가 있습니다. false를 반환하면 값이 변경되지 않습니다.

class BankAccount {
    var balance: Long by Delegates.vetoable(0L) { _, _, newValue ->
        if (newValue < 0) {
            println("잔액은 음수가 될 수 없습니다. 변경 거부.")
            false  // 변경 거부
        } else {
            true   // 변경 허용
        }
    }
}

fun main() {
    val account = BankAccount()
    account.balance = 10000   // 성공: 0 → 10000
    account.balance = -500    // 거부: 잔액은 음수가 될 수 없습니다.
    println(account.balance)  // 10000 (변경되지 않음)
}

vetoable은 도메인 규칙에 의한 방어적 프로그래밍에 적합합니다. 복잡한 유효성 검사 로직을 프로퍼티 선언부에 깔끔하게 담을 수 있죠.

Map 위임: JSON 스타일 데이터 매핑

Kotlin에서는 Map을 프로퍼티 위임의 대상으로 직접 사용할 수 있습니다. 프로퍼티 이름이 Map의 키로 매핑되는 방식입니다. JSON 데이터나 설정 파일 파싱에 매우 편리합니다.

class ServerConfig(config: Map<String, Any?>) {
    val host: String by config
    val port: Int by config
    val maxConnections: Int by config
    val debugMode: Boolean by config
}

fun main() {
    val configMap = mapOf(
        "host" to "localhost",
        "port" to 8080,
        "maxConnections" to 100,
        "debugMode" to false
    )
    
    val config = ServerConfig(configMap)
    println("${config.host}:${config.port}")  // localhost:8080
    println("최대 연결: ${config.maxConnections}")  // 최대 연결: 100
}

val 프로퍼티는 Map에, var 프로퍼티는 MutableMap에 위임할 수 있습니다. Map에 해당 키가 없으면 NoSuchElementException이 발생하므로, 선택적 값이 필요하다면 nullable 타입으로 선언하거나 withDefault를 활용합니다.

// MutableMap으로 양방향 바인딩
class MutableConfig(config: MutableMap<String, Any?>) {
    var host: String by config
    var port: Int by config
}

fun main() {
    val map = mutableMapOf<String, Any?>(
        "host" to "localhost",
        "port" to 8080
    )
    val config = MutableConfig(map)
    
    config.port = 9090
    println(map["port"])  // 9090 - Map도 함께 변경됨!
}

MutableMap 위임에서 주목할 점은 프로퍼티를 수정하면 원본 Map도 함께 변경된다는 것입니다. 프로퍼티와 Map이 같은 데이터를 공유하는 양방향 바인딩이 됩니다.

커스텀 프로퍼티 위임 구현 구조

커스텀 프로퍼티 위임 만들기

위임 계약: getValue와 setValue

Kotlin의 프로퍼티 위임은 사실 매우 단순한 계약(convention)을 따릅니다. 위임 객체가 operator fun getValue()를 가지면 val 프로퍼티에, 추가로 operator fun setValue()를 가지면 var 프로퍼티에 사용할 수 있습니다.

import kotlin.reflect.KProperty

class SimpleDelegate {
    private var storedValue: String = "초기값"
    
    operator fun getValue(
        thisRef: Any?,        // 프로퍼티를 소유한 객체
        property: KProperty<*>  // 프로퍼티 메타데이터
    ): String {
        println("${property.name} 프로퍼티 읽기")
        return storedValue
    }
    
    operator fun setValue(
        thisRef: Any?,
        property: KProperty<*>,
        value: String           // 새로 설정할 값
    ) {
        println("${property.name} 프로퍼티 쓰기: '$storedValue' → '$value'")
        storedValue = value
    }
}

class Example {
    var text: String by SimpleDelegate()
}

fun main() {
    val ex = Example()
    println(ex.text)    // text 프로퍼티 읽기 / 초기값
    ex.text = "변경됨"   // text 프로퍼티 쓰기: '초기값' → '변경됨'
}

thisRef는 프로퍼티를 소유한 객체의 참조이고, property는 프로퍼티의 이름이나 어노테이션 등 메타정보를 담고 있습니다. 이 두 파라미터 덕분에 하나의 위임 클래스로 여러 프로퍼티에 범용적으로 사용할 수 있습니다.

Kotlin은 이 계약을 더 명확하게 표현할 수 있도록 ReadOnlyProperty와 ReadWriteProperty 인터페이스를 제공합니다.

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

class TrimmedString : ReadWriteProperty<Any?, String> {
    private var trimmedValue: String = ""
    
    override fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return trimmedValue
    }
    
    override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        trimmedValue = value.trim()  // 항상 앞뒤 공백 제거
    }
}

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

fun main() {
    val input = UserInput()
    input.username = "  김철수  "
    input.email = " [email protected] "
    println("'${input.username}'")  // '김철수'
    println("'${input.email}'")     // '[email protected]'
}

TrimmedString 위임은 문자열이 저장될 때 항상 앞뒤 공백을 제거합니다. 이런 종류의 데이터 정규화 로직을 프로퍼티 레벨에서 투명하게 처리할 수 있다는 것이 커스텀 위임의 강력함입니다.

실전 예제: 환경 변수 위임

서버 애플리케이션에서 환경 변수를 읽는 코드는 반복적이고 지루합니다. 커스텀 위임으로 이를 깔끔하게 추상화할 수 있습니다.

class EnvironmentVariable(
    private val key: String? = null,
    private val default: String? = null
) : ReadOnlyProperty<Any?, String> {
    
    override fun getValue(thisRef: Any?, property: KProperty<*>): String {
        val envKey = key ?: property.name.uppercase()
        return System.getenv(envKey)
            ?: default
            ?: throw IllegalStateException(
                "환경 변수 '$envKey'가 설정되지 않았습니다."
            )
    }
}

// 편의 함수
fun env(key: String? = null, default: String? = null) =
    EnvironmentVariable(key, default)

class AppConfig {
    // 프로퍼티 이름을 대문자로 변환하여 환경 변수 조회
    val databaseUrl: String by env("DATABASE_URL")
    val serverPort: String by env("SERVER_PORT", default = "8080")
    
    // 키를 생략하면 프로퍼티 이름 기반: "API_KEY"
    val apiKey: String by env()
}

이 패턴의 핵심은 선언적 설정입니다. 프로퍼티를 선언하는 것만으로 환경 변수 바인딩이 완성됩니다. 기본값 지정, 키 커스터마이즈, 누락 시 에러 처리가 모두 위임 객체 안에 캡슐화되어 있어 사용하는 쪽에서는 일반 프로퍼티처럼 접근하면 됩니다.

실전 예제: 로깅 프로퍼티 변경 추적

디버깅이나 감사(audit) 목적으로 특정 프로퍼티의 모든 변경 이력을 기록해야 하는 경우가 있습니다.

class Audited<T>(
    private var value: T,
    private val propertyName: String? = null
) : ReadWriteProperty<Any?, T> {
    
    private val history = mutableListOf<Pair<T, T>>()  // (old, new)
    
    override fun getValue(thisRef: Any?, property: KProperty<*>): T = value
    
    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        val name = propertyName ?: property.name
        val old = this.value
        this.value = value
        history.add(old to value)
        println("[AUDIT] $name: $old → $value (총 ${history.size}회 변경)")
    }
    
    fun getHistory(): List<Pair<T, T>> = history.toList()
}

fun <T> audited(initialValue: T, name: String? = null) = 
    Audited(initialValue, name)

class Order {
    var status: String by audited("CREATED")
    var amount: Long by audited(0L)
}

fun main() {
    val order = Order()
    order.status = "PAID"       // [AUDIT] status: CREATED → PAID (총 1회 변경)
    order.status = "SHIPPED"    // [AUDIT] status: PAID → SHIPPED (총 2회 변경)
    order.amount = 25000L       // [AUDIT] amount: 0 → 25000 (총 1회 변경)
}

Audited 위임은 변경 이력을 내부 리스트에 쌓으면서 동시에 콘솔에 로그를 남깁니다. 이 리스트를 getHistory()로 조회할 수 있으므로, 감사 로그나 디버깅 리포트를 만들 때 유용합니다. Delegates.observable과 비교하면 이력 저장이 추가된 버전이라고 볼 수 있습니다.

실전 프로젝트 활용 패턴

지금까지 기본 개념과 구현 방법을 살펴봤으니, 실제 프로젝트에서 바로 적용할 수 있는 실용적인 패턴을 모아보겠습니다.

패턴 1: 스레드 안전한 캐시 위임

API 호출 결과나 계산 비용이 큰 값을 일정 시간 동안 캐싱하고 싶을 때 사용하는 패턴입니다. by lazy는 영구 캐싱이지만, 여기서는 TTL(Time-To-Live) 기반의 자동 만료 캐시를 구현합니다.

import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty

class CachedValue<T>(
    private val ttlMillis: Long,
    private val loader: () -> T
) : ReadOnlyProperty<Any?, T> {
    
    @Volatile private var cachedValue: T? = null
    @Volatile private var lastLoadTime: Long = 0L
    
    override fun getValue(thisRef: Any?, property: KProperty<*>): T {
        val now = System.currentTimeMillis()
        if (cachedValue == null || now - lastLoadTime > ttlMillis) {
            synchronized(this) {
                // 이중 검사 잠금(Double-Checked Locking)
                if (cachedValue == null || now - lastLoadTime > ttlMillis) {
                    cachedValue = loader()
                    lastLoadTime = System.currentTimeMillis()
                }
            }
        }
        @Suppress("UNCHECKED_CAST")
        return cachedValue as T
    }
}

fun <T> cached(ttlMillis: Long, loader: () -> T) = 
    CachedValue(ttlMillis, loader)

class WeatherService {
    // 5분(300,000ms)마다 갱신
    val currentTemperature: Double by cached(300_000L) {
        println("날씨 API 호출...")
        fetchTemperatureFromApi()  // 실제 API 호출
    }
    
    private fun fetchTemperatureFromApi(): Double = 32.5
}

TTL이 만료되기 전에는 캐싱된 값을 반환하고, 만료 후 접근 시 자동으로 loader를 다시 실행합니다. @Volatile과 synchronized를 조합한 이중 검사 잠금으로 멀티스레드 환경에서도 안전합니다.

패턴 2: 늦은 초기화에 재할당이 필요한 경우

lateinit var는 한 번 초기화한 후 다시 할당할 수 있지만, 원시 타입에는 사용할 수 없습니다. 또한 초기화 여부를 안전하게 체크하려면 리플렉션 기반의 isInitialized가 필요합니다. 커스텀 위임으로 이를 더 깔끔하게 처리할 수 있습니다.

class LateInit<T : Any> : ReadWriteProperty<Any?, T> {
    private var value: T? = null
    
    val isInitialized: Boolean get() = value != null
    
    override fun getValue(thisRef: Any?, property: KProperty<*>): T {
        return value ?: throw UninitializedPropertyAccessException(
            "프로퍼티 '${property.name}'이(가) 아직 초기화되지 않았습니다."
        )
    }
    
    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        this.value = value
    }
}

fun <T : Any> lateInit() = LateInit<T>()

class PluginManager {
    private val _dbConnection = lateInit<DatabaseConnection>()
    var dbConnection: DatabaseConnection by _dbConnection
    
    fun initialize(config: Config) {
        if (!_dbConnection.isInitialized) {
            dbConnection = DatabaseConnection(config)
        }
    }
    
    fun isReady(): Boolean = _dbConnection.isInitialized
}

위임 객체의 참조를 별도 변수로 보관하면 isInitialized 같은 메타 기능에 접근할 수 있습니다. lateinit과 달리 원시 타입에도 사용 가능하고, 초기화 상태 체크를 위해 리플렉션에 의존할 필요가 없습니다.

패턴 3: 범위 제한 프로퍼티

특정 범위 안의 값만 허용하는 프로퍼티가 필요한 경우, 커스텀 위임으로 선언적이고 재사용 가능한 검증 로직을 만들 수 있습니다.

class Clamped(
    private val range: IntRange,
    initialValue: Int = range.first
) : ReadWriteProperty<Any?, Int> {
    
    private var value: Int = initialValue.coerceIn(range)
    
    override fun getValue(thisRef: Any?, property: KProperty<*>): Int = value
    
    override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
        this.value = value.coerceIn(range)
    }
}

fun clamped(range: IntRange, initial: Int = range.first) = 
    Clamped(range, initial)

class AudioPlayer {
    var volume: Int by clamped(0..100, initial = 50)
    var bass: Int by clamped(-10..10, initial = 0)
    var treble: Int by clamped(-10..10, initial = 0)
}

fun main() {
    val player = AudioPlayer()
    player.volume = 150     // 100으로 클램핑
    player.volume = -20     // 0으로 클램핑
    player.bass = 5         // 정상
    println("볼륨: ${player.volume}, 베이스: ${player.bass}")  // 볼륨: 0, 베이스: 5
}

coerceIn() 함수를 활용하여 범위를 벗어나는 값을 자동으로 경계값으로 보정합니다. 예외를 던지는 대신 자동 보정을 선택한 것은 오디오 볼륨 같은 UI 슬라이더 시나리오에 적합하기 때문입니다. 엄격한 검증이 필요하다면 vetoable 스타일로 예외를 던지도록 변형할 수 있습니다.

Kotlin 실전 위임 패턴 5가지 요약

패턴 4: 클래스 위임으로 컬렉션 기능 확장

List나 Set 같은 표준 컬렉션에 프로젝트 고유의 기능을 추가하고 싶을 때, 상속 대신 위임을 사용하면 훨씬 안전합니다. 표준 컬렉션 클래스의 내부 구현에 의존하지 않기 때문입니다.

class UniqueList<T>(
    private val inner: MutableList<T> = mutableListOf()
) : MutableList<T> by inner {
    
    // add 계열만 오버라이드하여 중복 방지
    override fun add(element: T): Boolean {
        if (element in inner) return false
        return inner.add(element)
    }
    
    override fun add(index: Int, element: T) {
        if (element !in inner) {
            inner.add(index, element)
        }
    }
    
    override fun addAll(elements: Collection<T>): Boolean {
        val newElements = elements.filter { it !in inner }
        return inner.addAll(newElements)
    }
}

fun main() {
    val tags = UniqueList<String>()
    tags.add("kotlin")
    tags.add("jvm")
    tags.add("kotlin")  // 무시됨
    println(tags)       // [kotlin, jvm]
    println(tags.size)  // 2
    
    // List의 다른 모든 기능은 자동 위임
    println(tags.indexOf("jvm"))  // 1
    tags.removeAt(0)
    println(tags)  // [jvm]
}

MutableList 인터페이스에는 수십 개의 메서드가 있지만, by inner로 모두 위임하고 중복 방지가 필요한 add 계열 메서드만 오버라이드했습니다. Set과 달리 삽입 순서가 보장되면서도 중복을 허용하지 않는 컬렉션이 간단하게 완성됩니다.

패턴 5: provideDelegate로 위임 생성 시점 제어

Kotlin 1.1부터 도입된 provideDelegate 연산자는 위임 객체가 프로퍼티에 바인딩되는 시점에 개입할 수 있게 해줍니다. 프로퍼티 이름 기반의 검증이나 초기 설정에 유용합니다.

class ConfigProperty(
    private val prefix: String
) {
    // provideDelegate: 프로퍼티에 바인딩될 때 호출
    operator fun provideDelegate(
        thisRef: Any?,
        property: KProperty<*>
    ): ReadOnlyProperty<Any?, String> {
        val fullKey = "$prefix.${property.name}"
        val value = System.getProperty(fullKey)
            ?: throw IllegalStateException(
                "필수 설정 '$fullKey'가 누락되었습니다."
            )
        // 검증 통과 후 실제 위임 객체 반환
        return ReadOnlyProperty { _, _ -> value }
    }
}

fun configPrefix(prefix: String) = ConfigProperty(prefix)

class DatabaseConfig {
    // 객체 생성 시점에 "db.host", "db.port" 시스템 프로퍼티 존재 여부 검증
    val host: String by configPrefix("db")
    val port: String by configPrefix("db")
}

provideDelegate가 없다면 프로퍼티에 실제로 접근하는 시점에야 에러가 발생합니다. provideDelegate를 사용하면 객체 생성 시점에 모든 필수 설정의 존재 여부를 검증할 수 있어, 빠른 실패(fail-fast) 원칙을 따를 수 있습니다.

위임 패턴의 주의사항과 베스트 프랙티스

성능 고려사항

클래스 위임은 컴파일 타임에 포워딩 코드가 생성되므로 런타임 오버헤드가 없습니다. 프로퍼티 위임도 마찬가지로 컴파일러가 위임 객체의 메서드 호출로 변환하기 때문에, 일반적인 메서드 호출 수준의 비용만 발생합니다.

다만 프로퍼티 위임에서는 위임 객체 자체가 힙에 할당되므로, 수만 개의 프로퍼티에 각각 위임 객체를 생성하면 메모리 사용량이 늘어날 수 있습니다. 대부분의 애플리케이션에서는 문제가 되지 않지만, 초고성능이 필요한 구간에서는 인라인 프로퍼티를 고려하세요.

디버깅 팁

위임을 사용하면 프로퍼티 접근 시 실제로 실행되는 코드가 위임 객체 안에 있기 때문에, 디버거에서 한 단계 더 들어가야 합니다. IntelliJ IDEA는 위임 프로퍼티를 인식하여 디버거에서 위임 객체의 현재 값을 바로 보여주므로 크게 불편하지 않습니다.

문제 진단이 어려울 때는 위임 객체의 getValue/setValue에 로깅을 추가하는 것이 가장 빠른 방법입니다. 앞서 만든 Audited 위임처럼 변경 이력을 자동으로 추적하는 위임을 디버깅용으로 임시 교체하는 것도 효과적입니다.

피해야 할 안티패턴

위임은 강력한 도구이지만, 남용하면 오히려 코드를 읽기 어렵게 만듭니다. 몇 가지 주의할 점을 정리합니다.

첫째, 단순한 프로퍼티에 위임을 사용하지 마세요. 아무런 특별한 동작 없이 값을 저장하고 읽기만 하는 프로퍼티에 커스텀 위임을 적용하면 복잡성만 높아집니다. 일반 val/var면 충분합니다.

둘째, 위임 체이닝을 과도하게 사용하지 마세요. 위임 안에 또 다른 위임을 넣고, 그 안에 또 위임을 넣는 식으로 깊어지면 디버깅이 매우 어려워집니다. 보통 한두 단계가 적절합니다.

셋째, 부수 효과(side effect)를 숨기지 마세요. 프로퍼티를 읽는 것만으로 네트워크 호출이 발생하거나 파일에 기록하는 등의 무거운 부수 효과가 있다면, 이를 명확하게 문서화하거나 메서드로 노출하는 것이 낫습니다. 프로퍼티 접근은 가볍고 예측 가능해야 한다는 기대를 깨뜨리면 안 됩니다.

정리: 위임 패턴으로 깔끔한 Kotlin 코드 만들기

지금까지 Kotlin의 위임 패턴을 클래스 위임과 프로퍼티 위임으로 나누어 살펴보았습니다. 핵심을 다시 정리하면 다음과 같습니다.

클래스 위임(by 키워드)은 인터페이스 구현을 다른 객체에 맡기면서, 필요한 부분만 선택적으로 오버라이드할 수 있게 해줍니다. 데코레이터 패턴을 간결하게 구현하고, 상속 없이 기능을 조합하는 데 탁월합니다.

프로퍼티 위임은 프로퍼티의 읽기/쓰기 동작을 위임 객체에 맡깁니다. 표준 라이브러리의 lazy, observable, vetoable, map 위임만으로도 많은 시나리오를 커버할 수 있고, getValue/setValue 계약을 구현하면 어떤 커스텀 동작이든 만들 수 있습니다.

위임 패턴의 진짜 가치는 관심사의 분리와 코드 재사용에 있습니다. 캐싱, 검증, 로깅, 설정 바인딩 같은 횡단 관심사를 프로퍼티나 클래스 선언부에 선언적으로 적용할 수 있고, 한번 만든 위임 클래스는 프로젝트 전체에서 재사용됩니다.

이번 글에서 소개한 패턴들을 실제 프로젝트에 하나씩 적용해보시기 바랍니다. by lazy부터 시작해서, 반복되는 보일러플레이트가 보일 때마다 “이걸 위임으로 추상화할 수 있을까?” 하고 자문해보면 자연스럽게 활용 감각이 생길 것입니다. 상속의 유혹을 이기고 합성의 우아함을 선택하는 순간, Kotlin다운 코드가 시작됩니다.


🔗 함께 보면 좋은 글

  • Kotlin Sealed Class 핵심 정리와 실전 활용 패턴
  • Kotlin DSL 만들기 실전 가이드: 빌더 패턴 완전 정복


참고 자료

  • Kotlin 공식 문서 — Delegation — Kotlin의 클래스 위임과 프로퍼티 위임에 대한 공식 레퍼런스
  • Wikipedia — Delegation pattern — 위임 패턴의 개념, 상속과의 차이, 구현 방식을 설명하는 백과사전 문서

Tags:

KotlinKotlin by 키워드디자인패턴위임패턴프로퍼티위임
작성자

AICosmus

Follow Me
다른 기사
AI 안전 가드레일과 평가 아키텍처 일러스트
Previous

[온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 13/14화: LLM 가드레일·평가 아키텍처 — Qwen3 온프레미스 안전 설계

opencode MCP 서버 연동 개념 일러스트
Next

[opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 9/12화: opencode MCP 서버 연동 — 외부 도구·데이터 확장 실전 가이드

댓글 없음! 첫 댓글을 남겨보세요.

답글 남기기 응답 취소

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

최신 글

  • [opencode 시즌 2 심화 — 나만의 도메인 특화 에이전트 만들기] 1/12화: opencode 에이전트 아키텍처 완전 해부 — 2026 Primary·Subagent 5계층 구조
  • Kotlin 코루틴 핵심 5가지 개념과 실전 활용법
  • [opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 12/12화: opencode 로컬 모델 완전 가이드 2026 — Ollama·에어갭·규제 환경 도입 체크리스트
  • LLM 파인튜닝 실전 5단계 — 2026 LoRA 완벽 가이드
  • 금융 앱 생체인증 작동 원리, 지문·얼굴 보안 5단계 완전 해부

최신 댓글

  1. [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 9/14화: 온프레미스 RAG 파이프라인 — bge-m3·Qdrant 자체 호스팅 실전의 Dockerfile 최적화 실전 가이드 — 빌드·크기·보안 총정리 - AICosmus
  2. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  3. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  4. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  5. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  • About
  • Contact
  • Disclaimer
  • Privacy - Policy
  • Terms of Service
Copyright 2026 — AICosmus. All rights reserved. Blogsy WordPress Theme