Swift프로그래밍iOS 개발자

클로저를 인스턴스 프로퍼티에 할당할 때 어떤 특정한 참조 카운팅 조건이 유지 주기를 생성하며, 캡처 리스트가 이 문제를 해결하기 위해 ARC 의미론을 어떻게 변경하는가?

Hintsage AI 어시스턴트로 면접 통과

답변

질문의 배경

Swift가 **자동 참조 카운팅(ARC)**을 도입하기 전, 개발자들은 retain, releaseautorelease 호출로 메모리를 수동으로 관리했으며, 이는 잦은 메모리 누수나 덕지덕지의 포인터를 초래했습니다. SwiftARC는 컴파일 타임에 retain/release 호출을 삽입하여 자동화하지만, 주변 변수를 캡처하는 참조 타입인 클로저로 인해 미세한 복잡성을 도입했습니다. 이로 인해 Swift에 특정한 새로운 메모리 문제 클래스가 생겼으며, 두 개의 참조 타입이 분해할 수 없는 순환 의존성을 형성하게 되었고, 이러한 캡처 의미론을 명시적으로 제어하기 위해 도입된 캡처 리스트 문법이 필요하게 되었습니다.

문제

클래스 인스턴스가 클로저를 프로퍼티로 저장하고, 해당 클로저가 self 또는 다른 인스턴스 프로퍼티를 참조하는 경우, ARC는 클로저의 생애 동안 인스턴스를 유지하기 위해 인스턴스의 참조 카운트를 증가시킵니다. 인스턴스가 클로저를 강하게 참조하므로 유지 주기가 발생하게 됩니다: 인스턴스는 클로저를 강하게 유지하고, 클로저는 인스턴스를 강하게 유지합니다. 두 참조 카운트가 0에 도달하지 않기 때문에 deinit이 실행되지 않으며, 애플리케이션의 생애 동안 메모리 누수가 발생합니다.

해결책

Swift는 기본 캡처 행동을 수정하기 위해 클로저의 매개변수 목록 앞에 있는 대괄호 안에 쉼표로 구분된 표현인 캡처 리스트를 제공합니다. [weak self]를 지정하면 약한 참조(옵셔널, 해제될 때 nil)가 생성되며, [unowned self]는 소유하지 않는 참조(존재를 가정하며, 해제 후 접근 시 충돌 발생)를 생성합니다. 값을 위해서는 [x = x]를 사용하여 현재 값을 캡처합니다. 이는 강한 참조 순환을 명시적으로 깨뜨려, 외부 참조가 제거되면 ARC가 인스턴스를 해제할 수 있도록 합니다.

코드 예시:

class DataManager { var completionHandler: ((Data) -> Void)? var data: Data = Data() func fetchData() { // 유지 주기: self가 클로저를 보유, 클로저가 self를 보유 completionHandler = { newData in self.data = newData // self의 강한 캡처 } } func fetchDataFixed() { // 해결책: 약한 캡처 completionHandler = { [weak self] newData in guard let self = self else { return } self.data = newData } } deinit { print("DataManager가 해제되었습니다") } }

실제 상황

생산 iOS 애플리케이션에서 우리는 비동기적으로 프로필 데이터를 검색하기 위해 UserService 클래스를 사용하는 ProfileViewController를 구현했습니다. 이 서비스는 비동기 API로 작업을 지원하기 위해 프로퍼티에 저장된 클로저 기반의 완료 핸들러를 노출했습니다. 프로필 화면에서 벗어나는 것을 관찰했을 때 ViewController의 deinit이 호출되지 않으며, Instruments는 뷰 계층 구조를 유지하는 메모리 그래프 객체를 보고했습니다.

이 누수를 해결하기 위해 몇 가지 아키텍처 접근을 고려했습니다.

사용자가 프로파일 화면으로 돌아갈 때 이 사이클을 깨는 기술적으로 가능한 방법으로 viewWillDisappear에서 완료 핸들러를 명시적으로 nil로 설정해 보았습니다. 그러나 이는 갑작스러운 종료나 예기치 않은 상태 전환에 대해 신뢰할 수 없었습니다. 또한 클로저가 호출되지 않았고 뷰 컨트롤러가 사라지는 사건 전 시스템이 메모리 압박으로 해제되었을 때 여전히 누수가 발생했습니다. 이 방법은 과도한 방어적 프로그래밍을 필요로 했으며 뷰 컨트롤러가 서비스의 내부 상태를 관리하도록 강요함으로써 단일 책임 원칙을 위반했습니다.

우리는 클로저 내에서 [unowned self]를 사용하여 옵셔널 언래핑의 오버헤드를 피하는 방법을 평가했습니다. 이는 구문적 깨끗함과 제로 비용 추상화 이점을 제공했습니다. 그러나 테스트 중에 우리는 급속한 탐색으로 인해 ViewController가 네트워크 요청이 진행 중일 때 해제될 수 있는 경합 조건을 발견했습니다. 이는 콜백이 해제된 인스턴스에 접근하려 할 때 충돌을 초래했습니다. 생산에서 정의되지 않은 행동의 위험이 성능 이점을 능가했습니다.

우리는 클로저의 진입 지점에서 guard let self = self else { return } 검사를 결합한 [weak self] 방식을 구현했습니다. 이는 모든 생명 주기 시나리오를 안전하게 처리했습니다: 콜백이 실행되기 전에 뷰 컨트롤러가 해제되면 약한 참조가 nil이 되고, 가드는 조용히 실패하며, ARC는 이후 클로저를 정리합니다. 약간의 보일러플레이트 코드가 필요했지만 메모리 안전성과 충돌 없는 작업을 보장했습니다.

우리는 코드베이스 전반에 걸쳐 약한 캡처 방식을 보편적으로 채택했습니다. UserService 통합을 [weak self]를 사용하도록 리팩토링한 후, 메모리 그래프 디버깅을 통해 ProfileViewController 인스턴스가 즉시 해제되는 것을 확인했습니다. Xcode의 메모리 그래프 디버거는 클로저에서 남은 강한 참조가 없음을 보여주었고, Instruments의 누수 감지는 기능에서 누수가 0이라고 보고했습니다. 이 패턴은 모든 클로저 기반 비동기 API의 표준이 되었습니다.

후보자들이 자주 놓치는 점

클로저에서 구조체 인스턴스를 캡처하는 것과 클래스 인스턴스를 캡처하는 것의 차이점은 무엇이며, 구조체가 유지 주기를 생성할 수 없는 이유는 무엇인가?

많은 후보자들은 클로저에서 self를 캡처하는 것이 항상 유지 주기의 위험이 있다고 잘못 가정합니다. 구조체Swift에서 값 타입으로 복사되며 참조되지 않습니다. 클로저가 구조체를 캡처할 때, ARC는 구조체의 값을 클로저의 캡처 리스트로 복사(또는 최적화에 따라 불변 복사를 참조)하지만, 중요한 것은 구조체는 참조 카운트를 가지지 않는다는 것입니다. 클로저가 값을 보유하고 있기 때문에, 클로저와 원래 구조체 인스턴스 간에 순환 참조가 발생할 가능성은 없습니다.

위험은 오로지 self가 클래스(참조 타입)를 참조할 때만 존재하며, 클로저가 힙 객체에 대한 포인터를 저장하고 그 참조 카운트를 증가시킵니다. 이 구별을 이해하는 것은 SwiftUI 뷰 구조체와 UIKit 뷰 컨트롤러 작업 시 캡처 리스트 수식어를 적용할지를 결정하는 데 중요합니다.

[weak self][unowned self]의 객체 수명 추정에 대한 정확한 차이는 무엇이며, [unowned self]가 충돌을 유발하는 순간은 언제인가?

후보자들은 종종 이를 상호 교환적으로 다룹니다. [weak self]는 캡처를 옵셔널 WeakReference로 변환하며, ARC는 객체가 해제될 때 이를 자동으로 nil로 설정합니다. 접근하려면 옵셔널 바인딩이 필요하며, 객체가 사라지더라도 안전합니다. 반면, [unowned self]는 객체가 클로저의 전체 생애 동안 존재할 것이라는 가정 하에 소유하지 않는 참조를 생성합니다; 이는 절대로 nil로 설정되지 않는 암시적 언래핑 옵셔널과 유사합니다.

클로저가 객체의 생애를 초월하는 경우(예: 뷰 컨트롤러가 사라진 후 호출되는 저장된 완료 핸들러), self에 접근하면 해제된 포인터를 간접 참조하게 되어 EXC_BAD_ACCESS 충돌이 발생합니다. 클로저와 객체의 생애가 동일할 때만 [unowned self]를 사용해야 하며, 예를 들어 비탈출 클로저나 클로저가 캡처하는 객체보다 오래 살 수 없는 특정 위임 패턴에서 그렇습니다.

캡처 리스트가 클로저 범위 밖에서 선언된 변수와 어떻게 상호 작용하며, [x]가 값 타입에 대해 복사본이나 참조를 생성하는가?

캡처 리스트가 self에만 영향을 미친다고 잘못 이해하는 경우가 많습니다. { [x] in ... }라고 작성하면, 클로저 생성 시점에 x의 현재 값을 명시적으로 캡처하여 클로저 내에서 불변의 그림자 복사를 생성합니다. 캡처 리스트가 없는 경우, 클로저는 원래 변수 저장소의 참조를 캡처하여 클로저 생성 이후의 변화를 볼 수 있게 하며, 리플렉션이 가능한 순환 로직에 잠재적으로 참여할 수 있습니다.

Int 또는 String과 같은 값 타입의 경우, [x]는 복사본을 캡처하여 클로저가 x에 대한 외부 변경을 관찰하지 못하도록 하고 클로저의 동작이 캡처 시 상태에 따라 결정되게 합니다. 이 구별은 클로저가 정의된 범위를 넘어 탈출하고 원래 컨텍스트가 변형된 후 오랫동안 실행될 때 중요해집니다.