← Mobile Foundation 인터뷰 5 / 13

05 · Static vs Dynamic Dispatch

4가지 디스패치 → SIL 명령어 → 간접 분기와 CPU 파이프라인
진행률
0 / 0 완료
면접에서 나오는 형태
  1. Static과 Dynamic dispatch는 무엇이 다른가요? Swift에는 몇 종류가 있나요?
  2. final을 붙이면 왜 빨라지나요? 얼마나 빨라지나요?
  3. 동적 디스패치의 비용은 정확히 어디서 발생하나요?
L1 개념
어떤 함수를 실행할지 언제 정하느냐 — 컴파일 타임이면 static, 런타임이면 dynamic
L2 설계
Swift에는 4가지가 있다 — 직접 호출 · vtable · witness table · objc_msgSend
L3 구현
SIL 명령어로 눈에 보인다: function_ref / class_method / witness_method / objc_method
L4 CS
간접 분기 · 분기 예측 실패 · 파이프라인 플러시 — 그리고 진짜 비용인 인라이닝 차단

30초 답변 🔥 먼저 이 문장

어떤 함수를 실행할지 언제 정하느냐의 문제입니다. 컴파일 타임에 정해지면 static — 주소가 박히고 인라이닝까지 가능합니다. 런타임에 정해지면 dynamic — 테이블을 한 번 더 들여다봐야 합니다.

그런데 비용의 본질은 조회 한 번이 아닙니다. 조회 자체는 몇 사이클이에요. 진짜 비용은 컴파일러가 그 뒤의 최적화를 포기한다는 것입니다 — 인라이닝, 상수 접기, 죽은 코드 제거가 전부 막힙니다. CPU 입장에서도 간접 분기는 예측이 어려워서, 빗나가면 파이프라인을 통째로 비워야 합니다.

L1개념 — 네 가지를 코드로 한 번에

Swift의 디스패치 4종 — 한 파일에서 전부 보기
import Foundation

// ────────── ① 정적 디스패치 (직접 호출) ──────────
struct Circle {
    func draw() { print("circle") }        // struct는 상속이 없다 → 항상 정적
}
final class Square {
    func draw() { print("square") }        // final → 오버라이드 불가 → 정적
}

// ────────── ② vtable (클래스 상속) ──────────
class Shape {
    func draw() { print("shape") }         // 오버라이드 가능 → vtable 슬롯
}
class Triangle: Shape {
    override func draw() { print("triangle") }
}

// ────────── ③ witness table (프로토콜) ──────────
protocol Drawable { func draw() }
extension Circle: Drawable {}
extension Square: Drawable {}

func render(_ d: any Drawable) { d.draw() }      // existential → witness table
func render<T: Drawable>(_ d: T) { d.draw() }    // 특수화되면 → 정적!

// ────────── ④ objc_msgSend ──────────
class Legacy: NSObject {
    @objc dynamic func draw() { print("legacy") }  // 셀렉터 기반 런타임 조회
}

// ────────── 호출부 ──────────
Circle().draw()                     // ① 직접 호출 (인라인될 수도 있다)
let s: Shape = Triangle(); s.draw() // ② vtable 조회 후 호출
render(Circle() as any Drawable)    // ③ witness table 경유
Legacy().draw()                     // ④ objc_msgSend(obj, sel_draw)
방식언제어떻게대가 / 얻는 것
직접 호출struct/enum 메서드, final, private, 특수화된 제네릭, 프로토콜 extension의 비요구사항주소를 박아 호출 · 인라이닝 가능최소 비용 / 다형성 없음
vtableclass의 상속 가능한 메서드클래스별 함수 포인터 배열, 고정 인덱스 조회로드 2회 + 간접 분기 / 상속 다형성
witness tableany P, 미특수화 제네릭프로토콜별 함수 표를 인자로 함께 넘김vtable과 유사 + 박싱 가능 / 값 타입 다형성
objc_msgSend@objc dynamic, NSObject + @objc셀렉터로 런타임 해시 조회 (메서드 캐시)가장 비쌈 / KVO·swizzling·target-action

