ObservableObject 항목 내부의 편집 내용을 SwiftUI 목록 행에 반영하려면, 해당 항목을 @ObservedObject를 사용하는 행 뷰에 전달하고 변경 가능한 속성을 게시하세요. 이 가이드에서는 간단한 Mac용 레이블 편집기를 만들고, 컬렉션 업데이트, 행 관찰, 식별성이 각각 어떤 역할을 하는지 설명합니다.

항목을 변경해도 행이 바뀌지 않는 이유는 무엇인가요?

각 LabelItem이 클래스인 @Published var items: [LabelItem] 속성을 가진 저장소를 생각해 보세요. 항목을 추가하면 배열이 변경됩니다. 기존 항목에 새 제목을 할당하면 참조된 객체가 변경됩니다. 이는 서로 다른 변경입니다. Swift 배열은 값 타입이지만 클래스 인스턴스는 참조 의미론을 따릅니다. 클래스 참조를 담은 배열도 공유 인스턴스에 대한 참조를 보유합니다. Swift의 값 타입과 참조 타입.

실질적으로 배열을 게시해도 배열에 포함된 모든 객체의 모든 속성에 대한 관찰이 자동으로 설정되지는 않습니다. Apple의 마이그레이션 가이드에서는 객체 컬렉션 추적을 새로운 Observation 시스템의 장점으로 설명합니다. 기존 ObservableObject 구현에서는 각 행이 표시하는 객체에 자체적으로 연결되도록 하세요. Observable Object 프로토콜에서 Observable 매크로로 마이그레이션하기.

문제를 진단할 때는 두 가지 역할을 구분하면 유용합니다. 컨테이너 뷰는 목록에 어떤 항목이 속하는지 관찰하고, 각 행은 해당 항목의 변경 가능한 내용을 관찰합니다. Apple은 수신하는 ObservableObject의 게시된 변경으로 수신 뷰를 업데이트해야 할 때 @ObservedObject를 사용하는 래퍼로 문서화합니다. ObservedObject.

관찰되는 행을 포함한 완전한 macOS 예제 만들기

앱이 macOS 14 이전 버전을 지원하는 경우를 포함해, Combine의 ObservableObject를 이미 사용 중이라면 이 버전을 사용하세요. 이 예제는 소유 뷰에서 @StateObject를 사용하므로 macOS 11 이상이 필요합니다. Apple 문서에 따르면 @StateObject는 macOS 11부터 사용할 수 있습니다. 최신 Xcode Swift 컴파일러를 사용하세요. StateObject.

SwiftUI 인터페이스를 사용하는 macOS 앱 타깃을 만드세요. 생성된 앱 진입점 파일을 아래의 완전한 프로그램으로 바꾸고, 더 이상 필요하지 않다면 생성된 콘텐츠 뷰를 제거하세요. 타깃에는 @main 선언이 정확히 하나만 있어야 합니다. 예제의 레이블은 모두 임의로 만든 것이며 모든 상호작용은 앱 자체 창 안에서 이루어집니다.

import SwiftUI
import Combine
import Foundation

@MainActor
final class LabelItem: ObservableObject, Identifiable {
    let id = UUID()
    @Published var title: String
    @Published var isEnabled: Bool

    init(title: String, isEnabled: Bool = true) {
        self.title = title
        self.isEnabled = isEnabled
    }
}

@MainActor
final class LabelStore: ObservableObject {
    @Published var items: [LabelItem] = [
        LabelItem(title: "Amber"),
        LabelItem(title: "Cobalt", isEnabled: false)
    ]

    func reviseFirstItem() {
        guard let first = items.first else { return }
        first.title = first.title == "Amber" ? "Copper" : "Amber"
        first.isEnabled.toggle()
    }

    func addItem() {
        items.append(LabelItem(title: "New label"))
    }

    func removeLastItem() {
        guard !items.isEmpty else { return }
        items.removeLast()
    }
}

@MainActor
struct LabelRow: View {
    @ObservedObject var item: LabelItem

    var body: some View {
        HStack {
            TextField("Label", text: $item.title)
                .textFieldStyle(.roundedBorder)
                .frame(minWidth: 120)

            Text(item.title)
                .frame(minWidth: 100, alignment: .leading)

            Toggle("Enabled", isOn: $item.isEnabled)
        }
        .padding(.vertical, 4)
    }
}

@MainActor
struct LabelListView: View {
    @StateObject private var store = LabelStore()

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Button("Change first label") {
                    store.reviseFirstItem()
                }
                .disabled(store.items.isEmpty)

                Button("Add label") {
                    store.addItem()
                }

                Button("Remove last") {
                    store.removeLastItem()
                }
                .disabled(store.items.isEmpty)
            }

            Text("Rows: \(store.items.count)")

            List(store.items) { item in
                LabelRow(item: item)
            }
        }
        .padding()
        .frame(minWidth: 540, minHeight: 300)
    }
}

@main
@MainActor
struct LabelRowsApp: App {
    var body: some Scene {
        WindowGroup {
            LabelListView()
        }
    }
}

