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

21 · 심볼화와 릴리스 추적

주소가 함수명이 되는 과정, build UUID로 묶이는 dSYM, 완전·부분·미심볼화의 판별, 인라이닝이 스택을 바꾸는 방식, 릴리스 태깅과 CI 보장.
진행률
0 / 0 완료

18~20장에서 모은 크래시와 행 데이터는 주소 목록이다. 0x1046a59b4 같은 숫자를 Line.updateRectForExistingPoint(_:)로 바꾸지 못하면 아무것도 조사할 수 없다. 이 챕터는 그 변환 — 심볼화 — 이 무엇에 의존하고 왜 조용히 실패하는지를 다룬다. P0 14장 Q5가 "dSYM과 심볼화가 연결된다"까지였다면, 여기서는 왜 UUID가 안 맞고, 인라이닝이 스택을 어떻게 바꾸며, CI에서 무엇을 보장해야 하는지까지 내려간다.

Q1. 주소가 함수 이름이 되는 과정은 무엇인가?

🔑 30초 답변

크래시 리포트의 각 프레임은 (바이너리 이미지, 그 이미지 시작점으로부터의 오프셋) 쌍이다. 심볼화는 이 오프셋을 디버그 정보 파일(dSYM)의 주소→심볼 매핑에 넣어 함수명·파일명·줄번호를 찾는 과정이다. 실행 시 ASLR로 이미지가 무작위 주소에 로드되므로 절대 주소는 매번 다르지만, 이미지 시작점 기준 오프셋은 항상 같다 — 그래서 리포트에 0x1045a19b4 0x10459c000 + 22964처럼 절대주소·이미지시작·오프셋이 함께 찍힌다. 마지막 숫자 22964가 심볼화의 실제 입력이다.

CS 원리

컴파일러는 소스를 기계어로 바꾸면서 이름을 잃어버린다. CPU는 함수 이름을 모르고 주소만 안다. 그래서 디버깅을 위해 별도의 매핑 테이블을 만들어 둔다 — 이것이 디버그 심볼이고, 표준 포맷이 DWARF다.

매핑에 필요한 것은 세 가지다.

실행 시점의 문제는 ASLR이다. 보안을 위해 이미지가 매 실행마다 다른 베이스 주소에 로드되므로, 크래시 시점의 절대 주소는 컴파일 시점 주소와 무관하다. 해결은 단순하다 — 상대 오프셋으로 변환한다.

오프셋 = 크래시 시점 절대 주소 − 그 이미지의 로드 베이스 주소

0x1045a19b4 − 0x10459c000 = 22964

이 오프셋은 ASLR과 무관하게 같은 바이너리라면 항상 같다. 그래서 크래시 리포트의 Binary Images 섹션이 결정적이다 — 여기에 각 이미지의 로드 베이스 주소와 UUID가 적혀 있고, UUID로 올바른 dSYM을 고른 뒤 오프셋으로 조회하면 이름이 나온다.

크래시 리포트 프레임 0x1045a19b4 0x10459c000 + 22964 오프셋만 추출 22964 ASLR과 무관하게 항상 같음 Binary Images 섹션 UUID E3EA8743-C9E6-… 로드 베이스 0x10459c000 UUID로 dSYM 선택 UUID가 다르면 여기서 실패 DWARF 조회 오프셋 → 심볼 · 파일 · 줄 + 인라인 정보 Line.updateRect…(_:) Line.swift:230 UUID가 다르면 조회 자체가 안 되고, 프레임은 주소로 남는다. 즉 심볼화 실패는 "찾다 실패"가 아니라 "찾을 자료가 없음"이다.
심볼화의 실제 입력은 절대 주소가 아니라 이미지 기준 오프셋이다. 그래서 ASLR이 있어도 매핑이 가능하다.

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     ← 시스템: 심볼 있음
…

여기서 어느 쪽에 심볼이 없는지가 진단을 가른다.

실험 · 도구

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단계다. 여기서 다르면 그 뒤 무엇을 해도 안 된다.

프로젝트 적용

팀에서 정해둘 것 셋.

⚠️ 흔한 오해

"크래시 리포트의 주소를 그대로 찾으면 된다"는 틀렸다. 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).

