2026년 8월 15일 · 9분 읽기
SwiftUI 메모리 추적: jetsam 로그로 725MB 원인 좁히기
앱이 죽지 않은 채 화면 전환만 멈추는 현상을 jetsam 로그로 추적했습니다. 크래시 리포트가 없는 상황에서 생애 최대 725MB까지 좁힌 과정입니다.
2026년 8월 23일 업데이트
사용자가 이렇게 말했습니다. 화면 전환만 멈추고 스크롤과 뒤로가기는 됩니다.
크래시 리포트가 없었습니다. 앱이 죽지 않았으니 당연합니다. 죽지 않은 앱의 느려짐은 로그를 남기지 않습니다.
기기의 jetsam 기록을 열었습니다. 사용자가 멈춤을 겪은 그 시각의 파일이었습니다.
JetsamEvent-2026-08-11-112056.ipsJustSend가 생애 최대 725MB를 썼습니다. 그 시점 시스템 전체에서 7위였습니다. 같은 목록에 ChatGPT가 362MB, WebKit이 339MB였습니다. 둘의 두 배였습니다.
한눈에 보기
| 문제 | 사용자가 이렇게 말했습니다. |
| 결정 | JustSend가 생애 최대 725MB를 썼습니다. |
| 결과 | 기기에서만 나는 문제는 고쳐도 고쳤는지 알 수 없습니다. |
| 제약 | 시뮬레이터에서는 렉이 재현되지 않았습니다. |
jetsam 기록은 죽지 않은 앱도 남깁니다
jetsam은 iOS가 메모리를 회수하기 위해 프로세스를 정리하는 장치입니다. 메모리가 부족해지면 우선순위가 낮은 앱부터 종료합니다.
기록은 설정 → 개인정보 보호 및 보안 → 분석 및 향상에서 꺼냅니다. JetsamEvent-로 시작하는 파일입니다.
이 파일에는 그 순간 살아 있던 모든 프로세스의 메모리 사용량이 들어 있습니다. 죽은 프로세스만 적히는 것이 아닙니다.
우리 앱은 종료되지 않았습니다. 그래서 크래시 리포트가 없었습니다. 하지만 jetsam 기록에는 남아 있었습니다.
| 항목 | 값 |
|---|---|
| JustSend 생애 최대 | 725MB |
| 시스템 전체 순위 | 7위 |
| jetsam 시점 상태 | suspended |
| 그 상태에서 상주 | 122MB |
세 번째 줄이 두 번째 문제였습니다. 앱이 배경으로 내려간 suspended 상태인데 122MB를 붙들고 있었습니다.
배경에 있는 앱이 메모리를 놓지 않으면 다른 앱이 대신 죽습니다. 사용자가 겪은 "시스템 전체가 느려짐"이 여기서 나옵니다.
압축기 숫자가 시스템 압박을 설명했습니다
같은 기록에 이 값이 있었습니다.
compressions: 15,734,833iOS는 메모리가 부족하면 쓰지 않는 페이지를 압축합니다. 압축은 CPU를 씁니다.
천오백만 번이 넘는 압축이 누적됐습니다. 이 숫자가 첫 신고의 "iOS 시스템에도 영향이 있다"를 설명합니다. 우리 앱이 메모리를 붙들어서 시스템이 계속 압축하고 있었습니다.
크래시가 없는 것이 진단을 늦췄습니다
앱이 watchdog kill을 당하지 않았습니다. iOS가 응답 없는 앱을 강제 종료하는 장치인데, 여기까지 가지 않았습니다.
그래서 증상이 애매했습니다. 화면 전환은 멈추는데 스크롤은 됩니다. 뒤로가기도 됩니다. 앱이 살아 있으니까요.
사용자 입장에서는 "느리다"입니다. 개발자 입장에서는 재현할 단서가 없습니다.
죽지 않는 문제가 죽는 문제보다 찾기 어렵습니다.
시뮬레이터에서 재현 조건을 좁혔습니다
기기에서 725MB를 봤지만 어느 화면이 원인인지는 몰랐습니다.
긴 본문이 의심됐습니다. 사용자가 멈춤을 겪은 기록이 긴 메모였습니다.
시뮬레이터에서 2,500줄 본문을 만들어 상세 화면에 들어가 봤습니다.
| 시점 | 메모리 |
|---|---|
| 상세 진입 전 | 45MB |
| 상세 진입 후 | 611MB |
화면 하나를 여는 것으로 열세 배가 됐습니다.
숫자 하나가 더 있었습니다. CoreAnimation이 346MB를 쓰고 레이어가 3,573개였습니다.
레이어 3,573개가 원인을 가리켰습니다. 2,500줄 본문을 렌더하면서 줄마다 레이어를 만들고 있었습니다.
재현이 되면 절반은 끝납니다
기기에서만 나는 문제는 고쳐도 고쳤는지 알 수 없습니다. 시뮬레이터에서 45MB에서 611MB로 가는 것을 확인한 뒤에는 측정이 가능해졌습니다.
재현 조건을 만들 때 실제 사용자 데이터의 성질을 봐야 합니다. 2,500줄은 임의로 고른 숫자가 아니라 신고된 기록의 크기에서 나왔습니다.
게으름이 블록 경계에서 끝났습니다
상세 화면은 본문을 ScrollView > LazyVStack > ForEach(bodyFlowItems)로 그립니다. 겉으로는 지연 생성입니다.
블록 안쪽이 아니었습니다. 블록 하나를 그리는 뷰는 평범한 VStack { ForEach(block.sentences) }였습니다. 형광펜 앵커가 문장 단위라서 문장마다 뷰가 필요한데, 그 스택은 게으르지 않습니다.
블록 경계는 빈 줄입니다. 코드 펜스는 보호합니다. 그래서 빈 줄 없이 이어 쓴 글은 통째로 한 블록이 됩니다. 붙여넣은 원고, 첨부에서 추출한 원문, 자막, 로그가 그렇습니다.
한 블록 안의 문장 수천 개가 한꺼번에 실체화됐습니다.
같은 글자 수에서 블록 구조만으로 여섯 배가 갈렸습니다
같은 2,500줄 285KB 본문으로 세 가지를 재 봤습니다. iPhone 17 Pro 시뮬레이터입니다.
| 조건 | Physical footprint | CoreAnimation |
|---|---|---|
| 빈 줄 없음 → 1블록 (수정 전) | 623MB | 346MB / 레이어 3,573 |
| 같은 글을 빈 줄로 나눔 → 2,500블록 | 103MB | — |
| 빈 줄 없음 → 1블록 (수정 후) | 102MB | — |
첫 줄과 둘째 줄이 원인을 지목합니다. 글자 수가 같은데 블록 구조만으로 여섯 배가 갈렸습니다. 첫 줄과 셋째 줄이 고쳐졌다는 증거입니다. 수정 후에는 블록이 하나든 2,500개든 같습니다.
고친 것은 한 줄입니다. 블록 안쪽 문장 스택을 LazyVStack으로 바꿨습니다.
// StreamDetailScreen.swift:929-938
// **문장 줄도 게을러야 한다.** 바깥 `LazyVStack`의 게으름은 블록 경계에서만
// 듣는다 — 빈 줄 없이 이어 쓴 원고(붙여넣은 문서·추출된 원문·자막)는 통째로
// 한 블록이 되고, 그러면 그 안의 문장 수천 개가 한꺼번에 실체화된다.
//
// 실측(2026-08-11, iPhone 17 Pro 시뮬, 2500줄 285KB):
// 빈 줄 없음(1블록) 623MB · CoreAnimation 346MB / 레이어 3,573
// 같은 글을 빈 줄로 나눔(2500블록) 103MB
return LazyVStack(alignment: .leading, spacing: StreamTokens.x1) {
실측값을 주석에 그대로 두었습니다. 다음에 이 스택을 평범한 VStack으로 되돌리려는 사람이 숫자를 먼저 봅니다. "게으르게 할 필요 없어 보인다"는 판단을 623과 103이 막습니다.
분할 규칙은 건드리지 않았습니다. 블록 서수가 형광펜 앵커의 좌표입니다. 규칙이 바뀌면 사용자가 이미 그어 둔 자국이 전부 어긋납니다. 메모리를 줄이려고 사용자의 표시를 옮길 수는 없습니다.
펴 놓은 배열도 매 프레임 만들면 안 됩니다
같은 화면에 게으름을 무효로 만드는 자리가 하나 더 있었습니다.
본문은 섹션과 블록의 이중 구조인데, 그것을 LazyVStack 하나로 그리려면 한 줄기로 펴야 합니다. 그 펴는 계산을 body 안에서 하면 블록 수백 개짜리 배열을 프레임마다 새로 할당합니다.
// StreamDetailScreen.swift:93-97
/// 본문을 한 줄기로 편 것 — `LazyVStack`이 블록 단위로 게을러지도록.
///
/// 값이 바뀔 때 한 번만 만든다. `body`에서 매번 펴면 블록 수백 개짜리 배열을
/// 프레임마다 새로 할당하게 되고, 게으름으로 아낀 것을 그 자리에서 도로 쓴다.
@State private var bodyFlowItems: [BodyFlowItem] = []
@State에 담고 원문이 바뀔 때 한 번 채웁니다. 게으름은 뷰를 늦게 만드는 것이고, 그 뷰의 목록을 만드는 비용은 게으름이 줄여 주지 않습니다.
머리와 꼬리는 게으르게 하지 않았습니다. 개수가 정해져 있어서 게으르게 할 것이 없습니다. 게으름이 필요한 것은 가운데 본문뿐입니다. 그 둘을 VStack으로 묶어 예전 간격을 그대로 씁니다.
렌더러 개수를 줄이는 가설은 더 나빴습니다
처음 세운 가설은 "문장마다 마크다운 렌더러를 만드는 것이 범인"이었습니다. 블록당 렌더러 하나로 바꿔 봤습니다.
948MB가 됐습니다. CoreAnimation이 423MB, 레이어가 5,074개입니다. 고치려던 것보다 나빠졌습니다.
마크다운 렌더러는 긴 덩어리를 한 번에 파싱하고 배치할 때 더 큽니다. 변수는 렌더러 개수가 아니라 실체화되는 뷰의 개수였습니다.
계산 프로퍼티 하나가 전환마다 LCS 표를 다시 채웠습니다
같은 작업에서 하나 더 나왔습니다. 버전 비교 화면의 lines가 계산 프로퍼티였습니다.
계산 프로퍼티는 body를 평가할 때마다 다시 돕니다. 그리고 전환 애니메이션은 프레임마다 body를 평가합니다. 그 안에서 최악 2,000×2,000 크기의 LCS 표를 채우고 있었습니다.
@State로 옮기고 .task(id:)에서 Task.detached로 한 번만 계산하게 했습니다.
// MemoVersionHistoryView.swift:306-337
/// nil은 "아직 계산 중"이고 빈 배열은 "차이 없음"이다. 둘을 같은 값으로 두면
/// 계산이 끝나기 전에 "변경 없음"이 한 번 스쳐 지나간다.
@State private var lines: [LineDiff]?
...
.task(id: version.id) {
lines = await Task.detached(priority: .userInitiated) {
LineDiff.diff(old: old, new: new)
}.value
}
Optional을 쓴 이유가 주석에 있습니다. 계산 중과 차이 없음이 둘 다 "보여 줄 줄이 없는 상태"인데 화면에 쓸 말이 다릅니다. 하나로 합치면 계산이 끝나기 전에 "변경 없음"이 한 번 뜨고 지나갑니다. 사용자는 그것을 결과로 읽습니다.
.task(id: version.id)가 재계산 조건입니다. 비교하는 버전이 바뀔 때만 다시 돕니다. body가 몇 번 평가되든 이 블록은 상관없습니다.
최악 조합인 2,500줄 대 2,500줄에서 비교 화면이 1.2초에 전환됐습니다.
같은 시기에 렌더 낭비 두 가지를 걷어냈습니다
메모리와 별개로 목록 화면이 필요 이상 다시 그려지고 있었습니다.
SwiftUI에는 _logChanges()라는 진단 함수가 있습니다. 뷰의 body가 왜 다시 평가됐는지 콘솔에 찍어 줍니다.
이걸로 원인을 두 갈래로 갈랐습니다.
첫째, 썸네일 저장소를 @ObservedObject로 물고 있는 뷰가 넷이었습니다. 그 저장소의 값을 그리지도 않으면서 변경 통지를 구독하고 있었습니다. 썸네일 하나가 게시되면 붙어 있는 모든 뷰의 body가 돌았습니다.
둘째, 행 뷰의 저장 프로퍼티에 클로저가 아홉 개 있었습니다. 클로저는 매번 새로 만들어지므로 SwiftUI의 기본 비교가 "같다"고 판정하지 못합니다. 목록 표면이 한 번 돌면 화면의 모든 행이 따라 돌았습니다.
두 갈래의 성질이 다릅니다.
| 관찰 누수 | 클로저 프로퍼티 | |
|---|---|---|
| 무엇이 원인 | 그리지 않는 값을 구독한다 | 매번 새 값이라 비교가 실패한다 |
| 언제 도나 | 그 저장소가 게시할 때 | 부모가 한 번 돌 때 |
| 몇 개가 도나 | 구독한 뷰 넷 | 화면의 모든 행 |
| 고침 | 구독을 끊는다 | 클로저를 프로퍼티에서 뺀다 |
둘이 곱해집니다. 썸네일 하나가 게시되면 뷰 넷이 돌고, 그중 목록 표면이 돌면 그 아래 모든 행이 따라 돕니다. 스크롤하면서 썸네일이 계속 게시되니 이 곱셈이 계속 일어납니다.
제스처는 재평가 횟수로 쟀습니다
서랍 제스처가 세 번째 갈래였습니다. 실기 iPhone Air에서 좌우 제스처마다 렉이 났고 시스템까지 느려졌습니다.
시뮬레이터에서는 렉이 재현되지 않았습니다. M4 Max 호스트에서 드래그 중 메인 스레드가 93% 유휴였습니다. 그래서 시간을 버리고 하드웨어와 무관한 지표로 갈았습니다. 서랍을 한 번 여는 동안 body가 몇 번 재평가되는가입니다.
| 측정 | RootView.body |
StreamScreen.body |
|---|---|---|
| 고치기 전 | +11 | +11 |
| 고친 뒤 | +1 | +1 |
남은 1회는 서랍이 열림으로 정착할 때 조건 갈래가 바뀌는 것이라 없앨 수 없습니다.
원인은 진행도의 위치였습니다. 드래그 오프셋이 루트 뷰의 @State였고 루트 body가 그 값을 직접 읽었습니다. 마스크 곡률, 오프셋, 배경 농도 세 곳입니다. 그 루트 body 안에 목록 화면이 인라인돼 있고, 거기 넘기는 인자가 예순 개 남짓이며 대부분 매번 새로 만들어지는 클로저입니다. 손가락이 움직이는 매 순간 목록 트리 전체가 다시 세워졌습니다.
진행도를 관찰 가능한 값 하나로 내리고, 마스크를 소유하는 뷰와 서랍 위치를 소유하는 뷰를 따로 세웠습니다. 루트는 열렸는지 닫혔는지만 읽습니다.
본문을 받는 방식도 정했습니다. @ViewBuilder 클로저가 아니라 값으로 받습니다. 클로저로 받으면 그 뷰가 그려질 때마다 본문 트리가 새로 만들어져서 분리한 의미가 사라집니다.
제스처는 초당 수십 번 값이 바뀝니다. 재평가 범위가 넓으면 그 횟수가 곱해집니다.
찾는 순서를 여덟 단계로 적어 뒀습니다
아래 순서로 원인을 좁혔습니다.
| 순서 | 무엇을 보는가 | 무엇을 알 수 있는가 |
|---|---|---|
| 1 | 크래시 리포트 | 없으면 앱이 죽지 않았다는 뜻입니다 |
| 2 | jetsam 기록 | 죽지 않은 앱의 최대 사용량과 시스템 순위 |
| 3 | suspended 상태의 상주량 |
화면을 닫고도 놓지 않는 양 |
| 4 | 압축기 누적 횟수 | 시스템 전체가 받은 압박 |
| 5 | 시뮬레이터 재현 | 어느 화면이 원인인가 |
| 6 | CoreAnimation 사용량과 레이어 수 |
렌더 구조 문제인가 |
| 7 | 같은 입력에서 구조만 바꿔 보기 | 무엇이 변수인가 |
| 8 | _logChanges() |
왜 다시 그려지는가 |
1번이 없다고 거기서 멈추면 안 됩니다. 크래시 리포트가 없다는 것은 정보가 없다는 뜻이 아닙니다. 죽지 않았다는 정보입니다.
7번이 이 작업에서 결정적이었습니다. 같은 2,500줄을 블록 하나로 넣었을 때와 2,500개로 나눴을 때가 623MB와 103MB였습니다. 글자 수가 같으니 남은 변수는 구조뿐입니다.
실기에서 진단할 때 걸린 것도 적어 둡니다. log stream은 더 이상 기기를 지정하는 옵션을 받지 않습니다. devicectl로 시스템 크래시 로그 파일을 목록으로 받아 복사해 와야 합니다. jetsam 기록이 앱 메모리 추세의 정본입니다.
접근성 트리를 반복해서 읽는 것도 함정이었습니다. 폴링이 앱을 굶겨서 상세 진입이 65초로 보였습니다. 측정할 때는 폴링하지 말고 고정 시간 기다린 뒤 한 번만 읽습니다.
사용자의 말을 증상으로 번역해야 합니다
"화면 전환만 멈추고 스크롤은 됩니다"가 실제로는 이런 뜻이었습니다.
앱은 살아 있고, 메인 스레드가 완전히 막힌 것도 아니고, 새 화면을 만드는 작업만 오래 걸립니다. 새 화면을 만들 때 메모리를 크게 할당하고, 그 할당이 시스템 압축을 유발하고, 압축이 CPU를 쓰는 동안 전환이 멈춥니다.
사용자가 준 문장에 이미 원인의 범위가 들어 있었습니다. 스크롤이 된다는 것은 이미 만들어진 뷰는 정상이라는 뜻이고, 전환이 멈춘다는 것은 새로 만드는 경로에 문제가 있다는 뜻입니다.