← Study Hub

14 · 컴파일러·링커·실행 파일

Swift 소스가 실행 파일이 되어 구동되기까지 — 컴파일·링크·로드 파이프라인, 정적/동적 라이브러리와 프레임워크, 심볼과 dSYM 심볼리케이션
진행률
0 / 0 완료

← 챕터 목록

우리가 쓴 Swift 한 줄이 실제로 아이폰에서 도는 기계어가 되기까지는 컴파일 → 링크 → 로드라는 세 관문을 지난다. 이 챕터는 그 파이프라인과, 그 과정에서 등장하는 라이브러리·프레임워크·심볼·dSYM을 시니어 면접 깊이로 정리한다.

Q1. Swift 코드는 Compile, Link, Load를 거쳐 어떻게 실행되는가?

🔑 30초 답변

컴파일은 소스 파일 하나하나를 기계어 오브젝트(.o)로 바꾸는 단계이고, 링크는 그 오브젝트들과 라이브러리를 합쳐 심볼 참조를 해소해 하나의 실행 파일을 만드는 단계, 로드는 실행 시점에 OS의 동적 링커가 그 파일을 메모리에 올려 의존 라이브러리를 붙이고 실행을 시작하는 단계다. Swift는 특이하게 컴파일 중간에 SIL(Swift Intermediate Language)이라는 자체 중간 표현을 거친 뒤 LLVM IR로 내려가 기계어가 된다. iOS에서 실행 파일 포맷은 Mach-O이고, 로드는 dyld가 담당한다. 핵심은 컴파일과 링크는 빌드 타임, 로드(그리고 동적 심볼 바인딩)는 런타임이라는 시점 분리다.

CS 원리

컴파일러는 소스를 여러 단계로 변환한다. 어휘 분석(lexing)으로 토큰을 만들고, 구문 분석(parsing)으로 AST(추상 구문 트리)를 세운 뒤, 의미 분석(타입 검사)을 하고, 중간 표현(IR)으로 낮춘 다음 최적화하고 마지막에 그 CPU의 기계어로 내린다. 이때 나오는 산출물이 오브젝트 파일인데, 이건 아직 완성된 프로그램이 아니라 "다른 곳에 있는 함수(심볼)를 나중에 채워 넣어라"는 미해결 참조를 담은 조각이다.

링커(static linker)는 이 조각들을 모아 서로의 참조를 이어 붙이고(symbol resolution), 코드가 놓일 위치에 맞춰 주소를 고치는 재배치(relocation)를 수행해 실행 파일이나 라이브러리를 만든다. 실행 파일이 실제로 구동될 때는 OS의 로더/동적 링커가 파일을 가상 메모리에 매핑하고, 의존하는 동적 라이브러리를 찾아 붙이며(binding), 진입점까지 준비한 뒤 첫 코드를 실행한다.

Swift만의 특징은 AST와 LLVM IR 사이에 SIL이라는 SSA 형태의 자체 IR을 하나 더 둔다는 점이다. definite initialization(초기화 보장), ARC 최적화, 배타적 접근(exclusivity) 검사, 제네릭 특수화(generic specialization) 같은 Swift 고유의 분석·최적화가 이 SIL 단계에서 이뤄지고, 그 다음에야 LLVM IR로 내려가 공통 백엔드 최적화와 코드 생성을 거친다.

① 컴파일 · swiftc ② 링크 · ld ③ 로드 · 실행 · dyld .swift 소스 ↓ Parse · Sema = AST (타입 검사됨) ↓ SILGen · SIL 최적화 = SIL ↓ IRGen → LLVM IR = 기계어 → .o (Mach-O object) .o 여러 개 + .a · .dylib 심볼 해소 (resolve) 재배치 (relocation) dead-strip → 실행 파일 (Mach-O) 실행 파일 + 의존 dylib 가상 메모리 매핑 (mmap) 리베이스 (ASLR) 바인딩 (symbol) 이미지 초기화 → main() 실행
swiftc는 파일마다 SIL·LLVM IR을 거쳐 .o를 만들고(컴파일), ld가 이를 합쳐 실행 파일을 만든다(링크). 실제 구동 시 dyld가 메모리에 올려 심볼을 붙이고 실행한다(로드).

