← Swift & Foundation 로드맵 1 / 33

01 · 값 타입과 참조 타입

struct와 class를 가르는 값/참조 의미론부터 Copy-on-Write 내부 구현, MemoryLayout 패딩, actor가 참조 타입인 이유, 상속 대신 프로토콜+컴포지션의 트레이드오프까지.
진행률
0 / 0 완료

struct와 class 중 무엇을 쓸지는 문법 취향이 아니라 값 의미론(value semantics)과 참조 의미론(reference semantics)이 실제로 만드는 동작 차이의 문제다. 이 챕터는 그 선택이 메모리에서 무엇을 의미하는지 — 대입이 복사인지 공유인지, Copy-on-Write가 언제 실제 복제를 트리거하는지, 필드 하나 순서만 바꿔도 struct 크기가 왜 달라지는지 — 실제 바이트 수치로 확인한다. 마지막엔 actor가 왜 참조 타입일 수밖에 없는지, 상속 대신 프로토콜 + 컴포지션을 택했을 때 정말로 무엇을 잃는지까지 다룬다.

Q1. struct와 class는 무엇을 기준으로 고르는가? — 값/참조 의미론이 만드는 실제 차이

🔑 30초 답변

정체성(identity)이 필요 없고 각자 독립된 복사본으로 존재해도 되는 데이터는 struct, 여러 곳에서 "같은 하나의 인스턴스"를 공유해야 하고 한쪽의 변경이 다른 모든 참조에 보여야 하는 데이터는 class다. ==(값이 같은가)와 ===(정체성이 같은가, AnyObject에만 존재)가 이 결정을 코드로 드러낸다. 애플 공식 가이드는 "일단 struct를 우선하고, 필요하거나 적절할 때만 class를 쓰라"고 말한다.

원리

Swift에서 구조체·열거형·튜플은 값 타입이고, 클래스·액터·클로저는 참조 타입이다. 공식 문서는 값 타입을 "대입되거나 함수에 전달될 때 복사되는 타입"으로, 참조 타입을 "복사되지 않고 동일 인스턴스를 공유하는 타입"으로 정의한다. 구조체는 멤버별 초기화자(memberwise init)를 자동으로 얻지만 클래스 인스턴스는 그렇지 않다 — 이 자체가 두 타입이 서로 다른 용도로 설계됐다는 신호다.

내부 동작

struct 변수는 (다른 참조를 담고 있지 않다면) 필드 값을 그 변수의 저장 위치에 그대로 갖는다. 대입은 그 값을 다른 위치로 그대로 복사하는 것이라, 이후 두 변수는 완전히 독립된 메모리다. class 변수는 인스턴스를 직접 담지 않고 힙에 있는 인스턴스를 가리키는 포인터 하나를 담는다. 대입은 그 포인터만 복사하므로, 대입 이후에도 두 변수는 여전히 같은 힙 객체 하나를 가리킨다.

이 차이는 크기에도 그대로 나타난다. WWDC 2016 세션의 Point 예제에서, struct로 구현한 2차원 좌표는 2워드(x, y 각 1워드)만 쓰지만 같은 필드를 class로 구현하면 4워드를 쓴다 — 나머지 2워드는 참조 카운트와 타입 메타데이터 포인터 관리용이다. struct 안에 클래스 참조(혹은 클로저, 배열처럼 내부에 참조를 감춘 타입)가 들어 있으면, 그 개수만큼 ARC 비용은 그대로 남는다 — struct라는 이유만으로 참조 카운팅에서 자유로워지는 게 아니다.

대입 한 번 — struct는 복사, class는 공유 struct S { var age = 1 } var s1 = S(); var s2 = s1 s1 age = 1 s2 age = 1 각자 다른 메모리 — s2.age = 99 해도 s1은 그대로 class C { var v = 0 } let c1 = C(); let c2 = c1 c1 c2 C 인스턴스 (heap) refCount = 2, v = 0 c2.v = 42 하면 c1.v도 42 — 같은 인스턴스
struct 대입은 각자 독립된 저장 공간을 만들고, class 대입은 힙에 있는 같은 인스턴스 하나를 계속 공유한다.

실험 · 도구

다음 코드를 파일로 저장해 swift 파일명.swift로 실행하면 차이를 바로 확인할 수 있다.

클래스는 공유, 구조체는 독립 — 실행해서 직접 확인
class C { var v = 0 }
struct S { var age = 1 }

let c1 = C(); let c2 = c1
c2.v = 42
print("class: c1.v =", c1.v)              // 42 — 같은 인스턴스를 공유

var s1 = S(); var s2 = s1
s2.age = 99
print("struct: s1.age =", s1.age, "s2.age =", s2.age)  // 1 99 — 독립적

LLDB에서는 frame variable -L로 각 변수의 실제 주소를 확인할 수 있다. class 인스턴스를 담은 두 변수는 같은 주소를, struct 두 변수는 다른 주소를 가리킨다.

프로젝트 적용

네트워크 응답을 매핑한 모델, 화면에 뿌릴 뷰 모델처럼 "정체성이 중요하지 않고 값 자체가 중요한" 데이터는 struct로 시작한다. 여러 스레드에서 읽어도 값이 그대로 복사돼 전달되므로 데이터 레이스 걱정이 줄어든다.

DTO/뷰모델은 struct 우선
struct ProductViewModel {
    let id: String
    let title: String
    let priceText: String   // 이미 포매팅된 문자열 — 뷰가 그대로 표시
}

// 여러 셀에 값을 나눠줘도 원본 배열과는 독립적으로 복사되어 안전하다
func makeViewModels(from products: [Product]) -> [ProductViewModel] {
    products.map {
        ProductViewModel(id: $0.id, title: $0.title, priceText: "₩\($0.price)")
    }
}

캐시 매니저, 델리게이트, 옵저버처럼 "여러 곳에서 같은 하나를 공유해야" 의미가 성립하는 대상은 class로 남긴다. struct로 바꾸면 대입마다 복사가 일어나 서로 다른 캐시를 보게 되는 버그가 생긴다.

공유가 핵심 의미인 대상은 class
final class ImageCache {
    static let shared = ImageCache()
    private var storage: [String: UIImage] = [:]

    func image(for key: String) -> UIImage? { storage[key] }
    func insert(_ image: UIImage, for key: String) { storage[key] = image }
}

// 어디서 shared를 받아도 항상 같은 캐시 인스턴스 — struct였다면 대입마다 복사돼 캐시가 갈라진다
let cacheRef = ImageCache.shared
⚠️ 흔한 오해

"struct 변수 두 개는 절대 같은 메모리를 가리키지 않는다"는 정확하지 않다. var s2 = s1 직후 두 변수는 실제로 같은 버퍼(내부에 Array 같은 CoW 타입이 있는 경우)를 공유할 수 있다. 값 의미론이 보장하는 건 "동작이 마치 즉시 복사된 것처럼 보인다"는 것이지, "물리적으로 항상 즉시 복사된다"는 게 아니다. 실제 복제 시점은 Q2에서 다루는 Copy-on-Write가 결정한다.

🧒 쉽게 이해하기

