← 학습일지로 돌아가기

Closure Capture 완벽 가이드

클로저의 캡쳐 메커니즘, 메모리 구조, 값/참조 캡쳐 완전 정복

Q1. 캡쳐(Capture)란 무엇인가?

Q: 클로저에서 "캡쳐"라는 게 뭐야?
캡쳐 = 클로저가 외부 변수를 "잡아서" 자기 안에 저장하는 것

클로저는 자신의 스코프 밖에 있는 변수를 참조할 수 있고, 그 변수가 스코프를 벗어나도 계속 사용할 수 있다.

// 외부 변수 count를 캡쳐
func makeCounter() -> () -> Int {
    var count = 0
    return {
        count += 1    // count를 캡쳐함
        return count
    }
}

let counter = makeCounter()
print(counter())  // 1
print(counter())  // 2
print(counter())  // 3 - count가 계속 살아있음!
핵심: makeCounter()가 리턴하고 나서도 count 변수가 살아있다. 클로저가 캡쳐했기 때문!

Q2. 기본 캡쳐 방식 - 참조 캡쳐

Q: 클래스를 캡쳐하면 참조가 캡쳐되는 거지?
클래스든, 구조체든 기본적으로 "변수 자체"를 참조로 캡쳐한다.

변수가 가리키는 값이 바뀌면 클로저 내에서도 바뀐 값을 본다.

Class 캡쳐 예제

class ABC {
    var t = 12
}

let abc = ABC()
let closure = {
    print(abc.t)  // abc 캡쳐
}

abc.t = 100
closure()  // 100 출력 - 변경된 값 반영

Struct 캡쳐 예제

struct DEF {
    var t = 12
}

var def = DEF()
let closure = {
    print(def.t)  // def 캡쳐
}

def.t = 100
closure()  // 100 출력 - 변경된 값 반영!
주의: struct 변수를 capture list 없이 캡처하면 그 변수를 공유한다(값 복사 아님) → 캡처 후 바뀐 값이 반영된다.
⚠️ 단 "항상 힙 Box"는 아니다: 캡처한 클로저가 escaping일 때만 그 변수가 힙(캡처 컨텍스트)으로 올라간다. non-escaping이면 스택에 남을 수 있다(구현·최적화에 따라 다름). "struct는 무조건 참조 캡처 + 힙 Box"는 과도한 일반화.

Q3. 값 캡쳐 (Capture List)

Q: 클래스든, 구조체든 둘 다 힙에 두는 거지? 그러면 복사형식으로 하고 싶으면?
Capture List [var]를 사용하면 캡쳐 시점의 값을 복사한다.

Struct - 값 캡쳐

struct DEF { var t = 12 }

var def = DEF()
let closure = { [def] in  // 캡쳐 시점의 값 복사
    print(def.t)
}

def.t = 100
closure()  // 12 출력 - 복사본 사용

Class - Capture List의 한계

class ABC { var t = 12 }

let abc = ABC()
let closure = { [abc] in  // 레퍼런스만 복사됨!
    print(abc.t)
}

abc.t = 100
closure()  // 100 출력 - 여전히 원본 참조
Class는 Capture List를 써도 레퍼런스만 복사된다!

클래스는 본질적으로 레퍼런스 타입이기 때문.

Class 값 복사 방법

class ABC { var t = 12 }
let abc = ABC()

// 방법 1: 필요한 프로퍼티만 복사
let closure1 = { [copiedT = abc.t] in
    print(copiedT)  // 캡쳐 시점의 값
}

// 방법 2: 새 인스턴스 생성
let closure2 = { [copied = ABC()] in
    copied.t = abc.t  // 복사본 사용
    print(copied.t)
}

abc.t = 100
closure1()  // 12 출력

요약 테이블

타입 기본 캡쳐 Capture List 효과 값 복사 방법
Struct 변수 참조 (Box) [def] → 값 복사 ✅ Capture List
Class 레퍼런스 [abc] → 레퍼런스 복사 [copiedT = abc.t]

Q4. 클로저의 메모리 구조

