← 홈으로
Build · Linking · Rendering

빌드·링킹·렌더링 내부 동작 Q&A

빌드 스크립트 타이밍, dSYM, 심볼 테이블, Static 링킹, reduce 함수, CAShapeLayer, 렌더링 파이프라인, GIF ImageIO 다운샘플링까지 — 실무에서 마주치는 내부 동작 원리 정리

학습 날짜: 2026-07-13 · 업데이트: 2026-07-14 (GIF 다운샘플링 추가)

1. 빌드 스크립트 타이밍 (Pre vs Post)

Xcode 빌드 단계에서 스크립트가 언제 실행되는지 이해

빌드 순서

1. Pre-build Scripts (TargetScript.pre) ← R.swift 등 코드 생성 ↓ 2. Compile Sources (.swift → .o 파일) ↓ 3. Link Binary With Libraries (.o → 실행파일) ↓ 4. Post-build Scripts (TargetScript.post) ← dSYM 업로드 등

실제 예시

Pre-build: R.swift

// 리소스 스캔 → 타입 안전 코드 생성
// 생성된 R.generated.swift가 컴파일 대상
TargetScript.pre(
  script: "rswift generate ...",
  name: "RswiftString"
)

Post-build: Firebase dSYM

// 링킹 완료 후 dSYM 생성됨
// 생성된 dSYM을 서버에 업로드
TargetScript.post(
  script: "Crashlytics/run",
  name: "FirebaseCrashReport"
)
스크립트 타입실행 시점용도
pre()컴파일 전코드 생성 (R.swift, SwiftGen)
post()링킹 후dSYM 업로드, 빌드 아티팩트 처리

2. dSYM과 Symbolication

dSYM이란?

dSYM (Debug Symbol file) = 디버그 심볼 파일. 릴리즈 빌드에서 제거된 디버그 정보를 별도 보관.

릴리즈 바이너리: 0x00001234 에서 크래시 ↓ dSYM으로 변환 (Symbolication) 사람이 읽는 정보: LoginViewController.swift:42 viewDidLoad()

크래시 리포트 흐름

1. 사용자 기기에서 크래시 → 메모리 주소만 기록 (0x00001234) ↓ 2. Crashlytics 서버로 전송 → 여전히 주소만 ↓ 3. 서버에서 dSYM 매칭 (Symbolication) → 0x00001234 → LoginViewController.swift:42 ↓ 4. Firebase 콘솔에서 읽을 수 있는 스택트레이스

UUID 매칭

각 빌드마다 고유한 Build UUID가 있어서 정확히 그 빌드의 dSYM을 찾아 매칭:

# dSYM의 UUID 확인
$ dwarfdump --uuid MyApp.app.dSYM
UUID: 12345678-ABCD-1234-ABCD-123456789ABC (arm64)
UUID마다 dSYM이면 너무 많지 않나?
Debug 빌드는 심볼 strip 안 해서 dSYM 불필요. Release 배포분만 보관하므로 1년에 수십 개 수준.

3. 심볼 테이블 vs 디버그 심볼

Symbol Table

  • 바이너리 안에 포함
  • 함수명, 전역변수 → 메모리 주소
  • 링커가 사용 (동적 링킹)
0x1234 → "viewDidLoad"

Debug Symbol (DWARF)

  • dSYM에 별도 저장
  • 심볼 테이블 + 소스 파일, 줄 번호, 타입 정보
  • 디버거/크래시 리포트용
0x1234 → "viewDidLoad" in LoginVC.swift:42

DWARF = Debug With Arbitrary Record Format (디버그 정보 표준 포맷)

4. Static Framework 링킹: .a vs .o

.a 파일의 정체

.a.o 파일들의 아카이브(묶음)일 뿐:

# .a 안에 뭐가 있는지 확인
$ ar -t libNetwork.a
Network.o
Request.o
Response.o

링킹 과정

1. 링커가 .a 열기 (압축 해제 개념) libNetwork.a → Network.o, Request.o, Response.o 2. 필요한 .o만 선택 (dead code stripping) 앱에서 Network.fetch()만 사용 → ✅ Network.o (사용됨) ✅ Request.o (Network가 의존) ❌ Response.o (미사용, 제외) 3. 선택된 .o의 섹션을 최종 바이너리에 병합 MyApp 실행파일 ├── __TEXT (코드) ← 내 .o + Network.o + Request.o └── __DATA (데이터)
최종 결과물에는 .a도 .o도 없다.
.o 안의 코드/데이터 섹션이 합쳐져서 하나의 실행파일이 됨.

