← Swift & Foundation 로드맵 10 / 33

10 · 컬렉션과 해싱

Array의 amortized O(1) append가 실제로 언제 깨지는지, Dictionary/Set이 체이닝이 아니라 오픈 어드레싱으로 충돌을 처리하는 이유, 순회 순서가 실행마다 달라지는 SipHash 시딩까지 직접 측정해 확인한다.
진행률
0 / 0 완료

Array, Dictionary, Set는 실무에서 매일 쓰지만 "왜 빠른가"를 물으면 대답이 흐려지기 쉬운 자료구조다. 이 챕터는 append가 "평균적으로" O(1)이라는 말이 정확히 무엇을 뜻하는지, Dictionary/Set이 충돌을 처리하는 진짜 방식(체이닝이 아니다), 순회 순서가 실행마다 달라지는 이유, Hashable을 직접 구현할 때 지켜야 할 계약, 그리고 reserveCapacity·ContiguousArray가 실제로 이득이 되는 조건까지 — 전부 이 문서를 쓰면서 직접 돌려본 실측 수치로 확인한다.

Q1. Array의 append가 amortized O(1)이라는 말의 정확한 의미와, 그것이 깨지는 조건은?

🔑 30초 답변

"amortized O(1)"은 append 한 번 한 번이 항상 상수 시간이라는 뜻이 아니라, n번 append를 연달아 호출했을 때 총 비용을 n으로 나눈 평균이 O(1)이라는 뜻이다. Array는 용량이 꽉 찰 때마다 지수적으로 더 큰 버퍼를 새로 할당해 기존 원소를 복사하는데, 이 "가끔 발생하는 O(n) 재할당"들의 총합이 이미 O(n)이라서 n으로 나누면 평균이 상쇄돼 O(1)이 된다. 이게 깨지는 조건은 세 가지다 — 재할당이 실제로 일어나는 그 한 번(그 호출만 O(n)), 다른 변수가 같은 버퍼를 참조 중이라 Copy-on-Write가 전체 복제를 강제하는 경우(용량이 남아 있어도 O(n)), 그리고 insert(_:at:)처럼 뒤 원소를 통째로 밀어야 하는 연산은 애초에 amortized조차 아니고 항상 O(n)이다.

원리

Apple 공식 문서는 append(_:)를 이렇게 설명한다: "Because arrays increase their allocated capacity using an exponential strategy, appending a single element to an array is an O(1) operation when averaged over many calls to the append(_:) method." 반면 insert(_:at:)는 뒤따르는 원소를 전부 한 칸씩 밀어야 해서 항상 O(n)이고, remove(at:)도 "뒤 원소를 앞으로 당겨 빈틈을 메운다"고 문서화돼 O(n)이다. 이 amortized 분석의 정확한 배수(정확히 2배인지, 다른 비율인지)는 표준 라이브러리가 공개로 보증하는 계약이 아니다swiftlang/swift의 설계 문서도 "amortized O(1) growth"라는 성질만 약속하고 정확한 성장 배수는 구현 세부사항으로 남겨둔다.

내부 동작

Array 값은 힙에 있는 버퍼 클래스 인스턴스를 가리키는 얇은 구조체다. mutating 메서드를 호출하면 먼저 isKnownUniquelyReferenced로 "이 버퍼를 나만 참조하고 있는가"를 검사한다. 그 다음 append 한 번은 두 경로 중 하나를 탄다. 용량이 남아 있고 나만 참조 중이면 그 자리에서 바로 써서 O(1). 용량이 꽉 찼다면(공유 여부와 무관하게) 더 큰 새 버퍼를 할당하고 기존 원소를 전부 복사해야 해서 그 한 번만 O(n). 그리고 용량이 남아 있어도 다른 변수가 같은 버퍼를 붙잡고 있다면(isKnownUniquelyReferenced가 false), 공유를 깨기 위해 어차피 새 버퍼로 복제해야 해서 이 역시 O(n)이다 — 이때는 용량 부족이 원인이 아니라 순전히 "공유 중"이라는 이유만으로 복제가 일어난다.

재할당이 일어나는 두 가지 서로 다른 이유 ① 용량 부족 — 기하급수적 재할당 (실측: 2→4→8→16→36→76) cap 2 cap 4 cap 8 cap 16 cap 36 cap 76 화살표 하나 = 기존 원소 전부 복사(O(n)), 딱 1번만 ② 버퍼 공유 — 용량은 남아도 복제(CoW) a b refCount=2, capacity 16, count 10 — 자리는 아직 남아 있다 b.append(x) → isKnownUniquelyReferenced(&buf) == false 새 버퍼, refCount=1, capacity 16 복사 O(n) — 원인은 공유이지 용량 부족이 아니다
재할당은 용량이 꽉 찼을 때(①)뿐 아니라, 용량이 남아 있어도 버퍼를 다른 변수가 공유하고 있으면(②) 일어난다.

실험 · 도구

capacity가 실제로 몇 단위로 뛰는지는 Array.capacity를 append 루프 중간중간 찍어보면 바로 보인다. 아래는 이 문서를 쓰면서 로컬 Swift 6.2.1(arm64, macOS 26)에서 swift 파일명.swift로 직접 실행해 얻은 실측 결과다.

capacity 증가 시점 관찰 — 실측 출력 그대로
var a: [Int] = []
var lastCap = -1
for i in 0..<70 {
    a.append(i)
    if a.capacity != lastCap {
        print("count=\(a.count) capacity=\(a.capacity)")
        lastCap = a.capacity
    }
}
// 실측 출력:
// count=1 capacity=2
// count=3 capacity=4
// count=5 capacity=8
// count=9 capacity=16
// count=17 capacity=36
// count=37 capacity=76

주목할 점: 16 다음이 정확히 32가 아니라 36이고, 그 다음도 정확히 72가 아니라 76이다. "항상 2배"라는 통념과 다르게, 실제 성장 배수는 공개된 계약이 아닌 구현 세부사항이라 버전에 따라 달라질 수 있다는 걸 그대로 보여준다. 공유로 인한 복제는 withUnsafeBufferPointer로 버퍼 주소를 찍어 대입 전후를 비교하면 확인할 수 있다(01장 Q2에서 다룬 실험과 동일한 기법).

