[Effective Java]배열보다는 리스트를 사용하라

제네릭, 세 번째 아이템

Posted by SungBeom on April 05, 2020 · 7 mins read

배열과 제네릭 타입의 차이점

배열과 제네릭 타입에는 중요한 차이가 두 가지 있다. 첫 번째, 배열은 공변(covariant)이고, 불공변(invariant)이다. Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 된다. 반면 서로 다른 Type1과 Type2가 있을 때, List<Type1>은 List<Type2>의 하위 타입도 아니고 상위 타입도 아니다. 이것만 보면 제네릭에 문제가 있다고 생각할 수도 있지만, 사실 문제가 있는 건 배열 쪽이다.

1
2
3
// 런타임에 실패한다.
Object[] objectArray = new Long[1];
objectArray[0= "타입이 달라 넣을 수 없다.";  // ArrayStoreException을 던진다.
cs

1
2
3
// 컴파일되지 않는다!
List<Object> ol = new ArrayList<Long>();  // 호환되지 않는 타입이다.
ol.add("타입이 달라 넣을 수 없다.");
cs

어느 쪽이든 Long용 저장소에 String을 넣을 수는 없다. 다만 배열에서는 그 실수를 런타임에야 알게 되지만, 리스트를 사용하면 컴파일할 때 바로 알 수 있다.

두 번째 주요 차이로, 배열은 실체화(reify)된다. 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다. 그래서 Long 배열에 String을 넣으려 하면 ArraySotreException이 발생한다. 반면, 제네릭은 타입 정보가 런타임에는 소거(ensure)된다. 원소 타입을 컴파일타임에만 검사하며 런타임에는 알수조차 없다는 뜻이다.

이상의 주요 차이로 인해 배열과 제네릭은 잘 어우러지지 못한다. 예컨대 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다. 제네릭 배열을 만들지 못하게 막은 이유는 타입 안전하지 않기 때문이다. 이를 허용한다면 컴파일러가 자동 생성한 형변환 코드에서 런타임에 ClassCastException이 발생할 수 있고, 이는 제네릭 타입 시스템의 취지에 어긋나는 것이다.

배열을 제네릭으로 만들 수 없어 귀찮을 때도 있다. 예컨대 제네릭 컬렉션에서는 자신의 원소 타입을 담은 배열을 반환하는 게 보통은 불가능하다. 또한 제네릭 타입과 가변인수 메소드(varargs method)를 함께 쓰면 해석하기 어려운 경고 메시지를 받게 된다. 가변인수 메소드를 호출할 때마다 가변인수 매개변수를 담을 배열이 하나 만들어지는데, 이때 그 배열의 원소가 실체화 불가 타입(실체화되지 않아서 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입)이라면 경고가 발생하는 것이다. 이 문제는 @SafeVarargs 애너테이션으로 대처할 수 있다.

배열로 형변환할 때 제네릭 배열 생성 오류나 비검사 형변환 경고가 드는 경우 대부분은 배열인 E[] 대신 컬렉션인 List<E>를 사용하면 해결된다. 코드가 조금 복잡해지고 성능이 살짝 나빠질 수도 있지만, 그 대신 타입 안전성과 상호운용성은 좋아진다. 생성자에 컬렉션을 받는 Chooser 클래스를 예로 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 제네릭을 시급히 적용해야 한다!
public class Chooser {
    private final Object[] choiceArray;
 
    public Chooser(Collection choices) {
        choiceArray = choices.toArray();
    }
 
    public Object choose() {
        Random rnd = ThreadLocalRandom.current();
        return choiceArray[rnd.nextInt(choiceArray.length)];
    }
}
cs

이 클래스를 사용하려면 choose 메소드를 호출할 때마다 반환된 Object를 원하는 타입으로 형변환해야 한다. 혹시나 타입이 다른 원소가 들어 있었다면 런타임에 오류가 날 것이다. 이 클래스를 제네릭으로 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Chooser를 제네릭으로 만들기 위한 시도 - 컴파일되지 않는다.
public class Chooser<T> {
    private final T[] choiceArray;
 
    public Chooser(Collection<T> choices) {
        choiceArray = (T[]) choices.toArray();
    }
 
    public Object choose() {
        Random rnd = ThreadLocalRandom.current();
        return choiceArray[rnd.nextInt(choiceArray.length)];
    }
}
cs

T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다는 메시지다. 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없음을 기억하자! 이 프로그램은 동작하지만 컴파일러가 안전을 보장하지 못한다. 코드를 작성하는 사람이 안전하다고 확신한다면 주석을 남기고 애너테이션을 달아 경고를 숨겨도 되지만, 애초에 경고의 원인을 제거하는 편이 훨씬 낫다.

비검사 형변환 경고를 제거하려면 배열 대신 리스트를 쓰면 된다. 코드양이 조금 늘었고 아마도 조금 더 느릴 테지만, 런타임에 ClassCastException을 만날 일은 없으니 그만한 가치가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 리스트 기반 Chooser - 타입 안전성 확보!
public class Chooser<T> {
    private final List<T> choiceList;
 
    public Chooser(Collection<T> choices) {
        choiceList = new ArrayList<>(choices);
    }
 
    public Object choose() {
        Random rnd = ThreadLocalRandom.current();
        return choiceList.get(rnd.nextInt(choiceList.size()));
    }
}
cs

핵심 정리

배열과 제네릭에는 매우 다른 타입 규칙이 적용된다. 배열은 공변이고 실체화되는 반면, 제네릭은 불공변이고 타입 정보가 소거된다. 그 결과 배열은 런타임에는 타입 안전하지만 컴파일타임에는 그렇지 않다. 제네릭은 반대다. 그래서 둘을 섞어 쓰기란 쉽지 않다. 둘을 섞어 쓰다가 컴파일 오류나 경고를 만나면, 가장 먼저 배열을 리스트로 대체하는 방법을 적용해보자.