[2026] Swift 컬렉션 내부 | Array·Dictionary·Set과 Copy-on-Write(COW)

[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의 동작을 한 줄로 요약하면

  1. 읽기: 여러 Array 값이 동일 버퍼를 공유해도 무방(논리적 불변성 유지).
  2. 쓰기 직전: 버퍼가 다른 곳과 공유 중이면 복사(CoW) 후 수정; 유일하면 제자리(in-place) 수정.

DictionarySet도 동일한 원칙이 적용되며, 구현 세부(버킷·해시 테이블)는 타입마다 다르지만 “공유 참조 + 쓰기 시 고유화”라는 틀은 같습니다.


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와 브리징된 경우, 내부 표현이 달라지거나 추가 복사·브리징 비용이 붙을 수 있습니다. 성능이 민감하면 순수 Swift Array를 유지하는 편이 예측 가능합니다.

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고유성을 검사하는 패턴이 정석에 가깝습니다.

관련 글