L2언어 설계 — 정적으로 되돌리는 방법들

devirtualization을 유발하는 다섯 가지 (Swift)
// ① final — "더 이상 오버라이드되지 않는다"를 선언
final class Renderer {
    func draw() {}                 // 클래스가 final이면 메서드도 전부 정적
}
class Partial {
    final func draw() {}           // 메서드 단위로도 가능
}

// ② private / fileprivate — 가시성이 좁으면 컴파일러가 증명할 수 있다
class Internal {
    private func helper() {}       // 이 파일 밖에서 오버라이드 불가 → 정적
}

// ③ WMO(Whole Module Optimization) — 모듈 전체를 한 번에 본다
//    internal 메서드도 "이 모듈에 오버라이드가 없음"을 확인하면 정적으로 바뀐다
//    빌드 설정: SWIFT_COMPILATION_MODE = wholemodule (Release 기본값)

// ④ 제네릭 특수화 — 구체 타입별 사본을 만들어 witness table을 없앤다
func draw<T: Drawable>(_ x: T) { x.draw() }
draw(Circle())     // → draw_specialized_Circle(...) 로 복제되고 직접 호출

// ⑤ some P (opaque type) — 구체 타입이 하나로 고정되므로 특수화 가능
func make() -> some Drawable { Circle() }   // 항상 Circle → 정적
func makeAny() -> any Drawable { Circle() } // 상자에 담김 → 동적
⚠️ 반대로, 정적을 동적으로 되돌리는 것들
// ① @objc dynamic — 일부러 objc_msgSend 경로로 보낸다
class Model: NSObject {
    @objc dynamic var count = 0    // KVO를 쓰려면 필수 (성능이 아니라 기능 선택)
}

// ② 라이브러리 진화(library evolution) 모드
//    -enable-library-evolution 으로 빌드된 프레임워크는
//    "나중에 필드가 추가되어도 클라이언트를 다시 컴파일하지 않아도" 되어야 한다.
//    → struct 레이아웃조차 고정할 수 없으므로 접근이 간접적으로 바뀐다.
@frozen public struct Point {      // "이 레이아웃은 절대 안 바꾼다"고 약속하면
    public var x: Double           // 클라이언트가 직접 접근할 수 있다
    public var y: Double
}

// ③ 모듈 경계 — @inlinable 없이 public 함수는 다른 모듈에서 인라인 불가
@inlinable public func fastPath() { /* ... */ }   // 본문을 클라이언트에게 노출
📝 여기서 02번과 이어진다

ABI 안정성이 준 것(OS에 표준 라이브러리 내장)의 대가가 정확히 이것입니다. "나중에 바꿔도 되게" 만들려면 지금 못 박을 수 없고, 못 박지 못하면 간접 접근이 됩니다. @frozen·@inlinable은 "나는 이걸 영원히 안 바꾸겠다"고 미래의 자유를 팔아 성능을 사는 어노테이션입니다.

L3구현 — SIL에서 눈으로 확인하기

디스패치 방식은 추측할 필요가 없습니다. SIL을 뽑아 보면 명령어 이름에 그대로 적혀 있습니다.

직접 확인하는 명령 — 면접에서 "확인해 봤습니다"라고 말할 수 있는 근거
# SIL 덤프 (최적화 전)
swiftc -emit-sil Dispatch.swift | swift demangle

# 최적화 후 (특수화·devirtualization이 반영된 상태)
swiftc -O -emit-sil Dispatch.swift | swift demangle

# 디스패치 명령어만 골라 보기
swiftc -O -emit-sil Dispatch.swift \
  | grep -E "function_ref|class_method|witness_method|objc_method"

