[Swift] 성능 개선 3— Generic
제네릭 타입 변수들이 저장되고 복사되는 방식과 메서드 디스패치가 제네릭 타입과 어떻게 작동하는지 알아보겠습니다.
[Swift] 성능 개선 3— Generic
제네릭 타입의 저장, 복사, 메서드 디스패치 방식과 그 최적화가 성능에 미치는 영향을 알아보겠습니다.
이전글:
이전 글에서는 프로토콜 값이 어떻게 Existential Container 를 통해 저장되고 관리되는지 살펴보고, 프로토콜 타입 사용 시 성능을 최적화하는 방식에 대해 알아보았습니다.

이전에 설명한 것처럼, 우리의 앱에는 drawACopy라는 함수가 필요했습니다. 이 함수는 프로토콜 타입 매개변수(ex — Point, Line)을 받도록 설계되었지만, 실제로는 항상 구체적인 타입에 대해 사용되었습니다. 이때 제네릭을 사용하면 좀 더 유연하게 만들 수 있습니다.

프로토콜과 비교했을 때, 제네릭 코드는 정적인 형태의 다형성, 파라메트릭 폴리모피즘(parametric polymorphism)을 지원합니다. 이는 호출 지점마다 특정 타입을 사용하게 되는 방식으로, 매개변수의 타입이 고정됩니다.
예를 들어, 제네릭 매개변수 T를 받아들이는 foo라는 함수가 있다고 가정해봅시다.

foo 함수는 Drawable 프로토콜로 제한된 T 타입을 매개변수로 받습니다. 이 T 매개변수는 bar라는 다른 제네릭 함수로 전달됩니다. Point를 foo 함수에 전달하면, Swift는 T를 Point로 바인딩하고, 그 바인딩된 타입은 bar 함수에 전달됩니다.

이렇게 각 호출 지점에서 사용된 타입에 따라 제네릭 타입이 바인딩되므로, 정적 다형성이 발생합니다.
Implementation of Generic Methods
제네릭 메서드는 프로토콜 타입을 사용하는 것과 유사하지만, 중요한 차이점은 호출 지점마다 구체적인 타입을 사용한다는 점입니다. 이 때문에 existential container를 필요로 하지 않습니다. 대신, 제네릭 메서드는 VWT와 PWT를 활용하여, 메서드 실행에 필요한 적절한 타입에 맞는 동작을 결정합니다.
예를 들어, drawACopy라는 제네릭 함수가 있고, 여기서 Point 타입을 전달한다고 가정해보겠습니다. Swift에서 제네릭 함수는 매개변수의 타입이 호출 시점에서 결정되기 때문에, 존재론적 컨테이너를 사용하지 않고도 동작할 수 있습니다.

그 대신, Swift는 해당 타입에 맞는 VWT를 전달하여 메모리 할당 및 복사를 관리합니다. VWT는 주로 값 타입이 어떻게 다뤄져야 할지를 정의하는 메타데이터를 포함하고 있습니다.

예를 들어, Point가 전달되고 매개변수의 local 변수를 생성할 때, Swift는 이 타입에 맞는 VWT를 사용하여 필요한 힙 메모리를 할당하고, 데이터 복사 작업을 수행합니다.

또한, 매개변수에서 메서드 호출 시에는PWT를 이용하여, 전달된 타입에 맞는 draw 메서드를 찾아 실행합니다. PWT는 프로토콜을 준수하는 타입이 제공하는 메서드 구현을 추적하고, 해당 메서드의 주소를 제공하는 역할을 합니다.

제네릭 타입은 존재론적 컨테이너를 사용하지 않기 때문에, Swift는 이들 타입에 대해 스택에 값을 직접 할당합니다. 예를 들어, Point와 같은 작은 값은 valueBuffer에 직접 저장됩니다. 이 값 버퍼는 일반적으로 3개의 word 크기를 가집니다. 따라서 작은 값들은 스택에 효율적으로 저장될 수 있습니다.

하지만 Line과 같은 큰 값은 스택에 직접 저장되기에는 너무 크기 때문에, 힙에 저장됩니다. 이 경우, 힙에 할당된 메모리의 포인터는 local existential container에 저장되며, 이를 통해 해당 값에 접근하게 됩니다.

