본문 바로가기
Languages/Java

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

by yoon_seon 2024. 9. 27.

최근 [이펙티브 자바]를 읽으면서 배열보다 리스트를 주로 사용해 온 이유에 대해 다시 생각해 보게 되었고 이 경험을 공유하고자 합니다.

 

배열과 제네릭의 차이를 알아보고 배열보다는 제네릭을 사용할 수 있는 리스트를 사용하는 이유에 대해 다뤄보겠습니다.

 

첫 번째 이유 : 배열과 제네릭의 공변성과 불공변성

자바에서 배열은 공변성을 가지고, 제네릭은 불공변성을 가진다는 차이가 있습니다.

배열보다 리스트를 사용해야 하는 첫 번째 이유는 제네릭의 불공변성 덕분에 타입 안정성을 보장하기 때문입니다.

 

이를 이해하기위해 간단한 예로 공변성과 불공변성을 알아보겠습니다.

 

공변성

공변성은 말 그대로 '함께 변한다' 라는 뜻으로, 이를 코드 레벨에 적용해 본다면 모든 객체의 상위 클래스인 Object가 있고 하위 클래스인 Integer가 있을 때 배열 Integer[]은 배열 Object[]의 하위타입이 되는 성질을 갖습니다.

즉, 배열은 공변성을 가진다고 할 수 있습니다.

 

공변성을 가지는 배열은 아래와 같은 문제점이 있습니다.

Integer[] integers = new Integer[3];
integers[0] = 1;
integers[1] = 2;

Object[] objects = integers; // Integer[]는 Object[]의 하위 타입 이기에 가능
objects[0] = "타입이 달라 넣을 수 없다."; // ArrayStoreException 발생

배열은 공변성을 만족하기 때문에 Integer[] 배열을 Object[]로 할당할 수 있습니다.

하지만 이후 Object[]에 다른 타입(String)을 저장한다면, Integer[]로 초기화된 배열이기 때문에 오직 Integer 타입만 허용되어 ArrayStoreException이 발생하게 됩니다.

 

아쉽게도 이 예외는 컴파일 시점에서는 발견할 수 없고, 런타임 시점에 타입 불일치로 인해 발생하게 됩니다. 배열의 공변성 덕분에 다른 타입을 허용하는 것처럼 보이지만, 실제로는 저장된 타입에 맞는 객체만 허용되기 때문입니다.

 

불공변성

반면, 제네릭은 불공변성을 가집니다.
즉, 상위 클래스인 Object와 하위 클래스인 Integer가 있을 때 List<Object>List<Integer>는 아무런 관계가 없습니다.

List<Integer> integers = new ArrayList<>();
integers.add(1);
integers.add(2);

List<Object> objects = integers; // 호환되지 않기에 컴파일 에러 발생

제네릭은 불공변성을 만족하기 때문에, 위와 같은 실수를 런타임 시점이 아닌 컴파일 시점에 바로 발견할 수 있어 타입 안정성을 가지고 예외를 방지할 수 있습니다.

 

 

두 번째 이유 : 런타임의 형 변환 오류

배열로 형변환을 할 때 제네릭 배열 생성 오류나 비검사 형변환 경고(warning)가 뜨는 경우 E[] 대신 컬렉션 List<E>를 사용하면 대부분 해결됩니다.

 

간단한 예를 들어보겠습니다.

public class Chooser {
    private final Object[] choiceArray;

    public Chooser(Object[] choices) {
        this.choiceArray = choiceArray;
    }

    public Object choose() {
        Random random = ThreadLocalRandom.current();
        return choiceArray[random.nextInt(choiceArray.length)];
    }
}

Chooser 클래스는 컬렉션 안의 원소 중 하나를 무작위로 선택해 반환하는 choose 메서드를 제공하며, 생성자에 어떤 컬렉션을 넘기느냐에 따라 choiceArray 필드가 결정됩니다.

Integer[] integers = {1,2,3,4};

Chooser c = new Chooser(integers);
Integer chosenInteger = (Integer) c.choose(); // 올바른 타입으로 다운 캐스팅
System.out.println(chosenInteger);

String chosenString = (String) c.choose(); // 잘못된 타입으로 다운 캐스팅
System.out.println(chosenString); // 런타임시에 ClassCastException 발생

