사전 SwiftUI
구현체

SwiftUI

gabury1고친 사람 github-actions[bot]

SwiftUI 는 Apple 기기에서 도는 앱의 화면을 Swift 코드로 짜게 해 주는 Apple 의 도구입니다. 개발자는 화면이 어떤 모습이어야 하는지만 적습니다. 보여 줄 값이 바뀌면 SwiftUI 가 화면을 새로 맞춥니다.

쉽고 빠른 이해

SwiftUI 는 「이 값이면 화면이 이렇게 생긴다」를 적은 코드로 Apple 기기 앱의 화면을 짜게 합니다. 재생 중인지에 따라 버튼 글자를 「Play」나 「Pause」로 띄우는 버튼을 코드 몇 줄로 적는 식입니다.

이게 없으면 값이 바뀔 때마다 화면 부품을 찾아 직접 고치는 코드를 손으로 짜야 합니다. 같은 값을 두 곳이 보여 줄 때 한 곳만 고치면 값과 화면이 서로 다른 말을 합니다.

어떻게 도는가:

  1. 화면 조각마다 「이 값이면 이렇게 생긴다」를 적습니다
  2. 화면이 기대는 값에 @State 같은 표시를 붙여 SwiftUI 에 맡깁니다
  3. 그 값이 바뀌면 SwiftUI 가 그 값을 읽은 화면 조각만 다시 계산합니다. 달라진 곳만 화면에 옮깁니다

대가도 있습니다. 화면 코드를 언제 몇 번 다시 부를지 개발자가 정하지 못합니다. 그래서 화면 코드는 자주 불려도 견딜 만큼 짧게 끝나야 합니다. 새 기능은 그 기능이 들어온 운영체제 버전부터만 쓸 수 있습니다.

iPhone 과 Mac 을 비롯한 Apple 기기 앱의 화면에 씁니다. 다른 회사 기기의 앱에는 쓰지 않습니다. 예전 방식으로 짠 앱 안에도 SwiftUI 화면을 넣을 수 있습니다.

상세

SwiftUI 는 Apple 기기에서 도는 앱의 UI(User Interface, 사용자 인터페이스)를 짜는 프레임워크입니다. UI 는 사용자가 보고 누르는 화면 전체를 말합니다. SwiftUI 는 화면 조각인 뷰와 컨트롤, 배치 구조를 내놓습니다. 탭과 제스처 같은 입력을 앱에 넘겨 주는 이벤트 핸들러도 들어 있습니다.

앱의 데이터 모델에서 사용자가 보는 뷰까지 데이터가 흘러가는 길을 다루는 도구도 함께 내놓습니다. 아래 소절 대부분이 이 길을 다룹니다.

같은 방식으로 iPhone 의 iOS, iPad 의 iPadOS, Mac 의 macOS 앱 화면을 짭니다. tvOS·watchOS·visionOS 앱도 마찬가지입니다. 여러 운영체제의 앱이 코드를 나눠 쓸 수 있습니다. 같은 컨트롤이 놓인 기기와 맥락에 맞춰 모양을 바꿉니다.

코드는 Swift 로 적습니다. Swift 는 Apple 기기 앱을 짜는 데 쓰는 프로그래밍 언어입니다. Apple 의 다른 화면 프레임워크인 UIKit·AppKit·WatchKit 의 객체와 섞어 쓸 수도 있습니다. 붙이는 방법은 「맞물림」 절에 있습니다.

이 절은 SwiftUI 이전의 명령형 화면에서 출발합니다. 선언형으로 무엇이 바뀌었는지 본 다음, 뷰를 적는 법과 상태·바인딩·모델을 봅니다. 끝으로 모디파이어와 목록, 기능마다 붙은 운영체제 버전을 봅니다.

UIKit 의 명령형 화면

UIKit 은 SwiftUI 보다 먼저 쓰이던 Apple 의 화면 프레임워크입니다. UIKit 에서 화면 부품은 UIView 클래스의 객체입니다. 객체마다 그 메모리를 가리키는 포인터가 있습니다. 코드는 그 포인터로 부품을 집어 고칩니다.

이 방식에서 화면을 맞추는 짐은 컨트롤러 코드가 집니다. 컨트롤러 코드는 부품을 만듭니다. 배치와 설정도 맡습니다. 조건이 바뀌면 부품을 고치는 일까지 계속 떠맡습니다. 하위 뷰를 더하거나 빼는 일, 투명도를 바꾸는 일이 그런 예입니다. 무엇을 어떻게 바꾸라고 한 줄씩 시키는 이런 방식을 명령형 프로그래밍이라고 합니다.

명령형 화면에서는 뷰가 데이터를 읽을 때마다 보이지 않는 의존 관계가 하나 생깁니다. 데이터가 바뀌면 그 뷰도 새 값을 보여야 합니다. 이 관계를 개발자가 머리로 챙깁니다. 한 곳이라도 못 따라가면 그것이 버그입니다.

재생 상태를 버튼 글자와 제목 글자색 두 곳이 보여 준다고 해 봅시다. 상태가 바뀔 때 두 부품을 다 고쳐야 합니다. 하나를 빠뜨리면 버튼은 「Pause」를 띄웁니다. 제목은 멈춘 때의 색으로 남습니다.

선언형으로 바꾼 것

SwiftUI 는 반대로 갑니다. 개발자는 원하는 배치를 닮은 뷰 계층을 적습니다. 그것은 화면이 어떻게 생겨야 하는지를 적은 서술(description)입니다. 그리기와 고치기는 SwiftUI 가 맡습니다. 사용자 입력이 오거나 상태가 바뀌면 SwiftUI 가 뷰를 갱신합니다. 상태는 화면이 기대는 값 가운데 사용 중에 바뀌는 값입니다. 결과의 모양만 적고 거기까지 가는 절차는 도구에 맡기는 방식을 선언형 프로그래밍이라고 부릅니다.

