Search
🙊

이펙티브 자바:: 아이템 28 <배열보다는 리스트를 사용하라>

Intro::

이펙티브 자바 정리본입니다.

결론

배열은 공변이고 실체화되는 반면, 제네릭은 불공변이고 타입 정보가 소거됩니다.
배열은 런타임에 타입 안전하지만 컴파일 타임에는 그렇지 않습니다.
제네릭은 런타임에 타입 안전하지 않지만 컴파일 타임에 안전합니다.
배열과 제네릭을 섞어 쓰는것은 쉽지않고, 만약 섞어 사용하다가 컴파일 오류나 경고를 만난다면 배열을 리스트로 대체하는 방법을 적용해보자.

배열과 제네릭 차이

공변 불공변

Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 됩니다. 이와 같이 함께 변한다는 의미를 공변이라고 합니다.
배열이 공변인 반면, 제네릭은 불공변입니다. 서로 다른 타입 Type1과 Type2가 있을 때, List<Type1>은 List<Type2>의 하위 타입도 아니고 상위 타입도 아닙니다.
// 런타임 실패 public static void main(String[] args) { Object[] objects = new Long[1]; objects[0] = "타입이 달라 넣을 수 없습니다."; }
Java
복사
// 컴파일 실패 public static void main(String[] args) { List<Object> list = new ArrayList<Long>(); list.add("타입이 달라 넣을 수 없습니다!"); }
Java
복사
어느 쪽이든 에러가 발생하지만 컴파일 시에 알아차리는 것을 선호할 것이다.

실체화 유무

배열은 실체화됩니다. 즉, 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인합니다. 이전 예시에서 확인할 수 있듯, Long 배열에 String을 넣으려 하면 ArrayStoreException이 발생합니다. 반면, 제네릭은 타입 정보가 런타임에는 소거됩니다.원소 타입을 컴파일타임에만 검사하며, 런타임에는 알수조차 없다는 뜻입니다. 소거는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있게 해주는 메커니즘으로, 자바 5가 제네릭으로 순조롭게 전환될 수 있도록 해줬습니다.

제네릭 배열을 만들지 못하게 한 이유

