Tuist 로 모듈 생성 자동화하기

Hyeongseok Park
17 min readMar 24, 2023

--

안녕하세요? iOS 엔지니어 박형석입니다.

Tuist 를 도입하고 본격적인 모듈화 작업을 시작하면, 새로운 모듈 생성할 일이 많습니다. 이 때 마다 모듈을 구성하는 기본적인 파일을 새로 생성하고 수많은 Boilerplate Code 를 작성하고 주석을 수정해줘야 합니다. 복사 붙여넣기도 좋은 방안이지만 변경이 필요한 부분을 하나하나 찾아서 수정해야 하는건 실수하기 쉽고 여전히 번거로운 작업입니다.

이번 글에서는 Tuist 에서 이런 번거로운 작업을 자동화해서 명령어 하나로 기본 모듈을 생성하는 방법에 대해서 소개하려고 합니다. Best Practice 보다 하나의 Practice 정도로 봐주시고 더 좋은 방향이나 효율적인 방향이 있다면 댓글로 공유해 주시면 좋겠습니다.

Tuist Scaffold 명령어 활용하기

Tuist Command 에 scaffold 라는 명령어가 있습니다. init 과는 달리 이미 존재하는 프로젝트에 새로운 기능이나 컴포넌트를 부트스트랩하고 싶을 때 사용합니다. 저희의 목적은 이미 Tuist 로 관리하고 있는 Workspace 에 새로운 모듈을 추가하려는 것이니 매우 적합한 기능이죠.

Tuist 의 공식 문서에도 자세한 안내가 있습니다. https://docs.tuist.io/commands/scaffold

이 명령어의 사용법은 간단합니다. 아래와 같이 명령어를 입력하면 이름에 맞는 템플릿 파일을 찾아 템플릿에 맞게 동작, 저희가 원하는 컴포넌트를 부트스트랩합니다. 이 템플릿을 어떻게 구성하는지는 추후에 자세히 살펴보겠습니다.

tuist scaffold 템플릿 이름

만약 템플릿에 터미널에서 입력한 내용을 전달하고 싶다면 아래와 같이 사용합니다. 이렇게 CLI 에서 입력한 값은 템플릿에서 Template.Attibute 타입으로 받아 사용할 수 있고 템플릿을 동적으로 구성할 수 있습니다. 이 역시 아래에서 자세히 살펴보겠습니다.

tuist scaffold 템플릿 이름Template.Attibute Key Template.Attibute Value

Template Plugin 만들기

본격 템플릿을 만들기 전에 Template 을 위한 Plugin 을 먼저 제작합니다. 물론 Tuist 의 Manifest 의 하위 디렉토리인 Tuist 디렉토리에서 이 작업을 해도 되지만 역할 분리에 병이 생긴 저는 따로 Local Plugin 을 만들어 작업해 보겠습니다. tuist init 을 사용하셨다면 Plugins 가 기본으로 생성되어 있습니다. 없으시다면 같은 이름의 디렉토리를 제작하시면 됩니다.

Plugins 디렉토리 안에 TemplatePlugin 을 만들고, TemplatePlugin 내부에 Plugin.swift 를 파일을 생성해서 해당 디렉토리가 Local Plugin 으로써 인식될 수 있도록 합니다.

import ProjectDescription

let plugin = Plugin(name: "TemplatePlugin")

다음으로 루트 디렉토리에 있는 Tuist 디렉토리의 Config.swift 에 아래 코드를 추가합니다. Plugin 에 작성한 코드를 Tuist 에서 사용하기 위해서 tuist fetch 명령어를 입력, Plugin 을 등록해 줍니다.

import ProjectDescription

let config = Config(
// ...
plugins: [
.local(path: .relativeToManifest("../../Plugins/TemplatePlugin")),
]
)

Tuist 는 Templates 디렉토리 에서 scaffold 명령어와 함께 입력한 템플릿 이름 과 동일한 파일 이름의 템플릿을 찾아 부트스트랩하는데 사용합니다. 이를 위해 Templates 디렉토리를 추가합니다. 저희는 이곳에 모듈 생성을 위한 템플릿을 추가하고 관리할 예정입니다.