백엔드 개발자에게는 Thymeleaf 같은 서버 템플릿 엔진이 가까운 예입니다. 템플릿은 모델 데이터를 받아 웹 페이지 한 장을 만들어 냅니다. 데이터가 바뀌면 이미 보낸 페이지를 고치지 않습니다. 페이지를 새로 만듭니다.

SwiftUI 의 뷰도 상태를 받아 화면 모습을 내놓습니다. 뷰는 일어난 사건을 차례로 반영한 결과가 아니라 지금 상태의 함수입니다. 개발자는 사건이 오면 뷰 계층을 고치는 대신 상태를 고칩니다. 화면은 그 상태에서 다시 끌어냅니다.

두 방식을 나란히 놓으면 이렇습니다.

UIKit SwiftUI
화면 부품 화면이 떠 있는 동안 살아 있는 객체 갱신 때마다 새로 만드는 값
상태가 바뀌면 코드가 부품을 찾아 고친다 코드는 상태만 고친다. 화면 모습은 SwiftUI 가 다시 얻는다
뷰와 데이터 사이의 의존 관계 개발자가 챙긴다 SwiftUI 가 챙긴다

셋째 줄이 두 방식을 가르는 차이입니다. SwiftUI 는 뷰가 어떤 값을 읽었는지 기억해 둡니다. 그 값이 바뀌면 그 뷰를 다시 계산합니다.

View 프로토콜과 body

SwiftUI 에서 화면 조각 하나를 뷰라고 부릅니다. 뷰는 View 라는 프로토콜을 따르는 타입입니다.

Swift 의 프로토콜은 Java 의 인터페이스와 같은 구실을 합니다. 어떤 속성과 메서드를 갖춰야 하는지 약속만 적어 둡니다.

View 프로토콜의 주된 요구는 body 라는 계산 속성입니다. 계산 속성은 값을 저장하지 않습니다. 읽을 때마다 코드를 돌려 값을 내놓습니다. body 에는 이 뷰가 어떻게 생겼는지를 적습니다.

swift
struct MyView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

Text 는 글자를 화면에 보여 주는 뷰입니다. SwiftUI 가 기본으로 내놓는 뷰입니다. body 는 이런 기본 뷰와 개발자가 만든 뷰를 엮어 뷰 계층을 이룹니다.

SwiftUI 는 뷰를 갱신해야 할 때마다 body 를 읽습니다. 이 일은 뷰가 살아 있는 동안 되풀이될 수 있습니다. 대개 사용자 입력이나 시스템 이벤트가 계기입니다. body 가 돌려준 값을 SwiftUI 가 화면에 그립니다.

body 의 타입은 some View 로 적습니다. 「View 를 따르는 어떤 타입」이라는 뜻입니다. 실제 타입은 body 안에 무엇을 적었는지에 따라 정해집니다. 개발 중에 body 를 고칠 때마다 그 타입도 달라집니다.

그래서 실제 타입은 Swift 가 추론합니다. 개발자는 「View 를 따른다」는 것만 밝힙니다. 이렇게 실제 타입 이름을 감추고 따르는 프로토콜만 밝히는 반환 타입을 불투명 타입이라고 합니다.

MyView 는 class 가 아니라 struct 로 적었습니다. struct 는 Swift 의 구조체입니다. 넘기거나 대입할 때 복사되는 값 타입입니다. SwiftUI 의 뷰는 대개 구조체로 적습니다. UIKit 의 부품이 UIView 를 상속하는 클래스였던 것과 다릅니다.

@State

앞에서 상태를 화면이 기대는 값 가운데 사용 중에 바뀌는 값이라고 했습니다. 재생 버튼이 지금 재생 중인지가 상태입니다.

뷰 값은 갱신 때마다 새로 만들어집니다. 그래서 오래 남아야 할 상태는 뷰 구조체 안에 두지 않습니다. SwiftUI 가 관리하는 저장소에 맡깁니다. 그 저장소의 값을 읽고 쓰게 해 주는 것이 @State 입니다.

@State 같은 표시가 프로퍼티 래퍼입니다. 프로퍼티 래퍼는 속성 하나에 붙는 Swift 문법입니다. 그 속성을 읽고 쓸 때 끼어드는 코드를 붙여 줍니다.

Xcode 27 이상으로 빌드하면 시스템이 프로퍼티 래퍼 대신 State() 매크로를 씁니다.

swift
struct PlayButton: View {
    @State private var isPlaying: Bool = false // Create the state.

    var body: some View {
        Button(isPlaying ? "Pause" : "Play") { // Read the state.
            isPlaying.toggle() // Write the state.
        }
    }
}

Button 뒤 중괄호 안의 코드는 버튼이 눌렸을 때 돕니다. 이렇게 이름 없이 넘기는 코드 덩이가 Swift 의 클로저입니다. Java 의 람다와 같은 구실을 합니다.

버튼을 누르면 isPlaying 이 뒤집힙니다. 값이 바뀌면 SwiftUI 는 뷰 계층에서 이 값에 기대는 부분을 갱신합니다. 버튼 글자가 「Play」에서 「Pause」로 바뀝니다.

@State 는 private 로 선언합니다. 구조체는 속성마다 인자를 받는 생성자를 Swift 가 자동으로 만들어 줍니다. 그 생성자로 바깥에서 값을 넣으면 SwiftUI 의 저장소 관리와 부딪칠 수 있습니다. 상태는 그 값이 필요한 가장 위의 뷰에 둡니다. 한 뷰와 그 하위 뷰 안에서만 쓰는 값에 씁니다.

