테크
2026. 04. 09
에브리타임 캘린더의 화면 전환기(2): 더 나은 Cleanup을 찾아서
침묵하는 버그보다 정직한 에러를 택한 이유
지난 포스트 에브리타임 캘린더의 화면 전환기: TCA와 UIKit 사이의 '간극'을 메우는 법에서 소개했듯, 에브리타임 캘린더는 UIKit 네비게이션과 TCA를 연결하기 위해 세 가지 패턴을 사용합니다. 그중 Cleanup 패턴은 네비게이션의 선순환을 만드는 가장 핵심적인 고리인데요.
TCA에서는 자식 화면의 상태를 부모 Reducer의 optional state로 관리합니다. 자식 state가 nil에서 값이 생기면 화면이 열리고, 다시 nil로 돌아가면 화면이 닫힌 것으로 간주하죠. 이 사이클이 정상적으로 돌아가려면, ViewController가 네비게이션 스택에서 제거되는 시점에 부모에게 "나 사라졌어"라고 알려줘야 합니다. 부모는 그 신호를 받아 자식 state를 nil로 되돌리고, 다음 push를 준비합니다.

// 부모 Reducer: 자식이 사라졌다는 신호를 받으면 state를 정리합니다
case .calendarEventDetail(.view(.onDisappear)):
state.calendarEventDetailState = nil // 자식 Store와 모든 구독이 자동 정리됩니다문제는 이 ViewController가 스택에서 제거되는 시점을 감지하는 방법이 생각보다 다양하다는 점입니다. 에브리타임 캘린더 개발 과정에서 검토했던 세 가지 접근 방식을 비교해 보고, 언뜻 보면 가장 약점이 많아 보일 수 있는 방식을 최종 선택한 이유를 공유합니다.
setViewControllers: 스택을 통째로 바꾸고 마주친 복병
단순히 사용자가 ‘뒤로 가기’ 버튼을 누르는 상황만 있다면 문제는 간단합니다. 하지만 실제 앱 개발에서는 훨씬 복잡한 상황들이 복병으로 발생하곤 합니다. 에브리타임 캘린더의 일정 투표 기능이 좋은 예시입니다.

캘린더 화면에서 일정 투표 생성을 시작하면 화면이 전환되는데, 이 때는 단순한 push가 아닌 setViewControllers를 사용합니다.
private func pushToMeetingPollCreate() {
let viewController = MeetingPollCreateDateSelectionViewController()
viewController.hidesBottomBarWhenPushed = true
guard let rootViewController = navigationController?.viewControllers.first else {
return
}
navigationController?.setViewControllers(
[rootViewController, viewController], animated: true
)
}이 메서드는 네비게이션 스택을 통째로 교체합니다. 기존 스택이 어떤 모양이었든, 새로운 화면 구성으로 대체해버리는 것이죠.

이렇게 스택을 재구성하면 캘린더 화면은 새 스택에 포함되지 않으므로 UIKit에 의해 자연스럽게 제거됩니다. 사용자가 직접 뒤로 가기 버튼을 누른 것이 아니라, 코드가 스택을 갈아치우는 과정에서 중간에 있던 화면이 빠지게된 상황입니다.
여기서 중요한 점은 캘린더 화면이 TCA Store를 들고 있다는 것입니다. 부모 Reducer는 이 화면의 상태를 optional로 관리하고 있는데, 만약 이 상태가 nil로 돌아가지 않으면 다음에 캘린더를 다시 열 때 “이미 상태가 존재함”으로 간주되어 Push가 정상적으로 동작하지 않습니다. 결과적으로 어떤 방식으로 화면이 제거되든 그 시점을 정확히 감지해서 부모에게 알려줘야만 합니다.
1️⃣ 첫 번째 시도: viewWillDisappear + isMovingFromParent
화면이 제거되는 시점을 잡기 위해 가장 먼저 떠올릴 수 있는 방법은 viewWillDisappear입니다. 하지만 이 메서드는 우리가 원하는 것보다 훨씬 넓은 범위에서 호출된다는 단점이 있습니다. 네비게이션 스택에서 완전히 빠질 때뿐만 아니라, 다른 화면이 위에 쌓이거나 모달이 뜰 때, 심지어 앱이 백그라운드로 내려갈 때도 호출되기 때문입니다.
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isMovingFromParent {
store.send(.view(.onDisappear))
}
}이 조합은 setViewControllers에 의해 스택에서 밀려나는 경우와 사용자가 직접 ‘뒤로 가기’를 누르는 경우 모두에 정상적으로 대응합니다. 전체적인 흐름을 정리하면 다음과 같습니다.

