baegteun - iOS

Tuist 모듈화하기 - Modular Architecture 설계하기 본문

Tuist

Tuist 모듈화하기 - Modular Architecture 설계하기

baegteun 2023. 1. 5. 01:12

이 글은 Tuist 버전 3.15.0을 기준으로 작성되었습니다.

전체 소스코드는 https://github.com/baekteun/Tuist_Modular_Template 에 공개 + 템플릿 으로 사용가능합니다.

스타 한번씩 눌러주시면 매우 감사합니다..!

  • 레이어
  • 모듈화
    • Micro Feature
  • Tuist 설계
    • Plugin
    • Project 생성
    • 자동화

레이어

모듈보다 더 큰 관점에서 레이러를 먼저 분리해보도록 하겠습니다.
Feature, Domain, Core, Shared로 아래와 같은 기준으로 분리하였습니다.

  • Feature
    • Presentation 부분
    • ex) AuthFeature, ProfileFeature
  • Domain
    • Business Logic 부분
    • ex) AuthDomain, ProfileDomain
  • Core
    • 앱의 비즈니스를 포함하지 않고 순수 기능성 모듈이 위치한 레이어
    • ex) NetworkingModule, DatabaseModule
  • Shared
    • 모든 계층에서 사용 가능한 모듈
    • 더 넓은 의미의 공통적
    • ex) UtilityModule, LoggingModule

모듈화

모듈을 분리하는 기준은 가장 크게 도메인 관점에 따른 분리와 재사용 관점에 따라 분리할 수 있습니다.

Feature와 Domain은 도메인 관점에 따라 분리하고, Core와 Shared는 재사용 관점에 따라 분리하였습니다.

Micro Feature

Tuist의 공식 문서에는 Micro Feature에 대한 문서가 있습니다.

https://docs.tuist.io/building-at-scale/microfeatures/#what-is-a-µfeature

Micro Feature는 확장을 가능하게 하고, 빌드 및 테스트 주기를 최적화하며, 팀의 모범 사례를 보장하기 위해 Apple OS 애플리케이션을 구성하는 아키텍처 접근 방식입니다.
이 방식을 따라서 각 모듈을 구성할 것입니다.

Tuist 설계

Plguin

Tuist관리도 용이하게 하기 위해서 Plugin도 분리해주겠습니다.
Plugin은

  • Configurtaion 관리를 위한 ConfigurationPlugin
  • 디펜던시 관리를 위한 DependencyPlugin
  • 프로젝트 환경을 관리하기 위한 EnvironmentPlugin

으로 3종류의 Plugin을 만들겠습니다.

여기에서 더 자세한 내용을 볼 수 있습니다!

Project 생성

Project.swift에서 모듈을 만들때 쓸 함수를 만들겠습니다.
우선 CI일때를 처리 하기 위해

let isCI = (ProcessInfo.processInfo.environment["TUIST_CI"] ?? "0") == "1" ? true : false

를 위에 선언해놓겠습니다.
기본적으로 빌드는하면 SwiftLint를 돌리게 했는데, CI/CD를 할때는 SwiftLint를 돌릴 필요가 없기에 이후에 분기점을 둬서 CI일때는 실행하지 않도록 하기 위해서입니다.
또한 앞서 말했듯 Micro Feature를 적용할 것인데 이를 편하게 하기 위해

public enum MicroFeatureTarget {
    case interface
    case testing
    case unitTest
    case uiTest
    case demo
}

를 선언해놓겠습니다.

static func makeModule(
    name: String,
    platform: Platform = env.platform,
    product: Product,
    targets: Set<MicroFeatureTarget>,
    packages: [Package] = [],
    externalDependencies: [TargetDependency] = [],
    internalDependencies: [TargetDependency] = [],
    interfaceDependencies: [TargetDependency] = [],
    testingDependencies: [TargetDependency] = [],
    unitTestDependencies: [TargetDependency] = [],
    uiTestDependencies: [TargetDependency] = [],
    demoDependencies: [TargetDependency] = [],
    sources: SourceFilesList = .sources,
    resources: ResourceFileElements? = nil,
    settings: SettingsDictionary = [:],
    additionalPlistRows: [String: ProjectDescription.InfoPlist.Value] = [:],
    additionalFiles: [FileElement] = []
) -> Project

Project의 extension으로 makeModule 함수를 만들겠습니다.
각 파라미터의 의미는

  • name: 모듈 이름
  • platform: 플랫폼
  • product: 모듈의 product type (framework, staticFramework 등)
  • targets: 사용할 Micro Feature (Interface, testing, unitTests, uiTests, demo)
  • packages: 패키지들
  • externalDependencies: 외부 디펜던시
  • internalDependencies: 내부 디펜던시
  • interfaceDependencies: interface Target모듈의 디펜던시
  • testingDependencies: testing Target모듈의 디펜던시
  • unitTestDependencies: unitTests Target모듈의 디펜던시
  • uiTestDependencies: uiTests Target모듈의 디펜던시
  • demoDependencies: demo Target 모듈의 디펜던시
  • sources: 소스폴더의 경로
  • resources: 리소스폴더의 경로
  • settings: 모듈의 빌드 세팅
  • additionalPlistRows: info plist의 추가 Row
  • additionalFiles: Xcode에서 추가적으로 인식할 파일(ex README.md)

