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

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

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


아이템 28. 핵심 정리 1

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

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

String[] array;

List<String> list;

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

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

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

공변과 불공변

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

타입 소거

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

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

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

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


배열은 공변이다

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

String은 Object의 하위 타입이다.

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

String은 Object의 하위 타입이다.

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

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

Integer도 Object이기 때문이다.

그런데 실제 리스트는

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
타입 관계공변불공변
String → Object 관계 전파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의 일반적인 제네릭 타입은 불공변이므로 String이 Object의 하위 타입이어도 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를 사용해 타입 오류를 더 일찍 발견하는 것이 안전하다.

아이템 28. 핵심 정리 2

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

배열과 제네릭의 차이를 개념적으로 이해했다면 이제 실제 코드에서 왜 배열보다 리스트를 사용하는 것이 좋은지 살펴볼 수 있다.

앞에서는 다음 두 가지 중요한 차이를 확인했다.

배열
→ 공변
→ 실체화됨

제네릭
→ 불공변
→ 대부분 타입 소거됨

이번에는 이러한 차이가 실제 API 설계에 어떤 영향을 주는지 Chooser라는 간단한 클래스를 통해 살펴보자.

Chooser는 컬렉션을 하나 받아 그 안의 원소 중 하나를 무작위로 선택해서 반환하는 클래스다.

기능 자체는 단순하다.

Collection 전달

↓

내부에 원소 저장

↓

무작위 인덱스 선택

↓

해당 원소 반환

하지만 이 클래스를 배열 기반으로 설계하느냐 List 기반으로 설계하느냐에 따라 타입 안전성이 크게 달라진다.


배열을 사용하는 첫 번째 Chooser

먼저 제네릭을 사용하지 않고 배열로 구현해보자.

public class Chooser {

    private final Object[] choiceArray;

    public Chooser(
            Collection choices
    ) {
        choiceArray = choices.toArray();
    }

    public Object choose() {

        Random random = new Random();

        return choiceArray[
                random.nextInt(
                        choiceArray.length
                )
        ];
    }
}

생성자로 Collection을 전달받는다.

public Chooser(
        Collection choices
)

그리고 컬렉션의 내용을 배열로 변환한다.

choiceArray = choices.toArray();

Collection.toArray()의 반환 타입은

Object[]

이므로 내부 필드 역시 다음과 같다.

private final Object[] choiceArray;

범용적으로 사용할 수 있다는 장점은 있다

이 구조는 어떤 객체든 받아들일 수 있다.

숫자를 전달할 수도 있다.

List<Integer> numbers =
        List.of(
                10,
                20,
                30
        );

Chooser chooser =
        new Chooser(numbers);

문자열도 가능하다.

List<String> names =
        List.of(
                "A",
                "B",
                "C"
        );

Chooser chooser =
        new Chooser(names);

내부적으로 모든 객체는 Object로 다루기 때문이다.

하지만 바로 이 부분에서 문제가 생긴다.


choose()의 반환 타입이 Object다

Chooser는 내부 타입을 알지 못하기 때문에 다음과 같이 반환한다.

public Object choose()

따라서 클라이언트는 결과를 사용할 때 매번 형변환해야 한다.

Integer number =
        (Integer) chooser.choose();

문자열이라면 다음과 같다.

String name =
        (String) chooser.choose();

즉 API를 사용하는 클라이언트가 실제 타입을 알고 있어야 한다.


잘못된 형변환을 컴파일러가 막을 수 없다

문제는 다음 코드도 컴파일될 수 있다는 것이다.

List<String> names =
        List.of(
                "A",
                "B",
                "C"
        );

Chooser chooser =
        new Chooser(names);

Integer number =
        (Integer) chooser.choose();

실제로 Chooser 안에는 String만 들어 있다.

그런데 클라이언트는 결과를 Integer로 형변환했다.

컴파일러 입장에서 choose()의 반환 타입은 Object다.

Object

↓

Integer로 명시적 형변환

문법적으로 가능한 형변환이기 때문에 컴파일러가 완전히 막아주지 못한다.

실제로 실행해서 String이 반환되면 그제야 문제가 발생한다.

String 객체

↓

Integer로 cast

↓

ClassCastException

문제를 발견하는 시점이 너무 늦다

실제로 잘못된 것은 다음 코드다.

Integer number =
        (Integer) chooser.choose();

하지만 이것이 잘못되었다는 사실을 실행 전에는 확실하게 확인하지 못한다.

컴파일

성공

↓

프로그램 실행

↓

choose() 호출

↓

String 반환

↓

Integer로 형변환

↓

ClassCastException

앞선 제네릭 아이템에서 계속 강조했던 문제가 다시 등장한다.

타입 오류라면 가능한 한 런타임이 아니라 컴파일 시점에 발견하는 것이 좋다.


그렇다고 Integer[]로 만들 수는 없다

만약 이 Chooser가 숫자만 처리한다면 단순하게 해결할 수도 있다.

private final Integer[] choiceArray;

이렇게 하면 타입이 명확해진다.

하지만 Chooser의 목적은 특정 타입만 처리하는 것이 아니다.

다음 모두를 처리하고 싶다.

Integer

String

Member

Order

Payment

...

그렇다고 모든 객체를 Object[]로 처리하면 다시 클라이언트의 형변환 문제가 발생한다.

바로 이런 상황을 해결하기 위해 제네릭을 사용할 수 있다.


Chooser를 제네릭 클래스로 바꿔보자

먼저 타입 매개변수를 선언한다.

public class Chooser<T> {
}

이제 이 Chooser가 어떤 타입을 다루는지를 객체를 생성할 때 결정할 수 있다.

Chooser<Integer>
Chooser<String>
Chooser<Member>

즉 기존의

Object를 사용해서
모든 타입을 한꺼번에 처리

하는 방식 대신

타입은 다양하게 받을 수 있지만

각 Chooser 인스턴스는
하나의 타입을 안전하게 처리

하도록 만들 수 있다.


배열도 T[]로 변경하고 싶어진다

자연스럽게 다음과 같이 생각할 수 있다.

public class Chooser<T> {

    private final T[] choiceArray;
}

그리고 생성자도 다음처럼 만든다.

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

하지만 여기에서 문제가 발생한다.

Collection.toArray()가 반환하는 것은

Object[]

이다.

따라서 T[]에 바로 대입할 수 없다.


Object[]를 T[]로 형변환하면?

다음처럼 작성할 수는 있다.

public Chooser(
        Collection<T> choices
) {

    choiceArray =
            (T[]) choices.toArray();
}

이제 컴파일 오류 자체는 해결할 수 있지만 새로운 문제가 발생한다.

컴파일러가 unchecked cast 경고를 발생시킨다.

개념적으로 다음과 같은 경고다.