바인딩

상태를 하위 뷰에 그냥 넘기면 하위 뷰는 읽기만 합니다. 위 뷰에서 값이 바뀌면 하위 뷰도 갱신됩니다. 그러나 하위 뷰가 그 값을 고칠 수는 없습니다. 하위 뷰가 고쳐야 하면 바인딩(Binding)을 넘깁니다.

값을 실제로 저장해 둔 주인을 진실 공급원(source of truth)이라고 합니다. 앞의 예에서는 @State 가 붙은 속성이 진실 공급원입니다. 바인딩은 값을 직접 저장하지 않습니다. 다른 곳에 있는 진실 공급원에 이어지는 양방향 연결입니다.

swift
struct PlayButton: View {
    @Binding var isPlaying: Bool

    var body: some View {
        Button(isPlaying ? "Pause" : "Play") {
            isPlaying.toggle()
        }
    }
}

struct PlayerView: View {
    var episode: Episode
    @State private var isPlaying: Bool = false

    var body: some View {
        VStack {
            Text(episode.title)
                .foregroundStyle(isPlaying ? .primary : .secondary)
            PlayButton(isPlaying: $isPlaying) // Pass a binding.
        }
    }
}

VStack 은 안의 뷰를 세로로 쌓는 뷰입니다. 여기서는 제목 글자와 버튼이 세로로 놓입니다.

이번에는 PlayerView 가 isPlaying 의 주인입니다. 상태 이름 앞에 $ 를 붙인 $isPlaying 이 그 상태의 바인딩입니다. 사용자가 PlayButton 을 누르면 PlayerView 의 isPlaying 이 바뀝니다. 같은 값을 읽는 제목 글자색도 따라 바뀝니다.

명령형 화면에서 두 부품을 따로 고치던 예가 여기서는 상태 하나를 고치는 일로 줄었습니다. 값의 주인이 한 곳이므로 두 곳을 맞출 일이 없습니다. 진실 공급원을 하나만 두라는 이 원칙을 단일 진실 공급원이라고 합니다. 진실 공급원이 둘로 갈라지면 둘을 늘 맞춰야 해서 버그와 어긋남이 생기기 쉽습니다.

값의 주인 하나에 두 하위 뷰가 매달린 모양은 이렇습니다.

flowchart TD
    P["PlayerView · @State isPlaying · 진실 공급원"]
    P -->|"값을 읽는다"| T["Text · 제목 글자색"]
    P -->|"$isPlaying · 바인딩을 넘긴다"| B["PlayButton · @Binding isPlaying"]
    B -->|"값을 뒤집는다"| P

켜고 끄는 Toggle, 글자를 받는 TextField, 값을 밀어 고르는 Slider 같은 컨트롤은 모두 바인딩을 받습니다.

@Observable 모델

@State 는 한 뷰와 그 하위 뷰에서 쓰는 값에 씁니다. 그 밖의 데이터는 앱의 데이터 모델 타입에 둡니다.

모델의 변화를 SwiftUI 가 알아보게 하려면 모델 타입에 @Observable 을 붙입니다. @State 처럼 @ 로 시작하지만 붙는 곳이 다릅니다. @State 는 속성에 붙습니다. @Observable 은 타입에 붙는 매크로입니다.

매크로는 컴파일할 때 붙은 곳에 코드를 덧붙여 주는 Swift 문법입니다. @Observable 은 관찰을 돕는 코드를 모델에 덧붙입니다. 모델 코드는 데이터를 담는 속성에 집중할 수 있습니다.

swift
@Observable 
class Car {
   var name: String = ""
   var needsRepairs: Bool = false

   init(name: String, needsRepairs: Bool = false) {
       self.name = name
       self.needsRepairs = needsRepairs
   }
}

어느 뷰가 이 모델의 어느 속성을 읽었는지에 따라 다시 계산되는 뷰가 갈립니다. 그 갈림은 「동작」 절의 의존성 소절에 적었습니다.

모디파이어

뷰의 크기·테두리·글꼴 같은 성질은 모디파이어로 바꿉니다. 모디파이어는 뷰에 대고 부르는 메서드입니다.

모디파이어는 원래 뷰를 고치지 않습니다. 원래 뷰를 감싼 새 뷰를 돌려줍니다. 뷰 계층에서는 그 새 뷰가 원래 뷰를 대신합니다.

모디파이어는 잇달아 부를 수 있습니다. 뒤에 부른 모디파이어가 앞의 결과를 감쌉니다. 그래서 부르는 순서가 결과를 바꿉니다.

swift
Text("Title")
    .frame(width: 100)
    .border(Color.gray)

frame 은 글자를 폭 100 짜리 보이지 않는 틀로 감쌉니다. 그 결과를 border 가 다시 감쌉니다. 테두리는 틀의 폭 전체를 두릅니다.

swift
Text("Title")
    .border(Color.gray) // Apply the border first this time.
    .frame(width: 100)

이번에는 테두리를 먼저 둘렀습니다. 글자는 제 내용에 필요한 만큼만 공간을 씁니다. 테두리는 글자에만 붙습니다. 폭 100 짜리 틀은 그 바깥을 감쌉니다. 전체 배치에는 영향을 줍니다. 테두리에는 아무 영향이 없습니다.

두 코드가 뷰를 감싸는 겹은 이렇게 다릅니다.

