포스트

Java (4) - Generic: 컴파일하면 사라진다

List과 List의 클래스가 같다는 것을 확인하고 타입 소거 때문에 못 하게 되는 일들과 와일드카드가 필요한 이유를 다룹니다.

Java (4) - Generic: 컴파일하면 사라진다

자바 기초 시리즈의 4편입니다. 전체 목차는 0편에 있습니다.

3편에서 컬렉션을 계속 List<Integer>, Map<String, Integer>처럼 썼습니다. 이 꺾쇠 안의 타입이 실행할 때는 없습니다.

없어서 편한 점이 하나 있고, 없어서 못 하는 게 여럿 있습니다.

제네릭이 없었을 때

Java 5 이전에는 이렇게 썼습니다.

1
2
3
4
5
6
List names = new ArrayList();
names.add("김");
names.add(42);                          // 막을 방법이 없다

String first = (String) names.get(0);   // 꺼낼 때마다 캐스팅
String second = (String) names.get(1);  // 실행 중에 터진다

문제가 둘입니다. 아무거나 들어갑니다. 그리고 꺼낼 때마다 캐스팅해야 하고, 틀리면 실행 중에 터집니다.

제네릭은 이 검사를 컴파일 시점으로 앞당깁니다.

1
2
3
4
5
List<String> names = new ArrayList<>();
names.add("김");
names.add(42);          // 컴파일 에러

String first = names.get(0);   // 캐스팅이 필요 없다

여기까지가 제네릭의 목적 전부입니다. 컴파일러에게 검사를 시키는 것. 그리고 검사가 끝나면 컴파일러는 그 정보를 지웁니다.

타입 소거

확인 1: 두 리스트의 클래스가 같다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.*;

public class Gen {
    public static void main(String[] args) {
        List<String> s = new ArrayList<>();
        List<Integer> i = new ArrayList<>();

        System.out.println("s.getClass() = " + s.getClass());
        System.out.println("i.getClass() = " + i.getClass());
        System.out.println("같은 클래스인가 = " + (s.getClass() == i.getClass()));

        List raw = s;              // 로 타입으로 받는다
        raw.add(42);               // 검사 없이 들어간다
        System.out.println("s.size() = " + s.size());
        System.out.println("s = " + s);

        String first = s.get(0);   // String 으로 꺼낸다
        System.out.println(first);
    }
}
1
2
3
4
5
6
7
8
9
Note: Gen.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
s.getClass() = class java.util.ArrayList
i.getClass() = class java.util.ArrayList
같은 클래스인가 = true
s.size() = 1
s = [42]
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at Gen.main(Gen.java:19)

두 가지가 보입니다.

List<String>List<Integer>의 클래스가 같습니다. 실행 중에는 둘 다 그냥 ArrayList입니다. 꺾쇠 안의 정보는 클래스 파일에도 객체에도 남지 않습니다. 이걸 타입 소거(type erasure) 라고 합니다.

List<String>Integer가 들어갔습니다. 로 타입(List)으로 한 번 받으면 컴파일러가 검사를 못 합니다. 컴파일러는 경고만 하고 통과시킵니다.

그리고 마지막에 ClassCastException이 납니다. s.get(0)에는 캐스팅을 쓴 적이 없는데 캐스팅 예외가 났습니다.

1
2
3
4
5
6
7
[ 내가 쓴 코드 ]                    [ 컴파일 후 (개념) ]

List<String> s = ...                List s = ...
s.add("김");                        s.add("김");
String first = s.get(0);            String first = (String) s.get(0);
                                                   ^^^^^^^^
                                                   컴파일러가 넣어준다

컴파일러가 캐스팅을 대신 써준 것뿐입니다. 제네릭 이전에 손으로 쓰던 캐스팅을 컴파일러가 써주고, 대신 그 캐스팅이 항상 성공하도록 컴파일 시점에 검사해줍니다. 검사를 우회하면(로 타입) 예전과 똑같이 실행 중에 터집니다.

