xcframework를 SPM으로 배포하고 App Store Connect에 업로드하기 전에 발생한 문제와 해결
https://x.com/KyleSwifter/status/2051586032722985399?s=20
Dynamic Framework XCFramework에 Static Library 모듈 노출하기
전제
- Parent: Dynamic Framework
- Child(들): Swift Static Library (Parent가 의존)
- XCFramework로 배포 시 소비자가
import ChildModule 가능하게 하고 싶음
왜 기본적으로 안 되는가
Parent를 XCFramework로 만들면 Child의 오브젝트 코드는 Parent 바이너리에 링크되지만, .swiftinterface (모듈 인터페이스)는 포함되지 않는다. 컴파일러가 import ChildModule을 해석할 정보가 없어서 실패.
해결: .swiftinterface 수동 포함
Parent 아카이브 시 Child도 함께 빌드되므로, 빌드 산출물에서 .swiftmodule을 XCFramework에 복사하면 된다.
결과물 구조
Parent.xcframework/
ios-arm64/
Parent.framework/
Parent ← 바이너리 (Child 코드 포함)
Modules/
Parent.swiftmodule/
ChildA.swiftmodule/ ← 복사
ChildB.swiftmodule/ ← 복사
ios-arm64_x86_64-simulator/
Parent.framework/
Modules/
Parent.swiftmodule/
ChildA.swiftmodule/ ← 복사
ChildB.swiftmodule/ ← 복사
빌드 스크립트
#!/bin/bash
set -euo pipefail
PARENT="Parent"
CHILDREN=("ChildA" "ChildB" "ChildC")
OUTPUT="${PARENT}.xcframework"
SLICES=(
"generic/platform=iOS|ios-arm64"
"generic/platform=iOS Simulator|ios-arm64_x86_64-simulator"
)
# 1. Parent만 아카이브 (Child도 자동 빌드됨)
for pair in "${SLICES[@]}"; do
IFS='|' read -r dest slice <<< "$pair"
xcodebuild archive \
-scheme "$PARENT" \
-destination "$dest" \
-archivePath "./archives/${slice}" \
BUILD_LIBRARY_FOR_DISTRIBUTION=YES \
SKIP_INSTALL=NO
done
# 2. XCFramework 생성
ARGS=()
for pair in "${SLICES[@]}"; do
IFS='|' read -r _ slice <<< "$pair"
ARGS+=(-framework "./archives/${slice}.xcarchive/Products/Library/Frameworks/${PARENT}.framework")
done
xcodebuild -create-xcframework "${ARGS[@]}" -output "$OUTPUT"
# 3. 아카이브 산출물에서 Child .swiftmodule 복사
for pair in "${SLICES[@]}"; do
IFS='|' read -r _ slice <<< "$pair"
archive="./archives/${slice}.xcarchive"
modules_dir="${OUTPUT}/${slice}/${PARENT}.framework/Modules"
for child in "${CHILDREN[@]}"; do
src=$(find "$archive" -name "${child}.swiftmodule" -type d | head -1)
cp -R "$src" "$modules_dir/"
echo "✓ ${child} → ${slice}"
done
done
소비자 앱/Framework 사용 시 주의사항
| 항목 |
조건 |
대응 |
| 중복 심볼 |
소비자가 같은 Child를 독립 의존하면 링커 에러 |
Child를 Parent 전용으로 관리하면 문제없음 |
| ObjC 혼합 |
Child가 @objc 타입을 public 노출 |
module.modulemap도 함께 복사 필요. 순수 Swift면 해당 없음 |
| 리소스 번들 |
Child가 xib, asset 등 리소스 보유 |
번들을 Parent.framework에 수동 복사 필요. 리소스 없으면 해당 없음 |
| 전이 의존성 |
Child가 다른 외부 라이브러리에 의존 |
소비자에게 해당 의존성 명시 필요. 시스템 프레임워크만 쓰면 해당 없음 |
| 타입 노출 전파 |
소비자 Framework가 Child 타입을 자신의 public API에 노출 |
소비자의 소비자도 Parent.xcframework 필요. internal 사용이면 해당 없음 |
| Privacy Manifest |
Child가 Privacy API 사용 (iOS 17+) |
Parent의 PrivacyInfo.xcprivacy에 통합 선언 |
| 디버깅 |
소스 스텝 디버깅 시 매핑 제한 |
크래시 심볼리케이션은 정상 동작 |
이 방식이 적합한 경우
- Child가 순수 Swift, 리소스 없음, 전이 의존성 없음
- Child를 Parent 전용 내부 모듈로 관리 (중복 의존 방지)
- 빌드 자동화가 갖춰져 있음 (CI 스크립트로 관리)
- 배포 대상이 내부 또는 통제 가능한 소비자
Multi Swift Module XCFramework PoC Design
Problem
검증 목표는 OneSDK.xcframework 1개만 소비 앱에 추가해도, 소비 측에서 아래처럼 여러 Swift module을 직접 import할 수 있는지 확인하는 것이다.
import FeatureA
import FeatureB
이번 작업은 내부 검증용 PoC이며, 우선 소비 환경은 Xcode/Tuist에서 xcframework를 직접 추가하는 방식을 기준으로 한다. 작업 위치는 저장소 내부가 아니라 빈 외부 경로 ~/tmp/202605061700 를 사용한다.
Goals
OneSDK.xcframework 1개만 소비 앱에 연결한다.
- 소비 앱에서
import FeatureA, import FeatureB가 모두 컴파일된다.
FeatureA, FeatureB의 public 타입을 실제로 생성하고 호출할 수 있다.
- 최소 iOS Simulator slice에서 동작을 확인한다.
- 실패 시 import, link, runtime 중 어디에서 깨졌는지 분리해서 확인할 수 있다.
Non-Goals
- App Store 배포 가능한 안정 패턴 확정
- Swift Package
binaryTarget 배포까지 포함한 검증
- 리소스 번들 처리 검증
- 다수 모듈 또는 실제 상용 SDK 전체 이관
- device/simulator/macOS 전 플랫폼 동시 검증
Context
이 저장소는 이미 Tuist에서 Frameworks/*.xcframework를 직접 의존성으로 참조하는 구조를 사용한다. 하지만 이번 PoC는 그 영향에서 완전히 분리하기 위해 빈 외부 경로 ~/tmp/202605061700 에서 진행한다. 따라서 소비 측 패키지 매니저보다 framework 산출물 구조가 성립하는지를 먼저 검증하는 것이 맞다.
핵심 아이디어는 다음과 같다.
- 최종 링크 바이너리는
Carrier.framework/Carrier 하나만 둔다.
- 대신
Carrier.framework/Modules/ 아래에 Carrier.swiftmodule, FeatureA.swiftmodule, FeatureB.swiftmodule를 함께 배치한다.
- 소비 앱은
OneSDK.xcframework 하나만 링크하지만, Swift 컴파일러는 여러 module metadata를 보고 import FeatureA, import FeatureB를 허용한다.
Approach Options
1. Single carrier framework with multiple swiftmodule directories
하나의 framework binary 안에 여러 Swift module metadata를 병합한다.
장점
- 목표 형태와 정확히 일치한다.
- 소비 측은 xcframework 하나만 추가하면 된다.
단점
- Xcode가 자동 지원하는 정규 흐름이 아니므로 후가공 스크립트가 필요하다.
- Swift toolchain 변화에 민감할 가능성이 있다.
2. Multiple XCFrameworks distributed together
FeatureA.xcframework, FeatureB.xcframework를 따로 유지하고 소비 측에서 함께 추가한다.
장점
- 가장 안정적이다.
- 일반적인 배포 흐름과 가깝다.
단점
3. Single module facade
여러 기능을 하나의 module로 합쳐 import OneSDK만 허용한다.
장점
단점
import를 여러 module명으로 유지 요구를 충족하지 못한다.
Recommended Design
PoC는 Approach 1을 채택한다.
Topology
Carrier: dynamic framework, 최종 OneSDK.framework
FeatureA: static Swift library 성격의 산출물
FeatureB: static Swift library 성격의 산출물
ConsumerApp: OneSDK.xcframework 하나만 링크하는 샘플 소비 앱
Design Rules
- 최종 Mach-O 바이너리는
Carrier 하나만 남긴다.
FeatureA, FeatureB의 public symbol은 Carrier에 실제로 포함되어야 한다.
FeatureA.swiftmodule, FeatureB.swiftmodule는 Carrier.framework/Modules/ 아래에 병합한다.
- 보조 모듈의 interface는 링크 대상을 자기 이름이 아니라
Carrier로 보게 맞춘다.
- 각 slice는 동일한
Modules/ 구성을 가져야 한다.
Technical Flow
Phase 1. Minimal producer targets
최소 4개 타깃으로 시작한다.
Carrier
FeatureA
FeatureB
ConsumerApp
각 모듈에는 외부 검증용 public API를 얇게 둔다.
FeatureA.PublicTypeA
FeatureB.PublicTypeB
PoC 초반에는 public API를 최대한 단순하게 유지해 import 성립 여부와 symbol resolution을 쉽게 관찰한다.
Phase 2. Build module artifacts
FeatureA, FeatureB는 아래 산출물을 남겨야 한다.
- 구현 바이너리 산출물 (
.a 또는 대응 정적 링크 산출물)
FeatureA.swiftmodule/
FeatureA.swiftdoc
FeatureA.swiftinterface
FeatureB도 동일하다.
필수 빌드 조건:
BUILD_LIBRARY_FOR_DISTRIBUTION=YES
- slice별 module interface 생성
Phase 3. Link implementation into carrier
Carrier.framework/Carrier 하나에 FeatureA, FeatureB 구현 심볼을 포함시킨다. 소비 앱은 Carrier 하나만 링크하므로, 최종 심볼 해석은 이 바이너리에서 끝나야 한다.
Phase 4. Merge multiple swiftmodules into carrier
최종 framework 내부 구조는 아래를 목표로 한다.
Carrier.framework/
Carrier
Headers/
Modules/
Carrier.swiftmodule/
FeatureA.swiftmodule/
FeatureB.swiftmodule/
Objective-C/C 혼합이 없다면 module.modulemap은 필수는 아니다. 순수 Swift PoC라면 우선 swiftmodule 디렉터리 병합에 집중한다.
Phase 5. Align module link target
이번 PoC의 핵심 단계다.
FeatureA.swiftinterface, FeatureB.swiftinterface가 링크 대상을 자기 module명으로 기대하면 소비 앱은 FeatureA.framework, FeatureB.framework를 찾으려 한다. 따라서 보조 모듈 interface가 실제 링크 대상을 Carrier로 바라보도록 맞춰야 한다.
접근 후보는 아래 두 가지다.
- 빌드 단계에서
Carrier를 링크 이름으로 가지도록 compiler/linker 옵션을 조정한다.
- 빌드 산출 후
swiftinterface를 후가공해 보조 모듈의 link target metadata를 Carrier로 맞춘다.
PoC에서는 두 방식을 모두 후보로 열어두되, 우선순위는 빌드 단계에서 해결 가능한 방법 탐색이다. 해당 방법이 불가능하거나 재현성이 낮으면 후가공 스크립트로 전환한다.
이 단계가 실패하면 증상은 보통 아래와 같다.
import는 되지만 link 단계에서 framework not found 발생
Undefined symbols for architecture ...
Phase 6. Produce slice frameworks
우선 iOS Simulator slice 하나로 성립을 확인한다.
- 예:
ios-arm64_x86_64-simulator/Carrier.framework
PoC가 안정화되면 device slice를 추가한다.
Phase 7. Create XCFramework
완성된 slice별 Carrier.framework를 xcodebuild -create-xcframework로 묶어 OneSDK.xcframework를 생성한다.
이 단계는 포장 자체보다 slice 내부 framework 구조가 이미 올바른지가 더 중요하다.
Phase 8. Consumer validation
소비 앱에서 OneSDK.xcframework 하나만 추가한 상태로 아래를 검증한다.
import FeatureA
import FeatureB
FeatureA.PublicTypeA() 호출
FeatureB.PublicTypeB() 호출
- 앱 실행
Risk-First Validation
Highest priority risks
| Priority |
Risk |
Symptom |
What to inspect |
Response |
| 1 |
Module imports succeed but linking fails |
framework not found, Undefined symbols |
FeatureA/B.swiftinterface가 어느 바이너리명을 가리키는지 |
보조 모듈 link target을 Carrier로 맞춘다 |
| 2 |
Carrier binary misses implementation symbols |
타입 사용 시 링크 실패 또는 런타임 실패 |
nm, otool로 공개 symbol 존재 여부 확인 |
FeatureA/B를 정적으로 링크해 Carrier에 수렴 |
| 3 |
Slice drift |
simulator만 되고 device에서 실패 |
slice별 Modules/ 구성 비교 |
slice 조립 스크립트를 공통화 |
| 4 |
Toolchain sensitivity |
특정 Xcode에서만 동작 |
swiftinterface, .abi.json, library evolution 옵션 |
PoC 동안 Xcode 버전 고정 |
| 5 |
Non-standard framework/module split |
IDE 인덱싱 이상, archive 이상 |
import는 되는데 개발 경험이 불안정 |
PoC 성공 후 상용화 적합성 별도 판단 |
| 6 |
Codesign breaks after post-processing |
기기 실행 또는 배포 오류 |
후가공 후 codesign 상태 |
최종 산출물 기준 재서명 |
Failure checkpoints
-
Build failure
swiftinterface가 생성되지 않음
- 원인 후보:
BUILD_LIBRARY_FOR_DISTRIBUTION=YES 누락
-
Packaging failure
Modules/FeatureA.swiftmodule를 넣었지만 import가 안 됨
- 원인 후보: 폴더명, arch별 파일명, slice 구성이 불일치
-
Link failure
- import는 되지만 최종 link가 깨짐
- 원인 후보: 보조 모듈이
Carrier가 아니라 자기 framework 이름을 기대
-
Runtime failure
- 빌드는 되지만 타입 호출 시 크래시
- 원인 후보: 구현 심볼 누락 또는 중복 심볼 충돌
Execution Strategy
PoC는 큰 산출물을 한 번에 만들지 않고 아래 순서로 리스크를 분리한다.
- simulator 단일 slice
FeatureA 단일 모듈 import 성공
FeatureA 타입 사용 성공
FeatureB 추가 후 다중 import 성공
- 런타임 실행 성공
- device slice 확장
Validation Criteria
PoC 완료 조건은 아래를 모두 만족하는 것이다.
- 소비 앱에는
OneSDK.xcframework 1개만 추가한다.
import FeatureA, import FeatureB가 모두 컴파일된다.
- 각 모듈의 public 타입 인스턴스화와 메서드 호출이 성공한다.
- iOS Simulator 기준 앱 실행에 성공한다.
- 실패 시 import/link/runtime 중 어느 단계에서 깨졌는지 재현 가능하다.
Out of Scope Follow-Ups
PoC 성공 후 별도 판단이 필요한 항목:
- Swift Package
binaryTarget 배포
- code signing / notarization 정책 정리
- 장기적인 Xcode/Swift 버전 호환성
- 리소스 번들 포함
- 실제 상용 SDK 다중 모듈 이관
Open Questions For Planning
- PoC 위치 확정: PoC 산출물과 샘플 앱은 저장소 외부
~/tmp/202605061700 에 둔다.
FeatureA/B 산출물을 어떤 빌드 시스템으로 생성할지 (xcodebuild 중심 vs 다른 생산 파이프라인)
swiftinterface link target 정렬을 빌드 옵션으로 해결할지, 후가공 스크립트로 해결할지
swiftinterface 후가공이 필요하다면 수동 실험 후 스크립트화할지, 처음부터 자동화할지
xcframework를 SPM으로 배포하고 App Store Connect에 업로드하기 전에 발생한 문제와 해결
https://x.com/KyleSwifter/status/2051586032722985399?s=20
Dynamic Framework XCFramework에 Static Library 모듈 노출하기
전제
import ChildModule가능하게 하고 싶음왜 기본적으로 안 되는가
Parent를 XCFramework로 만들면 Child의 오브젝트 코드는 Parent 바이너리에 링크되지만,
.swiftinterface(모듈 인터페이스)는 포함되지 않는다. 컴파일러가import ChildModule을 해석할 정보가 없어서 실패.해결:
.swiftinterface수동 포함Parent 아카이브 시 Child도 함께 빌드되므로, 빌드 산출물에서
.swiftmodule을 XCFramework에 복사하면 된다.결과물 구조
빌드 스크립트
소비자 앱/Framework 사용 시 주의사항
@objc타입을 public 노출module.modulemap도 함께 복사 필요. 순수 Swift면 해당 없음PrivacyInfo.xcprivacy에 통합 선언이 방식이 적합한 경우
Multi Swift Module XCFramework PoC Design
Problem
검증 목표는
OneSDK.xcframework1개만 소비 앱에 추가해도, 소비 측에서 아래처럼 여러 Swift module을 직접 import할 수 있는지 확인하는 것이다.이번 작업은 내부 검증용 PoC이며, 우선 소비 환경은 Xcode/Tuist에서 xcframework를 직접 추가하는 방식을 기준으로 한다. 작업 위치는 저장소 내부가 아니라 빈 외부 경로
~/tmp/202605061700를 사용한다.Goals
OneSDK.xcframework1개만 소비 앱에 연결한다.import FeatureA,import FeatureB가 모두 컴파일된다.FeatureA,FeatureB의 public 타입을 실제로 생성하고 호출할 수 있다.Non-Goals
binaryTarget배포까지 포함한 검증Context
이 저장소는 이미 Tuist에서
Frameworks/*.xcframework를 직접 의존성으로 참조하는 구조를 사용한다. 하지만 이번 PoC는 그 영향에서 완전히 분리하기 위해 빈 외부 경로~/tmp/202605061700에서 진행한다. 따라서 소비 측 패키지 매니저보다 framework 산출물 구조가 성립하는지를 먼저 검증하는 것이 맞다.핵심 아이디어는 다음과 같다.
Carrier.framework/Carrier하나만 둔다.Carrier.framework/Modules/아래에Carrier.swiftmodule,FeatureA.swiftmodule,FeatureB.swiftmodule를 함께 배치한다.OneSDK.xcframework하나만 링크하지만, Swift 컴파일러는 여러 module metadata를 보고import FeatureA,import FeatureB를 허용한다.Approach Options
1. Single carrier framework with multiple swiftmodule directories
하나의 framework binary 안에 여러 Swift module metadata를 병합한다.
장점
단점
2. Multiple XCFrameworks distributed together
FeatureA.xcframework,FeatureB.xcframework를 따로 유지하고 소비 측에서 함께 추가한다.장점
단점
1개 산출물을 충족하지 못한다.3. Single module facade
여러 기능을 하나의 module로 합쳐
import OneSDK만 허용한다.장점
단점
import를 여러 module명으로 유지요구를 충족하지 못한다.Recommended Design
PoC는 Approach 1을 채택한다.
Topology
Carrier: dynamic framework, 최종OneSDK.frameworkFeatureA: static Swift library 성격의 산출물FeatureB: static Swift library 성격의 산출물ConsumerApp:OneSDK.xcframework하나만 링크하는 샘플 소비 앱Design Rules
Carrier하나만 남긴다.FeatureA,FeatureB의 public symbol은Carrier에 실제로 포함되어야 한다.FeatureA.swiftmodule,FeatureB.swiftmodule는Carrier.framework/Modules/아래에 병합한다.Carrier로 보게 맞춘다.Modules/구성을 가져야 한다.Technical Flow
Phase 1. Minimal producer targets
최소 4개 타깃으로 시작한다.
CarrierFeatureAFeatureBConsumerApp각 모듈에는 외부 검증용 public API를 얇게 둔다.
FeatureA.PublicTypeAFeatureB.PublicTypeBPoC 초반에는 public API를 최대한 단순하게 유지해 import 성립 여부와 symbol resolution을 쉽게 관찰한다.
Phase 2. Build module artifacts
FeatureA,FeatureB는 아래 산출물을 남겨야 한다..a또는 대응 정적 링크 산출물)FeatureA.swiftmodule/FeatureA.swiftdocFeatureA.swiftinterfaceFeatureB도 동일하다.필수 빌드 조건:
BUILD_LIBRARY_FOR_DISTRIBUTION=YESPhase 3. Link implementation into carrier
Carrier.framework/Carrier하나에FeatureA,FeatureB구현 심볼을 포함시킨다. 소비 앱은Carrier하나만 링크하므로, 최종 심볼 해석은 이 바이너리에서 끝나야 한다.Phase 4. Merge multiple swiftmodules into carrier
최종 framework 내부 구조는 아래를 목표로 한다.
Objective-C/C 혼합이 없다면
module.modulemap은 필수는 아니다. 순수 Swift PoC라면 우선swiftmodule디렉터리 병합에 집중한다.Phase 5. Align module link target
이번 PoC의 핵심 단계다.
FeatureA.swiftinterface,FeatureB.swiftinterface가 링크 대상을 자기 module명으로 기대하면 소비 앱은FeatureA.framework,FeatureB.framework를 찾으려 한다. 따라서 보조 모듈 interface가 실제 링크 대상을Carrier로 바라보도록 맞춰야 한다.접근 후보는 아래 두 가지다.
Carrier를 링크 이름으로 가지도록 compiler/linker 옵션을 조정한다.swiftinterface를 후가공해 보조 모듈의 link target metadata를Carrier로 맞춘다.PoC에서는 두 방식을 모두 후보로 열어두되, 우선순위는 빌드 단계에서 해결 가능한 방법 탐색이다. 해당 방법이 불가능하거나 재현성이 낮으면 후가공 스크립트로 전환한다.
이 단계가 실패하면 증상은 보통 아래와 같다.
import는 되지만 link 단계에서framework not found발생Undefined symbols for architecture ...Phase 6. Produce slice frameworks
우선 iOS Simulator slice 하나로 성립을 확인한다.
ios-arm64_x86_64-simulator/Carrier.frameworkPoC가 안정화되면 device slice를 추가한다.
Phase 7. Create XCFramework
완성된 slice별
Carrier.framework를xcodebuild -create-xcframework로 묶어OneSDK.xcframework를 생성한다.이 단계는 포장 자체보다 slice 내부 framework 구조가 이미 올바른지가 더 중요하다.
Phase 8. Consumer validation
소비 앱에서
OneSDK.xcframework하나만 추가한 상태로 아래를 검증한다.import FeatureAimport FeatureBFeatureA.PublicTypeA()호출FeatureB.PublicTypeB()호출Risk-First Validation
Highest priority risks
framework not found,Undefined symbolsFeatureA/B.swiftinterface가 어느 바이너리명을 가리키는지Carrier로 맞춘다nm,otool로 공개 symbol 존재 여부 확인FeatureA/B를 정적으로 링크해Carrier에 수렴Modules/구성 비교swiftinterface,.abi.json, library evolution 옵션Failure checkpoints
Build failure
swiftinterface가 생성되지 않음BUILD_LIBRARY_FOR_DISTRIBUTION=YES누락Packaging failure
Modules/FeatureA.swiftmodule를 넣었지만 import가 안 됨Link failure
Carrier가 아니라 자기 framework 이름을 기대Runtime failure
Execution Strategy
PoC는 큰 산출물을 한 번에 만들지 않고 아래 순서로 리스크를 분리한다.
FeatureA단일 모듈 import 성공FeatureA타입 사용 성공FeatureB추가 후 다중 import 성공Validation Criteria
PoC 완료 조건은 아래를 모두 만족하는 것이다.
OneSDK.xcframework1개만 추가한다.import FeatureA,import FeatureB가 모두 컴파일된다.Out of Scope Follow-Ups
PoC 성공 후 별도 판단이 필요한 항목:
binaryTarget배포Open Questions For Planning
~/tmp/202605061700에 둔다.FeatureA/B산출물을 어떤 빌드 시스템으로 생성할지 (xcodebuild중심 vs 다른 생산 파이프라인)swiftinterfacelink target 정렬을 빌드 옵션으로 해결할지, 후가공 스크립트로 해결할지swiftinterface후가공이 필요하다면 수동 실험 후 스크립트화할지, 처음부터 자동화할지