flowchart TD
    subgraph P1["frame 다음 border"]
        subgraph A2["border · 바깥 겹"]
            subgraph A1["frame · 폭 100"]
                A0["Text · Title"]
            end
        end
    end
    subgraph P2["border 다음 frame"]
        subgraph B2["frame · 폭 100 · 바깥 겹"]
            subgraph B1["border"]
                B0["Text · Title"]
            end
        end
    end
    P1 ~~~ P2

모디파이어에는 어떤 뷰에나 쓸 수 있는 일반 모디파이어와 특정 뷰에만 있는 모디파이어가 있습니다. 일반 모디파이어는 View 프로토콜이 정의한 것들입니다. 이들이 감싸 돌려주는 결과의 타입은 불투명 타입입니다.

padding() 같은 일반 모디파이어를 부른 뒤에는 Text 에만 있는 bold() 를 부를 수 없습니다. 결과가 더는 Text 가 아니기 때문입니다.

목록과 식별자

같은 모양의 줄이 되풀이되는 화면은 List 로 짭니다. List 는 데이터 묶음을 받아 원소마다 한 줄씩 만듭니다.

swift
struct Ocean: Identifiable {
    let name: String
    let id = UUID()
}

private var oceans = [
    Ocean(name: "Pacific"),
    Ocean(name: "Atlantic"),
    Ocean(name: "Indian"),
    Ocean(name: "Southern"),
    Ocean(name: "Arctic")
]

var body: some View {
    List(oceans) { ocean in
        Text(ocean.name)
    }
}

Ocean 은 Identifiable 프로토콜을 따릅니다. 원소마다 겹치지 않는 id 를 갖춘다는 약속입니다. 이 예에서는 UUID() 로 만든 값이 id 입니다. UUID(Universally Unique Identifier, 범용 고유 식별자)는 겹칠 일이 거의 없게 만든 식별자입니다.

원소마다 뷰를 만들어 주는 ForEach 도 같은 것을 요구합니다. 원소가 Identifiable 을 따르거나, id 인자로 무엇을 식별자로 쓸지 알려 줘야 합니다.

List 나 LazyVStack 같은 일부 컨테이너는 원소의 뷰를 미리 다 만들지 않습니다. 화면에 필요해질 때 가서야 묻습니다. LazyVStack 은 안의 뷰를 세로로 쌓는 컨테이너입니다.

이런 컨테이너에서 성능이 가장 잘 나오는 것은 원소 하나가 만드는 뷰의 수가 원소마다 같을 때입니다. 왜 그런지는 자료에 없습니다(미확인).

식별자가 왜 그렇게 중요한지는 「동작」 절의 정체성 소절에 적었습니다.

기능마다 붙은 운영체제 버전

SwiftUI 의 기능에는 저마다 쓸 수 있는 운영체제 버전이 붙어 있습니다. 표의 버전보다 낮은 운영체제에서는 그 기능을 못 씁니다.

운영체제 SwiftUI · View · @State · List @Observable 매크로
iOS 13.0 이상 17.0 이상
iPadOS 13.0 이상 17.0 이상
macOS 10.15 이상 14.0 이상
tvOS 13.0 이상 17.0 이상
watchOS 6.0 이상 10.0 이상
visionOS 1.0 이상 1.0 이상

iOS 16 기기에서 도는 앱은 @Observable 을 쓸 수 없다는 뜻입니다. 옛 운영체제까지 지원하는 앱은 이 표를 보고 쓸 기능을 고릅니다.

같은 코드가 운영체제 버전에 따라 다르게 돌기도 합니다. 표 화면 Table 의 줄을 ForEach 로 만드는 코드가 그런 예입니다. List 와 Table 은 어느 줄이 앞의 그 줄인지를 식별자로 가립니다. iOS 16 에서는 이 코드의 줄마다 그 줄의 값 자체가 식별자였습니다. iOS 17 에서는 성능을 위해 이 방식이 바뀌었습니다. 무엇으로 바뀌었는지는 자료에 없습니다(미확인). 예전 방식이 필요한 앱은 두 길 가운데 하나를 고릅니다. 컬렉션을 map 으로 한 번 거쳐 그 결과를 ForEach 에 넘기거나, ForEach 의 id 인자로 식별자로 쓸 속성을 직접 지정합니다. 앞 길이 왜 예전 방식을 되살리는지는 자료에 없습니다(미확인).

포기한 것

SwiftUI 가 안 하기로 한 것은 셋입니다. 화면 부품을 쥐고 고치는 일, 뷰를 포인터로 가리키는 일, body 를 부를 때를 개발자에게 맡기는 일입니다.

화면 부품을 쥐고 고치는 일

SwiftUI 코드는 화면 부품을 쥐고 고치지 않습니다. 이벤트 처리 코드에서 하위 뷰를 더하거나 빼지 않습니다. 투명도를 직접 바꾸지도 않습니다. 대신 상태를 고칩니다. 화면은 그 상태에서 다시 끌어냅니다.

얻는 것은 의존 관계를 개발자가 챙기지 않아도 된다는 점입니다. 뷰가 body 에서 어떤 값을 읽으면 SwiftUI 는 그 뷰의 그림이 그 값에 기댄다는 것을 압니다. 그 값이 바뀌면 SwiftUI 가 새 값으로 body 를 다시 요청합니다.

이벤트 처리 콜백에서 하위 뷰를 직접 고치다 보면 한 곳을 빠뜨리는 실수가 쉽게 납니다. SwiftUI 코드는 상태만 고칩니다. 그래서 이 실수가 끼어들 길이 좁습니다. 그 대가로 부품에 직접 손댈 수단이 사라집니다. 무엇을 언제 고칠지는 SwiftUI 가 정합니다.

뷰를 포인터로 가리키는 일

