Last Modified 2020.7.1

제네릭

제네릭은, 컴파일 타임에 좀 더 엄격한 타입 검사와 범용 알고리즘 구현을 위해 자바 5에 도입되었다. 다음 예제는 제네릭의 형태를 잘 보여준다. Box 클래스는 기본 타입을 제외한 모든 타입 레퍼런스를 저장할 수 있는 상자를 정의한다.

package examples;

public class Box<T> {

  private T t;

  public void set(T t) {
    this.t = t;
  }

  public T get() {
    return t;
  }

}

T를 타입 파라미터라 부른다. T는 클래스 몸체 어디든지 사용될 수 있다. Box 객체를 생성할 때 T의 타입을 지정해야 한다.

Box<Integer> intBox = new Box<Integer>();

T를 대체한 Integer를 타입 아규먼트라 부른다.

자바 7부터 도입된 타입 추론 기능으로 아래처럼 줄여 쓸 수 있다.

Box<Integer> intBox = new Box<>();

타입 파라미터는 알파벳 대문자 하나로 표기한다. 어느 문자도 상관없지만, 자바 API에서 제네릭의 타입 파라미터는 다음 관례를 지킨다.

  • 요소(Element)는 E
  • 키(Key)는 K
  • 숫자(Number)는 N
  • 타입(Type)은 T
  • 값(Value)은 V
  • 타입 파리미터가 하나 이상 필요할 때, 2번째 3번째 4번째 이름은 각각 S, U, V

컴파일 타임에 더 엄격한 타입 검사가 무엇인지 다음 예제로 확인하자.

package examples;

public class Test {

  public static void main(String ... args) {
    Box<Integer> intBox = new Box<>();
    intBox.set(1):
    System.out.println(intBox.get());
    intbox.set("2");
    System.out.println(intBox.get());
  }

}
Test.java:9: error: incompatible types: String cannot be converted to Integer
    intBox.set("2");
                ^
1 error

예제는 컴파일되지 않는다. Integer 타입만 저장할 수 있는 상자에 String 타입을 넣을 수 없다는 컴파일 에러를 일으킨다.

로(Raw) 타입

로(Raw) 타입은 타입 아규먼트가 없는 제네릭 클래스나 인터페이스를 뜻한다. 제네릭이 아닌 클래스나 인터페이스를 로 타입이라 하지 않는다. 어쩔 수 없이 사용하는 경우 외에 로 타입은 사용하지 말아야 한다. 컬렉션은 자바 5 이전에 제네릭이 아니었기에, 레거시 코드에서 로 타입으로 사용될 수 있다.

Box<Integer> intBox = new Box<Integer>();
Box rawBox = new Box();//Box는 제네릭 Box<T>의 로 타입

로 타입은 약속된 동작이 있다. 위 예에서 new Box()로 생성된 Box의 메소드 파라미터와 리턴 타입은 모두 Object로 결정된다.

이전 버전과 호환을 위해,
파라미터화된 타입을 로 타입에 할당할 수 있다.

Box<Integer> intBox = new Box<Integer>();
Box rawBox = intBox;

하지만 제네릭의 엄격한 타입 검사는 더이상 기대할 수 없게 된다. 예제를 통해 확인하자.

package examples;

public class Test {

  public static void main(String ... args) {
    Box<Integer> intBox = new Box<>();
    intBox.set(1):
    System.out.println(intBox.get());
    Box rawBox = intBox;
    rawBox.set("2");
    System.out.println(rawBox.get());
  }

}

컴파일 타임에 타입 검사를 하지 않은 예제는 컴파일되고, 실행하면 값을 출력한다.

1
2

로 타입을 파라미터화된 타입에 할당할 수 있다.

Box rawBox = new Box();
Box<Integer> intBox = rawBox;
package examples;

public class Test {

  public static void main(String ... args) {
    Box<Integer> intBox = new Box<>();
    intBox.set(1);
    System.out.println(intBox.get());
    Box rawBox = new Box();
    intBox = rawBox;        
    intBox.set("2");
    System.out.println(intBox.get());
  }

}
Test.java:11: error: incompatible types: String cannot be converted to Integer
    intBox.set("2");
                ^
