← iOS 개발자 CS 로드맵 6 / 35

06 · 자료구조와 알고리즘

복잡도·컬렉션·해시·이진탐색·정렬 안정성·페이지 병합을 CS 원리와 iOS 런타임(값 타입·COW·해시 시딩) 양쪽으로 답하는 심화 7문항
진행률
0 / 0 완료

자료구조·알고리즘 면접의 핵심은 복잡도 표를 외우는 게 아니라, 왜 그 복잡도가 나오는지iOS 런타임(값 타입·Copy-on-Write·해시 시딩)이 그 이론을 어떻게 비트는지를 함께 말하는 것이다. 아래 7문항을 그 두 축으로 답한다.

Q1. Array, Set, Dictionary의 평균·최악 시간 복잡도는 무엇인가?

🔑 30초 답변

Array는 인덱스 접근이 O(1), 끝에 추가는 amortized(분할상환) O(1), 중간 삽입·삭제와 값 검색은 O(n)이다. Set·Dictionary는 해시 기반이라 조회·삽입·삭제가 평균 O(1)이지만 최악 O(n)(모든 키가 한 버킷에 몰릴 때)이다. Swift는 실행마다 해시 시드를 무작위화해 이 최악을 악의적으로 유발하기 어렵게 만든다. 실무에서 더 자주 발목을 잡는 건 Array의 COW 복사 비용과 removeFirst()가 O(n)이라는 함정이다.

CS 원리

배열은 원소가 연속된 메모리에 놓이므로 주소 = base + i × stride 계산 한 번으로 임의 인덱스에 O(1)로 닿는다. 끝에 추가할 때 용량이 남으면 O(1)이고, 꽉 차면 더 큰 버퍼를 새로 잡아 전체를 복사하므로 그 한 번은 O(n)이다. 다만 용량을 대개 2배씩 늘리기 때문에 이 비싼 복사가 드물게 일어나 여러 번의 append에 비용을 나누면 평균 O(1)이 된다(amortized). 중간 삽입·삭제는 뒤 원소를 한 칸씩 밀어야 해 O(n)이다.

append 8번 — 칸이 꽉 찰 때만 "이사" 1 2 3 4 5 6 7 8 복사 복사 복사 복사 용량 1 용량 2 용량 4 용량 8 이사할 때 옮긴 원소 = 0 + 1 + 2 + 4 = 7개 쓰기 8 + 복사 7 = 15 → append 한 번당 2 남짓, 그래서 평균은 O(1)
색칠된 append에서만 두 배 큰 버퍼로 옮기는 "이사"가 일어난다. 용량을 두 배씩 잡으니 이사 간격이 계속 길어지고, 그 비싼 한 번이 뒤따르는 싼 append들에 나뉘어 흡수된다 — 이것이 amortized O(1)이다.

해시 테이블은 키를 해시 함수로 정수로 바꿔 버킷 인덱스를 정한다. 충돌이 없다면 위치가 바로 나오므로 O(1), 반대로 모든 키가 같은 버킷으로 몰리면 사실상 선형 탐색이 되어 O(n)이다.

iOS에서는

Swift의 Array·Set·Dictionary는 모두 값 타입이며 Copy-on-Write로 구현된다. 저장소를 고유하게 참조 중이면 변경이 제자리에서 일어나지만, 다른 곳과 공유 중이면 첫 변경에서 저장소 전체를 복제하므로 그 순간 O(n)의 숨은 비용이 든다.

Apple 문서append(_:)를 "같은 배열에 여러 번 호출할 때 평균 O(1)"로 명시한다. Set/Dictionary의 조회·삽입·삭제 평균 O(1)은 원소가 Hashable이어야 성립한다. 또한 Swift의 Hasher는 기본적으로 프로세스마다 무작위 시드를 쓰기 때문에(뒤 Q4 참고), 순회 순서가 실행마다 달라질 수 있고 그 순서에 의존하면 안 된다.

연산ArraySet / Dictionary
인덱스/키 조회O(1)평균 O(1) · 최악 O(n)
끝에 추가 (append/insert)amortized O(1)평균 O(1) · 최악 O(n)
중간 삽입·삭제O(n)-
맨 앞 삭제 (removeFirst)O(n)-
값 포함 검사 (contains)O(n) (선형탐색)평균 O(1)

실험 · 도구

reserveCapacity(_:)를 준 배열과 안 준 배열에 대량 append를 돌려 재할당 횟수 차이를 본다. 큐처럼 removeFirst()를 반복하는 코드와 removeLast()를 반복하는 코드를 XCTestmeasure {}로 재면, 앞의 것만 입력 크기 제곱으로 느려지는 걸 관찰할 수 있다. Instruments의 Time Profiler로 어느 프레임이 오래 걸리는지, Allocations로 COW 복사가 튀는지 확인한다.

reserveCapacity와 removeFirst 함정 (Swift)
var a = [Int]()
a.reserveCapacity(10_000)          // 재할당 제거 → append가 진짜 amortized O(1)
for i in 0..<10_000 { a.append(i) }

// ❌ 배열을 큐처럼 쓰면 removeFirst가 매번 O(n) → 전체 O(n^2)
while !a.isEmpty { _ = a.removeFirst() }

프로젝트 적용

끝에서만 넣고 빼면 Array로 충분하다. 앞에서 빼야 하면 swift-collections의 Deque(양 끝 O(1))나 head 인덱스 방식을 쓴다.

앞에서 빼야 하면 Deque나 head 인덱스
import Foundation

// Array.removeFirst()는 남은 원소를 전부 앞으로 당긴다 → 매번 O(n).
// 큐로 쓰면 전체가 O(n²)가 된다.

// ❌ 1만 개 작업 큐 → 약 5천만 번의 원소 이동
func drainBad(_ jobs: inout [Job]) {
    while !jobs.isEmpty {
        let job = jobs.removeFirst()      // O(n)
        run(job)
    }
}

// ✅ ① head 인덱스를 전진시킨다 — 의존성 없이 O(1)
func drain(_ jobs: [Job]) {
    var head = 0
    while head < jobs.count {
        run(jobs[head])
        head += 1
    }
}

// ✅ ② 넣고 빼기가 섞여 있으면 Deque (swift-collections)
import Collections

final class JobQueue {
    private var storage = Deque<Job>()

    func enqueue(_ job: Job) { storage.append(job) }          // O(1)
    func dequeue() -> Job? { storage.popFirst() }             // O(1)
    var count: Int { storage.count }
}

// ✅ ③ 의존성을 추가하기 싫으면 직접 만든 링 버퍼도 방법이다
struct RingQueue<T> {
    private var storage: [T?] = Array(repeating: nil, count: 16)
    private var head = 0, tail = 0, count = 0

    mutating func enqueue(_ v: T) {
        if count == storage.count { grow() }
        storage[tail] = v
        tail = (tail + 1) % storage.count
        count += 1
    }

    mutating func dequeue() -> T? {
        guard count > 0 else { return nil }
        defer { storage[head] = nil; head = (head + 1) % storage.count; count -= 1 }
        return storage[head]
    }

    private mutating func grow() {
        var next = [T?](repeating: nil, count: storage.count * 2)
        for i in 0..<count { next[i] = storage[(head + i) % storage.count] }
        storage = next; head = 0; tail = count
    }
}

struct Job {}
func run(_ j: Job) {}

같은 컬렉션에 멤버십 검사를 반복하면 Array.contains(O(n)) 대신 Set(O(1))로 바꾼다.

반복 멤버십 검사는 Set으로
import Foundation

struct Item: Identifiable { let id: String }

// ❌ Array.contains는 O(n) — 루프 안에서 부르면 전체가 O(n·m)
func filterBad(_ items: [Item], excluding blocked: [String]) -> [Item] {
    items.filter { !blocked.contains($0.id) }     // items 1만 × blocked 1천 = 1천만 비교
}

// ✅ Set으로 바꾸면 평균 O(1) — 전체가 O(n + m)
func filter(_ items: [Item], excluding blocked: [String]) -> [Item] {
    let blockedSet = Set(blocked)                 // 한 번만 O(m)
    return items.filter { !blockedSet.contains($0.id) }
}

// ✅ 중복 방문 방지도 같은 패턴 — insert가 판정과 삽입을 한 번에 한다
func visitOnce(_ nodes: [Node]) {
    var visited = Set<Node.ID>()
    for n in nodes where visited.insert(n.id).inserted {
        process(n)                                // 처음 본 것만
    }
}

// ✅ 교집합·차집합도 Set 연산이 훨씬 빠르고 의도가 드러난다
func diff(old: [String], new: [String]) -> (added: Set<String>, removed: Set<String>) {
    let o = Set(old), n = Set(new)
    return (n.subtracting(o), o.subtracting(n))
}

// ⚠️ 변환 비용을 잊지 말 것 — 한 번만 검사한다면 Array.contains가 더 싸다
func onceOnly(_ items: [String], _ target: String) -> Bool {
    items.contains(target)        // 1회 조회면 Set 만드는 비용이 더 크다
}

struct Node: Identifiable { let id: String }
func process(_ n: Node) {}

대략적인 최종 크기를 알면 대량 삽입 전에 reserveCapacity로 재할당·복사를 없앤다.

대량 삽입 전 용량 예약
import Foundation

// 컬렉션은 용량이 찰 때마다 더 큰 버퍼를 잡고 전체를 옮긴다.
// 최종 크기를 알면 그 재할당·재해시를 전부 없앨 수 있다.

// ✅ Array
func buildRows(_ source: [Raw]) -> [Row] {
    var rows: [Row] = []
    rows.reserveCapacity(source.count)
    for r in source { rows.append(Row(r)) }
    return rows
}

// ✅ Dictionary — 재해시가 특히 비싸다
func buildIndex(_ rows: [Row]) -> [String: Row] {
    var index = [String: Row](minimumCapacity: rows.count)
    for r in rows { index[r.id] = r }
    return index
}

// ✅ Set
func buildSeen(_ ids: [String]) -> Set<String> {
    var seen = Set<String>(minimumCapacity: ids.count)
    for id in ids { seen.insert(id) }
    return seen
}

// ✅ String도 마찬가지
func joinAll(_ parts: [String]) -> String {
    var out = ""
    out.reserveCapacity(parts.reduce(0) { $0 + $1.utf8.count } + parts.count)
    for p in parts { out += p; out += "\n" }
    return out
}

// 효과를 직접 재 본다
func measureReserve() {
    let n = 200_000
    let clock = ContinuousClock()

    let without = clock.measure {
        var a: [Int] = []
        for i in 0..<n { a.append(i) }
    }
    let with = clock.measure {
        var a: [Int] = []
        a.reserveCapacity(n)
        for i in 0..<n { a.append(i) }
    }
    print("예약 없음 \(without) vs 예약 \(with)")
}

struct Raw {}
struct Row { let id: String; init(_ r: Raw) { id = "" } }

하지 말 것: Set/Dictionary의 순회 순서에 로직을 의존하는 것, 큰 배열에서 removeFirst()를 반복 호출하는 것.

하지 말 것 — 순회 순서 의존 · removeFirst 반복
import Foundation

// ❌ ① Set·Dictionary의 순회 순서에 로직을 의존
func brokenOrder(_ tags: Set<String>) -> String {
    tags.joined(separator: ", ")
    // Swift는 실행마다 해시 시드를 무작위화한다 —
    // 같은 입력이어도 앱을 다시 켜면 순서가 달라진다.
    // 화면에 보이는 순서가 매번 바뀌거나, 스냅샷 테스트가 깨진다.
}

// ✅ 순서가 의미 있으면 명시적으로 정렬한다
func stableOrder(_ tags: Set<String>) -> String {
    tags.sorted().joined(separator: ", ")
}

// ✅ 삽입 순서를 유지해야 하면 OrderedSet (swift-collections)
import Collections
func insertionOrdered(_ tags: [String]) -> OrderedSet<String> {
    OrderedSet(tags)
}

// ❌ ② 큰 배열에서 removeFirst()를 반복 — O(n²)
func processBad(_ queue: inout [Job]) {
    while !queue.isEmpty {
        run(queue.removeFirst())
    }
}

// ✅ 인덱스를 전진시키거나 Deque를 쓴다
func process(_ queue: [Job]) {
    for job in queue { run(job) }     // 애초에 순회면 이걸로 충분하다
}

// ❌ ③ 비슷한 함정: removeFirst(k) · remove(at:0) · insert(at:0)
func alsoBad(_ a: inout [Int]) {
    a.insert(0, at: 0)                // O(n) — 앞쪽 삽입도 전부 밀어낸다
}
// ✅ 앞뒤가 모두 필요하면 Deque, 뒤에만 쓰면 append/popLast (둘 다 O(1))

struct Job {}
func run(_ j: Job) {}
⚠️ 흔한 오해

"Set·Dictionary는 항상 O(1)"이라는 말은 평균에 한정된 이야기다. 최악은 O(n)이며(모든 키 충돌·재해시 순간), Array.append도 "항상 O(1)"이 아니라 amortized여서 재할당이 걸린 단일 호출은 O(n)이다.

🧒 쉽게 이해하기

배열은 사물함이 한 줄로 쭉 붙어 있는 것과 같다. 3번 칸은 "시작 위치에서 세 칸"만 계산하면 바로 손이 닿으니, 3번이든 3만 번이든 걸리는 시간이 똑같다. 대신 맨 앞 칸을 비우면 뒤 물건을 전부 한 칸씩 앞으로 옮겨야 한다 — removeFirst()가 느린 이유가 이거다. amortized(분할상환)라는 말도 이 그림으로 보면 쉽다. 칸이 꽉 차면 두 배 큰 사물함으로 이사를 가는데, 그 한 번은 짐을 다 옮기니 확실히 비싸다. 그런데 이사할 때마다 크기를 두 배로 잡으니 다음 이사까지 버티는 기간이 계속 두 배로 길어지고, 이사비를 그 사이의 수많은 "넣기"에 나눠 붙이면 한 번당은 여전히 싸다. "항상 싸다"가 아니라 "몰아서 계산하면 싸다"는 뜻이다.