프로젝트 적용

최종 크기를 미리 알 수 있는 벌크 로드라면 reserveCapacity로 중간 재할당을 없앤다. 다만 그 이득의 실제 크기는 원소 타입에 따라 크게 갈리는데, 실측치는 Q5에서 다룬다.

개수를 아는 벌크 로드는 reserveCapacity로 시작
func loadAllRows(count: Int) -> [Row] {
    var rows: [Row] = []
    rows.reserveCapacity(count)   // 중간 재할당을 미리 없앤다 — 실측은 Q5
    for i in 0..<count {
        rows.append(Row(id: i))
    }
    return rows
}

앞쪽에 계속 끼워 넣는 패턴(insert(_:at:0))은 amortized조차 아니라 매번 O(n)이다. 뒤에 append하고 필요할 때만 순서를 뒤집는 편이 총 비용이 훨씬 작다.

앞에 끼워 넣기(O(n)×n번) vs 뒤에 append + 필요할 때만 뒤집기
// ❌ 매번 맨 앞에 삽입 — 호출마다 O(n), 전체는 O(n²)
var recentBad: [LogLine] = []
func prependBad(_ line: LogLine) {
    recentBad.insert(line, at: 0)
}

// ✅ append는 amortized O(1) — 보여줄 때만 한 번 뒤집는다
var recentGood: [LogLine] = []
func appendGood(_ line: LogLine) {
    recentGood.append(line)
}
func displayOrder() -> [LogLine] {
    recentGood.reversed().map { $0 }   // 여기서만 O(n), 딱 필요한 순간에
}
⚠️ 흔한 오해

"Array의 append는 항상 O(1)이다"는 부정확하다 — 정확히는 amortized O(1)이며, 재할당이 실제로 일어나는 그 한 번은 O(n)이다. "CoW 덕분에 배열을 복사(let b = a)해도 절대 비용이 안 든다"도 절반만 맞다 — 복사 자체(대입)는 참조 카운트만 올리므로 공짜이지만, 그 뒤 어느 한쪽이라도 mutate하면 그 한 번의 mutate가 O(n) 복제를 떠안는다. 비용은 없어진 게 아니라 미뤄진 것뿐이다.

🧒 쉽게 이해하기

회의실에 사람이 한 명씩 올 때마다 의자를 한 개씩 사러 나가면 매번 손해다. 그래서 "이번엔 자리가 두 배쯤 더 필요하겠다" 싶을 때 통째로 의자를 왕창 사두면, 대부분의 손님은 그냥 앉기만 하면 된다. 가끔(의자가 다 찼을 때만) 다시 통째로 사입고 옮기는 큰 이벤트가 일어난다 — 그 큰 이벤트들을 손님 수로 나누면 평균은 작다는 게 amortized O(1)이다. 비유가 깨지는 곳: "자리가 부족할 때만" 큰 이벤트가 일어나야 할 것 같지만, Swift는 "내 의자를 다른 사람도 같이 쓰고 있다"(버퍼 공유)는 이유만으로도 자리가 남아 있는데 통째로 새로 사입는 이벤트를 일으킨다 — 실제 회의실엔 없는 규칙이다.

꼬리 질문

reserveCapacity(n)을 부른 뒤 n개보다 더 많이 append하면 무슨 일이 일어나는가?
다시 원래의 지수적 성장 정책으로 돌아가 재할당한다. reserveCapacity는 그 시점에 "최소 이만큼은 보장한다"는 약속일 뿐, 그 이후의 growth 정책 자체를 바꾸거나 잠그지 않는다.
쉽게 말하면 의자를 100개 미리 사놨는데 101번째 손님이 오면, 다시 평소처럼 "왕창 더 사입는" 이벤트가 일어난다.
capacity가 count보다 크더라도 항상 in-place mutate가 되는가?
아니다. 공유 여부(isKnownUniquelyReferenced) 검사가 용량 검사보다 먼저다. 공유 중이면 용량이 남아 있어도 새 버퍼로 복제한다 — 위 다이어그램 ②가 정확히 이 경우다.
쉽게 말하면 의자가 남아 있어도 그 의자가 "내 것"이 아니라 옆 사람과 같이 쓰는 의자라면, 낙서하기 전에 내 의자를 새로 사야 한다.
removeLast()insert(at:0)처럼 O(n)인가?
아니다, removeLast()는 맨 끝 원소 하나만 없애면 되고 뒤따르는 원소를 밀 필요가 없어 O(1)에 가깝다. remove(at:)/insert(_:at:)가 O(n)인 이유는 "뒤 원소 전체를 한 칸씩 이동"시켜야 하기 때문이고, 맨 끝을 건드리는 연산은 애초에 이동시킬 뒤 원소 자체가 없다.
쉽게 말하면 줄의 맨 뒤 사람을 빼는 건 아무도 안 밀어도 되지만, 맨 앞이나 중간 사람을 빼면 뒤에 선 모두가 한 칸씩 당겨서 서야 한다.

Q2. Swift의 Dictionary/Set은 충돌을 어떻게 처리하는가? 최악의 경우는 언제 생기는가?

🔑 30초 답변

Swift의 Dictionary/Set은 오픈 어드레싱(open addressing) + 선형 탐사(linear probing)로 충돌을 처리한다 — 원소는 버킷 배열 안에 직접 저장되고, 원하는 슬롯이 이미 차 있으면 정해진 순서로 다음 슬롯을 확인해 빈 자리를 찾는다. Java HashMap처럼 버킷마다 연결 리스트를 매다는 체이닝(chaining) 방식이 아니다 — 한국어 자료에 이 오해가 특히 잦다. 평균은 O(1)이지만, 해시가 소수의 버킷에 몰리도록 설계됐거나(공격) 나쁜 Hashable 구현을 쓰면 대부분의 탐사가 버킷 전체를 훑어야 해 최악의 경우 O(n)까지 저하된다.