쉽게 말하면 "김OO 집에서 3미터 지점"이라는 표현이다. 현관인지 안방인지가 이걸로 갈린다. 그리고 여러 사고가 전부 같은 3미터 지점에서 났다면, 그 자리에 뭔가 있다는 뜻이다.
시스템 프레임워크의 심볼은 우리가 dSYM을 갖고 있지 않은데 어떻게 나오는가?

Apple이 기기 심볼(device symbols)을 별도로 제공하기 때문이다. iOS 버전마다 시스템 프레임워크의 심볼 정보가 있고, Xcode는 기기를 Mac에 연결하면 그 기기의 OS 버전에 해당하는 심볼을 자동으로 내려받아 로컬에 캐시한다.

그래서 다음 상황들이 설명된다.

  • 새 iOS 베타에서 온 리포트가 시스템 프레임만 주소로 나온다 → 그 버전의 심볼을 아직 못 받았다. 해당 버전 기기를 한 번 연결하면 해결되는 경우가 많다.
  • 다른 사람 Mac에서는 되는데 내 Mac에서는 안 된다 → 심볼 캐시가 로컬에 있기 때문이다.
  • Xcode를 업데이트했더니 갑자기 잘 된다 → Xcode에 최신 OS 심볼이 함께 배포되기도 한다.

다만 시스템 프레임의 심볼화는 진단에 보조적이다. 우리가 고칠 수 있는 것은 인앱 프레임이므로, 시스템 프레임이 주소로 남아 있어도 인앱 프레임만 제대로 나오면 대부분 조사가 가능하다. Apple이 "부분 심볼화도 크래시를 이해하기에 충분할 수 있다"고 하는 이유다.

반대로 인앱 프레임이 주소인데 시스템 프레임만 이름이 나오는 상황이 훨씬 심각하다 — 우리 dSYM 문제이고, 이건 우리가 고쳐야 한다.

쉽게 말하면 아파트 명부는 우리가 갖고 있는데, 공용 시설(엘리베이터·주차장) 도면은 관리사무소가 갖고 있다. 관리사무소에서 도면을 한 번 받아두면 그 다음부터는 잘 보인다. 새로 지은 시설이면 도면을 아직 못 받았을 수 있다.
crashlogCrashSymbolicator.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은 무엇이고 왜 빌드마다 달라지는가?

🔑 30초 답변

디버그 빌드는 심볼을 바이너리 안에 넣지만, 릴리스 빌드는 배포 크기를 줄이려고 별도 파일(dSYM)로 분리한다. 그리고 앱의 각 바이너리 — 메인 실행 파일, 프레임워크, 앱 익스텐션 — 가 각자 자기 dSYM을 갖는다. 바이너리와 dSYM은 build UUID로 묶이며, 양쪽에 같은 UUID가 기록된다. Apple 문서가 명시하듯 같은 소스라도 Xcode 버전이나 빌드 설정이 다르면 UUID가 달라지고, UUID가 다른 바이너리와 dSYM은 서로 호환되지 않는다. 그래서 배포한 빌드의 dSYM을 반드시 보관해야 하고, 나중에 같은 소스로 다시 빌드해도 그 dSYM은 쓸 수 없다.

CS 원리

여기서 작동하는 원리는 콘텐츠 기반 식별이다. UUID는 "이 소스 코드"가 아니라 "이 컴파일 결과물"을 식별한다. 왜 이래야 하는지는 반대를 상상해보면 명확하다 — 만약 소스 버전으로 식별한다면, 같은 소스를 다른 컴파일러로 빌드했을 때 주소 배치가 완전히 달라진 바이너리에 옛 매핑을 적용하게 되어 엉뚱한 함수명이 나온다. 틀린 답이 나오는 것은 못 찾는 것보다 훨씬 나쁘다.

그래서 시스템은 보수적으로 설계됐다 — UUID가 정확히 일치할 때만 매칭하고, 아니면 실패한다. 이 설계가 "왜 이렇게 까다롭냐"의 답이다.

같은 소스라도 빌드가 다르면 UUID가 다르다 — 그래서 사후 재생성이 안 된다 같은 소스 커밋 a1b2c3d 한 벌뿐 Xcode 16 Xcode 17 바이너리 A UUID e3ea…689d 바이너리 B UUID 7f10…c42a dSYM A UUID e3ea…689d dSYM B UUID 7f10…c42a ✓ 심볼화 ✕ 불일치 (A 리포트에 B dSYM) UUID를 바꾸는 것들 — 소스가 아니라 컴파일 결과물을 식별한다 Xcode·Swift 버전 최적화 레벨 빌드·링커 플래그 의존성 버전 불일치는 틀린 이름이 나오는 게 아니라 아예 실패한다 — 틀린 답이 더 나쁘기 때문이다. 그래서 배포한 그 빌드의 dSYM을 그때 보관하는 것 외에 방법이 없다.
UUID는 소스가 아니라 컴파일 결과물을 식별한다. 나중에 같은 소스로 다시 빌드해도 그 dSYM은 쓸 수 없다.

