본문 바로가기

Swift/개인프로젝트

[Swift] Petpion - 모듈화를 위한 도구 (Tuist, Swinject)

첫 프로젝트인 당프로젝트를 마치고 나서 든 생각은 후련하다, 시원하다 보다는 앱이 출시되고 나서도 더욱 불안한 느낌뿐이었습니다. 첫 프로젝트에서 cleanArchitecture를 기반으로 나눈 세 가지 layer(presentation, domain, data), mvvm, RxSwift 모두 앞선 교과서 격의 예제를 보고 따라 하듯 만든 느낌이 다분해서 그랬던 것 같습니다.

 

첫 프로젝트에서 개인적으로 가장 큰 수확이라고 느꼈던 부분은 협업간에 .xcodeproj 파일의 git conflict를 처음 경험하고 이를 해결하기 위한 방법을 모색하다가 tuist를 통해 프로젝트를 관리한 것입니다.

이를 통해 github상에. xcodeproj 파일과. xcworkspace 파일을 삭제해도 프로젝트를 생성하고 실행할 수 있게끔 만든, tuist을 사용함으로써 체감할 수 있는 편의성과 다른 사람들을 설득할 합리적인 이유가 뒷받침되는 프레임워크를 처음 사용한 것이었습니다.

 

이를 경험 삼아 다음 프로젝트에 사용하는 기술들에는 “합리적인 근거가 뒷받침이 되면 사용하자” 라는 생각을 가지고 Petpion이라는 두번째 프로젝트에 임했습니다.

그중 모듈화를 위한 흔적들을 기록해보려 합니다.

1. Tuist

첫 번째로 모듈화를 위하여 한 노력은 앱의 템플릿과 구조를 잡았습니다. 앱을 설계하고 구조를 잡는 과정에서 tuist 의 기능을 중점적으로 활용했습니다.

// project.swift
private enum Layer: CaseIterable {
    case core
    case domain
    case data
    case presentation
    
    var layerName: String {
        switch self {
        case .core: return "PetpionCore"
        case .domain: return "PetpionDomain"
        case .data: return "PetpionData"
        case .presentation: return "PetpionPresentation"
        }
    }
}

프로젝트의 Layer를 열거형의 케이스로 설계했습니다. 첫번째 프로젝트에서는 폴더로만 구분하여 사실상 레이어 간에 구분이 없다시피 했던 부분을 보완하기 위해 프레임워크로 나누도록 설계했습니다.

위 열거형 layer들을 통해 framework를 생성하도록 합니다.

 

/// project.swift
private let deploymentTarget: DeploymentTarget = .iOS(targetVersion: "14.0", devices: [.iphone])

func makePetpionFrameworkTargets(
    name: String,
    platform: Platform,
    dependencies: [TargetDependency]) -> [Target] {
        
        let sources = Target(
            name: name,
            platform: platform,
            product: .framework,
            bundleId: "com.\\(name)",
            deploymentTarget: deploymentTarget,
            infoPlist: .default,
            sources: ["Targets/\\(name)/Sources/**"],
            resources: [],
            dependencies: dependencies,
            settings: .settings(base: .init()
                .swiftCompilationMode(.wholemodule))
        )
        
        let tests = Target(
            name: "\\(name)Tests",
            platform: platform,
            product: .unitTests,
            bundleId: "com.\\(name)Tests",
            deploymentTarget: deploymentTarget,
            infoPlist: .default,
            sources: ["Targets/\\(name)/Tests/**"],
            resources: [],
            dependencies: [
                .target(name: name),
            ]
        )
        
        return [sources, tests]
    }

func makePetpionAppTarget(
    platform: Platform,
    dependencies: [TargetDependency]) -> Target {
        
        let platform = platform
        let infoPlist: [String: InfoPlist.Value] = [
            "CFBundleVersion": "1",
            "UIUserInterfaceStyle": "Light",
            "UILaunchStoryboardName": "LaunchScreen",
						...
            "NSPhotoLibraryUsageDescription": "사진첩 접근 권한 요청"
        ]
        
        return .init(
            name: "Petpion",
            platform: platform,
            product: .app,
            bundleId: "com.Petpion",
            deploymentTarget: deploymentTarget,
            infoPlist: .extendingDefault(with: infoPlist),
            sources: ["Targets/Petpion/Sources/**"],
            resources: ["Targets/Petpion/Resources/**"],
            entitlements: "./Petpion.entitlements",
            dependencies: dependencies,
            settings: makeConfiguration()
        )
    }

func makePetpionFrameworkTargets(name: String, platform: Platform, dependencies: [TargetDependency])

