🤖 이 글은 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]가 필요한지 체크리스트:
- 클로저가
@escaping인가? (비동기 콜백, 프로퍼티로 저장 등) - 클로저 안에서
self를 참조하는가? 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