⎮ Instruments Tool의 거짓말
Instruments Leaks로 메모리 누수를 검사했는데, 메모리 누수가 없다고 나옵니다

앱 속도는 계속 느려지고, ViewController의 deinit 로그는 안찍히는 상황.
아무리 생각해봐도 메모리 누수가 맞는데 Leaks Tool은 문제가 없다고 합니다
왜 이런 일이 발생한걸까요?
⎮ 메모리 누수란?
메모리 누수(Memory Leak)는 더 이상 사용되지 않는 객체가 메모리를 계속 점유하고 있는 상태를 말합니다
확인 방법은 단순하게 deinit 로그를 찍어보면 됩니다
deinit이 호출되면 정상 해제이고, deinit이 안뜨면 메모리에 남아 있는 상태입니다
class NetworkManager {
init() {
print("NetworkManager 생성")
}
deinit {
print("NetworkManager deinit - 이 메시지가 안 뜨면 메모리 누수!")
}
}
ViewController가 화면에서 사라졌다면, 해당 인스턴스는 메모리에서 해제되어야 합니다
하지만 deinit이 호출되지 않는다면 누군가 강하게 인스턴스를 참조하고 있기 때문에 내려가지 못하는 것입니다
이러한 누수가 반복되면,
메모리 사용량이 증가하면서
앱이 느려지고 배터리 소모가 증가합니다
결국 이러한 반복이 일정 수준 이상 쌓이게 되면 OS에서 앱을 강제 종료합니다
메모리 누수는 즉시적으로 드러나는 것이 아니라서,
사전에 이를 차단하는 것이 중요합니다
처음 코드 설계 단계에서부터 이를 잘 짠다면 누수 상황이 발생하지 않지만, 휴먼 에러는 언제나 있습니다
따라서 오늘은 메모리 누수를 탐지하는 법을 알아보려고 합니다
그 중 Instruments Leaks Tool에 대해 이야기 나눠보겠습니다
⎮ Instruments Leaks Tool
Xcode → Product → Profile (⌘ + I) 경로로 들어가면 템플릿이 나옵니다

여기서 Leaks를 선택합니다

다음과 같은 화면이 나올텐데, 빨간색 원형 버튼을 눌러주면 Leak 확인을 시작할 수 있습니다

그러면 다음과 같이 Leaks Tool이 주기적으로 메모리를 Scan하며 누수가 발생했는지 유무를 체크합니다

그러다 특정 동작 이후 메모리 Leak이 발생하면 빨간색 X 표시가 뜨고,
해당 표시를 더블클릭하면 아래의 이미지처럼 Call Tree와 Leaked 객체를 확인할 수 있습니다

