[Kotlin] 프로퍼티와 Backing Field

Kotlin 프로퍼티의 backing field 개념과 field 키워드의 역할을 정리하고, 커스텀 getter에서 field를 참조하지 않아 발생한 실전 버그 사례를 분석한다.

1. 프로퍼티란 — Java 필드와의 차이

Java

1
2
3
4
5
public class Person {
private String name; // 필드
public String getName() { return name; } // getter
public void setName(String n) { name = n; } // setter
}

Kotlin

1
2
3
4
5
6
7
class Person {
var name: String = "" // 필드 + getter + setter가 한 줄에
}

val p = Person()
p.name = "Alice" // 내부적으로 setter 호출
val s = p.name // 내부적으로 getter 호출

핵심
Kotlin의 var name은 겉보기엔 필드 같지만 실제로는 getter/setter를 포함한 접근 단위다. p.name은 직접 필드 접근이 아니라 함수 호출이다.

2. 프로퍼티의 3가지 구성요소

컴파일러는 var name: String = ""를 이렇게 풀어쓴다.

1
2
3
var name: String = ""
get() { return field } // 자동 생성 getter
set(value) { field = value } // 자동 생성 setter
구성요소 역할 접근 방법
backing field (field) 실제 값이 저장되는 숨겨진 메모리 공간 접근자 내부에서만 field 키워드로
getter (get()) 값을 반환. 생략 시 get() = field 자동 생성 p.name (읽기)
setter (set(value)) 값을 저장. 생략 시 set(v) { field = v } 자동 생성 p.name = "x" (쓰기)

구조도

1
2
3
4
5
6
7
8
외부 코드
↓ p.name (읽기) ↑ p.name = "x" (쓰기)
┌──────────────────────────────────────────┐
│ [getter] [setter] │
│ get() { ... } set(v) {... }│
│ ↓ ↓ │
│ field ←────── 공유 ────── │ ← backing field
└──────────────────────────────────────────┘

3. field 키워드

field프로퍼티 접근자 내부에서만 쓸 수 있는 특별한 식별자. 자기 자신의 backing field를 가리킨다.

1
2
3
4
5
6
7
8
9
var count: Int = 0
get() {
println("reading count")
return field // 접근자 안에서만 사용 가능
}
set(value) {
println("writing count: $value")
field = value
}

field가 필요한가 — 재귀 함정

1
2
var count: Int = 0
get() { return count } // ❌ 무한 재귀 → StackOverflow

count는 프로퍼티명이고, 읽으면 getter가 호출되고, 그 getter가 또 count를 읽고… 끝없이 반복.

→ 그래서 Kotlin은 backing field에 직접 접근하는 field라는 예약어를 따로 둠.

4. Backing Field는 언제 생기는가

참고: 모든 프로퍼티가 backing field를 갖는 것은 아니다.
컴파일러는 접근자에서 field 참조가 있을 때만 backing field를 생성한다.

생성되는 경우

1
2
3
4
var name: String = ""           // 기본 setter/getter가 field 사용 → 생성
var age: Int = 0
get() = field // 명시적 field 사용 → 생성
set(v) { field = v }

생성되지 않는 경우 (Computed Property)

1
2
3
4
5
6
val fullName: String
get() = "$firstName $lastName" // field 참조 없음 → backing field 없음

var password: String
get() = throw IllegalAccessException()
set(v) { saveToDB(v) } // 저장 안 함 → backing field 없음

5. 실전 사례 — volume 버그 분석

Before

1
2
3
4
5
6
override var volume: Float = 1.0f
get() = playerInternal?.volume ?: 1.0f // ← field를 안 씀
set(value) {
playerInternal?.volume = value
field = value // ← field를 씀 (저장만)
}

문제 상황

  • setter는 field를 쓰므로 backing field는 생성됨
  • getter는 field읽지 않음 → 저장된 값이 바깥으로 나올 방법이 없음
  • field쓰기만 하고 읽히지 않는 dead storage

재현 시나리오

1
2
3
4
5
mediator.volume = 0f         // setter → field = 0f, player.volume = 0f
mediator.release() // playerInternal = null
val v = mediator.volume // getter → playerInternal?.volume ?: 1.0f
// playerInternal = null → 1.0f 반환
// field의 0f는 영원히 못 읽음

재초기화 경로에서의 실제 버그

1
2
3
4
5
6
playerBucket = ExoPlayerPool.acquire(context).also { bucket ->
...
bucket.player.volume = volume // getter 호출
// playerBucket은 아직 대입 전 → playerInternal = null → 1.0f 반환
// 이전에 mute했던 0f가 소실됨
}

→ mute 상태로 재생되어야 할 영상이 갑자기 소리를 냄 (UX 사고 가능).

After

