이펙티브 자바 완벽 공략 2부

아이템 28. 배열보다는 리스트를 사용하라

아이템 28. 배열보다는 리스트를 사용하라


아이템 28. 핵심 정리 1

아이템 28. 배열보다는 리스트를 사용하라

Java의 배열과 제네릭은 얼핏 보면 비슷한 역할을 한다.

String[] array;

List<String> list;

둘 다 여러 개의 String을 저장하는 컨테이너처럼 보인다.

하지만 Java 타입 시스템의 관점에서는 두 방식이 상당히 다르게 동작한다.

이번 아이템을 이해하기 위해서는 크게 세 가지 개념을 알아야 한다.

공변과 불공변

실체화 타입과 비실체화 타입

타입 소거

이 용어들은 처음 보면 어렵지만 결국 하나의 질문으로 연결된다.

잘못된 타입의 객체를 저장하려고 했을 때 Java는 언제 그 사실을 발견하는가?

배열은 경우에 따라 런타임까지 가야 문제를 발견하지만, 제네릭을 사용한 List는 대부분의 잘못된 타입 사용을 컴파일 시점에 차단한다.

이 차이가 아이템 28에서 배열보다 리스트를 권장하는 가장 중요한 이유다.


배열은 공변이다

먼저 공변(Covariant) 부터 살펴보자.

StringObject의 하위 타입이다.

Object
  ↑
String

따라서 다음 대입은 당연히 가능하다.

String text = "hello";

Object object = text;

그렇다면 배열은 어떨까?

Java의 배열은 공변이다.

따라서 String[] 역시 Object[]로 참조할 수 있다.

String[] strings = {
        "A",
        "B",
        "C"
};

Object[] objects = strings;

이 코드도 정상적으로 컴파일된다.

관계를 표현하면 다음과 같다.

String <: Object

그리고 배열은 공변이므로

String[] <: Object[]

즉 배열의 요소 타입에 존재하는 상속 관계가 배열 타입에도 그대로 이어진다.

이것이 공변이다.


공변을 쉽게 이해하면

다음 관계가 있다고 하자.

String → Object

배열을 씌워도 관계가 함께 움직인다.

String[] → Object[]

그래서

Object[] objects =
        new String[10];

이 가능하다.

이것 자체는 편리해 보인다.

하지만 바로 여기에서 배열의 타입 안전성 문제가 발생한다.


Object[]로 참조하지만 실제 객체는 String[]이다

다음 코드를 보자.

Object[] objects =
        new String[10];

변수의 선언 타입만 보면

Object[]

이다.

따라서 다음 코드도 소스만 보면 가능해 보인다.

objects[0] = 100;

Integer 역시 Object이기 때문이다.

각 줄을 따로 보면 컴파일러 입장에서는 문제가 없다.

Object[] objects =
        new String[10];

String[]Object[]의 하위 타입이므로 정상이다.

그리고

objects[0] = 100;

도 변수 타입만 보면 Object[]이기 때문에 정상처럼 보인다.

하지만 두 코드를 연결해서 보면 문제가 명확하다.

실제 배열은 무엇일까?

String[]

이다.

결국 다음 일을 시도하는 셈이다.

String[] 안에 Integer 저장

당연히 허용할 수 없는 동작이다.


컴파일은 성공하지만 런타임에서 실패한다

다음 코드는 컴파일된다.

Object[] objects =
        new String[10];

objects[0] = 100;

하지만 실행하면 예외가 발생한다.

ArrayStoreException

왜냐하면 JVM은 실제 배열 객체가 String[]이라는 사실을 런타임에도 알고 있기 때문이다.

변수 타입

Object[]


실제 객체 타입

String[]

따라서 Integer를 저장하려는 순간 JVM이 검사한다.

Integer를 String[]에 저장 가능한가?

↓

NO

↓

ArrayStoreException

배열의 문제는 타입 오류를 늦게 발견한다는 것이다

문제의 본질은 배열이 무조건 위험하다는 것이 아니다.

다음 코드가 문제다.

Object[] objects =
        new String[10];

objects[0] = 100;

타입 관점에서 잘못된 코드인데도 컴파일러가 막지 못했다.