iOS에서는

iOS/macOS의 실행 파일 포맷은 Linux의 ELF가 아니라 Mach-O다. 파일 앞부분에 LC_LOAD_DYLIB(어떤 dylib에 의존하는지), LC_UUID(빌드 고유 식별자) 같은 로드 커맨드가 들어 있어 로더가 이를 읽고 동작한다. 링크는 Apple의 정적 링커(ld, Xcode 15부터는 새 링커 ld-prime)가, 로드·동적 바인딩은 dyld가 담당한다. dyld는 실행 파일을 매핑한 뒤 의존 dylib들을 재귀적으로 로드하고, ASLR로 무작위화된 실제 주소에 맞춰 포인터를 고치는 rebase와, 외부 심볼을 실제 주소로 연결하는 bind를 수행한 다음 초기화 루틴을 돌리고 main으로 넘어간다.

Swift 런타임과 표준 라이브러리(libswiftCore.dylib)도 이때 함께 로드된다. Swift 5에서 ABI 안정화가 이뤄진 iOS 12.2부터는 이 라이브러리가 OS에 내장되어 시스템에서 공유되지만, 그 이전 iOS에서는 앱 번들의 Frameworks/ 폴더에 Swift 표준 라이브러리를 통째로 동봉해야 했다. 참고로 예전 App Store 업로드에서 쓰이던 Bitcode 중간 표현은 Xcode 14부터 사용 중단(deprecated)되었다.

실험 · 도구

파이프라인 각 단계의 산출물을 직접 뽑아 보면 이해가 빨라진다. dyld의 로드 동작은 환경 변수로 관찰할 수 있고, 로드 타이밍은 DYLD_PRINT_STATISTICS로 요약을 볼 수 있다(Xcode의 DYLD_PRINT_STATISTICS=1 스킴 환경 변수). 예상 결과: 단계마다 파일이 점점 저수준(SIL → LLVM IR → 어셈블리 → 오브젝트)으로 내려가고, 최종 실행 파일의 otool -L에 의존 dylib 목록이 보인다.

각 단계 산출물 뽑기 · 링크/로드 관찰 (shell)
# 컴파일 중간 산출물을 단계별로 확인
swiftc -emit-silgen main.swift     # raw SIL (SILGen 직후)
swiftc -emit-sil     main.swift    # canonical SIL (mandatory 패스 적용, 최적화는 -O 필요)
swiftc -emit-ir      main.swift    # LLVM IR
swiftc -emit-assembly main.swift   # 어셈블리
swiftc -emit-object  main.swift    # main.o (Mach-O object)

# 링크 결과 · 로드 커맨드 확인
otool -L a.out     # 링크된 dylib 목록
otool -l a.out     # 로드 커맨드(LC_UUID, LC_LOAD_DYLIB ...)

# 로드(dyld) 동작 관찰
DYLD_PRINT_LIBRARIES=1  ./a.out    # 로드되는 이미지 출력
DYLD_PRINT_STATISTICS=1 ./a.out    # 런치 타이밍 요약

프로젝트 적용

⚠️ 흔한 오해

"Swift는 인터프리터 언어다"거나 "컴파일과 링크는 사실상 한 단계다"라는 말은 틀리다. Swift는 LLVM 기반으로 사전(AOT) 컴파일되어 네이티브 기계어가 된다(REPL·Playground의 즉시 실행 경험 때문에 생기는 오해다). 또 컴파일러는 파일별 .o까지만 만들고, 그것들을 하나로 합쳐 심볼을 잇는 건 별도의 링커 몫이다. 그래서 컴파일은 다 됐는데 링크에서 터지는 상황이 정상적으로 존재한다.

꼬리 질문

Q2. Static library와 Dynamic library는 무엇이 다른가?

🔑 30초 답변

