타입 지우기 – Type Erasure (Swift)

프로토콜 타입

우리가 만약 타입을 알 수 없는 어떤 객체의 특정한 메소드를 호출해야 하는 상황을 생각해보자. (어렵게 생각할 것 없이, 델리게이트 패턴에서 이것은 매우 흔한 일이다.) 타입을 알 수 없다는 것은, 그 객체가 공개하고 있는 인터페이스를 알 수 없다는 뜻이며, 따라서 어떤 메시지를 보내는 것이 불가능하다는 것을 의미한다. 하지만, 서로 다른 타입들이 같은 이름의 메소드를 구현해 둘 것을 약속만 한다면, 이야기가 달라진다.

프로토콜은 미리 정의된 인터페이스의 모음으로, 이를 따르는 타입들은 그 프로토콜에 명시된 인터페이스를 구현해놓은 것으로 가정할 수 있다. 동적 프로그래밍에서는 어떤 객체가 A타입처럼 행동하면 A타입으로 간주할 수 있다고 한다. (어떤 새가 오리처럼 날고, 오리처럼 꽥꽥거린다면 그 새를 오리라 부르지 않을 이유가 무엇인가) 굳이 동적 언어가 아니더라도, 어떤 객체가 그 실제 타입 T가 무엇이든간데, 프로토콜 P를 준수하고 있다면 우리는 P 타입에 대한 상호작용만 한다는 가정하에서 그 객체를 P 타입으로 보아도 무방할 것이다. 아니면 다른 S타입의 객체가 P를 준수한다고 하면, 두 객체를 여전히 같은 P 타입으로도 볼 수 있을 것이다.

이쯤에서 간단한 예시를 만들어보자. 자동차와 자동차를 만드는 공장에 대한 타입 특성을 프로토콜로 정의해보자.

// 차량들은 동력원에 대한 정보를 가지고 있어야 한다.
protocol CarProtocol {
  var power: String { get }
}

// 자동차 공장은 어떤 차량을 생성할 수 있다.
protocol CarFactory {
  associatedtype Car
  func produce() -> Car
}

이 프로토콜을 기반으로 여러 동력타입의 차량을 정의할 수 있고, 자동차 공장에 대한 모델을 정의할 수 있다. 아래 예에서는 2가지 차량과 공장을 정의해본다. 이 때, 두 공장은 모두 ElectricCar 타입의 자동차를 생산한다. 즉 연관 타입이 동일한 다른 공장인 셈이다. (연관 타입까지 달라지면 이야기가 또 달라지니 여기서는 생략)

struct ElectricCar: CarProtocol {
  var power: String = "Electoricity"
}

struct GasCar: CarProtocol {
  var power: String = "Gasoline"
}

struct TeslaFactory: CarFactory {
  func produce() -> ElectricCar{
    return ElectricCar()
  }
}

struct BMWFactory: CarFactory {
  func produce() -> ElectricCar{
    return ElectricCar()
  }
}

let f1 = TeslaFactory()
let f2 = BMWFactory()

var a: [Any] = [f1, f2]

배열의 타입이 [Any]가 아닌 [CarFactory]라면 각 원소에 대해서 produce()를 호출하는 등의 동작을 안전하게 처리할 수 있을테니, 그러한 타입의 배열을 만들어볼 수 있지 않을까?

var a: [CarFactory] = [f1, f2]

그런데 이게 왠일인가, 이 코드는 아래와 같은 에러를 내뱉으며 컴파일 되지 않는다.

protocol ‘CarFactory’ can only be used as a generic constraint because it has Self or associated type requirements


왜 안되는가?

자, 결론부터 먼저 말하자면 만약 CarFactory 프로토콜이 연관타입을 갖지 않는다면 문제가 되지 않는다. 그럼 왜 연관타입을 가질 때는 문제가 되는가?

(스포일러) 아직 Swift의 타입 시스템은 완벽하지 않다.

연관타입이 문제가 되는 상황을 만들어보자. produce()는 연관타입의 객체를 리턴하는 함수인데, 연관 타입의 객체를 인자로 받는 메소드가 프로토콜의 요구 사항에 등재되어 있다고 하자.

protocol CarFactory {
  associatedtype CarType
  var power: String { get }
  func produce() -> CarType
  func price(of car: CarType) -> Int
}
  

그리고 Swift의 타입 시스템이 연관 타입을 가지는 프로토콜을 구체적인 타입처럼 사용할 수 있게 해준다고 가정하자.

