P1의 마지막 챕터다. P0 14장이 "컴파일·링크·dyld 로드"의 원리였다면, 여기서는 그 산출물이 사용자에게 전달되는 과정을 다룬다 — 얼마나 크고, 왜 그만큼이며, 어떻게 재고, 어떻게 늘어나는 것을 막는가. 앱 크기는 설치 이탈률에 직접 연결되는 제품 지표이면서, 동시에 실행 시간과도 얽혀 있다(18장 Q7의 콜드 스타트). 그래서 성능 작업의 마지막 축이기도 하다.
Q1. 다운로드 크기와 설치 크기는 왜 다른가?
다운로드 크기는 압축된 전송량이고 설치 크기는 기기에서 차지하는 실제 용량이다. Apple의 App Size Report 표현으로는 compressed = 다운로드 크기, uncompressed = 설치된 앱의 크기다. 여기에 앱 시닝이 겹친다 — 사용자는 자기 기기에 맞는 변형(variant) 하나만 받으므로, 우리가 업로드한 전체보다 훨씬 작다. 그리고 결정적으로 우리가 만드는 .app·.xcarchive·업로드용 .ipa는 크기 측정에 쓸 수 없다 — dSYM처럼 사용자가 받지 않는 것이 들어 있기 때문이다. 정확한 값을 얻으려면 App Size Report를 만들거나 App Store Connect를 봐야 한다.
CS 원리
같은 앱에 여러 크기가 존재하는 이유는 세 단계의 변환을 거치기 때문이다.
- 빌드 산출물 — 모든 아키텍처, 모든 해상도 자산, 디버그 심볼을 포함한 최대 크기.
- 시닝(thinning) — 대상 기기에 필요한 것만 남긴다. 아키텍처 하나, 해당 해상도 자산만.
- 압축 — 전송을 위해 압축한다.
그래서 나오는 숫자가 다르다.
| 숫자 | 무엇 | 사용자가 체감하는 것 |
|---|---|---|
| 빌드 산출물 크기 | 모든 변형 + 디버그 정보 | 아무것도 — 측정에 쓰면 안 된다 |
| 다운로드 크기 (compressed) | 시닝 + 압축된 전송량 | 대기 시간, 데이터 요금 |
| 설치 크기 (uncompressed) | 기기에서 차지하는 용량 | 저장 공간 압박 |
두 숫자가 서로 다른 문제를 나타낸다는 점이 중요하다. 다운로드 크기는 설치 시점의 장벽이고, 설치 크기는 계속 남는 부담이다. 저장 공간이 부족한 사용자는 큰 앱부터 지운다.
그리고 압축률은 내용에 따라 크게 다르다. 이미 압축된 데이터(JPEG·동영상)는 더 안 줄지만, 실행 바이너리와 텍스트는 잘 줄어든다. 그래서 다운로드 크기를 줄이는 최적화와 설치 크기를 줄이는 최적화가 다를 수 있다.
iOS에서는
Apple 문서가 명확히 경고하는 것부터 짚어야 한다 — 디버깅용으로 만들거나 Xcode에서 App Store Connect에 업로드하는 바이너리는 크기 측정에 적합하지 않다. 구체적으로 다음을 쓰면 안 된다.
.app번들.xcarchive번들- 업로드용
.ipa파일
이유는 사용자가 받는 번들에 없는 것들이 들어 있기 때문이다 — 대표적으로 크래시 리포팅용 dSYM(21장)이다. dSYM은 수십~수백 MB인데 사용자는 받지 않는다. 그래서 .ipa 파일 크기를 앱 크기로 보고하면 크게 부풀려진 값이 된다.
정확한 값을 얻는 경로는 둘이다.
- 개발 중 — Mac에서 App Size Report를 만든다. 다운로드·설치 크기의 근접 추정치를 준다.
- 배포 후 — App Store Connect가 가장 정확하다. 변형별 크기를 보여주고, 모바일 연결로 다운로드하는 한도를 넘으면 경고한다.
그리고 실무에서 자주 혼동되는 두 가지 비대칭이 있다.
(1) TestFlight 빌드가 더 크다. Apple 문서 명시 — TestFlight로 배포되는 앱에는 App Store 빌드에 없는 추가 데이터가 들어 있어 더 크다. 이 데이터는 App Store 배포 시에는 포함되지 않는다. 그래서 TestFlight에서 본 크기로 판단하면 과대평가한다.
(2) App Store 최종 크기가 업로드한 바이너리보다 커질 수 있다. 이것도 문서에 명시돼 있다 — App Store가 앱 바이너리에 추가 처리를 해서, 불법 복제 방지를 위한 DRM을 추가하고 다시 압축하기 때문이다. 그래서 "업로드 전에 재봤으니 됐다"가 정확하지 않다.
왜 크기가 중요한지도 문서가 설명한다 — App Store는 모바일 연결로 설치할 수 있는 앱 크기에 한도를 둔다. 한도를 넘으면 사용자가 Wi-Fi에 연결해야 설치할 수 있다. 그래서 Apple의 권고는 "한도보다 충분히 낮게 유지해 설치 가능 사용자 기반을 최대화하고 설치 시간을 최소화하라"이다.
실험 · 도구
App Size Report 만드는 절차는 정해져 있다.
# ── Xcode UI로 만들기 ──
# 1. Product › Archive
# 2. Organizer에서 Distribute App
# 3. Ad Hoc / Development / Enterprise 중 하나 선택
# 4. 앱 시닝 옵션에서 "All compatible device variants" 선택 ← 이게 핵심
# 5. 서명하고 Mac으로 export
#
# 결과 폴더에 생기는 것:
# - universal IPA (구형 기기용, 모든 변형 포함)
# - 변형별 thinned IPA
# - App Thinning Size Report.txt ← 이 파일이 목적
리포트 형식은 이렇다.
App Thinning Size Report for All Variants of ExampleApp
Variant: ExampleApp.ipa
Supported variant descriptors: [device: iPhone11,4, os-version: 12.0], …
App + On Demand Resources size: 6.7 MB compressed, 18.6 MB uncompressed
App size: 6.7 MB compressed, 18.6 MB uncompressed
On Demand Resources size: Zero KB compressed, Zero KB uncompressed
compressed가 다운로드 크기, uncompressed가 설치 크기다. 이 예시에서는 2.8배 차이가 난다.
CI에서 자동화하려면 xcodebuild로 export하면서 thinning 키를 <thin-for-all-variants>로 준다.
# ── CI 자동화 ──
# ExportOptions.plist 에 thinning 키를 넣는다
cat > ExportOptions.plist <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>ad-hoc</string>
<key>thinning</key><string><thin-for-all-variants></string>
</dict>
</plist>
PLIST
# ⚠️ 값의 꺾쇠괄호는 반드시 이스케이프해야 한다 (문서 명시)
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build/export \
-exportOptionsPlist ExportOptions.plist
# 리포트에서 숫자만 뽑아낸다
grep "^App size:" "build/export/App Thinning Size Report.txt" | head -1
# App size: 6.7 MB compressed, 18.6 MB uncompressed
파싱해서 지표로 만드는 스크립트는 이렇다.
#!/bin/bash
# 모든 변형의 크기를 뽑아 최댓값을 기준으로 삼는다
REPORT="build/export/App Thinning Size Report.txt"
awk '
/^Variant:/ { variant = $2 }
/^App size:/ {
# "App size: 6.7 MB compressed, 18.6 MB uncompressed"
dl = $3; dlu = $4; inst = $6; instu = $7
printf "%-40s 다운로드 %s %s / 설치 %s %s\n", variant, dl, dlu, inst, instu
}
' "$REPORT"
# 가장 큰 변형이 기준이다 — 그 기기 사용자가 가장 오래 기다린다
프로젝트 적용
크기 관리의 출발점 넷.
.ipa파일 크기를 앱 크기로 보고하지 않는다. dSYM 등이 포함돼 부풀려진 값이다. 이 실수가 매우 흔하고, 실제보다 나쁘게 보이게 만든다.- App Size Report를 CI에 넣는다. 릴리스마다 자동 생성해 기록하면 추세가 보인다. Q5에서 다룰 회귀 감시의 기반이다.
- 가장 큰 변형을 기준으로 본다. 평균이 아니라 최악의 기기가 사용자 경험을 결정한다.
- App Store Connect 값과 정기적으로 대조한다. 개발 중 리포트는 근접 추정치이고, DRM 추가와 재압축 때문에 최종 값이 다를 수 있다.
".ipa 크기가 앱 크기"가 가장 흔한 오해다. Apple 문서가 측정에 쓸 수 없다고 명시한다 — dSYM처럼 사용자가 안 받는 것이 들어 있다. "TestFlight에서 본 크기가 실제 크기"도 틀렸다 — TestFlight 빌드에는 추가 데이터가 있어 더 크다. "업로드한 바이너리보다 작아질 것"도 항상 맞지는 않다 — App Store가 DRM을 추가하고 재압축하므로 약간 커질 수 있다. "다운로드 크기만 줄이면 된다"도 반쪽이다 — 설치 크기는 사용자가 앱을 지울지 결정하는 요인이다. 또 "모든 사용자가 같은 크기를 받는다"도 틀렸다 — 변형마다 다르다.
이사 짐을 생각해보자. 같은 짐에 대해 숫자가 여러 개 나온다.
- 창고에 있는 전부 — 여름옷·겨울옷·설명서·포장재까지 다 합친 것. 실제로 옮기는 건 이게 아니다.
- 트럭에 실은 부피 — 압축포장해서 실었다. 이게 다운로드 크기다. 옮기는 시간이 여기 달렸다.
- 새 집에서 펼친 부피 — 포장을 뜯으면 다시 커진다. 이게 설치 크기다. 집이 좁으면 이게 문제다.
여기서 중요한 실수가 하나 있다. 창고 전체 무게를 보고 "우리 짐 3톤"이라고 말하는 것이다. 거기엔 안 가져갈 포장재와 설명서가 들어 있다. 실제로 옮기는 건 훨씬 적다.
그리고 재밌는 사실 — 사람마다 받는 짐이 다르다. 원룸 사는 사람에게는 큰 가구를 안 보낸다. 그래서 "우리 짐이 얼마"라고 하나로 말할 수가 없고, 가장 큰 집으로 가는 짐을 기준으로 봐야 한다.
꼬리 질문
앱 시닝은 구체적으로 무엇을 걸러내는가?
세 축으로 걸러낸다.
(1) 아키텍처 슬라이싱. 앱 바이너리에 여러 아키텍처가 들어 있어도 사용자는 자기 기기 것 하나만 받는다. 21장 Q2 꼬리질문에서 본 arm64·arm64e 구분이 여기서도 나타난다.
(2) 자산 슬라이싱. 에셋 카탈로그에 해상도별·기기별 자산이 있으면 해당 기기에 필요한 것만 전달된다. 이게 Apple이 에셋 카탈로그를 쓰라고 강조하는 핵심 이유다 — 번들에 파일을 그냥 넣으면 이 최적화가 안 된다.
(3) 온디맨드 리소스. 앱 실행에 당장 필요하지 않은 자원을 나중에 내려받게 태그해두면 초기 다운로드에서 빠진다. 게임의 후반 스테이지 리소스, 튜토리얼 영상 같은 것이 대상이다.
여기서 실무적으로 중요한 점은 에셋 카탈로그 사용 여부가 시닝 효과를 좌우한다는 것이다. Apple 문서 표현대로 각 자산에 어느 기기용인지 나타내는 메타데이터를 태그하면 시닝이 주는 크기 감소를 최대화할 수 있고, 모든 기기에 필요하지 않은 자산이 있는 앱에서는 그 효과가 상당하다.
반대로 자산을 번들에 직접 넣으면 모든 사용자가 모든 해상도 이미지를 받는다. 3배·2배 이미지가 다 들어가면 대부분의 사용자가 필요 없는 절반 이상을 받는 셈이다.
온디맨드 리소스는 언제 쓰고 무엇을 조심해야 하는가?
초기 실행에 필요 없는 큰 자원이 있을 때 쓴다. 게임 스테이지, 튜토리얼 영상, 지역별 데이터, 특정 기능에서만 쓰는 모델 파일 같은 것들이다.
이득은 초기 다운로드 크기가 줄어드는 것이고, 이건 설치 이탈률에 직접 영향을 준다. 특히 모바일 연결 설치 한도를 넘는 경우 한도 아래로 내리는 유일한 수단이 될 수 있다.
조심할 것이 넷 있다.
(1) 첫 사용 시 대기가 생긴다. 리소스를 처음 필요로 하는 순간 다운로드가 시작되므로 그 지점에서 로딩이 발생한다. 사용자가 오프라인이면 기능이 아예 안 된다. 실패 처리와 안내가 필수다.
(2) 시스템이 언제든 회수한다. 저장 공간이 부족하면 OS가 온디맨드 리소스를 지운다. 한 번 받았으니 계속 있다고 가정하면 안 된다 — 매번 존재 여부를 확인해야 한다.
(3) 관리 복잡도. 태그 관리, 다운로드 상태 추적, 우선순위 설정이 코드로 들어온다. 작은 자원에 쓰면 복잡도가 이득보다 크다.
(4) 미리 받아두기가 필요할 수 있다. 사용자가 그 기능에 도달하기 전에 백그라운드로 받아두면 대기가 사라지지만, 그러면 결국 데이터를 쓴다 — 초기 다운로드를 미룬 것일 뿐이다.
판단 기준은 "이 자원을 안 쓰는 사용자가 많은가"다. 대부분이 쓰는 자원을 온디맨드로 빼면 거의 모두가 나중에 받게 되어 총 데이터는 그대로이고 사용자 경험만 나빠진다.
다운로드 크기와 설치 크기 중 무엇을 우선 줄여야 하는가?
사용자 여정의 어느 단계가 문제인가에 따라 다르다.
다운로드 크기가 우선인 경우 — 신규 설치 전환율이 목표일 때다. 모바일 연결 한도를 넘으면 Wi-Fi를 찾을 때까지 설치가 미뤄지고, 그 사이 상당수가 이탈한다. Apple이 한도보다 충분히 낮게 유지해 설치 가능 사용자 기반을 최대화하라고 권하는 이유다. 광고로 유입되는 앱이라면 이 지점이 결정적이다.
설치 크기가 우선인 경우 — 유지율이 목표일 때다. 사용자가 저장 공간을 정리할 때 큰 앱부터 지운다. 특히 저용량 기기 사용자에게는 상시 압박이다.
실무적으로는 둘이 대체로 함께 움직인다 — 자산이나 코드를 줄이면 양쪽이 다 준다. 그래서 대부분의 최적화는 구분 없이 이득이다.
갈리는 경우는 압축률이 다른 자원을 다룰 때다.
- 이미 압축된 미디어(JPEG·H.264) — 다운로드와 설치 크기 차이가 거의 없다. 줄이면 양쪽이 같이 준다.
- 비압축 데이터·텍스트·바이너리 — 압축이 잘 되므로 설치 크기는 큰데 다운로드 크기는 작다. 이런 자원을 줄이면 설치 크기 개선 효과가 크다.
그래서 두 숫자를 나란히 추적하고, 차이가 큰 항목을 보면 어느 쪽 문제인지 알 수 있다. Apple이 리포트에 두 값을 함께 주는 이유이기도 하다.
Q2. 앱 크기는 무엇이 지배하는가?
대개 실행 바이너리와 자산 둘이고, 어느 쪽이 지배적인지는 앱마다 다르다. 그래서 추측하지 말고 IPA를 뜯어봐야 한다 — Apple이 안내하는 방법이 정확히 그것이다. thinned IPA의 확장자를 ZIP으로 바꿔 풀고 번들 내용을 직접 보는 것이다. 그리고 기본 최적화 두 가지를 먼저 확인해야 한다. (1) 최적화 레벨이 의도대로인가 — C/ObjC의 Release 기본값은 Fastest, Smallest [-Os]이고, Swift의 Release 기본값은 -O(속도)다. 실수로 -Onone이 되어 있지 않은지 확인하고, 크기가 문제라면 Swift는 -Osize를 검토한다(기본값이 아니다). (2) 에셋 카탈로그를 쓰는가 — 안 쓰면 시닝이 작동하지 않아 모든 사용자가 모든 해상도 자산을 받는다.
CS 원리
바이너리 크기가 커지는 메커니즘을 알면 대응이 명확해진다.
- 코드 자체 — 우리가 쓴 코드와 의존성의 코드.
- 제네릭 특수화 — Swift 컴파일러가 제네릭 함수를 구체 타입별로 복제한다(P0 05장 Q7). 성능은 좋아지지만 코드 크기가 늘어난다.
- 인라이닝 — 함수 본문이 호출 지점에 복제된다(21장 Q4). 같은 트레이드오프다.
- 메타데이터 — Swift 런타임이 쓰는 타입 메타데이터, 프로토콜 준수 정보, 리플렉션 정보.
- 문자열 상수 — 소스에 박힌 문자열이 그대로 바이너리에 들어간다.
두 번째와 세 번째가 최적화 레벨과 직결된다. Swift의 -O는 성능을 위해 특수화와 인라이닝을 적극적으로 하고, -Osize는 코드 크기가 크게 늘어나는 경우를 억제한다. 그래서 같은 코드가 설정에 따라 눈에 띄게 다른 크기가 된다. 주의 — Swift에는 -Os가 없다(unknown argument 에러). Xcode의 Fastest, Smallest [-Os]는 C/ObjC용 GCC_OPTIMIZATION_LEVEL 설정이고, Swift는 별도로 SWIFT_OPTIMIZATION_LEVEL을 쓴다.
자산 쪽 원리는 다르다. 여기서는 중복이 핵심 문제다.
- 해상도 중복 — @1x·@2x·@3x가 다 들어가면 대부분의 사용자에게 3분의 2가 낭비다.
- 포맷 비효율 — 압축 설정이 적절하지 않으면 그대로 커진다.
- 미사용 자산 — 쓰지 않는 이미지·폰트·데이터가 계속 남아 있다.
Apple이 에셋 카탈로그를 강조하는 이유가 여기 있다 — 카탈로그에 넣고 메타데이터를 태그해야 시스템이 골라낼 수 있다. 번들에 파일로 넣으면 시스템은 그게 뭔지 모르므로 전부 전달한다.
iOS에서는
Apple이 제시하는 기본 최적화 순서를 그대로 따르는 것이 효율적이다.
1단계 — 릴리스 빌드 설정 확인. Apple 문서는 Release 구성의 기본 최적화 레벨이 Fastest, Smallest [-Os]이며 이것이 컴파일된 바이너리를 매우 작게 만들 수 있다고 안내한다 — 이는 C/ObjC(GCC_OPTIMIZATION_LEVEL) 이야기다. Swift는 SWIFT_OPTIMIZATION_LEVEL이 별도이고 Release 기본값이 -O다. 앱이 예상보다 크다면 설정이 의도치 않게 바뀌지 않았는지 먼저 확인한다.
# 최적화 레벨 확인 — Swift와 C/ObjC 는 설정이 별개다
xcodebuild -showBuildSettings -configuration Release -scheme MyApp \
| grep -E 'SWIFT_OPTIMIZATION_LEVEL|GCC_OPTIMIZATION_LEVEL|LLVM_LTO'
# SWIFT_OPTIMIZATION_LEVEL = -O ← Swift Release 기본값 (속도 우선)
# GCC_OPTIMIZATION_LEVEL = s ← C/ObjC 는 s (=-Os)
#
# ⚠️ -Onone 으로 바뀌어 있으면 바이너리가 크게 는다.
# 크기가 문제라면 Swift 는 -Osize 를 검토한다 (기본값 아님, 성능과 맞바꿈).
# Swift 에 -Os 는 존재하지 않는다.
2단계 — IPA를 뜯어본다. Apple이 안내하는 절차가 명확하다.
# thinned IPA 를 만든 뒤 (Q1) 내용을 직접 확인한다
cd build/export
cp "MyApp-iPhone14,2.ipa" inspect.zip
unzip -q inspect.zip -d inspect/
# 또는 풀지 않고 목록만 크기순으로 본다
unzip -lv inspect.zip | sort -k1 -n -r | head -30
# 앱 번들 안에서 가장 큰 항목 찾기
du -ah "inspect/Payload/MyApp.app" | sort -rh | head -30
# → 여기서 실행 바이너리가 큰지, 자산이 큰지가 즉시 드러난다
# 실행 바이너리 내부 구성 보기
size -m "inspect/Payload/MyApp.app/MyApp"
# __TEXT (코드) / __DATA / __LINKEDIT 별 크기
# 섹션별 상세 — Swift 메타데이터가 큰지 확인할 수 있다
otool -l "inspect/Payload/MyApp.app/MyApp" | grep -A4 "sectname" | head -60
Apple이 함께 안내하는 것이 미사용 파일 정리다 — 앱의 README 파일을 타깃에 추가하지 않았는지 확인하고, 사용하지 않는 이미지 자산과 헤더 파일을 제거하라는 구체적 예시까지 준다. 실제로 이런 것들이 쌓이는 경우가 흔하다.
3단계 — 에셋 카탈로그 채택. 문서가 권하는 것 셋이다.
- 메타데이터 태그. 각 자산에 어느 기기용인지 표시해 시닝 효과를 최대화한다.
- 리사이즈 가능 영역 정의. 이미지의 늘어나는 중앙 영역과 끝 캡을 정의하면 작은 이미지로 큰 영역을 채울 수 있다.
- 자산 유형별 압축 레벨 설정. 특히 wide color 이미지나 Metal 텍스처는 ASTC 압축을 고려한다.
4단계 — 데이터를 코드에서 빼낸다. 이것도 문서의 구체적 권고다 — 코드에 문자열로 데이터를 넣지 말고 property list 같은 자산 파일로 번들하라는 것이다. 일부 개발자가 소스 코드로 이미지 같은 리소스를 배포하기도 하는데, 이런 것들을 자산 파일로 옮기면 바이너리 크기가 크게 줄고, App Store Connect가 더 효율적으로 압축할 수 있다.
실험 · 도구
어디가 큰지 자동으로 분해하는 스크립트를 만들어두면 매번 편하다.
#!/bin/bash
# 앱 번들 구성을 카테고리별로 집계한다
APP="$1" # inspect/Payload/MyApp.app
echo "── 전체"
du -sh "$APP"
echo "── 실행 바이너리"
find "$APP" -maxdepth 1 -type f -perm +111 -exec du -h {} \; 2>/dev/null
echo "── 임베디드 프레임워크 (Q3)"
du -sh "$APP"/Frameworks/* 2>/dev/null | sort -rh
echo "── 앱 익스텐션 (24장)"
du -sh "$APP"/PlugIns/* 2>/dev/null | sort -rh
echo "── 에셋 카탈로그"
du -h "$APP"/Assets.car 2>/dev/null
echo "── 번들에 직접 들어간 파일 상위 20"
find "$APP" -type f ! -path "*/Frameworks/*" ! -path "*/PlugIns/*" \
-exec du -h {} \; | sort -rh | head -20
# ⚠️ 여기에 README, 테스트 픽스처, 미사용 이미지가 있는지 본다
에셋 카탈로그 내부는 assetutil로 들여다볼 수 있다.
# Assets.car 안의 항목별 정보
xcrun assetutil --info "$APP/Assets.car" > assets.json
python3 - <<'PY'
import json
items = json.load(open("assets.json"))
sized = [(i.get("SizeOnDisk", 0), i.get("Name", "?"), i.get("Scale", "")) for i in items if isinstance(i, dict)]
for s, n, sc in sorted(sized, reverse=True)[:20]:
print(f"{s/1024:8.1f} KB {n} @{sc}x")
PY
# → 어느 이미지가 큰지, 불필요한 스케일이 들어갔는지 확인
Swift 특유의 크기 요인도 확인할 수 있다.
# 심볼 크기 상위 — 제네릭 특수화가 폭발한 곳을 찾는다
nm -nm "$APP/MyApp" | sort -k1 | tail -30 # 주소 순 상위 심볼 (대략적 감만 준다)
# 링커 맵으로 정확한 기여도 확인 — 빌드 설정에서 Write Link Map File = YES
# Build Settings › Write Link Map File
# 결과: DerivedData/.../MyApp-LinkMap-normal-arm64.txt
awk '/^# Symbols:/{f=1;next} f{print $2, $NF}' MyApp-LinkMap-normal-arm64.txt \
| sort -k1 -r | head -30
# → 어느 심볼·어느 오브젝트 파일이 바이너리를 키우는지 정량적으로 나온다
링크 맵이 가장 정확한 도구다. 심볼별·오브젝트 파일별 기여도를 바이트 단위로 알려주므로, "어느 모듈이 바이너리의 몇 %인가"를 정확히 답할 수 있다.
프로젝트 적용
크기 관리 실무 규칙 다섯.
- 측정부터 한다. 추측으로 최적화하면 효과 없는 곳에 시간을 쓴다. IPA를 뜯고 링크 맵을 보는 30분이 며칠을 아낀다.
- 빌드 설정을 먼저 확인한다. 최적화 레벨이 잘못돼 있으면 다른 무엇보다 효과가 크고, 확인 비용이 0에 가깝다.
- 에셋 카탈로그를 기본으로. Apple 표현대로 일찍 채택하면 나중에 비싼 최적화가 필요할 가능성이 줄어든다. 새 프로젝트라면 처음부터 그렇게 한다.
- 코드에 데이터를 넣지 않는다. 큰 문자열 상수, 코드로 표현한 이미지, 하드코딩된 JSON은 자산 파일로 옮긴다.
- 미사용 자산 정리를 주기적으로. 기능이 제거돼도 자산은 남는 경우가 많다. 릴리스마다 상위 항목을 훑으면 발견된다.
"코드가 커서 앱이 크다"는 확인 없이 단정할 수 없다. 자산이 지배적인 앱이 훨씬 많다 — IPA를 뜯어봐야 안다. "이미지를 그냥 번들에 넣어도 시닝이 된다"도 틀렸다 — 에셋 카탈로그에 넣어야 시스템이 골라낼 수 있다. "릴리스 빌드니 최적화는 당연히 켜져 있다"도 확인이 필요하다 — 설정이 의도치 않게 바뀌는 일이 실제로 있다. "제네릭을 많이 쓰면 코드가 줄어든다"도 반대다 — 특수화로 복제되어 늘어날 수 있다. 또 "미사용 코드는 링커가 다 지워준다"도 과신이다 — Objective-C 런타임에서 참조되거나 리플렉션 대상이면 남는다.
짐이 왜 많은지 알아보려면 상자를 열어봐야 한다. "책이 많아서"라고 짐작했는데 열어보니 포장재가 절반인 경우가 흔하다.
그래서 순서가 이렇다.
1. 포장 방식부터 확인. 압축포장 기계를 "압축 안 함"으로 설정해두고 짐이 많다고 하는 경우가 있다. 스위치 하나 바꾸면 절반이 준다. 확인이 제일 싸니 먼저 한다.
2. 상자를 실제로 열어본다. 무게 순으로 늘어놓으면 범인이 바로 보인다. 대개 예상과 다르다.
3. 딱지를 붙인다. "여름옷", "겨울옷"이라고 표시해두면 이삿짐센터가 필요한 것만 골라 실을 수 있다. 표시가 없으면 전부 실어야 한다.
4. 안 쓰는 걸 버린다. 열어보면 설명서, 빈 상자, 몇 년째 안 쓴 물건이 꼭 나온다.
가장 흔한 실수는 1번과 2번을 건너뛰고 3번부터 하는 것이다. 딱지를 열심히 붙였는데 알고 보니 압축 스위치가 꺼져 있었다면 헛수고다.
꼬리 질문
Swift 제네릭과 프로토콜이 바이너리 크기에 주는 영향은?
양방향으로 작용한다.
크기를 늘리는 방향 — 특수화(specialization). 컴파일러가 제네릭 함수를 구체 타입별로 복제해 최적화한다(P0 05장 Q7). Array<Int>, Array<String>, Array<MyModel>에 대해 각각 코드가 생길 수 있다. 성능은 좋아지지만 타입 수만큼 코드가 곱해진다.
크기를 줄이는 방향 — 비특수화. 특수화하지 않으면 하나의 제네릭 구현이 witness table을 통해 동작한다. 코드는 하나뿐이라 작지만 동적 디스패치 비용이 붙는다.
Swift의 -Osize는 이 트레이드오프에서 크기 쪽으로 기운 선택을 한다 — 크게 늘어나는 특수화와 인라이닝을 억제한다. 다만 Release 기본값은 -O(속도)이므로, -Osize는 크기가 실제로 문제일 때 측정하고 바꾸는 선택지다.
실무에서 크기 문제가 될 만한 패턴이 둘 있다.
- 제네릭 타입을 많은 구체 타입으로 인스턴스화. 제네릭 뷰모델이나 리포지토리를 수십 개 모델에 적용하면 코드가 그만큼 복제될 수 있다.
@inlinable남용. 모듈 경계를 넘어 인라인되게 하면 호출처마다 코드가 복제된다.
대응은 측정 후 판단이다. 링크 맵으로 특정 제네릭 함수의 특수화가 크게 잡히면, 그 경로만 any 기반 존재 타입으로 바꿔 코드 공유를 유도할 수 있다. 다만 성능이 나빠질 수 있으므로 크기가 실제로 문제일 때만 한다.
일반 원칙은 "기본값을 믿고, 문제가 측정되면 그때 손댄다"이다. 제네릭을 피해서 코드를 짜는 것은 대개 손해다.
링크 맵으로 "어느 라이브러리가 몇 MB인가"를 어떻게 계산하는가?
링크 맵은 오브젝트 파일 목록과 심볼별 주소·크기를 담고 있어, 심볼을 오브젝트 파일로 묶어 합산하면 라이브러리별 기여도가 나온다.
구조는 세 부분이다.
# Object files:— 번호와 파일 경로 목록. 여기에libSomeSDK.a(Foo.o)형태로 나오므로 라이브러리를 식별할 수 있다.# Sections:— 세그먼트·섹션 범위.# Symbols:— 주소, 크기, 소속 오브젝트 파일 번호, 심볼 이름.
# 오브젝트 파일별 크기 합산
python3 - <<'PY'
import re, collections, sys
path = "MyApp-LinkMap-normal-arm64.txt"
objs, sizes = {}, collections.Counter()
section = None
for line in open(path, encoding="utf-8", errors="ignore"):
if line.startswith("#"):
if "Object files:" in line: section = "obj"
elif "Symbols:" in line: section = "sym"
else: section = None
continue
if section == "obj":
m = re.match(r"\[\s*(\d+)\]\s+(.*)", line.strip())
if m: objs[int(m.group(1))] = m.group(2)
elif section == "sym":
parts = line.split()
if len(parts) >= 3 and parts[1].startswith("0x"):
size = int(parts[1], 16)
m = re.match(r"\[\s*(\d+)\]", parts[2])
if m: sizes[int(m.group(1))] += size
# 라이브러리 단위로 묶는다 — libFoo.a(Bar.o) 에서 libFoo.a 를 추출
lib = collections.Counter()
for idx, size in sizes.items():
name = objs.get(idx, "?")
m = re.search(r"([^/]+\.a)\(", name)
lib[m.group(1) if m else "(앱 코드)"] += size
for name, size in lib.most_common(20):
print(f"{size/1024/1024:8.2f} MB {name}")
PY
이 결과가 SDK 도입 판단의 근거가 된다. "이 기능을 위해 3MB를 쓸 가치가 있는가"를 정량적으로 논의할 수 있다.
주의할 점은 링크 맵은 최종 앱 크기가 아니라 바이너리 기여도라는 것이다. 압축과 시닝을 거치지 않은 값이므로 실제 다운로드 크기 증가분은 더 작다. 비교·순위 판단에는 유효하지만 절대값으로 쓰면 과대평가한다.
그리고 링크 맵 생성은 빌드 설정 Write Link Map File = YES가 필요하고, 빌드 시간이 조금 는다. 상시 켜기보다 크기 조사할 때만 켜는 편이 낫다.
미사용 코드가 링커에서 제거되지 않는 경우는?
링커는 참조되지 않는 코드를 지울 수 있다(dead code stripping). 그런데 "참조된다"의 판정이 정적으로 안 되는 경우가 있고, 그럴 때 남는다.
대표적 사례가 넷이다.
(1) Objective-C 런타임 참조. ObjC 메서드는 objc_msgSend로 문자열 셀렉터를 통해 호출되므로, 링커가 "이 메서드를 아무도 안 부른다"를 증명할 수 없다. ObjC 클래스는 기본적으로 제거되지 않는다.
(2) 리플렉션·동적 조회. NSClassFromString, Mirror, Codable의 일부 경로처럼 런타임에 타입을 찾는 코드가 있으면 관련 메타데이터가 남아야 한다.
(3) Storyboard·XIB 참조. 인터페이스 파일에 클래스 이름이 문자열로 적혀 있으면 코드에서 참조가 없어도 필요하다.
(4) 정적 라이브러리 전체 링크. -ObjC나 -all_load 플래그를 쓰면 쓰지 않는 오브젝트 파일까지 전부 포함된다. ObjC 카테고리 때문에 필요한 경우가 있어 관행적으로 켜두는데, 크기 비용이 크다.
확인 방법은 링크 맵에서 예상 못 한 심볼이 큰 비중을 차지하는지 보는 것이다. 쓰지 않는 기능의 코드가 크게 잡히면 위 사례 중 하나다.
대응은 (a) -ObjC가 정말 필요한지 재검토, (b) 순수 Swift 모듈로 분리(Swift는 정적 분석이 더 잘 된다), (c) 쓰지 않는 의존성 제거다. 마지막이 가장 확실하다 — 안 쓰는 SDK를 빼는 것이 어떤 링커 옵션보다 효과적이다.
Q3. 동적 프레임워크는 어떤 비용을 주는가?
크기와 실행 시간 양쪽에 비용을 준다. P0 14장에서 본 static/dynamic 구분이 여기서 배포 관점의 귀결로 나타난다. 크기 쪽에서는 동적 프레임워크가 자체 번들·서명·메타데이터를 갖고, dead code stripping이 프레임워크 경계를 넘지 못한다 — 정적 링크라면 안 쓰는 코드가 제거될 수 있는데 동적이면 통째로 들어간다. 실행 시간 쪽에서는 dyld가 앱 시작 시 각 프레임워크를 로드하고 심볼을 바인딩해야 하므로 콜드 스타트가 늘어난다(18장 Q7). 그래서 프레임워크 개수 자체가 비용이고, 여러 작은 프레임워크보다 하나로 합치는 편이 대체로 낫다.
CS 원리
정적 링크와 동적 링크의 차이가 크기에 미치는 영향을 정리하면 이렇다.
| 정적 링크 | 동적 프레임워크 | |
|---|---|---|
| 포함 단위 | 실제로 참조되는 부분 | 프레임워크 전체 |
| dead code stripping | 적용됨 | 경계를 못 넘음 |
| 부가 파일 | 없음 | Info.plist, 서명, 번들 구조 |
| 앱 시작 비용 | 없음 (이미 바이너리 안) | dyld 로드 + 심볼 바인딩 |
| 여러 앱/익스텐션 공유 | 각자 복제 | 한 벌 공유 |
| 빌드 시간 | 링크가 무거움 | 증분 빌드에 유리 |
dead code stripping이 경계를 못 넘는다는 점이 크기 관점의 핵심이다. 정적 라이브러리라면 링커가 실제로 참조되는 오브젝트 파일만 가져올 수 있지만, 동적 프레임워크는 독립적으로 로드되는 단위라 통째로 포함된다. 큰 SDK에서 함수 하나만 써도 전부 들어간다.
실행 시간 쪽 원리는 P0 14장의 dyld 논의로 돌아간다. 앱 시작 시 dyld가 하는 일이 있다.
- 각 동적 라이브러리를 찾아서 메모리에 매핑한다.
- 의존성을 재귀적으로 해결한다.
- 심볼을 바인딩한다 — 각 참조를 실제 주소로 연결.
- 초기화 코드(
+load, C++ 정적 생성자 등)를 실행한다.
이 비용이 프레임워크 개수에 비례한다. 그래서 작은 프레임워크 20개가 큰 프레임워크 1개보다 느리다 — 총 코드량이 같아도 그렇다.
시스템 프레임워크는 예외다. dyld 공유 캐시에 미리 준비돼 있어 이 비용이 대부분 사라진다. 우리가 만든 프레임워크는 캐시에 없으므로 매번 비용을 낸다.
iOS에서는
실무 선택지와 특성을 정리하면 이렇다.
| 형태 | 크기 | 시작 시간 | 적합한 경우 |
|---|---|---|---|
정적 라이브러리 (.a) | 작음 | 영향 없음 | 리소스가 없는 순수 코드 |
| 정적 프레임워크 | 작음 | 영향 없음 | 리소스가 필요한 코드 (Xcode 15+ 리소스 번들 지원) |
| 동적 프레임워크 | 큼 | 개수만큼 | 익스텐션과 공유가 필요할 때 (24장) |
| XCFramework | 내부 형태에 따름 (정적/동적 모두 가능) | 바이너리 배포 | |
XCFramework는 별개 축이라는 점을 짚어야 한다. XCFramework는 여러 플랫폼·아키텍처 변형을 한 배포 단위로 묶는 포맷이지 정적/동적 여부를 정하는 것이 아니다. 안에 정적 라이브러리가 들어갈 수도, 동적 프레임워크가 들어갈 수도 있다.
배포 형태로 XCFramework가 표준이 된 이유는 fat binary의 한계 때문이다. 예전에는 여러 아키텍처를 하나의 바이너리에 합쳤는데, 시뮬레이터용 arm64와 기기용 arm64가 같은 아키텍처 이름이라 구분이 안 되는 문제가 생겼다. XCFramework는 플랫폼별로 디렉터리를 나눠 이를 해결한다.
그리고 24장의 익스텐션 제약이 여기 연결된다. 익스텐션과 코드를 공유하는 정식 수단이 embedded framework이므로, 익스텐션이 있으면 동적 프레임워크가 필요할 수 있다. 이 경우 크기·시작 시간 비용을 감수하는 대신, 익스텐션마다 코드가 복제되는 것을 피한다 — 정적으로 하면 앱과 익스텐션 각각에 코드가 들어가 오히려 더 커질 수 있다.
실험 · 도구
프레임워크 개수가 시작 시간에 미치는 영향은 직접 잴 수 있다.
아래 DYLD_PRINT_STATISTICS의 5줄 출력 형식은 dyld3 시절 기준이다. 현행 dyld에서는 아무것도 출력되지 않을 수 있다(로컬 바이너리로 확인한 결과 출력 없음). 값이 안 나오면 Instruments의 App Launch 템플릿을 쓴다 — 이쪽이 Apple의 현행 권고이고 pre-main 구간도 함께 분해해 준다. 아래 예시는 항목별로 무엇을 줄여야 하는지 이해하는 용도로 읽는다.
# dyld가 앱 시작에 쓰는 시간을 출력한다 (동작하는 환경이라면)
# Xcode › Edit Scheme › Run › Arguments › Environment Variables
# DYLD_PRINT_STATISTICS = 1
# ⚠️ 현행 OS 에서는 출력이 없을 수 있다 → Instruments App Launch 템플릿 사용
#
# 콘솔 출력 예시:
# Total pre-main time: 412.19 milliseconds (100.0%)
# dylib loading time: 198.34 milliseconds (48.1%) ← 프레임워크 로드
# rebase/binding time: 62.11 milliseconds (15.0%) ← 심볼 바인딩
# ObjC setup time: 41.22 milliseconds (10.0%)
# initializer time: 110.52 milliseconds (26.8%) ← +load 등
#
# → dylib loading 이 크면 프레임워크 개수를 줄이는 것이 직접적 개선이다
# 실제로 로드되는 동적 라이브러리 목록과 개수
otool -L "MyApp.app/MyApp" | grep -c "@rpath"
otool -L "MyApp.app/MyApp" | grep "@rpath"
# 번들 안 프레임워크 크기 순위
du -sh MyApp.app/Frameworks/* | sort -rh
정적/동적 전환의 효과를 비교하려면 두 빌드를 만들어 잰다.
# SPM 의존성이라면 Package.swift 에서 type을 바꿔 비교할 수 있다
# .library(name: "Core", type: .static, targets: ["Core"])
# .library(name: "Core", type: .dynamic, targets: ["Core"])
#
# 각각 빌드해 App Size Report(Q1)와 DYLD_PRINT_STATISTICS를 비교한다
# 비교 항목:
# 1) 다운로드/설치 크기 (Q1의 리포트)
# 2) pre-main 시간 (DYLD_PRINT_STATISTICS)
# 3) 앱 + 익스텐션 총 크기 (익스텐션이 있으면 정적이 더 클 수 있다)
3번이 중요하다. 익스텐션이 있는 앱에서는 정적이 항상 작은 것이 아니다. 앱과 익스텐션 두 곳에 같은 코드가 들어가면 동적으로 공유하는 편이 총합이 작다.
// 시작 시간에 영향을 주는 것 — 초기화 코드도 dyld 비용이다
// ❌ 전역 상수의 초기화가 무거우면 앱 시작에 얹힌다
let heavyResource = loadLargeConfiguration() // 프로세스 시작 시 실행될 수 있다
// ✅ 지연 초기화로 미룬다
enum Config {
static let shared: Configuration = {
// 실제로 처음 접근할 때 초기화된다 (swift_once)
loadLargeConfiguration()
}()
}
// ⚠️ 다만 19장 Q6에서 본 대로, 지연 초기화는 크래시 핸들러 같은
// 제약 있는 컨텍스트에서 예기치 않은 할당을 유발할 수 있다.
프로젝트 적용
선택 기준 다섯.
- 기본값을 정적으로 둔다. 익스텐션 공유가 필요하지 않다면 정적이 크기와 시작 시간 모두에 유리하다.
- 익스텐션이 있으면 총합으로 판단한다. 앱 + 익스텐션 전체 크기를 비교해야 정확하다.
- 프레임워크 개수를 줄인다. 모듈 분리는 빌드 시간과 아키텍처에 좋지만, 배포 단위까지 쪼갤 필요는 없다. 개발 시에는 여러 모듈로 두고 배포 시 하나로 합치는 구성이 좋다.
- 시작 시간을 측정한다.
DYLD_PRINT_STATISTICS로 pre-main 시간을 보고, dylib loading 비중이 크면 개수를 줄인다. 18장 Q7의 콜드 스타트 예산과 같은 축이다. - SDK 도입 시 크기 기여도를 확인한다. Q2의 링크 맵 분석으로 정량화하고, 도입 기준에 크기 항목을 넣는다 — 21장(dSYM), 23장(privacy manifest), 24장(app-extension-safe)에 이은 네 번째 도입 기준이다.
"동적 프레임워크는 공유되니 크기가 준다"는 iOS 앱 안에서는 대체로 틀렸다. 시스템 프레임워크는 공유되지만, 우리가 임베드한 프레임워크는 우리 앱 번들 안에만 있다. "정적이 항상 작다"도 조건부다 — 익스텐션이 있으면 코드가 복제되어 총합이 커질 수 있다. "프레임워크를 잘게 나누면 필요한 것만 로드된다"도 틀렸다 — 링크된 것은 시작 시 전부 로드된다(지연 로딩을 명시적으로 구현하지 않는 한). "XCFramework면 동적"도 오해다 — 포맷일 뿐 정적도 가능하다. 또 "시작 시간은 코드 실행만 세면 된다"도 부족하다 — pre-main 구간(dyld)이 상당한 비중을 차지한다.
이사할 때 가구를 조립해서 옮길지, 부품째 옮길지를 정하는 것과 비슷하다.
정적 링크 = 필요한 부품만 골라 상자에 섞어 넣기. 안 쓰는 나사는 빼고 담는다. 상자 하나라 옮기기도 편하다.
동적 프레임워크 = 가구를 통째로, 각자 포장해서 옮기기. 포장재가 따로 들고, 안 쓸 부품까지 다 들어 있다. 그리고 도착해서 하나씩 풀고 자리를 잡아야 해서 시간이 걸린다.
여기서 중요한 건 — 가구가 20개면 푸는 데 20번 걸린다는 것이다. 크기가 작아도 개수 자체가 시간이다.
그런데 집이 두 채라면(앱 + 익스텐션) 얘기가 달라진다. 부품을 섞어 담으면 양쪽 집에 똑같은 부품을 하나씩 넣어야 한다. 이럴 땐 가구 하나를 두 집이 나눠 쓰는 게 총량이 적다.
그래서 답은 "집이 한 채면 섞어 담고, 두 채면 계산해보라"이다.
꼬리 질문
모듈을 여러 개로 나누면서 배포는 하나로 합치는 방법은?
개발 시 모듈 분리의 이득(빌드 시간, 의존 경계 강제, 팀 분업)은 유지하면서 배포 비용을 피하는 방법이 몇 가지 있다.
(1) SPM에서 정적 라이브러리로 선언. Package.swift에서 type: .static으로 두면 여러 모듈로 나눠 개발하면서 최종 앱 바이너리에 합쳐진다. 가장 간단하고 효과적이다.
// Package.swift — 개발은 모듈별로, 배포는 앱 바이너리에 통합
products: [
.library(name: "Core", type: .static, targets: ["Core"]),
.library(name: "Networking", type: .static, targets: ["Networking"]),
.library(name: "Design", type: .static, targets: ["Design"]),
](2) 머지드 프레임워크. 여러 동적 프레임워크를 빌드 시점에 하나로 합쳐 dyld 로드 횟수를 줄이는 방식이 있다. 익스텐션 공유가 필요하면서 개수를 줄이고 싶을 때 검토할 수 있다.
(3) 엄브렐라 프레임워크. 동적 프레임워크 하나 안에 여러 모듈의 코드를 담는다. 구성이 복잡해질 수 있다.
실무 권장은 (1)이다. 대부분의 경우 충분하고 설정이 단순하다.
주의할 점은 정적 링크 시 리소스 처리다. 예전에는 정적 라이브러리가 리소스를 못 담아 별도 번들이 필요했는데, 최근 Xcode·SPM은 정적 타깃의 리소스 번들을 지원한다. 다만 번들 접근 코드가 정적/동적에 따라 달라질 수 있어(Bundle.module 등) 전환 시 확인이 필요하다.
그리고 ObjC 카테고리가 정적 링크에서 누락되는 고전적 문제가 있으므로, ObjC 코드가 섞여 있으면 -ObjC 플래그 필요 여부를 확인해야 한다(Q2 꼬리질문).
DYLD_PRINT_STATISTICS의 각 항목은 무엇을 줄이면 개선되는가?
항목별로 대응이 다르다.
dylib loading time — 동적 라이브러리를 찾고 매핑하는 시간. 프레임워크 개수를 줄이면 직접 개선된다. 개수에 비례하므로 가장 예측 가능한 최적화다.
rebase/binding time — 주소 재배치와 심볼 바인딩. 외부 심볼 참조 수에 비례한다. ObjC 클래스·메서드가 많으면 커진다. 모듈 간 참조를 줄이거나 정적 링크로 바꾸면 준다.
ObjC setup time — ObjC 런타임이 클래스·카테고리를 등록하는 시간. ObjC 클래스와 카테고리 수에 비례한다. Swift 전용 코드로 옮기면 준다.
initializer time — +load 메서드, C++ 정적 생성자, 전역 변수 초기화. 여기가 크면 우리 코드나 SDK가 시작 시점에 무거운 일을 하는 것이다. +load를 +initialize로 바꾸거나 지연 초기화로 미루면 개선된다.
진단 순서는 비중이 큰 항목부터다. 그리고 총 pre-main 시간이 콜드 스타트에서 차지하는 비율을 봐야 한다 — pre-main이 100ms인데 전체가 2초라면 다른 곳이 문제다(18장 Q7).
주의: 이 환경 변수는 디버그 실행에서만 동작하고, 실제 사용자 기기의 값은 다르다. 정확한 프로덕션 값은 MetricKit의 실행 시간 히스토그램으로 봐야 한다(19장 Q7).
SDK 도입 기준에 크기를 넣는다면 어떤 형태가 좋은가?
절대값 상한보다 절차와 근거를 정하는 편이 실효성이 있다. 네 가지 요소를 권한다.
(1) 측정 의무. 새 SDK를 도입하는 PR에는 크기 증가분을 측정해 기재한다. Q2의 링크 맵 방식이나 App Size Report 전후 비교를 쓴다. 측정하는 것만으로도 "일단 넣고 보자"가 줄어든다.
(2) 기능당 비용 환산. "이 SDK는 3MB이고 A·B 기능을 제공한다"를 적으면, 같은 기능을 더 싸게 얻을 방법을 검토하게 된다. 기능 하나만 쓰는데 큰 SDK를 넣는 경우가 흔하다.
(3) 임계 초과 시 논의. "1MB 이상이면 팀 논의"처럼 자동 통과 한도를 두면 작은 것은 마찰 없이 지나가고 큰 것만 검토된다.
(4) 다른 도입 기준과 묶는다. 이 챕터들에서 나온 항목이 이제 넷이다.
- dSYM 제공 (21장 Q3) — 크래시 진단 가능성
- privacy manifest 제공 (23장 Q2) — 심사 통과
- app-extension-safe (24장 Q6) — 익스텐션에서 사용 가능성
- 크기 기여도 (이 챕터)
여기에 유지보수 상태(최근 릴리스, 이슈 응답)를 더하면 체크리스트가 완성된다. 넷 다 "이 벤더가 플랫폼 요구사항을 따라오고 있는가"를 묻는 같은 종류의 질문이라, 하나가 나쁘면 나머지도 나쁜 경우가 많다.
그리고 제거 절차도 함께 정해두면 좋다. 도입은 쉽고 제거는 어려운 것이 SDK인데, "6개월 뒤 사용률 재검토" 같은 항목을 넣으면 죽은 의존성이 쌓이는 것을 막는다 — 18장 Q7의 "안 쓰는 계측 제거"와 같은 발상이다.
Q4. 크기 회귀를 CI에서 어떻게 막는가?
측정을 자동화하고 회귀를 빌드 실패로 만든다. 21장 Q6에서 본 "조용한 실패를 없앤다"가 여기서도 그대로 적용된다 — 앱 크기는 매 릴리스 조금씩 늘어나고 아무도 알아채지 못하다가 어느 날 한도에 부딪힌다. 구성은 셋이다. (1) 매 빌드 App Size Report 생성(Q1의 xcodebuild -exportArchive + thinning), (2) 기준선 대비 증가폭 검사 — 절대값 상한보다 "이전 릴리스 대비 N% 이상 증가하면 실패"가 실용적이다, (3) 증가 원인 자동 리포트 — 링크 맵과 번들 구성을 diff해 무엇이 늘었는지까지 알려준다. 원인을 안 알려주면 개발자가 임계를 올려버린다.
CS 원리
크기 회귀는 점진적 열화(gradual degradation)의 전형이다. 한 번에 크게 늘지 않고 릴리스마다 조금씩 늘어난다. 이런 문제의 성질이 셋 있다.
- 개별 변경은 정당해 보인다. 기능 추가에 200KB가 늘었다면 누구도 반대하지 않는다. 그런데 50번 반복되면 10MB다.
- 책임이 분산된다. 누구의 잘못도 아니므로 아무도 고치지 않는다.
- 임계에 도달했을 때는 이미 늦다. 되돌리려면 수십 개의 결정을 재검토해야 한다.
이런 문제에 효과적인 대응이 예산제(budget)다. 총량을 정해두고 넘으면 무언가를 빼야 하게 만든다. 그러면 각 추가 결정이 기회비용을 갖게 되어, "이 기능이 저 기능보다 3MB 가치가 있나"라는 질문이 자연스럽게 나온다.
다만 예산제가 작동하려면 조건이 있다.
- 측정이 자동이고 신뢰할 수 있어야 한다. 수동 측정은 반드시 빠진다.
- 피드백이 빨라야 한다. PR 단계에서 알려줘야 그 자리에서 조정한다. 릴리스 후에 알면 되돌리기 어렵다.
- 원인을 알려줘야 한다. "5MB 늘었다"만 알려주면 조사 비용 때문에 무시하게 된다.
3번이 특히 중요하다. 원인 없는 경고는 결국 임계를 올리는 것으로 귀결된다.
iOS에서는
CI 파이프라인 구성을 단계별로 정리하면 이렇다.
#!/bin/bash
set -euo pipefail
ARCHIVE="build/MyApp.xcarchive"
EXPORT="build/export"
BASELINE="ci/size-baseline.json"
# ── 1. 아카이브 + 시닝 export (Q1) ──
xcodebuild archive -scheme MyApp -configuration Release \
-archivePath "$ARCHIVE" -destination 'generic/platform=iOS'
xcodebuild -exportArchive -archivePath "$ARCHIVE" \
-exportPath "$EXPORT" -exportOptionsPlist ci/ExportOptions.plist
# ExportOptions.plist 에 thinning = <thin-for-all-variants> 필수
# ── 2. 리포트 파싱 — 가장 큰 변형을 기준으로 삼는다 ──
REPORT="$EXPORT/App Thinning Size Report.txt"
python3 ci/parse_size_report.py "$REPORT" > build/size-current.json
cat build/size-current.json
# {"max_download_mb": 24.3, "max_install_mb": 61.8, "variant": "iPhone14,2"}
# ── 3. 기준선과 비교 ──
python3 ci/compare_size.py "$BASELINE" build/size-current.json
# 실패 시 exit 1 — 빌드가 멈춘다
비교 스크립트가 원인까지 알려주는 것이 핵심이다.
# ci/compare_size.py
import json, sys
baseline = json.load(open(sys.argv[1]))
current = json.load(open(sys.argv[2]))
THRESHOLD_PCT = 3.0 # 이전 릴리스 대비 3% 이상 증가하면 실패
THRESHOLD_MB = 1.0 # 또는 절대 1MB 이상 (둘 중 하나만 넘어도 실패)
failed = False
for key, label in [("max_download_mb", "다운로드"), ("max_install_mb", "설치")]:
before, after = baseline[key], current[key]
delta = after - before
pct = delta / before * 100 if before else 0
mark = " "
# 절대값과 비율 중 하나만 넘어도 실패시킨다.
# AND 로 두면 앱이 커질수록 같은 MB 증가가 낮은 %가 되어 큰 증가를 놓친다.
if delta > THRESHOLD_MB or pct > THRESHOLD_PCT:
mark, failed = "❌", True
elif delta > 0:
mark = "⚠️ "
print(f"{mark} {label}: {before:.1f} → {after:.1f} MB "
f"({delta:+.2f} MB, {pct:+.1f}%)")
if failed:
print("\n크기 회귀 감지. 원인 분석:")
# 번들 구성 diff 를 함께 출력한다 — 이게 없으면 개발자가 임계만 올린다
print(open("build/size-diff.txt").read())
sys.exit(1)
원인 분석은 번들 구성과 링크 맵을 diff해서 만든다.
# ci/size-diff.sh — 무엇이 늘었는지 항목별로 비교한다
inventory() {
local app="$1"
# 파일별 크기 목록 (경로를 앱 번들 기준 상대 경로로)
find "$app" -type f -exec sh -c 'echo "$(stat -f%z "$1") ${1#*.app/}" ' _ {} \; \
| sort -k2
}
inventory "build/baseline/MyApp.app" > /tmp/base.txt
inventory "build/export/Payload/MyApp.app" > /tmp/curr.txt
join -j2 -a1 -a2 -e 0 -o 0,1.1,2.1 /tmp/base.txt /tmp/curr.txt \
| awk '{ d = $3 - $2; if (d != 0) printf "%+10.1f KB %s\n", d/1024, $1 }' \
| sort -rn | head -25 > build/size-diff.txt
cat build/size-diff.txt
# + 2841.3 KB Frameworks/NewAnalyticsSDK.framework/NewAnalyticsSDK
# + 412.0 KB Assets.car
# + 88.5 KB MyApp
# → "새 SDK가 2.8MB" 라는 결론이 즉시 나온다
이 출력이 있으면 PR 리뷰에서 바로 논의가 된다 — "이 SDK가 2.8MB인데 정말 필요한가?"
실험 · 도구
파이프라인이 실제로 동작하는지 일부러 깨뜨려 확인한다. 21장 Q6의 게이트 훈련과 같은 발상이다.
# 훈련 1: 큰 파일을 일부러 추가하고 CI를 돌린다
dd if=/dev/urandom of=Resources/dummy.bin bs=1m count=5
# → CI가 "❌ 다운로드: 24.3 → 29.1 MB (+4.80 MB, +19.8%)" 로 실패해야 정상
# → size-diff.txt 에 "dummy.bin" 이 나와야 정상
# 훈련 2: 기준선 파일을 지우고 돌린다
mv ci/size-baseline.json /tmp/
# → 기준선이 없을 때 "실패"가 아니라 "기준선 생성"으로 처리되는지 확인
# (첫 실행에서 항상 실패하면 아무도 안 쓰게 된다)
# 훈련 3: thinning 키를 빼고 export 한다
# → 리포트가 안 생기고, 그것이 명확한 에러로 드러나야 한다
기준선 갱신 정책도 정해야 한다.
# 기준선은 "릴리스된 버전"으로만 갱신한다
# - main 브랜치의 모든 커밋으로 갱신하면 회귀가 조금씩 누적되어도 안 걸린다
# (매번 직전 커밋 대비 비교라 3% 미만 증가가 무한히 쌓인다)
# - 릴리스 태그가 붙을 때만 갱신하면 "이번 릴리스 전체 증가분"이 검사된다
# 릴리스 시점에 기준선 갱신
if [ -n "${RELEASE_TAG:-}" ]; then
cp build/size-current.json ci/size-baseline.json
git add ci/size-baseline.json
git commit -m "chore: 크기 기준선 갱신 ($RELEASE_TAG)"
fi
이 기준선 갱신 주기가 설계에서 가장 중요한 결정이다. 매 커밋 갱신은 점진적 열화를 놓치고, 영원히 고정하면 정당한 기능 추가도 막힌다. 릴리스 단위가 적절한 균형이다.
프로젝트 적용
도입 순서를 효과 순으로 정리하면 이렇다.
- 측정과 기록부터. 실패시키지 않고 기록만 시작한다. 몇 릴리스치 데이터가 쌓이면 정상 증가 폭을 알 수 있고, 그때 임계를 정한다. 처음부터 임계를 추측해 걸면 오탐이 잦아 무시된다.
- PR 코멘트로 노출. 실패시키기 전에 PR에 크기 변화를 코멘트하는 단계를 둔다. 마찰 없이 인식을 만든다.
- 원인 diff 추가. 이게 있어야 논의가 된다.
- 임계 초과 시 실패. 데이터가 충분히 쌓인 뒤에 켠다.
- 정기 검토. 분기마다 크기 추세를 보고 정리 작업을 계획한다. 회귀 방지만으로는 이미 쌓인 것이 안 줄어든다.
그리고 익스텐션 크기도 함께 본다(24장). 익스텐션이 늘면 앱 전체가 늘고, 익스텐션은 자체 자원 예산도 빡빡하다.
"크기는 릴리스 전에 한 번 재면 된다"가 문제의 근원이다. 점진적 증가는 한 번의 측정으로 안 보인다 — 추세를 봐야 한다. "절대 상한을 정하면 된다"도 실용적이지 않다 — 기능이 늘면 크기도 느는 것이 정상이라 증가 폭으로 관리하는 편이 낫다. "매 커밋 기준선을 갱신하면 정확하다"는 정반대다 — 조금씩 늘어나는 것을 전부 통과시킨다. "크기가 늘었다고 알려주면 개발자가 고칠 것"도 순진하다 — 원인을 안 알려주면 임계를 올린다. 또 .ipa 크기로 비교하면 dSYM 변동 때문에 노이즈가 커서 신뢰할 수 없다(Q1).
몸무게 관리와 똑같다.
매일 조금씩 는다. 하루에 20g씩 늘면 아무도 못 느낀다. 그런데 1년이면 7kg다. 그리고 어느 날 옷이 안 맞는다.
이걸 막으려면 체중계를 놓고 매일 재는 것이 먼저다. 재지 않으면 모른다.
그런데 재기만 하면 부족하다. "어제보다 20g 늘었네"는 아무 의미가 없다 — 매일 그 정도는 변한다. "한 달 전보다 1kg 늘었네"가 신호다. 그래서 비교 기준을 어제가 아니라 한 달 전으로 잡아야 한다.
여기서 가장 중요한 게 하나 더 있다. "뭘 먹어서 늘었는지"를 알려줘야 한다. 그냥 "늘었어요"라고만 하면 사람들은 체중계를 치워버린다. "어제 야식 때문"이라고 알려주면 그때부터 행동이 바뀐다.
꼬리 질문
PR 단계에서 크기를 재려면 매번 아카이브해야 하는데 너무 느리지 않은가?
느리다. 그래서 단계를 나누는 것이 실용적이다.
(1) PR — 가벼운 근사 측정. 전체 아카이브 대신 빌드된 앱 번들의 크기나 링크 맵 기반 바이너리 크기만 비교한다. 정확하지는 않지만 큰 변화는 잡힌다. 새 SDK 추가나 큰 자산 추가는 여기서 걸린다.
(2) main 병합 후 — 정확한 측정. 아카이브 + 시닝 export로 정확한 값을 낸다. 야간 빌드로 돌리면 PR 속도에 영향이 없다.
(3) 릴리스 — 최종 확인과 기준선 갱신.
(1)에서 쓸 만한 근사 지표가 몇 가지 있다.
# 아카이브 없이 빠르게 비교할 수 있는 값들
du -sk "$BUILT_APP" # 앱 번들 총 크기 (dSYM 제외)
stat -f%z "$BUILT_APP/MyApp" # 실행 바이너리 크기
du -sk "$BUILT_APP/Frameworks" 2>/dev/null # 프레임워크 총 크기
du -sk "$BUILT_APP/Assets.car" 2>/dev/null # 에셋 카탈로그 크기이 값들은 시닝·압축을 안 거쳐 절대값은 틀리지만, 변화 방향과 대략적 규모는 정확하다. PR 게이트로는 충분하다.
그리고 캐싱이 도움이 된다. 의존성 빌드 산출물을 캐시하면 아카이브 시간이 크게 준다. 다만 캐시가 오염되면 잘못된 측정이 나오므로, 정확한 측정은 클린 빌드로 하는 것이 안전하다.
크기 예산을 팀이 실제로 지키게 만들려면?
기술적 장치만으로는 안 되고 운영 방식이 필요하다. 효과적인 것 넷을 꼽으면 이렇다.
(1) 예산을 제품 지표와 연결한다. "10MB는 안 된다"보다 "다운로드 크기가 X MB를 넘으면 모바일 설치가 막혀 신규 설치의 Y%를 잃는다"가 설득력이 있다. Apple이 한도보다 충분히 낮게 유지해 설치 가능 사용자 기반을 최대화하라고 하는 근거를 팀이 공유해야 한다.
(2) 예산을 팀·기능별로 나눈다. 전체 예산 하나면 "내 탓이 아니다"가 되기 쉽다. 기능 영역별로 배분하면 각 팀이 자기 몫을 관리한다.
(3) 정리를 정기 작업으로 만든다. 회귀 방지만으로는 이미 쌓인 것이 안 준다. 분기마다 "크기 정리 주간"처럼 시간을 배정하면 미사용 자산·죽은 의존성이 실제로 제거된다.
(4) 성공 사례를 공유한다. "이 SDK를 빼서 3MB 줄였고 설치 전환율이 올랐다"는 사례가 하나 있으면, 다음부터 논의가 훨씬 쉬워진다. 23장 Q6에서 본 "제거의 성공 사례 공유"와 같은 원리다.
반대로 실패하는 방식도 명확하다 — 임계만 걸어두고 원인 분석을 안 주면, 개발자는 임계를 올리는 PR을 올린다. 그게 가장 빠른 해결이기 때문이다. 원인을 즉시 보여주는 것이 이 실패를 막는 유일한 방법이다.
크기 최적화가 다른 지표를 나쁘게 만드는 경우는?
몇 가지 트레이드오프가 있다. 크기만 보고 최적화하면 다른 곳이 나빠질 수 있다.
(1) 최적화 레벨. Swift -Osize는 크기를 줄이지만 특수화·인라이닝을 억제해 성능을 일부 포기한다. 계산 집약적 코드에서는 반드시 측정하고 바꿔야 한다. Release 기본값이 -O(속도)라는 점은 Apple이 대부분의 앱에서는 속도 쪽 절충이 낫다고 본다는 뜻이다.
(2) 온디맨드 리소스. 초기 다운로드는 줄지만 첫 사용 시 대기가 생긴다(Q1 꼬리질문). 사용자 경험이 나빠질 수 있다.
(3) 이미지 압축 강화. 크기는 줄지만 화질이 떨어지거나 디코딩 비용이 는다. 압축률이 높은 포맷이 디코딩은 더 무거운 경우가 있고, 그건 25장 Q3의 Prepare 단계 비용이 된다.
(4) 동적 → 정적 전환. 크기는 줄지만 익스텐션이 있으면 코드가 복제되어 총합이 늘 수 있다(Q3). 또 빌드 시간이 길어질 수 있다.
(5) 자산을 서버에서 받기. 앱 크기는 확실히 줄지만 네트워크 의존이 생기고 오프라인에서 못 쓰며, 서버 비용과 관측 대상이 는다.
그래서 크기 최적화도 측정 기반이어야 한다. 바꾸기 전후로 크기와 함께 콜드 스타트·히치율·성공률을 비교하면 부작용이 드러난다. 18~25장에서 만든 관측 파이프라인이 여기서 쓰인다 — 어떤 최적화든 그것이 실제로 개선인지 확인할 수단이 있어야 한다.
Q5. P1을 마치며 — 무엇을 얻었는가?
P0가 "이 개념은 무엇인가"였다면 P1은 "내 손을 떠난 앱을 어떻게 관측하고 판독하는가"였다. 관통하는 원리가 셋 있다. (1) 관측은 공짜가 아니다 — 계측·수집·전송 모두 사용자의 배터리와 요금을 쓰고, 때로는 관측 도구가 관측 대상을 파괴한다(19장 Q6). (2) 측정 없이 최적화하지 않는다 — 개발 기기의 직관은 실사용자 분포와 다르고(22장 Q6), 도구마다 보이는 것이 다르다(20장 Q5, 25장 Q6). (3) 조용한 실패를 없앤다 — dSYM 누락(21장), privacy manifest 누락(23장), 크기 회귀(26장)는 전부 빌드는 성공하는데 나중에 터지는 같은 구조이고, 해법도 같다 — 검증을 명시적으로 넣고 실패시킨다.
CS 원리
P1의 아홉 챕터를 관통하는 구조를 정리하면 이렇다.
| 원리 | P0에서 | P1에서 |
|---|---|---|
| 격리 | 프로세스·샌드박스 (03장) | 익스텐션 경계, 렌더 서버 분리 (24·25장) |
| 프록시 측정 | — | 런루프 busy 구간으로 행 추정 (20장) |
| 샘플링 | — | 트레이스 단위 샘플링, 프로파일러 (22장) |
| 동시성 | 스레드·액터 (02장) | 프로세스 간 공유 컨테이너 (24장) |
| 가변값 분리 | — | 그룹핑·breadcrumb·프라이버시가 같은 해법 (18·23장) |
| 관심사 분리 | 결합·응집 (17장) | 격리 전염 회피, app-extension-safe (20·24장) |
다섯 번째 행이 P1에서 가장 반복적으로 나타난 패턴이다. 에러 메시지에서 가변값을 빼면 그룹핑이 복구되고(18장 Q6), breadcrumb이 접히고(18장 Q4), 프라이버시가 개선되고(23장 Q1), span 이름이 안정된다(22장 Q1). 하나의 설계 원칙이 네 가지 문제를 동시에 해결한다.
여섯 번째 행도 중요하다. P0 17장의 관심사 분리가 P1에서 성능과 배포 제약의 해법으로 되돌아온다 — UI 타입에서 로직을 분리하면 격리 전염이 사라지고(20장 Q6), 프레임워크에서 UIKit 의존을 줄이면 app-extension-safe 문제가 자동으로 풀린다(24장 Q6). 좋은 설계가 플랫폼 제약을 자연스럽게 만족시킨다는 것이 P1이 준 실증이다.
iOS에서는
실무에서 바로 점검할 수 있는 체크리스트로 정리하면 이렇다.
관측 파이프라인 (18~22장)
- 크래시·행·히치 지표를 각각 추적하고 있는가? 셋은 다른 문제다.
- 자체 리포터 숫자와 MetricKit 숫자를 대조하고 있는가? (19장 Q7)
- 인앱 프레임 보유율을 감시하고 있는가? (21장 Q3)
- 샘플링 비율을 필요 표본 수에서 역산했는가? (22장 Q4)
- 계측 오버헤드를 실측했는가? (22장 Q7)
플랫폼 준수 (23~24장)
- 모든 바이너리에 privacy manifest가 있는가? (23장 Q2)
- Required Reason API 사유가 실제 용도와 일치하는가? (23장 Q3)
- ATT 거부 상태로 전체 플로우를 테스트했는가? (23장 Q4)
- 공유 컨테이너 접근이 동기화돼 있는가? (24장 Q3)
- 익스텐션 크래시가 수집되고 있는가? (24장 Q1)
성능과 배포 (20·25·26장)
- commit hitch와 render hitch를 구분해 보고 있는가? (25장 Q6)
- 가장 느린 지원 기기에서 검증하는가? (20장 Q7)
- App Size Report가 CI에 있는가? (26장 Q4)
- SDK 도입 기준 네 가지를 확인하는가? — dSYM·manifest·extension-safe·크기
실험 · 도구
P1에서 다룬 도구를 어떤 질문에 쓰는지로 정리하면 선택이 쉬워진다.
| 질문 | 도구 | 챕터 |
|---|---|---|
| 앱이 왜 죽었나 | Exception Type → Termination Reason | 19장 |
| 주소가 무슨 함수인가 | atos, xcrun crashlog, dwarfdump --uuid | 21장 |
| 메인스레드가 얼마나 막혔나 | Instruments Hangs, 런루프 옵저버 | 20장 |
| 어디서 시간을 쓰나 | Time Profiler (바쁜 것), Hangs (기다리는 것) | 20·22장 |
| commit인가 render인가 | Animation Hitches | 25장 |
| 왜 GPU가 바쁜가 | Color Offscreen-Rendered 등 시각 진단 | 25장 |
| 무엇이 전송되나 | 프록시 캡처, beforeSend 훅 | 23장 |
| 무엇이 앱을 키우나 | App Size Report, 링크 맵 | 26장 |
| 실사용자에게 실제로 어떤가 | MetricKit, Xcode Organizer | 19·20장 |
마지막 행이 P1의 결론에 가깝다. 모든 로컬 측정은 표본 하나이고, 실사용자 분포는 다르다. 그래서 프로덕션 지표로 확인하기 전까지 개선은 가설이다.
프로젝트 적용
P1을 실제 프로젝트에 도입한다면 순서가 중요하다. 효과와 비용을 함께 고려한 순서는 이렇다.
- 아카이브 장기 보관(21장 Q6) — 이것만 있으면 나머지는 나중에 복구 가능하다. 비용이 거의 0이고 없으면 되돌릴 수 없다.
- 심볼화율 감시(21장 Q3) — 크래시 데이터의 신뢰성 자체가 여기 달렸다.
- MetricKit 수집(19장 Q7) — OS가 집계한 기준선을 확보한다. 자체 계측을 검증할 잣대가 생긴다.
- 행률·히치율 감시(20장) — 크래시율이 좋은데 평점이 나쁜 원인의 대부분이 여기다.
- privacy manifest 정비(23장) — 심사 차단 요인이므로 우선순위가 높다.
- 크기 측정 자동화(26장) — 기록만 먼저 시작한다.
- 트레이싱·프로파일링(22장) — 앞의 것들이 자리 잡은 뒤에 도입한다. 비용이 가장 크다.
순서의 근거는 "없으면 되돌릴 수 없는 것"부터다. 아카이브와 dSYM은 시점을 놓치면 영영 복구되지 않지만, 트레이싱은 언제 도입해도 그때부터 데이터가 쌓인다.
"관측 도구를 많이 붙일수록 안전하다"가 P1 전체를 관통하는 오해다. 도구는 초기화 시간·메모리·배터리·바이너리 크기를 쓰고, 때로는 관측 대상을 망가뜨린다. "데이터가 많으면 언젠가 쓸모 있다"도 틀렸다 — 안 보는 데이터는 가치가 0인데 비용은 계속 든다. "지표가 좋으면 사용자도 만족한다"도 성급하다 — 기술 지표가 전부 초록불인데 논리적 대기로 체감이 나쁠 수 있다(20장 Q3 꼬리질문). 그리고 가장 중요한 것 — "이 수치는 널리 알려져 있으니 맞다"는 태도다. P1에서 반복적으로 확인했듯 워치독 시간 허용치, 익스텐션 메모리 한도, 히치율 등급은 출처 등급이 제각각이다. 출처를 확인하는 습관이 이 챕터들이 남기는 가장 실용적인 결과일 수 있다.
P0가 자동차가 어떻게 움직이는지를 배운 것이라면, P1은 내가 판 자동차가 도로에서 어떻게 굴러가는지 알아내는 법을 배운 것이다.
중요한 차이가 있다. 정비소에서는 차를 들어올려 직접 볼 수 있다. 그런데 손님 차는 전국 어딘가에서 굴러다니고, 무슨 일이 있었는지는 손님이 가져다주는 기록으로만 안다.
그래서 배운 것이 셋이다.
첫째 — 기록 장치를 다는 것도 공짜가 아니다. 블랙박스가 배터리를 먹고, 심하면 블랙박스 때문에 시동이 안 걸린다. 그래서 "많이 달수록 좋다"가 아니라 "무엇을 알아야 하는가"부터 정해야 한다.
둘째 — 정비소에서 멀쩡하다고 도로에서도 멀쩡한 게 아니다. 우리 정비소는 평지에 포장도로다. 손님은 오래된 차로 언덕길을 다닌다. 그래서 제일 낡은 차, 제일 나쁜 길에서 확인해야 한다.
셋째 — 조용히 고장 나는 것이 제일 무섭다. 블랙박스 메모리카드가 빠져 있어도 차는 잘 굴러간다. 사고가 나서 영상을 보려는 순간에야 안다. 그래서 "카드가 꽂혀 있는지 매번 확인하는 절차"를 일부러 만들어야 한다.
이 세 가지가 P1 전체를 관통한다. 그리고 마지막으로 하나 더 — "다들 그렇게 안다"는 숫자를 그대로 믿지 않는 것. 확인해보면 출처가 없는 경우가 의외로 많다.
꼬리 질문
P1의 내용 중 팀에 가장 먼저 도입할 것 하나를 고른다면?
아카이브와 dSYM 보관이다. 이유가 셋 있다.
(1) 비가역적이다. 다른 것들은 나중에 시작해도 그때부터 데이터가 쌓이지만, 지나간 릴리스의 dSYM은 되살릴 수 없다(21장 Q2). 재현 빌드에 기대는 것은 실무적으로 신뢰하기 어렵다.
(2) 다른 모든 것의 전제다. 크래시 조사, 행 리포트 판독, 성능 분석이 전부 심볼화된 스택을 전제한다. 이게 없으면 다른 도구를 아무리 붙여도 주소 목록만 쌓인다.
(3) 비용이 거의 0이다. CI에 아티팩트 보관 스텝 하나면 되고, 저장 비용도 미미하다(21장 Q2 꼬리질문).
두 번째를 꼽으라면 MetricKit 수집이다. OS가 집계한 값이라 우리 계측을 검증할 기준선이 되고, 자체 리포터가 못 보는 SIGKILL 계열 종료까지 잡아준다(19장 Q7). 그리고 구현 비용이 낮다 — 구독 코드 몇 줄이면 되고, Apple이 런치 경로에서 호출해도 안전하다고 명시한다.
반대로 가장 나중에 도입해도 되는 것은 분산 트레이싱이다(22장). 가치는 크지만 서버 팀과의 합의, 샘플링 설계, 오버헤드 검증이 모두 필요해 선행 조건이 많다.
P1에서 배운 것 중 P0로 되돌아가 다시 보게 되는 부분은?
여러 곳이 있는데, 특히 다섯 지점이 P1을 거치고 나면 다르게 읽힌다.
(1) P0 01장 Jetsam·메모리 압박. 개념으로 배웠던 per-process 한도가 EXC_RESOURCE (MEMORY) 경고(19장 Q5)와 익스텐션의 훨씬 낮은 예산(24장 Q7)으로 구체화된다. 그리고 phys_footprint로 재야 한다는 실무 지식이 붙는다.
(2) P0 02장 Task는 스레드가 아니다. 이게 비동기 스택 절단으로 크래시 그룹핑을 흔들고(18장 Q6), 격리 전염으로 행을 만든다(20장 Q6). 개념이 관측 가능한 현상으로 이어진다.
(3) P0 07장 메인 블로킹과 런루프. P1에서 250ms·1초라는 구체적 임계와 busy 구간이라는 측정 방법을 얻는다(20장 Q3). "블로킹하면 안 된다"가 "얼마나, 어떻게 재는가"로 정밀해진다.
(4) P0 08장 프레임 파이프라인. 렌더 서버가 별도 프로세스라는 사실(25장 Q1)과 단계별 예산(25장 Q2)을 알고 나면, "60fps를 지켜라"가 "어느 단계의 예산을 지켜라"로 바뀐다.
(5) P0 17장 결합·응집. 이게 P1에서 성능과 배포 제약의 해법으로 되돌아온다 — 관심사를 분리하면 격리 전염이 사라지고(20장 Q6), app-extension-safe가 자동으로 만족되고(24장 Q6), 테스트도 쉬워진다. 설계 원칙이 실무 제약을 푸는 열쇠였다는 것이 P1의 가장 큰 발견일 수 있다.
P0를 다시 읽을 때 "이 개념이 프로덕션에서 어떤 신호로 나타나는가"를 물으면 훨씬 깊이 읽힌다.
P2에서 다룰 만한 주제는 무엇이 남았는가?
P1이 "관측하고 판독한다"였다면, P2는 "설계로 예방하고 특수 상황을 분석한다" 쪽으로 갈 수 있다. 남은 영역을 꼽으면 이렇다.
(1) 아키텍처 결정의 정량화. P0 17장이 원칙이었고 P1이 그 원칙의 실무적 이득을 보여줬다면, P2는 모듈 경계를 어디에 그을지, 의존 규칙을 어떻게 강제할지를 다룰 수 있다. 빌드 시간·팀 분업·변경 비용을 지표로 다루는 영역이다.
(2) 특수 장애 분석. P1에서 다루지 못한 것들이 있다 — 힙 손상 디버깅, 데이터 경쟁의 사후 분석, 재현되지 않는 크래시의 통계적 접근, 커널 로그 판독. 19장 Q6에서 리포터가 실패하는 경우를 봤는데, 그런 상황의 조사 기법이 여기 해당한다.
(3) 대규모 데이터·동기화. P0 09·16장이 기초였다면, 오프라인 우선 설계, 충돌 해결, 증분 동기화, 마이그레이션 전략은 훨씬 깊다.
(4) 보안 심화. P0 11장 이후로 앱 무결성 검증, 역공학 대응, 키 관리, 인증서 피닝의 트레이드오프 같은 영역이 남는다.
(5) 플랫폼 진화 대응. Swift 동시성 마이그레이션, ABI 안정성, 다중 플랫폼 대응처럼 시간 축의 문제들이다.
다만 P2를 시작하기 전에 P1을 실제로 적용해보는 것이 먼저다. 관측 파이프라인이 없으면 P2의 주제 대부분이 검증할 수 없는 이론으로 남는다 — 아키텍처 결정이 옳았는지, 장애 분석이 맞았는지 확인할 수단이 관측이기 때문이다.