그럼 왜 소거하느냐면 하위 호환 때문입니다. Java 5에서 제네릭을 넣으면서, 그 전에 컴파일된 라이브러리와 새 코드가 같은 JVM에서 돌아야 했습니다. 클래스 파일 포맷과 JVM을 안 바꾸는 쪽을 골랐고, 그 대가가 아래 제약들입니다.

소거 때문에 못 하는 것들

실행 중에 T가 뭔지 모르기 때문에 생기는 제약입니다.

확인 2: 배열을 못 만든다

1
2
3
4
5
6
7
8
9
public class A {
    static class Box<T> {
        private T[] items;
        Box(int size) {
            items = new T[size];
        }
    }
    public static void main(String[] args) {}
}
1
2
3
4
A.java:7: error: generic array creation
            items = new T[size];
                    ^
1 error

new T[size]JVM에게 “몇 바이트짜리 무슨 배열을 만들어라” 라고 말해야 하는데, 실행 시점에 T가 뭔지 모릅니다.

그래서 실제 라이브러리는 Object[]로 만들고 꺼낼 때 캐스팅합니다. ArrayList의 내부 필드가 Object[] elementData인 게 그 이유입니다.

1
2
3
4
5
6
7
static class Box<T> {
    private final Object[] items;
    Box(int size) { items = new Object[size]; }

    @SuppressWarnings("unchecked")
    T get(int i) { return (T) items[i]; }
}

같은 이유로 안 되는 것들이 더 있습니다.

1
2
3
public class K<T> {
    private static T instance;
}
1
2
3
4
K.java:2: error: non-static type variable T cannot be referenced from a static context
    private static T instance;
                   ^
1 error

static 필드는 인스턴스와 무관하게 하나뿐인데, T는 인스턴스마다 다릅니다. 애초에 말이 안 됩니다.

1
2
3
public class L<T> {
    T create() { return new T(); }
}
1
2
3
4
5
6
L.java:2: error: unexpected type
    T create() { return new T(); }
                            ^
  required: class
  found:    type parameter T
1 error

new T()도 어떤 생성자를 부를지 모르니 안 됩니다. 필요하면 만드는 방법을 밖에서 받습니다.

1
T create(Supplier<T> factory) { return factory.get(); }
1
2
3
4
5
6
import java.util.*;

public class J {
    static void print(List<String> list) {}
    static void print(List<Integer> list) {}
}
1
2
3
4
J.java:5: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure
    static void print(List<Integer> list) {}
                ^
1 error

소거하고 나면 두 메서드가 똑같아집니다. 둘 다 print(List)입니다. 오버로딩이 성립하지 않습니다.

o instanceof List<String>도 같은 이유로 컴파일되지 않습니다. 실행 중에 확인할 방법이 없으니 물어볼 수 없습니다.

위 컴파일 에러 문구는 javac 기준이고, JDK 버전에 따라 표현이 조금씩 다를 수 있습니다. 봐야 할 건 문장이 아니라 “실행 시점에 T를 모른다”는 이유 하나로 전부 설명된다는 점입니다.

제네릭은 공변이 아니다

여기서 사람들이 가장 많이 막힙니다.

확인 3: 배열은 되는데 리스트는 안 된다

StringObject의 하위 타입입니다. 그럼 List<String>List<Object>의 하위 타입일까요.

1
2
3
4
5
6
7
8
import java.util.*;

public class B {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        List<Object> objects = names;
    }
}
1
2
3
4
B.java:6: error: incompatible types: List<String> cannot be converted to List<Object>
        List<Object> objects = names;
                               ^
1 error

아닙니다. 그런데 배열은 됩니다.

1
2
Object[] objs = new String[2];   // 컴파일된다
objs[0] = 42;
1
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer

배열은 컴파일은 되고 실행 중에 터집니다. 이걸 배열의 공변성이라고 합니다.

  배열 제네릭
Object[] = String[] / List<Object> = List<String> 된다 (공변) 안 된다 (불공변)
잘못 넣으면 실행 중 ArrayStoreException 컴파일 에러
언제 알게 되나 운영에서 작성 중에

제네릭이 더 엄격한 게 아니라 더 일찍 잡는 것입니다.

