Swift Performance 향상 시키기 (feat. Method Dispatch)

Dynamic Dispatch & Static Dispatch

Lee Di
DelightRoom
12 min readOct 18, 2022

--

Photo by Robin Pierre on Unsplash

안녕하세요 Delightroom iOS 개발자 리디입니다. 오늘은 Swift Performance 향상 시키기 라는 주제를 들고 여러분을 찾아 뵈었습니다. 하지만 본론에 들어가기에 앞서! 우리가 먼저 알아야 할 개념이 있습니다. 바로 ‘Method Dispatch’ 입니다.

읭 Dispatch? DispatchQueue 이런거 말하는건가 ? 하시는 분들을 위해서 먼저 간단하게 이야기를 하고 본격적으로 딮-다이브 해보도록 하겠습니다.

Method Dispatch

Method Dispatch is the mechanism that helps to decide which operation should be executed, or more specifically, which method implementation should be used. 참고

Method Dispatch는 어떤 오퍼레이션이 실행될지, 특히 어떤 함수 구현이 사용되어야할 지 결정하는 것을 돕는 메커니즘 입니다.

‘읭? 무슨 말이지…’

Dispatch 의 사전적인 의미를 먼저 알아봅시다!

dispatch: [동사] (특히 특별한 목적을 위해) 보내다[파견하다]

즉, Method Dispatch란 실행 될 Method를 전달하는 타이밍. ‘어느 타이밍에 실행될 함수를 확정 짓고 전달하는 지’에 대한 이야기입니다.

‘그건 알아서 되는거 아니야? 이걸 통해서 어떤 장점이 있는데?’

라는 질문이 드시는 분들도 있으실 것이라 생각되는데요. 이러한 Method 전달 타이밍은 알아서 결정이 되기도 하지만, 개발자가 직접 결정 할 수도 있습니다. 그리고 이러한 결정들은 ‘runtime performance’ 에 직접 영향을 미치게 됩니다.

Method Dispatch Type

이러한 Method Dispatch의 방식은 2가지가 있습니다. Dynamic Dispatch와 Static Dispatch로 말이죠.

Dynamic은 말 그대로 동적이고, Static은 정적인 방식입니다. 그렇다면 이 둘은 어떻게 다를까요? 먼저 특징을 간단히 요약을 해보자면 다음과 같습니다.

  • Dynamic dispatch → 상속, override가 가능
  • Static dispatch → 상속, override가 불가능

조금씩 감이 오실 것이라 생각되는데요. 이제 우리에게 친숙한 Swift 를 통해 조금 더 설명 해보도록 하겠습니다.

Dynamic Dispatch & Static Dispatch

Swift는 기본적으로 Dynamic Dispatch 방식을 사용하고 있습니다.

많은 객체 지향(OOP) 언어들과 마찬가지로 다형성 때문입니다. 우리는 그 덕에 상속받은 클래스는 부모 클래스의 함수를 override 할 수 있고 그대로 차용해서 사용 할 수도 있습니다. 덕분에 다양한 표현력이 가능해지고 확장성있는 프로래밍을 할 수 있게 됩니다. 하지만 얻는 게 있다면 잃는 것도 있기 마련이죠! 그야말로 등가교환의 법칙…!

그렇게 잃은 것은 바로 Runtime Performance입니다.. (너무나 큰 것을 잃어버리고 말았다…ㅠㅠ)

그렇다면 왜 Dynamic Dispatch는 Static Dispatch보다 느릴까요? 비밀은 바로 vtable에 있습니다.

vtable(virtual table)

In Swift, dynamic dispatch defaults to indirect invocation through a vtable. If one attaches the dynamic keyword to the declaration, Swift will emit calls via Objective-C message send instead. In both cases this is slower than a direct function call because it prevents many compiler optimizations in addition to the overhead of performing the indirect call itself. 참고

설명에도 나와있듯이, Swift에서 Dynamic dispatch는 기본적으로 vtable을 통해 간접 호출됩니다. 그리고 이는 직접 호출에 비해 속도가 느리다고 이야기하고 있습니다.

그렇다면 vtable은 뭘까요? vtable에 대하여 나무위키에서는 다음과 같이 설명하고 있습니다.

