← Mobile Foundation 인터뷰 12 / 13

12 · setNeedsLayout vs layoutIfNeeded

"더럽다고 표시" vs "지금 청소해" → 병합 → 지연 평가와 증분 계산
진행률
0 / 0 완료
면접에서 나오는 형태
  1. setNeedsLayoutlayoutIfNeeded무엇이 다른가요?
  2. 제약을 바꿔서 애니메이션하려면 어떻게 하나요?
  3. UIKit은 왜 바로 계산하지 않고 미루나요?
L1 개념
setNeedsLayout = "나중에 다시 계산해 줘"(예약), layoutIfNeeded = "지금 당장"(동기 강제)
L2 설계
예약이 기본인 이유는 병합(coalescing) — 100번 무효화해도 계산은 한 번
L3 구현
계산은 RunLoop 한 바퀴의 끝, 커밋 직전에 몰아서 한다. 제약 변경 자체가 이미 무효화를 유발한다
L4 CS
지연 평가 · 더티 플래그 기반 증분 계산 · 제약 해결기(Cassowary)

30초 답변 🔥 먼저 이 문장

setNeedsLayout()"나중에 다시 계산해 줘"라는 예약입니다 — 더티 플래그만 켜고 즉시 반환합니다.
layoutIfNeeded()"지금 당장 계산해"입니다 — 더티 상태면 그 자리에서 layoutSubviews를 동기 호출합니다.

기본은 setNeedsLayout이고, layoutIfNeeded지금 확정된 프레임이 필요할 때만 씁니다.

예약 방식인 이유가 중요합니다 — 무효화는 병합됩니다. 한 사이클에 100번 setNeedsLayout을 불러도 layoutSubviews한 번만 불립니다. 루프 안에서 layoutIfNeeded를 부르면 그 이점을 스스로 버리는 셈입니다.

L1개념 — 세 쌍의 대응 관계

무효화 (예약)즉시 실행 (강제)실제 콜백무엇을 다시 하나
setNeedsLayout()layoutIfNeeded()layoutSubviews()서브뷰 위치·크기 계산
setNeedsUpdateConstraints()updateConstraintsIfNeeded()updateConstraints()제약 자체를 다시 만들기
setNeedsDisplay()CALayer.displayIfNeeded()draw(_:)픽셀 다시 그리기

패턴이 동일합니다 — "더럽다고 표시" / "지금 정리" / "실제 작업". UIView에는 displayIfNeeded가 없고 CALayer에만 있다는 점만 예외입니다.

동작을 눈으로 확인하는 코드
final class TraceView: UIView {
    private var layoutCount = 0
    override func layoutSubviews() {
        super.layoutSubviews()
        layoutCount += 1
        print("layoutSubviews 호출 #\(layoutCount)")
    }
}

let v = TraceView()

// ── 케이스 1: 무효화를 100번 ──
for _ in 0..<100 { v.setNeedsLayout() }
// 출력: (아직 아무것도 안 나옴 — 예약만 됨)
// 이번 RunLoop 사이클이 끝날 때 → "layoutSubviews 호출 #1"  ← 딱 한 번!

// ── 케이스 2: 무효화 + 즉시 실행을 100번 ──
for _ in 0..<100 {
    v.setNeedsLayout()
    v.layoutIfNeeded()
}
// 출력: #1 #2 #3 ... #100   ← 병합 이점을 전부 버렸다

// ── 케이스 3: 더티가 아닐 때 layoutIfNeeded ──
v.layoutIfNeeded()     // 아무 일도 안 일어난다 (이미 깨끗하므로)
v.layoutIfNeeded()     // 역시 아무 일도 없다
// → "IfNeeded"라는 이름 그대로, 필요할 때만 한다

L2설계 — 왜 즉시 계산하지 않는가

