🧩 공변(covariant)과 무공변(invariant)이란?
먼저 개념부터 짚고 가자.
공변이란 String이 Object의 하위 타입이라면,
List<String>도 List<Object>의 하위 타입으로 간주하는 성질이다.
하지만 자바 제네릭은 무공변이다.
즉, 타입 인자가 다르면 하위 타입으로 인정하지 않는다.
String extends Object // ✅ OK
List<String> → List<Object> // ❌ No
이 때문에 다음 코드도 허용되지 않는다.
List<Object> list = new ArrayList<String>(); // ❌ 컴파일 에러
🔒 왜 제네릭은 무공변으로 설계되었을까?
이유는 단 하나, 타입 안정성(type safety) 때문이다.
다음처럼 컴파일이 허용된다고 가정해보자.
List<Object> list = new ArrayList<String>();
list.add(123); // 문제 없어 보이지만...
list는 실제로 ArrayList<String>인데 외부에서는 Object로 보인다.
그래서 정수도 넣을 수 있고, 결국 런타임 오류로 이어질 수 있다.
이런 상황을 막기 위해 자바는 제네릭 타입을 무공변으로 설계했다.
타입이 다르면 아예 다른 타입으로 간주해 컴파일 타임에 차단하는 방식이다.
⚠️ 그런데 배열은 왜 가능한 걸까?
Object[] arr = new String[10]; // ✅ 컴파일 OK
arr[0] = 123; // ❌ ArrayStoreException (런타임 오류)
이 코드는 허용된다. 배열은 공변을 허용하기 때문이다.
하지만 잘못된 값을 넣으면 런타임에 JVM이 타입을 검사하고 오류를 발생시킨다.
이 차이는 다음에서 비롯된다.
항목 | 배열 | 제네릭 |
런타임에 타입 정보 유지 | ✅ 있음 (String[]) | ❌ 없음 (타입 소거됨) |
타입 오류 감지 시점 | 런타임 (JVM이 검사) | 컴파일 타임 (컴파일러가 검사) |
잘못된 타입 저장 시 | ArrayStoreException 발생 | 컴파일 자체가 안됨 |
배열은 타입 정보를 런타임에도 유지하기 때문에
JVM이 나중에라도 검사할 수 있다.
하지만 제네릭은 타입 소거(type erasure) 방식이기 때문에
런타임에는 타입 정보가 사라진다.
그래서 컴파일러가 처음부터 강하게 타입 검사를 해야만 안전하다.
🤔 실무에서는 너무 불편하지 않을까?
현실에선 다음과 같은 상황이 많다.
“리스트 내부 타입이 뭔지는 상관없고,
그냥 출력만 하면 된다.”
하지만 아래 코드는 컴파일되지 않는다.
public void printList(List<Object> list) { ... }
printList(new ArrayList<String>()); // ❌ 컴파일 에러
이때 등장하는 것이 와일드카드 <?>이다.
✅ 와일드카드로 유연하게 해결하자
public void printList(List<?> list) {
for (Object obj : list) {
System.out.println(obj);
}
}
이제 List<String>, List<Integer>, List<Double> 등
모든 리스트 타입을 받아들일 수 있고, 읽기 전용으로 안전하게 사용할 수 있다.
- 쓰기(add)는 제한되지만(컴파일 오류 발생),
- 읽기만 필요한 경우라면 가장 유연하고 안전한 방법이다.
🧾 제네릭 vs 와일드카드 정리표
항목 | 제네릭<T> | 와일드카드 , ? extends, ? super |
타입 관계 | 무공변 (하위 타입 인정 안 함) | 유연한 타입 허용 |
타입 정보 유지 | ❌ 타입 소거 | ❌ 타입 소거 |
읽기 | 가능 | 가능 (? extends) |
쓰기 | 가능 | 제한적 (? super만 가능) |
사용 위치 | 내부 구현 중심 | 외부 API 파라미터 수용 중심 |
🧵 마무리하며
자바는 제네릭에서 컴파일 타임 타입 안정성을 철저히 보장한다.
그 대가로 List<String>과 List<Object>를 자유롭게 오갈 수 없다.
반면 배열은 런타임 타입 정보를 유지하기 때문에 공변을 허용하지만,
잘못된 사용 시 런타임 예외가 발생한다.
- 배열: JVM이 나중에 검사할 수 있으므로 허용
- 제네릭: 런타임에 타입이 사라지므로 컴파일 타임에 반드시 검사
이런 제약을 해결하기 위해 도입된 것이 바로 와일드카드이다.
- ? extends T: 읽기 전용
- ? super T: 쓰기 전용
- <?>: 타입은 모르지만 읽기만 한다면 OK
제네릭과 와일드카드는 서로 다른 역할을 가진 안전장치이며,
JVM의 철학이 반영된 도구들이다.
'언어 > Java' 카테고리의 다른 글
왜 불변 객체를 써야 할까? (0) | 2025.05.03 |
---|---|
happens-before란 무엇인가? (0) | 2025.05.02 |
ABA 문제란? - CAS 에서 터질 수 있는 진짜 함정 (0) | 2025.05.02 |
Executor 프레임워크2 - Executor 프레임워크가 등장한 이유 (0) | 2025.04.30 |
Executor 프레임워크1 - 스레드를 직접 사용할 때 문제점 (0) | 2025.04.30 |