← Mobile Foundation 인터뷰 10 / 13

10 · Escaping / Non-Escaping

성능 옵션이 아니라 수명 계약 → SIL → 이스케이프 분석과 클로저 변환
진행률
0 / 0 완료
면접에서 나오는 형태
  1. @escaping은 무슨 뜻인가요? 왜 기본값이 non-escaping인가요?
  2. escaping 클로저에서는 왜 self.명시해야 하나요?
  3. 이 구분이 실제로 무엇을 바꾸나요?
L1 개념
@escaping"이 클로저가 함수 반환 이후까지 살 수 있다"는 수명 계약
L2 설계
기본값이 non-escaping인 이유 — 순환 참조가 원리적으로 불가능하고, 스택에 올릴 수 있다
L3 구현
SIL에서 partial_apply [on_stack] vs 힙 박스 할당으로 갈린다
L4 CS
이스케이프 분석 · 클로저 변환 · 수명 추론 — JVM·Go에도 있는 고전 최적화

30초 답변 🔥 먼저 이 문장

@escaping은 성능 옵션이 아니라 수명 계약입니다. "이 클로저가 함수가 반환된 뒤에도 살아 있을 수 있다"는 선언이죠.

Swift는 non-escaping이 기본값입니다. 왜냐하면 수명이 함수 스코프로 한정되면 세 가지를 얻기 때문입니다 — ① 클로저 컨텍스트를 스택에 올릴 수 있고, ② 캡처한 값의 수명을 연장할 필요가 없으며, ③ 순환 참조가 원리적으로 불가능합니다.

컴파일러가 escaping에서만 self.를 강제하는 이유가 바로 그것입니다 — "지금 self의 수명을 연장하고 있다"를 코드에 남기려고요.

L1개념 — 차이 전체와 판별

non-escaping (기본)@escaping
수명함수 반환 전 종료 보장반환 이후까지 가능
컨텍스트 할당스택 가능 (partial_apply [on_stack])힙 박스 + ARC
캡처수명 연장 불필요한 경우가 많다캡처 대상을 retain — 수명 연장
self생략 가능self. 또는 [self] 명시 필요
순환 참조원리적으로 불가능가능 → [weak self] 검토
프로퍼티 저장불가가능
재귀 사용배열에 담기 불가가능
코드로 보는 네 가지 escaping 필요 상황
// ───── 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에서 기본값을 뒤집었는데, 그 근거가 이 주제의 핵심입니다.

  1. 안전한 쪽이 기본이어야 한다 — non-escaping은 순환 참조가 불가능합니다. 위험한 쪽에 표시를 붙이는 게 맞습니다.
  2. 흔한 쪽이 기본이어야 한다map·filter·forEach처럼 대부분의 클로저 인자는 즉시 소비됩니다.
  3. 최적화 기회가 커진다 — 기본이 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)
}
비동기 전환 — escaping이 통째로 사라진다
// ───── 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에서 스택과 힙이 갈리는 지점

non-escaping — 스택에 올린다 STACK 함수 프레임 (지역 변수) 클로저 컨텍스트 캡처한 값들 — 같은 프레임 안 함수가 반환되면 프레임과 함께 사라진다 할당 비용 0 · ARC 없음 · 캐시에 붙어 있음 SIL: partial_apply [on_stack] @escaping — 힙에 박스를 만든다 STACK 함수 프레임 → 반환되면 사라짐 HEAP 클로저 박스 (refCount 있음) 캡처한 값들 — 여기로 옮겨진다 캡처된 객체는 retain 된다 할당 + ARC + 캐시 미스 + 순환 참조 가능 SIL: alloc_box + partial_apply
차이의 실체는 "이 클로저가 함수 프레임보다 오래 사는가" 하나다. 오래 살면 프레임과 함께 사라지면 안 되니 힙으로 옮겨야 한다.
직접 확인하기 — 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
//                                      //    컴파일 에러 아님!
🧠 L4 Swift가 택한 위치가 흥미로운 지점

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을 안다"가 아니라 "구분이 무엇을 바꿨는가"

"@escaping은 함수 밖에서도 쓰이는 클로저에 붙입니다."

"레거시 네트워크 계층이 전부 completion handler 방식이었고, 콜백마다 [weak self]를 붙일지 말지 판단해야 했습니다. 실제로 둘 다 문제가 났었어요 — 안 붙여서 순환 참조가 난 곳도 있었고, 붙여서 업로드 완료 처리가 조용히 스킵된 곳도 있었습니다. 후자가 훨씬 찾기 어려웠고요.

그래서 팀 기준을 하나로 정리했습니다 — '이 클로저를 self가 저장하는가'로만 판단한다. 저장하면 weak, 아니면 붙이지 않는다. DispatchQueue.main.asyncUIView.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입니다.

쉽게: "이 사람은 회의 끝나면 바로 나갑니다"를 보장하려면 그 사람이 어디 가는지 다 봐야 합니다. 그런데 봉투에 넣어서 다른 사람에게 넘기면(Optional로 감싸기) 어디로 갈지 추적할 수 없죠. 그래서 "언제까지 있을지 모른다"로 취급합니다.
L3 non-escaping이면 [weak self]가 정말 필요 없나요?

순환 참조 방지 목적으로는 필요 없습니다. 근거가 명확합니다 — non-escaping 클로저는 함수가 반환되기 전에 소멸하므로, self를 붙잡은 채로 살아남을 수 없습니다. 사이클이 성립하려면 양쪽이 서로를 계속 붙잡고 있어야 하는데, 한쪽이 곧 사라지니까요.

