Swift Package Manager 적용기

안녕하세요! 카페앱개발파트에서 iOS 개발을 하고 있는 셀린(Celine.jin)입니다.

이번에 카페앱 iOS 프로젝트에 종속성 관리를 위한 Apple의 공식 도구인 Swift Package Manager을 도입하여, 이에 대한 경험을 공유하고자 합니다. 이번 글에서는 Swift Package Manager를 도입한 이유와 사용법, 내부 빌드 과정에 대해 알아보겠습니다.

 

Swift Package Manager 소개


Swift Package Manager란?

Swift Package Manager(SwiftPM)는 종속성 관리를 위한 Apple의 공식 도구입니다. Cocoapods이나 Carthage와 같은 3rd party tool이 아닌 1st party로서, Xcode11에 내재된 기능으로 추가가 되었습니다.

애플에서 Swift Package Manager를 만든 이유는 Swift 생태계의 성장을 위해서입니다. Swift는 cross-platform 언어이고, 해당 언어를 지원하는 플랫폼에서 일관된 방식으로 코드를 구성하고 실행할 수 있는 툴이 필요했습니다. 그리고 Swift로 작성된 라이브러리가 쉽게 배포되고 사용되면서 성장하는 Swift 언어의 미래를 위해 해당 도구가 만들어졌습니다.

Swift Package Manager는 자체 빌드 시스템이 포함되어 있고, 소프트웨어의 구성과 테스트, 실행까지 포함하고 있습니다. 그리고 라이브러리를 배포하는 방법에 대한 새로운 표준을 정의하였습니다.


Swift Package Manager 도입 배경

카페 iOS 프로젝트에 Swift Package Manager를 도입한 이유는 다음과 같습니다.

  1. 모듈화: 이번 카페에 규모 있는 프로젝트가 진행 중이어서 빌드 속도 등 여러 가지 개발 개선을 위해 모듈화를 하기로 결정하였습니다.

  2. Dependency 관리: 기존 카페 프로젝트에는 종속성 도구로 Cocoapods를 사용하고 있었습니다. 모듈화 구조를 고민할 때 Xcode의 framework로 내부 프로젝트를 만들고, Cocoapods를 사용하여 종속성 관리를 진행해 보았습니다. 그러나 이 구조는 내부 framework에 추가된 pod dependency를 다른 framework에서 사용할 때 그 dependency를 Podfile에 다른 framework 대상으로 수동으로 추가해야 한다는 단점이 있었습니다. SwiftPM은 내부 모듈에 사용되는 dependency를 자동으로 관리해 주고, dependency를 추가 또는 변경할 때마다 pod install이라는 부가 작업을 하지 않아도 됩니다.

  3. 1st party: 공식 도구이므로 애플 주최하에 미래적으로 계속 발전이 될 종속성 도구입니다.

  4. 새로운 기술 도입: 모듈화 구조를 처음 진행하는 것이었고 기술 제한이 따로 없었으므로 해당 기술을 도입해 보자는 의견을 제시하였습니다.


Swift Package Manager 사용법

 

그럼 프로젝트에 Package를 추가하는 대표적인 방법과 Package를 생성하는 방법에 대해 알아보겠습니다.


프로젝트에 Package 추가하기

먼저, Xcode 프로젝트를 열고 프로젝트(SPMTest)를 선택합니다. 그리고 Package dependencies 탭 아래에 +를 누릅니다.

예를 들어, Alamofire 패키지를 추가해 보겠습니다. 위쪽에 Package URL을 넣고, Add Package를 선택합니다.

다음으로 추가를 원하는 Target을 정하고 Add Package를 눌러줍니다.

그러면 다음과 같이 Package Dependencies 목록에 Alamofire가 추가된 것을 볼 수 있습니다.

선택한 Target에서 General > Frameworks, Libraries, and Embedded Content를 보면 Alamofire 라이브러리가 추가되어있는 것을 볼 수 있습니다.

Package 생성하기

다음은 Package를 생성하는 방법에 대해 알아보겠습니다. 내부 모듈을 만들거나 어떤 코드를 publish 할 때 사용됩니다.

첫 번째 방법은 Xcode 툴로 생성하는 방법입니다. File > New > Package를 선택해서 생성할 수 있습니다.

