iOS Development Guide

Protocol Oriented Programming

Swift는 클래스 상속이 아닌 프로토콜 + 값 타입 + 제네릭의 조합으로 추상화를 설계하도록 권장한다. 이 문서는 프로토콜 기본 개념부터 시작해 `some`/`any`, type erasure, 그리고 컴파일러가 내부적으로 사용하는 Protocol Witness Table과 dispatch 동작까지 정리한다.

Swift core paradigm Compiler internals (PWT / Existential) Knowledge items #9, #10
학습 날짜

2026-06-03, 2026-06-04

오늘 한 일 (학습 일지)

처음 보는 사람용 한 줄 설명

Protocol Oriented Programming은 "이 타입이 무엇을 할 수 있는지(behavior)"를 먼저 정의하고, 그 약속을 지키는 값 타입을 조립해 시스템을 만든다는 발상이다. 클래스 상속처럼 "무엇인가(is-a)"가 아니라 "무엇을 할 수 있나(can-do)"에서 출발한다.

가장 쉬운 비유

상속은 "나는 자동차를 물려받은 트럭이다"라고 정체성을 정한다. POP는 "이 친구는 `Drivable`과 `Cargoable`을 둘 다 할 수 있다"라고 능력을 조립한다. 능력 조합이 자유로워지면 한 줄로 정해진 부모-자식 관계의 제약을 벗어날 수 있다.

가장 쉬운 코드 예시

protocol Greetable {
    var name: String { get }
    func greet() -> String
}

extension Greetable {
    func greet() -> String { "Hello, \(name)" }
}

struct User: Greetable {
    let name: String
}

User(name: "Hankyu").greet() // "Hello, Hankyu"

`Greetable`은 능력의 정의, `extension`은 기본 구현, `User`는 능력을 채택한 값 타입이다. 상속 없이도 동작이 한 곳에서 재사용된다.

왜 상속이 아닌 프로토콜인가

  • 다중 채택이 자유롭다 — 다이아몬드 문제 회피
  • 값 타입(`struct`)에도 적용된다 — 복사 의미와 자연스럽게 결합
  • 능력을 단위로 조립 → 강결합이 줄어든다
  • 제네릭과 결합하면 zero-cost 추상화가 가능하다

Why This Work Exists

Swift를 일정 기간 쓰다 보면 "왜 `Equatable`을 함수 파라미터로 그냥 못 받지?", "`some View`와 `any View`는 뭐가 다르지?", "`AnyView`는 왜 따로 있지?" 같은 질문을 만난다. 이 질문들은 모두 프로토콜의 정적/동적 디스패치와 existential container라는 한 가지 뿌리에서 나온다.

Scope / Non-scope

  • 프로토콜 기본 + extension의 dispatch 함정
  • associated type과 `Self` 제약
  • `some` / `any`의 의미와 비용
  • type erasure 패턴
  • Protocol Witness Table의 동작 흐름
  • Generics specialization과 dispatch 비교
  • 매크로(`@attached`)와 결합한 최신 변형은 다루지 않는다

As-is

흔한 오해: "프로토콜은 그냥 자바의 interface다", "`some`은 그냥 `any`의 신형 문법이다", "`AnyView`는 SwiftUI가 만들어 둔 무엇이다" 정도로 표면만 알고 넘어간다.

To-be

더 정확한 이해: 프로토콜은 witness table을 통한 dispatch 계약이고, `some`/`any`는 그 dispatch가 정적이냐 동적이냐를 결정하며, type erasure는 existential의 한계를 우회하기 위한 박스 패턴이다.

Section 1 — Protocol 기본기 (압축)

protocol Counter {
    var value: Int { get }
    mutating func increment()
}

struct InMemoryCounter: Counter {
    private(set) var value = 0
    mutating func increment() { value += 1 }
}

Section 2 — Protocol Extension과 Static Dispatch 함정

Protocol extension은 기본 구현을 제공해 "horizontal reuse"를 만든다. 그런데 프로토콜 선언부에 없고 extension에만 있는 메서드는 정적 디스패치된다. 이 한 가지 규칙이 의외로 자주 사람을 잡는다.

protocol P {
    func a()           // requirement
}
extension P {
    func a() { print("P.a default") }
    func b() { print("P.b ext only") }   // requirement 아님
}

struct S: P {
    func a() { print("S.a") }
    func b() { print("S.b") }
}

let p: P = S()
p.a()  // "S.a"          (dynamic, witness table)
p.b()  // "P.b ext only" (static, 컴파일 시점 P로 결정)

`b()`는 프로토콜 requirement가 아니므로 witness table에 들어가지 않는다. 타입을 `P`로 본 시점에 호출은 `P.b`로 정적 바인딩된다. 다형성을 기대하고 extension에만 메서드를 넣으면 의도와 다른 호출이 일어난다.

