한 줄 정의
코틀린 프로퍼티는 필드가 아니라 접근자(accessor)이므로 계산을 숨길 수 있지만, 상태를 읽고 쓰는 데만 쓰고 동작은 함수로 표현해야 합니다.
쉽게 말하면
자동차 계기판의 속도계는 보는 순간 현재 속도를 보여 줄 뿐이고, 들여다본다고 엔진이 돌거나 기름이 줄지는 않습니다.
반면 “목적지까지 경로 탐색” 버튼은 누르면 시간이 걸리고, 결과가 매번 달라질 수 있으며, 실패할 수도 있습니다.
프로퍼티는 계기판이어야 하고, 버튼처럼 동작하는 것은 함수로 만들어야 합니다.
왜 중요한가?
호출하는 쪽은 list.sum 처럼 생긴 코드를 보면 이미 계산된 값을 꺼낸다고 기대하지, 컬렉션 전체를 순회한다고 생각하지 않습니다.
프로퍼티 문법 뒤에 무거운 연산·예외·부수 효과가 숨어 있으면 반복문 안에서 무심코 호출하거나 예외 처리를 빠뜨리는 식으로 겉모습과 실제 비용이 어긋나는 버그 가 생깁니다.
핵심 내용
프로퍼티는 필드가 아니라 접근자입니다
자바 필드는 데이터를 담는 칸이지만, 코틀린 프로퍼티는 개념적으로 게터(val)와 게터·세터(var)의 묶음입니다.
데이터가 필요하면 백킹 필드(backing field) 를 field 식별자로 참조하고, 접근자가 field 를 쓰지 않으면 필드 자체가 생성되지 않습니다.
var name: String? = null
get() = field?.toUpperCase()
set(value) {
if (!value.isNullOrBlank()) {
field = value
}
}
val fullName: String
get() = "$name $surname" // 필드 없이 계산만 하는 프로퍼티이 덕분에 프로퍼티는 기본적으로 캡슐화되어 있고, 다음과 같은 일이 가능합니다.
| 기능 | 예시 | 의미 |
|---|---|---|
| 저장 방식 교체 | date 가 millis 를 감싸도록 변경 | 외부 사용 코드를 건드리지 않고 내부 표현만 바꿉니다 |
| 인터페이스에 선언 | interface Person { val name: String } | ”게터가 있다”는 계약을 정의합니다 |
| 오버라이드 | override val theAnswer: Long = ... | 하위 클래스가 값을 다르게 제공합니다 |
| 위임 | val db: Database by lazy { connectToDb() } | 값 제공 방식을 다른 객체에 맡깁니다 |
| 확장 프로퍼티 | val Context.inflater: LayoutInflater | 게터 함수이므로 확장으로도 만들 수 있습니다 |
// 직렬화 문제 등으로 Date 를 직접 저장할 수 없게 된 경우
var date: Date
get() = Date(millis)
set(value) {
millis = value.time
}date 를 쓰는 코드는 그대로 둔 채 실제 데이터만 millis 로 옮겼다는 점이 핵심입니다.
그렇다고 동작을 프로퍼티로 만들면 안 됩니다
// 이렇게 하지 마세요!
val Tree<Int>.sum: Int
get() = when (this) {
is Leaf -> value
is Node -> left.sum + right.sum
}
// 함수로 표현합니다
fun Tree<Int>.sum(): Int = when (this) {
is Leaf -> value
is Node -> left.sum() + right.sum()
}sum 은 모든 요소를 순회하는 알고리즘이므로 상태가 아니라 동작이고, 표준 라이브러리도 (1..100).sum() 처럼 함수로 제공합니다.
판단 기준: 함수라면 이름이 get/set 으로 시작할까?
함수로 정의했을 때 getX·setX 라는 이름이 자연스럽지 않다면 프로퍼티가 되어서는 안 됩니다.
구체적으로 다음 경우에는 함수를 씁니다.
| 상황 | 함수를 써야 하는 이유 |
|---|---|
| 비용이 크거나 O(1)보다 복잡함 | 프로퍼티 접근은 공짜라고 기대합니다 |
| 예외를 던질 수 있음 | 게터·세터가 예외를 던지리라 예상하지 않습니다 |
| 비즈니스 로직 포함 (로깅, 리스너 알림, 바인딩 갱신 등) | 프로퍼티는 단순한 읽기·쓰기만 한다고 기대합니다 |
| 호출할 때마다 결과가 다름 | 다른 스레드가 바꾸지 않는 한 같은 값이 나와야 합니다 |
타입 변환 (Int.toDouble()) | 변환은 메서드로 쓰는 관례가 있고, 프로퍼티는 내부 상태 일부처럼 읽힙니다 |
| 게터가 상태를 변경함 | 게터는 마음 놓고 여러 번 호출할 수 있어야 합니다 |
반대로, 상태에는 함수 대신 프로퍼티를 씁니다
// 이렇게 하지 마세요!
class UserIncorrect {
private var name: String = ""
fun getName() = name
fun setName(name: String) { this.name = name }
}
class UserCorrect {
var name: String = ""
}자바식 getter/setter 함수는 코틀린에서 군더더기입니다.
나중에 검증·변환이 필요해지면 그때 사용자 정의 접근자를 붙이면 되므로, 미리 함수로 감쌀 이유가 없습니다.
내 생각
- 도메인 엔티티의 계산 값은 비용으로 갈라 둡니다.
order.totalPrice처럼 이미 로드된 필드 몇 개를 합치는 건 프로퍼티로 충분하지만, 지연 로딩 컬렉션을 순회하거나 쿼리를 유발하는 계산은calculateTotalPrice()처럼 함수로 둬야 N+1 같은 비용이 호출부에 드러납니다. - JPA 엔티티의 커스텀 게터에는 로직을 넣지 않습니다. Jackson 직렬화·Hibernate 프록시가 게터를 마음대로 여러 번 호출하므로, 게터에 부수 효과나 예외가 있으면 디버깅하기 어려운 문제로 번집니다.
by lazy는 “한 번 계산 후 상태”일 때만 씁니다. 첫 접근에 DB 연결처럼 실패할 수 있는 작업이 숨는 것은 이 아이템의 경계선에 있는 사례이므로, 초기화 실패를 어디서 처리할지 먼저 정해 둡니다.
관련 개념
- 아이템 11 연산자의 의미는 함수의 이름과 일치해야 한다 — 문법이 주는 기대(연산자·프로퍼티)와 실제 동작이 일치해야 한다는 같은 원칙입니다