1. 배열 vs 제네릭 타입
배열과 제네릭 타입의 차이는 무엇일까? 우선, 배열은 고정된 크기와 타입을 가진 데이터 구조 (ex, int[], String[]) 이고, 제네릭은 다양한 타입을 유연하게 처리 (ex, List<T>) 할 수 있도록 설계되어있다. 중요한 차이점은, 배열은 공변이고, 제네릭 타입은 불공변이다. 아래에서 더 자세히 살펴보자.
1-1. 공변 vs 불공변
배열은 공변이고, 제네릭은 불공변이다. 무슨 말인가? 다시 말해, 공변은 자식 클래스가 부모클래스로 대체될 수 있다는 것이고, 불공변은 이와 반대로, 자식 클래스가 부모 클래스로 대체될 수 없다는 것이다. 공변과 불공변의 차이를 통해, 컴파일 단계에서 오류를 방지할 수 있냐 없냐 차이를 가를 수 있다. 아래 예시를 통해 살펴보자.
1-1-1. 공변
Object[] objArray = new String[10]; // 가능
원래라면, Object 클래스가 아니라, String 클래스로 타입을 설정해야 하지만, 배열의 경우 부모 클래스로 대체가 가능하다. 이렇게 되었을 때 컴파일 시 에러는 안나지만, 치명적인 오류가 하나 있다. 런타임 시 문제가 발생할 수 있다는 점이다.
objArray[0] = 123; // 런타임 오류 (ArrayStoreException)
String 이 아닌, 숫자를 넣으려고 했을 때, 런타임 오류가 난다. 한마디로, 언제 발생할 지 모르는 오류를 안고 가야한다는 것이다.
1-1-2. 불공변
List<Object> objList = new ArrayList<String>(); // 컴파일 오류
원래라면, 마찬가지로 Object 클래스가 아닌 String 클래스로 타입을 설정해야 한다. 제네릭 타입의 불공변 특성상, 자식 클래스 (String) 가 부모 클래스 (Object) 클래스로 대체될 수 없기 때문에, 컴파일 시에 오류가 나서 선조치가 가능하다.
1-2. 실체화 vs 소거
배열은 실체화되고, 제네릭은 소거된다. 무슨 말인가? 다시 말해, 배열은 런타임 시 정해진 타입의 원소를 인지하고 확인해, 에러를 뱉어낼 수 있다. 하지만, *제네릭은 컴파일 단계에서만 원소 타입을 검사하며, 런타임시에는 타입 정보가 소거된다는 것이다.*
2. 배열과 제네릭
위에서 확인했듯, 배열과 제네릭은 공변과 불공변, 실체화와 소거 등의 차이로 인해 어우러 지기 힘들다.
다시 말해, String 대신 List<String> 을 사용해 new List<String>[] 이런 식으로 배열과 제네릭을 혼합해 사용하기 힘들다는 것이다. 그렇다면 제네릭 배열을 만들지 못하게 막아놓은 이유는 무엇일까? 바로 타입의 안정성 때문이다. 제네릭 배열을 사용하면, 타입이 안정적이지 않다. 다시 말해 제네릭만 사용할 때는 문제가 없었던 런타임 오류를 제네릭 배열에서는 마주칠 수 있다는 것이다.
아래 예시를 통해 구체적으로 살펴보자.
List<String>[] stringLists = new List<String>[1]; // (1) 허용된다고 가정해보자.
List<Integer> intList = List.of(42); // (2) 원소가 하나인 List<Integer> 생성
Object[] objects = stringLists; // (3) objects에 stringLists를 할당
objects[0] = intList; // (4) objects의 첫번째 원소로 intList를 저장한다.
String s = stringLists[0].get(0); // (5) stringList[0]에 들어가있는 첫번째 요소는 Integer이므로 형변환 오류 발생.
String 대신 List<String> 을 넣은, (1) 과 같은 제네릭 배열이 있다고 가정하자. (3) 에서, Object 배열에 List<String> 을의 배열을 할당하게 되는데, 배열의 경우 공변 특성을 통해 자식 클래스를 부모 클래스로 대체할 수 있어 컴파일 에러가 발생하지 않는다. (4) 에서, objects 의 첫 원소. 즉 List<String> 제네릭 타입을 initList 로 초기화하는데, 소거 규칙에 의해 런타임 시에 원소 타입을 검사하지 않아, 이 또한 런타임 에러가 발생하지 않는다. 이미 문제가 두 번이나 있었지만, 에러를 잡지 못했다.
결국, (5) 에서, 42를 꺼내오려다가 String 타입으로 형변환이 불가능해 런타임 시 ClassCastException 이 발생하고야 말았다.
이 모든 일의 발단은, (1) 에서 제네릭 배열을 허용했기 때문이다.
배열의 공변, 그리고 제네릭의 소거와 같이 에러를 지나칠 수 있는 요건에 대해 고려하고자, 제네릭 배열은 사용해선 안된다.

