State Hoisting은 상태의 DI다

지훈·2026년 2월 26일

State Hoisting을 공부하면서 계속 기시감이 들었다. 상태를 직접 생성하지 않고 외부에서 주입받는다? 이 구조, 분명 어디서 봤다. DI(의존성 주입)였다.

그 순간 State Hoisting이 단순한 "상태를 위로 올리기"가 아니라는 확신이 생겼다.

"상태를 어떻게 추적하는가", "상태를 어디에 둘 것인가"는 완전히 다른 질문이다.

2편이 엔진(Snapshot System)에 대한 이야기였다면, 이 글은 설계(Architecture) 에 대한 이야기다.


이 글의 독자

  1. DI(의존성 주입)를 알고 있는 Android/백엔드 개발자

    • Hilt, Dagger, Koin 등을 사용해봤지만
    • 이 패턴이 UI 상태 관리와 어떻게 연결되는지 생각해본 적 없는 분
  2. State Hoisting을 "상태를 위로 올리는 것"으로만 이해하고 있는 개발자

    • 공식 문서의 예제는 따라해봤지만
    • "왜 이렇게 해야 하는가"에 대한 깊은 확신이 없는 분
  3. 다른 선언형 UI에서 Compose로 전환하는 개발자

    • React의 Lifting State Up, SwiftUI의 @Binding, Vue의 defineProps/defineEmits에 익숙하지만
    • Compose만의 State Holder 체계를 이해하고 싶은 분

의존성 주입, 이미 알고 있는 패턴

Android 개발자라면 의존성 주입(Dependency Injection)에 익숙할 것이다.

// ❌ Hard-coded dependency
class UserRepository {
    private val api = RetrofitClient.create()  // 직접 생성
    private val db = Room.databaseBuilder(...)  // 직접 생성

    fun getUser(id: String): User {
        return api.getUser(id)  // 테스트할 때 실제 API를 호출해야 함
    }
}

// ✅ Dependency Injection
class UserRepository(
    private val api: UserApi,      // 외부에서 주입
    private val db: UserDatabase   // 외부에서 주입
) {
    fun getUser(id: String): User {
        return api.getUser(id)  // 테스트 시 Fake 객체 주입 가능
    }
}

DI의 핵심은 제어의 역전(Inversion of Control) 이다. 객체가 자신의 의존성을 직접 생성하지 않고, 외부에서 주입받는다.
이것만으로 테스트 가능성, 재사용성, 유연성이 극적으로 향상된다.

State Hoisting은 이 원리를 UI 상태에 적용한 것이다.


State Hoisting = State Injection

Compose

// ❌ Stateful: 상태를 직접 생성 (Hard-coded dependency와 동일한 구조)
@Composable
fun TodoItem() {
    var checked by remember { mutableStateOf(false) }

    Row {
        Checkbox(checked = checked, onCheckedChange = { checked = it })
        Text("할 일")
    }
}

// ✅ Stateless: 상태를 외부에서 주입 (DI와 동일한 구조)
@Composable
fun TodoItem(
    checked: Boolean,                    // State ↓ (주입)
    onCheckedChange: (Boolean) -> Unit   // Event ↑ (콜백)
) {
    Row {
        Checkbox(
            checked = checked,
            onCheckedChange = onCheckedChange,
            modifier = Modifier.testTag("checkbox")
        )
        Text("할 일")
    }
}

Stateful TodoItem은 자신의 상태를 직접 생성한다.
UserRepositoryRetrofitClient를 직접 생성하는 것과 같은 구조다.
테스트할 때 특정 상태를 주입할 수 없고, 다른 화면에서 다른 로직으로 재사용할 수 없다.

Stateless TodoItem은 상태를 외부에서 받는다. UserRepositoryUserApi를 주입받는 것과 같다.
테스트 시 원하는 상태를 직접 전달할 수 있고, 어떤 화면에서든 재사용 가능하다.

// DI처럼 테스트가 간단해진다
@Test
fun todoItem_체크_상태_표시() {
    composeTestRule.setContent {
        TodoItem(
            checked = true,  // 원하는 상태를 직접 주입
            onCheckedChange = {}
        )
    }
    composeTestRule.onNodeWithTag("checkbox")
        .assertIsOn()
}