기능 자체는 문제없이 동작합니다. 하지만 코드를 가만히 들여다보면 약간의 아쉬움이 남습니다. 우리가 진짜 알고 싶은 건 "이 ViewController가 네비게이션 스택에서 제거되었는가?"단 하나입니다. 그런데 이를 확인하기 위해 화면이 사라지고 있는가?(viewWillDisappear)"와 "부모에게서 떨어져 나가는 중인가?(isMovingFromParent)"라는 두 가지 조건을 우회적으로 조합하고 있습니다. 목적은 하나인데, 코드는 불필요하게 두 단계를 거치고 있는 셈입니다.
우리의 의도를 코드에 조금 더 직접적으로 표현할 수는 없을까요? 이 간접적인 방식의 한계를 넘어서 조금 더 근본적인 해결책을 찾기 위해, 우리는 두 가지 대안을 추가로 검토했습니다.
2️⃣ 두 번째 시도: deinit — 가장 직관적인 정답
deinit {
store.send(.view(.onDisappear))
}ViewController가 메모리에서 완전히 해제되는 순간을 포착해 상태를 정리하는 방법입니다. 코드를 보면 의도가 한눈에 읽힙니다. "이 화면(객체)이 사라지면 상태도 정리한다." 복잡한 조건 분기도, 라이프사이클 메서드를 이리저리 조합할 필요도 없습니다.
이 방식이 어떻게 모든 예외 상황을 덮을 수 있는지 ARC(Automatic Reference Counting) 관점에서 살펴보겠습니다. ViewController가 네비게이션 스택에 머무는 동안, UINavigationController는 이 VC를 강한 참조(Strong Reference)로 쥐고 있습니다. 화면이 스택에서 제거되면 이 참조가 끊어지고, 다른 곳에서 붙잡고 있지 않는 한 ARC에 의해 메모리에서 해제됩니다. 그리고 이 해제되는 찰나에 deinit이 호출됩니다.

그렇다면 단순히 모달이 뜨거나 다른 화면이 그 위에 덮이는(Push) 경우는 어떨까요? 이때 ViewController는 여전히 네비게이션 스택에 존재하므로, UINavigationController가 참조를 유지합니다. 자연스럽게 참조 카운트(Reference Count)는 0이 되지 않고 deinit도 호출되지 않습니다. 앞서 살펴본 isMovingFromParent가 하던 필터링 역할을 ARC가 자동으로 대신해 주는 셈입니다.
앞서 본 패턴과 나란히 놓고 보면 그 차이가 더욱 명확해집니다.
// Before: 두 조건의 조합으로 의도를 간접 표현
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isMovingFromParent {
store.send(.view(.onDisappear))
}
}
// After: 의도를 직접 표현
deinit {
store.send(.view(.onDisappear))
}setViewControllers로 스택이 통째로 갈아 끼워지든, 사용자가 얌전히 뒤로 가기를 누르든, 화면이 제거되는 방식과 무관하게 동일한 규칙이 적용됩니다. 코드는 의도를 투명하게 드러내고, 동작 역시 완벽해 보입니다.
하지만 deinit에는 아주 치명적인 전제 조건이 하나 숨어 있습니다. 바로 'ARC가 참조 카운트를 0으로 만들어야만 호출된다'는 점입니다. 이를 뒤집어 말하면, 참조 카운트가 0이 되지 않으면 영영 호출되지 않는다는 뜻이기도 합니다.
⚠️ deinit의 치명적 약점: 순환 참조(Retain Cycle)
Combine 구독이나 클로저 내부에서 실수로 강한 참조를 만들어버리면, ViewController가 스택에서 빠져나와도 메모리에서는 해제되지 않는 문제가 발생합니다.
// ⚠️ 잘못된 코드: [weak self]가 빠져 있습니다
store.publisher.someValue
.sink { value in
self.updateSomething(value) // self를 강하게 캡처
}
.store(in: &cancellables)위 코드에서 Combine 구독은 cancellables에 저장되고, 이 cancellables는 ViewController의 프로퍼티입니다. 그런데 구독이 클로저 내부에서
self를 강하게 캡처하고 있어, 결국 [ViewController → cancellables → 구독 → ViewController]로 이어지는 순환 참조(Retain Cycle)가 형성됩니다.

