단방향 데이터 흐름 — 무엇인가, Android와 iOS의 UDF

저자: IT Sectr 게시일: 2026-02-20 읽는 시간: 13 분

단방향 데이터 흐름(Unidirectional Data Flow)이 무엇인지 이해하세요 — 단방향 데이터 스트림, 피드백 루프 없이 데이터가 폐쇄 루프 State → View → Intent → Reducer → State로 이동하는 아키텍처 패턴입니다. 양방향 바인딩과 달리 UDF는 상태 변경이 명시적 액션(Intent/Event)을 통해서만 발생하도록 보장하여 데이터 흐름을 예측 가능하고 추적 가능하게 만듭니다. Google I/O 2024에 따르면 UDF는 중간 및 높은 복잡도의 비즈니스 로직을 가진 Jetpack Compose 및 SwiftUI 애플리케이션에 권장되는 아키텍처입니다.

핵심 사항

  • 단방향 데이터 흐름(UDF) — 상태가 사용자 입력 → Intent → Reducer → 새 State → View 다시 렌더링의 사이클을 엄격히 따라 변경되는 아키텍처 패턴입니다.
  • Android에서 UDF는 ViewModel + StateFlow + Intent 처리를 통해 구현됩니다. iOS에서는 @Observable + Reducer 패턴(Composable Architecture)을 통해 구현됩니다.
  • Google은 2023년 문서부터 Jetpack Compose의 기본 아키텍처로 UDF를 권장합니다.
  • UDF는 단일 진실 공급원(Single Source of Truth)을 통해 양방향 바인딩의 무한 루프 문제를 제거합니다.
  • 주요 단점은 양방향 바인딩에 비해 보일러플레이트 코드가 더 많다는 것입니다(State, Intent, Reducer, Effect).

단방향 데이터 흐름이란?

단방향 데이터 흐름(UDF)은 데이터가 폐쇄 루프에서 한 방향으로 이동하여 View와 Model 간의 피드백 루프를 제거하는 아키텍처 패턴입니다. UI의 변경이 즉시 모델을 업데이트하는 양방향 바인딩과 달리 UDF는 모든 상태 변경에 대해 명시적 액션(Intent, Event, Action)이 필요합니다. 이로 인해 데이터 흐름이 완전히 예측 가능해집니다. 언제든지 어떤 액션이 현재 상태로 이어졌는지 확인할 수 있습니다.

UDF 개념은 웹 프레임워크(Redux(JavaScript, 2015) 및 Elm(2012))에서 유래했으며 모바일 개발에 적용되었습니다. Google I/O 2024에 따르면 UDF는 Jetpack Compose의 권장 아키텍처가 되어 LiveData를 사용하는 클래식 MVVM을 대체했습니다. iOS에서는 Point-Free의 The Composable Architecture(TCA)에 유사한 접근 방식이 구현되어 있으며 Swift Community Survey(2024)에 따르면 15% 이상의 iOS 개발자가 사용하고 있습니다.

UDF의 주요 장점은 단일 진실 공급원(SSOT)입니다. 모든 애플리케이션 상태가 한 곳에 저장되고 엄격히 정의된 작업을 통해 수정됩니다. 이는 모든 상태 변경이 기록되고 동일한 Intents를 다시 전송하여 재현될 수 있기 때문에 디버깅, 테스트 및 버그 재현을 단순화합니다.

UDF 작동 방식: State → View → Intent → Reducer 사이클

기본 UDF 사이클은 네 단계로 구성됩니다. State(현재 상태)가 View에 렌더링됩니다. 사용자가 액션을 수행하면 Intent가 됩니다. Intent는 Reducer(순수 함수)에서 처리되어 새로운 State를 생성합니다. 새 상태는 다시 렌더링을 위해 View로 전달됩니다. 이 사이클은 모든 사용자 또는 시스템 이벤트에서 반복됩니다.

사이클의 각 요소는 엄격한 책임을 가집니다. State — 특정 시점의 화면 상태를 설명하는 불변 객체. View — State를 렌더링하는 함수. Intent — 사용자의 의도를 설명하는 값(예: LoginIntent.Submit). Reducer — 부작용이 없는 순수 함수로, 현재 State와 Intent를 받아 새 State를 반환합니다. 부작용(네트워크 요청, 데이터베이스 작업)은 별도의 Middleware 또는 Effect 레이어로 이동됩니다.

