← SwiftUI 로드맵 5 / 5

05 · UIKit과 함께 살기 + 성능

UIViewRepresentable와 Coordinator, UIHostingController로 화면 단위 점진 도입. Self._printChanges()와 느린 업데이트 4대 원인, ForEach의 일정 개수 규칙.
진행률
0 / 0 완료
현실의 iOS 앱은 전부 SwiftUI로 새로 짜지 않는다. 이미 있는 UIKit 코드 위에 화면 단위로 얹는다. 이 챕터는 두 프레임워크를 양방향으로 잇는 방법과, 그렇게 만든 화면이 느려질 때 어디를 먼저 보는지를 다룬다.

1. SwiftUI 안에 UIKit — UIViewRepresentable

이미 있는 UIView(혹은 UIViewController)를 SwiftUI 계층에 넣는 표준 경로다. Apple UIViewRepresentable:

📝 Apple 원문

"The creation and update processes parallel the behavior of SwiftUI views, and you use them to configure your view with your app's current state information."

즉 UIKit 뷰가 SwiftUI의 생명주기 규칙 안으로 들어온다. 만들기 · 갱신하기 · 정리하기 세 단계다.

makeUIView(context:) 단 한 번 — 뷰 생성 updateUIView(_:context:) 상태가 바뀔 때마다 반복 반복 dismantleUIView(_:coordinator:) 사라질 때 정리 Coordinator delegate · target-action 수신 SwiftUI 상태 @Binding · @State delegate 지정 값 되돌리기 상태 변경 → update 재호출 🚨 이 UIKit 뷰의 frame · bounds · center · transform 을 직접 세팅하지 말 것
UIKit의 "델리게이트로 알려주기"는 SwiftUI에서 자동으로 이어지지 않는다. 그 연결을 사람이 놓는 자리가 Coordinator다.
🚨 반드시 지킬 경고 — 실무에서 제일 많이 밟는 지뢰

Apple 원문: "SwiftUI fully controls the layout of the UIKit view's frame, bounds, center, and transform properties. Don't directly set these layout-related properties on the view managed by a UIViewRepresentable instance from your own code because that conflicts with SwiftUI and results in undefined behavior."

UIKit 코드를 그대로 옮기면서 view.frame = ...이 한 줄 남아 있으면, 증상이 기기·OS 버전마다 다르게 나타나서 원인을 찾기 매우 어렵다. 4챕터의 3단계 협상이 SwiftUI 소관이라는 뜻이다.

2. Coordinator — 델리게이트를 상태로 되돌리는 다리

Apple이 명시한다: "The system doesn't automatically communicate changes occurring within your view to other parts of your SwiftUI interface... you must provide a Coordinator instance to facilitate those interactions. For example, you use a coordinator to forward target-action and delegate messages from your view to any SwiftUI views."

UITextView를 감싸는 최소 구현 — 3챕터의 "진실은 부모에" 원칙 그대로
struct RichTextView: UIViewRepresentable {
    @Binding var text: String            // 진실은 SwiftUI 쪽에 있다

    func makeUIView(context: Context) -> UITextView {
        let tv = UITextView()
        tv.delegate = context.coordinator // 다리를 연결
        return tv
    }

    func updateUIView(_ tv: UITextView, context: Context) {
        context.coordinator.parent = self       // ① 코디네이터가 최신 값을 보게 갱신
        if tv.text != text { tv.text = text }   // ② 같은 값이면 건드리지 않는다(커서 튐 방지)
    }

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    final class Coordinator: NSObject, UITextViewDelegate {
        var parent: RichTextView
        init(parent: RichTextView) { self.parent = parent }

        // UIKit 이벤트 → SwiftUI 상태
        func textViewDidChange(_ tv: UITextView) { parent.text = tv.text }
    }
}
🔑 ①번 줄이 왜 필요한가

makeCoordinator()한 번만 호출된다. 반면 뷰 struct(self)는 1챕터에서 봤듯 업데이트마다 새로 만들어진다. 그래서 코디네이터가 생성 시점의 self(또는 그때의 바인딩)를 붙잡고만 있으면, 나중에 낡은 값을 보고 동작할 위험이 있다. updateUIView는 매 업데이트마다 호출되니, 여기서 parent를 새로 갈아 주는 것이 정석이다.

