Swift - TCA(The Composable Architecture)

Marble·2026년 4월 5일

어쩌다가 TCA라는 것을 듣게 됐는데 찾아보니 Swift와 관련된 용어였어요. 그래서 이번 글에서는 TCA에 대해 알아볼게요

TCA

TCA는 The Composable Architecture의 약자로 Point-Free(Brandon Williams, Stephen Celis)가 만든 Swift/SwiftUI용 단방향 상태 관리 오픈소스 아키텍처 라이브러리입니다.

핵심 개념

핵심 개념은 다음과 같아요

  • 단방향 데이터 흐름 — 상태는 항상 한 방향으로만 흐름
  • 상태 불변성 — State는 직접 수정 불가, Action을 통해서만 변경
  • 순수 함수 — Reducer는 같은 입력에 항상 같은 출력
  • 합성 가능성 — 작은 기능 단위를 조합해 큰 기능을 만듦

예전에 React를 공부하며 배운 Redux 같다고 느껴졌었는데 실제로 Redux에서 영감을 받았다고 해요

기본 구조

User Interaction
        ↓
     Action
        ↓
     Reducer  →  Effect (API 호출, 타이머 등)
        ↓              ↓
      State  ←  Action (결과 반환)
        ↓
      View

위에 핵심 구조에서 말했듯이 단방향 데이터 흐름이죠? 버튼을 눌렀을 때 숫자가 올라가는 카운터 앱을 예시로 설명할게요.

[사용자가 + 버튼 누름]
          ↓
     Action 발생
    (.increment)
          ↓
      Reducer
    (state.count += 1)
          ↓
     State 변경
    (count: 0 → 1)
          ↓
     View 업데이트
    ("1" 표시)

State

화면에 필요한 모든 데이터로 지금 이 화면이 알아야 할 정보를 모아놓은 구조체예요.

struct State: Equatable {
var count = 0 // 현재 숫자
var isLoading = false // 로딩 중인지
}
State는 직접 수정할 수 없어요. 반드시 Action을 통해서만 바꿀 수 있어요.

Action

발생 가능한 모든 이벤트로 "이 화면에서 일어날 수 있는 일"을 열거형으로 정의해요.
버튼 탭, API 응답, 타이머 등 모든 이벤트가 여기에 포함돼요.

enum Action {
case increment // + 버튼 탭
case decrement // - 버튼 탭
case fetchUser // API 호출 요청
case fetchUserResponse(User) // API 응답 도착
}

Reducer

상태를 바꾸는 유일한 장소로 Action이 들어오면 State를 어떻게 바꿀지 결정하는 함수예요.
항상 같은 입력 → 같은 출력 이라는 규칙이 있어요. 단순한 상태 변화는 바로 처리하고, API 호출처럼 시간이 걸리는 작업은 Effect 로 넘겨요.

var body: some ReducerOf<Self> {
	Reduce { state, action in
    	switch action {

        // 단순 상태 변화 → 바로 처리
        case .increment:
            state.count += 1
            return .none          // 추가 작업 없음

        // 시간이 걸리는 작업 → Effect로 넘김
        case .fetchUser:
            state.isLoading = true
            return .run { send in
                let user = try await apiClient.fetchUser()
                await send(.fetchUserResponse(user)) // 완료되면 Action으로 돌아옴
        	}

        case .fetchUserResponse(let user):
            state.isLoading = false
            state.user = user
            return .none
    	}
	}
}

Effect

Reducer는 순수 함수여야 하기 때문에 API 호출, 타이머 같은 작업은 직접 처리하지 않아요. 대신 Effect로 감싸서 반환하고, 완료되면 결과를 다시 Action으로 돌려보내요.

fetchUser Action 들어옴
          ↓
Reducer에서 Effect 반환
          ↓
Effect가 API 호출 (비동기)
          ↓
결과를 fetchUserResponse Action으로 반환
          ↓
Reducer가 State 업데이트

.none 은 "추가 작업 없음", .run { } 은 비동기 작업이 있을 때 써요