UUID를 바꾸는 요인은 생각보다 많다.

마지막 항목이 자주 놓치는 지점이다. dwarfdump --uuid를 돌리면 arm64·arm64e 등 아키텍처별로 여러 UUID가 출력된다. 리포트의 UUID는 그중 하나와 맞아야 한다.

iOS에서는

dSYM 생성과 전달 경로를 정확히 알아두면 문제 진단이 빨라진다.

단계무엇이 일어나는가확인 포인트
빌드DEBUG_INFORMATION_FORMATDWARF with dSYM File이어야 dSYM이 생성된다배포 전 이 설정 확인
아카이브Xcode가 모든 바이너리와 dSYM을 아카이브 안에 모은다아카이브를 반드시 보관
업로드App Store Connect 업로드 시 심볼 포함 여부를 선택포함해야 Organizer가 자동 심볼화
수신심볼을 안 올려도 크래시 리포트는 온다 — 심볼명 없이Mac에 올바른 dSYM이 있으면 Xcode가 붙여준다

Apple이 특히 강조하는 문장이 있다 — 배포하는 각 빌드의 Xcode 아카이브를 반드시 보관해야 한다. 이 아카이브가 없으면 크래시 리포트로 문제를 진단하지 못할 수 있다.

실무에서 이게 무너지는 전형적 경로는 이렇다.

실험 · 도구

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을 찾아 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와 바이너리가 다르다. 최신 기기와 구형 기기에서 온 리포트가 서로 다른 슬라이스를 참조할 수 있다.

쉽게 말하면 같은 이름의 아파트가 A타입·B타입으로 나뉘어 있고, 명부도 타입별로 따로다. "명부 있으니 됐지" 하고 A타입 명부만 챙기면, B타입 사고는 못 찾는다. 사고 기록에 어느 타입인지 적혀 있으니 그걸 보고 골라야 한다.
dSYM 파일이 큰데 보관 기간을 어떻게 정해야 하는가?

기준은 "그 버전을 쓰는 사용자가 남아 있는 기간"이다. 크래시 리포트는 사용자가 앱을 실행할 때 올라오므로, 아주 오래된 버전에서도 리포트가 온다. 업데이트를 안 하는 사용자가 있는 한 계속 온다.

실용적 기준을 정하는 방법은 이렇다.

(1) 버전별 활성 사용자 비율을 본다. 특정 버전의 사용자가 전체의 0.1% 아래로 떨어지면 그 버전의 dSYM은 아카이브 계층(콜드 스토리지)으로 옮긴다.

(2) 강제 업데이트 정책과 연계한다. 최소 지원 버전을 강제하는 앱이라면 그 아래 버전의 dSYM은 삭제해도 안전하다.

(3) 저장 비용을 실제로 계산해본다. dSYM은 압축이 잘 되는 편이고, 릴리스 주기가 2주라면 연간 26개다. 개당 200 MB로 잡아도 연 5 GB 수준이라 오브젝트 스토리지에서는 무시할 만한 비용이다. 대개는 "보관 기간을 최적화할 이유가 없다"는 결론이 나온다.

주의할 것은 백엔드의 보관 정책이다. 관측 백엔드가 업로드된 디버그 파일을 일정 기간(예: 90일) 후 삭제할 수 있으므로, 우리 쪽 원본 보관은 그와 별개로 유지해야 한다. 백엔드에서 지워졌다면 다시 올리면 되지만, 원본까지 없으면 방법이 없다.

쉽게 말하면 오래된 명부를 언제 버릴까? 그 건물에 아직 사는 사람이 있는 동안은 버리면 안 된다. 그리고 명부는 종이 몇 장이라 창고에 두는 비용이 거의 없다. 버려서 아끼는 것보다 없어서 곤란한 쪽이 훨씬 크다.

Q3. 심볼화가 실패하면 무엇이 망가지는가?

🔑 30초 답변

