위임 프로퍼티(Delegated Properties)
기본 개념
위임 프로퍼티는 by 키워드를 사용하여 프로퍼티의 접근 로직을 별도 객체에 위임한다.
class Example {
var property: String by Delegate()
}
위 코드에서 property에 접근할 때:
Compose에서의 실제 사용 예
@Composable
fun SearchResultScreen(
viewModel: SearchViewModel = hiltViewModel()
) {
// by 사용: State<T> → T로 자동 언래핑
val screenState by viewModel.screenState.collectAsState()
// = 사용: LazyPagingItems<T>를 직접 받음
val lazyPagingItems = viewModel.searchResultPagingFlow.collectAsLazyPagingItems()
val lazyListState = rememberLazyListState()
}
by를 사용하는 경우의 동작 방식
val screenState by viewModel.screenState.collectAsState()
// collectAsState()의 반환 타입
fun <T> StateFlow<T>.collectAsState(): State<T>
// State 인터페이스
interface State<out T> {
val value: T
}
// State는 getValue() 연산자를 가짐
operator fun <T> State<T>.getValue(
thisObj: Any?,
property: KProperty<*>
): T = this.value
동작 흐름:
collectAsState()는 State<SearchScreenState> 객체를 반환
by 키워드는 위임 객체의 getValue() 연산자를 자동으로 호출
screenState에 접근할 때마다 State.value가 자동으로 호출됨
결과: screenState의 타입은 SearchScreenState (State 래퍼가 벗겨짐)
// 위임 사용 시
val screenState by viewModel.screenState.collectAsState()
println(screenState) // SearchScreenState 타입, 직접 사용 가능
// 위임 미사용 시
val screenState = viewModel.screenState.collectAsState()
println(screenState.value) // State.value로 접근해야 함
by를 사용하지 않는 경우
val lazyPagingItems = viewModel.searchResultPagingFlow.collectAsLazyPagingItems()
// collectAsLazyPagingItems()의 반환 타입
fun <T : Any> Flow<PagingData<T>>.collectAsLazyPagingItems(): LazyPagingItems<T>
// LazyPagingItems는 getValue() 연산자가 없음 (위임 불가)
class LazyPagingItems<T : Any> {
operator fun get(index: Int): T?
val itemCount: Int
// getValue() 연산자 없음!
}
왜 by를 사용하지 않는가?
Compose에서 by를 사용하는 패턴
함수 | 반환 타입 | 사용법 | 실제 타입 |
|---|
collectAsState()
| State<T>
| by | T |
remember { mutableStateOf(초기값) }
| MutableState<T>
| by | T |
rememberSaveable { mutableStateOf(초기값) }
| MutableState<T>
| by | T |
produceState(초기값) { }
| State<T>
| by | T |
derivedStateOf { }
| State<T>
| by | T |
공통점: 모두 State<T> 또는 MutableState<T>를 반환하므로 getValue() 연산자가 존재
표준 라이브러리 위임 프로퍼티
Kotlin 표준 라이브러리는 여러 유용한 위임 프로퍼티를 제공한다.
lazy (지연 초기화)
프로퍼티가 처음 접근될 때만 초기화된다.
class DatabaseManager {
// 비용이 큰 초기화 작업을 지연
val database: Database by lazy {
println("데이터베이스 초기화 중...")
Database.connect("jdbc:postgresql://localhost/mydb")
}
fun query() {
database.execute("SELECT * FROM users") // 이 시점에 초기화
}
}
// 사용
val manager = DatabaseManager()
// 아직 데이터베이스 초기화 안 됨
manager.query() // "데이터베이스 초기화 중..." 출력 후 쿼리 실행
manager.query() // 초기화 없이 바로 쿼리 실행
lazy의 스레드 모드:
// SYNCHRONIZED (기본값): 스레드 안전, 하나의 스레드만 초기화
val prop1 by lazy { /* ... */ }
// PUBLICATION: 여러 스레드가 초기화할 수 있지만, 첫 번째 결과만 사용
val prop2 by lazy(LazyThreadSafetyMode.PUBLICATION) { /* ... */ }
// NONE: 스레드 안전성 없음, 단일 스레드에서만 사용
val prop3 by lazy(LazyThreadSafetyMode.NONE) { /* ... */ }
observable (변경 감지)
프로퍼티 값이 변경될 때마다 콜백이 호출된다.
class User {
var name: String by Delegates.observable("초기값") { property, oldValue, newValue ->
println("${property.name}: $oldValue → $newValue")
}
}
// 사용
val user = User()
user.name = "Alice" // 출력: name: 초기값 → Alice
user.name = "Bob" // 출력: name: Alice → Bob
실전 활용 예:
class SettingsViewModel : ViewModel() {
var isDarkMode: Boolean by Delegates.observable(false) { _, old, new ->
if (old != new) {
// 테마 변경 이벤트 발생
themeManager.applyTheme(if (new) Theme.Dark else Theme.Light)
analyticsLogger.logThemeChange(new)
}
}
}
vetoable (변경 검증)
프로퍼티 값 변경을 조건부로 허용/거부할 수 있다.
class Account {
var balance: Int by Delegates.vetoable(0) { _, oldValue, newValue ->
// 잔액이 음수가 되지 않도록 검증
newValue >= 0
}
}
// 사용
val account = Account()
account.balance = 100 // 성공
println(account.balance) // 100
account.balance = -50 // 거부됨 (false 반환)
println(account.balance) // 여전히 100
실전 활용 예:
class FormViewModel : ViewModel() {
var age: Int by Delegates.vetoable(0) { _, _, newValue ->
// 나이는 0~150 사이만 허용
newValue in 0..150
}
var email: String by Delegates.vetoable("") { _, _, newValue ->
// 이메일 형식 검증
newValue.matches(Regex("^[A-Za-z0-9+_.-]+@(.+)$"))
}
}
Map 위임
Map의 키-값을 프로퍼티로 접근할 수 있다.
class User(map: Map<String, Any?>) {
val name: String by map
val age: Int by map
val email: String by map
}
// 사용
val user = User(mapOf(
"name" to "Alice",
"age" to 30,
"email" to "alice@example.com"
))
println(user.name) // Alice
println(user.age) // 30
println(user.email) // alice@example.com
변경 가능한 Map:
class MutableUser(map: MutableMap<String, Any?>) {
var name: String by map
var age: Int by map
}
val map = mutableMapOf<String, Any?>()
val user = MutableUser(map)
user.name = "Bob"
user.age = 25
println(map) // {name=Bob, age=25}
실전 활용 - JSON 파싱:
class ApiResponse(json: Map<String, Any?>) {
val status: String by json
val message: String by json
val data: Map<String, Any?>? by json
}
커스텀 위임 프로퍼티 만들기
읽기 전용 프로퍼티
getValue() 연산자만 구현하면 된다.
class LoggingDelegate<T>(private val initialValue: T) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
println("${property.name} 읽기: $initialValue")
return initialValue
}
}
// 사용
class Example {
val name: String by LoggingDelegate("Kotlin")
}
val example = Example()
println(example.name)
// 출력:
// name 읽기: Kotlin
// Kotlin
읽기/쓰기 프로퍼티
getValue()와 setValue() 모두 구현한다.
class RangeDelegate(private var value: Int, private val range: IntRange) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): Int {
return value
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: Int) {
value = when {
newValue < range.first -> range.first
newValue > range.last -> range.last
else -> newValue
}
println("${property.name} = $value (범위: $range)")
}
}
// 사용
class Settings {
var volume: Int by RangeDelegate(50, 0..100)
var brightness: Int by RangeDelegate(75, 0..100)
}
val settings = Settings()
settings.volume = 120 // volume = 100 (범위: 0..100)
settings.volume = -10 // volume = 0 (범위: 0..100)
println(settings.volume) // 0
ReadWriteProperty 인터페이스 사용
class UpperCaseDelegate : ReadWriteProperty<Any?, String> {
private var value: String = ""
override fun getValue(thisRef: Any?, property: KProperty<*>): String {
return value
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
this.value = value.uppercase()
}
}
// 사용
class User {
var username: String by UpperCaseDelegate()
}
val user = User()
user.username = "alice"
println(user.username) // ALICE
클래스 위임(Class Delegation)
인터페이스 구현을 다른 객체에게 위임할 수 있다.
기본 사용법
interface Repository {
fun getData(): String
fun saveData(data: String)
}
class RepositoryImpl : Repository {
override fun getData(): String = "Data from Repository"
override fun saveData(data: String) {
println("Saving: $data")
}
}
// Repository 인터페이스 구현을 RepositoryImpl 인스턴스에 위임
class ViewModel(repository: Repository) : Repository by repository {
// Repository의 모든 메서드가 자동으로 위임됨
// 추가 로직만 구현
fun processData() {
val data = getData() // repository.getData() 호출
println("Processing: $data")
}
}
// 사용
val repository = RepositoryImpl()
val viewModel = ViewModel(repository)
viewModel.getData() // "Data from Repository"
viewModel.saveData("New") // "Saving: New"
viewModel.processData() // "Processing: Data from Repository"
메서드 오버라이드
위임을 사용하면서도 특정 메서드만 오버라이드할 수 있다.
class CachingViewModel(
private val repository: Repository
) : Repository by repository {
private val cache = mutableMapOf<String, String>()
// getData()만 오버라이드
override fun getData(): String {
return cache.getOrPut("data") {
println("캐시 미스 - Repository에서 가져오기")
repository.getData()
}
}
// saveData()는 위임된 그대로 사용
}
// 사용
val viewModel = CachingViewModel(RepositoryImpl())
viewModel.getData() // "캐시 미스 - Repository에서 가져오기" + "Data from Repository"
viewModel.getData() // 캐시에서 바로 반환, 출력 없음
실전 활용 - Decorator 패턴
interface Logger {
fun log(message: String)
}
class ConsoleLogger : Logger {
override fun log(message: String) {
println("[LOG] $message")
}
}
// 타임스탬프를 추가하는 데코레이터
class TimestampLogger(logger: Logger) : Logger by logger {
override fun log(message: String) {
val timestamp = System.currentTimeMillis()
logger.log("[$timestamp] $message")
}
}
// 레벨을 추가하는 데코레이터
class LevelLogger(
private val logger: Logger,
private val level: String
) : Logger by logger {
override fun log(message: String) {
logger.log("[$level] $message")
}
}
// 사용 - 여러 데코레이터 체이닝
val logger = LevelLogger(
TimestampLogger(ConsoleLogger()),
"INFO"
)
logger.log("Application started")
// 출력: [LOG] [INFO] [1234567890] Application started
실전 활용 패턴
ViewModel에서 PreferencesDataStore 위임
class PreferenceDelegate<T>(
private val dataStore: DataStore<Preferences>,
private val key: Preferences.Key<T>,
private val defaultValue: T
) : ReadWriteProperty<Any?, T> {
private var cachedValue: T? = null
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
return cachedValue ?: defaultValue
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
cachedValue = value
CoroutineScope(Dispatchers.IO).launch {
dataStore.edit { preferences ->
preferences[key] = value
}
}
}
}
// 사용
class SettingsViewModel(private val dataStore: DataStore<Preferences>) : ViewModel() {
var isDarkMode: Boolean by PreferenceDelegate(
dataStore,
booleanPreferencesKey("dark_mode"),
false
)
var fontSize: Int by PreferenceDelegate(
dataStore,
intPreferencesKey("font_size"),
14
)
}
Lifecycle-aware 프로퍼티
class LifecycleAwareDelegate<T>(
private val lifecycle: Lifecycle,
private val initialValue: T
) : ReadWriteProperty<Any?, T> {
private var value: T = initialValue
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
throw IllegalStateException("Cannot access ${property.name} when destroyed")
}
return value
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
throw IllegalStateException("Cannot modify ${property.name} when destroyed")
}
this.value = value
}
}
// 사용
class MyFragment : Fragment() {
private var importantData: String by LifecycleAwareDelegate(
lifecycle,
""
)
}