← Swift & Foundation 로드맵 8 / 33

08 · 제네릭과 some·any

existential container의 3-word 버퍼와 힙 박싱 경계부터 some/any의 역제네릭 구도, primary associated type 문법, 제네릭 특수화까지 컴파일러 안쪽을 본다.
진행률
0 / 0 완료

제네릭과 some·any는 결국 같은 질문에 대한 서로 다른 답이다 — "이 값의 정확한 타입을 컴파일 타임에 고정할 것인가, 런타임까지 미룰 것인가." struct/class와 some/any의 큰 그림은 iOS CS 로드맵 05장에서 이미 다뤘으니, 여기서는 그 답이 메모리 레이아웃과 SIL(Swift Intermediate Language) 수준에서 실제로 어떻게 갈리는지, 그리고 각 선택의 비용을 컴파일러를 직접 열어보며 확인한다.

Q1. 실존 컨테이너(existential container)는 어떻게 생겼고, 언제 힙 박싱이 일어나는가?

🔑 30초 답변

Existential 컨테이너는 64비트에서 5-word(40바이트) 고정 구조다: 3-word 인라인 값 버퍼 + Value Witness Table(VWT) 포인터 + Protocol Witness Table(PWT) 포인터. 값의 크기가 3-word(24바이트) 이하면 그 값 자체가 버퍼에 그대로 들어가고 힙 할당이 없다. 24바이트를 넘으면 런타임이 힙에 박스를 새로 할당해 실제 값을 거기 두고, 버퍼의 첫 워드에는 그 박스를 가리키는 포인터만 남긴다. 이 경계는 Int가 아니라 워드 3개라는 숫자 자체가 기준이라는 게 핵심이다.

원리

SE-0335 원문이 이 비용을 정확히 명시한다: "existential types require dynamic memory unless the value is small enough to fit within an inline 3-word buffer. In addition to heap allocation and reference counting, code using existential types incurs pointer indirection and dynamic method dispatch that cannot be optimized away." 즉 힙 할당·ARC뿐 아니라 포인터 간접 참조와 동적 디스패치(PWT 경유)까지, 컴파일러가 최적화로 없앨 수 없는 비용 네 가지가 함께 따라온다.

내부 동작

실제로 Xcode 26 / Swift 6.2.1에서 직접 찍어보면 숫자가 정확히 맞아떨어진다. MemoryLayout<any P>.size는 40이고, 필드 개수를 1개(8B)부터 4개(32B)까지 늘린 구조체를 any P에 담아 컨테이너의 첫 워드를 읽으면 경계가 어디서 갈리는지 보인다: 24바이트(3-word)까지는 필드 값이 그대로 버퍼에 나타나고, 32바이트(4-word)부터는 첫 워드가 필드 값과 무관한 힙 주소로 바뀐다. 같은 타입의 서로 다른 인스턴스 두 개를 동시에 살려서 비교하면 그 주소가 매번 다르다는 것도 확인되므로, 우연이 아니라 진짜 malloc 박스라는 뜻이다.

클래스 전용 프로토콜(P: AnyObject)의 existential은 이보다 훨씬 작다 — 실측 16바이트(참조 1워드 + PWT 1워드)뿐이다. 클래스 인스턴스는 이미 그 자체가 포인터라 3-word 버퍼도, 별도 VWT도 필요 없기 때문이다. 반대로 프로토콜을 합성(any P1 & P2)하면 프로토콜마다 PWT 슬롯이 하나씩 늘어 컨테이너가 커진다(실측: any P1 40B → any P1 & P2 48B).

3-word라는 숫자 자체는 임의로 정해진 게 아니라 논쟁의 결과물이다. SR-3340 포럼 스레드에서 Chris Lattner는 2-word로 줄이자고 제안했고, Michael Ilseman은 2-word 버퍼가 단일 프로토콜 existential을 4-word로 만들어 32비트 ARM의 레지스터 배정에 유리한 "sweet spot"이라 지적했지만, Arnold Schwaighofer는 이를 뒷받침할 벤치마크가 없다는 이유로 3-word 유지를 옹호했다. 그 논쟁 이후 지금(2026)까지 3-word가 그대로 이어지고 있다.

existential container 해부 — 5 word(40B, x64) value buffer (3 word · 24B) VWT ptr PWT ptr (합성 프로토콜이면 PWT가 늘어남) ① 값 ≤ 24B(3-word 이하 구조체) → 버퍼에 그대로 인라인 저장 0x1111 0x2222 0x3333 ← 힙 할당 없음, 값 자체가 곧 버퍼 ② 값 > 24B → word0엔 힙 포인터만, 나머지 2word는 비워짐 힙 ptr 미사용 미사용 힙에 새로 할당된 박스 — 실제 값(32B+) + 참조 카운트 헤더 배열·컬렉션에 이런 값을 담을 때마다 malloc + retain/release가 발생한다
existential 컨테이너는 3-word 버퍼 + VWT/PWT 포인터로 크기가 고정(40B)돼 있다. 값이 버퍼를 넘으면 힙에 박싱되고 버퍼엔 포인터만 남는다.

실험 · 도구

워드 수를 1~4개로 늘린 구조체를 만들어 크기와 첫 워드 내용을 직접 비교하면 경계가 눈으로 보인다.

MemoryLayout으로 컨테이너 크기와 힙 박싱 경계 확인
protocol P {}
struct W3: P { let a, b, c: Int }      // 24B = 3 word, 경계값
struct W4: P { let a, b, c, d: Int }   // 32B = 4 word, 경계 초과

print(MemoryLayout<any P>.size)          // 40 (5 word, x64) — 실측
print(MemoryLayout<W3>.size, MemoryLayout<W4>.size)  // 24, 32

// 컨테이너 첫 워드를 직접 읽어 인라인 vs 박싱을 구분한다
func firstWord(_ v: any P) -> UInt {
    withUnsafeBytes(of: v) { $0.bindMemory(to: UInt.self)[0] }
}
let w3 = W3(a: 0x1111, b: 0x2222, c: 0x3333)
let w4 = W4(a: 0x5555, b: 0x6666, c: 0x7777, d: 0x8888)
print(String(firstWord(w3), radix: 16))   // "1111" — 실제 데이터가 그대로 인라인
print(String(firstWord(w4), radix: 16))   // "a251290b0" 같은, 필드 값과 무관한 힙 주소

// 두 박스를 배열로 동시에 살려두면 서로 다른 힙 주소임을 확인할 수 있다(진짜 malloc)
let boxes: [any P] = [
    W4(a: 1, b: 2, c: 3, d: 4),
    W4(a: 5, b: 6, c: 7, d: 8)
]
print(firstWord(boxes[0]) != firstWord(boxes[1]))  // true

Instruments의 Allocations 인스트루먼트로 W4 같은 큰 값을 [any P] 배열에 반복해서 담아보면 담을 때마다 힙 할당 이벤트가 찍히는 반면, W3는 아무리 반복해도 할당이 늘지 않는 걸 직접 관찰할 수 있다.

프로젝트 적용

이종(heterogeneous) 컬렉션이 꼭 필요해서 any를 쓸 때, 담기는 구체 타입들이 3-word를 넘는지 미리 가늠하라. 넘는다면 그 타입을 참조 타입(class)으로 바꾸는 것도 방법이다 — 클래스 existential은 16바이트로 더 작고 값 복사 자체가 포인터 복사라 박싱 비용이 없다.

큰 값 타입을 any로 담을 때의 트레이드오프
protocol Shape { func area() -> Double }

// ❌ 필드가 많은 struct를 그대로 any Shape 배열에 담으면
//    항목마다 힙 박스가 생긴다(3-word 초과 시)
struct Polygon: Shape {
    let points: [Double]   // 배열 프로퍼티라 struct 자체는 작지만,
    let a, b, c, d: Double // 이 필드들까지 더하면 금방 24B를 넘는다
    func area() -> Double { 0 }
}

// ✅ 애초에 힙에 있는 참조 타입으로 만들면 existential이 16B로 고정된다
final class PolygonRef: Shape {
    let points: [Double]
    init(points: [Double]) { self.points = points }
    func area() -> Double { 0 }
}
let shapes: [any Shape] = [PolygonRef(points: [0,0,1,1])]

