제네릭

smj_716·2025년 3월 20일

Java-study / live-study

목록 보기
14/16

1. 제네릭이란?

제네릭(Generic)은 다양한 타입의 객체를 다룰 수 있도록 클래스나 메서드에 타입 매개변수를 적용하는 기능이다.
쉽게 말하자면 하나의 값이 여러개의 데이터 타입들을 가질 수 있어 재사용성을 높일 수 있는 프로그래밍 방식이라고 할 수 있다.

제네릭을 사용하는 이유

  • 컴파일 시 타입 검사를 수행하여 런타임에는 안전한 코드를 실행할 수 있도록 한다.
  • 실행 시 발생할 수 있는 ClassCastException을 방지하여 예외 발생 가능성을 줄인다.
  • 형변환이 불필요하여 코드가 간결해지고 가독성이 향상된다.

🐿️ 다람쥐가 도토리를 저장하는 창고를 만들었다고 가정해보자!
하지만 다람쥐는 도토리뿐만 아니라 밤, 호두, 심지어 돌까지 넣기 시작했다.

제네릭을 사용하지 않은 창고

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) 캐스팅 불필요  

2. 제네릭의 기본 문법

➡️ 제네릭 클래스

제네릭 클래스는 클래스 내부에서 사용할 데이터 타입을 외부에서 지정할 수 있도록 만들어진 클래스다.
이렇게 하면 특정 타입에 의존하지 않고, 다양한 타입을 처리할 수 있는 유연한 클래스를 만들 수 있다.
위에 예시들의 코드가 제네릭 클래스를 이용한 코드이다.

엇!🤚
<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

3. 바운디드 타입 (Bounded Type Parameter)

제네릭 타입 매개변수의 허용 범위를 제한하는 기능이다.
즉, 특정 타입의 하위 타입만 사용 가능하도록 강제할 수 있다.

class 클래스명<T extends 제한할타입> { }
  • T extends 제한할타입 → T는 제한할타입 또는 그 하위 타입만 사용할 수 있다.
  • 제네릭에서 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();  // 출력: 냠 밤을 먹었다! 🌰
    }
}

4. 와일드카드 (Wildcard)

와일드카드는 제네릭 타입을 보다 유연하게 사용할 수 있도록 도와주는 기능이다.
어떤 특정한 타입을 강제하지 않고 다양한 타입을 받을 수 있도록 허용한다.

와일드카드의미사용 목적
<?>모든 타입을 허용타입을 명확히 알 필요 없을 때
<? extends T>T 또는 T의 하위 타입만 허용읽기 전용 (불변)
<? super T>T 또는 T의 상위 타입만 허용쓰기(저장) 가능

➡️ Unbounded Wildcard (<?>)

어떤 타입이든 허용하지만 내부 요소 추가는 불가능하다.

🐿️ 다람쥐 창고에 모든 종류의 창고를 출력할 수 있는 기능을 만들어보자!

// 와일드카드를 사용하여 모든 타입의 창고를 출력할 수 있음
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든 받을 수 있지만 내부 데이터를 변경할 수는 없다.

➡️ Upper Bounded Wildcard (<? extends T>)

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메서드 호출 시 특정 타입만 받도록 제한하는 역할이고 값을 저장할 수 없다.

➡️ Lower Bounded Wildcard (<? super T>)

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의 하위 타입이므로 불가능)

5. 제네릭의 타입 소거 (Type Erasure)

제네릭은 컴파일러가 타입 안정성을 검사하는 기능을 제공하지만 실제 컴파일 후 바이트코드에서는 제네릭 타입 정보가 사라지는 특징이 있다. 이것을 타입 소거라고 한다.
👉 컴파일 과정에서 제네릭 타입 정보가 제거되어 실제 .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, <?> 같은 와일드카드 문법은 컴파일 시 타입을 제한하고 체크해주는 도구이기 때문에 타입 소거 후에도 안전한 제네릭 코드 작성을 가능하게 해준다.


6. 제네릭의 제약 사항

(1) 타입 매개변수로 기본 타입 (Primitive Type) 사용 불가

class Box<T> {...}
Box<int> box = new Box<>() // ❌ 컴파일 오류 발생!
  • 제네릭 타입은 결국 Object로 변환된다. 그런데 기본 타입(int, double, boolean 등)은 Object를 상속받지 않는다.
  • 따라서 기본 타입을 직접 사용할 수 없고 대신 Wrapper 클래스(Integer, Double 등)을 사용해야 한다.

(2) 제네릭 배열 생성 불가

class Box<T> {
    private T[] items;
    
    public Box() {
        items = new T[10];  // ❌ 컴파일 오류 발생!
    }
}
  • 자바는 배열을 생성할 때 타입 정보를 유지해야 한다. 하지만 제네릭은 컴파일 시점에 타입이 사라져서(Type Erasure) T의 실제 타입을 알 수 없다.
  • 대신 Object 배열을 사용한 후 타입 캐스팅을 하거나, List<T>를 사용할 수 있다.

(3) static 멤버에서 타입 매개변수 사용 불가

class Box<T> {
    private static T item;  // ❌ 컴파일 오류 발생!
}
  • static 멤버(변수, 메서드)는 클래스 레벨에서 공유되는데 제네릭 타입은 인스턴스가 생성될 때 결정된다.

0개의 댓글