자바에서 제네릭(generic)이란 데이터의 타입(data type)을 일반화(generalize) 한다는 것을 의미합니다. 제네릭은 클래스나 메소드에서 사용할 내부 데이터 타입을 컴파일 시에 미리 지정하는 방법입니다.
이렇게 컴파일 시에 미리 타입 검사(type check)를 수행하면 다음과 같은 장점을 가집니다.
아래에서 더욱 자세하게 알아볼게요.
제네릭은 자바5 부터 새롭게 추가된 타입입니다. 해당 타입을 이용함으로써 잘못된 타입이 사용될 수 있는 문제를 컴파일 과정에서 제거할 수 있게 되었습니다. 예를 들면 이런 것이죠.
List<Youtube> youtubeList = new ArrayList<Youtube>();
youtubeList.add(new Youtube());
youtubeList.add(new DisneyPlus()); // 컴파일 에러 발생. Youtube 외에 다른 타입 저장불가
위에서 ArrayList로 선언한 youtubeList는 Youtube 객체에 대한 목록만 저장하는 list 형태이죠. 하지만 두번째 add에서 DisneyPlus 객체를 넣으려는 시도를 했기 때문에 컴파일 에러가 발생합니다.
제네릭은 클래스와 인터페이스, 그리고 메소드를 정의할 때 타입(Type)을 파라미터(Parameter)로 사용할 수 있도록 합니다. 보통 자바 표준 API문서를 보면 제네릭 표현이 많기 때문에 이것을 이해하지 못하면 표준 API 문서를 이해하기 어렵습니다. 그만큼 중요한 개념이죠.
제네릭을 사용하는 코드는 다음과 같은 이점을 가지고 있습니다. 중요한 개념이기 때문에 또 한번 볼게요.
예외처리에서도 배웠지만 실행 시 타입 에러가 발생하는 것 보다 컴파일 단계에서 타입 오류를 발견하는게 에러 방지에 유리합니다. 자바 컴파일러는 코드에서 잘못 사용된 타입 때문에 발생하는 문제점을 제거하기 위해 제네릭 코드에 대해 강한 타입 체크를 합니다. 체크를 해서 에러를 사전에 방지하는 것이죠.
다음 코드를 보면, 제네릭이 적용되지 않은 ArrayList객체는 불필요한 타입 변환을 해줘야합니다. List에 문자열 요소를 저장했지만, 요소를 찾아올 때에는 list.get(i) 타입이 Object이므로 반드시 String으로 타입 변환을 해야하는것이죠.
List list = new ArrayList();
list.add("Hello");
String str = (String) list.get(0);
다음과 같이 제네릭 코드로 수정하면 List에 저장되는 요소가 String 타입으로 정해지기 때문에 요소를 찾아올 때 타입 변환을 할 필요가 없습니다. 변환을 거치지 않기 때문에 성능이 향상되죠.
List<String> list = new ArrayList<String>();
list.add("Hello");
String str = list.get(0);
제네릭 타입은 타입을 파라미터로 가지는 클래스와 인터페이스를 말합니다. 클래스 또는 인터페이스 뒤에 <> 부호가 붙고, 사이에 타입 파라미터가 위치합니다.
public class 클래스명<T> { ... }
public interface 인터페이스명<T> { ... }
타입 파라미터는 변수명과 동일한 규칙에 따라 작성할 수 있지만, 일반적으로 대문자 알파벳 한 글자 T(Type)로 표현합니다. 실제 코드에서 제네릭 타입을 사용하려면 타입 파라미터에 구체적인 타입을 지정해야합니다.
그렇다면 왜 이런 타입 파라미터를 사용할까요? 이유를 알기 위해 아래 Box 클래스를 살펴봅시다.
public class Box {
private Object object;
public void set(Object object) {
this.object = object;
}
public Object get() {
return object;
}
}
Box 클래스의 필드 타입이 Object인데, Object 타입으로 선언한 이유는 필드에 모든 종류의 객체를 저장하고싶어서 입니다. Object 클래스는 모든 자바 클래스의 최상위 부모 클래스죠. 따라서 자식 객체는 부모 타입에 대입할 수 있다는 성질 때문에 모든 자바 객체는 Object 타입으로 자동 타입 변환되어 저장됩니다.
Object object = 자바의 모든 객체;
set() 메소드는 매개 변수 타입으로 Object를 사용하면서 모든 객체를 매개변수로 받을 수 있게 했고, 받은 매개변수값을 Object 필드에 저장시킵니다. 그리고 get() 메소드는 Object 필드에 저장된 객체를 Object 타입으로 리턴합니다. 만약 필드에 저장된 원래 타입의 객체를 얻으려면 다음과 같이 강제 타입 변환을 해야합니다.
Box box = new Box();
box.set("hello"); // String 타입을 Object 타입으로 자동 타입 변환해서 저장
String str = (String) box.get(); // Object 타입을 String 타입으로 강제 타입 변환해서 얻음
그렇다면 모든 종류의 객체를 저장하면서 타입 변환이 발생하지 않도록 하는 방법이 있을까요? 해결책은 제네릭 입니다. 다음 코드는 제네릭을 이용해서 Box 클래스를 수정한 것 입니다.
public class Box<T> {
private T t;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
}
타입 파라미터 T를 사용해서 Object 타입을 모두 T로 대체했습니다. T는 Box 클래스로 객체를 생성할 때 구체적인 타입으로 변경됩니다. 예를 들어 다음과같이 Box 객체를 생성했다고 가정해봅시다.
Box<String> box = new Box<String>();
타입 파라미터 T는 String 타입으로 변경되어 Box 클래스의 내부는 다음과 같이 자동으로 재구성됩니다.
public class Box<String> {
private String t;
public String get() {
return t;
}
public void set(String t) {
this.t = t;
}
}
필드 타입이 String으로 변경되었고, set() 메소드도 String 타입만 매개값으로 받을 수 있게 변경되었습니다. 그리고 get() 메소드 역시 String 타입으로 리턴하도록 변경되었습니다. 그래서 다음 코드를 보면 저장할 때와 읽어올 때 전혀 타입 변환이 발생하지 않습니다.
Box<String> box = new Box<String>();
box.set("hello");
String str = box.get();
이번에는 다음과 같이 Box 객체를 생성했다고 해봅시다. 참고로 Integer는 int값에 대한 객체 타입으로 자바에서 제공해주는 표준 API 입니다.
Box<Integer> box = new Box<Integer>();
타입 파라미터 T는 Integer 타입으로 변경되어 Box 클래스는 내부적으로 다음과 같이 자동으로 재구성됩니다.
public class Box<Integer> {
private Integer t;
public Integer get() {
return t;
}
public void set(Integer t) {
this.t = t;
}
}
필드 타입이 Integer로 변경되었고, set()메소드도 Integer 타입만 매개값으로 받을 수 있게 변경되었습니다. 그리고 get() 메소드 역시 Integer 타입으로 리턴하도록 변경되었습니다.
그래서 다음 코드를 보면 저장할 때와 읽어올 때 전혀 타입 변환이 발생하지 않습니다.
Box<Integer> box = new Box<Integer>();
box.set(6); // 자동 Boxing
int value = box.get(); // 자동 Unboxing
이와같이 제네릭은 클래스를 설계할 때 구체적인 타입을 명시하지 않고, 타입 파라미터로 대체합니다. 그리고 실제 클래스가 사용될 때 구체적인 타입을 지정함으로써 타입 변환을 최소화시킵니다.
public class Box<T> {
private T t;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
}
public class GenericExample {
public static void main(String[] args) {
Box<String> box = new Box<>();
box.set("hello");
String str = box.get();
System.out.println(str);
Box<Integer> box2 = new Box<>();
box2.set(9);
int value = box2.get();
System.out.println(value);
}
}
제네릭 메소드는 매개 타입과 리턴 타입으로 타입 파라미터를 갖는 메소드를 말합니다. 제네릭 메소드를 선언하는 방법은 리턴 타입 앞에 <> 기호를 추가하고 타입 파라미터를 기술한 다음, 리턴 타입과 매개 타입으로 타입 파라미터를 사용하면 됩니다. 직접 코드로 확인해보겠습니다.
public <타입파라미터, ..> 리턴타입 메소드명(매개변수, ...) {
}
다음 boxing() 제네릭 메소드는 <> 기호 안에 타입 파라미터 T를 기술한 뒤, 매개 변수 타입으로 T를 사용했고, 리턴 타입으로 제네릭 타입 Box를 사용했습니다.
public <T> Box<T> boxing(T t) {
}
제네릭 메소드는 다음과 같은 방식으로 호출할 수 있습니다. 컴파일러가 매개값의 타입을 보고 구체적인 타입을 추정하도록 할 수도 있습니다.
리턴타입 변수 = 메소드명(매개값); // 매개값을 보고 구체적 타입을 추정
예시
Box<Integer> box = boxing(100);
다음 예제는 Util 클래스에 boxing()을 정의하고 GenericMethodExample 클래스에서 호출했습니다.
예제를 보면서 제네릭 메소드 선언과 사용법을 확인해봅시다.
public class Util {
public static <T> Box<T> boxing(T t) {
Box<T> box = new Box<T>();
box.set(t);
return box;
}
}
public class GenericMethodExample {
public static void main(String[] args) {
Box<Integer> box1 = Util.boxing(100);
int intValue = box1.get();
Box<String> box2 = Util.boxing("홍길동");
String strValue = box2.get();
}
}
타입 파라미터에 구체적인 타입을 제한하는 기능입니다. 예를 들어 숫자를 연산하는 제네릭 메소드가 있다고 가정해봅시다. 이 메소드는 매개값으로 Number타입 혹은 Byte, Short, Integer, Long, Double타입의 인스턴스만 가져야 합니다. 이것이 제한된 타입 파라미터(bounded type parameter)가 필요한 이유입니다.
제한된 타입 파라미터를 선언하려면 타입 파라미터 뒤에 extends 키워드가 붙고 상위 타입을 명시하면 됩니다.
public **<T extends 상위타입>** 리턴타입 메소드(매개변수, ...) {
}
여기서 상위 타입은 클래스 뿐만 아니라 인터페이스도 가능합니다.
타입 파라미터에 지정되는 구체적인 타입은 상위 타입이거나 상위 타입의 하위 또는 구현 클래스만 가능합니다.
public <T extends Number> int compare(T t1, T t2) {
double v1 = t1.doubleValue();
double v2 = t2.doubleValue();
return Double.compare(v1, v2)
}
위 코드에서 doubleValue() 메소드는 Number 클래스에 정의되어 있는 메소드이고, 숫자를 double 타입으로 변환합니다. 그리고 Double.compare 메소드는 첫 번째 매개값이 작으면 -1을, 같으면 0을, 크면 1을 리턴합니다.
public class BoundedTypeParameterExample {
public static void main(String[] args) {
// String value = Util.compare("a", "b"); // String은 Number 타입이 아니므로 컴파일 오류 발생
int result1 = Util.compare(1, 2); // int -> Integer (자동 Boxing)
System.out.println(result1);
int result2 = Util.compare(4.5, 3); // double -> Double (자동 Boxing)
System.out.println(result2);
}
}
실행결과
-1
1
코드에서 ?를 일반적으로 와일드카드(wildcard)라고 부릅니다. 제네릭 타입을 매개값이나 리턴 타입으로 사용할 때 구체적인 타입 대신에 와일드카드를 다음과 같이 세가지 형태로 사용할 수 있습니다.
이론만으로 이해하기는 어려우므로, 예시를 보면서 알아봅시다.
public class Course<T> {
private String name;
private T[] students;
public Course(String name, int capacity) {
this.name = name;
students = (T[]) (new Object[capacity]); //타입 파라미터로 배열을 생성하려면 new T[n] 형태로 배열을 생성할 수 없고 (T[]) (new Object[n])으로 생성해야한다.
}
public String getName() {
return name;
}
public T[] getStudents() {
return students;
}
public void add(T t) {
// 배열에 비어있는 부분을 찾아서 수강생을 추가하는 메소드
for (int i = 0; i < students.length; i++) {
if (students[i] == null) {
students[i] = t;
break;
}
}
}
}
수강생이 될 수 있는 타입은 다음 4가지 클래스라고 가정해봅시다. Person의 하위 클래스로 Worker와 Student가 있고, Student의 하위 클래스로 HighStudent가 있습니다.

