List를 처음 쓰다 보면 이런 코드를 만나게 된다.
List<String> names = new ArrayList<>();
<String>이 뭔지 모르고 그냥 따라 쓰다가, 어느 순간 "이게 왜 있는 거지?"라는 의문이 생긴다. 이게 바로 제네릭(Generics)이다.
제네릭이 생기기 전에는 컬렉션에 뭘 담든 Object 타입으로 처리했다.
List list = new ArrayList();
list.add("안녕");
list.add(123); // 문자열이든 숫자든 다 들어간다
String name = (String) list.get(0); // 꺼낼 때 직접 형변환해야 한다
String wrong = (String) list.get(1); // 런타임 오류 — 123은 String이 아니다
어떤 타입이든 넣을 수 있다 보니 꺼낼 때 형변환이 필요했고, 잘못된 타입을 넣어도 컴파일 시점에 오류가 나지 않았다. 실행해봐야 터지는 오류는 찾기도 어렵고 고치기도 까다롭다.
제네릭은 이 문제를 해결하기 위해 등장했다.
타입을 <> 안에 명시해서 어떤 타입만 담을 수 있는지 컴파일러에게 알려준다.
List<String> names = new ArrayList<>();
names.add("김민수");
names.add("이지현");
names.add(123); // 컴파일 오류 — String만 들어갈 수 있다
String name = names.get(0); // 형변환 불필요
타입이 고정되니까 꺼낼 때 형변환도 필요 없고, 잘못된 타입을 넣으면 실행 전에 오류를 잡아준다.

직접 제네릭 클래스를 만들 수도 있다. 타입 파라미터는 보통 T(Type), E(Element), K(Key), V(Value) 같은 대문자 한 글자를 관례로 쓴다.
public class Box<T> {
private T item;
public void set(T item) {
this.item = item;
}
public T get() {
return item;
}
}
T는 실제 타입이 들어올 자리를 표시하는 placeholder다. 사용할 때 타입을 지정하면 그 자리에 들어간다.
Box<String> strBox = new Box<>();
strBox.set("안녕");
String value = strBox.get(); // 형변환 없이 String으로 받는다
Box<Integer> intBox = new Box<>();
intBox.set(42);
Integer num = intBox.get();
Box<String>과 Box<Integer>는 같은 클래스지만 다른 타입으로 동작한다.
메서드 단위로도 제네릭을 적용할 수 있다. 반환 타입 앞에 <T>를 붙인다.
public static <T> void print(T item) {
System.out.println(item);
}
print("안녕"); // String
print(42); // Integer
print(3.14); // Double
타입에 관계없이 동작하는 유틸리티 메서드를 만들 때 유용하다.
<T> 자리에 아무 타입이나 오지 못하도록 상한선을 걸 수 있다.
// T는 Number 또는 Number의 하위 클래스만 가능
public static <T extends Number> double sum(List<T> list) {
double total = 0;
for (T item : list) {
total += item.doubleValue();
}
return total;
}
sum(List.of(1, 2, 3)); // Integer — 가능
sum(List.of(1.1, 2.2, 3.3)); // Double — 가능
sum(List.of("a", "b")); // 컴파일 오류 — String은 Number가 아니다
doubleValue()는 Number 클래스의 메서드다. <T extends Number>로 제한해야 T가 doubleValue()를 가지고 있다고 컴파일러가 보장해준다.
제네릭 타입을 정확히 특정하지 않고 "어떤 타입이든" 받고 싶을 때 ?를 쓴다.
// 어떤 타입의 List든 받아서 출력
public static void printList(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
}
printList(List.of("a", "b", "c")); // List<String>
printList(List.of(1, 2, 3)); // List<Integer>
List<Object>는 List<String>을 받을 수 없다. 제네릭은 상속 관계를 따르지 않기 때문이다. 이때 List<?>를 쓰면 어떤 타입의 리스트든 받을 수 있다.
제네릭은 처음엔 <>가 낯설게 느껴지지만, 결국 "이 컨테이너에 어떤 타입을 담을 건지 미리 알려주는 것"이다. 타입을 명시하는 순간 형변환도 사라지고, 잘못된 타입을 넣는 실수도 컴파일 시점에 잡힌다.