@Stable 모르고 붙이면 성능 최적화가 아니라 거짓말쟁이입니다

지훈·2026년 3월 30일

1편에서 "순수 함수여야 한다"고 했습니다.
2편에서 "Snapshot System이 상태 변화를 추적한다"고 했습니다.
3편에서 "State Hoisting으로 진정한 순수 함수를 만든다"고 했습니다.

그런데 이 모든 원칙을 지키면 뭐가 좋은 건데?

답은 간단합니다. Compose Compiler가 자동으로 성능을 최적화해줍니다. 개발자가 순수 함수를 지키고 상태를 올바르게 설계하면, 컴파일러가 "이 Composable은 다시 실행할 필요 없다"고 판단하여 건너뜁니다.

여기까지만 이해하면 위험합니다. "그럼 Skip이 더 많이 일어나도록 애너테이션을 붙이면 성능이 더 좋아지겠네?"라는 결론으로 쉽게 이어지기 때문입니다. 이 글은 그 프레이밍이 왜 위험한지, 그리고 @Stable/@Immutable의 본질이 무엇인지를 다룹니다.


흔한 오해

Compose 성능 관련 글을 읽다 보면 이런 문장을 자주 만납니다.

"@Stable 붙이면 불필요한 Recomposition을 막아서 성능이 좋아진다."
"Recomposition Skip은 Compose의 성능 최적화 기법이다."

틀린 말은 아닙니다. Skip이 일어나면 성능이 좋아지는 건 사실입니다.

그런데 이 프레이밍엔 문제가 있습니다. @Stable을 "성능을 높이는 도구"로 이해하면, 자연스럽게 "그럼 더 많이 붙일수록 좋겠네?"라는 결론으로 이어집니다. 실제로 그렇게 쓰는 경우도 많습니다.

이 글은 그게 왜 위험한지, 그리고 @Stable의 본질이 무엇인지를 다룹니다.


Recomposition과 Recomposition Skip은 다르다

본론에 앞서 두 개념을 명확히 구분합니다. 혼용되는 경우가 많습니다.

Recomposition: 상태 변화로 인해 Composable 함수가 다시 실행되는 것.

Recomposition Skip: Recomposition이 트리거됐을 때, 특정 Composable의 재실행을 건너뛰는 것.

Skip이 일어나려면 Recomposition이 먼저 트리거되어야 합니다. 흐름은 이렇습니다.

상태 변화
  → Recomposition 트리거 (재실행 필요 여부 검사 시작)
  → 각 Composable 검사
      → 파라미터가 변경됨: 재실행
      → Stable 타입 + 파라미터 동일: Skip (재실행 생략)

"Skip = Recomposition을 안 한다"가 아니라, "Recomposition이 트리거됐지만 이 Composable은 건너뛴다"입니다.


이 글의 독자

  1. 시리즈 1~3편을 읽은 독자

    • 순수 함수, Snapshot System, State Hoisting을 이해했지만
    • "이것이 성능과 어떻게 연결되는지" 전체 그림을 그리고 싶은 분
  2. Stability를 "val 쓰면 좋다" 정도로만 이해하는 개발자

    • Stable/Unstable 분류는 알지만
    • 컴파일러가 이 분류를 필요로 하는지 근본 원리가 궁금한 분
    • @Stable을 성능 튜닝 도구로 쓰고 있는 분
  3. 다른 선언형 UI 경험자 (React, SwiftUI, Vue, Flutter)

    • React.memo, useMemo를 직접 작성하던 경험이 있지만
    • Compose는 이것을 어떻게 자동화하는지 알고 싶은 분

메모이제이션: 성능이 아니라 수학적 사실

시작하기 전에, CS(Computer Science)의 기초 개념 하나를 짚고 갑니다.

피보나치와 캐싱

// 매번 재계산
fun fibonacci(n: Int): Int {
    if (n <= 1) return n
    return fibonacci(n - 1) + fibonacci(n - 2)
}

// 메모이제이션: 한 번 계산한 결과를 캐싱
val cache = mutableMapOf<Int, Int>()
fun fibonacciMemo(n: Int): Int {
    return cache.getOrPut(n) {
        if (n <= 1) n else fibonacciMemo(n - 1) + fibonacciMemo(n - 2)
    }
}

