SwiftUI가 macOS 14 이후 실용적인 수준에 올라왔지만, 여전히 AppKit에만 존재하는 컨트롤이 있습니다. 이런 컨트롤을 SwiftUI 뷰 안에서 사용하려면 NSViewRepresentable을 통해 브리징해야 합니다. 이 노트는 실제 프로젝트에서 부딪힌 문제와 해결 방식을 정리합니다.
왜 브릿지가 필요한가
SwiftUI는 매년 새로운 컨트롤과 API를 추가하지만, AppKit에는 수십 년 축적된 컨트롤이 있습니다. 트로픽 소프트웨어가 과거에 사용했던 몇 가지 예를 들면 NSTokenField, NSOutlineView, 그리고 특정 세부 커스터마이징이 필요한 NSTextView 같은 것들입니다. SwiftUI에는 이런 컨트롤의 완벽한 대응물이 없거나, 있어도 커스터마이징의 깊이가 부족합니다.
이런 상황에서 두 가지 선택지가 있습니다. 첫째, 순수 SwiftUI로 비슷한 컨트롤을 처음부터 만들기. 둘째, AppKit 컨트롤을 NSViewRepresentable로 감싸 SwiftUI 안에서 사용하기. 대부분의 경우 두 번째가 빠르고 안정적입니다.
NSViewRepresentable의 기본 구조
NSViewRepresentable 프로토콜을 채택한 struct는 두 개의 메서드를 구현해야 합니다. makeNSView(context:)는 NSView 인스턴스를 만들어 반환합니다. updateNSView(_:context:)는 SwiftUI 상태가 변할 때 NSView를 갱신합니다.
struct TokenFieldRepresentable: NSViewRepresentable {
@Binding var tokens: [String]
func makeNSView(context: Context) -> NSTokenField {
let field = NSTokenField()
field.delegate = context.coordinator
return field
}
func updateNSView(_ nsView: NSTokenField, context: Context) {
nsView.objectValue = tokens
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSTokenFieldDelegate {
var parent: TokenFieldRepresentable
init(_ parent: TokenFieldRepresentable) {
self.parent = parent
}
func controlTextDidChange(_ obj: Notification) {
guard let field = obj.object as? NSTokenField else { return }
parent.tokens = field.objectValue as? [String] ?? []
}
}
}
이 구조가 실전에서 대부분의 상황을 커버합니다.
Coordinator 패턴의 역할
Coordinator는 AppKit 델리게이트 콜백을 SwiftUI 상태 변경으로 이어주는 다리 역할을 합니다. AppKit은 델리게이트 패턴에 의존하고, SwiftUI는 상태 바인딩에 의존합니다. Coordinator가 이 두 세계의 번역기가 됩니다.
주의할 점은 Coordinator가 유지되는 생명 주기입니다. Coordinator는 SwiftUI가 관리하는데, 뷰가 재생성될 때마다 Coordinator도 재생성될 수 있습니다. 델리게이트 관계는 강한 참조가 아닌 약한 참조로 유지되므로, AppKit 뷰에 델리게이트를 세팅한 뒤 Coordinator가 dealloc되면 콜백이 오지 않는 경우가 발생합니다.
이 문제는 대부분 makeNSView에서 델리게이트를 세팅한 뒤 context.coordinator를 참조로 유지하는 방식으로 해결됩니다. Coordinator 자체는 SwiftUI 프레임워크가 뷰의 생명 주기와 함께 유지하기 때문입니다.
업데이트 루프 주의
updateNSView는 SwiftUI 상태가 바뀔 때마다 호출됩니다. 이 안에서 다시 상태를 바꾸면 무한 루프가 발생할 수 있습니다. 예를 들어 updateNSView에서 nsView.objectValue = tokens를 설정하면, AppKit 델리게이트가 이를 텍스트 변경으로 감지하고 controlTextDidChange를 호출하고, 다시 SwiftUI 상태를 바꾸는 시퀀스가 생깁니다.
이를 방지하려면 업데이트가 프로그래밍적으로 발생했는지 사용자 입력인지 구분해야 합니다. 대부분의 경우 값이 실제로 다를 때만 세팅하는 가드로 충분합니다.
func updateNSView(_ nsView: NSTokenField, context: Context) {
let currentTokens = nsView.objectValue as? [String] ?? []
if currentTokens != tokens {
nsView.objectValue = tokens
}
}
이 가드만으로 대부분의 무한 루프가 방지됩니다.
첫 응답자와 포커스
SwiftUI는 자체 포커스 관리 시스템(@FocusState)이 있지만, AppKit의 첫 응답자 시스템과 자동으로 연결되지 않습니다. 브릿지된 AppKit 뷰에 포커스를 프로그래밍적으로 주려면 window.makeFirstResponder(nsView)를 명시적으로 호출해야 합니다.
이 호출은 updateNSView에서 SwiftUI 포커스 상태를 관찰하는 방식으로 통합할 수 있지만, 타이밍이 민감합니다. 뷰가 아직 창에 붙지 않은 시점에는 window가 nil입니다. DispatchQueue.main.async로 다음 런루프 사이클로 지연시키는 것이 안전한 패턴입니다.
크기 계산과 intrinsicContentSize
AppKit 뷰의 자연스러운 크기를 SwiftUI 레이아웃 시스템이 이해하려면 intrinsicContentSize가 제대로 반환되어야 합니다. 표준 AppKit 컨트롤은 대부분 이를 잘 구현하지만, 커스텀 뷰나 특정 설정에서는 SwiftUI가 크기를 파악하지 못하고 뷰를 압축하거나 확장하는 경우가 있습니다.
이런 경우 SwiftUI 쪽에서 .frame(width:, height:)로 명시적 크기를 주거나, NSViewRepresentable의 sizeThatFits(_:nsView:context:)를 구현해 SwiftUI에게 원하는 크기를 알려줄 수 있습니다.
다크 모드와 외관 적응
대부분의 AppKit 표준 컨트롤은 다크 모드에 자동으로 적응합니다. 커스텀 그리기가 있는 뷰의 경우 NSAppearance를 확인하고 색상을 조건적으로 반환해야 합니다.
SwiftUI에서 다크 모드로 전환되면 AppKit 뷰의 외관도 자동으로 업데이트되지만, 커스텀 그리기 코드가 있다면 viewDidChangeEffectiveAppearance 콜백에서 재그리기를 트리거해야 합니다.
실전 함정 몇 가지
스크롤 뷰 안에 브릿지된 컨트롤을 넣으면 스크롤 이벤트가 예상과 다르게 동작할 수 있습니다. 특히 NSTextView는 자체 스크롤을 가지므로, SwiftUI의 ScrollView 안에 넣으면 이중 스크롤이 됩니다. 이 경우 텍스트뷰의 자체 스크롤을 비활성화하거나, ScrollView를 사용하지 않는 방식으로 해결합니다.
애니메이션이 있는 상태 변화는 SwiftUI 애니메이션과 동기화되지 않을 수 있습니다. AppKit은 자체 애니메이션 시스템을 가지며, SwiftUI 애니메이션과 다른 타이밍으로 실행됩니다. 부드러운 통합이 필요하면 애니메이션을 명시적으로 조율해야 합니다.
대안: NSHostingView
방향이 반대인 경우 NSHostingView를 사용합니다. AppKit 코드베이스 안에서 SwiftUI 뷰를 사용하고 싶을 때 유용합니다. 새 프로젝트를 SwiftUI 우선으로 시작한 경우는 NSViewRepresentable이 자주 쓰이고, 기존 AppKit 프로젝트에 SwiftUI를 점진적으로 도입하는 경우는 NSHostingView가 자주 쓰입니다.
판단 기준
순수 SwiftUI로 다시 만들지, AppKit 컨트롤을 브릿지할지 판단할 때 편집팀이 따르는 기준은 다음과 같습니다.
SwiftUI 자체가 이미 유사한 컨트롤을 제공하고, 필요한 커스터마이징이 문서화된 API로 가능하다면 순수 SwiftUI로 갑니다.
AppKit 컨트롤이 이미 복잡한 상태 관리를 자체적으로 가지고 있고, 이를 SwiftUI로 다시 만들면 상당한 시간이 걸릴 것 같다면 브릿지합니다.
커스텀 그리기와 이벤트 처리가 프로젝트의 핵심 가치라면 순수 SwiftUI 또는 순수 AppKit 중 하나로 통일합니다. 두 세계를 오가는 것은 복잡성 관리 비용을 발생시킵니다.
참고
WWDC의 SwiftUI/AppKit 통합 세션들이 이 주제의 좋은 시작점입니다. 특히 Introducing SwiftUI 초기 세션과 이후의 macOS 업데이트 세션에서 브릿지 API 변화를 확인할 수 있습니다. Apple Developer Documentation의 NSViewRepresentable 페이지와 샘플 프로젝트도 참고할 만합니다.