Tuist와 모듈피플

iOS 모듈화 나도 이제 할 수 있다, 너도 할 수 있다.

Gordon Choi
31 min readDec 12, 2023

들어가기 전에 — 버전 이슈

필자의 이 글은 Tuist 3.33.3 버전을 기준으로 작성되었으며, 이후 버전에서는 일부 편의성 메서드에서 Deprecated 알림이 출현할 수 있다.

Tuist 사용 버전을 고정하려면 아래의 요령을 따라가면 된다.

  • tuist를 initialize한 디렉토리에 .tuist-version 파일을 만든다. (touch .tuist-version)
  • .tuist-version 파일을 열고(open .tuist-version), 사용하고자 하는 버전을 적은 뒤 저장한다. 이 글의 경우 3.33.3 을 입력했다. 딱 이 숫자만 적으면 된다.

항상 최신화할 수 있으면 좋겠지만, 글을 수정하는 현재 기준으로 TCA에 Swift 매크로를 적용한 버전과 함께 사용할 시 앱이 제대로 작동하지 않는 버그가 있다고 한다. 이를 포함한 여러 이슈에 대응해 버전을 고정하고자 하는 독자분의 경우 참고하시면 좋을 것 같다.

모듈피플 (Procreate로 직접 그림)

여는 말: Tuist는 왜?

필자가 2주쯤 전에 게시한 5개월간의 포스팅 계획 글에 최근 관심을 가지고 있는 키워드를 나열했다. 되짚어보자면…

  • Tuist 및 모듈화
  • Microfeatures Architecture
  • 접근성

Tuist와 모듈화라는 키워드가 직접 등장한다는 것만으로도 사실 충분히 학습해볼 만 한 동기는 있다. 하지만 왜? 이런 것들에 대해 관심을 가지게 된 것일까? 그냥 재미있어 보여서, 라고 접근하기에는 꽤 무거운 키워드라고 생각한다. 그래서 생각해 보았다. 왜 관심이 생겼는지.

모든 길은 Test로 통한다

이 키워드들에 관심을 가지게 된 이유는 다름 아닌 테스트 때문이었다.

먼저 모듈화의 경우, 큰 코드 베이스를 작은 코드 베이스로 쪼개면 테스트가 용이해진다는 생각에서 출발했다. 이전에도 MVVM이나 Clean Architecture 등에 고민하면서 하나하나의 코드 단위를 더 쉽게 테스트할 수 있게 하는 것에 집중했었다. 거기서 한 걸음 더 나아가 빌드의 단위마저도 분리해서, 하나의 기능과 관련되어 있는 앱만 실행해볼 수 있다면 어떨까? 하는 생각이었다.

마이크로피처 아키텍처는 이 아이디어를 구체화시킬 수 있는 수단이라고 생각했다. 아직 잘 모르지만, Source와 Interface, Tests, Testing, Example이라는 다섯 가지의 모듈 단위가 있다고 알고 있다. 테스트를 위한 모듈이 따로 안배되어 있는 것부터 필자가 생각하는 목적에 부합한다고 생각했다. 비록 아직은 Tests와 Testing이 어떤 차이가 있는지도 감을 덜 잡은 상태지만, 이후 학습해서 기록해볼 수 있을 것 같다.

접근성은 굉장히 재밌게도 UI Test에 도움이 된다고 해서 관심을 갖게 됐다. iOS 앱에서 UI Test를 진행하는 경우, accessibilityIdentifier를 활용해 유닛 테스트를 한다고 알고 있다. 접근성 관련 프로퍼티를 신경쓰다 보면 VoiceOver를 위시한 접근성 기능 지원에도 좀 더 충실할 수 있지 않을까. 마침 접근성에 대한 수요가 점점 대두되고 있기도 하고, 접근성이라는 키워드 자체가 생각보다도 더 많은 사람에게 도움이 될 수 있다는 것을 느꼈다. 이러한 생각에 대해서도 나중에 포스팅해볼 수 있을 것 같다.

이러한 이유들로 인해, 사실은 테스트와 관련된 키워드들이 필자의 머릿속을 채우고 있었던 것이다. 테스트를 거침으로써 앱의 신뢰도를 높일 수 있고, 이는 곧 필자가 개발한 서비스에 대한 만족과 나아가 필자의 개발자로서의 신뢰도를 높여줄 수 있을 것이라고 생각한다.