오류가 실행 시점까지 미뤄졌다.

잘못된 코드 작성

↓

컴파일 성공

↓

프로그램 실행

↓

잘못된 배열 저장 수행

↓

ArrayStoreException

앞선 제네릭 아이템에서 계속 강조했던 원칙과 연결된다.

가능하다면

Runtime Error

보다

Compile Time Error

가 좋다.

잘못된 코드를 실행하기도 전에 발견하는 것이 더 안전하다.


List의 제네릭은 불공변이다

이번에는 같은 구조를 List로 만들어보자.

List<String> strings =
        new ArrayList<>();

StringObject의 하위 타입이다.

하지만 다음 코드는 허용되지 않는다.

List<Object> objects =
        strings;

컴파일 오류가 발생한다.

String <: Object

라고 해서

List<String> <: List<Object>

가 되는 것은 아니다.

Java의 일반적인 제네릭 타입은 이런 관계에서 불공변 또는 불변(Invariant) 이다.


배열과 제네릭을 비교하면

배열은 다음 관계가 성립한다.

String <: Object

↓

String[] <: Object[]

하지만 제네릭은 그렇지 않다.

String <: Object

하지만

List<String> ≠ List<Object>

이것이 두 타입 시스템의 중요한 차이다.


왜 List을 List로 사용할 수 없을까?

만약 가능하다고 가정해보자.

List<String> strings =
        new ArrayList<>();

List<Object> objects =
        strings;

objects의 타입은 List<Object>다.

그러면 다음 코드가 가능해야 한다.

objects.add(100);

IntegerObject이기 때문이다.

그런데 실제 리스트는

List<String>

이다.

결국 다음 상태가 만들어진다.

List<String>

[
    "hello",
    100
]

제네릭의 타입 안전성이 깨진다.

그래서 Java는 애초에 첫 번째 대입부터 허용하지 않는다.

List<Object> objects =
        strings;

바로 컴파일 오류를 발생시킨다.


배열과 List의 실패 시점 차이

배열의 경우 다음 코드는 컴파일된다.

Object[] objects =
        new String[10];

그리고 나중에

objects[0] = 100;

를 실행하는 순간 실패한다.

Runtime

↓

ArrayStoreException

제네릭은 다음 단계에서 이미 막는다.

List<String> strings =
        new ArrayList<>();

List<Object> objects =
        strings;
Compile Time Error

비교하면 다음과 같다.

구분배열제네릭 List
타입 관계공변불공변
StringObject 관계 전파String[] → Object[] 가능List<String> → List<Object> 불가능
잘못된 타입 발견 가능 시점런타임 가능컴파일 타임
대표적인 오류ArrayStoreException컴파일 오류
타입 안전성상대적으로 약함상대적으로 강함

이것이 배열보다 제네릭 컬렉션을 선호해야 하는 첫 번째 이유다.


배열과 List의 성능을 혼동하면 안 된다

여기에서 한 가지 중요한 부분을 정확하게 구분해야 한다.

배열은 인덱스를 통해 빠르게 접근할 수 있다.

String value =
        array[100];

일반적으로 O(1) 접근이다.

그렇다고 해서 List가 무조건 처음부터 순차적으로 탐색하는 것은 아니다.

예를 들어 ArrayList도 내부적으로 배열을 사용하므로

String value =
        list.get(100);

와 같은 인덱스 접근은 일반적으로 O(1)이다.

반면 LinkedList의 인덱스 접근은 O(n)이 될 수 있다.

즉 성능은

배열 vs List

만으로 단순히 나눌 수 있는 문제가 아니다.

Array

→ 인덱스 접근 O(1)


ArrayList

→ 인덱스 접근 O(1)


LinkedList

→ 특정 인덱스 접근 O(n)

따라서 아이템 28의 핵심은 성능보다는 타입 시스템과 타입 안전성의 차이에 있다.


두 번째 차이: 배열은 실체화된다

이번에는 조금 어려운 용어인 실체화(Reification) 를 살펴보자.

Java 타입 시스템에서 실체화되었다는 것은 간단하게 말하면

런타임에도 해당 타입에 대한 충분한 정보를 알고 있다.

는 의미다.

배열은 실체화되는 타입이다.

