콘텐츠로 건너뛰기
Home » 왜 프로토콜 타입은 프로토콜을 따르지 않는가

왜 프로토콜 타입은 프로토콜을 따르지 않는가

  • Swift

Why A Protocol Type Doesn’t Conform to The Protocol Itself?

프로토콜은 사실 타입은 아니고, 실제 타입이 구현해야 할 어떤 ‘요구사항’을 정의한 것입니다. 그리고 실제 타입을 정의할 때 프로토콜이 요구하는 메소드와 멤버를 구현하면 해당 타입이 프로토콜을 준수한다(혹은 프로토콜을 채택한다)고 표현하죠. 프로토콜은 타입이 아니지만, 특정 객체에 대해서 프로토콜이 정의한 인터페이스를 사용하는 것에만 관심이 있는 상황을 가정해보겠습니다. 이 경우에 우리는 해당 객체에 대해서 이미 알고있는(프로토콜이 정의한) 메소드나 프로퍼티만 사용하고, 실제의 타입은 알 수 없거나 정해지지 않았다고 간주해야 합니다. 그렇다면 이러한 객체는 구체적인 타입을 확정할 수는 없지만 그 프로토콜을 준수하고 있다는 것만 보장하기 때문에, 프로토콜을 변수의 타입처럼 사용하는 것이 가능합니다.

이것을 프로토콜 타입이라고 합니다. 프로토콜 타입은 변수나 상수의 타입, 함수의 리턴, 함수의 인자에 대해서 타입 대신에 프로토콜만 명시하는 것으로 사용하는 것입니다. 이는 실제 타입에 구애받지 않고 같은 프로토콜을 준수하는 객체라면 어떤 것이든 사용할 수 있게 한다는 측면에서 유연한 코드를 작성할 수 있게 해줍니다. 함수의 경우에는 제네릭 함수를 사용하는 방법도 있겠습니다만, 변수의 타입을 선언할 때에는 구체적인 타입을 명시해야 하기 때문에 제네릭을 사용할 수 없기 때문에 의외로 프로토콜 타입을 사용하는 경우를 만나게 될 수 있습니다.

그런데 프로토콜 타입과 관련해서, 이상한 컴파일 에러가 나는 경우가 있습니다. “error: protocol ‘P’ as a type cannot conform to the protocol itself”라는 에러가 그것인데 “프로토콜 타입이 프로토콜을 따르지 않는다”는, 얼핏보면 말이 안되는 것 같은 에러입니다. 이런 에러는 왜 일어나는 것일까요?

간단한 예를 하나 들어보겠습니다. is 연산자는 어떤 객체가 특정한 타입인지를 판별하는데, 특정한 프로토콜을 준수하는지 여부도 확인하는 용도로 사용될 수 있습니다. 구조체 타입 Foo가 프로토콜 P를 준수하기 때문에 f is P는 참으로 평가됩니다.

protocol P {
  var value: String { get }
}

struct Foo: P {
  let value: String = "foo"
}

struct Bar: P {
  let value: STring = "bar"
}

let f = Foo()
print(f is P) // > true

이번에는 위 예제를 계속 확장해서 다음과 같이 몇 개의 함수를 더 작성해보겠습니다. make() 함수는 인자값에 따라서 Foo 혹은 Bar 타입의 함수를 리턴합니다. 리턴 타입이 조건에 따라 달라지기 때문에 Any 타입을 사용할 수도 있겠습니다만, Any 타입으로 리턴된 값은 특정한 타입으로 캐스팅해보기 전에는 그 내부의 속성을 전혀 사용할 수 없습니다. Foo 나 Bar는 프로토콜 P가 요구하고 있는 멤버를 갖추고 있기 때문에, 공통 조상을 가진 클래스는 아니지만 비슷한 종류로 생각할 수 있고, 따라서 make() 함수의 리턴값을 P 라는 프로토콜 타입으로 지정할 수 있습니다.

func make(_ x: Int) -> P {
  if x >= 0 { return Foo() }
  return Bar()
}

func test<T: P>(_ arg: T) -> Bool {
  return arg.value == "foo"
}