Q: 클로저는 레퍼런스 타입이라 스택에는 포인터만 있고 클로저는 힙에 있나?
대체로 맞다(단 무조건은 아님). 클로저는 참조 의미를 가지며, 캡처한 컨텍스트가 힙에 할당될 때 참조 타입처럼 동작한다. 다만 캡처가 없거나 non-escaping으로 최적화되면 힙 할당이 없을 수 있다 — "클로저는 무조건 힙 객체"는 구현 세부에 따라 다르다.
STACK
abc 포인터 (힙의 ABC 인스턴스)
closure 포인터 (힙의 Closure Object)
HEAP
ABC 인스턴스
refCount 2 ← abc + closure.captured 둘 다 참조
t 100
Closure Object
code pointer 0x1000 (Code Segment)
captured: abc ABC 인스턴스 참조
CODE SEGMENT (Text)
0x1000 { print(abc.t) }
↑ 클로저 본문 코드 (Read-Only, 실행 가능)
Q: code pointer는 데이터 세그먼트야 아니면 코드 세그먼트를 가리켜?
Code Segment (Text Segment)를 가리킨다.
  • Code Segment: 실행 가능한 기계어 코드 (Read-Only, Execute)
  • Data Segment: 전역 변수, 상수 등 (Read-Write)
클로저 = Context + Code
  • Context: 캡쳐된 변수들 (힙에 저장)
  • Code: 실행할 코드 주소 (코드 세그먼트 가리킴)

Q5. Struct 캡쳐 시 Box 구조

Q: struct도 캡쳐되면 힙에 가나?
맞다. 클로저가 struct 변수를 캡쳐하면, 그 변수를 담는 "Box"가 힙에 생성된다.
struct DEF { var t = 12 }

var def = DEF()      // 원래는 스택에
let closure = {
    print(def.t)     // 캡쳐 → def가 Box에 담겨 힙으로 이동
}
캡쳐 전
STACK
def DEF(t: 12) ← 값 자체가 스택에
캡쳐 후
STACK
def 포인터
closure 포인터
HEAP
Box
DEF t: 12 ← def, closure 둘 다 참조
Closure Object
captured Box 참조
이것이 struct도 기본적으로 "참조 캡쳐"되는 이유:

스택의 def와 클로저 모두 같은 Box를 참조하므로, def.t = 100 하면 클로저에서도 100이 보인다.

핵심 정리

1. 기본 캡쳐 = 변수 참조
  • Class든 Struct든 변수를 참조로 캡쳐
  • 외부에서 변경하면 클로저 내에서도 변경된 값 보임
2. 값 캡쳐 = Capture List [var]
  • Struct: [def] → 값 복사 성공
  • Class: [abc] → 레퍼런스만 복사
  • Class 값 복사: [value = abc.property]
3. 메모리 구조
  • 클로저 = 레퍼런스 타입, 힙에 저장
  • 캡쳐된 변수도 Box에 담겨 힙에 저장
  • Code Pointer → Code Segment (실행 코드)

🔁 복습 Q&A (2026-06-30) — 클로저 내부 구조 다시 보기

Q1. makeCounter에서 count가 계속 살아있는데, counter 변수가 힙에 있는 건가?
정확히는 counter가 가리키는 "클로저 객체"가 힙에 있다. let counter 변수 자체는 스택/스코프에 있지만, 그 값은 힙 클로저를 가리키는 참조(포인터)다.
func makeCounter() -> () -> Int {
    var count = 0
    return { count += 1; return count }
}
let counter = makeCounter()
counter() // 1
counter() // 2  ← count가 사라지지 않고 누적
STACK
counter 포인터
HEAP
Closure Object
code pointer { count += 1; return count }
box pointer Box 참조
Box (캡처된 변수)
count 0 → 1 → 2 ... ← 함수 끝나도 유지
왜 안 사라지나:

반환되는 escaping 클로저count를 캡처 → count가 스택이 아니라 힙 Box에 담겨 클로저와 함께 유지. makeCounter가 끝나도 클로저가 count를 들고 있어서 호출마다 누적된다. (ARC: counter가 살아있는 동안 클로저+count strong 유지, counter=nil 되면 함께 해제)

