자바 제네릭(Generics)은 자바 언어에서 가장 중요한 기능 중 하나입니다. 제네릭의 핵심 아이디어는 매우 단순하지만, 기존 문법과는 다른 형태로 작성되다 보니 때때로 복잡하게 느껴질 수 있습니다.
이 튜토리얼은 제네릭이라는 유용한 개념을 누구나 이해하기 쉽도록 소개하는 것을 목표로 합니다.
본격적으로 제네릭을 살펴보기에 앞서, 자바에 제네릭이 왜 필요했는지 그 이유부터 짚어보겠습니다.
자바 제네릭의 목적
자바 5에서 제네릭이 도입되기 전에는 다음과 같은 코드를 오류나 경고 없이 작성하고 컴파일할 수 있었습니다.
List list = new ArrayList();
list.add("hey");
list.add(new Object());
리스트나 다른 자바 컬렉션에 저장될 데이터 타입을 선언하지 않은 채 어떤 타입의 값이든 추가할 수 있었지만, 값을 꺼낼 때는 해당 값을 특정 타입으로 명시적으로 형 변환(casting)해야 했습니다.
위 리스트를 순회하는 경우를 생각해 봅시다.
for (int i=0; i< list.size(); i++) {
String value = (String) list.get(i); //i=1일 때 ClassCastException 발생
}
이처럼 저장될 데이터 타입을 미리 선언하지 않고 리스트를 생성하도록 허용하면, 프로그래머가 위와 같은 실수를 저지르기 쉽고 실행 시점에 ClassCastException이 발생할 수 있습니다.
제네릭은 바로 이런 실수를 방지하기 위해 도입되었습니다.
제네릭을 사용하면 아래 예시처럼 컬렉션을 생성할 때 저장될 데이터 타입을 명시적으로 선언할 수 있습니다.
참고: 여전히 저장될 데이터 타입을 지정하지 않고 자바 컬렉션 객체를 생성할 수 있지만, 권장되지 않습니다.
List<String> stringList = new ArrayList<>();
이제 String 타입 리스트에 Integer를 잘못 저장하려 하면 컴파일 타임 오류가 발생합니다. 이를 통해 프로그램이 런타임 오류에 빠지는 것을 사전에 막을 수 있습니다.
stringList.add(new Integer(4)); //컴파일 타임 에러
자바에 제네릭이 도입된 주된 목적은 런타임에 ClassCastException이 발생하는 상황을 피하는 것이었습니다.
자바 제네릭 만들기
제네릭은 자바 클래스와 메서드를 만들 때 활용할 수 있습니다. 각 유형별로 제네릭을 생성하는 방법을 예제와 함께 살펴보겠습니다.
제네릭 클래스(Generic Class)
제네릭 클래스를 만들 때는 클래스 이름 뒤에 꺾쇠 괄호 <> 안에 타입 매개변수를 추가합니다.
public class GenericClass<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return this.item;
}
}
여기서 T가 데이터 타입 매개변수입니다. T, N, E 등은 자바 관례에 따라 데이터 타입 매개변수로 흔히 사용되는 문자들입니다.
위 예제에서는 GenericClass 객체를 생성할 때 구체적인 데이터 타입을 지정할 수 있습니다.
public static void main(String[] args) {
GenericClass<String> gc1 = new GenericClass<>();
gc1.setItem("hello");
String item1 = gc1.getItem(); // "hello"
gc1.setItem(new Object()); //오류
GenericClass<Integer> gc2 = new GenericClass<>();
gc2.setItem(new Integer(1));
Integer item2 = gc2.getItem(); // 1
gc2.setItem("hello"); //오류
}
제네릭 클래스 객체를 생성할 때 타입 매개변수에는 기본형(primitive type)을 전달할 수 없습니다. Object 타입을 상속하는 참조 타입만 타입 매개변수로 사용할 수 있습니다.
예를 들면 다음과 같습니다.
GenericClass<float> gc3 = new GenericClass<>(); //오류
제네릭 메서드(Generic Method)
제네릭 메서드를 만드는 방식은 제네릭 클래스와 비슷합니다. 제네릭 메서드는 제네릭 클래스 내부뿐 아니라 일반 클래스 안에서도 구현할 수 있습니다.
public class GenericMethodClass {
public static <T> void printItems(T[] arr){
for (int i=0; i< arr.length; i++) {
System.out.println(arr[i]);
}
}
public static void main(String[] args) {
String[] arr1 = {"Cat", "Dog", "Mouse"};
Integer[] arr2 = {1, 2, 3};
GenericMethodClass.printItems(arr1); // "Cat", "Dog", "Mouse"
GenericMethodClass.printItems(arr2); // 1, 2, 3
}
}
여기서는 특정 타입의 배열을 전달하여 메서드를 파라미터화할 수 있습니다. 제네릭 메서드 printItems()는 전달받은 배열을 순회하며 저장된 항목들을 일반 자바 메서드처럼 출력합니다.
한정된 타입 매개변수(Bounded Type Parameters)
지금까지 만든 제네릭 클래스와 메서드는 기본형을 제외한 모든 데이터 타입으로 파라미터화할 수 있었습니다. 그렇다면 제네릭에 전달할 수 있는 데이터 타입을 제한하고 싶을 때는 어떻게 해야 할까요? 이때 한정된 타입 매개변수(bounded type parameters)가 사용됩니다.
특정 데이터 타입의 하위 클래스여야 한다고 지정함으로써, 제네릭 클래스나 메서드가 받아들이는 데이터 타입에 제한을 둘 수 있습니다.
예를 들면 다음과 같습니다.
//List의 하위 클래스만 허용
public class UpperBoundedClass<T extends List>{
//List의 하위 클래스만 허용
public <T extends List> void UpperBoundedMethod(T[] arr) {
}
}
여기서 UpperBoundedClass와 UpperBoundedMethod는 List 데이터 타입의 하위 타입으로만 파라미터화할 수 있습니다.
List 데이터 타입은 타입 매개변수의 상한(upper bound) 역할을 합니다. List의 하위 타입이 아닌 데이터 타입을 사용하려 하면 컴파일 타임 오류가 발생합니다.
상한은 클래스에만 국한되지 않으며, 인터페이스도 전달할 수 있습니다. 이 경우 인터페이스를 확장한다는 것은 곧 인터페이스를 구현한다는 의미입니다.
타입 매개변수는 아래 예시처럼 여러 개의 상한을 가질 수도 있습니다.
//Mammal과 Animal 양쪽 모두의 하위 클래스만 허용
public class MultipleBoundedClass<T extends Mammal & Animal>{
//Mammal과 Animal 양쪽 모두의 하위 클래스만 허용
public <T extends Mammal & Animal> void MultipleBoundedMethod(T[] arr){
}
}
허용되는 데이터 타입은 Animal과 Mammal 양쪽 모두의 하위 타입이어야 합니다. 상한 중 하나가 클래스라면, 그 클래스가 상한 선언에서 반드시 먼저 나와야 합니다.
위 예제에서 Mammal이 클래스이고 Animal이 인터페이스라면, 위와 같이 Mammal이 먼저 위치해야 합니다. 그렇지 않으면 컴파일 타임 오류가 발생합니다.
자바 제네릭 와일드카드(Wildcards)
와일드카드는 제네릭 타입의 파라미터를 메서드에 전달할 때 사용됩니다. 제네릭 메서드와 달리, 여기서는 제네릭 파라미터가 메서드가 받아들이는 파라미터에 전달된다는 점이 앞서 살펴본 데이터 타입 매개변수와 다릅니다. 와일드카드는 물음표(?) 기호로 표현합니다.
public void printItems(List<?> list) {
for (int i=0; i< list.size(); i++) {
System.out.println(list.get(i));
}
}
위 printItems() 메서드는 어떤 데이터 타입의 리스트든 파라미터로 받아들입니다. 덕분에 프로그래머는 서로 다른 데이터 타입의 리스트마다 코드를 반복해서 작성할 필요가 없습니다. 제네릭이 없었다면 타입별로 코드를 중복 작성해야 했을 것입니다.
상한 와일드카드(Upper Bounded Wildcards)
메서드가 받아들이는 리스트에 저장될 데이터 타입을 제한하고 싶다면 한정된 와일드카드를 사용할 수 있습니다.
예시는 다음과 같습니다.
public void printSubTypes(List<? extends Color> list) {
for (int i=0; i< list.size(); i++) {
System.out.println(list.get(i));
}
}
printSubTypes() 메서드는 Color의 하위 타입을 저장하는 리스트만 받아들입니다. RedColor나 BlueColor 객체의 리스트는 받아들이지만, Animal 객체의 리스트는 받아들이지 않습니다. Animal은 Color의 하위 타입이 아니기 때문입니다. 이것이 상한 와일드카드의 대표적인 예입니다.
하한 와일드카드(Lower Bounded Wildcards)
반대로 다음과 같은 메서드가 있다면,
public void printSuperTypes(List<? super Dog> list) {
for (int i=0; i< list.size(); i++) {
System.out.println(list.get(i));
}
}
printSuperTypes() 메서드는 Dog 클래스의 상위 타입을 저장하는 리스트만 받아들입니다. Mammal이나 Animal 객체의 리스트는 받아들이지만, LabDog 객체의 리스트는 받아들이지 않습니다. LabDog은 Dog의 상위클래스가 아니라 하위클래스이기 때문입니다. 이것이 하한 와일드카드의 예입니다.
결론
자바 제네릭은 도입된 이후 프로그래머들이 없이는 살기 어려운 필수 기능이 되었습니다.
이러한 인기는 프로그래머의 생산성을 높이는 데 크게 기여했기 때문입니다. 코딩 실수를 방지할 뿐만 아니라, 제네릭을 사용하면 코드 중복도 줄어듭니다. 서로 다른 데이터 타입마다 같은 코드를 반복 작성하지 않도록 클래스와 메서드를 일반화하는 모습을 눈치채셨나요?
제네릭을 깊이 이해하는 것은 자바 전문가로 성장하는 데 중요한 밑거름이 됩니다. 이제 이 튜토리얼에서 배운 내용을 실제 프로젝트 코드에 직접 적용해 보며 실력을 다져보세요.