1
2
3
4
5
6
override var volume: Float = 1.0f
// getter 생략 → 컴파일러가 get() = field 자동 생성
set(value) {
field = value
playerInternal?.volume = value
}

해결
커스텀 getter를 제거하면 기본 getter가 field를 반환. setter가 저장한 값이 정확히 읽힌다. 재초기화 시 volume getter가 field(0f)를 반환 → mute 유지 ✅

Setter 순서 변경 이유

1
2
3
4
5
6
7
8
9
10
11
// Before
set(value) {
playerInternal?.volume = value
field = value
}

// After
set(value) {
field = value
playerInternal?.volume = value
}

기능적으로는 동일하지만 “저장(field)이 전파(player)보다 먼저” 라는 불변식 유지. player.volume 쓰기가 동기 콜백을 발동시키고 그 콜백이 getter를 읽는다면, 옛 순서에서는 field가 아직 이전 값을 반환할 수 있음. 엣지 케이스 방어.

6. Backing Field vs Backing Property

주의: 이름은 비슷하지만 다른 개념이다.
Backing field: 언어가 자동 생성
Backing property: 개발자가 수동 생성

Backing Property 패턴 — StateFlow 예시

1
2
3
4
5
6
7
8
9
10
11
12
class ViewModel {
private val _uiState = MutableStateFlow(UiState()) // 내부용, 가변
val uiState: StateFlow<UiState> = _uiState // 외부용, 불변

fun update() {
_uiState.value = UiState(...) // 내부에서만 쓰기 가능
}
}

// 외부
vm.uiState.collect { ... } // 읽기만 가능
// vm.uiState = ... ← 컴파일 에러

uiState(외부용)가 _uiState(내부용)를 뒤에서 받치고(backing) 있어서 backing property라고 부름.

비교

Backing Field Backing Property
누가 만드나 Kotlin 컴파일러 자동 개발자가 수동
접근 방법 접근자 안에서 field 키워드 클래스 안에서 _uiState 같은 변수명
용도 프로퍼티의 실제 저장소 가변성/타입을 외부에 가리기
개수 프로퍼티당 0개 또는 1개 쌍(private + public)으로 존재

7. Q&A

Q. Kotlin 프로퍼티와 Java 필드의 차이는?

Java 필드는 순수 메모리 저장소이고, 접근하려면 getter/setter를 별도로 작성해야 한다. Kotlin 프로퍼티는 저장소 + getter + setter를 하나의 선언에 묶은 언어 수준의 추상화. p.name 구문은 필드 직접 접근처럼 보이지만 실제로는 접근자 호출로 컴파일된다.

Q. field 키워드는 왜 필요한가?

접근자 안에서 자기 자신의 프로퍼티명을 쓰면 재귀 호출이 되기 때문. field재귀 없이 backing field에 직접 접근하기 위한 특별한 식별자로, 접근자 안에서만 유효하다.

Q. Backing field가 없는 프로퍼티도 있나?

있다. getter/setter가 field를 전혀 참조하지 않으면 컴파일러는 backing field를 만들지 않는다. 계산된 프로퍼티(computed property)가 대표적.

1
2
val isEmpty: Boolean
get() = size == 0 // field 참조 없음 → backing field 없음

Q. Backing field와 backing property의 차이는?

Backing field는 언어가 자동 생성하는 숨겨진 저장 공간(field 키워드로 접근). Backing property는 개발자가 수동으로 만드는 private 프로퍼티로 public 프로퍼티의 가변성을 숨기는 패턴. 예: _uiState: MutableStateFlowuiState: StateFlow.

Q. 커스텀 getter를 만들 때 주의할 점은?

  1. field를 써야 할 때 쓰지 않으면, 저장된 값을 읽을 방법이 사라져 var의 반쪽이 죽은 코드가 된다 (volume 버그 사례).
  2. getter는 호출될 때마다 실행되므로, 비싼 연산을 넣으면 성능 문제가 된다.
  3. 외부에서 p.name을 읽는 건 함수 호출이므로, 연달아 읽을 때 값이 바뀔 수 있음을 가정해야 한다 (특히 스레드 관점).

8. 핵심 요약

1
2
3
4
5
6
7
프로퍼티 = (backing field?) + getter + setter(var인 경우)

접근자 안에서 `field` 키워드로만 접근
커스텀 접근자가 field를 안 쓰면 생성조차 안 됨

volume 버그 = setter가 field에 저장했는데 getter가 field를 안 읽은 것
해결 = 커스텀 getter 제거 → 기본 getter(= field) 사용

핵심 감각: “getter 호출은 함수 호출이다”를 체화하면 대부분의 혼동이 풀린다.

관련 링크

Author

KAMIYU

Posted on

2026-04-15

Updated on

2026-04-17

Licensed under

댓글