테크
2026. 03. 05
에브리타임 캘린더의 화면 전환기: TCA와 UIKit 사이의 '간극'을 메우는 법
에브리타임 캘린더가 화면을 전환하는 3가지 패턴
지난 24년 7월에 발행한 포스트 <13살 에브리타임 앱에 SwiftUI 적용하기>에서 2011년에 출시된 에브리타임 iOS 앱이 어떻게 레거시 기반구조를 딛고 SwiftUI와 TCA를 도입하게 되었는지를 소개했는데요. UIViewController가 컨테이너 역할을 하고, 그 안에서 SwiftUI View를 호스팅하는 구조였죠.
그런데 이 구조에서 작은 간극이 하나 있습니다. TCA는 가능한 한 모든 것을 상태로 관리하려고 합니다. 반면 UIKit의 네비게이션은 pushViewController, popViewController 처럼 명령형 호출로 움직입니다. 즉, “상태를 바꾸면 UI가 따라온다”는 TCA의 철학과, “직접 호출해야 화면이 전환된다”는 UIKit의 방식 사이에서 어딘가 접점을 찾아야 했습니다.
에브리타임 캘린더는 이 간극을 메우기 위해 세 가지 패턴을 사용하고 있습니다. 이 글에서는 캘린더의 화면 전환 구조를 소개하고, 그 과정에서 고민했던 지점들과 더 나은 방법을 찾기 위한 시도들을 함께 공유해보려 합니다.
에브리타임 캘린더의 화면 구조
25년 10월에 첫 선을 보인 에브리타임 캘린더의 모듈은 아래와 같은 5개의 ViewController로 구성되어 있습니다.
TimetableBackgroundViewController (루트, 컨테이너)
├─ CalendarSettingViewController (캘린더 설정)
└─ CalendarEventDetailViewController (일정 상세)
└─ CalendarEventAddEditViewController (일정 편집)
└─ CalendarEventRecurrenceSelectViewController (반복 규칙 선택)
└─ CalendarEventRecurrenceCustomViewController (커스텀 반복)통일된 네비게이션 구조를 유지하기 위해, 이 5개의 ViewController는 동일한 패턴으로 구현했죠. 각 ViewController는 TCA Store를 보유하고 있고, SwiftUI View를 UIHostingController로 임베딩합니다. UI 렌더링은 SwiftUI가 맡고, 화면 전환과 같은 네비게이션은 UIKit이 처리합니다.
예를 들어, CalendarEventDetailViewController는 CalendarEventDetailPage라는 SwiftUI View를 아래와 같이 호스팅합니다.
final class CalendarEventDetailViewController: UIViewController {
private let store: StoreOf<CalendarEventDetailReducer>
private var cancellables: Set<AnyCancellable> = .init()
private func setupView() {
let page = CalendarEventDetailPage(store: store)
let hostingController = UIHostingController(rootView: page)
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)
}
}문제는 이 ViewController들 사이의 Push, Pop, 그리고 정리 작업을 TCA의 상태와 어떻게 동기화할 것인가입니다.
TCA의 핵심 원칙 중 하나는 관심사의 분리입니다. Reducer는 상태를 변경하는 역할만 담당하고, viewController는 UIKit 동작을 실행하는 일만 담당해야 합니다. 만약 Reducer 안에서 pushViewController를 직접 호출하거나, ViewController에서 비즈니스 로직을 처리하기 시작하면, 흐름 추적이 어려워지고 단위 테스트가 힘들어지며 책임이 뒤섞이게 됩니다. 이 구조를 도식화해보겠습니다.

