← Study Hub

05 · Swift 런타임과 언어 구현

값/참조 타입, COW, 클로저와 ARC, Optional의 비트 표현, 제네릭·existential·opaque 타입을 메모리와 런타임 구현 관점에서 파헤친다.
진행률
0 / 0 완료

← 챕터 목록

이 챕터는 Swift 언어를 메모리와 런타임 관점에서 다룬다. 값/참조 타입, COW, 클로저 캡처와 ARC, Optional의 비트 표현, 그리고 제네릭·existential·opaque 타입이 컴파일러와 런타임에서 각각 어떻게 구현되는지를 면접에서 설명할 수 있는 수준으로 파고든다.

Q1. Struct와 Class는 의미와 메모리 관리에서 어떻게 다른가?

🔑 30초 답변

struct는 값 타입이라 대입·전달 시 값 자체가 복사되어 서로 독립적이고, class는 참조 타입이라 포인터(참조)만 복사되어 하나의 인스턴스를 공유합니다. class 인스턴스는 항상 힙에 살고 ARC(참조 카운팅)로 수명이 관리되며 isa·refcount 헤더를 갖습니다. struct는 선언된 자리에 인라인으로 놓여 그 자체는 ARC로 수명 관리되지 않고 retain cycle도 없지만(단 String·Array·클래스 프로퍼티 등 참조 필드를 품으면 값 복사·소멸 시 그 필드에 대한 retain/release는 여전히 발생), 클로저 캡처나 클래스 프로퍼티로 들어가면 힙에 올라갈 수 있습니다. class만 상속·deinit·식별자(===)를 가집니다. 그래서 기본은 struct, 공유 가변 상태·정체성·상속이 필요할 때 class를 씁니다.

CS 원리

값 타입은 변수 안에 값 그 자체가 들어 있다. 대입하면 값이 통째로 복사되고, 이후 한쪽을 바꿔도 다른 쪽은 그대로다(독립성). 참조 타입은 변수 안에 힙에 있는 객체를 가리키는 주소가 들어 있다. 대입하면 주소만 복사되므로 두 변수가 같은 객체를 공유하고, 한쪽에서 바꾼 변화가 다른 쪽에도 보인다.

참조 타입은 여러 곳에서 같은 객체를 가리키므로 "언제 해제할지"를 판단할 장치가 필요하다. Swift는 그 객체를 가리키는 강한 참조의 수를 세는 참조 카운팅을 쓴다. 카운트가 0이 되면 해제한다. 값 타입은 소유자가 명확(스코프가 끝나면 사라짐)해서 이런 카운팅이 필요 없다.

iOS에서는

class 인스턴스는 ARC가 관리한다. 64비트에서 객체 앞에는 최소 16바이트 헤더가 붙는데, metadata(isa) 포인터refcount 필드다. Swift 런타임의 swift_allocObject가 힙에 할당하고 swift_retain/swift_release가 카운트를 조정한다. @objc/NSObject 계열은 Objective-C 런타임과 브리징되어 isa가 Objective-C 클래스 객체를 가리킨다.

중요한 건 "struct=스택, class=힙"이 언어 차원의 보장이 아니라는 점이다. Swift는 값의 저장 위치를 스펙으로 못박지 않는다. 지역 struct는 대개 스택 프레임에, 배열 원소인 struct는 배열 버퍼에 인라인으로 놓이지만, escaping 클로저에 캡처되거나 크기가 큰 값은 컴파일러가 힙에 박싱할 수 있다. SwiftUI의 View는 struct(값)이고, UIKit의 UIView/UIViewController는 class다.

struct Point (값 타입) var a { x:1, y:2 } var b = a { x:1, y:2 } 복사 = 완전히 독립 (ARC 없음) b.x 변경해도 a는 그대로 class Node (참조 타입) var a var b = a 힙 객체 isa (metadata) refCount = 2 value: 42 공유 = 한쪽 변경이 양쪽에 보임 refCount 0 되면 ARC가 해제
값 타입은 인라인 복사로 독립, 참조 타입은 힙 객체를 공유하고 refcount로 관리된다.

실험 · 도구