UIKit 과 AppKit 의 부품은 클래스 객체라 저마다 포인터가 있습니다. 두 부품의 포인터가 같으면 같은 부품입니다. 포인터가 곧 부품의 정체를 가립니다. SwiftUI 는 이 포인터를 쓰지 않습니다. 뷰가 값 타입이라 가리킬 포인터가 없습니다.

뷰 값은 잠깐 살다 버려집니다. 그 수명에 기대는 코드를 짜지 않습니다. 갱신 때마다 같은 뷰를 나타내는 새 값이 만들어집니다.

얻는 것은 가벼움입니다. 뷰는 클래스를 상속하지 않습니다. 상속으로 딸려 오는 저장 속성이 없습니다.

뷰 값은 스택에 잡힙니다. 넘길 때는 값으로 복사됩니다. 클래스 객체였다면 들었을 비용이 빠집니다. 객체마다 메모리를 따로 잡는 비용과, 그 객체를 가리키는 곳이 몇인지 세어 두는 비용입니다.

SwiftUI 는 뒤에서 뷰 계층을 그리기에 맞는 자료구조로 바꿔 둡니다. 그래서 하위 뷰를 떼어 작은 뷰로 쪼개도 실행 비용이 거의 늘지 않습니다.

치르는 것도 있습니다. 뷰가 오래 들고 있어야 할 값은 뷰 값 안에 둘 수 없습니다. @State 처럼 SwiftUI 가 쥐는 저장소에 맡깁니다. 그 저장소는 포인터 대신 SwiftUI 가 매기는 정체성에 묶입니다. 정체성은 갱신을 거쳐도 같은 뷰임을 가리는 표지입니다. 어느 뷰가 어느 값을 읽었는지도 SwiftUI 가 의존성 그래프로 따로 적어 둡니다. 개발자 코드와 SwiftUI 가 각각 무엇을 쥐는지 그리면 이렇습니다.

flowchart TD
    subgraph DEV["개발자 코드가 만드는 것"]
        V["뷰 값 · struct · 갱신 때마다 새로 만든다"]
    end
    subgraph FW["SwiftUI 가 쥐는 것"]
        I["뷰의 정체성"]
        S["@State 저장소"]
        G["의존성 그래프"]
    end
    I --> S
    S -->|지금 값을 채운다| V
    V -->|body 가 읽은 값| G

body 를 부르는 때

개발자는 body 를 언제 몇 번 부를지 정하지 않습니다. SwiftUI 는 뷰를 자주 새로 만듭니다. body 도 자주 다시 계산합니다. 개발자는 상태만 고치면 됩니다. 그 상태에 기대는 뷰를 골라 부르는 일은 SwiftUI 가 합니다.

그 대신 body 는 짧게 끝나야 합니다. 화면이 다음 프레임을 그리기 전에 뷰 갱신이 끝나야 하기 때문입니다. 제때 못 끝내면 앱 화면이 끊겨 보입니다. body 가 오래 돌거나 뷰가 너무 자주 갱신되면 시스템이 다른 데 쓸 자원도 먹습니다.

body 가 오래 도는 흔한 원인은 body 안에서 비싼 계산을 하는 것입니다. 그런 계산은 비동기로 돌립니다. 결과는 캐시해 두어 뷰가 쓸 때마다 다시 계산하지 않게 합니다. 업무 로직도 뷰 밖의 모델 타입으로 옮깁니다. 뷰 생성자와 body, onAppear 같은 메서드 안에서는 오래 걸리는 일을 피합니다.

Xcode 는 Apple 기기 앱을 짜는 Apple 의 개발 도구입니다. 거기 딸린 측정 도구가 Instruments 입니다. body 계산이 오래 걸리는 곳과 너무 잦은 갱신을 이 도구로 찾습니다.

예시

WidgetKit 위젯

WidgetKit 은 위젯과 워치 컴플리케이션, 라이브 액티비티를 만드는 Apple 의 프레임워크입니다. 셋 다 앱을 열지 않고 한눈에 보는 화면입니다. 위젯 화면은 SwiftUI 로 짭니다. 위젯의 내용은 SwiftUI 뷰로 정합니다. 대개 다른 SwiftUI 뷰를 엮어 만듭니다.

swift
@main
struct GameStatusWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(
            kind: "com.mygame.game-status",
            provider: GameStatusProvider(),
        ) { entry in
            GameStatusView(entry.gameStatus)
        }
        .configurationDisplayName("Game Status")
        .description("Shows an overview of your game status")
        .supportedFamilies([.systemSmall])
    }
}

provider 는 타임라인을 만듭니다. 타임라인은 「이 날짜에 위젯을 이 내용으로 갱신하라」를 담은 항목들의 차례입니다. 이 예에서는 항목마다 게임 상태가 들어 있습니다. 중괄호 안의 클로저가 SwiftUI 뷰 GameStatusView 를 돌려줍니다. 항목의 날짜가 오면 WidgetKit 이 이 클로저를 불러 위젯 내용을 그립니다.

위젯이 보이는 모습은 뷰의 스냅샷을 바탕으로 합니다. 그래도 위젯이 보이는 동안 계속 갱신되는 SwiftUI 뷰도 쓸 수 있습니다. 다만 모든 SwiftUI 뷰가 위젯에서 도는 것은 아닙니다. Apple 은 위젯에서 도는 뷰를 따로 문서에 명단으로 적어 두었고, 그 명단에 없는 뷰는 WidgetKit 이 위젯 내용을 그릴 때 무시합니다.

위젯 종류(family) 같은 정보와 라이트·다크 색 구성은 WidgetKit 이 SwiftUI 환경에 넣어 줍니다. 환경은 SwiftUI 가 들고 있다가 뷰에 대 주는 값 묶음입니다. 뷰가 환경에서 값을 읽으면 그 값에 의존이 생깁니다. 위젯 뷰는 이렇게 환경에서 종류와 색 구성을 읽습니다.

