← Swift & Foundation 로드맵 6 / 33

06 · 클로저와 캡처

클로저는 왜 참조 타입인가, escaping이 힙에 무엇을 만드는가, 캡처 리스트는 언제 값을 얼리는가, weak self는 실제로 언제만 필요한가.
진행률
0 / 0 완료

클로저는 Swift에서 가장 자주 쓰이면서도 내부 동작은 가장 자주 오해되는 기능이다. 이 챕터는 클로저가 실제로 무엇으로 표현되는지(참조 타입, 코드 포인터 + 컨텍스트 포인터), escaping 여부가 그 표현을 스택에서 힙으로 어떻게 밀어내는지, 캡처 리스트가 정확히 언제 값을 얼리는지, 그리고 실무 면접에서 가장 자주 틀리는 질문인 "[weak self]가 정말 필요한가"를 미신이 아니라 판별 규칙으로 다룬다.

ARC의 retain/release 자체 — 강한·약한·미소유 참조가 왜 존재하고 iOS 메모리 압박과 어떻게 얽히는지 — 는 iOS CS 로드맵 01장에서 이미 다뤘다. 여기서는 그 개념을 반복하지 않고, 클로저가 캡처한 것이 실제로 힙에 무엇을 만드는지에 집중한다.

Q1. 클로저가 참조 타입이라는 사실이 실제로 어떤 동작 차이를 만드는가?

🔑 30초 답변

클로저를 상수·변수에 대입하는 것은 값을 복사하는 게 아니라 같은 클로저 인스턴스에 대한 참조를 공유하는 것이다. 클로저 값은 실제로 (코드 포인터, 캡처 컨텍스트 포인터) 한 쌍으로 표현되며, 두 변수가 이 쌍을 각각 복사해 들고 있어도 컨텍스트 포인터가 가리키는 힙(혹은 스택) 블록은 하나뿐이다. 그래서 한 변수를 통해 클로저를 호출해 캡처된 값을 바꾸면, 같은 클로저를 대입받은 다른 변수로 호출해도 그 변경이 그대로 보인다.

원리

Swift 공식 문서(TSPL)는 클로저나 함수를 상수·변수에 대입하는 행위를 "그 함수·클로저에 대한 참조를 만드는 것"이라고 명시적으로 규정한다. 즉 두 변수가 같은 클로저를 가리키면 같은 캡처 상태를 공유하는 두 개의 이름이 되는 것이지, 독립된 사본 두 개가 아니다.

내부 동작

클로저 값은 함수 포인터 하나로 끝나지 않는다. 캡처할 게 있는 클로저는 컴파일러가 "코드가 있는 곳"과 "캡처된 환경이 있는 곳"을 가리키는 두 개의 포인터, 즉 2-word짜리 thick function 값으로 만든다(캡처가 전혀 없는 최상위 함수는 컨텍스트 포인터가 필요 없어 1-word thin function으로 끝난다). MemoryLayout.size(ofValue:)로 확인하면 캡처가 있는 클로저 값의 size/stride는 16바이트(포인터 두 개), alignment는 8바이트로 나온다 — 64비트 환경에서 포인터 크기 그대로다.

대입(let counterB = counterA)은 이 16바이트 값 자체를 복사하지만, 그 안에 든 컨텍스트 포인터는 여전히 같은 캡처 블록을 가리킨다. 그 블록이 escaping 클로저처럼 힙에 있다면 대입 시 retain count도 함께 올라간다(Q2). 결과적으로 "클로저를 하나 더 만든 것"이 아니라 "같은 캡처 블록을 가리키는 손잡이를 하나 더 만든 것"에 가깝다.

대입은 복사가 아니라 참조 공유다 let counterA = makeCounter() let counterB = counterA (대입) 동일한 클로저 값 코드 포인터 + 컨텍스트 포인터 캡처 컨텍스트: count
counterB는 counterA의 사본이 아니라 같은 클로저 값(따라서 같은 캡처 컨텍스트)을 가리킨다 — counterB로 호출해도 counterA가 만든 count가 이어서 증가한다.

실험 · 도구

아래를 swift 파일명.swift로 직접 실행해보면 참조 공유가 바로 드러난다.

counterB는 counterA의 사본이 아니라 같은 캡처 컨텍스트를 가리킨다
func makeCounter() -> () -> Int {
    var count = 0
    return {
        count += 1
        return count
    }
}

let counterA = makeCounter()
let counterB = counterA   // 참조 복사 — 같은 캡처 컨텍스트를 공유

print(counterA())  // 1
print(counterB())  // 2  (counterA가 아니라 counterB로 불렀는데도 카운트가 이어진다)
print(counterA())  // 3

클로저 값 자체의 크기도 확인해보라: MemoryLayout.size(ofValue: counterA)는 16을 반환한다(코드 포인터 8바이트 + 컨텍스트 포인터 8바이트). 캡처가 하나도 없는 클로저 리터럴이라도 () -> Void 타입 값 자체의 크기는 여전히 16이다 — 최적화 전 타입 차원에서는 항상 thick으로 표현되기 때문이다.

프로젝트 적용

클로저를 배열·딕셔너리 등에 여러 번 담아 "여러 개의 독립된 핸들러"처럼 쓰고 있다면, 그것들이 정말 서로 다른 캡처 상태를 갖는지 확인하라. 같은 클로저 인스턴스를 여러 곳에 공유해 등록하면 그 안의 캡처된 상태도 함께 공유된다.

클로저 공유가 만드는 의도치 않은 상태 공유
final class BadgeCounter {
    private var handlers: [() -> Void] = []
    private(set) var count = 0

    func addHandler(_ handler: @escaping () -> Void) {
        handlers.append(handler)
    }

    func bump() {
        count += 1
        handlers.forEach { $0() }   // 등록된 모든 클로저가 "같은" self.count를 본다
    }
}
// handlers에 쌓인 클로저들은 서로 다른 인스턴스지만, 전부 같은 self를 참조 캡처하고 있어
// count 하나를 공유해서 본다 — "여러 개를 등록했으니 독립적이겠지"라는 가정이 틀리는 지점.

반대로 "지금 이 순간의 값"을 독립적으로 떼어 넘기고 싶다면, 참조 공유에 기대지 말고 캡처 리스트로 명시적인 스냅샷을 만들어라(자세한 시점 규칙은 Q3).

공유 대신 스냅샷이 필요할 때
func makeSnapshotHandler(current: Int) -> () -> Void {
    // [current]로 캡처하면 이 함수가 호출된 시점의 값이 클로저 안에 고정된다.
    // 캡처 리스트 없이 그냥 클로저를 쓰면 나중에 바뀐 current(있다면)를 보게 될 수 있다.
    { [current] in print("등록 당시 값:", current) }
}
⚠️ 흔한 오해

"클로저를 변수에 대입하면 복사본이 생긴다"는 틀렸다. 클로저는 참조 타입이라 대입은 참조 공유다. 반대로 "그러니까 모든 클로저는 힙에 있다"도 틀렸다 — escaping 여부에 따라 그 참조가 가리키는 블록이 스택일 수도 있다(Q2).

🧒 쉽게 이해하기

