정수 배열에서 최솟값을 찾는 함수를 작성했습니다. 이제 실수 배열에서도 같은 일을 해야 합니다. 로직은 완전히 동일한데, 타입만 다릅니다. 함수를 복사해서 타입만 바꿀 건가요? 제네릭 프로그래밍Generic Programming은 이 질문에서 출발합니다. 이번 글에서는 제네릭 프로그래밍이라는 패러다임 자체를 살펴봅니다. 알고리즘을 타입으로부터 분리하면서도 타입 안전성을 잃지 않으려면 어떤 원리가 필요한지, 그리고 C와 러스트가 이 원리를 각각 어떻게 실현하는지를 비교합니다.
제네릭이 없는 세상
배열에서 최솟값을 찾는 알고리즘을 생각해 보자. 의사 코드로 쓰면 이렇다.
이 알고리즘은 원소의 타입이 무엇이든 성립한다. "두 원소를 비교할 수 있다"는 성질만 있으면 된다. 그런데 대부분의 프로그래밍 언어에서 함수를 작성하려면 매개변수의 타입을 명시해야 한다. 타입이 달라지면 함수를 복제해야 하고, 복제된 함수들은 로직이 동일함에도 독립적으로 유지보수해야 한다.
제네릭 프로그래밍이 등장하기 전, 프로그래머들은 이 문제를 여러 방식으로 우회했다.
타입 정보 지우기
C에서 흔히 쓰이는 방법은 void *로 타입을 지우는 것이다.
void *find_min(void *base, size_t n, size_t size,
int (*cmp)(const void *, const void *)) {
char *min = base;
for (size_t i = 1; i < n; i++) {
char *elem = (char *)base + i * size;
if (cmp(elem, min) < 0)
min = elem;
}
return min;
}
이 함수는 어떤 타입이든 받을 수 있지만, 대가가 크다. 컴파일러는 void *를 통해 잘못된 타입이 전달되는 것을 감지할 수 없다. 비교 함수 cmp도 호출하는 쪽이 직접 제공해야 하므로, 비교 함수와 실제 데이터 타입이 일치하도록 하는 것은 전적으로 프로그래머의 일이다. 타입 안전성이 사라진 것이다.
코드를 복사하기
타입 안전성을 유지하려면 타입마다 함수를 만들어야 한다.
int min_int(int *arr, size_t n) { /* ... */ }
double min_dbl(double *arr, size_t n) { /* ... */ }
long min_lng(long *arr, size_t n) { /* ... */ }
로직은 완전히 같은데 이름과 타입만 다르다. 알고리즘에 버그가 발견되면 모든 복사본을 수정해야 하고, 하나라도 누락하면 동기화가 깨진다. 이것이 제네릭 프로그래밍이 해결하려는 본질적인 문제다. 알고리즘의 로직과 그것이 작용하는 타입을 어떻게 분리할 것인가?
제네릭 프로그래밍이란
제네릭 프로그래밍은 Alexander Stepanov와 David Musser가 1988년 ISSAC 학회에서 제안한 프로그래밍 패러다임이다(Musser and Stepanov, 1989). 핵심 아이디어는 이렇게 요약된다.
알고리즘을 가능한 한 일반적으로generic 작성하되, 알고리즘이 요구하는 타입의 성질을 명시적으로 선언한다.
여기서 "타입의 성질"이란, 알고리즘이 타입에 대해 가정하는 연산과 의미론1의 집합이다. Stepanov는 이를 개념concept이라 불렀다. 앞서 본 최솟값 알고리즘을 다시 보면, 이 알고리즘이 타입 에 대해 요구하는 개념은 하나다: "두 원소를 비교하여 순서를 결정할 수 있어야 한다." 이것이 전순서total order 또는 더 느슨하게 부분순서partial order 개념이다.
제네릭 프로그래밍의 핵심 원리를 정리하면 세 가지다.
- 타입 매개변수화type parameterization: 알고리즘이나 자료구조의 타입을 고정하지 않고, 매개변수로 추상화한다. 함수에서 값을 매개변수로 받듯, 타입도 매개변수로 받는다.
- 타입 제약type constraint: 타입 매개변수에 아무 제약이 없으면 그 타입에 대해 할 수 있는 일이 거의 없다. 제네릭 프로그래밍에서는 타입이 갖춰야 할 연산(비교, 복제, 산술 등)을 명시적으로 선언한다. 이 선언이 곧 개념이다.
- 타입 안전성 보존type safety preservation.
void *와 달리, 제네릭 프로그래밍에서는 컴파일러가 타입 제약의 충족 여부를 검사한다. 제약을 만족하지 않는 타입으로 알고리즘을 호출하면 컴파일 오류가 발생한다. 타입 안전성을 희생하지 않고도 추상화를 달성하는 것이다.
이 세 원리는 언어마다 구체적인 문법과 메커니즘이 다르지만, 제네릭 프로그래밍을 지원하는 모든 언어에 공통적으로 존재한다.
개념: 타입에 대한 요구 사항
제네릭 프로그래밍에서 가장 중요한 추상화 단위는 개념이다. 개념은 타입이 만족해야 할 연산의 집합과 그 연산들이 지켜야 할 의미론적 성질로 구성된다(Stepanov and McJones, 2009).
예를 들어 "순서를 비교할 수 있는 타입"이라는 개념을 정의한다면, 이 개념은 다음을 요구한다.
- 연산: 두 값 , 에 대해 가 정의되어야 한다.
- 의미론적 성질(공리): 비교 연산은 반사성(), 반대칭성(), 추이성()을 만족해야 한다.
개념이 중요한 이유는, 알고리즘의 정확성이 타입의 구체적인 구현이 아니라 개념이 보장하는 성질에 의존하기 때문이다. 정렬 알고리즘은 "원소가 int인지 string인지"에 의존하지 않는다. "원소가 순서 비교 가능하다"는 개념에만 의존한다. 이 분리가 알고리즘의 재사용성을 만드는 근간이다.
개념의 언어적 표현
각 언어는 개념을 서로 다른 방식으로 표현한다.
러스트에서는 트레이트(trait)가 개념의 역할을 한다. 트레이트는 타입이 구현해야 할 메서드 시그니처의 집합이며, 트레이트 바운드(trait bound)로 제네릭 타입 매개변수에 제약을 건다.
// PartialOrd 트레이트가 "순서 비교 가능" 개념을 표현한다
fn min<T: PartialOrd>(a: T, b: T) -> T {
if a <= b { a } else { b }
}
T: PartialOrd는 "타입 T는 PartialOrd 트레이트를 구현해야 한다"는 제약이다. PartialOrd를 구현하지 않는 타입으로 min을 호출하면 컴파일 오류가 발생한다.
C에서는 언어 수준의 개념 표현이 없다. 대신 C11의 _Generic 선택 표현식이 타입에 따라 서로 다른 함수를 선택하는 메커니즘을 제공한다. 이것은 개념을 명시적으로 선언하는 것이 아니라, 지원하는 타입을 직접 열거하는 방식이다.
#define min(a, b) _Generic((a), \
int: min_int, \
double: min_dbl, \
long: min_lng \
)(a, b)
C++20에서는 concept 키워드2로 개념을 직접 정의할 수 있게 되었다. 이는 Stepanov의 원래 아이디어가 언어 문법으로 공식 편입된 것이다.
구현 전략: 코드는 언제 만들어지는가
제네릭 프로그래밍의 원리는 하나지만, 이를 실현하는 컴파일러의 전략은 언어마다 크게 다르다. 핵심적인 차이는 "제네릭 코드가 구체 타입에 바인딩되는 시점"이다.
단형화: 컴파일 타임 코드 복제
러스트는 단형화monomorphization3를 사용한다. 컴파일러가 제네릭 함수가 사용된 구체 타입 조합을 모두 파악하고, 각 조합마다 전용 함수를 생성한다.
fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
a + b
}
let x = add(1_i32, 2_i32); // add_i32 생성
let y = add(1.0_f64, 2.0_f64); // add_f64 생성
컴파일러가 내부적으로 생성하는 코드는 개념적으로 이렇다.
fn add_i32(a: i32, b: i32) -> i32 { a + b }
fn add_f64(a: f64, b: f64) -> f64 { a + b }
단형화의 장점은 런타임 비용이 전혀 없다는 것이다. 생성된 코드는 제네릭이 아닌 코드를 직접 작성한 것과 동일한 성능을 보인다. 함수 호출이 직접 호출로 해석되므로 인라이닝4까지 가능하다. 이를 제로 비용 추상화zero-cost abstraction라 부른다.
대가는 바이너리 크기와 컴파일 시간의 증가다. 사용된 타입 조합이 개이면 함수도 개 생성된다. 이를 단형화 팽창monomorphization bloat이라 한다.
타입 소거: 런타임 간접 호출
단형화의 반대편에는 타입 소거type erasure가 있다. 자바의 제네릭이 대표적이다. 컴파일러는 타입 매개변수를 Object로 치환하고, 필요한 곳에 캐스팅 코드를 삽입한다. 바이너리에는 제네릭 함수가 단 하나만 존재한다. 바이너리 크기는 작지만, 런타임에 타입 정보가 사라지고 간접 호출 비용이 발생한다.
러스틑에서도 dyn Trait5을 통해 타입 소거 방식의 동적 디스패치dynamic dispatch를 선택할 수 있다. 단형화와 타입 소거는 양자택일이 아니라, 상황에 따라 선택하는 트레이드오프다.
| 단형화 | 타입 소거 | |
|---|---|---|
| 코드 생성 시점 | 컴파일 타임 | — (하나의 코드) |
| 런타임 비용 | 없음 (직접 호출) | 간접 호출 (vtable) |
| 바이너리 크기 | 타입 조합에 비례하여 증가 | 일정 |
| 타입 정보 | 런타임에 보존 | 런타임에 소거 |
전처리기 선택: C의 독특한 위치
C의 _Generic은 단형화와도, 타입 소거와도 다른 제3의 전략이다. _Generic은 새로운 코드를 생성하지 않는다. 이미 존재하는 함수 중에서 타입에 맞는 것을 선택할 뿐이다. 코드 생성은 전처리기 매크로(#define)나 프로그래머의 수작업이 담당한다.
이 스펙트럼 위에서 C는 가장 보수적인 위치에 있다. 컴파일러가 코드를 만들어주지 않으므로, 프로그래머가 타입별 함수를 직접 작성해야 한다는 부담이 있다. 하지만 그 대신 동작이 투명하고 예측 가능하다.
C와 Rust의 비교
같은 문제를 C와 Rust로 풀어 보면, 두 언어가 제네릭 프로그래밍의 원리를 얼마나 다르게 실현하는지 선명하게 드러난다.
타입 매개변수화
러스트는 언어 수준에서 타입 매개변수를 지원한다. <T>를 선언하면 컴파일러가 T를 추적하고 단형화한다.
fn double<T: std::ops::Add<Output = T> + Copy>(x: T) -> T {
x + x
}
C에는 타입 매개변수가 없다. _Generic이 타입별 분기를 제공하지만, 분기 대상인 함수는 프로그래머가 직접 작성해야 한다.
int double_int(int x) { return x + x; }
double double_dbl(double x) { return x + x; }
#define double(x) _Generic((x), \
int: double_int, \
double: double_dbl \
)(x)
타입 제약
Rust의 트레이트 바운드는 타입 제약을 선언적으로 표현한다. T: PartialOrd + Clone이라고 쓰면, 컴파일러가 이 제약의 충족 여부를 검증한다. 제약을 만족하지 않는 타입을 넣으면 명확한 오류 메시지가 나온다.
C의 _Generic에서 타입 제약은 열거적이다. 지원하는 타입을 연관 리스트에 직접 나열한다. 나열되지 않은 타입이 들어오면 default 분기로 빠지거나, default가 없으면 컴파일 오류가 발생한다. 새로운 타입을 지원하려면 매크로 자체를 수정해야 한다.
코드 생성
Rust는 컴파일러가 단형화를 통해 타입별 코드를 자동 생성한다. 프로그래머는 하나의 제네릭 함수만 작성하면 된다.
C에서는 _Generic이 코드를 생성하지 않으므로, 타입별 함수를 프로그래머가 수동으로 작성해야 한다. 이 수동 작업을 매크로로 자동화할 수 있지만, 그것은 전처리기의 텍스트 치환이지 언어 수준의 제네릭이 아니다.
| 원리 | Rust | C (_Generic) |
|---|---|---|
| 타입 매개변수화 | <T> 문법으로 언어가 지원 | 없음. 매크로로 우회 |
| 타입 제약 | 트레이트 바운드 (선언적) | 연관 리스트 (열거적) |
| 코드 생성 | 단형화 (컴파일러 자동) | 없음 (프로그래머 수동) |
| 런타임 비용 | 제로 (직접 호출) | 제로 (컴파일 타임 선택) |
| 확장성 | 새 타입은 트레이트만 구현하면 됨 | 새 타입마다 매크로 수정 필요 |
두 언어 모두 런타임 비용이 없다는 점은 같지만, 그 이유는 다르다. 러스트는 타입별로 전용 코드를 생성했기 때문에 비용이 없고, C는 컴파일 타임에 이미 선택이 끝났기 때문에 비용이 없다.
제네릭 프로그래밍의 가치
제네릭 프로그래밍이 추구하는 것은 단순한 코드 절약이 아니다. Stepanov가 반복적으로 강조한 것은, 알고리즘이 가능한 한 가장 약한 제약 아래에서 작동해야 한다는 원칙이다(Stepanov and McJones, 2009). 정렬 알고리즘이 "정수 배열"에서만 작동하도록 작성되면, 그 알고리즘은 불필요하게 좁다. "순서를 비교할 수 있는 원소의 시퀀스"라는 제약만으로 충분하다면, 그것이 올바른 추상화 수준이다.
이 원칙이 지켜지면 알고리즘은 자연스럽게 재사용 가능해지고, 라이브러리는 조합 가능해진다. C++의 STL, 러스트의 표준 라이브러리 이터레이터 체인, 그리고 자바의 컬렉션 프레임워크가 모두 이 원칙 위에 서 있다.
C의 _Generic은 이 스펙트럼에서 가장 보수적인 위치에 있지만, 함수 오버로딩조차 없는 C에 타입 기반 분기를 표준적으로 제공했다는 점에서 의미가 있다. 언어가 제네릭을 어디까지 지원하느냐는 다르지만, "알고리즘을 타입으로부터 분리한다"는 근본 아이디어는 동일하다.
출처
- Musser, D. R. and Stepanov, A. A. (1989) 'Generic Programming', in Gianni, P. (ed.) Symbolic and Algebraic Computation: International Symposium ISSAC '88. Lecture Notes in Computer Science, vol. 358. Berlin: Springer-Verlag, pp. 13–25.
- Stepanov, A. A. and McJones, P. (2009) Elements of Programming. Boston: Addison-Wesley.
- ISO/IEC 9899:2011 (C11 표준), §6.5.1.1 Generic selection.
- The Rust Programming Language (n.d.) Generic Data Types. Available at: https://doc.rust-lang.org/book/ch10-01-syntax.html (Accessed: 16 April 2026).
Footnotes
-
의미론semantics이란 연산이 수행하는 동작의 의미와 보장하는 성질을 말한다. 예를 들어 비교 연산의 의미론은 추이성, 반사성 같은 수학적 성질을 포함한다. 문법syntax은 연산을 수행하기 위한 작성 방식, 의미론은 연산의 의미다. ↩
-
C++20의
concept은template매개변수에 대한 명명된 제약 조건이다.concept Sortable = requires(T a, T b) { a < b; };처럼 연산 요구 사항을 직접 선언할 수 있으며, Stepanov가 STL 설계 시 구상했던 개념을 30년 만에 언어 수준으로 공식화한 것이다. ↩ -
단형화monomorphization는 제네릭(다형적) 코드를 구체 타입별 단형monomorphic 코드로 변환하는 컴파일 기법이다. "mono(하나) + morph(형태)"에서 유래했다. C++ 템플릿 인스턴스화도 본질적으로 같은 메커니즘이다. ↩
-
인라이닝inlining이란 함수 호출을 함수 본문의 코드로 대체하는 컴파일러 최적화 기법이다. 호출 오버헤드가 제거되고, 이후 최적화(상수 전파, 데드 코드 제거 등)의 기회가 열린다. ↩
-
dyn Trait은 Rust에서 트레이트 객체를 표현하는 문법이다. 데이터 포인터와 vtable 포인터로 구성된 팻 포인터(fat pointer)로, 런타임에 vtable을 통해 호출할 메서드를 결정한다. ↩