다음 예제는 registerCourseXXX() 메소드의 매개값으로 와일드카드 타입을 사용했습니다.
registerCourse() : 모든 수강생들이 들을 수 있는 과정을 등록하고
registerCourseStudent() : 학생만 들을 수 있는 과정을 등록합니다
registerCourseWorker() : 직장인만 들을 수 있는 과정을 등록합니다.
public class WildCardExample {
public static void registerCourse(Course<?> course) { //모든 과정
System.out.println(course.getName() + " 수강생: " + Arrays.toString(course.getStudents()));
}
public static void registerCourseStudent(Course<? extends Student> course) { //학생 과정
System.out.println(course.getName() + " 수강생: " + Arrays.toString(course.getStudents()));
}
public static void registerCourseWorker(Course<? super Worker> course) { // 직장인과 일반인 과정
System.out.println(course.getName() + " 수강생: " + Arrays.toString(course.getStudents()));
}
public static void main(String[] args) {
Course<Person> personCourse = new Course<Person>("일반인과정", 5);
personCourse.add(new Person("일반인"));
personCourse.add(new Worker("직장인"));
personCourse.add(new Student("학생"));
personCourse.add(new HighStudent("고등학생"));
Course<Worker> workerCourse = new Course<Worker>("직장인과정", 5);
workerCourse.add(new Worker("직장인"));
Course<Student> studentCourse = new Course<>("학생과정", 5);
studentCourse.add(new Student("학생"));
studentCourse.add(new HighStudent("고등학생"));
Course<HighStudent> highStudentCourse = new Course<>("고등학생과정", 5);
highStudentCourse.add(new HighStudent("고등학생"));
registerCourse(personCourse);
registerCourse(workerCourse);
registerCourse(studentCourse);
registerCourse(highStudentCourse); // 모든 과정 등록 가능
System.out.println();
// registerCourseStudent(personCourse); (X)
// registerCourseStudent(workerCourse); (X)
registerCourseStudent(studentCourse);
registerCourseStudent(highStudentCourse); // 학생 과정만 등록 가능
System.out.println();
registerCourseWorker(personCourse);
registerCourseWorker(workerCourse); // 직장인과 일반인 과정만 등록 가능
// registerCourseWorker(studentCourse); (X)
// registerCourseWorker(highStudentCourse); (X)
}
}
출력결과
일반인과정 수강생: [일반인, 직장인, 학생, 고등학생, null]
직장인과정 수강생: [직장인, null, null, null, null]
학생과정 수강생: [학생, 고등학생, null, null, null]
고등학생과정 수강생: [고등학생, null, null, null, null]
학생과정 수강생: [학생, 고등학생, null, null, null]
고등학생과정 수강생: [고등학생, null, null, null, null]
일반인과정 수강생: [일반인, 직장인, 학생, 고등학생, null]
직장인과정 수강생: [직장인, null, null, null, null]
제네릭 타입도 다른 타입과 마찬가지로 부모 클래스가 될 수 있습니다. 다음은 Product<T, M> 제네릭 타입을 상속해서 ChildProduct<T, M> 타입을 정의합니다.
public class ChildProduct<T, M> extends Product<T, M> {
}
자식 제네릭 타입은 추가적으로 타입 파라미터를 가질 수 있습니다. 다음은 세 가지 타입 파라미터를 가진 자식 제네릭 타입을 선언한 것입니다.
public class ChildProduct<T, M, C> extends Product<T, M> {
}
부모 제네릭 타입을 먼저 구현해보겠습니다.
public class Product<T, M> {
private T kind;
private M model;
public T getKind() {
return kind;
}
public M getModel() {
return model;
}
public void setKind(T kind) {
this.kind = kind;
}
public void setModel(M model) {
this.model = model;
}
}
부모 제네릭 클래스인 Product를 상속받은 자식 제네릭 클래스 ChildProduct를 구현해볼게요.
public class ChildProduct<T, M, C> extends Product<T, M> {
private C company;
public C getCompany() {
return this.company;
}
public void setCompany(C company) {
this.company = company;
}
}
제네릭 인터페이스를 구현한 클래스도 제네릭 타입이 되는데, 다음과 같이 제네릭 인터페이스가 있다고 가정해봅시다.
public interface Storage<T> {
void add(T item, int index);
T get(int index);
}
제네릭 인터페이스인 Storage 타입을 구현한 StorageImpl 클래스도 제네릭 타입이어야합니다.
public class StorageImpl<T> implements Storage<T> {
private T[] array;
public StorageImpl(int capacity) {
array = (T[]) (new Object[capacity]);
}
@Override
public void add(T item, int index) {
array[index] = item;
}
@Override
public T get(int index) {
return array[index];
}
}
다음 ChildProductAndStorageExample은 ChildProduct<T, M, C>와 StorageImpl 클래스의 사용 방법을 보여줍니다.
public class ChildProductAndStorageExample {
public static void main(String[] args) {
ChildProduct<Tv, String, String> product = new ChildProduct<Tv, String, String>();
product.setKind(new Tv());
product.setModel("SmartTv");
product.setCompany("Samsoong");
Storage<Tv> storage = new StorageImpl<>(100);
storage.add(new Tv(), 0);
}
}