제네릭(Generic)은 다양한 타입의 객체를 다룰 수 있도록 클래스나 메서드에 타입 매개변수를 적용하는 기능이다.
쉽게 말하자면 하나의 값이 여러개의 데이터 타입들을 가질 수 있어 재사용성을 높일 수 있는 프로그래밍 방식이라고 할 수 있다.
🐿️ 다람쥐가 도토리를 저장하는 창고를 만들었다고 가정해보자!
하지만 다람쥐는 도토리뿐만 아니라 밤, 호두, 심지어 돌까지 넣기 시작했다.
✅ 제네릭을 사용하지 않은 창고
class Storage { // 제네릭 없이 Object 사용
private Object item;
public void store(Object item) {
this.item = item;
}
public Object retrieve() {
return item;
}
}
Storage storage = new Storage();
storage.store(new Acorn()); // 도토리를 저장!
// 꺼낼 때 매번 타입 변환을 해야 한다.
String acorn = (Acorn) storage.retrieve(); // (Acorn) 캐스팅 필수
⚠️ 문제 발생 : 물건을 꺼낼 때마다 도토리인지 돌인지 직접 확인해야 한다.
storage.store(new Stone()); // 실수로 돌멩이를 저장
Acorn acorn = (Acorn) storage.retrieve(); // 실행 시 ClassCastException 발생!
⚠️ 문제 발생 : 다람쥐가 창고에서 도토리를 꺼내려다가 돌을 물어버렸다...
✅ 제네릭을 사용한 창고
class Storage<T> { // 특정 타입만 저장할 수 있는 창고
private T item;
public void store(T item) {
this.item = item;
}
public T retrieve() {
return item;
}
}
class Acorn {} // 도토리 클래스
// 다람쥐는 이제 도토리 전용 창고를 만듦
Storage<Acorn> acornStorage = new Storage<>();
acornStorage.store(new Acorn()); // 도토리만 저장 가능!
// 타입 변환 없이 바로 사용 가능!
Acorn acorn = acornStorage.retrieve(); // (Acorn) 캐스팅 불필요
제네릭 클래스는 클래스 내부에서 사용할 데이터 타입을 외부에서 지정할 수 있도록 만들어진 클래스다.
이렇게 하면 특정 타입에 의존하지 않고, 다양한 타입을 처리할 수 있는 유연한 클래스를 만들 수 있다.
위에 예시들의 코드가 제네릭 클래스를 이용한 코드이다.
엇!🤚
<T> 같은 타입 매개변수를 지정하고 있는데 사실상 어떤 알파벳이든 사용 가능하지만 일반적으로는 다음과 같은 관용적인 타입 명칭을 사용한다.
T : Type의 약자, 일반적인 제네릭 타입에 사용E : Element의 약자, 컬렉션(List, Set 등)의 요소를 나타낼 때 사용K, V : Key, Value의 약자, Map과 같은 키-값 구조에서 사용U, S : 두 개 이상의 제네릭 타입을 사용할 때 보조적으로 사용제네릭 메서드는 특정 메서드에서만 제네릭 타입을 사용할 때 활용된다.
클래스와 동일하게 메서드 선언 시 타입 매개변수를 지정하여 다양한 타입을 처리할 수 있다.
🐿️ 다람쥐 예시에 제네릭 메서드를 추가한다면?
class Storage<T> {
private T item;
public void store(T item) {
this.item = item;
}
// 제네릭 메서드
public static <U> void showItemType(U item) {
System.out.println("저장된 아이템의 타입: " + item.getClass().getSimpleName());
}
}
// 다람쥐 창고 사용
Storage<Acorn> acornStorage = new Storage<>();
acornStorage.store(new Acorn());
// 제네릭 메서드 호출
Storage.showItemType(acorn); // 출력: 저장된 아이템의 타입: Acorn
제네릭 타입 매개변수의 허용 범위를 제한하는 기능이다.
즉, 특정 타입의 하위 타입만 사용 가능하도록 강제할 수 있다.
class 클래스명<T extends 제한할타입> { }
extends는 특정 타입이나 인터페이스를 상속하거나 구현한 타입으로 제한할 때 사용된다.🐿️ 다람쥐가 저장할 수 있는 것은 먹을 수 있는 것(도토리, 밤)뿐이다.
돌과 같은 먹을 수 없는 것은 저장할 수 없도록 제한해보자!!
// 먹을 수 있는 것들의 공통 인터페이스
interface Edible {
void eat();
}
// 도토리와 밤은 먹을 수 있다.
class Acorn implements Edible {
public void eat() { System.out.println("냠 도토리를 먹었다! ⚱️"); }
}
class Chestnut implements Edible {
public void eat() { System.out.println("냠 밤을 먹었다! 🌰"); }
}
// 돌은 먹을 수 없다.
class Stone {}
// 바운디드 타입을 적용한 다람쥐 창고
class Storage<T extends Edible> { // T는 Edible을 구현한 타입만 가능
private T item;
public void store(T item) { this.item = item; }
public T retrieve() { return item; }
}
public class Main {
public static void main(String[] args) {
Storage<Acorn> acornStorage = new Storage<>();
acornStorage.store(new Acorn());
Storage<Chestnut> chestnutStorage = new Storage<>();
chestnutStorage.store(new Chestnut());
// Storage<Stone> stoneStorage = new Storage<>();
// ❌ 컴파일 에러 (Stone은 Edible이 아님)
acornStorage.retrieve().eat(); // 출력: 냠 도토리를 먹었다! ⚱️
chestnutStorage.retrieve().eat(); // 출력: 냠 밤을 먹었다! 🌰
}
}
와일드카드는 제네릭 타입을 보다 유연하게 사용할 수 있도록 도와주는 기능이다.
어떤 특정한 타입을 강제하지 않고 다양한 타입을 받을 수 있도록 허용한다.
| 와일드카드 | 의미 | 사용 목적 |
|---|---|---|
<?> | 모든 타입을 허용 | 타입을 명확히 알 필요 없을 때 |
<? extends T> | T 또는 T의 하위 타입만 허용 | 읽기 전용 (불변) |
<? super T> | T 또는 T의 상위 타입만 허용 | 쓰기(저장) 가능 |
어떤 타입이든 허용하지만 내부 요소 추가는 불가능하다.
🐿️ 다람쥐 창고에 모든 종류의 창고를 출력할 수 있는 기능을 만들어보자!
// 와일드카드를 사용하여 모든 타입의 창고를 출력할 수 있음
public static void printStorage(Storage<?> storage) {
System.out.println("창고에 저장된 아이템: " + storage.retrieve());
}
Storage<Acorn> acornStorage = new Storage<>();
acornStorage.store(new Acorn());
Storage<Chestnut> chestnutStorage = new Storage<>();
chestnutStorage.store(new Chestnut());
printStorage(acornStorage);
printStorage(chestnutStorage);
<?>을 사용하면 어떤 타입의 Storage든 받을 수 있지만 내부 데이터를 변경할 수는 없다.
T 또는 T의 하위 타입만 허용하는 와일드카드다.
🐿️ 다람쥐 창고에서 먹을 수 있는 것들만 출력하도록 제한해보자!
// Edible을 구현한 클래스만 받을 수 있음
public static void printEdibleStorage(Storage<? extends Edible> storage) {
storage.retrieve().eat(); // 안전하게 eat() 호출 가능!
}
...
printEdibleStorage(acornStorage); // 출력: 냠 도토리를 먹었다! ⚱️
printEdibleStorage(chestnutStorage); // 출력: 냠 밤을 먹었다! 🌰
// Storage<Stone> stoneStorage = new Storage<>();
// ❌ 컴파일 에러 (Stone은 Edible이 아님)
🌟 주의 : 바운디드 타입은 클래스나 메서드를 정의할 때 타입을 제한하는 역할이고 값을 저장할 수 있지만 / Upper Bounded Wildcard는 메서드 호출 시 특정 타입만 받도록 제한하는 역할이고 값을 저장할 수 없다.
T 또는 T의 상위 타입만 허용하는 와일드카드다.
🐿️ 다람쥐 창고에서 먹을 수 있는 것들을 저장할 때 유연하게 허용해보자!
// Edible의 상위 타입에만 저장 가능
public static void addEdibleItem(Storage<? super Edible> storage) {
storage.store(new Acorn()); // Edible의 하위 타입 저장 가능
}
Storage<Object> objectStorage = new Storage<>();
addEdibleItem(objectStorage); // ✅ 저장 가능 (Object는 Edible의 상위 타입)
// Storage<Acorn> acornStorage = new Storage<>();
// addEdibleItem(acornStorage);
// ❌ 컴파일 에러 (Acorn은 Edible의 하위 타입이므로 불가능)
제네릭은 컴파일러가 타입 안정성을 검사하는 기능을 제공하지만 실제 컴파일 후 바이트코드에서는 제네릭 타입 정보가 사라지는 특징이 있다. 이것을 타입 소거라고 한다.
👉 컴파일 과정에서 제네릭 타입 정보가 제거되어 실제 .class 파일에서는 원시 타입(Raw Type)으로 변환된다.
♦️ 타입 소거 후 (컴파일된 코드)
컴파일 후에는 제네릭 타입(T)이 원시 타입(Object)으로 변환된다.
제네릭이 사라진 후에는 원시 타입(Object)으로 처리되며 타입 캐스팅이 필요하다.
class Storage { // 제네릭이 제거됨
private Object item;
public void store(Object item) {
this.item = item;
}
public Object retrieve() {
return item;
}
}
// 다람쥐가 도토리 창고를 생성
Storage acornStorage = new Storage();
acornStorage.store(new Acorn());
// 수동 타입 변환(캐스팅) 필요!
Acorn acorn = (Acorn) acornStorage.retrieve();
하위 호환성 유지
제네릭이 추가되기 전에 List와 같은 컬렉션은 원시 타입(Raw Type)을 사용했다. JDK 1.5 이후에도 기존 코드를 그대로 사용할 수 있도록 제네릭을 컴파일 시점에만 검사하고, 실제 실행 코드에는 제거한다.
메서드 오버로딩 불가능
class Example {
public void method(List<String> list) { }
// ❌ 컴파일 오류 발생
public void method(List<Integer> list) { }
}
타입 소거 후 두 메서드는 method(List list)로 변환되며 중복 정의된 것과 동일한 상태가 되기 때문이다.
👉 ? extends, ? super, <?> 같은 와일드카드 문법은 컴파일 시 타입을 제한하고 체크해주는 도구이기 때문에 타입 소거 후에도 안전한 제네릭 코드 작성을 가능하게 해준다.
(1) 타입 매개변수로 기본 타입 (Primitive Type) 사용 불가
class Box<T> {...}
Box<int> box = new Box<>() // ❌ 컴파일 오류 발생!
Wrapper 클래스(Integer, Double 등)을 사용해야 한다.(2) 제네릭 배열 생성 불가
class Box<T> {
private T[] items;
public Box() {
items = new T[10]; // ❌ 컴파일 오류 발생!
}
}
Object 배열을 사용한 후 타입 캐스팅을 하거나, List<T>를 사용할 수 있다.(3) static 멤버에서 타입 매개변수 사용 불가
class Box<T> {
private static T item; // ❌ 컴파일 오류 발생!
}