위 메서드는 각 프레임워크 이름, 플랫폼, 의존성을 주입받아 해당하는 프레임워크와 테스트 타깃을 만들어 줍니다.

func makePetpionAppTarget(platform: Platform, dependencies: [TargetDependency])

위 메서드는 앱 타겟을 만들어주는 메서드로 앱에 필요한 추가적인 infoPlist, resources, entitlements 등을 추가로 주입해 줍니다.

위 메서드와 처음 정의한 Layer 열거형을 통하여 프로젝트를 구성합니다.

그다음 터미널에서 tuist generate 명령어를 실행하면 tuist를 통해 열거한 workspace 가 생성됩니다.

 

/// project.swift
let project: Project = .init(
    name: "Petpion",
    organizationName: "Petpion",
    packages: [
        .remote(url: "<https://github.com/Swinject/Swinject.git>", requirement: .upToNextMajor(from: "2.8.0")),
        .remote(url: "<https://github.com/firebase/firebase-ios-sdk>", requirement: .upToNextMajor(from: "10.1.0")),
        .remote(url: "<https://github.com/google/gtm-session-fetcher.git>", requirement: .upToNextMajor(from: "3.0.0")),
        .remote(url: "<https://github.com/Yummypets/YPImagePicker.git>", requirement: .upToNextMajor(from: "5.2.0")),
        .remote(url: "<https://github.com/guoyingtao/Mantis.git>", requirement: .exact("2.3.0")),
        .remote(url: "<https://github.com/airbnb/lottie-ios.git>"
                ,requirement: .upToNextMajor(from: "4.0.0")),
        .remote(url: "<https://github.com/kakao/kakao-ios-sdk>", requirement: .upToNextMajor(from: "2.11.0"))
    ],
    settings: makeConfiguration(),
    targets: [
        [makePetpionAppTarget(
            platform: .iOS,
            dependencies: [
                .target(name: Layer.core.layerName),
                .target(name: Layer.presentation.layerName),
                .target(name: Layer.domain.layerName),
                .target(name: Layer.data.layerName)
            ])],
        // core layer
        makePetpionFrameworkTargets(
            name: Layer.core.layerName,
            platform: .iOS,
            dependencies: [
                .package(product: "Swinject")
            ]),
        // presentation layer
        makePetpionFrameworkTargets(
            name: Layer.presentation.layerName,
            platform: .iOS,
            dependencies: [
                .target(name: Layer.core.layerName),
                .target(name: Layer.domain.layerName),
                .package(product: "YPImagePicker"),
                .package(product: "Mantis"),
                .package(product: "Lottie")
            ]),
        // data layer
        makePetpionFrameworkTargets(
            name: Layer.data.layerName,
            platform: .iOS,
            dependencies: [
                .target(name: Layer.core.layerName),
                .target(name: Layer.domain.layerName),
                .package(product: "FirebaseAuth"),
                .package(product: "FirebaseAnalytics"),
                .package(product: "FirebaseFirestore"),
                .package(product: "FirebaseStorage"),
                .package(product: "GTMSessionFetcherFull"),
                .package(product: "KakaoSDKCommon"),
                .package(product: "KakaoSDKAuth"),
                .package(product: "KakaoSDKUser")
            ]),
        // domain layer
        makePetpionFrameworkTargets(
            name: Layer.domain.layerName,
            platform: .iOS,
            dependencies: [
                .target(name: Layer.core.layerName)
            ])
    ].flatMap { $0 }
)

위 메서드를 통해 Petpion 이란 이름의 프로젝트를 생성합니다. 처음부터 하나하나 뜯어보겠습니다.

Petpion 프로젝트는 각 레이어(core, presentation, domain, data)의 프레임워크가 포함되고, 패키지로써

Swinject, firebase-ios-sdk, gtm-session-fetcher, YPImagePicker, Mantis, lottie-ios, kakao-ios-sdk 가 사용되며, targets에서 각 해당 레이어의 프레임워크를 생성합니다.

각 프레임워크 또한 역할에 맞는 특정 레이어, 혹은 패키지를 의존합니다.

 

각 레이어의 역할과 의존성을 간단히 설명하자면

presentation layer는 사용자 인터페이스의 역할을 합니다. 그러므로 여기선 이미지 피커 라이브러리(YPImagePicker), 애니메이션 라이브러리(Lottie), 도메인, core layer를 의존할 것입니다.