struct는 사진을 인화하는 것과 비슷하다. 친구에게 사진을 주면 각자 자기 사진을 갖고, 친구가 자기 사진에 낙서해도 내 사진은 멀쩡하다. class는 구글 문서 공유 링크를 보내는 것과 비슷하다. 링크를 여러 명에게 보내도 문서는 하나뿐이라, 누군가 고치면 링크를 받은 모두가 그 변경을 그대로 본다. 비유가 깨지는 곳: 사진은 인화하는 순간 진짜로 잉크가 나가지만, Swift의 "사진 인화"(struct 대입)는 실제로 필요해지는 순간까지 미뤄질 수 있다 — 이 절약 기법은 Q2에서 다룬다.

꼬리 질문

struct 안에 class 프로퍼티가 있으면 그 struct는 값 타입인가 참조 타입인가?
여전히 값 타입이다. struct 자체를 대입하면 필드들이 복사되지만, 그중 class 타입 필드는 "포인터가 복사"될 뿐이라 두 struct 인스턴스가 같은 하위 객체 하나를 공유하게 된다. 이를 shallow copy(얕은 복사)라 부르며, 그 하위 객체까지 독립시키려면 struct가 직접 복제 로직(필요하면 CoW)을 넣어야 한다.
쉽게 말하면 사진첩(struct)을 통째로 복사해도, 그 안에 꽂힌 "친구 연락처가 적힌 카드"(class 필드)는 카드 자체가 아니라 같은 카드를 가리키는 사본이 꽂히는 것과 같다. 카드 내용을 고치면 두 사진첩 모두에서 그 카드가 바뀐 것처럼 보인다.
`===`는 왜 struct에는 기본으로 없는가?
===는 "같은 메모리 인스턴스인가"를 묻는 연산자라 정체성 개념이 있는 AnyObject(class, actor)에만 정의돼 있다. struct는 애초에 "이 인스턴스"라는 정체성이 없고 값만 있으므로, 비교하려면 Equatable을 채택해 ==(값이 같은가)를 구현하는 것이 자연스러운 선택이다.
쉽게 말하면 "이 사진이 저 사진과 같은 인화지냐"는 물어볼 수 있어도, 복사된 사진에는 "원본이냐"는 질문 자체가 성립하지 않는 것과 비슷하다. 대신 "사진에 찍힌 내용이 같냐"만 물을 수 있다.
actor도 class처럼 참조 타입인데, struct/class 선택 기준이 actor에도 그대로 적용되는가?
"공유해야 하는 정체성이 있는가"라는 기준 자체는 그대로 적용되지만, actor는 거기에 "그 공유된 상태를 동시 접근으로부터 어떻게 보호할 것인가"라는 축이 하나 더 붙는다. class는 공유는 되지만 동시 접근 안전을 보장하지 않고, actor는 공유되면서 동시 접근까지 직렬화한다. 자세한 차이는 Q5에서 다룬다.
쉽게 말하면 class와 actor 둘 다 "같은 방을 공유한다"는 점은 같지만, class는 아무나 동시에 들어가도 막지 않고 actor는 문 앞에 한 번에 한 명만 들여보내는 안내원을 세워둔 것과 같다.

Q2. Copy-on-Write는 어떻게 동작하며, 커스텀 타입에 직접 구현하려면 무엇이 필요한가?

🔑 30초 답변

Copy-on-Write(CoW)는 대입·전달 시엔 내부 버퍼(클래스 인스턴스)의 참조 카운트만 올려 복사를 미루다가, 누군가 그 값을 실제로 변경하려는 순간에만 isKnownUniquelyReferenced로 "나 혼자 이 버퍼를 쓰고 있는가"를 검사해, 아니라면 그때 비로소 새 버퍼로 복제하는 패턴이다. 언어가 모든 struct에 자동으로 주는 기능이 아니라 Array/Dictionary/Set/String 같은 표준 라이브러리 타입이 명시적으로 구현한 것이며, 커스텀 타입에 넣으려면 내부 스토리지를 class로 만들고 이 검사를 직접 넣어야 한다.

Array/Dictionary의 CoW 자체와 ARC가 iOS 메모리에 어떻게 보이는지는 이미 iOS CS 로드맵 05장에서 다뤘다. 여기서는 그 메커니즘을 _read/_modify 접근자를 쓰는 커스텀 타입에 실제로 구현하는 데 집중한다.

원리

표준 컬렉션 구현은 Builtin.beginCOWMutation/Builtin.endCOWMutation 같은 컴파일러 내장 함수를 쓰고, 안전성 점검용으로만 isKnownUniquelyReferenced가 외부에 노출된다. 그 주석은 "버퍼가 유일하게 참조되고 있으면 true를 반환하고 버퍼를 변경 가능 상태로 만든다"고 설명한다. 순수하게 값 타입 필드로만 이뤄진 struct(Int, Bool 같은)는 CoW가 필요 없다 — 그냥 필드별로 복사하면 이미 저렴하다. CoW는 struct가 내부에 클래스 버퍼를 박스로 감싸고 있을 때만 의미가 있다.

내부 동작

대입 직후엔 두 변수가 같은 버퍼 주소를 가리킨다. 한쪽에서 mutating 메서드를 호출하는 순간, 그 메서드는 isKnownUniquelyReferenced(&storage)를 호출해 참조 카운트가 1인지 확인한다. 1이 아니면(=다른 변수도 같은 버퍼를 보고 있으면) 새 버퍼를 만들어 내용을 복사한 뒤 그 새 버퍼에 쓴다. 1이면 그 자리에서 바로 수정한다. 이 검사는 쓰기가 일어날 때만 실행되고, 읽기 전용 접근에는 전혀 관여하지 않는다.

① var a = TagList(["swift","ios"]) a Storage refCount = 1 ["swift","ios"] ② var b = a — 버퍼 참조만 복사 a b Storage refCount = 2 ["swift","ios"] ③ b.append("cow") — 공유 중이라 복제 a b 기존 Storage refCount = 1 ["swift","ios"] 새 Storage refCount = 1 ["swift","ios","cow"]
대입 직후에는 refCount만 올라가며 버퍼를 공유하다가, isKnownUniquelyReferenced가 false를 반환하는 순간에만 실제 복제가 일어난다.

실험 · 도구

버퍼 주소를 직접 찍어보면 트리거 시점이 바로 보인다.

대입 직후엔 같은 주소, append 이후엔 다른 주소
var a = [1, 2, 3]
var b = a
a.withUnsafeBufferPointer { print("a:", $0.baseAddress!) }
b.withUnsafeBufferPointer { print("b:", $0.baseAddress!) }   // a와 같은 주소

b.append(4)
b.withUnsafeBufferPointer { print("b append 후:", $0.baseAddress!) }  // 주소가 바뀜
a.withUnsafeBufferPointer { print("a는 그대로:", $0.baseAddress!) }   // a는 원래 주소 그대로

Instruments의 Allocations 도구를 붙이면 append 호출 순간에만 새 힙 블록이 잡히는 것을 직접 볼 수 있다.

프로젝트 적용

