메모리 누수 방지 — weak / unowned 정확히 사용하기
메모리 누수 방지 — weak / unowned 정확히 사용하기
1. 들어가며
iOS 실무 개발에서 가장 흔하게 발생하며, 발견하기 어렵고, 장애로 이어지는 문제가 바로 메모리 누수(Memory Leak)입니다.
특히 Swift의 ARC(Automatic Reference Counting)는 강력하지만, 실수로 인해 참조 싸이클이 생성되면 객체가 해제되지 않고 쌓여 앱 성능 저하, 화면 버벅임, 메모리 증가, 심지어 크래시까지 발생합니다.
이 문서는 weak/unowned의 원리부터 실무에서 반드시 지켜야 할 패턴, Swift Concurrency·Combine·Timer·Notification 등 실무에서 누수가 실제로 발생하는 모든 구간을 완전히 커버합니다.
2. ARC 기본 이해
ARC는 객체의 소유자(Strong Reference) 수를 기반으로 메모리를 관리합니다.
- Strong reference count > 0 → 메모리 유지
- Strong reference count = 0 → 객체 해제(deinit 호출)
문제는 객체들이 서로 strong으로 참조할 때 생깁니다.
3. 강한 순환 참조(Strong Reference Cycle)란?
아래와 같은 구조가 대표적입니다.
A → B (strong)
B → A (strong)
두 객체 모두 서로를 strong으로 참조하면 count가 0이 되지 않아 영원히 해제되지 않습니다.
4. weak / unowned의 차이 정확히 이해하기
4.1 weak
- 참조 대상이 해제될 수 있는 경우에 사용
- ARC가 자동으로 nil로 변경
- Optional 타입이어야 함
weak var delegate: SomeDelegate?
4.2 unowned
- 참조 대상이 “절대 먼저 해제되지 않는다”는 보장이 있을 때 사용
- Optional이 아님
- 잘못 사용하면 즉시 크래시 발생
- 매우 제한된 상황에서만 사용해야 한다
unowned let owner: SomeClass
4.3 반드시 기억해야 할 규칙
| 상황 | weak | unowned |
| 참조 대상이 먼저 해제될 수 있는가? | O | X |
| Optional 여부 | Yes | No |
| 크래시 위험 | 없음 | 있음 |
| UI / ViewController 시점 | weak 사용 | 거의 사용하지 않음 |
즉, 실무에서는 weak가 95% 이상입니다.
5. 실무에서 널리 발생하는 메모리 누수 사례
5.1 클로저 내부에서 self Strong Capture
service.request { result in
self.handle(result) // ❌ strong capture → 잠재적 메모리 누수
}
해결법
service.request { [weak self] result in
guard let self = self else { return }
self.handle(result)
}
5.2 Timer 누수
class MyVC: UIViewController {
var timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
print("Tick")
}
}
Timer는 self를 strong으로 잡고 있기 때문에 VC가 사라지지 않는다.
해결
timer.invalidate()
또는
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.doSomething()
}
5.3 NotificationCenter 누수
NotificationCenter.default.addObserver(
self,
selector: #selector(updateUI),
name: .someEvent,
object: nil
)
removeObserver를 하지 않으면 self가 계속 남아있다.
해결
iOS 9+에서는 자동 제거되지만 완전히 신뢰할 수 없으며 실무에서는 항상 명시적으로 제거:
deinit {
NotificationCenter.default.removeObserver(self)
}
또는 Combine을 사용:
notificationPublisher
.sink { [weak self] _ in ... }
.store(in: &cancellables)
5.4 Combine의 sink / assign
publisher
.sink { value in ... } // ❌ self를 strong capture
해결
publisher
.sink { [weak self] value in
self?.update(value)
}
.store(in: &cancellables)
5.5 Swift Concurrency Task 누수
아래는 iOS 17~18에서 실무에서 가장 많이 발생하는 누수 유형이다.
Task {
await self.loadData() // ❌ strong capture
}
해결
iOS17+ 안전한 패턴:
Task { [weak self] in
guard let self else { return }
await self.loadData()
}
또는 MainActor:
@MainActor
class MyViewModel {
func load() {
Task { [weak self] in
await self?.request()
}
}
}
5.6 ViewModel ↔ ViewController 상호 참조
VC가 VM을 strong으로, VM이 VC를 strong으로 참조하면 100% 누수 발생.
해결
ViewModel은 ViewController를 절대 strong으로 잡지 않는다.
weak var delegate: ViewModelDelegate?
6. 실무에서 가장 안전한 클로저 캡처 패턴
6.1 권장 1 — weak self + guard let self
{ [weak self] result in
guard let self else { return }
self.updateUI(result)
}
6.2 권장 2 — weak self + method 호출만
{ [weak self] in self?.reload() }
6.3 권장하지 않음 — unowned self
절대 해제되지 않는 구조에서만 사용.
예: ViewController → ViewModel 단방향.
7. 강한 참조를 없애는 패턴
7.1 “중간 Actor/Helper로 분리”
VC → ViewModel → UseCase → Repository
흐름을 단방향으로 구성하여 상호 Strong Reference 방지.
8. Instruments로 누수 확인하는 방법
8.1 Leaks 사용
- 실행 후 화면 push → pop 연속 테스트
- VC가 사라지지 않으면 누수
8.2 Allocations 사용
- Live Employee Graph
- Reference Cycle 추적
8.3 실제 실무 팁
- 앱 초기 화면에서만 테스트하지 말 것
- API 반복 호출 전후 비교
- SwiftUI는 NavigationStack push 시 특별히 주의
9. 체크리스트
- 클로저 내부 self 캡처는 반드시 weak
- Timer invalidate
- Notification removeObserver
- Combine sink는 반드시 [weak self]
- Task는 [weak self]
- ViewModel은 VC를 강하게 가지지 않음
- VC가 pop/dismiss 후 deinit이 호출되는지 매번 확인
- Instruments 정기 점검
10. 결론
메모리 누수는 Swift 개발에서 피할 수 없는 이슈입니다.
하지만 원인을 정확히 이해하고 weak/unowned를 올바르게 사용하면
대부분의 누수는 사전에 방지할 수 있습니다.
특히 Swift Concurrency, Combine, SwiftUI 전환으로 인해
실무에서 메모리 누수는 더 크게 증가하고 있습니다.
따라서 철저한 설계와 반복 점검이 필수입니다.