먼저 아래 내용에 대한 질문을 바탕으로 저는 작성해 보았습니다.
용어 및 개념 정리
Kotlin에서 var나 val로 변수를 선언하면, 단순히 값을 저장하는 변수를 만드는 것처럼 보이지만 사실은 그렇지 않습니다.
Kotlin에서는 클래스 내에서 변수를 선언할 때 컴파일 시점에 자동으로 getter와 setter로 변환됩니다.
즉, Java에서의 “필드(field)” 개념과는 달리, Kotlin은 이 추상화된 구조를 프로퍼티(property) 라고 부릅니다.
// Kotlin
var name: String = "콩틀린"
// Java
private String name = "콩틀린;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
코틀린은 이처럼 property 라는 개념을 적용하여 추상화된 접근 방식을 제공합니다. 이는 캡슐화와 데이터 보호 측면에서 매우 유리합니다.
코틀린 프로퍼티 내에서 필드를 사용하기 위해서는 백킹 필드(Backing Field) 를 사용합니다.
Backing Field 는 프로퍼티의 실제 값을 저장하는 저장소 입니다. getter와 setter가 값을 읽고 쓸 수 있도록 Kotlin 컴파일러가 자동으로 생성하는 내부 변수예요.
즉, 우리가 직접 선언하고 보여지진 않지만 프로퍼티의 실제 값을 담고 있는 숨겨진 저장소라고 생각하시면 돼요.
다만, 이러한 Backing Field 는 항상 생기지 않고 직접 선언해서 사용하는 건 아닙니다.
생성 조건은 아래와 같습니다.
- 접근자(Getter, Setter) 중에 하나 이상의 기본 구현을 사용하는 경우
- 접근자(Getter, Setter) 를 재정의하여 field 식별자를 참조하는 경우
field 를 직접 생성(선언) 은 불가능하지만 field 키워드를 통해서 참조 및 조작을 수행할 수 있습니다.

