← SwiftUI 로드맵 1 / 5

01 · 선언형으로 머리 바꾸기

UIKit은 뷰를 지시하고 SwiftUI는 상태에 대한 화면을 설명한다. body가 반복 호출되는 함수라는 것, 모디파이어가 설정이 아니라 감싸기라는 것, 뷰 init에서 일하면 안 되는 이유까지.
진행률
0 / 0 완료
UIKit에서 화면을 바꾸는 방법은 "뷰 객체를 찾아서 시키기"였다. label.text = "3". SwiftUI에서 그 문장은 사라진다. 대신 상태를 바꾸고, 화면이 어떻게 생겼는지 설명해 둔다. 이 챕터는 그 교체를 머릿속에서 끝내는 게 목표다.

1. UIKit은 지시하고, SwiftUI는 설명한다

Apple이 이 차이를 문서에 직접 써 놨다. Declaring a custom view의 첫 문단이다.

📝 Apple 원문

"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에서 그 책임은 프레임워크로 넘어간다. 내가 하는 일은 설명서를 정확히 쓰는 것뿐이다.

UIKit — 명령형 SwiftUI — 선언형 사용자 입력 · 이벤트 내 코드가 뷰를 찾는다 뷰 속성을 직접 변경 화면 상태와 화면의 동기화 = 내 책임 빠뜨리면 "UI가 안 바뀌는" 버그 사용자 입력 · 이벤트 상태 한 곳을 변경 SwiftUI가 body를 다시 읽는다 달라진 부분만 화면 갱신 동기화 = 프레임워크 책임 버그는 "상태가 틀린" 문제로 옮겨간다
같은 이벤트를 두 프레임워크가 처리하는 경로. 오른쪽에는 "뷰를 찾는" 단계가 없다 — 그래서 뷰 참조를 보관할 이유도 없다.

같은 화면, 두 프레임워크

말로만 하면 안 붙는다. 버튼을 누르면 숫자가 1 올라가는 화면을 양쪽으로 써 보자.

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.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)"                 // ⚠️ 이 줄을 빠뜨리면 그게 버그다
    }
}
SwiftUI — 상태만 바꾼다. 갱신은 프레임워크가 한다
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다.

최소 형태 — UIKit의 UIView 서브클래스 자리를 대신한다
struct MyView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

body가 언제 읽히는지가 핵심이다. Apple 문서:

📝 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가 몇 번 불리는지 세어 보기
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 }
        }
    }
}
🚨 body에서 절대 하면 안 되는 것
❌ body에서 상태 변경 · 네트워크 호출 — 무한 갱신으로 간다
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의 설명:

📝 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

쉼표도 return도 없이 그냥 나열된다
VStack {
    Text("Hello, World!")
    Text("Glad to meet you.")
}

이게 되는 건 @ViewBuilder 때문이다. Apple 설명대로 "view-producing closure parameter"에 붙는 속성으로, 여러 문장으로 된 클로저가 여러 자식 뷰를 만들어 반환하게 해 준다. 그래서 if도 클로저 안에서 그냥 쓸 수 있다.

쉼표도 return도 없고, 분기까지 그냥 쓴다
struct MenuView: View {
    let isSymbol: Bool

    var body: some View {
        VStack {                        // @ViewBuilder 클로저
            Text("Cut")
            Text("Copy")
            Text("Paste")
            if isSymbol {               // 조건도 그대로
                Text("Jump to Definition")
            }
        }
    }
}

내가 만든 함수에도 붙일 수 있다. 뷰를 조립하는 헬퍼를 만들 때 쓴다.

내 함수·프로퍼티에 @ViewBuilder 붙이기
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 문서:

📝 Apple 원문

"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() }
.padding().background(.blue) .background(.blue).padding() Text background 배경이 여백까지 칠해진다 Text → padding → background 순서로 감쌈 background(padding(Text)) Text padding (투명) 배경은 글자에만 붙는다 Text → background → padding 순서로 감쌈 padding(background(Text)) 모디파이어는 안에서 바깥으로 겹겹이 감싼다 — 그래서 순서가 결과다
같은 두 모디파이어, 순서만 바꿨다. 왼쪽은 배경이 여백을 포함하고, 오른쪽은 여백이 배경 밖에 생긴다.
🔑 핵심

모디파이어 체인은 바깥에서 안으로 읽지 말고, 위에서 아래로 "감싸는 순서"로 읽어라. 마지막에 붙인 모디파이어가 가장 바깥 껍질이다.

🟣 한 걸음 더 — 감싸지 않는 예외도 있다 정확히