원리

두 방식은 충돌을 저장하는 위치 자체가 다르다. 체이닝은 버킷 배열의 각 칸이 "포인터 하나"이고, 충돌한 원소들은 그 포인터가 가리키는 별도의 연결 리스트(혹은 트리)에 쌓인다. 오픈 어드레싱은 버킷 배열 자체가 원소를 직접 담고, 충돌하면 정해진 탐사 규칙(선형 탐사면 "바로 다음 슬롯")을 따라 빈 슬롯을 찾아 그 자리에 저장한다. Apple 공식 문서는 "A dictionary is a type of hash table, providing fast access to the entries it contains"라고만 설명하고 구체적인 충돌 처리 알고리즘까지는 명시하지 않지만, Swift 표준 라이브러리 구현은 오픈 어드레싱 + 선형 탐사 계열이다.

내부 동작

Swift의 네이티브 해시 버퍼는 버킷 개수가 2의 거듭제곱인 배열과, "이 슬롯이 차 있는가"를 표시하는 별도의 비트맵으로 이뤄져 있다. 삽입·조회 모두 키의 해시값을 버킷 수로 마스킹해 시작 슬롯을 정하고, 그 슬롯이 이미 다른 키로 차 있으면 순서대로 다음 슬롯을 확인해 나간다(선형 탐사). 이 구조 덕분에 조회 경로는 포인터를 따라 이곳저곳 튀는 대신 연속된 배열 위를 움직이므로 캐시 지역성이 좋다. 반대로 해시값이 소수의 슬롯에 몰리면(나쁜 Hashable 또는 의도적 공격) 탐사가 사실상 배열 전체를 순차 스캔하는 것과 같아져 O(n)으로 저하된다. 채워지는 정도(load factor)가 임계치를 넘으면 더 큰 버킷 배열로 전체를 재구성하는 리해시가 일어나는데, 이 리해시 비용도 Array의 재할당처럼 amortized로 상각된다.

체이닝 — Swift가 쓰지 않는 방식 (예: Java HashMap) bucket0 bucket1 bucket2 key A key B (충돌) 충돌 원소는 버킷 밖 별도 리스트에 쌓인다 오픈 어드레싱 + 선형 탐사 — Swift가 실제로 쓰는 방식 slot0: key X slot1key A 원래 자리 slot2key B가 여기 저장 slot3: 빈 슬롯 key A, B 둘 다 slot1을 원하면 B는 slot2로 탐사해 저장
체이닝은 버킷마다 리스트를 매달지만, Swift는 충돌한 원소를 버킷 배열 안의 다음 빈 슬롯에 그대로 저장한다.

실험 · 도구

hash(into:)를 일부러 모든 값이 같은 해시로 뭉치게 구현하면, 조회가 사실상 배열 전체를 훑는 최악의 경우를 재현할 수 있다. 아래는 실제로 돌려서 얻은 실측치다.

정상 Hashable vs 최악의 Hashable — 실측 결과
struct GoodKey: Hashable { let v: Int }

struct BadKey: Hashable {
    let v: Int
    func hash(into hasher: inout Hasher) {
        hasher.combine(0)   // 모든 값이 같은 해시로 뭉친다 — 최악의 Hashable
    }
    static func == (l: BadKey, r: BadKey) -> Bool { l.v == r.v }
}

let n = 3000
// good hash, n=3000 삽입 3000회 + 조회 3000회: 0.001024 초
// bad hash(모두 충돌), 동일 작업: 0.026476 초  ← 약 26배 느림

키 3000개에 조회까지 포함한 이 작업이 나쁜 해시에서 약 26배 느려졌다 — 오픈 어드레싱에서 해시가 몰리면 탐사가 곧 선형 스캔이 된다는 것을 그대로 보여준다.

프로젝트 적용

커스텀 Hashable을 만들 때는 반드시 실제로 값을 구별하는 프로퍼티를 combine에 먹인다. 구별력 없는 해시는 성능을 이 실험처럼 떨어뜨린다(계약 위반 여부는 Q4에서 다룬다).

구별력 있는 필드를 combine — 웬만하면 컴파일러 합성에 맡긴다
// 대부분은 이걸로 충분하다 — 컴파일러가 모든 저장 프로퍼티를 자동으로 combine한다
struct OrderKey: Hashable {
    let orderId: String
    let lineNumber: Int
}

삽입할 개수를 미리 안다면 init(minimumCapacity:)로 리해시 횟수를 줄인다.

개수를 아는 집계 작업엔 minimumCapacity로 시작
var counts = [String: Int](minimumCapacity: 10_000)
for word in words {
    counts[word, default: 0] += 1
}
⚠️ 흔한 오해

"Swift Dictionary/Set은 Java HashMap처럼 체이닝으로 충돌을 처리한다"는 잦은 오해이자 틀린 설명이다 — Swift는 오픈 어드레싱 + 선형 탐사를 쓴다. 반대로 "오픈 어드레싱이라 최악의 경우는 없다"도 틀렸다 — 해시가 소수의 슬롯에 몰리면(나쁜 Hashable 또는 의도적 해시 플러딩) 선형 탐사가 사실상 배열 전체를 훑는 것과 같아져 O(n)이 된다.

🧒 쉽게 이해하기

주차장에서 내 번호(해시)에 해당하는 자리가 이미 찼으면, 옆 자리부터 순서대로 살펴서 처음 만나는 빈 자리에 주차한다 — "내 자리 옆에 임시 주차 리스트를 따로 만드는" 체이닝 방식이 아니다. 비유가 깨지는 곳: 실제 주차장에서는 아무 빈자리에나 세워도 되지만, Dictionary의 탐사 순서는 미리 정해진 규칙(정확히 다음 슬롯)이라 사람 마음대로 고르는 게 아니다. 그리고 나중에 그 물건을 다시 찾을 때도 똑같은 순서로 훑어야 하므로, 애초에 특정 번호로 자리가 몰리게 설계된 상황(나쁜 해시)이면 찾을 때마다 주차장을 거의 다 훑어야 한다.

