SwiftUI Essentials (1)

Views and modifiers

View들은 UI를 구성하는 가장 기본적인 블록이다. UIKit의 UIView나 AppKit의 NSView와 같이 UI를 구성하는 기본적인 단위라고 할 수 있다.

다음 앱의 UI를 계층 구조로 살펴보자.

이 계층을 SwiftUI로 작성하면 다음과 같다.

SwiftUI는 이러한 뷰의 계층을 코드로 표현한다. 왼쪽의 코드 구조는 오른쪽의 뷰 계층 구조와 상당히 흡사한 것을 확인할 수 있다.

또한 코드에서 살펴볼 수 있듯이 뷰 계층을 표현하는데 addSubview와 같은 메소드를 사용하지 않는다. SwiftUI는 하나의 계층 구조를 각 뷰 조각들로 구성하는 것이 아니라 계층 전체를 하나의 완전한 구조로 생성한다. 왜냐하면 SwiftUI는 뷰를 명령형(imperatively)과 반대인 선언형(declaratively)으로 정의하고 있기 때문이다.

명령형과 선언형의 차이점을 살펴보자.

  • 명령형 코드 : 명시적인 명령(explicit commands)을 통해 결과를 구성
  • 선언형 코드 : 묘사(describing) 통해 결과를 구성. 단 이를 어떻게 생성할지는 다른 주체에 의해 결정

둘의 차이가 정의로만은 부족할 수 있다. 상황을 예를 들어 둘의 차이를 살펴보자.

명령형 코드는 친구에게 아보카도 토스트를 만드는 방법을 알려주는 것과 같다.

선언형 코드는 아보카도 토스트를 만드는 요리사에게 토스트 주문을 하는 것과 같다.

친구에게 토스트 만드는 방법을 설명할 때는 7. 아보카도의 중심을 제거해라.와 같이 내가 직접 단계별로 필요한 결과를 전달한다.

반면, 요리사에게는 내가 원하는 토스트의 모습을 묘사하여 전달하고 그것을 어떻게 만드는지는 전적으로 요리사의 몫이다. 그리고 요리사가 전문가라면 우리는 항상 최상의 품질을 보장받을 수 있다.

이 두 상황을 통해 명령형 코드와 선언형 코드의 차이점을 보다 쉽게 이해할 수 있었다.

SwiftUI가 요리사의 역할을 하는 것이다. 그럼 이제 SwiftUI의 요소들을 하나씩 살펴보자.

View Container Syntax

뷰 컨테이너는 여러 다른 컨텐트 뷰(Content View)들로 구성되어 있다. 뷰 컨테이너에는 VStack, HStack 등이 존재한다. 뷰 컨테이너의 일반적인 문법은 다음과 같다.

VStack을 다음과 같이 사용할 수 있는 것과 같다.

1
2
3
4
5
VStack { 
Imgae(...)
Text(...)
Text(...)
}

Image, Text와 같은 컨텐트 뷰들은 뷰 빌더(View Builder)라는 클로저 안에 나열된다. 그리고 뷰 컨테이너의 생성자는 이 뷰 빌더 클로저를 인자로 받는다. addSubview와 같은 함수를 호출하는 대신 이 클로저 블록 안에 원하는 뷰를 순서대로 나열만 해주면 된다.

실제로 뷰 빌더가 내부적으로 어떻게 동작하는지 확인하기 위해 VStack API를 살펴보자.

생성자 인자 중 content@ViewBuilder 속성(attribute)이 붙어있는 것을 확인할 수 있다. 스위프트 컴파일러는 @ViewBuilder 속성이 붙어 있으면 해당 클로저를 우리가 나열한 컨텐트 뷰들이 포함된 단일 뷰를 반환하는 클로저로 변환한다.

이런 특수한 클로저를 뷰 컨테이너의 생성자에 전달해줌으로써 뷰 컨테이너와 컨텐트 뷰들은 들여 쓰기로 자연스레 구분될 수 있다.

또한 VStackalignmentspacing과 같은 인자를 추가로 받아 정렬이나 간격을 조정해줄 수 있다.

SwiftUI에서 VStack, HStack과 같이 컨트롤(Control)도 뷰 컨테이너의 종류로 다른 뷰를 컨텐트 뷰로 포함할 수 있다. Control에는 Button, Toggle Slider 등이 있다. 위의 코드에서처럼 Text뿐만 아니라 다른 뷰도 컨텐트 뷰로 포함할 수 있다.

컨트롤은 사용자와 상호작용할 수 있는 요소들을 말한다. 공식 문서를 통해 컨트롤에 어떤 것들이 있는지 알 수 있다.

컨트롤과 뷰 컨테이너는 추후에 더 자세히 살펴보도록 하고 이젠 $ 싸인에 주목해보자.

Binding Syntax

Stepper를 선언하는 코드를 살펴보자. order.quantity를 넘기는데 $ 싸인이 앞에 붙었다. 이는 단순히. order.quantity 을 넘기는 것이 아닌 바인딩을 넘기는 것이다. 그럼 여기서 말하는 바인딩이란 뭘까?

영상의 예제 앱에서 StepperOrderForm이란 뷰에 포함되어 있다.

OrderForm은 현재 순서를 추적하기 위해 Order 타입에 의존하고 있다. 이 프로퍼티를 살펴보면 @State란 속성이 붙어있는 것을 확인할 수 있다. @State 속성이 붙어있으면 SwiftUI는 이를 보고 내부적으로 지속성 있는 상태(persistent state)를 생성하고 관리하며 상태의 값을 이 프로퍼티를 통해 접근하도록 한다.

우린 이 프로터티에 접근해 상태의 값을 읽거나 쓸 수 있다.

1
Text("Quantity: \(order.quantity)")

Stepper는 정적인 뷰가 아닌 컨트롤이다. 그 말은 사용자가 Stepper의 버튼을 누르면 그 상태가 변경될 수 있다는 의미다. 이를 위해선 단순히 읽기 전용인 값을 전달하는 것이 아니라 바인딩을 전달해야 한다.

바인딩은 일종의 관리되는 참조(managed reference)로 이를 통해 하나의 뷰가 다른 뷰의 상태를 변경할 수 있다. 이 예제에선 StepperOrderForm의 상태를 $order.quantity를 통해 변경하고 있는 것이다.

SwiftUI에서의 데이터 흐름에 대한 자세한 내용은 Data Flow Through SwiftUI 영상을 참고하자.

다시 예제 앱으로 돌아와 우리가 아직 살펴보지 못한 문법을 살펴보자.

Modifier

Text("Avocado Toast")에서 우린 font(.title)과 같은 메소드를 호출할 수 있다. 이 메소드가 하는 작업은 간단하다. 호출한 뷰로부터 새 뷰를 만들어내는 것이다. SwiftUI에서 이런 메소드를 변경자(Modifier)라 부른다.

이런 변경자에 의해 뷰 계층이 어떻게 변경되는지 살펴보자. Text("Avocado Toast")을 포함하는 VStack은 다음과 같은 계층 구조를 가진다.

하지만 여기에 font(.title) 변경자를 적용하면 뷰 계층은 다음과 같이 변경된다.

이렇게 변경자로 생성된 뷰는 기존의 뷰를 감싸고 뷰 계층에 포함된다. 이런 변경자는 다수의 변경자들과 함께 체이닝될 수 있다.

이렇게 변경자를 추가하게 되면 계층 구조는 빠른 속도로 비대해진다. 우리는 이전까지 이렇게 뷰 계층이 비대해지면 성능 이슈에 대해서 고민하곤 했다. 기존의 뷰 계층은 최대한 작고 가벼워야 했다.

하지만 SwiftUI는 이러한 부분에 대한 걱정을 덜어도 된다. 위에서 언급했듯이 우리는 선언형 코드를 작성한다. 우리는 단지 원하는 모습을 묘사할 뿐이고, SwiftUI가 이를 최적화한다. 우리가 아무리 많은 변경자를 사용해 Text를 여러 뷰로 감싸도 SwiftUI가 이를 보다 효율적인 자료구조로 최적화한다. 그리고 이렇게 최적화된 자료구조는 렌더링 시스템이 렌더링 하는데 사용한다.

이렇게 변경자 체이닝 문법은 성능 이슈에 대해 걱정할 필요 없이 많은 이점을 제공한다. 그중 하나로 변경자 체이닝은 시각적 요소의 직관적인 순서를 강제한다. 즉 체이닝에 참여하는 변경자의 순서에 따라 최종 렌더링 되는 모습이 달라진다는 것이다.

만약 이런 속성들을 변경자 체이닝으로 변경하는 것이 아닌 Text의 내부에 포함된 속성이라고 가정해보자. 우린 시행착오와 문서 없이는 각각의 속성들이 어떤 순서로 적용되는지 알 수 없을 것이다. 이런 속성들을 변경자를 통해 적용함으로써 우린 순서를 명시적으로 지정할 수 있다.

또한 이런 변경자들은 여러 뷰들에서 공유될 수 있다.

이렇게 변경자를 공유함으로써 각각의 뷰들은 보다 단순해질 수 있고 자신들만의 인터페이스에 집중할 수 있다. 이것이 SwiftUI의 기본 원칙이다.

더 작고 단일 목적의 뷰라는 원칙을 따름으로써 우리는 보다 이해하기 쉽고, 유지 보수가 쉬운 뷰를 만들 수 있다.

재사용성 역시 증가한다.

그리고 이렇게 각자의 역할별로 작게 나누어진 뷰들을 통해 보다 큰 뷰를 효과적으로 구성할 수 있다.

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 공부도 다시 시작할 수 있다!