커스텀 타입에 CoW를 넣으려면 스토리지를 class로 감싸고, 쓰기 경로마다 유일성 검사를 넣는다. 아래는 subscript_read/_modify 접근자로 읽기와 쓰기를 분리한 예다 — _read는 복사 없이 값을 "빌려주기"만 하고, _modify만 유일성 검사를 한다.

_read/_modify로 읽기·쓰기를 분리한 커스텀 CoW 타입
final class Storage {
    var items: [String]
    init(_ items: [String]) { self.items = items }
    func copy() -> Storage { Storage(items) }
}

struct TagList {
    private var storage: Storage

    init(_ items: [String] = []) {
        storage = Storage(items)
    }

    var count: Int { storage.items.count }

    subscript(index: Int) -> String {
        // 읽기 전용 경로 — 유일성 검사도, 복제도 하지 않는다
        _read {
            yield storage.items[index]
        }
        // 쓰기 경로 — 여기서만 "나 혼자 쓰고 있나"를 확인한다
        _modify {
            if !isKnownUniquelyReferenced(&storage) {
                storage = storage.copy()   // 공유 중이면 그때 복제
            }
            yield &storage.items[index]
        }
    }

    mutating func append(_ tag: String) {
        if !isKnownUniquelyReferenced(&storage) {
            storage = storage.copy()
        }
        storage.items.append(tag)
    }
}

var a = TagList(["swift", "ios"])
var b = a                        // 버퍼 참조만 복사, refCount = 2
b.append("cow")                  // 공유 중이라 복제 → b만 새 버퍼로
print(a.count, b.count)          // 2 3 — a는 영향받지 않음

트레이드오프는 "유일성 검사를 빠짐없이 넣어야 한다"는 규율이다. mutating 진입점 하나라도 검사를 빠뜨리면 컴파일 에러 없이 "공유된 버퍼를 몰래 같이 수정하는" 값 의미론 위반 버그가 생긴다.

유일성 검사를 깜빡한 버전 — 컴파일은 되지만 값 의미론이 깨진다
struct BuggyTagList {
    private var storage: Storage
    init(_ items: [String] = []) { storage = Storage(items) }
    var count: Int { storage.items.count }

    // ❌ isKnownUniquelyReferenced 체크가 빠졌다
    mutating func append(_ tag: String) {
        storage.items.append(tag)   // 공유 중이어도 그냥 기존 버퍼를 고쳐버린다
    }
}

var x = BuggyTagList(["a"])
var y = x
y.append("b")
print(x.count, y.count)   // 기대: 1 2 — 실제: 2 2, x도 같이 바뀌어버린다
⚠️ 흔한 오해

"Array/Dictionary/String이 값 타입이니 힙을 전혀 안 쓴다"는 틀렸다. 내부 버퍼는 여전히 클래스 기반 힙 객체이고, "복사해도 독립적으로 동작한다"는 값 타입 특성만 CoW로 흉내 내는 것이다. 또 "CoW는 컴파일러가 모든 struct에 자동으로 적용해준다"도 틀렸다 — 표준 라이브러리 컬렉션이 명시적으로 짜놓은 패턴이며, 사용자 정의 struct는 직접 구현해야 한다.

🧒 쉽게 이해하기

같이 보는 메모장이 있다고 하자. 친구랑 나눠 가져도 처음엔 진짜 복사를 하지 않고 "같은 메모장을 같이 보고 있다"고만 표시해둔다(참조 카운트). 그러다 둘 중 하나가 실제로 낙서를 하려는 순간, "어, 이거 나 혼자 보는 거 아니네?" 확인하고 그제서야 복사본을 새로 만들어서 낙서한다. 비유가 깨지는 곳: 진짜 메모장 복사는 사람이 판단해서 하지만, Swift의 이 판단(isKnownUniquelyReferenced)은 사람이 아니라 표준 라이브러리 코드 안에 미리 박아둔 "if문 하나"가 대신 해주는 것이다 — 그 if문을 커스텀 타입에서는 내가 직접 써야 한다.

꼬리 질문

버퍼가 변수 세 개에 공유되고 있을 때 그중 하나만 mutate하면 참조 카운트는 어떻게 바뀌는가?
원래 refCount=3이던 버퍼에서 mutate한 변수 하나만 새 버퍼(refCount=1)로 갈아타고, 나머지 두 변수는 여전히 기존 버퍼를 함께 본다(그래서 그 버퍼의 refCount는 3에서 2로 줄어든다). 표준 라이브러리는 정확히 "지금 mutate하려는 그 변수"만 떼어내고 나머지 공유 관계는 그대로 둔다.
쉽게 말하면 셋이 보던 메모장에서 한 명만 낙서하려 하면, 그 한 명만 새 메모장을 들고 나가고 남은 둘은 계속 원래 메모장을 같이 본다.
isKnownUniquelyReferenced가 false인데도 복제를 생략하면 어떤 버그가 생기나?
두 값 타입 변수가 여전히 같은 버퍼를 보고 있는 상태에서 한쪽만 바꾸려 한 건데, 복제를 생략하면 그 변경이 버퍼를 공유하는 다른 변수에도 그대로 보여버린다. 겉으로는 "값 타입인데 참조 타입처럼 동작"하는, 발견하기 아주 까다로운 버그가 된다. 위 BuggyTagList가 정확히 이 사례다.
쉽게 말하면 내 사진인 줄 알고 낙서했는데, 사실 친구도 똑같은 사진(원본 그대로)을 들고 있어서 친구 사진에도 낙서가 나타나 버리는 것과 같다.
필드가 전부 값 타입(Int, Bool 등)뿐인 struct에도 CoW가 필요한가?
필요 없다. CoW는 "복사 비용이 비싼 참조 타입 버퍼"를 값 타입처럼 보이게 하려고 쓰는 기법이라, 필드가 전부 값 타입이면 애초에 필드별 복사 자체가 이미 저렴해서 지연시킬 이유가 없다. CoW가 의미를 갖는 건 struct 내부에 클래스 기반 버퍼(혹은 그런 버퍼를 감춘 Array 같은 타입)를 들고 있을 때뿐이다.
쉽게 말하면 원래 가벼운 짐(순수 값 필드)은 그냥 각자 들고 다니면 되지, 굳이 "필요할 때만 나눠 들자"는 규칙(CoW)을 만들 필요가 없다.

Q3. `MemoryLayout`의 size / stride / alignment는 각각 무엇이며 왜 다른가?

🔑 30초 답변

size는 값이 실제로 차지하는 바이트 수, alignment는 그 타입이 메모리에서 시작할 수 있는 주소가 반드시 배수여야 하는 단위(하드웨어가 정렬 안 된 접근을 못 하거나 느리게 처리하기 때문), stride는 배열처럼 같은 타입을 연달아 배치할 때 한 원소에서 다음 원소로 넘어가는 실제 간격이다. stridesizealignment의 배수로 올림한 값이라 항상 size ≤ stride다. 이 세 값은 옛 sizeof/strideof/alignof 자유 함수를 대체해 SE-0136으로 MemoryLayout<T> 네임스페이스 아래로 옮겨졌다.

원리

