[12편] 메모리 관리(ARC) — 순환 참조와 weak, unowned

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: 11편

Python, JavaScript, Java 같은 언어는 가비지 컬렉터(GC)가 주기적으로 돌면서 안 쓰는 메모리를 회수합니다. Swift는 다릅니다. ARC(Automatic Reference Counting)라는 방식으로, 각 객체가 몇 번 참조되는지를 추적해서 0이 되는 순간 즉시 해제합니다. GC처럼 멈추는 시간이 없어 더 예측 가능한 성능을 냅니다.

하지만 ARC에는 한 가지 맹점이 있습니다. 두 객체가 서로를 강하게 참조하면 참조 횟수가 절대 0이 되지 않아 메모리가 영원히 남습니다. 이것이 순환 참조(retain cycle)입니다.


ARC 동작 원리

클래스 인스턴스를 변수에 대입할 때마다 참조 횟수(retain count)가 1 오릅니다. 변수가 없어지거나 nil이 되면 1 내려갑니다. 0이 되면 deinit이 호출되고 메모리가 해제됩니다.

class Dog {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) 생성")
    }
    deinit {
        print("\(name) 해제")
    }
}

var a: Dog? = Dog(name: "멍멍이")  // 참조 횟수: 1, "멍멍이 생성"
var b = a                           // 참조 횟수: 2
a = nil                             // 참조 횟수: 1
b = nil                             // 참조 횟수: 0, "멍멍이 해제"

구조체는 값 타입이라 복사되므로 ARC의 대상이 아닙니다. ARC는 클래스 인스턴스에만 적용됩니다.


순환 참조가 생기는 패턴

가장 전형적인 패턴입니다. 두 클래스가 서로를 강하게 참조합니다.

class Person {
    let name: String
    var apartment: Apartment?
    init(name: String) { self.name = name }
    deinit { print("\(name) 해제") }
}

class Apartment {
    let unit: String
    var tenant: Person?           // 강한 참조
    init(unit: String) { self.unit = unit }
    deinit { print("\(unit) 해제") }
}

var alice: Person? = Person(name: "Alice")
var apt: Apartment? = Apartment(unit: "101호")

alice!.apartment = apt   // Person → Apartment 강한 참조
apt!.tenant = alice      // Apartment → Person 강한 참조

alice = nil  // Person 참조 횟수: 1 (apt가 여전히 참조 중)
apt = nil    // Apartment 참조 횟수: 1 (Person이 여전히 참조 중)
// deinit이 호출되지 않음 — 메모리 누수

변수를 nil로 만들어도 두 인스턴스가 서로를 붙들고 있어 해제되지 않습니다.


weak — 약한 참조

한쪽 참조를 weak로 선언하면 참조 횟수를 올리지 않습니다. 참조하던 인스턴스가 해제되면 자동으로 nil이 됩니다. 그래서 weak 참조는 반드시 옵셔널 변수여야 합니다.

class Apartment {
    let unit: String
    weak var tenant: Person?      // 약한 참조 — 횟수 안 올림
    init(unit: String) { self.unit = unit }
    deinit { print("\(unit) 해제") }
}

var alice: Person? = Person(name: "Alice")
var apt: Apartment? = Apartment(unit: "101호")

alice!.apartment = apt
apt!.tenant = alice

alice = nil  // Person 참조 횟수: 0 → "Alice 해제", apt.tenant는 자동으로 nil
apt = nil    // Apartment 참조 횟수: 0 → "101호 해제"

관계가 대등하지 않을 때 — “소유하지 않는 쪽”에 weak를 씁니다. 위 예시에서는 아파트가 세입자를 소유하지 않으므로 weak가 적절합니다.


unowned — 미소유 참조

unowned도 참조 횟수를 올리지 않습니다. weak와의 차이는 옵셔널이 아니라는 점입니다. 참조하던 인스턴스가 해제된 뒤 접근하면 런타임 크래시가 납니다.

class Customer {
    let name: String
    var card: CreditCard?
    init(name: String) { self.name = name }
    deinit { print("\(name) 해제") }
}

class CreditCard {
    let number: Int
    unowned let owner: Customer   // 카드는 항상 주인이 있어야 함
    init(number: Int, owner: Customer) {
        self.number = number
        self.owner = owner
    }
    deinit { print("카드 \(number) 해제") }
}

