의존성
프로젝트 규모가 커지면 코드를 공유하고, 경계를 명확히 하며, 빌드 시간을 개선하기 위해 여러 타겟으로 나누는 것이 일반적입니다. 여러 target으로 나누게 되면 이들 사이의 의존 관계를 정의하여 의존성 그래프가 만들어지며, 여기에는 외부 의존성도 포함될 수 있습니다.
XcodeProj-codified graphs
Xcode와 XcodeProj의 설계 특성상 의존성 그래프를 관리하는 일은 번거롭고 실수하기 쉬운 작업이 될 수 있습니다. 발생할 수 있는 문제들을 예로 들어보면 다음과 같습니다:
- Xcode의 빌드 시스템은 프로젝트의 모든 산출물을 derived data 내 동일한 디렉터리에 저장하기 때문에, 이로 인해 각 타겟이 원래 사용해서는 안 되는 다른 target의 산출물을 import할 수 있습니다. 클린 빌드가 더 흔하게 사용되는 CI 환경이나, 나중에 다른 구성을 사용할 때 컴파일이 실패할 수 있습니다.
- 타겟의 전이적 동적 의존성들(transitive dynamic dependencies)은
LD_RUNPATH_SEARCH_PATHS
빌드 설정에 포함된 모든 디렉터리에 복사되어야 합니다. 이렇게 해당 의존성들이 복사되지 않으면, 타겟이 런타임에 의존성을 찾을 수 없습니다. 의존성 그래프가 간단할 때는 생각하고 설정하기 쉽지만, 그래프가 복잡해질수록 문제가 됩니다. - target이 정적 XCFramework를 링크할 때, Xcode가 번들을 처리하고 현재 플랫폼과 아키텍처에 맞는 바이너리를 추출할 수 있도록 추가 빌드 페이즈(Build Phase)가 필요합니다. 이 build phase는 자동으로 추가되지 않으며, 추가하는 것을 쉽게 잊어버릴 수 있습니다.
위의 내용들은 몇 가지 예시에 불과하며, 우리는 수년간 이보다 더 많은 문제들을 겪어왔습니다. 의존성 그래프를 관리하고 유효성을 검증하기 위해 엔지니어 팀이 필요하다고 상상해보세요. 더 안 좋은 경우는, 제어하거나 커스터마이즈할 수 없는 빌드 시스템(closed-source build system)이 빌드 시점에 이러한 복잡한 세부 사항을 해결하는 경우입니다. 어디서 많이 들어본 것 같지 않나요? 이 방식은 Apple이 Xcode와 XcodeProj에서 채택한 접근 방식이며, Swift Package Manager도 그대로 채택하고 있습니다.
의존성 그래프는 반드시 명시적이고 정적이어야 합니다. 그래야 검증되고 최적화될 수 있기 때문이죠. Tuist와 함께라면, 의존 관계를 정의하는데만 집중하세요. 나머지는 저희가 알아서 처리할게요. 복잡한 세부 구현 사항들은 추상화되어 신경 쓸 필요가 없습니다.
다음 섹션에서는 프로젝트에서 의존성을 선언하는 방법을 알아보겠습니다.
그래프 검증
Tuist는 프로젝트를 생성할 때 그래프를 검증하여 순환이 없고 모든 의존성이 유효한지 확인합니다. 덕분에 어떤 팀이든 그래프가 깨질 걱정 없이 의존성 그래프를 발전시킬 수 있습니다.
로컬 의존성
Target은 같은 프로젝트나 다른 프로젝트의 타겟, 그리고 바이너리에 의존할 수 있습니다. Target
을 생성할 때, dependencies
아규먼트에 다음과 같은 옵션들을 전달할 수 있습니다:
Target
: 같은 프로젝트에 있는 타겟을 의존성으로 선언합니다.Project
: 다른 프로젝트에 있는 타겟을 의존성으로 선언합니다.Framework
: 바이너리 프레임워크에 대한 의존성을 선언합니다.Library
: 바이너리 라이브러리에 대한 의존성을 선언합니다.XCFramework
: 바이너리 XCFramework에 대한 의존성을 선언합니다.SDK
: 시스템 SDK에 대한 의존성을 선언합니다.XCTest
: XCTest에 대한 의존성을 선언합니다.
의존성 조건
모든 의존성 유형은 플랫폼에 따라 의존성을 조건부로 연결하기 위한 condition
옵션을 허용합니다. 기본적으로, 타겟이 지원하는 모든 플랫폼에 대해 의존성이 연결됩니다.
외부 의존성
Tuist는 프로젝트에서 외부 의존성을 선언할 수 있습니다.
Swift Packages
Swift Packages는 프로젝트에서 의존성을 선언하는 권장 방법입니다. Xcode의 기본 통합 메커니즘을 사용하거나 Tuist의 XcodeProj 기반 통합을 통해 이를 통합할 수 있습니다.
Tuist의 XcodeProj 기반 통합
Xcode의 기본 통합이 가장 편리하긴 하지만, 중간 규모 및 대형 프로젝트에서 필요한 유연성과 제어 기능이 부족합니다. 이를 극복하기 위해 Tuist는 XcodeProj 기반 통합을 제공하여 XcodeProj의 target을 사용해 프로젝트에 Swift 패키지를 통합할 수 있도록 합니다. 덕분에 통합을 더 잘 제어할 수 있을 뿐만 아니라, caching 및 smart test runs과 같은 워크플로우와도 호환되도록 만들 수 있습니다.
XcodeProj의 통합은 새로운 Swift Package 기능을 지원하거나 더 많은 Package 구성을 처리하는데 시간이 더 걸릴 가능성이 큽니다. 하지만 Swift Packages와 XcodeProj target 간의 매핑 로직은 오픈소스이며, 커뮤니티에서 기여할 수 있습니다. 이는 Apple이 관리하는 비공개 소스인 Xcode의 기본 통합 방식과는 대조됩니다.
외부 의존성을 추가하려면 Tuist/
디렉터리나 프로젝트 루트에 Package.swift
파일을 생성해야 합니다.
// swift-tools-version: 5.9
import PackageDescription
#if TUIST
import ProjectDescription
import ProjectDescriptionHelpers
let packageSettings = PackageSettings(
productTypes: [
"Alamofire": .framework, // default is .staticFramework
]
)
#endif
let package = Package(
name: "PackageName",
dependencies: [
.package(url: "https://github.com/Alamofire/Alamofire", from: "5.0.0"),
]
)
PACKAGE SETTINGS
컴파일러 지시문으로 감싼 PackageSettings
인스턴스를 통해 패키지 통합 방식을 설정할 수 있습니다. 예를 들어, 위 예시에서는 packages에 사용되는 기본 product type을 재정의하는 데 사용됩니다. 기본적으로는, 필요하지 않을 것입니다.
Package.swift
파일은 외부 의존성을 선언하기 위한 인터페이스일 뿐, 그 외의 역할은 하지 않습니다. 그래서 package에는 target이나 product를 정의하지 않습니다. 의존성을 정의한 후에는, 다음 명령어를 실행하여 의존성을 Tuist/Dependencies
디렉터리에 설정하고 가져올 수 있습니다.
tuist install
# Resolving and fetching dependencies. {#resolving-and-fetching-dependencies}
# Installing Swift Package Manager dependencies. {#installing-swift-package-manager-dependencies}
눈치채셨겠지만, 저희는 CocoaPods'처럼 의존성 해석을 별도의 명령어로 분리하는 방식을 채택했습니다. 이렇게 하면 사용자가 원하는 시점에 의존성을 해석하고 업데이트할 수 있으며, Xcode에서 프로젝트를 열었을 때 바로 컴파일할 수 있는 상태가 됩니다. 이는 프로젝트가 커질수록 Apple이 제공하는 Swift Package Manager 통합 방식에서 개발자 경험이 저하되는 부분입니다.
프로젝트의 타겟에서 TargetDependency.external
의존성 타입을 사용하여 이러한 의존성을 참조할 수 있습니다:
import ProjectDescription
let project = Project(
name: "App",
organizationName: "tuist.io",
targets: [
.target(
name: "App",
destinations: [.iPhone],
product: .app,
bundleId: "io.tuist.app",
deploymentTargets: .iOS("13.0"),
infoPlist: .default,
sources: ["Targets/App/Sources/**"],
dependencies: [
.external(name: "Alamofire"),
]
),
]
)
외부 패키지에 대한 scheme이 생성되지 않음
Swift Package 프로젝트의 scheme 목록을 깔끔하게 유지하기 위해 scheme이 자동으로 생성되지 않습니다. Xcode의 UI를 통해 생성할 수 있습니다.
Xcode의 기본 통합
Xcode의 기본 통합 메커니즘을 사용하려면 프로젝트를 생성할 때 packages
목록을 전달하면 됩니다:
let project = Project(name: "MyProject", packages: [
.remote(url: "https://github.com/krzyzanowskim/CryptoSwift", requirement: .exact("1.8.0"))
])
그런 다음 target에서 참조하면 됩니다.
let target = .target(name: "MyTarget", dependencies: [
.package(product: "CryptoSwift", type: .runtime)
])
Swift Macro와 Build Tool Plugin의 경우 각각 .macro
와 .plugin
type을 사용해야 합니다.
SPM Build Tool Plugins
Tuist의 XcodeProj 기반 통합을 사용해 프로젝트의 의존성을 관리하더라도, SPM build tool plugin은 반드시 Xcode의 기본 통합 메커니즘을 통해 선언해야 합니다.
SPM 빌드 도구 플러그인의 실용적인 활용 사례는 Xcode의 'Run Build Tool Plug-ins' Build Phase에서 코드 린팅을 수행하는 것입니다. package manifest에서는 다음과 같이 정의됩니다:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Framework",
products: [
.library(name: "Framework", targets: ["Framework"]),
],
dependencies: [
.package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", .upToNextMajor(from: "0.56.1")),
],
targets: [
.target(
name: "Framework",
plugins: [
.plugin(name: "SwiftLint", package: "SwiftLintPlugin"),
]
),
]
)
build tool plugin이 포함된 Xcode 프로젝트를 생성하려면, 프로젝트 manifest의 packages
배열에 package를 선언하고, target의 dependencies에 .plugin
타입의 package를 포함해야 합니다.
import ProjectDescription
let project = Project(
name: "Framework",
packages: [
.remote(url: "https://github.com/SimplyDanny/SwiftLintPlugins", requirement: .upToNextMajor(from: "0.56.1")),
],
targets: [
.target(
name: "Framework",
dependencies: [
.package(product: "SwiftLintBuildToolPlugin", type: .plugin),
]
),
]
)
Carthage
Carthage는 frameworks
또는 xcframeworks
를 생성하므로, carthage update
명령어를 실행해 Carthage/Build
디렉토리에 의존성들을 생성한 후, .framework
또는 .xcframework
target dependency type을 사용하여 대상에서 의존성을 선언할 수 있습니다. 이 과정을 다음과 같이 스크립트로 작성하여 프로젝트 생성 전에 실행할 수 있습니다.
#!/usr/bin/env bash
carthage update
tuist generate
빌드 및 테스트
tuist build
와 tuist test
를 통해 프로젝트를 빌드하고 테스트하는 경우, tuist build
또는 tuist build
를 실행하기 전에 carthage update
명령어를 실행하여 Carthage로 해결된 의존성들이 존재하는지 확인해야 합니다.
CocoaPods
CocoaPods은 의존성을 통합하기 위해 Xcode 프로젝트가 필요합니다. Tuist를 사용하여 프로젝트를 생성한 후, pod install
명령어를 실행하여 프로젝트와 Pods 의존성이 포함된 workspace를 생성함으로써 의존성을 통합할 수 있습니다. 이 과정을 다음과 같이 스크립트로 작성하여 프로젝트 생성 전에 실행할 수 있습니다.
#!/usr/bin/env bash
tuist generate
pod install
WARNING
CocoaPods 의존성은 프로젝트 생성 직후 xcodebuild
를 실행하는 build
또는 test
와 같은 workflow와 호환되지 않습니다. 또한, Pods 의존성을 fingerprinting logic에서 고려하지 않기 때문에, binary caching 및 selective testing과도 호환되지 않습니다.
Static or dynamic
Framework와 Library는 정적(static) 또는 동적(dynamic)으로 링크할 수 있으며, 이는 앱 크기와 실행 시간과 같은 부분에 크게 영향을 미칩니다. 이것은 중요한 결정임에도 불구하고, 대부분은 깊이 고려되지 않고 선택됩니다.
일반적인 규칙은 빠른 실행 시간을 위해 릴리즈 빌드에서는 최대한 많은 항목을 정적으로 링크하고, 빠른 반복 작업을 위해 디버그 빌드에서는 최대한 많은 항목을 동적으로 링크하는 것입니다.
Xcode에서 프로젝트 그래프의 링크 방식(static <-> dynamic)을 변경하는 것은 전체 그래프에 영향을 미치기 때문에 간단하지 않습니다 (예: 라이브러리는 리소스를 포함할 수 없고, 정적 프레임워크는 임베드가 불필요함). Apple은 Swift Package Manager의 정적 및 동적 링크 자동 결정이나 Mergeable Libraries와 같은 컴파일 타임 솔루션을 통해 이 문제를 해결하려고 했습니다. 그러나, 이는 컴파일 그래프에 새로운 동적 변수들을 추가하여 비결정적 요소를 증가시키며, Swift Previews와 같이 컴파일 그래프에 의존하는 기능들이 불안정해질 가능성을 높입니다.
다행히도, Tuist는 정적 및 동적 링크 간의 변경과 관련된 복잡성을 개념적으로 단순화하고, 링크 타입과 관계없이 표준화된 bundle accessors를 생성합니다. 환경 변수를 통한 동적 구성과 함께 사용하면 호출 시점에 링크 타입을 전달할 수 있으며, 이 값을 manifest에서 사용해 target의 product 타입을 설정할 수 있습니다.
// Use the value returned by this function to set the product type of your targets.
func productType() -> Product {
if case let .string(linking) = Environment.linking {
return linking == "static" ? .staticFramework : .framework
} else {
return .framework
}
}
Tuist는 비용 문제로 인해 암시적 구성(implicit configuration)을 통한 편의성을 기본값으로 제공하지 않는 점을 참고하세요. 이는 최종 바이너리가 올바르게 생성되기 위해 사용자가 직접 링크 타입과 -ObjC
linker flag 같은 추가 빌드 설정을 해야한다는 뜻입니다. 따라서, 우리는 주로 문서 형태의 자료를 제공하여 사용자가 올바른 결정을 내릴 수 있도록 돕는 방식을 취하고 있습니다.
예시: COMPOSABLE ARCHITECTURE
많은 프로젝트에서 사용하는 Swift Package로는 Composable Architecture가 있습니다. 여기와 troubleshooting section에 설명된 대로, package를 정적으로 링크할 때는 OTHER_LDFLAGS
빌드 설정을 $(inherited) -ObjC
로 설정해야 합니다. Tuist의 기본 링크 방식이 정적 링크이기 때문입니다. 다른 방법으로는, package의 product type을 동적으로 override할 수 있습니다.
시나리오
링크 방식을 전부 정적 또는 동적으로만 설정하는 것이 불가능하거나 적절하지 않은 경우가 있습니다. 다음은 정적 및 동적 링크를 혼합해야 할 수 있는 상황들의 예입니다:
- 확장 기능이 포함된 앱: 앱과 확장 기능이 코드를 공유해야 하기 때문에, target들을 동적으로 만들어야할 수 있습니다. 그렇지 않으면, 동일한 코드가 앱과 확장 기능 모두에 중복되어 바이너리 크기가 커지게 됩니다.
- 사전에 컴파일된 외부 의존성: 때로는 정적 또는 동적으로 미리 컴파일된 바이너리가 제공되기도 합니다. 정적 바이너리는 동적 프레임워크나 라이브러리로 감싸서 동적으로 링크될 수 있습니다.
그래프를 변경할 때, Tuist는 이를 분석하여 "static side effect"를 감지하면 경고를 표시합니다. 이 경고는 동적 target을 통해 정적 target에 전이적으로 의존하는 target을 정적으로 링크할 때 발생할 수 있는 문제를 식별하는 데 도움을 줍니다. 이러한 side effect는 종종 바이너리 크기 증가로 나타나거나, 최악의 경우 런타임 크래시가 발생할 수 있습니다.
문제 해결
Objective-C 의존성
Objective-C 의존성을 통합할 때, Apple Technical Q&A QA1490에서 자세히 설명된 대로 런타임 크래시를 방지하기 위해 사용하는 target에 특정 flag를 포함해야 할 수 있습니다.
빌드 시스템과 Tuist는 flag가 필요한지여부를 추론할 수 없고, 이 flag가 잠재적으로 원치 않는 side effect를 발생시킬 수 있기 때문에, Tuist는 이러한 플래그들을 자동으로 적용하지 않습니다. 또한, Swift Package Manager는 -ObjC
를 .unsafeFlag
를 통해 포함되는 것으로 간주하기 때문에, 대부분의 package는 필요한 경우에도 이를 기본 링크 설정의 일부로 포함할 수 없습니다.
Objective-C 의존성(또는 내부 Objective-C target)을 사용하는 target은 필요할 경우 OTHER_LDFLAGS
에 -ObjC
또는 -force_load
flag를 설정하여 적용해야 합니다.
Firebase & Other Google Libraries
Google의 오픈 소스 라이브러리는 강력하지만, 종종 일반적이지 않은 아키텍처와 기술로 빌드되기 때문에 Tuist에 통합하기 어려울 수 있습니다.
다음은 Firebase와 Google의 다른 Apple 플랫폼 라이브러리들을 통합하기 위해 필요할 수 있는 몇 가지 팁입니다:
OTHER_LDFLAGS
에 -ObjC
가 추가되었는지 확인하세요
Google의 라이브러리 중 다수는 Objective-C로 작성되어 있습니다. 이로 인해, 사용하는 모든 target은 OTHER_LDFLAGS
빌드 설정에 -ObjC
flag를 포함해야 합니다. 이는 .xcconfig
파일에서 설정하거나 Tuist manifest 파일에서 내의 target 설정에서 직접 지정할 수 있습니다. 예시:
Target.target(
...
settings: .settings(
base: ["OTHER_LDFLAGS": "$(inherited) -ObjC"]
)
...
)
자세한 내용은 Objective-C Dependencies 섹션을 참고하세요.
FBLPromises
product type을 동적 프레임워크로 설정하기
일부 Google 라이브러리는 Google의 또 다른 라이브러리인 FBLPromises
에 의존합니다. FBLPromises
와 관련된 다음과 같은 크래시가 발생할 수 있습니다:
NSInvalidArgumentException. Reason: -[FBLPromise HTTPBody]: unrecognized selector sent to instance 0x600000cb2640.
Package.swift
파일에서 FBLPromises
의 product type을 .framework
로 명시적으로 설정하면 이 문제가 해결될 것입니다:
// swift-tools-version: 5.10
import PackageDescription
#if TUIST
import ProjectDescription
import ProjectDescriptionHelpers
let packageSettings = PackageSettings(
productTypes: [
"FPLPromises": .framework,
]
)
#endif
let package = Package(
...
.swiftmodule
에서 발생하는 전이적 정적 의존성 문제
동적 프레임워브나 라이브러리가 import StaticSwiftModule
을 통해 정적 라이브러리에 의존하는 경우, 해당 심볼이 동적 프레임워크나 라이브러리의 .swiftmodule
에 포함되어 컴파일 실패를 유발할 가능성이 있습니다. 이를 방지하기 위해서는 @_implementationOnly
를 사용하여 정적 의존성을 import해야 합니다:
@_implementationOnly import StaticModule