클로저는 Swift에서 가장 자주 쓰이면서도 내부 동작은 가장 자주 오해되는 기능이다. 이 챕터는 클로저가 실제로 무엇으로 표현되는지(참조 타입, 코드 포인터 + 컨텍스트 포인터), escaping 여부가 그 표현을 스택에서 힙으로 어떻게 밀어내는지, 캡처 리스트가 정확히 언제 값을 얼리는지, 그리고 실무 면접에서 가장 자주 틀리는 질문인 "[weak self]가 정말 필요한가"를 미신이 아니라 판별 규칙으로 다룬다.
ARC의 retain/release 자체 — 강한·약한·미소유 참조가 왜 존재하고 iOS 메모리 압박과 어떻게 얽히는지 — 는 iOS CS 로드맵 01장에서 이미 다뤘다. 여기서는 그 개념을 반복하지 않고, 클로저가 캡처한 것이 실제로 힙에 무엇을 만드는지에 집중한다.
Q1. 클로저가 참조 타입이라는 사실이 실제로 어떤 동작 차이를 만드는가?
클로저를 상수·변수에 대입하는 것은 값을 복사하는 게 아니라 같은 클로저 인스턴스에 대한 참조를 공유하는 것이다. 클로저 값은 실제로 (코드 포인터, 캡처 컨텍스트 포인터) 한 쌍으로 표현되며, 두 변수가 이 쌍을 각각 복사해 들고 있어도 컨텍스트 포인터가 가리키는 힙(혹은 스택) 블록은 하나뿐이다. 그래서 한 변수를 통해 클로저를 호출해 캡처된 값을 바꾸면, 같은 클로저를 대입받은 다른 변수로 호출해도 그 변경이 그대로 보인다.
원리
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). 결과적으로 "클로저를 하나 더 만든 것"이 아니라 "같은 캡처 블록을 가리키는 손잡이를 하나 더 만든 것"에 가깝다.
실험 · 도구
아래를 swift 파일명.swift로 직접 실행해보면 참조 공유가 바로 드러난다.
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 클로저도 참조 타입인가? 스택에 있어도 대입하면 참조가 복사되는가?
함수(func)도 클로저인가? 클로저와 함수의 관계는?
클로저 값 두 개가 같은 함수 리터럴에서 왔지만 서로 다른 호출에서 만들어졌으면 같은 컨텍스트를 공유하는가?
Q2. escaping과 non-escaping 클로저의 힙 할당 차이를 SIL(partial_apply [on_stack]) 수준에서
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] 표시는 아예 나타나지 않는다.
실험 · 도구
직접 재현해보면 차이가 명확하다. 아래 두 함수를 각각 다른 파일에 두고 swiftc -emit-sil -Onone 파일.swift -o 파일.sil로 SIL을 뽑아 partial_apply와 alloc_box/alloc_stack을 grep 해본다.
// --- 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 비용이 아예 생기지 않는다.
// ✅ 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으로 타입만 잠깐 맞춰준다 — 힙으로 실제로 옮기지 않는다.
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 클로저의 컨텍스트를 스택에 두기로 결정하는 근거는?
Q3. 캡처 리스트 [x]는 정확히 언제 값을 붙잡는가? 참조 캡처와 무엇이 다른가?
캡처 리스트 [x]는 클로저가 생성되는 바로 그 시점에 x를 평가해 그 결과값의 사본을 클로저 컨텍스트 안에 별도로 저장한다. 이후 바깥의 x가 바뀌어도 클로저 안의 사본은 영향받지 않는다. 반대로 캡처 리스트 없이 그냥 x를 참조하면 기본은 참조 캡처라, 클로저가 호출되는 시점에 바깥 x의 최신 값을 그대로 읽는다.
원리
기본 캡처 규칙은 "값에 의한 캡처"가 아니라 "참조에 의한 캡처"다 — 중첩 함수·클로저가 원래 스코프가 사라진 뒤에도 그 변수를 읽고 쓸 수 있는 이유가 이것이다. 캡처 리스트는 이 기본 규칙에 예외를 만드는 문법으로, "지금 이 순간의 값"을 클로저 생성 시점에 고정한다.
내부 동작
참조 캡처는 클로저 컨텍스트가 원본 변수의 저장 위치(박스 또는 주소)를 그대로 가리키게 만든다 — 그래서 원본이 바뀌면 클로저를 통해 봐도 바뀐 값이 보인다. 캡처 리스트가 있으면 컴파일러는 클로저 컨텍스트 안에 새로운 저장 칸을 만들고, 클로저를 만드는 표현식이 평가되는 그 순간의 값을 그 칸에 복사해 넣는다 — 이후 원본 변수와는 완전히 분리된 독립 저장소가 된다(기본은 let처럼 동작하지만, [var x = ...]처럼 var로 선언하면 클로저 내부에서 그 사본을 다시 바꿀 수도 있다).
실험 · 도구
실제로 실행해서 눈으로 확인하는 게 제일 빠르다.
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는 약하게, 그 외 값은 생성 시점 스냅샷으로.
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]는 뭐가 다른가?
캡처 리스트로 클래스 인스턴스를 캡처하면([obj]) 그 인스턴스 내용도 스냅샷처럼 복사되는가?
var로 선언한 캡처 리스트([var x = 0])는 무엇이 다른가?
Q4. [weak self]가 반드시 필요한 패턴과 불필요한 패턴을 어떻게 구분하는가?
판별 규칙은 하나뿐이다. "이 클로저를 계속 붙잡고 있는 무언가가, 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로 되돌아온다 — 명시적으로 끊지 않는 한 절대 안 풀린다.
구체적인 API별 판단은 다음과 같다.
| 패턴 / API | weak 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에 로그를 심고 세 경로를 나란히 돌리면 규칙이 그대로 드러난다. 아래는 실제로 실행한 결과다.
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) 프로퍼티에 저장 + 강한 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로도 같은 결론에 닿지만, 위처럼 deinit에 print 한 줄을 심는 쪽이 훨씬 빠르고 CI에서도 자동화할 수 있다.
프로젝트 적용
① 짧게 실행되고 버려지는 completion에는 weak을 강제로 붙이지 않아도 된다 — 다만 "self가 이미 없을 수도 있다"는 사실 자체는 그대로이므로, 화면이 이미 닫힌 뒤 UI를 건드리는 부작용이 있다면 그건 weak self 문제가 아니라 별도의 방어 로직(화면 상태 체크)이 필요하다.
final class ProfileViewController: UIViewController {
func loadAvatar() {
// 이 completion은 어디에도 저장되지 않고, 네트워크 콜백 한 번으로 끝난다.
// self → 클로저까지만 있고, 클로저를 들고 있는 URLSession task가
// 완료 후 release하므로 self로 되돌아오는 화살표가 없다 — 순환 아님.
APIClient.fetchAvatar { [self] image in
avatarView.image = image
}
}
}
② 프로퍼티로 저장되는 구독·옵저버·반복 타이머는 예외 없이 weak(또는 unowned)로 끊어야 한다 — 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를 안 걸어서 참조를 계속 들고 있으면 무슨 일이 일어나는가?
weak self 대신 저장 프로퍼티 클로저에 unowned self를 쓰면 무엇이 위험한가?
@escaping 클로저를 구조체(struct)의 프로퍼티에 저장할 때도 retain cycle을 걱정해야 하는가?
Q5. weak와 unowned는 무엇을 기준으로 고르는가? SE-0365가 바꾼 것은?
기준은 "이 참조 대상이 클로저(또는 나를 캡처한 대상)보다 먼저 죽을 수 있는가"다. 죽을 수 있다면 weak(자동으로 nil이 되는 옵셔널, 접근 시 안전) — 대상과 내가 항상 함께 죽는다고 확신할 수 있을 때만 unowned(옵셔널 아님, 이미 해제된 뒤 접근하면 런타임이 트랩). unowned(unsafe)는 그 안전 체크마저 꺼서 댕글링 접근이 크래시 없이 미정의 동작(UB)으로 이어질 수 있다. SE-0365(Swift 5.8)는 weak self를 guard let self else { return }(또는 if let self)로 언랩한 뒤에는, 그 스코프 안에서 self.을 매번 안 붙여도 되게 허용했다.
원리
TSPL은 unowned를 "closure와 그것이 캡처하는 인스턴스가 항상 서로를 참조하고, 항상 동시에 해제될 때" 쓰라고 규정하고, weak은 "캡처된 참조가 미래의 어느 시점에 nil이 될 수 있을 때" 쓰라고 규정한다. 즉 둘의 차이는 안전성 수준이 아니라 생명주기 관계에 대한 확신의 정도다.
내부 동작
weak 참조는 접근할 때마다 사이드 테이블(weak reference table)을 거쳐 "이 인스턴스가 아직 살아있는가"를 확인하고, 이미 해제됐다면 자동으로 nil을 돌려준다 — 이 조회 자체가 strong/unowned보다 약간의 런타임 비용을 더한다. unowned는 이 조회 없이 대상을 곧바로 역참조하되, 해제된 인스턴스에 접근하면(체크가 켜진 일반 빌드에서는) 트랩으로 죽는다. unowned(unsafe)는 그 트랩 체크마저 없어 Objective-C의 __unsafe_unretained처럼 이미 해제된 메모리를 그냥 읽으려 시도한다.
| 구분 | weak | unowned | unowned(unsafe) |
|---|---|---|---|
| 타입 | 항상 옵셔널 | 옵셔널 아님 | 옵셔널 아님 |
| 대상 해제 시 | 자동으로 nil | 댕글링 상태로 남고 접근 시 트랩 | 댕글링 상태로 남고 접근 시 UB(체크 없음) |
| 접근 비용 | 사이드 테이블 조회(약간의 오버헤드) | 직접 역참조(오버헤드 적음) | 직접 역참조, 체크도 없음(가장 빠름) |
| 고르는 기준 | 대상이 클로저보다 먼저 죽을 수 있다 | 대상과 항상 동시에 죽는다고 확신 | 거의 쓰지 않음 — 극한 성능 상황 한정 |
실험 · 도구
SE-0365 전후를 나란히 컴파일해보면 문법 차이가 바로 보인다.
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()
}
}
}
둘의 차이는 해제된 뒤에 접근했을 때 드러난다. 같은 상황을 두 번 돌려 실제 결과를 받았다.
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 = 0final 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이다 — 옵셔널 언래핑 한 줄의 비용이 댕글링 크래시보다 훨씬 싸다.
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의 대표 예시가 이 경우다. 인스턴스가 자기 자신을 참조하는 클로저를 자신의 저장 프로퍼티로 들고 있고, 그 클로저는 오직 그 인스턴스 안에서만 호출된다.
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)는 실무에서 언제 쓰는가?
guard let self else { return }와 guard let self = self else { return }는 같은 것인가?
SE-0365의 암묵적 self가 적용 안 되는 경우는?
Q6. @autoclosure는 무슨 문제를 푸는가? assert가 이를 쓰는 이유는?
@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로 확인하면 지연 평가가 눈으로 보인다.
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를 직접 응용할 수 있다.
func require(_ condition: Bool, _ reason: @autoclosure () -> String) {
if !condition {
fatalError(reason()) // 실패했을 때만 reason()이 평가된다
}
}
require(1 + 1 == 2, "산수가 깨졌다: \(1 + 1)") // 통과 — 문자열 보간조차 실행 안 됨
② @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는 "주문서를 쓰는 것"(클로저로 감싸는 것)에는 거의 비용이 안 든다 — 진짜 비용은 오직 "요리"(표현식 평가)에만 있다.