예시)
var nickname: String = "콩틀린"
get() = field.uppercase()
set(value) {
field = value.trim()
}
아래 코드의 isEmpty 경우 백킹 필드가 만들어 지지 않습니다.
// Kotlin
class A {
var size = 0
val isEmpty: Boolean
get() = this.size == 0
}
// Java 변환 코드
public final class A {
@NotNull
private int size;
public final int getSize() {
return this.size;
}
public final void setSize(@NotNull int var1) {
this.size = var1;
}
public final boolean isEmpty() {
return this.size == 0;
}
}
isEmpty 의 getter 를 커스텀 하는데 이에 대한 field 를 사용한 직접적인 접근을 수행하지 않기 때문에 실제 Java 변환 코드에서는 isEmpty 필드가 만들어지지 않은 것을 알 수 있습니다.
Kotlin 공식 문서에서는 getter/setter를 복잡하게 커스터마이징하는 것을 지양하라고 말합니다.
커스텀 접근자 내부에 연산이나 비즈니스 로직이 들어가면, 상태 관리가 어려워지고 예측 불가능한 부작용이 발생할 수 있기 때문입니다. 따라서 프로퍼티는 단순 상태를 나타내거나 설정하기 위한 목적으로만 설정하는 것이 좋고 비즈니스 로직의 경우 별도의 함수를 정의하는 것을 권장합니다.
정리해 본 팁
- 시간 복잡도 O(1) 을 넘어가는 연산을 지양
- 비즈니스 로직 지양
- 비결정적인 경우 지양(동일한 입력이나 조건에서도 결과가 일정하지 않은 코드)
- getter 에서 상태변경을 시도하는 것을 지양
기본적으로 한 객체에서 외부로 데이터를 제공할 때에는, 가능한 한 불변인 상태로 제공해주는 것이 좋습니다.
이를 처리하는 방법 중 하나가 Backing Property 입니다.
⇒ 내부 속성으로는 수정 할 수 있지만 외부적으로 수정할 수 없게 도와주는 코딩 패턴
코드를 통해 먼저 보겠습니다 ..!
class ShoppingCart {
// Backing property
private val _items = mutableListOf<String>()
// Public read-only view
val items: List<String>
get() = _items
fun addItem(item: String) {
_items.add(item)
}
fun removeItem(item: String) {
_items.remove(item)
}
}
_items 는 private 하게 변수를 선언해서 관리하고 이를 items 에서는 이를 받아 getter 만 구현해서 외부에서 사용될 프로퍼티는 읽기만 가능하게 하고 있어요.
공식 문서의 코딩 컨벤션에 따르면 Backing property 는 프로퍼티명 앞에 언더바( _ ) 를 붙입니다.
Use a leading underscore when naming backing properties to follow Kotlin coding conventions.
코틀린에서는 get, set 키워드 앞에 한정자 private 을 사용하게 되면 이를 외부에서 getter , setter 를 외부에서 사용하지 못하게 할수 있습니다.
class BankAccount() {
var balance: Int = 100
// Only the class can modify the balance
private set
}
fun main() {
val account = BankAccount()
account.balance = 100 // Error: cannot assign because setter is private
}
위 코드와 같이 balance 는 private set 으로 선언 되어 있기 때문에 선언된 클래스 내에서만 값을 수정할 수 있고 외부에서는 수정이 불가능합니다.
이제 한가지 의문점이 드는데요??
Backing Property 도 외부에서 접근이 불가능하게 하려고 한건데 그냥
private set을 써도 되지 않나요?
class Test1 {
private var _word = "test" // Backing Property
val word: String
get() = _word
}
class Test2 {
var word = "test" // private set 방식
private set
}
// Java 변환 코드
public final class Test1 {
private String _word = "test";
@NotNull
public final String getWord() {
return this._word;
}
}
public final class Test2 {
@NotNull
private String word = "test";
@NotNull
public final String getWord() {
return this.word;
}
}
네 놀랍게도 Backing Property 방식도 필드와 setter 를 생성하지 않기 때문에 구현 방식에 차이가 없습니다..!
하지만 아래와 같은 경우는 어떨까요??
MutableList 와 List 를 보겠습니다.
MutableList 에 내장 메소드인 add() 를 통해 단순히 원소를 하나 추가하는 상황이라고 생각해 보겠습니다.
여기서는 단순히 _word 를 전달하는 것이 아닌 _words.toList() 로 데이터를 복사해 전달했습니다.
(참고: MutableList 은 List 를 상속받고 있어 List → MutableList 로 다운캐스팅이 가능합니다.)
class Test1 {
private val _words = mutableListOf<String>()
val words: List<String>
get() = _words.toList()
}
class Test2 {
var words = mutableListOf<String>()
private set
}
fun main() {
val test1 = Test1()
// List MutableList 다운캐스팅
(test1.words as MutableList<String>).add("테스트") // Error: classCastException
println(test1.words)
val test2 = Test2()
test2.words.add("테스트")
println(test2.words)
}
위 코드에서 backing field 의 getter 를 구현할때 toList() 를 사용했습니다.
toList() 사용시에는 새로운 메모리에 데이터만을 복사한 객체를 생성하기 때문에 다운캐스팅이 불가능합니다 !!(참고로 toList() 로 전달하는 것이 아닌 _words 만 바로 전달한다면 캐스팅이 가능하겠죠?)
실제 안드로이드 개발시 ViewModel 내부에 상태관리시 MutableStateFlow 와 MutableSharedFlow 를 사용할 일이 많습니다. (Flow 개념은 생략하겠습니다..)
이때 Backing Property 를 사용합니다.
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
private val _sideEffect = MutableSharedFlow<SideEffect>()
val sideEffect = _sideEffect.asSharedFlow()
MutableStateFlow 와 MutableSharedFlow 는 각각 StateFlow 와 SharedFlow 로 받아서 불변인 Flow 로 사용하게 합니다.
private val _count = MutableStateFlow<Int>(0)
val count : StateFlow<Int> = _count
private val _errorEvent = MutableSharedFlow<String>()
val _errorEvent : SharedFlow<String> = _errorEvent
여기서 중요한점은 MutableStateFlow → StateFlow 와 MutableSharedFlow → SharedFlow 각각의 클래스 → 인터페이스 구조로 구현해 만들어진 클래스여서
StateFlow → MutableStateFlow 로 다운 캐스팅이 가능하게 됩니다.
fun main(){
val _flow = MutableStateFlow<Int>(1)
val flow: StateFlow<Int> = _flow
println(_flow.value)
(flow as MutableStateFlow).value = 2
println(_flow.value)
}
// 출력 결과
// 1
// 2
해당 코드와 같이 backing property 를 사용했음에도 다운 캐스팅을 통해서 값을 변경하게 합니다.
이를 보완하기 위해서는 asStateFlow(), asSharedFlow() 를 통해서 보완할 수 있습니다!

해당 확장함수는 ReadOnlyStateFlow 라는 래퍼 클래스로 감싸서 전달해주는 것을 볼 수 있는데
이는 StateFlow 타입으로 한번 감싸써 강제 캐스팅을 막을 수 있습니다.
fun main(){
val _flow = MutableStateFlow<Int>(1)
val flow: StateFlow<Int> = _flow.asStateFlow()
println(_flow.value)
(flow as MutableStateFlow).value = 2 // Error: classCastException
println(_flow.value)
}
공식 문서에서도 사용하고 있어 asStateFlow()이를 안쓸 이유는 없어 보입니다!!
다음의 링크를 참고했습니다.
Properties | kotlin Docs
StateFlow | kotlinx.coroutines - Kotlin Programming Language Docs
Coding conventions | kotlin Docs
배준형 코틀린에서 Backing Properties를 왜 사용해야 하죠? 블로그