반응형

iOS 개발자가 많이 하는 실수 - weak self를 빼먹어 강한 순환 참조(Strong Reference Cycle)로 메모리 누수가 발생하는 실수

 

iOS 개발에서 가장 흔한 메모리 누수 원인은 강한 순환 참조(Strong Reference Cycle)입니다.
특히 클로저(closure) 내부에서 self를 사용할 때 weak self 또는 unowned self를 쓰지 않으면
뷰 컨트롤러, 뷰모델, 매니저 객체 등이 해제되지 않고 메모리에 계속 남아있는 문제가 발생합니다.

 

1. 강한 순환 참조란 무엇인가?

Swift에서는 기본적으로 클래스 타입이 참조(reference)로 관리됩니다.

예:

class ViewModel {
    var onUpdate: (() -> Void)?
}

여기에서 다음 구조가 자주 나타납니다:

  • ViewController가 ViewModel을 강한 참조
  • ViewModel의 클로저가 ViewController(self)를 강한 참조

즉:

ViewController → ViewModel → Closure → ViewController

이렇게 서로를 강하게 잡고 있으면:

  • 화면에서 사라져도 ViewController가 해제되지 않음
  • 메모리 누수 발생
  • 다시 화면에 진입하면 객체가 중복 생성되어 버그 발생 가능

2. 문제 패턴: 클로저에서 self를 직접 캡처

예:

class MyViewController: UIViewController {
    let viewModel = MyViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.onUpdated = {
            self.updateUI()     // ❌ 강한 self 캡처
        }
    }
}

이 경우:

  • viewModel이 해제되기 전까지 VC도 같이 유지됨
  • 화면을 dismiss/popup/back 해도 VC 인스턴스가 메모리에 남음
  • 다시 push하면 여러개의 VC가 중첩되어 메모리/로직 꼬임

3. 해결 방법: weak self / unowned self 사용


3-1. 가장 흔한 패턴: weak self

viewModel.onUpdated = { [weak self] in
    guard let self = self else { return }
    self.updateUI()     // ✔️ 안전한 self 캡처
}

특징:

  • self가 해제되면 클로저는 nil이 되므로 안전
  • 대부분 상황에서 가장 추천되는 방식

3-2. unowned self (주의)

viewModel.onUpdated = { [unowned self] in
    self.updateUI()
}

특징:

  • self를 nil 체크 없이 바로 사용
  • self가 해제된 상태에서 호출되면 크래시 발생
  • 반드시 self의 라이프사이클이 클로저보다 더 길다는 것이 확실할 때만 사용

예: 애니메이션 블록, 특정 API 내부에서 보장되는 짧은 생명 주기


4. async/await에서도 동일한 문제 발생

많은 개발자가 간과하는 부분:

Task {
    await viewModel.load()
    self.updateUI()     // ❌ 강한 self 캡처
}

Swift Concurrency도 클로저 기반이기 때문에 self가 강하게 캡처됨.

해결:

Task { [weak self] in
    guard let self else { return }
    await viewModel.load()
    self.updateUI()
}

또는:

Task { @MainActor [weak self] in
    guard let self else { return }
    await viewModel.load()
    self.updateUI()
}

5. UIView.animate, Timer, NotificationCenter에서도 강한 self 발생

5-1. UIView.animate

UIView.animate(withDuration: 0.3) {
    self.view.alpha = 0    // ❌ self 캡처
}

해결:

UIView.animate(withDuration: 0.3) { [weak self] in
    self?.view.alpha = 0
}

5-2. Timer

timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
    self.updateTime()   // ❌ self 캡처
}

해결:

timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
    self?.updateTime()
}

5-3. NotificationCenter

NotificationCenter.default.addObserver(
    forName: .keyboardWillShow,
    object: nil,
    queue: .main
) { notification in
    self.handleKeyboard(notification)   // ❌ self 캡처
}

해결:

NotificationCenter.default.addObserver(
    forName: .keyboardWillShow,
    object: nil,
    queue: .main
) { [weak self] notification in
    self?.handleKeyboard(notification)
}

6. RxSwift, Combine, TCA에서도 동일한 패턴

Combine

publisher
    .sink { value in
        self.value = value   // ❌
    }

해결:

publisher
    .sink { [weak self] value in
        self?.value = value
    }

RxSwift

observable.subscribe(onNext: { value in
    self.value = value   // ❌
})

해결:

observable.subscribe(onNext: { [weak self] value in
    self?.value = value
})

TCA

Reducer 내부의 Effect나 Combine Publisher에서도 동일하게 weak self 필요.


7. 강한 순환 참조를 확실하게 감지하는 방법

7-1. deinit 확인하기

ViewController에 다음을 추가:

deinit {
    print("MyViewController deinit")
}

화면을 닫았는데도 deinit이 출력되지 않으면 메모리 누수입니다.


7-2. Xcode Instruments → Leaks / Allocations 사용

  • Leaks Instrument에서 객체가 해제되지 않는지 확인
  • Allocations에서 같은 화면을 여러 번 push했을 때 VC 인스턴스가 증가하는지 확인

8. 실무용 체크리스트

다음 클로저들 안에서 self를 쓰는 경우 반드시 점검해야 함:

  • 네트워크 completion handler
  • async/await Task block
  • UIView.animate
  • URLSession.dataTask
  • DispatchQueue.async
  • Timer handler
  • NotificationCenter observer
  • Combine sink
  • RxSwift subscribe
  • TCA EffectPublisher sink

질문:

  1. 이 클로저가 self보다 오래 살아남을 가능성이 있는가?
  2. self가 필요 없다면 캡처하지 않아도 되는가?
  3. self가 필요하다면 weak/unowned 중 무엇이 안전한가?
  4. VC의 deinit이 정상적으로 호출되는가?

9. 요약

  • 클로저는 기본적으로 self를 강하게 캡처한다.
  • self가 해제되어도 클로저가 self를 붙들고 있으면
    강한 순환 참조로 인해 메모리 누수가 발생한다.
  • 해결:
    • [weak self] 사용 + guard let self 패턴
    • 특정한 경우에만 [unowned self]
  • 비동기 작업, Timer, NotificationCenter, Combine/Rx, TCA 등
    모든 비동기 시나리오에서 동일하게 적용됨.

핵심 문장:

클로저 = self 캡처. self가 필요하면 weak self로 안전하게 캡처하라.

반응형
Posted by 까칠코더
,