타입 소거, 와일드카드, 경계, 가변성(공변/반공변)을 통해 안전하고 유연한 제네릭 API를 설계한다.
제네릭과 타입 시스템 심화
입문편에서 List<String>, Map<String, Integer> 같은 제네릭 컬렉션을 사용하는 법을 배웠습니다. 하지만 "왜 List<String>을 List<Object>에 넣을 수 없는지", "왜 캐스팅 없이도 타입이 맞는지", "와일드카드 ?는 언제 쓰는지"처럼 동작 원리에 관한 의문은 해소되지 않았을 수 있습니다.
이 강에서는 제네릭을 사용하는 것을 넘어 설계하고 이해하는 관점으로 전환합니다. 타입 소거가 런타임에서 어떤 한계를 만드는지, PECS 원칙이 왜 필요한지, 배열과 제네릭이 가변성 측면에서 어떻게 다른지를 깊이 있게 다루고, 재사용 가능한 라이브러리 수준의 API를 직접 설계해 봅니다.
학습 목표
- **타입 소거(type erasure)**의 동작 원리와 런타임에서 발생하는 제약을 설명할 수 있다.
- **와일드카드(
? extends,? super)**와 PECS 원칙을 적용해 유연한 메서드 시그니처를 작성할 수 있다. - 경계 타입 매개변수(
<T extends ...>) 와 다중 경계를 올바르게 선언할 수 있다. - 공변·반공변·불변의 개념을 이해하고 배열과 제네릭의 차이를 설명할 수 있다.
- 제네릭 메서드와 타입 추론을 활용해 재사용 가능한 API를 설계할 수 있다.
타입 소거(Type Erasure)의 동작 원리
Java의 제네릭은 컴파일 타임에만 존재합니다. 컴파일러는 타입 검사를 완료한 뒤 타입 매개변수를 모두 지워 버립니다. 이 과정을 **타입 소거(type erasure)**라고 합니다. 결과물인 바이트코드에는 T 자리에 상한 경계가 없으면 Object가, 있으면 그 경계 타입이 들어갑니다.
// 소스 코드
public class Box<T> {
private T value;
public T get() { return value; }
}
// 바이트코드 수준으로 변환된 모습 (javap 결과 요약)
public class Box {
private Object value; // T → Object
public Object get() { return value; }
}
경계가 있는 경우 첫 번째 경계 타입으로 치환됩니다.
// <T extends Comparable<T>> 선언 시
public class SortedBox<T extends Comparable<T>> {
private T value;
public T get() { return value; }
}
// 바이트코드: T → Comparable
런타임 한계: 소거로 인해 불가능한 것들
타입 소거 때문에 런타임에는 제네릭 타입 정보가 사라지므로 다음과 같은 코드는 컴파일 오류 또는 논리 오류를 유발합니다.
public <T> void example(Object obj) {
// ❌ 제네릭 타입으로 instanceof 검사 불가
if (obj instanceof T) { } // 컴파일 오류
// ❌ 제네릭 타입으로 배열 생성 불가
T[] arr = new T[10]; // 컴파일 오류
// ❌ 제네릭 타입으로 직접 인스턴스 생성 불가
T instance = new T(); // 컴파일 오류
// ✅ 클래스 리터럴을 인자로 넘겨 우회
// (다음 섹션에서 다룸)
}
⚠️ 주의 —
List<String>과List<Integer>는 런타임에 모두 동일한List클래스입니다. 따라서list instanceof List<String>같은 비교는 불가하며, 오버로딩도 둘을 구별하지 못합니다.
Class 리터럴로 타입 정보 복원하기
소거를 우회해 런타임에도 타입을 유지하려면 Class<T> 를 명시적으로 전달하는 패턴을 씁니다.
public class TypeSafeContainer<T> {
private final Class<T> type;
private T value;
public TypeSafeContainer(Class<T> type) {
this.type = type;
}
public void set(Object obj) {
// ✅ Class.cast()로 런타임 타입 검사
this.value = type.cast(obj);
}
public T get() { return value; }
}
// 사용
TypeSafeContainer<String> c = new TypeSafeContainer<>(String.class);
c.set("hello");
String s = c.get(); // 안전하게 꺼냄
와일드카드와 PECS 원칙
상한 와일드카드: ? extends T
List<? extends Number>는 "Number 또는 그 하위 타입의 List"를 의미합니다. 이 목록에서는 읽기(get) 는 안전하지만 쓰기(add) 는 금지됩니다. 컴파일러 입장에서 실제 타입이 Integer인지 Double인지 알 수 없기 때문입니다.
public double sum(List<? extends Number> list) {
double total = 0;
for (Number n : list) { // ✅ Number로 읽기 가능
total += n.doubleValue();
}
// list.add(1.5); // ❌ 쓰기 불가 — 컴파일 오류
return total;
}
하한 와일드카드: ? super T
List<? super Integer>는 "Integer 또는 그 상위 타입의 List"를 의미합니다. 이 목록에는 쓰기(add) 가 가능하지만 읽기(get) 결과는 Object로만 받을 수 있습니다.
public void fill(List<? super Integer> list, int count) {
for (int i = 0; i < count; i++) {
list.add(i); // ✅ Integer 추가 가능
}
// Integer val = list.get(0); // ❌ 컴파일 오류 — Object로만 가능
Object obj = list.get(0); // ✅ Object로 읽기는 가능
}
PECS: Producer Extends, Consumer Super
조슈아 블로흐(Joshua Bloch)가 정립한 원칙입니다.
💡 TIP — PECS: 매개변수가 데이터를 **생산(produce, 꺼냄)**하면
? extends, 데이터를 **소비(consume, 삽입)**하면? super를 사용하세요.
실제 Collections.copy() 메서드가 이 원칙을 완벽하게 보여 줍니다.
// JDK 구현 (간략화)
public static <T> void copy(
List<? super T> dest, // Consumer — T를 받아 넣으므로 super
List<? extends T> src // Producer — T를 꺼내 주므로 extends
) {
for (T item : src) {
dest.add(item);
}
}
// 사용 예시
List<Integer> integers = List.of(1, 2, 3);
List<Number> numbers = new ArrayList<>();
Collections.copy(numbers, integers); // ✅ Number super Integer, Integer extends Number
| 목적 | 와일드카드 | 읽기 | 쓰기 |
|---|---|---|---|
| 데이터를 꺼내 읽을 때 | ? extends T | T로 가능 | 불가 |
| 데이터를 삽입할 때 | ? super T | Object로만 | T 이하 가능 |
| 읽기/쓰기 모두 필요 | <T> (타입 매개변수) | 가능 | 가능 |
경계 타입 매개변수와 다중 경계
단일 경계
<T extends Comparable<T>>처럼 타입 매개변수에 상한을 지정하면, 메서드 본문에서 해당 인터페이스/클래스의 멤버를 직접 호출할 수 있습니다.
public <T extends Comparable<T>> T max(List<T> list) {
if (list.isEmpty()) throw new IllegalArgumentException("Empty list");
T result = list.get(0);
for (T item : list) {
if (item.compareTo(result) > 0) { // ✅ Comparable 메서드 직접 호출
result = item;
}
}
return result;
}
다중 경계
& 연산자로 여러 경계를 동시에 지정할 수 있습니다. 클래스는 첫 번째에, 인터페이스는 그 뒤에 위치해야 합니다.
// ✅ 클래스 먼저, 인터페이스는 & 뒤에
public <T extends Number & Comparable<T> & Cloneable> T clampedMax(List<T> list) {
return max(list);
}
// ❌ 클래스가 두 개 올 수 없음 — 컴파일 오류
// public <T extends Integer & Long> ...
다중 경계는 여러 능력을 동시에 갖춘 타입만 허용해야 할 때 유용합니다. 예를 들어 직렬화 가능하면서 비교 가능한 엔티티를 다루는 저장소 추상화가 그 전형적인 사례입니다.
public interface Repository<T extends Serializable & Comparable<T>> {
void save(T entity);
T findMax();
}
제네릭 메서드와 타입 추론
제네릭 메서드 선언 위치
타입 매개변수는 반환 타입 앞에 선언합니다. 이는 제네릭 클래스와 독립적으로 동작하므로, 일반 클래스 안에도 자유롭게 선언할 수 있습니다.
public class CollectionUtils {
// <T>가 반환 타입 앞에 위치
public static <T> List<T> repeat(T element, int times) {
List<T> result = new ArrayList<>();
for (int i = 0; i < times; i++) {
result.add(element);
}
return result;
}
// 두 개의 타입 매개변수
public static <K, V> Map<V, K> invertMap(Map<K, V> map) {
Map<V, K> inverted = new HashMap<>();
map.forEach((k, v) -> inverted.put(v, k));
return inverted;
}
}
타입 추론
Java 8 이상에서는 컴파일러가 문맥으로부터 타입 인수를 추론합니다. 대부분의 경우 명시적으로 적을 필요가 없습니다.
// ✅ 컴파일러가 T=String으로 추론
List<String> words = CollectionUtils.repeat("hello", 3);
// ✅ 명시적 타입 인수 지정 (복잡한 중첩 제네릭 등 일부 상황에서 필요)
List<String> words2 = CollectionUtils.<String>repeat("world", 2);
⚠️ 주의 — 타입 추론이 실패하면 컴파일러는
Object로 추론하거나 오류를 냅니다. 반환 타입만으로 추론이 필요한 경우 명시적 타입 인수를 지정하세요.
재귀적 타입 경계 (Recursive Type Bound)
자기 자신을 참조하는 타입 경계로, 인터페이스 Comparable<T> 구현 강제에 자주 쓰입니다.
// T가 자기 자신과 비교 가능함을 강제
public <T extends Comparable<T>> T min(T a, T b) {
return a.compareTo(b) <= 0 ? a : b;
}
// 빌더 패턴에서의 재귀적 경계 — 하위 클래스 빌더가 자신의 타입을 반환하도록 강제
public abstract class Builder<T, B extends Builder<T, B>> {
@SuppressWarnings("unchecked")
protected B self() { return (B) this; }
public abstract T build();
}
공변·반공변·불변과 배열 vs 제네릭
가변성 개념
타입 계층과 컨테이너 계층의 관계를 **가변성(variance)**이라 합니다.
| 가변성 | 의미 | 예시 |
|---|---|---|
| 공변(covariant) | Sub extends Super 이면 Container<Sub>도 Container<Super>의 하위 타입 | 배열 |
| 반공변(contravariant) | 반대 방향으로 계층 형성 | ? super T |
| 불변(invariant) | 계층 관계 없음 | List<T> |
배열은 공변이다 — 그리고 그것이 문제다
Java 배열은 공변입니다. String[]은 Object[]의 하위 타입으로 취급됩니다. 덕분에 편리하지만, 런타임 타입 안전성이 깨질 수 있습니다.
String[] strings = new String[3];
Object[] objects = strings; // ✅ 컴파일 OK — 공변 허용
objects[0] = 42; // ❌ 런타임 ArrayStoreException!
// 실제 배열은 String[]인데 Integer를 넣으려 하므로 예외 발생
제네릭은 불변이다 — 그래서 더 안전하다
List<String>은 List<Object>의 하위 타입이 아닙니다. 이 불변성 덕분에 컴파일 타임에 타입 오류를 잡습니다.
List<String> strings = new ArrayList<>();
// List<Object> objects = strings; // ❌ 컴파일 오류 — 불변
// 만약 허용된다면 어떻게 될까?
// objects.add(42); // Integer가 들어가지만 strings는 String만 있어야 함 → 런타임 폭탄
와일드카드로 유연성 확보
제네릭의 불변성을 완화하되 안전성을 유지하는 것이 와일드카드의 역할입니다.
// List<Integer>, List<Double> 모두 받아서 읽기 전용으로 처리
public void printAll(List<? extends Number> list) {
list.forEach(System.out::println);
}
List<Integer> ints = List.of(1, 2, 3);
List<Double> doubles = List.of(1.1, 2.2);
printAll(ints); // ✅
printAll(doubles); // ✅
💡 TIP — 배열 대신 제네릭 컬렉션을 사용하는 이유 중 하나가 불변성으로 인한 컴파일 타임 안전성입니다. 배열은 런타임 예외(
ArrayStoreException)로만 잡히는 오류를 제네릭은 컴파일 때 잡아 줍니다.
제네릭으로 재사용 가능한 라이브러리 API 설계하기
지금까지 배운 개념을 실제 API 설계에 적용해 봅니다. 페이징(paging)을 지원하는 범용 결과 래퍼 클래스와 변환 유틸리티를 설계합니다.
제네릭 결과 래퍼 (Result Wrapper)
/**
* 페이징 결과를 감싸는 범용 컨테이너.
* T: 원소 타입
*/
public final class Page<T> {
private final List<T> content;
private final int pageNumber;
private final int pageSize;
private final long totalElements;
private Page(List<T> content, int pageNumber, int pageSize, long totalElements) {
this.content = List.copyOf(content);
this.pageNumber = pageNumber;
this.pageSize = pageSize;
this.totalElements = totalElements;
}
// 정적 팩토리 메서드 — 타입 추론 활용
public static <T> Page<T> of(List<T> content, int pageNumber, int pageSize, long totalElements) {
return new Page<>(content, pageNumber, pageSize, totalElements);
}
// ✅ 제네릭 메서드: Page<T> → Page<R> 변환
public <R> Page<R> map(java.util.function.Function<? super T, ? extends R> mapper) {
List<R> mapped = content.stream()
.map(mapper)
.collect(java.util.stream.Collectors.toList());
return new Page<>(mapped, pageNumber, pageSize, totalElements);
}
public List<T> getContent() { return content; }
public int getPageNumber() { return pageNumber; }
public int getPageSize() { return pageSize; }
public long getTotalElements() { return totalElements; }
public int getTotalPages() { return (int) Math.ceil((double) totalElements / pageSize); }
}
PECS를 적용한 변환 유틸리티
public final class Transformer {
private Transformer() {}
/**
* src 리스트의 각 원소를 mapper로 변환해 dest에 추가.
* PECS: src는 생산자(extends), dest는 소비자(super)
*/
public static <T, R> void transform(
List<? extends T> src,
List<? super R> dest,
java.util.function.Function<T, R> mapper
) {
for (T item : src) {
dest.add(mapper.apply(item));
}
}
/**
* 여러 소스 리스트를 병합.
* 각 소스는 T의 하위 타입일 수 있으므로 ? extends T.
*/
@SafeVarargs
public static <T> List<T> concat(List<? extends T>... sources) {
List<T> result = new ArrayList<>();
for (List<? extends T> source : sources) {
result.addAll(source);
}
return result;
}
}
경계 타입 매개변수를 활용한 정렬 가능한 저장소
/**
* 요소를 저장하고 최솟값·최댓값을 반환하는 저장소.
* T는 반드시 Comparable을 구현해야 함.
*/
public class SortedStore<T extends Comparable<T>> {
private final List<T> items = new ArrayList<>();
public void add(T item) {
items.add(item);
}
public Optional<T> min() {
return items.stream().min(Comparator.naturalOrder());
}
public Optional<T> max() {
return items.stream().max(Comparator.naturalOrder());
}
// 상한 와일드카드로 외부에서 T의 하위 타입 목록도 받을 수 있음
public void addAll(Collection<? extends T> others) {
items.addAll(others);
}
}
전체 사용 예시
public class Main {
public static void main(String[] args) {
// Page 사용
List<String> names = List.of("Alice", "Bob", "Carol");
Page<String> namePage = Page.of(names, 0, 10, 3L);
// map으로 String → Integer 변환 (타입 안전)
Page<Integer> lengthPage = namePage.map(String::length);
lengthPage.getContent().forEach(System.out::println); // 5, 3, 5
// Transformer 사용
List<Integer> numbers = List.of(1, 2, 3);
List<Number> numStore = new ArrayList<>();
Transformer.transform(numbers, numStore, n -> n * 2.0); // Integer → Double → Number에 저장
// SortedStore 사용
SortedStore<Integer> store = new SortedStore<>();
store.add(5);
store.add(3);
store.add(9);
System.out.println(store.min()); // Optional[3]
System.out.println(store.max()); // Optional[9]
}
}
요약
- 타입 소거: 제네릭 타입 정보는 컴파일 후 사라지며, 런타임에는
instanceof T,new T[],new T()등이 불가합니다.Class<T>인자로 우회할 수 있습니다. - 와일드카드:
? extends T는 읽기(생산자)용,? super T는 쓰기(소비자)용이며, 이것이 PECS 원칙입니다. - 경계 타입 매개변수:
<T extends A & B>로 다중 경계를 지정하면 T가 A와 B의 능력을 모두 갖도록 강제할 수 있습니다. 클래스는 첫 번째에만 올 수 있습니다. - 가변성: 배열은 공변(런타임
ArrayStoreException위험), 제네릭은 불변(컴파일 타임 안전). 와일드카드로 필요한 만큼 유연성을 선택적으로 허용합니다. - 제네릭 메서드: 타입 매개변수를 반환 타입 앞에 선언하며, 컴파일러가 문맥에서 타입 인수를 추론합니다. 재귀적 경계로 자기 참조 제약을 걸 수 있습니다.
- API 설계 원칙: 입력은 최대한 유연하게(
? extends/? super), 반환 타입은 최대한 구체적으로 설계하면 사용자 친화적인 제네릭 API가 됩니다.
연습문제
-
아래 코드가 컴파일 오류를 일으키는 이유를 설명하고, 컴파일되도록 수정하세요.
public void addNumbers(List<Number> list) { list.add(1); } List<Integer> ints = new ArrayList<>(); addNumbers(ints); // 오류힌트: PECS에서 소비자에 해당하는 와일드카드는 무엇인가요?
-
다음 제네릭 메서드를 완성하세요. 두
List중에서 원소의 합이 더 큰 리스트를 반환하되,List<Integer>와List<Double>모두 인자로 받을 수 있어야 합니다.public ??? largerSum(List<??? extends Number> a, List<??? extends Number> b) { // ... }힌트: 반환 타입을
? extends Number와일드카드로 선언하면 두 입력 리스트 중 하나의 참조를 그대로 반환할 수 있습니다. 다만 호출부에서는 구체 타입(List<Integer>/List<Double>)이 아니라Number수준으로만 원소를 다룰 수 있다는 점에 유의하세요. -
다음 코드에서 런타임 예외가 발생하는 시점과 이유를 설명하고, 제네릭으로 재작성해 컴파일 타임에 오류를 잡도록 하세요.
Object[] arr = new String[3]; arr[0] = 100; // 어느 시점에 어떤 예외?힌트: 배열의 공변성이 이 문제의 핵심입니다.
-
TypeSafeMap클래스를 구현하세요. 키는Class<T>이고 값은T타입인 이종 컨테이너(heterogeneous container)로,put(Class<T>, T)와get(Class<T>)메서드를 제공해야 합니다.힌트: 내부 저장소는
Map<Class<?>, Object>를 쓰고,get시Class.cast()로 안전하게 캐스팅하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Java 심화” 강좌에 대한 댓글입니다.