클로저는 종이 한 장이 아니라 "같은 사물함 열쇠"를 복사해서 두 사람에게 나눠준 것과 같다. 열쇠를 하나 더 만들었다고 사물함 안 내용물까지 하나 더 생기는 게 아니다. 한 사람이 사물함 안 물건을 바꾸면, 다른 열쇠를 든 사람이 열어봐도 바뀐 물건이 보인다. 비유가 깨지는 곳: 열쇠(non-escaping)가 회의실 밖으로 못 나가게 정해져 있으면, 그 사물함 자체가 회의실 책상 위(스택)에 있을 수도 있다는 점은 이 비유만으로는 설명이 안 된다 — 그건 Q2의 이야기다.

꼬리 질문

non-escaping 클로저도 참조 타입인가? 스택에 있어도 대입하면 참조가 복사되는가?
그렇다. escaping 여부와 "참조 타입이냐"는 별개 축이다. non-escaping 클로저도 여전히 (코드 포인터, 컨텍스트 포인터) 값이고, 대입하면 그 참조가 공유된다. 다만 컴파일러가 그 참조가 함수 스코프를 벗어나지 못한다는 걸 증명할 수 있기 때문에 컨텍스트를 스택에 둘 수 있을 뿐, 참조 의미론 자체는 바뀌지 않는다.
쉽게 말하면 포스트잇도 여러 장 붙여놓고 같은 메모를 가리키게 할 수 있다 — 다만 회의실(함수) 밖으로 못 나갈 뿐이다.
함수(func)도 클로저인가? 클로저와 함수의 관계는?
Swift에서 이름 붙은 함수는 "캡처할 게 없는 특수한 클로저"로 취급된다. 캡처가 없으니 컨텍스트 포인터가 필요 없어(thin function) 함수 포인터 하나(8바이트)로 표현되고, 캡처가 있는 클로저는 thick(코드+컨텍스트, 16바이트)으로 표현된다.
쉽게 말하면 함수는 "아무 짐 없이 나가는 사람", 클로저는 "가방(캡처)까지 챙겨서 나가는 사람"이다. 짐 유무만 다를 뿐 같은 부류다.
클로저 값 두 개가 같은 함수 리터럴에서 왔지만 서로 다른 호출에서 만들어졌으면 같은 컨텍스트를 공유하는가?
아니다. makeCounter()를 두 번 호출하면 각각 독립된 캡처 컨텍스트(별도의 count)를 만든다. 공유는 "같은 클로저 인스턴스"를 참조로 복사했을 때만 일어나지, 같은 코드에서 나온 서로 다른 인스턴스끼리는 무관하다.
쉽게 말하면 같은 레시피(코드)로 빵을 두 번 구우면 서로 다른 개별 빵(컨텍스트)이 나온다. 레시피가 같다고 같은 빵을 나눠 먹는 게 아니다.

Q2. escaping과 non-escaping 클로저의 힙 할당 차이를 SIL(partial_apply [on_stack]) 수준에서

🔑 30초 답변

non-escaping 클로저(기본값)는 그 함수가 반환되기 전에만 호출될 수 있다는 게 타입 시스템으로 보장되므로, 컴파일러는 캡처 컨텍스트를 스택에 둘 수 있다(SIL에서 partial_apply [on_stack] + dealloc_stack). @escaping 클로저는 함수가 반환된 뒤에도 호출될 수 있어야 하므로 컨텍스트가 반드시 힙에 할당되고, alloc_box + strong_retain/strong_release로 참조 카운트가 관리된다. 힙이냐 스택이냐를 가르는 기준은 "캡처한 값이 무엇이냐"가 아니라 순전히 "escaping이냐 아니냐"다.

원리

"A closure is said to escape a function when the closure is passed as an argument to the function, but is called after the function returns."(TSPL Closures) — escaping은 클로저의 수명이 그 클로저를 받은 함수의 스택 프레임보다 오래갈 수 있다는 뜻이다. 수명이 함수 프레임을 넘어설 수 있으면 그 컨텍스트를 스택에 둘 수 없다 — 스택 프레임은 함수가 반환되는 순간 통째로 회수되기 때문이다.

내부 동작

SIL(Swift Intermediate Language) 레벨에서 클로저는 partial_apply 인스트럭션으로 표현된다. non-escaping 매개변수에 넘긴 클로저 리터럴은 캡처한 변수 자체가 alloc_stack으로 스택에 남고, 그 클로저의 컨텍스트도 partial_apply ... [on_stack]으로 스택에 할당된 뒤 함수가 끝나는 지점에 dealloc_stack으로 명시적으로 해제된다. 반면 escaping 매개변수에 넘긴 클로저는 캡처한 변수 자체가 alloc_box로 힙에 박싱되고, 컨텍스트를 넘기기 전에 strong_retain이, 함수가 끝날 때 strong_release가 붙는다 — [on_stack] 표시는 아예 나타나지 않는다.

non-escaping — 스택에 남을 수 있다 counter alloc_stack 클로저 컨텍스트 partial_apply [on_stack] 함수 반환 시 dealloc_stack ×2 힙 할당: 0바이트 escaping — 항상 힙에 있어야 한다 counter alloc_box (힙) 클로저 컨텍스트 strong_retain strong_release 함수가 반환된 뒤에도 생존 가능 힙 할당: 항상
같은 캡처라도 non-escaping은 스택에 남을 수 있지만, escaping은 컨텍스트가 반드시 힙에 할당되고 retain/release로 관리된다.

실험 · 도구

직접 재현해보면 차이가 명확하다. 아래 두 함수를 각각 다른 파일에 두고 swiftc -emit-sil -Onone 파일.swift -o 파일.sil로 SIL을 뽑아 partial_applyalloc_box/alloc_stack을 grep 해본다.

실제 swiftc -emit-sil 결과 — non-escaping과 escaping 대조
// --- non-escaping: withNonEscaping(_ body: () -> Void) 안에서 만든 클로저 ---
%0 = alloc_stack [var_decl] $Int, var, name "counter"      // counter 자체가 스택에
%4 = function_ref @...closure#1...
%5 = partial_apply [callee_guaranteed] [on_stack] %4(%0)   // 컨텍스트도 스택 — [on_stack]
%7 = apply %6(%5) : $@convention(thin) (@guaranteed @noescape @callee_guaranteed () -> ()) -> ()
dealloc_stack %5                                            // 컨텍스트 명시적 해제
dealloc_stack %0                                            // counter 명시적 해제

// --- escaping: withEscaping(_ body: @escaping () -> Void) 안에서 만든 클로저 ---
%0 = alloc_box ${ var Int }, var, name "counter"            // counter 자체가 힙 박스로
%1 = project_box %0, 0
%5 = function_ref @...closure#1...
strong_retain %0                                             // 컨텍스트를 넘기기 전에 retain
%7 = partial_apply [callee_guaranteed] %5(%0)                // [on_stack] 없음 — 힙 컨텍스트
%9 = apply %8(%7) : $@convention(thin) (@guaranteed @callee_guaranteed () -> ()) -> ()
strong_release %7
strong_release %0                                             // counter box release

같은 counter += 1; print(counter) 본문인데도, escaping으로 넘긴 쪽만 counter가 alloc_box(힙)로 바뀐다 — 캡처된 변수 자체가 클로저보다 오래 살아야 하니, 변수를 담는 그릇부터 힙으로 옮겨진 것이다.

프로젝트 적용

"일단 컴파일 에러 안 나게 @escaping부터 붙이고 보자"는 습관을 버려라. 진짜 나중에 호출되는 게 아니라면 non-escaping으로 남겨야 힙 할당·retain/release 비용이 아예 생기지 않는다.