domain layer는 앱의 비즈니스 로직을 다루는 부분입니다. 비즈니스 로직은 곧 앱의 핵심적인 기능을 코드로 구체화한 구현부입니다. 그러므로 어떤 레이어보다 직관성 있어야 하고 중점적으로 여겨져야 됩니다. 그래서 domain layer의 구현부는 다른 레이어의 변경여부에 영향을 받지 않도록 (core layer를 제외한) 어떤 레이어에도 의존하지 않도록 했습니다.

data layer에서는 이름 그대로 서버통신, third party 모듈과 직접적인 통신을 하고 있고, 라이브러리들 (Firebase, KakakoSDK)등 과 도메인, core layer를 의존합니다.

core layer는 모든 모듈의 공통적인 로직 및 유틸리티를 모아둔 모듈입니다. 또한 각 모듈을 조립해 줄 swinject를 의존합니다.

tuist 로 생성한 프로젝트의 결과물

체감할 수 있었던 장점

프로젝트가 점점 진행됨에 따라 의존성이나 프레임워크의 추가를 항상 project.swift 에 명시해줘야 하는 번거로움이 있지만 역으로 이 파일 하나만 들여다봐도 각 프레임워크 간 의존성을 단번에 파악할 수 있고 의존성 버전관리 또한 간편해집니다.

각 레이어를 프레임워크로 나눠주었고, 각 레이어의 프레임워크에 의존성을 주입했습니다. 이를 통해 의존성이 없는 레이어 간에는 접근을 할 수 없는 물리적인 장벽이 생겨서 만족스러웠습니다.

 

 

2. Swinject

특정 모듈을 구성하기 위해서 모든 레이어에 대한 의존성을 가지는 독립적인 의존성 주입 컴포넌트를 Swinject로 구성했습니다.

모든 레이어에 대한 의존성을 가지는 독립적인 의존성 주입 컴포넌트를 통하여 모듈의 의존성을 관리하면 더 이상 다른 모듈은 의존성을 가질 필요가 없어집니다. 즉 모듈 간의 조립을 한 컴포넌트에서 모두 처리하게 되고, 각 레이어에서는 단순히 주입된 의존성을 가진 인스턴스를 사용하기만 하면 됩니다. 이렇게 구조를 분리하면 각 레이어의 역할과 책임을 더욱 명확하게 구분할 수 있습니다.

또한 각 모듈이 필요한 곳에서 중복으로 의존성 주입하는 중복코드 또한 방지할 수 있었습니다.

/// SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    var window: UIWindow?
    var coordinator: Coordinator?
    var navigationController: UINavigationController?
    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        
        self.navigationController = UINavigationController()
        
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = navigationController
        window?.makeKeyAndVisible()
        
        initKakaoSDK()

				// Dependency Injection
        register()
        
        guard let mainCoordinator = DIContainer.shared.resolve(Coordinator.self, name: "MainCoordinator") else { return }
        coordinator = mainCoordinator
        coordinator?.start()
    }
    
		...
}

private extension SceneDelegate {
		...
    func register() {
        guard let navigationController = navigationController else { return }
        
        DataDIContainer().register()
        DomainDIContainer().register()
        PresentationDIContainer(navigationController: navigationController).register()
    }
}

앱이 실행될 때 각 레이어에서 container 인스턴스의 내부 register() 메서드를 통하여 각 클래스의 구현부분을 인터페이스 타입으로 정의합니다. 그리고 resolve() 메서드를 통해 container 인스턴스에 등록한 의존성을 인스턴스화 합니다.

 

간단히 swinject 의 container 내부 register() 메서드와 resolve() 메서드를 살펴 보겠습니다.

 

// Swinject - Container
public final class Container {
			...

@discardableResult
    public func register<Service>(
        _ serviceType: Service.Type,
        name: String? = nil,
        factory: @escaping (Resolver) -> Service
    ) -> ServiceEntry<Service> {
        return _register(serviceType, factory: factory, name: name)
    }
			...
@discardableResult
    // swiftlint:disable:next identifier_name
    public func _register<Service, Arguments>(
        _ serviceType: Service.Type,
        factory: @escaping (Arguments) -> Any,
        name: String? = nil,
        option: ServiceKeyOption? = nil
    ) -> ServiceEntry<Service> {
        let key = ServiceKey(serviceType: Service.self, argumentsType: Arguments.self, name: name, option: option)
        let entry = ServiceEntry(
            serviceType: serviceType,
            argumentsType: Arguments.self,
            factory: factory,
            objectScope: defaultObjectScope
        )
        entry.container = self
        services[key] = entry

        behaviors.forEach { $0.container(self, didRegisterType: serviceType, toService: entry, withName: name) }

        return entry
    }
}