각설하고, 이 포스트는 Tuist를 전혀 모르던 사람이 나름의 모듈화를 시도해보기까지의 과정을 담고 있다. 기술 포스트의 길이를 Reading Time 10분 내외로 가져가겠다는 다짐은 벌써 깨질 위기에 처했다(…).

In a nutshell

새 술은 새 부대에, 새 코드는 새 저장소에!

필자의 첫 개인 앱으로 배포했던 “들어봄” 앱이 있다. 간단히 설명하면 음성을 통해 음악을 검색하고, 그 메타데이터를 토대로 다른 음원 사이트에서 다시 검색함으로써 어느 사이트에 이 음악이 있는지 알 수 있는 앱. 자세한 내용은 앱 스토어에 들어봄을 검색하면 보실 수 있다.

최근 이 앱을 업데이트하고자 마음먹었다. 1.3.1 버전까지 나름 성의있게 업데이트했지만, 업데이트를 하지 않은 지도 어느덧 1년 반이 넘었다. 남들이 쓸 생각을 잘 하지 않는 라이브러리(적어도 필자 주변에서는 그렇다)인 ShazamKit을 사용해본 경험을 계속 살리고 싶기도 했고, 또 관련된 아이디어가 몇 가지 떠오르다 보니 2.0.0으로 판갈이를 하면서 컨셉도 약간 바꾸고, 기능을 추가하기로 했다. 마지막 업데이트 이후로 필자가 성장한 만큼, 최대한 많은 것을 시도해 보기로 마음먹었다. 그러다 보니 단순 리팩토링을 하기보다, 새로이 만드는 것이 더 빠르겠다는 결론이 났다. 또 그러다 보니, Tuist 실험대에 올라갈 프로젝트로 낙점이 됐다.

일단은 설계를 (둥글게)

모듈화를 도입하기 전, 어떤 모듈이 필요할지 미리 생각해 보는 것이 당연하다. 마이크로피처 아키텍처에서 제안하는 모든 모듈을 지원하지는 않더라도, 필요에 따라 모듈과 모듈이 속한 그룹을 정해 보기로 했다. 모듈 설계 기준에는 지난 Let’ Swift 2023에서 당근과 29CM 앱의 모듈 구조에 대해 연사분들이 발표해 주셨던 내용을 참고했다.

아래에서부터 Shared, Core, Feature, App의 단계를 거치도록 설계했다. App은 릴리즈할 앱이며, Feature는 각각의 기능이다. 탭 바를 가진 앱으로 리뉴얼할 것이기 때문에, 각 탭에 해당하는 모듈이 Feature 모듈이라고 할 수 있다. Core는 각각의 Feature를 구현하기 위한 작은 기능들이다. 퍼스트 파티 API를 사용하면서 애플 서버에 요청을 보낸다거나 실제 웹 상에서 네트워킹을 수행한다거나 하는 것들이 포함된다. 마지막으로 Shared는 모든 모듈에서 의존해 사용할 요소들로 구성했다. 예를 들어 서비스 관리에 필요한 상수 값들을 저장하는 네임스페이스가 이곳에 자리할 수 있다.

각각의 모듈은 아래에 있는 모듈에 의존한다. 그러니까 아래에 있을 수록 기반에 가깝고, 위로 올라갈수록 고수준의 로직을 구현한다고 볼 수 있겠다.

FigJam에서 간단히 설계해보았다

tuist init

Tuist는 기본적으로 콘솔 위에서 돌아가는 툴이다. 그래서 설계한 구조대로 레포지토리를 만들려면, 우선 콘솔을 켜야 한다. 콘솔에서 원하는 디렉토리로 이동한 후 아래 명령어를 입력해 준다.

tuist init --platform ios --template swiftui

# UIKit 프로젝트를 만들 경우
tuist init --platform ios

굉장히 직관적인 커맨드라고 생각한다. 이 커맨드를 사용해 특정 디렉토리를 tuist를 위해 init한다면, 이제 tuist로 프로젝트를 만들 준비가 된 것이다. init 직후의 상태는 아래와 같다.

