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

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(Application Binary Interface, 컴파일된 코드끼리 지켜야 하는 이진 수준 호출 규약) 안정화가 이뤄진 iOS 12.2부터는 이 라이브러리가 OS에 내장되어 시스템에서 공유되지만, 그 이전 iOS에서는 앱 번들의 Frameworks/ 폴더에 Swift 표준 라이브러리를 통째로 동봉해야 했다. 참고로 예전 App Store 업로드에서 쓰이던 Bitcode 중간 표현은 Xcode 14부터 사용 중단(deprecated)되었다.

실험 · 도구

파이프라인 각 단계의 산출물을 직접 뽑아 보면 이해가 빨라진다. dyld의 로드 동작은 환경 변수로 관찰할 수 있다(DYLD_PRINT_LIBRARIES로 로드되는 이미지, DYLD_PRINT_INITIALIZERS로 초기화 루틴). 예전에 쓰이던 DYLD_PRINT_STATISTICS는 dyld4가 도입된 iOS 15 / macOS Monterey부터 지원되지 않으므로, pre-main 구간 측정은 Instruments의 App Launch(Time Profiler) 템플릿이나 MetricKit의 MXAppLaunchMetric으로 해야 한다. 예상 결과: 단계마다 파일이 점점 저수준(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_LIBRARIES=1 ./a.out     # 로드되는 이미지 목록
#   (DYLD_PRINT_STATISTICS는 dyld4(iOS 15+)부터 제거되어 아무것도 출력하지 않는다)

프로젝트 적용

빌드가 느리면 "컴파일이 느린지, 링크가 느린지"부터 분리해 본다. Whole Module Optimization은 컴파일 최적화 시간을, 동적 프레임워크 개수는 링크·로드 시간을 좌우한다.

컴파일이 느린지 링크가 느린지 먼저 분리
# 빌드 시간을 감으로 고치면 엉뚱한 곳을 손대게 된다. 단계별로 나눠 잰다.

# ✅ ① 전체 빌드 타이밍 요약 — 어느 단계가 큰지 한눈에 보인다
xcodebuild -project App.xcodeproj -scheme App \
  -configuration Debug clean build \
  -showBuildTimingSummary
#   CompileSwiftSources   142.3 s
#   Ld                     18.7 s        ← 링크가 크면 동적 프레임워크·심볼 수를 본다
#   CompileAssetCatalog     4.1 s

# ✅ ② 컴파일이 크다면 — 느린 함수·표현식을 찾는다
#    Build Settings → Other Swift Flags 에 추가
#      -Xfrontend -warn-long-function-bodies=200
#      -Xfrontend -warn-long-expression-type-checking=200
#    → 200ms를 넘는 지점이 경고로 뜬다 (대개 타입 추론 폭발)

# 흔한 원인: 복잡한 리터럴 표현식
#   ❌ let x = [1, 2.0, 3].map { $0 * 2 } .reduce(0, +) / Double(n) + offset
#   ✅ 중간 변수로 쪼개고 타입을 명시하면 몇 초가 몇 밀리초가 된다
#      let values: [Double] = [1, 2, 3]

# ✅ ③ 파일별 컴파일 시간 순위
xcodebuild ... OTHER_SWIFT_FLAGS="-Xfrontend -debug-time-function-bodies" 2>&1 \
  | grep -E '^[0-9.]+ms' | sort -rn | head -20
#   (-driver-time-compilation은 현행 swift-driver에서 아무 출력도 하지 않는다.
#    파일 단위 순위는 Xcode의 Build Timeline / -showBuildTimingSummary로 본다)

# ✅ ④ 링크가 크다면
#    · 동적 프레임워크 개수를 줄인다(정적으로 합친다)
#    · 사용하지 않는 의존성을 제거한다
#    · Xcode 15+의 새 링커(ld-prime)를 쓰는지 확인한다
otool -L App.app/App | tail -n +2 | wc -l        # 동적 의존 개수

# ⚠️ Debug와 Release는 병목이 다르다. Debug는 증분 컴파일,
#    Release는 WMO·최적화가 시간을 먹는다. 목적에 맞는 구성으로 잰다.

런치가 느릴 땐 Instruments의 App Launch 템플릿으로 pre-main 구간(dylib 로딩·fixup·ObjC setup·initializer)을 먼저 측정한다. 감으로 최적화하지 말 것.

런치가 느리면 Instruments App Launch부터
# pre-main 구간(dyld가 이미지를 올리고 fixup을 적용하는 시간)은 main 이후 코드를 아무리
# 최적화해도 줄지 않는다. 줄이려면 dylib 수·심볼 수·ObjC 클래스 수·전역 초기화 같은
# '구조'를 건드려야 한다.
# 무엇이 큰지 먼저 확인해야 처방이 정해진다.

# ✅ 설정
#   Xcode → Product → Profile → App Launch 템플릿
#   (DYLD_PRINT_STATISTICS / _DETAILS는 iOS 15의 새 dyld부터 제거되어 아무것도 안 찍힌다.
#    옛 블로그 글을 따라 스킴 환경 변수에 넣지 말 것 — 아래 출력 형식은 iOS 14 이하의 것이다.)

# 옛 출력 예시와 읽는 법 — 지금은 App Launch 템플릿의 구간 이름으로 같은 것을 본다
#   Total pre-main time: 512.30 milliseconds (100.0%)
#            dylib loading time: 240.11 ms  (46.8%)   ← 동적 프레임워크가 많다
#           rebase/binding time:  96.40 ms  (18.8%)   ← 포인터·심볼이 많다
#               ObjC setup time:  41.02 ms  ( 8.0%)   ← ObjC 클래스·카테고리가 많다
#              initializer time: 134.77 ms  (26.3%)   ← +load, __attribute__((constructor))

# 처방
#   dylib loading 크다   → 동적 프레임워크를 정적으로 합친다 (가장 효과 큼)
#   rebase/binding 크다  → 심볼 수를 줄인다. 불필요한 public 노출을 internal로.
#   ObjC setup 크다      → ObjC 클래스·카테고리를 줄인다
#   initializer 크다     → +load를 initialize로, 전역 초기화를 지연시킨다

# ✅ post-main(첫 화면까지)은 Instruments의 App Launch 템플릿으로 본다
#    또는 코드로 직접 계측
#      os_signpost로 didFinishLaunching ~ 첫 프레임 구간을 감싼다

# ✅ 실사용자 분포는 MetricKit으로 수집한다
#    MXAppLaunchMetric.histogrammedTimeToFirstDraw → p50/p90을 추적

# ⚠️ 시뮬레이터 수치는 의미가 없다. 반드시 실기기 + Release로 잰다.
#    콜드 스타트를 재려면 앱을 완전히 종료하고 기기를 재부팅한 뒤 첫 실행을 본다.

"컴파일 에러"와 "링크 에러"를 헷갈리지 말 것. 타입 불일치는 컴파일 에러, Undefined symbols는 링크 에러로 원인 계층이 완전히 다르다.

컴파일 에러와 링크 에러를 구분
import Foundation

// 두 오류는 원인 계층이 완전히 다르다. 메시지 형태로 즉시 구분할 수 있다.

// ── 컴파일 에러: 타입·문법 문제. 파일 하나 안에서 판정된다.
func compileErrors() {
    // error: cannot convert value of type 'String' to expected argument type 'Int'
    // error: value of type 'Foo' has no member 'bar'
    // error: missing argument for parameter 'x' in call
    //
    // → 소스를 고친다. 파일:줄 위치가 정확히 나온다.
}

/*  ── 링크 에러: 심볼을 못 찾는다. 모든 오브젝트를 합칠 때 판정된다.

    Undefined symbols for architecture arm64:
      "_OBJC_CLASS_$_FIRApp", referenced from:
          objc-class-ref in AppDelegate.o
    ld: symbol(s) not found for architecture arm64

    → 소스가 아니라 '빌드 구성'을 본다:
       ① 그 프레임워크가 Link Binary With Libraries에 있는가?
       ② 아키텍처가 맞는가? (lipo -info)
       ③ C 상호운용이면 extern "C" / 모듈맵이 맞는가?

    ── 런타임 링크 에러: 로드 시점에 못 찾는다.

    dyld: Symbol not found: _someNewAPI
      Referenced from: /var/.../MyApp
      Expected in: /System/Library/Frameworks/Foo.framework/Foo

    → SDK/OS 버전 불일치나 @available 가드 누락이다.                        */

// ✅ 런타임 링크 에러 방지 — 가용성을 명시한다
func useNewAPI() {
    if #available(iOS 17.0, *) {
        newAPI()
    } else {
        legacyFallback()
    }
}