register 메서드는 ServiceType 매개변수를 받아 타입을 지정합니다. 또한 필요시 name 을 설정해 객체를 식별할 수 있도록 할 수 있습니다. 기본값은 ‘nil’이며 Coordinator 와 같은 한 인터페이스에 여러 구현체를 정의한 경우 name으로 관리를 하였습니다.

그리고 factory 는 Resolver 타입의 인자를 받아 생성된 객체를 반환합니다. ServiceEntry<Service> 는 등록된 모든 객체를 반환합니다. 특별한 경우를 제외하고는 @discardableResult 어노테이션을 통해 ServiceEntry<Service> 는 사용하지 않습니다.

 

extension Container: Resolver {
		...
    public func resolve<Service>(_ serviceType: Service.Type) -> Service? {
        return resolve(serviceType, name: nil)
    }
		
		public func resolve<Service>(_: Service.Type, name: String?) -> Service? {
	        return _resolve(name: name) { (factory: (Resolver) -> Any) in 
				factory(self)
				 }
    }

		public func _resolve<Service, Arguments>(
        name: String?,
        option: ServiceKeyOption? = nil,
        invoker: @escaping ((Arguments) -> Any) -> Any
    ) -> Service? {
        var resolvedInstance: Service?
        let key = ServiceKey(serviceType: Service.self, argumentsType: Arguments.self, name: name, option: option)

        if let entry = getEntry(for: key) {
            resolvedInstance = resolve(entry: entry, invoker: invoker)
        }

        if resolvedInstance == nil {
            resolvedInstance = resolveAsWrapper(name: name, option: option, invoker: invoker)
        }

        if resolvedInstance == nil {
            debugHelper.resolutionFailed(
                serviceType: Service.self,
                key: key,
                availableRegistrations: getRegistrations()
            )
        }

        return resolvedInstance
    }

		...
}

 

resolve 메서드는 serviceType을 사용하여 name 이 지정되어 있는지를 확인하고 _resolve 메서드를 호출합니다.

_resolve 메서드는 let key = ServiceKey(serviceType: Service.self, argumentsType: Arguments.self, name: name, option: option) 를 통해 ServiceKey 객체를 생성후, getEntry(for: key) 메서드로 등록된 service 항목을 가져오려고 시도합니다.

등록된 서비스를 찾으면 해당 엔트리를 반환합니다. 만약 없다면, resolveAsWrapper(name: name, option: option, invoker: invoker) 를 통해 이름과 함께 등록된 서비스를 반복적으로 찾게 됩니다.

 

 

Petpion UseCases

만약 Login모듈을 구성한다면, 초록색 화살표인 FirebaseAuth, Firestore, KakaoAuth Protocol을 의존하는 LoginUseCase를 생성해야 합니다. 이를 DomainDIContainer(). register()에서 구현한 부분입니다.

// DomainDIContainer.swift
import PetpionCore
import Swinject

public struct DomainDIContainer: Containable {
    
    public init() {}
    
    public var container: Swinject.Container = DIContainer.shared
    
    public func register() {
        registerUseCases()
    }
    
    private func registerUseCases() {
        ...
        container.register(LoginUseCase.self) { resolver in
            DefaultLoginUseCase(firebaseAuthRepository: resolver.resolve(FirebaseAuthRepository.self)!,
                                firestoreRepository: resolver.resolve(FirestoreRepository.self)!,
                                kakaoAuthReporitory: resolver.resolve(KakaoAuthRepository.self)!)
        }
    }
}

container에 LoginUseCase를 정의합니다. 여기서 다른 객체에 대한 의존성이 필요한 경우 해당 의존성을 생성하여 주입하는것이 아닌, resolver.resolve() 메서드를 통해 의존성을 제공합니다.

resolver.resolve() 메서드는 컨테이너에서 타입을 검색하고, 해당 타입에 대한 인스턴스를 반환합니다.

그렇기 때문에 당연히 container.resolve(Type.self) 를 하여 도메인 모듈 인스턴스를 가져오기 위해선 container에 Data Layer의 구현부가 register() 되어 있어야 합니다.

// DataDIContainer.swift
import PetpionCore
import PetpionDomain
import Swinject

public struct DataDIContainer: Containable {
    
    public init() {}

    public var container: Swinject.Container = DIContainer.shared
    
    public func register() {
        registerRepositories()
    }
    
    private func registerRepositories() {
        container.register(FirestoreRepository.self) { _ in
            DefaultFirestoreRepository()
        }
        
        container.register(FirebaseStorageRepository.self) { _ in
            DefaultFirebaseStorageRepository()
        }
        
        container.register(FirebaseAuthRepository.self) { _ in
            DefaultFirebaseAuthRepository()
        }
        
        container.register(KakaoAuthRepository.self) { _ in
            DefaultKakaoAuthRepository()
        }
    }
    
}

 

