Property Wrappers in Swift

Property wrapper는 프로퍼티를 정의하는 코드(code that defines a property)와 프로퍼티가 어떻게 저장되는지를 관리하는 코드(code that manages how a property is stored) 사이의 계층(layer)이다.

Property wrapper를 사용하면 정의할 때 관리 코드(management code)를 한 번만 작성하고 여러 프로퍼티에 재사용할 수 있다. 사실 이런 wrapper 개념은 이번에 갑자기 나타난 것이 아니다. lazy@NSCopying 같은 키워드도 wrapper의 한 종류다. 하지만 Swift 5.1에선 개발자가 이런 wrapper를 직접 만들 수 있게 되었다.

Property wrapper를 이해하기 위해선 Property wrapper가 어떤 문제를 해결할 수 있는지를 보면 된다. 객체 안의 프로퍼티에 값이 할당될 때마다 로그를 출력해주어야 한다고 가정해보자. 우린 다음과 같이 코드를 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Bar {
private var _x = 0

var x: Int {
get { _x }
set {
_x = newValue
print("New value is \(newValue)")
}
}
}

var bar = Bar()
bar.x = 1 // Prints 'New value is 1'

가장 직관적인 방법이다. 하지만 객체 안에 x뿐만 아니라 수많은 프로퍼티가 존재하고 이런 프로퍼티 모두가 값이 할당될 때마다 로그를 출력해주어야 한다면 상황은 달라진다.

이런 상황을 해결하기 위해 우린 새로운 타입을 정의해줄 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct ConsoleLogged<Value> {
private var value: Value

init(wrappedValue: Value) {
self.value = wrappedValue
}

var wrappedValue: Value {
get { value }
set {
value = newValue
print("New value is \(newValue)")
}
}
}

그리고 기존의 Bar 구조체를 아래와 같이 ConsoleLogged 타입을 사용해 바꿀 수 있다.

1
2
3
4
5
6
7
8
9
10
11
struct Bar {
private var _x = ConsoleLogged<Int>(wrappedValue: 0)

var x: Int {
get { _x.wrappedValue }
set { _x.wrappedValue = newValue }
}
}

var bar = Bar()
bar.x = 1 // Prints 'New value is 1'

이렇게 ConsoleLogged 타입을 사용해 중복 코드를 줄일 수 있다. Swift 5.1에선 이런 패턴을 Property wrapper라는 Syntatic Sugar로 제공한다. 사용 방법은 매우 간단하다. 기존의 ConsoleLogged@propertyWrapper만 붙이면 된다. @propertyWrapper를 타입(struct, class, enum) 앞에 붙이고 wrappedValue만 정의해주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@propertyWrapper
struct ConsoleLogged<Value> {
private var value: Value

init(wrappedValue: Value) {
self.value = wrappedValue
}

var wrappedValue: Value {
get { value }
set {
value = newValue
print("New value is \(newValue)")
}
}
}

프로퍼티가 어떻게 저장되는지를 관리하는 코드(code that manages how a property is stored)

이렇게 선언한 Property wrapper는 사용하기도 쉽다. 아래와 같이 두 가지 방법으로 Property wrapper를 사용할 수 있다.

1
2
3
4
struct Bar { 
@ConsoleLogged var x = 0 // 1
@ConsoleLogged(wrappedValue: 2) var y // 2
}

프로퍼티를 정의하는 코드(code that defines a property)

Property wrapper 안에는 wrappedValue와 같이 프로퍼티뿐만 아니라 메소드도 정의할 수 있다. 하지만 안에 정의된 메소드를 사용할 땐 스코프(scope)에 주의할 필요가 있다.

1
2
3
4
5
6
@propertyWrapper
struct Wrapper<T> {
var wrappedValue: T

func foo() { print("Foo") }
}

위와 같이 Property wrapper안에 foo라는 메소드를 정의했다.

1
2
3
4
5
struct HasWrapper {
@Wrapper var x = 0

func foo() { _x.foo() }
}

그럼 당연히 Wrapper를 Property wrapper로 사용하는 객체 내부에선 foo 메소드를 사용할 수 있다. (_x.foo())

