![[Java 10] 제네릭](https://img.thenullpage.com/posts/6454/6454_1_416a32.webp)
제네릭은 타입을 나중에 정하도록 비워 두는 장치다. List<String>, Map<String, Integer>의 꺾쇠 안에 들어가는 것이 제네릭이다. List는 목록이라는 구조만 정하고 무엇을 담을지는 쓸 때 채운다. 하나의 구조를 여러 타입에 재사용하면서도 각 자리에서 타입이 또렷하게 고정돼, 코드 재사용과 타입 안전을 동시에 얻는다. 제네릭은 자바 5부터 들어왔다.
제네릭이 없던 시절은 위험했다. 그 전에는 컬렉션이 모든 것을 Object로 담아, 꺼낼 때마다 형변환을 손으로 했고 엉뚱한 타입을 넣어도 컴파일러가 못 막았다. 문제는 꺼내 변환하는 순간에야 예외로 터졌다. 제네릭은 이 위험을 컴파일 단계로 끌어올린다. 잘못된 타입은 넣는 순간 에러가 되고 형변환도 필요 없다.
List raw = new ArrayList();
raw.add("hello");
raw.add(42); // 섞여도 안 막힘
String s = (String) raw.get(1); // 실행 중 ClassCastException
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(42); // 컴파일 에러
String t = list.get(0); // 형변환 불필요
제네릭 클래스는 직접 만들 수 있다. 클래스 이름 뒤에 <T>를 붙이면 T는 나중에 정해질 어떤 타입의 이름표가 된다. 아래 Box는 무엇을 담을지 쓸 때 정하는 상자다.
public class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
Box<String> b = new Box<>();
b.set("hello");
String v = b.get(); // 형변환 없이 String
타입 파라미터 이름에는 관례가 있다. 일반 타입은 T, 컬렉션 원소는 E, 맵의 키와 값은 K와 V, 반환 타입은 R을 쓴다. 한 글자 대문자가 보통이다. 문법이 강제하진 않지만 온 세상 자바 코드가 이 약속을 따르므로 어기면 읽기 불편하다.
제네릭 메서드는 메서드 단위로 타입을 비운다. 반환 타입 앞에 <T>를 적어 선언한다. 넘긴 인자를 보고 컴파일러가 T를 알아서 추론한다.
public static <T> T firstOf(List<T> list) {
return list.get(0);
}
String first = firstOf(List.of("a", "b")); // T는 String
Integer n = firstOf(List.of(1, 2, 3)); // T는 Integer
바운디드 타입은 비운 타입에 조건을 건다. <T extends Number>라 쓰면 T는 Number이거나 그 자식만 된다. 상한을 두면 그 상한이 보장하는 기능을 믿고 쓸 수 있다. 아무 타입이나 받으면 아무것도 못 하지만, 상한이 있으면 doubleValue 같은 메서드를 부를 수 있다.
public static <T extends Number> double sum(List<T> nums) {
double total = 0;
for (T n : nums) total += n.doubleValue();
return total;
}
제네릭끼리는 상속 관계가 이어지지 않는다. String은 Object의 자식이지만 List<String>은 List<Object>의 자식이 아니다. 허용하면 문자열 목록에 정수를 넣는 짓이 가능해져 타입 안전이 무너지기 때문이다. 이 성질을 무공변이라 부르고, 다음의 와일드카드가 이 경직됨을 풀어 준다.
와일드카드는 여러 타입을 유연하게 받는다. 물음표 ?는 어떤 타입인지 모르지만 무언가를 뜻한다. List<? extends Number>는 Number나 그 자식을 담은 목록을, List<? super Integer>는 Integer나 그 부모를 담은 목록을 받아 무공변의 경직됨을 푼다.
public static double total(List<? extends Number> nums) {
double sum = 0;
for (Number n : nums) sum += n.doubleValue();
return sum;
}
total(List.of(1, 2, 3)); // List<Integer> OK
total(List.of(1.5, 2.5)); // List<Double> OK
읽기냐 쓰기냐로 extends와 super를 가른다. 값을 꺼내 읽기만 하면 ? extends, 값을 넣기만 하면 ? super를 쓴다. 흔히 "생산자는 extends, 소비자는 super"로 외운다. 처음엔 억지스러워도 몇 번 겪으면 몸에 익는다.
타입 소거는 자바 제네릭의 독특한 한계다. 제네릭 정보는 컴파일할 때만 쓰이고 끝나면 지워진다. 그래서 실행 중에는 List<String>과 List<Integer>가 구분되지 않고 둘 다 그냥 List다. 제네릭이 없던 옛 코드와 섞여 돌아가야 했기에 택한 하위 호환용 설계다. 이 때문에 new T()나 new T[10]으로 객체와 배열을 만들 수 없고, instanceof로 제네릭 타입까지 확인할 수 없으며, List<String>과 List<Integer>로만 다른 오버로딩도 안 된다. 소거하면 서명이 같아지기 때문이다.
다이아몬드 연산자가 반복을 줄여 준다. 자바 7부터 오른쪽 꺾쇠를 비워 new HashMap<>()라고만 써도 왼쪽을 보고 타입을 채운다. 긴 제네릭 타입에서 코드가 눈에 띄게 짧아진다. 요즘은 이 다이아몬드가 기본이라 오른쪽에 타입을 반복하면 낡은 코드처럼 보인다.
날것의 제네릭 타입은 쓰지 말아야 한다. 꺾쇠를 아예 뺀 List를 날것의 타입이라 한다. 옛 호환을 위해 허용되지만 타입 안전이 통째로 사라져 옛 위험으로 되돌아간다. 컴파일러 경고를 무시하지 말고, 정말 아무거나 담아야 하면 날것 대신 List<?> 와일드카드를 쓴다. 새 코드에서 날것은 쓸 이유가 없다.
일상적인 사용은 생각보다 단순하다. 와일드카드와 소거 같은 깊은 부분을 봤지만, 대부분의 순간은 List<String>을 선언하고 Map<String, User>를 다루는 정도다. 직접 제네릭 클래스를 설계하거나 와일드카드를 고민하는 일은 라이브러리를 만들 때나 나온다. 매일 쓰는 부분부터 손에 익히는 게 순서다.
다음 편에서는 예외 처리를 다룬다. 타입을 안전하게 다루는 법을 봤으니 다음은 프로그램이 어긋났을 때의 예외다. 확인 예외와 미확인 예외의 구분, try-catch-finally의 흐름, 자원을 닫는 try-with-resources, 우리만의 예외를 만드는 법을 코드로 살핀다. Go의 값 반환, 러스트의 결과 타입과 대비되는 자바의 방식이라 언어 비교의 좋은 소재이기도 하다.