← Mobile Foundation 인터뷰 9 / 13

09 · 메모리 릭 원인 및 대응

순환 참조 → weak/unowned 판단 → side table → 도달 가능성과 참조 카운팅의 한계
진행률
0 / 0 완료
면접에서 나오는 형태
  1. 메모리 릭은 발생하나요? 어떻게 찾나요?
  2. [weak self]언제 필요하고 언제 불필요한가요?
  3. ARC가 있는데 릭이 나나요? GC였다면 안 났을까요?
L1 개념
ARC에서 릭 = 참조 카운트가 0이 되지 않는 것. 원인은 결국 서로 강하게 붙잡고 놓지 않는 것
L2 설계
[weak self]self가 그 클로저를 저장할 때만 필요하다. 무조건 붙이는 건 오히려 버그를 만든다
L3 구현
side table에 weak 참조를 모아 두고, 객체가 죽으면 전부 nil로 만든다. unowned는 그 검사를 생략한다
L4 CS
도달 가능성 · 그래프 사이클 · 지역 정보의 한계 — 릭이 아닌 메모리 증가와 Jetsam

30초 답변 🔥 먼저 이 문장

Swift는 ARC라서 참조 카운트가 0이 되지 않는 상황이 곧 릭입니다. 원인은 결국 하나예요 — 누군가를 강하게 붙잡은 채 서로를 놓지 않는 것.

현장에서 가장 많이 보는 셋은 ① 클로저의 self 강한 캡처, ② delegate를 strong으로 잡은 것, ③ Timer·URLSession처럼 시스템이 나를 붙잡는 경우입니다.

다만 메모리가 는다고 다 릭은 아닙니다. 도달은 가능한데 아무도 안 쓰는 abandoned memory는 Leaks 도구에 잡히지도 않습니다. 그래서 진단 순서가 중요합니다.

L1개념 — 원인 카탈로그와 코드

❌ 강한 순환 — 카운트가 0이 안 된다 ViewModel refCount 1 Closure refCount 1 strong strong (self 캡처) 외부 참조가 모두 사라져도 서로가 서로를 1로 유지 → 영원히 해제 안 됨 deinit이 절대 불리지 않는다 ✅ 한쪽을 weak으로 — 사이클이 끊긴다 ViewModel refCount 0 가능 Closure strong weak (카운트 안 올림) 외부 참조가 사라지면 ViewModel → 0 → 해제 → 클로저도 함께 weak 참조는 자동으로 nil이 된다
릭의 정체는 객체 그래프에 사이클이 생긴 것이다. 그리고 사이클 안의 노드들은 바깥에서 도달할 수 없어도 서로의 카운트를 1 이상으로 유지한다.
#원인왜 생기나
1클로저가 self 강한 캡처 + self가 그 클로저를 저장A → 클로저 → A 순환
2delegate를 strong var로 선언부모 ↔ 자식 순환
3부모 ↔ 자식 양방향 strong트리에 역방향 강참조
4Timer.scheduledTimer(target:selector:repeats:)RunLoop가 Timer를, Timer가 target을 강하게 잡는다
5URLSession(configuration:delegate:delegateQueue:)세션이 delegate를 강하게 잡는다 — 명시적 invalidate 필요
6addObserver(forName:object:queue:using:) 블록형반환 토큰을 보관했다 removeObserver 필요 + 블록의 self 캡처
7CADisplayLinkTimer와 같은 구조 — invalidate() 필수
8Coordinator / 화면 전환 객체부모가 자식을 잡고 자식이 부모를 잡음
9unowned인데 대상이 먼저 죽음릭이 아니라 크래시 — 구분해서 말할 것
① 클로저 순환 — 가장 흔하다
// ❌ 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가 캡처되지 않는다
② delegate — 프로토콜을 AnyObject로 제약해야 weak을 쓸 수 있다
// ❌ 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을 걸 수 없다. 그래서 컴파일러가 클래스 전용임을 요구한다.
③ Timer — self가 아니라 RunLoop가 문제다
// ❌ 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
④ URLSession — 문서에 명시된 강한 참조
// ❌ 세션이 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)   // 릭 걱정 없음
⑤ NotificationCenter — 블록형만 토큰 관리가 필요하다
// ✅ 셀렉터형: 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] 안의 미묘한 함정
guard let 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

수명 순서를 증명할 수 있을 때만 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입니다.

weak 참조가 붙으면 side table이 생긴다 힙 객체 metadata 포인터 refCount (strong) 또는 side table 포인터 저장 프로퍼티들 Side Table strong count unowned count weak count + 객체 포인터 weak Aweak Bweak C 해제 순서 ① strong count → 0 : deinit 실행 + 저장 프로퍼티 해제 (객체 메모리는 아직 남을 수 있음) ② weak/unowned count → 0 : side table까지 완전히 해제 — 그 전에 weak 접근은 nil을 돌려준다
weak이 "자동으로 nil이 되는" 마법은 없다. side table을 통해 간접 참조하고, 객체가 죽으면 그 테이블이 "이미 죽었음"을 알려 주는 것이다. 그래서 weak 접근은 unowned보다 약간 비싸다.
strong 해제와 메모리 해제가 다른 시점이라는 증거
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의 정리에 걸려 결정 불가능합니다. 그래서 모든 실용적 방식은 더 쉬운 질문으로 근사합니다 — "루트에서 도달 가능한가?"

