Compose 와 다른 선언형 UI 프레임워크의 상태 관리

지훈·2026년 2월 4일

이 글의 독자

  1. Compose 입문 후 "왜?"가 궁금해진 개발자

    • remember, mutableStateOf 사용법은 알지만
    • "왜 이렇게 동작하는지" 내부 원리가 궁금한 분
  2. 다른 선언형 UI 경험자 (React, Flutter, SwiftUI)

    • 이미 선언형 UI 개념은 익숙하지만
    • Compose만의 차이점(Snapshot System)을 알고 싶은 분
  3. Recomposition 최적화가 필요한 개발자

    • 성능 이슈를 겪고 있거나
    • "어디서 불필요한 recomposition이 발생하는지" 파악하고 싶은 분

선언형 UI 프레임워크의 핵심

"상태가 바뀌면 UI가 자동으로 바뀐다."

이 한 문장이 명령형에서 선언형 UI로의 전환을 요약한다고 합니다. 그렇다면 프레임워크는 어떤 상태가 변했는지 어떻게 알까요? 변한 상태와 연결된 UI만 어떻게 찾아낼까요? 불필요한 UI 업데이트는 어떻게 방지할까요?

React(2013)[^1]가 Virtual DOM 이라는 이 문제에 첫 대중적 해답을 제시한 이후, SwiftUI(2019)[^2], Jetpack Compose(2021)[^3]가 각자의 방식으로 같은 문제를 풀었다고 합니다. 흥미로운 점은 세 프레임워크가 서로 약간씩 다른 접근법을 택했다는 것입니다.

이 글에서는 Compose가 상태를 다루는 방식을 살펴보고, 다른 프레임워크와의 비교를 통해 선언형 UI의 핵심 원리를 이해해보려 합니다.

다른 프레임워크들의 기술을 이해하지 못해도 괜찮습니다. 그것들도 결국 선언형 UI 라는 것만 인지하면서 읽으셔도 됩니다.


상태 추적의 진화: Observer Pattern에서 자동 구독까지

1. 명시적 구독 (Observer Pattern)

Android 개발자라면 LiveData가 익숙할 것입니다.

// Android LiveData - 명시적 구독
liveData.observe(lifecycleOwner) { value ->
    textView.text = value  // 수동으로 UI 업데이트
}

개발자가 직접 구독하고, 콜백에서 UI를 업데이트합니다. 문제는 명확합니다. 구독 해제를 잊으면 메모리 누수가 발생하고, 여러 상태를 조합할 때 콜백 지옥에 빠지기 쉽습니다. LiveData가 Lifecycle-aware로 일부 문제를 해결했지만, 근본적인 한계는 남아있었습니다.

2. 프레임워크 추적 (React, Flutter)

공식 문서에 따르면[^4], React와 Flutter는 상태 변경의 추적을 프레임워크가 담당하도록 바꿨다고 합니다.

// React / React Native
const [count, setCount] = useState(0);
// setCount 호출 → React가 컴포넌트 re-render
// Flutter
setState(() => count++);
// setState 호출 → Flutter가 Widget과 서브트리 rebuild[^5]

개발자는 "상태를 변경한다"는 의도만 표현하고, 프레임워크가 어떤 UI를 업데이트할지 결정하는 방식입니다. 하지만 여전히 setter를 호출해야 합니다. count = count + 1만으로는 프레임워크가 변경을 감지하지 못한다고 합니다.

3. 읽기 시점 자동 구독 (Compose)

Compose는 한 단계 더 나아갔습니다.

// Jetpack Compose
var count by remember { mutableStateOf(0) }
Text("$count")  // 읽는 순간 자동으로 구독!

핵심 차이는 setter가 아니라 getter에서 구독이 발생한다는 점입니다. Text Composable이 count를 읽는 순간, Compose는 "이 Text는 count에 의존한다"고 기록합니다. 이후 count가 변경되면 해당 Text만 정확히 다시 실행됩니다.