1 error

이 경우는 컴파일 타임에 타입 검사를 한다.

제네릭 메소드

제네릭 메소드는 자신만의 타입 타라미터를 가진다. 타입 파라미터의 스코프는 그것이 선언된 메소드로 제한된다. 제네릭 메소드뿐만 아니라 일반 클래스 생성자도 자신만의 타입 파라미터를 갖도록 선언할 수 있다. 제네릭 메소드는 메소드 반환 형식 앞에 꺾쇠괄호 안의 타입 파라미터 리스트가 나타나는 모양을 갖는다.

package examples;

public interface Pair<K,V> {

  public K getKey();

  public V getValue();

}
package examples;

public class IdPasswdPair<K,V> implements Pair<K,V> {

  private K key;
  private V value;

  public IdPasswdPair(K key, V value) {
    this.key = key;
    this.value = value;
  }

  @Override
  public K getKey() {
    return key;
  }

  @Override
  public V getValue() {
    return value;
  }

}
package examples;

public class Util {

  //제네릭 메소드
  public static <K,V> boolean compare(Pair<K,V> p1, Pair<K,V> p2) {
    return p1.getKey().equals(p2.getKey()) && p1.getValue().equals(p2.getValue());
  }

  public static void main(String[] args) {
    if (args.length == 2) {
      Pair<String, String> inputData = new IdPasswdPair<>(args[0], args[1]);
      Pair<String, String> storedData = new IdPasswdPair<>("xman31", "1987qwertY");
      boolean isSame = Util.compare(inputData, storedData);
      if (isSame) {
        System.out.println("로그인에 성공했습니다.");
      } else {
        System.out.println("로그인에 실패했습니다. 아이디와 패스워드를 확인하세요");
      }
    } else {
      System.out.println("실행 방법: java examples.Util '아이디' '패스워드'");
    }
  }

}

compare 메소드를 호출하는 완전한 구문은 다음과 같다.

boolean isSame = Util.<String, String>compare(inputData, storedData);

타입 추론 기능으로 꺾쇠괄호 사이에 타입을 지정하지 않고 제네릭 메소드를 일반 메소드처럼 호출할 수 있다.

boolean isSame = Util.compare(inputData, storedData);

제약된 타입 파라미터

숫자에 대해 작동하는 메소드는 Number 또는 Number 하위 클래스의 인스턴스만 허용하려고 할 수 있다. 한계를 지정하는 타입 파라미터를 제약된 타입 파라미터라 한다. 제약된 타입 파라미터를 선언하려면 파라미터 이름과 extends 키워드, 그리고 상위 제약 타입을 나열한다.

package examples;

public class Box<T extends Number> {

  private T t;

  public void set(T t) {
    this.t = t;
  }

  public T get() {
    return t;
  }

}

Box 클래스 선언에서 <T extends Number>를 타입 파라미터 섹션이라 한다. <T extends java.io.Serializable>처럼 제약이 인터페이스일 수 있다. 제약이 인터페이스라도 extends 키워드를 사용한다. 타입 파라미터 섹션에서 제약을 하나 이상 둘 수 있다. 타입 파라미터 리스트는 하나 이상의 인터페이스, 또는 클래스 하나와 하나 이상의 인터페이스로 구성할 수 있다. 클래스 하나와 인터페이스가 섞여 있다면 타입 파라미터 리스트에서 클래스를 맨 처음 나오게 해야 한다.

<T extends Aclass & Binterface & Cinterface>

제네릭 메소드와 제약된 타입 파라미터

package examples;

public class GenericMethodsWithBoundedTypeParametersTest {

  public static <T extends Number & Comparable<T>> int countGreaterThan(T[] array, T elem) {
    int count = 0;
    for (T e : array) {
      if (e.compareTo(elem) > 0) {
        ++count;
      }
    }
    return count;
  }

