본문 바로가기

제네릭 목록과 타입 안전성

Object를 담는 목록이 값을 꺼낼 때에야 실패하는 문제를 확인하고, 제네릭 MyArrayList가 그 실수를 컴파일 단계에서 막는 방법과 내부 Object 배열의 캐스팅이 안전한 이유를 배웁니다.

앞 글의 MyArrayList<E>는 클래스 이름 뒤에 타입 매개변수 E를 달았지만, 정작 값은 Object[] 배열에 저장했습니다. 그리고 get에만 @SuppressWarnings("unchecked")를 붙여 꺼낸 Object를 E로 캐스팅했습니다. 제네릭 클래스를 처음 배운 글의 Box<T>를 목록에 적용한 셈인데, 목록에서는 두 가지 질문이 새로 생깁니다. 내부 배열은 왜 E[]가 아니라 Object[]인지, 그리고 경고까지 꺼 가며 하는 그 캐스팅이 정말 안전한지입니다.

이 글에서는 먼저 제네릭 없이 Object를 담는 목록이 어떻게 실패하는지 확인합니다. 이어서 타입 매개변수가 그 실수를 어디서 막아 주는지 보고, 제네릭 목록 안에서 배열과 캐스팅을 다루는 방법을 정리합니다.

Object를 담는 목록은 꺼낼 때에야 실패한다

무엇이든 담는 목록이 필요하다면 원소 타입을 Object로 두면 됩니다. 모든 클래스는 Object를 상속하므로 숫자도 문자열도 게시글도 들어갑니다. 다음 코드는 자바의 ArrayList를 ArrayList<Object>로 써서 그런 목록을 만들고, 조회 수 두 개를 넣어 합계를 구하려고 합니다. 그런데 실수로 두 번째 조회 수를 문자열로 넣었습니다.

lab/ObjectListCastFailure.java
import java.util.ArrayList;

public final class ObjectListCastFailure {
    public static void main(String[] args) {
        ArrayList<Object> values = new ArrayList<>();
        values.add(40);
        values.add("fifty");
        int total = (Integer) values.get(0) + (Integer) values.get(1);
        System.out.println(total);
    }
}
실행 결과
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')
	at ObjectListCastFailure.main(ObjectListCastFailure.java:8)

두 번의 add는 모두 문제없이 실행됩니다. 목록이 받는 타입이 Object이니 자동으로 Integer가 된 40도, 문자열 "fifty"도 받아들일 수밖에 없습니다. 실패는 합계를 구하는 8번째 줄에서 일어납니다. 꺼낸 값은 Object 타입이라 더하려면 Integer로 캐스팅해야 하는데, 첫 번째 캐스팅은 성공하지만 두 번째 값은 실제로 String이므로 ClassCastException이 납니다. 그래서 합계를 출력하는 줄은 실행되지 않습니다.

이 예제에서는 잘못 넣은 7번째 줄과 실패한 8번째 줄이 붙어 있어서 원인을 금방 찾을 수 있습니다. 하지만 실제 프로그램에서는 값을 넣는 코드와 꺼내는 코드가 서로 다른 클래스에 있고, 실행되는 시점도 한참 떨어져 있을 수 있습니다. 스택 트레이스는 꺼낸 곳만 가리키므로, 누가 언제 문자열을 넣었는지는 따로 추적해야 합니다.

문제의 뿌리는 '이 목록에는 Integer만 담는다'는 규칙이 프로그래머의 머릿속에만 있고 코드 어디에도 적혀 있지 않다는 점입니다. 목록을 쓰는 코드마다 꺼낸 값을 제각각 캐스팅하니, 같은 목록을 서로 다른 타입으로 해석하는 실수도 막을 방법이 없습니다.

타입 매개변수로 넣는 값과 꺼내는 값을 묶는다

제네릭 목록은 이 규칙을 타입으로 적습니다. MyArrayList<E>의 E는 목록 하나가 담을 원소의 타입이고, 목록을 만들 때 한 번 정해지면 그 목록의 모든 메서드에서 같은 타입을 뜻합니다. MyArrayList<Integer>로 만들면 add(E value)는 Integer만 받는 메서드가 되고, get은 Integer를 돌려주는 메서드가 됩니다. 앞의 실수를 제네릭 목록에서 똑같이 저질러 보겠습니다.

lab/TypedAddError.java
public final class TypedAddError {
    public static void main(String[] args) {
        MyArrayList<Integer> viewCounts = new MyArrayList<>();
        viewCounts.add(40);
        viewCounts.add("fifty");
    }

    private static final class MyArrayList<E> {
        private Object[] values = new Object[2];
        private int size;

        void add(E value) {
            values[size++] = value;
        }
    }
}
컴파일 결과
TypedAddError.java:5: error: incompatible types: String cannot be converted to Integer
        viewCounts.add("fifty");
                       ^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

이번에는 프로그램이 실행조차 되지 않습니다. javac가 문자열을 넣는 5번째 줄을 정확히 가리키며, String은 Integer로 바꿀 수 없다고 알려 줍니다. 마지막의 Note: 줄은 메시지를 짧게 줄여 보여 줬다는 안내일 뿐이니 신경 쓰지 않아도 됩니다. 잘못된 값이 들어오는 바로 그 자리에서 오류가 나므로, 원인과 오류 위치 사이의 거리가 없어집니다. 꺼내는 쪽도 달라집니다. get이 처음부터 Integer를 돌려주므로 호출하는 코드에 (Integer) 캐스팅을 쓸 필요가 없습니다.

이 효과는 E가 쓰기 메서드와 읽기 메서드 양쪽에 함께 쓰일 때만 생깁니다. 클래스가 제네릭이라도 add가 E 대신 Object를 받으면 입구가 다시 열립니다. 다음 코드는 get은 E를 돌려주지만 add는 Object를 받도록 잘못 만든 목록입니다.