꼬리 질문

체이닝(Java HashMap)과 비교했을 때 오픈 어드레싱의 실무적 장점과 단점은?
장점은 캐시 지역성이다 — 포인터를 따라 이곳저곳 튀지 않고 연속된 배열 위만 움직이므로 현대 CPU의 캐시 구조에 유리하다. 단점은 삭제가 더 까다롭고(단순히 슬롯을 비우면 그 이후의 탐사 경로가 끊길 수 있다), load factor가 높아질수록 성능이 급격히 나빠지는 경향이 체이닝보다 두드러진다는 점이다.
쉽게 말하면 체이닝은 집집마다 우편함 옆에 추가 상자를 매다는 것이고, 오픈 어드레싱은 그냥 옆집 문을 두드리는 것이다. 옆집 문 두드리기가 이동 거리는 짧지만, 동네가 이미 꽉 차 있으면 온 동네를 다 돌아야 할 수도 있다.
init(minimumCapacity:)를 안 주고 삽입만 반복하면 무슨 일이 생기나?
내부 버킷 배열이 채워질 때마다 더 큰 배열로 리해시(기존 원소를 전부 다시 해시해 새 위치에 재배치)하는 일이 반복된다. Array의 재할당처럼 amortized O(1) 삽입은 여전히 유지되지만, 실측 성능은 미리 용량을 확보해둔 경우보다 느릴 수 있다.
쉽게 말하면 이사할 때마다 더 큰 집으로 짐을 다시 정리해서 옮기는 것과 같다 — 처음부터 큰 집으로 갔으면 안 해도 될 일이다.
왜 해시가 몰리는 최악의 경우가 보안 문제(해시 플러딩)로 이어지는가?
공격자가 서버 API의 입력값을 조작해 의도적으로 같은 해시를 갖는 키를 잔뜩 만들어 넣으면, 그 Dictionary/Set의 삽입·조회가 O(n)으로 느려져 서버 자원을 소진시키는 서비스 거부(DoS) 공격이 된다. Swift가 이를 어떻게 막는지는 Q3에서 다룬다.
쉽게 말하면 놀이공원에서 한 줄만 골라 다 같이 새치기하면 그 줄만 완전히 마비되는 것과 비슷하다.

Q3. Set/Dictionary 순회 순서가 실행마다 달라지는 이유는? — SipHash 시딩과 SWIFT_DETERMINISTIC_HASHING

🔑 30초 답변

Set/Dictionary의 순회 순서는 원소들의 해시값이 결정하는 버킷 위치에서 나온다. Swift 4.2부터 이 해시 계산에 쓰이는 시드가 프로세스를 실행할 때마다 새로 무작위 생성되기 때문에, 똑같은 코드와 똑같은 내용의 Set이라도 실행할 때마다 순회 순서가 달라진다. 이건 버그가 아니라 해시 플러딩 공격(Q2)을 막기 위한 보안 설계다. SWIFT_DETERMINISTIC_HASHING=1 환경 변수는 이 랜덤화를 끄고 고정 시드를 쓰게 하는데, 테스트 전용이며 프로덕션에 쓰면 안 된다.

원리

Swift Forums의 stdlib 팀 공지는 이 설계를 명확히 설명한다: 해시 함수로 SipHash-1-3을 쓰고(구현 세부사항, 향후 바뀔 수 있음), 해시 플러딩 공격을 막기 위해 실행마다 랜덤 128비트 시드를 사용한다는 것이다. Hasher 공식 문서도 "같은 실행 내에서는 동일 입력이 항상 동일 해시값을 내지만, 실행마다 시드가 다시 부여돼 값이 달라지므로 해시값을 저장하거나 실행 간 비교하면 안 된다"고 명시한다.

내부 동작

런타임은 프로세스가 시작할 때 128비트 랜덤 시드를 한 번 만들어 그 프로세스의 모든 Hasher 인스턴스가 공유하는 SipHash 키로 쓴다. 여기서 한 걸음 더 들어가면: Swift 5부터는 컬렉션 인스턴스마다 자신이 쓰는 스토리지 버퍼의 메모리 주소까지 시드에 섞는다. 그래서 SWIFT_DETERMINISTIC_HASHING=1을 켜서 랜덤 시드를 고정해도, 서로 다른 프로그램(혹은 메모리 배치가 다른 두 실행)에서 완전히 같은 순회 순서를 재현하려면 "그 Set들이 정확히 같은 메모리 주소에 할당돼야" 하므로 사실상 보장되지 않는다. 이 환경 변수가 실제로 유용한 상황은 "같은 프로그램을 몇 번이고 다시 실행해도 항상 같은 순서가 나오게 해서 스냅샷 테스트를 안정시키는" 좁은 목적뿐이다.

let s: Set<Int> = [1,2,...,10] — 실행 1 vs 실행 2 실행 1 — 랜덤 시드 A SipHash(1, seedA) → 버킷 위치 [1,5,2,3,7,9,4,10,8,6] 실행 2 — 랜덤 시드 B SipHash(1, seedB) → 다른 버킷 위치 [6,3,5,9,7,8,10,4,2,1] 내용은 완전히 같지만(1~10), 시드가 다르면 순서가 다르다 — 실측 그대로 SWIFT_DETERMINISTIC_HASHING=1 — 두 실행 모두 [4,2,3,6,8,5,9,1,10,7] (동일, 테스트 전용)
랜덤 시드일 때는 실행마다 순서가 다르지만, SWIFT_DETERMINISTIC_HASHING=1로 고정하면 같은 프로그램 안에서는 실행마다 같은 순서가 재현된다.

실험 · 도구

같은 코드를 여러 번 실행해보면 바로 확인된다. 아래는 실제로 4번 실행해 얻은 결과다.

같은 Set을 여러 번 실행 — 실측 출력 그대로
let s: Set<Int> = [1,2,3,4,5,6,7,8,9,10]
print(Array(s))

