[WWDC24] 힙 메모리 분석하기(Analyze heap memory)
힙 메모리는 앱에서 직간접적으로 사용되며, 개발자가 제어하고 최적화할 수 있는 영역.
앱의 Reference 타입이 저장되는 곳이며, 쓰기 작업이 자주 발생하고 수정되기 때문에 중요함.
이렇게 수정된 메모리들은 메모리 한도에 포함되어 계산 됨.
이번 세션에서는 힙 메모리 측정과 감소 방법에 대해서 주로 다룰 것.
메모리 측정이나 최적화에 대해서 더 알고 싶다면 다른 세션들도 존재함.
- 측정하기
- 일시적 증가에 대응
- 지속적 증자 추적
- 메모리 누수 수정하기
- 런타임 성능 개선하기
총 5가지 세션에 대해서 다룸
힙을 이해하려면 앱의 전체 가상 메모리 내에서 어떤 맥락에 있는지를 알아야 함.
앱이 시작되면, 자체적인 빈 가상 메모리 주소 공간을 할당 받음.
앱이 실행될 때
시스템은 메인 실행 파일, 연결된 라이브러리, 프레임워크를 로드하고, 디스크에서 읽기 전용 리소스 영역을 매핑함.
앱이 실행 중에는
각 스레드의 로컬 및 임시 변수를 위한 스택 영역을 사용하며 동적이고 수명이 긴 메모리는 힙이라고 부르는 메모리 영역에배치됨.
힙을 자세히 보자면
하나의 메모리 블럭이 아니라, 여러 가상 메모리 영역으로 구성됨.
조금 더 영역 수준으로 보면, 각 영역은 개별 힙 할당으로 나뉨.
내부적으로 이런 영역들은 OS에 의해서 16KB 메모리 페이지로 구성되지만, 각 할당은 더 크거나 작을 수 있음.
이러한 메모리 페이지 상태는 세가지 중 하나임
- clean 페이지
- 쓰기 작업이 이루어지지 않은 메모리로 할당되었지만 사용되지 않은 공간이거나 디스크에서 읽기 전용으로 매핑된 파일을 나타내는 페이지일 수도 있음.
- 시스템이 언제든 이러한 페이지를 fault하고 discard할 수 있고, 비용이 저렴함.
- dirty 페이지
- 최근 애플리케이션이 쓰기 작업을 한 메모리.
- 한동안 사용되지 않은 더티 페이지는 버릴 수 없음.
- 메모리가 부족하면 시스템은 이들을 스왑, 압축하거나, 디스크에 데이터를 쓸 수도 있음.
- 이렇게 하는 경우에 메모리를 압축 해제하거나 디스크에서 fault할 수 있음.
이 세가지 중 더티와 스왑만 애플리케이션의 메모리 Footprint 공간에 포함되며, 대부분의 앱의 경우 힙 메모리 공간이 대부분을 차지함.
힙 영역은 malloc과 같은 유사한 allocation primivies로 생성되는 메모리.
대부분의 경우 이러한 함수를 직접 호출하지는 않지만, 컴파일러와 런타임은 Swift, Objective-C의 클래스 인스턴스를 생성할 때 이를 많이 사용함.
malloc으로 앱에서 long-lived 메모리를 동적 할당할 수 있음.
할당은 명시적으로 해제될 때까지 유지되며, 즉 해당 할당을 생성한 코드의 범위를 넘어설 수 있음.
이러한 함수는 규칙을 몇가지 적용하는데, 예를 들어 최소 할당 크기와 정렬은 16 바이트라서 4바이트로 요청해도 16바이트로 반올림 됨.
또한 보안 기능으로 대부분의 작은 할당은 해제될 때 0이 됨.
언어 런타임은 힙을 사용하여 long-lived 메모리를 할당함.
init을 확장해서 보면 아래처럼 malloc을 호출하여 할당함.
malloc은 디버깅 기능도 존재하는데, 각 할당의 호출 스택과 타임스탬프를 기록함.
MallocStackLogging을 활성화하면 메모리가 할당된 위치와 시기를 쉽게 추적할 수 있음.
Xcode > Edit Scheme에서 활성화 가능
앱의 풋프린트는 힙 외에도 많은 것들로 구성되지만 메모리 리포트는 대규모 메모리 문제와 일부 최근 기록을 보여줄 수 있음.
메모리 그래프 디버거는 모든 할당과 그 사이의 참조에 대한 스냅샷인 메모리 그래프를 캡처할 수 있음.
특정 할당에 집중해야 할 때 유용한 도구로 Xcode에서 바로 액세스 할 수 있음.
메모리 디버그를 위한 강력한 CLI Tools도 제공하는데 Leaks, heap, vmmap malloc_hisotry는 프로세스를 직접 분석하거나 이미 캡처된 메모리 그래프로 문제를 조사할 수 있음.
Allocations 템플릿에는 Allocations와 VM Tracker라는 도구가 존재.
- Allocations 힙과 VM 이벤트를 실시간으로 기록하여 활동을 실시간으로 확인
- VM Tracker는 주기적으로 스냅샷을 생성해 모든 가상 메모리를 측정
메모리 급증은 메모리 압박을 유발하고 시스템이 이에 반응함.
더티 메모리를 교체 및 압축하고, Read Only 메모리는 discard하며 백그라운드 작업을 종료할 수도 있음.
최악의 경우에는 앱을 종료시킬 수도 있음.
메모리 급증의 장기적 영향은 힙 메모리 영역을 조각화(fragmentation)하거나 구멍(hole)을 내므로 좋지 않음.
메모리 분석에는 두가지 방법이 존재.
- 특정 스파이크를 살펴보고 최저점에서 최고점에 있는 Created & Still Living 할당을 찾음
- 또는 전체적으로 큰 범위를 선택해 해당 범위에서 Created & Destroyed된 모든 할당을 찾을 수 있음.
하나의 스파이크 지점을 선택해서 이 행을 전체 바이트(Total Bytes) 순으로 정렬해 상위 원인을 찾아봄.
이 주제에서는 힙 메모리가 주제이지만 IOSurface 가상 메모리인 것 같음.
- VM: IOSurface: 이는 임시 메모리 문제가 배경 이미지 처리 방식과 관련이 있다는 것을 암시
이후에 Persistent Bytes를 기준으로 객체를 정렬하면 눈에 띄는 노드가 하나 생김
- @autoreleasepool content인데, 이게 너무 많이 생성되었음.
다른 하나의 방법은 더 넓은 범위에서 생성, 소멸되는 객체를 담당하는 코드를 찾는 것임.
1. 윈도우 하단엔서 Lifespan 필터를 Created & Destroyed로 변경
2. 스파이크 3개를 모두 선택
3. 이제 가운데에 위치한 세부 정보 뷰를 Call Trees로 변경
- 콜 트리는 할당을 역추적으로 분석해 가장 많은 메모리를 할당하는 코드를 볼 수 있게 해줌
- 전체를 살펴보면 8GB의 임시 할당이 존재
4. 오른쪽의 Heaviest Stack Trees이 단서를 제공
- 코드의 프레임이 고정 되어 있고, makeThumbnail() 코드에서 시작하면 될 것으로 보임.
- makeThumbnail()을 한번 클릭하면 콜 트리가 보이고, 더블 클릭하면 소스 코드가 보임.
코드 한 줄에서 기가바이트 단위의 코드가 생성되고 파괴되고 있음.
임시 메모리여야 하는 부분인데, 스파이크 정점까지 증가하다가 한번에 해제됨.
분석하기 위해서 콜 트리에서 몇 프레임 올라가서 ThumbnailLoader의 loadThumbnails 코드를 살펴보자.
for 루프에서 썸네일을 falut하고 있고, 루프가 실행되는 동안에 메모리가 증가하다가 루프가 끝나는 끝에서 떨어짐.
이전에 @autoreleasepool가 늘어났던 문제와 결부해서 확인하자면, @autoreleasepool는 임시 메모리 증가의 흔한 원인임.
Objective-C는 이 풀을 활용해 함수 반환값의 객체 수명을 연장함.
autoreleasepool은 해제를 지연시켜 이 반환값들을 유지하는데, 이는 Swift가 Objective-C API를 사용하는 프레임워크를 호출할 때 자동으로 릴리즈 된 객체를 생성할 수 있다는 의미
스레드에는 일반적으로 최상위 수준에 autoreleasepool이 있지만 자주 정리되지는 않음.
이는 코드가 객체로 풀을 채울 때 매우 중요할 수 있으며 반복문에서 쉽게 발생함.
모든 반복 객체는 같은 풀에 자동으로 릴리즈되며, 필요 이상으로 오래 존재할 수 있음.
이 경우에는 루프가 모두 완료될 때까지 기다림.
autoreleasepool은 객체 참조를 위해 내부적으로 콘텐츠 페이지를 할당함.
이건 Instrument의 Allocations 도구에서 볼 수 있음.
나중에 autoreleasepool이 비워지면 풀에서 지연된 릴리즈를 보내고 많은 객체가 한꺼번에 풀려남.
이를 해결하는 방법으로는 로컬 릴리즈 풀 범위를 정의해 수명 범위를 좁히는 것임.
해당 예제에서는 autoreleasepool에 있는 객체가 내부 루프 별로 풀에 보관되고, 반복될 때마다 릴리즈 됨.
즉, 누적되는 객체 수가 줄어들고 참조를 추적하는데 필요한 콘텐츠 페이지가 줄음.
대부분의 프로파일링에서는 정확한 타이밍을 위해 실제 기기를 사용하는 것이 좋으나,
힙 분석의 경우에는 시뮬레이터 환경이 동작에 더 가까워서 메모리 프로파일링에 사용해도 괜찮음.
Persistent growth는 할당이 해제되지 않는 메모리이며, 일반적으로 여러번의 할당으로 인하여 발생함
Allocation Mark Generation을 활용하면 시간대별로 증가를 세분화해서 확인할 수 있음.
Generation에 걸리는 구간은 그 할당이 끝날때까지도 추적함
위의 예제에서는 대부분의 증가는 __DataStorage.__bytes (malloc)에서 발생함.
해당 부분을 확장하여 할당을 확인해보니 ThumbnailLoader에서 가장 많이 사용.
Instruments에서 이 메모리 주소 중 하나를 Xcode의 메모리 디버그 그래프에 넣어보면 갤러기가 닫힌 후에도 데이터가 잔존하는 이유를 알 수 있음.
메모리 그래프를 잘 보려먼 이 할당이 왜 아직 존재하는가, 할당에 무엇이 들어있나를 알아야 함.
메모리 그래프 디버거가 이 부분을 분석할 수 있게 도와줌
이를 최대한 활용하려면 타입 정보와 참조 스캔에 대해서 이해해야 함.
참조에는 네 가지 주요 타입이 존재함.
- Unmanaged: 런타임이 알지만 자동으로 관리하지 않는 위치의 포인터로 이 포인터는 수동 소유 참조일 수도 아닐 수도 있음.
- Conservative: 불확실하거나 보수적인 참조로 스캔하는 메모리 타입을 도구가 알지 못하고 원시 메모리만 확인할 때 기록됨. 값이 포인터처럼 보인다면 포인터지만 타입 정보가 없으면 확인할 방법이 없음.
Instruments가 프로세스의 힙을 스캔할 때 각 Allocations에 대해 사용 가능한 최상의 타입 정보를 사용함.
첫 두 필드는 표준이며 참조 스캔을 할 중요한 내용이 없음.
하지만, 그 다음에 스캔할 coconut은 참조가 있음.
이 필드에는 힙 할당에 대한 포인터가 있으며 coconut 객체에 대한 강한 참조임.
Swift의 타입 시스템과는 비슷하지만 C와 C++에는 참조 소유권 정보가 없어 conservative(보수적) reference만 확인할 수 있음.
이 클래스의 인스턴스는 Coconut으로 보일거임.
가상 메서드나 다른 할당이 없는 타입의 경우에는 스택 추적으로 이름을 제공할 수 있음.
MallocStackLogging에서는 PalmTree:growCoconut()에서 malloc이라고 레이블링 될 수 있으며, 이는 해당 클래스가 무엇인지 알 수 있는 좋은 힌트임.
__DataStorage 객체가 왜 해제되지 않는지 확인해보고자 함.
끝까지 따라서 차례로 올라가보면 ThumbnailLoader.globalImageCache란 정적 객체가 보유하고 있음.
MallocStackLogging을 활성화한 상태로 실행중이므로 오른쪽 Inspector에서 할당 역추적을 볼 수 있음.
PhotoThumbnail에서 클로저 중 하나가 할당하고 있음.
해당 객체를 눌러서 소스 코드로 이동해보자.
faultThumbnail 메서드가 썸네일을 캐싱하고, 캐시 미스 시 새로운 썸네일을 생성하는 것 같음.
아마 조금 전에 보았던 globalImgaeCache에 저장하고 있을 것임.
해당 메서드는 로직에 문제가 있음.
- URL과 creationDate를 기반으로 캐싱
- 하지만 timestamp가 현재 시간을 사용하고 있어서 즉, 캐시에서 아무것도 찾을 수 없음.
따라서 이 메서드를 호출할 때마다 새로운 것을 캐시하게 되고 이것이 썸네일이 계속 증가하는 이유가 설명됨.
잘못된 타임 스탬프를 계산하는 코드를 변경하고, 메모리 보고서에서 계단 패턴이 보이지 않는지도 확인.
이렇게 수정할 경우 메모리가 더이상 증가하지 않음.
누수된 메모리를 이해하고 해결하려면 reachablility에 대해서 알아야 함.
프로그램의 모든 메모리는 나중에 사용될 어딘가에서 약함 참조가 아닌 실제 참조된 영역에 도달할 수 있어야 함.
힙에는 3가지 타입의 메모리가 존재함.
- Useful memory
- 프로그램에서 도달할 수 있고, 나중에 다시 사용할 메모리
- Abandoned(버려진) memory
- 도달하고 사용할 수 있지만 실제로는 다시 사용하지 않으며, 앱의 메모리 사용량에는 포함되지만 실제로는 그저 낭비되고 있음.
- 캐시에 데이터를 너무 많이 저장하거나 싱글톤에 큰 용량의 데이터를 유지해 발생할 수 있음.
- Leaked memory
- 누수가 발생하는 메모리로 재사용이 절대 불가능하여 도달 불가능한 메모리
- 일반적으로 할당이 수동으로 관리되는 영역이나 객체의 참조 사이클로 인해 마지막 포인터가 손실될 때 발생
대부분의 누수의 경우 순한에서 하나의 참조를 찾아 수정하는 것이 목표
실제로 누수된 참조를 제거하거나 소유권을 strong에서 weak, unowned 바꾸는 방법이 사용될 수 있음.
위의 예제에서는 ThumbnailRenderer, ThumbnailLoader 클로저 컨텍스트 사이의 작은 참조 순환으로 보임
Swift 클로저가 값을 캡처해야 할 때 힙에 메모리를 할당해 캡처를 저장함.
메모리 그래프는 이러한 할당을 클로저 컨텍스트로 표시함.
앱 힙의 각 클로저 컨텍스트는 라이브 클로저와 1:1로 대응.
클로저는 기본적으로 참조를 강하게 캡처해서 참조 사이클을 만들 수 있음.
이러한 순환은 약한 참조 혹은 비소유 캡처로 깰 수 있음.
참조 사이클은 위와 같은 예제임. (순환 참조와는 다름)
메모리 그래프 디버거는 이 참조를 강한 캡처로 표시하지만 클로저 메타데이터에는 변수 이름이 포함되지 않음.
클로저 컨텍스트의 모든 참조는 단순히 `capture`로 레이블링 됨.
ThumbnailRenderer에는 Loader에 대한 cacheProvider 참조가 있음.
Loader에는 클로저 컨텍스트를 참조하는 completionHandler가 있음.
Renderer로 돌아가는 캡처를 선택하면 Inspector에서 이 참조가 강한 참조임을 알려줌.
이 참조 사이클을 깨려면 클로저를 생성한 코드를 찾아야 함
클로저 컨텍스트에서 PhotosView의 코드로 이동해서 확인할 수 있음.
방금 확인한 문제는 클로저가 ThumbnailRenderer를 강하게 캡처하고 있으며, 참조 사이클을 발생시키고 있음.
해결 방법으로는
- 클로저 대신 Swift Concurrency를 사용하도록 변경할 수 있음.
- 혹은 클로저가 weak 또는 unowned로 사이클을 깰 수 있을 것임.
weak를 추가하여 클로저 컨텍스트에서 참조 사이클을 해결할 수 있음.
Leaks이 발생하는 이유는 여러가지가 있고, Leaks을 찾으면서 여러 의문점이 들 수 있음.
1. 왜 모든 메모리 누수를 찾아내지 못할까?
의도적으로 누수를 만들었다고 해도 Instruments는 항상 찾을 수 없음.
Instruments에 타입 정보가 없는 메모리가 많고 C와 같은 언어는 UnfaeMutablePointer를 허용함.
즉, Instruments 입장에서 포인터인 것처럼 보여도 그렇지 않을 수도 있다는 것을 허용해야 한다는 의미이기도 함.
Instruments는 보수적으로 포인터를 바이트 단위로 찾고 참조로 보이는 값을 찾은 다음에 할당 목록과 비교해서 확인함.
값이 일치하면 Instruments는 해당 블록에 대한 Uncertain하거나 Conservative reference를 기록함.
그러나 value는 숫자 값, 플래그 또는 유효한 포인터처럼 보이는 임의의 바이트일 수도 있음.
즉, Conservative reference로 인해서 실제 메모리 누수를 놓칠 수도 있음.
의도적으로 누수를 발생시켜서 확인하고 싶으면 이렇게 루프에 넣어서 확인할 수도 있음.
실제 앱에서 누수되는 코드는 일반적으로 여러번 실행되기 때문에 Instrument가 모든 누수를 찾지 못해도 원인이 되는 버그는 잡아낼 수 있음.
2. 보고된 메모리 릭이 횟수가 시간이 지나면서 달라지는 이유가 뭘까?
시간이 지나면서 누수는 메모리를 증가시키지만 힙은 노이즈가 많고 무작위일 수도 있음.
이 노이즈는 Conservative reference를 비결정적으로 만들기 때문에 나타나거나 사라질 수 있음.
3.noreturn 함수에서 메모리가 누수되는 이유는 뭘까요?
`noreturn` 또는 `-> Never` 로 선언된 함수는 “절대 반환하지 않는다”고 컴파일러에 알려주는 것이기 때문에, 그 이후의 코드나 정리는 필요 없다고 판단하여 지역 변수의 메모리 해제가 누락될 수 있음.
이로 인해 ARC(Automatic Reference Counting)를 사용하는 Swift에서는 일시적인 메모리 누수처럼 보이는 현상이 발생할 수 있음.
스레드를 영원히 저장하는데 사용하기도 하는데, Server 객체와 같이 noreturn 함수에 대한 호출에서 로컬 상태가 누수된 것으로 보고되는 경우에는 한가지 해결책은 명시적으로 전역에 저장하는 방법임.
객체를 로컬 함수 범위 바깥에 저장하면 Instrument에서 볼 수 있는 영역에 참조가 저장됨.
그리고 Instrument가 해당 값을 볼 수 있어서 객체가 누수된 것이 아니라 reachiblity하다고 간주되며, 로컬 변수가 컴파일러에 의해 보존되지 않은 상황에도 적용됨.
메모리 감소는 앱 성능을 크게 향상시킬 수 있으며, 추가 개선을 위해 주의해야 할 런타임 세부사항도 있음.
weak와 unowned는 Swift에서 강한 참조 사이클을 피하기 위해서 주로 사용하는 도구임.
이들의 차이점과 사용 시기에 대해서 더 명확히 알아볼 것.
약한 참조는 항상 옵셔널 타입이며 대상이 소멸되면 nil이 됨.
Source의 대상과 상관없이 항상 약한 참조를 사용할 수 있음.
하지만 weak은 약간의 오버헤드도 발생하는데, weak 참조를 구현하기 위해 Swift는 처음 weak 참조가 생길 때 대상 객체에 대한 weak 참조를 저장할 공간을 메모리에 할당함
해당 예제에서는 shallow 객체가 사라진 후 weak 참조를 천천히 nil로 만들 수 있게 함.
unowned는 weak와 달리 대상을 직접 보유함.
이는 추가 메모리를 사용하지 않고 weak 참조보다 접근 시간이 더 짧음을 의미함.
옵셔널이 아닐 수도 있고 상수일 수도 있음.
하지만 unowned의 사용이 항상 유효한 것은 아님.
예제에서 coconut의 참조를 weak 대신 unowned로 사용하고 shallow가 먼저 사라질 경우에 shallow가 더 먼저 deinit될 경우 shallow는 제거되지만 할당이 해제되지는 않음.
이러한 이유 때문에 unowned 참조가 안전함.
unowned 참조는 반드시 무언가를 가리켜야 하며, unowned 참조는 weak 참조를 강제로 언래핑 하는 것과 비슷함.
unowned에 접근하지 않더라도 그대로 두는건 좋지 않음.
unowned 참조가 존재하는 한 참조 대상은 할당 해제될 수 없고 메모리를 낭비하게 됨.
대상의 수명이 얼마나 될지 모른다면 weak 함수의 약간의 오버헤드는 감수할 가치가 있음.
메모리 그래프에 weak 참조 또는 unowned 참조가 보이지 않는다면 Xcode 프로젝트에서 컴파일러 옵션을 활성화해서 확인할 수 있음.
암묵적으로 `self`가 사용되어 strong 참조 사이클이 생성됨.
따라서 메서드를 클로저로 사용한 경우에 주의해야 함.
이를 해결하려면 defaultAction()을 호출하는 클로저를 정의해서 사용하면 여전히 `self` 캡처를 사용하지만 캡처가 명시적이며 캡처 목록을 사용하여 strong이 되지 않게끔 할 수 있음.
해당 케이스에서는 unowned도 좋은 선택이며, init 클로저는 대상인 ByteProducer 인스턴스와 수명이 일치하기 때문에 가능함.
클로즈는 다른 코드로 할당되거나 비동기적으로 디스패치되지 않으므로 캡처된 자신보다 오래 지속될 수 없음.
이러한 선택의 성능 차이는 때때로 누적되어 나타남.
만약 이러한 객체는 백만개 할당하고, 메모리 그래프를 내보내면 힙 CLI Tools 도구로 비용에 대해서 요약된 결과를 확인할 수 있음.
각 인스턴스마다 weak 참조 스토리지 할당이 하나씩 있는데 해당 스토리지는 ByteProducer 자체 만큼이나 많은 메모리를 사용함.
unowned의 경우에는 이 메모리가 필요하지 않음.
요점은 weak 참조는 좋은 기본값이며 unowned 참조는 참조가 대상보다 오래 지속되지 않음을 보장할 수 있을 때 메모리와 시간을 절약할 수 있다는 것임.
CPU 오버헤드를 유발하는 영역을 찾으려면 프로파일링 하고 swift_weakLoadStrong()과 같은 런타임 함수에 대한 호출을 찾기
가급적이면 ARC를 우회하지 말자. 수동으로 Reference Count를 관리하는 것으로 인한 누수는 디버깅이 매우 어려움.
weak와 unowned 외에도 자동 retain과 release 호출이 프로파일링 핫스팟으로 나타날 수 있음.
UnSafeManagedPointer를 사용하거나 성능에 민감한 코드를 메모리 불완전 언어로 옮기는 것보다 더 나은 해결책이 있음.
-whole-module-optimization을 활성화 해 두기.
인라이닝을 더 많이 허용해 오버헤드를 줄일 수 있음.
명시적으로 특수화가 필요한 제네릭도 프로파일링하고 찾아보기.
가장 많이 복사되는 구조체 필드를 단수하게 유지하는 것도 좋음.
프로파일링은 비용이 많이 드는 구조체 복사 식별에 도움이 됨.
이러한 구조체에서는 참조 타입과 COW타입 any 사용을 최소화하기
관찰 비용도 성능의 일부로 MallocStackLogging과 Allocation은 실시간 데이터를 추적하므로 모든 할당에 대한 정보 기록에 메모리와 CPU가 필요함.
Leaks, VM Tracker, 메모리 그래프는 스냅샷 기반이므로 분석 중에 대상 앱을 일시 중단해야 함. 이로 인해서 스냅샷 과정에서 앱이 잠시 끊기거나 멈출 수 있음.
'apple > WWDC' 카테고리의 다른 글
[WWDC25] 앱의 전력 사용량 프로파일링 및 최적화하기 (Profile and optimization power usage in your app) (0) | 2025.06.11 |
---|---|
[WWDC21] Modern Cell configuration (0) | 2025.05.17 |
[WWDC22] Swift 동시성 시각화 및 최적화 (Visualize and optimize Swift Concurrency) (0) | 2025.05.01 |
[WWDC22] Meet distributed actors in Swift (0) | 2025.04.30 |
[WWDC21] Protect mutable state with Swift actors (0) | 2025.04.27 |