처음 Tuist를 써 보는 독자시라면, 이게 도대체 무슨 말인지 너무 헷갈릴 것이다. 적어도 필자는 그랬다. 각각의 소스를 열어 보고 무슨 말인지 알아들어 보려고 각고의 노력을 기울였다. 이 궁금증은 각각의 모듈에 들어가는 요소와 Xcode 프로젝트 상에서 등장하는 요소를 일대일 매칭을 어느 정도 할 줄 알게 되어서야 해결할 수 있었다.

아 그리고, 디렉토리를 처음 초기화할 때 git init과 .gitignore의 추가도 잊지 않고 해 주면 좋다.

tuist edit

뭔가 swift 파일들이 생겼다. 그런데 모듈이랄 만 한 것은 딱히 보이지 않는다. 사실 이대로 바로 쓸 수 있는 것이 아니다! tuist edit을 통해 어느 정도 설정해주어야 한다.

tuist edit
tuist edit --permanent

이 명령어는 Manifests.xcworkspace 임시 프로젝트를 만들고 로드한다. Manifests 프로젝트 안에는 Plugins 타겟과 Manifests 타겟이 있다. 임시 프로젝트기 때문에 닫았을 경우 사라지며, 만약 남겨두고 싶다면 permanent 명령어를 더해주면 된다. 단 공식 문서에 따르면 Manifests 프로젝트를 git과 같은 버전 관리 시스템에 포함시키는 것은 매우 권장하지 않는다고 한다.

Plugins 타겟은 tuist 사용 과정에서 쓸 수 있는 “플러그인”을 추가할 수 있는 타겟이다. 프로젝트 생성 단계에서 추가적으로 수행할 수 있는 동작을 정의할 수 있다. 아래에 작성할 Template 작성 등의 작업이 좋은 예시이다.

Manifests 타겟은 프로젝트의 구조와 설정을 정의하는 Project.swift 파일을 가지고 있고, 실제 프로젝트를 생성할 때 참고한다. 한편 마이크로피처를 구현하기 위해 여러 개의 Project.swift 파일을 가지고 있다면, Workspace.swift 파일을 통해 각각의 Project.swift 파일을 인식해서 각각의 모듈(타겟)으로 빌드하게 된다. 각각의 Project.swift 파일은 각각의 모듈을 대표하는 것이다. 그래서 tuist init을 한 이후에 tuist edit을 거치지 않고 하술할 tuist generate를 수행하면 일단은 화면이 나오고 동작하는 것을 확인할 수 있다. 단지 모듈이 하나인 앱이 등장할 뿐.

그렇다면 이제 해야 할 일은 명확하다. Workspace.swift를 작성하고, 편리한 모듈 설정을 위해 Plugin 타겟에 makeAppModule, makeFeatureModule 등의 메서드를 작성하는 것.

tuist init을 수행한 바로 그 디렉토리에서 Workspace.swift 파일을 먼저 만든다. 기왕 콘솔을 켰으니까 멋지게 해 보도록 하자.

touch Workspace.swift

Workspace 파일의 역할은 상술했듯 여러 개의 Project.swift 파일을 각각의 모듈로 인식해서 generate시 프로젝트를 만드는 데 반영하는 것이다. 그래서 워크스페이스의 이름과 프로젝트가 어디에 들어 있는지, 이 두 가지를 정해 주어야 한다.

// Workspace.swift

import ProjectDescription

let workspace = Workspace(
name: "HeardItBefore",
projects: [
"Projects/**"
]
)

필자는 Projects라는 디렉토리를 만들고 그 안에서 프로젝트들을 관리하고자 한다. 그렇기 때문에 워크스페이스 이름과 프로젝트 폴더를 이렇게 설정해 주었다. 이후 실제로 해당 디렉토리를 작성한 다음, 프로젝트 파일들을 넣어 줄 것이다.

다음으로 tuist edit을 통해 Manifests 프로젝트를 로드한다. Manifests 타겟에서 Tuist — ProjectDescriptionHelpers 디렉토리를 보면 Project+Extensions라는 파일이 있다. 모듈을 쉽게 만들기 위한 편의성 메서드를 만들겠다고 했지만, 사실 Tuist에서도 기본적으로 어느 정도 제공해 준다. 다만 팀별로 모듈을 관리하는 방법론이 다르기 때문에, 이 코드를 삭제하고 커스텀하게 모듈 작성 메서드를 만들어 사용하는 경우가 많다. 필자 또한 이러한 방식을 사용할 생각.