⚠️ updateUIView에서 무조건 대입하지 말 것

tv.text = text를 조건 없이 쓰면, 타이핑 → 상태 변경 → updateUIView → 대입 → 커서가 맨 끝으로 튀는 고전적 버그가 난다. 값이 실제로 다를 때만 대입한다. updateUIView는 1챕터의 body처럼 여러 번 호출되는 함수라고 생각해야 한다.

멤버호출 시점여기서 할 일
makeUIView(context:)한 번생성 · 델리게이트 연결 · 변하지 않는 설정
updateUIView(_:context:)상태가 바뀔 때마다상태를 UIKit에 반영 (차이가 있을 때만)
makeCoordinator()한 번델리게이트 객체 생성
static dismantleUIView(_:coordinator:)사라질 때static이다 — 인스턴스 메서드로 쓰면 호출되지 않는다
🚨 dismantleUIViewstatic이다

나머지 셋과 달리 이것만 타입 메서드다. Apple 문서의 실제 선언:

developer.apple.com — UIViewRepresentable.dismantleUIView(_:coordinator:)
@MainActor @preconcurrency
static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)

static을 빼고 인스턴스 메서드로 작성하면 컴파일은 되지만 프로토콜 요구사항을 만족시키지 못해 호출되지 않는다. 옵서버 해제를 여기 넣었다면 조용히 누수된다. self에 접근할 수 없는 것도 이 때문이며, 정리에 필요한 상태는 coordinator에 담아 둬야 한다.

ViewController를 감쌀 때 — UIViewControllerRepresentable

기존 앱에서 재사용할 게 UIView보다 UIViewController인 경우가 훨씬 많다. 구조는 같고 이름만 바뀐다.

기존 차트 VC를 SwiftUI 화면 안에서 재사용하기
struct LegacyChart: UIViewControllerRepresentable {
    var points: [Double]
    var onSelect: (Int) -> Void            // 콜백은 클로저로 받는다

    func makeUIViewController(context: Context) -> ChartViewController {
        let vc = ChartViewController()
        vc.delegate = context.coordinator   // 다리 연결
        return vc
    }

    func updateUIViewController(_ vc: ChartViewController, context: Context) {
        context.coordinator.parent = self   // 최신 값으로 갱신
        if vc.points != points {            // 달라졌을 때만
            vc.points = points
            vc.redraw()
        }
    }

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    // 여기도 static 이다 (dismantleUIView 와 동일)
    static func dismantleUIViewController(_ vc: ChartViewController,
                                          coordinator: Coordinator) {
        vc.cancelPendingRedraw()
    }

    final class Coordinator: NSObject, ChartViewControllerDelegate {
        var parent: LegacyChart
        init(parent: LegacyChart) { self.parent = parent }

        func chart(_ vc: ChartViewController, didSelect index: Int) {
            parent.onSelect(index)          // UIKit 이벤트 → SwiftUI 클로저
        }
    }
}

// 쓰는 쪽에서는 평범한 SwiftUI 뷰다
struct DashboardView: View {
    @State private var selected: Int?

    var body: some View {
        VStack {
            LegacyChart(points: [1, 4, 2, 8]) { selected = $0 }
                .frame(height: 220)         // 크기는 SwiftUI가 정한다
            if let selected {
                Text("\(selected)번 선택됨")
            }
        }
    }
}
📝 확인한 선언

dismantleUIViewControllerstatic이다 — Apple 문서 실제 선언: @MainActor @preconcurrency static func dismantleUIViewController(_ uiViewController: Self.UIViewControllerType, coordinator: Self.Coordinator). 두 Representable이 같은 규칙을 따른다.

3. UIKit 안에 SwiftUI — UIHostingController

기존 앱에 화면 단위로 점진 도입하는 경로다. Apple UIHostingController:

📝 Apple 원문

"Create a UIHostingController object when you want to integrate SwiftUI views into a UIKit view hierarchy... Use the hosting controller like you would any other view controller, by presenting it or embedding it as a child view controller."

