🟣 다 쓴 객체가 안 사라진다
가비지 컬렉터가 도는 언어를 먼저 접했던 터라, Swift에서 메모리를 언제 어떻게 지우는지가 처음엔 막막했다. free를 직접 부르는 것도 아닌데 메모리가 새지 않는다니 신기했고, 반대로 분명히 화면을 닫았는데 그 화면 객체의 deinit이 안 불리는 걸 보고는 당황했다. 다 쓴 객체가 메모리에 그대로 남아 있었다.
두 현상 모두 같은 장치에서 나온다. Swift는 참조 타입의 수명을 참조 카운팅으로 관리한다. 자동으로 세어 준다는 뜻에서 ARC(Automatic Reference Counting)다. 이 장은 그 셈법과, 셈이 어긋나 객체가 안 사라지는 순환참조를 다룬다.
🟣 참조 카운팅: 세어서 0이면 지운다
ARC의 규칙은 한 문장이다. 클래스 인스턴스는 자기를 가리키는 강한 참조의 개수를 세고, 그 수가 0이 되는 순간 메모리에서 해제된다. 새 참조가 생기면 카운트가 오르고, 참조가 사라지면 내려간다. 이 셈과 해제 코드를 컴파일러가 알아서 끼워 넣는다.
class Person {
let name: String
init(name: String) { self.name = name }
deinit { print("\(name) 해제됨") }
}
var a: Person? = Person(name: "Lody") // 카운트 1
var b = a // 카운트 2 (같은 인스턴스)
a = nil // 카운트 1
b = nil // 카운트 0 → 여기서 "Lody 해제됨" 출력deinit은 인스턴스가 해제되는 순간 딱 한 번 불린다. 그래서 카운트가 정말 0이 됐는지 눈으로 확인하는 좋은 창이 된다. 위 코드에서 b = nil을 하는 순간에야 메시지가 찍힌다. 그 전까지는 누군가 여전히 인스턴스를 붙잡고 있다는 뜻이다.
여기서 아무것도 안 붙인 평범한 참조가 전부 강한 참조라는 점이 중요하다. 강한 참조는 대상을 붙잡아 카운트를 올린다. "내가 이걸 쓰는 동안은 지우지 마"라는 선언이다. 대부분의 상황에서 이 기본 동작이 정답이라 평소엔 의식할 일이 없다. 문제는 이 강한 참조가 서로를 물고 늘어질 때 생긴다.
🟣 강한 참조가 만드는 순환
두 인스턴스가 서로를 강한 참조로 가리키면, 카운트가 영영 0으로 안 내려간다. 이것이 순환참조(retain cycle)다.
class Person {
var apartment: Apartment?
deinit { print("Person 해제됨") }
}
class Apartment {
var tenant: Person?
deinit { print("Apartment 해제됨") }
}
var lody: Person? = Person()
var unit: Apartment? = Apartment()
lody!.apartment = unit // Person → Apartment 강한 참조
unit!.tenant = lody // Apartment → Person 강한 참조
lody = nil // 아무것도 해제 안 됨
unit = nil // 여전히 아무것도 해제 안 됨lody와 unit 변수를 둘 다 nil로 만들었으니 바깥에서 접근할 길은 사라졌다. 그런데 deinit이 하나도 안 불린다. 두 인스턴스가 서로를 강하게 붙잡고 있어서 카운트가 각각 1로 남았기 때문이다. 아무도 밖에서 도달할 수 없는데 메모리에는 그대로 살아 있다. 이게 Swift에서 메모리가 새는 대표적인 방식이다.
🟣 weak과 unowned: 세지 않는 참조
순환을 끊으려면 두 참조 중 하나를 카운트에 포함되지 않는 참조로 바꾸면 된다. Swift는 이걸 위해 weak과 unowned를 준다. 둘 다 대상을 가리키되 카운트를 올리지 않는다. 그래서 상대가 해제되는 걸 막지 않는다.
class Apartment {
weak var tenant: Person? // 세지 않는 참조로 변경
deinit { print("Apartment 해제됨") }
}tenant를 weak으로 바꾸면 그림이 달라진다. Apartment는 Person을 가리키지만 카운트를 올리지 않으므로, lody = nil 시점에 Person의 카운트가 0이 되어 해제된다. Person이 해제되면 그가 붙잡던 Apartment도 카운트가 0이 되어 뒤따라 해제된다. 순환이 풀린 것이다.
🚀 weak과 unowned 중 무엇을 쓰나
둘의 차이는 대상이 사라졌을 때의 행동이다. weak은 대상이 해제되면 자동으로 nil이 된다. 그래서 weak 변수는 항상 옵셔널이어야 한다. 접근할 때마다 "아직 살아 있나"를 확인하게 된다. 대상이 나보다 먼저 사라질 수 있는 관계에 쓴다.
unowned는 대상이 항상 나보다 오래 살아 있다고 가정하고 확인을 생략한다. 옵셔널이 아니며, 만약 이미 해제된 대상에 접근하면 앱이 크래시한다. 대상의 수명이 나를 확실히 감쌀 때만 안전하다.
판단 기준은 "이 참조가 가리키는 대상이 나보다 먼저 죽을 수 있는가"였다. 먼저 죽을 수 있으면 weak, 절대 아니면 unowned다. 확신이 안 서면 weak이 안전한 기본값이다. 크래시보다 nil 확인이 낫기 때문이다.
🟣 클로저 캡처가 만드는 순환
실무에서 순환참조를 가장 자주 만든 건 두 클래스가 아니라 클로저였다. 1장에서 클로저는 바깥 변수를 참조로 캡처한다고 했다. 클로저가 self를 캡처하면 self를 강하게 붙잡는다. 그런데 그 클로저를 다시 self가 프로퍼티로 붙잡고 있으면, self와 클로저가 서로를 무는 순환이 된다.
class ViewModel {
var onUpdate: (() -> Void)?
var title = "제목"
func bind() {
onUpdate = {
print(self.title) // 클로저가 self를 강하게 캡처
}
}
deinit { print("ViewModel 해제됨") }
}self가 onUpdate 클로저를 붙잡고, onUpdate 클로저가 다시 self를 붙잡는다. 두 클래스 순환과 구조가 똑같다. 이 상황이 특히 위험한 건, @escaping 클로저에서 잘 터진다는 점이다. 함수가 끝난 뒤에도 어딘가에 저장돼 나중에 실행되는 클로저를 @escaping으로 표시하는데, 저장된다는 건 곧 self를 오래 붙잡는다는 뜻이기 때문이다. 반대로 함수 안에서 즉시 실행되고 끝나는 비탈출 클로저는 self를 잠깐만 붙잡으므로 순환을 만들지 않는다.
해법은 캡처 목록에서 참조를 약하게 잡는 것이다.
onUpdate = { [weak self] in
guard let self else { return }
print(self.title)
}[weak self]는 클로저가 self를 약한 참조로 캡처하게 한다. 카운트를 안 올리므로 순환이 생기지 않는다. weak self는 옵셔널이 되므로 안에서 guard let self로 풀어 쓴다. @escaping 클로저 안에서 self를 쓰면 일단 [weak self]를 의심하는 습관이 이때 들었다.
🟣 순환참조를 눈으로 찾기
순환참조는 크래시가 아니라 조용한 누수라 코드만 봐서는 놓치기 쉽다. 그래서 디버깅 도구로 확인하는 습관이 중요했다. 입문기에 익힌 세 가지가 지금도 기본이다.
가장 단순한 건 deinit에 로그를 심는 것이다. 화면을 닫았는데 deinit 로그가 안 찍히면 그 객체가 안 죽은 것이고, 순환참조를 가장 먼저 의심한다. 위 예제들에서 deinit을 넣어 둔 이유가 이것이다.
코드 실행을 멈추고 상태를 들여다볼 때는 LLDB를 쓴다. Xcode에서 줄 옆을 눌러 breakpoint를 걸면 그 지점에서 실행이 멈춘다. 멈춘 상태에서 콘솔에 po(print object) 명령으로 변수를 찍어 보면 객체의 현재 내용을 볼 수 있다.
(lldb) po viewModel
(lldb) po viewModel.onUpdatepo로 클로저가 여전히 붙어 있는지, 프로퍼티가 예상대로인지 확인한다. 눈으로 참조 관계를 통째로 보고 싶을 때는 Xcode의 Memory Graph Debugger를 켠다. 실행 중 메모리 그래프를 떠서 어떤 객체가 어떤 객체를 붙잡고 있는지 화살표로 보여주는데, 서로를 가리키는 순환이 있으면 그 고리가 그림으로 드러난다. 머릿속으로 그리던 참조 그림을 도구가 대신 그려 주니 순환을 훨씬 빨리 찾았다.
🟣 정리
- ARC는 클래스 인스턴스를 가리키는 강한 참조의 수를 세고, 0이 되면 자동으로 해제한다.
deinit은 해제 순간 한 번 불리므로 카운트가 0이 됐는지 확인하는 창이 된다. - 아무것도 안 붙인 참조는 전부 강한 참조다. 두 인스턴스가 서로를 강하게 가리키면 카운트가 0이 안 되어 메모리가 샌다. 이것이 순환참조다.
weak과unowned는 카운트를 올리지 않아 순환을 끊는다. 대상이 나보다 먼저 죽을 수 있으면weak(자동nil), 절대 아니면unowned다. 확신이 없으면weak이 안전하다.- 클로저가
self를 강하게 캡처하고self가 그 클로저를 붙잡으면 순환이 된다. 특히@escaping클로저에서 잘 생기며,[weak self]캡처로 끊는다. - 순환은 조용히 새므로
deinit로그, LLDB의po와 breakpoint, Memory Graph Debugger로 확인한다. 면접에서 "메모리 누수를 어떻게 찾느냐"는 이 도구들과deinit확인으로 답하면 구체적이다.
weak 참조는 대상이 사라지면 nil이 된다고 했다. 값이 있을 수도 없을 수도 있는 이 상태를 Swift는 옵셔널이라는 타입으로 다룬다. nil이 왜 별도의 타입까지 만들어 특별 대우를 받는지가 마지막 장이다.