- TCA의 단방향 흐름을 그림으로 설명해보세요.
- 리듀서 합성이 왜 필요한가요?
Scope·ifLet·forEach는 각각 언제 씁니까? TestStore가 일반 단위 테스트보다 엄격한 지점은 어디인가요?- TCA를 쓰지 말아야 할 상황은 언제인가요?
단방향이 푸는 문제 🔥 핵심
화면이 복잡해질 때 진짜로 아픈 건 코드 양이 아니라 "지금 화면이 왜 이 모습인지 설명할 수 없게 되는 것"입니다. 상태를 여러 곳에서 고칠 수 있으면, 버그가 났을 때 누가 마지막으로 바꿨는지를 추적해야 합니다.
단방향 아키텍처의 이득을 한 문장으로 말해야 한다면: "상태가 바뀌는 경로가 하나뿐이라, 화면의 현재 모습을 State만 보고 설명할 수 있다"입니다.
네 조각 — State · Action · 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개고 각 탭에 목록과 상세가 있으면 상태 관리 코드가 한 덩어리로 부풀어 오릅니다.
Scope, 0 또는 1개면 ifLet, N개면 forEach.| 연산자 | 자식 State | 대표 상황 |
|---|---|---|
Scope | 항상 존재 (1개) | 탭, 고정 섹션 |
ifLet | 옵셔널 (0 또는 1개) | 모달, 시트, 알럿 |
forEach | N개 (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)이라고 합니다.
TestStore는 이걸 테스트 실패로 만들어 줍니다.
완전성이 부담스러우면 store.exhaustivity = .off로 완화할 수 있습니다.
다만 그러면 TestStore를 쓰는 가장 큰 이유를 버리는 것이라, 통합 테스트처럼
흐름이 매우 긴 경우에만 쓰는 게 일반적입니다.
계보 — MVVM · ReactorKit · TCA
면접에서 "TCA를 안 써봤다"고 답할 상황이라면, 인접한 단방향 경험을 정확히 대조하는 게 가장 강한 답입니다. 세 아키텍처는 같은 방향으로 진화한 계보입니다.
@Dependency, TestStore.| 항목 | ReactorKit | TCA |
|---|---|---|
| 상태 변경 단계 | 2단 (mutate → reduce) |
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() 필요 |
균형 잡힌 결론: 복잡한 상태를 가진 화면에 선택적으로 적용하는 것. 아키텍처는 전부 아니면 전무가 아니라, 복잡도가 그 비용을 정당화하는 곳에 씁니다.