Specialization of generics
Static Polymorphism and Generic Optimization
“이게 더 빠른가요? 더 나은 방법인가요? 그냥 프로토콜 타입을 사용할 수 없나요?“라는 질문이 나올 수 있습니다. 사실, 제네릭을 사용하는 방식은 정적 형태의 다형성(static polymorphism)을 활용하는데, 이를 통해 제네릭 최적화(specialization of generics)라는 강력한 컴파일러 최적화가 가능해집니다. 이를 좀 더 깊이 살펴보겠습니다.
drawACopy라는 제네릭 함수를 예로 들어보겠습니다. 이 함수가 Point를 전달받아 호출된다고 가정했을 때, 제네릭은 정적 다형성을 활용하게 됩니다. 즉, 호출부에서 하나의 타입만 사용되며, Swift는 이 타입을 기반으로 함수 내 제네릭 파라미터를 대체하고, 해당 타입에 맞는 특화된 코드를 생성합니다.
예를 들어, drawACopy 함수가 Point 타입을 받으면, Swift는 Point에 특화된 버전을 생성하게 되며, 이 함수의 내부 코드는 Point 타입에 맞춰 최적화된 코드로 구성됩니다. 이 과정은 컴파일러가 최적화를 수행할 수 있게 만들어, 매우 빠르게 실행될 수 있습니다.

Code Size and Performance
“그럼 코드 크기가 증가하는 거 아닌가요?“라는 의문이 들 수 있습니다. 맞습니다, 정적 타입에 따라 제네릭 함수의 특화 버전이 생성되면 코드 크기가 커질 수 있습니다. 그러나 정적 타입 정보를 활용한 컴파일러 최적화 덕분에 코드 크기가 늘어나는 것만큼 성능이 떨어지지는 않습니다. 오히려 코드 크기가 줄어들 가능성도 있습니다.
왜냐하면, 컴파일러는 특화된 메서드를 인라인으로 삽입할 수 있으며, 이는 더 많은 컨텍스트를 제공하고, 이를 바탕으로 더 공격적인 최적화를 가능하게 합니다. 예를 들어, Point에 특화된 drawACopy 메서드는 인라인으로 삽입되어, 사실상 한 줄의 코드로 축약될 수 있습니다.


이후, 이 코드는 draw 메서드 호출로 축소되며, 불필요한 메서드 호출을 제거할 수 있게 됩니다. 이와 같은 방식으로, 컴파일러는 더 이상 사용되지 않는 코드나 메서드를 제거하고, Line 타입에 대해서도 유사한 최적화를 수행할 수 있습니다.

따라서 정적 다형성이 코드 크기를 무조건 증가시키지는 않으며, 오히려 최적화의 기회를 제공합니다.
Whole Module Optimization
이제 “언제 이런 최적화가 발생할까요?“라는 질문을 할 수 있습니다. 이를 설명하기 위해 간단한 예제를 살펴보겠습니다. Point 타입을 정의하고, 그 타입으로 지역 변수를 생성하여 초기화하고, 이를 drawACopy 함수에 전달하는 예시입니다.

Swift는 이 지역 변수를 확인하고, 이를 초기화하는 과정을 거슬러 올라가면서 해당 타입이 Point임을 파악할 수 있습니다. 이를 통해 타입을 유추할 수 있게 됩니다. 또한, Swift는 제네릭 함수와 타입 정의가 모두 한 파일에 정의되어 있을 때 이 과정을 손쉽게 처리할 수 있습니다.

그렇다면, 이 최적화가 어떻게 이루어지는지 좀 더 구체적으로 살펴보겠습니다. 만약 Point의 정의가 다른 파일에 존재한다고 가정해보겠습니다. 이 경우, 별도로 컴파일되기 때문에, UsePoint 파일에서 Point의 정의를 사용할 수 없습니다. 즉, 두 파일이 서로 독립적으로 컴파일되면, 컴파일러는 Point 타입에 대한 정보를 제대로 이용할 수 없습니다.
하지만, 모듈 전체 최적화(Whole Module Optimization)를 활성화하면, 컴파일러는 두 파일을 하나의 단위로 컴파일합니다. 이를 통해 컴파일러는 Point의 정의를 확인하고, 더 나은 최적화를 수행할 수 있게 됩니다. 이 방식은 Xcode 8부터 기본으로 활성화되어 있으며, 최적화 기회를 크게 향상시킵니다.
Generic Stored Properties
이제 프로그램으로 돌아가겠습니다. 우리 프로그램에는 Drawable 프로토콜 타입의 쌍(pair)가 존재했습니다. 이 쌍을 어떻게 사용했는지 다시 한 번 주목해 보겠습니다.