1
2
let a = HasWrapper()
a._x.foo() // ❌ '_x' is inaccessible due to 'private' protection level

하지만 위와 같이 외부에서의 접근은 불가능하다. 이유는 private 수준의 접근 제어를 갖기 때문이다. 물론 외부에서 접근할 수 있는 방법은 존재한다. projectedValue를 사용하면 된다.

우리는 projectedValue를 통해 부수적인 API를 외부에 노출시킬 수 있다. wrappedValue와 다르게 projectedValue는 타입의 제한이 없다.

1
2
3
4
5
6
7
8
@propertyWrapper
struct Wrapper<T> {
var wrappedValue: T

var projectedValue: Wrapper<T> { return self }

func foo() { print("Foo") }
}

그리고 우리는 projectedValuex$을 붙여 접근할 수 있다.

1
2
let a = HasWrapper()
a.$x.foo() // Prints 'Foo'

SwiftUI엔 @State, @Binding과 같은 빌트인 Property wrapper가 존재한다. 이에 대해선 추후 다루게 될 SwiftUI 포스팅에서 하나씩 살펴보도록 하자.

이번 포스팅에선 간단한 예로 UserDefaults를 Property wrapper를 통해 보다 간편하게 사용할 수 있는 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
@propertyWrapper
struct UserDefault<T> {
var key: String
var initialValue: T
var wrappedValue: T {
set { UserDefaults.standard.set(newValue, forKey: key) }
get { UserDefaults.standard.object(forKey: key) as? T ?? initialValue }
}
}

먼저 UserDefault라는 Property wrapper를 위와 같이 정의했다. initialValue를 통해 값이 존재하지 않을 때 초기값을 제공할 수 있다. 그리고 아래와 같이 사용할 수 있다.

1
2
3
4
5
6
7
8
9
enum UserPreferences {
@UserDefault(key: "isCheatModeEnabled", initialValue: false) static var isCheatModeEnabled: Bool
@UserDefault(key: "highestScore", initialValue: 10000) static var highestScore: Int
@UserDefault(key: "nickname", initialValue: "cloudstrife97") static var nickname: String
}

UserPreferences.isCheatModeEnabled = true
UserPreferences.highestScore = 25000
UserPreferences.nickname = "squallleonhart"

물론 Property wrapper로 선언된 프로퍼티에도 한계가 존재한다.

  • 하위 클래스에서 오버라이딩될 수 없다.
  • lazy, weak, @NSCopying과 같이 사용할 수 없다.
  • 커스텀 set, get을 사용할 수 없다.
  • 프로토콜이나 익스텐션에서 선언될 수 없다.

오늘은 Swift 5.1에 새로 추가된 Property wrapper에 대해 매우 간단히 알아보았다. 이후에는 SwiftUI에서 사용되고 있는 Property wrapper들에 대해서 소개해보려 한다.


참고 자료

Opaque Types in Swift

오늘은 Swift 5.1에서 처음 소개된 불투명 타입(Opaque Type)에 대해 공부해보고 기록해보려 한다. 사실 SwiftUI를 본격적으로 공부하려다 some 키워드에 막혀 이게 무엇인지 알아보려다 여기까지 오게 되었다.

이렇게 SwiftUI는 다시금 뒤로 미뤄졌다.🙄 진짜 제대로 시작해야 하는데..

불투명 타입을 공부하면서 명확하게 이해가 된 부분도 있지만, 아직은 불투명하게 다가온 부분도 있었다. 그 이유는 아무래도 어느 문법을 공부해도 마찬가지겠지만, 직접 프로젝트에 사용하여 필요성을 느껴보지 못해서 그런 것 같다.

@escaping 클로저를 처음 배웠을 때도 이와 비슷한 느낌이었다.

오늘은 단순히 문법적 이해를 위한 기록으로 작성하고 추후 기회가 된다면 직접 사용해보고 이 문법이 왜 도입되었고, 언제 사용하면 좋을지 다른 사람들이 소개한 케이스가 아닌 내가 경험한 케이스를 소개해보려 한다.