배열이 실행 중에 잡을 수 있는 이유는 배열은 자기 원소 타입을 기억하기 때문입니다. new String[2]로 만든 배열은 실행 중에도 “나는 String 배열”이라는 걸 알고 있어서, Integer를 넣으려 하면 거부할 수 있습니다.

제네릭은 그걸 못 합니다. 실행 시점에 List<String>은 그냥 List입니다. 실행 중에 못 잡으니 컴파일 시점에 아예 막는 것입니다.

만약 List<Object> objects = names;가 허용됐다면 이렇게 됩니다.

1
2
3
4
5
6
7
   List<String> names ●───┐
                          ├──▶ ┌──────────────┐
   List<Object> objects ●─┘    │ ArrayList    │
                               └──────────────┘
   objects.add(42);        ← Object 리스트니까 당연히 허용
   String s = names.get(0); ← String 리스트니까 캐스팅 없이 꺼냄
                            → 확인 1의 ClassCastException

그래서 와일드카드가 있다

불공변이 안전하기는 한데, 이런 메서드를 못 쓰게 됩니다.

1
2
3
4
static double sum(List<Number> numbers) { ... }

sum(List.of(1, 2, 3));       // List<Integer> → 안 된다
sum(List.of(1.0, 2.0));      // List<Double>  → 안 된다

IntegerDoubleNumber인데 리스트로 감싸면 안 넘어갑니다. 와일드카드는 이 자리를 푸는 도구입니다.

? extends — 꺼내기만 한다

1
2
3
4
5
static double sum(List<? extends Number> numbers) {
    double total = 0;
    for (Number n : numbers) total += n.doubleValue();
    return total;
}

List<Integer>, List<Double>, List<Number> 전부 넘어갑니다. 대신 넣을 수는 없습니다.

1
2
3
4
static double sum(List<? extends Number> numbers) {
    numbers.add(1);
    ...
}
1
2
3
4
5
6
F.java:5: error: incompatible types: int cannot be converted to CAP#1
        numbers.add(1);
                    ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number
1 error

왜 못 넣냐면, 무엇이 들어와 있는지 모르기 때문입니다.

1
2
3
4
5
6
7
   List<? extends Number> numbers 에 실제로 들어올 수 있는 것

     List<Integer>  →  여기에 Double 을 넣으면 안 된다
     List<Double>   →  여기에 Integer 를 넣으면 안 된다
     List<Number>   →  둘 다 괜찮다

   컴파일러는 셋 중 무엇인지 모른다  →  전부 막는다

반대로 꺼내는 건 안전합니다. 무엇이 들어 있든 Number이기는 하기 때문입니다.

? super — 넣기만 한다

정확히 뒤집힌 상황입니다.

1
2
3
static void copy(List<? super Integer> dst, List<? extends Integer> src) {
    for (Integer n : src) dst.add(n);
}

List<? super Integer>에 들어올 수 있는 건 List<Integer>, List<Number>, List<Object>입니다. 셋 중 무엇이든 Integer를 넣는 건 항상 안전합니다.

대신 꺼내면 Object로만 나옵니다. List<Object>일 수도 있으니 그 이상은 보장할 수 없습니다.

확인 4: 둘을 같이 써본다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.*;

public class H {
    static void copy(List<? super Integer> dst, List<? extends Integer> src) {
        for (Integer n : src) dst.add(n);
    }

    public static void main(String[] args) {
        List<Number> numbers = new ArrayList<>();
        List<Object> objects = new ArrayList<>();

        copy(numbers, List.of(1, 2, 3));
        copy(objects, List.of(4, 5));

        System.out.println("numbers = " + numbers);
        System.out.println("objects = " + objects);

        List<? super Integer> dst = numbers;
        Object first = dst.get(0);
        System.out.println("꺼내면 Object 로만 = " + first);
    }
}
1
2
3
numbers = [1, 2, 3]
objects = [4, 5]
꺼내면 Object 로만 = 1

? super를 안 쓰고 List<Object> dst로 받았다면 첫 번째 호출부터 막힙니다.

1
2
3
static void copy(List<Object> dst, List<? extends Number> src) { ... }

