Post

클로저 캡쳐 매커니즘

클로저 캡쳐 매커니즘

[Swift] 클로저 캡처: 내가 알던 값이 왜 아니지? (Value vs Reference Capture)

Swift 개발을 하다 보면 비동기 처리나 콜백 함수에서 예상과는 다른 값이 출력되어 당황하는 경우가 있습니다. “분명 값을 바꿨는데 왜 옛날 값이 나오지?” 혹은 반대로 “Struct인데 왜 최신 값이 나오지?” 같은 의문들이죠.

오늘은 실제 예제 코드 두 가지를 통해 **클로저의 캡처 리스트(Capture List)**와 메모리 참조 방식을 완벽하게 파헤쳐 보겠습니다.


Case 1: “왜 10이 아니라 0이 나오죠?” (Capture List)

첫 번째 예제는 Int형 변수(Value Type)와 클로저 캡처 리스트를 사용한 경우입니다.

1
2
3
4
5
6
7
8
var counter = 0
// [counter]를 통해 값을 '복사'해서 캡처함
let closure = { [counter] in 
    print(counter) 
}

counter = 10
closure() // 결과: 0 출력

💡 원인 분석: 캡처 리스트(Capture List)와 Const Value

이 코드의 핵심은 클로저 선언부의 대괄호 [counter]입니다. 이를 **캡처 리스트(Capture List)**라고 부릅니다.

  1. 복사(Copy) 발생 시점: [counter]를 작성하는 순간, 클로저는 **“클로저가 생성되는 시점”**의 counter 값(0)을 상수(Constant)로 복사하여 자신만의 영역에 저장해 둡니다.
  2. 독립성: 이후 외부의 counter 변수가 10이 되든 100이 되든, 클로저 내부에 이미 복사된 counter는 영향을 받지 않습니다.

만약 [counter]를 빼고 아래처럼 작성했다면 결과는 다릅니다.

1
2
3
4
5
6
// 캡처 리스트 없음
let closure = { 
    print(counter) // 외부 변수 counter의 '주소'를 참조
}
counter = 10
closure() // 결과: 10 출력

캡처 리스트가 없으면 클로저는 기본적으로 **Reference Capture(참조 캡처)**를 하기 때문에, 실행 시점에 외부 변수의 최신 값을 가져옵니다.


Case 2: “Struct인데 왜 업데이트된 값이 나오죠?” (Reference Capture)

두 번째 예제는 Struct(구조체)를 Class(클래스) 안에서 사용할 때 발생하는 혼동입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct User {
    var name: String
}

class ViewModel {
    var user = User(name: "Green")
    
    lazy var displayName: () -> String = {
        // ⚠️ struct는 값 복사라고 생각해서 "Green"이 고정될 것 같지만...
        return self.user.name
    }
    
    func updateUser() {
        user.name = "Updated"
        print(displayName()) 
    }
}

결과값: updateUser()를 실행하면 **“Updated”**가 출력됩니다.

💡 원인 분석: self를 캡처했기 때문

많은 분들이 User가 구조체(Value Type)이므로, 클로저가 생성될 때 “Green”이라는 값이 복사되어 박제될 것이라고 오해합니다. 하지만 여기엔 함정이 있습니다.

  1. 무엇을 캡처했는가?: 위 클로저 안에는 캡처 리스트([...])가 없습니다. 따라서 클로저는 self(ViewModel의 인스턴스)를 참조 캡처(Reference Capture) 합니다.
  2. 접근 경로: 클로저가 실행(displayName())될 때의 동작 순서는 다음과 같습니다.
    • ① 캡처해 둔 self (ViewModel 인스턴스)를 바라봅니다.
    • ② 그 인스턴스의 현재 user 프로퍼티에 접근합니다.
    • updateUser()에 의해 user는 이미 “Updated”로 변경된 상태입니다.
  3. 결론: User가 Struct인 것은 중요하지 않습니다. 우리가 캡처한 것은 User 그 자체가 아니라, User를 들고 있는 self이기 때문입니다.

만약 “Green” (변경 전 값)을 유지하고 싶다면?

이때야말로 캡처 리스트를 사용해야 합니다.

1
2
3
4
lazy var displayName: () -> String = { [user] in // [user]로 캡처 리스트 명시
    // 클로저 생성 시점의 user(Green)가 '값 복사'됨
    return user.name
}

위처럼 작성하면 self를 거치지 않고 user 구조체 자체를 복사해서 저장하므로, 나중에 값이 바뀌어도 “Green”이 출력됩니다.


🚀 핵심 요약 (Cheat Sheet)

구분코드 형태동작 방식키워드
기본 (Default){ print(value) }Reference Capture
실행 시점의 최신 값을 가져옴
참조, 최신값
캡처 리스트{ [value] in print(value) }Value Capture (Copy)
클로저 생성 시점의 값을 복사하여 저장 (상수 취급)
복사, 불변, 스냅샷
객체 속성 접근{ print(self.structProp) }Reference Capture of self
self를 참조하므로, 속성이 바뀌면 바뀐 값을 가져옴
self 참조

개발자를 위한 조언

  • 값이 나중에 바뀌더라도 클로저 생성 시점의 데이터를 유지해야 한다면 [변수명] 캡처 리스트를 꼭 사용하세요.
  • Class 내부에서 lazy var 클로저를 쓸 때, 내부 프로퍼티가 Struct라도 self를 통해 접근하면 언제나 최신 값을 가져온다는 점을 명심하세요.

This post is licensed under CC BY 4.0 by the author.