LODY/정리

애플 플랫폼 백과사전 / Data Essentials in SwiftUI (WWDC20 10040)

Data Essentials in SwiftUI (WWDC20 10040)

원문: Data Essentials in SwiftUI — WWDC20 #10040 발표자: Curt Clifton, Luca Bernardi, Raj Ramamurthy (SwiftUI 팀) 공개: 2020년 6월

SwiftUI 데이터 흐름의 교과서 격 세션이다. iOS 14에서 @StateObject가 도입된 시점, "왜 @ObservedObject만으로는 부족한가" 라는 질문에 Apple이 공식적으로 답한 자료이기도 하다. 세션은 세 파트로 나뉜다. Curt가 기본(@State·@Binding), Luca가 데이터 모델(ObservableObject 계열), Raj가 life cycle과 확장(@SceneStorage·@AppStorage) 을 맡는다.


Part 1: 세 가지 질문

뷰를 만들 때 먼저 물어야 하는 세 가지

새 SwiftUI 뷰를 그리기 전에 항상 세 가지를 자문해야 한다.

  1. 이 뷰는 일을 하기 위해 어떤 데이터가 필요한가?
  2. 이 뷰가 그 데이터를 어떻게 조작할 것인가?
  3. 그 데이터는 어디에서 오는가? (source of truth)

세 번째 질문이 데이터 모델 설계의 핵심이다. 진실의 원천이 어디인지를 먼저 결정해야 나머지가 따라붙는다.

예제: 읽는 중인 책 카드

책 썸네일·제목·저자·진행률을 보여 주는 단순한 카드를 그린다고 하자. 이 뷰가 필요한 데이터는 Bookprogress: Double 두 개. 뷰는 이 데이터를 수정하지 않고 표시만 한다. 값은 부모 뷰가 인스턴스화할 때 넘겨주면 된다.

struct BookCard: View {
    let book: Book
    let progress: Double
 
    var body: some View {
        HStack {
            Cover(book.coverName)
            VStack(alignment: .leading) {
                TitleText(book.title)
                AuthorText(book.author)
            }
            Spacer()
            RingProgressView(value: progress)
        }
    }
}

여기서 중요한 건 let으로 선언된 프로퍼티의 의미다. 수정하지 않을 데이터는 그냥 let. source of truth는 상위 어딘가에 있고, 이 뷰는 단지 그 값을 한 번 렌더링하기 위해 존재할 뿐이다.

용어 설명

  • source of truth: 해당 데이터의 원본 한 곳. SwiftUI는 이 원본을 여러 뷰가 참조하는 구조를 전제로 설계됐다
  • let property: 뷰의 "input parameter". SwiftUI는 이 값을 observable dependency로 추적하지 않고, 부모가 다른 값을 넣어 뷰를 새로 만들 때만 바뀐다

Part 2: @State의 본질

sheet 프레젠테이션과 지역 상태

책 탭 시 진행률 업데이트 sheet가 뜬다고 하자. sheet 프레젠테이션은 뷰 내부에 한정된 상태가 필요하다. isPresented: Bool, 입력 중인 note: String, progress: Double. 이들을 EditorConfig라는 struct로 묶으면 두 가지 이점이 있다.

  • 관련 프로퍼티 캡슐화와 독립 테스트
  • EditorConfig가 value type이라 프로퍼티 하나만 바뀌어도 EditorConfig 전체가 바뀐 것으로 SwiftUI가 인식
struct EditorConfig {
    var isEditorPresented = false
    var note = ""
    var progress: Double = 0
 
    mutating func present(initialProgress: Double) {
        progress = initialProgress
        note = ""
        isEditorPresented = true
    }
}
 
struct BookView: View {
    @State private var editorConfig = EditorConfig()
 
    func presentEditor() { editorConfig.present(initialProgress: 0) }
 
    var body: some View {
        Button(action: presentEditor) { /* … */ }
    }
}

@State가 하는 일을 한 줄로

@State는 SwiftUI가 이 프로퍼티의 저장소를 대신 관리하겠다는 선언이다.

뷰는 body 실행 중에만 일시적으로 존재한다. body 실행이 끝나면 struct 값은 사라진다. 그런데 @State로 표시된 프로퍼티만은 SwiftUI가 별도의 저장소에 유지한다. 다음 렌더 패스에서 SwiftUI가 뷰 struct를 다시 만들 때, @State 프로퍼티는 기존 저장소로 연결돼 복원된다.

이것이 뷰가 일시적(ephemeral)인데도 상태가 유지되는 메커니즘이다.


Part 3: @Binding

value type이 가르쳐 주는 한계

EditorConfig를 자식 뷰인 ProgressEditor에 그냥 넘기면 어떻게 될까?

ProgressEditor(editorConfig: editorConfig) // ❌ 자식은 복사본만 받는다

