Wireframe

Opaque 리턴타입(Swift 5.1)

이 블로그의 다른 글에서 Swift의 타입 지우기에 대해서 살펴본 바 있습니다. 특정한 연관 타입에 의존하지 않는 프로토콜은 그 자체로 타입처럼 취급될 수 있지만, 연관 타입에 의존하는 프로토콜은 일종의 제네렉과 비슷하기 때문에 구체적인 타입처럼 사용될 수 없습니다. 타입 지우기(Type Erasure)는 특정한 프로토콜을 따르는 Any 타입을 만들어서 프로토콜을 채택한 실제 타입을 감추고 프로토콜의 기능적 잇점은 누릴 수 있게 하는 기법입니다. 하지만 타입 지우기를 사용하려면 특정 타입을 감싸는 별도의 타입을 만들어야 하고, 성능상으로도 오버헤드가 발생하는 제약이 있습니다. Swift는 프로토콜을 실제 구체적인 타입으로 사용할 수 있는 다른 방법을 제공하는데, 그것이 바로 Opaque 타입입니다. Swift 5.1부터 도입된 이 기능은 Type Erasure를 사용하지 않고 함수의 리턴 타입으로 실제 타입이 아닌 프로토콜을 타입으로 정의할 수 있게 하는 기능입니다.

Opaque Type

“불투명 타입”으로 직역되는 Opaque Type이라는 말은, “그 내부를 들여다 볼 수 없는 타입”이라는 의미입니다. 사실 Swift에서 처음으로 제시하는 용어는 아니고, 예전부터 쓰이던 말입니다. 어떤 라이브러리나 프레임워크를 작성할 때, 라이브러리 사용자가 핸들링할 수 있는 객체를 제공하면서 그 내부 구조를 숨기고 싶을 때 사용하는 방식입니다.

예를 들어 sqlite3의 C/C++ 인터페이스에는 sqlite3라는 데이터베이스 핸들러 객체 타입이 있습니다. DB커넥션을 만들면 생성되는 객체로, DB를 조작하는 모든 함수는 이 객체를 첫번째 인자로 받아서 작동합니다. 그 객체의 내부 구조는 알 수 없지만, 실제 DB파일의 파일ID 같은 몇 가지 정보를 담고 있을 것으로 생각됩니다. 이 객체는 `sqlite3 `라이브러리 내부에서는 적절한 C 구조체로 작성되어 있겠지만, 외부에서 보이는 타입은 void * 타입의 포인터일 뿐입니다. 따라서 라이브러리의 사용자는 sqlite3 타입의 내부 구조에 대해서는 알 수 없고, 해당 객체가 실제 데이터베이스에 대함 참조를 의미한다고 생각하고 사용해야 합니다.

Swift의 Opaque Type 역시 모듈 내부에서 사용하는 어떤 타입을 외부에 노출할 때, 모듈 내부에서 사용하는 실제 타입을 숨기고 그 인터페이스만 노출하려는 의도를 가지고 사용됩니다. 즉 C언어에서 사용하던 Opaque 타입과 같습니다. 이 때 노출하려는 인터페이스는 외부에 건네진 객체의 인터페이스 집합이며, 따라서 프로토콜을 사용하여 노출하게 됩니다. 즉 “실제 타입은 알 수 없지만 어떤 프로토콜을 따르는 타입”이라는 의미로 이해할 수 있습니다. 그리고 이것은 어떤 프로토콜이 함수의 리턴 값, 함수의 파라미터 값, 변수에 대한 직접적인 타입으로서 그대로 사용될 수 있다는 것을 의미합니다. 즉, Type Erasure와 거의 같은 용도로도 사용될 수 있다는 말입니다.

Opaque Type을 사용할 수 있는 케이스

Opaque 타입은 Type Erasure를 사용할 수 있는 대부분의 경우에 적용이 가능합니다. 간단한 예와 함께 살펴보겠습니다.

protocol AuthToken {
  associatedtype SecretType
  var value: SecretType { get }
}