이것이 어떻게 가능한지 이해하려면, Compose의 Snapshot System을 살펴봐야 합니다.


Snapshot System: Compose만의 접근

React의 선택: Virtual DOM + Diffing

React 공식 문서를 보면[^6], React는 상태가 변경되면 전체 컴포넌트 함수를 다시 실행합니다. 재실행된 컴포넌트들의 새 Element 객체들이 생성되고, 기존과 비교(reconciliation)하여 실제로 변경된 부분만 DOM에 반영하는 식입니다.

function Parent() {
    const [count, setCount] = useState(0);
    return (
        <div>
            <Child1 count={count} />  {/* count 사용 */}
            <Child2 />                 {/* count 미사용 */}
        </div>
    );
}
// count 변경 시: Parent, Child1, Child2 모두 함수 실행
// → Virtual DOM 비교 → 실제 변경은 Child1만
// (React.memo()[^7]로 Child2의 re-render를 방지할 수 있다고 합니다)

유연하고 예측 가능하지만, 함수 실행 자체의 비용이 존재한다는 의견이 많습니다.

Compose의 선택: Snapshot + 세밀한 의존성 추적

Compose는 다른 방식을 택합니다. React가 컴포넌트 단위로 다시 실행한 뒤 diffing으로 변경점을 찾는 반면, Compose는 개별 State 단위로 "누가 이 값을 읽었는가"를 추적합니다.

이를 가능하게 하는 것이 Snapshot System[^8]입니다. Snapshot System은 상태의 읽기와 쓰기를 가로채서, 어떤 Composable이 어떤 State를 읽었는지 정밀하게 기록합니다.

@Composable
fun Parent() {
    var count1 by remember { mutableStateOf(0) }
    var count2 by remember { mutableStateOf(0) }

    Column {
        Text("Count1: $count1")  // count1만 구독
        Text("Count2: $count2")  // count2만 구독
        Text("Static")           // 아무것도 구독 안 함
    }
}
// count1 변경 시: 첫 번째 Text만 Recomposition
// 나머지는 실행조차 되지 않음

어떻게 보면, Git의 commit과 비슷하게 생각할 수도 있습니다. Snapshot은 특정 시점의 모든 State 값을 캡처합니다. Compose는 각 Composable이 어떤 State를 "읽었는지" 추적하고, 해당 State가 변경된 Snapshot이 적용될 때 관련 Composable만 다시 실행합니다.

관련해서 굉장히 자세히 설명된 글이 있으니 SnapShot System 을 더 깊게 알고 싶으신 분은 참고하세요

왜 이 차이가 중요한가?

두 접근법 모두 결과적으로 "필요한 UI만 업데이트"합니다. 하지만 언제 그 결정을 내리는지가 다릅니다.

  • React: 런타임에 전체를 실행한 후 diffing으로 결정
  • Compose: 읽기 시점에 의존성을 기록하고, 변경 시 해당 부분만 실행

Compose 방식은 불필요한 함수 실행 자체를 줄이는 것으로 보입니다. 반면 React 방식은 예측 가능성과 디버깅 편의성에서 장점이 있다는 의견도 있습니다. 어느 쪽이 "더 좋다"가 아니라, 각 프레임워크의 설계 철학이 반영된 trade-off라고 생각합니다.


Recomposition의 범위 제어

"정확히 필요한 부분만 다시 그린다"는 말이 실제로 어떻게 동작하는지 실험해봅시다.

@Composable
fun GranularDemo() {
    var count1 by remember { mutableStateOf(0) }
    var count2 by remember { mutableStateOf(0) }

    Column {
        CounterDisplay(count1, "Counter1")
        CounterDisplay(count2, "Counter2")

        Row {
            Button(onClick = { count1++ }) { Text("+1") }
            Button(onClick = { count2++ }) { Text("+2") }
        }
    }
}

@Composable
fun CounterDisplay(count: Int, label: String) {
    // Recomposition 발생 시 로그 출력
    SideEffect { Log.d("Recomposition", "$label recomposed") }
    Text("$label: $count")
}

