프로젝트를 모듈로 나눠본 적이 없었습니다. 비공개 CocoaPods로 플레이어를 만든 적은 있지만요. 이미 많은 Pods들이 deprecated되고 있다는 것을 아마 아실 텐데요, 그런 경우 SPM를 사용해야 합니다.
이번에 저는 Tuist에서 데이터베이스 모듈을 하위 프로젝트로 분리 하는 것에 도전했습니다.
현재 폴더 구조를 보면,

Sources 폴더는 Providers, Containers, Dependencies … 등을 가졌습니다.
Core - Model
TCA는 Module들의 기능을 호출하고 그들 사이의 Model들을 사용합니다. 그래서 먼저 공유된 Model을 모듈화 하기로 했습니다.
주요 공용 코드들을 Core 폴더로 옮겼습니다.

여기에 새로운 Tuist 프로젝트를 만들었더니, Project.swift가 두개 되었습니다. 하나는 Core 프로젝트의 것이고, 다른 하나는 최상위 프로젝트를 위한 것입니다.

이 Manifest 파일의 모든 프로젝트 이름과 Bundle ID에 접미사를 추가하고,

Core Project를 generate 했습니다.

이렇게 추가된 Core 프로젝트를 App 프로젝트에서 접근하기 위해 Project.swift에 추가했고
let project = Project(
packages: [
...,
.package(path: .relativeToCurrentFile("Modules/Core"))
],
targets: [
.target(
...,
dependencies: [
...,
.package(product: "...Core", type: .runtime)
],
다시 generate 했을 때 패키지 목록에서 확인할 수 있었습니다.

하지만 import 할 수는 없었죠.

Workspace
Workspace 없이는 프로젝트 간 import 할 수 없기 때문에 Workspace 구성을 해야 했습니다. 여기에서 Workspace 예제를 볼 수 있습니다.
먼저, 최상위 경로에 Workspace 파일을 만들고

Workspace를 제외한 모든 파일들을 Projects/App 폴더로 이동했습니다.


프로젝트 이름을 단순화하기 위해 앱 이름도 제거했습니다.

generate를 다시 하니 Projects 폴더 아래에 App과 Core 프로젝트가 나타났습니다.

생성된 Core 프로젝트 아래로 모듈에 속한 소스 파일들을 옮겨줬습니다.

이제 Core framework를 import 할 수 있지만 아직 그 안의 Type들은 사용할 수 없습니다.

이런 경우는 Type이 다른 프로젝트에 공개되어있지 않은 것이기 때문에 공개해줘야 합니다.
@Model
public final class QBCharacter {
Type이 중첩된 상태라면 중간 Type들도 당연히 공개해줘야 하죠.
public var elementalType: ElementalType
이렇게 필요한 모든 Type들을 공개해 줬는데도 여전히 사용할 수가 없었습니다. 왜일까요?
이유는 앱이 Framework를 포함하고 있지 않기 때문입니다.

그래서 다시 Workspace manifest를 수정해줘야 했습니다.
Core 프로젝트 의존성을 App 프로젝트 manifest에 추가시켜 주자..
dependencies: [
...,
.project(target: "....Core",
path: .relativeToManifest("../Core"))
],
App 프로젝트에서 Core framework를 확인할 수 있었고

빌드도 성공했습니다.
tuist build
tuist graph

Repository - Database
제 앱은 DatabaseProvider를 통해서 데이터베이스를 사용했었습니다. 그래서 이번에는 그걸 Repository 프로젝트로 옮기고자 합니다.
먼저 Repositories 아래에 Local 프로젝트를 만들었습니다.
tuist init — platform ios -n Local

그리고 데이터베이스 관련 소스들을 생성한 프로젝트로 이동시켰죠.

마지막으로 App에 repository 프로젝트를 포함시켰습니다.
dependencies: [
...,
.project(target: "....LocalRepository",
path: .relativeToManifest("../Repositories/Local"))
],
Provider라는 이름으로 사용할 것이기 때문에, 이름을 DatabaseRepositoryProvider로 바꾼 후 Protocol을 만들고,
public protocol DatabaseRepository{
func fetchCharacter(where keyword: String) throws -> [QBCharacter]?
func insert<Model>(_ model: Model) where Model : PersistentModel
}
extension DatabaseRepositoryProvider : DatabaseRepository
Dependency Type을 새로 만든 Protocol로 바꿨습니다.
private enum DatabaseDependencyKey: DependencyKey {
@MainActor
static var liveValue: DatabaseRepository
= DatabaseRepositoryProvider.init(...)

Store - search
TCA에선 각 모듈 이름을 Feature로 명명하곤 합니다만, 저는 Store라고 이름 지었습니다. Type이 StoreOf이기 때문이죠.
@Bindable var store: StoreOf<CharacterSearchStore>
만약 기능이 view와 store를 둘 다 가져야 한다면, 접미사로 feature를 붙일 것입니다.
이 Store를 모듈화 하는 것은 이전 모듈들보다 복잡합니다. DependencyKey와 다른 Store들을 사용하기 때문인데요.
ModelContainer
DependencyKey는 ModelContainer를 사용하기 때문에 그걸 위한 interface도 만들어줘야 합니다.
private enum DatabaseDependencyKey: DependencyKey {
@MainActor
static var liveValue: DatabaseRepository
= DatabaseRepositoryProvider.init(container: ModelContainer.generate())
그래서 먼저 Shared project를 만들고 ModelContainer.generate를 그 아래 Sources로 옮겨줄 겁니다.
DependencyKey
Store에서 database를 사용하려면, Dependency Key도 Shared 프로젝트 안에 있어야 합니다. 하지만 TCA를 import 하지 않고서는 Xcode가 Shared 프로젝트를 컴파일할 수 없었습니다.
let project = Project(
name: "Shared",
packages: [.remote(
url: "https://github.com/pointfreeco/swift-composable-architecture",
requirement: .upToNextMinor(from: "1.9.2"))],
targets: [
.target(
...,
dependencies: [.project(target: "....Core",
path: .relativeToManifest("../Core")),
.package(product: "ComposableArchitecture", type: .runtime)]
이제 Shared 프로젝트는 빌드할 수 있지만 컴파일러는 App 프로젝트를 빌드하는 동안 DependencyKey 찾지 못한다고 합니다.

그래서 구글링을 하고 여러 Repository들을 참고해서 방법을 찾았습니다. 바로 하위 프로젝트에서는 이렇게 DependencyValue와 TestDependencyKey만 포함하는 겁니다.
extension DatabaseDependencyKey: TestDependencyKey {
@MainActor
public static var testValue: DatabaseRepository = ...
}
public extension DependencyValues {
var database: DatabaseRepository {
이렇게 하자 Xcode가 이번에는 TestDependencyKey 관련된 불평을 합니다.

왜인지는 잘 모르겠지만 Shared 프로젝트를 static framework로 바꿔서 해결되었습니다. (더 좋은 방법 있으면 알려주세요 🙏)
targets: [
.target(
name: "....Shared",
destinations: .iOS,
product: .staticFramework,
이렇게 해결이 되자 ThirdParty 프로젝트가 TCA를 포함할 때 staticFramework라면, ThirdParty만 포함하면 Shared에서 TCA도 사용할 수 있지 않을까 생각했습니다.

AddStore
이제 SearchStore 프로젝트를 사용할 수 있게 되었지만, 하위에 AddStore도 가집니다.
@Reducer
struct CharacterSearchStore {
@ObservableState
struct State: Equatable {
...
@Presents var addCharacterState: AddCharacterStore.State?
}
그래서 AddStore라는 static framework를 만들고 AddCharacterStore와 내부에 State와 Action 또한 만들었습니다. 추가로 공개된 생성자도 만들어줬습니다. 아마 여러분은 @MemberwiseInit(.public) 같은 macro 라이브러리를 사용할 수도 있겠죠 😎
@Reducer
public struct AddCharacterStore {
@ObservableState
public struct State: Equatable {
public var ...
public init(...) {
...
}
}
public enum Action {
case ...
}
public init(){}

SearchStore
이런 방법으로 마침내 이 글에서 하고자 하는 최종 프로젝트를 만들 수 있었습니다.
import ComposableArchitecture
import ...HelperCore
import ...HelperLocalRepository
import ...HelperShared
import AddCharacterStore
@Reducer
public struct CharacterSearchStore {
@ObservableState
public struct State: Equatable {
...
@Presents
public var addDateState: AddDateStore.State?
public init(...) {
...
}
}
public enum Action {
...
}
@Dependency(\.database) var database
public init() {
}

단순화하기
Dependencies
위에서 보신 것처럼 의존성에 중복이 있습니다. Shared 프로젝트를 가져온다면, Core 프로젝트를 가져올 필요가 없어지고. Store 프로젝트들은 Shared 프로젝트를 가져다 쓰기 때문에 그것도 필요 가져올 필요가 없어집니다.

이제 App은 Store들만 의존할 수 있게 되었습니다.
dependencies: [
.package(product: "LSExtensions", type: .runtime),
.package(product: "RoundedBorder", type: .runtime),
.project(target: "AddCharacterStore",
path: .relativeToManifest("../Stores/AddCharacter")),
.project(target: "SearchCharacterStore",
path: .relativeToManifest("../Stores/SearchCharacters"))
],
Project Description Helper
이제 Tuist manifest들은 많은 프로젝트를 가지게 되어서 더욱 복잡해졌습니다. 하지만 보시는 것처럼 Swift로 manifest를 만들었기 때문에 아래처럼 우리만의 축약된 방식을 만들 수 있습니다.
extension TargetDependency {
class Stores {
static let addCharacter : TargetDependency
= .project(target: "AddCharacterStore",
path: .relativeToRoot("Projects/Stores/AddCharacter"))
}
}
어떻게 하면 이 extension을 모든 프로젝트에서 사용할 수 있을까요? Tuist는 Project description helper를 제공합니다. Project에 대한 extension을 공유하고 싶으면, Tuist/ProjectDescriptionHelpers 폴더로 옮기고 ProjectDescriptionHelpers를 import 하기만 하면 되죠.

Tuist 폴더가 없으면 걱정하지 마세요 그냥 만들면 됩니다.

Extension 파일을 만들었지만, ProjectDesriptionHelpers를 import 할 수 없습니다. 이럴 때는 다시 tuist edit를 실행해야 해요, 그럼 Xcode에서 ProjectDesriptionHelpers Framework를 볼 수 있습니다.

Helper Framework를 import 한 후에도, Xcode는 오류를 뱉을 겁니다. 왜냐면 우리의 extension이 framework에 탑재되었기 때문이죠, project manifest에서 사용하려면 반드시 extension을 public으로 설정해야 합니다.
public extension TargetDependency {
class Stores {
public static let addCharacter : TargetDependency
= .project(target: "AddCharacterStore",
path: .relativeToRoot("Projects/Stores/AddCharacter"))
}
}
이것도 마찬가지로 tuist edit를 다시 실행하지 않으면 오류가 없어지지 않습니다. 이제 코드가 보기에 더 좋아졌네요.

Tuist는 Template도 제공합니다만, 그것까지는 다루지 않을게요. 이미 좋은 프로젝트에 관한 많은 글들이 있어서, Tuist를 사용해서 Store를 하위 프로젝트로 방법에 대해서만 초점을 맞췄습니다.
도움이 되셨다면, 좋아요를.. 더 많은 iOS 관련글을 읽어 보세요.
저와 더 얘기하고 싶다면, LinkedIn으로 오세요. 감사합니다!
문제해결
Cannot find type … in scope
type 선언에 public 추가.
Property ‘id’ must be declared public because it matches a requirement in public protocol ‘Identifiable’
public var id
Property cannot be declared public because its type uses an internal type
중첩된 type도 public으로
Couldn’t find target ‘
Target의 의존성과 프로젝트의 이름을 일치시키세요.
Property ‘…’ must be declared public because it matches a requirement in public protocol ‘Reducer’
State, Action, Body를 만들어주세요
initializer is inaccessible due to ‘internal’ protection level
public init(){ }
참고자료
'Programming > iOS' 카테고리의 다른 글
프로젝트에 Tuist로 TCA 준비하기 (0) | 2025.02.26 |
---|---|
기존 프로젝트에 Tuist 4 설정하기 (2) | 2025.01.17 |
The selected Xcode version is 16.2, which is not compatible with this project’s Xcode version requirement of 14.0.0. (0) | 2025.01.12 |
Sonoma에서 Xcode 14 실행하기 #iOS #Xcode (0) | 2025.01.11 |
Ventura에서 Xcode 15 실행하는 방법 #Xcode #MacOS #강제실행 #제한해제 (0) | 2025.01.04 |