2026년 8월 15일 · 8분 읽기
iOS 앱 콜드 스타트 최적화: 873ms에서 482ms로 줄인 측정과 수정
SwiftUI 앱 콜드 스타트를 873ms에서 482ms로 줄인 과정입니다. Instruments 대신 단계별 로그를 남기는 도구를 먼저 만들었고, 로그인 상태의 680ms까지 측정했습니다.
2026년 8월 23일 업데이트
앱이 느리게 뜬다는 것은 알고 있었습니다. 어디가 느린지는 몰랐습니다.
Instruments를 붙이면 전체 그림은 보이지만 우리 코드의 어느 줄이 몇 밀리초를 쓰는지 단계로 나뉘지 않았습니다. 그래서 측정 도구를 먼저 만들었습니다.
만들고 재 보니 앱이 소유한 구간이 873ms였습니다. 그중 310ms가 함수의 기본 인자 하나에서 나왔습니다.
한눈에 보기
| 문제 | 앱이 느리게 뜬다는 것은 알고 있었습니다. |
| 결정 | 측정 조건도 고정했습니다. |
| 결과 | PerformanceResourcePolicyTests에 두 개를 추가했습니다. |
| 제약 | 로그인 상태에서 몇 퍼센트 줄었는지는 주장하지 않았습니다. |
프로세스 출생 시각을 0으로 잡는 도구를 먼저 만들었습니다
측정 없이 최적화하면 추측입니다. 그런데 무엇을 기준으로 잴지가 먼저입니다.
main()이 불린 시점을 0으로 잡으면 그 앞의 시간이 안 보입니다. 그래서 프로세스가 태어난 시각을 0으로 잡았습니다. kinfo_proc에서 꺼냅니다.
LaunchTrace + scripts/audit/launch-bench.sh시작 코멘트에 이유가 한 줄로 적혀 있습니다.
프로세스 출생 시각(kinfo_proc)을 0으로 잡은 단계별 로그가 없으면 어디가 느린지는 추측이 된다.
측정 조건도 고정했습니다. iPhone 17 Pro 시뮬레이터, iOS 27, Release 빌드, 다섯에서 일곱 번의 중앙값입니다.
Debug 빌드로 재면 안 됩니다. 최적화가 꺼져 있고 로깅이 살아 있어서 실제 사용자가 겪는 시간과 다릅니다.
평균이 아니라 중앙값입니다. 첫 실행은 파일 캐시가 없어 느립니다. 평균을 내면 그 한 번이 전체를 끌어올립니다.
구간을 두 지점으로 잡았습니다
| 지점 | 무엇 |
|---|---|
init.begin |
앱 코드가 처음 도는 시점 |
splash.hidden |
스플래시가 걷히고 첫 화면이 보이는 시점 |
이 두 지점 사이가 "앱이 소유한 구간"입니다. 그 앞은 시스템이 바이너리를 로드하는 시간이고 우리가 직접 줄일 수 없습니다.
init.begin |
splash.hidden |
구간 | |
|---|---|---|---|
| 착수 전 | 1357 | 2230 | 873ms |
| 착수 후 | 1421 | 1903 | 482ms |
init.begin이 1357에서 1421로 늘어난 것에 주의해야 합니다. 이 값은 프로세스 출생부터 앱 코드 시작까지의 시간이고, 우리가 고친 것과 무관하게 실행마다 흔들립니다.
두 수치를 함께 적어야 이 흔들림에 속지 않습니다. 끝 지점만 보면 2230에서 1903으로 줄었다고 말할 수 있지만, 시작점이 다르므로 구간을 봐야 합니다.
310ms가 기본 인자 하나에서 나왔습니다
프로브를 심어 RootView.init이 메인 스레드에서 310ms를 쓰는 것을 찾았습니다.
RootView.init의 첫 줄이 이런 모양이었습니다.
// 문제가 있던 구조
@MainActor
final class HomeViewModel {
init(summaryService: SummaryOrganizationProviding = SummaryOrganizationService.runtime()) {
// ^^^ 여기가 310ms
}
}
// 호출부
struct RootView: View {
init() {
let home = HomeViewModel() // 기본 인자가 여기서 평가됩니다
}
}
HomeViewModel()을 부르는 것만으로 SummaryOrganizationService.runtime()이 실행됩니다. 그 안에서 SystemLanguageModel.default가 깨어납니다. 온디바이스 모델의 핸들을 초기화하는 비용입니다.
첫 화면은 아무것도 요약하지 않습니다. 요약을 하지 않는 화면이 요약 엔진을 깨우고 있었습니다.
Swift의 기본 인자는 호출부에서 평가됩니다
여기가 함정입니다.
기본 인자가 함수 정의 안에 적혀 있으니 함수 안에서 평가될 것 같습니다. 아닙니다. 호출하는 자리에서 평가되어 인자로 전달됩니다.
그래서 HomeViewModel()이라고 쓴 곳마다 SummaryOrganizationService.runtime()이 실행됩니다. 뷰모델 본문에 무거운 코드가 없어도 조립하는 것만으로 비용이 발생합니다.
의존성 주입을 기본 인자로 하는 패턴은 편합니다. 테스트에서 다른 구현을 넣을 수 있습니다. 대신 이 성질을 알고 써야 합니다.
하나만 고치면 옆으로 옮겨 갑니다
같은 자리에 두 개가 더 있었습니다.
| 무엇 | 어디 |
|---|---|
SummaryOrganizationService.runtime() |
HomeViewModel의 기본 인자 |
fmAvailability |
SettingsViewModel의 기본 인자 |
profileOrchestrator |
StreamRuntime의 기본 인자 |
세 경로가 모두 온디바이스 모델을 깨웁니다. 하나만 지연시키면 비용은 다른 경로에서 납니다.
코멘트에 이렇게 적혀 있습니다.
하나만 고치면 비용이 옆으로 옮겨 갈 뿐이다.
셋을 함께 지연 평가로 바꿨습니다. 결과는 RootView.init 310ms에서 1.7ms입니다.
그 비용은 배경으로 옮겼습니다
없앤 것이 아닙니다. 모델은 언젠가 깨어나야 합니다.
스플래시가 걷힌 뒤 유틸리티 스레드에서 한 번 부릅니다.
// FMAvailability.swift:45-47
static func warmInBackground() {
Task.detached(priority: .utility) { _ = check() }
}
사용자가 설정을 열거나 첫 메모를 저장할 때는 이미 준비돼 있습니다. 아무도 그 310ms를 기다리지 않습니다.
스플래시가 서버 응답을 기다렸습니다
두 번째 원인은 인증이었습니다.
스플래시는 authStore.ready()가 끝난 뒤 걷혔습니다. 그 함수가 토큰 갱신 왕복을 기다렸습니다.
네트워크가 죽으면 어떻게 되는지 계산해 보면 문제가 보입니다. timeoutIntervalForRequest가 60초입니다. 지하철에서 앱을 켜면 브랜드 화면이 최대 1분 동안 서 있습니다.
복원을 로컬과 원격으로 갈랐습니다
| 함수 | 무엇을 기다리는가 | 누가 쓰는가 |
|---|---|---|
ready() |
키체인에서 토큰 읽기만 | 스플래시 |
readyIncludingRemote() |
서버 확인까지 | 서버 결과가 필요한 호출부 |
ready()는 로컬에 토큰이 있으면 로그인된 것으로 보고 화면을 넘깁니다. 서버 확인은 뒤에서 진행합니다.
이걸 낙관적 인증이라고 부릅니다. 토큰이 만료됐으면 서버가 401을 주고, 그때 낙관적으로 세운 상태를 되돌립니다.
실기에서 확인했습니다. 로그인 버튼을 누른 1초 뒤 홈에 기록 8행이 섰습니다. 로그에 t=1s memory.row=8로 남았습니다.
되돌려서 테스트가 실패하는지 봤습니다
고친 것이 실제로 그 문제를 고쳤는지 증명해야 합니다.
ready()를 옛 판본으로 되돌렸습니다. 서버까지 기다리는 구현입니다. 그러자 테스트 두 개가 실패했습니다.
testReadyDoesNotWaitForServerRefresh
testExpiredTokenClearsOptimisticAuthentication고친 판본에서는 통과합니다. 순서가 증거를 만듭니다. 통과하는 테스트를 새로 쓰는 것보다, 옛 코드에서 실패하는 것을 보여 주는 쪽이 강합니다.
배경 작업이 첫 프레임과 경쟁했습니다
세 번째 원인은 우선순위였습니다.
앱을 켤 때 정리 작업 셋이 돌고 있었습니다.
| 작업 | 무엇 |
|---|---|
| 임시 파일 청소 | 오래된 캐시 삭제 |
| 휴지통 만료 청소 | 기간이 지난 항목 영구 삭제 |
| 임베딩 백필 | 검색용 벡터 200건 생성 |
셋 다 기본 우선순위로 돌았습니다. 첫 프레임을 그리는 작업과 같은 우선순위입니다. CPU를 나눠 씁니다.
셋을 .utility로 내렸습니다. 급하지 않은 일이고, 사용자가 화면을 보기 전에 끝날 필요가 없습니다.
메인 스레드에서 DB 전체를 읽고 있었습니다
휴지통 청소에 더 나쁜 것이 있었습니다.
purgeExpired가 @MainActor에서 동기 GRDB 읽기로 휴지통 전체를 스캔했습니다. 메인 스레드가 디스크를 기다립니다.
이 갈래는 실기 발열 조사에서도 나온 것과 같습니다. 메인 스레드에서 동기 I/O를 하면 화면이 멈추고, 멈춘 동안 시스템은 무언가 잘못됐다고 판단합니다.
메인 밖으로 냈습니다.
회귀를 막는 테스트를 남겼습니다
고친 것이 다시 돌아오는 것을 막아야 합니다. 특히 기본 인자 문제는 코드를 읽어서 알아채기 어렵습니다.
PerformanceResourcePolicyTests에 두 개를 추가했습니다. 내용은 "뷰모델을 조립하는 것이 모델을 깨우지 않는다"입니다.
누군가 편의를 위해 기본 인자에 runtime()을 다시 넣으면 이 테스트가 실패합니다.
전체 스위트의 실패 개수를 착수 전과 비교했습니다
전체 테스트 1,420건 중 8건이 실패했습니다. 착수 전과 같은 집합이었습니다. 전부 이전부터 실패하던 것들입니다.
이 비교가 없으면 8건이 내가 만든 것인지 알 수 없습니다. 성능 작업을 시작하기 전에 실패 목록을 먼저 저장해 둬야 합니다.
두 건은 판정이 애매했습니다. VoiceManualSummaryDiagnosisTests 2건이 제가 바꾼 StreamRuntime 경로에 걸쳐 있었습니다. 되돌린 판본으로 따로 돌려서 같은 실패가 나는 것을 확인했습니다. 제 원인이 아니었습니다.
그 1,420건이 어느 트리였는지를 빠뜨렸습니다
나중에 이 숫자가 틀린 것을 알았습니다.
1,420건 8실패는 전체 워킹트리에서 잰 값입니다. 그 트리에는 다른 세션 네 개의 미커밋 수정이 함께 있었습니다. 같은 스위트를 HEAD에서 재면 1,398건 20실패입니다.
| 어디서 쟀나 | 테스트 | 실패 |
|---|---|---|
| 전체 워킹트리 | 1,420 | 8 |
| HEAD | 1,398 | 20 |
열두 건 차이를 "이전부터 실패하던 것"으로 읽으면 판단이 흐려집니다. 남의 미커밋 수정이 내 실패를 덮고 있었습니다.
그래서 규칙을 하나 더 적었습니다. 실패 개수를 적을 때 어느 트리에서 쟀는지 함께 적습니다.
손대지 않기로 한 1.33초
앱 구간이 482ms인데 그 앞의 pre-main이 1.33초에서 1.42초입니다. 세 배가 넘습니다.
여기를 줄이는 것이 더 큰 이득입니다. 그런데 손대지 않았습니다.
원인을 특정하지 못했기 때문입니다. 세 가지를 시도했습니다.
| 가설 | 확인 방법 | 결과 |
|---|---|---|
| 팻 바이너리가 무겁다 | ONLY_ACTIVE_ARCH=YES로 arm64 단독 빌드 |
1377ms로 변화 없음. 기각 |
| 우리 프레임워크가 많다 | 로드된 이미지 수 확인 | 1,318장 중 우리 것 5장 |
| 동적 링크가 느리다 | DYLD_PRINT_STATISTICS |
이 런타임에서 아무것도 출력하지 않음 |
셋 다 원인을 가리키지 못했습니다.
그리고 시뮬레이터의 pre-main은 실기와 다릅니다. 시뮬레이터는 맥의 파일 시스템과 dyld를 쓰므로 그 값이 기기의 값을 대신하지 못합니다.
코멘트에 이렇게 적혀 있습니다.
재지 못한 수 위에 링크 구조를 바꾸지 않는다.
링크 구조를 바꾸는 일은 되돌리기 어렵습니다. 프레임워크를 정적으로 합치거나 모듈을 병합하면 빌드 구조가 바뀝니다. 그 대가를 측정하지 못한 이득을 위해 낼 수는 없습니다.
실기 프로파일링이 열리면 다시 봅니다.
482ms는 로그아웃 상태였습니다
이 작업의 문서에 482ms를 적었습니다. 나중에 실기에서 로그인 상태로 재 봤더니 680ms였습니다.
수치가 틀린 것은 아닙니다. 조건을 빠뜨린 것입니다. 같은 기기, 같은 Release 설정, 일곱 번의 중앙값으로 두 상태를 나란히 재 봤습니다.
| 구간 | 로그아웃 | 로그인 | 차 |
|---|---|---|---|
pre-main (init.begin) |
1,421 | 1,341.8 | −79 |
auth.ready 구간 |
156 | 348.4 | +192 |
| 앱 소유 구간 | 482 | 679.5 | +198 |
차이가 전부 auth.ready에 있습니다. 실계정에서는 첫 프레임이 할 일이 더 많습니다. 계정 저장소 9건을 화면에 투영하고 동기화를 배선합니다.
로그인 상태에서 몇 퍼센트 줄었는지는 주장하지 않았습니다. 착수 전 코드에는 계측 자체가 없어서, 같은 자로 재려면 계측만 남기고 동작만 되돌린 판본을 따로 세워야 합니다. 그 실험은 하지 않았습니다.
성능 수치를 적을 때 조건을 함께 적어야 합니다. 기기, OS 버전, 빌드 설정, 반복 횟수, 그리고 어떤 상태의 사용자인지입니다.
UI 자동화는 비밀번호를 흘리고 저장소를 갈아 끼웁니다
실기 검증 과정에서 부수적으로 알게 된 것이 둘 있습니다.
첫째, 비밀번호 입력입니다. UI 자동화로 type을 쓰면 AutoFill이 개입해 한 글자만 남습니다. paste로 넣어야 14자가 전부 들어갑니다.
둘째, 이게 더 위험합니다. xcodebuild test가 호스트 앱을 띄우므로 계정 관련 테스트가 시뮬레이터의 실제 앱 그룹 저장소를 갈아 끼웁니다. 전체 스위트를 두 번 돌리자 사용자 기록 7건이 2건만 남았습니다.
시뮬레이터에서 화면을 촬영할 일이 있으면 테스트 스위트를 돌리기 전에 해야 합니다.