Closure Capture 완벽 가이드
클로저의 캡쳐 메커니즘, 메모리 구조, 값/참조 캡쳐 완전 정복
Q1. 캡쳐(Capture)란 무엇인가?
클로저는 자신의 스코프 밖에 있는 변수를 참조할 수 있고, 그 변수가 스코프를 벗어나도 계속 사용할 수 있다.
// 외부 변수 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가 계속 살아있음!
count 변수가 살아있다. 클로저가 캡쳐했기 때문!
Q2. 기본 캡쳐 방식 - 참조 캡쳐
변수가 가리키는 값이 바뀌면 클로저 내에서도 바뀐 값을 본다.
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 출력 - 변경된 값 반영!
⚠️ 단 "항상 힙 Box"는 아니다: 캡처한 클로저가 escaping일 때만 그 변수가 힙(캡처 컨텍스트)으로 올라간다. non-escaping이면 스택에 남을 수 있다(구현·최적화에 따라 다름). "struct는 무조건 참조 캡처 + 힙 Box"는 과도한 일반화.
Q3. 값 캡쳐 (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 값 복사 방법
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. 클로저의 메모리 구조
- Code Segment: 실행 가능한 기계어 코드 (Read-Only, Execute)
- Data Segment: 전역 변수, 상수 등 (Read-Write)
- Context: 캡쳐된 변수들 (힙에 저장)
- Code: 실행할 코드 주소 (코드 세그먼트 가리킴)
Q5. Struct 캡쳐 시 Box 구조
struct DEF { var t = 12 }
var def = DEF() // 원래는 스택에
let closure = {
print(def.t) // 캡쳐 → def가 Box에 담겨 힙으로 이동
}
스택의 def와 클로저 모두 같은 Box를 참조하므로, def.t = 100 하면 클로저에서도 100이 보인다.
핵심 정리
- Class든 Struct든 변수를 참조로 캡쳐
- 외부에서 변경하면 클로저 내에서도 변경된 값 보임
- Struct:
[def]→ 값 복사 성공 - Class:
[abc]→ 레퍼런스만 복사 - Class 값 복사:
[value = abc.property]
- 클로저 = 레퍼런스 타입, 힙에 저장
- 캡쳐된 변수도 Box에 담겨 힙에 저장
- Code Pointer → Code Segment (실행 코드)
🔁 복습 Q&A (2026-06-30) — 클로저 내부 구조 다시 보기
let counter 변수 자체는 스택/스코프에 있지만, 그 값은 힙 클로저를 가리키는 참조(포인터)다.
func makeCounter() -> () -> Int {
var count = 0
return { count += 1; return count }
}
let counter = makeCounter()
counter() // 1
counter() // 2 ← count가 사라지지 않고 누적
반환되는 escaping 클로저가 count를 캡처 → count가 스택이 아니라 힙 Box에 담겨 클로저와 함께 유지. makeCounter가 끝나도 클로저가 count를 들고 있어서 호출마다 누적된다. (ARC: counter가 살아있는 동안 클로저+count strong 유지, counter=nil 되면 함께 해제)
[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 (캡처 시점 값 복사본)
캡처 시점의 참조를 고정 → 이후 box를 다른 인스턴스로 재할당해도 클로저는 캡처한 그 인스턴스를 본다. 단 그 인스턴스의 내부 변경은 공유된다. 진짜 독립 복사를 원하면 [snap = box.value]처럼 직접 값을 떠서 캡처해야 한다.
| 용어 | 정체 | 비유(요리사) |
|---|---|---|
| 코드 포인터 | 실행할 함수 본문(기계어)의 주소. "무슨 코드를 실행할지". 코드 영역(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)
- 캡처된 변수: 클로저가 만들어질 때부터 필요 → 오래 살아야 함 → 힙
- 내부 지역변수: 클로저가 실행될 때만 필요 → 실행 끝나면 버림 → 스택
클로저도 결국 함수다. 실행되면 스택 프레임 생기고, 내부 지역변수는 그 스택 프레임에 할당된다.
Q5. 캡처된 변수와 ARC 동작 (2026-07-06)
예시 코드
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는 스택에
② 클로저가 count 캡처 - Box 생성
④ makeCounter 종료 - 스택 프레임 사라짐
⑤ counter가 클로저 참조
⑥⑦ counter() 호출
counter() → count += 1 → Box.value = 1
counter() → count += 1 → Box.value = 2
Box는 계속 살아있음 (refCount: 1)
⑧ counter 스코프 종료 - 해제 체인
- counter 해제 → Closure refCount: 1 → 0 → Closure 해제
- Closure 해제 → Box 참조 해제 → Box refCount: 1 → 0 → Box 해제
결과: 힙에 아무것도 안 남음
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)
[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() |
❌ 무시됨 | 변수 재할당은 새 주소 — 클로저는 옛 주소를 봄 |
메모리 상태 시각화
진짜 독립 복사가 필요하면?
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]처럼 값을 직접 캡처