React

React에서는 이것을 "Lifting State Up"[1]
이라고 하는 것 같다.

// ❌ Stateful: 자체 상태 소유
function TodoItem() {
    const [checked, setChecked] = useState(false);

    return (
        <label>
            <input
                type="checkbox"
                checked={checked}
                onChange={(e) => setChecked(e.target.checked)}
            />
            할 일
        </label>
    );
}

// ✅ Stateless: props로 주입
function TodoItem({ checked, onCheckedChange }) {
    return (
        <label>
            <input
                type="checkbox"
                checked={checked}
                onChange={(e) => onCheckedChange(e.target.checked)}
            />
            할 일
        </label>
    );
}

// 부모가 상태를 소유
function TodoList() {
    const [checked, setChecked] = useState(false);
    return <TodoItem checked={checked} onCheckedChange={setChecked} />;
}

React Native

React Native는 React와 동일한 훅(useState)을 사용하되, DOM 대신 네이티브 컴포넌트(Switch, View)를 렌더링한다.

// ❌ Stateful: 자체 상태 소유
function TodoItem() {
    const [checked, setChecked] = useState(false);
    return (
        <View style={styles.row}>
            <Switch value={checked} onValueChange={setChecked} />
            <Text>할 일</Text>
        </View>
    );
}

// ✅ Stateless: props로 주입
function TodoItem({ checked, onCheckedChange }) {
    return (
        <View style={styles.row}>
            <Switch value={checked} onValueChange={onCheckedChange} />
            <Text>할 일</Text>
        </View>
    );
}

// 부모가 상태를 소유
function TodoList() {
    const [checked, setChecked] = useState(false);
    return <TodoItem checked={checked} onCheckedChange={setChecked} />;
}

상태 관리 패턴 자체는 React Web과 완전히 동일하지만, 실행 환경(네이티브 브릿지)이 다르다.
<input> 대신 <Switch>, <div> 대신 <View>를 사용한다는 차이뿐이다.

SwiftUI

SwiftUI는 @Binding Property Wrapper로 State Hoisting을
표현한다[2].

// ❌ Stateful: 자체 상태 소유
struct TodoItem: View {
    @State private var checked = false

    var body: some View {
        Toggle("할 일", isOn: $checked)
    }
}

// ✅ Stateless: 부모의 상태에 바인딩
struct TodoItem: View {
    @Binding var checked: Bool

    var body: some View {
        Toggle("할 일", isOn: $checked)
    }
}

// 부모가 상태를 소유
struct TodoList: View {
    @State private var isChecked = false

    var body: some View {
        TodoItem(checked: $isChecked)  // $ prefix로 Binding 전달
    }
}

@State는 "이 View가 상태를 소유한다", @Binding은 "부모의 상태에 연결된다"는 것을 타입 시스템으로 명시한다.
Compose의remember { mutableStateOf() } vs 매개변수 주입과 같은 역할이지만,
SwiftUI는 Property Wrapper 타입이라는 언어 기능으로 소유권과 참조를 더 명시적으로 구분한다.

Flutter

Flutter는 StatelessWidget / StatefulWidget 클래스 구분으로 이 패턴을 표현한다[3].

// ❌ StatefulWidget: 자체 상태 관리
class TodoItem extends StatefulWidget {
  
  _TodoItemState createState() => _TodoItemState();
}

class _TodoItemState extends State<TodoItem> {
  bool checked = false;

  
  Widget build(BuildContext context) {
    return CheckboxListTile(
      value: checked,
      onChanged: (value) => setState(() => checked = value!),
      title: Text('할 일'),
    );
  }
}

// ✅ StatelessWidget: 상태를 외부에서 주입
class TodoItem extends StatelessWidget {
  final bool checked;
  final ValueChanged<bool> onCheckedChange;

  const TodoItem({
    required this.checked,
    required this.onCheckedChange,
  });

  
  Widget build(BuildContext context) {
    return CheckboxListTile(
      value: checked,
      onChanged: (value) => onCheckedChange(value!),
      title: Text('할 일'),
    );
  }
}

