이 챕터는 Swift가 함수 호출 하나를 실제로 어떻게 실행 코드에 연결하는지를 다룬다. vtable·protocol witness table·objc_msgSend라는 서로 다른 자료구조가 왜 셋이나 필요한지, 그리고 그 차이를 모르면 반드시 한 번은 걸리는 프로토콜 확장의 정적 디스패치 함정을 파고든다. 마지막으로 프로토콜 지향 설계가 클래스 상속과 근본적으로 무엇이 다른지, @objc dynamic이 그 모든 최적화를 포기하면서까지 필요한 이유를 짚는다.
Q1. vtable / protocol witness table / objc_msgSend는 각각 어떻게 동작하며 언제 쓰이는가?
Swift 컴파일러는 함수 호출 하나를 놓고 크게 네 가지 방식 중 하나를 고른다. 대상 함수가 컴파일 타임에 이미 확정되는 정적(direct) 호출, 클래스 상속의 오버라이드를 지원하는 vtable 기반 동적 호출, 프로토콜 준수(구조체·열거형·클래스 무관)를 위한 witness table 기반 동적 호출, 그리고 dynamic이 붙었을 때만 등장하는 Objective-C 런타임의 objc_msgSend다. 넷 중 컴파일 타임에 결정되는 정적 호출만 인라인이 가능하고, 나머지 셋은 정도만 다를 뿐 모두 어떤 형태로든 런타임에 대상을 찾는다. 비용은 대체로 정적 < vtable ≈ witness table < objc_msgSend 순이다.
원리
디스패치란 호출 표현식이 실제로 어떤 코드를 실행할지 결정하는 메커니즘이며, 그 결정 시점이 컴파일 타임이냐 런타임이냐가 핵심 축이다. 언어마다 이를 구현하는 자료구조가 다른데, Swift는 값 타입·참조 타입·Objective-C 상호운용이라는 서로 다른 요구를 동시에 만족해야 해서 단일한 디스패치 방식을 쓰지 않고 네 가지를 병행한다.
내부 동작
정적 디스패치는 구조체·열거형 메서드, final로 선언된 클래스, 그리고 프로토콜 확장에만 정의되고 요구사항 목록에는 없는 메서드에 쓰인다. 컴파일러가 대상 함수의 심볼을 이미 알고 있으므로 인라인까지 가능하다.
vtable은 오버라이드 가능한 클래스 메서드에 쓰인다. 클래스마다 함수 포인터 배열(vtable)을 두고 선언 순서대로 인덱스를 할당하며, 서브클래스가 오버라이드하면 그 인덱스의 포인터만 교체된다. 공식 최적화 문서는 "Classes use dynamic dispatch for methods and property accesses by default"라고 명시한다.
witness table은 프로토콜 요구사항 메서드에 쓰이며, 구조체·열거형·클래스를 가리지 않는다. conforming 타입마다 별도의 Protocol Witness Table(PWT)이 생성되고, 요구사항이 그 안의 슬롯이 되어 준수 시점에 실제 구현으로 채워진다. Apple Developer Forums 답변은 witness table의 목적이 구조체 메서드를 가상화하는 게 아니라 "구조체 값이 프로토콜을 통해 참조될 때 어떤 구현을 쓸지 해석하는 것"이라고 설명한다. 값 타입은 여기에 더해 Value Witness Table(VWT — allocate/copy/destroy/deallocate)도 함께 필요하다.
objc_msgSend는 dynamic 키워드가 붙은 멤버에만 등장한다. 셀렉터로 Objective-C 런타임의 메서드 캐시를 찾고, 캐시 미스가 나면 isa 체인을 따라 상위 클래스로 올라가며 메서드 리스트를 선형 탐색한다. 캐시가 있어도 witness table 조회보다 무겁다.
| 기준 | 정적(direct) | vtable | witness table | objc_msgSend |
|---|---|---|---|---|
| 결정 시점 | 컴파일 타임 | 컴파일 타임에 슬롯 확정, 실제 구현은 서브클래스가 채움 | 컴파일 타임에 슬롯 확정, 실제 구현은 준수 시점에 채워짐 | 런타임(매 호출 셀렉터 탐색) |
| 자료구조 | 없음 | 클래스별 함수 포인터 배열(vtable) | conforming 타입별 Protocol Witness Table | ObjC 클래스의 메서드 캐시 + 셀렉터 목록(isa 체인) |
| 오버라이드 가능성 | 없음(직접 호출) | 서브클래스가 오버라이드 | conforming 타입마다 다른 구현 | 메서드 스위즐링으로 IMP 교체 가능 |
| 인라이닝 가능성 | 가능 | 불가 | 불가(구체 타입을 알면 특수화로 우회 가능) | 불가 |
| 대략적 비용 | 사실상 0 | 낮음(포인터 하나 역참조) | 낮음~중간(PWT 포인터 역참조) | 상대적으로 높음(셀렉터 조회, 캐시 미스 시 더 커짐) |
| 언제 쓰이는가 | struct/enum 메서드, final class, 확장의 비-요구사항 메서드 | 오버라이드 가능한 클래스 메서드 | 프로토콜 요구사항 메서드(구조체/열거형/클래스 무관) | @objc dynamic 멤버 — KVO/스위즐링/ObjC 콜백 |
실험 · 도구
일곱 가지 호출을 한 파일에 모아 SIL을 뽑으면 어떤 호출이 어떤 인스트럭션으로 내려가는지 1:1로 대조할 수 있다. 아래는 실제로 컴파일해 얻은 결과다.
import Foundation
protocol Greeter { func greet() }
extension Greeter { func farewell() { print("bye") } } // 요구사항이 아니다
struct S: Greeter { func greet() { print("S") } }
class Base: Greeter {
func greet() { print("Base") } // 오버라이드 가능
final func sealed() { print("sealed") } // final 메서드
}
final class Leaf: Greeter { func greet() { print("Leaf") } }
class ObjCish: NSObject {
@objc dynamic func ping() { print("ping") }
}
func run() {
let s = S(); s.greet() // ① struct
let b = Base(); b.greet() // ② 오버라이드 가능한 클래스 메서드
b.sealed() // ③ final 메서드
let l = Leaf(); l.greet() // ④ final 클래스
let p: any Greeter = S()
p.greet() // ⑤ 프로토콜 요구사항
p.farewell() // ⑥ extension 기본 구현
let o = ObjCish(); o.ping() // ⑦ @objc dynamic
}
run()S
Base
sealed
Leaf
S
bye
ping 2 class_method
114 function_ref
15 objc_method
6 witness_methodrun() 본문만 잘라내면 호출 순서대로 정확히 대응되는 것이 보인다. 아래는 SIL 원문에서 디스패치 인스트럭션만 뽑은 것이다(임시 값 번호는 지웠다).
// ① s.greet()
function_ref @$s13dispatch_demo1SV5greetyyF : $@convention(method) (S) -> ()
// ② b.greet()
class_method #Base.greet : (Base) -> () -> (), $@convention(method) (@guaranteed Base) -> ()
// ③ b.sealed()
function_ref @$s13dispatch_demo4BaseC6sealedyyF
// ④ l.greet()
function_ref @$s13dispatch_demo4LeafC5greetyyF
// ⑤ p.greet()
witness_method $@opened("FBD88BD8-…", any Greeter) Self, #Greeter.greet
// ⑥ p.farewell()
function_ref @$s13dispatch_demo7GreeterPAAE8farewellyyF
// ⑦ o.ping()
objc_method #ObjCish.ping!foreign : (ObjCish) -> () -> (), $@convention(objc_method)| # | 호출 | SIL 인스트럭션 | 디스패치 |
|---|---|---|---|
| ① | s.greet() — struct | function_ref | 정적 |
| ② | b.greet() — 오버라이드 가능 | class_method | vtable |
| ③ | b.sealed() — final 메서드 | function_ref | 정적 |
| ④ | l.greet() — final 클래스 | function_ref | 정적 |
| ⑤ | p.greet() — 프로토콜 요구사항 | witness_method | witness table |
| ⑥ | p.farewell() — extension 기본 구현 | function_ref | 정적 — Q2의 핵심 |
| ⑦ | o.ping() — @objc dynamic | objc_method | objc_msgSend |
③과 ④가 함께 function_ref인 것이 final의 효과를 그대로 보여준다 — 메서드 하나에 붙이든 클래스 전체에 붙이든 같은 결과다. 그리고 ⑥이 witness_method가 아니라 function_ref라는 사실이 다음 문항의 답 전체다.
프로젝트 적용
① 핫 루프에서 여러 concrete 타입을 섞어 담을 필요가 없다면 [any Shape] 같은 existential 배열 대신 제네릭을 써서, 같은 모듈 안이라면 컴파일러가 witness table 자체를 없애도록(특수화) 유도하라.
protocol Shape {
func area() -> Double
}
struct Circle: Shape {
let radius: Double
func area() -> Double { .pi * radius * radius }
}
// ❌ [any Shape]는 원소마다 existential 컨테이너(최대 40바이트, 3-word 초과 시 힙 박싱)를
// 만들고, 합산 루프의 매 호출이 protocol witness table을 경유한다 — 인라인/특수화 불가.
func totalAreaExistential(_ shapes: [any Shape]) -> Double {
shapes.reduce(0) { $0 + $1.area() }
}
// ✅ 제네릭 <T: Shape>는 호출부에서 구체 타입이 확정되므로, 같은 모듈 안에서는
// 컴파일러가 이 호출을 통째로 특수화해 witness table 자체를 없앨 수 있다.
func totalArea<T: Shape>(_ shapes: [T]) -> Double {
shapes.reduce(0) { $0 + $1.area() }
}
let circles = (0..<10_000).map { _ in Circle(radius: 2.0) }
print(totalArea(circles)) // 동종 배열이라면 제네릭 버전을 우선 고려한다② 서브클래싱 계획이 없는 리프 클래스는 final로 명시해 vtable indirect call 자체를 없애라. 구체적인 근거는 3번 문항에서 더 판다.
import Foundation
// ❌ 오버라이드 가능성이 열려 있으면 컴파일러는 안전하게 vtable 호출을 없애지 못한다.
class JSONValidator {
func validate(_ data: Data) -> Bool { !data.isEmpty }
}
// ✅ 실제로 서브클래싱할 계획이 없다면 final로 명시한다.
final class StrictJSONValidator {
func validate(_ data: Data) -> Bool { !data.isEmpty }
}"구조체는 항상 정적 디스패치다"는 틀렸다 — 프로토콜 요구사항으로 호출될 때는 구조체도 witness table을 거치는 동적 디스패치를 쓴다. 정적 디스패치가 보장되는 건 구체 타입으로 직접 호출할 때뿐이다. "witness table도 결국 vtable과 같은 것"이라는 말도 부정확하다 — vtable은 상속 계층의 오버라이드 해결용이고 witness table은 상속이 없는 프로토콜 준수 해결용이며 값 타입까지 다룬다는 점에서 목적과 구조가 다르다. "dynamic을 붙이면 witness table 기반이라 더 빠르다"도 틀렸다 — dynamic은 objc_msgSend 경로로 가며 이는 witness table보다 무거운 셀렉터 조회다.
전화를 건다고 생각해보자. 정적 호출은 번호를 이미 외워서 바로 거는 것이다. vtable은 가족 단축번호 목록에서 정해진 자리(예: 3번)를 누르는 것이다 — 그 자리에 다른 사람이 배정되면(오버라이드) 그 사람이 받는다. witness table은 처음 만난 사람의 명함에 적힌 번호로 거는 것이다. 사람(타입)마다 명함이 다르니 명함을 보고 걸어야 한다. objc_msgSend는 114 안내에 전화해서 "이 사람 번호 좀 찾아주세요"라고 부탁하는 것이다 — 가장 늦게 연결된다. 비유가 깨지는 곳: 실제로는 이 네 전화 방식 중 무엇을 쓸지 프로그래머가 매번 고르는 게 아니라, "프로토콜이냐 클래스냐 dynamic이냐"라는 선언만 하면 컴파일러가 알아서 방식을 정한다.
꼬리 질문
구조체가 프로토콜을 준수할 때 메서드 호출이 어떻게 동적으로 해석되는지, witness table을 중심으로 설명하라.
any P나 제네릭 파라미터로 참조될 때는 이 PWT를 통해 실제 구현을 찾는다. 값 자체를 다루는 별도의 Value Witness Table(할당/복사/소멸)도 함께 필요하다.vtable과 protocol witness table의 근본적 차이는 무엇인가?
final 키워드가 성능에 미치는 영향과, 컴파일러가 암묵적으로 final을 추론할 수 있는 조건은?
Q2. 프로토콜 extension의 기본 구현이 "오버라이드되지 않는" 이유는?
프로토콜 요구사항으로 선언된 메서드는 conforming 타입마다 witness table 슬롯을 갖는다 — 그 슬롯이 실제 구현을 가리키므로, 어떤 정적 타입으로 부르든 항상 conforming 타입의 구현이 실행된다. 반면 프로토콜 확장에만 정의되고 요구사항 목록에는 없는 메서드는 witness table에 슬롯 자체가 없다. 컴파일러는 호출부의 정적 타입만 보고, 컴파일 타임에 이미 결정된 함수를 직접 호출한다. 그래서 같은 객체라도 구체 타입 변수로 부르느냐 프로토콜 타입 변수로 부르느냐에 따라 결과가 달라질 수 있다. 실무 규칙은 하나다 — 커스터마이즈 가능해야 하는 메서드는 반드시 프로토콜 요구사항으로 선언하라.
원리
Swift 프로토콜은 두 겹의 API 표면을 갖는다. 하나는 프로토콜이 강제하는 계약인 "요구사항"이고, 다른 하나는 확장이 얹어주는 "추가 기능"이다. 요구사항은 conforming 타입마다 다르게 구현될 수 있다는 전제가 있어 동적으로 풀리지만, 요구사항이 아닌 확장 메서드는 그 전제가 없다 — 프로토콜 준수 여부와 무관하게 "이 프로토콜을 만족하는 타입이면 누구나 쓸 수 있는, 이름만 프로토콜 옆에 붙어 있는 함수"에 가깝다.
내부 동작
import Foundation
protocol Greeter {
func greet() -> String // 프로토콜 "요구사항" — witness table에 슬롯이 생긴다
}
extension Greeter {
// 요구사항의 기본 구현
func greet() -> String { "Greeter 기본 인사" }
// 요구사항이 아니다 — 어떤 프로토콜에도 선언된 적이 없다.
// witness table에 슬롯이 없으므로 호출부는 컴파일 타임에 정적으로 바인딩된다.
func farewell() -> String { "Greeter 확장의 작별인사" }
}
struct Person: Greeter {
// greet()는 요구사항이므로 이 구현이 witness table 슬롯을 채운다 → 항상 이게 불린다.
func greet() -> String { "Person의 인사" }
// farewell()은 요구사항이 아니라서 "오버라이드"가 아니라, 같은 이름의
// 별개 정적 함수를 하나 더 만든 것에 가깝다. Person 타입으로 직접 부를 때만 보인다.
func farewell() -> String { "Person의 작별인사" }
}
let concrete = Person()
print(concrete.greet()) // "Person의 인사" — 구체 타입 직접 호출
print(concrete.farewell()) // "Person의 작별인사" — 구체 타입 직접 호출
let asProtocol: Greeter = concrete // 같은 인스턴스, 정적 타입만 Greeter로 소거
print(asProtocol.greet()) // "Person의 인사" — witness table 경유, 여전히 Person의 구현
print(asProtocol.farewell()) // "Greeter 확장의 작별인사" — ⚠️ 정적 바인딩, Person의 구현이 무시된다!greet()는 Greeter의 요구사항이므로 witness table을 통해 동적으로 풀린다 — PWT의 슬롯은 준수 시점에 Person.greet()로 채워지고, 어떤 정적 타입으로 부르든 그 슬롯을 그대로 찾아간다. 반면 farewell()은 Greeter에도, Greeter가 상속한 어떤 프로토콜에도 요구사항으로 선언된 적이 없다. Person이 같은 이름의 farewell()을 정의해도 이는 "오버라이드"가 아니라 이름이 같은 별개의 정적 함수를 하나 더 만든 것에 가깝다 — Person 타입으로 직접 부를 때만 그 함수가 보이고, 정적 타입이 Greeter로 소거되는 순간 컴파일러는 Greeter 확장에 있는 farewell()을 직접 호출로 바인딩해버린다. 이 함정을 다룬 글도 같은 결론을 확인해준다 — 확장 메서드에 대한 "재정의"는 오버라이드가 아니라 새 정적 함수를 만드는 것과 같다.
실험 · 도구
위 코드를 실제로 돌리면 네 줄이 나온다. 마지막 줄이 이 문항의 전부다.
Person의 인사 ← concrete.greet()
Person의 작별인사 ← concrete.farewell()
Person의 인사 ← asProtocol.greet() 요구사항이라 Person 구현이 불린다
Greeter 확장의 작별인사 ← asProtocol.farewell() 요구사항이 아니라 extension 구현이 불린다같은 인스턴스인데 정적 타입만 Greeter로 바꿨더니 farewell()의 결과가 달라졌다. 왜 그런지는 SIL에서 그대로 보인다.
// concrete.greet() — 구체 타입이라 정적으로 바인딩
function_ref @$s7greeter6PersonV5greetSSyF : (Person) -> @owned String
// concrete.farewell() — 역시 정적
function_ref @$s7greeter6PersonV8farewellSSyF
// asProtocol.greet() — 요구사항이므로 런타임에 witness table 조회
witness_method $@opened("…", any Greeter) Self, #Greeter.greet
// asProtocol.farewell() — 요구사항이 아니므로 여전히 정적!
function_ref @$s7greeter7GreeterPAAE8farewellSSyF그리고 결정적인 증거는 witness table 자체다. SIL은 witness table을 그대로 출력해준다.
sil_witness_table hidden Person: Greeter module greeter {
method #Greeter.greet: <Self where Self : Greeter> (Self) -> () -> String
: @$s7greeter6PersonVAA7GreeterA2aDP5greetSSyFTW
}farewell은 이 표에 아예 없다. "오버라이드되지 않는다"는 말은 비유가 아니라 물리적 사실이다 — 런타임이 조회할 슬롯이 존재하지 않으므로 조회할 방법 자체가 없고, 컴파일러는 정적 타입에 보이는 함수를 그냥 직접 부른다. 프로토콜 타입으로 담는 순간 정적 타입은 Greeter이고, 거기서 보이는 farewell()은 extension의 것뿐이다.
"구체 타입으로 부르든 프로토콜 타입으로 부르든 요구사항은 둘 다 witness_method로 나간다"는 설명을 종종 보는데, 위 실측이 반례다. concrete.greet()은 function_ref다 — 컴파일러가 구체 타입 Person을 알고 있으므로 witness table을 거칠 이유가 없다. witness_method가 나오는 것은 정적 타입이 프로토콜일 때뿐이다. 결과가 같아 보여서(둘 다 "Person의 인사") 구분이 흐려지지만, 같은 결과에 도달하는 경로가 다르고 비용도 다르다.
프로젝트 적용
① 나중에 conforming 타입이 다르게 구현해야 할 가능성이 조금이라도 있는 메서드는, 확장에만 두지 말고 처음부터 요구사항으로 승격하라.
// ❌ 커스텀 축출 정책을 만들고 싶은데 evict()가 확장에만 있다 — 오버라이드해도
// 프로토콜 타입을 통한 호출에서는 반영되지 않을 수 있다.
protocol Cache {
associatedtype Key: Hashable
associatedtype Value
func store(_ value: Value, for key: Key)
}
extension Cache {
func evict() { print("기본 LRU 축출") } // 요구사항이 아님
}
// ✅ 오버라이드 가능해야 한다면 요구사항으로 선언하고, 확장은 "기본값"만 준다.
protocol Cache2 {
associatedtype Key: Hashable
associatedtype Value
func store(_ value: Value, for key: Key)
func evict() // 요구사항으로 승격 → witness table 슬롯 생김
}
extension Cache2 {
func evict() { print("기본 LRU 축출") } // 이제 이건 "디폴트"이지 전부가 아니다
}② 이 함정에 다시 안 걸리도록, 구체 타입 호출과 프로토콜 타입 호출의 결과가 요구사항 메서드에서는 반드시 같아야 한다는 것을 회귀 테스트로 고정해두면 실수를 조기에 잡는다.
func testGreeterDispatchParity() {
let concrete = Person()
let asProtocol: Greeter = concrete
// greet()는 요구사항이라 정적 타입과 무관하게 항상 같아야 한다.
assert(concrete.greet() == asProtocol.greet())
// farewell()은 요구사항이 아니므로 다른 게 "정상"이다 — 같아지면 오히려
// 설계가 바뀌었다는 신호로 읽어야 한다.
assert(concrete.farewell() != asProtocol.farewell())
}"요구사항이 아닌 확장 메서드도 서브클래스/conforming 타입에서 재정의하면 다형적으로 호출된다"는 틀렸다 — 프로토콜 타입으로 호출하면 정적 바인딩된다. "구조체는 상속이 없으니 확장 메서드도 항상 정적이다"도 부분적으로만 맞다 — 프로토콜 요구사항 메서드는 구조체여도 witness table을 통한 동적 디스패치를 쓴다. 정적 디스패치가 강제되는 건 확장의 비-요구사항 메서드뿐이다.
명함과 사내 별명 비유로 보자. 회사 정식 직함(요구사항)은 누구에게 물어봐도 그 사람의 실제 직함이 나온다 — 명함(witness table)에 그렇게 적혀 있으니까. 그런데 그 사람만의 개인 별명(확장 전용 메서드)은 "부서 배치도"(정적 타입)에는 안 적혀 있어서, 배치도를 보고 부르면 그 사람의 별명이 아니라 배치도에 원래 적힌 설명(확장의 기본값)이 튀어나온다. 비유가 깨지는 곳: 실제로는 두 결과 다 "같은 사람"에게 물어본 것이다. 사람이 바뀐 게 아니라, 무엇을 기준으로 답을 찾을지(명함이냐 배치도냐)가 통째로 바뀌는 것이다.
꼬리 질문
farewell()을 프로토콜 요구사항으로 승격하면 왜 문제가 해결되는가?
dynamic 키워드로 이 문제를 우회할 수 있는가? 그 대가는?
제네릭 제약 <T: Greeter> 안에서 self.farewell()을 호출해도 같은 함정이 재현되는가?
Q3. final과 Whole Module Optimization은 devirtualization에 각각 어떻게 기여하는가?
final은 "이 클래스/메서드는 오버라이드될 수 없다"를 소스 레벨에서 확정하므로, 컴파일러가 항상 안전하게 vtable indirect call을 직접 호출로 바꿀 수 있다 — 이를 devirtualization이라 부른다. Whole Module Optimization(WMO)은 final이 없어도 모듈 전체 소스를 한 번에 보고 "이 모듈 안에 오버라이드하는 서브클래스가 없다"는 사실을 스스로 증명해 같은 최적화를 적용할 수 있다. private/fileprivate는 오버라이드 가능한 범위를 파일이나 타입 내부로 좁혀, WMO 없이도 컴파일러가 오버라이드 후보 부재를 증명하게 해준다. 반대로 dynamic은 이 모든 추론을 무효화하고 항상 런타임 objc_msgSend 경로를 강제해 devirtualization 자체를 원천 차단한다.
원리
devirtualization은 "이 호출 지점에서 실제로 가능한 구현이 정확히 하나뿐"임을 컴파일러가 증명할 수 있을 때만 안전한 최적화다. 그 증명의 근거는 세 갈래로 얻어진다 — 언어 차원의 명시적 봉인(final), 컴파일 단위 차원의 전역 가시성(WMO), 접근 제어 차원의 스코프 축소(private/fileprivate).
내부 동작
final은 클래스나 클래스의 프로퍼티·메서드·서브스크립트에 적용해 오버라이드를 언어 차원에서 금지한다. 공식 OptimizationTips.rst는 이렇게 설명한다: "The `final` keyword is a restriction ... such that the declaration cannot be overridden. This implies that the compiler can emit direct function calls instead of indirect calls." 오버라이드 후보가 원천적으로 없으니 vtable 조회 자체가 무의미해진다.
WMO(Whole Module Optimization)는 모듈의 소스 전체를 한 번에 컴파일 단위로 보는 모드다. 같은 문서는 WMO가 "모듈 전체 소스를 한꺼번에 컴파일해, 옵티마이저가 모듈 전역 가시성을 갖게 한다(module wide visibility)"고 설명한다. 그 결과 final이 없는 클래스라도, 그 클래스가 모듈 밖에 공개(open 등)되지 않고 모듈 안 어디에도 서브클래스가 없다면 컴파일러가 스스로 오버라이드 후보 부재를 증명해 devirtualize할 수 있다. swift.org 블로그는 WMO가 프로젝트에 따라 최대 2~5배 속도 향상을 낼 수 있다고 보고한다.
private/fileprivate는 그 선언을 오버라이드할 수 있는 범위를 파일이나 타입 내부로 좁힌다. 같은 문서는 이렇게 설명한다: "This allows the compiler to be able to ascertain all other potentially overriding declarations." 즉 WMO 없이 단일 파일만 컴파일하더라도, 그 파일 안에 실제 오버라이드가 없으면 컴파일러가 암묵적으로 final처럼 취급할 수 있다.
반대로 dynamic은 이 모든 추론을 무효화한다. Swift 공식 문서(The Swift Programming Language — Declarations)는 명확히 이렇게 못박는다: "access to that member is always dynamically dispatched using the Objective-C runtime. Access to that member is never inlined or devirtualized by the compiler." final·WMO·private가 아무리 증명해도, dynamic이 붙어 있으면 그 증명 자체가 무시된다.
실험 · 도구
조건을 하나씩 바꿔가며 실제로 측정했다. 먼저 측정용 코드다 — speak()에도 @inline(never)가 붙어 있는 것이 중요하다.
class Animal {
@inline(never) func speak() -> Int { 1 }
}
class Dog: Animal { // ← 이 블록의 유무가 변수 ①
@inline(never) override func speak() -> Int { 2 }
}
@inline(never) func total(_ a: Animal, _ n: Int) -> Int {
var s = 0
for _ in 0..<n { s += a.speak() } // ← 이 호출을 관찰한다
return s
}
let arg = CommandLine.arguments.count
print(total(arg > 99 ? Dog() : Animal(), 3))처음엔 total에만 @inline(never)를 붙였는데, 옵티마이저가 speak()를 통째로 인라인해 루프가 순수 산술로 바뀌어 호출 자체가 사라졌다 — class_method 0은 최적화가 아니라 관찰 대상이 없어진 것이었다. 두 번째로는 -parse-as-library로 컴파일해 호출자가 없자 total이 죽은 코드로 제거됐다. "인스트럭션이 사라졌다"와 "함수가 사라졌다"는 다르다 — SIL을 셀 때는 대상 함수가 여전히 존재하는지부터 확인해야 한다.
조건 class_method function_ref(speak)
──────────────────────────────────────────────────────────────────
서브클래스 있음 · -Onone 1 0
서브클래스 있음 · -O 1 0
서브클래스 있음 · -O -wmo 1 0 ← 지워지지 않는다
서브클래스 없음 · -O 1 0 ← WMO 없이는 안 된다
서브클래스 없음 · -O -wmo 0 2 ← 여기서 바뀐다
final 클래스 · -O 0 — ← WMO 없이도 즉시세 줄이 각각 다른 것을 말한다. ③ 서브클래스가 실제로 있으면 WMO를 켜도 class_method가 남는다 — 진짜 다형성이라 지울 수 없기 때문이고, 이것이 devirtualization이 "최적화 옵션"이 아니라 사실 확인임을 보여준다. ④ 서브클래스가 없어도 -O만으로는 부족하다 — 파일 하나만 보고는 "이 모듈 어디에도 서브클래스가 없다"를 증명할 수 없기 때문이다. ⑤ 그 둘이 모두 갖춰졌을 때 비로소 정적 호출로 바뀐다.
마지막 final 행은 결과가 조금 다르게 나타난다. 컴파일러가 total을 특수화된 버전으로 새로 만들고 원래 이름은 썽크로 남기므로, 실제 본문을 보려면 특수화 함수를 찾아야 한다.
sil shared [noinline] @$s7e_final5totalySiAA6AnimalC_SitFTf4dn_n : (Int) -> Int {
bb1:
…
// function_ref specialized Animal.speak()
%16 = function_ref @$s7e_final6AnimalC5speakSiyFTf4d_n : $@convention(thin) () -> Int
%17 = apply %16() : $@convention(thin) () -> Int
br bb3(…) ← 루프 진입 전이다
function_ref로 바뀐 것에 더해 호출이 루프 밖으로 끌려 나갔다(bb1은 루프 본문 bb3보다 앞이다). 정적 호출이 되면 컴파일러가 "이 호출은 매번 같은 값을 낸다"를 증명할 수 있어 루프 불변 코드 이동까지 이어지는 것이다. devirtualization의 진짜 이득은 간접 호출 한 번을 아끼는 게 아니라, 그 뒤의 최적화가 전부 열린다는 데 있다.
프로젝트 적용
① 서브클래싱 계획이 없는 리프 클래스는 명시적으로 final을 붙여, WMO 설정이나 빌드 구성에 의존하지 않고 항상 devirtualize 근거를 확보하라.
// ❌ 오버라이드 가능성이 열려 있으면, WMO가 꺼진 빌드에서는 vtable 호출이 남는다.
class ImageDecoder {
func decode(_ data: Data) -> UIImage? { nil }
}
// ✅ 서브클래싱할 계획이 없다면 final로 명시 — 빌드 설정과 무관하게 직접 호출이 보장된다.
final class ImageDecoder2 {
func decode(_ data: Data) -> UIImage? { nil }
}② 모듈 밖에서 서브클래싱될 일이 없는 기반 클래스는 멤버를 private/fileprivate로 좁혀, WMO 여부와 무관하게 컴파일러가 스스로 증명하도록 만들어라.
// 이 파일 밖에서 서브클래싱할 계획이 없는 기반 클래스는, 메서드를 fileprivate으로
// 좁히면 컴파일러가 "이 파일만 보면" 오버라이드 후보가 없다는 걸 증명할 수 있다.
class RequestSigner {
fileprivate func sign(_ payload: Data) -> Data { payload } // 이 파일 밖 서브클래스는 못 건드림
func send(_ payload: Data) { _ = sign(payload) } // 내부에서만 호출
}"final을 안 쓰면 무조건 vtable 호출이다"는 틀렸다 — WMO가 켜져 있고 그 클래스가 모듈 밖에 공개(public/open)되지 않았다면, final 없이도 자동으로 devirtualize될 수 있다. "private으로 좁히면 자동으로 인라인까지 보장된다"도 틀렸다 — devirtualization은 인라인의 전제조건 중 하나일 뿐, 실제 인라인 여부는 함수 크기 등 별도 휴리스틱에 달려 있다. "dynamic을 안 쓰면 항상 devirtualize된다"도 틀렸다 — public/open 클래스의 오버라이드 가능한 메서드는 모듈 경계 밖에서 서브클래싱될 수 있으므로 여전히 vtable을 써야 한다.
학교 반장 선거에 비유해보자. final은 "이 반은 반장 후보가 원래부터 딱 한 명으로 정해져 있다"고 미리 못박아두는 것이다 — 선생님(컴파일러)은 투표함(vtable)을 만들 필요 없이 그 한 명에게 바로 전달하면 된다. WMO는 선생님이 학교 전체 명부(모듈 전체 소스)를 한꺼번에 다 보고서 "어차피 이 반엔 후보 등록한 애가 하나도 없네"를 스스로 확인하고 투표함을 치우는 것이다. private은 그 반 안에서만 후보 등록이 가능하도록 규칙을 좁혀서, 학교 전체를 안 봐도 그 반 안 서류만 보면 후보가 없다는 걸 알 수 있게 하는 것이다. dynamic은 "선거 규칙과 상관없이 언제든 새 후보가 깜짝 등록될 수 있다"고 미리 선언해 투표함을 절대 못 치우게 하는 것이다. 비유가 깨지는 곳: 실제 컴파일러는 이 증명을 선거 당일(런타임)이 아니라 시험 전날(컴파일 타임)에 끝내야 하므로, "투표"라는 행위 자체는 한 번도 일어나지 않고 미리 정해진 번지수로 바로 찾아가는 것에 가깝다.
꼬리 질문
WMO가 켜져 있어도 devirtualize가 안 되는 경우는 언제인가?
private으로 좁혔는데 같은 파일 안에 실제 서브클래스가 있으면 어떻게 되는가?
dynamic을 붙이면 실제로 어떤 디스패치 경로로 가는가?
Q4. 프로토콜 지향 프로그래밍은 클래스 상속과 설계상 무엇이 다른가?
클래스 상속은 단일 부모만 허용하고, 부모의 저장 프로퍼티·구현을 그대로 물려받아 super로 재사용할 수 있는 대신 그 계층에 들어가는 타입은 반드시 class여야 한다. 프로토콜 지향 설계는 여러 프로토콜을 동시에 준수(합성)할 수 있고 struct/enum도 참여할 수 있으며, 이미 존재하는 타입에도 나중에(retroactively) 준수를 추가할 수 있다. 대신 프로토콜은 저장 프로퍼티를 직접 제공하지 못하고(요구만 가능), super 개념이 없으며, 확장으로 기본 구현을 주다 보면 2번 문항의 정적 디스패치 함정에 빠지기 쉽다.
원리
두 방식 모두 다형성을 얻는 수단이지만, 프로토콜 지향 설계는 "무엇을 할 수 있는가"(능력, 프로토콜)를 "무엇으로부터 왔는가"(계보, 상속)로부터 분리한다. 상속은 이 둘을 하나로 묶어 부모 하나로부터 구조와 능력을 통째로 물려주는 모델이다.
내부 동작
| 관점 | 프로토콜 지향(구성) | 클래스 상속 |
|---|---|---|
| 적용 가능 타입 | struct/enum/class 전부 | class 계층에만 |
| 여러 개 동시 채택 | A & B 합성으로 여러 개 동시 가능(단, 클래스 타입은 하나까지만 섞을 수 있음) | 단일 상속만 |
| 후행 채택(retroactive) | 이미 있는 타입에 나중에 준수 추가 가능(extension) | 클래스 계층은 정의 시점에 고정 |
| 저장 프로퍼티 공유 | 확장은 저장 프로퍼티를 만들 수 없다(요구만 가능) | 부모 클래스가 실제로 저장 프로퍼티를 갖고 상속 |
| super로 재사용 | 프로토콜엔 super 개념이 없다 | 오버라이드해도 super로 부모 구현 재사용 |
| 기본 구현의 함정 | 확장 기본 구현이 요구사항 승격 여부에 따라 정적으로 바인딩될 수 있다(2번 문항) | vtable 기반이라 오버라이드가 항상 다형적으로 반영 |
후행 채택이 특히 강력한 이유는, 타입을 처음 설계할 때 미래에 무엇을 준수시킬지 몰라도 된다는 데 있다. 다만 외부 타입에 외부 프로토콜 준수를 나중에 추가하면 전역 유일성 문제(같은 conformance를 여러 모듈이 서로 다르게 정의하는 충돌)가 생길 수 있어 @retroactive 표시가 필요해진다 — 이 위험은 08장에서 깊게 다룬다. 확장이 저장 프로퍼티를 못 만드는 이유도 명확하다 — 확장은 타입이 이미 컴파일된 뒤에 붙는 것이라, 그 타입의 메모리 레이아웃(크기)을 나중에 늘릴 방법이 없기 때문이다.
실험 · 도구
두 경우를 실제로 컴파일해 대조했다. 클래스 쪽은 즉시 거부된다.
class Bird { func fly() {} }
class Swimmer { func swim() {} }
class Duck: Bird, Swimmer {}multi.swift:3:19: error: multiple inheritance from classes 'Bird' and 'Swimmer'
[#MultipleInheritance]진단 그룹 이름이 [#MultipleInheritance]로 따로 붙어 있다는 것은, 이것이 우연한 구현 제약이 아니라 언어가 명시적으로 금지하는 항목이라는 뜻이다.
반면 프로토콜은 개수 제한이 없다. 4개로 확인하고, 혹시 어떤 한계가 있는지 20개까지 늘려봤다.
protocol Flying { func fly() }
protocol Swimming { func swim() }
protocol Quacking { func quack() }
protocol Walking { func walk() }
struct Duck: Flying, Swimming, Quacking, Walking {
func fly() {}; func swim() {}; func quack() {}; func walk() {}
}
print("프로토콜 4개 채택 OK:", Duck.self)$ ./mo
프로토콜 4개 채택 OK: Duck
$ ./many # 프로토콜 20개를 채택한 struct
프로토콜 20개 채택 OK차이의 근원은 저장 공간이다. 클래스 상속은 부모의 저장 프로퍼티 레이아웃을 물려받으므로 부모가 둘이면 두 레이아웃을 어떻게 겹칠지 정할 수 없다(C++가 가상 상속으로 푸는 그 문제다). 프로토콜은 요구사항 목록일 뿐 저장 공간을 갖지 않으므로 몇 개를 이어붙여도 충돌할 레이아웃이 없다 — 채택할 때마다 witness table이 하나씩 늘어날 뿐이다.
프로젝트 적용
① "여러 능력을 동시에 가진 타입"을 표현해야 할 때, 클래스 다중 상속이 안 되어 계층이 꼬이는 대신 프로토콜 합성으로 풀어라.
// ❌ 상속으로 표현하면 "날 수 있고 헤엄치는 동물"을 위한 다중 상속 자체가 불가능하다.
class Animal {}
class Bird: Animal { func fly() { print("날다") } }
// class Duck: Bird, Swimmer {} // Swift는 클래스 다중 상속을 애초에 지원하지 않는다
// ✅ 능력을 프로토콜로 쪼개면 struct도, 여러 능력의 조합도 자유롭게 표현된다.
protocol Flying { func fly() }
protocol Swimming { func swim() }
struct Duck: Flying, Swimming {
func fly() { print("날다") }
func swim() { print("헤엄치다") }
}
// 여러 능력을 동시에 요구하는 함수도 합성 타입으로 자연스럽게 표현된다.
func showOff(_ animal: some Flying & Swimming) {
animal.fly()
animal.swim()
}② 이미 정의된 타입을 건드리지 않고도, 나중에 새 능력을 후행 채택으로 붙여라 — 상속이었다면 처음부터 계층을 다시 설계해야 했을 일이다.
struct Order {
let id: String
let total: Decimal
}
protocol Summarizable {
var summary: String { get }
}
// Order의 원래 정의를 건드리지 않고도 준수를 "나중에" 붙일 수 있다.
extension Order: Summarizable {
var summary: String { "\(id) — \(total)" }
}
// 여기서는 내가 정의한 프로토콜에 내가 정의한 타입을 준수시키는 것이라
// @retroactive 경고 대상이 아니다(외부 타입 x 외부 프로토콜 조합만 해당 — 08장 참고)."프로토콜 지향이면 상속을 아예 안 써도 된다"는 틀렸다 — UIKit/AppKit 같은 프레임워크와 상호운용하려면 여전히 NSObject 계열 클래스 상속이 필요한 지점이 있다(16장 참고). "프로토콜 확장은 상속의 완전한 대체재다"도 틀렸다 — 저장 프로퍼티를 실제로 공유하거나 super로 부모 구현을 재사용해야 한다면 상속이 여전히 더 직접적인 도구다.
상속은 "부모에게서 몸의 구조를 통째로 물려받는 것"에 가깝다 — 뼈대와 장기까지 다 가지지만, 부모는 하나뿐이다. 프로토콜은 "이 사람은 수영 자격증이 있다, 이 사람은 운전면허가 있다"처럼 자격증을 여러 장 딸 수 있는 것에 가깝다. 자격증(프로토콜)은 여러 개를 동시에 딸 수 있지만, 자격증 자체가 몸(저장 공간)을 주지는 않는다 — 실제로 수영할 몸은 각자 알아서 마련해야 한다. 비유가 깨지는 곳: 자격증 시험은 공짜 답안(기본 구현)을 던져주기도 하는데, 그 답안을 내 마음대로 다시 쓸 수 있는지 없는지는 그 문제가 "필수 과목"으로 등록됐는지에 달려 있다(2번 문항).
꼬리 질문
프로토콜은 왜 저장 프로퍼티를 직접 제공하지 못하는가?
var x: Int { get set }처럼 "이런 프로퍼티가 있어야 한다"는 요구만 할 수 있고, 실제 저장 공간은 conforming 타입이 책임진다.어떤 경우엔 여전히 클래스 상속이 프로토콜 구성보다 나은 선택인가?
프로토콜 합성(A & B)에 클래스 타입 하나까지 포함할 수 있다는 게 무슨 뜻인가?
SomeProtocol & AnotherProtocol 형태의 합성 타입에는 프로토콜을 몇 개든 나열할 수 있지만, 클래스 타입은 최대 하나까지만 섞어 넣을 수 있다(예: SomeClass & SomeProtocol). 이는 클래스가 단일 상속만 허용되는 제약이 합성 타입에도 그대로 반영된 것이다 — 프로토콜은 여러 개 조합해도 서로 충돌하지 않지만, 클래스 계보는 두 개를 동시에 요구할 수 없다.Q5. @objc dynamic은 언제 필요하며 무슨 대가를 치르는가?
@objc는 Swift 선언을 Objective-C 런타임에서 보이게(셀렉터·메타데이터 생성) 만드는 것이고, dynamic은 그 호출을 항상 objc_msgSend를 통한 런타임 메시지 전송으로 강제해 컴파일 타임 devirtualization·인라이닝·제네릭 특수화를 전부 포기시키는 것이다. 이 조합이 필요한 대표 상황은 KVO(Key-Value Observing), 메서드 스위즐링, 그리고 일부 Objective-C 프레임워크 콜백(타겟-액션, 델리게이트 셀렉터 기반 API)이다. 비용은 매 호출마다 치르는 셀렉터 조회(캐시 미스 시 클래스 계층 선형 탐색)이고, 대상 멤버는 Objective-C가 표현할 수 있는 타입이어야 하며, KVO처럼 완전한 동적 기능을 쓰려면 실무적으로 NSObject 계열 클래스여야 한다.
원리
Swift 고유의 디스패치(정적/vtable/witness table)와 Objective-C 런타임 디스패치(objc_msgSend)는 서로 다른 세계다. dynamic은 그 경계를 건너 Swift 코드를 사실상 "완전한 Objective-C 메서드"로 만들어, ObjC 런타임의 introspection·스위즐링 API가 개입할 여지를 만드는 스위치다.
내부 동작
Swift 공식 문서(The Swift Programming Language — Declarations)는 dynamic을 이렇게 정의한다: "Apply this modifier to any member of a class that can be represented by Objective-C. ... access to that member is always dynamically dispatched using the Objective-C runtime. Access to that member is never inlined or devirtualized by the compiler." 그리고 이렇게 덧붙인다: "Because declarations marked with the `dynamic` modifier are dispatched using the Objective-C runtime, they must be marked with the `objc` attribute." 즉 dynamic은 반드시 @objc를 동반해야 하고, 그 대상은 "Objective-C로 표현 가능한 멤버"로 제한된다.
@objc가 적용 가능한 대상 자체도 제한적이다. 같은 문서의 Attributes 페이지는 "nonnested classes, protocols, nongeneric enumerations(정수 raw-value 타입에 한정), properties and methods of classes, protocols and optional members of a protocol, initializers, and subscripts"에만 적용할 수 있다고 명시한다. 제네릭 타입 파라미터가 섞인 시그니처, 연관값을 가진 열거형, ObjC로 브리징되지 않는 구조체 등은 애초에 @objc 대상이 될 수 없다 — 그래서 그런 멤버는 dynamic도 붙일 수 없다.
정확히는 dynamic 자체가 "클래스는 반드시 NSObject를 상속해야 한다"를 문법적으로 강제하지는 않는다. 그러나 KVO처럼 objc_msgSend 위에서 동작하는 대표 기능들은 NSObject가 제공하는 관찰 인프라(willChangeValue/didChangeValue, isa-swizzling 기반의 KVO 전용 서브클래스 생성)에 의존한다 — 그래서 실무적으로는 KVO 대상 클래스가 NSObject 계열이어야 한다.
비용 측면에서는 1번 문항의 표에서 본 것처럼 objc_msgSend가 셀렉터를 캐시에서 찾고, 미스 시 isa 체인을 따라 상위 클래스의 메서드 리스트를 선형 탐색한다 — witness table 조회보다 무거운 경로다. 그럼에도 이 비용을 치르는 이유는 명확하다. KVO는 프로퍼티 변경을 옵저버에게 알리기 위해 런타임에 클래스를 조작(isa-swizzling)해야 하고, 메서드 스위즐링은 실행 중에 구현(IMP)을 바꿔치기해야 하며, 일부 ObjC 프레임워크 콜백(타겟-액션, 델리게이트 셀렉터)은 애초에 셀렉터 기반 호출을 전제로 설계되어 있다. 이 세 상황은 컴파일 타임에 고정된 정적/vtable/witness table 디스패치로는 애초에 표현할 수 없다. KVO와 isa-swizzling의 관계는 27장에서, NSObject 브리징의 나머지 이야기는 16장에서 더 깊게 다룬다.
실험 · 도구
@objc dynamic의 대가는 바이너리 심볼 테이블에서 눈으로 확인할 수 있다. 거의 같은 두 프로그램을 만들어 nm으로 대조했다.
// pure.swift
class Plain { func ping() -> Int { 1 } }
let p = Plain()
print(p.ping())
// dyn.swift
import Foundation
class Dyn: NSObject { @objc dynamic func ping() -> Int { 1 } }
let d = Dyn()
print(d.ping())pure.swift → _objc_msgSend 심볼 0개, 바이너리 51,864 bytes
dyn.swift → _objc_msgSend 심볼 2개, 바이너리 52,792 bytes
$ nm bin_dyn | grep objc_msgSend
U _objc_msgSend
U _objc_msgSendSuper2U는 undefined, 즉 "이 심볼을 런타임에 dyld가 채워 넣어야 한다"는 표시다. 순수 Swift 쪽에는 이 참조가 아예 존재하지 않는다 — 메시지 디스패치 기계 자체를 링크하지 않는다는 뜻이다. 두 번째로 나온 _objc_msgSendSuper2는 super 호출용 별도 진입점으로, NSObject를 상속하면 초기화 경로에서 함께 딸려온다.
바이너리 크기 차이 928바이트는 이 예제 기준이며 앱 규모에 비례하는 값이 아니다 — 여기서 읽어야 할 것은 크기가 아니라 "심볼 참조가 0이냐 아니냐"라는 이분법이다. 내 앱에서 이 명령을 돌려 _objc_msgSend가 나온다면, ObjC 프레임워크를 쓰거나 어딘가에 @objc dynamic·KVO 대상이 있다는 신호다.
호출 비용의 정량 비교(objc_msgSend가 직접 호출보다 몇 배 느린가)는 이 강의가 측정하지 않았다. 신뢰할 수 있는 수치를 내려면 인라인·분기 예측·캐시 효과를 통제한 마이크로벤치마크가 필요하고, 그렇게 얻은 숫자도 실제 앱의 병목과 상관이 낮은 경우가 많다. 여기서 확인한 것은 "디스패치 경로가 다르다"는 구조적 사실까지이며, 성능이 문제라면 Instruments의 Time Profiler로 내 앱의 실제 호출 스택에서 objc_msgSend 비중을 재는 것이 맞는 순서다.
프로젝트 적용
① KVO로 실제 관찰이 필요한 프로퍼티만 @objc dynamic으로 최소화하고, 나머지는 순수 Swift 프로퍼티로 유지하라. @objcMembers로 클래스 전체를 한 번에 노출시키면 필요 없는 멤버까지 전부 objc_msgSend 경로를 타게 된다.
import Foundation
// ❌ 클래스 전체에 @objcMembers를 걸면 모든 멤버가 objc_msgSend 경로를 타게 되어
// devirtualization/인라인/제네릭 특수화를 전부 포기하게 된다.
@objcMembers
class BadProfile: NSObject {
var name: String = "" // KVO 대상이 아닌데도 덩달아 dynamic이 되어버림
var isOnline: Bool = false
}
// ✅ 실제로 KVO로 관찰해야 하는 프로퍼티만 @objc dynamic으로 최소화한다.
class Profile: NSObject {
@objc dynamic var isOnline: Bool = false // KVO 관찰 대상 — objc_msgSend 경유
var name: String = "" // 순수 Swift 프로퍼티 — 직접 접근
}
let profile = Profile()
let observation = profile.observe(\.isOnline, options: [.new]) { _, change in
print("온라인 상태 변경:", change.newValue ?? false)
}② 메서드 스위즐링(예: 분석 SDK의 화면 노출 자동계측)을 실무에서 쓸 때는 원본·대체 메서드 모두 dynamic이어야 한다. 그렇지 않으면 다른 Swift 코드가 그 메서드를 직접 호출/인라인으로 컴파일해버려 스위즐링이 일부 호출 지점에서 무시된다.
import Foundation
import ObjectiveC
class ViewController: NSObject {
// dynamic이 없으면 이 메서드에 대한 다른 곳의 호출이 컴파일러에 의해
// 직접 호출/인라인으로 굳어버릴 수 있어, 아래 스위즐링이 일부 호출 지점에서 무시된다.
@objc dynamic func viewDidAppear() {
print("원본 viewDidAppear")
}
@objc dynamic func swizzled_viewDidAppear() {
print("계측 코드 실행") // 예: 화면 노출 로깅
swizzled_viewDidAppear() // 스위즐링 후에는 실제로 원본을 가리킨다(IMP가 교환됐으므로)
}
}
func swizzleViewDidAppear() {
guard
let original = class_getInstanceMethod(ViewController.self, #selector(ViewController.viewDidAppear)),
let swizzled = class_getInstanceMethod(ViewController.self, #selector(ViewController.swizzled_viewDidAppear))
else { return }
method_exchangeImplementations(original, swizzled) // IMP를 서로 맞바꾼다
}"@objc만 붙이면 dynamic 없이도 스위즐링이 항상 먹힌다"는 틀렸다 — @objc는 그 멤버를 Objective-C 런타임에 노출시킬 뿐, Swift 자신의 호출 지점까지 objc_msgSend를 강제하지는 않는다. dynamic이 없으면 같은 모듈 안의 다른 Swift 코드가 그 메서드를 vtable이나 직접 호출로 컴파일해버릴 수 있어, 스위즐링한 구현이 일부 호출 지점에서는 무시된다. 반대로 "dynamic은 무조건 피해야 할 비용"이라는 말도 지나치다 — KVO나 스위즐링처럼 대체 수단이 없는 경우엔 필요한 비용이다.
우체국 비유로 보자. 보통 편지(정적/vtable/witness table 호출)는 미리 정해진 주소로 바로 배달된다. dynamic은 매번 우체국(objc 런타임)에 가서 "이 사람 지금 주소가 뭐예요?"라고 물어보고 배달하는 것과 같다 — 느리지만, 그 사람이 이사(메서드 스위즐링으로 구현 교체)를 가도 다음 편지부터 바로 새 주소로 간다. KVO는 "이 사람 주소가 바뀔 때마다 나한테 알려줘"라고 우체국에 미리 부탁해두는 서비스인데, 우체국을 거치지 않는 편지(정적/vtable 호출)는 애초에 우체국이 모르니 알려줄 수가 없다. 비유가 깨지는 곳: 실제로 "이사"(스위즐링)는 항상 위험하고 부작용이 큰 저수준 런타임 조작이라, 우체국 비유처럼 가볍게 여기고 프로덕션 코드에 함부로 써서는 안 된다.