SPM Interface 패턴과 증분 빌드 최적화
Swift Package Manager의 핵심 개념(static/dynamic, product/target, package 식별자)과 Interface 모듈 패턴을 통한 증분 빌드 최적화 전략을 다룬다. 외부 SDK를 Adapter 패턴으로 감싸 의존성 역전을 적용하는 실전 기법까지 포함한다.
2026-07-10
1. SPM 기본 개념
Static vs Dynamic 링킹
| 구분 | Static Library | Dynamic Framework |
|---|---|---|
| 빌드 시 | 코드가 실행 파일에 복사됨 | 별도 바이너리로 유지 |
| 앱 번들 위치 | 앱 실행 파일 내부 | Frameworks/ 폴더 |
| 앱 시작 시 | 로드 없음 (이미 포함) | dyld가 로드 |
| SPM 기본값 | 기본값 (type 생략 시) | 명시적 지정 필요 |
.library(name:targets:)에서 type을 생략하면 SPM이 자동 결정하며, 대부분 static으로 처리된다.
Product vs Target
| 용어 | 정의 | 용도 |
|---|---|---|
| Target | 컴파일 단위 (모듈) | 증분 빌드의 최소 단위 |
| Product | 외부에 노출하는 라이브러리 | 다른 패키지가 의존할 수 있는 단위 |
// Package.swift 예시
let package = Package(
name: "MyFeatureHome",
products: [
// Products: 외부 노출용
.library(name: "MyFeatureHomeFeature", targets: ["MyFeatureHomeFeature"]),
.library(name: "MyFeatureHomeDataInterface", targets: ["MyFeatureHomeDataInterface"]),
],
targets: [
// Targets: 실제 컴파일 단위
.target(name: "MyFeatureHomeFeature", dependencies: [...]),
.target(name: "MyFeatureHomeDataInterface", dependencies: []),
]
)
2. Package 식별자 규칙
의존성 선언 시 .product(name:package:)의 package 파라미터 값은 의존 방식에 따라 결정된다:
| 의존 방식 | package 식별자 | 예시 |
|---|---|---|
| URL 의존 | URL 마지막 부분 (.git 제외) | .../chat-sdk.git → "chat-sdk" |
| path 의존 | 폴더명 | ../../Core/MyNetwork → "MyNetwork" |
| name:path: 의존 | 명시한 name | .package(name: "Foo", path: ...) → "Foo" |
// URL 의존
.package(url: "https://github.com/example/chat-sdk.git", exact: "1.0.44")
.product(name: "ChatSDK", package: "chat-sdk") // URL 마지막
^^^^^^^^^^^^^^^^
// path 의존
.package(path: "../../Core/MyNetwork")
.product(name: "MyNetwork", package: "MyNetwork") // 폴더명
^^^^^^^^^^^^^^^
단축형 사용 조건
// 단축형 "MyNetwork" // 명시적 (동일한 의미) .product(name: "MyNetwork", package: "MyNetwork")
패키지가 여러 product를 제공해도 상관없다. 해당 이름의 product가 존재하고 package 식별자와 같으면 단축형 사용 가능:
// RxSwift 패키지: RxSwift, RxCocoa, RxRelay 등 여러 product 제공 "RxSwift" // ✅ 단축형 가능 (product명 == package식별자) .product(name: "RxRelay", package: "RxSwift") // ⚠️ 명시 필요 (RxRelay ≠ RxSwift)
Package(name:)의 용도
Package.swift 최상단의 name은 표시용이며, URL/path 의존 시 식별자로 사용되지 않는다:
| 용도 | 사용 여부 |
|---|---|
| Xcode UI 표시 | O |
| 에러 메시지 표시 | O |
| URL 의존 시 package: 파라미터 | X (URL 마지막 부분 사용) |
| path 의존 시 package: 파라미터 | X (폴더명 사용, name:path: 제외) |
3. binaryTarget (xcframework)
xcframework를 SPM에서 사용하려면 반드시 .binaryTarget으로 선언해야 한다:
targets: [
.target(
name: "SDKWrapper",
dependencies: [
"SGSAuthGoogle", // binaryTarget 이름으로 의존
]
),
.binaryTarget(
name: "SGSAuthGoogle",
path: "SGSSDK/SGSAuthGoogle.xcframework"
),
]
xcframework vs 소스 코드
| 구분 | xcframework | 소스 코드 |
|---|---|---|
| DerivedData에 빌드 산출물 | X (이미 바이너리) | O (.o, .swiftmodule 생성) |
| 빌드 시 동작 | 링커가 직접 참조 | 컴파일 필요 |
| 앱 번들 위치 | Static → 실행파일 / Dynamic → Frameworks/ | 동일 |
4. Interface 모듈 패턴 (증분 빌드 최적화)
왜 Interface를 분리하나?
구현체가 바뀔 때마다 의존하는 모든 모듈이 재컴파일되는 것을 방지한다:
구조
// BlockUserInterface 모듈 (protocol만)
public protocol BlockUserService {
var blockedUserMemberNos: Set<String> { get }
func blockUser(memberNo: String, completion: @escaping (Result<Bool, Error>) -> Void)
func unblockUser(memberNo: String, completion: @escaping (Result<Bool, Error>) -> Void)
}
// BlockUserManager 모듈 (구현체)
import BlockUserInterface
public final class BlockUserManager: BlockUserService {
// 구현...
}
Interface 분리 판단 기준
| 조건 | Interface 필요? |
|---|---|
| 자주 바뀜 + 의존자 많음 | 강력 추천 |
| 자주 바뀜 + 의존자 1~2개 | 효과 적음 |
| 안 바뀜 + 의존자 많음 | 큰 의미 없음 |
| 유틸/외부 SDK (버전 고정) | 불필요 (AppLog 등) |
Interface 이득 = (변경 빈도) × (의존자 수) × (각 의존자 빌드 시간)
5. 외부 SPM과 Interface 패턴
외부 SPM에 Interface를 둬도 증분 빌드 이득이 있을까?
외부 SPM은 버전 고정(exact: "2.9.0")되어 있어 변경이 없다. 변경이 없으면 재컴파일도 없으므로 Interface로 차단할 것이 없다.
단, SDK 버전 업 시에는?
Interface가 SDK 타입을 완전히 숨기면 이득이 있다:
// ❌ SDK 타입 노출 → 이득 없음
protocol ChatSDKProtocol {
func blockUser(_ id: String) async -> Result<Void, ChatSDKError>
} ^^^^^^^^^^^^^^^^^
// SDK 버전 바뀌면 → protocol도 바뀜 → 의존자 재컴파일
// ✅ SDK 타입 숨김 → 이득 있음
protocol ChatSDKProtocol {
func blockUser(_ id: String) async -> Result<Void, MyChatError>
} ^^^^^^^^^^^
// SDK 버전 바뀌어도 → protocol 불변 → 의존자 재컴파일 없음
| Interface 설계 | SDK 버전 업 시 |
|---|---|
| SDK 타입 노출 | Interface 변경 → 의존자 재컴파일 → 이득 없음 |
| SDK 타입 숨김 | Interface 불변 → 의존자 재컴파일 없음 → 이득 있음 |
6. Adapter 패턴으로 외부 SDK 감싸기
외부 SDK를 직접 사용하지 않고 Adapter로 감싸면 테스트/교체/증분빌드 이점을 얻는다:
코드 예시
// ===== ChatServiceInterface 모듈 (SDK 모름) =====
public struct MyChatError: Error {
public let code: Int
public let message: String
}
public protocol ChatServiceProtocol {
func blockUser(_ id: String) async -> Result<Void, MyChatError>
}
// ===== ChatServiceAdapter 모듈 (SDK + Interface 알음) =====
import ChatServiceInterface
import ChatSDK
public class ChatServiceImpl: ChatServiceProtocol {
public func blockUser(_ id: String) async -> Result<Void, MyChatError> {
let sdkResult = await ChatSDK.shared.block(id)
switch sdkResult {
case .success:
return .success(())
case .failure(let sdkError):
// SDK 타입 → 내 타입으로 변환
let myError = MyChatError(
code: sdkError.code,
message: sdkError.message
)
return .failure(myError)
}
}
}
의존 방향
| 모듈 | SDK 의존 | Interface 의존 |
|---|---|---|
| Interface | X | - |
| Adapter | O | O |
| FeatureA, FeatureB... | X | O |
| App | X (Adapter 통해 간접) | O |
7. 의존성 역전 원칙 (DIP)과 빌드 이득
App에서 Adapter 주입
// App (Composition Root) let chatService: ChatServiceProtocol = ChatServiceImpl() let featureA = FeatureA(chatService: chatService) // 주입 let featureB = FeatureB(chatService: chatService) // 주입
// FeatureA - SDK 전혀 모름
class FeatureA {
let chatService: ChatServiceProtocol // Interface 타입
func doSomething() {
chatService.blockUser("123") // Interface 메서드 호출
}
}
SDK 버전 업 시 재컴파일 범위
App이 Adapter를 의존하고, Adapter가 SDK를 의존하므로 SDK 변경 시 App도 재컴파일된다. 이는 피할 수 없다.
App 1개 재컴파일 vs Feature 10개 재컴파일 안 함 → 여전히 큰 이득
8. Tuist에서 SPM 설정
productTypes 설정 위치
// Tuist/Package.swift
#if TUIST
let packageSettings = PackageSettings(
productTypes: [
"Alamofire": .staticFramework,
"LineSDK": .framework, // dynamic
"MyNetwork": .staticFramework,
],
baseSettings: .settings(...)
)
#endif
내부 vs 외부 패키지 등록
| 상황 | Tuist 매니페스트 등록 |
|---|---|
| App이 직접 import | O (AppPackages, AppDependencies) |
| 내부 SPM이 의존 (App은 import 안 함) | X (해당 패키지의 Package.swift에만) |
// AppPackages.swift - App이 직접 쓰는 것만
public let appPackages: [ProjectDescription.Package] = [
.local(path: .relativeToRoot("Core/SDKWrapper")),
// SGSBase, SGSAuth 등은 여기 없음 (SDKWrapper가 내부적으로 의존)
]
Q&A
9. Xcode Navigator: Packages vs Package Dependencies
| 섹션 | 내용 | 조건 |
|---|---|---|
| Packages (프로젝트 하위) | ABTest, CookieManager, MyFeatureHome 등 | appPackages에 직접 선언된 로컬 패키지 |
| Package Dependencies | AppPolicy (local), RxSwift 7.8.0, Alamofire 등 | 모든 해결된 의존성 (직접 + 전이적, 로컬 + 외부) |
전이적 의존성 해결
어딘가의 Package.swift dependencies에 .package(path:) 또는 .package(url:)로 선언되면 → SPM이 해결 → 전체 그래프에서 사용 가능
appPackages에 없어도 사용 가능?
| 상황 | 사용 가능? |
|---|---|
| appPackages에 직접 선언 | ✅ |
| 다른 패키지가 의존 (전이적 해결) | ✅ |
| 아무도 의존 안 함 | ❌ (해결 안 됨) |
전이적으로 사용 가능해도, App이 직접 쓰는 패키지는 appPackages에 명시하는 게 안전. 나중에 중간 패키지가 의존을 제거하면 App도 깨짐.
Interface 모듈이 appPackages에 없어도 되는 이유
App은 구현체만 생성해서 넘기면 되기 때문:
// FeatureA (Interface만 앎)
class FeatureA {
let service: BlockUserService // Interface 타입
init(service: BlockUserService) {
self.service = service
}
}
// App (구현체만 생성해서 넘김)
let impl = BlockUserManager.shared // 구현체
let feature = FeatureA(service: impl) // 그냥 넘기기
// → Swift가 자동으로 프로토콜로 업캐스팅
| App 코드 | Interface import 필요? |
|---|---|
| 구현체 생성 후 그냥 넘기기 | 불필요 |
| Interface 타입으로 변수 선언 | 필요 (let s: BlockUserService = ...) |
| DI 컨테이너에 프로토콜로 등록 | 필요 (container.register(BlockUserService.self)) |
BlockUserInterface, SDKWrapperInterface, NetworkInterface 등 Interface 모듈들이 Package Dependencies에 있는 이유:
→ Feature/Core 모듈들이 의존하지만, App은 구현체만 넘기면 되므로 appPackages에 직접 선언 불필요
10. Adapter 패턴의 실제 용도: Unit Test
Adapter 패턴은 증분 빌드 이득이 아니라 테스트 용이성을 위한 것:
현재 구조 (테스트 어려움)
public final class BlockUserManager: BlockUserService {
public func blockUser(...) {
// 하드코딩 → 테스트 시 진짜 API 호출됨
let result = await ChatSDKCompat.shared.blockUser(memberNo)
}
}
Adapter 적용 후 (테스트 가능)
// Protocol
protocol ChatSDKServiceProtocol {
func blockUser(_ memberNo: String) async -> Result<Void, Error>
}
// 주입받는 구조
public final class BlockUserManager: BlockUserService {
private let chatSDK: ChatSDKServiceProtocol
public init(chatSDK: ChatSDKServiceProtocol) {
self.chatSDK = chatSDK
}
public func blockUser(...) {
let result = await chatSDK.blockUser(memberNo) // 주입받은 것 사용
}
}
Mock으로 테스트하는 것
| 시나리오 | 테스트 내용 |
|---|---|
| SDK 성공 반환 | completion에 .success(true) 전달되나? |
| SDK 실패 반환 | convertToBlockUserError 변환 제대로 되나? |
| reloadList=true | reloadBlockList() 호출되나? |
| reloadList=false | reloadBlockList() 호출 안 되나? |
SDK 자체가 아니라 "SDK 응답에 대한 내 코드의 로직"을 테스트. SDK는 SDK 팀이 테스트함.
Adapter vs Interface 목적 비교
| 패턴 | 주 목적 | 증분 빌드 이득 |
|---|---|---|
| Interface 모듈 (BlockUserInterface) | Feature 재컴파일 방지 | ✅ 있음 |
| Adapter (ChatSDKServiceProtocol) | Unit Test Mock 주입 | ❌ 없음 (이미 Interface로 충분) |
핵심 정리
- SPM 기본값은 Static - Dynamic은 명시적 지정 필요
- package 식별자 = URL 마지막 부분 또는 폴더명 (Package name 아님)
- Interface 분리 이득 = 변경 빈도 × 의존자 수 × 빌드 시간
- 외부 SPM Interface = SDK 타입 완전히 숨겨야 버전 업 시 이득
- Adapter 패턴 = 증분 빌드 X, Unit Test Mock 주입이 목적
- DIP 적용 결과 = App만 재컴파일, Feature들은 재컴파일 안 함
- Packages vs Package Dependencies = 직접 선언 vs 전이적 해결 (모두 사용 가능)