Skip to content

제네릭 프로그래밍에 대한 단상

왜 제네릭 제네릭하는걸까

Makonea
·2026년 3월 6일·16분

제네릭 프로그래밍(Generic Programming)은 구체적인 효율적인 알고리즘에서 추상화를 수행하여 제네릭 알고리즘을 도출하고,
이를 다양한 데이터 표현과 결합하여 유용한 소프트웨어를 생성하는 아이디어를 중심으로 한다.예를 들어, 유한한 시퀀스(finite sequences)에서 동작하는 제네릭 정렬 알고리즘(generic sorting algorithms)을 정의할 수 있으며,이를 배열(arrays)이나 연결 리스트(linked lists)와 같은 서로 다른 데이터 구조에 적용하여 다양한 정렬 알고리즘을 생성할 수 있다.

논문에서는 데이터(data), 알고리즘적(algorithmic), 구조적(structural), 표현적(representational) 추상화의 네 가지 유형을 다루고 있으며,
이를 활용하여 Ada 라이브러리에서 소프트웨어 컴포넌트를 구축하는 예제를 소개한다.
주요 논점은 제네릭 알고리즘과 그 형식적 명세(formal specification) 및 검증(verification) 방법이며, 이를 퀵소트(quicksort) 알고리즘에서
사용되는 분할 알고리즘(partitioning algorithm)을 예로 들어 설명한다.
논문의 결론은, 제네릭 프로그래밍을 이용한 소프트웨어 컴포넌트 라이브러리가 소프트웨어 생산성 향상에 중요한 이점을 제공할 수 있다는 점을 강조한다.
Alexander A.Stepanov 1988


제네릭 프로그래밍이란 무엇인가?

제네릭 프로그래밍이란 하나의 데이터가 특정 데이터 타입에만 종속되지 않고, 여러 데이터 타입을 가질 수 있는 기술에 중점을 두어

재사용성을 높일 수 있는 프로그램 방식이라고 요약할 수 있다.

그럼 우선 우리가 시작해야할 지점은 무엇인가? 그 부분을 찾아보자.

우리는 왜 제네릭 프로그래밍이 나왔는가에 대해서 시작할 것이다.

제네릭 프로그래밍의 시작은 1980년대 OOP의 부상속에서 차별화에서 시작했다.

1980년대 초 OOP는 '객체', '클래스'를 중심으로 프로그래밍의 대세를 차지하기 시작했다.

하지만 이러한 접근 방식에는 한계가 엄연히 존재했다.

1. 타입에 종속적인 코드: 특정 데이터 타입에만 작동하는 클래스를 작성한다.

예를들어 정수 배열을 정렬하는 함수와, 문자열 배열을 정렬하는 함수는 같은 알고리즘을 공유함에도 불구하고 별개로 작성해야만 한다

2. 재사용성의 제한: OOP는 상속과 다형성을 통해 일부 문제를 해결할 수 있었지만 여전히 코드의 재사용성에는 제약이 있었다.

제네릭 프로그래밍은 이러한 문제를 극복하기 위해 등장했다.

위에 논문의 서문에도 나왔듯

다양한 데이터 표현 방식과 결합할 수 있는 일반적 알고리즘을 얻는데 집중할 수 있다.

제네릭 프로그래밍의 특징은 많지만, 현대의 제네릭 프로그래밍은 다음 3가지를 가진다.

1. 타입 독립성(Type Independence): 특정 데이터 타입에 종속되지 않음

2. 코드 재사용성(Code Reusability) : 동일 알고리즘이 다른 타입에도 적용될 수 있음

3. 효율성(Efficiency) : 런타임 오버헤드 없이 컴파일 타에 최적화된 코드를 생성한다.

적어도 나에게 있어서 STL은 프로그래밍이 가능한 유일한 방식을 나타냅니다.
물론 이것은 대부분의 교과서에서 설명되었고 지금도 그렇게 설명되는 C++ 프로그래밍 방식과는 상당히 다릅니다.