Reducer는 상태만 다루고, ViewController는 Action을 전달하고 UIKit 동작을 실행합니다. 상태가 바뀌면 Combine의 publisher나 ifLet를 통해 그 변화가 전달되고, ViewController는 그 신호를 받아 UINavigationController로 push나 pop, Alert 같은 화면 전환을 처리합니다.
이 구조를 유지하면서도 자연스럽게 네비게이션을 연결하기 위해 사용한 패턴은 총 세 가지입니다.
패턴 1️⃣ Push | 상태가 생기면 화면이 열린다
TCA에서는 자식 화면을 부모 Reducer의 optional state로 표현합니다. 예를 들어 CalendarReducer는 자식인 CalendarEventDetailReducer의 State를 optional로 가지고 있습니다.
// CalendarReducer.State
var calendarEventDetailState: CalendarEventDetailReducer.State?
// ...
case .onTapEvent(let event):
state.calendarEventDetailState = CalendarEventDetailReducer.State(event: event)이 값의 기본 상태는 nil이며, 사용자가 일정을 탭하는 순간 해당 state가 생성됩니다. 여기서 중요한 점은 Reducer가 “이 화면을 열어라”라고 직접 명령하지 않는다는 것입니다. 대신 “이 화면에 필요한 데이터가 준비되었다”라는 사실을 상태로 선언합니다. 화면 전환은 그 선언에 대한 결과로 따라옵니다.
부모 ViewController는 Combine을 통해 이 state 변화를 구독하고 있습니다. 그리고 calendarEventDetailState가 nil에서 값이 있는 상태로 바뀌는 순간을 감지해 화면을 push합니다.
calendarStore
.scope(state: \.calendarEventDetailState, action: \.calendarEventDetail)
.ifLet(
// TCA Store의 ifLet은 nil → non-nil이 될때만 then 클로저를 실행합니다
then: { [weak self] childStore in
let vc = CalendarEventDetailViewController(childStore)
vc.hidesBottomBarWhenPushed = true
self?.navigationController?.pushViewController(vc, animated: true)
}
)
.store(in: &cancellables)여기서 TCA Store의 ifLet은 nil → non-nil로 변하는 시점에만 then 클로저를 실행한다는 점이 중요합니다. 즉, 부모가 자식 state를 생성하는 순간에만 push가 발생하고, 이미 값이 존재하는 상태에서는 다시 실행되지 않습니다.
결국 이 구조에서는 네비게이션이 명령이 아니라 상태 변화의 결과로 동작합니다. 부모가 자식 state를 만들면, ViewController가 그 변화를 감지해 화면을 전환합니다. 이 프로젝트의 다섯 개 화면 전환은 모두 이 동일한 패턴을 따르고 있습니다.
패턴 2️⃣ Cleanup | 화면이 사라지면 상태를 정리한다
Push는 state가 nil → non-nil로 바뀌는 순간에 발생합니다. 그렇다면 다음 Push가 정상적으로 동작하려면, 이 state를 다시 nil로 되돌려야 합니다. 이 정리를 언제, 어떻게 하느냐가 바로 Cleanup 패턴의 핵심입니다.
현재 캘린더의 모든 ViewController는 isMovingFromParent를 활용해 이 시점을 감지하고 있습니다.
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isMovingFromParent { // 네비게이션 스택에서 제거되는 중일 때만 true
store.send(.view(.onDisappear))
}
}isMovingFromParent는 해당 ViewController가 네비게이션 스택에서 실제로 제거되는 순간에만 true가 됩니다. 단순히 다른 화면이 위에 push되거나 모달이 표시되는 경우에는 false이기 때문에, “이 화면이 진짜로 닫히는 시점”만 정확하게 잡아낼 수 있습니다.
이때 ViewController는 .onDisappear 액션을 Store로 전달하고, 부모 Reducer는 이를 받아 자식 state를 nil로 되돌립니다.
case .calendarEventDetail(.view(.onDisappear)):
state.calendarEventDetailState = nil // 자식 Store와 모든 구독이 자동 정리됩니다Push-Cleanup 사이클을 정리하면 다음과 같습니다.




