label.text = "3", spinner.stopAnimating(), tableView.reloadData(). SwiftUI에서는 이런 줄이 한 줄도 없다. 대신 상태를 바꾸고, 그 상태일 때 화면이 어떻게 생겼는지 설명해 둔다. 오늘은 머릿속의 기본값을 지시(명령형)에서 설명(선언형)으로 바꾼다.오늘의 목표 · 30분
- UIKit 카운터 화면을 보고, SwiftUI로 옮길 때 지워지는 줄(뷰 참조·갱신 호출)을 짚어낸다.
body에 부수효과(네트워크·상태 변경)가 들어간 코드를 찾아.task나 액션으로 옮긴다.- 모디파이어 체인을 "안에서 바깥으로 감싸는 순서"로 읽고,
padding/background순서 버그를 고친다. - 뷰
init에서 무거운 일을 하는 코드를 찾아 멤버와이즈 init + 준비된 값으로 바꾼다.
1. 명령형 vs 선언형 — 누가 동기화를 책임지나 🔥 핵심
차이는 "조건이 바뀔 때마다 화면을 맞춰 주는 일"을 누가 하느냐다(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. … with a declarative approach, you create a lightweight description of your user interface … SwiftUI then manages drawing and updating these views."
풀이: 명령형은 뷰를 만들고 조건이 바뀔 때마다 고치는 짐이 컨트롤러에 있다. 선언형은 가벼운 "설명서"만 쓰고, 그리고 고치는 일은 SwiftUI가 한다.
같은 화면, 두 프레임워크
버튼을 누르면 숫자가 1 올라가는 화면이다. 줄 수보다 어떤 종류의 줄이 사라지는지를 본다.
import UIKit
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.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)" // ⚠️ 이 줄을 빠뜨리면 그게 버그다
}
}import SwiftUI
struct CounterView: View {
@State private var count = 0 // 진실은 하나뿐
var body: some View {
VStack {
Text("\(count)") // count에서 파생된 결과
Button("+1") { count += 1 } // 상태만 바꾼다
}
}
}SwiftUI 쪽에는 label.text = …, 뷰 저장 프로퍼티, addSubview, 제약, @objc 셀렉터가 없다. 문법이 짧아서가 아니라 "데이터와 화면을 맞추는 일" 자체가 사라져서다.
왜 이게 버그를 줄이나 — 이벤트 순서 폭발
WWDC20 · Introduction to SwiftUI에서 SwiftUI 팀은 UIKit 시절 실제 버그(멈추지 않는 activity indicator)를 예로 든다. 원인은 예상하지 못한 이벤트 순서였다. 이벤트 핸들러 4개가 불릴 수 있는 순서는 24가지이고, 12개면 12!, 약 4억 8천만 가지다.
"We've codified it in the framework by making 'body' the only entry point that is ever called." — WWDC20 · Introduction to SwiftUI
풀이: UIKit에서도 좋은 코드는 모든 갱신을 updateUI() 한 곳에 모았다. SwiftUI는 그 모범 사례를 프레임워크 규칙으로 만들었다 — 화면을 만드는 입구는 body 하나뿐이다.
선언형 안에도 명령형은 있다
WWDC24 · SwiftUI essentials는 둘이 배타적이지 않다고 말한다. 뷰 구조는 선언으로, 상태를 바꾸는 순간은 명령으로 쓴다. 그 대표가 Button의 액션이다.
struct PetListView: View {
@State private var pets = ["Whiskers", "Roofus"]
var body: some View {
List {
ForEach(pets, id: \.self) { Text($0) } // 선언: "이 배열이면 이런 목록"
Button("Add Pet") {
pets.append("Bubbles") // 명령: 상태를 바꾼다 (행 추가 코드는 없다)
}
}
}
}행을 추가하는 insertRows가 없다. 배열이 바뀌면 SwiftUI가 이전·새 목록을 비교해 삽입을 만든다(WWDC19 · SwiftUI Essentials).
2. View는 값이다 — body는 "설명서" 🔥 핵심
SwiftUI 뷰는 View 프로토콜을 따르는 struct다. 요구사항은 사실상 body 하나다(Apple 문서 · View).
| 관점 | UIKit UIView | SwiftUI View |
|---|---|---|
| 정체 | 클래스 인스턴스 (참조) | struct 값 (복사) |
| 공통 속성 | 상속으로 받음 (alpha, backgroundColor …) | 없음 — 모디파이어가 각각 따로 가짐 |
| 수명 | 화면에 있는 동안 계속 살아 있음 | 업데이트마다 새로 만들고 버림 |
| 변경 방법 | 속성에 대입 | 상태를 바꾸면 새 값이 만들어짐 |
| 참조 보관 | IBOutlet, 프로퍼티 | 불가 — 붙잡을 객체가 없다 |
근거는 WWDC19 · SwiftUI Essentials다. UIKit OrderCell: UIView는 alpha·backgroundColor 등 저장 프로퍼티를 전부 물려받는다. SwiftUI OrderRow는 자기 입력(let order: String)만 갖고, .opacity(0.8)는 뷰의 속성이 아니라 감싸는 모디파이어 뷰다. 공통 저장소가 필요 없으니 View는 프로토콜이 됐다.
"This means your view doesn't inherit any stored properties. It's allocated on the stack, and it's passed by value. … you should never hesitate to re-factor your SwiftUI code because extracting a subview has virtually no runtime overhead." — WWDC20 · Introduction to SwiftUI
풀이: 뷰 struct는 저장 프로퍼티를 물려받지 않고 값으로 전달된다. 그래서 서브뷰를 쪼개도 실행 비용이 거의 없다 — Day 2의 출발점이다.
body의 재귀는 어디서 끝나나
body는 또 다른 View를 돌려준다. 그 뷰도 body를 갖는다면 끝이 없어 보인다. 끝은 프리미티브 뷰다 — Text, Image, Color, Shape, Spacer처럼 자기 내용을 SwiftUI가 직접 그리는 뷰다(WWDC19 · SwiftUI Essentials).
some View — 정확한 타입은 컴파일러가 안다
Apple 문서는 body를 opaque type으로 선언하는 이유를 "정확한 타입은 body 내용에 따라 달라지고, Swift가 자동으로 추론한다"고 설명한다(Declaring a custom view). 실제 타입을 찍어 보면 이유가 보인다.
struct TypePeek: View {
var body: some View {
VStack {
Text("A")
Text("B")
}
}
}
// 어딘가에서 (예: 버튼 액션)
// print(type(of: TypePeek().body))
// 출력: VStack<TupleView<(Text, Text)>>SwiftUI는 이 타입 구조로 "지난번과 같은 뷰인가"를 판단한다 — Day 4 (identity)에서 다룬다.
표준 컨트롤은 목적(라벨+역할)으로 정의돼 VoiceOver가 그 라벨을 그대로 읽는다(WWDC19 · SwiftUI Essentials). 누를 수 있는 것은 HStack에 탭 제스처를 붙이기보다 Button으로 만든다. 이미지만 있는 경우 .accessibilityLabel("삭제")로 이름을 준다.
3. body는 언제, 몇 번 불리나
Apple 문서의 답은 "필요할 때마다, 반복해서"다. 한 번 실행되는 viewDidLoad와 정반대다.
"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." — Declaring a custom view
풀이: SwiftUI는 뷰를 갱신해야 할 때마다 body를 읽는다. 뷰가 사는 동안 여러 번, 주로 사용자 입력이나 시스템 이벤트 때문에.
"필요할 때" = 의존하는 값이 바뀔 때
WWDC24 · SwiftUI essentials: "Any piece of data that a view uses in its body is a dependency of that view." body가 읽은 데이터가 바뀌면 SwiftUI가 그 뷰의 body를 다시 부른다.
struct BodyCallCounter: View {
@State private var count = 0
var body: some View {
let _ = print("body 호출 — count=\(count)") // ⚠️ 실험용. 커밋하지 말 것
VStack {
Text("\(count)")
Button("+1") { count += 1 }
}
}
}let _ = …는 뷰 빌더 안에서 선언문을 쓰는 방법이다. 버튼을 열 번 누르면 로그가 최소 열한 줄(최초 1 + 탭 10) 찍힌다.
body는 "그리는 함수"다. 여기서 네트워크를 부르거나 상태를 바꾸면 갱신이 갱신을 부른다. UIKit에서 viewDidLoad에 넣던 로딩을 body로 옮기면 이렇게 된다.
// ❌ body가 평가될 때마다 새 Task — 요청이 또 나간다
struct FeedViewWrong: View {
@State private var items: [String] = []
var body: some View {
let _ = Task { items = await FeedAPI.fetch() }
List(items, id: \.self) { Text($0) }
}
}
// ✅ 부수효과는 .task에 — 수명은 Day 12에서
struct FeedView: View {
@State private var items: [String] = []
var body: some View {
List(items, id: \.self) { Text($0) }
.task { items = await FeedAPI.fetch() }
}
}
enum FeedAPI {
static func fetch() async -> [String] { ["a", "b"] }
}4. 모디파이어는 설정이 아니라 감싸기다 🔥 핵심
UIKit 눈으로 보면 .padding()은 프로퍼티 대입 같다. 아니다. Configuring views 문서는 이렇게 말한다.
"Modifiers return a view that wraps the original view and replaces it in the view hierarchy." / "You commonly chain modifiers, each wrapping the result of the previous one."
풀이: 모디파이어는 원래 뷰를 감싼 새 뷰를 돌려준다. 체인은 앞 결과를 한 겹씩 더 감싸는 것이다.
ModifiedContent는 "A value with a modifier applied to it"이다. 겹이 늘어도 SwiftUI가 뒤에서 효율적인 자료구조로 접으니 비용은 걱정하지 않는다(WWDC19 · SwiftUI Essentials).
감싸기라서 순서가 곧 결과다
WWDC19 데모: 초록 배경 텍스트 뒤에 padding을 붙이면 배경은 그대로다. 배경이 텍스트만 감싸고 여백은 그 바깥에 생겼기 때문이다.
struct OrderDemo: View {
var body: some View {
VStack(spacing: 20) {
Text("저장") // ① padding 먼저 → 배경이 여백까지 칠해진다
.padding(12)
.background(Color.green)
Text("저장") // ② background 먼저 → 배경은 글자에만, 여백은 바깥
.background(Color.green)
.padding(12)
}
}
}Apple 문서의 예도 같다. .frame(width: 100).border(Color.gray)는 100pt 전체에 테두리를, .border(Color.gray).frame(width: 100)은 텍스트에만 테두리를 그린다(Configuring views).
감싸기의 두 가지 부작용 — 알고 쓰면 무기
.font는 컨테이너 자체엔 효과가 없지만, 따로 덮어쓰지 않은 자식 전부에 적용된다.Image 전용 .resizable()은 .padding() 뒤에 못 붙는다. padding이 돌려준 건 더 이상 Image가 아니라 some View이기 때문이다.struct FontCascade: View {
var body: some View {
VStack {
Text("Title")
.font(.title) // 이 자식만 덮어쓴다
Text("First body line.")
Text("Second body line.")
}
.font(.body) // 스택 안 텍스트의 기본 폰트
}
}// ❌ error: value of type 'some View' has no member 'resizable'
// Image(systemName: "star")
// .padding()
// .resizable()
// ✅ Image일 때 resizable, 그다음 범용 모디파이어
struct ResizableFirst: View {
var body: some View {
Image(systemName: "star")
.resizable()
.frame(width: 40, height: 40)
.padding()
}
}모디파이어 체인은 위에서 아래로 "감싸는 순서"로 읽는다. 마지막 줄이 가장 바깥 껍질이다. 레이아웃 크기가 이상하면 .border(Color.red)를 한 줄씩 옮겨 가며 각 겹의 경계를 확인한다 — WWDC19 · Building Custom Views with SwiftUI의 팁이다.
5. init에서 일하지 마라 + UIKit 번역표
UIKit의 init·viewDidLoad는 "한 번만 실행되는 준비 구역"이었다. SwiftUI 뷰 struct의 init은 그렇지 않다. 부모의 body가 다시 평가될 때마다 자식 뷰 값도 새로 만들어진다.
"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." — Declaring a custom view
풀이: 시스템은 언제든 뷰를 다시 초기화할 수 있다. 그러니 init에서 무거운 일을 하지 말고, 가능하면 init을 직접 쓰지 말고 멤버와이즈 init에 맡긴다.
// ❌ 부모 body가 평가될 때마다 정렬과 포매팅이 또 돈다
struct ReportViewWrong: View {
let lines: [String]
init(raw: [Date]) {
self.lines = raw.sorted(by: >).map { $0.formatted(date: .abbreviated, time: .omitted) }
}
var body: some View {
List(lines, id: \.self) { Text($0) }
}
}
// ✅ 가공은 모델(상위 계층)에서 끝내고 넘긴다 — 명시적 init 없음
struct ReportView: View {
let lines: [String]
var body: some View {
List(lines, id: \.self) { Text($0) }
}
}
// 호출부: ReportView(lines: model.formattedLines)멤버와이즈 init만 쓰면 Preview에 가짜 데이터를 꽂기 쉽다: #Preview { ReportView(lines: ["1월 3일", "1월 2일"]) }. init에 로직이 있으면 Preview가 그 로직까지 돌려야 한다.
UIKit 습관 → SwiftUI 번역표
코스 전체의 색인이다. "없음" 칸이 가장 중요하다.
| UIKit 습관 | SwiftUI | 메모 |
|---|---|---|
UIView/UIViewController 서브클래스 | View 준수 struct | 값. 참조를 보관하지 않는다 |
addSubview(_:) | body에 선언 | 코드 모양이 곧 계층 (Day 2) |
label.text = "3" | Text("\(count)") | 대입이 아니라 상태 변경 (Day 3) |
viewDidLoad에서 로딩 | .task { } | 뷰 수명에 묶인 비동기 작업 (Day 12) |
viewWillAppear | .onAppear { } | 나타날 때마다 (Day 4) |
setNeedsLayout() / reloadData() | 없음 | "강제 갱신" 개념 자체가 없다 |
IBOutlet 참조 | 없음 | 붙잡을 객체가 없다 |
prepareForReuse() | 없음 | identity로 관리 (Day 4) |
| delegate / target-action | 클로저 · Binding | UIKit 래핑 땐 Coordinator (Day 6) |
| Auto Layout 제약 | 제안→결정→배치 | 방향이 다르다 (Day 7) |
UINavigationController.push | NavigationStack + 경로 상태 | Day 10 |
reloadData()가 없는 건 필요 없는 구조라서다. 갱신을 강제하고 싶어지면 화면이 읽는 값이 상태가 아닌 곳(일반 프로퍼티, 싱글턴 캐시)에 있다는 뜻이다. 그 값을 상태로 옮기는 방법이 Day 3·5다.
✅ 복습 · 5분
UIKit 코드를 SwiftUI로 옮길 때 대응물이 "없는" 줄 세 종류는?
IBOutlet, let label = UILabel()), 갱신 명령(label.text = …, reloadData(), setNeedsLayout()), 계층 조작(addSubview). 셋 다 "데이터와 화면을 맞추는 일"이고, SwiftUI는 그 일을 프레임워크가 한다.body에서 네트워크 요청을 시작하면 왜 안 되나?
body는 뷰를 갱신해야 할 때마다 반복해서 읽힌다(Apple 문서). 요청이 body 평가 횟수만큼 나가고, 결과로 상태가 바뀌면 다시 body가 불려 또 요청이 나간다. 부수효과는 .task나 버튼 액션에 둔다.Text("A").padding().background(.blue)와 Text("A").background(.blue).padding()의 차이를 구조로 설명하면?
background(padding(Text))라서 배경이 여백까지 칠하고, 뒤는 padding(background(Text))라서 배경은 글자에만 붙고 여백은 투명하게 바깥에 생긴다.struct Probe: View {
@State private var n = 0
init() { print("init") }
var body: some View {
let _ = print("body \(n)")
Button("tap") { n += 1 }
}
}🔮 코드 예측: 화면이 처음 뜬 뒤 버튼을 한 번 누르면 콘솔에 새로 찍히는 줄은? (부모 뷰는 다시 그려지지 않는다고 가정)
body 1 한 줄이다. n은 Probe의 body가 읽은 의존성이라 body만 다시 평가된다. Probe 값을 새로 만드는 건 부모 body가 평가될 때라서 init은 찍히지 않는다. 반대로 부모가 다시 그려지면 init이 찍힌다 — 그래서 init에서 일하면 안 된다.🔁 UIKit 경험 연결: UIKit에서 "모든 갱신을 updateUI() 한 곳에 모으는" 패턴이 왜 좋았고, SwiftUI는 그걸 어떻게 바꿨나?
body를 유일한 진입점으로 만들었다(WWDC20 10119).🛠 미니 과제 (선택 · 10분)
아래 카드에는 오늘 배운 실수가 세 개 숨어 있다. Xcode Preview 하나로 고친다.
import SwiftUI
struct PriceTag: View {
let label: String
init(price: Int) {
// (1) init에서 포매팅
self.label = price.formatted(.currency(code: "KRW"))
}
var body: some View {
Text(label)
.background(Color.yellow) // (2) 배경이 여백을 안 덮는다
.padding(12)
.onTapGesture { print("tap") } // (3) 여백을 눌러도 반응 없음
}
}
#Preview { PriceTag(price: 12000) }- 요구 1: 명시적
init을 지우고, 이미 포매팅된 문자열을 받는 멤버와이즈 init으로 바꾼다. - 요구 2: 노란 배경이 12pt 여백까지 덮게 모디파이어 순서를 고친다.
- 요구 3: 누를 수 있는 요소라는 걸 VoiceOver도 알게
Button으로 바꾼다.
확인 방법: Preview에서 노란 영역이 글자보다 넓은지 보고, Preview를 Live 모드로 두고 노란 가장자리를 눌러 콘솔에 tap이 찍히는지 본다.
정답 코드 펼치기
import SwiftUI
struct PriceTag: View {
let label: String // 멤버와이즈 init 자동 생성
var body: some View {
Button {
print("tap")
} label: {
Text(label)
.padding(12) // 먼저 넓히고
.background(Color.yellow) // 넓어진 영역 전체를 칠한다
}
.buttonStyle(.plain) // 기본 버튼 틴트 제거
}
}
#Preview { PriceTag(label: 12000.formatted(.currency(code: "KRW"))) }배경이 칠해진 영역은 내용이 있으므로 탭이 잡힌다. Button으로 감싸면 VoiceOver가 "버튼"으로 읽는다.