조사 불능만이 아니라 집계가 통째로 오염된다. 심볼화가 안 되면 스택 프레임이 주소로 남는데, 18장 Q6에서 본 대로 지문은 스택에서 계산되므로 주소가 지문 재료가 된다. 그런데 이미지 베이스는 실행마다 다르고 오프셋도 빌드마다 달라지므로, 같은 버그가 릴리스마다 — 심하면 실행마다 — 다른 이슈로 쪼개진다. 증상은 이렇다. 이슈 수가 폭증하고, 각 이슈의 발생 건수가 1~2건이라 심각도를 판단할 수 없고, "인앱 프레임 없음" 비율이 오른다. 심볼화는 조사 편의가 아니라 데이터 정합성의 전제조건이다.

CS 원리

심볼화 실패가 그룹핑을 파괴하는 구조를 정리하면 이렇다.

지문 함수는 대략 f(예외 타입, 메시지, 인앱 프레임 목록) 형태다. 정상 상태에서 인앱 프레임 목록은 함수명이므로 안정적이다 — 같은 버그면 같은 함수명이 나온다.

심볼화가 실패하면 이 자리에 주소가 들어간다. 주소의 안정성은 계층적으로 무너진다.

무엇이 지문에 들어가나안정성결과
함수명 (정상)빌드가 바뀌어도 대체로 동일같은 버그 = 같은 이슈
이미지 기준 오프셋같은 빌드 안에서는 동일릴리스마다 이슈가 갈린다
절대 주소ASLR로 실행마다 다름발생 건마다 이슈가 갈린다

세 번째 행이 최악의 경우다. 백엔드가 절대 주소를 정규화하지 않으면 이슈 수 = 크래시 건수가 되어 대시보드가 완전히 무용해진다. 대부분의 백엔드는 오프셋으로 정규화하므로 두 번째 행에 머무는 경우가 많지만, 그래도 릴리스마다 이슈가 리셋되는 것은 그대로다.

지문 = f(예외 타입, 메시지, 인앱 프레임 목록) — 세 번째가 흔들린다 정상 Cart.checkout() 함수명 — 빌드가 바뀌어도 동일 이슈 1개 · 추세가 보인다 심볼화 실패 → 이 자리에 주소가 들어간다 오프셋 YourApp + 0x4a1c 같은 빌드 안에서만 동일 릴리스마다 이슈가 갈린다 절대 주소 0x1049a24a1c ASLR — 실행마다 다름 이슈 수 = 크래시 건수 진짜 손해는 조사 불능이 아니라 추세를 못 보는 것이다 3개월 묵은 버그가 매 릴리스 "새로 생긴 버그"로 보여 우선순위가 역전된다
심볼화는 지문 계산의 입력이다. 실패하면 조사보다 집계가 먼저 망가진다.

이게 왜 아픈지는 추세를 못 본다는 데 있다. "이 버그가 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이 백엔드에 없다 → 그 빌드의 크래시는 심볼화 안 됨

대시보드 쪽에서는 다음 두 지표를 만들어 둔다.

그리고 릴리스 직후 자동 점검을 넣으면 가장 빨리 잡힌다.

# 릴리스 후 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

프로젝트 적용

운영 규칙 넷으로 정리된다.

⚠️ 흔한 오해

"심볼화는 조사할 때만 필요하다"가 핵심 오해다. 심볼화는 지문 계산의 입력이므로 실패하면 집계가 먼저 망가진다. "나중에 dSYM을 올리면 과거 크래시도 심볼화된다"도 대개 틀렸다 — 백엔드에 따라 이후 수신분만 처리하고, 이미 만들어진 이슈 그룹은 그대로다. "이슈가 많이 뜨니 버그가 많다"는 18장에서 본 오독이 여기서 재발한다 — 심볼화 실패 때문일 수 있다. 또 "미심볼화 리포트도 주소로 추적하면 된다"는 실무적으로 불가능에 가깝다 — 이론상 오프셋으로 조회할 수 있지만 dSYM이 없어서 실패한 상황이므로 조회할 자료 자체가 없다.

🧒 쉽게 이해하기

병원 접수를 생각해보자. 환자가 오면 같은 사람의 진료 기록을 하나로 묶어야 한다. 그래야 "이 환자가 세 달째 같은 증상"이라는 걸 안다.

묶는 기준이 이름이면 잘 된다. 이름은 변하지 않으니까.