Leaks Tool을 사용해서 직관적으로 메모리 누수를 확인해볼 수 있습니다
⎮ 메모리 누수 케이스
보통의 흔한 메모리 누수 케이스는 다음과 같습니다
#1. Delegate 패턴에서 weak을 빠뜨린 경우
// MARK: - Delegate 패턴 예제 (메모리 누수 발생)
protocol NetworkManagerDelegate: AnyObject {
func didReceiveData(_ data: String)
}
class NetworkManager {
var delegate: NetworkManagerDelegate? // weak 없음! 메모리 누수 원인
deinit {
print("NetworkManager deinit")
}
func fetchData() {
// 네트워크 작업 시뮬레이션
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.delegate?.didReceiveData("Sample Data")
}
}
}
// MARK: - AViewController (메모리 누수 발생)
class AViewController: UIViewController {
private let networkManager = NetworkManager()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
// 순환 참조 발생!
// AViewController -> networkManager (strong)
// networkManager -> delegate (strong) -> AViewController
networkManager.delegate = self
networkManager.fetchData()
}
private func setupUI() {
//UI관련 코드 생략
}
deinit {
print("AViewController deinit") // deinit이 안되면 누수
}
}
extension AViewController: NetworkManagerDelegate {
func didReceiveData(_ data: String) {
print("Received data: \(data)")
}
}
// MARK: - BViewController (AViewController를 push하는 화면)
class BViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
// UI코드 생략
}
@objc private func pushToAViewController() {
let aViewController = AViewController()
navigationController?.pushViewController(aViewController, animated: true)
}
deinit {
print("BViewController deinit")
}
}
위의 케이스는 A 뷰컨트롤러가 networkManager를 강하게 소유하고,
NetworkManager가 delegate로 다시 A 뷰컨트롤러를 강하게 소유하고 있는
대표적인 순환참조 케이스입니다
이 같은 케이스에서는 B 뷰컨트롤러에서 A 뷰컨트롤러를 push했다가 pop을 했음에도
A 뷰컨트롤러에서 deinit 로그가 찍히지 않습니다
(AViewController→ NetworkManager → AViewController)
#2. 클로저 캡처
class VideoPlayer {
var onPlaybackUpdate: (() -> Void)?
deinit {
print("VideoPlayer deinit")
}
func startPlayback() {
// 재생 시작 시뮬레이션
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.onPlaybackUpdate?()
}
}
}
class BViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
// UI코드 생략
}
// addTarget 함수
@objc private func pushToCViewController() {
let cViewController = CViewController()
navigationController?.pushViewController(cViewController, animated:
true)
}
}
// MARK: - CViewController (클로저 메모리 누수 발생)
class CViewController: UIViewController {
private let videoPlayer = VideoPlayer()
private var playbackCount = 0
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
// 순환 참조 발생!
// CViewController → videoPlayer (strong)
// videoPlayer → onPlaybackUpdate 클로저 (strong) → self (strong) → CViewController
videoPlayer.onPlaybackUpdate = {
self.updateUI() // [weak self] 없이 self 캡처!
self.playbackCount += 1
print("Playback update count: \(self.playbackCount)")
}
videoPlayer.startPlayback()
}
private func setupUI() {
// UI코드 생략
}
private func updateUI() {
// UI 업데이트 로직
print("UI Updated")
}
deinit {
print("CViewController deinit") // 안 찍힘 (메모리 누수!)
}
}
이 케이스는 참조 타입의 VideoPlayer 객체의 클로저를 강하게 참조하면서,
해당 클로저 내부에서 self인 CViewController 자신을 참조했기 때문에 메모리에서 해제가 되지 않습니다
(CViewController→ ViewPlayer → 클로저 → CViewController)
위의 코드를 기반으로 Leaks Tool을 실행하면 어떻게 될까요?
안타깝게도 Leaks Tool은 지금의 메모리 누수를 잡지 못합니다
엥? Leaks Tool인데요?
⎮ Leaks Tool의 한계
Instrument Leaks는 왜 메모리 누수를 캐치하지 못할까요?
Leaks는 메모리 누수를 보조하는 도구입니다
Leaks 자체가 메모리 누수의 판단을 보수적으로 하기 때문에,
ARC의 관리 하에 있는 상황에서 일반적으로 발생하는 순환 참조 케이스에서는 누수를 탐지하지 못합니다
(순환 참조처럼 서로가 잡고 있는 상황에서는 아직 살아있는 객체로 인식)
⎮ Leaks Tool이 잘 잡는 케이스(오디오 등의 특수한 상황)
C/Objective-C/CF 계열 메모리처럼 메모리에 직접 할당하며 해제해야하는 경우에는
누수가 발생했을 때 Leaks가 잘 탐지합니다
실수로 free(), CFRelease(), deallocate() 메서드를 까먹으면 고립된 메모리가 발생하면서
이를 Leaks Tool이 탐지하게 됩니다
class AudioRecorder {
func processAudioSamples() {
// 실수: 오디오 샘플 저장용 버퍼
let sampleCount = 44100 // 1초 분량
let buffer = UnsafeMutablePointer<Float>.allocate(capacity: sampleCount)
// 오디오 처리...
for i in 0..<100 {
buffer[i] = Float(i) * 0.1
}
print("\(sampleCount * MemoryLayout<Float>.size)바이트 오디오 버퍼 할당")
// 실수: buffer.deallocate() 안 함!
// → 고립된 메모리 발생
}
deinit {
print("AudioRecorder deinit")
}
}
아쉽게도, 우리가 실수로 누락한 캡처 리스트 또는 delegate 패턴에서의 weak 키워드 누락 같은 경우는
leaks Tool로는 완벽하게 잡아낼 수 없는 것이죠
⎮ 메모리 누수를 잡아내는 다양한 방법들
memory leak을 잡아내는 가장 쉬운 방법은,
모든 ViewController에 deinit을 추가하고 해당 VC가 deinit이 되는지 확인하는 것입니다
deinit이 되지 않으면, 어딘가에서 VC를 강하게 잡고 있다는 것입니다
또 다른 방법은, memory Graph를 확인하는 방법입니다
Debug Navigator -> Memory Graph 버튼을 눌러서 확인할 수 있습니다
만약 좌측에서 VC의 인스턴스가 여러개라면 메모리 누수가 발생하는 상황으로 인지하고 원인을 파악하면 됩니다
다음 포스팅에서는 위에서 말한 deinit과 memory Graph를 활용한 누수 탐지 방법에 대해 알아보겠습니다
'Swift > TOPIC' 카테고리의 다른 글
| Hash / Hashable / Hasher / HashTable (0) | 2025.09.09 |
|---|---|
| Swift | 메모리 누수(2) (Memory Graph) (0) | 2025.09.01 |
| Swift | GCD와 Swift Concurrency (0) | 2025.08.26 |
| Swift | translatesAutoresizingMaskIntoConstraints는 왜 끄는걸까? (2) | 2025.07.20 |
| Swift | 초기화에는 시간이 얼마나 걸릴까? (0) | 2025.07.13 |