MemoryLayout으로 크기를, 카운트는 관찰용으로 CFGetRetainCount(또는 Instruments Allocations)로 볼 수 있다. 값 복사의 독립성은 코드로 바로 재현된다.

값 복사 vs 참조 공유 (Swift)
struct SPoint { var x = 0 }
final class CPoint { var x = 0 }

var sa = SPoint(); var sb = sa      // 값 복사
sb.x = 99
print(sa.x, sb.x)                   // 0 99  (독립)

let ca = CPoint(); let cb = ca      // 참조 복사(같은 객체)
cb.x = 99
print(ca.x, cb.x)                   // 99 99 (공유)
print(ca === cb)                    // true  (같은 식별자)

// MemoryLayout은 헤더를 제외한 값의 저장 크기만 잰다
print(MemoryLayout<SPoint>.size)  // 8   (Int 하나)
print(class_getInstanceSize(CPoint.self)) // 24  (16B 헤더 + Int 8)

Instruments의 Allocations로 class 생성 시 힙 할당이 찍히는지, struct는 안 찍히는지 비교하면 저장 위치 차이를 눈으로 확인할 수 있다.

프로젝트 적용

⚠️ 흔한 오해

"struct는 항상 스택, class는 항상 힙"은 틀렸다. class 인스턴스는 항상 힙이 맞지만, struct는 escaping 클로저 캡처·클래스 프로퍼티·큰 값 등으로 힙에 올라갈 수 있고 Swift는 저장 위치를 언어 차원에서 보장하지 않는다.

꼬리 질문

Q2. Value semantics와 Reference semantics는 무엇인가?

🔑 30초 답변

Value semantics는 "한 변수를 통해 값을 바꿔도 다른 변수·복사본에는 영향이 없다"는 독립성 보장이고, reference semantics는 "여러 참조가 같은 인스턴스를 가리켜 한쪽 변경이 모두에게 보이는" 공유입니다. 핵심은 이게 저장 위치나 복사 시점이 아니라 관찰 가능한 동작에 대한 정의라는 점입니다. 그래서 Array·String처럼 COW로 복사를 지연시켜도 value semantics는 유지됩니다. 주의할 함정은 struct 안에 참조 타입(예: NSMutableArray)을 넣으면 그 부분은 공유되어 value semantics가 깨진다는 것입니다.

CS 원리

value semantics를 정의하는 기준은 "복사본을 만든 뒤 원본과 복사본이 서로에게 영향을 주지 않는가"이다. 이는 구현이 아니라 계약이다. 실제로 매번 즉시 복사하든(eager), mutation 순간에만 복사하든(COW, lazy) 관찰되는 동작이 독립적이면 value semantics다.

reference semantics는 값이 아니라 정체성(identity)을 다룬다. 두 참조가 같은 객체인지(===)가 의미를 가지며, 어느 참조로 상태를 바꿔도 같은 객체를 보는 모두가 그 변화를 관찰한다.

iOS에서는

Swift에서 struct·enum·tuple은 value semantics, class·closure는 reference semantics를 갖는다. 표준 라이브러리 컬렉션(Array·Dictionary·Set·String)은 COW로 value semantics를 제공한다. Foundation 브리징에서 NSArray(참조)와 Array(값)의 차이가 대표적이다. SwiftUI는 value semantics에 크게 의존한다. @State가 이전 값과 새 값을 비교해 뷰를 갱신하는 diffing은 값이 독립적으로 복사·비교된다는 전제 위에서 동작한다.

실험 · 도구

value semantics가 깨지는 하이브리드 struct (Swift)
struct Box {                 // struct지만…
    var items = NSMutableArray()   // 참조 타입 프로퍼티
}
var a = Box()
var b = a                    // Box 자체는 값 복사
b.items.add("x")             // 그러나 items는 같은 객체를 공유!
print(a.items.count)         // 1  ← value semantics가 깨졌다

// 순수 값 타입이면
struct Pure { var items: [String] = [] }  // Array는 COW 값 타입
var c = Pure(); var d = c
d.items.append("x")
print(c.items.count)         // 0  ← 독립적 (value semantics 유지)