// ✅ 약한 링크가 필요하면 (선택적 프레임워크)
//    Build Phases → Link Binary → Status: Optional
//    그리고 사용 전에 nil 체크

@available(iOS 17.0, *) func newAPI() {}
func legacyFallback() {}
⚠️ 흔한 오해

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

에러 메시지만 봐도 어느 관문에서 막혔는지 알 수 있다 빌드 타임 · 내 맥 런타임 · 사용자 기기 ① 컴파일 swiftc ② 링크 ld ③ 로드 dyld ④ 실행 내 코드 Cannot convert type 타입 검사 실패 Undefined symbols 라이브러리 안 넘김 Library not loaded dylib 파일이 없음 Symbol not found 심볼 버전 불일치 ① ②는 빌드하다 실패하고, ③ ④는 사용자 기기에서 크래시한다. 컴파일이 됐다고 링크·로드까지 안전한 게 아니다.
같은 "심볼을 못 찾음"이라도 빌드 중이면 링커 에러, 실행 중이면 dyld 에러다. 시점이 다르면 고칠 곳도 다르다.
🧒 쉽게 이해하기

반 아이들이 각자 글을 써서 문집을 만든다고 해 보자. 각자 원고를 인쇄용 판으로 다듬는 게 컴파일이다. 이때 어떤 원고에는 "뒷이야기는 지훈이 글에 있음, ○쪽"이라고 적어야 하는데 쪽수 자리는 비워 둔다 — 아직 책을 안 묶었으니 몇 쪽인지 알 수가 없다. 그 빈칸이 심볼 참조다. 원고를 다 모아 순서를 정하고 그 빈칸에 진짜 쪽수를 채워 넣는 게 링크다. 그래서 내 원고 자체는 흠 없이 잘 다듬어졌는데(컴파일 성공) 지훈이가 원고를 안 냈으면 거기서 터진다(링크 실패). 완성된 책을 책상에 펴고 읽기 시작하는 게 로드인데, 이때 dyld는 표지 뒤에 적힌 "같이 봐야 할 책 목록"을 보고 그 책들까지 꺼내 온다.

Swift가 남다른 건 원고를 인쇄판으로 바로 넘기지 않고 중간에 자기네 전용 교정본을 한 번 만든다는 점이다. 그게 SIL이다. 초기화를 빠뜨린 곳, ARC(메모리를 자동으로 정리해 주는 장치) 관련 낭비, 제네릭 같은 Swift만의 문제를 이 교정본에서 잡고 나서야 LLVM으로 넘긴다. 비유가 깨지는 데도 하나 있다 — 책은 쪽수가 한 번 정해지면 안 바뀌지만, 프로그램은 켤 때마다 메모리 어디에 놓일지가 달라진다(ASLR). 그래서 dyld가 올리면서 쪽수를 한 번 더 보정하는데, 그게 rebase다.

꼬리 질문

Whole Module Optimization을 켜면 SIL 단계에서 무엇이 달라지고, 왜 증분 빌드에는 불리한가?

기본(파일 단위) 모드에서는 컴파일러가 파일 하나만 보고 SIL을 만들어 다른 파일에 정의된 함수는 불투명한 외부 호출로 남긴다. WMO는 모듈 전체를 한 번에 SIL로 올려 놓고 보기 때문에, 모듈 안에서만 쓰이는 함수의 인라이닝, 제네릭 특수화, final이 아니어도 오버라이드가 없다고 증명되는 메서드의 devirtualization 같은 교차 파일 최적화가 가능해진다.

대신 컴파일 단위가 모듈 전체라, 파일 한 줄만 고쳐도 원칙적으로 모듈 전부를 다시 SIL로 올려 재분석해야 한다. 그래서 파일별로 바뀐 것만 다시 컴파일하는 증분 빌드의 이점이 거의 사라진다. 보통 Debug는 증분 우선(-Onone+파일 단위), Release는 WMO로 나눠 쓴다.

쉽게 말하면 WMO는 책 전체를 책상에 펼쳐 놓고 문장을 서로 다듬는 것과 같다. 멀리 있는 문장끼리도 맞춰 고칠 수 있어 결과물이 매끄럽지만, 한 줄만 바꿔도 책을 통째로 다시 펼쳐야 해서 오래 걸린다. 그래서 평소(디버그)엔 페이지 하나씩만 손보고, 출시(릴리스) 때만 전체를 펼친다.
정적 링크만으로 앱을 구성하면 dyld의 로드 단계에서 어떤 작업이 사라지는가?

의존 dylib를 재귀적으로 찾아 매핑하고, 각 이미지의 초기화 루틴을 도는 일, 그리고 그 이미지들 사이의 외부 심볼을 실제 주소로 잇는 bind 작업이 크게 줄거나 사라진다. 코드가 이미 실행 파일 하나에 흡수돼 있으니 dyld가 여러 이미지를 오가며 심볼을 해소할 필요가 없어 런치가 빨라진다.

다만 iOS 앱이 dyld를 완전히 벗어나지는 못한다. UIKit·Foundation 같은 시스템 라이브러리는 여전히 동적으로 로드·bind되고, ASLR 때문에 실행 파일 자신도 rebase는 겪는다. "내가 만든 동적 프레임워크"의 로드·bind 비용이 없어지는 것으로 이해하는 게 정확하다.

쉽게 말하면 짐을 상자 하나로 미리 합쳐 두면 도착해서 여러 상자를 풀 필요가 없어 그만큼 시작이 빠르다. 다만 UIKit 같은 시스템 짐은 어차피 따로 받아 열어야 해서 dyld(앱을 켤 때 필요한 코드를 메모리에 올리고 연결하는 장치)가 완전히 손을 놓지는 못한다.
ASLR이 켜진 상태에서 rebase와 bind는 각각 무엇을 고치는가? 둘의 차이는?

ASLR은 보안을 위해 이미지를 매번 무작위 베이스 주소에 올린다. rebase는 그 이미지 내부를 가리키는 포인터를 고치는 일이다. 링크 당시 preferred 베이스 기준으로 박힌 주소에 실제 로드된 slide(로드 주소 − preferred 베이스)를 더해 준다.

bind다른 이미지에 있는 심볼을 가리키는 참조를 그 심볼의 실제 런타임 주소로 채우는 일이다. 즉 rebase는 "내 안을 가리키는 포인터 보정", bind는 "바깥 심볼 연결"로 대상이 다르다. 최신 dyld/체인드 픽스업에서는 이 둘이 하나의 포맷으로 합쳐져 처리되지만, 개념적 역할은 그대로다.

쉽게 말하면 rebase는 "내 건물 안을 가리키는 화살표"를 건물이 새로 앉은 만큼 통째로 밀어 주는 일이고, bind는 "옆 건물에 사는 사람"의 실제 자리를 찾아 빈칸에 적어 넣는 일이다. 화살표가 가리키는 게 내 안이냐 바깥이냐가 다를 뿐이다.

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에 프리링크됨)에만 해당한다. 개발자가 만든 동적 프레임워크는 각 앱 번들 안에 개별 사본으로 들어가므로 앱 사이 공유가 없다.

앱 A와 앱 B가 같은 코드를 쓸 때, 실제로 몇 벌이 올라가나 ① 정적 링크 (.a) 앱 A 실행 파일 libFoo 코드 복사됨 앱 B 실행 파일 libFoo 코드 복사됨 → libFoo 2벌 앱끼리 공유 없음 ② 내가 만든 동적 프레임워크 앱 A 번들 Foo.framework 사본 앱 B 번들 Foo.framework 사본 → 여전히 2벌 로드·bind 비용만 추가 ③ Apple 시스템 라이브러리 (UIKit 등) 앱 A 앱 B dyld shared cache — UIKit 1벌 → 진짜 1벌 공유 OS가 미리 올려 둔 것
동적으로 만들어도 내 프레임워크는 앱마다 사본이 하나씩이다. 앱 사이에 진짜로 공유되는 건 ③ 시스템 라이브러리뿐이다.

그래서 런치 성능이 걸리면 정적 링크가 유리하다. 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 타입으로 정적/동적 판별

프로젝트 적용

기본은 정적 링크로 시작하고, "재컴파일 없는 교체", "앱 확장과 코드 공유", "순환 의존 분리" 같은 명확한 이유가 있을 때만 동적으로 바꾼다.

기본은 정적 링크로 시작
// Package.swift — SPM에서 링크 타입을 명시적으로 고른다