저장소가 항목 인스턴스를 소유합니다. LabelListView는 @StateObject를 사용해 저장소를 만들고 유지하며, 행은 @ObservedObject를 사용해 기존 인스턴스를 전달받습니다. Apple은 상태 객체가 선언한 컨테이너의 식별성이 유지되는 동안 지속되며, 관찰 객체는 일반적으로 새 기본값으로 생성하는 대신 입력으로 사용해야 한다고 설명합니다. StateObject, ObservedObject.

텍스트 필드와 토글은 항목의 게시된 속성에 바인딩을 사용합니다. 옆에 표시되는 텍스트는 같은 제목을 읽으므로 편집과 표시가 하나의 모델을 공유하는지 눈으로 확인할 수 있습니다. @MainActor는 이 예제의 모델 및 인터페이스 작업이 메인 액터에서 수행되도록 합니다. Apple은 메인 액터의 실행자가 메인 디스패치 큐와 동등하다고 설명합니다. ObservedObject, MainActor.

이 프로그램은 문서화된 API를 기준으로 검토했지만 여기서 컴파일하지는 않았습니다. 사용 가능한 환경에 Swift 툴체인이 없습니다. Xcode에서 빌드하고 실행해 배포 대상에서 동작을 확인하세요.

항목 편집과 컬렉션 변경을 각각 확인하기

다음 순서에 따라 두 업데이트 경로를 구분하세요. 아래 결과는 프로그램에서 예상되는 동작이며, 실제 테스트 측정 결과를 보고한 것이 아닙니다.

  1. 앱을 실행합니다. 처음에는 Amber와 Cobalt라는 레이블이 붙은 행 두 개가 표시됩니다.
  2. Change first label을 클릭합니다. 첫 번째 행은 Copper를 표시하고 Enabled 토글도 바뀝니다. 행 수는 두 개로 유지됩니다.
  3. 첫 번째 행의 Label 필드 내용을 편집합니다. 옆에 표시되는 텍스트에 같은 제목이 반영됩니다.
  4. Add label을 클릭합니다. New label이라는 세 번째 행이 나타나고 항목 수가 늘어납니다.
  5. 목록이 빌 때까지 Remove last를 클릭합니다. 제거 및 첫 레이블 변경 버튼은 비활성화되고, 레이블 추가는 계속 사용할 수 있습니다.

두 번째 단계는 변경이 컨테이너 뷰의 버튼 동작에서 시작되므로 특히 유용합니다. 배열을 교체하지 않고 기존 항목을 변경합니다. 따라서 행이 성공적으로 업데이트되면 항목 관찰 경로를 직접 확인할 수 있습니다. 삽입 및 제거 단계에서는 저장소의 게시된 배열을 별도로 확인합니다.

삽입은 작동하지만 제목 변경이 반영되지 않는다면, 행에서 제목을 읽는 속성 선언을 확인하세요. 이 구조에서는 @ObservedObject를 통해 항목을 받아야 하며 변경 가능한 제목은 @Published여야 합니다. 게시 동작은 해당 래퍼가 적용된 속성에 연결되며, 객체 그래프 전체에 재귀적으로 적용되는 선언이 아닙니다. Published, ObservedObject.

남은 문제를 유용한 순서로 진단하기

작성자와 행이 같은 인스턴스를 공유하는지 확인하기

LabelRow(item:)에 전달된 객체와 동작에서 수정하는 객체를 추적하세요. 제목이 같은 다른 LabelItem을 만들면 별도의 인스턴스가 생깁니다. 마찬가지로 제목을 행의 로컬 상태에 복사하면 동기화해야 할 값이 하나 더 생깁니다. 이 편집기에서는 초안 작성 후 확정하는 워크플로가 반드시 필요한 경우가 아니라면 공유 항목에 직접 바인딩하세요. 이는 값을 복사하는 것과 클래스 참조를 공유하는 것의 차이에 관한 Swift의 문서에 따른 것입니다. Swift의 값 타입과 참조 타입.

내용이 바뀌어도 식별성 유지하기

이 예제는 항목마다 저장된 불변 식별자를 부여합니다. 일반적인 동적 목록 구성의 기반인 ForEach에는 식별 가능한 요소나 명시적인 식별자가 필요합니다. 식별성은 업데이트 과정에서 SwiftUI가 생성된 콘텐츠를 해당 데이터 요소와 연결하도록 합니다. 하지만 식별성이 행의 관찰 연결을 대체하지는 않습니다. ForEach.

일상적인 새로고침 방법으로 변경되는 .id(UUID())을 추가하지 마세요. Apple 문서에 따르면 뷰의 식별성이 바뀌면 포커스 상태를 포함한 상태가 초기화됩니다. 편집한 제목을 표시하기 위해 행을 다시 만들면 콘텐츠 편집에 별도의 생명주기 변경이 추가됩니다. 먼저 관찰 경로를 설정하세요. StateObject.

부모 요약을 추가 소비자로 다루기