메모이제이션의 원리는 단순합니다. 같은 입력이 들어오면, 다시 계산하지 않고 이전 결과를 반환한다.

그런데 이 전략이 작동하려면 두 가지 전제조건이 필요합니다.

  1. 참조 투명성: 같은 입력이면 항상 같은 출력이 나와야 한다 (= 순수 함수)
  2. 신뢰할 수 있는 동등성 비교: "이전과 같은 입력인가?"를 판단할 수 있어야 한다

첫 번째는 1편에서 다뤘습니다. Composable 함수가 순수 함수여야 하는 이유. 두 번째가 이 글의 핵심입니다. 이것이 바로 Stability입니다.

프레이밍 뒤집기: Skip은 왜 가능한가

여기서 하나 짚고 갑니다.

Skip은 "성능을 위해 재실행을 피하는" 것인가?
아니면 "동일한 입력이면 출력이 이미 알려져 있으므로 재실행이 불필요한" 것인가?

두 문장은 같아 보이지만 다릅니다. 전자는 Skip을 최적화 수단으로 보고, 후자는 수학적 사실의 활용으로 봅니다.

성능 향상은 부산물입니다. 핵심은 "이 함수는 순수하므로 답을 이미 안다"는 사실입니다. 이 구분은 뒤에서 @Stable의 본질을 이해할 때 결정적입니다.

Compose의 자동 메모이제이션

Compose의 Skipping은 이 메모이제이션을 UI 함수에 적용한 것입니다.

@Composable
fun UserCard(name: String, age: Int) {
    Text("$name, $age살")
}

UserCard("심지", 25)가 이전 Composition과 동일하다면, Compose는 이 함수를 다시 실행하지 않습니다. 이전 결과를 그대로 사용합니다.

하지만 컴파일러가 이 판단을 내리려면, nameage가 "이전과 같다"는 것을 확신할 수 있어야 합니다. StringInt는 불변이고 equals()가 신뢰할 수 있으니까 가능한 것입니다.

만약 파라미터의 equals()를 신뢰할 수 없다면? 컴파일러는 안전하게 매번 다시 실행합니다.

이 "신뢰할 수 있는가?"의 판단 기준이 Stability입니다.


@Composable을 달면 컴파일러는 무엇을 하는가

Stability가 왜 필요한지 이해하려면, Compose Compiler가 우리 코드를 어떻게 변환하는지 들여다볼 필요가 있습니다.

작성한 코드 vs 실제 실행되는 코드

// 우리가 작성하는 코드
@Composable
fun UserCard(name: String, age: Int) {
    Text("$name, $age살")
}

// Compose Compiler가 변환한 코드 (단순화)
fun UserCard(
    name: String,
    age: Int,
    $composer: Composer,   // 숨겨진 파라미터 1: Composition 추적
    $changed: Int          // 숨겨진 파라미터 2: 변경 여부 비트마스크
) {
    $composer.startRestartGroup(hash)

    // 핵심: 파라미터가 변경되지 않았다면 SKIP
    if ($changed and 0b0110 == 0 && $composer.skipping) {
        $composer.skipToGroupEnd()
        return
    }

    // 실제 UI 렌더링
    Text("$name, $age살", $composer, ...)

    $composer.endRestartGroup()?.updateScope { nextComposer, nextChanged ->
        UserCard(name, age, nextComposer, nextChanged)
    }
}

Compose Compiler는 Kotlin 컴파일러 플러그인[^1]으로, @Composable 함수에 두 개의 숨겨진 파라미터를 주입합니다.

  • $composer: Composition 트리를 추적하는 객체. 2편에서 다룬 Slot Table과 연결됩니다.
  • $changed: 각 파라미터의 변경 여부를 비트(bit) 단위로 기록하는 정수.

핵심은 if ($changed and 0b0110 == 0 && $composer.skipping) 부분입니다. 이 조건이 참이면 함수 본문 전체를 건너뜁니다. 이것이 바로 Skipping입니다.

$changed 비트마스크의 역할