import PackageDescription

let package = Package(
    name: "Core",
    platforms: [.iOS(.v16)],
    products: [
        // ✅ 기본은 정적 — 런치 시 dylib 로딩·바인딩 비용이 없다
        .library(name: "Core", type: .static, targets: ["Core"]),
        .library(name: "Networking", type: .static, targets: ["Networking"]),

        // ✅ 동적은 '명확한 이유'가 있을 때만
        //    · 앱 본체와 확장(위젯·공유시트)이 코드를 공유해야 한다
        //    · 재컴파일 없이 교체해야 한다
        //    · 순환 의존을 끊어야 한다
        .library(name: "SharedUI", type: .dynamic, targets: ["SharedUI"]),
    ],
    targets: [
        .target(name: "Core"),
        .target(name: "Networking", dependencies: ["Core"]),
        .target(name: "SharedUI", dependencies: ["Core"]),
    ]
)

/*  CocoaPods에서도 명시한다
      use_frameworks! :linkage => :static

    Xcode 프로젝트 타깃이면
      Build Settings → Mach-O Type → Static Library

    ⚠️ 정적 링크의 대가
       · 앱 바이너리가 커진다 (__TEXT 페이지 인 비용 증가)
       · 여러 타깃이 같은 라이브러리를 쓰면 각각에 복제된다
       · 빌드 시간이 늘 수 있다(링크 단계)

    ✅ 판단: 앱 본체 하나뿐이면 정적이 거의 항상 낫다.
       확장이 여럿이고 공유 코드가 크면 그때 동적을 검토한다.                */

SPM/CocoaPods에서 링크 타입을 의식적으로 고른다. CocoaPods use_frameworks! :linkage => :static, SPM 타깃의 .static/.dynamic 지정으로 명시.

링크 타입을 의식적으로 고르고 확인
# 라이브러리가 실제로 어떻게 링크됐는지는 도구로 확인해야 한다.
# "프레임워크니까 동적"이라는 가정은 자주 틀린다.

# ✅ ① 앱이 로드하는 동적 라이브러리 목록
otool -L MyApp.app/MyApp
#   /usr/lib/libSystem.B.dylib
#   @rpath/SharedUI.framework/SharedUI      ← 우리가 만든 동적 프레임워크
#   /System/Library/Frameworks/UIKit.framework/UIKit

# ✅ ② 특정 프레임워크의 Mach-O 타입 확인
file MyFramework.framework/MyFramework
#   ... dynamically linked shared library arm64   → 동적
#   ... current ar archive                        → 정적(.a를 프레임워크로 포장)

# ✅ ③ 아키텍처 확인 (Undefined symbol의 흔한 원인)
lipo -info libCore.a
#   Architectures in the fat file: arm64 x86_64
#   → 시뮬레이터용 arm64가 없으면 M1 맥 시뮬레이터에서 링크 실패

# ✅ ④ 심볼 중복 확인 — 같은 정적 라이브러리를 두 동적 프레임워크가 품으면
nm -gU FrameworkA.framework/FrameworkA | grep 'T _' | sort > /tmp/a.txt
nm -gU FrameworkB.framework/FrameworkB | grep 'T _' | sort > /tmp/b.txt
comm -12 /tmp/a.txt /tmp/b.txt | head
#   겹치는 심볼이 나오면 중복 로드 경고("Class X is implemented in both...")가 뜬다
#   → 공용 의존은 한 곳에서만 링크되게 경계를 정리한다

# ✅ ⑤ 바이너리 크기 기여도
xcrun size -m MyApp.app/MyApp | head -20

# ⚠️ SPM의 .library type을 생략하면 Xcode가 자동으로 정한다 —
#    의도한 결과인지 위 명령으로 반드시 확인한다

같은 정적 라이브러리를 서로 다른 두 동적 프레임워크가 동시에 품으면 심볼 중복/중복 로드가 생긴다. 공용 의존은 한 곳에서만 링크되게 경계를 정리할 것.

공용 의존은 한 곳에서만 링크
/*  문제 상황
      AppTarget
        ├── FeatureA.framework (동적)  ──┐
        └── FeatureB.framework (동적)  ──┴── 둘 다 Core.a(정적)를 품는다

    결과
      · Core의 심볼이 바이너리에 두 벌 존재한다
      · ObjC 클래스면 런타임 경고: "Class Foo is implemented in both ...
        One of the two will be used. Which one is undefined."
      · 전역 상태(싱글턴·static var)가 두 벌이 되어 조용히 어긋난다
      · 앱 크기도 그만큼 늘어난다                                           */

import PackageDescription

// ✅ 해법 ① Core도 동적으로 만들어 한 벌만 존재하게 한다
let packageA = Package(
    name: "App",
    products: [
        .library(name: "Core", type: .dynamic, targets: ["Core"]),      // 한 벌
        .library(name: "FeatureA", type: .dynamic, targets: ["FeatureA"]),
        .library(name: "FeatureB", type: .dynamic, targets: ["FeatureB"]),
    ],
    targets: [
        .target(name: "Core"),
        .target(name: "FeatureA", dependencies: ["Core"]),
        .target(name: "FeatureB", dependencies: ["Core"]),
    ]
)

// ✅ 해법 ② 전부 정적으로 하고 앱 타깃에서 한 번만 링크한다
let packageB = Package(
    name: "App",
    products: [
        .library(name: "Core", type: .static, targets: ["Core"]),
        .library(name: "FeatureA", type: .static, targets: ["FeatureA"]),
        .library(name: "FeatureB", type: .static, targets: ["FeatureB"]),
    ],
    targets: [
        .target(name: "Core"),
        .target(name: "FeatureA", dependencies: ["Core"]),
        .target(name: "FeatureB", dependencies: ["Core"]),
    ]
)
// 세 라이브러리가 모두 정적이라 Core의 오브젝트가 최종 실행 파일에 한 번만 편입된다 — 애초에 중복이 생기지 않는다 (정적 링크의 이점)

/*  ✅ 검증
      · 런타임 로그에서 "implemented in both" 경고가 없는지
      · 싱글턴 주소가 같은지 확인
          print(Unmanaged.passUnretained(Core.shared).toOpaque())
        두 프레임워크에서 찍어 보고 값이 다르면 두 벌인 것이다              */
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

점심을 도시락으로 싸 갈지 급식으로 먹을지 고르는 문제와 비슷하다. 정적 라이브러리는 도시락이다. 반찬을 내 가방에 다 넣어 다니니 가방은 무겁지만, 급식실이 문을 닫아도 나는 밥을 먹는다. 동적 라이브러리는 급식이다. 가방은 가벼운데 급식실이 없거나 메뉴가 바뀌어 있으면 못 먹는다 — 파일이 없거나 버전이 안 맞아 실행이 실패하는 게 딱 이 경우다. 급식의 최대 장점은 한 솥으로 여러 반이 나눠 먹는다는 건데, 링크 방식 논쟁의 절반은 이 "나눠 먹기"가 진짜로 되느냐에 걸려 있다.

iOS의 함정이 바로 거기다. 내가 만든 동적 프레임워크는 급식처럼 보이지만, 실은 앱마다 자기 급식실을 통째로 짊어지고 다니는 꼴이다. 앱 번들 안에 각자 사본이 들어가니 옆 앱과 나눠 먹을 게 없다. 진짜로 한 솥을 나눠 쓰는 건 UIKit 같은 Apple 시스템 라이브러리(dyld shared cache에 미리 올려 둔 것)뿐이다. 그래서 "동적으로 만들면 메모리가 절약된다"는 말은 iOS에선 대체로 성립하지 않고, 대신 앱을 켤 때마다 급식실 문 여는 시간(dyld가 이미지를 찾아 붙이는 비용)만 더 낸다.

꼬리 질문

같은 정적 라이브러리를 앱 본체와 앱 확장이 각각 정적으로 링크하면 무엇이 문제인가?

앱 본체와 확장은 별개의 실행 파일이라, 각자 그 정적 라이브러리 코드를 통째로 복사해 넣는다. 그래서 바이너리 크기가 중복되고, 두 프로세스가 동시에 살아 있으면 물리 메모리에도 같은 코드가 따로 올라간다(정적 코드는 공유되지 않는다).