// 부모가 상태를 소유
class TodoList extends StatefulWidget {
  
  _TodoListState createState() => _TodoListState();
}

class _TodoListState extends State<TodoList> {
  bool checked = false;

  
  Widget build(BuildContext context) {
    return TodoItem(
      checked: checked,
      onCheckedChange: (value) => setState(() => checked = value),
    );
  }
}

Flutter는 StatefulWidgetStatelessWidget으로 클래스 자체를 바꿔야 한다. Compose나 React처럼 함수 시그니처만 변경하는 것에 비해
보일러플레이트가 많지만, "이 위젯이 상태를 소유하는가?"를 클래스 계층으로 강제한다는 점에서 의도가 명확하다.

Vue

Vue는 SFC(Single File Component) 안에서 ref()로 상태를 소유하거나,
defineProps/defineEmits로 부모에게
위임한다[12].

<!-- ❌ Stateful: ref()로 자체 상태 소유 -->
<script setup>
import { ref } from 'vue'
const checked = ref(false)
</script>

<template>
  <label>
    <input type="checkbox" v-model="checked" />
    할 일
  </label>
</template>

<!-- ✅ Stateless: defineProps + defineEmits로 주입 -->
<script setup>
defineProps({ checked: Boolean })
defineEmits(['update:checked'])
</script>

<template>
  <label>
    <input
      type="checkbox"
      :checked="checked"
      @change="$emit('update:checked', $event.target.checked)"
    />
    할 일
  </label>
</template>


<!-- 부모에서 사용 -->
<TodoItem v-model:checked="isChecked" />

Vue 에서는 이전 예시와 같은 State Hoisting 이 보이지 않는다.
그런데 Vue의 흥미롭고 독특한 점은 State Hoisting을 언어 기능이 아닌 프레임워크 컨벤션으로 흡수했다는 것이다.
defineProps + defineEmits(['update:xxx'])의 패턴을 따르면,
부모는 v-model:xxx로 자연스럽게 상태를 소유하면서 자식에게 위임할 수 있다.
Compose가 함수 시그니처로, SwiftUI가 @Binding 타입으로 표현한 것을 Vue는 v-model 로 표현한다.

비교 정리

Stateful (Hard-coded)Stateless (Injected)
Composeremember { mutableStateOf() }매개변수 (value, onValueChange)
ReactuseState()props ({ value, onChange }) (DOM)
RNuseState()props ({ value, onChange }) (Native)
SwiftUI@State@Binding
FlutterStatefulWidget + setStateStatelessWidget + 생성자
Vueref()defineProps + defineEmits
DIval api = ApiService()constructor(api: ApiService)

문법은 다르지만 구조는 동일하다. "상태를 직접 소유할 것인가, 외부에서 주입받을 것인가." 이것이 State Hoisting의 본질이고, DI의 본질이다.


어디까지 올릴 것인가

State Hoisting이 좋다고 모든 상태를 최상위로 끌어올리면 안 된다. 과도한 Hoisting은 과도한 DI만큼이나 해롭다.

3가지 규칙

공식 문서[4]에서 제시하는 Hoisting 기준은 세 가지다:

  1. 읽기 기준: State를 읽는 모든 Composable의 최소 공통 조상까지
  2. 쓰기 기준: State를 변경하는 가장 높은 레벨까지
  3. 동시 변경: 같은 이벤트로 변경되는 State는 같은 레벨
// ✅ 적절한 Hoisting
// query는 SearchBar와 ResultList 모두에서 읽힌다
// → 공통 조상인 SearchScreen까지 올린다
@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }

    Column {
        SearchBar(
            query = query,
            onQueryChange = { query = it }
        )
        ResultList(query = query)
    }
}

@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

@Composable
fun ResultList(query: String) {
    // query를 사용하여 결과 표시
}

안티패턴: 과도한 Hoisting