===로 정체성을 확인하고, withUnsafePointer나 buffer 주소를 찍어 "공유 중인지"를 관찰하면 두 semantics의 차이를 정량적으로 볼 수 있다.

프로젝트 적용

⚠️ 흔한 오해

"value semantics = 항상 복사한다 / 스택에 있다"는 오해다. value semantics는 동작(독립성)의 정의이지 저장 위치나 복사 전략이 아니다. COW는 복사를 미뤄도 value semantics를 지킨다. 또 "struct면 무조건 value semantics"도 틀렸다 — 참조 타입 프로퍼티를 품으면 그 부분은 공유된다.

꼬리 질문

Q3. Swift Array는 어떤 자료구조이며 Copy-on-Write는 어떻게 동작하는가?

🔑 30초 답변

Swift Array는 원소를 연속된 힙 버퍼에 담는 동적 배열입니다. 임의 접근은 O(1), append는 용량을 기하급수적으로(대략 2배) 늘리며 분할상환 O(1)입니다. Array는 struct(값 타입)지만 내부적으로 버퍼(클래스)를 가리키는 참조 하나를 들고 있어서, 복사하면 버퍼 참조만 O(1)로 복사되고 refcount만 올라갑니다. 이후 어느 한쪽을 변경하려는 순간 isKnownUniquelyReferenced로 버퍼가 공유 중인지 확인해, 공유 중이면 새 버퍼로 복사한 뒤 수정하고(그래서 다른 복사본은 안전), 단독 소유면 제자리에서 수정합니다. 이것이 COW입니다.

CS 원리

Array는 C++의 vector나 Java ArrayList와 같은 계열의 growable contiguous array다. 용량(capacity)이 차면 더 큰 버퍼를 새로 잡아 원소를 옮긴다. 증가율이 상수배(기하급수적)이므로 N번 append의 총비용이 O(N), 즉 append 하나당 분할상환 O(1)이 된다.

COW는 "값 타입의 독립성"과 "복사 비용 회피"를 동시에 얻는 기법이다. 값을 공유하는 동안은 복사를 하지 않다가, 누군가 실제로 변경(write)을 시도하고 그 버퍼가 둘 이상에게 공유되고 있을 때에만 복사한다. 공유 여부는 버퍼의 참조 카운트가 1인지로 판단한다.

1) var a = [1,2,3] a buffer refCount=1 [1,2,3] 2) var b = a (O(1), 복사 아님) a b buffer refCount=2 [1,2,3] 3) b.append(4) → 공유 감지 → 복사 a b buffer refCount=1 [1,2,3] 새 buffer refCount=1 [1,2,3,4] a는 옛 버퍼 그대로 → value semantics 유지
복사는 참조만 O(1)로 하고, 공유 중 변경 시점에만 실제 버퍼를 복제한다.

iOS에서는

Swift stdlib의 Array·ContiguousArray·Dictionary·Set·String이 모두 COW다. COW의 핵심 프리미티브는 isKnownUniquelyReferenced(_:)로, 인자로 받은 클래스 참조의 카운트가 정확히 1이면 true를 준다. 버퍼는 클래스 인스턴스라 힙에 있다. ObjC의 NSArray에서 브리징된 Array는 native 버퍼가 아닐 수 있는데, ContiguousArray는 브리징 없이 항상 연속 native 저장을 보장한다.

실험 · 도구

버퍼 주소로 COW 관찰 + 직접 만든 COW 타입 (Swift)
var a = [1, 2, 3]
var b = a
a.withUnsafeBufferPointer { print($0.baseAddress!) }  // 같은 주소
b.withUnsafeBufferPointer { print($0.baseAddress!) }  // 같은 주소 (공유)
b.append(4)
b.withUnsafeBufferPointer { print($0.baseAddress!) }  // 다른 주소 → 복사됨

// 직접 COW: 변경 직전에만 버퍼를 복제
final class Ref<T> { var value: T; init(_ v: T) { value = v } }
struct COWBox<T> {
    private var ref: Ref<T>
    init(_ v: T) { ref = Ref(v) }
    var value: T { ref.value }
    mutating func set(_ v: T) {
        if !isKnownUniquelyReferenced(&ref) { ref = Ref(ref.value) } // 공유 중이면 복제
        ref.value = v
    }
}

