
Java 개발을 하다 보면 List<String>과 같은 제네릭(Generic)을 숨 쉬듯이 사용하게 됩니다.
하지만 “왜 런타임에는 제네릭 타입 정보가 남아있지 않을까?” 혹은 “왜 instanceof List<String>은 컴파일 에러가 날까?”라는 의문을 가져본 적이 있으신가요?
이 글에서는 Java가 제네릭을 어떻게 처리하는지,
그리고 컴파일러와 JVM의 역할이 어떻게 나뉘는지 심도 있게 파헤쳐 봅니다.
제네릭이 없던 시절의 Java와 JSR-14
Java 5에 제네릭이 도입되기 전, 컬렉션을 다루는 코드는 런타임 에러의 지뢰밭이었습니다.
제네릭 도입을 제안했던 JSR-14 Proposal의 문구를 보면 당시의 고민이 잘 드러납니다.
"Many programs and libraries written in Java are intrinsically generic.
However, Java lacks the ability to specify generic types.
As a result, programs are harder to read and maintain, and are more likely to fail with runtime type errors."
( → Java의 많은 프로그램과 라이브러리는 본질적으로 여러 타입을 다뤄야 했지만, 이를 타입 매개변수로 표현할 방법 없었음. 그 결과 코드는 읽고 유지보수하기 어려워졌으며, 런타임 타입 오류로 인해 실패할 확률 높았음. )
당시에는 컬렉션이 모든 값을 Object로 관리했기 때문에 다음과 같은 코드가 아무런 에러 없이 컴파일되었습니다.
List list = new ArrayList();
list.add("Java");
list.add(10); // 컴파일 단계에서는 아무런 문제가 없음
// 값을 꺼내 사용하는 순간 문제 발생
String s = (String) list.get(1); // ClassCastException 발생!
add()가 받는 값이 Object이므로 String과 Integer를 함께 저장해도 컴파일러(Syntax Analysis 및 Type Checking)는 이를 통과시킵니다.
문제는 프로그램이 실제로 실행(Runtime)되어 형변환 코드가 호출되는 순간에 나타납니다.
잘못된 타입 사용이 실행 중에야 ClassCastException으로 터지면서 앱이 강제 종료되는 것입니다. 제네릭은 이러한 런타임 타입 오류를 가능한 한 컴파일 시점에 발견하여 타입 안정성을 높이기 위해 도입되었습니다.
하위 호환성의 딜레마와 Type Erasure (타입 소거)
제네릭을 도입하기로 결정했지만, Java 설계자들에게 큰 숙제는 “하위 호환성(Binary Compatibility)”입니다.
"Type erasure enables Java applications that use generics to maintain binary compatibility with Java libraries and applications that were created before generics."
제네릭이 추가되었다고 해서 기존 Java 라이브러리와 애플리케이션이 동작하지 않으면 안 되었습니다.
이를 위해 Java는 ArrayList<String>과 ArrayList<Integer>를 각각 별도의 런타임 클래스(바이트코드)로 새로 만드는 대신!
기존의 ArrayList 실행 구조를 그대로 사용하면서 컴파일 과정에서 타입 인자를 제거하는 방식을 선택했습니다. 이를 Type Erasure(타입 소거)라고 부릅니다.
Java Language Specification (JLS) 4.6에서는 Type Erasure를 다음과 같이 정의합니다.
"Type erasure is a mapping from types (possibly including parameterized types and type variables) to types (that are never parameterized types or type variables)."
(타입 소거는 제네릭 타입이나 타입 변수를 포함하는 타입을, 더 이상 제네릭이 아닌 타입으로 변환하는 규칙이다.)
그렇다면 타입 정보가 소거되는데도 어떻게 타입 안정성이 유지될까요? 그 비밀은 컴파일러의 3단계 마법에 있습니다. 제네릭이 JVM 관점에서는 신텍스 슈가일수도..?!
아주 참고로,,
신택스 슈가(Syntactic Sugar)의 사전적 정의는 "언어의 기능은 그대로 둔 채, 사람이 읽고 쓰기 쉽게 문법적으로만 감싸 놓은 것"입니다.
내부적으로 새로운 기능이 추가된 것이 아니라, 컴파일러가 코드를 기존의 방식으로 예쁘게 번역해 주는 것이죠. (물론 JVM관점에서.. 컴파일러는 하는게 많습니다.)
이제 컴파일러가 하는 .. 컴파일러의 역할을 봐야겠죠?
컴파일러의 역할 변화(크게 3단계): Type Erasure (타입 소거)
Generic을 도입하더라도 기존 Java 라이브러리와 애플리케이션이 계속 함께 동작해야 했습니다.
이를 위해 Java는 Generic마다 완전히 새로운 런타임 클래스(.class→ 바이트코드)를 만드는 대신, 컴파일 과정에서 대부분의 타입 인자를 제거하는 Type Erasure 방식을 선택했습니다.
다시 말해서, 제너릭의 타입 안정성은 런타임에 JVM이 추적해서 만드는 것이 아닙니다. 컴파일러-javac가 실행 전에 검사하고 안전 장치를 마련함으로 완성됩니다.
Type erasure enables Java applications that use generics to maintain binary compatibility with Java libraries and applications that were created before generics.
추가로 ArrayList<String>과 ArrayList<Integer>를 각각 별도의 런타임 클래스로 만드는 대신,
둘 모두 기존 ArrayList 실행 구조를 사용하도록 설계한 것입니다.
제네릭과 관련된 검사는 주로 컴파일러가 담당하고, JVM은 기존과 같은 방식으로 생성된 바이트코드를 실행합니다.
그렇다면 Type Erasure가 적용되는데도 어떻게 런타임 때 타입 안정성이 유지될까요?
이를 이해하려면 컴파일러가 소스 코드를 .class 파일로 바꾸는 과정에서 어떤 일을 수행하는지 살펴봐야 합니다.