Google Android Architecture 기사(2024)에 따르면 Reducer의 순수성은 핵심 요구 사항입니다. Reducer에 네트워크 호출이나 데이터베이스 쓰기가 포함되어 있으면 데이터 흐름 테스트 및 디버깅이 불가능해집니다. 모든 부작용은 Reducer를 호출하기 전에 ViewModel 코루틴 또는 Swift Task에서 실행되어야 하며 결과는 새 Intent로 전송되어야 합니다.

Android의 UDF: ViewModel + StateFlow + Intent

Android에서 UDF 구현은 세 가지 Jetpack 구성 요소를 기반으로 합니다. ViewModel은 라이프사이클을 관리하고, StateFlow는 반응형 상태 스트림을 제공하며, Intent(sealed class)는 모든 가능한 사용자 액션을 설명합니다. View는 Compose에서 collectAsState() 또는 View 시스템에서 observe()를 통해 StateFlow를 구독합니다.

Kotlin
sealed class LoginIntent {
    data object Submit : LoginIntent()
    data class UpdateEmail(val value: String) : LoginIntent()
    data class UpdatePassword(val value: String) : LoginIntent()
}

data class LoginState(
    val email: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val error: String? = null
)

class LoginViewModel : ViewModel() {
    private val _state = MutableStateFlow(LoginState())
    val state: StateFlow<LoginState> = _state.asStateFlow()

    fun onIntent(intent: LoginIntent) {
        when (intent) {
            is LoginIntent.UpdateEmail -> {
                _state.update { it.copy(email = intent.value) }
            }
            is LoginIntent.UpdatePassword -> {
                _state.update { it.copy(password = intent.value) }
            }
            is LoginIntent.Submit -> {
                _state.update { it.copy(isLoading = true, error = null) }
                loginUseCase(_state.value.email, _state.value.password)
                    .onSuccess {
                        _state.update { it.copy(isLoading = false) }
                    }
                    .onFailure { e ->
                        _state.update { it.copy(isLoading = false, error = e.message) }
                    }
            }
        }
    }
}

@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val state by viewModel.state.collectAsState()

    LoginForm(
        email = state.email,
        onEmailChange = { viewModel.onIntent(LoginIntent.UpdateEmail(it)) },
        password = state.password,
        onPasswordChange = { viewModel.onIntent(LoginIntent.UpdatePassword(it)) },
        isLoading = state.isLoading,
        onSubmit = { viewModel.onIntent(LoginIntent.Submit) }
    )
}

예제는 Android에서의 완전한 UDF 사이클을 보여줍니다. LoginIntent는 모든 가능한 액션(이메일 변경, 비밀번호 변경, 양식 제출)을 설명하고, LoginState는 불변 상태이며, LoginViewModel은 Intents를 처리하고 StateFlow를 업데이트하며, Compose 화면은 collectAsState()를 통해 상태를 구독합니다. 모든 상태 변경은 특정 Intent 처리의 결과이며 데이터 흐름을 완전히 투명하게 만듭니다.

iOS의 UDF: TCA 및 Observable 패턴

iOS에서 UDF는 The Composable Architecture(TCA)(Point-Free) 또는 iOS 17+의 네이티브 Observable 패턴을 통해 구현됩니다. TCA는 State + Action + Reducer + Store의 기성 사이클을 제공하며 Store는 유일한 진실 공급원이고 View는 @Observable 또는 ObservableObject를 통해 변경을 구독합니다.

Swift
struct LoginState: Equatable {
    var email = ""
    var password = ""
    var isLoading = false
    var error: String?
}

enum LoginAction {
    case emailChanged(String)
    case passwordChanged(String)
    case submit
    case loginResponse(Result<User, Error>)
}

let loginReducer = Reducer<LoginState, LoginAction> { state, action in
    switch action {
    case .emailChanged(let email):
        state.email = email
        return .none
    case .passwordChanged(let password):
        state.password = password
        return .none
    case .submit:
        state.isLoading = true
        state.error = nil
        return .run { send in
            let result = await loginUseCase(state.email, state.password)
            await send(.loginResponse(result))
        }
    case .loginResponse(.success):
        state.isLoading = false
        return .none
    case .loginResponse(.failure(let error)):
        state.isLoading = false
        state.error = error.localizedDescription
        return .none
    }
}