Type Identity

불투명 타입을 소개하기 전 가장 먼저 본인이 불투명 타입을 공부하면서 헷갈렸던 부분을 먼저 소개해보려 한다. 스위프트 공식 문서나 여러 해외 블로그들을 읽다 보면 불투명 타입을 설명하는 데 있어 정체성(Identity)를 자주 언급하는 것을 확인할 수 있다. 정확하게 말하면 타입 정체성(Type Identity)이다.

타입 정체성이란 무엇일까? 몇 개의 글과 함께 예제 코드를 보고서야 조금은 감을 잡을 수 있었다. 내가 이해한 타입 정체성은 다음과 같다.

우린 스위프트에서 프로토콜을 타입으로써 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
protocol Analytics {
func log()
}

class FirebaseAnalytics: Analytics {
func log() { /* ... */ }
}

class FlurryAnalytics: Analytics {
func log() { /* ... */ }
}

class AnalyticsManager {
private let analytics: Analytics

init(_ analytics: Analytics) {
self.analytics = analytics
}

func sendLog() {
analytics.log()
}
}

let manager1 = AnalyticsManager(FirebaseAnalytics())
let manager2 = AnalyticsManager(FlurryAnalytics())

전적으로 예제를 위한 예제 코드다.

AnalyticsManager는 어떤 애널리틱스 툴을 사용해도 로그만 전송하면 된다. 우리는 보통 이런 경우 위와 같이 프로토콜을 만들고 행위를 정의한다. 그리고 행위를 구현한 구현체를 프로토콜 타입으로써 주입해준다.

AnalyticsManagersendLog를 호출할 때 AnalyticsManagerFirebaseAnalyticsFlurryAnalytics가 아닌 단지 Analytics 타입으로 analytics 프로퍼티를 사용한다. 이렇게 프로토콜 타입은 사용되는 시점에 자신의 실제 타입에 대한 정체성을 잃게 된다.

“잃게 된다”라는 어감(?) 때문인지 부정적으로 다가올 수 있지만 이게 프로토콜 타입을 사용하는 이유다.

추상화의 의미를 생각해보면 정체성을 잃는다는 것을 보다 쉽게 받아들일 수 있다.

그리고 이런 프로토콜 타입은 프로그램에 유연성을 제공해주기 때문에 의존성 주입 등 코드 작성 전반에 걸쳐 유용하게 사용된다.

하지만 이러한 유연성이 때로는 코드 복잡성을 야기할 수 있다. 일반적인 프로토콜 타입에는 해당하지 않지만 associatedtype이나 Self를 사용하는 프로토콜 타입은 타입으로써 반환되거나, 파라미터로 전달, 프로퍼티로 사용될 수 없다. 만일 그렇게 사용하면 우린 굉장히 익숙한 에러 메세지를 볼 수 있다.

1
Protocol 'SomeProtocol' can only be used as a generic constraint because it has Self or associated type requirements

너무 돌아왔다. 결국 프로토콜 타입은 타입 정체성을 잃고 유연성을 제공한다는 것에 주목하자!


Opaque Types

불투명 타입의 사용법은 간단하다. 반환되는 프로토콜 타입 앞에 some 키워드를 붙여주면 끝이다. 하지만 단순히 문법의 사용법만 가지곤 문법을 활용할 수 없다. 언제 불투명 타입을 사용할 수 있고, 불투명 타입을 왜 사용하는지에 대해 알아보았다.

이를 위해 불투명 타입이 없던 Swift 5.1 이전으로 돌아가 보자. 결제수단을 제공하는 프레임워크를 작성한다고 가정해보자.

1
2
3
public func favoriteCreditCard() -> CreditCard { 
return getLastUsedCreditCard()
}

위와 같은 방식으로 사용자가 최근에 사용한 카드를 사용자가 선호하는 카드로 반환해줄 수 있다. 하지만 이러한 과정에서 우리는 굳이 사용자가 알 필요 없고, 알아서는 안되는 CreditCard라는 타입을 노출시킨다.

우리는 이런 문제를 프로토콜을 사용하여 해결하려 한다.