목록 위에 활성화된 항목 수를 추가한다고 가정해 보세요. 여기에 나온 기존 설계에서는 LabelStore를 관찰해도 부모가 각 항목의 isEnabled 게시자를 자동으로 관찰하지 않습니다. 따라서 행만 수정해도 요약에 필요한 의존성이 설정되지는 않습니다.

요약의 업데이트 방식을 명시적으로 선택하세요. 저장소에서 자식의 알림을 모으거나, 편집 내용을 게시된 컬렉션에 다시 기록하는 값 타입 항목을 사용하거나, 배포 대상이 허용하는 경우 Observation을 도입할 수 있습니다. 이 선택지는 컬렉션과 항목의 구분에서 도출되는 아키텍처 옵션입니다. 예제는 행을 직접 관찰하는 방식만 구현합니다. Apple의 마이그레이션 가이드에서는 새 시스템이 컬렉션과 뷰가 실제로 읽는 속성을 추적할 수 있다고 설명합니다. Observable Object 프로토콜에서 Observable 매크로로 마이그레이션하기.

게시자 기반으로 알림을 모은다면 알림 시점을 고려하세요. Apple은 @Published가 willSet 중에 게시한다고 명시합니다. 즉, 구독자는 저장된 속성이 바뀌기 전에 새 값을 받습니다. 해당 콜백 안에서 속성을 다시 읽으면 이전 값을 반환할 수 있습니다. 그 이벤트를 계산할 때는 방출된 값을 사용하세요. Published.

macOS 14 이상에서 Observation으로 전환해야 할 때는 언제인가요?

최소 배포 대상이 macOS 14 이상이라면 SwiftUI에서 Observation 시스템을 사용할 수 있습니다. @Observable 모델을 사용하면 SwiftUI는 뷰를 평가하는 동안 읽은 관찰 가능 속성을 추적합니다. Apple 가이드에서는 관찰 가능한 객체 컬렉션의 개별 제목이 변경될 때 표시된 제목이 업데이트되는 예제를 소개합니다. 앱에서 모델 데이터 관리하기.

이 예제를 마이그레이션하려면 import Observation을 추가하고 LabelItem과 LabelStore 모두에서 @Observable을 사용하도록 변경하세요. 두 타입의 ObservableObject 적합성을 제거하고 @Published도 삭제하세요. 소유 저장소 속성은 @StateObject에서 @State로 바꾸세요. 이 행에는 편집 가능한 컨트롤이 있으므로 입력 항목 래퍼를 @ObservedObject에서 @Bindable로 바꾸세요. 이러한 변경은 Apple이 문서화한 마이그레이션 경로를 따릅니다. Observable Object 프로토콜에서 Observable 매크로로 마이그레이션하기.

표시 전용 행은 일반 저장 속성으로 관찰 가능한 항목을 전달받고 body에서 제목을 읽을 수 있습니다. @Bindable은 TextField와 Toggle 같은 컨트롤에 필요한 바인딩을 제공하며 @ObservedObject와는 목적이 다릅니다. 새 Observation 시스템만 사용하는 모델을 @ObservedObject로 감싸지 마세요. 이 래퍼의 제네릭 제약 조건은 ObservableObject를 요구합니다. Bindable, ObservedObject.

결정은 간단합니다. 현재 배포 범위를 유지하면서 기존 Combine 모델을 수정하려면 관찰 행 방식을 유지하세요. macOS 14를 최소 버전으로 설정할 수 있고 속성 단위 의존성 추적이 인터페이스의 나머지 부분에도 적합하다면 Observation을 고려하세요.

행에 AppKit 텍스트 뷰를 포함하면 무엇이 달라지나요?

SwiftUI 예제는 표준 컨트롤을 사용하므로 사용자 지정 TextKit 통합이 필요하지 않습니다. 자체 행이 이미 NSViewRepresentable로 AppKit 뷰를 래핑한다면 모델 관찰을 확인한 다음 두 번째 경계도 확인하세요. Apple은 대응하는 SwiftUI 상태가 변경될 때 updateNSView(_:context:)에서 AppKit 뷰의 구성을 업데이트하라고 안내합니다. 네이티브 뷰를 한 번 만드는 것만으로는 이후 구성 업데이트가 구현되지 않습니다. updateNSView(_:context:).

내장된 NSTextView의 경우 모델과 뷰 간 동기화를 텍스트 레이아웃과 구분하세요. Apple은 NSTextView를 AppKit 텍스트 시스템의 프런트엔드로 설명하며, macOS 12 이상에서 layoutManager 속성에 명시적으로 접근할 때 적용되는 호환성 전환 방법을 문서화합니다. 행의 관찰 연결 누락을 진단하는 동안 레이아웃 엔진 변경을 도입하지 마세요. NSTextView.

레이블 편집기의 행 경계에서 구체적인 수정은 완료됩니다. 저장소를 유지하고, 편집 가능한 항목 속성을 게시하며, 표시되는 위치에서 전달받은 각 항목을 관찰하세요. 컬렉션을 변경하지 않고 항목을 수정해 확인한 다음 삽입과 제거를 확인하세요. 이 순서를 따르면 어떤 연결을 살펴봐야 하는지 명확히 알 수 있습니다.

출처