Notice
Recent Posts
Recent Comments
Link
Tags
more
관리 메뉴

주완 님의 블로그

[Kotlin] Stable 과 Immutable 톺아보기 본문

Android

[Kotlin] Stable 과 Immutable 톺아보기

vvan2 2025. 12. 20. 07:16

Android Compose 의 @Immutable 와 @Stable

 

Android Compose 의 @Immutable 와 @Stable

Jetpack Compose에서 @Immutable와 @Stable 어노테이션은 컴포저블 함수의 리컴포지션을 최적화하는 데 중요한 역할을 합니다. 각각의 어노테이션은 특정한 상황에서 사용되며, 사용하는 것과 사용하지

choi-dev.tistory.com

 

Kotlin Collections Immutable: ImmutableList vs PersistentList 이해하기

 

Kotlin Collections Immutable: ImmutableList vs PersistentList 이해하기

Kotlin의 kotlinx.collections.immutable는 불변 컬렉션을 효율적으로 다루기 위한 전용 컬렉션 구현체를 제공합니다. 특히 ImmutableList와 PersistentList는 API 설계와 내부 구현에서 각각 다른 목적을 갖습니

velog.io

해당글에서 참조했습니다)

 

Jetpack Compose 에서 Immutable , Stable 어노테이션은 컴포져블 함수의 리컴포지션을 최적화하는 데 중요한 역할을 합니다. 각각의 어노테이션은 특정한 상황에서 사용되며, 사용하는 것과 사용하지 않는 것에 따라 성능에 미치는 영향이 달라집니다.

Immutable

클래스가 불변임을 명시. 불변 객체는 상태가 변경되지 않기 때문에, Jetpack Compose 는 해당 객체가 변경되지 않는다고 가정하고 리컴포지션을 피할 수 있습니다.

객체가 불변이며, 내부의 모든 필드도 불변 객체일 때 사용합니다. 이는 data class 와 같은 경우에 자주 사용됩니다.

@Immutable
data class User(val name: String, val age: Int)

stable

객체가 안정적임을 명시한다. 이 말은 객체의 특정 프로퍼티가 변경될 수 있지만, 객체 자체는 변경되지 않거나 자주 변경되지 않는다는 것을 의미합니다. 객체가 안정적이기 때문에, Jetpack Compose는 이 객체가 자주 변경되지 않는다고 가정하고 불필요한 리컴포지션을 피하려고 시도합니다.

@Stable
class Counter(var count: Int)

프젝하다보면 헷갈리는게 좀 있는데 계층별로 한 번 알아봅시다.

1. Data Layer (Repository)

// API Response - 어노테이션 불필요
data class UserResponse(
    val name: String,
    val age: Int,
    val hobbies: List<String>
)

// Room Entity - 어노테이션 불필요
@Entity
data class UserEntity(
    @PrimaryKey val id: String,
    val name: String,
    val age: Int
)

Data 계층에서는 어노테이션 불필요합니다. 이 계층은 Compose와 직접 관련이 없고, 단순히 데이터 전송/저장 목적이기 때문이죠.

2. Domain Layer

// Domain Model - 선택적으로 사용
@Immutable // 또는 어노테이션 없이
data class User(
    val id: String,
    val name: String,
    val age: Int,
    val hobbies: List<String> // 일반 List도 OK
)

Domain 계층도 선택적입니다. 하지만 이 모델이 Presentation까지 그대로 사용된다면 여기서부터 붙이는 게 좋습니다.

3. Presentation Layer

// UI State - 반드시 사용!
@Immutable
data class UserUiState(
    val user: User,
    val isLoading: Boolean = false,
    val hobbies: ImmutableList<String> // 또는 kotlinx.collections.immutable.List
)

// 또는 Stable이 필요한 경우
@Stable
class UserListState(
    private val _users: SnapshotStateList<User> = mutableStateListOf()
) {
    val users: List<User> = _users
    
    fun addUser(user: User) {
        _users.add(user)
    }
}

언제 어노테이션을 붙일까?

//  추천: Presentation 계층의 UiState
@Immutable
data class ProductUiState(
    val products: ImmutableList<Product>,
    val selectedCategory: String,
    val isLoading: Boolean
)

//  Domain 모델이 UI에서 직접 사용될 때
@Immutable
data class Product(
    val id: String,
    val name: String,
    val price: Int
)

//  불필요: Data 계층
data class ProductResponse(...) // 어노테이션 없음

ImmutableList vs List - 언제 사용할까?

// 1. 일반 List - Compose가 불변으로 인식 못함
data class State1(
    val items: List<String> //  불안정
)

// 2. ImmutableList - Compose가 불변으로 인식
@Immutable
data class State2(
    val items: ImmutableList<String> //  안정적
)

// 3. persistentListOf 사용 예시
import kotlinx.collections.immutable.*

val myList = persistentListOf("A", "B", "C")
val newList = myList.add("D") // 새로운 리스트 반환 (불변)

ViewModel에서 변환

class UserViewModel : ViewModel() {
    private val repository: UserRepository
    
    // MutableStateFlow로 관리
    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    fun loadUsers() {
        viewModelScope.launch {
            repository.getUsers()
                .map { userResponse ->
                    // Data -> Domain 변환
                    userResponse.toDomain()
                }
                .collect { users ->
                    // List -> ImmutableList 변환
                    _uiState.value = UserUiState(
                        users = users.toImmutableList(),
                        isLoading = false
                    )
                }
        }
    }
}