두 번째 방법은 Terminal 명령어로 생성하는 방법입니다. 터미널에서 특정 디렉터리에 swift package init 명령어를 입력하면, 해당 폴더 이름을 기준으로 기본적인 디렉터리 구조를 만들어줍니다. 오른쪽 구조도를 보면 Sources 디렉터리와 Tests 디렉터리, package manifest 파일인 Package.swift 등이 만들어진 것을 볼 수 있습니다.


Package 구성 요소

패키지는 다음과 같이 targets, products, dependencies 크게 3가지로 이루어져 있습니다.

이 구성 요소들은 package manifest 파일인 Package.swift 파일의 내용으로 구성이 됩니다. 이 manifest 파일에 대해 자세히 알아보겠습니다.


Package Manifest API

아래 폴더 구조를 보면 MyPackage라는 디렉터리에 Package.swift 파일이 있습니다. 이렇게 Swift Package란, manifest 파일인 Package.swift 파일을 포함하는 디렉터리이고, 이 manifest 파일로 해당 디렉터리를 Swift Package로 식별합니다.

그럼 Package.swift 파일 내용을 살펴보겠습니다.

첫 번째 라인은 항상 Swift tool version으로 시작합니다, 해당 버전은 패키지를 빌드 하는데 필요한 Swift 컴파일러의 최소 버전입니다.

				
					// swift-tools-version: 5.5
				
			

그 다음 라인은 import PackageDescription입니다. 이 라이브러리는 manifest 파일이 패키지를 생성하는데 사용할 API를 포함하고 있습니다.

				
					import PackageDescription
				
			

그 다음 라인은 패키지의 이니셜라이저입니다. MyPackage라는 이름을 가진 패키지를 생성하였습니다.

				
					let package = Package(
    name: "MyPackage"
)
				
			

다음은 Target을 구성하는 방법입니다. 타겟은 모듈로서, 내부적인 모듈을 구성하는 방법입니다.

				
					targets: [
    .target(
        name: "MyTarget",
        dependencies: []
    )
],
				
			

Package는 Target에 대한 표준 구조를 가집니다. 위에 폴더 구조를 보게 되면, Sources 디렉터리에 타겟의 모든 파일이 위치하고, 각 타겟은 타겟 이름과 동일한 이름으로 하위 디렉터리에 위치합니다.

다음은 Test target을 구성하는 방법입니다. Test target은 Tests 디렉터리 아래에 위치하고, 일반적으로 다른 타겟을 테스트하기 때문에 dependencies 파라미터를 선언해야 합니다.

				
					targets: [
    .testTarget(
        name: "MyTargetTests",
        dependencies: ["MyTarget"]
    )
],
				
			

다음은 Product를 구성하는 방법입니다. Product은 해당 패키지로부터 어떠한 타겟을 노출시키고, 다른 패키지에서 사용할 수 있도록 해줍니다. 지금 같은 경우는 MyTarget 라이브러리로 MyTarget 모듈을 노출시킨 것을 볼 수 있습니다. targets 파라미터에는 타겟을 여러 개 선언할 수 있고, 하나의 라이브러리로 여러 개의 타겟을 노출할 수 있습니다.

				
					products: [
    .library(
        name: "MyTarget",
        targets: ["MyTarget"]
    )
],
				
			

Target Custom Path 구성하기

새로 생성한 패키지가 아닌 기존에 다른 종속성 도구를 이용하여 배포되는 상황이라면, 디렉터리 구조를 Swift Package의 표준 구조인 Sources 디렉터리 아래에 위치하는 것이 어려울 수 있습니다. 그럴 경우에는 다음과 같이, target의 path 파라미터를 사용하여 custom path를 지정합니다. 해당 path는 manifest 파일을 기준으로 상대 경로를 적어줍니다.

				
					targets: [
    .target(
        name: "MyTarget",
        path: "MyTarget",
        dependencies: []
    )
],
				
			

Package Dependency 구성하기

다음은 dependency를 구성하는 방법입니다. 패키지 이니셜라이저의 dependencies 파라미터에 추가되고, Source URL과 Version 정보로 이루어졌습니다.

				
					dependencies: [
    .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.0.0")),
],
				
			

Version 정보에 대해 알아보겠습니다. 예를 들어 버전이 5.0.0 이라고 하면, 앞에 5가 major, 다음 0은 minor, 다음 0은 path 버전을 의미합니다.

				
					// Version-based requirements