하지만 보세요.
나는 C++로 프로그램을 작성하려고 했던 것이 아니라,
소프트웨어를 다루는 올바른 방법을 찾으려고 했던 것입니다.

나는 오랫동안 내가 말하고 싶은 것을 표현할 수 있는 언어를 찾아왔습니다.
다시 말해, 내가 무엇을 말하고 싶은지는 이미 알고 있습니다.

그것을 C++로도 말할 수 있고,
Ada로도 말할 수 있으며,
Scheme으로도 말할 수 있습니다.

나는 언어에 나 자신을 맞추어 적응합니다.
하지만 내가 말하려는 것의 본질은 언어에 의존하지 않습니다.

지금까지는 C++가 내가 발견한 언어 중에서 그것을 가장 잘 표현할 수 있는 언어입니다.
이 언어가 이상적인 매체는 아니지만,
내가 시도해 본 어떤 언어보다도 더 많은 것을 할 수 있습니다.

그리고 사실 언젠가 제네릭 프로그래밍을 염두에 두고 설계된 언어가 등장하기를 바라고 있습니다.


제네릭 프로그래밍은 1970년대 후반부터 연구하다가, Ada에서 시작해 Scheme을 거쳐 C++에 정착하여

알렉산더 스테파노프는 C++에서

알고리즘은 데이터 구조에 독립적이어야한다는 철학을 실현할 라이브러리 STL을 만든다.

사실 C++의 시작이 C에서 OOP를 적용하기 위해서 시작된 것이라는 걸 생각하면 조금 블랙코미디처럼 느껴지기도 한다.

OOP의 핵심 개념의 캡슐화는 데이터와 행위의 결합인데, STL은 그 행위와 데이터를 분리하는데 힘을 쓰기때문에,

마치 OOP를 위해 태어난 언어에서 OOP와는 다른 철학을 구현하는 핵심 라이브러리가 탄생했다는 것은 참 재밌는 것이다.

(물론 생각에 따라 정반대의 철학은 아니기때문에 충분히 나올 수 있는 개념이긴했다)

실제로 제네릭 프로그래밍이 OOP에 대항해서 나왔다는 사실은 이후에

C++ 표준 템플릿 라이브러리(STL)의 창시자인 알렉산더 스테파노프가 말한다

네, STL은 객체 지향적이지 않습니다. 저는 객체 지향성이라는 것이 거의 인공 지능만큼이나 사기라고 생각합니다.
저는 이 OOP를 하는 사람들로부터 흥미로운 코드 조각을 아직 보지 못했습니다. 어떤 면에서는, 제가 AI에게 불공평하긴 합니다.
저는 MIT AI 연구소 사람들로부터 많은 것을 배웠고, 그들은 정말 근본적인 작업을 해냈습니다.
빌 고스퍼의 Hakmem은 프로그래머가 읽어야 할 최고의 것들 중 하나입니다. AI는 진지한 기반이 없을 수도 있지만,
고스퍼와 스톨먼(Emacs), 모세스(Macsyma), 그리고 서스만(가이 스틸과 함께 Scheme)을 만들어냈습니다.
저는 OOP가 기술적으로 건전하지 않다고 생각합니다. OOP는 단일 유형에서만 달라지는 인터페이스의 관점에서 세상을 분해하려고 시도합니다.
실제 문제를 다루려면 여러 유형에 걸쳐있는 인터페이스 패밀리인 다중 정렬 대수가 필요합니다.
저는 OOP가 철학적으로 건전하지 않다고 생각합니다. OOP는 모든 것이 객체라고 주장합니다.
그것이 사실이라 할지라도 그다지 흥미롭지 않습니다. 모든 것이 객체라고 말하는 것은 아무것도 말하지 않는 것과 같습니다.
저는 OOP가 방법론적으로 잘못되었다고 생각합니다. OOP는 클래스로 시작합니다.
마치 수학자들이 공리부터 시작하는 것과 같습니다. 당신은 공리부터 시작하지 않습니다.
당신은 증명부터 시작합니다. 관련 증명들을 많이 발견했을 때만 공리를 떠올릴 수 있습니다. 당신은 공리로 끝을 맺습니다.
프로그래밍에서도 마찬가지입니다. 흥미로운 알고리즘부터 시작해야 합니다.
알고리즘을 잘 이해했을 때만 알고리즘이 작동하도록 하는 인터페이스를 떠올릴 수 있습니다.
-Alexander A.Stepanov