이 상태가 되면 스택에서 제거되어 UINavigationController의 참조가 끊어지더라도, 자기들끼리의 순환 참조가 남아있어 참조 카운트는 절대 0이 되지 않습니다. 결과적으로 deinit은 영영 호출되지 않고, 부모 Reducer에게 “나 사라졌어”라고 알리는 Cleanup도 실패하고 맙니다.
올바른 코드는 다음과 같습니다.
// ✅ 올바른 코드: [weak self]로 순환 참조를 끊어줍니다
store.publisher.someValue
.sink { [weak self] value in
self?.updateSomething(value)
}
.store(in: &cancellables)이 방식의 약점은 너무나도 명확합니다. 개발자의 사소한 실수 하나가 단순한 메모리 누수를 넘어, 화면의 상태 초기화 로직 전체를 망가뜨릴 수 있기 때문입니다.
다행히 이 약점을 효과적으로 보완하는 대안이 있습니다.
3️⃣ 세 번째 시도: didMove(toParent: nil) — 순환 참조 발생 시 안전한 대안
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if parent == nil {
store.send(.view(.onDisappear))
}
}세 번째로 검토한 접근 방식은 didMove(toParent:)입니다. 이 메서드는 UIKit의 Container ViewController 아키텍처에서 제공합니다. 일반적으로 UIKit에서 자식 ViewController를 다룰 때, addChild → didMove(toParent: self) 쌍으로 추가하고, willMove(toParent: nil) → removeFromParent로 제거하는 패턴을 따르는데, 바로 이 프로토콜의 일부입니다.
재미있는 점은 에브리타임 캘린더 코드에서도 이미 이 메서드를 아주 친숙하게 사용하고 있다는 것입니다. SwiftUI View를 UIHostingController로 화면에 임베딩할 때 명시적으로 호출하는 바로 그 메서드죠.
private func setupView() {
let page = CalendarContentsPage(store: store)
let hostingController = UIHostingController(rootView: page)
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.didMove(toParent: self) // 👈 이미 쓰고 있는 메서드입니다
}UINavigationController도 Container ViewController의 일종입니다. 따라서 네비게이션 스택에서 ViewController가 제거되면, UIKit이 내부적으로 알아서 didMove(toParent: nil)을 호출해 줍니다.
이 접근 방식의 가장 강력한 장점은 ARC의 동작과 무관하다는 것입니다. 실수로 순환 참조가 발생하든 안 하든, 네비게이션 스택에서 밀려나는 즉시 동기적으로 상태가 정리됩니다.
앞서 살펴본 두 번째 방식(deinit)의 치명적인 약점을 정면으로 보완하는 훌륭한 선택지입니다. 메모리 누수는 발생할지언정, 최소한 앱의 상태 초기화 로직 전체가 멈추는 대참사는 막을 수 있으니까요. 훨씬 안전하고, 동작 시점도 예측 가능합니다.
그렇다면, didMove가 정답일까요?
세 가지 방식은 어디서 운명이 갈렸는가
이 질문에 답하기 위해, 세 가지 방식을 표 비교해 보겠습니다. 보시다시피, 핵심 동작은 놀라울 정도로 비슷합니다.

