
본 글은 Kotlin Intermediate : Properties 페이지를 번역한 내용입니다.
이 장에서는 코틀린의 프로퍼티가 내부적으로 어떻게 작동하는지를 더 깊이 살펴보고, 코드에서 이를 더 유연하게 활용할 수 있는 다른 방법들을 알아보겠습니다.
코틀린에서 프로퍼티(Property)는 값을 읽거나 변경할 수 있도록 하는 프로퍼티 접근자(Property Accessor) 즉, get(), set()라는 함수를 기본적으로 제공합니다.
이 함수들은 컴파일러가 빌드 시점에 자동으로 생성하기 떄문에, 코드상에서는 보이지 않습니다.
이때, 프로퍼티의 실제 값은 백킹 필드(Backing Field) 라는 내부 변수를 통해 저장되고 관리됩니다.
우리는 클래스 프로퍼티(Class Properties)를 선언할때 다음과 같이 작성합니다.
아래 코드에선 Contact 클래스가 선언 되었고, 내부 프로퍼티로 id, email, category가 존재합니다.
class Contact(val id: Int, val email: String) {
var category: String = ""
}
컴파일러 변환시 다음과 같은 코드로 변경됩니다.
class Contact(val id: Int, var email: String) {
val category: String = ""
get() = field
set(value) {
field = value
}
}
get() 함수는 category의 값을 반환합니다.set() 함수는 전달받은 매개변수 value를 field에 할당 합니다.field 값이 “친구”로 치환됩니다.💡 프로퍼티를 val로 선언 할경우 이는 읽기 전용 변수로,
set()함수를 제외한get()만 생성됩니다
프로퍼티의 값을 field로 접근을 하면,
get(), set() 함수 로직을 커스텀 할 때 무한 루프가 발생하는 상황을 최소화 할 수 있습니다.
아래는 예시 코드입니다.
class Person {
val name: String = ""
name 프로퍼티의 첫 글자가 대문자가 되어야 한다고 가정 하겠습니다.
이를 구현하기 위해 .replaceFirsrChar()과 .uppercase()확장 함수를 사용하는 set()함수를 작성합니다.
하지만 set() 함수 내부에서 해당 프로퍼티를 직접 참조하면, 자신이 자신을 호출하는 무한루프 상태가 되며 StackOverflowError 오류가 발생합니다.
class Person {
var name: String = ""
set(value) {
// This causes a runtime error
name = value.replaceFirstChar { firstChar -> firstChar.uppercase() }
}
}
fun main() {
val person = Person()
person.name = "kodee"
println(person.name)
// Exception in thread "main" java.lang.StackOverflowError
}
Backing field를 사용하여 set() 함수가 프로퍼티 자체가 아니라 그 프로퍼티의 field를 참조 하도록 변경하면, 오류가 해결 됩니다.
class Person {
var name: String = ""
set(value) {
field = value.replaceFirstChar { firstChar -> firstChar.uppercase() }
}
}
fun main() {
val person = Person()
person.name = "kodee"
println(person.name)
// Kodee
}
Backing field는 프로퍼티의 값을 로깅 하거나 값이 변경되었을때 특정 로직 실행 혹은 이전 값과 현재 값을 비교하는 등 다양한 상황에서 사용 할 수 있습니다.
이전에 배웠던 확장 함수(Extension function)과 같이 클래스에서도 확장(Extension)이란 개념이 존재합니다.
확장 프로퍼티(Extension properties)는 클래스의 원본 코드를 건들이지 않고도 프로퍼티를 추가할 수 있습니다.
그러나 확장 프로퍼티로 프로퍼티를 정의할 경우, 코틀린에선 해당 프로퍼티에 대해 Backing field를 제공하지 않습니다.
이말인즉슨 확장 프로퍼티를 사용할 땐 get() set() 함수를 직접 작성해야 합니다.
추가로 Backing field가 존재 하지 않는다는건, 프로퍼티가 값을 저장하거나 반환 할 수 있는 능력이 없다는 뜻입니다.
확장 프로퍼티는 다음과 같이 작성할 수 있습니다.
val String.lastChar: Char
확장 프로퍼티는 상속을 사용하지 않고도 계산된 값을 프로퍼티 형태로 제공(Computed Property)하고 싶을 때 가장 유용합니다.
확장 프로퍼티는 매개변수가 하나뿐인 함수, 즉 수신 객체(receiver) 를 인자로 받는 함수처럼 동작한다고 생각할 수 있습니다.
val String.firstAndLast: String
get() = "${this.first()}-${this.last()}"예시로 firstName과 lastName을 프로퍼티로 가지고 있는 data class인 Person이 있다고 하겠습니다.
data class Person(val firstName: String, val lastName: String)
Person 데이터 클래스를 수정하거나 상속하지 않고도,
그 사람의 전체 이름(full name) 에 접근할 수 있도록 하고 싶다고 가정해봅시다.
이럴 때는 사용자 정의 get() 함수를 가진 확장 프로퍼티 를 만들어 해결할 수 있습니다.
data class Person(val firstName: String, val lastName: String)
// Extension property to get the full name
val Person.fullName: String
get() = "$firstName $lastName"
fun main() {
val person = Person(firstName = "John", lastName = "Doe")
// Use the extension property
println(person.fullName)
// John Doe
}
이미 존재하는 프로퍼티의 이름으로 확장 프로퍼티를 생성할 수 없습니다
위와 마찬가지로 Kotlin 표준 라이브러리는 확장 프로퍼티를 광범위하게 사용합니다.
자세한 내용은 CharSequence의 lastIndex를 확인하세요.
Classes and Interface 단원에서 이미 위임(Delegated)에 대해 배웠습니다.
프로퍼티에서도 위임을 사용할 수 있는데, 이 경우 프로퍼티 접근자(get/set 함수)의 동작을 다른 객체에 위임(대신 시킨다)하게 됩니다.
이 기능은 단순한 Backing field로는 처리하기 어려운 복잡한 요구사항이 있을때 유용합니다.
예를 들어, 값을 데이터베이스 테이블, 브라우저 세션, 또는 맵(Map)에 저장해야 하는 경우에 사용할 수 있습니다.
또한, 위임 프로퍼티를 사용하면 반복적인(Boilerplate) 코드를 줄일 수 있습니다.
프로퍼티의 get/set 로직이 위임된 객체에서 정의되므로, 기능이 분리되어 더 간결하고 재사용성이 높아집니다.
val displayName: String by Delegate
displayName은 Delegate 객체를 프로퍼티 접근자로 참조합니다
위임하는 모든 객체에는 getValue() 연산자 함수가 있어야 하며, Kotlin은 위임하려는 객체에 getValue() 함수가 존재하는지 검사합니다.
만약 프로퍼티가 변수(mutable)이면 setValue()도 포함해야 합니다.
기본적으로 getValue() 와 setValue()는 다음과 같이 구현합니다.
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {}
operater 는 이러한 함수를 연산자 함수로 표시하여 get() 및 set() 함수를 오버로드 할 수 있도록 합니다thisRef 는 위임된 프로퍼티가 속해있는 객체(Person 클래스 안에 name 프로퍼티가 있다고 가정하면 Person을 반환)를 반환합니다.property 는 값이 읽히거나 변경되는 프로퍼티 자체를 나타냅니다.기본적으로 getValue() 함수는 String을 반환하지만 필요에 따라 타입을 변경할 수 있습니다.
setValue() 함수에는 프로퍼티에 새롭게 들어가는 값을 받는데 사용되는 매개변수가 있습니다.
그러면 실제로 어떻게 보일까요? display name과 같은 계산된 속성(Computed property)을 원한다고 가정해 보겠습니다.
하지만 해당 내용이 계산하는데 비용이 크고, 앱이 성능에 민감하다면
이 값을 한 번만 계산해서 캐싱해두고, 이후에는 빠르게 접근할 수 있도록 구현할 것입니다.
이럴때 위임 프로퍼티를 사용해서 display name을 캐싱할 수 있습니다.
먼저, 위임에 사용할 객체를 생성해야 합니다.
이 예제에서 객체는 CachedStringDelegate 클래스의 인스턴스가 됩니다.
class CachedStringDelegate {
var cachedValue: String? = null
}
cachedValue 프로퍼티는 이름 그대로 캐시된 값을 저장합니다.
CachedStringDelegate 클래스 안에서는 이 프로퍼티의 동작을 커스터마이징 하기 위해, 위임된 속성의 get() 동작을 담당하는 getValue() 함수를 구현합니다.
class CachedStringDelegate {
var cachedValue: String? = null
operator fun getValue(thisRef: Any?, property: Any?): String {
if (cachedValue == null) {
cachedValue = "Default Value"
println("Computed and cached: $cachedValue")
} else {
println("Accessed from cache: $cachedValue")
}
return cachedValue ?: "Unknown"
}
}
getValue() 함수에서 cachedValue가 null인지 확인을 합니다.
만약 그렇다면 cachedValue에는 “Default Value” 값을 저장하고 해당 값을 로깅 목적으로 출력합니다.
반대로 cachedValue 프로퍼티에 이미 값이 존재한다면(즉, 이전에 계산된 값이 있으면), 그 값을 다른 메시지와 함께 출력합니다.
마지막으로 Elvis 연산자(?:)를 사용해 cachedValue를 반환하며, 값이 null일 경우 “Unkown”을 반환합니다.
이제 캐시하려는 프로퍼티(val displayName)을 CachedStringDelegate 클래스의 인스턴스로 위임할 수 있습니다.
class CachedStringDelegate {
var cachedValue: String? = null
operator fun getValue(thisRef: User, property: Any?): String {
if (cachedValue == null) {
cachedValue = "${thisRef.firstName} ${thisRef.lastName}"
println("Computed and cached: $cachedValue")
} else {
println("Accessed from cache: $cachedValue")
}
return cachedValue ?: "Unknown"
}
}
class User(val firstName: String, val lastName: String) {
val displayName: String by CachedStringDelegate()
}
fun main() {
val user = User("John", "Doe")
// First access computes and caches the value
println(user.displayName)
// Computed and cached: John Doe
// John Doe
// Subsequent accesses retrieve the value from cache
println(user.displayName)
// Accessed from cache: John Doe
// John Doe
}
getValue() 함수에서 thisRef 매개변수의 타입은 원래 Any?이지만, 위임된 대상이 User클래스 이므로 컴파일러가 이를 User 타입으로 추론합니다
이렇게 추론됨으로써 getValue() 함수 내부에서 User 클래스의 firstName 및 lastName 속성에 직접 접근할 수 있게 됩니다.
Kotlin 표준 라이브러리는 직접 위임 클래스를 만들지 않아도 사용할 수 있는 유용한 기본 위임 들을 제공합니다.
이러한 표준 위임을 사용하면 getValue()나 setValue() 함수를 직접 정의 할 필요가 없습니다.
프로퍼티를 처음 사용하는 시점에 값을 할당하고 싶으면 lazy properties 를 사용할 수 있습니다
표준 라이브러리에서 Lazy 라는 이름으로 제공합니다.
아직 사용하지 않는 변수에 값이 저장되어 있으면, 불필요한 메모리 사용이 발생합니다. Lazy 프로퍼티로 이를 해결할 수 있습니다.
Lazy 인터페이스의 인스턴스를 만드려면 lazy() 함수를 사용하고, 이 함수에 람다 표현식을 전달해야 합니다
이 람다는 get() 함수가 처음 호출될 때만 실행되며, 그 결과가 이후 호출에서도 같이 재사용(캐시) 됩니다.
class Database {
fun connect() {
println("Connecting to the database...")
}
fun query(sql: String): List<String> {
return listOf("Data1", "Data2", "Data3")
}
}
val databaseConnection: Database by lazy {
val db = Database()
db.connect()
db
}
fun fetchData() {
val data = databaseConnection.query("SELECT * FROM data")
println("Data: $data")
}
fun main() {
// First time accessing databaseConnection
fetchData()
// Connecting to the database...
// Data: [Data1, Data2, Data3]
// Subsequent access uses the existing connection
fetchData()
// Data: [Data1, Data2, Data3]
}
프로퍼티 값의 변화를 감지하려면 observable property를 사용합니다.
이는 프로퍼티 값이 변경될 때 이를 감지하고 특정 동작을 실행하고 싶을 때 유용합니다.
Kotlin 표준 라이브러리는 이러한 기능을 위해 Delegates 객체를 제공합니다.
observable 프로퍼티를 만들기 위해서는 먼저 다음을 import 해야 합니다.
import kotlin.properties.Delegates.observable
그런 다음 observable() 함수를 사용하고, 프로퍼티 값이 변경될 때마다 실행할 람다 표현식을 전달합니다.
import kotlin.properties.Delegates.observable
class Thermostat {
var temperature: Double by observable(20.0) { _, old, new ->
if (new > 25) {
println("Warning: Temperature is too high! ($old°C -> $new°C)")
} else {
println("Temperature updated: $old°C -> $new°C")
}
}
}
fun main() {
val thermostat = Thermostat()
thermostat.temperature = 22.5
// Temperature updated: 20.0°C -> 22.5°C
thermostat.temperature = 27.0
// Warning: Temperature is too high! (22.5°C -> 27.0°C)
}
observable 프로퍼티는 로깅 및 디버깅 목적뿐만 아니라 UI 업데이트나 데이터 유효성 검사와 같은 로직에서도 사용할 수 있습니다