// ❌ 모든 State를 App 최상위로 올림
@Composable
fun App() {
    // 검색 화면의 State
    var searchQuery by remember { mutableStateOf("") }
    var searchResults by remember { mutableStateOf(emptyList<Item>()) }

    // 설정 화면의 State
    var darkMode by remember { mutableStateOf(false) }
    var fontSize by remember { mutableStateOf(14) }

    // 프로필 화면의 State
    var userName by remember { mutableStateOf("") }
    var userEmail by remember { mutableStateOf("") }

    NavHost(...) {
        composable("search") {
            SearchScreen(
                query = searchQuery,
                onQueryChange = { searchQuery = it },
                results = searchResults
            )
        }
        composable("settings") {
            SettingsScreen(
                darkMode = darkMode,
                onDarkModeChange = { darkMode = it },
                fontSize = fontSize,
                onFontSizeChange = { fontSize = it }
            )
        }
        // ...
    }
}

이것은 DI에서 모든 의존성을 Application 레벨 싱글톤으로 등록하는 것과 같다.
검색 화면, 설정 화면, 프로필 화면의 상태가 한 곳에서 관리되면서 서로 관련 없는 상태끼리 결합되고, App Composable의 책임이 비대해진다.
Compose의 Smart Recomposition이 실제 영향 범위를 제한해주더라도, 코드의 구조적 결합은 유지보수를 어렵게 만든다.

원칙: "최소한 필요한 만큼만" 올린다. State를 사용하는 Composable들의 공통 조상까지만.
DI에서 의존성의 스코프를 Hilt 에서처럼 @Singleton,@ActivityScoped, @ViewModelScoped로 구분하는 것과 같은 사고방식이다.

React 생태계에서는 이 문제를 "Prop Drilling" 이라고 부르며[5]
props(부모가 자식에게 전달하는 데이터)를 여러 계층에 걸쳐 뚫고(drill)내려보낸다는 의미다
— 보통 Context API나 다른 상태 관리 라이브러리(Redux, Zustand)로 해결한다고 한다.
React Native도 React와 동일한 Context API(createContext, useContext)를 그대로 사용하므로 패턴이 완전히 같다.

// ❌ React - Prop Drilling 문제
function App() {
    const [theme, setTheme] = useState('light');
    return <Layout theme={theme} setTheme={setTheme} />;
}
function Layout({ theme, setTheme }) {
    return <Sidebar theme={theme} setTheme={setTheme} />;
}
function Sidebar({ theme, setTheme }) {
    return <ThemeToggle theme={theme} setTheme={setTheme} />;
    // App → Layout → Sidebar → ThemeToggle
    // 중간 컴포넌트들은 theme을 사용하지도 않으면서 전달만 한다
}

// ✅ Context API로 해결
const ThemeContext = createContext();

function App() {
    const [theme, setTheme] = useState('light');
    return (
        <ThemeContext.Provider value={{ theme, setTheme }}>
            <Layout />
        </ThemeContext.Provider>
    );
}
function ThemeToggle() {
    const { theme, setTheme } = useContext(ThemeContext);
    // 중간 컴포넌트를 거치지 않고 직접 접근
}

Compose에서는 CompositionLocal[14]이 같은 역할을 한다:

// Compose - CompositionLocal
val LocalTheme = compositionLocalOf { Theme.Light }

@Composable
fun App() {
    var theme by remember { mutableStateOf(Theme.Light) }

    CompositionLocalProvider(LocalTheme provides theme) {
        Layout()  // theme을 매개변수로 전달할 필요 없음
    }
}

@Composable
fun ThemeToggle() {
    val theme = LocalTheme.current  // 트리 어디서든 직접 접근
}

SwiftUI에서는 @Environment[15]가 이 역할이다:

// SwiftUI - Environment (의사 코드: 실제 사용 시 커스텀 EnvironmentKey 구현 필요)
struct App: View {
    @State private var theme = Theme.light

    var body: some View {
        Layout()
            .environment(\.theme, theme)  // 하위 트리 전체에 제공
    }
}

struct ThemeToggle: View {
    @Environment(\.theme) var theme  // 트리 어디서든 직접 접근
}

Vue에서는 provide/inject가 같은 역할이다[11]:

<!-- 부모 컴포넌트 -->
<script setup>
import { ref, provide } from 'vue'

const theme = ref('light')
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide('theme', { theme, toggleTheme })
</script>

<!-- 하위 컴포넌트 (중간 컴포넌트를 거치지 않고 직접 접근) -->
<script setup>
import { inject } from 'vue'
const { theme, toggleTheme } = inject('theme')
</script>

Vue의 provide/inject는 React의 Context API, Compose의 CompositionLocal과 같은 역할이다. 부모가 provide하면
하위 트리 어디서든 inject로 접근할 수 있어, 중간 컴포넌트를 거치지 않는다.

과도한 Hoisting의 해결책은 모든 선언형 UI 프레임워크에서 동일하다. 트리 전체에 걸쳐 공유해야 하는 상태는 Hoisting 대신 암시적 전달 메커니즘을 사용한다.


단방향 데이터 흐름 (UDF)

State Hoisting을 적용하면 자연스럽게 단방향 데이터 흐름(Unidirectional Data Flow)이 만들어진다.

State ↓  (부모 → 자식: 데이터)
Event ↑  (자식 → 부모: 이벤트)

이 패턴은 Compose가 발명한 것이 아니다. 그 기원은 Elm Architecture(2012)까지 거슬러 올라간다.

Elm: UDF의 원조

Elm[6]은 순수 함수형 언어로,
웹 애플리케이션을 Model-Update-View 세 가지 요소로 구성한다:

-- Model: 애플리케이션의 전체 상태
type alias Model =
    { count : Int }

-- Msg: 발생할 수 있는 모든 이벤트
type Msg
    = Increment
    | Decrement

-- Update: 이벤트를 받아 새 상태를 반환하는 순수 함수
-- (실제 Elm에서는 Cmd Msg로 부수효과도 반환하나, 여기서는 핵심 흐름만 표현)
update : Msg -> Model -> Model
update msg model =
    case msg of
        Increment ->
            { model | count = model.count + 1 }
        Decrement ->
            { model | count = model.count - 1 }

-- View: 상태를 받아 UI를 반환하는 순수 함수
view : Model -> Html Msg
view model =
    div []
        [ button [ onClick Decrement ] [ text "-" ]
        , text (String.fromInt model.count)
        , button [ onClick Increment ] [ text "+" ]
        ]
Model → view() → Html → 사용자 클릭 → Msg → update() → 새 Model → ...

이 흐름에서 상태 변경의 경로는 단 하나다. View가 직접 Model을 수정하지 않는다. 반드시 Msg를 발행하고, update 함수가 새 Model을 만든다. 양방향
바인딩이 없으므로 상태 동기화 버그가 구조적으로 불가능하다.

Flux: UDF가 프론트엔드 메인스트림으로

2014년, Facebook은 채팅 알림 버그로 고생하고 있었다. 읽지 않은 메시지 수가 실제와 맞지 않는 문제였다.
원인은 양방향 데이터 바인딩이었다. Model과 View가 서로를 직접 갱신하면서, 컴포넌트가 많아질수록 상태 변경의 경로를 추적할 수 없었다.

Flux[16]는 이 문제를 단방향 흐름으로 해결했다:

Action → Dispatcher → Store → View → (사용자 이벤트) → Action → ...
  • Action: "무엇이 일어났는가"를 서술하는 객체 (예: { type: 'MESSAGE_READ', id: 42 })
  • Dispatcher: 모든 Action을 받아 등록된 Store들에 순서대로 전달하는 중앙 허브
  • Store: 상태를 보유하고, Dispatcher에서 받은 Action에 따라 상태를 변경
  • View: Store의 상태를 구독하여 UI를 렌더링

Elm Architecture와의 차이는 Dispatcher의 존재다.
Elm은 런타임이 암묵적으로 메시지를 라우팅하지만,
Flux는 Dispatcher라는 명시적 중앙 허브를 두어 여러 Store 간의 의존성을 waitFor()로 조율한다.
또한 Elm의 update는 순수 함수지만, Flux의 Store는 내부 상태를 직접 변이한다.