정적 라이브러리(.a)는 링크 시점에 그 코드가 실행 파일 안으로 복사되어 하나가 된다. 런타임 의존이 없고 로드가 빠른 대신 바이너리가 커진다. 동적 라이브러리(.dylib)는 실행 파일에는 참조(stub)만 남고 로드 시점에 dyld가 연결한다. 바이너리는 작지만 로드 비용이 들고, 그 파일이 있어야 실행된다. iOS에서 중요한 함정은, 내가 만든 동적 프레임워크는 앱마다 각자 사본을 번들에 넣어 로드하므로 앱들 사이에 메모리가 공유되지 않는다는 점이다(공유는 Apple 시스템 라이브러리의 dyld shared cache에만 해당). 그래서 iOS에선 런치 성능을 위해 정적 링크가 유리한 경우가 많다.

CS 원리

둘의 본질적 차이는 심볼을 언제 붙이느냐다. 정적 라이브러리는 여러 .o를 묶은 아카이브에 불과해서, 링커가 필요한 오브젝트만 골라 실행 파일에 편입시킨다. 결과물은 자기 완결적(self-contained)이라 배포·실행이 단순하고, 링커의 dead-code stripping으로 안 쓰는 코드가 잘 제거된다. 단점은 같은 라이브러리를 쓰는 프로그램마다 코드가 중복 복사된다는 것.

동적 라이브러리는 실행 파일과 분리된 채로 존재하고, 로드 시점(또는 심볼을 처음 호출하는 지연 바인딩 시점)에 동적 링커가 주소를 연결한다. 이론적 장점은 (1) 여러 프로세스가 물리 메모리의 한 사본을 공유할 수 있고, (2) 라이브러리만 교체해 앱 재컴파일 없이 업데이트할 수 있다는 것. 대신 로드마다 심볼 해소 비용이 들고, 버전이 안 맞거나 파일이 없으면 실행이 실패한다("dylib hell").

정적(static) — 링크 시 코드가 실행 파일 안으로 복사 실행 파일 libFoo 코드 (복사됨) → 외부 의존 없음 → 바이너리 큼 · 앱마다 사본 동적(dynamic) — 로드 시 dyld 가 연결 실행 파일 stub (참조) dyld libFoo.dylib (별도 파일) → 바이너리 작음 → 로드 비용 · 파일 필수
정적은 링크 때 코드를 실행 파일에 흡수한다. 동적은 참조만 남기고 실행 시 dyld가 별도 파일을 붙인다.

iOS에서는

iOS는 iOS 8 전까지 서드파티 동적 프레임워크를 금지했고, 앱 확장(App Extension)과 함께 임베디드 동적 프레임워크가 허용됐다. 여기서 핵심 오해가 생긴다. "동적이니까 메모리를 공유한다"는 교과서적 장점은 Apple 시스템 라이브러리(UIKit, Foundation 등, dyld shared cache에 프리링크됨)에만 해당한다. 개발자가 만든 동적 프레임워크는 각 앱 번들 안에 개별 사본으로 들어가므로 앱 사이 공유가 없다.

그래서 런치 성능이 걸리면 정적 링크가 유리하다. Apple은 WWDC 2016 "Optimizing App Startup Time"에서 동적 프레임워크 개수를 줄이라고 권고했고(당시 6개 남짓을 기준선으로 언급), WWDC 2023 "Meet mergeable libraries"에서는 개발 중엔 동적, 릴리스 땐 하나로 병합해 정적처럼 만드는 mergeable libraries로 두 방식의 장점을 함께 얻는 길을 제시했다.

항목Static library (.a)Dynamic library (.dylib)
심볼 연결 시점링크(빌드) 타임로드/런타임
실행 파일 크기큼(코드 흡수)작음(참조만)
런치 속도빠름dyld 해소 비용
재컴파일 없이 교체불가가능
앱 간 메모리 공유(iOS)해당 없음내 프레임워크는 공유 안 됨
런타임 파일 누락 위험없음있음

실험 · 도구

라이브러리 타입 확인 (shell)
file libFoo.a         # current ar archive (정적 아카이브)
file libFoo.dylib     # Mach-O 64-bit dynamically linked shared library