  public static void main(String[] args) {
    Integer[] arr = {1,2,3,4,5,6,7,8,9,10};
    int count = countGreaterThan(arr,7); //7보다 큰 배열 요소 수
    System.out.println(count);
  }

}
3

Comparable<T> 인터페이스는 단 하나의 메소드만 정의한다.

package java.util;

public interface Comparable<T> {
  public int compareTo(T o);
}

Comparable<T> 인터페이스의 compareTo 메소드의 구현 내용은 약속되어 있다. a.compareTo(b)와 같이 실행된다고 가정하면, 메소드는 다음 값을 반환해야 한다.

if a == b, 0.
if a > b, 1.
if a < b, -1. 

Integer 클래스는 Number 클래스를 상속하며 Comparable<Integer> 인터페이스를 구현한다. Double, Long, Float, Short, Byte도 Number 클래스를 상속하며 Comparable<Integer> 인터페이스를 구현한다.

제네익의 상속

다음 메소드를 가정하자:

public void boxTest(Box<Number> n) { /* ... */ }

이 메소드에 Box<Integer>나 Box<Double>를 전달할 수 있을까? 없다. Box<Integer> 와 Box<Double>은 Box<Number>의 서브 타입이 아니기 때문이다. A와 B가 관계가 있건 없건 상관없이 MyClass<A>와 MyClass<B>는 관계가 없다. MyClass<A>와 MyClass<B>의 부모는 Object 클래스다.

Number와 Integer는 상속 관계다.
Number Integer

Number와 Integer가 상속 관계라 하더라도 Box<Number> 와 Box<Integer>는 아무런 관계가 없다.
Box<Number> Box<Integer>

Box<Number> 와 Box<Integer>는 모두 Object를 상속한다.
Object Box<Number> Box<Integer>

List<E>는 Collection<E>을 상속한다. ArrayList<E>는 List<E>를 구현한다. 따라서 List<String>은 Collection<String>의 서브 타입이고, ArrayList<String>는 List<String>의 서브 타입이다. 타입 아규먼트를 변경하지 않으면, 상속 관계는 보존된다.

타입 추론

타입 추론은 자바 컴파일러가 메소드 호출과 그에 상응하는 선언을 보고 호출에 적용되는 타입 아규먼트를 결정하는 능력이다.

public static <U> void addBox(U u, List<Box<U>> boxes) {...}

제네릭 메소드 addBox는 U라는 타입 파라미터를 정의하고 있다. addBox라는 제네릭 메소드를 호출하려면 다음과 같이 타입 파라미터를 지정한다.

BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);

일반적으로 컴파일러는 제네릭 메소드 호출의 타입 파라미터를 유추할 수 있다. 따라서 대부분의 경우 타입 파라미터를 지정할 필요 없다. 타입 추론으로 인해 제네릭 메소드를 일반 메소드처럼 호출할 수 있다.

BoxDemo.addBox(Integer.valueOf(10), listOfIntegerBoxes);

컴파일러가 컨텍스트로부터 타입 아규먼트를 추론할 수 있는 한, 제네릭 클래스의 생성자 호출에 필요한 타입 파라미터를 다이아몬드로 바꿀 수 있다. <>를 비공식적으로 다이아몬드라 한다. 예를 들어, 다음 변수 선언을 가정하자.

Map<String, List<String>> myMap = new HashMap<String, List<String>>();

생성자의 파라미터화된 타입을 다이아몬드로 바꿀 수 있다.

Map<String, List<String>> myMap = new HashMap<>();

생성자도 제네릭 메소드처럼 자신만의 타입 파라미터를 가질 수 있다.

class MyClass<X> {
  <T> MyClass(T t) {
    //...
  }
}

다음 MyClass로부터 인스턴스를 생성하는 코드다.

MyClass<Integer> myObject = new MyClass<>("");

컴파일러는, X를 Integer로, T를 String으로 타입을 추론한다. 자바 7 이전에는 다음과 같이 타입 아규먼트를 지정해야 했다.