Flux가 증명한 것은 "단방향이면 디버깅이 된다"는 사실이었다. 이 아이디어에 Elm의 순수 함수 개념을 결합한 것이 Redux다.

Redux: Flux + Elm의 순수 함수

Redux[7]는 Flux의 단방향 흐름에 Elm Architecture의 순수 함수 개념을 결합했다:

// Action (= Elm의 Msg)
const increment = () => ({ type: 'INCREMENT' });
const decrement = () => ({ type: 'DECREMENT' });

// Reducer (= Elm의 update, 순수 함수)
function counterReducer(state = { count: 0 }, action) {
    switch (action.type) {
        case 'INCREMENT':
            return { ...state, count: state.count + 1 };
        case 'DECREMENT':
            return { ...state, count: state.count - 1 };
        default:
            return state;
    }
}

// Component (= Elm의 view)
function Counter({ count, onIncrement, onDecrement }) {
    return (
        <div>
            <button onClick={onDecrement}>-</button>
            <span>{count}</span>
            <button onClick={onIncrement}>+</button>
        </div>
    );
}

// Store에서 연결 (Redux 원형 — 현재는 Redux Toolkit의 configureStore 사용)
const store = createStore(counterReducer);
Store → Component → 사용자 클릭 → Action → Reducer → 새 Store → ...

용어만 다르다. Elm의 Model은 Redux의 Store, Msg는 Action, update는 Reducer다.

Compose에서의 UDF

Compose는 ViewModel + sealed class + StateFlow로 같은 패턴을 구현한다:

// State (= Elm의 Model, Redux의 Store)
data class CounterState(val count: Int = 0)

// Event (= Elm의 Msg, Redux의 Action)
sealed interface CounterEvent {
    data object Increment : CounterEvent
    data object Decrement : CounterEvent
}

// ViewModel: 상태 보유(Store) + 이벤트 처리(Reducer) 역할을 겸함
class CounterViewModel : ViewModel() {
    private val _state = MutableStateFlow(CounterState())
    val state = _state.asStateFlow()

    fun onEvent(event: CounterEvent) {
        when (event) {
            CounterEvent.Increment ->
                _state.update { it.copy(count = it.count + 1) }
            CounterEvent.Decrement ->
                _state.update { it.copy(count = it.count - 1) }
        }
    }
}

// Screen: ViewModel과 순수 UI를 연결
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    Counter(
        count = state.count,
        onIncrement = { viewModel.onEvent(CounterEvent.Increment) },
        onDecrement = { viewModel.onEvent(CounterEvent.Decrement) }
    )
}

// 순수 Composable (= Elm의 view, State Hoisting 적용)
@Composable
fun Counter(
    count: Int,
    onIncrement: () -> Unit,
    onDecrement: () -> Unit
) {
    Row {
        Button(onClick = onDecrement) { Text("-") }
        Text("$count")
        Button(onClick = onIncrement) { Text("+") }
    }
}
ViewModel → Composable → 사용자 클릭 → Event → ViewModel → 새 State → ...

Counter Composable은 State Hoisting이 적용된 순수 함수다. 상태를 소유하지 않고, 이벤트를 직접 처리하지 않는다. 1편에서 다뤘던 순수 함수의 조건을
모두 충족한다.

SwiftUI에서의 UDF

SwiftUI도 ObservableObject로 유사한 구조를 만든다[8] (iOS 17+에서는 @Observable로도 가능하지만, 여기서는 호환성이 넓은
ObservableObject를 사용한다):

// ViewModel
class CounterViewModel: ObservableObject {
    @Published private(set) var count = 0

    func increment() {
        count += 1
    }

    func decrement() {
        count -= 1
    }
}

// View
struct CounterScreen: View {
    @StateObject private var viewModel = CounterViewModel()

    var body: some View {
        Counter(
            count: viewModel.count,
            onIncrement: { viewModel.increment() },
            onDecrement: { viewModel.decrement() }
        )
    }
}

// 순수 View
struct Counter: View {
    let count: Int
    let onIncrement: () -> Void
    let onDecrement: () -> Void

    var body: some View {
        HStack {
            Button("-", action: onDecrement)
            Text("\(count)")
            Button("+", action: onIncrement)
        }
    }
}