반대로 힙 박싱 자체가 문제라면, existential이 정말 필요한지부터 재검토하라. 이종 저장이 목적이 아니라면 제네릭(<T: Shape>)이나 some Shape로 바꾸는 것만으로 버퍼/박싱 문제 자체가 사라진다(Q2·Q5 참고).

이종 저장이 필요 없다면 제네릭으로 박싱 자체를 제거
// any Shape 배열은 항목마다 dispatch·잠재적 박싱 비용이 있다
func totalArea(_ shapes: [any Shape]) -> Double {
    shapes.reduce(0) { $0 + $1.area() }
}

// 같은 구체 타입만 다룬다면 제네릭이 항상 더 싸다 — 특수화되면 박싱도 dispatch도 없다
func totalArea<S: Shape>(_ shapes: [S]) -> Double {
    shapes.reduce(0) { $0 + $1.area() }
}
⚠️ 흔한 오해

"existential 값은 무조건 힙에 할당된다"는 틀렸다. 3-word 이하면 버퍼에 그대로 인라인되어 힙 할당이 없다. 반대로 "any 키워드가 도입되면서 성능이 나빠졌다"도 틀렸다 — any는 원래 있던 비용을 명시적으로 드러내는 표기일 뿐, SE-0335 도입 전후로 런타임 표현이나 비용이 달라진 게 아니다.

🧒 쉽게 이해하기

작은 주머니(버퍼) 하나가 동전 3개까지만 들어간다고 생각해보자. 동전이 3개 이하면 그냥 주머니에 넣고 다니면 된다. 그런데 동전이 4개, 5개로 늘어나면 주머니에 다 안 들어가니까, 근처 물품 보관함(힙)에 맡기고 보관함 열쇠만 주머니에 넣고 다닌다. 열쇠는 작으니까 항상 주머니에 들어간다. 비유가 깨지는 곳: 진짜 열쇠와 다르게, 이 "열쇠"(포인터)는 그 자체로 원래 동전 하나가 있던 자리를 그대로 차지한다 — 주머니 모양(버퍼 크기)이 바뀌는 게 아니라 안에 든 게 동전에서 열쇠로 바뀔 뿐이다.

꼬리 질문

프로토콜을 합성(any P1 & P2)하면 컨테이너 크기는 왜, 얼마나 늘어나는가?
3-word 값 버퍼와 VWT 포인터는 그대로지만, PWT 포인터는 준수해야 할 프로토콜마다 하나씩 필요하다. 실측으로 any P1은 40바이트인데 any P1 & P2는 48바이트로, 정확히 PWT 하나(8바이트)만큼 늘었다. 프로토콜을 더 합성할수록 이 슬롯이 계속 늘어나므로, 합성 개수가 많아지면 존재 자체의 오버헤드도 그만큼 커진다.
쉽게 말하면 자격증이 여러 개 필요한 자리라면, 신분증 지갑에 자격증 사본을 한 장씩 더 끼워 넣는 것과 같다. 자격증이 늘수록 지갑이 두꺼워진다.
클래스 전용 프로토콜(protocol P: AnyObject)의 existential이 16바이트로 더 작은 이유는?
클래스 인스턴스는 애초에 힙에 있고 변수는 그 힙 객체를 가리키는 포인터 하나일 뿐이다. 그래서 3-word 값 버퍼도, 별도의 VWT(값의 복사·소멸을 다루는 테이블)도 필요 없다 — ARC가 이미 그 일을 한다. 남는 건 "참조 포인터 1워드 + PWT 포인터 1워드"뿐이라 16바이트로 끝난다. 값 타입도 담을 수 있는 일반 existential이 40바이트인 것과 대비된다.
쉽게 말하면 클래스는 원래부터 "저 창고에 있다"는 주소 쪽지 한 장으로 충분한데, 값 타입은 물건 자체를 들고 다닐 수도 있으니 더 큰 주머니가 필요한 것과 같다.
[any P] 배열에 크기가 제각각인 구체 타입들을 섞어 담으면 배열 저장소 자체는 어떻게 되는가?
배열의 각 슬롯은 크기가 제각각인 실제 값이 아니라 고정 크기 40바이트 컨테이너를 저장한다. 그래서 배열 원소 접근(O(1) 인덱싱)은 그대로 성립하지만, 3-word를 넘는 원소마다 별도의 힙 박스가 따라붙어 배열을 순회할 때마다 포인터를 한 번 더 따라가야 한다(포인터 인디렉션). 배열 자체의 메모리 지역성(locality)은 원소 크기가 제각각이어도 깨지지 않지만, 큰 원소의 실제 데이터는 그 지역성 밖(힙 여기저기)에 흩어져 있다는 게 함정이다.
쉽게 말하면 사물함(배열)은 칸마다 크기가 똑같다. 큰 짐은 사물함 안에 "물품 보관소 몇 번" 쪽지만 넣어두고, 진짜 짐은 사물함과 상관없는 다른 창고에 흩어져 있는 셈이다.

Q2. someany는 무엇이 다른가? "역제네릭(reverse generics)"이란?

🔑 30초 답변

some P(SE-0244)는 호출자가 타입을 고르지 못한다 — 반환 위치에서는 callee(함수 자신)가 구체 타입 하나를 고정하고, 호출자는 "그게 뭔지는 몰라도 항상 같은 타입"이라는 사실(타입 정체성)만 받는다. 이걸 뒤집힌 방향이라는 뜻에서 "역제네릭"이라 부른다 — 보통 제네릭은 호출자가 타입을 고르는데, opaque 타입은 그 반대이기 때문이다. any P는 정반대로 값의 concrete type이 실행마다 바뀔 수 있고, 그래서 Q1의 existential 컨테이너에 박싱된 채 저장되며 항상 동적 디스패치를 거친다.

원리

SE-0244의 동기는 라이브러리 저자가 Union<Rectangle, Transformed<Rectangle>> 같은 복잡한 제네릭 타입을 그대로 노출하지 않고 "Shape를 준수한다"는 사실만 노출하려는 데 있다. 원문 표현으로는 "Generics are bound by the caller, while opaque types are bound by the callee" — 제네릭 시그니처를 화살표 오른쪽에 두는 셈이라는 프레이밍이 여기서 나온다. 이후 SE-0341이 파라미터 위치에도 some P를 허용했는데, func f(_ p: some P)func f<_T: P>(_ p: _T)의 축약형과 동치라고 명시한다 — 이 경우는 반환 위치와 달리 호출자가 실제 인자를 통해 타입을 정한다.

내부 동작

결정적 차이는 existential과 달리 opaque 타입은 타입 정체성(type identity)을 유지한다는 것이다. SE-0244 원문: "Unlike an existential, though, clients still have access to the type identity." 그래서 컴파일러가 호출부에서 함수 본문을 볼 수 있으면(인라이닝) 구체 타입을 그대로 알아 간접비용을 없앨 수 있다.

실제로 -emit-sil로 확인하면 이 차이가 인스트럭션 레벨에서 그대로 드러난다. func makeShape() -> some Shape { Circle() }는 반환 슬롯 타입이 $*Circle로, 구체 타입 그대로 값을 만들어 넣는다. 반면 func makeShape() -> any Shape { Circle() }는 반환 슬롯이 $*any Shape이고, 그 안에 init_existential_addr %0, $Circle 인스트럭션이 등장한다 — Circle 값을 existential 컨테이너 형태로 박싱해 넣는 전용 명령이다. some 쪽에는 이 인스트럭션이 아예 없다.

누가 구체 타입을 고르는가 제네릭 <T:P> / 파라미터 위치 some P 호출자(caller) 구체 타입을 고른다 함수(callee) → WMO/inlinable이면 특수화 가능 반환 위치 some P — "역제네릭" 함수(callee) 타입 고정, 정체 감춤 호출자: "무슨 Shape"인지 몰라도 항상 같은 타입임은 안다 any P — 매번 실행 시점에 결정 호출마다 다른 concrete type이 들어올 수 있음 existential 박싱(Q1) → 항상 witness table 경유, 특수화 불가
제네릭·파라미터 some은 호출자가 타입을 고르고, 반환 some은 callee가 고른 타입을 감춰서 넘기며(역제네릭), any는 매 순간 다른 타입일 수 있어 박싱된다.

실험 · 도구

-emit-sil로 같은 함수를 some/any 두 버전으로 만들어 비교하면 init_existential_addr 유무가 바로 갈린다.