대략의 코드는 다음과 같다.

    static func makeAppModule(
name: String,
bundleId: String,
platform: Platform = .iOS,
product: Product,
organizationName: String = "Gordon Choi",
packages: [Package] = [],
deploymentTarget: DeploymentTarget? = .iOS(
targetVersion: "16.0", devices: [.iphone]
),
settings: Settings,
dependencies: [TargetDependency] = [],
sources: SourceFilesList = ["Sources/**"],
resources: ResourceFileElements? = nil,
infoPlist: InfoPlist = .default,
entitlements: Path? = nil,
schemes: [Scheme] = []
) -> Project {
let appTarget = Target(
name: name,
platform: platform,
product: product,
bundleId: bundleId,
deploymentTarget: deploymentTarget,
infoPlist: infoPlist,
sources: sources,
resources: resources,
entitlements: entitlements,
scripts: [],
dependencies: dependencies
)

let targets = [appTarget]

return Project(
name: name,
organizationName: organizationName,
packages: packages,
settings: settings,
targets: targets,
schemes: schemes
)
}

메서드에 있는 각각의 파라미터들은 Tuist가 Project를 만들기 위해 필요한 요소들이다. 이름이나 번들 아이디, 플랫폼, 프로덕트의 종류, … 보다 보면 피로한 내용이지만, 사실 Xcode 프로젝트의 코드가 아닌 프로젝트 설정 부분에서 하나씩 찾아볼 수 있는 내용들이다. 이 중 Sources로 지정해 준 폴더 안에 있는 것이 바로 “소스 코드”! 앱에서 쓰는 코드를 소스 코드라고 불렀었지, 라는 생각이 번뜩 들면서 ‘이렇게 설계하면 코드는 어디에 써야 하는 거야?’ 라는 의문이 말끔히 풀렸다.

앱 타겟과 프레임워크(혹은 스태틱 라이브러리) 타겟을 구분지어서 앱과 피처를 구현한다. 피처는 라이브러리로 만들어서 구현하고, Example 앱이나 진짜 앱은 이 라이브러리를 import한 다음 기능을 사용할 수 있다. 피처 타겟은 또한 테스트 타겟을 포함하여, 각각의 피처에 대해 원활한 테스트를 실행할 수 있게끔 구현한다.

이제 만든 메서드를 활용해보기로 한다. Projects 디렉토리 안에서 설계할 때 정의했던 layer별로 폴더를 만들고, 각각의 폴더 안에 Project.swift 파일이 있어야 한다. 필자는 Sources/App 디렉토리 아래에 HeardItBefore라는 이름으로 앱 타겟을 만들어 보았다.

import ProjectDescription
import ProjectDescriptionHelpers
import MyPlugin

let localHelper = LocalHelper(name: "MyPlugin")

let project = Project.makeAppModule(
name: "HeardItBefore",
bundleId: .appBundleID(name: "HeardItBefore"),
product: .app,
settings: .settings()
)

번들 아이디 같은 경우 static하게 작성해 주는 메서드를 활용했기 때문에 저렇게 쓸 수 있다. 번들 아이디는 보안 이슈도 있기 때문에 양해 부탁드린다..

이렇게 Project 프로퍼티를 가지고 있는 Project.swift 파일을 Tuist가 감지해서 하나의 모듈을 만들어 주는 것이다. 모든 모듈에 대해 동일한 작업을 수행해 주면 된다.

tuist generate

tuist generate

tuist generate 커맨드는 Manifests에서 정의한 내용을 바탕으로 실제 작업할 수 있는 파일을 만든다. Tuist의 메인 기능 중 하나로, Project.swift나 Workspace.swift 등의 Manifest 파일들을 읽어 들임으로써 프로젝트의 의존성 그래프를 파악하고, Xcode의 프로젝트나 워크스페이스로 만든다. 만약 다루는 프로젝트가 커서 generate를 통해 모두 만드는 것이 어렵다면, 일부 모듈만 generate할 수도 있다.

tuist generate CoreApp

위에서 정의한 워크스페이스와 프로젝트 파일을 기반으로 앱 모듈이 만들어질 것이다.. 라고 생각했다. 그런데!

The target HeardItBefore has the following invalid source files globs:
- The directory "{있어야 하는 디렉토리}" in the glob pattern "{사용자}/HeardItBefore/Projects/Sources/**" does not exist.
Consider creating an issue using the following link: https://github.com/tuist/tuist/issues/new/choose