// 실행 1 (랜덤 시드): [1, 5, 2, 3, 7, 9, 4, 10, 8, 6]
// 실행 2 (랜덤 시드): [6, 3, 5, 9, 7, 8, 10, 4, 2, 1]
// 실행 3 (SWIFT_DETERMINISTIC_HASHING=1): [4, 2, 3, 6, 8, 5, 9, 1, 10, 7]
// 실행 4 (SWIFT_DETERMINISTIC_HASHING=1): [4, 2, 3, 6, 8, 5, 9, 1, 10, 7]  ← 3번과 동일

랜덤 시드일 때는 두 실행의 순서가 완전히 다르고, 환경 변수로 시드를 고정하면 같은 프로그램을 다시 돌려도 순서가 그대로 재현된다.

프로젝트 적용

Dictionary/Set 내용을 JSON으로 내보내거나 UI 리스트로 보여줄 때는 절대 순회 순서를 그대로 쓰지 말고 명시적으로 정렬한다.

순회 순서를 그대로 내보내지 말고 정렬
// ❌ 실행마다 다른 순서로 인코딩될 수 있다
let tags: Set<String> = ["swift", "ios", "network"]
let json = try JSONEncoder().encode(Array(tags))

// ✅ 정렬해서 실행에 상관없이 같은 순서를 보장
let sortedJSON = try JSONEncoder().encode(tags.sorted())

스냅샷 테스트나 골든 파일 비교에 Set/Dictionary 순회를 그대로 쓰면 CI가 랜덤하게 깨진다 — 비교 전에 항상 정렬한다.

테스트 비교는 정렬 후에
func sameElements(_ a: Set<String>, _ b: Set<String>) -> Bool {
    a.sorted() == b.sorted()   // Array(a) == Array(b)는 순서가 달라 실패할 수 있다
}
⚠️ 흔한 오해

"SWIFT_DETERMINISTIC_HASHING=1이면 어느 프로그램에서 실행하든 항상 같은 순서가 나온다"는 오해다 — 버퍼 메모리 주소까지 시드에 섞이므로, 메모리 배치가 다른 두 실행에서는 여전히 달라질 수 있다. "Dictionary/Set의 순회 순서는 삽입 순서를 따른다"도 틀렸다 — 순서가 필요하면 애초에 Array나 정렬된 키 목록처럼 순서를 보장하는 자료구조를 따로 써야 한다.

🧒 쉽게 이해하기

매일 아침 제비뽑기로 줄 서는 순서를 다시 정하는 반에 비유할 수 있다. 학생 명단(내용)은 같아도, 그날그날 뽑기 기계(랜덤 시드)가 다시 섞이면 줄 서는 순서가 매번 달라진다. 비유가 깨지는 곳: 진짜 제비뽑기는 "오늘은 정해진 순서대로 하자"고 하면 그대로 따르지만, Swift는 SWIFT_DETERMINISTIC_HASHING=1로 "정해진 순서"를 시켜도 반 편성 교실(메모리 주소)이 학교(프로그램)마다 다르면 여전히 결과가 달라질 수 있다.

꼬리 질문

이 랜덤화가 없다면 어떤 공격이 가능한가?
공격자가 해시 충돌을 유발하는 입력을 미리 계산해 서버에 넣으면 Dictionary/Set 연산이 O(n)으로 저하돼 CPU를 고갈시키는 해시 플러딩 DoS 공격이 가능하다(Q2). 시드를 프로세스마다 무작위로 만들면 공격자가 "이 해시 함수로는 항상 충돌하는 입력"을 미리 계산해둘 수 없다.
쉽게 말하면 매번 자물쇠 번호(시드)가 바뀌면, 미리 알아낸 비밀번호(공격용 입력)가 다음 날엔 안 통하는 것과 같다.
hashValue를 저장했다가 나중에(다른 실행에서) 비교하면 무슨 문제가 생기는가?
시드가 프로세스마다 다르므로 다른 실행에서 계산한 해시값과 같을 이유가 없고, 알고리즘 자체도 stdlib 버전에 따라 바뀔 수 있어 저장된 해시값은 다음 실행이나 다음 버전에서 아무 의미가 없다.
쉽게 말하면 오늘 뽑은 복권 번호를 내일 다시 확인하려는 것과 같다 — 추첨기가 매일 새로 세팅되므로 어제 번호는 오늘 아무 의미가 없다.
같은 프로세스, 같은 실행 안에서 내용이 똑같은 두 Set을 만들면 순회 순서가 항상 같은가?
보장되지 않는다. 컬렉션 인스턴스별로 자신의 버퍼 메모리 주소까지 시드에 섞이기 때문에, 같은 프로세스 안이라도 두 Set이 서로 다른 메모리 위치에 할당됐다면 순서가 달라질 수 있다.
쉽게 말하면 같은 학교, 같은 날이라도 두 반이 서로 다른 교실(메모리 주소)에서 각자 제비뽑기를 하면, 학생 명단이 같아도 줄 서는 순서는 반마다 다를 수 있다.

Q4. Hashable을 직접 구현할 때 Hasher를 어떻게 써야 하고, 무엇을 하면 안 되는가?

🔑 30초 답변

hash(into:) 안에서는 hasher.combine(_:)동등성 비교(==)에 실제로 관여하는 프로퍼티만 먹여야 한다 — 옛날 Swift(hashValue를 직접 XOR·합산하던 시절)의 관용구를 흉내 내면 안 된다. 절대 하면 안 되는 두 가지는 (1) ==에 안 쓰는 프로퍼티를 combine에 섞는 것(같다고 판정되는 두 값의 해시가 달라져 계약 위반), (2) 해시값을 저장하거나 실행·버전 간 비교하는 것(Q3에서 다룬 시드·알고리즘이 안정적이지 않아서). Set에 넣은 뒤 해시에 관여하는 프로퍼티를 몰래 바꾸면, 그 원소는 여전히 컬렉션 안에 있지만 어떤 조회로도 다시 찾을 수 없는 "유령 원소"가 된다.

원리

