Java Generics & Wildcards 완전 정복

devK08·2025년 12월 29일

1. 들어가며


Generics를 알아야 하는 이유

Generics가 없던 시절 코드 (Java 5 이전 코드)

Generics가 없었던 시절에는 List에 값이 어떤 형태든 허용되었다.
그래서 위와 같이 코드를 짤 수 있었다.

List list = new ArrayList();
list.add("Hello");
list.add(123);
list.add(new User());String str = (String) list.get(1);

위 코드를 실행하게 되면 ClassCastException이 발생한다.
이유는 리스트에서 Integer 타입의 값을 꺼내 String으로 타입 캐스팅을 시도했기 때문이다.

Generics가 생긴 후 코드 (Java 5 이후 코드)

Generics가 도입된 이후,
런타임에 발생하던 타입 관련 오류를 컴파일 시점에 잡을 수 있게 되었다.

List<String> list = new ArrayList<>();
list.add("Hello");
list.add(123); // 컴파일 에러String str = list.get(0);

위 코드에서 list.add(123)은 컴파일 단계에서 에러가 발생한다.
왜냐하면, List<String>으로 타입을 지정했기 때문에 String이 아닌 값은 애초에 넣을 수 없기 때문이다.
또한 값을 꺼낼 때도 별도의 타입 캐스팅 없이 바로 사용할 수 있다.


2. Generics 기초


Generic이란?

클래스나 메서드에서 사용할 타입을 외부에서 지정할 수 있게 해주는 기능이다.
쉽게 말해, 타입을 파라미터처럼 전달받아 사용하는 것이다.

// 제네릭 클래스 정의
public class Box<T> {
    private T item;public void set(T item) {
        this.item = item;
    }public T get() {
        return item;
    }
}// 사용
Box<String> stringBox = new Box<>();
stringBox.set("Hello");
String value = stringBox.get();Box<Integer> intBox = new Box<>();
intBox.set(123);
Integer number = intBox.get();

타입 파리미터 컨벤션

기호의미예시
TTypeBox<T>
EElementList<E>
KKeyMap<K, V>
VValueMap<K, V>

Generic 메서드

public class Utils {public static <T> T getFirst(List<T> list) {
        return list.get(0);
    }
}// 사용
List<String> names = List.of("Kim", "Lee", "Park");
String first = Utils.getFirst(names);  // "Kim"

여기서 주의할 점이 있다면,
Generic 메서드는 항상 메서드 반환 타입 앞에 타입 파라미터 <T>를 넣어야 한다.
그 이유는 <T>는 Java가 모르는 타입이라서,
Java에게 "이 메서드에서는 <T>타입을 쓸거야" 라고 알려줘야하기 때문이다.

제한된 타입 파라미터

타입 파라미터에 특정 타입의 하위 타입만 올 수 있도록 제한하는 기능이다.

문제점

// 제한 없는 제네릭
public <T> void printDouble(T value) {
    System.out.println(value.doubleValue());  // 컴파일 에러
}

<T>에는 아무 타입이나 올 수 있기 때문에, doubleValue() 메서드가 있는지 보장할 수 없다.

해결방법

// Number의 하위 타입만 허용
public <T extends Number> void printDouble(T value) {
    System.out.println(value.doubleValue());
}

<T extends Number>로 선언하면, <T>는 반드시 Number의 하위 타입이어야 한다.
Number 클래스에는 doubleValue() 메서드가 있으므로 안전하게 호출할 수 있다.


3. 공변성, 반공변성, 무공변성


변성이란?

"타입 간의 상속 관계제네릭에서도 유지되는가?"에 대한 개념이다.
StringObject의 하위 타입이다.
그렇다면 List<String>List<Object>의 하위 타입일까?

공변(Covariant)

AB의 하위 타입이면, T<A>T<B>의 하위 타입이다.

즉, 상속 관계가 그대로 유지된다.

// 배열은 공변
Object[] objectArr = new String[3];  // 컴파일 성공Object[] objectArr = new String[3];
objectArr[0] = 123; // ArrayStoreException 발생

위 코드를 보면 알 수 있듯이,
공변은 컴파일 시점에 문제가 발생하지 않고, 런타임 시점에 문제가 발생한다.

무공변(Invariant)

AB의 하위 타입이어도, T<A>T<B>는 아무 관계가 없다.

즉, 상속 관계가 유지되지 않는다.

List<String> stringList = new ArrayList<>();
List<Object> objectList = stringList; // 컴파일 에러

위 코드를 보면 알 수 있듯이,
무공변으로 코드를 짜면, 컴파일 단계에서 오류가 발생한다.

반공변(Contravariant)

반공변 (Contravariant)

AB의 하위 타입이면, T<B>T<A>의 하위 타입이다.

즉, 상속 관계가 반대로 뒤집힌다.

