여기로 들어온 자네, 혹시 자바 라이브러리를 쓰다 ArrayList<T>, HashMap<K, V> 같은 표현을 본 적이 있는가?
만약 여기서 T, K, V 같은 문법이 무엇을 의미하는지 잘 몰랐다면,
당신은 아직 **자바 제네릭(Generics)**의 개념을 제대로 이해하지 못하고 있었을 가능성이 있다.
이 문법들은 단순한 기호가 아니라, 데이터 타입을 안전하고 유연하게 다루기 위해 자바가 제공하는 핵심 기능이다
오늘은 제네릭이 나오는 본질적인 이유와 문법에 대해서 알아보도록 하자
사실 필자도 제네릭을 직접 사용하며 코드를 작성해 본 경험은 많지 않다.
하지만 라이브러리나 자바에서 제공하는 클래스와 함수를 따라가다 보면, 자연스럽게 제네릭을 자주 접하게 된다.
이 궁금증을 풀고, 왜 제네릭이 필요한지 명확히 이해하기 위해 이번 글을 작성하고자 한다.

지네릭스가 왜 나왔어??
ArrayList list = new ArrayList();
list.add("hello");
list.add(123); // 컴파일에서는 문제없음
String s = (String) list.get(1); // 런타임 오류 발생!
ArrayList<String> list = new ArrayList<>(); // String 타입만 허용
list.add("Hello");
// list.add(123); // 컴파일 단계에서 오류 발생
String s = list.get(0); // 형변환 불필요
System.out.println(s);
위의 두 코드의 차이는 첫 번째 코드는 Object로 다루다 보니 잘못된 타입을 넣어도 파일 시점에서는 잡히지 않는다.
하지만 밑에 코드는 컴파일 단계에서 잘못된 타입을 미리 알 수 있다는 점이다.
이 말은 즉 ClassCastException을 방지하면서
형변환을 하지 않아도 되기 때문에 코드가 더 깔끔해진다는 부분이 있다.
class Box {
private Object content;
public void set(Object content) { this.content = content; }
public Object get() { return content; }
}
Box box = new Box();
box.set("Hello");
box.set(123); // 아무 타입이나 넣을 수 있음
String s = (String) box.get(); // 형변환 필요
class Box<T> {
private T content;
public void set(T content) { this.content = content; }
public T get() { return content; }
}
Box<String> stringBox = new Box<>();
stringBox.set("Hello");
// stringBox.set(123); // 컴파일 단계에서 오류 발생
String s = stringBox.get(); // 형변환 필요 없음
Box 클래스는 모든 데이터를 Object로 다루기 때문에 뭐든 넣을 수 있다.
근데 문제는 잘못된 타입을 넣어도 컴파일 단계에서는 아무 오류도 안 잡히고,
데이터를 꺼낼 때마다 형변환(casting)을 해줘야 한다는 것.
Box<T> 클래스는 Box를 만들 때 타입을 미리 지정할 수 있다.
예를 들어 Box<String>을 만들면 문자열만 담을 수 있고,
Box<Integer>를 만들면 숫자만 담을 수 있는 식.
이 방식은 컴파일 단계에서 잘못된 타입을 넣는 걸 막아주고,
데이터를 꺼낼 때 형변환도 필요 없어서 코드가 훨씬 깔끔
깔끔하게 표로 정리해볼까??
제네릭 미사용 제네릭 사용
타입 안전성 런타임 오류 위험 컴파일 시점에서 오류 방지 형변환 필요함 필요 없음 재사용성 낮음, 타입별 클래스 필요 높음, 하나의 클래스/메서드 재사용 가능 코드 가독성 떨어짐 깔끔
지네릭 클래스의 선언
class 클래스명<T> {
private T 변수;
public void set(T 변수) { this.변수 = 변수; }
public T get() { return 변수; }
}
제네릭 클래스는 클래스를 만들 때 타입을 미리 정하지 않고,
나중에 사용할 때 지정할 수 있는 클래스다.
쉽게 말하면,
“무엇이든 담을 수 있는 상자인데, 상자를 만들 때 담을 타입을 선택하는 것”
<T>: 타입 매개변수(Type Parameter)를 의미
T는 Type의 약자
E, K, V 등 다른 문자도 사용 가능 (Element, Key, Value 등 의미에 맞게 선택)
T는 클래스 안에서 변수, 메서드 파라미터, 반환 타입 등 여러 곳에서 사용 가능
아래는 실제 사용 예시이다
class Container<T> {
private T item;
public void put(T item) {
this.item = item;
}
public T get() {
return item;
}
}
Container<String> stringContainer = new Container<>();
stringContainer.put("Hello");
System.out.println(stringContainer.get());
Container<Integer> intContainer = new Container<>();
intContainer.put(100);
System.out.println(intContainer.get());
지네릭스의 제한 조건
static멤버에는 타입 변수 T를 사용할 수 없다.
일단 static 멤버에 제네릭을 사용한다는건 논리적으로 모순이다
private static T a; 이 부분에서
static 멤버는 클래스 자체에 속하기 때문에 하나의 클래스 영역에서,인스턴스와 무관하게존재하게 된다.
이 말은 즉슨 T는 객체를 생성할 때 타입을 지정해줘서 객체마다 다른 값을 가질 수 있는데static 키워드를 사용하게 되면 클래스 단위로 하나만 존재 해야하는 유일성이 없어지게 된다.
객체마다 달라질 수 있는 T와 하나만 존재해야 하는 static
지네릭 타입의 배열 T[]를 생성하는 것은 허용되지 않는다.
제네릭 T는 컴파일 시점에만 존재하고, 런타임시에는 JVM이 T가 뭔지 모른다.
대신 그냥 Object처럼 처리한다.
그런데 배열은 런타임에 타입 정보를 알고 있어야 안전하게 만들 수 있음.
예를 들어 String 배열은 JVM이 “이건 String 배열!”이라고 알고, 값 넣을 때 검사.
만약 T[]를 만들 수 있다면, JVM은 T가 뭔지 모르기 때문에 배열에 아무거나 넣어도 타입 체크를 못하게 돼요.
그래서 자바는 T[] 배열 생성 자체를 막는다.
지네릭스 클래스의 생성시 고려해야 될 점
참조변수와 생성자에 대입된 타입이 일치해야한다.
// ✅ 참조 변수와 생성자 타입 일치
Container<String> stringContainer = new Container<String>();
stringContainer.set("Hello");
System.out.println(stringContainer.get());
// ❌ 타입 불일치 예시
// Container<String> wrongContainer = new Container<Integer>(); // 컴파일 오류!
두 지네릭 클래스가 상속관계이고 대입된 타입이 일치하는 것은 가능
// ✅ 참조 변수와 생성자 타입이 일치하면 상속 관계라도 가능
Container<String> container = new SpecialContainer<String>();
container.set("Hello");
System.out.println(container.get()); // 출력: Hello
// ❌ 타입이 다르면 안 됨
// Container<String> wrongContainer = new SpecialContainer<Integer>(); // 컴파일 오류!
대입된 타입과 다른 타입의 객체는 추가할 수 없음 (당연한 것)
// Container는 String 타입으로 대입
Container<String> stringContainer = new Container<String>();
stringContainer.set("Hello"); // ✅ OK
System.out.println(stringContainer.get());
// ❌ 다른 타입 객체는 추가할 수 없음
// stringContainer.set(123); // 컴파일 오류!
제네릭 타입에 extends ???
제네릭 타입에 extends가 나온 이유
자바의 제네릭은 타입 안전성을 높이고 코드 재사용성을 늘리는 것이 핵심 목표.
그런데 때때로 모든 타입을 다 허용하면 문제가 생길 수 있다.
class Animal {}
class Dog extends Animal {}
class Cat extends Animal {}
class Cage<T> {
private T occupant;
public void setOccupant(T occupant) { this.occupant = occupant; }
}
만약 제네릭에 제한을 걸지 않으면, Cage<String>처럼 원하지 않는 타입도 들어올 수 있음
그러면 occupant.sound() 같은 Animal 관련 기능을 호출할 때 컴파일러가 타입을 보장해주지 못함.
class Cage<T extends Animal> {
}
이런식으로 extends를 걸어준다면 Animal 클래스와 그 자손 클래스만 T에 대입 가능
interface Soundable {
void sound();
}
class Cage<T extends Soundable> {
}
인터페이스도 마찬가지 해당 인터페이스를 구현한 클래스만 대입이 가능하다
마지막으로 제네릭의 와일드 카드를 알아보자

