Back to posts

제네릭은 런타임에 소거 되지만, 런타임에 리플렉션이 제네릭 정보를 알수 있는 이유

제네릭 소거와 바이트코드 메타데이터, 리플렉션의 관계를 정리.

2024년 10월 12일

제네릭은 런타임에선 소거된다

제네릭은 런타임에서 소거된다. 이유는 JDK 1.5에 제네릭이 추가되면서 하위호환을 위해서다. 자세한 이유와 구조적 한계는 아래 글을 참고했다.

소거 때문에 생기는 대표적인 제약

소거 때문에 아래 같은 제약이 생긴다.

  • instanceof List<String> 같은 체크는 불가능하다.
  • new T[]는 만들 수 없다. (generic array creation)
  • static T field처럼 static에서 타입 파라미터를 쓸 수 없다.
  • catch (T t) 같은 예외 캐치는 허용되지 않는다.

리플렉션은 제네릭 정보를 알 수 있다

아래처럼 리플렉션으로 myMethod의 반환 타입을 출력하면 정상적으로 List<String>이 출력된다.

public class MyClass {
    List<String> myMethod() {
        return null;
    }
}
 
public static void main(String[] args) throws NoSuchMethodException {
    Class<MyClass> clazz = MyClass.class;
    Method hello = clazz.getDeclaredMethod("myMethod", null);
    Type genericReturnType = hello.getGenericReturnType();
    System.out.println(genericReturnType);  // java.util.List<java.lang.String>
}

갑작스런 의문

이때 갑자기 "리플렉션으로는 제네릭을 알 수 있을까?"라는 의문이 들었다. 리플렉션은 Class 기반 API이고 바이트코드를 읽는다. 런타임에서 동작하니 제네릭을 못 쓰는 게 아닐까 싶었다. (자바 플랫폼에 대한 지식이 낮아서 생긴 의혹이다)

바이트코드로 확인한 제네릭 메타데이터

결론은 바이트코드에 제네릭 정보가 남아있기 때문이다. 아래는 javap로 확인한 바이트코드다. java.util.List<java.lang.String> myMethod(); 부분이 보인다.

javap

javap -v -s MyClass
Classfile /Users/bonjugi/IdeaProjects/temp/out/production/temp/MyClass.class
  Last modified 2024. 10. 12.; size 397 bytes
  SHA-256 checksum d6457f46eba7520d1e30f7bd915aa4b8733c9fee241156a66172b554c6be2fb5
  Compiled from "MyClass.java"
public class MyClass
  minor version: 0
  major version: 66
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #7                          // MyClass
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Class              #8             // MyClass
   #8 = Utf8               MyClass
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               LMyClass;
  #14 = Utf8               myMethod
  #15 = Utf8               ()Ljava/util/List;
  #16 = Utf8               Signature
  #17 = Utf8               ()Ljava/util/List<Ljava/lang/String;>;
  #18 = Utf8               SourceFile
  #19 = Utf8               MyClass.java
{
  public MyClass();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 4: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LMyClass;
 
  java.util.List<java.lang.String> myMethod();
    descriptor: ()Ljava/util/List;
    flags: (0x0000)
    Code:
      stack=1, locals=1, args_size=1
         0: aconst_null
         1: areturn
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       2     0  this   LMyClass;
    Signature: #17                          // ()Ljava/util/List<Ljava/lang/String;>;
}
SourceFile: "MyClass.java"

descriptor와 Signature가 둘 다 표기돼 있다. 둘의 차이는 제네릭 정보가 있느냐 없느냐다. 정확히는 모르겠지만, 제네릭이 1.5에 추가되며 하위호환을 유지해야 했고 그 흔적이 스펙에 남은 걸로 보인다. 리플렉션 API도 getType과 1.5에 추가된 getGenericType이 따로 있다.

Oracle JVM 스펙 문서에도 1.5에 추가됐다고 쓰여 있다.

IDE

IDE에서도 바이트코드를 볼 수 있다. javap와 포맷이 다르지만 정보는 같다. 오히려 가독성은 더 좋다.

// class version 66.0 (66)
// access flags 0x21
public class MyClass {
 
  // compiled from: MyClass.java
 
  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 4 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this LMyClass; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1
 
  // access flags 0x0
  // signature ()Ljava/util/List<Ljava/lang/String;>;
  // declaration: java.util.List<java.lang.String> myMethod()
  myMethod()Ljava/util/List;
   L0
    LINENUMBER 7 L0
    ACONST_NULL
    ARETURN
   L1
    LOCALVARIABLE this LMyClass; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1
}

결론

제네릭은 런타임에 소거된다. 하지만 리플렉션은 바이트코드의 메타데이터를 읽기 때문에 제네릭 정보를 알 수 있다. 리플렉션은 객체를 로딩하는 게 아니라 클래스 메타데이터를 읽는다는 점에서 관계가 없다.

제네릭은 1.5에 추가되었고 하위호환을 유지한다. 그래서 JVM 스펙 곳곳에 1.5 전후 흔적이 남아 있는 듯하다.