진짜 escaping이 필요한 경우만 표시한다
// ✅ map처럼 함수 실행 중에만 호출되는 변환 클로저 — non-escaping이 기본값이라 표시 불필요
func transform(_ values: [Int], _ f: (Int) -> Int) -> [Int] {
    values.map(f)
}

// ✅ 네트워크 콜백처럼 함수가 반환된 뒤(비동기로) 호출되는 경우만 @escaping
func fetchUser(id: String, completion: @escaping (User?) -> Void) {
    URLSession.shared.dataTask(with: makeURL(for: id)) { data, _, _ in
        completion(User(data: data))   // 함수 fetchUser는 이미 반환된 뒤에 호출된다
    }.resume()
}

non-escaping 클로저를 escaping을 요구하는 API에 어쩔 수 없이 잠깐 흘려보내야 한다면(그 API가 실제로는 즉시 실행하고 끝난다는 걸 개발자가 보장할 수 있을 때), withoutActuallyEscaping으로 타입만 잠깐 맞춰준다 — 힙으로 실제로 옮기지 않는다.

withoutActuallyEscaping — 잠깐만 escaping인 척
func apply(_ closures: [() -> Void]) {
    // 표준 라이브러리 API가 @escaping을 요구하지만, 이 함수 안에서만 쓰고 끝난다는 걸 보장할 때
    withoutActuallyEscaping(closures[0]) { escapable in
        [escapable].forEach { $0() }
    }
}
⚠️ 흔한 오해

"non-escaping 클로저는 항상 스택에 할당된다"는 과장이다. 정확히는 "스택에 할당될 수 있다"이지 보장은 아니다. 반대로 "escaping 클로저는 캡처한 값이 없으면 힙을 안 쓴다"도 틀렸다 — escaping이라는 사실 자체가 컨텍스트를 힙에 박스로 만드는 코드를 생성시킨다. 캡처가 없으면 그 박스 안이 비어 있을 뿐이다.

🧒 쉽게 이해하기

non-escaping 클로저는 회의실 안에서만 쓰는 포스트잇 메모다. 회의 끝나면 그냥 버리면 되니까 책상(스택) 위에 붙여놔도 된다. escaping 클로저는 택배로 부칠 소포다. 상자 안에 짐이 있든 없든, 밖으로 내보내야 하니 반드시 포장(힙 박스)해서 운송장(retain/release)까지 붙여야 한다. 비유가 깨지는 곳: 포스트잇도 "회의실 밖으로 못 나간다"는 규칙 때문에 책상에 붙여두는 것이지, 재질 자체 때문이 아니다 — 컴파일러가 "이건 못 나간다"를 증명 못 하면 포스트잇도 결국 소포처럼 포장된다.

꼬리 질문

non-escaping 클로저 컨텍스트가 항상 스택에 할당되는가?
아니다 — "할 수 있다"이지 보장은 아니다. 원칙은 "escaping은 반드시 힙, non-escaping은 스택 가능"이지 "non-escaping은 항상 스택"이 아니다. 최적화 레벨이나 캡처 복잡도에 따라 컴파일러가 여전히 힙을 쓸 수도 있다.
쉽게 말하면 "차 없이 걸어가도 된다"와 "차 없이는 못 간다"는 다르다. non-escaping은 스택으로 "가도 된다"일 뿐, 매번 강제되는 건 아니다.
escaping 클로저 안에서 아무것도 캡처하지 않으면 힙 할당이 없어지는가?
아니다 — escaping이라는 사실 자체가 컨텍스트를 힙에 두게 만든다. 캡처값이 없어도 partial_apply 자체가 힙 박스로 만들어지는 코드 경로는 그대로 남는다. 다만 캡처할 게 전혀 없으면 그 박스 안이 비어 있을 뿐이라 오버헤드가 작다.
쉽게 말하면 택배 상자(escaping)는 안이 비어 있어도 상자 자체(포장·운송)가 필요하다. 포스트잇(non-escaping)처럼 안 쓰고 버릴 수 없다.
컴파일러가 non-escaping 클로저의 컨텍스트를 스택에 두기로 결정하는 근거는?
non-escaping 클로저는 정의상 그 클로저를 받은 함수의 실행이 끝나기 전까지만 호출될 수 있다는 걸 타입 시스템이 보장한다. 그래서 컴파일러는 그 컨텍스트의 수명이 현재 스택 프레임을 넘지 않는다는 걸 정적으로 증명할 수 있고, 이 증명이 성립하면 [on_stack] partial_apply + dealloc_stack으로 최적화한다.
쉽게 말하면 "이 손님은 오늘 안에 나간다"는 걸 미리 알고 있으면, 방을 굳이 길게 안 잡아도(임시 좌석 = 스택) 된다.

Q3. 캡처 리스트 [x]는 정확히 언제 값을 붙잡는가? 참조 캡처와 무엇이 다른가?

🔑 30초 답변

캡처 리스트 [x]클로저가 생성되는 바로 그 시점x를 평가해 그 결과값의 사본을 클로저 컨텍스트 안에 별도로 저장한다. 이후 바깥의 x가 바뀌어도 클로저 안의 사본은 영향받지 않는다. 반대로 캡처 리스트 없이 그냥 x를 참조하면 기본은 참조 캡처라, 클로저가 호출되는 시점에 바깥 x의 최신 값을 그대로 읽는다.

원리

기본 캡처 규칙은 "값에 의한 캡처"가 아니라 "참조에 의한 캡처"다 — 중첩 함수·클로저가 원래 스코프가 사라진 뒤에도 그 변수를 읽고 쓸 수 있는 이유가 이것이다. 캡처 리스트는 이 기본 규칙에 예외를 만드는 문법으로, "지금 이 순간의 값"을 클로저 생성 시점에 고정한다.

내부 동작

참조 캡처는 클로저 컨텍스트가 원본 변수의 저장 위치(박스 또는 주소)를 그대로 가리키게 만든다 — 그래서 원본이 바뀌면 클로저를 통해 봐도 바뀐 값이 보인다. 캡처 리스트가 있으면 컴파일러는 클로저 컨텍스트 안에 새로운 저장 칸을 만들고, 클로저를 만드는 표현식이 평가되는 그 순간의 값을 그 칸에 복사해 넣는다 — 이후 원본 변수와는 완전히 분리된 독립 저장소가 된다(기본은 let처럼 동작하지만, [var x = ...]처럼 var로 선언하면 클로저 내부에서 그 사본을 다시 바꿀 수도 있다).

생성 시점 이후 x = 100으로 바뀌면? 클로저 A 참조 캡처 (기본) x 저장소 공유되는 원본 x = 100 대입 클로저 생성 이후 호출하면 → 참조 캡처: 100 클로저 B [x] 캡처 리스트 x 복사본 = 0 생성 시점에 고정, 독립 호출하면 → 캡처 리스트: 0 (안 변함)
참조 캡처(위)는 나중에 바뀐 원본 값을 그대로 읽지만, 캡처 리스트(아래)는 클로저가 생성된 시점의 값을 독립된 사본으로 떼어 가진다.

실험 · 도구

실제로 실행해서 눈으로 확인하는 게 제일 빠르다.

참조 캡처는 나중 값을, 캡처 리스트는 생성 시점 값을 본다
var x = 0
let byReference = { print("참조 캡처:", x) }
let byValue = { [x] in print("캡처 리스트:", x) }

x = 100

byReference()  // 참조 캡처: 100 — 지금(호출 시점) x 값을 읽는다
byValue()      // 캡처 리스트: 0   — 클로저 생성 시점의 값이 이미 고정되어 있다

