← 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] = []로 초기화하는 게 공짜인 이유가 이겁니다.

countcapacity는 다르다

실측 — 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 (테스트 전용)

⑤ 슬라이스는 원본 버퍼 전체를 붙잡는다 🔥 메모리 함정

이게 이 챕터에서 실무 사고로 가장 많이 이어지는 항목입니다. ArraySliceSubstring은 "필요한 부분만 잘라 가벼운 것"처럼 보이지만 정반대입니다.

실측 — 원소 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 문서 — 슬라이스가 원본 스토리지 전체에 대한 참조를 들고 있다는 공식 서술