자바 제네릭(Generic)을 사용하다 보면 ? extends T와 ? super T라는 와일드카드를 만나게 됩니다. 이 두 가지는 각각 **상한 경계 와일드카드(Upper Bounded Wildcard)**와 **하한 경계 와일드카드(Lower Bounded Wildcard)**를 의미하며, 이를 언제 써야 하는지를 깔끔하게 정리한 법칙이 바로 **PECS(Producer Extends, Consumer Super)**입니다. 오늘은 자바 제네릭의 핵심 개념인 공변성(Covariance)과 반공변성(Contravariance), 그리고 PECS 법칙을 실전 예제와 함께 완벽하게 정리합니다.
1. 자바 제네릭의 불변성(Invariance) 이해
자바의 제네릭은 기본적으로 **불변(Invariant)**합니다. 이것이 무슨 의미인지부터 이해해야 합니다.
// 일반 배열은 공변(Covariant) - 컴파일은 되지만 런타임에 ArrayStoreException 위험
Number[] numbers = new Integer[10]; // 컴파일 OK
numbers[0] = 3.14; // 런타임에 ArrayStoreException 발생!
// 제네릭은 불변(Invariant) - 컴파일 단계에서 타입 안전성 보장
List<Number> numbers2 = new ArrayList<Integer>(); // 컴파일 에러!
// Type mismatch: cannot convert from ArrayList<Integer> to List<Number>
핵심 개념:
Integer는Number의 하위 타입이지만,List<Integer>는List<Number>의 하위 타입이 아닙니다.- 이것이 바로 제네릭의 불변성(Invariance)이며, 타입 안전성을 컴파일 타임에 보장하기 위한 설계입니다.
- 그러나 이 규칙은 때로 지나치게 엄격하여 코드 유연성을 저해합니다. 이를 위해 와일드카드(
?)가 존재합니다.
2. 공변성(Covariance): ? extends T — 상한 경계
? extends T는 T 자기 자신 또는 T의 하위 클래스만 허용합니다.
// Number를 상속하는 Integer, Double, Long 모두 허용
List<? extends Number> numbers = new ArrayList<Integer>();
List<? extends Number> numbers2 = new ArrayList<Double>();
// 읽기는 가능: Number 타입으로 꺼낼 수 있음
Number n = numbers.get(0); // OK
// 쓰기는 불가능: 어떤 타입을 넣어야 할지 컴파일러가 모름
numbers.add(1); // 컴파일 에러!
numbers.add(null); // null만 예외적으로 허용
왜 쓰기(add)가 안 될까요? numbers에는 List<Integer>가 들어올 수도 있고, List<Double>이 들어올 수도 있습니다. 만약 numbers.add(3.14)를 허용하면 List<Integer>에 Double이 들어가는 타입 불일치가 발생할 수 있으므로 컴파일러가 원천 차단합니다. 즉, ? extends T 컬렉션은 데이터를 읽기(produce)만 안전하게 할 수 있는 생산자(Producer)입니다.
3. 반공변성(Contravariance): ? super T — 하한 경계
? super T는 T 자기 자신 또는 T의 상위 클래스만 허용합니다.
// Integer의 상위 타입인 Number, Object 모두 허용
List<? super Integer> list = new ArrayList<Number>();
List<? super Integer> list2 = new ArrayList<Object>();
// 쓰기는 가능: Integer 및 그 하위 타입 추가 OK
list.add(1); // OK (Integer 추가)
list.add(10); // OK
// 읽기는 불완전: Object 타입으로만 꺼낼 수 있음 (타입 정보 손실)
Object o = list.get(0); // OK
Integer i = list.get(0); // 컴파일 에러! Object로만 캐스팅 가능
왜 읽기가 제한될까요? list에는 List<Number>가 들어올 수도 있고, List<Object>가 들어올 수도 있습니다. 꺼낸 값이 Integer인지, Number인지, Object인지 컴파일러가 확신할 수 없으므로 가장 넓은 타입인 Object로만 처리가 가능합니다. 즉, ? super T 컬렉션은 데이터를 쓰기(consume)만 안전하게 할 수 있는 소비자(Consumer)입니다.
4. PECS 법칙 완전 정리
PECS = Producer Extends, Consumer Super
| 상황 | 와일드카드 | 허용 연산 | 비유 |
|---|---|---|---|
| 컬렉션에서 꺼내서 읽는 경우 (생산자) | ? extends T |
Read O / Write X | 상품을 꺼내 판매하는 생산자(Producer) |
| 컬렉션에 넣어서 쓰는 경우 (소비자) | ? super T |
Read X (Object만) / Write O | 상품을 받아 보관하는 소비자(Consumer) |
| 읽기와 쓰기를 모두 하는 경우 | T (와일드카드 없음) |
Read O / Write O | 생산과 소비 모두 하는 중개자 |
5. 실전 예제: Collections.copy() API
자바 표준 라이브러리의 Collections.copy()는 PECS 법칙이 완벽하게 적용된 대표 예시입니다.
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
// src: T 또는 T의 하위 타입 리스트 → 데이터를 꺼내는(produce) 소스
// dest: T 또는 T의 상위 타입 리스트 → 데이터를 넣는(consume) 대상
for (int i = 0; i < src.size(); i++) {
T element = src.get(i); // src는 Producer(extends): 읽기 가능
dest.set(i, element); // dest는 Consumer(super): 쓰기 가능
}
}
// 실제 사용 예시
List<Integer> integers = List.of(1, 2, 3);
List<Number> numbers = new ArrayList<>(Arrays.asList(0.0, 0.0, 0.0));
Collections.copy(numbers, integers);
// integers (? extends Number)에서 읽고
// numbers (? super Integer)에 씁니다.
System.out.println(numbers); // [1, 2, 3]
분석:
src는List<? extends T>: Integer 리스트에서 Number로 읽기(produce) 가능 → Extendsdest는List<? super T>: Number 리스트에 Integer를 쓰기(consume) 가능 → Super
6. 비바운드 와일드카드(Unbounded Wildcard): <?>
타입에 관계없이 모든 제네릭 타입을 허용하되, 읽기만 가능한 완전 불명확한 타입입니다.
// 어떤 타입의 List든 출력 가능한 메서드
public void printAll(List<?> list) {
for (Object o : list) {
System.out.println(o);
}
// list.add(1); // 컴파일 에러! 타입을 모르므로 쓰기 불가
}
printAll(List.of(1, 2, 3)); // OK
printAll(List.of("a", "b")); // OK
printAll(List.of(1.0, 2.0)); // OK
7. 자주 묻는 질문 (FAQ)
Q1. 제네릭 메서드와 와일드카드는 언제 사용을 구분하나요?
제네릭 메서드(<T>)는 입력과 출력 타입 간에 관계가 있을 때 사용합니다. 예를 들어 T를 받아서 T를 반환하는 경우입니다. 와일드카드(?)는 컬렉션 내부 타입을 신경 쓰지 않아도 될 때, 또는 읽기/쓰기 중 하나만 할 때 사용합니다. 메서드 시그니처에서 타입 파라미터 T가 한 번만 등장한다면 와일드카드를 사용하는 것이 더 명확합니다.
Q2. List<Object>와 List<?>는 어떻게 다른가요?
List<Object>에는 어떤 타입의 객체든 넣을 수 있습니다. 반면 List<?>는 어떤 타입인지 알 수 없기 때문에 null 외에는 아무것도 넣을 수 없습니다. 또한 List<Object>는 List<String>을 매개변수로 받을 수 없지만, List<?>는 가능합니다.
Q3. PECS가 헷갈릴 때 기억하는 쉬운 방법이 있나요?
"내가 데이터를 꺼내서 쓴다면(내가 소비자라면) → 컬렉션이 생산자 → extends, 내가 데이터를 넣어야 한다면(내가 생산자라면) → 컬렉션이 소비자 → super"라고 기억하면 됩니다. 메서드 관점에서 src(출처)는 extends, dest(목적지)는 super라고 연결 지어 외워두는 것도 좋습니다.
8. 마무리 및 권장사항
PECS 법칙은 처음에는 매우 헷갈리지만, 제네릭의 타입 안전성을 유지하면서도 API의 유연성을 극대화하는 자바 설계의 정수입니다. 핵심은 컬렉션이 데이터를 공급하는 역할(Producer)이면 extends, 데이터를 받아들이는 역할(Consumer)이면 super를 사용하는 것입니다. Collections.copy()나 Spring의 BeanFactory 소스코드에서 이 패턴을 직접 찾아보면 이해가 훨씬 빠릅니다.
'개발지식' 카테고리의 다른 글
| [Spring] JPA N+1 문제 원인 분석과 페치 조인(Fetch Join) 및 @EntityGraph 해결책 (0) | 2026.07.23 |
|---|---|
| [Security] 비밀번호를 안전하게 보관하는 법: Hash, Salt, 그리고 Key Stretching 이해 (0) | 2026.07.22 |
| [Docker] 도커 컨테이너를 가볍고 안전하게! 멀티 스테이지 빌드(Multi-stage Build) 가이드 (0) | 2026.07.21 |
| [Java] 자바 21(Java 21) 가상 스레드(Virtual Threads) 도입 배경과 기존 스레드 모델 비교 (1) | 2026.07.19 |
| How to Parse JSON in Java Using Gson – A Complete Guide (0) | 2025.04.17 |