$changed는 각 파라미터에 대해 약 2~3비트를 사용하여 상태를 기록합니다.[^2]

$changed 비트 구조 (단순화):
[파라미터2 상태][파라미터1 상태][Composer 상태]
    2비트          2비트          2비트

비트 값의 의미:

  • 00: 불확실 (아직 비교하지 않음)
  • 01: 동일 (이전 값과 같음)
  • 10: 변경됨

여기서 결정적인 질문이 등장합니다. 컴파일러는 어떤 기준으로 "이 파라미터는 비교 가능하다"고 판단하는가?

equals()를 호출해서 비교하면 되는 것 아니냐고 생각할 수 있습니다. 하지만 모든 타입의 equals()가 신뢰할 수 있는 것은 아닙니다. 이것이 Stability가 필요한 이유입니다.


Stability: 컴파일러가 타입을 '신뢰'하는 기준

Stable/Immutable/Unstable의 분류가 왜 필요한지, 컴파일러의 관점에서 설명합니다.

컴파일러의 질문

Compose Compiler가 Skip 로직을 생성할 때 묻는 질문은 하나입니다.

"이 타입의 equals() 결과를 신뢰할 수 있는가?"

  • 신뢰할 수 있다 (Stable): equals()로 비교하여 같으면 Skip
  • 신뢰할 수 없다 (Unstable): 비교 자체를 포기하고 항상 Recompose

"안전한 기본값(safe default)"은 항상 Recompose하는 것입니다. 성능은 떨어질 수 있지만, 결과가 틀리지는 않습니다.

val이 중요한 이유

val vs var의 차이를 단순히 "불변이 좋다"로 외우는 개발자가 많습니다. 하지만 컴파일러 관점에서 보면 이유가 명확합니다.

// Stable — 생성 후 외부에서 변경 불가
data class User(
    val name: String,
    val age: Int
)

// Unstable — var이므로 외부에서 언제든 변경 가능
data class MutableUser(
    var name: String,
    var age: Int
)

var 프로퍼티가 있는 MutableUser의 문제는 이것입니다.

val user = MutableUser("심", 25)
val snapshot1 = user  // equals 비교를 위해 참조 저장

user.name = "민수"    // 외부에서 값 변경

// snapshot1.equals(user) → 두 시점에 다른 결과가 나올 수 있음
// 컴파일러가 "파라미터가 같은가?"를 판단할 기준이 흔들린다

컴파일러가 user를 이전 Composition의 user와 비교하려는 시점에, 누군가 name을 변경해버릴 수 있습니다. 이러면 equals() 결과를 신뢰할 수 없습니다.

val은 이 문제를 원천적으로 차단합니다. 생성 이후 값이 바뀌지 않으니, equals()는 항상 일관된 결과를 반환합니다.

타입 분류

분류조건예시
Stable모든 프로퍼티가 val + Stable 타입String, Int, data class (val만)
Immutable생성 후 절대 변경 불가원시 타입, @Immutable 선언 클래스
Unstablevar 포함, 또는 Unstable 프로퍼티 포함var 포함 클래스, List<T>

List가 Unstable인 이유: 타입 시스템의 한계

이 부분은 타입 이론 관점에서 보면 더 명확합니다.

fun processList(users: List<User>) { ... }

Kotlin의 List<T>는 인터페이스입니다. 실제 구현체가 ArrayList(mutable)일 수도, Collections.unmodifiableList(immutable)일 수도 있습니다. 컴파일러는 컴파일 시점에 구현체를 알 수 없습니다.

val users: List<User> = mutableListOf(User("심", 25))

// 컴파일러 관점: users가 MutableList인지 확인 불가
// → 안전하게 Unstable로 처리

이것은 Kotlin 타입 시스템의 한계입니다. List라는 인터페이스가 불변을 보장하지 않기 때문입니다. 해결책은 컴파일러가 확신할 수 있는 불변 타입을 사용하는 것입니다.

// kotlinx.collections.immutable 사용
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList

@Composable
fun UserList(users: ImmutableList<User>) {  // Stable
    LazyColumn {
        items(users) { user ->
            UserCard(user.name, user.age)
        }
    }
}