쌍을 생성할 때마다, 우리는 사실상 예를 들어, Line의 쌍이나 Point의 쌍처럼 동일한 타입의 쌍을 생성하려 했습니다. 하지만 이 과정에서 Line 쌍을 생성할 때, 두 번의 힙 할당이 발생하는 비용이 있었습니다.

프로그램을 다시 살펴보니, 제네릭 타입을 사용할 수 있다는 점을 알게 되었습니다. 이를 통해, 우리는 쌍을 제네릭 타입으로 정의할 수 있고, 그 제네릭 타입의 첫 번째와 두 번째 프로퍼티가 동일한 타입을 가지도록 할 수 있습니다. 이렇게 되면, 컴파일러는 항상 동일한 타입의 쌍만 생성하도록 강제할 수 있습니다.

이와 같은 방식으로 제네릭 타입을 사용하게 되면, 프로그램 내에서 Point를 Line의 쌍에 저장하려는 시도는 허용되지 않게 됩니다. 이는 우리가 원하는 동작이지만, 성능 면에서 더 나아졌는지, 아니면 나빠졌는지를 살펴볼 필요가 있습니다.
여기서, 이번에는 저장된 프로퍼티가 제네릭 타입인 쌍(pair)을 사용했다고 가정해보겠습니다. 이전에 언급했듯이, 타입은 런타임에 변경될 수 없습니다. 이는 생성된 코드에서 Swift가 enclosing type(포함 타입)의 내부에 메모리를 인라인으로 할당할 수 있음을 의미합니다. 예를 들어, Line의 쌍을 생성할 때, Line의 메모리는 포함하는 쌍 내부에 인라인으로 할당됩니다.

이 방식에서는 추가적인 힙 할당이 필요하지 않게 되며, 메모리 관리가 훨씬 효율적입니다. 다만, 앞서 언급한 것처럼 저장 프로퍼티에 나중에 다른 타입의 값을 저장할 수는 없습니다. 그러나 이는 우리가 원하는 동작입니다. 즉, 제네릭 타입을 사용함으로써 타입 안전성을 유지하면서도 메모리 효율성을 개선할 수 있습니다.
이와 같이, 제네릭을 통해 쌍을 정의함으로써 성능 개선과 타입 안전성을 동시에 달성할 수 있음을 알 수 있습니다. 이는 메모리 할당을 최적화하고, 불필요한 힙 할당을 방지하는 매우 효과적인 방법입니다.
Performance of Generic Code
VWT와 PWT를 사용하여 특화되지 않은 코드가 어떻게 동작하는지 살펴보고, 컴파일러가 제네릭 함수의 타입별 버전을 생성하는 코드 특화를 어떻게 수행하는지 알아보겠습니다.

