JAVA (16) - 제네릭
1. 개요
자바에서 제네릭(Generic)은 클래스, 인터페이스, 메서드를 정의할 때 사용할 데이터 타입을 파라미터로 지정할 수 있게 해주는 기능이다. Java 5에 도입되었으며, 데이터 타입(Data Type)의 안정성을 높이고 형변환의 번거로움을 줄이기 위해 사용된다. 다양한 타입의 객체들을 다루는 컬렉션 프레임워크 등에서 필수적으로 활용된다.
2. 제네릭의 필요성과 장점
제네릭이 도입되기 전에는 여러 타입을 다루기 위해 최상위 클래스인 Object 타입을 사용했다. 하지만 Object 타입을 사용하면 객체를 꺼낼 때마다 명시적 형변환(Casting)이 필요했고, 잘못된 타입이 들어갈 경우 런타임 에러가 발생할 위험이 컸다.
제네릭을 사용하면 다음과 같은 장점을 얻는다.
- 컴파일 타임 타입 검사: 런타임에 발생할 수 있는
ClassCastException을 컴파일 시점에 미리 찾아내어 방지한다. - 불필요한 형변환 제거: 객체를 꺼내올 때 명시적으로 타입 변환을 하지 않아도 되므로 코드가 간결해진다.
- 코드 재사용성 증가: 하나의 클래스나 메서드로 다양한 데이터 타입을 처리할 수 있다.
// 제네릭 사용 전
List list = new ArrayList();
list.add("hello");
String str = (String) list.get(0); // 명시적 형변환 필요
// 제네릭 사용 후
List<String> list = new ArrayList<>();
list.add("hello");
String str = list.get(0); // 형변환 생략 가능, 타입 안정성 확보
3. 제네릭의 기본 문법 및 관례
제네릭 클래스는 클래스 이름 뒤에, 제네릭 메서드는 반환 타입 앞에 <T>와 같은 형태로 타입 파라미터를 선언한다.
타입 파라미터 관례
개발자 간의 약속으로 통용되는 대표적인 타입 파라미터 기호는 다음과 같다.
<T>: Type (타입)<E>: Element (요소, 주로 컬렉션에서 사용)<K>: Key (키)<V>: Value (값)<N>: Number (숫자)
// 제네릭 클래스 정의
public class Box<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
}
제네릭 클래스 사용 예시
앞에서 정의한 Box<T>를 이용해 문자열과 정수를 각각 저장하고 꺼내 보자. 다음 코드는 main 메서드 안에서 실행할 수 있다.
// 문자열을 보관하는 Box
Box<String> stringBox = new Box<>();
stringBox.setItem("hello");
String text = stringBox.getItem();
System.out.println(text); // hello
// 정수를 보관하는 Box
Box<Integer> integerBox = new Box<>();
integerBox.setItem(100);
Integer number = integerBox.getItem();
System.out.println(number); // 100
Box<String>에서는 클래스에 선언한 T를 String으로 사용하므로, setItem()은 문자열을 받고 getItem()은 문자열을 반환한다. Box<Integer>에서는 T를 Integer로 사용하며, 값을 꺼낼 때 별도의 형변환이 필요 없다. 오른쪽의 new Box<>()에서 <>는 왼쪽 변수 선언을 바탕으로 타입을 추론하도록 한다.
문자열을 저장하도록 지정한 Box<String>에 정수를 넣으면 컴파일 오류가 발생한다.
stringBox.setItem(100); // 컴파일 오류: String 타입이 필요하다.
setItem()과 getItem()은 클래스에서 선언한 T를 사용하는 메서드다. 다음 절의 제네릭 메서드처럼 메서드 자체의 타입 파라미터를 선언한 것은 아니다.
4. 제네릭 메서드와 제한된 타입 파라미터
(1) 제네릭 메서드
메서드의 리턴 타입 앞에 제네릭 타입을 선언하여 사용하는 메서드다. 클래스가 제네릭이 아니더라도 메서드 단위로 제네릭을 적용할 수 있다.
다음 예제는 하나의 printAndReturn 메서드에 문자열과 정수를 전달하고, 반환값을 각각의 타입으로 받는다. GenericMethodExample.java 파일로 저장하면 실행할 수 있다.
public class GenericMethodExample {
public <T> T printAndReturn(T item) {
System.out.println(item);
return item;
}
public static void main(String[] args) {
GenericMethodExample example = new GenericMethodExample();
// 전달한 값에 따라 타입을 추론하므로 형변환이 필요 없다.
String text = example.printAndReturn("hello");
Integer number = example.printAndReturn(100);
// 호출할 때 타입을 명시할 수도 있다.
String message = example.<String>printAndReturn("java");
}
}
<T>는 이 메서드에서 사용할 타입 파라미터를 선언하고, 반환 타입 T와 매개변수 타입 T는 같은 타입을 사용한다는 뜻이다. 위 호출에서는 각각 String, Integer, String으로 결정된다. 정수 100은 오토박싱을 통해 Integer로 전달된다.
아래 명령으로 컴파일하고 실행하면 메서드에 전달한 값이 순서대로 출력된다.
javac GenericMethodExample.java
java GenericMethodExample
hello
100
java
GenericMethodExample 클래스 자체에는 타입 파라미터가 없다. 메서드가 자신의 <T>를 선언하므로, 같은 객체에서도 호출마다 다른 타입의 값을 처리할 수 있다.
(2) 제한된 타입 파라미터 (Bounded Type Parameter)
extends 키워드를 사용하면 특정 클래스의 하위 타입이나 특정 인터페이스를 구현한 타입으로만 제네릭을 제한할 수 있다.
// Number 클래스와 그 하위 클래스(Integer, Double 등)만 허용
public class NumberBox<T extends Number> {
private T number;
}
5. 와일드카드 (?)
제네릭 타입에서 지정할 타입을 정확히 알지 못하거나 범위를 다르게 지정해야 할 때 와일드카드 ?를 사용한다.
<?>(Unbounded Wildcard): 모든 타입을 허용한다. (<? extends Object>와 동일)<? extends T>(Upper Bounded Wildcard):T와 그 하위 타입만 허용한다. (상한 제한, 읽기 전용으로 자주 사용)<? super T>(Lower Bounded Wildcard):T와 그 상위 타입만 허용한다. (하한 제한, 쓰기 전용으로 자주 사용)
Class<?>의 의미와 사용 예시
Class<T>는 T 타입의 이름, 메서드 등 타입 정보를 담는 객체를 표현하는 제네릭 클래스다. 예를 들어 String.class는 문자열 값이 아니라 String 타입의 정보를 담는 Class<String> 객체다.
Class<?>는 어떤 타입을 나타내는지 구체적으로 지정하지 않은 Class 객체를 뜻한다. 클래스 이름처럼 공통으로 제공되는 정보만 필요할 때 사용하면 여러 타입의 클래스 정보를 받을 수 있다.
다음 예제는 Class<?> 매개변수로 문자열과 정수 타입의 클래스 정보를 받아 이름을 출력한다. ClassExample.java 파일로 저장하면 실행할 수 있다.
public class ClassExample {
public static void printClassName(Class<?> type) {
System.out.println(type.getName());
}
public static void main(String[] args) {
Class<String> stringType = String.class;
Class<Integer> integerType = Integer.class;
printClassName(stringType);
printClassName(integerType);
}
}
printClassName()은 구체적인 타입을 몰라도 getName()을 호출할 수 있다. 아래 명령으로 실행하면 전달한 두 타입의 이름이 출력된다.
javac ClassExample.java
java ClassExample
java.lang.String
java.lang.Integer
Class<?>와 Class<Object>는 다르다. Class<Object>는 Object 타입을 나타내는 클래스 객체를 뜻하므로, String.class를 대입할 수 없다.
Class<?> anyType = String.class; // 가능
anyType = Integer.class; // 가능
Class<Object> objectType = Object.class; // 가능
// Class<Object> wrong = String.class; // 컴파일 오류
또한 Class<?>의 ?는 알 수 없는 타입을 표현할 뿐, 타입에 이름을 붙이지 않는다. 메서드의 매개변수와 반환값에 같은 타입을 연결해야 한다면 앞에서 살펴본 <T>와 Class<T>를 사용한다.
6. 제네릭 타입 소거 (Type Erasure)
자바의 제네릭은 하위 호환성을 유지하기 위해 컴파일 시점에만 타입을 검사하고 런타임에는 제네릭 타입 정보를 제거(Erasure)하는 방식으로 동작한다.
- 컴파일러는 제네릭 타입을 바이트 코드로 변환할 때 상한 타입(기본적으로는
Object,extends가 있으면 제한된 타입)으로 변환한다. - 이 때문에 런타임에는 제네릭 타입 정보가 존재하지 않아
new T()와 같은 인스턴스 직접 생성이나 제네릭 배열 생성(new T[10])이 불가능하다.
7. 정리
제네릭은 자바에서 객체의 타입을 컴파일 시점에 지정하여 타입 안정성을 확보하고 불필요한 형변환을 줄이는 핵심 기능이다. 와일드카드와 제한된 타입 파라미터를 사용하면 유연한 다형성 설계를 구현할 수 있다.
단, 자바 내부적으로 컴파일 시 제네릭 타입이 제거되는 타입 소거(Type Erasure)가 일어난다는 특성을 숙지하고 사용하는 것이 필요하다.