iOS 메모리 누수 탐지 및 순환 참조 해결 전략

의 안정성을 해치는 가장 치명적인 요소 중 하나는 메모리 누수(Memory Leak)입니다. 기능 단위 테스트에서는 발견되지 않다가, 사용 시간이 길어지거나 특정 시나리오가 반복될 때 비로소 OOM(Out of Memory) 크래시로 이어지는 경우가 빈번합니다. 특히 iOS의 ARC(Automatic Reference Counting) 모델은 가비지 컬렉션(GC)과 달리 개발자가 객체 간의 소유 관계를 명확히 설계하지 않으면 필연적으로 순환 참조(Retain Cycle)를 유발합니다. 본 글에서는 Xcode Instruments와 Memory Graph Debugger를 활용한 정량적 누수 탐지 방법과, 이를 아키텍처 레벨에서 방지하기 위한 엔지니어링 전략을 다룹니다.

1. ARC 메커니즘과 누수의 원인 분석

Swift의 메모리 관리는 ARC에 의존합니다. 이는 컴파일 타임에 retainrelease 코드를 자동으로 삽입하여 런타임에 참조 카운트를 추적하는 방식입니다. GC와 달리 "Stop-the-world" 현상이 없어 성능상 이점이 있지만, 객체 간의 참조 그래프가 순환 구조를 이룰 경우 영구적으로 메모리가 해제되지 않는 문제가 발생합니다.

Technical Note: ARC는 힙(Heap) 영역에 할당된 클래스 인스턴스에만 적용됩니다. 구조체(Struct)나 열거형(Enum) 같은 값 타입(Value Type)은 스택에 저장되거나 포함된 객체의 라이프사이클을 따르므로, 직접적인 순환 참조의 주체가 되는 경우는 드뭅니다.

주로 다음과 같은 패턴에서 누수가 발생합니다.

  • Delegate 패턴: 델리게이트 속성을 weak로 선언하지 않았을 때.
  • Closure 캡처: 클로저 내부에서 self를 강하게 참조할 때.
  • Timer 객체: 타겟 객체를 강하게 참조하며 반복 실행될 때.
  • Combine/RxSwift: 구독(Subscription)이 적절히 취소(Cancel)되지 않았을 때.

2. Instruments를 활용한 정밀 분석

단순히 코드를 훑어보는 것으로는 복잡한 객체 그래프 속의 누수를 찾아내기 어렵습니다. Xcode Instruments의 LeaksAllocations 도구는 메모리 사용량을 시각화하고, 해제되지 않은 객체를 추적하는 데 필수적입니다.

Leaks Instrument

Leaks 도구는 주기적으로 힙 스캔을 수행하여 참조 카운트가 0이 아니지만 루트(Root)에서 접근 불가능한 객체를 탐지합니다. 타임라인에 빨간색 'X' 표시가 나타나면 해당 시점에 누수가 발생한 것입니다. 하단의 Call Tree 뷰를 통해 해당 객체가 어디서 할당되었는지 역추적할 수 있습니다.

Allocations & Generation Analysis

Leaks 도구만으로는 "버려진 메모리(Abandoned Memory)"를 모두 찾을 수 없습니다. 객체가 어딘가에 불필요하게 캐싱되어 있거나, 배열에 계속 쌓이는 경우 Leaks는 이를 누수로 판단하지 않습니다. 이때 Allocations 도구의 Mark Generation 기능을 사용합니다.

Best Practice: Generation Analysis 기법을 사용하십시오. 특정 화면 진입 전 'Mark Generation'을 누르고, 화면에 진입했다가 빠져나온 뒤 다시 마킹합니다. 이 과정에서 Generation 간의 메모리 델타(Delta)가 0으로 회귀하지 않고 양수(+)로 유지된다면, 해당 뷰 컨트롤러나 뷰 모델이 메모리에 남아있다는 강력한 증거입니다.

3. Memory Graph Debugger 활용

Instruments가 무거운 분석 도구라면, Memory Graph Debugger는 개발 중 즉시 사용할 수 있는 경량 도구입니다. 실행 중 Xcode 하단 디버그 바의 아이콘을 클릭하면 현재 메모리에 올라와 있는 객체들의 관계도(Reference Graph)를 3D로 보여줍니다.

보라색 느낌표 아이콘이 붙은 객체는 누수가 의심되는 객체입니다. 이를 클릭하면 우측 Inspector 영역에서 Backtrace를 확인할 수 있어, 누가 이 객체를 붙들고 있는지 파악할 수 있습니다.

주의: 시스템 프레임워크 내부(UIKit, Foundation 등)에서 발생하는 누수가 잡히는 경우도 있습니다. 이는 개발자가 제어할 수 없는 영역이므로, 본인이 작성한 코드에서 발생한 누수인지 필터링하는 능력이 필요합니다.

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 선택 기준

weakunowned는 모두 참조 카운트를 증가시키지 않지만, 생명주기 보장 여부에서 차이가 있습니다. 잘못된 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...
    }
}
Critical: .store(in: &cancellables)를 누락하면 구독이 즉시 해제되거나(리턴 값을 무시할 때), 영원히 유지되어(전역 변수 등에 할당 시) 메모리 누수를 일으킬 수 있습니다. 뷰 모델의 deinit이 호출되는지 로그를 통해 반드시 확인하십시오.

5. 트레이드오프와 결론

메모리 누수를 막기 위해 모든 클로저에 [weak self]를 남발하는 것은 방어적인 코딩일 수 있으나, 때로는 불필요한 옵셔널 바인딩 코드를 양산합니다. 객체의 수명 주기가 명확히 종속 관계(예: UIView 애니메이션 블록 내)라면 강한 참조가 허용될 수 있습니다.

그러나 유지보수 관점에서는 명시적인 약한 참조가 안전합니다. 특히 팀 단위 개발에서는 코드의 수명 주기를 직관적으로 파악하기 어려우므로, 기본적으로 weak를 사용하고 명확한 근거가 있을 때만 강한 참조를 유지하는 정책을 수립하는 것이 좋습니다. 정기적인 프로파일링과 CI 파이프라인 내의 Leak Test 통합을 통해 OOM 없는 견고한 앱을 구축하십시오.

OlderNewest

Post a Comment