알렉산더는 OOP를 기술적으로나 철학적으로 극렬하게 비판하는데, 사실 현대 언어들의 OOP 개념을 보면 어느정도 이해가 가는 맥락이다.

왜냐하면 C#과 자바등 현대의 언어들은 모든것을 'object'를 상속받는 식으로 설계되어있기때문에, 생성하는 모든것을 Object(객체)라는 관점에서 보기때문이다.

이렇게 되면 객체의 경계가 모호해지기때문에, 이론상의 OOP와 현실적 구현의 간극이 엄연히 존재하게 되는 것이다.

사실 스테파노프의 OOP 비판은 상속Inheritance 에 국한되어있던 90년대 초반의 OOP에 가깝다. 실제로 OOP는 그 초기에 모습에 변화가 되기도했고

최근에는 명확한 is-a 관계가 아니면 상속을 가급적 피하라는 것이 현대 아키텍처의 정설이다. 설계적 관점을 넘어 로우레벨(Low-level)로 내려가면 그 이유는 더 명확해진다.

OOP의 다형성(Subtype Polymorphism)은 가상 테이블(vtable)을 거치는 런타임 동적 바인딩을 강제한다. 이는 메모리 포인터 추적에 따른 캐시 미스(Cache Miss)를 유발하여 필연적으로 기계어 레벨의 성능 오버헤드를 발생시킨다. 반면, 제네릭의 매개변수적 다형성(Parametric Polymorphism)은 컴파일 타임에 타입을 확정 짓고 최적화된 코드를 생성한다.

결론적으로 OOP는 '런타임의 유연성'을 얻기 위해 하드웨어의 실행 성능을 희생한 패러다임이며, 제네릭은 '컴파일 타임의 타입 고정'을 통해 시스템의 효율성을 극대화한 기술적 대안이라는 점이 스테파노프의 비판에 힘을 실어준다

이 글을 보고 그럼 OOP가 무조건 나쁜 것인가? 라고 볼 사람때문에 부연설명하자면

스테파노프의 OOP의 '타입' 문제에 초점을 맞추지만, 실제로 OOP 프로그래밍은 일반적으로 '관계'에 초점을 두기때문에

뛰어난 프로그래머의 자신의 학문에 대한 자부심으로 인해서 조금 다른 철학을 깍아 내리는 것이 아닐까하는 것도 사실이다.

이제 역사를 알았으니, 이제 어떻게 사용하는지 한번 보자.

현대에서의 제네릭 프로그래밍

제네릭을 구분 짓는데 여러가지가 있다.

기본적으로 여러방식이 나뉘지만 일반적으로 자주 사용되는 분류법으로 어떻게 분리되어있는지만 이야기해보자.

제네릭 구현 방식의 분류

제네릭은 타입 처리 시점에 따라 크게 두 가지로 나뉜다.

정적 제네릭 (Static Generics)

컴파일 타임에 타입이 완전히 결정되고, 타입별로 독립된 코드가 생성된다. 런타임에는 제네릭이라는 개념 자체가 존재하지 않는다. 이미 구체적인 타입의 코드로 변환된 상태다.

구현 전략: 모노모피제이션 (Monomorphization)

C++
template <typename T>
T Add(T a, T b) { return a + b; }

