← SwiftUI 14일 마스터 1 / 14

Day 1 · 선언형으로 머리 바꾸기

UIKit의 '뷰를 찾아 시키기'를 '상태를 바꾸고 화면을 설명하기'로 바꾸고, body 호출 시점과 모디파이어 순서의 원리를 익힌다.
진행률
0 / 0 완료
UIKit에서 화면을 바꾸는 방법은 "뷰 객체를 찾아서 시키기"였다. label.text = "3", spinner.stopAnimating(), tableView.reloadData(). SwiftUI에서는 이런 줄이 한 줄도 없다. 대신 상태를 바꾸고, 그 상태일 때 화면이 어떻게 생겼는지 설명해 둔다. 오늘은 머릿속의 기본값을 지시(명령형)에서 설명(선언형)으로 바꾼다.

오늘의 목표 · 30분

  • UIKit 카운터 화면을 보고, SwiftUI로 옮길 때 지워지는 줄(뷰 참조·갱신 호출)을 짚어낸다.
  • body에 부수효과(네트워크·상태 변경)가 들어간 코드를 찾아 .task나 액션으로 옮긴다.
  • 모디파이어 체인을 "안에서 바깥으로 감싸는 순서"로 읽고, padding/background 순서 버그를 고친다.
  • 뷰 init에서 무거운 일을 하는 코드를 찾아 멤버와이즈 init + 준비된 값으로 바꾼다.

1. 명령형 vs 선언형 — 누가 동기화를 책임지나 🔥 핵심

차이는 "조건이 바뀔 때마다 화면을 맞춰 주는 일"을 누가 하느냐다(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. … with a declarative approach, you create a lightweight description of your user interface … SwiftUI then manages drawing and updating these views."

풀이: 명령형은 뷰를 만들고 조건이 바뀔 때마다 고치는 짐이 컨트롤러에 있다. 선언형은 가벼운 "설명서"만 쓰고, 그리고 고치는 일은 SwiftUI가 한다.

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

같은 화면, 두 프레임워크

버튼을 누르면 숫자가 1 올라가는 화면이다. 줄 수보다 어떤 종류의 줄이 사라지는지를 본다.

UIKit — 뷰를 만들고, 붙잡고, 직접 갱신한다
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)"                  // ⚠️ 이 줄을 빠뜨리면 그게 버그다
    }
}
SwiftUI — 상태만 바꾼다. 갱신은 프레임워크가 한다
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천만 가지다.

UIKit — 이벤트마다 서브뷰를 직접 고친다 SwiftUI — 이벤트는 상태만 바꾼다 target-action delegate notification completion lifecycle label spinner button 이벤트 4개 → 순서 24가지, 12개 → 약 4.8억 "행복한 경로"만 코딩하고 나머지를 놓친다 target-action delegate notification completion lifecycle 상태 body 진입점은 body 하나 → 호출 순서도 하나 화면은 언제나 "현재 상태"에서 다시 파생된다
왼쪽은 이벤트와 서브뷰 사이 선이 늘수록 경우의 수가 폭발한다. 오른쪽은 모든 선이 "상태" 한 점으로 모인다.
📝 Apple 원문

"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의 액션이다.

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 UIViewSwiftUI View
정체클래스 인스턴스 (참조)struct 값 (복사)
공통 속성상속으로 받음 (alpha, backgroundColor …)없음 — 모디파이어가 각각 따로 가짐
수명화면에 있는 동안 계속 살아 있음업데이트마다 새로 만들고 버림
변경 방법속성에 대입상태를 바꾸면 새 값이 만들어짐
참조 보관IBOutlet, 프로퍼티불가 — 붙잡을 객체가 없다

근거는 WWDC19 · SwiftUI Essentials다. UIKit OrderCell: UIView는 alpha·backgroundColor 등 저장 프로퍼티를 전부 물려받는다. SwiftUI OrderRow는 자기 입력(let order: String)만 갖고, .opacity(0.8)는 뷰의 속성이 아니라 감싸는 모디파이어 뷰다. 공통 저장소가 필요 없으니 View는 프로토콜이 됐다.

📝 Apple 원문

"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). 실제 타입을 찍어 보면 이유가 보인다.

실험 — body가 만드는 실제 타입 찍어 보기
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)에서 다룬다.

내가 만드는 것 — 뷰 값(struct) SwiftUI가 유지하는 것 1번째 body 결과 2번째 body 결과 3번째 body 결과 일회용 — 만들고 비교하고 버린다 여기에 뭔가 저장해도 남지 않는다 차이만 반영 효율적인 내부 자료구조 하나만 존재 · 계속 유지 상태 저장소 (@State 등) SwiftUI가 뷰 대신 보관 (Day 3·4) 오래 사는 건 이쪽이다 스크롤 위치·입력 중 텍스트가 여기 남는다
뷰 값은 설명서일 뿐 저장소가 아니다. WWDC24 표현으로 SwiftUI는 설명을 받아 "efficient data structure"를 뒤에서 유지한다.
♿ 접근성 한 줄

표준 컨트롤은 목적(라벨+역할)으로 정의돼 VoiceOver가 그 라벨을 그대로 읽는다(WWDC19 · SwiftUI Essentials). 누를 수 있는 것은 HStack에 탭 제스처를 붙이기보다 Button으로 만든다. 이미지만 있는 경우 .accessibilityLabel("삭제")로 이름을 준다.

3. body는 언제, 몇 번 불리나

Apple 문서의 답은 "필요할 때마다, 반복해서"다. 한 번 실행되는 viewDidLoad와 정반대다.

📝 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." — 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를 다시 부른다.

실험용 — 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에서 절대 하면 안 되는 것

