- 메모리 릭은 왜 발생하나요? 어떻게 찾나요?
[weak self]는 언제 필요하고 언제 불필요한가요?- ARC가 있는데 왜 릭이 나나요? GC였다면 안 났을까요?
[weak self]는 self가 그 클로저를 저장할 때만 필요하다. 무조건 붙이는 건 오히려 버그를 만든다unowned는 그 검사를 생략한다30초 답변 🔥 먼저 이 문장
현장에서 가장 많이 보는 셋은 ① 클로저의
self 강한 캡처, ② delegate를 strong으로 잡은 것, ③ Timer·URLSession처럼 시스템이 나를 붙잡는 경우입니다.다만 메모리가 는다고 다 릭은 아닙니다. 도달은 가능한데 아무도 안 쓰는 abandoned memory는 Leaks 도구에 잡히지도 않습니다. 그래서 진단 순서가 중요합니다.
L1개념 — 원인 카탈로그와 코드
| # | 원인 | 왜 생기나 |
|---|---|---|
| 1 | 클로저가 self 강한 캡처 + self가 그 클로저를 저장 | A → 클로저 → A 순환 |
| 2 | delegate를 strong var로 선언 | 부모 ↔ 자식 순환 |
| 3 | 부모 ↔ 자식 양방향 strong | 트리에 역방향 강참조 |
| 4 | Timer.scheduledTimer(target:selector:repeats:) | RunLoop가 Timer를, Timer가 target을 강하게 잡는다 |
| 5 | URLSession(configuration:delegate:delegateQueue:) | 세션이 delegate를 강하게 잡는다 — 명시적 invalidate 필요 |
| 6 | addObserver(forName:object:queue:using:) 블록형 | 반환 토큰을 보관했다 removeObserver 필요 + 블록의 self 캡처 |
| 7 | CADisplayLink | Timer와 같은 구조 — invalidate() 필수 |
| 8 | Coordinator / 화면 전환 객체 | 부모가 자식을 잡고 자식이 부모를 잡음 |
| 9 | unowned인데 대상이 먼저 죽음 | 릭이 아니라 크래시 — 구분해서 말할 것 |
// ❌ self가 클로저를 저장하고, 클로저가 self를 강하게 잡는다
final class ViewModel {
var onUpdate: (() -> Void)?
func bind() {
onUpdate = { self.reload() } // self ↔ onUpdate 순환
}
func reload() { }
deinit { print("deinit") } // 절대 안 불린다
}
// ✅ 캡처 리스트로 끊는다
onUpdate = { [weak self] in
guard let self else { return }
self.reload()
}
// ✅ 필요한 것만 캡처하는 것도 방법 — self를 아예 안 잡는다
let logger = self.logger
onUpdate = { logger.log("updated") } // self가 캡처되지 않는다// ❌ strong delegate
protocol LoaderDelegate { func didFinish() }
final class Loader {
var delegate: LoaderDelegate? // strong! 부모를 잡는다
}
// ✅ AnyObject 제약 + weak
protocol LoaderDelegate: AnyObject { func didFinish() }
final class Loader {
weak var delegate: LoaderDelegate? // weak은 클래스 타입에만 가능
}
// AnyObject 제약이 필요한 이유: 값 타입은 참조 카운팅 대상이 아니라
// weak을 걸 수 없다. 그래서 컴파일러가 클래스 전용임을 요구한다.// ❌ target-action 형은 Timer가 target을 강하게 잡는다
timer = Timer.scheduledTimer(timeInterval: 1, target: self,
selector: #selector(tick),
userInfo: nil, repeats: true)
// RunLoop → Timer → self (self가 timer를 들고 있으면 순환까지 완성)
// ✅ 블록 API + weak self
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
// ⚠️ 하지만 weak self 만으로는 부족하다!
// RunLoop가 Timer를 계속 잡고 있으므로 타이머는 영원히 발화한다.
// self가 죽어도 self?.tick() 이 무시될 뿐, 타이머는 계속 돈다.
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
timer?.invalidate() // ✅ 반드시 명시적으로 무효화
timer = nil
}
// 같은 구조: CADisplayLink, DispatchSourceTimer// ❌ 세션이 delegate를 강하게 잡는다 — 앱 종료까지 유지된다
final class Downloader: NSObject, URLSessionDelegate {
private lazy var session = URLSession(configuration: .default,
delegate: self, // strong!
delegateQueue: nil)
deinit { print("deinit") } // 안 불린다
}
// ✅ 명시적으로 무효화해야 delegate를 놓는다
final class Downloader: NSObject, URLSessionDelegate {
private lazy var session = URLSession(configuration: .default,
delegate: self, delegateQueue: nil)
func finish() {
session.finishTasksAndInvalidate() // 진행 중 작업은 끝내고 해제
// 또는 session.invalidateAndCancel() // 즉시 취소하고 해제
}
}
// ✅ 애초에 delegate가 필요 없으면 shared 나 async API를 쓴다
let (data, _) = try await URLSession.shared.data(from: url) // 릭 걱정 없음// ✅ 셀렉터형: iOS 9+ 부터 옵저버가 죽으면 자동 해제된다
NotificationCenter.default.addObserver(
self, selector: #selector(handle), name: .foo, object: nil)
// ⚠️ 블록형: 반환 토큰을 보관했다가 직접 제거해야 한다
private var token: NSObjectProtocol?
token = NotificationCenter.default.addObserver(
forName: .foo, object: nil, queue: .main
) { [weak self] _ in // ← self 캡처도 주의
self?.handle()
}
deinit {
if let token { NotificationCenter.default.removeObserver(token) }
}
// ✅ 더 나은 대안 — 수명이 구조적으로 관리된다
for await _ in NotificationCenter.default.notifications(named: .foo) {
handle()
} // Task가 취소되면 자동으로 정리된다L2설계 — [weak self]를 언제 쓰지 않는가
여기서 실력이 갈립니다. weak self는 공짜가 아닙니다 — 옵셔널 언래핑 코드가 늘고, 정말로 실행돼야 할 작업이 조용히 취소될 수 있습니다.
// 질문: "이 클로저를 self(또는 self가 소유한 것)가 저장하는가?"
// 저장하면 weak, 아니면 불필요.
// ───── ① non-escaping — 순환이 원리적으로 불가능 ─────
let doubled = numbers.map { self.transform($0) } // ✅ weak 불필요
items.forEach { self.process($0) } // ✅ weak 불필요
// 함수가 반환되기 전에 끝나므로 self를 붙잡은 채 남을 수 없다
// ───── ② 한 번 실행되고 해제되는 escaping — 순환이 아니다 ─────
DispatchQueue.main.async {
self.updateUI() // ✅ weak 불필요
} // 실행 후 클로저가 해제되며 self도 놓는다. 잠깐 수명이 연장될 뿐이다
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0 // ✅ weak 불필요
}
// ───── ③ self가 저장하는 escaping — 여기가 진짜 위험 ─────
final class VM {
var handler: (() -> Void)?
func bind() {
handler = { self.reload() } // ❌ weak 필요!
}
}
// ───── ④ weak을 쓰면 오히려 버그가 되는 경우 ─────
func saveAndClose() {
api.upload(data) { [weak self] result in // ⚠️ 위험한 선택
self?.markSaved() // 화면이 닫히면 저장 기록이 안 남는다
}
}
// ✅ 끝까지 실행돼야 하는 작업은 self를 잡거나, self와 무관한 객체가 담당한다
func saveAndClose() {
let store = self.store // self 대신 필요한 것만
api.upload(data) { result in store.markSaved(result) }
}[weak self] 안의 미묘한 함정// ❌ 각 self? 마다 따로 검사 — 중간에 self가 죽으면 절반만 실행된다
api.load { [weak self] data in
self?.items = data
self?.tableView.reloadData() // 여기서 self가 nil일 수도 있다
self?.updateBadge() // 상태가 어긋난다
}
// ✅ 한 번에 확정하고 진행 — 실행하면 끝까지 실행한다
api.load { [weak self] data in
guard let self else { return }
self.items = data
self.tableView.reloadData()
self.updateBadge()
}
// guard let self 이후로는 강한 참조라 이 블록이 끝날 때까지 self가 산다weak vs unowned
// weak : 대상이 죽으면 자동으로 nil. Optional. 안전. side table 비용 약간
// unowned : Optional 아님, 더 빠름. 대상이 먼저 죽으면 💥 크래시
// ✅ unowned가 안전한 전형 — 자식이 부모보다 오래 살 수 없는 구조
final class Customer {
var card: CreditCard?
deinit { print("Customer 해제") }
}
final class CreditCard {
unowned let owner: Customer // 카드는 소유자 없이 존재할 수 없다
init(owner: Customer) { self.owner = owner }
}
// ❌ unowned가 위험한 전형 — 비동기 콜백
api.load { [unowned self] data in // 응답 오기 전에 화면을 닫으면 💥
self.items = data
}
// 규칙: 비동기·콜백·수명이 불확실 → weak
// 구조적으로 부모가 더 오래 삼이 보장 → unowned (그리고 주석으로 근거)L3구현 — weak은 어떻게 자동으로 nil이 되는가
객체가 죽을 때 그 객체를 가리키던 weak 참조들이 어떻게 전부 nil이 되는지가 이 층의 질문입니다. 답은 side table입니다.
final class Node {
let id: Int
init(id: Int) { self.id = id }
deinit { print("deinit \(id)") }
}
var strongRef: Node? = Node(id: 1)
weak var weakRef = strongRef
strongRef = nil // "deinit 1" 출력 — deinit은 여기서 실행된다
print(weakRef == nil) // true — weak 접근은 nil
// 하지만 weakRef 변수가 스코프에 살아 있는 동안
// side table은 아직 남아 있다 (weak count > 0).
// → deinit 실행 시점 ≠ 메모리 완전 반환 시점
// unowned 는 이 검사를 생략하기 때문에 더 빠르지만,
// 죽은 객체에 접근하면 검사 없이 진행 → 크래시(또는 그보다 나쁜 것)// ── ① deinit 로그: 가장 싸고 가장 확실한 1차 판별 ──
deinit { print("✅ \(type(of: self)) deinit") }
// 화면을 닫았는데 안 찍히면 → 붙잡혀 있다
// ⚠️ 단, "안 찍힘 = 릭"은 아니다. 네비게이션 스택이나 캐시에
// 정당하게 남아 있을 수도 있다. 다음 단계로 확인한다.
// ── ② Memory Graph Debugger: 누가 붙잡고 있는지 그림으로 ──
// Xcode 디버그 중 [Debug Memory Graph] 버튼
// · 왼쪽 트리에서 해당 클래스 검색
// · 인스턴스를 고르면 오른쪽에 "들어오는 참조" 화살표가 전부 보인다
// · 보라색 ! 배지 = 런타임이 릭으로 판정한 것
// 순환 참조를 찾는 데는 이게 가장 강력하다.
// ── ③ Instruments → Leaks: 도달 불가 메모리 ──
// ⚠️ 순환 참조는 "서로 도달 가능"이라 Leaks가 못 잡는 경우가 있다
// ── ④ Instruments → Allocations + Mark Generation ──
// 화면 진입 → Mark Generation → 화면 이탈 → Mark Generation
// 이걸 3~5회 반복했을 때 세대별 잔존 바이트가 계속 늘면 문제.
// ★ abandoned memory는 여기서만 잡힌다.
// ── ⑤ 회귀 방지: 릭 확인을 테스트로 ──
func testNoLeak() {
weak var weakVC: FeedViewController?
autoreleasepool {
let vc = FeedViewController()
vc.loadViewIfNeeded()
weakVC = vc
}
XCTAssertNil(weakVC, "FeedViewController가 해제되지 않았습니다")
}L4CS 근본 — 지역 정보만으로는 사이클을 판정할 수 없다
① 메모리 관리의 진짜 질문: "도달 가능한가"
자동 메모리 관리의 목표는 "프로그램이 앞으로 이 객체를 쓸 것인가"를 아는 것입니다. 그런데 이건 Rice의 정리에 걸려 결정 불가능합니다. 그래서 모든 실용적 방식은 더 쉬운 질문으로 근사합니다 — "루트에서 도달 가능한가?"
② 두 접근의 근본 차이 — 지역 vs 전역
| 참조 카운팅 (ARC) | 추적 GC | |
|---|---|---|
| 판정 방식 | 지역 — 각 객체의 들어오는 참조 수 | 전역 — 루트에서 그래프를 순회 |
| 알고리즘 | 증감 카운트 | mark & sweep, 삼색 마킹, 세대별 수집 |
| 사이클 | 순수 RC는 못 잡는다 (부분 추적을 덧붙이면 가능 — CPython) | 잡는다 — 루트에서 도달 안 되면 쓰레기 |
| 시점 | 즉시·결정적 | 비결정적 |
| 정지 | 전역 정지 없음 | 수집 시 정지 가능 |
| 비용 위치 | 증감할 때마다 분산 | 수집할 때 몰림 |
순수(naive) 참조 카운팅은 사이클을 수집할 수 없습니다. 카운트는 "나를 가리키는 화살표 수"라는 지역 정보인데, 사이클 안의 노드는 바깥에서 도달할 수 없어도 서로가 서로를 가리켜 카운트가 0이 되지 않기 때문입니다.
못 잡는 것은 순수 참조 카운팅입니다. 참조 카운팅에 부분 추적을 덧붙이면 사이클도 수집할 수 있습니다. 고전 알고리즘이 trial deletion(시험 삭제)이고, Bacon–Rajan의 Concurrent Cycle Collection in Reference Counted Systems(ECOOP 2001)가 대표 논문입니다. 실제로 CPython이 참조 카운팅 + 사이클 수집기를 함께 씁니다.
원리는 이렇습니다 — 카운트가 감소했지만 0이 되지 않은 객체를 후보로 모아 두고, 그 후보에서 닿는 부분그래프만 골라 내부 참조를 가상으로 지워 봅니다. 그래도 카운트가 0이 되면 외부에서 도달 불가라는 뜻이니 사이클 쓰레기입니다. 전체 그래프를 순회하지 않는다는 점에서 추적 GC와도 다릅니다.
그래서 정확한 진술은 "참조 카운팅은 사이클을 못 잡는다"가 아니라 "지역 정보만으로는 못 잡으므로, 잡으려면 어떤 형태로든 도달 가능성 검사를 추가해야 한다"입니다.
Swift는 그 추가 장치를 넣지 않기로 선택했고(비결정적 지연과 추가 메모리를 피하려고), 그래서 weak/unowned가 언어 기능으로 존재합니다.
Swift / ObjC : 순환을 개발자가 끊는다 (weak / unowned)
→ 사이클 수집기를 "넣지 않기로 선택"했다. 성능·결정성을 산 것.
Python : 참조 카운팅 + 사이클 수집기 병행 ← "RC는 사이클을 못 잡는다"의 반례
→ gc 모듈이 trial deletion으로 순환을 수집한다 (비결정적 요소 도입)
C++ shared_ptr: weak_ptr 로 개발자가 끊는다 (Swift와 같은 철학)
Rust : Rc/RefCell 순환 가능 → Weak 으로 끊는다
단, 소유권 모델 덕에 애초에 Rc를 쓸 일이 적다
Java / C# : 추적 GC — 사이클을 자동으로 수집
대신 정지 시간과 메모리 여유가 필요하다
모바일 UI의 요구사항 때문입니다. 사이클 수집기를 넣으려면 객체 그래프를 주기적으로 순회해야 하고, 그건 ① 예측 불가능한 지연, ② 순회 중 그래프 변경을 막기 위한 동기화, ③ 추가 메모리를 요구합니다.
16.7ms 프레임 예산과 Jetsam이 도는 빡빡한 메모리 환경에서는 그 대가가 큽니다.
그래서 Swift는 "사이클은 드물고, 드문 것은 개발자가 처리하는 게 낫다"는 쪽을 택했습니다. weak/unowned가 언어 기능인 이유죠.
③ 릭이 아닌데 메모리가 느는 경우 — 꼭 같이 말할 것
// ── ① 진짜 릭 (leaked) — 도달 불가인데 해제 안 됨 ──
// 원인: 순환 참조, C 메모리 free 누락
// 도구: Leaks, Memory Graph Debugger
// ── ② abandoned memory — 도달 가능한데 아무도 안 씀 ──
// ⚠️ Leaks 도구에 안 잡힌다. 참조가 살아 있으니까.
final class ImageCache {
static let shared = ImageCache()
private var cache: [URL: UIImage] = [:] // 무제한 증가
func store(_ img: UIImage, for url: URL) { cache[url] = img }
}
// ✅ NSCache는 메모리 압박 시 자동 방출된다
final class BetterCache {
private let cache = NSCache<NSURL, UIImage>()
init() {
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
}
}
// ── ③ 정상적인 큰 사용량 — 릭도 abandoned도 아님 ──
// 원본 이미지를 그대로 들고 있는 경우 등
// 4000×3000 이미지 하나 = 4000*3000*4 ≈ 48MB (디코딩된 비트맵)
// ✅ 다운샘플링으로 필요한 크기만 (11번 참조)④ Jetsam — iOS에는 스왑이 없다
// 데스크톱 (Linux/macOS)
// 물리 메모리 부족 → 페이지를 디스크로 스왑 → 느려지지만 계속 동작
// 그래도 부족하면 → OOM Killer가 프로세스 종료 (최후 수단)
// iOS — Jetsam (XNU의 memorystatus 서브시스템)
// ⚠️ 앱 메모리에 대한 스왑이 없다 (플래시 수명·전력 때문)
// → dirty 페이지를 회수할 방법이 없으니 "종료"가 1차 수단이 된다
// → "느려지는 단계" 없이 바로 죽는다
//
// 그리고 축이 두 개다 — 이걸 구분해서 말하면 확실히 다르다:
//
// ① 우선순위 밴드 (전역 메모리 부족 시)
// Foreground 앱 ← 가장 마지막에 종료 (사용자가 보는 중)
// Suspended 백그라운드
// 백그라운드 데몬 · 유휴 ← 여기부터 죽는다
//
// ② 앱별 메모리 상한 (per-process limit) ★ 실무에서 더 자주 겪는 쪽
// 전역 메모리가 넉넉해도, 내 앱이 상한을 넘으면 내 앱만 콕 집어 종료된다.
// 즉 "기기 메모리가 남았는데 왜 죽지?"의 답이 이것이다.
//
// → 리눅스 OOM killer는 반응적·전역적(사후에 oom_score로 희생자 선정)이고,
// Jetsam은 선제적·협조적·우선순위 기반이다. 성격이 다르다.
// 앱이 받는 신호 — 두 계층이 있다
// ① UIKit 수준 (거칠다)
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
imageCache.removeAllObjects() // 다시 만들 수 있는 것부터 버린다
}
// ② DispatchSource 수준 (normal / warning / critical 로 세밀하다)
let src = DispatchSource.makeMemoryPressureSource(
eventMask: [.warning, .critical], queue: .main)
src.setEventHandler {
switch src.data {
case .warning: imageCache.countLimit = 20 // 줄이기
case .critical: imageCache.removeAllObjects() // 전부 비우기
default: break
}
}
src.resume()
// 여기서 footprint를 줄이면 Jetsam을 피할 수 있다 — 경고는 "기회"다
// SwiftUI / 일반
NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil, queue: .main) { _ in purgeCaches() }
// 우선순위: 포그라운드 앱이 가장 안전, 백그라운드 앱이 먼저 죽는다
// → 그래서 "백그라운드 갔다 오면 앱이 재시작되는" 현상이 생긴다
// → 상태 복원(state restoration)이 필요한 이유
// 크래시 리포트에서 구분하는 법
// EXC_RESOURCE / JETSAM → 메모리 초과로 종료됨 (일반 크래시가 아니다)결론: iOS에서는 "릭이 없어도 죽을 수 있습니다." 그래서 메모리 관리는 릭 사냥만이 아니라 총 사용량 관리까지 포함합니다.
CS 정본으로 더 내려가기
이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.
경험으로 말하기
❌ "순환 참조가 있어서 weak self로 해결했습니다."
✅ "채팅 화면을 반복해 드나들면 메모리가 계단식으로 올라갔습니다. 먼저 deinit에 로그를 넣었는데 안 찍혔습니다.
Memory Graph Debugger로 보니 URLSession이 delegate(=ViewController)를 잡고 있었어요 — 클로저가 아니라 세션이 문서대로 delegate를 강하게 잡는 케이스였습니다.
finishTasksAndInvalidate()를 붙여 해결했습니다.
그런데 그 뒤에도 메모리가 조금씩 늘었습니다. Leaks에는 아무것도 안 잡혔고, Allocations의 Mark Generation으로 보니 이미지 캐시가 무제한으로 커지고 있었습니다.
이건 릭이 아니라 abandoned memory라 도구가 다르더군요. NSCache로 바꾸고 totalCostLimit을 걸었습니다.
마지막으로 회귀 테스트를 넣었습니다 — weak var로 참조를 잡고 autoreleasepool 밖에서 nil인지 확인하는 방식입니다."
핵심: 두 문제가 다른 종류였고 도구도 달랐다는 것을 말하면, 도구를 외운 게 아니라 구분할 줄 안다가 드러납니다.
꼬리 질문 대비
L2 [weak self]를 무조건 붙이면 안 되나요?
안 됩니다. 세 가지 실제 비용이 있습니다.
- ① 조용한 실패 — 가장 위험합니다. 저장·로그 전송·정리처럼 끝까지 실행돼야 하는 작업에
weak self를 붙이면, 화면이 닫혔을 때 아무 에러 없이 그냥 안 됩니다. 디버깅이 매우 어렵습니다. - ② 코드 노이즈 —
guard let self else { return }가 도배되면, 정작 진짜 필요한 곳이 눈에 안 띕니다. "다 붙어 있으니 이것도 그냥 붙였겠지"가 되죠. - ③ 미미한 런타임 비용 — weak 접근은 side table을 거칩니다. 대부분 무시할 수준이지만 핫 루프에서는 의미가 있을 수 있습니다.
판별은 한 문장입니다: "이 클로저를 self(또는 self가 소유한 것)가 저장하는가?" 저장하면 weak, 아니면 불필요합니다. DispatchQueue.main.async나 UIView.animate는 저장하지 않으므로 불필요합니다.
L3 deinit이 안 불리면 무조건 릭인가요?
아닙니다. 이걸 구분할 줄 아는 게 중요합니다. 정당한 이유가 여럿 있습니다.
- 네비게이션 스택 — push한 화면은 pop하기 전까지 살아 있는 게 정상입니다.
- 부모 컨테이너가 보관 —
UITabBarController는 탭을 전환해도 자식을 유지합니다. - 의도적 캐싱 — 재진입 성능을 위해 일부러 들고 있는 경우.
- autorelease pool 타이밍 — ObjC 상호운용 구간에서는 pool이 비워질 때까지 늦어질 수 있습니다. 그래서 릭 테스트에
autoreleasepool을 감싸는 것입니다.
그래서 확인 순서가 있습니다. ① deinit이 안 찍힌다 → ② Memory Graph Debugger로 "누가 붙잡고 있는지"를 본다 → ③ 그 참조가 정당한지 판단한다.
2단계를 건너뛰고 weak을 붙이면, 정당하게 살아 있어야 할 객체가 조기에 죽어서 더 이상한 버그가 됩니다.
L4 GC 언어였다면 이런 릭이 안 났을까요?
순환 참조로 인한 릭은 안 났을 겁니다. 추적 GC는 루트에서 도달 가능성을 보므로, 사이클이 있어도 바깥에서 도달 불가면 수집합니다. 이건 순수 참조 카운팅이 지역 정보만으로는 못 하는 일입니다(부분 추적을 덧붙이면 가능하고, Swift는 그걸 안 넣기로 택했습니다).
그런데 "메모리 문제가 없어진다"는 아닙니다. GC 언어에도 릭이 있습니다 — 다만 형태가 다릅니다.
- 의도치 않은 도달 가능성 — 정적 컬렉션에 계속 넣고 안 지우는 것. GC 입장에서는 "쓰고 있는 객체"라 수집 대상이 아닙니다. Java의 대표적 릭 패턴이죠.
- 리스너 미해제 — 옵저버를 등록하고 안 지우면 발행자가 계속 붙잡습니다. iOS의 NotificationCenter 문제와 구조가 같습니다.
- 즉 abandoned memory는 GC로 해결되지 않습니다. 이건 알고리즘 문제가 아니라 설계 문제거든요.
모바일에서 GC의 대가가 왜 큰지(프레임 예산·메모리 여유·결정적 해제)는 02번에서 이미 다뤘으니 여기서는 반복하지 않겠습니다. 이 페이지의 논점은 그 트레이드오프가 아니라 "왜 릭이 나는가"이고, 그 답은 위의 지역 정보 대 전역 정보입니다.
정리: "ARC는 사이클을 개발자에게 떠넘기는 대신 결정성과 낮은 지연을 삽니다. 트레이드오프이지 열등한 선택이 아닙니다. 그리고 어느 쪽이든 abandoned memory는 개발자가 설계로 막아야 합니다."
쉽게 이해하기
ARC를 "이 물건 내가 쓰는 중"이라고 손을 드는 규칙이라고 생각해 봅시다. 아무도 손을 안 들면 그 물건은 버려집니다. 간단하죠.
순환 참조는 이렇습니다. A와 B 둘만 남았는데, A가 "B를 쓰는 중"이라고 손을 들고 B도 "A를 쓰는 중"이라고 손을 듭니다. 바깥에서 보면 둘 다 아무도 안 쓰는데, 서로가 서로에 대해 손을 들고 있으니 영원히 안 버려집니다.
여기가 핵심입니다. 손을 세는 방식은 "내 물건에 손이 몇 개 올라갔나"만 봅니다. 이건 그 물건 주변만 보는 정보예요. 그런데 "정말 아무도 안 쓰나"는 방 전체를 봐야 알 수 있습니다. 주변만 보고는 방 전체를 알 수 없다 — 이게 참조 카운팅의 한계이고, 이건 성능 문제가 아니라 원리적인 것입니다.
Java 같은 언어는 가끔 방 전체를 순찰합니다("입구에서 시작해서 손 잡고 이어진 것만 남기고 나머지는 다 버려"). 그러면 A-B 짝은 입구와 안 이어져 있으니 같이 버려집니다. 대신 순찰 도는 동안 모두 잠깐 멈춰야 하죠. 게임이나 스크롤처럼 1초에 60번 그림을 그려야 하는 앱에서는 이 멈춤이 치명적입니다. 그래서 iOS는 순찰을 안 하고 "손 드는 규칙"만 씁니다 — 대신 서로 손 들고 버티는 상황은 사람이 풀어야 하고, 그 도구가 weak입니다.
마지막으로 꼭 알아야 할 게 있습니다. "물건이 안 버려지는 것"만 문제가 아닙니다. 실제로 쓰고 있다고 우기면서 창고에 계속 쌓아 두는 것도 똑같이 위험해요(캐시 무한 증가). 이건 순찰을 돌아도 안 치워집니다 — 주인이 "쓸 거야"라고 하니까요. 그리고 iOS에는 창고가 넘칠 때 짐을 옮겨 둘 지하실이 없습니다(스왑 없음). 넘치면 그냥 가게 문을 닫아 버립니다(Jetsam). 그래서 "릭이 없다"가 "안전하다"를 뜻하지 않습니다.
릭은 객체 그래프에 생긴 사이클이고, 참조 카운팅이 이를 못 잡는 것은 지역 정보로 전역 성질을 판정할 수 없기 때문이다. 그리고 iOS에서는 릭이 없어도 총 사용량으로 죽을 수 있다.
설계 근거 · 1차 자료
이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.
- TSPL — 순환 참조를 개발자가 끊도록 설계한 이유와 weak·unowned의 계약
- SE-0306 — 격리로 공유를 줄이는 접근 — 릭 예방의 설계적 대안