-emit-sil로 some vs any 반환의 실제 차이 확인
protocol Shape { func draw() -> String }
struct Circle: Shape { func draw() -> String { "circle" } }

func makeShapeSome() -> some Shape { Circle() }
func makeShapeAny() -> any Shape { Circle() }

// swiftc -emit-sil 로 뽑아보면:
// makeShapeSome(): bb0(%0 : $*Circle) — 구체 타입 그대로, existential 인스트럭션 없음
// makeShapeAny() : bb0(%0 : $*any Shape)
//   %4 = init_existential_addr %0, $Circle   ← 박싱 전용 인스트럭션
//   store %3 to %4

// 파라미터 위치 some은 호출자가 타입을 고르므로 함수 값으로 참조가 안 된다
func render(_ s: some Shape) { print(s.draw()) }
// let f = render
// ❌ error: generic parameter 'some Shape' could not be inferred
//    (호출 문맥이 없으면 컴파일러가 채워 넣을 구체 타입을 못 고른다)

프로젝트 적용

WWDC22 "Embrace Swift generics" 가이드라인 그대로: 기본은 some을 쓰고, 배열처럼 서로 다른 구체 타입을 한 컬렉션에 섞어야 할 때만 any로 바꿔라.

동종이면 some, 이종 저장이 필요하면 any
protocol Shape { func area() -> Double }
struct Circle: Shape { let r: Double; func area() -> Double { .pi * r * r } }
struct Square: Shape { let s: Double; func area() -> Double { s * s } }

// ✅ 항상 같은 구체 타입 하나만 돌려준다 — some으로 정체성 유지 + 특수화 가능
func makeDefaultShape() -> some Shape { Circle(r: 1) }

// ✅ Circle과 Square를 한 배열에 같이 담아야 한다 — 이종 저장은 any의 존재 이유
let mixed: [any Shape] = [Circle(r: 1), Square(s: 2)]
print(mixed.map { $0.area() })

API 경계에서 "이 함수는 항상 같은 타입을 돌려준다"는 사실을 some으로 타입 시스템에 알려두면, 호출자 쪽에서 == 같은 동일 타입 간 연산도 안전하게 쓸 수 있다(Q3의 covariant 소거와도 연결된다).

some은 타입 정체성이 남아있어 같은 타입 간 연산이 가능하다
protocol Shape: Equatable { func draw() -> String }
struct Circle: Shape { func draw() -> String { "circle" } }

func makeShape() -> some Shape { Circle() }

let a = makeShape()
let b = makeShape()
print(a == b)   // ✅ 컴파일됨 — 둘 다 실제로는 Circle이라는 걸 컴파일러가 안다
// any Shape 였다면 Equatable의 Self 요구사항이 contravariant 위치라 이 비교 자체가 막힌다(Q3)
⚠️ 흔한 오해

"some은 그냥 any의 축약형이다"는 틀렸다. 타입 정체성 유지 여부가 근본적으로 다르다 — some은 항상 하나의 고정된 concrete type을 가리키지만 any는 실행마다 다른 concrete type을 담을 수 있다. 또한 "파라미터 위치의 some P와 제네릭 <T: P>는 완전히 같다"도 정확하지 않다 — 유일한 실질 차이는 some 파라미터를 가진 함수를 함수 값으로 참조할 수 없다는 점이다(호출 문맥이 있어야 타입을 추론할 수 있기 때문).

🧒 쉽게 이해하기

some은 식당의 "오늘의 메뉴"와 같다. 셰프(함수)가 오늘 무슨 재료로 요리할지 정하고, 손님(호출자)은 정확히 뭔지는 몰라도 "오늘의 메뉴를 시키면 항상 같은 그릇에 같은 방식으로 나온다"는 건 안다. any는 "아무거나 담는 도시락통"이다 — 매번 다른 요리가 들어올 수 있으니 열어볼 때마다(호출할 때마다) 확인이 필요하다. 비유가 깨지는 곳: 실제로는 "오늘의 메뉴"(some)도 매 호출마다 다시 정해질 수 있다 — 다만 같은 함수 안에서는 항상 같은 하나의 구체 타입으로 고정된다는 게 핵심이지, 매번 무작위로 바뀌는 게 아니라는 뜻이다.

꼬리 질문

some Shape를 반환하는 함수에서 조건문에 따라 Circle도 반환하고 Square도 반환하면 어떤 일이 일어나는가?
컴파일 에러가 난다. SE-0244는 "opaque 타입을 반환하는 함수의 구현은 모든 return문에서 반드시 같은 concrete type T의 값을 반환해야 한다"고 명시한다. some은 "호출자에게 정체를 숨긴 하나의 고정된 타입"이라는 계약이므로, 함수 본문이 조건에 따라 서로 다른 타입을 섞어 반환하면 그 계약 자체가 성립하지 않는다. 여러 타입을 조건별로 돌려줘야 한다면 any Shape로 바꾸거나, 공통 wrapper 타입을 만들어야 한다.
쉽게 말하면 "오늘의 메뉴"라고 해놓고 어떤 손님에겐 파스타를, 어떤 손님에겐 스테이크를 주면 안 된다 — 오늘의 메뉴는 오늘 하루는 하나로 정해져 있어야 한다.
파라미터 위치의 some P를 가진 함수를 왜 변수에 대입해서 값으로 넘길 수 없는가?
func render(_ s: some Shape)는 내부적으로 func render<_T: Shape>(_ s: _T)와 동치인 암묵적 제네릭 함수다. 제네릭 함수를 값으로 참조하려면 그 순간 타입 파라미터 _T가 무엇인지 확정할 문맥이 있어야 하는데, let f = render처럼 호출 없이 참조만 하는 시점에는 어떤 Shape가 들어올지 알 방법이 없다. 그래서 "generic parameter could not be inferred" 에러가 난다 — 명시적으로 <T: Shape>(_ s: T)라고 써도 똑같이 함수 값 참조 시점엔 같은 문제가 있지만, 보통 그 제네릭 함수를 호출하는 코드 안에서 바로 쓰기 때문에 체감이 덜할 뿐이다.
쉽게 말하면 "아무 동물이나 데려오면 밥을 준다"는 규칙만 있고 아직 아무 동물도 안 왔는데, 그 규칙 자체를 다른 사람에게 "이거 받아" 하고 물건처럼 건넬 수는 없는 것과 같다.
언제 제네릭 <T: P> 대신 any P를 선택해야 하는가?
서로 다른 구체 타입을 같은 컬렉션이나 같은 변수에 함께 담아야 할 때가 any의 유일하면서도 명확한 존재 이유다. 예컨대 UI 레이어의 [any Shape]처럼 다형적인 도형들을 한 배열로 순회해야 하는 경우가 그렇다. 반대로 "이 함수/타입은 항상 하나의 구체 타입만 다룬다"면 제네릭이나 some이 항상 더 싸다 — 특수화 가능성(Q5)과 박싱 없음이라는 이득을 공짜로 챙길 수 있기 때문이다. "일단 유연하게 만들자"는 이유만으로 any를 먼저 고르는 것은 대체로 손해다.
쉽게 말하면 한 상자에 사과만 담을 거면 사과 전용 상자(제네릭)를 쓰고, 사과와 오렌지를 같이 담아야 할 때만 아무거나 담는 상자(any)를 꺼내라는 것이다.

Q3. SE-0309 이전에 associated type을 가진 프로토콜을 실존 타입으로 못 쓴 이유는? 무엇이 풀렸나?

🔑 30초 답변

associatedtype이나 Self 요구사항이 있는 프로토콜(PAT, Protocol with Associated Type)은 역사적으로 두 가지 이유로 existential 타입(any P)이 될 수 없었다: (1) associated type 메타데이터를 동적으로 복구하지 못했던 protocol witness table의 불완전한 구현, (2) Self가 파라미터처럼 non-covariant(contravariant) 위치에 나오면 existential 밖에서 그 타입을 명시할 방법이 없었다는 것. SE-0309가 이 제약을 풀어 모든 프로토콜을 existential로 쓸 수 있게 했지만, Self가 non-covariant 위치에 있는 멤버는 여전히 접근할 수 없다.

원리