struct LoginView: View {
    let store: StoreOf<LoginReducer>

    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            Form {
                TextField("Email", text: viewStore.binding(get: \.email, send: { .emailChanged($0) }))
                SecureField("Password", text: viewStore.binding(get: \.password, send: { .passwordChanged($0) }))
                Button("로그인") { viewStore.send(.submit) }
            }
        }
    }
}

loginReducer는 순수 함수입니다. 네트워크 요청을 직접 실행하지 않고 TCA 런타임에 의해 실행될 Effect를 반환합니다. 이를 통해 테스트에서 효과를 대체하여 리듀서를 격리하여 테스트할 수 있습니다. View는 WithViewStore를 통해 Store 변경을 구독하고 send()를 통해 Actions를 전송합니다. Store가 파괴되면 TCA가 자동으로 효과를 취소하여 메모리 누수를 방지합니다.

UDF 대 MVVM: 차이점은?

MVVMUDF는 종종 혼동되지만 근본적인 차이가 있습니다. MVVM은 코드를 세 가지 레이어(Model, View, ViewModel)로 분리하는 구조적 패턴이지만 데이터 흐름의 방향을 정의하지 않습니다. UDF는 해당 구조 내에서 데이터가 어떻게 이동하는지 설명하는 행동 패턴입니다. LiveData를 사용한 MVVM에서는 양방향 바인딩과 단방향 흐름이 모두 가능합니다. UDF는 MVVM에 엄격한 Intent 처리 규칙을 추가합니다.

Android Developers 문서(2024)에 따르면 Compose의 권장 아키텍처는 MVVM 내의 UDF입니다. ViewModel이 State를 저장하고 Intents를 처리하며 View가 State를 구독하고 Intents를 전송합니다. Google은 비즈니스 로직이 없는 단순한 화면에 대해서만 DataBinding을 통한 양방향 바인딩을 사용하는 클래식 MVVM을 권장합니다. Jetpack Compose의 주요 시나리오는 명시적 이벤트 처리가 있는 UDF입니다.

비교 표:

특성MVVM(클래식)MVVM + UDF
데이터 흐름정의되지 않음엄격히 단방향
상태 변경setText()로 직접Intent → Reducer만
단일 진실 공급원아니요
Reducer 테스트 용이성낮음높음(순수 함수)
Google 권장레거시 방식Compose 기본

UDF 구현 시 일반적인 실수

가장 흔한 실수는 Reducer 내의 부작용입니다. MVVM에 익숙한 개발자는 네트워크 요청을 Intent 핸들러에 직접 배치하여 Reducer를 불순하게 만들고 테스트 용이성을 손상시킵니다. 모든 효과는 값(Effect / SideEffect)으로 반환되어 프레임워크 인프라에 의해 실행되어야 합니다. Android에서는 ViewModel의 코루틴이 사용되며 TCA에서는 Effect.run이 사용됩니다.

두 번째 실수는 지나치게 세분화된 Intents입니다. 모든 키 입력, 슬라이더 이동 및 텍스트 변경이 별도의 Intent를 생성합니다. 입력 필드의 경우 이는 과도합니다. 이러한 경우 양식 내에서 단방향 흐름이 있는 바인딩(로컬 상태)을 사용하고 중요한 액션(제출, 탐색)에 대해서만 전역 Intent를 전송하는 것이 허용됩니다.

세 번째 실수는 효과 취소 처리 부족입니다. 사용자가 화면을 떠났는데 코루틴 또는 Task가 계속 실행 중인 경우 결과가 이미 파괴된 View에 적용될 수 있습니다. Android에서는 viewModelScope.cancel() 또는 takeWhileActive()를 사용합니다. TCA에서는 Store가 파괴될 때 효과가 자동으로 취소됩니다. Google Issue Tracker(2024)에 따르면 완료되지 않은 코루틴의 누수는 Compose 애플리케이션에서 충돌 원인 상위 5위 안에 듭니다.