✅ setNeedsLayout — 병합된다 텍스트 변경제약 변경 숨김 토글폰트 변경 더티 플래그 = true 4번 켜도 플래그는 하나 layoutSubviews × 1 사이클 끝에서 한 번 제약 해결 1회 · 프레임 예산 안전 ❌ 매번 layoutIfNeeded — 병합 없음 변경 1변경 2 변경 3변경 4 layout#1 layout#2 layout#3 layout#4 제약 해결 4회 — 같은 결과를 얻는 데 4배 그리고 중간 3번의 결과는 화면에 나오지도 않는다 (커밋은 사이클 끝에 한 번뿐이므로) = 순수한 낭비
중간 계산 결과는 화면에 나오지도 않습니다. 커밋은 사이클 끝에 한 번뿐이니까요. 그래서 중간의 즉시 계산은 결과에 영향을 주지 않는 순수한 비용입니다.

실무 사용처 ① — 제약 애니메이션 (가장 자주 나오는 정답 패턴)

❌ → ✅ 순서가 전부다
// ❌ 시작 상태가 확정되지 않아 애니메이션이 튄다
UIView.animate(withDuration: 0.3) {
    self.heightConstraint.constant = 300
    self.view.setNeedsLayout()      // ← 제약 변경이 이미 무효화한다. 불필요.
    self.view.layoutIfNeeded()
}
// 문제: 이전에 밀려 있던 레이아웃까지 이 블록 안에서 함께 처리되어
//       "의도하지 않은 뷰들"이 같이 애니메이션된다.

// ✅ 정답 패턴 — 시작 확정 → 변경 → 블록 안에서 한 번
view.layoutIfNeeded()                 // (1) 밀린 레이아웃을 먼저 정리 = 시작 상태 확정
heightConstraint.constant = 300       // (2) 목표 값 (이 자체가 setNeedsLayout 유발)
UIView.animate(withDuration: 0.3) {
    self.view.layoutIfNeeded()        // (3) 종료 상태로 보간
}

// ✅ 더 싼 방법 — 레이아웃을 아예 안 건드린다
UIView.animate(withDuration: 0.3) {
    self.panel.transform = CGAffineTransform(translationX: 0, y: -120)
}
// transform · opacity · backgroundColor 는 레이아웃 무효화가 없다
// → 렌더 서버가 처리하고, 메인 스레드는 커밋 한 번으로 끝난다
📝 UIView.animate가 "레이아웃을 애니메이션"하는 원리

애니메이션 블록은 매 프레임 실행되지 않습니다. 시작할 때 딱 한 번 실행돼요. 그 안에서 layoutIfNeeded()를 부르면 최종 프레임 값이 계산되고, UIKit은 시작 값과 최종 값 사이를 보간하는 애니메이션을 레이어에 겁니다.

그래서 (1)단계가 중요합니다. 밀린 레이아웃이 있으면 "시작 값"이 내가 생각한 값이 아닐 수 있고, 그러면 엉뚱한 곳에서 시작하는 애니메이션이 나옵니다.

실무 사용처 ② — 지금 프레임 값이 필요할 때

동기 강제가 정당한 경우들
// ① 계산 직전 — 프레임 값이 확정돼야 한다
containerView.layoutIfNeeded()
let contentHeight = stackView.frame.height   // 이제 신뢰 가능
scrollView.contentSize = CGSize(width: view.bounds.width, height: contentHeight)

// ② 스냅샷/이미지 렌더링 직전
view.layoutIfNeeded()
let image = UIGraphicsImageRenderer(bounds: view.bounds).image { ctx in
    view.layer.render(in: ctx.cgContext)
}

// ③ 셀 높이를 수동 계산할 때 (systemLayoutSizeFitting 사용이 더 낫지만)
cell.contentView.layoutIfNeeded()
let h = cell.contentView.systemLayoutSizeFitting(
    UIView.layoutFittingCompressedSize).height