참고 자료

Universal Link & Custom URL Scheme

커스텀 스킴과 유니버셜 링크에 대해 알아보도록 하자.

이 둘에 대해 얼핏 많이 들어보았을 것이다. 단순히 말해 모두 딥링크를 지원하는 방법이다. 그렇다면 이 둘의 차이점은 무엇일까? 그리고 딥링크의 역할은 무엇이며 딥링크를 통해 우리는 어떤 이점을 얻을 수 있을까. 이것도 함께 알아보도록 하자.

딥 링크란?

위키피디아의 정의에 따르면 딥링크는 특정 페이지에 도달할 수 있는 링크를 말한다. 링크란 단어는 실생활 대화에서도 많이 사용하고 실제로 링크도 많이 사용한다.

*”야~ 내가 링크 보냈어 확인해봐”*

우리는 실생활에서 이런 말을 자주 하곤 한다. 그렇다면 위의 말에 담긴 뜻은 무엇일까? 그것은 바로 정보 전달이다. 정보 전달로는 부족하다. 편리한 정보 전달이다. 그럼 여기서 편리하다는 것은 무엇을 의미할까?

상대방이 내가 알려준 정보, 컨텐츠를 스스로 키워드를 이용하여 검색을 통해 접하는 것이 아닌 링크로 하여금 상대방에게 내가 전달하고자 하는 정보, 컨텐츠를 바로 노출시키는 것이 나는 편리한 정보 전달의 수단으로써의 링크라고 생각한다.

위의 *”야~ 내가 링크 보냈어 확인해봐”*의 링크가 딥링크다. 이 딥링크는 단순히 웹 브라우저 상에서만 존재하는 수단이 아니다. 우린 이런 딥링크를 이용해 특정 앱의 특정 컨텐츠를 상대방에게 편리하게 전달할 수 있다.

이렇게 우리는 딥링크를 통해 사용자에게 우리 앱의 컨텐츠를 Seamless하게 노출시킬 수 있다.


커스텀 URL 스킴이란?

커스텀 스킴은 iOS에서 딥링크를 지원하는 방법 중 하나이다.

현재는 애플에서 유니버셜 링크를 사용하도록 강력하게 추천하고 있다.

Universal links are strongly recommended as a best practice.

이 이유에 대해선 밑에서 천천히 살펴보도록 하자.

공식 문서에서 커스텀 URL 스킴을 앱 내의 자원에 접근할 수 있는 방법을 제공할 수 있는 방법이라고 설명하고 있다. 애플은 시스템 앱들에 대해 미리 정의된 앱 스킴을 제공한다. mailto, tel 그리고 facetime이 그것들이다.

커스텀 URL 스킴을 지원하는 방법은 어렵지 않다.

  1. 앱의 URL 포맷을 정의한다.

  2. 스킴을 등록해 시스템이 정의된 URL에 따라 알맞는 앱으로 사용자를 보낼 수 있도록 한다.

  3. 앱이 열린 URL을 적절히 처리해야 한다.

URL은 반드시 정의된 커스텀 스킴 이름으로 시작해야 하며 파라미터를 함께 넘겨 이에 따른 별도의 동작을 처리하게 할 수도 있다.

1
2
3
4
5
6
7
let url = URL(string: "myphotoapp:Vacation?index=1")

UIApplication.shared.open(url!) { (result) in
if result {
// The URL was delivered successfully!
}
}

위와 같이 정의된 커스텀 스킴은 문서에서도 중복되지 않게 작성하라고 권고하고 있다. 그럼에도 불구하고 커스텀 스킴은 중복이 발생할 수 있다. 그리고 이렇게 중복된 스킴을 갖고 있는 두 앱이 존재한다면 시스템은 사용자가 의도하지 않은 다른 앱을 실행시키게 될 수 있다.

또한 앱이 설치되어 있지 않다면 스킴은 동작하지 않는다. canOpenURL(_:)를 통해 스킴에 해당하는 URL을 열 수 있는지를 검사할 수 있지만 앱이 설치되어 있지 않았을 때 우리가 원하는 궁극적인 행동은 커스텀 URL 스킴으로는 한계가 있다.

그렇다면 여기서 “우리가 궁극적인 행동”이란 무엇일까?

우리는 다른 앱에서 우리의 컨텐츠를 그대로 보여주는 것이 아닌 사용자가 우리 앱에서 우리의 컨텐츠를 소비할 수 있도록 이동시키고 싶을 것이다. (사파리를 통해 미디움의 컨텐츠를 보는 것보다, 미디움 앱을 통해 컨텐츠를 보는 것이 보다 쾌적한 사용자 경험을 제공할 수 있다.)

하지만 앱이 설치되어 있지 않았을 때 단순히 이동시키지 못하는 것에 그치지 않고 최소한 우리 앱을 설치할 수 있는 앱 스토어로 이동이라도 시켜준다면 우리 앱으로의 유입은 앞의 상황보다 더욱 나아질 수 있다.

위의 내용을 통해 우리는 커스텀 URL 스킴의 두 가지 단점을 알 수 있었다.

  1. 중복이 일어나 다른 앱을 실행시킬 수 있다.
  2. 앱이 설치되어 있지 않을 때 충분한 조치를 취할 수 없다.

그리고 1번의 이유는 치명적인 보안 이슈의 여부도 있기 때문에 애플은 이러한 커스텀 URL 스킴의 단점을 보완하고자 유니버셜 링크라는 것을 만들었다.

중요

iOS 9.0 이상의 앱에선 열고자 하는 앱의 커스텀 스킴을 Info.plistLSApplicationQueriesSchemes키의 값으로 반드시 등록을 해야 한다. 그렇지 않으면 설령 해당 스킴의 앱이 설치되어 있다 하더라도 canOpenURL(_:) 메소드는 항상 false를 반환할 것이다.


유니버셜 링크란?

유니버셜 링크도 커스텀 URL 스킴과 동일하게 모바일 환경에서 딥링크를 지원하기 위한 수단이다. 하지만 유니버셜 링크는 앱이 설치되어 있지 않으면 웹 페이지를 통해 컨텐츠를 보여준다는 것이다. (물론 앱이 설치되어 있다면 바로 해당 앱을 실행시켜 컨텐츠를 보여준다.) 그렇기 때문에 유니버셜 링크는 표준 HTTP, HTTPS 링크다.

앱이 설치되어 있지 않을 경우 유니버셜 링크를 통해 컨텐츠를 웹을 통해 노출시키는 방법과 앱 스토어로 사용자를 보내는 방법이 있다.

