iOS 모노레포 생존기 (3) — 서드파티 격리와 빌드 시스템
강민석
2026년 3월 5일6분 분량
왜 감싸는가
김캐디는 27개의 외부 라이브러리를 사용합니다. TCA, Alamofire, Kingfisher, Firebase, Kakao SDK, RxSwift 등 — 대부분의 iOS 프로젝트가 쓰는 라이브러리들입니다.
일반적으로 외부 라이브러리는 이렇게 사용합니다:
import ComposableArchitecture
import Kingfisher
import Alamofire간단하고 직관적이지만, 모듈이 106개인 환경에서는 몇 가지 문제가 생깁니다.
변경 지점의 분산
Kingfisher에서 SDWebImage로 이미지 로딩 라이브러리를 교체한다고 해봅시다. import Kingfisher가 적힌 모듈이 20개라면, 20개 모듈의 Project.swift에서 의존성을 변경하고, 20개 모듈의 소스에서 import를 바꿔야 합니다.
API 노출 범위
import Kingfisher를 하면 Kingfisher의 모든 public API가 노출됩니다. KFImage, ImageCache, ImageDownloader, 다양한 Modifier — 개발자마다 다른 API를 사용하면 코드 일관성이 깨지고, 라이브러리 교체 시 수정 범위가 예측 불가능해집니다.
SPM Product 이름의 불확실성
SPM 패키지의 product 이름이 패키지 이름과 다른 경우가 많습니다. Firebase SDK 하나만 해도 FirebaseMessaging, FirebaseCrashlytics, FirebaseAnalytics, FirebaseInAppMessaging-Beta 등 6개 이상의 product가 있고, InAppMessaging의 정확한 이름이 FirebaseInAppMessaging-Beta라는 걸 기억해야 합니다.
래퍼 모듈 패턴
해결 방법은 단순합니다. 모든 외부 라이브러리를 자체 모듈로 한 번 감싸고, 나머지 코드는 래퍼 모듈만 import합니다.
패턴 1: 단순 re-export
대부분의 래퍼 모듈은 @_exported import 한 줄로 구성됩니다:
// ThirdParty_TCA/Sources/Exports.swift
@_exported import ComposableArchitecture@_exported는 import를 전이시킵니다. import ThirdParty_TCA를 하면 ComposableArchitecture의 모든 심볼을 별도 import 없이 사용할 수 있습니다. 실질적으로 ComposableArchitecture의 별칭(alias)인 셈입니다.
여러 SPM product를 하나의 래퍼로 묶을 수도 있습니다:
// ThirdParty_Firebase/Sources/Exports.swift
@_exported import FirebaseAILogic
@_exported import FirebaseAnalytics
@_exported import FirebaseCore
@_exported import FirebaseCrashlytics
@_exported import FirebaseInAppMessaging
@_exported import FirebaseMessaging
@_exported import FirebasePerformanceFeature 모듈에서는 import ThirdParty_Firebase 하나로 Firebase의 모든 기능에 접근할 수 있습니다. FirebaseInAppMessaging-Beta라는 이상한 product 이름을 기억할 필요가 없습니다.
패턴 2: API 파사드
일부 라이브러리는 re-export 대신 자체 API를 제공하여 사용 범위를 제한합니다. Kingfisher 래퍼가 대표적입니다:
// ThirdParty_Kingfisher/Sources/Interface.swift
import Kingfisher // @_exported 아님 — Kingfisher API를 직접 노출하지 않음
/// SwiftUI 이미지 뷰
public struct URLImage: View {
private let imageView: KFImage
public init(_ url: URL?) {
self.imageView = KFImage(url)
}
public var body: some View {
imageView
.retry(retryStrategy)
.resizable()
}
}
/// UIKit 이미지뷰 extension
extension UIImageView {
public func setImage(from url: URL?) {
self.kf.setImage(with: url, options: [.retryStrategy(kfRetryStrategy)])
}
}
/// 이미지 캐시 관리
public final class ImageCacheManager {
public static let shared = ImageCacheManager()
// ...
}이 패턴의 장점은 분명합니다:
- Feature 모듈에서는
URLImage(url)과imageView.setImage(from: url)만 사용 가능.KFImage,ImageDownloader등 Kingfisher 내부 API에 직접 접근 불가 - 재시도 전략(
DelayRetryStrategy)이 래퍼 내부에서 통일 관리됨 - Kingfisher를 다른 라이브러리로 교체할 때, 이 파일만 수정하면 됨
래퍼 모듈의 Project.swift
래퍼 모듈의 프로젝트 파일은 makeThirdPartyProject 헬퍼로 3줄이면 충분합니다:
// ThirdParty_TCA/Project.swift
@_spi(ThirdPartyLibrary) import DependencyPlugin
import ProjectDescription
import ProjectDescriptionHelpers
let project = Project.makeThirdPartyProject(
module: ThirdPartyLibrary.ThirdParty_TCA,
dependencies: [DEP.SPMTarget.ComposableArchitecture]
)여러 SPM product를 묶는 경우:
// ThirdParty_Kakao/Project.swift
let project = Project.makeThirdPartyProject(
module: ThirdPartyLibrary.ThirdParty_Kakao,
dependencies: [
DEP.SPMTarget.KakaoSDKCommon,
DEP.SPMTarget.KakaoSDKAuth,
DEP.SPMTarget.KakaoSDKUser,
DEP.SPMTarget.KakaoSDKTalk,
DEP.SPMTarget.KakaoSDKShare,
DEP.SPMTarget.KakaoSDKNavi,
DEP.SPMTarget.KakaoSDKTemplate,
DEP.SPMTarget.KakaoAdSDK,
]
)DEP.SPMTarget은 이전 편에서 다룬 것처럼, SPM product 이름을 타입으로 관리하는 상수입니다.
27개 래퍼 모듈 전체
| 범주 | 래퍼 모듈 | 묶인 SPM product 수 |
|---|---|---|
| 아키텍처 | ThirdParty_TCA | 1 |
| 네트워크 | ThirdParty_Alamofire | 1 |
| 이미지 | ThirdParty_Kingfisher | 1 (파사드 패턴) |
| 데이터베이스 | ThirdParty_Realm | 2 |
| Firebase | ThirdParty_Firebase | 6 |
| 인증 | ThirdParty_Kakao, ThirdParty_GoogleSignIn, ThirdParty_Facebook | 8, 1, 1 |
| 지도 | ThirdParty_GoogleMaps, ThirdParty_Naver | 1, 1 |
| Reactive | ThirdParty_ReactiveX, ThirdParty_CombineCocoa | 4, 1 |
| UI | ThirdParty_Lottie, ThirdParty_SnapKit, ThirdParty_Toast 등 9개 | 각 1 |
| 분석/CRM | ThirdParty_Amplitude, ThirdParty_Mixpanel, ThirdParty_Airbridge, ThirdParty_BluxClient | 각 1 |
| DI | ThirdParty_Swinject | 1 |
빌드 시스템
래퍼 모듈은 의존성 관리뿐 아니라 빌드 성능에도 영향을 줍니다. 외부 라이브러리는 자주 변경되지 않으므로, 한 번 빌드한 결과를 캐싱하면 이후 빌드에서 재사용할 수 있습니다.
SPM 의존성 관리: Package.swift
모든 외부 패키지는 프로젝트 루트의 Package.swift에서 중앙 관리됩니다:
// Package.swift
let package: Package = .init(
name: "Kimcaddie",
platforms: [.iOS(.v17)],
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", exact: "1.16.1"),
.package(url: "https://github.com/Alamofire/Alamofire.git", exact: "5.9.0"),
.package(url: "https://github.com/onevcat/Kingfisher.git", exact: "8.5.0"),
.package(url: "https://github.com/firebase/firebase-ios-sdk.git", exact: "12.8.0"),
// ...30개 이상
]
)모든 버전을 exact:로 고정하여 재현 가능한 빌드를 보장합니다.
Tuist 전용 설정으로, 각 패키지의 product 타입을 명시적으로 지정합니다:
#if TUIST
let packageSettings: PackageSettings = .init(
productTypes: [
"Alamofire": .framework,
"Kingfisher": .framework,
"RxSwift": .framework,
// ...
// ComposableArchitecture → 기본값(.staticLibrary) 사용
// Firebase, Realm → XCFramework로 제공
]
)
#endif.framework로 지정된 패키지는 Tuist의 캐싱 대상이 됩니다. 빌드된 framework 바이너리를 캐시에 저장하고, 이후 빌드에서는 소스 컴파일 없이 캐시된 바이너리를 사용합니다.
외부 의존성 캐싱
make cache target=Kimcaddie
# → tuist cache Kimcaddie --external-only-external-only플래그가 핵심입니다. 자주 변하는 자체 모듈은 캐싱하지 않고, 변하지 않는 외부 라이브러리만 캐싱합니다. 한 번 캐싱하면 외부 라이브러리 버전을 올리기 전까지 소스 컴파일이 발생하지 않습니다.
선택적 Workspace 생성
1편에서 다뤘듯, 환경 변수로 필요한 앱의 모듈만 로드합니다:
make kimcaddie # Kimcaddie 46 + Common 15 + ThirdParty 27 = 88개 모듈
make birdiept # BirdiePT 12 + Common 15 + ThirdParty 27 = 54개 모듈BirdiePT를 개발할 때 Kimcaddie의 46개 모듈을 로드할 필요가 없으므로, 프로젝트 생성과 Xcode 인덱싱이 빨라집니다.
Makefile 자동화
반복적인 Tuist 명령을 Makefile로 감쌌습니다:
TUIST :=$(shell mise where tuist)/bin/tuist
# 외부 의존성 설치
install:
$(TUIST) install
# 프로젝트 생성
generate:
TUIST_ROOT_DIR=${PWD} TUIST_BUILD_CONFIG=${config} TUIST_WORKSPACE_NAME=${target}$(TUIST) generate${target} --no-open
# 외부 의존성 캐싱
cache:
TUIST_ROOT_DIR=${PWD}$(TUIST) cache${target} --external-only
# 새 모듈 스캐폴딩
module:
$(TUIST) scaffold Framework --layer ${layer} --name ${name}
# 앱별 단축 명령어
kimcaddie:
make generate config=${config} target="Kimcaddie"TUIST 변수가 mise where tuist로 결정되는 점에 주목하세요. .mise.toml에 Tuist 4.140.0이 고정되어 있으므로, 팀 전체가 동일한 Tuist 버전을 사용하게 됩니다.
의존성 그래프 시각화
모듈이 100개를 넘으면 의존성 관계를 머릿속으로 그리기 어렵습니다. Tuist의 graph 명령과 Graphviz를 조합해 의존성 그래프를 PNG로 생성합니다:
make graph name=kimcaddie내부적으로는 이런 과정을 거칩니다:
# 1. Tuist가 .dot 파일 생성
TUIST_WORKSPACE_NAME=Kimcaddie tuist graph Kimcaddie -d -t -f dot
# 2. 노이즈 제거 — Example 타겟과 ThirdParty 모듈 제외
sed -i '' '/Example/d; /ThirdParty_/d' graph.dot
# 3. Graphviz로 PNG 렌더링
dot -Tpng graph.dot -o DependencyGraph/kimcaddie_graph.pngExample 타겟과 ThirdParty 모듈을 제거하는 이유는, 이들이 포함되면 그래프가 너무 복잡해져서 핵심 모듈 간의 관계가 보이지 않기 때문입니다. 생성된 그래프는 레포에 커밋해두어 항상 최신 상태를 확인할 수 있게 합니다.
활용 팁
래퍼 모듈을 만들지 말아야 할 때
모든 외부 라이브러리에 래퍼가 필요한 건 아닙니다. 래퍼의 가치는 교체 가능성과 API 제한에서 옵니다. 프로젝트의 핵심 아키텍처를 구성하는 라이브러리(예: TCA)는 래퍼를 만들어도 교체할 일이 거의 없으므로, 단순 re-export가 적절합니다. 반면, 이미지 로딩이나 분석 SDK처럼 교체 가능성이 있는 라이브러리는 파사드 패턴이 효과적입니다.
관련 product 묶기
Kakao SDK처럼 하나의 벤더에서 여러 product를 제공하는 경우, 개별 래퍼를 만들지 말고 하나로 묶는 것이 관리 비용을 줄입니다. ThirdParty_Kakao 하나가 8개의 Kakao SDK product를 포함합니다. 다만, 불필요한 product까지 묶으면 빌드 시간이 늘어나므로, 실제로 사용하는 product만 포함하는 게 좋습니다.
캐시 무효화 시점
외부 라이브러리 버전을 올리면 캐시가 무효화됩니다. make install로 패키지를 다시 resolve하고, make cache로 재캐싱한 뒤, make generate로 프로젝트를 재생성하면 됩니다. 이 순서를 지키지 않으면 Xcode에서 모듈을 찾지 못하는 에러가 발생할 수 있습니다.
다음 편에서는 이 모노레포의 CI/CD 전략을 다룹니다. 3개 앱을 PR 라벨로 선택적으로 빌드하는 방법, 앱스토어 심사 상태를 자동으로 모니터링하고 슬랙으로 알림받는 워크플로우, 그리고 코드 포맷을 자동으로 맞추는 이야기입니다.