Instruments Allocations로 append가 실제 힙 재할당을 유발하는 순간을 확인하고, reserveCapacity를 넣기 전후로 재할당 횟수가 줄어드는지 비교한다.

프로젝트 적용

⚠️ 흔한 오해

"var b = a는 배열 전체를 즉시 복사한다"는 오해다. 복사는 참조만 O(1)로 이뤄지고, 실제 원소 복제는 공유 중 변경이 일어나는 순간으로 지연된다. 반대로 "COW라 절대 복사 안 한다"도 틀렸다 — 공유 상태에서 write하면 반드시 복사한다.

꼬리 질문

Q4. Closure는 값을 어떻게 캡처하며 Retain cycle은 왜 발생하는가?

🔑 30초 답변

클로저는 참조 타입이며, 기본적으로 주변 스코프의 변수를 값 스냅샷이 아니라 그 변수의 저장 공간(box) 참조로 캡처합니다. 그래서 캡처한 변수를 바깥에서 바꾸면 클로저 안에서도 바뀐 값이 보입니다. 캡처 대상이 class 인스턴스면 클로저가 그것을 strong으로 붙잡습니다. 이때 그 인스턴스(self)가 다시 클로저를 프로퍼티로 strong 보관하면 서로를 붙잡아 refcount가 0이 되지 못하는 retain cycle(순환 참조)이 생겨 메모리가 새어나갑니다. ARC는 카운팅만 하고 사이클을 못 걷어내므로, 캡처 리스트 [weak self]/[unowned self]로 한쪽을 약하게 끊어줍니다.

CS 원리

클로저는 코드 + 캡처한 환경(closed-over environment)을 함께 들고 다니는 값이다. Swift는 기본적으로 변수 자체를 캡처한다(변수의 값을 그 시점에 복사하는 게 아니라). 그래서 캡처한 변수가 나중에 바뀌면 그 변화가 반영된다. 반면 캡처 리스트 [x]를 쓰면 클로저 생성 시점의 x 값을 복사해 고정한다.

retain cycle은 참조 카운팅 방식의 근본적 한계다. A가 B를 strong으로, B가 A를 strong으로 참조하면 둘 다 카운트가 최소 1로 유지되어 스코프를 벗어나도 해제되지 않는다. GC(추적형 가비지 컬렉터)는 도달 불가능한 사이클을 수거하지만, ARC는 순수 카운팅이라 사이클을 감지하지 못한다.

self (class) var onDone 프로퍼티 closure { self.finish() } strong 보관 strong 캡처 refCount 둘 다 ≥ 1 → 영원히 해제 안 됨 (누수)
self↔closure가 서로 strong으로 물면 ARC가 카운트를 0으로 못 낮춰 누수가 된다.

iOS에서는

실무 사이클의 단골은 저장되는 클로저다 — completion handler 프로퍼티, Combine sink, RxSwift 구독, Timer·NotificationCenter 블록. 방어는 캡처 리스트로 한다. [weak self]는 self를 optional로 약하게 잡아 self가 죽으면 nil이 되고, [unowned self]는 nil 검사를 생략하지만 self가 이미 죽었으면 crash한다. 델리게이트를 weak var delegate로 선언하는 것도 같은 원리다.

실험 · 도구

순환 참조와 [weak self]로 끊기 (Swift)
final class Downloader {
    var onDone: (() -> Void)?

    func start() {
        onDone = { self.finish() }        // self ↔ onDone 사이클
    }

    func startSafe() {                    // 끊기
        onDone = { [weak self] in
            guard let self else { return }
            self.finish()
        }
    }

    func finish() {}
    deinit { print("deinit") }            // 사이클이면 절대 안 찍힘
}

Xcode의 Memory Graph Debugger(디버그 세션 중 메모리 아이콘)로 보라색 순환 화살표를 찾거나, Instruments Leaks로 누수 인스턴스를 잡는다. deinit에 로그를 심어 해제가 실제로 일어나는지 확인하는 게 가장 빠르다.

프로젝트 적용