SE-0309 원문의 두 가지 예제가 이 제약의 핵심을 보여준다. protocol Equatable { static func ==(lhs: Self, rhs: Self) -> Bool }처럼 Self가 파라미터 위치(contravariant)에 있으면, any Equatable 두 값을 비교하려 할 때 "어느 concrete Self가 들어올지" 컴파일러가 보장할 수 없어 여전히 금지된다. 반대로 protocol Copyable { func copy() -> Self }처럼 Self가 반환 위치(covariant)에만 있으면, WWDC22가 "result type erasure"라 부르는 메커니즘으로 자동으로 상위 바운드 타입(예: any Copyable)으로 소거되어 안전하게 허용된다.

내부 동작

Producing 위치(반환값)와 consuming 위치(파라미터)가 정반대로 취급되는 이유는 "누가 타입을 보장하는가"에 있다. 반환값은 함수 내부가 만들어내는 것이므로 컴파일러가 최소한 "이 프로토콜을 준수하는 어떤 타입"이라는 사실만은 보장할 수 있다 — 그래서 상위 바운드로 안전하게 소거된다. 반면 파라미터는 호출자가 채워 넣는 것인데, existential 값 하나만 보고는 그 안에 어떤 concrete Self가 들어있는지 컴파일 타임에 알 수 없으므로, 같은 concrete type이어야 하는 연산(== 등)을 호출부에서 강제할 방법이 없다.

직접 실험해보면 이 비대칭이 그대로 드러난다. associated type을 반환하는 produce() -> Food 메서드는 any Animal을 통해 호출해도 문제없이 컴파일되고 결과 타입만 소거된다. 반면 any Equatable 두 값을 ==로 비교하면 "binary operator '==' cannot be applied to two 'any Equatable' operands"라는 에러가 그대로 재현된다.

producing(허용) vs consuming(금지) 위치 producing 위치 — 반환값(covariant) func produce() -> Food any Animal 로 호출 ✅ any Food로 자동 소거 (허용) result type erasure consuming 위치 — 파라미터(contravariant) static func ==(Self, Self) any Equatable 두 값 비교 ❌ 컴파일 에러 (여전히 금지) 어떤 concrete Self인지 보장 불가 SE-0309는 이 비대칭을 만든 게 아니라, 그동안 프로토콜 전체를 막던 제약만 풀었다
SE-0309 이후에도 Self가 반환 위치(producing)에만 있으면 소거돼 허용되지만, 파라미터 위치(consuming)에 있으면 여전히 컴파일 에러다.

실험 · 도구

producing과 consuming을 각각 직접 재현하면 어느 쪽이 허용되고 어느 쪽이 막히는지 컴파일러 에러 메시지로 바로 확인된다.

producing 위치는 허용, consuming 위치는 여전히 금지
// 1) SE-0309 이후: associated type이 있어도 existential 자체는 가능
protocol Identifiable2 { associatedtype ID2: Hashable; var id2: ID2 { get } }
struct User: Identifiable2 { var id2: Int }
let value: any Identifiable2 = User(id2: 1)   // ✅ 컴파일됨

// 2) producing 위치(반환값)는 자동 소거되어 허용
protocol Copyable2 { func copy2() -> Self }
struct Doc: Copyable2 { func copy2() -> Doc { self } }
func test(_ c: any Copyable2) {
    let x = c.copy2()   // ✅ any Copyable2로 소거되어 컴파일됨
}

// 3) consuming 위치(파라미터)는 여전히 금지
let x: any Equatable = "Paul"
let y: any Equatable = "Alex"
print(x == y)
// ❌ error: binary operator '==' cannot be applied to two 'any Equatable' operands

프로젝트 적용

프로토콜을 설계할 때 나중에 existential(any)로 쓸 계획이라면, Self를 되도록 반환 위치에만 두어라. 파라미터에 Self를 요구하는 멤버가 있으면 그 멤버는 any를 통해서는 영원히 호출할 수 없다.

Self를 파라미터에 두면 그 멤버는 any로 호출 불가능해진다
protocol Mergeable {
    func merged(with other: Self) -> Self   // other: Self ← 파라미터 위치, contravariant
}
struct Score: Mergeable {
    let value: Int
    func merged(with other: Score) -> Score { Score(value: value + other.value) }
}

func combine(_ a: any Mergeable, _ b: any Mergeable) {
    // a.merged(with: b)
    // ❌ b가 정확히 a와 같은 concrete type인지 컴파일러가 보장 못해 호출 불가
}

consuming 위치의 멤버를 꼭 호출해야 한다면, existential을 그대로 쓰지 말고 제네릭 헬퍼로 "언박싱"하라 — 제네릭 컨텍스트 안에서는 두 값이 같은 concrete type이라는 게 타입 시스템에 보장되므로 호출이 열린다.

제네릭 헬퍼로 언박싱하면 consuming 위치 멤버도 호출 가능
func combineTyped<T: Mergeable>(_ a: T, _ b: T) -> T {
    a.merged(with: b)   // ✅ 제네릭 컨텍스트에선 a, b가 같은 T임이 보장된다
}
// 호출부에서 같은 concrete type끼리만 넘기면 컴파일된다
print(combineTyped(Score(value: 1), Score(value: 2)).value)
⚠️ 흔한 오해

"SE-0309 이후로는 associated type/Self 프로토콜을 existential로 써도 아무 제약이 없다"는 틀렸다. non-covariant 위치(파라미터 등)의 멤버는 여전히 접근 불가능하다. 또한 "PAT은 애초에 정의 자체가 안 됐다"도 틀렸다 — protocol Collection<Element>처럼 PAT 자체는 계속 정의할 수 있었고, 막혔던 건 그것을 existential 타입으로 쓰는 것뿐이었다.

🧒 쉽게 이해하기

자판기를 생각해보자. "오늘 뭐가 나올지 몰라도 나온 걸 마시면 된다"(반환/producing)는 안전하다 — 뭐가 나오든 마실 수만 있으면 되니까. 그런데 "정확히 이 음료여야만 들어가는 투입구"(파라미터/consuming)는 다르다 — 아무 음료나 넣었다가 자판기가 기대하던 것과 다르면 고장 난다. any로 감싼 값은 "이 안에 정확히 뭐가 들었는지" 확신할 수 없으니, 투입구 쪽 검사를 통과시켜줄 수가 없는 것이다. 비유가 깨지는 곳: 실제로는 "고장"이 아니라 컴파일 자체가 안 되는 것이다 — 런타임에 잘못된 걸 넣어서 터지는 게 아니라,애초에 그런 코드를 쓸 수조차 없게 막아둔 것이다.

꼬리 질문

SE-0309 이전에 associated type이 있는 프로토콜을 existential로 쓸 수 없었던 근본 이유 두 가지는 각각 무엇인가?
첫째는 구현상의 한계였다 — protocol witness table의 불완전한 구현이 남긴 잔재로, associated type의 메타데이터를 런타임에 동적으로 복구할 방법이 없었다. 둘째는 타입 시스템상의 근본 제약으로, Self나 그에 기반한 associated type이 non-covariant(파라미터 등) 위치에 등장하면 existential 밖에서 그 타입을 명시할 방법이 없어 프로토콜 전체의 existential 사용 자체가 막혔다. SE-0309는 후자의 제약을 "일부 멤버만 접근 제한"으로 완화해 첫 번째 구현 한계와 함께 풀었다.
쉽게 말하면 예전에는 방 하나(프로토콜)에 위험한 물건(파라미터 위치 Self) 하나가 있으면 방 전체를 아예 못 들어가게 잠갔는데, 지금은 그 위험한 물건이 있는 서랍만 잠그고 나머지 방은 들어갈 수 있게 바뀐 것이다.
"result type erasure"란 정확히 무엇이며 producing과 consuming에서 왜 다르게 취급되는가?
WWDC22 "Design protocol interfaces in Swift"가 붙인 이름으로, existential 타입에서 associated type을 반환하는 메서드를 호출하면 컴파일러가 그 결과 타입을 자동으로 상위 바운드(예: associated type의 제약 프로토콜)로 소거해 표현하는 메커니즘이다. Producing(반환) 위치는 함수가 값을 만들어 "내보내는" 방향이라 "이 프로토콜을 준수하는 어떤 타입"이라는 사실만으로 안전이 보장되지만, consuming(파라미터) 위치는 호출자가 값을 "넣는" 방향이라 그 값이 원래 감춰진 concrete type과 정확히 일치하는지 보장할 방법이 없어 다르게 취급된다.
쉽게 말하면 상자에서 뭔가를 꺼내 줄 때는 "이 상자에서 나온 것"이라고만 말해도 되지만, 상자에 뭔가를 넣어달라고 할 때는 "정확히 이 모양이어야 들어간다"는 게 안 맞으면 곤란한 것과 같다.
any Equatable 두 값을 비교할 수 없는 이유를 covariance/contravariance 관점에서 다시 설명하면?
Equatable==static func ==(lhs: Self, rhs: Self) -> Bool로, Self가 파라미터(contravariant 위치)에 두 번 등장한다. any Equatable 값 두 개는 각각 내부에 서로 다른 concrete type(예: StringInt)을 담고 있을 수 있는데, ==는 애초에 "같은 concrete type끼리만" 정의된 연산이다. existential 값만 보고는 그 안의 concrete type이 서로 같은지 컴파일 타임에 확인할 방법이 없으므로, 컴파일러는 이 호출 자체를 원천적으로 막는다.
쉽게 말하면 서로 다른 나라 화폐 두 장을 놓고 "이게 똑같은 돈이야?"라고 묻는 것과 비슷하다 — 애초에 같은 화폐끼리 비교해야 의미가 있는데, 상자 안을 열어보기 전에는 뭐가 들었는지 모르니 비교 자체를 허락할 수 없다.