5. 심볼 테이블과 링킹 타이밍

컴파일 결과물 (.o 파일)

MyViewController.o 심볼 테이블 ├── [정의된 심볼 - 내 코드] │ viewDidLoad → 0x0000 (상대 주소) │ setupUI → 0x0048 │ └── [미정의 심볼 - 외부 코드] ← "나중에 채워줘" UIView.addSubview → ??? (undefined) Network.fetch → ??? (undefined)

Static vs Dynamic

Static FrameworkDynamic Framework
주소 확정 시점링킹 타임런타임 (dyld)
저장 위치앱 바이너리에 합쳐짐GOT/스텁에 기록
예시Network.fetch → 0x50000UIView.addSubview → [스텁] → 런타임에 채움

GOT (Global Offset Table): 동적 심볼의 실제 주소를 저장하는 별도 테이블. 코드 섹션은 읽기 전용, GOT만 쓰기 가능.

6. reduce vs reduce(into:)

핵심 차이: 복사 vs 수정

reduce (복사)

[1,2,3].reduce([Int]()) { arr, n in
    return arr + [n * 2]  // 새 배열 반환
}
// 매 반복마다 새 배열 생성

reduce(into:) (수정)

[1,2,3].reduce(into: [Int]()) { arr, n in
    arr.append(n * 2)  // 기존 배열 수정
}
// 하나의 배열 계속 사용

결과는 같다. 성능만 다름.

reducereduce(into:)
동작새 값 복사 반환기존 값 직접 수정 (inout)
문법return 필요return 없음
용도Int, String 등Array, Dictionary 등
성능컬렉션에선 비효율컬렉션에 효율적

실제 사용 예: 도메인 치환

public func apply(to urlString: String) -> String {
    rules.reduce(urlString) { result, rule in
        result.replacingOccurrences(of: rule.from, with: rule.to)
    }
}
// 문자열은 값 타입이라 reduce 사용 OK

7. CAShapeLayer

벡터 경로(CGPath)를 그리는 특수 레이어

기본 사용

let shapeLayer = CAShapeLayer()
shapeLayer.path = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 100, height: 100)).cgPath
shapeLayer.fillColor = UIColor.red.cgColor
shapeLayer.strokeColor = UIColor.black.cgColor
shapeLayer.lineWidth = 2
shapeLayer.strokeEnd = 0.0  // 애니메이션용

view.layer.addSublayer(shapeLayer)

strokeEnd 애니메이션

strokeEnd: 0.0 strokeEnd: 0.5 strokeEnd: 1.0 ○ ◐ ● (안 보임) (반만 그림) (전체 그림)

CALayer vs CAShapeLayer

CALayerCAShapeLayer
내용물비트맵 (contents)벡터 경로 (path)
확대 시픽셀 깨짐선명함
애니메이션transform, opacity+ strokeEnd, path
용도이미지, 뷰 백킹도형, 선, 프로그레스

8. 래스터라이즈 (Rasterize)

벡터(수학 공식) → 비트맵(픽셀)으로 변환

벡터: "중심 (50,50), 반지름 30인 원" (수학 공식) ↓ 래스터라이즈 비트맵: ⬜⬜⬜🟥🟥🟥⬜⬜⬜ ⬜⬜🟥🟥🟥🟥🟥⬜⬜ ⬜🟥🟥🟥🟥🟥🟥🟥⬜ 🟥🟥🟥🟥🟥🟥🟥🟥🟥 ⬜🟥🟥🟥🟥🟥🟥🟥⬜ ⬜⬜🟥🟥🟥🟥🟥⬜⬜ ⬜⬜⬜🟥🟥🟥⬜⬜⬜

왜 비싼 작업인가?

9. 렌더링 파이프라인: CAShapeLayer vs draw(_:)

CAShapeLayer 흐름

App Process (Main Thread) │ shapeLayer.path = newPath ← 속성 설정만 (가벼움) │ shapeLayer.strokeEnd = 0.5 │ ▼ CATransaction.commit() ───────────────────────────────── Render Server (별도 프로세스) │ path(벡터) → 래스터라이즈 → 텍스처 │ ← 여기서 비트맵 생성 (앱 메인스레드 아님!) │ ▼ GPU: 텍스처 합성

draw(_:) 흐름