⚠️ 흔한 오해

"클로저에는 항상 [weak self]를 붙여야 한다"는 과잉 규칙이다. 사이클은 클로저가 self에 저장되어 오래 사는 경우에만 문제다. non-escaping/동기 실행 클로저는 호출 직후 해제되어 사이클이 없고, 무분별한 weak는 오히려 로직 버그(원하는 시점에 nil)를 만든다.

꼬리 질문

Q5. Escaping closure는 수명과 메모리 할당에 어떤 영향을 주는가?

🔑 30초 답변

non-escaping 클로저는 함수가 반환하기 전에 반드시 실행(또는 폐기)된다고 보장되므로, 컴파일러가 캡처 컨텍스트를 스택에 두고 불필요한 retain을 생략할 수 있습니다. @escaping 클로저는 함수가 끝난 뒤에도 저장되거나 나중에 호출될 수 있어(비동기 콜백, 프로퍼티 저장) 캡처 컨텍스트를 힙에 박싱하고 캡처한 참조들을 클로저 수명 동안 strong으로 붙잡습니다. 즉 escaping은 캡처 대상의 수명을 늘리고 힙 할당·ARC 비용을 유발합니다. 바로 이 "수명이 길어진다"는 성질 때문에 retain cycle과 [weak self] 문제가 주로 escaping에서 생깁니다. Swift에서 클로저 파라미터는 기본이 non-escaping이라, 저장·비동기가 필요할 때만 @escaping을 붙입니다.

CS 원리

"escaping"은 클로저가 자신을 만든 함수의 호출 수명을 벗어나(escape) 살아남을 수 있는가의 문제다. 벗어나지 않는다면(non-escaping) 컴파일러는 캡처 저장소의 수명이 함수 호출과 함께 끝난다는 걸 알기에 스택 할당이 가능하고 참조 카운트 조작을 줄일 수 있다. 벗어난다면 캡처된 상태가 함수 반환 이후에도 유효해야 하므로 힙에 올려 참조 카운팅으로 관리해야 한다.

iOS에서는

Swift 3부터 클로저 파라미터의 기본은 non-escaping이고, 저장하거나 비동기로 넘기려면 @escaping을 명시해야 한다. URLSession.dataTask(completionHandler:), DispatchQueue.async, Combine의 sink 등이 escaping이다. 임시로 escaping 제약을 우회해야 할 땐 withoutActuallyEscaping(_:do:)를 쓴다. 최근에는 async/await가 많은 escaping completion 패턴을 대체해, 콜백을 저장하지 않고 suspension point로 수명을 다룬다.

실험 · 도구

컴파일러가 강제하는 escaping 규칙 (Swift)
var handlers: [() -> Void] = []

func nonEscaping(_ f: () -> Void) {
    handlers.append(f)   // ❌ 컴파일 에러: non-escaping 파라미터는 저장 불가
    f()                  // ✅ 즉시 호출만 가능
}

func escaping(_ f: @escaping () -> Void) {
    handlers.append(f)   // ✅ 저장 가능 → f가 캡처한 것들이 계속 살아있음
}

escaping이 캡처한 self의 수명을 늘리는지는 deinit 로그로 확인한다: escaping 클로저를 배열에 저장한 채로 두면 원래 스코프가 끝나도 self가 살아있고, 배열을 비우면 그제서야 deinit이 찍힌다. 힙 할당 자체는 Instruments Allocations에서 클로저 컨텍스트 객체로 관찰된다.

프로젝트 적용

⚠️ 흔한 오해

"@escaping은 성능에 영향이 없다" 혹은 "escaping이면 무조건 메모리 누수"는 둘 다 틀렸다. escaping은 힙 박싱·참조 카운팅이라는 비용을 유발할 뿐 그 자체가 누수는 아니다 — 누수는 self↔클로저 사이클이 있을 때만 생긴다. escaping은 "수명을 늘린다"는 것이 본질이다.

꼬리 질문

Q6. Optional은 어떤 문제를 해결하며 메모리에서 어떻게 표현될 수 있는가?

🔑 30초 답변

