[2026] Swift 컬렉션 내부 | Array·Dictionary·Set과 Copy-on-Write(COW)
이 글의 핵심
표준 라이브러리 컬렉션은 값 타입처럼 보이지만, 내부적으로 힙 버퍼를 참조 공유하고 필요할 때만 복사하는 Copy-on-Write(COW)로 동작합니다. 버퍼 고유성·참조 카운트·코드 예시로 실무에서의 복사 비용을 판단하는 기준을 제시합니다.
들어가며
Array, Dictionary, Set은 모두 구조체로 정의된 값 타입입니다. 그러나 “값이므로 항상 스택에 복사된다”는 설명은 과도한 단순화입니다. 실제로는 힙에 올라간 저장 버퍼를 여러 변수가 참조로 공유하다가, 돌연변이(mutation)가 필요해지는 순간에만 버퍼를 복사하는 Copy-on-Write(COW) 패턴이 핵심입니다. 이 글은 그 메커니즘이 왜 필요한지, 언제 복사가 일어나는지, 직접 최적화할 때 무엇을 볼지를 정리합니다.
1. 값 타입인데 왜 “참조” 이야기가 나오나
값 타입의 의미론(semantics)은 “대입·전달 시 논리적으로 복사”입니다. 구현은 성능을 위해 지연 복사를 할 수 있고, Swift 표준 컬렉션은 그 대표입니다. 사용자가 var b = a처럼 복사본을 갖는 것처럼 보이지만, 읽기만 하면 두 변수는 같은 내부 버퍼를 가리킬 수 있습니다. 한쪽이 수정되려 할 때 런타임이 버퍼가 공유 중인지 확인하고, 공유 중이면 새 버퍼로 복사한 뒤 수정합니다.
이 구조 덕분에 불필요한 O(n) 복사를 피하면서도, 수정 시에는 복사본이 분리되어 값 의미론을 유지합니다.
2. COW의 동작을 한 줄로 요약하면
- 읽기: 여러
Array값이 동일 버퍼를 공유해도 무방(논리적 불변성 유지). - 쓰기 직전: 버퍼가 다른 곳과 공유 중이면 복사(CoW) 후 수정; 유일하면 제자리(in-place) 수정.
Dictionary와 Set도 동일한 원칙이 적용되며, 구현 세부(버킷·해시 테이블)는 타입마다 다르지만 “공유 참조 + 쓰기 시 고유화”라는 틀은 같습니다.
3. isKnownUniquelyReferenced와 직접 COW
표준 라이브러리 외에 직접 COW 버퍼를 설계할 때, 클래스(참조 타입)로 백킹 스토리지를 두고 구조체가 그것을 들고 가는 패턴이 흔합니다. 쓰기 전에 “나만 이 클래스를 가리키는가?”를 확인하려면 다음이 사용됩니다.
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)
}
}
설명: isKnownUniquelyReferenced는 강한 참조가 정확히 하나일 때만 true를 반환합니다. 공유 중이면 새 Box를 만들어 물리적 복사를 수행하고, 유일하면 기존 버퍼에 바로 추가합니다. 표준 Array의 내부도 이런 판단을 저수준에서 수행합니다(표현은 타입·최적화에 따라 다름).
주의: unowned나 순환 참조가 끼면 카운트 해석이 달라질 수 있어, 백킹 클래스는 final로 두고 참조 관계를 단순하게 유지하는 것이 안전합니다.
4. 언제 비용이 커지나
- 대용량 배열을 여러 스코프에 전달한 뒤, 한쪽만
append하면 그 순간 큰 복사가 발생할 수 있습니다. - 다중 스레드에서 같은 버퍼를 동시에 돌연변이하려 하면 값 타입이라도 데이터 레이스는 여전히 UB입니다. Swift의 배타적 접근(exclusivity) 규칙과 함께 이해해야 합니다.
- Objective-C
NSArray와 브리징된 경우, 내부 표현이 달라지거나 추가 복사·브리징 비용이 붙을 수 있습니다. 성능이 민감하면 순수 SwiftArray를 유지하는 편이 예측 가능합니다.
5. 미세 벤치마크에 앞서
실무에서는 추측보다 측정이 우선입니다. Instruments의 Allocations, Time Profiler, 때로는 copy-on-write가 의심되는 구간만 따로 마이크로 벤치마크하는 방식이 일반적입니다. “값 타입이라 가볍다”는 문장만 믿고 대용량 컬렉션을 무분별하게 복제·전달하면, COW가 숨겨 주는 복사 비용이 한 번에 드러나는 지점이 생깁니다.
내부 동작과 핵심 메커니즘
이 글의 주제는 「[2026] Swift 컬렉션 내부 | Array·Dictionary·Set과 Copy-on-Write(COW)」입니다. 여기서는 앞선 설명을 구현·런타임 관점에서 한 번 더 압축합니다. 구성 요소 간 책임 분리와 관측 가능한 지점을 기준으로 생각하면, “입력이 어디서 검증되고, 핵심 연산이 어디서 일어나며, 부작용(I/O·네트워크·디스크)이 어디서 터지는가”가 한눈에 드러납니다.
처리 파이프라인(개념도)
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
알고리즘·프로토콜 관점에서의 체크포인트
- 불변 조건(Invariant): 각 단계가 만족해야 하는 조건(예: 버퍼 경계, 프로토콜 상태, 트랜잭션 격리)을 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 동일 입력에 동일 출력이 보장되는 순수한 층과, 시간·네트워크에 의해 달라질 수 있는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화/역직렬화, 문자 인코딩, syscall 횟수, 락 경합처럼 “한 번의 호출이 아니라 누적되는 비용”을 의심 목록에 넣습니다.
프로덕션 운영 패턴
실서비스에서는 기능 구현과 함께 관측·배포·보안·비용이 동시에 요구됩니다. 아래는 팀에서 자주 쓰는 최소 체크리스트입니다.
| 영역 | 운영 관점에서의 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율/지연 분위수, 주요 의존성 타임아웃이 보이는가 |
| 안전성 | 입력 검증·권한·비밀 관리가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등한 연산에만 적용되는가, 서킷 브레이커·백오프가 있는가 |
| 성능 | 캐시 계층·배치 크기·풀링·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리, 마이그레이션 호환성이 문서화되어 있는가 |
운영 환경에서는 “개발자 PC에서는 재현되지 않던 문제”가 시간·부하·데이터 크기 때문에 드러납니다. 따라서 스테이징의 데이터 양·네트워크 지연을 가능한 한 현실에 가깝게 맞추는 것이 중요합니다.
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스 컨디션, 타임아웃, 외부 의존성 불안정 | 최소 재현 스크립트 작성, 분산 트레이스·로그 상관관계 확인 |
| 성능 저하 | N+1 쿼리, 동기 I/O, 잠금 경합, 과도한 직렬화 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 클로저/이벤트 구독 누수, 대용량 객체의 불필요한 복사 | 상한·TTL·스냅샷 비교(힙 덤프/트레이스) |
| 빌드·배포만 실패 | 환경 변수·권한·플랫폼 차이 | CI 로그와 로컬 diff, 컨테이너/런타임 버전 핀(pin) |
권장 디버깅 순서: (1) 최소 재현 만들기 (2) 최근 변경 범위 좁히기 (3) 의존성·환경 변수 차이 확인 (4) 관측 데이터로 가설 검증 (5) 수정 후 회귀·부하 테스트.
정리
- 표준 컬렉션은 값 의미론을 유지하면서, 내부적으로 COW로 복사 비용을 지연합니다.
- 수정 시점에만 고유 버퍼가 필요하며, 그때 O(n)급 복사가 가능하다는 점을 염두에 두어야 합니다.
- 직접 COW 타입을 만들 때는
isKnownUniquelyReferenced로 고유성을 검사하는 패턴이 정석에 가깝습니다.
관련 글
- Swift 클래스와 구조체 | Class, Struct, Enum
- Swift 변수와 타입 | var, let, 옵셔널
- iOS·Swift 내부 구조 심화 — ARC, 증인 테이블, UIKit/SwiftUI