LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


Swift 타입 시스템 탐험기

안녕하세요. 김유진입니다. 이번 글에서는 제가 iOS 신입 스터디에서 가장 흥미롭게 공부했던 Swift의 타입 시스템을 다루겠습니다. 

어떤 프로그래밍 언어든 코드를 재사용하기 위한 도구가 있습니다. Swift에서는 제네릭스(generics)를 이용해서 유연하게 재사용 가능한 코드를 작성할 수 있습니다. 제네릭스는 Swift를 사용하다 보면 끊임없이 만나게 되는데요. ArrayDictionary를 포함해 표준 라이브러리의 수많은 API가 제네릭스로 구현되어 있기 때문입니다. Swift 제네릭스는 널리 쓰이면서 해마다 진화해 왔습니다. 또한 앞으로도 문법과 기능 측면에서 수많은 변화를 예고하고 있는 역동적인 주제입니다.

이번 글에서는 Swift의 제네릭스와 프로토콜 타입, opaque result 타입이 어떻게 맞물려 코드 재사용 문제를 풀어 나가고 있는지 살펴보고, 타입 시스템이 앞으로 나아갈 방향을 소개하겠습니다. 글은 다음과 같은 순서로 진행하겠습니다.

참고. 이 글의 모든 내용은 Swift 5.5 기준입니다.

코드 재사용을 위해 제네릭스 사용하기

스터디에서 가장 쉽게 받아들일 수 있었던 개념은 제네릭스였습니다. 제네릭스를 이용하면 여러 타입에 공통적으로 사용할 수 있는 함수와 자료형을 정의할 수 있습니다. 먼저 제네릭스가 없는 경우에 발생할 수 있는 문제점을 알아보기 위해 세 수를 인자로 받아 가장 큰 수를 리턴하는 maxInt(_:_:_:) 함수를 생각해 보겠습니다.

func maxInt(_ a: Int, _ b: Int, _ c: Int) -> Int {
    if a > b && a > c {
        return a
    } else if b > c && b > a {
        return b
    } else {
        return c
    }
}
​
print("The answer to life: (maxInt(3, 42, 7))")

위 함수를 만들고 난 뒤에 세 부동소수점 수 중 최댓값을 고르거나 세 문자열 중 사전 순으로 제일 뒤에 등장하는 문자열을 고르는 함수도 필요해졌다고 생각해 보겠습니다. 어떻게 하면 될까요? 가장 쉽게 생각할 수 있는 방법은 아래처럼 maxDouble(_:_:_:), maxString(_:_:_:) 함수를 추가하는 것입니다.

func maxDouble(_ a: Double, _ b: Double, _ c: Double) -> Double {
    if a > b && a > c {
        return a
    } else if b > c && b > a {
        return b
    } else {
        return c
    }
}
​
func maxString(_ a: String, _ b: String, _ c: String) -> String {
    if a > b && a > c {
        return a
    } else if b > c && b > a {
        return b
    } else {
        return c
    }
}

이런, 완전히 똑같은 코드가 세 번이나 반복되고 있습니다. 함수를 정의할 때야 복사를 하면 된다지만, 고쳐야 할 일이 생기면 매번 세 번씩 잊지 않고 똑같은 수정을 해줘야 하니 상당히 불편합니다. 이럴 때 제네릭스를 이용하면 구체적인 타입을 명시하지 않고 "어떤 타입"의 세 원소 중 가장 큰 원소를 고르는 max(_:_:_:) 함수를 정의할 수 있습니다. 아래 코드에서는 함수 이름 뒤에서 Element라고 타입 파라미터를 선언하고, 함수 인자와 리턴 타입에서 그 타입 파라미터를 사용하고 있습니다.

// ❌ 컴파일되지 않습니다. 에러 메시지: Referencing operator function '>' on 'Comparable' requires that 'Element' conform to 'Comparable' 
func max<Element>(_ a: Element, _ b: Element, _ c: Element) -> Element {
    if a > b && a > c {
        return a
    } else if b > c && b > a {
       return b
    } else {
       return c
    }
}
​
print("The answer to life: (max(3, 42, 7))")
print(max("Alphabet", "한글", "ひらがな"))
print("Maximum temperature today is (max(27.3, 31.2, 24.8))")