객체 그래프 — 루트에서 도달 가능한가? 루트 스택·전역·레지스터 A B ✓ 도달 가능 — 살아 있어야 함 C ✗ 도달 불가 — 쓰레기 refCount 0 → ARC가 해제 문제의 경우 — 사이클 D E 루트에서 도달 불가 = 쓰레기인데, 둘 다 refCount ≥ 1
ARC는 "들어오는 화살표 개수"만 본다. 그건 지역 정보다. "루트에서 도달 가능한가"는 전역 정보라서, 지역 정보만으로는 알 수 없다. 이게 참조 카운팅의 구조적 한계다.

② 두 접근의 근본 차이 — 지역 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 — 사이클을 자동으로 수집
                대신 정지 시간과 메모리 여유가 필요하다
🧠 L4 "왜 Swift는 사이클 수집기를 안 넣었나요?"

모바일 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에는 스왑이 없다

데스크톱 OOM과 다른 점
// 데스크톱 (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 붙였습니다"가 아니라 "어떻게 찾았고 왜 그 방법인가"

"순환 참조가 있어서 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.asyncUIView.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는 "쓴 사람이 바로 치우기"인데, 둘이 서로 "네가 먼저 놔" 하면 영원히 안 치워집니다. GC는 가끔 순찰을 돌며 "아무도 안 쓰는 것"을 치우는 방식이라 그 문제가 없죠. 대신 순찰 도는 동안 잠깐 멈춰야 하고, 무엇보다 창고에 잔뜩 쌓아 두고 안 쓰는 물건은 순찰로도 못 치웁니다 — 주인이 "쓸 거야"라고 하니까요.

쉽게 이해하기

ARC를 "이 물건 내가 쓰는 중"이라고 손을 드는 규칙이라고 생각해 봅시다. 아무도 손을 안 들면 그 물건은 버려집니다. 간단하죠.

순환 참조는 이렇습니다. A와 B 둘만 남았는데, A가 "B를 쓰는 중"이라고 손을 들고 B도 "A를 쓰는 중"이라고 손을 듭니다. 바깥에서 보면 둘 다 아무도 안 쓰는데, 서로가 서로에 대해 손을 들고 있으니 영원히 안 버려집니다.

여기가 핵심입니다. 손을 세는 방식은 "내 물건에 손이 몇 개 올라갔나"만 봅니다. 이건 그 물건 주변만 보는 정보예요. 그런데 "정말 아무도 안 쓰나"는 방 전체를 봐야 알 수 있습니다. 주변만 보고는 방 전체를 알 수 없다 — 이게 참조 카운팅의 한계이고, 이건 성능 문제가 아니라 원리적인 것입니다.

Java 같은 언어는 가끔 방 전체를 순찰합니다("입구에서 시작해서 손 잡고 이어진 것만 남기고 나머지는 다 버려"). 그러면 A-B 짝은 입구와 안 이어져 있으니 같이 버려집니다. 대신 순찰 도는 동안 모두 잠깐 멈춰야 하죠. 게임이나 스크롤처럼 1초에 60번 그림을 그려야 하는 앱에서는 이 멈춤이 치명적입니다. 그래서 iOS는 순찰을 안 하고 "손 드는 규칙"만 씁니다 — 대신 서로 손 들고 버티는 상황은 사람이 풀어야 하고, 그 도구가 weak입니다.

마지막으로 꼭 알아야 할 게 있습니다. "물건이 안 버려지는 것"만 문제가 아닙니다. 실제로 쓰고 있다고 우기면서 창고에 계속 쌓아 두는 것도 똑같이 위험해요(캐시 무한 증가). 이건 순찰을 돌아도 안 치워집니다 — 주인이 "쓸 거야"라고 하니까요. 그리고 iOS에는 창고가 넘칠 때 짐을 옮겨 둘 지하실이 없습니다(스왑 없음). 넘치면 그냥 가게 문을 닫아 버립니다(Jetsam). 그래서 "릭이 없다"가 "안전하다"를 뜻하지 않습니다.

🔑 한 문장

릭은 객체 그래프에 생긴 사이클이고, 참조 카운팅이 이를 못 잡는 것은 지역 정보로 전역 성질을 판정할 수 없기 때문이다. 그리고 iOS에서는 릭이 없어도 총 사용량으로 죽을 수 있다.

설계 근거 · 1차 자료

이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.

📌 각 자료가 뒷받침하는 것
  • TSPL — 순환 참조를 개발자가 끊도록 설계한 이유와 weak·unowned의 계약
  • SE-0306 — 격리로 공유를 줄이는 접근 — 릭 예방의 설계적 대안