단어들은 처음 들어보지만 하는 역할들이 다 익숙하죠?
Swift에서 자주 사용하는 MVVM과 비슷한 구조라서 그럴거에요

MVVMTCA하는 일
ViewView화면 그리기
ViewModelStore상태 보유, 로직 처리
ViewModel 메서드Action + Reducer상태 변경
ViewModel 프로퍼티State화면에 필요한 데이터
async 함수Effect비동기 작업

MVVM에서 ViewModel이 상태를 들고 있고 View가 이를 구독하듯이, TCA에서는 Store가 그 역할을 해요. Repository 패턴과도 닮아있는데, 둘 다 "직접 건드리지 말고 나를 통해서 해"라는 철학이에요

Store

상태(State)를 보유하고 Action을 받아 Reducer로 전달하는 런타임이에요. 보통 앱 진입점에서 한 번만 만들고 하위 View로 내려줘요. ViewModel을 초기화하는 것과 같다고 보면 돼요.

let store = Store(initialState: Counter.State()) {
    Counter()
}

ViewStore

MVVM에서 View가 @Published 프로퍼티를 구독하듯, ViewStore는 Store의 State 변화를 감지해 View를 업데이트해요. View와 Store를 연결하는 창구 역할이에요. 하지만 MVVM과 다른 점이라면 MVVM에서는 View가 ViewModel을 직접 구독하지만 TCA에서는 View가 Store를 직접 구독하지 않아요. ViewStore라는 창구를 통해서만 State를 읽고 Action을 보내요

struct CounterView: View {
    let store: StoreOf<Counter>

    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            Text("\(viewStore.count)")     // State 읽기
            Button("+") {
                viewStore.send(.increment) // Action 보내기
            }
        }
    }
}

전체 흐름을 MVVM과 나란히 보면 이렇게 돼요.

  • MVVM
    View → ViewModel.메서드() → @Published 변경 → View 업데이트
  • TCA
    View → store.send(Action) → Reducer → State 변경 → ViewStore → View 업데이트

의존성 관리

TCA는 자체 DI 시스템을 제공해요. MVVM에서 생성자 주입으로 Mock을 넣어주던 것과 비슷하지만, 전역으로 등록해두고 어디서든 꺼내 쓸 수 있어요

struct Counter: Reducer {
	@Dependency(\.apiClient) var apiClient
    @Dependency(\.continuousClock) var clock

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            case .fetchUser:
                return .run { send in
                    let user = try await apiClient.fetchUser() // 주입된 의존성 사용
                    await send(.fetchUserResponse(user))
                }
        }
    }
}

그래서 테스트 하기가 용이하죠. 테스트할 때 withDependencies로 실제 구현체를 Mock으로 교체하기만 하면 테스트할 수 있으니까요

// MVVM — 생성자에 Mock 주입
let viewModel = CounterViewModel(apiClient: MockAPIClient())

// TCA — withDependencies로 교체
withDependencies {
    $0.apiClient.fetchUser = { .mock }
} operation: {
    // 이 블록 안에서는 Mock이 사용됨
}

장단점

장단점은 다음과 같아요

장점

  • 상태 변화가 예측 가능하고 추적하기 쉬움
  • 테스트 작성이 매우 용이
  • 의존성 관리가 명확
  • 대규모 앱에서 기능 단위 분리가 깔끔

단점

  • 학습 곡선이 가파름
  • 보일러플레이트 코드가 많아짐
  • 간단한 화면에도 구조를 갖춰야 해서 오버엔지니어링이 될 수 있음
  • 컴파일 타임이 길어질 수 있음

마지막으로 언제 사용하면 좋을지 표로 정리하고 마무리 할게요

상황추천 여부
상태가 복잡하고 여러 화면이 공유
테스트 커버리지가 중요한 프로젝트
팀 프로젝트, 협업 필요
간단한 토이 프로젝트
SwiftUI 처음 배우는 단계
profile
개발자가 되고 싶은 공돌이

0개의 댓글