입니다.
이후부터 Project를 구성할것입니다.

let scripts: [TargetScript] = isCI ? [] : [.swiftLint]

앞서서 했던 isCI를 사용해서 scripts를 정의해줍니다. CI라면 없이, CI가 아니라면 SwiftLint를 포함시켜거 스크립트를 돌립니다.

let ldFlagsSettings: SettingsDictionary = product == .framework ?
["OTHER_LDFLAGS": .string("$(inherited) -all_load")] :
["OTHER_LDFLAGS": .string("$(inherited)")]

product가 framework (dynamic framework)라면 staticLibrary에 작성된 모든 멤버를 적재하도록 링커에 -all_load 옵션을 달아줍니다.

import ProjectDescription

public extension SettingsDictionary {
    static let codeSign = SettingsDictionary()
        .codeSignIdentityAppleDevelopment()
        .automaticCodeSigning(devTeam: "\(Apple Team Identifier)")
}
let settings: Settings = .settings(
    base: env.baseSetting
        .merging(.codeSign)
        .merging(settings)
        .merging(ldFlagsSettings),
    configurations: [
        .debug(name: .dev, xcconfig: .relativeToXCConfig(type: .dev, name: name)),
        .debug(name: .stage, xcconfig: .relativeToXCConfig(type: .stage, name: name)),
        .release(name: .prod, xcconfig: .relativeToXCConfig(type: .prod, name: name))
    ],
    defaultSettings: .recommended
)

프로젝트의 세팅에 사용할 변수입니다.
baseSetting에 파라미터로 받은 settigns와 방금 위에만든 ldFlagsSettings, 그리고 따로 extension으로 뺴놓은 codeSign merging 해줍니다.
그리고 configuration은 dev, stage, prod로 분리해놓고, defaultSettings는 recommended로 해놓았습니다.

var allTargets: [Target] = []
var dependencies = internalDependencies + externalDependencies

// MARK: - Interface
if targets.contains(.interface) {
    dependencies.append(.target(name: "\(name)Interface"))
    allTargets.append(
        Target(
            name: "\(name)Interface",
            platform: platform,
            product: .framework,
            bundleId: "\(env.organizationName).\(name)Interface",
            deploymentTarget: env.deploymentTarget,
            infoPlist: .default,
            sources: .interface,
            scripts: scripts,
            dependencies: interfaceDependencies,
            additionalFiles: additionalFiles
        )
    )
}

이후부터는 Micro Feature를 위한 Target을 설정하는 건데 위와 거의 비슷한 흐름을 가집니다.
모든 Feature를 거치고나면

let schemes: [Scheme] = targets.contains(.demo) ?
[.makeScheme(target: .dev, name: name), .makeDemoScheme(target: .dev, name: name)] :
[.makeScheme(target: .dev, name: name)]

...

extension Scheme {
    static func makeScheme(target: ConfigurationName, name: String) -> Scheme {
        return Scheme(
            name: name,
            shared: true,
            buildAction: .buildAction(targets: ["\(name)"]),
            testAction: .targets(
                ["\(name)Tests"],
                configuration: target,
                options: .options(coverage: true, codeCoverageTargets: ["\(name)"])
            ),
            runAction: .runAction(configuration: target),
            archiveAction: .archiveAction(configuration: target),
            profileAction: .profileAction(configuration: target),
            analyzeAction: .analyzeAction(configuration: target)
        )
    }
    static func makeDemoScheme(target: ConfigurationName, name: String) -> Scheme {
        return Scheme(
            name: name,
            shared: true,
            buildAction: .buildAction(targets: ["\(name)DemoApp"]),
            testAction: .targets(
                ["\(name)Tests"],
                configuration: target,
                options: .options(coverage: true, codeCoverageTargets: ["\(name)DemoApp"])
            ),
            runAction: .runAction(configuration: target),
            archiveAction: .archiveAction(configuration: target),
            profileAction: .profileAction(configuration: target),
            analyzeAction: .analyzeAction(configuration: target)
        )
    }
}

scheme을 만듭니다.
그리고 마지막으로

return Project(
    name: name,
    organizationName: env.organizationName,
    packages: packages,
    settings: settings,
    targets: allTargets,
    schemes: schemes
)

Project를 만들어서 리턴합니다.

모듈을 만들때 Project.makeModule 을 사용해서 사용할 수 있습니다.

다만, App 모듈을 만들때는 따로 Target을 만들지 않고, Scheme만 dev, stage, prod 3개 만들어주겠습니다.

자동화