1
2
3
4
5
6
7
8
9
10
protocol PaymentType { }
struct CreditCard: PaymentType { }
struct ApplePay: PaymentType { }

func favoritePaymentType() -> PaymentType {
if likesApplyPay {
return ApplePay()
}
return getLastUsedCreditCard()
}

얼핏 보면 해결된 것처럼 보일 수 있지만 사용자가 PaymentType을 비교해야 한다면 얘기는 달라진다.

1
protocol PaymentType: Equatable { }

Equatable 타입은 내부적으로 Self를 사용한다.

이렇게 결제수단의 비교를 위해 Equatable을 사용하면 타입 정체성에서 언급한 에러 메세지를 보게 된다.

1
Error: Protocol 'PaymentType' can only be used as a generic constraint because it has Self or associated type requirements

물론 이런 문제를 제네릭과 타입 제거 기법(type erasure techniques)을 통해 해결할 수 있다. 하지만 이는 프레임워크의 사용을 더욱 어렵게 만들며 역시 의도치 않게 내부 타입을 노출시킬 수 있다.

불투명 타입이 이런 문제를 해결할 수 있다. 방법은 매우 간단하다.

1
2
3
public func favoriteCreditCard() -> some PaymentType { 
return getLastUsedCreditCard()
}

물론 방법이 매우 간단한 만큼 한계도 존재한다. 불투명 타입을 반환하는 함수는 오로지 하나의 타입만 반환할 수 있다. 위의 코드에서 함수의 반환 타입은 CreditCard다. 하지만 컴파일러가 이를 PaymentType인 것 마냥 다룬다. 내부적으론 실제 타입을 반환하는 것이기 때문에 실제 타입으로 할 수 있는 모든 것들은 할 수 있다. 즉 타입 정체성이 보장된다는 것이다. 타입의 정체성이 보장되기 때문에 associatedtype이나 Self를 사용하는 프로토콜도 반환 타입으로 바로 사용할 수 있다.

물론 불투명 타입의 한계도 존재한다. 이런 타입의 정체성을 보장하기 위해 하나의 타입만을 반환할 수 있다.

1
2
3
4
5
6
func favoritePaymentType() -> some PaymentType { 
if likesApplyPay {
return ApplePay()
}
return getLastUsedCreditCard()
}

즉 위와 같은 코드는 사용할 수 없다는 뜻이다.

다시 불투명 타입을 사용하면 어떤 점이 좋은지 이야기해보자. 위에서 언급했듯이 불투명 타입은 모듈 내부의 타입을 노출시키지 않을 수 있다. 이게 단순히 하나의 타입을 노출시키지 않는다는 것을 의미하기도 하지만, 타입이 내부적으로 어떤 타입들로 구성되어 있는지도 숨길 수 있다.

SwiftUI를 예로 들어보자. SwiftUI 프로젝트를 생성하면 하나의 템플릿 코드를 확인할 수 있다.

1
2
3
4
5
struct ContentView: View { 
var body: some View {
Text("Hello World")
}
}

바로 내가 SwiftUI 공부를 멈추고 불투명 타입을 공부하기 시작하게 된 코드다.

이 코드에서 some 키워드를 사용한 이유가 뭘까? some 키워드를 지우면 컴파일이 되지 않는 걸까? 그렇지 않다. some 키워드가 없이도 충분히 프로그램을 만들 수 있다.

1
2
3
4
5
struct ContentView: View { 
var body: Text {
Text("Hello World")
}
}

some 키워드를 사용하지 않고 SwiftUI로 화면을 만들어보자. SwiftUI에선 VStack을 이용해 스택 뷰의 형태를 구현할 수 있다. 그리고 SwiftUI에서 이런 컨테이너 타입들은 모두 제네릭 타입이다.

1
2
3
4
5
6
7
8
struct ContentView: View {
var body: VStack<TupleView<(Text, Image)>> {
VStack {
Text("Hello World")
Image(systemName: "video.fill")
}
}
}

.

.

🤔

자자..! 일단 계속 만들어보자. 하나의 텍스트를 더 추가해보자.

