← Mobile Foundation 인터뷰 13 / 13

13 · Collection과 메모리

보장 → CoW·Index → 버퍼와 해시 테이블 → amortized 분석과 캐시 지역성
진행률
0 / 0 완료
면접에서 나오는 형태
  1. Array·Set·Dictionary는 무엇을 기준으로 고르나요?
  2. append가 O(1)이라는데, 정말 항상 O(1)인가요?
  3. 복잡도가 O(1)인데 왜 더 느린 경우가 있나요?
L1 개념
셋의 차이는 "무엇을 보장하느냐" — 순서·유일성·키 조회. 복잡도는 그 보장의 결과다
L2 설계
Index가 Int가 아닌 이유, 값 의미론 + CoW, lazy가 중간 배열을 없애는 방식
L3 구현
값은 스택에 포인터 8B, 데이터는 힙. 용량은 malloc 크기 클래스가 정한다. 슬라이스는 원본 버퍼 전체를 붙잡는다
L4 CS
amortized 분석 · 해시 이론 · 캐시 지역성 — 복잡도가 같아도 속도가 다른 이유

30초 답변 🔥 먼저 이 문장

세 컬렉션은 "무엇을 보장하느냐"가 다르고, 복잡도는 그 보장에서 따라 나온 결과입니다.
Array는 순서와 연속 메모리를 보장합니다 → 인덱스 접근 O(1), 탐색 O(n).
Set/Dictionary는 유일성과 키 조회를 보장합니다 → 조회 평균 O(1), 대신 순서를 포기합니다.

그래서 고르는 기준은 "순서가 의미 있는가"와 "무엇으로 찾는가"입니다.

다만 실무에서는 한 가지를 더 봅니다 — 복잡도가 실제 속도를 결정하지 않는 구간이 있습니다. 원소가 몇 개 안 되면 O(n)인 Array.contains가 O(1)인 Set.contains보다 빠릅니다. 해시 계산 비용과 캐시 지역성 때문입니다.

L1개념 — 보장이 먼저, 복잡도는 결과

ArraySetDictionary
보장하는 것순서 · 중복 허용 · 연속 메모리유일성키 → 값 조회
포기하는 것키 조회순서순서
인덱스/키 접근O(1)—평균 O(1)
탐색 containsO(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]이 왜 안 될까요? 이건 불편하게 만들려던 게 아니라 거짓말을 막으려는 설계입니다.

String에 정수 첨자가 없는 이유 (Swift)
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 타입을 따로 둬서 "이건 공짜 점프가 아니다"를 눈에 보이게 했다.
📝 이게 02번의 "런타임 비용을 타입으로 옮긴다"와 같은 수법

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]처럼 "칸 열 개"를 선언하는 문법이 없고, 런타임이 필요할 때 잡고 늘립니다. 그래서 "내 배열이 지금 메모리를 얼마나 쓰는가"가 눈에 안 보이는데, 구조를 알면 계산할 수 있습니다.

실측 — 컬렉션 "값" 자체는 전부 포인터 하나다 (arm64 · Swift 6.2.1)
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가 실제 복사를 트리거할 때다.
STACK (또는 포함 객체 안) var a: [Int] 8바이트 — 포인터 하나 원소 수와 무관하게 항상 8B HEAP — 실제 데이터는 전부 여기 버퍼 객체 (_ContiguousArrayStorage) metadata 8B refCount 8B count 8B capacity 8B 헤더 = 32바이트 e0e1e2 e3e4 빈칸 ← count개 (연속 배치) ← capacity까지 그래서 계산이 된다 실제 힙 사용량 ≈ 32바이트(헤더) + capacity × 원소크기 — count가 아니라 capacity 기준이다. 원소가 클래스면 그 칸에는 포인터 8B만 있고, 실제 객체는 힙 여기저기에 따로 있다(포인터 추격).
컬렉션 값은 스택에 8바이트, 데이터는 전부 힙. 그래서 "배열을 함수에 넘기는 비용"과 "배열이 쓰는 메모리"는 완전히 다른 이야기다.
🔑 빈 컬렉션은 힙을 아예 안 쓴다 — 실측
빈 배열 세 개가 같은 주소를 가리킨다
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는 다르다

실측 — 40개를 담는 데 버퍼는 76칸 (Swift 6.2.1, arm64)
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   ← 재할당이 일어나지 않는다
🧠 왜 2배가 아닌가 할당자 크기 클래스가 답이다

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가 높아지면 탐사 길이가 급격히 늘어난다
실측 — Set/Dictionary 용량은 버킷 수(2의 거듭제곱) × 3/4
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은 "필요한 부분만 잘라 가벼운 것"처럼 보이지만 정반대입니다.

실측 — 원소 1개짜리 슬라이스가 1000개 전부를 살려 둔다
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개   💥 전부 살아 있다
🚫 왜 위험한가 — Leaks 도구에 안 잡힌다

슬라이스는 원본 스토리지에 대한 참조를 그대로 들고 있습니다. 보이는 범위만 좁아졌을 뿐이죠. 그래서 파일 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(...) 으로 복사.
보너스 함정 — 슬라이스의 인덱스는 0부터가 아니다 (실측)
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)의 필요조건이다.
⚠️ amortized가 깨지는 세 가지 — 이걸 말하면 확실히 다르다

① 재할당이 실제로 일어나는 그 한 번 — 그 호출만은 O(n)입니다. 평균이 O(1)이지 매번 O(1)이 아닙니다. 프레임 예산이 빡빡한 스크롤 경로에서는 이 한 번이 hitch가 됩니다.
② CoW가 걸린 경우 — 다른 변수가 같은 버퍼를 보고 있으면 용량이 남아 있어도 전체 복사가 일어납니다.
③ insert(_:at:) — 뒤 원소를 전부 밀어야 하므로 amortized조차 아니고 항상 O(n)입니다.

