Tuist 를 활용해 확장 가능한 모듈 구조 만들기

Kanghoon
당근 테크 블로그
12 min readApr 20, 2023

배경

앱의 규모가 커지면 커질수록 단일 프로젝트로 앱을 개발하기 점점 어려워집니다. 먼저 빌드 속도가 느려지고, 객체간 물리적 의존을 끊을 수 없기 때문에 설계가 오염되는 경우가 많습니다.

물리적 의존이란?
모듈이 분리되지 않으면, 객체간 접근이 물리적으로 제한되는 것이 아니기에, 객체를 오용하기 쉽습니다. (개인의 의지에 따라 코드를 작성할 수 있다)

ex) 추상화된 객체가 구체적인 구현체를 알게되는 상황

당근마켓에서는 위 문제를 해결하기 위해 앱 프로젝트를 수많은 모듈로 분리했고, 이를 쉽게 관리하기 위해 Tuist 라는 도구를 활용하고 있습니다.

기준 없이 모듈을 분리만 한다면, 프로젝트의 구조는 더 복잡해지고 얻을 수 이점이 줄어듭니다.

최근, 모듈 분리를 처음 시도하는 분들을 위해 당근마켓의 앱을 관통하는 Modular Architecture 에 대해 소개했습니다.

MVVM, MVI 등 흔히 이야기되는 Presentation Layer 아키텍처와 다른 개념입니다.

발표 내용을 짧게 요약해보면..

  1. 역할에 따라 모듈 계층을 나눠 설계합니다.
  2. 계층에는 Feature, Domain, Core, Shared 가 존재합니다.
  3. 새로운 모듈을 추가할 때 모듈의 역할에 따라 적절한 계층을 지정하고, 인터페이스와 구현체를 분리할지 판단합니다.

위 아키텍처를 토대로 당근마켓 프로젝트는 수십개의 모듈에서 수백개의 모듈로 분리되었고, 점차 기존의 방식으로 Xcode 프로젝트를 관리하기 어려워졌습니다.

이번 글에서는 당근마켓에서 Tuist 를 활용해 어떻게 Xcode 프로젝트 관리 경험을 개선했는지 소개합니다.

모듈이 많아지며 생기는 문제

모듈의 수가 점차 늘어나면서 Tuist 에서 기본적으로 제공하는 인터페이스만으로는 프로젝트 관리에 어려움이 발생했습니다.

별도의 개선 없이 Tuist 를 사용하면 수백개의 프로젝트 의존성이 존재하더라도, 이를 String 타입에 의존해 모듈을 추가하고 의존성을 구성하게 됩니다.

이 때 엔지니어들은 프로젝트 구성에 어려움을 느낄 수 있고, 실수가 발생하기 쉽습니다.

구체적인 예시로 살펴보겠습니다.

// Project.swift

Target(
name: "FooFeature",
platform: .iOS,
product: .framework,
bundleId: "com.featrues.foo",
infoPlist: .default,
sources: "Sources",
dependencies: [
// 의존성을 String 기반으로 추가해야 함
.project(
target: "BarFeatureInterface",
path: .relativeToRoot("Projects/Feature/Bar")
),
.project(
target: "BazFeatureInterface",
path: .relativeToRoot("Projects/Feature/Baz")
),
.project(
target: "AnalyticsInterface",
path: .relativeToRoot("Projects/Core/Analytics")
),
]
)

위처럼 FooFeature 모듈에 BarFeatureInterface, BazFeatureInterface, AnalyticsInterface 세가지 의존성을 추가한다고 생각해봅시다.

이를 작성하기 위해 현재 프로젝트에 어떤 모듈이 어떤 디렉토리에 존재하는지와 해당 모듈의 타겟 구성을 모두 이해해야 합니다.

이 문제는 팀원이 많아질수록, 프로젝트가 커질수록 심화됩니다.

당근마켓에서는 이 문제를 해결하기 위해 프로젝트 명세를 컴파일러(자동완성)에 의존해 작성할 수 있도록 개선했습니다.

디렉토리 구조를 코드로 생성하기

> Projects
- App
- Sources
- Resources
- Tests

- Feature
- Foo
- Interface
- Sources
- Testing
- Tests

- Bar
- ...

- Core
- Analytics
- Interface
- Sources
- Testing
- Tests

- Auth
- ...

앞서 발표 자료에서 언급한 모듈 아키텍처에 따라, 앱의 디렉토리 구조가 위 예제처럼 구성되어있다고 가정해보겠습니다.

모듈 아키텍처를 따르기에 디렉토리 구조에 규칙성이 존재합니다. 디렉토리 구조를 통해 모듈 명세를 Swift 코드로 생성할 수 있는 스크립트를 작성합니다. 이 스크립트는 새로운 모듈이 생성될 때마다 호출되어야 합니다.

스크립트를 통해 생성된 Swift 코드는 DependencyPlugin 이라는 Tuist Plugin 으로 관리합니다. 예시 디렉토리 구조에 따라 생성된 모듈 명세 코드는 아래와 같습니다.

// DependencyPlugin
//
// GeneratedModules.swift

enum FeatureModule: String, CaseIterable {
case Foo
case Bar
}

enum CoreModule: String, CaseIterable {
case Analytics
case Auth
}

먼저, 이를 활용해 Workspace.swift 에 프로젝트와 스키마를 자동으로 구성하도록 작성합니다.

// Workspace.swift

