iOS 모노레포 생존기 (1) — 모듈 구조 설계

강민석

강민석

2026년 3월 3일7분 분량

iOS 모노레포 생존기 (1) — 모듈 구조 설계

배경


김캐디는 세 개의 앱을 운영하고 있습니다.

  • Kimcaddie — 스크린골프 예약 앱 (골퍼용)
  • KimcaddieOwner — 매장 관리 앱 (사장님용)
  • BirdiePT — 골프 스윙 분석 앱

초기에는 각 앱이 별도의 Xcode 프로젝트였습니다. 문제는 앱 사이에 공유해야 할 코드가 생기면서 시작되었습니다. 네트워크 클라이언트, 키체인 래퍼, 디자인 토큰 같은 것들을 앱마다 복사-붙여넣기하고 있었고, 한쪽에서 버그를 고치면 다른 쪽에서 같은 버그가 살아있는 상황이 반복되었습니다.

“공유 코드를 한 곳에서 관리하자”는 자연스러운 결론에 이르렀고, 세 개의 앱을 하나의 레포지토리(모노레포)로 합치기로 했습니다. 하지만 단순히 코드를 한 폴더에 모으는 것만으로는 부족합니다. 79개의 모듈이 서로 얽히지 않도록 구조가 필요했습니다.

이 글에서는 김캐디가 Tuist와 TMA(The Module Architecture)를 기반으로 모노레포의 모듈 구조를 설계한 과정을 공유합니다.

선택지


모노레포의 모듈 관리 도구로 고려한 방법은 세 가지였습니다.

1. SPM 멀티패키지

Swift Package Manager만으로 각 모듈을 Package.swift로 관리하는 방식입니다. 별도 도구 설치가 필요 없다는 장점이 있지만, 모듈이 수십 개를 넘어가면 Xcode의 패키지 resolve 시간이 급격히 늘어나고, Xcode 프로젝트 설정(Build Settings, Signing 등)을 코드로 관리하기 어렵습니다.

2. 단일 프로젝트 + 폴더 분리

하나의 .xcodeproj에서 폴더와 타겟을 나누는 방식입니다. 설정은 간단하지만, 모듈 간 의존성 방향을 강제할 수 없어서 “Features 모듈이 다른 Features 모듈을 직접 import하면 안 된다” 같은 규칙을 컨벤션으로만 지켜야 합니다. 프로젝트 파일(.pbxproj)의 충돌 문제도 피할 수 없습니다.

3. Tuist + TMA

Tuist로 프로젝트를 코드로 생성하고, TMA(The Module Architecture) 패턴으로 모듈을 계층화하는 방식입니다. 모듈마다 독립된 Project.swift를 가지므로 .pbxproj 충돌이 사라지고, 의존성 방향을 코드 레벨에서 강제할 수 있습니다. 대신 Tuist라는 추가 도구에 대한 학습 비용이 있고, 프로젝트 생성(generate) 단계가 추가됩니다.

왜 Tuist + TMA를 선택했나

결정적인 이유는 의존성 방향의 강제였습니다. 79개 모듈이 자유롭게 서로를 참조하면 금방 스파게티가 됩니다. Tuist는 각 모듈의 Project.swift에서 의존성을 명시적으로 선언하게 하고, 선언하지 않은 모듈은 import 자체가 불가능합니다. 컨벤션이 아닌 컴파일러가 규칙을 지켜주는 셈입니다.

.pbxproj 충돌이 사라지는 것도 팀 생산성에 큰 영향을 미쳤습니다. 여러 사람이 동시에 Feature를 개발할 때, 프로젝트 파일 충돌로 머지가 막히는 일이 더 이상 없습니다.

구현


모듈 계층 구조

김캐디의 모듈은 4개의 계층으로 나뉩니다.

계층역할의존 방향
App앱 진입점, 조립(Composition Root)아래 모든 계층 참조 가능
FeaturesUI + TCA Reducer (화면 단위)Domain, Shared, Common
Domain비즈니스 로직, API 서비스Core, Shared
Core네트워크, DB, 공통 엔티티Shared
Shared디자인 시스템, 유틸리티없음 (최하위)

Common은 계층이 아닌 앱 횡단 모듈입니다. KeychainClient, PhotosClient 같은 시스템 API 래퍼들로, 세 앱 모두에서 공유합니다.