// ④ "무효화 여부와 무관하게" 지금 확정하고 싶을 때 — 이때는 짝이 맞다
view.setNeedsLayout()      // 더티가 아니어도 강제로 더럽게 만들고
view.layoutIfNeeded()      // 지금 계산한다
// ⚠️ 다만 이 짝을 습관적으로 쓰면 병합 이점을 계속 버리게 된다.
//    "정말 지금 값이 필요한가?"를 먼저 물어야 한다.
🚫 안티패턴 네 가지
이렇게 쓰면 프레임을 잡아먹는다
// ❌ ① 매 프레임 경로에서 레이아웃 무효화
func scrollViewDidScroll(_ scrollView: UIScrollView) {
    headerHeightConstraint.constant = max(60, 200 - scrollView.contentOffset.y)
    view.layoutIfNeeded()          // 💥 스크롤 매 프레임마다 제약 해결
}
// ✅ 레이아웃을 안 건드리는 속성으로
func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let scale = max(0.7, 1 - scrollView.contentOffset.y / 400)
    header.transform = CGAffineTransform(scaleX: scale, y: scale)
}

// ❌ ② layoutSubviews 안에서 layoutIfNeeded
override func layoutSubviews() {
    super.layoutSubviews()
    contentView.layoutIfNeeded()   // 💥 재귀 위험
}

// ❌ ③ layoutSubviews를 직접 호출
myView.layoutSubviews()            // 💥 절대 금지
// 더티 플래그가 정리되지 않아 나중에 또 불린다. 반드시 layoutIfNeeded()를 쓴다.

// ❌ ④ 루프 안에서 즉시 실행
for item in items {
    stackView.addArrangedSubview(makeRow(item))
    stackView.layoutIfNeeded()     // 💥 N번 제약 해결 → O(N × 제약수)
}
// ✅ 전부 추가하고 나서 한 번 (또는 아예 시스템에 맡긴다)
items.forEach { stackView.addArrangedSubview(makeRow($0)) }
stackView.layoutIfNeeded()

L3구현 — 계산은 언제 실제로 일어나는가

08번의 RunLoop 그림이 여기서 다시 나옵니다. 레이아웃 패스는 이벤트 처리 이후, 커밋 직전에 있습니다.

한 사이클 안의 순서
[이벤트 처리]
   └ 터치 핸들러 · 타이머 · 네트워크 콜백에서 내 코드가 뷰를 더럽힌다
       setNeedsLayout() × N  →  더티 플래그 하나
       제약 변경           →  내부적으로 setNeedsLayout 유발
       텍스트/이미지 변경   →  intrinsicContentSize 변경 → 무효화
   ↓
[업데이트 사이클 — CATransaction 커밋 직전]
   ① updateConstraints  (더티인 것만, 아래→위)
   ② layoutSubviews     (더티인 것만, 위→아래)   ← 여기서 딱 한 번
   ③ display / draw     (더티인 것만)
   ↓
[커밋]
   레이어 트리를 인코딩해 렌더 서버로 전송
   ↓
[다시 잠듦]

// layoutIfNeeded() 는 이 순서를 기다리지 않고
// "지금 ②를 실행"하는 것이다.
무효화 전파 방향 — 위/아래가 다르다
// setNeedsLayout: 자기 자신을 더티로 표시
//   → 부모 방향으로도 전파될 수 있다 (내 크기가 바뀌면 부모 배치도 영향)

// layoutIfNeeded: 리시버와 그 하위 트리를 즉시 정리
//   ⚠️ 상위가 더티면 UIKit이 위쪽부터 정리할 수 있다
//      (내 프레임은 부모가 정해 주므로)

// 그래서 "이 뷰만 딱 계산"은 보장되지 않는다
someDeepView.layoutIfNeeded()
// → 실제로는 필요한 조상까지 함께 정리될 수 있다