int main()
{
    int sum = Add(3, 5);       // 컴파일러가 Add<int>를 생성
    double d = Add(1.0, 2.0);  // 컴파일러가 Add<double>을 별도 생성
}

컴파일러가 사용된 타입마다 별도의 함수/클래스를 찍어낸다. Add<int>Add<double>은 바이너리에서 완전히 다른 함수다. 런타임 오버헤드가 없고 최적화가 극대화되지만, 사용된 타입 수만큼 코드가 복제되므로 바이너리 크기가 증가할 수 있다.

대표 언어: C++ 템플릿, Rust, Swift

동적 제네릭 (Dynamic Generics)

컴파일 타임에 타입을 체크하지만, 런타임에도 제네릭 구조가 유지된다. 타입별로 코드를 복제하지 않고, 하나의 코드가 여러 타입을 처리한다. 다만 런타임에서 타입 정보를 어떻게 다루느냐에 따라 두 가지 전략으로 갈린다.

(a) 타입 소거 (Type Erasure) — Java

Java
public class Box<T> {
    private T value;
    public void set(T value) { this.value = value; }
    public T get() { return value; }
}
 
Box<Integer> intBox = new Box<>();
intBox.set(10);
int value = intBox.get(); //컴파일 타임에 Integer로 강제됨

컴파일 타임에 타입을 체크한 뒤, 런타임에는 타입 정보를 삭제한다. 위 코드는 컴파일 후 Box<Object>로 변환되고, get() 호출 시 캐스팅이 삽입된다. 바이트코드가 하나만 존재하므로 바이너리 크기에 영향이 없지만, 런타임에 Box<Integer>인지 Box<String>인지 알 수 없다.

(b) 런타임 구체화 (Reification) — C#

C#
public class Box<T>
{
    private T value;
    public void Set(T value) { this.value = value; }
    public T Get() { return value; }
}

var intBox = new Box<int>();
Console.WriteLine(intBox.GetType()); // System.Box`1[System.Int32]

C#은 런타임에도 제네릭 타입 정보가 보존된다. IL에 타입이 그대로 남아있고, JIT 컴파일러가 값 타입int, float)에 대해서는 모노모피제이션처럼 별도 네이티브 코드를 생성하고, 참조 타입string, object)에 대해서는 코드를 공유한다. 타입 소거의 한계가 없으면서 성능 이점도 부분적으로 가져간다.

타입 매개변수에서 컨셉 기반 제네릭으로

제네릭의 기본 단위는 타입 매개변수(T, K, V)다. 모든 제네릭 언어가 이 방식을 공유한다. 차이는 이 타입 매개변수에 얼마나 엄격한 제약을 거느냐에서 갈린다.(왜 T,K,V 인가하면 Type,Key,Value,Element,Result의 앞머리에서 따왔는데, 약어들이 용례로 굳어진 것이다.)

(a) 무제약 타입 매개변수 (Unconstrained)

C++
template <typename T>
class Box
{
    T value;
public:
    void set(T v) { value = v; }
    T get() { return value; }
};

T에 어떤 타입이든 들어올 수 있다. 컴파일러는 인스턴스화 시점에서야 T가 해당 연산을 지원하는지 확인한다. 지원하지 않으면 그때 에러가 발생하는데, 이 에러 메시지가 템플릿 인스턴스화 체인을 따라 올라가면서 극도로 길어지는 것이 무제약 방식의 고질적 문제다.

대표: C++ 템플릿(C++20 이전), Java 제네릭, C# 제네릭

(b) 제약 기반 타입 매개변수 (Constrained)

C++
template <typename T>
concept Addable = requires(T a, T b) { a + b; };

template <Addable T>
T Add(T a, T b) { return a + b; }

타입 매개변수에 "이 타입은 최소한 이 연산을 지원해야 한다"는 제약을 명시한다. Addable을 만족하지 않는 타입이 들어오면 인스턴스화 전에 명확한 에러가 발생한다. 무제약 방식의 에러 메시지 지옥을 구조적으로 해결한 것이다.