세 방법 모두 "스택에서 제거되면 반응하고, 모달에는 반응하지 않는다"는 핵심 동작은 완벽히 수행합니다.
차이가 나는 곳은 딱 한 군데, 순환 참조가 발생했을 때뿐입니다. 여기서 deinit만 유일하게 Cleanup에 실패합니다. 언뜻 보면 deinit이 가장 약한 선택지처럼 보입니다. 순환 참조에 취약하고, 호출 시점도 "메모리 해제 시"로 가장 늦으니까요. 반면 didMove(toParent: nil)은 모든 면에서 완벽해 보입니다.
그런데도 우리는 과감히 deinit을 선택했습니다. 이유가 뭘까요?
문제를 숨기는 코드 VS 문제를 드러내는 코드
순환 참조가 발생했을 때 deinit이 호출되지 않아 상태 초기화가 실패한다는 것. 저희는 이것을 ‘약점’이 아니라 ‘기능’이라고 판단했습니다.
만약 deinit을 사용 중인데 순환 참조가 발생했다고 가정해 봅시다. Cleanup이 실패하면 부모의 상태(State)가 nil로 돌아가지 않습니다. 그 결과, 사용자가 다음에 같은 화면을 열려고 할 때 Push 동작이 먹통이 됩니다.
개발자는 즉시 이상을 감지합니다. "어? 왜 화면이 안 열리지? 상태가 정리가 안 됐네?" 그리고 디버깅 과정을 거쳐 순환 참조를 발견하고, 즉각 근본 원인을 수정하게 됩니다.
반면 didMove(toParent:)를 사용하고 있다면 어떨까요? 순환 참조가 심각하게 발생해 있어도 Cleanup은 정상적으로 동작합니다. 상태는 깔끔하게 nil로 돌아가고, 화면 전환도 문제없이 부드럽게 이루어집니다. 겉으로는 모든 것이 완벽하게 돌아가는 것처럼 보입니다.
하지만 그 이면에서 ViewController는 메모리에서 해제되지 않은 채 계속 살아 숨 쉬고 있습니다. 사용자가 해당 화면을 열고 닫을 때마다, 해제되지 않는 인스턴스가 메모리에 하나씩 차곡차곡 쌓입니다. 이 끔찍한 메모리 누수는 단위 테스트에서도 잡히지 않습니다. 코드 리뷰에서도 쉽게 눈에 띄지 않습니다. 앱이 한참 동안 실행된 뒤 메모리 경고(Memory Warning)가 뜨거나, 최악의 경우 사용자의 앱이 OOM(Out Of Memory)으로 강제 종료될 때까지 아무도 이 문제를 알아차리지 못할 수 있습니다.
didMove는 문제가 있어도 조용히 덮고 넘어갑니다.deinit은 문제를 숨기지 않고 격렬하게 드러냅니다.
더 안전해 보이는 코드와, 문제를 즉각적으로 드러내는 코드. 저희는 주저 없이 후자를 택했습니다. 코드가 오류를 품은 채 조용히 동작하는 것보다, 문제가 있을 때 차라리 개발자에게 요란하게 알려주는 쪽이 장기적으로 훨씬 더 건강하고 견고한 코드베이스를 만든다고 믿기 때문입니다.
-
Written by 황영수 | 비누랩스 소프트웨어 엔지니어
방법을 찾고 결과를 만드는 iOS 개발자입니다.
-
비누팀과 함께 더 나은 미래를 만들어 나갈 동료를 기다립니다.
대학생들이 마주하는 일상의 문제를 정의하고 해결하며, 함께 캠퍼스 라이프의 기준을 바꿔나갈 분을 찾고 있습니다. 저희와 함께 즐거운 변화를 경험하고 싶다면 망설임 없이 지원해주세요!