우리가 쓴 Swift 한 줄이 실제로 아이폰에서 도는 기계어가 되기까지는 컴파일 → 링크 → 로드라는 세 관문을 지난다. 이 챕터는 그 파이프라인과, 그 과정에서 등장하는 라이브러리·프레임워크·심볼·dSYM을 시니어 면접 깊이로 정리한다.
Q1. Swift 코드는 Compile, Link, Load를 거쳐 어떻게 실행되는가?
컴파일은 소스 파일 하나하나를 기계어 오브젝트(.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로 내려가 공통 백엔드 최적화와 코드 생성을 거친다.
.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 목록이 보인다.
# 컴파일 중간 산출물을 단계별로 확인
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 # 런치 타이밍 요약프로젝트 적용
- 빌드가 느리면 "컴파일이 느린지, 링크가 느린지"부터 분리해 본다. Whole Module Optimization은 컴파일 최적화 시간을, 동적 프레임워크 개수는 링크·로드 시간을 좌우한다.
- 런치가 느릴 땐
DYLD_PRINT_STATISTICS로 로드/리베이스/바인딩에 쓰는 시간을 먼저 측정한다. 감으로 최적화하지 말 것. - "컴파일 에러"와 "링크 에러"를 헷갈리지 말 것. 타입 불일치는 컴파일 에러,
Undefined symbols는 링크 에러로 원인 계층이 완전히 다르다.
"Swift는 인터프리터 언어다"거나 "컴파일과 링크는 사실상 한 단계다"라는 말은 틀리다. Swift는 LLVM 기반으로 사전(AOT) 컴파일되어 네이티브 기계어가 된다(REPL·Playground의 즉시 실행 경험 때문에 생기는 오해다). 또 컴파일러는 파일별 .o까지만 만들고, 그것들을 하나로 합쳐 심볼을 잇는 건 별도의 링커 몫이다. 그래서 컴파일은 다 됐는데 링크에서 터지는 상황이 정상적으로 존재한다.
꼬리 질문
- Whole Module Optimization을 켜면 SIL 단계에서 무엇이 달라지고, 왜 증분 빌드에는 불리한가?
- 정적 링크만으로 앱을 구성하면 dyld의 로드 단계에서 어떤 작업이 사라지는가?
- ASLR이 켜진 상태에서 rebase와 bind는 각각 무엇을 고치는가? 둘의 차이는?
Q2. Static library와 Dynamic library는 무엇이 다른가?
정적 라이브러리(.a)는 링크 시점에 그 코드가 실행 파일 안으로 복사되어 하나가 된다. 런타임 의존이 없고 로드가 빠른 대신 바이너리가 커진다. 동적 라이브러리(.dylib)는 실행 파일에는 참조(stub)만 남고 로드 시점에 dyld가 연결한다. 바이너리는 작지만 로드 비용이 들고, 그 파일이 있어야 실행된다. iOS에서 중요한 함정은, 내가 만든 동적 프레임워크는 앱마다 각자 사본을 번들에 넣어 로드하므로 앱들 사이에 메모리가 공유되지 않는다는 점이다(공유는 Apple 시스템 라이브러리의 dyld shared cache에만 해당). 그래서 iOS에선 런치 성능을 위해 정적 링크가 유리한 경우가 많다.
CS 원리
둘의 본질적 차이는 심볼을 언제 붙이느냐다. 정적 라이브러리는 여러 .o를 묶은 아카이브에 불과해서, 링커가 필요한 오브젝트만 골라 실행 파일에 편입시킨다. 결과물은 자기 완결적(self-contained)이라 배포·실행이 단순하고, 링커의 dead-code stripping으로 안 쓰는 코드가 잘 제거된다. 단점은 같은 라이브러리를 쓰는 프로그램마다 코드가 중복 복사된다는 것.
동적 라이브러리는 실행 파일과 분리된 채로 존재하고, 로드 시점(또는 심볼을 처음 호출하는 지연 바인딩 시점)에 동적 링커가 주소를 연결한다. 이론적 장점은 (1) 여러 프로세스가 물리 메모리의 한 사본을 공유할 수 있고, (2) 라이브러리만 교체해 앱 재컴파일 없이 업데이트할 수 있다는 것. 대신 로드마다 심볼 해소 비용이 들고, 버전이 안 맞거나 파일이 없으면 실행이 실패한다("dylib hell").
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) | 해당 없음 | 내 프레임워크는 공유 안 됨 |
| 런타임 파일 누락 위험 | 없음 | 있음 |
실험 · 도구
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 타입으로 정적/동적 판별프로젝트 적용
- 기본은 정적 링크로 시작하고, "재컴파일 없는 교체", "앱 확장과 코드 공유", "순환 의존 분리" 같은 명확한 이유가 있을 때만 동적으로 바꾼다.
- SPM/CocoaPods에서 링크 타입을 의식적으로 고른다. CocoaPods
use_frameworks! :linkage => :static, SPM 타깃의.static/.dynamic지정으로 명시. - 같은 정적 라이브러리를 서로 다른 두 동적 프레임워크가 동시에 품으면 심볼 중복/중복 로드가 생긴다. 공용 의존은 한 곳에서만 링크되게 경계를 정리할 것.
"동적 라이브러리는 항상 메모리를 아낀다"는 iOS에서 대체로 틀리다. 앱 간 공유는 dyld shared cache의 시스템 라이브러리에만 적용되고, 내가 번들에 넣은 동적 프레임워크는 앱마다 사본이 로드된다. "동적=최신 유지 편함"도 앱 스토어 배포 모델에선 큰 의미가 없다. 반대로 "정적이 무조건 빠르다"도 과장이다 — 정적이라도 코드가 커지면 __TEXT 페이지 인 비용이 늘 수 있다.
꼬리 질문
- 같은 정적 라이브러리를 앱 본체와 앱 확장이 각각 정적으로 링크하면 무엇이 문제인가?
- mergeable libraries는 정확히 어느 시점에 무엇을 병합해 정적 링크의 이점을 재현하는가?
- 지연 바인딩(lazy binding)은 동적 라이브러리의 어떤 비용을 언제로 미루는가?
Q3. Framework와 Library는 무엇이 다른가?
순수 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로 해결 |
프로젝트 적용
- 서드파티에 바이너리로 배포할 땐 헤더/리소스/다중 플랫폼을 한 번에 담는 XCFramework를 쓴다. 순수 소스 배포라면 SPM 소스 패키지가 더 단순하다.
- 리소스(에셋 카탈로그, 로컬라이즈 파일)를 함께 배포해야 하면 프레임워크(또는 리소스 번들을 갖춘 SPM 타깃)를 택한다. 맨
.a로는 리소스가 안 따라간다. - "프레임워크로 만들었으니 자동으로 동적"이라고 가정하지 말 것. Mach-O 타입/링크 설정을 실제로 확인하고 결정한다.
"프레임워크는 동적, 라이브러리는 정적"은 흔하지만 틀린 공식이다. 프레임워크는 포장 형식이고 정적/동적은 링크 방식이라 네 조합이 모두 존재한다(정적 프레임워크, 동적 프레임워크, 정적 라이브러리, 동적 라이브러리). 또 "framework는 library보다 크고 무겁다"도 본질이 아니다 — 차이는 무게가 아니라 헤더·리소스·메타를 함께 묶어 배포하느냐에 있다.
꼬리 질문
- 같은 arm64 슬라이스라도 디바이스용과 시뮬레이터용을 fat 바이너리에 함께 둘 수 없는 이유는?
- 정적 프레임워크에 리소스를 넣으면 그 리소스는 최종 앱 번들 어디로 가는가?
- 모듈맵(module.modulemap)이 하는 일은 무엇이고,
@import는 헤더#import와 어떻게 다른가?
Q4. Symbol은 무엇이며 Undefined symbol 오류는 왜 발생하는가?
심볼은 오브젝트 코드 안의 이름표다 — 함수·전역 변수 같은 개체에 붙는 (맹글링된) 이름이고, "여기서 정의됨(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가 난다.
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를 가드 없이 부르면 이 문제가 정확히 재현된다.
실험 · 도구
nm -gU MyApp # 이 바이너리가 내보내는(defined, global) 심볼
nm -u MyApp # 아직 해소 안 된(undefined) 심볼
# Swift 맹글링된 심볼 읽기
$ swift demangle '$s5MyApp4UserV4nameSSvg'
$s5MyApp4UserV4nameSSvg ---> MyApp.User.name.getter : Swift.String# 링크 타임 (빌드 실패)
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이 뜨면 소스부터 뒤지지 말고 (1) 그 프레임워크를 Link Binary 단계에 넣었는지, (2) 아키텍처(arm64/시뮬레이터)가 맞는지, (3) C 상호운용이면
extern "C"·모듈맵이 맞는지를 먼저 본다. _OBJC_CLASS_$_가 보이면 프레임워크 링크 누락,$s…면 Swift 심볼 —swift demangle로 어느 타입/함수인지 즉시 확인한다.- 런타임
Symbol not found는 대개 SDK/OS 버전 불일치나@available가드 누락이다. 최소 배포 타깃과 API 가용성을 점검한다.
"Undefined symbol = 내 코드에 오타/버그"라는 건 대체로 오해다. 함수가 소스에 멀쩡히 있어도, 그 함수가 든 라이브러리를 링크 명령에 안 넘기면 undefined가 난다(원인은 빌드 구성). 또 "링크만 되면 런타임에도 안전하다"도 틀리다 — 동적 링크에선 링크는 통과해도 실제 로드된 라이브러리에 심볼이 없으면 런타임에 dyld: Symbol not found로 죽는다.
꼬리 질문
- weak symbol이 런타임에 null로 해소될 때, 그 심볼을 가드 없이 호출하면 무슨 일이 일어나는가?
- 같은 심볼이 두 라이브러리에 defined로 존재하면(duplicate symbol) 링커는 어떻게 처리하며, 무엇이 위험한가?
- C++의
extern "C"는 정확히 심볼의 무엇을 바꿔서 C와의 연결을 가능하게 하는가?
Q5. dSYM과 Crash symbolication은 어떻게 연결되는가?
릴리스 빌드는 크기·보안 때문에 함수 이름·파일·줄 같은 디버그 정보를 바이너리에서 떼어 낸다. 그래서 크래시 리포트에는 함수 이름이 아니라 메모리 주소만 남는다. 그 디버그 정보(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에 묶어 주는 앵커 역할을 한다.
iOS에서는
dSYM 생성은 빌드 설정 DEBUG_INFORMATION_FORMAT가 결정한다. 릴리스 구성의 dwarf-with-dsym이어야 dsymutil이 돌아 별도 .dSYM 번들이 생기고, 디버그 기본값 dwarf는 DWARF를 .o 파일에 남겨 둔 채 dSYM을 만들지 않는다(실행 파일엔 debug map만 있다). UUID는 dwarfdump --uuid로 바이너리와 dSYM 양쪽에서 확인해 일치 여부를 본다. 실제 변환은 atos나 symbolicatecrash로 하며, 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 문서에 정리돼 있다.
실험 · 도구
# 바이너리와 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)프로젝트 적용
- 릴리스/아카이브 구성에서
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym을 강제하고, 모든 아카이브의 dSYM을 버전별로 보관한다. 재빌드하면 UUID가 바뀌어 예전 크래시를 못 푼다. - Crashlytics/Sentry를 쓰면 dSYM 업로드를 CI에 자동화한다. 특히 SPM/CocoaPods 의존 프레임워크의 dSYM 누락으로 서드파티 프레임 안이 안 풀리는 일이 잦다.
- 심볼리케이션이 실패하면 제일 먼저
dwarfdump --uuid로 UUID 일치부터 확인한다. 코드 문제가 아니라 dSYM 매칭 문제인 경우가 대부분이다.
"dSYM은 바이너리 안에 들어 있다"거나 "아무 dSYM으로나 심볼리케이션된다"는 오해다. 릴리스 바이너리는 심볼이 스트립되어 있고, DWARF는 별도 dSYM 번들에 있다. 그리고 심볼리케이터는 UUID가 정확히 일치하는 dSYM만 쓴다 — 같은 소스라도 재빌드하면 UUID가 달라져 예전 dSYM은 쓸 수 없다. "빌드 번호만 같으면 된다"도 아니다. 앵커는 언제나 UUID다.
꼬리 질문
- 크래시 리포트에 로드 주소(slide)가 없으면
atos로 왜 정확히 심볼리케이션할 수 없는가? - 같은 앱의 서로 다른 두 빌드가 우연히 같은 함수 배치를 가져도 dSYM을 바꿔 쓰면 안 되는 이유는?
- 시스템 프레임워크 프레임(UIKit 등)은 내 dSYM에 없는데, 그건 무엇으로 어떻게 심볼리케이션되는가?