첫 번째 "+1" 버튼을 누르면 "Counter1 recomposed"만 출력됩니다. 두 번째 "+2" 버튼을 누르면 "Counter2 recomposed"만 출력됩니다. Android Studio의 Layout Inspector에서도 recomposition count를 확인할 수 있습니다.[^9]

SwiftUI와의 비교

SwiftUI 문서를 보니[^10], SwiftUI도 비슷한 최적화를 수행하는 것 같습니다. 다만 Property Wrapper 구분이 필요해 보입니다.

// SwiftUI
struct Parent: View {
    @State private var count = 0  // 이 View가 소유

    var body: some View {
        VStack {
            ChildView(count: $count)  // $로 Binding 전달
        }
    }
}

struct ChildView: View {
    @Binding var count: Int  // 부모의 상태에 바인딩
    // ...
}

Compose는 by delegation 하나로 일관되게 처리합니다. remember { mutableStateOf() }rememberSaveable { mutableStateOf() }든 사용 방식이 동일합니다. (SwiftUI를 직접 써보지 않아서 정확한 비교는 어렵습니다.)


remember의 정체: 메모이제이션 그 이상

remember는 단순한 캐싱이 아닙니다. Compose의 Slot Table[^8]이라는 데이터 구조와 연결되어 있습니다.

Slot Table: Composition의 메모리

Composable 함수가 처음 실행될 때, remember의 값은 Slot Table에 저장됩니다. 이후 Recomposition이 발생해도 Slot Table에서 값을 읽어오기 때문에 재생성되지 않습니다. Composition이 제거될 때(화면을 벗어날 때) Slot Table도 정리되어 GC 대상이 됩니다.

Initial Composition: remember { expensive() } 실행 → Slot에 저장
Recomposition: Slot에서 값 읽기 (expensive() 재실행 X)
Leave Screen: Composition 제거 → Slot 정리 → GC

React Hooks와의 유사성

React 문서에 따르면[^11], useState도 비슷한 메커니즘을 사용하는 것 같습니다.

// React - 호출 순서로 상태 식별 (React 공식 문서 기반)
function Component() {
    const [a, setA] = useState(1);  // 첫 번째 슬롯
    const [b, setB] = useState(2);  // 두 번째 슬롯
}
// Compose - Slot Table 위치로 상태 식별
@Composable
fun Component() {
    val a = remember { mutableStateOf(1) }  // 첫 번째 슬롯
    val b = remember { mutableStateOf(2) }  // 두 번째 슬롯
}

둘 다 "호출 순서"가 중요합니다. 조건문 안에서 Hook이나 remember를 호출하면 순서가 바뀔 수 있어 예상치 못한 버그가 발생합니다. 이것이 React의 "Rules of Hooks"[^12]와 Compose의 유사한 규칙이 존재하는 이유라고 합니다.


remember vs rememberSaveable: 생명주기의 선택

두 함수의 차이는 얼마나 오래 값을 유지하는가에 있습니다.[^13]

상황rememberrememberSaveable
Recomposition유지유지
Configuration Change (화면 회전)리셋유지
Process Death (시스템 종료)리셋유지

선택 기준

  • remember: 스크롤 위치, 애니메이션 상태, 포커스 상태 등 일시적인 UI 상태
  • rememberSaveable: 사용자 입력값, 탭 선택 상태, 검색어 등 보존이 필요한 데이터
// 스크롤 위치 - 화면 회전 시 맨 위로 가도 괜찮음
val scrollState = rememberScrollState()

// 사용자가 입력한 텍스트 - 화면 회전 시 사라지면 안 됨
var text by rememberSaveable { mutableStateOf("") }

다른 프레임워크의 대응

ComposeReact NativeFlutterSwiftUI
rememberuseStateState 객체@State
rememberSaveableAsyncStorage + 수동 복원SharedPrefs + 수동 복원@SceneStorage[^14]

