스위프트는 **C++**의 제로 비용 추상화와 Objective-C의 동적 유연성 간의 간극을 메우기 위해 설계되었습니다. 초기 버전은 클래스 상속과 가상 메서드 테이블에 중점을 두었지만, 스위프트 2.0에서 프로토콜 지향 프로그래밍이 도입되면서 더 미세하게 조정된 분배 모델을 필요로 하게 되었습니다. 컴파일러 팀은 프로토콜 요구 사항 (프로토콜 본체에 선언된 메서드)은 런타임 다형성을 위해 증인 테이블을 활용하고, 확장에서만 정의된 메서드는 정적으로 해결되도록 혼합 접근 방식을 선택했습니다. 이 설계 결정은 복고 모델링과 값 유형을 지원할 필요성과 정적 분배의 성능 특성을 희생하지 않으려는 필요성에서 유래되었습니다.
개발자들은 일반적으로 프로토콜 확장에서 메서드 구현을 제공하는 것이 폴리모픽하게 준수하는 유형이 이를 오버라이드할 수 있는 "기본" 동작을 생성한다고 가정합니다. 그러나 스위프트는 확장 메서드를 인스턴스의 런타임 유형이 아닌 참조의 컴파일 타임 유형을 기반으로 정적으로 분배합니다. 존재론 상자(any Protocol)를 사용할 때, 컴파일 타임 유형은 존재론 컨테이너 자체로, 모든 구체적 유형의 오버라이드를 무시하고 확장 구현으로 호출이 해결됩니다. 이는 이질적인 컬렉션에서 서브클래스 또는 구조체의 사용자 정의 구현이 조용히 우회되어 생기는 교묘한 버그를 초래합니다.
진정한 동적 다형성을 가능하게 하려면, 메서드는 프로토콜 선언 내에서 프로토콜 요구 사항으로 선언되어야 합니다. 이를 통해 컴파일러는 해당 메서드에 대한 증인 테이블 항목을 할당하게 되어 런타임에서 유형의 증인 테이블을 통해 올바른 구현을 조회할 수 있게 됩니다. 다형성이 필요 없는 성능 중시 알고리즘의 경우 메서드는 확장에 남아 컴파일러가 이를 인라인 하거나 다른 정적 최적화를 수행할 수 있도록 해야 합니다. **스위프트 5.6+**에서는 존재론 타입 소거를 보다 명확하게 하기 위해 명시적인 any 키워드 구문이 도입되었으며, 이는 타입 정보가 손실되고 정적 분배가 기본적으로 확장으로 돌아간다는 점을 상기시켜줍니다.
protocol Drawable { func draw() // 요구 사항: 증인 테이블을 통한 동적 분배 } extension Drawable { func draw() { print("기본") } func render() { print("정적 렌더") } // 확장: 정적 분배 전용 } struct Circle: Drawable { func draw() { print("원") } func render() { print("원 렌더") } } let shape: any Drawable = Circle() shape.draw() // "원"을 출력합니다 (동적 분배) shape.render() // "정적 렌더"를 출력합니다 (정적 분배 - Circle의 버전을 무시합니다!)
우리는 다양한 형상이 RenderCommand 프로토콜을 준수하는 벡터 그래픽 엔진을 개발하고 있었습니다. 처음에 모든 형상의 기본 래스터화된 썸네일을 제공하기 위해 프로토콜 확장에서 전적으로 generatePreview() 메서드를 추가했습니다. 구체적 유형인 BezierCurve와 Polygon은 선명한 렌더링을 위한 자신만의 최적화된 generatePreview() 메서드를 구현했습니다. 이러한 형상들을 렌더링 파이프라인을 처리하기 위해 [any RenderCommand] 배열에 저장할 때, 각 요소에서 generatePreview()를 호출하면 사용자 정의 고품질 미리보기 대신 동일한 흐릿한 기본 이미지가 생성되는 것을 발견했습니다.
우리는 세 가지 솔루션을 고려했습니다. 첫째, 우리는 generatePreview()를 RenderCommand 프로토콜 선언으로 이동하여 공식 요구 사항으로 만드는 것이었습니다. 이 접근 방식은 증인 테이블을 통한 동적 분배를 보장하며 런타임에서 올바른 메서드 해결을 보장합니다. 그러나 이 경우 모든 형상 유형이 자체 구현을 요건으로 명시해야 하며, 맞춤화가 필요하지 않은 유형에 대한 기본 구현을 확장에 유지함으로써 보일러플레이트를 완화할 수 있습니다.
둘째, 우리는 파이프라인을 제너릭을 사용하여 func process<T: RenderCommand>(commands: [T])와 같은 함수 시그니처로 재구성할 수 있었습니다. 이는 스위프트가 컴파일 타임에 제너릭을 단일형태화하기 때문에 올바른 구현에 대한 정적 분배를 유지합니다. 단점은 이 경우 이질적인 형상 유형(BezierCurve와 Polygon)을 단일 배열에 저장할 수 없게 되며, 타입 소거 래퍼를 구현해야 하므로 코드 복잡성이 크게 증가합니다.
셋째, 우리는 Visitor 패턴을 구현하여 메서드 호출을 적절한 구체적 유형으로 수동으로 라우팅할 수 있었습니다. 이는 프로토콜 정의를 완전히 수정하지 않고도 다형적 동작을 달성할 수 있게 하지만, 이 솔루션은 상당한 보일러플레이트 코드를 도입하고 시스템에 새로운 형상 유형이 추가될 때마다 유지 관리 부담을 증가시킵니다.
결국 우리는 프로토콜이 모듈 내부에 있었고, 다형적 동작의 명확성이 렌더링 엔진의 정확성에 중요했기 때문에 첫 번째 솔루션을 선택했습니다. 요구 사항을 추가하는 것은 이진 크기에 미미한 영향을 미치는 반면, 증인 테이블 간접 호출의 약간의 오버헤드는 렌더링 계산에 비해 감지할 수 없었습니다. 이러한 변경을 구현한 후, 미리보기 생성이 각 형상의 최적화된 구현을 올바르게 활용하여 UI에서 시각적 왜곡을 제거했습니다.
왜 서브클래스가 프로토콜 확장에서만 정의된 메서드를 오버라이드할 수 없는가?
메서드가 프로토콜 자체에 선언되지 않고 프로토콜 확장에서만 정의된 경우, 스위프트는 이를 위한 증인 테이블 항목을 할당하지 않습니다. 분배는 참조 유형을 기준으로 컴파일 타임에 정적으로 해결됩니다. 클래스가 프로토콜을 준수하고 동일한 시그니처로 메서드를 정의하면, 이는 확장 메서드를 오버라이드하지 않고 해당 확장 메서드를 가리는 새로운 관련 없는 메서드를 생성합니다. 이는 프로토콜 존재론(any Protocol)을 통해 접근할 때 프로토콜 확장의 구현이 항상 호출되며, 클래스의 버전을 무시하게 됩니다. 다형적 동작을 달성하려면 메서드는 프로토콜 선언에 요구 사항으로 선언되어야 합니다.
any 대신 some (불투명 결과 유형)을 사용할 때 프로토콜 확장 메서드에 대한 분배에 어떤 영향을 미치는가?
some Drawable인 경우, 구체적 유형은 스위프트의 제너릭 단일형태화 덕분에 컴파일 타임에 알려져 있습니다. 불투명 유형의 확장 메서드를 호출할 때, 컴파일러는 구체적 유형의 구현에 대해 정적으로 분배할 수 있습니다. 반면에 any Drawable은 구체적 유형을 소거하는 존재론 상자이므로, 비요구 사항 메서드에 대해 기본 구현을 사용해야 합니다. 핵심 차이는 some이 정적 다형성을 유지하여 컴파일러가 올바른 메서드에 인라인하거나 직접 바인딩할 수 있게 하는 반면, any는 런타임 가상 테이블 조회를 요구하며 비요구 사항에 대해서는 기본적으로 확장으로 돌아갑니다.
확장 메서드를 프로토콜 요구 사항으로 변환할 때 이진 크기와 성능에 미치는 영향은?
확장 메서드를 프로토콜 요구 사항으로 변환하면 프로토콜의 증인 테이블에 항목이 추가되어 64비트 아키텍처에서 각 준수에 대해 약 8바이트의 이진 크기가 증가합니다. 각 준수 유형은 이제 이 증인 테이블에서 이 슬롯을 채워야 하므로, 유형당 소량의 메모리 오버헤드가 추가됩니다. 성능 측면에서 요구 사항은 증인 테이블을 통한 간접 호출 오버헤드를 발생시키며 (하나의 추가 포인터 역참조 및 점프), 반면 확장 메서드는 인라인되거나 직접 호출되어 제로 오버헤드를 갖습니다. 그러나 요구 사항의 인라인 소실은 CPU의 분기 예측기로 보완되는 경우가 많으며, 올바른 다형적 동작의 이점이 대부분의 애플리케이션 코드에서는 간접 호출의 나노초 단위 비용보다 대개 더 중요합니다.