Swift가 macOS/iOS에서 지금도 Objective-C 런타임 위에 서 있다는 사실은 평소엔 잘 보이지 않는다. 이 챕터는 그 접점이 실제로 코드에 어떻게 드러나는지 — String이 NSString으로 캐스팅되는 순간 진짜로 무슨 클래스가 나오는지, KVO가 관찰을 시작하자마자 몰래 서브클래스를 심는지 — 를 object_getClass로 직접 찍어서 확인한다. "브리징은 공짜다"와 "브리징은 원소마다 다시 만든다"는 말이 각각 어느 계층에서만 맞는지, 그리고 2026년에도 NSObject를 상속해야 하는 몇 안 되는 자리가 어디인지가 핵심이다.
Q1. Toll-free bridging이란 무엇이고 비용은 정확히 어디서 발생하는가?
좁은 의미의 toll-free bridging은 CoreFoundation 타입(CFStringRef, CFArrayRef...)과 대응하는 Foundation 클래스(NSString, NSArray...) 사이의 캐스팅만을 가리키며, 이 둘은 메모리상 진짜 같은 객체라 변환이 포인터를 다르게 보는 것뿐이라 정말 공짜다. Swift 값 타입(String, Array, Dictionary, Set)과 그 ObjC 대응 클래스 사이의 브리징은 이름은 비슷하지만 다른 메커니즘 — _ObjectiveCBridgeable을 통한 조건부 변환 — 이라 표현 방식이 다르면 실제 할당·복사·박싱이 일어날 수 있다. 이 둘을 같은 것으로 뭉뚱그리는 게 가장 흔한 오류다.
원리
CoreFoundation은 Objective-C 런타임 없이도 모든 Darwin 플랫폼에서 쓸 수 있는 순수 C API로 설계됐고, Foundation은 애초에 그 CF 타입들 위에 얇게 씌운 Objective-C 래퍼로 시작했다(Apple 개념 문서). 이 이중 구조 자체가 toll-free bridging의 근거다 — CFStringRef와 NSString은 설계 단계부터 같은 메모리 레이아웃을 공유하는 하나의 타입으로 만들어졌다. 반면 Swift String/Array/Dictionary/Set은 CF/NS 클러스터와 무관하게 설계된 진짜 Swift 값 타입이고, ObjC와의 상호운용은 나중에 _ObjectiveCBridgeable 프로토콜로 얹은 것이다(Q2에서 다룬다).
내부 동작
CFStringRef와 NSString 사이의 캐스트는 새 객체를 전혀 만들지 않는다 — 실행 시점에 변환 코드가 도는 게 아니라, 컴파일러가 같은 비트 패턴을 다른 타입으로 다시 본다. 반면 [Int]를 NSArray로 캐스팅했을 때 실제로 무슨 일이 일어나는지 실측해보면 흥미롭다. 10만 개짜리 [Int]를 as NSArray로 캐스팅하는 데는 0.0055ms밖에 걸리지 않는다 — 10만 개의 NSNumber를 그 자리에서 박싱하기엔 터무니없이 짧다. type(of:)로 실제 타입을 찍어보면 결과는 평범한 NSArray 서브클래스가 아니라 __SwiftDeferredNSArray라는 지연 래퍼다. 진짜 박싱 비용은 캐스트 시점이 아니라, ObjC 쪽이 그 배열의 원소를 실제로 하나씩 꺼내 읽는 순간에 나눠서 발생한다 — 같은 10만 개 배열을 처음부터 끝까지 순회하며 각 원소를 NSNumber로 꺼내 보면 그제서야 16ms가 걸린다(측정치, Swift 6.2.1 / macOS 26 / Apple Silicon). 즉 비용이 "브리징하는 시점"이 아니라 "실제로 소비하는 시점"으로 미뤄진다 — Q2에서 볼 String 브리징의 지연 전략과 같은 사상이다.
실험 · 도구
아래 코드를 파일로 저장해 swift 파일명.swift로 돌리면 캐스트가 지연 래퍼를 만든다는 것과, 실제 소비 시점에만 비용이 붙는다는 것을 직접 확인할 수 있다.
import Foundation
let ints: [Int] = Array(0..<100_000)
let t0 = DispatchTime.now()
let nsArr = ints as NSArray
let t1 = DispatchTime.now()
print(type(of: nsArr)) // __SwiftDeferredNSArray — 아직 박싱하지 않은 지연 래퍼
var acc = 0
for i in 0..<nsArr.count {
let obj = nsArr[i] // 여기서 비로소 Int -> NSNumber 박싱이 일어난다
if let n = obj as? NSNumber { acc += n.intValue > 0 ? 1 : 0 }
}
let t2 = DispatchTime.now()
print("캐스트:", Double(t1.uptimeNanoseconds - t0.uptimeNanoseconds) / 1_000_000, "ms")
print("순회 접근:", Double(t2.uptimeNanoseconds - t1.uptimeNanoseconds) / 1_000_000, "ms")LLDB에서는 po type(of: someBridgedValue)로 캐스트 직후의 실제 런타임 클래스를 바로 확인할 수 있다.
프로젝트 적용
① ObjC 델리게이트 API에 Swift 배열을 그대로 넘기는 건 걱정할 정도로 비싸지 않다 — 캐스트 자체는 지연 래퍼를 만들 뿐이다.
func exposeToObjC(_ ids: [Int]) -> NSArray {
ids as NSArray // __SwiftDeferredNSArray로 감쌀 뿐, 이 시점엔 원소를 박싱하지 않는다
}② 다만 그 결과를 스크롤·레이아웃처럼 자주 도는 루프 안에서 반복해 접근하면, 지연됐던 박싱 비용이 접근할 때마다 조금씩 청구된다. 뜨거운 경로에선 한 번만 순회해서 필요한 형태로 확정해두는 편이 낫다.
// ❌ 매 프레임 콜백마다 NSArray 원소를 다시 언박싱
func tableView(_ tv: UITableView, cellForRowAt ip: IndexPath) -> UITableViewCell {
let n = legacyNSArray[ip.row] as! NSNumber // 호출될 때마다 박싱 비용
// ...
}
// ✅ 화면에 쓰기 전에 한 번만 Swift 네이티브 타입으로 확정
let ids: [Int] = legacyNSArray.compactMap { ($0 as? NSNumber)?.intValue }"toll-free bridging이니 Array<Int>를 NSArray로 바꾸는 것도 공짜다"는 두 계층을 혼동한 말이다. 진짜 toll-free인 건 CF↔NS뿐이고, Swift 값 타입↔NS 클래스는 _ObjectiveCBridgeable이 처리하는 별개의 메커니즘이다. 반대로 "Swift 브리징은 항상 그 자리에서 전체를 복사한다"도 틀렸다 — 위 실측처럼 캐스트 자체는 지연 래퍼를 만들 뿐, 비용은 실제 소비 시점까지 미뤄지는 경우가 흔하다.
CF와 NS 사이를 오가는 건 한 사람을 별명으로 부르는 것과 같다. "철수"라 부르든 "김철수"라 부르든 그 사람은 똑같이 그 자리에 있으니 아무 일도 할 필요가 없다. Swift 배열을 NSArray로 바꾸는 건 두꺼운 책을 "필요한 페이지만 그때그때 번역해주겠다"고 약속하는 것과 비슷하다 — 약속하는 순간엔 아무 페이지도 번역하지 않고, 누가 실제로 3쪽을 펴서 읽어달라고 할 때만 그 페이지를 번역한다. 비유가 깨지는 곳: 실제 번역가는 사람이 요청할 때마다 매번 다시 번역하지만, 컴퓨터는 한 번 박싱한 값을 계속 재사용하기도 해서 "몇 번을 다시 읽어도 매번 새로 번역한다"는 비유가 완전히 정확하진 않다.
꼬리 질문
[Int] as NSArray 캐스트에서 정말로 함수 호출이 0번인가?
__SwiftDeferredNSArray 래퍼 객체 자체는 새로 만들어지는 힙 객체이고, 그걸 가리키는 참조를 주고받는 과정에서 retain/release 정도의 가벼운 오버헤드는 있다. "toll-free"가 의미하는 건 "새 버퍼를 할당하고 원소를 복사·변환하는 무거운 작업이 없다"는 것이지, 참조 카운팅 자체까지 0이라는 뜻은 아니다.왕복 캐스팅(as NSArray 후 다시 as! [Int])도 지연되는가?
NSArray로 캐스팅한 뒤 다시 [Int]로 되돌리는 데 0.0127ms밖에 걸리지 않았는데, 이는 10만 개의 NSNumber를 전부 풀어서 새 Int 배열을 만들기엔 너무 짧은 시간이다. 왕복 전체가 서브밀리초 단위로 끝난다는 건 양쪽 방향 모두 즉시 전체를 변환하지 않는다는 정황 증거이지만, 내부적으로 정확히 어떤 지연 구조를 쓰는지까지는 이 실험만으로 단정하지 않는다.CFBridgingRetain/CFBridgingRelease는 언제 필요한가?
void* 하나만 받는 C API에 Swift/ObjC 객체를 실어 보내야 할 때)로 객체를 넘길 때, 소유권을 수동으로 넘기고 돌려받는 지점에서 쓴다. CFBridgingRetain은 ARC 객체의 소유권을 가져와 retain count를 유지한 채 CFTypeRef로 바꾸고, CFBridgingRelease는 그 소유권을 다시 ARC에 돌려준다. 이 챕터에서 본 as 캐스트는 ARC가 양쪽 다 관리하는 일반적인 경우라 이 두 함수가 필요 없다.Q2. _ObjectiveCBridgeable은 무슨 일을 하는가? "String은 그냥 NSString"이 왜 부정확한가?
_ObjectiveCBridgeable은 표준 라이브러리 내부(비공개) 프로토콜로, String/Array/Dictionary/Set 같은 값 타입에 _bridgeToObjectiveC()·_forceBridgeFromObjectiveC() 같은 언더스코어 메서드를 요구해 ObjC 타입과 상호 변환하는 규약을 제공한다. "String은 그냥 NSString이다"가 부정확한 이유는, object_getClass로 실제 런타임 클래스를 찍어보면 상황(길이, 컴파일타임 상수 여부, 출처)에 따라 완전히 다른 표현 — 힙 할당 자체가 없는 태그드 포인터, Swift 네이티브 버퍼가 그대로 NSString 행세를 하는 경우, 진짜 NSString에서 브리지된 경우 — 가 갈리기 때문이다. "String == NSString"은 하나의 진실이 아니라 여러 구현 전략을 뭉뚱그린 말이다.
원리
SE-0069가 지적하듯 표준 라이브러리는 거의 전부 값 타입인데(Swift 2.2 기준 struct 109개 vs class 6개) Foundation 클러스터만 참조 타입이던 부조화를 없애려고 String/Array/Dictionary/Set은 진짜 값 타입으로 재설계됐다. 그러면서도 방대한 ObjC API 표면과 상호운용해야 하니, 컴파일러와 런타임이 _ObjectiveCBridgeable을 통해 "이 값 타입을 이렇게 ObjC 객체로 내보내고, 이렇게 되돌린다"는 규약을 표준 라이브러리 소스에 심어뒀다. 이 프로토콜은 공개 문서화된 API가 아니라 컴파일러가 as 캐스팅 문법 뒤에서 호출하는 내부 구현이다.
내부 동작
말로 설명하기보다 직접 object_getClass를 찍어보면 뉘앙스가 바로 드러난다(Swift 6.2.1 / macOS 26 실측). 짧은 리터럴 "hi"는 NSTaggedPointerString으로 나온다 — 힙에 아무것도 할당하지 않고 문자 데이터를 포인터 비트 안에 직접 욱여넣은 "즉시값"이다. 75자짜리 컴파일타임 상수 리터럴은 NSIndirectTaggedPointerString으로 나온다 — 이 역시 malloc을 부르지 않고, 컴파일된 바이너리 안의 상수 문자열 테이블을 가리키기만 한다. NSString(string:)으로 만든 뒤 다시 as String으로 되돌린 값도 같은 NSIndirectTaggedPointerString이었는데, 이건 애초에 그 리터럴 자체가 컴파일타임 상수였기 때문이다. 반면 반복문으로 런타임에 조립한 80바이트짜리 문자열(컴파일타임 상수가 아님)은 Swift.__StringStorage로 나왔다 — Swift 고유의 네이티브 문자열 버퍼 클래스가 복사 없이 그 자체로 NSString 행세를 한 것이다. 5바이트짜리 짧은 런타임 문자열은 출처와 무관하게 다시 NSTaggedPointerString이었다. 다섯 케이스 중 어느 것도 "새 NSString 버퍼를 할당해 데이터를 복사"하지 않았다 — 전부 즉시값이거나 기존 Swift 버퍼의 재사용이었다.
| String 출처 | 길이 | 실측 object_getClass | 힙 할당 |
|---|---|---|---|
짧은 리터럴 "hi" | 2자 | NSTaggedPointerString | 없음 — 포인터에 값 직접 인코딩 |
| 긴 리터럴(컴파일타임 상수) | 75자 | NSIndirectTaggedPointerString | 없음 — 컴파일된 상수 테이블 참조 |
NSString(string:)에서 브리지 | 21자(상수) | NSIndirectTaggedPointerString | 없음 |
| 런타임 조립(반복문으로 생성) | 80바이트 | Swift.__StringStorage | Swift 버퍼 그대로 재사용 — 새 복사 없음 |
| 런타임 조립(짧음) | 5바이트 | NSTaggedPointerString | 없음 |
실험 · 도구
아래 코드를 그대로 저장해 swift 파일명.swift로 실행하면 위 표의 다섯 줄을 직접 재현할 수 있다.
import Foundation
func className(_ s: String) -> String {
NSStringFromClass(object_getClass(s as AnyObject)!)
}
let short: String = "hi"
let long: String = "this is a much longer string literal that exceeds the small string buffer"
let fromNS: String = NSString(string: "bridged-from-nsstring") as String
var built = ""
for i in 0..<30 { built += "x\(i)" } // 컴파일타임 상수가 아니게 만든다
let shortBuilt = String(repeating: "a", count: 5)
print("짧은 리터럴:", className(short))
print("긴 리터럴:", className(long))
print("NSString에서 브리지:", className(fromNS))
print("런타임 조립(80B):", className(built))
print("런타임 조립(짧음):", className(shortBuilt))프로젝트 적용
① ObjC 프레임워크로 문자열을 자주 넘기는 코드에서 "브리징 = 항상 복사"라고 가정하고 미리 캐싱 같은 걸 만들 필요는 없다 — 실측상 대부분의 경로가 이미 무할당이거나 버퍼를 재사용한다.
@objc protocol LegacyDelegate {
func didReceive(text: String)
}
// 매 호출마다 새 NSString 버퍼를 만든다고 걱정해 별도 캐싱 계층을 두는 건
// 대부분의 경우 불필요한 조기 최적화다
② 반대로 "String은 사실 NSString이니 NSString API로 캐스팅 없이 막 써도 된다"는 식으로 타입을 흐리게 쓰면, UTF-16 기반인 NSString의 인덱스 체계가 Swift의 grapheme 기반 String.Index와 어긋나는 버그를 만든다(자세한 내용은 12장). 브리징이 저렴하다는 것과 두 타입의 의미론이 같다는 것은 별개다.
let emoji = "👨👩👧👦"
print(emoji.count) // 1 — Swift grapheme 기준
print((emoji as NSString).length) // 훨씬 큰 값 — UTF-16 code unit 기준"String은 그냥 NSString의 Swift 이름일 뿐이다"는 틀렸다. String은 UTF-8 기반의 진짜 Swift 값 타입이고, 브리징된 인스턴스가 내부적으로 tagged pointer나 __StringStorage를 재사용하는 경우가 많을 뿐이다. 거꾸로 "브리징은 항상 새 NSString 객체를 할당한다"도 틀렸다 — 위 실측 다섯 케이스 중 진짜 새 버퍼를 할당한 경우는 없었다.
이름표가 두 개인 사물함이 있다고 하자. 안에 물건이 아주 작으면(짧은 문자열) 이름표 자체에 내용을 다 적어버려서 사물함이 필요 없다(태그드 포인터). 물건이 크면 원래 있던 상자를 그대로 두고 이름표만 하나 더 붙인다(__StringStorage 재사용). 아주 가끔은 다른 사람이 이미 포장해둔 상자를 그대로 받아 쓴다(진짜 NSString). 셋 다 "NSString"이라는 이름표가 붙지만 안의 사정은 전혀 다르다. 비유가 깨지는 곳: 실제로는 어떤 방식을 쓸지 프로그래머가 고르는 게 아니라 문자열의 길이·출처에 따라 런타임이 알아서 정한다.
꼬리 질문
tagged pointer는 정말로 "객체"인가, 아니면 그냥 정수값인가?
object_getClass를 부르면 NSTaggedPointerString이라는 진짜 클래스 이름이 나온다 — ObjC 런타임이 포인터의 특정 비트 패턴을 보고 "이건 힙 주소가 아니라 태그드 값"이라고 구분해서 마치 진짜 객체인 것처럼 메시지 디스패치를 흉내 내주기 때문이다. 개발자 입장에서는 힙 객체와 똑같이 다룰 수 있지만, 내부적으로는 malloc도 free도 없다.왜 컴파일타임 상수 리터럴은 NSTaggedPointerString이 아니라 NSIndirectTaggedPointerString으로 나오는가?
malloc이 없다는 공통점은 같고, "값을 직접 담느냐 상수 테이블을 가리키느냐"만 다르다.런타임에 조립한 문자열이 Swift.__StringStorage로 나왔다는 건 정확히 무슨 뜻인가?
__StringStorage가 NSString이 요구하는 최소 인터페이스(length, characterAtIndex: 등에 해당하는 것)를 자체적으로 구현하고 있어서, ObjC 쪽에서 그 값을 NSString처럼 다뤄도 실제로는 처음부터 있던 Swift 버퍼를 그대로 참조한다는 뜻이다. 즉 "String을 NSString으로 바꾼다"가 아니라 "이미 있는 Swift 버퍼에 NSString인 척하는 얼굴을 하나 씌운다"에 가깝다.Q3. 오늘날 NSObject를 상속해야만 하는 경우는 언제인가?
NSObject 상속이 실제로 강제되는 자리는 좁다 — KVO의 observe(_:options:changeHandler:)/addObserver(_:forKeyPath:) API 표면 자체가 NSObject 클래스에 붙어 있어서 @objc dynamic 프로퍼티만으로는 부족하고, NSCoding/NSSecureCoding 기반 아카이빙(Interface Builder 언아카이빙 포함), NSObjectProtocol을 상속하는 @objc 델리게이트 프로토콜(UITableViewDataSource 등), 그리고 XPC/Pasteboard처럼 클래스 화이트리스트를 요구하는 시스템 API가 대표적이다. 순수 Foundation 값 타입(String/Array/Data/URL 등)을 쓰는 데는 전혀 필요 없다.
원리
NSObject는 ObjC 런타임과의 접점 — isKindOfClass:, respondsToSelector:, perform(_:with:), KVC/KVO, 동적 메시지 디스패치 — 을 제공하는 루트 클래스다. Swift 순수 class는 이 런타임 인프라를 자동으로 상속하지 않는다. 다만 @objc/dynamic 멤버를 개별적으로 붙이면 그 멤버는 ObjC 런타임에 노출될 수 있는데, 이게 "NSObject가 제공하는 API 전체를 쓸 수 있다"는 뜻은 아니다 — 아래 실측이 그 간극을 정확히 보여준다.
내부 동작
NSObject를 상속하지 않은 순수 Swift 클래스에 @objc dynamic var value: Int = 0을 붙이면 컴파일이 그대로 통과한다. 그 인스턴스를 is NSObject로 검사하면 false가 나오지만, is NSObjectProtocol로 검사하면 true가 나온다 — ObjC 런타임이 이 클래스를 최소한의 수준으로는 인식한다는 뜻이다. 그런데도 그 인스턴스에 .observe(\.value, options: [.new]) { ... }를 호출하면 "value of type 'PlainA' has no member 'observe'"라는 진짜 컴파일 에러가 난다(Swift 6.2.1 / macOS 26 실측). 이유는 observe(_:options:changeHandler:)가 NSObjectProtocol이 아니라 구체적으로 NSObject 클래스에 붙은 확장 메서드이기 때문이다. 즉 "이 클래스의 멤버 하나하나를 ObjC 런타임에 노출한다"는 것과 "이 클래스가 NSObject가 제공하는 KVO observer API 전체를 쓸 수 있다"는 건 완전히 다른 문제다 — 실무에서 NSObject를 상속해야 하는 이유는 정확히 이 간극 때문이다.
| 상황 | 왜 NSObject 전체가 필요한가 | 대표 API |
|---|---|---|
| KVO | observe/addObserver(_:forKeyPath:)가 NSObjectProtocol이 아니라 NSObject 클래스 자체의 API | observe(_:options:changeHandler:) |
| NSCoding/NSSecureCoding 아카이빙 | NSKeyedArchiver/Unarchiver가 NSObject 계층의 alloc/init 인스턴스화 규약에 의존 | Interface Builder .xib/.storyboard, NSItemProvider |
| UIKit 델리게이트 프로토콜 다수 | UITableViewDataSource 등이 NSObjectProtocol을 상속해 요구사항에 포함시킴 | UITableViewDataSource, UICollectionViewDataSource |
| 구식 KVO 기반 Combine 퍼블리셔 | NSObject.publisher(for:) 자체가 NSObject 인스턴스 메서드 | .publisher(for:) |
| XPC / Pasteboard / Drag&Drop | 클래스 화이트리스트 계약이 NSObject 계층 인스턴스화를 전제 | NSXPCConnection, NSItemProvider |
실험 · 도구
아래 두 파일을 각각 swiftc -typecheck와 swift로 돌리면 위에서 말한 간극을 그대로 재현할 수 있다.
import Foundation
class PlainA {
@objc dynamic var value: Int = 0 // 컴파일 성공
}
let a = PlainA()
print(a is NSObject) // false
print(a is NSObjectProtocol) // true — 최소한의 런타임 인식은 됨
// let token = a.observe(\.value) { _, _ in }
// ❌ error: value of type 'PlainA' has no member 'observe'같은 프로퍼티를 NSObject를 상속한 클래스에 옮기면 observe가 바로 나타난다 — Q4에서 이 API를 실제로 써서 isa-swizzling까지 확인한다.
프로젝트 적용
① 순수하게 "값이 바뀌면 알림받고 싶다"는 요구뿐이라면 NSObject + KVO로 무겁게 갈 필요가 없다. Swift 네이티브 @Observable(27장)이 ObjC 런타임·isa-swizzling 없이 같은 문제를 푼다.
// ❌ 관찰만 하면 되는데 NSObject 상속과 @objc dynamic까지 끌고 온다
final class LegacySettings: NSObject {
@objc dynamic var isDarkMode: Bool = false
}
// ✅ Swift 네이티브 관찰 — ObjC 런타임도, isa-swizzling도 필요 없다
@Observable
final class Settings {
var isDarkMode: Bool = false
}② 반대로 UITableViewDataSource처럼 이미 NSObjectProtocol을 요구하는 시스템 API를 구현할 땐 NSObject를 피할 방법이 없다 — 그 자리에서 억지로 순수 Swift 타입을 쓰려 하지 않는다.
final class ProductListDataSource: NSObject, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
products.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// ...
UITableViewCell()
}
private let products: [String] = []
}"NSObject를 상속하지 않으면 @objc를 아예 쓸 수 없다"는 틀렸다 — 위 실측처럼 순수 Swift 클래스에도 @objc dynamic 프로퍼티는 붙는다. 반대로 "@objc dynamic만 붙이면 NSObject가 제공하는 기능(예: KVO의 observe)이 자동으로 딸려온다"도 틀렸다 — 그 API들은 프로토콜 준수가 아니라 NSObject 클래스 자체에 정의돼 있어서, 진짜로 그 클래스를 상속해야만 쓸 수 있다.
NSObjectProtocol만 인식되는 것은 회사 로비까지만 들어갈 수 있는 방문증과 같다 — 경비원이 "너 여기 온 거 알아"라고는 해주지만(is NSObjectProtocol == true), 사무실 문은 하나도 못 연다. NSObject를 진짜로 상속하는 건 사무실 곳곳 문을 다 여는 정직원 카드를 받는 것과 같다. 비유가 깨지는 곳: 현실에서는 방문증을 정직원 카드로 업그레이드해달라고 요청할 수 있지만, Swift에서는 그 업그레이드가 "나중에 상속을 추가"하는 식으로 되지 않고 애초에 클래스 선언 시점에 NSObject를 상속했어야 한다.
꼬리 질문
is NSObjectProtocol이 true인데 왜 .responds(to:)는 정적으로 호출할 수 없는가?
is는 런타임 타입 검사라 "이 인스턴스가 실제로 그 프로토콜 요구사항을 만족하는 객체로 ObjC 런타임에 인식되는가"만 확인한다. 반면 .responds(to:) 같은 멤버를 점(.) 문법으로 정적으로 호출하려면, 컴파일러가 그 타입의 선언에서 NSObjectProtocol 준수를 명시적으로 읽을 수 있어야 한다. PlainA는 class PlainA { ... }로만 선언돼 있어 소스 코드 어디에도 NSObjectProtocol 준수를 선언하지 않았으므로, 런타임은 인식해도 정적 타입 체커는 그 멤버를 노출해주지 않는다.@objcMembers를 클래스에 붙이면 NSObject 상속 없이도 KVO를 쓸 수 있는가?
@objcMembers는 그 클래스의 적용 가능한 멤버 전부에 @objc를 자동으로 붙여주는 선언 편의 기능일 뿐, NSObject가 제공하는 확장 API(observe 등)나 NSObjectProtocol 준수 선언 자체를 추가해주지는 않는다. 여전히 각 멤버가 개별적으로 ObjC 런타임에 노출될 뿐이라, 이 챕터에서 확인한 간극이 그대로 남는다.Swift Concurrency 관점에서 NSObject 상속 자체가 부담이 되는 경우가 있는가?
NSObject를 상속한 클래스는 기본적으로 Sendable이 아니고, 특히 그 서브클래스가 가변 프로퍼티를 갖고 여러 액터/스레드에 노출되면 Swift 6 strict concurrency 검사에서 계속 경고·에러를 만나게 된다. 순수히 관찰만 필요한 상황에서 NSObject를 습관적으로 상속하면, 나중에 동시성 경계를 넘길 때마다 그 대가를 치르게 된다. 자세한 내용은 17장에서 이어간다.Q4. @objc와 dynamic은 각각 무엇을 하며, KVO에는 왜 둘 다 필요한가?
@objc는 그 멤버를 ObjC 런타임의 selector 테이블에 노출시킬 뿐이고, 그 멤버를 실제로 어떻게 호출할지는 여전히 Swift 컴파일러가 정적으로 결정할 수 있다. dynamic은 그 멤버의 모든 호출이 반드시 ObjC의 동적 메시지 디스패치(objc_msgSend)를 거치도록 강제한다. KVO가 둘 다 요구하는 이유는, 관찰이 시작되는 순간 런타임이 원본 클래스를 몰래 서브클래싱해(NSKVONotifying_...) setter를 오버라이드하는데, 그 가로채기가 실제로 발동하려면 (1) 그 프로퍼티가 애초에 ObjC selector로 존재해야 하고(@objc), (2) 실제 setter 호출이 컴파일 타임에 확정되지 않고 매번 런타임에 다시 찾아져야(dynamic) 하기 때문이다.
vtable/witness table/objc_msgSend 세 디스패치 방식 자체의 기초는 이미 7장에서 다뤘다. 여기서는 그 지식을 KVO의 isa-swizzling에 실제로 적용해 무엇이 가로채지고 무엇이 안 가로채지는지 직접 확인한다.
원리
Swift 컴파일러는 호출부의 정적 타입을 알고 있으면 그 정보를 최대한 활용해 빠른 디스패치(직접 호출 또는 vtable)를 쓰려 한다. @objc는 "이 멤버를 ObjC 런타임이 찾을 수 있게 만든다"는 선언일 뿐, "이 멤버의 모든 호출을 ObjC 런타임을 거치게 강제한다"는 뜻이 아니다. dynamic이 그 강제를 담당한다 — 이 멤버에 대해서는 Swift가 정적 최적화를 포기하고 항상 objc_msgSend를 부르도록 만든다. 메서드 스위즐링(다른 구현으로 selector를 바꿔치기하는 기법)이나 KVO의 isa-swizzling처럼 "런타임에 호출을 가로챈다"는 기법은 전부 이 동적 디스패치가 실제로 매번 일어난다는 전제 위에 서 있다.
내부 동작
@objc dynamic var name: String을 가진 NSObject 서브클래스에서 관찰을 시작하기 전후로 object_getClass를 찍어보면 다음과 같다(Swift 6.2.1 / macOS 26 실측).
관찰 전 object_getClass: main.Dog
관찰 전 type(of:): main.Dog
관찰 후 object_getClass: ..NSKVONotifying_main.Dog
관찰 후 type(of:): main.Dog ← 그대로!관찰을 시작하자마자 object_getClass가 가리키는 실제 클래스가 NSKVONotifying_Dog로 바뀐다 — 런타임이 Dog의 동적 서브클래스를 새로 만들어(objc_registerClassPair 계열 API로) name의 setter를 오버라이드한 뒤, 그 인스턴스의 isa 포인터를 이 새 클래스로 바꿔치기(swizzling)한 것이다. 흥미로운 건 type(of:)는 여전히 원래 클래스 Dog를 보여준다는 점이다 — KVO가 심는 서브클래스는 -class(Swift에서 type(of:)가 최종적으로 의존하는 경로)를 오버라이드해서 원래 클래스 이름을 거짓으로 돌려주도록 만들어져 있다. 그래서 isa 포인터를 직접 읽는 object_getClass만 진짜 클래스를 드러내고, "정상적인" 클래스 조회 경로는 이 스위즐링을 숨긴다.
이 가로채기가 dynamic 없이도 성립하는지 직접 확인해볼 수 있다. @objc만 붙이고 dynamic은 뺀 프로퍼티로 같은 실험을 하면, Swift 컴파일러가 실제로 경고를 낸다.
final class Cat: NSObject {
@objc var name: String = "Nabi" // dynamic 없음
}
let cat = Cat()
let token = cat.observe(\.name) { _, change in print("콜백:", change.newValue ?? "?") }
cat.name = "Yaong"
// 실제 컴파일러 경고:
// warning: passing reference to non-'@objc dynamic' property 'name' to KVO method
// 'observe(_:options:changeHandler:)' may lead to unexpected behavior or runtime trap실제로 돌려보면 object_getClass는 이번에도 NSKVONotifying_Cat으로 바뀐다 — @objc만으로도 런타임이 서브클래스를 만드는 것 자체는 시도한다. 하지만 cat.name = "Yaong"이 콜백을 발동시키지 않는다. name이 dynamic이 아니므로, cat.name = ...이라는 대입은 Swift 컴파일러가 정적 타입 Cat을 알고 있는 그 자리에서 곧장 저장소에 쓰는 코드로 컴파일돼버려서, 애초에 objc_msgSend를 거치지 않고 스위즐링된 서브클래스의 오버라이드된 setter를 우회한다. isa는 바뀌었는데 그 바뀐 isa를 실제로 거쳐 가는 호출이 하나도 없는 셈이다.
실험 · 도구
아래 코드를 그대로 저장해 swift 파일명.swift로 실행하면 위 실측 출력을 그대로 재현할 수 있다.
import Foundation
final class Dog: NSObject {
@objc dynamic var name: String = "Rex"
}
let dog = Dog()
print("관찰 전:", NSStringFromClass(object_getClass(dog)!))
let obs = dog.observe(\.name) { _, _ in }
print("관찰 후:", NSStringFromClass(object_getClass(dog)!))
obs.invalidate()LLDB에서는 관찰 중인 인스턴스에 po object_getClass(dog)를 직접 쳐서 같은 걸 확인할 수 있다.
프로젝트 적용
① Interface Builder나 KVO로 관찰될 프로퍼티는 반드시 @objc dynamic 둘 다 붙인다 — 하나라도 빠지면 컴파일은 되지만 콜백이 조용히 발동하지 않는, 발견하기 아주 까다로운 버그가 된다.
final class PlayerState: NSObject {
@objc dynamic var score: Int = 0 // @objc만 있고 dynamic이 빠지면 관찰이 조용히 실패한다
}② 크래시 로그나 디버거에서 NSKVONotifying_YourClass라는 낯선 클래스 이름을 보게 되면 당황하지 말고 "누군가 이 인스턴스를 KVO로 관찰 중"이라는 신호로 읽는다. 버그가 아니라 정상적인 isa-swizzling의 흔적이다.
// 크래시 로그에 NSKVONotifying_PlayerState 가 보이면
// "PlayerState의 어떤 인스턴스를 누가 관찰하고 있다"는 뜻이다.
// observationInfo를 찍어보면 어떤 키패스를 관찰 중인지 힌트를 얻을 수 있다.
print(dog.observationInfo as Any)"@objc만 붙이면 KVO가 동작한다"는 틀렸다 — 위 실측처럼 isa 스위즐링 자체는 일어나도 실제 setter 호출이 dynamic 없이는 objc 디스패치를 타지 않아 콜백이 발동하지 않는다. 반대로 "dynamic이 성능을 늘 심각하게 깎아먹으니 피해야 한다"도 과장이다 — dynamic은 그 멤버 하나의 호출 방식만 바꿀 뿐이고, KVO·Interface Builder 연동처럼 애초에 동적 디스패치가 필요한 자리에서는 그 비용이 기능의 전제 조건이다.
@objc는 네 이름을 회사 전화번호부에 등록하는 것과 같다 — 누군가 전화번호부를 뒤져서 너를 찾아낼 수는 있게 된다. dynamic은 "내 전화는 항상 안내데스크를 거쳐서 연결해달라"는 규칙이다. 안내데스크를 거치기로 약속해야만, 안내데스크가 "어, 이 사람 지금 자리를 옮겼으니 대신 이 사람한테 연결해드릴게요"(가로채기)라고 바꿔치기할 수 있다. 안내데스크를 안 거치고 다들 네 자리 직통 번호로 바로 걸면, 안내데스크가 아무리 바꿔치기를 준비해놔도 아무 소용이 없다. 비유가 깨지는 곳: 현실의 안내데스크는 매번 거치면 다소 느려지지만, KVO 대상이 아닌 일반 코드에서는 dynamic 없는 직통 호출이 압도적으로 흔하고 빠르다는 게 오히려 기본값이다.
꼬리 질문
@objc만 있고 dynamic이 없는데도 isa가 NSKVONotifying_...으로 바뀌는 이유는?
observe(_:options:changeHandler:)는 호출 시점에 그 프로퍼티가 ObjC selector로 존재하는지만 확인하고 서브클래스 생성 자체를 진행한다 — 이 단계는 @objc만으로 충분하다. 문제는 그 다음 단계다. 실제로 값을 바꾸는 cat.name = "Yaong" 같은 코드가 스위즐링된 서브클래스의 오버라이드된 setter를 거쳐 가느냐는 별개의 문제이고, 이건 그 대입문이 컴파일될 때 정적 디스패치로 굳어지는지 objc_msgSend로 나가는지에 달려 있다. dynamic이 없으면 후자가 성립하지 않아 서브클래스는 만들어졌지만 아무도 그 서브클래스의 오버라이드를 거치지 않는 상황이 된다.왜 type(of:)는 스위즐링된 클래스를 보여주지 않는가?
-class(그리고 관련된 -classForCoder 등)를 오버라이드해서, 실제 isa가 NSKVONotifying_Dog임에도 불구하고 "내 클래스는 Dog다"라고 거짓으로 답하도록 설계돼 있다. Swift의 type(of:)는 이 경로를 통해 클래스를 조회하기 때문에 스위즐링을 알아채지 못한다. object_getClass는 그 오버라이드를 우회해 isa 포인터를 직접 읽으므로 진짜 클래스가 드러난다. 이건 버그가 아니라 KVO가 "구현 세부사항을 숨겨서 기존 코드가 깨지지 않게" 의도적으로 설계한 방식이다.-class)에는 여전히 원래 이름이 적혀 있지만, 뒷조사(object_getClass, isa 직접 확인)를 해보면 사실 다른 사람으로 바뀌어 있었다는 뜻이다.observe 토큰을 여러 번 만들면 서브클래스도 여러 번 새로 만들어지는가?
NSKVONotifying_Dog로 바뀌어 있으므로, 런타임은 그 존재하는 스위즐링된 서브클래스를 재사용한다 — 관찰할 때마다 매번 새 서브클래스를 만드는 게 아니다. 다만 같은 이름의 클래스가 다른 인스턴스에서 이미 등록돼 있는지, 그 클래스가 프로세스 안에서 한 번만 캐시되는지 같은 세부사항까지는 이 실험만으로 확인하지 않았다.Q5. NSSecureCoding은 무엇으로부터 안전한가? Codable로 대체할 수 없는 것은?
NSSecureCoding은 암호화나 무결성 검증이 아니라 객체 치환 공격(object substitution attack)으로부터 안전하다 — 예전 NSCoding은 아카이브에 적힌 클래스 이름 문자열을 그대로 믿고 인스턴스화했는데, 그 바이트를 공격자가 조작할 수 있다면 예상 밖의 위험한 클래스가 인스턴스화될 수 있었다. decodeObject(of:forKey:)는 호출부가 "이 키엔 이 클래스만 허용한다"를 미리 선언하게 해 그 공격을 막는다. Codable엔 이 개념 자체가 없는데, 정적 타입 T: Decodable이 호출 지점에 이미 컴파일 타임으로 고정돼 있어서 공격자가 이름 붙인 임의 타입이 인스턴스화될 여지가 애초에 없기 때문이다. Codable이 대체하지 못하는 것은 순환·동일성을 보존하는 객체 그래프, 그리고 NSAttributedString처럼 NSSecureCoding만 구현하고 Codable은 채택하지 않은 프레임워크 객체다.
원리
고전적인 NSCoding 디코딩은 아카이브 안에 적힌 클래스 이름을 NSClassFromString 계열로 찾아 그 클래스의 init(coder:)를 호출하는 방식이었다 — 아카이브 바이트를 공격자가 통제할 수 있는 상황(네트워크로 받은 데이터, 캐시 파일, 붙여넣기 등)이라면, 이건 Java의 ObjectInputStream이나 Python의 pickle과 같은 계열의 고전적인 "역직렬화 취약점" 표면이다. NSSecureCoding은 여기에 딱 하나를 더한다 — 디코딩하는 쪽이 "이 키에서는 정확히 이 클래스(들)만 나와야 한다"를 미리 선언하게 만들고, 실제 아카이브의 클래스가 그 화이트리스트에 없으면 인스턴스를 만들지 않는다.
내부 동작
직접 실험한 결과가 이 방어를 그대로 보여준다. NSAttributedString은 NSSecureCoding과 NSCoding은 채택하지만 Decodable은 채택하지 않는다(실측: NSAttributedString.self is Decodable.Type은 false). requiringSecureCoding: true로 아카이빙하면 378바이트짜리 데이터가 나오고, decodeObject(of: NSAttributedString.self, forKey:)에 해당하는 unarchivedObject(ofClass:from:)로 정확한 클래스를 지정해 복원하면 "hello"가 그대로 돌아온다. 그런데 같은 데이터를 일부러 잘못된 클래스(NSString.self)로 복원을 시도하면, 크래시도 오류도 없이 nil이 돌아온다 — 이게 바로 화이트리스트 검사가 실제로 작동하는 순간이다. 아카이브에 무슨 클래스 이름이 적혀 있든, 호출부가 기대한 클래스와 다르면 그 자리에서 조용히 실패로 처리된다.
| 항목 | Codable | NSSecureCoding |
|---|---|---|
| 막는 대상 | 신뢰 경계라는 개념 자체가 없음 | 객체 치환 공격(예상 밖 클래스 인스턴스화) |
| 타입 검사 시점 | 호출부 제네릭 T가 컴파일 타임에 고정 | 런타임에 클래스 화이트리스트와 비교 |
| 암호학적 보안(암호화·무결성) | 없음 | 없음 — "안전"은 타입 안전을 뜻할 뿐 |
| 순환 참조·동일성 보존 그래프 | 지원 안 함 — 트리 구조 전제 | NSKeyedArchiver가 객체 그래프의 동일성·순환을 보존 |
NSAttributedString/UIImage/UIColor | 대부분 미채택 | 채택됨 — 이 경로로만 직렬화 가능 |
실험 · 도구
아래 코드가 위 표와 다이어그램의 실측 결과를 그대로 만든다.
import Foundation
print(NSAttributedString.self is Decodable.Type) // false — Codable 미채택
let attr = NSAttributedString(string: "hello")
let data = try! NSKeyedArchiver.archivedData(withRootObject: attr, requiringSecureCoding: true)
print(data.count) // 378
// 올바른 클래스로 복원 — 성공
let back = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data)
print(back?.string ?? "nil") // "hello"
// 일부러 다른 클래스를 기대하며 복원 — 화이트리스트 불일치로 nil
let wrong = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSString.self, from: data)
print(wrong as Any) // nil프로젝트 적용
① UIImage처럼 Codable을 채택하지 않고 NSSecureCoding만 지원하는 UIKit 타입을 Codable 구조체 안에 담아야 한다면, NSKeyedArchiver로 만든 Data를 그 구조체의 프로퍼티로 감싸는 계층적 합성이 실무 해법이다.
struct ProfileImageWrapper: Codable {
let imageData: Data
init(image: UIImage) {
// requiringSecureCoding: true로 화이트리스트 검증 경로를 강제한다
imageData = try! NSKeyedArchiver.archivedData(withRootObject: image, requiringSecureCoding: true)
}
var image: UIImage? {
try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: imageData)
}
}② NSSecureCoding을 직접 구현할 때는 반드시 클래스를 구체적으로 지정하는 decodeObject(of:forKey:)를 쓴다 — deprecated된 decodeObject(forKey:)는 화이트리스트 검사 자체가 없어 이 챕터에서 다룬 방어를 무력화한다.
final class Config: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true }
let name: String
init(name: String) { self.name = name }
required init?(coder: NSCoder) {
// ✅ 클래스를 명시 — 다른 클래스가 오면 nil
guard let name = coder.decodeObject(of: NSString.self, forKey: "name") as String? else { return nil }
self.name = name
}
func encode(with coder: NSCoder) {
coder.encode(name, forKey: "name")
}
}"NSSecureCoding이니까 안전(보안)하다"는 틀렸다 — 이건 암호화도 무결성 검증도 아니고, 딱 하나 "예상한 클래스만 인스턴스화된다"는 타입 안전만 보장한다. 데이터 자체를 위·변조로부터 지키는 건 별도의 서명·암호화 계층이 해야 할 일이다. 반대로 "Codable이 나왔으니 NSCoding은 죽은 기술이다"도 틀렸다 — NSAttributedString 같은 다수의 프레임워크 타입이 여전히 Codable을 채택하지 않고 NSSecureCoding 경로에만 의존한다.
택배를 받을 때 "보낸 사람이 누구든 상자 안에 뭐가 들었는지는 상관없이 그냥 연다"가 옛날 방식(NSCoding)이라면, "상자 겉면에 적힌 발신자가 내가 미리 정해둔 목록에 있는 사람일 때만 연다"가 NSSecureCoding이다. 목록에 없는 발신자면 상자를 아예 열지 않고 그대로 돌려보낸다(nil). 비유가 깨지는 곳: 이 검사는 "발신자 이름이 목록에 있는가"만 볼 뿐 상자 안 내용물이 진짜 안전한지, 상자가 배송 중에 뜯겼는지는 전혀 확인하지 않는다 — 이름표 검사와 내용물 보안은 서로 다른 문제다.
꼬리 질문
Codable로 UIImage를 직접 채택시키면 안 되는가?
extension UIImage: Codable을 시도할 수는 있지만, UIImage는 Apple이 제공하는 클래스이고 내부 저장 방식(비트맵, 벡터, 애니메이션 프레임 등)이 공개돼 있지 않아 안전하고 손실 없는 init(from:)/encode(to:)를 직접 작성하기 어렵다. 이미 검증된 NSSecureCoding 경로를 감싸는 쪽이 훨씬 안전하고, Apple이 내부 구현을 바꿔도 그대로 동작한다.순환 참조가 있는 객체 그래프를 Codable로 인코딩하면 무슨 일이 일어나는가?
Encoder/Decoder 모델은 기본적으로 트리 구조(부모가 자식을 소유하는 형태)를 전제한다. A가 B를 참조하고 B가 다시 A를 참조하는 순환이 있으면, 순진하게 구현한 encode(to:)는 무한 재귀에 빠져 스택 오버플로로 크래시하기 쉽다. NSKeyedArchiver는 이미 인코딩한 객체를 식별자로 추적해 같은 객체를 다시 만나면 참조만 남기는 방식으로 이 문제를 근본적으로 해결해두었다.실측에서 잘못된 클래스로 복원을 시도했을 때 왜 크래시가 아니라 nil이 나왔는가?
unarchivedObject(ofClass:from:)(그리고 그 기반인 decodeObject(of:forKey:))는 클래스 불일치를 프로그래밍 오류가 아니라 정상적으로 실패할 수 있는 상황으로 설계했다. 예상 밖의 클래스가 나왔다고 그 자리에서 앱을 죽여버리면, 공격자가 오히려 그 크래시 자체를 서비스 거부(DoS) 수단으로 악용할 수 있다. 그래서 API는 실패를 던지거나(throws) nil을 돌려주는 형태로 설계돼, 호출부가 "복원 실패"를 정상적인 제어 흐름으로 처리하게 만든다.