그런데 위 코드는 '>' 연산을 사용하려면 'Element'가 'Comparable'해야 한다며 컴파일이 되지 않았습니다. 이유가 뭘까요? 위 코드에서는 a > b에서와 같이 인자로 받은 Element 타입을 비교 연산에 사용하고 있는데요. 모든 타입이 비교 가능한 건 아니기 때문입니다. max(_:_:_:) 함수는 비교 가능한 원소에 대해서만 동작하면 됩니다. 그렇다면 Element 자리에 비교 가능한 타입만 올 수 있다는 정보를 컴파일러에게 전달해 주면 되는데요. 이때 프로토콜이 등장합니다.

제네릭스에 프로토콜로 제약 조건 걸기

저희가 지금 명시하고 싶은 것은 Element 타입이 크기를 비교할 수 있는 타입이라는 것입니다. 에러 메시지가 설명했듯이, Swift 표준 라이브러리는 크기를 비교할 수 있는 타입이 지켜야 할 Comparable 프로토콜을 정의하고 있습니다.

public protocol Equatable {
    static func == (lhs: Self, rhs: Self) -> Bool
}
​
public protocol Comparable: Equatable {
    static func < (lhs: Self, rhs: Self) -> Bool
}

Int, Double, String은 이미 Comparable 프로토콜을 만족하고 있습니다. 따라서 아래처럼 Comparable한 타입만 Element 자리에 올 수 있다는 제약을 설정해 주면 정상적으로 컴파일되는 것을 확인할 수 있습니다.

func max<Element: Comparable>(_ a: Element, _ b: Element, _ c: Element) -> Element {
    if a > b && a > c {
        return a
    } else if b > c && b > a {
        return b
    } else {
        return c
    }
}

만약 비교 연산이 아니라 특정 기능을 수행하는 메서드가 필요한 경우라면 직접 프로토콜을 정의해서 제약 조건으로 사용할 수도 있습니다. 예를 들어 신문 기사, 영상 보도 등 여러 미디어의 내용을 한 줄로 요약해서 보여주는 서비스를 만든다고 생각해 봅시다. 그렇다면 각각의 미디어를 타입으로 정의하고, 한 줄로 요약하는 기능을 프로토콜로 정의해서 해당 프로토콜을 제약 조건으로 활용하고 싶을 수 있습니다. 구체적으로 살펴보자면 다음과 같습니다.

protocol Summarizable {
    func summarize() -> String
}
​
struct NewsArticle: Summarizable {
    var title: String
    func summarize() -> String {
        title
    }
}
​
struct BlogPost: Summarizable {
    var title: String
    var author: String
    func summarize() -> String {
        "(author) posted (title)"
    }
}
​
func showSummary<Media: Summarizable>(of media: Media, to user: User) {
    print("Showed summary (media.summarize()) to user (user.name)")
}
​
showSummary(of: NewsArticle(title: "LINE에서 테스트를 최적화하는 방법"), to: User(name: "유진"))

먼저 '요약해서 문자열로 만들기'라는 행위를 Summarizable이라는 프로토콜로 정의했습니다. 그다음에 사용자에게 showSummary(of:to:) 함수의 타입 파라미터 Media 자리에 올 수 있는 타입을 Summarizable 프로토콜로 제한했습니다. 이제 각 미디어 타입마다 Summarizable 프로토콜만 구현해 주면 요약 결과물을 보여주는 코드를 공유할 수 있습니다.

프로토콜은 타입으로도 사용할 수 있습니다

Swift 프로그래밍 언어 가이드 책을 읽다 보니 프로토콜 그 자체를 타입으로도 사용할 수 있다는 것을 알게 되었습니다. 예를 들어 위에서 작성했던 showSummary(of:to:) 함수는 아래처럼 작성할 수도 있습니다.

// 제네릭스를 사용한 경우
func showSummary<Media: Summarizable>(of media: Media, to user: User) {
    print("Showed summary (media.summarize()) to user (user.name)")
}

// 프로토콜을 타입으로 사용한 경우
func showSummary(of media: Summarizable, to user: User) {
    print("Showed summary (media.summarize()) to user (user.name)")
}

