TIL (Today I Learned)

[Java] Generic의 원리와 활용법

기만나🐸 2025. 1. 6. 14:59

Generic

Java의 제네릭(Generic)은 데이터 타입을 일반화하여 코드 재사용성을 높이고 타입 안정성을 보장하는 기능이다.

Generic은 일반적(generalized)이라는 뜻으로, 다양한 데이터 타입에서 동작하도록 설계된 클래스나 메서드를 작성할 수 있다.

 

Generic의 기본 원리

Java 1.5 이전에는 타입을 지정하지 않고 Object를 사용하여 모든 데이터를 처리했다.

List list = new ArrayList();
list.add("Hello");
list.add(123); // 서로 다른 타입 추가 가능

String str = (String) list.get(0); // 강제 캐스팅 필요

 

💥문제점

  1. 강제 캐스팅 필요 : 반환 값을 원하는 타입으로 변환
  2. 타입 안정성 부족 : 잘못된 타입이 추가되더라도 컴파일러가 검출하지 못함

 

위와 같은 문제점을  Generic으로 해결했다.

Java의 제네릭은 컴파일 시점에 타입을 검사한다.

List<String> list = new ArrayList<>();
list.add("Hello");
// list.add(123); // 컴파일 오류 발생

String str = list.get(0); // 강제 캐스팅 불필요

Generic의 주요 개념

1. Generic 클래스

데이터 타입에 의존하지 않는 클래스 설계가 가능

// T는 Type의 약자
public class Box<T> {
    private T item;

    public void setItem(T item) {
        this.item = item;
    }

    public T getItem() {
        return item;
    }
}
Box<String> stringBox = new Box<>();
stringBox.setItem("Hello");
System.out.println(stringBox.getItem()); // 출력: Hello

Box<Integer> intBox = new Box<>();
intBox.setItem(123);
System.out.println(intBox.getItem()); // 출력: 123

 

2. Generic 메서드

메서드에만 제네릭을 적용할 수도 있다.

public class Utils {
    // T는 메서드에만 적용됨
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.println(element);
        }
    }
}
String[] strings = {"A", "B", "C"};
Utils.printArray(strings); // 출력: A, B, C

Integer[] numbers = {1, 2, 3};
Utils.printArray(numbers); // 출력: 1, 2, 3

 

3. Bounded Type Parameters

제네릭 타입에 제약 조건을 설정할 수 있다.

public class NumberBox<T extends Number> {
    private T item;

    public void setItem(T item) {
        this.item = item;
    }

    public T getItem() {
        return item;
    }
}
NumberBox<Integer> intBox = new NumberBox<>();
intBox.setItem(123); // OK

// NumberBox<String> strBox = new NumberBox<>(); // 컴파일 오류
  • <T extends Number> : T는 Number 또는 그 하위 클래스(Integer, Double 등)만 가능
  • <T extends Enum<T>> : T는 Enum 또는 그 하위 타입만 가능

 

4. Wildcards: ?

제네릭을 더 유연하게 사용하기 위해 와일드카드를 제공.

와일드카드는 제네릭 타입을 제한 없이 나타내기 위해 사용하는 기호이다.

?는 "어떤 타입인지 모름"을 의미하며, 다음과 같은 경우에 유용하다.

1. 특정 타입을 정확히 알 필요가 없을 때
2. 여러 제네릭 타입을 다룰 때

 

  • ? (Unbounded Wildcard) : 어떤 타입이든 받을 수 있다. (제한 없음)
public void printList(List<?> list) {
    for (Object item : list) {
        System.out.println(item);
    }
}
List<String> stringList = List.of("A", "B", "C");
List<Integer> intList = List.of(1, 2, 3);

printList(stringList); // 출력: A, B, C
printList(intList);    // 출력: 1, 2, 3
  •  장점
    • String, Integer 등 다양한 타입의 리스트를 한 메서드에서 처리할 수 있다.
  • 제약
    • 리스트 내부의 타입을 알 수 없으므로, 요소를 추가하거나 수정할 수 없다.
    • ex) list.add()는 사용 불가

 

  • ? extends T (Upper Bounded Wildcard) : T 또는 그 하위 타입만 허용, 읽기 전용으로 활용됨
public void printNumbers(List<? extends Number> list) {
    for (Number number : list) {
        System.out.println(number);
    }
}
List<Integer> intList = List.of(1, 2, 3);
List<Double> doubleList = List.of(1.1, 2.2, 3.3);

printNumbers(intList);    // 출력: 1, 2, 3
printNumbers(doubleList); // 출력: 1.1, 2.2, 3.3
  •  장점
    • Number의 하위 타입을 모두 처리할 수 있다.
  • 제약
    • 리스트에 새로운 요소를 추가할 수 없다.
    • ex) list.add(5)

 

  • ? super T (Lower Bounded Wildcard) : T 또는 그 상위 타입만 허용, 쓰기 전용으로 활용됨