MyClass<Integer> myObject = new MyClass<Integer>("");

추론 알고리즘은 아규먼트와 대상 타입을 사용해 추론한다.

대상 타입
Integer i = Integer.parseInt("10");
parseInt 메소드의 반환 타입은 int이지만, 이 값을 저장하는 대상인 i의 타입은 Integer이다. 즉, 대상 타입은 Integer가 된다.

다음은 Collections.emptyList 메소드 선언이다.

static <T> List<T> emptyList();

타입 추론으로 Collections.emptyList 메소드를 아래와 같이 호출할 수 있다.

List<String> listOne = Collections.emptyList();

List<String>이 대상 타입이다. 컴파일러는, emptyList 메소드가 List<T> 타입을 반환하기에, T를 String이라 추론한다. 자바 7과 자바 8 모두 이렇게 추론한다. 자바 7 이전에서는 다음과 같이 T의 값을 지정해야 한다.

List<String> listOne = Collections.<String>emptyList();

다른 상황을 보자.

void processStringList(List<String> stringList) {
  //process
}

자바 7에서는 다음 문이 컴파일되지 않는다.

processStringList(Collections.emptyList());

자바 7 컴파일러는 다음과 같은 오류 메시지를 생성한다.
List<Object> cannot be converted to List<String>
컴파일러에는 타입 아규먼트 T의 값이 필요하므로 Object 값으로 시작한다. 따라서 Collections.emptyList를 호출하면 List<Object> 타입의 값이 반환되며, 이는 processStringList 메소드와 호환되지 않는다. 자바 7에서는 다음과 같이 타입 아규먼트의 값을 지정해야 한다.

processStringList(Collections.<String>emptyList());

자바 8에선 대상 타입이 메소드 아규먼트를 포함하도록 확장되었다. 자바 8 컴파일러는, processStringList 메소드가 List<String> 타입의 아규먼트가 필요하므로, 타입 파라미터 T를 String으로 추론한다. 자바 8은 다음 코드를 컴파일한다.

processStringList(Collections.emptyList());

와일드카드

제네릭 코드에서 물음표(?)를 와일드카드라 부른다. 와일드카드는 알려지지 않은 타입을 표현한다. 와일드카드는 제네릭 메소드 호출이나 제네릭 클래스의 인스턴스 생성에 필요한 타입 아규먼트로 사용되지 않는다.

상한 제약 와일드카드

Number나 Number의 서브 타입(Integer, Double 및 Float)에서 작동하는 메소드를 작성하려면, List<? extends Number>로 지정한다. List<Number>는 List<? extends Number>보다 더 제한적이다. 전자는 오로지 타입 Number 리스트와 일치하지만, 후자는 타입 Number 또는 Number 하위 클래스 목록과 일치하기 때문이다.

import java.util.Arrays;
import java.util.List;

public class WildCardTest {

  public static double sumOfList(List<? extends Number> list) {
    double s = 0.0;
    for (Number n : list) {
      s += n.doubleValue();
    }
    return s;
  }

  public static void main(String[] args) {
    List<Integer> li = Arrays.asList(1,2,3);
    System.out.println("sum = " + sumOfList(li));
  }

}
sum = 6.0
Arrays.asList
Arrays.asList 메소드는 지정된 배열을 변환해 고정 크기 리스트를 반환한다. 고정 크기 리스트라는 데 주의해야 한다. 다음을 실행하면 런타임 에러가 발생한다.
List<Integer> list = Arrays.asList(1,2,3);
list.add(4);//런타임 에러

제약없는 와일드카드

List<?>처럼 쓰는 와일드카드를 제약 없는 와일드카드라 한다. 다음의 경우 제약 없는 와일드카드를 고려할 수 있다.

  • Object 클래스가 제공하는 기능을 사용하는 메소드를 작성할 때.
  • 제네릭 클래스에서 타입 파라미터에 의존하지 않은 메소드를 작성할 때. 예를 들어, List.size 또는 List.clear
  • Class<?>는 자주 사용되는데, Class<T> 클래스의 대부분 메소드가 T에 의존하지 않기 때문이다.

