참조 순환 트러블 슈팅

Ios_Roy·2025년 8월 22일

트러블슈팅

목록 보기
2/3
post-thumbnail

🧯 Retain Cycle Troubleshooting

Swift에서 클래스 간 강한 참조와 클로저 캡처로 인해 생기는 순환 참조(retain cycle)를 재현하고, 원인 분석과 해결 방법(weak/unowned)을 정리했습니다.


증상 (Symptoms)

  • A와 B를 nil로 설정했는데도 deinit가 호출되지 않음
  • 메모리 그래프(🧠 Xcode Debug Memory Graph)에 객체가 잔존
  • 실행 후 메모리 사용량이 지속 증가 (Leaks/Allocations에서 관찰)

🧪 재현 코드 (문제 유발)

1) 클래스 상호 참조로 인한 순환 참조

final class A {
    var b: B?
    deinit { #logDebug("A deinit") }
}

final class B {
    var a: A?
    var closure: (() -> Void)?    // 클로저 기반 순환 참조 추가 예정
    deinit { #logDebug("B deinit") }
}

func leakDemo() {
    var a: A? = A()
    var b: B? = B()

    a?.b = b          // A → B (강한 참조)
    b?.a = a          // B → A (강한 참조)

    // 클로저가 A를 강하게 캡처 → B → closure → A 고리 형성
    b?.closure = {
        #logDebug("uses A: \(String(describing: a))")
    }

    a = nil
    b = nil
    // ❌ "A deinit", "B deinit" 출력되지 않음 (누수)
}

원인

  • A ↔ B가 서로를 강하게 참조 → 참조 카운트가 0이 되지 않음
  • B가 보관하는 closure가 외부 변수 a를 강하게 캡처 → 고리 강화

🔍 원인 분석 (Why)

  • Swift의 클래스는 참조 타입 → ARC(자동 참조 카운팅) 기반
  • 기본 캡처는 강한 참조: 클로저는 외부 변수를 특별히 지정하지 않으면 강하게 캡처
  • 양방향 강한 참조 + 클로저 강한 캡처가 겹치면 해제가 불가

🛠️ 해결 방법 (Fix)

A. 객체 간 강한 참조 고리 끊기 → 한쪽을 weak

final class A2 {
    weak var b: B2?          // 한쪽을 weak로
    deinit { #logDebug("A2 deinit") }
}

final class B2 {
    var a: A2?
    var closure: (() -> Void)?
    deinit { #logDebug("B2 deinit") }
}

func fixedDemo_weak_reference() {
    var a: A2? = A2()
    var b: B2? = B2()

    a?.b = b
    b?.a = a

    // 클로저는 아래 B안에서 따로 처리 (B의 클로저 섹션 참고)
    a = nil
    b = nil
    // ✅ "A2 deinit", "B2 deinit" 정상 출력
}

B. 클로저 캡처에서 강한 참조 제거 → [weak a] 또는 [unowned a]

1) 안전 우선: weak

func fixedDemo_closure_weak() {
    var a: A? = A()
    var b: B? = B()

    a?.b = b
    b?.a = a

    b?.closure = { [weak a] in
        guard let a else {
            #logDebug("A already deallocated")
            return
        }
        #logDebug("uses A safely: \(a)")
    }

    b?.closure?()  // A가 살아있다면 동작

    a = nil
    b?.closure?()  // "A already deallocated"
    b = nil
    // ✅ 정상 해제
}

2) 성능/간결성, 생명주기 보장 시: unowned

unowned는 대상이 반드시 살아있다는 보장이 있을 때만 사용. 보장 깨지면 런타임 크래시!

func fixedDemo_closure_unowned() {
    var a: A? = A()
    var b: B? = B()

    a?.b = b
    b?.a = a

    b?.closure = { [unowned a] in
        // a는 살아있다고 가정
        #logDebug("uses A (unowned): \(a!)")
    }

    b?.closure?()

    // 아래 순서에서 a를 먼저 nil로 만들고 다시 closure를 호출하면 크래시!
    a = nil
    b = nil
}

🤔 weak vs unowned 선택 가이드

기준weakunowned
참조 방식Optional(자동 nil)Non-optional
안전성높음 (nil 체크 필요)낮음 (해제 시 크래시)
성능/간결성약간 비용 있지만 안전약간 빠르고 간결
사용 조건생명주기 보장 불가생명주기 보장 확실

권장: 보통은 weak + guard let 패턴을 기본값으로,
성능/보장이 확실한 경우에만 unowned.


디버깅 팁

  • Xcode → Debug Memory Graph (⌘ + 7 / 디버그 중 좌측 아이콘)
    → 강한 참조 고리를 시각적으로 확인
  • Instruments → Leaks/Allocations
    → 런타임 중 메모리 누수/증가 추적
  • 로그 추가
    • 각 클래스에 deinit { #logDebug("... deinit") } 추가해서 해제 시점 확인

✅ 체크리스트

  • 양방향 참조 관계 중 한쪽을 weak 로 변경했는가?
  • 클로저 캡처 리스트에 [weak self]/[weak a] 또는 [unowned ...]를 적용했는가?
  • unowned 사용 시 생명주기 보장을 명확히 했는가? (문서/주석 포함)
  • deinit 로그가 정상 출력되는지 확인했는가?
  • Memory Graph / Instruments로 잔존 객체가 없는지 확인했는가?

🧱 Before / After 요약

Before (문제)

b?.closure = {
    #logDebug(a!)             // 강한 캡처 + 강제 언래핑 → 누수/크래시 위험
}

After (해결: weak)

b?.closure = { [weak a] in
    guard let a else { return }
    #logDebug(a)
}

After (해결: unowned, 보장 시)

b?.closure = { [unowned a] in
    #logDebug(a)              // a의 생존이 반드시 보장되어야 함
}

❓FAQ

Q. weak는 왜 Optional이어야 하나요?
A. 대상이 해제되면 ARC가 자동으로 nil을 대입하기 때문에 Optional이어야 합니다.

Q. unowned는 언제 쓰나요?
A. 캡처한 대상이 클로저보다 오래 살거나 동일한 생명주기임이 확실할 때만 권장됩니다.

Q. 클래스 대신 struct를 쓰면 괜찮나요?
A. struct는 값 타입이라 ARC 관리를 받지 않아 참조 고리 문제가 없습니다. 하지만 참조语가 필요한 경우엔 클래스를 써야 합니다.

profile
iOS 개발자 공부하는 Roy

0개의 댓글