클래스가 가상 함수(또는 가상 메소드)을 정의할 때마다, 대부분의 컴파일러들은 클래스에 숨겨진 멤버 변수를 추가하는데, 이것은 (가상) 함수들에 대한 포인터들의 배열들(가상 메소드 테이블(VMT 또는 Vtable)라고 불리는)을 가리킨다. 이 포인터들은 실행 기간 도중에 정확한 함수를 가리키게 되는데, 왜냐하면 컴파일 타임에는 베이스 함수가 호출될 것인지 또는 베이스 클래스를 상속한 클래스에 의해서 구현될 지 알려져 있지 않기 때문이다. 참고

잘 이해가 가시나요? 조금 더 쉽게 풀어서 이야기해보겠습니다.

우리는 Swift의 Dynamic Dispatch 를 통해 override상속을 할 수 있고 (실제로 하는 지는 중요하지 않습니다), 이를 위해 method 들과 property 들은 vtable에 배열로 저장됩니다.그리고 호출이 일어나는 시점(Runtime)에 Method Table에서 해당 method 들과 property를 찾고 간접 호출(indirect call) 방식으로 호출하게 됩니다.

이러한 방식은 당연히 ‘직접 호출 방식’ 보다 느리고, 컴파일러의 최적화에도 좋지 않은 영향을 미칩니다.

그렇다면 우리는 당연히 다형성이 필요한 경우를 제외하고는 Dynamic dispatch 를 지양하는 것이 성능에 좋은 영향을 미칠 수 있다고 유추해볼 수 있습니다.

자! 이제 간단한 퀴즈를 통해 우리의 이해도가 얼마나 높아졌는지 파악해 봅시다.

  1. class는 static? / dynamic?
  2. method는 static? / dynamic?
  3. protocol은 static? / dynamic?
  4. class를 extension으로 분리하면 static? / dynamic?

잠깐 생각하는 시간을 갖고 밑으로 내려볼까요?

.

.

.

답:

  1. class는 Reference Type 이며 상속이 가능합니다. 때문에 vtable을 사용합니다. 즉 답은 dynamic
  2. mothod들도 Reference Type이기 때문에 기본적으로 모두 포인터로 주소값을 갖고 있습니다. 즉 답은 dynamic!
  3. 어떤 곳에서 채택하고 있는지를 런타임 중에 확인해야 하기에 Dynamic을 사용하게 됩니다.
  4. @objc 의 힘을 빌리지 않는 경우 오버라이딩이 불가능함. 즉 static!

그럼 이제 한번 오늘의 주제인 Reducing Dynamic Dispatch 방식을 통해 퍼포먼스를 높히는 방법을 배워보겠습니다!

Reducing Dynamic Dispatch

애플에서는 다음의 방식들을 통해서 Dynamic Dispatch를 줄이는 것을 권장하고 있습니다. 참고

final

Use final when you know that a declaration does not need to be overridden

우리는 final 키워드를 통해 더 이상 해당 클래스가 상속 / 상속에 따른 재정의 가 필요 없다고 선언할 수 있습니다. 즉, 컴파일러에게 ‘Dynamic Dispatch가 필요하지 않음’을 명시적으로 설명하고, 이를 통해 컴파일러는 Dynamic Dispatch 를 안전하게 제거 할 수 있습니다.

아래 예시에서 update()Dynamic Dispatch를 통해 호출되므로, 하위 클래스가 override를 통해 재정의 할 수 있습니다. 하지만 final 을 붙혀줌으로써 point / velocity 는 객체의 stored property를 통해 직접 액세스 되고, updatePoint() 직접 호출 하는 방식으로 변하게 됩니다. 당연히 overhead도 줄고 성능은 향상됩니다.

class ParticleModel {
final var point = ( x: 0.0, y: 0.0 )
final var velocity = 100.0

final func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
point = newPoint
velocity = newVelocity
}

func update(newP: (Double, Double), newV: Double) {
updatePoint(newP, newVelocity: newV)
}
}

그리고 클래스 자체에 final 을 붙힘으로 class 전체에 적용하는 방식도 가능합니다. 이 때에 클래스에 구현된 모든 property들과 method들은 직접 호출 방식으로 변경되며, override가 불가능해 집니다.