Q2. "Class는 Capture List를 써도 레퍼런스만 복사된다"가 무슨 말?
[x] capture list는 "캡처 시점에 x의 을 복사"하는데, x가 클래스면 그 값이 곧 참조(포인터)다. 그래서 복사되는 건 참조뿐 → 클로저와 외부가 같은 객체를 공유한다(객체 복제가 아님).
// class — 참조만 복사 → 같은 객체 공유
class Box { var value = 0 }
var box = Box()
let c = { [box] in print(box.value) }
box.value = 10
c()   // 👉 10  (같은 Box 객체)

// struct — 값 복사 → 독립 스냅샷
struct SBox { var value = 0 }
var s = SBox()
let f = { [s] in print(s.value) }
s.value = 10
f()   // 👉 0   (캡처 시점 값 복사본)
capture list가 클래스에서 고정하는 것:

캡처 시점의 참조를 고정 → 이후 box다른 인스턴스로 재할당해도 클로저는 캡처한 그 인스턴스를 본다. 단 그 인스턴스의 내부 변경은 공유된다. 진짜 독립 복사를 원하면 [snap = box.value]처럼 직접 값을 떠서 캡처해야 한다.

Q3. 클로저 / 코드 포인터 / 박스 — 각각 뭔가?
클로저 = "실행할 코드 + 그 코드가 쓸 데이터"를 묶은 힙 객체. 안에 두 부분이 있다 ↓
용어 정체 비유(요리사)
코드 포인터 실행할 함수 본문(기계어)의 주소. "무슨 코드를 실행할지". 코드 영역(text)을 가리킴 레시피 책갈피
박스(Box) 캡처한 변수를 담는 힙 공간. "그 코드가 쓸 데이터". 함수가 끝나도 유지 재료 바구니
왜 힙에?

escaping 클로저는 자기를 만든 함수보다 오래 살아야 하므로(스택은 함수 끝나면 사라짐) 클로저 객체도, 캡처 Box도 에 둔다. 호출 = 코드 포인터로 점프해 본문 실행 + Box의 변수 사용.

Q4. escaping vs non-escaping 메모리 위치 (2026-07-06)

클로저와 캡처 변수의 위치

클로저 자체 캡처된 변수
escaping 힙 (Box)
non-escaping 스택 가능 스택 가능
핵심 원리:
  • escaping = 함수보다 오래 산다 = 스택 프레임 사라져도 데이터 필요 =
  • non-escaping = 함수와 함께 죽는다 = 스택 프레임 살아있는 동안만 = 스택 가능

클로저 내부 지역변수는?

escaping이든 non-escaping이든 클로저 내부 지역변수는 실행 시점에 스택이다.

변수 종류 escaping일 때 위치
캡처된 변수 (외부에서 가져온 것) 힙 (Box)
클로저 내부 지역변수 (안에서 선언) 스택 (실행 시)
// 예시
var captured = 10  // 외부 변수

let closure: () -> Void = {
    // captured → 힙 (Box) - escaping이라 함수 끝나도 살아야 함

    let local = 20  // 클로저 내부 지역변수 → 실행 시 스택
    print(captured + local)
}

DispatchQueue.main.async(execute: closure)
클로저 실행 시 메모리 구조
HEAP
Closure Object
code pointer Code Segment
box pointer captured Box
captured Box
captured 10
STACK (클로저 실행 중)
local 20 ← 지역변수는 스택
왜 이렇게 되나?
  • 캡처된 변수: 클로저가 만들어질 때부터 필요 → 오래 살아야 함 → 힙
  • 내부 지역변수: 클로저가 실행될 때만 필요 → 실행 끝나면 버림 → 스택

클로저도 결국 함수다. 실행되면 스택 프레임 생기고, 내부 지역변수는 그 스택 프레임에 할당된다.

Q5. 캡처된 변수와 ARC 동작 (2026-07-06)

Q: 캡처된 변수가 힙에 클래스처럼 ARC 관리되나?
맞다. 캡처된 변수를 담는 Box 자체가 ARC로 관리된다. Box는 refCount를 가진 힙 객체다.

예시 코드

func makeCounter() -> () -> Int {
    var count = 0                    // ① count 생성
    let closure = { () -> Int in     // ② 클로저가 count 캡처
        count += 1
        return count
    }
    return closure                   // ③ 클로저 반환
}                                    // ④ makeCounter 종료

let counter = makeCounter()          // ⑤ counter가 클로저 참조
counter()                            // ⑥ → 1
counter()                            // ⑦ → 2
// counter 스코프 종료               // ⑧ 해제