let x = make(1)
print(x.value) // > "foo"
print(x is P) // > true

print(test(x))
// error: protocol 'P' as a type cannot conform to the protocol itself

test() 함수는 프로토콜 P를 준수하는 타입의 객체를 인자로 받아서, 그 value 값이 “foo” 인지를 검사하는 함수입니다. 함수 내부에서는 프로토콜 P의 요구사항인 value 라는 멤버에 접근하고 있기 때문에 타입에 관해서는 문제없이 작동한다는 것을 예상할 수 있습니다. 하지만 정작 P 타입의 객체인 x를 전달하면 프로토콜 타입 P는 그 자신을 준수하지 않는다는 에러가 나게 됩니다. 하지만 P 타입인 객체 x에 대해서는 프로토콜이 요구하는 인터페이스를 사용할 수가 있단 말이죠? 실제로 x is P 연산 역시 참으로 평가됩니다. 그런데 왜 x는 test()의 인자로 전달할 수가 없는 것일까요?

해설

이 부분은 실제로도 상당히 애매한 문제이고, 쉽게 납득이 안되는 상황입니다. 이 문제에 대한 원론적인 답변은 다음과 같습니다.

프로토콜은 어떤 타입이 구현해야 할 인터페이스를 정의한 것이고, 프로토콜 그 자체가 요구하는 인터페이스에 대한 구현을 제공하지 않습니다. 따라서 프로토콜 타입은 그 자신의 프로토콜을 준수하지 않는 것으로 간주합니다.

이 말은 틀린 것이 아닙니다. 그렇지만 위 코드를 약간 수정한 아래 예제를 볼까요?

func test(_ arg: P) -> Bool {
  return arg.value == "foo"
}

print(test(make(1))) // > true
print(test(make(0))) // > false

인자를 프로토콜 타입으로 정의한 경우에는 이 코드는 문제없이 작동합니다. 함수의 인자 arg는 프로토콜 P를 만족하는 것 같고, 정상적으로 작동하는데 앞선 예제에서는 도대체 무슨 차이가 있는 것일까요?

“프로토콜 타입”을 프로토콜과 상관없는 완전히 별개의 타입이라고 생각하는 것에서 출발해 보겠습니다. 어떤 프로토콜 P에서 요구하는 인터페이스를 함수 내부에서 사용한다면, 그 인자의 실제 타입에 구애받지 않고 작동하도록 지정하는 방법에는 아래의 두 가지가 있는 셈입니다.

// 조건을 포함하는 제네릭 타입을 사용
func doSomething<T: P>(_ obj: T) { ... }

// 프로토콜 타입을 사용
func doSomething(_ obj: P) { ... }

첫번째 제네릭 함수에는 (이미 앞에서도 봤지만) P를 준수하는 Foo, Bar 타입의 변수를 전달하면 작동하지만, P 타입의 변수를 전달하면 작동하지 않습니다. 두 번째 함수에서는 Foo, Bar, P 타입 모두 전달해서 작동하는 것을 확인할 수 있습니다. 즉 프로토콜을 따르는 타입의 변수는 프로토콜 타입으로 암묵적으로 인정된다는 것을 알 수 있습니다.

  1. 프로토콜 P를 준수하는 타입 T의 객체는 프로토콜 타입으로서 전달할 수 있습니다.
  2. 프로토콜 타입 P는, 프로토콜 P를 준수하는 제네릭 타입의 자리로 전달될 수 없습니다.

하지만 이것이 처음의 질문인 “왜 프로토콜 타입은 프로토콜을 준수하지 않는가?”에 대한 답을 주지 못합니다. 아래의 평범한 예를 다시 한 번 보겠습니다. 이번에는 이전 예제와 달리 프로토콜에 “정적 프로퍼티”를 정의하고 있다는 차이점이 있습니다.

protocol Animal {
  func foo()
  static var bar: String { get }
}

struct Dog: Animal {
  func foo() { print("Woof") }
  static var bar: String = "dogdogdog"
}

struct Cat: Animal {
  func foo() { print("Meow") }
  static var bar: String = "catcatcat"
}