Section 3 — Associated Type (PAT)

`associatedtype`은 "이 프로토콜을 채택하는 타입이 결정할 동반 타입"을 의미한다. `Self`나 PAT을 requirement에 노출하는 순간 프로토콜은 일반 타입처럼 쓰일 수 없게 된다 — 흔히 보는 "Protocol can only be used as a generic constraint" 에러의 원인이다.

protocol Container {
    associatedtype Item
    var count: Int { get }
    mutating func append(_ item: Item)
    subscript(i: Int) -> Item { get }
}

// X: 컴파일 에러 — Item을 결정할 수 없음
// func sum(_ c: Container) -> Int { ... }

// O: 제네릭 제약으로 사용
func describe<C: Container>(_ c: C) -> String where C.Item == Int {
    "count=\(c.count), first=\(c[0])"
}

`Self`/PAT이 있으면 컴파일러는 메모리 레이아웃을 단일 box로 만들 방법이 없다. 그래서 Swift 5.7 이전에는 "쓸 수 없는 프로토콜"처럼 보였고, 이후 `any`로 일부 허용된다 (단 호출 가능한 멤버에 제약 존재).

Section 4 — `some` (Opaque Type)

`some P`는 "한 가지 구체 타입인데, 외부에는 P로만 노출하겠다"는 의미다. 컴파일 시점에 구체 타입이 결정되어 있고, 호출자가 그 구체 타입을 알 수 없을 뿐이다.

func makeShape() -> some Shape {
    Circle()   // 반환 구체 타입은 단일하게 Circle
}
  • 식별성(identity)이 보존된다 — 같은 함수의 반환값은 항상 같은 타입
  • 대부분 정적 디스패치 → 박싱 없음 → zero-cost에 가깝다
  • SwiftUI의 `var body: some View`가 `some`인 이유: 매번 다른 타입을 그릴 수 없고, 컴파일 시 단일 View 트리 타입으로 합쳐진다

Section 5 — `any` (Existential Type)

`any P`는 "런타임에 어떤 P 구체 타입이든 담을 수 있는 상자"다. 컴파일러는 이 상자를 위해 existential container를 만든다.

let shapes: [any Shape] = [Circle(), Square(), Triangle()]
for s in shapes { s.draw() }   // dynamic dispatch
  • 이질적인 타입을 한 컬렉션에 담을 때 필요
  • 박싱 + witness table 간접 호출 비용 발생
  • Swift 5.7부터 `any` 키워드가 의무화된 이유는 비용을 코드에서 명시적으로 드러내기 위함

Section 6 — Existential Container의 실제 구조

`any P` 한 값은 메모리에서 보통 다음과 같이 구성된다 (요지 단순화).

┌──────────────────────────────┐
│ value buffer (3 word, 24B)   │  ← 작으면 inline, 크면 힙 포인터
├──────────────────────────────┤
│ type metadata pointer        │  ← 어떤 구체 타입인가
├──────────────────────────────┤
│ value witness table pointer  │  ← copy/destroy 방법
├──────────────────────────────┤
│ protocol witness table       │  ← P의 requirement → 구체 메서드 매핑
└──────────────────────────────┘

구체 값이 3-word(보통 24바이트) 이하면 buffer에 inline으로 들어가고, 그보다 크면 힙에 할당되고 buffer에는 포인터만 들어간다. 이게 `any`가 "값 복사처럼 보이지만 힙을 만질 수 있는" 이유다.

Section 7 — Protocol Witness Table 동작 흐름

클래스의 vtable과 비슷하지만, 한 가지 큰 차이가 있다: 클래스의 vtable은 객체 안에 isa 포인터로 박혀 있고, PWT는 (타입, 프로토콜) 쌍마다 별도로 만들어져 existential 또는 generic context에서 전달된다.

sequenceDiagram participant Caller participant Existential as any P container participant PWT as P-witness-table-for-S participant Concrete as S.method impl Caller->>Existential: p.method() Existential->>PWT: lookup index for "method" PWT->>Concrete: call function pointer Concrete-->>Caller: result

반면 generic 호출 `func f<T: P>(_ t: T)`은 컴파일러가 호출 사이트마다 PWT를 인자처럼 넘긴다. 그리고 최적화가 켜지면 specialization이 일어나 T가 고정된 별도 함수로 복제되고, 디스패치 자체가 사라진다. 이게 generic이 종종 zero-cost로 불리는 이유다.

Section 8 — Dispatch 비교 표

형태Dispatch비용여러 구체 타입 수용
`func f(p: any P)` Dynamic (PWT) 박싱 + 간접 호출 O (런타임 가변)
`func f(p: some P)` Static (대부분) zero-cost에 가까움 X (호출당 단일)
`func f<T: P>(_ t: T)` Static (specialization 시) zero-cost (specialized) O (호출 사이트마다 다른 T)
Class 일반 메서드 Dynamic (vtable) 간접 호출 O
`final` / `struct` 메서드 Static zero-cost X

