SPM · Modular Architecture · Build Optimization

SPM Interface 패턴과 증분 빌드 최적화

Swift Package Manager의 핵심 개념(static/dynamic, product/target, package 식별자)과 Interface 모듈 패턴을 통한 증분 빌드 최적화 전략을 다룬다. 외부 SDK를 Adapter 패턴으로 감싸 의존성 역전을 적용하는 실전 기법까지 포함한다.

SPM Incremental Build Tuist DIP
학습 날짜

2026-07-10

1. SPM 기본 개념

Static vs Dynamic 링킹

구분 Static Library Dynamic Framework
빌드 시 코드가 실행 파일에 복사됨 별도 바이너리로 유지
앱 번들 위치 앱 실행 파일 내부 Frameworks/ 폴더
앱 시작 시 로드 없음 (이미 포함) dyld가 로드
SPM 기본값 기본값 (type 생략 시) 명시적 지정 필요
SPM 기본값은 Static

.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")  // 폴더명
                                         ^^^^^^^^^^^^^^^

단축형 사용 조건

product명 == package 식별자일 때 단축형 가능
// 단축형
"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를 분리하나?

구현체가 바뀔 때마다 의존하는 모든 모듈이 재컴파일되는 것을 방지한다:

❌ Interface 없음: BlockUserManager 변경 ↓ App 재컴파일 ChatManager 재컴파일 ProfileManager 재컴파일 SocialScene 재컴파일 ✅ Interface 있음: BlockUserManager 변경 (내부만) ↓ BlockUserInterface 불변 (protocol만) ↓ App, ChatManager 등 재컴파일 안 함

구조

// 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 의존 없음 │ │ │ protocol ChatServiceProtocol │ │ struct MyChatError │ └─────────────────────────────────────┘ ▲ │ 의존 │ ┌─────────────────────────────────────┐ │ ChatServiceAdapter (모듈) │ ← SDK + Interface 둘 다 의존 │ │ │ import ChatServiceInterface │ │ import ChatSDK │ │ │ │ class ChatServiceImpl: │ │ ChatServiceProtocol { │ │ func block() -> MyChatError { │ │ let sdkResult = SDK.block() │ ← SDK 호출 │ return convert(sdkResult) │ ← 변환 │ } │ │ } │ └─────────────────────────────────────┘

코드 예시

// ===== 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 버전 업 시 재컴파일 범위

ChatSDK 2.9.0 → 3.0.0 │ ▼ ChatServiceAdapter 재컴파일 ✅ (SDK 직접 의존) │ ▼ App 재컴파일 ⚠️ (Adapter 의존하므로 불가피) │ ▼ ChatServiceInterface 불변 (SDK 타입 없음) │ ▼ FeatureA 재컴파일 안 함 ✅ FeatureB 재컴파일 안 함 ✅ FeatureC 재컴파일 안 함 ✅
App은 재컴파일된다

App이 Adapter를 의존하고, Adapter가 SDK를 의존하므로 SDK 변경 시 App도 재컴파일된다. 이는 피할 수 없다.

Feature 모듈들이 이득

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

Q: SPM 기본 링킹 타입은?
A: Static Library. type을 생략하면 SPM이 자동 결정하며 대부분 static으로 처리된다.
Q: Dynamic Framework가 Static을 의존하면?
A: Static 코드가 Dynamic에 흡수된다. App이 같은 Static을 또 의존하면 중복 심볼 문제 발생. Tuist/SPM에서도 동일.
Q: xcframework도 DerivedData에 저장되나?
A: 아니오. xcframework는 이미 컴파일된 바이너리라 빌드 산출물이 없다. 원본 위치에서 직접 링크된다.
Q: binaryTarget을 library의 targets에 넣어야 하나?
A: 불필요. target의 dependencies에만 넣으면 된다. library targets에 넣어도 동작은 같지만 불필요한 중복.
Q: 외부 SPM에 Interface를 둬도 증분 빌드 이득?
A: 기본적으로 없음. 버전 고정이라 변경이 없기 때문. 단, SDK 버전 업 시 Interface가 SDK 타입을 숨기면 이득 있음.
Q: 모든 의존성에 Interface를 만들어야 하나?
A: 아니오. 자주 바뀌고 의존자가 많은 모듈만. 안정된 유틸/외부 SDK는 직접 의존이 더 효율적.
Q: App은 SDK 버전 업 시 재컴파일되나?
A: . App이 Adapter를 의존하고 Adapter가 SDK를 의존하므로 불가피. 하지만 Feature 모듈들이 재컴파일 안 되는 게 핵심 이득.
Q: Interface에서 SDK 타입을 어떻게 숨기나?
A: 자체 타입(MyChatError 등)을 정의하고, Adapter에서 SDK 타입 → 자체 타입으로 변환한다. SDK가 내 타입을 알 필요 없음.

9. Xcode Navigator: Packages vs Package Dependencies

섹션 내용 조건
Packages (프로젝트 하위) ABTest, CookieManager, MyFeatureHome 등 appPackages직접 선언된 로컬 패키지
Package Dependencies AppPolicy (local), RxSwift 7.8.0, Alamofire 등 모든 해결된 의존성 (직접 + 전이적, 로컬 + 외부)

전이적 의존성 해결

appPackages에 있는 것: ├── CookieManager ──→ Packages에 표시 │ │ │ └── Package.swift에서 AppPolicy 의존 │ .package(path: "../../Core/AppPolicy") │ SPM이 CookieManager 해결할 때: └── AppPolicy도 같이 해결 ──→ Package Dependencies에 표시
핵심

어딘가의 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로 충분)

핵심 정리