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
반응형
'iOS & SwiftUI' 카테고리의 다른 글
SwiftUI 상태 관리 완전 정복기 (3) | 2025.08.13 |
---|---|
🎯 UserDefaults vs FileManager vs CoreData: SwiftUI 앱에서의 저장 방식 비교와 선택 (0) | 2025.07.01 |
SwiftUI에서 앱 실행 시 인증 흐름 설계하기 (자동 잠금 / 해제 타이밍) (2) | 2025.06.18 |
SwiftUI에서 Face ID / Touch ID 인증 구현하기 (1) | 2025.06.15 |
SwiftUI에서 TextEditor 커스터마이징과 UX 개선 팁 (0) | 2025.06.12 |