그런데 이름을 못 읽는 상황이 생겼다고 하자. 명부가 없어서 "3동 22964걸음 지점 사람"이라고만 적힌다. 문제는 단지가 매일 위치를 옮긴다는 것이다. 그래서 같은 사람이 어제는 "3동 22964", 오늘은 "7동 51203"으로 기록된다.

결과가 어떻게 될까? 진료 기록이 올 때마다 새 환자가 된다. 장부에는 환자 수천 명이 각각 1회씩 방문한 것으로 남는다. "세 달째 같은 증상인 사람"은 영원히 안 보인다.

여기서 무서운 건 장부가 고장 났다는 사실이 안 보인다는 점이다. 오히려 "환자가 정말 많네" 하고 넘어가게 된다. 그래서 "이름이 적힌 기록의 비율"을 따로 세어봐야 한다. 그 비율이 갑자기 떨어지면 명부에 문제가 생긴 것이다.

꼬리 질문

백엔드가 절대 주소가 아니라 오프셋으로 정규화한다는 것을 어떻게 확인하는가?

가장 확실한 방법은 같은 크래시를 두 번 발생시켜 이슈가 합쳐지는지 보는 것이다. dSYM을 일부러 올리지 않은 테스트 빌드에서 같은 크래시를 여러 기기·여러 실행으로 발생시킨다. 절대 주소를 쓴다면 매번 다른 이슈가 생기고, 오프셋으로 정규화한다면 하나로 묶인다.

간접 확인 방법도 있다. 미심볼화 이슈의 스택 표시를 보면 백엔드가 무엇을 저장했는지 드러난다 — 0x1045a19b4 형태의 절대 주소만 보이면 정규화가 약한 것이고, YourApp + 22964처럼 이미지 기준 오프셋으로 보이면 정규화하고 있는 것이다.

대부분의 성숙한 백엔드는 크래시 리포트를 파싱할 때 Binary Images 정보를 함께 받아 수집 시점에 오프셋으로 변환한다. 이게 표준적인 구현이고, 그래야 나중에 dSYM을 올렸을 때 소급 심볼화가 가능해진다.

실무적으로 더 중요한 것은 정규화를 하더라도 릴리스 간 그룹핑은 여전히 깨진다는 점이다. 오프셋은 빌드마다 달라지기 때문이다. 그러니 "정규화하니까 괜찮다"가 아니라 dSYM을 제대로 올리는 것 외에 대안이 없다.

쉽게 말하면 접수처가 "3동 22964걸음"이라고 적는지 "오늘 기준 GPS 좌표"로 적는지의 차이다. 전자는 적어도 하루 안에서는 같은 사람을 같은 사람으로 알아본다. 후자는 매 순간 다른 사람이 된다. 물론 전자도 단지가 리모델링되면 무용지물이다.
심볼화율이 떨어졌을 때 이미 쪼개진 이슈들은 어떻게 정리하는가?

현실적으로 정리보다 손절이 답인 경우가 많다. 순서는 이렇다.

(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. 최적화와 인라이닝은 스택을 어떻게 바꾸는가?

🔑 30초 답변

릴리스 빌드에서는 스택이 소스와 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]

여기서 읽어야 할 표식이 넷이다.

Apple 도구가 함께 출력하는 경고도 같은 맥락이다 — "최적화와 함께 컴파일되어 스텝 실행이 이상하게 동작할 수 있고 변수를 볼 수 없을 수 있다", 그리고 "이 주소는 특정 코드 줄과 연결되지 않으며, 컴파일러 최적화 때문일 수 있다".

iOS에서는

Swift는 인라이닝이 특히 공격적이라 이 영향이 크다. 몇 가지 Swift 특유의 요인이 있다.

실행된 코드 ≠ 스택에 남는 프레임 소스 (내가 쓴 것) -O 런타임 스택 DWARF 로 복원한 스택 level1() level2() level3() ← 트랩 level1() — 본문이 펼쳐짐 프레임 없음 호출이 없으니 프레임도 없다 level1() [opt] level2() [inlined] level3() [inlined] "스택에 없으면 안 지났다"가 가장 흔한 오독이다 읽어야 할 표식 [opt] 값·줄번호 근사 [inlined] 복원된 프레임 [synthetic] 컴파일러 생성 <unavailable> 변수 소실 인라인 프레임을 복원하지 않는 도구로 보면 가운데 열만 보게 된다
인라인된 함수는 실행됐어도 프레임이 없다. DWARF 인라인 정보를 읽는 도구만 원래 구조를 복원한다.