value type이라 복사가 일어난다. 자식이 복사본의 note·progress를 아무리 바꿔도, SwiftUI가 관리하는 원본 @State는 그대로다. 자식이 부모와 소통할 길이 없어진다.

자식이 자신의 @State를 따로 갖는 것도 틀린 해법이다. 그건 또 다른 source of truth를 만드는 것이지, 원본을 공유하는 게 아니다. 단일 source of truth를 유지하면서 자식에게 쓰기 권한을 주는 도구가 @Binding이다.

struct BookView: View {
    @State private var editorConfig = EditorConfig()
 
    var body: some View {
        ProgressEditor(editorConfig: $editorConfig)
    }
}
 
struct ProgressEditor: View {
    @Binding var editorConfig: EditorConfig
 
    var body: some View {
        TextEditor(text: $editorConfig.note)
    }
}

$ 접두사는 @Stateprojected value로, Binding<Value>를 만들어 낸다. 더 중요한 건 Binding에서 또 다른 Binding을 파생시킬 수 있다는 점이다. $editorConfig.noteBinding<String>. SwiftUI의 TextField·TextEditor·Toggle 같은 컨트롤이 전부 Binding을 받는 이유가 여기 있다.

Part 1 요약

  • 상수 데이터: 일반 property (let / var)
  • 뷰 내부에 한정된 일시 상태: @State
  • 다른 뷰가 소유한 데이터를 수정해야 할 때: @Binding

Part 4: ObservableObject의 설계

여기부터 Luca 파트다. @State는 "뷰에 한정된 일시 상태"용이다. 앱의 실제 데이터 모델 — 저장·동기화·side effect 관리가 필요한 것들 — 은 @State로 다루지 않는다.

ObservableObject 프로토콜

protocol ObservableObject: AnyObject {
    associatedtype ObjectWillChangePublisher: Publisher = ObservableObjectPublisher
    var objectWillChange: ObjectWillChangePublisher { get }
}

핵심은 세 가지다.

  1. class 전용: reference type만 채택 가능
  2. 요구사항 하나: objectWillChange Publisher
  3. "will" change: 이름 그대로 변경 이전에 방출. SwiftUI가 여러 변경을 하나의 업데이트로 coalesce하려면 "바뀔 거다"라는 사전 신호가 필요하기 때문이다

모델과 source of truth의 관계

ObservableObject를 채택하는 것은 "이 타입이 뷰에 노출되는 데이터 의존성 표면(data dependency surface)" 이라고 선언하는 것이다. 모델 전체와 같을 필요는 없다. 값은 struct로 유지하고, lifecycle·side effect를 class로 감싸는 식으로 분리할 수 있다.

class CurrentlyReading: ObservableObject {
    let book: Book
    @Published var progress: ReadingProgress
 
    init(book: Book, progress: ReadingProgress) {
        self.book = book
        self.progress = progress
    }
}

@Published는 default Publisher를 자동으로 만들어 값 변경 직전에 방출한다. @Published var progress로 충분하고, 세세한 관찰 로직을 직접 쓸 일이 거의 없다.


Part 5: @ObservedObject vs @StateObject vs @EnvironmentObject

뷰에서 ObservableObject 인스턴스에 의존성을 만드는 세 가지 property wrapper.

@ObservedObject: 소유하지 않는다

struct BookView: View {
    @ObservedObject var currentlyReading: CurrentlyReading
 
    var body: some View {
        /* … */
    }
}

인스턴스의 lifecycle 관리는 사용자 책임이다. 뷰는 단지 "이 객체의 변경을 관찰하겠다"고 신고만 할 뿐, 객체를 만들지도 유지하지도 않는다.

@StateObject: iOS 14의 결정적 추가

@ObservedObject만으로 충분하지 않은 이유가 여기서 나온다. 아래 코드는 흔한 실수 패턴이다.

struct ReadingListViewer: View {
    var body: some View {
        NavigationView {
            ReadingList()
        }
    }
}
 
struct ReadingList: View {
    @ObservedObject var store = ReadingListStore()  // ❌ 버그
 
    var body: some View { /* … */ }
}

ReadingListViewer.body가 실행될 때마다 ReadingList가 재생성되고, 그때마다 ReadingListStore가 새 heap allocation으로 다시 만들어진다. 느린 업데이트를 유발하고, 더 치명적으로는 상태가 매번 초기화돼서 날아간다.

iOS 14에서 도입된 @StateObject가 이 문제를 정면으로 푼다.

struct ReadingList: View {
    @StateObject var store = ReadingListStore()  // ✅
 
    var body: some View { /* … */ }
}

@StateObject는 SwiftUI가 body 최초 실행 직전에 딱 한 번 인스턴스화하고, 뷰의 전체 life cycle 동안 같은 인스턴스를 유지한다. 뷰가 아무리 자주 재생성돼도 객체는 살아남는다.