이 클래스를 사용하려면 choose 메서드를 호출할 때 마다 반환된 Object를 원하는 타입으로 형변환 해야하며, 잘못된 타입으로 다운캐스팅 한다면 런타임 시점에 형변환 예외가 발생하게 됩니다.

 

따라서 이 클래스를 제네릭 클래스로 변경해보겠습니다.

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

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

    public T choose() {
        Random random = ThreadLocalRandom.current();
        return choiceArray[random.nextInt(choiceArray.length)];
    }
}

 

Chooser 클래스가 제네릭으로 변경됨에 따라 실행코드도 아래와 같이 수정할 수 있습니다.

List<Integer> integers = List.of(1,2,3,4);
Chooser<Integer> c = new Chooser<>(integers);
Integer chosenInteger = c.choose();
System.out.println(chosenInteger);

String chosenString = (String) c.choose(); // 컴파일 에러 발생
System.out.println(chosenString);

이전 코드에서는 반환된 값을 다운캐스팅해야 했기 때문에 타입 안전성을 보장하지 못했지만 제네릭을 적용함으로써 컴파일 시점에서 타입이 고정되므로, 잘못된 타입으로 형변환할 수 없게 되어 런타임 시점에 발생할 수 있는 오류를 방지할 수 있습니다.

 

이처럼 제네릭을 사용하면 불필요한 형변환을 제거하고, 더 안전하고 읽기 쉬운 코드를 작성할 수 있습니다.

 

하지만, 제네릭 클래스로 변경 후 컴파일을 하면 이번에는 warning이 발생합니다.

 

제네릭에서 타입 파라미터 T 가 어떤 타입인지 컴파일러가 알 수 없으므로, 이 형변환이 런타임에도 안전한지 보장할 수 없다는 것입니다.

제네릭이 타입 정보를 소거하기 때문에 런타임 시점에 어떤 타입이 사용되는지 알 수 없기 때문입니다.

 

비검사 경고(warning) 이기 때문에 코드가 문제 없이 실행되기는 하지만, 타입 안전성을 보장하지 못합니다.

 

위와 같은 비검사 경고는 배열을 리스트로 변경한다면 간단하게 제거할 수 있습니다.

public class Chooser<T> {
    private final List<T> choiceArray; // 배열을 리스트로 변경

    public Chooser(Collection<T> choices) {
        this.choiceArray = new ArrayList<>(choices); // 배열을 리스트로 변경
    }

    public T choose() {
        Random random = ThreadLocalRandom.current();
        return choiceArray.get(random.nextInt(choiceArray.size())); // 배열을 리스트로 변경 
    }
}

위와 같이 코드를 수정하면 리스트를 사용하게 되어 런타임에서 형변환 예외가 발생할 가능성이 없어집니다.

코드의 양이 조금 늘어나고, 성능이 약간 느려질 수는 있지만, 그만큼 안전성을 높일 수 있습니다.

 

정리

  1. 배열의 공변성과 제네릭의 불공변성
    • 배열은 공변성을 가지고 있어 상위타입과 하위타입 간의 호환이 가능하지만, 런타임 시점에 타입 불일치로 인한 오류가 발생할 수 있다.
    • 제네릭은 불공변성을 가지며, 컴파일 시점에 타입 안전성을 보장하기에 잘못된 타입을 미리 차단할 수 있어 오류 발생 가능성을 줄인다.
  2. 런타임의 형 변환 오류
    • 제네릭을 사용하면 컴파일 시점에서 타입이 고정되므로, 잘못된 타입으로 형변환할 수 없어 런타임 시점에 발생할 수 있는 형변환 예외를 방지할 수 있다.
    • 배열을 사용하는 경우 형변환 예외 발생 가능성이 있으므로, 리스트로 대체하는 것이 안전하다.
  3. 비검사 경고 해결
    • 제네릭에서 타입 정보가 소거되므로 컴파일러가 런타임 시 타입 안전성을 보장할 수 없다.
    • 배열을 리스트로 대체하면 이러한 비검사 경고를 제거할 수 있으며, 더 안전한 코드 작성이 가능하다.

댓글