와일드카드(Wildcard) 는 정확한 타입은 모르지만, 어떤 범위 안의 타입이면 허용이라는 뜻쉽게 말하면
“뭘 넣을지 모르지만, 안전하게 읽거나 처리할 수 있다”
class Animal {
void sound() {
System.out.println("Animal");
}
}
class Dog extends Animal {
void sound()
{
System.out.println("Woof");
}
}
class Cat extends Animal {
void sound()
{
System.out.println("Cat!!!");
}
}
public class Main {
public static void printAnimals(List<? extends Animal> animals) {
for (Animal a : animals) a.sound(); // 읽기 가능
}
public static void main(String[] args) {
List<Dog> dogs = List.of(new Dog(), new Dog());
List<Cat> cats = List.of(new Cat(), new Cat());
printAnimals(dogs); // 둘 다 가능
printAnimals(cats);
}
}
해당 printAnimals 함수를 보면, List<? extends Animal> 타입의 매개변수를 받고 있다.
이 와일드카드(? extends Animal) 덕분에
Dog 리스트, Cat 리스트 등 Animal을 상속한 다양한 타입의 리스트를
한 메서드에서 받을 수 있다.
만약 와일드카드가 없었다면
public static void printAnimals(List<Animal> animals) {
}
이 경우 Animal 타입 리스트만 허용
List<Dog>나 List<Cat>는 전달할 수 없어서
매번 오버로드하거나 타입 변환을 해야 하는 불편함이 생긴다!
'자바' 카테고리의 다른 글
| 자바 가비지 컬렉터란? 무엇일까 이야기 만들어봤지 왜 있는데? (0) | 2024.02.05 |
|---|---|
| 함수형 인터페이스 (3) | 2024.01.11 |
| 람다식 (1) | 2024.01.11 |