오늘은 Static Dispatch & Dynamic Dispatch의 대해 개념적으로 이해를 하고 넘어가보자 이번 면접을 통해 해당 개념을 질문 받았는데 명칭이 생소하다고 느껴서인지 어느정도 개념은 알고 있었지만 제대로 정리가 되어있지 않아 아예 대답을 하지 못했다 ㅠㅠ 끝나고 나와서 찾아보니 아~ 맞다 이런 느낌.. 그래도 이제라도 정리하고 제대로 정리하는 기회를 가져보자!
먼저 Static Dispatch & Dynamic Dispatch에 들어가기 전에 Dispatch에 대해서 먼저 짚고 넘어가자면 메소드나 함수 호출 시점에서 어떤 구현이 실행될지를 결정하는 어떠한 메커니즘이다 정도로 알고 넘어가면 될 것 같다.
내가 호출한 함수를 컴파일 타임에 결정하냐, 런타임에 결정하냐에 따라 방식이 정해지는데 이 방식을 결정한다고 생각하면 된다!
여기서 이제 컴파일 타임에서 실행되는 함수와 런타임에서 실행되는 함수로 구분되는 것이 바로 Static Dispatch & Dynamic Dispatch 개념이다!
Static Dispatch
스태틱 디스패치는 컴파일 타임에 호출 대상을 결정하고 주로 값 타입(Struct, Enum)이나 제네릭 코드에서 사용되며, 컴파일러가 인라인 최적화 같은 고급 최적화를 적용할 수 있다. 컴파일 타임에 호출되어 사용되기 때문에 성능상 이점을 가질 수 있다는 것이 특징!
간단한 예제 코드를 살펴보면 Swift에서는 값 타입의 메소드와 제네릭 코드에서 컴파일 타임에 메소드가 호출된다.
struct Calculator {
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
}
let calc = Calculator()
print(calc.add(3, 4)) // 컴파일 타임에 결정되어 인라인 최적화 가능
여기서 추가적으로 메소드에 final 이나 private를 붙이면 서브클래스에서 오버라이딩이 불가능하여 컴파일러가 정적 호출을 선택할 수 있게 된다.
Dynamic Dispatch
다이나믹 디스패치는 런타임에 실제 객체의 타입에 따라 호출 대상을 결정한다. 클래스의 메소드 오버라이딩과 같은 다형성을 지원하는 핵심 메커니즘이라고 할 수 있다.
런타임에 호출될 함수를 결정하기 때문에 클래스 마다 함수 포인터들의 배열인 vTable이라는 것을 유지하게 되는데 하위 클래스가 메서드를 호출할 때 이 vTable를 참조하여 실제 호출할 함수를 결정하게 되는데 이 과정들이 런타임에 일어나기 때문에 성능적으로 손해를 보게 된다.
class Animal {
func sound() {
print("Animal sound")
}
}
class Dog: Animal {
override func sound() {
print("Bark")
}
}
let pet: Animal = Dog()
pet.sound() // 런타임에 Dog의 sound()가 호출됨 (다이나믹 디스패치)
기본적으로 클래스의 메소드는 오버라이드가 가능하므로, 실제 인스턴스의 타입에 따라 호출이 결정된다.
또한, 프로토콜에 선언된 메소드는 해당 프로토콜 타입으로 호출할 경우 다이나믹 디스패치가 적용된다. 단, 프로토콜 확장에서 기본 구현을 제공하는 메소드는 스태틱 디스패치되는 점 참고!!!!
protocol Greetable {
func greet() -> String
}
extension Greetable {
func greet() -> String {
return "Hello from extension"
}
}
struct Person: Greetable { }
let p: Greetable = Person()
print(p.greet()) // 프로토콜 확장의 기본 구현이 호출되며 정적 디스패치됨
정리하자면 Static Dispatch & Dynamic Dispatch 이 두가지 방식의 차이점은 스태틱 디스패치는 컴파일 시점에서 함수를 결정하기 때문에 성능적으로 이점이 있고, 다이나믹 디스패치는 런타임 시점에서 함수를 결정하기 때문에 성능상 손해가 있다는 점이다!
Reference Type (Class)에서의 Dynamic Dispatch
그렇다면 성능에 손해가 있는데 동적 디스패치가 필요한 이유가 무엇이 있을지 생각해보자!
먼저 동적 디스패치가 필요한 이유는
- 상속과 오버라이딩
클래스는 상속을 통해 기능을 확장할 수 있으며, 하위 클래스에서 부모 클래스의 메서드를 오버라이딩 할 수 있다.
예를 들어
class Human {
func sayHello() {
print("Hello Human!")
}
}
class Teacher: Human {
override func sayHello() {
print("Hello Teacher!")
}
}
let person: Human = Teacher()
person.sayHello() // 출력: Hello Teacher!
여기서 실제 인스턴스는 Teacher이지만, 변수의 타입은 Human이다. 그래서 런타임 시점에 실제 객체의 타입에 따라 올바른 메서드가 호출되어야 하기 때문에 Human을 상속받은 Teacher를 사용하는 것!
vTable (가상 테이블)의 역할
- vTable의 구조:
클래스는 각 메서드에 대한 함수 포인터를 저장하는 vTable을 갖게 되는데
하위 클래스는 상위 클래스의 vTable을 복사한 뒤, 오버라이딩한 메서드의 포인터를 교체한다. 만약 오버라이딩 하지 않은 메서드는 상위 클래스의 포인터가 그대로 남게 된다!
- 런타임 디스패치 과정 :
메서드를 호출할 때, 런타임은 해당 인스턴스의 vTable을 조회하여 실제로 호출할 함수의 주소를 찾고, 그 주소로 점프하게 된다.
이 과정은 두 단계 이상의 추가 명령(vTable 접근, 함수 포인터 로딩 등)이 필요하여 정적 호출보다 약간의 오버헤드가 발생한다.
Class와 같은 참조타입을 최적화 하는 법
클래스의 메서드는 오버라이딩 가능성이 항상 있기 때문에, 컴파일러는 기본적으로 동적 디스패치를 선택한다. 이를 성능 향상을 목적으로 정적 디스패치로 전환하는 방법은 메서드에 final이나 private 키워드를 붙여 사용하면 더이상 오버라이딩되지 않으므로 컴파일러는 이를 정적 디스패치로 최적화 할 수 있게 된다!
그렇기 때문에 적절한 접근 제어자 및 final 를 활용해 class의 성능을 최적화 할 수 있다는 점을 잊지말고 머리에 꼭꼭 넣어두자!!
Value Type에서의 Static Dispatch
구조체와 열거형은 상속이 불가능하므로 오버라이딩의 가능성이 전혀 없다.
예를들어
struct Human {
func sayHello() {
print("Hello Human!")
}
}
let person = Human()
person.sayHello() // 항상 Human의 sayHello가 호출됨
이렇게 호출할 메서드가 상속이 불가능하기 때문에 스태틱 디스패치가 적용되며 이 경우 컴파일 타임에 결정되기 때문에 직접 함수 주소로 점프하여 인라인 최적화 등 다양한 최적화가 적용된다.
이로 인해 런타임 오버헤드가 거의 없고 빠른 실행 속도를 보이게 되니 이 개념을 꼭 잊지 말자!!
Protocol에서의 Dispatch 및 Witness Table
마지막으로 프로토콜의 특징과 디스패치에 대해서 알아보자!
프로토콜은 메서드의 선언부만을 제공하며, 실제 구현은 각 타입이 제공하거나 프로토콜 확장에서 기본 구현을 제공할 수 있다.
만약 인스턴스가 구체적인 타입으로 호출된다면, 컴파일러는 정적으로 해당 구현을 호출할 수 있게 되지만 프로토콜 타입으로 호출할 경우, 실제 구현이 어떤 타입에 속하는지 런타임에 결정되어야 하기때문에 동적 디스패치를 사용하게 된다.
- Witness Table
프로토콜을 준수하는 각 타입은 자신만의 Witness Table을 갖게 된다. 이 테이블은 해당 타입이 프로토콜의 요구사항을 어떻게 구현했는지에 대한 함수 포인터나 정보들을 저장한다.
프로토콜 타입의 변수로 메서드를 호출할 때, 런타임은 인스턴스의 Witness Table을 조회하여 올바른 구현을 찾아 호출한다. 이 과정은 클래스의 vTable과 유사하지만, 프로토콜에서는 여러 타입이 동일한 프로토콜을 채택할 수 있기 때문에, 이를 일반화한 메커니즘이다.
protocol Human {
func description()
}
struct Teacher: Human {
func description() {
print("I'm a teacher")
}
}
struct Student: Human {
func description() {
print("I'm a student")
}
}
var person: Human = Teacher()
person.description() // 런타임에 Teacher의 구현을 Witness Table을 통해 호출
person = Student()
person.description() // 런타임에 Student의 구현을 Witness Table을 통해 호출
여기서 프로토콜 확장에서 기본 구현을 제공한 경우, 만약 해당 메서드가 프로토콜 요구 사항에 직접 포함되지 않고 확장에서만 구현된 것이라면, 정적 디스패치가 적용될 수 있으므로, 호출되는 구현이 예상과 다를 수 있다. 이 점을 유의해서 프로토콜 기반 설계를 할 필요가 있어보인다..!
한번 정리한 것으로 큰 중요한 내용은 이해가 됐지만 아직 깊게 이해하기 위해서는 조금 더 깊게 파고들어 공부해볼 필요가 있는 부분인 것 같다 ㅠㅠ 조금 어렵네.. 그래도 지금까지 접근제어자를 쓰면서 정확하게 어떤 장점이 있는지 궁금했었는데 이제야 좀 알게된 것 같아 살짝 반성한다..!
다음 포스팅도 동일하게 이 정적, 동적 디스패치에 대해서 조금 더 깊게 알아보는 과정으로 다시 작성해봐야겠다!!
오늘은 여기까지!
'◽️ Programming > iOS' 카테고리의 다른 글
Sendable에 대해서 알아보자 (0) | 2025.02.24 |
---|---|
Swift Concurrency에서 Task는 어떤 역할을 담당하나 (0) | 2025.02.20 |
Objective-C를 Swift에 가져와 사용하기 (0) | 2025.02.12 |
Swift Concurrency 중 MainActor의 역할은 무엇인가 (0) | 2025.02.03 |
Vision 프레임 워크를 활용해 얼굴 인식하기 (0) | 2025.01.20 |