Set·Dictionary는 줄 서 있는 사물함이 아니라, 이름을 넣으면 "몇 번 칸"을 바로 뱉는 계산기가 달린 사물함이다. 그래서 있는지 없는지 확인할 때 칸을 하나씩 훑을 필요가 없다. 다만 계산기가 서로 다른 두 이름에게 같은 칸 번호를 줄 수도 있는데(충돌), 그럴 때만 그 칸 안을 뒤진다. 모든 이름이 같은 칸으로 몰리는 최악에는 결국 다 뒤지게 되니 O(n)이 된다.

꼬리 질문

배열을 큐로 쓰면 왜 O(n²)이 되고, 어떻게 O(n)으로 되돌리나?

removeFirst()는 맨 앞을 빼고 남은 원소 전부를 한 칸씩 당기므로 호출 한 번이 O(n)이고, 이걸 n번 반복하면 O(n²)이 된다. 되돌리는 방법은 두 가지다. head 인덱스만 앞으로 옮기고 실제 삭제는 미루는 "링 버퍼/head pointer" 방식으로 dequeue를 amortized O(1)로 만들거나, 애초에 양 끝 O(1)을 보장하는 swift-collections의 Deque를 쓴다. 후자가 의도도 명확하고 안전하다.

쉽게 말하면 맨 앞 사람을 빼면 뒤에 선 사람 전부가 한 칸씩 앞으로 걸어와야 하는 줄이다. 한 명 빼는 데 줄 길이만큼 일이 생기니, n명을 그렇게 빼면 n×n이 된다. 해법은 사람을 옮기지 말고 "여기서부터가 줄의 시작"이라는 표시(head 인덱스)만 앞으로 미는 것, 또는 애초에 양 끝에서 빼도 되게 만들어진 Deque를 쓰는 것이다.
Hashable을 잘못 구현해 모든 값이 같은 해시를 내면 Set의 복잡도는 어떻게 변하나?

모든 키가 한 버킷으로 몰려 사실상 선형 탐색이 되므로 조회·삽입·삭제가 평균 O(1)에서 O(n)으로 퇴화하고, n개를 넣는 전체 비용은 O(n²)이 된다. 규약(a == b ⇒ 같은 해시)만 지키면 크래시나 오답은 없고 성능만 무너지기 때문에 벤치 전에는 발견하기 어렵다. 그래서 특별한 이유가 없으면 컴파일러 자동 합성 Hashable을 쓰는 게 안전하다.

쉽게 말하면 사물함 번호를 계산해 주는 계산기가 무슨 이름을 넣든 "3번"만 뱉는 상황이다. 결국 3번 칸 하나에 전부 쌓여서 하나씩 뒤져야 하니, 빠르라고 쓴 Set이 그냥 배열이 된다. 무서운 건 답은 계속 맞게 나온다는 점이다. 크래시도 오답도 없이 느려지기만 해서 시간을 재 보기 전엔 아무도 눈치채지 못한다.
COW가 있는데 배열을 inout으로 넘기면 복사가 일어나나?

inout 자체는 값을 복사하지 않는다. 호출자의 저장소를 그대로 빌려 쓰다가 함수가 끝날 때 되쓰는 write-back 방식이라, 그 저장소를 다른 변수가 공유하고 있지 않으면 함수 안에서 변경해도 복사가 없다. COW 복사는 inout 여부가 아니라 변경 시점에 저장소 참조가 유일한가(isKnownUniquelyReferenced)로 결정된다. 즉 inout은 오히려 불필요한 복사를 피하는 통로이고, 복사를 유발하는 건 여러 곳이 같은 배열을 붙들고 있는 공유 상태다.

쉽게 말하면 inout은 배열을 복사해서 건네는 게 아니라 원본 서랍 열쇠를 잠깐 빌려주는 것이다. 그래서 inout 자체가 복사를 만들지는 않는다. COW(Copy-on-Write, 고칠 때만 복사하는 방식) 복사는 "지금 이 저장소를 나 말고 또 누가 붙들고 있나"만 보고 결정된다. 범인은 inout이 아니라 같은 배열을 여기저기서 나눠 갖고 있는 상황이다.

Q2. 시간 복잡도와 공간 복잡도는 무엇이며 실제 입력 크기와 어떻게 연결되는가?

🔑 30초 답변

시간 복잡도는 입력 크기 n이 커질 때 연산 횟수가 어떻게 증가하는지, 공간 복잡도는 추가로 쓰는 메모리가 어떻게 증가하는지를 나타내는 점근적 상한(Big-O)이다. 상수와 저차항을 버리므로 "n이 충분히 클 때의 성장률"만 본다. 그래서 실무에선 갈래가 갈린다. n이 작으면(화면 행 수처럼) 숨겨진 상수(ARC 리테인, COW 복사, 캐시 미스)가 승부를 가르고, n이 무한정 커질 수 있으면(무한 스크롤 피드, 거래 내역) 점근 등급이 지배한다.

CS 원리

Big-O는 상한, Θ는 위아래로 딱 맞는 한계, Ω는 하한을 뜻한다. 점근 표기는 n을 무한대로 보낼 때의 기준이라 상수 배와 낮은 차수 항을 무시한다. 그래서 3n + 100도, 0.5n도 모두 O(n)이다. 성장률만 놓고 보면 O(1) < O(log n) < O(n) < O(n log n) < O(n²) 순서로 나빠진다. 한 번은 비싸지만 평균으로 싸지는 경우를 분할상환(amortized)으로, 최악의 한 번을 worst-case로 따로 말한다. 공간 복잡도는 보통 입력을 뺀 추가(auxiliary) 메모리를 재며, 재귀 호출이 쌓는 스택 깊이도 여기에 포함된다.

iOS에서는

화면에 보이는 행처럼 n이 작을 때는 점근 등급보다 상수가 지배한다.

실험 · 도구

입력 크기를 2배씩 늘리며 measure {}로 시간을 재면, 실측 곡선이 이론 등급과 맞는지 볼 수 있다(2배 늘렸을 때 O(n)은 2배, O(n²)은 4배). Instruments의 Time Profiler로 CPU 시간, Allocations로 메모리 증가, os_signpost로 특정 구간의 지속 시간을 계측한다.

프로젝트 적용

먼저 n의 실제 범위를 확인한다. 화면 20행과 10만 개 무한 스크롤은 완전히 다른 문제다.

n의 실제 범위를 먼저 확인
import Foundation

// 같은 코드라도 n이 20이냐 20만이냐에 따라 정답이 정반대다.
// 최적화를 시작하기 전에 '내 n'을 숫자로 적어 본다.

enum Workload {
    case tableRows          // n ≈ 20~100      (화면에 보이는 행)
    case searchResults      // n ≈ 100~1,000   (한 페이지)
    case infiniteFeed       // n ≈ 10,000+     (누적 스크롤)
    case fullSync           // n ≈ 1,000,000   (전체 동기화)
}

// ✅ n에 따라 다른 구현을 고른다
func lookup(_ id: String, in items: [Item], index: [String: Item]?) -> Item? {
    if items.count < 64 {
        // 작은 n: 연속 메모리 선형 스캔이 캐시 친화적이라 해시보다 빠를 수 있다
        return items.first { $0.id == id }
    }
    // 큰 n: 해시 인덱스
    return index?[id]
}

// ✅ 인덱스를 만들지 말지도 n으로 결정한다
struct Repository {
    private var items: [Item] = []
    private var index: [String: Item]?

    mutating func reload(_ new: [Item]) {
        items = new
        // 인덱스 구축 비용(O(n))이 조회 이득보다 큰 구간이 있다
        index = new.count >= 64
            ? Dictionary(uniqueKeysWithValues: new.map { ($0.id, $0) })
            : nil
    }
}

// ✅ 최악을 가정해 상한을 문서화해 둔다
//    "이 목록은 서버가 페이지당 100개, 최대 50페이지 → n ≤ 5,000"
//    상한이 정해져 있으면 그 범위에서만 최적화하면 된다

struct Item: Identifiable { let id: String }

작은 n에서는 상수가 좋은 O(n)(연속 메모리 선형 처리)이 O(n log n)보다 빠를 수 있다. 벤치로 확인하고 고른다.

작은 n에서는 상수가 승부를 가른다
import Foundation

// Big-O는 n이 클 때의 성장률이다. 작은 n에서는 상수(할당·해시·간접 참조)가 지배한다.

struct Row { let id: Int; let name: String }

// 후보 ①: 선형 스캔 — 상수가 작고 캐시 친화적
func linearFind(_ rows: [Row], id: Int) -> Row? {
    rows.first { $0.id == id }
}

// 후보 ②: 해시 조회 — 점근은 좋지만 딕셔너리 구축·해싱 상수가 붙는다
func hashFind(_ index: [Int: Row], id: Int) -> Row? {
    index[id]
}

// ✅ 추측하지 말고 실제 n 범위에서 재 본다
func benchmark(_ rows: [Row]) {
    let index = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) })
    let clock = ContinuousClock()
    let target = rows.count / 2

    let linear = clock.measure {
        for _ in 0..<10_000 { _ = linearFind(rows, id: target) }
    }
    let hash = clock.measure {
        for _ in 0..<10_000 { _ = hashFind(index, id: target) }
    }
    print("n=\(rows.count) → 선형 \(linear) · 해시 \(hash)")
}

// 실제로 재 보면 대략 이런 그림이 나온다:
//   n=10   선형 승  (해싱 상수 > 10번 비교)
//   n=100  비슷
//   n=1000 해시 압승
//
// ⚠️ 반드시 Release(-O)로 잰다. Debug에서는 ARC·제네릭 오버헤드가
//    양쪽을 다르게 왜곡해 결론이 뒤집힐 수 있다.

재귀는 깊이 한계를 의식하고, 깊어질 수 있으면 반복문 + 명시적 스택으로 바꾼다.

재귀 깊이를 의식하고 반복으로 전환
import Foundation

// 재귀는 호출마다 스택 프레임을 쌓는다. 메인 스레드는 1MB뿐이고,
// 넘치면 catch할 수 없는 EXC_BAD_ACCESS다.

final class TreeNode {
    var value: Int = 0
    var children: [TreeNode] = []
}

// ❌ 깊이가 입력에 달려 있다
func sumRecursive(_ node: TreeNode) -> Int {
    node.value + node.children.reduce(0) { $0 + sumRecursive($1) }
}

// ✅ 명시적 스택 — 깊이가 힙에서 자란다
func sum(_ root: TreeNode) -> Int {
    var total = 0
    var stack = [root]
    while let node = stack.popLast() {
        total += node.value
        stack.append(contentsOf: node.children)
    }
    return total
}

// ✅ 꼬리 재귀처럼 보이는 것도 Swift는 최적화를 보장하지 않는다 — 반복이 안전하다
func factorialLoop(_ n: Int) -> Int {
    var result = 1
    if n > 1 { for i in 2...n { result *= i } }   // max(2,n)을 쓰면 0!·1!이 2가 된다
    return result
}

// ✅ 깊이를 남겨야 하는 순회는 (노드, 깊이) 쌍을 스택에 넣는다
func maxDepth(_ root: TreeNode) -> Int {
    var best = 0
    var stack = [(node: root, depth: 1)]
    while let (node, depth) = stack.popLast() {
        best = max(best, depth)
        for c in node.children { stack.append((c, depth + 1)) }
    }
    return best
}

// ⚠️ 신뢰할 수 없는 입력(서버 JSON·사용자 파일)의 깊이는 파싱 단계에서 제한한다
enum ParseError: Error { case tooDeep }
func parse(depth: Int) throws {
    guard depth < 128 else { throw ParseError.tooDeep }
}

하지 말 것: 상수만 다른 두 O(n) 코드를 "둘 다 O(n)이니 똑같다"고 판단하는 것.

하지 말 것 — 같은 O면 같은 성능이라고 판단
import Foundation

// 같은 O(n)이어도 상수가 수십 배 차이 날 수 있다.

// 세 구현 모두 O(n)이지만 실제 시간은 전혀 다르다
func sumA(_ values: [Int]) -> Int {
    values.reduce(0, +)                       // 가장 빠름 — 연속 메모리 순회
}

func sumB(_ values: [Int]) -> Int {
    var total = 0
    for v in values { total += v }            // 사실상 동일
    return total
}

func sumC(_ values: [Int]) -> Int {
    values.map { $0 }.reduce(0, +)            // 중간 배열을 하나 더 만든다 — 훨씬 느림
}

// ❌ 더 극적인 예 — 둘 다 "문자열을 만든다"지만
func joinBad(_ parts: [String]) -> String {
    var s = ""
    for p in parts { s += p }                 // 분할상환은 O(n)이지만 재할당·용량 확인이 반복돼 상수가 크다
    return s
}
func joinGood(_ parts: [String]) -> String {
    parts.joined()                            // 한 번에 크기를 계산해 할당
}

// ✅ 판단은 측정으로
func compare(_ parts: [String]) {
    let clock = ContinuousClock()
    print("직접 이어붙이기 \(clock.measure { _ = joinBad(parts) })")
    print("joined        \(clock.measure { _ = joinGood(parts) })")
}

// 숨은 상수의 대표적 출처
//   · 중간 컬렉션 생성 (map → reduce 체인)
//   · COW 복사 (공유 상태에서 mutating)
//   · ARC retain/release (참조 타입 요소)
//   · 캐시 미스 (포인터 추적 구조)
//   · 경계 검사 (withUnsafeBufferPointer로 제거 가능)
⚠️ 흔한 오해

"Big-O가 작으면 항상 빠르다"는 틀렸다. Big-O는 n이 클 때의 성장률일 뿐, 작은 n에선 숨은 상수가 승부를 가른다. 또 "O(1) = 즉시"도 아니다. 입력과 무관한 상수 시간이라는 뜻이지 0이 아니다.

🧒 쉽게 이해하기

Big-O는 "얼마나 걸리나"를 재는 자가 아니라, "짐이 두 배가 되면 시간이 몇 배가 되나"만 재는 자다. 그래서 3n + 100도 0.5n도 똑같이 O(n)이다. 둘 다 짐이 두 배면 시간도 대략 두 배니까. 여기서 제일 많이 미끄러지는 곳이 "등급이 좋으면 무조건 빠르다"는 생각이다. 편의점에 갈 때는 그냥 걸어가는 게 빠르지만, 옆 도시에 갈 때는 시동 걸고 주차 자리 찾는 수고를 감수하고도 차가 압도적으로 이긴다. 걷기는 준비가 0인 대신 거리가 늘수록 급격히 불리해지고, 차는 준비(상수)가 큰 대신 거리당 늘어나는 폭이 작다 — 두 선이 만나는 지점이 어디냐가 실무의 진짜 질문이다.

