Java 제네릭 — 타입 소거, 와일드카드, PECS
List에 String을 넣었는데 꺼낼 때 Integer로 캐스팅하면? 컴파일러는 아무 말 없고, 런타임에서야ClassCastException이 터진다. 제네릭은 이 문제를 왜, 어떻게 해결하는 걸까?
제네릭이 왜 필요한가
제네릭 은 타입을 파라미터로 받아 컴파일 시점에 타입 안전성을 보장하는 기능이에요. 캐스팅 없이도 타입이 보장됩니다.
제네릭 없던 시절, 컬렉션은 전부 Object로 저장했어요.
List list = new ArrayList();
list.add("hello");
list.add(123); // 컴파일 에러 안 남
String s = (String) list.get(1); // 런타임에 ClassCastException
뭘 넣든 컴파일러가 막지 않으니, 꺼낼 때마다 캐스팅해야 하고 잘못된 타입은 런타임에서야 터집니다. 제네릭을 적용하면 컴파일 시점에 차단돼요.
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(123); // 컴파일 에러!
String s = list.get(0); // 캐스팅 불필요
제네릭 클래스 / 메서드 / 인터페이스
제네릭 클래스
public class Box<T> {
private T item;
public void set(T item) { this.item = item; }
public T get() { return item; }
}
Box<String> box = new Box<>();
box.set("apple");
String fruit = box.get();
T는 타입 파라미터입니다. 관례적으로 T(Type), E(Element), K(Key), V(Value), N(Number) 등을 써요. 그냥 관례일 뿐이고 아무 이름이나 써도 동작은 합니다.
제네릭 메서드
클래스가 아니라 메서드 단위로도 타입 파라미터를 선언할 수 있어요.
public class Util {
public static <T> T getFirst(List<T> list) {
return list.get(0);
}
}
String first = Util.getFirst(List.of("a", "b", "c")); // 타입 추론
반환 타입 앞에 <T>를 선언하는 게 포인트예요. 클래스 레벨 제네릭과는 별개의 타입 파라미터라서, 제네릭 클래스가 아니어도 쓸 수 있습니다.
제네릭 인터페이스
public interface Repository<T, ID> {
T findById(ID id);
void save(T entity);
}
public class UserRepository implements Repository<User, Long> {
@Override
public User findById(Long id) { /* ... */ }
@Override
public void save(User entity) { /* ... */ }
}
Spring Data JPA의 JpaRepository<T, ID>가 대표적인 사례입니다. 이런 식으로 구현 클래스에서 구체적인 타입을 지정하면, 타입 안전한 CRUD를 바로 쓸 수 있어요.
바운디드 타입 파라미터
타입 파라미터에 제약을 걸 수도 있어요.
public <T extends Number> double sum(List<T> list) {
return list.stream().mapToDouble(Number::doubleValue).sum();
}
T extends Number이니까 Number의 하위 타입만 허용됩니다. String을 넣으려 하면 컴파일 에러가 나요. 여러 제약을 걸고 싶으면 &로 이어 붙이면 돼요.
public <T extends Comparable<T> & Serializable> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
클래스는 하나만, 인터페이스는 여러 개 가능합니다. 클래스가 있으면 맨 앞에 와야 해요.
타입 소거 (Type Erasure)
타입 소거 란 컴파일러가 제네릭 타입 정보를 확인한 뒤 바이트코드에서 지워버리는 것입니다. 런타임에는 제네릭 정보가 없어요.
// 소스 코드
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0);
// 바이트코드 (개념적)
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // 컴파일러가 캐스팅 삽입
바운디드 타입이면 해당 바운드로, 아니면 Object로 대체돼요. Box<T extends Number>는 소거 후 Number item이 됩니다.
왜 이런 설계일까요? Java 5에서 제네릭을 추가할 때, Java 4 이하 코드와의 하위 호환성 을 유지해야 했기 때문입니다. List와 List<String>이 런타임에 동일한 클래스여야 기존 코드가 깨지지 않아요.
타입 소거로 인한 제약
타입 소거 때문에 런타임에서 제네릭 타입 정보가 없습니다. 이게 여러 제약을 만들어요.
new T() 불가
public class Factory<T> {
public T create() {
// return new T(); // 컴파일 에러! 타입 정보가 없어서 인스턴스 생성 불가
}
}
런타임에 T가 뭔지 모르니까 생성자를 호출할 수 없어요. 우회하려면 Class<T>를 넘겨야 합니다.
public class Factory<T> {
private final Class<T> type;
public Factory(Class<T> type) { this.type = type; }
public T create() throws Exception {
return type.getDeclaredConstructor().newInstance();
}
}
instanceof T 불가
public <T> boolean isTypeOf(Object obj) {
// return obj instanceof T; // 컴파일 에러
}
마찬가지로 런타임에 T 정보가 없어요. Class<T>를 받아서 type.isInstance(obj)로 우회해야 합니다.
제네릭 배열 생성 불가
// T[] arr = new T[10]; // 컴파일 에러
// 이것도 안 됨
// List<String>[] arr = new List<String>[10]; // 컴파일 에러
배열은 런타임에 타입 정보를 갖고 있어서(ArrayStoreException 던지려고), 타입이 소거되는 제네릭과 충돌합니다. 배열 대신 List<List<String>> 같은 컬렉션을 쓰는 게 맞아요.
제네릭 타입으로 오버로딩 불가
// 컴파일 에러 — 소거 후 둘 다 process(List)가 됨
public void process(List<String> list) { }
public void process(List<Integer> list) { }
소거되면 시그니처가 동일해지니까 오버로딩이 안 됩니다.
와일드카드
제네릭 타입을 좀 더 유연하게 쓰고 싶을 때 와일드카드 ?를 씁니다.
Unbounded 와일드카드: ?
public void printAll(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
}
어떤 타입이든 받을 수 있어요. 단, list.add()는 null 말고는 못 넣습니다. 타입을 모르니까 안전하게 막아둔 거예요.
상한 와일드카드: ? extends T
public double sum(List<? extends Number> list) {
double total = 0;
for (Number n : list) {
total += n.doubleValue();
}
return total;
}
sum(List.of(1, 2, 3)); // List<Integer> OK
sum(List.of(1.1, 2.2, 3.3)); // List<Double> OK
Number의 하위 타입만 허용해요. 읽기는 Number로 가능하지만, 쓰기는 안 됩니다. 컴파일러 입장에서 List<Integer>인지 List<Double>인지 모르니까, 아무것도 넣을 수 없게 막는 거예요.
하한 와일드카드: ? super T
public void addNumbers(List<? super Integer> list) {
list.add(1);
list.add(2);
list.add(3);
}
List<Number> numbers = new ArrayList<>();
addNumbers(numbers); // OK — Number는 Integer의 상위 타입
List<Object> objects = new ArrayList<>();
addNumbers(objects); // OK — Object도 Integer의 상위 타입
Integer의 상위 타입만 허용합니다. 쓰기는 Integer로 가능하지만, 읽을 때는 Object로밖에 못 받아요.
PECS — Producer Extends, Consumer Super
Joshua Bloch가 Effective Java에서 제시한 원칙입니다. 외우기 쉬운데 의미가 확실해요.
- 데이터를 꺼내서 쓰는 쪽(Producer) →
? extends T - ** 데이터를 집어넣는 쪽(Consumer)** →
? super T
Collections.copy가 교과서적인 예시입니다.
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (int i = 0; i < src.size(); i++) {
dest.set(i, src.get(i));
}
}
src에서 데이터를 ** 꺼내니까 **(Producer) extends, dest에 데이터를 ** 넣으니까 **(Consumer) super입니다.
실제 사용 예를 볼게요.
List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = new ArrayList<>(List.of(0.0, 0.0, 0.0));
Collections.copy(nums, ints); // Integer → Number로 복사
List<Integer>는 List<? extends Number>에 해당하고(Producer), List<Number>는 List<? super Integer>에 해당합니다(Consumer). PECS가 딱 맞아떨어져요.
반환 타입에는 와일드카드를 쓰지 않습니다. List<?>를 리턴하면 호출 쪽에서 아무것도 할 수 없어요. 와일드카드는 ** 파라미터에서만** 쓰는 게 원칙입니다.
제네릭과 상속: 불공변(Invariant)
String이 Object의 하위 타입이지만, List<String>은 List<Object>의 하위 타입이 ** 아닙니다.** 제네릭은 불공변(invariant)이에요.
이유는 단순합니다. 만약 허용하면 List<Object>로 취급된 List<String>에 Integer를 넣을 수 있게 되어 타입 안전성이 깨져요. 공변이 필요하면 와일드카드(? extends)를 쓰면 됩니다.
배열은 공변이다.
String[]은Object[]의 하위 타입이므로 잘못된 타입을 넣으면 런타임에ArrayStoreException이 발생한다. 제네릭은 같은 실수를 컴파일 타임에 잡는다.
리플렉션과 제네릭
타입 소거 때문에 런타임에 제네릭 정보가 없다고 했는데, 완전히 없는 건 아닙니다. ** 클래스 선언부에 적힌 제네릭 정보 **는 바이트코드에 메타데이터로 남아 있어요.
ParameterizedType
public class StringList extends ArrayList<String> { }
Type superclass = StringList.class.getGenericSuperclass();
if (superclass instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) superclass;
Type[] typeArgs = pt.getActualTypeArguments();
System.out.println(typeArgs[0]); // class java.lang.String
}
StringList이 ArrayList<String>을 상속하면서 타입 인자를 고정했기 때문에, 이 정보가 클래스 메타데이터에 기록됩니다. 리플렉션으로 꺼낼 수 있어요.
TypeToken 패턴 (Super Type Token)
이걸 응용한 게 TypeToken 패턴이에요. Gson이 대표적으로 씁니다.
Type type = new TypeToken<List<String>>() {}.getType();
List<String> list = gson.fromJson(json, type);
new TypeToken<List<String>>() {}이 하는 일은, 익명 클래스를 만들어서 TypeToken<List<String>>을 상속하는 거예요. 상속 관계에 적힌 타입 인자 정보는 소거되지 않으니까, 런타임에도 List<String>이라는 정보를 가져올 수 있습니다.
Spring의 ParameterizedTypeReference도 같은 원리예요.
ResponseEntity<List<User>> response = restTemplate.exchange(
"/api/users",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<User>>() {}
);
실무 활용
제네릭 리포지토리 패턴
생성자에서 Super Type Token 원리로 Class<T>를 추출합니다. JPA EntityManager에 Class 객체가 필요한데, 매번 파라미터로 받지 않아도 돼요.
public abstract class BaseRepository<T, ID> {
private final Class<T> entityType;
@SuppressWarnings("unchecked")
protected BaseRepository() {
ParameterizedType pt = (ParameterizedType)
getClass().getGenericSuperclass();
this.entityType = (Class<T>) pt.getActualTypeArguments()[0];
}
}
// UserRepository extends BaseRepository<User, Long>
// → entityType은 자동으로 User.class
타입 안전한 빌더
리플렉션과 제네릭을 조합하면 범용 빌더를 만들 수 있어요. build(Class<T>)에 Class 객체를 넘겨 인스턴스를 생성하고, 리플렉션으로 필드를 설정합니다. 실무에서는 Lombok @Builder를 쓰지만, 제네릭과 리플렉션의 조합을 이해하기 좋은 예시예요.
제네릭 이벤트 핸들러
이벤트 타입별로 핸들러를 분리할 때 제네릭이 진가를 발휘해요. Class<T>를 키로 쓰면서 타입 안전성을 확보합니다.
public class EventBus {
private final Map<Class<?>, List<EventHandler<?>>> handlers
= new HashMap<>();
public <T extends Event> void register(
Class<T> eventType, EventHandler<T> handler) {
handlers.computeIfAbsent(eventType, k -> new ArrayList<>())
.add(handler);
}
}
주의할 점
브릿지 메서드
타입 소거 후 시그니처가 달라진 오버라이딩 메서드를 연결하기 위해 컴파일러가 자동 생성하는 메서드입니다.
public class StringBox implements Box<String> {
@Override
public String get() { return "hello"; }
}
소거 후 Box 인터페이스의 get()은 Object get()이 됩니다. 그런데 StringBox는 String get()을 구현했어요. 시그니처가 다르니까 오버라이딩이 아닌 셈이에요.
이걸 맞추기 위해 컴파일러가 브릿지 메서드를 끼워 넣습니다.
// 컴파일러가 생성하는 브릿지 메서드
public Object get() {
return get(); // String get()을 호출
}
javap -c StringBox로 바이트코드를 뜯어보면 실제로 확인할 수 있어요. isBridge() 메서드로 리플렉션에서 구별도 가능합니다.
제네릭과 Enum
Enum은 제네릭을 쓸 수 없어요. enum Status<T> { ... } 이런 건 불가능합니다. 왜냐하면 Enum 상수는 각각이 Enum 클래스의 인스턴스인데, 상수마다 다른 타입 파라미터를 가질 수 없기 때문이에요.
하지만 Enum이 제네릭 인터페이스를 구현하는 건 가능합니다.
public enum JsonParser implements Parser<JsonNode> {
INSTANCE;
@Override
public JsonNode parse(String input) { /* ... */ }
}
raw 타입 사용 금지
List(raw)를 쓰면 제네릭의 이점이 전부 사라집니다. Comparable<Student>를 쓰면 compareTo 파라미터가 Student로 고정되어 캐스팅이 필요 없어요. 같은 맥락에서 Class<T>도 raw Class보다 Class<String> 형태로 쓰는 것이 안전합니다.
재귀적 타입 바운드
T extends Comparable<T>는 "자기 자신과 비교 가능한 타입"이라는 뜻이에요. 컬렉션에서 최대값을 구하는 제네릭 메서드를 만들 때 이 패턴이 필요합니다.
public static <T extends Comparable<T>> T max(Collection<T> c) {
Iterator<T> it = c.iterator();
T result = it.next();
while (it.hasNext()) {
T t = it.next();
if (t.compareTo(result) > 0) result = t;
}
return result;
}
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
정리
| 항목 | 핵심 |
|---|---|
| 제네릭의 목적 | 컴파일 시점 타입 안전성 + 캐스팅 제거 |
| 타입 소거 | 런타임에 제네릭 정보 없음. 하위 호환성 때문 |
| 소거로 인한 제약 | new T(), instanceof T, 제네릭 배열 생성, 오버로딩 모두 불가 |
? extends T | 데이터를 꺼내는 쪽(Producer). 읽기만 가능 |
? super T | 데이터를 넣는 쪽(Consumer). 쓰기 가능 |
| PECS | Producer Extends, Consumer Super |
| 불공변 | List<String>은 List<Object>의 하위 타입이 아님 |
| Super Type Token | 상속 관계에 적힌 타입 인자는 소거되지 않아 리플렉션으로 추출 가능 |
다음 글에서는 ** 람다와 스트림 **을 다룹니다. 컬렉션을 더 간결하고 선언적으로 처리하는 방법이 궁금하다면 이어서 봐주세요.