lab/ObjectAddLeak.java
public final class ObjectAddLeak {
    public static void main(String[] args) {
        MyArrayList<Integer> viewCounts = new MyArrayList<>();
        viewCounts.add(40);
        viewCounts.add("fifty");
        System.out.println("size=" + viewCounts.size());
        int total = viewCounts.get(0) + viewCounts.get(1);
        System.out.println("total=" + total);
    }

    private static final class MyArrayList<E> {
        private final Object[] values = new Object[4];
        private int size;

        void add(Object value) {   // E 대신 Object를 받는 실수
            values[size++] = value;
        }

        @SuppressWarnings("unchecked")
        E get(int index) {
            return (E) values[index];
        }

        int size() {
            return size;
        }
    }
}
실행 결과
size=2
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')
	at ObjectAddLeak.main(ObjectAddLeak.java:7)

이 코드는 javac -Xlint:all로 모든 경고를 켜고 컴파일해도 경고 하나 없이 컴파일됩니다. 유일하게 경고가 날 만한 get의 캐스팅은 @SuppressWarnings로 꺼 두었기 때문입니다. 실행하면 문자열도 목록에 들어가 size=2가 출력되고, 처음 예제와 똑같이 값을 꺼내 더하는 7번째 줄에서 실패합니다. 목록을 MyArrayList<Integer>로 선언했어도, 들어오는 값을 검사하는 곳이 없으면 그 선언은 아무것도 막지 못합니다. '넣은 종류와 꺼내는 종류가 같다'는 관계는 add의 매개변수와 get의 반환 타입이 같은 E일 때 비로소 성립합니다.

제네릭 목록 안에서 Object 배열을 쓰는 이유

그렇다면 MyArrayList<E> 안의 배열은 왜 E[]가 아니라 Object[]일까요? E[]로 선언하고 new E[10]으로 만들면 캐스팅도 필요 없을 것 같지만, 이 코드는 컴파일되지 않습니다.

lab/GenericArrayCreation.java
public final class GenericArrayCreation<E> {
    private E[] values = new E[10];
}
컴파일 결과
GenericArrayCreation.java:2: error: generic array creation
    private E[] values = new E[10];
                         ^
1 error

타입 소거를 다룬 글에서 본 것처럼, 자바의 제네릭은 컴파일할 때 타입을 검사한 뒤 타입 인자 정보를 지웁니다. 이것을 타입 소거(type erasure)라고 하며, 그래서 실행 중인 MyArrayList<Integer> 객체는 자기 E가 Integer라는 사실을 알지 못합니다. 반면 배열은 실행 중에도 자기 원소 타입을 기억하고, 값을 넣을 때마다 그 타입이 맞는지 검사합니다. Number[] numbers = new Integer[2];로 만든 배열에 3.14를 넣으면 ArrayStoreException이 나는 것도 이 검사 때문입니다. new E[10]을 실행하려면 어떤 원소 타입의 배열을 만들지 알아야 하는데 그 정보가 없으니, 컴파일러가 처음부터 금지하는 것입니다.

그래서 직접 만드는 제네릭 목록은 Object[]에 값을 저장하고, 꺼낼 때 E로 캐스팅합니다. 다음은 끝 추가, 조회, 교체를 갖춘 제네릭 목록입니다. 조회 수 40과 55를 넣은 뒤 두 번째 값을 60으로 바꾸고 합계를 출력합니다.

src/GenericArrayListBoundary.java
import java.util.Arrays;

public final class GenericArrayListBoundary {
    public static void main(String[] args) {
        MyArrayList<Integer> viewCount = new MyArrayList<>();
        viewCount.add(40);
        viewCount.add(55);
        viewCount.set(1, 60);
        System.out.println("total=" + (viewCount.get(0) + viewCount.get(1)));
        System.out.println(viewCount);
    }

    private static final class MyArrayList<E> {
        private Object[] values = new Object[2];
        private int size;

        void add(E value) {
            if (size == values.length) values = Arrays.copyOf(values, values.length * 2);
            values[size++] = value;
        }

        @SuppressWarnings("unchecked")
        E get(int index) {
            check(index);
            return (E) values[index];
        }

        E set(int index, E value) {
            check(index);
            E old = get(index);
            values[index] = value;
            return old;
        }

        private void check(int index) {
            if (index < 0 || index >= size)
                throw new IndexOutOfBoundsException("index=" + index + ", size=" + size);
        }

        @Override
        public String toString() {
            return Arrays.toString(Arrays.copyOf(values, size));
        }
    }
}
실행 결과
total=100
[40, 60]

set(1, 60)은 1번 칸의 55를 60으로 바꾸고, 바꾸기 전의 값 55를 돌려줍니다. main은 이 반환값을 쓰지 않으므로 55는 어디에도 출력되지 않습니다. 원소를 새로 넣은 것이 아니라 있던 칸을 바꾼 것이므로 크기는 2 그대로이고, 합계는 40 + 60 = 100입니다. get(0) + get(1)에 캐스팅이 없는데도 덧셈이 되는 것은 get이 Integer를 돌려주고, 자바가 이를 int로 자동 언박싱하기 때문입니다. set이 돌려주는 이전 값도 E 타입이므로, 교체 결과를 쓰는 쪽에서도 캐스팅이 필요 없습니다.

get과 set은 이미 있는 칸을 다루므로 둘 다 check로 0 <= index && index < size를 확인합니다. 이 목록의 add는 끝 추가만 하지만, 배열 기반 목록을 처음 만든 글처럼 위치를 지정하는 삽입을 더한다면 그 메서드는 index == size까지 허용해야 합니다.

Object 캐스팅 실패와 Integer 교체 결과를 비교한다