서비스에 유니버셜 링크를 지원하기 위해선 웹 서버에서의 별도의 작업을 필요로 한다. 앱의 번들 ID와 앱이 열어야 할 경로(path)를 포함하는 AASA(Apple-App-Site-Association 파일이 웹 서버에 등록되어 있어야 한다.

AASA 파일을 서버에 등록해야 하기 때문에 해당 도메인 소유자가 아닌 이상 등록을 할 수가 없다. 그렇기 때문에 고유하고 안전하게 딥링크를 지원할 수 있다.

AASA 파일은 JSON 형태로 다음과 같이 작성되어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"applinks": {
"apps": [],
"details": [{
"appID": "D3KQX62K1A.com.example.photoapp",
"paths": ["/albums"]
},
{
"appID": "D3KQX62K1A.com.example.videoapp",
"paths": ["/videos"]
}]
}
}
  • appID: .의 포맷의 값으로 앱의 식별자를 의미하는 값.

  • paths: 앱에서 처리할 수 있는 링크 경로의 배열

    와일드카드 문자를 사용해 보다 다양한 경로를 간편하게 지원할 수 있다.

    /videos/samples/201?/* : videos/sample에서 2010년대(201?)의 하위 모든 경로를 의미한다.

이미 웹 브라우저를 통해 컨텐츠를 즐기고 있던 사용자가 웹 브라우저 컨텐츠 내부의 유니버셜 링크를 클릭하면 웹 브라우저를 통해 계속해서 컨텐츠를 즐기수 있도록 한다. 계속해서 웹 브라우저에서 컨텐츠를 즐기던 사용자를 앱으로 보내버리는 것은 오히려 사용자 경험을 해칠 수 있기 때문이다.

사용자가 유니버셜 링크를 클릭하여 링크가 활성화되면 iOS는 앱을 실행시키고 NSUserActivity 객체에 정보를 담아보낸다. 그리고 사용자는 AppDelegate 메소드인 application(_:continue:restorationHandler:)를 오버라이딩하여 처리한다.

유니버셜 링크 활성화를 통해 전달된 NSUserActivityactivityTypeNSUserActivityTypeBrowsingWeb이다. 또한 NSUserActivity 객체의 webpageURL 프로퍼티는 사용자가 클릭한 URL을 포함하고 있다. 그리고 NSURLComponents를 통해 URL의 경로나 파라미터를 추출해낼 수 있다.

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
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([Any]?) -> Void) -> Bool
{
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL,
let components = NSURLComponents(url: incomingURL, resolvingAgainstBaseURL: true),
let path = components.path,
let params = components.queryItems else {
return false
}

print("path = \(path)")

if let albumName = params.first(where: { $0.name == "albumname" } )?.value,
let photoIndex = params.first(where: { $0.name == "index" })?.value {

print("album = \(albumName)")
print("photoIndex = \(photoIndex)")
return true

} else {
print("Either album name or photo index missing")
return false
}
}

번외

일반적인 딥링크가 아닌 디퍼드 딥링크(Deferred)라는 개념이 존재한다. 그럼 디퍼드 딥링크는 기존의 딥링크와 다른 점은 무엇일까?

기존의 딥링크를 통해 우리가 할 수 있는 최선의 행동은 웹을 통해 컨텐츠를 보여주던지, 혹은 앱스토어로 사용자를 보내는 것에 불과하다. 디퍼드 딥링크를 굳이 번역해보자면 지연된 딥링크 정도로 해석해볼 수 있을 것이다. 그렇다면 여기서 무엇이 지연되었다는 것일까?

상황을 가정해보자. 앱이 설치되어 있지 않은 상태에서 기존의 딥링크를 통해 앱스토어까지 사용자를 보냈고 사용자가 앱을 설치했다. 딥링크의 궁극적인 목표는 앱의 설치가 아니라 특정 컨텐츠로 사용자를 보내는 것이다. 그렇기 때문에 현재 상황에서 딥링크 본연의 목적을 달성했다고 보기는 힘들다. 여기서 디퍼드 딥링크가 이런 문제를 해결해 줄 수 있다.

디퍼드 딥링크를 사용하면 링크를 통해 앱스토어로 보내진 사용자가 앱을 설치하고 최초로 앱을 실행시켰을 때 최초 링크를 통해 보여주었어야 할 컨텐츠로 사용자를 이동시킨다. 이렇게 앱스토어 > 앱 설치 > 앱 실행이라는 지연된 과정을 거쳐 컨텐츠를 사용자에게 보여줄 수 있는 기능의 딥링크를 디퍼드 딥링크라고 한다.

그렇다면 링크를 통해 앱을 설치하고 최초로 실행한 사용자와 일반적으로 앱 스토어에서 바로 앱을 설치하여 최초로 실행한 사용자를 어떻게 구분하여 컨텐츠를 보여줄까? 이 부분에 대해서 정확하게 설명된 내용을 찾기 힘들었지만 큰 그림에서 설명해준 글에선 이를 다음과 같이 설명하고 있다.

Historically this has been done through fingerprinting. Fingerprinting works by generating a “fingerprint” of a web user consisting of a device’s IP address and user-agent (operating system, operating system version, and other device specific parameters) and generating another fingerprint when a user opens the app. Recent techniques like Branch’s People Based Attribution model have moved beyond fingerprinting to using an unique Identity Graph that matches a device’s unique browser identifier to its unique app identifier, tying it back to the actual user across web and app to achieve true cross platform attribution, giving better matching between link licks and app opens.


참고자료

  1. Allowing Apps and Websites to Link to Your Content
  2. Universal links in iOS
  3. Deferred Deep Linking

이직기 (feat. 근황)

근황 👣

이직을 하게 되었고 퇴사를 했다.

10월 중순에 퇴사를 하고 새 회사로 출근하는 것으로 정해졌지만 전 회사 사정으로 의도치 않게 9월까지 마무리하는 것으로 결정나서 애매하게 시간이 떠버리게 되었다.

그래서 나만의 시간, 가족과의 시간을 보내게 되었다.

10월 첫째 주는 제주도로 혼자 여행을 떠났고, 10월 둘째 주는 가족과 예정되어 있던 코타키나발루로 떠나게 되었다. 그리고 출근 마지막 주인 10월 셋째 주는 학교도 방문하고, 다시 출근을 하면 만나기 힘들 것 같은 지인들도 만나려 한다.

그리고 틈틈이 평소에 읽고 싶었던 문서나 책을 읽으며 시간을 보내고 있다. (효율적으로 시간을 보내고 있는 것 같지는 않다.😅)

나만의 시간을 보내게 되어 나태해졌는지 블로그에 소홀해졌다. 그래서 이렇게 근황과 이직기를 시작으로 다시금 글을 써보려 한다.

이직기 🏢 ➡️ 🏬

이직을 결심하게 된 이유

군대와 동아리 등 여러 단체 생활을 하면서 느낀 점은 누구와 함께 하는지가 가장 중요하다는 것이었다. 이는 직장 생활에서도 동일하게 작용한다고 생각한다. 어쩌면 직장 생활에서 가장 중요한 부분 중 하나라고 생각한다.

다행히 전 직장을 포함해서 난 사람 운은 정말 좋았다고 생각한다. 팀원들과 의사소통을 하면서 내가 부족한 점이 무엇인지를 많이 깨닫게 되었고 또한 팀원들을 보면서 스스로 어떻게 개선해야 할지도 깨닫게 되었다. 기술적인 토론도 심심치 않게 나누어 팀원들의 훌륭한 지식도 여러 방면으로 습득하게 되었다. 위의 내용을 한 줄로 요약하자면 사람 때문에 이직하게 된 것은 아니다. 오히려 사람 때문에 아쉬움이 컸다.

내가 이직을 하게 된 계기는 서비스에 있다. 결정적인 이유는 서비스와 내가 맞지 않았다고 생각한다. 나는 퇴근을 하고선 이슈가 터지지 않는 이상 회사 앱을 켜본 기억이 드물다. 내가 만들고 있는 앱이지만 내가 사용하지 않았단 이야기다. 개인적으로 나는 내가 잘 사용할 수 있는 서비스를, 내가 필요한 서비스를 만들 때 스스로 더 재미를 느끼고 개발을 하는 것 같다.

물론 평생 나와 맞는 것, 내가 재미를 느끼는 것만 개발할 수 있다고는 생각하지 않는다. 하지만 적어도 주니어 개발자에겐 재미를 느끼고 개발한다는 동기부여가 필요하다고 생각한다. 이런 나의 가치관과 전 회사의 서비스는 맞지 않았다.

또한 전 회사의 서비스는 비교적 보편적인 서비스는 아니었다고 생각한다. 현재는 특수한 목적을 갖고 서비스를 사용하는 사용자가 대다수였다. 개인적으로 나는 사람들의 일상에 녹아있는 보편적인 서비스를 개발하고 싶은 마음이 컸다.

위의 내용을 토대로 다음 회사는 다음의 세 가지를 충족하길 원했다.

  1. 커뮤니케이션이 활발한 조직
  2. 내가 자주 사용할 수 있는 서비스
  3. 보편적인 서비스

물론 주변의 많은 사람들이 *”적어도 2년, 아니 1년이라도 채우고 움직여라”라는 조언을 많이 해주었다. 내가 생각해도 그 조언은 틀리지 않았고 나 같아도 주변에 그렇게 조언을 해줄 것 같다. 하지만 내가 1년도 채우지 않고 이직을 하기로 결정하게 된 계기는 나에겐 *”1년도 채우지 않고 이직했다”라는 꼬리표를 감수할 수 있을 만큼 위에 나열된 부분에 대한 욕망이 더욱 컸기 때문이라고 생각한다. 어쩌면 나는 평생 다른 사람들에게 이러한 이유들을 수없이 “해명”해야 할 수도 있다. 하지만 그런 점을 감수하고도 보다 빨리 내 개발 가치관과 맞는 회사를 찾는 것이 중요하다고 판단한 것이다.

내가 문을 두드렸던 기업 리스트

많은 기업에 지원한 것은 아니지만 그 나름대로 의미가 있을 것이라고 생각하여 이렇게 기록을 해본다.

순서는 무관하며 기업명은 밝히지 않도록 하겠습니다. 기업을 설명하는 코멘트는 개인적인 주관이 포함되어 있을 수 있습니다.

  • 국내 OTT 서비스 중 가장 영향력이 높다고 생각하는 W사

    • 지원 경로 : 프로그래머스 2019 앱 개발자 온라인 잡 페어

    • 채용 프로세스 :

      W사는 기업 개별 전형에선 과제 전형을 진행

    • 결과 : 과제 전형에서 탈락

    • 개인적으로 네이버 핵데이를 통해서 잠깐 경험했던 미디어 프로그래밍에 대해 관심도 있었고, 서비스 자체도 애용했던 서비스라 지원을 하게 되었다. 과제 전형을 진행하게 되었고, 과제 전형에선 어떤 라이브러리를 사용해도 무관하다고 안내받았다. 48시간이라는 시간 내에 주어진 과제를 제출해야 했다. 처음엔 48시간이 충분하다고 생각했으나 일과 병행하려니 시간은 생각보다 많이 촉박했다. 기능 구현에 대한 고민과 더불어 프로그램 구조에 대한 고민을 함께 하려니 나에겐 턱없이 부족한 시간이었다. 어찌어찌 제출은 하였으나 내가 생각해도 완성도가 높진 않았다고 생각했고, 결과는 내가 예상한 것에서 벗어나지 않았다.

    • 채용 과정에서 좋았던 점 : 과제에 대한 결과와 더불어 과제에 대한 피드백이 주어졌다는 점에서 무엇이 부족했는지 납득할 수 있었고 내 코드를 되돌아볼 수 있는 계기가 되었다.

    • 채용 과정에서 아쉬웠던 점 : 과제 안내 메일에서 자사 서비스 UI를 참고하여 개발하되 동일할 필요는 없다고 언급하였으나 피드백에선 “UI 구현에서 자사 서비스 앱과 비슷하게 구현하셨으면 했습니다.“라는 피드백을 받아 이 피드백에 대해선 조금은 납득하기 어려웠다. 하지만 과제를 내는 기업 입장에선 자사 서비스와 유사하게 구현되어 있으면 당연히 좋은 인상을 받을 수 있다는 것은 부인하기 힘든 사실인 것 같다.

  • 동네 기반 중고 직거래 플랫폼 서비스 D사

    • 지원 경로 : W사와 동일

    • 채용 프로세스 : W사와 동일

      D사는 기업 개별 전형에서 전화 면접을 진행

    • 결과 : 최종 면접에서 탈락

    • 서비스를 사용하면서 굉장히 만족도가 높았던 서비스였고, 기타 다른 중고 거래 플랫폼들에 비해 신뢰도가 상당히 높았던 서비스였다. 또한 슈퍼 개발자분들이 모여 계신 기업으로 iOS 개발자분도 유명하신 분이시라 더욱 함께하고 싶은 마음이 컸다. 전화 면접에서는 iOS 전반에 걸친 질문들을 해주셨고, 간단한 CS에 관한 질문도 해주셨다. 전화 면접을 통과하고 최종 면접을 진행했고, 합류하고 싶었던 마음이 컸던지 다른 기업 면접과는 비교가 안될 정도로 크게 긴장하였다. 그로 인해 알고 있던 내용들도 제대로 답하지 못했다. 또한 끝나고 들었던 생각이지만 너무 내 의견만을 고집했던 경향이 없지 않아 있었던 것 같다. 하지만 덕분에 보다 사고적으로 열릴 수 있었던 계기였다.

    • 채용 과정에서 좋았던 점 : 확실히 유연한 분위기 속에서 면접이 진행되었고, 면접관으로 들어오신 분들 모두가 지원자를 배려하려는 마음이 느껴져서 좋았다. 또한 면접비도 제공이 되었다.🤑 시간이 어느 정도 흐르고 한 개발자 컨퍼런스에서 면접관으로 들어오셨던 분이 나를 알아보시고 말씀을 걸어주셔서 굉장히 감사하게 생각했다.

  • 한국의 글로벌 키즈 컨텐츠 기업 S사

    • 지원 경로 : 지인의 추천
    • 채용 프로세스 : 서류 전형 ➡️ 1차 실무진 면접 ➡️ 2차 CEO 및 인사팀 면접 (최종 면접)
    • 결과 : 최종 합격
    • 이 회사는 D사 최종 면접을 보러 갔던 날 마주쳤던 지인의 추천으로 지원하게 된 기업이다. 이 기업은 미국에 거주하는 어린 조카들을 통해 먼저 알게 되었고, 국내 기업이라는 점에서 적지 않게 놀랐던 기억이 난다. 글로벌 컨텐츠로는 국내에서 높은 지위를 갖고 있는 기업이라고 생각이 든다. 서류 전형을 거쳐 진행한 1차 실무진 면접의 난이도는 꽤나 높았던 것으로 기억한다. 부족한 CS 지식에 대해 깊이 파고드는 질문들이 이어졌고 만족스러운 답변을 하지 못했던 것 같다. 또한 iOS에 관한 질문에서도 평소에는 그냥 넘어갔을 부분에 대해 질문이 들어와 적지 않게 당황했던 기억이 난다. 1차 면접을 그리 잘 보지 않았던 터라 기대는 하지 않았으나 1차 면접에 합격하여 최종 면접에 임했고 최종 면접답게 조금은 추상적인 질문들을 주고받으며 면접을 진행했다. 그리고 얼마 뒤 최종 합격 통보를 받았다.
    • 채용 과정에서 좋았던 점 : 모든 채용 프로세스가 신속하게 진행되었다. 역시 면접관으로 들어오셨던 분들의 배려로 편안한 분위기 속에서 면접을 진행할 수 있었다.
  • 일상에서 누구나 누릴 수 있는 금융의 이로운 흐름을 만드는 K사

    • 지원 경로 : 헤드헌팅
    • 채용 프로세스 : 서류 전형 ➡️ 과제 전형 ➡️ 1차 실무진 면접 ➡️ 2차 CTO 및 인사팀 면접 (최종 면접)
    • 결과 : 최종 합격
    • 헤드헌터를 통한 대규모 채용을 진행하고 있던 기업이었고, 나 역시 헤드헌터로부터 채용 관련 연락을 받았다. 내가 일상에서 상당히 애용하고 있는 간편 결제 서비스를 제공하는 기업으로 앱 역시 상당히 잘 만들어져 있다는 인상을 받고 있던 터라 주저 없이 지원을 하게 되었다. 서류 전형 이후 주어진 과제는 재직자를 감안하여 2주라는 넉넉한 시간이 주어졌고, 덕분에 기능 외적으로 구조적인 부분에 대해 많은 고민을 할 수 있었다. 과제를 완성하고 관련 도큐먼트를 작성하는데도 적지 않은 노력을 기울였다. 과제 전형 합격 이후 1차 면접에선 주어진 시간 중 90%는 과제 리뷰로 진행되었다. 내가 작성한 코드에 대해 면접관분들과 많은 의견을 나누었고, 이러한 과정 속에서 내가 생각한 의도를 충분히 전달할 수 있었다. 1차 면접 과정은 굉장히 즐거운 시간으로 기억된다. 의견을 나누는 과정에서 무엇이 서툴렀고, 무엇을 잘못 생각했는지 깨달을 수 있던 시간이었다. 그리고 1차 면접을 보고 돌아오는 길에 합격 통보를 받았고 2차 면접 일정을 잡았다. 금요일에 진행된 2차 면접은 CTO님과 두 분의 인사담당분들이 함께 했다. 1차 면접과는 사뭇 분위기가 달랐고 조금 더 경직된 분위기에서 면접은 진행되었다. 그리고 1시간 30분 넘게 진행되었던 1차 면접과는 다르게 40분 만에 면접이 끝났다. 이로 인한 불안감은 있었지만 월요일에 최종 합격 통보를 받을 수 있었다.
    • 채용 과정에서 좋았던 점 : 모든 채용 프로세스가 신속하게 진행되었다. 1차 실무진 면접은 면접이라는 상황을 떠나 굉장히 즐거운 경험으로 기억된다. 과제 전형 역시 프로그램 전반적으로 많은 고민을 해볼 수 있는 경험이었다. 채용 프로세스를 진행하면서 이렇게 간절함이 컸던 회사는 처음이었다.

결과적으로 내가 일상에서 상당히 자주 애용하는 서비스를 운영하는 K사를 선택하였다. 그리고 끝이 아닌 새로운 시작이라고 생각하며 해당 서비스에 기여하고 싶은 마음이 상당히 크다. 더 많은 개발자분들과 더 많은 대화를 나눌 수 있다는 기대감도 크다. 그리고 그런 과정에서 느끼고 배운 것들을 주기적으로 글을 통해 기록해보려 한다.

RxSwift 들여다보기

RxSwift 들여다보기


RxSwift를 들여다보려 한다. 이유는 간단하다. 내부 동작 원리가 궁금했다.

이 궁금증의 시작은 RxSwift의 대표적인 개념에서부터 시작했다. 정확히 말하자면 RxSwift만의 대표적인 개념이 아니라 리액티브 프로그래밍 언어들에서 볼 수 있는 대표적인 개념들이다.

  • Observable
  • Observer
  • Subscription

리액티브 프로그래밍 언어에서 Observable은 이벤트 스트림을 흘려보내고 Observer는 이를 Subscription하여 이벤트에 반응하게 된다.

처음 RxSwift를 접했을 때 이 문장을 읽고 이해하는데 그렇게 오랜 시간이 걸리지 않았다. 하지만 코드를 작성하고 읽기 시작하면서 하나의 의문점이 생겼는데, 계속 공부를 하면 해결이 될까 했던 궁금증이 여전히 해결되지 않는 모습을 보였다.

그 궁금증의 주체는 바로 Observer다. 간단한 코드를 살펴보자.

1
2
3
Observable<String>.just("Hello World").subscribe(onNext: { 
print($0)
}).disposed(by: disposeBag)

이 곳에서 위에서 언급된 대표적인 개념들을 찾아보자.

ObservableSubscription은 육안으로 확인할 수 있다. 그렇다면 Observer는 어디 있을까. 이게 내 궁금증의 시작이었다. 코드를 그대로 해석하면 Observable을 바로 Subscription하는 모습을 확인할 수 있다.

*”여기서 Observer는 누구지 “*

그래서 RxSwift 소스 코드를 열어보았다.

ObservableType


먼저 ObservableType부터 살펴보자. ObservableType은 프로토콜로 ObservableObservableType을 따른다. ObservableType을 바로 보기 전에 앞서 ObservableType+Extensions.swift 파일 안의 extension부터 시작해보자.

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public func subscribe(onNext: ((Element) -> Void)? = nil, onError: ((Swift.Error) -> Void)? = nil, onCompleted: (() -> Void)? = nil, onDisposed: (() -> Void)? = nil)
-> Disposable {
let disposable: Disposable

if let disposed = onDisposed {
disposable = Disposables.create(with: disposed)
}
else {
disposable = Disposables.create()
}

#if DEBUG
let synchronizationTracker = SynchronizationTracker()
#endif

let callStack = Hooks.recordCallStackOnError ? Hooks.customCaptureSubscriptionCallstack() : []

let observer = AnonymousObserver<Element> { event in

#if DEBUG
synchronizationTracker.register(synchronizationErrorMessage: .default)
defer { synchronizationTracker.unregister() }
#endif

switch event {
case .next(let value):
onNext?(value)
case .error(let error):
if let onError = onError {
onError(error)
}
else {
Hooks.defaultErrorHandler(callStack, error)
}
disposable.dispose()
case .completed:
onCompleted?()
disposable.dispose()
}
}
return Disposables.create(
self.asObservable().subscribe(observer),
disposable
)
}

⁉️

벌써 첫 번째 궁금증이 해결되었다! subscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable 내부적에서 생성되고 있었다.

우리가 넘겨준 onNext, onError, onCompleted 그리고 onDisposed들이 내부적으로 어떻게 사용되고 있는지 살펴보자. 꽤나 직관적인 걸 알 수 있다. 하지만 첫 번째 궁금증이 해결된 것과 동시에 여러 궁금증들이 생겼다.

직관적이어도 정확히 어떻게 동작하는지는 이 코드만 봐서 나는 모두 알 수 없었다. 그래서 하나씩 따라 들어가 보았다.

1
2
3
4
5
6
if let disposed = onDisposed {
disposable = Disposables.create(with: disposed)
}
else {
disposable = Disposables.create()
}

먼저 subscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable 메소드에 onDisposed 클로저가 넘어왔다면 이를 이용해 Disposable을 만들고 넘어오지 않았다면 빈 Disposable을 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let observer = AnonymousObserver<Element> { event in            
#if DEBUG
synchronizationTracker.register(synchronizationErrorMessage: .default)
defer { synchronizationTracker.unregister() }
#endif

switch event {
case .next(let value):
onNext?(value)
case .error(let error):
if let onError = onError {
onError(error)
}
else {
Hooks.defaultErrorHandler(callStack, error)
}
disposable.dispose()
case .completed:
onCompleted?()
disposable.dispose()
}
}

여기서 생성되는 ObserverAnonymousObsever로 생성자 함수 인자로 클로저를 받는다. 여기서 받는 클로저는 우리가 subscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable 메소드의 인자로 받은 클로저들을 이벤트에 알맞게 호출해주는 행위를 정의하고 있다.

그리고 위에서 생성된 disposable을 사용하고 있는 것에 주목하자.

그럼 AnonymousObserver는 무엇이고 생성자로 전달된 클로저는 어떻게 사용되는 걸까 AnonymousObserver를 살펴보자.

AnonymousObservable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
final class AnonymousObserver<Element>: ObserverBase<Element> {
typealias EventHandler = (Event<Element>) -> Void

private let _eventHandler : EventHandler

init(_ eventHandler: @escaping EventHandler) {
#if TRACE_RESOURCES
_ = Resources.incrementTotal()
#endif
self._eventHandler = eventHandler
}

override func onCore(_ event: Event<Element>) {
return self._eventHandler(event)
}

#if TRACE_RESOURCES
deinit {
_ = Resources.decrementTotal()
}
#endif
}

먼저 AnonymousObserverObserverBase 클래스를 상속받고 있다. 그리고 ObserverBase 클래스의 onCore(_:) 메소드를 오버라이딩하고 있고 그 내부에서 우리가 생성자로 넘겨준 클로저를 호출하고 있는 것을 확인할 수 있다.

그러면 이벤트가 흘러들어왔을 때 결국이 onCore(_:) 메소드가 호출되어야 우리가 넘겨준 클로저들도 호출될 텐데 onCore(_:)는 어디서 호출되는지 ObserverBase 클래스로 가보자.

ObserverBase

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ObserverBase<Element> : Disposable, ObserverType {
private let _isStopped = AtomicInt(0)

func on(_ event: Event<Element>) {
switch event {
case .next:
if load(self._isStopped) == 0 {
self.onCore(event)
}
case .error, .completed:
if fetchOr(self._isStopped, 1) == 0 {
self.onCore(event)
}
}
}

func onCore(_ event: Event<Element>) {
rxAbstractMethod()
}

func dispose() {
fetchOr(self._isStopped, 1)
}
}

onCore(_:) 메소드는on(_:) 메소드에서 호출되는 것을 확인할 수 있다. 그럼 다시 on(_:) 메소드가 호출되는 곳을 찾아야 하는데 이는 잠시 후에 밑에서 좀 더 알아보도록 하자. 일단 on(_:) 메소드가 호출되어야 한다는 사실만 기억하고 있자.

subscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable 메소드 내부에서 마지막으로 살펴볼 코드는 다음과 같다.

1
2
3
4
return Disposables.create(
self.asObservable().subscribe(observer),
disposable
)

subscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable 메소드 정의에서 볼 수 있듯이 Disposable을 반환하는데 위와 같이 생성해서 반환한다.

그럼 여기서 이제 나는

1
self.asObservable().subscribe(observer)

이 코드를 먼저 살펴보려 한다. 먼저 asObservable()Observable.swift 파일에서 확인할 수 있다. ObservableType 프로토콜 메소드로 기본 구현되어 있는 메소드를 Observable 메소드는 아래와 같이 정의했다.

1
2
3
public func asObservable() -> Observable<Element> {
return self
}

프로토콜인 ObservableType이 아닌 Observable 타입을 반환하기 위한 메소드이다.

그다음으로 살펴볼 것은 바로 ObservableType.swift 파일에 정의되어있는 func subscribe<Observer: ObserverType>(_ observer: Observer) -> Disposable where Observer.Element == Element 메소드다. 우리는 위에서 생성된 AnonymousObserver 객체를 이 메소드 안에 넣어주었다.

파일에 설명된 정의를 살펴보면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Subscribes `observer` to receive events for this sequence.
이 시퀀스에 대한 이벤트를 수신하기 위해선 `observer`를 구독해라.

* sequences can produce zero or more elements so zero or more `Next` events can be sent to `observer`
*시퀀스는 0개 혹은 그 이상의 요소들을 생산할 수 있고 그렇기 때문에 0개 혹은 그 이상의 `Next` 이벤트들은 `observer`에 전해질 수 있다.

* once an `Error` or `Completed` event is sent, the sequence terminates and can't produce any other elements
* `Error` 혹은 `Completed` 이벤트가 전달되면, 시퀀스는 종료되고 다른 요소를 생산할 수 없다.

It is possible that events are sent from different threads, but no two events can be sent concurrently to `observer`.
다른 스레드로부터 이벤트가 전달될 수 있다. 하지만 두 이벤트가 동시에 `observer`에 전달될 순 없다.

When sequence sends `Complete` or `Error` event all internal resources that compute sequence elements will be freed.
시퀀스가 `Complete` 혹은 `Error` 이벤트를 보내면 내부에서 시퀀스 요소들을 연산하던 자원들은 모두 자유로운 상태가 된다.

To cancel production of sequence elements and free resources immediately, call `dispose` on returned subscription.
시퀀스 요소 생산을 취소하고 자원을 즉시 자유롭게하고 싶다면, 반환되는 구독 객체의 `dispose` 메소드를 호출해라.

returns: Subscription for `observer` that can be used to cancel production of sequence elements and free resources.
반환값: `observer`에 대한 구독 객체로 시퀀스 요소 생산을 취소하거나 자원을 자유롭게 하는데 사용된다.

여기서 제일 위의 설명을 살펴보자.

1
이 시퀀스에 대한 이벤트를 수신하기 위해선 'observer'를 구독해라

그리고 리액티브 공식 사이트에서 설명하고 있는 Observable의 정의를 살펴보자.

1
2
In ReactiveX an observer subscribes to an Observable. Then that observer reacts to whatever item or sequence of items the Observable emits
ReactiveX에서 observer는 Observable을 구독한다. 그럼 그 observer는 Observable이 방출하는 아이템에 반응한다.

여기서 내가 이해한 것은 ObserverObservable을 구독하고 그 구독 객체를 우리가 관찰하는 것으로 이해했다.

음.. 구독에 대한 반응을 구독하는…? 그런 느낌으로 이해하고 있는데 이에 대해 피드백을 부탁드립니다. 😭

결국 우리가 subscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable을 호출하는 대상은 func subscribe<Observer: ObserverType>(_ observer: Observer) -> Disposable where Observer.Element == Element가 반환하는 구독 객체다.

Observable을 바로 구독하고 있던 것이 아니였다!

그럼 이번엔 위에서도 보았던 Disposable에 대해 조금 살펴보도록 하자. 먼저 Disposablesubscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable 안에서 여러 방식으로 생성되고 사용되고 있는 것을 확인할 수 있다. Disposable을 생성하는 방법은 몇 가지가 있다. 오늘은 위에서 확인할 수 있었던 세 가지 방법에 대해서만 살펴보도록 하자.

AnonymousDisposable

subscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable 내부에서 살펴볼 Disposable 생성 방법 첫번째는 AnonymousDisposable이다. AnonymousDisposable.swift을 살펴보자.

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
fileprivate final class AnonymousDisposable : DisposeBase, Cancelable {
public typealias DisposeAction = () -> Void

private let _isDisposed = AtomicInt(0)
private var _disposeAction: DisposeAction?

/// - returns: Was resource disposed.
public var isDisposed: Bool {
return isFlagSet(self._isDisposed, 1)
}

/// Constructs a new disposable with the given action used for disposal.
///
/// - parameter disposeAction: Disposal action which will be run upon calling `dispose`.
fileprivate init(_ disposeAction: @escaping DisposeAction) {
self._disposeAction = disposeAction
super.init()
}

// Non-deprecated version of the constructor, used by `Disposables.create(with:)`
fileprivate init(disposeAction: @escaping DisposeAction) {
self._disposeAction = disposeAction
super.init()
}

/// Calls the disposal action if and only if the current instance hasn't been disposed yet.
///
/// After invoking disposal action, disposal action will be dereferenced.
fileprivate func dispose() {
if fetchOr(self._isDisposed, 1) == 0 {
if let action = self._disposeAction {
self._disposeAction = nil
action()
}
}
}
}

extension Disposables {

/// Constructs a new disposable with the given action used for disposal.
///
/// - parameter dispose: Disposal action which will be run upon calling `dispose`.
public static func create(with dispose: @escaping () -> Void) -> Cancelable {
return AnonymousDisposable(disposeAction: dispose)
}

}

가장 먼저 보이는 것은 AnonymousDisposableDisposeBase 클래스를 상속받고 Cancelable을 체택하고 있다는 것이다. 그리고 create(with:) -> Cancelable을 보면 알 수 있듯이 Cancelable 프로토콜 타입으로 생성 결과를 반환한다. 그리고 dispose 메소드가 호출될 때 생성 인자로 받은 액션 클로저를 실행하는 것을 확인할 수 있다.

AnonymousDisposablesubscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable에서 onDisposed에 인자가 넘어오면 생성된다. 즉 diposed될 때 추가적으로 해주어야 할 동작이 넘어오면 해당 동작을 실행시킬 수 있는 AnonymousDisposable을 생성하는 것이다.

NopDisposable

그 다음으로 살펴볼 방법은 NopDisposable이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fileprivate struct NopDisposable : Disposable {

fileprivate static let noOp: Disposable = NopDisposable()

fileprivate init() {

}

/// Does nothing.
public func dispose() {
}
}

extension Disposables {
/**
Creates a disposable that does nothing on disposal.
*/
static public func create() -> Disposable {
return NopDisposable.noOp
}
}