예를 들어

String[] strings =
        new String[10];

이라는 배열 객체가 만들어지면 JVM은 런타임에도 이것이 String[]이라는 사실을 알고 있다.

그래서 다음 코드가 실행될 때

Object[] objects =
        strings;

objects[0] =
        Integer.valueOf(10);

JVM은 검사할 수 있다.

현재 배열의 실제 타입

String[]

↓

저장하려는 객체

Integer

↓

호환되지 않음

↓

ArrayStoreException

배열의 실제 요소 타입이 런타임에도 존재하기 때문에 가능한 검사다.


제네릭의 타입 인수는 대부분 런타임에 소거된다

이번에는 다음 코드를 보자.

List<String> strings =
        new ArrayList<>();

소스 코드에는 분명히

String

이라는 제네릭 타입 정보가 있다.

컴파일러는 이 정보를 적극적으로 사용한다.

다음 코드를 막는다.

strings.add(100);

그리고 다음 코드는 안전하게 사용할 수 있게 한다.

String value =
        strings.get(0);

하지만 Java 제네릭은 기본적으로 타입 소거(Type Erasure) 방식으로 구현된다.

따라서 런타임 객체가 다음처럼 별도의 클래스로 만들어지는 것은 아니다.

ArrayListOfString

ArrayListOfInteger

ArrayListOfOrder

실제로는 모두 기본적으로 같은 ArrayList 클래스 구현을 사용한다.


타입 소거를 개념적으로 이해해보자

다음 코드를 작성했다고 하자.

List<String> values =
        new ArrayList<>();

values.add("hello");

String value =
        values.get(0);

소스 수준에서 컴파일러는 String이라는 타입 정보를 알고 있다.

컴파일 이후의 동작을 이해하기 위한 개념적인 모습은 다음과 비슷하다.

List values =
        new ArrayList();

values.add("hello");

String value =
        (String) values.get(0);

컴파일 전에

List<String>

이라는 타입 정보를 이용해 타입 안전성을 검사하고,

필요한 위치에는 컴파일러가 형변환을 추가한다.


타입 소거는 단순히 모든 타입 정보가 완전히 없어진다는 뜻은 아니다

타입 소거를

컴파일되면 제네릭과 관련된 정보가
무조건 하나도 남지 않는다.

라고 이해하면 지나치게 단순하다.

클래스 파일에는 제네릭 선언과 관련한 일부 메타데이터가 Signature 등의 형태로 남을 수 있고 Reflection을 통해 선언 수준의 제네릭 정보를 확인할 수도 있다.

하지만 중요한 것은 런타임 객체 자체가 List<String>List<Integer>라는 서로 다른 실체화 타입으로 구분되는 것은 아니라는 것이다.

예를 들어 런타임에 다음과 같은 검사는 할 수 없다.

if (value instanceof List<String>) {
}

구체적인 String 타입 인수를 런타임에서 검사할 수 없기 때문이다.


배열은 실체화되고 제네릭은 대부분 실체화되지 않는다

두 구조를 비교하면 다음과 같다.

배열

new String[10]

런타임에도 알고 있다.

String[]

그래서 저장할 때 타입을 검사할 수 있다.


제네릭

new ArrayList<String>()

컴파일러는 String 정보를 이용하지만 런타임에 ArrayList<String>라는 별도 클래스 인스턴스가 만들어지는 것은 아니다.

이것이

Array

→ Reified


Generic Parameterized Type

→ 대부분 Non-Reifiable

라고 설명하는 이유다.


타입 소거를 사용한 이유

Java에 제네릭이 추가된 것은 Java 5부터다.

그 이전에는 이미 다음과 같은 코드가 광범위하게 존재했다.

List list =
        new ArrayList();

Java 5에서 제네릭을 도입하면서 기존 라이브러리와 코드 생태계와의 마이그레이션 호환성을 유지해야 했다.

그래서 기존의 List와 완전히 별개의 새로운 런타임 타입 체계를 만드는 대신 타입 소거를 중심으로 제네릭을 구현했다.

다만 한 가지는 정확하게 구분해야 한다.

Java 5에서 컴파일된 클래스 파일이 Java 4 JVM에서도 그대로 실행되도록 하기 위해 타입 소거를 사용했다는 의미는 아니다.