Specialized Generics — Struct Type
먼저, 구조체를 포함하는 특화된 제네릭 코드의 성능 특성을 살펴보겠습니다. 이 경우, 구조체 타입을 사용하는 것과 동일한 성능 특성을 가집니다. 왜냐하면, 생성된 코드는 마치 이 함수를 구조체를 사용하여 작성한 것처럼 작동하기 때문입니다. 즉, 구조체 타입 값을 복사할 때 힙 할당이 필요하지 않으며, 메모리는 스택에 할당됩니다.
또한, 구조체 타입은 참조를 포함하지 않는 경우 참조 카운팅도 발생하지 않으므로, 메모리 관리가 간단합니다. 이런 점들은 정적 메서드 디스패치(static method dispatch)를 가능하게 해줍니다. 정적 디스패치는 컴파일러 최적화를 촉진시켜 런타임 실행 시간을 줄여줍니다.
Specialized Generics — Class Type
반면, 클래스 타입을 사용하면 클래스와 유사한 성능 특성을 가집니다. 즉, 인스턴스를 생성할 때 힙 할당이 필요하고, 값을 전달할 때 참조 카운팅이 발생합니다. 또한, V-Table을 통한 동적 디스패치(dynamic dispatch)가 이루어지게 되어, 런타임에서 메서드 선택이 이루어집니다. 이는 구조체와 클래스의 성능 차이를 구체적으로 보여줍니다.
UnSpecialized Generics — Small Value
다음으로, 작은 값을 포함하는 특화되지 않은 제네릭 코드를 살펴보겠습니다. 작은 값들은 스택에 할당된 값 버퍼(valueBuffer)에 적합하므로, 지역 변수에 대해 힙 할당이 필요하지 않습니다. 만약 값이 참조를 포함하지 않으면, 참조 카운팅도 발생하지 않습니다. 이러한 점은 성능 상 큰 이점을 제공합니다.
또한, 위트니스 테이블(witness table)을 사용하면, 모든 잠재적인 호출부(call-site)에서 하나의 구현을 공유할 수 있습니다. 이를 통해 제네릭 함수는 한 번의 컴파일로 여러 타입에 대해 효율적으로 실행될 수 있습니다. 이는 타입에 따라 특화된 버전을 생성하는 컴파일러 최적화와 결합되어 런타임 성능을 최적화하는 데 중요한 역할을 합니다.
UnSpecialized Generics— Large Value
특화되지 않은 제네릭 코드에서 큰 값이 사용될 때는, 일반적으로 힙 할당이 필요하게 됩니다. 이는 제네릭 코드가 다양한 타입을 처리할 수 있도록 설계되어 있기 때문인데, 이 상태에서 값이 크거나 참조를 포함하는 경우 힙 메모리 할당이 필연적으로 발생합니다. 이를 통해 간접 저장(indirect storage) 방식이나 Copy-on-Write 기법을 사용하여 힙 할당 문제를 해결할 수 있습니다.
큰 값이 참조를 포함하고 있다면 참조 카운팅이 발생하며, 이를 통해 동적 디스패치(dynamic dispatch)의 장점을 얻을 수 있음. 이는 제네릭 구현을 코드 전반에 걸쳐 공유할 수 있음을 의미합니다.
요약
3편에 걸쳐 Swift 성능 최적화에 대해 알아보았습니다. 결론적으로, 앱 내 엔터티에 적합한 추상화를 선택하는 것이 중요합니다. 동적 런타임 타입 요구사항을 최소화하면 정적 타입 검사를 통해 컴파일 시 프로그램의 정확성을 확인할 수 있습니다. 또한, 컴파일러는 더 많은 정보를 바탕으로 최적화할 수 있어 실행 속도가 빨라집니다.
프로그램 내 엔터티를 구조체(structs)와 열거형(enums) 같은 값 타입으로 표현할 수 있다면, 값 의미론(value semantics)을 얻을 수 있습니다. 이는 의도치 않은 상태 공유를 방지하고 최적화 가능한 코드를 작성할 수 있음을 의미합니다. 만약 클래스를 사용해야 하는 경우, 객체 지향 프레임워크 사용이나 특정 엔터티 필요에 따라 참조 카운팅 비용을 줄이는 여러 기술을 적용할 수 있습니다.
프로그램의 일부를 정적인 형태의 다형성(static polymorphism)으로 표현할 수 있다면, 제네릭 코드와 값 타입을 결합하여 빠르고 최적화된 코드를 작성할 수 있습니다. 이 경우에도 구현을 공유할 수 있습니다. 반면, 동적 다형성이 필요한 경우(예: Drawable 프로토콜 타입 배열), 프로토콜 타입과 값 타입을 결합하여 클래스 사용과 유사한 성능을 내면서도 값 의미론을 유지할 수 있습니다.
프로토콜 타입이나 제네릭 타입 내부에서 큰 값을 복사할 때 힙 할당 문제가 발생한다면, 간접 저장(indirect storage) 방식과 Copy-on-Write 기법을 활용하여 이를 해결할 수 있습니다.
결국, 각각의 장단점을 알고 상황에 맞게 사용하는 것이 가장 중요합니다.
참고:
[embed]Understanding Swift Performance | Notion Introductiongeumgagyeong.notion.site
메타데이터
- post_id
- ff9fd7730abd
- slug
- swift-성능-개선-3-generic-ff9fd7730abd
- url
- https://medium.com/@gageum0/swift-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-3-generic-ff9fd7730abd
- canonical_url
- https://medium.com/@gageum0/swift-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-3-generic-ff9fd7730abd
- author_url
- https://medium.com/@gageum0
- status
- ok
- fetched_at
- 2026-07-21 03:54:55