위 함수의 media: Summarizable처럼 프로토콜 그 자체를 타입으로 사용하는 경우를 '프로토콜 타입' 혹은 'existential'이라고 부릅니다. 제네릭스를 사용할 때는 타입 파라미터를 선언하고 프로토콜로 제약을 설정하는 등 코드가 장황했지만, 프로토콜 타입을 사용하니 파라미터 바로 뒤에 프로토콜이 명시돼 코드가 명료합니다.

그렇다면 제네릭스와 프로토콜 타입은 같은 의미일까요? 어떻게 동작할까요? 스터디 과정에서 생긴 의문을 멘토님이 추천해 주신 Swift 성능 이해하기 WWDC 세션과 Swift 언어 포럼의 Improving the UI of generics 토론 스레드 등을 보며 해소할 수 있었습니다. 둘의 차이를 이해하기 위해 Summarizable한 미디어를 저장하는 큐를 만든다고 생각해 보겠습니다. 제네릭스를 사용하는 방법과 프로토콜 타입을 사용하는 방법, 두 가지를 생각해 볼 수 있습니다. 코드로 설명하자면 아래와 같습니다.

struct MediaQueueExistential { // existential은 프로토콜 타입의 또 다른 이름입니다
    var queue: [Summarizable]
}
​
struct MediaQueueGeneric<Media: Summarizable> {
    var queue: [Media]
}
​
​
let newsArticle = NewsArticle(title: "LINE에서 테스트를 최적화하는 방법")
let blogPost = BlogPost(title: "Swift 제네릭스 탐험기", author: "유진")
​
​
// ✅ 잘 컴파일됩니다
let queueExistential = MediaQueueExistential(queue: [newsArticle, blogPost])
​
​
// ❌ 에러 메시지: Type of expression is ambiguous without more context
let queueGeneric = MediaQueueGeneric(queue: [newsArticle, blogPost])

MediaQueueExistential은 별문제 없이 NewsArticleBlogPost 등 다양한 구체적인 타입의 원소를 큐에 받아들입니다. 하지만 MediaQueueGeneric을 초기화하려고 하면 타입이 모호하다는 에러 메시지와 함께 컴파일이 되지 않습니다. 에러 메시지가 말해주듯, 타입 유추에 도움이 될 만한 맥락을 주어야 할 것 같은데요. 일단 MediaQueueGeneric<NewsArticle>이나 MediaQueueGeneric<BlogPost> 타입은 특정한 종류의 미디어만 담을 수 있는 큐이기 때문에 저희가 원하는 타입이 아닙니다. 그렇다면 Summarizable 프로토콜 타입을 제네릭의 타입 파라미터로 주면 어떻게 될까요?

// ❌ 에러 메시지: Value of protocol type 'Summarizable' cannot conform to 'Summarizable'
let queueGeneric: MediaQueueGeneric<Summarizable> = MediaQueueGeneric(queue: [newsArticle, blogPost])

Summarizable 타입이 Summarizable 프로토콜을 만족하지 않는다는 에러가 발생했습니다. 도대체 무슨 이야기일까요? 문법도 깔끔하고 이상한 에러 메시지도 등장하지 않는 프로토콜 타입만 사용하고 제네릭스는 사용하지 않는 게 맞는 걸까요?

그럼 언제 프로토콜 타입을 사용하고 언제 제네릭스를 사용하죠?

제네릭스와 프로토콜 타입은 추상화의 단위가 다릅니다. 제네릭스는 타입 단위에서 추상화를 합니다. 첫 예시에서 세 값 중 가장 큰 값을 찾는 max(_:_:_:) 함수를 생각해 보면, 서로 비교할 세 인자는 모두 같은 구체적인 타입이었습니다. Int, String, Double 각각은 Comparable 프로토콜을 만족하지만, max(1, "xin chào", 2.718)과 같이 사용할 수는 없었죠. 이처럼 max(_:_:_:) 함수는 Comparable 프로토콜을 이용해 어떤 타입의 인자를 받을 수 있는지 제한하는 것이지, Comparable한 다양한 타입을 받을 수는 없습니다. 이런 의미에서 제네릭스를 타입 단위의 추상화라고 부릅니다.