var factories: [CarFactory] = [f1, f2, ... ]
var totalPrice: Int = 0
for f in factories {
  totalPrice += f.price(of: <# ??? #>) // 1
} 

자, 여기서 문제가 발생한다. 연관 타입을 가진 프로토콜을 그 자체로 구체적인 타입으로 허용할한다 가정하더라도 결국에는 해당 연관 타입이 구체적으로 결정되어야 하는 순간이 올 때 컴파일러가 처리할 수 없는 영역이 발생하게 된다는 것이다. 이것은 마치 배열이 그 자체로는 제네릭 타입으로 정의될 수 있지만 실제 배열 인스턴스를 Array<T> 타입의 배열로 만들 수 없는 것과도 상통한다.

사실 연관 타입이 특정한 조건(프로토콜)을 갖는 경우라면 이러한 제한을 조금은 완화할 수 있으리라 생각되지만 모순이 발생하는 케이스를 적절히 격리할 수 있을만큼 Swift의 타입 시스템이 똑똑하지는 않은 것 같다. 프로토콜이 의존하는 연관타입 자체를 외부에서 명시하게끔 하면 되지 않을까? 마치 제네릭처럼 말이다. (struct Peonex: Alien<Fire> { ... } 처럼 쓰도록) 하지만 아직 Swift에서는 프로토콜에 대해서는 제네릭 타입 선언을 허용하지 않는다. 이 점은 아직 컴파일러의 한계이다.

해결방법

이에 대해 이미 많이 쓰이고 있는 해결 방법은 별도의 컨테이너로 프로토콜 타입의 구체적인 인스턴스를 감싸하는 것이다. 연관 타입을 갖는 프로토콜은 컴파일러의 한계로 제네릭이 되지 못했지만 그 비슷한 개념을 지원하고 싶은 것이므로, 진짜 제네릭으로 만들어버리는 것이다.

요점은 간단하다. 연관 타입을 CarType으로 하는 제네릭을 만들고 그 자체가 CarFactory를 따르도록 한다. 그리고 구체적 타입의 내부 구현을 알 수 없으므로 그에 필요한 메소드들을 캡쳐하여 포워드하는 식으로 만들면 된다. 이 시점에서 생성할 때의 타입 제한 규칙만 주의깊게 작성해주면 된다.

struct AnyCarFactory<C> : CarFactory {
    private var _produce: () -> C
    init<G: CarFactory>(_ base: G) where G.CarType == C {
        _produce = base.produce
    }
    
    func produce() -> C {
        return _produce()
    }
}

이제 CarType만 동일하다면 공장의 실제 타입에 상관 없이 하나의 타입으로 묶는 방법이 생겼다. var x: [AnyCarType<ElectricCar>] = []와 같은 배열을 생성할 수 있다.


이미 Swift의 표준 라이브러리에서도 AnySequence와 같은 타입 지우개들이 몇 가지 정의되어 널리 쓰이고 있다. 이러한 타입 지우개를 사용하면 동일 프로토콜을 따르는 서로 다른 타입들을 하나의 타입처럼 사용할 수 있다. 하지만 여전히 이 패턴도 완전한 해결책은 될 수 없다. 먼저 제네릭 타입 지우개를 사용하더라도 연관 타입을 제네릭 인자로 빼내는 수준이다. 만약 Power의 타입이 다르다면 같은 배열로 여전히 묶을 수는 없다.

그리고 타입 지우개의 가장 치명적인 단점인데, 타입 지우개를 사용하기 위해서는 많은 양의 보일러 플레이트 코드를 작성해야 한다. 많은 보일러 플레이트는 결국 코드의 복잡성을 높이고 유지보수를 어렵게 만든다. 결국 Swift의 타입 시스템의 개선이 필요하다.

그 외

사실 이러한 문제는 Swift 코어 개발자들도 인지하고 있고, 매 버전마다 조금씩의 개선이 나오고 있다. Swift 5.1에서는 Opaque 타입이라는 게 소개됐다. 이는 배열의 원소 타입은 아니고 개별 객체의 타입에 대해서 모호한 프로토콜 타입을 사용할 수 있게 해주는 것이다.

var f1: CarFactory = TeslaFactory()
// ERROR : Protocol 'CarFactory' can be only used as a generic constraint ...

var f1: some CarFactory = TeslaFactory()
print(f1.produce())
// OK