순서를 바꿔 x = 100을 클로저 생성 이전으로 옮기면 두 클로저가 똑같이 100을 찍는다 — 캡처 리스트가 "생성 시점"을 고정하는 것이지 "항상 예전 값"을 고정하는 게 아님을 확인할 수 있다.

프로젝트 적용

반복문 안에서 만드는 escaping 클로저에 루프 변수를 그대로 참조 캡처하면, 클로저가 실제로 실행되는 시점의 최종 값을 보게 되는 함정이 생길 수 있다. 그 순간의 값을 스냅샷으로 고정하려면 캡처 리스트를 쓴다.

루프 인덱스를 생성 시점 값으로 얼려서 넘기기
var scheduled: [() -> Void] = []

for i in 0..<3 {
    // [i]가 없으면 i는 참조 캡처되어, 나중에 배열을 실행할 때 세 클로저 모두
    // "그 시점의 i" — 즉 이미 반복이 끝난 뒤의 값을 보게 될 수 있다.
    scheduled.append { [i] in print("작업", i, "실행") }
}

scheduled.forEach { $0() }   // 작업 0 실행 / 작업 1 실행 / 작업 2 실행 — 생성 시점 값 그대로

[weak self]와 값 캡처를 함께 쓰고 싶을 때는 캡처 리스트 안에 나란히 적으면 된다 — self는 약하게, 그 외 값은 생성 시점 스냅샷으로.

weak self와 값 캡처를 캡처 리스트에 함께 쓰기
final class Uploader {
    let uploadID = UUID()

    func start(_ completion: @escaping (Bool) -> Void) {
        // self는 weak로, uploadID는 지금 이 순간 값으로 스냅샷 떠서 로그에 남긴다.
        // self.uploadID를 나중에 self를 통해 다시 읽는 것과 달리, 이 값은 self 생사와 무관하게 남는다.
        performUpload { [weak self, id = self.uploadID] success in
            print("업로드", id, success ? "성공" : "실패")
            self?.finish(success)
        }
    }
    func finish(_ success: Bool) {}
    func performUpload(_ completion: @escaping (Bool) -> Void) { completion(true) }
}
⚠️ 흔한 오해

"캡처 리스트는 클로저가 실행될 때의 값을 고정한다"는 틀렸다. 정확히는 클로저가 생성될 때의 값이다. 실행 시점과 생성 시점이 같으면 구분이 안 되지만, 생성 후 바로 실행하지 않고 나중에(비동기·저장 후) 실행하는 escaping 클로저에서는 이 차이가 버그를 가르는 지점이 된다.

🧒 쉽게 이해하기

참조 캡처는 창문 밖 풍경을 실시간으로 계속 보여주는 CCTV 화면이고, 캡처 리스트는 그 순간 찍어 둔 폴라로이드 사진이다. CCTV(참조 캡처)는 나중에 봐도 지금 풍경이 보이지만, 사진(캡처 리스트)은 찍던 그 순간 그대로 멈춰 있다. 비유가 깨지는 곳: 캡처 리스트를 var로 선언하면 그 사진 위에 나중에 낙서를 할 수 있다 — 다만 그 낙서는 원본 풍경(바깥 변수)에는 절대 번지지 않는다.

꼬리 질문

캡처 리스트에 self를 넣을 때 [self]와 [weak self]는 뭐가 다른가?
[self]는 여전히 강한 참조 캡처다(문법적으로 명시했을 뿐, struct/enum 문맥이나 가독성 목적으로 쓴다). [weak self]는 약한 참조를 캡처해 self 생명주기에 영향을 주지 않는다. [self]만 쓰면 순환 참조 판정(Q4)이 그대로 적용된다.
쉽게 말하면 [self]는 "나 여기 있다"고 손 드는 것뿐이고 실제로는 계속 손을 잡고 있는 것(강한 캡처)이다. [weak self]는 손을 살짝만 잡아 언제든 놓을 수 있게 하는 것이다.
캡처 리스트로 클래스 인스턴스를 캡처하면([obj]) 그 인스턴스 내용도 스냅샷처럼 복사되는가?
아니다 — 캡처 리스트가 복사하는 건 "그 시점의 참조 값"이지 인스턴스 내용이 아니다. 클래스는 참조 타입이라 [obj]로 캡처해도 여전히 같은 힙 인스턴스를 가리키며, 그 인스턴스의 프로퍼티가 나중에 바뀌면 클로저 안에서도 바뀐 값이 보인다. "생성 시점 값 고정"은 struct·Int 같은 값 타입에서만 체감되고, 참조 타입에서는 "그 시점의 참조 대상"만 고정될 뿐 내용은 계속 최신이다.
쉽게 말하면 사진(캡처 리스트) 안에 사람이 아니라 "그 사람 집 주소"만 찍혀 있다면, 나중에 그 집 안 가구가 바뀌어도 사진 속 주소는 여전히 같은 집을 가리키고, 가서 보면 바뀐 가구가 보인다.
var로 선언한 캡처 리스트([var x = 0])는 무엇이 다른가?
캡처 리스트 항목도 var로 선언 가능하며, 그러면 클로저 컨텍스트 안에 있는 그 복사본 자체를 클로저 내부에서 다시 변경할 수 있다(원본 외부 변수에는 영향 없음). "생성 시점 값의 독립적이고 변경 가능한 사본"을 만드는 것이다.
쉽게 말하면 사진을 let으로 찍으면 액자에 넣어 못 바꾸고, var로 찍으면 그 사진 위에 낙서는 할 수 있지만 원본 풍경(바깥 변수)에는 낙서가 안 번진다.

Q4. [weak self]가 반드시 필요한 패턴과 불필요한 패턴을 어떻게 구분하는가?

🔑 30초 답변

판별 규칙은 하나뿐이다. "이 클로저를 계속 붙잡고 있는 무언가가, self로부터도 다시 도달 가능한가?" 예(Yes)면 순환 참조이므로 weak/unowned가 필요하고, 아니오(No)면 클로저가 언젠가 실행되고 버려지는 일회성이라 weak이 불필요하다. "escaping 클로저 안에서 self를 쓰면 무조건 weak"이라는 규칙은 없다 — 순환은 self가 그 클로저를 (직접 또는 간접적으로) 다시 소유할 때만 생긴다.

원리

강한 순환 참조(strong reference cycle)는 두 인스턴스가 서로를 강하게 참조해 참조 카운트가 절대 0에 도달하지 못하는 상태다. 클로저가 이 그래프에 끼어드는 유일한 방식은, 클로저가 self가 소유한 어딘가(저장 프로퍼티, 옵저버 토큰, 반복 타이머, Combine 구독 등)에 저장되고, 그 클로저 자신이 다시 self를 강하게 캡처하는 경우다. 클로저가 어디에도 저장되지 않고 실행 후 즉시 버려진다면, 그 화살표는 self로 되돌아오지 않으므로 사이클이 아니다.

내부 동작

self → (self가 소유한 저장소) → 클로저 컨텍스트 → self, 이 네 마디가 전부 이어지면 순환이다. 일회성 DispatchQueue.main.async { self.reload() }는 self → 클로저까지만 있고, 그 클로저를 들고 있는 건 큐뿐이다 — 큐는 실행 후 클로저를 release하므로 self로 돌아오는 마지막 화살표 자체가 없다(임시로 self를 붙잡아 생명을 살짝 늘릴 뿐, 영구적 사이클이 아니다). 반대로 self.timer = Timer.scheduledTimer { [self] in ... }는 self가 timer를 소유하고, timer가 클로저를 들고 있고, 그 클로저가 다시 self를 강하게 캡처해 self로 되돌아온다 — 명시적으로 끊지 않는 한 절대 안 풀린다.

