[Java] 오늘날의 제네릭이 되기까지 제네릭의 발전과 소거(Generic and Erasure) from Brian Goetz
아래는 자바 언어 아키텍트인 Brian Goetz의 “Background: how we got the generics we have (Or, how I learned to stop worrying and love erasure)” 포스팅을 재구성하여 작성한 것입니다. 해당 포스팅은 현재의 제네릭에 도달하게 된 과정과 그 이유에 초점을 맞추고, 현재 제네릭이 우리가 구축하려는 "더 나은" 제네릭에 어떤 영향을 미칠지에 대한 토대를 마련하기 위해 작성되었습니다. 특히 2004년 Java에 제네릭을 추가하기 위해 소거가 실제로 합리적이고 실용적인 선택이었음을 강조하며, 소거로의 전환을 선택하게 한 많은 강요들이 지금도 여전히 작동하고 있을 수 있음을 강조하고자 합니다.
1. 오늘날의 제네릭이 되기까지 제네릭의 발전과 소거
(Generic and Erasure)
[ 소거(Erasure)에 대하여 ]
소거란 무엇인가?
소거(Erasure)는 자바나 제네릭에만 국한된 것이 아니라 한 수준의 코드를 하위 수준으로 변환할 때 어디에서나 그리고 종종 요구된다. 상위 언어에서 중간 표현 계층, 네이티브 코드, 하드웨어로 스택을 내려갈수록 하위 수준에서 제공하는 타입 추상화는 상위 수준보다 항상 더 간단하고 약하므로 컴파일러는 소거를 활용한다. 즉, 소거는 컴파일러로 하여금 고수준에서 갖는 더 풍부한 타입을 저수준의 덜 풍부한 타입에 매핑시키는 기술이다.
예를 들어 자바 파일을 바이트코드로 컴파일할 때 제네릭의 타입이 소거되며, C 파일을 네이티브 코드로 컴파일 할 때 unsigned int에서 unsigned가 소거되어 모두 동일하게 int로 취급되며(CPU 레벨에서 사용되는 general-purpose register에는 부호 정보가 관리되지 않으며 연산 시에 처리됨), const 변수는 컴파일 후에 정보가 소거되고 변경 가능한 레지스터와 메모리 위치에 저장되는 등 소거는 널리 사용된다.
제네릭 설계 시에 선택 가능한 2가지 접근 방식
제네릭 타입을 파라미터로 지정할 수 있는 매개변수 다형성(parameteric polymorphism)이 있는 언어에서는 제네릭 타입을 번역하는 데 동질 번역과 이질 번역이라는 두 가지 접근 방식이 있다. 참고로 여기서 매개변수 다형성이란 프로그램밍 시에 특정한 타입에 의존하지 않는 코드를 작성할 수 있게 해 주는 개념으로, 주로 제네릭(Generic) 타입을 통해 구현된다. 예를 들어, Java의 제네릭을 사용하여 다양한 타입의 리스트를 다룰 수 있는 List<T>는 parametric polymorphism의 대표적인 예시이다. 이를 통해 List<Integer>, List<String> 등 다양한 타입의 리스트를 동일한 로직으로 처리할 수 있다.
class GenericTest {
@Test
void parametericPolymorphism() {
System.out.println(returnList(List.of(1, 2, 3, 4, 5)));
}
public <T> List<T> returnList(List<T> list){
return list;
}
}
동질 번역(Homogeneous translation)에서는 제네릭 클래스 Foo<T>가 Foo.class와 같은 단일 아티팩트로 번역된다. 반면에 이질 번역(Heterogeneous translation)에서는 Foo<String> 또는 Foo<Integer>와 같이 별도의 엔티티로 취급되어 별도의 아티팩트를 생성하게 된다. 대표적으로 자바는 동질 번역을 사용하며, C++ 는 이질 번역을 사용한다.
이질 번역에서 타입이 다르다면 서로 갖는 의미도 다르고 생성되는 코드도 다르다. 이는 타입 안전성(각 인스턴스화에 대하여 개별적으로 타입 검사가 가능함)과 생성된 코드의 품질(각 인스턴스화 별로 최적화가 가능함) 측면에서 장점이 있다. 반면에 이는 더 큰 코드 footprint를 갖게 됨을 의미하며, 각각은 서로 전혀 관련이 없는 타입이므로 Java의 와일드카드를 통한 처리가 불가능하다. 스칼라의 경우 타입 변수 별로 이를 생성하려고 시도했더니 클래스가 9n 개로 폭발적으로 증가했으며, 몇 줄의 코드 뿐인데 100MB의 JAR 파일이 만들어졌다.
결국 언어의 설계자가 항상 절충안을 만들어야 한다. 이질 번역은 정적 및 동적 footprint가 커지고 런타임에 공유가 줄어드는 대신 더 많은 타입 구체화를 제공하며, 이 모든 것이 성능에 영향을 미친다. 반면에 동질 번역은 Java의 와일드카드 같이 추상화하는 데 더 용이하다.
자바 제네릭에서의 타입 소거
Java는 동종 번역을 사용하여 제네릭을 번역하는데, 이때의 상황을 크게 2가지로 분류할 수 있다.
- List<String>와 같이 구체적인 타입으로 존재하는 경우
- <T extends Object> 같이 타입 변수가 존재하는 경우
List<String>와 같이 구체적인 타입으로 존재하는 경우에 제네릭 타입 정보(String)는 컴파일 후 제거되고, 원시 타입(Raw Type)인 List만 남는다. 구체적인 제네릭 타입(String) 정보는 런타임에 존재하지 않게 된다.
// 자바 소스 코드
List<String> list = new ArrayList<>();
list.add("Hello");
// 컴파일 후 바이트코드
List list = new ArrayList();
list.add("Hello");
<T extends Object> 같이 타입 변수가 존재하는 경우, 타입 변수 T는 컴파일 시점에 바운드(여기선 Object)로 대체되며 제네릭 정보는 제거된다. 타입 변수가 <T> 로 바운드가 존재하지 않는 경우에는 Object로 대체된다.
// 자바 소스 코드
class Box <T extends Object> {
private T value;
public T get() {
return value;
}
}
// 컴파일 후 바이트코드
class Box {
private Object value;
public Object get() {
return value;
}
}
즉, 자바 컴파일러(javac)는 와일드카드(Box<?>)와 원시 타입(Box)을 포함한 Box의 모든 인스턴스에 대하여 단일 클래스 파일인 Box.class를 생성한다. 필드, 메서드 및 수퍼 타입의 설명자(descriptor)는 지워지고, 타입 변수는 해당 바운드로 지워지고, 일반 타입은 다음과 같이 헤드(List<String>은 List로 지워짐)로 지워진다.
class Box {
private Object t;
public Box(Object t) { this.t = t; }
public Box copy() { return new Box(t); }
public Object t() { return t; }
}
컴파일러가 클래스 파일을 읽을 때 Generic Signature는 볼 수 있도록 Signature Attribute에 유지되지만, JVM은 링크에서 지워진 디스크립터만 사용한다. 이 방식은 클래스파일 수준에서 Box<T>의 레이아웃과 API가 모두 지워진다는 것을 의미한다. 사용처에서도 동일한 일이 발생하는데, Box<String>에 대한 참조가 Box로 지워지고 사용 사이트에서는 String에 대한 합성 형변환이 추가된다. 여기서 Generic Signature, Signature Attribute는 자바의 제네릭 타입 정보를 클래스 파일에 저장하는 방식과 관련이 있는데, 각각 다음의 개념에 해당한다.
- Generic Signatures
- 자바 컴파일러는 런타임에 사용되지 않더라도 일부 제네릭 타입 정보를 클래스 파일에 시그니처 형태로 남기는데, 이를 generic signature라고 함
- 예를 들어 Box<T> 클래스의 제네릭 타입 정보는 컴파일 후에도 .class 파일에 남아있어서 컴파일러나 IDE 같은 도구가 클래스 파일을 읽을 때 타입 관계를 파악할 수 있도록 함
- Signature Attribute
- 자바 컴파일러는 제네릭 타입 정보(예: <T>, <String> 등)를 Signature라는 이름의 특별한 속성(attribute) 안에 기록하여 .class 파일에 포함함
- Signature attribute는 제네릭 타입의 정보를 저장하는 공간으로, 런타임에 사용되지는 않지만 컴파일러나 리플렉션을 통해 제네릭 타입 정보를 활용해야 하는 도구가 이를 참조할 수 있음
[ 타입 소거 선택의 이유 ]
타입 소거에 대한 대안책들
왜 잘 작동하는 타입 정보를 컴파일러로 하여금 버리게 했을까? 이 질문을 더 잘 이해하기 위해, 타입 정보를 '구체화'했을 때 어떤 일을 할 수 있을지, 그리고 그것에 따른 비용이 무엇인지도 함께 질문해 봐야 한다. 구체화된 타입 매개변수 정보를 사용하는 몇 가지 방법을 상상해 볼 수 있다.
- 리플렉션: 일부 사람들에게 "구체화된 제네릭"은 단순히 List가 어떤 타입의 리스트인지 물어볼 수 있는 기능을 의미합니다. 언어 도구들(예: instanceof 같은)이나 타입 변수 패턴 매칭을 사용하거나, 타입 매개변수에 대해 질의할 수 있는 리플렉션 라이브러리를 사용하는 방법이 존재할 수 있음
- 레이아웃 또는 API 특수화: 원시 타입이나 인라인 클래스가 있는 언어에서는 Pair<int, int>의 레이아웃을 두 개의 객체 참조가 아닌 두 개의 int 값을 직접 담도록 평탄화할 수 있으면 유용할 것임
- 런타임 타입 검사: 클라이언트가 List<String>에 Integer를 넣으려 할 때 힙 오염이 발생할 수 있는데, 힙 오염이 실제로 발생하기 전에 이를 감지하고 실패하도록 할 수 있음
각각의 기능들은 서로 배타적이지 않지만, 각각 다른 목표(프로그래머의 편의성, 성능, 안전성)를 돕기 위한 것이며, 서로 다른 의미와 비용을 수반한다. “구체화를 원한다”고 말하는 것은 쉽지만, 더 깊이 파고들면 이 중 무엇이 가장 중요한지, 그리고 상대적인 비용과 이점이 크게 나뉜다는 것을 알 수 있다. 여기서 타입 소거가 합리적이고 실용적인 선택이었음을 이해하려면, 당시의 목표, 우선순위와 제약, 대안이 무엇이었는지 또한 이해해야 한다.
자바 제네릭의 목표였던 점진적인 마이그레이션 호환성(Gradual migration compatibility)
자바 제네릭은 다음과 같은 야심찬 요구사항을 채택했다.
It must be possible to evolve an existing non-generic class to be generic in a binary-compatible and source-compatible manner. 기존의 비제네릭 클래스를 바이너리 호환성과 소스 호환성을 유지하면서 제네릭 클래스로 진화시킬 수 있어야 한다.
이는 제네릭 도입 이전에 ArrayList와 같이 사용하던 기존 클라이언트와 하위 클래스들이 제네릭화된 ArrayList<T>에 대해 모두 변경 없이 컴파일할 수 있어야 하고, 기존 클래스 파일들이 제네릭화된 ArrayList<T>의 메서드에 연결될 수 있어야 함을 의미한다. 이를 위해 제네릭화된 클래스의 클라이언트와 하위 클래스들은 즉시, 나중에, 혹은 전혀 제네릭화를 하지 않을 수 있으며, 다른 클라이언트나 하위 클래스의 유지 관리자가 무엇을 하기로 선택했는지와 상관없이 이를 독립적으로 결정할 수 있어야 했다.
이 요구사항이 없었다면, 클래스를 제네릭화하기 위해 모든 클라이언트와 하위 클래스들이 최소한 다시 컴파일되거나 수정되어야 하는 “깃발 꽂는 날(flag day)”가 필요했을 것이다. 특히 ArrayList와 같은 핵심 클래스의 경우, 이는 사실상 전 세계의 모든 자바 코드가 한 번 재컴파일되거나, 그렇지 않으면 영원히 Java 1.4에 머물러야 함을 의미한다. Java 생태계 전체에서 이는 불가능했으므로, 클라이언트가 그 제네릭화를 인지하지 않더라도 핵심 플랫폼 클래스 또는 서드파티 라이브러리를 제네릭화할 수 있는 제네릭 타입 시스템이 필요했다.
즉, 제네릭이 하위 호환성을 고려하지 않았다면 제네릭과 연관된 기존의 모든 코드를 버리거나 개발자들이 기존 코드를 수정하여 제네릭을 사용하며 기존 코드를 유지했어야 했는데, 제네릭화를 호환 가능하게 만들면 코드에 대한 투자를 유지하면서도 기존 코드는 남게 되는 것이다.
flag day를 피하고자 했던 이유는 자바 설계의 본질적인 측면에서 비롯되는데, 각 자바 파일들은 개별적으로 컴파일되고 동적으로 링크된다. 개별 컴파일(separately compiled)이란 모든 소스 파일이 한 번에 컴파일되는 것이 아니라, 각 소스 파일이 하나 이상의 클래스 파일로 따로 컴파일된다는 의미이다. 동적 링크(dynamically linked)는 클래스 간의 참조가 기호 정보를 기반으로 런타임에 연결된다는 뜻이다. 예를 들어, 클래스 C가 클래스 D의 void m(int x) 메서드를 호출한다면, C의 클래스 파일에는 호출하는 메서드의 이름과 서술자 ((I)V)가 기록되며, 링크 시점에 D에서 이 이름과 서술자와 일치하는 메서드를 찾아 호출 지점과 연결하게 된다.
이는 복잡한 작업처럼 들릴 수 있지만, 개별 컴파일과 동적 링크는 Java의 가장 큰 장점 중 하나를 가능하게 한다. 즉, C를 D의 한 버전으로 컴파일한 후 클래스 경로에 다른 버전의 D를 두고 실행할 수 있다. (단, D에 바이너리 호환성을 해치는 변경을 가하지 않아야 한다.)
The pervasive commitment to dynamic linkage is what allows us to simply drop a new JAR on the class path to update to a new version of a dependency, without having to recompile anything. We do this so often we don’t even notice – but if this stopped working, it would indeed be noticed. 동적 링크에 대한 지속적인 의지가 있기에, 새로운 JAR 파일을 클래스 경로에 추가하는 것만으로도 의존성의 새 버전으로 업데이트할 수 있으며, 이를 위해 다시 컴파일할 필요가 없습니다. 우리는 이 작업을 너무 자주 하다 보니 특별히 의식하지 않을 정도지만, 만약 이것이 작동하지 않는다면 분명 큰 문제가 될 것입니다.
제네릭이 자바에 도입될 당시, 이미 전 세계에 많은 자바 코드가 존재했고, 그 클래스 파일들은 java.util.ArrayList와 같은 API에 대한 참조로 가득했다. 만약 이를 호환 가능하게 제네릭화하지 않았다면, 이를 대체할 새로운 API를 작성해야 했을 것이다. 더 나아가, 기존 API를 사용하는 모든 클라이언트 코드는 계속해서 자바 1.4에 머물거나 혹은 새로운 API를 사용하도록 변경해야 했을 것이다. (애플리케이션 코드뿐만 아니라 애플리케이션이 의존하는 모든 서드파티 라이브러리도 포함하여) 이는 당시 존재하던 거의 모든 자바 코드의 가치를 떨어뜨리는 결과를 초래했을 것이다.
반면 C#은 반대의 선택을 했다. 즉, VM을 업데이트하고 기존 라이브러리와 이를 의존하는 모든 사용자 코드를 무효화했다. 당시 C#은 세계적으로 비교적 적은 코드만 존재했기 때문에 이러한 선택을 할 수 있었지만, 자바는 그럴 수 없었다.
하지만 이러한 선택의 결과로 제네릭 클래스가 제네릭 클라이언트와 비제네릭 클라이언트를 동시에 가질 수 있게 되었다. 이는 소프트웨어 개발 과정에 큰 도움이 되지만, 혼합된 사용에서 타입 안전성에 잠재적인 영향을 미칠 수 있다.
힙 오염(Heap pollution)
이렇게 타입을 소거하고 제네릭과 비제네릭 클라이언트 간의 상호운용성을 지원하면 힙 오염(heap pollution)의 가능성이 생긴다. 이는 박스에 저장된 값의 런타임 타입이 컴파일타임에 예상된 타입과 호환되지 않을 수 있다는 것을 의미한다. 예를 들어, Box<String>을 사용하는 클라이언트는 T가 String에 할당될 때마다 캐스트가 추가되며, 이 캐스트는 데이터가 제네릭 타입(박스 구현)의 세계에서 구체적인 타입의 세계로 전환되는 지점에서 힙 오염을 감지할 수 있게 한다. 힙 오염이 존재하는 경우, 이러한 캐스트는 실패할 수 있다.
힙 오염은 비제네릭 코드가 제네릭 클래스를 사용할 때나, 우리가 unchecked 캐스트나 원시 타입(raw types)을 사용하여 잘못된 제네릭 타입의 변수에 대한 참조를 만들 때 발생할 수 있다. (unchecked 캐스트나 원시 타입을 사용할 때, 컴파일러는 힙 오염이 발생할 수 있음을 경고한다.)
Box<String> bs = new Box<>("hi!"); // safe
Box<?> bq = bs; // safe, via subtyping
Box<Integer> bi = (Box<Integer>) bq; // unchecked cast -- warning issued
Integer i = bi.get(); // ClassCastException in synthetic cast to Integer
이 코드에서의 죄악은 Box<?>에서 Box<Integer>로의 unchecked 캐스트이다. 이는 우리가 개발자가 실제로 Box<Integer>라고 말하는 대로 믿어야 한다는 의미이다. 그러나 힙 오염(heap pollution)은 즉시 감지되지 않고, Box<String>에 들어 있는 String을 Integer로 사용하려 할 때, 뭔가 잘못되었음을 알 수 있다.
이 코드를 이해하는 방법은 다음과 같다. 만약 Box를 Box<Integer>로 캐스트한 뒤, 다시 Box<String>으로 캐스트하여 사용한다고 해도 (이 캐스트가 합당하지 않더라도) 프로그램은 오류를 발생시키지 않는다. 이는 동일한 타입을 유지하고 있기 때문이다. 하지만 이질 번역(heterogeneous translation)에서 Box<String>과 Box<Integer>는 다른 런타임 타입을 가진다. 이 경우, Box<String>을 Box<Integer>로 캐스트하려고 하면 타입 불일치로 인해 캐스트가 실패한다. 즉, 런타임에서 타입이 실제로 달라지므로, 캐스트가 잘못된 타입으로 이루어졌음을 감지하고 오류를 발생시킬 수 있다. 언어는 실제로 제네릭에 대해 상당히 강력한 안전 보장을 제공하지만, 우리가 규칙을 따를 경우에만 그렇다.
If a program compiles with no unchecked or raw warnings, the synthetic casts inserted by the compiler will never fail. 즉, 프로그램이 unchecked나 raw 경고 없이 컴파일된다면, 컴파일러가 삽입한 **합성 캐스트(synthetic casts)**는 절대 실패하지 않을 것입니다.
다시 말해서, 힙 오염(heap pollution)은 우리가 비제네릭 코드와 상호작용하거나 컴파일러에게 거짓말을 할 때만 발생할 수 있다. 힙 오염이 발견되는 지점에서는, 우리가 예상했던 타입과 실제로 발견된 타입에 대한 정보를 담은 깨끗한 예외(exception)가 발생한다. 즉, 힙 오염이 발생하면, 컴파일러는 정확한 타입 정보를 제공하는 예외를 던져서, 어떤 타입이 예상되었고 실제로 어떤 타입이 발견되었는지를 알려준다. 이를 통해 개발자는 문제가 발생한 부분을 파악하고 수정할 수 있다.
합성 캐스트(synthetic casts)는 자바 컴파일러가 제네릭 타입을 처리할 때 자동으로 삽입하는 타입 캐스트이다. 제네릭은 타입 정보를 컴파일 타임에만 사용하고 런타임에서는 타입 정보가 소거(erase)되기 때문에, 컴파일러는 런타임에서 올바른 타입을 보장하기 위해 자동으로 캐스트를 삽입하는데, 이때 삽입된 캐스트를 합성 캐스트라고 부른다.
JVM 구현과 언어 생태계(Context: Ecosystem of JVM implementations and languages)
제네릭 설계 선택은 JVM 구현과 JVM에서 실행되는 언어 생태계의 구조에도 영향을 받았다. 대부분의 개발자에게 "Java"는 하나의 거대한 엔티티로 보이지만, 실제로는 Java 언어와 JVM은 별개의 엔티티로 각기 다른 사양을 가지고 있다. 자바 컴파일러는 JVM을 위한 클래스 파일을 생성하며, JVM은 해당 클래스 파일이 원래 어떤 언어에서 왔는지에 상관없이 유효한 클래스 파일이라면 실행할 수 있다. 실제로는 200개가 넘는 언어들이 JVM을 컴파일 대상로 사용하고 있으며, 그 중 일부는 자바 언어와 많은 부분을 공유하는 언어들(예: Scala, Kotlin)이 있고, 다른 일부는 자바 언어와 매우 다른 언어들(예: JRuby, Jython, Jaskell)이 있다.
JVM이 자바와 매우 다른 언어들까지도 컴파일 대상로 사용될 수 있었던 이유 중 하나는, 자바 언어의 영향을 최소화하면서 컴퓨팅을 위한 추상적 모델을 제공하기 때문이다. 언어와 가상 머신 간의 추상화 계층은 JVM 위에서 실행되는 다른 언어들의 생태계를 촉진시키는 데 유용했을 뿐만 아니라, JVM의 독립적인 구현 생태계에도 도움이 되었다. 현재 시장은 많이 통합되었지만, 제네릭이 자바에 추가되었을 당시에는 상업적으로 실행 가능한 JVM 구현체가 12개 이상 존재했다. 제네릭을 구체화(reify) 하려면 언어뿐만 아니라 JVM도 이를 지원하도록 향상시켜야 했기 때문에, 이는 중요한 설계 고려사항이었다.
그 당시 JVM에 제네릭 지원을 추가하는 것이 기술적으로 가능했을지라도, 이는 상당한 엔지니어링 투자와 많은 구현자들 간의 조정 및 합의가 필요한 일이었을 것이다. 또한, JVM 위에서 실행되는 언어들의 생태계도 구체화된 제네릭에 대해 의견을 가질 가능성이 있었다. 예를 들어, 제네릭의 구체화(reification)가 런타임에서 타입 검사를 포함하는 것으로 해석된다면, Scala와 같은 언어는 어떻게 반응했을까? Scala는 선언 위치에서 제네릭을 선언하는 방식(declaration-site generics)을 사용하므로, JVM이 자바의 불변(Generic Invariant) 서브타입 규칙을 강제하는 것을 기쁘게 받아들일까?
[ 소거는 프로그래밍적 타협이다(Erasure was the pragmatic compromise) ]
이러한 제약들(기술적 제약과 생태계적 제약)은 제네릭 타입 정보를 컴파일 타임에 소거하는 동질 번역으로 나아가게 하는 강력한 원동력이 되었다. 이를 요약하자면, 이 결정을 내리게 만든 주요 요소들은 다음과 같다
- 런타임 비용
- 이질적인 번역(heterogeneous translation)은 여러 가지 런타임 비용을 수반합니다. 예를 들어, 정적 및 동적 메모리 사용량 증가, 클래스 로딩 비용 증가, JIT(Just-In-Time) 컴파일 비용 증가, 코드 캐시 압력 증가 등이 있습니다. 이로 인해 개발자는 타입 안전성과 성능 사이에서 선택을 강요받을 수 있습니다.
- 마이그레이션 호환성
- 제네릭을 구체화(reified generics)하려는 마이그레이션을 지원할 수 있는 기존의 번역 방식은 소스 및 이진 호환성을 제공할 수 없었습니다. 이는 "플래그 데이(flag day)"와 같은 대규모 변경을 초래하고, 기존 코드에서의 상당한 투자가 무효화될 수 있었습니다.
- 런타임 비용 (보너스판):
- 만약 구체화가 런타임에서 타입을 검사하는 것으로 해석된다면(예: 자바의 공변 배열에서의 동적 타입 검사처럼), 이는 큰 런타임 영향을 미칩니다. JVM은 각 필드나 배열 요소에 대해 제네릭 하위 타입 검사를 수행해야 하므로 성능에 큰 영향을 줄 수 있습니다. (List<String>처럼 간단한 타입에서는 저렴하게 느껴질 수 있지만, Map<? extends List<? super Foo>>, ? super Set<? extends Bar>>와 같은 복잡한 타입에서는 금방 비용이 증가합니다. 실제로, 나중에 제네릭 하위 타입 결정 가능성에 대한 의문이 제기되었습니다.)
- JVM 생태계
- 여러 JVM 벤더들이 타입 정보가 런타임에서 어떻게 구체화될 것인지에 대해 합의하는 것은 매우 어려운 일이었습니다.
- 배포의 실용성
- 만약 여러 JVM 벤더들이 실제로 동작할 수 있는 방안에 합의할 수 있었다 하더라도, 이는 이미 상당히 큰 복잡성을 가지고 있는 작업의 복잡성, 일정, 위험을 크게 증가시켰을 것입니다.
- 언어 생태계
- 예를 들어 Scala와 같은 언어는 자바의 불변 제네릭이 JVM의 의미론에 포함되는 것에 대해 기꺼이 동의하지 않았을 수 있습니다. JVM에서 제네릭에 대한 언어 간 호환성을 위한 합의가 이루어지면, 역시 작업의 복잡성, 일정, 위험이 크게 증가했을 것입니다.
- 어차피 소거(Erasure) 문제를 처리해야 했음
- 만약 타입 정보를 런타임에 보존할 수 있었다 하더라도, 이전에 컴파일된 클래스파일은 여전히 제네릭화되기 전의 형태일 수 있어, 타입 정보가 없는 ArrayList가 힙에 존재할 수 있습니다. 이 경우 힙 오염(heap pollution)의 위험이 여전히 존재합니다.
- 일부 유용한 관용구를 표현할 수 없음
- 기존 제네릭 코드는 때때로 컴파일러가 알지 못하는 런타임 타입에 대한 정보를 알고 있을 때 unchecked cast를 사용할 수 있습니다. 제네릭 타입 시스템에서 이를 표현할 수 있는 쉬운 방법이 없기 때문에, 많은 경우 구체화된 제네릭을 사용하면 이러한 기법들이 불가능해지며, 이를 다른 방법으로 표현해야 하고, 이는 종종 더 비효율적인 방법이 될 수 있습니다.