- 지금 프로젝트의 아키텍처는 무엇이고, 왜 그걸 골랐나요?
- 도입해서 실제로 사라진 문제는? 대신 무엇을 지불했나요?
- ReactorKit은 왜 Action에서 State로 바로 안 가고 Mutation을 거치나요?
mutate)와 상태 전이(reduce)를 메서드 수준에서 분리scan을 건 것. 스레딩 계약·@Pulse·disposeBag이 실무 함정30초 답변 🔥 먼저 이 문장
우리 문제는 ViewController 비대화와 상태 추적 불가였습니다. 한 화면에 로딩·에러·필터·페이지네이션이 얽혀 있었고, QA 리포트를 재현할 수 없었습니다.
ReactorKit은 Action → Mutation → State 단방향을 강제하고, 결정적으로 부수효과는
mutate(), 상태 전이는 reduce()로 메서드를 나눠 버립니다.
reduce()는 순수 함수라 "이 상태가 왜 이렇게 됐는지"가 그 안에서 전부 설명됩니다.
이미 RxSwift를 쓰고 있어서 학습 비용이 낮았고, 화면 단위로 점진 도입이 가능했던 점이 결정적이었습니다.
L1개념 — 아키텍처가 실제로 다른 지점
아키텍처 패턴들이 서로 다른 지점은 사실 딱 하나입니다. "상태를 누가, 어디서 바꿀 수 있는가"에 얼마나 강한 제약을 거는가. 나머지(폴더 구조, 파일 이름, 레이어 개수)는 표면입니다.
| 패턴 | 상태 변경 제약 | 결과 |
|---|---|---|
| MVC | 없음 — 아무 데서나 | 소규모 최속, 커지면 ViewController가 전부 흡수 |
| MVVM | 거의 없음 — ViewModel의 아무 메서드/프로퍼티 | 뷰 로직 분리엔 충분, 상태 문제는 그대로 재발 |
| MVVM + Coordinator | 화면 전환만 분리 | 내비게이션 책임 명확, 상태 문제는 그대로 |
| VIPER | 역할은 분리, 흐름 제약은 약함 | 대규모 팀 표준화에 유리, 파일 수 폭발 |
| ReactorKit | reduce() 한 곳에서만 · 부수효과는 mutate()로 격리 | 화면 단위 추적·테스트 가능. Rx 의존이 대가 |
| TCA | Reducer 한 곳에서만 · 앱 전역 트리까지 | 합성·의존성 주입까지 내장. 비용이 가장 크다 |
L2언어·설계 — 왜 Mutation을 한 단계 더 두는가
mutate는 효과를 내고, reduce는 절대 내지 않는다.ReactorKit README의 Design Goal은 정확히 셋입니다. 이걸 그대로 말할 수 있으면 "써 봤다"가 아니라 "왜 이걸 골랐는지 안다"가 됩니다.
- Testability — 첫 번째 목적이 비즈니스 로직을 뷰에서 떼어내는 것. Reactor는 뷰에 대한 의존이 전혀 없어서 단독으로 테스트된다.
- Start Small — 앱 전체가 한 아키텍처를 따를 필요가 없다. 화면 하나부터 부분 도입할 수 있고, 기존 프로젝트를 다시 쓰지 않아도 된다.
- Less Typing — 간단한 일을 복잡하게 쓰지 않는다. 다른 아키텍처보다 코드가 적다.
두 번째가 실무에서 가장 큰 차이를 만듭니다. TCA는 사실상 앱 전체를 그 방식으로 재편하도록 밀어붙이지만, ReactorKit은 "이번 스프린트에 만드는 화면 하나만" 적용할 수 있습니다. 레거시가 많은 프로젝트에서 도입 리스크가 결정적으로 다릅니다.
왜 Action에서 State로 바로 가지 않는가
이게 ReactorKit 면접의 핵심 질문입니다. 답은 "비동기 하나가 상태 변화 여러 개를 만들기 때문"입니다.
// ❌ Action → State 로 직결하면, 비동기 결과를 넣을 자리가 없다
func reduce(state: State, action: Action) -> State {
switch action {
case .refresh:
// 여기서 API를 호출하면? → reduce가 더 이상 순수 함수가 아니다
// 호출 안 하면? → 로딩 시작만 반영하고 결과는 반영할 길이 없다
return state
}
}
// ✅ 두 단계로 쪼개면 둘 다 해결된다
// mutate : 비동기를 다루고, "상태 변화 여러 개"를 스트림으로 내보낸다
// reduce : 그 변화 하나를 받아 새 상태를 만든다 (동기·순수)import ReactorKit
import RxSwift
final class SearchViewReactor: Reactor {
// ① 사용자 의도
enum Action {
case updateQuery(String)
case search
case clear
}
// ② 상태 변화의 최소 단위 — Action과 1:1이 아니다
enum Mutation {
case setQuery(String)
case setLoading(Bool)
case setItems([Item])
case setError(String)
case reset
}
// ③ 화면이 그릴 수 있는 전부
struct State {
var query: String = ""
var isLoading: Bool = false
var items: [Item] = []
@Pulse var errorMessage: String? // 같은 값이어도 매번 이벤트를 받고 싶을 때
}
let initialState = State()
private let searchService: SearchServiceType
init(searchService: SearchServiceType) { // 의존성은 생성자 주입
self.searchService = searchService
}
// ④ 부수효과 — 여기서만
func mutate(action: Action) -> Observable<Mutation> {
switch action {
case let .updateQuery(query):
return .just(.setQuery(query)) // 동기 변화도 Mutation 하나
case .search:
// ★ Action 하나 → Mutation 셋. 이게 2단계 분리의 존재 이유다
let query = currentState.query // 진입 시점 값을 붙잡는다
return Observable.concat([
.just(.setLoading(true)),
searchService.search(query)
.observe(on: MainScheduler.instance)
.map(Mutation.setItems)
.catch { .just(.setError($0.localizedDescription)) },
.just(.setLoading(false))
])
case .clear:
return .just(.reset)
}
}
// ⑤ 상태 전이 — 순수 함수. 효과 절대 금지
func reduce(state: State, mutation: Mutation) -> State {
var newState = state
switch mutation {
case let .setQuery(query): newState.query = query
case let .setLoading(flag): newState.isLoading = flag
case let .setItems(items): newState.items = items
case let .setError(message): newState.errorMessage = message
case .reset: newState = State()
}
return newState
}
}final class SearchViewController: UIViewController, ReactorView {
var disposeBag = DisposeBag()
func bind(reactor: SearchViewReactor) {
// Action (View → Reactor)
searchBar.rx.text.orEmpty
.map { Reactor.Action.updateQuery($0) }
.bind(to: reactor.action)
.disposed(by: disposeBag)
searchButton.rx.tap
.map { Reactor.Action.search }
.bind(to: reactor.action)
.disposed(by: disposeBag)
// State (Reactor → View)
reactor.state.map(\.items)
.distinctUntilChanged() // 중복 렌더 방지
.bind(to: tableView.rx.items(cellIdentifier: "cell")) { _, item, cell in
cell.textLabel?.text = item.title
}
.disposed(by: disposeBag)
reactor.state.map(\.isLoading)
.distinctUntilChanged()
.bind(to: indicator.rx.isAnimating)
.disposed(by: disposeBag)
// @Pulse — 같은 에러 메시지가 두 번 와도 두 번 다 뜬다
reactor.pulse(\.$errorMessage)
.compactMap { $0 }
.subscribe(onNext: { [weak self] in self?.showAlert($0) })
.disposed(by: disposeBag)
}
}
// 주입은 바깥에서
let vc = SearchViewController()
vc.reactor = SearchViewReactor(searchService: searchService)@Pulse가 푸는 문제 — 물어보면 가점
State는 "현재 상태"라서 일회성 이벤트와 궁합이 나쁩니다.
에러 알럿을 그냥 errorMessage: String?로 두면, 같은 메시지가 연달아 오면 distinctUntilChanged에 걸려 두 번째가 안 뜹니다.
반대로 distinctUntilChanged를 빼면 무관한 상태 변경 때마다 알럿이 다시 뜨고요.
@Pulse는 값이 대입될 때마다 내부 카운터를 올려서 "같은 값이어도 새 이벤트"를 구분합니다.
상태(state)와 이벤트(event)는 다른 것이라는 걸 프레임워크가 인정하고 넣은 장치입니다.
ReactorKit에는 전역 앱 상태가 없습니다. 이건 누락이 아니라 "Start Small"의 필연적 결과입니다.
로그인 사용자처럼 여러 화면이 공유하는 값은 transform(mutation:)으로 각 Reactor에 합류시킵니다.
let currentUser: BehaviorSubject<User> // 전역 상태 (앱 어디든)
func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
Observable.merge(mutation, currentUser.map(Mutation.setUser))
}
// → 이 화면의 Action에서 온 Mutation과 전역 변경이 같은 파이프로 들어온다
// reduce 입장에서는 출처를 구분할 필요가 없다L3런타임 — Reactor 안에서 실제로 일어나는 일
reactor.action.onNext(.search) 한 줄이 실행될 때 무슨 일이 벌어지는지 보면, ReactorKit의 성능 특성과 함정이 전부 설명됩니다.
let action = ActionSubject<Action>() // 진입점
let mutation = transform(action: action.asObservable()) // ① 액션 가공
.flatMap { self.mutate(action: $0) } // ② 부수효과 실행
let piped = transform(mutation: mutation) // ③ 전역 스트림 합류
let state = piped
.scan(initialState) { state, mutation in // ④ ★ 여기가 핵심
self.reduce(state: state, mutation: mutation)
}
.startWith(initialState)
.share(replay: 1) // 늦게 구독해도 최신 상태
// ★ scan 이 전부다.
// State 스트림은 "Mutation 스트림에 reduce를 누적 적용한 것"이다.
// 현재 상태를 어딘가 저장해 두는 게 아니라, 초기 상태부터 접어 온 결과다.
ReactorKit은 스레드를 대신 옮겨 주지 않습니다. transform·mutate·reduce·상태 구독자 모두 업스트림이 방출한 스레드에서 그대로 실행됩니다.
| 보장 | 책임 |
|---|---|
| 같은 스레드에서의 재진입 액션 직렬화 | ReactorKit |
| 액션을 메인 스레드에서 넣기 | 개발자 (위반 시 DEBUG 경고) |
mutate의 백그라운드 → 메인 복귀 | 개발자 (mutate 안에서 .observe(on:)) |
// mutate 안에서 메인으로 되돌린다
return searchService.search(query) // 백그라운드에서 방출될 수 있다
.observe(on: MainScheduler.instance) // ✅
.map(Mutation.setItems)
// transform(action:)에 외부 스트림을 merge할 때도 같은 규칙
func transform(action: Observable<Action>) -> Observable<Action> {
Observable.merge(
action,
pushEvents
.observe(on: MainScheduler.instance) // ✅
.map { Action.received($0) }
)
}"State는 항상 메인에서 온다"고 가정하고 UI를 바인딩하면 안 됩니다 — 그건 개발자가 만들어야 하는 보장입니다.
① disposeBag과 순환 참조 — 구독 클로저가 self를 강하게 잡고, self가 disposeBag을 소유하면 순환입니다.
구독 안에서는 [weak self]가 사실상 기본입니다(09번).
② currentState 늦게 읽기 — mutate 안에서 currentState를 읽는 건 편하지만, 비동기 뒤에 읽으면 그 사이 값이 바뀌어 있을 수 있습니다.
07번의 race condition과 정확히 같은 구조입니다 — await 대신 스트림일 뿐이죠.
// ❌ 체인 뒤쪽에서 currentState를 다시 읽는다
return Observable.concat([
.just(.setLoading(true)),
service.search(currentState.query)
.flatMap { _ in service.related(self.currentState.query) } // ⚠️ 그 사이 바뀌었을 수 있다
.map(Mutation.setItems)
])
// ✅ 진입 시점에 값을 붙잡아 둔다
let query = currentState.query
return Observable.concat([
.just(.setLoading(true)),
service.search(query)
.flatMap { _ in service.related(query) }
.map(Mutation.setItems)
])
③ Reactor 재주입 — reactor 프로퍼티에 다시 대입하면 bind(reactor:)가 다시 호출됩니다.
disposeBag을 새로 만들지 않으면 구독이 중첩되어 액션이 두 번씩 나갑니다.
테스트 — Reactor는 뷰 없이 단독으로 돈다
// ① 최종 상태만 보면 되는 경우
func testClear() {
let reactor = SearchViewReactor(searchService: StubSearchService())
reactor.action.onNext(.updateQuery("swift"))
XCTAssertEqual(reactor.currentState.query, "swift")
reactor.action.onNext(.clear)
XCTAssertEqual(reactor.currentState.query, "")
}
// ② 중간 상태(isLoading: false → true → false)까지 봐야 하는 경우
// currentState로는 못 잡는다. TestScheduler로 시간축을 만든다.
func testLoadingSequence() {
let scheduler = TestScheduler(initialClock: 0)
let reactor = SearchViewReactor(searchService: StubSearchService())
let disposeBag = DisposeBag()
scheduler.createHotObservable([.next(100, SearchViewReactor.Action.search)])
.subscribe(reactor.action)
.disposed(by: disposeBag)
let response = scheduler.start(created: 0, subscribed: 0, disposed: 1000) {
reactor.state.map(\.isLoading)
}
XCTAssertEqual(response.events.map(\.value.element), [
false, // 초기 상태
true, // .search 직후
false // 완료 후
])
}
TCA의 TestStore는 기본이 exhaustive라 선언하지 않은 상태 변화가 하나라도 있으면 실패합니다.
ReactorKit 테스트에는 그런 강제가 없어서 내가 확인한 것만 검증됩니다 — 빠뜨린 상태 변화는 조용히 통과합니다.
이건 ReactorKit의 약점입니다. 면접에서 먼저 인정하면 신뢰가 올라갑니다. 대신 "검증할 상태를 골라 쓰니 무관한 필드 변경에 테스트가 덜 깨진다"는 반론도 가능합니다.
L4CS 근본 — 밀리 머신의 두 함수를 메서드로 쪼갠 것
여기서 한 층 더 내려가면, ReactorKit의 2단계 구조가 임의의 설계가 아니라 상태 기계 이론의 표준 분해라는 게 보입니다.
① 유한 상태 기계에는 함수가 두 개 있다
밀리 머신 = (Q, Σ, Λ, δ, λ, q₀)
Q 상태 집합 Σ 입력 알파벳 Λ 출력 알파벳
δ : Q × Σ → Q 전이 함수 (다음 상태)
λ : Q × Σ → Λ 출력 함수 (부수적으로 내보내는 것)
// TCA — 두 함수를 한 메서드에 합쳐 놓는다
Reduce { state, action in
state.x = 1 // δ 역할
return .run { ... } // λ 역할 (Effect)
}
// ReactorKit — 두 함수를 서로 다른 메서드로 분리한다
func mutate(action: Action) -> Observable // λ 에 해당 (효과·출력)
func reduce(state: State, mutation: Mutation) -> State // δ 에 해당 (순수 전이)
// ★ 그래서 ReactorKit의 reduce는 교과서적 δ 그 자체다.
// 반환 타입에 Effect가 섞여 있지 않다 — 순수성이 시그니처로 강제된다. 이게 "Mutation이 왜 필요한가"의 진짜 답입니다. Mutation은 중간 자료구조가 아니라 δ의 입력 알파벳입니다. Action(사용자 의도)과 Mutation(상태 변화)을 나눈 덕분에 "의도 하나가 변화 여러 개"가 자연스럽게 표현됩니다.
② State 스트림은 fold(누적)다
// 리스트에 대한 fold — 결과 하나
[m1, m2, m3].reduce(s0, reduce) == reduce(reduce(reduce(s0, m1), m2), m3)
// 스트림에 대한 scan — 중간 결과를 전부 방출
mutations.scan(s0, accumulator: reduce)
방출: s1 = reduce(s0, m1)
s2 = reduce(s1, m2)
s3 = reduce(s2, m3) …
// 그래서 상태는 "저장된 것"이 아니라 "접어 온 것"이다.
// sn = fold(reduce, s0, [m1 … mn])
//
// 여기서 두 가지가 따라 나온다:
// ① 초기 상태 + Mutation 로그만 있으면 어느 시점이든 재구성된다 → 이벤트 소싱
// ② reduce가 순수해야만 이 재구성이 성립한다 → 순수성은 편의가 아니라 전제
12번의 지연·병합, 11번의 누적과 마찬가지로 fold는 이 인터뷰 전체에 반복되는 계산 모델입니다.
Rx의 scan, Swift의 reduce(into:), TCA의 Reducer가 전부 같은 것입니다.
③ 참조 투명성 → 재현 가능성
State0 ──m1──▶ State1 ──m2──▶ State2 ── … ──mn──▶ Staten
// reduce가 순수(참조 투명)하므로:
// Staten = fold(reduce, State0, [m1 … mn])
//
// → 초기 상태 + Mutation 시퀀스만 있으면 어떤 시점의 화면이든 되살릴 수 있다.
// → QA는 "스크린샷"이 아니라 "Action/Mutation 로그"를 보내면 된다.
//
// 중요한 건 이 성질이 ReactorKit의 기능이 아니라 순수 함수의 수학적 귀결이라는 것.
// "ReactorKit이라서 재현된다"가 아니라
// "전이 함수를 순수하게 유지했기 때문에 재현된다"가 정확한 인과다.func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
mutation.do(onNext: { Logger.log("MUTATION \($0)") })
}
func transform(state: Observable<State>) -> Observable<State> {
state.do(onNext: { Logger.log("STATE \($0)") })
}
// 파이프라인 한 곳에만 붙이면 그 화면의 모든 상태 전이가 기록된다.
// 이게 "단방향이라 추적 가능하다"의 구체적 의미다.④ 불가능한 상태를 표현 불가능하게 = 상태 공간 축소
// ❌ 표현 가능한 조합 2×2×2 = 8, 유효한 건 4
struct BadState {
var isLoading: Bool
var error: String?
var items: [Item]
}
// "로딩 중인데 에러도 있고 아이템도 있는" 상태를 코드로 만들 수 있다
// ✅ 상태 공간 자체를 잘라낸다
struct GoodState {
var query: String = ""
var phase: Phase = .idle
enum Phase { case idle, loading, loaded([Item]), failed(String) }
}
// 정확히 4가지. 무효 상태가 존재하지 않는다.
// 이건 ReactorKit의 기능이 아니라 State를 값 타입 하나로 모았기에 가능한 설계다.02번의 대수적 데이터 타입에서 봤듯 "make illegal states unrepresentable"은 감성적 조언이 아니라 상태 공간 크기를 8에서 4로 줄이는 산술입니다.
⑤ 사이클 없는 데이터 흐름
양방향 바인딩은 A가 B를 바꾸고 B가 A를 바꾸는 사이클을 만듭니다. 사이클이 있으면 "최종 값"을 얻으려고 고정점에 도달할 때까지 반복해야 하고, 수렴 보장도 없습니다(무한 루프 나는 바인딩 버그의 정체).
단방향은 흐름 그래프를 비순환(DAG)으로 만듭니다. DAG에서는 한 번의 순회로 결과가 확정됩니다 — "추론하기 쉽다"의 정확한 의미가 이겁니다. 같은 원리가 12번의 레이아웃 무효화 전파에서 다시 나옵니다.
Out of the Tar Pit — "복잡도의 원인은 상태와 제어"라고 못 박은 논문. 본질적 상태와 우발적 상태를 나눕니다. reduce를 순수하게 두는 이유의 이론적 배경.
Parnas 1972 — 모듈 기준은 "처리 순서"가 아니라 "변경 가능성 은닉". View ↔ Reactor 1:1 분리의 근거.
소프트웨어 아키텍처 — 결합도·응집도와 의존 규칙.
얻은 것과 지불한 것
| 얻은 것 | 메커니즘 (어느 층에서 나오는가) |
|---|---|
| 뷰 없는 로직 테스트 | L2 — Reactor가 뷰에 의존하지 않는다 (공식 Design Goal 1번) |
| 순수한 상태 전이 | L4 — reduce가 δ 그 자체. 효과가 시그니처에 섞이지 않는다 |
| 버그 재현 | L4 — 상태 = fold. 초기 상태 + Mutation 로그로 재구성 |
| 점진 도입 | L2 — 화면 하나부터 가능(Design Goal 2번). 레거시 프로젝트에서 결정적 |
| Massive VC 해소 | L1 — 로직이 Reactor로 빠지고 View는 바인딩만 남는다 |
| 지불한 것 | 구체적으로 |
|---|---|
| RxSwift 의존 | 가장 큰 비용. 팀이 Rx를 모르면 학습 곡선이 ReactorKit이 아니라 Rx에서 발생한다. 빌드 시간·바이너리 크기도 붙는다 |
| Swift Concurrency와의 간극 | mutate가 Observable을 반환하는 구조라 async/await 코드와 경계를 계속 변환해야 한다 |
| 테스트 강제력 약함 | TCA TestStore의 exhaustive 검증 같은 장치가 없다 — 빠뜨린 상태 변화는 조용히 통과 |
| 합성 도구 없음 | Scope·ifLet·forEach에 해당하는 것이 없다. 부모-자식 Reactor 연결은 직접 설계해야 한다 |
| 보일러플레이트 | Action·Mutation 두 enum을 유지해야 한다. 단순 화면에서는 Mutation이 Action의 복사본이 되기 쉽다 |
① 신규 프로젝트를 Swift Concurrency + SwiftUI로 시작 — Rx를 새로 들일 이유가 약하다. Observation 기반 선택지를 먼저 본다.
② 화면이 단순하고 상태가 거의 없음 — Action·Mutation 두 enum이 순수 비용이 된다.
③ 팀에 Rx 경험이 없음 — 진짜 비용은 ReactorKit이 아니라 Rx다.
④ 앱 전역 상태 관리가 핵심 요구 — ReactorKit은 전역 상태를 정의하지 않는다. 그건 별도 설계가 필요하다.
CS 정본으로 더 내려가기
이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.
reduce()를 순수 코어로 명시 / Q4 mutable shared state / Q5 추상화가 많을수록 좋은가CS 로드맵
🗺️ios-cs 02 · 동시성·병렬성·스레드 — Q3 race condition vs data race (currentState 함정의 정체)CS 로드맵
경험으로 말하기
Situation — "ViewController가 800줄이었고, 화면 상태 플래그가 10개였습니다. 유효한 조합이 몇 개인지 아무도 몰랐습니다."
Complication — "QA 리포트를 재현할 수 없었습니다. 로직이 뷰 안에 있어서 테스트를 붙일 자리도 없었고요."
Options — "MVVM 정리 / MVVM + Coordinator / ReactorKit / TCA를 놓고 봤습니다."
Pick — "ReactorKit을 골랐습니다. 이유는 둘입니다. ① 이미 RxSwift를 쓰고 있어서 추가 학습 비용이 Rx가 아니라 Reactor 패턴 하나였고, ② 화면 단위 점진 도입이 가능해 레거시를 멈추지 않아도 됐습니다. TCA는 얻는 게 더 많지만 전면 재편을 요구해서 그 시점에 감당할 수 없었습니다."
Evaluate — "로직이 Reactor로 빠지면서 단위 테스트를 처음으로 붙일 수 있게 됐고, Mutation 로그로 재현이 가능해졌습니다. 대신 Action·Mutation 두 enum 유지가 단순 화면에서는 과했고, 지금은 Swift Concurrency 전환 시 Rx 경계를 어떻게 걷어낼지가 숙제입니다."
꼬리 질문 대비
L2 Action과 Mutation을 굳이 나눠야 하나요? 같아 보이는데요.
단순 화면에서는 실제로 거의 1:1이 되고, 그때는 보일러플레이트가 맞습니다. 이걸 인정하고 시작하는 게 좋습니다.
나뉘어야 하는 이유는 둘의 개수가 맞지 않는 순간에 드러납니다.
- 1 → N —
.search하나가setLoading(true)→setItems→setLoading(false)셋을 낳습니다. Action만 있으면 이 중간 단계를 표현할 자리가 없습니다. - N → 1 —
.pullToRefresh와.retryTapped가 같은setItems로 수렴합니다.reduce는 출처를 구분할 필요가 없습니다. - 0 → 1 — 전역 스트림에서 온 변경은 대응하는 Action이 없습니다.
transform(mutation:)으로 Mutation만 합류시킵니다.
CS로 말하면 Action은 사용자 의도, Mutation은 상태 기계의 입력 알파벳입니다. 둘이 같은 집합일 이유가 없습니다.
L3 reduce()에서 API를 호출하면 안 되는 이유가 뭔가요?
"규칙이니까"가 아니라 세 가지가 동시에 깨집니다.
- ① 테스트가 불가능해진다 —
reduce는 동기 순수 함수라서 "입력 넣고 출력 비교"로 테스트합니다. 네트워크가 들어오면 그 즉시 비동기·비결정적이 됩니다. - ② 재현이 깨진다 — 상태는
fold(reduce, s0, mutations)입니다.reduce가 순수하지 않으면 같은 Mutation 로그를 다시 접어도 같은 상태가 안 나옵니다. - ③ 호출 횟수 보장이 없다 —
reduce는 Rx 파이프라인의scan안에서 돕니다. 구독 형태에 따라 몇 번 불릴지를 개발자가 통제하지 못합니다. 여기에 부수효과가 있으면 API가 몇 번 호출될지 알 수 없습니다.
③이 특히 위험합니다 — "가끔 결제 요청이 두 번 나간다" 류의 버그가 정확히 이 자리에서 나옵니다.
reduce는 계산기입니다. 같은 버튼을 누르면 항상 같은 답이 나와야 하죠. 계산기 안에 "누를 때마다 주문 전화 걸기"를 넣으면 몇 번 걸릴지 아무도 모릅니다.L4 그럼 TCA로 갔으면 더 좋았던 거 아닌가요?
"더 좋다"가 아니라 "무엇을 사고 무엇을 팔았는가"로 답하는 게 맞습니다. 둘은 같은 계산 모델의 다른 포장입니다 — 둘 다 상태를 fold로 굴리고, 둘 다 단방향입니다.
| ReactorKit | TCA | |
|---|---|---|
| 기반 | RxSwift 스트림 | Swift Concurrency + Observation |
| 상태 전이 | 2단계 — reduce가 순수 δ | 1단계 — Reducer가 상태+Effect 동시 |
| 도입 단위 | 화면 하나부터 (Start Small) | 사실상 앱 전체 재편 |
| 의존성 주입 | 생성자 주입 — 수동 | @Dependency 내장(시계·UUID까지) |
| 테스트 강제력 | 내가 확인한 것만 | TestStore exhaustive |
| 기능 합성 | 직접 설계 | Scope·ifLet·forEach |
정리하면 TCA는 강제력과 부산물을 더 주고, 그 대가로 전면 재편과 러닝커브를 요구합니다. 레거시가 크고 Rx가 이미 깔린 조직에서는 ReactorKit의 "화면 단위 점진 도입"이 실질적으로 더 큰 가치일 수 있습니다.
그리고 정직하게 덧붙일 것 — 지금 신규 프로젝트를 시작한다면 저는 Rx를 먼저 의심할 겁니다. 언어가 동시성을 흡수한 뒤로 Rx가 주던 이점의 상당 부분이 표준에 들어왔으니까요. 기술 선택은 시점의 함수라는 걸 보여 주는 답이 됩니다.
쉽게 이해하기
주방을 생각해 봅시다. MVC/MVVM 주방은 누구나 아무 냄비에 손을 댈 수 있습니다. 요리사 둘일 땐 빠릅니다. 그런데 열 명이 되면 "이 국이 왜 짜지?"의 답을 아무도 모릅니다. 소금 넣은 사람이 여럿이고 기록이 없거든요.
ReactorKit 주방에는 규칙이 둘 있습니다.
- ① 손님 주문은 창구로만(Action). 요리사에게 직접 말할 수 없습니다.
- ② 그리고 여기가 특이한데 — 주문서와 작업 지시서를 나눕니다. "파스타 하나"(Action)가 들어오면 주방장이 "면 삶기 / 소스 데우기 / 접시 담기"라는 지시서 여러 장(Mutation)으로 바꿉니다.
왜 굳이 나눌까요? 장 보러 나가는 일(API 호출)은 주방장만 합니다(mutate). 실제로 냄비를 만지는 사람(reduce)은 지시서만 보고 움직이고, 절대 밖에 나가지 않습니다.
그래서 냄비 담당자는 완벽하게 예측 가능합니다. "이 냄비 상태에 이 지시서를 주면 결과는 항상 이것" — 밖에 나갔다 오지 않으니까요. 이 사람만 따로 떼어 시험(테스트)할 수 있는 이유가 이겁니다.
여기가 CS로 이어지는 지점입니다. 지시서를 순서대로 다 모아 두면, 처음 냄비 상태부터 하나씩 다시 적용해서 지금 상태를 그대로 재현할 수 있습니다. 이걸 접기(fold)라고 하고, 은행이 잔고가 아니라 거래 내역을 정본으로 두는 이유와 정확히 같습니다. 잔고는 언제든 내역에서 다시 계산되니까요.
마지막으로 이 규칙이 공짜가 아니라는 것. 주문서와 지시서를 두 벌 쓰는 종이값이 들고, 주방 전체가 Rx라는 배관 시스템을 알아야 합니다. 요리사가 둘뿐인 주방에 창구를 세우는 건 손해고, 이걸 아는 게 아키텍처를 고를 줄 안다는 뜻입니다.
아키텍처는 기능을 늘리는 도구가 아니라 가능한 일을 줄이는 도구다. ReactorKit이 줄인 것은 "상태를 바꿀 수 있는 자리"와 "부수효과가 들어갈 수 있는 자리" 둘이고, 그 대가는 Rx 의존과 enum 두 벌이다.
설계 근거 · 1차 자료
이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.
- ReactorKit README — Design Goal(Testability · Start Small · Less Typing),
mutate()/reduce()의 역할 분담, Threading 절의 책임 표,@Pulse, 전역 상태를 정의하지 않는다는 방침 - TCA 저장소 — 비교에 쓴
TestStore·@Dependency·Scope의 1차 설명 - Out of the Tar Pit —
reduce를 순수하게 두는 이유의 이론적 배경 - Parnas 1972 — View ↔ Reactor를 나누는 기준이 "변경 가능성 은닉"인 근거