다음 printList 메소드를 보자.

package examples;

import java.util.Arrays;
import java.util.List;

public class PrintListTest {
  
  public static void printList(List<Object> list) {
    for (Object elem : list) {
      System.out.print(elem + " ");
    }
    System.out.println();
  }
  
  public static void main(String[] args) {
    List<Integer> li = Arrays.asList(1,2,3);
    List<String> ls = Arrays.asList("one","two","three");
    printList(li);//컴파일 에러
    printList(ls);//컴파일 에러
  }

}

printList(li);와 printList(ls); 에서 컴파일 에러가 발생한다.
The method printList(List<Object>) is not applicable for the arguments (List<Integer>)
The method printList(List<Object>) is not applicable for the arguments (List<String>)

printList 메소드의 목적은 모든 타입의 리스트를 출력하는 것이겠지만, List<Integer>와 List<String> 타입 리스트를 출력하지 못한다. List<Integer>와 List<String> 모두 List<Object>의 서브 타입이 아니기 때문이다. 파라미터 타입을 List<Object> 에서 List<?>로 수정하면, printList 메소드는 어떤 타입의 리스트도 모두 출력할 수 있다. 어떤 구체적인 타입 A에 대해, List<A>는 List<?>의 서브 타입이다.

package examples;

import java.util.Arrays;
import java.util.List;

public class PrintListTest {

  public static void printList(List<?> list) {
    for (Object elem : list) {
      System.out.print(elem + " ");
    }
    System.out.println();
    System.out.println("List.size() = " + list.size());
  }

  public static void main(String[] args) {
    List<Integer> li = Arrays.asList(1,2,3,4);
    List<String> ls = Arrays.asList("one","two","three");
    printList(li);
    printList(ls);
  }

}
1 2 3 4
List.size() = 4
one two three
List.size() = 3

하한 제약 와일드카드

바닥을 지정하는, 하한 제한 와일드카드는 <? super A> 처럼 사용한다.

Integer나 Integer의 슈퍼 타입(Number, Object)의 리스트에서 작동하는 메소드를 작성하려면, List<? super Integer>를 사용한다.

public static void addNumbers(List<? super Integer> list) {
  for (int i = 1; i <= 10;i++) {
    list.add(i);
  }
}

와일드카드와 서브 타입

Integer가 Number의 서브 타입이라 하더라도, List<Integer>는 List<Number>의 서브 타입이 아니다. 사실상, 두 타입은 서로 관계가 없다. List<Number>와 List<Integer>의 공통 부모는 List<?>이다.
generics-listParent

타입 삭제

제네릭이 그대로 바이트 코드가 되지 않는다. 컴파일 과정에서 제네릭은 다음 과정을 거쳐 사라진다. 이를 타입 삭제(Type Erase)라 한다.

  • 제약이 있는 타입 파라미터는 제약 타입으로, 제약이 없는 타입 파라미터는 Object로 바꾼다.
  • 타입을 안전하게 보존하기 위해 필요하다면 타입 캐스트를 삽입한다.
  • 제네릭 상속에서 다형성 보존이 필요하다면 브리지 메소드를 생성한다.

다음 예는 단일 연결 리스트의 노드를 표현하는 클래스다.

public class Node<T> {

  private T data;
  private Node<T> next;

  public Node(T data, Node<T> next) {
    this.data = data;
    this.next = next;
  }

  public T getData() {
    return data;
  }
  
}

제약이 없는 타입 파라미터는 Object로 변경된다.

public class Node {

  private Object data;
  private Node next;

  public Node(Object data, Node next) {
    this.data = data;
    this.next = next;
  }

  public Object getData() {
    return data;
  }
  
}

Node 클래스를 제약 타입 파라미터를 사용하도록 변경한다.

public class Node<T extends Comparable<T>> {

  private T data;
  private Node<T> next;

  public Node(T data, Node<T> next) {
    this.data = data;
    this.next = next;
  }