더 미묘한 문제는 라이브러리가 전역 상태(전역 변수, dispatch_once 싱글턴, Objective-C 클래스 레지스트리)를 가질 때다. 각 프로세스가 그 상태의 별도 사본을 갖게 되어 "확장에서 바꾼 값이 본체에 안 보인다" 같은 혼동이 생긴다. 이 라이브러리를 하나의 동적 프레임워크로 빼면 번들에 사본이 하나만 남고(코드 __TEXT 페이지는 두 프로세스가 물리적으로 공유 가능) 바이너리 크기 중복과 코드 중복 로드가 줄어든다. 다만 앱 본체와 확장은 여전히 별개 프로세스라 __DATA의 가변 전역 상태는 공유되지 않는다 — 확장에서 바꾼 값을 본체에 보이게 하려면 동적 프레임워크가 아니라 App Groups(공유 컨테이너, UserDefaults(suiteName:), 공유 파일 등)를 써야 한다.

쉽게 말하면 같은 교과서를 두 사람이 각자 한 권씩 복사해 들고 다니는 꼴이라 가방(바이너리)이 둘 다 무거워진다. 게다가 각자 자기 책에만 메모하니 한쪽 메모가 다른 쪽엔 안 보인다 — 메모를 같이 보려면 책이 아니라 둘이 함께 쓰는 게시판(App Groups)이 따로 있어야 한다.
mergeable libraries는 정확히 어느 시점에 무엇을 병합해 정적 링크의 이점을 재현하는가?

개발 중에는 각 모듈을 동적 프레임워크로 두어 증분 빌드·빠른 링크의 이점을 유지한다. 핵심은 이 동적 라이브러리들에 링커가 병합 가능(mergeable) 메타데이터를 심어 둔다는 점이다.

그리고 릴리스 빌드의 링크 시점에, 최종 실행 파일(또는 상위 프레임워크)을 만드는 링커가 그 메타데이터를 이용해 여러 동적 라이브러리의 내용을 하나의 바이너리 안으로 끌어와 합친다. 결과적으로 런타임에는 로드·bind할 이미지 수가 줄어 정적 링크처럼 런치가 빨라지면서, 개발 중에는 동적의 편의를 그대로 쓰는 절충이 된다.

쉽게 말하면 평소엔 레고 부품을 따로따로 두고 고칠 것만 빨리 갈아 끼우다가, 출시할 때 링커가 그 부품들을 하나로 딱 붙여 버린다. 그래서 앱을 켤 때는 붙은 덩어리 하나만 올리면 되니 빠르고, 개발 중엔 여전히 부품 단위로 편하게 만질 수 있다.
지연 바인딩(lazy binding)은 동적 라이브러리의 어떤 비용을 언제로 미루는가?

동적 라이브러리의 함수 심볼을 실제 주소로 해소하는 bind 비용을, 런치 순간이 아니라 그 함수를 처음 호출하는 순간으로 미루는 것이다. 전통적으로는 함수 호출이 스텁(PLT 비슷한 __stubs)을 거치고, 첫 호출 때 dyld의 해소 루틴이 불려 실제 주소를 찾아 채운 뒤 이후 호출은 바로 이어지게 했다.

덕분에 앱이 실제로 안 쓰는 심볼은 해소 비용을 아예 안 낸다. 다만 최근 dyld는 런치 시 fixup을 한꺼번에 적용하는 방식(chained fixups)으로 옮겨가, 예전만큼 lazy binding에 의존하지 않는다는 점은 조건으로 밝혀 둔다.

쉽게 말하면 안 걸지도 모르는 번호를 미리 다 저장해 두지 않고, 처음 전화 걸 때 그제야 찾아 저장하는 식이다. 그래서 실제로 한 번도 안 부른 함수는 주소 찾는 값을 아예 안 낸다. 다만 요즘 dyld는 켤 때 한꺼번에 정리해 버려서 이 방식에 예전만큼 기대지는 않는다.

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가 그 자리를 대체했다.

포장 방식과 링크 방식은 서로 다른 축 — 네 칸이 모두 존재한다 정적 빌드 때 실행 파일에 흡수 동적 로드 때 dyld가 연결 라이브러리 코드 산출물만 프레임워크 코드·헤더·리소스를 한 폴더에 담은 것 libFoo.a 리소스는 못 담음 별도 .bundle 필요 libFoo.dylib 리소스는 못 담음 실행 시 파일이 있어야 함 Foo.framework Mach-O 타입: 정적 리소스는 앱 번들로 복사 Foo.framework Mach-O 타입: DYLIB 번들째 앱 안에 들어감
아래 두 칸은 이름도 Foo.framework로 똑같다. 겉포장만 봐서는 정적인지 동적인지 알 수 없다는 뜻이다.
구분Library (.a / .dylib)Framework (.framework)
정체컴파일된 코드 산출물코드+헤더+리소스+메타 번들(디렉터리)
헤더/모듈맵 포함별도 관리번들 안에 포함
리소스 동봉불가(별도 .bundle)가능
정적/동적둘 다 가능둘 다 가능(직교)
다중 플랫폼 배포lipo fat 바이너리(한계 있음)XCFramework로 해결

프로젝트 적용

서드파티에 바이너리로 배포할 땐 헤더/리소스/다중 플랫폼을 한 번에 담는 XCFramework를 쓴다. 순수 소스 배포라면 SPM 소스 패키지가 더 단순하다.

바이너리 배포는 XCFramework
# 여러 플랫폼(기기·시뮬레이터·Catalyst)을 한 배포물에 담으려면 XCFramework다.
# 예전 fat 바이너리는 같은 arm64인데 기기용과 시뮬레이터용을 구분할 수 없었다.

# ✅ ① 각 플랫폼별로 아카이브
xcodebuild archive \
  -scheme Core \
  -destination "generic/platform=iOS" \
  -archivePath build/ios.xcarchive \
  SKIP_INSTALL=NO \
  BUILD_LIBRARY_FOR_DISTRIBUTION=YES     # ← 모듈 안정성. 반드시 켠다.

xcodebuild archive \
  -scheme Core \
  -destination "generic/platform=iOS Simulator" \
  -archivePath build/sim.xcarchive \
  SKIP_INSTALL=NO \
  BUILD_LIBRARY_FOR_DISTRIBUTION=YES

# ✅ ② 하나로 합친다
xcodebuild -create-xcframework \
  -framework build/ios.xcarchive/Products/Library/Frameworks/Core.framework \
  -debug-symbols "$PWD/build/ios.xcarchive/dSYMs/Core.framework.dSYM" \
  -framework build/sim.xcarchive/Products/Library/Frameworks/Core.framework \
  -debug-symbols "$PWD/build/sim.xcarchive/dSYMs/Core.framework.dSYM" \
  -output Core.xcframework

# ⚠️ BUILD_LIBRARY_FOR_DISTRIBUTION=YES를 빼면
#    Swift 버전이 다른 프로젝트에서 "module compiled with Swift X.Y cannot be
#    imported by Swift X.Z" 오류가 난다.

# ⚠️ dSYM을 함께 넣지 않으면 소비자 쪽에서 크래시 심볼리케이션이 안 된다.

# ✅ ③ SPM으로 배포하려면 binaryTarget
#    .binaryTarget(name: "Core",
#                  url: "https://.../Core.xcframework.zip",
#                  checksum: "abc123...")
swift package compute-checksum Core.xcframework.zip

# ✅ 순수 소스 배포라면 XCFramework가 필요 없다 — SPM 소스 패키지가 훨씬 단순하다

리소스(에셋 카탈로그, 로컬라이즈 파일)를 함께 배포해야 하면 프레임워크(또는 리소스 번들을 갖춘 SPM 타깃)를 택한다. 맨 .a로는 리소스가 안 따라간다.

리소스가 있으면 프레임워크 또는 SPM 리소스 타깃
// .a(정적 라이브러리)에는 코드만 들어간다. 에셋·로컬라이즈·nib는 따라가지 않는다.

// ✅ SPM에서 리소스를 포함하는 방법
import PackageDescription

let package = Package(
    name: "DesignKit",
    defaultLocalization: "ko",
    products: [.library(name: "DesignKit", targets: ["DesignKit"])],
    targets: [
        .target(
            name: "DesignKit",
            resources: [
                .process("Resources/Assets.xcassets"),   // 컴파일·최적화된다
                .process("Resources/ko.lproj"),
                .copy("Resources/config.json")           // 그대로 복사된다
            ]
        )
    ]
)

// ✅ 코드에서 접근할 때는 Bundle.module을 쓴다 (SPM이 자동 생성)
import UIKit

enum DesignKitAssets {
    static var logo: UIImage? {
        UIImage(named: "logo", in: .module, compatibleWith: nil)
    }

    static func localized(_ key: String) -> String {
        NSLocalizedString(key, bundle: .module, comment: "")
    }