핵심 규칙은 단순합니다: 의존성은 위에서 아래로만 흐른다. Feature가 다른 Feature를 직접 참조할 수 없고, Core가 Feature를 알 수 없습니다. 이 규칙 덕분에 하위 모듈을 수정해도 영향 범위가 예측 가능합니다.

앱별 모듈 규모

FeaturesDomainCoreShared합계
Kimcaddie18158546
KimcaddieOwner21126
BirdiePT5-4312
Common----15
ThirdParty----27

세 앱과 Common, ThirdParty를 합치면 총 106개 모듈입니다. 이 규모에서 수작업으로 의존성을 관리하는 건 사실상 불가능합니다.

TMA: 모듈 내부의 타겟 구조

각 모듈은 TMA(The Module Architecture) 패턴에 따라 최대 5개의 타겟으로 나뉩니다.

타겟Product역할
SourcesstaticLibrary / framework실제 구현 코드
InterfacestaticLibrary프로토콜, DTO 등 공개 계약
TestsunitTests유닛 테스트
TestingstaticFrameworkMock, Stub 등 테스트 헬퍼
Exampleapp모듈 단독 실행용 미니 앱

모든 모듈이 5개 타겟을 다 가질 필요는 없습니다. 실제로 대부분의 모듈은 Sources만, 또는 Sources + Interface 조합을 사용합니다.

이 구조의 핵심은 Interface 분리입니다. 다른 모듈이 참조할 때 Sources(구현체)가 아닌 Interface(계약)에만 의존하면, 구현을 변경해도 의존하는 모듈을 다시 빌드할 필요가 없습니다.

타입세이프 모듈 선언

Tuist에서 모듈 간 의존성은 기본적으로 문자열로 선언합니다:

swift
// ❌ 기본 Tuist 방식 — 오타가 런타임이 아닌 generate 시점까지 가야 발견됨
.project(target: "BookingService", path: .relativeToRoot("Projects/Kimcaddie/Domain/BookingService"))

경로와 타겟 이름을 수동으로 입력하니, BookingServiceBookingSerivce로 오타 내면 tuist generate 시점에야 에러가 나옵니다. 모듈이 100개를 넘으면 이런 실수가 빈번해집니다.

김캐디는 enum + Modulable 프로토콜로 이 문제를 해결했습니다.

swift
// Modulable 프로토콜
public protocol Modulable: CaseIterable {
  var path: String { get }
}

// 타입세이프 의존성 선언
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)")
    )
  }
}

모든 모듈을 enum case로 등록합니다:

swift
@_spi(Kimcaddie)
public enum Kimcaddie {
  public enum Features: Modulable {
    case AIAgentFeature
    case GolfGroupFeature
    case ProfileFeature
    // ...
  }

  public enum Domain: Modulable {
    case BookingService
    case ShopService
    // ...
  }
}

이제 의존성 선언이 이렇게 바뀝니다:

swift
// ✅ 타입세이프 — 오타 시 컴파일 에러
.dependency(module: Kimcaddie.Domain.BookingService)
.dependency(module: Kimcaddie.Features.Feature, target: .interface)
.dependency(module: Common.KeychainClient)

Kimcaddie.Domain.BookingSevice로 오타를 내면? Xcode 자동완성 시점에서 바로 잡힙니다. tuist generate까지 갈 필요도 없습니다.

@_spi로 앱 간 모듈 격리

세 앱의 모듈이 하나의 플러그인에 정의되어 있으므로, 아무런 제한 없이 BirdiePT의 Project.swift에서 Kimcaddie.Features.AIAgentFeature를 참조할 수 있게 됩니다. 이를 방지하기 위해 Swift의 @_spi (System Programming Interface) 어노테이션을 활용합니다.

swift
// 각 앱의 모듈 정의에 @_spi 적용
@_spi(Kimcaddie) public enum Kimcaddie { ... }
@_spi(KimcaddieOwner) public enum KimcaddieOwner { ... }
@_spi(BirdiePT) public enum BirdiePT { ... }
@_spi(Common) public enum Common: Modulable { ... }

모듈의 Project.swift에서 필요한 SPI 그룹만 import합니다:

swift
// Kimcaddie의 Feature → Kimcaddie + Common + ThirdParty만 접근 가능
@_spi(Kimcaddie) @_spi(Common) @_spi(ThirdPartyLibrary) import DependencyPlugin

// BirdiePT의 Feature → BirdiePT + Common + ThirdParty만 접근 가능
@_spi(BirdiePT) @_spi(Common) @_spi(ThirdPartyLibrary) import DependencyPlugin