protocol Authenticator {
  associatedtype Token: AuthToken
  func authenticate() -> Token
}

먼저 위와 같이 두 개의 프로토콜을 정의했습니다. Authenticator는 어떤 인증 절차를 수행하고 토큰을 생성하는 동작을 묘사하는 프로토콜입니다. 편의상 입력값은 생략했습니다. Authenticator가 생성하는 토큰은 상황에 따라 달라질 수 있으므로, Token 이라는 연관 타입에 의존하고 있습니다.

AuthenticatorToken은 다시 AuthToken 프로토콜을 준수해야 합니다. 이 프로토콜은 value 라는 토큰값을 보여주어야 하는데, 토큰값의 타입 역시 연관타입으로 선언되어 있습니다. 이 프로토콜을 따르는 타입들을 아래에 몇 가지 정의해봅니다. OAuth는 내부에 중첩된 타입으로 Token을 정의하고 있고, NumToken, QAuth는 토큰과 인증자가 각각 정의된 형태로 정의하였습니다. 두 가지 인증자 타입의 차이는 토큰 내부의 값의 타입이 다른 것입니다.

struct OAuth: Authenticator {
  struct Token: AuthToken {
    let value: String
  }

  func authenticate() -> Token {
    return Token(value: "secret")
  }
}

struct NumToken: AuthToken {
  let value: UInt
}

struct QAuth: Authenticator {
  func authenticate() -> NumToken{
    return NumToken(value: 123)
  }
}

아제 우리는 Authenticator 프로토콜을 준수하는 임의의 타입을 사용해서 토큰을 돌려받는 함수를 작성해볼 수 있습니다. 아마 가장 기본적인 방법으로는 아래와 같은 제네릭 함수를 떠올릴 수 있습니다. 이 함수는 컴파일하는데까지는 별다른 문제가 없습니다.

func login<T: Authenticator>(with auth: T) -> T.Token {
  return auth.authenticate()
}

이제 이 코드를 사용하는 쪽에서 어떻게 사용할 수 있을지 알아보겠습니다. 먼저 인자로 전달할 “구체적인 Authenticator의 타입“과, 그 연관 타입-역시 value의 타입이 연관타입인-에 대한 구체적인 타입을 알고 있어야 합니다. 예를 들어 OAuth 타입의 객체를 인자로 넘기게 된다면 리턴 타입은 OAuth.Token이 되어야 할 것입니다.

let auth: OAuth = ...
let res_token: OAuth.Token = login(with:auth)

하지만 login() 함수를 이렇게 사용하려면, 사용자는 OAuth라는 타입의 내부 구조에 대해서 알고 있어야 한다는 전제가 필요합니다. 만약 QAuth를 대신 사용하려 한다면 let res_token: NumToken = ... 과 같이 사용하여야 겠죠. 만약 Authenticator 프로토콜을 따르는 타입이 여러 가지라면 모든 타입을 알아야 합니다. 만약 라이브러리를 작성하고 있는 상황이라면, 이러한 내부적인 실제 타입은 숨겨야 하는 필요도 있습니다.

심지어 아래와 같이 두 개 이상의 인증을 조합하여 한 번에 처리할 수 있는 타입이 아래와 같이 정의되어 있는 경우도 있을 수 있습니다. ComplexAuth 는 두 가지 인증자를 결합하여 인증한 토큰들을 전달하는데, 이렇게 결합한 인증자를 다시 다른 인증자와 계속 결합할 수 있으므로 마음만 먹는다면 엄청나게 복잡한 타입을 만드는 것도 가능합니다.

struct ComplexAuth<T: Authenticator, U: Authenticator>:
    Authenticator {
  struct Token: AuthToken {
    var value: (T.Token.Secret, U.Token.Secret)
  }
  let _authT: () -> T.Token
  let _authU: () -> U.Token
  init(a: T, b: U) {
    _authT = a.authenticate
    _authU = b.authenticate
  }
  func authenticate() -> Token {
    return Token(value: (_authT().value, _authU().value))
  }
}