위젯 화면을 SwiftUI 로 짜도록 정한 이유는 미확인입니다.

Apple 튜토리얼 첫 장의 두 파일

Apple 튜토리얼 첫 장이 보여 주는 두 파일입니다. 첫째는 첫 화면 뷰입니다. 둘째는 앱의 시작점입니다. 튜토리얼 머리에는 이 코드가 지금의 SwiftUI·Xcode 관행은 아니라는 경고가 붙어 있습니다.

swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

ContentView 는 지구 아이콘과 「Hello, world!」 글자를 VStack 으로 세로로 쌓은 뷰입니다. imageScale·foregroundStyle·padding 이 모디파이어입니다. padding 은 VStack 전체를 감쌉니다. #Preview 는 이 뷰를 Xcode 편집기 옆 캔버스에 미리 그리게 합니다.

swift
import SwiftUI

@main
struct LandmarksApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

앱의 구조는 App 프로토콜을 따르는 타입으로 정합니다. @main 이 붙은 이 타입이 앱의 시작점입니다. App 은 장면(scene)을 담습니다. 장면은 뷰를 담습니다. 이 앱은 WindowGroup 장면 하나에 ContentView 를 담았습니다.

동작

이 절은 상태 하나가 바뀐 뒤 화면이 고쳐지기까지를 봅니다. 대상은 앞의 재생 버튼입니다. SwiftUI 는 무엇을 언제 어떻게 바꿀지를 세 가지로 가립니다.

무엇을 가리나
정체성 여러 번의 갱신을 거치며 어떤 요소가 같은 것인지 다른 것인지
수명 뷰와 데이터가 시간에 걸쳐 언제부터 언제까지 있는지
의존성 화면을 언제, 왜 갱신해야 하는지

갱신 한 번의 차례

재생 버튼을 누르면 이런 차례로 화면이 고쳐집니다.

  1. isPlaying 이 바뀝니다. SwiftUI 는 이 값에 의존하는 뷰만 무효로 표시합니다
  2. 무효가 된 뷰마다 새 뷰 값을 만듭니다. 뷰의 저장 속성이 전부 이 값에 담깁니다
  3. @State 같은 동적 속성을 SwiftUI 가 쥔 지금 값으로 채웁니다. 동적 속성은 SwiftUI 가 값을 채워 넣는 속성입니다
  4. body 를 불러 자식 뷰들을 얻습니다
  5. 자식 쪽으로 같은 일을 되풀이합니다. 새 값이 나왔거나 의존한 값이 바뀐 뷰만 갱신합니다

4번에서 만들어진 자식 뷰 값에는 부모가 넘긴 값이 담깁니다. 그 값이 앞 갱신 때와 다르면 그 자식도 갱신 대상이 됩니다. 이렇게 자식 값을 만들다 보면 바뀌는 의존 값이 더 늘어나 갱신이 아래로 번지기도 하는데, 늘 번지지는 않습니다.

뷰가 값 타입이라 SwiftUI 는 앞뒤 뷰 값을 견줄 수 있습니다. 그래서 정말 달라진 뷰만 골라 갱신합니다.

sequenceDiagram
    participant 사용자
    participant 뷰 as PlayButton
    participant S as SwiftUI
    participant 화면
    사용자->>뷰: 버튼을 누른다
    뷰->>S: 맡겨 둔 isPlaying 을 뒤집는다
    Note over S: isPlaying 에 의존하는 뷰만 무효로 표시한다
    S->>뷰: 새 뷰 값을 만들고 body 를 부른다
    뷰-->>S: 새 body 값
    Note over S: 앞 값과 견준다
    S->>화면: 달라진 곳만 고친다

정체성

SwiftUI 는 갱신을 거듭해도 어느 뷰가 앞의 그 뷰인지 알아야 합니다. 그 구별의 근거가 뷰의 정체성입니다. 정체성을 매기는 방법은 둘입니다.

무엇으로 가리나 예
명시적 정체성 개발자가 준 식별자나 데이터에 든 식별자 List 원소의 id · id(_:) 모디파이어
구조적 정체성 뷰 계층 안에서 뷰의 타입과 위치 if 문 같은 조건 코드 안의 뷰

구조적 정체성은 개발자가 따로 적지 않아도 SwiftUI 가 뷰 계층의 구조에서 만들어 냅니다.

id(_:) 모디파이어에 넘긴 값이 바뀌면 그 뷰의 정체성이 다시 매겨집니다. 그러면 그 뷰의 상태가 처음으로 돌아갑니다.

식별자는 안정적이어야 합니다. 데이터가 바뀔 때마다 새로 생기는 식별자를 쓰면 SwiftUI 는 매번 다른 뷰로 봅니다. 그러면 상태를 잃습니다. 배열 인덱스를 식별자로 쓰면 원소의 위치가 곧 정체성이 됩니다. 맨 앞에 원소 하나가 새로 들어오면 나머지 원소의 정체성이 모두 바뀌어 심한 버그가 날 수 있습니다.

List 와 Table 은 데이터에 무슨 변화가 생겼는지를 식별자로 압니다. 일관성을 지키려고 원소의 식별자를 처음부터 전부 모읍니다. 그래서 식별자를 빨리 만들 수 있으면 목록을 띄우고 갱신하는 시간이 그만큼 줄어듭니다.

수명

뷰 값과 뷰의 정체성은 다릅니다. 뷰 값은 갱신 때마다 새로 생겼다가 버려집니다. 정체성은 이어집니다. SwiftUI 가 보기에 새로 생긴 뷰 값들은 모두 같은 뷰입니다. 뷰의 수명은 그 정체성이 이어지는 기간입니다.