// Mapper
fun UserResponse.toDomain(): User = User(
    id = id,
    name = name,
    age = age
)

@Immutable
data class UserUiState(
    val users: ImmutableList<User> = persistentListOf(),
    val isLoading: Boolean = true
)

정리하자면…

  1. Presentation 계층에서 신경을 쓰자 - Composable에 전달되는 데이터만 최적화
  2. ImmutableList는 List 타입이 UiState에 포함될 때 사용 - 단순 데이터 전달은 일반 List도 괜찮다
  3. persistentListOf는 리스트 수정이 자주 일어날 때 - 효율적인 복사본 생성
  4. 과도한 최적화 주의 - 성능 문제가 실제로 발생할 때 적용
// 간단한 경우 - 어노테이션 없어도 OK
@Composable
fun UserScreen(users: List<User>) { ... }

// 복잡한 State - 어노테이션 필수!
@Composable
fun UserScreen(uiState: UserUiState) { ... }

List는 변경이 잦으면 ImmutableList, 아니면 일반 List로도 충분


+++ 추가로 Stablity에 대해서 보자면

Compose가 자동으로 Stable하다고 판단하는 타입

Compose Compiler는 특정 타입들을 자동으로 stable하다고 간주합니다:

// 자동으로 Stable - 어노테이션 불필요
data class User(
    val name: String,           // 원시 타입
    val age: Int,               // 원시 타입
    val isActive: Boolean,      // 원시 타입
    val score: Double           // 원시 타입
)

@Composable
fun UserCard(user: User) {  //  Skippable!
    Text(user.name)
}

Compose가 Stable하다고 보는 타입들

  1. 원시 타입: Int, Long, Float, Double, Boolean, Char, Byte, Short
  2. String
  3. 함수 타입: () -> Unit, (Int) -> String 등
  4. Enum
  5. @Immutable / @Stable 붙은 클래스

문제는 List….

//  Unstable - List는 자동으로 stable하지 않음!
data class UserState(
    val name: String,          //  Stable
    val age: Int,              //  Stable
    val hobbies: List<String>  //  Unstable!
)

@Composable
fun UserProfile(state: UserState) {  //  Non-skippable!
    // hobbies가 List라서 전체가 unstable
}

왜 List는 Unstable일까?

// List는 인터페이스이고, 구현체가 mutable일 수 있음
val list1: List<String> = mutableListOf("A", "B")
val list2: List<String> = listOf("A", "B")

// Compose 입장에서는 둘을 구분 못함
// 따라서 안전하게 unstable로 판단

Compose Compiler 리포트로 확인하기

// build.gradle.kts
kotlinCompilerExtensionVersion = "1.5.0"

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs += listOf(
            "-P",
            "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
                "${project.buildDir}/compose_metrics"
        )
    }
}

실제 리포트 예시

// Stable한 경우
stable class User {
  stable val name: String
  stable val age: Int
  <runtime stability> = Stable
}

// Unstable한 경우  
unstable class UserState {
  stable val name: String
  stable val age: Int
  unstable val hobbies: List<String>  // ← 이것 때문에!
  <runtime stability> = Unstable
}

해결 방법들

1. ImmutableList 사용 (권장)

@Immutable  // 명시적으로 표시
data class UserState(
    val name: String,
    val age: Int,
    val hobbies: ImmutableList<String>  //  Stable!
)

2. @Immutable 어노테이션 (간단한 경우)

@Immutable
data class UserState(
    val name: String,
    val age: Int,
    val hobbies: List<String>  // Compose에게 "믿어줘"라고 알림
)

3. @Stable + 주의해서 사용

@Stable
data class UserState(
    val name: String,
    val age: Int,
    val hobbies: List<String>
) {
    // 내부에서 hobbies를 변경하지 않도록 주의!
}

Ex)

//  Non-skippable
data class ProductList(
    val products: List<Product>,  // Unstable!
    val total: Int
)

@Composable
fun ProductGrid(state: ProductList) {  // 매번 리컴포지션!
    LazyColumn {
        items(state.products) { product ->
            ProductCard(product)
        }
    }
}

//  Skippable
@Immutable
data class ProductList(
    val products: ImmutableList<Product>,  // Stable!
    val total: Int
)

@Composable
fun ProductGrid(state: ProductList) {  // Skip 가능!
    LazyColumn {
        items(state.products) { product ->
            ProductCard(product)
        }
    }
}

타입 Stability Skippable 비고

Int, String Stable skip가능 자동 판단
List<T> Unstable 불가 문제
ImmutableList<T> Stable 가능 권장
@Immutable data class Stable 가능 명시적 표시
// 정리
data class Good(
    val id: Int,           //  자동 Stable
    val name: String       //  자동 Stable
)  // → 전체 Stable, Skippable!

data class Bad(
    val id: Int,           //  Stable
    val tags: List<String> //  Unstable
)  // → 전체 Unstable, Non-skippable!

@Immutable
data class Fixed(
    val id: Int,
    val tags: ImmutableList<String>  //  Stable
)  // → 전체 Stable, Skippable!