가장 단순한 경우, 4바이트 정수 하나만 있는 타입은 세 값이 전부 같다: MemoryLayout<Int32>.size, .stride, .alignment 모두 4다. 세 값이 갈라지는 건 서로 다른 정렬 요구를 갖는 필드 여러 개가 한 타입 안에 섞일 때부터다.

내부 동작

다음 struct로 확인해보자.

Puppy — Int(8B, align 8) 다음에 Bool(1B)
struct Puppy {
    let age: Int         // 8바이트, alignment 8
    let isTrained: Bool  // 1바이트, alignment 1
}
print(MemoryLayout<Puppy>.size)       // 9  — 8 + 1
print(MemoryLayout<Puppy>.stride)     // 16 — size를 alignment(8)의 배수로 올림
print(MemoryLayout<Puppy>.alignment)  // 8  — 가장 큰 필드(Int)의 alignment를 물려받음

실제로 필요한 바이트는 age 8바이트와 isTrained 1바이트를 합친 9바이트(size)뿐이다. 그런데 이 struct를 배열에 여러 개 연달아 두면, 두 번째 Puppy도 8바이트 배수 위치(age가 8바이트 정렬을 요구하므로)에서 시작해야 한다. 9바이트 뒤 다음 8의 배수는 16이라, 9번째 바이트부터 15번째 바이트까지 7바이트가 아무 의미 없는 패딩으로 채워지고 그 결과 stride는 16이 된다. 이 타입의 alignment 자체는 필드 중 가장 큰 정렬 요구(여기선 Int의 8)를 그대로 물려받는다.

struct Puppy { let age: Int; let isTrained: Bool } age: Int (8B) padding (7B) isTrained size = 9 stride = 16 (alignment 8의 배수로 올림)
실제 데이터는 9바이트뿐이지만, 다음 Puppy가 다시 8바이트 배수 위치에서 시작하도록 7바이트 패딩이 붙어 stride는 16이 된다.

실험 · 도구

위 코드를 그대로 파일에 저장해 swift 파일명.swift로 돌리면 세 숫자를 바로 확인할 수 있다. existential 타입에서도 같은 API가 그대로 작동한다 — any 프로토콜 값 하나를 담는 상자(existential container)는 값 버퍼 3워드 + 메타데이터 1워드 + 위트니스 테이블 1워드, 총 5워드(64비트에서 40바이트)로 고정돼 있는데, 다음 코드로 직접 확인할 수 있다.

existential container 크기는 담긴 값의 실제 타입과 무관하게 고정
protocol P {}
struct Small: P { let a: Int8 }              // MemoryLayout<Small>.size == 1
struct Big: P { let a, b, c, d: Int64 }      // MemoryLayout<Big>.size == 32

let values: [P] = [Small(a: 1), Big(a: 1, b: 2, c: 3, d: 4)]
for v in values {
    print(MemoryLayout<P>.size)   // 둘 다 40 — existential container 크기(5워드)
}

LLDB에서는 expr -- MemoryLayout<T>.size로, SIL 레벨에서는 swiftc -emit-silinit_existential_addr 같은 명령을 검색해 컨테이너가 실제로 어떻게 만들어지는지 볼 수 있다.

프로젝트 적용

대량의 레코드를 배열에 담을 때는 size가 아니라 stride 기준으로 전체 메모리를 추정해야 실제 footprint를 맞출 수 있다.

배열 전체 메모리는 stride × count로 추정한다
struct Puppy { let age: Int; let isTrained: Bool }

func estimatedFootprint(count: Int) -> Int {
    // size(9)가 아니라 stride(16) 기준 — 배열 원소 사이 패딩까지 포함해야 실제 값이 나온다
    MemoryLayout<Puppy>.stride * count
}
print(estimatedFootprint(count: 100_000))  // 1,600,000 바이트 ≈ 1.53MB

네트워크로 받은 대용량 배열을 UnsafeRawBufferPointer로 직접 다룰 일이 있다면, 원소 간 이동에 size가 아니라 stride를 써야 다음 원소 경계를 침범하지 않는다.

stride를 기준으로 다음 원소 위치를 계산
func secondElementOffset<T>(of type: T.Type) -> Int {
    // size로 계산하면 패딩을 무시하게 되어 실제 두 번째 원소 위치와 어긋난다
    MemoryLayout<T>.stride
}
print(secondElementOffset(of: Puppy.self))  // 16
⚠️ 흔한 오해

"size가 곧 배열에서 다음 원소까지의 거리"라는 생각은 틀렸다. 그 거리는 stride이고, size는 그보다 작거나 같을 뿐이다. "정렬은 그냥 컴파일러가 신경 쓰는 최적화 옵션이니 무시해도 된다"도 틀렸다 — 정렬 안 된 주소 접근은 일부 하드웨어에서 아예 금지되거나, 허용되더라도 여러 번의 메모리 접근으로 쪼개져 느려진다.

🧒 쉽게 이해하기

이삿짐을 트럭 칸에 나눠 싣는다고 하자. 상자 자체의 크기가 size다. 그런데 트럭 선반이 특정 간격(예: 8칸 단위)으로만 칸막이를 세울 수 있다면, 상자가 그 간격에 딱 안 맞을 때 남는 빈 공간까지 포함한 "한 칸의 실제 길이"가 stride다. alignment는 그 선반이 강제하는 칸막이 간격 자체다. 비유가 깨지는 곳: 트럭 선반 간격은 사람이 편의상 정한 규칙처럼 보이지만, 실제 정렬 규칙은 CPU가 정렬 안 된 주소를 아예 못 읽거나 두 번에 나눠 읽어야 하는 하드웨어의 물리적 제약에서 나온다.

꼬리 질문

`MemoryLayout.size(ofValue:)`는 실제 런타임 타입 크기를 알려주는가, 아니면 `MemoryLayout<T>.size`와 같은가?
같다. size(ofValue:)는 인스턴스의 "진짜 동적 타입" 크기를 재는 함수가 아니라, 타입 이름을 직접 쓰지 않아도 되도록 그 값의 컴파일 타임 정적 타입을 기준으로 MemoryLayout<T>.size를 그대로 호출해주는 편의 함수일 뿐이다. 그래서 any P처럼 정적 타입이 프로토콜인 값에 호출하면, 안에 든 구체 타입이 무엇이든 항상 existential container 자체의 고정 크기(40)가 나온다.
쉽게 말하면 상자 겉면에 "이 상자는 몇 리터"라고 적힌 스티커를 읽는 것과 같다. 안에 실제로 뭐가 들었는지와 상관없이, 상자(정적 타입) 스티커에 적힌 숫자를 그대로 알려준다.
stride가 size보다 작을 수는 없는가?
없다. stride는 배열에서 한 원소가 다음 원소의 메모리를 침범하지 않도록 보장하는 값이라, 정의상 항상 실제 필요한 바이트 수(size) 이상이어야 한다. 정렬 요구가 우연히 자연수 배수와 딱 맞아떨어지면 stride == size가 될 수는 있지만, 그보다 작아지는 경우는 없다.
쉽게 말하면 한 칸의 길이가 상자 크기보다 짧으면 옆 상자와 겹쳐버리니, 무조건 상자 크기 이상이어야 한다.
alignment가 1인 타입도 있는가?
있다. UInt8, Int8, Bool처럼 1바이트짜리 원시 타입은 alignment가 1이다. 어떤 주소든 1의 배수이므로 사실상 정렬 제약이 없는 셈이고, 이런 필드는 다른 필드 사이의 빈틈을 메우는 용도로 배치하기 좋다(Q4에서 이 성질을 활용한다).
쉽게 말하면 1칸짜리 아주 작은 블록은 아무 자리에나 끼워 넣을 수 있어서, 큰 블록들 사이의 좁은 틈을 메우는 데 딱 좋다.

