본문 바로가기

Develop/JAVA

자바의 제네릭 타입 소거, 리스트에 관하여 (Java Generics Type Erasure, List)

728x90

1. 자바의 제네릭과 로타입 (Java Generics and Raw Type)

public class<T> Example{
	private T member;
}

위와 같이 클래스 및 인터페이스 선언에 타입 매개변수 T 가 사용되면 제네릭 클래스, 제네릭 인터페이스라고 말하는데, 이때 사용된 이 클래스 Example<T> 를 제네릭타입이라고 이야기한다.

제네릭을 사용하면 로타입이라는 개념이 나오는데, 로타입은 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않았을 때를 의미한다 즉, 위 제네릭 타입Example<T>Example 로만 선언하여 사용했을 경우를 말한다.

public class Example<T> {
    private T member;

    public Example(T member) {
        this.member = member;
    }

    public static void main(String[] args) {
        Example<Integer> parameterType = new Example<>(1);
        Integer parameterTypeMember = parameterType.member;
        System.out.println(parameterTypeMember);

        Example rawType = new Example(1);
        Object rawTypeMember = rawType.member;
        System.out.println(rawTypeMember);
    }
}

위 코드는 제네릭 파라미터 타입과 로타입을 사용한 경우이다. 하지만 로타입은 사용하지말자.

제네릭의 장점은 컴파일 타임에 타입에 대한 안정성을 보장받을 수 있다는 점이다. 제네릭 타입으로 선언한 변수는 컴파일 타임에 타입 체크를 하기 때문에 런타임에서 ClassCastException과 같은 UncheckedException을 보장 받을 수 있다.

반면 아래와 같이 로타입으로 사용될 경우에는 제네릭을 사용했을 때의 안정성과 표현력이라는 장점을 발휘할 수 없기 때문에, IDE 에서도 "Raw use of parameterized class 'Example' " 라는 경고를 주는 것을 볼 수 있다.

그럼 로타입이 나오게 된 이유는 무엇일까? 로타입이 나오게 된 이유는 제네릭의 특징인 소거와 관련이 있다.

제네릭은 JDK5 에서 도입이되었다. 버그를 줄이기 위한 목적과, 다른 추상화된 타입에 대한 레이어를 추가하기 위해서이다.
이에 따라 제네릭을 도입한 JDK5는 기존의 코드를 모두 수용하면서 제네릭을 사용하는 새로운 코드와의 호환성을 유지 했어야 했다. 따라서 코드의 호환성 때문에 : 로타입의 지원 + 제네릭을 구현할 때 소거(erasure)하는 방식을 이용하였다.

 

2. 제네릭의 타입소거 (Generics Type Erasure)

소거란 원소 타입을 컴파일 타임에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것이다. 다른 말로는 컴파일 타임에만 타입에 대한 제약 조건을 적용하고, 런타임에는 타입에 대한 정보를 소거하는 프로세스이다.

List<Object> ol = new ArrayList<Long>(); // 컴파일 에러
ol.add("타입이 달라 넣을 수 없다");

다음과 같은 상황에서 컴파일시에 타입 오류를 바로 알 수 있다 (리스트도 제네릭 타입으로 구현되어있기 때문에)

Java 컴파일러는 타입소거를 아래와 같이 적용한다.

  • 제네릭 타입( Example<T>) 에서는 해당하는 타입 파라미터 (T) 나 Object로 변경해준다. 
    Object로 변경하는 경우는 unbounded 된 경우를 뜻하며, 이는 <E extends Comparable<E>>와 같이 bound를 해주지 않은 경우를 의미한다.
    따라서 이 소거 규칙에 대한 바이트코드는 제네릭을 적용할 수 있는 일반 클래스, 인터페이스, 메서드에만 해당된다.
  • 타입 안정성 보존을 위해 필요하다면 type casting을 넣어준다.
  • 확장된 제네릭 타입에서 다형성을 보존하기 위해 bridege method를 생성한다.