반면 프로토콜을 사용해서 만든 MediaQueueExistential 자료구조에는 Summarizable을 만족하는 값이라면 무엇이든 프로토콜 타입으로 만들어 넣을 수 있기 때문에 NewsArticle이 들어갈 수도 있고, BlogPost가 들어갈 수도 있습니다. 프로토콜 타입은 이렇듯 해당 프로토콜을 만족하기만 한다면 어떤 값이든 들어갈 수 있기 때문에 값 단위의 추상화라고 부릅니다.

  제네릭스 프로토콜 타입
추상화 단위 타입
좋은 예 func max<Element: Comparable>(_ a: Element, _ b: Element) struct MediaQueue { var media: [Summarizable] }
나쁜 예 struct MediaQueue<Media: Summarizable> { var media: [Media] } func max(_ a: Comparable, _ b: Comparable)

하지만 여전히 왜 Summarizable 타입이 Summarizable 프로토콜을 만족하지 못하는지 알 수 없습니다. 그 이유를 이해하기 위해 먼저 제네릭스와 프로토콜 타입이 어떻게 구현되어 있는지 살펴보겠습니다. 제네릭스의 구현은 Swift 리포지터리의 제네릭스 디자인 문서를, 프로토콜 타입의 구현은 상기한 Swift 성능 이해하기 WWDC 세션을 참고해서 살펴보았습니다.

구현: 제네릭스와 특수화, 프로토콜 타입과 Existential 컨테이너

먼저 제네릭스는 타입에 대한 메타데이터를 넘겨주는 방식이나 특수화(specialization)를 통해 구현됩니다.

타입에 대한 메타데이터를 넘겨주는 방법은 각 제네릭스 원소가 지원하는 함수의 목록을 함수 호출 시에 함께 넘겨주는 방식입니다. 예를 들어 max(_:_:_:) 함수의 Element 타입은 Comparable 프로토콜을 만족하므로 응당 ==> 연산을 지원해야 하니, max(3, 42, 7)을 호출할 때에 Int의 비교 연산자들을 담고 있는 표를 함께 넘겨줍니다. max(_:_:_:) 함수는 표의 어느 위치에 어느 비교 연산이 정의되어 있는지 알고 있기 때문에 어떤 타입이 들어와도 그 타입에 맞는 > 연산을 호출할 수 있습니다. 이 방법을 이용하면 > 연산의 구현이 런타임에 결정되기 때문에 함수 호출 인라이닝을 비롯한 컴파일러가 수행하는 다양한 최적화 기법의 혜택을 받을 수 없다는 단점이 있습니다.

특수화란 우리가 제네릭스를 이용해 maxInt(_:_:_:), maxDouble(_:_:_:), maxString(_:_:_:) 함수를 max(_:_:_:) 함수 하나로 만들었던 것의 정반대 작업을 컴파일러가 수행하는 것을 말합니다. 구체적으로 max(3, 42, 7)처럼 Int 타입을 인자로 max(_:_:_:) 함수를 호출하면, 컴파일러가 제네릭스를 사용한 함수의 본문을 복사해서 maxInt(_:_:_:) 함수를 작성, 그 함수를 대신 호출합니다. 이렇게 함수를 복사하기 때문에 Double, String 등 다양한 타입으로 max(_:_:_:) 함수를 호출하면 여러 벌의 복사본이 생길 수 있습니다. 특수화를 하면 컴파일 시점에 어떤 타입의 > 연산을 사용하는지 알 수 있기 때문에 최적화할 여지가 많아집니다. 하지만 한 함수를 여러 번 복사하고, 더군다나 복사본이 함수 본문으로 인라이닝될 수 있기 때문에 바이너리 사이즈가 커집니다.

어떤 함수가 특수화가 될 지는 Swift 컴파일러 최적화 레벨의 영향을 받습니다. Swift는 -Onone, -O, -Osize의 세 최적화 레벨을 제공합니다. 각각 개발용, 출시용, 바이너리 사이즈 최적화용입니다. 개발용에서는 제네릭스를 사용한 코드가 특수화되지 않습니다. 출시용의 경우에는 기본적으로 컴파일러의 휴리스틱을 통해 특수화 여부가 결정되는데요. 함수의 어트리뷰트로 특수화를 유도하거나 프로파일링을 통해 결정하도록 할 수도 있습니다(이에 대한 자세한 내용은 제네릭스 디자인 문서에서 확인할 수 있습니다). 바이너리 사이즈 최적화 레벨은 Swift 4.1에 추가된 레벨로(참고), 출시용 컴파일에 비해 제한적으로 인라이닝을 수행해 바이너리 사이즈를 줄입니다.

