제네릭 — 파라미터 다형성과 그 구현의 갈림길
타입을 매개변수로 받아 여러 타입에 같은 코드를 재사용하는 제네릭(파라미터 다형성)을 언어 공통 개념으로 정리한다. 구현이 갈리는 지점(단형화 vs 타입 소거), 아무 타입이나 막는 제약(constraints)을 짚고, C++ 템플릿·Go generics·Rust 제네릭이 같은 개념을 어떻게 다르게 구현하는지 대응시킨다.
난이도 중급 · 선행 없음
한 줄 요약
제네릭은 타입을 매개변수로 받아, 여러 타입에 같은 코드를 재사용하는 기법이다. 정식 명칭은 파라미터 다형성(parametric polymorphism). max나 List 같은 걸 타입마다 다시 짜지 않게 해준다.
어떤 문제를 푸는가
두 값 중 큰 걸 반환하는 max를 생각하자. 제네릭이 없으면 타입마다 똑같은 로직을 복붙해야 한다.
1
2
3
int max_int(int a, int b) { return a > b ? a : b; }
double max_double(double a, double b) { return a > b ? a : b; }
// string용도... 또 복붙?
로직은 완전히 같고 타입만 다르다. 이 중복을 없애는 게 제네릭이다.
1
2
template<typename T>
T max(T a, T b) { return a > b ? a : b; } // 모든 타입에 하나로
제네릭 ≠ 상속 다형성. 상속(subtyping)은 “여러 타입을 부모 하나로 다룸(virtual/interface)”, 제네릭은 “하나의 코드를 여러 타입에 찍어냄”이다. 둘은 다른 축이다.
구현의 갈림길 — 단형화 vs 타입 소거
여기가 언어별 성능·특성이 갈리는 핵심이다. “제네릭 코드를 실제 기계어로 어떻게 바꾸느냐”에서 두 갈래다.
① 단형화(Monomorphization) — 타입마다 코드 복제
컴파일러가 사용된 타입마다 특수화된 코드를 따로 생성한다. max<int>, max<double>이 각각 별도 함수로 찍혀 나온다.
- 장점: 런타임 비용 0. 각 타입에 최적화·인라인되어 손으로 쓴 코드만큼 빠르다.
- 단점: 타입 종류만큼 코드가 불어난다(code bloat). 컴파일이 느려진다.
- 예: C++ 템플릿, Rust 제네릭
② 타입 소거(Type Erasure) / 박싱 — 코드 하나로 공유
컴파일 후 타입 정보를 지우고, 하나의 코드가 여러 타입을 처리한다. 값은 참조/박스로 간접 접근한다.
- 장점: 코드가 작다. 컴파일이 빠르다.
- 단점: 박싱·간접 참조에 런타임 비용이 있다. 런타임에 실제 타입 정보가 제한된다.
- 예: Java 제네릭(소거), Go의 옛
interface{}방식
Go는 그 중간
Go 제네릭(1.18+)은 GC shape stenciling이라는 절충을 쓴다. 메모리 레이아웃(shape)이 같은 타입들끼리는 코드를 공유하고, 다른 shape은 나눠 찍는다. 단형화의 code bloat와 소거의 런타임 비용 사이에서 타협한 방식이다.
| 단형화 | 타입 소거 | |
|---|---|---|
| 코드 크기 | 큼(타입마다 복제) | 작음(공유) |
| 런타임 비용 | 없음 | 박싱·간접 |
| 컴파일 속도 | 느림 | 빠름 |
| 대표 | C++, Rust | Java, (Go는 중간) |
아무 타입이나는 안 된다 — 제약(constraints)
max는 > 비교가 되는 타입에만 의미가 있다. 제네릭에 “이런 능력을 갖춘 타입만”이라는 조건을 거는 게 제약이다. 제약이 없으면 에러가 사용 지점에서 늦게, 난해하게 터진다(옛 C++ 템플릿 에러의 악명).
| 언어 | 제약 방식 |
|---|---|
| C++ | concepts (C++20) — template<std::totally_ordered T> |
| Rust | trait bounds — fn max<T: PartialOrd>(...) |
| Go | 인터페이스 제약 — func Max[T constraints.Ordered](...) |
세 언어 모두 “타입 T는 비교/출력/복제 등 특정 능력을 갖춰야 한다“를 명시하게 한다. 제약을 붙이면 에러가 정의 지점에서 명확히 잡히고, 문서 역할도 한다.
언어별 정리
| 언어 | 도입 | 방식 | 제약 |
|---|---|---|---|
| C++ | 원조(템플릿) | 단형화 | concepts (C++20) |
| Rust | 처음부터 | 단형화 | trait bounds |
| Go | 1.18 (2022) | 중간(shape) | 인터페이스 제약 |
- C++: 템플릿은 제네릭 그 이상(메타프로그래밍까지). → 템플릿
- Go: 오래 제네릭 없이
interface{}로 버티다 1.18에서 도입. Go 철학상 최소한으로 넣었다. → Go 학습 로드맵 - Rust: 제네릭 + 트레이트가 언어의 중심. 단형화라 C++급 성능.
C++ 템플릿을 아는 사람에게 Rust 제네릭은 익숙하다 — 둘 다 단형화다. Go 제네릭은 “훨씬 절제된 버전”으로 느껴질 것이다.
스스로 점검
1. 제네릭(파라미터 다형성)과 상속 다형성(subtyping)의 차이는?
답
상속 다형성은 여러 타입을 부모/인터페이스 하나로 다룬다(virtual·interface, 런타임 디스패치). 제네릭은 하나의 코드를 여러 타입에 찍어낸다(대개 컴파일 타임). 방향이 다른 축이다.
2. 단형화의 장점과 대가는?
답
장점은 런타임 비용 0 — 타입마다 특수화·인라인되어 손으로 쓴 코드만큼 빠르다. 대가는 타입 종류만큼 코드가 불어나는 code bloat와 느려지는 컴파일이다. C++·Rust가 이 방식이다.
3. 제네릭에 제약(constraints)을 거는 이유는?
답
아무 타입이나 허용하면 >·출력 같은 연산이 되는지 보장할 수 없다. 제약을 붙이면 에러가 사용 지점이 아니라 정의 지점에서 명확히 잡히고, “이 타입은 이런 능력이 필요하다”는 문서 역할도 한다. C++ concepts, Rust trait bounds, Go 인터페이스 제약이 각각 이 역할이다.