otool -hv MyApp.framework/MyApp   # filetype 이 DYLIB 인지 확인
# → EXECUTE/DYLIB 등 Mach-O 타입으로 정적/동적 판별

프로젝트 적용

⚠️ 흔한 오해

"동적 라이브러리는 항상 메모리를 아낀다"는 iOS에서 대체로 틀리다. 앱 간 공유는 dyld shared cache의 시스템 라이브러리에만 적용되고, 내가 번들에 넣은 동적 프레임워크는 앱마다 사본이 로드된다. "동적=최신 유지 편함"도 앱 스토어 배포 모델에선 큰 의미가 없다. 반대로 "정적이 무조건 빠르다"도 과장이다 — 정적이라도 코드가 커지면 __TEXT 페이지 인 비용이 늘 수 있다.

꼬리 질문

Q3. Framework와 Library는 무엇이 다른가?

🔑 30초 답변

순수 CS 관점에서 라이브러리는 내가 호출하는 코드 모음이고, 프레임워크는 반대로 내 코드를 불러 주는 "제어의 역전"(Inversion of Control, "우리를 호출하지 마세요, 우리가 부를게요") 구조를 갖춘 골격이다. 하지만 Apple 플랫폼에서 실무적으로 이 질문은 패키징 형식 이야기다. Apple의 .framework는 바이너리(정적이든 동적이든) + 헤더 + 리소스 + 모듈맵 + Info.plist를 한 번들(디렉터리)로 묶은 배포 단위이고, 라이브러리는 그냥 코드 산출물(.a/.dylib)일 뿐이다. 즉 "프레임워크=동적, 라이브러리=정적"이 아니라, 프레임워크는 포장 방식이고 정적/동적은 링크 방식이라 서로 직교한다.

CS 원리

전통적 구분은 누가 흐름을 통제하느냐다. 라이브러리를 쓸 때는 내 코드가 주도권을 쥐고 필요할 때 함수를 호출한다. 프레임워크를 쓸 때는 프레임워크가 앱의 생명주기와 흐름을 쥐고, 내가 그 안에 콜백·서브클래스·델리게이트 형태로 코드를 끼워 넣으면 프레임워크가 적절한 때에 그것을 호출한다(할리우드 원칙). UIKit이 viewDidLoad를 대신 불러 주는 게 딱 이 관계다.

또 하나의 축은 포장 단위다. 라이브러리는 컴파일된 코드(+헤더) 그 자체지만, 프레임워크는 코드에 더해 실행에 필요한 리소스와 메타데이터까지 하나의 배포 단위로 묶는다. 이 "번들로서의 프레임워크" 개념은 Apple에서 특히 구체적 파일 구조로 규정되어 있다.

iOS에서는

Apple의 .framework는 실제 디렉터리다. macOS에서는 Versions/A/… 심볼릭 링크로 버전 관리를 하지만, iOS에서는 코드 서명 제약 때문에 이 구조가 평탄화(flatten)되어 심링크가 없다. 안에는 바이너리, Headers/, Modules/module.modulemap, 리소스, Info.plist가 들어간다. 라이브러리(.a)는 리소스를 담지 못해서 이미지·nib 같은 자원을 함께 배포하려면 별도 .bundle이 필요하지만, 프레임워크는 리소스를 자체적으로 품을 수 있다.

정적/동적과의 직교성을 보여주는 게 정적 프레임워크(Mach-O 타입이 정적인 프레임워크)의 존재다. 그리고 여러 아키텍처·플랫폼(디바이스 arm64, 시뮬레이터 arm64/x86_64, Catalyst)을 한 배포물에 담기 위한 XCFramework(.xcframework)가 Xcode 11에서 도입됐다. 예전 fat 바이너리(lipo로 합침)로는 같은 arm64인데 디바이스용과 시뮬레이터용을 한 슬라이스에 공존시킬 수 없어서, 플랫폼별 슬라이스를 폴더로 나눠 담는 XCFramework가 그 자리를 대체했다.