Q4. primary associated type(SE-0346)은 무엇을 가능하게 했으며 어떤 where 절로 풀리는가?

🔑 30초 답변

SE-0346은 프로토콜이 protocol Sequence<Element>처럼 하나 이상의 "primary associated type"을 선언하면, any Collection<Int>·some Sequence<String>처럼 제네릭 타입 인스턴스화와 비슷한 꺾쇠 문법으로 associated type을 제약할 수 있게 한다. 이건 순수한 문법 설탕이다 — Collection<Int>는 정확히 Collection where Element == Int로 풀리며, 로컬에서 두 형태의 SIL을 직접 비교해도 완전히 동일한 제네릭 시그니처가 나온다. 런타임 표현이나 existential 컨테이너 레이아웃(Q1)은 전혀 바뀌지 않는다.

원리

SE-0346 원문: "Protocols can declare one or more primary associated types using a syntax similar to a generic parameter list of a concrete type." 그리고 개수 제약도 명시한다: "Specifying fewer or more type arguments than the number of primary associated types is an error." 오직 프로토콜이 primary로 선언한 associated type만 꺾쇠 문법의 대상이 된다 — 나머지 associated type은 여전히 where 절로만 제약할 수 있다. SE-0358이 표준 라이브러리 핵심 프로토콜들(Sequence<Element>, Collection<Element>, Identifiable<ID> 등)에 이걸 실제로 부여했다.

내부 동작

이게 정말 순수 문법 설탕인지는 SIL로 바로 검증된다. func sumWhereClause<S: Sequence>(_ seq: S) -> Int where S.Element == Intfunc sumPrimaryAT(_ seq: some Sequence<Int>) -> Int를 각각 컴파일해 -emit-sil을 비교하면, 두 함수의 제네릭 시그니처가 <τ_0_0 where τ_0_0 : Sequence, τ_0_0.Element == Int>글자 하나까지 동일하게 나온다. 확장(extension)에도 같은 규칙이 적용된다: extension Collection<String>extension Collection where Element == String과 동치이며 실제로 컴파일해도 똑같이 동작한다.

단, SE-0358은 모든 프로토콜에 primary associated type을 붙이지는 않았다 — LazySequenceProtocol, LazyCollectionProtocol, OptionSet, AsyncSequence 등은 향후 호환성 문제를 우려해 의도적으로 제외됐다.

이전(where 절)이후(primary associated type)
func f<S: Sequence>(_ s: S) where S.Element == Intfunc f(_ s: some Sequence<Int>)
extension Collection where Element == Stringextension Collection<String>
any Collection (Element 접근 불가)any Collection<Int> (Element == Int 제약)

실험 · 도구

동치성은 코드 두 줄만 작성해도 바로 눈으로 확인된다.

where 절과 primary associated type의 SIL 동일성 확인
func sumWhereClause<S: Sequence>(_ seq: S) -> Int where S.Element == Int {
    seq.reduce(0, +)
}
func sumPrimaryAT(_ seq: some Sequence<Int>) -> Int {
    seq.reduce(0, +)
}
// swiftc -emit-sil 실측 (Swift 6.2.1) — 제약 구조는 같지만 텍스트가 똑같진 않다.
//   where 절 버전 : <S     where S     : Sequence, S.Element     == Int>
//   some 버전     : <τ_0_0 where τ_0_0 : Sequence, τ_0_0.Element == Int>
// 명시적 제네릭 파라미터는 소스에 쓴 이름(S)을 그대로 쓰고, some 파라미터는
// 컴파일러가 붙인 익명 이름(τ_0_0)을 쓴다. 이름만 다르고 제약은 동일하므로
// SE-0346은 순수 문법 설탕이다 — SIL·ABI 어느 쪽도 바뀌지 않는다.

// any Collection<Int>: 존재하면서도 Element 타입 제약을 그대로 유지
func printAllPrimary(_ c: any Collection<Int>) {
    for x in c { print(x) }
}
printAllPrimary([10, 20, 30])

// 개수를 잘못 지정하면 컴파일 타임에 바로 걸린다
protocol MySeq<Element> { associatedtype Element; associatedtype Index }
// func f(_ s: any MySeq<Int, String>) {}
// ❌ error: protocol type 'MySeq' specialized with too many type arguments (got 2, but expected 1)

프로젝트 적용

팀 컨벤션이 허용한다면 새 API 시그니처는 짧은 꺾쇠 문법으로 통일하라 — 특히 any Collection<Element>는 existential이면서도 타입 안전성(Element 제약)을 동시에 얻는 유일한 조합이라 실무에서 자주 쓸 만하다.

any + primary associated type으로 이종 저장과 타입 안전을 동시에
// Int를 원소로 하는 아무 Collection이나 받되, Element 타입은 고정하고 싶을 때
func sumAny(_ c: any Collection<Int>) -> Int {
    c.reduce(0, +)
}
print(sumAny([1, 2, 3]))
print(sumAny(1...3))

기존 코드베이스에 where 절로 작성된 시그니처가 이미 많다면 굳이 다 바꿀 필요는 없다 — 순수 문법 설탕이라 동작이 달라지지 않으므로, 점진적으로 새 코드에서만 짧은 문법을 쓰는 것으로 충분하다.

기존 where 절 코드는 그대로 둬도 무방하다
// 이미 있는 코드 — 굳이 마이그레이션할 필요 없음
func oldStyle<C: Collection>(_ c: C) -> Int where C.Element == Int {
    c.reduce(0, +)
}
// 새로 쓸 코드만 짧은 문법으로
func newStyle(_ c: some Collection<Int>) -> Int {
    c.reduce(0, +)
}
⚠️ 흔한 오해

"이 문법이 성능을 개선한다"는 틀렸다 — 순수 문법적 편의이며 SIL·ABI·existential 레이아웃 어느 것도 바뀌지 않는다는 게 로컬 실험으로 확인된다. "Collection<Int>처럼 쓰면 모든 associated type을 다 이렇게 파라미터화할 수 있다"도 틀렸다 — 오직 프로토콜이 primary로 선언한 associated type만 꺾쇠 문법 대상이다.

🧒 쉽게 이해하기

택배 상자 겉면에 "내용물: 사과"라고 짧게 라벨을 붙이는 것과, 상자 안 서류에 "이 상자는 반드시 사과만 담아야 함"이라고 길게 써두는 것은 결과가 똑같다 — 둘 다 "사과만 들어있다"는 같은 약속이다. 다만 겉면 라벨이 훨씬 읽기 편하다. 비유가 깨지는 곳: 이 라벨은 아무 상자에나 붙일 수 있는 게 아니다. 상자 제조사(프로토콜 설계자)가 "이 항목은 겉면 라벨링이 가능하다"(primary로 선언)고 미리 정해둔 것만 붙일 수 있다.

꼬리 질문

