← iOS 개발자 CS 로드맵 26 / 35

26 · 앱 크기와 배포

다운로드 크기와 설치 크기의 차이, 앱 시닝과 App Size Report, 크기를 지배하는 요인, 동적 프레임워크의 이중 비용, CI에서 크기 회귀를 막는 법.
진행률
0 / 0 완료

P1의 마지막 챕터다. P0 14장이 "컴파일·링크·dyld 로드"의 원리였다면, 여기서는 그 산출물이 사용자에게 전달되는 과정을 다룬다 — 얼마나 크고, 왜 그만큼이며, 어떻게 재고, 어떻게 늘어나는 것을 막는가. 앱 크기는 설치 이탈률에 직접 연결되는 제품 지표이면서, 동시에 실행 시간과도 얽혀 있다(18장 Q7의 콜드 스타트). 그래서 성능 작업의 마지막 축이기도 하다.

Q1. 다운로드 크기와 설치 크기는 왜 다른가?

🔑 30초 답변

다운로드 크기는 압축된 전송량이고 설치 크기는 기기에서 차지하는 실제 용량이다. Apple의 App Size Report 표현으로는 compressed = 다운로드 크기, uncompressed = 설치된 앱의 크기다. 여기에 앱 시닝이 겹친다 — 사용자는 자기 기기에 맞는 변형(variant) 하나만 받으므로, 우리가 업로드한 전체보다 훨씬 작다. 그리고 결정적으로 우리가 만드는 .app·.xcarchive·업로드용 .ipa는 크기 측정에 쓸 수 없다 — dSYM처럼 사용자가 받지 않는 것이 들어 있기 때문이다. 정확한 값을 얻으려면 App Size Report를 만들거나 App Store Connect를 봐야 한다.

CS 원리

같은 앱에 여러 크기가 존재하는 이유는 세 단계의 변환을 거치기 때문이다.

  1. 빌드 산출물 — 모든 아키텍처, 모든 해상도 자산, 디버그 심볼을 포함한 최대 크기.
  2. 시닝(thinning) — 대상 기기에 필요한 것만 남긴다. 아키텍처 하나, 해당 해상도 자산만.
  3. 압축 — 전송을 위해 압축한다.

그래서 나오는 숫자가 다르다.

숫자무엇사용자가 체감하는 것
빌드 산출물 크기모든 변형 + 디버그 정보아무것도 — 측정에 쓰면 안 된다
다운로드 크기 (compressed)시닝 + 압축된 전송량대기 시간, 데이터 요금
설치 크기 (uncompressed)기기에서 차지하는 용량저장 공간 압박

두 숫자가 서로 다른 문제를 나타낸다는 점이 중요하다. 다운로드 크기는 설치 시점의 장벽이고, 설치 크기는 계속 남는 부담이다. 저장 공간이 부족한 사용자는 큰 앱부터 지운다.

그리고 압축률은 내용에 따라 크게 다르다. 이미 압축된 데이터(JPEG·동영상)는 더 안 줄지만, 실행 바이너리와 텍스트는 잘 줄어든다. 그래서 다운로드 크기를 줄이는 최적화와 설치 크기를 줄이는 최적화가 다를 수 있다.

같은 앱인데 숫자가 셋 나오는 이유 — 두 번의 변환 빌드 산출물 arm64 + arm64e @1x @2x @3x 전부 dSYM ← 사용자는 안 받음 시닝 이 기기용 변형 하나 아키텍처 1개 · 해당 해상도만 = 설치 크기 18.6 MB 압축 전송량 = 다운로드 6.7 MB .ipa / .app / .xcarchive ❌ 측정에 쓰면 안 된다 사용자 체감: 저장 공간 압박 → 삭제 사용자 체감: 대기 시간 · 데이터 요금 App Size Report 의 compressed = 다운로드, uncompressed = 설치 이 예시에서 2.8배 차이. 그리고 사용자마다 받는 변형이 다르다. TestFlight 빌드는 더 크고, App Store 최종본은 DRM·재압축으로 조금 커질 수 있다.
두 번의 변환이 세 숫자를 만든다. 빌드 산출물은 어느 사용자도 받지 않으므로 측정 기준이 될 수 없다.