핵심은 마지막 문장이다 — 그냥 평범한 UIViewController다. 기존 내비게이션·코디네이터·라우터 구조를 바꾸지 않아도 된다.

① 화면 전체를 교체 — 기존 내비게이션에 그냥 push
let vc = UIHostingController(rootView: SettingsView())
navigationController?.pushViewController(vc, animated: true)   // 평범한 VC처럼

더 실전에 가까운 건 화면의 일부만 SwiftUI로 바꾸는 것이다. 자식 뷰 컨트롤러로 심는다.

② 화면 일부만 교체 — 자식 VC로 embed
final class DashboardViewController: UIViewController {
    private var summary: UIHostingController<SummaryCardView>?

    override func viewDidLoad() {
        super.viewDidLoad()

        let host = UIHostingController(rootView: SummaryCardView())
        summary = host

        addChild(host)                                   // ① 자식 VC로 등록
        host.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(host.view)
        NSLayoutConstraint.activate([                    // ② 배치는 UIKit이 한다
            host.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])
        host.didMove(toParent: self)                     // ③ 빠뜨리면 생명주기가 안 간다
    }

    // 데이터가 바뀌면 rootView를 갈아 끼운다
    func update(with model: Summary) {
        summary?.rootView = SummaryCardView(model: model)
    }
}
⚠️ 앞 절의 "frame 만지지 말라"와 헷갈리지 말 것

방향이 반대다. SwiftUI 안의 UIKit 뷰(UIViewRepresentable)는 SwiftUI가 배치를 소유하므로 frame을 건드리면 안 된다. 반대로 UIKit 안의 SwiftUI 뷰(UIHostingController)는 UIKit이 배치를 소유하므로 제약을 걸어 주는 게 정상이고 필수다.

기준은 단순하다 — 누가 부모인지가 배치를 정한다.

🚨 didMove(toParent:)를 빠뜨리면

addChild만 하고 didMove(toParent:)를 안 부르면 컨테인먼트가 반쯤만 성립한다. viewWillAppear 계열 전달과 회전·트레잇 변경 알림이 어긋나고, SwiftUI 쪽 .onAppear·.task가 기대한 시점에 안 오는 것처럼 보인다. 증상이 "SwiftUI가 이상하다"로 보이지만 원인은 UIKit 컨테인먼트다.

기존 UIKit 앱 (UINavigationController) UIHostingController(rootView:) SwiftUI 화면 (SettingsView) UIViewRepresentable — 기존 UIKit 차트 뷰 재사용 양방향으로 겹칠 수 있다 → 전면 재작성 없이 화면 단위로 이행
실무 이행 전략: 새 화면부터 SwiftUI로 만들고, 그 안에서 검증된 UIKit 컴포넌트는 그대로 재사용한다.
🔑 점진 도입 순서 (실무 추천)

새로 만드는 화면부터 SwiftUI로 (기존 코드 리스크 0) → ② 그 안에서 검증된 UIKit 뷰는 Representable로 재사용 → ③ 상태 설계가 익숙해진 뒤 단순한 기존 화면(설정·약관·정적 목록)을 교체 → ④ 복잡한 화면은 마지막에. 2챕터의 상태 설계가 손에 익기 전에 복잡한 화면부터 건드리면 반드시 되돌아온다.

4. 느려졌을 때 — 진단 루프와 _printChanges

WWDC23 — Demystify SwiftUI performance가 제시하는 순서는 단순하다: 증상 → 측정 → 원인 식별 → 최적화 → 재검증. 추측으로 고치지 말라는 뜻이다.

가장 값싼 첫 도구는 Self._printChanges()다. "이 뷰가 왜 갱신됐는지"를 콘솔에 찍어 준다.

디버그 전용 — body 맨 앞에 한 줄
var body: some View {
    Self._printChanges()      // 무엇이 이 갱신을 유발했는지 출력
    return VStack { /* ... */ }
}
⚠️ 디버그 전용 API

_로 시작하는 비공개 API다. 커밋에 남기지 말고 진단이 끝나면 지운다.

5. 느린 업데이트의 4대 원인

WWDC23이 꼽은 원인들이다. 앞 챕터들과 정확히 연결된다.