① 캡처 전 - count는 스택에

makeCounter 스택 프레임
count 0 ← 아직 스택에 있음
힙: (없음)

② 클로저가 count 캡처 - Box 생성

makeCounter 스택 프레임
count 포인터
closure 포인터
HEAP
Box
refCount 2 ← count 포인터 + Closure
value 0
Closure Object
refCount 1 ← closure 포인터
captured Box

④ makeCounter 종료 - 스택 프레임 사라짐

makeCounter 스택 프레임: (사라짐) → count, closure 포인터 해제
HEAP (여전히 살아있음)
Box
refCount 21 ← Closure만 참조
value 0
Closure Object
refCount 1 ← 반환값으로 전달됨

⑤ counter가 클로저 참조

호출자 스택
counter Closure Object
HEAP
Closure Object
refCount 1
captured Box
Box
refCount 1
value 0

⑥⑦ counter() 호출

counter() → count += 1 → Box.value = 1
counter() → count += 1 → Box.value = 2

Box는 계속 살아있음 (refCount: 1)

⑧ counter 스코프 종료 - 해제 체인

  1. counter 해제 → Closure refCount: 1 → 0Closure 해제
  2. Closure 해제 → Box 참조 해제 → Box refCount: 1 → 0Box 해제

결과: 힙에 아무것도 안 남음

refCount 변화 요약

시점 Closure refCount Box refCount Box.value
② 캡처 직후 1 2 0
④ makeCounter 종료 1 1 0
⑥ counter() 1 1 1
⑦ counter() 1 1 2
⑧ counter 해제 0 → 해제 0 → 해제 -
핵심 정리
  • Box refCount가 2인 이유: 스택의 원본 변수(count)와 Closure 둘 다 같은 Box를 참조
  • 함수 끝나도 Box가 살아있는 이유: Closure가 Box를 참조 → refCount가 0이 아님
  • 해제 순서: counter 해제 → Closure 해제 → Box 해제 (연쇄 반응)

Q6. Capture List가 클래스(참조 타입)에서 고정하는 것 (2026-07-06)

Q: capture list [box]가 클래스 인스턴스를 캡처할 때 뭘 고정하는 건가?
참조(주소)를 고정한다 — 인스턴스 자체가 아니라 "그 인스턴스를 가리키는 포인터"를 복사.

코드로 이해하기

class Box { var value = 10 }

var box = Box()

let closure = { [box] in      // ← capture list
    print(box.value)
}

box.value = 99                // 같은 인스턴스의 내부 변경
box = Box()                   // box 변수를 다른 인스턴스로 재할당

closure()  // 99 출력

왜 99인가?

상황 결과 이유
box.value = 99 ✅ 반영됨 같은 인스턴스를 가리키니까 내부 변경은 공유
box = Box() ❌ 무시됨 변수 재할당은 새 주소 — 클로저는 옛 주소를 봄

메모리 상태 시각화

① 캡처 직후
Stack
box → 0x1000
closure.box → 0x1000
Heap (0x1000)
Box { value: 10 }
둘 다 같은 인스턴스(0x1000) 참조
② box.value = 99
Stack
box → 0x1000
closure.box → 0x1000
Heap (0x1000)
Box { value: 99 }
✅ 같은 인스턴스 내부 변경 → 둘 다 99를 봄
③ box = Box()
Stack
box0x2000
closure.box → 0x1000
Heap
(0x1000) Box { value: 99 }
(0x2000) Box { value: 10 }
❌ 변수 재할당 → 클로저는 여전히 0x1000

진짜 독립 복사가 필요하면?

class Box { var value = 10 }
var box = Box()

let closure = { [snap = box.value] in   // value 자체를 복사
    print(snap)                          // Int는 값 타입 → 완전히 독립
}

box.value = 99
closure()  // 10 출력 — snap은 캡처 시점의 값(10)을 가지고 있음
핵심 정리
  • [box] = "캡처 시점에 box가 가리키는 인스턴스의 참조(주소)를 복사"
  • 내부 변경은 공유됨 (같은 인스턴스니까)
  • 변수 재할당은 분리됨 (주소가 달라지니까)
  • 완전 독립이 필요하면 [snap = box.value]처럼 값을 직접 캡처