iOS에서는

Apple 문서가 명확히 경고하는 것부터 짚어야 한다 — 디버깅용으로 만들거나 Xcode에서 App Store Connect에 업로드하는 바이너리는 크기 측정에 적합하지 않다. 구체적으로 다음을 쓰면 안 된다.

이유는 사용자가 받는 번들에 없는 것들이 들어 있기 때문이다 — 대표적으로 크래시 리포팅용 dSYM(21장)이다. dSYM은 수십~수백 MB인데 사용자는 받지 않는다. 그래서 .ipa 파일 크기를 앱 크기로 보고하면 크게 부풀려진 값이 된다.

정확한 값을 얻는 경로는 둘이다.

그리고 실무에서 자주 혼동되는 두 가지 비대칭이 있다.

(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>&lt;thin-for-all-variants&gt;</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 크기가 앱 크기"가 가장 흔한 오해다. 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. 앱 크기는 무엇이 지배하는가?

🔑 30초 답변

대개 실행 바이너리자산 둘이고, 어느 쪽이 지배적인지는 앱마다 다르다. 그래서 추측하지 말고 IPA를 뜯어봐야 한다 — Apple이 안내하는 방법이 정확히 그것이다. thinned IPA의 확장자를 ZIP으로 바꿔 풀고 번들 내용을 직접 보는 것이다. 그리고 기본 최적화 두 가지를 먼저 확인해야 한다. (1) 최적화 레벨이 의도대로인가 — C/ObjC의 Release 기본값은 Fastest, Smallest [-Os]이고, Swift의 Release 기본값은 -O(속도)다. 실수로 -Onone이 되어 있지 않은지 확인하고, 크기가 문제라면 Swift는 -Osize검토한다(기본값이 아니다). (2) 에셋 카탈로그를 쓰는가 — 안 쓰면 시닝이 작동하지 않아 모든 사용자가 모든 해상도 자산을 받는다.

CS 원리

바이너리 크기가 커지는 메커니즘을 알면 대응이 명확해진다.

두 번째와 세 번째가 최적화 레벨과 직결된다. Swift의 -O는 성능을 위해 특수화와 인라이닝을 적극적으로 하고, -Osize코드 크기가 크게 늘어나는 경우를 억제한다. 그래서 같은 코드가 설정에 따라 눈에 띄게 다른 크기가 된다. 주의 — Swift에는 -Os가 없다(unknown argument 에러). Xcode의 Fastest, Smallest [-Os]C/ObjC용 GCC_OPTIMIZATION_LEVEL 설정이고, Swift는 별도로 SWIFT_OPTIMIZATION_LEVEL을 쓴다.

자산 쪽 원리는 다르다. 여기서는 중복이 핵심 문제다.

Apple이 에셋 카탈로그를 강조하는 이유가 여기 있다 — 카탈로그에 넣고 메타데이터를 태그해야 시스템이 골라낼 수 있다. 번들에 파일로 넣으면 시스템은 그게 뭔지 모르므로 전부 전달한다.

같은 이미지 3벌 — 카탈로그에 넣었느냐가 전송량을 가른다 번들에 직접 icon@1x icon@2x icon@3x 시닝 시스템이 뭔지 모른다 → 3벌 전부 전달 에셋 카탈로그 icon (1x·2x·3x + 기기 태그) 메타데이터로 "누구용"인지 표시 시닝 @3x 기기 3x 만 받음 @2x 기기 2x 만 받음 시닝은 "시스템이 무엇인지 아는 자산"에만 작동한다 번들에 그냥 넣으면 대부분의 사용자가 쓰지도 않을 2/3를 받는다. 그래서 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단계 — 에셋 카탈로그 채택. 문서가 권하는 것 셋이다.

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를 뜯어봐야 안다. "이미지를 그냥 번들에 넣어도 시닝이 된다"도 틀렸다 — 에셋 카탈로그에 넣어야 시스템이 골라낼 수 있다. "릴리스 빌드니 최적화는 당연히 켜져 있다"도 확인이 필요하다 — 설정이 의도치 않게 바뀌는 일이 실제로 있다. "제네릭을 많이 쓰면 코드가 줄어든다"도 반대다 — 특수화로 복제되어 늘어날 수 있다. 또 "미사용 코드는 링커가 다 지워준다"도 과신이다 — 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 기반 존재 타입으로 바꿔 코드 공유를 유도할 수 있다. 다만 성능이 나빠질 수 있으므로 크기가 실제로 문제일 때만 한다.

일반 원칙은 "기본값을 믿고, 문제가 측정되면 그때 손댄다"이다. 제네릭을 피해서 코드를 짜는 것은 대개 손해다.

쉽게 말하면 옷 만드는 본(pattern)이 하나 있다. 사이즈별로 미리 재단해두면 입을 때 빠르지만 창고에 자리를 많이 차지한다. 본만 두고 필요할 때 재단하면 자리는 적게 쓰는데 매번 시간이 걸린다. 어느 쪽이 나은지는 사이즈가 몇 종류인지에 달렸다.
링크 맵으로 "어느 라이브러리가 몇 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가 필요하고, 빌드 시간이 조금 는다. 상시 켜기보다 크기 조사할 때만 켜는 편이 낫다.

쉽게 말하면 이삿짐 상자마다 "이건 누구 물건"이라고 적혀 있는 명세서다. 사람별로 무게를 합치면 "김씨 짐이 전체의 40%"가 나온다. 그러면 누구에게 짐을 줄여달라고 할지가 명확해진다.
미사용 코드가 링커에서 제거되지 않는 경우는?

링커는 참조되지 않는 코드를 지울 수 있다(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. 동적 프레임워크는 어떤 비용을 주는가?

🔑 30초 답변

크기와 실행 시간 양쪽에 비용을 준다. 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가 하는 일이 있다.

  1. 각 동적 라이브러리를 찾아서 메모리에 매핑한다.
  2. 의존성을 재귀적으로 해결한다.
  3. 심볼을 바인딩한다 — 각 참조를 실제 주소로 연결.
  4. 초기화 코드(+load, C++ 정적 생성자 등)를 실행한다.

이 비용이 프레임워크 개수에 비례한다. 그래서 작은 프레임워크 20개가 큰 프레임워크 1개보다 느리다 — 총 코드량이 같아도 그렇다.

시스템 프레임워크는 예외다. dyld 공유 캐시에 미리 준비돼 있어 이 비용이 대부분 사라진다. 우리가 만든 프레임워크는 캐시에 없으므로 매번 비용을 낸다.

iOS에서는

실무 선택지와 특성을 정리하면 이렇다.

형태크기시작 시간적합한 경우
정적 라이브러리 (.a)작음영향 없음리소스가 없는 순수 코드
정적 프레임워크작음영향 없음리소스가 필요한 코드 (Xcode 15+ 리소스 번들 지원)
동적 프레임워크개수만큼익스텐션과 공유가 필요할 때 (24장)
XCFramework내부 형태에 따름 (정적/동적 모두 가능)바이너리 배포

XCFramework는 별개 축이라는 점을 짚어야 한다. XCFramework는 여러 플랫폼·아키텍처 변형을 한 배포 단위로 묶는 포맷이지 정적/동적 여부를 정하는 것이 아니다. 안에 정적 라이브러리가 들어갈 수도, 동적 프레임워크가 들어갈 수도 있다.

배포 형태로 XCFramework가 표준이 된 이유는 fat binary의 한계 때문이다. 예전에는 여러 아키텍처를 하나의 바이너리에 합쳤는데, 시뮬레이터용 arm64와 기기용 arm64가 같은 아키텍처 이름이라 구분이 안 되는 문제가 생겼다. XCFramework는 플랫폼별로 디렉터리를 나눠 이를 해결한다.

그리고 24장의 익스텐션 제약이 여기 연결된다. 익스텐션과 코드를 공유하는 정식 수단이 embedded framework이므로, 익스텐션이 있으면 동적 프레임워크가 필요할 수 있다. 이 경우 크기·시작 시간 비용을 감수하는 대신, 익스텐션마다 코드가 복제되는 것을 피한다 — 정적으로 하면 앱과 익스텐션 각각에 코드가 들어가 오히려 더 커질 수 있다.

정적 링크 동적 프레임워크 앱만 있을 때 앱 바이너리 쓰는 코드만 포함 (2 MB) 앱 (1 MB) Framework.framework 전체 포함 + 서명·plist (3 MB) 합계 2 MB · dyld 로드 0회 합계 4 MB · dyld 로드 1회 익스텐션 3개 가 붙으면 앱 +2MB Share +2MB 알림 +2MB 위젯 +2MB 합계 8 MB — 코드가 4번 복제 앱 1MB 익스텐션 ×3 (0.3MB) Framework 한 벌 앱·익스텐션이 공유 (3 MB) 합계 4.9 MB — 역전 "정적이 항상 작다"가 아니다 — 익스텐션 개수가 손익분기를 뒤집는다 그리고 동적은 개수만큼 dyld 로드·심볼 바인딩 비용이 앱 시작에 얹힌다. 작은 프레임워크 20개가 큰 프레임워크 1개보다 느리다 — 총 코드량이 같아도. (숫자는 구조를 보여주는 예시값이다. 실제 손익분기는 앱마다 측정해야 한다.)
익스텐션이 없으면 정적이 유리하지만, 여러 익스텐션이 같은 코드를 쓰면 동적 공유가 총합에서 이긴다.

실험 · 도구

프레임워크 개수가 시작 시간에 미치는 영향은 직접 잴 수 있다.

⚠️ 확인하고 쓸 것

아래 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에서 본 대로, 지연 초기화는 크래시 핸들러 같은
//    제약 있는 컨텍스트에서 예기치 않은 할당을 유발할 수 있다.

프로젝트 적용

선택 기준 다섯.

⚠️ 흔한 오해

"동적 프레임워크는 공유되니 크기가 준다"는 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).

쉽게 말하면 이사 후 정리 시간을 항목별로 적어둔 것이다. "상자 나르기 3시간, 포장 뜯기 1시간, 자리 잡기 2시간" 식이다. 어디가 오래 걸렸는지 알아야 다음에 무엇을 바꿀지 정할 수 있다.
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의 "안 쓰는 계측 제거"와 같은 발상이다.

쉽게 말하면 이삿짐에 물건을 추가할 때 "무게가 얼마인지 적어라"는 규칙만 있어도 사람들이 신중해진다. 그리고 "5kg 넘으면 같이 상의"라고 하면 작은 건 그냥 넣고 큰 것만 따져보게 된다.

Q4. 크기 회귀를 CI에서 어떻게 막는가?

🔑 30초 답변

측정을 자동화하고 회귀를 빌드 실패로 만든다. 21장 Q6에서 본 "조용한 실패를 없앤다"가 여기서도 그대로 적용된다 — 앱 크기는 매 릴리스 조금씩 늘어나고 아무도 알아채지 못하다가 어느 날 한도에 부딪힌다. 구성은 셋이다. (1) 매 빌드 App Size Report 생성(Q1의 xcodebuild -exportArchive + thinning), (2) 기준선 대비 증가폭 검사 — 절대값 상한보다 "이전 릴리스 대비 N% 이상 증가하면 실패"가 실용적이다, (3) 증가 원인 자동 리포트 — 링크 맵과 번들 구성을 diff해 무엇이 늘었는지까지 알려준다. 원인을 안 알려주면 개발자가 임계를 올려버린다.

CS 원리

크기 회귀는 점진적 열화(gradual degradation)의 전형이다. 한 번에 크게 늘지 않고 릴리스마다 조금씩 늘어난다. 이런 문제의 성질이 셋 있다.

이런 문제에 효과적인 대응이 예산제(budget)다. 총량을 정해두고 넘으면 무언가를 빼야 하게 만든다. 그러면 각 추가 결정이 기회비용을 갖게 되어, "이 기능이 저 기능보다 3MB 가치가 있나"라는 질문이 자연스럽게 나온다.

릴리스마다 2%씩 — 개별로는 전부 임계(3%) 아래다 릴리스 MB 20.0 20.4 20.8 21.2 21.6 22.1 22.5 23.0 23.5 9번의 릴리스로 +17.5% — 아무도 못 막았다 기준선을 매 커밋 갱신 비교 대상 = 직전 커밋 (+2%) → 매번 임계 아래 → 전부 통과 기준선을 릴리스마다 갱신 비교 대상 = 지난 릴리스 (+17.5%) → 임계 초과 → 빌드 실패 같은 데이터, 같은 임계인데 비교 기준을 언제로 잡느냐 잡느냐 못 잡느냐를 가른다
점진적 열화는 비교 기준의 주기가 전부다. 직전 커밋과 비교하면 어떤 임계를 걸어도 통과한다.

다만 예산제가 작동하려면 조건이 있다.

  1. 측정이 자동이고 신뢰할 수 있어야 한다. 수동 측정은 반드시 빠진다.
  2. 피드백이 빨라야 한다. PR 단계에서 알려줘야 그 자리에서 조정한다. 릴리스 후에 알면 되돌리기 어렵다.
  3. 원인을 알려줘야 한다. "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

기준선 갱신 주기가 설계에서 가장 중요한 결정이다. 매 커밋 갱신은 점진적 열화를 놓치고, 영원히 고정하면 정당한 기능 추가도 막힌다. 릴리스 단위가 적절한 균형이다.

프로젝트 적용

도입 순서를 효과 순으로 정리하면 이렇다.

  1. 측정과 기록부터. 실패시키지 않고 기록만 시작한다. 몇 릴리스치 데이터가 쌓이면 정상 증가 폭을 알 수 있고, 그때 임계를 정한다. 처음부터 임계를 추측해 걸면 오탐이 잦아 무시된다.
  2. PR 코멘트로 노출. 실패시키기 전에 PR에 크기 변화를 코멘트하는 단계를 둔다. 마찰 없이 인식을 만든다.
  3. 원인 diff 추가. 이게 있어야 논의가 된다.
  4. 임계 초과 시 실패. 데이터가 충분히 쌓인 뒤에 켠다.
  5. 정기 검토. 분기마다 크기 추세를 보고 정리 작업을 계획한다. 회귀 방지만으로는 이미 쌓인 것이 안 줄어든다.

그리고 익스텐션 크기도 함께 본다(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 게이트로는 충분하다.

그리고 캐싱이 도움이 된다. 의존성 빌드 산출물을 캐시하면 아카이브 시간이 크게 준다. 다만 캐시가 오염되면 잘못된 측정이 나오므로, 정확한 측정은 클린 빌드로 하는 것이 안전하다.

쉽게 말하면 매일 정밀 체성분 검사를 할 수는 없다. 대신 매일은 일반 체중계, 한 달에 한 번 정밀 검사로 나눈다. 매일 재는 값이 정확하지 않아도 갑자기 3kg 늘었다는 건 잡아낸다.
크기 예산을 팀이 실제로 지키게 만들려면?

기술적 장치만으로는 안 되고 운영 방식이 필요하다. 효과적인 것 넷을 꼽으면 이렇다.

(1) 예산을 제품 지표와 연결한다. "10MB는 안 된다"보다 "다운로드 크기가 X MB를 넘으면 모바일 설치가 막혀 신규 설치의 Y%를 잃는다"가 설득력이 있다. Apple이 한도보다 충분히 낮게 유지해 설치 가능 사용자 기반을 최대화하라고 하는 근거를 팀이 공유해야 한다.

(2) 예산을 팀·기능별로 나눈다. 전체 예산 하나면 "내 탓이 아니다"가 되기 쉽다. 기능 영역별로 배분하면 각 팀이 자기 몫을 관리한다.

(3) 정리를 정기 작업으로 만든다. 회귀 방지만으로는 이미 쌓인 것이 안 준다. 분기마다 "크기 정리 주간"처럼 시간을 배정하면 미사용 자산·죽은 의존성이 실제로 제거된다.

(4) 성공 사례를 공유한다. "이 SDK를 빼서 3MB 줄였고 설치 전환율이 올랐다"는 사례가 하나 있으면, 다음부터 논의가 훨씬 쉬워진다. 23장 Q6에서 본 "제거의 성공 사례 공유"와 같은 원리다.

반대로 실패하는 방식도 명확하다 — 임계만 걸어두고 원인 분석을 안 주면, 개발자는 임계를 올리는 PR을 올린다. 그게 가장 빠른 해결이기 때문이다. 원인을 즉시 보여주는 것이 이 실패를 막는 유일한 방법이다.

쉽게 말하면 "살 빼세요"라고만 하면 아무도 안 한다. "3kg 빼면 무릎이 안 아파집니다"라고 하면 다르다. 그리고 가족이 각자 자기 몫을 관리하게 하고, 한 달에 하루는 냉장고 정리하는 날로 정하면 실제로 돌아간다.
크기 최적화가 다른 지표를 나쁘게 만드는 경우는?

몇 가지 트레이드오프가 있다. 크기만 보고 최적화하면 다른 곳이 나빠질 수 있다.

(1) 최적화 레벨. Swift -Osize는 크기를 줄이지만 특수화·인라이닝을 억제해 성능을 일부 포기한다. 계산 집약적 코드에서는 반드시 측정하고 바꿔야 한다. Release 기본값이 -O(속도)라는 점은 Apple이 대부분의 앱에서는 속도 쪽 절충이 낫다고 본다는 뜻이다.

(2) 온디맨드 리소스. 초기 다운로드는 줄지만 첫 사용 시 대기가 생긴다(Q1 꼬리질문). 사용자 경험이 나빠질 수 있다.

(3) 이미지 압축 강화. 크기는 줄지만 화질이 떨어지거나 디코딩 비용이 는다. 압축률이 높은 포맷이 디코딩은 더 무거운 경우가 있고, 그건 25장 Q3의 Prepare 단계 비용이 된다.

(4) 동적 → 정적 전환. 크기는 줄지만 익스텐션이 있으면 코드가 복제되어 총합이 늘 수 있다(Q3). 또 빌드 시간이 길어질 수 있다.

(5) 자산을 서버에서 받기. 앱 크기는 확실히 줄지만 네트워크 의존이 생기고 오프라인에서 못 쓰며, 서버 비용과 관측 대상이 는다.

그래서 크기 최적화도 측정 기반이어야 한다. 바꾸기 전후로 크기와 함께 콜드 스타트·히치율·성공률을 비교하면 부작용이 드러난다. 18~25장에서 만든 관측 파이프라인이 여기서 쓰인다 — 어떤 최적화든 그것이 실제로 개선인지 확인할 수단이 있어야 한다.

쉽게 말하면 짐을 줄이려고 겨울옷을 다 버리면 겨울에 춥다. 압축포장을 너무 세게 하면 옷이 구겨진다. 필요한 걸 창고에 맡기면 쓸 때마다 가지러 가야 한다. 줄이는 건 좋은데, 줄인 뒤에 불편해지지 않았는지도 확인해야 한다.

Q5. P1을 마치며 — 무엇을 얻었는가?

🔑 30초 답변

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). 하나의 설계 원칙이 네 가지 문제를 동시에 해결한다.

P1에서 가장 반복적으로 나온 패턴 — 가변값 분리 불변 부분과 가변 부분을 나눈다 "주문 8f3a 결제 실패" → 템플릿 + 파라미터 그룹핑 복구 이슈가 안 쪼개진다 18장 Q6 breadcrumb 압축 반복이 접힌다 18장 Q4 프라이버시 개선 PII 가 애초에 안 실린다 23장 Q1 span 이름 안정 집계가 가능해진다 22장 Q1 하나의 습관이 네 가지 문제를 동시에 해결한다 P0 개념이 P1에서 실무 제약의 해법으로 되돌아온다 P0 17장 관심사 분리 UI 타입에서 로직 분리 → 격리 전염 소멸 (20장 Q6) P0 03장 프로세스 격리 익스텐션 경계 · 렌더 서버 분리 (24 · 25장)
P1의 아홉 챕터는 서로 다른 주제 같지만 같은 원리가 다른 자리에서 반복된다.

여섯 번째 행도 중요하다. P0 17장의 관심사 분리가 P1에서 성능과 배포 제약의 해법으로 되돌아온다 — UI 타입에서 로직을 분리하면 격리 전염이 사라지고(20장 Q6), 프레임워크에서 UIKit 의존을 줄이면 app-extension-safe 문제가 자동으로 풀린다(24장 Q6). 좋은 설계가 플랫폼 제약을 자연스럽게 만족시킨다는 것이 P1이 준 실증이다.

iOS에서는

실무에서 바로 점검할 수 있는 체크리스트로 정리하면 이렇다.

관측 파이프라인 (18~22장)

플랫폼 준수 (23~24장)

성능과 배포 (20·25·26장)

실험 · 도구

P1에서 다룬 도구를 어떤 질문에 쓰는지로 정리하면 선택이 쉬워진다.

질문도구챕터
앱이 왜 죽었나Exception TypeTermination Reason19장
주소가 무슨 함수인가atos, xcrun crashlog, dwarfdump --uuid21장
메인스레드가 얼마나 막혔나Instruments Hangs, 런루프 옵저버20장
어디서 시간을 쓰나Time Profiler (바쁜 것), Hangs (기다리는 것)20·22장
commit인가 render인가Animation Hitches25장
왜 GPU가 바쁜가Color Offscreen-Rendered 등 시각 진단25장
무엇이 전송되나프록시 캡처, beforeSend23장
무엇이 앱을 키우나App Size Report, 링크 맵26장
실사용자에게 실제로 어떤가MetricKit, Xcode Organizer19·20장

마지막 행이 P1의 결론에 가깝다. 모든 로컬 측정은 표본 하나이고, 실사용자 분포는 다르다. 그래서 프로덕션 지표로 확인하기 전까지 개선은 가설이다.

프로젝트 적용

P1을 실제 프로젝트에 도입한다면 순서가 중요하다. 효과와 비용을 함께 고려한 순서는 이렇다.

  1. 아카이브 장기 보관(21장 Q6) — 이것만 있으면 나머지는 나중에 복구 가능하다. 비용이 거의 0이고 없으면 되돌릴 수 없다.
  2. 심볼화율 감시(21장 Q3) — 크래시 데이터의 신뢰성 자체가 여기 달렸다.
  3. MetricKit 수집(19장 Q7) — OS가 집계한 기준선을 확보한다. 자체 계측을 검증할 잣대가 생긴다.
  4. 행률·히치율 감시(20장) — 크래시율이 좋은데 평점이 나쁜 원인의 대부분이 여기다.
  5. privacy manifest 정비(23장) — 심사 차단 요인이므로 우선순위가 높다.
  6. 크기 측정 자동화(26장) — 기록만 먼저 시작한다.
  7. 트레이싱·프로파일링(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의 주제 대부분이 검증할 수 없는 이론으로 남는다 — 아키텍처 결정이 옳았는지, 장애 분석이 맞았는지 확인할 수단이 관측이기 때문이다.

쉽게 말하면 정비를 배우고, 도로에서 어떻게 굴러가는지 알아내는 법을 배웠다. 다음은 애초에 고장이 덜 나게 설계하는 법아무도 못 고친 이상한 고장을 파헤치는 법이다. 그런데 그 전에 — 지금 배운 걸 실제 차에 한 번 달아봐야 한다. 안 해보면 다음 단계도 책 속 이야기로 남는다.

출처 · 참고자료