뷰 레이아웃 (1) — 레이아웃 사이클
뷰 레이아웃 학습 로드맵 1단계. UIKit이 화면을 그리기 전에 도는 3패스(update → layout → display)와 dirty 처리, setNeeds* vs *IfNeeded를 정리한다. 주인님 질문 + 답변 보존.
큰 그림 — 매 런루프, 그리기 전에 3패스
상태가 바뀌면 즉시 다시 그리지 않고 dirty(더러움) 표시 → 런루프 끝 무렵 모아서 한 번에 처리한다(성능). 처리 순서는 3패스.
| 패스 | 뷰 훅 / VC 훅 | dirty 표시 / 즉시 강제 |
|---|---|---|
| ① Update (제약) | updateConstraints() / updateViewConstraints() | setNeedsUpdateConstraints() / updateConstraintsIfNeeded() |
| ② Layout (프레임) 🔴 | layoutSubviews() / viewDidLayoutSubviews() | setNeedsLayout() / layoutIfNeeded() |
| ③ Display (그리기) | draw(_:) | setNeedsDisplay() |
frame/bounds에 의존하는 코드는 viewDidLoad가 아니라 layoutSubviews/viewDidLayoutSubviews에서 읽어라(viewDidLoad 시점엔 frame 미확정).
Q&A
"제약을 바꾼다"가 무슨 말? heightConstraint.constant = 200 이런 거야?
네, 그게 가장 흔한 예. "제약 변경"은 여러 가지를 포함한다:
런타임 실무에선 ①constant 변경·②isActive 토글이 대부분.
updateViewConstraints가 snp의 makeConstraints 같은 거야? constant=200 같은 거야?
셋은 서로 다른 층위다.
| 것 | 정체 |
|---|---|
updateViewConstraints() / updateConstraints() | 레이아웃 사이클의 update 패스 훅(타이밍). 오버라이드해 그 안에 제약 코드 넣을 수 있음 |
snp.makeConstraints { } | SnapKit DSL — 제약을 새로 생성 |
snp.updateConstraints { } | SnapKit DSL — 기존 제약 constant 갱신 (≈ .constant = 200) |
heightConstraint.constant = 200 | 특정 제약 하나의 값 변경 |
updateViewConstraints()는 "언제"(타이밍 훅), makeConstraints는 "무엇"(제약 정의) → 같은 게 아니다. 실무에선 update 패스를 거의 오버라이드 안 함 — makeConstraints로 만들고 .constant =로 바꾸면 됨(대량 조건부 교체 시에만 update 패스 사용).
순서 — 상태 변경 → dirty → 런루프 끝에 처리 → viewDidLayoutSubviews로 frame 적용 → draw, 이게 맞아?
거의 정확하다. 두 군데만 다듬으면 완벽:
- (A) 제약을 바꾸면 dirty 표시가 자동.
constant = 200하면 UIKit이 알아서 그 뷰를 "layout 필요"로 표시(직접setNeedsLayout()불필요) - (B) frame을 실제 적용하는 건
layoutSubviews(뷰 레벨, top-down).viewDidLayoutSubviews는 VC 루트뷰 레이아웃이 끝난 뒤의 알림 콜백이지 frame 적용 주체가 아님
상태 변경 → (자동)dirty → 런루프 끝 commit 시 ① updateConstraints(bottom-up) → ② layoutSubviews로 프레임 확정(top-down) → viewDidLayoutSubviews 알림 → ③ draw → 렌더.
updateConstraints() / updateViewConstraints() 깊게 (2026-06-16)
둘 다 레이아웃 사이클 ① Update 패스의 훅 — "제약을 갱신하는 전용 자리". updateConstraints()는 UIView, updateViewConstraints()는 UIViewController(루트뷰 담당) 버전.
updateConstraints() | updateViewConstraints() | |
|---|---|---|
| 소속 | UIView | UIViewController |
| 대상 | 그 뷰 자신의 제약 | VC 루트뷰(self.view)의 제약 |
| 방향 | bottom-up (서브뷰 → superview) | VC 레벨 훅 |
super | 반드시 맨 마지막에 호출 | |
핵심 질문 — setNeedsUpdateConstraints()가 호출되면 더티로만 만들고, 런루프 끝 레이아웃 패스에서 updateConstraints()가 발생하는 거야?
거의 정확. 두 군데만 정밀화하면 완벽.
- ✅
setNeedsUpdateConstraints()= 더티 마킹만. 즉시updateConstraints()를 부르지 않음 (그 뷰를 "제약 갱신 필요"로 표시 + 상위 체인/윈도우를 needs-layout 등록) - ✅ 그 뷰의
updateConstraints()는 다음 레이아웃 패스에서 실행 - 🔧 타이밍 — "다음 런루프 사이클"보다는 지금 런루프가 한 바퀴 다 돌고 idle 직전(CATransaction commit 시점). 보통 같은 런루프 반복의 끝, 그리고 화면 그리기 직전이다.
- 🔧 구조 — '레이아웃 패스'는 사실 3개 서브패스.
updateConstraints()는 그 첫 번째이지layoutSubviews와 같은 단계가 아니다.
updateConstraintsIfNeeded() → ① 서브패스만 즉시 · layoutIfNeeded() → ①+② 전부 즉시
그럼 updateConstraints()를 오버라이드해서 거기 제약을 넣어야 해?
대부분 아니다. Apple 권고: "제자리에서 제약을 바꾸는 게 너무 느리거나, 한 사이클에 중복 변경이 많을 때만 오버라이드하라."
- 보통은
viewDidLoad/init에서 제약 한 번 세팅 → 변하는 건.constant바꾸거나.isActive토글 - 오버라이드하면 멱등(idempotent)해야 — 회전·trait 변경 등으로 생각보다 자주 호출됨. 매번
addConstraint하면 중복 누적 → 충돌 - 이득: 여러 곳에서
setNeedsUpdateConstraints()를 불러도 실제 재계산은 다음 ① 패스에 한 번으로 모임(coalescing)
핵심 패턴 2개 (실무 단골)
| 의미 | 타이밍 | |
|---|---|---|
setNeedsLayout() | "더럽다" 표시만 | 다음 사이클(지연, 여러 변경 합쳐 1번) |
layoutIfNeeded() | 지금 강제 실행 | 즉시(동기) |
제약 애니메이션 — layoutIfNeeded의 대표 용도:
"constraint 바꿨는데 애니메이션이 끊긴다/안 된다"의 정답이 이것.
다음 단계 (로드맵)
- 2단계 — 크기 결정: intrinsic content size, CHCR(Content Hugging / Compression Resistance), priority
- 3단계 — Auto Layout 엔진 내부(Cassowary), systemLayoutSizeFitting, self-sizing cell
- 4단계 — SwiftUI 레이아웃 모델(부모 제안 → 자식 결정 → 부모 배치), Layout 프로토콜
- 5단계 — 디버깅/성능
관련 문서: Auto Layout Update Cycle · UIViewController Lifecycle