Flutter에서의 UDF

Flutter의 BLoC(Business Logic Component) 패턴[9]도 같은 구조다:

// Event
abstract class CounterEvent {}
class Increment extends CounterEvent {}
class Decrement extends CounterEvent {}

// State
class CounterState {
  final int count;
  CounterState(this.count);
}

// BLoC (= ViewModel, Reducer)
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(CounterState(0)) {
    on<Increment>((event, emit) => emit(CounterState(state.count + 1)));
    on<Decrement>((event, emit) => emit(CounterState(state.count - 1)));
  }
}

// Widget
class CounterPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return BlocBuilder<CounterBloc, CounterState>(
      builder: (context, state) {
        return Row(
          children: [
            ElevatedButton(
              onPressed: () => context.read<CounterBloc>().add(Decrement()),
              child: Text('-'),
            ),
            Text('${state.count}'),
            ElevatedButton(
              onPressed: () => context.read<CounterBloc>().add(Increment()),
              child: Text('+'),
            ),
          ],
        );
      },
    );
  }
}

Vue에서의 UDF

Vue는 React의 useReducer처럼 내장된 UDF 훅은 없지만, reactive() + dispatch 함수로 Redux/Elm 스타일을 직접 구현할 수 있다.
실제 프로덕션에서는 Pinia 스토어(Vue의 공식 상태 관리 라이브러리)가 이 역할을 한다.

<script setup>
import { reactive } from 'vue'

// State (= Elm의 Model)
const state = reactive({ count: 0 })

// dispatch (Elm의 update와 유사하나, reactive 객체를 직접 변이)
function dispatch(action) {
  switch (action) {
    case 'INCREMENT':
      state.count++
      break
    case 'DECREMENT':
      state.count--
      break
  }
}
</script>

<template>
  <div>
    <button @click="dispatch('DECREMENT')">-</button>
    <span>{{ state.count }}</span>
    <button @click="dispatch('INCREMENT')">+</button>
  </div>
</template>
Action → dispatch() → State 변경 → 반응형 시스템이 UI 갱신

reactive()는 Vue의 반응형 시스템[12]이 변경을 감지하므로, Compose의 MutableState나 React의 setState처럼 명시적 갱신 호출
없이도 UI가 자동으로 업데이트된다.

단, Elm/Redux의 update/reducer는 새 상태를 반환하는 순수 함수인 반면, Vue의 dispatch는 reactive 객체를 직접 변이한다. 이는 Vue의
Proxy 기반 반응형 시스템의 설계 철학에 따른 것으로, 같은 UDF 흐름(Action → 상태 변경 → UI 갱신)을 다른 메커니즘으로 달성한다.

React Native에서의 UDF

React Native는 React의 useReducer를 그대로 사용한다[13]. Redux 없이도 컴포넌트 레벨에서 UDF를 구현할 수 있다.

// Reducer (= Elm의 update, 순수 함수)
function counterReducer(state, action) {
    switch (action.type) {
        case 'INCREMENT':
            return { ...state, count: state.count + 1 };
        case 'DECREMENT':
            return { ...state, count: state.count - 1 };
        default:
            return state;
    }
}

// 순수 Presentational 컴포넌트
function Counter({ count, onIncrement, onDecrement }) {
    return (
        <View style={styles.row}>
            <Pressable onPress={onDecrement}><Text>-</Text></Pressable>
            <Text>{count}</Text>
            <Pressable onPress={onIncrement}><Text>+</Text></Pressable>
        </View>
    );
}

// Container 컴포넌트
function CounterScreen() {
    const [state, dispatch] = useReducer(counterReducer, { count: 0 });
    return (
        <Counter
            count={state.count}
            onIncrement={() => dispatch({ type: 'INCREMENT' })}
            onDecrement={() => dispatch({ type: 'DECREMENT' })}
        />
    );
}
useReducer → dispatch(Action) → Reducer → 새 State → 컴포넌트 리렌더링

