🔥 불투명 타입과 박스형 프로토콜 타입의 차이점 1편

813자
11분

Swift에서 불투명한(opaque) 타입을 반환하는 것은 함수의 반환 타입으로 박스형 프로토콜 타입을 사용하는 것과 매우 유사해 보입니다. 하지만 이 두 가지 반환 타입은 타입 정체성(identity)을 보존하는지 여부에 따라 차이가 있습니다. 불투명한 타입은 호출자가 어떤 타입인지 알 수 없지만, 하나의 특정 타입을 나타냅니다. 반면에 박스형 프로토콜 타입은 해당 프로토콜을 준수하는 모든 타입을 나타낼 수 있습니다. 일반적으로 박스형 프로토콜 타입은 저장하는 값의 기본 타입에 대해 더 많은 유연성을 제공하고, 불투명한 타입은 기본 타입에 대해 더 강력한 보장을 할 수 있습니다.

예를 들어, 다음은 불투명한 반환 타입 대신 박스형 프로토콜 타입을 반환 타입으로 사용하는 flip(_:) 함수의 버전입니다:

func protoFlip<T: Shape>(_ shape: T) -> Shape {
    return FlippedShape(shape: shape)
}
swift

이 버전의 protoFlip(_:)flip(_:)과 동일한 본문을 가지고 있으며, 항상 같은 타입의 값을 반환합니다. 그러나 flip(_:)과는 달리, protoFlip(_:)이 반환하는 값은 항상 같은 타입일 필요는 없으며, 단지 Shape 프로토콜을 준수하기만 하면 됩니다. 다시 말해, protoFlip(_:)flip(_:)보다 호출자와의 API 계약이 훨씬 느슨합니다. 이는 여러 타입의 값을 반환할 수 있는 유연성을 가지고 있습니다:

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

이 코드의 수정된 버전은 전달된 도형에 따라 Square 인스턴스 또는 FlippedShape 인스턴스를 반환합니다. 이 함수에 의해 반환된 두 개의 뒤집힌 도형은 완전히 다른 타입을 가질 수 있습니다. 이 함수의 다른 유효한 버전들은 동일한 도형의 여러 인스턴스를 뒤집을 때 서로 다른 타입의 값을 반환할 수 있습니다. protoFlip(_:)에서 반환된 값에 대한 덜 구체적인 타입 정보는 반환된 값에 대해 타입 정보에 의존하는 많은 연산을 사용할 수 없다는 것을 의미합니다. 예를 들어, 이 함수에서 반환된 결과를 비교하는 == 연산자를 작성하는 것은 불가능합니다.

let protoFlippedTriangle = protoFlip(smallTriangle)
let sameThing = protoFlip(smallTriangle)
protoFlippedTriangle == sameThing  // 오류
swift

예제의 마지막 줄에서 오류가 발생하는 이유는 여러 가지입니다. 가장 직접적인 문제는 Shape이 프로토콜 요구사항의 일부로 == 연산자를 포함하지 않는다는 것입니다. 만약 추가하려고 시도한다면, 다음에 마주칠 문제는 == 연산자가 좌변과 우변 인수의 타입을 알아야 한다는 것입니다. 이러한 종류의 연산자는 일반적으로 프로토콜을 채택하는 구체적인 타입과 일치하는 Self 타입의 인수를 받지만, 프로토콜에 Self 요구사항을 추가하는 것은 프로토콜을 타입으로 사용할 때 발생하는 타입 소거(type erasure)를 허용하지 않습니다.

함수의 반환 타입으로 박스형 프로토콜 타입을 사용하면 프로토콜을 준수하는 모든 타입을 반환할 수 있는 유연성을 얻을 수 있습니다. 그러나 그 유연성의 비용은 반환된 값에 대해 일부 연산을 수행할 수 없다는 것입니다. 예제에서 == 연산자를 사용할 수 없음을 보여주었는데, 이는 박스형 프로토콜 타입을 사용할 때 보존되지 않는 특정 타입 정보에 의존하기 때문입니다.