let dog = Dog()
let cat: Animal = Cat()

dog.makeNoise() // > "Woof"
cat.makeNoise() // > "Meow"

Animal 타입인 객체에 대해서 foo()를 호출하는 것은 객체의 실제 타입과 상관없이 호출 가능합니다. Animal 타입으로 명시적으로 지정되어 있지만, 실제 객체의 타입은 여전히 참조가능하며 메소드는 구현되어 있기 때문입니다. 하지만 Animal.bar 와 같이 정적 프로퍼티에 접근하려고 하면 문제가 됩니다. 코드 어디서도 정의된 바도 없고, 프로토콜에 대해서는 저장 프로퍼티를 정의할 수 없기 때문에 정의하는 것도 불가능합니다. 어떤 타입이 프로토콜을 준수한다는 것은 프로토콜의 모든 요구사항을 충족해야 하기 때문에 Animal 그 자체는 프로토콜 Animal을 준수하지 않는 것으로 볼 수 있습니다.

// 타입 정적 프로퍼티 접근
print(Dog.bar) // > "dogdogdog"
print(Cat.bar) // > "catcatcat"

print(Animal.bar) 
// ERROR: static member 'bar' cannot be used.

즉 정적 프로퍼티를 요구하고 있는 경우, 프로토콜은 해당 프로토콜 타입에 대해 채택되는 것이 불가능합니다. 이를 일반화하여 “프로토콜 타입은 프로토콜을 준수하지 않는 것으로 간주한다”는 규칙이 Swift 컴파일러 내부에 정의되어 있는 것 같습니다.

보통 프로토콜에서 이니셜라이저나, 정적 멤버, 연관타입을 요구 사양에 명시한다면, 이 프로토콜은 제네릭 타입의 조건으로만 사용되며 프로토콜 타입으로는 사용될 수 없는 것으로 봅니다. 하지만 Swift 컴파일러는 이러한 프로토콜도 변수의 타입으로 지정하여 사용할 수 있도록 허용은 해주고 있는 것입니다. 오히려 거꾸로 이런 프로토콜에 대해서 프로토콜 타입으로 사용하지 못하도록 해야하는 것이 아닌가 하는 생각이 들지만, 이것을 제한하면 유연성을 해치기 때문에 계속 허용하는 것 같습니다. (참고로 예외적으로 Error 프로토콜은 프로토콜 타입으로 사용되더라도, 그 자신을 따르는 것으로 취급합니다.)

따라서 프로토콜 타입의 사용에는 이러한 부분을 주의해야 합니다. 다행스러운 것은 변수의 타입을 프로토콜 타입으로 명시하더라도, 객체의 실제 타입이 제거되는 것은 아니라는 점입니다. 따라서 정적 메소드에 접근하려는 경우에는 동적으로 실제 타입을 구해서, 구체적인 타입의 정적 프로퍼티에 접근해야 합니다.

func testAnimal<T: Animal>(_ animal: T) {
  animal.foo()
  print("My animal's bar: \(T.bar)")
}


func dynamicTestAnimal(_ animal: P) {
  animal.foo()
  print("My animal's bar: \(type(of:T).bar)")
}

혹은 프로토콜 타입 대신 불투명 타입으로 변수의 타입을 지정하는 것도 가능합니다. some P 타입은 프로토콜 타입과 달리 변수 자체의 구체적인 타입의 정보를 분명하게 가지고 있음을 보장하기 때문에, 프로토콜을 준수하는 것을 컴파일러가 알 수 있습니다.

let x: some Animal = Dog()
testAnimal(x)
// "Woof"
// "My animal's bar: dogdogdog

정적 멤버 말고 연관타입을 가지고 있는 경우는 다른 글에서 다룬 바 있습니다만, 연관 타입에 의존하는 프로토콜 타입은 구체적인 타입이 정해지지 않은 제네릭 타입과 같기 때문에 변수의 타입으로도 사용될 수 없습니다. 이런 경우에는 타입을 지우는 기법을 사용하거나, 불투명 타입을 사용하는 것으로 해결할 수 있습니다.