1. SwiftUI 안에 UIKit — UIViewRepresentable
이미 있는 UIView(혹은 UIViewController)를 SwiftUI 계층에 넣는 표준 경로다. Apple UIViewRepresentable:
"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의 생명주기 규칙 안으로 들어온다. 만들기 · 갱신하기 · 정리하기 세 단계다.
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."
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를 새로 갈아 주는 것이 정석이다.
tv.text = text를 조건 없이 쓰면, 타이핑 → 상태 변경 → updateUIView → 대입 → 커서가 맨 끝으로 튀는 고전적 버그가 난다. 값이 실제로 다를 때만 대입한다. updateUIView는 1챕터의 body처럼 여러 번 호출되는 함수라고 생각해야 한다.
| 멤버 | 호출 시점 | 여기서 할 일 |
|---|---|---|
makeUIView(context:) | 한 번 | 생성 · 델리게이트 연결 · 변하지 않는 설정 |
updateUIView(_:context:) | 상태가 바뀔 때마다 | 상태를 UIKit에 반영 (차이가 있을 때만) |
makeCoordinator() | 한 번 | 델리게이트 객체 생성 |
static dismantleUIView(_:coordinator:) | 사라질 때 | static이다 — 인스턴스 메서드로 쓰면 호출되지 않는다 |
dismantleUIView는 static이다나머지 셋과 달리 이것만 타입 메서드다. Apple 문서의 실제 선언:
@MainActor @preconcurrency
static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)static을 빼고 인스턴스 메서드로 작성하면 컴파일은 되지만 프로토콜 요구사항을 만족시키지 못해 호출되지 않는다. 옵서버 해제를 여기 넣었다면 조용히 누수된다. self에 접근할 수 없는 것도 이 때문이며, 정리에 필요한 상태는 coordinator에 담아 둬야 한다.
ViewController를 감쌀 때 — UIViewControllerRepresentable
기존 앱에서 재사용할 게 UIView보다 UIViewController인 경우가 훨씬 많다. 구조는 같고 이름만 바뀐다.
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)번 선택됨")
}
}
}
}dismantleUIViewController도 static이다 — Apple 문서 실제 선언: @MainActor @preconcurrency static func dismantleUIViewController(_ uiViewController: Self.UIViewControllerType, coordinator: Self.Coordinator). 두 Representable이 같은 규칙을 따른다.
3. UIKit 안에 SwiftUI — UIHostingController
기존 앱에 화면 단위로 점진 도입하는 경로다. Apple UIHostingController:
"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다. 기존 내비게이션·코디네이터·라우터 구조를 바꾸지 않아도 된다.
let vc = UIHostingController(rootView: SettingsView())
navigationController?.pushViewController(vc, animated: true) // 평범한 VC처럼더 실전에 가까운 건 화면의 일부만 SwiftUI로 바꾸는 것이다. 자식 뷰 컨트롤러로 심는다.
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)
}
}방향이 반대다. SwiftUI 안의 UIKit 뷰(UIViewRepresentable)는 SwiftUI가 배치를 소유하므로 frame을 건드리면 안 된다. 반대로 UIKit 안의 SwiftUI 뷰(UIHostingController)는 UIKit이 배치를 소유하므로 제약을 걸어 주는 게 정상이고 필수다.
기준은 단순하다 — 누가 부모인지가 배치를 정한다.
didMove(toParent:)를 빠뜨리면addChild만 하고 didMove(toParent:)를 안 부르면 컨테인먼트가 반쯤만 성립한다. viewWillAppear 계열 전달과 회전·트레잇 변경 알림이 어긋나고, SwiftUI 쪽 .onAppear·.task가 기대한 시점에 안 오는 것처럼 보인다. 증상이 "SwiftUI가 이상하다"로 보이지만 원인은 UIKit 컨테인먼트다.
① 새로 만드는 화면부터 SwiftUI로 (기존 코드 리스크 0) → ② 그 안에서 검증된 UIKit 뷰는 Representable로 재사용 → ③ 상태 설계가 익숙해진 뒤 단순한 기존 화면(설정·약관·정적 목록)을 교체 → ④ 복잡한 화면은 마지막에. 2챕터의 상태 설계가 손에 익기 전에 복잡한 화면부터 건드리면 반드시 되돌아온다.
4. 느려졌을 때 — 진단 루프와 _printChanges
WWDC23 — Demystify SwiftUI performance가 제시하는 순서는 단순하다: 증상 → 측정 → 원인 식별 → 최적화 → 재검증. 추측으로 고치지 말라는 뜻이다.
가장 값싼 첫 도구는 Self._printChanges()다. "이 뷰가 왜 갱신됐는지"를 콘솔에 찍어 준다.
var body: some View {
Self._printChanges() // 무엇이 이 갱신을 유발했는지 출력
return VStack { /* ... */ }
}_로 시작하는 비공개 API다. 커밋에 남기지 말고 진단이 끝나면 지운다.
5. 느린 업데이트의 4대 원인
WWDC23이 꼽은 원인들이다. 앞 챕터들과 정확히 연결된다.
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))") // 매 갱신마다 포매팅
}
}@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 문서에 직접 적혀 있다.
"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개 또는 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에 추가하면 비일정 개수를 만드는 뷰를 콘솔에 로그한다.
-LogForEachSlowPath YESWWDC23이 꼽은 것들 — 모두 "요소당 개수를 알 수 없게" 만든다.
① 인라인 필터링: 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))
}
}
}@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 }애니메이션이 "부드럽게" 대신 "깜빡" 바뀌면 값이 아니라 identity가 바뀐 것이다. if/else로 뷰를 갈아탔거나 .id()가 바뀌었는지 확인하라. 보간할 대상이 같은 뷰여야 애니메이션이 성립한다.
이 챕터 요약
UIViewRepresentable:make(1회) ·update(반복) ·Coordinator(델리게이트 다리).frame·transform은 절대 직접 세팅하지 않는다.updateUIView에서 값이 다를 때만 대입한다 — 아니면 커서가 튄다.UIHostingController는 평범한UIViewController다 → 기존 내비게이션 구조 그대로 화면 단위 점진 도입.- 느려지면 추측하지 말고
Self._printChanges()부터. 원인은 비싼 init · 무거운 body · 느린 식별 · 과한 의존성 넷 중 하나다. ForEach는 요소당 뷰 개수가 일정해야 한다. 인라인 필터·조건부 셀·AnyView가 이를 깬다.- 애니메이션은 상태 변화에 붙는다. 깜빡 바뀌면 identity를 의심하라.
2시간으로 핵심 20%를 관통했다. 이 뒤는 필요할 때 찾아가면 되는 롱테일이다.
· 비동기·동시성 — .task, async/await, actor 격리 → 이 사이트의 Swift Concurrency 강의
· 커스텀 레이아웃 — Layout 프로토콜, alignmentGuide, PreferenceKey
· 애니메이션 심화 — matchedGeometryEffect, transition, Animatable 직접 구현
· 데이터 영속화 — SwiftData, @Query
· 최신 디자인 시스템 — iOS 26의 Liquid Glass 등 (이 강의는 버전 무관 개념 위주로 다뤘다)