@State 저장소는 이 수명에 묶입니다. 뷰가 처음 만들어져 나타날 때 SwiftUI 가 초기값으로 저장소를 잡습니다. 정체성이 바뀌거나 뷰가 빠지면 그 수명이 끝납니다. 정체성이 바뀐 때는 상태가 새것으로 바뀝니다.

stateDiagram-v2
    [*] --> 살아있음: 처음 나타난다 · 초기값으로 State 저장소를 잡는다
    살아있음 --> 살아있음: 갱신 · 새 뷰 값 · 같은 정체성
    살아있음 --> [*]: 정체성이 바뀌거나 뷰가 빠진다 · State 를 버린다

앞 소절에서 id(_:) 값을 바꾸면 상태가 처음으로 돌아간 까닭이 여기 있습니다. 상태가 얼마나 남는지는 뷰의 수명이 정합니다.

의존성

뷰가 무엇에 의존하는지를 SwiftUI 는 의존성 그래프로 관리합니다. 어떤 의존 값이 바뀌면 그 값을 읽은 뷰만 무효가 됩니다. 정체성은 이 그래프의 뼈대입니다. 바뀐 내용을 맞는 뷰로 보내는 길이 정체성이기 때문입니다.

의존이 생기는 길은 여럿입니다.

의존 대상 예
뷰의 상태 @State
바인딩 @Binding
환경 값 위젯 종류(family) · 라이트·다크 색 구성
관찰 가능한 모델 데이터 @Observable 모델의 속성

@Observable 모델에서는 body 가 그 모델의 속성을 읽을 때 의존이 생깁니다. 읽지 않은 속성이 바뀌면 그 뷰는 영향을 받지 않습니다.

아래 BookView 가 그 예입니다. Book 은 앞 「상세」 절의 Car 처럼 @Observable 을 붙인 모델 타입입니다. 책 한 권을 담고 title·author·isAvailable 을 속성으로 갖습니다.

swift
struct BookView: View {
    var book: Book
    
    var body: some View {
        Text(book.title)
    }
}

이 뷰는 book 의 title 이 바뀔 때만 갱신됩니다. author 나 isAvailable 이 바뀌어도 갱신되지 않습니다.

book 속성에는 @State 같은 표시가 없습니다. @Observable 모델은 body 가 속성을 읽는 것만으로 의존이 생깁니다. 모델 객체를 뷰가 직접 들고 있어야 할 때는 @State 에 담습니다.

body 가 모델의 속성을 하나도 안 읽으면 그 뷰는 의존을 맺지 않습니다. 그래서 모델 객체를 여러 겹의 뷰를 거쳐 아래로 넘겨도 중간 뷰들은 의존을 맺지 않습니다. 모델을 넘기는 길과 의존이 생기는 길은 이렇게 다릅니다.

flowchart TD
    subgraph VH["뷰 계층"]
        A["위 뷰"] -->|"book 을 넘긴다"| M["중간 뷰"]
        M -->|"book 을 넘긴다"| BV["BookView · body 가 title 을 읽는다"]
    end
    subgraph MD["Book · @Observable 모델"]
        TI["title"]
        AU["author"]
        IS["isAvailable"]
    end
    TI -->|"바뀌면 이 뷰만 갱신"| BV

맞물림

SwiftUI 는 Apple 의 다른 프레임워크와 도구에 붙어서 돕니다. 아래 넷이 그 맞물림입니다. UIKit 화면에 SwiftUI 를 넣는 방향과 그 반대 방향, 모델 변화를 알리는 두 방식, 그리고 Xcode 미리 보기입니다.

UIKit 화면에 넣는 UIHostingController

UIKit 에서 화면 한 장은 뷰 컨트롤러가 맡습니다. 뷰 컨트롤러는 화면에 놓인 부품들을 관리하는 객체입니다.

UIHostingController 는 SwiftUI 뷰 계층을 관리하는 UIKit 뷰 컨트롤러입니다. 만들 때 루트로 쓸 SwiftUI 뷰를 넘깁니다. 나중에 rootView 속성으로 바꿀 수도 있습니다. UIKit 쪽은 이 컨트롤러를 다른 뷰 컨트롤러처럼 띄우거나 자식 뷰 컨트롤러로 끼워 넣습니다. UIKit 앱 안에 SwiftUI 화면을 넣을 때 이 길을 씁니다.

UIKit 뷰를 품는 UIViewRepresentable

거꾸로 SwiftUI 화면 안에 UIKit 의 UIView 를 넣을 때는 UIViewRepresentable 프로토콜을 따르는 타입을 만듭니다. 이 타입의 메서드를 시스템이 알맞은 때 불러 뷰를 만들고 고칩니다.

makeUIView 는 뷰를 처음 만들 때 한 번만 불립니다. 그 뒤의 갱신에는 updateUIView 가 불립니다. SwiftUI 쪽 상태가 바뀌어 그 UIKit 뷰에 영향이 가면 SwiftUI 가 updateUIView 를 부릅니다. 개발자는 이 메서드 안에서 새 상태에 맞게 뷰 설정을 고칩니다.

반대 방향은 저절로 이어지지 않습니다. UIKit 뷰 안에서 생긴 변화를 시스템이 다른 SwiftUI 뷰에 알려 주지 않습니다. 그래서 Coordinator 객체를 하나 더 만듭니다. UIKit 뷰가 보내는 이벤트 메시지를 이 객체가 받아 SwiftUI 쪽으로 넘깁니다. SwiftUI 는 makeUIView 보다 먼저 이 객체를 만듭니다.