실무에서 이게 드러나는 전형적 상황 셋이다.

(1) "이 함수가 스택에 없다." 분명 그 경로를 지났는데 스택에 안 보인다. 인라인됐을 가능성이 높다. crashlogCrashSymbolicator.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)
}

다만 이건 성능과의 맞바꿈이므로 뜨거운 경로에는 쓰지 않는다. 진입점·경계 함수처럼 호출 빈도가 낮고 진단 가치가 큰 곳에만 선택적으로 적용한다.

프로젝트 적용

실무 규칙 넷.

⚠️ 흔한 오해

"스택에 없으면 그 코드를 안 지났다"가 가장 흔한 오독이다. 인라인된 함수는 실행됐어도 프레임이 없다. "심볼화가 됐으니 줄번호가 정확하다"도 틀렸다 — 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. 릴리스·환경·배포 채널은 어떻게 태깅해야 하는가?

🔑 30초 답변

세 축을 분리해서 붙여야 한다. 릴리스는 "어떤 코드인가"(버전+빌드 번호), 환경은 "어떤 백엔드·설정을 보는가"(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)실무상 충분이슈 추적의 기본 축
빌드 UUIDe3ea8743-…완전dSYM 매칭, 심볼화
커밋 SHAa1b2c3d소스 기준 완전코드 변경 추적
세 축은 서로 독립이다 — 하나로 뭉치면 질문에 못 답한다 릴리스 — 어떤 코드인가 4.12.0 (1843) + 커밋 SHA · 빌드 UUID "어느 버전부터 나빠졌나" 환경 — 무엇을 보는가 production / staging / dev 백엔드 · 설정 · 피처 플래그 "실사용자 데이터인가" 채널 — 어떻게 설치됐나 App Store / TestFlight / 사내 빌드 시점 주입이 안전 "실사용자에게 갔나" 축이 직교하기 때문에 조합이 그대로 의미가 된다 4.12.0(1843) · staging · TestFlight → QA 단계, 무시해도 됨 4.12.0(1843) · production · App Store → 실사용자 장애, 최우선 4.12.0(1843) · production · 사내 → 같은 코드인데 채널만 다름 "버전 문자열"만 붙이면 이 세 칸이 한 덩어리로 뭉친다 4.12.0 하나에 빌드 3·4·5가 섞이고, QA와 실사용자 크래시가 같은 이슈가 된다
릴리스 · 환경 · 채널은 서로 다른 질문에 답하는 축이다. 하나로 합치면 어느 질문에도 답하지 못한다.

이 넷을 모두 이벤트에 실어 두면 각 질문에 답할 수 있다. "어느 버전부터 나빠졌나"는 릴리스 식별자로, "어느 커밋이 원인인가"는 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,
    ])
}

프로젝트 적용

태깅 설계에서 결정할 것 넷.

그리고 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에서 어떻게 보장하는가?

🔑 30초 답변

핵심 원칙은 "조용한 실패를 없애는 것"이다. dSYM 파이프라인이 깨지는 이유는 대부분 실패가 경고로만 남고 빌드는 성공하기 때문이다. 그래서 네 단계를 강제한다. (1) 생성 보장DEBUG_INFORMATION_FORMATdwarf-with-dsym인지 빌드 전 검증. (2) 개수 검증 — 앱·익스텐션·프레임워크의 dSYM 개수가 예상과 일치하는지. (3) 업로드 강제 — 실패 시 빌드 실패. (4) 사후 확인 — 백엔드에 등록된 UUID를 조회해 대조하고, 릴리스 후 24시간 심볼화율을 점검. 특히 (2)와 (4)가 실무에서 가장 자주 빠지고, 그래서 가장 많이 터진다.

CS 원리

이건 페일 세이프(fail-safe) 설계의 문제다. 시스템이 실패했을 때 안전한 쪽으로 실패하도록 만드는 것이다.

dSYM 파이프라인의 기본 상태는 페일 사일런트(fail-silent)다.

실패 신호가 어디에도 나타나지 않는다. 문제는 몇 주 뒤 "크래시를 조사하려는 순간"에야 드러나고, 그때는 이미 그 빌드의 dSYM이 사라졌을 수 있다.