Java 프로그램은 소스 코드를 작성했다고 곧바로 실행되는 것이 아닙니다.
먼저 javac (컴파일러)가 소스 코드를 분석하고, 타입 규칙을 위반한 부분이 없는지 검사한 뒤 .class 파일을 생성합니다.
이 과정에서 컴파일 오류가 하나라도 발견되면 .class 파일이 정상적으로 생성되지 않으며, 개발자는 해당 코드를 수정하기 전까지 프로그램을 실행할 수 없습니다.
"개발자야! 너가 이 제너릭 타입 불일치한거 수정 전까지 실행(run) 못하게 할거야!"
즉, Generic은 잘못된 타입 사용을 런타임까지 넘기지 않고 컴파일 단계에서 개발자에게 수정을 강제하는 장치로 동작합니다.
1단계: Type Checking — 실행 전에 잘못된 타입 차단
개발자가 List<String> list = new ArrayList<>()라고 선언하면, 컴파일러는 이 리스트에 String만 저장할 수 있다는 엄격한 제약을 적용합니다.
List<String> list = new ArrayList<>();
list.add("Java"); // OK
list.add(10); // x 컴파일 에러 발생!
컴파일러는 list.add(10)을 만나면 Integer를 String 전용 리스트에 저장하려 했다고 판단해 컴파일 오류를 발생시킵니다.
이 오류는 JVM이 프로그램을 실행하다 발견하는 것이 아닙니다.
실행 전에 javac가 발견하므로, 개발자가 코드를 수정하기 전에는 정상적인 .class 파일을 만들 수 없고 프로그램도 실행할 수 없습니다.
2단계: 필요한 형변환을 자동으로 삽입!
타입 검사를 통과했다면, 정상적인 Generic 사용 범위에서는 해당 리스트에서 꺼낸 값을 String으로 사용할 수 있습니다. 따라서 개발자는 Java 5 이전처럼 값을 꺼낼 때마다 명시적인 형변환을 작성하지 않아도 됩니다.
String s = list.get(0); // 개발자는 형변환을 안 적었음
// 이전에는 개발자가 직접 아래처럼 작성
// String s = (String) list.get(0)
컴파일러는 이 코드를 컴파일하면서 필요한 타입 변환을 바이트코드에 삽입합니다.
왜 그럴까요? → 컴파일 오류가 안났으면? “아!! 정상정인 제너릭타입”임이 파악됬기 때문입니다.
소스 코드에는 (String)이 없지만, 생성된 바이트코드에는 반환된 객체가 String인지 확인하는 checkcast java/lang/String 명령이 포함됩니다.
Java 5 이전에는 컬렉션의 get()이 Object를 반환했기 때문에 개발자가 (String) list.get(0)처럼 형변환을 직접 작성해야 했습니다.
Generic 도입 이후에는 컴파일러가 이 작업을 대신합니다.
// 컴파일러가 바이트코드로 바꿀 때 실제 만드는 모습
String s = (String) list.get(0);
3단계: Generic 타입 정보를 소거한다
Java Language Specification (JLS) 4.6에서는 Type Erasure를 아래처럼 정의합니다.
“Type erasure is a mapping from types (possibly including parameterized types and type variables) to types (that are never parameterized types or type variables)“
(타입 소거는 제네릭 타입이나 타입 변수를 포함하는 타입을, 더 이상 제네릭이 아닌 타입으로 변환하는 규칙임)
타입 검사와 필요한 캐스팅 삽입을 마친 뒤, 컴파일러는 대부분의 Generic 타입 정보를 제거합니다.
이를 Type Erasure라고 합니다.
중요한 점은 먼저 타입 검사를 끝낸 다음 정보를 지운다는 것입니다. Generic 정보가 바이트코드에서 대부분 사라지더라도, 잘못된 타입 사용은 이미 컴파일 단계에서 차단되었고 필요한 checkcast 명령도 삽입된 상태입니다.
결국 최종 생성된 .class 바이트코드는 옛날 방식처럼 작동합니다:
// 최종적으로 실행되는 바이트코드 형태
List list = new ArrayList();
list.add("Java");
String s = (String) list.get(0);
컴파일러의 처리 흐름
- 타입 검사: 선언한 Generic 타입과 맞지 않는 코드를 발견하면 컴파일 오류를 발생시킨다. 개발자가 수정하기 전에는 정상적으로 컴파일하거나 실행할 수 없다.
- 캐스팅 삽입: 값을 꺼내 사용하는 위치에 필요한
checkcast명령을 자동으로 삽입한다. - 타입 소거: 기존 라이브러리 및 실행 모델과의 바이너리 호환성을 유지하기 위해 대부분의 Generic 타입 정보를 제거한다.
즉, Generic의 타입 안정성은 JVM이 런타임에 Generic 타입을 추적해서 만드는 것이 아니라, 컴파일러가 실행 전에 검사하고 필요한 바이트코드를 생성함으로써 만들어집니다.
그런데 궁금하지 않나요? 개발자가 더이상 형 변환코드를 작성하지 않아도 된다는데…
Deep Dive : Generic 타입을 지정했는데도 컴파일러가 왜 checkcast가 필요할까?
개발자는 String s = list.get(0);처럼 형변환 없이 값을 꺼냅니다.
그런데 컴파일러는 왜 바이트코드에 다시 String 타입 검사를 넣는 걸까요?
그 이유는 OpenJDK의 ArrayList 소스코드[ 링크 클릭시 바로 들어가요~]를 보면 알 수 있습니다. OpenJDK의 ArrayList 구현을 보면 실제 요소 저장 공간은 E[]가 아니라 Object[]로 선언되어 있습니다.
// OpenJDK ArrayList 내부 구현의 일부
transient Object[] elementData;
public E get(int index) {...}
private Object[] grow(int minCapacity) { ... }
즉, ArrayList<String>으로 선언하더라도 내부 배열에는 객체 참조가 Object 형태로 저장됩니다. 내부적으로 ArrayList의 핵심 동작인 동적 크기 조절, 배열 복사 등 전체가 Object 배열 기반으로 굴러간다는 것!
ArrayList<String>으로 선언하더라도, 내부적으로 실제 요소가 저장되는 공간은 E[]가 아니라 Object[]입니다.
단순히 저장 공간만 그런 것이 아닙니다.
데이터를 추가하다가 공간이 부족해져서 내부적으로 배열 크기를 늘릴 때(grow), 기존 배열을 새로운 배열로 복사할 때(Arrays.copyOf), 심지어 toArray()로 배열을 반환할 때 등 내부의 모든 핵심 동작이 Object 타입 배열을 기준으로 수행됩니다….
그래서 get() 메서드가 소스 코드 레벨에서는 E를 반환하는 것처럼 보이지만, Type Erasure 이후 바이트코드 관점에서는 단순히 거대한 Object 배열에서 Object 하나를 툭 꺼내 반환하는 형태가 됩니다.
- 내부
Object[]에서 값 조회 Object타입으로 반환checkcast String(여기서 바이트코드 상의 캐스팅 수행)String변수에 안전하게 할당
즉, 저장부터 크기 확장, 복사까지 내부 생태계 자체가 Object 기반으로 작동하기 때문에, 반환된 값을 특정 타입의 변수(String value)에 대입하려면 메모리와 바이트코드 레벨에서 물리적인 다운캐스팅이 필수적입니다.
과거에는 개발자가 직접 손으로 썼던 이 형변환을, 이제는 컴파일러가 안전성을 보장하며 대신 써주는 구조인 것입니다.
정리하자면은
Generic 도입에 필요한 타입 검사와 바이트코드 변환은 주로 javac가 담당합니다.
JVM은 원래의 타입 인자를 추적하지 않고, 컴파일러가 생성한 바이트코드를 기존 방식대로 실행합니다.
maintain binary compatibility
그래서 런타임때 JVM이 “list.add(10)” 코드에 대해서 판단을 하지 않습니다.
이 판단은..
런타임때 JVM이 하는게 아닌, 사전에 Compiler가 하게 됩니다.
if (list instanceof ArrayList<String>) {}
그래서 런타임 때 JVM은 ArrayList이라는 타입 자체를 알지 못합니다.
Type Erase에 의해 제너릭 타입 정보는 제거되었기 때문입니다.

