일단 Integer, String 타입을 담고 출력하는 코드를 보도록 하자.
package generic.ex1;
public class IntegerBox {
private Integer value;
public void setValue(Integer value) {
this.value = value;
}
public Integer getValue() {
return value;
}
}
package generic.ex1;
public class StringBox {
private String value;
public void setValue(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
package generic.ex1;
public class BoxMain1 {
public static void main(String[] args) {
IntegerBox integerBox = new IntegerBox();
integerBox.setValue(10);
Integer integer = integerBox.getValue();
System.out.println("integer = " + integer);
StringBox stringBox = new StringBox();
stringBox.setValue("hello");
String string = stringBox.getValue();
System.out.println("string = " + string);
}
}
/*
integer = 10
string = hello
*/
보다시피 간단한 코드다. IntergerBox는 정수 10을 저장하고 출력하고 있다. StringBox도 문자열 “hello” 를 저장하고 출력하고 있다. 물론 IntegerBox의 경우에는 오토 박싱에 의해 int형 10은 Integer 타입으로 자동 변환된다. 근데 여기서 Integer와 String 이외의 타입을 더 담고 싶다면 어떡하지? 그에 해당하는 클래스를 다 만들어야 하나?
모든 타입을 다 받을 수 있는 방법… 바로 Object 클래스가 떠올랐다. 다형적 참조를 이용해 해결해보자.
package generic.ex1;
public class ObjectBox {
private Object value;
public void setValue(Object value) {
this.value = value;
}
public Object getValue() {
return value;
}
}
Object는 모든 타입의 부모이기 때문에, 자바의 모든 타입을 ObjectBox에 보관할 수 있다. 이제 그 어떤 타입이 와도 두렵지 않다.
package generic.ex1;
public class BoxMain2 {
public static void main(String[] args) {
ObjectBox integerBox = new ObjectBox();
integerBox.setValue(10);
Integer integer = (Integer) integerBox.getValue();
System.out.println("integer = " + integer);
ObjectBox stringBox = new ObjectBox();
stringBox.setValue("hello");
String string = (String) stringBox.getValue();
System.out.println("string = " + string);
// 잘못된 타입의 인수 전달
integerBox.setValue("문자를 넣는다면...?");
Integer result = (Integer) integerBox.getValue();
System.out.println("result = " + result);
}
}
/*
integer = 10
string = hello
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer
(java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')
at generic.ex1.BoxMain2.main(BoxMain2.java:18)
*/
보다시피, 모든 타입을 담을 수 있지만… integerBox.getValue()로 값을 불러올 때 문제가 생길 수 있다. integerBox.getValue()의 반환 타입은 보다시피 Object다. 따라서 반드시 Object 타입을 자식 타입으로 다운 캐스팅 해주는 과정이 필요하다. 그리고 개발자가 실수로 잘못된 타입의 인수를 전달해도 문제가 발생한다. setValue()로 값을 설정할 때는 Object를 매개 변수로 받기 때문에 전혀 문제가 없지만, 값을 꺼낼 때는 얘기가 다르다. 위의 예시에서는 “문자를 넣는다면…?” 이라는 문자열을 받았는데, 문자열을 Integer로 다운 캐스팅한다? 바로 ClassCastException이 터지고 프로그램은 종료된다.
결국 Object 클래스로 코드의 중복을 제거하고, 재사용성이 개선되기는 했으나, 개발자가 실수로 다른 타입의 값을 입력하면 타입 안전성 문제가 발생할 수 있다. 그리고 반환 시점에도 Object로 반환되기 때문에 원하는 타입으로 정확하게 받을 수는 없고, 항상 불안한 다운 캐스팅을 수행해야 한다. 이런 방식으로는 코드 재사용과 타입 안전성이라는 2마리 토끼를 모두 잡을 수 없다.
현재 상황을 요약해보자면…
BoxMain1의 경우: 타입별로 Integer, String과 같이 클래스를 모두 정의해야 하므로 타입 안전성은 확보됐지만, 코드의 재사용성은 떨어진다.BoxMain2의 경우: Object 클래스를 이용해서 코드의 중복은 현저하게 줄었지만, 타입 안전성이 확보되지 못한다.위의 문제점을 제네릭을 이용하면 원큐에 해결 가능하다. 아래 코드를 보자.
package generic.ex1;
public class GenericBox<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
<>를 사용한 클래스를 제네릭 클래스라고 한다. 제네릭 클래스를 사용할 때는 Integer나 String과 같은 타입을 미리 결정하지 않고, 나중에 객체를 생성할 때 T가 알맞은 타입으로 바뀐다. 여기서 T를 타입 매개 변수라고 한다.
package generic.ex1;
public class BoxMain3 {
public static void main(String[] args) {
// 객체 생성 시점에 T의 타입이 결정된다.
GenericBox<Integer> integerBox = new GenericBox<Integer>();
integerBox.set(10);
// integerBox.set("문자"); // Integer 타입만 허용, 컴파일 오류
Integer integer = integerBox.get(); // Integer 타입 변환, 캐스팅 할 필요 없음.
System.out.println("integer = " + integer);
GenericBox<String> stringBox = new GenericBox<String>();
stringBox.set("hello");
String string = stringBox.get();
System.out.println("string = " + string);
// 원하는 모든 타입 사용 가능
GenericBox<Double> doubleBox = new GenericBox<Double>();
doubleBox.set(3.14);
Double doubleValue = doubleBox.get();
System.out.println("doubleValue = " + doubleValue);
// 생성하는 제네릭 타입 생략 가능
GenericBox<Integer> integerBox2 = new GenericBox<>();
}
}
/*
integer = 10
string = hello
doubleValue = 3.14
*/
객체의 생성 시점에서 <> 안에 원하는 타입을 지정해준 것을 볼 수 있다. GenericBox<Integer> integerBox = new GenericBox<Integer>()라고 입력하면 T가 모두 Integer로 변하는 것이다. 따라서 Integer 타입을 입력하고 조회할 수 있다. 위의 코드에서 integerBox.set("문자")처럼 다른 타입을 넣는다면 컴파일러가 꾸짖어 준다.

또한, integerBox.get()처럼 값을 반환 받을 때도 알아서 Integer를 반환하기 때문에 일일이 다운 캐스팅을 해줄 필요도 없다.
“이처럼 제네릭의 핵심은 사용할 타입을 미리 결정하지 않는다는 것이다. 실제 사용하는 생성 시점에 타입을 결정한다.”
제네릭을 도입한다고 해서 GenericBox<Integer>, GenericBox<String>와 같은 코드가 실제로 만들어지는 것은 아니다. 대신, 자바 컴파일러가 내가 입력한 타입 정보를 기반으로 이런 코드가 있다고 가정하고 컴파일 과정에 타입 정보를 반영하는 것이다. 이 과정에서 타입이 맞지 않으면 컴파일 오류가 발생한다. 그리고 위의 코드에서는 GenericBox<Integer> integerBox = new GenericBox<Integer>()와 같은 식으로 <Integer>를 2번 써 넣었는데, 자바는 왼쪽에 있는 변수를 선언할 때의 <Integer>를 보고 오른쪽에 있는 객체를 생성할 때 필요한 타입 정보를 얻을 수 있기 때문에 오른쪽의 <Integer>는 생략해도 무방하다. 이런 식으로 자바가 스스로 타입 정보를 추론해서 개발자가 타입 정보를 생략할 수 있게 해주는 것을 타입 추론이라고 한다.
위에서 봤다시피, 제네릭의 핵심은 “GenericBox라는 걸 생성해서 사용하는 시점에 타입 매개 변수를 결정할거다!” 였다. 이 “결정” 을 미래로 미루는 것이 포인트라고 했다.
제네릭은 메서드의 매개 변수와 인자의 관계와 비슷하게 작동한다. 차이가 있다면, 메서드의 매개 변수는 “사용할 값에 대한 결정” 을 나중으로 미루는 것이고, 제네릭의 타입 매개 변수는 “사용할 타입에 대한 결정” 을 나중으로 미루는 것이다.
제네릭에서 사용하는 용어도 매개 변수, 인자의 용어를 그대로 사용한다. 다만 타입을 결정하는 것이기에 앞에 타입을 붙일 뿐이다.
GenericBox<T>에서 TGenericBox<Integer>에서 IntegerGenericBox<String>에서 String그리고 제네릭 타입의 타입 매개 변수 <T>에 타입 인자를 전달해서 제네릭의 사용 타입을 결정한다.
GenericBox<T>Integer → GenericBox<Integer>String → GenericBox<String>
<용어 정리>
제네릭 타입(Generic Type)타입 매개 변수(Type Parameter)타입 인자(Type Argument)
타입 매개 변수는 일반적인 변수명처럼 소문자로 사용해도 크게 문제는 없지만, 일반적으로는 대문자를 사용하고 용도에 맞는 단어의 첫글자를 사용한다.
E - ElementK - KeyN - NumberT - TypeV - ValueS, U, V 기타 등등 - 그 외의 경우
제네릭을 사용할 때, 한번에 여러 타입 매개 변수를 선언할 수도 있다. 그리고 제네릭의 타입 인자로 기본형은 사용할 수 없는 대신, 래퍼 클래스를 사용하면 된다.