Java 클래스 파일에는 버전이 있기 때문에 최신 Java 버전으로 컴파일된 클래스 파일을 무조건 더 오래된 JVM에서 실행할 수 있는 것은 아니다.

핵심은 Java 5 이전에 작성된 기존 API와 코드가 새로운 제네릭 코드와 함께 사용할 수 있도록 마이그레이션 호환성을 확보하는 것에 가깝다.


배열과 제네릭이 잘 어울리지 않는 이유

이제 두 특징을 한 번에 모아보자.

배열은

공변

+

실체화

되어 있다.

제네릭은

불공변

+

대부분 비실체화

되어 있다.

정반대 성격을 가진다.

특징배열제네릭
타입 관계공변불공변
런타임 요소 타입 정보유지타입 인수 대부분 소거
타입 검사런타임에도 수행주로 컴파일 시점
실체화 여부실체화대부분 비실체화

이러한 차이 때문에 배열과 제네릭을 섞으면 타입 안전성을 보장하기 까다로운 문제가 생긴다.


제네릭 배열을 만들어보자

다음과 같은 배열을 만들고 싶다고 생각해보자.

List<String>[] stringLists =
        new List<String>[10];

Java에서는 컴파일되지 않는다.

generic array creation

이라는 오류를 만나게 된다.

왜 Java는 이런 배열을 만들지 못하게 할까?

단순히 문법을 제한한 것이 아니라 타입 안전성이 깨질 수 있기 때문이다.


만약 List[]을 허용한다고 가정해보자

실제로는 컴파일되지 않지만 이해를 위해 가능하다고 가정해보자.

List<String>[] stringLists =
        new List<String>[1];

이 객체는

List<String>의 배열

이다.

이제 배열의 공변성을 이용한다.

배열은 공변이므로

Object[] objects =
        stringLists;

가 가능해야 한다.

구조는 다음과 같다.

List<String>[]

↓

Object[]

Integer 리스트를 하나 만든다

다음 리스트가 있다고 하자.

List<Integer> integers =
        List.of(42);

이 객체 자체는 Object다.

따라서 현재 변수의 타입이 Object[]objects에는 다음처럼 넣을 수 있어 보인다.

objects[0] =
        integers;

여기서 배열이 일반적인 실체화 타입처럼 List<String>까지 완전히 검사할 수 있다면 막아야겠지만 문제가 있다.


런타임에는 String이라는 제네릭 타입 정보를 충분히 알 수 없다

제네릭 타입 인수인

<String>

은 런타임 객체의 실체화 타입으로 유지되지 않는다.

결국 런타임 관점에서는 배열의 요소가 대략

List

라는 정도의 정보만으로 처리될 수밖에 없다.

그리고 실제로 넣는 객체 역시

List<Integer>

지만 런타임에 실체화된 객체 타입은 기본적으로

List

계열이다.

따라서

List 배열

↓

List 객체 저장

자체는 런타임 배열 저장 검사만으로 구체적인 제네릭 타입 인수까지 구별할 수 없다.


이제 타입 안전성이 깨진다

처음 변수는 다음 타입이었다.

List<String>[] stringLists

개발자는 당연히 다음을 믿는다.

stringLists 안에는
List<String>만 들어 있다.

하지만 앞에서

objects[0] =
        List.of(42);

를 수행했다.

실제로 첫 번째 원소는 List<Integer>가 된다.

그런데 소스 코드의 타입 시스템에서는 여전히

stringLists[0]

List<String>이라고 생각한다.

따라서 다음 코드도 정상적으로 컴파일될 것이다.

String value =
        stringLists[0].get(0);

결국 ClassCastException으로 이어진다

실제로 들어 있는 값은

Integer 42

다.

하지만 컴파일러는 List<String>이라고 알고 있기 때문에 꺼낸 값을 String으로 취급한다.

타입 소거 이후 필요한 곳에서는 개념적으로 다음과 같은 형변환이 추가된다.

String value =
        (String) stringLists[0]
                .get(0);

그러면 결국

Integer

↓

String으로 형변환

↓

ClassCastException

이 발생한다.