소스 파일 경로로 지정한 폴더가 존재하지 않는다는 에러 메시지가 나왔다. Tuist는 정말 마법 같은 기능을 수행하지만, 폴더를 자동으로 만들어 주지는 않는 것이다! 앞선 과정에서 Project.swift를 작성해 줄 때도 의문을 가진 독자분이 계실 것이다. 폴더를 나누는 과정, 폴더 안에 Project.swift를 생성하고 작성하는 과정 전부 손수 해 줘야 한다! 당연히 Sources 폴더도 그렇다.

일단은 시키는 대로, Sources 폴더를 만들었다. 그리고 다시 generate해 보기로 한다. 오! 이제는 뭔가 만들어져서 프로젝트로 나왔다. 앗 그런데! 분명 Sources 폴더를 만들었는데 모듈 구조 안에는 해당 폴더가 보이지 않는다!

내 소스 폴더 어디갔어!!

만약 설마, 라고 생각하신 독자분이 있다면 아마 그 설마가 맞을 것이다. Sources 폴더 안에 임의의 .swift 소스 코드 파일이 있어야 모듈에서 이를 인식하고 모듈 내의 디렉토리 구조에 포함시킨다. 그리고 이것은 또 손수 만들어 주어야 한다. 해당 Sources 디렉토리로 이동한 다음, 아무 Swift 파일을 또 만들어 주자. 그리고 나서 generate를 수행하면?

여기있네~

드디어 Sources 폴더를 잘 인식한다.

이 타겟은 App 타겟이므로, 이대로는 빌드가 안 되는 것은 마찬가지이다. iOS 앱의 빌드 과정을 잘 생각해보면, 앱이 실행될 때는 main 애트리뷰트를 찾아간다고 했다. Xcode상에서 새 프로젝트를 생성할 때는 AppDelegate든 App이든 자동으로 붙어서 나오기 때문에 간과하기 쉬운 사실. SwiftUI 프로젝트이므로, 기본적인 App과 ContentView를 만든 다음 연결해 보겠다.

// HeardItBeforeApp.swift
import SwiftUI

@main
struct HeardItBeforeApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

// ContentView.swift
import SwiftUI

struct ContentView: View {
var body: some View {
Text("Hello, World!")
}
}

이렇게 했더니, 우와! 드디어 화면이 나왔다. 그런데…

무언가 이상한 점을 눈치채셨는지?

화면 위아래가 잘려서 나오는 이유는, Info.plist에서 원래 정해주는 Launch Screen 값이 없기 때문이다. 사실 LaunchScreen 말고도 서비스에 따라 다른 여러 가지 설정을 Info.plist에서 해 주어야 하고, 이것은 모듈 전체를 통틀어 일관되게 유지될 필요가 있다. 그렇기 때문에 미리 Info.plist 파일을 준비해 두고, 저장소의 일정 공간에 보관한 다음 모듈 생성시 가져오는 것이 편하게 관리할 수 있는 방법일 수 있다. 한편 미리 프로퍼티를 몇 가지 정해 두고 그에 관련한 값을 지정해서 하는 방법도 있다.

필자는 미리 준비해 둔 Info.plist를 활용하기로 했다. makeAppModule 메서드에 infoPlist 파라미터가 있었던 것을 떠올릴 수 있다. 바로 그 파라미터에 사용하고자 하는 파일 혹은 코드를 입력해주면 된다.

import ProjectDescription
import ProjectDescriptionHelpers
import MyPlugin

let localHelper = LocalHelper(name: "MyPlugin")

let project = Project.makeAppModule(
name: "HeardItBefore",
bundleId: .appBundleID(name: "HeardItBefore"),
product: .app,
settings: .settings(),
infoPlist: .file(path: "../Support/Info.plist")
)

이렇게 하면 Projects/Support/Info.plist 파일을 가져와서 사용한다. .file 형식에 대해서는 scaffold 파트에서 후술하도록 하겠다. 아무튼 이렇게 하면!

광활~

드디어, Tuist를 활용해 첫 모듈을 작성하고, 이후에 추가할 모듈들에 대한 기반 구조도 갖추었다. 라이브러리 모듈 또한 비슷한 방식으로 생성한 다음, 앱 모듈이 의존하게끔 해 주면 된다. 비슷한 방식으로 또..?