.package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.0.0")
.package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.0.0"))
.package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMinor(from: "5.0.0"))
.package(url: "https://github.com/Alamofire/Alamofire.git", .exact("5.0.0"))
				
			
  • from: 해당 버전 이상부터 사용을 의미합니다.

  • uptoNextMajor: 5.0.0부터 6.0.0 이전까지 사용을 의미합니다.

  • uptoNextMinor: 5.0.0부터 5.1.0 이전까지 사용을 의미합니다.

  • exact: 해당 버전으로 고정을 의미합니다.

애플은 특별한 경우가 아니라고 하면 exact 사용을 권장하지는 않습니다. 그 이유는 dependency들의 버전을 resolve 할 때 버전 충돌이 날 가능성이 많기 때문입니다.

그리고 다음과 같이 branch와 특정 커밋 기준으로도 패키지의 dependency를 사용할 수 있습니다.

				
					// Branch-based requirements
.package(url: "https://github.com/Alamofire/Alamofire.git", .branch("master")),

// Revision-based requirement
.package(url: "https://github.com/Alamofire/Alamofire.git", .revision("85cfe06")),
				
			

MyTaget 타겟에 Alamofire를 디펜던시로 사용하려면, 다음과 같이 Target의 dependencies 파라미터에 해당 이름을 추가합니다.

				
					targets: [
    .target(
        name: "MyTarget",
        dependencies: [
            "Alamofire"
        ]
    )
]
				
			

 

Resource

Xcode12부터 타겟에 리소스가 있을 경우 그 리소스들을 번들화를 해주어 해당 타겟에서 사용할 수 있도록 해주었습니다.

아래 파일들은 패키지를 빌드 한 결과물입니다. 이 중에 MyPackage_MyTarget.bundle은 컴파일러가 해당 타겟에 포함된 리소스들을 번들로 만들어준 결과물입니다.

그리고 Xcode12부터 해당 기능이 추가되었으므로, manifest 파일에서 swift-tools-version을 5.3 이상으로 선언해야 합니다.

하나의 타겟에는 여러 가지 리소스 타입이 올 수 있습니다. 아래와 같은 파일의 타입은 컴파일러가 해당 파일들의 목적이 분명하다고 판단하기 때문에 manifest 파일에 따로 명시하지 않아도 번들화를 해줍니다.

그러나 다음과 같은 파일의 타입은 목적이 다양하다고 판단하기 때문에 manifest 파일에 명시해야 리소스 번들에 추가됩니다.

Package Manifest API

타겟에 리소스가 있을 경우, 해당 리소스들을 manifest 파일에서 target의 resources 파라미터에 추가합니다. 아래와 같이 xcassets와 storyboard 파일은 manifest 파일에 추가하지 않아도 번들화를 해주고, png 파일이나 디렉터리는 명시적으로 추가되어야 번들화가 되는 것을 볼 수 있습니다.

리소스를 추가할 때, .process는 리소스 번들 루트에 위치되고, .copy는 디렉터리 구조를 유지하면서 추가됩니다.

타겟 내 코드에서 리소스 접근 방법

타겟에서 리소스 번들에 접근할 때 SwiftPM은 Bundle.module이라는 accessor로 접근합니다. 

				
					// Swift
let image = UIImage(named: "Logo", in: Bundle.module)
// Objective-C
UIImage *image = [UIImage imageNamed:@"Logo" inBundle:SWIFTPM_MODULE_BUNDLE];
				
			

Package.resolved

해당 파일은 프로젝트의 workspace에 정의되어 있는 모든 패키지에 대한 버전 정보를 기록하는 파일입니다. Cocoapods에서 Podfile.lock의 개념으로, 협업할 때 같은 패키지의 버전을 사용하기 위해 필요합니다.

project.xcworkspace > xcshareddata > swiftpm > Package.resolved에 위치하고, 해당 파일도 커밋이 되어야 협업 시 동일한 패키지 버전을 사용할 수 있습니다.

 

Swift Package Manger 내부 빌드 과정

 

그럼 좀 더 깊게 들어가서, 패키지를 빌드 하는 과정에서 컴파일러는 어떤 파일을 생성하는지와 생성된 파일의 역할에 대해서 알아보겠습니다.

다음은 Xcode13 기준으로 패키지를 빌드 하는 과정을 보여주는 빌드 로그입니다. 타겟에 리소스가 포함되어 있으므로 리소스 번들인 MyPackage_MyTarget을 만들어주는 것을 확인할 수 있고, MyTarget 타겟도 빌드하는 과정을 볼 수 있습니다.