즉 제네릭 배열을 허용한다면 제네릭이 보장하려고 했던 핵심 원칙이 무너진다.

잘못된 타입 사용

↓

컴파일 성공

↓

런타임 ClassCastException

Java가 제네릭 배열 생성을 금지하는 이유

바로 이런 상황을 막기 위해 Java에서는 다음을 허용하지 않는다.

new List<String>[10];
new List<Integer>[10];
new E[10];

일반적으로 실체화되지 않는 제네릭 타입을 원소로 하는 배열을 직접 만들 수 없다.

컴파일 시점에서 막는다.

Generic Array Creation

↓

Compile Time Error

이는 불편한 제한처럼 보이지만 오히려 제네릭의 타입 안전성을 지키기 위한 제한이다.


제네릭 배열 문제를 전체 흐름으로 보면

만약 허용된다고 가정하면 다음 과정이 가능해진다.

List<String>[] 생성

↓

배열 공변성 사용

↓

Object[]로 참조

↓

List<Integer> 저장

↓

런타임 배열 검사는
String 타입 인수를 확인할 수 없음

↓

List<Integer> 저장 성공 가능

↓

List<String>이라고 믿고 조회

↓

Integer를 String으로 변환

↓

ClassCastException

Java는 이 상황 자체를 만들지 못하도록

첫 번째 단계

List<String>[] 생성

부터 금지한다.


왜 List를 사용하면 이 문제가 사라질까?

배열 대신 다음과 같이 작성한다고 생각해보자.

List<List<String>> lists =
        new ArrayList<>();

이제 List<Integer>를 넣으려고 한다.

lists.add(
        List.of(42)
);

컴파일러가 즉시 막는다.

왜냐하면 lists의 요소 타입은 정확하게

List<String>

이기 때문이다.

List<List<String>>

↓

추가 가능한 타입

List<String>

List<Integer>는 다른 타입이다.

List<Integer>

≠

List<String>

따라서 타입 오류가 런타임까지 가지 않는다.


배열에서는 Runtime, List에서는 Compile Time

두 코드를 비교해보자.

배열

Object[] values =
        new String[10];

values[0] = 42;

컴파일된다.

실행하면

ArrayStoreException

이 발생한다.


제네릭 List

List<String> values =
        new ArrayList<>();

values.add(42);

애초에 컴파일되지 않는다.

Compile Error

이 차이가 핵심이다.


아이템 26, 27과도 연결된다

지금까지 제네릭 관련 아이템에서는 계속 비슷한 원칙이 등장했다.

아이템 26에서는

Raw Type을 사용하지 마라.

↓

타입 정보를 유지하라.

라고 했다.

아이템 27에서는

Unchecked Warning을 제거하라.

↓

컴파일러가 타입 안전성을
최대한 검증할 수 있도록 하라.

라고 했다.

그리고 아이템 28에서는

제네릭과 배열 중 선택할 수 있다면
List를 고려하라.

↓

타입 오류를 컴파일 시점에 발견하라.

라고 말한다.

결국 세 아이템의 방향은 동일하다.

Runtime에 타입 오류를 발견하지 말고

Compile Time에 최대한 발견하자.

실체화 타입이라는 개념을 조금 더 정확하게 이해하자

모든 제네릭 관련 타입이 무조건 비실체화 타입인 것은 아니다.

예를 들어 다음과 같은 타입들은 실체화 타입에 포함될 수 있다.

String

Integer

String[]

List

List<?>

반면 다음처럼 구체적인 제네릭 타입 인수를 가진 매개변수화 타입은 일반적으로 실체화되지 않는다.

List<String>

List<Integer>

Map<String, Integer>

그래서 다음은 불가능하다.

new List<String>[10];

하지만 비한정적 와일드카드는 실체화 가능한 형태이기 때문에 다음과 같은 선언은 가능하다.

List<?>[] lists =
        new List<?>[10];

이 차이는 제네릭을 깊이 있게 이해할 때 중요하다.

다만 일반적인 애플리케이션 코드에서는 이런 제네릭 배열을 적극적으로 만드는 것보다

List<List<?>>

같은 컬렉션 기반 설계를 먼저 고려하는 편이 이해와 유지보수 측면에서 자연스러운 경우가 많다.