1
2
3
4
5
6
7
8
9
struct ContentView: View {
var body: VStack<TupleView<(Text, Text, Image)>> {
VStack {
Text("Hello World")
Text("My name is Corn")
Image(systemName: "video.fill")
}
}
}

좀 더 복잡한 레이아웃을 그려보자!

.

.

.

.

1
2
3
4
5
6
7
struct ContentView: View {
var body: List<Never, TupleView<(HStack<TupleView<(VStack<TupleView<(Text, Text)>>, Text)>>, HStack<TupleView<(VStack<TupleView<(Text, Text)>>, Text)>>)>> {
...
.....
........
}
}

자 완성했다! 근데 중간 어디쯤에 있는 TextImage로 바꾸려 한다! 그러면…🤯

이쯤 되면 왜 불투명 타입이 필요한지 절실히 깨달을 수 있다. 우린 단지 body어떤(some) View 타입을 반환한다 정도만 알고 싶은 것이다. 그리고 그 정도의 정보만 필요하다.

우린 some 키워드로 body는 단지 View 타입을 반환한다는 사실을 명시해줄 수 있다.

1
2
3
4
5
6
7
struct ContentView: View {
var body: some View {
...
.....
........
}
}

마무리

아직은 내가 직접 작성한 모듈 혹은 프로젝트에서 불투명 타입을 사용해보진 못했다. 하지만 이젠 불투명 타입의 존재와 언제 사용하면 좋을지에 대해 훑어보았기 때문에 다음에 기회가 왔을 때 불투명 타입 사용을 고려해볼 수 있게 되었다.

SwiftUI 공부도 다시 시작할 수 있다!


참고 자료

OptionSet

오늘은 회사 과제를 진행하던 중 처음 접한 스위프트의 OptionSet이라는 친구를 살펴보려 한다. 처음 접한 개념이지만 알아보니 그렇게 어렵지 않은 개념이면서도 유용하게 사용할 수 있을 것 같은 개념이어서 이렇게 기록해보려 한다.


먼저 그 개념을 공식 문서를 통해 살펴보도록 하자.

OptionSet 프로토콜은 비트들 각각이 집합의 요소를 표현하는 비트 집합 타입을 표현하는데 사용된다. 이 프로토콜을 채택한 사용자 정의 타입에선 요소 검사(해당 요소가 집합에 속하는지), 합집합, 교집합 연산과 같은 집한 연산들을 수행할 수 있다.

옵션 집합(OptionSet 프로토콜을 채택한 사용자 정의 타입)을 만들기 위해선 타입 선언 부분에 rawValue를 포함시켜야 한다. 사용자 정의 타입으로 만든 옵션 집합이 기본 집합 연산을 수행하기 위해선 rawValue 프로퍼티는 반드시 FixedWidthInteger 프로토콜을 따르고 있는 타입(Int, UInt8 등등)이어야 한다. 다음으로는 정적(static) 변수로 고유한 2의 거듭제곱 값(1, 2, 4, 8, …)을 rawValue로 갖는 옵션들을 생성한다. 이렇게 2의 거듭제곱 값을 rawValue로 가져야 각각의 옵션들은 단일 비트로 표현이 가능하기 때문이다.

OptionSet은 다음과 같이 정의할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
struct ShippingOptions: OptionSet {
let rawValue: Int

static let nextDay = ShippingOptions(rawValue: 1 << 0) // 0001
static let secondDay = ShippingOptions(rawValue: 1 << 1) // 0010
static let priority = ShippingOptions(rawValue: 1 << 2) // 0100
static let standard = ShippingOptions(rawValue: 1 << 3) // 1000

static let express: ShippingOptions = [.nextDay, .secondDay] // ??
static let all: ShippingOptions = [.express, .priority, .standard] // ??
}

먼저 왜 2의 거듭제곱 값으로 표현되어야 할까?

그 이유는 위에서 언급되었듯이 단일 비트로 각각의 값을 표현할 수 있기 때문이다. (Bitmask)

1
2
3
4
0001 // 1
0010 // 2
0100 // 4
1000 // 8

