DEV ℧ Developer Diary

[EffectiveJava] item28 - 배열보다는 리스트를 사용하라

배열과 제네릭 타입에는 중요한 차이가 두가지 있다. 첫 번째로 배열은 공변(covariant : 함께 변한다)이다. Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 된다. 반면, 제네릭은 불공변(invariant : 함께 변하지 않는다.)이다. 즉, 서로 다른 타입 Type1, Type2가 있을 때, List은 List의 하위 타입도 아니고 상위 타입도 아니다.

배열과 제네릭

이것만 가지고는 배열보다는 리스트를 써야한다는 문제를 알 수가 없다. 다음의 예시를 살펴보도록 하자.

Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없다."; // ArrayStoreException을 던진다.

배열에 넣은 코드는 컴파일은 성공하지만 런타임에서 ArrayStoreException이 발생한다.

컬렉션을 사용한 코드를 살펴보자.

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

컬렉션을 사용한 코드는 컴파일 단계에서 에러가 발생하기 때문에 문제점을 바로 알아 챌 수 있다.

배열과 제네릭의 실체화

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

소거(erasure)란 제네릭이 지원되기 전의 로 타입의 레거시 코드와 제네릭 타입을 함께 사용할 수 있게 해주는 메커니즘으로, 자바 5가 제네릭으로 순조롭게 전환될수 있도록 해줬다.

이상의 주요 차이로 인해 배열과 제네릭은 잘 어우러 지지 못한다.

예를 들어 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다. 즉, 코드를 new List<E>[], new List<String>[], new E[] 식으로 작성하면 컴파일할 때 제네릭 배열 생성 오류를 일으킨다.

제네릭 배열을 만들지 못하게 막은 이유는 무엇일까? 타입 안전하지 않기 때문이다. 이를 허용한다면 컴파일러가 자동 생성한 형변형 코드에서 런타임 ClassCastException이 발생할 수 있다.

E, List<E>, List<String> 같은 타입을 실체화 불가 타입(non-reifiable type)이라 한다. 쉽게 말해, 실체화되지 않아서 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입이다. 소거 메커니즘 때문에 매개변수화 타입 가운데 실체화 될 수 있는 타입은 List<?>와 Map<?,?> 같은 비한정적 와일드카드 타입 뿐이다.

배열과 제네릭의 형변환

배열을 제네릭으로 만들 수 없어 귀찮을 때도 있다. 예컨대 제네릭 컬렉션에서는 자신의 원소 타입을 담은 배열을 반환하는게 보통은 불가능하다. 또한 제네릭 타입과 가변인수 메서드(varargs method)를 함께 쓰면 해석하기 어려운 경고 메시지를 받게된다.

이 문제는 @SafeVarargs 애너테이션을 대체할 수 있다.

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

예시를 한번 들어보자. 아래는 E[]을 사용한 Chooser 클래스이다. 생성자에 어떤 컬랙션을 넘기느냐에 따라 원소중 하나를 무작위로 선택해 반환하는 choose 메소드를 제공한다.

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)];
    }
}

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

이 클래스를 한번 제네릭으로 만들어보자.

public class Chooser<T> {
    private final T[] choiceArray;

    public Chooser(Collection<T> choices) {
        choiceArray = choices.toArray();
    }

    // choose 메서드는 그대로이다.
}

하지만 이 클래스를 컴파일 하면 다음의 오류 메시지가 출력된다.

Cooser.java:9: error: incompatible types: Object[] cannot be converted to T[]
      choiceArray = choices.toArray();
                                   ^

   where T is a type-variable:
    T extends Object declared in class Chooser

Object 배열을 T 배열로 형변환 할 수 없기 때문에 에러가 난다. 그렇다면 형변환을 추가해주도록 하자.

choiceArray = (T[]) choices.toArray();

그렇다면 이번엔 경고가 뜬다.

Cooser.java:9: warning: [unchecked] unckecked cast
       choiceArray = (T[]) choices.toArray();
                                          ^
  required: T[], found: Object[] where T is a type-variable:
T extends Object declared in class Chooser

T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다는 메시지이다. 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없음을 기억하자!

이 경고는 @SuppressWarnings(“unchecked”)를 달아 해결 할 수 있지만, 애초에 경고의 원인을 제거하는 편이 훨씬 낫다.

비검사 형변환 경고를 제거하려면 배열 대신 리스트를 쓰면 된다. 다음 코드는 경고와 에러가 없어진 깔끔한 코드이다.

public class Chooser<T> {
    private final List<T> choiceList;

    public Chooser(Collection<T> choices) {
        choiceList = new ArrayLisr<>(choices);
    }

    public T choose() {
        Random rnd = ThreadLocalRandom.current();
        return choiceList.get(rnd.nextInt(choiceList.size()));
    }
}

이번 버전은 코드양이 조금 늘었고 아마도 조금 더 느릴테지만, 런타임에 ClassCastException을 만날 일은 없으니 그만한 가치가 있다.