MyTarget 타겟의 빌드 과정을 보면, 소스 코드를 빌드 한 object 파일 이외에 modulemap이나 swiftmodule 등 여러 파일이 생성된 것을 볼 수 있습니다. 이 파일들이 생성됨으로써, MyTarget 모듈이 다른 모듈에 사용이 가능하고 Objecitve-C에도 사용이 가능합니다.

그러면 추가로 생성된 파일들의 역할에 대해서 알아보겠습니다.


MyTarget.modulemap

modulemap 파일이 어떤 기능을 하는지 알아보기 전에 Clang module이라는 개념에 대해서 알아보겠습니다.


Clang Module

Clang은 Apple의 공식 c 컴파일러입니다. 예를 들어, Objective-C에서 다른 파일에 있는 코드를 사용하고 싶을 경우, C이기 때문에 #include#import를 사용할 수 있습니다. 이 동작 방식은 해당 문에 선언된 헤더 파일 전체를 단순히 지시자의 위치로 복사하는 방식으로 동작합니다. 이 방식은 헤더 파일의 선언이 많아질수록 빌드 속도에 영향을 주게 되어, 빌드 속도 개선을 위해 등장한 것이 Clang Module입니다.

Clang Module은 프레임워크 당 헤더를 한 번만 찾아서 파싱을 한 이후, 해당 정보를 디스크에 저장하여 캐시 된 상태로 재사용할 수 있도록 하였습니다. 그리고 @import 어노테이션을 사용하여 Objective-C에서 모듈 단위로 import를 할 수 있게 빌드 시간을 개선하였습니다.


Module Map

modulemap 파일은 Clang Module을 구성해 주는 파일로, 특정 헤더 파일의 집합을 module로 변환해 주는 방식을 나타냅니다.

아래 파일은 위의 빌드 로그에서 본 MyTarget.modulemap 파일입니다. 파일 내용을 보면, module로 MyTarget이 선언되어 있고 header는 MyTarget-Swift.h로 되어있습니다. 이 말은 즉, MyTarget 모듈은 MyTarget-Swift.h 헤더 파일로 구성이 되어있다는 의미입니다.

이렇게 MyTarget이라는 Clang Module을 만들어주어 Objective-C에서 모듈로 import가 가능해졌습니다.


MyTarget-Swift.h

해당 파일은 Swift로 작성된 코드를 Objective-C에서 사용할 수 있도록 해주는 인터페이스 역할을 합니다. NSObject를 상속한 Swift 클래스와 @objc 어노테이션이 있는 요소들로부터 만들어줍니다.

왼쪽에 Swift 코드를 보면 PetCollar 클래스는 NSObject를 상속하고 있고, color프로퍼티는 @objc가 선언되어 있습니다. 이 파일을 컴파일러는 오른쪽과 같이 Objective-C에서 사용할 수 있는 헤더를 만들어줍니다.

 
MyTarget.swiftmodule

Swift에서 Module은 기본 배포 단위이고, 다른 모듈에 있는 선언문들을 사용하려면 모듈을 import 해야 합니다.

예를 들어, PetWall이라는 모듈을 import 하고 이 모듈에 선언되어 있는 PetViewController 클래스의 이니셜라이저를 사용했다고 가정하겠습니다. 이때 컴파일러는 이 이니셜라이저의 타입이 올바르게 사용되어 있는지 확인하는 작업을 하게 되는데, 이 작업은 swiftmodule 파일을 deserialize 하여 확인합니다.

swiftmodule 파일은 모듈에 선언된 API를 다른 모듈에 노출시키는 역할을 하고, 바이너리 표현(binary representation) 방식을 사용합니다.

 
resource_bundle_accessor.swift

빌드 로그를 보면 해당 파일도 컴파일러가 생성을 해주는 것을 볼 수 있고, 파일의 구현부는 다음과 같이 되어있습니다.

앞에서 리소스를 소개할 때, 모듈에서 리소스 번들에 접근할 때는 Bundle.module이라는 accessor로 접근한다고 말씀을 드렸습니다. 파일의 내용을 보면, Foundation.Bundlemodule이라는 static 변수가 extension 되어있고, 그 안에는 리소스 번들 이름인 MyPackage_MyTarget이 선언되어 있는 것을 볼 수 있습니다.

