← iOS 기술면접 — 모던 스택 5종 1 / 5

01 · TCA — 단방향 아키텍처의 완성형

State·Action·Reducer·Effect의 구조, 합성, TestStore, 그리고 언제 쓰지 말아야 하는가
진행률
0 / 0 완료
이 장에서 답할 수 있게 되는 질문
  1. TCA의 단방향 흐름을 그림으로 설명해보세요.
  2. 리듀서 합성이 왜 필요한가요? Scope·ifLet·forEach는 각각 언제 씁니까?
  3. TestStore가 일반 단위 테스트보다 엄격한 지점은 어디인가요?
  4. TCA를 쓰지 말아야 할 상황은 언제인가요?

단방향이 푸는 문제 🔥 핵심

화면이 복잡해질 때 진짜로 아픈 건 코드 양이 아니라 "지금 화면이 왜 이 모습인지 설명할 수 없게 되는 것"입니다. 상태를 여러 곳에서 고칠 수 있으면, 버그가 났을 때 누가 마지막으로 바꿨는지를 추적해야 합니다.

❌ 상태를 아무나 고침 네트워크 타이머 델리게이트 상태 누가 바꿨는지 추적 불가 ✅ 한 곳만 통과 네트워크 타이머 델리게이트 Reducer 상태 변경 경로가 하나 = 로그 한 줄로 재현
단방향의 값어치는 "코드가 예뻐지는 것"이 아니라 상태 변경 경로가 하나로 좁혀지는 것입니다. 그래서 액션 로그만 있으면 버그를 재현할 수 있습니다.
🔑 핵심

단방향 아키텍처의 이득을 한 문장으로 말해야 한다면: "상태가 바뀌는 경로가 하나뿐이라, 화면의 현재 모습을 State만 보고 설명할 수 있다"입니다.

네 조각 — State · Action · Reducer · Effect

View State를 그린다 Action Reducer (inout State, Action) → Effect 동기 · 순수 Effect — 바깥세상 결과를 Action으로 State Dependency API · DB · 시계 테스트에서 교체
루프의 핵심은 Reducer가 동기·순수하다는 것입니다. 네트워크를 직접 부르지 않고 "이런 일을 해달라"는 Effect만 반환하므로, 리듀서 자체는 입력·출력만 있는 함수가 됩니다.
@Reducer
struct ClinicList {
  @ObservableState
  struct State: Equatable {
    var clinics: [Clinic] = []
    var isLoading = false
    @Presents var detail: ClinicDetail.State?
  }

  enum Action {
    case onAppear
    case clinicsResponse(Result<[Clinic], Error>)   // 결과도 Action이다
    case detail(PresentationAction<ClinicDetail.Action>)
  }

  @Dependency(\.clinicClient) var clinicClient

  enum CancelID { case fetch }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .onAppear:
        state.isLoading = true
        return .run { send in
          await send(.clinicsResponse(Result { try await clinicClient.fetch() }))
        }
        .cancellable(id: CancelID.fetch)      // 화면을 나가면 취소된다

      case let .clinicsResponse(.success(clinics)):
        state.isLoading = false
        state.clinics = clinics
        return .none

      case .clinicsResponse(.failure):
        state.isLoading = false
        return .none

      case .detail:
        return .none
      }
    }
    .ifLet(\.$detail, action: \.detail) { ClinicDetail() }
  }
}
조각규칙흔한 실수
State 화면을 그리는 데 필요한 전부. Equatable 권장 뷰에만 있는 임시 상태를 밖에 둠
Action 일어날 수 있는 모든 일. 사용자 입력 + 시스템 응답 응답을 Action으로 안 만들고 직접 State 수정
Reducer 동기 · 순수. 여기서 네트워크·시계·랜덤 금지 Date()·UUID()를 리듀서에서 직접 호출
Effect 바깥세상과의 접촉. 결과는 다시 Action으로 .cancellable(id:) 누락 → 화면 나가도 계속 실행
⚠️ 주의

리듀서 안에서 Date()UUID()를 직접 부르면 순수성이 깨져 테스트가 불가능해집니다. 같은 입력에 같은 출력이 안 나오기 때문입니다. TCA가 @Dependency(\.date), @Dependency(\.uuid)를 기본 제공하는 이유가 이것입니다.

합성 — 작은 리듀서를 트리로 🔥 핵심

단방향 아키텍처를 직접 만들어 본 사람이 반드시 만나는 벽이 있습니다. 화면이 커질수록 리듀서 하나가 비대해지는 것입니다. 탭이 5개고 각 탭에 목록과 상세가 있으면 상태 관리 코드가 한 덩어리로 부풀어 오릅니다.