setNeedsDisplay() 호출 │ ▼ Display Pass ───────────────────────────────── App Process (Main Thread) ⚠️ │ draw(_ rect:) 호출 │ CGContext에 그림 → layer.contents 생성 │ ← 래스터라이즈가 메인스레드에서! (무거움) │ ▼ CATransaction.commit() ───────────────────────────────── Render Server │ 이미 만들어진 텍스처 받음 │ ▼ GPU: 합성만

핵심 비교

CAShapeLayerdraw(_:)
트리거속성 변경 (path 등)setNeedsDisplay()
래스터라이즈 위치Render Server앱 메인스레드
메인스레드 부담작음
Render Server 역할래스터라이즈 + 합성합성만
CAShapeLayer가 draw보다 나은 이유:
무거운 래스터라이즈를 앱 밖(Render Server)으로 넘김 → 메인스레드 부담 감소

10. frame 변경 시 텍스처 재사용

origin만 바꿀 때 → 텍스처 재사용

view.frame.origin.x = 100 ↓ layer.position 변경 ✅ layer.contents 변경 ❌ (그대로) ↓ Render Server: 기존 텍스처 재사용 GPU: 합성만 (래스터라이즈 없음)

size 바꿀 때 → 다시 그림

view.frame.size = CGSize(width: 200, height: 200) ↓ layer.bounds.size 변경 ↓ 픽셀 수 바뀜 → 비트맵 해상도 안 맞음 ↓ contents 다시 래스터라이즈 필요
변경 항목contents 재생성GPU 동작
position (origin)합성만
transform합성만
opacity합성만
bounds.size⚠️ 보통 필요텍스처 재업로드 + 합성
backgroundColor텍스처 재업로드 + 합성

Core Animation이 빠른 이유: position, transform, opacity는 숫자값만 바뀌므로 기존 텍스처 재사용

11. Lottie 애니메이션 동작 원리

전체 흐름

1. 파싱 단계 .json / .lottie 파일 ↓ DotLottie / LottieAnimation 모델로 파싱 (키프레임, 쉐이프, 트랜스폼 등 구조화) 2. 레이어 트리 구축 JSON 구조에 맞춰 CALayer 계층 생성: ├── CAShapeLayer (벡터 도형) ├── CAGradientLayer (그라디언트) ├── CATransformLayer (3D 변환) └── CALayer + mask (마스크) 3. 애니메이션 구동 (매 프레임) currentFrame 변경 (0 → 1 → 2 → ... → 60) ↓ 키프레임 보간 (interpolation) ↓ 각 레이어 속성 업데이트: - shapeLayer.path = 보간된 CGPath - layer.transform = 보간된 CATransform3D - layer.opacity = 보간된 값 4. Core Animation → Render Server → GPU 합성

JSON → CAShapeLayer "직접 넣는 게 아님"

// ❌ 단순하지 않음
shapeLayer.path = json["path"]

// ✅ 실제로는 키프레임 보간 후 적용
let interpolatedPath = interpolate(
    from: keyframe1.path,
    to: keyframe2.path,
    progress: currentProgress  // 0.0 ~ 1.0
)
shapeLayer.path = interpolatedPath

Lottie 렌더링 엔진 선택

Core Animation (기본)Main Thread
렌더링Render Server (GPU)앱 메인스레드 (CPU)
성능좋음복잡한 애니메이션에서 버벅임
지원 범위일부 효과 미지원모든 효과 지원

12. CALayer 상속 관계

CALayer (기본 레이어) │ ├── CAShapeLayer (벡터 경로) ├── CAGradientLayer (그라디언트) ├── CATextLayer (텍스트) ├── CAScrollLayer (스크롤) ├── CATransformLayer (3D 변환 컨테이너) ├── CAReplicatorLayer (복제) ├── CAEmitterLayer (파티클) └── CATiledLayer (타일 기반 대용량)

모두 CALayer의 서브클래스. 각자 특화된 렌더링 방식을 가짐.

레이어별 래스터라이즈 여부

레이어내용물래스터라이즈
CALayer (이미지)contents (CGImage)❌ 이미 비트맵
CALayer (빈 컨테이너)없음❌ 그릴 게 없음
CAShapeLayerpath (CGPath)✅ 벡터 → 비트맵
CAGradientLayercolors, locations✅ 그라디언트 계산
CATextLayerstring✅ 텍스트 → 비트맵
핵심: "화면에 그릴 내용이 있는 레이어"만 래스터라이즈.
이미 비트맵(CGImage)이거나 빈 컨테이너는 래스터라이즈 불필요.