let workspace = Workspace(
name: "App",
projects: {
var projects: [Path] = [
Path("Projects/App"),
]

projects += FeatureModule.allCases.map {
Path("Projects/Feature/\($0.rawValue)")
}

projects += CoreModule.allCases.map {
Path("Projects/Core/\($0.rawValue)")
}

return projects
}()
)

이제 모듈이 추가될 때마다 작성한 스크립트를 실행하면 Workspace.swift 파일을 수정하지 않아도 프로젝트를 생성할 수 있습니다.

프로젝트 구성에 생성된 코드 활용하기

이제 생성된 모듈 명세 코드를 활용해 Project.swift 파일을 쉽게 작성할 수 있도록 개선합니다. 프로젝트 구성시 가장 많이 변경하는 dependencies 속성 작성 경험을 개선했습니다.

생성된 모듈 명세 Swift 코드를 이용해 TargetDependency 를 컴파일러에 의존해 생성할 수 있도록 작성합니다.

Feature, Core 계층 모듈의 Interface, Implmentation, Testing 타겟을 생성할 수 있도록 메서드를 제공하고, 이 또한 Tuist DependencyPlugin 에 적용하였습니다.

// DependencyPlugin
//
// TargetDependency+Modules.swift

// MARK: - Feature

extension TargetDependency {

private static func feature(target: String, moduleName: String) -> TargetDependency {
.project(target: target, path: .relativeToRoot("Projects/Feature/" + moduleName))
}

public static func feature(interface moduleName: FeatureModule) -> TargetDependency {
.feature(target: moduleName.rawValue + "FeatureInterface", moduleName: moduleName.rawValue)
}

public static func feature(implementation moduleName: FeatureModule) -> TargetDependency {
.feature(target: moduleName.rawValue + "Feature", moduleName: moduleName.rawValue)
}

public static func feature(testing moduleName: FeatureModule) -> TargetDependency {
.feature(target: moduleName.rawValue + "FeatureTesting", moduleName: moduleName.rawValue)
}
}


// MARK: - Core

extension TargetDependency {

private static func core(target: String, moduleName: String) -> TargetDependency {
.project(target: target, path: .relativeToRoot("Projects/Core/" + moduleName))
}

public static func core(interface moduleName: CoreModule) -> TargetDependency {
.core(target: moduleName.rawValue + "Interface", moduleName: moduleName.rawValue)
}

public static func core(implementation moduleName: CoreModule) -> TargetDependency {
.core(target: moduleName.rawValue, moduleName: moduleName.rawValue)
}

public static func core(testing moduleName: CoreModule) -> TargetDependency {
.core(target: moduleName.rawValue + "Testing", moduleName: moduleName.rawValue)
}
}

위 코드를 사용하면 이전에 작성했던 Project.swift 파일을 아래처럼 변경할 수 있습니다. 컴파일러 자동 완성에 의존해 쉽게 작성할 수 있을 뿐만 아니라, 작성되는 코드의 양이 줄어들어 가독성도 좋아집니다.

// Project.swift

Target(
name: "FooFeature",
platform: .iOS,
product: .framework,
bundleId: "com.featrues.foo",
infoPlist: .default,
sources: "Sources",
dependencies: [
.feature(interface: .Bar),
.feature(interface: .Baz),
.core(interface: .Analytics),
]
)

모듈 생성 스캐폴딩하기

이제 작성한 스크립트를 신규 모듈 생성시에 매번 호출해줘야 합니다. 신규 모듈 생성에는 tuist scaffold 를 활용하기 때문에 tuist scaffold 실행 이후에 모듈 명세 생성 스크립트가 실행될 수 있도록 합니다.

이는 여러 방법이 존재하지만, 이번 글에서는 Makefile 을 통해 간단히 제공하는 방법을 소개합니다.

module:
tuist scaffold $(type) --name $(name)
./generate-dependency-modules

이제 Shell 에서 아래처럼 실행하면 신규 모듈을 간편하게 생성할 수 있습니다.

$ make module type=core name=Analytics

Test Doubles 자동 생성하기

Testing 타겟은 해당 Interface 모듈의 TestDoubles 을 제공하는 타겟입니다.

수동으로 파일을 생성하기보다 mockolo 혹은 sourcery 를 활용해 코드를 자동 생성하면, 관리 비용이 줄어듭니다.

당근마켓의 경우 mockolo 를 활용하고 있습니다.

마무리

거대한 앱 프로젝트를 만들다보면, 프로젝트에 대해 적은 이해도를 가진 엔지니어도 쉽게 프로젝트를 생성하고 수정할 수 있는 환경이 중요해집니다. 이 때 Swift 코드의 자동 완성과 문서화 주석을 활용하면 큰 도움을 받을 수 있습니다.

Tuist 는 XcodeGen 과 다르게 Swift 를 사용한다는 큰 장점이 있습니다. 이를 활용한다면, 앞서 공유한 방법 뿐 아니라 여러시도를 해볼 수 있습니다.

이번글에서는 당근마켓에서 Tuist 를 활용을 위해 시도한 개선점 중 ‘간편한 프로젝트 작성 경험 만들기’ 에 대해 공유해보았습니다. 최근 Tuist 를 활용하는 팀들이 많이 늘어났는데, 커뮤니티에 다양한 Tuist 활용 사례가 공유되길 기대하며 글을 마칩니다.

당근마켓에서 함께할 iOS 엔지니어를 찾고 있어요. 당근마켓에 대해 궁금한 점이 있으시면 ray@daangn.com 으로 메일 남겨주세요. 가벼운 티타임도 가능해요!

--

--