본문으로 건너뛰기
Previous
Next
Swift 컬렉션 내부 | Array·Dictionary·Set과 Copy-on-Write

Swift 컬렉션 내부 | Array·Dictionary·Set과 Copy-on-Write

Swift 컬렉션 내부 | Array·Dictionary·Set과 Copy-on-Write

이 글의 핵심

표준 라이브러리 컬렉션은 값 타입처럼 보이지만, 내부적으로 힙 버퍼를 참조 공유하고 필요할 때만 복사하는 Copy-on-Write(COW)로 동작합니다. 버퍼 고유성·참조 카운트·코드 예시로 실무에서의 복사 비용을 판단하는 기준을 제시합니다.

#03 | 📋 전체 목차 | 이전: #02 변수와 타입 · 다음: #04 클래스

Array, Dictionary, Set이 뭐가 다르냐고만 백날 설명해봤자, 막상 나중에 “왜 여기서만 느리지?” 할 때 뼈에 사무치는 건 전부 Copy-on-Write 쪽이야. 세 타입 다 겉은 값 타입인데, 구현은 힙에 올라간 저장 버퍼를 참조로 공유하다가, 누군가 쓰기를 시도하는 순간에만 “이제는 내 거”로 떼어 내는 식이거든. Array순서인덱스에 집착하는 편(목록, 큐/스택, 테이블 행), Set순서 없고 유일(Hashable만 맞으면 멤버십·태그·중복 제거), Dictionary키로 값 찾는 연관 배열이고.

솔직히 NSArray 쓰지 마요. (Swift 쪽 Array를 말하는 거. 레거시 ObjC API에서 NSArray만 계속 끌고 오면 브리징·내부 표현이 꼬이고, COW로 예측하던 “지금 공유냐, 지금 갈리냐”가 측정이 안 맞는 구간이 생기기 쉬워.) 성능 민감하면 순수 Swift Array / Dictionary / Set에 고정해. NSDictionary도 마찬가지고.


Copy-on-Write 때문에 퍼포먼스 한바탕 쌓였던 썰을 잠깐. 예전에 파이프라인에서 var b = a로 큰 Int 배열을 나눠 쥐고, A 경로는 읽기만 하고 B 경로만 append 한 번 했는데, Time Profiler에서 append 한 방이 갑자기 뚱뚱해진 거지. “값 복사 아니었나?” 하고 뜯어보니, 읽기만 할 땐 둘 다 같은 힙 버퍼를 암시적으로 공유하다가, B가 쓰기 들어가는 시점에 unshare → 전체 복사가 터진 거였어. Instruments Allocations에 스파이크로 찍히는 패턴이 딱 그거야. “값 타입이라 가볍다”는 멘트만 믿고 대용량 컬렉션을 콕콕 찍어 넘기면, 한 번에 O(n) 복사가 딱 수정하는 그 줄에 붙는다 — 이게 COW의 반대편. 멀티스레드에서 동시 mutation이면 데이터 레이스는 그대로지, 값 타입이었다고 참이 다 해결되는 건 아님. 배타적 접근이랑 같이 머릿속에 같이 박혀 있어야 함.

Set·Dictionary 키는 Hashable이지. Equatable이랑 해시 규칙이 한 세트로 움직여야 하고, 가변 상태를 키에 끼우면 그 순간부터 집합/맵의 뇌절이 시작됨. 추론 꼬이면 이렇게 타입을 박아 두는 게 덜 아픔.

let emptyInts: [Int] = []
let emptyMap: [String: Int] = [:]
let emptySet: Set<String> = []

JSON 풀고 나서 빈 배열이 [Any] 쪽으로 흘러가면 나중에 디버깅만 길어짐 — 경계에선 반환 타입을 죽이지 말고 고정해.

Array 생성은 대충 익혀 둬.

var a = [1, 2, 3]
var b = Array(repeating: 0, count: 10)
var c = Array(1...5) // [1,2,3,4,5]
var d: [String] = .init() // 빈 배열