tuist scaffold Feature 를 입력하면, Templates 디렉토리 내부에 있을 Feature.swift 라는 템플릿을 찾아 사용합니다.

TemplatePlugin 내부

Template 설계하기

TemplatePlugin 과 Templates 디렉토리를 만들었다면 Template (Feature.swfit) 을 만들어 필요한 로직을 추가하면 됩니다. 하지만 본격적으로 시작하기 전에 Template 을 어떻게 만들건지 간단한 설계를 먼저하시면 좋습니다.

새로운 모듈 은 Modular Architecture 어떻게 설계했고 각 모듈의 역할이 무엇인지에 따라 다르기 때문에 현 프로젝트를 고려하여 템플릿을 설계할 필요가 있습니다. 저는 간단한 예시를 위해 하나의 기능 모듈이 아래의 구조를 가진다고 생각하고 Template 에 필요한 파일을 구성(설계)해 보겠습니다.

위와 같이 설계된 모듈을 제작하고 Demo App 에서 구현한 기능을 실행해 볼 수 있는 기본 모듈을 만들기 위해서는 아래와 같은 파일이 필요합니다.

  • 모듈의 Manifest 인 Project.swift 템플릿 제작
  • Demo App 타겟에 필요한 AppDelegate 파일 제작 및 RootViewController 설정 로직 추가
  • Feature 타겟에서 RootViewController 에 사용될 FeatureViewController 제작

위와 같은 파일은 예시를 위해서 케이스를 단순화시켰으니, 설계 자체는 스무스하게 스킵해 주세요 🙂 무튼, 위와 같은 파일 구성을 위한 템플릿을 만들어 보겠습니다.

Template 만들기

이제 본격적으로 Feature 템플릿을 만들어 보겠습니다. 템플릿을 다음과 같은 과정으로 만들어 집니다.

  1. Template Manifest 인 Feature.swift 제작
  2. Project, AppDelegate, FeatureViewController 파일 생성을 위한 Stencil 파일 제작
  3. 작성자와 현재 날짜 기입을 위한 Python 스크립트 작성
  4. tuist scaffold 명령어 실행을 위한 Makefile 스크립트 작성

Template Manifest 인 Feature.swift 제작

Templates 디렉토리 내부에 Feature 모듈을 만들기 위한 Template Manifest 파일을 정의합니다. 파일 이름은 Feature.swift 로 해서, tuist scaffold Feature 명령어를 실행시 해당 템플릿에 접근할 수 있도록 합니다.

Feature.swift 의 내부는 아래와 같습니다. Template 타입을 초기화하면, Tuist 가 이 템플릿을 기반으로 동작 및 모듈을 구성합니다.

import ProjectDescription

let template = Template(
description: "A template for a new feature module",
attributes: [],
items: []
)
  • description 는 말 그대로 템플릿에 대한 설명입니다.
  • attributes 는 scaffold 명령어 뒤에 추가한 값을 Template 내부에서 사용할 수 있도록 합니다. optional 과 required 옵션이 있습니다.
  • items 는 템플릿을 기반으로 생성할 아이템 리스트입니다. string, file, directory 와 같은 옵션이 있는데, 저희는 파일 단위의 작업이 필요하기 때문에 file 아이템을 사용할 예정입니다.

템플릿을 채워가보겠습니다. description 은 단순 설명이니 attribute 부터 설정하겠습니다. 부연 설명은 주석으로 추가했습니다.

import ProjectDescription

// tuist scaffold Feature -name Home 와 같이 사용하면 아래 속성 값에 Home 이 들어옵니다.
// global 로 선언한 이유는 name 으로 들어온 value 를 해당 파일 내부에서 사용하기 위해서 입니다.
// 해당 프로퍼티는 stencil 파일에서도 사용됩니다.
let name: Template.Attribute = .required("name")
let author: Template.Attribute = .required("author")
let currentDate: Template.Attribute = .required("currentDate")

let template = Template(
description: "A template for a new feature module",
attributes: [
// 선언한 attribute 를 배열안에 선언해 주시면 됩니다.
name,
author,
currentDate,
],
items: []
)

