반응형

iOS 개발자가 많이 하는 실수 - capture list를 잘못 사용해 클로저가 의도와 다르게 작동하는 실수

Swift 클로저에서 capture list는 캡처 방식(값/참조/weak/unowned 등)을 제어하는 중요한 문법입니다.
하지만 정확한 동작 방식을 이해하지 못한 상태에서 사용하면:

  • 값이 업데이트되지 않거나
  • 예상과 다른 시점의 값이 들어가거나
  • 여전히 strong self가 캡처되거나
  • 크래시나 미묘한 버그

가 발생합니다.

1. capture list 기본 개념

클로저에서 다음과 같은 구문을 쓸 수 있습니다.

{ [weak self, value = someValue] in
    ...
}

capture list는:

  • 클로저가 어떤 이름을 어떤 방식으로 캡처할지를 지정
  • 기본 캡처 규칙:
    • 참조 타입(class)은 참조 자체를 캡처 → strong reference
    • 값 타입(struct, enum)은 현재 시점의 값을 복사

capture list 안에서:

  • weak self → self를 약한 참조로 캡처 (Optional)
  • unowned self → self를 비소유 참조로 캡처 (Optional 아님, 해제 후 접근 시 크래시)
  • x = expr  expr의 현재 값을 캡처해서 x라는 이름으로 사용

2. 실수 1: [weak self] 썼는데도 강한 self가 캡처되는 경우

다음 코드는 겉으로 보기엔 weak self처럼 보이지만, 실제로는 strong self를 캡처합니다.

viewModel.onUpdate = { [weak self] in
    self?.reload()
    someAsyncCall {
        self?.doAnotherThing()   // ❌ 이 self는 어떤 self인가?
    }
}

여기에서:

  • 바깥 클로저의 capture list에는 [weak self]가 있지만
  • 안쪽 클로저 someAsyncCall { ... }에서는 다시 바깥의 self?를 캡처한 self를 strong으로 잡을 수 있습니다.

Swift는 self?라는 Optional을 통해 접근하더라도,

안쪽 클로저가 그 Optional 자체를 strong으로 캡처할 수 있기 때문에

결국 예상과 다른 참조 그래프가 생깁니다.

안전한 패턴: strongSelf로 한 번만 고정

viewModel.onUpdate = { [weak self] in
    guard let self = self else { return }

    self.reload()

    someAsyncCall { [weak self] in
        self?.doAnotherThing()
    }
}

또는:

viewModel.onUpdate = { [weak self] in
    guard let strongSelf = self else { return }

    strongSelf.reload()

    someAsyncCall { [weak strongSelf] in
        strongSelf?.doAnotherThing()
    }
}

포인트:

  • 각 클로저마다 캡처 방식을 명시
  • “이 self는 어떤 self인가?”를 항상 의식적으로 관리

3. 실수 2: [value = someVar]가 “참조”라고 착각

다음 코드를 보겠습니다.

var count = 0

let handler = { [value = count] in
    print("captured:", value)
}

count = 10
handler()   // 출력은?

직관적으로 “마지막 count 값 10이 찍히겠지?”라고 오해하는 경우가 많지만

실제 출력은:

captured: 0

이유:

  • capture list value = count는 당시 시점의 count 값을 복사해서 value라는 이름으로 캡처
  • 이후 count 값을 바꿔도, 클로저 안의 value는 이미 복사된 값

즉, [value = someVar]는 “스냅샷 캡처”에 가깝습니다.

올바른 이해

  • [value = expr]는 expr을 평가한 결과를 값으로 캡처
  • 이후 expr에 사용된 변수들이 변경되어도 클로저 안 값은 변하지 않음

4. 실수 3: 반복문 안에서 클로저가 모두 같은 값만 보는 문제

Swift에서 for 루프 안에서 클로저를 생성할 때

루프 변수 캡처 방식을 헷갈리면 의도와 다른 동작이 발생할 수 있습니다.

예:

var handlers: [() -> Void] = []

for i in 0..<3 {
    handlers.append {
        print("i =", i)
    }
}

handlers[0]()   // i = 0
handlers[1]()   // i = 1
handlers[2]()   // i = 2

Swift에서는 위 예시는 기대대로 작동하지만,

개발자가 “모든 핸들러가 마지막 값 2만 찍힐 것”이라 착각하는 경우도 있고

반대로 다른 언어의 경험(예: JS var 캡처) 때문에 헷갈려 하는 경우도 많습니다.

문제가 실제로 발생하는 패턴은 주로 “참조 타입”이나 “외부 값”을 캡처할 때입니다.

var item: Item? = nil
var handlers: [() -> Void] = []

for i in 0..<3 {
    item = Item(id: i)
    handlers.append {
        print("item.id =", item?.id ?? -1)
    }
}

