Java에서 제네릭(Generic)은 다양한 타입의 객체들을 다루는 클래스나 메서드에서 컴파일 시점의 타입 체크(compile-time type check) 를 가능하게 해주는 강력한 기능입니다. 제네릭을 올바르게 사용하면 타입 안정성을 높이고, 불필요한 형변환(casting)을 줄이는 동시에 코드의 가독성과 재사용성도 향상시킬 수 있습니다.
Java의 컬렉션 클래스(ArrayList, HashMap 등)는 내부적으로 Object 타입을 사용하여 다양한 객체를 저장할 수 있도록 설계되어 있습니다. 하지만 이 경우, 객체를 꺼낼 때는 원래의 타입으로 형변환이 필요하며, 잘못된 형변환은 런타임 에러로 이어질 수 있습니다.
제네릭은 이러한 문제를 컴파일 시점에 미리 방지할 수 있도록 도와줍니다.
ArrayList<Tv> tvList = new ArrayList<Tv>();
tvList.add(new Tv()); // OK
tvList.add(new Audio()); // 컴파일 에러
Tv t = tvList.get(0); // 형변환 불필요
| 항목 | 설명 |
|---|---|
| 타입 안정성 확보 | 잘못된 타입의 객체 저장을 컴파일 시점에 차단하여 안정성을 높임 |
| 형변환 생략 가능 | 불필요한 cast 문법 제거로 코드가 간결해짐 |
| 재사용성 향상 | 다양한 타입에 대해 하나의 클래스 또는 메서드로 대응 가능 |
타입 안정성을 높인다는 것은 의도하지 않은 타입의 객체를 저장하는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 형변환되어 발생할 수 있는 오류를 줄여준다는 뜻입니다.
class Box<T> {}
| 용어 | 설명 |
|---|---|
Box<T> | 제네릭 클래스(Generic Class) "T의 Box" 또는 "T Box"라고 읽으며, 타입 매개변수 T를 사용하는 클래스를 의미합니다. |
| T | 타입 변수(Type Variable) 또는 타입 매개변수(Type Parameter) 실제 타입이 지정되기 전까지 임시적으로 쓰이는 타입 기호입니다. 관례적으로 T, E, K, V, N 등의 대문자 한 글자를 사용합니다. |
| Box | 원시 타입(Raw Type) |
| 컴파일 이후 처리 | Java 컴파일러는 제네릭 타입 정보를 컴파일 시에 검사한 뒤, 바이트코드에서는 타입 정보를 제거합니다. |
Java에서 다형성(polymorphism)은 객체지향 프로그래밍의 핵심 개념 중 하나로, 상위 타입의 참조변수로 하위 타입의 객체를 참조할 수 있도록 허용합니다. 그러나 제네릭(Generic)에서는 이러한 다형성이 타입 매개변수 간에는 적용되지 않으며, 제네릭 타입의 일치가 필수적이라는 점을 이해해야 합니다.
제네릭 클래스의 객체를 생성할 때, 참조변수에 지정해준 타입과 생성자에 지정해준 제네릭 타입은 일치해야 합니다. 클래스 Tv와 Product가 서로 상속관계에 있어도 일치해야 합니다.
class Product {}
class Tv extends Product {}
class Audio extends Product {}
ArrayList<Tv> list = new ArrayList<Tv>(); // ✅ OK: 타입 일치
ArrayList<Product> list = new ArrayList<Tv>(); // ❌ 컴파일 에러
제네릭 타입이 아닌 클래스의 타입 간에 다형성을 적용하는 것은 가능합니다. 이 경우에도 제네릭 타입은 일치해야 합니다.
List<Tv> list1 = new ArrayList<Tv>(); // OK: ArrayList는 List 구현체
List<Tv> list2 = new LinkedList<Tv>(); // OK: LinkedList도 List 구현체
Product 타입을 제네릭 타입으로 지정하면, Product를 상속하는 모든 객체를 저장할 수 있습니다. 단, 꺼낼 때는 구체적인 타입으로 명시적 형변환이 필요합니다.
ArrayList<Product> list = new ArrayList<Product>();
list.add(new Product());
list.add(new Tv());
list.add(new Audio());
// 꺼낼 때, 형변환이 필요하다.
Tv t = (Tv)list.get(1);
Java 제네릭의 유연성은 장점이지만, 경우에 따라 특정 계층의 타입만 허용하도록 제한할 필요가 있습니다. 예를 들어, FruitBox라는 클래스를 만들 때, 오직 Fruit의 자손 클래스만 담을 수 있도록 해야 의미가 명확해지고 타입 안정성도 확보됩니다.
이러한 제한을 적용하기 위해 extends 키워드를 사용하여 제네릭 타입에 경계(bound)를 설정할 수 있습니다.
class FruitBox<T> {
ArrayList<T> list = new ArrayList<T>();
}
이 경우 FruitBox<Toy> 와 같이 아무 타입이나 제네릭 타입으로 지정할 수 있습니다.
FruitBox<Toy> fruitBox = new FruitBox<Toy>();
fruitBox.list.add(new Toy()); // 문제 없음
타입 안정성은 유지되지만, 의미적으로 어색한 타입도 허용된다는 점에서 제약이 필요할 수 있습니다.
class Fruit {}
class Apple extends Fruit {}
class Grape extends Fruit {}
class Toy {}
class FruitBox<T extends Fruit> {
ArrayList<T> list = new ArrayList<T>();
}
FruitBox<Apple> appleBox = new FruitBox<Apple>(); // ✅ OK
FruitBox<Grape> grapeBox = new FruitBox<Grape>(); // ✅ OK
FruitBox<Toy> toyBox = new FruitBox<Toy>(); // ❌ 에러: Toy는 Fruit의 자손이 아님
T extends Fruit 은 제네릭 타입 T가 Fruit 또는 그 자손 클래스만 가능하다는 뜻입니다. 이를 통해 컴파일 시점에 잘못된 타입 대입을 방지할 수 있습니다.
extends는 클래스뿐 아니라 인터페이스에도 사용할 수 있습니다.
interface Eatable {}
class FruitBox<T extends Eatable> {
ArrayList<T> list = new ArrayList<T>();
}
제네릭 타입 T는 Eatable 인터페이스를 구현한 타입만 가능합니다.
하나의 타입이 특정 클래스의 자손이면서 동시에 인터페이스도 구현해야 할 경우, 아래와 같이 & 연산자를 활용합니다.
class Fruit implements Eatable {}
class Apple extends Fruit {}
class FruitBox<T extends Fruit & Eatable> {
ArrayList<T> list = new ArrayList<T>();
}
T는 Fruit의 자손이면서 Eatable 인터페이스를 반드시 구현해야 합니다.
다중 제한 시, 클래스는 반드시 첫 번째에 작성해야 하며, 그 이후에 인터페이스들을 나열합니다.
Java 제네릭(Generic)은 타입 안정성과 유연한 설계를 동시에 제공하지만, 몇 가지 기술적 제약도 함께 존재합니다. 이러한 제약은 대부분 타입 소거(type erasure) 라는 Java 컴파일러의 제네릭 처리 방식에서 비롯됩니다.
제네릭 타입 매개변수는 인스턴스 수준에서만 유효합니다. 따라서 static 영역에서는 사용할 수 없습니다.
class Box<T> {
static T item; // ❌ 컴파일 에러
static int compare(T t1, T t2) { // ❌ 컴파일 에러
return 0;
}
}
T는 인스턴스가 생성될 때 지정되는 타입입니다.
하지만 static 멤버는 클래스 수준에서 공유되므로, 인스턴스가 생성되기도 전에 T의 구체적인 타입을 알 수 없습니다.
제네릭 타입 배열의 참조 변수는 선언할 수 있지만, 실제로 배열을 생성하는 것은 허용되지 않습니다.
class Box<T> {
T[] itemArr; // ✅ 참조 변수 선언은 가능
T[] toArray() {
T[] tmpArr = new T[itemArr.length]; // ❌ 컴파일 에러
return tmpArr;
}
}
Java의 new 연산자는 배열의 요소 타입을 컴파일 시점에 명확히 알아야 합니다.
하지만 제네릭 타입은 컴파일 후 타입 정보가 제거(타입 소거)되므로, new T[]와 같은 표현은 사용할 수 없습니다.
앞서 설명한 바와 같이, 제네릭 타입 간에는 다형성이 적용되지 않는다는 점이 Java 제네릭의 중요한 특성입니다. 예를 들어 List<Product> 와 List<Tv> 는 서로 아무 관계가 없는 타입으로 간주됩니다.
이러한 제한 속에서 제네릭 타입 간에도 유연한 관계를 허용하고 싶다면, 와일드 카드(?)를 사용해야 합니다. 와일드 카드는 제네릭 타입의 경계를 유동적으로 만들어, 특정 범위 내의 타입을 받아들이도록 허용합니다.
| 문법 | 설명 | 허용 범위 |
|---|---|---|
<?> | 모든 타입 허용 | Object를 상한으로 하는 모든 타입 |
<? extends T> | 상한 제한 (Upper Bound) | T 또는 T의 자손 타입만 허용 |
<? super T> | 하한 제한 (Lower Bound) | T 또는 T의 조상 타입만 허용 |
ArrayList<? extends Product> list = new ArrayList<Tv>(); // OK
ArrayList<? extends Product> list = new ArrayList<Audio>(); // OK
static Juice makeJuice(FruitBox<? extends Fruit> box) {
String tmp = "";
for(Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
Juicer.makeJuice(new FruitBox<Fruit>());
Juicer.makeJuice(new FruitBox<Apple>());
Java에서 제네릭 메서드(Generic Method)는 메서드 수준에서 타입 매개변수를 선언하여, 다양한 타입에 유연하게 대응할 수 있도록 설계된 메서드입니다. 제네릭 클래스와는 별도로, 특정 메서드에 한해 타입을 일반화하고자 할 때 매우 유용하게 사용됩니다.
타입 매개변수의 선언 위치는 반환 타입 바로 앞에 위치하며, 대표적으로 Collections.sort() 가 바로 제네릭 메서드이다.
static <T> void sort(List<T> list, Comparator<? super T> c)
class FruitBox<T> {
...
static <T> void sort(List<T> list, Comparator<? super T> c) {
...
}
...
}
제네릭 클래스에 정의된 타입 매개변수가 T이고 제네릭 메서드에 정의된 타입 매개변수가 T이어도 이 둘은 전혀 별개의 것입니다.
static 멤버에는 타입 매개변수를 사용할 수 없지만, 이처럼 메서드에 제네릭 타입을 선언하고 사용하는 것은 가능합니다.
참고로 제네릭 메서드는 제네릭 클래스가 아닌 클래스에도 정의될 수 있습니다.
static <T extends Fruit> Juice makeJuice(FruitBox<T> box) {
String tmp = "";
for(Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
Juicer.<Fruit>makeJuice(fruitBox);
Juicer.<Apple>makeJuice(appleBox);