final class ParticleModel {
var point = ( x: 0.0, y: 0.0 )
var velocity = 100.0
// ...
}

private

Infer final on declarations referenced in one file by applying the private keyword.

두번째 방식은 접근 제어자를 통한 방식입니다. private은 keyword를 통해 참조할 수 있는 곳을 현재 파일로 제한할 수 있습니다. 따라서 컴파일러는 private 키워드가 참조될 수 있는 곳에서 잠재적으로 override 가 될 수 있는지 없는지를 판단합니다. 그리고 override 하는 곳이 없다면 컴파일러는 final 키워드를 자동으로 추론하고 메서드 및 속성 액세스에 대한 간접 호출을 제거할 수 있습니다.

class ParticleModel {
private var point = ( x: 0.0, y: 0.0 )
private var velocity = 100.0

private func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
point = newPoint
velocity = newVelocity
}

func update(newP: (Double, Double), newV: Double) {
updatePoint(newP, newVelocity: newV)
}
}

이전 예시와 마찬가지로 위의 예시에서는 update() 를 제외하고는 직접 호출 방식을 취하고 있으며, 아래 예시에서는 class 앞에 private을 선언함으로 class전체에 적용하는 형식을 보여주고 있습니다.

private class ParticleModel {
var point = ( x: 0.0, y: 0.0 )
var velocity = 100.0
// ...
}

WMO(Whole Module Optimization)

Use Whole Module Optimization to infer final on internal declarations.

Swift compiler는 기본적으로 모듈별 컴파일을 합니다. 때문에 internal access level(default access level)은 정의된 모듈 내에서만 접근이 가능하게 됩니다. 그리고 internal access level에 대해서 서로 다른 파일에서 override 되었는지 확인이 불가능합니다.

하지만 WMO를 사용하게 되면 모든 모듈을 한번에 compile하게 되고, internal level 에 대해서 override가 되는지 추론을 할 수있게 되고 그렇지 않은 경우 내부적으로 final을 붙히게 됩니다.

public class ParticleModel {
var point = ( x: 0.0, y: 0.0 )
var velocity = 100.0

func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
point = newPoint
velocity = newVelocity
}

public func update(newP: (Double, Double), newV: Double) {
updatePoint(newP, newVelocity: newV)
}
}

var p = ParticleModel()
for i in stride(from: 0.0, through: times, by: 1.0) {
p.update((i * sin(i), i), newV:i*1000)
}

위의 예시는 public 접근 제어자가 붙은 것도 확인이 가능한데, 이때 whole module Optimization 을 키게 된다면 point, velocity, updatePoint() 에 대해서 자동으로 final을 추론하게 됩니다.

한가지 다행인 것은 이 Xcode 8 부터 Whole Module Optimization은 release 할 때 켜져 있다는 것입니다. 신경 쓸 부분이 줄었다 🙂

결론

우리가 막연히 듣던 ‘final 을 붙혀라’ ‘접근 제어자를 붙혀라’에 대하여 오늘은 조-금 더 딮하게 이야기 해 보았습니다. 이제 우리는 단순히 팀 동료들에게 명시적으로 전달하기 위함을 넘어, 앱의 성능을 위함을 이해하고 코드를 작성할 수 있게 되었습니다.

어떠셨나요? 저는 종종 단순하게 보이던 것들이 앱 성능에까지 영향을 미침을 알게 되었을 때 너무 재미 있더라구요! 여러분도 이 글을 읽으시며 저와 같은 재미를 느끼셨다면 좋겠네요 :)

앞으로도 종종 재미난 개발 이야기로 찾아 뵙겠습니다~!

긴 글 읽어주셔서 감사드립니다 👨‍💻

⏰ 딜라이트룸에서 알라미와 함께 아침을 바꿀 분들을 모십니다 🙌

👉 딜라이트룸에 어떤 포지션이 있는지 궁금해요(+ 입사 축하금 100만원💸)

👉 딜라이트룸 미디엄 팔로우하기 (카테고리 섹션 오른쪽의 ‘Follow’ 버튼 클릭)

👉 딜라이트룸 일상을 엿볼 수 있는 인스타그램 구경하기

--

--