그래서 코드를 고르기 전에 "우리 n이 대체로 몇이냐"부터 확인해야 한다. 화면에 20줄 보이는 목록과 10만 건짜리 무한 스크롤은 아예 다른 문제다. 공간 쪽에서는 재귀가 함정인데, 함수가 자기를 부를 때마다 읽다 만 책에 책갈피를 끼워 책상에 쌓아 두는 것과 같아서 깊이가 곧 메모리다. 책상이 넘치면 스택 오버플로다. 비유가 완벽하진 않다 — 걷기가 실제로 거리의 제곱만큼 나빠지진 않는다. 여기서 빌려온 건 "준비 비용 대 늘어나는 기울기"의 대결 구도뿐이다.

시간 여기서 역전 O(n) · 상수 큼 O(n²) · 상수 작음 입력 크기 n →
왼쪽(작은 n)에서는 준비 비용이 거의 없는 O(n²) 쪽이 오히려 빠르고, 오른쪽으로 갈수록 성장률이 좋은 O(n)이 이긴다. 우리 데이터가 교차점의 왼쪽에 있는지 오른쪽에 있는지가 실제 선택을 가른다.

꼬리 질문

평균 O(1) 해시 조회와 O(log n) 이진탐색 중 실제로 뭐가 더 빠를 수 있고, 왜 그런가?

n이 작거나 캐시 지역성이 중요한 상황에선 정렬된 Array 위의 이진탐색이 더 빠를 수 있다. 해시 조회는 상수가 1이 아니라 해시 함수 계산(Hasher/SipHash는 꽤 무겁다) + 버킷 인덱싱 + 충돌 시 == 비교가 붙고, 흩어진 버킷을 건드려 캐시 미스가 나기 쉽다. 반면 배열이 작아 전체가 캐시에 올라와 있으면 이진탐색은 매 접근이 캐시 히트라 싸다(이진탐색 자체의 점프하는 접근 패턴은 오히려 프리페치엔 불리하지만, 대상이 작으면 그게 문제 되지 않는다). 다만 삽입·삭제가 잦으면 정렬 유지 비용(O(n))이 커서 해시가 유리해진다.

쉽게 말하면 해시는 이름을 계산기에 넣어 칸 번호를 뽑는 방식이라, 계산기를 돌리는 수고가 매번 꼬박꼬박 붙는다. 자료가 아주 적으면 그 계산 한 번이, 작은 배열을 서너 번 반으로 접는 것보다 오히려 비싸다. 게다가 작은 배열은 통째로 CPU 캐시(자주 쓰는 걸 손 닿는 곳에 올려 두는 작은 선반)에 올라와 있어서 들여다보는 값이 거의 공짜다. 대신 자료가 자주 들락날락하면 정렬을 계속 유지하는 값이 커져서 해시 쪽으로 저울이 기운다.
재귀 깊이는 공간 복잡도에 어떻게 잡히며 iOS에서 어떤 크래시로 나타나나?

재귀는 호출마다 스택 프레임(지역변수·복귀 주소)이 쌓이므로 최대 재귀 깊이가 곧 O(깊이)의 보조 공간이다. 이 스택을 다 쓰면 스택 오버플로가 나는데, iOS에선 보통 EXC_BAD_ACCESS(가드 페이지를 밟음)로 크래시하며 힙 부족(malloc 실패)과는 다른 신호다. 메인 스레드 스택은 기본 1MB, 보조 스레드는 더 작으므로(Thread.stackSize로 조정) 깊어질 수 있는 탐색은 반복문 + 명시적 스택으로 바꾸거나 꼬리 재귀를 반복문으로 푸는 게 안전하다.

쉽게 말하면 함수가 자기를 다시 부를 때마다 읽던 페이지에 책갈피를 꽂아 책상에 한 권씩 쌓아 둔다. 깊이 들어갈수록 책탑이 높아지고, 책상(스택) 밖으로 넘치는 순간 앱이 죽는다. 이건 힙이 모자라 메모리 할당이 실패하는 것과는 다른 사고라 EXC_BAD_ACCESS로 찍힌다. 깊어질 수 있는 탐색이면 책상 대신 훨씬 넓은 창고(힙에 둔 Array)에 책갈피를 쌓는 반복문으로 바꾼다.
amortized O(1)과 average O(1)은 어떻게 다른가?

amortized(분할상환)는 연속된 연산 시퀀스의 총비용을 횟수로 나눈 값으로, 최악 케이스를 포함해도 평균이 O(1)임을 보장한다. Array.append가 대표적으로, 가끔 O(n) 재할당이 있어도 그 비용이 이후 싼 append들에 흡수되어 어떤 시퀀스든 평균 O(1)이 나온다. 반면 average(평균)는 입력 분포에 대한 기댓값이라 확률적 개념이고, 나쁜 입력이 오면 실제로 느려질 수 있다. 해시 조회의 평균 O(1)이 이쪽으로, 최악은 O(n)이다.

쉽게 말하면 amortized는 1년치 회비를 12로 나눠 월세처럼 보는 것에 가깝다. 어쩌다 큰돈이 나가도 뒤따르는 싼 달들이 흡수하니, 어떤 순서로 쓰든 한 번당 평균은 싸다고 약속할 수 있다. average는 "보통은 안 막힌다"에 가깝다. 운 나쁘게 나쁜 입력이 오면 진짜로 느려지고 아무도 막아 주지 않는다. 앞은 보장이고 뒤는 기대값이다.

Q3. Stack과 Queue는 어떤 문제에 적합한가?

🔑 30초 답변

Stack은 LIFO(나중에 넣은 걸 먼저 꺼냄)라 가장 최근 것부터 되짚어야 하는 문제 — 되돌리기(undo), 화면 뒤로가기, DFS, 괄호·수식 파싱, 백트래킹 — 에 맞는다. Queue는 FIFO(먼저 넣은 걸 먼저 꺼냄)라 들어온 순서대로 공평하게 처리하는 문제 — BFS, 작업 스케줄링, 버퍼링(생산자-소비자), 이벤트 처리 — 에 맞는다. iOS의 UINavigationController는 화면을 스택으로, DispatchQueue는 작업 제출을 FIFO로 다룬다.

CS 원리

Stack은 한쪽 끝(top)에서만 push/pop/peek 하며 셋 다 O(1)이다. Queue는 뒤에서 enqueue, 앞에서 dequeue 하며 역시 O(1)이다. 선택 기준은 단순하다. "가장 최근 것을 먼저 처리"하고 싶으면 LIFO, "먼저 온 것을 먼저 처리"하고 싶으면 FIFO다.

Stack · LIFO Queue · FIFO D (top) C B A (bottom) push pop A B C D front back enqueue dequeue
Stack은 top 한쪽에서만 넣고 빼 최근 것이 먼저 나온다. Queue는 뒤로 넣고(enqueue) 앞에서 빼(dequeue) 들어온 순서대로 나온다. 두 구조 모두 양 끝 연산은 O(1)이다.

iOS에서는

프로젝트 적용

LIFO/FIFO 요구를 코드에서 명시적 타입(Deque 등)으로 드러내 의도를 못 박는다.

LIFO/FIFO를 타입으로 드러내기
import Foundation
import Collections

// 배열로 스택·큐를 흉내 내면 의도가 코드에 안 보이고,
// 나중에 누가 removeFirst()를 넣어도 아무도 못 알아챈다.

// ❌ 의도가 이름에만 있다
var undoArray: [Command] = []
func undoBad() { _ = undoArray.popLast() }

// ✅ 타입으로 못 박는다 — 잘못된 연산이 아예 없다
struct Stack<Element> {
    private var storage: [Element] = []

    var isEmpty: Bool { storage.isEmpty }
    var top: Element? { storage.last }

    mutating func push(_ e: Element) { storage.append(e) }   // O(1)
    mutating func pop() -> Element? { storage.popLast() }    // O(1)
}

// ✅ 되돌리기 — 가장 최근 것부터
final class UndoManager2 {
    private var undoStack = Stack<Command>()
    private var redoStack = Stack<Command>()

    func perform(_ c: Command) {
        c.execute()
        undoStack.push(c)
        redoStack = Stack()          // 새 작업을 하면 redo는 무효
    }

    func undo() {
        guard let c = undoStack.pop() else { return }
        c.revert()
        redoStack.push(c)
    }
}

// ✅ 큐 — 들어온 순서대로
struct Queue<Element> {
    private var storage = Deque<Element>()
    mutating func enqueue(_ e: Element) { storage.append(e) }
    mutating func dequeue() -> Element? { storage.popFirst() }
    var isEmpty: Bool { storage.isEmpty }
}

protocol Command { func execute(); func revert() }

트리·그래프·뷰 계층 탐색에서 깊이 우선이면 스택(또는 재귀), 너비 우선이면 큐를 쓴다.

탐색 방향에 맞는 자료구조 고르기
import UIKit
import Collections

// 깊이 우선(DFS)이면 스택, 너비 우선(BFS)이면 큐.
// 같은 순회 코드에서 자료구조만 바꾸면 탐색 순서가 바뀐다.

// ✅ DFS — 한 갈래를 끝까지 파고든다 (뷰 계층에서 특정 타입 찾기)
func findFirst<T: UIView>(_ type: T.Type, in root: UIView) -> T? {
    var stack = [root]
    while let view = stack.popLast() {
        if let match = view as? T { return match }
        stack.append(contentsOf: view.subviews)
    }
    return nil
}

// ✅ BFS — 가까운 것부터 본다 ("root에서 가장 얕은 곳에 있는 스크롤뷰 자손" 같은 문제. 반대로 '가장 가까운 조상'은 탐색이 아니라 superview 체인을 거슬러 올라가면 된다)
func findNearest<T: UIView>(_ type: T.Type, in root: UIView) -> T? {
    var queue = Deque([root])
    while let view = queue.popFirst() {
        if let match = view as? T { return match }
        queue.append(contentsOf: view.subviews)
    }
    return nil
}

// ✅ 재귀 없이 스택을 쓰면 깊이 한계도 함께 해결된다
func allLabels(in root: UIView) -> [UILabel] {
    var found: [UILabel] = []
    var stack = [root]
    while let v = stack.popLast() {
        if let l = v as? UILabel { found.append(l) }
        stack.append(contentsOf: v.subviews)
    }
    return found
}

// ✅ 그래프에서는 방문 표시가 필수 (사이클 방지)
func reachable(from start: Node, edges: [Node.ID: [Node]]) -> Set<Node.ID> {
    var visited: Set<Node.ID> = []
    var queue = Deque([start])
    while let n = queue.popFirst() {
        guard visited.insert(n.id).inserted else { continue }
        queue.append(contentsOf: edges[n.id] ?? [])
    }
    return visited
}

struct Node: Identifiable { let id: String }

하지 말 것: 큰 배열에서 removeFirst()를 반복해 큐를 흉내 내는 것.

하지 말 것 — 배열로 큐 흉내내기
import Foundation

// 이 패턴이 실제 앱에서 성능 문제로 나타나는 대표적인 자리들이다.

struct Task2 {}

// ❌ ① 작업 큐
final class BadTaskQueue {
    private var tasks: [Task2] = []
    func next() -> Task2? {
        tasks.isEmpty ? nil : tasks.removeFirst()      // O(n)
    }
}

// ❌ ② BFS 순회 — 노드 수가 많으면 바로 티가 난다
func bfsBad(_ root: TreeNode) {
    var queue = [root]
    while !queue.isEmpty {
        let n = queue.removeFirst()                    // O(n) × 노드 수
        queue.append(contentsOf: n.children)
    }
}

// ❌ ③ 슬라이딩 윈도우
func windowBad(_ values: [Int], size: Int) -> [Int] {
    var window: [Int] = []
    var out: [Int] = []
    for v in values {
        window.append(v)
        if window.count > size { window.removeFirst() }  // O(size) × n
        out.append(window.reduce(0, +))
    }
    return out
}

// ✅ 각각의 올바른 형태
import Collections

func bfs(_ root: TreeNode) {
    var queue = Deque([root])
    while let n = queue.popFirst() {                   // O(1)
        queue.append(contentsOf: n.children)
    }
}

func window(_ values: [Int], size: Int) -> [Int] {
    var deque = Deque<Int>()
    var sum = 0, out: [Int] = []
    for v in values {
        deque.append(v); sum += v
        if deque.count > size, let old = deque.popFirst() { sum -= old }
        out.append(sum)                                // 합도 증분으로 유지
    }
    return out
}

final class TreeNode { var children: [TreeNode] = [] }
⚠️ 흔한 오해

"DispatchQueue는 완전한 FIFO다"는 절반만 맞다. 실행 순서까지 FIFO인 건 직렬 큐뿐이다. 동시 큐는 작업을 순서대로 시작하지만 병렬로 돌아 완료 순서는 뒤섞일 수 있다.

🧒 쉽게 이해하기

Stack은 급식실에 쌓아 둔 식판 더미다. 맨 위 것부터 꺼내니 제일 나중에 올린 게 제일 먼저 나간다. 뒤로가기 버튼이 딱 이 모양이라, 방금 본 화면부터 거꾸로 되짚어 나온다. Queue는 매표소 줄이다. 먼저 온 사람이 먼저 표를 받고 새로 온 사람은 맨 뒤에 붙으니, 아무도 새치기당하지 않는다.

진짜 헷갈리는 건 DispatchQueue다. 이름이 "큐"라서 끝나는 순서까지 줄 순서일 거라 믿기 쉽다. 창구가 하나뿐인 직렬(serial) 큐면 그 믿음이 맞다. 한 사람이 끝나야 다음 사람이 들어가니 들어간 순서가 곧 나온 순서다. 그런데 창구가 여러 개인 동시(concurrent) 큐에서는 창구에 들어가는 순서만 줄 순서고, 볼일이 짧은 사람이 먼저 나온다. 그래서 A를 먼저 던졌다는 이유만으로 A가 먼저 끝날 거라 기대하면 안 된다.