    static func config() throws -> Data {
        guard let url = Bundle.module.url(forResource: "config", withExtension: "json")
        else { throw ResourceError.missing }
        return try Data(contentsOf: url)
    }
}

// ⚠️ Bundle.module은 '리소스가 선언된 SPM 타깃 안'에서만 존재한다.
//    앱 타깃에서 쓰면 컴파일 에러다 — 거기서는 Bundle.main을 쓴다.

// ⚠️ .framework로 배포한다면 Bundle(for:)로 자기 번들을 찾는다
final class BundleToken {}
extension Bundle {
    static var designKit: Bundle { Bundle(for: BundleToken.self) }
}

enum ResourceError: Error { case missing }

"프레임워크로 만들었으니 자동으로 동적"이라고 가정하지 말 것. Mach-O 타입/링크 설정을 실제로 확인하고 결정한다.

'프레임워크=동적'이라고 가정하지 않기
# .framework는 '포장 방식'이고, 정적/동적은 '링크 방식'이다. 서로 직교한다.
# 네 조합이 모두 존재한다:
#   정적 라이브러리(.a) / 동적 라이브러리(.dylib)
#   정적 프레임워크     / 동적 프레임워크

# ✅ 실제 타입을 확인한다
file Core.framework/Core
#   Mach-O 64-bit dynamically linked shared library arm64  → 동적 프레임워크
#   current ar archive                                     → 정적 프레임워크

# ✅ Mach-O 헤더로도 확인 가능
otool -hv Core.framework/Core | head -5
#   magic      cputype  filetype
#   MH_MAGIC_64  ARM64   DYLIB      → 동적
#   MH_MAGIC_64  ARM64   OBJECT     → 정적

# ✅ 앱이 실제로 동적 로드하는지
otool -L MyApp.app/MyApp | grep Core
#   출력이 있으면 동적, 없으면 정적으로 합쳐진 것

# ✅ Xcode에서 설정하는 곳
#   프레임워크 타깃 → Build Settings → Mach-O Type
#     Dynamic Library / Static Library
#   앱 타깃 → General → Frameworks, Libraries, and Embedded Content
#     Embed & Sign      → 동적 (번들에 포함되어 런타임에 로드)
#     Do Not Embed      → 정적이거나 시스템 제공

# ⚠️ 흔한 사고: 정적 프레임워크를 Embed & Sign으로 설정
#    → 바이너리에 이미 합쳐졌는데 번들에도 복사되어 앱 크기가 두 배가 된다
#    → 심사에서 "unused framework" 경고가 뜨기도 한다

# ✅ 앱 번들에 실제로 무엇이 들어갔는지 확인
ls -la MyApp.app/Frameworks/
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

원래 뜻으로 보면 둘의 차이는 "누가 먼저 말을 거느냐"다. 라이브러리는 공구함이다. 필요할 때 내가 드라이버를 꺼내 쓰고, 언제 쓸지는 전적으로 내가 정한다. 프레임워크는 오케스트라에 가깝다. 지휘자가 곡 전체를 끌고 가고 나는 내 파트가 나올 때 연주할 뿐이다 — UIKit이 viewDidLoad를 대신 불러 주는 게 딱 이 관계다.

그런데 Apple 쪽에서 이 질문이 나오면 대개 다른 이야기, 즉 "어떻게 포장했느냐"를 묻는 것이다. .framework는 사실 폴더 하나로 된 선물 상자다. 안에 부품(바이너리)만 있는 게 아니라 설명서(헤더·모듈맵)와 스티커·그림(리소스)까지 같이 들어 있다. .a는 부품만 든 봉지라서, 그림도 같이 주려면 봉지를 하나 더 챙겨야 한다. 여기서 제일 많이 미끄러지는 지점이 있다. 상자냐 봉지냐는 "포장"이고, 정적이냐 동적이냐는 "언제 붙이느냐"라서 둘이 서로를 결정하지 않는다. 그래서 "정적인 상자"도 얼마든지 존재한다.

꼬리 질문

같은 arm64 슬라이스라도 디바이스용과 시뮬레이터용을 fat 바이너리에 함께 둘 수 없는 이유는?

fat(universal) 바이너리는 슬라이스를 CPU 아키텍처로만 구분한다. 플랫폼이 달라도 CPU가 같으면 슬라이스가 충돌한다 — Apple Silicon 이전에도 Mac Catalyst와 시뮬레이터가 둘 다 x86_64라 같은 문제가 있었고(XCFramework 도입 동기가 이쪽이다), Apple Silicon 이후 디바이스와 시뮬레이터가 모두 arm64가 되면서 더 흔해졌다. 둘을 한 fat 바이너리에 넣으면 아키텍처 식별자가 충돌해 lipo가 구분할 수 없다.

실제로는 아키텍처가 같아도 플랫폼과 타깃 트리플이 다르다(디바이스 arm64-apple-ios vs 시뮬레이터 arm64-apple-ios-simulator). Mach-O 자체는 이 차이를 LC_BUILD_VERSION의 platform 값으로 기록하지만 fat(universal) 헤더가 cputype/cpusubtype만으로 슬라이스를 식별해 담지 못해서, 플랫폼별 슬라이스를 디렉터리로 나눠 담는 XCFramework가 도입돼 이 문제를 해결했다.

쉽게 말하면 fat 바이너리는 칸을 "CPU 종류"로만 나눈다. 그런데 아이폰용과 시뮬레이터용이 둘 다 arm64라 이름표가 똑같아서 한 칸에 같이 못 넣는다. 그래서 아예 폴더를 따로 파서 나눠 담는 게 XCFramework다.
정적 프레임워크에 리소스를 넣으면 그 리소스는 최종 앱 번들 어디로 가는가?

정적 프레임워크의 코드는 링크 시점에 앱 실행 파일 안으로 흡수되므로, .framework라는 별도 번들이 앱 안에 남지 않는다. 그래서 그 안의 리소스는 프레임워크 폴더째로 복사되는 게 아니라, 빌드 시스템이 앱 번들 최상위(또는 리소스 번들)로 따로 복사해 줘야 실행 시 찾을 수 있다.

이 때문에 리소스 접근 코드에서 번들을 잘못 잡는 실수가 잦다. SPM은 리소스를 가진 정적 타깃에 대해 .bundle을 만들고 Bundle.module로 그 번들을 가리키게 해 준다. 즉 정적 프레임워크의 리소스는 "코드가 흡수된 뒤에도 리소스는 어딘가에 별도로 놓여야 한다"가 핵심이다.

쉽게 말하면 정적이면 코드는 앱 몸통에 녹아 들어가서 프레임워크 폴더 자체가 앱 안에 안 남는다. 그러니 폴더에 얹혀 있던 그림·소리 같은 리소스는 갈 곳을 잃고, 빌드가 앱 본체 쪽으로 따로 옮겨 줘야 실행할 때 찾을 수 있다. 코드와 리소스가 서로 다른 자리로 흩어지는 셈이다.
모듈맵(module.modulemap)이 하는 일은 무엇이고, @import는 헤더 #import와 어떻게 다른가?

모듈맵은 흩어진 헤더들을 하나의 모듈로 묶어 "이 헤더 묶음이 UIKit이라는 모듈이다"라고 컴파일러에 선언하는 파일이다. 덕분에 컴파일러는 헤더를 매번 텍스트로 펼치는 대신, 한 번 파싱한 모듈을 미리 컴파일된 형태로 캐시해 재사용한다.

#import는 전처리기가 헤더 텍스트를 그 자리에 그대로 붙여 넣는 것이라, 매크로 오염과 반복 파싱 비용이 있다. @import Foo;는 텍스트 삽입이 아니라 모듈 단위로 불러오는 것이라 파싱이 한 번으로 끝나고, 링크할 프레임워크까지 자동으로 지정된다(autolink). Swift가 C/Objective-C를 쓰는 것도 이 모듈 시스템 위에서 이뤄진다.

쉽게 말하면 #import는 남의 문서를 통째로 복사해 내 문서에 붙이는 것이라, 여러 곳에서 붙일수록 느려지고 서로 내용이 섞여 오염된다. 모듈맵으로 헤더들을 한 묶음으로 정해 두면 @import는 "그 묶음 하나 불러와" 한마디로 끝나고, 컴파일러가 미리 정리해 둔 걸 재사용해서 훨씬 빠르다.

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이 뜨면 소스부터 뒤지지 말고 (1) 그 프레임워크를 Link Binary 단계에 넣었는지, (2) 아키텍처(arm64/시뮬레이터)가 맞는지, (3) C 상호운용이면 extern "C"·모듈맵이 맞는지를 먼저 본다.