프로토콜 타입은 existential 컨테이너라는 자료구조로 구현됩니다. Existential은 프로토콜 타입의 또 다른 이름인데요, 프로그래밍 언어 타입 이론에서 existential이라고 부르는 타입을 Swift에서 구현한 것이 바로 프로토콜 타입입니다. 따라서 existential 컨테이너는 다시 말해 프로토콜 타입의 컨테이너라는 의미입니다.

이 컨테이너 타입은 다섯 워드1로 구성되어 있어서 첫 세 워드는 값 버퍼로 사용되고, 네 번째 워드는 값 참고표(Value Witness Table, 이하 VWT)를, 다섯 번째 워드는 프로토콜 참고표(Protocol Witness Table, 이하 PWT)를 가리킵니다.

세 워드 이하의 타입, 예를 들어 위 예시의 Point 같은 타입이 프로토콜 타입으로 사용될 때는 이 값 버퍼의 첫 번째, 두 번째 워드가 Point의 x, y 좌표를 저장하는 데 사용되고, 세 번째 워드는 사용하지 않는 공간이 됩니다. 위 예시의 Line처럼 네 워드 이상의 크기를 가지는 타입은 그 값이 힙에 할당되고, 값 버퍼의 첫 워드가 그 할당을 가리킵니다.

값이 한 번 existential 컨테이너에 담기면 구체적인 타입을 모르게 되므로 값을 취급할 때 항상 VWT를 참고하게 됩니다. 예를 들어 Drawable 프토로콜 타입의 값을 복사할 때는 VWT의 copy()를 사용합니다. 이 프로토콜 타입의 구체적인 값이 Int라면 VWT 필드는 IntVWT를 가리키고 있고, IntVWTcopy() 함수는 값 버퍼의 첫 워드를 옮기도록 구현되어 있을 것입니다. 반면 클래스 인스턴스의 VWT에서는 copy() 함수가 레퍼런스 카운트를 하나 늘린 후 첫 워드에 저장된 레퍼런스를 복사하도록 구현되어 있을 것입니다. PWT는 구체적인 타입이 해당 프로토콜을 어떻게 구현하고 있는지를 담고 있습니다. draw()처럼 프로토콜이 정의하는 메서드를 호출하면 이 PWT에서 현재 저장하고 있는 구체적인 타입에 맞는 구현을 찾게 됩니다.

이렇게 existential 컨테이너를 이용하면 같은 프로토콜을 만족하기만 할 뿐 다른 사이즈를 가지는 Point, Line 같은 타입을 균일하게 5 워드 크기로 다룰 수 있게 됩니다. 사이즈가 일정하기 때문에 배열에 넣을 수도 있고, 다른 구조체의 프로퍼티로 사용할 수도 있는 거죠. 하지만 그 대가로 사이즈가 3 워드 이상인 자료형은 힙에 할당되고, 메서드가 동적으로 디스패치되는 오버헤드가 발생합니다.

프로토콜 타입의 한계

// 아까 봤던 프로토콜 타입을 제네릭스의 타입 매개변수로 사용하고 있습니다
// ❌ 에러 메시지: Value of protocol type 'Summarizable' cannot conform to 'Summarizable'
let queueGeneric: MediaQueueGeneric<Summarizable> = MediaQueueGeneric(queue: [newsArticle, blogPost])

이제 다시 'Summarizable 프로토콜 타입이 Summarizable 프로토콜을 만족하지 못하는 문제'로 돌아와 봅시다. 앞서 살펴봤듯이 제네릭스는 타입 단위의 추상화입니다. 컴파일 과정에서 특수화가 되든 되지 않든, 타입 파라미터는 프로토콜이 요구하는 메서드를 제시할 수 있어야 합니다. 그런데 Summarizable 프로토콜 타입은 summarize() 메서드를 구현하고 있지 않습니다. 오직 Summarizable을 만족하는 NewsArticle과 BlogPost와 같은 구체적인 타입만 summarize() 메서드을 구현하고, Summarizable 자체의 구현은 없습니다. 그렇기 때문에 Summarizable 프로토콜 타입은 Summarizable하지 못합니다.