13. CGImage = 비트맵

CGImage는 이미 픽셀 데이터(비트맵):

let cgImage: CGImage
// 내부: width × height × 4 bytes (RGBA)
// 예: 100×100 이미지 = 40,000 bytes 픽셀 배열

layer.contents가 있으면

layer.contents = cgImage → 이미 비트맵(픽셀 데이터) 존재 → 래스터라이즈 불필요 ❌ → GPU에 텍스처로 업로드만 → 합성만 ✅

비교

CAShapeLayer (벡터)

path = CGPath(...)
└── 벡터 → 비트맵 변환 필요
└── Render Server가 래스터라이즈
└── GPU 합성

CALayer (비트맵)

contents = CGImage
└── 이미 비트맵
└── 텍스처 업로드만
└── GPU 합성

UIImageView가 가벼운 이유: UIImage 내부 CGImage(비트맵)를 layer.contents에 할당 → 래스터라이즈 없이 합성만

14. GPU 합성(Compositing) 과정

Render Server │ ├── 1. 각 레이어 래스터라이즈 (필요한 것만) │ CAShapeLayer → 텍스처 A │ CAGradientLayer → 텍스처 B │ CALayer(이미지) → 텍스처 C (이미 비트맵, 업로드만) │ ├── 2. GPU에 텍스처들 전달 │ └── 3. GPU가 레이어 트리 순서대로 합성 (position, transform, opacity 적용) 텍스처 A ─┐ 텍스처 B ─┼──▶ 최종 프레임버퍼 ──▶ 화면 텍스처 C ─┘

15. GIF 다운샘플링: ImageIO vs Full Decode + Resize

GIF를 TableView에서 썸네일로 표시할 때, 두 가지 방식의 메모리 차이를 Instruments로 측정한 결과

두 가지 방식 비교

OLD: Full Decode + Resize

// 1. 원본 전체 디코딩
let cgImage = CGImageSourceCreateImageAtIndex(
    source, index, nil)
// → 333×474pt (1000×1422px) = 5.7MB/프레임

// 2. 리사이즈
let resized = image.kf.resize(
    to: targetSize, for: .aspectFill)
// → 80×114pt (240×342px) = 0.33MB/프레임

// 3. 원본 해제, 결과만 캐시

NEW: ImageIO 다운샘플링

// 타겟 크기로 바로 디코딩
let options: [CFString: Any] = [
    kCGImageSourceThumbnailMaxPixelSize: maxPixel,
    kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceCreateThumbnailWithTransform: true
]
let cgImage = CGImageSourceCreateThumbnailAtIndex(
    source, index, options as CFDictionary)
// → 80×114pt (240×342px) = 0.33MB/프레임
// 원본 전체를 메모리에 올리지 않음!

Instruments 측정 결과

지표OLDNEW차이
피크 메모리920 MiB890 MiB30 MiB (3.3%↓)
GIF 처리 할당량 (Call Tree)96 MB95 MB~1 MB
캐시 메모리 (Generation)126 MiB124 MiB~2 MiB

왜 피크 차이만 유의미한가?

OLD 메모리 흐름 (프레임별): ┌─────────────────────────────────────────┐ │ 프레임 1 처리 중: 원본 5.7MB + 결과 0.3MB │ ← 피크! │ 프레임 1 완료: 결과 0.3MB (원본 해제) │ │ 프레임 2 처리 중: 원본 5.7MB + 결과 0.6MB │ ← 피크! │ ... │ └─────────────────────────────────────────┘ NEW 메모리 흐름 (프레임별): ┌─────────────────────────────────────────┐ │ 프레임 1 처리 중: 결과 0.3MB │ │ 프레임 2 처리 중: 결과 0.6MB │ │ ... │ └─────────────────────────────────────────┘ → 최종 캐시는 동일 (결과 크기 같음) → 피크만 다름 (원본 디코딩 여부)

30MB 차이의 의미

테스트 환경: ~10MB GIF 6장이 TableView에 동시 노출

30MB ÷ 5.7MB/프레임 ≈ 5~6 프레임 → Kingfisher가 여러 GIF를 병렬 처리할 때 → 동시에 5~6개 프레임이 원본 디코딩 중이었음 → OLD: 5.7MB × 6 = 34MB 피크 추가 → NEW: 피크 추가 없음

aspectFill을 위한 maxPixel 계산

kCGImageSourceThumbnailMaxPixelSize긴 변 기준이므로, aspectFill처럼 짧은 변 기준으로 하려면:

let originalRatio = image.size.width / image.size.height
let referenceRatio = referenceSize.width / referenceSize.height

let maxPixel: CGFloat
if originalRatio < referenceRatio {
    // 세로가 긴 이미지: width 맞추면 height가 긴 변
    let resultWidth = referenceSize.width * scale
    maxPixel = resultWidth / originalRatio
} else {
    // 가로가 긴 이미지: height 맞추면 width가 긴 변
    let resultHeight = referenceSize.height * scale
    maxPixel = resultHeight * originalRatio
}

let options: [CFString: Any] = [
    kCGImageSourceThumbnailMaxPixelSize: maxPixel,
    kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceCreateThumbnailWithTransform: true
]

Instruments로 측정하는 방법

측정 대상도구방법
피크 메모리Allocations 그래프그래프 최고점에 마우스 호버
특정 코드 할당량Call Tree + 필터Input Filter에 클래스명 입력
화면 전환 전후 증가량GenerationMark Generation 버튼으로 스냅샷
결론:
ImageIO 다운샘플링은 최종 캐시 크기는 동일하지만, 처리 중 피크 메모리를 30MB(GIF당 약 5MB) 절감.
OOM 위험 감소, 특히 여러 GIF가 동시 로드되는 TableView/CollectionView에서 효과적.

Q&A 정리

빌드 스크립트는 링킹 이후에 도나?
pre()는 컴파일 전, post()는 링킹 후. R.swift는 코드 생성이라 pre, dSYM 업로드는 post.
디버그 심볼이 심볼 테이블이야?
아니다. 심볼 테이블 ⊂ 디버그 심볼. DWARF는 심볼 테이블 + 소스 파일/줄 번호/타입 정보까지 포함.
Static Framework면 .a가 포함돼? .o가 포함돼?
.o가 포함됨. .a는 .o 파일들의 아카이브(묶음)일 뿐. 링커가 .a를 열어서 필요한 .o만 추출해 합침.
reduce와 reduce(into:) 결과는 같아?
결과는 같다. reduce는 매번 새 값 복사, reduce(into:)는 하나의 값 직접 수정. 배열/딕셔너리엔 into가 효율적.
CAShapeLayer도 메인스레드에서 그려?
속성 설정은 메인스레드. 하지만 실제 래스터라이즈는 Render Server가 함. draw(_:)와 달리 무거운 작업이 앱 밖에서 처리됨.
frame 바꿀 때 GPU가 텍스처 다시 그려?
origin만 바꾸면 텍스처 재사용 (합성만). size가 바뀌면 해상도가 달라지므로 다시 래스터라이즈 필요.
Lottie는 JSON을 CAShapeLayer에 그대로 넣어?
아니다. JSON 파싱 → 키프레임 보간(interpolation) → 보간된 값을 레이어 속성에 적용. 프레임마다 보간 계산 후 업데이트.
CAShapeLayer, CAGradientLayer와 CALayer 관계는?
CALayer의 서브클래스(상속). CAShapeLayer는 벡터 경로, CAGradientLayer는 그라디언트 등 각자 특화된 렌더링 담당.
모든 레이어가 래스터라이즈 되나?
아니다. 그릴 내용 있는 레이어만. 이미 비트맵(CGImage)이거나 빈 컨테이너 레이어는 래스터라이즈 불필요.
CGImage가 비트맵이야? layer.contents 있으면 합성만?
맞다. CGImage는 이미 픽셀 데이터(비트맵). layer.contents에 CGImage가 있으면 래스터라이즈 없이 GPU 합성만.
GIF 다운샘플링하면 캐시 메모리도 줄어?
아니다. 최종 결과 크기가 같으면 캐시는 동일. 차이는 처리 중 피크 메모리. OLD는 원본 전체 디코딩 후 resize, NEW는 처음부터 타겟 크기로 디코딩.
프레임당 5.7MB인데 왜 30MB밖에 안 줄어?
순차 처리 + 즉시 해제 때문. 한 번에 1프레임만 원본 디코딩 → 바로 해제. 30MB ÷ 5.7MB ≈ 5~6프레임이 병렬 처리 중이었던 것.
kCGImageSourceThumbnailMaxPixelSize가 뭐야?
ImageIO에서 썸네일 생성 시 최대 픽셀 크기. 긴 변이 이 값에 맞춰지고 비율 유지. 원본보다 크게 확대는 안 함.