본문 바로가기

개발/이펙티브 자바

Effective Java ( 이펙티브 자바 ) - 아이템 28

배열보다는 리스트를 사용하라


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

  이것만 보면 제네릭에 문제가 있다고 생각할 수도 있지만, 사실 문제가 있는 건 배열 쪽이다. 다음은 문법상 허용되는 코드다.

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

하지만 다음 코드는 문법에 맞지 않는다.

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

어느 쪽이든 Long용 저장소에 String을 넣을 수는 없다. 다만 배열과 달리 리스트를 사용하면 컴파일할 때 바로 알 수 있다.

 

 

  두 번째 주요 차이로, 배열은 실체화된다. 무슨 뜻인고 하니, 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다. 그래서 앞선 ArrayStoreException 이 발생한다. 반면, 제네릭은 타입 정보가 런타임에는 소거된다. 

 

  이상의 주요 차이로 인해 배열과 제네릭은 잘 어우러지지 못한다. 예컨대 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다. 즉, 코드를 new List<E>[], new List<String>[], new E[] 식으로 작성하면 컴파일할 때 제네릭 배열 생성 오류를 일으킨다.

 

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

List<String>[] stringLists = new List<String>[1]; // (1)
List<Integer> intList = List.of(42);    // (2)
Object[] objects = stringLists;      // (3)
objects[0] = intList;        // (4)
String s = stringLists[0].get(0);      // (5)

위와 같은 상황을 가정해보자. (1)의 제네릭 배열 생성이 허용된다고 가정해보자. (2)는 원소가 하나인 List<Integer>를 생성한다. (3)은 (1)에서 생성한 배열을 Object 배열에 할당한다. (4)는 (2)에서 생성한 List<Integer>의 인스턴스를 Object 배열의 첫 원소로 저장한다. 제네릭은 소거 방식으로 구현되어서 이 역시 성공한다. 즉, 런타임에는 List<Integer> 인스턴스의 타입은 단순히 List가 되고, List<Integer>[] 인스턴스의 타입은 List[]가 된다. 따라서 (4)에서도 ArrayStoreException을 일으키지 않는다.

  여기부터가 문제다. List<String> 인스턴스만 담겠다고 선언한 stringLists 배열에는 지금 지금 List<Integer> 인스턴스가 저장돼 있다. 그리고 (5)는 원소를 꺼내려한다. 컴파일러는 꺼낸 원소를 자동으로 String으로 형변환하는데, 이 원소는 Integer이므로 런타임에 ClassCastException이 발생한다. 이런 일을 방지하려면 제네릭 배열이 생성되지 않도록 (1)에서 컴파일 오류를 내야 한다.

 

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

 

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

 

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

 

  생성자에서 컬렉션을 받는 Chooser 클래스를 예로 살펴보자. 이 클래스는 컬렉션 안의 원소 중 하나를 무작위로 선택해 반환하는 choose 메서드를 제공한다. 생성자에 어떤 컬렉션을 넘기느냐에 따라 이 클래스를 주사위판, 매직 8볼, 몬테카를로 시뮬레이션용 데이터 소스 등으로 사용할 수 있다. 

 

  다음은 제네릭을 쓰지 않고 구현한 가장 간단한 버전이다. 

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 메서드는 그대로다.
}

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

Object[] cannot be converted to T[]. choiceArray = choices.toArray();

 

걱정할 거 없다. Object 배열을 T 배열로 형변환하면 된다.

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

그런데 이번엔 경고가 뜬다.

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

 

T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전할지 보장할 수 없다는 메시지다. 제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없음을 기억하자! 그렇다면 이 프로그램은 동작할까? 동작한다. 단지 컴파일러가 안전을 보장하지 못할 뿐이다. 

  비검사 형변환 경고를 제거하려면 배열 대신 리스트를 쓰면 된다. 다음 Chooser는 오류나 경고 없이 컴파일된다. 

public class Chooser<T> {
   private final List<T> choiceList;
   
   public Chooser(Collection<T> choices) {
      choiceList = new ArrayList<>(choices);
   }
   
   public T choose() {
      Random rnd = ThreadLocalRandom.current();
      return choiceList.get(rnd.nextInt(choiceList.size()));
   }
}

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

 

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