만일 2의 거듭제곱이 아니라면 어떻게 될까? 이에 대해서는 밑에서 얘기해보자.

처음 OptionSet을 봤을 때 가장 먼저 든 생각은 *”열거형(enum)이랑 뭐가 다른거지!?”* 였다. 그 생각을 몇몇의 글들을 읽어보면서 정리해보았다.

열거형과 OptionSet을 채택한 타입과의 가장 큰 차이점은 단일 타입 변수가 가질 수 있는 경우의 수의 차이다. 열거형 타입은 해당 타입의 케이스를 하나만 나타낼 수 있다. 하지만 OptionSet 여러 케이스를 하나의 변수로 표현할 수 있다. 열거형에서 이를 표현하려면 여러 케이스에 해당하는 열거형 타입을 배열과 같은 콜렉션 타입으로 표현해야 한다.

OptionSet은 여러 케이스를 하나의 변수로 담을 수 있기 때문에 각각의 케이스는 유일해야 한다. 그리고 이를 위해 우리는 비트 값으로 이를 표현한 것이고 2의 거듭제곱으로 표현한 것도 그와 같은 이유다. 실제로 여러 케이스를 표현한다고 여러 값을 갖고 있을 필요는 없다. 위의 ShippingOptions을 살펴보자.

express[.nextDay, .secondDay]로 표현된다. 하지만 [ShippingOptions]이 아닌 ShippingOptions 타입 변수에 할당된다. 2의 거듭제곱으로 표현되고 있다는 걸 상기시켜보면 실제로 express의 값은 .nextDay(0001).secondDay(0010)을 더한 0011인 것이다. 그리고 모든 원시 값이 2의 거듭제곱이기 때문에 0011만 보아도 00010010의 조합인 걸 알 수 있다.

여기서 2의 거듭제곱으로 해야 하는 이유가 나온다. 만일 2의 거듭제곱 값이 아닌 0011 , 즉 3을 원시 값으로 갖는 케이스가 있다면 .express와 구분이 되지 않기 때문이다.

또한 서버에 값을 전송할 때 리스트 형태의 값을 전달하는 것보다 이렇게 비트 마스크로 표현된 값을 보내는 것이 더 수월할 수 있다. (물론 서버 개발자와의 확실한 상호 협의가 요구되지만)

그리고 위에서 언급했듯이 OptionSet을 따르는 프로토콜은 집합 연산을 수행할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
let normal: Pet = [.nextDay, .secondDay]
let options2: Pet = [.nextDay, .priority, .standard]
let intersection = options1.intersection(options2)
print(intersection) // ShippingOptions(rawValue: 1) 👉 nextDay

let union = options1.union(options2)
print(union) // ShippingOptions(rawValue: 15) 👉 nextDay, secondDay, priority & standard

let subtracting = options1.subtracting(options2)
print(subtracting) // ShippingOptions(rawValue: 2) 👉 secondDay

let contains = options1.contains(.standard)
print(contains) // false

오늘은 이렇게 간단히 OptionSet에 대해 알아보았다. 이를 사용한다면 보다 여러 케이스를 포함하는 상황에서 보다 적은 코드로 각각의 상황에 대응할 수 있을 것 같다.

ABI Stability

스위프트 5.0에서 가장 많이 주목을 받고 있는 부분이 바로 ABI 안정화(Stability)이다. 대체 ABI가 안정화된다는 것이 무엇을 의미하는지, 왜 ABI 안정화를 지원하게 됬는지 ABI 자체가 무엇인지에 대해 알아보려 한다.

기본적으로 Swift ABI Stability Manifesto 글을 기반으로 각종 블로그 글들을 참고하면서 나름대로 정리해보았다.


The Big Picture