Undefined symbol은 빌드 구성부터 확인
# 소스를 뒤지기 전에 이 순서로 확인한다. 대부분 여기서 끝난다.

# ① 그 프레임워크가 링크 단계에 있는가?
#    Xcode → 앱 타깃 → Build Phases → Link Binary With Libraries
#    또는 Build Settings → Other Linker Flags 의 -framework / -l 항목

# ② 아키텍처가 맞는가? (M1 맥에서 특히 흔하다)
lipo -info libCore.a
#   Architectures in the fat file: arm64 x86_64
#   → 기기용 arm64와 시뮬레이터용 arm64는 다르다.
#     둘 다 필요하면 XCFramework를 써야 한다.

# 현재 빌드 대상 확인
xcodebuild -showBuildSettings -scheme App | grep -E 'ARCHS|VALID_ARCHS|SDKROOT'

# ③ C/C++ 상호운용이면 이름 맹글링이 맞는가?
#    C++ 헤더를 C에서 쓰려면
#      #ifdef __cplusplus
#      extern "C" {
#      #endif
#      void my_function(void);
#      #ifdef __cplusplus
#      }
#      #endif
#
#    Swift에서 C를 쓰려면 모듈맵이 필요하다
#      module CLib {
#          header "clib.h"
#          export *
#      }

# ④ 심볼이 실제로 라이브러리에 있는지 확인
nm -gU libCore.a | grep fetchData
#   출력이 없으면 그 심볼은 정말 없는 것이다 (오타·조건부 컴파일·접근 수준)

# ⑤ 어떤 오브젝트가 그 심볼을 요구하는지
#    오류 메시지의 "referenced from:" 줄이 알려 준다
#      referenced from: objc-class-ref in AppDelegate.o

_OBJC_CLASS_$_가 보이면 프레임워크 링크 누락, $s…면 Swift 심볼 — swift demangle로 어느 타입/함수인지 즉시 확인한다.

심볼 이름이 원인을 알려준다
# 심볼 이름의 접두사만 봐도 어느 계층 문제인지 좁혀진다.

# ── ObjC 클래스 참조
#   "_OBJC_CLASS_$_FIRApp", referenced from: objc-class-ref in AppDelegate.o
#   → 프레임워크 링크 누락. FirebaseCore를 Link Binary에 추가한다.

# ── ObjC 메타클래스
#   "_OBJC_METACLASS_$_MyClass"
#   → 같은 원인. 클래스가 정의된 오브젝트/라이브러리가 안 들어갔다.

# ── C 함수
#   "_sqlite3_open"
#   → 시스템 라이브러리 누락. libsqlite3.tbd를 추가한다.

# ── Swift 심볼 (맹글링됨)
#   "$s4Core6ClientC5fetchyyKF"
xcrun swift demangle '$s4Core6ClientC5fetchyyKF'
#   → Core.Client.fetch() throws -> ()
#   어느 모듈의 어떤 타입·함수인지 즉시 알 수 있다

# 여러 개를 한 번에 풀기 — 링커 출력을 그대로 넘긴다
xcodebuild build 2>&1 | xcrun swift demangle

# ── Swift 심볼 접두사 읽는 법
#   $s      Swift 심볼 시작
#   4Core   모듈 이름 (길이 + 이름)
#   6Client 타입 이름
#   C       class (V=struct, O=enum, P=protocol)
#   F       function

# ✅ 반대로 내 코드의 심볼 이름을 알고 싶으면
nm -gU MyApp.app/MyApp | xcrun swift demangle | grep 'Core.Client'

# ⚠️ 접근 수준도 원인이 된다 — internal 심볼은 모듈 밖에서 안 보인다.
#    라이브러리에서 노출하려면 public 또는 @_spi가 필요하다.

런타임 Symbol not found는 대개 SDK/OS 버전 불일치나 @available 가드 누락이다. 최소 배포 타깃과 API 가용성을 점검한다.

런타임 Symbol not found는 가용성 문제
import Foundation

/*  빌드는 통과했는데 실행 시 죽는다:

      dyld[1234]: Symbol not found: _$s10Foundation8SomeNewAPIVMa
        Referenced from: /var/containers/.../MyApp
        Expected in:     /System/Library/Frameworks/Foundation.framework/Foundation

    원인: 최신 SDK로 빌드했는데 구버전 OS에서 실행됐다.
          그 심볼은 새 OS에만 존재한다.                                     */

// ✅ ① @available로 가드한다 — 컴파일러가 강제해 준다
func useModernAPI() {
    if #available(iOS 17.0, *) {
        modernFeature()
    } else {
        legacyFallback()
    }
}

@available(iOS 17.0, *)
func modernFeature() {}
func legacyFallback() {}

// ✅ ② 타입 전체가 새 OS 전용이면 타입에 붙인다
@available(iOS 17.0, *)
struct NewWidget {
    func render() {}
}

// ✅ ③ 프로퍼티·저장 위치에도 가드가 필요하다
final class Manager {
    // ❌ 저장 프로퍼티는 @available로 감쌀 수 없다
    // @available(iOS 17.0, *) var widget: NewWidget?

    // ✅ 계산 프로퍼티나 Any로 우회하거나, 타입 자체를 분리한다
    private var _widget: Any?

    @available(iOS 17.0, *)
    var widget: NewWidget? {
        get { _widget as? NewWidget }
        set { _widget = newValue }
    }
}

// ✅ ④ 최소 배포 타깃을 확인한다
//    Build Settings → iOS Deployment Target
//    이 값보다 새로운 API를 가드 없이 쓰면 컴파일 경고가 뜬다

// ✅ ⑤ 선택적으로 링크되는 프레임워크는 약한 링크로
//    Build Phases → Link Binary → Status: Optional
//    사용 전에 존재 확인:
func useOptionalFramework() {
    if NSClassFromString("SomeOptionalClass") != nil {
        // 안전하게 사용
    }
}

// ⚠️ 실기기 테스트를 최소 배포 타깃 OS에서 반드시 한 번은 해야 잡힌다.
//    최신 OS에서만 테스트하면 이 오류는 출시 후에 발견된다.
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

심볼은 코드 조각에 붙은 이름표다. 컴파일러가 만든 .o는 저마다 목록 두 장을 들고 다닌다 — "나는 greet이라는 이름표를 갖고 있다"와 "나는 log라는 이름표를 가진 애를 찾는 중이다". 링커가 하는 일은 찾는 쪽과 가진 쪽을 짝지어 주는 것뿐이다. 그래서 log()가 소스에 멀쩡히 있어도, 그게 들어 있는 라이브러리를 링커에게 안 건네주면 짝을 못 찾아 Undefined symbol이 난다. 오타 문제가 아니라 "찾을 대상을 아예 안 보여 줬다" 문제인 것이다.

이름표가 미묘하게 안 맞아 못 찾는 경우도 많다. 같은 이름의 함수를 여러 개 둘 수 있으니 Swift와 C++는 이름 뒤에 타입·모듈 정보를 덧붙인 정식 이름표를 쓴다(name mangling, 이름을 규칙대로 비틀어 적는 것). C는 그런 걸 안 붙인다. 규칙이 다른 둘을 그냥 이으면 같은 함수인데 이름표가 달라 서로를 못 알아본다 — extern "C"가 "얘는 C 방식 이름표로 불러라"라고 알려 주는 표시다. 마지막으로 짝짓기가 두 번 일어난다는 걸 같이 기억해 두면 좋다. 빌드하다 실패하면 ld: Undefined symbols, 빌드는 넘어갔는데 사용자 기기에 올라온 dylib에 그 이름표가 없으면 dyld: Symbol not found다.

꼬리 질문

weak symbol이 런타임에 null로 해소될 때, 그 심볼을 가드 없이 호출하면 무슨 일이 일어나는가?

약하게 링크된 심볼이 현재 OS에 없으면 그 주소는 NULL(0)로 채워진다. 함수라면 0번지로 점프하는 셈이라, 가드 없이 부르면 EXC_BAD_ACCESS(널 포인터 역참조) 크래시가 난다. 데이터 심볼이면 0을 읽어 잘못된 값으로 진행하다 터진다.

이걸 막는 정석이 if #available(iOS 17, *)나 함수 포인터 nil 체크로 실제로 존재할 때만 호출하는 것이다. Swift에서는 @available과 배포 타깃이 이 약한 링크를 자동으로 걸어 주므로, 최소 배포 타깃보다 높은 API를 #available 가드 없이 부르면 바로 이 크래시가 재현된다.