Q4. 프로퍼티 선언 순서만 바꿔도 struct 크기가 달라지는 이유는?

🔑 30초 답변

컴파일러는 각 필드를 "그 필드 타입의 alignment 배수인 오프셋"에만 배치할 수 있고, Swift는 현재 관찰되는 구현 동작상 대체로 선언한 순서 그대로 필드를 배치한다(단, 이는 언어 스펙이 보장하는 게 아니라 관찰된 컴파일러 동작이다). 그래서 정렬 요구가 큰 필드와 작은 필드가 뒤섞여 선언되면 필드 사이사이에 패딩이 낭비되고, 정렬 요구가 큰 필드부터 몰아서 선언하면 패딩이 끝부분(tail)으로만 모여 전체 크기가 줄어든다.

원리

Q3에서 본 정렬 규칙 자체(필드는 자기 alignment 배수 위치에서 시작)는 모든 컴파일러에 공통인 원리다. 그런데 "그 필드들을 어떤 순서로 메모리에 늘어놓을지" 자체를 재배치해 최적화할지는 언어/컴파일러의 구현 선택이다. Swift Unboxed의 실측이 보여주듯 Swift는 필드를 자동으로 재정렬해주지 않고 선언 순서를 그대로 레이아웃 순서로 쓰므로, 개발자가 순서를 바꾸는 것 자체가 실제 패딩 양을 바꾼다.

내부 동작

필드 4개를 가진 struct로 직접 확인해보자.

필드 순서만 바꾼 두 struct — 실제로 측정한 값
struct UserBadge {
    let isVerified: Bool   // 1B, align 1
    let level: Int16       // 2B, align 2
    let score: Int64       // 8B, align 8
    let tier: UInt8        // 1B, align 1
}
struct UserBadgeReordered {
    let score: Int64       // 8B, align 8 — 가장 큰 정렬 요구를 먼저
    let level: Int16       // 2B, align 2
    let tier: UInt8        // 1B, align 1
    let isVerified: Bool   // 1B, align 1
}

print(MemoryLayout<UserBadge>.size, MemoryLayout<UserBadge>.stride)                   // 17 24
print(MemoryLayout<UserBadgeReordered>.size, MemoryLayout<UserBadgeReordered>.stride)  // 12 16

필드 구성은 완전히 같은데도 stride가 24바이트에서 16바이트로, 약 33% 줄어든다. 원래 순서(UserBadge)에서는 isVerified(1B) 뒤에 level(align 2)을 2의 배수 위치에 놓기 위한 패딩 1바이트, level 뒤에 score(align 8)를 8의 배수 위치에 놓기 위한 패딩 4바이트, 그리고 전체 크기를 8의 배수로 맞추는 tail 패딩 7바이트까지 총 12바이트가 낭비된다(24바이트 중 절반). 재배치한 쪽(UserBadgeReordered)은 정렬 요구가 큰 필드부터 순서대로 놓아 필드 사이 패딩이 전혀 없고, 마지막에 전체 크기를 8의 배수로 맞추는 tail 패딩 4바이트만 남는다.

이전 순서 — isVerified, level, score, tier isV. level score (8B) tier 패딩 (1+4+7 = 12B) size 17 / stride 24 재배치 — score, level, tier, isVerified score (8B) level tier isV. tail 패딩(4B) size 12 / stride 16 — 약 33% 절약
필드 사이에 흩어져 있던 패딩(빗금)을 tail 하나로 몰면 stride가 24바이트에서 16바이트로 줄어든다.

실험 · 도구

위 두 struct를 그대로 파일에 저장해 swift 파일명.swift로 실행하면 17/24와 12/16이 그대로 찍힌다. 대량 배열에서 체감하려면 다음처럼 전체 메모리 절약량을 직접 계산해본다.

10만 건 기준 절약량 직접 계산
let count = 100_000
let before = MemoryLayout<UserBadge>.stride * count           // 2,400,000B ≈ 2.29MB
let after  = MemoryLayout<UserBadgeReordered>.stride * count  // 1,600,000B ≈ 1.53MB
print("절약:", before - after, "바이트")                        // 800,000B ≈ 0.76MB

프로젝트 적용

대량으로(수만~수십만 건) 배열에 담길 struct는 필드를 정렬 요구가 큰 타입(Int64, Double, 참조 타입 등 8바이트급)부터, 작은 타입(Bool, UInt8, enum raw 등 1바이트급)은 뒤로 몰아 선언한다.

서버 응답을 대량으로 파싱해 배열에 담는 모델 — 필드 순서 정리
// ❌ 선언 순서가 뒤섞여 있으면 필드 사이사이에 패딩이 낀다
struct LogEntryBad {
    let isError: Bool       // 1B
    let timestamp: Int64    // 8B — 이 앞에 패딩 7B
    let level: UInt8        // 1B
    let sequence: Int32     // 4B — 이 앞에 패딩 3B
}

// ✅ 큰 정렬 요구 → 작은 정렬 요구 순서로 정리
struct LogEntryGood {
    let timestamp: Int64    // 8B
    let sequence: Int32     // 4B
    let level: UInt8        // 1B
    let isError: Bool       // 1B — tail 패딩만 최소로 남음
}

정렬 최적화가 항상 큰 이득은 아니다. 필드가 전부 같은 alignment라면 순서를 바꿔도 아무것도 달라지지 않는다.

모든 필드가 같은 alignment면 순서는 크기에 영향이 없다
struct AllFlags {
    let a: Bool
    let b: Bool
    let c: Bool
    let d: Bool
}
struct AllFlagsReordered {
    let d: Bool
    let c: Bool
    let b: Bool
    let a: Bool
}
print(MemoryLayout<AllFlags>.size, MemoryLayout<AllFlags>.stride)                  // 4 4
print(MemoryLayout<AllFlagsReordered>.size, MemoryLayout<AllFlagsReordered>.stride) // 4 4 — 완전히 동일
⚠️ 흔한 오해

"struct 필드 순서는 그냥 가독성 문제일 뿐 런타임엔 아무 영향이 없다"는 틀렸다 — 위 UserBadge/UserBadgeReordered 실측이 보여주듯 순서만으로 stride가 33% 달라진다. 반대로 "필드 순서를 정렬 요구 순으로 최적화하면 패딩을 완전히 0으로 만들 수 있다"도 과장이다 — tail 패딩은 "전체 크기가 가장 큰 alignment의 배수여야 한다"는 규칙 때문에 남는 하한선이 있어, 재배치를 아무리 잘해도 완전히 없앨 수는 없는 경우가 많다.

