Backend/Java

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

김세진 2024. 2. 18. 22:30
반응형

 

 

 

 

 

배열 vs 제네릭(리스트)

 

1. 공변 vs 불공변 (공변: 함께 변함)

배열은 공변이다. 즉, Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 된다.

그렇기 때문에 다음은 문법 상 허용되는 배열 코드이다.

 

Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없음"; // ArrayStoreException 발생

 

위 코드는 문법상 허용되어 컴파일 타임에 예외가 일어나지 않지만, 실제로 스트링이 배열에 들어갈 때 런타임 오류가 발생한다. 당연하게도 String은 Long 배열에 들어갈 수 없기 때문이다.

 

반면, 제네릭은 불공변이다. 서로 다른 두 제네릭 타입이 있을 때, 그 타입 간의 상속 관계와는 무관하게 서로 다른 타입으로 간주한다는 의미이다. 즉, 임의의 Type1, Type2가 있을 때  List<Type1>은 List<Type2>의 하위 타입도, 상위 타입도 아니다. 

아래는 위 코드를 리스트로 변환한 코드이다.

 

List<Object> ol = new ArrayList<Long>(); // 컴파일 에러 발생
ol.add("타입이 달라 넣을 수 없음");

 

위 코드를 IDE에서 확인해 보면 다음과 같이 표시된다.

 

 

타입이 맞지 않다며 아예 컴파일조차 불가능하다. 런타임보다는 컴파일 단계에서 예외를 알아차리는 것이 안정성이 높을 것이다.

 

 

2. 실체화(reify)

배열은 실체화된다. 즉, 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다. 위의 1번에서 보았듯, 런타임 때 Long 배열에 String 타입을 추가하려고 하면 ArrayStoreException이 발생한다.

 

반면 제네릭은 원소 타입을 컴파일 타임에만 검사하고, 이후 모두 타입 정보가 지워진다(Object로 변환). 이러한 제네릭의 기본 특성을 소거(erasure)라 한다. 따라서 런타임에는 제네릭의 원소 타입 정보를 알 수 조차 없다. 아래는 문법상 허용되는 제네릭 코드와 그 실행 결과이다.

 

List<Integer> integerList = new ArrayList<>();
List rawList = integerList;
rawList.add("Hello");
rawList.add(123);

System.out.println(rawList);

 

 

1번에서 배열의 공변 특성을 설명하며 첨부한 코드와 달리, 런타임에 ArrayStoreException이 발생하지 않고 List<Integer>로 선언했음에도 불구하고 String 타입이 잘 담기는 모습을 볼 수 있다.

 

 

제네릭 배열

 

이같은 특성 때문에 배열과 제네릭은 잘 어우러지지 못한다. 타입 안전성을 위해 자바에서는 제네릭 배열의 생성을 금지하여 컴파일 오류를 발생시킨다. 아래와 같이 제네릭 배열을 생성하려 하면 컴파일 오류를 뱉는다.

 

 

만약 제네릭 배열을 허용한다면 컴파일러가 자동 생성한 형변환 코드에서 런타임에 ClassCastException이 발생할 수 있다. 이는 런타임에 ClassCastException이 발생하는 일을 막아주겠다는 제네릭 타입의 취지에 어긋난다. 아래와 같이 제네릭 배열의 생성이 허용된다면 발생할 수 있는 일에 대해 살펴보자.

 

List<String>[] stringLists = new List<String>[1];
List<Integer> intList = List.of(42);
Object[] objects = stringLists;
objects[0] = intList;

String s = stringLists[0].get(0); // ClassCastException

 

List<String>을 원소로 받는 제네릭 배열을 선언하고, 배열은 공변성을 지니므로 objects에 문제 없이 할당했다. 그리고 objects의 첫 번째 원소로 List<Integer> 인스턴스를 할당한다. 타입이 서로 다르지만 제네릭은 컴파일 과정에서 타입이 소거되므로 문제 없이 저장이 가능하다.

 

4번째 줄까지 따른 결과, stringLists 에는 처음엔 List<String> 인스턴스만 담겠다고 선언했지만 List<Integer>가 할당되어 있다. 여기서 마지막 줄에서 첫 번째 배열의 원소를 꺼낼 때 컴파일러가 원소를 String 타입으로 캐스팅하는데, 실제로는 Integer가 담겨져 있으므로ClassCastException이 발생하게 된다.

 

이같은 상황을 방지하기 위해 자바에서는 제네릭 배열의 생성 자체를 금하게 된 것이다.

 

 

실전

 

원소 중 하나를 무작위로 선택해 반환하는 Chooser 클래스를 예시로 살펴보도록 하자. 아래는 Collection과 배열을 이용해 간단히 구현한 예제이다.

 

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

 

리스트와 제네릭 타입을 도입해 형변환에 대해 타입 안전성을 확보했다.

 

정리

 

1. 배열은 공변이고 실체화되는 반면, 제네릭은 불공변이고 타입 정보가 소거된다.

2. 따라서 배열은 런타임에 타입 안전하다. 반면, 제네릭은 컴파일타임에 타입 안전하다.

3. 리스트는 배열보다 느리지만 성능이 아주 중요한 코드가 아닐 경우 타입 안전성을 확보하기 위해 배열보다는 리스트를 사용하자.

 

 

 

 

반응형