이 접근 방식의 또 다른 문제는 도형 변환이 중첩되지 않는다는 것입니다. 삼각형을 뒤집은 결과는 Shape 타입의 값이고, protoFlip(_:) 함수는 Shape 프로토콜을 준수하는 어떤 타입의 인수를 받습니다. 그러나 박스형 프로토콜 타입의 값은 해당 프로토콜을 준수하지 않습니다. protoFlip(_:)에 의해 반환된 값은 Shape을 준수하지 않습니다. 이는 protoFlip(protoFlip(smallTriangle))과 같이 여러 변환을 적용하는 코드가 유효하지 않다는 것을 의미합니다. 뒤집힌 도형이 protoFlip(_:)에 대한 유효한 인수가 아니기 때문입니다.

반면에, 불투명한 타입은 기본 타입의 정체성을 보존합니다. Swift는 연관 타입을 추론할 수 있으므로, 박스형 프로토콜 타입을 반환 값으로 사용할 수 없는 곳에서 불투명한 반환 값을 사용할 수 있습니다. 예를 들어, 다음은 제네릭Container 프로토콜 버전입니다:

protocol Container {
    associatedtype Item
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}
extension Array: Container { }
swift

Container는 연관 타입을 가지고 있기 때문에 함수의 반환 타입으로 사용할 수 없습니다. 또한 제네릭 반환 타입의 제약 조건으로도 사용할 수 없는데, 함수 본문 외부에는 제네릭 타입이 무엇이어야 하는지 추론할 만한 충분한 정보가 없기 때문입니다.

// 오류: 연관 타입이 있는 프로토콜은 반환 타입으로 사용할 수 없습니다.
func makeProtocolContainer<T>(item: T) -> Container {
    return [item]
}
 
// 오류: C를 추론할 정보가 충분하지 않습니다.
func makeProtocolContainer<T, C: Container>(item: T) -> C {
    return [item]
}
swift

반환 타입으로 불투명한 타입 some Container를 사용하면 원하는 API 계약을 표현할 수 있습니다. 함수는 컨테이너를 반환하지만 컨테이너의 타입을 지정하지 않습니다:

func makeOpaqueContainer<T>(item: T) -> some Container {
    return [item]
}
let opaqueContainer = makeOpaqueContainer(item: 12)
let twelve = opaqueContainer[0]
print(type(of: twelve))
// "Int" 출력
swift

twelve의 타입은 Int로 추론되는데, 이는 불투명한 타입에서 타입 추론이 작동한다는 사실을 보여줍니다. makeOpaqueContainer(item:)의 구현에서, 불투명한 컨테이너의 기본 타입은 [T]입니다. 이 경우 TInt이므로, 반환 값은 정수 배열이고 Item 연관 타입은 Int로 추론됩니다. ContainersubscriptItem을 반환하므로, twelve의 타입도 Int로 추론됩니다.

불투명 타입과 박스형 프로토콜 타입의 차이점 정리

불투명 타입과 박스형 프로토콜 타입은 타입 정보를 숨기는 방식과 타입 캐스팅 가능 여부에 있어 중요한 차이점이 있습니다.

특징불투명 타입 (some)박스형 프로토콜 타입 (any)
타입 정보 숨김컴파일 타임에 완전히 숨김런타임에 숨김
다운 캐스팅불가능가능 (as?, as! 사용)
타입 안정성강력한 타입 안정성 제공다운 캐스팅으로 인한 런타임 오류 가능성 있음

불투명 타입은 컴파일 타임에 타입 정보를 완전히 숨기므로 다운 캐스팅이 불가능하지만, 강력한 타입 안정성을 제공합니다. 반면에 박스형 프로토콜 타입은 런타임에 타입 정보를 숨기므로 다운 캐스팅이 가능하지만, 런타임 오류의 가능성이 있습니다.

따라서 상황에 따라 적절한 타입을 선택하는 것이 중요합니다. 강력한 타입 안정성과 타입 정보 보존이 필요한 경우에는 불투명 타입을, 유연성과 다운 캐스팅이 필요한 경우에는 박스형 프로토콜 타입을 사용하는 것이 좋겠죠.