// intrinsicContentSize를 바꾸는 커스텀 뷰라면 반드시
override var text: String {
    didSet {
        invalidateIntrinsicContentSize()   // ✅ 이걸 안 부르면 크기가 안 바뀐다
    }
}
// invalidateIntrinsicContentSize() 도 무효화 계열이다 —
// "내 고유 크기가 바뀌었으니 다시 물어봐 줘"
측정 — 레이아웃이 몇 번 도는지 직접 세기
// ① 커스텀 뷰에 카운터
final class CountingView: UIView {
    static var layoutCount = 0
    override func layoutSubviews() {
        super.layoutSubviews()
        Self.layoutCount += 1
    }
}

// ② os_signpost로 구간 측정 (11번과 같은 방법)
let sp = OSSignposter(subsystem: "com.app.layout", category: "pass")
override func layoutSubviews() {
    let s = sp.beginInterval("layoutSubviews", id: sp.makeSignpostID())
    defer { sp.endInterval("layoutSubviews", s) }
    super.layoutSubviews()
}

// ③ Instruments → Time Profiler에서 찾을 이름
//    -[UIView layoutSubviews]
//    -[NSISEngine ...]              ← Auto Layout 해결기 내부
//    NSISEngine::optimize           ← 여기가 무거우면 제약이 너무 많다

// ④ Auto Layout 충돌·모호성 디버깅
//    실행 인자: -UIViewLayoutFeedbackLoopDebuggingThreshold 100
//    → 레이아웃 피드백 루프(무한 재계산)를 잡아 준다

L4CS 근본 — 지연 평가, 증분 계산, 제약 해결

① 더티 플래그는 "무효화 기반 증분 계산"이다

이 패턴에는 이름이 있습니다. 증분 계산(incremental computation)"입력이 조금 바뀌었을 때 전체를 다시 계산하지 않고 영향받은 부분만 다시 계산한다"는 기법입니다.

같은 패턴을 Swift로 직접 구현해 보면 구조가 보인다
final class ExpensiveValue {
    private var cached: Int?
    private var inputs: [Int] = []

    // 무효화 — setNeedsLayout 에 해당
    func setNeedsRecompute() {
        cached = nil                     // 플래그를 끄는 것만. 계산은 안 한다.
    }

    // 즉시 실행 — layoutIfNeeded 에 해당
    func recomputeIfNeeded() -> Int {
        if let cached { return cached }  // 깨끗하면 아무 일도 안 한다
        let result = expensiveWork(inputs)
        cached = result
        return result
    }

    func update(_ new: [Int]) {
        inputs = new
        setNeedsRecompute()              // 변경은 무효화만 유발
    }
}

// 이게 정확히 UIKit이 하는 일이다:
//   · 변경 → 플래그만 켠다 (싸다)
//   · 결과가 필요한 순간 → 그때 계산 (필요한 만큼만)
//   · 여러 번 변경 → 플래그는 하나 → 계산도 한 번  ← 병합
//
// 그리고 lazy var, Combine의 @Published, SwiftUI의 뷰 무효화,
// 빌드 시스템의 증분 빌드, 스프레드시트의 재계산이 전부 같은 구조다.

② 병합(coalescing)은 어디에나 있다

이름같은 아이디어
UIKit레이아웃 무효화 병합N번 더럽혀도 계산 1회
CPU/메모리write combining연속된 작은 쓰기를 모아 한 번에 버스로 보냄
OS디스크 write-back 캐시쓰기를 모았다가 한꺼번에 플러시
네트워크Nagle 알고리즘작은 패킷을 모아 한 번에 전송
UI 이벤트debounce / throttle연속 입력을 하나로 합침
DB배치 커밋트랜잭션을 모아 커밋

전부 같은 명제입니다 — "각 작업의 고정 비용이 크면, 모아서 한 번에 하는 게 싸다." 레이아웃의 고정 비용은 제약 해결기를 돌리는 것이고, 그게 다음 이야기입니다.

③ Auto Layout은 제약 해결기(Cassowary)다