실무 결정의 핵심: "호출 시점에 구체 타입이 한 가지인가, 여러 가지인가"로 갈린다. 한 가지면 `some` 또는 generic, 여러 가지여야만 한다면 `any` 또는 type erasure.

Section 9 — Type Erasure

Associated type이 있는 프로토콜(`Sequence`, `Publisher`, 직접 만든 PAT 프로토콜)은 `any P`로 사용하는 데 제약이 크다. 이때 구체 타입을 박스 클래스/struct로 감싸 단일 타입으로 노출하는 패턴이 type erasure다.

protocol Animal {
    associatedtype Food
    func eat(_ food: Food)
}

struct AnyAnimal<Food>: Animal {
    private let _eat: (Food) -> Void
    init<A: Animal>(_ base: A) where A.Food == Food {
        self._eat = base.eat
    }
    func eat(_ food: Food) { _eat(food) }
}

let zoo: [AnyAnimal<String>] = [
    AnyAnimal(Cat()),
    AnyAnimal(Dog())
]

Section 10 — 실무 의사결정 플로우

flowchart TD A["API 입력/반환 타입을 정해야 함"] --> B{"구체 타입이 호출 시점에 단일?"} B -- "Yes" --> C{"호출자가 그 타입을 알아야 하나?"} C -- "Yes" --> D["제네릭
func f<T: P>(_ t: T)"] C -- "No" --> E["some P"] B -- "No" --> F{"associated type 있는 프로토콜?"} F -- "Yes" --> G["Type Erasure
(AnyX 박스)"] F -- "No" --> H["any P"]

Class Diagram

classDiagram class Protocol { +requirements } class ProtocolWitnessTable { +functionPointers } class ExistentialContainer { +valueBuffer +typeMetadata +pwtPointer } class ConcreteType { +method() } Protocol --> ProtocolWitnessTable : declares slots ConcreteType --> ProtocolWitnessTable : fills slots (witness) ExistentialContainer --> ProtocolWitnessTable : uses for dispatch ExistentialContainer --> ConcreteType : holds value

Risks And Decisions Needed

  • `any`를 남용하면 컬렉션이 박싱으로 가득 차 성능이 떨어진다.
  • Extension에만 메서드를 넣고 다형성을 기대하면 silent bug가 생긴다 (Section 2).
  • Type erasure 박스를 직접 만들면 PWT 동작 이해 없이 작성하다 retain cycle/타입 안전성을 잃기 쉽다.
  • POP를 "모든 것을 protocol로" 식으로 받아들이면 작은 도메인까지 과한 추상화가 생긴다.

Operations / Rollout Checklist

  • 후속 문서로 "SwiftUI에서 `some View`가 body 트리에 미치는 영향"을 분리할 수 있다.
  • "Generic specialization과 -Onone vs -O" 비교 문서를 추가할 수 있다.
  • "PAT 프로토콜을 직접 type-erase하는 5단계 레시피"를 별도 문서로 정리한다.

QA / 자가 학습 체크리스트

Q&A — 학습 중 막혔던 질문 모음 (2026-06-04)

본문을 읽다가 막힌 단어/개념을 GPT와 Q&A로 풀어낸 기록. 같은 질문이 다시 나올 때 빠르게 다시 보기 위해 누적한다.

Q1. "다이아몬드 문제 회피"가 뭐야?

다중 상속에서 같은 조상의 멤버가 두 경로로 내려와 충돌하는 문제. 모양이 마름모(◇)라 다이아몬드 문제다. 클래스 다중 상속을 허용하는 C++은 virtual 상속으로 우회해야 하고, Java/Kotlin/Swift class는 다중 상속 자체를 금지해 회피한다. Swift 프로토콜은 "요구사항만 정의 + 기본 구현 충돌 시 컴파일 에러"로 채택 타입이 직접 해결하게 강제한다.

Q2. "제네릭과 결합하면 zero-cost 추상화가 가능하다"의 의미

추상화는 보통 간접 호출과 박싱 비용이 따라온다. 그런데 <T: P> generic 함수는 컴파일러가 specialization을 수행해 T별 전용 함수로 복제하므로 PWT 조회와 박싱이 사라진다. 손으로 직접 짠 구체 타입 코드와 동일한 어셈블리가 나온다 — 이게 zero-cost.

Q3. 박싱(Boxing)이 뭐야?

작은 값을 일정한 모양의 "상자" (existential container) 에 담아 넘기는 것. 타입마다 크기가 다른데 any P 슬롯은 고정 크기여야 해서 박스가 필요하다. Swift는 buffer가 보통 3-word(24 byte). 그보다 작으면 inline, 크면 heap에 따로 두고 buffer엔 포인터만.

Q4. "레지스터/스택에 위치"가 뭔말?

CPU 메모리 계층. 레지스터(CPU 내부, 0 사이클) → L1/L2/L3 캐시 → 스택(함수 호출마다 자동 할당) → 힙(명시적 malloc). 앞으로 갈수록 빠름. let n = 42 같은 스택 변수는 거의 공짜, 힙은 malloc/free 비용 발생.

Q5. PWT 조회 후 "함수 호출"이 뭔말?

PWT는 함수 포인터 배열이다. 슬롯에서 함수 주소를 꺼내 그 주소로 call(점프)하는 것 = 함수 호출. 구체 타입은 컴파일 시점에 호출 주소가 어셈블리에 박혀 있어서 조회 없이 바로 call.

Q6. (오답노트) Swift는 모듈 단위로 컴파일된다

모듈 = 함께 컴파일되는 코드 단위 (SPM, Framework, Xcode target, main app). 모듈 빌드 시 .swiftmodule(시그니처)과 바이너리(.dylib/.o, 본문)가 나뉜다. 모듈 경계를 넘는 호출은 specialization, inlining, dead code elimination 같은 최적화가 막힐 수 있다.

Q7. 함수 시그니처가 뭐야?

함수의 "겉모습" — 이름 + 파라미터 타입 + 반환 타입. 본문은 제외. .swiftmodule엔 시그니처만 들어 있고, 본문은 라이브러리 바이너리에 따로 컴파일되어 있다.

Q8. "라이브러리 바이너리에 이미 컴파일되어 있다"

라이브러리 빌드 시점에 generic 본문은 "T 모르는 일반화된 형태"의 기계어로 굳어 버린다. App을 컴파일할 때 "T = Circle"로 다시 짜고 싶어도 본문 소스가 없으니 specialization 불가능. @inlinable은 본문 소스를 .swiftmodule에 같이 박아 넣어 specialization을 가능하게 한다.

Q9. @inlinable은 함수에 붙이는 거 아니야?

맞다. 함수/메서드/연산자/computed property에 붙인다. 타입 자체에는 못 붙인다. "Array, Dictionary가 @inlinable 처리되어 있다"는 말은 그 타입의 거의 모든 메서드 각각에 @inlinable이 붙어 있다는 뜻.

Q10. "박스 열기 + 포인터 조회 + jump" 자세히

any Shapes.area() 호출 단계:

  1. 박스(existential container) 열기 — buffer에서 실제 값 위치 확보
  2. PWT 포인터 로드 — container에서 PWT 주소를 레지스터로
  3. 함수 포인터 조회 — PWT[area 슬롯]에서 함수 주소를 가져옴
  4. 그 주소로 CALL — 점프해서 실행

구체 타입은 CALL Circle.area 한 줄로 끝. 핫 루프에서 차이 누적되면 체감된다.

Q11. "박싱이 generic 쪽이 덜 일어난다"

Specialization 실패한 경우의 얘기. Generic 함수는 ABI가 "값은 T 크기 그대로 + PWT를 별도 인자로 전달"이라 박싱 없이 PWT dispatch만 일어난다. any는 호출 전부터 박싱이 일어난다.

Q12. (오답노트 정정) any도 inline이면 zero-cost?

아니다. 부분적으로만 맞다. 작은 값(≤ 3 word)은 inline boxing이라 박싱 비용은 ≈ 0이 맞다. 하지만 dispatch 비용은 크기와 무관하게 항상 PWT 조회 + 간접 호출이 일어난다. Zero-cost가 되려면 generic + specialization 조합이 필요하다.

Q13. 메모리 블록이 뭐야?

연속된 메모리 주소 범위. "방 한 칸"처럼 생각하면 된다. inline = 같은 블록 안 / heap = 분리된 다른 블록을 포인터로 가리킴.

Q14. "Array 본체는 힙"이 뭔말?

Swift Array는 struct(값 타입)지만 내부에 힙 포인터 하나만 가진다. 진짜 원소들이 담긴 storage는 힙에. 그래서 "껍데기는 스택, 본체(저장소)는 힙"이다. String, Dictionary, Set도 같은 구조.

Q15. Generic + 모듈 경계에서 박싱 안 하는 이유

Generic 함수의 호출 규약(ABI)이 any와 다르다. any P는 호출자가 값을 existential container로 박싱해 넘기지만, <T: P>는 T의 실제 크기 그대로 값을 전달하고 PWT를 숨겨진 별도 인자로 함께 넘긴다. 박스라는 고정 모양이 필요 없어서 박싱이 일어나지 않는다. Specialization 실패해도 PWT dispatch만 일어나고 박싱은 없다.

요약 한 줄

"any는 박싱 + PWT, <T: P>는 PWT만 (성공 시엔 그것도 사라짐). 구체 타입은 다 없음."