본문 바로가기

단방향 데이터 흐름(One-way Data Flow) 완전 정복 — SwiftUI 실습 예제와 설계 원칙

@Prof.SSong2025. 8. 14. 09:12
728x90
반응형

들어가며

 

앱을 개발하다 보면 데이터 변경이 여러 곳에서 일어나고, 그로 인해 UI 반영이 꼬이거나 성능 저하가 발생하는 경우가 있습니다.

이를 깔끔하게 해결해 주는 설계 원칙이 단방향 데이터 흐름(One-way Data Flow)입니다.

오늘은 SwiftUI 환경에서 단방향 데이터 흐름을 설계하는 방법 실습 예제 코드를 통해,
이 원칙을 어떻게 적용할 수 있는지 살펴보겠습니다.

 


 

1. 단방향 데이터 흐름이란?

 

단방향 데이터 흐름은 상태(State)가 오직 한 방향으로만 이동하는 구조를 말합니다.

흐름은 다음과 같습니다.

[User Action / 시스템 이벤트]
           ↓
    [ViewModel: 상태 변경]
           ↓
      [View: UI 렌더링]

 

  • View: 화면 표시와 사용자 입력을 담당. 직접 상태를 수정하지 않음.
  • ViewModel: 상태의 유일한 소유자. 모든 변경은 ViewModel 메서드(=Intent)를 통해서만 가능.
  • Model/Service: API, DB 등 외부 데이터 소스와의 통신 담당.

 


 

2. SwiftUI에서의 기본 패턴

 

SwiftUI에서는 다음과 같은 상태 관리 도구를 활용합니다.

 

  • @StateObject: View에서 ViewModel을 소유하고 상태를 구독할 때 사용.
  • @ObservedObject: 외부에서 주입받은 ViewModel을 구독할 때 사용.
  • @EnvironmentObject: 전역 공유 상태를 여러 뷰에서 접근할 때 사용.
  • @Observable: Swift Observation 기반의 최신 상태 관리 방법.

 


 

3. 실습 예제 — Mock 데이터 기반 대시보드

 

아래 예제는 버스/지하철/도로 상태를 5초마다 갱신하는 Mock 대시보드입니다.

 

 

데이터 모델

struct BusArrival: Identifiable {
    let id = UUID()
    let route: String
    let minutes: Int
}

struct SubwayETA: Identifiable {
    let id = UUID()
    let line: String
    let station: String
    let minutes: Int
}

struct RoadSpeed: Identifiable {
    let id = UUID()
    let roadName: String
    let speed: Int
}

 


 

상태(State) 구조

struct DashboardState {
    var filter: TransportFilter = .all
    var busArrivals: [BusArrival] = []
    var subwayETAs: [SubwayETA] = []
    var roadSpeeds: [RoadSpeed] = []
    var isLoading = false
    var lastUpdated: Date? = nil
    var errorMessage: String? = nil
}

enum TransportFilter {
    case all, bus, subway, road
}

 


 

서비스 (Mock 데이터 생성)

protocol TransportService {
    func fetchAll() async throws -> (bus: [BusArrival], subway: [SubwayETA], road: [RoadSpeed])
}

struct MockTransportService: TransportService {
    func fetchAll() async throws -> (bus: [BusArrival], subway: [SubwayETA], road: [RoadSpeed]) {
        // 랜덤 데이터 생성
        let bus = [
            BusArrival(route: "7016", minutes: Int.random(in: 1...10)),
            BusArrival(route: "152", minutes: Int.random(in: 3...15))
        ]
        let subway = [
            SubwayETA(line: "2호선", station: "강남", minutes: Int.random(in: 1...8)),
            SubwayETA(line: "9호선", station: "고속터미널", minutes: Int.random(in: 2...12))
        ]
        let road = [
            RoadSpeed(roadName: "강변북로", speed: Int.random(in: 20...80)),
            RoadSpeed(roadName: "올림픽대로", speed: Int.random(in: 20...80))
        ]
        return (bus, subway, road)
    }
}

 


 

ViewModel — 단방향 데이터 흐름의 핵심

@Observable final class DashboardViewModel {
    private let service: TransportService
    private var task: Task<Void, Never>?
    var state = DashboardState()
    
    init(service: TransportService) {
        self.service = service
    }
    
    func start() {
        task?.cancel()
        task = Task { [weak self] in
            guard let self else { return }
            while !Task.isCancelled {
                await self.refresh()
                try? await Task.sleep(nanoseconds: 5_000_000_000)
            }
        }
    }
    
    func stop() { task?.cancel() }
    
    @MainActor
    func refresh() async {
        state.isLoading = true
        do {
            let result = try await service.fetchAll()
            state.busArrivals = result.bus
            state.subwayETAs = result.subway
            state.roadSpeeds = result.road
            state.lastUpdated = Date()
            state.errorMessage = nil
        } catch {
            state.errorMessage = "데이터를 불러오지 못했습니다."
        }
        state.isLoading = false
    }
    
    func setFilter(_ filter: TransportFilter) {
        state.filter = filter
    }
}

 


 

View — 상태를 읽고 UI로 변환

struct DashboardView: View {
    @StateObject private var vm = DashboardViewModel(service: MockTransportService())
    
    var body: some View {
        VStack {
            if vm.state.isLoading {
                ProgressView("업데이트 중...")
            } else {
                Text("마지막 갱신: \(vm.state.lastUpdated?.formatted() ?? "-")")
                List {
                    if vm.state.filter == .all || vm.state.filter == .bus {
                        Section(header: Text("버스 도착 정보")) {
                            ForEach(vm.state.busArrivals) { bus in
                                Text("\(bus.route)번 버스: \(bus.minutes)분 후 도착")
                            }
                        }
                    }
                    if vm.state.filter == .all || vm.state.filter == .subway {
                        Section(header: Text("지하철 도착 정보")) {
                            ForEach(vm.state.subwayETAs) { subway in
                                Text("\(subway.line) \(subway.station): \(subway.minutes)분 후 도착")
                            }
                        }
                    }
                    if vm.state.filter == .all || vm.state.filter == .road {
                        Section(header: Text("도로 속도")) {
                            ForEach(vm.state.roadSpeeds) { road in
                                Text("\(road.roadName): \(road.speed) km/h")
                            }
                        }
                    }
                }
            }
            HStack {
                Button("전체") { vm.setFilter(.all) }
                Button("버스") { vm.setFilter(.bus) }
                Button("지하철") { vm.setFilter(.subway) }
                Button("도로") { vm.setFilter(.road) }
            }
            .padding()
        }
        .onAppear { vm.start() }
        .onDisappear { vm.stop() }
    }
}

 


 

4. 핵심 정리

 

  • 상태는 ViewModel 하나가 소유(Single Source of Truth).
  • View는 상태를 읽기만 하고, 변경은 Intent 메서드로만 전달.
  • 주기적 갱신(타이머)도 ViewModel 내부에서만 처리.
  • Mock 서비스로 먼저 흐름을 검증한 뒤, 실제 API로 교체 가능.

 


 

마무리하며

 

단방향 데이터 흐름은 처음에는 다소 제약적으로 보일 수 있지만, 규모가 커질수록 유지보수성과 안정성을 확보해 주는 강력한 원칙입니다.

SwiftUI와 MVVM 패턴에서 이 원칙을 적용하면, 데이터 변경 경로가 명확하고 예측 가능한 앱을 만들 수 있습니다.

728x90
반응형
목차