tuist scaffold

망설여지는 이유는 아무래도 손으로 해 줘야 하는 것이 너무 많다고 느껴져서인 것 같다. 지금까지 손수 Project.swift 파일을 작성해 주었고, Sources 디렉토리와 빈 swift 파일도 만들어 주었다. 몇 번 정도는 괜찮겠지만, 모듈을 만들 때마다 이런 작업을 반복한다고 생각하면 조금 아찔하다.

다행히 Tuist에는 이런 경우를 대비한 기능이 안배되어 있다. 바로 scaffold 기능인데, 이번에는 커맨드부터 쓰지 않고 템플릿 파일부터 만들어 볼 것이다.

템플릿 파일은 Project 파일처럼 Tuist에서 제공하고 컨트롤하는 파일이다. 이를 활용하면 모듈을 만들 때 명령어 한 줄 만으로 자동으로 만들 수 있지만, 이를 활용하기 위해서는 엄밀한 경로 설정이 중요하다. Tuist 디렉토리에 들어가서 Templates라는 디렉토리를 먼저 만든다. 그 다음 Templates 디렉토리 아래에서 만들고자 하는 템플릿의 이름을 가진 디렉토리를 다시 만들고, 그 아래에 템플릿 이름과 똑같은 이름의 .swift 파일을 만들면 된다. app이라는 이름의 템플릿을 만들고 싶다면, Tuist/Templates/app/app.swift 파일을 만들어야 한다는 뜻.

그러면 이 app.swift 파일은 어떻게 채워야 할까? 필자가 작성한 예시 코드를 첨부한다.

import Foundation

import ProjectDescription

let appName: Template.Attribute = .required("name")
let appPlatform: Template.Attribute = .optional("platform", default: "ios")

let template = Template(
description: "New App module template",
// 여기서 뭘 받느냐에 따라 커맨드가 바뀐다.
attributes: [
appName,
appPlatform
],
// scaffold를 했을 때 생성될 앱을 미리 정의해서 빼 두었다.
items: AppTemplate.allCases.map { $0.item }
)

enum AppTemplate: CaseIterable {
case app
case contentView
case project
case launchScreen

var item: Template.Item {
switch self {
case .app:
return .file(path: .appBasePath + "/Sources/\(appName)App.swift", templatePath: "App.stencil")
case .contentView:
return .file(path: .appBasePath + "/Sources/ContentView.swift", templatePath: "ContentView.stencil")
case .project:
return .file(path: .appBasePath + "/Project.swift", templatePath: "Project.stencil")
case .launchScreen:
return .file(path: .appBasePath + "/Resources/LaunchScreen.storyboard", templatePath: "LaunchScreen.storyboard")
}
}
}

extension String {
static var appBasePath: Self {
return "Projects/App/\(appName)"
}
}

상기 예시에서 appName과 appPlatform을 애트리뷰트로 받는 것은, scaffold 커맨드를 입력했을 때 어떤 인자를 추가로 요구할지를 나타낸다.

items 안에 들어가는 .file에 대해서는, Tuist 안에서 파일을 다루는 방식이라고 할 수 있을 것이다. .string, .file, .directory의 세 가지가 있다.

  • .string의 경우 path, contents 두 가지 파라미터를 받으며, path는 파일이 생성될 경로를, contents는 String으로 된 내용을 적을 수 있다.
  • .file의 경우 path와 templatePath 파라미터를 받는다. path에 templatePath에 해당하는 파일을 복사해서 넣는다, 단, templatePath에 있는 파일이 .stencil 파일이라면 내부적으로 파싱을 해서 처리한 다음 넣는다. .stencil 파일은 Swift를 위한 Template 언어라고 한다.
  • .directory의 경우 path와 sourcePath 파라미터를 받으며, sourcePath 아래에 있는 모든 디렉토리 구조를 path에 복사 붙여넣기 한다.

필자가 사용한 방식은 앱 모듈을 만들기 위한 최소한의 요소만을 사용하기 위해 App, ContentView, Project 그리고 LaunchScreen 파일만 스텐실을 활용해 불러오도록 설정했다.

스텐실 파일이 어떤 것인지 간단히 보여드리자면, 이렇다.

// App.stencil

import SwiftUI