Primary associated type이 where 절과 비교했을 때 표현력 차이가 있는가?
없다. 로컬에서 직접 두 형태를 컴파일해 -emit-sil로 제네릭 시그니처를 비교하면 완전히 동일하게 나온다 — 타입 시스템에 새 기능을 추가한 게 아니라 기존 where 절을 더 짧게 쓰는 표기법일 뿐이다. 유일한 실질적 차이는 표현 가능 범위다 — 프로토콜이 primary로 선언하지 않은 associated type은 이 짧은 문법으로 제약할 수 없고 반드시 where 절을 써야 한다.
쉽게 말하면 같은 말을 줄여 쓴 것뿐이지, 줄여 쓸 수 있는 말이 정해져 있다는 것만 다르다.
표준 라이브러리에서 AsyncSequence가 primary associated type을 부여받지 못한 이유를 추측한다면?
SE-0358은 AsyncSequence를 의도적으로 제외했다. 확정적으로 밝혀진 이유는 원문에 명시돼 있지 않지만, 문서가 "향후 호환성 문제"를 우려한다고 밝힌 맥락에서 미루어 보면, AsyncSequence는 이후 Failure associated type 추가처럼 프로토콜 자체가 계속 진화 중이었고, primary associated type은 한 번 붙이면 "제거하거나 순서를 바꾸는 것이 소스 호환성을 깬다"는 제약(SE-0346 명시)이 있어 섣불리 확정 짓기 부담스러웠을 가능성이 높다.
쉽게 말하면 아직 인테리어 공사가 안 끝난 방에는 문패를 미리 못 박아 걸지 않는 것과 비슷하다 — 나중에 방 용도가 바뀌면 문패도 다시 떼야 하니까.
확장(extension)에서 Collection<String>처럼 쓰는 문법은 실제로 어떻게 풀리는가?
extension Collection<String> { ... }extension Collection where Element == String { ... }와 완전히 동치다. 로컬에서 이 확장을 실제로 작성해 배열에 메서드를 추가해보면 where 절로 쓴 것과 동일하게 동작한다 — 컴파일러가 꺾쇠 문법을 보는 즉시 내부적으로 where 절 형태로 변환해서 처리하기 때문에, 이 둘 사이에 별도의 컴파일 경로 차이가 없다.
쉽게 말하면 "사과 상자 전용 스티커"를 붙이나 "이 상자는 사과 전용입니다"라고 써 붙이나, 창고 관리 시스템 입장에서는 완전히 같은 규칙으로 처리되는 것과 같다.

Q5. 제네릭 특수화는 언제 일어나는가? @inlinable은 왜 모듈 경계에서만 의미가 있는가?

🔑 30초 답변

제네릭 함수는 기본적으로 witness table을 파라미터로 받는 "제네릭 그대로"의 코드로 컴파일된다. 컴파일러가 함수 본문과 호출부의 구체 타입을 동시에 볼 수 있을 때만 그 타입 전용 코드를 새로 만드는 특수화(specialization)가 일어난다. 같은 모듈 안에서는 Whole Module Optimization(WMO)이 기본적으로 이 조건을 만족시키지만, 다른 모듈에서 호출하는 경우엔 원칙적으로 본문을 볼 수 없다 — 함수에 @inlinable을 붙여 본문 자체를 모듈 인터페이스에 노출시켜야만 크로스모듈 특수화가 가능해진다. 그래서 @inlinable은 같은 모듈 안에서는 사실상 의미가 없고, 모듈 경계를 넘을 때만 값이 있다.

원리

swiftlang/swift의 OptimizationTips.rst: "The Swift compiler will emit a version of the generic function specialized to the specific type." 그리고 WMO에 대해서는: "causes the compiler to compile a module's sources all together at once. This allows the optimizer to have module wide visibility." — 즉 WMO가 주는 건 정확히 "모듈 전체를 한눈에 보는 시야"이고, 특수화는 그 시야 안에서 컴파일러가 구체 타입을 실제로 확인했을 때 일어나는 최적화다.

내부 동작

단일 파일을 -O로 컴파일해 -emit-sil을 뽑아보면, 제네릭 함수 genericSum<T: Numeric>와 별도로 ...genericSumyxSayxGSjRzlFSi_Tg5 같은 심볼이 새로 생긴다. 맹글링에 포함된 Tg5가 "generic specialization"을 뜻하는 표식이고, 이 심볼의 시그니처는 제네릭 파라미터 없이 (Array<Int>) -> Int로 완전히 구체화돼 있다 — witness table을 넘기지 않는 정적 코드다. -wmo를 켜면 실제로 쓰이지 않는 제네릭 원본은 사라지고 특수화된 버전만 남는다.

모듈 경계를 넘는 경우는 실제로 두 개의 별도 모듈(LibA, Main)을 만들어 확인할 수 있다. @inlinable 없이 정의한 genericSumPlain<T: Numeric>을 다른 모듈에서 호출하면, Main 모듈의 SIL에 여전히 function_ref genericSumPlain<A>(_:)라는 제네릭 그대로의 호출이 남는다 — 컴파일러가 LibA의 함수 본문을 볼 수 없어 특수화를 못 하는 것이다. 반면 @inlinable을 붙인 genericSumInlinable은 Main 모듈 SIL에 함수 호출 흔적조차 남지 않는다 — 본문이 통째로 인라인/특수화되어 호출 지점에 그대로 풀려버린 것이다.

제네릭 특수화 파이프라인 제네릭 SIL — <T: Numeric>, witness table을 파라미터로 받음 컴파일러가 호출부의 구체 타입을 볼 수 있는가? 예 — 같은 모듈 WMO, 또는 다른 모듈 + @inlinable 특수화된 SIL — Int 전용 witness table 파라미터 사라짐, 완전 정적(Tg5) 아니오 — 다른 모듈, @inlinable 없음 제네릭 SIL 그대로 function_ref로 witness table 계속 전달
컴파일러가 구체 타입을 볼 수 있을 때만(WMO 또는 @inlinable) 제네릭 SIL이 특수화되고, 그렇지 않으면 witness table을 넘기는 제네릭 코드 그대로 남는다.

실험 · 도구

같은 모듈 특수화는 파일 하나로, 크로스모듈 차이는 두 모듈을 실제로 빌드해서 비교하면 확실하다.

같은 모듈 -O에서 특수화 심볼 확인
@inline(never)
func genericSum<T: Numeric>(_ xs: [T]) -> T {
    var total: T = .zero
    for x in xs { total += x }
    return total
}
let ints: [Int] = [1,2,3,4,5]
print(genericSum(ints))

// swiftc -O -emit-sil 로 뽑으면 원본 제네릭 함수 외에
// "...genericSumyxSayxGSjRzlFSi_Tg5" 같은 심볼이 추가로 생긴다.
// Tg5는 "generic specialization" 표식 — 시그니처가
// (Array<Int>) -> Int 로 제네릭 파라미터 없이 완전히 구체화돼 있다.
// -wmo를 추가하면 쓰이지 않는 제네릭 원본은 사라지고 특수화 버전만 남는다.
크로스모듈: @inlinable 유무에 따른 특수화 차이
// LibA.swift (별도 모듈로 컴파일)
public func genericSumPlain<T: Numeric>(_ xs: [T]) -> T {
    var total: T = .zero
    for x in xs { total += x }
    return total
}
@inlinable
public func genericSumInlinable<T: Numeric>(_ xs: [T]) -> T {
    var total: T = .zero
    for x in xs { total += x }
    return total
}

// Main.swift (LibA를 import, -O -wmo로 컴파일)
import LibA
print(genericSumPlain([1,2,3]))       // SIL에 여전히 function_ref genericSumPlain<A>(_:) 로 남음
print(genericSumInlinable([1,2,3]))   // SIL에 호출 흔적 자체가 사라짐 — 완전히 인라인/특수화됨

프로젝트 적용

사내 SPM 패키지처럼 여러 모듈로 나뉜 프로젝트에서 핫 패스에 있는 작은 제네릭 유틸리티 함수는 @inlinable을 고려할 만하다. 다만 이건 함수 구현을 모듈 인터페이스에 그대로 노출하는 트레이드오프다 — 캡슐화를 일부 포기하는 대가로 크로스모듈 특수화를 얻는 것이다.

핫 패스 유틸리티에 @inlinable — 캡슐화와 성능의 트레이드오프
// 다른 모듈에서도 자주 호출되는 작은 제네릭 함수라면
@inlinable
public func clamp<T: Comparable>(_ value: T, in range: ClosedRange<T>) -> T {
    min(max(value, range.lowerBound), range.upperBound)
}
// ⚠️ @inlinable 함수 안에서는 그 모듈의 internal 심볼을 쓸 수 없다
//    (그러면 그 내부 심볼까지 외부에 노출해야 하기 때문) — @usableFromInline 필요