self가 클로저를 저장 + 클로저가 self를 강하게 캡처 self (인스턴스) self.onUpdate (저장 프로퍼티) 클로저 컨텍스트 self 강한 캡처 retain +1 — 절대 풀리지 않는 순환 [weak self]로 캡처하면 마지막 화살표가 retain에 영향을 주지 않는다 self 정상적으로 deinit됨 self.onUpdate (저장 프로퍼티) 클로저 컨텍스트 weak self 캡처 weak 참조 — retain count 변화 없음
위: self → 저장 프로퍼티 → 클로저 컨텍스트 → self로 되돌아오는 강한 순환. 아래: 마지막 화살표를 weak self로 바꾸면 retain count가 늘지 않아 self가 정상적으로 해제된다.

구체적인 API별 판단은 다음과 같다.

패턴 / APIweak self이유
DispatchQueue.main.async { self... } (저장하지 않고 바로 실행)불필요큐가 클로저를 짧게 들고 있다가 실행 후 release한다. self로 되돌아오는 저장소가 없다.
URLSession.dataTask(...) { ... }.resume()의 completion불필요task가 완료되면 정확히 한 번 불리고 버려진다. 단 task 자체를 self 프로퍼티로 오래 들고 있다면 이야기가 달라진다.
UIView.animate(withDuration:animations:completion:)불필요애니메이션 엔진이 실행 동안만 클로저를 들고 있다가 완료 후 놓는다.
Combine .sink { self... }를 self.cancellables에 저장필요self → cancellables → AnyCancellable → 클로저 컨텍스트(self 강한 캡처) → self, 자기 자신에게 돌아오는 순환이다.
NotificationCenter.addObserver(forName:using:) 토큰을 self가 프로퍼티로 보관필요self → 토큰 → 클로저(self 캡처) → self. deinit까지 self가 토큰을 들고 있는 한 순환이 유지된다.
Timer.scheduledTimer(repeats: true) { self... }를 self.timer에 저장필요self → timer → 클로저(self 캡처) → self. invalidate() 전까지 RunLoop가 계속 붙잡는다.
lazy var closure = { ... self ... } (프로퍼티 자체가 클로저)필요self → 저장 프로퍼티(클로저) → self 캡처 → self, 교과서적인 순환이다.
Task { await self... } (어디에도 저장하지 않는 fire-and-forget)대체로 불필요Task 자체가 self 프로퍼티에 저장되지 않는 한 완료 후 스스로 해제된다. self.task = Task { ... }처럼 저장하면 Timer와 같은 순환이 된다.

실험 · 도구

deinit에 로그를 심고 세 경로를 나란히 돌리면 규칙이 그대로 드러난다. 아래는 실제로 실행한 결과다.

q4.swift — 같은 구조를 세 가지로만 바꿔 대조
import Foundation

// (a) 클로저를 프로퍼티에 저장 + self 강한 캡처
final class Leaky {
    var onDone: (() -> Void)?
    var name = "Leaky"
    func setup() { onDone = { print("done:", self.name) } }
    deinit { print("  ✅ Leaky.deinit") }
}

// (b) 완전히 같은 코드에 [weak self] 만 추가
final class Fixed {
    var onDone: (() -> Void)?
    var name = "Fixed"
    func setup() { onDone = { [weak self] in print("done:", self?.name ?? "-") } }
    deinit { print("  ✅ Fixed.deinit") }
}

// (c) 저장하지 않는 일회성 비동기 — self 를 강하게 캡처해도 된다
final class OneShot {
    var name = "OneShot"
    func run(_ done: @escaping () -> Void) {
        DispatchQueue.global().async { print("done:", self.name); done() }
    }
    deinit { print("  ✅ OneShot.deinit") }
}

do { var a: Leaky? = Leaky(); a?.setup(); a = nil }
do { var b: Fixed? = Fixed(); b?.setup(); b = nil }
let sem = DispatchSemaphore(value: 0)
do { var c: OneShot? = OneShot(); c?.run { sem.signal() }; sem.wait(); c = nil }
실행 결과 — (a)의 deinit 만 없다
(a) 프로퍼티에 저장 + 강한 self:
   ↑ deinit 이 안 찍혔으면 누수          ← Leaky.deinit 이 출력되지 않았다

(b) 같은 코드 + [weak self]:
  ✅ Fixed.deinit

(c) 저장하지 않는 일회성:
done: OneShot
  ✅ OneShot.deinit

(a)와 (b)의 소스 차이는 [weak self] 여덟 글자뿐인데 한쪽만 해제된다. 그리고 (c)가 이 문항의 진짜 요점이다 — self를 강하게 캡처했는데도 정상 해제됐다. 클로저가 DispatchQueue에 잠깐 넘어갔다가 실행 후 버려지므로 순환이 만들어지지 않기 때문이다. 즉 문제는 "강한 캡처"가 아니라 "인스턴스가 그 클로저를 다시 소유하는가"다.

🔑 판별 규칙

이 실측이 규칙을 한 줄로 만들어준다 — self가 그 클로저를 (직접이든 간접이든) 소유하고 있는가? 소유하면 [weak self]가 필요하고(a→b), 소유하지 않으면 필요 없다(c). onDone·observers·handlers처럼 클로저를 담는 프로퍼티 이름이 보이면 그 자리를 먼저 의심하면 된다.

Xcode의 Debug Memory Graph나 Instruments Leaks로도 같은 결론에 닿지만, 위처럼 deinitprint 한 줄을 심는 쪽이 훨씬 빠르고 CI에서도 자동화할 수 있다.

프로젝트 적용

짧게 실행되고 버려지는 completion에는 weak을 강제로 붙이지 않아도 된다 — 다만 "self가 이미 없을 수도 있다"는 사실 자체는 그대로이므로, 화면이 이미 닫힌 뒤 UI를 건드리는 부작용이 있다면 그건 weak self 문제가 아니라 별도의 방어 로직(화면 상태 체크)이 필요하다.

일회성 completion — weak 없이도 순환은 안 생긴다
final class ProfileViewController: UIViewController {
    func loadAvatar() {
        // 이 completion은 어디에도 저장되지 않고, 네트워크 콜백 한 번으로 끝난다.
        // self → 클로저까지만 있고, 클로저를 들고 있는 URLSession task가
        // 완료 후 release하므로 self로 되돌아오는 화살표가 없다 — 순환 아님.
        APIClient.fetchAvatar { [self] image in
            avatarView.image = image
        }
    }
}

프로퍼티로 저장되는 구독·옵저버·반복 타이머는 예외 없이 weak(또는 unowned)로 끊어야 한다 — self가 그 저장소를 들고 있는 한 절대 스스로 풀리지 않는다.

저장되는 구독은 weak self 없이는 절대 안 풀린다
import Combine

final class FeedViewModel {
    private var cancellables: Set<AnyCancellable> = []
    private let refreshed = PassthroughSubject<Void, Never>()

    func bind() {
        refreshed
            .sink { [weak self] in
                // self → cancellables → 이 AnyCancellable → 클로저 컨텍스트 → self로
                // 되돌아오는 순환을, weak self가 마지막 화살표에서 끊어준다.
                self?.reload()
            }
            .store(in: &cancellables)   // self가 이 구독을 소유
    }
    func reload() {}
}
⚠️ 흔한 오해