Object[]

↓

T[]

정말 안전한 형변환인지
컴파일러가 증명할 수 없음

왜 컴파일러는 T[] 변환을 믿지 못할까?

앞에서 배웠듯 제네릭 타입 매개변수 T는 런타임에 그대로 실체화되지 않는다.

다음 코드가 있다고 하자.

Chooser<String>

소스 코드에서는 T가 String이라는 사실을 알고 있다.

하지만 타입 소거 때문에 런타임에서 T 자체를 이용해

이 배열은 반드시 String[]이다.

라고 검증하기 어렵다.

반면

choices.toArray()

의 결과는 실제로 Object[]다.

따라서

(T[]) choices.toArray()

는 컴파일러가 안전하다고 확신할 수 없는 형변환이다.

그래서 비검사 경고가 발생한다.


앞선 아이템 27과 연결된다

아이템 27에서는 이런 상황을 배웠다.

Unchecked Warning 발생

↓

먼저 제거 가능한지 확인

↓

정말 제거할 수 없다면

↓

타입 안전성을 직접 증명

↓

@SuppressWarnings("unchecked")

따라서 다음처럼 만들 수도 있다.

@SuppressWarnings("unchecked")
public Chooser(
        Collection<T> choices
) {

    choiceArray =
            (T[]) choices.toArray();
}

경고를 없앨 수는 있다.

하지만 이보다 더 좋은 방법이 있다.

애초에 배열을 사용하지 않는 것이다.


List로 바꾸자

배열 대신 리스트를 사용한다.

public class Chooser<T> {

    private final List<T> choiceList;

    public Chooser(
            Collection<T> choices
    ) {

        choiceList =
                new ArrayList<>(choices);
    }
}

이제 내부 저장소는

T[]

가 아니라

List<T>

다.

가장 중요한 변화는 이것이다.

Object[] → T[]

같은 위험한 형변환 자체가 사라진다.

비검사 경고도 없다.


Collection을 새로운 ArrayList로 복사한다

생성자에서는 전달받은 Collection을 그대로 저장하지 않고 새로운 ArrayList로 복사한다.

choiceList =
        new ArrayList<>(choices);

이 방식에는 또 하나의 장점이 있다.

외부에서 전달받은 컬렉션 객체 자체에 대한 의존을 줄일 수 있다.

예를 들어 다음 코드가 있다고 하자.

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

source.add("A");
source.add("B");

Chooser<String> chooser =
        new Chooser<>(source);

그 후 외부에서

source.clear();

를 수행하더라도 Chooser가 자신의 ArrayList를 별도로 만들어두었다면 컨테이너 구조 자체는 독립적이다.


정확히는 얕은 복사다

다만 이것을 완전한 의미의 깊은 방어적 복사라고 이해하면 안 된다.

new ArrayList<>(choices)

는 리스트 구조를 새로 만들지만 리스트 안에 들어 있는 객체까지 복제하지는 않는다.

예를 들어

List<Member>

를 복사한다면

원래 ArrayList
        │
        ├──── Member A
        └──── Member B

새 ArrayList
        │
        ├──── Member A
        └──── Member B

두 리스트가 같은 Member 객체를 참조할 수 있다.

따라서

외부 리스트에 add/remove

→ Chooser 내부 리스트 구조에 직접 영향 없음

은 기대할 수 있지만

Member 자체의 상태 변경

까지 차단되는 것은 아니다.

이 부분은 방어적 복사를 이해할 때 중요하다.


choose()도 T를 반환할 수 있다

기존 구현에서는 다음과 같았다.

public Object choose()

이제 클래스 자체가 T를 알고 있으므로 반환 타입도 T로 만들 수 있다.

public T choose() {

    Random random =
            new Random();

    return choiceList.get(
            random.nextInt(
                    choiceList.size()
            )
    );
}

배열에서 사용하던

choiceArray.length

대신 리스트에서는

choiceList.size()

를 사용한다.

원소 접근도

choiceArray[index]

대신

choiceList.get(index)

를 사용한다.


완성된 Chooser

전체 구조는 다음과 같이 만들 수 있다.

public class Chooser<T> {

    private final List<T> choiceList;

    public Chooser(
            Collection<T> choices
    ) {

        choiceList =
                new ArrayList<>(choices);
    }

    public T choose() {

        Random random =
                new Random();

        return choiceList.get(
                random.nextInt(
                        choiceList.size()
                )
        );
    }
}

이제 배열이 완전히 사라졌다.

그리고 동시에

Object 사용

unchecked cast

클라이언트의 명시적 cast

도 사라졌다.


사용하는 코드도 타입 안전해진다

숫자를 사용하는 경우를 보자.

List<Integer> numbers =
        List.of(
                10,
                20,
                30
        );

Chooser<Integer> chooser =
        new Chooser<>(numbers);

Integer number =
        chooser.choose();

중요한 부분은 마지막이다.

Integer number =
        chooser.choose();

더 이상 다음 형변환이 없다.

(Integer)

Chooser<Integer>라는 선언 자체가

이 Chooser는 Integer를 다룬다.

라는 타입 계약을 제공하기 때문이다.


String Chooser도 동일하게 사용할 수 있다

List<String> names =
        List.of(
                "Alice",
                "Bob",
                "Charlie"
        );

Chooser<String> chooser =
        new Chooser<>(names);

String selected =
        chooser.choose();

역시 형변환이 없다.

String selected =
        chooser.choose();

컴파일러가 반환 타입을 이미 알고 있다.


잘못된 타입 사용은 컴파일 단계에서 차단된다

Chooser<Integer>가 있다고 하자.

Chooser<Integer> chooser =
        new Chooser<>(
                List.of(
                        1,
                        2,
                        3
                )
        );

다음 코드는 가능하다.

Integer value =
        chooser.choose();

하지만 다음 코드는 사용할 수 없다.

String value =
        chooser.choose();

컴파일 단계에서 바로 오류가 발생한다.

chooser.choose()

반환 타입

Integer

↓

String 변수에 대입

↓

Compile Error

기존 Object[] 기반 코드에서는 잘못된 형변환이 런타임까지 넘어갈 수 있었지만 이제는 컴파일러가 바로 막는다.


기존 배열 기반 구현과 비교해보자

Object[] 기반

public Object choose()

클라이언트:

Integer value =
        (Integer) chooser.choose();

문제점은 다음과 같다.

타입 정보 부족

↓

클라이언트가 직접 cast

↓

잘못된 cast 가능

↓

Runtime ClassCastException

제네릭 배열 기반

private final T[] choiceArray;

생성 과정에서

(T[]) choices.toArray()

가 필요할 수 있다.

문제는 다음과 같다.

Object[]

↓

T[] unchecked cast

↓