이렇게 붙이면 배치 주도권은 SwiftUI 에 넘어갑니다. 그 UIKit 뷰의 center·bounds·frame·transform 은 SwiftUI 가 전부 쥡니다. 개발자 코드가 이 속성을 직접 바꾸면 SwiftUI 와 부딪혀 동작이 정의되지 않습니다.

두 방향을 나란히 그리면 이렇습니다.

flowchart TD
    subgraph H1["UIKit 앱이 SwiftUI 뷰를 품는다"]
        A1["UIKit 뷰 컨트롤러 계층"] --> A2["UIHostingController"] --> A3["SwiftUI 뷰"]
    end
    subgraph H2["SwiftUI 화면이 UIKit 뷰를 품는다"]
        B1["SwiftUI 뷰 계층"] -->|updateUIView| B2["UIView"]
        B2 -->|이벤트 메시지| B3["Coordinator"]
        B3 --> B1
    end
    H1 ~~~ H2

모델 변화를 알리는 Combine 과 Observation

Combine 은 값의 변화를 신호로 흘려보내는 Apple 의 프레임워크입니다. @Observable 이 나오기 전에는 모델의 변화를 Combine 의 ObservableObject 프로토콜로 SwiftUI 에 알렸습니다.

ObservableObject 를 따르는 객체는 기본으로 objectWillChange 라는 퍼블리셔를 갖습니다. 퍼블리셔는 Combine 에서 신호를 내보내는 객체입니다. @Published 를 붙인 속성이 바뀌기 직전에 이 퍼블리셔가 신호를 냅니다.

SwiftUI 는 그 신호를 받아 이 객체를 쓰는 뷰를 갱신합니다. 이때 단위는 객체입니다. 뷰가 읽지 않은 @Published 속성이 바뀌어도 그 뷰는 갱신됩니다.

Observation 은 옵저버 패턴을 Swift 전용으로 구현한 것입니다. @Observable 매크로로 씁니다. 이 방식에서는 body 가 직접 읽은 속성이 바뀔 때만 뷰가 갱신됩니다. 옵셔널 값이나 객체 컬렉션의 변화도 따라갈 수 있습니다. ObservableObject 로는 안 되던 일입니다.

ObservableObject (Combine) @Observable (Observation)
갱신 단위 객체 — 어느 @Published 속성이든 바뀌면 body 가 읽은 속성
속성 표시 @Published 필요 없다
iOS 13.0 이상 17.0 이상

한 앱 안에서 두 방식을 섞어 쓸 수 있습니다. 모델 타입을 하나씩 옮겨 가면 됩니다. 다만 SwiftUI 는 모델이 어느 방식을 쓰느냐에 따라 변화를 다르게 따라갑니다. 그래서 옮기는 동안 앱의 동작이 조금 달라질 수 있습니다.

Xcode 미리 보기

Xcode 는 SwiftUI 뷰의 미리 보기를 코드 옆 캔버스에 그려 보여 줍니다. 뷰 코드를 고치면 미리 보기도 따라 갱신됩니다. 어떤 뷰를 그릴지는 #Preview 매크로로 Xcode 에 알립니다.

swift
struct ContentView: View {
    var body: some View {
        // ...
    }
}

#Preview {
    ContentView()
}

앱이 정의하는 뷰마다 미리 보기를 하나 이상 두는 것이 보통입니다. 개발과 시험, 디버깅에 씁니다. 미리 보기 안에서는 그 뷰가 평소 돌 때 필요한 입력과 모델 데이터를 넣어 줄 수 있습니다. 그 밖에 뷰가 돌 때 필요한 것들도 미리 보기 안에서 마련할 수 있습니다.

관련 항목

SwiftUI 가 속하는 상위 분류

프레임워크 · UI 툴킷 · 선언형 프로그래밍 · 모바일 개발 · 사용자 인터페이스

SwiftUI 앱이 도는 운영체제

iOS · iPadOS · macOS · watchOS · tvOS · visionOS

SwiftUI 를 짜고 재는 언어와 도구

Swift · Xcode · Instruments · Swift Package Manager

SwiftUI 코드가 기대는 Swift 문법

구조체 · 값 타입 · 프로토콜 · 계산 속성 · 프로퍼티 래퍼 · 불투명 타입 · 클로저 · 매크로 · 프로토콜 지향 프로그래밍

SwiftUI 화면을 이루는 구성 요소

뷰 · UI 상태 · 바인딩 · 모디파이어 · 레이아웃 · ViewBuilder · NavigationStack · LazyVStack

SwiftUI 가 갱신할 뷰를 가리는 기준

뷰 정체성 · 뷰 수명 · 의존성 그래프 · Identifiable

SwiftUI 화면 설계에 적용되는 원칙

단일 진실 공급원 · 단방향 데이터 흐름 · 옵저버 패턴 · 불변 객체

SwiftUI 와 맞물리는 Apple 프레임워크

UIKit · AppKit · WatchKit · WidgetKit · ActivityKit · Combine · Observation · SwiftData · Core Data

SwiftUI 로 짜는 Apple 기기 화면

위젯 · 라이브 액티비티 · 워치 컴플리케이션 · 앱 확장

SwiftUI 가 대신하는 명령형 화면 부품

명령형 프로그래밍 · 뷰 컨트롤러 · 스토리보드 · Interface Builder · Auto Layout

SwiftUI 와 같은 선언형 방식의 UI 도구

Jetpack Compose · React · Flutter · React Native · Compose Multiplatform · 크로스플랫폼 프레임워크 · 템플릿 엔진

다른 이름: 스위프트UI · 스위프트 UI