질문에 대한 답변.
Objective-C는 수동 참조 유지/해제 주기와 약한 참조를 위한 직접 포인터에 의존하였으며, 이는 모든 객체 접근 시 상당한 성능 패널티가 발생하는 런타임 스위즐링 또는 전역 해시 테이블을 필요로 하였습니다. Apple이 Swift를 설계할 때, 약한 참조가 해제되었을 때 자동으로 nil이 되는 제로잉 약한 참조를 지원하는 자동 메모리 관리 모델이 필요했습니다. 이는 약한 참조를 경험하지 않는 대다수의 객체에 부담을 주지 않아야 하는 상황에서 발생했습니다. 이 필요성은 약한 참조 메타데이터를 필요할 때만 외부화하는 사이드 테이블 아키텍처의 개발로 이어졌습니다.
중심 문제는 메모리 효율성과 안전성을 균형 있게 맞추는 것이었습니다. 모든 객체 헤더가 약한 참조 추적을 위한 인라인 저장소(예: 약한 포인터의 연결 리스트 또는 인라인 약한 카운트)를 포함하면, 모든 클래스 인스턴스의 메모리 발자국이 크게 증가하여 강한 참조만 사용하는 성능 중심 코드에 페널티를 줄 수 있습니다. 반대로, 약한 참조를 객체 주소로 키가 설정된 전역 해시 테이블에 저장하면 동기화 병목 현상과 객체가 해제될 때 복잡한 회수 논리가 발생합니다. 마지막 강한 참조가 사라질 때 스레드 안전한 원자적 제로잉을 보장하면서 약한 참조가 없는 객체에는 제로 비용을 부과하는 메커니즘을 만드는 것이 도전이었습니다.
Swift는 각 클래스 인스턴스 헤더에 별도의 힙 할당된 사이드 테이블 구조체에 대한 nullable 포인터를 포함하는 사이드 테이블 시스템을 사용합니다. 이 사이드 테이블은 약한 참조 수와 객체에 대한 백 포인터를 저장하며, 약한 참조는 사실상 객체가 아닌 이 사이드 테이블을 가리킵니다. 강한 참조 수가 0에 도달하면, 런타임은 사이드 테이블 내의 객체 포인터를 원자적으로 nil로 설정하여 모든 기존 약한 참조가 다음 접근 시 nil을 관찰하게 됩니다. 객체의 메모리는 약한 참조 수가 0에 도달할 때까지 할당된 상태로 남아 있으며, 이 시점에 사이드 테이블과 객체 메모리가 회수됩니다.
실생활의 상황
소셜 미디어 애플리케이션을 위해 고해상도 이미지 파이프라인을 개발한다고 상상해 보십시오. 여기서 ViewController 인스턴스는 사용자 아바타를 다운로드하여 표시합니다. 중복 네트워크 요청을 방지하기 위해 다운로드된 UIImage 객체에 대한 참조를 저장하는 ImageCache 싱글톤을 구현합니다. 이로 인해 동일한 아바타를 표시하는 여러 뷰 컨트롤러가 기본 메모리 버퍼를 공유할 수 있습니다.
고려했던 한 가지 접근 방식은 무작위 제거 정책이 있는 NSCache에 강한 참조를 저장하는 것이었습니다. 이는 즉각적인 접근과 유형 안전성을 보장했지만, 캐시가 모든 이미지를 무기한 유지하여 심각한 메모리 누수를 초래하고, 결국 긴 스크롤 세션 동안 메모리 경고와 앱 종료를 촉발하는 문제를 야기했습니다. 장점으로는 단순성과 빠른 접근이 있었지만, 무제한 메모리 증가의 단점으로 인해 생산에서는 적합하지 않았습니다.
또 다른 고려된 접근 방식은 수동 관찰자 패턴을 구현하여 뷰 컨트롤러가 할당 해제 시 캐시를 알리고 특정 항목을 삭제하는 방법이었습니다. 이론적으로 누수를 방지할 수 있었지만, 이는 뷰 계층과 캐싱 계층 간의 견고한 긴밀한 결합을 초래했으며, 빠른 탐색 전환 동안 레이스 조건을 처리하기 위한 방대한 보일러플레이트가 필요했습니다. 이로 인해 알림 메시지를 놓치거나 지연될 경우 충돌 위험이 있었습니다.
선택된 솔루션은 캐시 구현 내에서 Swift의 기본 약한 참조를 활용하였습니다:
class ImageCache { private var cache: [URL: WeakBox<UIImage>] = [:] func image(for url: URL) -> UIImage? { return cache[url]?.value } func setImage(_ image: UIImage, for url: URL) { cache[url] = WeakBox(value: image) } } final class WeakBox<T: AnyObject> { weak var value: T? init(value: T) { self.value = value } }
캐시 딕셔너리의 값을 WeakBox 래퍼를 통해 약하게 선언함으로써, ImageCache는 이미지가 여전히 메모리에 존재하는지를 확인할 수 있었고, 활성 뷰 컨트롤러가 그 아바타를 표시하지 않을 경우 자동으로 회수할 수 있었습니다. 이는 메모리 누수와 수동 부기 오버헤드를 모두 제거하여 피드를 빠르게 스크롤할 때 최대 메모리 사용량을 40% 줄이고 시스템의 메모리 감시자가 종료하는 것을 방지했습니다.
후보자들이 종종 놓치는 점
왜 약한 참조에 접근하는 것이 강한 참조에 접근하는 것보다 느릴 수 있으며, 이러한 성능 차이가 측정 가능해지는 특정 조건은 무엇인가요?
약한 참조에 접근하려면 객체 헤더에 저장된 사이드 테이블 포인터의 역참조가 필요하고, 그 다음 사이드 테이블에서 객체 포인터를 원자적으로 로드하여 제로화되었는지를 확인해야 합니다. 오버헤드는 최소하지만(일반적으로 추가적인 간접 참조가 하나 발생), 수천 개의 항목이 있는 대규모 컬렉션을 반복하여 모든 요소에 약한 참조를 통해 접근할 때 측정 가능해집니다. 반면 강한 참조는 원자적 보장이 필요 없이 단일 포인터 추적만으로 접근하면 됩니다.
구현 수준에서 소유되지 않은 참조(unowned reference)와 약한 참조(weak reference)의 차이는 무엇이며, 객체의 할당 해제 후 소유되지 않은 참조에 접근하려고 하면 nil이 아닌 런타임 크래시가 발생하는 이유는 무엇인가요?
약한 참조는 제로화를 가능하게 하기 위해 사이드 테이블을 활용하는 반면, 소유되지 않은 참조(기본 안전 모드에서는)도 사이드 테이블을 참조하지만 객체가 소유되지 않은 참조가 존재하는 한 할당되어 있을 것으로 가정합니다. 따라서 객체가 할당 해제되면 크래시가 발생합니다. 왜냐하면 사이드 테이블 항목이 파괴로 표시되지만 nil로 설정되지 않기 때문입니다. 후보자들은 종종 안전성이 없는 소유되지 않은 참조가 사이드 테이블을 전혀 우회하여, 할당 해제 후 접근할 경우 메모리를 손상시키는 다 dangling C 포인터처럼 동작한다는 점을 놓칩니다. 반면 안전한 소유되지 않은 참조는 적어도 사이드 테이블의 할당 해제 비트에 의해 결정론적으로 트랩됩니다.
객체 인스턴스의 메모리가 해제 완료 후 강한 참조가 모두 사라진 경우에도 힙에 남아 있는 이유는 무엇이며, 이 메모리는 실제로 언제 해제되나요?
메모리는 사이드 테이블이 약한 참조 수를 유지하고 있기 때문에 지속됩니다. 객체 헤더와 그와 관련된 저장소는 약한 수가 0에 도달할 때까지 회수될 수 없습니다. 이는 약한 참조가 결코 재활용된 메모리를 가리키지 않도록 보장합니다. 마지막 약한 참조가 파괴되어 약한 수가 0이 된 후에야 런타임은 사이드 테이블과 객체의 메모리 영역을 해제하며, 이는 개발자에게는 보이지 않지만 use-after-free 취약점을 방지하는 데 중요합니다.