컴파일러가 안전성 증명 불가능

↓

Unchecked Warning

List 기반

private final List<T> choiceList;

클라이언트에서는

T value =
        chooser.choose();

가 된다.

명시적 형변환 없음

Unchecked Warning 없음

Generic Array 문제 없음

Compile Time 타입 검사

세 구현 중 가장 자연스럽다.


구조를 한 번에 비교하면

1. Object[] 방식

Collection
   ↓
Object[]
   ↓
choose() → Object
   ↓
Client cast 필요
   ↓
Runtime 오류 가능

다음은 제네릭 배열 방식이다.

2. T[] 방식

Collection<T>
   ↓
Object[]
   ↓
Unchecked Cast
   ↓
T[]
   ↓
Warning 발생

그리고 리스트 방식이다.

3. List<T> 방식

Collection<T>
   ↓
new ArrayList<>(...)
   ↓
List<T>
   ↓
choose() → T
   ↓
Client cast 불필요

제네릭이 Object보다 좋은 이유가 다시 드러난다

처음 Chooser에서 Object[]를 사용했던 이유는 범용성을 얻기 위해서였다.

Integer도 지원

String도 지원

Member도 지원

Order도 지원

하지만 Object를 이용한 범용성에는 비용이 있었다.

범용성

O

타입 안전성

X

제네릭을 사용하면 상황이 달라진다.

범용성

O

타입 안전성

O

Chooser<T> 하나만 작성하면서도

Chooser<Integer>
Chooser<String>
Chooser<Member>

를 모두 타입 안전하게 사용할 수 있다.


Raw Type을 사용해서는 안 된다

제네릭 클래스로 바꾼 뒤 사용하는 쪽에서도 타입을 생략하면 안 된다.

다음과 같이 사용하는 것은 좋지 않다.

Chooser chooser =
        new Chooser(numbers);

Chooser는 제네릭 타입이므로 Raw Type을 사용한 것이다.

아이템 26에서 살펴본 문제가 다시 발생한다.

다음처럼 사용해야 한다.

Chooser<Integer> chooser =
        new Chooser<>(numbers);

또는 타입 추론이 가능한 위치에서는 상황에 따라 var 등을 사용할 수도 있지만 핵심은 생성되는 객체의 제네릭 타입 관계가 유지되어야 한다는 것이다.


다이아몬드 연산자도 함께 활용할 수 있다

다음처럼 타입을 두 번 반복할 필요는 없다.

Chooser<Integer> chooser =
        new Chooser<Integer>(numbers);

Java 7 이후에는 다음처럼 작성할 수 있다.

Chooser<Integer> chooser =
        new Chooser<>(numbers);

오른쪽의 타입은 컴파일러가 추론한다.

왼쪽

Chooser<Integer>

↓

오른쪽

new Chooser<>()

제네릭의 타입 안전성을 유지하면서 코드도 간결하게 작성할 수 있다.


배열에서 List로 바꾸면 무엇이 좋아졌을까?

가장 먼저 명시적 형변환이 사라졌다.

기존

Integer value =
        (Integer) chooser.choose();

개선

Integer value =
        chooser.choose();

비검사 경고도 사라졌다

제네릭 배열을 사용하려고 하면 등장했던

(T[]) choices.toArray()

가 없어졌다.

따라서

unchecked cast

자체가 필요하지 않다.

@SuppressWarnings("unchecked")도 필요하지 않다.

이것은 아이템 27의 원칙과도 잘 맞는다.

경고를 억제하는 것보다 경고가 발생하지 않는 구조로 만드는 것이 더 좋다.


잘못된 타입을 더 빨리 발견한다

Object[] 방식에서는 잘못된 타입 사용이 런타임까지 갈 수 있었다.

Compile

↓

성공

↓

Runtime

↓

ClassCastException

제네릭 List 방식에서는 대부분 컴파일 단계에서 막는다.

Compile

↓

Type Error

↓

실행 전에 수정

결과적으로 타입 오류를 발견하는 시점이 앞당겨진다.


API 표현력도 좋아진다

다음 API를 비교해보자.

public Object choose()

이 코드만 봐서는 무엇을 반환하는지 알기 어렵다.

반면

public T choose()

그리고 객체의 타입이

Chooser<Order>

라면 반환값이 Order라는 사실을 즉시 알 수 있다.

즉 제네릭은 타입 안전성뿐 아니라 API의 표현력도 높여준다.


배열보다 List를 사용한다는 의미

이번 아이템은 무조건 모든 배열을 List로 바꾸라는 뜻은 아니다.

예를 들어 다음과 같은 배열은 자연스럽다.

byte[] buffer;
int[] counts;
char[] chars;

특히 primitive 값이나 저수준 데이터, 고정된 크기의 데이터를 처리하는 경우 배열이 적절할 수 있다.

중요한 것은 다음 상황이다.

여러 종류의 참조 타입을
제네릭하게 처리해야 한다.

+

배열을 사용하면
unchecked cast가 필요하다.

이런 경우라면 List<T>로 바꿀 수 있는지를 적극적으로 검토하는 것이 좋다.


성능이 민감하지 않다면 List가 더 단순한 경우가 많다

배열은 언어 수준의 매우 단순한 자료구조이고 일부 상황에서는 메모리나 성능 측면에서 유리할 수 있다.

하지만 일반적인 비즈니스 애플리케이션에서 객체 컬렉션을 다루는 경우에는

타입 안전성

가독성

Collection API

동적 크기

유지보수성

등을 함께 고려해야 한다.

특히 ArrayList는 내부적으로 배열을 사용하고 get(index)가 일반적으로 O(1)이므로 단순히

배열은 인덱스 접근이 빠르고
List는 느리다.

라고 판단하는 것은 적절하지 않다.

실제 요구사항과 구현체 특성을 기준으로 선택해야 한다.


제네릭 배열이 보이면 List를 검토하자

실무에서 다음과 같은 코드가 보인다면 한 번 의심해볼 수 있다.

private final T[] values;

그리고 내부에서

@SuppressWarnings("unchecked")

또는

(T[]) ...

같은 코드가 반복된다면

private final List<T> values;

로 바꿀 수 있는지 살펴볼 가치가 있다.

물론 모든 경우에 List가 정답은 아니다.

하지만 단지 여러 제네릭 원소를 보관하고 조회하기 위한 목적이라면 List<T>가 더 단순하고 타입 안전한 설계가 되는 경우가 많다.


아이템 26부터 이어지는 흐름

지금까지 제네릭과 관련된 아이템들의 방향은 일관적이다.

아이템 26

Raw Type을 사용하지 말라.

↓

타입 정보를 버리지 말라.

아이템 27

Unchecked Warning을 제거하라.

↓

컴파일러가 검증하지 못하는
타입 연산을 최소화하라.