handlers[0]()   // item.id = 2
handlers[1]()   // item.id = 2
handlers[2]()   // item.id = 2

이유:

  • 클로저 안에서는 item 변수 자체를 캡처
  • 루프가 끝나면 item에는 마지막 값만 남아 있음
  • 모든 클로저가 동일한 참조를 보게 됨

해결: capture list로 각 시점의 값 스냅샷 캡처

for i in 0..<3 {
    let item = Item(id: i)
    handlers.append { [item] in
        print("item.id =", item.id)   // ✔️ 각각 다른 item
    }
}

여기서 [item]은 루프 내부의 let item을 값으로 캡처하여

각 클로저마다 고유값을 갖게 합니다.


5. 실수 4: unowned를 남용해 크래시 유발

unowned는 메모리 누수를 막는 데 도움이 되지만,

self가 해제된 뒤에 클로저가 호출되면 즉시 크래시가 발생합니다.

viewModel.onUpdate = { [unowned self] in
    self.reload()   // self가 이미 해제되어 있으면 크래시
}

이 패턴은 다음과 같은 경우 매우 위험합니다.

  • viewModel이 VC보다 오래 살 수 있는 경우
  • 클로저 실행 시점이 예측하기 어려운 경우
  • Notification, Timer, async Task 등 외부에서 오래 유지되는 경우

권장 전략

  • 기본값: [weak self]
  • unowned는 다음과 같은 좁은 조건에서만 사용
    • 클로저 생명주기가 self보다 명백히 짧고
    • 로직상 self가 살아있지 않으면 클로저가 불릴 수 없는 경우

6. 실수 5: capture list 순서/평가 시점을 잘못 이해

var a = 1
var b = 2

let closure = { [x = a, y = b] in
    print(x, y)
}

a = 10
b = 20

closure()   // 무엇이 출력될까?

출력:

1 2

이유:

  • capture list는 클로저 생성 시점에 한 번 평가되어
    변수들이 복사되어 저장됨
  • 이후 원본 a, b가 변경되어도
    캡처된 x, y는 변하지 않음

이 동작은 “초기 상태 스냅샷이 필요할 때”는 매우 유용하지만,

“항상 최신 상태를 반영해주겠지”라고 오해하면 버그의 원인이 됩니다.


7. 좋은 패턴 vs 나쁜 패턴 비교

나쁜 패턴 (의도 모호, strong self 잠복)

viewModel.onUpdate = { [weak self] in
    self?.reload()
    someAsyncCall {
        self?.doSomething()
    }
}
  • 바깥 클로저에서는 weak self
  • 안쪽 클로저의 self는 어떤 캡처인지 불분명
  • strong reference cycle 위험

좋은 패턴 (self 생명주기 분명)

viewModel.onUpdate = { [weak self] in
    guard let self else { return }
    self.reload()

    someAsyncCall { [weak self] in
        self?.doSomething()
    }
}

또는:

viewModel.onUpdate = { [weak self] in
    guard let strongSelf = self else { return }
    strongSelf.reload()

    someAsyncCall { [weak strongSelf] in
        strongSelf?.doSomething()
    }
}

“스냅샷”이 필요한 경우의 좋은 패턴

let timeout = config.timeout

taskManager.startTask { [timeout] in
    // 이 클로저는 config가 변경되어도
    // 생성 시점의 timeout을 사용
    run(with: timeout)
}

8. 실무용 체크리스트

클로저 + capture list를 사용했을 때 다음을 점검합니다.

  1. [weak self]를 넣었지만, 내부에서 다시 self를 strong으로 캡처하는 코드가 없는가?
  2. unowned를 썼다면, self가 클로저보다 반드시 오래 산다고 정말 확신할 수 있는가?
  3. [value = expr]를 썼을 때, 값이 “스냅샷”이라는 사실을 알고 있는가?
  4. 반복문 내부 클로저에서 참조 타입을 캡처할 때, 의도한 값/객체를 캡처하고 있는가?
  5. deinit이 정상적으로 호출되는지 확인해보았는가?

9. 요약

  • capture list는 강력하지만,
    동작을 정확히 이해하지 못한 상태에서 사용하면 예측하기 어려운 버그를 만든다.
  • [weak self]를 적었다고 끝이 아니라,
    안쪽 클로저들까지 self 캡처 상태를 모두 확인해야 한다.
  • [value = someVar]는 “참조”가 아니라 “그 시점의 값 복사”이다.
  • 반복문, 비동기 로직, 수명 긴 객체와 함께 쓸 때 특히 주의해야 한다.

핵심 문장:

capture list는 ‘어떻게'와 '언제의 값'을 캡처할지 명시하는 도구다.
모호하게 쓰면, 코드도 모호하게 동작한다.

 

 

반응형
Posted by 까칠코더
,