독립 Object 목록의 cast 실패와 Integer 목록의 55→60 대체는 다른 실행 경로입니다. 대체가 크기를 바꾸지 않는 상태와 읽기 실패 시점을 비교할 수 있습니다.

Object 캐스팅 실패와 Integer 교체 결과를 비교한다

ObjectListCastFailure는 40과 문자열을 함께 넣은 뒤 두 번째 Integer 캐스팅에서 멈추고, 별도 GenericArrayListBoundary는 Integer 55를 60으로 바꾸어 total 100을 출력합니다.

입력 타입 · 실패와 정상 교체

Object 캐스팅 실패와 Integer 교체 결과를 비교한다

Object 목록은 문자열도 저장하지만 Integer로 읽을 때 실패합니다. Integer 목록 예제는 55를 60으로 바꿔 합계 100을 출력합니다.

Object 캐스팅 실패와 Integer 교체의 다른 원문 영역마다 별도 원문입니다. Object 목록의 String fifty를Integer로 읽으면ClassCastException으로 출력전에 실패합니다. Integer 목록은40/55중둘째를60으로교체해크기2/합계100입니다. 실선은 각 프로그램의 저장·읽기·결과 관계입니다. 원문 A · Object 목록 두 타입을 보관 Integer 40 / String fifty 둘째 값을 Integer로 String 객체는 변환 안 됨 캐스팅 실패 ClassCastException 합계 println은 미실행 원문 B · Integer 목록 두 정수를 보관 40 / 55, size=2 set(1, 60) 교체 이전 값 55 반환 main은 반환값 미사용 정상 결과 [40, 60] / size=2 total=100 영역마다 별도 원문 실행 실선: 실제 저장·읽기·출력 타입 안전성과 숫자 계산 구별
Object 캐스팅 실패와 Integer 교체의 다른 원문 영역마다 별도 원문입니다. Object 목록의 String fifty를Integer로 읽으면ClassCastException으로 출력전에 실패합니다. Integer 목록은40/55중둘째를60으로교체해크기2/합계100입니다. 실선은 각 프로그램의 저장·읽기·결과 관계입니다. 원문 A · Object 목록 두 타입을 보관 Integer 40 / String fifty 둘째 값을 Integer로 String 객체는 변환 안 됨 캐스팅 실패 ClassCastException 합계 println은 미실행 원문 B · Integer 목록 두 정수를 보관 40 / 55, size=2 set(1, 60) 교체 이전 값 55 반환 main은 반환값 미사용 정상 결과 [40, 60] / size=2 total=100 영역마다 별도 원문 실행 실선: 실제 저장·읽기·출력 타입 안전성과 숫자 계산 구별

ArrayList
<Object>

실제 쓰기와 읽기
add(40), add("fifty") 모두 허용
(Integer) get(0)는 40
(Integer) get(1)에서 String을 Integer로 요구
실행 결과와 범위
ClassCastException
합계 println(total)에 도달하지 않음
타입 인자는 Object

MyArrayList
<Integer>

실제 쓰기와 읽기
add(40), add(55)
set(1, 60)으로 55를 교체
set이 돌려준 55는 main에서 사용하지 않음
실행 결과와 범위
total=100
[40, 60]
get 내부의 E 캐스팅은 비검사 캐스팅이며 이 두 읽기는 정상 완료

이 예의 조건

  • Object/Integer 목록은 독립 원문이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2영역마다 별도 원문입니다. Object 목록의 String fifty를 Integer로 읽으면 ClassCastException으로 출력 전에 실패합니다. Integer 목록은 40/55 중 둘째를 60으로 교체해 크기 2/합계 100입니다. 실선은 각 프로그램의 저장·읽기·결과 관계입니다.
5Integer 40
6String fifty
1440
1555, size=2
21size=2
원 도표의 설명과 주석
구분내용
설명영역마다 별도 원문입니다. Object 목록의 String fifty를 Integer로 읽으면 ClassCastException으로 출력 전에 실패합니다. Integer 목록은 40/55 중 둘째를 60으로 교체해 크기 2/합계 100입니다. 실선은 각 프로그램의 저장·읽기·결과 관계입니다.
전체 읽기 정보Object 캐스팅 실패와 Integer 교체 결과를 비교한다 Object 캐스팅 실패와 Integer 교체 결과를 비교한다 ObjectListCastFailure는 40과 문자열을 함께 넣은 뒤 두 번째 Integer 캐스팅에서 멈추고, 별도 GenericArrayListBoundary는 Integer 55를 60으로 바꾸어 total 100을 출력합니다. 입력 타입 · 실패와 정상 교체 Object 캐스팅 실패와 Integer 교체 결과를 비교한다 Object 목록은 문자열도 저장하지만 Integer로 읽을 때 실패합니다. Integer 목록 예제는 55를 60으로 바꿔 합계 100을 출력합니다. ArrayList <Object> 실제 쓰기와 읽기 add(40), add("fifty") 모두 허용 (Integer) get(0) 는 40 (Integer) get(1) 에서 String을 Integer로 요구 실행 결과와 범위 ClassCastException 합계 println(total) 에 도달하지 않음 타입 인자는 Object MyArrayList <Integer> 실제 쓰기와 읽기 add(40), add(55) set(1, 60) 으로 55를 교체 set 이 돌려준 55는 main에서 사용하지 않음 실행 결과와 범위 total=100 [40, 60] get 내부의 E 캐스팅은 비검사 캐스팅이며 이 두 읽기는 정상 완료
예의 값과 변화
관찰·상황값·보충 정보변화·이유
저장 상태

A는 혼합 타입, B는 정수 두 개를 보관합니다.

  • A: Integer40,String fifty
  • B: 40,55 / size2
  • 넓은 Object 계약은 숫자만 보관하도록 제한하지 않는다.
소비/교체