이런 종류의 시스템을 안전하게 만드는 표준적 방법이 검증 지점을 명시적으로 삽입하는 것이다. 자연적으로 실패가 드러나지 않으므로, 일부러 확인하고 일부러 실패시켜야 한다.

그리고 검증은 가능한 한 빨리 배치한다. 빌드 시점 검증이 배포 후 검증보다 낫고, 배포 후 검증이 사고 시점 발견보다 낫다.

각 단계는 실패해도 조용하다 — 그래서 게이트를 직접 넣는다 빌드 설정 dwarf-with-dsym 아카이브 dSYM 여러 개 생성 업로드 백엔드 · 아티팩트 배포 App Store · TF 크래시 수신 게이트 1 설정값 검증 게이트 2 개수 검증 ← 자주 빠짐 게이트 3 실패 시 빌드 실패 게이트 4 24h 심볼화율 ← 자주 빠짐 게이트가 없으면 문제는 "크래시를 조사하려는 순간"에 발견된다 그 시점에는 이미 그 빌드의 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}%"

프로젝트 적용

도입 순서를 우선순위대로 정리하면 이렇다. 한 번에 다 하기 어려우면 위에서부터 한다.

  1. 아카이브 장기 보관 — 이것만 있으면 나머지는 나중에 복구 가능하다. 가장 먼저 넣는다.
  2. 업로드 실패 시 빌드 실패 — 한 줄 수정(|| exit 1)으로 큰 효과.
  3. 릴리스 후 심볼화율 점검 — 게이트가 새어도 24시간 안에 잡힌다. 비용 대비 효과가 가장 크다.
  4. 개수 검증 — 익스텐션 추가 시 누락을 잡는다. 타깃이 여러 개인 프로젝트에서 필수.
  5. 백엔드 등록 확인 — 가장 정확하지만 백엔드 API에 의존한다.
  6. 게이트 훈련 — 분기 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 문제만 선명하게 드러난다.

쉽게 말하면 시험 점수가 100점이 아니라고 무조건 문제는 아니다. 원래 평균이 88점이었는데 갑자기 60점이 되면 그때가 문제다. 그리고 몇 문제 안 풀고 낸 시험의 점수는 믿을 게 못 된다.
모노레포나 여러 앱을 배포하는 조직에서 이 파이프라인을 어떻게 확장하는가?

세 가지 원칙으로 확장한다.

(1) 검증 로직을 공유 스크립트로 추출한다. 앱마다 CI 설정을 복사하면 하나만 고치고 나머지를 놓치는 일이 반드시 생긴다. verify-dsyms.sh를 공용 저장소나 공유 CI 템플릿에 두고 앱은 호출만 한다. 기대 개수처럼 앱별로 다른 값은 인자나 설정 파일로 넘긴다.

(2) UUID 매니페스트를 중앙에 모은다. 앱·릴리스·UUID·커밋을 한 테이블에 쌓아두면, 나중에 어떤 앱의 어떤 UUID든 역조회할 수 있다. 크래시 리포트만 있고 어느 앱·어느 릴리스인지 모르는 상황에서 결정적이다.

(3) 지표를 앱별로 나누되 대시보드는 하나로 둔다. "전 앱의 심볼화율" 한 화면이 있으면 특정 앱만 깨진 것을 즉시 안다. 앱별로 대시보드가 흩어져 있으면 아무도 안 보는 앱이 생긴다.

추가로 공유 프레임워크의 dSYM이 까다롭다. 여러 앱이 같은 내부 프레임워크를 쓰면, 그 프레임워크는 앱마다 다시 빌드되어 앱마다 다른 UUID를 갖는다. 그래서 "프레임워크 dSYM을 한 번만 올리면 된다"고 생각하면 안 되고, 각 앱의 아카이브에 포함된 것을 각각 올려야 한다.

사전 빌드된 바이너리로 배포하는 구조라면 반대로 한 번 빌드된 것이 여러 앱에 공유되므로 UUID가 같고, 한 번만 올리면 된다. 어느 구조인지 아는 것이 중요하다.

쉽게 말하면 건물이 여러 채면 소화기 점검표를 건물마다 복사하지 말고 한 장짜리 표준 점검표를 만들어 다 같이 쓴다. 그리고 전체 현황판을 하나 두면, 어느 건물이 점검을 빼먹었는지 한눈에 보인다.

출처 · 참고자료