프로토콜 타입의 또 다른 문제는 애초에 타입으로 사용할 수 없는 프로토콜도 있다는 것입니다. 예를 들어 Equatable은 타입으로 사용할 수 없습니다. String과 Int 타입은 Equatable 프로토콜을 만족합니다. 즉, 다음 함수들이 정의되어 있습니다.

  • static func == (lhs: String, rhs: String) -> Bool
  • static func == (lhs: Int, rhs: Int) -> Bool

하지만 아래처럼 Equatable을 타입으로 사용하려고 하면, 제네릭스의 제약 조건으로만 사용할 수 있다며 에러가 발생합니다.

// ❌ 에러 메시지: Protocol 'Equatable' can only be used as a generic constraint because it has Self of associated type requirements
let hmm: Equatable = "Hmmmm"
let seven: Equatable = 7
​
if hmm == seven {
    print("어느 구현을 쓰죠?!")
}

에러 메시지는 EquatableSelf 혹은 연관 타입(associated type)을 요구하고 있기 때문에 타입으로 사용할 수 없다고 얘기하고 있습니다. Equatable이 요구하는 == 연산자의 두 인자가 Self이니, 컴파일러의 진단은 정확합니다. String끼리, Int끼리 비교하는 구현만 있지 IntString을 비교할 함수는 없으니 논리적으로도 납득이 갑니다. 그런데 에러 메시지가 말하는 'Self 혹은 연관 타입'이라는 조건은 어디서 온 걸까요? Self가 들어가기만 한다면 모든 프로토콜은 Equatable처럼 타입으로 사용하기에 부적절해지는 걸까요?

먼저 연관 타입이 있는 프로토콜을 타입으로 사용할 수 없었던 건 Swift 초창기에 PWT가 불완전하게 구현되어 PWT에서 연관 타입 정보를 불러올 수 없었기 때문입니다. 이 문제는 이미 해결되어 PWT로부터 연관 타입의 메타데이터를 불러올 수 있게 되었기 때문에, 순전히 레거시에 의해 사용이 제한되어 있는 경우입니다.

반면 Self를 사용하는 프로토콜을 제한하는 건 타입 안전성을 위한 조치입니다. Equatable의 예로 확인할 수 있듯, Self를 요구하는 프로토콜을 타입으로 사용하면 existential 컨테이너 내부의 구체적인 타입이 일치하지 않아 어떤 구현을 이용해야 할지 모르는 상황이 생기기 때문입니다. 이 문제를 좀 더 구체적으로 살펴보기 위해 아래 프로토콜 P의 예시를 살펴봅시다.

protocol P {
    func foo() -> Self
    func bar(baz: Self)
}

P 프로토콜 타입의 변수 qux가 있다고 가정해봅시다. 기본적으로 프로토콜 P 자체에는 foo()의 구현이 없지만, qux의 existential 컨테이너 내부의 값은 P를 만족하므로 foo()의 구현을 가지고 있습니다. 따라서 qux.foo()를 호출할 때 내부 값의 foo()를 호출하고 그 리턴 값을 다시 existential 컨테이너에 담는다면 타입 관점에서 안전하게 Pfoo()를 구현할 수 있습니다. foo()가 리턴하는 Self처럼, 구체적인 타입을 프로토콜 타입으로 안전하게 치환할 수 있는 경우를 타입 이론에서는 covariant하다고 표현합니다. 반면 qux.bar(baz:)를 호출하는 경우에는 quxbaz의 구체적인 타입이 일치하지 않을 경우 Equatable에서처럼 타입 안전성 문제가 발생합니다. 이렇듯 Self의 위치에 따라 구체적인 타입을 프로토콜 타입으로 치환할 수 없는 경우가 생기기 때문에 Swift 5.5는 프로토콜 정의에 covariant하지 않은 Self가 단 하나라도 있으면 프로토콜을 타입으로 사용할 수 없도록 막고 있습니다.