A는 둘째 값을 Integer cast하고 B는 60으로 대체합니다.

  • A: String→Integer 실패
  • B set 1 반환: 55 / main 미사용
  • cast와 정수 대체는 서로 다른 실행 동작이다.
결과

A는 출력 전에 멈추고 B의 크기는 그대로입니다.

  • A: ClassCastException
  • B: [40,60],size2,total100
  • 요소 교체가 목록 크기를 늘리지 않는다.

Object 캐스팅 실패와 Integer 교체 결과를 비교한다

독립 Object 목록의 cast 실패와 Integer 목록의 55→60 대체는 다른 실행 경로입니다. 대체가 크기를 바꾸지 않는 상태와 읽기 실패 시점을 비교할 수 있습니다.

Object 캐스팅 실패와 Integer 교체 결과를 비교한다

ObjectListCastFailure는 40과 문자열을 함께 넣은 뒤 두 번째 Integer 캐스팅에서 멈추고, 별도 GenericArrayListBoundary는 Integer 55를 60으로 바꾸어 total 100을 출력합니다.

입력 타입 · 실패와 정상 교체

Object 캐스팅 실패와 Integer 교체 결과를 비교한다

Object 목록은 문자열도 저장하지만 Integer로 읽을 때 실패합니다. Integer 목록 예제는 55를 60으로 바꿔 합계 100을 출력합니다.

Object 캐스팅 실패와 Integer 교체의 다른 원문 영역마다 별도 원문입니다. Object 목록의 String fifty를Integer로 읽으면ClassCastException으로 출력전에 실패합니다. Integer 목록은40/55중둘째를60으로교체해크기2/합계100입니다. 실선은 각 프로그램의 저장·읽기·결과 관계입니다. 원문 A · Object 목록 두 타입을 보관 Integer 40 / String fifty 둘째 값을 Integer로 String 객체는 변환 안 됨 캐스팅 실패 ClassCastException 합계 println은 미실행 원문 B · Integer 목록 두 정수를 보관 40 / 55, size=2 set(1, 60) 교체 이전 값 55 반환 main은 반환값 미사용 정상 결과 [40, 60] / size=2 total=100 영역마다 별도 원문 실행 실선: 실제 저장·읽기·출력 타입 안전성과 숫자 계산 구별
Object 캐스팅 실패와 Integer 교체의 다른 원문 영역마다 별도 원문입니다. Object 목록의 String fifty를Integer로 읽으면ClassCastException으로 출력전에 실패합니다. Integer 목록은40/55중둘째를60으로교체해크기2/합계100입니다. 실선은 각 프로그램의 저장·읽기·결과 관계입니다. 원문 A · Object 목록 두 타입을 보관 Integer 40 / String fifty 둘째 값을 Integer로 String 객체는 변환 안 됨 캐스팅 실패 ClassCastException 합계 println은 미실행 원문 B · Integer 목록 두 정수를 보관 40 / 55, size=2 set(1, 60) 교체 이전 값 55 반환 main은 반환값 미사용 정상 결과 [40, 60] / size=2 total=100 영역마다 별도 원문 실행 실선: 실제 저장·읽기·출력 타입 안전성과 숫자 계산 구별

ArrayList
<Object>

실제 쓰기와 읽기
add(40), add("fifty") 모두 허용
(Integer) get(0)는 40
(Integer) get(1)에서 String을 Integer로 요구
실행 결과와 범위
ClassCastException
합계 println(total)에 도달하지 않음
타입 인자는 Object

MyArrayList
<Integer>

실제 쓰기와 읽기
add(40), add(55)
set(1, 60)으로 55를 교체
set이 돌려준 55는 main에서 사용하지 않음
실행 결과와 범위
total=100
[40, 60]
get 내부의 E 캐스팅은 비검사 캐스팅이며 이 두 읽기는 정상 완료

이 예의 조건

  • Object/Integer 목록은 독립 원문이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2영역마다 별도 원문입니다. Object 목록의 String fifty를 Integer로 읽으면 ClassCastException으로 출력 전에 실패합니다. Integer 목록은 40/55 중 둘째를 60으로 교체해 크기 2/합계 100입니다. 실선은 각 프로그램의 저장·읽기·결과 관계입니다.
5Integer 40
6String fifty
1440
1555, size=2
21size=2
원 도표의 설명과 주석
구분내용
설명영역마다 별도 원문입니다. Object 목록의 String fifty를 Integer로 읽으면 ClassCastException으로 출력 전에 실패합니다. Integer 목록은 40/55 중 둘째를 60으로 교체해 크기 2/합계 100입니다. 실선은 각 프로그램의 저장·읽기·결과 관계입니다.
전체 읽기 정보Object 캐스팅 실패와 Integer 교체 결과를 비교한다 Object 캐스팅 실패와 Integer 교체 결과를 비교한다 ObjectListCastFailure는 40과 문자열을 함께 넣은 뒤 두 번째 Integer 캐스팅에서 멈추고, 별도 GenericArrayListBoundary는 Integer 55를 60으로 바꾸어 total 100을 출력합니다. 입력 타입 · 실패와 정상 교체 Object 캐스팅 실패와 Integer 교체 결과를 비교한다 Object 목록은 문자열도 저장하지만 Integer로 읽을 때 실패합니다. Integer 목록 예제는 55를 60으로 바꿔 합계 100을 출력합니다. ArrayList <Object> 실제 쓰기와 읽기 add(40), add("fifty") 모두 허용 (Integer) get(0) 는 40 (Integer) get(1) 에서 String을 Integer로 요구 실행 결과와 범위 ClassCastException 합계 println(total) 에 도달하지 않음 타입 인자는 Object MyArrayList <Integer> 실제 쓰기와 읽기 add(40), add(55) set(1, 60) 으로 55를 교체 set 이 돌려준 55는 main에서 사용하지 않음 실행 결과와 범위 total=100 [40, 60] get 내부의 E 캐스팅은 비검사 캐스팅이며 이 두 읽기는 정상 완료
예의 값과 변화
관찰·상황값·보충 정보변화·이유
저장 상태

