본문 바로가기

iOS

[iOS] 모듈화 가보자고..🚀(3) - Tuist 적용해보자!

 지난번 포스트들에서는 모듈화를 시작하게된 배경과 방법, 그리고 tuist를 적용하지 않고 Base 모듈과 Design System모듈을 만드는 방법까지 적용했습니다.
지난 포스트들은 아래 링크를 통해 읽어볼 수 있습니다.

👉 모듈화 1편 포스트 보러가기 [iOS] 모듈화 가보자고..🚀(1) - 모듈화하게 된 배경과 진행 방법
👉 모듈화 2편 포스트 보러가기
[iOS] 모듈화 가보자고..🚀(2) - Base(Util), Design System 모듈화 하기 (Tuist 적용 전)

이번에는 지난 포스트 내용에 이어져 Tuist를 도입해보고자 합니다.

1. Tuist란? (바로가기)

.xcodeproj, .xcworkspace 파일을 생성하고 관리해주는 생산성 도구입니다.
또한 모듈 환경을 구성하는데 편리한 기능을 제공합니다.

2. Tuist를 도입하게 된 배경

2-1. .xcodeproj 파일 충돌 방지

아마도 혼자가 아닌 여러명에서 하나의 프로젝트 안에서 협업을 진행하면 무조건 경험해보셨을겁니다!
프로젝트 안에서 파일을 생성/삭제하거나, 파일의 위치를 변경하거나, SPM라이브러리를 추가/삭제 하는 경우 대부분 .xcodeproj파일이 수정되었을겁니다. (작업하고 나서 커밋날릴 때 항상 확인하게됩니다..) 혼자 커밋하며 작업하는 상황이라면 큰 문제가 되지 않겠지만.. 여러명이서 협업하며 개발하는 상황이라면 리베이스나 머지할 때 가장 많이 충돌이 발생하는 파일이 .xcodeproj파일 입니다.

하지만 Tuist를 도입하게되면 .xcodeproj파일 뿐만 아니라 .workspace를 tuist 명령어를 통해 생성할 수 있으므로 해당 파일들을 gitIgnore에 추가해놓고 사용할 수 있습니다. => .xcodeproj파일의 변경을 신경쓰지 않고 개발할 수 있습니다.

2-2. 모듈 관리의 불편함

이전 포스트에서 설정해둔 모듈 환경을 사용하다보니 편한점도 많았는데, 불편한 점들도 생각보다 많이 있었습니다. 그 중 하나는 모듈 환경을 다시 설정하는데 생각보다 많은 시간과 귀찮은 과정들이 필요하다는 점이였습니다. 메인 프로젝트를 열고 모듈 하나씩 수동으로 설정해야하는 불편함이 있었습니다. 

Tuist 사용하게 되면 Tuist 파일 안에 모듈까지 코드로 정의되어있어서 명령어 한번 입력하면 모듈관계까지 모두 구성해주는 것이 편해집니다.

3. Tuist 도입하기 (기존의 프로젝트에 Tuist Integration)

Tuist를 도입하고 나서 느낀점이지만 기존에 사용중인 프로젝트에서 Tuist Integration하는 것이 새로운 프로젝트에 처음부터 Tuist를 적용하는 것보다 훨씬 어렵습니다.. 제 경우에는 회사에서 기존에 사용중인 프로젝트가 있으므로 해당 프로젝트에 Tuist를 도입해야만 하는 상황이였습니다. 이 상황에서는 신경써야할 요소들이 생각보다 너무 많아서 힘들었습니다..그래도 하나씩 기록으로 남겨보고자 합니다.

3-1. Workspace.swift 파일 작성

Tuist 프로젝트를 만든 뒤 Top-Down으로 상위개념부터 하나씩 정의해나가는 순서로 진행했습니다.
그중 가장 상위개념인 Workspace.swift 파일을 생성했습니다. Workspace.swift는 .xcworkspace 파일을 생성해주는 파일입니다. 
Base, Design System, Frip 총 3가지 프로젝트 파일을 가지고 있으므로 3개의 프로젝트를 포함하는 워크스페이스가 필요했습니다.

// Workspace.swift
import ProjectDescription

let workspace = Workspace(
    name: "Frip",
    projects: ["Projects/**"], // Base, Design System, Frip 3가지 모듈이 위치한 디렉토리
    schemes: []
)

