프로퍼티
프로퍼티 (Properties)
Kotlin의 프로퍼티는 데이터에 접근하거나 변경하는 함수를 직접 작성하지 않아도 데이터를 저장하고 관리할 수 있게 해 줘요. 프로퍼티는 클래스, 인터페이스, 객체, 컴패니언 객체에서 쓸 수 있고, 이런 구조 바깥에서 최상위 프로퍼티(top-level property)로도 선언할 수 있습니다.
모든 프로퍼티에는 이름, 타입, 그리고 자동으로 생성되는 get() 함수가 있어요. 이 함수를 **게터(getter)**라고 부르고, 게터로 프로퍼티 값을 읽을 수 있습니다. 프로퍼티가 가변(mutable)이라면 set() 함수, 즉 **세터(setter)**도 있어서 프로퍼티 값을 바꿀 수 있어요.
게터와 세터를 통틀어 접근자(accessor)라고 불러요.
출처: Kotlin 공식 문서
본문
프로퍼티 선언
프로퍼티는 가변(var) 또는 읽기 전용(val)일 수 있어요. .kt 파일에서 최상위 프로퍼티로 선언할 수도 있습니다. 최상위 프로퍼티는 패키지에 속한 전역 변수라고 생각하면 돼요.
// File: Constants.kt
package my.app
val pi = 3.14159
var counter = 0
클래스, 인터페이스, 객체 안에서도 프로퍼티를 선언할 수 있어요.
// Class with properties
class Address {
var name: String = "Holmes, Sherlock"
var street: String = "Baker"
var city: String = "London"
}
// Interface with a property
interface ContactInfo {
val email: String
}
// Object with properties
object Company {
var name: String = "Detective Inc."
val country: String = "UK"
}
// Class implementing the interface
class PersonContact : ContactInfo {
override val email: String = "[email protected]"
}
프로퍼티를 사용하려면 이름으로 참조하면 돼요.
class Address {
var name: String = "Holmes, Sherlock"
var street: String = "Baker"
var city: String = "London"
}
interface ContactInfo {
val email: String
}
object Company {
var name: String = "Detective Inc."
val country: String = "UK"
}
class PersonContact : ContactInfo {
override val email: String = "[email protected]"
}
//sampleStart
fun copyAddress(address: Address): Address {
val result = Address()
// Accesses properties in the result instance
result.name = address.name
result.street = address.street
result.city = address.city
return result
}
fun main() {
val sherlockAddress = Address()
val copy = copyAddress(sherlockAddress)
// Accesses properties in the copy instance
println("Copied address: ${copy.name}, ${copy.street}, ${copy.city}")
// Copied address: Holmes, Sherlock, Baker, London
// Accesses properties in the Company object
println("Company: ${Company.name} in ${Company.country}")
// Company: Detective Inc. in UK
val contact = PersonContact()
// Access properties in the contact instance
println("Email: ${contact.email}")
// Email: [email protected]
}
//sampleEnd
Kotlin에서는 코드를 안전하고 읽기 쉽게 유지하기 위해 프로퍼티를 선언할 때 초기화하는 것을 권장해요. 다만 특별한 경우에는 나중에 초기화할 수도 있습니다.
컴파일러가 초기화 값이나 게터의 반환 타입에서 프로퍼티 타입을 추론할 수 있다면 타입 선언은 생략할 수 있어요.
var initialized = 1 // The inferred type is Int
var allByDefault // ERROR: Property must be initialized.
커스텀 게터와 세터
기본적으로 Kotlin은 게터와 세터를 자동으로 생성해 줘요. 검증, 포맷팅, 다른 프로퍼티에 기반한 계산 같은 추가 로직이 필요할 때 직접 커스텀 접근자를 정의할 수 있습니다.
커스텀 게터는 프로퍼티에 접근할 때마다 실행돼요.
//sampleStart
class Rectangle(val width: Int, val height: Int) {
val area: Int
get() = this.width * this.height
}
//sampleEnd
fun main() {
val rectangle = Rectangle(3, 4)
println("Width=${rectangle.width}, height=${rectangle.height}, area=${rectangle.area}")
}
컴파일러가 게터에서 타입을 추론할 수 있다면 타입을 생략할 수 있어요.
val area get() = this.width * this.height
커스텀 세터는 초기화할 때를 제외하고 프로퍼티에 값을 할당할 때마다 실행돼요. 관례상 세터 파라미터 이름은 value지만, 다른 이름을 써도 됩니다.
class Point(var x: Int, var y: Int) {
var coordinates: String
get() = "$x,$y"
set(value) {
val parts = value.split(",")
x = parts[0].toInt()
y = parts[1].toInt()
}
}
fun main() {
val location = Point(1, 2)
println(location.coordinates)
// 1,2
location.coordinates = "10,20"
println("${location.x}, ${location.y}")
// 10, 20
}
가시성 변경 또는 애노테이션 추가
Kotlin에서는 기본 구현을 대체하지 않고도 접근자의 가시성을 바꾸거나 애노테이션을 추가할 수 있어요. 이런 변경을 본문 {} 안에서 할 필요가 없습니다.
접근자의 가시성을 바꾸려면 get 또는 set 키워드 앞에 수정자를 붙여요.
class BankAccount(initialBalance: Int) {
var balance: Int = initialBalance
// Only the class can modify the balance
private set
fun deposit(amount: Int) {
if (amount > 0) balance += amount
}
fun withdraw(amount: Int) {
if (amount > 0 && amount <= balance) balance -= amount
}
}
fun main() {
val account = BankAccount(100)
println("Initial balance: ${account.balance}")
// 100
account.deposit(50)
println("After deposit: ${account.balance}")
// 150
account.withdraw(70)
println("After withdrawal: ${account.balance}")
// 80
// account.balance = 1000
// Error: cannot assign because setter is private
}
접근자에 애노테이션을 달려면 get 또는 set 키워드 앞에 애노테이션을 붙여요.
// Defines an annotation that can be applied to a getter
@Target(AnnotationTarget.PROPERTY_GETTER)
annotation class Inject
class Service {
var dependency: String = "Default Service"
// Annotates the getter
@Inject get
}
fun main() {
val service = Service()
println(service.dependency)
// Default service
println(service::dependency.getter.annotations)
// [@Inject()]
println(service::dependency.setter.annotations)
// []
}
이 예시는 게터와 세터에 어떤 애노테이션이 붙어 있는지 보여주기 위해 리플렉션을 사용해요.
배킹 필드 (Backing fields)
값을 메모리에 저장해야 할 때 컴파일러는 프로퍼티를 위해 배킹 필드를 자동으로 생성해요.
예를 들어 기본 get()과 set() 함수를 사용하면 저장된 값을 읽고 쓰기 때문에 컴파일러가 배킹 필드를 만듭니다.
var count = 0
커스텀 get() 또는 set() 함수에서 field 키워드를 사용하면 배킹 필드에 접근할 수 있어요. 게터나 세터에 추가 로직을 넣거나, 프로퍼티가 바뀔 때 추가 동작을 트리거할 수 있죠.
이 예시에서 score 프로퍼티는 set() 함수 안에서 배킹 필드를 사용해서, 값을 갱신할 때 로그 이벤트도 함께 발생시켜요.
class Scoreboard {
var score: Int = 0
set(value) {
field = value
// Adds logging when updating the value
println("Score updated to $field")
}
}
fun main() {
val board = Scoreboard()
board.score = 10
// Score updated to 10
board.score = 20
// Score updated to 20
}
모든 프로퍼티가 배킹 필드를 기본으로 생성하는 건 아니에요. 필요 없을 수 있기 때문이죠. 예를 들어 isEmpty 프로퍼티는 접근할 때마다 size 프로퍼티에서 값을 계산하므로 배킹 필드가 없습니다.
val isEmpty: Boolean
get() = this.size == 0
명시적 배킹 필드
때로는 더 많은 유연성이 필요할 수 있어요. 예를 들어 프로퍼티를 내부에서는 수정할 수 있지만 외부에서는 수정할 수 없게 만들고 싶은 API가 있다고 해 볼게요. 그럴 때는 명시적 배킹 필드를 사용할 수 있습니다.
다음 예시에서 ShoppingCart 클래스는 쇼핑 카트에 담긴 모든 것을 나타내는 items 프로퍼티를 가져요. 클래스는 items 프로퍼티를 문자열의 읽기 전용 리스트로 노출하지만, 내부적으로는 명시적 배킹 필드를 가진 가변 리스트에 데이터를 저장합니다.
class ShoppingCart {
// Public read-only view with explicit backing field
val items: List<String>
field = mutableListOf()
fun addItem(item: String) {
items.add(item)
}
fun removeItem(item: String) {
items.remove(item)
}
}
fun main() {
val cart = ShoppingCart()
cart.addItem("Apple")
cart.addItem("Banana")
println(cart.items)
// [Apple, Banana]
cart.removeItem("Apple")
println(cart.items)
// [Banana]
}
이 예시에서 컴파일러는 mutableListOf() 호출에서 배킹 필드의 타입을 추론해요. 바로 MutableList<String>이죠. 배킹 필드의 타입을 명시적으로 선언할 수도 있습니다.
val items: List<String>
// Explicit backing field with explicit type
field: MutableList<String> = mutableListOf()
ShoppingCart 클래스의 예시에서 컴파일러는 items 프로퍼티를 MutableList<String> 타입으로 스마트 캐스트해요. 그래서 클래스는 add()와 remove() 함수를 통해 카트에 항목을 추가하고 제거할 수 있습니다. 클래스 바깥에서는 컴파일러가 공개 프로퍼티 타입인 List<String>을 사용하므로, API 사용자는 items 리스트에 뭐가 들어 있는지만 읽을 수 있어요.
제약 사항
명시적 배킹 필드를 사용하려면 그 프로퍼티와 배킹 필드 자체가 특정 규칙을 따라야 해요. 프로퍼티는 다음과 같은 경우에만 명시적 배킹 필드를 가질 수 있습니다.
추가로 배킹 필드 타입은 프로퍼티 타입의 하위 타입이어야 하고 private 가시성을 가져야 해요.
이런 제약을 피하고 싶다면 배킹 프로퍼티(backing property)를 대신 사용할 수 있어요.
배킹 프로퍼티
명시적 배킹 필드가 상황에 맞지 않는다면, 배킹 프로퍼티라는 코딩 패턴을 시도해 볼 수 있어요.
예를 들어 프로퍼티에 커스텀 게터가 필요하다면 이렇게 해요.
class UserDirectory {
private val _users = mutableListOf(
"sarah",
"mike",
"emma"
)
val users: List<String>
get() = _users.sorted()
fun addUser(username: String) {
_users.add(username)
}
}
fun main() {
val directory = UserDirectory()
directory.addUser("alex")
println(directory.users)
// [alex, emma, mike, sarah]
}
배킹 프로퍼티 이름을 지을 때는 앞에 밑줄을 붙여서 Kotlin 코딩 컨벤션을 따르세요.
이 예시에서 UserDirectory 클래스는 디렉터리에 있는 모든 사용자를 나열하는 읽기 전용 users 프로퍼티를 가져요. _users 변수는 실제 리스트를 담고 있는 private 배킹 프로퍼티입니다. 공개 users 프로퍼티의 게터는 반환하기 전에 항목을 정렬해요.
컴파일 타임 상수
읽기 전용 프로퍼티의 값이 컴파일 타임에 알려져 있다면, const 수정자로 컴파일 타임 상수로 표시할 수 있어요. 컴파일 타임 상수는 컴파일 타임에 인라인되어 각 참조가 실제 값으로 대체됩니다. 게터가 호출되지 않기 때문에 더 효율적으로 접근할 수 있어요.
// File: AppConfig.kt
package com.example
// Compile-time constant
const val MAX_LOGIN_ATTEMPTS = 3
컴파일 타임 상수는 다음 요구 사항을 충족해야 해요.
컴파일 타임 상수에도 배킹 필드가 있으므로 리플렉션으로 상호작용할 수 있습니다.
이런 프로퍼티는 애노테이션에서도 사용할 수 있어요.
const val SUBSYSTEM_DEPRECATED: String = "This subsystem is deprecated"
@Deprecated(SUBSYSTEM_DEPRECATED) fun processLegacyOrders() { ... }
늦은 초기화 프로퍼티와 변수
보통 프로퍼티는 생성자에서 초기화해야 해요. 하지만 항상 그게 편리한 건 아닙니다. 예를 들어 의존성 주입(dependency injection)을 통해 초기화하거나, 단위 테스트의 셋업 메서드 안에서 초기화할 수 있어요.
이런 상황을 처리하려면 프로퍼티에 lateinit 수정자를 붙이면 됩니다.
public class OrderServiceTest {
lateinit var orderService: OrderService
@SetUp fun setup() {
orderService = OrderService()
}
@Test fun processesOrderSuccessfully() {
// Calls orderService directly without checking for null
// or initialization
orderService.processOrder()
}
}
lateinit 수정자는 다음으로 선언된 var 프로퍼티에 사용할 수 있어요.
- 최상위 프로퍼티
- 지역 변수
- 클래스 본문 안의 프로퍼티
클래스 프로퍼티에는 다음 규칙이 적용돼요.
- 주 생성자(primary constructor)에는 선언할 수 없어요
- 커스텀 게터나 세터를 가질 수 없어요
모든 경우에 프로퍼티나 변수는 non-nullable이어야 하고 프리미티브 타입이 아니어야 해요.
lateinit 프로퍼티를 초기화하기 전에 접근하면, Kotlin은 접근 중인 초기화되지 않은 프로퍼티를 식별하는 특정 예외를 던져요.
class ReportGenerator {
lateinit var report: String
fun printReport() {
// Throws an exception as it's accessed before
// initialization
println(report)
}
}
fun main() {
val generator = ReportGenerator()
generator.printReport()
// Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property report has not been initialized
}
lateinit var가 이미 초기화되었는지 확인하려면 그 프로퍼티에 대한 참조에서 isInitialized 프로퍼티를 사용해요.
class WeatherStation {
lateinit var latestReading: String
fun printReading() {
// Checks whether the property is initialized
if (this::latestReading.isInitialized) {
println("Latest reading: $latestReading")
} else {
println("No reading available")
}
}
}
fun main() {
val station = WeatherStation()
station.printReading()
// No reading available
station.latestReading = "22°C, sunny"
station.printReading()
// Latest reading: 22°C, sunny
}
isInitialized는 코드에서 이미 해당 프로퍼티에 접근할 수 있을 때만 사용할 수 있어요. 프로퍼티는 같은 클래스, 바깥 클래스, 또는 같은 파일의 최상위 프로퍼티로 선언되어 있어야 합니다.
프로퍼티 오버라이딩
Overriding properties 문서를 참고하세요.
위임 프로퍼티
로직을 재사용하고 코드 중복을 줄이기 위해, 프로퍼티의 값을 얻고 설정하는 책임을 별도의 객체에 위임할 수 있어요.
접근자 동작을 위임하면 프로퍼티의 접근자 로직이 중앙에 모여 재사용하기 쉬워져요. 이 접근 방식은 다음과 같은 동작을 구현할 때 유용합니다.
- 값을 게으르게 계산하기
- 주어진 키로 맵에서 읽기
- 데이터베이스에 접근하기
- 프로퍼티에 접근할 때 리스너에 알리기
이런 공통 동작은 라이브러리에서 직접 구현하거나, 외부 라이브러리가 제공하는 기존 위임자를 사용할 수 있어요. 자세한 내용은 위임 프로퍼티 문서를 참고하세요.
더 알아보기 (Learn more)
- Kotlin 공식 문서의 Properties 페이지
- Delegated properties · Visibility modifiers · Reflection
- Classes · Interfaces