아이템 28

배열보다 List를 사용하라.

↓

제네릭과 잘 맞지 않는
배열 구조를 피하고
타입 안전한 컬렉션을 사용하라.

궁극적으로 모두 같은 목표를 가진다.

타입과 관련된 오류를
가능한 한 컴파일러에게 맡기자.

Chooser 리팩터링 전후 비교

구분배열 기반 ChooserList<T> 기반 Chooser<T>
내부 저장 타입Object[] 또는 T[]List<T>
범용성OO
타입 정보 유지약함강함
클라이언트 형변환필요불필요
잘못된 형변환 가능성있음크게 감소
unchecked cast발생 가능불필요
@SuppressWarnings필요할 수 있음불필요
반환 타입ObjectT
타입 오류 발견런타임까지 갈 수 있음컴파일 타임에 대부분 차단
API 표현력낮음높음

리팩터링의 핵심

기존 코드는 다음 구조였다.

Collection

↓

Object[]

↓

Object 반환

↓

Client에서 cast

제네릭 배열을 사용하면

Collection<T>

↓

Object[]

↓

T[]로 unchecked cast

↓

T 반환

이 된다.

하지만 List를 사용하면 가장 단순해진다.

Collection<T>

↓

List<T>

↓

T 반환

중간에 위험한 형변환 단계가 사라진다.

이것이 배열을 리스트로 바꿨을 때 얻는 가장 큰 장점이다.


핵심 정리

  • 여러 타입에서 재사용할 수 있는 배열 기반 클래스를 만들기 위해 Object[]를 사용하면 클라이언트가 반환값을 직접 형변환해야 할 수 있다.
  • 클라이언트가 실제 내부 원소 타입과 다른 타입으로 형변환하면 컴파일은 성공하더라도 런타임에 ClassCastException이 발생할 수 있다.
  • 이러한 문제를 해결하기 위해 Chooser<T>처럼 클래스를 제네릭 타입으로 만들 수 있다.
  • 하지만 내부 저장소까지 T[]로 만들려고 하면 Collection.toArray()가 반환하는 Object[]를 T[]로 변환해야 하는 문제가 생길 수 있다.
  • (T[]) 형변환은 타입 소거 때문에 컴파일러가 안전성을 완전히 확인할 수 없어 비검사 형변환 경고가 발생할 수 있다.
  • 타입 안전성을 직접 증명한 뒤 @SuppressWarnings("unchecked")를 사용할 수도 있지만, 경고를 억제하는 것보다 경고가 발생하지 않는 설계가 더 좋다.
  • 배열 대신 List<T>를 사용하면 Object[] → T[] 형변환 자체가 사라지므로 비검사 경고를 제거할 수 있다.
  • new ArrayList<>(choices)를 이용하면 외부에서 전달한 Collection과 별도의 리스트 구조를 만들어 외부 컬렉션의 구조 변경으로부터 내부 상태를 분리할 수 있다.
  • 다만 new ArrayList<>(choices)는 원소 객체까지 복제하는 깊은 복사가 아니라 리스트 구조만 복사하는 얕은 복사다.
  • Chooser<T>의 choose()는 Object가 아니라 T를 반환할 수 있으므로 클라이언트에서 명시적인 형변환이 필요하지 않다.
  • Chooser<Integer>에서 choose()의 반환 타입은 Integer, Chooser<String>에서는 String으로 타입 안전하게 결정된다.
  • 잘못된 타입의 변수에 반환값을 대입하려 하면 런타임이 아니라 컴파일 시점에 오류를 확인할 수 있다.
  • 제네릭 클래스를 사용하는 쪽에서도 Raw Type을 사용하지 말고 Chooser<Integer>처럼 매개변수화 타입을 사용해야 한다.
  • Java 7 이후에는 new Chooser<>(...)처럼 다이아몬드 연산자를 활용해 타입 반복을 줄일 수 있다.
  • 모든 배열을 List로 바꿔야 하는 것은 아니며 primitive 데이터, byte buffer, 고정 크기 데이터, 저수준 성능이 중요한 코드에서는 배열이 적절할 수 있다.
  • 하지만 제네릭 원소를 저장하기 위해 T[], unchecked cast, @SuppressWarnings("unchecked")가 필요해지고 있다면 List<T>로 바꿀 수 있는지를 우선 검토할 가치가 있다.
  • 아이템 26, 27, 28은 모두 타입 정보를 최대한 유지하고 타입 오류를 런타임보다 컴파일 시점에 발견하도록 설계하라는 동일한 방향을 가진다.

한 줄 요약

여러 타입을 안전하게 처리해야 하는 제네릭 클래스에서 배열을 사용하면 Object[], T[] 형변환과 비검사 경고가 발생하기 쉽지만 List<T>로 바꾸면 위험한 형변환 없이 타입 정보를 유지하고 클라이언트의 캐스팅도 제거할 수 있으므로, 제네릭 컬렉션을 설계할 때는 특별한 이유가 없다면 배열보다 리스트를 우선 고려하는 것이 좋다.

아이템 28. 완벽 공략 @SafeVarargs

아이템 28 완벽 공략: @SafeVarargs와 제네릭 가변인자

Java에서 가변인자(Varargs)는 메서드가 정해지지 않은 개수의 인자를 받을 수 있도록 해주는 기능이다.

다음과 같이 매개변수 타입 뒤에 점 세 개를 붙여 선언한다.

public static void print(
        String... values
) {
    ...
}

호출하는 쪽에서는 필요한 만큼 값을 전달할 수 있다.

print("A");

print(
        "A",
        "B"
);

print(
        "A",
        "B",
        "C"
);

사용하기 편리하고 문법도 단순하다.

하지만 가변인자와 제네릭이 만나는 순간 이야기가 복잡해진다.

public static void method(
        List<String>... lists
) {
    ...
}

이 코드는 작성할 수 있다.

그런데 앞에서 다음 코드는 사용할 수 없다고 배웠다.

new List<String>[10];

Java는 제네릭 배열 생성을 금지한다.

generic array creation

이라는 컴파일 오류가 발생한다.

그렇다면 이상한 점이 생긴다.

List<String>[]

직접 생성
→ 불가능


List<String>...

가변인자로 선언
→ 가능

왜 이런 차이가 발생할까?

그리고 왜 컴파일러는 제네릭 가변인자를 사용할 때

Possible heap pollution
from parameterized vararg type

과 같은 경고를 보여주는 것일까?

이것을 이해하려면 먼저 가변인자의 내부 동작을 이해해야 한다.


가변인자는 결국 배열이다

다음 메서드를 보자.

public static void print(
        String... values
) {

    for (String value : values) {
        System.out.println(value);
    }
}

호출하는 쪽에서는 다음처럼 작성한다.