Compose의 rememberSaveable은 이 과정을 자동화했다는 점에서 개발 경험이 좋습니다. 단, 저장 가능한 타입이어야 하고(Parcelable, Saver 구현 등), 큰 데이터는 ViewModel이나 다른 저장소를 사용해야 합니다.


선언형 UI

네 가지 모바일 프레임워크의 Counter 구현을 비교해봤습니다. (다른 프레임워크 코드는 공식 문서와 예제를 참고했습니다.)

// React Native
const Counter = () => {
    const [count, setCount] = useState(0);
    return (
        <TouchableOpacity onPress={() => setCount(count + 1)}>
            <Text>{count}</Text>
        </TouchableOpacity>
    );
};
// Flutter
class _CounterState extends State<Counter> {
    int count = 0;
    
    Widget build(BuildContext context) {
        return ElevatedButton(
            onPressed: () => setState(() => count++),
            child: Text('$count'),
        );
    }
}
// SwiftUI
struct Counter: View {
    @State var count = 0
    var body: some View {
        Button("\(count)") { count += 1 }
    }
}
// Jetpack Compose
@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) { Text("$count") }
}

문법은 다르지만, 패턴은 놀랍도록 유사해 보입니다.

  1. 상태 선언: useState / setState / @State / mutableStateOf
  2. UI 정의: 상태를 읽어 화면에 표시
  3. 이벤트 처리: 사용자 액션에서 상태 변경
  4. 자동 갱신: 프레임워크가 변경 감지 후 UI 업데이트

핵심 정리

  1. 상태 추적의 진화: 명시적 Observer → setter 기반 추적 → 읽기 시점 자동 구독
  2. Compose의 차별점: Snapshot System으로 세밀한 의존성 추적, 필요한 Composable만 재실행
  3. 공통 원리: 상태 → UI 단방향 흐름, 프레임워크 제어 업데이트, 최소한의 재렌더링

다른 프레임워크를 배울 때

React의 useState가 익숙하다면 Compose의 mutableStateOf는 금방 적응할 수 있을 것 같습니다. SwiftUI의 @State를 이해했다면 remember의 생명주기도 직관적으로 느껴질 것입니다.

결국 모든 선언형 UI 프레임워크는 같은 문제를 풀고 있는 것 같습니다. "상태 변화를 UI에 어떻게 효율적으로 반영할 것인가." 각자의 해법이 다를 뿐, 핵심 원리를 이해하면 문법은 표면적인 차이에 불과하다고 생각합니다.


마무리

이 글은 Compose의 State와 Recomposition을 공부하면서 정리한 내용입니다. Compose에 대한 내용은 공식 문서와 실제 테스트를 통해 검증했지만, 다른 프레임워크(React, Flutter, SwiftUI)에 대한 비교는 공식 문서와 자료를 참고한 것이라 부정확한 부분이 있을 수 있습니다.

틀린 내용이나 보완할 점이 있다면 댓글로 알려주시면 정말 감사하겠습니다. 함께 배워가면 좋겠습니다.


각주

[^1]: Why React? - React 첫 공개 (2013)
[^2]: WWDC 2019 - Introducing SwiftUI
[^3]: Jetpack Compose 1.0 stable
[^4]: React - Preserving and Resetting State
[^5]: Flutter - StatefulWidget
[^6]: React - Render and Commit
[^7]: React.memo
[^8]: Under the Hood of Jetpack Compose Part 2 - Snapshot System, Slot Table
[^9]: Lifecycle of Composables
[^10]: SwiftUI - State, SwiftUI - Binding
[^11]: React - Hooks at a Glance
[^12]: Rules of Hooks
[^13]: State and Jetpack Compose - remember vs rememberSaveable
[^14]: SwiftUI - SceneStorage
[^저자]: 저자의 분류 및 해석입니다. 틀린 부분이 있다면 댓글로 알려주세요.

profile
안드로이드 개발 공부

0개의 댓글