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를 써도 괜찮습니다.
- non-escaping 클로저 (함수 내에서만 실행되고 끝나는 경우)
- 값이 즉시 사용되고, self 생존이 반드시 보장되어야 하는 경우
- 옵셔널 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도 허용 가능하지만,
컨텍스트를 잘 구분해서 사용하는 것이 중요하다.