🧒 쉽게 이해하기

이삿짐 트럭에 상자를 실을 때, 크고 무거운 상자(정렬 요구가 큰 필드)를 먼저 딱딱 맞춰 넣고, 자잘한 상자(정렬 요구가 작은 필드)를 그 사이 빈틈에 채우면 트럭 공간이 알뜰하게 찬다. 순서를 거꾸로 해서 작은 상자부터 아무렇게나 넣으면, 큰 상자를 넣을 때마다 자리를 맞추느라 곳곳에 빈 공간(패딩)이 남는다. 비유가 깨지는 곳: 이삿짐은 작은 상자를 아무 데나 끼워도 되지만, 메모리는 "이 타입은 반드시 자기 정렬 배수 위치에서만 시작해야 한다"는 규칙을 하드웨어가 강제한다는 점에서 이삿짐보다 훨씬 엄격하다.

꼬리 질문

필드가 전부 같은 타입(예: Bool 8개)이면 순서를 바꿔도 크기가 달라지는가?
달라지지 않는다. 모든 필드의 alignment가 같으면 어떤 순서로 배치해도 필드 사이에 낄 패딩이 생기지 않는다. 위 AllFlags/AllFlagsReordered가 둘 다 size 4, stride 4로 동일한 것이 그 증거다. 정렬 요구가 서로 다른 필드들이 섞여 있을 때만 순서가 패딩 양에 영향을 준다.
쉽게 말하면 크기가 똑같은 상자들만 있으면 어떤 순서로 쌓아도 빈틈이 안 생기는 것과 같다.
필드 순서를 아무리 잘 최적화해도 없앨 수 없는 패딩이 있는 이유는?
UserBadgeReordered의 실제 데이터는 12바이트인데 stride는 16바이트다 — 필드 사이 패딩은 0으로 없앴지만, "전체 stride는 그 타입에서 가장 큰 alignment(여기선 8)의 배수여야 한다"는 규칙 때문에 4바이트짜리 tail 패딩은 재배치로도 없앨 수 없다. 이 하한선은 배열에 이 타입을 여러 개 늘어놓아도 각 원소가 다시 8바이트 배수 위치에서 시작하도록 보장하기 위해 필요하다.
쉽게 말하면 아무리 짐을 알뜰하게 싸도 트럭 칸막이 규격(가장 큰 상자 기준) 자체는 못 줄이는 것과 같다.
이 최적화는 class의 인스턴스 프로퍼티에도 똑같이 의미가 있는가?
class 내부 인스턴스 레이아웃에도 같은 정렬 규칙은 적용되지만, 이 최적화가 주는 이득은 크지 않다. 배열에 class 인스턴스를 담으면 배열이 실제로 저장하는 건 인스턴스 하나하나가 아니라 8바이트 포인터 하나씩이라, 원소 사이 stride는 항상 포인터 크기로 고정된다. 패딩 절약이 크게 체감되는 경우는 값 타입을 배열에 인라인으로 대량 배치할 때다.
쉽게 말하면 학생 명부에 "학생 본인"을 통째로 줄 세우면 자리 낭비가 클 수 있지만, "학생이 있는 교실 번호"만 적으면 번호 하나 적는 칸 크기는 항상 똑같아서 순서 재배치가 별 의미가 없다.

Q5. actor는 왜 참조 타입인가? class와 무엇이 같고 무엇이 다른가?

🔑 30초 답변

actor는 "격리된 가변 상태를 가진 단 하나의 인스턴스"라는 개념 자체가 참조 정체성을 요구하기 때문에 참조 타입이다. 값 타입이었다면 대입할 때마다 상태가 통째로 복사돼 "이 액터의 상태"라는 단일한 진실의 원천이 사라진다. class처럼 힙에 할당되고 참조 카운트로 관리되며 ===로 정체성을 비교할 수 있지만, class와 달리 상속이 불가능하고, 저장 프로퍼티 접근이 격리 규칙의 통제를 받으며, 직렬 실행기(serial executor)가 한 번에 한 태스크만 그 격리 상태에 접근하게 만든다.

원리

SE-0306은 actor를 "자신의 메일박스에 있는 메시지를 한 번에 하나씩 처리해, 어떤 액터도 자신의 격리된 코드를 동시에 실행하는 두 개의 태스크를 갖지 않는다"고 정의한다. 다른 액터로 값을 넘기려면 그 타입이 Sendable을 만족해야 한다는 규칙도 여기서 나온다 — 격리 경계를 넘는 데이터는 "동시에 안전하게 공유해도 되는 값"이어야 하기 때문이다.

내부 동작

actor의 상호 배제(mutual exclusion)는 스레드 락이 아니라 메시지(파셜 태스크) 큐잉으로 구현된다. 동기 코드를 실행하는 동안엔 배타적이지만, await 지점(suspension point)에서는 다른 태스크가 끼어들 수 있다 — 이게 reentrancy(재진입)다. 즉 actor는 스레드 안전은 보장해도 "await 앞뒤로 상태가 그대로일 것"이라는 불변식까지 보장하지는 않는다. actor 재진입이 실제로 어떤 버그를 만드는지, await 지점에서 무엇을 조심해야 하는지는 Swift 동시성 인터뷰 강의에서 깊이 다룬다 — 여기서는 "actor가 왜 참조 타입인가, 그게 정체성·격리에 무엇을 의미하는가"에만 집중한다.

항목classactor
메모리 상 정체성참조 타입, 힙 할당, ===로 비교참조 타입, 힙 할당, ===로 비교
상속가능(단일 상속)불가 — actor 계층 구조 자체가 없다
동시 접근 안전성보장 없음 — 직접 락/큐로 방어해야 한다직렬 실행기가 한 번에 한 태스크만 격리 상태에 접근시킨다
격리 경계 밖 접근일반 프로퍼티 접근과 동일await 필요(단, nonisolated는 예외)
다른 도메인으로 전달타입 제약 없음(단 데이터 레이스 위험은 그대로)보통 Sendable 타입만 안전하게 주고받는다

실험 · 도구

정체성만 놓고 보면 actor도 class와 똑같이 동작한다는 걸 직접 확인할 수 있다.

actor도 참조 복사 — 같은 인스턴스를 가리킨다
actor SessionCache {
    private var tokens: [String: String] = [:]
    func store(_ token: String, for userID: String) { tokens[userID] = token }
    func token(for userID: String) -> String? { tokens[userID] }
}

func demo() async {
    let cache = SessionCache()
    let cache2 = cache                       // 참조 복사 — 같은 액터 인스턴스를 가리킴
    await cache.store("abc123", for: "u1")
    let t = await cache2.token(for: "u1")
    print(t ?? "nil")                        // "abc123" — cache와 cache2는 같은 상태를 본다
}

swiftc -emit-sil로 actor 메서드를 컴파일하면 hop_to_executor 명령어가 삽입되는 것을 볼 수 있다 — 격리 경계를 넘을 때마다 "지금 이 액터의 실행기 위에 있는지" 확인하고 아니면 건너뛰는(hop) 코드다.

프로젝트 적용