let example_token = ComplexAuth(a:OAuth(), b:QAuth())
                    .authenticate()
// example_token: ComplexAuth<OAuth, QAuth>.Token

따라서 라이브러리나 모듈 외부에서 이러한 인터페이스를 사용하는 타입을 쓰려면 프로토콜을 리턴 값의 타입으로 사용하는 것이 더 나은 선택일 수 있습니다. 그러나, 연관 타입에 의존하는 프로토콜은 단독으로 이렇게 사용할 수 없습니다. 연관 타입에 의존하는 프로토콜은 클래스나 구조체에 비유하면 제네릭 타입과 비슷하기 때문입니다.

이전에는 이런 경우에 Type Erasure를 사용하여, 프로토콜을 채택하고 있는 구체적인 타입을 감추는 방식을 사용했습니다. 그런데 Type Erasure는 각 프로토콜별로 별도의 래퍼(wrapper) 타입을 작성해야 하고, 래퍼로서 작동하는 특성 상 오버헤드가 있기 때문에 성능 측면에서 불리합니다.

Swift 5.1부터는 some 키워드를 도입해서, 연관타입이 있더라도 프로토콜을 구체적인 타입인 것처럼 사용하도록 하는 기능이 추가되었습니다. 함수의 리턴 값이나, 리턴 값을 받아 저장할 변수의 타입에 some 프로토콜이름 의 형식으로 사용할 수 있게 됩니다. 이 타입은 언어 자체의 기능으로 구체적인 타입을 숨기고, 프로토콜에서 공개하고 있는 인터페이스에 대해서만 접근할 수 있게 해주는 기능입니다.

func login<T: Authenticator>(with auth: T) -> some AuthToken {
  return auth.authenticate()
}

login 함수를 위와 같이 some AuthToken 타입을 반환한다고 하면 AuthToken의 실제 타입에 대해서 고민할 필요가 없게 됩니다. 이는 Array<T> 와 같은 제네릭 타입의 표기를 프로토콜에서는 사용할 수 없는 부분을 대체하는 문법이라고 보아도 좋습니다.

Opaque Type vs Type Erasure

Opaque Type 은 번거롭게 타입을 지운 Any* 류의 래퍼를 따로 작성할 필요가 없고(예제에서는 간단한 구조의 프로토콜에 대해서만 보였지만, 프로토콜에서 정의하는 인터페이스의 양이 많은 경우에는 상당히 귀찮은 작업이 될 수 밖에 없습니다), 성능상의 오버헤드가 없기 때문에, 많은 경우 타입 지우개를 대체할 수 있습니다.

Opaque Type은 사용하기 쉽고 간편한 등 여러 장점이 있지만, 몇 가지 제약이 있습니다. 위에서 예로 든 login() 함수는 some AuthToken 타입을 리턴하는데, 이는 AuthToken 프로토콜을 준수하는 타입이기만 하면 실제 타입에 상관없이 어떤 타입이라도 리턴할 수 있다고 했습니다. 하지만 함수 내에서 return 구문이 두 개 이상인 경우에, 각각의 분기에서 리턴하는 값의 구체적인 타입이 달라져서는 안됩니다.

func login2(with auth: OAuth, path: Int) -> some AuthToken {
  if path>= 0 {
    return auth.authenticate()  // #1 -> OAuth.Token
  }
  return NumToken(value: 123)   // #2 -> NumToken
}

==> error: function declares an opaque return type, 
but the return statements in its body do not have matching underlying types

위 함수에서는 path 라는 파라미터의 부호에 따라서 OAuth.Token 타입이나 NumToken 둘 중 하나의 타입을 리턴하려고 시도합니다. 이 경우 Swift 컴파일러는 타입이 다른 값을 리턴하려 한다면서 에러를 내게 됩니다. 사실 이것은 Opaque 타입의 제약이라고 하기에는 조금 부정확합니다. 하나의 함수에서 특정한 조건에 따라 리턴하는 타입이 달라지기 때문에, 위와 같은 경우는 제네릭이나 Type Erasure를 사용해서도 구현할 수 없습니다. 다만 위 예제에서는 OAuth.Token과 NumToken 두 타입 모두 some AuthToken으로 볼 수 있기 때문에 착각하기 쉬운 함정이라고 보는 편이 맞겠습니다.