코드를 살펴보면 알 수 있듯이 어떠한 추가적인 행동을 하지 않는다. dispose 메소드 안도 비어있다. subscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable에서 onDisposednil일때 NopDisposable을 생성한다. 즉 어떤 추가적인 행동을 취할 필요가 없을 때 NopDisposable을 생성하는 것으로 이해할 수 있다.

BinaryDisposable

마지막으로 살펴볼 방법은 BinaryDisposable을 통한 생성이다. BinaryDisposable은 두 개의 Disposable 객체를 인자로 받아 생성된다. 그리고 dispose 호출시 두 개의 Disposabledispose 메소드를 호출해준다.

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
private final class BinaryDisposable : DisposeBase, Cancelable {

private let _isDisposed = AtomicInt(0)

// state
private var _disposable1: Disposable?
private var _disposable2: Disposable?

/// - returns: Was resource disposed.
var isDisposed: Bool {
return isFlagSet(self._isDisposed, 1)
}

/// Constructs new binary disposable from two disposables.
///
/// - parameter disposable1: First disposable
/// - parameter disposable2: Second disposable
init(_ disposable1: Disposable, _ disposable2: Disposable) {
self._disposable1 = disposable1
self._disposable2 = disposable2
super.init()
}

/// Calls the disposal action if and only if the current instance hasn't been disposed yet.
///
/// After invoking disposal action, disposal action will be dereferenced.
func dispose() {
if fetchOr(self._isDisposed, 1) == 0 {
self._disposable1?.dispose()
self._disposable2?.dispose()
self._disposable1 = nil
self._disposable2 = nil
}
}
}