쉽게 말하면 구버전 OS엔 없는 기능이라 그 자리에 0(빈 주소)이 들어가 있다. 확인 없이 그냥 부르면 0번지로 뛰어들어 앱이 그 자리에서 죽는다. if #available은 "이 기능이 실제로 있을 때만 눌러라"라는 안전 스위치인 셈이다.
같은 심볼이 두 라이브러리에 defined로 존재하면(duplicate symbol) 링커는 어떻게 처리하며, 무엇이 위험한가?

strong 심볼이 둘 이상 defined면 정적 링커는 duplicate symbol 오류로 링크를 거부한다. weak로 표시돼 있으면 하나만 채택하고 나머지를 버려(coalescing) 링크는 통과시킨다.

위험은 이 "통과되는" 경우다. 서로 다른 두 버전의 같은 심볼(예: 두 프레임워크가 각자 품은 같은 오픈소스)이 있을 때, 링커가 임의로 하나만 남기면 나머지는 조용히 무시된다. 그러면 어떤 코드가 살아남았는지 예측 불가라 미묘한 런타임 버그가 생긴다. 특히 Objective-C 클래스가 중복되면 런치 때 ObjC 런타임이 objc[…]: Class X is implemented in both … 경고를 내고 어느 쪽 구현이 쓰일지 보장되지 않는다. 그래서 공용 의존은 한 곳에서만 링크되게 경계를 정리해야 한다.

쉽게 말하면 같은 이름의 함수가 두 군데에 진짜로 있으면 링커가 "누굴 골라야 할지 모르겠다"며 대놓고 멈춘다 — 이건 차라리 낫다. 진짜 무서운 건 weak라서 링커가 말없이 한쪽만 남기고 나머지를 버릴 때다. 버려진 쪽 코드를 믿고 있으면 원인 모를 버그가 조용히 생긴다.
Objective-C 카테고리(category)만 들어 있는 정적 라이브러리(.a)를 링크했는데, 컴파일·링크는 통과하지만 런타임에 그 카테고리 메서드를 호출하면 unrecognized selector 크래시가 나는 이유는? 링커의 -ObjC(또는 -all_load) 플래그는 이를 어떻게 푸는가?

정적 라이브러리는 .o들을 묶은 아카이브일 뿐이고, 링커는 참조되는 심볼을 정의한 멤버만 골라 실행 파일에 편입시킨다. 그런데 카테고리는 새 클래스 심볼(_OBJC_CLASS_$_)을 만들지 않고 기존 클래스에 메서드를 런타임에 추가하는 것이라, 그 .o에는 다른 코드가 참조할 링커 심볼이 없다. 그래서 링커가 "아무도 안 쓰는 멤버"로 판단해 통째로 빼 버리면, 런타임이 시작 시 읽어 메서드를 붙이는 카테고리 메타데이터(__DATA,__objc_catlist에 실린다)가 애초에 바이너리에 들어가지 않는다. 링크는 참조가 다 풀려 성공하지만, 런타임엔 그 메서드가 클래스에 등록된 적이 없어 셀렉터를 못 찾고 크래시한다.

-ObjC는 Objective-C 클래스나 카테고리가 든 멤버는 심볼 참조 여부와 무관하게 전부 편입하라고 링커에 지시해 이 누락을 막는다. -all_load는 한발 더 나가 아카이브의 모든 멤버를 무조건 로드한다(바이너리가 커지고 중복 심볼 위험이 생길 수 있어 보통 -ObjC를 먼저 쓴다). 요즘 대부분의 정적 프레임워크/CocoaPods 통합에서 "메서드가 있는데 unrecognized selector가 난다"의 표준 처방이 바로 -ObjC다.

쉽게 말하면 카테고리는 새 클래스를 만들지 않고 기존 클래스에 메서드만 슬쩍 얹는 거라, 그 파일엔 링커가 붙잡을 이름표가 없다. 그래서 링커가 "아무도 안 쓰는 조각이네" 하고 통째로 버린다. -ObjC는 "Objective-C가 든 조각은 안 쓰는 것처럼 보여도 다 넣어"라고 시켜서 이 누락을 막는다.

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 이 아는 세계 — 링크 당시 배치 updateProfile() 0x100000000 (이미지 시작) 0x100040f34 (정적 주소) ② 실제 실행 — ASLR 로 통째로 밀려 올라감 여기서 크래시 0x104988000 (load addr) 0x1049c8f34 (크래시 주소) slide 0x1049c8f34 − 0x104988000 = 0x40f34 → 이 오프셋을 이미지 시작에 더해 dSYM 을 조회하면 ProfileViewModel.swift:42 가 나온다
두 막대는 같은 이미지인데 놓인 자리만 다르다. 그 차이(slide)를 빼 주지 않으면 dSYM 에서 엉뚱한 줄을 읽게 된다.

프로젝트 적용

릴리스/아카이브 구성에서 DEBUG_INFORMATION_FORMAT = dwarf-with-dsym을 강제하고, 모든 아카이브의 dSYM을 버전별로 보관한다. 재빌드하면 UUID가 바뀌어 예전 크래시를 못 푼다.

릴리스 구성에서 dSYM 생성을 강제
# dSYM이 없으면 사후 크래시 로그는 오프셋 숫자일 뿐이다.

# ✅ ① Build Settings 확인
#    Debug Information Format
#      Debug   : DWARF                    (빠른 빌드)
#      Release : DWARF with dSYM File     ← 반드시 이것
#
#    빌드 설정 키: DEBUG_INFORMATION_FORMAT = dwarf-with-dsym

# CI에서 명시적으로 강제
xcodebuild archive \
  -scheme MyApp \
  -configuration Release \
  -archivePath build/MyApp.xcarchive \
  DEBUG_INFORMATION_FORMAT=dwarf-with-dsym

# ✅ ② 생성됐는지 확인
ls build/MyApp.xcarchive/dSYMs/
#   MyApp.app.dSYM
#   Core.framework.dSYM          ← 의존 프레임워크 것도 있어야 한다

# ⚠️ SPM/CocoaPods 의존 프레임워크의 dSYM이 빠지는 일이 잦다.
#    그러면 서드파티 프레임 안쪽이 심볼리케이션되지 않는다.

# ✅ ③ 모든 아카이브의 dSYM을 버전별로 보관한다
VERSION=$(defaults read "$PWD/build/MyApp.xcarchive/Info.plist" \
          ApplicationProperties | grep CFBundleShortVersionString)