List<? super Integer> list1 = new ArrayList<Integer>();
List<? super Integer> list2 = new ArrayList<Number>();
List<? super Integer> list3 = new ArrayList<Object>();

위 코드를 보면 알 수 있듯이,
Integer상위 타입Number, Object를 담는 리스트도 대입이 가능하다.

일반적인 상속 관계는 Integer → Number → Object 순이지만,
반공변에서는 List<Object> → List<Number> → List<Integer> 순으로 뒤집힌다.


4. Wildcard


Wildcard란?

Wildcard는 제네릭에서 유연한 타입을 허용하기 위해 사용하는 기능이다.

? 기호로 표현하며, "어떤 타입이든" 이라는 의미를 가진다.

필요한 이유

public void printList(List<Object> list) {
    for (Object obj : list) {
        System.out.println(obj);
    }
}List<String> names = List.of("Kim", "Lee", "Park");
printList(names);  // 컴파일 에러

제네릭은 무공변이기 때문에 List<String>List<Object>로 받을 수 없다.

public void printList(List list) {
    for (Object obj : list) {
        System.out.println(obj);
    }
}List<?> names = List.of("Kim", "Lee", "Park");
printList(names);  // ✅ 가능!

이를 위해서, List<?>를 사용하면 어떤 타입의 리스트든 받을 수 있다.

T?의 차이

구분T (타입 파라미터)? (와일드카드)
역할타입을 선언해서 재사용아무 타입이나를 의미
사용여러 곳에서 같은 타입으로 묶을 때일회성으로 타입을 받을 때

T - 타입을 묶어서 재사용

public <T> void copy(List<T> src, List<T> dest) {
    for (T item : src) {
        dest.add(item);
    }
}

srcdest같은 타입이어야 한다.
T로 선언했기 때문에 둘이 묶여있다.

? - 그냥 아무거나

public void printAll(List<?> list) {
    for (Object item : list) {
        System.out.println(item);
    }
}

어떤 타입의 List든 상관없이 받겠다는 의미다.
타입을 재사용할 필요가 없을 때 사용한다.

Wildcard의 종류

종류문법의미특징
비한정<?>모든 타입 허용읽기만 가능
상한 경계<? extends T>T와 T의 하위 타입만 허용읽기만 가능
하한 경계<? super T>T와 T의 상위 타입만 허용쓰기만 가능

5. PECS 원칙


Producer-Extends, Consumer-Super

데이터를 읽을 때extends, 데이터를 쓸 때super를 사용하라.

약자의미와일드카드동작
Producer데이터를 생산 (꺼내는 쪽)<? extends T>읽기
Consumer데이터를 소비 (넣는 쪽)<? super T>쓰기

6. 타입 소거


타입 소거란?

제네릭 타입 정보가 컴파일 시점에만 존재하고, 런타임에는 제거되는 것을 말한다.

타입 소거를 해야하는 이유

하위 호환성 때문이다.

  • 제네릭은 Java 5 (2004년)에 도입되었다.
  • 그 전에 작성된 수많은 코드들과 호환되어야 했다.
  • 그래서 런타임에는 제네릭 정보를 지우는 방식을 선택했다.

7. SpringBoot Generics 적용 사례


ResponseEntity<T>

HTTP 응답을 표현하는 클래스로, Body의 타입을 제네릭으로 지정한다.

@GetMapping("/{id}")
public ResponseEntity getUser(@PathVariable Long id) {
    UserDto user = userService.findById(id);
    return ResponseEntity.ok(user);
}@GetMapping
public ResponseEntity<List> getUsers() {
    List users = userService.findAll();
    return ResponseEntity.ok(users);
}


ResponseEntity<T>HttpEntity<T>를 상속받아 구현되어 있다.
제네릭 덕분에 어떤 타입이든 Body로 감싸서 반환할 수 있다.


8. 자주하는 실수 & 주의사항


1. 기본형(Primitive) 타입 사용 불가

List<int> list = new ArrayList<>();      // 컴파일 에러
List<Integer> list = new ArrayList<>();  // Wrapper 클래스 사용

제네릭은 참조 타입만 사용할 수 있다.
기본형 대신 Wrapper 클래스(Integer, Double, Boolean 등)를 사용해야 한다.

2. 제네릭 타입으로 오버로딩 불가

public void print(List<String> list) { }
public void print(List<Integer> list) { } // 컴파일 에러

타입 소거 후 둘 다 print(List list)가 되어 메서드 시그니처가 동일해진다.

3. Generics 인스턴스 생성 불가

문제

public class Box {
    public T createInstance() {
        return new T();  // 컴파일 에러
    }
}

런타임에 T가 무슨 타입인지 모르기 때문에 인스턴스를 생성할 수 없다.

해결 방법

public class Box {
    public T createInstance(Supplier supplier) {
        return supplier.get();  // Supplier를 통해 생성
    }
}

profile
안녕하세요. 개발자 지망 고등학생입니다.

0개의 댓글