이펙티브 자바 실전 강의 교재¶
5장 — 제네릭¶
대상: Java/Spring 백엔드 입문~중급 수강생 형식: 개념 설명 → 비유 → 현업 예제 → 따라하기(실습) → 함정 → 체크리스트 → 퀴즈 전제 환경: Java 17+
0. 이 장을 시작하기 전에¶
0.1 학습 목표¶
- 로 타입을 추방하고, 컴파일러가 타입 오류를 미리 잡게 만든다.
- 비검사 경고를 제로로 만드는 습관을 들인다.
- 한정적 와일드카드(PECS)로 유연하면서도 안전한 API를 설계한다.
0.2 큰 그림 — 제네릭은 "컴파일 타임 안전벨트"¶
[ 기본기 ] [ 직접 만들기 ] [ 유연하게 ]
26 로 타입 금지 29 제네릭 타입 31 와일드카드(PECS) ⭐
27 비검사 경고 제거 30 제네릭 메서드 32 제네릭 가변인수 주의
28 배열보다 리스트 33 타입 안전 이종 컨테이너
비유 — 제네릭은 "내용물 라벨이 붙은 상자"입니다.
- 로 타입
List: 라벨 없는 상자 → 꺼낼 때마다 "이게 뭐였지?" 형변환·런타임 오류.List<String>: "문자열 전용" 라벨 → 엉뚱한 걸 넣으면 컴파일 시점에 막힌다. 제네릭의 본질은 "오류를 런타임에서 컴파일 타임으로 앞당기는 것"입니다.
0.3 현업에서 왜 중요한가¶
- 컬렉션·DTO·Repository·유틸은 전부 제네릭 위에 서 있습니다.
- IDE의 노란 경고(unchecked)를 방치하면, 나중에
ClassCastException이 운영에서 터집니다. - 라이브러리/공용 모듈 API의 유연성은 와일드카드 설계에서 갈립니다.
아이템 26. 로 타입은 사용하지 말라¶
한 줄 요약¶
List 같은 로 타입(raw type)을 쓰지 마라. 제네릭의 안전성·표현력을 통째로 버리는 행위다.
비유 — "라벨 없는 상자"¶
List(로 타입)는 내용물 라벨이 없는 상자입니다. 무엇이든 들어가고, 꺼낼 때마다 형변환과 "혹시 다른 게?"라는 불안이 따라옵니다.
문제¶
// ❌ 로 타입: 컴파일은 되지만, 런타임에 ClassCastException 폭탄
List list = new ArrayList();
list.add("hello");
list.add(42); // 막아주지 않음
String s = (String) list.get(1); // 런타임에 터짐
해법¶
// ✅ 컴파일러가 잘못된 삽입을 즉시 차단
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(42); // ← 컴파일 에러로 미리 막힘
구분: 로 타입 vs List<Object> vs List<?>¶
| 표기 | 의미 | 안전성 |
|---|---|---|
List (로 타입) |
타입 검사 포기 | ❌ 쓰지 말 것 |
List<Object> |
모든 타입 허용(명시적) | ✅ 타입 안전 |
List<?>(비한정 와일드카드) |
"뭔가의 List"(읽기 위주) | ✅ 안전, 단 add 불가(null 제외) |
로 타입을 허용하는 유일한 예외:
class리터럴(List.class)과instanceof(런타임에 제네릭 정보가 소거되므로).
현업 예제¶
레거시 코드에서 로 타입을 만나면, 점진적으로 제네릭으로 교체합니다. 새 코드에서는 다이아몬드(<>)로 항상 타입을 명시하세요.
따라하기 (실습 26-A)¶
- 로 타입
List에 String과 Integer를 섞어 넣고 런타임 예외를 재현한다. List<String>으로 바꿔 같은 실수가 컴파일 단계에서 막히는 것을 확인한다.
체크리스트¶
- 코드에
List,Map등 타입 파라미터 없는 로 타입이 있는가? → 제네릭화 - "모든 타입"이 필요하면
List<Object>또는List<?>로 명시했는가?
퀴즈¶
Q. 로 타입이 위험한 한 문장 이유는?
A. 컴파일러의 타입 검사를 무력화해, 본래 컴파일 시점에 막혔을 오류를 런타임의 ClassCastException으로 미룹니다.
아이템 27. 비검사 경고를 제거하라¶
한 줄 요약¶
컴파일러의 비검사(unchecked) 경고를 모두 제거하라. 못 없애면 안전을 증명한 뒤 최소 범위로 @SuppressWarnings.
비유 — "자동차 경고등"¶
경고등(노란 unchecked)을 테이프로 가리는 게 아니라, 원인을 고쳐서 끄는 것이 정석입니다. 정말 문제없음을 확인했다면, 그 한 부품에만(최소 범위) "점검 완료" 스티커를 붙입니다.
원칙¶
- 가능한 모든 비검사 경고를 제거하면
ClassCastException걱정이 사라진다. - 없앨 수 없고 타입 안전을 확신한다면
@SuppressWarnings("unchecked")를 가능한 가장 좁은 범위(변수 선언 등)에 단다. - 억제할 때는 왜 안전한지 주석을 남긴다.
public <T> T[] toArray(T[] a) {
if (a.length < size) {
// 우리가 만든 배열과 매개변수 타입이 같으므로 형변환이 안전하다.
@SuppressWarnings("unchecked")
T[] result = (T[]) Arrays.copyOf(elements, size, a.getClass());
return result;
}
// ...
}
현업 메모¶
빌드 설정에서 -Xlint:unchecked를 켜고, 가능하면 경고를 에러로 취급(-Werror)해 신규 경고가 못 들어오게 막는 팀도 많습니다.
체크리스트¶
- 새로 생긴 unchecked 경고를 방치하고 있지 않은가?
-
@SuppressWarnings를 메서드 전체가 아니라 변수 단위로 좁혔는가? - 억제 사유를 주석으로 남겼는가?
퀴즈¶
Q. @SuppressWarnings를 가능한 한 좁은 범위에 달아야 하는 이유는?
A. 넓게 달면 그 안의 다른 진짜 경고까지 묻혀서, 정말 위험한 코드를 놓치게 되기 때문입니다.
아이템 28. 배열보다는 리스트를 사용하라¶
한 줄 요약¶
배열은 공변·실체화, 제네릭은 불공변·소거. 둘을 섞으면 위험하다. 가능하면 배열 대신 리스트.
비유 — "늦게 터지는 폭탄 vs 미리 막는 안전장치"¶
- 배열은 잘못된 타입을 런타임에야
ArrayStoreException으로 터뜨립니다(늦게 터지는 폭탄). - 제네릭 리스트는 컴파일 시점에 막아 줍니다(미리 막는 안전장치).
두 가지 핵심 차이¶
// 1) 공변(covariant) vs 불공변(invariant)
Object[] arr = new Long[1];
arr[0] = "타입 안 맞음"; // 컴파일 OK, 런타임에 ArrayStoreException ❌
List<Object> list = new ArrayList<Long>(); // 컴파일 에러로 미리 차단 ✅
-
배열:
Long[]은Object[]의 하위 타입(공변) → 런타임에야 들킴. -
제네릭:
List<Long>은List<Object>의 하위 타입이 아님(불공변) → 컴파일에 들킴. -
실체화(reified): 배열은 런타임에 원소 타입을 안다.
-
소거(erasure): 제네릭은 런타임에 타입 정보가 지워진다 →
new T[],new List<String>[]같은 제네릭 배열 생성은 금지.
현업 메모¶
제네릭과 배열을 함께 쓰다 비검사 경고가 나면, 대개 배열을 List로 교체하면 깔끔하게 해결됩니다. 약간의 성능을 내주고 타입 안전성을 얻는 거래입니다.
따라하기 (실습 28-A)¶
Object[] = new String[]에 다른 타입을 넣어ArrayStoreException을 런타임에 재현한다.- 동일 시나리오를
List로 작성해 컴파일 단계에서 막히는 것을 확인한다.
퀴즈¶
Q. "배열은 공변, 제네릭은 불공변"이 실무에 주는 함의는?
A. 배열은 타입 불일치를 런타임에 늦게 발견하고, 제네릭은 컴파일 타임에 미리 발견합니다. 그래서 타입 안전 측면에서 리스트가 유리합니다.
아이템 29. 이왕이면 제네릭 타입으로 만들라¶
한 줄 요약¶
직접 만드는 컨테이너 클래스는 제네릭 타입으로 만들어, 사용자가 형변환 없이 안전하게 쓰게 하라.
비유 — "전용 라벨을 붙일 수 있는 상자 제작"¶
상자를 만들 때 "원하는 라벨을 붙일 수 있게" 설계하면, 사용자는 자기 용도에 맞는 라벨(<String>, <User>)을 달아 안전하게 씁니다.
예제: Stack을 제네릭으로¶
public class Stack<E> {
private E[] elements;
private int size = 0;
@SuppressWarnings("unchecked") // 안전성 확인 후 억제(아이템 27)
public Stack() {
elements = (E[]) new Object[16]; // 소거 때문에 우회 생성
}
public void push(E e) { elements[size++] = e; }
public E pop() {
E result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제(아이템 7)
return result;
}
}
사용자는 Stack<Contract>처럼 쓰며 형변환이 필요 없습니다.
퀴즈¶
Q. 제네릭 클래스 내부에서 new E[]가 안 되는 이유와 우회법은?
A. 타입 소거로 런타임에 E를 알 수 없어 제네릭 배열을 직접 생성할 수 없습니다. (E[]) new Object[...]로 만들고, 타입 안전을 확인한 뒤 @SuppressWarnings("unchecked")로 억제합니다.
아이템 30. 이왕이면 제네릭 메서드로 만들라¶
한 줄 요약¶
입력·출력 타입이 연동되는 정적 유틸 메서드는 제네릭 메서드로 만들어 타입 안전과 추론을 살려라.
비유 — "어떤 재료든 받는 만능 레시피"¶
재료 타입에 묶이지 않고, 들어온 재료 타입을 그대로 결과에 반영하는 레시피입니다.
예제¶
// 타입 매개변수 <E>를 메서드에 선언
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
Set<E> result = new HashSet<>(s1);
result.addAll(s2);
return result;
}
현업 메모¶
Collections.emptyList(), Optional.empty() 같은 JDK 메서드가 제네릭 메서드의 전형입니다. 항등 함수처럼 타입을 가리지 않는 유틸도 제네릭 싱글턴 팩터리로 만들 수 있습니다.
퀴즈¶
Q. 제네릭 메서드에서 타입 매개변수 선언은 어디에 위치하는가?
A. 메서드의 반환 타입 앞에 <E> 형태로 선언합니다(예: public static <E> Set<E> union(...)).
아이템 31. 한정적 와일드카드를 사용해 API 유연성을 높이라 (PECS) ⭐핵심¶
한 줄 요약¶
PECS: Producer-Extends, Consumer-Super. 데이터를 꺼내 주는(생산) 매개변수엔 ? extends E, 받아 담는(소비) 매개변수엔 ? super E.
비유 — "납품처와 수거업체"¶
- 생산자(Producer) = 물건을 주는 쪽: 우리 가게에 재료를 공급하는 납품처.
Apple이든Fuji(하위 타입)든 "과일(E)"로 받으면 됩니다 →? extends E. - 소비자(Consumer) = 물건을 받아 담는 쪽: 우리가 만든 "과일(E)"을 가져가 처리할 수거업체. 과일을 담을 수 있으려면 그 통은 "과일 이상(상위 타입)"이면 됩니다 →
? super E.
외우는 법: 꺼내면(produce) extends, 담으면(consume) super.
예제: Stack의 pushAll / popAll¶
public class Stack<E> {
// src에서 원소를 '꺼내(produce)' 우리 스택에 넣는다 → 생산자 → extends
public void pushAll(Iterable<? extends E> src) {
for (E e : src) push(e);
}
// dst에 우리 원소를 '담는다(consume)' → 소비자 → super
public void popAll(Collection<? super E> dst) {
while (!isEmpty()) dst.add(pop());
}
}
이렇게 하면 Stack<Number>에 Iterable<Integer>를 push하고, Collection<Object>로 pop할 수 있어 API가 훨씬 유연해집니다.
와일드카드가 막아주는 것¶
Stack<Number> stack = new Stack<>();
Iterable<Integer> integers = List.of(1, 2, 3);
stack.pushAll(integers); // ? extends 덕분에 OK (없으면 컴파일 에러)
Collection<Object> objects = new ArrayList<>();
stack.popAll(objects); // ? super 덕분에 OK
추가 규칙¶
- 반환 타입에는 와일드카드를 쓰지 마라(클라이언트 코드까지 와일드카드가 번진다).
- 매개변수가 생산자이자 소비자라면 와일드카드를 쓰지 말고 정확한 타입을 써라.
Comparable/Comparator는 언제나 소비자이므로Comparable<? super T>가 정석.
현업 예제¶
컬렉션을 받는 유틸/서비스 메서드 시그니처에 PECS를 적용하면, 호출부가 더 다양한 타입을 넘길 수 있어 재사용성이 올라갑니다.
// 합계를 '꺼내 읽기만' 하므로 생산자 → extends
public static double sum(List<? extends Number> nums) {
double total = 0;
for (Number n : nums) total += n.doubleValue();
return total;
}
// List<Integer>, List<Double> 모두 전달 가능
따라하기 (실습 31-A)¶
- 와일드카드 없는
pushAll(Iterable<E>)를 만들고Stack<Number>에List<Integer>를 push해 컴파일 에러를 확인한다. ? extends E로 바꿔 컴파일이 통과하는 것을 확인한다.popAll에? super E를 적용하고Collection<Object>로 받아본다.
체크리스트¶
- 매개변수가 데이터를 꺼내 주는가(생산) →
? extends - 매개변수가 데이터를 받아 담는가(소비) →
? super - 반환 타입에 와일드카드를 쓰지 않았는가?
-
Comparator/Comparable에? super를 적용했는가?
퀴즈¶
Q. PECS를 한 문장으로 설명하라.
A. 매개변수가 데이터를 생산(꺼내 줌)하면 ? extends E, 소비(받아 담음)하면 ? super E를 쓴다 — Producer-Extends, Consumer-Super.
Q. sum(List<? extends Number> nums)에서 nums에 add를 못 하는 이유는?
A. ? extends는 "정확한 원소 타입을 알 수 없는 생산자"라, 무엇을 넣어도 타입 안전을 보장할 수 없어 컴파일러가 add(null 제외)를 막습니다. 읽기는 안전하지만 쓰기는 막힙니다.
아이템 32. 제네릭과 가변인수를 함께 쓸 때는 신중하라¶
한 줄 요약¶
제네릭 가변인수(T... args)는 힙 오염 위험이 있다. 안전을 보장하면 @SafeVarargs로 명시하라.
비유 — "라벨과 내용물이 어긋난 상자 더미"¶
가변인수는 내부적으로 배열을 만드는데, 제네릭 배열은 소거 때문에 라벨(타입)과 실제 내용물이 어긋날 수 있습니다(힙 오염). 어긋남이 퍼지면 엉뚱한 곳에서 ClassCastException이 납니다.
규칙¶
- 제네릭 varargs 배열에 값을 저장하지 마라(오염 유발).
- 그 배열의 참조를 외부에 노출하지 마라.
- 위 두 가지를 지켜 안전하면, 메서드에
@SafeVarargs를 달아 경고를 없앤다.
@SafeVarargs
static <T> List<T> flatten(List<? extends T>... lists) {
List<T> result = new ArrayList<>();
for (List<? extends T> list : lists) result.addAll(list);
return result; // 배열을 노출하지도, 저장하지도 않음 → 안전
}
퀴즈¶
Q. 제네릭 가변인수 메서드가 "안전"하려면 지켜야 할 두 조건은?
A. ①varargs 배열에 아무것도 저장하지 않는다, ②그 배열의 참조를 외부에 노출하지 않는다. 이 두 가지를 지키면 @SafeVarargs로 표시할 수 있습니다.
아이템 33. 타입 안전 이종 컨테이너를 고려하라¶
한 줄 요약¶
키 자체를 제네릭(Class<T>)으로 만들면, 여러 타입을 한 컨테이너에 타입 안전하게 담을 수 있다.
비유 — "타입별 전용 사물함"¶
Class<T> 객체를 열쇠로 쓰면, "String 칸엔 String만, Integer 칸엔 Integer만" 들어가는 사물함을 만들 수 있습니다. 칸(키)마다 타입이 보장됩니다.
예제: Favorites¶
public class Favorites {
private Map<Class<?>, Object> favorites = new HashMap<>();
public <T> void put(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), type.cast(instance));
}
public <T> T get(Class<T> type) {
return type.cast(favorites.get(type)); // 안전한 형변환
}
}
Favorites f = new Favorites();
f.put(String.class, "java");
f.put(Integer.class, 0xcafe);
String s = f.get(String.class); // 형변환 없이 타입 안전
현업 예제¶
Spring의 ApplicationContext.getBean(Class<T> type)이 이 패턴입니다. 타입을 키로 받아 그 타입의 빈을 안전하게 돌려줍니다. 어노테이션 정보 맵 등에도 흔히 쓰입니다.
퀴즈¶
Q. 타입 안전 이종 컨테이너에서 키로 Class<T>를 쓰는 이유는?
A. Class<T>는 키이면서 타입 토큰 역할을 해, type.cast(...)로 꺼낼 때 컴파일·런타임 모두 타입 안전을 보장하기 때문입니다.
5장 종합 정리¶
한눈에 보는 결정 가이드¶
| 상황 | 선택 |
|---|---|
| 컬렉션/컨테이너 선언 | 로 타입 금지(26), 항상 타입 명시 |
| 컴파일 경고 | 비검사 경고 제거(27), 못 없애면 좁게 억제 |
| 배열 vs 리스트 | 가능하면 리스트(28) |
| 내가 만드는 타입/메서드 | 제네릭 타입(29)·제네릭 메서드(30) |
| API 유연성 | PECS 와일드카드(31) |
| 제네릭 + varargs | 힙 오염 주의, 안전하면 @SafeVarargs(32) |
| 여러 타입을 한 곳에 | 타입 안전 이종 컨테이너(33) |
종합 체크리스트 (코드 리뷰용)¶
- 로 타입이 코드에 남아 있지 않은가
- unchecked 경고가 0인가 (억제는 최소 범위 + 사유 주석)
- 배열과 제네릭을 섞어 경고를 내고 있지 않은가 → 리스트화
- 컬렉션 받는 API에 PECS를 적용했는가
- 제네릭 varargs 메서드의 안전 조건을 지켰는가
종합 퀴즈¶
Q1. 제네릭의 가장 큰 가치를 한 문장으로?
A. 타입 오류를 런타임에서 컴파일 타임으로 앞당겨, ClassCastException 없이 안전하게 쓰게 한다.
Q2. PECS를 "납품처/수거업체" 비유로 다시 설명하라.
A. 데이터를 공급(생산)하는 매개변수는 하위 타입까지 받도록 ? extends(납품처), 데이터를 받아 담는(소비) 매개변수는 상위 타입 통이면 되도록 ? super(수거업체)를 쓴다.
Q3. getBean(Class<T>)이 5장의 어떤 아이템을 구현한 것인가?
A. 아이템 33, 타입 안전 이종 컨테이너(Class를 타입 토큰 키로 사용).
다음 장 예고 — 6장: 열거 타입과 애너테이션¶
int 상수 대신 enum, EnumSet/EnumMap, 그리고 명명 패턴 대신 애너테이션을 다룹니다. 상태·코드값·권한 같은 도메인 상수를 안전하게 모델링하는 현업 필수 장입니다.