3-2. Dependencies.swift 파일 작성

Dependencies.swift파일은  Carthage or SPM 라이브러리를 정의하는 곳입니다. 
여기에 정의된 라이브러리들은 tuist fetch라는 명령어를 통해 패치되어있다가 .xcodeproj파일이 생성되는 시기에 프로젝트 안에 의존되게 됩니다. 프로젝트가 생성될 때 프로젝트와 외부 라이브러리 사이의 의존성 그래프를 single graph로 만들게 되어 의존성 그래프에 대한 유효성 검사를 기존 방식인 CocoPod, SPM보다 빠르게 진행할 수 있습니다.

저는 여기에다가 Base, Design System, Frip에서 공통적으로 사용하는 라이브러리들을 정의했습니다. (Rx 관련 라이브러리..등)
여러 모듈에서 공통으로 같은 라이브러리를 사용하면 또 충돌이 발생할 가능성이 높기 때문에 Dependencies에서 관리하도록 했습니다.

// Dependencies.swift
import ProjectDescription

let dependencies = Dependencies(
    swiftPackageManager: SwiftPackageManagerDependencies(
        [
            // Base, Design System, Frip 에서 사용하는 SPM 라이브러리들 정의해줍니다.
            .remote(url: "https://github.com/ReactiveX/RxSwift.git", requirement: .exact(Version(6, 5, 0))),
            .remote(url: "https://github.com/RxSwiftCommunity/RxDataSources.git", requirement: .exact(Version(5, 0, 2))),
            .remote(url: "https://github.com/ReactorKit/ReactorKit.git", requirement: .exact(Version(3, 2, 0))),
            .remote(url: "https://github.com/devxoul/Then", requirement: .exact(Version(3, 0, 0))),
            .remote(url: "https://github.com/SnapKit/SnapKit", requirement: .exact(Version(5, 6, 0))),
            .remote(url: "https://github.com/Alamofire/Alamofire.git", requirement: .upToNextMajor(from: Version(5, 6, 1)))
            ...
        ],
        baseSettings: .settings(configurations: [
        	// 라이브러리별 빌드세팅을 정의해줍니다.
            // 프립에서는 타입별 (Debug, Release), 서버별 (Dev, Beta, Staging, Production)별 8개의 개발 환경 설정을 사용중입니다.
            .debug(name: "Debug-Dev"),
            .debug(name: "Debug-Beta"),
            .debug(name: "Debug-Staging"),
            .debug(name: "Debug-Production"),
            .release(name: "Release-Dev"),
            .release(name: "Release-Beta"),
            .release(name: "Release-Staging"),
            .release(name: "Release-Production"),
        ])
    ),
    platforms: [.iOS]
)

위와 같이 설정해두고 나중에 .xcodeproj 파일 생성 전 tuist fetch 명령어를 통해 라이브러리들을 한번에 가져올 수 있습니다.

3-3. 모듈별 Project.swift 파일 작성

Project.swift파일은 .xcodeproj파일을 생성해주는 파일입니다. 지금 상황에서 사용중인 모듈은 Frip, Base, Design System 총 3개의 모듈이므로 각각의 디렉토리에 Project.swift 파일을 생성한 뒤 정의해줍니다.

Base 모듈의 Project.swift

// Base 모듈의 Project.swift
// 경로는 Projects/Frientrip-Base/Project.swift