extension Disposables {

/// Creates a disposable with the given disposables.
public static func create(_ disposable1: Disposable, _ disposable2: Disposable) -> Cancelable {
return BinaryDisposable(disposable1, disposable2)
}
}

subscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable의 결과로 반환되는 DisposableBinaryDisposable이다. 그리고 이 BinaryDisposableonDisposed 인자값의 상태에 따라 만들어진 Disposable (AnonymousDisposable 혹은 NopDisposable)과 Observable에 대한 Observer의 구독 객체를 의미하는 Disposable로 만들어진다. Observable에 대한 Observer의 구독 객체는 우리가 Observable을 어떻게 생성하냐에 따라 다르다.

그럼 just를 통해 생성된 Observable을 예로 살펴보자.

just

just를 통해 생성된 Observable은 인자로 받은 단일 이벤트를 그 즉시 방출하고 바로 완료되어 Observable의 시퀀스가 종료된다.

먼저 just 메소드부터 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
extension ObservableType {
/**
Returns an observable sequence that contains a single element.

- seealso: [just operator on reactivex.io](http://reactivex.io/documentation/operators/just.html)

- parameter element: Single element in the resulting observable sequence.
- returns: An observable sequence containing the single specified element.
*/
public static func just(_ element: Element) -> Observable<Element> {
return Just(element: element)
}
}