현재 스위프트의 가장 최우선 순위는 향후 스위프트 버전과의 호환성(compatibilty)이다. 호환성은 다음의 두 가지 목표를 갖고 있다.

  1. 소스 호환성(Source compatibility)은 새 컴파일러가 구 버전의 스위프트를 컴파일 할 수 있다는 것을 의미한다. 이는 새 스위프트 버전이 나오면 스위프트 개발자들이 직면했던 마이그레이션의 고통을 줄여주는 목적을 갖고 있다. 소스 호환성 없이는 프로젝트는 버전 잠금(version-lock)에 직면하는데 이는 프로젝트와 패키지 내부의 모든 소스코드가 동일한 스위프트 버전으로 작성되어야 한다는 것을 의미한다. 소스 호환성이 존재한다면 패키지 작성자는 그들의 사용자가 새로운 버전의 스위프트를 사용할 수 있도록 하며 여러 스위프트 버전을 단일 코드 기반으로 유지할 수 있다.
  2. 바이너리 프레임워크와 런타임 호환성(Binary framwork & runtime compatibility)은 다양한 스위프트 버전에서 동작할 수 있는 바이너리 형태의 프레임워크 배포를 가능하게 한다. 바이너리 프레임워크는 프레임워크 API의 소스-레벨 정보와 통신하는 스위프트 모듈 파일(Swift module file)과 런타임 중 로드되는 컴파일된 구현체인 공유 라이브러리(shared library)를 포함한다. 따라서 바이너리 프레임워크 호환성(binary framework compatibility)은 두 가지 목적을 갖고 있다.
    • 모듈 포맷 안정성(Module format stability)은 컴파일러가 프레임워크의 공개 인터페이스를 나타내는 모듈 파일을 안정화시킨다. 이는 API의 선언과 inlineable 코드를 포함한다. 이 모듈 파일은 컴파일러가 프레임워크를 사용하는 클라이언트 코드를 컴파일 할 때 타입 검사, 코드 생성 등과 같은 필수 작업을 진행하는데 사용된다.
    • ABI 안전성(ABI stability)은 서로 다른 스위프트 버전으로 컴파일된 어플리케이션과 라이브러리 사이의 바이너리 호환성을 가능하게 한다.

What is ABI?

런타임 중에 스위프트 프로그램 바이너리는 ABI를 통해 다른 라이브러리와 요소들과 상호작용한다. ABI는 Application Binary Interface를 의미하며 독립적으로 컴파일된 바이너리 엔티티(실체)들이 서로 연결되고 실행되기 위해서 반드시 따라야 규격이다. 이러한 바이너리 엔티티들은 함수를 호출하는 방법, 메모리에서 데이터가 표현되는 방법 그리고 그들의 메타데이터가 어디에 존재해야하는지 그리고 어떻게 접근해야하는지 등의 저수준의 상세 사항들을 따라야 한다.

API를 사용할 때 우리는 사용하려는 기능의 내부 로직은 크게 신경쓰지 않고 API의 원하는 기능을 취할 수 있다. 라이브러리가 업데이트되어도 우리가 호출하는 API의 메소드의 외형은 동일하게 사용할 수 있어야 한다. 라이브러리가 업데이트될 때마다 외형이 변경된다면 우리는 계속해서 우리의 코드를 이에 맞춰 수정해야 한다.

ABI도 마찬가지라고 생각한다. ABI의 안정화 없이 스위프트 버전이 올라가게 되면 우리는 프로젝트에서 사용되는 스위프트를 새 버전의 스위프트로 계속해서 마이그레이션 해야 한다.

What is ABI Stability?

ABI 안정화란 향후 새로운 버전의 컴파일러가 안정환된 ABI 규격을 따르는 바이너리를 생성할 수 있는 수준으로 만드는 것을 의미한다.

ABI 안정화는 오직 외부에 노출되는 공공 인터페이스와 심볼의 불변성에만 영향을 미친다. 내부 심볼, 컨벤션 그리고 레이아웃은 ABI 규격을 깰 필요 없이 지속해서 변경할 수 있다. 예를 들어 미래의 컴파일러는 공개되어 있는 공공 인터페이스를 유지하는한 내부 함수 호출의 호출 규칙을 자유롭게 변경할 수 있다.

What Does ABI Stability Enable?

