iOS 메모리 누수: Xcode Instruments로 Retain Cycle 완벽 제거하기 (OOM 방지)

앱을 10분 이상 사용했을 때 발열이 심해지거나 UI 반응 속도가 느려진다면, 90% 확률로 iOS 메모리 누수(Memory Leak)가 원인이다. ARC(Automatic Reference Counting)가 메모리를 관리해주지만, 개발자가 만든 순환 참조(Retain Cycle)까지 해결해주지는 않는다. 이 글에서는 Xcode의 강력한 도구인 Instruments를 활용해 누수를 시각적으로 탐지하고, Swift 클로저에서 발생하는 순환 참조를 영구적으로 제거하는 프로덕션 레벨의 해결책을 제시한다.

1. 증상 진단: Xcode Instruments로 누수 현장 포착하기

로그만 보고 누수를 짐작하는 것은 시간 낭비다. Xcode Instruments의 'Leaks' 및 'Allocations' 템플릿을 사용하면 누수가 발생하는 정확한 객체와 시점을 알 수 있다. 특히 iOS 성능 최적화를 위해서는 정량적인 데이터가 필수적이다.

Quick Check: Instruments를 실행하기 전, Xcode 하단의 Debug Memory Graph 아이콘을 먼저 클릭해보라. 보라색 느낌표(!) 아이콘이 뜬다면 이미 메모리 누수가 확정적이다.

Instruments 실행 프로세스

  1. Xcode에서 Product > Profile (Cmd + I) 실행.
  2. 템플릿 선택 화면에서 Leaks를 선택.
  3. 앱이 실행되면 녹화(Record) 버튼을 누르고, 누수가 의심되는 화면(ViewController)을 Push했다가 Pop하는 동작을 3~5회 반복한다.
  4. 타임라인에 빨간색 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을 제거해야만 사용자에게 쾌적한 경험을 제공할 수 있다.

OlderNewest

Post a Comment