@main
struct {{ name }}App: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

거의 Swift와 똑같다! 단지 아까 받는 애트리뷰트인 appName이 여기에는 name으로써 해석되어 들어가게 될 뿐. 예시와 같은 경우는 HeardItBeforeApp이 될 것이다.

이렇게 셋업한 템플릿에는 아래와 같은 파일이 포함되어 있다. 디렉토리 이름을 소문자로 정한 이유는, 그렇게 하는 것이 콘솔에서 입력할 때 편해서 그렇게 했다.

오랜 기다림이었다. 먼저 아까 만들어 두었던, App 폴더 아래에 있던 모든 것을 지웠다. scaffold의 매력은, Project와는 다르게 디렉토리 경로를 찾아가는 과정에서 없는 디렉토리가 있다면 자동으로 만들어준다는 데 있다.

App 디렉토리를 확장했지만 아무것도 들어 있지 않다

그리고 커맨드를 입력해 본다. 아까 name은 필수, platform은 선택사항으로 두었었다. 그렇다면 실제로 콘솔에 입력할 때는 name 애트리뷰트만 입력해주면 된다.

tuist scaffold --name HeardItBefore

파일이 자동으로 생기는 것이 보인다. 지체없이 tuist generate한다. 그리고 실행 가능한 앱 모듈이 로드된 것을 확인할 수 있다. 동일한 방식으로 라이브러리 템플릿도 정할 수 있다.

UIKit 프로젝트를 수행할 경우

iOS 앱은 구동시 main 애트리뷰트를 찾아간다고 앞에서 설명했다. SwiftUI는 App 구조체에 이것이 붙어 있다. UIKit 기반 앱에는 AppDelegate에 이것이 부여되어 있다. 그래서 UIKit 기반으로 프로젝트를 수행할 경우 AppDelegate, SceneDelegate 등이 기본적으로 필요하며 또한 기본 뷰 컨트롤러를 정해 주어야 실행 가능한 앱 타겟을 만들 수 있다. 또 xcassets도 미리 추가해 주어야 한다. 이 모든 것을 스텐실로 만들어 두어야 하기 때문에, SwiftUI 앱보다는 좀 더 손이 많이 간다.

이전에 UIKit 기반으로 연습할 때 작성한 템플릿

한편 이렇게 설정해서 구동시켜도 화면이 안 나오는 경우가 있다. UIKit 기반 앱의 경우 SceneDelegate를 활용한 Multi-Scene 앱을 만들 경우 Info.plist에서 Application Scene Manifest — Enable Multiple Windows를 YES로 하지 않으면 화면이 뜨지 않는 불상사가 일어날 수 있다. 그렇기 때문에 필자는 UIKit, SwiftUI 프로젝트를 위한 Info.plist 파일을 각각 따로 가지고 있다. 매번 프로젝트 시작할 때마다 써먹을 생각으로.

Feature도 만들어서 붙여볼까

비슷한 방식으로 라이브러리로 된 피처도 만들었다. 피처 라이브러리는 오히려 UIKit 프로젝트와 SwiftUI 프로젝트에서 만드는 방법이 대개 비슷해서 편하다.

tuist scaffold module --name HelloFeature
module 템플릿 설정
scaffold를 통해 만든 Feature 모듈

이를 의존하고 싶다면, 해당 앱의 Project.swift 파일에서 Dependency를 추가해주면 된다.

import Foundation

import ProjectDescription
import ProjectDescriptionHelpers
import MyPlugin

let localHelper = LocalHelper(name: "MyPlugin")

let project = Project.makeAppModule(
name: "HeardItBefore",
bundleId: .appBundleID(name: ".HeardItBefore"),
product: .app,
settings: .settings(),
dependencies: [
.project(target: "HelloFeature", path: .relativeToRoot("Projects/Feature/HelloFeature"))
],
sources: ["Sources/**"],
resources: ["Resources/**"],
infoPlist: .file(path: "../../Support/Info.plist")
)

relativeToRoot을 사용해 루트로부터 경로를 찾아 가서 피처에 해당하는 xcodeproj 파일을 의존하게 했다. 저 경로를 입력할 때도 휴먼 에러가 발생하기 쉬운 만큼, Manifests 프로젝트에서 TargetDependency.Project를 확장한 다음 네임스페이스에 각 모듈의 경로를 미리 저장해 둔다면 오류가 덜할 것이다. 이것까지도 자동화하는 방법이 있을 지 모르지만, 아직은 필자의 지식 범위 밖이다.