상태 변경 뷰 init ① 비싼 초기화 body 계산 ② 무거운 body 식별 ③ 느린 id 렌더 ④ 불필요한 의존성 → 갱신 자체가 과하다 ① 비싼 init.task {} 로 미룬다 · 2챕터 @State 기본값 함정 ② 무거운 body필터·정렬·문자열 조립을 모델로 내리고 캐시 ③ 느린 식별id 계산을 싸게 · 요소당 뷰 개수 일정하게 · 3챕터 ④ 과한 의존성뷰를 쪼개고 @Observable 로 세분화 · 2챕터
네 지점 모두 앞 챕터에서 이미 본 개념이다. 성능 최적화가 별도 기술이 아니라 구조를 제대로 잡은 결과인 이유다.
❌ body에서 매번 필터링·정렬·문자열 조립
var body: some View {
    let visible = items.filter { $0.isActive }.sorted { $0.date > $1.date }
    return List(visible) { item in
        Text("\(item.name) · \(formatter.string(from: item.date))")   // 매 갱신마다 포매팅
    }
}
✅ 모델이 미리 계산해 두고, body는 읽기만 한다
@Observable final class ItemListModel {
    private(set) var visible: [ItemRow] = []     // 이미 필터·정렬·포매팅된 결과

    func reload() { /* 여기서 한 번만 계산 */ }
}

var body: some View {
    List(model.visible) { row in
        Text(row.displayTitle)                   // 조립된 문자열을 그대로
    }
}

6. List · ForEach 규칙 — 요소당 뷰 개수를 일정하게

이건 SwiftUI 리스트 성능의 가장 중요한 단일 규칙이고, Apple ForEach 문서에 직접 적혀 있다.

📝 Apple 원문

"Some containers like List or LazyVStack will query the elements within a for each lazily. To obtain maximal performance, ensure that the view created from each element in the collection represents a constant number of views."

이유: List는 화면에 보이는 셀만 만들지만, 전체 행 개수는 미리 알아야 스크롤바와 스크롤 위치를 계산할 수 있다. 요소마다 뷰 개수가 다르면 그 계산을 위해 전부 훑어야 한다.

❌ 요소당 개수가 다르다 ✅ 요소당 항상 1개 ForEach { if 조건 { Text() } } 요소 A → 1개 요소 B → 0개 요소 C → 1개 요소 D → 0개 전체 행 수를 곱셈으로 못 구한다 → 전부 훑어야 함 (slow path) 인라인 filter · 조건부 셀 · AnyView 모두 같은 문제 ForEach { VStack { if 조건 {…} } } 요소 A → 1개 요소 B → 1개 요소 C → 1개 요소 D → 1개 행 수 = 요소 수 × 1 → 즉시 계산 (fast path) 더 좋은 답: 모델에서 미리 걸러서 넘긴다
Apple 문서의 행 수 공식: Lists: Row count = element count × views per element. 곱셈이 성립해야 빠르다.
Apple 문서의 예시 — 비일정 → 일정으로
// ❌ 요소마다 1개 또는 0개
ForEach(namedFonts) { namedFont in
    if namedFont.name.count != 2 { Text(namedFont.name) }
}

// ✅ 조건을 스택으로 감싸 개수를 고정
ForEach(namedFonts) { namedFont in
    VStack {
        if namedFont.name.count != 2 { Text(namedFont.name) }
    }
}
🔑 진단 런치 인자

Apple이 제공하는 감지 옵션이 있다. 스킴의 Arguments에 추가하면 비일정 개수를 만드는 뷰를 콘솔에 로그한다.

Scheme → Run → Arguments Passed On Launch
-LogForEachSlowPath YES
🚨 같은 문제를 만드는 3가지 패턴

WWDC23이 꼽은 것들 — 모두 "요소당 개수를 알 수 없게" 만든다.

인라인 필터링: ForEach(dogs.filter { ... }) → 모델에서 미리 걸러라.
조건부 셀: if dog.hasToy { DogCell(dog) } → 스택으로 감싸거나 데이터를 나눠라.
AnyView: 개수를 알 수 없다 → 3챕터에서 본 대로 @ViewBuilder로 대체.