"모디파이어는 감싼다"는 View 프로토콜의 기본 모디파이어 이야기다. 일부 타입은 자기 타입을 그대로 돌려주는 전용 오버로드를 따로 갖는다. Text.font(_:)의 실제 선언이 그 예다.

Text 전용 오버로드 — 감싸지 않고 Text를 그대로 반환한다
nonisolated func font(_ font: Font?) -> Text

그래서 Text("Hi").font(.title)의 타입은 ModifiedContent<…>가 아니라 그냥 Text다. 실무 판단이 달라지지는 않는다(순서 규칙은 그대로 성립한다). 다만 타입을 직접 들여다볼 때 혼란을 피하려면 알아 둘 만하다 — Text끼리 +로 이어붙이는 게 되는 이유도 이것이다.

4. 뷰 init에서 일하지 마라

UIKit에서 init이나 viewDidLoad는 "한 번만 실행되는 준비 구역"이었다. 그 감각을 SwiftUI로 가져오면 성능 사고가 난다. Apple이 명시적으로 경고한다.

🚨 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에 네트워크 호출이나 무거운 파싱을 넣으면 업데이트마다 반복된다.

❌ 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) } }
}
✅ 이미 준비된 값을 받거나, 비동기 준비는 .task로 미룬다
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 가능
    }
}
⚠️ .taskviewDidLoad와 같은 보장이 아니다

Apple State 문서는 .task를 "called only once when the view first appears"라고 설명한다. 하지만 이건 뷰의 수명에 묶인 비동기 작업이라, viewDidLoad처럼 "객체가 사는 동안 무조건 정확히 한 번"과 동일한 보장은 아니다. 뷰가 사라지고 다시 등장하는 경로가 있는 화면에서는 "무조건 1회"에 로직을 의존시키지 말고 상태로 방어하라(예: 이미 불렀는지 플래그, 또는 값이 비었을 때만 로드).

🟢 쉽게 이해하기

뷰 struct는 주문서다. 주문서는 몇 장이든 다시 쓸 수 있고, 쓰는 데 1초도 안 걸려야 한다. 주문서를 쓸 때마다 재료를 사러 시장에 다녀오면(무거운 init) 주방이 멈춘다. 장보기는 .task에서 따로 한다.

5. 뷰 값 vs 실제 화면 — 무엇이 오래 사는가

여기까지 오면 이상하게 느껴지는 게 하나 있다. 뷰가 매번 새로 만들어진다면, 스크롤 위치나 입력 중인 텍스트는 어디에 남는가?

내가 만드는 것 — 뷰 값(struct) SwiftUI가 유지하는 것 1번째 body 결과 2번째 body 결과 3번째 body 결과 일회용 — 만들고 비교하고 버린다 여기에 뭔가 저장해도 남지 않는다 차이만 반영 실제 렌더 트리 하나만 존재 · 계속 유지 상태 저장소 (@State 등) 뷰가 아니라 identity에 매달림 오래 사는 건 이쪽이다 스크롤 위치·입력 중 텍스트가 여기 남는다
뷰 값은 설명서일 뿐 저장소가 아니다. 상태는 SwiftUI가 따로 보관하고, 그 보관함의 열쇠가 3챕터의 identity다.

답은 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클로저 · BindingUIKit 래핑 시엔 Coordinator (5챕터)
NSLayoutConstraint (양방향 제약)3단계 크기 협상4챕터 — 방향이 다르다
prepareForReuse()없음대신 identity로 관리 (3챕터)
ViewModel을 VC가 소유@State + @Observable2챕터
⚠️ 가장 중요한 칸

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 이면 이 한 줄이 전부
UIKit 화면 하나를 SwiftUI로 옮길 때 실제로 지워지는 것들
// ❌ SwiftUI로 옮기면서 같이 가져오면 안 되는 습관들
private var titleLabel: UILabel!        // 뷰 참조 보관 → 붙잡을 객체가 없다
override func viewDidLoad() { ... }     // → .task / .onAppear
func updateUI() {                       // 상태와 화면을 맞추는 함수 자체가 불필요
    titleLabel.text = model.title
    badgeView.isHidden = !model.isPro
}
tableView.reloadData()                  // → 상태 변경
delegate?.didTapSave()                  // → 클로저 또는 @Binding
🟣 한 걸음 더 참고

View 프로토콜은 기본적으로 메인 액터에 격리된다. 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는 메인 스레드에서"를 손으로 지켰던 규칙이 타입 시스템으로 올라온 것이다.

이 챕터 요약

출처 · 참고자료