layoutSubviews가 왜 비싼지는 Auto Layout이 실제로 무엇을 푸는지를 알아야 이해됩니다. 이건 배치 계산이 아니라 선형 제약 만족 문제입니다.

제약은 선형 방정식/부등식의 연립이다
// 우리가 쓰는 것
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16)
label.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -16)
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)

// 해결기가 푸는 것
//   label.left  =  view.left + 16
//   label.right ≤  view.right - 16
//   label.centerY = view.centerY
//   label.width  = label.right - label.left
//   ... (뷰마다 4개 변수 × 제약 개수)
//
// 여기에 우선순위(priority)가 붙으면 "가능한 만족시키되 안 되면 완화"가 되어
// 단순 연립방정식이 아니라 "최적화 문제"가 된다.

// Auto Layout은 Cassowary 계열의 증분 제약 해결기를 쓴다.
//   · Cassowary(Badros·Borning·Stuckey, 2001)는 증분 심플렉스 기반 알고리즘으로,
//     UI 레이아웃을 겨냥해 만들어진 것이 맞다.
//   · 다만 "Apple이 Cassowary를 그대로 쓴다"는 Apple 1차 문서로 확인되는 사실은 아니다.
//     널리 알려진 사실이지만, 면접에서는 "Cassowary 계열"이라고 말하는 편이 안전하다.
//
// 알고리즘 이름보다 중요한 성질은 이것이다:
//   "제약을 하나 추가/변경했을 때 처음부터 다시 풀지 않고
//    기존 해에서 증분적으로 갱신할 수 있다"
🧠 L4 여기서 두 가지가 한꺼번에 설명된다

① 제약이 많아지면 왜 비용이 빠르게 늘어나나
선형 계획 문제의 비용은 변수·제약 개수에 따라 커집니다. 뷰 하나가 변수 4개(left/top/width/height)를 만들고, 제약이 그것들을 엮습니다. 중첩 스택뷰가 특히 무거운 이유는 레벨마다 뷰와 제약이 함께 늘어나 문제 크기가 커지기 때문입니다.

② 왜 "모아서 한 번"이 그렇게 유리한가
해결기를 기동하고 해를 확정하는 것 자체에 고정 비용이 있습니다. 제약 4개를 바꾸고 한 번 푸는 것과, 하나 바꿀 때마다 네 번 푸는 것은 같은 답에 네 배 비용입니다. layoutIfNeeded를 루프에서 부르는 게 나쁜 이유가 여기 있습니다.

④ 무효화 전파는 의존 그래프 문제다

왜 방향이 정해져 있는가
// 뷰 계층은 의존 그래프다
//   자식의 위치 ← 부모의 크기 에 의존
//   부모의 크기 ← 자식의 intrinsicContentSize 에 의존 (셀프사이징)
//
// 그래서 UIKit은 두 방향으로 나눠 처리한다:
//
//   updateConstraints : 아래(자식) → 위(부모)
//     "내 고유 크기가 이렇습니다"를 위로 올린다
//
//   layoutSubviews    : 위(부모) → 아래(자식)
//     확정된 크기를 아래로 내려보낸다
//
// 이 두 패스로 나누는 것이 의존 그래프의 위상 정렬에 해당한다.
// 사이클이 생기면? → 피드백 루프 = 무한 재계산

// ❌ 사이클을 만드는 전형
override func layoutSubviews() {
    super.layoutSubviews()
    // 자식 프레임을 보고 → 제약을 바꾼다 → 다시 레이아웃 필요 → 무한
    heightConstraint.constant = label.frame.height
}
// ✅ 제약으로 표현하면 해결기가 한 번에 푼다
label.bottomAnchor.constraint(equalTo: container.bottomAnchor)
// → "관계"를 선언하면 해결기가 순서를 알아서 정한다.
//    "값을 읽어서 값을 쓰는" 명령형 코드가 사이클을 만든다.