반대로 프로파일링 없이 라이브러리 공개 API 전체에 @inlinable을 습관적으로 붙이지 마라 — ABI 안정성과 캡슐화를 넓게 포기하는 대가에 비해, 같은 모듈 안에서는 WMO가 이미 특수화를 해주므로 실익이 없는 경우가 대부분이다.

같은 모듈 안에서는 @inlinable이 대체로 무의미하다
// ❌ 같은 모듈 안에서만 쓰이는 함수에 습관적으로 @inlinable — 실익 없이 캡슐화만 넓힘
@inlinable
func internalHelper<T>(_ x: T) -> T { x }

// ✅ 같은 모듈이면 그냥 두어라 — WMO가 필요하면 알아서 특수화한다
func internalHelperPlain<T>(_ x: T) -> T { x }
⚠️ 흔한 오해

"제네릭은 항상 witness table을 거치므로 항상 느리다"는 틀렸다 — 컴파일러가 구체 타입을 볼 수 있는 상황(WMO, @inlinable)에서는 특수화되어 비제네릭 코드와 사실상 동일한 성능을 낸다. "@inlinable을 붙이면 같은 모듈 안에서도 성능이 좋아진다"도 대체로 틀렸다 — 같은 모듈이면 WMO가 이미 인라이닝·특수화를 하므로, @inlinable의 실질 효과는 크로스모듈 상황에서만 나타난다.

🧒 쉽게 이해하기

제네릭 함수는 "아무 재료나 넣을 수 있는 레시피"다. 같은 부엌(모듈) 안에서 요리사가 레시피와 오늘 산 재료(구체 타입)를 동시에 보면, 그 재료 전용으로 레시피를 다시 써서(특수화) 더 빠르게 요리한다. 다른 부엌(다른 모듈)에서 온 레시피는 원래 재료가 뭔지 매번 확인하며 요리해야 느린데, 레시피 원문 자체를 공개(@inlinable)해두면 다른 부엌도 그걸 보고 전용 레시피를 만들 수 있다. 비유가 깨지는 곳: 레시피를 공개하는 건 공짜가 아니다 — 원래 감춰뒀을 조리 비법(내부 구현)까지 남에게 보여주는 대가를 치른다.

꼬리 질문

조건부 준수(Array: Equatable where Element: Equatable)가 있는 상태에서 동적 캐스팅(as? P)을 하면 왜 런타임 비용이 추가되는가?
조건부 준수는 컴파일 타임에 결정되는 게 아니라, "이 타입 파라미터가 조건을 만족하는가"를 런타임에 조회해야 하는 경우가 있다. as? P 같은 동적 캐스팅이 조건부 준수를 만나면, Swift 런타임은 swift_conformsToProtocol 계열의 조회를 추가로 수행해 그 특정 타입 인자가 정말 조건을 만족하는지 확인한다. 이건 정적 특수화로 미리 해결되는 문제가 아니라, "지금 이 값의 실제 타입이 조건을 만족하는가"를 그 순간에 물어야 하는 근본적으로 동적인 질문이기 때문이다.
쉽게 말하면 "이 학생이 우등생 명단에 있나?"를 미리 다 외워두는 게 아니라 그때그때 명단을 펼쳐서 확인하는 것과 같다.
@_specialize@inlinable과 무엇이 다른가?
@_specialize는 언더스코어가 붙은 실험적 속성으로, 특정 구체 타입 조합에 대해 컴파일러가 반드시 특수화 버전을 만들어 두도록 명시적으로 강제한다. 반면 @inlinable은 "본문을 모듈 인터페이스에 노출한다"는 것일 뿐, 실제로 크로스모듈 특수화를 할지 말지는 여전히 호출부 컴파일러의 최적화 판단에 달려 있다. @_specialize는 프로파일링으로 확인된 핫 패스에서, 원하는 타입 조합의 특수화를 확정적으로 보장하고 싶을 때 쓰는 더 공격적인 도구다.
쉽게 말하면 @inlinable은 "레시피를 공개해뒀으니 필요하면 가져다 써도 된다"이고, @_specialize는 "이 재료 조합의 전용 레시피는 무조건 따로 만들어 두라"는 강한 지시다.
WMO를 켜면 항상 이득만 있는가, 트레이드오프는 없는가?
WMO는 모듈 전체를 한 번에 최적화 단위로 묶어 인라이닝·특수화 범위를 넓히지만, 그 대가로 증분 빌드가 어려워진다 — 파일 하나만 바뀌어도 모듈 전체를 다시 컴파일해야 하므로 개발 중 반복 빌드 시간이 늘어날 수 있다. 그래서 실무에서는 보통 릴리스 빌드에는 WMO를 켜고, 디버그/개발 빌드에는 끄는 식으로 나눠 쓴다(Xcode의 릴리스/디버그 빌드 설정 차이가 대체로 이렇게 돼 있다).
쉽게 말하면 책 전체를 한 번에 훑어보며 교정하면 더 꼼꼼하게 고칠 수 있지만, 한 페이지만 고칠 때도 매번 책 전체를 다시 훑어야 해서 시간이 오래 걸리는 것과 같다.

Q6. 조건부 준수(SE-0143)와 @retroactive(SE-0364)는 각각 무슨 문제를 다루는가?

🔑 30초 답변

둘은 완전히 다른 문제를 다룬다. SE-0143(조건부 준수)은 Array<Element>Element: Equatable일 때만 Equatable을 준수하게 하는 타입 시스템 표현력 문제를 푼다(전엔 [[Int]] == [[Int]]조차 컴파일이 안 됐다). SE-0364(@retroactive)는 전혀 다른 층위의 문제다 — 외부 타입에 외부 프로토콜 준수를 추가할 때 생기는 전역 유일성 충돌 위험을 경고하고 문서화하도록 강제한다. 중요한 건, @retroactive경고를 잠재우고 위험을 문서화할 뿐 — 두 모듈이 실제로 같은 conformance를 각자 선언해 충돌하는 상황 자체는 전혀 막지 못한다. 그 상황이면 @retroactive를 붙였든 안 붙였든 여전히 undefined behavior다.

원리

SE-0143의 동기: "arrays of arrays of Equatable types cannot be compared (e.g., [[Int]] == [[Int]] will fail to compile) because... the arrays themselves are never Equatable." 이를 extension Array: Equatable where Element: Equatable { ... }로 해결한다. SE-0364의 동기는 근본적으로 다르다: "protocol conformances are globally unique within a process in the Swift runtime, and if multiple modules declare the same conformance, it can cause major problems for library clients." 만약 Foundation 같은 프레임워크가 나중에 동일한 conformance를 추가하면 "their application will exhibit undefined behavior, as it is indeterminate which definition of this conformance will 'win.'"

내부 동작

조건부 준수는 컴파일 타임 타입 체크로 끝나지 않는다 — 동적 캐스팅(as? P)이 조건부 준수를 만나면 "그 조건이 실제로 만족되는가"를 런타임에 한 번 더 조회해야 한다(Q5 꼬리 질문 참고). 그리고 겹치는(overlapping) 조건부 준수는 서로소로 보여도 전면 금지된다 — SE-0143 원문: "the ban extends even to multiple conformances that are 'clearly' disjoint."

@retroactive는 순전히 컴파일 타임 마커다. Swift 6 모드에서 외부 타입(예: Date)에 외부 프로토콜(예: Identifiable)을 준수시키면 컴파일러가 경고를 낸다 — 실제로 재현해보면 "extension declares a conformance of imported type 'Date' to imported protocol 'Identifiable'; this will not behave correctly if the owners of 'Foundation' introduce this conformance in the future"라는 경고와 함께 "add '@retroactive' to silence this warning"이라는 노트가 뜬다. @retroactive를 붙이면 이 경고만 사라진다 — 반면 같은 모듈에 정의한 프로토콜에 외부 타입을 준수시키는 경우는 애초에 경고 자체가 뜨지 않는다(안전하기 때문). 어느 경우든 @retroactive가 런타임에 conformance 등록 방식이나 충돌 감지 로직을 바꾸는 건 전혀 없다.