copy(numbers, List.of(1, 2, 3));   // numbers 는 List<Number>
1
2
3
4
G.java:9: error: incompatible types: List<Number> cannot be converted to List<Object>
        copy(numbers, List.of(1, 2, 3));
             ^
1 error

PECS를 외우지 않는 법

Producer Extends, Consumer Super 라는 암기법이 있는데, 외우는 대신 이렇게 생각하면 됩니다.

1
2
3
4
5
6
7
8
9
10
   이 컬렉션에서 값을 꺼내 쓰나?         →  ? extends
     (컬렉션이 값을 내주는 쪽 = Producer)
     "무엇이든 최소한 이 타입이기는 하다"

   이 컬렉션에 값을 넣나?                →  ? super
     (컬렉션이 값을 받는 쪽 = Consumer)
     "무엇이든 이 타입은 받아줄 수 있다"

   둘 다 하나?                           →  와일드카드를 쓸 수 없다
     정확한 타입(List<T>)을 쓴다

확인 4의 copy(dst, src)에서 src는 꺼내기만 하고 dst는 넣기만 합니다. 그래서 각각 extendssuper가 붙었습니다. 표준 라이브러리의 Collections.copy() 시그니처가 정확히 이 모양입니다.

실무에서 쓰는 자리

직접 제네릭 클래스를 만드는 일은 생각보다 드뭅니다. 대부분은 이미 만들어진 것을 쓰기만 합니다. 그래도 두 자리에서는 직접 만들게 됩니다.

공통 응답 래퍼.

1
2
3
4
5
6
public record ApiResponse<T>(int code, String message, T data) {

    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>(200, "OK", data);
    }
}
1
2
ApiResponse<MemberResponse> r1 = ApiResponse.ok(member);
ApiResponse<List<OrderResponse>> r2 = ApiResponse.ok(orders);

data의 타입만 다르고 나머지는 같습니다. 제네릭이 없으면 응답 타입마다 래퍼 클래스를 하나씩 만들거나 Object로 두고 캐스팅해야 합니다.

저장소 인터페이스.

1
2
3
4
5
public interface JpaRepository<T, ID> {
    Optional<T> findById(ID id);
    T save(T entity);
    List<T> findAll();
}

MemberRepository extends JpaRepository<Member, Long>라고 쓰면 findById(1L)Optional<Member>를 돌려줍니다. 캐스팅 없이 씁니다. 스프링 데이터 JPA를 쓸 때 이미 이 구조를 쓰고 있었던 셈입니다.

내가 헷갈렸던 지점

정리

  • 제네릭의 목적은 캐스팅 검사를 실행 시점에서 컴파일 시점으로 앞당기는 것이다. 컴파일러는 검사가 끝나면 꺾쇠 안의 정보를 지우고, 대신 캐스팅을 자동으로 넣어준다
  • 그래서 List<String>List<Integer>는 실행 중에 같은 클래스다. 이게 타입 소거고, 하위 호환을 위해 선택된 방식이다
  • 소거 때문에 new T[], static T 필드, new T(), 소거 후 겹치는 오버로딩, instanceof List<String> 이 전부 안 된다. 이유는 하나다. 실행 시점에 T가 뭔지 모른다
  • 제네릭은 공변이 아니다. List<Object> = List<String>이 안 된다. 배열은 되지만 실행 중에 ArrayStoreException이 난다. 제네릭이 더 엄격한 게 아니라 더 일찍 잡는 것이다
  • 와일드카드는 불공변 때문에 막힌 자리를 푸는 도구다. 꺼내 쓰면 ? extends(넣기 불가), 넣기만 하면 ? super(꺼내면 Object) 다. 이유는 둘 다 “실제로 무엇이 들어와 있는지 모른다”로 같다
  • PECS는 외우지 말고 “이 컬렉션에서 꺼내나, 이 컬렉션에 넣나” 로 판단한다. 둘 다 하면 와일드카드를 못 쓴다

5편에서 이 컬렉션들을 다루는 다른 방법인 스트림을 봅니다. 그리고 for문보다 느린 경우를 측정합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.