배열이 나쁜 자료구조라는 뜻은 아니다

이번 아이템의 제목만 보면

배열은 쓰지 마라.

처럼 받아들일 수 있지만 그런 의미는 아니다.

배열이 자연스러운 상황도 분명히 있다.

예를 들어

byte[] buffer =
        new byte[8192];

와 같은 바이너리 데이터 처리나

int[] scores =
        new int[100];

처럼 고정된 크기의 primitive 데이터를 다루는 경우에는 배열이 매우 적절하다.

또한 저수준 성능이나 특정 API와의 연동에서도 배열이 필요할 수 있다.

핵심은 제네릭 타입을 이용해 컬렉션을 표현할 수 있는 상황에서 배열의 공변성과 실체화 특성 때문에 타입 안전성이 약해지는 구조를 만들지 말라는 것이다.


배열과 ArrayList의 선택을 성능만으로 판단하지 말자

배열과 ArrayList 모두 내부적으로 연속적인 배열 구조를 활용할 수 있으므로 인덱스 조회는 일반적으로 빠르다.

array[index]

list.get(index)

모두 일반적으로 O(1)이다.

물론 배열은 primitive를 직접 저장할 수 있고 객체 구조가 더 단순하기 때문에 메모리나 일부 성능 측면에서 유리할 수 있다.

예를 들어

int[]

List<Integer>

는 boxing 비용과 메모리 구조가 다르다.

따라서 저수준 성능이 중요한 코드에서는 배열이 유리할 수 있다.

하지만 일반적인 객체 컬렉션 API 설계에서는

성능

뿐 아니라

타입 안전성

API 표현력

크기 변경 편의성

컬렉션 API 활용

까지 함께 고려해야 한다.


실무에서는 배열보다 List가 편한 이유도 많다

배열은 크기가 생성 시점에 고정된다.

String[] names =
        new String[10];

반면 ArrayList는 동적으로 크기를 조절할 수 있다.

List<String> names =
        new ArrayList<>();

names.add("A");
names.add("B");
names.add("C");

또한 Collection API를 사용할 수 있다.

names.removeIf(
        name -> name.isBlank()
);
names.sort(
        Comparator.naturalOrder()
);
names.contains("Yoon");

제네릭의 타입 안전성과 함께 풍부한 Collection API를 사용할 수 있다는 것도 실무에서 List가 자주 선택되는 이유다.


API를 설계할 때도 타입 안전성을 생각하자

다음과 같은 API를 만들었다고 하자.

public Object[] findAll() {
    ...
}

클라이언트는 각 원소가 어떤 타입인지 정확하게 알기 어렵다.

반면

public List<Order> findAll() {
    ...
}

처럼 작성하면 API 자체가 의미를 전달한다.

반환되는 모든 요소는 Order다.

컴파일러도 그 계약을 검사해준다.

즉 List를 선택하는 것은 단순한 컨테이너 선택이 아니라 API의 타입 계약을 명확하게 만드는 것과도 연결된다.


배열과 제네릭을 비교할 때 기억할 두 축

이번 내용을 가장 단순하게 정리하면 두 개의 축만 기억하면 된다.

첫 번째: 공변성

배열

String[] → Object[]

가능


제네릭

List<String> → List<Object>

불가능

배열은 공변이고 일반적인 제네릭 타입은 불공변이다.


두 번째: 실체화

String[]

↓

Runtime에도 String[]임을 안다.

반면

List<String>

↓

String 타입 인수는
런타임 객체 타입으로 실체화되지 않는다.

그래서 제네릭 배열과 배열의 런타임 타입 검사를 함께 유지하면 타입 시스템에 모순이 생길 수 있다.

Java는 그 문제를 막기 위해 제네릭 배열 생성을 제한한다.


전체 구조로 정리해보자

배열

공변
+
실체화

↓

String[]을 Object[]로 참조 가능

↓

잘못된 값을 넣으면

↓

Runtime
ArrayStoreException

반면

제네릭

불공변
+
타입 인수 대부분 비실체화

↓

List<String>을
List<Object>로 참조할 수 없음

↓

잘못된 타입 관계를

↓

