반응형

 iOS 개발자가 많이 하는 실수 - 클로저에서 self를 강하게(strong) 캡처해 메모리 누수가 발생

 

ARC 기반인 Swift/iOS에서 가장 흔한 메모리 누수(메모리 릭) 원인 중 하나가 바로

클로저가 self를 강하게 잡고 있고,

self도 그 클로저를 강하게 참조하는 순환 참조(Retain Cycle)

입니다.

특히 다음과 같은 상황에서 자주 발생합니다.

  • URLSession completion handler
  • Timer
  • 애니메이션 클로저
  • 라이브러리/프레임워크가 유지하는 callback
  • 비동기 작업 큐(DispatchQueue, OperationQueue, async Task 등)

1. ARC와 클로저 캡처의 기본 원리

Swift는 참조 카운트(ARC)로 메모리를 관리합니다.

  • class 인스턴스는 참조 카운트가 0이 되면 메모리 해제
  • 강한 참조(strong)가 하나라도 남아 있으면 해제되지 않음

클로저는 기본적으로 참조 타입이며,

클로저 내부에서 self를 사용하면 self를 강하게 캡처(strong capture) 합니다.

service.request { response in
    self.handle(response)    // 여기서 self를 강하게 캡처
}

만약:

  • self가 이 클로저를 프로퍼티로 가지고 있고(strong)
  • 클로저는 self를 캡처(strong) 한다면

→ 둘이 서로를 강하게 참조하는 순환 참조(retain cycle)가 발생해

메모리가 해제되지 않습니다.


2. 대표적인 메모리 누수 패턴


2-1. URLSession + ViewController

class MyViewController: UIViewController {
    func loadData() {
        URLSession.shared.dataTask(with: url) { data, response, error in
            self.handle(data)  // ❌ self 강한 캡처
        }.resume()
    }
}

만약 ViewController가 어디선가 URLSessionTask를 강하게 유지하고 있고,

그 Task의 completion 클로저에서 self를 강하게 잡으면

VC가 화면에서 사라져도 해제되지 않을 수 있습니다.


2-2. Timer

class MyViewController: UIViewController {
    var timer: Timer?

    func startTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            self.tick()   // ❌ self 강한 캡처 + timer도 self가 강하게 들고 있음
        }
    }
}
  • self  timer를 strong으로 유지
  • timer → 클로저를 strong으로 유지
  • 클로저 → self를 strong으로 캡처

→ ViewController가 닫혀도 메모리가 해제되지 않음


2-3. Notification / KVO / 기타 Callback

NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { note in
    self.handle(note)   // ❌ self 강한 캡처
}

removeObserver를 하지 않거나,

observer를 어딘가에서 강하게 유지하면 동일한 문제가 발생할 수 있습니다.


3. 해결책: 캡처 리스트와 weak self / unowned self


3-1. [weak self] 기본 패턴

service.request { [weak self] response in
    guard let self else { return }    // 또는 guard let self = self else { return }
    self.handle(response)
}
  • 클로저는 self를 약한 참조(weak)로 캡처
  • self가 먼저 해제되면 클로저 안의 self는 nil이 됨
  • retain cycle이 발생하지 않음

Timer 예시:

timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
    guard let self else { return }
    self.tick()
}

3-2. [unowned self] 패턴 (주의 필요)

service.request { [unowned self] response in
    self.handle(response)
}
  • self가 클로저보다 항상 오래 살아있다고 보장할 수 있을 때만 사용
  • self가 먼저 해제되면 크래시 발생

실무에서는 [weak self]가 더 안전하며,

[unowned self]는 성능/논리상 확실한 경우에만 신중히 사용.


3-3. self를 캡처해야 할지 먼저 판단하기

모든 클로저에 무조건 [weak self]를 다는 것도 안티 패턴에 가깝고,

“이 클로저의 생명주기가 self보다 길 가능성이 있는가?”를 먼저 판단하는 것이 중요합니다.

  • 그럴 가능성이 있다 → [weak self]
  • 절대 없다 (예: 즉시 실행, non-escaping) → strong self 허용

4. escaping / non-escaping 클로저 이해


4-1. non-escaping 클로저에서는 strong self 허용

func work(_ handler: () -> Void) {
    handler()
}

work {
    self.doSomething()    // 여기서는 순환 참조 위험 거의 없음
}

non-escaping 클로저는 함수 실행 중에만 살아있고

함수 종료 후에는 해제되므로 retain cycle이 발생할 가능성이 매우 낮습니다.


4-2. escaping 클로저에서는 항상 주의

func request(completion: @escaping (Result<Data, Error>) -> Void) {
    self.completion = completion  // 어딘가에 저장
}

이 completion에서 self를 strong으로 캡처하면:

  • self → completion 강한 참조
  • completion → self 강한 캡처

순환 참조 발생 가능성이 매우 크므로

escaping 클로저에는 기본적으로 [weak self]를 고려해야 합니다.


5. 실무에서의 패턴 정리


5-1. 기본 추천 패턴: weak self + guard let self

{ [weak self] value in
    guard let self else { return }
    self.handle(value)
}

장점:

  • retain cycle 방지
  • self가 해제된 시점에는 클로저 로직을 조용히 종료
  • 가장 널리 쓰이는 안전한 패턴

5-2. async/await + self 캡처

Task { [weak self] in
    guard let self else { return }
    let data = try await api.fetch()
    self.updateUI(data)
}

여기서도 Task는 escaping context이므로 [weak self]가 필요합니다.


5-3. Timer 해제까지 포함한 안전 패턴

class MyViewController: UIViewController {
    private var timer: Timer?

    func startTimer() {
        timer?.invalidate()
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            guard let self else {
                return
            }
            self.tick()
        }
    }

    deinit {
        timer?.invalidate()
    }
}
  • [weak self]로 retain cycle 방지
  • deinit에서 Timer invalidate까지 해주는 것이 좋음

6. self를 strong으로 캡처해도 괜찮은 경우

무조건 [weak self]가 정답은 아닙니다.

다음과 같은 경우는 strong self를 써도 괜찮습니다.

  1. non-escaping 클로저 (함수 내에서만 실행되고 끝나는 경우)
  2. 값이 즉시 사용되고, self 생존이 반드시 보장되어야 하는 경우
  3. 옵셔널 self 처리가 오히려 로직을 복잡하게 만들 때

예:

UIView.animate(withDuration: 0.25) {
    self.view.layoutIfNeeded()   // 보통 여기선 strong self도 괜찮은 편
}

다만, UIView.animate도 내부적으로 escaping일 수 있음을 고려해

일관성 있게 [weak self]를 쓰는 팀도 많습니다.


7. 정리

  • 클로저는 기본적으로 self를 강하게 캡처한다.
  • self와 클로저가 서로를 강하게 참조하면 retain cycle → 메모리 누수 발생.
  • 특히 escaping 클로저(URLSession, Timer, Notification, Task 등)에서는 [weak self]를 기본으로 고려해야 한다.
  • 가장 실무적인 패턴:
    • [weak self] + guard let self else { return }
  • non-escaping, 짧은 생명주기 클로저에서는 strong self도 허용 가능하지만,
    컨텍스트를 잘 구분해서 사용하는 것이 중요하다.

 

반응형
Posted by 까칠코더
,