2.1 실체화 불가 타입
실체화 불가란, 위에서 언급한 소거의 개념으로 런타임 시 정해진 타입의 원소를 인지하지 못해 에러를 뱉어낼 수 없다는 것이다. E, List<String>, List<E> 가 그 예다. 소거 매커니즘으로 인해, 실체화될 수 있는 타입은 List<?> 와 Map<?,?> 같은 비한정적 와일드카드 타입이 있다. 그렇다고 배열을 비한정적 와일드카드 타입으로 만들 수는 있지만, 쓰일 일은 거의 없다.
3. E[] 대신 List<E>
배열보다는 컬렉션인 리스트를 사용하자. 코드가 조금 복잡해지고 성능이 살짝 나빠지는 대신, 타입 안정성을 가져가보자. 아래 예시를 통해 살펴보자. 컬렉션 안의 원소 중 하나를 무작위로 선택해 반환하는 choose 메서드를 제공하는 Chooser 클래스이다.
첫번째로, 제네릭을 사용하지 않고 구현한 간단한 방법이다.
public class Chooser {
private final Object[] choiceArray;
public Chooser(final Object[] choiceArray) {
this.choiceArray = choiceArray;
}
public Object choose(){
Random random = ThreadLocalRandom.current();
return choiceArray[random.nextInt(choiceArray.length)];
}
}
choose 메서드에서 Object 형태로 반환하기 때문에, 호출할 때마다 원하는 타입으로 형 변환을 해주어야 하며, 예키치 못한 런타임 형변환 오류가 발생할 수 있다. 이 클래스를 제네릭으로 수정해보자.
public class ListChooser {
private final List<T> choiceList;
public ListChooser(final Collection<T> choices) {
this.choiceList = new ArrayList<>(choices);
}
public T choose(){
Random random = ThreadLocalRandom.current();
return choiceList[random.nextInt(choiceList.size())];
}
}
크게 두 가지의 수정이다. 먼저, 배열을 리스트로 바꾸었다. 배열은 런타임 시 원소 타입을 체크하기 때문에 런타임에러가 날 수 있어 소거가 지원되는 제네릭의 리스트를 사용하자. 그리고, Object 클래스 대신 실체화 불가 타입인 T 를 통해 형변환이 필요 없도록 만들자.
'1️⃣ 백앤드 > 이펙티브 자바' 카테고리의 다른 글
| [아이템30] 이왕이면 제네릭 메서드로 만들라 (1) | 2024.10.24 |
|---|---|
| [아이템29] 이왕이면 제네릭 타입으로 만들라 (0) | 2024.10.24 |
| [아이템27] 비검사 경고를 제거하라 (0) | 2024.10.22 |
| [아이템26] 로 타입은 사용하지 말라 (0) | 2024.10.18 |
| [아이템25] 톱레벨 클래스는 한 파일에 하나만 담으라 (0) | 2024.10.17 |