다음은 items 를 설정해 보겠습니다. items 에는 저희가 stencil 을 사용해 제작할 파일과 해당 파일의 Path 를 설정합니다. 파일의 Path 를 설정할 때 디렉토리 이름을 함께 기입해주면 없는 디렉토리라도 만들어 줍니다.

import ProjectDescription

let name: Template.Attribute = .required("name")
let author: Template.Attribute = .required("author")
let currentDate: Template.Attribute = .required("currentDate")

let template = Template(
description: "A template for a new feature module",
attributes: [
name,
author,
currentDate,
],
items: FeatureTemplate.allCases.map { $0.item }
)

// 아래 코드는 items 손쉽게 관리하기 위한 Sugar Code 입니다.
enum FeatureTemplate: CaseIterable {
case project
case appDelegate
case viewController

var item: Template.Item {
switch self {
case .project:
// Home 디렉토리에 FeatureProject.stencil 를 기반으로 Project.swift 파일을 제작합니다.
return .file(
path: .basePath + "/Project.swift",
templatePath: "Project.stencil" // 이 경우 Template 과 동일한 디렉토리를 의미합니다.
)

case .appDelegate:
// 마찬가지로 Sources/App Path 에 HomeAppDelegate.swift 파일을 제작합니다.
return .file(
path: .basePath + "/Sources/App/\(name)AppDelegate.swift",
templatePath: "AppDelegate.stencil"
)

case .viewController:
// 동일하게 HomeViewController.swift 파일을 제작합니다.
return .file(
path: .basePath + "/Sources/Feature/\(name)ViewController.swift",
templatePath: "ViewController.stencil"
)
}
}
}

extension String {
static var basePath: Self {
// 모듈 이름이 Home 이라면 아래 Path 에 자동으로 Home 디렉토리가 만들어집니다.
return "Projects/Features/Feature\(name)"
}
}

Project, AppDelegate, FeatureViewController 파일 생성을 위한 Stencil 파일 제작

위와 같이 작성을 완료했다면, templatePath 에 적어두었던 stencil 파일을 제작할 차례입니다. 현재 상황에서는 세 가지 Stencil 파일을 만들어야 하는데, 과정은 동일해서 AppDelegate 를 예시로 들겠습니다.

AppDelegate.stencil 파일의 내부는 아래와 같습니다.

//
// {{ name }}AppDelegate.swift
// Feature{{ name }}App
//
// Created by {{ author }} on {{ currentDate }}
// Copyright © 2023 MyCompany Inc. All rights reserved.
//

import UIKit

import Feature{{ name }}

@UIApplicationMain
class {{ name }}AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
self.window = UIWindow(frame: UIScreen.main.bounds)
self.window?.rootViewController = {{ name }}ViewController()
self.window?.makeKeyAndVisible()
return true
}
}
  • 템플릿 파일을 동적으로 사용하기 위해 {{ name }} 과 같이 기입하면, Feature.swift 에서 attribute 로 등록한 값이 자동으로 기입됩니다.
  • Demo App 은 Feature 로직이 있는 타겟과 다른 타겟(앱 타겟)이라 별도의 import 가 필요해 위와 같이 구현했습니다.
  • 가장 기본적인 형태의 AppDelegate 를 구성했지만, 프로젝트에서 리소스 초기화나 의존성 등록 등과 같은 작업이 있다면 관련 코드를 추가해 놓아도 됩니다.

아무쪼록 이런 방식으로 Project 와 ViewController Stencil 파일을 구성하시면 간편하게 모듈의 기본이 되는 파일을 동적으로 만드실 수 있습니다.

작성자와 현재 날짜 기입을 위한 Python 스크립트 작성

저희가 기입했던 attributes 중 모듈의 이름의 경우, 만들고자 하는 모듈의 이름이 매번 다르기 때문에 직접 기입할 필요가 있습니다. 하지만 작성자와 현재 시간의 경우 Xcode 에서 파일을 제작할 때 자동으로 기입되는 것처럼, 자동화하는 것이 멘탈에 이롭습니다. 이를 위한 다음 두 스크립트를 작성하고 사용합니다.

