리테인 사이클은 ARC에서 두 개 이상의 객체가 strong 참조를 통해 서로를 참조하여 닫힌 루프를 형성하는 상황입니다. Apple Memory Management Guide, 2026에 따르면, 리테인 사이클은 사이클 내의 모든 객체의 해제를 차단하는데, 그 이유는 각 객체의 retain count가 ≥ 1이기 때문입니다. GC에서의 메모리 누수와 달리, 리테인 사이클은 사이클의 외부 참여자가 하나라도 살아 있는 한 객체가 계속 살아 있음을 보장합니다 — 그리고 사이클이 고립된 경우에는 모든 외부 참조를 잃은 후에도 그렇습니다.
주요 요점
리테인 사이클은 둘 이상의 객체가 strong 참조를 통해 서로를 소유하여 닫힌 의존 그래프를 만드는 상황입니다. ARC는 이러한 객체를 해제할 수 없습니다. 그 이유는 각 객체의 retain count가 항상 ≥ 1이기 때문입니다: 객체 A가 B를 보유하고, B가 A를 보유하며, 그들의 카운터가 결호 0이 되지 않습니다.
이 문제는 참조 계수 시스템(ARC, MRR)에서만 발생합니다. 기브지 컬렉션(GC)에서는 수집기가 루트 세트로부터 참조 그래프를 통해 도달 불가능성을 판단합니다 — 사이클은 장애물이 아닙니다. 그러나 ARC에서는 사이클이 누수와 동등한데, 그 이유는 결정적 해제를 계수로 수행할 경우 순환 의존성을 해결할 수 없기 때문입니다.
WWDC 2012 Session 406에 따르면, 리테인 사이클은 Objective-C 및 Swift 애플리케이션에서 가장 흔한 메모리 누수 원인입니다. 일반적인 시나리오: 디리게이트와의 부모-자식 관계, self를 캡처하는 클로저, 그리고 양방향 관계를 가진 계층적 아키텍처.
모든 iOS 개발자가 마주치는 고전적인 리테인 사이클 시나리오를 살펴보겠습니다. 이러한 패턴을 이해하는 것이 ARC로 안전한 코드를 작성하는 기본입니다.
고전적 시나리오: 부모 객체(예: UIViewController)가 자식 객체를 생성하고 그의 디리게이트가 됩니다. 둘 다 strong 참조를 사용하면 리테인 사이클이 발생합니다. 해결법은 — 디리게이트가 weak여야 한다는 것입니다.
// 오류: strong delegate를 통한 리테인 사이클
protocol ChildDelegate: AnyObject { }
class ParentVC: UIViewController, ChildDelegate {
var child: ChildVC?
func showChild() {
child = ChildVC()
child?.delegate = self // Parent → Child (strong)
} // Child → Parent (delegate를 통한 strong)
} // ⚠️ 리테인 사이클!
class ChildVC: UIViewController {
var delegate: ChildDelegate? // ❌ 기본 strong
}
// 수정: weak delegate
class ChildVC: UIViewController {
weak var delegate: ChildDelegate? // ✅ weak — 보유하지 않음
}
예시에서 ParentVC는 child 속성을 통해 ChildVC에 대한 strong 참조를 유지합니다. ChildVC는 delegate를 통해 ParentVC에 대한 strong 참조를 유지합니다. 사이클이 닫혀 있습니다. 수정: weak var delegate — 참조가 retain count를 증가시키지 않으며, ParentVC를 해제할 수 있습니다.
NSTimer는 리테인 사이클의 고전적 인 원인입니다. 타이머는 타겛(보통 self)을 보유하고, 타겛은 속성을 통해 타이머를 보유합니다. 타이머가 일회용이라도 invalidate가 호출됨 때까지 해제되지 않습니다. 해결법: deinit 또는 viewDidDisappear에서 항상 timer.invalidate()를 호출하세요.
캐스케이딩 소유권을 가진 아키텍처(코어디네이터, 라우터)에서는 다단계 사이클이 자주 발생합니다: Coordinator → ViewController → ViewModel → Coordinator(콜백을 통해). 체인의 각 strong 참조는 신중히 선택해야 합니다 — 어떤 고리에서도 한 개의 weak 참조가 사이클을 끈어줍니다.
Swift의 클로저는 strong 참조로 외부 변수를 캡처합니다. 클로저가 객체의 속성으로 저장되고(예: completion handler) self를 캡처하면, 리테인 사이클이 만들어집니다: self → closure → self.
이것은 현대 Swift 개발에서 가장 흔한 리테인 사이클 원인입니다. 이것은 암시적으로 발생합니다 — 개발자가 클로저 내에서 self 캡처를 인지하지 못할 수 있으며, 특히 명시적인 self 없이 축약 구문을 사용할 때 그렇습니다.
class DownloadService {
var onComplete: ((Data) -> Void)?
var result: Data?
func startDownload() {
// ❌ 리테인 사이클: self → onComplete → self
onComplete = { data in
self.result = data
self.notifyUI()
}
// ✅ 수정: weak self의 캡처 리스트
onComplete = { [weak self] data in
guard let self else { return }
self.result = data
self.notifyUI()
}
}
func notifyUI() { }
}
캡처 리스트 [weak self]는 클로저 내에서 self에 대한 약한 참조를 만듭니다. 클로저 실행 전에 DownloadService가 해제되면 self가 nil이 되고, 코드가 guard를 통해 안전하게 종료됩니다. 이것은 Swift에서 비동기 클로저의 표준 패턴입니다 — 클로저가 속성으로 저장될 때는 항상 사용해야 합니다.
unowned self는 weak self의 대안으로, self가 클로저보다 오래 살아 있다고 보장되는 경우에 사용합니다. 예: 즉시 실행되는 동기 클로저(sorted, filter). 이러한 경우 self는 확실히 존재하며, unowned는 안전합니다. 그러나 unowned는 해제된 객체에 접근하면 비행하므로 weak가 기본 안전 선택지로 간주됩니다.
애플리케이션 성능을 위해 리테인 사이클을 조기에 감지하는 것이 극히 중요합니다. iOS 개발에서 순환 참조를 식별하기 위한 기본 도구와 기법을 살펴보겠습니다.
Xcode Memory Debugger(Debug Memory Graph)는 메모리에서 객체의 그래프를 참조와 함께 보여주는 시각적 도구입니다. 리테인 사이클은 strong 화살표의 닫힌 사술로 표시됩니다. 실행 방법: 앱 실행 중 Debug area 패널에서 Debug Memory Graph 버튼을 클릭합니다. 각 객체는 타입, 주소 및 참조 목록과 함께 표시됩니다.
Instruments Leaks는 자동 누수 감지를 위한 프로파일러입니다. 할당을 기록하고 실시간으로 참조 그래프를 분석합니다. 리테인 사이클만 아니라 까메로운 참조, 해제되지 않은 ViewController 및 다른 누수도 감지합니다. Leaks는 정확한 객체와 보유 체인을 지적합니다.
가장 간단한 방법은 각 주요 클래스의 deinit에 print를 추가하는 것입니다. 객체가 파기되어야 할 때 deinit이 호출되지 않으면 리테인 사이클이 있는 것입니다. 이 방법은 도구가 필요 없고 초기 진단에 효과적입니다.
| 도구 | 유형 | 사용 시기 |
|---|---|---|
| Memory Debugger | 시각적 그래프 | 네비게이션 후 매니어 체크 |
| Instruments Leaks | 자동 분석 | 회귀 테스트, CI |
| deinit print | 매니어 로깅 | 개발, 코드 리뷰 |
| Malloc Scribble | 런타임 플래그 | use-after-free 디버깅 |
권장 접근 방식: 개발 중에는 deinit 로깅, 매니어 테스트에서는 Memory Debugger, 그리고 자동 회귀 누수 감지를 위한 CI/CD 파이프라인에서는 Instruments Leaks를 사용하세요.
리테인 사이클은 프로덕션에서 수정하는 것보다 예방하는 것이 더 쉽습니다. 다음은 순환 참조의 위험을 최소화하는 몇 가지 규칙입니다.
모든 디리게이트와 dataSource는 weak여야 합니다. 이 규칙은 UIKit에 포함되어 있습니다: Apple SDK의 모든 디리게이트 프로토콜은 weak 속성으로 선언됩니다(UITableView.delegate, UICollectionView.dataSource). 사용자 자체 프로토콜에는 weak var delegate: MyDelegate?를 사용하고, 프로토콜을 AnyObject에서 상속하세요.
속성(완료 핸들러, 콜백)으로 저장되고 self를 캡처하는 모든 클로저는 캡처 리스트에서 [weak self]를 사용해야 합니다. 예외는 즉시 실행되고 저장되지 않는 클로저(sorted, map, filter)입니다. 이러한 경우 unowned self가 안전합니다.
복잡한 아키텍처(VIPER, Coordinators, Redux)에서는 strong 참조의 방향을 추적하세요. 소유자는 하위에 대한 strong 참조를 유지하지만, 하위는 weak 또는 unowned를 통해서만 소유자를 참조해야 합니다. 단방향 데이터 흐름이 참조 관리를 간소화합니다.
// 예시: deinit 로깅 확인
class BaseViewController: UIViewController {
deinit {
print("✅ \(type(of: self)) deallocated")
}
}
// 사용: 모든 ViewController가 BaseViewController를 상속
class ProfileVC: BaseViewController {
var viewModel: ProfileViewModel?
var onLogout: (() -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
onLogout = { [weak self] in
self?.dismiss(animated: true)
}
}
}
// ProfileVC 닫을 때 콘솔에 "✅ ProfileVC deallocated" 파이밌을 기대
deinit 로깅이 있는 기본 클래스는 즉시 피드백을 제공합니다. 화면이 닫힐 때 메시지가 나타나지 않으면 이 클래스에 리테인 사이클이 있는 것입니다. 모든 ViewController에 대해 이 실무를 프로젝트 템플릿에 추가하세요.
자주 묻는 질문
리테인 사이클은 strong 참조의 닫힌 루프가 해제를 차단하는 특정 ARC 문제입니다. GC에서는 수집기가 참조 카운트가 아닌 루트 세트로부터의 도달 가능성을 분석하므로 사이클은 누수가 아닙니다. ARC에서는 고립된 사이클은 확실한 누수입니다.
Weak 참조는 객체의 retain count를 증가시키지 않습니다. 사이클에서 한 strong 참조를 weak로 대체하면 각 객체의 retain count가 0이 될 수 있습니다. 객체가 해제된 후 weak 참조는 자동으로 nil이 되어 해제된 메모리에 대한 접근을 막습니다.
네, 리테인 사이클은 임의 수의 객체를 포함할 수 있습니다: A → B → C → A. 이를 빨라려면 하나의 고리를 끈으면 됩니다 — 어떤 strong 참조를 weak 또는 unowned로 대체하면 됩니다. 도구는 객체 쌍만 아닌 전체 그래프를 보여줍니다.
GCD(Grand Central Dispatch)는 실행 후 클로저를 저장하지 않습니다. DispatchWorkItem은 실행되고 해제됩니다. 클로저가 self를 캡처하더라도 마찬가지입니다. 리테인 사이클은 클로저가 속성(클래스의 완료 핸들러)으로 저장될 때만 발생하고, 큐에 전달될 때는 발생하지 않습니다.
Instruments Leaks가 항상 일시적인 리테인 사이클(수 초 동안 지속)이나 브리징을 통한 C/C++ 객체의 순환 참조를 찾는 것은 아닙니다. 완전한 확인을 위해서는 시니에서 모든 주요 객체의 deinit 로깅과 함께 Memory Debugger를 직접 사용하세요.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.