패턴 3️⃣ Side Effect | 상태가 바뀌면 UIKit이 반응한다
Push는 상태 변화로 자연스럽게 표현되지만, Pop은 다릅니다. popViewController는 UIKit의 명령적 호출이기 때문입니다. 그렇다면 이 명령을 TCA 안에서는 어떻게 트리거할 수 있을까요?
이를 위해 ViewControllerActionState라는 컨벤션을 정의했습니다. Reducer가 ViewController에게 전달하는 일종의 리모컨이라고 생각하면 이해하기 쉽습니다. Reducer가 리모컨의 버튼을 누르면, ViewController는 그에 대응하는 UIKit 동작을 실행합니다.
화면마다 필요한 동작이 다르기 때문에, 각 Reducer의 State에는 해당 화면 전용 ViewControllerActionState를 포함시킵니다.
// CalendarEventDetailReducer.State
var viewControllerActionState: ViewControllerActionState?
enum ViewControllerActionState: Equatable {
case pop // 화면 닫기
case presentDeleteAlert // 삭제 확인 Alert
case presentActionSheet // 선택지 ActionSheet
case presentSuccessToast // 성공 Toast
case presentErrorToast(String) // 에러 Toast
}Reducer는 비즈니스 로직의 결과로 이 값을 세팅합니다.
// CalendarEventDetailReducer
case .onTapBackButton:
state.viewControllerActionState = .pop
case .handleDeleteEventSuccess:
state.viewControllerActionState = .presentSuccessToast
case .handleDeleteEventFailure(let message):
state.viewControllerActionState = .presentErrorToast(message)ViewController는 이 state를 Combine으로 관찰하다가 값이 설정되면, 그에 맞는 UIKit 동작을 수행합니다.
store.publisher.viewControllerActionState
.compactMap { $0 }
.sink { [weak self] actionState in
guard let self else { return }
// 1. State에 따라 UIKit 명령 실행
switch actionState {
case .pop:
self.navigationController?.popViewController(animated: true)
case .presentDeleteAlert:
self.presentDeleteAlert()
case .presentActionSheet:
self.presentActionSheet()
case .presentSuccessToast:
self.presentToast(message: "삭제되었습니다", isSuccess: true)
case .presentErrorToast(let message):
self.presentToast(message: message, isSuccess: false)
}
// 2. 실행 후 반드시 State를 초기화
store.send(.delegate(.resetViewControllerActionState))
}
.store(in: &cancellables)여기서 resetViewControllerActionState는 반드시 필요합니다. 이 초기화가 빠지면, 다음에 동일한 액션(예: 삭제 성공)이 발생하더라도 State 값이 변하지 않은 것으로 간주됩니다. Equatable 비교에 의해 Combine이 새로운 이벤트로 인식하지 않기 때문입니다. 그 결과 화면이 아무 반응도 하지 않는 상황이 발생합니다. 따라서 “실행 후 초기화”는 반드시 한 세트로 동작해야 합니다.
이 패턴의 장점은 Pop뿐 아니라 Alert, Toast, ActionSheet처럼 UIKit 고유의 명령적 동작을 모두 동일한 방식으로 처리할 수 있다는 점입니다. Reducer는 상태만 변경하고, ViewController는 그 상태에 반응해 UIKit 동작을 수행하는 역할 분리가 유지됩니다.
이 구조는 동작을 체이닝하는 데에도 자연스럽게 확장됩니다. 예를 들어 삭제 성공 후 토스트를 먼저 보여주고, 토스트가 사라지면 화면을 닫아야 하는 경우를 생각해볼 수 있습니다.
// Reducer
case .handleDeleteEventSuccess:
state.viewControllerActionState = .presentSuccessToast // 1. 먼저 토스트
case .onDismissSuccessToast:
state.viewControllerActionState = .pop // 2. 토스트 닫히면 Pop// ViewController
private func presentToast(message: String, isSuccess: Bool) {
SimpleToast.show(withVC: self, text: message, onDismiss: { [weak self] in
if isSuccess {
// 토스트가 사라지면 Reducer에게 알림
self?.store.send(.delegate(.onDismissSuccessToast))
}
})
}이처럼 Reducer와 ViewController는 상태를 매개로 서로 신호를 주고받습니다. Reducer는 “무엇을 해야 하는지”를 상태로 선언하고, ViewController는 이를 해석해 실제 UIKit 동작을 수행합니다.
이 흐름을 다이어그램으로 정리해보면, 두 계층 사이의 역할 분리가 더욱 명확하게 드러납니다.


정리하면, TCA 기반의 에브리타임 iOS앱에서 UIKit 네비게이션을 다루기 위해 세 가지 패턴을 사용하고 있습니다.
1️⃣ Push — store.scope.ifLet으로 optional state의 변화를 감지해 push를 트리거합니다.
2️⃣ Cleanup — ViewController가 사라질 때 부모의 state를 nil로 되돌려 네비게이션 사이클을 완성합니다.
3️⃣ Side Effect — ViewControllerActionState라는 리모컨을 통해, Reducer가 선언하고 ViewController가 실행하는 구조를 만듭니다.
이 세 가지 패턴은 TCA의 상태 관리 방식을 유지하면서도, UIKit의 명령형 네비게이션을 안정적으로 활용하기 위한 연결 고리입니다. SwiftUI로 UI를 빠르게 구성하고, 네비게이션은 UIKit에 맡기되, 그 사이를 TCA의 상태가 이주는 방식입니다.
각 기술 스택의 장점을 유지하면서도 역할을 분리하고 싶다면, 이 구조가 하나의 현실적인 선택지가 될 수 있을 것입니다. 비슷한 고민을 하고 있는 분들께 이 글이 작은 참고가 되기를 바랍니다.
-
Written by 황영수 | 비누랩스 소프트웨어 엔지니어
방법을 찾고 결과를 만드는 iOS 개발자입니다.