Just 객체를 just(_:) 인자로 받은 Element로 생성하여 반환한다. 그럼 Just 정의부를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
final private class Just<Element>: Producer<Element> {
private let _element: Element

init(element: Element) {
self._element = element
}

override func subscribe<Observer: ObserverType>(_ observer: Observer) -> Disposable where Observer.Element == Element {
observer.on(.next(self._element))
observer.on(.completed)
return Disposables.create()
}
}

먼저 Just는 클래스로 Producer를 상속받는다.

ProducerObservable을 상속받는다. 여기선 이 정도만 알고 넘어가자. 끝까지 들어가니 머리가 뒤죽박죽이라 현재는 제대로 이해를 하지 못했다.😭

그리고 func subscribe<Observer: ObserverType>(_ observer: Observer) -> Disposable where Observer.Element == Element 메소드를 오버라이딩하고 있다.

just가 단일 이벤트를 방출하고 바로 종료된다는 뜻을 위의 코드를 통해 이해할 수 있을 것이다. 그리고 justNopDisposable을 생성한다. 그리고 Observable은 구독이 일어나기 전까지 이벤트를 방출하지 않는다는 의미를 여기서 코드로 이해할 수 있다.

func subscribe<Observer: ObserverType>(_ observer: Observer) -> Disposable where Observer.Element == Element가 호출됨에 따라 on(_:) 메소드가 호출되고 func subscribe<Observer: ObserverType>(_ observer: Observer) -> Disposable where Observer.Element == Element 메소드는 subscribe(onNext: ,onError: ,onCompleted: ,onDisposed:) -> Disposable 메소드가 호출되어야 호출되는 메소드기 때문이다.

그럼 위에서 “ 일단 on(_:) 메소드가 호출되어야 한다는 사실만 기억하고 있자. “ 라고 했던 말을 여기서 확인할 수 있다.

뭔가 흐름이 잡힌 것 같으면서도 잡히지 않은 것 같은 느낌이 든다. 아직 왜 여기서 BinaryDisposable을 사용하는 이유는 잘 이해가 가질 않는다. 그래도 Observable의 생성되고 이벤트에 반응하는 것까지의 흐름을 파악할 수 있어서 유익한 시간이었다. 아직 100프로 이해하지 못한 부분들에 대해서는 추후에 계속해서 이해해보려 노력할 것이다.

참고자료


  • [RxSwift] Observer
    • 나와 같이 Observer 존재에 대한 의문을 갖고 글을 작성하셨다. 덕분에 어디부터 어떻게 살펴보아야 할지 감을 잡을 수 있었다.
  • RxSwift Disposable
    • 역시 RxSwift의 코드를 열어보면서 코드들의 존재 이유에 대해 서술해주신 글이다.

Specifying the Scenes Your App Support

WWDC19에서 iOS 13이 발표되고 새로운 것들이 다수 생겼다. 바인딩을 지원하는 Combine, 선언형 UI 방식으로 UI를 구현할 수 있는 SwiftUI 프레임워크까지 많은 것들이 나왔다.

이와 함께 앱 생명주기와 관련된 새 공식 문서가 등장했는데 여기서 Scene이라는 개념이 등장한다. Managing Your App’s Life Cycle 문서를 살펴보기 전에 Specifying the Scenes Your App Support 문서를 먼저 살펴보며 Scene의 등장에 대한 이유를 이해하고자 한다.

참고로 아직은 베타 문서라 추후에 내용이 변경되거나 사용되는 클래스, 프로토콜 그리고 메소드 등의 이름이 변경될 수 있다.

Overview


iOS 13과 그 이상에서는 사용자가 앱 UI의 사본을 여러 개 만들어 앱 스위처 내에서 서로 전환할 수 있다. iPad에서 사용자는 또한 앱 UI의 사본과 사본을 나란히 디스플레이할 수 있다. 각각의 앱 UI 사본에 대해 Scene 객체를 사용하여 UI를 화면에 띄우는 윈도우, 뷰 그리고 뷰 컨트롤러를 관리한다.

WWDC19 키노트에서는 이를 Multi-Window Capability라 설명하며 동일한 메모 앱 두 개를 나란히 실행시키는 모습을 보여주었다.

사용자가 새 Scene을 요청할 때, UIKit은 이에 해당하는 Scene 객체를 만들고 이것의 초기화 설정을 다룬다. 이를 위해 UIKit은 당신이 제공한 정보에 의존한다. 앱은 반드시 자신이 지원하는 Scene의 유형과 해당 Scene을 관리하는데 사용하는 객체를 선언해야 한다. 이 작업은 앱의 Info.plist에 정적으로 정의하거나, 런타임에 동적으로 정의할 수 있다.

중요

Scene을 앱에서 지원하는 것은 선택이지만, 앱의 UI 사본들을 동시에 보여주고 싶다면 반드시 지원해야 한다.

Enable Scene Support in Your Project Settings


앱은 앱의 구성 설정을 갱신하여 Scene을 명시적으로 선택해야 한다.

  1. Xcode 프로젝트를 연다.
  2. General Settings로 이동한다.
  3. Deployment Info 섹션에서 “Support multiple windows” 체크박스를 활성화한다.

멀티플 윈도우 옵션을 활성화하면 Xcode는 앱의 Info.plist 파일에 UIApplicationSceneManifest 키를 추가한다. 이 키의 존재는 시스템에게 당신의 앱이 Scene을 지원한다는 사실을 전달한다. 이 키의 값은 딕셔너리로 초기에는 오직 UIApplicationSupportsMultipleScenes 키만 포함하고 있는 상태다.