❌ 세 패턴이 한 화면에 다 모인 흔한 코드
List {
    // ① 인라인 필터 — 매 갱신마다 필터가 돌고, 행 수를 미리 못 구한다
    ForEach(dogs.filter { $0.isAdopted }) { dog in
        // ② 조건부 셀 — 요소당 1개 또는 0개
        if dog.hasToy {
            // ③ AnyView — 개수를 알 수 없다
            AnyView(DogCell(dog: dog))
        }
    }
}
✅ 필터는 모델로, 분기는 셀 안으로, AnyView는 제거
@Observable final class Kennel {
    private(set) var all: [Dog] = []
    private(set) var adopted: [Dog] = []      // 미리 걸러 둔 결과

    func reload(_ dogs: [Dog]) {
        all = dogs
        adopted = dogs.filter(\.isAdopted)    // 데이터가 바뀔 때 한 번만
    }
}

List {
    // 요소당 정확히 1개 — 행 수 = adopted.count × 1
    ForEach(kennel.adopted) { dog in
        DogCell(dog: dog)                     // 분기는 셀 내부로 내렸다
    }
}

struct DogCell: View {
    let dog: Dog
    var body: some View {
        HStack {
            Text(dog.name)
            if dog.hasToy {                   // 셀 안의 분기는 행 수에 영향 없다
                Image(systemName: "star.fill")
            }
        }
    }
}
🔑 왜 셀 안으로 내리면 괜찮은가

List가 알아야 하는 건 "행이 몇 개냐"다. DogCell은 어떤 경우에도 행 하나이므로 곱셈이 성립한다. 그 안에서 아이콘이 하나 늘든 말든 행 수는 그대로다. 분기를 없애는 게 아니라, 행 경계 안쪽으로 옮기는 것이 요령이다.

증상가장 먼저 볼 곳처방
타이핑·스크롤이 버벅인다Self._printChanges()불필요한 의존성 제거 · 뷰 쪼개기
화면 진입이 느리다init · @State 기본값.task {}로 지연 생성
큰 목록 첫 표시가 느리다ForEach 안의 조건·필터요소당 개수 고정 · 모델에서 필터
관계없는 뷰까지 갱신된다ObservableObject 사용 여부@Observable로 이행 (2챕터)
입력 중 커서가 튄다updateUIView의 무조건 대입값이 다를 때만 대입
UIKit 뷰 위치가 이상하다frame/transform 직접 세팅제거 — SwiftUI 소관

7. 애니메이션 — 상태 변화에 붙인다

UIKit의 UIView.animate는 "이 블록 안의 변경을 애니메이션해라"였다. SwiftUI는 "이 값이 바뀌면 애니메이션해라"다. 3챕터를 떠올리면 자연스럽다 — 화면은 상태의 함수이므로, 애니메이션도 상태 변화에 붙는다.

두 가지 방식
// ① 특정 값의 변화에 애니메이션을 붙인다 (권장)
Circle()
    .scaleEffect(scale)
    .animation(.easeIn, value: scale)

// ② 상태를 바꾸는 쪽에서 감싼다
withAnimation(.easeIn) { scale += 0.1 }
⚠️ 3챕터와 연결

애니메이션이 "부드럽게" 대신 "깜빡" 바뀌면 값이 아니라 identity가 바뀐 것이다. if/else로 뷰를 갈아탔거나 .id()가 바뀌었는지 확인하라. 보간할 대상이 같은 뷰여야 애니메이션이 성립한다.

이 챕터 요약

🟣 여기서 멈추고, 다음에 볼 것 로드맵

2시간으로 핵심 20%를 관통했다. 이 뒤는 필요할 때 찾아가면 되는 롱테일이다.

· 비동기·동시성.task, async/await, actor 격리 → 이 사이트의 Swift Concurrency 강의
· 커스텀 레이아웃Layout 프로토콜, alignmentGuide, PreferenceKey
· 애니메이션 심화matchedGeometryEffect, transition, Animatable 직접 구현
· 데이터 영속화 — SwiftData, @Query
· 최신 디자인 시스템 — iOS 26의 Liquid Glass 등 (이 강의는 버전 무관 개념 위주로 다뤘다)

출처 · 참고자료