iOS 모노레포 생존기 (2) — 타입세이프 모듈 의존성
강민석
2026년 3월 4일5분 분량
문제
이전 편에서 Tuist와 TMA로 106개 모듈의 계층 구조를 잡은 이야기를 했습니다. 구조는 잡았지만, 모듈 간 의존성을 선언하는 방식에서 또 다른 문제가 생겼습니다.
Tuist에서 다른 모듈에 의존하려면 기본적으로 이렇게 작성합니다:
// Tuist 기본 방식
.project(
target: "BookingService",
path: .relativeToRoot("Projects/Kimcaddie/Domain/BookingService")
)타겟 이름과 파일 경로를 문자열로 직접 입력하는 방식입니다. 모듈이 10개일 때는 괜찮았지만, 100개를 넘어가면서 문제가 드러나기 시작했습니다.
오타가 늦게 발견된다
// ❌ "Service"를 "Serivce"로 오타
.project(target: "BookingSerivce", path: .relativeToRoot("Projects/Kimcaddie/Domain/BookingSerivce"))이 오타는 코드를 작성하는 시점에는 발견되지 않습니다. tuist generate를 실행해야 “해당 경로에 프로젝트가 없습니다”라는 에러를 볼 수 있고, 에러 메시지만으로는 오타인지 경로가 틀린 건지 바로 구분이 안 됩니다.
경로를 외워야 한다
BookingService의 경로가 Projects/Kimcaddie/Domain/BookingService라는 것을 기억해야 합니다. Domain인지 Core인지, Kimcaddie 아래인지 Common 아래인지 — 모듈이 많아질수록 이 경로를 매번 확인해야 하는 비용이 커집니다.
Interface 타겟 네이밍이 불일치한다
TMA 패턴에서는 Sources 타겟과 Interface 타겟이 분리됩니다. BookingService의 Interface는 BookingServiceInterface로 이름을 붙이는데, 이 접미사 규칙을 개발자가 직접 지켜야 합니다:
// Sources 타겟 참조
.project(target: "BookingService", path: ...)
// Interface 타겟 참조 — 접미사 "Interface"를 수동으로 붙여야 함
.project(target: "BookingServiceInterface", path: ...)해결
이 문제들의 공통점은, 사람이 문자열을 수동으로 관리한다는 것입니다. 해결 방법은 명확합니다 — 문자열을 타입으로 바꾸면 컴파일러가 검증해줍니다.
Modulable 프로토콜
모든 모듈을 enum case로 등록하고, 경로를 자동으로 계산하는 Modulable 프로토콜을 만들었습니다:
public protocol Modulable: CaseIterable {
var path: String { get }
}CaseIterable을 요구하는 이유는, Workspace 생성 시 allCases로 모든 모듈을 자동 열거하기 위해서입니다 (이전 편에서 다룬 선택적 Workspace 생성).
모듈 등록
각 앱의 모듈을 enum으로 정의합니다:
@_spi(Kimcaddie)
public enum Kimcaddie {
public enum Features: Modulable {
case AIAgentFeature
case GolfGroupFeature
case ProfileFeature
// ...18개
}
public enum Domain: Modulable {
case BookingService
case ShopService
case AuthService
// ...15개
}
}path는 enum의 case 이름과 계층 이름으로 자동 계산됩니다:
extension Kimcaddie.Domain {
public var path: String {
"Kimcaddie/Domain/\(String(describing: self))"
// .BookingService → "Kimcaddie/Domain/BookingService"
}
}Common, ThirdParty도 같은 패턴입니다:
@_spi(Common)
public enum Common: Modulable {
case KeychainClient
case PhotosClient
case UserNotificationClient
// ...15개
}
extension Common {
public var path: String {
"Common/\(String(describing: self))"
// .KeychainClient → "Common/KeychainClient"
}
}타입세이프 의존성 선언
enum case를 받아 Tuist의 TargetDependency를 생성하는 헬퍼를 만들었습니다:
extension TargetDependency {
public static func dependency<T: Modulable>(
module: T,
target: TargetType = .sources
) -> TargetDependency {
let moduleName = String(describing: module)
return .project(
target: "\(moduleName)\(target.postfixName)",
path: .relativeToRoot("Projects/\(module.path)")
)
}
public enum TargetType: Hashable {
case sources // postfix: ""
case interface // postfix: "Interface"
}
}이 한 함수가 앞서 말한 세 가지 문제를 모두 해결합니다.
적용
Before vs After
문자열 기반 (Before):
let project = Project(
name: "AIAgentFeature",
targets: [
.target(
name: "AIAgentFeature",
dependencies: [
.project(target: "FeatureInterface",
path: .relativeToRoot("Projects/Kimcaddie/Features/Feature")),
.project(target: "DIContainer",
path: .relativeToRoot("Projects/Kimcaddie/Features/DIContainer")),
.project(target: "Domain",
path: .relativeToRoot("Projects/Kimcaddie/Domain/Domain")),
.project(target: "ThirdParty_TCA",
path: .relativeToRoot("Projects/ThirdPartyLibrary/ThirdParty_TCA")),
]
)
]
)타입 기반 (After):
let project = Project.makeTMABasedProject(
module: Kimcaddie.Features.AIAgentFeature,
targets: [.sources, .interface],
dependencies: [
.sources: [
.dependency(module: Kimcaddie.Features.Feature, target: .interface),
],
.interface: [
.dependency(module: Kimcaddie.Features.DIContainer),
.dependency(module: Kimcaddie.Domain.Domain),
.dependency(module: ThirdPartyLibrary.ThirdParty_TCA),
],
]
)차이점을 정리하면:
| 문자열 기반 | 타입 기반 | |
|---|---|---|
| 모듈 이름 | "BookingService" (문자열) | Kimcaddie.Domain.BookingService (enum) |
| 경로 | 수동 입력 | path 프로퍼티에서 자동 계산 |
| Interface 접미사 | "BookingServiceInterface" 수동 | .interface enum case |
| 오타 감지 | tuist generate 시점 | Xcode 자동완성 시점 |
| 프로젝트 이름 | "AIAgentFeature" 수동 | module: 파라미터에서 추출 |
의존성 해석 과정
.dependency(module: Kimcaddie.Domain.BookingService, target: .interface)가 내부에서 어떻게 변환되는지 따라가보면:
enum case 하나에서 타겟 이름과 경로가 모두 결정됩니다. 개발자가 기억해야 할 것은 Kimcaddie.Domain.BookingService라는 Swift 표현식 하나뿐입니다.
SPM 의존성도 타입으로
외부 SPM 패키지 의존성도 같은 원칙을 적용했습니다:
extension DEP {
public enum SPMTarget {}
}
public extension DEP.SPMTarget {
static let ComposableArchitecture: TargetDependency = .external(name: "ComposableArchitecture")
static let Alamofire: TargetDependency = .external(name: "Alamofire")
static let Kingfisher: TargetDependency = .external(name: "Kingfisher")
static let FirebaseMessaging: TargetDependency = .external(name: "FirebaseMessaging")
static let FirebaseCrashlytics: TargetDependency = .external(name: "FirebaseCrashlytics")
// ...30개 이상
}ThirdParty 래퍼 모듈의 Project.swift에서 이렇게 사용합니다:
let project = Project.makeThirdPartyProject(
module: ThirdPartyLibrary.ThirdParty_Firebase,
dependencies: [
DEP.SPMTarget.FirebaseMessaging,
DEP.SPMTarget.FirebaseCrashlytics,
DEP.SPMTarget.FirebaseAnalytics,
DEP.SPMTarget.FirebaseInAppMessaging,
DEP.SPMTarget.FirebasePerformance,
DEP.SPMTarget.FirebaseAILogic,
]
)DEP.SPMTarget. 까지 입력하면 Xcode가 사용 가능한 SPM 타겟을 모두 자동완성해줍니다. SPM 패키지의 정확한 product 이름을 기억할 필요가 없어집니다.
새 모듈 추가 과정
새 모듈 ReviewFeature를 추가한다고 해봅시다.
1단계: enum에 case 추가
public enum Features: Modulable {
case AIAgentFeature
case GolfGroupFeature
case ReviewFeature // ← 추가
// ...
}이 한 줄이 추가되면:
- path가 자동으로 "Kimcaddie/Features/ReviewFeature"로 계산됨
- Workspace.swift의 Features.allCases에 자동 포함됨
- 다른 모듈에서 .dependency(module: Kimcaddie.Features.ReviewFeature)로 참조 가능
2단계: 스캐폴딩
make module layer="Kimcaddie/Features" name="ReviewFeature"3단계: 다른 모듈에서 참조
// 어떤 Feature의 Project.swift
.dependency(module: Kimcaddie.Features.ReviewFeature)경로를 외우거나, 다른 모듈의 Project.swift를 복사-붙여넣기 할 필요가 없습니다.
효과
오타가 코드 작성 시점에 잡힌다
.dependency(module: Kimcaddie.Domain.BookingSevice)
// ~~~~~~~~~~
// 컴파일 에러: Type 'Kimcaddie.Domain' has no member 'BookingSevice'tuist generate를 실행하기도 전에, Xcode가 빨간 줄을 그어줍니다. 자동완성을 사용하면 오타 자체가 발생하지 않습니다.
경로를 기억할 필요가 없다
BookingService가 Domain에 있는지 Core에 있는지 기억나지 않아도 됩니다. Kimcaddie.을 입력하면 Features, Domain, Core, Shared 네 가지 계층이 나오고, 거기서 Domain.을 선택하면 해당 계층의 모든 모듈이 자동완성됩니다.
Kimcaddie.
├── Features.
│ ├── AIAgentFeature
│ ├── GolfGroupFeature
│ └── ...
├── Domain.
│ ├── BookingService ← 여기
│ ├── ShopService
│ └── ...
├── Core.
└── Shared.Interface 참조가 일관된다
// Sources 타겟 참조
.dependency(module: Kimcaddie.Features.Feature)
// Interface 타겟 참조 — target 파라미터만 변경
.dependency(module: Kimcaddie.Features.Feature, target: .interface).interface라는 enum case가 "Interface" 접미사를 관리하므로, 이름 규칙을 사람이 기억할 필요가 없습니다.
코드 리뷰가 편해진다
PR에서 Project.swift 변경을 리뷰할 때, 문자열 경로를 일일이 검증하지 않아도 됩니다. Kimcaddie.Domain.BookingService라는 표현 자체가 “Kimcaddie 앱의 Domain 계층에 있는 BookingService 모듈”이라는 의미를 전달합니다.
다음 편에서는 서드파티 라이브러리를 래퍼 모듈로 격리하는 패턴과, 선택적 Workspace 생성·캐싱을 포함한 빌드 시스템 이야기를 다루겠습니다.