Java (4) - Generic: 컴파일하면 사라진다
List
자바 기초 시리즈의 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: 배열은 되는데 리스트는 안 된다
String은 Object의 하위 타입입니다. 그럼 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> → 안 된다
Integer도 Double도 Number인데 리스트로 감싸면 안 넘어갑니다. 와일드카드는 이 자리를 푸는 도구입니다.
? 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는 넣기만 합니다. 그래서 각각 extends와 super가 붙었습니다. 표준 라이브러리의 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문보다 느린 경우를 측정합니다.