② 해시 테이블 — 평균 O(1)의 조건

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)이 이길까요? 복잡도 표기가 감추는 두 가지 때문입니다.

[Int] 4개 — 캐시 라인 하나 0123 메모리 접근 1회 → 나머지는 캐시 히트 비교 4번 = 전부 레지스터·L1에서 ≈ 1 ns Set — 해시 계산 후 점프 hash(x) 계산 버킷 = h % n 계산 비용 + 예측 불가능한 위치로 점프 n이 작아도 이 고정 비용은 그대로 ≈ 4 ns (n과 무관하게) O(1)은 "n이 커져도 안 늘어난다"는 뜻이지 "항상 빠르다"가 아니다
점근 표기는 n이 무한대로 갈 때의 증가율만 말한다. 실제 코드에서 n이 5인지 500만인지는 표기에 안 들어 있고, 그 구간에서는 상수 인자와 캐시가 지배한다.
🧠 L4 그래서 실무 판단은 이렇게

"작으니까 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는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 끝까지 파는 자료가 허브에 이미 있습니다.

경험으로 말하기

🎤 틀 "Set이 빠릅니다"가 아니라 "무엇을 재고 무엇을 바꿨는가"

❌ "조회가 많아서 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 쓰지 말고, 앞부분만 쓰거나 체인이 길 때만 쓴다."

쉽게: 뷔페에서 접시에 다 담아 놓고 먹기(일반)와 먹을 때마다 하나씩 가져오기(lazy)의 차이입니다. 다 먹을 거면 한 번에 담는 게 낫고, 한두 개만 먹고 갈 거면 그때그때 가져오는 게 낫죠.
L3 Dictionary에 넣은 뒤 키의 프로퍼티를 바꾸면 어떻게 되나요?

그 원소를 다시 찾을 수 없게 됩니다. 그리고 크래시가 아니라 조용한 오작동이라 더 위험합니다.

이유는 저장 위치가 넣을 때 계산한 해시로 정해졌기 때문입니다. 해시에 들어가는 값이 바뀌면 새로 계산한 위치에는 아무것도 없습니다.

Swift에서는 값 타입이라 대체로 막히지만, 참조 타입 키면 뚫린다
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²)를 넣어 두면 그날 장애가 납니다. 반면 상수 인자 손해는 최악이어도 몇 배에서 멈춥니다.

정리: "복잡도로 재앙을 막고, 프로파일러로 상수를 줄인다." 순서를 바꾸면 안 됩니다 — 측정 없이 상수를 만지는 건 미신이고, 복잡도를 무시하는 건 시한폭탄입니다.

쉽게: 지도에서 "이 길은 막히면 심하게 막힌다"가 복잡도이고, "지금 이 시간엔 저 길이 5분 빠르다"가 실측입니다. 평소엔 실측이 맞지만, 차가 몰리는 날 재앙이 되는 건 항상 앞쪽입니다.

쉽게 이해하기

세 컬렉션을 물건 보관 방식으로 생각해 봅시다.

여기서 재미있는 사실. 물건이 4개뿐이면 창고 계산기를 쓰는 게 오히려 느립니다. 계산하는 시간에 4개를 그냥 눈으로 훑는 게 빠르거든요. 실측해 보니 16개쯤에서 뒤집혔습니다.

그리고 메모리 쪽에 함정이 둘 있습니다.

첫째, 책장은 항상 여유 칸을 둡니다. 책 40권을 꽂았는데 칸은 76개예요. 왜냐하면 칸이 꽉 찰 때마다 더 큰 책장으로 통째로 옮겨야 하는데, 한 칸씩 늘리면 이사를 너무 자주 하거든요. 그래서 이사할 때마다 크기를 확 키웁니다. 덕분에 이사 횟수가 확 줄어서 평균적으로는 한 권 꽂는 게 순식간이 됩니다. 대신 빈 칸이 남습니다.

둘째가 진짜 조심할 부분입니다. 책 1000권 중 한 권만 따로 표시해 두는 것처럼 보이는 게 있어요(슬라이스). 그런데 실제로는 "1000권짜리 책장 전체를 그대로 두고, 그중 한 권만 가리키는 것"입니다. 그래서 책장이 통째로 안 치워집니다. 실제로 세어 보니 1000권 전부 살아 있었습니다.

더 나쁜 건 이게 "물건을 잃어버린 것"이 아니라 "쓰고 있다고 우기는 것"이라 분실물 탐지기(Leaks 도구)에 안 걸린다는 점입니다. 그래서 오래 보관할 거면 반드시 그 한 권만 복사해서 새로 두고, 원래 책장과의 연결을 끊어야 합니다.

🔑 한 문장

컬렉션 선택은 "무엇을 보장받고 무엇을 포기할 것인가"이고, 복잡도는 그 선택의 결과다. 그리고 O(1)은 "커져도 안 늘어난다"는 뜻이지 "지금 빠르다"가 아니며, 메모리에서는 담은 만큼이 아니라 잡고 있는 만큼이 비용이다.

설계 근거 · 1차 자료

이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 수치는 전부 Swift 6.2.1 · arm64 실측입니다.

📌 각 자료가 뒷받침하는 것
  • TSPL Collection Types — 세 컬렉션이 각각 보장하는 것의 공식 정의
  • Array 문서 — 용량을 지수적으로 늘린다는 성장 정책과 amortized 비용의 근거
  • Hasher 문서 — 해시값을 저장하거나 실행 간 비교하면 안 된다는 공식 경고
  • ArraySlice 문서 — 슬라이스가 원본 스토리지 전체에 대한 참조를 들고 있다는 공식 서술