개요
CocoaPods Private Spec Repo를 활용하여 iOS 프로젝트를 계층별 모듈로 분리하는 방법을 정리한다.
이 글에서 다루는 프로젝트는 2020년경에 설계한 것이다. 현재는 Swift Package Manager가 주류로 자리잡았고 Tuist 같은 프로젝트 생성 도구도 널리 사용되고 있어 당시와 트렌드가 다를 수 있다. 하지만 모듈 분리의 원칙과 계층 구조 설계는 도구가 달라져도 동일하게 적용할 수 있으므로 뒤늦게나마 정리해본다.
정리
1. 아키텍처 개요
프로젝트는 4개의 계층으로 구성된다.
┌───────────────────────────────────────────┐
│ App │
│ (synstagram-app) │
├───────────────────────────────────────────┤
│ Scene Layer │
│ (LoginScene, ...) │
├───────────────────────────────────────────┤
│ Module Layer │
│ (APIService, Dependencies) │
├───────────────────────────────────────────┤
│ Foundation Layer │
│ (Network, Extensions, DIContainer, UI) │
└───────────────────────────────────────────┘
| 계층 | 역할 | 예시 |
|---|---|---|
| Foundation | 앱에 종속되지 않는 범용 라이브러리 | BinaryLoaderNetwork, BinaryLoaderExtensions |
| Module | 앱 고유의 서비스 및 의존성 인터페이스 | APIService/Auth, Dependencies/Login |
| Scene | 화면 단위의 독립적인 기능 모듈 | LoginScene |
| App | 모듈 조립과 화면 전환을 담당하는 진입점 | Synstagram |
하위 계층은 상위 계층을 알지 못한다. Foundation은 Module을 모르고 Module은 Scene을 모른다. 의존성은 항상 위에서 아래로만 흐른다.
2. 저장소 구조
모듈화 프로젝트에는 두 종류의 저장소가 필요하다.
| 저장소 종류 | 역할 | 예시 |
|---|---|---|
| 소스 저장소 | 실제 코드가 있는 저장소 | binaryloader-network, synstagram-scene-login |
| 스펙 저장소 | podspec 파일을 버전별로 관리하는 저장소 | cocoapods-specs, synstagram-scene-cocoapods-specs |
스펙 저장소는 계층별로 분리했다.
| 스펙 저장소 | 대상 | 등록된 Pod |
|---|---|---|
cocoapods-specs |
Foundation 레이어 | BinaryLoaderNetwork, BinaryLoaderExtensions, BinaryLoaderDIContainer, BinaryLoaderUI |
synstagram-module-cocoapods-specs |
Module 레이어 | APIService, Dependencies |
synstagram-scene-cocoapods-specs |
Scene 레이어 | LoginScene |
스펙 저장소를 계층별로 분리하면 팀에서 접근 권한을 세분화할 수 있고 Foundation 레이어의 스펙 저장소는 다른 앱 프로젝트에서도 재사용할 수 있다.
3. Foundation 레이어
앱에 종속되지 않는 범용 라이브러리다. 다른 프로젝트에서도 그대로 가져다 쓸 수 있다.
3.1. BinaryLoaderNetwork
Moya를 래핑한 네트워크 추상화 레이어다.
# BinaryLoaderNetwork.podspec
Pod::Spec.new do |s|
s.name = 'BinaryLoaderNetwork'
s.version = '1.0.5'
s.ios.deployment_target = '13.0'
s.source_files = 'BinaryLoaderNetwork/Module/Source/*.swift'
s.dependency 'Moya', '15.0.0'
end
핵심은 NetworkTarget 프로토콜과 NetworkProvider 클래스다.
// NetworkTarget.swift
public protocol NetworkTarget: TargetType {
var baseURL: URL { get }
var path: String { get }
var method: Method { get }
var sampleData: Data { get }
var task: Task { get }
var headers: [String: String]? { get }
}
// NetworkProvider.swift
public final class NetworkProvider<Target: NetworkTarget> {
private let provider: MoyaProvider<Target>
@discardableResult
public func request(target: Target, completion: @escaping Completion) -> Cancellable {
let cancellable = provider.request(target, completion: completion)
return cancellable
}
public func request(target: Target) async -> Result<Response, MoyaError> {
let result = await provider.request(target)
return result
}
}
Moya의 타입을 직접 노출하지 않고 typealias로 래핑하여 외부에 제공한다.
// Moya+Wrapping.swift
public typealias Method = Moya.Method
public typealias Task = Moya.Task
public typealias Response = Moya.Response
public typealias MoyaError = Moya.MoyaError
이렇게 하면 Moya 버전이 변경되어 타입명이 바뀌더라도 래핑 파일만 수정하면 되므로 하위 모듈의 변경 범위를 최소화할 수 있다.
3.2. BinaryLoaderDIContainer
Property Wrapper 기반의 의존성 주입 컨테이너다. @Injectable로 의존성을 선언하고 Container.shared.register(type:)으로 등록한다.
# BinaryLoaderDIContainer.podspec
Pod::Spec.new do |s|
s.name = 'BinaryLoaderDIContainer'
s.version = '1.0.5'
s.source_files = 'BinaryLoaderDIContainer/Module/Source/*.swift'
end
3.3. BinaryLoaderUI
UI 컴포넌트를 subspec으로 분리하여 필요한 것만 선택적으로 사용할 수 있다.
# BinaryLoaderUI.podspec
Pod::Spec.new do |s|
s.name = 'BinaryLoaderUI'
s.version = '1.0.3'
s.default_subspec = :none
s.subspec 'InsetTextField' do |ss|
ss.source_files = 'BinaryLoaderUI/Module/InsetTextField/Source/*.swift'
end
end
default_subspec = :none으로 설정하면 pod 'BinaryLoaderUI'만으로는 아무것도 포함되지 않고 pod 'BinaryLoaderUI/InsetTextField'처럼 명시적으로 subspec을 지정해야 한다.
4. Module 레이어
앱 고유의 비즈니스 로직을 담당하는 계층이다. Foundation 레이어에 의존하며 Scene 레이어에서 사용된다.
4.1. APIService
네트워크 API 호출을 캡슐화한다. 기능별로 subspec을 분리한다.
# APIService.podspec
Pod::Spec.new do |s|
s.name = 'APIService'
s.version = '1.0.8'
s.dependency 'BinaryLoaderExtensions', '1.0.3'
s.default_subspec = :none
s.subspec 'Auth' do |ss|
ss.source_files = 'APIService/Module/Auth/Source/*.swift'
ss.dependency 'BinaryLoaderNetwork', '1.0.5'
end
end
Auth subspec은 AuthService 클래스를 통해 로그인/로그아웃 API를 제공한다. NetworkTarget을 구현한 AuthNetworkTarget으로 엔드포인트를 정의하고 NetworkProvider로 요청을 보낸다.
// AuthNetworkTarget.swift
struct AuthNetworkTarget {
enum Route {
case isAlreadyLogin
case login(request: AuthLoginModel.Request)
case logout
}
private let route: Route
}
extension AuthNetworkTarget: NetworkTarget {
var baseURL: URL {
let URLString = "https://photo.domain.com/photo/webapi"
return URLString.toAPIURL
}
var task: BinaryLoaderNetwork.Task {
var parameters: [String: Any] = [
"api": "SYNO.PhotoStation.Auth",
"version": "1"
]
switch route {
case .isAlreadyLogin:
parameters["method"] = "checkauth"
case .login(let request):
parameters["method"] = "login"
parameters["username"] = request.username
parameters["password"] = request.password
case .logout:
parameters["method"] = "logout"
}
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
}
}
4.2. Dependencies
Scene 간 의존성 인터페이스를 프로토콜로 정의한다. Scene 모듈은 이 프로토콜에만 의존하므로 구체적인 구현을 알 필요가 없다.
# Dependencies.podspec
Pod::Spec.new do |s|
s.name = 'Dependencies'
s.version = '1.0.2'
s.default_subspec = :none
s.subspec 'Login' do |ss|
ss.source_files = 'Dependencies/Module/Login/Source/*.swift'
end
s.subspec 'AlbumList' do |ss|
ss.source_files = 'Dependencies/Module/AlbumList/Source/*.swift'
end
end
// LoginDependency.swift
public protocol LoginDependency {
var viewController: UIViewController { get }
}
// AlbumListDependency.swift
public protocol AlbumListDependency {
func getViewController(username: String) -> UIViewController
}
이 패턴의 핵심은 Scene이 다른 Scene을 직접 import하지 않는 것이다. LoginScene이 AlbumListScene으로 화면 전환을 해야 할 때 AlbumListDependency 프로토콜을 통해 ViewController를 가져온다. 실제 구현체는 App 레이어에서 DI Container로 주입한다.
5. Scene 레이어
화면 단위의 독립적인 기능 모듈이다. Clean Swift(VIP) 아키텍처를 사용한다.
# LoginScene.podspec
Pod::Spec.new do |s|
s.name = 'LoginScene'
s.version = '1.0.12'
s.source_files = 'LoginScene/Module/Source/**/*.{swift,xib}'
s.resource = 'LoginScene/Module/Resources/*.xcassets'
s.dependency 'BinaryLoaderDIContainer', '1.0.5'
s.dependency 'BinaryLoaderExtensions', '1.0.3'
s.dependency 'BinaryLoaderUI/InsetTextField', '1.0.3'
s.dependency 'APIService/Auth', '1.0.8'
s.dependency 'Dependencies/Login', '1.0.2'
s.dependency 'Dependencies/AlbumList', '1.0.2'
end
디렉토리 구조는 Clean Swift 패턴을 따른다.
LoginScene/Module/
├── Source/
│ ├── Dependency/
│ │ └── LoginDependencyItem.swift
│ ├── Interactor/
│ │ ├── LoginInteractor.swift
│ │ └── LoginWorker.swift
│ ├── Model/
│ │ ├── LoginModel.swift
│ │ ├── LoginModelDTOMapper.swift
│ │ └── LoginModelErrorMapper.swift
│ ├── Presenter/
│ │ └── LoginPresenter.swift
│ ├── Router/
│ │ └── LoginRouter.swift
│ └── View/
│ ├── LoginViewController.swift
│ ├── LoginViewController.xib
│ ├── ContentView.swift
│ └── ...
└── Resources/
├── Color.xcassets
└── Image.xcassets
Scene 모듈은 Dependencies의 프로토콜을 구현하여 외부에 진입점을 제공한다.
// LoginDependencyItem.swift
public struct LoginDependencyItem: Dependency, LoginDependency {
public var viewController: UIViewController {
return LoginViewController(nibName: LoginViewController.className, bundle: LoginViewController.bundle)
}
}
Scene 간 화면 전환은 Router에서 Dependencies 프로토콜을 통해 수행한다.
// LoginRouter.swift
final class LoginRouter: NSObject, LoginRoutingLogic {
@Injectable
private var albumListDependency: AlbumListDependency?
func routeToAlbumList(username: String) {
let albumListVC = albumListDependency?.getViewController(username: username)
let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }
keyWindow?.rootViewController = albumListVC
}
}
@Injectable은 DI Container에서 등록된 구현체를 자동으로 주입한다.
6. App 통합
App 레이어는 모든 모듈을 조립하는 진입점 역할을 한다.
6.1. Podfile
source 'https://github.com/CocoaPods/Specs.git'
source 'https://github.com/binaryloader/cocoapods-specs.git'
source 'https://github.com/binaryloader/synstagram-module-cocoapods-specs.git'
source 'https://github.com/binaryloader/synstagram-scene-cocoapods-specs.git'
platform :ios, '13.0'
project 'Synstagram'
inhibit_all_warnings!
use_frameworks!
def scenes
pod 'LoginScene', '1.0.13'
end
target :'Synstagram' do
scenes
end
Scene Pod만 명시하면 CocoaPods가 의존성 그래프를 해석하여 하위 계층의 모든 Pod을 자동으로 설치한다.
6.2. DI Container 등록
App 레이어에서 각 Scene의 DependencyItem을 DI Container에 등록한다.
// DependencyContainer.swift
final class DependencyContainer {
static func registerAll() {
registerLoginSceneDependency()
}
}
extension DependencyContainer {
private static func registerLoginSceneDependency() {
Container.shared.register(type: LoginDependencyItem.self)
}
}
6.3. 앱 시작
// AppDelegate.swift
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
DependencyContainer.registerAll()
HierarchyCoordinator.configure(window: &window)
return true
}
}
// HierarchyCoordinator.swift
final class HierarchyCoordinator {
static func configure(window: inout UIWindow?) {
window = UIWindow(frame: UIScreen.main.bounds)
let launcher = LoginSceneLauncher()
launcher.launch(to: &window)
}
}
7. 버전 관리 워크플로우
모듈의 코드를 수정하면 다음 순서로 변경 사항을 전파한다.
소스 코드 수정 → 버전 범프 → 태그 생성 → 스펙 저장소에 podspec 등록 → 하위 모듈 의존성 업데이트 → 반복
예를 들어 BinaryLoaderNetwork의 코드를 수정한 경우:
BinaryLoaderNetwork.podspec의s.version범프git tag 1.0.5생성 후 푸시cocoapods-specs저장소에BinaryLoaderNetwork/1.0.5/BinaryLoaderNetwork.podspec등록- BinaryLoaderNetwork에 의존하는 APIService의 의존성 버전 업데이트
- APIService도 같은 과정 반복
- 최종적으로 App의 Podfile에서
pod install실행
이 과정이 번거롭게 느껴질 수 있지만 각 모듈의 버전이 명시적으로 고정되어 있으므로 어떤 시점에서든 정확히 어떤 버전의 코드가 사용되고 있는지 추적할 수 있다.
댓글남기기