이게 선언형 레이아웃의 본질적 이점입니다. 관계를 선언하면 계산 순서를 시스템이 위상 정렬로 정하고, 개발자는 사이클을 만들 수 없습니다. 01번에서 본 단방향 흐름이 여기서 다시 나옵니다 — 사이클 없는 의존 그래프는 한 번의 순회로 확정된다.

⑤ 지연 평가 — 이 자료 전체를 관통하는 마지막 정리

같은 아이디어가 다섯 번 나왔다
더티 플래그        (12번) — 레이아웃 계산을 사이클 끝까지 미룬다
Copy-on-Write      (04번) — 복사를 쓰기 시점까지 미룬다
이미지 지연 디코딩 (11번) — 압축 해제를 그릴 때까지 미룬다
lazy var                  — 초기화를 첫 접근까지 미룬다
Effect / async     (01·10번) — 부수효과를 실제 실행 시점까지 미룬다

// 관통하는 명제:
//   "비싼 일을 진짜 필요해질 때까지 미루면,
//    상당수는 아예 필요해지지 않는다."
//
// 그리고 미룬 일들은 자연스럽게 "모여서" 한 번에 처리된다 — 병합.
// 지연과 병합은 같은 전략의 앞뒤다.

CS 정본으로 더 내려가기

이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.

경험으로 말하기

🎤 "차이를 안다"가 아니라 "무엇을 고쳤는가"

"setNeedsLayout은 예약이고 layoutIfNeeded는 즉시 실행입니다."

"확장/축소되는 패널 애니메이션이 처음 한 번만 튀는 문제가 있었습니다. 코드를 보니 animate 블록 안에서 제약을 바꾸고 layoutIfNeeded를 부르고 있었는데, 블록 진입 시점에 밀려 있던 레이아웃까지 함께 처리되면서 관계없는 뷰들이 같이 움직인 것이었습니다.

블록 밖에서 layoutIfNeeded()로 시작 상태를 확정하고, 제약을 바꾼 뒤 블록 안에서 한 번만 부르도록 고쳤습니다.

그리고 같이 발견한 게 하나 더 있었는데, scrollViewDidScroll에서 헤더 높이 제약을 매 프레임 바꾸고 있었습니다. Time Profiler에서 NSISEngine이 상위에 있길래 transform 기반으로 바꿨더니 제약 해결이 아예 사라졌습니다 — 레이아웃을 최적화한 게 아니라 레이아웃을 안 하게 만든 것이 핵심이었습니다."

꼬리 질문 대비

L2 setNeedsLayout()layoutIfNeeded()를 연달아 쓰는 건 잘못인가요?

상황에 따라 다릅니다. "무조건 잘못"이라고 하면 오히려 부정확합니다.

정당한 경우: "더티 여부와 무관하게 지금 확정하고 싶을 때"입니다. layoutIfNeeded()만 부르면 이미 깨끗한 경우 아무 일도 안 합니다. 그래서 강제로 다시 계산시키려면 먼저 더럽혀야 하죠.

이럴 때는 짝이 맞다
// 외부에서 데이터가 바뀌었지만 UIKit이 그걸 모르는 경우
customView.dataChanged()      // 무효화를 유발하지 않는 내부 변경
customView.setNeedsLayout()   // 명시적으로 더럽히고
customView.layoutIfNeeded()   // 지금 계산해서 프레임을 읽는다
let h = customView.frame.height

문제가 되는 경우: 습관적으로 붙이는 것입니다. 특히 ① 제약을 방금 바꿨을 때(이미 무효화되어 setNeedsLayout이 불필요), ② 루프 안(병합 이점을 버림), ③ 매 프레임 경로.

판단 기준: "지금 이 값을 읽어야 하는가?" 읽어야 하면 정당하고, 그냥 "확실히 하려고" 붙이는 거라면 프레임 예산을 버리는 것입니다.

