"마이크 버튼 눌러도 멍 때리다가 컴포저에 뭔가 입력해야 활성화돼. 근데 이게 거의 그런다는 거고 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) }로 쓰면 컴파일됐고. 최소 재현 조건은 끝내 못 좁혔어. 그러니 메서드 참조를 금지하는 규칙이 아니라, 그 크래시를 만났을 때 꺼내 쓰는 우회로로 기억해.