  public T getData() {
    return data;
  }
  
}

컴파일러는 제약 타입 파라미터 T를 첫 번째 제약 클래스인 Comparable로 바꾼다.

public class Node {

  private Comparable data;
  private Node next;

  public Node(Comparable data, Node next) {
    this.data = data;
    this.next = next;
  }

  public Comparable getData() {
    return data;
  }
  
}

컴파일러는 제네릭 메소드의 파라미터에서도 타입 파라미터를 삭제한다.

public static <T> int count(T[] anArray, T elem) {
  int cnt = 0;
  for (T e : anArray) {
    if (e.equals(elem)) {
      ++cnt;
    }
  }
  return cnt;
}

제약이 없는 T이기에, 컴파일러는 T를 Object로 바꾼다.

public static int count(Object[] anArray, Object elem) {
  int cnt = 0;
  for (Object e : anArray) {
    if (e.equals(elem)) {
      ++cnt;
    }
  }
  return cnt;
}

다음과 같이 클래스가 선언되었다고 가정하자.

class Shape
class Circle extends Shape
class Rectangle extends Shape

모양을 그리는 제네릭 메소드를 다음과 같이 작성할 수 있다.

public static <T extends Shape> void draw(T shape) { /* ... */ }

컴파일러는 T를 Shape로 대체한다.

public static void draw(Shape shape) { /* ... */ }

타입 삭제의 효과와 브리지 메소드

때로는 타입 삭제로 인해 예상치 못한 상황이 발생한다. 다음 예는 이러한 상황이 어떻게 일어나는지 보여준다.

package examples;

public class Box<T> {

  private T t;

  public void set(T t) {
    this.t = t;
  }

  public T get() {
    return t;
  }
}
package examples;

public class IntBox extends Box<Integer> {

  @Override
  public void set(Integer t) {
    super.set(t);
  }

  @Override
  public Integer get() {
    return super.get();
  }
}
package examples;

public class BridgeTest {

  public static void main(String[] args) {
    IntBox ibox = new IntBox();
    Box box = ibox;
    box.set("Hello World!");//런타임 에러
  }
}

box.set("Hello World!")에서 런타임 에러를 발생한다:

Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
  at IntBox.set(IntBox.java:1)
  at BridgeTest.main(BridgeTest.java:8)

ClassCastException: java.lang.String cannot be cast to java.lang.Integer 란 메시지는 컴파일러가 만든 브리지 메소드 때문이다. Box와 IntBox는 타입 삭제 프로세스를 거쳐 다음과 같이 변경된다.

package examples;

public class Box {

  private Object t;

  public void set(Object t) {
    this.t = t;
  }

  public Object get() {
    return t;
  }
}
package examples;

public class IntBox extends Box {

  @Override
  public void set(Integer t) {
    super.set(t);
  }
  
  //Bridge method generated by the compiler
  public void set(Object t) {
    set((Integer) t);
  }
  
  @Override
  public Integer get() {
    return super.get();
  }
}

타입 삭제 후, 메소드 시그니처가 일치하지 않는다.
Box의 메소드는 set(Object)가 되고 IntBox의 메소드는 set(Integer)이 된다.
따라서 IntBox.set(Integer)은 Box.set(Object)를 오버라이딩하지 않는다.
다형성을 유지하기 위해 자바 컴파일러는 브리지 메소드를 생성하여 서브 타입이 기대한 대로 작동하도록 한다.
이렇듯 제네릭을 상속하거나 구현하는 클래스를 컴파일할 때, 컴파일러는 타입 삭제 프로세스의 일부로서 브리지 메소드라는 복합 메소드를 생성할 수 있다.
IntBox 클래스를 위해, 컴파일러는 set(Integer)를 위한 브리지 메소드를 생성한다.
브리지 메소드에 대해 걱정할 필요는 없지만 익셉션 메시지를 확인할 때 브리지 메소드의 존재를 인식하지 못하면 혼란스러울 수 있다.

구체화할 수 없는 타입

