struct와 class 중 무엇을 쓸지는 문법 취향이 아니라 값 의미론(value semantics)과 참조 의미론(reference semantics)이 실제로 만드는 동작 차이의 문제다. 이 챕터는 그 선택이 메모리에서 무엇을 의미하는지 — 대입이 복사인지 공유인지, Copy-on-Write가 언제 실제 복제를 트리거하는지, 필드 하나 순서만 바꿔도 struct 크기가 왜 달라지는지 — 실제 바이트 수치로 확인한다. 마지막엔 actor가 왜 참조 타입일 수밖에 없는지, 상속 대신 프로토콜 + 컴포지션을 택했을 때 정말로 무엇을 잃는지까지 다룬다.
Q1. struct와 class는 무엇을 기준으로 고르는가? — 값/참조 의미론이 만드는 실제 차이
정체성(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라는 이유만으로 참조 카운팅에서 자유로워지는 게 아니다.
실험 · 도구
다음 코드를 파일로 저장해 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로 시작한다. 여러 스레드에서 읽어도 값이 그대로 복사돼 전달되므로 데이터 레이스 걱정이 줄어든다.
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로 바꾸면 대입마다 복사가 일어나 서로 다른 캐시를 보게 되는 버그가 생긴다.
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에는 기본으로 없는가?
===는 "같은 메모리 인스턴스인가"를 묻는 연산자라 정체성 개념이 있는 AnyObject(class, actor)에만 정의돼 있다. struct는 애초에 "이 인스턴스"라는 정체성이 없고 값만 있으므로, 비교하려면 Equatable을 채택해 ==(값이 같은가)를 구현하는 것이 자연스러운 선택이다.actor도 class처럼 참조 타입인데, struct/class 선택 기준이 actor에도 그대로 적용되는가?
Q2. Copy-on-Write는 어떻게 동작하며, 커스텀 타입에 직접 구현하려면 무엇이 필요한가?
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 = [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만 유일성 검사를 한다.
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하면 참조 카운트는 어떻게 바뀌는가?
isKnownUniquelyReferenced가 false인데도 복제를 생략하면 어떤 버그가 생기나?
BuggyTagList가 정확히 이 사례다.필드가 전부 값 타입(Int, Bool 등)뿐인 struct에도 CoW가 필요한가?
Q3. `MemoryLayout`의 size / stride / alignment는 각각 무엇이며 왜 다른가?
size는 값이 실제로 차지하는 바이트 수, alignment는 그 타입이 메모리에서 시작할 수 있는 주소가 반드시 배수여야 하는 단위(하드웨어가 정렬 안 된 접근을 못 하거나 느리게 처리하기 때문), stride는 배열처럼 같은 타입을 연달아 배치할 때 한 원소에서 다음 원소로 넘어가는 실제 간격이다. stride는 size를 alignment의 배수로 올림한 값이라 항상 size ≤ stride다. 이 세 값은 옛 sizeof/strideof/alignof 자유 함수를 대체해 SE-0136으로 MemoryLayout<T> 네임스페이스 아래로 옮겨졌다.
원리
가장 단순한 경우, 4바이트 정수 하나만 있는 타입은 세 값이 전부 같다: MemoryLayout<Int32>.size, .stride, .alignment 모두 4다. 세 값이 갈라지는 건 서로 다른 정렬 요구를 갖는 필드 여러 개가 한 타입 안에 섞일 때부터다.
내부 동작
다음 struct로 확인해보자.
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)를 그대로 물려받는다.
실험 · 도구
위 코드를 그대로 파일에 저장해 swift 파일명.swift로 돌리면 세 숫자를 바로 확인할 수 있다. existential 타입에서도 같은 API가 그대로 작동한다 — any 프로토콜 값 하나를 담는 상자(existential container)는 값 버퍼 3워드 + 메타데이터 1워드 + 위트니스 테이블 1워드, 총 5워드(64비트에서 40바이트)로 고정돼 있는데, 다음 코드로 직접 확인할 수 있다.
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-sil로 init_existential_addr 같은 명령을 검색해 컨테이너가 실제로 어떻게 만들어지는지 볼 수 있다.
프로젝트 적용
① 대량의 레코드를 배열에 담을 때는 size가 아니라 stride 기준으로 전체 메모리를 추정해야 실제 footprint를 맞출 수 있다.
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를 써야 다음 원소 경계를 침범하지 않는다.
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보다 작을 수는 없는가?
alignment가 1인 타입도 있는가?
UInt8, Int8, Bool처럼 1바이트짜리 원시 타입은 alignment가 1이다. 어떤 주소든 1의 배수이므로 사실상 정렬 제약이 없는 셈이고, 이런 필드는 다른 필드 사이의 빈틈을 메우는 용도로 배치하기 좋다(Q4에서 이 성질을 활용한다).Q4. 프로퍼티 선언 순서만 바꿔도 struct 크기가 달라지는 이유는?
컴파일러는 각 필드를 "그 필드 타입의 alignment 배수인 오프셋"에만 배치할 수 있고, Swift는 현재 관찰되는 구현 동작상 대체로 선언한 순서 그대로 필드를 배치한다(단, 이는 언어 스펙이 보장하는 게 아니라 관찰된 컴파일러 동작이다). 그래서 정렬 요구가 큰 필드와 작은 필드가 뒤섞여 선언되면 필드 사이사이에 패딩이 낭비되고, 정렬 요구가 큰 필드부터 몰아서 선언하면 패딩이 끝부분(tail)으로만 모여 전체 크기가 줄어든다.
원리
Q3에서 본 정렬 규칙 자체(필드는 자기 alignment 배수 위치에서 시작)는 모든 컴파일러에 공통인 원리다. 그런데 "그 필드들을 어떤 순서로 메모리에 늘어놓을지" 자체를 재배치해 최적화할지는 언어/컴파일러의 구현 선택이다. Swift Unboxed의 실측이 보여주듯 Swift는 필드를 자동으로 재정렬해주지 않고 선언 순서를 그대로 레이아웃 순서로 쓰므로, 개발자가 순서를 바꾸는 것 자체가 실제 패딩 양을 바꾼다.
내부 동작
필드 4개를 가진 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바이트만 남는다.
실험 · 도구
위 두 struct를 그대로 파일에 저장해 swift 파일명.swift로 실행하면 17/24와 12/16이 그대로 찍힌다. 대량 배열에서 체감하려면 다음처럼 전체 메모리 절약량을 직접 계산해본다.
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라면 순서를 바꿔도 아무것도 달라지지 않는다.
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개)이면 순서를 바꿔도 크기가 달라지는가?
AllFlags/AllFlagsReordered가 둘 다 size 4, stride 4로 동일한 것이 그 증거다. 정렬 요구가 서로 다른 필드들이 섞여 있을 때만 순서가 패딩 양에 영향을 준다.필드 순서를 아무리 잘 최적화해도 없앨 수 없는 패딩이 있는 이유는?
UserBadgeReordered의 실제 데이터는 12바이트인데 stride는 16바이트다 — 필드 사이 패딩은 0으로 없앴지만, "전체 stride는 그 타입에서 가장 큰 alignment(여기선 8)의 배수여야 한다"는 규칙 때문에 4바이트짜리 tail 패딩은 재배치로도 없앨 수 없다. 이 하한선은 배열에 이 타입을 여러 개 늘어놓아도 각 원소가 다시 8바이트 배수 위치에서 시작하도록 보장하기 위해 필요하다.이 최적화는 class의 인스턴스 프로퍼티에도 똑같이 의미가 있는가?
Q5. actor는 왜 참조 타입인가? class와 무엇이 같고 무엇이 다른가?
actor는 "격리된 가변 상태를 가진 단 하나의 인스턴스"라는 개념 자체가 참조 정체성을 요구하기 때문에 참조 타입이다. 값 타입이었다면 대입할 때마다 상태가 통째로 복사돼 "이 액터의 상태"라는 단일한 진실의 원천이 사라진다. class처럼 힙에 할당되고 참조 카운트로 관리되며 ===로 정체성을 비교할 수 있지만, class와 달리 상속이 불가능하고, 저장 프로퍼티 접근이 격리 규칙의 통제를 받으며, 직렬 실행기(serial executor)가 한 번에 한 태스크만 그 격리 상태에 접근하게 만든다.
원리
SE-0306은 actor를 "자신의 메일박스에 있는 메시지를 한 번에 하나씩 처리해, 어떤 액터도 자신의 격리된 코드를 동시에 실행하는 두 개의 태스크를 갖지 않는다"고 정의한다. 다른 액터로 값을 넘기려면 그 타입이 Sendable을 만족해야 한다는 규칙도 여기서 나온다 — 격리 경계를 넘는 데이터는 "동시에 안전하게 공유해도 되는 값"이어야 하기 때문이다.
내부 동작
actor의 상호 배제(mutual exclusion)는 스레드 락이 아니라 메시지(파셜 태스크) 큐잉으로 구현된다. 동기 코드를 실행하는 동안엔 배타적이지만, await 지점(suspension point)에서는 다른 태스크가 끼어들 수 있다 — 이게 reentrancy(재진입)다. 즉 actor는 스레드 안전은 보장해도 "await 앞뒤로 상태가 그대로일 것"이라는 불변식까지 보장하지는 않는다. actor 재진입이 실제로 어떤 버그를 만드는지, await 지점에서 무엇을 조심해야 하는지는 Swift 동시성 인터뷰 강의에서 깊이 다룬다 — 여기서는 "actor가 왜 참조 타입인가, 그게 정체성·격리에 무엇을 의미하는가"에만 집중한다.
| 항목 | class | actor |
|---|---|---|
| 메모리 상 정체성 | 참조 타입, 힙 할당, ===로 비교 | 참조 타입, 힙 할당, ===로 비교 |
| 상속 | 가능(단일 상속) | 불가 — actor 계층 구조 자체가 없다 |
| 동시 접근 안전성 | 보장 없음 — 직접 락/큐로 방어해야 한다 | 직렬 실행기가 한 번에 한 태스크만 격리 상태에 접근시킨다 |
| 격리 경계 밖 접근 | 일반 프로퍼티 접근과 동일 | await 필요(단, nonisolated는 예외) |
| 다른 도메인으로 전달 | 타입 제약 없음(단 데이터 레이스 위험은 그대로) | 보통 Sendable 타입만 안전하게 주고받는다 |
실험 · 도구
정체성만 놓고 보면 actor도 class와 똑같이 동작한다는 걸 직접 확인할 수 있다.
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 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로 남긴다.
// ✅ 정체성 공유가 필요 없다 — 그냥 값 타입으로 충분
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끼리는 상속이 왜 안 되는가?
actor 프로퍼티를 외부에서 읽으려면 왜 항상 await가 필요한가?
await를 요구한다. 다만 nonisolated로 선언한 프로퍼티나 메서드는 애초에 격리 상태에 접근하지 않으므로 예외적으로 await 없이 호출할 수 있다.actor 재진입이 실제로 버그를 만드는 시나리오를 하나만 짚어준다면?
await를 거친 뒤에 차감하는" 코드다. 확인과 차감 사이에 await가 끼어 있으면, 그 틈에 다른 태스크가 같은 액터의 잔액을 이미 바꿔놨을 수 있어 확인했던 값이 더 이상 유효하지 않다. 이 패턴과 방어법의 자세한 분석은 Swift 동시성 인터뷰 강의에서 다룬다.Q6. 상속 대신 프로토콜 + 컴포지션을 택하면 무엇을 얻고 무엇을 잃는가?
클래스 상속은 "공유 저장 상태 + 동적 오버라이드"를 계층 하나로 묶어 공짜로 주지만, 계층이 깊어질수록 서로 무관한 기능들까지 강제로 물려받게 되고 클래스는 다중 상속이 안 돼 여러 축의 기능을 동시에 섞기 어렵다. 프로토콜 + 컴포지션은 필요한 능력만 골라 여러 개를 동시에 채택할 수 있어 결합이 느슨해지지만, 프로토콜 자체는 저장 프로퍼티를 가질 수 없어 상태는 각 타입이 직접 들고 있어야 하고, extension의 기본 구현은 정적 디스패치라 클래스처럼 "재정의가 항상 다형적으로 호출된다"는 보장이 없다.
원리
클래스는 기본적으로 프로퍼티·메서드에 동적 디스패치(vtable)를 쓴다. 프로토콜의 동적 디스패치는 witness table로 구현되며, 타입마다 하나씩 생긴다. 상속은 "is-a" 관계를 표현하고 단일 계층만 허용하는 반면, 프로토콜 채택은 "can-do"(이 능력이 있다) 관계를 표현하고 한 타입이 여러 프로토콜을 동시에 채택할 수 있다.
내부 동작
중요한 함정은 프로토콜 extension의 기본 구현이 정적 디스패치로 처리된다는 점이다. 프로토콜이 요구사항(requirement)으로 선언한 메서드는 동적 디스패치라 각 타입의 구현이 호출되지만, extension에만 있고 프로토콜 시그니처엔 없는 메서드는 정적 디스패치다. 그래서 그런 메서드를 "재정의"해도, 프로토콜 타입 변수를 통해 부르면 실제 타입의 구현이 아니라 extension의 기본 구현이 호출되는 경우가 생긴다. 클래스 상속의 override에는 이런 함정이 없다 — vtable 조회는 항상 "가장 파생된 오버라이드"를 찾는다.
실험 · 도구
swiftc -O -emit-sil로 컴파일해 class_method(vtable을 거치는 동적 호출) 명령어와 function_ref(직접 호출) 명령어를 비교하면, 상속 계층의 오버라이드와 프로토콜 extension 기본 구현이 SIL 레벨에서 다르게 컴파일되는 걸 확인할 수 있다.
프로젝트 적용
① iOS에서 흔한 패턴 하나 — 여러 화면이 "로딩 스피너"와 "에러 배너"라는 서로 무관한 두 기능을 쓰려고 공통 베이스 뷰컨트롤러를 상속하는 구조다. SettingsVC는 스피너만 필요한데도 showError까지 함께 물려받는다.
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 { /* ... */ }② 이를 프로토콜 + 컴포지션으로 바꾸면 각 화면이 필요한 능력만 채택한다. 트레이드오프는 정직하게 남는다 — BaseViewController는 private let spinner를 한 번만 선언하면 모든 서브클래스가 공짜로 공유했지만, 아래 버전에서는 loadingIndicator를 채택하는 타입마다 각자 선언해야 한다. 또 showSpinner()를 어떤 화면이 자기 방식대로 다시 구현하고 싶다면, 그 재구현이 프로토콜 타입 변수를 통해 호출될 때도 항상 불리는지 반드시 확인해야 한다 — extension 기본 구현은 정적 디스패치이기 때문이다.
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가 바로 그 경우다 — 각 뷰컨트롤러가 직접 저장 프로퍼티로 들고 있다.여러 프로토콜을 조합하다 이름이 겹치는 메서드가 있으면 어떻게 되는가?
(self as SpecificProtocol).method()처럼 어느 프로토콜의 구현인지 명시적으로 캐스팅해 지정해야 한다.그래도 상속이 프로토콜 + 컴포지션보다 나은 경우는 언제인가?
UIView/UIViewController처럼 프레임워크 자체가 이미 클래스 상속 기반으로 설계돼 있어 그 계층에 올라타야만 하는 경우도 현실적으로는 상속을 피할 수 없다.