용량 뻔하면 reserveCapacity재할당 줄이는 편이 낫다.

var buf: [Int] = []
buf.reserveCapacity(1_000)
for i in 0..<1_000 { buf.append(i) }

RandomAccessCollection이라 임의 인덱스 접근은 O(1)에 가깝게 쓰면 됨. 끝에 붙이는 append는 상각 O(1) 쪽, 가운데 insert/remove는 뒤를 밀어서 평균 O(n) — 이걸 “배열은 다 O(1)”로 착각하지 말라는 얘기.

map / filter / reduce는 설명 길게 안 할게. 링 잘 풀다 보면 reduce(into:)가 중간 짓이기 복사를 줄이는 데 괜찮다.

let nums = [1, 2, 3]
let squares = nums.map { $0 * $0 }
let evens = nums.filter { $0 % 2 == 0 }
let sum = nums.reduce(0, +)
let reversed = nums.reduce(into: [Int]()) { acc, x in acc.insert(x, at: 0) }
let result = nums
    .filter { $0 > 0 }
    .map { "\($0)" }
    .joined(separator: ",")

단계마다 새 시퀀스/배열 뜨는 건 감수해야지. 덩어리가 크면 한 번에 도는 forlazy를 같이 봐.

Set — 넣을 때 Hashable이 살아 있어야 하고, remove는 없는 걸 없애면 nil 돌아오는 패턴이랑 잘 맞고.

var s: Set = [1, 2, 3, 2] // {1,2,3}
s.insert(4)
s.remove(10) // nil
let a: Set = [1, 2, 3]
let b: Set = [3, 4, 5]
a.union(b)
a.intersection(b)
a.symmetricDifference(b)
a.isSubset(of: b)

순서가 필요하면 sorted()배열로 뺀 다음에 UI에 갖다 붙이는 게 맞다 — Set이 순서를 약속하지 않는다는 건 타협이 아니라 스펙이야.

Dictionary — 첨자는 Value?고, default:는 “없는 키 읽기”에 기본값 주는 용도지, 쓰기 의도랑은 헷갈리지 말고.

var scores = ["kim": 10, "lee": 20]
scores["park"] = 15
let x = scores["kim"]        // Int?
let y = scores["unknown", default: 0]
scores.merge(["kim": 1, "cho": 5]) { old, new in old + new }
scores["lee", default: 0] += 3
let keys = scores.keys
let values = scores.values

for-in이랑 forEachbreak가 없다는 것만 머릿속에. enumerated()는 0부터 돈다는 전제 잊지 말고, Array가 아닌 컬렉션이면 zip이 더 말이 되는 경우가 많다.

compactMapnil 떨구고, flatMap한 단계 평탄화 — 옛날 flatMap이 옵셔널도 먹던 시절이랑 이름은 같아서 새 코드는 시그니처 꼭 확인해. 그룹 [[1,2],[3]] 펴기 따위는.

let groups = [[1, 2], [3], [4, 5, 6]]
let flat = groups.flatMap { $0 }

슬라이스 xs[1..<3]ArraySlice고, 원본 배열 메모리랑 엮일 수 있어. 원본이 줄거나 재할당될 수 있는 시나리오랑 겹치면 Array(slice)스냅샷 떠 두는 쪽이 덜 쓰리다. String은 UTF-8이라 인덱스 = 정수가 아니라는 건 그냥 뼈때리게 외워.

COW의 뼈대는 이렇다: 읽기는 공유해도 괜찮다고 보고, 쓰기 직전에 “이 버퍼 나만 쓰냐?” — 아니면 복제하고 고친다, 맞으면 제자리로 고친다. Dictionary/Set도 큰 원리는 같고, 버킷·해시 테이블 디테일만 다름.

직접 COW 래핑할 때 쓰는 박스 패턴은 아직도 isKnownUniquelyReferenced로 버티는 편이 정석에 가깝다.

final class Box<T> {
    var value: T
    init(_ value: T) { self.value = value }
}

struct COWContainer {
    private var box: Box<[Int]>