let project = Project(
    name: "Frientrip-Base",
    organizationName: "frientrip",
    settings: .settings(
        base: [:],
        configurations: [
            .debug(
                name: "Debug-Dev",
                settings: SettingsDictionary().swiftCompilationMode(.singlefile),
                xcconfig: "Config/Frientrip-Base-Project-Debug.xcconfig"
            ),
            // 기존에 사용하던 모듈 프로젝트의 configrations을 정의해줍니다...
        ],
        defaultSettings: .recommended
    ),
    targets: [
        Target(
            name: "Frientrip-Base",
            platform: .iOS,
            product: .framework, // 모듈 형태이므로 .framework
            bundleId: "{bundleId 입력}",
            deploymentTarget: .iOS(targetVersion: "14.5", devices: .iphone),
            sources: ["Sources/**"], // 모듈의 소스코드가 들어있는 경로 입력
            resources: ["Resources/**"], // 모듈의 리소스가 들어있는 경로 입력
            dependencies: [
                // Dependencies.swift 정의해둔 라이브러리의 경우에는 .external키워드를 사용해 정의할 수 있습니다.
                .external(name: "RxCocoa"),
                .external(name: "RxDataSources"),
                .external(name: "RxRelay"),
                .external(name: "RxSwift"),
                .external(name: "RxSwiftExt"),
                .external(name: "SnapKit"),
                .external(name: "Then")
                ...
            ],
            settings: .settings(
                base: [:],
                configurations: [
                    .debug(
                        name: "Debug-Dev",
                        settings: SettingsDictionary().swiftCompilationMode(.singlefile),
                        xcconfig: "Config/Frientrip-Base.xcconfig"
                    ),
                    // 기존 타겟에서 사용하던 configurations 정의...
                ],
                defaultSettings: .recommended
            )
        )
    ],
    schemes: [
        Scheme(
            name: "Frientrip-Base",
            shared: true,
            buildAction: .buildAction(targets: ["Frientrip-Base"]),
            testAction: .targets(["Frientrip-Base"], configuration: "Debug-Beta"),
            runAction: .runAction(configuration: "Debug-Beta"),
            archiveAction: .archiveAction(configuration: "Release-Beta")
        )
    ]
)

저는 중간에 configurations를 정의하는 곳에서 가장 삽질을 많이 했었습니다. 입사 전부터 있던 기존 프로젝트에 설정되어있는 설정값들이 있었고, 해당 값들이 정확하게 어떤 기능을 하고 있는지 이해하기 어려운 값들도 있어서 Adopting Tuist 문서에 나와있는 것 처럼 기촌 프로젝트의 설정 값들을 .xcconfig 파일로 추출해놓고 해당 파일을 tuist에서 참조하도록 설정했습니다.

추가적으로 타겟 안에 있는 settings.configrations 값들도 동일하게 .xcconfig파일을 참조하도록 설정했습니다.

Frip 모듈의 Project.swift

Frip 모듈의 Project.swift도 Base 모듈 정의한 것과 비슷하게 정의했습니다.