print(
        "A",
        "B",
        "C"
);

겉으로는 여러 개의 매개변수를 전달하는 것처럼 보인다.

하지만 메서드 내부에서 values의 타입은 사실상 배열이다.

String[]

따라서 다음과 같은 코드도 가능하다.

public static void print(
        String... values
) {

    System.out.println(
            values.length
    );

    System.out.println(
            values[0]
    );
}

개념적으로 호출 과정에서는 다음과 같은 배열이 만들어진다고 생각할 수 있다.

print("A", "B", "C")

↓

String[] 생성

↓

[
    "A",
    "B",
    "C"
]

↓

print(String[] values)

즉

T... 형태의 가변인자는 메서드 내부에서 T[] 형태의 배열로 다뤄진다.

이 점이 제네릭과 결합됐을 때 문제의 출발점이 된다.


일반적인 가변인자는 크게 문제가 없다

다음은 단순하다.

public static void print(
        String... values
) {
}

String[]은 실체화되는 타입이다.

JVM은 런타임에도 이 배열이

String[]

이라는 사실을 알 수 있다.

따라서 잘못된 타입을 넣으려고 하면 런타임 배열 검사가 가능하다.

문제는 다음과 같이 제네릭 타입을 가변인자로 사용했을 때 발생한다.

public static void print(
        List<String>... lists
) {
}

이 매개변수는 내부적으로 무엇일까?

개념적으로는

List<String>[]

형태의 배열이 필요하다.

바로 여기에서 앞선 아이템의 문제가 다시 등장한다.


List[]는 원래 만들 수 없다

우리는 다음 코드를 직접 작성할 수 없다.

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

왜냐하면 List<String>은 실체화되지 않는 타입이기 때문이다.

제네릭 타입 인수인

<String>

은 런타임 배열의 요소 타입으로 완전히 유지되지 않는다.

배열은 자신의 요소 타입을 런타임에 알아야 한다.

반면 제네릭의 타입 인수는 타입 소거의 영향을 받는다.

즉

배열

→ 런타임에 요소 타입 검사 필요


제네릭 타입 인수

→ 런타임에 완전히 실체화되지 않음

이라는 서로 다른 특성이 충돌한다.

그래서 Java는 일반적인 제네릭 배열 생성을 금지한다.


그런데 가변인자에서는 제네릭 배열이 만들어질 수 있다

다음 메서드는 선언할 수 있다.

public static void print(
        List<String>... lists
) {
}

하지만 내부적으로는 가변인자를 전달하기 위해 배열이 필요하다.

결과적으로 컴파일러는

List<String>[]

와 관련된 구조를 만들어야 한다.

바로 이 때문에 컴파일러가 경고를 발생시킨다.

대표적인 메시지는 다음과 같다.

Possible heap pollution
from parameterized vararg type

여기서 정확한 용어는 Heap Pollution이다.


Heap Pollution이란?

Heap Pollution을 직역하면

Heap

→ 객체가 저장되는 메모리 영역


Pollution

→ 오염

이다.

하지만 단순히 JVM의 Heap 메모리가 물리적으로 이상해진다는 의미는 아니다.

제네릭 문맥에서 Heap Pollution은 대략 다음과 같은 상태를 말한다.

매개변수화 타입의 변수가 자신이 약속한 타입과 호환되지 않는 객체를 참조하게 된 상태

예를 들어 원래

List<String>

만 들어 있어야 할 위치에

List<Integer>

가 들어간다면 타입 시스템이 기대하는 상태와 실제 객체의 상태가 달라진다.

이것을 Heap Pollution이라고 볼 수 있다.


제네릭 가변인자로 Heap Pollution을 만들어보자

다음 메서드를 생각해보자.

static void dangerous(
        List<String>... stringLists
) {

}

stringLists는 내부적으로 배열이다.

List<String>[]

처럼 다뤄진다.

그런데 배열은 공변이다.

따라서 다음과 같이 참조할 수 있다.

Object[] objects =
        stringLists;

왜 가능할까?

개념적으로 다음 관계 때문이다.

List<String> 객체

↓

Object

그리고

List<String>[]

↓

Object[]

배열의 공변성이 적용된다.


Object[]가 되면 다른 객체를 저장할 수 있다

이제 다음 리스트를 만든다.

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

그리고 앞에서 만든 Object[]에 넣는다.

objects[0] = integers;

코드만 보면 objects는 Object[]다.

List<Integer> 역시 Object이므로 저장할 수 있어 보인다.

그런데 실제 objects가 참조하고 있는 것은 무엇이었을까?

List<String>[]

이었다.

따라서 논리적으로 다음 일이 벌어진 것이다.

List<String>[]

첫 번째 위치

↓

List<Integer> 저장

타입 시스템이 기대하던 상태가 깨졌다.

이것이 Heap Pollution이다.


전체 코드를 보면

다음과 같은 구조가 된다.