A는 혼합 타입, B는 정수 두 개를 보관합니다.

  • A: Integer40,String fifty
  • B: 40,55 / size2
  • 넓은 Object 계약은 숫자만 보관하도록 제한하지 않는다.
소비/교체

A는 둘째 값을 Integer cast하고 B는 60으로 대체합니다.

  • A: String→Integer 실패
  • B set 1 반환: 55 / main 미사용
  • cast와 정수 대체는 서로 다른 실행 동작이다.
결과

A는 출력 전에 멈추고 B의 크기는 그대로입니다.

  • A: ClassCastException
  • B: [40,60],size2,total100
  • 요소 교체가 목록 크기를 늘리지 않는다.

도표의 A는 앞에서 본 Object 목록이 두 번째 값을 Integer로 캐스팅하다 출력 전에 멈추는 과정이고, B는 이 제네릭 목록에서 교체가 크기를 바꾸지 않는 과정입니다. 두 프로그램은 서로 별개이며, 실패하는 시점과 결과를 나란히 비교하려고 함께 그렸습니다.

값을 바꿔서 확인해 보세요. 처음에 넣는 두 값을 35와 65로 바꾸고 set(1, 60) 대신 set(0, 50)을 호출하면, 목록과 set이 돌려주는 값, 크기, 합계는 각각 무엇이 될까요?

답 확인하기

목록은 [50, 65], set이 돌려주는 이전 값은 35, 크기는 2, 합계는 115입니다. 50을 끝에 추가한 것이 아니라 0번 칸을 바꾼 것이므로 크기는 늘지 않습니다. 또 main이 반환값을 출력하지 않으므로 이전 값 35가 저절로 화면에 나타나지도 않습니다.

get 안의 비검사 캐스팅이 안전한 이유

get의 (E) values[index]는 컴파일러가 실행 중에 확인해 줄 수 없는 캐스팅입니다. 타입 소거 때문에 실행 중에는 E가 무엇인지 모르므로, 이 캐스팅은 실제로는 아무것도 검사하지 않습니다. 이런 캐스팅을 비검사 캐스팅(unchecked cast)이라고 하며, 컴파일러는 경고를 냅니다. @SuppressWarnings("unchecked")를 빼고 컴파일하면 기본 설정에서는 Note:로 시작하는 안내 두 줄만 나오고, -Xlint:unchecked 옵션을 주면 다음과 같이 위치까지 알려 줍니다.

javac -Xlint:unchecked 경고의 핵심
warning: [unchecked] unchecked cast
            return (E) values[index];
                             ^
  required: E
  found:    Object

그런데도 이 캐스팅이 안전하다고 말할 수 있는 것은 목록이 지키는 규칙 때문입니다. values 배열에 값을 쓰는 곳은 add(E)와 set(int, E) 두 메서드뿐이고, 둘 다 컴파일러가 E 타입인지 검사한 값만 받습니다. 그러니 0번부터 size - 1번까지의 칸에는 언제나 E 타입의 값만 들어 있고, 그 칸에서 꺼낸 값을 E로 캐스팅해도 틀릴 일이 없습니다. 객체 배열로 회원 저장소 만들기에서 본 불변식(invariant), 곧 프로그램이 실행되는 동안 늘 참이어야 하는 조건과 같은 종류의 규칙입니다. 컴파일러는 메서드 여러 개에 걸친 이런 규칙까지는 확인하지 못하므로, 사람이 확인하고 경고를 끄는 것입니다.

경고를 끄는 범위는 좁을수록 좋습니다. @SuppressWarnings("unchecked")를 클래스 전체에 붙이면, 나중에 다른 메서드에 생긴 위험한 캐스팅의 경고까지 함께 사라집니다. 그래서 실제로 캐스팅하는 메서드 하나에만 붙이고, 필요하면 왜 안전한지 주석으로 남깁니다. 경고를 끄는 것은 문제를 숨기는 일이 아니라 '이 캐스팅은 확인을 마쳤다'고 표시하는 일이어야 합니다.

이 규칙이 깨지면 어떻게 되는지는 앞의 ObjectAddLeak이 보여 줍니다. add가 Object를 받는 순간 '모든 값이 E를 거쳐 들어온다'는 규칙이 무너지는데, 경고는 이미 꺼져 있으니 컴파일러는 아무 말도 하지 않습니다. 그 실행 결과의 스택 트레이스를 다시 보면, 실패한 곳이 get이 아니라 main의 7번째 줄이라는 점도 눈여겨볼 만합니다. get 안의 (E) 캐스팅은 실제로 아무것도 검사하지 않기 때문입니다. 실제 검사는 컴파일러가 get의 반환값을 Integer로 쓰는 호출 쪽, 곧 main에 자동으로 넣어 둔 Integer 캐스팅에서 일어납니다. 그래서 규칙이 깨진 제네릭 목록은 Object 목록과 똑같이, 값을 꺼내 쓰는 먼 곳에서 실패합니다.

더 깊이: (E[]) new Object[n]으로 배열을 만드는 방법

내부 배열을 E[]로 선언하고, 생성자에서 values = (E[]) new Object[capacity];처럼 한 번만 캐스팅하는 방법도 있습니다. 이렇게 하면 get은 캐스팅 없이 values[index]를 돌려줄 수 있습니다. 실행 중에는 E[]라는 타입도 Object[]로 지워지므로, 배열을 클래스 안에서만 쓰는 동안은 문제가 없습니다. 하지만 실제로 만든 배열은 여전히 Object[]이므로, 이 배열이 E[]라는 타입을 달고 클래스 밖으로 나가는 순간 문제가 생깁니다.