Reducer는 Elm의 update, Redux의 reducer와 동일한 순수 함수다. React Web의 Redux 패턴과 거의 동일하지만, useReducer는 외부
라이브러리 없이 컴포넌트 레벨에서 UDF를 구현할 수 있다는 점이 차이다.

개념적 계보

Elm Architecture (2012)
   ↓ Model → Update → View
Flux (2014)
   ↓ Action → Dispatcher → Store → View
Redux (2015)
   ↓ Store → Reducer → Component
...

연도는 정식 출시 기준이며, 발표 시점과 다를 수 있습니다.

Flux(2014)는 "단방향이면 디버깅이 된다"를 증명한 전환점이다. 이후 계보는 여기에 순수 함수 개념을 결합하는 방향으로 발전했다.

Elm → Flux → Redux → Compose 계보의 공통 원칙:

  1. Single Source of Truth: 상태는 한 곳에서만 관리
  2. 단방향 흐름: State ↓, Event ↑
  3. 순수 함수로 상태 변환: 이전 상태 + 이벤트 → 새 상태
  4. 예측 가능성: 상태 변경의 경로가 명확

왜 단방향이어야 하는가

양방향 데이터 바인딩의 문제를 보자:

Component A ⇄ 공유 상태 ⇄ Component B
                  ↕
             Component C

A가 상태를 변경
→ B의 onChange 트리거
→ B가 다시 상태를 변경
→ A의 onChange 트리거
→ 무한 루프? 예측 불가능한 상태?

양방향 바인딩에서는 "누가, 왜 상태를 변경했는가"를 추적하기 어렵다. 컴포넌트가 3개만 되어도 상태 변경의 경로가 기하급수적으로 복잡해진다.

단방향 흐름은 이 문제를 구조적으로 해결한다:

사용자 클릭 → Event → ViewModel(단일 진입점) → 새 State → UI 갱신

상태 변경의 경로가 하나이므로 디버깅이 직관적이다. 버그가 발생하면 "어떤 Event가 발행되었는가"만 추적하면 된다. Redux DevTools이나 MVI의 로깅이 가능한 이유도 이 단방향 구조 덕분이다.


정리

1편: 순수 함수 (함수의 성질)
     "Composable 함수는 왜 순수해야 하는가"
         ↓
2편: State & Recomposition (상태 추적 메커니즘)
     "State가 변경되면 어떻게 필요한 UI만 다시 그리는가"
         ↓
3편: State Hoisting & UDF (상태 배치 아키텍처)
     "State를 어디에, 어떤 구조로 관리할 것인가"

순수 함수(1편)가 목표라면, Snapshot System(2편)은 엔진이고, State Hoisting과 UDF(3편)는 설계 원칙이다.

결국 Compose가 추구하는 것은 하나다. 예측 가능한 UI. 순수 함수로 결정론적 렌더링을 보장하고, Snapshot System으로 효율적인 변경 감지를 수행하며, State Hoisting과 UDF로 상태 변경의 경로를 단순화한다.

세 가지가 결합될 때 비로소 "상태가 바뀌면 UI가 자동으로, 안전하게, 효율적으로 바뀐다"는 선언형 UI의 약속이 실현된다. 그리고 이것은 Compose만의 이야기가 아니다.
React, SwiftUI, Flutter, Vue, React Native, Elm — 문법과 메커니즘은 다르지만 추구하는 방향은 같다.


참고 자료

공식 문서

UDF 계보

비교 프레임워크


각주

[^1]: React - Sharing State Between Components
[^2]: SwiftUI - Binding
[^3]: Flutter - StatelessWidget
[^4]: Where to hoist state
[^5]: React - Passing Data Deeply with Context
[^6]: The Elm Architecture
[^7]: Redux - Three Principles
[^8]: SwiftUI - ObservableObject
[^9]: Flutter BLoC Pattern
[^10]: Compose UI Architecture - State holders
[^11]: Vue - Provide / Inject
[^12]: Vue - Reactivity Fundamentals
[^13]: React - useReducer
[^14]: Compose - CompositionLocal
[^15]: SwiftUI - Environment
[^16]: Flux - In-Depth Overview

profile
안드로이드 개발 공부

0개의 댓글