ImmutableList는 구현 수준에서 불변성을 보장하므로, 컴파일러가 Stable로 판단할 수 있습니다.


@Stable과 @Immutable은 컴파일러와의 계약이다

이제 본론입니다.

컴파일러가 자동으로 Stable로 판단하지 못하는 경우, 개발자가 직접 선언할 수 있습니다.

// @Immutable: "이 타입의 인스턴스는 생성 후 절대 변하지 않습니다"
@Immutable
data class Config(
    val apiKey: String,
    val baseUrl: String
)

// @Stable: "변할 수 있지만, 변경 시 Composition에 알립니다"
@Stable
class CounterState(initialCount: Int = 0) {
    var count by mutableStateOf(initialCount)  // Snapshot System으로 관찰 가능
        private set

    fun increment() { count++ }
}

여기서 이 애너테이션의 본질을 정확히 이해해야 합니다.

  • @Immutable: "이 타입의 인스턴스는 생성 후 절대 바뀌지 않는다" — 컴파일러와의 계약
  • @Stable: "이 타입이 바뀌면 Compose가 반드시 감지할 수 있다" — 컴파일러와의 계약

계약이 성립하면 컴파일러가 Skip을 허용합니다. 계약을 위반하면 어떻게 될까요?

거짓 계약: 조용한 버그

@Stable  // 거짓 계약
// ArrayList는 외부에서 감지 불가능하게 변경될 수 있음
data class BadState(val items: ArrayList<String>)

@Composable
fun MyList(state: BadState) {
    Column {
        state.items.forEach { item ->
            Text(item)
        }
    }
}
// 사용하는 쪽
val state = BadState(arrayListOf("Apple", "Banana"))
MyList(state)

// 나중에
state.items.add("Cherry")  // Compose가 감지 못함
// MyList는 Skip됨 — "Cherry"가 화면에 나타나지 않음

@Stable을 붙였으니 컴파일러는 "이 타입의 변경은 항상 감지 가능하다"고 믿습니다. 그런데 ArrayListmutableStateOf 없이 직접 변경했으므로 Compose가 변경을 모릅니다.

결과는 성능 저하가 아니라 UI 버그입니다. 데이터는 바뀌었는데 화면이 갱신되지 않습니다.

또 다른 형태의 거짓 계약도 있습니다.

@Stable
class BrokenState {
    var value: Int = 0  // mutableStateOf가 아님 → Compose가 변경을 감지하지 못함
}

겉보기엔 @Stable의 조건("변경 시 Composition에 알린다")을 지킨 것 같지만, valuemutableStateOf가 아니라 일반 var이므로 값이 바뀌어도 Snapshot System에 알림이 가지 않습니다. 역시 조용한 버그입니다.

@Stable/@Immutable을 성능 도구로 쓰면 이런 일이 생깁니다. "성능을 높이려고" 붙였는데, 실제로는 컴파일러에게 거짓말을 한 것이고, 그 거짓말이 버그로 돌아옵니다.


자동 최적화 vs 수동 최적화: 프레임워크별 접근

1편부터 계속해온 크로스 프레임워크 비교를 이어갑니다. "불필요한 재실행을 어떻게 방지하는가?"에 대한 각 프레임워크의 해법입니다.

React: 개발자가 직접 최적화

// React: 수동 메모이제이션
const UserCard = React.memo(({ name, age }) => {
    return <div>{name}, {age}</div>;
});

// 부모 컴포넌트
function UserList({ users }) {
    // useCallback으로 콜백도 메모이제이션해야 함
    const handleClick = useCallback((id) => {
        // ...
    }, []);

    return users.map(user => (
        <UserCard
            key={user.id}
            name={user.name}
            age={user.age}
            onClick={handleClick}  // useCallback 없으면 매번 새 함수 → 메모 무효화
        />
    ));
}

React에서는 React.memo로 컴포넌트를 감싸야 불필요한 re-render를 방지할 수 있습니다[^3]. 그리고 useMemo, useCallback으로 props와 콜백도 별도로 메모이제이션해야 합니다.