런타임에 완전히 사용 가능한 타입을 '구체화할 수 있는 타입'이라 한다. 구체화할 수 있는 타입은 기본 타입, 제네릭이 아닌 타입, 로(Raw)-타입, 제약 없는 와일드카드다.

구체화할 수 없는 타입으로는 컴파일 타임에 타입 삭제 프로세스에 의해 제거되는 정보를 가진 타입으로 제약 없는 와일드카드를 제외한 제네릭 코드가 그것이다. 예들 들어, 런타임에 JVM은 List<String>와 List<Integer>의 차이를 구별할 수 없다.

힙(Heap) 오염

힙 오염은 파라미터화된 타입의 변수가 파라미터화된 타입이 아닌 객체를 참조할 때 발생한다. 이 상황은 컴파일 시 unchecked 경고를 발생하는 코드를 수행할 때 일어난다. unchecked 경고가 컴파일 또는 런타임에 발생하면, 파라미터화된 타입을 포함하는 동작의 올바름을 검증할 수 없다. 예를 들어 힙 오염은 로-타입과 파라미터화된 타입을 섞어 쓸 때나, unchecked 경고를 내는 타입 캐스트를 수행할 때 발생한다.

모든 코드가 동시에 컴파일되는 정상적인 상황에서 컴파일러는 잠재적인 힙 오염에 대해 unchecked 경고를 낸다. 코드를 분리하여 컴파일하게 되면 힙 오염의 잠재적 위험을 감지하는 게 힘들어진다. 코드가 경고 없이 컴파일되면 힙 오염은 일어나지 않는다.

구체화할 수 없는 타입 파라미터를 가진 가변 아규먼트 메소드의 잠재적 취약점

구체화할 수 없는 파라미터를 가진 가변 아규먼트(varargs) 메소드는 힙 오염을 발생시킬 수 있다.

자바 5에 도입된, 가변 아규먼트 메소드는 아규먼트 수를 조절하여 호출할 수 있게 해 준다.

package examples;

public class VarargsTest {

  public static void sum(int ... a) {
    int sum = 0;
    for (int i : a) {
      sum += i;
    }
    System.out.println(sum);
  }
  
  public static void main(String[] args) {
    sum();
    sum(1);
    sum(1,2,3);
    sum(1,2,3,4);
  }

}
0
1
6
10

구체화할 수 없는 타입 파라미터를 가진 가변 아규먼트 메소드를 생각해 보자.

public static void faultyMethod(List<String> ... l) {
  //..
}

이 메소드를 컴파일할 때, 다음 경고가 faultyMethod 메소드의 선언에서 발생한다.
warning: [varargs] Possible heap pollution from parameterized vararg type l
왜 그럴까?

타입 삭제 프로세스 과정에서 컴파일러는 가변 아규먼트를 배열로 바꾼다. 그러나, 컴파일러는 파라미터화된 타입 배열 생성을 허락하지 않는다. 결국, 메소드 파라미터 l의 타입이 List[]가 된다. 이로 인해 힙 오염이 발생할 수 있다.

제네릭의 제한

제네릭은 다음과 같은 제한이 있다.

  1. 기본 타입으로 제네릭 타입의 인스턴스를 만들 수 없다.
  2. 타입 파라미터의 인스턴스를 생성할 수 없다.
  3. 정적 필드 타입으로 타입 파라미터를 선언할 수 없다.
  4. instanceof 연산자에 타입 파라미터를 사용할 수 없다.
  5. 파라미터화된 타입으로 배열을 만들 수 없다.
  6. 제네릭 클래스는 직간접적으로 Throwable 클래스를 상속할 수 없다.
  7. 메소드는 타입 파라미터의 인스턴스를 catch 할 수 없다.
  8. 타입 삭제 후 같은 시그니처를 가지게 되는 메소드를 오버로드할 수 없다.

1. 기본 타입으로 제네릭 타입을 인스턴스로 만들 수 없다.

class Password<T> {
  private T t;

  public Password(T t) {
    this.t = t;
  }
}