이렇게 했을 때 메인 앱에서는 이런 식으로 활용할 수 있다. tuist generate를 해 본다.

각각의 모듈을 분리해 개발할 수 있다
// HelloFeature 모듈
// BaseObject.swift

import Foundation

// 접근제어자에 주의하자 - 둘 다 public이어야 앱 모듈에서 인식하는 상황을 겪었다
public class HelloFeature {
public static func hello(_ appName: String) -> String {
return "Hello, \(appName)!"
}
}
// HeardItBefore App 모듈
// ContentView.swift

import SwiftUI
import HelloFeature

struct ContentView: View {
var body: some View {
Text(HelloFeature.hello("HeardItBefore"))
}
}
오늘의 결과물

이렇게 해서, Tuist를 활용해 새로운 프로젝트를 간단히(!!) 셋업하는 과정에 대해 알아보았다. 테스트 타겟의 설정, 그리고 외부 라이브러리 의존에 대해서는 이어지는 글에 써 볼 것이다. 지금 분리되어 있는 모듈 구조만 해도 각각의 모듈에 대해 독립적으로 테스트를 진행할 수 있으리라는 것이 자명해서, 타겟을 추가하는 것만 익히면 관리가 매우 용이한 프로젝트로 거듭날 수 있을 것 같다.

닫는 말: 더 좋은 앱은 무엇일까?

근래 필자는 좋은 앱을 표현하는 키워드로 “신속성”을 자주 언급하곤 한다. 무엇에 대한 신속성인가? 바로 감이 오지 않을 수 있다. 필자가 추구하는 신속성은 사용의 신속성, 그리고 개발의 신속성이다.

먼저 사용의 신속성은 사용자가 앱을 통해 원하는 바를 빠르게 이룰 수 있는 것을 나타낸다. 당연히 앱 자체의 퍼포먼스가 포함된다. 한편 사용자 경험에 대한 고려와 접근성 또한 사용의 신속성의 범주에 들어간다고 생각한다. 편리한 UI/UX는 사용자가 원하는 것을 더 빨리 찾아낼 수 있도록 하고, 접근성의 지원을 통해 어떠한 상황에서도 원하는 것을 수행할 수 있게 만들 수 있기 때문이다.

개발의 신속성은 서비스를 개발 혹은 고도화하고자 할 때 효율적으로 개발할 수 있는 것을 말한다. 대표적인 예시로 프로젝트의 구조를 들 수 있다. 구조에 대한 고민 없이 일단 구현한 프로젝트는 이후 유지보수에 어려움을 겪을 가능성이 높다. 서비스가 점점 커지고 코드 간의 의존성이 복잡해지다 보면, 추후 기능을 추가할 때 해당 태스크와 관련 없는 부분의 코드를 수정할 일이 생길 수도 있다. 또 기존 기능을 수정하고자 할 때 어디서부터 어떻게 수정해야 의도한 대로 작동할지도 파악하기 어려울 수 있다.

테스트가 용이한 구조에 대해 고민하다 보면, 둘 다 어느 정도 이룰 수 있다고 느낀다. 테스트 자체는 앱의 신뢰성을 끌어올림으로써 사용의 신속성을 달성하는 데 도움을 줄 수 있다. 또 각각의 기능이 테스트가 용이하게끔 구조를 설계하는 과정에서 코드 간의 의존성을 느슨하게 만들게 되고, 이는 곧 새로운 기능의 추가나 기능의 수정을 용이하게 만든다. 건드려야 할 코드의 범위가 명확해지고 또 좁아지기 때문.

위 사례를 통해 생각해 보면, 사용의 신속성과 개발의 신속성이 보장되는 것은 좋은 앱을 만들기 위한 조건이다. 테스트가 용이한 구조에 대해 고민하는 것은 좋은 앱을 만드는 초석이 된다는 것. 그리고 그 중 하나의 방법으로 Tuist를 활용한 모듈 구조를 짜 보는 것이 있겠구나, 하는 것. 이런 것들을 느낄 수 있다.

References

https://ios-development.tistory.com/275

이분 방송을 시청하고 그런 건 아니지만 이 곡은 재밌더군요

--

--