직렬 큐 창구 1개 A B C 완료 순서 A → B → C 동시 큐 창구 여러 개 A (길다) B C 완료 순서 B → C → A 시간
직렬 큐는 한 번에 하나씩 처리하니 넣은 순서가 곧 끝나는 순서다. 동시 큐는 A·B·C를 순서대로 시작하지만 함께 굴러가서, 짧은 B가 먼저 끝나고 긴 A가 마지막이 된다.

꼬리 질문

큐를 두 개의 스택으로 amortized O(1)에 구현하려면 어떻게 하나?

입력용 스택 inbox와 출력용 스택 outbox를 둔다. enqueue는 inbox에 push하고, dequeue는 outbox가 비었을 때만 inbox를 전부 뒤집어 옮긴 뒤 outbox에서 pop한다. 각 원소는 평생 최대 한 번만 옮겨지므로, 옮기는 O(n)이 그 뒤의 여러 dequeue에 분산되어 연산당 amortized O(1)이 된다. 단 최악의 단일 dequeue는 O(n)이라, 지연이 튀면 안 되는 실시간 경로엔 Deque가 낫다.

쉽게 말하면 접시를 한 더미에 쌓으면 제일 나중에 올린 게 위에 온다. 이걸 옆 더미로 한 장씩 옮겨 쌓으면 순서가 뒤집혀서, 이제 맨 위가 제일 먼저 들어온 접시다. 그래서 꺼낼 때는 옆 더미가 완전히 빌 때만 한 번 통째로 뒤집으면 된다. 접시 한 장은 평생 딱 한 번만 옮겨지니 나눠 계산하면 싸다. 다만 뒤집는 그 한 번은 여전히 오래 걸려서, 화면이 끊기면 안 되는 경로에서는 그 순간에 툭 튄다.
DFS를 재귀 대신 명시적 스택으로 바꾸면 무엇이 좋아지나?

가장 큰 이점은 콜 스택 한계에서 벗어나는 것이다. 깊은 그래프에서 재귀는 스택 오버플로로 크래시하지만, 힙에 있는 Array 스택을 쓰면 깊이 한계가 사실상 사라진다. 또 탐색 상태를 값으로 들고 있어 중단·재개, 방문 순서 커스터마이즈, 특정 지점에서 조기 종료가 쉬워진다. 대가로 방문 표시·자식 push 순서를 직접 관리해야 해 코드가 장황해진다.

쉽게 말하면 재귀는 책갈피를 책상 위에 쌓는 거라 책상 크기(메인 스레드 기준 1MB 남짓)에 갇힌다. 스택을 Array로 직접 들고 다니면 같은 책갈피를 훨씬 넓은 창고(힙)에 두는 셈이라 깊이 걱정이 사라진다. 덤으로 "어디까지 봤는지"가 그냥 값이라 중간에 멈췄다 이어서 하거나 조건이 맞으면 바로 그만두기도 쉽다. 대신 방문 표시나 자식 넣는 순서를 이제 내가 챙겨야 해서 코드가 길어진다.
"먼저 온 순서"가 아니라 "중요도 순서"가 필요하면 Queue 대신 무엇을 쓰나?

우선순위 큐(priority queue), 보통 이진 힙(binary heap)으로 구현한 자료구조를 쓴다. 삽입과 최댓값/최솟값 추출이 각각 O(log n)이고 최우선 원소 peek은 O(1)이다. Swift 표준 라이브러리엔 없고 swift-collections의 Heap을 쓰면 된다. 우선순위가 같은 원소들의 순서까지 고정하려면 (priority, 삽입순번) 튜플을 키로 써서 tie-breaker를 명시한다.

쉽게 말하면 응급실 대기실이다. 온 순서가 아니라 급한 사람이 먼저 들어간다. heap(힙)은 "지금 제일 급한 사람이 누구인지"만 항상 맨 위에 유지해 두는 구조라, 새 환자를 받거나 제일 급한 사람을 빼낼 때 전체를 다시 줄 세우지 않고 몇 단계만 손보면 된다. 급한 정도가 똑같은 사람끼리의 순서까지 정하고 싶으면 "같으면 먼저 온 사람 우선"이라고 규칙을 직접 붙여 준다.

Q4. Hash table은 어떻게 동작하며 collision은 어떻게 처리하는가?

🔑 30초 답변

해시 테이블은 키를 해시 함수로 정수로 바꾸고, 그 값을 버킷 개수로 나눈 나머지를 인덱스로 삼아 O(1)에 저장·조회한다. 서로 다른 키가 같은 버킷을 가리키는 충돌은 크게 (1) 체이닝(버킷마다 연결 리스트) 또는 (2) 개방 주소법(빈 슬롯을 탐사)으로 푼다. 원소가 많아져 부하율이 임계를 넘으면 더 큰 테이블로 재해시한다. Swift의 Set·Dictionary는 프로세스마다 무작위 시드를 쓰는 Hasher(SipHash)를 사용한다.

CS 원리

이상적인 해시 함수는 키를 버킷에 고르게 흩뿌리고, 입력이 조금만 달라도 결과가 확 바뀐다(애벌런치). 그래도 버킷 수는 유한하므로 충돌은 반드시 생긴다. 처리 방식은 두 갈래다.

부하율(원소 수 ÷ 버킷 수)이 임계를 넘으면 버킷을 키우고 전부 다시 배치한다. 이 재해시는 O(n)이지만 드물게 일어나 amortized로 흡수된다.

key A key B hash(A) % 8 = 3 hash(B) % 8 = 3 선형 탐사 +1 A B 0 1 2 3 4 5 6 7
A와 B가 모두 버킷 3으로 해싱되어 충돌한다. 개방 주소법은 3이 차 있으면 다음 슬롯(4)으로 선형 탐사해 B를 넣는다. 조회 때도 같은 규칙으로 따라간다.

iOS에서는

Swift의 Set·DictionaryHasher(SipHash 계열)로 해시를 만든다. 이 해시는 기본적으로 프로세스가 실행될 때마다 무작위 시드를 쓴다(SE-0206). 그래서 (1) 악의적으로 충돌을 몰아 넣어 O(n)으로 떨어뜨리는 hash-flooding 공격을 막고, (2) 그 대가로 순회 순서가 실행마다 달라진다. 표준 라이브러리의 네이티브 해시 저장소는 구현 세부로는 개방 주소법을 쓰지만, 이는 공개 API 보장이 아니라 내부 구현이다.

Hashable 규약이 핵심이다. 같으면 해시도 같아야 한다(a == ba.hashValue == b.hashValue). 이 규약이 깨지면 조회가 실패한다. 반대로 해시가 같아도 값이 다를 수 있고(정상적인 충돌), 최종 판정은 항상 ==가 한다. 키를 넣은 뒤 해시에 영향을 주는 속성을 바꾸면 그 키를 다시 찾지 못하게 된다.

잘못된 Hashable — 모든 키가 충돌해 O(n)으로 퇴화 (Swift)
struct BadKey: Hashable {
    let id: Int
    func hash(into hasher: inout Hasher) { }               // 아무것도 안 섞음 → 항상 같은 해시
    static func == (l: BadKey, r: BadKey) -> Bool { l.id == r.id }
}
// 규약(== 이면 hash 같음)은 지켰지만, 모든 키가 한 버킷에 몰려
// Set/Dictionary 조회가 평균 O(1)이 아니라 O(n)으로 떨어진다.

실험 · 도구

BadKey처럼 hash(into:)를 비운 타입과, 컴파일러가 자동 합성한 정상 타입을 각각 Set에 대량 삽입·조회해 시간을 비교하면 O(n) 퇴화를 눈으로 확인할 수 있다. 테스트에서 순회 순서를 고정하고 싶으면 환경변수 SWIFT_DETERMINISTIC_HASHING=1로 시드를 끌 수 있다(테스트 전용, 프로덕션 금지).

프로젝트 적용

커스텀 타입을 Set 원소나 Dictionary 키로 쓸 때는 Hashable==일관되게 두되, 특별한 이유가 없으면 컴파일러 자동 합성을 쓴다.

Hashable은 자동 합성을 쓰기
import Foundation
import CoreGraphics        // CGSize의 Equatable·Hashable은 여기서 온다

// 컴파일러가 합성해 주면 ==과 hash가 자동으로 일관된다.
// 직접 구현하면 그 일관성을 사람이 책임져야 한다.

// ✅ 모든 저장 프로퍼티가 Hashable이면 한 줄이면 끝난다
struct ItemID: Hashable {
    let namespace: String
    let raw: UUID
}

struct Coordinate: Hashable {
    let x: Int
    let y: Int
}

// ✅ enum도 마찬가지 (연관값이 Hashable이면)
enum Category: Hashable {
    case system
    case user(id: UUID)
}

// ⚠️ 직접 구현해야 하는 경우: 동일성에 참여하지 않는 필드가 있을 때
struct CachedImage: Hashable {
    let url: URL
    let size: CGSize
    var lastAccessed: Date          // ← 동일성과 무관 (캐시 메타데이터)

    // ✅ ==에 쓴 필드만 hash에 넣는다. 규약: a == b ⇒ hash(a) == hash(b)
    static func == (l: CachedImage, r: CachedImage) -> Bool {
        l.url == r.url && l.size == r.size
    }
    func hash(into hasher: inout Hasher) {
        hasher.combine(url)
        hasher.combine(size.width)
        hasher.combine(size.height)
    }
}

// ❌ 흔한 실수 — ==에는 쓰는데 hash에는 빠뜨림
struct Broken: Hashable {
    let a: Int
    let b: Int
    static func == (l: Broken, r: Broken) -> Bool { l.a == r.a && l.b == r.b }
    func hash(into h: inout Hasher) { h.combine(a) }   // b 누락
    // 규약은 지켜지지만(같으면 해시도 같다) b만 다른 값들이 전부 충돌해
    // Set/Dictionary가 O(n)으로 떨어진다
}

해시에 넣은 속성은 삽입 후 변경하지 않는다. 가변 상태는 해시에서 빼거나 별도 저장한다.

해시에 참여하는 속성은 삽입 후 변경 금지
import Foundation

// 컬렉션은 삽입 시점의 해시로 버킷을 정한다. 그 뒤에 해시가 바뀌면
// 원소가 '엉뚱한 버킷'에 남아 영영 찾을 수 없게 된다.

// ❌ 클래스 + 가변 프로퍼티가 해시에 참여
final class MutableKey: Hashable {
    var name: String
    init(name: String) { self.name = name }

    static func == (l: MutableKey, r: MutableKey) -> Bool { l.name == r.name }
    func hash(into h: inout Hasher) { h.combine(name) }
}

func demonstrateBug() {
    let key = MutableKey(name: "a")
    var set: Set<MutableKey> = [key]

    key.name = "b"                       // 💥 해시가 바뀌었다

    print(set.contains(key))             // false — 자기 자신도 못 찾는다
    print(set.count)                     // 1 — 여전히 안에는 있다
}

// ✅ ① 키는 값 타입 + 불변으로
struct ImmutableKey: Hashable {
    let name: String                     // let — 바뀔 수 없다
}

// ✅ ② 가변 상태가 필요하면 해시에서 빼고 별도로 관리한다
struct Entry: Hashable {
    let id: UUID                         // 동일성·해시는 id만
    static func == (l: Entry, r: Entry) -> Bool { l.id == r.id }
    func hash(into h: inout Hasher) { h.combine(id) }
}

final class EntryStore {
    private var entries: Set<Entry> = []
    private var metadata: [UUID: Metadata] = [:]     // 가변 부분은 옆에

    func touch(_ id: UUID) {
        metadata[id, default: Metadata()].accessCount += 1   // 해시에 영향 없음
    }
}
struct Metadata { var accessCount = 0 }

대량 삽입 전 reserveCapacity(_:)로 재해시 횟수를 줄인다.

대량 삽입 전 reserveCapacity로 재해시 줄이기
import Foundation

// 해시 테이블은 부하율이 임계를 넘으면 더 큰 테이블을 잡고
// 모든 원소를 '다시 해싱'해 옮긴다. 예약하면 이걸 없앨 수 있다.

// ❌ 20만 개 삽입 중 재해시가 여러 번 일어난다
func buildBad(_ ids: [String]) -> Set<String> {
    var seen = Set<String>()
    for id in ids { seen.insert(id) }
    return seen
}

// ✅ 최종 크기를 미리 알려 준다
func build(_ ids: [String]) -> Set<String> {
    var seen = Set<String>(minimumCapacity: ids.count)
    for id in ids { seen.insert(id) }
    return seen
}

// ✅ Dictionary도 동일
func buildIndex(_ rows: [Row]) -> [String: Row] {
    var index = [String: Row](minimumCapacity: rows.count)
    for r in rows { index[r.id] = r }
    return index
}

// ✅ 키가 유일함이 보장되면 이 이니셜라이저가 가장 빠르다
func buildFast(_ rows: [Row]) -> [String: Row] {
    Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) })
}

// ✅ 중복 가능성이 있으면 병합 규칙을 명시한다 (크래시 방지)
func buildMerging(_ rows: [Row]) -> [String: Row] {
    Dictionary(rows.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new })
}

// 효과 측정
func measure(_ ids: [String]) {
    let clock = ContinuousClock()
    print("예약 없음 \(clock.measure { _ = buildBad(ids) })")
    print("예약 있음 \(clock.measure { _ = build(ids) })")
}

struct Row { let id: String }

하지 말 것: hashValue를 직접 비교하거나 디스크에 저장하는 것(시드 때문에 실행 간 불변이 아니다).

하지 말 것 — hashValue를 저장하거나 직접 비교
import Foundation

// Swift의 Hasher는 프로세스마다 무작위 시드를 쓴다(해시 DoS 방어).
// 그래서 같은 값이라도 앱을 다시 켜면 hashValue가 달라진다.

struct Key: Hashable { let name: String }

// ❌ ① 디스크·서버에 저장 — 다음 실행에서 전혀 다른 값이 되어 못 찾는다
func persistBad(_ key: Key) {
    UserDefaults.standard.set(key.hashValue, forKey: "cacheKey")   // 💥
}

// ✅ 안정적인 식별자를 직접 만든다
func persist(_ key: Key) {
    UserDefaults.standard.set(key.name, forKey: "cacheKey")
}

