label.text = "3". SwiftUI에서 그 문장은 사라진다.
대신 상태를 바꾸고, 화면이 어떻게 생겼는지 설명해 둔다. 이 챕터는 그 교체를 머릿속에서 끝내는 게 목표다.
1. UIKit은 지시하고, SwiftUI는 설명한다
Apple이 이 차이를 문서에 직접 써 놨다. Declaring a custom view의 첫 문단이다.
"With a traditional imperative approach, the burden is on your controller code not only to instantiate, lay out, and configure views, but also to continually make updates as conditions change. In contrast, with a declarative approach, you create a lightweight description of your user interface... SwiftUI then manages drawing and updating these views."
번역하면: UIKit에서 "조건이 바뀔 때마다 화면을 계속 맞춰 주는 일"은 내 책임이었다. SwiftUI에서 그 책임은 프레임워크로 넘어간다. 내가 하는 일은 설명서를 정확히 쓰는 것뿐이다.
같은 화면, 두 프레임워크
말로만 하면 안 붙는다. 버튼을 누르면 숫자가 1 올라가는 화면을 양쪽으로 써 보자.
final class CounterViewController: UIViewController {
private var count = 0 // 진실 ①: 데이터
private let label = UILabel() // 진실 ②: 화면에 보이는 값
private let button = UIButton(type: .system)
override func viewDidLoad() {
super.viewDidLoad()
label.text = "\(count)" // 최초 동기화
button.setTitle("+1", for: .normal)
button.addTarget(self, action: #selector(tap), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [label, button])
stack.axis = .vertical
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack) // 계층에 직접 꽂는다
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
@objc private func tap() {
count += 1
label.text = "\(count)" // ⚠️ 이 줄을 빠뜨리면 그게 버그다
}
}struct CounterView: View {
@State private var count = 0 // 진실은 하나뿐
var body: some View {
VStack {
Text("\(count)") // count에서 파생된 결과
Button("+1") { count += 1 } // 상태만 바꾼다
}
}
}오른쪽에 label.text = ...에 해당하는 줄이 없다. 뷰를 저장할 프로퍼티도, addSubview도, 제약도, @objc 셀렉터도, viewDidLoad도 없다. 27줄이 8줄이 된 건 문법이 짧아서가 아니라 할 일이 사라져서다 — "데이터와 화면을 맞추는 일"이 전부 프레임워크로 넘어갔다.
실무에서 이 차이가 체감되는 지점은 버그의 모양이다. UIKit에서는 "상태는 맞는데 UI가 안 바뀐다"(갱신 호출 누락)가 흔하다. SwiftUI에서는 그 버그가 구조적으로 사라지고, 대신 "상태가 틀렸다"는 버그만 남는다. 디버깅이 렌더링 추적에서 데이터 흐름 추적으로 옮겨간다.
UIKit은 인형극이다. 인형(뷰 객체)을 손에 쥐고, 팔을 직접 들어 올린다. 인형이 지금 어떤 자세인지 내가 기억해야 한다.
SwiftUI는 레시피다. "설탕이 2스푼이면 케이크는 이렇게 생긴다"를 적어 두면, 설탕을 3스푼으로 바꿨을 때 케이크를 다시 만드는 건 주방(프레임워크)이 한다. 내가 케이크를 손으로 고치지 않는다.
2. View 프로토콜과 body — 프레임워크가 언제 읽는가
SwiftUI 뷰는 View 프로토콜을 따르는 구조체(struct)다. 요구사항은 사실상 하나, body다.
struct MyView: View {
var body: some View {
Text("Hello, World!")
}
}body가 언제 읽히는지가 핵심이다. Apple 문서:
"SwiftUI reads the value of this property any time it needs to update the view, which can happen repeatedly during the life of the view, typically in response to user input or system events."
즉 body는 한 번 실행되고 끝나는 초기화 코드가 아니다. 수십, 수백 번 다시 호출되는 함수다. 여기서 UIKit 습관 두 개가 곧바로 깨진다.
body에서 무거운 계산을 하면 그 비용이 매 업데이트마다 반복된다 (5챕터에서 다시 다룬다).body는 "지금 상태면 이렇게 생겼다"만 말해야 한다. 여기서 상태를 바꾸거나 네트워크를 호출하면 갱신이 다시 갱신을 부른다.
믿기지 않으면 직접 세어 보면 된다. 버튼을 열 번 누르고 콘솔을 보라.
struct BodyCallCounter: View {
@State private var count = 0
var body: some View {
print("body 호출됨 — count=\(count)") // ⚠️ 실험용. 실제 코드에 남기지 말 것
return VStack { // print를 쓰면 명시적 return이 필요하다
Text("\(count)")
Button("+1") { count += 1 }
}
}
}var body: some View {
if items.isEmpty {
loadItems() // ❌ 상태를 바꾼다 → body 재호출 → 또 호출 → …
}
return List(items) { ItemRow(item: $0) }
}var body: some View {
List(items) { ItemRow(item: $0) }
.task { await loadItems() } // 등장 시점에 한 번
}UIKit에서 viewDidLoad에 로딩을 넣던 습관이 body로 오면 이렇게 된다. body는 "그리는 함수"이지 "실행하는 함수"가 아니다.
some View — 왜 타입을 안 적어도 되나
some View는 "정확한 타입은 컴파일러가 알아서 정하되, View를 따른다는 것만 보장한다"는 뜻이다(opaque type). Apple의 설명:
"you declare the body property as an [opaque type], using the some View syntax, to indicate only that the body's type conforms to View. The exact type depends on the body's content, which varies as you edit the body during development. Swift infers the exact type automatically."
실제 타입은 사람이 손으로 쓸 만한 게 아니다. VStack 안에 Text 두 개를 넣으면 타입은 VStack<TupleView<(Text, Text)>>가 되고, 여기에 모디파이어를 붙이면 그 전체가 다시 ModifiedContent<…>로 한 겹 감싸인다. 중첩이 깊어질수록 타입은 계속 길어진다. 이 긴 타입이 낭비가 아니라 SwiftUI의 핵심 장치다 — 3챕터에서 이 타입이 "같은 뷰인지" 판단하는 근거로 쓰인다는 걸 본다.
여러 자식을 나열할 수 있는 이유 — ViewBuilder
VStack {
Text("Hello, World!")
Text("Glad to meet you.")
}이게 되는 건 @ViewBuilder 때문이다. Apple 설명대로 "view-producing closure parameter"에 붙는 속성으로, 여러 문장으로 된 클로저가 여러 자식 뷰를 만들어 반환하게 해 준다. 그래서 if도 클로저 안에서 그냥 쓸 수 있다.
struct MenuView: View {
let isSymbol: Bool
var body: some View {
VStack { // @ViewBuilder 클로저
Text("Cut")
Text("Copy")
Text("Paste")
if isSymbol { // 조건도 그대로
Text("Jump to Definition")
}
}
}
}내가 만든 함수에도 붙일 수 있다. 뷰를 조립하는 헬퍼를 만들 때 쓴다.
struct ProfileScreen: View {
let user: User
var body: some View {
VStack {
header // 아래에서 정의한 조각
badges(for: user)
}
}
@ViewBuilder
private var header: some View { // 프로퍼티에도 붙는다
Text(user.name).font(.title)
if let bio = user.bio {
Text(bio).foregroundStyle(.secondary)
}
}
@ViewBuilder
private func badges(for user: User) -> some View { // 함수에도 붙는다
if user.isPro { ProBadge() }
if user.isVerified { VerifiedBadge() }
}
}@ViewBuilder 클로저 안의 if는 평범한 분기처럼 보이지만 그렇지 않다. 이건 타입을 바꾸는 분기이고, 그래서 뷰의 identity에 영향을 준다. 3챕터에서 이것 때문에 상태가 날아가는 사고를 다룬다.
3. 모디파이어는 설정이 아니라 감싸기다
UIKit 눈으로 보면 .font(.title)은 프로퍼티 대입처럼 보인다. 아니다. Apple View 문서:
"A modifier is nothing more than a method called on a particular view. The method returns a new, altered view that effectively takes the place of the original in the view hierarchy."
"Modifiers work by wrapping the view instance on which you call them in another view with the specified characteristics."
즉 Text("Hi").padding()은 Text의 padding 속성을 켜는 게 아니라, Text를 감싸는 새로운 뷰를 만든다. 이 사실 하나가 초보자가 가장 많이 겪는 "왜 결과가 다르지?"를 전부 설명한다.
VStack(spacing: 20) {
// ① padding 먼저 → background가 여백까지 감싼다 (버튼처럼 보인다)
Text("저장")
.padding(12)
.background(Color.blue)
// ② background 먼저 → 배경은 글자에만 붙고, 여백은 배경 밖에 생긴다
Text("저장")
.background(Color.blue)
.padding(12)
}탭 영역이 안 넓어지는 문제가 대표적이다. .padding을 탭 영역을 만드는 모디파이어보다 나중에 붙이면, 여백은 눈에 보이지만 그 여백을 눌러도 반응하지 않는다.
// ❌ contentShape 이 padding 안쪽만 덮는다
HStack { Image(systemName: "trash"); Text("삭제") }
.onTapGesture { delete() }
.padding(16)
// ✅ 먼저 넓히고, 넓어진 영역 전체를 탭 대상으로 지정한다
HStack { Image(systemName: "trash"); Text("삭제") }
.padding(16)
.contentShape(Rectangle())
.onTapGesture { delete() }모디파이어 체인은 바깥에서 안으로 읽지 말고, 위에서 아래로 "감싸는 순서"로 읽어라. 마지막에 붙인 모디파이어가 가장 바깥 껍질이다.
"모디파이어는 감싼다"는 View 프로토콜의 기본 모디파이어 이야기다. 일부 타입은 자기 타입을 그대로 돌려주는 전용 오버로드를 따로 갖는다. Text.font(_:)의 실제 선언이 그 예다.
nonisolated func font(_ font: Font?) -> Text그래서 Text("Hi").font(.title)의 타입은 ModifiedContent<…>가 아니라 그냥 Text다. 실무 판단이 달라지지는 않는다(순서 규칙은 그대로 성립한다). 다만 타입을 직접 들여다볼 때 혼란을 피하려면 알아 둘 만하다 — Text끼리 +로 이어붙이는 게 되는 이유도 이것이다.
4. 뷰 init에서 일하지 마라
UIKit에서 init이나 viewDidLoad는 "한 번만 실행되는 준비 구역"이었다. 그 감각을 SwiftUI로 가져오면 성능 사고가 난다. Apple이 명시적으로 경고한다.
"If an input value changes, SwiftUI notices the change and redraws only the affected parts of your interface. This might involve reinitializing your entire view, but SwiftUI manages that for you."
"Because the system may reinitialize a view at any time, it's important to avoid doing any significant work in your view's initialization code. It's often best to omit an explicit initializer... allowing Swift to synthesize a member-wise initializer instead."
정리하면 뷰 struct는 언제든 몇 번이든 다시 만들어질 수 있는 일회용 값이다. 그 init에 네트워크 호출이나 무거운 파싱을 넣으면 업데이트마다 반복된다.
struct ReportView: View {
let rows: [Row]
init(raw: [RawRow]) {
// 뷰가 다시 만들어질 때마다 이 정렬·파싱이 또 돈다
self.rows = raw.map(Row.init).sorted { $0.date > $1.date }
}
var body: some View { List(rows) { RowCell(row: $0) } }
}struct ReportView: View {
let rows: [Row] // 명시적 init 없음 → 멤버와이즈 init 자동 생성
var body: some View { List(rows) { RowCell(row: $0) } }
}
// 무거운 준비가 꼭 뷰 안에서 필요하면 최초 등장 시 1회만
struct ReportLoaderView: View {
@State private var rows: [Row] = []
var body: some View {
List(rows) { RowCell(row: $0) }
.task { rows = await Report.load() } // 최초 등장 시, async 가능
}
}.task는 viewDidLoad와 같은 보장이 아니다Apple State 문서는 .task를 "called only once when the view first appears"라고 설명한다. 하지만 이건 뷰의 수명에 묶인 비동기 작업이라, viewDidLoad처럼 "객체가 사는 동안 무조건 정확히 한 번"과 동일한 보장은 아니다. 뷰가 사라지고 다시 등장하는 경로가 있는 화면에서는 "무조건 1회"에 로직을 의존시키지 말고 상태로 방어하라(예: 이미 불렀는지 플래그, 또는 값이 비었을 때만 로드).
뷰 struct는 주문서다. 주문서는 몇 장이든 다시 쓸 수 있고, 쓰는 데 1초도 안 걸려야 한다. 주문서를 쓸 때마다 재료를 사러 시장에 다녀오면(무거운 init) 주방이 멈춘다. 장보기는 .task에서 따로 한다.
5. 뷰 값 vs 실제 화면 — 무엇이 오래 사는가
여기까지 오면 이상하게 느껴지는 게 하나 있다. 뷰가 매번 새로 만들어진다면, 스크롤 위치나 입력 중인 텍스트는 어디에 남는가?
답은 SwiftUI가 따로 보관한다는 것이다. 그래서 @State 같은 장치가 필요하다 — 그게 2챕터, 그 보관함이 언제 비워지는지가 3챕터다.
6. UIKit 습관 → SwiftUI 번역표
이 표는 이 강의 전체의 색인이기도 하다. 지금은 "번역이 없는 칸"에 특히 주목하면 된다.
| UIKit 습관 | SwiftUI | 메모 |
|---|---|---|
UIView/UIViewController 서브클래스 (객체) | View 준수 struct (값) | 뷰 참조를 보관하지 않는다 |
viewDidLoad | .task { } | 최초 등장 시 · async 지원 (완전히 같은 보장은 아님 — 위 ⚠️ 참고) |
viewWillAppear | .onAppear { } | 등장할 때마다 |
label.text = "3" | Text(count.description) | 대입이 아니라 상태 변경 |
setNeedsLayout() / reloadData() | 없음 | "강제 갱신" 개념 자체가 없다 |
addSubview(_:) | body에 선언 | 계층은 코드 모양이 곧 구조 |
IBOutlet 참조 보관 | 없음 | 붙잡을 객체가 없다 |
| delegate / target-action | 클로저 · Binding | UIKit 래핑 시엔 Coordinator (5챕터) |
NSLayoutConstraint (양방향 제약) | 3단계 크기 협상 | 4챕터 — 방향이 다르다 |
prepareForReuse() | 없음 | 대신 identity로 관리 (3챕터) |
| ViewModel을 VC가 소유 | @State + @Observable | 2챕터 |
setNeedsLayout·reloadData·IBOutlet 자리가 "없음"인 게 우연이 아니다. SwiftUI에 "화면을 강제로 갱신하는 API"가 없는 건 미완성이어서가 아니라, 그게 필요 없는 구조라서다. 갱신을 강제하고 싶은 충동이 들면 대부분 상태 설계가 잘못됐다는 신호다.
// UIKit — 데이터를 바꾸고, 갱신을 명령한다
items.append(newItem)
tableView.reloadData() // ⚠️ 빠뜨리면 화면이 안 바뀐다
// 또는 부분 갱신
tableView.insertRows(at: [IndexPath(row: items.count - 1, section: 0)], with: .automatic)
// SwiftUI — 상태를 바꾸면 그걸로 끝
items.append(newItem) // items가 @State / @Observable 이면 이 한 줄이 전부// ❌ SwiftUI로 옮기면서 같이 가져오면 안 되는 습관들
private var titleLabel: UILabel! // 뷰 참조 보관 → 붙잡을 객체가 없다
override func viewDidLoad() { ... } // → .task / .onAppear
func updateUI() { // 상태와 화면을 맞추는 함수 자체가 불필요
titleLabel.text = model.title
badgeView.isHidden = !model.isPro
}
tableView.reloadData() // → 상태 변경
delegate?.didTapSave() // → 클로저 또는 @BindingView 프로토콜은 기본적으로 메인 액터에 격리된다. Apple 문서: "A type conforming to this protocol inherits @preconcurrency @MainActor isolation from the protocol if the conformance is declared in its original declaration." UIKit에서 "UI는 메인 스레드에서"를 손으로 지켰던 규칙이 타입 시스템으로 올라온 것이다.
이 챕터 요약
- UIKit은 뷰를 지시하고, SwiftUI는 상태에 대한 화면을 설명한다. 동기화 책임이 프레임워크로 넘어간다.
body는 초기화 코드가 아니라 반복 호출되는 함수다. 가볍고 순수하게 유지한다.- 모디파이어는 속성 설정이 아니라 감싸기다. 그래서 순서가 결과를 바꾼다.
- 뷰
init에서 무거운 일을 하지 않는다 — 언제든 재초기화될 수 있다. - 뷰 값은 일회용이고, 오래 사는 건 SwiftUI가 든 렌더 트리와 상태 저장소다.