    init(_ elements: [Int]) {
        box = Box(elements)
    }

    mutating func append(_ x: Int) {
        if !isKnownUniquelyReferenced(&box) {
            box = Box(box.value)
        }
        box.value.append(x)
    }
}

unowned가 끼어 있거나 참조 꼬이면 해석이 달라질 수 있으니, 백킹 클래스는 final단순하게. 표준 Array저수준에서 비슷한 판단을 함 — “공짜”가 아님.

제네릭은 컴파일 타임에 Element: Equatable 같은 걸 걸어두고 쓰면, 런타임에 contains 뜯다가 터지는 것보다 낫다.

func firstPair<T: Collection>(_ c: T) -> (T.Element, T.Element)? {
    guard c.count >= 2 else { return nil }
    let i = c.index(c.startIndex, offsetBy: 1)
    return (c[c.startIndex], c[i])
}

AnySequence 같은 소거 래퍼는 공개 API에서 꼭 필요할 때만 — 성능·가독성 둘 다 깎임.

Sequence는 한 방향 순회, Collection은 인덱스 규약, RandomAccessCollection은 인덱스 산술 O(1) 쪽 보장이 추가되는 옷이야. ArrayRandomAccessCollection.

실제로 써 먹는 조각 몇 개 — 중복 뺄 때 Setid를 굴리는 패턴, Dictionary(grouping:)으로 그룹핑, reduce(into:)로 빈도 세기.

struct User: Hashable { let id: Int; let name: String }
let users = [User(id:1,name:"a"), User(id:2,name:"b"), User(id:1,name:"c")]
var seen = Set<Int>()
let unique = users.filter { seen.insert($0.id).inserted }
let items = [("fruit","apple"), ("fruit","banana"), ("veg","carrot")]
let grouped = Dictionary(grouping: items, by: { $0.0 })
let words = ["a", "b", "a", "a", "c"]
let counts = words.reduce(into: [String: Int]()) { acc, w in
    acc[w, default: 0] += 1
}

zip은 짧은 쪽에 맞춰 잘리고, stride는 등간격, lazy한 번 도는 파이프라인에 맞는 약 — 여러 번 순회하면 “지연이 절약이니까?”가 뒤집힐 수 있음.

let big = Array(1...1_000_000)
let sum = big.lazy.filter { $0 % 2 == 0 }.map { $0 * 2 }.reduce(0, +)

Array클래스를 넣으면 배열은 값이어도 원소는 참조라, 공유 상태는 그대로 올라감. map 클로저가 self강하게 잡는 건 레퍼 이슈로 잘 터지지 — weak 쪽은 타입 설계랑 같이 봐야지, 배열 전체를 약하게 만드는 표준 문법은 없다.

Index out of range 뜨면 isEmpty / indices / first 쪽 먼저 보면 됨. Dictionary 키가 “있는데 nil”이면 정규화(trim, 대소문자) 의심. Set에서 못 찾으면 Hashable이랑 == 불일치 의심. 슬라이스 쓰고 나서 원본이 바꼈는데 이상해지면 Array(slice). 성능 훅 가면 lazy한 루프합쳤는지 다시 봐. 다시 강조: Obj-C 컬렉션이 섞이면 브리징·복사 측정이 당신이 쓴 Swift랑 안 맞을 수 있음.

시리즈 안에서 이어 읽을 거면 #04 클래스, #02, iOS·Swift 심화, C++ 복사 알고리즘 쯤. Swift 공식 Language GuideSequence / Collection 키워드로 겹어서 읽으면 전체가 연결돼.

맺으면: 컬렉션은 API는 단순한데, COW·슬라이스 수명·브리징에서만큼은 눈이 번쩍해야 하고, 수정 시점고유 버퍼가 필요해질 수 있다는 전제 — 그때 O(n) 복사 — 를 머릿속에 넣고 다니면, “왜 Copy-on-Write 때문에 퍼포먼스가?” 하는 날이 많이 줄어든다. 그리고 NSArray는… 위에서 말했잖아.