자바 제네릭 - 타입 소거 와일드카드 extends super 제네릭 배열이 안 되는 이유 (자바 중급 13단원)
이 단원에서 배우는 것
12단원에서 List<String>, Map<String, Integer> 를 계속 썼다. 꺾쇠 안에 타입을 적으면 꺼낼 때 캐스팅이 필요 없다는 것 정도는 이제 몸으로 안다. 이번 단원은 그 꺾쇠를 내가 만드는 클래스와 메서드에 직접 쓰는 단원이다.
제네릭은 자바에서 문법이 가장 어색한 영역이다. 이유가 있다. Java 5에서 기존 코드와 호환되게 추가하느라 타협을 많이 했고, 그 타협의 결과가 타입 소거다. 타입 소거를 이해하지 못하면 와일드카드도, 제네릭 배열 금지도 전부 외워야 하는 규칙이 된다. 도서관 시스템의 저장소(Repository)를 만들면서 본다.
- 제네릭 클래스와 제네릭 메서드를 직접 선언한다
- 타입 소거가 무엇이고, 그로 인해 무엇이 금지되는지 설명한다
? extends와? super를 언제 쓸지 PECS 원칙으로 판단한다
왜 필요한가
Java 5 이전에는 컬렉션에 타입 정보가 없었다.
// Java 5 이전 스타일
List items = new ArrayList();
items.add(new Book("B-001", "자바의 정석"));
items.add("실수로 들어간 문자열"); // 컴파일 통과
Book b = (Book) items.get(1); // 런타임에 ClassCastException
넣을 때는 아무 오류가 없고 꺼내는 시점에 터진다. 넣은 코드와 꺼내는 코드가 다른 파일, 다른 팀일 수 있으니 원인 추적이 어렵다. 제네릭은 이 오류를 컴파일 시점으로 앞당기는 장치다.
그리고 하나 더. 도서관 시스템에 ItemRepository, LoanRepository, MemberRepository 가 필요하다고 하자. 셋 다 저장하고, 전부 가져오고, 조건으로 찾는 코드가 타입만 다르고 완전히 같다. 제네릭이 없으면 세 번 복사해야 하고, 버그를 고칠 때도 세 군데를 고쳐야 한다.
문법과 예제
제네릭 클래스
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
public class Repository<T> {
private final List<T> items = new ArrayList<>();
public void save(T item) { items.add(item); }
public Optional<T> findFirst(Predicate<T> condition) {
for (T item : items) {
if (condition.test(item)) return Optional.of(item);
}
return Optional.empty();
}
public List<T> findAll() { return List.copyOf(items); }
public int size() { return items.size(); }
}
T 는 클래스를 선언할 때는 정해지지 않고 new Repository<Book>() 하는 시점에 정해진다. 관례적으로 T(Type), E(Element), K/V(Key/Value), R(Result)을 쓴다.
public class RepositoryDemo {
public static void main(String[] args) {
Repository<LibraryItem> repo = new Repository<>(); // 다이아몬드 연산자
repo.save(new Book("B-001", "자바의 정석", "남궁성"));
repo.save(new Dvd("D-014", "매트릭스", 2));
System.out.println(repo.findFirst(i -> i.dailyFee() >= 500).orElse(null)); // 매트릭스
System.out.println(repo.findAll() + " size=" + repo.size());
// repo.save("문자열"); // 컴파일 에러 — 이게 제네릭의 목적이다
}
}
findAll() 이 List.copyOf(items) 를 반환하는 것에 주목한다. 내부 리스트를 그대로 돌려주면 호출자가 저장소 내용을 마음대로 바꿀 수 있다. 초급 6단원에서 "게터가 내부 배열을 그대로 돌려준다"는 문제를 봤는데, 컬렉션에서도 같은 원칙이 적용된다.
제네릭 메서드와 제한(bounded) 타입
클래스 전체가 제네릭일 필요는 없다. 메서드 하나만 제네릭으로 만들 수 있고, 이때 타입 파라미터는 반환 타입 앞에 선언한다.
import java.util.Collection;
public class Fees {
/** T 는 LibraryItem 이거나 그 하위 타입이어야 한다 */
public static <T extends LibraryItem> long totalDailyFee(Collection<T> items) {
long sum = 0;
for (T item : items) sum += item.dailyFee(); // 제한이 있으니 dailyFee() 를 쓸 수 있다
return sum;
}
/** 두 타입 파라미터를 쓴다 */
public static <K, V> String describe(K key, V value) {
return key + "=" + value;
}
}
<T extends LibraryItem> 이 없으면 item.dailyFee() 를 호출할 수 없다. 컴파일러 입장에서 T 는 Object 일 수도 있기 때문이다. 제한을 걸어야 그 타입의 메서드를 쓸 수 있다.
타입 소거 — 제네릭의 정체
여기가 핵심이다. 제네릭 타입 정보는 컴파일이 끝나면 사라진다. 컴파일러가 타입을 검사하고 필요한 캐스팅을 자동으로 넣은 뒤, 바이트코드에서는 T 를 Object(제한이 있으면 그 제한 타입)로 바꿔버린다.
System.out.println(new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass());
true
List<String> 과 List<Integer> 는 런타임에 같은 클래스다. 이 한 줄에서 제네릭의 제약 대부분이 파생된다.
| 금지되는 것 | 이유 |
|---|---|
new T() | 런타임에 T 가 무엇인지 모른다 |
new T[10] | 같음 |
x instanceof List<String> | 런타임에 <String> 정보가 없다. instanceof List<?> 만 가능 |
static T field; | 인스턴스마다 T 가 다른데 static 은 하나뿐이다 |
같은 클래스에 f(List<Book>) 와 f(List<Dvd>) | 소거 후 둘 다 f(List) 가 되어 시그니처가 충돌한다 |
catch (MyException<T> e) | 예외 타입은 런타임에 정확히 구분돼야 한다 |
제네릭 배열이 안 되는 이유
new T[10] 이 왜 막혔는지는 배열과 제네릭의 성질이 정반대라는 데서 온다.
// 배열은 공변(covariant) — Book[] 은 LibraryItem[] 로 쓸 수 있다
LibraryItem[] arr = new Book[2];
arr[0] = new Dvd("D-014", "매트릭스"); // 컴파일 통과, 런타임에 ArrayStoreException
// 제네릭은 불공변(invariant) — List<Book> 은 List<LibraryItem> 이 아니다
List<LibraryItem> list = new ArrayList<Book>(); // 컴파일 에러
배열은 런타임에 자기 원소 타입을 기억하고 있어서 잘못 넣으면 ArrayStoreException 으로 막는다. 제네릭은 소거되어 런타임에 기억하는 게 없다. 그래서 제네릭 배열을 허용하면 아무도 막지 못하는 상황이 생긴다.
// 만약 이게 허용된다면 (실제로는 컴파일 에러)
List<Book>[] arr = new List<Book>[1];
Object[] objs = arr; // 배열은 공변이니 가능
objs[0] = List.of(new Dvd("D-014", "매트릭스")); // 런타임엔 그냥 List 라 막을 방법이 없다
Book b = arr[0].get(0); // ClassCastException — 캐스팅한 적도 없는데
제네릭 배열이 필요하면 List<T> 를 쓴다. 굳이 배열이어야 하면 Object[] 로 만들고 캐스팅한 뒤 @SuppressWarnings("unchecked") 를 가장 좁은 범위에 붙인다.
import java.util.Arrays;
import java.util.EmptyStackException;
public class SimpleStack<E> {
private Object[] elements = new Object[16];
private int size;
public void push(E e) {
if (size == elements.length) elements = Arrays.copyOf(elements, size * 2);
elements[size++] = e;
}
@SuppressWarnings("unchecked") // elements 에는 push 로 E 만 들어간다
public E pop() {
if (size == 0) throw new EmptyStackException();
E result = (E) elements[--size];
elements[size] = null; // 참조를 지워 GC 가 회수하게 한다
return result;
}
}
raw 타입과 힙 오염
제네릭을 지운 List(raw 타입)를 섞어 쓰면 컴파일러의 검사가 무력화된다.
import java.util.*;
public class HeapPollution {
@SuppressWarnings({"unchecked", "rawtypes"})
public static void main(String[] args) {
List<String> titles = new ArrayList<>();
List raw = titles; // raw 타입으로 받으면 검사가 사라진다
raw.add(42); // 경고만 뜨고 통과
System.out.println("size=" + titles.size());
String s = titles.get(0); // 여기서 터진다
}
}
size=1
Exception in thread "main" java.lang.ClassCastException:
class java.lang.Integer cannot be cast to class java.lang.String
주목할 점은 예외가 난 줄에 캐스팅이 없다는 것이다. titles.get(0) 은 컴파일러가 넣어준 (String) 캐스팅을 포함하고 있고, 그게 터진 것이다. 잘못은 raw.add(42) 에 있는데 예외는 저 멀리서 난다. 이런 상태를 힙 오염이라고 부르고, raw 타입을 쓰지 않는 것이 유일한 예방책이다. 컴파일 경고(unchecked warning)를 무시하지 않는 습관이 여기서 값어치를 한다.
와일드카드 — extends 와 super
제네릭이 불공변이라 List<Dvd> 를 List<LibraryItem> 자리에 넘길 수 없다. 그래서 유연성을 되찾는 장치가 와일드카드다.
import java.util.*;
public class Wildcards {
/** 읽기만 한다 — Producer 이므로 extends */
public static long overdueSum(List<? extends LibraryItem> items, int days) {
long sum = 0;
for (LibraryItem item : items) sum += item.dailyFee() * days;
return sum;
// items.add(new Book(...)); ← 컴파일 에러. 실제 타입이 무엇인지 모르니 넣을 수 없다
}
/** 넣기만 한다 — Consumer 이므로 super */
public static void addSample(List<? super Dvd> sink) {
sink.add(new Dvd("D-999", "샘플 DVD", 1));
// LibraryItem i = sink.get(0); ← 컴파일 에러. 꺼내면 Object 로만 받을 수 있다
}
public static void main(String[] args) {
List<Dvd> dvds = new ArrayList<>(List.of(
new Dvd("D-014", "매트릭스", 2), new Dvd("D-015", "인셉션", 1)));
System.out.println("5일 연체 시 " + overdueSum(dvds, 5) + "원"); // List<LibraryItem> 이 아닌데도 넘어간다
List<LibraryItem> sink = new ArrayList<>();
addSample(sink); // List<LibraryItem> 도 List<Object> 도 받는다
System.out.println(sink);
}
}
5일 연체 시 5000원
[샘플 DVD]
PECS: Producer-Extends, Consumer-Super. 그 컬렉션에서 값을 꺼내 쓰기만 하면
? extends, 값을 집어넣기만 하면? super. 꺼내고 넣기를 둘 다 해야 하면 와일드카드를 쓰지 말고List<T>를 쓴다.
이 원칙이 JDK 안에서 실제로 어떻게 쓰이는지 보면 이해가 빨라진다.
// java.util.Collections
public static <T> void copy(List<? super T> dest, List<? extends T> src)
// 넣는 쪽 = super 꺼내는 쪽 = extends
매개변수에는 와일드카드를 적극적으로 쓰되, 반환 타입에는 쓰지 않는다. List<? extends LibraryItem> 을 반환하면 호출하는 쪽 코드가 전부 와일드카드에 감염된다.
실무에서 자주 틀리는 것
1. List<Object> 와 List<?> 를 같은 것으로 안다
List<Book> books = new ArrayList<>();
List<Object> a = books; // 컴파일 에러 — 불공변이므로 불가능
List<?> b = books; // OK — "무언가의 리스트"
b.add(new Book("B-001", "자바의 정석")); // 컴파일 에러 — 원소 타입을 모르니 아무것도 못 넣는다
b.add(null); // 이것만 가능
Object o = b.get(0); // 꺼내는 건 Object 로만
System.out.println(b.size()); // 타입과 무관한 연산은 자유
List<?> 는 "아무 타입이나 들어간다"가 아니라 "어떤 특정 타입인데 그게 뭔지 모른다"는 뜻이다. 그래서 넣는 것이 전부 막힌다. "타입 상관없이 크기만 세면 되는" 유틸리티 메서드에 쓴다.
2. static 메서드에서 클래스의 타입 파라미터를 쓰려 한다
public class Repository<T> {
// 컴파일 에러: non-static type variable T cannot be referenced from a static context
// public static Repository<T> empty() { return new Repository<>(); }
// 올바른 형태 — 메서드가 자기 타입 파라미터를 따로 선언한다
public static <E> Repository<E> empty() { return new Repository<>(); }
}
Repository<Book> repo = Repository.empty(); // 타입 추론
Repository<Book> repo2 = Repository.<Book>empty(); // 명시할 수도 있다
클래스의 T 는 인스턴스를 만들 때 정해지는데 static 멤버는 인스턴스와 무관하게 하나뿐이다. static 메서드는 자기 타입 파라미터를 static 뒤에 직접 선언해야 한다.
3. 가변인자와 제네릭을 같이 쓰면서 경고를 무시한다
static <T> List<T> listOf(T... values) { // 경고: Possible heap pollution
return new ArrayList<>(Arrays.asList(values));
}
T... 는 내부적으로 T[] 배열이고, 앞에서 봤듯이 제네릭 배열은 안전하지 않다. 이 메서드가 매개변수 배열에 값을 저장하지 않고 밖으로 노출하지도 않는다면 안전하다. 그 사실을 @SafeVarargs 로 선언한다. 이 애너테이션은 static, final, private 메서드에만 붙일 수 있다(재정의되면 안전 보장이 깨지므로).
@SafeVarargs
static <T> List<T> listOf(T... values) {
return new ArrayList<>(Arrays.asList(values));
}
4. 제네릭 타입을 런타임에 알아내려 한다
public <T> T parse(String json) {
// 불가능 — 런타임에 T 가 무엇인지 알 방법이 없다
}
// 실무 해법: Class 객체를 같이 받는다 (타입 토큰)
public <T> T parse(String json, Class<T> type) {
Object result = doParse(json);
return type.cast(result);
}
JSON 라이브러리 API가 readValue(json, Book.class) 처럼 클래스 객체를 요구하는 이유가 이것이다. 소거된 타입 정보를 인자로 다시 넣어주는 것이다.
스스로 확인하기
- 다음 세 메서드의 매개변수를
List<LibraryItem>,List<? extends LibraryItem>,List<? super LibraryItem>중 무엇으로 선언할지 고르고 이유를 쓴다. (가) 목록에 든 자료들의 하루 연체료 합계를 구한다. (나) 목록에 신착 자료를 추가한다. (다) 목록에서 연체료가 가장 비싼 자료를 꺼내 맨 앞으로 옮긴다. - 다음 코드가 컴파일되지 않는 이유를 설명하라.
public class Box<T> { public boolean isSameType(Object o) { return o instanceof T; } } - 아래 코드의 출력과, 예외가 난다면 어느 줄에서 나는지 답하라.
List<String> list = new ArrayList<>(); List raw = list; raw.add(1); System.out.println("A: " + list.size()); System.out.println("B: " + list.get(0).length());
정답
- (가)
List<? extends LibraryItem>. 꺼내 읽기만 하므로 Producer,List<Dvd>나List<Book>도 받을 수 있어 호출 범위가 넓어진다. (나)List<? super LibraryItem>. 넣기만 하므로 Consumer,List<Object>에도 넣을 수 있다. (다)List<LibraryItem>. 꺼내기와 넣기를 둘 다 하므로 와일드카드로는 불가능하다. PECS의 경계가 여기다. - 타입 소거 때문이다.
instanceof는 런타임에 타입을 확인하는데 그 시점에T는 이미Object로 지워져 있어 비교할 대상이 없다. 필요하면Class<T>를 생성자로 받아type.isInstance(o)로 검사한다. A: 1이 출력되고,B:줄에서ClassCastException이 난다.raw.add(1)은 raw 타입이라 검사 없이 통과하고,list.get(0)은 컴파일러가 넣어둔(String)캐스팅에서Integer를 만나 실패한다. 잘못한 줄과 터지는 줄이 다르다는 점이 raw 타입의 위험성이다.