- struct와 class는 무엇을 기준으로 고르나요?
- "struct가 더 빠르다"는 말은 항상 맞나요?
- Array는 struct인데 복사할 때 왜 안 느린가요?
Sendable 차이가 결정적isKnownUniquelyReferenced로 구현30초 답변 🔥 먼저 이 문장
struct는 값 의미론 — 대입/전달하면 복사되고, 각자가 자기 것만 봅니다. class는 참조 의미론 — 같은 것을 가리킵니다. 그래서 identity(
===)가 있고, 한쪽에서 바꾸면 다른 쪽에도 보입니다.그래서 "이 데이터는 복사되는 게 맞는가, 공유되는 게 맞는가"를 먼저 정하고 성능은 그다음입니다. 다만 Swift 6부터는 여기에 하나가 더 붙습니다 — 값 타입은
Sendable을 거의 공짜로 얻고, class는 그렇지 않습니다.
L1개념 — 차이의 전체 지도
| struct | class | |
|---|---|---|
| 의미론 | 값 (복사) | 참조 (공유) |
| 저장 위치 | 인라인 — 스택 또는 포함하는 객체 안 | 항상 힙 |
| 인스턴스 오버헤드 | 없음 | 객체 헤더 16바이트 (64비트) |
| 수명 관리 | 스코프 — 컴파일 타임에 결정 | ARC — 런타임 카운팅 |
| identity | 없음 (=== 불가) | 있음 |
| 상속 | 불가 (프로토콜로 대체) | 가능 |
| 변경 | var + mutating | let이어도 프로퍼티 변경 가능 |
Sendable | 프로퍼티가 모두 Sendable이면 자동 추론(비-public) | final + 전부 immutable 이어야 수동 채택 가능 |
deinit | 없음 | 있음 |
Sendable 자동 추론의 범위
"struct는 자동으로 Sendable"은 절반만 맞습니다. 자동 추론은 public이 아닌 타입에만 적용됩니다.
public struct는 모듈 밖에서 쓰이므로, 라이브러리 저자가 의도적으로 약속해야 합니다 — 명시적으로 : Sendable을 적어야 하죠.
struct Internal { var x: Int } // ✅ 자동으로 Sendable
public struct Public { public var x: Int } // ❌ 자동 아님
public struct Public2: Sendable { public var x: Int } // ✅ 명시해야 한다L2언어 설계 — 선택 기준과 CoW
먼저: 왜 Swift는 값 타입을 1급으로 올렸나
"기본은 struct"라는 권고는 취향이 아니라 Objective-C 시절의 특정 버그 유형에 대한 대응입니다. 여기를 설명할 수 있으면 설계 의도를 안다는 게 드러납니다.
// ── Objective-C: 참조가 기본이라 "넘긴 뒤"를 통제할 수 없다 ──
- (void)configureWith:(NSMutableArray *)items {
self.items = items; // 💥 호출자가 나중에 items를 바꾸면 내 상태도 바뀐다
}
// 그래서 Cocoa 전반에 이 관행이 생겼다:
- (void)configureWith:(NSArray *)items {
self.items = [items copy]; // 방어적 복사 — "혹시 모르니 일단 복사"
}
// @property (copy) NSString *name; ← copy 어트리뷰트가 흔한 이유가 이것
// 문제점
// ① 개발자가 매번 기억해야 한다 (잊으면 조용히 버그)
// ② 필요 없을 때도 복사한다 (항상 비용을 낸다)
// ③ 얕은 복사라서 중첩 구조는 여전히 샌다
// ── Swift: 복사를 기본값으로 바꿔 이 셋을 한 번에 없앤다 ──
func configure(with items: [Item]) {
self.items = items // ✅ 이미 값이다. 방어적 복사가 불필요하다
}그런데 "복사가 기본"은 곧바로 성능 반론에 부딪힙니다. 원소 10만 개 배열을 함수에 넘길 때마다 복사하면 쓸 수 없는 언어가 되죠. Swift의 판단은 "의미론은 값으로 고정하고, 비용은 표준 라이브러리가 흡수한다"였습니다 — 그 흡수 장치가 바로 다음에 볼 CoW입니다.
① 문제 — 참조가 기본이면 "넘긴 뒤 남이 바꾸는" 버그가 생기고, 방어적 복사라는 수동 관행으로 막아야 했다.
② 선택 — 복사를 기본값으로 만들어 그 관행 자체를 없앤다. 안전을 개발자 기억이 아니라 언어 규칙에 둔다.
③ 대가와 보완 — 복사 비용은 CoW로 지연시킨다. 즉 계약은 값, 구현은 공유다.
그리고 이 선택은 10년 뒤에 배당금을 냅니다. Swift 6가 데이터 경합을 컴파일 타임에 막을 수 있는 이유가
"값 타입은 공유되지 않으므로 Sendable을 자동으로 얻는다"이기 때문입니다.
2014년의 값 타입 결정이 2024년의 동시성 안전을 가능하게 한 셈이고, 이걸 연결해서 말하면 "특징 나열"이 "설계 서사"가 됩니다.
근거 수준에 대해 — Apple의 Choosing Between Structures and Classes는 "기본은 struct"라는 권고와 그 예외를 공식 문서로 밝히고 있습니다. 반면 위의 "방어적 복사 관행을 없애려 했다"는 인과는 Apple이 문서로 못 박은 문장이 아니라, 값 의미론 도입의 배경으로 널리 받아들여지는 해석입니다. 면접에서는 "공식 권고는 이렇고, 그 배경은 이렇게 이해하고 있습니다"로 층을 나눠 말하는 편이 정확합니다.
Apple의 공식 선택 기준
- 기본은 struct.
- ObjC 상호운용이 필요하면 class.
- identity 제어가 필요하면 class — 공유되는 하나의 인스턴스여야 할 때.
- 상속이 필요하면 class — 단, 프로토콜 + 컴포지션을 먼저 검토.
Copy-on-Write — "struct인데 왜 안 느린가"
Array·String·Dictionary·Set은 struct지만 내부에 힙 버퍼 참조를 들고 있습니다.
복사 시점엔 참조만 복사하고(O(1)), 쓰기 시점에 유일 소유자가 아니면 그때 실제로 복사합니다.
final class Storage {
var data: [Int]
init(_ d: [Int]) { data = d }
func copy() -> Storage { Storage(data) }
}
struct Box {
private var storage: Storage
init(_ d: [Int]) { storage = Storage(d) }
var values: [Int] { storage.data } // 읽기는 그냥 통과
mutating func append(_ v: Int) {
if !isKnownUniquelyReferenced(&storage) {
storage = storage.copy() // 나만 쓰는 게 아니면 그때 복사
}
storage.data.append(v)
}
}
String은 small string optimization이 있습니다. 충분히 짧은 문자열(UTF-8 기준 15바이트 이하)은 힙 버퍼 없이 String 값 안에 직접 들어갑니다.
그래서 짧은 문자열은 할당도, ARC도, CoW 검사도 없습니다. "String은 항상 힙"이 아닌 게 정확합니다.
L3구현 — 메모리에서 실제로 무엇이 다른가
Int 하나 담은 클래스가 24바이트 이상을 쓰는 이유이고, 작은 객체를 수만 개 만들 때 문제가 되는 지점이다.MemoryLayout — size / stride / alignment
struct A { var flag: Bool; var num: Int64; var flag2: Bool }
struct B { var flag: Bool; var flag2: Bool; var num: Int64 }
MemoryLayout<A>.size // 17 — Bool(1) + 패딩7 + Int64(8) + Bool(1)
MemoryLayout<A>.alignment // 8 — 가장 엄격한 멤버(Int64)를 따른다
MemoryLayout<A>.stride // 24 — 17을 8의 배수로 올림 (배열에서 다음 원소까지)
MemoryLayout<B>.size // 16 — Bool(1) + Bool(1) + 패딩6 + Int64(8)
MemoryLayout<B>.stride // 16 — 이미 8의 배수라 꼬리 패딩이 없다
// ★ size와 stride의 차이가 여기서 드러난다
// size : 마지막 유효 바이트까지 — 꼬리 패딩을 포함하지 않는다 (A = 17)
// stride : size를 alignment의 배수로 올림한 값 — 꼬리 패딩 포함 (A = 24)
// 배열의 원소 간격을 정하는 건 size가 아니라 stride다.
//
// 선언 순서만 바꿔서 stride가 24 → 16 으로 줄었다.
// 원소 100만 개 배열이면 8MB 차이다.CPU는 메모리를 바이트 단위가 아니라 워드 단위로 읽습니다. 8바이트 값이 8의 배수 주소에 있으면 한 번에 읽지만, 걸쳐 있으면 두 번 읽고 이어 붙여야 합니다. 일부 아키텍처는 아예 정렬되지 않은 접근을 오류로 처리합니다. 그래서 컴파일러가 패딩을 넣어 정렬을 맞춥니다 — 공간을 써서 시간을 사는 것입니다.
L4CS 근본 — 진짜 성능 차이는 캐시에서 난다
"class는 힙 할당이라 느리다"는 흔한 설명인데, 절반만 맞습니다. 할당 비용도 있지만, 실무에서 훨씬 크게 작용하는 건 메모리 계층입니다.
① 메모리 계층 — 숫자를 알면 판단이 바뀐다
위는 사이클 기준이고, ios-cs 12 · CPU와 성능 Q2는 같은 계층을 시간 기준으로 정리합니다 — 레지스터 ≈0.3ns, L1~L3 ≈1–20ns, DRAM ≈50–100ns, SSD 수십~수백µs. 둘은 같은 사실의 다른 표현이고(사이클 ÷ 클럭 = 시간), 면접에서는 정확한 숫자보다 "단계마다 자릿수가 바뀐다"를 말하는 게 안전합니다.
한 가지 iOS 특수 사정도 알아 두면 좋습니다 — Apple Silicon은 통합 메모리(Unified Memory)라 CPU·GPU·Neural Engine이 같은 물리 DRAM 풀을 공유합니다. CPU↔GPU 복사 비용이 줄어드는 대신 공유 대역폭을 아끼는 것이 중요해지고, 이건 11번의 렌더링 비용과 바로 이어집니다.
② 캐시 라인과 공간 지역성
CPU는 메모리를 바이트가 아니라 캐시 라인 단위로 가져옵니다. 라인 크기는 아키텍처마다 다른데, x86은 보통 64바이트, Apple Silicon은 128바이트입니다.
즉 Int 하나(8바이트)를 읽어도 그 주변 120바이트가 함께 캐시에 올라옵니다.
여기서 struct 배열과 class 배열의 결정적 차이가 나옵니다.
struct PointS { var x: Double; var y: Double }
final class PointC { var x = 0.0; var y = 0.0 }
let structs = (0..<1_000_000).map { _ in PointS(x: 1, y: 2) }
let classes = (0..<1_000_000).map { _ in PointC() }
// 순회 — 여기가 진짜 차이가 나는 곳
var sum = 0.0
for p in structs { sum += p.x } // 연속 메모리 → 캐시 라인마다 여러 개 처리
for p in classes { sum += p.x } // 포인터 → 힙 임의 위치 → 미스 + ARC까지
// 주의: 실제 수치는 요소 크기·접근 패턴·최적화 수준에 따라 크게 달라진다.
// 면접에서는 배수를 말하지 말고 "왜 차이가 나는가"를 말하는 게 안전하다.③ 참조 카운팅이 멀티코어에서 비싼 진짜 이유
retain/release는 원자적 증감입니다. 그런데 원자적 연산의 비용은 명령어 몇 개가 아니라 캐시 일관성 트래픽에서 나옵니다.
- 여러 코어가 같은 객체를 retain/release 하면, 그 refCount가 든 캐시 라인의 소유권이 코어 사이를 왕복합니다.
- 이걸 MESI 같은 캐시 일관성 프로토콜이 처리하는데, 소유권 이동마다 코어 간 통신이 발생합니다.
- 결과적으로 공유 객체를 여러 스레드가 만지면 refCount 자체가 병목이 될 수 있습니다.
여기서 "값 타입을 기본으로"라는 조언이 성능 조언이자 동시성 조언으로 합쳐집니다.
값 타입은 공유되지 않으니 refCount 경쟁이 없고, 그래서 Sendable도 자동으로 얻습니다 — 07번 Thread Safety와 만나는 지점입니다.
④ CoW는 OS가 먼저 쓰던 기법이다
Copy-on-Write는 Swift의 발명이 아니라 운영체제의 고전 기법입니다. fork()로 프로세스를 복제할 때 메모리 전체를 복사하지 않고, 페이지를 공유한 뒤 읽기 전용으로 표시합니다. 누군가 쓰려고 하면 그때 페이지 폴트를 일으켜 그 페이지만 복사하죠.
| OS의 fork COW | Swift의 CoW | |
|---|---|---|
| 단위 | 메모리 페이지 (보통 4KB / 16KB) | 타입의 힙 버퍼 |
| 공유 여부 판정 | 페이지 테이블 + MMU 보호 비트 | isKnownUniquelyReferenced (refCount) |
| 복사 트리거 | 쓰기 시 페이지 폴트 (하드웨어) | 쓰기 시 명시적 검사 (소프트웨어) |
| 아이디어 | 동일 — "복사한 것처럼 보이게 하되, 실제 복사는 쓰기 순간까지 미룬다" | |
이 절의 결론은 struct vs class 판단에 직접 걸립니다. 값 의미론은 계약이고, 복사는 구현입니다.
사용자에게는 언제나 "복사됐다"가 보장되지만, 실제 memcpy는 공유가 깨지는 순간에만 일어납니다.
그래서 "struct는 복사 비용 때문에 느리다"는 CoW를 쓰는 타입에는 성립하지 않습니다.
비용이 붙는 쪽은 CoW가 없는 큰 struct(고정 크기 배열을 품은 값 타입 등)이고, 그건 inout·borrowing으로 따로 다뤄야 합니다.
판단할 때 물어야 할 것은 "struct냐 class냐"가 아니라 "이 타입에 CoW가 있는가"입니다.
참고 — 이 "미뤄서 필요할 때만 한다" 전략은 12번(레이아웃 무효화)에서 다른 층으로 한 번 더 나옵니다. 전체 정리는 거기서 합니다.
실무 선택 프레임
| 대상 | 선택 | 이유 |
|---|---|---|
| 모델 · DTO · 좌표 · 설정 | struct | 복사가 맞다. Sendable 자동. 캐시 친화적 |
| 화면 상태 | struct | 스냅샷·diff가 가능해진다 (01번) |
| 캐시 · 네트워크 클라이언트 · 코디네이터 | class 또는 actor | 하나를 공유해야 한다 — identity가 본질 |
| UIKit 컴포넌트 | class | 선택지 없음 |
| 거대한 값 + 잦은 전달 | 측정 후 결정 | 복사 비용 vs 간접 참조 비용. inout·borrowing도 검토 |
CS 정본으로 더 내려가기
이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.
경험으로 말하기
❌ "class를 struct로 바꿔서 빨라졌습니다."
✅ "차트가 데이터 포인트를 final class로 들고 있었고, 수만 개를 매 프레임 순회했습니다.
Time Profiler에서 순회 루프 자체가 아니라 swift_retain/swift_release가 상위에 올라와 있었습니다.
포인트에는 identity가 필요 없었고 공유되지도 않아서 struct로 바꿨습니다. ARC 호출이 사라졌고, 배열이 연속 메모리가 되면서 순회 자체도 빨라졌습니다.
다만 포인트를 담는 컨테이너는 class로 남겼습니다 — 여러 화면이 같은 데이터셋을 봐야 했거든요."
여기엔 측정 → 원인 → 의미론적 정당화 → 안 바꾼 부분까지 들어 있습니다.
꼬리 질문 대비
L2 "struct가 항상 빠르다"는 맞나요?
아닙니다. 이 답을 못 하면 감점입니다.
- 큰 struct를 자주 복사하면 memcpy 비용이 그대로 듭니다. 수백 바이트짜리 struct를 함수 인자로 계속 넘기면 포인터 하나 넘기는 class보다 비쌉니다.
- 완화 수단이 있습니다 — 컴파일러가 상당수 복사를 제거하고,
inout·borrowing으로 명시적으로 빌려 쓸 수 있으며, 내부에 CoW 저장소를 두는 방법도 있습니다. - 반대로 class가 유리한 경우도 명확합니다 — 큰 데이터를 여러 곳이 공유해야 하고 변경이 드물 때.
정확한 진술: "struct는 할당과 ARC를 없애고 지역성을 좋게 하지만, 복사 비용을 만듭니다. 어느 쪽이 이기는지는 크기와 접근 패턴이 정하고, 그건 측정해야 압니다."
L3 struct 안에 class 프로퍼티가 있으면 값 타입인가요?
이게 실무에서 진짜 사고를 내는 지점입니다. 타입은 struct지만 값 의미론이 깨집니다.
final class Box { var value = 0 }
struct Wrapper { var box = Box() } // struct 안에 참조 타입
var a = Wrapper()
var b = a // struct 복사 — 하지만 box 는 "포인터가" 복사된다
b.box.value = 99
print(a.box.value) // 99 ⚠️ a도 바뀌었다!복사된 것은 참조뿐이고 가리키는 객체는 하나입니다. 이걸 얕은 복사(shallow copy)라고 하고, 값 의미론을 원한다면 깊은 복사를 직접 구현해야 합니다(CoW 패턴).
Swift 6에서는 이게 컴파일 에러로도 나타납니다 — Box가 Sendable이 아니므로 Wrapper도 Sendable이 아니고, 격리 경계를 넘길 수 없습니다. 컴파일러가 "이건 진짜 값 타입이 아니다"를 알려 주는 셈입니다.
L4 class가 힙에 있어서 느린 거라면, 힙 할당만 빠르게 하면 되지 않나요?
좋은 질문이고, 답은 "할당은 이미 꽤 빠르지만 그게 주된 비용이 아니다"입니다. 세 가지가 겹칩니다.
- ① 할당 비용 — malloc은 크기별 free list와 메타데이터를 관리합니다. 요즘 할당자는 빠르지만 공짜는 아니고, 멀티스레드에서는 락 또는 스레드 로컬 캐시가 필요합니다.
- ② ARC 비용 — 원자적 refCount 증감 + 멀티코어에서 캐시 라인 소유권 왕복. 이게 앞서 본 MESI 트래픽입니다.
- ③ 지역성 손실 — 이게 보통 가장 큽니다. 할당을 0으로 만들어도, 데이터가 힙에 흩어져 있으면 순회할 때마다 캐시 미스가 납니다. 미스 하나가 200 사이클이면, 할당 몇 나노초를 아끼는 것보다 훨씬 큽니다.
그래서 고성능 코드의 방향이 "할당을 빠르게"가 아니라 "데이터를 연속으로 배치"인 겁니다. 게임 엔진의 SoA(Structure of Arrays)나 ECS 아키텍처가 극단적으로 이 방향을 밀어붙인 예입니다.
쉽게 이해하기
struct는 복사해서 나눠 주는 종이입니다. 내가 내 종이에 낙서해도 네 종이는 멀쩡해요. class는 화이트보드 위치를 알려 주는 것입니다. 우리 둘 다 같은 보드를 보니까, 내가 지우면 너한테도 지워집니다.
그런데 "종이는 복사하니까 느리겠네?"라고 생각하기 쉬운데, Swift는 여기서 꾀를 부립니다. 일단 "복사했다고 말만 해 두고" 실제로는 같은 종이를 봅니다. 그러다 누군가 연필을 드는 순간에만 진짜로 복사합니다. 이게 CoW예요. 대부분은 읽기만 하니까, 진짜 복사는 생각보다 훨씬 적게 일어납니다.
그리고 여기가 CS로 이어지는 지점입니다. 컴퓨터가 느려지는 진짜 이유는 계산이 많아서가 아니라 데이터를 가지러 멀리 가야 해서인 경우가 많습니다. CPU 바로 옆 서랍(캐시)에 있으면 순식간이고, 창고(메인 메모리)까지 가야 하면 50배 넘게 걸립니다.
종이 100장이 한 뭉치로 묶여 있으면(struct 배열) 한 번에 서랍으로 가져올 수 있습니다. 그런데 "창고 A-3칸, B-7칸, C-1칸..." 위치 목록만 들고 있으면(class 배열) 100번 창고를 왕복해야 해요. 이게 struct 배열이 빠른 진짜 이유입니다 — 할당을 안 해서가 아니라, 가지러 갈 거리가 짧아서.
먼저 복사냐 공유냐로 정하고, 성능이 문제되면 할당이 아니라 지역성을 본다. 그리고 Swift 6에서는 이 선택이 Sendable을 공짜로 받느냐까지 결정한다.
설계 근거 · 1차 자료
이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.
- Apple — "기본은 struct"라는 권고와 그 네 가지 예외의 1차 출처
- WWDC16 — 할당·참조 카운팅·메모리 배치를 축으로 한 성능 설명의 원전