Xcode 에서 새로운 파일을 생성할 때, MacOS 에서 사용자의 Full Name 에 해당하는 값을 작성자의 이름으로 사용하고 있습니다. 이를 가져오는 스크립트를 작성합니다. 마지막 print 문은 os 에서 가져온 이름을 다른 곳(Makefile)에서 사용할 수 있도록 출력하는 로직이 필요해 추가했습니다.

import subprocess

def get_full_name():
command = 'osascript -e "long user name of (system info)"'
process = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True)
output, _ = process.communicate()
return output.decode().strip()

print(get_full_name())

현재 시간도 마찬가지로 파이썬 스크립트로 작성합니다. 작성자와 현재 날짜 모두 파이썬 내장 라이브러리를 사용하고 있기 때문에 별도의 라이브러리 설치가 필요하지 않습니다.

import datetime

def get_current_date():
today = datetime.date.today() # Format the date as "YYYY/MM/DD"
formatted_date = today.strftime('%Y/%m/%d')
return formatted_date

print(get_current_date())

tuist scaffold 명령어 실행을 위한 Makefile 스크립트 작성

대망의 마지막 작업입니다. 매번 터미널에 모듈의 이름, 현재 날짜, 작성자 등을 작성해줄 수 없으니 Makefile 에서 타겟을 제작해서 CLI 에서 간편하게 스크립트를 실행할 수 있도록 합니다. 명령어는 필요한 내용만을 전달하기 위해 make module name=Home 와 같이 구성해서 사용하도록 했습니다.

# Global Variables

USER_NAME = $(shell pipenv run python scripts/author_name.py)
CURRENT_DATE = $(shell pipenv run python scripts/current_date.py)

# Target

module:
pipenv install
tuist scaffold Feature \
--name ${name} \
--currentDate $(CURRENT_DATE) \
--author $(USER_NAME)
tuist generate

위 내용을 좀 더 상세하게 설명드리면,

  1. USER_NAME, CURRENT_DATE 는 파이썬 스크립트 실행을 통해 가져온 내용을 Makefile 에 전달하기 위해 Global 변수입니다. 타겟 내부에서 선언시 변수의 읽기 쓰기 타이밍이 맞지 않아 제대로 동작하지 않습니다.
  2. pipenv install 은 파이썬 환경을 프로젝트에서 독립적으로 유지하기 위해 pipenv 를 사용하기에 필요한 작업입니다. python3 를 사용하시려면 위 변수 선언에서 python3 를 사용하시고 해당 라인은 지우셔도 됩니다.
  3. tuist scaffold Feature 는 미리 언급했던 것처럼 Feature.swift 템플릿을 기반으로 모듈을 생성하겠다는 내용입니다.
  4. --name ${name} 는 name= 로 입력받은 값을 Feature.swift 에 기입했던 name attribute 로 전달하겠다는 의미입니다.
  5. date, author 는 이미 위에서 선언한 변수를 사용해 tuist scaffold 시 해당 attribute 로 값을 전달하겠다는 의미입니다.

위와 같이 Template 및 스크립트를 모두 작성하셨다면, 터미널에서 make module name=Home 을 실행해 보세요! 모듈이 자동으로 생성되고 tuist generate 를 통해 Workspace 가 바로 생성 및 실행되어 템플릿대로 만들어진 모듈을 바로 확인하실 수 있습니다. 이제 남은 건 길고 지루한 커플링과 추상화와의 싸움입니다.

마치며

Tuist 의 Scaffold 의 활용 방법은 무궁무진한 것 같습니다. 물론 Tuist 에서 Stencil 라이브러리에 접근해 커스텀 할 수 있는 인터페이스는 제공하지 않아 한계가 있지만, 다양한 템플릿을 만들어 반복되는 작업, 휴먼에러가 많은 작업을 자동화할 수 있는 점은 큰 이점이라고 생각됩니다.

모듈화를 진행 중이시거나 가속화하고 싶으신 분들에게 조금이라도 도움이되길 바라며, 궁금하신 점이나 수정이 필요한 내용이 있다면 편히 글 남겨주시면 반영하겠습니다:)

읽어주셔서 감사합니다.

--

--