그래서~ 제네릭을 이해하는 건 컴파일러, 실행하는 건 JVM
JLS 4.7의 Reifiable Types 섹션에는 다음과 같은 문장이 있습니다.
*"Because some type information is erased during compilation, not all types are available at run time"*
(컴파일 과정에서 일부 타입 정보가 제거되므로, 모든 타입 정보가 런타임에 존재하는 것은 아니다…)
런타임에 동작하는 JVM은 ArrayList<String>이라는 구체적인 타입 자체를 알지 못합니다.
컴파일 과정에서 Type Erasure에 의해 파라미터 정보(<String>)가 날아갔기 때문입니다.
JVM은 그저 바이트코드에 적혀 있는 대로 “아, 여기서는 String으로 캐스팅하라는 명령(checkcast)이 있네” 하고 수동적으로 실행할 뿐입니다.
이것이 바로 런타임에 instanceof List<String>을 사용할 수 없는 이유입니다.
JVM 입장에서는 확인할 수 있는 런타임 타입 인자 정보가 아예 존재하지 않기 때문입니다. (if (list instanceof ArrayList<?>) 와 같이 와일드카드나 원시 타입으로만 검사할 수 있습니다.)
요약하자면:
제네릭의 타입 안정성은 런타임의 JVM이 똑똑해서 판단해 주는 것이 아닙니다.
사전에 컴파일러(javac)가 철저하게 타입을 검사하고, 오류가 있으면 컴파일을 막는 강제성, 그리고 안전함이 입증된 후 필요한 곳에 정확히 형변환 코드를 자동 삽입해 주는 치밀함 덕분입니다.
지금까지 살펴본 것처럼 Java의 Generic은 JVM이 런타임에 타입을 추적하는 기능이 아니라, 컴파일러가 타입을 검사하고 필요한 바이트코드를 생성하여 타입 안정성을 보장하는 문법입니다.
덕분에 개발자는 형변환을 직접 작성하지 않아도 되고, 많은 타입 오류를 프로그램 실행 전에 발견할 수 있게 되었습니다.
그런데 Generic을 조금만 사용하다 보면 또 하나의 의문이 생깁니다.
왜 Dog는 Animal의 자식인데, List<Dog>는 List<Animal>이 아닐까요?
다음 글에서는 이 질문을 시작으로 불공변(Invariance), 와일드카드(?), extends, super, 그리고 PECS까지 이어지는 Generic의 핵심 개념을 살펴보겠습니다.
References
- Java Language Specification (JLS) §4.6 Type Erasure
https://docs.oracle.com/javase/specs/jls/se21/html/jls-4.html#jls-4.6 - Java Language Specification (JLS) §4.7 Reifiable Types
https://docs.oracle.com/javase/specs/jls/se21/html/jls-4.html#jls-4.7 - OpenJDK ArrayList.java (Object[] elementData etc..... )
https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.base/share/classes/java/util/ArrayList.java