public void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
    list.add(3);
}
List<Number> numberList = new ArrayList<>();
addNumbers(numberList);

System.out.println(numberList); // 출력: [1, 2, 3]
  • 장점
    • Integer 뿐만 아니라 Number, Object 같은 상위 타입 리스트에도 요소를 추가할 수 있다.
  • 제약
    • 리스트에서 요소를 읽으면 타입이 Object로 반환되므로, 구체적인 타입을 알 수 없다.

 

✏️ Wildcard 비교 요약

Wildcard 설명 읽기 쓰기
? 모든 타입 허용
? extends T T와 그 하위 타입만 허용
? super T T와 그 상위 타입만 허용

<T extends Number>와 <? extends Number>

내가 헷갈려서 추가 정리하는 <T extends Number>와 <? extends Number> 차이점

 

1. <T extends Number>

T라는 고유한 타입 변수를 선언하여, 제네릭 클래스나 메서드 내에서 일관되게 사용할 수 있다.

메서드나 필드에서 T 타입으로 데이터를 다룰 수 있다.

 

2. <? extends Number>

제네릭 타입을 제한하지만, 구체적인 타입 변수를 선언하지는 않는다.

?는 불특정 타입을 나타내며, 그 타입이 Number 또는 그 하위 클래스임을 보장

읽기 전용으로 데이터를 처리할 때 유용

 

3. 차이점 비교

특징 <T extends Number> <? extends Number>
타입 변수 사용 여부 T라는 타입 변수를 선언하여 내부에서 사용 가능 타입 변수 선언 없이 ?로 표현
적용 범위 제네릭 클래스 또는 메서드에 사용 가능 제네릭 메서드의 매개변수에 주로 사용
읽기/쓰기 읽기/쓰기 모두 가능(T를 통해 추가 가능) 읽기만 가능(리스트에 추가 불가)
데이터 추가 가능
ex: setItem(T item)
불가능
컴파일러가 타입을 알 수 없기 때문
유연성 특정 타입으로 제한된 작업 가능 (T 고정) 다양한 타입의 데이터를 처리할 때 유연함
사용 사례 클래스나 메서드 내에서 타입 변수를 일관되게 사용해야 할 때

데이터의 읽기와 쓰기 작업을 모두 처리해야할 때

제네릭 클래스나 메서드를 설계할 때
제네릭 컬렉션을 처리하면서 읽기 작업만 필요할 때

다양한 타입의 데이터를 처리해야 하지만, 특정 타입에 고정되지 않아야 할 때

타입 안정성을 유지하면서 여러 제네릭 타입을 받아야 할 

Generic 활용 예시

1. 데이터 변환을 위한 유틸리티 클래스

제네릭을 사용하여 범용적으로 사용할 수 있는 유틸리티 메서드를 작성할 수 있다.

public class Pair<K, V> {
    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }
}
Pair<String, Integer> pair = new Pair<>("Age", 30);
System.out.println(pair.getKey() + ": " + pair.getValue()); // 출력: Age: 30

 

2. 타입 제약을 통한 도메인 모델링

도메인에 맞는 타입 제약을 걸어 잘못된 데이터 사용을 방지할 수 있다.

public class Repository<T extends Entity> {
    private List<T> entities = new ArrayList<>();

    public void save(T entity) {
        entities.add(entity);
    }

    public List<T> findAll() {
        return entities;
    }
}

abstract class Entity {
    private Long id;
    // 공통 속성 및 메서드
}

class User extends Entity {
    private String name;
}

class Product extends Entity {
    private String title;
}
Repository<User> userRepo = new Repository<>();
userRepo.save(new User());
// userRepo.save(new Product()); // 컴파일 오류

Generic의 장점과 주의사항

장점

  • 타입 안정성
    • 잘못된 타입 사용을 컴파일 시점에 방지
  • 코드 재사용성
    • 데이터 타입에 상관없이 동일한 코드 구조를 사용할 수 있다.
  • 가독성 및 유지보수성 향상
    • 강제 캐스팅이 줄어들어 코드가 간결해진다.
  • 컴파일 시 타입 검사
    • 런타임 오류를 줄이고, 안전한 코드를 작성할 수 있다.

 

주의사항

  • Type Erasure
    • 제네릭은 컴파일 시 타입 정보가 제거된다.
    • 따라서 런타임에는 타입 정보를 확인할 수 없다.
  • 기본 타입 사용 불가
    • 제네릭은 객체 타입만 허용하므로 기본 타입(int, double 등)을 사용할 수 없다.
    • 대신 래퍼 클래스(Wrapper Class)인 Integer, Double 등을 사용해야 한다.