Optional은 "값이 없을 수 있음(null)"을 타입 시스템에 명시해 암묵적 null 역참조로 인한 크래시를 컴파일 타임에 막습니다. T?는 사실 enum Optional<Wrapped> { case none; case some(Wrapped) }이고, 컴파일러가 언래핑을 강제합니다. 메모리 표현은 순진하게 보면 태그 1바이트 + 페이로드지만, Swift는 extra inhabitant(그 타입에서 쓰이지 않는 비트 패턴)를 태그로 재활용합니다. 예를 들어 클래스 참조는 all-zero(널) 패턴이 유효한 값이 아니므로 그 패턴을 .none으로 써서 크기가 전혀 늘지 않고(MemoryLayout<C?>.size == MemoryLayout<C>.size), Bool?도 1바이트입니다. 반대로 Int처럼 모든 비트 패턴을 다 쓰는 타입은 여분이 없어 태그 바이트가 추가되어 Int?가 9바이트(stride 16)가 됩니다.

CS 원리

null 참조는 토니 호어가 "10억 달러짜리 실수"라 부른 문제다. 어떤 참조든 널일 수 있으면 모든 역참조가 잠재적 크래시가 된다. Optional은 "널 가능성"을 별도 타입으로 승격시켜, 값을 쓰기 전에 반드시 존재를 확인(언래핑)하도록 컴파일러가 강제한다. 널 가능성이 타입에 드러나 컴파일 타임에 검증된다는 게 핵심이다.

메모리 최적화의 열쇠는 extra inhabitants다. 어떤 타입 T의 저장 표현에는 실제 값으로 쓰이지 않는 비트 패턴들이 있을 수 있다(예: 포인터의 0x0, Bool의 2~255). 컴파일러는 이 여분 패턴을 Optional.none 같은 케이스 태그로 재활용해서 추가 저장 공간 없이 enum을 표현한다.

iOS에서는

Objective-C 상호운용에서 포인터의 nil은 클래스 타입의 Optional.none과 맞물린다. NS_ASSUME_NONNULL_BEGIN_Nullable/_Nonnull 애노테이션이 Swift의 T?/T로 매핑된다. T!(IUO, Implicitly Unwrapped Optional)도 내부적으로는 Optional이지만 접근 시 자동 언래핑되며, 2단계 초기화가 필요한 @IBOutlet 등에 쓰인다. 크기는 MemoryLayout으로 직접 검증할 수 있다.

실험 · 도구

Optional의 메모리 크기 확인 (Swift)
MemoryLayout<Int>.size        // 8
MemoryLayout<Int?>.size       // 9   (여분 없음 → 태그 1B 추가)
MemoryLayout<Int?>.stride     // 16  (정렬 때문)

MemoryLayout<UInt8>.size      // 1
MemoryLayout<UInt8?>.size     // 2   (256패턴 다 씀 → 태그 필요)

MemoryLayout<Bool>.size       // 1
MemoryLayout<Bool?>.size      // 1   (Bool은 2~255가 여분 → 태그 재활용)
MemoryLayout<Bool??>.size     // 1   (중첩해도 여분이 남아 안 커짐)

class C {}
MemoryLayout<C>.size          // 8
MemoryLayout<C?>.size         // 8   (널 포인터 0x0을 .none으로 재활용)

이 숫자들을 직접 출력해 보면 "Optional은 항상 커진다/박싱된다"가 왜 틀렸는지 바로 드러난다. Int?만 커지고 클래스·Bool은 안 커진다.

프로젝트 적용

⚠️ 흔한 오해

"Optional은 항상 크기가 커지고 힙에 박싱된다"는 오해다. Optional은 별도 힙 박스가 아니라 값 자체의 인라인 표현이며, extra inhabitant 덕분에 클래스 참조·Bool 등은 크기가 전혀 늘지 않는다. "nil은 곧 포인터 0"이라는 설명도 포인터/클래스 타입에 한해 맞는 이야기다.

꼬리 질문

Q7. Generic과 Protocol 기반 다형성은 어떻게 다른가?

🔑 30초 답변