# 어셈블리까지
swiftc -O -S Dispatch.swift
SIL 명령어 대응표 — 이 이름이 곧 디스패치 방식이다
// Swift                          →  SIL 명령어
Circle().draw()                   →  function_ref @...Circle4drawyyF   // ① 정적
                                     apply

let s: Shape = Triangle()         →  class_method %0 : $Shape, #Shape.draw   // ② vtable
s.draw()                             apply

func f(_ d: any Drawable)         →  witness_method $@opened(...), #Drawable.draw  // ③ PWT
{ d.draw() }                         apply

Legacy().draw()                   →  objc_method %0 : $Legacy, #Legacy.draw!foreign // ④ msgSend
                                     apply

// ─── -O 를 켜고 다시 뽑으면 이런 변화가 보인다 ───
// class_method   → function_ref   (final/WMO로 devirtualize 됨)
// witness_method → function_ref   (제네릭이 특수화됨)
// 아예 apply 자체가 사라짐        (인라인됨)

existential container — any P가 실제로 담기는 모양

any Drawable 의 실제 저장 형태 (5 words = 40바이트) word 0word 1word 2 inline buffer (3 words) type metadata 크기·복사·소멸 방법 witness table draw() 함수 포인터 값이 3 words 이하 → 그대로 담긴다 struct Small { var x, y: Int } // 16B x=1 y=2 (버퍼 안에 직접) 추가 할당 없음 · 간접 참조 없음 3 words 초과 → 힙에 박싱된다 struct Big { var a,b,c,d: Int } // 32B 힙 포인터 힙 박스 a b c d 할당 + ARC + 캐시 미스
[any Drawable] 배열이 느려지는 이유가 여기 다 있다 — 원소마다 witness table 경유, 값이 크면 힙 박싱, 그래서 포인터 추격까지.
직접 재보기 — existential이 커지는 지점 (Swift)
protocol P {}
struct S3: P { var a, b, c: Int }        // 24바이트 = 3 words
struct S4: P { var a, b, c, d: Int }     // 32바이트 = 4 words

MemoryLayout<any P>.size        // 40 — 버퍼 3 + metadata 1 + witness 1

// 박싱 여부는 크기로 갈린다
func check() {
    let x: any P = S3(a:1,b:2,c:3)   // 인라인 버퍼에 들어감 (힙 할당 없음)
    let y: any P = S4(a:1,b:2,c:3,d:4) // 힙 박싱 발생
    _ = (x, y)
}

// ✅ 실무 회피법 — 애초에 existential을 안 만든다
func drawAll<T: P>(_ xs: [T]) { }      // 동종 배열이면 제네릭이 낫다
let shapes: [any P] = [Circle(), Square()]            // 이종이 진짜 필요할 때만

L4CS 근본 — 간접 분기와 파이프라인

① 기계어 수준에서 무엇이 다른가

개념적으로 이런 모양이 된다 (ARM64 스타일, 단순화)
; ─── 정적 디스패치 ───
bl      _draw_Circle          ; 목적지 주소가 명령어에 박혀 있다
                              ; → 페치 단계에서 이미 다음 명령을 안다
                              ; → 애초에 인라인되어 이 줄이 사라질 수도 있다

