[번역] Static vs Dynamic Dispatch in Swift: A decisive choice (Shubham Bakshi)

삭제된 Velog·2025년 1월 9일
0

Swift

목록 보기
5/5
post-thumbnail

본 글은 Static vs Dynamic Dispatch in Swift: A decisive choice (Shubham Bakshi)를 한국어로 번역하여 옮긴 글입니다.

구문이 깔끔하게 강조되고 내용을 보충하여 보다 더 잘 다듬어진 글들은 모두 저의 개인 블로그에서 찾으실 수 있습니다! 피드백과 격려에 매우 감사드리며, 앞으로의 컨텐츠에 큰 힘이 됩니다.

Static vs Dynamic Dispatch in Swift: A decisive choice


객체지향 프로그래밍(Object-Oriented Programming)에 익숙하시다면, 이러한 메서드 디스패치(method dispatch) 기술(특히 동적 디스패치(dynamic dispatch))이 새롭지는 않을 겁니다.

메서드 디스패치는 어느 연산자를 수행할지, 더 구체적으로, 어느 메서드 구현이 사용되어야 하는지 결정하는 데 도움을 주는 메커니즘입니다.

Basics

먼저 시작하자면, 정적 디스패치(static dispatch)값 타입참조 타입 모두에서 지원됩니다.

그러나, 동적 디스패치는 오직 참조 타입(예: 클래스)에서만 지원됩니다. 이러한 이유는 동적 디스패치상속을 필요로 하는데, 값 타입은 상속을 지원하지 않기 때문입니다.

그 점을 염두에 두고, 다음으로 넘어갑시다!

전체적인 그림을 살펴보면, 디스패치에는 2가지(정적과 동적)가 아니라, 4가지 유형이 있습니다.

  1. Inline (가장 빠른)

  2. Static Dispatch

  3. Virtual Dispatch

  4. Dynamic Dispatch (가장 느린)

어떤 디스패치 기술을 사용할 지는 컴파일러가 결정하며, 컴파일러는 우선 인라인(inline)을 선택하고, 필요에 따라 그다음 기술로 내려갑니다.

Static vs Dynamic or Swift vs Objective-C

기본적으로, 오브젝티브-C는 동적 디스패치를 지원합니다. 이 디스패치 기술은 다형성(polymorphism)이라는 방식으로 개발자에게 유연성을 제공합니다. 서브 클래싱과 기존 메서드 등을 재정의하는 건 훌륭하지만, 대가가 따릅니다.

동적 디스패치상당한 런-타임 오버헤드를 대가로 언어의 표현력을 증가시킵니다. 이는 동적 디스패치의 경우, 매번 메서드를 호출 할 때마다 컴파일러는 특정 메서드의 구현을 확인하기 위해 위트니스 테이블(witness table)을 살펴봐야 한다는 걸 의미합니다(다른 언어에서는 가상 테이블(virtual table) 또는 디스패치 테이블(dispatch table)). 컴파일러는 코드가 상위 클래스의 구현을 참조하는지, 아니면 하위 클래스의 구현을 참조하는지 결정해야 합니다. 그리고 모든 객체에 대한 메모리가 런-타임에 할당되기 때문에, 컴파일러는 런-타임에만 해당 검사를 수행할 수 있습니다.

그러나, 정적 디스패치는 이러한 문제가 없습니다. 이 디스패치 기술은 컴파일러가 컴파일 시간에 어떤 메서드 구현을 호출해야 하는지 알고 있습니다. 그래서 컴파일러는 상당한 최적화를 수행할 수 있으며, 심지어 가능하다면 코드를 인라인으로 변환해 전체적인 작업 속도를 무지하게 빠르게 만들 수 있습니다!

그렇다면 Swift에서는 이 둘을 어떻게 사용할 수 있을까요?

  • 동적 디스패치_를 사용하려면, 상속을 사용하여 기본 클래스를 상속한 후, 기본 클래스의 기존 메서드를 재정의하세요. 또한, dynamic를 사용할 수 있으며, 메서드를 오브젝티브-C 런-타임에 노출시키기 위해 @objc 키워드를 접두어로 붙여야 합니다.

  • 정적 디스패치_를 사용하려면, 클래스와 메서드가 재정의되지 않도록 보장하기 위해 finalstatic 키워드를 사용해야 합니다.