// ✅ 콘텐츠 해시가 필요하면 암호학적 해시를 쓴다 (실행 간 안정)
import CryptoKit
func stableHash(_ s: String) -> String {
    // 파일명에 쓸 거면 base64가 아니라 16진수로 — 표준 base64에는 '/'가 들어가 경로가 깨진다
    SHA256.hash(data: Data(s.utf8)).map { String(format: "%02x", $0) }.joined()
}

// ❌ ② 동일성 판정에 hashValue 비교 — 충돌이 정상이므로 틀린다
func equalsBad(_ a: Key, _ b: Key) -> Bool {
    a.hashValue == b.hashValue        // 다른 값도 같은 해시일 수 있다
}

// ✅ 동일성은 ==가 판정한다
func equals(_ a: Key, _ b: Key) -> Bool { a == b }

// ❌ ③ 캐시 파일 이름에 hashValue 사용
func cachePathBad(_ url: URL) -> String {
    "\(url.hashValue).cache"          // 재실행 시 캐시 전체가 미아가 된다
}

// ✅ 안정적인 파일명
func cachePath(_ url: URL) -> String {
    stableHash(url.absoluteString) + ".cache"
}
⚠️ 흔한 오해

"해시값이 같으면 같은 객체다"는 틀렸다. 충돌은 정상이며 최종 동일성은 ==가 판정한다. 반대로 "==인데 해시가 달라도 괜찮다"도 틀렸다. 그건 규약 위반이라 넣은 값을 못 찾는 버그가 된다.

🧒 쉽게 이해하기

해시 테이블은 번호판을 보고 주차 구역을 정해 주는 주차장이라고 생각하면 된다. "번호판을 이 규칙에 넣었더니 3번 구역"처럼 자리를 계산해 버리니, 차를 찾을 때 주차장을 다 돌지 않고 3번으로 곧장 간다. 문제는 다른 차도 3번이 나올 수 있다는 것이다(충돌). 구역 수는 정해져 있고 차 번호판은 무한하니 이건 피할 방법이 없다. 3번 자리에 차를 줄줄이 세우는 게 체이닝, 옆의 빈 자리로 밀어 넣는 게 개방 주소법이다.

개방 주소법에서 차를 뺄 때 자리를 그냥 "빈 칸"으로 두면 안 되는 이유도 이 그림에서 보인다. 옆으로 밀려 주차한 차를 찾으러 온 사람이 중간의 빈 칸을 보고 "여기서 끊겼네, 없구나" 하고 돌아가 버리기 때문이다. 그래서 "여긴 비었지만 옆까지 더 가 보세요"라는 표지판을 남기는데, 이게 tombstone이다. 그리고 같은 구역이 나왔다고 같은 차는 아니다 — 최종 확인은 언제나 번호판을 직접 대조하는 ==가 한다. Swift가 실행할 때마다 배정 규칙을 슬쩍 바꾸는 건(해시 시드 무작위화), 누가 일부러 전부 3번이 나오는 번호판만 골라 들이밀어 주차장을 마비시키는 걸 막기 위해서다. 대신 그 대가로 주차된 차를 훑는 순서가 실행할 때마다 달라진다.

꼬리 질문

체이닝과 개방 주소법은 삭제 처리가 어떻게 다른가(왜 tombstone이 필요한가)?

체이닝은 버킷의 리스트에서 노드 하나만 떼어내면 끝이라 삭제가 단순하다. 개방 주소법은 슬롯을 그냥 비우면 안 되는데, 탐사(probe)로 뒤 슬롯에 밀려 저장된 항목의 조회가 빈 슬롯에서 멈춰 찾지 못하는 버그가 생기기 때문이다. 그래서 삭제한 자리에 "여긴 지워졌지만 탐사는 계속하라"는 tombstone 표식을 남긴다. tombstone이 쌓이면 탐사가 길어져 성능이 나빠지므로 주기적 재해시로 청소한다.

쉽게 말하면 개방 주소법은 자리가 차 있으면 옆으로 밀어 주차하는 방식이다. 그래서 나중에 그 차를 찾을 때도 원래 자리부터 옆으로 계속 훑는데, 중간 차가 빠져 빈칸이 되면 찾는 사람이 "여기서 끊겼네" 하고 멈춰 버린다. 그래서 지운 자리에 "여긴 비었지만 더 가 보세요"라는 팻말을 세우고, 이게 tombstone이다. 체이닝은 한 자리에 줄줄이 매달아 두는 방식이라 그냥 한 칸 떼면 끝이라 이런 팻말이 필요 없다. 팻말이 잔뜩 쌓이면 찾는 길이 길어지니 가끔 통째로 다시 정리한다.
해시 시드를 무작위화하면 Set/Dictionary가 최악 O(n)으로 퇴화하는 걸 완전히 막을 수 있나? 시드와 무관하게 여전히 O(n)이 되는 경우는?

막지 못한다. 시드 무작위화는 공격자가 어떤 키가 충돌할지 예측해서 일부러 한 버킷으로 몰아넣는 hash-flooding만 어렵게 할 뿐, 최악 O(n)이라는 성질 자체를 없애는 게 아니다. 시드가 무엇이든 퇴화하는 대표 경우는 두 가지다. hash(into:)가 늘 같은 값을 내는 잘못된 Hashable은 모든 키를 한 버킷에 몰아 조회를 O(n)으로 떨어뜨리고, 부하율이 임계를 넘어 더 큰 테이블로 옮기는 재해시 순간의 그 단일 연산도 O(n)이다(연속된 삽입 전체로는 amortized로 흡수되지만). 정상적인 충돌도 확률적으로 계속 생기므로, 시드는 '악의가 유발하는 최악'을 막는 방어일 뿐 '최악이 원천적으로 불가능'하다는 보장이 아니다.

쉽게 말하면 시드 무작위화는 자물쇠 비밀번호를 매번 새로 바꾸는 것이지, 자물쇠를 부술 수 없게 만드는 게 아니다. 남이 미리 "이 키들만 넣으면 전부 같은 칸으로 몰린다"고 계산해서 들이미는 걸 막아 줄 뿐이다. 내가 스스로 hash(into:)를 비워 두면 시드가 무엇이든 전부 한 칸으로 가고, 테이블을 키우며 전부 다시 배치하는 그 한 번도 여전히 원소 수만큼 걸린다. "최악이 사라졌다"가 아니라 "남이 일부러 최악을 만들기 어려워졌다"이다.
EquatableHashable이 서로 어긋나게 구현되면 구체적으로 어떤 버그가 나나?

규약은 "a == b이면 해시도 같아야 한다"이다. 이게 깨져 같은 값인데 해시가 다르면, 두 값이 다른 버킷으로 들어가 방금 넣은 키를 조회에서 못 찾는 유령 버그가 난다. Dictionary에 같은 키가 중복으로 들어가거나 Set에 논리적으로 동일한 원소가 두 번 남을 수도 있다. 반대로 해시는 같은데 ==가 다르게 판정하는 건 정상적인 충돌일 뿐 버그가 아니다 — 최종 동일성은 항상 ==가 결정하기 때문이다.

쉽게 말하면 같은 사람인데 갈 때마다 다른 사물함 번호가 나오는 상황이다. 넣을 땐 3번에 뒀는데 찾을 땐 7번을 열어 보니 "그런 거 없다"가 된다. 방금 넣은 값을 못 찾고, Set인데 똑같은 게 두 개 남아 있는 유령 같은 버그가 여기서 나온다. 반대 방향, 즉 번호는 같은데 사람이 다른 건 그냥 흔한 일이고 마지막에 얼굴을 대조(==)하니 아무 문제가 없다.

Q5. Binary search를 적용할 수 있는 전제 조건은 무엇인가?

🔑 30초 답변

이진탐색의 전제는 둘이다. (1) 데이터가 검색에 쓰는 것과 같은 기준으로 정렬돼 있어야 하고, (2) 임의 인덱스 접근이 O(1)이어야(RandomAccessCollection) 진짜 O(log n)이 나온다. 연결 리스트처럼 임의 접근이 O(n)이면 이진탐색을 해도 전체가 O(n)으로 무너진다. Swift 표준 라이브러리엔 이진탐색 함수가 없어 직접 구현하거나 swift-algorithms의 partitioningIndex를 쓴다.

CS 원리

이진탐색은 정렬된 구간의 가운데를 보고 목표와 비교해 절반을 통째로 버린다. 매번 후보가 반으로 줄어 비교는 O(log n)이다. 성립 조건은 정렬 순서와 비교 술어가 일치하는 것이다(이름순으로 정렬해 두고 날짜로 이진탐색하면 안 된다). 중복이 있으면 "처음 위치"와 "마지막 위치"를 구분해 찾아야 한다(lower/upper bound). 그리고 가운데 계산은 (lo + hi) / 2 대신 lo + (hi - lo) / 2로 오버플로를 피한다.

lo mid hi 1 3 5 7 9 11 13 15 17 arr[mid]=9 < 13 → 왼쪽 절반 버림 lo mid hi 1 3 5 7 9 11 13 15 17 arr[mid]=13 = target ✓
정렬된 9개 원소에서 13을 찾는다. 첫 mid(9)가 목표보다 작으니 왼쪽 절반을 버리고, 남은 구간의 mid에서 13을 찾는다. 후보가 매번 절반으로 준다.

iOS에서는

Swift 표준 라이브러리에는 binarySearch가 없다(주목할 만한 공백). 직접 구현하거나 swift-algorithmspartitioningIndex(where:)를 쓴다. ArrayRandomAccessCollection이라 인덱스 이동이 O(1)이지만, Collection만 만족하는 타입에선 index(_:offsetBy:)가 O(k)라 이진탐색의 이점이 사라진다. 또 Swift는 Int 오버플로 시 조용히 넘어가지 않고 트랩(크래시)한다. 다만 64비트에서 배열 인덱스 합(lo + hi)이 넘칠 일은 사실상 없고, 큰 정수 값 구간을 이진탐색할 때 (lo + hi) / 2가 실제로 위험해진다. 어느 쪽이든 lo + (hi - lo) / 2가 안전한 습관이다.

오버플로에 안전한 이진탐색 (Swift)
func binarySearch<T: Comparable>(_ a: [T], _ target: T) -> Int? {
    var lo = 0, hi = a.count - 1
    while lo <= hi {
        let mid = lo + (hi - lo) / 2       // (lo+hi)/2 는 오버플로 위험(Swift는 트랩)
        if a[mid] == target { return mid }
        else if a[mid] < target { lo = mid + 1 }   // 정렬 기준과 같은 비교여야 함
        else { hi = mid - 1 }
    }
    return nil
}

실험 · 도구

정렬 안 된 배열에 이진탐색을 돌리면 크래시가 아니라 조용히 틀린 결과(있는 값을 없다고 하거나 그 반대)를 낸다는 걸 확인한다. 반복 횟수를 로그로 세면 정렬된 n개에서 ⌊log₂ n⌋ + 1번을 넘지 않는다.

프로젝트 적용

같은 데이터를 여러 번 조회하면 한 번 정렬해 두고 이진탐색한다. 삽입이 잦으면 정렬 배열 유지 비용(삽입 O(n))과 Set/Dictionary(평균 O(1))를 저울질한다.

한 번 정렬해 두고 이진탐색
import Foundation

struct Event { let timestamp: Date; let name: String }

// ✅ 여러 번 조회할 데이터는 정렬을 한 번만 하고 재사용한다
struct EventIndex {
    private let sorted: [Event]

    init(_ events: [Event]) {
        sorted = events.sorted { $0.timestamp < $1.timestamp }   // O(n log n) 1회
    }

    /// 주어진 시각 이후 첫 이벤트의 인덱스 (lower bound)
    func firstIndex(onOrAfter date: Date) -> Int {
        var low = 0, high = sorted.count
        while low < high {
            let mid = low + (high - low) / 2        // 오버플로 안전
            if sorted[mid].timestamp < date { low = mid + 1 } else { high = mid }
        }
        return low
    }

    /// 범위 조회도 O(log n) 두 번으로 끝난다
    func events(from start: Date, to end: Date) -> ArraySlice<Event> {
        let lo = firstIndex(onOrAfter: start)
        let hi = firstIndex(onOrAfter: end)
        return sorted[lo..<hi]
    }
}

// 사용
func usage(_ events: [Event]) {
    let index = EventIndex(events)                  // 정렬 1회
    let today = index.events(from: .now.addingTimeInterval(-86400), to: .now)
    _ = today                                        // 이후 조회는 전부 O(log n)
}

// ⚠️ 삽입이 잦으면 정렬 배열 유지 비용(삽입 O(n))이 커진다.
//    그때는 Dictionary(평균 O(1) 조회) 또는 정렬 트리를 검토한다.
//    선택 기준: 조회 : 삽입 비율과, 범위 조회가 필요한지 여부.

정렬 기준과 탐색 기준을 반드시 동일하게 맞춘다.

정렬 기준과 탐색 기준을 일치시키기
import Foundation

struct User { let id: Int; let name: String; let score: Int }

// ❌ 이름순으로 정렬해 놓고 점수로 이진탐색 — 조용히 틀린 답을 준다
func brokenSearch(_ users: [User], score: Int) -> User? {
    let sorted = users.sorted { $0.name < $1.name }     // 이름순
    var low = 0, high = sorted.count - 1
    while low <= high {
        let mid = (low + high) / 2
        if sorted[mid].score == score { return sorted[mid] }
        else if sorted[mid].score < score { low = mid + 1 }   // 💥 전제가 깨졌다
        else { high = mid - 1 }
    }
    return nil
}

// ✅ 정렬과 탐색이 같은 키를 쓰도록 타입으로 묶는다
struct SortedBy<T, K: Comparable> {
    private let items: [T]
    private let key: (T) -> K

    init(_ items: [T], by key: @escaping (T) -> K) {
        self.key = key
        self.items = items.sorted { key($0) < key($1) }   // 여기서 쓴 키를
    }

    func firstIndex(onOrAfter value: K) -> Int {
        var low = 0, high = items.count
        while low < high {
            let mid = low + (high - low) / 2
            if key(items[mid]) < value { low = mid + 1 } else { high = mid }
        }
        return low                                        // 탐색에서도 그대로 쓴다
    }