한 줄로 정리

  • @StateObject: 뷰가 소유 + SwiftUI가 lifecycle 관리. @State의 reference 버전
  • @ObservedObject: 외부에서 넘겨받음. 수명 관리는 호출자 책임
  • @EnvironmentObject: 조상 뷰 어딘가에서 주입, 깊은 자식에서 꺼내 씀 (boilerplate 제거용)

@EnvironmentObject 패턴

깊은 뷰 계층에서 ObservableObject를 여러 레벨에 걸쳐 수동으로 전달하는 건 boilerplate가 과하다. 이 경우 부모에서 .environmentObject(store)로 주입하고, 자식에서 @EnvironmentObject var store: ReadingListStore로 받는다. SwiftUI는 실제로 읽는 뷰에서만 dependency를 등록한다.


Part 6: view life cycle과 slow update

이 파트(Raj)는 2편·3편의 "Demystify" 시리즈로 이어지는 브리지다.

업데이트 루프

SwiftUI의 업데이트 흐름은 다음을 무한 반복한다.

이 사이클이 원활하게 돌아야 프레임이 떨어지지 않는다. 어느 한 단계라도 비싼 작업이 끼면 slow update가 발생하고, 이는 끊김·hang으로 나타난다.

slow update를 피하는 원칙

  • 뷰 초기화가 싸야 한다. bodypure function. side effect 금지
  • body가 언제 얼마나 자주 호출될지 가정하지 말 것. SwiftUI는 똑똑해서 종종 예상과 다르게 호출한다
  • 비싼 작업은 body 바깥 — .task·.onReceive·별도 actor·백그라운드 queue 등 — 으로 옮겨야 한다

Part 7: Storage (Process lifetime을 넘어서)

@State·@StateObject프로세스가 살아 있는 동안만 유지된다. 앱이 죽거나 기기가 재시작되면 날아간다. 이 한계를 메우기 위한 두 도구.

@SceneStorage: scene 단위

struct ReadingListViewer: View {
    @SceneStorage("selection") var selection: String?
 
    var body: some View {
        NavigationView {
            ReadingList(selection: $selection)
            BookDetailPlaceholder()
        }
    }
}

같은 scene이 복구될 때 값이 함께 복원된다. key는 해당 데이터 타입에 대해 unique 해야 한다. 재시작 이후에도 사용자가 보던 지점으로 돌려보낼 수 있는 가장 가벼운 도구.

@AppStorage: 앱 전역 (UserDefaults 래퍼)

struct BookClubSettings: View {
    @AppStorage("updateArtwork") private var updateArtwork = true
    @AppStorage("syncProgress") private var syncProgress = true
 
    var body: some View {
        Form {
            Toggle(isOn: $updateArtwork) { /* … */ }
            Toggle(isOn: $syncProgress) { /* … */ }
        }
    }
}

설정값 같은 가벼운 데이터용. UserDefaults에 저장되므로 큰 데이터를 여기 넣지 말 것 — persistence는 공짜가 아니다.

용어 설명

  • scene: iOS 13에서 도입된 앱 UI의 인스턴스 단위. iPad 멀티윈도우에서 각 창 = scene
  • UserDefaults: Apple 플랫폼의 key-value 설정 저장소
  • process lifetime: 앱 프로세스가 살아 있는 기간. 강제 종료·재부팅 시 리셋됨

한 장짜리 의사결정 표

상황도구
변하지 않는 값, 외부에서 주입let property
뷰 내부에만 한정된 단순 값 (Bool·String 등)@State
다른 뷰가 소유한 값을 쓰기까지 할 때@Binding
뷰가 직접 소유하고 lifecycle까지 관리할 참조형 모델@StateObject
외부에서 넘겨받은 참조형 모델 (수명은 남이 관리)@ObservedObject
앱 전역 모델을 여러 계층 넘어 공유@EnvironmentObject + .environmentObject(...)
재시작 이후에도 scene의 UI 상태 복원@SceneStorage
가벼운 설정값을 앱 전역에 저장@AppStorage

관련 읽을거리

한국어 정리

  • naljin — WWDC20 Data Essentials in SwiftUI 정리 (Medium) — 같은 세션을 한국어로 정리한 대표 자료. 발표 흐름 그대로를 꼼꼼히 옮겨 둠
  • wwdcnotes — Data Essentials in SwiftUI. 영문이지만 섹션별 타임스탬프와 코드를 함께 정리해 찾아보기 좋음. wwdcnotes.com

추가 탐구

  • SwiftLee — StateObject vs. @ObservedObject: The differences explained — 실제 버그 재현과 함께 설명. avanderlee.com
  • Hacking with Swift — What's the difference between @ObservedObject, @State, and @EnvironmentObject? — 초심자 관점의 비교. hackingwithswift.com
  • Apple Documentation — StateObject — 공식 API 설명. developer.apple.com

이 시리즈의 다음 편