mkdir -p ~/dSYMs/"$VERSION"
cp -R build/MyApp.xcarchive/dSYMs/* ~/dSYMs/"$VERSION"/

# ⚠️ 재빌드하면 UUID가 바뀐다. 같은 소스여도 예전 dSYM으로는 못 푼다.
#    → 릴리스한 바로 그 아카이브의 dSYM을 보관해야 한다.

# ✅ ④ Bitcode를 쓰는 경우(레거시) App Store Connect에서 재생성된 dSYM을 받아야 한다
#    Xcode → Organizer → Archives → Download Debug Symbols

Crashlytics/Sentry를 쓰면 dSYM 업로드를 CI에 자동화한다. 특히 SPM/CocoaPods 의존 프레임워크의 dSYM 누락으로 서드파티 프레임 안이 안 풀리는 일이 잦다.

dSYM 업로드를 CI에 자동화
# 사람이 손으로 올리면 반드시 빠뜨린다. 파이프라인에 넣는다.

# ✅ Firebase Crashlytics
find "$ARCHIVE_PATH/dSYMs" -name "*.dSYM" -print0 \
  | xargs -0 "${PODS_ROOT}/FirebaseCrashlytics/upload-symbols" \
      -gsp "$PROJECT_DIR/GoogleService-Info.plist" -p ios

# ✅ Sentry
sentry-cli debug-files upload \
  --org my-org --project my-app \
  "$ARCHIVE_PATH/dSYMs"

# ✅ Bugsnag
find "$ARCHIVE_PATH/dSYMs" -name "*.dSYM" \
  -exec bugsnag-dsym-upload --api-key "$BUGSNAG_KEY" {} \;

# ✅ 업로드 결과를 검증한다 — 조용히 실패하는 경우가 있다
sentry-cli debug-files check "$ARCHIVE_PATH/dSYMs/MyApp.app.dSYM"

# ✅ 누락을 CI에서 잡는다
EXPECTED=$(otool -L MyApp.app/MyApp | grep '@rpath' | wc -l)
FOUND=$(ls "$ARCHIVE_PATH/dSYMs" | wc -l)
if [ "$FOUND" -lt "$EXPECTED" ]; then
  echo "❌ dSYM 누락: 예상 $EXPECTED개, 발견 $FOUND개"
  exit 1
fi

# ⚠️ Run Script Phase로 넣으면 로컬 빌드마다 돌아 느려진다.
#    Archive 액션이나 CI 단계에서만 실행되게 조건을 건다:
#      if [ "$CONFIGURATION" = "Release" ]; then ... fi

# ⚠️ 업로드 도구가 실패해도 빌드가 성공으로 끝나면 아무도 모른다.
#    반드시 exit code를 확인하고 실패 시 빌드를 중단시킨다.

심볼리케이션이 실패하면 제일 먼저 dwarfdump --uuid로 UUID 일치부터 확인한다. 코드 문제가 아니라 dSYM 매칭 문제인 경우가 대부분이다.

심볼리케이션 실패는 UUID부터 확인
# 심볼리케이션이 안 되면 열에 아홉은 dSYM 매칭 문제다. 코드 문제가 아니다.

# ✅ ① 바이너리의 UUID
dwarfdump --uuid MyApp.app/MyApp
#   UUID: 8A3F1C2D-4E5B-6789-ABCD-EF0123456789 (arm64) MyApp

# ✅ ② dSYM의 UUID
dwarfdump --uuid MyApp.app.dSYM
#   UUID: 8A3F1C2D-4E5B-6789-ABCD-EF0123456789 (arm64) MyApp
#   → 두 값이 '정확히' 같아야 한다

# ✅ ③ 크래시 리포트의 UUID
#    리포트 하단 "Binary Images" 섹션을 본다
#      0x104b78000 - 0x104c9ffff MyApp arm64  <8a3f1c2d4e5b6789abcdef0123456789>
#    이 값과 dSYM UUID를 대조한다

# ✅ ④ 보관 중인 dSYM에서 맞는 것을 찾는다
mdfind "com_apple_xcode_dsym_uuids == 8A3F1C2D-4E5B-6789-ABCD-EF0123456789"

# ✅ ⑤ 수동 심볼리케이션 (도구가 안 될 때)
#    로드 주소(0x104b78000)와 크래시 주소(0x104b9c1f4)를 넣는다
atos -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
     -arch arm64 -l 0x104b78000 0x104b9c1f4
#   → OrderViewModel.submit() (in MyApp) (OrderViewModel.swift:142)

# ✅ ⑥ 전체 리포트를 한 번에
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
"$DEVELOPER_DIR/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash" crash.ips MyApp.app.dSYM > symbolicated.txt

# ⚠️ UUID가 다르면 그 dSYM으로는 절대 안 된다. 같은 커밋에서 다시 빌드해도
#    빌드 번호·타임스탬프가 달라지면 UUID가 바뀐다.
#    → 릴리스 산출물을 그대로 보관하는 것이 유일한 해법이다.
⚠️ 흔한 오해

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

🧒 쉽게 이해하기

릴리스 앱은 크래시 리포트에 함수 이름 대신 숫자 주소만 남긴다. 이름표를 떼고 출시했기 때문이다. dSYM은 그 떼어 낸 이름표를 따로 모아 둔 건물 배치도 같은 파일이다 — "301호는 updateProfile" 식으로 호수 기준으로 적혀 있다. 그런데 크래시 리포트에 찍힌 건 호수가 아니라, 건물이 어디에 서 있든 상관없이 잰 절대 위치다. 건물은 앱을 켤 때마다 다른 자리에 놓이니까(ASLR), 절대 위치에서 정문 위치를 빼야 비로소 몇 호인지가 나온다. 그 빼는 값이 slide고, atos -l에 넣어 주는 로드 주소가 바로 그 정문 위치다.

UUID는 "이 배치도는 몇 번째로 지은 그 건물 것"이라는 도장이다. 소스를 한 글자만 고쳐 다시 빌드해도 방 배치가 조금씩 달라지니, 도장이 안 맞는 배치도를 억지로 쓰면 301호에 엉뚱한 사람 이름이 뜬다. 없는 것보다 나쁜, 그럴듯한 거짓말이 되는 셈이다. 그래서 툴은 UUID가 정확히 같을 때만 그 dSYM을 쓴다. 심볼리케이션이 안 될 때 코드부터 뒤지지 말고 dwarfdump --uuid로 도장부터 맞춰 보라는 이유가 이것이다.

꼬리 질문

크래시 리포트에 로드 주소(slide)가 없으면 atos로 왜 정확히 심볼리케이션할 수 없는가?

dSYM은 링크 당시 기준의 정적 주소로 심볼을 색인한다. 하지만 크래시 리포트의 주소는 ASLR로 무작위 위치에 로드된 런타임 주소다. 정적 주소로 되돌리려면 그 이미지가 실제로 올라간 로드 베이스를 알아야 하는데, atos -l에 넣는 값이 바로 그 로드 주소다(slide는 이 로드 주소에서 링크 당시 preferred 베이스를 뺀 값이고, atos가 내부적으로 이 보정을 해 준다).

slide를 모르면 offset을 얼마나 빼야 할지 알 수 없어, 엉뚱한 정적 주소로 조회하게 된다. 그러면 아무 심볼도 안 나오거나 실제와 다른 함수로 잘못 매핑돼 오히려 디버깅을 오도한다. 그래서 로드 주소는 심볼리케이션의 필수 입력이다.

쉽게 말하면 dSYM은 "정문에서 몇 미터 지점" 식으로 함수 위치를 적어 뒀다. 그런데 크래시 리포트에서 정문이 어디 서 있었는지(로드 주소)가 빠지면 몇 미터를 빼야 할지 알 수가 없다. 그러면 엉뚱한 방을 짚어서 오히려 디버깅을 헷갈리게 만든다.
같은 앱의 서로 다른 두 빌드가 우연히 같은 함수 배치를 가져도 dSYM을 바꿔 쓰면 안 되는 이유는?

심볼리케이터는 함수 배치가 비슷한지를 보지 않고 UUID가 정확히 일치하는지만 본다. 두 빌드는 재컴파일·재링크되면서 UUID(LC_UUID)가 달라지므로, 애초에 UUID가 안 맞아 정상 툴은 그 dSYM 사용을 거부한다.

강제로 우회해 쓴다 해도, 최적화·인라이닝·코드 배치가 조금만 달라지면 같은 주소가 다른 함수·다른 줄로 매핑된다. 결과는 "그럴듯하지만 틀린" 심볼로, 없느니만 못한 디버깅 정보가 된다. UUID를 앵커로 삼는 건 바로 이런 오매핑을 막기 위해서다.

쉽게 말하면 방 배치가 비슷해 보여도 툴은 배치도의 도장(UUID)이 똑같은지만 확인한다. 도장이 다른 배치도를 억지로 쓰면 있는 것보다 나쁜, 그럴듯한 가짜 이름이 뜬다. 그래서 두 빌드가 우연히 닮았어도 dSYM을 서로 바꿔 쓰면 안 된다.
시스템 프레임워크 프레임(UIKit 등)은 내 dSYM에 없는데, 그건 무엇으로 어떻게 심볼리케이션되는가?

UIKit·Foundation 같은 시스템 프레임 주소는 내 앱 dSYM이 아니라, 해당 OS 버전·기기 아키텍처에 맞는 시스템 심볼(OS symbols)로 푼다. 이 심볼은 그 iOS 버전이 설치된 기기를 Xcode에 연결하면 자동으로 복사돼 ~/Library/Developer/Xcode/iOS DeviceSupport/에 캐시된다.

Xcode Organizer는 이 캐시를 이용해 시스템 프레임을 자동으로 심볼리케이션한다. 그래서 낯선 OS 버전의 크래시는 그 버전 기기를 한 번 연결하지 않으면 시스템 프레임이 주소로만 남을 수 있다. 다만 시스템 프레임은 대개 크래시의 원인이 아니라 경로일 뿐이라, 내 코드 프레임이 풀리는 게 더 중요하다.

쉽게 말하면 UIKit 같은 남의 코드는 내 배치도(dSYM)에 없고, 애플이 OS 버전마다 따로 가진 배치도로 푼다. 그 버전이 깔린 기기를 Xcode에 한 번 꽂아 두면 그 배치도가 복사돼 자동으로 채워진다. 사실 이 줄들은 사고가 지나간 길일 뿐이라, 내 코드 줄만 잘 풀려도 대개 충분하다.

출처 · 참고자료