SwiftUI 애플리케이션을 위한 경량 단방향 상태 관리 라이브러리로, The Composable Architecture (TCA)에서 영감을 받았습니다.
ReducerKit에 대한 상세 문서는 아래 웹 페이지에서 확인할 수 있습니다:
ReducerKit은 단방향 데이터 플로우 패턴을 사용하여 SwiftUI 애플리케이션의 상태를 관리하는 간단하면서도 강력한 방법을 제공합니다. 예측 가능하고, 테스트 가능하며, 유지보수가 쉬운 애플리케이션을 만들 수 있습니다.
- 단방향 데이터 플로우: 명확하고 예측 가능한 상태 관리 패턴
- 타입 안전: Swift의 타입 시스템을 활용한 컴파일 타임 안전성
- 부수 효과 관리: 상태 변경과 비동기 작업의 명확한 분리
- SwiftUI 통합:
@Observable을 기반으로 한 원활한 SwiftUI 통합 - 세밀한 관찰:
@ObservableState매크로로 프로퍼티별 View 업데이트 최적화 - 동시성 안전:
@MainActor와Sendable을 활용한 완전한 Swift Concurrency 지원 - 경량: 최소한의 의존성과 간단한 API
- 테스트 가능: Reducer와 상태 변경을 쉽게 테스트
- iOS 17.0+ / macOS 14.0+ / tvOS 17.0+ / watchOS 10.0+ / visionOS 1.0+
- Swift 6.0+
- Xcode 16.0+
Swift Package Manager를 사용하여 프로젝트에 ReducerKit을 추가하세요:
- Xcode에서 File > Add Package Dependencies... 선택
- 저장소 URL 입력:
https://github.com/JDLibraries/ReducerKit
- 사용할 버전 선택
또는 Package.swift 파일에 직접 추가:
dependencies: [
.package(url: "https://github.com/ljdongz/ReducerKit", from: "2.0.2")
]State는 기능이 표시하고 동작하는 데 필요한 모든 데이터를 나타냅니다. @ObservableState 매크로를 사용하여 프로퍼티별 세밀한 관찰을 활성화합니다.
import ReducerKit
@ObservableState
struct CounterState: Equatable {
var count: Int = 0
var isLoading: Bool = false
var errorMessage: String?
}@ObservableState 매크로는:
- 각 프로퍼티를 개별적으로 관찰 가능하게 만듭니다
- 변경된 프로퍼티를 사용하는 View만 업데이트합니다
- SwiftUI의 성능을 최적화합니다
Action은 사용자 상호작용이나 시스템 이벤트 등 기능에서 발생할 수 있는 모든 이벤트를 나타냅니다.
enum Action: Sendable {
case increment
case decrement
case fetchData
case dataReceived(Result<Data, Error>)
}Reducer 프로토콜은 액션에 대한 응답으로 상태가 어떻게 변경되는지를 정의합니다. 현재 상태와 액션을 받아 새로운 상태와 선택적 부수 효과를 반환하는 순수 함수입니다.
struct MyReducer: Reducer {
// ...
// State, Action 정의
// ...
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .increment:
state.count += 1
return .none
case .decrement:
state.count -= 1
return .none
case .fetchData:
state.isLoading = true
return .run { send in
let data = await api.fetch()
await send(.dataReceived(.success(data)))
}
case let .dataReceived(result):
state.isLoading = false
// 결과 처리...
return .none
}
}
}Effect는 네트워크 요청, 타이머 또는 모든 비동기 작업과 같은 부수 효과를 나타냅니다. Reducer를 순수하게 유지하면서도 필요한 비동기 작업을 수행할 수 있게 해줍니다.
// 부수 효과 없음
return .none
// 콜백이 있는 비동기 작업
return .run { send in
let result = await performAsyncWork()
await send(.workCompleted(result))
}Store는 모든 것을 조율합니다 - 상태를 보유하고, Reducer를 통해 액션을 처리하며, 부수 효과를 관리합니다.
let store = Store(
initialState: MyReducer.State(),
reducer: MyReducer()
)비동기 숫자 팩트 기능이 있는 카운터 기능의 완전한 예제입니다:
import ReducerKit
struct CounterReducer: Reducer {
@ObservableState
struct State: Equatable {
var count: Int = 0
var isLoading: Bool = false
var numberFact: String?
}
enum Action: Sendable {
case increment
case decrement
case numberFactButtonTapped
case numberFactResponse(String)
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .increment:
state.count += 1
return .none
case .decrement:
state.count -= 1
return .none
case .numberFactButtonTapped:
state.isLoading = true
state.numberFact = nil
return .run { [count = state.count] send in
do {
let (data, _) = try await URLSession.shared.data(
from: URL(string: "http://numbersapi.com/\(count)/trivia")!
)
let fact = String(decoding: data, as: UTF8.self)
await send(.numberFactResponse(fact))
} catch {
await send(.numberFactResponse("팩트를 불러오는데 실패했습니다"))
}
}
case let .numberFactResponse(fact):
state.isLoading = false
state.numberFact = fact
return .none
}
}
}import SwiftUI
import ReducerKit
struct CounterView: View {
@State private var store = Store(
initialState: CounterReducer.State(),
reducer: CounterReducer()
)
var body: some View {
VStack(spacing: 40) {
// ✅ dynamicMemberLookup으로 직접 접근 (필수)
Text("\(store.count)")
HStack {
Button("+") { store.send(.increment) }
Button("-") { store.send(.decrement) }
}
Button("숫자 팩트 가져오기") {
store.send(.numberFactButtonTapped)
}
.disabled(store.isLoading)
if let fact = store.numberFact {
Text(fact)
}
}
}
}struct TodosReducer: Reducer {
@ObservableState
struct State: Equatable {
var todos: [Todo] = []
var isLoading: Bool = false
var error: String?
}
enum Action: Sendable {
case loadTodos
case todosLoaded(Result<[Todo], Error>)
case addTodo(String)
case todoAdded(Todo)
case toggleTodo(Todo.ID)
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .loadTodos:
state.isLoading = true
state.error = nil
return .run { send in
let result = await TodoAPI.fetchTodos()
await send(.todosLoaded(result))
}
case let .todosLoaded(.success(todos)):
state.isLoading = false
state.todos = todos
return .none
case let .todosLoaded(.failure(error)):
state.isLoading = false
state.error = error.localizedDescription
return .none
case let .addTodo(title):
return .run { send in
let todo = await TodoAPI.createTodo(title: title)
await send(.todoAdded(todo))
}
case let .todoAdded(todo):
state.todos.append(todo)
return .none
case let .toggleTodo(id):
guard let index = state.todos.firstIndex(where: { $0.id == id }) else {
return .none
}
state.todos[index].isCompleted.toggle()
return .none
}
}
}ReducerKit을 사용하면 상태 로직을 쉽게 테스트할 수 있습니다:
import XCTest
@testable import YourApp
import ReducerKit
final class CounterReducerTests: XCTestCase {
func testIncrement() {
var state = CounterReducer.State(count: 0)
let reducer = CounterReducer()
let effect = reducer.reduce(into: &state, action: .increment)
XCTAssertEqual(state.count, 1)
XCTAssertEqual(effect, .none)
}
func testDecrement() {
var state = CounterReducer.State(count: 5)
let reducer = CounterReducer()
let effect = reducer.reduce(into: &state, action: .decrement)
XCTAssertEqual(state.count, 4)
XCTAssertEqual(effect, .none)
}
func testNumberFactRequest() {
var state = CounterReducer.State(count: 42)
let reducer = CounterReducer()
let effect = reducer.reduce(into: &state, action: .numberFactButtonTapped)
XCTAssertTrue(state.isLoading)
XCTAssertNil(state.numberFact)
// Effect 테스트는 추가 설정이 필요합니다
}
}ReducerKit은 단방향 데이터 플로우를 따릅니다:
- View가 사용자 이벤트를 Action으로 변환하여 Store에 전달
- Store가 Action과 현재 State를 바탕으로 Reducer의 reduce 메서드 호출
- Reducer가 Action에 따라 State를 업데이트
- 변경된 State가 View에 반영되어 UI를 업데이트합니다.
- Reducer는 reduce 실행 결과로 Effect를 반환
- Effect 작업을 수행하고 새로운 Action을 전송 (사이클 반복)
- @ObservableState 매크로 사용: 모든 State struct에
@ObservableState를 적용하세요 - dynamicMemberLookup 활용: View에서 반드시
store.count형태로 직접 접근하세요 (스냅샷이 아닌 관찰 가능한 접근) - Reducer를 순수하게 유지: Reducer는 상태만 수정해야 하며, 직접 부수 효과를 수행하면 안 됩니다
- 비동기 작업에 Effect 사용: 모든 비동기 작업은 Effect를 통해야 합니다
- Effect에서 값 캡처: 경쟁 조건을 피하기 위해 Effect를 생성할 때 필요한 상태 값을 캡처하세요
- 단일 진실 공급원: 모든 기능 상태를 하나의 State 구조체에 보관하세요
- Action 구성: 도메인별로 액션을 구성하기 위해 중첩된 enum을 사용하세요
- Reducer 테스트: 부수 효과와 독립적으로 상태 변경을 테스트하세요
// ❌ View에서는 금지됨 - 스냅샷으로 접근하면 업데이트 감지 안 됨
Text("\(store.state.count)")
// ✅ View에서는 필수 - dynamicMemberLookup으로 접근
Text("\(store.count)")store.state 프로퍼티는 다음 경우에만 사용:
- 전체 State를 함수에 전달할 때
- 디버깅/로깅 목적으로 전체 상태를 확인할 때
- State 스냅샷을 저장할 때
View에서는 절대로 store.state를 사용하지 마세요. 접근 시점의 스냅샷을 반환하므로 이후 변경을 감지할 수 없습니다.
완전한 샘플 프로젝트는 Examples 디렉토리를 확인하세요:
- Counter: 비동기 숫자 팩트가 있는 기본 카운터
- 더 많은 예제가 곧 추가됩니다!
기여를 환영합니다! Pull Request를 자유롭게 제출해주세요.
ReducerKit은 MIT 라이선스로 제공됩니다. 자세한 내용은 LICENSE 파일을 참조하세요.
The Composable Architecture에서 영감을 받음