var customer: Customer? = Customer(name: "Bob")
customer!.card = CreditCard(number: 1234, owner: customer!)

customer = nil
// "Bob 해제", "카드 1234 해제" — 순서대로 정리됨

신용카드는 항상 주인이 있고, 주인이 사라지면 카드도 사라집니다. 카드가 주인을 참조할 때 주인이 먼저 해제될 일이 없다는 확신이 있을 때 unowned를 씁니다.

weak vs unowned 선택 기준:

  • 참조하는 인스턴스가 더 짧게 살 수 있다 → weak (nil 가능성 있음)
  • 참조하는 인스턴스가 항상 더 오래 산다 → unowned (nil 없음)
  • 확신이 없다면 weak가 더 안전하다

클로저에서의 순환 참조

6편에서 잠깐 다뤘지만 좀 더 구체적으로 살펴봅니다. 클래스가 클로저를 프로퍼티로 갖고, 그 클로저가 self를 캡처하면 순환 참조가 생깁니다.

class ViewController {
    var name = "메인"
    lazy var description: () -> String = {
        return "화면 이름: \(self.name)"  // self를 강하게 캡처
        // ViewController → description(클로저) → ViewController 순환
    }
    deinit { print("ViewController 해제") }
}

var vc: ViewController? = ViewController()
print(vc!.description())
vc = nil  // deinit 호출 안 됨 — 메모리 누수

캡처 리스트로 해결합니다.

class ViewController {
    var name = "메인"
    lazy var description: () -> String = { [weak self] in
        guard let self = self else { return "" }
        return "화면 이름: \(self.name)"
    }
    deinit { print("ViewController 해제") }
}

var vc: ViewController? = ViewController()
print(vc!.description())
vc = nil  // "ViewController 해제" — 정상 해제

[weak self] 패턴 정리

실제 코드에서 가장 자주 쓰는 패턴입니다.

// 패턴 1: guard let — self가 nil이면 조기 탈출
networkCall { [weak self] result in
    guard let self = self else { return }
    self.updateUI(with: result)
}

// 패턴 2: 옵셔널 체이닝 — nil이면 그냥 무시
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
    self?.tick()
}

언제 [weak self]가 필요한지 체크리스트:

  1. 클로저가 @escaping인가? (비동기 콜백, 프로퍼티로 저장 등)
  2. 클로저 안에서 self를 참조하는가?
  3. self가 그 클로저를 (직접 또는 간접으로) 소유하는가?

셋 다 해당되면 [weak self]를 쓰면 됩니다.


메모리 누수 진단

Xcode에서 메모리 누수를 확인하는 방법입니다.

  • Memory Graph Debugger: 앱 실행 중 Xcode 툴바의 메모리 그래프 버튼 클릭 → 인스턴스 간 참조 관계를 시각화
  • Instruments → Leaks: 시간에 따른 메모리 누수 추적
  • deinit에 print 추가: 개발 중 인스턴스가 제대로 해제되는지 확인하는 가장 간단한 방법

다른 언어와 비교

Python / Java C / C++ Swift
메모리 관리 방식 가비지 컬렉터(GC) 수동 (malloc/free) ARC (자동 참조 카운팅)
해제 시점 GC 실행 시 (불확정) free() 호출 시 참조 횟수 0이 되는 즉시
순환 참조 GC가 처리(Python 제외) 해당 없음 weak/unowned로 직접 처리
약한 참조 weakref 모듈 없음 weak var

핵심 요약

  • ARC는 참조 횟수를 추적해 0이 되는 순간 인스턴스를 해제한다. 구조체에는 적용되지 않는다.
  • 두 클래스가 서로를 강하게 참조하면 참조 횟수가 0이 되지 않는 순환 참조가 생긴다.
  • weak는 참조 횟수를 올리지 않고, 대상이 해제되면 자동으로 nil이 된다. 옵셔널이어야 한다.
  • unowned는 대상이 항상 더 오래 산다는 확신이 있을 때 쓴다. nil이 되지 않는다.
  • 클로저가 self를 캡처하고 self가 그 클로저를 소유하면 순환 참조가 생긴다. [weak self]로 끊는다.

다음 편은 13편 — Swift Concurrency 1부: async/await와 구조화된 동시성입니다. 콜백 지옥을 탈출하는 Swift의 현대적인 비동기 처리 방식을 다룹니다.

🤖 Generated with Claude Code