그럼으로써 해당 모듈에 Bundle.module로 리소스 번들에 접근이 가능해졌습니다.


이슈와 해결 사례

 

Keditor SDK

저희 카페앱개발파트 같은 경우는 전사 에디터 플랫폼인 Keditor SDK 업무도 맡고 있습니다. 그래서 관련된 프로젝트에 SwiftPM 지원 작업을 하면서 겪었던 이슈에 대해 말씀드리겠습니다.

Swift Package Manager의 특징 중의 하나는 C 계열과 Swift 파일을 하나의 타겟으로 사용하지 못한다는 점입니다. 작업한 프로젝트 중 하나는 Objective-C와 Swift 파일이 섞여 있는 프로젝트였습니다. 위와 같은 SwiftPM의 특징으로 인해, Objective-C 파일만을 가지는 타겟과 Swift 파일만을 가진 타겟을 분리하여 dependency로 연결하여 문제를 해결하였습니다.

그 내용을 dependency graph로 살펴보면 다음과 같습니다.

왼쪽 AS-IS 그래프를 살펴보면, A모듈은 B모듈만을 디펜던시로 연결하는 구조였다면, 오른쪽 TO-BE 그래프는 기존 A모듈에서 Swift 코드만을 가진 A_swift모듈로 분리하였고, A_objc모듈이 A_swift를 디펜던시로 연결하는 구조로 변경하였습니다.

또한, Objective-C로 구성되어 있는 모듈은 manifest 파일에서 타겟을 정의할 때, Target의 publicHeaderPath를 정의해야 다른 모듈에서 해당 API를 사용할 수 있습니다. 이 경우는 include 폴더를 생성하여 public하게 노출할 헤더 파일의 목록을 symbolic link로 생성하여 작업하였습니다.

				
					.target(
    name: "A_objc", 
    publicHeadersPath: "include",
    ...
)
				
			

카페앱 iOS

카페앱 iOS 프로젝트에 Swift Package Manager를 도입했을 때 종속성 도구를 하나로 가져가자 라는 목표가 있었습니다.

기존 카페앱에서는 Cocoapods를 사용하고 있었고, SwiftPM을 도입하면서 종속성 도구를 두 가지로 사용할 수 있는 상황이었습니다. 이 경우에는 명시적으로 각각의 종속성 도구에 동일한 라이브러리를 추가하지 않을 수 있지만, 추가된 라이브러리의 dependency로 동일한 라이브러리를 갖고 있는 상황이 발생할 수 있습니다. 이럴 경우 static linking이라면 빌드 시 컴파일 에러, dynamic linking이라면 실행 시 warning이 발생하기 때문에 이런 상황을 방지하고자 위와 같은 목표가 있었습니다.

이 목표를 실현하기 위해서는 해결해야 하는 것들이 몇 가지 있었습니다.

  • Keditor SDK: 저희 파트에서 작업하여 해결하였습니다.

  • Open Source: 유지 보수가 중단된 것들은 Fork한 후 작업을 진행하여 완료하였습니다.

  • Enterprise Library: 해당 부서에 지원 요청하였습니다.

그 결과, 현재 검토 중인 Enterprise 라이브러리 몇 가지를 제외하고는 모두 SwiftPM으로 마이그레이션을 완료한 상태이고, 추후 Cocoapods를 제거할 예정입니다.


Review

 

장점

  • 모듈화 작업 시 모듈을 생성하고 배포하는 방식이 다른 종속성 도구와 비교했을 때 간단하다는 점입니다.

  • pod install과 같은 부가적인 작업을 하지 않아도 dependency를 추가 또는 변경할 수 있다는 점과 프로젝트 내부에 있는 모듈의 depedency를 수동으로 관리하지 않아도 된다는 점입니다.

  • 1st party이기 때문에 애플 주최하에 미래적으로 계속 발전이 될 종속성 도구라는 점입니다.

단점

  • C 계열과 Swift 파일을 하나의 모듈로 사용하지 못한다는 점입니다.

  • Xcode에 패키지 정보가 녹여져 있다 보니 프로젝트 파일 로드 시 Xcode가 package graph를 resolve하는 시간이 악간 소요된다는 점입니다.


이렇게 장점과 단점까지 알아보았고, 카페앱 iOS 프로젝트에 Swift Package Manager를 도입한 경험을 공유드렸습니다. 긴 글 읽어주셔서 감사합니다.

Latest Posts