제네릭은 parametric polymorphism으로, 하나의 알고리즘을 여러 타입에 적용하되 각 사용처의 구체 타입 정보를 컴파일 타임에 유지합니다(T는 각 인스턴스화에서 특정 구체 타입). 최적화 시 specialization으로 정적 디스패치·인라이닝이 가능하고 박싱이 없습니다. 반면 프로토콜을 타입으로 쓰는 existential(any P)은 구체 타입을 지우고 박스(existential container)에 담아, protocol witness table을 통한 동적 디스패치로 동작합니다. 그래서 이종(heterogeneous) 컬렉션을 담을 수 있지만 런타임 비용과 (값이 크면) 힙 할당이 따릅니다. 요약하면 제네릭 = 컴파일 타임·타입 보존·저비용, existential = 런타임·타입 소거·유연하지만 비용.

CS 원리

다형성에는 여러 종류가 있다. 제네릭은 "타입을 매개변수로 받는" parametric polymorphism이다. func f<T: P>(_ x: T)에서 T는 호출마다 하나의 구체 타입으로 결정되고, 그 구체 타입 관계가 함수 전체에 걸쳐 유지된다(반환 타입에도, associated type에도). existential은 "이 프로토콜을 따르는 어떤 타입이든"을 하나의 값으로 다루는 것으로, 구체 타입을 런타임 박스 뒤로 숨긴다.

구현 관점에서 비특수화 제네릭과 existential은 둘 다 witness table로 동작한다. protocol witness table(PWT)은 프로토콜 요구사항의 실제 구현 주소표, value witness table(VWT)은 그 값을 복사·소멸·이동하는 방법의 표다. 제네릭은 이 표를 숨은 인자로 넘겨받고, existential은 박스 안에 함께 담는다는 차이가 있다.

any P — existential container (프로토콜 1개 기준 5 word = 40B) value buffer · 3 words inline inline inline metadata ptr (VWT 접근) PWT ptr 동적 디스패치 값이 3 word를 넘으면 buffer엔 힙 포인터만 두고 실제 값은 힙에 박싱된다. 제네릭(특수화): 이 박스 없이 구체 타입으로 인라인 + 정적 디스패치
existential은 값+메타데이터+witness table을 한 박스에 담아 타입을 지운다. 제네릭 특수화는 이 박스를 없앤다.

iOS에서는

WWDC16 "Understanding Swift Performance"에 따르면 existential container는 대략 5 word — 3 word 인라인 value buffer + metadata 포인터 + protocol witness table 포인터 — 이고, 값이 3 word를 넘으면 힙에 박싱한다. MemoryLayout<any P>.size는 프로토콜 1개 기준 40바이트다. 제네릭 specialization은 최적화(예: -O + Whole Module Optimization, 또는 @inlinable)로 구체 타입이 보일 때 일어나며 보장된 동작은 아니다.

실험 · 도구

generic vs existential (Swift)
protocol Shape { func area() -> Double }

// 제네릭: 컴파일 타임에 T 확정, 특수화 시 정적 디스패치·박싱 없음
func totalG<S: Shape>(_ shapes: [S]) -> Double { shapes.reduce(0) { $0 + $1.area() } }

// existential: 이종 컬렉션 가능, 동적 디스패치 + 박싱
func totalE(_ shapes: [any Shape]) -> Double { shapes.reduce(0) { $0 + $1.area() } }

print(MemoryLayout<any Shape>.size)   // 40 (5 word)

swiftc -O -emit-sil로 SIL을 뜯어보면 제네릭 호출이 특수화되어 witness_method 대신 직접 호출로 바뀌는지 확인할 수 있다. 큰 값을 any에 담는 코드는 Instruments Allocations에서 박싱 힙 할당으로 관찰된다.

프로젝트 적용

⚠️ 흔한 오해

"프로토콜 타입(any)을 써도 제네릭과 성능이 같다"는 오해다. existential은 동적 디스패치 + (값이 크면) 힙 박싱 비용이 있다. 반대로 "제네릭은 런타임에 타입별로 코드가 무조건 복제된다"도 틀렸다 — specialization은 최적화일 뿐 보장이 아니고, 비특수화 제네릭은 witness table로 동작한다.

꼬리 질문