AppReducer Scope ifLet forEach SearchTab 항상 존재 Detail? 있다가 없어짐 (모달) Row × N IdentifiedArray 각 리듀서는 자기 State만 안다 → 따로 테스트 가능 부모는 자식의 내부를 모르고, 자식은 부모의 존재를 모른다
세 연산자는 자식 상태의 개수로 갈립니다 — 항상 1개면 Scope, 0 또는 1개면 ifLet, N개면 forEach.
연산자자식 State대표 상황
Scope항상 존재 (1개)탭, 고정 섹션
ifLet옵셔널 (0 또는 1개)모달, 시트, 알럿
forEachN개 (IdentifiedArray)리스트 항목마다 독립 상태
var body: some ReducerOf<Self> {
  Reduce { state, action in
    // 부모 자신의 로직
  }
  .ifLet(\.$detail, action: \.detail) { ClinicDetail() }
  .forEach(\.rows, action: \.rows) { ClinicRow() }
}
🔑 핵심

합성이 주는 진짜 이득은 테스트 단위가 쪼개지는 것입니다. ClinicRow의 동작을 검증하려고 앱 전체 State를 만들 필요가 없습니다. 이게 "리듀서 하나가 비대해지는" 문제의 근본 해법입니다.

TestStore — 결과가 아니라 과정을 검증한다 🔥 핵심

@Test func 목록_로딩_성공() async {
  let store = TestStore(initialState: ClinicList.State()) {
    ClinicList()
  } withDependencies: {
    $0.clinicClient.fetch = { [Clinic.mock] }     // 의존성 교체
  }

  await store.send(.onAppear) {
    $0.isLoading = true                            // 바뀔 상태를 전부 적는다
  }
  await store.receive(\.clinicsResponse.success) {
    $0.isLoading = false
    $0.clinics = [.mock]
  }
}

일반 단위 테스트는 "최종 결과가 맞는가"를 봅니다. TestStore"무슨 일이 어떤 순서로 일어났는가"를 전부 봅니다. 이 성질을 완전성(exhaustivity)이라고 합니다.

테스트 시작 테스트 종료 ① send 상태 변화를 전부 단언하지 않으면 → diff와 함께 실패 ② receive Effect가 보낸 액션을 안 받고 남기면 → 실패 ③ 종료 시점 아직 살아있는 Effect가 있으면 → 실패 ③ 덕분에 취소 누락이 테스트에서 잡힌다
특히 ③이 실무에서 값집니다. 화면을 나갔는데 Effect가 계속 도는 버그는 눈으로 안 보이는데, TestStore는 이걸 테스트 실패로 만들어 줍니다.
📝 노트

완전성이 부담스러우면 store.exhaustivity = .off로 완화할 수 있습니다. 다만 그러면 TestStore를 쓰는 가장 큰 이유를 버리는 것이라, 통합 테스트처럼 흐름이 매우 긴 경우에만 쓰는 게 일반적입니다.

계보 — MVVM · ReactorKit · TCA

면접에서 "TCA를 안 써봤다"고 답할 상황이라면, 인접한 단방향 경험을 정확히 대조하는 게 가장 강한 답입니다. 세 아키텍처는 같은 방향으로 진화한 계보입니다.

MVVM 뷰와 로직 분리 상태 변경은 자유 ReactorKit Action→Mutation→State 부수효과는 RxSwift TCA Action→State + Effect 부수효과는 Concurrency + 단방향 강제 + 합성 · 의존성 · 테스트 공통 사상 — 단일 진실 공급원 · 부수효과 격리 · 예측 가능한 상태 전이
ReactorKit에서 TCA로 넘어갈 때 새로 배워야 하는 건 사상이 아니라 도구입니다 — 합성 연산자, @Dependency, TestStore.
항목ReactorKitTCA
상태 변경 단계 2단 (mutatereduce) 1단 (reduce가 Effect 반환)
부수효과 런타임 RxSwift Observable Swift Concurrency
합성 프레임워크 지원 없음 Scope·ifLet·forEach
의존성 주입 직접 구축해야 함 @Dependency 내장
테스트 스텁 Reactor로 State 확인 TestStore — 액션 시퀀스까지

언제 쓰지 말아야 하나 🧩 롱테일

경력이 있는 지원자에게 기대하는 건 "쓸 줄 안다"가 아니라 "언제 안 써야 하는지 안다"입니다. 장점만 말하면 오히려 감점입니다.

비용구체적으로
보일러플레이트 화면 하나에 State·Action·Reducer·View·테스트. 정적인 안내 화면엔 과함
성능 store scoping을 잘못하면 무관한 변경에 뷰가 재평가됨. @ObservableState로 크게 나아졌지만 사라지진 않음
학습 곡선 팀의 절반만 이해하면 오히려 코드가 더 어려워짐
버전 종속 아키텍처를 서드파티에 맡기는 것. TCA는 실제로 breaking change가 잦았음
디버깅 액션 흐름이 간접적이라 스택 트레이스로 추적이 어려움 → _printChanges() 필요
🔑 핵심

균형 잡힌 결론: 복잡한 상태를 가진 화면에 선택적으로 적용하는 것. 아키텍처는 전부 아니면 전무가 아니라, 복잡도가 그 비용을 정당화하는 곳에 씁니다.

출처 · 참고자료