Compile Time에 차단

그래서 제네릭과 함께 객체 컬렉션을 설계하는 상황에서는 List가 더 자연스럽고 타입 안전한 선택인 경우가 많다.


핵심 정리

  • Java의 배열과 제네릭은 타입 시스템에서 서로 다른 방식으로 동작한다.
  • 배열은 공변이므로 String[]Object[] 변수로 참조할 수 있다.
  • String[]Object[]로 참조한 뒤 Integer를 저장하는 코드는 컴파일되지만 런타임에 ArrayStoreException이 발생한다.
  • 배열의 공변성 때문에 일부 타입 오류가 컴파일 시점이 아니라 런타임까지 미뤄질 수 있다.
  • Java의 일반적인 제네릭 타입은 불공변이므로 StringObject의 하위 타입이어도 List<String>List<Object>의 하위 타입이 아니다.
  • List<String>List<Object>로 사용할 수 있게 허용하면 Integer 같은 객체를 String 리스트에 넣을 수 있어 타입 안전성이 깨지기 때문에 Java가 컴파일 단계에서 이를 차단한다.
  • 배열은 실체화 타입이므로 JVM이 런타임에도 배열의 실제 요소 타입을 알고 있다.
  • 그래서 String[]Integer를 저장하려 하면 JVM이 런타임에 이를 감지할 수 있다.
  • List<String> 같은 매개변수화 타입의 구체적인 타입 인수는 타입 소거로 인해 런타임 객체 타입으로 완전히 실체화되지 않는다.
  • 타입 소거는 Java 5 이전의 기존 코드와 새로운 제네릭 코드 사이의 마이그레이션 호환성을 고려한 설계와 밀접하게 관련되어 있다.
  • Java 5로 컴파일한 클래스가 Java 4 JVM에서 그대로 실행될 수 있다는 의미의 하위 버전 호환성과는 구분해야 한다.
  • 타입 소거 이후에도 일부 제네릭 메타데이터가 클래스 파일에 남을 수 있으므로 “제네릭 정보가 모든 의미에서 완전히 사라진다”고 이해하면 지나치게 단순하다.
  • 배열은 공변이면서 실체화되고 제네릭은 불공변이면서 대부분 비실체화되기 때문에 둘을 조합하면 타입 안전성을 유지하기 어렵다.
  • new List<String>[10] 같은 제네릭 배열 생성을 Java가 허용하지 않는 이유도 이러한 타입 안전성 문제 때문이다.
  • 제네릭 배열이 허용된다면 배열의 공변성을 통해 Object[]로 참조한 뒤 List<Integer>를 저장하고 나중에 List<String>으로 읽어 ClassCastException이 발생하는 상황을 만들 수 있다.
  • Java는 이러한 문제를 런타임까지 미루지 않고 제네릭 배열 생성 자체를 컴파일 시점에 차단한다.
  • 배열이 모든 상황에서 나쁜 것은 아니며 primitive 배열, byte buffer, 고정 크기 데이터, 저수준 성능이 중요한 코드에서는 여전히 적절하다.
  • ArrayList 역시 get(index)가 일반적으로 O(1)이므로 List가 항상 처음부터 순차 탐색한다는 설명은 정확하지 않다.
  • 배열과 List의 선택은 단순한 성능 차이보다 타입 안전성, 데이터 크기 변화, API 표현력, 컬렉션 기능 등을 함께 고려해야 한다.
  • 아이템 26의 Raw Type 제거, 아이템 27의 비검사 경고 제거, 아이템 28의 List 선호는 모두 타입 오류를 가능한 한 런타임이 아니라 컴파일 시점에 발견하자는 동일한 방향을 가지고 있다.

한 줄 요약

배열은 공변이면서 런타임에 요소 타입이 실체화되기 때문에 잘못된 타입 저장이 ArrayStoreException으로 늦게 발견될 수 있지만, 제네릭 List는 불공변성을 통해 잘못된 타입 관계를 컴파일 단계에서 차단하므로 제네릭 컬렉션을 설계할 때는 가능하면 배열보다 List를 사용해 타입 오류를 더 일찍 발견하는 것이 안전하다.


© 2020. All rights reserved.

SIKSIK