대표: C++20 Concepts, Rust Trait Bounds, Swift Protocol Generics, C# where T : 제약 참고로, C#의 where T : IComparable<T> 같은 제네릭 제약도 이 범주에 속한다. 컨셉만큼 표현력이 풍부하지는 않지만, 인터페이스 기반으로 타입 매개변수를 제약한다는 점에서 같은 계열이다.

이후 제네릭 프로그래밍은 템플릿 메타 프로그래밍으로의 응용이 되는데

C++
template <int N>
struct Factorial {
    static constexpr int value = N * Factorial<N - 1>::value;
};
 
template <>
struct Factorial<0> {
    static constexpr int value = 1;
};
 
int main() {
    constexpr int result = Factorial<5>::value; //120
}

제네릭 프로그래밍이 타입을 일반화하는 데 집중한다면, 템플릿 메타 프로그래밍은 컴파일 타임 계산 자체를 목적으로 한다

핵심은 템플릿을 프로그래밍 언어 처럼 사용한다는 것이고, 템플릿 안에서 다양한 프로그램을 만들 수 있다.

즉 제네릭 프로그래밍에서 성능을 얻기 위해 만들어진 방식인 것이다.

이렇게 이 글만 보면, 제네릭은 마치 은탄환처럼 알고리즘을 추출하여 모든것에 적용하기 쉬운 것처럼 보인다.

하지만 이는 이론의 관점이지 실적용에서는 많은 비판을 받는다.

제네릭 프로그래밍은 장점만 보이는 것 같은데

단점은 없는가?

C++
template <typename InputIt, typename OutputIt, typename UnaryOp>
OutputIt transform(InputIt first, InputIt last, OutputIt d_first, UnaryOp unary_op);

예시 코드를 하나보자.

C++의 std::transform 함수는 입력범위[first,last]의 각 요소에 대해 단항 연산자(unary_op)를 적용하고, 결과를 출력한 후, 반복자(d_first)로 저장한다.

이 코드는 매우 간단한 코드지만, 문제가 생길때 오류 메시지를 길게 내뿜기 시작한다.

템플릿 코드에서 문제가 발생하면, 컴파일러는 추상화 계층까지 올라가 분석하고, 이 과정에서 복잡한 오류 메세지를 발생시켜 프로그래머로 하여금 실수를 찾기 힘들게 한다.

흔히 우리가 하는 프로그래밍 방식인 '레고 조립(래핑된 메서드 블록을 가져와 조립)'하는 방식은 실수가 눈에 금방 잡히지만, 템플릿 인스턴스화 실패는 정말 버그 잡기가 어렵다.

또한 자바 제네릭 방식인 Type Erasure방식은

컴파일러가 체크하는 것처럼 보이지만, 런타임에는 타입 정보가 소거되어 예상치 못한 오류가 발생할 수도 있다.

(C#은 런타임에도 제네릭 타임 정보가 유지된다(Reification), IL에서 구체적인 제네릭 타입이 보존되어 런타임에도 타입정보를 확인할 수 있기에 자바 제네릭의 문제다.)

그리고 타입 매개변수 제네릭 프로그래밍은 가독성을 떨어뜨리기로 악명높고.

결국

이론상으로는 우월하고 멋있어보이지만, 실무에서는 프로그래머에게 지옥을 선사한다고 할 수 있다.

제네릭 프로그래밍은 정말로 아름다운 프로그래밍을 가능하게 만들 수 있고,

이를 통해서 엄청난 소프트웨어 공학의 '부'를 축적할 수 있다.

하지만 잘못 사용하면 제네릭 프로그래밍 속에 숨겨진 추상화의 '빚'을 판단하지 못해,

프로그래밍이 지옥이 되는 대공황도 올 수 있다는 점을 알아야한다.

결국 이 모든 것을 감안해서 조절하는 것이 프로그래머의 실력인 것이다.