    subscript(i: Int) -> T { items[i] }
    var count: Int { items.count }
}

// 사용 — 키를 한 곳에만 적으므로 어긋날 수가 없다
func usage(_ users: [User]) {
    let byScore = SortedBy(users) { $0.score }
    let i = byScore.firstIndex(onOrAfter: 80)
    _ = i
}

하지 말 것: 조회할 때마다 재정렬하는 것(그러면 조회당 O(n log n)이라 이진탐색의 의미가 없다).

하지 말 것 — 조회할 때마다 재정렬
import Foundation

struct Item { let id: Int; let priority: Int }

// ❌ 조회 한 번에 O(n log n) — 이진탐색을 쓰는 의미가 완전히 사라진다
func findBad(_ items: [Item], priority: Int) -> Item? {
    let sorted = items.sorted { $0.priority < $1.priority }   // 💥 매번 정렬
    return binarySearch(sorted, priority)
}

// 이 패턴은 특히 UI 코드에서 조용히 숨는다
final class BadViewModel {
    var items: [Item] = []

    // cellForRowAt에서 호출되면 화면에 보이는 행 수만큼 정렬이 돈다
    func item(at index: Int) -> Item {
        items.sorted { $0.priority < $1.priority }[index]      // 💥
    }
}

// ✅ 정렬 결과를 캐시하고, 원본이 바뀔 때만 무효화한다
final class ViewModel {
    private var items: [Item] = [] {
        didSet { sortedCache = nil }          // ✅ 변경 시 무효화
    }
    private var sortedCache: [Item]?

    private var sorted: [Item] {
        if let sortedCache { return sortedCache }
        let s = items.sorted { $0.priority < $1.priority }
        sortedCache = s
        return s
    }

    func item(at index: Int) -> Item { sorted[index] }   // 정렬은 최대 1회

    func reload(_ new: [Item]) { items = new }
}

// ✅ 정렬 상태를 계속 유지해야 하면 삽입 시점에 위치를 찾아 넣는다
func insertSorted(_ item: Item, into array: inout [Item]) {
    var low = 0, high = array.count
    while low < high {
        let mid = low + (high - low) / 2
        if array[mid].priority < item.priority { low = mid + 1 } else { high = mid }
    }
    array.insert(item, at: low)      // 탐색 O(log n) + 삽입 O(n)
}

func binarySearch(_ a: [Item], _ p: Int) -> Item? { nil }
⚠️ 흔한 오해

"정렬만 돼 있으면 어디서든 O(log n)"은 틀렸다. 임의 접근이 O(1)이 아니면(연결 리스트) O(n)이 된다. 또 "이진탐색은 항상 선형탐색보다 빠르다"도 아니다. 작은 n이나 캐시 지역성이 좋은 경우 선형탐색이 빠를 수 있고, 정렬 비용까지 포함하면 단 한 번 조회엔 선형이 낫다.

🧒 쉽게 이해하기

두꺼운 국어사전에서 "자료"를 찾을 때 첫 장부터 넘기는 사람은 없다. 대충 가운데를 펼쳐 보고 "자"보다 앞이면 앞쪽 반을 통째로 덮어 버리고, 뒤면 뒤쪽 반을 덮는다. 한 번 펼칠 때마다 후보가 절반으로 줄기 때문에 100만 단어짜리 사전도 스무 번쯤이면 끝난다. 이게 이진탐색이고, O(log n)의 정체다.

여기서 놓치기 쉬운 전제가 두 개다. 첫째, 사전이 가나다순으로 꽂혀 있어야 하고 내가 반을 버리는 기준도 가나다여야 한다. 가나다순 책장에서 "출판연도"로 반을 버리면 답이 엉뚱해지는데, 정말 무서운 건 이때 앱이 죽지 않고 조용히 틀린 답을 준다는 점이다. 있는 값을 없다고 하거나 그 반대인데, 크래시가 아니라 아무 소리 없이 그렇게 된다. 둘째, 아무 데나 바로 펼칠 수 있어야 한다. 두루마리 편지처럼 처음부터 감아 나가야 하는 구조(연결 리스트)라면 "가운데"까지 가는 데만 처음부터 세어야 해서, 반씩 버려 아낀 이득이 통째로 사라진다.

꼬리 질문

중복 값에서 "target 이상이 처음 나오는 위치"(lower bound)를 찾으려면 조건을 어떻게 바꾸나?

값이 같아도 멈추지 말고 경계를 계속 좁혀야 한다. a[mid] < target이면 lo = mid + 1, 아니면(>=) hi = mid로 두고 lo < hi 동안 반복하면, 종료 시 lo가 target 이상이 처음 나오는 위치가 된다(없으면 삽입 위치). upper bound는 조건을 <=로 바꾸면 된다. 핵심은 찾자마자 return하지 않고 반쪽을 계속 버려 경계로 수렴시키는 것이다. Swift에선 swift-algorithms의 partitioningIndex(where:)가 이 술어 기반 경계 탐색을 그대로 제공한다.

쉽게 말하면 같은 값이 여러 개일 때 "아무거나 하나"가 아니라 "제일 왼쪽 것"을 찾아야 하는 상황이다. 그래서 찾았다고 손을 떼지 말고, 값이 같아도 "더 왼쪽에 또 있을지 모른다" 치고 오른쪽 절반을 계속 버린다. 그렇게 구간을 좁히면 결국 경계 한 점에서 만난다. 찾는 값이 아예 없어도 그 자리가 "여기 끼워 넣으면 되는 위치"라 그대로 쓸모가 있다.
회전된 정렬 배열(rotated sorted array)에서는 이진탐색을 어떻게 변형하나?

매 단계에서 mid를 기준으로 좌우 중 어느 쪽이 정상 정렬되어 있는지를 먼저 판정한다(a[lo] <= a[mid]이면 왼쪽이 정렬). 정렬된 쪽에 대해서만 target이 그 범위 안에 드는지 검사해 버릴 절반을 정하면, 회전점을 몰라도 여전히 O(log n)에 찾을 수 있다. 중복이 있으면 a[lo] == a[mid] == a[hi]인 경우 어느 쪽이 정렬됐는지 못 가려 lo/hi를 한 칸 좁히며 진행해야 하고, 이때 최악은 O(n)으로 떨어진다.

쉽게 말하면 가나다순 책장을 누가 중간에서 잘라 앞뒤 뭉치를 바꿔 꽂아 놓은 것이다. 전체는 엉망이지만 가운데를 짚으면 왼쪽이든 오른쪽이든 한쪽은 반드시 멀쩡히 정렬돼 있다. 그 멀쩡한 쪽을 골라 "찾는 게 여기 범위 안이냐"만 보면 버릴 절반을 확실히 정할 수 있다. 다만 양 끝과 가운데 값이 전부 같아 버리면 어느 쪽이 멀쩡한지 가릴 수가 없어서 한 칸씩 밀며 갈 수밖에 없고, 그때는 결국 다 훑는 꼴이 된다.
정렬 상태를 유지하면서 삽입도 잦다면 어떤 자료구조가 O(log n) 삽입·검색을 주나?

균형 이진 탐색 트리(red-black tree, AVL) 또는 skip list, B-tree 계열이 삽입·삭제·검색을 모두 O(log n)에 준다. 정렬 배열은 검색은 O(log n)이지만 삽입이 원소 밀기 때문에 O(n)이라 삽입이 잦으면 불리하다. 다만 Swift 표준 라이브러리엔 정렬 트리가 없어 직접 구현하거나 별도 패키지가 필요하고, "정렬 순회는 드물고 조회·삽입만 잦다"면 오히려 Dictionary/Set(평균 O(1))가 상수까지 포함해 더 빠른 경우가 많다.

쉽게 말하면 정렬된 배열은 찾기는 빠른데, 중간에 하나 끼워 넣으려면 뒤 물건을 전부 밀어야 한다. 균형 이진 탐색 트리(red-black tree처럼 스스로 좌우 높이를 맞추는 구조)는 넣고 빼고 찾는 게 전부 log n에서 끝난다. 문제는 Swift 기본 라이브러리에 이게 없다는 것이다. 그래서 "정렬된 순서로 쭉 훑을 일은 별로 없고 조회·삽입만 잦다"면 트리를 직접 만들기보다 Dictionary를 쓰는 편이 현실적으로 더 빠르다.

Q6. 정렬 알고리즘의 안정성은 UI 데이터에서 언제 중요한가?

🔑 30초 답변

안정 정렬(stable sort)은 정렬 키가 같은 원소들의 원래 상대 순서를 보존한다. UI에서 두 경우에 중요하다. (1) 다단계 정렬 — 2차 키로 먼저 정렬한 뒤 1차 키로 다시 정렬할 때 안정성이 없으면 동점 그룹 안의 순서가 뒤엉킨다. (2) diffable 스냅샷 갱신 — 같은 값끼리 순서가 매번 달라지면 셀이 이유 없이 자리를 바꾸는 애니메이션과 깜빡임이 생긴다. Swift의 표준 sort는 Swift 5.0부터 구현상 안정적이었고, 문서로 보장된 것은 SE-0372가 반영된 Swift 5.8부터다.

CS 원리

안정 정렬은 같은 키를 가진 원소들의 입력 순서를 그대로 둔다. 그래서 다단계 정렬을 "덜 중요한 키 먼저, 더 중요한 키 나중"으로 안정 정렬을 연쇄하면 원하는 다중 기준이 완성된다. 병합정렬과 Timsort는 안정적이고, 일반적인 퀵정렬·힙정렬은 불안정하다.

입력(이름순 정렬 상태)안정 정렬 → 점수순불안정 정렬 → 점수순
Ann·90 / Bea·90 / Cy·80Cy·80, Ann·90, Bea·90 (동점 이름순 유지)Cy·80, Bea·90, Ann·90 (순서 뒤섞임 가능)

iOS에서는

Apple 문서는 현재 sort(by:)/sorted(by:)에 대해 "정렬 알고리즘은 안정성이 보장된다"고 명시한다. 구현이 안정 정렬(수정 timsort)로 바뀐 것은 Swift 5.0이지만, 문서상의 보장은 SE-0372로 Swift 5.8에서야 확정됐다 — Swift 5.7까지의 표준 라이브러리 주석은 오히려 "안정성이 보장되지 않는다"였고, 그 이전 introsort 시절에는 구현 자체가 불안정했다. NSDiffableDataSourceSnapshot으로 목록을 갱신할 때는 항목 순서가 곧 애니메이션을 결정한다. 동점 원소의 순서가 스냅샷마다 흔들리면 실제로는 변한 게 없는데도 move 애니메이션이 남발된다. 즉 정렬 안정성이 애니메이션 안정성으로 직결된다.

다단계 정렬 — 안정성 연쇄 vs 튜플 비교 (Swift)
// 방법1: 안정 정렬 연쇄 — 덜 중요한 키(이름) 먼저, 중요한 키(날짜) 나중
items.sort { $0.name < $1.name }
items.sort { $0.date < $1.date }   // 같은 날짜끼리 이름순 유지(안정성 보장에 의존)

// 방법2: 튜플 비교 — tie-breaker를 명시(결정적, 안정성에 덜 의존)
items.sort { ($0.date, $0.name) < ($1.date, $1.name) }

프로젝트 적용

다단계 정렬은 안정 정렬 연쇄 또는 튜플 비교 중 하나로 한다. 튜플 비교는 tie-breaker가 코드에 드러나 결정적이라 더 견고하다.

다단계 정렬은 튜플 비교 또는 안정 연쇄
import Foundation

struct Task3 { let title: String; let due: Date; let priority: Int }

// ✅ 방법 ① 튜플 비교 — tie-breaker가 한 줄에 드러나고 정렬을 한 번만 돈다
func sortByTuple(_ tasks: inout [Task3]) {
    tasks.sort { ($0.priority, $0.due, $0.title) < ($1.priority, $1.due, $1.title) }
}

// ✅ 방법 ② 안정 정렬 연쇄 — 덜 중요한 키 먼저, 중요한 키 나중
func sortByChaining(_ tasks: inout [Task3]) {
    tasks.sort { $0.title < $1.title }        // 3차
    tasks.sort { $0.due < $1.due }            // 2차
    tasks.sort { $0.priority < $1.priority }  // 1차 — 앞 순서가 보존된다
}

// ⚠️ 연쇄는 순서를 거꾸로 생각하기 쉽다. 중요한 키가 '나중'이다.
// ⚠️ 정렬을 n번 돌므로 비용도 n배다.

// ✅ 내림차순이 섞이면 튜플이 안 되므로 비교 함수를 명시한다
func sortMixed(_ tasks: inout [Task3]) {
    tasks.sort { a, b in
        if a.priority != b.priority { return a.priority > b.priority }  // 내림차순
        if a.due != b.due { return a.due < b.due }                      // 오름차순
        return a.title < b.title
    }
}

// ✅ 재사용할 비교자는 타입으로 뽑아 둔다
struct TaskComparator: SortComparator {
    var order: SortOrder = .forward
    func compare(_ a: Task3, _ b: Task3) -> ComparisonResult {
        let base: ComparisonResult = (a.priority != b.priority)
            ? (a.priority < b.priority ? .orderedAscending : .orderedDescending)
            : a.title.compare(b.title)
        // ⚠️ order를 반영하지 않으면 컬럼 헤더로 방향을 토글해도 아무 일이 없다
        guard order == .reverse else { return base }
        switch base {
        case .orderedAscending:  return .orderedDescending
        case .orderedDescending: return .orderedAscending
        case .orderedSame:       return .orderedSame
        }
    }
}

func sortWithComparator(_ tasks: inout [Task3]) {
    tasks.sort(using: TaskComparator())
}

동점 순서가 UI 정체성(셀 애니메이션, 선택 유지)에 영향을 주면, 안정성에 기대기보다 명시적 tie-breaker(예: 고유 id)를 넣어 순서를 완전히 결정적으로 만든다.

UI 정체성에 영향을 주면 명시적 tie-breaker
import UIKit

struct Post: Hashable, Identifiable {
    let id: UUID
    let author: String
    let createdAt: Date
}

// diffable 스냅샷은 '순서'로 애니메이션을 결정한다.
// 동점 항목의 순서가 갱신마다 흔들리면 실제로 변한 게 없는데도 셀이 자리를 바꾼다.