이후 로그인 모듈을 사용한 메서드입니다.

// Presentaion Layer - NeedLoginCoordinator
import Foundation
import UIKit

import PetpionDomain
import PetpionCore

final class NeedLoginCoordinator: NSObject, Coordinator {
    
    public var childCoordinators: [Coordinator] = []
    public var navigationController: UINavigationController
    
    weak var mainCoordinatorDelegate: MainCoordinatorDelegage?
    var navigationItemType: NavigationItemType?
    
    public init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    public func start() {
        let needLoginViewController = getNeedLoginViewController()
        needLoginViewController.coordinator = self
        navigationController.pushViewController(needLoginViewController, animated: true)
    }
    
    func presentLoginView() {
        let loginViewController = getLoginViewController()
        loginViewController.coordinator = self
        navigationController.present(loginViewController, animated: true)
    }
	
		...
}

extension NeedLoginCoordinator {
    private func getNeedLoginViewController() -> NeedLoginViewController {
        guard let navigationItemType = navigationItemType else {
            fatalError("getNeedLoginViewController occurred error")
        }
        let viewModel: NeedLoginViewModelProtocol = NeedLoginViewModel(navigationItemType: navigationItemType)
        return NeedLoginViewController(viewModel: viewModel)
    }
    
    private func getLoginViewController() -> LoginViewController {
        guard let loginUseCase: LoginUseCase = DIContainer.shared.resolve(LoginUseCase.self),
              let uploadUserUseCase: UploadUserUseCase = DIContainer.shared.resolve(UploadUserUseCase.self) else {
            fatalError("getLoginViewController did occurred error")
        }
        let viewModel: LoginViewModelProtocol = LoginViewModel(loginUseCase: loginUseCase, uploadUserUseCase: uploadUserUseCase)
        return LoginViewController(viewModel: viewModel)
    }

		...
}

처음 설계와는 다르게 presentaion layer에서 뷰를 그릴 때 뷰모델의 상태, 특정 작업 이후 받아올 수밖에 없는 viewModel의 인스턴스 들이 점점 생기면서 컴파일 타임에 viewModel의 의존성을 정의하기 어려워 지게 됐습니다. 따라서 현재 view와 viewModel은 coodinator에서 해당 viewModel의 의존하는 도메인 모듈을 주입하여 생성하였습니다.

체감할 수 있었던 장점

모든 레이어에 의존성을 보유한 독립적인 container 인스턴스를 생성했고, container 내부 메서드인 register(Type.self) 를 통해서 container 내부에 인터페이스 타입을 인스턴스로 생성하였습니다.

그리고 도메인 모듈의 의존성을 resolver.resolve 메서드를 사용하여 컨테이너에서 필요한(container에 등록된) 객체를 주입하도록 요청했습니다.

이를 통해 컨테이너가 담당하는 객체의 생명주기 관리 및 의존성 교체 부분에서 더 자유로워졌습니다.

생명주기 관리에서 느꼈던 장점은 위 예시로 든 로그인 모듈은 한정적인 부분에서만 사용되지만 필수적인 모듈입니다. 이 로그인 모듈에 의존하는 FirebaseAuthRepository, KakaoAuthRepository또한 Login을 위한 repository 라고 볼 수 있습니다. 만약 이 모듈을 의존성을 주입할 때 직접 repository를 생성하여 주입했다면, 결국 생성하는 과정에서, 즉 컴파일 타임에서 객체의 인스턴스가 결정되었습니다.

이를 swinject를 통하여 의존성을 주입할때 (register메서드 내 resolver.resolve 메서드를 통해) 객체의 인스턴스를 주입하므로 객체의 의존성을 동적으로 생성하고 주입하였습니다. 즉 해당 모듈이 실행될 때 객체의 인스턴스가 결정된 것입니다.

이를 통해 해당 의존성 객체의 생성 및 소멸 그리고 중복 생성에 대한 부분을 덜어낼수있었습니다.

 

결론

결국 모듈화의 목적은 대규모 소프트웨어 개발에서 유지보수성과 가독성의 향상으로 인한 생산성 증가가 가장 큰 장점인 것 같습니다. 개인프로젝트라 모듈화에 대한 특출 난 장점을 느끼기는 쉽지 않았지만, tuist와 swinject를 활용한 유지보수성과 가독성의 향상은 체감 가능한 부분이었습니다.