TCA Async / Effect 사용 방법 (SwiftUI / Composable Architecture)
TCA(Composable Architecture)를 사용하다 보면 단순한 버튼 탭이나 상태 변경만 처리하는 것이 아니라, 네트워크 요청, 데이터 저장, 타이머, 지연 처리처럼 비동기 작업을 reducer에서 어떻게 다뤄야 하는지 고민하게 된다.
이때 중요한 개념이 바로 Effect와 async 처리다. TCA에서는 reducer가 상태만 바꾸는 것이 아니라, 필요한 경우 외부 작업을 수행한 뒤 다시 액션을 보낼 수 있다.
이 글에서는 TCA에서 Async / Effect를 어떻게 사용하는지, 기본 개념부터 실무에서 자주 쓰는 패턴까지 정리한다.
TCA에서 Effect란 무엇인가
TCA에서 reducer는 단순히 state만 바꾸는 함수가 아니다. 필요하면 추가 작업을 실행하고, 그 결과를 다시 action으로 돌려보낼 수 있다.
이런 “외부 작업” 또는 “나중에 다시 action을 발생시키는 작업”을 보통 effect라고 부른다.
- API 요청
- 파일 저장
- 로컬 DB 읽기
- 1초 뒤 액션 보내기
- 타이머 시작
- 비동기 작업 결과 전달
즉, effect는 “상태 변경 외의 일”을 reducer 바깥 세계와 연결하는 도구라고 보면 된다.
Async / Effect가 필요한 이유
예를 들어 할 일 목록을 서버에서 불러오는 화면을 생각해보자. 사용자가 화면에 진입하면 다음 순서가 필요하다.
- 로딩 상태를 true로 바꾼다
- 비동기로 서버 요청을 보낸다
- 응답이 오면 목록을 state에 반영한다
- 로딩 상태를 false로 바꾼다
이 과정은 한 번의 동기 상태 변경만으로 해결되지 않는다. 그래서 reducer가 effect를 반환하고, 비동기 작업이 끝난 뒤 다시 action을 받아 상태를 업데이트하는 구조가 필요하다.
가장 기본적인 비동기 패턴
TCA에서 가장 많이 쓰는 기본 패턴은 다음과 같다.
- 사용자 액션 발생
- reducer가 로딩 상태 변경
- .run 으로 비동기 작업 실행
- 작업 결과를 다시 action으로 전달
- 결과 action에서 state 업데이트
예제를 보면 더 이해가 쉽다.
Todo 목록 불러오기 예제
import ComposableArchitecture
@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
}
}
}
}
이 코드에서 핵심은 .run이다. 이 블록 안에서 async 작업을 실행하고, 끝난 뒤 다시 send로 액션을 보낸다.
.run의 역할
.run은 비동기 작업을 effect로 감싸는 가장 대표적인 방식이다. 이 안에서는 async/await 문법을 그대로 사용할 수 있다.
return .run { send in
let todos = try await todoClient.fetchTodos()
await send(.todosResponse(todos))
}
즉, reducer는 다음 의미를 갖게 된다.
- 지금은 로딩 상태로 바꾼다
- 비동기 작업을 시작한다
- 결과가 오면 다시 액션을 보낸다
실무에서 가장 자주 보는 TCA 비동기 패턴이 바로 이 구조다.
성공 / 실패를 함께 처리하는 방법
실제 앱에서는 성공만 처리하면 안 되고 실패도 같이 처리해야 한다. 그래서 Result 또는 TaskResult 패턴을 많이 사용한다.
import ComposableArchitecture
@Reducer
struct TodoFeature {
@Dependency(\.todoClient) var todoClient
@ObservableState
struct State: Equatable {
var todos: [String] = []
var isLoading = false
var errorMessage: String?
}
enum Action {
case onAppear
case todosResponse(TaskResult<[String]>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.isLoading = true
state.errorMessage = nil
return .run { send in
await send(
.todosResponse(
TaskResult {
try await todoClient.fetchTodos()
}
)
)
}
case let .todosResponse(.success(todos)):
state.todos = todos
state.isLoading = false
return .none
case .todosResponse(.failure):
state.isLoading = false
state.errorMessage = "목록을 불러오지 못했습니다."
return .none
}
}
}
}
이 방식의 장점은 성공과 실패를 하나의 응답 액션에서 자연스럽게 처리할 수 있다는 점이다.
왜 Dependency와 함께 써야 하나
비동기 작업을 reducer 안에 직접 구현하는 것은 추천되지 않는다. 예를 들어 URLSession 코드를 reducer에 바로 넣기 시작하면 테스트하기도 어렵고 책임도 섞이기 쉽다.
그래서 보통 네트워크 호출이나 저장소 접근은 Dependency로 분리한다.
@Dependency(\.todoClient) var todoClient
그리고 reducer는 “무엇을 요청할지”만 알고, 실제 구현은 외부 dependency가 담당하게 만든다.
딜레이 효과 처리하기
실무에서는 API 호출 말고도 일정 시간 뒤 액션을 보내야 하는 경우가 있다. 예를 들어 토스트 자동 닫기, 스플래시 지연, 버튼 debounce 같은 경우다.
이럴 때도 .run 안에서 clock이나 sleep을 이용해 처리할 수 있다.
import ComposableArchitecture
@Reducer
struct ToastFeature {
@Dependency(\.continuousClock) var clock
@ObservableState
struct State: Equatable {
var isToastVisible = false
}
enum Action {
case showToast
case hideToast
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .showToast:
state.isToastVisible = true
return .run { send in
try await clock.sleep(for: .seconds(2))
await send(.hideToast)
}
case .hideToast:
state.isToastVisible = false
return .none
}
}
}
}
이 구조는 “2초 뒤 토스트 숨기기” 같은 흐름에서 자주 사용된다.
여러 action을 순차적으로 보내는 패턴
비동기 작업 중간에 여러 액션을 보내고 싶을 때도 있다. 예를 들어 로딩 시작 → 응답 처리 → 분석 로그 전송 같은 흐름이다.
.send를 여러 번 호출하면 순차적인 이벤트 흐름을 만들 수 있다.
return .run { send in
await send(.loadingStarted)
let todos = try await todoClient.fetchTodos()
await send(.todosResponse(todos))
await send(.analyticsLogged)
}
이 패턴은 흐름이 길어질수록 액션 이름을 명확하게 짓는 것이 중요하다.
취소 가능한 Effect 만들기
검색 자동완성처럼 이전 요청을 취소해야 하는 경우도 있다. 이럴 때는 취소 ID를 두고 cancel 가능한 effect로 만드는 패턴을 사용한다.
import ComposableArchitecture
@Reducer
struct SearchFeature {
@Dependency(\.searchClient) var searchClient
@ObservableState
struct State: Equatable {
var query = ""
var results: [String] = []
}
enum Action {
case queryChanged(String)
case searchResponse([String])
}
enum CancelID {
case search
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .queryChanged(query):
state.query = query
return .run { [query] send in
let results = try await searchClient.search(query)
await send(.searchResponse(results))
}
.cancellable(id: CancelID.search, cancelInFlight: true)
case let .searchResponse(results):
state.results = results
return .none
}
}
}
}
이 패턴은 입력이 계속 바뀌는 검색 화면에서 특히 자주 사용된다. 이전 요청이 끝나기도 전에 새 요청이 들어오면 이전 effect를 취소하고 최신 요청만 유지할 수 있다.
Async / Effect 테스트는 어떻게 하나
TCA의 장점 중 하나는 effect도 테스트하기 쉽다는 점이다. TestStore를 사용하면 비동기 effect가 발생시킨 액션까지 검증할 수 있다.
func testLoadTodos() async {
let store = TestStore(
initialState: TodoFeature.State()
) {
TodoFeature()
} withDependencies: {
$0.todoClient.fetchTodos = {
["Buy Milk", "Write Blog"]
}
}
await store.send(.onAppear) {
$0.isLoading = true
}
await store.receive(.todosResponse(.success(["Buy Milk", "Write Blog"]))) {
$0.todos = ["Buy Milk", "Write Blog"]
$0.isLoading = false
}
}
즉, Async / Effect는 테스트와 함께 설계하는 것이 가장 좋다.
실무에서 자주 쓰는 Async / Effect 패턴
- 화면 진입 시 목록 로드
- 저장 버튼 탭 시 서버 저장
- 검색어 변경 시 자동완성 요청
- 토스트 / 로딩 / 스플래시 지연 처리
- 결제 결과 콜백 처리
- 푸시 토큰 등록
이런 로직은 대부분 reducer에서 effect로 표현하면 흐름이 깔끔해진다.
자주 하는 실수
1. reducer 안에 직접 네트워크 구현을 넣는 경우
URLSession 코드를 reducer에 직접 넣으면 테스트와 모듈 분리가 어려워진다. 외부 기능은 Dependency로 분리하는 편이 좋다.
2. 성공 케이스만 처리하는 경우
실제 앱에서는 실패 흐름이 더 중요할 때도 많다. TaskResult나 Result를 써서 에러 처리까지 같이 설계하는 것이 좋다.
3. 로딩 상태를 해제하지 않는 경우
비동기 작업 시작 시 로딩 상태를 켰다면, 성공과 실패 모두에서 반드시 끄는 흐름을 넣어야 한다.
4. 취소가 필요한 effect를 계속 누적시키는 경우
검색이나 타이머처럼 중복 실행이 문제 되는 로직은 cancellable 패턴까지 함께 고려하는 것이 좋다.
Reducer 구조 관점에서 보면
Async / Effect 로직이 많아질수록 reducer 구조 설계가 더 중요해진다. 좋은 구조는 보통 다음 특징을 가진다.
- 동기 상태 변경과 비동기 effect 흐름이 명확히 나뉜다
- 외부 구현은 Dependency로 빠져 있다
- 성공 / 실패 action이 명확하다
- 테스트 가능한 구조다
즉, effect를 잘 쓴다는 것은 단순히 async 코드를 넣는 것이 아니라 상태 변화와 외부 작업의 경계를 잘 나누는 것에 가깝다.
정리
- TCA의 effect는 reducer가 외부 작업을 수행하고 다시 action을 보낼 수 있게 해 주는 구조다.
- 최신 async 패턴에서는
.run을 많이 사용한다. - 성공 / 실패 처리는
TaskResult또는Result패턴으로 정리하면 깔끔하다. - 네트워크나 저장소 접근은 Dependency로 분리하는 것이 좋다.
- 검색 같은 흐름에서는 cancellable effect 패턴이 유용하다.
- Async / Effect 로직도 TCA에서는 TestStore로 테스트하기 쉽다.
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 NavigationStackStore 사용 방법 (SwiftUI / Composable Architecture) (0) | 2026.03.15 |
|---|---|
| TCA BindingAction 사용 방법 (SwiftUI / Composable Architecture) (0) | 2026.03.15 |
| TCA Reducer 구조 설계 방법 (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 |