public static <E> boolean containsElement(E[] elements, E element) {
	for (E e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

실제로 이렇게 선언되어있는 제네릭 메서드의 경우 선언 방식에 따라 컴파일러가 타입파라미터 E를 변경한다.

public static boolean containsElement(Object[] elements, Object element) {
	for (Object e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

컴파일러는 첫번째 규칙에 따라 타입 파라미터 E가 bound하게 선언되어있지 않기 때문에 타입 파라미터 EInteger로 우선적으로 바꾼다.

이때 만약 프로그래머가 continasElement(Integer[], Integer) 형식으로 해당 메서드를  사용했다면, 컴파일러 내부에서 두번재 규칙에 따라 타입 안정성 보존을 위해 Object -> Integer로의 타입 캐스트 코드를 넣어주어 제네릭의 타입 안정성을 보장해주는 것이다. 

반면 로타입일 경우에는, 타입 파라미터가 정해져있지 않아. Object로 변환한 것에서 끝난다.

더보기

반면 타입 파라미터 E를 bound하게 설정한다면 

public static <E extends Comparable<E>> void containsElement(E[] elements) {
	for (E e : elements) {
		System.out.println("%s", e);
	}
}

타입이 소거될때 Object로 바뀌는 것이 아닌 한정시킨 타입인 Comparable로 변환된다.

public static void containsElement(Comparable[] elements) {
	for (Comparable e : elements) {
		System.out.println("%s", e);
	}
}

추가로 세번째 규칙에 대해서 언급하자면 java compiler는 제네릭의 타입안정성을 위해 Bridge Method도 만들어낼 수있다. Bridge Method는 java 컴파일러가 컴파일 할 때 메서드 시그니처가 조금 다르거나 애매할 경우에대비하여 작성된 메서드이다. 이 경우는 파리미터화된 클래스나 인터페이스를 확장한 클래스를 컴파일 할 때 생길 수 있다.

public class IntegerStack extends Stack<Integer> {
    public Integer push(Integer value) {
        super.push(value);
        return value;
    }
}

 

Java 컴파일러는 다형성을 제네릭 타입 소거에서도 지키기 위해, IntegerStackpush(Integer) 메서드와 Stack의 push(Object) 메서드 시그니처 사이에 불일치가 없어야 했다. 따라서 컴파일러는 런타임에 해당 제네릭 타입의 타입소거를 위한 Bridge 메서드를 만드는데 아래와같은 방식으로 만든다.

public class IntegerStack extends Stack {
    // Bridge method generated by the compiler
     
    public Integer push(Object value) {
        return push((Integer)value);
    }
 
    public Integer push(Integer value) {
        return super.push(value);
    }
}

extends Stack<Integer> -> Stack 으로  변경한 것을 볼 수 있으며, push에 parameter를 Object가 아닌 Integer로 맞추기 위한 도우미 메서드가 늘어났다는 것을 알 수 있다. 결과적으로 Stack 클래스의 push method는 타입소거를 진행한 후에, IntegerStack 클래스의 원본 push 방법을 사용하게 한다.

 

3. 제네릭에서는 리스트를 사용하자

실체화 불가 타입(Non-Reifiable Type)에 대한 설명이 있다 runtime에 타입 정보를 갖고있지 않고, compile-time에 타입 소거가 되는 타입을 의미한다고 한다. 이에 반대하는 개념으로는 실체화(reifiable)가 있다. 이는 타입 정보를 런타임에 완벽하게 사용할 수 있는 유형으로, 소거와는 반대 개념이다. 

실체화 불가 타입의 대표적인 예시로는 List<String> List<Number> 와 같은 리스트가 있고, 실체화 타입의 대표적인 예시로는 String[], Number[] 와 같은 배열이 있다.

이펙티브 자바에서는 타입소거라는 특성이 있는 제네릭은 실체화 불가 타입인 List와 함께 사용하기를 권장한다. Array 에서는 런타임에 타입정보를 갖고있는데, 제네릭을 사용하면 타입이 소거되기 때문에 해당 제네릭 변수에 대한 정보를 런타임에 갖고있지 않기 때문이다.

사실 이 부분은 이펙티브 자바를 읽으면서 스터디에서 했던 제네릭과 관련한 이야기를 예시로 이야기 하려한다.

관련 이슈 : https://github.com/Java-Bom/ReadingRecord/issues/88

 

[아이템 32] toArray · Issue #88 · Java-Bom/ReadingRecord

193p 마지막 코드에서 toArray를 바로 String[]배열로 받는건 되는데 왜 pickTwo를 거쳐서 String[]로 받는건 안될까?

github.com

static <T> T[] pickTwo(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return toArray(a, b);
    case 1:
      return toArray(b, c);
    case 2:
      return toArray(a, c);
  }
  throw new AssertionError();
}

static <T> T[] toArray(T... args) {
  return args;
}

public static void main(String[] args) {
  String[] strings = pickTwo("좋은", "빠른", "저렴한");  
}

위 코드에서 ClassCastException이 터진다. 반면 아래와 같이 pickTwo 메서드를 사용했을 때는 ClassCastException이 터지지 않는다. 왜그럴까? 

static <T> List<T> pickTwoList(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return Arrays.asList(a, b);
    case 1:
      return Arrays.asList(b, c);
    case 2:
      return Arrays.asList(a, c);
  }
  throw new AssertionError();
}

List<String> strings = pickTwoList("좋은", "빠른", "저렴한");

문제를 좀 더 단순히 해보자.

static <T> List<T> pickList(T a) {
        return Arrays.asList(a);
    }

    static <T> T[] toArray(T... args) {
        return args;
    }

    static <T> T[] pickArray(T a) {
        return toArray(a);
    }

    public static void main(String[] args) {
        List<String> stringList = pickList("자바봄");
        String[] stringArray = pickArray("자바봄");
   }
}

위 코드에서 pickList를 지나 pickArray를 실행하면 runtimeException이 터지는 것을 볼 수 있다. 내용은 [Ljava.lang.Object; cannot be cast to [Ljava.lang.String; 이다 무엇이 문제일까?

정답은 Array와 List의 실체화에 있었다. 

코드를 이해해보자. 위에서는 제네릭 타입추론이 2 depth가 들어간다. pickArray에서 타입 매개변수 TString과 대응하여 들어가기 때문에 pickArray(String) toArray(String...) 이 들어갈 것으로 예상한다. 하지만 실제 런타임에 확인해보면 pickArray(String),  toArray(Object...)가 들어간다. 그 이유는 위에서 제네릭 타입추론을 이야기할 때 우선적으로 bounded가 아닌 매개변수일경우 컴파일러가 Object로 대체한다는 이야기와 대응된다. pickArray에서는 main에 있는 String 타입으로 타입추론이 가능했으나 toArray는 제네릭 타입을 바라보고 있으므로 타입추론을 Object로 하여 런타임에 타입정보를 갖고있게 된다.

따라서 pickArrayString[] 으로 타입캐스팅을 준비하였으나, 위에서 말한 것처럼 런타임에 toArray가 갖고있는 타입은 Object[] 이므로 런타임에 (String[]) Object[] 와 같은 형식으로 강제적으로 타입캐스팅을 하다가 ClassCastException이 발생한다. (String 배열은, Object 배열의 하위 타입이 아니기 때문에 Casting이 되지 않는다.)

반면에 pickList를 호출할때는 컴파일이 성공한다. 이 이유는 List가 실체화 불가타입이었기 때문이다. 컴파일 타임에 캐스팅할 정보가 이미 결정이되고, 런타임때에는 제네릭의 소거라는 특성 때문에 Java 컴파일러가 타입에 맞는 캐스팅 방식을 올바르게 추가해줘서 캐스팅 에러가 나지 않는다.

정리하자면, 리스트 + 제네릭은 컴파일 타임에 결정된 캐스팅 정보가 올바르기 때문에 통과가 된다. 제네릭의 장점인 컴파일 타임에 타입이 안맞는 것을 체크해주는 걸 List에서도 수행하기 때문이다. 반면 배열 같은 경우엔 타입 캐스팅을 런타임에 결정하기 때문에 문제가 생긴 것이다. 

 

조금 길었지만 사실 결론은 간단하다. 제네릭은 타입소거라는 특징으로 컴파일러가 컴파일 타임에 타입을 추론할 수 있으며, 이런 타입 추론 기능을 강력하게 사용하기 위해서는 런타임에 타입을 추론하는 Array 대신에 컴파일타임에 타임을 추론하는 List를 함께 사용해야 안정성을 보장 할 수 있다는 것이다.

 

참고 글

https://www.baeldung.com/java-generics

Effecitve Java 3/E - Chapter 5: Generics 

https://docs.oracle.com/javase/tutorial/java/generics/erasure.html

https://www.baeldung.com/java-type-erasure