다행히 이런 제한 사항은 곧 완화될 예정입니다. 연관 타입에 의한 제한은 레거시 구현의 잔재이고, covariant한 멤버가 있다고 프로토콜 전체를 타입으로 사용할 수 없게 막는 건 과한 제한이라는 의견이 받아들여졌기 때문인데요. 앞으로는 기본적으로 모든 프로토콜을 타입으로 사용할 수는 있되, covariant하지 않은 Self를 가진 멤버만 사용할 수 없을 것이라고 합니다. 이런 변경 사항은 SE-0309 프로포절에서 자세히 소개하고 있습니다.

이렇게 제약 조건으로서의 프로토콜과 타입으로서의 프로토콜은 사뭇 다른 개념이기 때문에 최근의 Swift 진화 문서에서는 프로토콜 타입을 'any Protocol'이라고 부르자는 논의도 있습니다(참고). 이렇게 이름을 바꾸게 되면 혼란스럽던 에러 메시지도 'any Summarizable이라는 타입이 Summarizable 프로토콜의 제약조건을 만족시키지 못한다'라고 좀 더 이해하기 쉽게 바뀌고, 프로토콜 타입의 구현이나 한계를 설명하는 것도 더 쉬워질 것입니다.

제네릭스와 프로토콜 타입의 빈자리를 opaque result 타입이 채웁니다

이제 타입 단위 추상화인 제네릭스와 값 단위 추상화인 프로토콜 타입의 차이를 이해했습니다. 프로토콜 타입이 그 자체로 프로토콜을 만족하지 못하는 이유도 알았고, 어떤 프로토콜은 타입으로 사용할 수 없다는 것도 알았습니다. 이런 맥락에서 보면 opaque result 타입이 왜 필요한지 이해하기 쉽습니다. Swift 진화 프로포절에서 가져온 아래의 예시를 살펴봅시다.

// ❌ 컴파일되지 않습니다. 에러 메시지: Protocol can only be used as a generic constraint because it has 'Self' or associated type requirements
func evenValues<C: Collection>(in collection: C) -> Collection
where C.Element == Int {
    collection.lazy.filter { $0 % 2 == 0 }
}

어떤 정수 Collection을 받아서 짝수만 남긴 Collection을 리턴하는 함수를 작성하려고 합니다. 이때 리턴 타입이 Collection 프로토콜을 만족한다는 정보만 제시하고 실제로 어떤 타입인지는 숨기고 싶을 수 있습니다. 지금은 lazy collection을 이용해 구현했지만 차후에 구현 방식을 바꾸고 싶을 수도 있고, 위의 lazy collection의 타입을 그대로 적자니 리턴 타입이 너무 장황해지기 때문입니다. 이럴 때 위 코드처럼 Collection 프로토콜 타입을 리턴하고 싶을 수 있지만, Collection은 연관 타입이 있기 때문에 프로토콜 타입으로 사용할 수 없습니다.

에러 메시지에서는 Collection이 오직 제네릭스의 제약 조건으로만 사용될 수 있다고 설명하고 있습니다. 만약 에러에서 요구한 대로 제네릭스의 제약 조건으로 추가하면 어떻게 될까요?

// ❌ 컴파일되지 않습니다.
func evenValues<C: Collection, Output: Collection>(in collection: C) -> Output
where C.Element == Int, Output.Element == Int {
    collection.lazy.filter { $0 % 2 == 0 }
}

이 함수의 경우에는 Output 콜렉션이 어떤 구체적인 타입이 될지를 evenValues(in:)의 호출자가 결정하게 됩니다. 우리가 원하는 것은 함수의 작성자가 구체적인 타입을 결정하고, 호출자는 프로토콜에 명시된 메서드만 이용해서 리턴된 짝수의 Collection을 조작하는 것입니다. 이 함수 시그니처는 호출자가 구체적인 리턴 타입을 결정할 수 있다는 약속을 하고 있습니다. 함수의 본문은 이 약속을 지키지 않고 LazyFilterSequence라는 구체적인 타입을 리턴하고 있기 때문에 컴파일 에러가 발생합니다. 마지막으로 아래처럼 구체적인 타입을 그대로 적으면 어떨까요?

