앱을 10분 이상 사용했을 때 발열이 심해지거나 UI 반응 속도가 느려진다면, 90% 확률로 iOS 메모리 누수(Memory Leak)가 원인이다. ARC(Automatic Reference Counting)가 메모리를 관리해주지만, 개발자가 만든 순환 참조(Retain Cycle)까지 해결해주지는 않는다. 이 글에서는 Xcode의 강력한 도구인 Instruments를 활용해 누수를 시각적으로 탐지하고, Swift 클로저에서 발생하는 순환 참조를 영구적으로 제거하는 프로덕션 레벨의 해결책을 제시한다.
1. 증상 진단: Xcode Instruments로 누수 현장 포착하기
로그만 보고 누수를 짐작하는 것은 시간 낭비다. Xcode Instruments의 'Leaks' 및 'Allocations' 템플릿을 사용하면 누수가 발생하는 정확한 객체와 시점을 알 수 있다. 특히 iOS 성능 최적화를 위해서는 정량적인 데이터가 필수적이다.
Instruments 실행 프로세스
- Xcode에서
Product>Profile(Cmd + I) 실행. - 템플릿 선택 화면에서 Leaks를 선택.
- 앱이 실행되면 녹화(Record) 버튼을 누르고, 누수가 의심되는 화면(ViewController)을 Push했다가 Pop하는 동작을 3~5회 반복한다.
- 타임라인에 빨간색 X 표시가 뜬다면 해당 시점에 메모리 해제가 실패한 것이다.
Allocations 도구에서는 # Persistent 카운트를 주목해야 한다. 화면을 닫았음에도 해당 ViewController의 인스턴스 개수가 줄어들지 않고 계속 증가한다면, 이는 전형적인 좀비 객체 현상이다.
2. 원인 분석: Swift Retain Cycle (순환 참조)
가장 흔한 Swift Retain Cycle은 클로저(Closure)가 self(ViewController 등)를 강하게 참조하고, 동시에 self가 해당 클로저를 속성으로 소유할 때 발생한다. 서로가 서로의 멱살을 잡고 놓지 않아 Reference Count가 0이 되지 못하는 상황이다.
문제의 코드 패턴 (메모리 누수 발생)
class BadViewController: UIViewController {
var onDataFetched: (() -> Void)?
var dataManager = DataManager()
override func viewDidLoad() {
super.viewDidLoad()
// [Critical] 클로저가 self를 강하게 캡처함
dataManager.fetchData { result in
self.updateUI(with: result)
self.onDataFetched?()
}
}
func updateUI(with data: String) {
print("UI Updated")
}
deinit {
print("BadViewController 메모리 해제됨") // 이 로그가 절대 찍히지 않음
}
}
BadViewController를 팝(Pop)하더라도, dataManager의 클로저가 self를 잡고 있기 때문에 뷰 컨트롤러는 메모리에서 해제되지 않는다. 이는 앱 디버깅 시 가장 빈번하게 발견되는 OOM의 원인이다.
3. 해결책: [weak self]와 Capture List 활용
이 문제를 해결하기 위해 Capture List인 [weak self]를 사용하여 참조 카운트를 증가시키지 않는 약한 참조(Weak Reference)를 만들어야 한다.
수정된 코드 (메모리 누수 해결)
class GoodViewController: UIViewController {
var dataManager = DataManager()
override func viewDidLoad() {
super.viewDidLoad()
// [Fix] weak self를 사용하여 강한 참조 고리를 끊음
dataManager.fetchData { [weak self] result in
// self가 메모리에서 해제되었을 경우를 대비해 unwrapping
guard let self = self else { return }
self.updateUI(with: result)
}
}
deinit {
// 정상적으로 출력됨
print("GoodViewController 메모리 해제 성공: Retain Cycle 없음")
}
}
weak vs unowned 비교
| 키워드 | 참조 타입 | 객체 해제 시 동작 | 권장 상황 |
|---|---|---|---|
| weak | Optional | nil이 됨 (Crash 없음) | 비동기 네트워크 콜, 대부분의 UI 이벤트 핸들러 |
| unowned | Non-Optional | 접근 시 Crash 발생 | 클로저와 객체의 수명 주기가 완벽히 동일함이 보장될 때 (지양 권장) |
unowned는 약간의 성능 이점이 있을 수 있으나, 객체가 이미 해제된 상태에서 접근하면 앱이 즉시 강제 종료된다. 프로덕션 환경의 안정성을 위해 기본적으로 weak를 사용하는 것이 iOS 성능 최적화의 정석이다.
4. Combine 및 RxSwit에서의 누수 관리
최신 iOS 개발 환경인 Combine이나 RxSwift를 사용할 때도 Cancellable 혹은 DisposeBag 처리를 누락하면 누수가 발생한다. 반드시 구독 객체를 저장하고, 뷰 컨트롤러 해제 시 함께 정리되도록 해야 한다.
// Combine 예시
viewModel.$data
.sink { [weak self] data in
self?.updateView(data)
}
.store(in: &cancellables) // 필수: 구독권 저장
결론
iOS 메모리 누수는 앱의 안정성을 해치는 침묵의 살인자다. Xcode Instruments를 통한 주기적인 프로파일링과 클로저 내 [weak self] 사용을 습관화해야 한다. 특히 대규모 이미지를 다루거나 복잡한 내비게이션 구조를 가진 앱이라면, 배포 전 반드시 Memory Graph를 확인하여 Retain Cycle을 제거해야만 사용자에게 쾌적한 경험을 제공할 수 있다.
Post a Comment