개발자가 최적화의 책임을 집니다. React.memo를 빠뜨리면 불필요한 re-render가 발생하고, useCallback을 빠뜨리면 React.memo가 무효화됩니다.

React 19(2024.12 정식 릴리스)에서는 React Compiler[^4]가 도입되어 이 수동 메모이제이션을 자동화했습니다. Compose가 처음부터 가지고 있던 것을 React도 컴파일러 레벨에서 해결한 것입니다.

Compose: 컴파일러가 자동 최적화

// Compose: 자동 메모이제이션 (개발자는 아무것도 하지 않음)
@Composable
fun UserCard(name: String, age: Int) {
    Text("$name, $age살")
}

// 부모 Composable
@Composable
fun UserList(users: ImmutableList<User>) {
    LazyColumn {
        items(users) { user ->
            UserCard(user.name, user.age)
            // name과 age가 Stable(String, Int)이므로
            // 컴파일러가 자동으로 Skip 로직 생성
        }
    }
}

Compose에서는 개발자가 최적화 코드를 작성하지 않습니다. 파라미터 타입이 Stable하면, 컴파일러가 알아서 Skip 로직을 생성합니다. 개발자의 역할은 "좋은 코드를 쓰는 것"입니다. 순수 함수를 지키고, 불변 타입을 사용하면 됩니다.

SwiftUI: 구조체 기반 자동 비교

// SwiftUI: struct의 Equatable 기반 diffing
struct UserCard: View {
    let name: String
    let age: Int

    var body: some View {
        Text("\(name), \(age)살")
    }
}

SwiftUI는 Swift의 struct를 활용합니다[^5]. struct는 값 타입이므로 복사 시 독립적인 인스턴스가 생성됩니다. 프레임워크가 이전 struct와 새 struct를 비교하여, 변경이 없으면 body를 다시 평가하지 않습니다.

Swift 언어 자체가 값 타입(struct)과 참조 타입(class)을 명확히 구분하기 때문에, Compose처럼 별도의 Stability 분석이 덜 필요합니다.

Vue: 반응형 그래프 자동 추적

<!-- Vue: Proxy 기반 자동 의존성 추적 -->
<script setup>
import { ref, computed } from 'vue'

const name = ref('심')
const age = ref(25)

// computed: 의존하는 ref가 변할 때만 재계산
const display = computed(() => `${name.value}, ${age.value}`)
</script>

<template>
  <div>{{ display }}</div>
  <!-- name이나 age가 변할 때만 이 부분 업데이트 -->
</template>

Vue는 JavaScript의 Proxy를 활용하여 반응형 의존성 그래프를 구축합니다[^6]. 어떤 데이터가 어떤 템플릿 부분에서 사용되는지 자동으로 추적하므로, 개발자가 메모이제이션을 신경 쓸 필요가 거의 없습니다.

Flutter: 반자동 최적화

// Flutter: const constructor + shouldRebuild
class UserCard extends StatelessWidget {
  final String name;
  final int age;

  // const constructor가 가능하면 Flutter가 rebuild 방지
  const UserCard({required this.name, required this.age});

  
  Widget build(BuildContext context) {
    return Text('$name, $age살');
  }
}

// 사용 시 const 키워드로 최적화 힌트
const UserCard(name: '심지', age: 25)

Flutter에서는 const 생성자를 사용하면 위젯 인스턴스를 컴파일 타임에 캐싱합니다[^7]. 하지만 런타임 값에 대해서는 const를 쓸 수 없으므로, 최적화 범위가 제한적입니다.

비교 정리

프레임워크최적화 방식개발자 부담최적화 시점
ComposeStability 기반 자동 Skip낮음 (좋은 타입 설계)컴파일 타임
React (18 이하)React.memo + useMemo + useCallback높음 (수동)런타임
React (19+)React Compiler 자동 메모이제이션낮음 (자동)컴파일 타임
SwiftUIstruct Equatable diffing낮음 (언어가 보장)런타임
VueProxy 반응형 그래프낮음 (자동 추적)런타임
Flutterconst + shouldRebuild중간컴파일/런타임