구분Library (.a / .dylib)Framework (.framework)
정체컴파일된 코드 산출물코드+헤더+리소스+메타 번들(디렉터리)
헤더/모듈맵 포함별도 관리번들 안에 포함
리소스 동봉불가(별도 .bundle)가능
정적/동적둘 다 가능둘 다 가능(직교)
다중 플랫폼 배포lipo fat 바이너리(한계 있음)XCFramework로 해결

프로젝트 적용

⚠️ 흔한 오해

"프레임워크는 동적, 라이브러리는 정적"은 흔하지만 틀린 공식이다. 프레임워크는 포장 형식이고 정적/동적은 링크 방식이라 네 조합이 모두 존재한다(정적 프레임워크, 동적 프레임워크, 정적 라이브러리, 동적 라이브러리). 또 "framework는 library보다 크고 무겁다"도 본질이 아니다 — 차이는 무게가 아니라 헤더·리소스·메타를 함께 묶어 배포하느냐에 있다.

꼬리 질문

Q4. Symbol은 무엇이며 Undefined symbol 오류는 왜 발생하는가?

🔑 30초 답변

심볼은 오브젝트 코드 안의 이름표다 — 함수·전역 변수 같은 개체에 붙는 (맹글링된) 이름이고, "여기서 정의됨(defined)"인지 "다른 곳에 있음(undefined/external)"인지의 속성을 갖는다. 링커는 각 오브젝트의 undefined 참조를 다른 오브젝트·라이브러리의 defined 심볼과 이어 준다. Undefined symbol 오류는 그 정의를 어디서도 못 찾았다는 뜻이다. 원인은 대부분 소스 오타가 아니라 링크할 라이브러리/프레임워크를 안 넘김, 잘못된 아키텍처, C++/C 맹글링 불일치, 심볼 미노출 같은 빌드 구성 문제다. 링크 타임 오류(ld: Undefined symbols)와, 동적 라이브러리에서 런타임에 못 찾는 오류(dyld: Symbol not found)는 시점이 다르다.

CS 원리

컴파일러가 만든 오브젝트 파일에는 심볼 테이블이 있다. 각 항목은 이름, 값(주소/오프셋), 그리고 defined/undefined·global/local·weak/strong 같은 속성을 갖는다. 한 .o가 자기 안에 없는 함수를 부르면, 그 함수 이름은 이 파일에선 undefined(외부 참조)로 남는다. 링커의 일이 바로 이 undefined 항목을, 그것을 defined로 제공하는 다른 오브젝트/라이브러리와 맞춰 연결하는 것이다. 하나라도 짝을 못 찾으면 링크가 실패한다.

이름이 안 맞으면 연결도 안 된다. 그래서 언어들은 타입·모듈·제네릭 정보를 이름에 인코딩하는 name mangling을 쓴다. C는 대체로 맹글링을 안 하지만(Mach-O에선 이름 앞에 _만 붙는다), C++와 Swift는 오버로딩·네임스페이스를 구분하려고 이름을 변형한다. 그래서 C 함수를 C++에서 부를 때 extern "C"를 빼먹으면 "정의는 있는데 이름이 달라" undefined가 난다.

링크: 참조(undefined)를 정의(defined)에 연결한다 main.o defined: greet() undefined: log() → ? ld logger.o defined: log() logger.o(또는 -l 옵션)를 빼면 → ld: Undefined symbols: log() 런타임 동적 라이브러리에서 못 찾으면 → dyld: Symbol not found
링커는 각 오브젝트의 undefined 심볼을 defined 심볼과 맞춘다. 정의를 제공하는 오브젝트/라이브러리를 안 넘기면 undefined symbol 오류가 난다.

iOS에서는

iOS에서 가장 자주 보는 형태는 프레임워크를 타깃에 링크하지 않아 생기는 오류다. 특히 Objective-C 클래스는 _OBJC_CLASS_$_클래스명 형태의 심볼로 참조되므로, 에러 메시지에 이 접두사가 보이면 "그 클래스가 든 프레임워크를 안 링크했다"는 강력한 신호다. Swift 심볼은 $s로 시작하는 맹글링 이름을 갖고, swift demangle로 사람이 읽을 수 있는 형태로 되돌릴 수 있다.