자주 묻는 질문

UDF와 MVI의 차이점은 무엇인가요?

MVI(Model-View-Intent)는 세 가지 필수 요소(Intent(의도), Model(상태), View(표시))를 가진 UDF의 특정 사례입니다. 주요 차이점은 MVI에서 각 화면 상태가 단일 불변 구조(Sealed class)로 설명되며 View는 Model에서 UI로의 순수 함수라는 것입니다. UDF는 Redux 및 Elm을 포함한 모든 단방향 흐름을 설명하는 더 넓은 용어입니다. Google 문서에서 UDF는 일반적인 이름으로 사용되고 MVI는 특정 구현으로 사용됩니다.

UDF가 과도한 경우는 언제인가요?

UDF는 유효성 검사가 없는 단일 입력 필드 화면, 정적 페이지 및 플레이스홀더 화면에 과도합니다. 화면에 비즈니스 로직이 없고 상태가 사용자 액션에 의존하지 않는 경우 UDF는 이점 없이 불필요한 코드를 추가합니다. 이러한 시나리오에서는 단방향 바인딩 또는 SwiftUI의 간단한 @State로 충분합니다. UDF는 가능한 화면 상태 수가 3~4개를 초과하고/하거나 부작용이 있는 경우에 정당화됩니다.

UDF를 테스트하는 방법은?

Reducer는 순수 함수이므로 테스트는 State와 Intent의 다양한 조합으로 호출하고 결과 State와 Effect를 확인하는 것으로 귀결됩니다. Android에서는 StateFlow 테스트에 Turbine을 사용합니다. Intent를 전송하고 다음 State 방출을 확인합니다. TCA에는 Action 이후 예상된 State 필드만 변경되고 예상된 Effects만 실행되었는지 자동으로 확인하는 내장 TestStore가 있습니다.

UDF와 양방향 바인딩을 결합할 수 있나요?

예, 결합이 가능하며 종종 최적입니다. 양식 내의 입력 필드에는 키 입력마다 Intent를 생성하지 않도록 로컬 양방향 바인딩(또는 SwiftUI의 Binding)을 사용합니다. 양식 제출 시 수집된 데이터가 포함된 단일 Intent를 전송하면 Reducer가 처리합니다. 이 하이브리드 접근 방식(전역 UDF + 로컬 양방향 바인딩)은 70%의 상용 SwiftUI 애플리케이션에서 사용됩니다(Swift Community Survey 2024 데이터).

UDF, Redux 및 Elm의 공통점은 무엇인가요?

세 패턴 모두 단일 진실 공급원을 가진 단방향 데이터 흐름을 구현합니다. Elm(2012)은 함수형 언어로 순수한 Model → View → Update 사이클을 처음 도입했습니다. Redux(2015)는 Store, Reducer 및 Action의 개념으로 Elm을 JavaScript에 적용했습니다. UDF는 모바일 개발을 위한 이러한 아이디어의 일반화입니다. 세 접근 방식 모두 원자적 상태 업데이트를 통한 변경의 예측 가능성을 보장합니다.

요약

  • 단방향 데이터 흐름(UDF)은 단방향 State → View → Intent → Reducer 사이클을 가진 패턴으로 예측 가능한 상태 변경을 보장합니다.
  • Android에서 UDF는 ViewModel + StateFlow + sealed class Intent를 통해 구현되고 iOS에서는 TCA(Reducer + Store) 또는 네이티브 Observable을 통해 구현됩니다.
  • Google은 2023년부터 Jetpack Compose의 기본 아키텍처로 UDF를 권장합니다.
  • Reducer는 부작용이 없는 순수 함수입니다. 모든 네트워크 요청 및 데이터베이스 작업은 Effect 레이어로 이동됩니다.
  • UDF는 명시적 Intent 처리 및 단일 진실 공급원을 통해 양방향 바인딩의 무한 루프 문제를 제거합니다.
  • 주요 위험 — Reducer 내 부작용, 지나치게 세분화된 Intents 및 완료되지 않은 코루틴.
  • 하이브리드 접근 방식(양식의 로컬 양방향 바인딩 + 전역 UDF)은 대부분의 상용 애플리케이션에 최적입니다.

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기