Kotlin의 위임 속성: lazy와 observable
1. 개요
Kotlin은 by 키워드를 사용하여 property의 읽고 쓰기 작업을 다른 객체에 위임할 수 있습니다. 이 메커니즘을 *위임 속성(delegated properties)*이라고 합니다. 표준 라이브러리에는 자주 사용되는 여러 delegate가 포함되어 있습니다. 가장 일반적인 것은 지연 초기화를 위한 lazy, 변경사항을 감시하기 위한 observable, 그리고 유효하지 않은 변경을 차단하기 위한 vetoable입니다.
이 글을 읽고 나면 각 delegate를 사용하는 방법과 lazy의 올바른 스레드 안정성 모드를 선택하는 방법을 알게 될 것입니다. 또한 이들에 대한 테스트를 작성하는 방법과 피해야 할 상황들을 알게 될 것입니다.
2. 설정
이 글의 delegate들은 kotlin-stdlib에 이미 포함되어 있으므로 추가 라이브러리를 설치할 필요가 없습니다. 테스트를 작성하려면 JUnit 5와 AssertJ를 추가하세요:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
Spring Boot 3.x를 사용하는 경우, spring-boot-starter-test에 이미 두 라이브러리가 모두 포함되어 있습니다.
3. 위임 속성이 작동하는 방식
val x: T by someDelegate를 작성하면, 컴파일러는 delegate를 보유할 숨겨진 필드를 생성합니다. x의 getter는 someDelegate.getValue(...)를 호출합니다. x가 var인 경우, setter는 setValue(...)를 호출합니다.
메커니즘을 명확히 보기 위해 간단한 delegate를 작성할 수 있습니다:
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class TrimmedString(private var value: String = "") : ReadWriteProperty<Any?, String> {
override fun getValue(thisRef: Any?, property: KProperty<*>): String = value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
this.value = value.trim()
}
}
class Customer {
var name: String by TrimmedString()
}
fun main() {
val customer = Customer()
customer.name = " Nguyễn Văn An "
println("[${customer.name}]")
}
각 부분에 대한 설명:
TrimmedString은ReadWriteProperty를 구현하며,getValue와setValue두 함수를 포함합니다.customer.name을 할당할 때마다 Kotlin은setValue를 호출하고, 문자열이trim()처리된 후 저장됩니다.property매개변수는 현재 접근 중인 property의 이름을 알려줍니다.observable은 이 매개변수를 사용하여 변경된 property를 알립니다.
[Nguyễn Văn An]
표준 라이브러리에 가장 일반적으로 사용되는 delegate들이 이미 있으므로 delegate를 직접 작성할 필요가 거의 없습니다.
4. by lazy로 지연 초기화
4.1. 기본 사용법
lazy는 람다를 받습니다. 람다는 property를 처음 읽을 때만 실행됩니다. 결과는 이후의 모든 읽기에 대해 캐시됩니다.
class ReportService {
val exchangeRates: Map<String, Double> by lazy {
println("환율 로딩 중...")
mapOf("USD" to 25_400.0, "EUR" to 27_300.0)
}
}
fun main() {
val service = ReportService()
println("Service 생성됨")
println(service.exchangeRates["USD"])
println(service.exchangeRates["EUR"])
}
결과:
Service 생성됨
환율 로딩 중...
25400.0
27300.0
주의할 세 가지 점이 있습니다:
ReportService를 생성해도 환율 로딩이 트리거되지 않습니다.- “환율 로딩 중…“은 property가 두 번 읽혔지만 한 번만 인쇄됩니다.
lazy는val에만 사용할 수 있습니다.var x by lazy { ... }는 컴파일 오류를 발생시킵니다. 왜냐하면Lazy에는setValue가 없기 때문입니다.
lazy는 생성 비용이 크지만 항상 필요하지는 않은 값에 적합합니다. 예를 들어: 복잡한 정규식, 파일에서 읽은 구성, 또는 외부 서비스를 호출하는 클라이언트.
4.2. 스레드 안정성 모드
기본적으로 lazy는 LazyThreadSafetyMode.SYNCHRONIZED를 사용합니다. 이 모드는 여러 스레드가 동시에 접근해도 람다가 정확히 한 번만 실행되도록 보장합니다. 필요에 따라 모드를 변경할 수 있습니다:
class PriceCalculator {
// 한 스레드만 initializer를 실행하고, 다른 스레드는 대기
val taxTable: Map<String, Double> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
mapOf("VN" to 0.1, "SG" to 0.09)
}
// 여러 스레드가 initializer를 실행할 수 있지만, 첫 번째 결과를 유지
val discountRules: List<String> by lazy(LazyThreadSafetyMode.PUBLICATION) {
listOf("BLACK_FRIDAY", "TET_SALE")
}
// 동기화 없음, 단일 스레드에서만 실행되는 경우에만 사용
val formatter: java.text.DecimalFormat by lazy(LazyThreadSafetyMode.NONE) {
java.text.DecimalFormat("#,###")
}
}
fun main() {
val calculator = PriceCalculator()
println(calculator.taxTable["VN"])
println(calculator.discountRules.first())
println(calculator.formatter.format(1_250_000))
}
0.1
BLACK_FRIDAY
1,250,000
다음 표에 따라 모드를 선택할 수 있습니다:
| 모드 | Initializer 실행 | 사용 시기 |
|---|---|---|
SYNCHRONIZED |
정확히 1번 | 기본값, 대부분의 경우에 안전 |
PUBLICATION |
여러 번 가능, 첫 번째 결과 유지 | Initializer 비용이 낮고 부작용이 없으며 락을 피하고 싶을 때 |
NONE |
보장되지 않음 | 코드가 단일 스레드에서만 실행됨 (UI 스레드, 내부 루프) |
주의:
NONE은 락이 없어서 약간 더 빠릅니다. Spring singleton bean에서 여러 요청이 동시에 접근하는 경우 initializer가 여러 번 실행될 수 있습니다. 이 경우 스레드가 서로 다른 인스턴스를 볼 수 있습니다. 측정하지 않고 “최적화”를 위해 모드를 변경하지 마세요.
4.3. Initializer가 예외를 던질 때
람다가 예외를 던지면 값이 캐시되지 않습니다. 다음 읽기는 람다를 다시 실행합니다:
class ConfigLoader {
var attempts = 0
val apiKey: String by lazy {
attempts++
if (attempts < 2) error("설정 파일을 읽을 수 없습니다")
"secret-key-123"
}
}
fun main() {
val loader = ConfigLoader()
runCatching { loader.apiKey }.onFailure { println("오류: ${it.message}") }
println(loader.apiKey)
println("시도 횟수: ${loader.attempts}")
}
오류: 설정 파일을 읽을 수 없습니다
secret-key-123
시도 횟수: 2
오류가 일시적일 때 이 동작은 유리합니다. 그러나 initializer의 비용이 매우 크고 오류가 반복되면 각 접근마다 그 비용이 다시 발생합니다.
4.4. 초기화 여부 확인
값이 이미 생성되었는지 확인해야 하면 Lazy 객체를 별도의 변수에 보유하고 그것으로 위임하세요:
class CacheHolder {
private val productCacheDelegate = lazy { mutableMapOf<String, Int>() }
val productCache: MutableMap<String, Int> by productCacheDelegate
fun isCacheReady(): Boolean = productCacheDelegate.isInitialized()
}
fun main() {
val holder = CacheHolder()
println(holder.isCacheReady())
holder.productCache["SKU-1"] = 10
println(holder.isCacheReady())
}
false
true
이 방법은 종료 시 리소스를 해제하고 싶을 때 유용합니다. 단, 리소스가 실제로 생성되었을 때만 해당됩니다.
4.5. lazy vs lateinit?
이 두 가지 방식은 자주 혼동됩니다:
lazy:val과 함께 사용됩니다. 값은 property 자체에서 계산되며 첫 번째 읽기에서 계산됩니다.lateinit:var과 non-null 타입과 함께 사용되며,Int와 같은 기본 타입에는 적용되지 않습니다. 값은 외부에서 나중에 할당되며, 예를 들어 의존성 주입 또는 테스트의@BeforeEach함수를 통해 할당됩니다.
class 내에서 값을 계산하는 방법을 알고 있으면 lazy를 사용하세요. 값이 외부에서 “전달된” 경우 lateinit를 사용하세요.
5. Delegates.observable로 변경 감시
Delegates.observable는 초기 값과 콜백을 받습니다. 콜백은 새로운 값이 할당된 후에 실행됩니다.
import kotlin.properties.Delegates
enum class OrderStatus { NEW, PAID, SHIPPED, DELIVERED }
class Order(val id: String) {
val history = mutableListOf<String>()
var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { property, old, new ->
history += "${property.name}: $old -> $new"
}
}
fun main() {
val order = Order("ORD-001")
order.status = OrderStatus.PAID
order.status = OrderStatus.SHIPPED
order.status = OrderStatus.SHIPPED
order.history.forEach(::println)
}
status: NEW -> PAID
status: PAID -> SHIPPED
status: SHIPPED -> SHIPPED
설명:
OrderStatus.NEW은 초기값입니다. 이 값을 할당해도 콜백이 트리거되지 않습니다.- 콜백은 세 가지 매개변수를 받습니다:
property(메타데이터),old(이전 값),new(새로운 값). - 세 번째 줄은 동일한 값을 다시 할당해도 콜백이 여전히 실행됨을 보여줍니다.
값이 실제로 변경될 때만 반응하려면 콜백 내에서 필터링하세요:
var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { _, old, new ->
if (old != new) history += "$old -> $new"
}
observable은 일반적으로 감사 로그 기록, 객체를 “dirty”로 표시하여 저장하거나, 파생된 값을 업데이트하는 데 사용됩니다.
6. Delegates.vetoable로 유효하지 않은 값 차단
vetoable은 observable과 유사하지만, 콜백은 할당 전에 실행되며 Boolean을 반환합니다. 콜백이 false를 반환하면 이전 값이 유지됩니다.
import kotlin.properties.Delegates
class CartItem(val sku: String) {
var quantity: Int by Delegates.vetoable(1) { _, _, new ->
new in 1..99
}
}
fun main() {
val item = CartItem("SKU-42")
item.quantity = 5
println(item.quantity)
item.quantity = 0
println(item.quantity)
item.quantity = 150
println(item.quantity)
}
5
5
5
0과 150 할당은 조용히 거부되며, quantity는 여전히 5입니다.
주의:
vetoable은 예외를 던지지 않으므로 호출 코드는 할당이 실패했는지 알 수 없습니다. 결제 금액과 같은 중요한 비즈니스 데이터의 경우require(...)를 사용하는 커스텀 setter를 사용하여 오류가 명확하게 노출되도록 해야 합니다.vetoable은 UI의 슬라이더에서 값 범위 제한처럼 잘못된 값을 무시하는 것이 원하는 동작인 경우에 적합합니다.
7. Delegate에 대한 테스트 작성
아래의 테스트 섹션은 세 가지 주요 동작을 확인합니다: lazy는 한 번만 초기화되고, observable은 히스토리를 기록하며, vetoable은 잘못된 값을 거부합니다.
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class DelegatedPropertiesTest {
class ExpensiveHolder(private val counter: AtomicInteger) {
val value: String by lazy {
counter.incrementAndGet()
Thread.sleep(50)
"ready"
}
}
@Test
fun `lazy는 여러 스레드가 동시에 접근해도 initializer를 한 번만 실행`() {
val counter = AtomicInteger()
val holder = ExpensiveHolder(counter)
val executor = Executors.newFixedThreadPool(8)
repeat(8) { executor.submit { holder.value } }
executor.shutdown()
executor.awaitTermination(5, TimeUnit.SECONDS)
assertThat(holder.value).isEqualTo("ready")
assertThat(counter.get()).isEqualTo(1)
}
@Test
fun `observable은 매번 상태 변경을 기록`() {
val order = Order("ORD-002")
order.status = OrderStatus.PAID
order.status = OrderStatus.DELIVERED
assertThat(order.history).containsExactly(
"status: NEW -> PAID",
"status: PAID -> DELIVERED"
)
}
@Test
fun `vetoable은 거부된 값에 대해 이전 값을 유지`() {
val item = CartItem("SKU-7")
item.quantity = 10
item.quantity = -3
assertThat(item.quantity).isEqualTo(10)
}
}
첫 번째 테스트는 8개의 스레드가 value를 동시에 읽습니다. 기본 SYNCHRONIZED 모드에서 counter는 항상 1입니다. LazyThreadSafetyMode.NONE으로 변경하고 여러 번 다시 실행하면 테스트가 때때로 실패합니다. 이는 모드 간의 차이를 빠르게 검증하는 방법입니다.
8. 일반적인 오류 및 사용하면 안 되는 상황
JPA 엔티티에서 delegate 사용. 컴파일러가 생성한 실제 필드는 status$delegate이며, 타입은 ObservableProperty이지 OrderStatus가 아닙니다. Hibernate는 이 타입의 필드를 자동으로 매핑할 수 없습니다. 엔티티의 경우 일반 필드와 커스텀 setter를 사용하거나 엔티티 리스너를 사용하세요.
lazy의 람다가 참조를 유지. 람다는 실행이 완료될 때까지 유지됩니다. property를 절대 읽지 않는 큰 객체를 캡처하면 그 객체는 여전히 메모리에 유지됩니다.
init 블록에서 lazy 너무 빨리 읽기. 람다가 아래에 선언된 property를 사용하고 init 내에서 lazy property를 읽으면, null 또는 0과 같은 초기화되지 않은 값을 받을 수 있습니다.
프레임워크의 메커니즘 대체. Spring에서 bean을 직접 만들거나 connection pool의 수명 주기를 직접 관리하기 위해 by lazy를 사용하지 마세요. 컨테이너가 리소스를 닫는 것을 관리할 수 있도록 @Lazy 또는 ObjectProvider를 사용하세요.
observable에서 무거운 부작용. 콜백은 setter 내에서 동기적으로 실행됩니다. 콜백 내에서 HTTP를 호출하거나 데이터베이스에 쓰면 간단한 할당이 느리고 오류가 발생하기 쉬워집니다. 즉시 무거운 작업을 하는 대신 이벤트를 발행하세요.
observable이 스레드 안전하다고 착각. observable과 vetoable은 동기화 메커니즘이 없습니다. 여러 스레드가 동시에 할당하면 콜백이 부정확한 old를 받을 수 있습니다.
9. 결론
위임 속성은 property 접근 로직을 클래스에서 분리하여 코드를 더 간결하고 읽기 쉽게 만들어줍니다. 생성 비용이 큰 값에는 by lazy를 사용하고 명확한 이유가 없으면 기본 SYNCHRONIZED 모드를 유지하세요. 변경사항에 반응해야 할 때는 Delegates.observable을 사용하고, 값이 변경되지 않아도 콜백이 실행된다는 점을 기억하세요. 잘못된 값을 조용히 무시하고 싶을 때는 Delegates.vetoable을 사용하고, 명확하게 오류를 보고해야 할 때는 setter에서 require로 전환하세요.
getValue와 setValue를 이해하면 문자열 정규화, Map에서 값 읽기, 접근 로깅 등 특정 요구사항에 맞는 delegate를 직접 작성할 수 있습니다. JPA 엔티티처럼 프레임워크가 실제 필드를 볼 필요가 있는 곳에서는 delegate를 사용하지 않도록 주의하면 됩니다.