본문 바로가기
C.W.K.
Stream
Lesson 04 of 06 · published

@Observable 대 ObservableObject

~15 min · appkit-swiftui, observation, swiftui, state

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"마이크 버튼 눌러도 멍 때리다가 컴포저에 뭔가 입력해야 활성화돼. 근데 이게 거의 그런다는 거고 100%도 아니야." — 아빠, TestFlight 빌드를 써보고

"뷰가 갱신돼야 해"의 두 세대

ObservableObject(Combine)는 옛 방식이야. 클래스가 @Published 속성이랑 objectWillChange로 변경을 알리고, 뷰는 @ObservedObject@StateObject자기가 들고 있는 객체를 구독해. 변경 알림이 하나라도 오면 그 뷰 전체가 무효화돼.

@Observable(Observation 프레임워크, macOS 14 / iOS 17 / watchOS 10)은 새 방식이야. 매크로 덕분에 클래스가 뷰 본문이 실제로 읽는 속성을 추적하고, 뷰는 그 속성이 바뀔 때만 다시 그려져. 모델은 평범한 속성이나 @State로 들고, $model.draft 같은 바인딩이 필요하면 @Bindable을 써.

하드웨어 고장처럼 보였던 버그

가족의 첫 네이티브 받아쓰기는 눌러도 반응 없는 마이크 버튼을 단 채로 나갔어. 사용자가 글자를 하나 치면 살아나기도 하고, 혼자 켜진 것 같기도 했어. 딱 하드웨어가 오락가락하는 모습이었지. 근데 마이크는 내내 녹음하고 있었어.

앱 모델이 ObservableObject였고, 받아쓰기 객체(이것도 ObservableObject)를 평범한 속성으로 들고 있었어. SwiftUI는 뷰가 들고 있는 객체만 구독해. 그래서 받아쓰기 객체의 @Published 변경은 어떤 뷰에도 전달되지 않았어. 글자를 치면 모델@Published 속성이 바뀌니까 화면이 다시 그려졌고, 듣는 중 상태가 그때 같이 나타난 거야. ObservableObject에서 고치려면 자식의 objectWillChange를 부모 쪽으로 전달하거나, 뷰에서 자식을 따로 @ObservedObject로 들어야 해. Observation에선 이 함정이 아예 없어. 본문에서 model.dictation.isListening을 읽으면 중첩된 객체를 거쳐서도 그 속성이 추적되거든. 가족의 공유 음성 컴포넌트가 @Observable인 이유가 딱 이거야.

아빠의 보고에서 얻은 교훈은 이거야. 하드웨어 고장처럼 보이는데 상관없는 UI 동작에 따라 달라지는 증상이라면, 반대 증거가 나오기 전까진 다시 그리기 문제로 봐.

우리 집 규칙, 그리고 컴파일러 각주 하나

가족 배포 대상은 전부 이미 Observation을 지원해. 그래서 규칙은 이래. 새 모델은 @Observable로 쓰고, 기존 ObservableObject 모델은 손댈 때 옮겨. 각주는 빌드 Mac에서 나왔어. SwiftUI Binding의 setter를 메서드 참조(set: model.selectProvider)로 넘겼더니, Swift 6.3.3이 코드 생성 단계에서 두 번 크래시했어. 명시적 클로저 set: { model.selectProvider($0) }로 쓰면 컴파일됐고. 최소 재현 조건은 끝내 못 좁혔어. 그러니 메서드 참조를 금지하는 규칙이 아니라, 그 크래시를 만났을 때 꺼내 쓰는 우회로로 기억해.

Code

중첩 객체 함정, 그리고 함정이 없는 Observation 버전·swift
import SwiftUI
import Combine

// The trap: a nested ObservableObject held as a plain property.
@MainActor final class DictationOld: ObservableObject { @Published var isListening = false }

@MainActor final class ComposerModelOld: ObservableObject {
    @Published var draft = ""
    let dictation = DictationOld()     // its @Published changes reach NO view through this model
}

struct ComposerViewOld: View {
    @ObservedObject var model: ComposerModelOld
    var body: some View {
        VStack {
            Text(model.dictation.isListening ? "Listening…" : "Tap to talk")   // stale until draft changes
            TextField("Draft", text: $model.draft)                            // typing here refreshes the label
        }
    }
}

// The fix: Observation tracks what the body reads, through nested @Observable objects.
@MainActor @Observable final class Dictation { var isListening = false }

@MainActor @Observable final class ComposerModel {
    var draft = ""
    let dictation = Dictation()
    var provider = "local"
    func selectProvider(_ name: String) { provider = name }
}

struct ComposerView: View {
    @Bindable var model: ComposerModel
    var body: some View {
        VStack {
            Text(model.dictation.isListening ? "Listening…" : "Tap to talk")
            TextField("Draft", text: $model.draft)
            Picker("Provider", selection: Binding(
                get: { model.provider },
                set: { model.selectProvider($0) }     // explicit closure, not set: model.selectProvider
            )) {
                Text("Local").tag("local")
                Text("Remote").tag("remote")
            }
        }
    }
}

External links

Exercise

Spark의 작은 SwiftUI 창에 코드 블록의 두 버전을 다 만들어. 누르면 1초 뒤에 dictation.isListening을 토글하는 버튼을 추가해. ObservableObject 버전에선 텍스트 필드에 뭔가 칠 때까지 라벨이 안 바뀌는지, @Observable 버전에선 바로 바뀌는지 확인해. 이어서 옛 버전을 Observation으로 옮기지 말고, 자식의 objectWillChange를 부모로 전달하는 방식으로 고쳐봐.
Hint
ComposerModelOld 이니셜라이저에서 dictation.objectWillChange를 구독하고, 이벤트마다 self.objectWillChange.send()로 넘겨. AnyCancellable은 저장 속성에 보관해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.