프로젝트를 만드는 방법은 만들었지만 실제로 만들게 된다면 모듈 하나를 복사하고 이름을 바꾸고 필요없는 Target은 지우고 DependencyPlugin에 경로 넣어주고 하는 등 귀찮은 과정들이 좀 있습니다.
이런 반복작업같은거 조금 자동화를 시켜보려합니다.
우선 모듈 생성을 자동화해야할 때 입력해야 하는 것은 레이어(Feature, Domain, Core, Shared), 모듈 이름, Micro Feature 중 포함하는 Target을 선택하는 것을 입력받는 것이 필요합니다.

enum LayerType: String {
    case features = "Features"
    case services = "Services"
    case core = "Core"
    case shared = "Shared"
}

print("Enter layer name\n(Features | Services | Core | Shared)", terminator: " : ")
let layerInput = readLine()
guard 
    let layerInput, 
    !layerInput.isEmpty ,
    let layerUnwrapping = LayerType(rawValue: layerInput)
else {
    print("Layer is empty or invalid")
    exit(1)
}
let layer = layerUnwrapping
print("Layer: \(layer.rawValue)\n")

print("Enter module name", terminator: " : ")
let moduleInput = readLine()
guard let moduleNameUnwrapping = moduleInput, !moduleNameUnwrapping.isEmpty else {
    print("Module name is empty")
    exit(1)
}
var moduleName = moduleNameUnwrapping
print("Module name: \(moduleName)\n")

print("This module has a 'Interface' Target? (y\\n, default = n)", terminator: " : ")
let hasInterface = readLine()?.lowercased() == "y"

print("This module has a 'Testing' Target? (y\\n, default = n)", terminator: " : ")
let hasTesting = readLine()?.lowercased() == "y"

print("This module has a 'UnitTests' Target? (y\\n, default = n)", terminator: " : ")
let hasUnitTests = readLine()?.lowercased() == "y"

print("This module has a 'UITests' Target? (y\\n, default = n)", terminator: " : ")
let hasUITests = readLine()?.lowercased() == "y"

print("This module has a 'Demo' Target? (y\\n, default = n)", terminator: " : ")
let hasDemo = readLine()?.lowercased() == "y"

시간을 들여서 만든건 아니라서 지금 보기에는 상당히.. 더러운거같습니다만. 일단은 넘어,,갑시다..!
이후에 enum ModulePaths에 새로운 모듈의 경로를 추가해주고, xcconfig를 만들고, 각 Micro Feature들의 모듈을 경로에 등록 및 파일을 만들어줍니다. 이후 마지막으로 Project.swift파일을 만들어줄것입니다.

func registerModuleDependency() {
    registerModulePaths()
    makeProjectDirectory()
    registerXCConfig()
    registerMicroTarget(target: .sources)
    var targetString = "["
    if hasInterface {
        registerMicroTarget(target: .interface)
        makeScaffold(target: .interface)
        targetString += ".\(MicroTargetType.interface), "
    }
    if hasTesting {
        registerMicroTarget(target: .testing)
        makeScaffold(target: .testing)
        targetString += ".\(MicroTargetType.testing), "
    }
    if hasUnitTests {
        makeScaffold(target: .unitTest)
        targetString += ".\(MicroTargetType.unitTest), "
    }
    if hasUITests {
        makeScaffold(target: .uiTest)
        targetString += ".\(MicroTargetType.uiTest), "
    }
    if hasDemo {
        makeScaffold(target: .demo)
        targetString += ".\(MicroTargetType.demo), "
    }
    if targetString.hasSuffix(", ") {
        targetString.removeLast(2)
    }
    targetString += "]"
    makeProjectSwift(targetString: targetString)
    makeProjectScaffold(targetString: targetString)
}

디자인 패턴을 쓰고 싶었지만,, 단일 파일이라 미묘하게 부담스러워서 하드코딩을 저질러버렸다.
전체 코드는 여기에서 볼 수 있습니다!
+ 모듈의 Target파일을 생성할 때 tuist scaffold를 사용했는데, 이에 관한건 나중에 작성해보겠습니다..!

그리고 이 파일을 실행하는 것은 프로젝트 Root에 Makefile을 만들어서 명령어를 만들어놓겠습니다.

...


module:
    swift Scripts/GenerateModule.swift

...

으로 make module 을 치면 모듈을 만들 수 있게 해줍니다.

우선 여기까지 마치고, 더 자세한 내용은 https://github.com/baekteun/Tuist_Modular_Template 에서 볼 수 있습니다.

+ 해당 Repository의 코드는 현재 포스트와 일부 달라졌습니다
+ https://baegteun.tistory.com/15 에서 조금 더 개선한 버전을 볼 수 있습니다

References By

https://minsone.github.io
SyncSwift 2022 - Modular Architecture 시작하기
Let'Swift 2022 - Modular Architecture /w Tuist
https://docs.tuist.io/building-at-scale/microfeatures/

Comments