쉽게: "지금 청소해"는 손님이 곧 오는데 상태를 확인해야 할 때 필요합니다. 그런데 물건 하나 옮길 때마다 매번 전체 청소를 시키면, 어차피 마지막에 한 번 하면 될 일을 열 번 하는 거예요.
L3 제약을 바꾸면 자동으로 무효화되나요?

됩니다. 그래서 setNeedsLayout()을 따로 부를 필요가 대부분 없습니다.

무효화를 자동으로 유발하는 것들:

  • constraint.constant = ... / isActive 토글
  • addConstraint / removeConstraint / NSLayoutConstraint.activate
  • addSubview / removeFromSuperview
  • frame·bounds 변경 (자기 자신과 슈퍼뷰 배치에 영향)
  • label.text·imageintrinsicContentSize가 바뀌는 변경
  • isHidden 토글 (스택뷰 안이면 특히)

자동으로 안 되는 경우도 있습니다 — 커스텀 뷰에서 intrinsicContentSize의 근거 데이터가 바뀌었을 때입니다. UIKit은 내 계산식을 모르니까요.

이때는 직접 알려 줘야 한다
final class BadgeView: UIView {
    var count: Int = 0 {
        didSet {
            label.text = "\(count)"
            invalidateIntrinsicContentSize()   // ✅ 없으면 크기가 안 바뀐다
        }
    }
    override var intrinsicContentSize: CGSize {
        CGSize(width: 24 + CGFloat("\(count)".count) * 8, height: 24)
    }
}

정리: UIKit이 아는 변경은 자동, 내가 정의한 계산식이 바뀐 건 수동입니다.

쉽게: 가구를 옮기면 청소부가 눈으로 보고 "다시 정리해야겠네" 합니다(자동). 그런데 서랍 안 내용물만 바꾸면 밖에서는 안 보이니 내가 말해 줘야 합니다.
L4 UIKit은 왜 즉시 계산하지 않고 미루도록 설계됐나요?

세 층으로 답하면 완결됩니다.

① 병합 이득이 크기 때문입니다. 한 번의 사용자 액션이 여러 개의 변경을 유발합니다 — 텍스트도 바꾸고, 제약도 바꾸고, 뷰도 숨기죠. 즉시 계산하면 N번 제약 해결인데, 중간 결과는 화면에 나오지도 않습니다. 커밋이 사이클 끝에 한 번뿐이니까요. 그래서 중간 계산은 결과에 영향 없는 순수한 낭비입니다.

② 제약 해결에 고정 비용이 있기 때문입니다. Auto Layout은 선형 제약 만족 문제를 푸는 해결기(Cassowary)입니다. 배치를 "계산"하는 게 아니라 연립 부등식을 우선순위와 함께 최적화하죠. 해결기를 기동하고 해를 확정하는 데 드는 고정 비용이 있어서, "모아서 한 번"이 구조적으로 유리합니다.

③ 순서 문제를 없애기 때문입니다. 즉시 계산이면 변경 순서에 따라 중간 상태가 달라집니다. 부모를 먼저 바꾸느냐 자식을 먼저 바꾸느냐에 따라 계산 횟수도, 중간 프레임 값도 달라지죠. 미뤄서 한 번에 하면 "모든 변경이 적용된 최종 상태"에서만 계산하므로, 순서에 무관하게 결과가 같습니다. 이건 성능이 아니라 정확성 이득입니다.

그리고 이 설계는 UIKit 고유가 아닙니다 — 브라우저의 리플로우 배칭, React의 상태 업데이트 배칭, SwiftUI의 뷰 무효화가 전부 같은 구조입니다. "선언적으로 더럽히고, 한 번에 조정한다."

쉽게: 이사할 때 가구를 하나 옮길 때마다 방 전체를 재배치하면 말이 안 되죠. 다 옮겨 놓고 한 번에 정리합니다. 게다가 중간 배치는 아무도 안 보니까 할 이유가 없어요. 그리고 한 번에 정리하면 어떤 순서로 옮겼든 결과가 같습니다.

