앱의 안정성을 해치는 가장 치명적인 요소 중 하나는 메모리 누수(Memory Leak)입니다. 기능 단위 테스트에서는 발견되지 않다가, 사용 시간이 길어지거나 특정 시나리오가 반복될 때 비로소 OOM(Out of Memory) 크래시로 이어지는 경우가 빈번합니다. 특히 iOS의 ARC(Automatic Reference Counting) 모델은 가비지 컬렉션(GC)과 달리 개발자가 객체 간의 소유 관계를 명확히 설계하지 않으면 필연적으로 순환 참조(Retain Cycle)를 유발합니다. 본 글에서는 Xcode Instruments와 Memory Graph Debugger를 활용한 정량적 누수 탐지 방법과, 이를 아키텍처 레벨에서 방지하기 위한 엔지니어링 전략을 다룹니다.
1. ARC 메커니즘과 누수의 원인 분석
Swift의 메모리 관리는 ARC에 의존합니다. 이는 컴파일 타임에 retain과 release 코드를 자동으로 삽입하여 런타임에 참조 카운트를 추적하는 방식입니다. GC와 달리 "Stop-the-world" 현상이 없어 성능상 이점이 있지만, 객체 간의 참조 그래프가 순환 구조를 이룰 경우 영구적으로 메모리가 해제되지 않는 문제가 발생합니다.
주로 다음과 같은 패턴에서 누수가 발생합니다.
- Delegate 패턴: 델리게이트 속성을
weak로 선언하지 않았을 때. - Closure 캡처: 클로저 내부에서
self를 강하게 참조할 때. - Timer 객체: 타겟 객체를 강하게 참조하며 반복 실행될 때.
- Combine/RxSwift: 구독(Subscription)이 적절히 취소(Cancel)되지 않았을 때.
2. Instruments를 활용한 정밀 분석
단순히 코드를 훑어보는 것으로는 복잡한 객체 그래프 속의 누수를 찾아내기 어렵습니다. Xcode Instruments의 Leaks와 Allocations 도구는 메모리 사용량을 시각화하고, 해제되지 않은 객체를 추적하는 데 필수적입니다.
Leaks Instrument
Leaks 도구는 주기적으로 힙 스캔을 수행하여 참조 카운트가 0이 아니지만 루트(Root)에서 접근 불가능한 객체를 탐지합니다. 타임라인에 빨간색 'X' 표시가 나타나면 해당 시점에 누수가 발생한 것입니다. 하단의 Call Tree 뷰를 통해 해당 객체가 어디서 할당되었는지 역추적할 수 있습니다.
Allocations & Generation Analysis
Leaks 도구만으로는 "버려진 메모리(Abandoned Memory)"를 모두 찾을 수 없습니다. 객체가 어딘가에 불필요하게 캐싱되어 있거나, 배열에 계속 쌓이는 경우 Leaks는 이를 누수로 판단하지 않습니다. 이때 Allocations 도구의 Mark Generation 기능을 사용합니다.
3. Memory Graph Debugger 활용
Instruments가 무거운 분석 도구라면, Memory Graph Debugger는 개발 중 즉시 사용할 수 있는 경량 도구입니다. 실행 중 Xcode 하단 디버그 바의 아이콘을 클릭하면 현재 메모리에 올라와 있는 객체들의 관계도(Reference Graph)를 3D로 보여줍니다.
보라색 느낌표 아이콘이 붙은 객체는 누수가 의심되는 객체입니다. 이를 클릭하면 우측 Inspector 영역에서 Backtrace를 확인할 수 있어, 누가 이 객체를 붙들고 있는지 파악할 수 있습니다.
4. 코드 레벨 솔루션 및 패턴
Closure 캡처 리스트 최적화
클로저는 정의되는 시점의 컨텍스트를 캡처합니다. 클래스 인스턴스(self) 내부에서 클로저를 속성으로 갖고 있고, 그 클로저가 다시 self를 참조하면 강한 순환 참조가 발생합니다.
class DataFetcher {
var onComplete: (() -> Void)?
var data: [String] = []
func fetchData() {
// Bad: self를 강하게 캡처하여 순환 참조 발생
/*
onComplete = {
self.data = ["Result"]
}
*/
// Good: weak self를 사용하여 약한 참조로 변경
onComplete = { [weak self] in
guard let self = self else { return }
self.data = ["Result"]
}
}
}
Weak vs Unowned 선택 기준
weak와 unowned는 모두 참조 카운트를 증가시키지 않지만, 생명주기 보장 여부에서 차이가 있습니다. 잘못된 unowned 사용은 앱 크래시를 유발합니다.
| 키워드 | Optional 여부 | 메모리 해제 시 동작 | 사용 권장 시나리오 |
|---|---|---|---|
| weak | Optional (User?) |
nil로 변경됨 |
참조하는 객체가 먼저 해제될 가능성이 있을 때 (대부분의 경우 권장) |
| unowned | Non-Optional | 변경되지 않음 (Dangling Pointer) | 참조 대상의 수명이 참조하는 객체와 같거나 더 길다는 것이 100% 보장될 때 |
Combine 프레임워크 메모리 관리
Combine이나 RxSwift 같은 리액티브 프로그래밍에서는 Subscription이 해제되지 않아 발생하는 누수가 흔합니다. AnyCancellable을 저장하는 Set을 적절히 관리해야 합니다.
import Combine
class ViewModel {
// 구독 취소를 위한 저장소
private var cancellables = Set<AnyCancellable>()
func bind() {
NotificationCenter.default.publisher(for: .someEvent)
.sink { [weak self] _ in
// self가 해제되면 sink 내부 로직도 중단되어야 함
self?.handleEvent()
}
.store(in: &cancellables) // 필수: 인스턴스 해제 시 구독 자동 취소
}
private func handleEvent() {
// Logic...
}
}
.store(in: &cancellables)를 누락하면 구독이 즉시 해제되거나(리턴 값을 무시할 때), 영원히 유지되어(전역 변수 등에 할당 시) 메모리 누수를 일으킬 수 있습니다. 뷰 모델의 deinit이 호출되는지 로그를 통해 반드시 확인하십시오.
5. 트레이드오프와 결론
메모리 누수를 막기 위해 모든 클로저에 [weak self]를 남발하는 것은 방어적인 코딩일 수 있으나, 때로는 불필요한 옵셔널 바인딩 코드를 양산합니다. 객체의 수명 주기가 명확히 종속 관계(예: UIView 애니메이션 블록 내)라면 강한 참조가 허용될 수 있습니다.
그러나 유지보수 관점에서는 명시적인 약한 참조가 안전합니다. 특히 팀 단위 개발에서는 코드의 수명 주기를 직관적으로 파악하기 어려우므로, 기본적으로 weak를 사용하고 명확한 근거가 있을 때만 강한 참조를 유지하는 정책을 수립하는 것이 좋습니다. 정기적인 프로파일링과 CI 파이프라인 내의 Leak Test 통합을 통해 OOM 없는 견고한 앱을 구축하십시오.
Post a Comment