여러 화면·태스크가 공유해야 하는 가변 상태(세션 토큰, 진행 중인 다운로드 목록 등)는 actor로 감싸 데이터 레이스를 컴파일 타임에 막는다.

공유 상태를 actor로 감싸 동시 접근을 안전하게
actor DownloadTracker {
    private var inFlight: Set<URL> = []

    func begin(_ url: URL) -> Bool {
        guard !inFlight.contains(url) else { return false }  // 이미 진행 중이면 중복 요청 방지
        inFlight.insert(url)
        return true
    }
    func finish(_ url: URL) { inFlight.remove(url) }
}

정체성 공유가 필요 없는 단순 값 컨테이너까지 actor로 만들면 모든 접근에 await가 붙어 호출부가 무거워진다. 공유·동시 접근 보호가 실제로 필요한 경계에만 actor를 쓰고, 나머지는 struct로 남긴다.

공유가 필요 없다면 actor로 감쌀 이유가 없다
// ✅ 정체성 공유가 필요 없다 — 그냥 값 타입으로 충분
struct RequestBudget {
    private(set) var remaining: Int
    mutating func consume() { remaining -= 1 }
}

// ❌ 단순 값 컨테이너를 actor로 감싸면 호출부마다 await가 강제된다
actor RequestBudgetActor {
    private var remaining: Int
    init(_ remaining: Int) { self.remaining = remaining }
    func consume() { remaining -= 1 }
}

func demoOveruse() async {
    var budget = RequestBudget(remaining: 3)
    budget.consume()              // 동기 호출 — 가볍다

    let budgetActor = RequestBudgetActor(3)
    await budgetActor.consume()   // 공유할 대상이 없는데도 await가 강제된다
}
⚠️ 흔한 오해

"actor는 항상 안전하니 await 전후로 상태가 그대로일 것이다"는 틀렸다. reentrancy 때문에 await 하는 동안 다른 태스크가 같은 액터의 상태를 바꿔놓을 수 있다. actor가 보장하는 건 "동시에 두 코드가 같은 상태를 동시에 실행하지 않는다"는 것이지, "내가 await하고 돌아왔을 때 상태가 내가 떠났을 때와 같다"는 게 아니다.

🧒 쉽게 이해하기

학교 상담실에 선생님 한 분(액터 인스턴스)이 있다. 여러 학생(태스크)이 동시에 문을 두드려도 한 번에 한 명씩만 들어가 이야기한다. 한 학생이 상담실 위치를 친구에게 알려줘도(참조를 복사해도) 상담실이 복제되는 게 아니라 같은 상담실 문을 두드리게 된다(정체성 공유). 비유가 깨지는 곳: 실제로는 학생이 상담 도중 잠깐 자리를 비우면(await) 다른 학생이 그 틈에 끼어들어 선생님과 먼저 이야기를 시작할 수도 있다는 재진입성까지는 이 비유로 다 설명하기 어렵다 — 이 부분은 동시성 강의에서 깊게 다룬다.

꼬리 질문

actor끼리는 상속이 왜 안 되는가?
Swift는 actor 계층 구조 자체를 지원하지 않는다. class처럼 부모 actor를 상속하는 문법이 없고, "class actor" 같은 것도 없다. actor의 핵심은 상태를 격리하고 접근을 직렬화하는 것이라, 상속이 주는 "동적 오버라이드"는 오히려 격리 경계를 예측하기 어렵게 만들기 쉬워 설계에서 배제됐다.
쉽게 말하면 상담실 여러 개를 계층으로 겹쳐 놓는 것 자체가 "한 번에 한 명만"이라는 규칙을 헷갈리게 만들기 때문에 애초에 그런 구조를 안 만든 것이다.
actor 프로퍼티를 외부에서 읽으려면 왜 항상 await가 필요한가?
격리 경계를 넘는 접근은 잠재적으로 실행기를 갈아타야(hop) 할 수 있는 지점이라, 컴파일러는 이를 "잠재적으로 suspend할 수 있는 지점"으로 취급해 await를 요구한다. 다만 nonisolated로 선언한 프로퍼티나 메서드는 애초에 격리 상태에 접근하지 않으므로 예외적으로 await 없이 호출할 수 있다.
쉽게 말하면 상담실 안의 이야기를 들으려면 항상 "문을 열고 들어가는" 절차(await)가 필요하지만, 상담실 문에 붙은 안내판(nonisolated)을 읽는 건 문을 열 필요가 없는 것과 같다.
actor 재진입이 실제로 버그를 만드는 시나리오를 하나만 짚어준다면?
가장 흔한 패턴은 "잔액을 확인하고 그 값을 바탕으로, 중간에 await를 거친 뒤에 차감하는" 코드다. 확인과 차감 사이에 await가 끼어 있으면, 그 틈에 다른 태스크가 같은 액터의 잔액을 이미 바꿔놨을 수 있어 확인했던 값이 더 이상 유효하지 않다. 이 패턴과 방어법의 자세한 분석은 Swift 동시성 인터뷰 강의에서 다룬다.
쉽게 말하면 통장 잔액을 확인한 다음 은행 창구에 가는 사이(await) 다른 사람이 먼저 그 돈을 인출해버릴 수 있는 것과 같다.

Q6. 상속 대신 프로토콜 + 컴포지션을 택하면 무엇을 얻고 무엇을 잃는가?

🔑 30초 답변

클래스 상속은 "공유 저장 상태 + 동적 오버라이드"를 계층 하나로 묶어 공짜로 주지만, 계층이 깊어질수록 서로 무관한 기능들까지 강제로 물려받게 되고 클래스는 다중 상속이 안 돼 여러 축의 기능을 동시에 섞기 어렵다. 프로토콜 + 컴포지션은 필요한 능력만 골라 여러 개를 동시에 채택할 수 있어 결합이 느슨해지지만, 프로토콜 자체는 저장 프로퍼티를 가질 수 없어 상태는 각 타입이 직접 들고 있어야 하고, extension의 기본 구현은 정적 디스패치라 클래스처럼 "재정의가 항상 다형적으로 호출된다"는 보장이 없다.

원리

클래스는 기본적으로 프로퍼티·메서드에 동적 디스패치(vtable)를 쓴다. 프로토콜의 동적 디스패치는 witness table로 구현되며, 타입마다 하나씩 생긴다. 상속은 "is-a" 관계를 표현하고 단일 계층만 허용하는 반면, 프로토콜 채택은 "can-do"(이 능력이 있다) 관계를 표현하고 한 타입이 여러 프로토콜을 동시에 채택할 수 있다.

내부 동작

중요한 함정은 프로토콜 extension의 기본 구현이 정적 디스패치로 처리된다는 점이다. 프로토콜이 요구사항(requirement)으로 선언한 메서드는 동적 디스패치라 각 타입의 구현이 호출되지만, extension에만 있고 프로토콜 시그니처엔 없는 메서드는 정적 디스패치다. 그래서 그런 메서드를 "재정의"해도, 프로토콜 타입 변수를 통해 부르면 실제 타입의 구현이 아니라 extension의 기본 구현이 호출되는 경우가 생긴다. 클래스 상속의 override에는 이런 함정이 없다 — vtable 조회는 항상 "가장 파생된 오버라이드"를 찾는다.