SE-0143 조건부 준수SE-0364 @retroactive
다루는 문제제네릭 타입이 타입 파라미터 조건에 따라서만 프로토콜을 준수하게 하기외부 타입 × 외부 프로토콜 conformance의 전역 유일성 충돌 위험 경고
해결하는 것컴파일 타임 타입 시스템 표현력([[Int]] == [[Int]] 같은 것을 가능하게 함)컴파일러 경고 억제 + 위험 의도의 명시적 문서화
해결하지 못하는 것겹치는 조건부 준수는 여전히 전면 금지(서로소로 보여도)두 모듈이 실제로 같은 conformance를 각자 선언하는 충돌 자체

실험 · 도구

두 제안 모두 로컬에서 직접 재현하면 각자가 정확히 무엇을 바꾸는지 컴파일러 메시지로 확인된다.

조건부 준수 — 조건을 만족할 때만 컴파일된다
struct Wrapper<T> { let v: T }
extension Wrapper: Equatable where T: Equatable {
    static func == (l: Wrapper, r: Wrapper) -> Bool { l.v == r.v }
}
let w1 = Wrapper(v: 1)
print(w1 == Wrapper(v: 1))   // ✅ Int: Equatable 이므로 성립

struct NotEquatable {}
let w2 = Wrapper(v: NotEquatable())
// print(w2 == w2)
// ❌ error: operator function '==' requires that 'NotEquatable' conform to 'Equatable'
//    note: requirement from conditional conformance of 'Wrapper<NotEquatable>' to 'Equatable'
@retroactive — 외부×외부 conformance에서만 경고, 붙이면 억제될 뿐
import Foundation
protocol MyIdentifiable { var myID: String { get } }

// 외부 타입(Date) × 내 모듈 프로토콜 → 경고 없음(안전)
extension Date: MyIdentifiable {
    var myID: String { "\(timeIntervalSince1970)" }
}

// 외부 타입(Date) × 외부 프로토콜(Identifiable) → 경고 발생
extension Date: Identifiable {
    public var id: TimeInterval { timeIntervalSince1970 }
}
// ⚠️ warning: extension declares a conformance of imported type 'Date'
//    to imported protocol 'Identifiable'; this will not behave correctly
//    if the owners of 'Foundation' introduce this conformance in the future
//    note: add '@retroactive' to silence this warning

// @retroactive를 붙이면 경고만 사라진다 — 실제 충돌 위험 자체는 그대로
extension Date: @retroactive Identifiable {
    public var id: TimeInterval { timeIntervalSince1970 }
}

프로젝트 적용

조건부 준수를 쓸 때는 겹치는 conformance를 설계 단계에서부터 피하라 — 컴파일러가 "서로소니까 괜찮겠지"라고 봐주지 않는다.

겹치는 조건부 준수는 서로소로 보여도 컴파일러가 거부한다
protocol Flyable {}
protocol Swimmable {}
struct Animal<T> { let trait: T }

extension Animal: Flyable where T: Flyable {}
// extension Animal: Flyable where T: Swimmable {}
// ❌ 같은 (Animal, Flyable) 쌍에 대한 두 번째 조건부 준수 — 조건이 겹치지 않아 보여도 금지된다

외부 타입에 외부 프로토콜을 준수시켜야 한다면, @retroactive를 붙이기 전에 "그 프레임워크가 나중에 같은 conformance를 추가할 가능성이 정말 없는지" 먼저 스스로 확인하라 — 컴파일러는 그 확인을 대신 해주지 않는다.

@retroactive는 최종 안전 확인이 아니라 의도 문서화일 뿐
import Foundation

// ✅ 애플이 향후 Identifiable 준수를 Date에 직접 추가할 가능성이 낮다고 판단했다면
extension Date: @retroactive Identifiable {
    public var id: TimeInterval { timeIntervalSince1970 }
}
// ⚠️ 하지만 이건 "내가 위험을 감수하겠다"는 선언일 뿐,
//    다른 패키지가 같은 conformance를 이미 선언해뒀는지까지 검증해주진 않는다.
//    실제로 다른 모듈이 같은 (Date, Identifiable) conformance를 선언하고 있다면
//    @retroactive 여부와 무관하게 undefined behavior다.
⚠️ 흔한 오해

"@retroactive를 붙이면 충돌 문제 자체가 해결된다"는 틀렸다. @retroactive는 컴파일러 경고를 억제하고 "이 conformance는 의도적으로 추가했다"는 사실을 문서화할 뿐, 두 모듈이 실제로 같은 conformance를 각자 선언하는 충돌 자체를 막거나 감지하지 않는다. 그런 충돌이 실제로 일어나면 undefined behavior는 @retroactive 유무와 무관하게 그대로 발생한다.

🧒 쉽게 이해하기

조건부 준수는 "우유가 들어간 아이스크림만 유제품 코너에 진열한다"는 규칙과 같다 — 조건(우유 포함)을 만족할 때만 그 자격(진열)을 준다. @retroactive는 전혀 다른 이야기다 — 남의 가게(외부 타입) 물건에 내가 마음대로 "이건 이렇게 분류합니다"라는 스티커를 붙이면서 "제가 임의로 붙였어요, 나중에 문제 생길 수 있어요"라고 적어두는 것이다. 비유가 깨지는 곳: 스티커에 경고 문구를 적었다고 해서, 원래 가게 주인이 나중에 진짜로 다른 분류 스티커를 붙일 위험 자체가 사라지는 건 아니다 — 그냥 "내가 알고 붙였다"는 기록만 남을 뿐이다.

꼬리 질문

겹치는 조건부 준수가 "서로소로 보여도" 왜 전면 금지되는가?
SE-0143은 컴파일러가 "이 두 조건이 정말 동시에 만족될 수 없다"는 것을 일반적으로 증명할 방법이 없다는 전제를 깔고 있다. 예컨대 T: FlyableT: Swimmable이 지금 코드베이스에서는 겹치는 타입이 없어 보여도, 나중에 어떤 타입이 두 프로토콜을 동시에 준수하게 되는 순간 "어느 conformance가 진짜인지"가 모호해진다. 컴파일러는 이런 미래의 가능성까지 정적으로 배제할 수 없으므로, 안전을 위해 "지금 서로소로 보이는 것"까지 포함해 원천적으로 금지한다.
쉽게 말하면 지금 당장은 겹치는 사람이 없어 보여도, "이름이 같은 사람은 절대 없다"고 미리 장담할 수 없으니 아예 그런 규칙 자체를 허용하지 않는 것과 같다.
내 모듈에서 정의한 프로토콜에 외부 타입을 준수시키는 것도 @retroactive 경고 대상인가?
아니다. 로컬 실험에서 Date(외부 타입)를 내 모듈의 MyIdentifiable(내 프로토콜)에 준수시켰을 때는 경고가 전혀 뜨지 않았다. 경고는 외부 타입 × 외부 프로토콜 조합에서만 발생한다 — 그 conformance의 "소유권"이 어느 쪽에도 완전히 내 모듈에 있지 않을 때만 다른 모듈이 같은 conformance를 나중에 추가할 위험이 생기기 때문이다. 프로토콜이 내 모듈 소유라면, 그 프로토콜에 대한 conformance는 내가 전적으로 통제하므로 충돌 위험이 없다.
쉽게 말하면 내가 만든 분류표에 남의 물건을 등록하는 건 문제없다 — 그 분류표를 나만 관리하니까. 위험한 건 남이 만든 분류표에 남의 물건을 내 마음대로 등록할 때다.
SE-0364의 리뷰 과정에서 무엇이 논쟁의 핵심이었는가?
1차 리뷰(2022-07) 이후 "Returned for Revision"으로 반려되어 무음 처리(silencing) 메커니즘 자체를 재검토했고, 2차 리뷰(2023년)에서는 @retroactive라는 스펠링/키워드 선택에 논의가 집중됐다. 이후 2024년에는 "같은 패키지 내 conformance는 허용"하는 어멘드먼트 리뷰도 추가로 진행됐다 — 한 패키지 안에서 여러 타겟이 서로의 타입/프로토콜을 참조하는 경우까지 매번 @retroactive를 강제하는 게 지나치다는 문제의식이었다.
쉽게 말하면 "경고를 어떻게 끌지"와 "경고를 끌 때 뭐라고 표시할지"를 두고 몇 년에 걸쳐 다듬은 것이다.

출처 · 참고자료