// ❌ 날짜만으로 정렬 — 같은 초에 올라온 글들의 순서가 불안정할 수 있다
func snapshotBad(_ posts: [Post]) -> NSDiffableDataSourceSnapshot<Int, Post.ID> {
    var snap = NSDiffableDataSourceSnapshot<Int, Post.ID>()
    snap.appendSections([0])
    snap.appendItems(posts.sorted { $0.createdAt > $1.createdAt }.map(\.id))
    return snap
}

// ✅ 완전히 결정적인 순서를 만든다 — id가 최종 tie-breaker
func snapshot(_ posts: [Post]) -> NSDiffableDataSourceSnapshot<Int, Post.ID> {
    var snap = NSDiffableDataSourceSnapshot<Int, Post.ID>()
    snap.appendSections([0])
    let ordered = posts.sorted {
        ($0.createdAt, $0.id.uuidString) > ($1.createdAt, $1.id.uuidString)
    }
    snap.appendItems(ordered.map(\.id))
    return snap
}

// ✅ 정렬 로직을 한 곳에 모아 두면 화면마다 어긋날 일이 없다
extension Array where Element == Post {
    /// 피드 표준 정렬 — 최신순, 동점이면 id순 (완전 결정적)
    func feedOrdered() -> [Post] {
        sorted { ($0.createdAt, $0.id.uuidString) > ($1.createdAt, $1.id.uuidString) }
    }
}

// ✅ 테스트로 결정성을 고정한다
func test_정렬이_결정적이다(_ posts: [Post]) {
    let a = posts.feedOrdered().map(\.id)
    let b = posts.shuffled().feedOrdered().map(\.id)
    assert(a == b, "입력 순서에 따라 결과가 달라진다")
}

하지 말 것: 동점 순서가 중요한데 tie-breaker 없이 "정렬이 알아서 유지해 주겠지" 가정하는 것.

하지 말 것 — 안정성에 암묵적으로 기대기
import Foundation

struct Row { let group: String; let name: String }

// Swift 표준 sort는 안정성이 보장되지만, 그 사실에 '암묵적으로' 기대는 코드는
// 읽는 사람에게 전제가 보이지 않아 위험하다.

// ❌ 왜 이 순서가 나오는지 코드만 봐서는 알 수 없다
func sortBad(_ rows: inout [Row]) {
    rows.sort { $0.name < $1.name }
    rows.sort { $0.group < $1.group }
    // "같은 group 안에서 name순"이 되는 이유가 안정성이라는 걸
    // 주석 없이는 아무도 모른다. 누가 중간에 다른 정렬을 끼워 넣으면 조용히 깨진다.
}

// ✅ 전제를 코드에 드러낸다
func sortExplicit(_ rows: inout [Row]) {
    rows.sort { ($0.group, $0.name) < ($1.group, $1.name) }
    // 규칙이 한 줄에 다 있다. 읽는 사람이 추론할 필요가 없다.
}

// ✅ 연쇄를 써야 한다면 이유를 남긴다
func sortChained(_ rows: inout [Row]) {
    // 안정 정렬 연쇄: 2차 키(name) 먼저, 1차 키(group) 나중.
    // Swift sort는 안정성이 보장되므로 group이 같은 무리 안에서 name 순서가 유지된다.
    rows.sort { $0.name < $1.name }
    rows.sort { $0.group < $1.group }
}

// ⚠️ 다른 언어·라이브러리로 옮길 때 특히 위험하다
//    C++ std::sort, Java Arrays.sort(primitives), 대부분의 quicksort 구현은 불안정하다.
//    서버와 클라이언트가 같은 정렬을 재현해야 한다면 tie-breaker를 명시하는 게 안전하다.

// ✅ 동점 순서가 눈에 보이는 결과(UI 순서, 페이지네이션 경계)에 영향을 주면
//    안정성이 아니라 명시적 규칙에 의존한다
⚠️ 흔한 오해

"정렬은 다 똑같고 전부 안정적이다"는 틀렸다. 힙정렬·일반 퀵정렬은 불안정하다. 또 "Swift sort는 처음부터 안정성이 보장돼 있었다"도 틀렸다. 구현은 Swift 5.0부터 안정적이었지만 문서상의 보장은 SE-0372로 Swift 5.8에서야 붙었고, 그 이전 introsort 시절에는 구현 자체가 불안정했다.

🧒 쉽게 이해하기

안정 정렬은 "동점이면 원래 있던 앞뒤를 건드리지 않는다"는 약속이다. 점수순으로 자리를 다시 배치하는데 90점이 둘이면, 안정 정렬은 원래 앞자리였던 아이를 계속 앞에 둔다. 불안정 정렬이 그 둘을 바꿔 놓아도 "점수순"이라는 약속 자체를 어긴 건 아니라서 아무도 오류라고 말해 주지 않는다. 그냥 돌릴 때마다 결과가 달라질 수 있을 뿐이다.

이 약속이 있어서 다단계 정렬을 이렇게 할 수 있다. 덜 중요한 기준(이름)으로 먼저 줄을 세우고, 그다음 중요한 기준(날짜)으로 다시 세우면 같은 날짜 안에서는 아까의 이름 순서가 그대로 남는다. 순서를 거꾸로 생각하기 쉬운데, 중요한 기준이 나중이다. UI에서 이게 왜 문제가 되냐면, 목록을 새로 그릴 때마다 동점 항목들의 앞뒤가 흔들리면 실제로 바뀐 게 없는데도 셀들이 서로 자리를 바꾸는 애니메이션이 튀기 때문이다. 그래서 제일 확실한 방법은 안정성에 기대는 대신 "점수가 같으면 id가 작은 쪽이 앞"처럼 동점 규칙을 코드에 직접 써 두는 것이다.

① 원본 ② 이름순으로 먼저 ③ 날짜순 안정 정렬 Bea · 5/2 Ann · 5/1 Dan · 5/1 Cy · 5/2 Ann · 5/1 Bea · 5/2 Cy · 5/2 Dan · 5/1 Ann · 5/1 Dan · 5/1 Bea · 5/2 Cy · 5/2 같은 5/1 안에서 Ann → Dan, 같은 5/2 안에서 Bea → Cy 가 그대로 남았다 덜 중요한 키를 먼저, 중요한 키를 나중에 — 순서를 거꾸로 하면 무너진다
이름으로 먼저 세운 뒤 날짜로 다시 세운다. 정렬이 안정적이라 날짜가 같은 무리 안에서는 앞 단계의 이름 순서가 보존된다. 불안정 정렬이면 이 마지막 단계에서 같은 날짜끼리 순서가 뒤엉킬 수 있다.

꼬리 질문

sort(by:)에 넘긴 비교 클로저가 strict weak ordering(엄격 약순서)을 위반하면 — 예컨대 a < bb < a가 동시에 성립하는 등 비일관적일 때 — 정렬 결과와 안정성 보장은 어떻게 되나?

sort(by:)는 비교 술어가 엄격 약순서(비반사성·비대칭성·추이성, 그리고 '동등' 관계의 추이성)를 만족한다고 전제한다. a < bb < a가 동시에 참이 되는 식으로 이 계약이 깨지면 정렬 결과는 unspecified가 되어 어떤 순열이 나올지 정의되지 않고, '같은 키의 원래 순서를 보존한다'는 안정성 보장도 애초에 '같음'이 일관되게 정의되지 않으니 의미를 잃는다. Swift의 sort는 메모리 안전이라 C++ std::sort처럼 범위 밖 접근으로 크래시하진 않고 원소가 사라지지도 않지만, 나오는 순서에는 어떤 보장도 없다. 같은 입력에는 같은 결과가 나오더라도 입력 순서나 원소 개수가 조금만 달라지면(또는 Swift 버전이 바뀌어 내부 알고리즘이 달라지면) 결과가 예측 불가능하게 뒤집히므로, 재현이 어려운 '가끔 순서가 이상한' 버그로 보이지만 실제로는 정렬 계약 위반이다.

쉽게 말하면 정렬 함수에게 "둘 중 누가 앞이냐"를 물어보는 심판이 내가 넘긴 클로저다. 그 심판이 A한테도 "네가 앞"이라 하고 B한테도 "네가 앞"이라 하면, 정렬은 그냥 아무 순서나 내놓는다. Swift는 안전해서 크래시가 나거나 원소가 사라지진 않지만, 나온 순서에 대해서는 어떤 약속도 하지 않는다. 그래서 "가끔 순서가 이상한데 재현이 안 되는" 버그처럼 보이지만 사실은 내가 규칙을 어긴 것이다. 동점끼리 진짜 같은지도 정의가 안 되니 안정성 이야기 자체가 성립하지 않는다.
튜플 비교와 안정 정렬 연쇄는 각각 언제 더 낫나?

튜플 비교는 한 번의 정렬로 끝나고 tie-breaker가 코드에 드러나 결정적이며, 모든 키 우선순위를 한 술어로 표현할 수 있을 때 가장 견고하다. 안정 정렬 연쇄는 정렬 기준을 동적으로 조합하거나(사용자가 컬럼 헤더를 눌러 누적 정렬), 각 단계의 비교가 서로 다른 데이터 소스에서 와서 하나의 튜플로 묶기 어려울 때 유용하다. 다만 연쇄는 정렬을 여러 번 돌려 비용이 더 크고 안정성 보장에 의존하므로, 단순하고 정적인 다중 기준이면 튜플 비교가 낫다.

쉽게 말하면 튜플 비교는 "날짜순, 같으면 이름순"이라는 규칙을 한 줄에 다 적어 두는 것이다. 규칙이 눈에 보이니 헷갈릴 일이 없고 정렬을 한 번만 돌린다. 연쇄는 사용자가 컬럼 머리를 눌러 정렬 기준을 그때그때 얹는 화면처럼, 기준이 미리 정해지지 않을 때 쓸모가 있다. 대신 정렬을 여러 번 돌려 느리고, "안정 정렬이니까 앞 순서가 남아 있겠지"라는 눈에 안 보이는 전제에 기대게 된다.
diffable data source에서 같은 identifier가 중복되면 무슨 일이 벌어지나?

item identifier는 유일해야 한다는 게 NSDiffableDataSourceSnapshot의 전제다. 같은 identifier를 두 번 append하면 diff를 계산할 수 없어 런타임에서 "duplicate identifiers" 경고를 찍고, 상황에 따라 어서션 실패나 크래시로 이어진다. 설령 넘어가더라도 어느 셀이 진짜인지 몰라 잘못된 셀이 갱신·애니메이션되는 시각 버그가 난다. 그래서 스냅샷을 만들기 전에 Set<ID>로 dedup하고, id는 배열 인덱스가 아니라 안정적이고 유일한 서버 id를 써야 한다.

쉽게 말하면 diffable data source는 학생증 번호를 보고 누가 새로 왔고 누가 자리를 옮겼는지 계산한다. 같은 번호를 가진 사람이 둘이면 계산 자체가 성립하지 않아서, 경고를 찍거나 그대로 죽는다. 넘어가더라도 엉뚱한 셀이 갱신되는 시각 버그가 남는다. 그래서 번호는 서버가 주는 변하지 않는 id여야 한다. "배열에서 몇 번째"는 앞에 글 하나만 끼어들어도 전부 어긋나니 번호로 쓰면 안 된다.

Q7. Pagination 데이터를 중복 없이 효율적으로 병합하려면 어떻게 하는가?

🔑 30초 답변

각 아이템의 안정적 고유 ID를 기준으로 Set<ID>에 "이미 본 것"을 담고, 새 페이지에서 처음 보는 ID만 추가하면 전체 O(n)에 중복 없이 병합된다. Swift에선 seen.insert(id).inserted가 O(1) 판정과 삽입을 한 번에 해 준다. 더 근본적으로는 offset 기반 대신 cursor(keyset) 페이지네이션을 쓰면 목록이 중간에 바뀌어도 중복·누락이 생기지 않는다.

CS 원리

병합의 핵심은 "이미 봤는지"를 싸게 판정하는 것이다. 매번 배열 전체를 훑어 contains로 확인하면 O(n) × n = O(n²)이 된다. 해시 집합으로 판정하면 O(1)이라 전체가 O(n)으로 떨어진다. 두 페이지가 같은 키로 정렬돼 있다면, 병합정렬의 merge 단계처럼 앞에서부터 훑으며 O(n+m)에 합치고 동일 키를 걸러낼 수도 있다.

새 페이지 id 7 id 3 id 9 seen : Set<ID> { 1, 3, 5 } insert(id).inserted? 결과(추가) id 7 ✓ id 9 ✓ id 3 은 중복 → 버림
새 페이지의 각 id를 seen 집합과 대조한다. 처음 본 7·9만 결과에 추가되고, 이미 있던 3은 버려진다. 판정·삽입이 O(1)이라 병합 전체가 O(n)이다.

iOS에서는

Set.insert(_:)(inserted: Bool, memberAfterInsert)를 돌려주므로, 삽입 성공 여부로 "처음 본 것"을 바로 가릴 수 있다. 순서를 보존해야 하면 swift-collectionsOrderedSet/OrderedDictionary가 삽입 순서 유지 + O(1) 유일성을 함께 준다. Diffable data source는 item identifier가 유일해야 하며, 중복 identifier를 append하면 런타임 경고나 크래시가 난다. 그래서 클라이언트 dedup은 선택이 아니라 필수다. ID는 Identifiable의 서버 id처럼 안정적이고 유일한 값이어야 하며, 배열 인덱스나 객체 주소를 쓰면 안 된다.

Set.insert로 O(n) 중복 제거 병합 (Swift)
var items: [Feed] = []
var seen = Set<Feed.ID>()

func merge(_ page: [Feed]) {
    for item in page where seen.insert(item.id).inserted {
        items.append(item)          // 처음 본 id만 추가 → 페이지 크기에 비례(O(page))
    }
}

실험 · 도구

같은 페이지를 일부러 두 번 주입한 뒤 diffable 스냅샷을 apply 해, dedup이 없을 때 콘솔에 뜨는 중복 identifier 경고와 dedup을 넣은 뒤의 정상 동작을 비교한다. contains로 매번 전체 배열을 훑는 O(n²) 버전과 Set 기반 O(n) 버전을 measure {}로 재면 페이지가 쌓일수록 차이가 벌어진다.

