모듈화(Modularizing) 작업을 하면서 저희 앱은 더 커지고 로딩 속도가 느려졌습니다. 코드를 전혀 수정하지 않고, 단 하나의 빌드 설정(build setting)만으로 모든 것을 되돌릴 수 있었습니다.
저희는 새로운 코드를 한 줄도 작성하지 않았습니다. 단지 모듈화 가이드에서 알려주듯이 일부 코드를 프레임워크(frameworks)로 이동시켰을 뿐입니다.
그런 다음 릴리스 빌드(release build)를 아카이브(archived)했는데, 앱의 크기가 커진 것을 확인했습니다.
Confused math lady
이것은 제가 근무했던 팀에서 발생한 일이었고, 이를 해결하는 데 일주일이 걸렸습니다. 여기에 사례 파일을 공유합니다. 범죄 현장을 데모 앱으로 재구성했기 때문에 아래의 모든 수치는 실제이며 재현 가능합니다.
🗂️ 사례 파일: 무게가 늘어난 앱
- 피해자:
MyApp, 새로 모듈화된 상태
- 증상: 코드를 단지 _이동_시켰을 뿐인데 크기가 커짐
- 용의자:
Xcode · CI 캐시(CI cache) · 역행하는 수성(Mercury in retrograde) · public
- 해결책: 빌드 설정 하나, 22% 작아짐, 로딩 시 로드되는 프레임워크 0개
🔍 증거 A: 73 KB 박스 속의 3 KB 코드
세 개의 모듈 모두 일반적인 동적 프레임워크(dynamic frameworks)이며, 이는 Xcode가 기본적으로 제공하는 방식입니다. SupportFeature를 보세요: 코드 약 3KB가 바이너리 73KB로 전송되고 있습니다.
이것은 전자레인지용 상자에 담긴 USB 케이블과 같습니다.
모든 동적 프레임워크는 네 개의 세그먼트(segments)로 분할되며, arm64 아키텍처에서는 내용물이 거의 비어 있더라도 각 세그먼트는 16KB 페이지에 패딩(padding) 처리됩니다. 따라서 각 프레임워크는 코드를 담기 전에 약 64KB의 포장 비용을 지불하는 셈입니다. 게다가, dyld(런타임 로더)가 앱이 실행될 때마다 모든 프레임워크를 개별적으로 찾고(find), 매핑하고(map), 바인딩해야 합니다.
CocoaPods와 use_frameworks!를 사용하거나, 동적 SPM 제품(dynamic SPM products)을 사용하는 경우 어떤가요? 그러면 모든 의존성(dependency)이 콜드 로딩(cold launch) 시 공항 보안 검색대에서 추가적인 여권 검사를 받는 것과 같습니다. 이것이
패딩(Padding)은 작은 프레임워크를 설명해 주지만, 코드가 88 KB밖에 안 되는데 왜 CoreKit이 316 KB인지에 대해서는 설명하지 못합니다.
🧤 전시물 B: 지문(Fingerprints)
앱 타겟에서 코드를 분리할 때, 컴파일러는 약 400번 정도 다음과 같은 오류를 반환합니다:
'EventName' is inaccessible due to 'internal' protection level
그래서 모두가 하는 일을 합니다:
public struct EventName {
public static let appOpen = "App Open"
}
Scooby-Doo unmasking
여기 인터로게이션 기록이 있습니다:
🧑💻 나: appOpen을 아무도 호출하지 않는데, 왜 제거(strip)하지 않았나요?
🔗 링커(Linker): public입니다. 프레임워크 외부의 누군가가 호출할 수도 있습니다.
🧑💻 나: 누가요?
🔗 링커(Linker): …누군가요. 또한 그 타입 메타데이터를 유지하고 내보내기 테이블(export table)에 추가했습니다. 감사하세요.
모든 public 심볼은 세 군데에서 비용을 발생시킵니다:
- 링커가 제거할 수 없는 코드 (
__TEXT)
- 타입 메타데이터 (
__const)
- 내보내기 테이블의 항목 (
__LINKEDIT), 이는 프레임워크의 전화번호부와 같습니다.
이를 증명하기 위해, 저는 동일한 CoreKit (360개의 상수 및 40개의 모델)을 두 번 만들었습니다. 유일하게 바뀐 것은 접근 수준이었습니다:
| CoreKit | public | internal |
|---|
| Binary | 316 KB | 221 KB |
| ... | | |
| 같은 코드인데도 30% 더 작아졌습니다. 전화번호부가 마을보다 커진 것입니다. | | |
🪤 전시물 C: 오해의 소지(False Leads)
우리는 이 문제를 해결하기 위해 일주일 동안 리팩토링에 매달렸습니다:
public struct EventName { ... } // 구조체(struct)
public enum EventName { ... } // 아니요, 열거형(enum)
public enum EventName: String { ... } // 열거형 케이스!
...
각각의 크기 보고서:
They're the same picture
타입의 형태는 중요하지 않습니다. 코드는 여전히 public이며, 프레임워크는 계속해서 패키징 세금(packaging tax)을 지불하고 있습니다. 진정한 해결책은 public 선언을 줄이는 것이지만, 이는 1/4에 달하는 정리 작업입니다. 우리는 더 빠른 승리를 원했기 때문에, 다른 질문을 던졌습니다:
만약 프레임워크가 우리가 배포하는 빌드에서 분리되어 있을 필요가 없다면 어떨까요?
💡 돌파구: 둘 다일 필요는 없을까?
동적 프레임워크(Dynamic frameworks)는 빌드는 빠르지만 실행 시 느립니다. 정적 라이브러리(Static libraries)는 실행은 빠르지만 빌드가 느립니다. Xcode 15의 **병합 가능 라이브러리(mergeable libraries)**가 이 상충 관계를 살펴보고 다음과 같이 제안했습니다:
Why not both
이 아이디어를 한 장으로 정리하면 다음과 같습니다:
- 🛠️ 디버그(Debug): 아무것도 변경되지 않습니다. 앱이 각 프레임워크를 다시 내보내기(re-exports)하므로 증분 빌드(incremental builds)가 빠르게 유지됩니다.
- 🚀 릴리스(Release): 링커(linker)가 모든 프레임워크를 앱 바이너리(app binary)에 병합하고 경로를 따라 문자열(strings), 셀렉터(selectors), 스텁(stubs)을 중복 제거합니다. 프레임워크 폴더는 리소스용으로 번들(bundle)에 남아 있지만, 해당 바이너리는 빈 껍데기(empty shells)가 됩니다.
🔧 해결책: 단 하나의 설정
**앱 타겟(app target)**을 선택 → 빌드 설정(Build Settings) → Merged Binary 생성(Create Merged Binary) 검색 → 이를 Automatic으로 설정합니다.
MERGED_BINARY_TYPE = automatic
끝입니다. 코드 변경도, 새로운 도구도 필요 없습니다. automatic은 프로젝트에서 빌드된 모든 직접 프레임워크 의존성(direct framework dependency)을 병합합니다.
⚠️ 주의사항: 오직 직접적인(direct) 의존성만 병합됩니다. 만약 CoreKit이 SupportFeature를 통해서만 앱에 도달한다면, 이를 앱 타겟에 명시적으로 연결해야 합니다.
🎛️ 정밀한 제어를 원한다면? 앱에서는 MERGED_BINARY_TYPE = manual로 설정하고, 병합하려는 각 프레임워크에는 MERGEABLE_LIBRARY = YES를 설정합니다.
⚖️ 결론
| MyApp.app (Release) | 이전(Before) | 이후(After) |
|---|
| 총 크기 | 560 KB | 436 KB (−22%) |
| ... | | |
| 앱 실행 파일(executable)이 커진 이유는 모든 코드가 그 안으로 이동했기 때문입니다. 뒤에 남겨진 프레임워크 바이너리는 빈 껍데기에 불과합니다. | | |
이것은 단 하나의 빌드 설정과 제로 코드 변경만으로 이루어졌습니다. 데모는 세 개의 작은 프레임워크를 사용합니다. 실제 앱은 수십 개에 달합니다.
📜 상세 내용
With great power comes great responsibility
- 디버그(Debug)와 릴리스(Release)는 다릅니다. 병합은 릴리스 빌드에서만 발생하므로, 실제로 배포할 빌드를 테스트해야 합니다.
- 아카이브(Archives) 생성 시간이 더 오래 걸립니다. 병합은 추가적인 링커 작업이므로, 대규모 앱에 미치는 영향을 측정해야 합니다.
- 수동 모드(Manual mode)는 잊기 쉽습니다. 공유되는
.xcconfig 파일에 MERGEABLE_LIBRARY = YES를 넣어주세요.
- 엣지 케이스(Edge cases):
- 병합된 프레임워크에서
Bundle(for:)는 iOS 12 이상이 필요합니다.
dlopen 경로는 병합된 바이너리(binary)를 가리켜야 합니다.
- 충돌 보고서(Crash reports)는 앱을 이미지로 나열합니다.
✅ 다음과 같은 경우에 가치가 있습니다: 첫 번째 파티(first-party) 프레임워크가 몇 개 이상 있고 누군가가 앱 크기나 실행 시간을 신경 쓰는 경우. ❌ 건너뛰세요: 프레임워크가 하나 또는 두 개이거나 바이너리 SDK를 배포하는 경우.
✅ 사례 종결 (Case Closed)
숙제:
nm -gU YourFramework | wc -l을 실행하고 대비하세요.
public일 필요가 없는 항목 하나를 다시 internal로 변경하세요.
- 브랜치에서
MERGED_BINARY_TYPE = automatic을 설정하고 아카이브를 비교하세요.
👉 GitHub에서 데모 저장소 가져가기: 이 게시물에 나오는 모든 숫자의 근거가 되는 측정 스크립트입니다.
📺 링커 심층 분석: 병합 가능한 라이브러리 알아보기 (WWDC23)
저는 이 여섯 글자 키워드를 찾기 전까지는 Xcode, CI 캐시, 그리고 심지어 행성 탓을 했습니다. 이제는 무언가를 비난하기 전에 nm을 실행합니다.
Mic drop
이번 스프린트에서 다섯 번째 프레임워크를 만든 팀원에게 보내주세요.