Kotlin 의 프로퍼티를 알아보자 (feat. Backing Properties)

Ryan·2025년 11월 12일
0

Kotlin

목록 보기
4/5
post-thumbnail

먼저 아래 내용에 대한 질문을 바탕으로 저는 작성해 보았습니다.

  • 프로퍼티는 무엇인지? 내부적으로 어떻게 동작하는지?
  • 또, 프로퍼티에는 프로퍼티의 값을 뒷받침 하는 필드인 backing field 라는 것을 사용한다고 하는데 내부 구현은 어떻게 되고 동작방식에는 차이가 있는지?
  • 코틀린에서 변경 가능한 데이터보다 변경 할 수 없는 불변 데이터 사용을 도와주는 방법은 무엇인지?

용어 및 개념 정리

  • getter 와 setter 를 통틀어서 “accessors(접근자)” 라고 부름.
  • property 는 필드와 접근자인 getter, setter 를 통틀어서 부르는 말(속성).

1. Kotlin 의 Property

Kotlin에서 varval로 변수를 선언하면, 단순히 값을 저장하는 변수를 만드는 것처럼 보이지만 사실은 그렇지 않습니다.

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 라는 개념을 적용하여 추상화된 접근 방식을 제공합니다. 이는 캡슐화와 데이터 보호 측면에서 매우 유리합니다.

2. Backing Field

코틀린 프로퍼티 내에서 필드를 사용하기 위해서는 백킹 필드(Backing Field) 를 사용합니다.

Backing Field 는 프로퍼티의 실제 값을 저장하는 저장소 입니다. gettersetter가 값을 읽고 쓸 수 있도록 Kotlin 컴파일러가 자동으로 생성하는 내부 변수예요.
즉, 우리가 직접 선언하고 보여지진 않지만 프로퍼티의 실제 값을 담고 있는 숨겨진 저장소라고 생각하시면 돼요.

⚙️ Backing Field 생성 조건

다만, 이러한 Backing Field 는 항상 생기지 않고 직접 선언해서 사용하는 건 아닙니다.

생성 조건은 아래와 같습니다.

  • 접근자(Getter, Setter) 중에 하나 이상의 기본 구현을 사용하는 경우
  • 접근자(Getter, Setter) 를 재정의하여 field 식별자를 참조하는 경우

field 를 직접 생성(선언) 은 불가능하지만 field 키워드를 통해서 참조 및 조작을 수행할 수 있습니다.
kotlin 선언 기본구조

예시)

var nickname: String = "콩틀린"
    get() = field.uppercase()
    set(value) {
        field = value.trim()
    }

❌ Backing Field 생성 되지 않는 경우

아래 코드의 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 에서 상태변경을 시도하는 것을 지양

3. Backing Properties

기본적으로 한 객체에서 외부로 데이터를 제공할 때에는, 가능한 한 불변인 상태로 제공해주는 것이 좋습니다.
이를 처리하는 방법 중 하나가 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)
    }
}

_itemsprivate 하게 변수를 선언해서 관리하고 이를 items 에서는 이를 받아 getter 만 구현해서 외부에서 사용될 프로퍼티는 읽기만 가능하게 하고 있어요.

공식 문서의 코딩 컨벤션에 따르면 Backing property 는 프로퍼티명 앞에 언더바( _ ) 를 붙입니다.

Use a leading underscore when naming backing properties to follow Kotlin coding conventions.

❓왜 Backing Property 를 쓸까?

코틀린에서는 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 를 생성하지 않기 때문에 구현 방식에 차이가 없습니다..!

하지만 아래와 같은 경우는 어떨까요??
MutableListList 를 보겠습니다.
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 만 바로 전달한다면 캐스팅이 가능하겠죠?)

4. .asStateFlow() .asShareFlow()

실제 안드로이드 개발시 ViewModel 내부에 상태관리시 MutableStateFlowMutableSharedFlow 를 사용할 일이 많습니다. (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를 왜 사용해야 하죠? 블로그

profile
Seungjun Gong

0개의 댓글