Swift 컬렉션 내부 | Array·Dictionary·Set과 Copy-on-Write
이 글의 핵심
컬렉션은 값 타입이니 매번 통째로 복사된다는 설명은 절반만 맞습니다. 실제로는 버퍼 참조가 하나일 때만 제자리에서 수정되고 공유 중이면 그때 복사되므로, 이를 알아야 불필요한 복사 비용을 가늠할 수 있습니다. NSArray 브리징이 이 예측을 흐트러뜨리는 경우와 [Any]로 타입이 흐려지는 문제도 짚습니다.
#03 | 📋 전체 목차 | 이전: #02 변수와 타입 · 다음: #04 클래스
Array, Dictionary, Set이 무엇이 다른지만 아무리 설명해도, 나중에 “왜 여기서만 느리지?” 하고 절실하게 부딪히는 문제는 대부분 Copy-on-Write와 관련되어 있습니다. 세 타입 모두 겉으로는 값 타입이지만, 구현은 힙에 올라간 저장 버퍼를 참조로 공유하다가 누군가 쓰기를 시도하는 순간에만 “이제 내 것”으로 떼어 내는 방식이기 때문입니다. Array는 순서와 인덱스가 중요한 경우(목록, 큐/스택, 테이블 행)에 쓰고, Set은 순서가 없고 원소가 유일해야 할 때(Hashable만 만족하면 멤버십 검사·태그·중복 제거) 쓰며, Dictionary는 키로 값을 찾는 연관 배열입니다.
Copy-on-Write 함정: NSArray와 실전 사례
실무에서는 NSArray를 쓰지 않는 것을 권합니다. (Swift의 Array를 쓰라는 뜻입니다. 레거시 ObjC API에서 NSArray를 계속 끌고 오면 브리징과 내부 표현이 꼬여서, COW로 예측하던 “지금 공유 중인지, 지금 분리되는지”가 측정 결과와 맞지 않는 구간이 생기기 쉽습니다.) 성능이 중요하다면 순수 Swift Array / Dictionary / Set으로 고정하십시오. NSDictionary도 마찬가지입니다.
구체적으로 무엇이 꼬이는지 보면 이렇습니다. Objective-C API에서 받은 NSArray를 Swift Array로 바꾸면, 변환 시점에 원소를 모두 복사하지 않고 원래 NSArray 저장소를 그대로 감싸 두는 경우가 있습니다(지연 브리징). 이 배열은 첫 수정 순간 네이티브 Swift 버퍼로 전체를 옮겨 담으므로, 코드상으로는 고유한 배열에 append 한 번 한 것뿐인데 O(n) 복사가 발생합니다. 반대 방향인 Swift Array를 NSArray로 넘길 때도, 원소가 클래스 인스턴스라면 거의 비용 없이 감싸지만 Int나 String 같은 값 타입이라면 원소마다 Objective-C 객체로 박싱하는 O(n) 변환이 필요합니다. 두 경우 모두 Instruments에서 보면 원인과 무관한 줄에 비용이 찍혀 COW만 의심하다 시간을 쓰게 됩니다.
COW 비용이 실제로 어디에 찍히는지 예를 들어 보겠습니다. 큰 Int 배열을 var b = a로 나눠 가진 뒤 a는 읽기만 하고 b에만 append를 한 번 하면, 읽는 동안에는 두 변수가 같은 힙 버퍼를 공유하다가 b가 처음 쓰는 순간 버퍼 전체가 복사됩니다. 그래서 Time Profiler나 Allocations에서는 복사 비용이 var b = a 줄이 아니라 그 append 한 줄에 O(n)으로 찍힙니다. “값 타입이라 가볍다”는 말만 믿고 큰 컬렉션을 여러 곳에 넘긴 뒤 각자 수정하면, 수정하는 바로 그 줄에 복사가 붙는다는 것이 COW의 이면입니다. 또한 COW는 여러 스레드가 같은 변수를 동시에 변경(mutation)하는 데이터 레이스를 막아 주지 않으므로, 배타적 접근 규칙과 함께 이해해야 합니다.
컬렉션 생성과 타입 고정
Set의 원소와 Dictionary의 키는 Hashable이어야 합니다. Equatable과 해시 규칙이 한 세트로 일관되게 동작해야 하며, 가변 상태를 키에 넣으면 그 순간부터 집합과 맵이 오동작하기 시작합니다. 규칙은 “a == b이면 a.hashValue == b.hashValue”입니다. ==는 id만 비교하는데 hash(into:)에는 name까지 넣으면, 같은 사용자가 이름만 바뀌어 다른 버킷에 들어가 contains가 false를 돌려줍니다. 참조 타입(클래스)을 키로 쓰고 키로 넣은 뒤 해시에 쓰이는 프로퍼티를 바꾸면 원소가 잘못된 버킷에 남아 영영 찾을 수 없게 됩니다. 컴파일러가 자동 합성하는 Hashable은 모든 저장 프로퍼티를 일관되게 쓰므로, 직접 구현해야 할 이유가 없다면 합성에 맡기는 편이 안전합니다. 타입 추론이 꼬인다면 다음처럼 타입을 명시해 두는 편이 덜 고생스럽습니다.
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) }
Array는 RandomAccessCollection이므로 임의 인덱스 접근은 O(1)로 쓸 수 있습니다. 끝에 붙이는 append는 상각 O(1)이지만, 중간 insert/remove는 뒤쪽 원소를 밀어야 하므로 평균 O(n)입니다. “배열 연산은 모두 O(1)“이라고 착각하지 않도록 주의하십시오.
고차 함수로 변환하기: map, filter, reduce
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: ",")
단계마다 새 시퀀스나 배열이 만들어지는 비용은 감수해야 합니다. 데이터가 크다면 한 번에 도는 for 루프나 lazy를 함께 검토하십시오.
reduce(into:)가 reduce(_:_:)보다 나은 이유는 COW와 직결됩니다. reduce([]) { acc, x in acc + [x] }처럼 누적값을 매번 새로 만들어 반환하는 형태는 반복마다 배열 전체를 복사하므로 전체가 O(n²)이 됩니다. reduce(into:)는 누적값을 inout으로 넘겨 제자리에서 수정하게 하므로, 버퍼가 고유한 한 복사 없이 append됩니다. 다만 위 예제의 acc.insert(x, at: 0)은 앞에 끼워 넣는 연산이라 매번 뒤쪽 원소를 밀어 결국 O(n²)입니다. 역순이 목적이라면 nums.reversed()(뒤집힌 뷰를 O(1)로 반환)나 Array(nums.reversed())가 맞는 도구이고, 이 예제는 reduce(into:) 문법을 보여 주는 용도로만 이해하면 됩니다.
Set과 Dictionary 다루기
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
scores["lee", default: 0] += 3은 default:가 쓰기에도 쓰이는 드문 경우인데, 이 형태는 “키가 없으면 0에서 시작해 3을 더해 저장”을 해시 탐색 한 번으로 처리합니다. scores["lee"] = (scores["lee"] ?? 0) + 3은 같은 결과지만 탐색을 두 번 하고, 값이 배열 같은 큰 타입이면 꺼내는 순간 복사본이 생겨 COW 비용까지 더해집니다. 빈도 세기나 그룹에 원소 추가하기(dict[key, default: []].append(x))에서 이 차이가 크게 나타납니다.
keys와 values는 배열이 아니라 딕셔너리에 대한 뷰이며, 순서가 정해져 있지 않습니다. 같은 딕셔너리라도 원소를 추가·삭제하면 순서가 바뀔 수 있고, Swift는 프로세스마다 해시 시드를 무작위로 정하므로 실행할 때마다 순서가 달라질 수 있습니다. 테스트에서 Array(dict.keys)를 기댓값과 그대로 비교하다가 가끔 실패하는 문제가 이 때문이며, 순서가 필요하면 sorted()로 정렬해서 비교해야 합니다.
순회, flatMap, 슬라이스
for-in과 달리 forEach에서는 break를 쓸 수 없다는 점을 기억하십시오. 클로저 안의 return은 현재 원소 처리만 끝내고 다음 원소로 넘어가므로, continue처럼 동작할 뿐 루프를 멈추지 못합니다. enumerated()는 0부터 센다는 전제를 잊지 말고, Array가 아닌 컬렉션이라면 zip이 더 적합한 경우가 많습니다. enumerated()가 돌려주는 것은 인덱스가 아니라 0부터 세는 순번(offset)입니다. Array에서는 우연히 둘이 같지만, 슬라이스나 String에서는 다르므로 순번을 첨자로 쓰면 엉뚱한 원소를 읽거나 크래시가 납니다. 인덱스와 원소가 함께 필요하면 zip(c.indices, c)가 정확합니다.
compactMap은 nil을 걸러 내고, flatMap은 한 단계 평탄화를 합니다. 예전에는 flatMap이 옵셔널도 처리하던 시절이 있어 이름이 같으므로, 새 코드에서는 시그니처를 꼭 확인하십시오. [[1,2],[3]] 같은 그룹을 펴는 예시는 다음과 같습니다.
let groups = [[1, 2], [3], [4, 5, 6]]
let flat = groups.flatMap { $0 }
슬라이스 xs[1..<3]는 ArraySlice이며, 원소를 복사하지 않고 원본 배열의 버퍼를 공유합니다. 값 의미론은 그대로라서 이후 원본을 수정해도 COW로 분리될 뿐 슬라이스 내용이 바뀌지는 않습니다. 실제 함정은 두 가지입니다. 첫째, 슬라이스는 원본의 인덱스를 그대로 물려받습니다. xs[2...]의 첫 인덱스는 2이므로 slice[0]은 Fatal error: Index out of bounds로 크래시합니다. slice.startIndex나 first를 써야 합니다. 둘째, 작은 슬라이스 하나가 거대한 원본 버퍼 전체를 붙잡고 있습니다. 100만 개짜리 배열에서 앞 10개만 잘라 오래 보관하면 원본 배열이 해제돼도 버퍼는 남아 메모리가 줄지 않습니다. 오래 보관할 슬라이스는 Array(slice)로 스냅샷을 떠서 필요한 만큼만 새로 할당하는 편이 안전합니다. String도 인덱스가 정수가 아닌데, 이는 UTF-8 저장 방식 때문이라기보다 한 “문자”(Character, 확장 문자소 클러스터)가 여러 바이트·여러 유니코드 스칼라로 이루어질 수 있어 n번째 문자를 찾으려면 앞에서부터 세어야 하기 때문입니다. 그래서 str[i]는 제공되지 않고 index(_:offsetBy:)는 O(n)입니다.
Copy-on-Write 내부 동작과 직접 구현
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도 저수준에서 비슷한 판단을 하므로, COW가 “공짜”는 아닙니다.
이 구현에서 중요한 세부 사항이 몇 가지 있습니다. isKnownUniquelyReferenced(&box)가 inout으로 인자를 받는 것은, 검사하는 동안 호출 자체가 임시 강한 참조를 만들어 카운트를 2로 부풀리지 않게 하기 위해서입니다. 그리고 이 함수는 강한 참조만 셉니다. 다른 곳에서 weak나 unowned로 같은 Box를 보고 있으면 “고유하다”고 판단해 제자리에서 수정하므로, 그 약한 참조 쪽에서는 값이 바뀐 것이 보입니다. 또 멀티스레드에서 같은 COWContainer 변수를 두 스레드가 동시에 수정하면, 둘 다 “고유함”을 확인한 뒤 같은 버퍼를 수정하는 데이터 경쟁이 생깁니다. COW는 서로 다른 변수 사이의 복사를 싸게 만드는 기법이지, 한 변수에 대한 동시 접근을 보호하는 장치가 아닙니다. Swift 6의 엄격한 동시성 검사는 이런 공유를 컴파일 단계에서 경고해 줍니다.
표준 라이브러리 수준의 COW 타입을 직접 만들 일은 많지 않습니다. 대부분은 구조체의 저장 프로퍼티를 Array·Dictionary로 두기만 해도 COW가 자동으로 따라옵니다. 직접 구현이 의미 있는 경우는 여러 컬렉션과 메타데이터를 한 덩어리로 묶은 큰 구조체를 자주 복사하면서 드물게만 수정할 때입니다. 이때 전체를 하나의 박스에 넣으면, 필드마다 참조 카운트를 올리던 복사가 박스 참조 하나로 줄어듭니다.
제네릭은 컴파일 타임에 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)이라는 보장이 추가된 프로토콜입니다. Array는 RandomAccessCollection에 해당합니다.
실전 패턴: 그룹핑, 빈도 세기, lazy
실제로 자주 쓰는 코드 조각 몇 가지를 소개합니다. 중복을 제거할 때 Set에 id를 넣어 걸러 내는 패턴, 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·filter의 클로저는 탈출하지 않는(non-escaping) 클로저라 self를 캡처해도 순환 참조가 생기지 않습니다. 순환 참조는 클로저를 프로퍼티에 저장하는 등 탈출하는 클로저가 self를 강하게 잡을 때 생깁니다. 원소를 약한 참조로 담고 싶다면 weak var 프로퍼티를 가진 래퍼 구조체를 만들어야 하며, 배열 전체를 약한 참조로 만드는 표준 문법은 없습니다.
Index out of range부터 Hashable 불일치까지: 트러블슈팅
Index out of range가 발생하면 isEmpty / indices / first부터 확인하십시오. 슬라이스에서 이 에러가 났다면 0부터 시작한다고 가정하지 않았는지 먼저 봅니다. Dictionary 키가 “있는데 nil”이 나온다면 정규화(trim, 대소문자) 문제를 의심하십시오. 유니코드 정규화도 원인이 될 수 있는데, Swift의 String 비교는 정규 등가성을 따르므로 조합형·완성형 한글은 같은 키로 취급되지만, 서버와 주고받는 과정에서 바이트 단위로 비교하는 다른 계층이 끼면 불일치가 생깁니다. Set에서 원소를 찾지 못한다면 Hashable과 ==의 불일치를 의심하십시오. 슬라이스를 오래 보관하는 곳에서 메모리가 예상보다 줄지 않는다면 Array(slice)로 복사하십시오. 성능이 떨어진다면 lazy를 쓰거나 한 루프로 합쳤는지 다시 확인하십시오. 다시 강조하지만, Obj-C 컬렉션이 섞이면 브리징과 복사 측정 결과가 작성한 Swift 코드의 예상과 맞지 않을 수 있습니다.
시리즈 안에서 이어 읽으신다면 #04 클래스, #02, #06 제네릭, iOS·Swift 심화 정도를 추천합니다. Swift 공식 Language Guide는 Sequence / Collection 키워드를 겹쳐서 읽으면 전체 흐름이 연결됩니다.
마무리
컬렉션은 API 자체는 단순하지만 COW, 슬라이스 수명, 브리징은 주의 깊게 봐야 합니다. 공유된 버퍼를 처음 수정하는 순간 O(n) 복사가 일어날 수 있다는 점을 알고 있으면, 프로파일러에 찍힌 뜻밖의 비용을 훨씬 빨리 설명할 수 있습니다.