Hashable의 계약은 한 방향이다: a == b이면 반드시 ab의 해시가 같아야 한다(역은 필요 없다 — 해시가 같아도 ==가 거짓일 수 있고, 그게 바로 충돌이다). 이 계약이 깨지면 Set/Dictionary가 내부적으로 하는 최적화 — "같은 버킷·같은 탐사 경로에 있는 원소끼리만 ==로 최종 확인한다" — 자체가 무너진다. 해시가 다른데 실제로는 같은 값이면 애초에 같은 버킷을 찾아가지도 못하니 == 비교 기회조차 얻지 못한다.

내부 동작

Hasher는 Set/Dictionary가 조회할 때마다 내부적으로 새로 만들어 쓰는 값 타입이다. 사용 패턴은 var hasher = Hasher(); hasher.combine(x); let h = hasher.finalize()인데, Hashable을 채택하면 이 과정을 hash(into:) 하나로 합쳐서 쓴다 — finalize()는 컴파일러가 자동으로 호출해주므로 직접 부를 필요가 거의 없다. 여기서 "무엇을 combine해야 하는가"가 헷갈리기 쉬운데, 정확한 기준은 "그보다 적게 combine하는 건 괜찮지만(해시 분포만 나빠진다), 그보다 많이 combine하면 안 된다(계약 위반)"이다.

상황규칙어기면
==에 쓰는 프로퍼티반드시 combine에 포함일부만 빠뜨리면 해시 분포만 나빠짐(계약 위반은 아님)
==에 안 쓰는 프로퍼티combine에 넣으면 안 됨a==b인데 해시가 달라짐 — 계약 위반, 조회 실패
해시값(Int) 자체저장·직렬화·실행 간 비교 금지시드·알고리즘이 실행/버전마다 달라짐(Q3)

실험 · 도구

Set에 넣은 뒤 해시에 쓰이는 프로퍼티를 몰래 바꾸면 무슨 일이 생기는지 실제로 재현해봤다.

삽입 후 해시 관련 프로퍼티를 바꾸는 버그 — 실측 출력 그대로
final class Tag: Hashable {
    var name: String
    init(_ name: String) { self.name = name }
    static func == (l: Tag, r: Tag) -> Bool { l.name == r.name }
    func hash(into hasher: inout Hasher) { hasher.combine(name) }
}

var set: Set<Tag> = [Tag("swift")]
let t = set.first!
t.name = "kotlin"   // Set에 넣은 뒤, hash(into:)가 쓰는 프로퍼티를 몰래 바꿔버린다
print("contains(t):", set.contains(t))
print("contains(kotlin):", set.contains(Tag("kotlin")))
print("contains(swift):", set.contains(Tag("swift")))
print("count:", set.count, "elements:", set.map { $0.name })

// 실측 출력:
// contains(t): false
// contains(kotlin): false
// contains(swift): false
// count: 1 elements: ["kotlin"]

t 자기 자신을 조회해도, 바뀐 이름("kotlin")으로 조회해도, 원래 이름("swift")으로 조회해도 셋 다 false다. 그런데 set.count는 여전히 1이고 map으로 직접 순회하면 "kotlin"이라는 원소가 실제로 들어 있다 — 존재는 하지만 어떤 조회로도 찾을 수 없는 원소가 만들어진 것이다. t는 옛 해시값이 가리키던 버킷 위치에 그대로 남아 있는데, 새 이름으로 계산한 해시는 다른 버킷을 가리키기 때문이다.

프로젝트 적용

여러 필드가 있는 타입에서 ==가 비교하는 필드와 hash(into:)가 combine하는 필드를 정확히 맞춘다. 가능하면 컴파일러 합성에 맡기는 편이 이 실수 자체를 원천 차단한다.

==와 hash(into:)가 보는 필드를 맞춘다
struct UserKey: Hashable {
    let id: String
    let displayName: String   // == 비교에는 쓰지 않는다

    static func == (l: UserKey, r: UserKey) -> Bool { l.id == r.id }
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)          // ✅ ==가 실제로 비교하는 프로퍼티만 combine
        // ❌ hasher.combine(displayName) — ==는 무시하는데 해시는 달라져 버린다
    }
}

Set/Dictionary에 넣은 값에서 hash(into:)가 참조하는 프로퍼티는, 그 값이 컬렉션 안에 있는 동안은 불변으로 취급한다. 꼭 바꿔야 한다면 꺼냈다가 바꾸고 다시 넣는다.

해시에 관여하는 프로퍼티를 바꿀 땐 꺼냈다가 다시 넣는다
var tags: Set<Tag> = [Tag("swift")]
if let existing = tags.first(where: { $0.name == "swift" }) {
    tags.remove(existing)     // 먼저 꺼내고
    existing.name = "kotlin"  // 그 다음에 바꾸고
    tags.insert(existing)     // 새 해시 위치로 다시 넣는다
}
⚠️ 흔한 오해

"hashValue를 프로퍼티별로 XOR·합산해서 손으로 만들면 안전하다"는 옛 관용구는 더 이상 권장되지 않는다 — SipHash 기반 Hasher가 훨씬 균등한 분포와 해시 플러딩 방어를 제공하므로 손으로 조합하지 말고 combine을 쓴다. 또 "값이 같으면(==) 모든 프로퍼티를 combine해야 한다"는 것도 오해다 — 정확히는 "==가 실제로 비교하는 프로퍼티만" combine하면 되고, 그보다 적게 combine하는 건 계약 위반이 아니라 그냥 분포가 나빠질 뿐이다.

🧒 쉽게 이해하기

학생을 구분하는 기준(==)이 "학번"이라면, 학생증 번호(해시)도 학번만으로 만들어야 한다. 만약 "오늘 기분"까지 학생증 번호에 섞어 넣으면, 같은 학생(학번 같음)인데도 어제와 오늘 학생증 번호가 달라져서 출석부에서 그 학생을 못 찾게 된다. 비유가 깨지는 곳: 실제 학생증은 한 번 발급하면 안 바뀌지만, 위 Tag 버그처럼 Swift Set에 넣은 뒤 "기분"에 해당하는 값을 몰래 바꿔버리면 학생증 번호와 실제 특징이 어긋나 버린다 — 사람이라면 바로 눈치채겠지만 컴퓨터는 그 어긋남을 스스로 확인할 방법이 없다.