body는 "그리는 함수"다. 여기서 네트워크를 부르거나 상태를 바꾸면 갱신이 갱신을 부른다. UIKit에서 viewDidLoad에 넣던 로딩을 body로 옮기면 이렇게 된다.

🔁 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"] }
}
① 버튼 탭 액션 실행 ② 상태 변경 count += 1 ③ body 재평가 count를 읽은 뷰만 ④ 비교 이전 값 vs 새 값 ⑤ 화면 반영 달라진 부분만 내가 하는 일 상태를 바꾸는 것까지 SwiftUI가 하는 일 ③~⑤ — 그래서 body는 빠르고 순수해야 한다
내 코드가 끝나는 지점은 ②다. ③은 몇 번이고 반복될 수 있으니, body에는 계산과 설명만 둔다.

4. 모디파이어는 설정이 아니라 감싸기다 🔥 핵심

UIKit 눈으로 보면 .padding()은 프로퍼티 대입 같다. 아니다. Configuring views 문서는 이렇게 말한다.

📝 Apple 원문

"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."

풀이: 모디파이어는 원래 뷰를 감싼 새 뷰를 돌려준다. 체인은 앞 결과를 한 겹씩 더 감싸는 것이다.

.background(Color.blue) — 가장 바깥 .padding() Text("Hi") 마지막에 붙인 모디파이어가 가장 바깥 껍질 print(type(of: …)) 결과 ModifiedContent< ModifiedContent< Text, _PaddingLayout>, _BackgroundStyleModifier<Color>> (내부 타입 이름은 OS 버전마다 다를 수 있다)
코드에서는 위에서 아래로 쓰지만, 구조로는 안에서 바깥으로 한 겹씩 싸인다. 타입이 그 중첩을 그대로 드러낸다.

ModifiedContent는 "A value with a modifier applied to it"이다. 겹이 늘어도 SwiftUI가 뒤에서 효율적인 자료구조로 접으니 비용은 걱정하지 않는다(WWDC19 · SwiftUI Essentials).

감싸기라서 순서가 곧 결과다

WWDC19 데모: 초록 배경 텍스트 뒤에 padding을 붙이면 배경은 그대로다. 배경이 텍스트만 감싸고 여백은 그 바깥에 생겼기 때문이다.

두 줄 순서만 바꿨는데 결과가 다르다 — Preview에서 나란히 확인
struct OrderDemo: View {
    var body: some View {
        VStack(spacing: 20) {
            Text("저장")                 // ① padding 먼저 → 배경이 여백까지 칠해진다
                .padding(12)
                .background(Color.green)

            Text("저장")                 // ② background 먼저 → 배경은 글자에만, 여백은 바깥
                .background(Color.green)
                .padding(12)
        }
    }
}
.padding().background(.green) .background(.green).padding() Text background 배경이 여백까지 칠해진다 Text → padding → background 순서로 감쌈 background(padding(Text)) Text padding (투명) 배경은 글자에만 붙는다 Text → background → padding 순서로 감쌈 padding(background(Text)) 모디파이어는 안에서 바깥으로 겹겹이 감싼다 — 그래서 순서가 결과다
같은 두 모디파이어, 순서만 바꿨다. 왼쪽은 배경이 여백을 포함하고, 오른쪽은 여백이 배경 밖에 생긴다.

Apple 문서의 예도 같다. .frame(width: 100).border(Color.gray)는 100pt 전체에 테두리를, .border(Color.gray).frame(width: 100)은 텍스트에만 테두리를 그린다(Configuring views).

감싸기의 두 가지 부작용 — 알고 쓰면 무기

① 자식에게 퍼진다
컨테이너에 붙인 .font는 컨테이너 자체엔 효과가 없지만, 따로 덮어쓰지 않은 자식 전부에 적용된다.
② 전용 모디파이어는 순서가 먼저
Image 전용 .resizable()은 .padding() 뒤에 못 붙는다. padding이 돌려준 건 더 이상 Image가 아니라 some View이기 때문이다.
① 스택에 기본값, 자식에서 덮어쓰기 (Apple 문서 예제)
struct FontCascade: View {
    var body: some View {
        VStack {
            Text("Title")
                .font(.title)            // 이 자식만 덮어쓴다
            Text("First body line.")
            Text("Second body line.")
        }
        .font(.body)                     // 스택 안 텍스트의 기본 폰트
    }
}
🔁 padding 뒤 Image 전용 모디파이어(❌ 컴파일 에러) → 전용 모디파이어를 먼저(✅)
// ❌ 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가 다시 평가될 때마다 자식 뷰 값도 새로 만들어진다.

⚠️ Apple의 경고

"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에 맡긴다.

🔁 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)
👀 Preview 팁

멤버와이즈 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클로저 · BindingUIKit 래핑 땐 Coordinator (Day 6)
Auto Layout 제약제안→결정→배치방향이 다르다 (Day 7)
UINavigationController.pushNavigationStack + 경로 상태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는 그걸 어떻게 바꿨나?
여러 이벤트가 각자 서브뷰를 고치면 호출 순서 조합이 폭발한다(4개면 24가지). 갱신을 한 메서드에 모으면 순서가 하나라 그 문제가 사라진다. SwiftUI는 이 모범 사례를 규칙으로 굳혀 body를 유일한 진입점으로 만들었다(WWDC20 10119).
쉽게 말하면 친구 다섯 명이 각자 칠판을 고치면 엉망이 되니까, "칠판은 반장 한 명만 쓴다"는 규칙을 만든 것이다.

🛠 미니 과제 (선택 · 10분)

아래 카드에는 오늘 배운 실수가 세 개 숨어 있다. Xcode Preview 하나로 고친다.

시작 코드 — 새 SwiftUI 파일에 붙여 넣기
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) }

확인 방법: 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가 "버튼"으로 읽는다.

출처 · 참고자료