TCA Reducer 구조 설계 방법 (SwiftUI / Composable Architecture)
TCA(Composable Architecture)를 사용하다 보면 State, Action, Reducer를 나누는 기본 구조는 금방 익숙해진다. 하지만 실제 프로젝트로 들어가면 또 다른 고민이 생긴다. 바로 Reducer를 어떤 구조로 설계해야 유지보수가 쉬운가 하는 점이다.
작은 예제에서는 reducer 하나에 모든 로직을 넣어도 동작한다. 하지만 화면이 커지고, 비동기 작업이 늘어나고, 자식 Feature가 생기고, 네비게이션과 바인딩 로직까지 들어가기 시작하면 reducer 구조를 잘 설계하지 않으면 금방 복잡해진다.
이 글에서는 TCA에서 Reducer를 어떻게 설계하면 좋은지, 기본 원칙부터 실무에서 자주 쓰는 구조까지 정리한다.
Reducer란 무엇인가
Reducer는 TCA의 가장 핵심적인 구성 요소 중 하나다. 공식 문서에서도 reducer는 앱의 상태를 변화시키고, 필요한 경우 effect를 반환하는 도구라고 설명한다.
쉽게 말하면 reducer는 다음 역할을 한다.
- Action을 받는다
- 현재 State를 변경한다
- 필요하면 비동기 작업이나 추가 액션을 발생시킨다
즉, Feature의 실제 동작 로직이 모이는 중심이라고 보면 된다.
기본적인 Reducer 구조
최근 TCA에서는 보통 @Reducer 매크로를 사용해서 reducer를 정의한다. 기본 구조는 다음과 같다.
import ComposableArchitecture
@Reducer
struct CounterFeature {
@ObservableState
struct State: Equatable {
var count = 0
}
enum Action {
case incrementButtonTapped
case decrementButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.count += 1
return .none
case .decrementButtonTapped:
state.count -= 1
return .none
}
}
}
}
이 구조는 가장 단순한 형태지만, Reducer 설계의 기본 원리를 잘 보여준다.
Reducer 구조 설계가 중요한 이유
작은 Feature에서는 reducer가 짧고 단순해서 문제가 잘 드러나지 않는다. 하지만 실무에서는 다음과 같은 이유로 구조 설계가 중요해진다.
- 액션 수가 빠르게 늘어난다
- 비동기 로직이 많아진다
- 자식 Feature가 생긴다
- 탭 이동, 화면 이동, 바인딩 처리까지 함께 들어간다
- 테스트해야 할 분기가 많아진다
즉, reducer를 단순히 “switch 문이 있는 곳”으로 보면 안 되고, Feature의 책임을 어디까지 둘지 설계하는 관점으로 보는 것이 중요하다.
좋은 Reducer 구조의 기본 원칙
Reducer를 설계할 때는 보통 다음 원칙을 먼저 잡아두는 것이 좋다.
- 하나의 reducer는 하나의 Feature 책임에 집중한다
- 부모 reducer는 흐름과 조합을 관리한다
- 자식 reducer는 자기 화면 로직에 집중한다
- 복잡한 분기는 하위 reducer나 헬퍼 메서드로 분리한다
- 비동기 의존성은 Dependency로 분리한다
즉, reducer가 비대해지는 가장 큰 원인은 “한 reducer가 너무 많은 책임을 갖는 것”이다.
Reducer를 작게 유지하는 가장 쉬운 방법
가장 쉬운 방법은 Feature를 화면 단위로 나누는 것이다. 예를 들어 목록 화면과 상세 화면이 있다면 둘을 하나의 reducer에 다 넣기보다 각각 별도 Feature로 나누는 편이 좋다.
Feature
├── TodoListFeature
└── TodoDetailFeature
그리고 부모 reducer가 이 둘을 조합한다.
@Reducer
struct TodoListFeature {
@Reducer
enum Path {
case detail(TodoDetailFeature)
}
@ObservableState
struct State: Equatable {
var items: [String] = []
var path = StackState<Path.State>()
}
enum Action {
case itemTapped(String)
case path(StackAction<Path.State, Path.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .itemTapped(item):
state.path.append(.detail(TodoDetailFeature.State(title: item)))
return .none
case .path:
return .none
}
}
.forEach(\.path, action: \.path)
}
}
이 구조의 장점은 목록 reducer는 목록 흐름에 집중하고, 상세 reducer는 상세 화면 자체에 집중할 수 있다는 점이다.
Reducer body를 어떻게 나누면 좋을까
최근 TCA reducer는 보통 var body: some ReducerOf<Self> 안에 여러 reducer를 조합하는 형태로 작성한다. 즉, body는 “로직 한 덩어리”라기보다 여러 reducer 연산을 조립하는 공간에 가깝다.
예를 들면 다음처럼 구성할 수 있다.
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .onAppear:
return .none
case .saveButtonTapped:
return .none
case .binding:
return .none
}
}
}
이 구조는 바인딩 처리와 일반 로직을 자연스럽게 분리해 준다. 공식 bindings 문서도 reducer에 바인딩 도구를 조합해서 설정 화면 같은 로직을 더 쉽게 만들 수 있다고 설명한다. :contentReference[oaicite:1]{index=1}
BindingReducer를 분리해서 쓰는 이유
폼 화면처럼 텍스트 입력이나 토글 바인딩이 많은 경우, 모든 것을 switch 문으로 직접 처리하면 reducer가 금방 지저분해진다. 이럴 때 BindingReducer()를 넣고 바인딩 액션만 따로 다루면 구조가 훨씬 깔끔해진다.
@Reducer
struct SettingsFeature {
@ObservableState
struct State: Equatable {
@BindingState var isNotificationEnabled = false
@BindingState var username = ""
}
enum Action: BindableAction {
case binding(BindingAction<State>)
case saveButtonTapped
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding:
return .none
case .saveButtonTapped:
return .none
}
}
}
}
이렇게 하면 사용자의 입력은 바인딩 시스템이 처리하고, reducer는 필요한 비즈니스 로직에 더 집중할 수 있다.
부모 Reducer와 자식 Reducer 책임 나누기
Reducer 구조 설계에서 가장 중요한 포인트 중 하나는 부모와 자식의 책임을 명확히 나누는 것이다.
- 부모 reducer: 전체 흐름, 화면 이동, 자식 조합
- 자식 reducer: 자기 화면 상태와 액션 처리
예를 들어 자식 화면에서 편집 버튼이 눌렸다고 해서 자식 reducer가 직접 다음 화면을 push하기보다, delegate 액션을 부모에게 보내고 부모 reducer가 실제 네비게이션을 결정하는 구조가 더 깔끔하다.
case .detail(.delegate(.editRequested)):
state.path.append(.edit(EditFeature.State()))
return .none
이 방식은 네비게이션 흐름이 부모 reducer에 모이기 때문에 구조를 이해하고 테스트하기가 훨씬 쉽다.
비동기 로직은 Reducer 안에서 어떻게 다루나
TCA reducer는 상태를 바꾸는 것뿐 아니라 effect도 반환할 수 있다. 최근 공식 문서와 튜토리얼도 reducer에서 비동기 로직을 .run 같은 방식으로 처리하는 흐름을 설명한다. :contentReference[oaicite:2]{index=2}
예를 들어 API를 불러오는 구조는 다음처럼 만들 수 있다.
@Reducer
struct TodoFeature {
@Dependency(\.todoClient) var todoClient
@ObservableState
struct State: Equatable {
var todos: [String] = []
var isLoading = false
}
enum Action {
case onAppear
case todosResponse([String])
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.isLoading = true
return .run { send in
let todos = try await todoClient.fetchTodos()
await send(.todosResponse(todos))
}
case let .todosResponse(todos):
state.todos = todos
state.isLoading = false
return .none
}
}
}
}
이때 중요한 점은 네트워크 구현 자체를 reducer 안에 직접 넣지 않고, Dependency를 통해 외부에서 주입받는다는 점이다. 이렇게 해야 reducer 구조도 단순해지고 테스트도 쉬워진다.
Reducer가 너무 커졌을 때 나누는 방법
실무에서는 reducer가 커지는 순간이 반드시 온다. 그럴 때는 보통 다음 순서로 정리하면 좋다.
1. 화면 단위로 하위 Feature 분리
상세 화면, 편집 화면, 필터 화면이 섞여 있다면 각각 별도 Feature로 뺀다.
2. 바인딩 로직 분리
BindingReducer를 도입해서 입력 처리와 일반 액션 처리를 분리한다.
3. 외부 기능은 Dependency로 분리
네트워크, analytics, storage 같은 것은 reducer 밖으로 뺀다.
4. 긴 switch 분기는 의미 단위로 정리
필요하다면 private 메서드로 일부 분기를 나누어 읽기 쉽게 만들 수 있다.
private func handleSave(state: inout State) -> Effect<Action> {
state.isSaving = true
return .none
}
다만 reducer 본문이 너무 잘게 쪼개져서 오히려 읽기 어려워지는 것도 피하는 편이 좋다.
Reducer 조합 패턴
실무에서 자주 보는 reducer body 구조는 대략 이런 형태다.
var body: some ReducerOf<Self> {
Scope(state: \.child, action: \.child) {
ChildFeature()
}
BindingReducer()
Reduce { state, action in
switch action {
case .onAppear:
return .none
case .binding:
return .none
case .child:
return .none
}
}
}
이 구조의 장점은 다음과 같다.
- 자식 reducer 조합
- 바인딩 처리 분리
- 부모 로직 집중
즉, reducer를 “하나의 giant switch”로 만들기보다 조합 가능한 단위로 설계하는 것이 핵심이다.
onChange 같은 reducer 도구 활용하기
최근 TCA 문서에는 reducer 단계에서 특정 값 변화에 반응할 수 있는 onChange 도구도 소개되어 있다. 예를 들어 바인딩된 값이 바뀔 때 추가 로직을 실행해야 하는 경우에 유용하다. :contentReference[oaicite:3]{index=3}
BindingReducer()
.onChange(of: \.username) { oldValue, newValue in
Reduce { state, action in
state.isValid = !newValue.isEmpty
return .none
}
}
이런 도구를 활용하면 switch 문 안에서 모든 분기를 직접 관리하지 않아도 돼서 reducer 구조가 더 명확해질 수 있다.
좋은 Reducer 구조를 판단하는 기준
실제로 reducer 구조가 괜찮은지 판단할 때는 다음 질문을 해보면 좋다.
- 이 reducer가 한 화면 또는 한 흐름에 집중하고 있는가?
- 네트워크나 저장소 구현이 직접 섞여 있지 않은가?
- 자식 화면 책임이 부모 reducer에 과하게 들어오지 않았는가?
- 액션 이름만 봐도 흐름이 읽히는가?
- 테스트 코드를 쓰기 쉬운 구조인가?
이 질문에 대부분 “그렇다”고 답할 수 있으면 reducer 구조가 꽤 잘 잡혀 있다고 볼 수 있다.
자주 하는 실수
1. reducer 하나에 너무 많은 화면 로직을 넣는 경우
목록, 상세, 편집, 모달, 필터까지 한 reducer에 다 넣으면 상태와 액션이 급격히 비대해진다.
2. 외부 객체를 reducer 안에서 직접 생성하는 경우
APIClient(), Date(), UUID() 같은 것을 직접 쓰기 시작하면 테스트가 어려워지고 결합도가 높아진다.
3. 자식 Feature 없이 거대한 switch 문으로만 버티는 경우
처음에는 빠르지만 규모가 커질수록 유지보수가 어려워진다.
4. 부모와 자식의 책임이 섞이는 경우
자식 reducer가 부모 path를 직접 건드리거나, 부모 reducer가 자식 내부 화면 로직까지 다루면 구조가 꼬이기 쉽다.
실무에서 추천하는 설계 흐름
TCA reducer 구조를 설계할 때는 보통 다음 순서가 안정적이다.
- 먼저 화면 단위로 Feature를 나눈다.
- 부모 reducer와 자식 reducer의 책임을 나눈다.
- 외부 기능은 Dependency로 분리한다.
- 입력 화면은 BindingReducer를 쓴다.
- 네비게이션은 부모 reducer가 조립한다.
- Reducer body는 조합 중심으로 유지한다.
이렇게 설계하면 앱 규모가 커져도 reducer가 비교적 건강하게 유지된다.
정리
- TCA reducer는 상태를 변경하고 effect를 반환하는 핵심 로직이다.
- 좋은 reducer 구조는 하나의 Feature 책임에 집중한다.
- 부모 reducer는 흐름과 조합을, 자식 reducer는 자기 화면 로직을 담당하는 구조가 좋다.
- 외부 기능은 Dependency로 분리하고, 입력 로직은 BindingReducer로 분리하면 reducer가 깔끔해진다.
- Reducer를 giant switch 하나로 키우기보다 조합 가능한 단위로 나누는 것이 실무에서 훨씬 유리하다.
TCA (Composable Architecture) 관련 글
- TCA (Composable Architecture) 개념 정리 (Swift / iOS)
- TCA Feature 모듈 구조 설계 방법 (SwiftUI / Composable Architecture)
- TCA Reducer 구조 설계 방법 (SwiftUI / Composable Architecture)
- TCA Store scope 사용 방법 (SwiftUI / Composable Architecture)
- TCA Dependency 사용 방법 (SwiftUI / Composable Architecture)
- TCA Async / Effect 사용 방법 (SwiftUI / Composable Architecture)
- TCA BindingAction 사용 방법 (SwiftUI / Composable Architecture)
- TCA 테스트 코드 작성 방법 (SwiftUI / Composable Architecture)
- TCA NavigationStack 구조 구현 방법 (SwiftUI / Composable Architecture)
- TCA StackState 사용 방법 정리 (SwiftUI / Composable Architecture)
- TCA NavigationStackStore 사용 방법 (SwiftUI / Composable Architecture)
- TCA Delegate 패턴 화면 이동 구현 방법 (SwiftUI / Composable Architecture)
- TCA TabView 구조 설계 방법 (SwiftUI / Composable Architecture)
- TCA Optional State Navigation 패턴 정리 (SwiftUI / Composable Architecture)
'IT' 카테고리의 다른 글
| TCA BindingAction 사용 방법 (SwiftUI / Composable Architecture) (0) | 2026.03.15 |
|---|---|
| TCA Async / Effect 사용 방법 (SwiftUI / Composable Architecture) (0) | 2026.03.15 |
| TCA Store scope 사용 방법 (SwiftUI / Composable Architecture) (0) | 2026.03.15 |
| TCA 테스트 코드 작성 방법 (SwiftUI / Composable Architecture) (0) | 2026.03.15 |
| TCA Dependency 사용 방법 (SwiftUI / Composable Architecture) (0) | 2026.03.15 |