🧩 Generics란?

ArrayList, LinkedList와 같은 Collections를 사용할 때 이런 형태들을 많이 봤을 것이다.
Queue<Integer[]> queue = new LinkedList<>();
이렇게 <> 괄호 안에 사용 타입을 명시하는 것을 Generics라고 한다. 인터페이스 및 메서드를 정의할 때 타입(클래스 또는 인터페이스)을 매개변수로 사용할 수 있도록 한다.
Generics를 쓰는 이유
- 컴파일 타임에 더욱 강력한 타입 검사 가능 (안정성)
- 형 변환 제거 가능 (편의성)
- 코드 재사용성 향상
// Generics를 사용하지 않은 경우 (형 변환 필요)
List list = new ArrayList();
list.add("hello");
String str = (String) list.get(0);
// Generics를 사용한 경우 (형 변환 필요X)
List<String> list = new ArrayList<String>();
list.add("hello");
String str = list.get(0) // 형 변환 없음
🧩 Generics Types
먼저 다음과 같은 일반적인 Box 클래스를 보자.
class Box {
private Object item;
public void setItem(Object item) { this.item = item; }
public Object getItem() { return item; }
}
public class Main {
public static void main(String[] args) {
Box box = new Box();
box.setItem("안녕하세요"); // String 넣음
// 꺼낼 때 반드시 형변환(Casting)이 필요함
String text = (String) box.getItem();
// 만약 실수로 Integer를 넣고 String으로 꺼내려 하면
// 컴파일러는 모른 채 넘어갔다가 실행 중에 ClassCastException 발생!
}
}
이 클래스의 메서드는 Object를 매개변수로 받거나 반환하기 때문에 기본형(원시형)이 아니라면 어떤 유형이든 자유롭게 전달할 수 있다. 하지만 컴파일 시점에 클래스가 어떻게 사용되는 지 확인할 방법이 없기 때문에 런타임 오류가 발생할 수 있다.
제네릭을 사용하는 경우 클래스 이름 옆에 <T> (Type parameter)를 붙여서 동적으로 타입을 결정한다.
class Box<T> {
private T item;
public void setItem(T item) { this.item = item; }
public T getItem() { return item; }
}
public class Main {
public static void main(String[] args) {
// 이 상자는 String 전용이라고 라벨을 붙임
Box<String> stringBox = new Box<>();
stringBox.setItem("안녕하세요");
// 형변환 없이 바로 String으로 꺼낼 수 있음
String text = stringBox.getItem();
// stringBox.setItem(100); // <- 컴파일 에러 발생
}
}
이제 stringBox에는 문자열만 넣을 수 있고, int형을 넣으려고 하면 컴파일 에러가 발생한다.
기존 버전과 비교했을 때 Object는 모두 T로 대체되었다. 타입 변수는 클래스 타입, 인터페이스 타입, 배열 타입 등 기본형이 아닌 모든 참조형 타입이 가능하다.