연관 타입이 없는 프로토콜과 Opaque Type

Opaque Type이 도입되기 이전에도 연관 타입이 없는 프로토콜은 함수의 리턴 타입으로 사용하는 것이 가능했습니다. 조금 전의 예제에서 AuthToken 이 항상 문자열 타입의 value 만 갖는 것으로 바꿔보겠습니다. Opaque Type은 이러한 프로토콜 타입을 대체해서 사용할 수도 있습니다. 이에 해당하는 대부분의 경우, 함수의 내부 코드는 전혀 변하지 않고 단지 리턴값의 타입에 “some”이 붙느냐 아니냐의 차이만 존재합니다.

‘some ProtoX’ 라는 리턴 타입은 특정한 하나의 구체적인 타입을 의미하게 됩니다. 반면에 ‘ProtoX’ 만 사용하여 리턴 타입을 정의했다면, 이 프로토콜 타입은 프로토콜을 따르는 아무런 타입이나 될 수 있습니다. (즉 여전히 구체적인 타입은 아닌 셈입니다.) 이 둘의 가장 큰 차이는 Opaque Type이 리턴 값의 실제 타입에 대한 조금 더 강한 보장을 한다는 것입니다. 따라서 some 이 앞에 붙는 Opaque 타입은 명시적으로 그렇지 않더라도 암묵적으로는 Self 에 대한 의존을 갖게 됩니다. 따라서 일반적인 프로토콜 타입 보다는 더 많은 타입 정보에 의존하는 것이 가능합니다.

Swift 공식 문서에는 다음과 같은 함수 예제가 하나 있습니다.

func protoFlip<T: Shape>(_ shape: T) -> Shape {
  if shape is Square { return shape }
  return FilppedShape(shape: shape)

let a = protoFlip(smallTriangle)
let b = protoFlip(a) // Error!!

protoFlip(_:) 함수는 프로토콜 타입 Shape를 리턴합니다. 이렇게 리턴된 객체 a를 다시 protoFlip(_:)에 전달하려하면, “Shape가 프로토콜 Shape를 따르지 않는다.”는 이상한 에러가 납니다. 위 코드에서 a는 함수의 리턴 타입 선언에 따라 프로토콜 타입 Shape 이고, 이는 해당 프로토콜을 따르는 모종의 타입입니다. 곧 a의 타입은 구체적으로 정해지지 않았다고 보는 것입니다.

여기서 모순처럼 보이는 것은 이렇습니다. 객체 a에 대해서는 프로토콜 a를 따른다는 정보 외에는 사용할 수 있는 것이 전혀 없습니다. 왜냐면 프로토콜에서 정의한 인터페이스 외에는 컴파일러가 a에 대해 아는 것이 전혀 없기 때문입니다. 따라서 컴파일러는 a의 타입이 프로토콜을 따르는지 알 지 못합니다. 어떤 타입인지 알지 못하지만 Shape 프로토콜을 따르는 객체가 <T: Shape> 타입의 인자가 될 수 없다니… 이것은 정말 이상합니다.

똑같은 함수의 본체를 가지고 단지 리턴 타입만 some Shape로 바꾸면 위 코드는 에러 없이 동작합니다. 이처럼 some *** 타입은 본래 타입을 겉으로 드러낼 필요가 없다 뿐이지 함수에 의해 반환된 원래 타입에 대한 정보를 잃어버리지는 않는다는 것입니다.

프로토콜 타입이 왜 프로토콜을 따르지 않는지에 대해서는 어떻게 이해하고 설명할지에 대해서 좀 더 고민이 필요해보입니다. 그럼 오늘은 여기까지.

Exit mobile version