흥미로운 점은, React 19 이전에는 React만 수동 최적화를 요구했지만, React Compiler 도입 이후 모든 주요 선언형 UI 프레임워크가 자동 최적화를 지원하게 되었다는 것입니다. 다만 자동화의 방식은 각각 다릅니다.

차이는 무엇을 근거로, 누가 신뢰를 결정하는가입니다.

  • Compose: 타입 계약(Stability) — 개발자가 타입으로 보장을 선언 → 컴파일러가 판단
  • React Compiler: 코드 정적 분석 — 컴파일러가 함수를 분석해서 판단
  • SwiftUI: 값 타입 — 언어가 값 의미론을 보장
  • Vue: 런타임 추적 — 실행 중에 의존성을 자동으로 파악
  • Flutter: const 선언 — 개발자가 불변성을 직접 보장

방식은 다르지만 목표는 같습니다. "이 함수는 순수하다"는 것을 증명하고, 그 증명이 성립하면 Skip합니다. 이 수렴은 우연이 아닙니다. 순수 함수의 수학적 성질에서 필연적으로 따라오는 귀결입니다.


정리

결국 Compose의 설계 철학은 하나입니다. 개발자가 올바른 원칙(순수 함수, 불변 타입, 단방향 데이터 흐름)을 따르면, 컴파일러가 성능 최적화를 자동으로 수행합니다.

React 18 이하에서는 React.memo, useMemo, useCallback을 빠뜨리지 않아야 했고, React 19에서는 React Compiler가 이를 자동화했습니다. Compose에서는 처음부터 val을 쓰고 순수 함수를 지키면 컴파일러가 알아서 합니다.

다만 한 가지 함정이 있습니다. @Stable@Immutable을 "Skip을 더 많이 일으키는 성능 튜닝 도구"로 오해하면 위험합니다. 이 애너테이션은 튜닝 옵션이 아니라 컴파일러와의 계약입니다. "이 타입의 equals()를 신뢰해도 된다", "이 타입이 바뀌면 반드시 감지할 수 있다"고 개발자가 보증하는 선언이죠. 보증이 실제로 성립할 때만 붙여야 하고, 성능을 높이려고 남용하면 거짓 계약이 되어 UI가 갱신되지 않는 조용한 버그를 만듭니다. 성능은 올바른 타입 설계의 부산물이지, 애너테이션을 더 붙인다고 나오는 것이 아닙니다.

물론 이것이 Compose가 "더 좋다"는 뜻은 아닙니다. React의 명시적 최적화, SwiftUI의 값 타입 활용, Vue의 반응형 추적 모두 각자의 설계 철학이 반영된 trade-off입니다. 중요한 것은 각 프레임워크가 같은 문제를 풀고 있다는 것, 그리고 핵심 원리를 이해하면 어떤 프레임워크든 빠르게 적응할 수 있다는 것입니다.


이 글은 Compose의 Stability와 Compiler를 공부하면서 정리한 내용입니다. 다른 프레임워크에 대한 비교는 공식 문서를 참고했지만 부정확한 부분이 있을 수 있습니다. 틀린 내용이나 보완할 점이 있다면 댓글로 알려주시면 감사하겠습니다.


시리즈

참고 자료

공식 문서

프레임워크 비교


[^1]: Compose Compiler는 Kotlin Compiler Plugin으로, 빌드 시점에 @Composable 함수를 변환합니다. kotlinx.serialization, Parcelize 등과 같은 메커니즘입니다.

[^2]: $changed 비트마스크의 정확한 구조는 Compose Runtime 소스코드에서 확인할 수 있습니다. 이 글에서는 이해를 위해 단순화했습니다.

[^3]: React - memo — "memo lets you skip re-rendering a component when its props are unchanged."

[^4]: React Compiler — React 19(2024.12)에서 도입된 컴파일러. 수동 메모이제이션을 자동화합니다.

[^5]: SwiftUI Managing User Interface State — SwiftUI가 struct 기반 View를 관리하는 방식.

[^6]: Vue Reactivity in Depth — Vue의 Proxy 기반 반응형 시스템.

[^7]: Flutter Performance: Best practices — Use const widgets — const constructor를 사용한 위젯 캐싱.

profile
안드로이드 개발 공부

0개의 댓글