let project = Project(
    name: "Frip",
    organizationName: "frientrip",
    packages: [
    	// Frip 모듈에서만 사용하는 SPM 라이브러리 정의해주었습니다.
        .remote(url: "https://github.com/apollographql/apollo-ios", requirement: .range(from: Version(0, 27, 1), to: Version(0, 28, 0))),
        ...
    ],
    settings: .settings(
        base: [:],
        configurations: [
        	// 프로젝트에서 사용하는 configurations 리스트 선언
            .debug(
                name: "Debug-Dev",
                settings: SettingsDictionary().swiftCompilationMode(.singlefile),
                xcconfig: "Config/FripProject-Debug.xcconfig"
            ),
            ...
        ],
        defaultSettings: .recommended
    ),
    targets: [
        Target(
            name: "Frip",
            platform: .iOS,
            product: .app,
            bundleId: "$(PRODUCT_BUNDLE_IDENTIFIER)", // bundleId가 스키마에 따라 달라지도록 설정했습니다.
            deploymentTarget: .iOS(targetVersion: "14.5", devices: .iphone),
            infoPlist: "Frip/Config/Info.plist",
            sources: ["Frip/Sources/**", "Frip/Resources/R.generated.swift"],
            resources: [
                .glob(pattern: "Frip/Resources/**",
                "Frip/Sources/Networking/GraphQL/Mutations/**",
                "Frip/Sources/Networking/GraphQL/Quries/**"
            ],
            entitlements: "Frip/Config/Frip.entitlements",
            scripts: [
            	// 기존 프로젝트의 Project > Build Phases에 있던 아이템들이 있다면 정의해줍니다.
                // CocoaPod, GQL, R, Lint와같은 툴을 사용중이였다면 정의가 필요합니다.
                .pre(
                    script: "python3 $SRCROOT/Frip/Scripts/string_resources.py $PROJECT_DIR/Frip",
                    name: "Synchronize Strings"
                ),
                ...
            ],
            dependencies: [
                .project(target: "fds-iOS", path: "../FDS"), // Frip 프로젝트에서 모듈을 사용하므로 .project키워드로 연결시켜줍니다.
                .project(target: "Frientrip-Base", path: "../Frientrip-Base"), // Frip 프로젝트에서 모듈을 사용하므로 .project키워드로 연결시켜줍니다.
                .package(product: "Apollo"), // .package로 선언하면 SPM 라이브러리가 설치됩니다.
                ...
            ],
            settings: .settings(
                base: [:],
                configurations: [
                	// 타겟에서 사용하는 configurations 선언합니다.
                ],
                defaultSettings: .recommended(excluding: ["ASSETCATALOG_COMPILER_APPICON_NAME"])
            )
        ),
        // 테스트 타겟이 있으면 추가 선언해줍니다.
    ],
    schemes: [
    	// 기존 프로젝트 schemes에 있던 값들을 그대로 코드로 만들어줍시다.
        Scheme(
            name: "Frip-Dev",
            shared: true,
            buildAction: .buildAction(targets: ["Frip"]),
            testAction: .targets(["FripTests"], configuration: "Debug-Dev"),
            runAction: .runAction(
                configuration: "Debug-Dev",
                executable: "Frip",
                arguments: .init(
                    environment: ["OS_ACTIVITY_MODE": "disabled"],
                    launchArguments: [.init(name: "-FIRDebugEnalbed", isEnabled: true)]
                )
            ),
            archiveAction: .archiveAction(configuration: "Release-Beta")
        ),
        ...
    ]
)

Base 모듈의 dependencies 와는 다르게 Frip 모듈에서는 .package라는 키워드를 사용해서 정의했는데요, .package 키워드는 우리가 프로젝트에서 SPM 라이브러리를 추가하는 것과 동일하게 동작하는 키워드입니다. .external 키워드는 Dependencies.swift에서 정의한 라이브러리를 가져오므로 두개의 차이가 존재합니다.

Dependencies.swift에 정의 후, .external 키워드로 가져온 경우 .framework 형태로 받아집니다.
.pakcage 형태로 정의한 경우 static library 형태로 받아와집니다.

4. Tuist generate

위에 과정들이 tuist를 도입하는 모든 과정이 있는 것은 아니고 조금씩 생략되어있습니다. (중요하다고 생각되는 부분을 기록으로 남겨두었습니다.)
위 과정들을 거치고 나서 tuist fetch -> tuist generate 순서로 명령어를 입력하게 되면 .xcworkspace, .xcodeproj 파일이 생성되게됩니다.

.workspace 파일 열게되면 tuist에서 정의한대로 모듈간 의존성이 구성된 것도 확인할 수 있습니다.

5. Troubleshooting

5-1. CocoaPod 미지원

바로 위 사진에서 보이듯이 Pods 프로젝트가 아직 남아있습니다. 
모든 라이브러리를 SPM으로 옮기지 못했고, 남아있는 CocoaPod 라이브러리가 있는 상황입니다.

Tuist에서는 CocoaPods을 지원하지 않는다고 공식문서에 적혀있는 상황입니다. 모든 라이브러리를 SPM으로 사용할 수 있다면 베스트지만 피치못할 사정으로 CocoaPods을 사용해야한다면 tuist generate 이후에 pod install 명령어를 통해 한번더 팟 라이브러리를 설치해야합니다.

6. 느낀점

Tuist를 도입하며 프로젝트 전체를 구석구석을 한번씩 탐색하게 되보는 계기가 되었습니다.
알지못했던 디렉토리도 있었으며 실제 파일은 있지만, 엑코에서 링킹되어있지 않은 파일도 있었고,, 사용하지 않는 파일도 대거 발견되어 정리할 수 있었습니다. 또한 디렉토리를 직관적으로 정리하는 계기도 되었습니다!

이외에도 모듈간 라이브러리 의존성을 정리하는 방법과 프로젝트의 빌드 설정값들이 의미하는 값들도 이해하는 기회가 되어서 좋았습니다.
서비스 외적으로 변경된 것은 아무것도 없지만 내부적으로 깔끔하게 청소를 하는 기분이 들어서 뿌듯했습니다.
프로젝트가 지저분하다고 느껴져서 청소를 해보고 싶다는 생각이 들면 Tuist 도입을 추천드리고 싶습니다.