꼬리 질문

컴파일러가 자동 합성(synthesized)해주는 Hashable을 쓰면 이 버그를 피할 수 있나?
합성된 Hashable==hash(into:)도 "모든 저장 프로퍼티"를 똑같은 기준으로 자동 생성하므로 둘이 어긋나는 버그 자체가 원천 차단된다. 다만 저장 프로퍼티 중 일부(예: 캐시용 필드)를 동등성 판단에서 빼고 싶다면 여전히 손으로 ==hash(into:)를 같이 짜야 하고, 그 순간부터 둘을 맞출 책임은 다시 개발자에게 돌아온다.
쉽게 말하면 자동 합성은 "학번만 보고 학생증도 학번만으로 만들어주는 기계"라 어긋날 일이 없지만, 기계를 안 쓰고 손으로 만들면 실수할 여지가 생긴다.
mutable class를 Dictionary 키로 쓰는 게 일반적으로 위험한 이유는?
class는 참조 타입이라 삽입 후에도 외부에서 그 인스턴스의 프로퍼티를 계속 바꿀 길이 열려 있고, 그 프로퍼티가 hash(into:)에 관여하면 위에서 재현한 "유령 원소" 버그가 언제든 다시 나타날 수 있다. 키로 쓸 타입은 값 타입으로 만들거나, class라면 해시에 관여하는 프로퍼티를 let으로 불변화하는 편이 안전하다.
쉽게 말하면 이름표를 손으로 쓴 옷걸이(class, 참조)를 옷장 특정 칸에 걸어뒀는데, 나중에 누가 이름표를 몰래 고쳐버리면 그 칸을 다시 찾을 수가 없다 — 아예 처음부터 지워지지 않는 인쇄 이름표(불변 값)를 쓰는 게 안전하다.
Set.contains(_:)는 내부적으로 몇 번의 == 비교를 하는가?
평균적으로는 한 번에 가깝다 — 같은 버킷·같은 탐사 경로에서 해시가 같은 원소를 만났을 때만 ==로 최종 확인하기 때문이다. 하지만 해시 충돌이 몰릴수록(Q2의 최악의 경우) 탐사 경로 위의 여러 원소와 순서대로 == 비교를 해야 해서 그 횟수가 늘어난다.
쉽게 말하면 해시가 잘 퍼져 있으면 물건을 찾을 때 딱 한 서랍만 열어보면 되지만, 다 같은 서랍에 몰려 있으면 그 서랍 안 물건을 하나하나 확인해야 한다.

Q5. reserveCapacityContiguousArray는 언제 실제로 이득인가?

🔑 30초 답변

reserveCapacity는 append 도중 몇 차례 일어날 재할당(그때마다 기존 원소 전체를 복사)을 미리 없애주는데, 그 이득의 크기는 "원소 하나를 복사하는 비용"에 비례한다. Int처럼 값 하나가 그냥 8바이트 복사인 경우엔 실측으로도 차이가 거의 없었지만, String 필드가 섞인 struct처럼 복사마다 참조 카운팅이 딸린 무거운 원소에서는 실측으로 5배 넘게 차이가 났다. ContiguousArray는 원소가 class·@objc 프로토콜 타입일 때만 의미가 있다 — NSArray로 브릿징될 가능성 자체를 없애 그 검사 경로를 스킵하는데, 순수 값 타입 원소에는 Array도 이미 연속 메모리라 실질적 차이가 없다. 과장하지 않는 게 중요하다.

원리

Array는 이미 "충분히 좋은" 기하급수적 성장 정책을 갖고 있으므로(Q1), reserveCapacity의 이득은 "재할당 이벤트 수(대략 log n번)를 얼마나 줄이느냐"가 아니라 "그 한 번 한 번의 재할당이 얼마나 비싼가"에 달려 있다. 원소가 순수 값 타입(ARC 없음)이면 재할당 시 복사는 그냥 memcpy라 몇 번 덜 하나 더 하나 차이가 크지 않다. 원소에 String이나 클래스 참조가 섞여 있으면, 재할당마다 기존 원소 전부를 retain/release하며 옮겨야 해서 그 비용이 실제로 드러난다. ContiguousArrayArray와 저장 방식이 거의 같지만 NSArray 브릿징 지원 자체를 포기하는 대신, Element가 class/@objc 프로토콜일 때 있던 "브릿징 가능성 검사" 경로를 없애 더 예측 가능한 성능을 낸다 — Element가 struct/enum이면 그 검사 경로 자체가 애초에 없으므로 차이도 없다.

내부 동작

실측은 두 단계로 진행했다. 처음엔 순수 Int 배열로 swift(최적화 없이) 돌렸더니 차이가 거의 잡음 수준이었다. 그래서 -O로 최적화를 켜고, 복사 비용이 실제로 드는 String 필드가 섞인 struct로 다시 측정했다 — 이 과정 자체가 "벤치마크는 반드시 -O로, 그리고 원소가 무거운 케이스로 돌려야 진짜 신호가 보인다"는 교훈이다.

실험reserveCapacity 없음reserveCapacity 있음차이
Int append, n=300,000 (최적화 없음)0.0254초0.0250초~2% — 사실상 잡음
Rec(Int×4+String) append, n=2,000,000 (-O)0.0358초0.0067초~5.3배
실험Array<Box>(class 원소)ContiguousArray<Box>차이
순회 합산, n=1,000,0000.0136초0.0087초~1.56배

실험 · 도구

reserveCapacity — 원소 종류에 따라 이득이 달라진다 (실측)
import Foundation
func time(_ label: String, _ block: () -> Void) {
    let start = Date()
    block()
    print("\(label): \(Date().timeIntervalSince(start))s")
}
let n = 300_000
time("append, no reserveCapacity") {
    var a: [Int] = []
    for i in 0..<n { a.append(i) }
}
time("append, reserveCapacity(n)") {
    var a: [Int] = []
    a.reserveCapacity(n)
    for i in 0..<n { a.append(i) }
}
// 실측(최적화 없음): 0.025367s vs 0.024960s — 차이는 잡음 수준

