[Java 10] 제네릭


제네릭은 타입을 나중에 정하도록 비워 두는 장치다. 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, 맵의 키와 값은 KV, 반환 타입은 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>라 쓰면 TNumber이거나 그 자식만 된다. 상한을 두면 그 상한이 보장하는 기능을 믿고 쓸 수 있다. 아무 타입이나 받으면 아무것도 못 하지만, 상한이 있으면 doubleValue 같은 메서드를 부를 수 있다.

public static <T extends Number> double sum(List<T> nums) {

double total = 0;

for (T n : nums) total += n.doubleValue();

return total;

}


제네릭끼리는 상속 관계가 이어지지 않는다. StringObject의 자식이지만 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의 값 반환, 러스트의 결과 타입과 대비되는 자바의 방식이라 언어 비교의 좋은 소재이기도 하다.