빌드·링킹·렌더링 내부 동작 Q&A
빌드 스크립트 타이밍, dSYM, 심볼 테이블, Static 링킹, reduce 함수, CAShapeLayer, 렌더링 파이프라인, GIF ImageIO 다운샘플링까지 — 실무에서 마주치는 내부 동작 원리 정리
1. 빌드 스크립트 타이밍 (Pre vs Post)
Xcode 빌드 단계에서 스크립트가 언제 실행되는지 이해
빌드 순서
실제 예시
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) = 디버그 심볼 파일. 릴리즈 빌드에서 제거된 디버그 정보를 별도 보관.
크래시 리포트 흐름
UUID 매칭
각 빌드마다 고유한 Build UUID가 있어서 정확히 그 빌드의 dSYM을 찾아 매칭:
# dSYM의 UUID 확인
$ dwarfdump --uuid MyApp.app.dSYM
UUID: 12345678-ABCD-1234-ABCD-123456789ABC (arm64)
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
링킹 과정
.o 안의 코드/데이터 섹션이 합쳐져서 하나의 실행파일이 됨.
5. 심볼 테이블과 링킹 타이밍
컴파일 결과물 (.o 파일)
Static vs Dynamic
| Static Framework | Dynamic Framework | |
|---|---|---|
| 주소 확정 시점 | 링킹 타임 | 런타임 (dyld) |
| 저장 위치 | 앱 바이너리에 합쳐짐 | GOT/스텁에 기록 |
| 예시 | Network.fetch → 0x50000 | UIView.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) // 기존 배열 수정
}
// 하나의 배열 계속 사용
결과는 같다. 성능만 다름.
| reduce | reduce(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 애니메이션
CALayer vs CAShapeLayer
| CALayer | CAShapeLayer | |
|---|---|---|
| 내용물 | 비트맵 (contents) | 벡터 경로 (path) |
| 확대 시 | 픽셀 깨짐 | 선명함 |
| 애니메이션 | transform, opacity | + strokeEnd, path |
| 용도 | 이미지, 뷰 백킹 | 도형, 선, 프로그레스 |
8. 래스터라이즈 (Rasterize)
벡터(수학 공식) → 비트맵(픽셀)으로 변환
왜 비싼 작업인가?
- 100×100 원 → 10,000 픽셀 계산
- 1000×1000 원 → 1,000,000 픽셀 계산
- 각 픽셀마다: 도형 안/밖 판단, 안티앨리어싱, 색상 결정
9. 렌더링 파이프라인: CAShapeLayer vs draw(_:)
CAShapeLayer 흐름
draw(_:) 흐름
핵심 비교
| CAShapeLayer | draw(_:) | |
|---|---|---|
| 트리거 | 속성 변경 (path 등) | setNeedsDisplay() |
| 래스터라이즈 위치 | Render Server | 앱 메인스레드 |
| 메인스레드 부담 | 작음 | 큼 |
| Render Server 역할 | 래스터라이즈 + 합성 | 합성만 |
무거운 래스터라이즈를 앱 밖(Render Server)으로 넘김 → 메인스레드 부담 감소
10. frame 변경 시 텍스처 재사용
origin만 바꿀 때 → 텍스처 재사용
size 바꿀 때 → 다시 그림
| 변경 항목 | contents 재생성 | GPU 동작 |
|---|---|---|
| position (origin) | ❌ | 합성만 |
| transform | ❌ | 합성만 |
| opacity | ❌ | 합성만 |
| bounds.size | ⚠️ 보통 필요 | 텍스처 재업로드 + 합성 |
| backgroundColor | ✅ | 텍스처 재업로드 + 합성 |
Core Animation이 빠른 이유: position, transform, opacity는 숫자값만 바뀌므로 기존 텍스처 재사용
11. Lottie 애니메이션 동작 원리
전체 흐름
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의 서브클래스. 각자 특화된 렌더링 방식을 가짐.
레이어별 래스터라이즈 여부
| 레이어 | 내용물 | 래스터라이즈 |
|---|---|---|
| CALayer (이미지) | contents (CGImage) | ❌ 이미 비트맵 |
| CALayer (빈 컨테이너) | 없음 | ❌ 그릴 게 없음 |
| CAShapeLayer | path (CGPath) | ✅ 벡터 → 비트맵 |
| CAGradientLayer | colors, locations | ✅ 그라디언트 계산 |
| CATextLayer | string | ✅ 텍스트 → 비트맵 |
이미 비트맵(CGImage)이거나 빈 컨테이너는 래스터라이즈 불필요.
13. CGImage = 비트맵
CGImage는 이미 픽셀 데이터(비트맵):
let cgImage: CGImage
// 내부: width × height × 4 bytes (RGBA)
// 예: 100×100 이미지 = 40,000 bytes 픽셀 배열
layer.contents가 있으면
비교
CAShapeLayer (벡터)
path = CGPath(...)
└── 벡터 → 비트맵 변환 필요
└── Render Server가 래스터라이즈
└── GPU 합성
CALayer (비트맵)
contents = CGImage
└── 이미 비트맵
└── 텍스처 업로드만
└── GPU 합성
UIImageView가 가벼운 이유: UIImage 내부 CGImage(비트맵)를 layer.contents에 할당 → 래스터라이즈 없이 합성만
14. GPU 합성(Compositing) 과정
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 측정 결과
| 지표 | OLD | NEW | 차이 |
|---|---|---|---|
| 피크 메모리 | 920 MiB | 890 MiB | 30 MiB (3.3%↓) |
| GIF 처리 할당량 (Call Tree) | 96 MB | 95 MB | ~1 MB |
| 캐시 메모리 (Generation) | 126 MiB | 124 MiB | ~2 MiB |
왜 피크 차이만 유의미한가?
30MB 차이의 의미
테스트 환경: ~10MB GIF 6장이 TableView에 동시 노출
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에 클래스명 입력 |
| 화면 전환 전후 증가량 | Generation | Mark Generation 버튼으로 스냅샷 |
ImageIO 다운샘플링은 최종 캐시 크기는 동일하지만, 처리 중 피크 메모리를 30MB(GIF당 약 5MB) 절감.
OOM 위험 감소, 특히 여러 GIF가 동시 로드되는 TableView/CollectionView에서 효과적.
Q&A 정리
pre()는 컴파일 전, post()는 링킹 후. R.swift는 코드 생성이라 pre, dSYM 업로드는 post.