스위프트는 힙에 할당된 저장소를 감싸는 값 타입에 대해 **복사-쓰기(Copy-on-Write, COW)**라는 최적화 전략을 사용합니다. 즉, 할당 시점에 즉시 깊은 복사를 수행하는 대신, 인스턴스가 실제로 수정될 때까지 복제를 지연시킵니다. 이는 값 타입이 내부적으로 공유되는 백킹 클래스 인스턴스를 참조하도록 하고, isKnownUniquelyReferenced 런타임 함수를 사용하여 참조 카운트가 하나일 때를 감지하여 구현됩니다. 수정이 일어날 때 참조가 고유하면 버퍼를 제자리에서 수정하고, 그렇지 않으면 쓰기 전에 복사를 생성하여 값 의미론을 유지하면서 성능 저하 없이 조치를 취합니다.
우리 팀은 커다란 CVPixelBuffer 백킹 저장소를 감싸는 커스텀 Image 구조체를 정의하여 고성능 이미지 처리 파이프라인을 구축하고 있었습니다. 프로파일링 중에 발생한 문제는 각 필터 적용마다 4K 이미지의 세 개의 중간 복사가 생성되어 프레임당 300MB의 할당을 유발하고 iPad 기기에서 메모리 경고를 발생시켰습니다.
우리는 이 병목 현상을 해결하기 위해 세 가지 접근 방식을 고려했습니다. 첫 번째 접근 방식은 Image를 구조체에서 클래스로 변환하는 것이었습니다. 이를 통해 참조 의미론을 사용하여 복사를 완전히 제거했지만, 여러 처리 체인이 우연히 동일한 픽셀 데이터를 동시에 공유하고 수정할 때 심각한 스레드 안전성 버그가 발생하여 시각적 아티팩트와 디버깅하기 어려운 경쟁 조건을 초래했습니다.
두 번째 접근 방식은 구조체 지정을 유지하면서 UnsafeMutablePointer와 memcpy 최적화를 사용해 수동 깊은 복사를 구현했습니다. 이를 통해 엄격한 값 의미론을 통한 안전성을 보장했지만, 프로파일링에서는 모든 함수 인자가 12MB 메모리 할당 및 비트 연산 복사를 유발하여 목표보다 800% 더 많은 CPU 시간을 소모하는 것으로 나타났습니다.
세 번째 접근 방식은 수동으로 복사-쓰기(COW) 의미론을 구현했습니다. 우리는 실제 CVPixelBuffer를 보유하기 위해 비공식 ImageBuffer 클래스를 생성하고, Image 구조체가 이 클래스를 참조하도록 만들고, 모든 변형 메서드가 수정 이전에 isKnownUniquelyReferenced를 검사하도록 구현했습니다:
final class ImageBuffer { var pixels: CVPixelBuffer init(_ buffer: CVPixelBuffer) { self.pixels = buffer } } struct Image { private var buffer: ImageBuffer mutating func applyFilter(_ filter: Filter) { if !isKnownUniquelyReferenced(&buffer) { buffer = ImageBuffer(buffer.pixels.deepCopy()) } filter.process(buffer.pixels) } }
참조가 고유하지 않으면 먼저 버퍼를 복사했습니다. 우리는 이 솔루션을 선택한 이유는 읽기 전용 작업 중에 불필요한 복사를 제거하면서 스위프트의 값 의미론 안전성을 유지했기 때문입니다.
결과적으로 메모리 압력이 94% 감소하고 이미지당 프레임 처리 시간이 120ms에서 18ms로 개선되었으며, 이를 통해 앱이 구형 하드웨어에서도 열 제약 없이 실시간 비디오 스트림을 처리할 수 있었습니다.
우리가 수동으로 참조 카운트를 확인할 수 없는 이유는 무엇인가요?
많은 후보자들이 수동으로 참조 카운트를 추적하거나 메모리 주소를 비교할 것을 제안합니다. 그러나 isKnownUniquelyReferenced는 단순한 카운트 체크가 아니라, 메모리 작업의 재정렬을 방지하는 컴파일러 삽입 장벽이 포함되어 있습니다. 이 본질이 없다면, 컴파일러는 고유성 검사를 최적화하여 제거하거나, 런타임은 Objective-C 런타임 상호작용 또는 추가적인 소유되지 않은 참조를 유지하는 변환으로 인해 잘못된 긍정을 반환할 수 있습니다.
COW가 스위프트의 배타성 집행과 어떻게 상호작용하나요?
후보자들은 종종 COW가 클래스를 포함하는 모든 값 타입에 대해 자동으로 작동한다고 믿습니다. 그러나 스위프트의 배타성 규칙은 변형이 독점적인 접근을 요구한다는 점을 간과하고 있습니다. 사용자 정의 COW를 구현할 때는 변형이 시작되기 전에 isKnownUniquelyReferenced 검사가 발생해야 하며, 버퍼 교체는 검사와 관련하여 원자적으로 발생해야 합니다. 검사를 하는 동안 여러 참조를 유지하면 런타임에서 배타성 위반을 유발하거나 고유성 감지에서 잘못된 부정이 발생할 수 있습니다.
COW가 동시 컨텍스트에서 복제를 방지하지 못하는 경우는 언제인가요?
Swift 5.5의 동시성 모델에서 후보자들은 COW가 스레드 안전한 변형을 제공한다고 가정합니다. 그러나 COW는 단일 스레드 내에서만 안전성을 보장합니다. 액터 경계를 넘어 값을 전달하거나 Sendable로 표시할 때, 컴파일러는 격리를 유지하기 위해 즉각적인 복사를 강제할 수 있습니다. 또한, 백킹 클래스가 Objective-C 객체를 포함하는 경우, isKnownUniquelyReferenced는 Objective-C의 약한 참조 구현으로 인해 보수적으로 잘못된 값을 반환하여 불필요한 복사를 유발하고 소유권 모델 재구성을 요구할 수 있습니다.