"클로저 안에서 self를 쓰면 무조건 weak을 붙여야 한다"는 틀렸다 — 위 판별 규칙을 통과하지 못하는(순환이 안 생기는) 클로저에 불필요하게 weak을 붙이면 매번 옵셔널 언래핑 보일러플레이트만 늘어난다. 반대로 "DispatchQueue.async·UIView.animate는 절대 사이클을 안 만든다"도 과장이다 — 그 클로저 자체를 배열이나 프로퍼티에 저장해 재사용하면 이야기가 달라진다. 기준은 API 이름이 아니라 "일회성으로 실행되고 버려지는가"다.

🧒 쉽게 이해하기

개(self)가 사람(클로저)을 데리고 산책을 나갔다가 볼일을 마치고 돌아오면(일회성 실행), 둘을 묶어둘 목줄이 필요 없다. 그런데 개와 사람을 목줄로 묶어서 둘 다 같은 기둥(self가 소유한 프로퍼티)에 매어 두면, 둘 중 누구도 먼저 풀려날 수 없다 — 그게 순환이다. 이때 목줄 한쪽을 헐겁게(weak) 매어두면, 개가 먼저 자리를 떠도 사람 쪽 목줄은 그냥 헐렁하게 남을 뿐 아무도 묶여 있지 않게 된다. 비유가 깨지는 곳: 실제로는 "누가 누구를 소유하는가"가 방향성이 있는 관계라서, 목줄 비유처럼 대칭적이지 않다 — self가 클로저를 소유(프로퍼티)하는 방향과 클로저가 self를 캡처하는 방향은 서로 다른 화살표다.

꼬리 질문

self가 이미 nil이 됐는데 weak self를 안 걸어서 참조를 계속 들고 있으면 무슨 일이 일어나는가?
self가 "이미 nil"이 되는 게 아니라, weak을 안 걸면 애초에 self가 deinit되지 못한다(강한 순환이면). deinit이 영원히 안 불려 메모리 릭이 되는 것이지, "쓰다가 깨지는" 크래시가 아니다 — ARC의 순환 참조는 크래시가 아니라 "영원히 안 죽는 것"이 문제다.
쉽게 말하면 위험한 건 "이미 죽은 사람을 건드리는 사고"가 아니라 "아무도 안 죽어서 방이 계속 안 비워지는 것"이다.
weak self 대신 저장 프로퍼티 클로저에 unowned self를 쓰면 무엇이 위험한가?
저장 프로퍼티에 저장된 클로저(self가 소유한 클로저)에서 self를 unowned로 캡처하면 순환 자체는 끊긴다(둘 다 retain count를 안 늘리므로). 하지만 self가 deinit된 뒤에도 그 클로저가 어딘가(다른 곳)에서 여전히 호출 가능한 경로가 남아 있으면 댕글링 접근 크래시가 난다. 확신이 없으면 weak이 안전한 기본값으로 권장되는 이유가 여기 있다.
쉽게 말하면 안전벨트(weak)는 사고가 나면 그냥 못 움직이게 하고(nil), unowned는 안전벨트를 뺀 채 "절대 사고 안 난다"고 약속하는 것이다 — 약속이 틀리면 크게 다친다(크래시).
@escaping 클로저를 구조체(struct)의 프로퍼티에 저장할 때도 retain cycle을 걱정해야 하는가?
struct 자체는 값 타입이라 그 구조체 인스턴스와 클로저 사이에 참조 순환이 생기지는 않는다(struct는 자기 자신을 참조로 캡처당할 수 없다 — escaping closure가 struct self의 mutable reference를 캡처 못 하는 규칙과 연결된다). 다만 그 구조체가 클래스 인스턴스를 캡처한 클로저를 프로퍼티로 들고 있고, 그 구조체가 다시 그 클래스 인스턴스의 프로퍼티로 저장되면 클래스 쪽에서 순환이 생길 수 있다 — 판별 기준은 여전히 "self로부터 도달 가능한가"다.
쉽게 말하면 상자(struct)는 자기 자신을 다시 참조로 못 붙잡는다. 하지만 그 상자를 사람(class)이 들고 있고 상자 안 내용물이 다시 그 사람을 가리키면, 결국 사람 쪽에서 순환이 생길 수 있다.

Q5. weak와 unowned는 무엇을 기준으로 고르는가? SE-0365가 바꾼 것은?

🔑 30초 답변

기준은 "이 참조 대상이 클로저(또는 나를 캡처한 대상)보다 먼저 죽을 수 있는가"다. 죽을 수 있다면 weak(자동으로 nil이 되는 옵셔널, 접근 시 안전) — 대상과 내가 항상 함께 죽는다고 확신할 수 있을 때만 unowned(옵셔널 아님, 이미 해제된 뒤 접근하면 런타임이 트랩). unowned(unsafe)는 그 안전 체크마저 꺼서 댕글링 접근이 크래시 없이 미정의 동작(UB)으로 이어질 수 있다. SE-0365(Swift 5.8)는 weak selfguard let self else { return }(또는 if let self)로 언랩한 뒤에는, 그 스코프 안에서 self.을 매번 안 붙여도 되게 허용했다.

원리

TSPLunowned를 "closure와 그것이 캡처하는 인스턴스가 항상 서로를 참조하고, 항상 동시에 해제될 때" 쓰라고 규정하고, weak은 "캡처된 참조가 미래의 어느 시점에 nil이 될 수 있을 때" 쓰라고 규정한다. 즉 둘의 차이는 안전성 수준이 아니라 생명주기 관계에 대한 확신의 정도다.

내부 동작

weak 참조는 접근할 때마다 사이드 테이블(weak reference table)을 거쳐 "이 인스턴스가 아직 살아있는가"를 확인하고, 이미 해제됐다면 자동으로 nil을 돌려준다 — 이 조회 자체가 strong/unowned보다 약간의 런타임 비용을 더한다. unowned는 이 조회 없이 대상을 곧바로 역참조하되, 해제된 인스턴스에 접근하면(체크가 켜진 일반 빌드에서는) 트랩으로 죽는다. unowned(unsafe)는 그 트랩 체크마저 없어 Objective-C의 __unsafe_unretained처럼 이미 해제된 메모리를 그냥 읽으려 시도한다.

구분weakunownedunowned(unsafe)
타입항상 옵셔널옵셔널 아님옵셔널 아님
대상 해제 시자동으로 nil댕글링 상태로 남고 접근 시 트랩댕글링 상태로 남고 접근 시 UB(체크 없음)
접근 비용사이드 테이블 조회(약간의 오버헤드)직접 역참조(오버헤드 적음)직접 역참조, 체크도 없음(가장 빠름)
고르는 기준대상이 클로저보다 먼저 죽을 수 있다대상과 항상 동시에 죽는다고 확신거의 쓰지 않음 — 극한 성능 상황 한정

실험 · 도구

SE-0365 전후를 나란히 컴파일해보면 문법 차이가 바로 보인다.

SE-0365 이전 / 이후 — guard let self 언랩 뒤의 self. 표기
final class Fetcher {
    var value = 0

    func load(_ completion: @escaping () -> Void) {
        DispatchQueue.global().async { [weak self] in
            guard let self else { return }
            // Swift 5.7까지: 언랩한 뒤에도 매번 self.을 명시해야 컴파일됐다
            self.value += 1
            // Swift 5.8부터(SE-0365): guard let self로 언랩한 뒤에는
            // 암묵적 self로도 같은 self를 가리키는 게 보장된다
            value += 1
            completion()
        }
    }
}

