18~20장에서 모은 크래시와 행 데이터는 주소 목록이다. 0x1046a59b4 같은 숫자를 Line.updateRectForExistingPoint(_:)로 바꾸지 못하면 아무것도 조사할 수 없다. 이 챕터는 그 변환 — 심볼화 — 이 무엇에 의존하고 왜 조용히 실패하는지를 다룬다. P0 14장 Q5가 "dSYM과 심볼화가 연결된다"까지였다면, 여기서는 왜 UUID가 안 맞고, 인라이닝이 스택을 어떻게 바꾸며, CI에서 무엇을 보장해야 하는지까지 내려간다.
Q1. 주소가 함수 이름이 되는 과정은 무엇인가?
크래시 리포트의 각 프레임은 (바이너리 이미지, 그 이미지 시작점으로부터의 오프셋) 쌍이다. 심볼화는 이 오프셋을 디버그 정보 파일(dSYM)의 주소→심볼 매핑에 넣어 함수명·파일명·줄번호를 찾는 과정이다. 실행 시 ASLR로 이미지가 무작위 주소에 로드되므로 절대 주소는 매번 다르지만, 이미지 시작점 기준 오프셋은 항상 같다 — 그래서 리포트에 0x1045a19b4 0x10459c000 + 22964처럼 절대주소·이미지시작·오프셋이 함께 찍힌다. 마지막 숫자 22964가 심볼화의 실제 입력이다.
CS 원리
컴파일러는 소스를 기계어로 바꾸면서 이름을 잃어버린다. CPU는 함수 이름을 모르고 주소만 안다. 그래서 디버깅을 위해 별도의 매핑 테이블을 만들어 둔다 — 이것이 디버그 심볼이고, 표준 포맷이 DWARF다.
매핑에 필요한 것은 세 가지다.
- 주소 → 심볼 이름 — 어떤 주소 범위가 어떤 함수인가.
- 주소 → 소스 위치 — 그 주소가 어느 파일 몇 번째 줄에서 나왔는가.
- 인라인 정보 — 인라인된 함수는 호출 프레임이 없으므로, "이 주소는 사실 A 안에 인라인된 B였다"는 정보를 따로 기록한다.
실행 시점의 문제는 ASLR이다. 보안을 위해 이미지가 매 실행마다 다른 베이스 주소에 로드되므로, 크래시 시점의 절대 주소는 컴파일 시점 주소와 무관하다. 해결은 단순하다 — 상대 오프셋으로 변환한다.
오프셋 = 크래시 시점 절대 주소 − 그 이미지의 로드 베이스 주소
0x1045a19b4 − 0x10459c000 = 22964
이 오프셋은 ASLR과 무관하게 같은 바이너리라면 항상 같다. 그래서 크래시 리포트의 Binary Images 섹션이 결정적이다 — 여기에 각 이미지의 로드 베이스 주소와 UUID가 적혀 있고, UUID로 올바른 dSYM을 고른 뒤 오프셋으로 조회하면 이름이 나온다.
iOS에서는
Apple 문서가 제시하는 완전 심볼화된 리포트는 이렇게 생겼다.
Thread 0 name: Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0 TouchCanvas 0x1046a59b4 Line.updateRectForExistingPoint(_:) + 988
1 TouchCanvas 0x1046a56d0 Line.updateRectForExistingPoint(_:) + 248
2 TouchCanvas 0x1046ab130 CanvasView.updateEstimatedPropertiesForTouches(_:) + 832
3 TouchCanvas 0x1046a8610 @objc ViewController.touchesEstimatedPropertiesUpdated(_:) + 120
4 UIKitCore 0x1a6cff6ec forwardMethod1 + 108
…
각 줄의 구성은 프레임 번호 · 바이너리 이름 · 절대 주소 · 심볼 + 심볼 시작으로부터의 바이트 오프셋이다. 마지막 + 988은 함수 시작점에서 988바이트 떨어진 명령어라는 뜻이라, 함수 안 어디쯤인지 감을 준다.
부분 심볼화 리포트는 이렇게 갈린다.
0 TouchCanvas 0x1045a19b4 0x10459c000 + 22964 ← 우리 앱: 심볼 없음
1 TouchCanvas 0x1045a16d0 0x10459c000 + 22224 ← 우리 앱: 심볼 없음
4 UIKitCore 0x1a6cff6ec forwardMethod1 + 108 ← 시스템: 심볼 있음
…
여기서 어느 쪽에 심볼이 없는지가 진단을 가른다.
- 우리 앱 프레임만 주소 → 우리 dSYM이 없거나 UUID 불일치.
- 시스템 프레임만 주소 → 그 OS 버전의 기기 심볼이 Mac에 없다. Xcode가 기기를 연결하면 자동으로 내려받는데, 그 과정이 안 끝났거나 해당 버전을 처음 보는 경우다.
- 둘 다 주소 → 미심볼화. Apple 표현으로 거의 쓸모가 없다.
- 서드파티 프레임워크만 주소 → 그 프레임워크의 dSYM이 필요하다. Apple은 해당 개발자에게 요청하라고 안내한다.
실험 · 도구
Apple이 제공하는 심볼화 경로는 셋이다. 상황에 따라 골라 쓴다.
# (1) Xcode에서 — 파일을 열면 자동 시도
# 크래시 리포트는 확장자가 .crash 또는 .ips 여야 한다.
# 다른 확장자거나 확장자가 없으면 이름을 바꾼 뒤에 열 것.
# (2) crashlog — LLDB 기반. 인라인 프레임과 소스 줄까지 복원한다
xcrun crashlog /path/to/TouchCanvas.ips
# 내부적으로 lldb.macosx.crashlog 모듈을 LLDB에 설치해 실행한다.
# 이미 LLDB 세션이 있다면:
# (lldb) command script import lldb.macosx.crashlog
# (lldb) crashlog /path/to/TouchCanvas.ips
xcrun crashlog --help # 프레임 선택, 디스어셈블 줄 수, 이미지 목록 표시 등
# (3) CrashSymbolicator.py — JSON(.ips) 포맷과 인라인 프레임을 기본 지원
cd "$(xcode-select -p)/../SharedFrameworks/CoreSymbolicationDT.framework/Resources"
python3 CrashSymbolicator.py /path/to/TouchCanvas.ips -d /path/to/TouchCanvas.app.dSYM
# 결과를 stdout으로 출력한다. CI에서 자동화하기 좋다.
수동으로 한 프레임만 확인하고 싶을 때는 atos가 가장 빠르다.
# dSYM의 UUID 확인 — 리포트의 Binary Images UUID와 대조한다
dwarfdump --uuid TouchCanvas.app.dSYM
# UUID: E3EA8743-C9E6-3C68-BF04-8D51363B689D (arm64) …/TouchCanvas
# 오프셋으로 직접 조회 (-o 바이너리, -l 로드 베이스, 마지막이 절대 주소)
atos -arch arm64 -o TouchCanvas.app.dSYM/Contents/Resources/DWARF/TouchCanvas \
-l 0x10459c000 0x1045a19b4
# → Line.updateRectForExistingPoint(_:) (in TouchCanvas) (Line.swift:230)
# 오프셋만 알고 있다면 로드 베이스를 0으로 두고 오프셋을 주소로 넘겨도 된다
atos -arch arm64 -o …/DWARF/TouchCanvas -l 0x0 0x59b4
dwarfdump --uuid 결과와 리포트의 UUID를 비교하는 것이 모든 심볼화 문제 진단의 1단계다. 여기서 다르면 그 뒤 무엇을 해도 안 된다.
프로젝트 적용
팀에서 정해둘 것 셋.
- 리포트를 받으면 UUID 대조부터 한다. "심볼화가 안 된다"는 문의의 대부분이 UUID 불일치이고, 확인에 10초면 된다.
- 서드파티 프레임워크의 dSYM을 배포 시점에 함께 보관한다. 나중에 요청하면 그 버전의 dSYM이 이미 없을 수 있다. XCFramework를 받을 때 dSYM이 포함돼 있는지 확인하는 것이 계약 사항에 가깝다.
.ips확장자를 유지한다. 사용자가 텍스트로 붙여넣어 준 리포트를.txt로 저장하면 Xcode가 열지 않는다. 확장자만 바꾸면 된다는 사실을 아는 것이 의외로 유용하다.
"크래시 리포트의 주소를 그대로 찾으면 된다"는 틀렸다. ASLR 때문에 절대 주소는 실행마다 다르므로 반드시 이미지 베이스를 빼서 오프셋으로 바꿔야 한다. "심볼화는 서버에서 알아서 해준다"도 조건부다 — 업로드 시 심볼을 함께 올려야 App Store가 심볼명을 붙여준다. "부분 심볼화면 dSYM 문제"라고 단정하는 것도 성급하다 — 시스템 프레임만 주소라면 기기 심볼을 못 받은 것이고 dSYM과 무관하다. 또 "심볼화가 실패하면 에러가 난다"도 아니다 — 조용히 주소로 남을 뿐이라 능동적으로 확인하지 않으면 몇 달을 모른다.
아파트에서 "누가 사고를 냈나"를 알아내는 상황을 떠올려보자.
사고 기록에는 "3동 앞에서 22964걸음 떨어진 지점"이라고만 적혀 있다. 사람 이름이 없다. 왜냐하면 기록 장치는 이름을 모르고 위치만 알기 때문이다.
이름을 알려면 입주자 명부가 필요하다. 명부에는 "3동 입구에서 22964걸음 지점 = 김OO 집"처럼 적혀 있다. 이 명부가 dSYM이다.
여기서 두 가지가 중요하다.
첫째, 왜 "걸음 수"로 적을까? 아파트 단지 전체가 매일 위치를 옮기기 때문이다(ASLR). 오늘 3동이 여기 있었어도 내일은 저기 있다. 그래서 절대 위치는 쓸모없고, 3동 입구 기준 상대 거리만 항상 같다.
둘째, 명부는 동마다 다르다. 그리고 리모델링을 하면 명부가 통째로 달라진다. 그래서 명부마다 "언제 버전인지"를 나타내는 번호(UUID)가 붙어 있고, 사고 기록의 번호와 명부의 번호가 정확히 같아야 한다. 하나라도 다르면 엉뚱한 사람 이름이 나오는 게 아니라 아예 못 찾는다.
꼬리 질문
심볼 이름 뒤의 + 988은 무엇이며 어떻게 활용하는가?
그 함수의 시작 주소로부터 몇 바이트 떨어진 명령어인지를 나타낸다. 함수 전체가 심볼 하나로 묶이므로, 함수 안 어디서 크래시했는지를 알려면 이 값이 필요하다.
활용법은 셋이다.
(1) 함수 초입인지 판단. 값이 작으면(수십 바이트) 함수 진입 직후다. 인자 검증이나 프롤로그에서 문제가 생겼을 가능성이 높다. 값이 크면 함수 본문 깊은 곳이다.
(2) 줄번호 없이도 위치 추정. dSYM이 없어 줄번호를 못 얻어도, 같은 함수의 여러 크래시에서 오프셋이 모두 같다면 정확히 같은 명령어에서 죽고 있다는 뜻이다. 오프셋이 제각각이면 함수 전체에 걸친 문제(예: 인자로 넘어온 값이 이상)일 가능성이 크다.
(3) 디스어셈블과 대조. otool -tv나 LLDB로 그 함수를 디스어셈블하면 해당 오프셋의 명령어를 직접 볼 수 있다. 최적화된 릴리스 빌드에서 줄번호가 부정확할 때 최후의 수단이다.
주의할 점은 인라이닝이 있으면 이 값의 의미가 흐려진다는 것이다. 겉보기 함수 A의 오프셋 988이 실제로는 인라인된 B의 코드일 수 있다(Q4).
시스템 프레임워크의 심볼은 우리가 dSYM을 갖고 있지 않은데 어떻게 나오는가?
Apple이 기기 심볼(device symbols)을 별도로 제공하기 때문이다. iOS 버전마다 시스템 프레임워크의 심볼 정보가 있고, Xcode는 기기를 Mac에 연결하면 그 기기의 OS 버전에 해당하는 심볼을 자동으로 내려받아 로컬에 캐시한다.
그래서 다음 상황들이 설명된다.
- 새 iOS 베타에서 온 리포트가 시스템 프레임만 주소로 나온다 → 그 버전의 심볼을 아직 못 받았다. 해당 버전 기기를 한 번 연결하면 해결되는 경우가 많다.
- 다른 사람 Mac에서는 되는데 내 Mac에서는 안 된다 → 심볼 캐시가 로컬에 있기 때문이다.
- Xcode를 업데이트했더니 갑자기 잘 된다 → Xcode에 최신 OS 심볼이 함께 배포되기도 한다.
다만 시스템 프레임의 심볼화는 진단에 보조적이다. 우리가 고칠 수 있는 것은 인앱 프레임이므로, 시스템 프레임이 주소로 남아 있어도 인앱 프레임만 제대로 나오면 대부분 조사가 가능하다. Apple이 "부분 심볼화도 크래시를 이해하기에 충분할 수 있다"고 하는 이유다.
반대로 인앱 프레임이 주소인데 시스템 프레임만 이름이 나오는 상황이 훨씬 심각하다 — 우리 dSYM 문제이고, 이건 우리가 고쳐야 한다.
crashlog와 CrashSymbolicator.py는 언제 각각 쓰는가?
xcrun crashlog는 사람이 대화형으로 조사할 때 유리하다. LLDB 안에서 동작하므로 심볼화 후에 thread backtrace --extended로 더 자세히 보거나, 디스어셈블을 붙이거나, 이미지 목록을 확인하는 등 후속 조사를 이어서 할 수 있다. Apple 문서의 예시에서도 심볼화 결과에 Swift runtime failure: Index out of range at Line.swift:0:27 같은 트랩 사유가 함께 표시되는 것을 볼 수 있는데, LLDB가 프로세스 상태를 해석해주기 때문이다.
CrashSymbolicator.py는 자동화에 유리하다. 결과를 stdout으로 JSON 형태로 뱉으므로 파이프로 이어 붙이기 쉽고, JSON 포맷(.ips) 리포트와 인라인 프레임을 기본 옵션으로 지원한다. dSYM 경로를 -d로 명시하므로 CI에서 아티팩트 경로를 넘기기도 편하다.
실무 조합은 이렇다 — CI·백엔드 자동 심볼화는 CrashSymbolicator.py, 개발자가 특정 리포트를 파고들 때는 crashlog. 그리고 프레임 하나만 급히 확인할 때는 atos가 가장 빠르다.
참고로 CrashSymbolicator.py는 Xcode 앱 번들 안에 있으므로 경로가 Xcode 버전에 따라 달라질 수 있다. xcode-select -p로 기준 경로를 잡는 습관이 안전하다.
Q2. dSYM은 무엇이고 왜 빌드마다 달라지는가?
디버그 빌드는 심볼을 바이너리 안에 넣지만, 릴리스 빌드는 배포 크기를 줄이려고 별도 파일(dSYM)로 분리한다. 그리고 앱의 각 바이너리 — 메인 실행 파일, 프레임워크, 앱 익스텐션 — 가 각자 자기 dSYM을 갖는다. 바이너리와 dSYM은 build UUID로 묶이며, 양쪽에 같은 UUID가 기록된다. Apple 문서가 명시하듯 같은 소스라도 Xcode 버전이나 빌드 설정이 다르면 UUID가 달라지고, UUID가 다른 바이너리와 dSYM은 서로 호환되지 않는다. 그래서 배포한 빌드의 dSYM을 반드시 보관해야 하고, 나중에 같은 소스로 다시 빌드해도 그 dSYM은 쓸 수 없다.
CS 원리
여기서 작동하는 원리는 콘텐츠 기반 식별이다. UUID는 "이 소스 코드"가 아니라 "이 컴파일 결과물"을 식별한다. 왜 이래야 하는지는 반대를 상상해보면 명확하다 — 만약 소스 버전으로 식별한다면, 같은 소스를 다른 컴파일러로 빌드했을 때 주소 배치가 완전히 달라진 바이너리에 옛 매핑을 적용하게 되어 엉뚱한 함수명이 나온다. 틀린 답이 나오는 것은 못 찾는 것보다 훨씬 나쁘다.
그래서 시스템은 보수적으로 설계됐다 — UUID가 정확히 일치할 때만 매칭하고, 아니면 실패한다. 이 설계가 "왜 이렇게 까다롭냐"의 답이다.
UUID를 바꾸는 요인은 생각보다 많다.
- Xcode 버전 (컴파일러 버전)
- Swift 버전, 최적화 레벨
- 빌드 설정 변경 (컴파일 플래그, 링커 플래그)
- 의존성 버전 변경
- 아키텍처 (같은 dSYM 안에 아키텍처별로 UUID가 따로 있다)
마지막 항목이 자주 놓치는 지점이다. dwarfdump --uuid를 돌리면 arm64·arm64e 등 아키텍처별로 여러 UUID가 출력된다. 리포트의 UUID는 그중 하나와 맞아야 한다.
iOS에서는
dSYM 생성과 전달 경로를 정확히 알아두면 문제 진단이 빨라진다.
| 단계 | 무엇이 일어나는가 | 확인 포인트 |
|---|---|---|
| 빌드 | DEBUG_INFORMATION_FORMAT이 DWARF with dSYM File이어야 dSYM이 생성된다 | 배포 전 이 설정 확인 |
| 아카이브 | Xcode가 모든 바이너리와 dSYM을 아카이브 안에 모은다 | 아카이브를 반드시 보관 |
| 업로드 | App Store Connect 업로드 시 심볼 포함 여부를 선택 | 포함해야 Organizer가 자동 심볼화 |
| 수신 | 심볼을 안 올려도 크래시 리포트는 온다 — 심볼명 없이 | Mac에 올바른 dSYM이 있으면 Xcode가 붙여준다 |
Apple이 특히 강조하는 문장이 있다 — 배포하는 각 빌드의 Xcode 아카이브를 반드시 보관해야 한다. 이 아카이브가 없으면 크래시 리포트로 문제를 진단하지 못할 수 있다.
실무에서 이게 무너지는 전형적 경로는 이렇다.
- CI가 매번 클린 빌드 — 아티팩트를 안 남기면 그 빌드의 dSYM은 영원히 사라진다.
- 개발자 로컬에서 아카이브 후 업로드 — 그 사람 Mac에만 아카이브가 있고, 퇴사하거나 디스크를 정리하면 끝이다.
- Bitcode 시대의 잔재 — 과거 App Store가 재컴파일하던 시절에는 스토어에서 dSYM을 다시 받아야 했다. 그 습관이 남아 "스토어에서 받으면 되지"라고 생각하는 경우가 있는데, 현재는 업로드한 dSYM을 우리가 보관하는 것이 기본이다.
실험 · 도구
UUID 불일치는 명령 두 줄로 확정할 수 있다.
# 1) 크래시 리포트에서 우리 앱 이미지의 UUID를 뽑는다
# ⚠️ iOS 15+ 의 .ips 는 JSON 이다. 이미지 목록은 usedImages 배열에 있다.
jq -s -r '.[1].usedImages[] | select(.name == "YourApp") | "\(.uuid) base=\(.base)"' YourApp.ips
# e3ea8743-c9e6-3c68-bf04-8d51363b689d base=4367056896
#
# Console/Xcode 가 번역해 보여주는 텍스트라면 Binary Images 섹션을 읽으면 된다:
# 0x10459c000 - 0x1045b7fff YourApp arm64 <e3ea8743c9e63c68bf048d51363b689d> /var/...
# 2) 갖고 있는 dSYM들의 UUID를 전부 나열한다
find ~/Library/Developer/Xcode/Archives -name '*.dSYM' -print0 \
| xargs -0 -n1 dwarfdump --uuid
# 3) Spotlight로 UUID 직접 검색 — Apple이 안내하는 방법
mdfind "com_apple_xcode_dsym_uuids == E3EA8743-C9E6-3C68-BF04-8D51363B689D"
# 찾으면 그 dSYM이 맞는 것이다. 아무것도 안 나오면 그 빌드의 dSYM이 이 Mac에 없다.
UUID 표기 형식이 다르다는 점에 주의한다 — 리포트는 하이픈 없는 소문자(e3ea8743c9e6…), dwarfdump는 하이픈 있는 대문자(E3EA8743-C9E6-…)로 출력한다. 비교 전에 정규화해야 한다.
# 정규화해 "집합 포함" 으로 비교한다
# ⚠️ dwarfdump --uuid 는 슬라이스마다 한 줄을 낸다(arm64, arm64e).
# 첫 줄만 잡아 비교하면 아키텍처가 다를 때 무조건 불일치로 오판한다.
norm() { tr -d '-' | tr 'A-Z' 'a-z'; }
report_uuid=$(jq -s -r '.[1].usedImages[] | select(.name=="YourApp") | .uuid' YourApp.ips | norm)
dwarfdump --uuid YourApp.app.dSYM | awk '{print $2}' | norm | sort -u > /tmp/dsym-uuids.txt
if grep -qx "$report_uuid" /tmp/dsym-uuids.txt; then
echo "✅ 일치"
else
echo "❌ 불일치 — 이 dSYM 으로는 심볼화 불가"
echo " 리포트: $report_uuid"
echo " dSYM : $(tr '\n' ' ' < /tmp/dsym-uuids.txt)"
fi
같은 소스에서 UUID가 달라지는 것도 직접 확인해볼 수 있다.
# 같은 소스를 최적화 레벨만 바꿔 두 번 빌드하면 UUID가 달라진다
swiftc -O -g Demo.swift -o demo-O
swiftc -Onone -g Demo.swift -o demo-Onone
dwarfdump --uuid demo-O demo-Onone
# → 서로 다른 UUID가 나온다. "소스가 같으니 dSYM도 호환되겠지"가 틀린 이유다.
프로젝트 적용
dSYM 보관을 사람의 기억이 아니라 파이프라인에 맡겨야 한다. 최소 요건 넷.
- CI에서 아카이브 전체를 아티팩트로 저장한다. dSYM만 따로 빼도 되지만, 아카이브를 통째로 두면 나중에 필요한 것이 더 있을 때 대응할 수 있다. 보관 기간은 앱의 최소 지원 기간보다 길어야 한다 — 2년 전 버전을 아직 쓰는 사용자가 있으면 그 dSYM도 필요하다.
- 업로드는 자동화한다. 관측 백엔드에 dSYM을 올리는 단계를 CI에 넣고, 실패하면 빌드를 실패시킨다. 경고로 두면 반드시 언젠가 조용히 빠진다.
- 앱 익스텐션과 임베디드 프레임워크의 dSYM도 함께 올린다. 메인 앱만 올리면 익스텐션 크래시가 심볼화되지 않는다. 익스텐션은 별도 프로세스·별도 바이너리이므로 dSYM도 별개다(24장).
- 업로드 성공을 검증한다. 올렸다고 끝이 아니라, 백엔드에 그 UUID가 등록됐는지 조회해 확인하는 단계까지 넣는다.
# CI 스텝 예시 — 아카이브 안의 모든 dSYM을 찾아 UUID 목록을 남기고 업로드한다
ARCHIVE="build/YourApp.xcarchive"
# 1) 아카이브 안 모든 dSYM 열거 (앱 + 익스텐션 + 프레임워크)
find "$ARCHIVE/dSYMs" -name '*.dSYM' | tee dsyms.txt
# 2) UUID 매니페스트 생성 — 나중에 "이 빌드의 dSYM이 뭐였나" 추적용
while read -r d; do dwarfdump --uuid "$d"; done < dsyms.txt > dsym-uuids.txt
cat dsym-uuids.txt
# 3) 개수 검증 — 예상 개수와 다르면 실패시킨다
EXPECTED=4 # 앱 + 공유익스텐션 + 알림익스텐션 + 공용프레임워크
ACTUAL=$(wc -l < dsyms.txt)
[ "$ACTUAL" -eq "$EXPECTED" ] || { echo "❌ dSYM $ACTUAL개 (예상 $EXPECTED개)"; exit 1; }
# 4) 업로드 (백엔드에 맞는 명령으로 교체) — 실패 시 빌드 실패
upload-dsyms "$ARCHIVE/dSYMs" || exit 1
"소스가 같으면 다시 빌드해서 dSYM을 만들면 된다"가 가장 비싼 오해다. Apple이 명시하듯 Xcode 버전이나 빌드 설정이 다르면 UUID가 달라지고, 그러면 그 dSYM은 쓸 수 없다. 재현 빌드를 보장하지 않는 한 사후 재생성은 불가능하다고 봐야 한다. "메인 앱 dSYM만 올리면 된다"도 틀렸다 — 바이너리마다 각자 dSYM이 있다. "디버그 빌드에는 dSYM이 없다"도 부정확하다 — 디버그는 심볼을 바이너리 안에 넣을 뿐이다. 또 "dSYM을 올렸으니 심볼화가 될 것"이라고 확인 없이 넘어가는 것이 위험하다 — 업로드 실패는 조용하고, 몇 달 뒤 크래시를 조사하려 할 때 발견된다.
다시 아파트 명부 이야기다. 이번엔 왜 명부를 나중에 다시 만들 수 없는지다.
아파트를 지을 때마다 호수 배정 방식이 조금씩 달라진다. 같은 설계도로 지어도, 시공사가 다르거나 자재가 바뀌면 방 크기가 미세하게 달라지고 그러면 "입구에서 몇 걸음"이 전부 밀린다.
그래서 명부에는 "이 건물 고유번호"가 찍혀 있다. 사고 기록에도 같은 번호가 찍힌다. 번호가 다르면 명부를 못 쓴다.
여기서 사람들이 자주 하는 실수 — "설계도가 있으니 다시 지어서 명부를 만들면 되지". 안 된다. 다시 지으면 고유번호가 새로 붙고, 걸음 수도 미묘하게 달라진다. 그때 그 건물의 명부는 그때 만들어 보관해두는 수밖에 없다.
그리고 하나 더 — 단지에는 건물이 여러 개다. 본관(앱), 별관(익스텐션), 부속동(프레임워크). 명부는 건물마다 따로 있다. 본관 명부만 챙기면 별관에서 난 사고는 영영 못 밝힌다.
꼬리 질문
재현 가능 빌드(reproducible build)를 만들면 dSYM을 나중에 재생성할 수 있는가?
이론적으로는 가능하지만 실무에서 보장하기가 매우 어렵다. 완전히 동일한 바이너리를 다시 만들려면 아래가 전부 고정돼야 한다.
- Xcode 버전과 Swift 컴파일러 버전 (패치 버전까지)
- SDK 버전
- 모든 의존성의 정확한 버전과 그 의존성들의 빌드 산출물
- 빌드 설정 전체
- 빌드 경로 (경로가 디버그 정보에 박히는 경우가 있다)
- 타임스탬프나 빌드 번호 같은 비결정적 입력이 없을 것
마지막 항목이 특히 까다롭다. 빌드 시각이나 커밋 해시를 소스에 주입하는 흔한 관행이 있으면 그 자체로 결과가 달라진다.
또 검증 비용이 문제다. "재현 가능하다"고 믿으려면 실제로 재현해서 UUID가 같은지 확인해야 하는데, 그 확인을 매 릴리스마다 할 거라면 그냥 dSYM을 보관하는 편이 훨씬 싸다.
그래서 실무 결론은 명확하다 — 재현성에 기대지 말고 보관한다. dSYM은 수십~수백 MB 수준이라 저장 비용이 크지 않고, 압축하면 더 줄어든다. 사고가 났을 때 없는 쪽의 비용이 비교할 수 없이 크다.
아키텍처별로 UUID가 여러 개인 것은 실무에서 어떤 문제를 만드는가?
같은 dSYM 파일 안에 arm64·arm64e 등 슬라이스가 여러 개 있고 각각 다른 UUID를 가진다. 여기서 세 가지 실무 문제가 생긴다.
(1) 매칭 스크립트의 버그. dwarfdump --uuid 출력의 첫 줄만 잡아 비교하면, 리포트가 다른 아키텍처에서 온 경우 불일치로 오판한다. 모든 슬라이스 UUID를 모아 집합으로 비교해야 한다.
(2) 업로드 누락. 일부 도구는 특정 아키텍처만 업로드하거나, 시뮬레이터 슬라이스를 잘못 포함시킨다. 백엔드에 등록된 UUID 목록을 실제로 조회해 확인하는 것이 안전하다.
(3) atos 호출 시 -arch 지정. 아키텍처를 안 주거나 틀리게 주면 조회에 실패하거나 이상한 결과가 나온다. 리포트의 Binary Images 줄에 아키텍처가 적혀 있으므로 그것을 그대로 넘긴다.
참고로 arm64e는 포인터 인증(PAC)을 쓰는 변형이라 arm64와 바이너리가 다르다. 최신 기기와 구형 기기에서 온 리포트가 서로 다른 슬라이스를 참조할 수 있다.
dSYM 파일이 큰데 보관 기간을 어떻게 정해야 하는가?
기준은 "그 버전을 쓰는 사용자가 남아 있는 기간"이다. 크래시 리포트는 사용자가 앱을 실행할 때 올라오므로, 아주 오래된 버전에서도 리포트가 온다. 업데이트를 안 하는 사용자가 있는 한 계속 온다.
실용적 기준을 정하는 방법은 이렇다.
(1) 버전별 활성 사용자 비율을 본다. 특정 버전의 사용자가 전체의 0.1% 아래로 떨어지면 그 버전의 dSYM은 아카이브 계층(콜드 스토리지)으로 옮긴다.
(2) 강제 업데이트 정책과 연계한다. 최소 지원 버전을 강제하는 앱이라면 그 아래 버전의 dSYM은 삭제해도 안전하다.
(3) 저장 비용을 실제로 계산해본다. dSYM은 압축이 잘 되는 편이고, 릴리스 주기가 2주라면 연간 26개다. 개당 200 MB로 잡아도 연 5 GB 수준이라 오브젝트 스토리지에서는 무시할 만한 비용이다. 대개는 "보관 기간을 최적화할 이유가 없다"는 결론이 나온다.
주의할 것은 백엔드의 보관 정책이다. 관측 백엔드가 업로드된 디버그 파일을 일정 기간(예: 90일) 후 삭제할 수 있으므로, 우리 쪽 원본 보관은 그와 별개로 유지해야 한다. 백엔드에서 지워졌다면 다시 올리면 되지만, 원본까지 없으면 방법이 없다.
Q3. 심볼화가 실패하면 무엇이 망가지는가?
조사 불능만이 아니라 집계가 통째로 오염된다. 심볼화가 안 되면 스택 프레임이 주소로 남는데, 18장 Q6에서 본 대로 지문은 스택에서 계산되므로 주소가 지문 재료가 된다. 그런데 이미지 베이스는 실행마다 다르고 오프셋도 빌드마다 달라지므로, 같은 버그가 릴리스마다 — 심하면 실행마다 — 다른 이슈로 쪼개진다. 증상은 이렇다. 이슈 수가 폭증하고, 각 이슈의 발생 건수가 1~2건이라 심각도를 판단할 수 없고, "인앱 프레임 없음" 비율이 오른다. 심볼화는 조사 편의가 아니라 데이터 정합성의 전제조건이다.
CS 원리
심볼화 실패가 그룹핑을 파괴하는 구조를 정리하면 이렇다.
지문 함수는 대략 f(예외 타입, 메시지, 인앱 프레임 목록) 형태다. 정상 상태에서 인앱 프레임 목록은 함수명이므로 안정적이다 — 같은 버그면 같은 함수명이 나온다.
심볼화가 실패하면 이 자리에 주소가 들어간다. 주소의 안정성은 계층적으로 무너진다.
| 무엇이 지문에 들어가나 | 안정성 | 결과 |
|---|---|---|
| 함수명 (정상) | 빌드가 바뀌어도 대체로 동일 | 같은 버그 = 같은 이슈 |
| 이미지 기준 오프셋 | 같은 빌드 안에서는 동일 | 릴리스마다 이슈가 갈린다 |
| 절대 주소 | ASLR로 실행마다 다름 | 발생 건마다 이슈가 갈린다 |
세 번째 행이 최악의 경우다. 백엔드가 절대 주소를 정규화하지 않으면 이슈 수 = 크래시 건수가 되어 대시보드가 완전히 무용해진다. 대부분의 백엔드는 오프셋으로 정규화하므로 두 번째 행에 머무는 경우가 많지만, 그래도 릴리스마다 이슈가 리셋되는 것은 그대로다.
이게 왜 아픈지는 추세를 못 본다는 데 있다. "이 버그가 3개월째 있다"를 보려면 이슈가 릴리스를 가로질러 유지돼야 하는데, 릴리스마다 새 이슈가 되면 매번 "새로 생긴 버그"처럼 보인다. 그러면 오래된 버그일수록 우선순위가 낮게 매겨지는 역전이 일어난다.
iOS에서는
심볼화 실패의 진단 신호를 정리하면 이렇다. 20장 Q7 꼬리질문에서 다룬 "인앱 프레임 없는 크래시"의 원인 구분과 이어진다.
| 증상 | 가능한 원인 | 확인 방법 |
|---|---|---|
| 우리 앱 프레임만 주소 | dSYM 없음 / UUID 불일치 | mdfind로 UUID 검색 |
| 시스템 프레임만 주소 | 기기 심볼 미수신 | 해당 OS 기기 연결 |
| 특정 프레임워크만 주소 | 서드파티 dSYM 없음 | 벤더에 요청 |
| 익스텐션 크래시만 주소 | 익스텐션 dSYM 업로드 누락 | 업로드 목록 확인 |
| 릴리스마다 이슈 리셋 | 심볼화 실패가 지속 중 | 인앱 프레임 비율 추이 |
마지막 행이 가장 늦게 발견되는 문제다. 개별 리포트를 열어보지 않으면 모르고, 대시보드는 "이슈가 많다"고만 보여준다.
실험 · 도구
심볼화 상태를 지표로 만들어 감시하는 것이 핵심이다. 개별 리포트를 열어보는 방식으로는 놓친다.
# (1) 로컬 리포트 더미에서 심볼화율을 계산한다
total=0; unsym=0
for f in reports/*.ips; do
total=$((total+1))
# 우리 앱 이름이 들어간 프레임 중 "0x… + 숫자" 형태(=미심볼화)가 있는지
if grep -E "YourApp\s+0x[0-9a-f]+\s+0x[0-9a-f]+ \+ [0-9]+" "$f" > /dev/null; then
unsym=$((unsym+1))
fi
done
echo "미심볼화 리포트: $unsym / $total"
# (2) 백엔드에 등록된 dSYM UUID 목록과, 배포한 빌드의 UUID 목록을 대조
# 둘의 차집합이 비어야 한다
comm -23 <(sort released-uuids.txt) <(sort backend-uuids.txt)
# 출력이 있으면 그 UUID들의 dSYM이 백엔드에 없다 → 그 빌드의 크래시는 심볼화 안 됨
대시보드 쪽에서는 다음 두 지표를 만들어 둔다.
- 인앱 프레임 보유율 — 전체 크래시 중 인앱 프레임이 하나 이상 심볼화된 비율. 정상이면 대체로 85~95% 범위이고(커뮤니티·경험칙 — 공식 기준 아님, 앱마다 다르다), 급락하면 dSYM 파이프라인이 깨진 것이다.
- 릴리스별 이슈 신규 생성률 — 새 릴리스에서 "처음 보는 이슈"의 비율. 코드 변경 대비 지나치게 높으면 그룹핑이 리셋되고 있다는 신호다.
그리고 릴리스 직후 자동 점검을 넣으면 가장 빨리 잡힌다.
# 릴리스 후 24시간 시점에 실행하는 점검 스크립트 (개념 예시)
NEW_VERSION="4.12.0"
# 1) 이 버전에서 온 크래시 중 심볼화된 비율
rate=$(query_backend "crashes where version=$NEW_VERSION and has_inapp_frame=true" \
| percent_of "crashes where version=$NEW_VERSION")
# 2) 임계 미달이면 알람 — dSYM 업로드 누락을 24시간 안에 잡는다
if [ "$rate" -lt 80 ]; then
alert "❌ $NEW_VERSION 심볼화율 ${rate}% — dSYM 업로드 확인 필요"
fi
프로젝트 적용
운영 규칙 넷으로 정리된다.
- 심볼화율을 릴리스 체크리스트에 넣는다. 배포 후 24시간 내 확인. 이 시점에 발견하면 dSYM을 뒤늦게라도 올려 이후 들어오는 리포트는 심볼화할 수 있다.
- 이미 심볼화 없이 그룹핑된 이슈는 되돌아가지 않는다는 것을 이해한다. 18장 Q6 꼬리질문에서 본 대로 과거 데이터는 대개 재분류되지 않는다. 그래서 빨리 발견하는 것이 유일한 대책이다.
- dSYM 업로드 실패를 빌드 실패로 승격한다. 경고로 두면 반드시 새어 나간다.
- 익스텐션·프레임워크를 포함한 개수 검증을 CI에 넣는다(Q2의 스크립트).
"심볼화는 조사할 때만 필요하다"가 핵심 오해다. 심볼화는 지문 계산의 입력이므로 실패하면 집계가 먼저 망가진다. "나중에 dSYM을 올리면 과거 크래시도 심볼화된다"도 대개 틀렸다 — 백엔드에 따라 이후 수신분만 처리하고, 이미 만들어진 이슈 그룹은 그대로다. "이슈가 많이 뜨니 버그가 많다"는 18장에서 본 오독이 여기서 재발한다 — 심볼화 실패 때문일 수 있다. 또 "미심볼화 리포트도 주소로 추적하면 된다"는 실무적으로 불가능에 가깝다 — 이론상 오프셋으로 조회할 수 있지만 dSYM이 없어서 실패한 상황이므로 조회할 자료 자체가 없다.
병원 접수를 생각해보자. 환자가 오면 같은 사람의 진료 기록을 하나로 묶어야 한다. 그래야 "이 환자가 세 달째 같은 증상"이라는 걸 안다.
묶는 기준이 이름이면 잘 된다. 이름은 변하지 않으니까.
그런데 이름을 못 읽는 상황이 생겼다고 하자. 명부가 없어서 "3동 22964걸음 지점 사람"이라고만 적힌다. 문제는 단지가 매일 위치를 옮긴다는 것이다. 그래서 같은 사람이 어제는 "3동 22964", 오늘은 "7동 51203"으로 기록된다.
결과가 어떻게 될까? 진료 기록이 올 때마다 새 환자가 된다. 장부에는 환자 수천 명이 각각 1회씩 방문한 것으로 남는다. "세 달째 같은 증상인 사람"은 영원히 안 보인다.
여기서 무서운 건 장부가 고장 났다는 사실이 안 보인다는 점이다. 오히려 "환자가 정말 많네" 하고 넘어가게 된다. 그래서 "이름이 적힌 기록의 비율"을 따로 세어봐야 한다. 그 비율이 갑자기 떨어지면 명부에 문제가 생긴 것이다.
꼬리 질문
백엔드가 절대 주소가 아니라 오프셋으로 정규화한다는 것을 어떻게 확인하는가?
가장 확실한 방법은 같은 크래시를 두 번 발생시켜 이슈가 합쳐지는지 보는 것이다. dSYM을 일부러 올리지 않은 테스트 빌드에서 같은 크래시를 여러 기기·여러 실행으로 발생시킨다. 절대 주소를 쓴다면 매번 다른 이슈가 생기고, 오프셋으로 정규화한다면 하나로 묶인다.
간접 확인 방법도 있다. 미심볼화 이슈의 스택 표시를 보면 백엔드가 무엇을 저장했는지 드러난다 — 0x1045a19b4 형태의 절대 주소만 보이면 정규화가 약한 것이고, YourApp + 22964처럼 이미지 기준 오프셋으로 보이면 정규화하고 있는 것이다.
대부분의 성숙한 백엔드는 크래시 리포트를 파싱할 때 Binary Images 정보를 함께 받아 수집 시점에 오프셋으로 변환한다. 이게 표준적인 구현이고, 그래야 나중에 dSYM을 올렸을 때 소급 심볼화가 가능해진다.
실무적으로 더 중요한 것은 정규화를 하더라도 릴리스 간 그룹핑은 여전히 깨진다는 점이다. 오프셋은 빌드마다 달라지기 때문이다. 그러니 "정규화하니까 괜찮다"가 아니라 dSYM을 제대로 올리는 것 외에 대안이 없다.
심볼화율이 떨어졌을 때 이미 쪼개진 이슈들은 어떻게 정리하는가?
현실적으로 정리보다 손절이 답인 경우가 많다. 순서는 이렇다.
(1) 먼저 파이프라인을 고친다. dSYM을 올려 이후 수신분부터 정상 심볼화되게 한다. 이게 최우선이고, 과거 데이터 정리는 그 다음이다.
(2) 백엔드의 병합 기능을 확인한다. 일부 백엔드는 이슈를 수동으로 병합하는 기능을 제공한다. 쪼개진 이슈가 열 개 수준이면 병합이 실용적이지만, 수백 개면 현실성이 없다.
(3) 영향 규모를 다른 축으로 추정한다. 이슈가 쪼개져 개별 건수가 작아 보여도, 해당 릴리스의 전체 크래시율은 정확하다. "이 릴리스가 이전 대비 크래시율이 30% 높다"는 사실은 그룹핑과 무관하게 유효하므로, 심각도 판단은 이 지표로 대신한다.
(4) 미심볼화 이슈를 별도 태그로 격리한다. 정상 이슈 목록에 섞이면 우선순위 판단을 계속 방해한다. 필터로 분리해두고, 심볼화가 복구된 뒤의 데이터로 다시 판단한다.
교훈은 명확하다 — 이 문제는 사후 정리가 거의 불가능하므로 예방과 조기 감지가 전부다. 그래서 릴리스 후 24시간 점검을 자동화하는 것이다.
서드파티 SDK의 dSYM을 못 구하면 어떻게 대응하는가?
Apple의 안내는 해당 개발자에게 dSYM을 요청하라는 것이고, 이게 정공법이다. 다만 못 구하는 경우 대응은 이렇다.
(1) 인앱 프레임으로 우회한다. 크래시가 SDK 내부에서 나더라도 스택 아래로 내려가면 우리가 그 SDK를 호출한 지점이 있다. 그 프레임이 심볼화돼 있으면 "우리 코드의 어느 호출이 문제를 유발했나"는 알 수 있다. 대부분의 SDK 크래시는 우리가 넘긴 인자나 사용 방식이 원인이므로 이것으로 충분한 경우가 많다.
(2) 그룹핑에서 SDK 프레임을 배제한다. 미심볼화된 SDK 프레임이 지문에 들어가면 그룹핑이 흔들린다. 백엔드에서 인앱 프레임만으로 지문을 계산하도록 설정하면 안정성이 회복된다.
(3) SDK 버전을 태그로 붙인다. "특정 SDK 버전에서만 발생"을 확인할 수 있으면 벤더에 보고할 근거가 되고, 다운그레이드로 즉시 완화할 수도 있다.
(4) 소스 배포로 전환을 검토한다. 바이너리 배포 SDK 대신 소스 형태(SPM 소스 패키지)로 받을 수 있다면 우리가 빌드하므로 dSYM도 우리가 갖게 된다. 다만 빌드 시간과 라이선스 문제가 따라온다.
장기적으로는 SDK 도입 기준에 "dSYM 제공 여부"를 넣는 것이 맞다. 디버깅 불가능한 바이너리를 앱에 넣는 것은 운영 리스크다.
Q4. 최적화와 인라이닝은 스택을 어떻게 바꾸는가?
릴리스 빌드에서는 스택이 소스와 1:1로 대응하지 않는다. 컴파일러가 함수를 인라인하면 그 함수의 프레임이 스택에서 사라지고, 꼬리 호출 최적화가 적용되면 호출자 프레임이 대체된다. Apple의 심볼화 도구가 보여주는 [inlined] 표시가 그 흔적이다. 또 최적화된 코드에서는 줄번호가 부정확할 수 있고 — Apple의 도구도 "이 주소는 특정 코드 줄과 연결되지 않는다. 컴파일러 최적화 때문일 수 있다"는 경고를 낸다 — 변수 값도 <unavailable>로 나온다. 실무 함의는 셋이다. 스택 프레임 수를 신뢰하지 말 것, 줄번호를 절대적으로 믿지 말 것, 그리고 인라이닝 변화가 그룹핑을 흔들 수 있다는 것.
CS 원리
인라이닝은 호출 오버헤드를 없애는 대신 코드 크기를 늘리는 최적화다. 호출 명령·프롤로그·에필로그·인자 전달이 사라지고, 호출된 함수의 본문이 호출 지점에 직접 삽입된다.
디버깅 관점에서 이게 만드는 문제는 런타임 스택에 그 함수의 흔적이 없다는 것이다. 스택 프레임은 실제 함수 호출로 만들어지는데, 인라인되면 호출 자체가 없으므로 프레임도 없다.
DWARF는 이 문제를 인라인 정보를 별도로 기록해서 푼다 — "주소 범위 X는 함수 A 안이지만, 사실 함수 B가 A의 15번째 줄에 인라인된 것"이라는 메타데이터를 남긴다. 심볼화 도구가 이 정보를 읽으면 실제로는 없는 프레임을 복원할 수 있다. Apple 문서에서 [inlined]로 표시된 프레임이 정확히 그것이다.
* frame #0: 0x…19b4 TouchCanvas`Swift runtime failure: Index out of range
at Line.swift:0:27 [synthetic] [opt] [inlined]
frame #1: 0x…16cf TouchCanvas`Line.updateRectForExistingPoint(point=<unavailable>)
at <compiler-generated>:0 [synthetic] [opt]
frame #2: 0x…712f TouchCanvas`Line.updateWithTouch(touch=<unavailable>)
at Line.swift:29:24 [synthetic] [opt]
frame #3: 0x…460f TouchCanvas`ViewController.touchesEstimatedPropertiesUpdated(…)
at ViewController.swift:121:20 [synthetic] [opt] [inlined]
여기서 읽어야 할 표식이 넷이다.
[opt]— 최적화된 코드다. 변수 값과 줄번호를 그대로 믿으면 안 된다.[inlined]— 실제 스택 프레임이 아니라 DWARF 인라인 정보로 복원한 프레임이다.[synthetic]— 컴파일러가 생성한 코드다.<unavailable>— 최적화로 변수가 레지스터에서 사라져 값을 알 수 없다.
Apple 도구가 함께 출력하는 경고도 같은 맥락이다 — "최적화와 함께 컴파일되어 스텝 실행이 이상하게 동작할 수 있고 변수를 볼 수 없을 수 있다", 그리고 "이 주소는 특정 코드 줄과 연결되지 않으며, 컴파일러 최적화 때문일 수 있다".
iOS에서는
Swift는 인라이닝이 특히 공격적이라 이 영향이 크다. 몇 가지 Swift 특유의 요인이 있다.
- 제네릭 특수화 — 제네릭 함수가 구체 타입별로 복제되면서 인라인되기 쉬워진다(P0 05장 Q7).
- 프로토콜 witness 제거 — 컴파일러가 구현이 하나뿐임을 증명하면 동적 디스패치를 직접 호출로 바꾸고(devirtualization), 그 다음 인라인한다.
- 작은 함수의 대량 인라인 — 계산 프로퍼티, 짧은 헬퍼, 클로저가 흔히 사라진다.
@inlinable·@_transparent— 모듈 경계를 넘어 인라인되도록 표시된 함수들.
실무에서 이게 드러나는 전형적 상황 셋이다.
(1) "이 함수가 스택에 없다." 분명 그 경로를 지났는데 스택에 안 보인다. 인라인됐을 가능성이 높다. crashlog나 CrashSymbolicator.py처럼 인라인 프레임을 복원하는 도구로 다시 보면 나타난다.
(2) 줄번호가 엉뚱하다. 크래시 줄이 빈 줄이나 관계없는 줄을 가리킨다. 최적화로 명령어 순서가 재배치되면 주소→줄 매핑이 근사치가 된다. 이럴 때는 줄번호 대신 함수 단위로 보고, + 오프셋과 디스어셈블로 좁힌다.
(3) 릴리스마다 스택 모양이 바뀐다. Xcode나 Swift 버전이 올라가면서 인라이닝 결정이 달라지면 프레임 구성이 통째로 변한다. 심볼화가 정상인데도 그룹핑이 갈리는 원인이 될 수 있다(Q3, 18장 Q6).
실험 · 도구
인라이닝이 스택을 바꾸는 것을 직접 확인해보자.
// Demo.swift
func level3() -> Int {
let a = [1, 2, 3]
return a[99] // 여기서 트랩
}
func level2() -> Int { level3() }
func level1() -> Int { level2() }
print(level1())
# 최적화 없이 빌드 — 세 프레임이 모두 보인다
swiftc -Onone -g Demo.swift -o demo-onone
./demo-onone # 크래시
# level3() → level2() → level1() 이 각각 프레임으로 나온다
# 최적화 켜고 빌드 — level2, level3 프레임이 사라진다
swiftc -O -g Demo.swift -o demo-opt
./demo-opt # 크래시
# 스택이 짧아지고, 인라인 정보를 읽는 도구로만 원래 구조가 복원된다
# 인라인 정보를 포함해 심볼화 — [inlined] 표시를 확인한다
xcrun crashlog demo-opt.ips
# 또는 atos에 -i(인라인 프레임 표시) 옵션을 준다
atos -arch arm64 -o demo-opt -l 0x0 -i 0x1234
어떤 함수가 인라인됐는지는 컴파일러에게 물어볼 수도 있다.
# SIL 수준에서 인라인 결과 확인
swiftc -O -emit-sil Demo.swift | grep -A5 'sil.*level1'
# level2/level3 호출이 사라지고 본문이 펼쳐져 있으면 인라인된 것
# 최적화 리마크로 인라인 결정을 보고받는다
swiftc -O -Xllvm -pass-remarks=inline Demo.swift -o /dev/null 2>&1 | head -20
실무 팁 하나 — 특정 함수가 스택에 반드시 남아야 한다면 인라인을 막을 수 있다.
// 크래시 조사에 중요한 경계 함수는 인라인을 막아 스택에 남긴다
@inline(never)
func performCriticalOperation(_ input: Payload) throws -> Result {
// 이 프레임이 스택에 남으므로 "어느 경계에서 실패했나"를 항상 알 수 있다
try process(input)
}
다만 이건 성능과의 맞바꿈이므로 뜨거운 경로에는 쓰지 않는다. 진입점·경계 함수처럼 호출 빈도가 낮고 진단 가치가 큰 곳에만 선택적으로 적용한다.
프로젝트 적용
실무 규칙 넷.
- 줄번호를 단독 근거로 쓰지 않는다. 최적화 빌드에서는 근사치다. 함수명 + 오프셋 + 주변 프레임을 함께 본다.
- 인라인 프레임을 복원하는 도구를 기본으로 쓴다.
CrashSymbolicator.py는 기본 옵션에서 인라인 프레임을 지원한다. 인라인을 복원하지 않는 도구로 보면 중간 단계가 통째로 사라진 스택을 보게 된다. - 중요한 경계에는
@inline(never)를 고려한다. 모듈 진입점, 에러 변환 지점, 외부 SDK 호출 래퍼처럼 "여기를 지났는지가 중요한" 함수들. - Xcode·Swift 버전 업그레이드 후 그룹핑 변화를 확인한다. 인라이닝 결정이 바뀌면 이슈가 갈릴 수 있다. 업그레이드 릴리스에서 "신규 이슈 비율"이 튀면 이 가능성을 의심한다.
"스택에 없으면 그 코드를 안 지났다"가 가장 흔한 오독이다. 인라인된 함수는 실행됐어도 프레임이 없다. "심볼화가 됐으니 줄번호가 정확하다"도 틀렸다 — Apple 도구 자체가 최적화 때문에 주소가 특정 줄과 연결되지 않을 수 있다고 경고한다. "<unavailable>은 도구 버그"도 아니다 — 최적화로 변수가 레지스터에서 제거된 정상 결과다. 또 "@inline(never)를 많이 붙이면 디버깅이 쉬워진다"도 위험하다 — 성능 저하가 실질적이고, 뜨거운 경로에 붙이면 그 자체가 성능 문제가 된다. 마지막으로 "디버그 빌드에서 재현해서 스택을 보면 된다"도 한계가 있다 — 최적화 여부가 버그 재현 자체에 영향을 줄 수 있고(경쟁 조건, 타이밍), 릴리스에서만 나는 버그도 있다.
회사에서 서류가 결재를 받는 과정을 생각해보자. 원래는 대리 → 과장 → 부장 세 단계를 거친다. 그래서 서류 뒷면에 도장이 세 개 찍힌다.
그런데 효율을 높이려고 "대리와 과장 단계는 부장이 한 번에 처리"하도록 바꿨다. 일은 더 빨라진다. 대신 서류에 도장이 하나만 찍힌다.
이제 사고가 났다. 서류를 보니 부장 도장만 있다. 그럼 대리와 과장은 관여 안 한 걸까? 아니다. 그 일은 여전히 처리됐고, 다만 도장을 따로 안 찍었을 뿐이다.
다행히 회사에는 "어떤 단계를 합쳤는지 적어둔 업무 규정집"이 있다(DWARF 인라인 정보). 이걸 같이 보면 "부장 도장 하나가 사실은 세 단계를 합친 것"임을 복원할 수 있다. 좋은 조사 도구는 이 규정집을 함께 본다.
그리고 한 가지 더 — 일을 합치다 보면 순서도 조금씩 섞인다. 그래서 "몇 시 몇 분에 정확히 어느 줄을 처리했나"는 대략적인 값이 된다. 도장 시각을 초 단위까지 믿으면 안 된다.
꼬리 질문
꼬리 호출 최적화(tail call)는 스택을 어떻게 바꾸며, 인라이닝과 무엇이 다른가?
인라이닝은 호출된 함수의 본문을 호출 지점에 삽입하는 것이고, 꼬리 호출 최적화는 호출자의 스택 프레임을 재사용하는 것이다.
함수 A의 마지막 동작이 함수 B를 호출하고 그 결과를 그대로 반환하는 형태라면, B가 끝난 뒤 A가 할 일이 없다. 그래서 컴파일러는 A의 프레임을 B의 프레임으로 덮어쓰고 점프한다. 결과적으로 A의 프레임이 스택에서 사라진다.
디버깅 관점의 차이는 이렇다.
- 인라인은 DWARF에 인라인 정보가 남아 복원 가능하다.
- 꼬리 호출은 프레임이 물리적으로 재사용된 것이라 복원할 정보가 없다. 호출자를 알 방법이 사라진다.
그래서 꼬리 호출이 적용된 경로는 스택이 짧아지고, "누가 이 함수를 불렀나"를 알 수 없게 된다. 재귀가 깊은 코드나 위임 패턴이 많은 코드에서 두드러진다.
대응은 인라인과 비슷하다 — 진단이 중요한 경계에서는 결과를 바로 반환하지 말고 지역 변수에 담았다가 반환하면 꼬리 호출 형태가 깨져 프레임이 남는다. 다만 컴파일러가 이것도 최적화할 수 있으므로 확실하지는 않고, @inline(never)처럼 명시적인 수단이 더 안정적이다.
Swift 런타임 트랩이 [synthetic] 프레임으로 나오는 이유는?
Swift 컴파일러가 안전성 검사를 우리가 쓰지 않은 코드로 자동 삽입하기 때문이다. 배열 인덱스 검사, 정수 오버플로 검사, Optional 강제 언래핑 검사는 소스에 없지만 기계어에는 있다.
이 코드에는 대응하는 소스 줄이 애매하다. Apple 예시에서 Swift runtime failure: Index out of range at Line.swift:0:27처럼 줄 번호가 0으로 나오는 이유가 이것이다 — 컴파일러가 만든 코드라 정확한 소스 줄이 없다. 대신 실패 종류를 문자열로 알려준다.
실무적으로는 이게 매우 유용하다. Index out of range, Unexpectedly found nil while unwrapping an Optional value, Arithmetic overflow 같은 문자열이 그대로 나오므로, 줄번호가 없어도 어떤 종류의 안전성 위반인지는 확실히 알 수 있다. 그 다음 인접한 인앱 프레임에서 위치를 좁힌다.
19장 Q1에서 본 EXC_BREAKPOINT (SIGTRAP)이 이 계열의 크래시이고, 여기까지 이어진다 — Exception Type이 SIGTRAP이면 Swift 런타임 트랩을 의심하고, 심볼화된 리포트에서 [synthetic] 프레임의 실패 메시지를 찾는 것이 판독 순서다.
디버그 빌드에서 재현되지 않는 크래시는 어떻게 접근하는가?
먼저 왜 안 재현되는지를 분류한다.
(1) 최적화가 동작을 바꾼 경우. 정의되지 않은 동작(초기화되지 않은 메모리 사용, 데이터 경쟁)은 최적화 여부에 따라 결과가 달라진다. 이 경우 디버그에서는 "우연히 잘 도는" 것이지 문제가 없는 게 아니다. Address Sanitizer·Thread Sanitizer를 켜면 디버그 빌드에서도 드러난다.
(2) 타이밍이 달라진 경우. 디버그 빌드는 느려서 경쟁 조건이 다른 방향으로 흐른다. 반복 실행이나 스트레스 테스트로 확률을 높인다.
(3) 릴리스에만 있는 조건. #if DEBUG로 갈린 코드, 다른 서버 환경, 다른 인증서. 코드 경로 자체가 다르므로 릴리스 구성으로 로컬 빌드해 재현한다.
실무적으로 가장 유용한 중간 지점은 "릴리스 최적화 + 디버그 심볼 + 디버거 연결"이다. Xcode에서 스킴의 Run 구성을 Release로 바꾸고 DEBUG_INFORMATION_FORMAT을 유지하면, 최적화된 코드를 디버거로 붙여 볼 수 있다. 변수 값이 <unavailable>로 안 보이는 불편은 있지만 동작은 릴리스와 같다.
그래도 안 되면 로그로 좁히는 것이 남은 방법이다 — 의심 경로에 Logger를 심고(18장 Q3, 레벨을 notice 이상으로), TestFlight로 배포해 재현되면 로그를 회수한다.
Q5. 릴리스·환경·배포 채널은 어떻게 태깅해야 하는가?
세 축을 분리해서 붙여야 한다. 릴리스는 "어떤 코드인가"(버전+빌드 번호), 환경은 "어떤 백엔드·설정을 보는가"(production / staging / dev), 배포 채널은 "어떻게 설치됐는가"(App Store / TestFlight / 사내 배포 / 시뮬레이터)다. 셋을 하나로 뭉치면 — 예컨대 환경을 릴리스에 섞으면 — 같은 코드의 문제인데 환경별로 이슈가 갈리거나, 다른 코드인데 하나로 뭉친다. 그리고 릴리스 식별자는 dSYM UUID와 이어져야 한다. "버전 4.12.0"만으로는 부족하다. 같은 버전 문자열로 여러 빌드가 나갈 수 있기 때문이다.
CS 원리
이건 식별자 설계 문제다. 좋은 식별자의 조건은 둘이다.
- 유일성 — 서로 다른 것에 같은 식별자가 붙지 않아야 한다.
- 안정성 — 같은 것에는 항상 같은 식별자가 붙어야 한다.
"버전 문자열"은 이 중 유일성이 깨진다. 4.12.0이라는 마케팅 버전으로 빌드 3, 4, 5가 순차 배포되는 것은 흔한 일이고, 각각은 다른 코드다. 그래서 릴리스 식별자는 최소한 버전 + 빌드 번호여야 한다.
더 엄밀하게는 빌드 UUID가 진짜 식별자다. Q2에서 본 대로 UUID는 컴파일 결과물을 식별하므로, 같은 버전·빌드 번호라도 다시 빌드하면 달라진다. 그래서 실무 구성은 이렇다.
| 축 | 값의 예 | 유일성 | 용도 |
|---|---|---|---|
| 마케팅 버전 | 4.12.0 | 낮음 | 사용자·비즈니스 커뮤니케이션 |
| 릴리스 식별자 | 4.12.0 (1843) | 실무상 충분 | 이슈 추적의 기본 축 |
| 빌드 UUID | e3ea8743-… | 완전 | dSYM 매칭, 심볼화 |
| 커밋 SHA | a1b2c3d | 소스 기준 완전 | 코드 변경 추적 |
이 넷을 모두 이벤트에 실어 두면 각 질문에 답할 수 있다. "어느 버전부터 나빠졌나"는 릴리스 식별자로, "어느 커밋이 원인인가"는 SHA로, "왜 심볼화가 안 되나"는 UUID로 답한다.
iOS에서는
iOS에서 이 값들을 어디서 얻는지 정리하면 이렇다.
import Foundation
enum BuildInfo {
/// 마케팅 버전 — Info.plist의 CFBundleShortVersionString
static let version = Bundle.main
.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
/// 빌드 번호 — Info.plist의 CFBundleVersion
static let build = Bundle.main
.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
/// 릴리스 식별자 — 이슈 추적의 기본 축
static var release: String { "\(version) (\(build))" }
/// 커밋 SHA — 빌드 스크립트가 Info.plist에 주입해 둔다
static let commit = Bundle.main
.object(forInfoDictionaryKey: "GitCommitSHA") as? String ?? "unknown"
/// 배포 채널 — 어떻게 설치됐는가
///
/// ⚠️ 런타임 추론은 근본적으로 휴리스틱이다. 특히
/// embedded.mobileprovision 은 Enterprise 전용이 아니라
/// **Development · Ad Hoc · Enterprise 빌드에 모두** 들어 있고
/// App Store 빌드에만 없다. 그래서 그것만으로는 Enterprise 를 가릴 수 없다.
/// → 정확한 값이 필요하면 **빌드 시점에 주입**하는 편이 안전하다(아래 injected).
static var channel: Channel {
// (권장) 빌드 설정에서 주입한 값이 있으면 그것을 신뢰한다
if let injected = Bundle.main.object(forInfoDictionaryKey: "DistributionChannel") as? String,
let ch = Channel(rawValue: injected) {
return ch
}
#if targetEnvironment(simulator)
return .simulator
#else
// 프로비저닝 프로파일이 없으면 App Store 배포다
guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"),
let raw = try? String(contentsOf: url, encoding: .isoLatin1) else {
return .appStore
}
// Enterprise 는 ProvisionsAllDevices, 개발 빌드는 get-task-allow 로 구분한다
if raw.contains("ProvisionsAllDevices") { return .enterprise }
if raw.contains("get-task-allow") { return .development }
return .testFlight // 그 외 sandboxReceipt 계열 = TestFlight 또는 Ad Hoc
#endif
}
enum Channel: String { case appStore, testFlight, enterprise, development, simulator }
}
/// 환경 — 어떤 백엔드·설정을 보는가. 배포 채널과 독립적이다.
enum AppEnvironment: String {
case production, staging, development
static var current: AppEnvironment {
// 빌드 구성이나 원격 설정으로 결정된다.
// ⚠️ 채널로 추론하지 말 것 — TestFlight 빌드가 production을 볼 수도 있다.
#if PRODUCTION
return .production
#elseif STAGING
return .staging
#else
return .development
#endif
}
}
여기서 채널과 환경을 분리해야 하는 이유가 코드에 드러난다. TestFlight 빌드가 프로덕션 백엔드를 보는 것은 매우 흔한 구성이고(출시 직전 검증), 이때 채널은 testFlight지만 환경은 production이다. 하나로 뭉치면 "프로덕션 크래시" 목록에 TestFlight 크래시가 섞이거나 반대로 빠진다.
덧붙여 Xcode Organizer는 App Store와 TestFlight 데이터를 구분해서 보여준다. 자체 백엔드에서도 같은 구분을 유지해야 두 소스를 대조할 수 있다(19장 Q7).
실험 · 도구
커밋 SHA를 빌드에 주입하는 것은 빌드 스크립트 한 줄이면 된다.
# Xcode의 Run Script Build Phase 또는 CI 스텝
# Info.plist에 커밋 SHA를 심는다
#
# ⚠️ 가드를 맨 앞에 둔다. 뒤에 두면 Debug 빌드에서도 Info.plist 를 이미 고친 뒤
# 빠져나가므로, 막으려던 문제(매 빌드 증분 무효화)가 그대로 발생한다.
[ "$CONFIGURATION" = "Release" ] || exit 0
PLIST="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
SHA=$(git rev-parse --short HEAD)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
/usr/libexec/PlistBuddy -c "Add :GitCommitSHA string $SHA" "$PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Set :GitCommitSHA $SHA" "$PLIST"
/usr/libexec/PlistBuddy -c "Add :GitBranch string $BRANCH" "$PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Set :GitBranch $BRANCH" "$PLIST"
주의 사항이 하나 있다 — 커밋 SHA를 소스에 주입하면 빌드 결과가 매번 달라진다(Q2 꼬리질문의 재현성 논의). Info.plist에만 넣고 소스 코드에는 안 넣으면 컴파일 결과 자체는 영향이 적다.
수집한 축들이 제대로 붙었는지는 이벤트를 직접 확인한다.
// 앱 시작 시 한 번 전역 스코프에 심는다 (18장 Q5)
func configureObservability() {
Observability.setTags([
"release": BuildInfo.release, // "4.12.0 (1843)"
"channel": BuildInfo.channel.rawValue,
"env": AppEnvironment.current.rawValue,
// ⚠️ commit SHA는 카디널리티가 릴리스와 1:1이라 태그로 둬도 되지만,
// 릴리스 태그가 이미 있으므로 컨텍스트로 두는 편이 태그 예산을 아낀다
])
Observability.setContext("build", [
"commit": BuildInfo.commit,
"branch": Bundle.main.object(forInfoDictionaryKey: "GitBranch") as? String ?? "",
"os": ProcessInfo.processInfo.operatingSystemVersionString,
])
}
프로젝트 적용
태깅 설계에서 결정할 것 넷.
- 릴리스 식별자 포맷을 하나로 고정한다.
4.12.0 (1843)인지4.12.0+1843인지 팀 안에서 통일한다. 포맷이 흔들리면 같은 릴리스가 두 축으로 갈린다. - 환경을 채널로 추론하지 않는다. 빌드 구성에서 명시적으로 결정한다.
- 개발·시뮬레이터 데이터를 프로덕션 프로젝트로 보내지 않는다. 노이즈가 되고, 개발 중 일부러 낸 크래시가 지표를 오염시킨다. 프로젝트를 분리하거나 최소한 환경 필터를 기본값으로 건다.
- 릴리스 등록을 CI에서 자동화한다. 대부분의 백엔드가 "릴리스 생성 + 커밋 연결" API를 제공한다. 이걸 붙이면 새 이슈가 어느 커밋에서 유입됐는지 자동으로 추정된다.
그리고 dSYM 업로드와 릴리스 등록을 같은 CI 스텝에 묶는 것이 실용적이다. 둘이 짝이 안 맞으면 심볼화도 릴리스 추적도 깨지므로, 하나로 묶어 실패 시 함께 실패시킨다.
"버전만 태그하면 충분하다"가 흔한 부족이다. 같은 마케팅 버전으로 여러 빌드가 나가므로 빌드 번호 없이는 어느 코드인지 특정되지 않는다. "TestFlight 빌드는 어차피 소수니 프로덕션과 섞여도 된다"도 위험하다 — TestFlight 사용자는 베타 기능을 쓰고 크래시 확률이 높아 지표를 왜곡한다. "환경은 채널로 알 수 있다"도 틀렸다 — TestFlight 빌드가 프로덕션 백엔드를 보는 구성이 일반적이다. 또 "커밋 SHA를 넣으면 어떤 코드인지 완전히 특정된다"도 절반만 맞다 — 같은 커밋을 다른 Xcode 버전으로 빌드하면 바이너리가 다르다. 완전한 특정은 여전히 빌드 UUID다.
약 봉지에 붙는 라벨을 생각해보자. 라벨에는 여러 정보가 따로따로 적혀 있다.
- 제품명("감기약 4.12") — 사람들이 부르는 이름. 그런데 같은 이름으로 여러 번 생산된다.
- 제조 번호("LOT 1843") — 몇 번째 생산분인지. 문제가 생기면 이 번호로 회수한다.
- 유통 경로("약국용" / "병원용" / "샘플") — 어떻게 사람 손에 들어갔는지.
- 보관 조건("실온" / "냉장") — 어떤 환경에서 쓰였는지.
이걸 하나로 뭉치면 어떻게 될까? "감기약 4.12"라고만 적으면, 문제가 생겼을 때 어느 생산분인지 몰라 전량 회수해야 한다. 반대로 제품명 없이 제조 번호만 적으면 사람들이 무슨 약인지 모른다.
특히 헷갈리기 쉬운 게 유통 경로와 보관 조건이다. "샘플로 받은 약"이 "냉장 보관"일 수도 있고 "실온"일 수도 있다. 둘은 다른 축이다. "샘플이면 실온이겠지"라고 추측하면 틀린다.
그래서 라벨에는 이 정보들이 각자 자기 칸에 적혀 있어야 한다. 그래야 "3번 생산분, 약국 경로, 냉장 보관"에서만 문제가 났다는 걸 정확히 짚어낼 수 있다.
꼬리 질문
커밋 SHA를 이벤트에 실으면 무엇을 추가로 할 수 있는가?
세 가지가 가능해진다.
(1) 유입 커밋 자동 추정. 백엔드가 릴리스와 커밋 목록을 알고 있으면, 새 이슈가 처음 나타난 릴리스의 커밋 범위를 후보로 제시할 수 있다. 파일 경로가 스택에 있으면 그 파일을 만진 커밋으로 더 좁혀진다. 20장 Q7의 "문제가 처음 나타난 버전 찾기 → 그 사이 변경으로 범위 축소"를 자동화하는 셈이다.
(2) 담당자 자동 배정. 유입 커밋의 작성자에게 이슈를 배정하는 워크플로가 가능하다. 다만 이건 팀 문화에 따라 역효과가 날 수 있어 신중해야 한다 — 비난 문화로 흐르면 아무도 리팩터링을 안 하게 된다.
(3) 핫픽스 검증. 핫픽스를 배포한 뒤 "그 커밋 이후 빌드에서 해당 이슈가 사라졌는지"를 정확히 확인할 수 있다. 버전만으로는 핫픽스가 실제로 그 빌드에 들어갔는지 확신하기 어렵다.
주의할 점은 브랜치 전략과의 상호작용이다. 릴리스 브랜치에서 체리픽을 많이 하면 커밋 순서와 릴리스 순서가 어긋나 자동 추정이 틀릴 수 있다. 추정은 후보 제시로 쓰고 최종 판단은 사람이 하는 것이 안전하다.
개발 빌드 데이터를 완전히 차단하는 것과 별도 프로젝트로 분리하는 것 중 무엇이 나은가?
상황에 따라 다르지만 별도 프로젝트로 분리하는 쪽이 대체로 낫다.
완전 차단의 장점은 단순함이다. 개발 중 데이터가 아예 안 나가므로 프로덕션 지표가 깨끗하고, 실수로 개인정보가 전송될 위험도 없다. 단점은 계측 자체가 동작하는지 검증할 수 없다는 것이다. "심볼화가 되나", "태그가 제대로 붙나", "breadcrumb이 유용한가"를 프로덕션에 나가서야 확인하게 된다.
별도 프로젝트의 장점은 계측 파이프라인 자체를 개발 중에 검증할 수 있다는 것이다. 새 태그를 추가하거나 그룹핑 규칙을 바꿀 때 개발 프로젝트에서 먼저 확인할 수 있다. 단점은 관리 대상이 늘고 비용이 조금 는다.
실무 절충안은 이렇다 — 개발·시뮬레이터는 별도 프로젝트로, 프로덕션과 TestFlight는 같은 프로젝트에 환경 태그로 구분. TestFlight를 분리하지 않는 이유는 출시 전 검증 데이터를 프로덕션과 같은 기준으로 보고 싶기 때문이고, 태그가 있으면 필요할 때 나눌 수 있다.
그리고 개발 프로젝트의 데이터 보관 기간은 짧게 잡는다. 검증 목적이라 오래 둘 이유가 없고 비용만 든다.
같은 앱 버전이 여러 UUID를 갖는 상황은 언제 생기며 어떻게 다루는가?
몇 가지 경우에 생긴다.
(1) 재빌드 후 재업로드. 심사 반려나 업로드 실패로 같은 버전·빌드 번호를 다시 빌드해 올리면 UUID가 달라진다. 빌드 번호를 올리는 것이 원칙이지만 실수가 생긴다.
(2) 아키텍처 슬라이스. Q2 꼬리질문에서 본 대로 arm64·arm64e가 각각 UUID를 갖는다. 이건 정상이다.
(3) 여러 바이너리. 앱 본체·익스텐션·프레임워크가 각자 UUID를 갖는다. 이것도 정상이며, 하나의 릴리스가 여러 UUID를 갖는 기본 상태다.
다루는 방법은 릴리스 하나에 UUID 집합을 매핑하는 것이다. CI에서 만든 dsym-uuids.txt(Q2)를 릴리스 메타데이터로 함께 저장하면, 나중에 "이 UUID는 어느 릴리스 것인가"를 역조회할 수 있다.
(1)이 문제가 되는 이유는 어느 빌드가 실제로 배포됐는지 모호해지기 때문이다. 사용자 기기에서 온 크래시의 UUID로 역조회했더니 두 후보가 나오면, 어느 코드인지 확정할 수 없다. 예방책은 단순하다 — 빌드 번호를 CI가 자동 증가시켜 재사용을 원천 차단한다.
Q6. dSYM 파이프라인을 CI에서 어떻게 보장하는가?
핵심 원칙은 "조용한 실패를 없애는 것"이다. dSYM 파이프라인이 깨지는 이유는 대부분 실패가 경고로만 남고 빌드는 성공하기 때문이다. 그래서 네 단계를 강제한다. (1) 생성 보장 — DEBUG_INFORMATION_FORMAT이 dwarf-with-dsym인지 빌드 전 검증. (2) 개수 검증 — 앱·익스텐션·프레임워크의 dSYM 개수가 예상과 일치하는지. (3) 업로드 강제 — 실패 시 빌드 실패. (4) 사후 확인 — 백엔드에 등록된 UUID를 조회해 대조하고, 릴리스 후 24시간 심볼화율을 점검. 특히 (2)와 (4)가 실무에서 가장 자주 빠지고, 그래서 가장 많이 터진다.
CS 원리
이건 페일 세이프(fail-safe) 설계의 문제다. 시스템이 실패했을 때 안전한 쪽으로 실패하도록 만드는 것이다.
dSYM 파이프라인의 기본 상태는 페일 사일런트(fail-silent)다.
- dSYM을 안 만들어도 빌드는 성공한다.
- 업로드에 실패해도 앱은 정상 배포된다.
- 심볼화가 안 돼도 크래시 리포트는 온다.
- 사용자는 아무 영향을 받지 않는다.
즉 실패 신호가 어디에도 나타나지 않는다. 문제는 몇 주 뒤 "크래시를 조사하려는 순간"에야 드러나고, 그때는 이미 그 빌드의 dSYM이 사라졌을 수 있다.
이런 종류의 시스템을 안전하게 만드는 표준적 방법이 검증 지점을 명시적으로 삽입하는 것이다. 자연적으로 실패가 드러나지 않으므로, 일부러 확인하고 일부러 실패시켜야 한다.
그리고 검증은 가능한 한 빨리 배치한다. 빌드 시점 검증이 배포 후 검증보다 낫고, 배포 후 검증이 사고 시점 발견보다 낫다.
iOS에서는
단계별로 무엇을 검증할지 구체화하면 이렇다.
| 게이트 | 검증 내용 | 실패 시 |
|---|---|---|
| 1. 설정 | DEBUG_INFORMATION_FORMAT = dwarf-with-dsym | 빌드 중단 |
| 2. 생성 | 아카이브의 dSYM 개수 = 기대값 | 빌드 실패 |
| 2b. UUID 기록 | 모든 dSYM의 UUID를 매니페스트로 저장 | — |
| 3. 업로드 | 백엔드 업로드 명령 종료 코드 0 | 빌드 실패 |
| 3b. 아티팩트 | 아카이브를 장기 보관소에 복사 | 빌드 실패 |
| 4. 사후 | 백엔드 등록 UUID ⊇ 배포 UUID | 배포 차단 또는 알람 |
| 5. 운영 | 릴리스 후 24h 심볼화율 ≥ 임계 | 알람 |
게이트 2에서 기대 개수를 어떻게 정하는가가 실무 포인트다. 하드코딩하면 타깃을 추가할 때 깨지고, 안 하면 검증 의미가 없다. 절충안은 프로젝트 설정에서 동적으로 계산하는 것이다.
#!/bin/bash
set -euo pipefail
ARCHIVE="$1"
# ── 게이트 1: 빌드 설정 검증 (빌드 전에 실행) ──
fmt=$(xcodebuild -showBuildSettings -scheme YourApp -configuration Release \
| awk -F' = ' '/DEBUG_INFORMATION_FORMAT/{print $2}' | tr -d ' ')
if [ "$fmt" != "dwarf-with-dsym" ]; then
echo "❌ DEBUG_INFORMATION_FORMAT=$fmt — dSYM이 생성되지 않는다"; exit 1
fi
# ── 게이트 2: 개수 검증 (아카이브 후) ──
# 기대값을 하드코딩하지 않고, 아카이브 안 실행 바이너리 개수로 계산한다
expected=$(find "$ARCHIVE/Products/Applications" \
\( -name '*.app' -o -name '*.appex' -o -name '*.framework' \) | wc -l | tr -d ' ')
actual=$(find "$ARCHIVE/dSYMs" -maxdepth 1 -name '*.dSYM' | wc -l | tr -d ' ')
echo "dSYM $actual개 / 바이너리 $expected개"
if [ "$actual" -lt "$expected" ]; then
echo "❌ dSYM 누락 — 다음 바이너리의 dSYM이 없다:"
# 어느 것이 빠졌는지까지 알려준다
comm -23 \
<(find "$ARCHIVE/Products" \( -name '*.app' -o -name '*.appex' -o -name '*.framework' \) \
-exec basename {} \; | sed 's/\.[^.]*$//' | sort) \
<(find "$ARCHIVE/dSYMs" -name '*.dSYM' -exec basename {} .dSYM \; \
| sed 's/\.[^.]*$//' | sort)
exit 1
fi
# ── 게이트 2b: UUID 매니페스트 ──
: > dsym-uuids.txt
while IFS= read -r d; do
dwarfdump --uuid "$d" >> dsym-uuids.txt
done < <(find "$ARCHIVE/dSYMs" -name '*.dSYM')
echo "── UUID 매니페스트 ──"; cat dsym-uuids.txt
# ── 게이트 3: 업로드 (실패 시 즉시 중단 — set -e 가 처리) ──
upload-dsyms "$ARCHIVE/dSYMs"
# ── 게이트 3b: 장기 보관 ──
tar czf "archive-$(git rev-parse --short HEAD).tar.gz" "$ARCHIVE"
put-to-long-term-storage "archive-$(git rev-parse --short HEAD).tar.gz"
# ── 게이트 4: 백엔드 등록 확인 ──
sleep 30 # 백엔드 처리 대기
backend_uuids=$(fetch-registered-uuids)
missing=$(comm -23 \
<(grep -o '[0-9A-F-]\{36\}' dsym-uuids.txt | tr 'A-Z' 'a-z' | sort -u) \
<(echo "$backend_uuids" | tr 'A-Z' 'a-z' | sort -u))
if [ -n "$missing" ]; then
echo "❌ 백엔드에 등록되지 않은 UUID:"; echo "$missing"; exit 1
fi
echo "✅ dSYM 파이프라인 전 단계 통과"
실험 · 도구
게이트가 실제로 동작하는지 일부러 깨뜨려 확인해야 한다. 이건 소방 훈련과 같다.
# 훈련 1: dSYM 생성을 끄고 빌드해본다 → 게이트 1이 잡아야 한다
xcodebuild archive -scheme YourApp -configuration Release \
DEBUG_INFORMATION_FORMAT=dwarf # dSYM 없이
# → CI 스크립트가 "❌ DEBUG_INFORMATION_FORMAT=dwarf" 로 실패해야 정상
# 훈련 2: 익스텐션 dSYM을 지우고 검증을 돌린다 → 게이트 2가 잡아야 한다
rm -rf build/YourApp.xcarchive/dSYMs/ShareExtension.appex.dSYM
./ci/verify-dsyms.sh build/YourApp.xcarchive
# → "❌ dSYM 누락" 과 함께 ShareExtension 이름이 출력돼야 정상
# 훈련 3: 업로드 엔드포인트를 잘못 지정한다 → 게이트 3이 잡아야 한다
UPLOAD_URL=https://invalid.example ./ci/upload.sh
# → 종료 코드가 0이 아니어야 정상
이 훈련을 주기적으로(예: 분기마다) 돌려야 하는 이유는, 게이트 자체가 조용히 망가질 수 있기 때문이다. 스크립트가 || true로 감싸지거나, 조건문이 항상 참이 되도록 잘못 수정되는 일이 흔하다.
운영 지표 쪽도 검증한다.
# 릴리스 후 24시간 자동 점검 (CI 스케줄 잡)
VERSION=$(latest-released-version)
rate=$(backend-query "symbolication_rate for version=$VERSION")
count=$(backend-query "crash_count for version=$VERSION")
# 표본이 너무 적으면 판단을 미룬다 — 조기 오탐 방지
if [ "$count" -lt 20 ]; then
echo "표본 부족($count건) — 판단 보류"; exit 0
fi
if [ "${rate%.*}" -lt 80 ]; then
alert "❌ $VERSION 심볼화율 ${rate}% (표본 ${count}건) — dSYM 확인 필요"
exit 1
fi
echo "✅ $VERSION 심볼화율 ${rate}%"
프로젝트 적용
도입 순서를 우선순위대로 정리하면 이렇다. 한 번에 다 하기 어려우면 위에서부터 한다.
- 아카이브 장기 보관 — 이것만 있으면 나머지는 나중에 복구 가능하다. 가장 먼저 넣는다.
- 업로드 실패 시 빌드 실패 — 한 줄 수정(
|| exit 1)으로 큰 효과. - 릴리스 후 심볼화율 점검 — 게이트가 새어도 24시간 안에 잡힌다. 비용 대비 효과가 가장 크다.
- 개수 검증 — 익스텐션 추가 시 누락을 잡는다. 타깃이 여러 개인 프로젝트에서 필수.
- 백엔드 등록 확인 — 가장 정확하지만 백엔드 API에 의존한다.
- 게이트 훈련 — 분기 1회.
그리고 이 파이프라인의 소유자를 명시해두는 것이 중요하다. 관측 인프라는 "모두의 것이라 아무의 것도 아닌" 영역이 되기 쉽고, 그러면 깨져도 아무도 안 고친다. 릴리스 담당이나 플랫폼 팀 중 한 곳에 명시적으로 귀속시킨다.
"업로드 스크립트를 넣었으니 됐다"가 가장 위험한 안심이다. 업로드 실패가 경고로만 남고 빌드가 성공하면 없는 것과 같다. "빌드가 성공했으니 dSYM도 잘 갔다"도 틀렸다 — 두 사건은 독립적이다. "백엔드가 dSYM을 영구 보관한다"도 확인이 필요하다 — 보관 기간 정책이 있는 경우가 많아 우리 쪽 원본 보관은 별도로 유지해야 한다. "심볼화율은 100%여야 한다"도 비현실적이다 — 서드파티 프레임워크나 시스템 심볼 미수신 때문에 일정 비율은 항상 미심볼화로 남는다. 그래서 절대값보다 급락 감지가 실용적이다. 마지막으로 "게이트를 넣었으니 이제 안전하다"도 방심이다 — 게이트 자체가 조용히 무력화될 수 있어 주기적 훈련이 필요하다.
소화기를 생각해보자. 건물에 소화기를 비치하는 건 쉽다. 문제는 정말 필요한 순간에 작동하느냐다.
소화기가 고장 나도 아무 일도 안 일어난다. 알람이 울리지도 않고, 건물 운영에 지장도 없고, 아무도 모른다. 불이 나는 그 순간까지는. 그리고 그때는 이미 늦었다.
그래서 소방 규정에는 정기 점검이 들어 있다. 압력계를 확인하고, 유효기간을 보고, 기록을 남긴다. 아무 일도 안 일어나는데 굳이 확인하는 것이다.
dSYM도 똑같다. 없어도 앱은 잘 돌고 사용자는 아무 불편이 없다. 크래시 리포트도 계속 온다. 그걸 읽으려는 순간까지는 아무 문제가 없어 보인다.
그래서 세 가지를 해야 한다. 첫째, 만들어졌는지 확인한다. 둘째, 제자리에 보관됐는지 확인한다. 셋째, 가끔 일부러 고장 내보고 점검 절차가 실제로 잡아내는지 확인한다.
세 번째가 특히 중요하다. 점검표에 체크만 하고 실제로는 안 보는 경우가 생기기 때문이다. 점검 절차 자체를 점검해야 한다.
꼬리 질문
dSYM 업로드를 빌드 실패로 만들면 배포가 막히는데, 급한 핫픽스 상황에서는 어떻게 하는가?
원칙과 예외를 명시적으로 설계해야 한다. 무조건 막으면 우회 방법이 암묵적으로 생기고, 그 우회가 일상이 된다.
실무적 구성은 이렇다.
(1) 순서를 바꾼다. dSYM 업로드를 배포보다 먼저 실행한다. 업로드가 성공해야 배포로 넘어가는 구조면, 실패해도 배포되지 않은 상태에서 멈추므로 되돌릴 것이 없다. 급하면 업로드 문제만 고치고 재시도하면 된다.
(2) 명시적 우회 플래그를 둔다. SKIP_DSYM_UPLOAD=1 같은 환경 변수로 건너뛸 수 있게 하되, 사용하면 알람이 울리고 티켓이 자동 생성되게 한다. 우회 자체를 막는 게 아니라 보이게 만드는 것이 목적이다.
(3) 아카이브 보관은 절대 건너뛰지 않는다. 업로드는 나중에 다시 할 수 있지만, 아카이브를 안 남기면 복구가 불가능하다. 이 단계만은 우회 불가로 둔다.
핵심은 "복구 가능한 실패"와 "복구 불가능한 실패"를 구분하는 것이다. 업로드 실패는 복구 가능하므로 우회를 허용해도 되지만, 아카이브 유실은 복구 불가능하므로 허용하면 안 된다.
심볼화율 임계를 몇 %로 잡아야 하며, 100%가 안 되는 정상적 이유는?
100%가 안 되는 정상적 이유가 여럿 있다.
- 서드파티 프레임워크 dSYM 미보유 (Q3 꼬리질문)
- 시스템 프레임에서만 발생한 크래시 — 인앱 프레임이 애초에 없다
- 새 iOS 버전의 기기 심볼 미수신
- 비동기 스택 절단으로 인앱 프레임이 없는 경우(18장 Q6)
- 손상된 리포트 — 전송 중 잘리거나 파싱 실패
그래서 절대 임계보다 기준선 대비 변화가 실용적이다. 방법은 이렇다.
(1) 안정 구간의 기준선을 측정한다. 파이프라인이 정상일 때 몇 %인지 몇 개 릴리스에 걸쳐 잰다. 앱마다 다르지만 대개 85~95% 범위다(커뮤니티·경험칙). 절대값보다 자기 앱의 기준선을 재서 쓰는 것이 맞다.
(2) 기준선 대비 하락폭으로 알람을 건다. "기준선보다 10%p 이상 낮으면" 같은 식이다. 절대값 80%를 임계로 걸었는데 우리 기준선이 82%면 알람이 늘 아슬아슬하고, 기준선이 95%면 88%로 떨어져도 안 울린다.
(3) 표본 수 하한을 둔다. 릴리스 직후 크래시가 몇 건뿐이면 비율이 크게 흔들린다. 최소 건수(예: 20건)를 넘을 때만 판단한다.
또 하나 유용한 보조 지표가 "우리 앱 이미지의 프레임 중 심볼화된 비율"이다. 전체 프레임이 아니라 우리 바이너리에 속한 프레임만 보면, 서드파티·시스템 요인이 제거되어 우리 dSYM 문제만 선명하게 드러난다.
모노레포나 여러 앱을 배포하는 조직에서 이 파이프라인을 어떻게 확장하는가?
세 가지 원칙으로 확장한다.
(1) 검증 로직을 공유 스크립트로 추출한다. 앱마다 CI 설정을 복사하면 하나만 고치고 나머지를 놓치는 일이 반드시 생긴다. verify-dsyms.sh를 공용 저장소나 공유 CI 템플릿에 두고 앱은 호출만 한다. 기대 개수처럼 앱별로 다른 값은 인자나 설정 파일로 넘긴다.
(2) UUID 매니페스트를 중앙에 모은다. 앱·릴리스·UUID·커밋을 한 테이블에 쌓아두면, 나중에 어떤 앱의 어떤 UUID든 역조회할 수 있다. 크래시 리포트만 있고 어느 앱·어느 릴리스인지 모르는 상황에서 결정적이다.
(3) 지표를 앱별로 나누되 대시보드는 하나로 둔다. "전 앱의 심볼화율" 한 화면이 있으면 특정 앱만 깨진 것을 즉시 안다. 앱별로 대시보드가 흩어져 있으면 아무도 안 보는 앱이 생긴다.
추가로 공유 프레임워크의 dSYM이 까다롭다. 여러 앱이 같은 내부 프레임워크를 쓰면, 그 프레임워크는 앱마다 다시 빌드되어 앱마다 다른 UUID를 갖는다. 그래서 "프레임워크 dSYM을 한 번만 올리면 된다"고 생각하면 안 되고, 각 앱의 아카이브에 포함된 것을 각각 올려야 한다.
사전 빌드된 바이너리로 배포하는 구조라면 반대로 한 번 빌드된 것이 여러 앱에 공유되므로 UUID가 같고, 한 번만 올리면 된다. 어느 구조인지 아는 것이 중요하다.