@escaping은 무슨 뜻인가요? 왜 기본값이 non-escaping인가요?- escaping 클로저에서는 왜
self.를 명시해야 하나요? - 이 구분이 실제로 무엇을 바꾸나요?
@escaping은 "이 클로저가 함수 반환 이후까지 살 수 있다"는 수명 계약partial_apply [on_stack] vs 힙 박스 할당으로 갈린다30초 답변 🔥 먼저 이 문장
@escaping은 성능 옵션이 아니라 수명 계약입니다. "이 클로저가 함수가 반환된 뒤에도 살아 있을 수 있다"는 선언이죠.Swift는 non-escaping이 기본값입니다. 왜냐하면 수명이 함수 스코프로 한정되면 세 가지를 얻기 때문입니다 — ① 클로저 컨텍스트를 스택에 올릴 수 있고, ② 캡처한 값의 수명을 연장할 필요가 없으며, ③ 순환 참조가 원리적으로 불가능합니다.
컴파일러가 escaping에서만
self.를 강제하는 이유가 바로 그것입니다 — "지금 self의 수명을 연장하고 있다"를 코드에 남기려고요.
L1개념 — 차이 전체와 판별
| non-escaping (기본) | @escaping | |
|---|---|---|
| 수명 | 함수 반환 전 종료 보장 | 반환 이후까지 가능 |
| 컨텍스트 할당 | 스택 가능 (partial_apply [on_stack]) | 힙 박스 + ARC |
| 캡처 | 수명 연장 불필요한 경우가 많다 | 캡처 대상을 retain — 수명 연장 |
self | 생략 가능 | self. 또는 [self] 명시 필요 |
| 순환 참조 | 원리적으로 불가능 | 가능 → [weak self] 검토 |
| 프로퍼티 저장 | 불가 | 가능 |
| 재귀 사용 | 배열에 담기 불가 | 가능 |
// ───── non-escaping — 함수 안에서 다 쓰고 끝난다 ─────
func perform(_ work: () -> Void) {
work()
}
func measure(_ block: () -> Void) -> TimeInterval {
let start = CFAbsoluteTimeGetCurrent()
block()
return CFAbsoluteTimeGetCurrent() - start
}
// ───── ① 저장 — 프로퍼티나 컬렉션에 담는다 ─────
final class Notifier {
private var handlers: [() -> Void] = []
func add(_ h: @escaping () -> Void) { handlers.append(h) }
func fire() { handlers.forEach { $0() } }
}
// ───── ② 비동기 콜백 ─────
func fetch(_ done: @escaping (Data?) -> Void) {
URLSession.shared.dataTask(with: url) { data, _, _ in
done(data) // 함수는 이미 반환된 뒤에 불린다
}.resume()
}
// ───── ③ 다른 큐/스레드로 전달 ─────
func runLater(_ block: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: block)
}
// ───── ④ 옵셔널 클로저 — 자동으로 escaping이 된다 ─────
func configure(_ handler: ((Int) -> Void)?) { // @escaping을 쓸 수 없다(중복)
self.stored = handler // 이미 escaping이라 저장 가능
}
// 왜? Optional로 감싸는 순간 값이 다른 곳에 복사·저장될 수 있어
// "함수 반환 전에 끝난다"를 컴파일러가 보장할 수 없다.final class Store {
var saved: (() -> Void)?
func take(_ block: () -> Void) { // non-escaping
// saved = block
// ❌ error: assigning non-escaping parameter 'block' to
// an @escaping closure
}
func takeEscaping(_ block: @escaping () -> Void) {
saved = block // ✅ 계약이 맞다
}
}
// non-escaping 안에서는 self. 을 생략할 수 있다
func sync(_ block: () -> Void) { block() }
final class VM {
var count = 0
func run() {
sync { count += 1 } // ✅ self. 생략 가능 — 캡처가 안전하니까
}
func runAsync() {
DispatchQueue.main.async {
// count += 1
// ❌ error: reference to property 'count' in closure requires
// explicit use of 'self'
self.count += 1 // ✅ 명시해야 한다
}
}
}self. 강제는 잔소리가 아니라 설계다
escaping 클로저가 self를 캡처하면 self의 수명이 그 클로저만큼 늘어납니다. 이건 릭의 시작점이 될 수 있는 중요한 사건인데, count += 1처럼 쓰면 코드만 봐서는 안 보입니다.
그래서 컴파일러가 self.를 쓰게 만들어 "여기서 self를 붙잡고 있다"를 눈에 보이게 합니다.
Swift 5.3부터는 [self] 캡처 리스트로도 만족시킬 수 있는데, 이 역시 캡처를 명시한다는 점에서 같은 목적입니다.
DispatchQueue.main.async { self.count += 1 } // 매번 self. 명시
DispatchQueue.main.async { [self] in count += 1 } // 캡처 리스트로 한 번만L2설계 — 왜 기본값을 non-escaping으로 뒤집었나
Swift 3 이전에는 escaping이 기본이었고 @noescape를 붙여 표시했습니다. SE-0103에서 기본값을 뒤집었는데, 그 근거가 이 주제의 핵심입니다.
- 안전한 쪽이 기본이어야 한다 — non-escaping은 순환 참조가 불가능합니다. 위험한 쪽에 표시를 붙이는 게 맞습니다.
- 흔한 쪽이 기본이어야 한다 —
map·filter·forEach처럼 대부분의 클로저 인자는 즉시 소비됩니다. - 최적화 기회가 커진다 — 기본이 non-escaping이면 컴파일러가 더 많은 곳에서 스택 할당을 할 수 있습니다.
var counter = 0
// ① 기본(참조 캡처): 변수 자체를 붙잡는다 — 나중 값이 보인다
let byReference = { print(counter) }
counter = 10
byReference() // 10
// ② 캡처 리스트: 클로저 "생성 시점"의 값을 복사한다
counter = 0
let byValue = { [counter] in print(counter) }
counter = 10
byValue() // 0 ← 생성 시점 값
// ③ 참조 타입을 캡처 리스트에 넣으면? 포인터가 복사될 뿐이다
final class Box { var v = 0 }
let box = Box()
let captured = { [box] in print(box.v) } // box 참조를 강하게 복사
box.v = 99
captured() // 99 ← 같은 객체를 보므로 최신 값
// ④ 여러 개 · 약한 참조 혼합
let handler = { [weak self, box, id = item.id] in
guard let self else { return }
self.process(id, box)
}// ───── before: escaping 콜백 ─────
final class Repo {
func load(_ done: @escaping (Result<User, Error>) -> Void) {
api.fetch { [weak self] data in // 순환 참조 신경 써야 함
guard let self else { return }
done(self.parse(data))
}
}
}
// ───── after: async — 콜백이 반환값이 된다 ─────
final class Repo {
func load() async throws -> User {
let data = try await api.fetch() // 클로저가 없다
return parse(data) // self. 도 필요 없다
}
}
// 얻는 것:
// · 클로저가 없으니 self를 붙잡을 주체가 없다 → 순환 참조 없음
// · 취소가 구조적으로 전파된다 (Task 취소 → await 지점에서 전파)
// · 에러가 throws 로 자연스럽게 올라간다 (Result 중첩 불필요)
// · 호출 순서가 코드 순서와 같다
// 레거시를 감쌀 때
func load() async throws -> User {
try await withCheckedThrowingContinuation { continuation in
legacyLoad { result in
continuation.resume(with: result) // 정확히 한 번만!
}
}
}L3구현 — SIL에서 스택과 힙이 갈리는 지점
# 확인 명령
swiftc -emit-sil Closures.swift | swift demangle | grep -E "partial_apply|alloc_box"
# non-escaping 인 경우
# %5 = partial_apply [callee_guaranteed] [on_stack] %4(%3)
# ^^^^^^^^^^ 스택에 올렸다
# dealloc_stack %5
# escaping 인 경우
# %2 = alloc_box ${ var Int } ← 힙 박스 할당
# %6 = partial_apply [callee_guaranteed] %5(%2)
# strong_retain %6 ← ARC 등장
# 왜 alloc_box 가 필요한가:
# 캡처한 지역 변수를 클로저가 "변경"할 수 있으면,
# 그 변수는 스택 프레임이 아니라 힙 박스에 살아야 한다.
# → 그래서 var 캡처 + escaping 조합이 가장 비싸다withoutActuallyEscaping — 계약을 임시로 빌리는 탈출구// non-escaping 클로저를 escaping을 요구하는 API에 넘겨야 할 때
func parallelMap<T, U>(_ xs: [T], _ transform: (T) -> U) -> [U] {
var result = [U?](repeating: nil, count: xs.count)
withoutActuallyEscaping(transform) { escapable in
// 이 스코프 안에서만 escaping인 척 쓸 수 있다
DispatchQueue.concurrentPerform(iterations: xs.count) { i in
result[i] = escapable(xs[i])
}
// ⚠️ 이 블록을 벗어나기 전에 모든 사용이 끝나야 한다 — 개발자 책임
}
return result.compactMap { $0 }
}
// ❌ 계약을 어기면? 정의되지 않은 동작이다
func bad(_ f: () -> Void) {
withoutActuallyEscaping(f) { escapable in
DispatchQueue.global().async { escapable() } // 💥 스코프를 벗어나서 산다
} // 여기서 f의 스택 컨텍스트가 사라지는데 나중에 호출된다
}
// → 02번에서 본 "탈출구" 목록의 하나다. 컴파일러가 증명 못 하는 것을
// 개발자가 대신 보증하는 구조.L4CS 근본 — 이스케이프 분석과 클로저 변환
① 클로저란 무엇인가 — 함수 + 환경
클로저는 람다 계산법에서 온 개념입니다. 정의는 간단합니다 — "코드 + 그 코드가 참조하는 자유 변수들의 환경". 자유 변수(free variable)란 클로저 밖에서 온 변수를 말합니다.
// 우리가 쓴 것
func makeCounter() -> () -> Int {
var count = 0
return { count += 1; return count } // count는 자유 변수
}
// 컴파일러가 하는 일 — 개념적 변환 (closure conversion / lambda lifting)
struct __Context { // 캡처한 자유 변수들을 담는 구조체
var count: Int
}
func __closureBody(_ ctx: inout __Context) -> Int {
ctx.count += 1
return ctx.count
}
// 클로저 값 = (함수 포인터, 컨텍스트 포인터) 쌍
// ★ 여기서 핵심 질문이 나온다:
// __Context 를 어디에 둘 것인가?
// · 함수가 반환되기 전에 다 쓴다면 → 스택 (공짜)
// · 반환 후에도 살아야 한다면 → 힙 (할당 + ARC)
//
// 이 판단이 바로 "이스케이프 분석"이다.② 이스케이프 분석 — 언어를 가리지 않는 고전 최적화
이스케이프 분석(escape analysis)은 "이 값이 생성된 스코프를 벗어나는가"를 판정하는 정적 분석입니다. 벗어나지 않으면 힙 대신 스택에 할당할 수 있고, 그러면 할당 비용·GC/ARC 비용·캐시 미스가 전부 사라집니다.
| 언어 | 방식 | 누가 판단하나 |
|---|---|---|
| Swift | @escaping 어노테이션 | 개발자가 선언, 컴파일러가 검증 — 타입의 일부 |
| Go | 전자동 이스케이프 분석 | 컴파일러 — go build -gcflags=-m으로 결과 확인 가능 |
| Java (JVM) | JIT의 이스케이프 분석 + 스칼라 치환 | 런타임 — 실행 프로파일을 보고 판단 |
| Rust | 수명(lifetime) + 소유권 | 타입 시스템이 강제 — 대여 검사기가 컴파일 에러로 |
| C++ | 람다 캡처 방식을 직접 선택 | 개발자 — 틀리면 dangling reference (UB) |
// ── Swift: 타입에 적는다. 컴파일러가 계약 위반을 잡는다 ──
func f(_ cb: () -> Void) // 반환 전에 끝난다 (계약)
func g(_ cb: @escaping () -> Void) // 더 살 수 있다 (계약)
// ── Go: 컴파일러가 알아서 판단하고, 물어보면 알려 준다 ──
// $ go build -gcflags='-m' main.go
// ./main.go:5:6: can inline f
// ./main.go:9:10: &x escapes to heap ← 힙으로 갔다
// ./main.go:12:9: x does not escape ← 스택에 남았다
// ── Rust: 수명이 타입의 일부. 어기면 컴파일 에러 ──
// fn f<'a>(cb: &'a dyn Fn()) // 'a 만큼만 산다
// fn g(cb: Box<dyn Fn() + 'static>) // 영원히 살 수 있다
// ── C++: 개발자가 캡처 방식을 고르고, 틀리면 아무도 안 막는다 ──
// auto f = [&]() { use(local); }; // 참조 캡처
// return f; // 💥 local이 죽은 뒤 호출 → UB
// // 컴파일 에러 아님!
Go나 JVM은 컴파일러가 알아서 판단합니다 — 편하지만 결과가 코드에 안 보이고, 최적화가 안 되면 왜 안 됐는지 알기 어렵습니다.
C++은 개발자가 결정하지만 틀려도 아무도 안 막습니다 — dangling reference는 정의되지 않은 동작입니다.
Swift는 그 중간입니다 — 개발자가 타입에 선언하고, 컴파일러가 계약 위반을 컴파일 에러로 잡습니다.
그래서 @escaping은 최적화 힌트가 아니라 API 계약의 일부입니다. 이게 02번의 "런타임 검사를 타입으로 옮긴다"가 클로저 수명에 적용된 모습이고,
"수명을 타입으로 표현한다"는 점에서 Rust의 수명 표기와 같은 계열입니다.
③ 스택 할당이 왜 그렇게 싼가
// 스택 할당 — 명령어 하나
sub sp, sp, #32 ; 스택 포인터를 32바이트 내린다. 끝.
; 해제도 명령어 하나 — 함수 반환 시 sp를 되돌린다
add sp, sp, #32
// 힙 할당 — malloc 호출
; 1. 크기 클래스 결정
; 2. free list 탐색 (또는 스레드 로컬 캐시 확인)
; 3. 메타데이터 갱신
; 4. 멀티스레드면 동기화 (또는 per-thread arena)
; → 수십~수백 사이클
; 해제도 마찬가지 + ARC라면 refCount 원자 연산까지
// 게다가 지역성 차이 (04번)
// 스택: 방금 쓴 프레임 근처 → 거의 항상 L1 캐시에 있다
// 힙 : 임의 위치 → 캐시 미스 가능 → 200+ 사이클그래서 "non-escaping이면 빠르다"의 정확한 의미는 할당·해제 비용 + ARC 비용 + 캐시 미스 셋이 한꺼번에 사라진다는 것입니다. 조회 한 번 아끼는 수준이 아닙니다.
④ 수명이 타입에 있으면 생기는 일 — 이 자료 전체와 이어지는 지점
// ① 클로저 수명 → @escaping → 스택 할당 가능 (10번, 이 페이지)
// ② 값 수명 → 값 타입 → ARC 자체가 불필요 (04번)
// ③ 소유권 → ~Copyable → 복사 자체를 금지 (02번)
// ④ 격리 도메인 → Sendable → 락 없이 안전 (07번)
// ⑤ 오버라이드 여부 → final → 정적 디스패치 (05번)
//
// 전부 같은 문장이다:
// "컴파일 타임에 알 수 있으면, 런타임에 확인할 필요가 없다."
//
// Swift의 어노테이션들이 귀찮게 느껴지는 이유도 여기 있다 —
// 그것들은 장식이 아니라 "컴파일러에게 주는 증명 재료"다.CS 정본으로 더 내려가기
이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.
경험으로 말하기
❌ "@escaping은 함수 밖에서도 쓰이는 클로저에 붙입니다."
✅ "레거시 네트워크 계층이 전부 completion handler 방식이었고, 콜백마다 [weak self]를 붙일지 말지 판단해야 했습니다.
실제로 둘 다 문제가 났었어요 — 안 붙여서 순환 참조가 난 곳도 있었고, 붙여서 업로드 완료 처리가 조용히 스킵된 곳도 있었습니다. 후자가 훨씬 찾기 어려웠고요.
그래서 팀 기준을 하나로 정리했습니다 — '이 클로저를 self가 저장하는가'로만 판단한다. 저장하면 weak, 아니면 붙이지 않는다.
DispatchQueue.main.async나 UIView.animate는 저장하지 않으니 안 붙입니다.
그 뒤 네트워크 계층을 async로 옮기면서 이 판단 자체가 필요 없어졌습니다. 콜백이 반환값이 되니 self를 붙잡을 클로저가 애초에 없거든요.
구조를 바꿔서 판단 지점을 없앤 것이 가장 큰 개선이었습니다."
꼬리 질문 대비
L2 옵셔널 클로저는 왜 자동으로 escaping인가요?
핵심은 "Optional로 감싸는 순간 컴파일러가 수명을 추적할 수 없다"입니다.
non-escaping의 보장은 "이 클로저 값이 함수 반환 전에 반드시 소멸한다"인데, 이걸 보장하려면 컴파일러가 그 값이 어디로 가는지 전부 알아야 합니다.
그런데 Optional<() -> Void>는 일반적인 제네릭 값입니다 — 다른 변수에 대입되고, 컬렉션에 담기고, 반환될 수 있죠. 그 모든 경로를 추적하는 건 현실적이지 않습니다.
func a(_ f: () -> Void) { } // non-escaping
func b(_ f: (() -> Void)?) { } // 자동으로 escaping
// func c(_ f: (@escaping () -> Void)?) { } // ❌ 중복이라 쓸 수 없다
// 같은 이유로 이것들도 escaping이다
func d(_ fs: [() -> Void]) { } // 배열에 담긴 클로저
struct Holder { let f: () -> Void } // 구조체 프로퍼티
// ✅ non-escaping을 유지하고 싶으면 옵셔널을 벗긴다
func e(_ f: () -> Void = { }) { } // 기본값으로 옵셔널 회피일반 규칙: 클로저가 "함수 파라미터로 직접" 오는 위치가 아니면 전부 escaping입니다.
L3 non-escaping이면 [weak self]가 정말 필요 없나요?
순환 참조 방지 목적으로는 필요 없습니다. 근거가 명확합니다 — non-escaping 클로저는 함수가 반환되기 전에 소멸하므로, self를 붙잡은 채로 살아남을 수 없습니다. 사이클이 성립하려면 양쪽이 서로를 계속 붙잡고 있어야 하는데, 한쪽이 곧 사라지니까요.
items.forEach { self.process($0) } // ✅ weak 불필요
let r = numbers.map { self.transform($0) } // ✅ weak 불필요
array.sorted { self.compare($0, $1) } // ✅ weak 불필요
// 컴파일러가 self. 생략도 허용한다는 것 자체가 "안전하다"는 신호다다만 두 가지 주의점이 있습니다.
- escaping인데 순환이 아닌 경우와 헷갈리지 말 것 —
DispatchQueue.main.async는 escaping이지만 순환은 아닙니다(실행 후 해제). 이건 "non-escaping이라 안전"이 아니라 "저장되지 않아서 안전"입니다. 이유가 다릅니다. - 재진입 상황 — non-escaping이라도 그 안에서 self의 상태를 바꾸면 논리 버그가 날 수 있습니다. 이건 수명 문제가 아니라 07번의 race condition 계열입니다.
L4 Go나 Java는 어노테이션 없이도 잘 되는데, Swift는 왜 개발자가 적어야 하나요?
세 가지 이유가 있고, 셋 다 트레이드오프입니다.
① ABI와 호출 규약의 일부이기 때문입니다. non-escaping 클로저는 호출 규약 자체가 다릅니다 — 캡처 컨텍스트가 스택에 있다고 가정하고, 소유권을 넘기지 않습니다(callee_guaranteed). 이건 함수 시그니처의 일부여야 하고, 그래서 ABI 안정성(02번)의 대상입니다. 라이브러리가 나중에 escaping으로 바꾸면 바이너리 호환이 깨집니다. 즉 이건 최적화가 아니라 공개 계약입니다.
② 전역 분석 없이 모듈 단위로 컴파일해야 하기 때문입니다. Go는 컴파일러가 전체를 보고 판단하지만, Swift는 분리 컴파일 + 미리 컴파일된 프레임워크를 지원해야 합니다. 남의 모듈 함수 본문을 못 보는데 이스케이프 여부를 어떻게 알까요? 시그니처에 적혀 있어야 합니다.
③ 계약이 문서 역할을 합니다. @escaping이 보이면 호출자는 "클로저가 오래 살 수 있으니 캡처를 조심하라"는 신호를 API만 보고 받습니다. Go 방식이면 이 정보가 구현 안에 숨어 있어서, 라이브러리 사용자는 알 수 없습니다.
대가도 분명합니다 — 보일러플레이트가 늘고, 리팩터링 시 시그니처가 전파됩니다. 함수 하나를 escaping으로 바꾸면 호출 체인 전체에 번지죠. Swift는 그 대가를 치르고 ABI 안정성과 API 명시성을 샀습니다. 02번의 "안전을 어노테이션으로 산다"가 여기서도 똑같이 나타납니다.
쉽게 이해하기
회의실에 서류를 들고 들어간다고 해 봅시다.
non-escaping은 "회의 끝나면 서류를 두고 나간다"는 약속입니다. 그러면 서류를 회의실 테이블에 그냥 올려 두면 됩니다. 회의 끝나면 테이블을 치우면서 같이 정리되니까요. 보관함에 넣고 번호표 받고 하는 절차가 필요 없습니다(힙 할당 없음).
@escaping은 "서류를 들고 나갈 수도 있다"는 뜻입니다. 그러면 테이블에 두면 안 되죠 — 보관함에 넣고 관리해야 합니다(힙 할당 + 참조 카운팅). 그리고 여기서 문제가 생깁니다. 내가 서류를 들고 있고, 서류에 내 이름이 적혀 있으면, 서로를 놓지 못해 영원히 남습니다(순환 참조).
그래서 Swift가 이상한 규칙 하나를 강요합니다. 들고 나갈 수 있는 서류를 만들 때는 "이건 내 것"이라고 크게 써야 합니다(self. 명시). 귀찮지만 이유가 있어요 — 내가 뭘 붙잡고 있는지 안 보이면 놓는 걸 잊으니까요.
여기가 재미있는 부분입니다. "이 물건이 이 방을 벗어나는가"를 판단해서 테이블에 둘지 보관함에 넣을지 정하는 것 — 이건 Swift만의 이야기가 아닙니다. Go도, 자바도 똑같은 판단을 합니다. 이름도 있어요 — 이스케이프 분석.
차이는 누가 판단하느냐입니다. Go는 비서가 알아서 판단해 줍니다(편하지만 결과가 안 보임). C++은 내가 정하는데 틀려도 아무도 안 말려 줍니다(그래서 사고가 남). Swift는 내가 계약서에 적고, 어기면 문에서 막습니다.
그리고 가장 좋은 해법은 규칙을 잘 지키는 게 아니라 상황 자체를 없애는 것입니다. async/await로 바꾸면 서류를 들고 나갈 일 자체가 없어집니다 — 회의실에서 답을 받아서 나오면 되니까요. 판단할 게 없으면 틀릴 일도 없습니다.
@escaping은 수명을 타입에 적는 계약이고, 그 계약이 있으면 컴파일러가 스택 할당과 ARC 생략이라는 보상을 준다. 이건 Swift 고유 문법이 아니라 이스케이프 분석이라는 고전 최적화를, 자동 추론 대신 명시적 계약으로 구현한 것이다.
설계 근거 · 1차 자료
이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.
- SE-0103 — 기본값을 뒤집은 제안 그 자체 — 이 챕터 설계 의도의 1차 출처
- TSPL — @escaping의 계약과 self 명시 요구의 공식 설명