- UIViewController 생명주기를 순서대로 말해 보세요.
viewDidLoad와viewWillAppear에는 각각 무엇을 넣나요? 왜죠?- 이 콜백들은 누가 언제 호출하나요?
viewIsAppearing(iOS 17, iOS 13까지 백디플로이)이 빈칸을 채웠다30초 답변 🔥 먼저 이 문장
viewDidLoad에서는 뷰가 존재하지만 크기는 최종값이 아닙니다.viewWillAppear는 여러 번 불립니다 — 뒤로 갔다 오면 또 불립니다.viewDidLayoutSubviews도 여러 번 불리므로 멱등해야 합니다.그래서 "1회성 초기화 / 매번 갱신 / 크기 확정 후 보정" 세 종류로 나눠서 배치합니다. 크기가 확정된 상태로 등장 준비를 하려면 iOS 17의
viewIsAppearing이 정답이고, iOS 13까지 백디플로이되어 실무에서 바로 쓸 수 있습니다.
L1개념 — 순서와 확정 상태
| 시점 | 여기에 넣는 것 | 넣으면 안 되는 것 |
|---|---|---|
viewDidLoad | 1회성 설정 — 서브뷰 추가, 제약, delegate, 바인딩, 등록 | view.bounds 기반 계산 (아직 최종 크기 아님) |
viewWillAppear | 매번 갱신 — 데이터 새로고침, 네비바 상태, 옵저버 등록 | 1회성 초기화(중복 실행), 무거운 동기 작업 |
viewIsAppearing | 크기 의존 준비 — 스크롤 위치 복원, 셀 선택 해제, 초기 스크롤 | — |
viewDidLayoutSubviews | 프레임 확정 후 보정 — 그라디언트 frame, 코너 반경 | 비멱등 작업 — 서브뷰 추가, 카운터 증가 |
viewDidAppear | 애니메이션 시작, 분석 이벤트, 카메라/센서 시작 | 표시 전에 끝나야 할 준비 (깜빡임) |
viewWillDisappearviewDidDisappear | 옵저버 해제, 타이머 중지, 상태 저장 | — |
L2설계 — 코드로 보는 배치와 함정
final class FeedViewController: UIViewController {
private let tableView = UITableView()
private let gradientLayer = CAGradientLayer()
private var didRestoreScroll = false
// ───── 1회성: 딱 한 번만 해야 하는 것 ─────
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(tableView)
tableView.dataSource = self
tableView.register(FeedCell.self, forCellReuseIdentifier: "cell")
view.layer.insertSublayer(gradientLayer, at: 0)
bindViewModel()
// ❌ 여기서 tableView.frame = view.bounds 하면 안 된다 — bounds가 아직 임시값
}
// ───── 매번: 돌아올 때마다 갱신 ─────
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)
viewModel.refresh()
NotificationCenter.default.addObserver(
self, selector: #selector(didUpdate),
name: .feedUpdated, object: nil)
}
// ───── 크기 확정 후, 화면에 보이기 전 ─────
override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
// 여기서는 bounds·safeAreaInsets·traitCollection 이 정확하다
if !didRestoreScroll {
didRestoreScroll = true
tableView.setContentOffset(savedOffset, animated: false)
}
// 셀 선택 해제도 여기가 자연스럽다 (전환 애니메이션과 함께 보인다)
if let ip = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: ip, animated: animated)
}
}
// ───── 여러 번 불린다 → 반드시 멱등하게 ─────
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
tableView.frame = view.bounds // ✅ 대입이라 몇 번 해도 같다
gradientLayer.frame = view.bounds // ✅ 멱등
// ❌ view.addSubview(banner) ← 부를 때마다 배너가 쌓인다
// ❌ count += 1 ← 부를 때마다 증가한다
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
NotificationCenter.default.removeObserver(self, name: .feedUpdated, object: nil)
savedOffset = tableView.contentOffset
}
deinit { print("✅ FeedViewController deinit") } // 릭 확인용
}// ① viewWillAppear에 1회성 초기화 → 뒤로 갔다 오면 중복
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
sections.append(headerSection) // 💥 돌아올 때마다 헤더가 늘어난다
}
// ② viewDidLoad에서 bounds로 원형 마스크 → 회전·사이즈 변경 후 깨짐
override func viewDidLoad() {
super.viewDidLoad()
avatar.layer.cornerRadius = avatar.bounds.width / 2 // 💥 bounds가 아직 임시
}
// ✅ 크기 확정 후에
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
avatar.layer.cornerRadius = avatar.bounds.width / 2
}
// ③ viewDidLayoutSubviews에서 레이아웃 무효화 → 무한 루프
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
heightConstraint.constant = label.frame.height // 💥 다시 레이아웃 유발
view.layoutIfNeeded() // 💥 재귀
}
// ④ loadView에서 super 호출 — 직접 뷰를 만들 때는 부르면 안 된다
override func loadView() {
// super.loadView() // ❌ 스토리보드/nib 로드가 내 뷰를 덮어쓴다
view = CustomRootView() // ✅ 직접 대입
}
// ⑤ deinit이 안 불림 → 순환 참조 (09번 주제)
// 생명주기 디버깅의 1순위 체크는 언제나 deinit 로그다화면 가장자리를 스와이프해서 뒤로 가다가 중간에 놓아서 취소하면, 콜백이 이렇게 옵니다.
viewWillDisappear // 스와이프 시작 — "사라질 거야"
↓ (사용자가 놓아서 취소됨)
viewWillAppear // "다시 나타날 거야"
viewIsAppearing
viewDidAppear
// 💥 문제: viewWillDisappear 에서 리소스를 해제해 버렸는데
// viewDidDisappear 는 오지 않았다.
// ✅ 방어: transitionCoordinator 로 취소 여부를 확인한다
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
transitionCoordinator?.notifyWhenInteractionChanges { ctx in
if ctx.isCancelled {
self.restoreState() // 취소됐으면 되돌린다
}
}
}
// ✅ 더 안전한 규칙: "해제"는 viewDidDisappear 에서,
// "일시 중지"만 viewWillDisappear 에서UIView 쪽 생명주기
final class BadgeView: UIView {
// ① 생성
override init(frame: CGRect) { super.init(frame: frame); setup() }
required init?(coder: NSCoder) { super.init(coder: coder); setup() }
// ② 슈퍼뷰에 붙기 직전 / 직후
override func willMove(toSuperview newSuperview: UIView?) { }
override func didMoveToSuperview() { }
// ③ 윈도우에 붙기 — "화면에 실제로 올라간" 시점
override func didMoveToWindow() {
if window != nil { startAnimating() } // 화면에 붙었다
else { stopAnimating() } // 떨어졌다 → 타이머 정리
}
// ④ 레이아웃 — 여러 번 불린다. 멱등해야 한다. 직접 호출 금지
override func layoutSubviews() {
super.layoutSubviews()
dot.frame = CGRect(x: bounds.maxX - 8, y: 0, width: 8, height: 8)
}
// ⑤ 그리기 — 구현하면 백킹스토어가 생긴다. 필요 없으면 구현하지 말 것
// override func draw(_ rect: CGRect) { }
// ⑥ 크기 협상에 참여
override var intrinsicContentSize: CGSize { CGSize(width: 24, height: 24) }
}didMoveToWindow가 실무에서 유용한 이유
셀 안의 애니메이션이나 타이머를 화면에 붙어 있을 때만 돌리고 싶을 때 가장 정확한 지점입니다.
window == nil이면 화면에서 떨어진 것이므로 여기서 정리하면, 재사용 풀에 들어간 셀이 타이머를 계속 돌리는 문제가 사라집니다(11번과 연결).
L3구현 — 이 콜백들은 누가 언제 부르는가
생명주기 콜백은 공중에서 오지 않습니다. 전부 메인 스레드의 RunLoop 한 바퀴 안에서 불립니다. 이 그림을 알면 12번(setNeedsLayout)과 11번(프레임 예산)이 같은 그림의 다른 부분이라는 게 보입니다.
layoutSubviews, cellForRowAt이 전부 이 한 바퀴 안에서 불린다. 어느 하나가 오래 걸리면 루프가 다음 바퀴를 못 돌고 프레임을 놓친다.// ❌ 스크롤하면 타이머가 멈춘다
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in tick() }
// scheduledTimer 는 .default 모드에만 등록된다.
// 스크롤 중에는 RunLoop가 .tracking 모드로 전환되므로 타이머가 안 돈다.
// ✅ .common 에 추가하면 스크롤 중에도 돈다
let timer = Timer(timeInterval: 1, repeats: true) { _ in tick() }
RunLoop.main.add(timer, forMode: .common)
// 모드는 "지금 이 루프가 어떤 소스들을 처리할지"의 필터다.
//
// ★ 이게 왜 생명주기 이야기인가:
// viewWillAppear 에서 건 타이머가 "스크롤하면 멈춘다"는 제보로 돌아오는 일이
// 흔하다. 원인은 생명주기 배치가 아니라 이 루프의 모드다.
// 즉 "언제 호출되나"만이 아니라 "그 루프가 지금 무엇을 처리 중인가"까지
// 봐야 콜백 기반 코드의 동작이 설명된다.print("A")
DispatchQueue.main.async { print("C") } // 지금 실행 중인 작업이 끝난 뒤
print("B")
// 출력: A B C
// 왜? main.async 는 "메인 큐에 넣는 것"이고,
// 메인 큐는 현재 실행 중인 블록이 끝나야 다음 블록을 꺼낸다.
// → 그래서 "다음 RunLoop 반복에서" 라고 흔히 말하지만,
// 정확히는 "현재 작업이 반환된 뒤 메인 큐의 다음 차례에서" 이다.
// 실무 활용: 레이아웃이 끝난 뒤에 뭔가 하고 싶을 때
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
DispatchQueue.main.async { // 이번 레이아웃 패스가 끝난 뒤
self.scrollToBottom() // 재귀 무효화를 피한다
}
}L4CS 근본 — 이벤트 루프와 제어의 역전
① 앱은 "실행되는 프로그램"이 아니라 "이벤트를 기다리는 루프"다
// 배치 프로그램 (전통적 제어 흐름)
func main() {
let input = readInput()
let result = process(input)
print(result)
} // 내가 흐름을 통제한다. 끝나면 종료.
// GUI 앱 (이벤트 주도)
func main() {
setup()
while true { // ← UIApplicationMain 이 이 루프를 돈다
let event = waitForEvent() // ← 이벤트가 없으면 여기서 잠든다
dispatch(event) // ← 등록된 콜백을 부른다
layoutIfNeeded()
renderIfNeeded()
}
}
// 내가 흐름을 통제하지 않는다. 프레임워크가 나를 부른다.
// → 이것이 제어의 역전(Inversion of Control)이고,
// "생명주기 콜백"이 존재하는 이유다.
"할리우드 원칙(Hollywood Principle) — 우리에게 전화하지 마세요, 우리가 전화하겠습니다."
viewDidLoad를 내가 부르지 않고 UIKit이 부르는 게 이상해 보인다면, 그건 제어권이 프레임워크에 있기 때문입니다.
② "잠든다"는 것의 정확한 의미 — 폴링이 아니라 블로킹
while true라고 쓰면 CPU를 100% 태울 것 같지만, 실제로는 앱이 대기 중일 때 CPU 사용률이 0%입니다. 이유는 바쁜 대기(busy waiting)가 아니라 블로킹이기 때문입니다.
| 폴링 (busy wait) | 블로킹 (RunLoop가 하는 것) | |
|---|---|---|
| 동작 | while !hasEvent { } 계속 확인 | 커널에게 "이벤트 오면 깨워 줘"라고 하고 잠듦 |
| CPU | 100% 점유 | 0% — 스레드가 대기 큐로 내려간다 |
| 배터리 | 급속 소모 | 거의 소모 없음 |
| 구현 | — | Darwin에서는 mach 포트에서 mach_msg 블로킹 |
CFRunLoop의 실제 구현은 mach 포트 집합에 대해 mach_msg로 블로킹합니다. 터치 이벤트, 타이머 만료, 네트워크 완료 같은 것들이 커널을 통해 그 포트로 메시지를 보내면 스레드가 깨어납니다.
이게 인터럽트 기반 I/O의 사용자 공간 버전입니다 — 하드웨어가 인터럽트로 CPU를 깨우듯, 커널이 메시지로 스레드를 깨웁니다.
한 층 더 내려가면 mach_msg는 시스템 콜입니다. 앱 코드는 특권이 없어서 스스로 잠들 수 없습니다 — 스레드를 대기 큐에 넣는 건 스케줄러의 일이고, 스케줄러는 커널에 있으니까요.
그래서 반드시 커널로 넘어가야 하고, 그 통로가 trap입니다.
앱 코드 (EL0, 특권 없음)
│ CFRunLoopRun → __CFRunLoopServiceMachPort → mach_msg
│
▼ SVC 명령 (Supervisor Call) — 하드웨어가 특권 수준을 올린다
XNU 커널 (EL1)
│ · 이 스레드를 실행 큐에서 빼서 포트의 대기 큐로 옮긴다
│ · 다른 스레드를 스케줄한다 ← 그래서 CPU 사용률이 0%가 된다
│
▼ (터치·타이머·네트워크 완료가 그 포트로 메시지를 보내면)
│ · 대기 큐에서 스레드를 꺼내 다시 실행 큐로
▼ ERET — 특권 수준을 내리고 앱으로 복귀
앱 코드 (EL0) — mach_msg 가 반환되고, RunLoop가 이벤트를 처리한다
// ARM64는 EL0(앱) / EL1(커널) / EL2(하이퍼바이저) / EL3(보안 모니터)로 나뉜다.
// "앱이 잠든다"는 표현이 실제로는 이 왕복 한 번이다.
//
// ⚠️ 정밀하게: mach_msg 는 BSD 시스템 콜이 아니라 Mach trap 이다.
// Darwin은 두 계열의 트랩을 갖는다 — BSD syscall(read/write 등)과
// Mach trap(포트·메시지·VM 등). 둘 다 svc 명령으로 EL0→EL1로 넘어가지만
// 커널 안에서 다른 테이블로 분기한다.
// "시스템 콜"이라고 뭉뚱그려도 대화는 통하지만, 구분해서 말하면 한 층 더 들어간 게 된다.
스택 맨 위에 mach_msg가 있으면 — 정상적으로 잠든 것입니다. 커널이 관리하는 대기 상태라 CPU도 배터리도 안 씁니다.
스택 맨 위에 내 코드(또는 동기 I/O·DispatchSemaphore.wait)가 있으면 — 진짜 블로킹입니다. 루프가 한 바퀴를 못 끝내고 있는 상태죠.
둘은 스택 모양이 다릅니다. Time Profiler나 일시 정지로 메인 스레드를 봤을 때 이 구분을 할 수 있으면, "앱이 멈췄다"는 신고를 몇 초 만에 분류할 수 있습니다.
# 앱이 유휴 상태일 때 메인 스레드 스택을 떠 보면
# 항상 이 모양의 바닥이 보인다
Thread 0 (main):
0 libsystem_kernel.dylib mach_msg2_trap ← 커널에서 블로킹 중
1 libsystem_kernel.dylib mach_msg
2 CoreFoundation __CFRunLoopServiceMachPort
3 CoreFoundation __CFRunLoopRun
4 CoreFoundation CFRunLoopRunSpecific
5 GraphicsServices GSEventRunModal
6 UIKitCore UIApplicationMain
7 MyApp main
# Xcode 디버거에서 일시 정지하거나, Instruments의 Time Profiler로 확인 가능.
# "메인 스레드가 블록됐다"는 말의 의미도 여기서 명확해진다 —
# mach_msg 가 아니라 "내 코드"가 스택 맨 위에 오래 남아 있는 상태다.③ 생명주기는 상태 기계다
┌──────────────┐
init ──▶ │ NotLoaded │
└──────┬───────┘
│ loadView + viewDidLoad ← 이 간선은 한 번만 지난다
▼
┌──────────────┐
┌───▶│ Loaded │◀────┐
│ └──────┬───────┘ │
│ │ viewWillAppear / viewIsAppearing
│ ▼ │ viewDidDisappear
│ ┌──────────────┐ │
│ │ Appearing │─────┤ ← 전환 취소 시 여기서 되돌아온다
│ └──────┬───────┘ │
│ │ viewDidAppear
│ ▼ │
│ ┌──────────────┐ │
└────│ Appeared │─────┘
viewWillDisappear └──────┘
// 이 그래프로 함정 두 개가 즉시 설명된다:
// ① NotLoaded → Loaded 간선은 1회뿐이다
// → viewDidLoad에 1회성을 넣는 것이 "맞는" 이유
// ② Loaded ⇄ Appearing ⇄ Appeared 는 사이클이다
// → viewWillAppear에 1회성을 넣으면 사이클을 돌 때마다 반복된다
// ③ Appearing → Loaded 로 되돌아오는 간선이 존재한다
// → 전환 취소 시 viewDidAppear 없이 viewWillDisappear가 오는 이유01번에서 본 상태 기계가 여기서 다시 나옵니다. "순서를 외운다"가 아니라 "전이 그래프에서 이 간선을 몇 번 지나는가"로 보면, 무엇을 어디에 넣을지가 암기가 아니라 추론이 됩니다.
④ SwiftUI에는 생명주기가 없다 — 대신 identity가 있다
struct ProfileView: View {
let userID: String
@State private var draft = "" // 이 상태의 수명은 "뷰 값"이 아니라 identity에 매달린다
var body: some View { // body는 수시로 재계산된다 — 이건 "생성"이 아니다
TextField("메모", text: $draft)
.task(id: userID) { // identity가 바뀌면 취소되고 재시작
await load(userID)
}
}
}
// ① 뷰는 객체가 아니라 값(struct)이다. body 재계산 ≠ 화면 재생성
// ② @State의 수명은 structural identity (타입 + 뷰 트리 위치) 가 정한다
// ③ .id() 로 identity를 바꾸면 @State가 초기화된다 — "재생성"의 유일한 방법
// ④ onAppear/onDisappear 는 UIKit 생명주기와 1:1이 아니다
// 그래서 UIKit 습관이 이렇게 번역된다
// viewDidLoad → .task { } 또는 초기화 시점 (단, 여러 번 불릴 수 있음)
// viewWillAppear → .onAppear { }
// viewDidLayoutSubviews → GeometryReader / onGeometryChange
// "화면을 리셋" → .id(newValue) 로 identity 교체
UIKit은 "객체의 수명"에 콜백을 걸고, SwiftUI는 "값의 identity"에 상태를 매답니다.
UIKit에서는 viewDidLoad가 언제 불리는지가 중요하고, SwiftUI에서는 identity가 언제 바뀌는지가 중요합니다.
그래서 SwiftUI에서 "왜 상태가 초기화되죠?"의 답은 항상 "identity가 바뀌었으니까"이고, "왜 초기화가 안 되죠?"의 답은 "identity가 그대로니까"입니다.
CS 정본으로 더 내려가기
이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.
경험으로 말하기
❌ "viewDidLoad에 초기화하고 viewWillAppear에 갱신합니다."
✅ "리스트에서 상세로 갔다 돌아오면 섹션 헤더가 하나씩 늘어나는 버그가 있었습니다.
viewWillAppear에 sections.append()가 있었는데, 이 콜백이 매번 불린다는 걸 놓친 것이었죠.
고치면서 팀 규칙을 하나 만들었습니다 — "이 콜백은 이 화면 수명에서 몇 번 불리는가"를 먼저 세고 배치한다.
그리고 스크롤 위치 복원은 viewWillAppear에서 bounds가 아직 확정이 아니라 어긋났는데, iOS 17의 viewIsAppearing으로 옮기니 정확해졌습니다 —
iOS 13까지 백디플로이되어서 최소 타깃을 올리지 않고 바로 쓸 수 있었습니다."
꼬리 질문 대비
L2 viewWillAppear와 viewIsAppearing은 뭐가 다른가요?
차이는 "그 시점에 무엇이 확정돼 있는가" 하나입니다.
viewWillAppear— 뷰가 아직 계층에 완전히 자리 잡기 전일 수 있어서,bounds·safeAreaInsets·traitCollection이 최종값이 아닐 수 있습니다.viewIsAppearing— 뷰가 계층에 추가되고 기하 정보와 trait이 정확해진 뒤, 그리고 전환 애니메이션이 시작되기 전에 불립니다.
그래서 "크기에 의존하는 등장 준비"는 전부 여기가 맞습니다 — 스크롤 위치 복원, 초기 스크롤, 크기 기반 초기 배치.
실무에서 가장 중요한 사실: iOS 17 SDK로 빌드하면 iOS 13까지 백디플로이됩니다. 즉 @available 분기 없이 그냥 override하면 됩니다. "iOS 17 전용이라 못 쓴다"가 흔한 오해예요.
viewWillAppear는 "무대에 나갈 준비해"이고, viewIsAppearing은 "무대에 섰다, 조명 켜지기 직전"입니다. 무대 크기를 재려면 올라가 본 뒤에 재야죠.L3 viewDidLayoutSubviews는 왜 여러 번 불리나요?
레이아웃이 한 번에 끝나는 계산이 아니기 때문입니다. 여러 번 불리는 상황이 실제로 많습니다.
- 초기 표시 — 임시 크기로 한 번, 실제 크기로 다시.
- 회전·분할 화면·사이즈 클래스 변경.
- 키보드 등장으로 safe area 변경.
- 자식 뷰가 제약을 바꿔 재계산 유발 — 이게 무한 루프의 원인이 되기도 합니다.
UITableView/UICollectionView의 셀프사이징 — 측정 과정에서 여러 패스.
그래서 규칙은 하나입니다. 여기 넣는 코드는 반드시 멱등해야 합니다 — 몇 번 실행해도 결과가 같아야 하죠. frame = bounds 같은 대입은 안전하고, addSubview·append·+= 1 같은 누적은 위험합니다.
그리고 레이아웃 무효화를 유발하는 코드를 여기 넣으면 재귀가 됩니다. 꼭 필요하면 DispatchQueue.main.async로 이번 패스 밖으로 밀거나, 애초에 제약으로 표현할 수 있는지 다시 봐야 합니다.
L4 이 콜백들은 결국 누가 부르는 건가요?
한 층씩 내려가면서 답하면 완결됩니다.
① 프레임워크가 부릅니다 — UIApplicationMain이 잡고 있는 메인 RunLoop가 이벤트를 받아 UIKit 내부로 넘기고, UIKit이 전환 상태에 따라 해당 콜백을 호출합니다. 내가 흐름을 통제하지 않는 제어의 역전 구조입니다.
② RunLoop는 폴링하지 않습니다 — 이벤트가 없으면 mach 포트에서 mach_msg로 블로킹하며 잠듭니다. 그래서 유휴 시 CPU가 0%입니다. 커널이 터치·타이머·네트워크 완료를 그 포트로 보내면 깨어납니다. 인터럽트 기반 I/O의 사용자 공간 버전이라고 보면 됩니다.
③ 그래서 "메인 스레드를 막으면 안 된다"의 의미가 명확해집니다 — 루프가 한 바퀴를 못 끝내면 다음 이벤트를 꺼내지도, 레이아웃·렌더를 하지도 못합니다. 터치가 안 먹는 게 아니라 큐에 쌓인 채 처리가 안 되는 것이고, 화면이 멈추는 게 아니라 새 프레임을 커밋하지 못하는 것입니다.
④ 프레임 예산과 연결됩니다 — 60Hz면 이 한 바퀴가 16.7ms 안에 끝나야 합니다(11번). 생명주기 콜백에서 동기 네트워크나 디스크 I/O를 하면 안 되는 이유가 이것입니다.
쉽게 이해하기
연극 무대를 생각해 봅시다.
viewDidLoad= 무대 세트를 짓는 날. 딱 한 번입니다. 소품을 놓고 배선을 합니다. 그런데 무대 크기는 아직 임시예요 — 실제 극장에 맞춰 조정되기 전이죠.viewWillAppear= 막 오르기 직전. 공연이 여러 번 있으니 매번 불립니다. 소품 위치 확인, 조명 상태 확인. 여기서 세트를 또 지으면 공연할 때마다 무대가 늘어납니다.viewIsAppearing= 무대에 올라섰고 조명 켜지기 직전. 이제 무대 크기가 정확합니다. "저 위치에 서야 한다" 같은 크기 의존 준비는 여기서.viewDidLayoutSubviews= 무대 크기가 확정될 때마다. 극장이 바뀌거나 회전하면 또 불립니다. "소파를 벽에 붙여" 같은 몇 번 해도 같은 지시만 넣어야 해요.viewDidAppear= 막이 완전히 올라간 뒤. 이제 연기를 시작합니다.
그런데 이 모든 걸 누가 지시할까요? 배우가 아니라 무대 감독입니다. 배우는 "이제 나갈게요"라고 정하지 않고, 감독이 부르면 나갑니다. 이게 프로그래밍에서 제어의 역전이에요 — 내 코드가 프레임워크를 부르는 게 아니라 프레임워크가 내 코드를 부릅니다.
그리고 감독은 계속 두리번거리며 일감을 찾지 않습니다. 할 일이 없으면 의자에 앉아 완전히 쉽니다. 대신 벨을 하나 걸어 두죠. 관객이 들어오거나(터치), 알람이 울리거나(타이머), 택배가 오면(네트워크 응답) 벨이 울리고 그때 일어납니다. 그래서 앱이 가만히 있을 때 배터리가 안 닳는 거예요.
여기서 가장 중요한 결론이 나옵니다. 감독은 한 명입니다. 한 가지 일에 10초를 쓰면, 그동안 벨이 울려도 못 갑니다. 터치도 안 먹고, 화면도 안 바뀌고, 애니메이션도 멈춥니다. "메인 스레드를 막지 마라"가 규칙이 아니라 구조적 사실인 이유입니다.
생명주기는 이벤트 루프에 걸린 상태 기계다. 순서를 외우는 대신 "이 간선을 몇 번 지나는가"와 "이 시점에 무엇이 확정됐는가" 두 가지만 물으면, 배치는 추론으로 나온다.
설계 근거 · 1차 자료
이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.
- Apple — 무엇이 확정된 시점인지, 그리고 iOS 13까지 백디플로이된다는 공식 서술
- WWDC23 — viewIsAppearing을 추가한 이유와 기존 콜백의 한계 설명
- Apple — 각 콜백의 계약