Let's dive deep

Static Dispatch(or Direct Dispatch)

앞서 설명한 바와 같이, 컴파일러가 컴파일-타임에 명령어의 위치를 알아내는 게 가능하기에 정적 디스패치는 동적 디스패치와 비교해 빠른 속도를 보여줍니다. 그래서, 함수가 호출되면 컴파일러는 연산을 수행하기 위해 함수의 메모리 주소로 바로 이동합니다. 이는 엄청난 성능 향상과 인라이닝(inlining)과 같은 상당한 컴파일러 최적화라는 결과로 이어집니다.

Dynamic Dispatch

앞서 설명한 바와 같이, 동적 디스패치는 구현이 컴파일-타임이 아닌 런-타임에 선택되며, 이는 약간의 오버헤드를 더합니다.

이제 여러분은 “이렇게 비용이 많이 드는데, 왜 이것을 사용하는 걸까?“라고 궁금해할 수도 있습니다.

이는 바로 유연성 때문입니다. 사실, 대부분의 객체지향 프로그래밍 언어는 다형성의 존재를 허용하기 때문에 동적 디스패치를 지원합니다.

동적 디스패치에는 두 가지 유형이 있습니다.

1. Table Dispatch

이 디스패치 기술은 특정 메서드의 구현을 찾기 위해 위트니스 테이블(또는 가상 테이블)이라 불리는 함수 포인터의 배열로 구성된 테이블을 활용합니다.

그럼 이 위트니스 테이블은 어떻게 동작하나요?

  • 모든 하위 클래스는 고유한 테이블의 복사본을 가집니다.

  • 이 테이블은 해당 클래스가 재정의한 모든 메서드에 대해 서로 다른 함수 포인터를 가집니다.

  • 하위 클래스에 새로운 메서드가 구현되면, 해당 메서드 포인터들은 배열의 끝에 추가됩니다.

  • 마침내, 컴파일러는 런-타임에 이 테이블을 활용하여 메서드에 대해 호출할 구현을 확인합니다.

컴파일러는 테이블에서 구현에 대한 메모리 주소를 읽어야 하고, 해당 주소로 이동합니다. 이는 두 개의 추가 명령어를 요구하기 때문에 정적 디스패치보다 느리지만, 메시지 디스패치보다는 여전히 빠릅니다.

NOTE: 이 독특한 디스패치 기술은 가상 테이블을 사용하기 때문에 가상 디스패치(virtual dispatch)가 될 수 있겠으나, 이에 대한 확실한 참고 자료를 찾을 수 없었습니다.

2. Message Dispatch

이 동적 디스패치 기술은 (말 그대로) 가장 동적입니다. 사실, 메시지 디스패치는 코코아 프레임워크가 KVO, 코어 데이터와 같은 주요 기능에서 (최적화 부분을 제외하고) 사용할 정도로 매우 뛰어납니다.

또한, 이는 메서드 바꿔치기(method swizzling)를 가능케 해주는데, 이는 일반적으로 이 기술을 사용하여 런-타임에 메서드의 기능을 변경할 수 있다는 것을 의미합니다.

이제, Swift 컴파일러는 메시지 디스패치를 기본적으로(out-of-the-box) 제공하지 않습니다. 메시지 디스패치 기술을 사용하려면 오브젝티브-C 런-타임을 활용해야 합니다.

이 디스패치를 활용하기 위해, 명시적으로 dynamic 키워드를 적어야 합니다. Swift 4.0 이전에는 dynamic을 사용할 때마다 암시적으로 @objc가 추가되었지만, Swift 4.0 이후부터는 메서드가 오브젝티브-C 런-타임과 메시지 디스패치에 노출되도록 하기 위해 명시적으로 @objc를 적어야 합니다.