둘의 차이는 해제된 뒤에 접근했을 때 드러난다. 같은 상황을 두 번 돌려 실제 결과를 받았다.

q5a.swift — weak 는 자동으로 nil 이 된다
final class Resource { let id = 7; deinit { print("  Resource.deinit") } }
var strongRef: Resource? = Resource()
weak var weakRef = strongRef
unowned let unownedRef = strongRef!
print("해제 전 weakRef   :", weakRef as Any)
print("해제 전 unownedRef:", unownedRef.id)
strongRef = nil
print("해제 후 weakRef   :", weakRef as Any)
실행 결과 — 정상 종료
해제 전 weakRef   : Optional(q5a.Resource)
해제 전 unownedRef: 7
  Resource.deinit
해제 후 weakRef   : nil        ← 대입한 적이 없는데 nil 이 되어 있다
exit code = 0
q5b.swift — unowned 는 해제 후 접근하면 트랩한다
final class Resource { let id = 7; deinit { print("  Resource.deinit") } }
var strongRef: Resource? = Resource()
unowned let unownedRef = strongRef!
strongRef = nil
print("해제 후 unownedRef.id 접근 시도…")
print(unownedRef.id)          // ← 여기서 트랩
실행 결과 — 프로세스가 중단된다
Fatal error: Attempted to read an unowned reference but object 0x100e4e0e0
             was already destroyed
  Resource.deinit
해제 후 unownedRef.id 접근 시도…
exit code = 134        # 128 + 6 (SIGABRT)

메시지가 정확히 무엇이 잘못됐는지 말해준다 — "이미 파괴된 객체의 unowned 참조를 읽으려 했다". 그리고 종료 코드 134(SIGABRT)Part C 29장 Q2의 배타적 접근 위반과 같은 신호다. 두 경우 모두 런타임 라이브러리가 진단 문구를 먼저 출력한 뒤 중단하기 때문이며, 산술 오버플로 같은 인라인 검사(31장 Q1, 133/SIGTRAP)와는 구분된다. 크래시 로그에 134와 함께 Attempted to read an unowned reference가 보이면 원인이 바로 특정된다.

⚠️ 출력 순서에 속지 말 것

위 결과에서 Fatal error:맨 위에 찍히고 해제 후 … 접근 시도…가 그 아래에 나온다. 코드 순서와 반대라 혼란스럽지만, print는 버퍼링되고 치명적 오류 메시지는 표준 오류로 즉시 나가기 때문이다. 크래시 로그를 읽을 때 "마지막 print 다음 줄에서 죽었다"고 추론하면 틀릴 수 있다는 실전 교훈이기도 하다.

프로젝트 적용

확신이 없으면 기본은 항상 weak이다 — 옵셔널 언래핑 한 줄의 비용이 댕글링 크래시보다 훨씬 싸다.

기본값: weak + guard let self else return
final class SearchViewModel {
    let api = API()

    func search(_ query: String, completion: @escaping ([Result]) -> Void) {
        api.search(query) { [weak self] results in
            guard let self else { return }
            self.cache(results)
            completion(results)
        }
    }
    func cache(_ results: [Result]) {}
}
struct Result {}
final class API {
    func search(_ q: String, completion: @escaping ([Result]) -> Void) { completion([]) }
}

self와 클로저의 생명주기가 설계상 항상 함께 끝난다는 걸 구조적으로 보장할 수 있을 때만 unowned를 쓴다 — TSPL의 대표 예시가 이 경우다. 인스턴스가 자기 자신을 참조하는 클로저를 자신의 저장 프로퍼티로 들고 있고, 그 클로저는 오직 그 인스턴스 안에서만 호출된다.

unowned가 적절한 경우 — 클로저와 self가 항상 함께 산다
final class HTMLElement {
    let name: String
    let text: String?

    // asHTML은 self의 저장 프로퍼티이고, self가 살아있는 동안에만 호출될 클로저다.
    // self가 사라지면 이 클로저도 함께 사라지므로, weak의 옵셔널 언래핑이 오히려 불필요하다.
    lazy var asHTML: () -> String = { [unowned self] in
        if let text = self.text {
            return "<\(self.name)>\(text)</\(self.name)>"
        } else {
            return "<\(self.name) />"
        }
    }

    init(name: String, text: String? = nil) {
        self.name = name
        self.text = text
    }
}
⚠️ 흔한 오해

"unowned는 weak보다 항상 위험하니 쓰지 말아야 한다"는 과장이다. self와 클로저가 구조적으로 항상 함께 해제된다고 보장할 수 있으면, unowned가 매번 옵셔널을 언래핑하지 않아도 되는 더 정확한 표현이다. 다만 그 보장이 조금이라도 불확실하면 weak이 항상 더 안전한 기본값이다.

🧒 쉽게 이해하기

weak은 스마트폰 즐겨찾기 연락처 같다 — 상대가 번호를 없애면 자동으로 "연락처 없음"으로 바뀐다. unowned는 손으로 종이에 적어둔 전화번호다 — 상대 번호가 없어져도 종이는 그대로 남아 있다가, 그 번호로 전화를 걸면(접근하면) "없는 번호"라고 크게 알려준다(트랩). unowned(unsafe)는 그 종이 번호를 확인도 안 하고 무작정 거는 것이다. 비유가 깨지는 곳: unowned가 "크게 알려주는" 건 사실 안전장치이지 사고가 아니다 — 진짜 위험한 쪽은 확인 절차 자체가 없는 unowned(unsafe)다.

꼬리 질문

unowned(unsafe)는 실무에서 언제 쓰는가?
거의 안 쓴다. Objective-C 상호운용에서 weak/unowned 런타임 체크 오버헤드조차 감당 못 하는 극도로 hot한 경로 정도에서만 제한적으로 고려된다. 일반 앱 코드에서는 체크가 없다는 대가(댕글링 시 크래시 대신 UB)가 이득보다 훨씬 크다.
쉽게 말하면 안전 확인 절차를 아예 생략한 지름길이다. 정말 급하고 위험을 완전히 통제할 수 있을 때만, 그것도 아주 드물게 쓴다.
guard let self else { return }와 guard let self = self else { return }는 같은 것인가?
같다 — SE-0345의 축약 옵셔널 바인딩 문법 덕분에 self처럼 이름이 같으면 = self를 생략할 수 있게 된 것뿐, 의미(원래 weak self를 언랩해 지역 강한 상수 self를 새로 만드는 것)는 동일하다.
쉽게 말하면 "self = self"라고 두 번 말하나 "self"라고 한 번만 말하나 같은 뜻이다. 그냥 짧게 줄인 말투다.
SE-0365의 암묵적 self가 적용 안 되는 경우는?
guard let self 바인딩이 오직 클로저의 weak self 캡처만을 가리켜야 한다는 조건이 있다. 예를 들어 guard let self = self ?? fallback처럼 다른 값과 합쳐지면 컴파일러가 그 self가 원래 캡처와 동일한지 보장 못 하므로 암묵적 self가 허용되지 않고 명시적으로 self.을 계속 써야 한다.
쉽게 말하면 "이게 진짜 원래 그 사람이 맞다"는 확신이 있을 때만 이름을 줄여 부를 수 있다. 중간에 다른 사람과 섞이면 다시 풀네임(self.)을 불러야 한다.

Q6. @autoclosure는 무슨 문제를 푸는가? assert가 이를 쓰는 이유는?