non-escaping에는 weak이 불필요하다
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.asyncescaping이지만 순환은 아닙니다(실행 후 해제). 이건 "non-escaping이라 안전"이 아니라 "저장되지 않아서 안전"입니다. 이유가 다릅니다.
  • 재진입 상황 — non-escaping이라도 그 안에서 self의 상태를 바꾸면 논리 버그가 날 수 있습니다. 이건 수명 문제가 아니라 07번의 race condition 계열입니다.
쉽게: 회의실 안에서만 서류를 보는 사람에게는 "가져가지 마세요"라고 할 필요가 없습니다. 애초에 나갈 때 두고 가니까요. 반면 복사해서 가져가는 사람(escaping)에게는 말해야 합니다.
L4 Go나 Java는 어노테이션 없이도 잘 되는데, Swift는 왜 개발자가 적어야 하나요?

세 가지 이유가 있고, 셋 다 트레이드오프입니다.

① ABI와 호출 규약의 일부이기 때문입니다. non-escaping 클로저는 호출 규약 자체가 다릅니다 — 캡처 컨텍스트가 스택에 있다고 가정하고, 소유권을 넘기지 않습니다(callee_guaranteed). 이건 함수 시그니처의 일부여야 하고, 그래서 ABI 안정성(02번)의 대상입니다. 라이브러리가 나중에 escaping으로 바꾸면 바이너리 호환이 깨집니다. 즉 이건 최적화가 아니라 공개 계약입니다.

② 전역 분석 없이 모듈 단위로 컴파일해야 하기 때문입니다. Go는 컴파일러가 전체를 보고 판단하지만, Swift는 분리 컴파일 + 미리 컴파일된 프레임워크를 지원해야 합니다. 남의 모듈 함수 본문을 못 보는데 이스케이프 여부를 어떻게 알까요? 시그니처에 적혀 있어야 합니다.

③ 계약이 문서 역할을 합니다. @escaping이 보이면 호출자는 "클로저가 오래 살 수 있으니 캡처를 조심하라"는 신호를 API만 보고 받습니다. Go 방식이면 이 정보가 구현 안에 숨어 있어서, 라이브러리 사용자는 알 수 없습니다.

대가도 분명합니다 — 보일러플레이트가 늘고, 리팩터링 시 시그니처가 전파됩니다. 함수 하나를 escaping으로 바꾸면 호출 체인 전체에 번지죠. Swift는 그 대가를 치르고 ABI 안정성과 API 명시성을 샀습니다. 02번의 "안전을 어노테이션으로 산다"가 여기서도 똑같이 나타납니다.

쉽게: Go는 "알아서 판단해 주는 비서"이고 Swift는 "계약서에 적으라는 변호사"입니다. 비서가 편하지만, 다른 회사와 거래할 때(다른 모듈·미리 만든 라이브러리)는 계약서에 적혀 있어야 서로 믿을 수 있습니다.

쉽게 이해하기

회의실에 서류를 들고 들어간다고 해 봅시다.

non-escaping"회의 끝나면 서류를 두고 나간다"는 약속입니다. 그러면 서류를 회의실 테이블에 그냥 올려 두면 됩니다. 회의 끝나면 테이블을 치우면서 같이 정리되니까요. 보관함에 넣고 번호표 받고 하는 절차가 필요 없습니다(힙 할당 없음).

@escaping"서류를 들고 나갈 수도 있다"는 뜻입니다. 그러면 테이블에 두면 안 되죠 — 보관함에 넣고 관리해야 합니다(힙 할당 + 참조 카운팅). 그리고 여기서 문제가 생깁니다. 내가 서류를 들고 있고, 서류에 내 이름이 적혀 있으면, 서로를 놓지 못해 영원히 남습니다(순환 참조).

그래서 Swift가 이상한 규칙 하나를 강요합니다. 들고 나갈 수 있는 서류를 만들 때는 "이건 내 것"이라고 크게 써야 합니다(self. 명시). 귀찮지만 이유가 있어요 — 내가 뭘 붙잡고 있는지 안 보이면 놓는 걸 잊으니까요.

여기가 재미있는 부분입니다. "이 물건이 이 방을 벗어나는가"를 판단해서 테이블에 둘지 보관함에 넣을지 정하는 것 — 이건 Swift만의 이야기가 아닙니다. Go도, 자바도 똑같은 판단을 합니다. 이름도 있어요 — 이스케이프 분석.

차이는 누가 판단하느냐입니다. Go는 비서가 알아서 판단해 줍니다(편하지만 결과가 안 보임). C++은 내가 정하는데 틀려도 아무도 안 말려 줍니다(그래서 사고가 남). Swift는 내가 계약서에 적고, 어기면 문에서 막습니다.

그리고 가장 좋은 해법은 규칙을 잘 지키는 게 아니라 상황 자체를 없애는 것입니다. async/await로 바꾸면 서류를 들고 나갈 일 자체가 없어집니다 — 회의실에서 답을 받아서 나오면 되니까요. 판단할 게 없으면 틀릴 일도 없습니다.

🔑 한 문장

@escaping수명을 타입에 적는 계약이고, 그 계약이 있으면 컴파일러가 스택 할당과 ARC 생략이라는 보상을 준다. 이건 Swift 고유 문법이 아니라 이스케이프 분석이라는 고전 최적화를, 자동 추론 대신 명시적 계약으로 구현한 것이다.

설계 근거 · 1차 자료

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

📌 각 자료가 뒷받침하는 것
  • SE-0103기본값을 뒤집은 제안 그 자체 — 이 챕터 설계 의도의 1차 출처
  • TSPL — @escaping의 계약과 self 명시 요구의 공식 설명