또한 제네릭에도 다형성이 그대로 적용된다.
import java.util.ArrayList;
import java.util.List;
class Animal {}
class Cat extends Animal {}
class Dog extends Animal {}
class AnimalBox<T> {
List<T> animals = new ArrayList<>();
public void add(T animal) {
animals.add(animal);
}
}
public class Ex2 {
public static void main(String[] args) {
AnimalBox<Animal> animalBox = new AnimalBox<>();
animalBox.add(new Animal());
animalBox.add(new Cat());
animalBox.add(new Dog());
}
}
Type Parameter 명명 규칙
관례적으로 타입 매개변수('형인자'라고도 한다) 이름은 영어 대문자 한 글자로 작성된다.
<T>(Type): 일반적인 타입 (예:Box<T>)<E>(Element): 리스트나 배열의 요소 (예:ArrayList<E>)<K>(Key): 키 값 (예:HashMap<K, V>)<V>(Value): 결과 값 (예:HashMap<K, V>)<N>(Number): 숫자 타입
타입 소거 (Type Erasure)
자바의 제네릭은 제네릭이 존재하지 않던 이전 버전과의 호환성을 위해 컴파일 타임에만 존재한다. 컴파일러가 코드를 검사한 이후 JVM이 실행하는 바이트코드에서는 <T> 같은 제네릭 타입 정보가 모두 소거되고 Object로 바뀐다.
🧩 Generics Wild Card
제네릭을 처음 배울 때 가장 많이 헷갈리는 부분이 바로 ? 기호가 들어간 와일드카드다. 이게 왜 필요하나면, 제네릭은 타입이 정확히 일치하지 않으면 컴파일 에러를 내는 불변(Invariant) 특성을 가지기 때문이다. (예를 들어 Integer는 Number의 자식이지만 List<Integer>는 List<Number>의 자식이 아니다.)
비제한 와일드카드 <?>
어떤 타입이든 상관없이 제네릭 객체를 읽고 싶을 때 사용한다. 주로 컬렉션의 길이를 구하거나 내부 요소를 출력하는 등 타입에 의존하지 않는 단순한 작업을 할 때 쓰인다.
public class WildcardExample {
// 어떤 타입의 List가 오든 길이를 출력하는 메서드
public static void printListSize(List<?> list) {
System.out.println("리스트 크기: " + list.size());
// 타입이 뭔지 모르기 때문에 데이터를 (add할 수는 없음 (null만 가능)
// list.add("Hello"); // 컴파일 에
}
public static void main(String[] args) {
List<String> strList = Arrays.asList("A", "B");
List<Integer> intList = Arrays.asList(1, 2, 3);
printListSize(strList); // 리스트 크기: 2
printListSize(intList); // 리스트 크기: 3
}
}
상한 경계 와일드카드 <? extends T>
T 타입이거나 T의 자식 타입만 들어올 수 있다. 주로 데이터를 Read할 때 쓰인다. 컴파일러 입장에서는 이 리스트 안에 들어있는 게 정확히 T인지 그 자식인지 알 수 없지만, 최소한 T 타입으로 꺼낼 수 있다는 것을 보장받는다.
// 부모: Number, 자식: Integer, Double
public class UpperBoundExample {
// Number와 그 자식들(Integer, Double 등)의 리스트만 허용
public static double sumNumbers(List<? extends Number> list) {
double sum = 0.0;
// 꺼낼 때는 무조건 Number 타입임이 보장됨 (안전하게 Read)
for (Number n : list) {
sum += n.doubleValue();
}
return sum;
}
public static void main(String[] args) {
List<Integer> intList = Arrays.asList(1, 2, 3);
List<Double> doubleList = Arrays.asList(1.5, 2.5);
System.out.println(sumNumbers(intList)); // 6.0
System.out.println(sumNumbers(doubleList)); // 4.0
}
}
하한 경계 와일드카드 <? super T>
T 타입이거나 T의 부모 타입만 들어올 수 있다. 주로 데이터를 Write하는 데 쓰인다. 리스트의 정확한 타입은 모르지만, 최소한 T의 부모 타입이므로 T나 T의 자식 객체를 넣는 것이 안전함을 보장한다.
public class LowerBoundExample {
// Integer와 그 부모들(Number, Object)의 리스트만 허용
public static void addNumbers(List<? super Integer> list) {
// Integer 객체를 추가하는 것이 100% 안전하게 보장됨
list.add(10);
list.add(20);
// 꺼낼 때는 정확히 어떤 타입인지 모르므로 최상위 타입인 Object로만 꺼낼 수 있음
// Integer n = list.get(0); // 컴파일 에러
Object obj = list.get(0); // 통과
}
public static void main(String[] args) {
List<Number> numberList = new ArrayList<>();
List<Object> objectList = new ArrayList<>();
addNumbers(numberList);
addNumbers(objectList);
// List<Double>은 Integer의 부모가 아니므로 에러 발생
}
}
PECS
Effective Java의 저자 조슈아 블로크는 이 복잡한 와일드카드 규칙을 쉽게 외우기 위해 PECS라는 공식을 제안했다.
- Producer Extends: 컬렉션으로부터 데이터를 읽기만 한다면(생산자 역할)
? extends T사용 - Consumer Super: 컬렉션에 데이터를 쓰기만 한다면(소비자 역할)
? super T사용
| 와일드카드 | 주 목적 | Write | Read |
? extends T |
데이터 꺼내기 | ❌ (null만 가능) | ⭕ |
? super T |
데이터 넣기 | ⭕ (T 타입만) | 🔺 (Object로만) |
<? extends T>에는 왜 넣을 수는 없고 꺼낼 수만 있을까?
List<? extends Animal>은 "동물이거나, 동물의 자식들이 들어있는 리스트"를 의미한다. "자식 타입이니까 고양이랑 강아지를 넣을 수 있겠구나"라는 생각이 들지만, 컴파일러의 생각은 다르다.
List<? extends Animal> cafe;
이 cafe의 정체는 런타임에서 아래 세 가지 중 하나로 정해질 것이다.
new ArrayList<Animal>()new ArrayList<Cat>(): 고양이 전용 리스트new ArrayList<Dog>(): 강아지 전용 리스트
만약 cafe가 ArrayList<Dog>와 연결된 상태에서 cafe.add(new Cat())을 시도하면 어떻게 될까? 강아지 전용 카페에 고양이가 들어가서 말 그대로 개판이 될 것이다.
컴파일러는 이 cafe가 정확히 어떤 동물 전용 카페인지 확신할 수 없다. 그래서 "안전하지 않으니까 아예 아무것도 넣지 마"라며 데이터 Write를 원천 차단하는 것이다.
대신 Read하는 것은 100% 안전하다. 안에 강아지가 있든 고양이가 있든 카페에 있는 것은 무조건 동물이라는 사실은 변함 없기 때문이다.
<? super Dog>에는 왜 넣을 수만 있고 꺼내기는 힘들까?
List<? super Dog>는 "강아지이거나, 강아지의 부모 타입들이 들어있는 리스트"를 의미한다.
List<? super Dog> cafe;
이 cafe의 정체는 아래 세 가지 중 하나일 것이다.
new ArrayList<Dog>()new ArrayList<Animal>()new ArrayList<Object>()
이번에는 컴파일러가 데이터 Write를 허용한다. cafe의 정체가 강아지 전용이든, 동물 전용이든, 범용(Object)이든 상관없이 여기에 강아지를 넣는 것은 언제나 100% 안전하기 때문이다.
반면 이번에는 데이터 Read가 어렵다. 상자에서 무언가를 꺼냈을 때 그게 강아지일지, 다른 동물일지, 아니면 아예 동물과 상관 없는 다른 객체일지 컴파일러는 알 방법이 없기 때문이다. 그래서 컴파일러는 꺼낼 때 가장 추상적인 형태인 Object 타입으로만 꺼내도록 강제한다.
제너릭 메서드와 와일드카드 메서드의 차이점
두 방식은 겉보기에는 비슷해 보이지만, 타입 간의 관계를 어떻게 다루느냐에서 차이가 있다.
우선 제너릭 메서드(<T>)는 메서드 내부에서 동일한 타입을 여러 번 참조하거나, 반환 타입과 매개변수 타입이 일치해야 할 때 사용한다.
// dest와 src 두 리스트의 타입(T)이 정확히 일치해야 함을 강제함
public <T> void copyList(List<T> dest, List<T> src) {
// ...
}
// 들어온 리스트의 타입(T)과 반환되는 타입(T)이 같아야 할 때 사용
public <T> T getFirstElement(List<T> list) {
return list.get(0);
}
반면 와일드카드(?)는 메서드가 특정 타입에 의존하지 않을 때 사용한다. 리스트 안에 무슨 타입이 있든 상관 없이 그냥 길이를 구하거나 내부 요소 출력만 할 때 적합하다.
// 리스트 안의 요소 타입은 상관없이 List의 기본 기능(size)만 사용함
public void printSize(List<?> list) {
System.out.println("크기: " + list.size());
}
[참고자료]
- Java 공식 문서 - Generics
Lesson: Generics (Updated) (The Java™ Tutorials > Learning the Java Language)
The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Dev.java for updated tutorials taking advantag
docs.oracle.com
- Inpa dev - 자바 제네릭(Generics) 개념 & 문법 정복하기
☕ 자바 제네릭(Generics) 개념 & 문법 정복하기
제네릭 (Generics) 이란 자바에서 제네릭(Generics)은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다. 객체별로 다른 타입의 자료가 저장될 수 있도록 한다. 자바에서 배
inpa.tistory.com
'CS > Java' 카테고리의 다른 글
| [Java]Comparable과 Comparator (0) | 2026.08.01 |
|---|---|
| [Java]Lambda 와 Stream (0) | 2026.07.31 |
| Java로 멀티 쓰레드 찍먹하기 (0) | 2026.02.21 |