struct Rec { var a, b, c, d: Int; var s: String = "0123456789abcdef" }
let n2 = 2_000_000
time("Rec append, no reserve") {
    var a: [Rec] = []
    for i in 0..<n2 { a.append(Rec(a: i, b: i, c: i, d: i)) }
}
time("Rec append, reserveCapacity(n2)") {
    var a: [Rec] = []
    a.reserveCapacity(n2)
    for i in 0..<n2 { a.append(Rec(a: i, b: i, c: i, d: i)) }
}
// swift -O로 실행한 실측: 0.035768s vs 0.006719s — 약 5.3배
Array<Box> vs ContiguousArray<Box> — class 원소 순회 (실측)
final class Box { var v = 0 }
let n3 = 1_000_000
let src = (0..<n3).map { _ in Box() }
time("Array<Box> sum") {
    var s = 0
    let arr = Array(src)
    for b in arr { s += b.v }
    _ = s
}
time("ContiguousArray<Box> sum") {
    var s = 0
    let carr = ContiguousArray(src)
    for b in carr { s += b.v }
    _ = s
}
// 실측: 0.013644s vs 0.008701s — 약 1.56배

프로젝트 적용

최종 크기를 알 수 있는 벌크 로드에서 무거운 원소(문자열·클래스가 섞인 struct)를 담을 때는 reserveCapacity를 꼭 쓴다. 순수 Int/Double 배열이라면 이득이 미미하니, 가독성을 해치면서까지 억지로 넣을 가치는 적다.

String 필드가 있는 struct 벌크 파싱엔 reserveCapacity가 실제로 이득
struct Row { let id: Int; let name: String; let email: String }

func parse(_ lines: [String]) -> [Row] {
    var rows: [Row] = []
    rows.reserveCapacity(lines.count)   // Row엔 String이 둘 — 재할당마다 ARC 트래픽이 실제로 든다
    for line in lines {
        rows.append(makeRow(from: line))
    }
    return rows
}

원소가 class(또는 @objc 프로토콜)이고 NSArray로 내보낼 일이 전혀 없는 순수 Swift 내부 컬렉션이라면 ContiguousArray로 선언해 브릿징 검사 경로를 원천적으로 없앤다. 값 타입 원소에는 바꿀 이유가 없다.

NSArray로 나갈 일 없는 내부 전용 버퍼는 ContiguousArray
final class Particle {
    var x, y: Double
    init(x: Double, y: Double) { self.x = x; self.y = y }
}

// 시뮬레이션 내부에서만 도는, ObjC로 내보낼 일 없는 버퍼
var particles = ContiguousArray<Particle>()
particles.reserveCapacity(10_000)
⚠️ 흔한 오해

"reserveCapacity는 항상 눈에 띄게 빠르다"는 과장이다 — 위 실측처럼 원소가 가벼운 값 타입이면 차이가 잡음 수준으로 사라진다. "ContiguousArrayArray보다 무조건 빠르다"도 틀렸다 — 차이는 오직 class/@objc 원소일 때만 나타나고, struct/enum 원소에는 사실상 같다(내부 저장 방식이 이미 똑같이 연속적이라서).

🧒 쉽게 이해하기

이사 갈 때 상자를 몇 개 쓸지 미리 계산해서 한 번에 다 준비해두는 것(reserveCapacity)은, 옮기는 짐이 무거운 가구(String·class 필드)일 때는 왕복 횟수를 확 줄여주는 큰 이득이지만, 옮기는 짐이 젓가락 한 벌(순수 Int)처럼 가벼우면 상자를 미리 준비하나 그때그때 하나씩 사러 나가나 큰 차이가 없다. 비유가 깨지는 곳: 실제 이사에서는 "상자 왕복 횟수"가 눈에 보이지만, Swift의 재할당 횟수는 코드만 봐서는 안 보여서 이렇게 직접 시간을 재보지 않으면 어느 쪽이 진짜 이득인지 알 수가 없다.

꼬리 질문

reserveCapacity(n)에 실제 필요한 것보다 훨씬 큰 n을 넘기면 어떻게 되나?
그만큼의 메모리를 미리 확보해두므로, 실제로 채우지 않을 만큼 과도하게 크면 불필요한 메모리 낭비가 된다. 정확한 개수를 모른다면 대략의 상한(예: 페이지네이션 한 페이지 크기)만 줘도 재할당 횟수를 줄이는 효과는 대부분 얻을 수 있다.
쉽게 말하면 손님이 몇 명 올지 모른다고 의자를 500개 미리 깔아두면, 안 쓰는 의자가 자리만 차지한다.
Dictionary/Set에도 reserveCapacity에 해당하는 게 있는가?
있다. init(minimumCapacity:)(Q2)나 reserveCapacity(_:) 메서드로 동일한 논리가 적용된다 — 미리 채워질 개수를 알면 리해시 횟수를 줄일 수 있다.
쉽게 말하면 Array든 Dictionary든 "미리 몇 개 담을지 알려주는" 창구는 똑같이 있다.
ContiguousArray를 NSArray가 필요한 API에 그대로 넘기면 무슨 일이 일어나는가?
컴파일이 안 된다 — ContiguousArray는 애초에 _ObjectiveCBridgeable을 채택하지 않아 NSArray로 브릿징되는 경로 자체가 없다. ObjC API에 넘기려면 먼저 Array(contiguousArray)로 변환해야 하고, 그 변환 자체에 원소 개수만큼의 비용이 든다.
쉽게 말하면 처음부터 "번역기가 없는 언어"로 적어둔 문서라, 번역이 필요한 곳에 그냥 들고 가면 안 통한다 — 다시 번역기가 있는 문서(Array)로 옮겨 적어야 한다.

출처 · 참고자료