ABI 안정화는 OS 공급자가 운영체제에 스위프트 표준 라이브러리와 구 버전 혹은 새 버전의 스위프트로 만들어진 어플리케이션과의 호환성을 갖는 런타임을 내장할 수 있도록 한다. 이렇게 되면 이러한 플랫폼 상의 앱을 배포할 때 표준 라이브러리를 앱 내에 포함시켜 배포할 필요가 없어진다. 이는 도구의 의존성을 줄여주고 운영체제에 보다 우수한 조화를 가능하게 한다.

기존의 iOS 앱 번들에는 해당 앱을 만드는데 사용한 버전의 스위프트 표준 라이브러리를 포함하고 있었다. 즉 스위프트 4.2로 만들어진 앱은 스위프트 4.2 ABI를 포함하는 스위프트 4.2 동적 라이브러리를 앱 번들 내에 포함하고 있었고 스위프트 3.0으로 만들어진 앱은 3.0 ABI를 포함하는 동적 라이브러리를 앱 번들 내에 포함하고 있었다는 것이다.

즉 각각의 언어는 각각의 OS 버전과 서로 다른 ABI 규격을 갖고 있었기 때문에 다른 버전의 OS에서 앱이 실행되기 위해선 앱 번들 자체에 스위프트 동적 라이브러리를 포함했어야 했다.

ABI가 안정화되면 이렇게 앱 번들 내에 해당 버전의 스위프트 다이나믹 라이브러리를 포함할 필요가 없기 때문에 앱 사이즈는 줄어들 수 있다. 왜냐하면 OS와 스위프트의 버전 차이가 존재해도 ABI 규격은 동일하기 때문이다. 스위프트 표준 라이브러리와 스위프트 런타임이 OS에 내장되는 것이다.

Module Stability

ABI 안정화는 런타임 중의 스위프트 버전들의 혼용에 관한 것이다. 컴파일 시점은 어떤가? 스위프트는 “swiftmodule”이라는 불투명한 아카이브 포맷을 사용해 수동으로 작성된 헤더 파일이 아닌 “MagicKt” 프레임워크와 같은 라이브러이의 인터페이스를 나타낸다. 그러나 “swiftmodule” 포맷 역시 현재 컴파일러 버전에 묶여잇고 이는 만일 “MagicKit”이 다른 스위프트 버전으로 만들어졌다면 개발자는 import MagicKit을 통해 해당 프레임워크를 사용할 수 없다는 것을 의미한다. 즉 앱 개발자와 라이브러리 제작자는 반드시 같은 버전의 컴파일러를 사용해야 한다.

이러한 제한 사항을 없애기 위해 라이브러리 제작자는 현재 구현중인 현재는 구현되고 있는 모듈 안전성(module stability)라 불리는 기능을 필요로 한다. 이를 통해 라이브러리를 사용하는 개발자는 모듈이 어떤 컴파일러로 만들어졌는지 생각할 필요 없이 모듈을 사용할 수 있다.

예를들어 스위프트6 그리고 스위프트7 컴파일러는 스위프트6로 만들어진 프레임워크의 인터페이스를 읽을 수 있는 것이다.

Libraray Evolution

우리는 지금까지 컴파일러 교체에 관해 얘기했지만 스위프트 코드도 동일하다. 오늘날 스위프트 라이브러리가 변경되면 해당 스위프트 라이브러리를 사용하는 앱은 재컴파일 되어야 한다. 이는 몇 가지 장점이 있는데 컴파일러가 앱이 사용하는 라이브러리의 버전을 정확히 알고 있기 때문에 코드 크기를 줄일 수 있는 추가적인 가정(assumption)을 할 수 있고 앱을 보다 빠르게 실행시킬 수 있다. 하지만 이러한 가정은 다음 라이브러리 버전에는 맞지 않을 수 있다.

Library Evolution 기능은 앱을 재컴파일 할 필요 없이 새로운 버전의 라이브러리의 기능을 사용할 수 있도록 하는 것이다.

위의 예제에서 앱은 노란색 버전으로 만들어진 프레임워크로 만들어졌다. library evolution과 함께 노란색 버전을 가진 시스템에서의 실행은 물론이고 새롭게 개선된 빨간색 버전에서도 실행될 수 있다.


참고 자료