쉽게 이해하기

방 정리를 생각해 봅시다.

setNeedsLayout()"어질러졌음"이라는 쪽지를 붙여 두는 것입니다. 붙이는 데 1초도 안 걸리고, 열 번 붙여도 쪽지는 한 장이에요. 실제 정리는 안 합니다.

layoutIfNeeded()"지금 당장 정리해"입니다. 쪽지가 붙어 있으면 그 자리에서 치웁니다. 쪽지가 없으면? 아무것도 안 합니다("IfNeeded"니까요).

왜 쪽지 방식이 좋을까요? 물건을 열 개 옮긴다고 해 봅시다. 하나 옮길 때마다 방을 전부 재배치하면 열 번 재배치입니다. 그런데 중간 아홉 번의 결과는 아무도 안 봐요 — 문은 마지막에 한 번 열리니까요. 그러니 다 옮기고 한 번만 정리하면 됩니다. 결과는 똑같고 일은 10분의 1이죠.

그리고 여기 숨은 이득이 하나 더 있습니다. 한 번에 정리하면 어떤 순서로 옮겼든 결과가 같습니다. 하나씩 정리하면 순서에 따라 중간 모습이 달라지고, 그게 "어떨 땐 되고 어떨 땐 안 되는" 버그가 됩니다.

애니메이션은 왜 순서가 중요할까요? 애니메이션은 "시작 모습"과 "끝 모습"을 알아야 그 사이를 부드럽게 이어 줍니다. 그런데 쪽지가 붙은 채로 시작하면, 시스템이 생각하는 "시작 모습"이 내가 보고 있는 화면과 다를 수 있어요. 그래서 먼저 한 번 정리해서 시작 모습을 확정하고, 그다음에 목표를 정하고, 애니메이션 안에서 정리를 시킵니다.

마지막으로 가장 좋은 방법은 따로 있습니다. 정리가 필요 없는 방식으로 움직이는 것이에요. 가구를 실제로 옮기는 대신 사진을 확대·이동해서 보여주면(transform) 방은 그대로고 보이는 것만 바뀝니다. 스크롤할 때마다 헤더 크기를 바꾸는 것 같은 매 프레임 변화는 이렇게 처리해야 합니다.

🔑 한 문장

setNeedsLayout"더럽다고 표시", layoutIfNeeded"지금 청소해". 기본은 표시만 하고 맡기는 것이고, 그 이유는 지연과 병합이라는 같은 전략의 앞뒤 — 미룬 일은 모여서 한 번에 처리되고, 그래야 제약 해결기를 한 번만 돌린다.

🎓 완주 12주제를 마치며

12개 주제는 사실 세 가지 CS 아이디어로 압축됩니다.

① 지연 — CoW(04), 지연 디코딩(11), 더티 플래그(12). 필요해질 때까지 미룬다.
② 정적 지식으로 동적 비용 없애기 — 타입 안정성(02·06), 정적 디스패치(05), 이스케이프 분석(10), Sendable(07). 컴파일 타임에 알면 런타임에 안 해도 된다.
③ 공유를 줄여 추론 가능성 얻기 — 단방향 흐름(01), 값 타입(04), actor 격리(07), 순환 끊기(09). 공유 가변 상태가 복잡도의 근원이다.

면접에서 주제가 바뀌어도 이 셋 중 하나로 착지시킬 수 있으면, 12개를 외운 게 아니라 하나를 이해한 것이 드러납니다.

설계 근거 · 1차 자료

이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.

📌 각 자료가 뒷받침하는 것
  • Badros·Borning·Stuckey — 증분 제약 해결이 왜 "모아서 한 번"에 유리한지의 이론
  • Apple — "더티일 때만 즉시 실행"이라는 계약의 공식 서술
  • Apple — 무효화/실행/콜백 세 쌍의 정의