Password 객체를 생성할 때, 타입 파라미터 T에 기본 타입을 대체할 수 없다.

Password<int> pw = new Password<>(19019);//컴파일 에러

2. 타입 파라미터의 인스턴스를 생성할 수 없다.

public static <E> append(List<E> list) {
  E elem = new E();//컴파일 에러
  list.add(elem);
}

3. 정적 필드 타입으로 타입 파라미터를 선언할 수 없다.

public class BasketballPlayer<T> {
  private static T teamFouls; //가능하다고 가정하자
}

BasketballPlayer 인스턴스를 생성한다.

BasketballPlayer<Byte> jodan = new BasketballPlayer<>();
BasketballPlayer<Short> pippen = new BasketballPlayer<>();
BasketballPlayer<Integer> rodman = new BasketballPlayer<>();

세 BasketballPlayer 인스턴스가 공유하는 teamFouls 필드의 타입이 Byte이면서 Short이면서 Integer일 수 없다.

4. instanceof 연산자에 타입 파라미터를 사용할 수 없다.

자바 컴파일러가 제네릭 코드에서 모든 타입 파라미터를 제거하기 때문에, 런타임은 제네릭의 타입 파라미터가 사용되는지 알 수 없다.

public static <E> void rtti(List<E> list) {
  if (list instanceof ArrayList<Integer>) { //컴파일 에러
    //..
  }
}

런타임은 코드에서 사용된 ArrayList<Integer>와 ArrayList<String>의 차이를 구별하지 못한다. 제약 없는 와일드카드를 사용하면 목록이 ArrayList인지는 확인할 수 있다.

public static void rtti(List<?> list) {
  if (list instanceof ArrayList<?>) { //OK
    //..
  }
}

5. 파라미터화된 타입으로 배열을 만들 수 없다.

List<Integer>[] arrayOfLists = new ArrayList<Integer>[2];//컴파일 에러

먼저 ArrayStoreException이 발생하는 경우를 보자.

Object[] strings = new String[2];
strings[0] = "Hello";
strings[1] = 2019;//런타임에 ArrayStoreException 발생

제네릭 리스트에서 똑같이 시도한다면, 문제가 될 수 있다.

Object[] stringLists = new List<String>[2];//컴파일 에러가 발생하지만 가능하다고 가정하자.
stringLists[0] = new ArrayList<String>();//OK
stringLists[1] = new ArrayList<Integer>();//ArrayStoreException 익셉션이 발생해야 하지만 런타임은 이를 감지할 수 없다.

런타임은 List<String>과 List<Integer>를 구별할 수 없기에, 파라미터화된 리스트의 배열이 허용된다면, ArrayStoreException 예외를 던져야 할 상황이 오더라도 던지지 못할 것이다.

6. 제네릭 클래스는 직간접적으로 Throwable 클래스를 상속할 수 없다.

다음 클래스는 컴파일되지 않는다.

// Throwable를 상속하는 Exception을 상속
class MathException<T> extends Exception { .. } //컴파일 에러
// Throwable을 직접 상속
class QueneFullException<T> extends Throwable { .. } //컴파일 에러

7. 메소드는 타입 파라미터의 인스턴스를 catch 할 수 없다.

public static <T extends Exception> void execute(List<T> jobs) {
  try {
    for (T job : jobs) {
      //..
    }
  } catch (T e) {//컴파일 에러: catch 블록에서 타입 파라미터를 사용할 수 없다.
    //..
  }
}

반면, throws 절에는 타입 파라미터를 사용할 수 있다.

class Parser<T extends Exception> {
  public void parse(File file) throws T { //OK
    //..
  }
}

8. 타입 삭제 후 같은 시그니처를 가지게 되는 메소드를 오버로드할 수 없다.

public class Example {
  public void print(Set<String> strSet) { .. }
  public void print(Set<Integer> intSet) { .. }
}

타입 삭제 후 print 메소드는 같은 시그니처를 갖게 되므로 컴파일 에러가 발생한다.

참고