런타임 버전 불일치도 흔하다. 앱은 새 API 심볼을 참조하는데 실제 로드된 프레임워크(구버전)에 그 심볼이 없으면 dyld: Symbol not found로 크래시한다. 이를 막는 장치가 약한 링크(weak linking)다. @available로 표시된 새 OS API는 약하게 링크되어, 구버전 OS에서 심볼이 없으면 null로 해소되고 if #available 가드로 호출을 건너뛴다. 그래서 최소 배포 타깃보다 높은 API를 가드 없이 부르면 이 문제가 정확히 재현된다.

실험 · 도구

심볼 조회와 Swift 맹글링 해독 (shell)
nm -gU MyApp     # 이 바이너리가 내보내는(defined, global) 심볼
nm -u  MyApp     # 아직 해소 안 된(undefined) 심볼

# Swift 맹글링된 심볼 읽기
$ swift demangle '$s5MyApp4UserV4nameSSvg'
$s5MyApp4UserV4nameSSvg ---> MyApp.User.name.getter : Swift.String
두 오류 시점 비교 (build log / runtime log)
# 링크 타임 (빌드 실패)
Undefined symbols for architecture arm64:
  "_OBJC_CLASS_$_SomeSDKClass", referenced from:
      objc-class-ref in ViewController.o
ld: symbol(s) not found for architecture arm64

# 런타임 (앱 크래시) — 동적 라이브러리 버전 불일치
dyld[4211]: Symbol not found: _$s7SomeSDK7ServiceC5startyyF
  Referenced from: /private/var/.../MyApp.app/MyApp
  Expected in:     /private/var/.../Frameworks/SomeSDK.framework/SomeSDK

프로젝트 적용

⚠️ 흔한 오해

"Undefined symbol = 내 코드에 오타/버그"라는 건 대체로 오해다. 함수가 소스에 멀쩡히 있어도, 그 함수가 든 라이브러리를 링크 명령에 안 넘기면 undefined가 난다(원인은 빌드 구성). 또 "링크만 되면 런타임에도 안전하다"도 틀리다 — 동적 링크에선 링크는 통과해도 실제 로드된 라이브러리에 심볼이 없으면 런타임에 dyld: Symbol not found로 죽는다.

꼬리 질문

Q5. dSYM과 Crash symbolication은 어떻게 연결되는가?

🔑 30초 답변

릴리스 빌드는 크기·보안 때문에 함수 이름·파일·줄 같은 디버그 정보를 바이너리에서 떼어 낸다. 그래서 크래시 리포트에는 함수 이름이 아니라 메모리 주소만 남는다. 그 디버그 정보(DWARF)를 별도로 보관한 동반 파일이 dSYM이고, 주소를 다시 사람이 읽을 함수·파일·줄로 되돌리는 작업이 심볼리케이션이다. 둘을 잇는 열쇠는 UUID다. 바이너리와 dSYM은 같은 빌드 UUID(LC_UUID)를 갖고, 심볼리케이터는 크래시 리포트의 UUID와 정확히 일치하는 dSYM만 사용한다. 그래서 재빌드하면 UUID가 바뀌어 예전 dSYM은 무용지물이 된다.

CS 원리

최적화된 릴리스 바이너리는 심볼이 스트립되어 스택 프레임이 그냥 주소들의 나열이다. 디버그 빌드는 이 매핑(주소→함수·파일·줄)을 DWARF로 갖되, Apple 툴체인에서는 그 DWARF가 링크된 실행 파일이 아니라 컴파일 산물인 .o 오브젝트 파일에 남고 실행 파일은 그 .o들을 가리키는 debug map만 갖는다(그래서 디버깅 때 빌드 중간 산물을 지우면 안 된다). 릴리스에서는 dsymutil이 그 .o들의 DWARF를 하나의 dSYM 번들로 모은다. 심볼리케이션은 크래시의 각 주소를 이 DWARF로 조회해 원래 이름으로 복원하는 과정이다.