UIApplicationSupportMultipleScenes 키의 값은 당신의 앱이 실제로 동시에 여러 Scene을 지원하는지를 시스템에게 알린다. Xcode는 이 값을 기본적으로 true로 지정한다. 하지만 한 번에 하나의 Scene만 보여주고 싶으면 비활성화하면 된다. 멀티플 Scene을 지원하기 위해선 서로 다른 Scene들이 서로를 침범하지 않도록 방지하기 위한 추가 작업이 필요하다. 예를 들어 Scene에서 동일한 공유 데이터 구조를 사용하는 경우 앱 데이터 무결성을 유지하기 위해 해당 구조에 대한 접근을 조정해야 한다.

Configure the Details for Each Scene


UIKit은 당신이 제공한 정보를 사용해 앱의 Scene 생성을 담당한다. 가장 간단한 방법은 이 정보를 앱의 Info.plist를 사용하는 것이다.

  1. Xcode를 열고 Info.plist 파일을 선택한다.
  2. Application Scene Manifest 항목의 (+) 버튼을 누른다. 이 항목은 UIApplicationSceneManifest 키에 해당한다. 없는 경우 프로젝트 설정에서 위에서 언급한 대로 이를 추가하면 된다.
  3. 메뉴가 등장하면 Scene Configuration을 선택한다.
  4. Scene Configuration 항목에서 (+) 버튼을 클릭한다.
  5. 당신의 앱에 메인 Scene을 추가하기 위해 Application Session Role을 선택한다.
  6. 제공된 항목에 Scene의 상세 정보를 기입한다.

대부분의 앱은 오직 하나의 메인 Scene만 필요하지만 멀티플 Scene을 추가하고 각각을 다르게 구성할 수 있다. 예를 들어 알림과 관련된 컨텐츠를 보여주기 위한 두 번째 Scene을 포함시킬 수 있다. UIKit은 각 Scene에 대해 다음 정보를 필요로 한다.

  • UIWindowScene 클래스의 이름
  • 당신의 앱이 Scene을 관리하는데 사용하는 사용자 정의 델리게이트 객체 클래스의 이름. 이 클래스는 반드시 UIWindowSceneDelegate 프로토콜을 따라야 한다.
  • 앱에서 Scene을 내부적으로 식별하는데 사용하는 고유한 이름
  • Scene의 초기 UI를 포함하는 스토리보드의 이름. .storyboard 파일 확장자를 제외한 이름을 명시한다.

Scene을 구성하는데 필요한 정보 추가적인 정보는 UISceneConfigurations를 참고하라.

Create the Interface for Your Scene


스토리보드를 사용해 Scene의 UI를 지정한다. UISceneStoryboardFile 키에 지정한 스토리보드는 Scene을 보여주는데 사용되는 초기 뷰 컨트롤러를 포함한다. Scene 객체를 생성하는 것 외에도 UIKit은 Scene에 대한 윈도우를 생성하고 Scene의 스토리보드에서 초기 뷰 컨트롤러 지정한다. UIWindowSceneDelegate 객체의 메소드를 사용해서 코드로 이 뷰 컨트롤러를 교체할 수 있다.

중요

스토리보드의 초기 뷰 컨트롤러를 지정하는 것을 잊지 말아야 한다. UIKit는 UI를 구성할 때 이 뷰 컨트롤러의 존재에 의존한다.

Change Your Scene’s Configuration Dynamically


실제로 Scene 객체를 생성하기 전에 UIKit은 앱 델리게이트의 메소드인 application(_:configurationForConnecting:options:)를 호출하여 당신이 Scene과 연관된 상세 정보를 수정할 수 있도록 한다. 이 방법을 사용하여 UIKit에서 제공하는 옵션에 따라 Scene 구성을 조정할 수 있다. 예를 들어 시스템이 Scene에 알림 응답(Notification response)을 전달할 때 알림과 연관된 인터페이스와 함께 다른 스토리보드를 지정할 수 있다.

동적으로 Scene 구성을 구현하지 않으면 UIKit은 Scene을 생성하는데 Info.plist의 정보를 사용한다.

Adopt Scene-Based Life-Cycle Semantices


Scene에 대한 지원을 추가하면 앱이 생명 주기 이벤트에 대응하는 방식이 변경된다. Scene을 사용하지 않는 앱에선 앱 델리게이트 객체가 포그라운드 혹은 백그라운드로의 전환을 담당한다. 앱에서 Scene을 지원하게 되면 UIKit은 이러한 책임을 당신이 지정한 Scene 델리게이트 객체에 위임한다. Scene 생명 주기는 다른 Scene에 독립적이고, 앱 자체와도 독립적이다. 그러므로 당신이 지정한 Scene 델리게이트 객체가 이러한 전환을 담당해야 한다.

만일 앱이 iOS 12를 지원한다면 앱 델리게이트와 Scene 델리게이트 객체 모두에서 생명 주기 전환을 처리할 수 있다. UIKit은 오로지 하나의 델리게이트 객체에만 생명 주기 관련 이벤트 알림을 보낸다. iOS 13 이상에선 UIKit은 Scene 델리게이트 객체에 알림을 보내고, iOS 12 이하에선 UIKit은 앱 델리게이트에 해당 알림을 보낸다.

생명 주기 이벤트를 어떻게 다루는지에 대한 추가적인 정보는 Managing Your App’s Life Cycle를 참고하라.

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에 대해 알아보았다. 이를 사용한다면 보다 여러 케이스를 포함하는 상황에서 보다 적은 코드로 각각의 상황에 대응할 수 있을 것 같다.

Bitcode in iOS

오늘은 iOS의 비트코드(Bitcode)에 대해 알아보려 한다. 비트코드를 알아보게 된 계기는 현재 인턴을 진행하면서 기존 네이버 지도 API를 네이버 지도 V3 API로 교체를 해야 하는 작업을 과제로 받았다.

이를 위해 데모 프로젝트와 문서를 보면서 샘플 프로젝트를 만드려는데 시뮬레이터에서는 정산적으로 빌드가 되고 실행이 되는 반면 실제 디바이스에서 테스트를 진행하려니 비트코드 에러가 발생하면서 빌드조차 되지 않았다.

이 에러를 해결 방법은 간단했는데 Build Setting 탭으로 들어가 Enable Bitcode 항목을 No로 지정해주면 되었다. 하지만 늘 그렇듯 어떤 문제를 했는데 그 해결 방법이 어떠한 방법으로 문제를 해결했는지에 대해 알지 못하는 것만큼 불안한 것은 없다. 특히 이렇게 빌드 세팅의 속성을 변경해주는 경우에는 더 그렇다.

그래서 이에 관해 조금 찾아보았고 그 정보들을 정리해서 기록해보려 한다.

Bitcode

먼저 애플 공식 문서는 비트코드를 다음과 같이 설명하고 있다.

비트코드는 컴파일된 프로그램의 중간 표현(intermediate representation)이다. 비트코드를 포함한 앱을 앱 스토어에 업로드하면 앱은 컴파일되고 앱 스토어어와 링크될 것이다. 비트코드를 포함하면 새로운 버전의 앱을 앱 스토어에 다시 제출할 필요 없이 애플이 앱 바이너리(컴퓨터가 실행할 수 있는 코드를 포함하고 있는 파일)를 다시 최적화할 수 있다.

iOS 앱에서 비트코드는 기본으로 포함되지만 이를 선택할 수 있다. watchOS와 tvOS 앱에서 비트코드는 필수다. 만일 비트코드를 제공한다면 모든 앱과 앱 번들 안의 모든 프레임워크(프로젝트 안의 모든 타겟)는 비트코드를 포함해야 한다.

Xcode는 기본적으로 앱의 심볼을 숨기기 때문에 이는 애플이 읽을 수 없다. 앱을 앱 스토어에 올릴 때 심볼을 포함할 것인지에 대한 옵션이 주어진다. 심볼을 포함하면 테스트플라이트 혹은 앱 스토어를 통해 앱을 배포했을 때 애플은 앱에 대한 크래시 리포트를 제공한다. 만약 크래시리포트를 직접 수집하고 상징화(Symbolication : 상징화는 크래시 로그의 메모리 주소를 사람이 읽을 수 있는 함수명과 라인 넘버로 교체하는 작업을 말한다.)하기를 원한다면 심볼을 업로드할 필요 없다. 대신 앱 배포 후 비트코드 컴파일 dSYM 파일을 다운로드 받을 수 있다.

모든 뜻을 이해할 순 없었지만 내 프로젝트에서 에러가 발생하는 이유는 추측할 수 있었다. Xcode를 통해 프로젝트를 생성하면 기본적으로 비트코드는 포함되도록 설정되어 있다. 하지만 네이버 지도 V3 API 프레임워크는 비트코드를 포함하고 있지 않기 때문에 문서의 내용 중 *”만일 비트코드를 제공한다면 모든 앱과 앱 번들 안의 모든 프레임워크(프로젝트 안의 모든 타겟)는 비트코드를 포함해야 한다.”*에 위배된다. 그래서 프로젝트의 빌드 세팅에서 비트코드 포함 옵션을 No로 지정하니 프로젝트가 정상적으로 빌드 되고 실행될 수 있던 것이다.

하지만 여전히 비트코드 이 자체에 대한 이해는 부족하다. 좀 더 자료를 찾아보자. 역시 나와 비슷한 사람은 많았고 누군가 Quora에 질문을 올렸고 이에 대한 답변이 애플의 공식 문서보다 친절하고 보다 명확하게 와닿았다.