오브젝티브-C 런-타임을 사용하기 때문에, 메시지를 보낼(dispatch) 때, 런-타임은 호출할 메서드를 결정하기 위해 클래스 계층을 탐색합니다. 이는 정말 느립니다. 따라서 성능을 높이기 위해 런-타임은 캐시를 제공하며, 이는 어느 정도 차이를 만들어 냅니다.

컴파일러는 우리가 명시적으로 dynamic 키워드를 적지 않는 한, 항상 디스패치 기술을 정적 디스패치로 업그레이드하려고 시도합니다.

Examples

Value Types

struct Person {
	func isTrritating() -> Bool { } // Static
}
extension Person { 
	func canBeEasilyPissedOff() -> Bool { } // Static
}

structenum은 값 타입이고 상속을 지원하지 않기 때문에, 컴파일러는 절대로 상속할 수 없다는 사실을 알고 정적 디스패치로 처리합니다.

Protocol

protocol Animal {
	func isCute() -> Bool { } // Table
}
extension Animal {
	func canGetAngry() -> Bool { } // Static
}

여기서 주목해야 할 핵심은 확장에 정의된 모든 메서드는 정적 디스패치를 사용한다는 점입니다.

(옮긴이 주: 요구사항인 메서드를 확장에 정의한다면 동적 디스패치를 사용합니다.)

Class

class Dog: Animal {
	func isCute() -> Bool { } // Table
    @objc dynamic func hoursSleep() -> Int { } // Message
}
extension Dog {
	func canBite() -> Bool { } // Static
    @objc func goWild() { } // Message
}
final class Employee {
	func canCode() -> Bool { } // Static
}
  • 일반적인 메서드 선언은 프로토콜과 동일한 규칙을 따릅니다.

(옮긴이 주: 상위 클래스의 확장에 정의된 메서드는 하위 클래스에서 재정의할 수 없어 정적 디스패치를 사용합니다.)

  • @objc를 사용해 메서드를 오브젝티브-C 런-타임에 노출하면, 메서드는 메시지 디스패치를 사용합니다.

  • 그러나, 클래스에 final을 적으면, 해당 클래스는 상속될 수 없으므로, 그 메서드들은 정적 디스패치를 사용합니다.

자, 지금까지는 제가 말하고, 여러분이 제 말을 그대로 믿는 상황이었죠, 그렇죠?

이러한 메서드가 앞서 설명한 디스패치 기술을 실제로 사용한다는 걸 어떻게 증명하죠?

이를 위해서, 우리는 Swift Intermediate Language를 살펴봐야 합니다. 온라인에서 조사한 바에 따르면, 방법이 하나 있습니다.

  1. 함수가 테이블 디스패치를 사용하면, SIL에 vtable(또는 witness_table)이 보입니다.
sil_vtable Animal {
#Animal.isCute!1: (Animal) -> () -> () : main.Animal.isCute() -> () // Animal.isCute()
......
}
  1. 함수가 메시지 디스패치를 사용하면, volatile 키워드가 보입니다. 호출부에 나타납니다. 또한, 해당 함수가 오브젝티브-C 런-타임을 사용해 호출된다는 걸 나타내는 foreignobjc_method라는 두 키워드를 찾을 수 있습니다.
%14 = class_method [volatile] %13 : $Dog, #Dog.goWild!1.foreign : (Dog) -> () -> (), $@convention(objc_method) (Dog) -> () 
  1. 상기 두 가지 경우가 보이지 않는다면, 정적 디스패치를 사용한다는 걸 의미합니다.

좋아요! 이것으로 제 이야기는 마치겠습니다. 이번 주제는 두 개의 글로 나눠 구성할 계획이었으며, 다음 글(여기에서 확인)에서는 테스트 케이스를 통해 정적 디스패치와 동적 디스패치 간의 성능 비교를 해보겠습니다.

참고 자료: Method dispatch in Swift — Thuyen’s corner

LinkedIn 👱🏻를 통해 연락해주시거나, 아니면 다른 채널 📬을 통해서도 연락하실 수 있습니다.

profile
rlarjsdn3.github.io

0개의 댓글