🔑 30초 답변

@autoclosure는 함수에 넘긴 인자 표현식을 컴파일러가 자동으로 인자 없는 클로저(() -> T)로 감싸서, 그 표현식의 평가를 "호출부에서 넘기는 시점"이 아니라 "함수 안에서 그 클로저가 실제로 호출되는 시점"까지 미루는 문법이다. assert(condition, message())가 이걸 쓰는 이유는, condition이 이미 참이라 실패 메시지가 필요 없는 상황에서 message()를 만드는 비용(문자열 조립 등)조차 들이지 않기 위해서다.

원리

일반 표현식은 함수 호출 시점에 즉시 평가되어 값으로 넘어간다. @autoclosure는 파라미터 타입이 "인자 없이 값 하나를 반환하는 함수 타입"일 때만 적용 가능하며, 호출부 코드는 여전히 값을 넘기는 것처럼({ } 없이) 보이지만 실제로는 평가가 지연된 클로저가 전달된다.

내부 동작

컴파일러는 @autoclosure 파라미터의 실제 타입을 () -> T로 바꾸고, 호출부에서 그 자리에 쓰인 표현식을 자동으로 { 표현식 }처럼 감싸 넘기도록 코드를 생성한다. 함수 본문에서 그 파라미터를 호출(message())하는 순간까지 원래 표현식은 평가되지 않는다. @autoclosure @escaping 조합도 가능해서, 그 지연된 평가 자체를 나중에(함수가 반환된 뒤에) 실행하도록 저장해둘 수도 있다.

파라미터 형태호출부 문법평가 시점
일반 값 타입 (_ x: String)logIfNeeded(true, expensiveDescription())호출하는 즉시(항상 평가)
명시적 클로저 (_ x: () -> String)logIfNeeded(true) { expensiveDescription() }호출부에서 감싼 뒤, 함수 안에서 부를 때만
@autoclosure (_ x: @autoclosure () -> String)logIfNeeded(true, expensiveDescription())값처럼 넘기는 문법 그대로, 함수 안에서 부를 때만

실험 · 도구

부작용이 있는 표현식을 넘겨 "언제 실제로 평가되는지"를 print로 확인하면 지연 평가가 눈으로 보인다.

@autoclosure의 지연 평가를 직접 확인
func logIfNeeded(_ enabled: Bool, _ message: @autoclosure () -> String) {
    if enabled {
        print("[LOG]", message())   // 여기서 처음 평가된다
    }
    // enabled가 false면 message()는 한 번도 호출되지 않는다
}

func expensiveDescription() -> String {
    print("비용이 큰 문자열을 실제로 만드는 중...")
    return "무거운 디버그 설명"
}

logIfNeeded(false, expensiveDescription())
// 이 한 줄만으로는 "비용이 큰 문자열을 실제로 만드는 중..."이 출력되지 않는다 —
// enabled가 false라서 message()가 아예 호출되지 않았기 때문이다.

프로젝트 적용

assert처럼 "조건이 실패했을 때만 비용을 들여도 되는" 메시지·로그 생성에 @autoclosure를 직접 응용할 수 있다.

assert와 같은 패턴의 커스텀 지연 로깅
func require(_ condition: Bool, _ reason: @autoclosure () -> String) {
    if !condition {
        fatalError(reason())   // 실패했을 때만 reason()이 평가된다
    }
}

require(1 + 1 == 2, "산수가 깨졌다: \(1 + 1)")   // 통과 — 문자열 보간조차 실행 안 됨

@autoclosure @escaping을 조합하면, 값을 나중에 필요할 때만 계산하는 "지연 기본값 제공자"를 만들 수 있다.

@autoclosure @escaping — 나중에 필요할 때만 계산되는 기본값
struct LazyDefault<Value> {
    private let makeValue: () -> Value

    // 호출부는 그냥 값을 넘기는 것처럼 보이지만, 평가는 resolve()가 불릴 때까지 미뤄진다.
    init(_ makeValue: @autoclosure @escaping () -> Value) {
        self.makeValue = makeValue
    }
    func resolve() -> Value { makeValue() }
}

func computeExpensiveDefault() -> Int {
    print("기본값을 실제로 계산하는 중...")
    return 42
}

let fallback = LazyDefault(computeExpensiveDefault())   // 아직 계산 안 됨
// ... 나중에, 정말 필요해지면
let value = fallback.resolve()                           // 여기서 처음 계산된다
⚠️ 흔한 오해

"@autoclosure는 그냥 문법 설탕이라 성능·의미와 무관하다"는 틀렸다. 평가 지연 자체가 핵심 기능이다 — 부작용이 있거나 계산 비용이 큰 표현식을 조건부로만 평가하고 싶을 때 이 지연이 정확히 그 문제를 푼다.

🧒 쉽게 이해하기

@autoclosure는 배달 주문서 같은 것이다. 주문서를 건네는 순간 음식이 나오는 게 아니라, 진짜로 문 앞에 도착했을 때(함수 안에서 호출될 때)에야 요리를 시작한다. 주문이 취소되면(조건이 안 맞으면) 아예 요리를 안 하고 끝난다. 비유가 깨지는 곳: 진짜 배달 주문은 주문서 자체를 쓰는 데도 시간이 들지만, @autoclosure는 "주문서를 쓰는 것"(클로저로 감싸는 것)에는 거의 비용이 안 든다 — 진짜 비용은 오직 "요리"(표현식 평가)에만 있다.

꼬리 질문

@autoclosure는 파라미터 하나에 표현식을 여러 개 감쌀 수 있는가?
아니다. @autoclosure는 "인자 없이 값 하나를 반환하는" 클로저로 딱 한 표현식만 감싼다 — 함수의 특정 파라미터 하나에 붙는 속성이라, 여러 인자를 한 번에 지연 평가로 묶으려면 각 파라미터에 개별적으로 @autoclosure를 붙여야 한다.
쉽게 말하면 포장지 하나가 선물 하나를 감싸듯, @autoclosure도 파라미터 하나씩 개별 포장한다.
@autoclosure와 그냥 () -> T 클로저 파라미터의 차이는 호출부에서 어떻게 보이는가?
() -> T라면 호출부에서 f({ expression })처럼 명시적으로 중괄호로 감싸야 하지만, @autoclosure면 f(expression)처럼 그냥 값 넘기듯 쓸 수 있다 — 컴파일러가 표현식을 자동으로 무인자 클로저로 감싸주기 때문이다.
쉽게 말하면 @autoclosure는 마술처럼 "그냥 재료를 건넨 것"처럼 보이게 하면서, 사실은 "필요할 때 요리하라"는 레시피 카드를 건네는 것이다.
@autoclosure를 남용하면 어떤 문제가 생기는가?
호출부 코드가 "그냥 값을 넘기는 것처럼" 보여서, 실제로는 매번 클로저 컨텍스트가 생성되고 지연 평가된다는 사실이 코드만 봐서는 잘 드러나지 않는다. 부작용이 있는 표현식을 넘기는 API에 @autoclosure를 남발하면 "언제 평가되는지"를 독자가 추론하기 어려워지고, 디버깅 시 실행 순서를 오해하기 쉽다.
쉽게 말하면 마술을 아무 데나 쓰면 사람들이 "언제 진짜로 요리되는지" 헷갈려 한다. 꼭 필요한 곳(assert처럼 안 쓰이면 계산 자체를 스킵해야 하는 곳)에만 쓰는 게 좋다.

출처 · 참고자료