lab/CastArrayPitfall.java
public final class CastArrayPitfall {
    public static void main(String[] args) {
        MyArrayList<Integer> viewCounts = new MyArrayList<>(2);
        viewCounts.add(40);
        System.out.println("get(0)=" + viewCounts.get(0));
        Integer[] snapshot = viewCounts.rawArray();
        System.out.println("length=" + snapshot.length);
    }

    private static final class MyArrayList<E> {
        private final E[] values;
        private int size;

        @SuppressWarnings("unchecked")
        MyArrayList(int capacity) {
            values = (E[]) new Object[capacity];
        }

        void add(E value) {
            values[size++] = value;
        }

        E get(int index) {
            return values[index];
        }

        E[] rawArray() {
            return values;
        }
    }
}
실행 결과
get(0)=40
Exception in thread "main" java.lang.ClassCastException: class [Ljava.lang.Object; cannot be cast to class [Ljava.lang.Integer; ([Ljava.lang.Object; and [Ljava.lang.Integer; are in module java.base of loader 'bootstrap')
	at CastArrayPitfall.main(CastArrayPitfall.java:6)

get(0)은 잘 동작하지만, rawArray()가 돌려준 배열을 Integer[] 변수에 받는 6번째 줄에서 실패합니다. 호출 쪽에 들어간 캐스팅이 실제 배열의 타입인 Object[](메시지의 [Ljava.lang.Object;)를 확인하고, Integer[]가 아니라고 거절하기 때문입니다. 두 방법 모두 쓰이지만, Object[]에 저장하고 꺼낼 때마다 캐스팅하는 방식에는 이런 위험이 없습니다. OpenJDK의 ArrayList도 원소를 Object[] 타입의 elementData 필드에 저장하고, 원소를 꺼낼 때는 E로 캐스팅해 돌려주는 elementData(int) 메서드를 거칩니다.

MyArrayList<Integer>는 MyArrayList<Number>가 아니다

Integer는 Number의 하위 타입이지만, MyArrayList<Integer>는 MyArrayList<Number>의 하위 타입이 아닙니다. 제네릭 클래스를 처음 배운 글에서 본 불공변 규칙이 목록에도 그대로 적용됩니다. 목록에서는 그 이유가 더 잘 보이는데, 자바의 List로 대입을 시도해 보겠습니다.

lab/InvarianceError.java
import java.util.ArrayList;
import java.util.List;

public final class InvarianceError {
    public static void main(String[] args) {
        List<Integer> integers = new ArrayList<>();
        List<Number> numbers = integers;
        numbers.add(3.14);
    }
}
컴파일 결과
InvarianceError.java:7: error: incompatible types: List<Integer> cannot be converted to List<Number>
        List<Number> numbers = integers;
                               ^
1 error

이 대입이 허용된다면 다음 줄의 numbers.add(3.14)도 컴파일됩니다. numbers의 타입은 List<Number>이고, 3.14가 자동으로 바뀐 Double은 Number이기 때문입니다. 그런데 numbers와 integers는 같은 목록을 가리키므로, Integer만 들어 있어야 할 목록에 Double이 들어가 버립니다. 그러면 integers에서 꺼낸 값을 Integer로 쓰는 곳에서 다시 ClassCastException이 날 것입니다. 컴파일러는 이런 일을 막으려고 대입 자체를 금지합니다. 여러 종류의 숫자 목록을 받아서 읽기만 하는 메서드가 필요하다면, 와일드카드를 다루는 글에서 본 List<? extends Number>를 매개변수 타입으로 씁니다.

타입 인자를 빼고 MyArrayList raw = viewCounts;처럼 쓰는 raw 타입도 같은 구멍을 엽니다. raw.add("fifty")는 컴파일 오류 없이 컴파일되고, 기본 설정에서는 Note: 안내만 나옵니다. -Xlint:unchecked로 컴파일해야 unchecked call to add(E) as a member of the raw type MyArrayList라는 경고를 볼 수 있습니다. 결과는 add가 Object를 받던 목록과 같아서, 그 목록에서 값을 꺼내 쓰는 곳이 나중에 실패합니다. raw 타입은 제네릭 이전에 작성된 오래된 코드와 연결해야 하는 좁은 자리에서만 쓰고, 그런 경고를 빌드할 때마다 으레 나오는 것으로 여기지 않아야 합니다.

게시글 저장소는 Entry만 담는다

이 장의 게시글 저장소는 제네릭을 쓰지 않습니다. 담는 것이 언제나 Entry 한 종류뿐이므로, 배열을 처음부터 Entry[]로 만들면 되기 때문입니다.

app/PostRepositoryCliCH143.java
public final class PostRepositoryCliCH143 {
    public static void main(String[] args) {
        PostRepository repository = new PostRepository();
        repository.add("generic-entry", 42);
        repository.add("checked-cast", 53);
        repository.print();
        System.out.println("total=" + repository.totalViewCount());
    }

    private static final class PostRepository {
        private Entry[] entries = new Entry[2];
        private int size;

        void add(String title, int viewCount) {
            if (title == null || title.isBlank()) throw new IllegalArgumentException("title");
            if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
            ensureCapacity(size + 1);
            entries[size++] = new Entry(title, viewCount);
        }

        private void ensureCapacity(int required) {
            if (required <= entries.length) return;
            Entry[] grown = new Entry[Math.max(required, entries.length * 2)];
            System.arraycopy(entries, 0, grown, 0, size);
            entries = grown;
        }

        int totalViewCount() {
            int total = 0;
            for (int i = 0; i < size; i++) {
                total += entries[i].viewCount();
            }
            return total;
        }

        void print() {
            for (int i = 0; i < size; i++) {
                Entry entry = entries[i];
                System.out.println(i + ":" + entry.title() + "=" + entry.viewCount());
            }
        }
    }

    private record Entry(String title, int viewCount) {}
}
실행 결과
0:generic-entry=42
1:checked-cast=53
total=95

Entry[]의 칸에는 Entry만 들어갈 수 있고, entries[i]로 꺼낸 값도 Entry이므로 이 코드에는 캐스팅이 한 번도 나오지 않습니다. 제네릭은 MyArrayList처럼 여러 타입에 다시 쓸 구조를 만들 때 필요한 도구입니다. 한 가지 타입만 다루는 클래스라면 처음부터 구체적인 타입을 쓰는 편이 가장 간단합니다.

Entry 레코드가 제목과 조회 수를 한 객체로 묶는다는 점도 타입 안전성의 일부입니다. 배열과 인덱스에서 본 병렬 배열처럼 제목은 String[]에, 조회 수는 int[]에 따로 담으면, 한쪽 배열만 옮기거나 지우는 실수 하나로 같은 인덱스의 제목과 조회 수가 어긋날 수 있습니다. 이 저장소의 add는 두 값을 검증한 뒤 완성된 Entry를 한 칸에 저장하므로 그런 어긋남이 생길 수 없습니다. 출력은 입력 순서대로 두 줄이고, 합계는 42 + 53 = 95입니다.

Object, 제네릭, 공통 상위 타입 가운데 고르기

목록에 무엇을 담을지에 따라 원소 타입을 정하는 방법이 달라집니다.

상황선택
한 목록에 한 종류의 값만 담는다그 타입을 타입 인자로 쓴다. 예: MyArrayList<Integer>
서로 다른 종류를 한 목록에 섞어 담아야 한다공통 인터페이스나 sealed 계층을 만들어 타입 인자로 쓴다
목록을 쓰는 코드에 캐스팅이 보인다타입 인자를 빠뜨렸거나 Object로 받는 곳이 있는지 찾는다
제네릭 클래스 안에서 배열을 쓴다Object[]에 저장하고 비검사 캐스팅을 한 메서드에 모은다
목록에 null이 들어올 수 있다허용할지 정해서 문서에 적는다

서로 다른 종류를 담아야 할 때 Object로 도망가면 편해 보이지만, 그 대가로 타입 정보가 사라지고 꺼내는 쪽이 모두 캐스팅을 떠맡게 됩니다. 담을 수 있는 종류를 공통 인터페이스로 묶거나, 종류가 정해져 있다면 sealed 계층으로 가능한 종류를 모두 적어 두는 편이 낫습니다. 그러면 목록은 여전히 한 타입을 담고, 컴파일러는 계속 검사를 해 줍니다.

마지막 줄은 제네릭이 막아 주지 않는 부분입니다. 제네릭이 걸러 내는 것은 다른 타입의 값이지 null이 아니므로, List<Integer>에도 null은 들어갑니다. 그 값을 int로 쓰는 순간 언박싱 과정에서 NullPointerException이 나므로, null을 받을지는 타입과 별개로 정해서 문서에 적어 두어야 합니다.

정리

  • Object를 담는 목록은 무엇이든 받으므로 잘못된 값이 들어와도 꺼내서 캐스팅할 때에야 실패하고, 값을 넣은 곳과 실패한 곳이 멀어집니다.
  • MyArrayList<E>의 E를 add의 매개변수와 get의 반환 타입에 함께 쓰면, 잘못된 값은 넣는 줄에서 컴파일 오류가 되고 꺼내는 쪽에서는 캐스팅이 사라집니다.
  • 타입 소거 때문에 new E[n]은 만들 수 없으므로, 제네릭 목록은 Object[]에 저장하고 get에서 E로 캐스팅합니다.
  • 이 비검사 캐스팅은 '모든 쓰기가 E를 거친다'는 규칙 덕분에 안전합니다. 경고는 캐스팅하는 메서드 하나에서만 끄고, Object나 raw 타입으로 들어오는 쓰기 경로를 만들지 않습니다.
  • MyArrayList<Integer>는 MyArrayList<Number>에 대입할 수 없습니다. 허용하면 정수 목록에 다른 종류의 숫자가 들어갈 수 있기 때문입니다.

연습 문제

문제 1: 캐스팅 없이 큰 값 고르기

Integer만 받는 두 칸짜리 저장소를 만들고, 30과 70을 넣은 뒤 둘 중 큰 값을 출력하세요. 저장소가 Object를 돌려주거나, 호출하는 쪽에서 캐스팅하는 방식은 쓰지 마세요.

해설 보기

저장소를 제네릭 클래스 Store<E>로 만들고 add와 get에 같은 E를 쓰면, Store<Integer>의 get이 처음부터 Integer를 돌려줍니다. 배열 캐스팅은 저장소 안의 get 한 곳에만 있습니다.

exercise/TypedMaximumStoreSolution.java
public final class TypedMaximumStoreSolution {
    public static void main(String[] args) {
        Store<Integer> store = new Store<>();
        store.add(30);
        store.add(70);
        int maximum = Math.max(store.get(0), store.get(1));
        System.out.println("maximum=" + maximum);
    }

    private static final class Store<E> {
        private final Object[] values = new Object[2];
        private int size;

        void add(E value) {
            values[size++] = value;
        }

        @SuppressWarnings("unchecked")
        E get(int index) {
            if (index < 0 || index >= size) throw new IndexOutOfBoundsException(index);
            return (E) values[index];
        }
    }
}
실행 결과
maximum=70

store.get(0)과 store.get(1)은 Integer를 돌려주고, Math.max(int, int)에 넘길 때 자동으로 int로 언박싱됩니다. 호출하는 쪽에 캐스팅이 하나도 없는데도 이 코드를 믿을 수 있는 것은, Store에 값을 쓰는 유일한 메서드 add가 E만 받기 때문입니다. 이 저장소는 확장을 구현하지 않았으므로 세 번째 값을 넣으면 배열 범위를 벗어난다는 점도 기억해 두세요.

문제 2: 어느 줄이 컴파일될까

다음 코드에서 ①부터 ④까지 각 줄이 컴파일되는지 판단하고, 컴파일되지 않는 줄은 그 이유를 설명하세요. 줄마다 add가 받는 타입이 무엇인지, ④에서는 대입하는 두 변수의 타입이 각각 무엇인지를 먼저 적어 보면 됩니다.

exercise/WhichLinesCompile.java
public final class WhichLinesCompile {
    public static void main(String[] args) {
        MyArrayList<Number> numbers = new MyArrayList<>();
        numbers.add(1);                                 // ①
        numbers.add(2.5);                               // ②
        MyArrayList<Integer> integers = new MyArrayList<>();
        integers.add(2.5);                              // ③
        numbers = integers;                             // ④
    }

    private static final class MyArrayList<E> {
        private final Object[] values = new Object[4];
        private int size;

        void add(E value) {
            values[size++] = value;
        }
    }
}
해설 보기

①과 ②는 컴파일됩니다. numbers의 add는 Number를 받는데, 1은 자동으로 Integer가 되고 2.5는 Double이 되며 둘 다 Number의 하위 타입입니다. 즉 MyArrayList<Number>에는 여러 종류의 숫자를 섞어 담을 수 있습니다.

③과 ④는 컴파일 오류입니다. javac의 출력은 다음과 같습니다.

컴파일 결과
WhichLinesCompile.java:7: error: incompatible types: double cannot be converted to Integer
        integers.add(2.5);                              // ③
                     ^
WhichLinesCompile.java:8: error: incompatible types: MyArrayList<Integer> cannot be converted to MyArrayList<Number>
        numbers = integers;                             // ④
                  ^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
2 errors

③은 Integer만 받는 목록에 double 값을 넣으려 했기 때문입니다. ④는 원소를 넣는 문제가 아니라 목록 자체를 대입하는 문제입니다. Integer가 Number의 하위 타입이어도 MyArrayList<Integer>는 MyArrayList<Number>의 하위 타입이 아니므로 대입할 수 없습니다. ④가 허용된다면 바로 ②처럼 numbers.add(2.5)로 정수 목록에 Double을 넣을 수 있게 됩니다.

문제 3: removeLast 추가하기

GenericArrayListBoundary의 MyArrayList<E>에 마지막 원소를 지우고 그 값을 돌려주는 E removeLast()를 추가하세요. 호출하는 쪽에서 캐스팅하지 않아도 되어야 하고, 지운 칸은 배열 기반 목록을 처음 만든 글에서처럼 비워야 합니다. 목록이 비어 있으면 java.util.NoSuchElementException을 던집니다.

해설 보기

새 메서드에서 비검사 캐스팅을 또 하지 않도록, 마지막 원소는 이미 캐스팅을 맡고 있는 get으로 꺼냅니다. 그러면 @SuppressWarnings가 붙은 곳은 여전히 get 하나뿐입니다.

exercise/RemoveLastSolution.java
import java.util.Arrays;
import java.util.NoSuchElementException;

public final class RemoveLastSolution {
    public static void main(String[] args) {
        MyArrayList<Integer> viewCounts = new MyArrayList<>();
        viewCounts.add(40);
        viewCounts.add(55);
        viewCounts.add(60);
        int last = viewCounts.removeLast();
        System.out.println("removed=" + last + ", list=" + viewCounts + ", size=" + viewCounts.size());

        MyArrayList<String> empty = new MyArrayList<>();
        try {
            empty.removeLast();
        } catch (NoSuchElementException e) {
            System.out.println("empty: " + e.getMessage());
        }
    }

    private static final class MyArrayList<E> {
        private Object[] values = new Object[2];
        private int size;

        void add(E value) {
            if (size == values.length) values = Arrays.copyOf(values, values.length * 2);
            values[size++] = value;
        }

        @SuppressWarnings("unchecked")
        E get(int index) {
            if (index < 0 || index >= size)
                throw new IndexOutOfBoundsException("index=" + index + ", size=" + size);
            return (E) values[index];
        }

        E removeLast() {
            if (size == 0) throw new NoSuchElementException("list is empty");
            E last = get(size - 1);
            values[--size] = null;
            return last;
        }

        int size() {
            return size;
        }

        @Override
        public String toString() {
            return Arrays.toString(Arrays.copyOf(values, size));
        }
    }
}
실행 결과
removed=60, list=[40, 55], size=2
empty: list is empty

세 번째 add에서 배열이 길이 4로 늘어난 뒤, removeLast는 2번 칸의 60을 꺼내고 그 칸을 비웁니다. 마지막 원소를 지울 때는 옮길 원소가 없으므로, 칸을 비우지 않으면 지운 객체의 참조가 그대로 남습니다. 빈 목록에서는 get(-1)에 이르기 전에 먼저 size를 확인하므로, 인덱스 예외 대신 상황에 맞는 NoSuchElementException이 납니다. 반환 타입이 E이므로 main은 결과를 캐스팅 없이 int 변수에 받습니다.

다음 글

지금까지 만든 목록은 모두 배열 위에 있어서, 중간에 넣거나 지울 때마다 뒤쪽 원소를 옮겨야 했습니다. 단방향 연결 목록에서는 배열 없이 노드를 참조로 이어 붙이는 목록을 만듭니다. 원소를 옮기지 않고도 연결만 바꿔 넣고 뺄 수 있는 대신, i번째 원소를 찾으려면 맨 앞에서부터 따라가야 한다는 새로운 비용이 생깁니다.