[Java] 제네릭/와일드카드/타입 소거 정리
제네릭과 타입 파라미터
제네릭은 클래스/인터페이스/메서드가 다룰 타입을 타입 파라미터로 받아, 컴파일 시점에 타입을 강하게 체크한다.
타입 파라미터는 관례적으로 T, E, K, V 같은 단일 대문자를 많이 쓴다.
T: TypeE: ElementK: KeyV: Value
사용하는 이유
- 컴파일 타임 타입 안정성: 잘못된 타입 사용을 컴파일 단계에서 막는다.
- 캐스팅 제거: 컬렉션에서 꺼낼 때 불필요한 캐스팅과 런타임 예외 위험을 줄인다.
raw type을 쓰면 컴파일러가 타입을 제대로 검사하지 못해, 나중에 꺼내 쓸 때 캐스팅이 필요하고 런타임 오류 가능성이 커진다.
제네릭 클래스/인터페이스
선언부에 하나 이상의 타입 파라미터를 두면 제네릭 클래스/인터페이스라 부른다.
타입 파라미터 도입으로 타입 변수(Type Variable)를 필드/생성자/메서드 같은 멤버 수준에서 사용할 수 있다.
class Box<T> {
private T item;
public void set(T v) { this.item = v; }
public T get() { return item; }
}
다만 정적 컨텍스트에서는 클래스 타입 파라미터를 사용할 수 없다.
class Box<T> {
static T x; // 컴파일 에러
static <T> T ok(T v) { return v; } // OK. 제네릭 메서드의 T는 클래스의 T와 별개
}
클래스의 T 는 인스턴스마다 결정되는데, 정적 멤버는 클래스 레벨에서 공유되므로 어떤 T 를 써야 하는지 결정할 수 없다.
제네릭 인터페이스 구현 패턴
크게 두 가지 방법이 있다.
- 타입 고정: 인터페이스의 타입 파라미터를 구현 시점에 구체 타입으로 확정
- 제네릭 전파: 구현 클래스도 타입 파라미터를 선언해 그대로 전달
interface Repository<T> {
void save(T entity);
T findById(String id);
}
// 타입 고정
class UserRepository implements Repository<User> {
public void save(User u) { ... }
public User findById(String id) { ... }
}
// 제네릭 전파
class TestRepository<T> implements Repository<T> {
public void save(T e) { ... }
public T findById(String id) { ... }
}
- 타입 고정은
UserRepository가 User 전용으로 동작하게 만든다. - 제네릭 전파는
TestRepository<User>,TestRepository<Order>처럼 재사용 가능한 범용적인 구현체가 된다.
멀티 타입 파라미터와 다이아몬드 연산자
타입을 한 개뿐만 아니라 여러 개를 선언할 수도 있다.
class Pair<K, V> {
K key;
V value;
}
다이아몬드 연산자 <> : 타입 파라미터를 유추해서 자동으로 설정해준다. (자바 7부터 도입)
// 다이아몬드 없을 때
List<String> a = new ArrayList<String>();
// 다이아몬드 사용
List<String> b = new ArrayList<>();
제네릭 메서드
자체적으로 매개 타입과 리턴 타입으로 타입 파라미터를 갖는 메서드를 말한다.
메서드 선언부(리턴 타입 앞)에 <> 기호를 붙이고 이 기호 안에 타입 파라미터를 선언하면 된다.
public <타입파라미터, ...> 리턴타입 메서드명(매개변수, ...) { ... }
이런 메서드를 호출하는 방법
- 타입 파라미터 명시
- 인자를 보고 타입 추론
// 명시적 타입 지정
리턴타입 변수 = 클래스.<구체적인 타입>메서드명(인자); // static 메서드
리턴타입 변수 = 객체참조.<구체적인 타입>메서드명(인자); // instance 메서드
// 타입 추론
리턴타입 변수 = 메서드명(인자);
제한된 타입 파라미터(Bounded Type Parameters)
타입 파라미터에 제약을 걸어 특정 타입의 하위 타입만 허용할 수 있다
<T extends 상위타입>
여기서 T 는 상위 타입이거나 상위 타입의 하위 타입 또는 구현 클래스만 올 수 있다.
클래스, 인터페이스, 메서드 모든 곳에 적용 가능하다.
왜 필요하지? ⇒ 제약이 없으면 T 의 상위 타입은 Object 로, 사용 가능한 것은 Object 의 멤버로 제한된다. 바운드를 주면 해당 타입의 멤버 사용이 가능해진다.
class Box<T extends Number> {
T value;
double toDouble() {
return value.doubleValue(); // Number 메서드 사용 가능
}
}
만약에 위 예시 처럼 Number 로 제한을 걸면, 메서드에서 해당 타입과 관련된 멤버를 사용할 수 있다.
여러 개의 제약도 걸 수 있다.
// 클래스가 먼저 와야 하고, 인터페이스는 뒤에 (클래스는 최대 1개)
public class MultiBox<T extends Number & Comparable<T> & Serializable> {
// T는 Number를 상속하고, Comparable과 Serializable을 구현해야 함
}
와일드카드
와일드카드인 ? 는 알 수 없는 타입을 나타내며, 제네릭 타입을 파라미터로 받는 메서드에서 유연성을 제공한다.
변성(Variance)
A <: B: A는 B의 하위 타입(subtype)이다.Integer <: NumberArrayList<String> <: List<String>
- 공변(covariant):
A <: B일 때,G<A> <: G<B>처럼 취급- 배열은 대표적인 공변이다. ⇒
A[] <: B[]
- 배열은 대표적인 공변이다. ⇒
- 반공변(contravariant):
A <: B일 때,G<B> <: G<A>처럼 취급 - 불공변(invariant): 서로 관계 없음
- 제네릭은 기본적으로 불공변
세 가지 종류가 있다.
비제한 와일드카드(Unbounded Wildcard)
사용: ?
어떤 타입이든 허용한다. 원소 타입을 알 수 없어서 원소는 Object 로만 안전하게 다룰 수 있다
public void printList(List<?> list) {
System.out.println(list.size());
for (Object obj : list) {
System.out.println(obj);
}
}
원소 타입에 의존하지 않고 컬렉션을 다루고 싶을 때(예: 순회/출력/크기 확인 등) 사용할 수 있다.
상한 와일드카드(Upper Bounded Wildcard)
사용: ? extends T
원소 타입은 T 또는 T 의 하위 타입을 허용한다. ⇒ 공변성 제공
읽기 중심일 때 유리하다.
public double sumOfList(List<? extends Number> list) {
double sum = 0.0;
for (Number n : list) {
sum += n.doubleValue();
}
return sum;
}
sumOfList(Arrays.asList(1, 2, 3)); // List<Integer>
sumOfList(Arrays.asList(1.5, 2.5, 3.5)); // List<Double>
sumOfList(Arrays.asList(new BigDecimal(1))); // List<BigDecimal>
List<?>,List<? extends T>: 읽기 OK,add는null만 가능
하한 와일드카드(Lower Bounded Wildcard)
사용: ? super T
원소 타입이 T 또는 T 의 상위 타입을 허용한다. ⇒ 반공변성 제공
읽는 건 제한되지만, 쓰기가 가능하다.
public void addIntegers(List<? super Integer> list) {
list.add(1);
list.add(2);
list.add(3);
}
addIntegers(new ArrayList<Integer>());
addIntegers(new ArrayList<Number>());
addIntegers(new ArrayList<Object>());
// --------------
List<? super Integer> list = new ArrayList<Number>();
list.add(1); // OK
list.add(2); // OK
Integer i = list.get(0); // 컴파일 에러
Object o = list.get(0); // OK - Object로만 읽기 가능
List<? super T>: 쓰기 OK, 읽기는 Object로만 안전
PECS 원칙
⇒ Producer Extends, Consumer Super
Producer와 Consumer를 컬렉션 입장에서 생각하면 된다.
- Producer ⇒ 데이터를 생산해서 제공한다. (우리는 읽기
get).? extends T사용 - Consumer ⇒ 데이터를 받아서 소비한다. (우리가 쓰기
add).? super T사용
// Producer: 컬렉션이 데이터를 생산해서 우리에게 준다
List<? extends Number> producer = ...;
Number n = producer.get(0); // 컬렉션 -> 우리 (읽기)
// Consumer: 컬렉션이 우리가 생산한 데이터를 소비한다
List<? super Integer> consumer = ...;
consumer.add(526); // 우리 -> 컬렉션 (쓰기)
? extends Number: Number의 하위 타입 중 뭔지 모름- 꺼내면 최소한
Number는 보장됨 → 읽기 안전 - 넣으려면?
List<Integer>일 수도,List<Double>일 수도 있어서 뭘 넣어야 할지 모름 → 쓰기 불가
- 꺼내면 최소한
? super Integer: Integer의 상위 타입 중 뭔지 모름Integer넣으면Number든Object든 다 받을 수 있음 → 쓰기 안전- 꺼내면?
Object로만 보장됨 → 읽기 제한
타입 소거(Type Erasure)
먼저 용어를 정리하고 가자
- 매개변수화된 타입(Parameterized Type): 제네릭 클래스나 인터페이스에 구체적인 타입 인자(Type Arguments)를 제공해 만든 타입. (예:
List<String>,Map<Integer, String>)
타입 소거는 매개변수화된 타입과 타입 변수를 포함한 타입을, 파라미터화되지 않은 타입으로 변환하는 매핑이다.
즉, 제네릭은 컴파일 타임에만 존재하고, 런타임에는 타입 정보가 소거된다.
⇒ Java 5 이전 코드와의 하위 호환성 목적
// 원본 코드
public class Box<T> {
private T item;
public T get() {
return item;
}
public void set(T item) {
this.item = item;
}
}
// 타입 소거 후
public class Box {
private Object item;
public Object get() {
return item;
}
public void set(Object item) {
this.item = item;
}
}
제한된 타입 파라미터의 경우, 소거 될 때 Object 대신 첫 번째 바운드로 대체된다.
// 컴파일 전
public class Box<T extends Number> {
private T item;
}
// 타입 소거 후 -> 바운드 타입으로 대체
public class Box {
private Number item;
}
여러 개의 제한된 타입이 있을 경우 역시 첫 번째 바운드로 대체된다.
// 컴파일 전
public class MultiBox<T extends Number & Comparable<T>> {
private T item;
}
// 타입 소거 후 -> 첫 번째 바운드(Number)로 대체
public class MultiBox {
private Number item;
}
브릿지 메서드
타입 소거로 인해 메서드 시그니처가 변경되면 다형성이 깨질 수 있기 때문에, 컴파일러가 synthetic bridge 메서드라는 것을 생성한다.
public class Box<T> {
private T item;
public void set(T item) {
this.item = item;
}
public T get() {
return item;
}
}
public class StringBox extends Box<String> {
@Override
public void set(String item) { // 오버라이드 의도
super.set(item);
}
@Override
public String get() { // 공변 반환 타입
return super.get();
}
}
이런 코드가 있을 때, 타입이 소거가 됐을 때 메서드를 보면
// Box의 소거된 메서드
public void set(Object item)
public Object get()
// StringBox의 메서드
public void set(String item)
public String get()
시그니처가 달라져서 오버라이드가 아닌 오버로드가 된다.
그래서 컴파일러는 아래와 같이 메서드를 만들어준다.
// 컴파일러가 StringBox에 자동 생성
public class StringBox extends Box {
// 실제 구현 메서드
public void set(String item) {
super.set(item);
}
public String get() {
return super.get();
}
// 브릿지 메서드 (synthetic)
public void set(Object item) {
set((String) item); // 실제 메서드로 위임
}
// 브릿지 메서드 (synthetic)
// 소스 레벨에서는 같은 get()처럼 보이지만,
// 바이트코드에서는 디스크립터가 다름:
// - 브릿지: get()Ljava/lang/Object;
// - 실제: get()Ljava/lang/String;
// JVM은 이름 + 디스크립터로 메서드를 구분하므로 무한 재귀가 아님
public Object get() {
return get();
}
}
(참고: 위 코드는 바이트코드에 생성되는 브릿지 메서드를 의사코드로 표현한 것이며, 자바 소스 코드로 그대로 작성할 수 없다.)
힙 오염(Heap Pollution)
매개변수화된 타입 변수가, 그 매개변수화된 타입이 아닌 객체를 참조하게 되는 현상을 의미한다.
프로그램이 컴파일 타임에 비검사(unchecked) 경고를 발생시키는 어떤 연산을 수행했을 때 나타날 수 있다.
힙 오염이 생긴 근본적인 원인은 타입 소거다.
why? 타입 소거 → 런타임에 제네릭 타입 정보 없음 → 잘못된 타입 삽입 감지 불가 → 힙 오염
몇 가지 예시를 보면서 이해해보자.
Raw 타입을 사용하는 경우
List<String> strings = new ArrayList<>();
List raw = strings; // unchecked
raw.add(526); // OK -> 힙 오염 발생
String s = strings.get(0); // ClassCastException 발생
List<String>에 Integer(526)가 들어가 힙 오염이 발생한다.
Unchecked cast로 인한 힙 오염
List<Integer> ints = new ArrayList<Integer>();
ints.add(1);
Object obj = ints;
List<String> strings = (List<String>) obj; // unchecked cast 경고
strings.add("hello"); // OK -> 힙 오염 발생
for (Integer i : ints) {
System.out.println(i);
}
// 결과
1
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')
가변인자(Varargs) 사용으로 인한 힙 오염
static void pollution(List<String>... lists) {
Object[] arr = lists; // 런타임에는 List[]로 보임
arr[0] = List.of(526); // 힙 오염
String s = lists[0].get(0); // ClassCastException (Integer -> String)
}
List<String>... 는 컴파일 시 타입 인자가 소거되어 파라미터가 List[] 로 취급된다.
만약 가변인자에 대한 힙 오염이 없음을 개발자가 보장한다면, @SafeVarargs 어노테이션을 사용할 수 있다.
@SafeVarargs
static <T> List<T> safe(T... elements) { ... }
Reifiable Types
- reify 뜻 : <추상 개념 등을> 구체[구상]화하다, 구체화하여 생각하다 (네이버 사전)
타입 소거 과정에서 정보 손실이 없어, 런타임에 타입 정보가 완전히 보존되는 타입을 말한다.
종류
- 원시 타입(primitive type)
- int, long, float, double, boolean, char, byte, short
- 제네릭이 아닌 일반 클래스/인터페이스
- String, Integer, Object, Runnable, Exception 등
- Raw type → 타입 인자 없이 사용된 제네릭 타입
- List, Map, ArrayList 등
- 모든 타입 파라미터가 Unbounded Wildcard인 파라미터화된 타입
- List<?>, Map 등
- 중첩된 경우, 외부 타입과 내부 타입이 모두 reifiable이어야 한다.
- Outer.Inner, Outer<?>.Inner<?>
Non-Reifiable Types
타입 소거로 인해 런타임에 타입 정보가 부분적으로 손실되는 타입을 말한다.
종류
- 타입 변수
- Bounded Wildcard를 포함한 파라미터화된 타입
- 구체적인 타입 인자를 가진 파라미터화된 타입
Non-Reifiable 타입으로 캐스팅하면 unchecked cast 경고가 발생한다.
⇒ 런타임 시 잠재적으로 ClassCastException 이 발생 가능하기 때문
예를 들어, 타입 소거가 발생하면 List<String> 과 List<Integer> 는 구분이 안된다. 둘 다 List 로 보인다.
Non-Reifiable 타입의 배열은 생성할 수 없다.
List<String>[] stringLists = new List<String>[10]; // 불가
⇒ 배열의 공변성과 타입 안정성이 충돌하기 때문.
배열은 자신이 실제로 어떤 타입인지 런타임에 알고 있어서 잘못된 타입 저장을 할 경우, ArrayStoreException 을 발생시킨다.
그런데 Non-Reifiable 타입은 소거되어 런타임에 구분할 수 없다.
// 만약 허용된다고 가정하면
List<String>[] stringLists = new List<String>[10];
// 배열은 공변이므로 Object[]로 참조 가능
Object[] objects = stringLists;
// 런타임에는 List 로만 보임 (타입 소거)
// ArrayStoreException 없이 잘못된 타입을 저장할 수 있다.
objects[0] = new ArrayList<Integer>();
참고로 List<?>[]처럼 reifiable인 경우는 배열 생성이 가능하다.
제네릭 제약사항
타입 소거로 인해 발생하는 제약들이 있다.
타입 파라미터로 인스턴스 생성 불가
public <T> void create() {
T obj = new T(); // 컴파일 에러
T[] arr = new T[10]; // 컴파일 에러
}
런타임에 T 가 Object 로 소거되면서 실제 어떤 클래스의 생성자를 호출해야 하는지 알 수 없다. new T() 는 소거 후 new Object()가 되어버리는데, 이는 의도한 타입과 다르게 된다.
배열도 마찬가지로 런타임에 배열의 실제 컴포넌트 타입을 알아야 생성할 수 있는데, T 가 소거되면 이 정보를 알 수 없다.
instanceof 에 매개변수화된 타입 사용 불가
if (obj instanceof List<String>) { } // 컴파일 에러
if (obj instanceof List<?>) { } // OK (Unbounded는 가능)
instanceof 는 런타임에 타입을 검사하는데, 타입 소거로 인해 List<String> 과 List<Integer> 는 런타임에 둘 다 List 로만 보인다. 구분이 불가능하므로 컴파일러가 허용하지 않는다.
List<?> 는 reifiable 타입이라 런타임에도 List 인지 확인만 하면 돼서 허용된다.
제네릭 예외 클래스 불가
class MyException<T> extends Exception { } // 컴파일 에러
JVM에서 예외를 처리할 때, catch 블록에서의 예외 타입을 런타임에 매칭한다. 타입 소거로 인해 MyException<String> 과 MyException<Integer> 가 구분되지 않으므로, 어떤 catch 블록을 실행해야 할지 결정할 수 없다.
정적 컨텍스트에서 클래스 타입 파라미터 사용 불가
class Box<T> {
static T item; // 컴파일 에러
static void print(T t) { } // 컴파일 에러
}
클래스의 타입 파라미터 T 는 인스턴스 생성 시점에 결정된다. 정적 멤버는 인스턴스 없이 클래스 레벨에서 공유되므로, 어떤 T 를 사용해야 하는지 알 수 없다.
만약 위 코드가 만약 허용된다고 해보자
Box<String> stringBox = new Box<>();
Box<Integer> intBox = new Box<>();
이런 코드가 있을 때 Box.item 은 String 인가? Integer 인가?
'CS > Java' 카테고리의 다른 글
| [Java] try/catch/finally: 바이트코드 분석 (1) | 2026.01.21 |
|---|---|
| [Java] 중첩 클래스(Nested class) 정리 - 바이트코드 분석을 곁들인 (0) | 2026.01.17 |
| [Java] 생성자 초기화 순서 정리: 5단계에서 7단계로 (feat. JEP 513) (0) | 2026.01.13 |