타입 안전하지 않기 때문입니다.
이를 허용한다면 컴파일러가 자동 생성한 형변환 코드에서 런타임에 ClassCastException이 발생할 수 있습니다.
이는 런타임에 ClassCastException이 발생하는 것을 막아주겠다는 제네릭 타입 시스템의 취지에 어긋납니다.
public static void main(String[] args) { // 컴파일 에러 Generic array creation not allowed List<String>[] stringLists = new List<String>[10];// (1) List<Integer> integerList = List.of(42);// (2) Object[] objects = stringLists;// (3) objects[0] = integerList;// (4) String s = stringLists[0].get(0);// (5) }
Java
복사
만약 제네릭 배열이 된다고 가정하면
(3)은 (1)에서 생성한 List<String> 배열을 Object 배열에 할당합니다. 런타임에는 List<String>[] 이 단순히 List[] 로 소거 되기 때문에 공변이 발생하기 때문입니다.
(4) 또한 마찬가지로 (2)에서 생성한 인스턴스를 저장합니다.
(5)는 stringLists의 처음 리스트에서 첫 원소를 꺼내려 하는데, 컴파일러는 꺼낸 원소를 자동으로 String 으로 형변환합니다. 이때, 이 원소는 Integer이므로 런타임에 ClassCastException이 발생하게 됩니다.
E, List<E>, List<String> 같은 타입을 실체화 불가 타입이라고 합니다. 쉽게 말해, 실체화되지 않아서 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입임을 의미합니다. 소거 메커니즘 때문에 매개변수화 타입 가운데 실체화될 수 있는 타입은 List<?>와 Map<?,?>같은 비한정적 와일드카드 타입뿐입니다.

배열을 제네릭으로 만들 수 없어서 귀찮은 경우

제네릭 컬렉션에서는 자신의 원소 타입을 담은 배열을 반환하는 게 보통은 불가능합니다.
또한 제네릭 타입과 가변인수 메서드를 함께 쓰면 해석하기 어려운 경고 메시지를 받게 됩니다. 가변인수 메서드를 호출할 때마다 가변인수 매개변수를 담을 배열이 하나 만들어지는데, 이때 그 배열의 원소가 실체화 불가 타입이라면 경고가 발생하는 것입니다.
배열로 형변환할 때 제네릭 배열 생성 오류나 비검사 형변환 경고가 뜨는 경우 대부분은 배열인 E[] 대신 List<E>를 사용하면 해결 가능합니다. 코드가 조금 복잡해지고 성능이 살짝 나빠질 수 있지만, 타입 안전성과 상호운용성은 좋아집니다.

배열 대신 컬렉션 사용 예시

// 배열 예시 public class Chooser { private final Object[] choiceArray; public Chooser(Collection choices) { choiceArray = choices.toArray(); } public Objectchoose() { Random rnd = ThreadLocalRandom.current(); return choiceArray[rnd.nextInt(choiceArray.length)]; } }
Java
복사
해당 코드는 매번 Object에서 형변환을 해주어야 한다. 제네릭으로 수정해보자.
public class Chooser<T> { private final T[] choiceList; public Chooser(Collection<T> choices) { choiceList = (T[]) choices.toArray();// (1) } public T choose() { Random rnd = ThreadLocalRandom.current(); return choiceList[rnd.nextInt(choiceList.length)]; } }
Java
복사
(1) 에서 Object 배열을 T[] 배열로 형변환을 해줍니다. 해당 프로그램은 동작은 하지만, 컴파일러가 안전을 보장하지 못합니다.
T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장을 할 수 없습니다. 제네릭에서는 원소의 타입 정보가 소거되어 런타임에서는 무슨 타입인지 알 수 없기 때문입니다.
// 코드 28-6 리스트 기반 Chooser - 타입 안전성 확보! (168쪽) 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())); } }
Java
복사

질문

제네릭의 경우 타입 정보가 런타임에는 소거된다고 하는데 이게 무슨 말인가?

제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 구체적인 타입을 알 수 없게 됩니다. 이때 소거된 제네릭 타입 파라미터는 상한이 지정되지 않은 경우 Object로, 상한이 지정된 경우 그 상한 타입으로 대체됩니다.
// 타입 상한 List<T extends Number>...
Java
복사

그래서 제네릭과 배열은 뭐가 다른건가??

1.
배열은 공변(covariant): 자바 배열은 공변성을 가집니다. 즉, String[]Object[]의 하위 타입입니다. 그래서 배열에서는 다음과 같은 코드가 컴파일됩니다:
Object[] objects = new String[10]; // 가능
Java
복사
하지만, 배열의 공변성 때문에 문제가 발생할 수 있습니다:
Object[] objects = new String[10]; objects[0] = 42; // 런타임에서 ArrayStoreException 발생
Java
복사
여기서 objectsString[] 배열로 실제로 만들어졌기 때문에, 정수 값을 넣으면 런타임에 ArrayStoreException이 발생합니다. 즉, 배열은 런타임에 타입을 체크합니다.
2.
제네릭은 불공변(invariant): 제네릭 타입은 불공변입니다. 즉, List<String>List<Object>와 아무런 관련이 없습니다. 제네릭스는 컴파일 시 타입을 체크하며, 런타임에는 타입 소거(type erasure)가 일어나 타입 정보가 사라집니다.
예를 들어, 다음 코드는 컴파일되지 않습니다:
List<Object> list = new ArrayList<String>(); // 컴파일 오류
Java
복사
이는 제네릭스가 타입 안정성을 컴파일 시점에서 보장하려고 설계되었기 때문입니다.

컴파일러가 T[] 대신 Object[]로 처리하는 이유

제네릭 배열을 사용할 때 컴파일러는 배열을 만들 수 없습니다. 그 이유는 타입 소거와 관련이 있습니다.
1.
타입 소거(Type Erasure): 제네릭스는 런타임에 타입 정보가 사라지게 됩니다. 즉, 컴파일된 자바 바이트코드에서는 제네릭 타입이 지워지기 때문에, T와 같은 타입 매개변수는 런타임에 더 이상 알 수 없습니다. 이는 런타임에 제네릭 타입을 구체적으로 추적할 수 없음을 의미합니다.
예를 들어:
T[] array = new T[10]; // 컴파일 오류
Java
복사
이런 코드를 허용하면, 런타임에 T가 무엇인지 알 수 없기 때문에 안전하지 않으며, 자바는 이를 막기 위해 컴파일 오류를 발생시킵니다.
2.
배열의 런타임 타입 체크: 배열은 런타임에 타입을 검사합니다. 제네릭스는 컴파일 시점에만 타입을 검사하고 런타임에는 타입 정보가 소거되므로, 제네릭 배열은 런타임에 타입 안전성을 보장할 수 없습니다. 만약 T[] 배열을 허용하면 런타임에 T 타입을 검사할 수 없기 때문에 배열의 타입 안전성이 깨집니다.
예를 들어, 만약 T[]가 허용되었고 T가 런타임에 String이라고 추론된다고 하더라도, 런타임에 정확한 타입을 추적할 수 없기 때문에 안전하지 않습니다. 그래서 컴파일러는 T[] 대신 Object[]로 처리합니다.

요약:

1.
배열의 공변성과 런타임 타입 체크: 배열은 런타임에 타입을 검사하고, 공변성을 가집니다. 하지만 제네릭스는 불공변성을 가지며 런타임에는 타입 정보가 소거되므로, 제네릭 배열은 런타임에 타입 안전성을 보장할 수 없습니다.
2.
타입 소거로 인한 런타임 정보 부족: 제네릭스는 런타임에 타입 정보가 소거되기 때문에, 컴파일러는 T[] 배열을 허용하지 않고 대신 Object[]로 처리합니다. 이는 런타임 타입 안전성을 보장하기 위함입니다.
따라서, 자바는 컴파일 시점에서 타입 안정성을 보장하기 위해 제네릭 배열 생성을 제한하며, 런타임에 안전하지 않은 상황을 방지하기 위해 배열을 Object[]로 처리합니다.

비한정적 와일드카드 타입이 왜 실체화 가능 타입인가?

실체화 가능 타입(Reifiable Type)이란 런타임에 완전한 타입 정보를 유지하는 타입을 말합니다. 즉, 타입 정보가 소거되지 않고, 런타임에서도 그 타입에 대한 정보를 가지고 있는 타입을 가리킵니다.
하지만, 제네릭 타입은 컴파일 타임에만 타입 정보를 사용하고, 런타임에서는 그 타입 정보가 소거되므로, 대부분의 제네릭 타입은 실체화 불가능(Non-Reifiable) 타입에 해당합니다.

실체화 가능 타입의 예

기본 타입(Primitive types): int, char, boolean 등은 런타임에서도 타입 정보를 유지하므로 실체화 가능 타입입니다.
비제네릭 타입(Non-generic types): 예를 들어, List 같은 타입은 제네릭 정보가 전혀 없으므로 실체화 가능합니다.
비한정적 와일드카드 타입(?): List<?>와 같은 타입은 실체화 가능 타입으로 간주됩니다.
List<?>의 경우 컴파일타임에서 타입소거가 되어 런타임에는 List 타입으로 간주됩니다. 이때 List<?>는 런타임에서 List<Object>와 같은 역할을 수행하기 때문에 실체화 가능하다고 판단할 수 있습니다.

References::

이펙티브 자바 / 조슈아 블로크 지음 (프로그래밍 인사이트)