func evenValues<C: Collection>(in collection: C) -> LazyFilterSequence<LazySequence<C>.Elements>
where C.Element == Int {
    return collection.lazy.filter { $0 % 2 == 0 }
}

컴파일은 잘 되지만 여러 문제가 있습니다. 함수의 호출자가 evenValues(in:) 함수의 구현 디테일을 고스란히 손에 얻게 되었습니다. 만약 호출자가 LazyFilterSequence에만 있고 Collection에는 없는 메서드를 사용한다면, 이후 evenValues(in:)의 구현이 바뀌었을 때 호출자의 코드가 깨질 것입니다. 즉, 너무 많은 구현 디테일을 노출하고 있습니다. 게다가 리턴 타입이 너무 장황해서 잘 읽히지도 않습니다.

지금까지의 세 방법을 다시 정리해 봅시다. 먼저 Collection은 프로토콜 타입으로 사용할 수 없었습니다. 제네릭스를 사용하는 방법은 호출자가 리턴 타입을 결정하게 되기 때문에, 저희가 의도한 구현이 아닙니다. 리턴 타입을 곧이곧대로 드러내는 방식은 구현 디테일을 과하게 노출해서 이후 구현을 변경할 여지를 제한합니다. 표로 정리하면 아래와 같습니다.

  호출자가 타입 결정 피호출자가 타입 결정
타입 단위 추상화 제네릭스  
값 단위 추상화 프로토콜 타입 프로토콜 타입

이 빈자리에 들어가는 개념이 바로 opaque result 타입입니다. 아래가 opaque result 타입을 이용한 코드인데요. 리턴 타입의 Collection 앞에 some이 붙은 것을 제외하면 프로토콜 타입을 사용한 것과 동일합니다.

func evenValues<C: Collection>(in collection: C) -> some Collection
where C.Element == Int {
    return collection.lazy.filter { $0 % 2 == 0 }
}

기존의 제네릭스는 호출자가 구체적인 타입을 결정하고, 함수 작성자는 인자가 어떤 프로토콜을 만족하는지의 정보만 가지고 본문을 작성해야 했습니다. 반대로 opaque result 타입은 함수의 작성자가 구체적인 타입을 결정하고, 함수 호출자는 리턴 값이 어떤 프로토콜을 만족하는지만 알 수 있습니다. Opaque result 타입을 이용하면 리턴 타입을 장황하게 작성하는 귀찮음을 겪을 필요가 없고, 구현 디테일을 과도하게 노출하지도 않습니다. 게다가 컴파일러가 리턴 값의 구체적인 타입을 알고 있기 때문에 정적 디스패치를 통한 최적화도 가능합니다.

Swift 타입 시스템의 미래

그렇다면 Collection의 튜플을 리턴하고 싶을 땐 어떻게 해야 할까요? (some Collection, some Collection)처럼 리턴하면 될까요? 두 Collection이 같은 구체적인 타입을 가지는지 어떻게 나타낼 수 있을까요? 안타깝게도 아직 Swift는 some 키워드를 이렇게 구조적으로 활용하는 것을 허용하고 있지 않습니다. 하지만 제네릭스의 문법을 opaque result 타입에 도입해서 func foo() -> <C: Collection> (C, C) 같이 하나의 구체적인 타입인 여러 Collection을 리턴하는 것을 표현한다거나, 혹은 반대로 제네릭스의 위치에서 some 키워드를 사용해서 func bar<C: Collection>(x: C) 대신 func bar(x: some Collection)으로 쓸 수 있는 등 다양한 문법 변경이 논의되고 있습니다.

여기까지 제가 LINE에서 iOS 개발에 입문하며 공부한 Swift의 타입 시스템에 대한 이해를 나눠보았습니다. 제네릭스와 프로토콜을 적절히 이용하면 효율적이고 안전하면서도 유연한 코드를 작성할 수 있습니다. 앞으로 Swift 타입 시스템이 지원할 더 명료한 문법과 강력한 기능을 기대하며 이만 글을 마치겠습니다. 긴 글 읽어주셔서 감사합니다!


  1. 워드란 특정 프로세서가 자연스럽게 처리할 수 있는 데이터의 크기를 말합니다. 64비트 프로세서라고 말할 때의 64비트가 바로 워드 크기입니다.