LLVM

비트코드를 이해하기 위해선 먼저 LLVM(Low Level Virtual Machine)에 대해 알아야 한다. LLVM은 라이브러리로 코드를 중간 매체 혹은 기계 코드로 컴파일하는데 사용된다. LLVM을 통해 많은 컴파일러와 언어들을 만들어낸다.

이렇게 만들어진 컴파일러의 컴파일 과정은 세 단계로 나뉜다.

  1. 컴파일러 프론트 엔드는 소스 코드를 중간 표현 단계로 변환한다.
  2. 이 중간 표현 단계는 불필요한 코드를 제거하고 하는 등의 최적하 과정을 겪는다. 이 과정은 소스 코드도 기계 코드도 아닌 중간 표현 단계에서 진행되는데 옵티마이저가 더욱 쉽게 해석할 수 있는 형태이기 때문이다.
  3. 컴파일러 백 엔드가 중간 표현 단계를 기반으로 기계 코드를 생성한다.

비트코드는 LLVM을 통해 앱의 코드를 받아 이를 비트코드로 전환하고 주어진 지침을 통해 이를 실행 가능한 앱으로 전환하는 방법을 안다. 즉 비트코드는 어떤 아키텍쳐에서도 실행되기를 준비하는 중간 단계인 것이다. 간단히 말해서, 이러한 구조는 애플이 앱 스토어에 새로운 CPU 지원을 백엔드에 쉽게 추가할 수 있다는 것을 의미하며 이렇게 되면 비트코드는 이를 통해 새로운 아키텍처로 컴파일 하는 방법을 알 수 있는 것이다.

비트코드를 포함하지 않는다면 컴파일러는 머신 코드만을 포함하는 실행 파일을 생성할 것이다.

하지만 비트코드를 포함한다면 비트코드는 기계 코드와 나란히 실행 파일에 포함될 것이다.

비트코드의 형태로 앱 스토어에 제출하면 앱 스토어는 해당 앱을 다운로드 받는 디바이스 환경에 맞춰 최적화를 진행하여 내려보낼 것이다. 이 과정은 앱 시닝(App Thinning)에 포함된다. 앱 시닝에 대한 내용은 추후에 살펴보고 포스팅할 예정이다.

일례로 살펴보면 애플은 2013년에 아이폰 5s부터 64비트 칩셋으로 교체할 것이라고 발표했고 앱 개발자들은 이를 위해 앱을 다시 컴파일해서 제출해야 했다. 그 이유는 비트코드가 아닌 실행 가능한 코드 자체를 올렸기 때문에 이는 새로운 아키텍쳐 칩셋 환경에서 동작할 수 없었기 때문이다. 이제는 비트코드가 포함된 실행 파일을 올려도 새로운 디바이스 환경에서 앱이 동작할 수 있게 되었다.


참고 자료

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과 함께 노란색 버전을 가진 시스템에서의 실행은 물론이고 새롭게 개선된 빨간색 버전에서도 실행될 수 있다.


참고 자료

init?(coder:), init(nibName:, bundle:), awakeFromNib()

평소에 뷰 컨트롤러나 뷰를 만들기 위해 스토리보드뿐만 아니라 .xib나 코드의 형태로 만들어 사용하기도 한다. 하지만 이들 각각의 방법으로 뷰 컨트롤러를 생성할 때 생성되는 시점이나 불리는 메소드를 명확히 알지 못해 매번 작성한 코드를 이리저리 움직여가며 동작을 확인했다. 오늘은 이렇게 정확하기 알지 못했던 개념을 개념을 더욱 명확하게 하기 위해 공부하고 기록한 내용이다.


xib? nib?

  • nib - NeXT Interface Builder
  • xib - XML Interface Builder

.xib.nib의 차이점은 무엇일까? 차이점이라기보단 .xib 파일은 빌드 시점에 .nib 파일의 형태로 바뀐다. 인터페이스 빌더로 작업한 UI는 .xib 파일의 형태로 저장되고 빌드 시점에 앱 번들로 복사되고 런타임에 로드된다. 또한 .xib 파일은 텍스트 기반의 파일로 바이너리 파일인 .nib 파일보다 소스 컨트롤에 용이하며 읽기에 용이하다.

init?(coder:)

xib 파일로 만든 뷰는 실제로 저장될 때 아카이빙 되어 저장된다. 그러므로 해당 뷰를 불러올 때는 언아카이빙를 통해 불러와야 한다. 스토리보드도 내부적으론 일종의 xib 파일들로 이루어져 있기 때문에 로그를 찍어보면 init?(coder:) 생성자가 불리는 것을 확인할 수 있다. 하지만 아직 이 시점에선 @IBOutlet이나 @IBAction은 준비되어 있지 않다.

init(nibName:, bundle:)

nib 파일명은 결국 우리가 생성해준 xib 파일이 바뀐 결과물이기 때문에 이 둘의 이름은 확장자를 제외하곤 동일하다.

init(nibName:, bundle:) 생성자는 코드로 nib 파일로 만들어진 뷰를 불러올 때 사용하는 생성자이다. 이는 본인도 적지 않게 사용해봤다. 사용법은 nibName 아규먼트에는 nib파일의 이름에 해당하는 값을 넣어주는데 본인은 언제나 두 아규먼트 모두에 nil 값을 할당해주었다. 그 이유가 궁금해서 찾아봤는데 공식 문서는 이를 다음과 같이 설명하고 있었다.

nibName

이 프로퍼티는 init(nibName:, bundle:) 생성자에 의해 생성되는 시점에 지정된 값을 포함한다. 이 프로퍼티는 nil일 수 있다.

뷰 컨트롤러의 뷰를 저장하기 위해 nib 파일을 사용한다면 뷰 컨트롤러를 생성하는 시점에 생성자에 해당 이름을 명시해주는 것을 추천한다. 하지만 만일 nib 파일 명을 지정해주지 않고 loadView를 오버라이딩하지 않는다면 뷰 컨트롤러는 xib 파일을 다른 방법을 이용해 이를 찾아낼 것이다. 구체적으로 말하자면 적절한 파일 이름 (.nib 확장자를 제외한)의 nib를 찾고 뷰를 필요로 할 때 이를 로드하여 사용할 것이다. 적절한 이름을 찾는 과정은 다음의 순서를 따라 찾는다.

  1. 만일 뷰 컨트롤러 클래스의 이름이 MyViewController와 같이 Controller 끝난다면 Controller를 제외한 MyView.nib 파일을 찾을 것이다.
  2. 뷰 컨트롤러와 이름이 동일한 nib 파일을 찾는다. MyViewController라면 MyViewController.nib

위와 같은 이유로 생성 시점에 아규먼트를 넘겨줘야 한다면 다음과 같이 생성자를 정의해줄 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyViewController: UIViewController {
private var prop: Int

init(prop: Int) {
self.prop = prop
super.init(nibBundle: nil, bundle: nil)
}

// 사용자 정의 생성자를 작성해주었기 때문에 반드시 required 생성자를 구현해주어야 한다.
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}

awakFromNib()

사실 awakeFromNib()는 생성자가 아니다. 근데 생각보다 이를 생성자라고 생각하고 사용하는 경우가 적지 않은데 이를 잘못 이해한다면 원하는 결과물을 얻을 수 없을 것이다. 그럼 awakeFromNib은 무엇일까?

이 메소드는 init?(coder:)를 통해 뷰가 모두 언아카이빙된 후 호출된다. @IBOulet@IBAction이 모두 자리가 잡힌 후 호출되는 것이다. init?(coder:)가 언아카이빙의 시작점이라면 awakeFromNib()은 끝나는 시점이라고 할 수 있다.

이 세 메소드들을 공부하면서 각각 로그를 찍어보았는데 한곳에서 알 수 없는 현상이 발생하였다. 이는 다음과 같은 상황이었다. xib 파일을 하나 만들고 UIViewController를 상속받는 뷰 컨트롤러를 생성하였고 이를 xib 파일의 File's Owner로 지정하였다. 그리고 뷰 컨트롤러의 awakeFromNib() 메소드에 로그를 출력하도록 코드를 추가해보았지만 awakeFromNib()은 호출되지 않았다.

그 이유를 찾아본 결과 File's Owner는 이렇게 언아카이빙 되는 뷰 객체와 관련성이 없다. 위에서 언급했듯이 awakeFromNib() 언아카이빙이 모두 끝난 후 호출되는 메소드이다. File's Owner는 언아카이빙이 시작되기 전에 이미 존재하고 언아카이빙이 모두 끝난 후 해당 객체와 연결되기 때문에 File's Owner인 뷰 컨트롤러 안에서의 awakeFromNib()의 호출은 무의미하다.

하지만 스토리보드와 뷰 컨트롤러에선 얘기가 좀 달라진다. 스토리보드에 뷰 컨트롤러를 올리고 로그를 찍어보면 정상적으로 찍히는 것을 확인할 수 있다.

1
2
ViewController - init(coder:)
ViewController - awakeFromNib

그 이유는 해당 뷰 컨트롤러 객체 클래스를 File's Owner가 아닌 해당 뷰 컨트롤러 자체이기 때문이다. 그렇기 때문에 스토리보드 위에 올라가있는 뷰 컨트롤러는 언아키이빙 과정을 거치기 때문에 정상적으로 awakeFromNib()이 호출되는 것을 확인할 수 있다.


참고자료

  1. iOS: initWithCoder:, initWithNibName:bundle:, awakeFromNib, loadView
  2. What’s up with the .NIB -> .XIB?