프로젝트 적용

가능하면 백엔드에 cursor 페이지네이션을 요청한다. offset은 목록이 중간에 바뀌면 중복·누락을 만든다.

가능하면 cursor 페이지네이션 요청
import Foundation

// offset은 "몇 번째부터"라서, 목록이 중간에 바뀌면 경계가 어긋난다.
//   1페이지(0~19) 받은 뒤 새 글 1개가 맨 앞에 추가되면
//   2페이지(20~39)는 원래 19번이었던 항목부터 다시 준다 → 중복
//   반대로 글이 삭제되면 → 누락

// ❌ offset 기반
struct OffsetRequest: Encodable {
    let offset: Int
    let limit: Int
}

// ✅ cursor(keyset) 기반 — "이 지점 다음부터"라 삽입·삭제에 영향받지 않는다
struct CursorRequest: Encodable {
    let after: String?      // 직전 페이지 마지막 항목의 커서
    let limit: Int
}

struct Page<T: Decodable>: Decodable {
    let items: [T]
    let nextCursor: String?     // nil이면 마지막 페이지
    var hasMore: Bool { nextCursor != nil }
}

// 클라이언트 루프
final class FeedPager {
    private var cursor: String?
    private(set) var isFinished = false

    func loadNext() async throws -> [Post] {
        guard !isFinished else { return [] }
        let page: Page<Post> = try await api.feed(after: cursor, limit: 20)
        cursor = page.nextCursor
        isFinished = !page.hasMore
        return page.items
    }

    func reset() { cursor = nil; isFinished = false }
}

// ⚠️ 커서는 불투명 문자열로 다룬다 — 클라가 파싱·생성하려 하면 안 된다.
//    서버가 (정렬키, tie-breaker id)를 인코딩한 값이다.

struct Post: Decodable {}
enum api {
    static func feed(after: String?, limit: Int) async throws -> Page<Post> {
        Page(items: [], nextCursor: nil)
    }
}

클라이언트는 Set<ID> 또는 OrderedSet로 방어적 dedup을 한다. 서버가 완벽해도 재시도·경합으로 중복이 올 수 있다.

클라이언트에서 방어적으로 dedup
import Foundation

// 서버가 완벽해도 중복은 온다: 재시도, 경합, 푸시와 폴링의 겹침, 캐시 재생.
// 클라이언트에 얇은 방어선을 두는 비용은 거의 0이다.

struct Post: Identifiable, Hashable {
    let id: UUID
    let title: String
    let updatedAt: Date
}

final class FeedStore {
    private(set) var items: [Post] = []
    private var seen = Set<Post.ID>()

    /// 처음 본 것만 뒤에 붙인다 — 페이지 크기에 비례하는 O(page)
    func append(_ page: [Post]) {
        items.reserveCapacity(items.count + page.count)
        for post in page where seen.insert(post.id).inserted {
            index[post.id] = items.count      // append와 upsert가 같은 장부를 쓰게 한다
            items.append(post)
        }
    }

    /// 갱신이 올 수도 있으면 '최신으로 교체'까지 처리한다
    private var index: [Post.ID: Int] = [:]

    func upsert(_ page: [Post]) {
        for post in page {
            if let i = index[post.id] {
                // 더 새로운 것만 반영 (오래된 응답이 나중에 도착할 수 있다)
                if post.updatedAt > items[i].updatedAt { items[i] = post }
            } else {
                index[post.id] = items.count
                seen.insert(post.id)          // seen도 함께 갱신해야 append와 어긋나지 않는다
                items.append(post)
            }
        }
    }

    func reset() { items.removeAll(); seen.removeAll(); index.removeAll() }
}

// ✅ 순서를 유지한 채 중복만 없애는 간단한 방법
func deduped(_ posts: [Post]) -> [Post] {
    var seen = Set<Post.ID>(minimumCapacity: posts.count)
    return posts.filter { seen.insert($0.id).inserted }
}

identifier는 안정적이고 유일해야 한다. 인덱스·주소·순번을 쓰지 않는다.

id는 안정적이고 유일해야 한다
import Foundation

// diffable data source·SwiftUI ForEach는 id로 셀 정체성을 판단한다.
// id가 불안정하면 셀이 이유 없이 재생성되고 애니메이션이 튄다.

// ❌ ① 배열 인덱스를 id로 — 목록이 바뀌면 전혀 다른 항목을 같다고 판단한다
struct BadRow: Identifiable {
    let id: Int          // 0, 1, 2 … 순번
    let title: String
}

// ❌ ② 내용 기반 id — 제목만 바뀌어도 '다른 항목'이 되어 셀이 사라졌다 나타난다
struct AlsoBad: Identifiable {
    var id: String { title }
    let title: String
}

// ❌ ③ 객체 주소·hashValue — 실행마다 달라진다

// ✅ 서버가 준 안정적 식별자를 그대로 쓴다
struct Post: Identifiable, Hashable {
    let id: UUID         // 서버 발급. 내용이 바뀌어도 유지된다.
    var title: String
    var likeCount: Int
}

// ✅ 서버 id가 없는 로컬 항목은 생성 시점에 한 번 부여한다
struct Draft: Identifiable {
    let id = UUID()      // ⚠️ let — 매번 새로 만들면 안 된다
    var text: String = ""
}

// ❌ 이렇게 하면 매 접근마다 새 id가 나온다
struct BrokenDraft: Identifiable {
    var id: UUID { UUID() }      // 💥 뷰가 그릴 때마다 다른 항목이 된다
}

// ✅ 두 소스를 합칠 때는 네임스페이스로 충돌을 막는다
enum FeedItemID: Hashable {
    case post(UUID)
    case ad(String)
}

// SwiftUI에서
// ForEach(posts) { post in ... }          // Identifiable이면 자동
// ForEach(posts, id: \.id) { ... }        // 명시
// ❌ ForEach(0..<posts.count) { ... }      // 인덱스 — 항목이 바뀌면 깨진다

하지 말 것: 병합할 때마다 contains로 전체 배열을 훑는 것(O(n²)).

하지 말 것 — 병합할 때마다 contains로 전체 훑기
import Foundation

struct Post: Identifiable { let id: UUID }

// ❌ 병합마다 기존 배열 전체를 훑는다 — 페이지가 쌓일수록 급격히 느려진다
func mergeBad(_ items: inout [Post], _ page: [Post]) {
    for post in page {
        if !items.contains(where: { $0.id == post.id }) {     // O(n)
            items.append(post)
        }
    }
    // 20페이지째면 items가 400개 → 페이지당 20 × 400 = 8,000번 비교
    // 100페이지째면 20 × 2,000 = 40,000번. 전체로는 O(n²).
}

// ✅ Set으로 O(1) 판정
final class FeedStore {
    private(set) var items: [Post] = []
    private var seen = Set<Post.ID>()

    func merge(_ page: [Post]) {
        for post in page where seen.insert(post.id).inserted {
            items.append(post)                                 // O(page)
        }
    }
}

// 두 방식의 차이를 재 본다
func benchmark() {
    let pages = (0..<100).map { p in (0..<20).map { _ in Post(id: UUID()) } }
    let clock = ContinuousClock()

    let bad = clock.measure {
        var items: [Post] = []
        for page in pages { mergeBad(&items, page) }
    }

    let good = clock.measure {
        let store = FeedStore()
        for page in pages { store.merge(page) }
    }

    print("contains \(bad) vs Set \(good)")
}

// ⚠️ 같은 함정이 숨는 다른 자리들
//    · firstIndex(where:)로 매번 위치 찾기 → 인덱스 딕셔너리를 유지한다
//    · removeAll(where:)를 루프 안에서 호출 → 한 번만 호출한다
//    · 정렬된 배열에 매번 sorted() → 삽입 위치를 이진탐색으로 찾는다
⚠️ 흔한 오해

"offset 페이지네이션이면 클라이언트 dedup은 필요 없다"는 틀렸다. 목록에 새 글이 끼어들면 같은 아이템이 두 페이지에 걸치거나 건너뛰어진다. 또 "Set에 넣으면 순서가 유지된다"도 틀렸다. Set은 순서가 없으므로, 순서가 필요하면 OrderedSet이나 별도 배열을 함께 쓴다.

🧒 쉽게 이해하기

무한 스크롤은 긴 줄에서 열 명씩 데려오는 일과 비슷하다. 성가신 건 내가 다음 열 명을 부르러 간 사이 줄 맨 앞에 누가 새로 끼어들 수 있다는 점이다. "30번째부터 열 명"처럼 위치로 말하면(offset) 한 명 끼어든 만큼 전체가 한 칸씩 밀려서, 아까 이미 데려온 사람이 또 오거나 반대로 한 명이 통째로 건너뛰어진다. cursor는 "아까 마지막이 철수였으니 철수 다음부터"라고 사람을 기준점으로 잡는다. 앞에서 무슨 일이 벌어져도 철수는 여전히 철수라 경계가 흔들리지 않는다.

클라이언트 쪽 방어는 더 단순하다. 이미 받은 id를 놀이공원 손목밴드처럼 Set에 모아 두고, 새로 온 사람이 밴드를 이미 차고 있으면 그냥 돌려보낸다. Set에 물어보는 건 앞의 주차장 계산기처럼 한 번에 끝나서(평균 O(1)) 목록이 아무리 길어져도 느려지지 않는다. 반대로 매번 지금까지 모은 배열을 처음부터 훑어 "이 사람 있었나?" 확인하는 건 줄 전체를 매번 다시 세는 것과 같아서, 페이지가 쌓일수록 급격히 무거워진다.

① 1페이지로 A · B · C를 받았다 A B C D E ② 그 사이 맨 앞에 새 글 N이 끼어들었다 N A B C D E ③ 2페이지를 요청하면 offset · "4~6번째" C D E C가 또 온다 → 중복 cursor · "C 다음부터" D E 겹침도 빠짐도 없다
offset은 "몇 번째부터"라 앞에 하나가 끼면 경계가 한 칸 밀려 C를 두 번 받는다. cursor는 "어떤 아이템 다음부터"라 앞에서 무슨 일이 있어도 경계가 그대로 남는다.

꼬리 질문

offset과 cursor 페이지네이션은 중복·누락 특성이 어떻게 다른가?

offset(LIMIT/OFFSET)은 "n번째부터 몇 개"라는 위치 기준이라, 페이지를 넘기는 사이 목록 앞에 새 글이 끼면 뒤 아이템이 한 칸 밀려 이전 페이지 끝과 중복되고, 앞의 글이 삭제되면 하나가 건너뛰어진다. cursor(keyset)는 "이 키(예: id/timestamp) 다음부터"라는 내용 기준이라, 그 사이 삽입·삭제가 있어도 경계가 특정 레코드에 고정되어 중복·누락이 원리적으로 생기지 않는다. 대신 cursor는 정렬 키 위에 인덱스가 있어야 하고 임의 페이지 점프가 어렵다.

쉽게 말하면 offset은 "앞에서 서른 번째부터"라고 위치로 말하는 것이다. 앞에 한 명이 끼어들면 뒤가 통째로 한 칸 밀려서 이미 데려온 사람을 또 데려오고, 반대로 앞에서 한 명이 빠지면 한 명을 통째로 건너뛴다. cursor는 "철수 다음부터"라고 사람을 찍어 말하니 앞에서 무슨 일이 나든 경계가 흔들리지 않는다. 대신 "17페이지로 바로 점프" 같은 건 못 한다. 철수를 지나야만 그다음이 어디인지 알 수 있으니까.
두 페이지가 각각 정렬돼 있을 때 O(n+m)에 병합하려면?

병합정렬의 merge 단계를 쓴다. 두 배열에 포인터를 하나씩 두고 앞에서부터 더 작은(정렬 기준상 앞선) 쪽을 결과에 넣으며 그 포인터만 전진시키면, 각 원소를 정확히 한 번씩만 보므로 O(n+m)이다. 같은 키가 양쪽에 있으면 한쪽을 넣고 나머지 포인터도 함께 전진시켜 중복을 걸러낸다. 이미 정렬돼 있다는 전제가 핵심이라, 정렬돼 있지 않으면 Set<ID> 기반 dedup(역시 O(n+m))이 더 단순하다.

쉽게 말하면 양쪽 줄이 이미 순서대로 서 있으면, 두 줄의 맨 앞 사람만 번갈아 견줘서 더 앞에 올 쪽을 새 줄로 옮기면 된다. 사람마다 딱 한 번씩만 쳐다보니 두 줄을 합친 인원만큼만 일하면 끝이다. 같은 사람이 양쪽에 다 있으면 한 번만 세우고 두 줄 다 다음으로 넘겨 중복을 지운다. 단 "이미 줄 서 있다"가 전제라, 뒤죽박죽이면 그냥 손목밴드(Set)로 걸러내는 편이 더 간단하다.
아이템이 실시간으로 수정·삭제될 때 병합 로직은 무엇을 더 처리해야 하나?

"처음 본 id만 추가"로는 부족하다. 이미 본 id가 다시 왔을 때 버리는 대신 최신 내용으로 교체(upsert)해야 하고, 이를 위해 id → 값 Dictionary와 순서 배열(또는 OrderedDictionary)을 함께 두는 게 낫다. 삭제 이벤트가 오면 해당 id를 컬렉션에서 실제로 제거해야 하며, 어느 버전이 최신인지 판정할 updatedAt/버전 같은 필드로 out-of-order 갱신을 방어한다. diffable data source라면 이 교체·삭제가 자연스럽게 move/reload/delete 애니메이션으로 반영된다.

쉽게 말하면 새로 온 사람만 챙기면 끝인 게 아니다. 아까 본 사람이 다시 오면 그냥 돌려보내지 말고 최신 정보로 갈아 끼워야 한다(그새 내용이 바뀌었을 수 있으니까). "이 사람 나갔다"는 소식이 오면 목록에서 진짜로 빼고, 어느 소식이 더 최신인지 헷갈릴 때를 대비해 "언제 고쳤는지" 도장(updatedAt)을 보고 더 나중 것만 반영한다. 늦게 도착한 옛날 소식이 방금 받은 새 내용을 덮어쓰지 않게 막으려는 것이다.

출처 · 참고자료