; ─── 동적 디스패치 (vtable) ───
ldr     x8, [x0]              ; (1) 객체에서 metadata 포인터 로드   ← 메모리 접근
ldr     x9, [x8, #0x50]       ; (2) 테이블에서 함수 포인터 로드     ← 또 메모리 접근
blr     x9                    ; (3) 레지스터가 가리키는 곳으로 점프 ← 간접 분기

; 비용의 정체:
;   (1)(2) 두 번의 의존적 로드 — 캐시에 없으면 각각 수십~수백 사이클
;   (3)    간접 분기 — 주소를 "예측"해야 한다

② 분기 예측과 파이프라인 플러시

현대 CPU는 명령어를 파이프라인으로 겹쳐 실행합니다. 그래서 분기를 만나면 결과가 나오기 전에 다음 명령을 미리 가져와야 합니다 — 그게 분기 예측입니다.

예측 성공 — 파이프라인이 계속 찬다 IFIDEX MEMWB 매 사이클 명령이 완료된다 예측 실패 — 비우고 다시 채운다 IFIDEX ✕ 버림 ✕ 버림 IF ID 10~20 사이클 손실 — 그동안 아무것도 완료 안 됨 호출 지점에 한 타입만 오면 BTB가 잘 맞히고, 여러 타입이 섞이면 자주 빗나간다 → 그래서 동종 배열(제네릭)이종 배열([any P])보다 유리하다
같은 any P 호출이라도, 실제로 한 종류만 들어오면 CPU가 예측으로 상쇄한다. "동적 디스패치 = 느리다"가 단순화인 이유이자, 벤치마크가 실제 데이터 분포에 좌우되는 이유다.

③ 진짜 비용 — 인라이닝이 막히면 그 뒤가 전부 막힌다

조회 2번 + 예측 실패보다 훨씬 큰 손실이 있습니다. 인라이닝이 되면 그 뒤에 최적화가 줄줄이 따라옵니다. 동적 디스패치는 그 연쇄를 끊습니다.

인라이닝이 열어 주는 연쇄 최적화 (Swift)
// ───────── 정적: 인라인 → 상수 접기 → 죽은 코드 제거 ─────────
struct Config {
    let debugMode = false
    func scale(_ v: Int) -> Int { debugMode ? v * 2 : v }
}
func compute() -> Int {
    Config().scale(21)
}
// 컴파일러가 하는 일:
//   1. scale() 인라인          → (false ? 21*2 : 21)
//   2. 상수 접기               → 21
//   3. Config() 할당도 불필요  → 죽은 코드 제거
//   최종 기계어: mov w0, #21   ← 함수 호출이 통째로 사라졌다

// ───────── 동적: 아무것도 못 한다 ─────────
protocol Scaler { func scale(_ v: Int) -> Int }
func computeDynamic(_ s: any Scaler) -> Int {
    s.scale(21)
}
// 컴파일러가 아는 것: "어떤 함수가 불릴지 모른다"
//   → 인라인 불가
//   → debugMode가 false인지 알 수 없으니 상수 접기 불가
//   → 부수효과가 있을지 모르니 죽은 코드 제거 불가
//   → 레지스터 할당도 보수적으로 (호출 규약을 지켜야 하니까)
🧠 L4 그래서 정확한 진술은 이것

"동적 디스패치의 직접 비용은 작습니다 — 로드 두 번과 간접 분기입니다. 실제로 큰 것은 기회비용이에요. 인라이닝이 막히면 상수 접기, 죽은 코드 제거, 레지스터 최적화가 연쇄적으로 막힙니다. 그래서 작은 함수를 자주 호출하는 핫 루프에서 차이가 가장 크고, 무거운 함수를 가끔 호출하는 곳에서는 사실상 차이가 없습니다."

이렇게 답하면 다음 질문("그럼 어디에 final을 붙여야 하나요?")의 답이 자동으로 따라옵니다 — 핫 루프에만, 그리고 측정한 뒤에.

④ objc_msgSend가 살아남은 이유 — 인라인 캐시

셀렉터 조회가 매번 전체 탐색이라면 아무도 안 썼을 것이다
// 개념적 동작
objc_msgSend(receiver, @selector(draw))
  ↓
  1. receiver 에서 isa (클래스 포인터) 로드
  2. 클래스의 "메서드 캐시"(셀렉터 → IMP 해시 테이블)에서 조회  ← 대부분 여기서 끝
  3. 미스면 클래스 계층을 따라 올라가며 메서드 목록 탐색
  4. 찾으면 캐시에 넣고 호출 (다음부터는 2에서 히트)

// 이 "한 번 찾은 결과를 캐시" 아이디어가 인라인 캐싱이고,
// Smalltalk/Self 연구에서 나와 지금은 JavaScript 엔진까지 다 쓴다.
// → 그래서 objc_msgSend 는 "느리지만 감당 가능한" 수준에 머문다.

// 다만 Swift 정적 호출과 비교하면 여전히 큰 차이:
//   · 인라인 불가 (런타임에 swizzling으로 바뀔 수 있으므로 원리적으로 불가능)
//   · 인자를 레지스터 규약에 맞춰 세팅하는 고정 비용

중요한 건 @objc dynamic을 성능 때문에 고르는 사람은 없다는 것입니다. KVO, method swizzling, target-action, #selector — 전부 런타임에 메서드를 바꿔치기할 수 있어야 성립하는 기능이고, 그건 정적 디스패치로는 원리적으로 불가능합니다. 즉 느림은 그 기능의 가격입니다.

CS 정본으로 더 내려가기

이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.

경험으로 말하기

🎤 측정 → 원인 → 국소 수정 → 안 건드린 곳

"성능을 위해 클래스에 final을 붙였습니다."

"차트가 스크롤 중에 프레임을 놓쳐서 Time Profiler를 봤더니, 데이터 포인트를 변환하는 코드가 [any Formatter] 배열을 원소마다 순회하고 있었습니다. 포맷터 종류는 3개뿐이고 런타임에 늘어나지 않아서 enum + switch로 바꿨습니다. existential 박싱과 witness table 경유가 사라졌고, switch는 인라인도 됩니다. 클래스 전체에 final을 도배하지는 않았습니다 — 그건 측정된 병목이 아니었고, 확장 지점을 막는 비용이 있으니까요."

핵심: 최적화 이야기에서 가장 신뢰를 주는 건 "안 한 것"을 말할 때입니다.

꼬리 질문 대비

L2 그럼 모든 클래스에 final을 붙이는 게 좋나요?

성능만 보면 그렇지만, 성능이 유일한 기준이 아닙니다. 두 가지를 나눠서 답하면 좋습니다.

  • 성능 관점 — 대부분의 코드에서 차이가 측정 불가 수준입니다. WMO를 켠 릴리즈 빌드는 internal 클래스를 이미 상당수 devirtualize합니다. 그러니 "성능 때문에 다 붙인다"는 근거가 약합니다.
  • 설계 관점 — 오히려 이쪽이 설득력 있습니다. final"이 클래스는 상속을 염두에 두고 만들지 않았다"는 문서입니다. 상속을 고려한 설계(어떤 메서드를 오버라이드해도 안전한지)는 실제로 어렵고, 대부분의 클래스는 그렇게 설계되지 않았습니다.

그래서 실무 규칙은 "기본값으로 final을 붙이고, 상속이 필요해지면 그때 의도적으로 뗀다"가 합리적입니다. 근거는 성능이 아니라 의도의 명시입니다.

쉽게: 문에 "관계자 외 출입 금지"를 붙이는 것과 같습니다. 빨라지려고 붙이는 게 아니라, 여기 들어와도 되는지 아무도 확인 안 했다는 뜻이에요.
L3 제네릭을 쓰면 무조건 정적 디스패치인가요?

아닙니다. "특수화(specialization)가 일어나면" 정적입니다. 안 일어나는 조건이 실제로 꽤 있습니다.

  • 모듈 경계 — 다른 모듈의 제네릭 함수는 본문이 안 보이면 특수화할 수 없습니다. @inlinable이 있어야 가능합니다.
  • 코드 크기 판단 — 특수화는 함수를 타입마다 복제하는 것이라 바이너리가 커집니다. 컴파일러가 이득이 없다고 판단하면 안 합니다.
  • 타입이 런타임에 결정되는 경우[any P]를 순회하며 제네릭 함수에 넘기면 각 원소의 구체 타입이 컴파일 타임에 없습니다.
  • 디버그 빌드-Onone에서는 특수화가 거의 일어나지 않습니다. 디버그 빌드로 성능을 측정하면 안 되는 이유가 이것입니다.
특수화 여부를 확인하는 법
# 특수화된 함수 이름에는 "specialized" 가 붙는다
swiftc -O -emit-sil Main.swift | swift demangle | grep specialized

# 최적화 리마크로 확인 (왜 안 됐는지까지 알려 준다)
swiftc -O -Xllvm -sil-print-function=... Main.swift
쉽게: 제네릭은 "주문 제작해 드릴게요"인데, 공장이 항상 제작해 주는 건 아닙니다. 주문서가 안 보이거나(모듈 경계), 제작비가 너무 크면(코드 크기) 그냥 범용 제품을 씁니다.
L4 동적 디스패치가 조회 몇 사이클이면, 왜 그렇게 강조하나요?

세 층으로 나눠 답하면 완결됩니다.

① 직접 비용은 실제로 작습니다. 캐시에 있으면 로드 2번 + 간접 분기 — 한 자릿수 사이클입니다. 이것만 보면 무시할 만합니다.

② 그런데 조건이 나빠지면 자릿수가 바뀝니다. vtable이나 witness table이 캐시에 없으면 로드마다 수백 사이클이고, 호출 지점에 여러 타입이 섞이면 분기 예측 실패로 10~20 사이클이 추가됩니다. 게다가 04번의 포인터 추격과 겹치면 미스가 연쇄됩니다.

③ 가장 큰 건 기회비용입니다. 인라이닝이 막히면 상수 접기·죽은 코드 제거·레지스터 최적화가 전부 막힙니다. 10줄짜리 함수가 0줄로 사라질 수 있었는데 호출로 남는 것이 진짜 손실입니다.

그래서 결론은 "평균적으로는 무시할 만하고, 핫 루프에서는 자릿수가 바뀔 수 있다. 그래서 측정 없이는 말하지 않는다"입니다.

쉽게: 문 하나 여는 데 1초 걸린다고 해 봅시다. 하루에 열 번이면 상관없어요. 그런데 1초마다 60번씩 열어야 하면(스크롤의 매 프레임) 이야기가 달라집니다. 게다가 문이 있으면 "짐을 통째로 옮겨 놓는 지름길"(인라이닝)을 못 씁니다.

쉽게 이해하기

전화를 건다고 생각해 봅시다.

그런데 진짜 비용은 다른 데 있습니다. 번호를 외우고 있으면 상대가 누군지 아니까 "아, 걔는 항상 '네'라고 하니까 아예 안 물어봐도 되겠네" 같은 지름길을 쓸 수 있습니다(상수 접기). 안내 데스크를 거치면 누가 받을지 모르니 지름길을 못 씁니다.

그리고 CPU에는 또 하나가 있습니다. CPU는 전화가 연결되기 전에 미리 다음 할 일을 준비해 둡니다. 상대가 늘 같은 사람이면 잘 맞히는데, 매번 다른 사람이 받으면 준비한 걸 다 버리고 처음부터 해야 합니다. 이게 분기 예측 실패예요.

그래서 결론이 이렇게 됩니다. 가끔 거는 전화면 아무래도 상관없고, 1초에 수백 번 거는 전화라면 번호를 외우는 게 낫습니다. 그리고 어느 쪽인지는 재 봐야 압니다.

🔑 한 문장

동적 디스패치의 비용은 조회 한 번이 아니라 최적화 차단이고, CPU 입장에서는 예측할 수 없는 점프다. 그래서 핫 루프에서만 문제가 되고, 측정 없이 final을 도배하는 건 근거가 아니라 미신이다.

설계 근거 · 1차 자료

이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.

📌 각 자료가 뒷받침하는 것
  • WWDC16 — 디스패치 4종과 existential container 설명의 원전
  • Library Evolution in Swift — ABI 안정성을 위해 무엇을 간접화했는가 — 디스패치가 되돌아가는 이유