BirdiePT의 Project.swift에서 @_spi(Kimcaddie)를 import하지 않으면, Kimcaddie.Features에 접근하는 순간 컴파일 에러가 납니다. 앱 간의 모듈 경계가 컨벤션이 아닌 컴파일러에 의해 강제됩니다.

선택적 Workspace 생성

106개 모듈을 매번 전부 로드하면 Xcode가 느려집니다. Workspace.swift에서 환경 변수에 따라 필요한 앱의 모듈만 생성합니다:

swift
let workspaceName = Environment.workspaceName.getString(default: "Kimcaddie")

let workspace = Workspace(
  name: workspaceName,
  projects: {
    var projects: [Path] = []

    switch WorkspaceType(rawValue: workspaceName) {
    case .Kimcaddie:
      projects.append(Path("Projects/Kimcaddie/App"))
      projects += Kimcaddie.Features.allCases.map { /* path 자동 생성 */ }
      projects += Kimcaddie.Domain.allCases.map { ... }
      projects += Kimcaddie.Core.allCases.map { ... }
      projects += Kimcaddie.Shared.allCases.map { ... }

    case .KimcaddieOwner:
      // KimcaddieOwner 모듈만
    case .BirdiePT:
      // BirdiePT 모듈만
    }

    // Common과 ThirdParty는 항상 포함
    projects += Common.allCases.map { ... }
    projects += ThirdPartyLibrary.allCases.map { ... }
    return projects
  }()
)

ModulableCaseIterable을 요구하기 때문에, allCases로 해당 계층의 모든 모듈을 자동으로 열거합니다. 새 모듈을 enum에 추가하면 Workspace에도 자동으로 포함됩니다. 별도로 Workspace.swift를 수정할 필요가 없습니다.

bash
make kimcaddie        # Kimcaddie + Common + ThirdParty만 로드 (46 + 15 + 27 = 88개)
make birdiept         # BirdiePT + Common + ThirdParty만 로드 (12 + 15 + 27 = 54개)

실제 모듈의 Project.swift

이 모든 것이 조합된 실제 Project.swift는 이렇게 생겼습니다:

swift
// Projects/Kimcaddie/Features/AIAgentFeature/Project.swift

@_spi(Common) @_spi(Kimcaddie) @_spi(ThirdPartyLibrary) import DependencyPlugin
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project.makeTMABasedProject(
  module: Kimcaddie.Features.AIAgentFeature,
  scripts: [],
  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),
    ],
  ]
)

한눈에 이 모듈이 어떤 타겟을 가지고, 무엇에 의존하는지 파악할 수 있습니다. 문자열은 하나도 없고, 모든 의존성이 타입으로 표현됩니다.

새 모듈 추가하기

새 모듈을 추가하는 과정은 두 단계입니다:

1단계: enum에 case 추가

swift
public enum Features: Modulable {
  case AIAgentFeature
  case GolfGroupFeature
  case NewFeature  // ← 이 한 줄 추가
  // ...
}

2단계: 스캐폴딩 실행

bash
make module layer="Kimcaddie/Features" name="NewFeature"

이것만으로 Project.swift, 소스 디렉토리, 기본 파일이 생성되고, Workspace.swiftallCases 덕분에 자동으로 새 모듈을 포함합니다.

회고


프로젝트 파일 충돌 제거. 가장 체감이 큰 변화입니다. .pbxproj 머지 충돌로 30분씩 잡아먹히던 일이 완전히 사라졌습니다. 각 모듈이 독립된 Project.swift를 가지니, 서로 다른 Feature를 동시에 개발해도 충돌할 파일이 없습니다.

모듈 추가의 저비용. enum case 하나 + 스캐폴드 명령어 하나로 모듈이 생깁니다. “모듈 만들기 귀찮으니 기존 모듈에 넣자”라는 유혹이 줄었고, 적절한 크기의 모듈 분리가 자연스러워졌습니다.

의존성 방향 강제. Feature → Domain → Core 방향을 코드가 강제하므로, 코드 리뷰에서 “이 의존성 방향이 맞나요?”라는 논의가 사라졌습니다.


다음 편에서는 Modulable 프로토콜과 타입세이프 의존성 선언을 더 깊이 다루면서, 문자열 기반 선언에서 겪었던 실제 실수들과 enum 패턴으로 해결한 이야기를 풀어보겠습니다.


Series · 김캐디 iOS

1 / 4
강민석

강민석

iOS Engineer

김캐디 iOS 앱개발을 담당하고있습니다