- Array·Set·Dictionary는 무엇을 기준으로 고르나요?
append가 O(1)이라는데, 정말 항상 O(1)인가요?- 복잡도가 O(1)인데 왜 더 느린 경우가 있나요?
Int가 아닌 이유, 값 의미론 + CoW, lazy가 중간 배열을 없애는 방식30초 답변 🔥 먼저 이 문장
Array는 순서와 연속 메모리를 보장합니다 → 인덱스 접근 O(1), 탐색 O(n).
Set/Dictionary는 유일성과 키 조회를 보장합니다 → 조회 평균 O(1), 대신 순서를 포기합니다.
그래서 고르는 기준은 "순서가 의미 있는가"와 "무엇으로 찾는가"입니다.
다만 실무에서는 한 가지를 더 봅니다 — 복잡도가 실제 속도를 결정하지 않는 구간이 있습니다. 원소가 몇 개 안 되면 O(n)인
Array.contains가 O(1)인 Set.contains보다 빠릅니다. 해시 계산 비용과 캐시 지역성 때문입니다.
L1개념 — 보장이 먼저, 복잡도는 결과
| Array | Set | Dictionary | |
|---|---|---|---|
| 보장하는 것 | 순서 · 중복 허용 · 연속 메모리 | 유일성 | 키 → 값 조회 |
| 포기하는 것 | 키 조회 | 순서 | 순서 |
| 인덱스/키 접근 | O(1) | — | 평균 O(1) |
탐색 contains | O(n) | 평균 O(1) | 평균 O(1) |
| 끝에 추가 | amortized O(1) | 평균 O(1) | 평균 O(1) |
| 중간 삽입/삭제 | O(n) — 뒤를 전부 민다 | 평균 O(1) | 평균 O(1) |
| 최악 | — | O(n) (해시 충돌) | O(n) (해시 충돌) |
| 원소 요구사항 | 없음 | Hashable | 키가 Hashable |
"순서가 의미 있으면 Array, 유일성이 의미 있으면 Set, 무언가로 찾아야 하면 Dictionary."
그리고 Array에서 contains를 반복 호출하고 있으면 그건 Set이어야 한다는 신호입니다 — O(n)을 n번 돌면 O(n²)가 됩니다.
프로토콜 계층 — 알고리즘이 요구하는 최소 능력
Sequence 한 번 순회할 수 있다 (다시 순회 가능하다는 보장 없음)
└ Collection 여러 번 순회 가능 · Index로 접근 · count가 O(1)
└ BidirectionalCollection 뒤로도 갈 수 있다 (index(before:))
└ RandomAccessCollection 임의 위치로 O(1) 점프 · 거리 계산 O(1)
// 구체 타입이 어디에 속하는가
Array → RandomAccessCollection (i번째로 바로 점프 가능)
String → BidirectionalCollection (⚠️ RandomAccess 아님!)
Set/Dictionary → Collection (순서가 없으니 앞뒤 개념도 없음)
// 왜 나눠 놨나 — 함수가 "필요한 최소 능력"만 요구할 수 있게
func middle<C: RandomAccessCollection>(_ c: C) -> C.Element {
c[c.index(c.startIndex, offsetBy: c.count / 2)] // O(1) 점프가 필요하다
}
// String을 넘기면 컴파일 에러 → "이 알고리즘은 String에 쓰면 O(n)이 된다"를
// 런타임 성능 문제가 아니라 컴파일 에러로 미리 막는다L2언어 설계 — Index는 왜 Int가 아닌가
다른 언어에서 오면 가장 낯선 부분입니다. str[3]이 왜 안 될까요? 이건 불편하게 만들려던 게 아니라 거짓말을 막으려는 설계입니다.
let s = "가나다"
// s[2] // ❌ 컴파일 에러
let i = s.index(s.startIndex, offsetBy: 2)
print(s[i]) // "다"
// 왜? Swift의 String은 "사람이 인식하는 문자(Character)" 단위인데,
// 그 하나가 UTF-8에서 1~4바이트로 길이가 제각각이다.
"a".utf8.count // 1
"가".utf8.count // 3
"👨👩👧👦".utf8.count // 25 ← 이모지 하나가 25바이트, Character로는 1개
// 저장은 연속 바이트인데 "n번째 문자"는 앞에서부터 세어야만 안다 → O(n)
// 만약 s[2] 문법을 허용했다면?
// · 보기엔 O(1)처럼 생겼는데 실제로는 O(n)
// · for i in 0..<s.count { s[i] } 가 조용히 O(n²)가 된다
//
// ★ Swift의 선택: "O(1)처럼 보이는 O(n)"을 허용하느니 문법을 불편하게 만든다.
// Index 타입을 따로 둬서 "이건 공짜 점프가 아니다"를 눈에 보이게 했다.
RandomAccessCollection을 요구하면 "O(1) 점프가 되는 것만 받겠다"가 타입 제약이 됩니다.
성능 특성을 주석이나 문서가 아니라 컴파일러가 검사할 수 있는 형태로 만든 것이죠.
값 의미론과 CoW — 컬렉션이 struct인 이유
var a = [1, 2, 3]
var b = a // O(1) — 버퍼를 공유, 참조 카운트만 +1
b.append(4) // 여기서 실제 복사 (유일 소유자가 아니므로)
// 그래서 이런 코드가 안전하다 — 방어적 복사가 필요 없다
func process(_ items: [Item]) {
// items를 아무리 읽어도 호출자의 배열이 바뀔 일이 없다
}
// ⚠️ 하지만 CoW는 "공짜"가 아니라 "미룬" 것이다
var big = Array(0..<1_000_000)
var copy = big // 여기까지 O(1)
copy[0] = 99 // 💥 여기서 100만 개 전체 복사 (O(n))
// 루프 안에서 이런 패턴이 나오면 O(n²)가 된다자세한 메커니즘은 04번 Struct vs Class에서 다룹니다 — "계약은 값, 구현은 공유"가 그 챕터의 결론이었죠.
L3구현 — 메모리에서 실제로 무슨 일이
① 컬렉션은 메모리 어디에 어떻게 잡히나
Swift에서 컬렉션 크기는 개발자가 정하지 않습니다. C의 int a[10]처럼 "칸 열 개"를 선언하는 문법이 없고, 런타임이 필요할 때 잡고 늘립니다.
그래서 "내 배열이 지금 메모리를 얼마나 쓰는가"가 눈에 안 보이는데, 구조를 알면 계산할 수 있습니다.
MemoryLayout<[Int]>.size // 8 ← 원소가 100만 개여도 8
MemoryLayout<[String]>.size // 8
MemoryLayout<Set<Int>>.size // 8
MemoryLayout<[Int: Int]>.size // 8
MemoryLayout<String>.size // 16 ← 짧은 문자열을 인라인에 담으려고 조금 크다
// ★ 그래서 Array를 함수에 넘기는 건 "8바이트 복사"다.
// "큰 배열을 값으로 넘기면 느리다"는 오해가 여기서 갈린다 —
// 느려지는 건 넘길 때가 아니라 CoW가 실제 복사를 트리거할 때다.let e1: [Int] = []
let e2: [Int] = []
let e3 = [Int]()
// 세 버퍼 주소를 찍어 보면 — 전부 동일했다
// 8420809880 / 8420809880 / 8420809880 → true
let emptyDict: [Int: Int] = [:]
emptyDict.capacity // 0
빈 컬렉션은 전역 공유 싱글턴을 가리킵니다. 그래서 []를 아무리 많이 만들어도 할당이 0번입니다.
구조체 프로퍼티를 var items: [Item] = []로 초기화하는 게 공짜인 이유가 이겁니다.
② count와 capacity는 다르다
var a: [Int] = []
for i in 1...40 { a.append(i) }
a.count // 40
a.capacity // 76 ← 36칸(47%)이 비어 있다
// 용량이 커진 순간들을 기록해 보면
// capacity 변화: [2, 4, 8, 16, 36, 76, 156]
// ↑ 여기서부터 정확히 2배가 아니다. 왜?
// reserveCapacity로 미리 잡으면
var b: [Int] = []
b.reserveCapacity(40)
for i in 1...40 { b.append(i) }
b.capacity // 44 ← 재할당이 일어나지 않는다Swift는 "2배로 늘려 달라"고 요청하지만, 실제 크기는 malloc이 정합니다. malloc은 아무 크기나 주는 게 아니라 정해진 크기 클래스로 반올림하거든요. 그리고 Swift는 그렇게 받은 여유분까지 capacity로 씁니다 — 어차피 받은 메모리니까요.
capacity = (malloc_good_size(헤더 32B + 8B × 요청개수) - 32) / 8
현재cap 2배요청 필요바이트 good_size → 예측cap
1 2 48B 48B → 2
2 4 64B 64B → 4
4 8 96B 96B → 8
8 16 160B 160B → 16
16 32 288B 320B → 36 ← 32를 요청했는데 36을 받았다
36 72 608B 640B → 76
76 152 1248B 1280B → 156
예측: [2, 4, 8, 16, 36, 76, 156]
실측: [2, 4, 8, 16, 36, 76, 156] ✅ 완전 일치16 → 36이 튀는 이유가 여기서 정확히 설명됩니다. 32개(288B)를 요청했는데 malloc의 크기 클래스가 320B라서, 남는 32바이트로 4칸을 더 만든 겁니다. "이왕 받은 메모리를 버리지 않는다"는 것이고, 그래서 성장 배수가 정확히 2가 아닙니다.
면접에서의 값어치 — "지수적으로 늘어납니다"까지는 누구나 말합니다. "요청은 2배지만 실제 값은 할당자 크기 클래스가 정합니다"까지 가면 런타임과 할당자의 경계를 아는 사람이 됩니다.
이미지 URL 1000만 개를 담은 배열이라면 최대 두 배 가까운 버퍼를 들고 있을 수 있습니다.
그리고 removeAll()은 기본적으로 용량을 유지합니다 — 원소만 지우고 버퍼는 그대로 둡니다.
a.removeAll() // count=0, capacity는 그대로 유지
a.removeAll(keepingCapacity: false) // 버퍼도 해제
// 캐시 배열을 비울 때 이걸 놓치면 "지웠는데 메모리가 안 준다"가 된다
// → 09번의 "릭이 아닌 메모리 증가"에 해당한다③ Dictionary/Set은 오픈 어드레싱 해시 테이블이다
// Swift의 Dictionary/Set = 오픈 어드레싱(open addressing) + 선형 탐사(linear probing)
// 버킷 배열이 원소를 "직접" 담고, 자리가 차 있으면 다음 칸을 본다.
// Java HashMap 처럼 버킷마다 연결 리스트를 매다는 체이닝이 아니다.
버킷: [ _ ][ A ][ B ][ _ ][ C ][ _ ][ _ ][ _ ]
↑
hash(B) = 1 인데 이미 A가 있음 → 다음 칸(2)에 저장 (선형 탐사)
// 오픈 어드레싱의 장점 — 여기가 CS와 만나는 지점
// 원소가 버킷 배열 안에 연속으로 놓여 있다 → 탐사가 캐시 라인을 타고 간다
// 체이닝은 포인터를 따라 힙 여기저기로 점프한다 → 캐시 미스 (04번의 포인터 추격)
// 대가
// 삭제가 까다롭다(빈 칸을 그냥 만들면 탐사가 끊긴다 → tombstone 필요)
// load factor가 높아지면 탐사 길이가 급격히 늘어난다var s = Set<Int>()
for i in 1...80 { s.insert(i) } // 용량이 바뀐 순간들을 기록
Set capacity: [1, 3, 6, 12, 24, 48, 96]
Dict capacity: [1, 3, 6, 12, 24, 48, 96] // 같은 구현을 공유한다
// 대조해 보면 정확히 맞는다
// 버킷 2 × 3/4 = 1
// 버킷 4 × 3/4 = 3
// 버킷 8 × 3/4 = 6
// 버킷 16 × 3/4 = 12
// 버킷 32 × 3/4 = 24
// 버킷 64 × 3/4 = 48
// 버킷 128 × 3/4 = 96
// ★ 두 가지를 알 수 있다
// ① 버킷 수는 항상 2의 거듭제곱 → 나머지 연산을 비트 마스킹(h & (n-1))으로 대체
// ② load factor 상한이 3/4 → 4칸 중 1칸은 의도적으로 비워 둔다
// (꽉 채우면 선형 탐사가 길어져 O(1)이 무너지므로)
//
// 즉 Dictionary는 "원소 수"가 아니라 "버킷 배열 전체"만큼 메모리를 쓴다.
// 원소 96개짜리 Dictionary = 버킷 128칸 + 각 버킷의 키/값/점유 비트맵reserveCapacity의 효과가 컬렉션마다 다르다
Array는 재할당 + 기존 원소 전체 복사가 사라집니다 — 원소 복사가 비쌀수록(참조 카운팅이 딸린 struct 등) 이득이 큽니다. Int처럼 가벼우면 차이가 거의 없습니다.
Set/Dictionary는 재할당 때 전부 다시 해싱(rehash) 합니다 — 복사만이 아니라 해시 계산을 처음부터 다시 하는 것이라 상대적으로 더 비쌉니다.
④ 힙을 피하는 방법 — 크기를 컴파일러에게 알려 주면 된다
지금까지 본 것의 근본 원인은 하나입니다 — 컬렉션의 크기가 런타임에 정해지기 때문에 힙에 잡아야 한다. 스택은 함수 진입 시점에 크기가 확정돼야 하고, 스코프를 벗어나면 사라지니까요(10번과 같은 이야기).
뒤집으면 크기를 컴파일 타임에 알려 주면 힙을 안 써도 됩니다. Swift에는 그 수단이 셋 있습니다.
// ① 튜플 — 원소 수가 타입에 박힌다
MemoryLayout<(Int, Int, Int, Int)>.size // 32 ← 힙 없음, 통째로 인라인
MemoryLayout<[Int]>.size // 8 ← 포인터 (데이터는 힙)
// ② InlineArray (Swift 6.2, SE-0453) — 고정 길이 배열이 언어에 들어왔다
let a: InlineArray<4, Int> = [1, 2, 3, 4]
MemoryLayout<InlineArray<4, Int>>.size // 32 ← 힙 할당 0
// 길이가 타입 파라미터라서 컴파일 타임에 크기가 확정된다
// ③ withUnsafeTemporaryAllocation — 스택에 임시 버퍼
withUnsafeTemporaryAllocation(of: Int.self, capacity: 64) { buf in
for i in 0..<64 { buf[i] = i }
// 이 스코프 안에서만 유효 — 벗어나면 사라진다
} // 힙 할당·해제 없음Array를 쓰는 게 맞다
위 셋은 크기가 진짜로 고정이고 핫 패스에서 할당이 병목으로 측정됐을 때의 도구입니다.
일반 코드에서 Array를 피하려 들면 얻는 것보다 잃는 게 큽니다 — 표준 API를 못 쓰고, 크기가 바뀌는 순간 전부 다시 써야 하니까요.
이 절의 요점은 "쓰라"가 아니라 "왜 Array가 힙을 쓰는지 설명할 수 있어야 한다"입니다.
크기를 런타임에 정하는 대가로 힙을 쓴다는 인과를 알면, reserveCapacity가 왜 도움이 되는지도 같은 논리로 설명됩니다.
let s: Set = [1,2,3,4,5,6,7,8,9,10]
print(Array(s))
// 1회차: [4, 1, 2, 5, 6, 8, 7, 3, 9, 10]
// 2회차: [10, 4, 8, 7, 9, 6, 1, 3, 2, 5] ← 같은 코드, 같은 내용, 다른 순서
// 왜? Swift 4.2부터 해시 시드가 프로세스마다 무작위로 생성된다.
// 버그가 아니라 해시 플러딩 공격(HashDoS) 방어 설계다.
//
// ★ 그래서 절대 하면 안 되는 것
// · Set/Dictionary 순회 순서에 의존하는 로직
// · hashValue를 파일·DB·네트워크에 저장하고 다음 실행에서 비교
// · 순서를 가정한 스냅샷 테스트
//
// 테스트에서 순서를 고정해야 하면 SWIFT_DETERMINISTIC_HASHING=1 (테스트 전용)⑤ 슬라이스는 원본 버퍼 전체를 붙잡는다 🔥 메모리 함정
이게 이 챕터에서 실무 사고로 가장 많이 이어지는 항목입니다. ArraySlice와 Substring은 "필요한 부분만 잘라 가벼운 것"처럼 보이지만 정반대입니다.
final class Box { deinit { Counter.freed += 1 } }
func run(_ mode: String) {
Counter.freed = 0
var keep: Any? = nil
do {
let big = (0..<1000).map { _ in Box() } // 객체 1000개
switch mode {
case "slice": keep = big[0..<1] // 1개짜리 슬라이스 보관
case "copy": keep = Array(big[0..<1]) // 새 배열로 복사해서 보관
default: break // 아무것도 안 보관
}
} // big 스코프 종료
print("[\(mode)] 해제 \(Counter.freed)/1000")
}
// 실행 결과 (Swift 6.2.1 실측)
// [none ] 해제 1000/1000 → 살아남음 0개 ✅ 정상
// [copy ] 해제 999/1000 → 살아남음 1개 ✅ 의도대로
// [slice] 해제 0/1000 → 살아남음 1000개 💥 전부 살아 있다슬라이스는 원본 스토리지에 대한 참조를 그대로 들고 있습니다. 보이는 범위만 좁아졌을 뿐이죠. 그래서 파일 6천만 줄을 파싱해서 한 줄만 남겨도 원본 버퍼 전체가 메모리에 남습니다.
이건 순환 참조가 아니라 도달 가능한 메모리라 09번의 abandoned memory에 해당합니다 — Leaks 도구에 안 잡히고, Allocations의 세대별 잔존으로만 보입니다.
// ❌ 슬라이스를 프로퍼티에 저장
self.header = allLines[0..<10] // 전체 파일이 메모리에 남는다
// ✅ 새 인스턴스로 복사 → 원본과의 연결이 끊긴다
self.header = Array(allLines[0..<10])
self.name = String(fullText[range]) // Substring도 마찬가지
// 규칙: 슬라이스는 "지금 이 함수 안에서 훑고 버릴 때"만 쓴다.
// 스코프를 넘겨 저장할 거면 반드시 Array(...) / String(...) 으로 복사.let big = Array(0..<10)
let slice = big[5...]
slice.startIndex // 5 ← 0이 아니다!
slice.endIndex // 10
slice.count // 5
slice.first // 5
slice[5] // 5 ✅ 유효
// slice[0] // 💥 Index out of bounds 크래시
// ✅ 안전한 접근
slice.first
slice[slice.startIndex]
for (offset, v) in slice.enumerated() { } // offset은 0부터L4CS 근본 — 복잡도가 말하지 않는 것
① amortized O(1) — 왜 지수적으로 늘리면 평균이 상수가 되나
append는 가끔 버퍼 전체를 새로 할당하고 복사합니다. 그런데도 "평균 O(1)"이라고 하는 근거가 등비급수입니다.
n개를 append 하는 동안 일어나는 복사 횟수의 총합
= 마지막 재할당에서 n/2개 + 그 전 n/4개 + n/8 + …
= n/2 + n/4 + n/8 + … < n ← 등비급수(공비 1/2)의 합은 n 미만
총비용 O(n)을 n번으로 나누면 → 평균 O(1) ★ 이게 amortized의 정의
// ⚠️ "일정량씩" 늘리면 왜 안 되는가 — 대조해 보면 명확하다
// 매번 +1칸씩 늘린다면:
// 복사 총합 = 1 + 2 + 3 + … + n = n(n+1)/2 = O(n²)
// 평균 = O(n) → 상수가 아니다!
//
// ★ 그래서 "지수적 성장"은 구현 편의가 아니라 amortized O(1)의 필요조건이다.
① 재할당이 실제로 일어나는 그 한 번 — 그 호출만은 O(n)입니다. 평균이 O(1)이지 매번 O(1)이 아닙니다. 프레임 예산이 빡빡한 스크롤 경로에서는 이 한 번이 hitch가 됩니다.
② CoW가 걸린 경우 — 다른 변수가 같은 버퍼를 보고 있으면 용량이 남아 있어도 전체 복사가 일어납니다.
③ insert(_:at:) — 뒤 원소를 전부 밀어야 하므로 amortized조차 아니고 항상 O(n)입니다.
② 해시 테이블 — 평균 O(1)의 조건
① 해시 함수가 값을 고르게 흩뿌린다
→ 몰리면 탐사가 길어져 최악 O(n)
② load factor(채움 비율)를 낮게 유지한다
→ 꽉 찰수록 선형 탐사 길이가 급격히 늘어난다 → 리해싱으로 관리
③ 해시 계산 자체가 싸다
→ 긴 String 키는 해시 한 번에 전체를 훑는다. "O(1)"의 상수가 크다
// ★ Hashable 계약 — 어기면 조용히 망가진다
// a == b 이면 반드시 a.hashValue == b.hashValue
// (역은 성립 안 해도 된다 — 다른 값이 같은 해시일 수 있다 = 충돌)
struct User: Hashable {
let id: UUID
var nickname: String // 자주 바뀐다
// ❌ == 는 id만 보는데 hash는 nickname까지 넣으면 계약 위반
static func == (l: User, r: User) -> Bool { l.id == r.id }
func hash(into hasher: inout Hasher) {
hasher.combine(id)
// hasher.combine(nickname) // 💥 넣으면 안 된다
}
}
// 어기면? Set에 넣은 뒤 nickname을 바꾸는 순간 그 원소를 "찾을 수 없게" 된다.
// 크래시가 아니라 조용한 오작동이라 더 나쁘다.③ 복잡도가 같아도 속도가 다르다 — 캐시 지역성
이게 이 챕터의 L4 핵심이고, 면접에서 가장 인상적인 대목입니다. O(1)이 O(n)보다 항상 빠르지 않습니다.
contains 한 번의 평균 시간 (Swift 6.2.1 -O, arm64) n Array.contains Set.contains 승자
4 1.0 ns 3.8 ns Array ← O(n)이 이긴다
8 2.7 ns 9.0 ns Array
16 9.1 ns 7.9 ns Set ← 교차점
32 12.6 ns 5.5 ns Set
128 41.7 ns 4.8 ns Set
1,000 266.7 ns 4.2 ns Set
100,000 24,359.6 ns 4.5 ns Set ← 5,400배
// 두 가지가 동시에 보인다
// ① Set은 n이 10만 배 늘어도 4~5ns로 평평하다 → 진짜 O(1)
// ② 그런데 n ≤ 8 에서는 Array가 이긴다왜 작은 n에서 O(n)이 이길까요? 복잡도 표기가 감추는 두 가지 때문입니다.
- 상수 인자 — O(1)의 "1"은 해시 계산 + 버킷 인덱싱 + 탐사입니다. Array의 비교 한 번보다 훨씬 무겁습니다. 원소가 4개면 비교 4번이 해시 1번보다 쌉니다.
- 캐시 지역성 —
[Int]4개는 32바이트라 캐시 라인 하나에 통째로 들어옵니다(04번: Apple Silicon 128바이트). 즉 메모리 접근이 사실상 한 번입니다. 반면 해시 테이블은 계산한 버킷 위치로 점프하므로 지역성이 없습니다.
"작으니까 Array 써도 된다"가 아닙니다. 그건 미세 최적화고, 대부분 의미 없습니다.
진짜 교훈은 반대 방향입니다 — "O(1)이니까 안심"이 틀릴 수 있다.
긴 String 키를 쓰는 Dictionary를 스크롤 매 프레임에서 조회하면, O(1)이어도 해시 계산이 프레임 예산을 먹습니다.
그리고 n이 커질 때의 차이는 절대적입니다 — 위 실측에서 n=10만일 때 5,400배입니다. "작을 땐 별 차이 없고, 커지면 자릿수가 바뀐다"가 정확한 요약이고, 그래서 기본값은 자료구조를 올바르게 고르는 것입니다.
④ 메모리 관점 정리 — 컬렉션이 쓰는 공간
① Array : 헤더 32B + capacity × 원소크기 (실측 40개 → 76칸, 47% 여유)
removeAll()은 기본적으로 capacity 유지
② Set/Dict : 버킷 수(2의 거듭제곱) × 3/4 가 capacity (실측 [1,3,6,12,24,48,96])
4칸 중 1칸은 의도적으로 비워 둔다 — 꽉 채우면 탐사가 길어지므로
③ 슬라이스 : 원본 버퍼 전체 (실측 1개 슬라이스 → 1000개 전부 생존)
④ 원소가 클래스: 배열은 "포인터 배열" → 실제 데이터는 힙 여기저기
순회할 때마다 포인터 추격 + 캐시 미스 (04번)
// 그래서 대용량 컬렉션에서 메모리를 아끼려면
// · reserveCapacity로 재할당 자체를 줄인다 (원소 복사가 비쌀 때 특히)
// · 오래 보관할 슬라이스는 Array(...)로 복사해 원본을 끊는다
// · 값 타입 원소를 쓰면 연속 배치 + ARC 없음
// · 다 쓴 캐시 배열은 removeAll(keepingCapacity: false)CS 정본으로 더 내려가기
이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 끝까지 파는 자료가 허브에 이미 있습니다.
경험으로 말하기
❌ "조회가 많아서 Array를 Set으로 바꿨습니다."
✅ "찜한 상품 표시가 목록이 길어질수록 느려졌습니다. Time Profiler를 보니 cellForRowAt 안에서 favoriteIDs.contains(item.id)가 상위였고,
favoriteIDs가 배열이었습니다. 셀마다 O(n)이니 화면 전체로는 O(n²)였죠.
Set<UUID>로 바꿔서 해결했는데, 같이 확인한 게 두 가지 있습니다.
하나는 순서가 필요 없다는 것 — 찜 목록 표시용 순서는 별도 배열로 유지하고 조회용만 Set으로 뒀습니다.
다른 하나는 Set으로 바꾸면 순회 순서가 실행마다 달라진다는 점이라, 순서에 의존하던 스냅샷 테스트를 먼저 고쳤습니다.
반대로 안 바꾼 곳도 있습니다 — 옵션 태그처럼 원소가 서너 개인 배열은 그대로 뒀습니다. 그 크기에서는 해시 계산이 오히려 비싸고, 측정해도 차이가 없었습니다."
꼬리 질문 대비
L2 lazy를 붙이면 항상 빨라지나요?
아닙니다. lazy가 없애는 건 중간 배열 할당이지 계산 자체가 아닙니다.
// ✅ 이득 — 중간 배열 2개가 안 생기고, 조건에 맞는 첫 원소에서 멈춘다
let first = items.lazy.map(transform).filter(isValid).first
// ❌ 손해 — 어차피 전부 순회할 거면 중간 배열이 오히려 캐시에 유리하고,
// lazy는 원소마다 클로저 호출이 끼어 인라이닝이 어려워질 수 있다
let all = Array(items.lazy.map(transform)) // 그냥 items.map(transform)이 낫다더 위험한 건 성능이 아니라 의미론입니다.
- 부수효과가 늦게·여러 번 실행된다 —
lazy.map { print($0); return f($0) }는 순회할 때 찍히고, 두 번 순회하면 두 번 찍힙니다. - 원본을 붙잡는다 — lazy 뷰는 원본 컬렉션에 대한 참조를 들고 있어서, 슬라이스와 같은 메모리 함정이 생길 수 있습니다.
규칙: "전부 다 쓸 거면 lazy 쓰지 말고, 앞부분만 쓰거나 체인이 길 때만 쓴다."
L3 Dictionary에 넣은 뒤 키의 프로퍼티를 바꾸면 어떻게 되나요?
그 원소를 다시 찾을 수 없게 됩니다. 그리고 크래시가 아니라 조용한 오작동이라 더 위험합니다.
이유는 저장 위치가 넣을 때 계산한 해시로 정해졌기 때문입니다. 해시에 들어가는 값이 바뀌면 새로 계산한 위치에는 아무것도 없습니다.
final class Tag: Hashable {
var name: String // 가변!
init(_ n: String) { name = n }
static func == (l: Tag, r: Tag) -> Bool { l.name == r.name }
func hash(into h: inout Hasher) { h.combine(name) }
}
let tag = Tag("swift")
var set: Set<Tag> = [tag]
print(set.contains(tag)) // true
tag.name = "ios" // 💥 해시가 바뀌었다
print(set.contains(tag)) // false — 넣어 둔 그 객체인데 못 찾는다
print(set.count) // 1 — 있긴 있다. 찾지 못할 뿐그래서 두 가지 규칙이 나옵니다.
- 키는 불변으로 — 값 타입 +
let이 안전합니다. Swift 컬렉션이 값 의미론인 게 여기서도 도움이 됩니다. - 해시에는 변하지 않는 것만 —
id처럼 고정된 식별자만combine하고, 표시명 같은 가변 필드는 넣지 않습니다. 단==와 일관되어야 합니다.
L4 O(1)이 O(n)보다 항상 빠른 건 아니라면, 복잡도는 왜 보나요?
복잡도가 답하는 질문이 "지금 빠른가"가 아니라 "커지면 어떻게 되는가"이기 때문입니다. 이 둘을 구분하는 게 핵심입니다.
① 점근 표기는 증가율만 말한다. O(1)은 "n이 커져도 안 늘어난다"는 뜻이지 "항상 빠르다"가 아닙니다. 상수 인자는 표기에서 의도적으로 버려집니다 — 하드웨어·구현마다 다르니까요.
② 그래서 둘 다 봐야 합니다. 실측에서 n=8이면 Array가 3배 빨랐지만, n=10만이면 Set이 5,400배 빨랐습니다. 작은 쪽 차이는 ns 단위고 큰 쪽 차이는 자릿수입니다. 비대칭이 크므로 기본값은 복잡도가 좋은 쪽이어야 합니다.
③ 복잡도는 "안전장치"다. 데이터가 얼마나 커질지는 대개 개발 시점에 모릅니다. 테스트에서 10개였던 게 운영에서 10만 개가 됩니다. O(n²)를 넣어 두면 그날 장애가 납니다. 반면 상수 인자 손해는 최악이어도 몇 배에서 멈춥니다.
정리: "복잡도로 재앙을 막고, 프로파일러로 상수를 줄인다." 순서를 바꾸면 안 됩니다 — 측정 없이 상수를 만지는 건 미신이고, 복잡도를 무시하는 건 시한폭탄입니다.
쉽게 이해하기
세 컬렉션을 물건 보관 방식으로 생각해 봅시다.
- Array = 일렬로 놓은 책장. 3번째 칸이 어디인지 바로 압니다(O(1)). 대신 특정 책을 찾으려면 처음부터 훑어야 합니다(O(n)). 중간에 하나 끼워 넣으면 뒤를 전부 밀어야 하고요.
- Set = 이름표로 자리를 정하는 창고. 물건 이름을 계산기에 넣으면 자리 번호가 바로 나옵니다(O(1)). 대신 순서라는 게 없습니다 — 넣은 순서대로 꺼낼 수 없어요.
- Dictionary = 이름표 창고인데, 물건에 쪽지가 붙어 있는 것. 이름으로 찾아서 쪽지를 읽습니다.
여기서 재미있는 사실. 물건이 4개뿐이면 창고 계산기를 쓰는 게 오히려 느립니다. 계산하는 시간에 4개를 그냥 눈으로 훑는 게 빠르거든요. 실측해 보니 16개쯤에서 뒤집혔습니다.
그리고 메모리 쪽에 함정이 둘 있습니다.
첫째, 책장은 항상 여유 칸을 둡니다. 책 40권을 꽂았는데 칸은 76개예요. 왜냐하면 칸이 꽉 찰 때마다 더 큰 책장으로 통째로 옮겨야 하는데, 한 칸씩 늘리면 이사를 너무 자주 하거든요. 그래서 이사할 때마다 크기를 확 키웁니다. 덕분에 이사 횟수가 확 줄어서 평균적으로는 한 권 꽂는 게 순식간이 됩니다. 대신 빈 칸이 남습니다.
둘째가 진짜 조심할 부분입니다. 책 1000권 중 한 권만 따로 표시해 두는 것처럼 보이는 게 있어요(슬라이스). 그런데 실제로는 "1000권짜리 책장 전체를 그대로 두고, 그중 한 권만 가리키는 것"입니다. 그래서 책장이 통째로 안 치워집니다. 실제로 세어 보니 1000권 전부 살아 있었습니다.
더 나쁜 건 이게 "물건을 잃어버린 것"이 아니라 "쓰고 있다고 우기는 것"이라 분실물 탐지기(Leaks 도구)에 안 걸린다는 점입니다. 그래서 오래 보관할 거면 반드시 그 한 권만 복사해서 새로 두고, 원래 책장과의 연결을 끊어야 합니다.
컬렉션 선택은 "무엇을 보장받고 무엇을 포기할 것인가"이고, 복잡도는 그 선택의 결과다. 그리고 O(1)은 "커져도 안 늘어난다"는 뜻이지 "지금 빠르다"가 아니며, 메모리에서는 담은 만큼이 아니라 잡고 있는 만큼이 비용이다.
설계 근거 · 1차 자료
이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 수치는 전부 Swift 6.2.1 · arm64 실측입니다.
reserveCapacity)developer.apple.com
🔗Apple — Hasher (실행마다 시드가 달라진다는 명시)developer.apple.com
🔗Apple — ArraySlice (원본 스토리지 공유 경고)developer.apple.com
- TSPL Collection Types — 세 컬렉션이 각각 보장하는 것의 공식 정의
- Array 문서 — 용량을 지수적으로 늘린다는 성장 정책과 amortized 비용의 근거
- Hasher 문서 — 해시값을 저장하거나 실행 간 비교하면 안 된다는 공식 경고
- ArraySlice 문서 — 슬라이스가 원본 스토리지 전체에 대한 참조를 들고 있다는 공식 서술