Before — 상속 BaseViewController 스피너 + 에러 배너 공통 로직 ProfileVC SettingsVC FeedVC 세 화면 모두 스피너·에러 로직을 통째로 물려받는다 After — 프로토콜 + 컴포지션 ProfileVC Loading Error SettingsVC Loading FeedVC Error 각 화면이 필요한 능력만 선택해 조합한다 — 공통 저장 상태는 없다, 각자 선언해야 한다
상속은 계층에 있는 모든 기능을 강제로 물려주지만, 프로토콜 + 컴포지션은 필요한 능력만 골라 조합한다 — 대신 공유 저장 상태는 사라진다.

실험 · 도구

swiftc -O -emit-sil로 컴파일해 class_method(vtable을 거치는 동적 호출) 명령어와 function_ref(직접 호출) 명령어를 비교하면, 상속 계층의 오버라이드와 프로토콜 extension 기본 구현이 SIL 레벨에서 다르게 컴파일되는 걸 확인할 수 있다.

프로젝트 적용

iOS에서 흔한 패턴 하나 — 여러 화면이 "로딩 스피너"와 "에러 배너"라는 서로 무관한 두 기능을 쓰려고 공통 베이스 뷰컨트롤러를 상속하는 구조다. SettingsVC는 스피너만 필요한데도 showError까지 함께 물려받는다.

Before — 모든 화면이 원치 않는 기능까지 강제로 물려받는 상속
class BaseViewController: UIViewController {
    private let spinner = UIActivityIndicatorView(style: .medium)
    func showSpinner() { spinner.startAnimating() }
    func hideSpinner() { spinner.stopAnimating() }
    func showError(_ message: String) { /* 배너 표시 */ }
}

// SettingsVC는 스피너만 필요한데도 showError까지 함께 상속받는다
final class SettingsViewController: BaseViewController { /* ... */ }

이를 프로토콜 + 컴포지션으로 바꾸면 각 화면이 필요한 능력만 채택한다. 트레이드오프는 정직하게 남는다 — BaseViewControllerprivate let spinner를 한 번만 선언하면 모든 서브클래스가 공짜로 공유했지만, 아래 버전에서는 loadingIndicator채택하는 타입마다 각자 선언해야 한다. 또 showSpinner()를 어떤 화면이 자기 방식대로 다시 구현하고 싶다면, 그 재구현이 프로토콜 타입 변수를 통해 호출될 때도 항상 불리는지 반드시 확인해야 한다 — extension 기본 구현은 정적 디스패치이기 때문이다.

After — 필요한 능력만 선택적으로 조합
protocol LoadingPresentable: AnyObject {
    var loadingIndicator: UIActivityIndicatorView { get }
}
extension LoadingPresentable where Self: UIViewController {
    func showSpinner() { loadingIndicator.startAnimating() }
    func hideSpinner() { loadingIndicator.stopAnimating() }
}

protocol ErrorPresentable: AnyObject {}
extension ErrorPresentable where Self: UIViewController {
    func showError(_ message: String) { /* 배너 표시 */ }
}

// ProfileVC는 두 능력 다 필요
final class ProfileViewController: UIViewController, LoadingPresentable, ErrorPresentable {
    let loadingIndicator = UIActivityIndicatorView(style: .medium)
}

// SettingsVC는 로딩만 필요 — ErrorPresentable을 강제로 물려받지 않는다
final class SettingsViewController: UIViewController, LoadingPresentable {
    let loadingIndicator = UIActivityIndicatorView(style: .medium)
}
⚠️ 흔한 오해

"프로토콜 지향 프로그래밍으로 바꾸면 상속의 코드 중복 문제가 전부 사라진다"는 과장이다. 공유 저장 상태를 잃는 대가는 실제로 남아 있고, 그만큼의 보일러플레이트(각 타입이 프로퍼티를 직접 선언하는 일)가 생긴다. "프로토콜 extension에만 있는 메서드도 상속의 override처럼 항상 동적으로 재정의된다"도 틀렸다 — 프로토콜 요구사항에 없는 extension 전용 메서드는 정적 디스패치다.

🧒 쉽게 이해하기

상속은 부모님 집을 그대로 물려받아 그 집 구조(방 배치, 가구)를 그대로 쓰는 것이다. 프로토콜 + 컴포지션은 필요한 가구(능력)만 골라 내 집에 들여놓는 것이다. 가구는 집마다 각자 사야 하지만(=상태를 각자 보관해야 하지만), 부모님 집 구조에 얽매이지 않고 내 집을 자유롭게 짤 수 있다. 비유가 깨지는 곳: 가구를 내가 새로 산 것처럼 다시 마련해도(=재정의해도), 택배 기사(프로토콜 타입 변수를 통한 호출)가 옛날 카탈로그 사진(정적 디스패치)만 보고 예전 가구를 배달해버릴 수 있다는 점은 집·가구 비유로는 설명이 안 된다.

꼬리 질문

프로토콜에 저장 프로퍼티가 없는데 `{ get set }` 요구사항은 어떻게 채우는가?
프로토콜은 "이런 프로퍼티가 있어야 한다"는 요구만 하지, 그 프로퍼티를 대신 보관해주지 않는다. 채택하는 타입이 저장 프로퍼티로 직접 선언하거나, 연산 프로퍼티로 다른 곳에 위임해 요구사항을 채워야 한다. 위 예제의 loadingIndicator가 바로 그 경우다 — 각 뷰컨트롤러가 직접 저장 프로퍼티로 들고 있다.
쉽게 말하면 "TV가 있어야 한다"는 규칙만 있고 TV 자체를 나눠주진 않으니, 그 규칙을 지키려는 사람이 자기 돈으로 TV를 사야 하는 것과 같다.
여러 프로토콜을 조합하다 이름이 겹치는 메서드가 있으면 어떻게 되는가?
두 프로토콜의 extension 기본 구현이 같은 이름·시그니처를 가지면 모호성(ambiguous) 컴파일 에러가 난다. 이럴 땐 (self as SpecificProtocol).method()처럼 어느 프로토콜의 구현인지 명시적으로 캐스팅해 지정해야 한다.
쉽게 말하면 두 친구가 똑같이 생긴 리모컨을 줬는데 어느 리모컨으로 TV를 켜야 할지 헷갈리는 것과 같다. "이건 A 친구가 준 리모컨으로"라고 콕 집어 말해줘야 한다.
그래도 상속이 프로토콜 + 컴포지션보다 나은 경우는 언제인가?
여러 서브타입이 정말로 같은 가변 상태를 공유하고, 그 상태에 대한 다형적 오버라이드 자체가 설계의 핵심일 때는 상속이 더 적합할 수 있다. 또한 UIView/UIViewController처럼 프레임워크 자체가 이미 클래스 상속 기반으로 설계돼 있어 그 계층에 올라타야만 하는 경우도 현실적으로는 상속을 피할 수 없다.
쉽게 말하면 애초에 한 건물(프레임워크)이 계단식 구조로 지어져 있으면, 그 건물에 들어가려면 그 계단을 써야 하는 것과 같다.

출처 · 참고자료