static void dangerous(
        List<String>... stringLists
) {

    Object[] objects =
            stringLists;

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

    objects[0] =
            integers;

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

마지막 코드를 보자.

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

컴파일러는 stringLists의 타입을 알고 있다.

List<String>[]

따라서

stringLists[0]

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

그리고

get(0)

의 반환값 역시 String이라고 생각한다.

하지만 실제 들어 있는 것은

List<Integer>

다.

그리고 값은

42

다.

결국 실행 과정에서 Integer를 String으로 사용하려고 하면서 문제가 발생한다.

Integer

↓

String으로 cast

↓

ClassCastException

왜 배열 저장 시점에 ArrayStoreException이 발생하지 않을까?

앞에서 다음 코드는 즉시 실패했다.

Object[] objects =
        new String[1];

objects[0] = 42;

실행하면

ArrayStoreException

이 발생한다.

JVM이 실제 배열이 String[]이라는 사실을 알고 있기 때문이다.

그렇다면 왜 제네릭 가변인자에서는 같은 방식으로 완벽하게 막지 못할까?

핵심은 타입 소거다.

개념적으로 런타임 배열은 구체적인

List<String>[]

정보보다는

List[]

에 가까운 실체화 가능한 타입 정보만 가지고 처리된다.

그런데 List<Integer> 역시 런타임에서는 List다.

따라서

List[] 안에 List 저장

이라는 배열 수준의 검사는 통과할 수 있다.

하지만 리스트 내부에 무엇이 들어 있는지까지 배열의 런타임 검사가 보장하지 못한다.

이 때문에 문제가 더 늦게 드러날 수 있다.


그래서 컴파일러가 경고한다

다음과 같이 선언하면

static void dangerous(
        List<String>... stringLists
) {
}

컴파일러가 경고를 보여줄 수 있다.

Possible heap pollution
from parameterized vararg type

컴파일러가 말하고 있는 것은 대략 다음과 같다.

가변인자는 배열인데

↓

배열의 요소 타입이
List<String> 같은 제네릭 타입이고

↓

그 제네릭 타입은
런타임에 완전히 실체화되지 않으므로

↓

타입 안전성이 깨질 가능성이 있다.

즉 단순한 스타일 경고가 아니다.

실제로 ClassCastException으로 이어질 수 있는 타입 안전성 문제를 알려주는 것이다.


모든 제네릭 가변인자 메서드가 위험한 것은 아니다

여기서 중요한 구분이 필요하다.

다음과 같은 메서드를 생각해보자.

static void printAll(
        List<String>... lists
) {

    for (List<String> list : lists) {

        for (String value : list) {
            System.out.println(value);
        }
    }
}

가변인자 배열을 받기는 한다.

하지만 이 배열을 이용해서 위험한 작업을 하지 않는다.

배열에 새로운 값을 저장하지 않음

다른 Object[] 참조를 만들어 변조하지 않음

외부에 배열을 반환하지 않음

단순히 읽어서 사용

이 경우 개발자가 코드를 검토해서

이 메서드는 varargs 배열의
타입 안전성을 깨뜨리지 않는다.

라고 판단할 수 있다.

이때 사용할 수 있는 애너테이션이

@SafeVarargs

다.


@SafeVarargs란?

다음과 같이 사용한다.

@SafeVarargs
static void printAll(
        List<String>... lists
) {

    for (List<String> list : lists) {

        for (String value : list) {
            System.out.println(value);
        }
    }
}

이 애너테이션은 단순히

경고가 보기 싫으니 없애라.

라는 의미로 사용해서는 안 된다.

개발자가 컴파일러에게 다음 사실을 약속하는 것이다.

이 메서드는 제네릭 가변인자를 사용하지만 해당 가변인자 배열과 관련하여 타입 안전성을 깨뜨리는 동작을 하지 않는다.

즉 이름 그대로

Safe

+

Varargs

다.


@SafeVarargs는 안전성을 만들어주지 않는다

이 부분이 가장 중요하다.

다음 메서드가 실제로 위험하다고 생각해보자.

static void dangerous(
        List<String>... lists
) {

    Object[] objects =
            lists;

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

    String value =
            lists[0].get(0);
}

여기에

@SafeVarargs

를 붙였다고 해보자.

@SafeVarargs
static void dangerous(
        List<String>... lists
) {
    ...
}

그렇다고 코드가 안전해지는 것은 아니다.

실제 동작은 여전히 위험하다.

@SafeVarargs 추가

↓

경고 감소

하지만

↓

Heap Pollution 가능성 그대로 존재

↓

ClassCastException 가능성 그대로 존재

즉 애너테이션은 마법처럼 타입 안전성을 제공하지 않는다.


@SuppressWarnings와 비슷한 면이 있다

아이템 27에서 다음 애너테이션을 살펴봤다.

@SuppressWarnings("unchecked")

@SafeVarargs 역시 경고와 관련된 애너테이션이라는 점에서는 비슷하다.

하지만 의미는 훨씬 구체적이다.

@SuppressWarnings("unchecked")는 일반적으로 해당 범위의 unchecked 경고를 억제한다.

반면

@SafeVarargs

는 제네릭이나 매개변수화 타입을 사용하는 가변인자 메서드에 대해

이 varargs 사용은 안전하다.

라는 개발자의 계약을 표현한다.

따라서 아무 곳에 무턱대고 붙이면 안 된다.


안전한 제네릭 가변인자의 첫 번째 조건

가장 먼저 가변인자 배열에 아무것도 저장하지 않는 것이 중요하다.

예를 들어 다음처럼 배열 내용을 변경하는 코드는 신중해야 한다.

static <T> void method(
        T... values
) {

    // 위험 여부를 신중히 검토해야 함
    values[0] = ...;
}

특히 다른 타입의 값을 우회해서 넣을 수 있도록 배열을 노출하거나 형변환한다면 위험하다.

대표적으로 다음 패턴은 피해야 한다.

Object[] objects =
        values;

그 후

objects[0] =
        something;

같은 작업을 수행하면 Heap Pollution으로 이어질 가능성이 생긴다.


두 번째 조건은 가변인자 배열을 외부에 노출하지 않는 것이다

다음과 같은 메서드를 생각해보자.

static <T> T[] toArray(
        T... values
) {

    return values;
}

언뜻 보면 단순해 보인다.

하지만 가변인자 배열 자체가 메서드 밖으로 빠져나간다.

varargs 배열

↓

return

↓

외부 코드가 배열 참조 획득

가변인자 배열의 실제 런타임 타입과 호출 지점의 제네릭 타입 조합에 따라 안전성을 추론하기 매우 복잡해질 수 있다.

따라서 제네릭 가변인자 배열을 그대로 외부에 넘기는 것은 특히 조심해야 한다.


배열 참조가 빠져나가는 것을 위험하게 보는 이유

메서드 내부에서만 배열을 사용한다면 타입 안전성을 분석하기가 상대적으로 쉽다.

Method

┌─────────────────────┐
│                     │
│ varargs array       │
│                     │
│ 읽기만 함           │
│                     │
└─────────────────────┘

하지만 배열을 반환하면

Method

↓

T[] 반환

↓

호출자

↓

다른 객체로 전달

↓

다른 타입으로 참조

↓

변경 가능

처럼 추적 범위가 커진다.

따라서 제네릭 가변인자 배열을 안전하게 유지하려면 배열 참조가 메서드 외부로 빠져나가지 않도록 하는 것이 중요한 기준이 된다.


읽기 전용으로 사용하는 경우는 비교적 안전하다

다음 메서드를 보자.

@SafeVarargs
static <T> void printAll(
        T... values
) {

    for (T value : values) {
        System.out.println(value);
    }
}

이 메서드는 다음 작업만 한다.

배열 순회

↓

값 읽기

↓

출력

가변인자 배열 자체를 수정하지 않는다.

외부에 반환하지도 않는다.

다른 배열 변수에 넘겨 타입을 깨뜨리지도 않는다.

이런 메서드는 @SafeVarargs를 적용하기 좋은 전형적인 사례다.


여러 리스트를 읽기만 하는 경우

예를 들어 다음 메서드도 생각할 수 있다.

@SafeVarargs
static <T> void printLists(
        List<T>... lists
) {

    for (List<T> list : lists) {

        for (T value : list) {
            System.out.println(value);
        }
    }
}

가변인자의 타입은

List<T>...

이므로 내부적으로 제네릭 배열과 관련된 문제가 존재한다.

하지만 코드에서

List<T>[] 변조

X

다른 타입의 List 삽입

X

가변인자 배열 반환

X

이고 순수하게 읽는 용도로만 사용한다면 타입 안전성을 검토한 뒤 @SafeVarargs를 사용할 수 있다.


위험한 코드와 안전한 코드를 비교해보자

위험한 코드

static void dangerous(
        List<String>... lists
) {

    Object[] array = lists;

    array[0] =
            List.of(42);
}

문제는 다음과 같다.

List<String>[]

↓

Object[]

↓

List<Integer> 삽입

↓

Heap Pollution

이 메서드에는 단순히 @SafeVarargs를 붙여서는 안 된다.

먼저 코드를 안전한 구조로 바꿔야 한다.


안전성을 검토할 수 있는 코드

@SafeVarargs
static void print(
        List<String>... lists
) {

    for (List<String> list : lists) {

        for (String value : list) {
            System.out.println(value);
        }
    }
}

이 코드는 배열을 읽기만 한다.

Varargs Array

↓

읽기

↓

출력

타입 오염을 일으키는 연산이 없다.

이런 경우 @SafeVarargs를 사용할 근거가 생긴다.


@SafeVarargs를 붙이기 전에 질문해야 할 것

제네릭 가변인자 메서드를 작성했다면 다음 질문을 해볼 수 있다.

가변인자 배열에
다른 타입의 값을 저장하는가?

배열을 Object[] 같은 타입으로
우회해서 수정하는가?

가변인자 배열의 참조를
외부에 반환하는가?

다른 메서드에 전달해서
외부에서 변경할 가능성이 있는가?

타입 안전성을 깨뜨릴 수 있는
연산을 수행하는가?

이런 위험이 없다면 @SafeVarargs 적용을 검토할 수 있다.

반대로 하나라도 명확하게 위험하다면 애너테이션을 붙이는 것이 해결책이 아니다.


@SafeVarargs는 개발자의 주장이다

이 애너테이션을 조금 다르게 생각하면 이해하기 쉽다.

컴파일러는 다음과 같이 말한다.

나는 이 제네릭 varargs가
완전히 안전한지 증명할 수 없다.

개발자는 코드를 검토한 후

내가 이 구현을 검토했고

이 varargs 배열을 통해
타입 안전성이 깨지지 않는 것을 보장한다.

라고 선언한다.

그 선언이

@SafeVarargs

다.

따라서 애너테이션을 붙인 개발자가 안전성에 대한 책임을 가져간다고 생각하면 된다.


경고를 없애는 것과 문제를 없애는 것은 다르다

아이템 27에서도 동일한 내용을 살펴봤다.

다음과 같은 경고가 있다고 하자.

Possible heap pollution

여기에 @SafeVarargs를 붙이면 경고가 사라질 수 있다.

하지만 두 상황을 반드시 구분해야 한다.

올바른 사용

코드 분석

↓

실제로 안전함을 확인

↓

컴파일러가 증명하지 못함

↓

@SafeVarargs

잘못된 사용

경고 발생

↓

귀찮음

↓

@SafeVarargs

↓

노란 줄 사라짐

↓

끝

후자는 경고만 숨긴 것이다.

실제 버그는 그대로 남아 있을 수 있다.


제네릭 가변인자가 꼭 필요한지도 생각해보자

@SafeVarargs를 붙이기 전에 한 단계 더 생각할 수도 있다.

정말 제네릭 가변인자가 필요한가?

예를 들어

static <T> void process(
        List<T>... values
)

대신 API에 따라

static <T> void process(
        List<List<T>> values
)

같은 형태가 더 자연스러울 수도 있다.

또는

Collection<List<T>>

형태를 받을 수도 있다.

가변인자를 사용하는 것이 호출 편의성 측면에서 의미가 있다면 사용할 수 있지만, 단지 여러 데이터를 전달하기 위해 습관적으로 제네릭 가변인자를 사용하는 것이라면 컬렉션 기반 API가 더 단순한 경우도 있다.


varargs와 배열의 관계를 다시 정리해보자

다음 선언은

void method(
        String... values
)

메서드 내부에서는 사실상

String[]

를 사용한다.

그리고

void method(
        List<String>... values
)

라고 하면 내부적으로는 개념적으로

List<String>[]

와 관련된 구조가 만들어진다.

바로 이 때문에

제네릭

+

배열

↓

타입 안전성 충돌

문제가 다시 등장한다.

가변인자는 제네릭과 배열이 만나는 대표적인 경계라고 이해하면 된다.


아이템 28과 자연스럽게 연결된다

아이템 28에서는

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

고 했다.

그 이유 중 하나가 바로 제네릭과 배열의 타입 체계가 잘 어울리지 않기 때문이다.

배열

공변
+
실체화


제네릭

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

그런데 가변인자를 사용하면 언어 차원에서 다시 배열이 등장한다.

T...

↓

T[]

그래서 제네릭 가변인자에는 본질적으로 조심해야 할 부분이 생긴다.

@SafeVarargs는 바로 그 경계에서

이 메서드의 구현은
varargs 배열을 안전하게 사용하고 있다.

는 사실을 표현하기 위한 도구다.


@SafeVarargs와 @SuppressWarnings의 차이

두 애너테이션을 함께 비교하면 이해하기 쉽다.

구분@SuppressWarnings("unchecked")@SafeVarargs
목적특정 unchecked 경고 억제제네릭 가변인자 사용이 안전함을 선언
적용 의미경고를 표시하지 않음varargs 관련 타입 안전성 계약
안전성을 자동 보장XX
개발자의 안전성 검증 필요OO
주요 사용 위치다양한 제네릭 비검사 연산제네릭/매개변수화 타입 varargs 메서드

둘 다 중요한 공통점이 있다.

Annotation을 붙였다고

코드가 안전해지는 것은 아니다.

@SafeVarargs를 아무 인스턴스 메서드에 붙일 수 있는 것은 아니다

@SafeVarargs는 오버라이딩을 통해 구현이 달라질 수 있는 일반적인 인스턴스 메서드에 무조건 붙이는 용도의 애너테이션은 아니다.

대표적으로 안전성 계약을 안정적으로 유지할 수 있는

static 메서드

final 메서드

private 메서드

생성자

같은 위치에서 사용할 수 있다.

핵심 이유는 상속을 통해 구현이 바뀌는 메서드까지 상위 구현이 안전하다고 단정하기 어렵기 때문이다.

다만 Java 버전에 따라 적용 가능한 세부 범위가 확장된 부분이 있으므로 실제 커스텀 API를 작성할 때는 사용 중인 JDK의 @SafeVarargs 정의를 확인하는 것이 좋다.


왜 private 메서드도 중요한가?

예를 들어 다음과 같은 내부 헬퍼가 있다고 하자.

@SafeVarargs
private static <T> void print(
        T... values
) {

    for (T value : values) {
        System.out.println(value);
    }
}

외부 API에는 보이지 않지만 제네릭 varargs를 내부 구현에서 사용하는 경우에도 컴파일러 경고가 발생할 수 있다.

구현을 검토한 결과 안전하다면 @SafeVarargs를 통해 의도를 명확하게 표현할 수 있다.


실무에서의 판단 기준

실무 코드에서 다음과 같은 메서드를 발견했다고 생각해보자.

static <T> void execute(
        List<T>... lists
) {
    ...
}

단순히

@SafeVarargs

부터 붙이지 않는다.

먼저 내부 구현을 살펴본다.

1. 배열을 읽기만 하는가?

for (List<T> list : lists) {
    ...
}

비교적 안전하다.


2. 배열에 값을 넣는가?

lists[0] = ...;

타입과 값의 관계를 매우 신중하게 검토해야 한다.


3. Object[]로 우회하는가?

Object[] objects =
        lists;

이런 코드가 있다면 Heap Pollution 가능성을 강하게 의심해야 한다.


4. 배열을 반환하는가?

return lists;

배열 참조가 외부로 노출되므로 안전성 분석이 어려워질 수 있다.


5. 외부 코드가 배열을 변경할 수 있는가?

가변인자 배열의 참조가 외부로 빠져나가면 내부에서 안전하게 만들었다고 생각한 불변식이 외부에서 깨질 수 있다.


가능하면 더 안전한 API를 선택한다

가령 단순히 여러 값을 받아 읽는 것이라면 가변인자가 호출 측면에서 편리하다.

print(
        "A",
        "B",
        "C"
);

따라서 일반적인 실체화 타입의 가변인자는 유용하게 사용할 수 있다.

하지만 제네릭 타입과 결합했을 때 계속 타입 안전성 문제가 발생한다면

List<T>
Collection<T>

같은 API가 더 적절한지도 검토할 수 있다.

아이템 28의 원칙을 그대로 적용하는 것이다.


SafeVarargs를 한 문장으로 이해하면

다음과 같이 기억하면 된다.

@SafeVarargs

=

"컴파일러가 이 제네릭 가변인자의
안전성을 완전히 증명할 수는 없지만,

이 메서드 구현에서는
varargs 배열을 통해 타입 안전성을
깨뜨리지 않는다는 것을
개발자가 보장한다."

그렇기 때문에 상당히 강한 의미를 가진 애너테이션이다.


전체 흐름으로 정리해보자

Generic Varargs 선언

List<String>...

↓

Varargs는 내부적으로 배열

↓

List<String>[] 성격의 구조 발생

↓

List<String>은 비실체화 타입

↓

컴파일러가 완전한 타입 안전성을
보장하기 어려움

↓

Possible Heap Pollution Warning

여기에서 두 갈래로 나뉜다.

실제로 위험한 코드인가?

├─ YES
│
│  코드 수정
│
│  @SafeVarargs로 숨기면 안 됨
│
└─ NO
   │
   │ 구현상 타입 안전성을 증명
   │
   └─ @SafeVarargs 적용 가능

핵심 정리

  • 가변인자(Varargs)는 Type... 형태로 선언하며 호출자가 불특정 개수의 인자를 전달할 수 있게 해준다.
  • 가변인자는 메서드 내부에서 결국 배열로 다뤄진다.
  • String...은 내부적으로 String[] 형태로 처리된다.
  • List<String>...처럼 제네릭 타입을 가변인자로 사용하면 내부적으로 제네릭 타입의 배열과 관련된 문제가 발생한다.
  • Java에서는 new List<String>[10] 같은 제네릭 배열 생성을 직접 허용하지 않지만 제네릭 가변인자 선언은 허용한다.
  • 이 때문에 컴파일러는 Possible heap pollution from parameterized vararg type 같은 경고를 발생시킬 수 있다.
  • 정확한 용어는 Heap Pollution이며 매개변수화 타입 변수가 타입 계약에 맞지 않는 객체를 참조하게 되는 상태를 의미한다.
  • 제네릭 가변인자 배열을 Object[]로 참조한 뒤 다른 타입의 제네릭 객체를 저장하면 Heap Pollution을 만들 수 있다.
  • 예를 들어 List<String>...에 List<Integer>가 들어가도록 만들면 나중에 값을 String으로 읽는 순간 ClassCastException이 발생할 수 있다.
  • 배열의 런타임 검사는 List라는 실체화된 수준의 타입은 확인할 수 있지만 List<String>과 List<Integer>의 타입 인수 차이까지 완벽하게 검사할 수 없다.
  • 모든 제네릭 가변인자 메서드가 위험한 것은 아니며 배열을 변경하거나 외부에 노출하지 않고 읽기만 하는 경우 타입 안전성을 보장할 수 있다.
  • 이런 경우 @SafeVarargs를 이용해 해당 제네릭 가변인자 사용이 안전하다는 개발자의 계약을 표현할 수 있다.
  • @SafeVarargs는 코드를 안전하게 만들어주는 애너테이션이 아니라 개발자가 이미 안전성을 검증했다는 사실을 표현하고 관련 경고를 억제하는 역할을 한다.
  • 실제로 Heap Pollution을 일으키는 코드에 @SafeVarargs를 붙여도 버그 자체는 사라지지 않는다.
  • 가변인자 배열을 Object[]로 우회해 수정하거나 잘못된 타입의 객체를 저장하는 코드는 @SafeVarargs로 숨기지 말고 구조 자체를 수정해야 한다.
  • 가변인자 배열의 참조를 외부에 그대로 반환하거나 노출하는 것도 타입 안전성 측면에서 신중하게 검토해야 한다.
  • @SafeVarargs를 적용하기 전에는 가변인자 배열에 값을 저장하는지, 다른 배열 타입으로 우회하는지, 외부로 배열 참조를 노출하는지를 확인해야 한다.
  • 필요하다면 제네릭 가변인자 대신 List<T>나 Collection<T> 같은 컬렉션 기반 API를 사용하는 방법도 고려할 수 있다.
  • 아이템 28에서 살펴본 배열과 제네릭의 불일치가 가변인자에서 다시 나타나며 @SafeVarargs는 바로 이 경계에서 안전한 사용임을 명시하기 위한 도구다.

한 줄 요약

제네릭 가변인자는 내부적으로 비실체화 제네릭 타입의 배열을 만들어 Heap Pollution 가능성을 발생시키므로, 가변인자 배열을 변경하거나 외부에 노출하지 않아 타입 안전성을 확실히 증명할 수 있을 때만 @SafeVarargs를 사용해야 하며, 애너테이션으로 경고를 숨기는 것보다 실제로 안전한 구조를 만드는 것이 우선이다.


© 2020. All rights reserved.

SIKSIK