Q8. any와 some은 무엇이 다른가?

🔑 30초 답변

some Popaque type으로, 구현부가 고른 하나의 고정된 구체 타입이지만 그 이름을 호출자에게는 감춥니다("역방향 제네릭"). 타입이 하나로 고정되므로 타입 관계가 보존되고, 정적 디스패치·제로 코스트입니다. any Pexistential로, 런타임에 서로 다른 구체 타입을 담을 수 있는 박스이며 타입을 지우고 동적 디스패치·박싱을 씁니다. 한마디로 some은 "정체는 하나인데 이름만 가림", any는 "여러 타입을 담는 상자". SwiftUI의 some View가 대표 예로, body가 반환하는 거대한 단일 구체 타입을 감춰 프레임워크가 안정적 타입으로 diffing하게 해줍니다.

CS 원리

some P(opaque type, SE-0244)는 "타입이 하나로 정해져 있지만 그게 무엇인지는 API 경계에서 숨긴다"는 개념이다. 호출자는 구체 타입을 모르지만 컴파일러는 안다. 그래서 두 번 호출한 some Equatable이 같은 타입임을 컴파일러가 보장하고, 타입 관계를 유지한 채 최적화(정적 디스패치)를 한다.

any P(existential, SE-0335)는 "이 프로토콜을 따르는 어떤 타입이든" 담는 상자다. 실행 중에 담긴 타입이 바뀔 수 있고(컬렉션에 여러 타입 혼재 가능), 그 유연성의 대가로 타입 소거·동적 디스패치·박싱이 따른다. 파라미터 위치의 some P(SE-0341)는 <T: P> 제네릭의 축약이다.

구분some P (opaque)any P (existential)
구체 타입하나로 고정(감춰짐)런타임에 여러 타입 가능
타입 관계 보존유지소거
디스패치정적(특수화 가능)동적(witness table)
박싱/비용없음(제로 코스트)existential 박스·힙 가능
이종 컬렉션불가가능([any P])

iOS에서는

SwiftUI의 var body: some View가 핵심 사례다. body가 만드는 타입은 VStack<TupleView<...>>처럼 거대한 단일 구체 타입인데, opaque로 감추면 이름을 안 적어도 되고 프레임워크는 안정적 구체 타입을 얻어 뷰 diffing과 identity 추적을 효율적으로 한다. 여기에 any View를 쓰면 매 뷰가 박싱되고 타입 안정성이 사라져 성능·정확성이 나빠진다. WWDC22 "Embrace Swift generics"가 이 선택을 자세히 다룬다.

실험 · 도구

some은 단일 타입, any는 이종 (Swift)
protocol Animal { func sound() -> String }
struct Dog: Animal { func sound() -> String { "woof" } }
struct Cat: Animal { func sound() -> String { "meow" } }

// some: 반환 타입이 하나로 고정되어야 함
func makeOne() -> some Animal { Dog() }        // OK
// func broken(_ f: Bool) -> some Animal {     // ❌ 두 구체 타입 반환 불가
//     f ? Dog() : Cat()
// }

// any: 서로 다른 타입을 한 배열에
let zoo: [any Animal] = [Dog(), Cat()]         // OK (이종)
print(zoo.map { $0.sound() })                  // ["woof", "meow"]

print(MemoryLayout<any Animal>.size)           // 40 (existential 박스)

조건에 따라 다른 타입을 some으로 반환하려 하면 컴파일 에러가 나는 걸 직접 확인하라 — 이게 "some은 하나의 타입"이라는 성질의 증거다. @ViewBuilder는 이 제약을 _ConditionalContent라는 단일 타입으로 감싸 우회한다.

프로젝트 적용

⚠️ 흔한 오해

"someany는 문법 설탕 차이일 뿐"은 틀렸다. some은 정적·단일 타입·제로 코스트, any는 동적·이종·박싱으로 런타임 동작과 비용이 다르다. 또 "반환 위치의 some은 제네릭과 동일"도 아니다 — 반환 some구현부가 타입을 정하지만, 제네릭 파라미터는 호출자가 타입을 정하므로 서로 대체 불가다.

꼬리 질문

출처 · 참고자료