State는 하나, Action으로만 변경, Reducer는 순수 함수. 단방향 데이터 흐름으로 상태 변화를 완벽히 추적하고 테스트할 수 있습니다.
State — 앱의 모든 상태를 하나의 struct에.
Reducer — (inout State, Action) -> [Effect] 순수 함수.
Effect — 비동기 작업 후 다시 Action을 전송.
import Foundation
import Combine
// MARK: - Redux Store (완성)
// ---- State: 앱의 모든 상태 ----
struct AppState: Equatable {
var tasks: [TaskItem] = []
var searchText = ""
var filterPriority: TaskItem.Priority? = nil
var isLoading = false
var error: String? = nil
// Computed property — State에서 파생
var filteredTasks: [TaskItem] {
tasks.filter { task in
let matchSearch = searchText.isEmpty ||
task.title.localizedCaseInsensitiveContains(searchText)
let matchPriority = filterPriority == nil || task.priority == filterPriority
return matchSearch && matchPriority
}
}
}
// ---- Action: 가능한 모든 이벤트 ----
enum AppAction: Equatable {
case loadTasks
case tasksLoaded([TaskItem])
case addTask(title: String, priority: TaskItem.Priority)
case taskAdded(TaskItem)
case toggleTask(id: UUID)
case deleteTask(id: UUID)
case setSearchText(String)
case setFilter(TaskItem.Priority?)
case setError(String?)
}
// ---- Effect: 비동기 사이드 이펙트 ----
struct Effect {
let run: @Sendable () async -> AppAction?
}
// ---- Reducer: 순수 함수 ----
// (State, Action) -> [Effect]
// 같은 입력이면 항상 같은 결과 → 테스트 최강
func appReducer(state: inout AppState, action: AppAction) -> [Effect] {
switch action {
case .loadTasks:
state.isLoading = true
return [Effect { .tasksLoaded(TaskStorage.shared.loadAll()) }]
case .tasksLoaded(let tasks):
state.isLoading = false
state.tasks = tasks
return []
case .addTask(let title, let priority):
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { state.error = "Empty title"; return [] }
let task = TaskItem(title: trimmed, priority: priority)
// 저장은 Effect가 담당 (사이드 이펙트 분리)
return [Effect { TaskStorage.shared.add(task); return .taskAdded(task) }]
case .taskAdded(let task):
state.tasks.insert(task, at: 0)
return []
case .toggleTask(let id):
if let i = state.tasks.firstIndex(where: { $0.id == id }) {
state.tasks[i].isCompleted.toggle()
let tasks = state.tasks
return [Effect { TaskStorage.shared.saveAll(tasks); return nil }]
}
return []
case .deleteTask(let id):
state.tasks.removeAll { $0.id == id }
let tasks = state.tasks
return [Effect { TaskStorage.shared.saveAll(tasks); return nil }]
case .setSearchText(let t): state.searchText = t; return []
case .setFilter(let p): state.filterPriority = p; return []
case .setError(let m): state.error = m; return []
}
}
// ---- Store: Reducer를 실행하는 런타임 ----
@MainActor
final class Store: ObservableObject {
@Published private(set) var state: AppState
private let reducer: (inout AppState, AppAction) -> [Effect]
init(initial: AppState = AppState(),
reducer: @escaping (inout AppState, AppAction) -> [Effect] = appReducer) {
self.state = initial
self.reducer = reducer
}
func send(_ action: AppAction) {
let effects = reducer(&state, action)
for effect in effects {
Task {
if let next = await effect.run() { send(next) }
}
}
}
}
// ---- 테스트 예시 ----
// Reducer가 순수 함수이므로 Store 없이 직접 테스트 가능
//
// func test_addTask() {
// var state = AppState()
// let effects = appReducer(state: &state, action: .addTask(title: "Buy milk", priority: .high))
// // effects를 실행하지 않고 State 변화만 검증
// // (비동기 Effect는 별도 테스트)
// }AppState 하나가 앱의 모든 상태를 소유합니다. 어느 화면에서든 같은 State를 구독하므로 상태 불일치가 없습니다.(inout State, Action) -> [Effect]입니다. 외부 상태를 참조하지 않아 같은 입력에 항상 같은 결과를 냅니다. XCTest로 단독 테스트가 매우 쉽습니다.