주소 계산이 하나 더 필요하다. ASLR 때문에 프로세스는 매번 다른 베이스 주소에 로드되므로, 크래시 리포트의 실행 주소에서 그 이미지의 slide(로드 주소 − 링크 당시의 preferred 베이스)를 빼서 링크 당시 기준의 정적 주소를 구한 뒤, 그 정적 주소로 dSYM을 조회한다. UUID가 이 모든 것을 올바른 dSYM에 묶어 주는 앵커 역할을 한다.

심볼리케이션 = 주소 → 함수·파일·줄 (UUID 로 dSYM 매칭) 크래시 리포트 frame: 0x1049c8f34 load addr: 0x104988000 UUID: A1B2C3… dSYM (DWARF) 주소 → 함수·파일·줄 정적 심볼 테이블 UUID: A1B2C3… UUID 일치 확인 ① 실행 주소 − slide(ASLR) = 정적 주소 ② 정적 주소로 dSYM 조회 → User.updateProfile() (ProfileViewModel.swift:42) 도구: atos · symbolicatecrash
크래시 리포트의 주소와 dSYM을 UUID로 맞춘 뒤, ASLR slide를 빼 정적 주소로 바꿔 DWARF를 조회하면 함수·파일·줄이 복원된다.

iOS에서는

dSYM 생성은 빌드 설정 DEBUG_INFORMATION_FORMAT가 결정한다. 릴리스 구성의 dwarf-with-dsym이어야 dsymutil이 돌아 별도 .dSYM 번들이 생기고, 디버그 기본값 dwarf는 DWARF를 .o 파일에 남겨 둔 채 dSYM을 만들지 않는다(실행 파일엔 debug map만 있다). UUID는 dwarfdump --uuid로 바이너리와 dSYM 양쪽에서 확인해 일치 여부를 본다. 실제 변환은 atossymbolicatecrash로 하며, Xcode Organizer는 이 과정을 자동화한다.

배포 경로에 따라 dSYM 출처가 갈린다. Bitcode가 쓰이던 시절엔 Apple이 App Store에서 앱을 재컴파일했기 때문에 App Store Connect에서 dSYM을 내려받아야 로컬 것과 UUID가 맞았지만, Bitcode가 사용 중단된 지금은 로컬 아카이브의 dSYM이 곧 배포 바이너리와 일치한다. Crashlytics·Sentry 같은 도구는 이 dSYM을 업로드받아 서버에서 심볼리케이션한다. 시스템 프레임워크 프레임은 Apple의 OS 심볼(디바이스·OS 버전별)로 별도 해소된다. 자세한 흐름은 Apple의 Analyzing a crash report 문서에 정리돼 있다.

실험 · 도구

UUID 확인과 주소→심볼 변환 (shell)
# 바이너리와 dSYM 의 UUID 가 같아야 심볼리케이션 가능
dwarfdump --uuid MyApp.app/MyApp
  UUID: A1B2C3D4-... (arm64)
dwarfdump --uuid MyApp.app.dSYM/Contents/Resources/DWARF/MyApp
  UUID: A1B2C3D4-... (arm64)     # ← 같아야 함

# 주소 → 심볼 (-l 은 크래시 리포트의 이미지 로드 주소)
atos -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
     -arch arm64 -l 0x104988000 0x1049c8f34
  User.updateProfile() (in MyApp) (ProfileViewModel.swift:42)

프로젝트 적용

⚠️ 흔한 오해

"dSYM은 바이너리 안에 들어 있다"거나 "아무 dSYM으로나 심볼리케이션된다"는 오해다. 릴리스 바이너리는 심볼이 스트립되어 있고, DWARF는 별도 dSYM 번들에 있다. 그리고 심볼리케이터는 UUID가 정확히 일치하는 dSYM만 쓴다 — 같은 소스라도 재빌드하면 UUID가 달라져 예전 dSYM은 쓸 수 없다. "빌드 번호만 같으면 된다"도 아니다. 앵커는 언제나 UUID다.

꼬리 질문

출처 · 참고자료