배경
앱의 규모가 커지면 커질수록 단일 프로젝트로 앱을 개발하기 점점 어려워집니다. 먼저 빌드 속도가 느려지고, 객체간 물리적 의존을 끊을 수 없기 때문에 설계가 오염되는 경우가 많습니다.
물리적 의존이란?
모듈이 분리되지 않으면, 객체간 접근이 물리적으로 제한되는 것이 아니기에, 객체를 오용하기 쉽습니다. (개인의 의지에 따라 코드를 작성할 수 있다)ex) 추상화된 객체가 구체적인 구현체를 알게되는 상황
당근마켓에서는 위 문제를 해결하기 위해 앱 프로젝트를 수많은 모듈로 분리했고, 이를 쉽게 관리하기 위해 Tuist 라는 도구를 활용하고 있습니다.
기준 없이 모듈을 분리만 한다면, 프로젝트의 구조는 더 복잡해지고 얻을 수 이점이 줄어듭니다.
최근, 모듈 분리를 처음 시도하는 분들을 위해 당근마켓의 앱을 관통하는 Modular Architecture 에 대해 소개했습니다.
MVVM, MVI 등 흔히 이야기되는 Presentation Layer 아키텍처와 다른 개념입니다.
발표 내용을 짧게 요약해보면..
- 역할에 따라 모듈 계층을 나눠 설계합니다.
- 계층에는 Feature, Domain, Core, Shared 가 존재합니다.
- 새로운 모듈을 추가할 때 모듈의 역할에 따라 적절한 계층을 지정하고, 인터페이스와 구현체를 분리할지 판단합니다.
위 아키텍처를 토대로 당근마켓 프로젝트는 수십개의 모듈에서 수백개의 모듈로 분리되었고, 점차 기존의 방식으로 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 으로 메일 남겨주세요. 가벼운 티타임도 가능해요!