[Java] 제네릭 (Generic) 완전 정복 T와 와일드 카드

2026. 6. 17. 15:42·Java
반응형

컬렉션 쓸 때 항상 보이는 List<String>, Map<String, Integer>. 그냥 외워서 쓰다 보면 어느 순간 <T>, <?>, <? extends Number> 같은 게 나왔을 때 알아도 멈칫하는 순간이 오더라. 그래서 이번 기회에 그냥 글로 적어보고 싶어서 주제삼아 작성했다.

 

제네릭은 클래스나 메서드를 만들 때 다룰 데이터 타입을 미리 정해두지 않고, 실제로 쓰는 시점에 타입을 끼워 넣을 수 있게 해주는 기능이다. List<T> 에서 T 가 바로 그 자리다. 사용할 때 String이 들어가면 String을 다루는 리스트가 되고, Integer가 들어가면 Integer를 다루는 리스트가 된다. 같은 코드인데 타입만 바꿔서 여러 군데 쓸 수 있는 셈이다.


제네릭을 왜 쓰는걸까

제네릭 없이 코드를 짜면 두 가지 문제가 생긴다.

첫째, 캐스팅(타입 변환, 예를 들어 Object 로 저장해둔 값을 다시 String 으로 바꾸는 작업)을 매번 해줘야 한다. 어떤 타입을 저장하는 자료구조를 만들 때 타입을 특정하지 않으면 Object 로 다루게 되는데, 꺼내서 쓸 때마다 원래 타입으로 직접 바꿔줘야 한다.

둘째, 타입을 잘못 넣어도 컴파일할 때는 에러가 안 난다. 그러다가 프로그램이 실제로 돌아가는 시점(런타임)에 ClassCastException 이라는 에러가 터진다. 이게 문제다. 코드를 빌드할 때는 멀쩡해 보이는데, 막상 실행해보니 터지는 거다.

List<String> list = new ArrayList<>();
list.add("hello");
list.add(123);  // 컴파일 에러. 타입이 안 맞으니 미리 잡아준다.

String value = list.get(0);  // 캐스팅 없이 바로 String으로 받음

 

List<String> 이라고 선언하면 컴파일러가 "이 리스트에는 String만 들어간다"고 인식한다. 그래서 123 같은 다른 타입을 넣으려고 하면 컴파일 단계에서 바로 막아준다. 실행해보고 나서야 알게 되는 에러를, 코드 짜는 단계에서 미리 알려주는 것.  문제가 생기면 간단하게 수정하여 재배포 할 수 있겠다.


T, E, K, V - 타입 파라미터 네이밍 컨벤션

List<T> 에서 T 같은 걸 타입 파라미터라고 부른다. 실제 타입이 들어갈 자리를 임시로 표시해둔 것이다.

알파벳은 아무거나 써도 동작은 똑같다. 근데 회사나 문서 등 여러 자바 코드를 보다보면 보통 이런식으로 많이 쓴다.

기호 의미 사용 예

T Type. 그냥 일반적인 타입을 가리킬 때 class Box<T>
E Element. 컬렉션에 담기는 원소를 가리킬 때 List<E>
K Key. Map의 키 Map<K, V>
V Value. Map의 값 Map<K, V>
N Number. 숫자류 타입 <N extends Number>

 

T 자리에 A 라고 써도 컴파일러는 신경 안 쓴다. 근데 같이 일하는 사람이 코드 읽을 때 "이게 뭐지" 하고 멈추게 되니까, 관례를 따르는 게 좋다.


제네릭 클래스

직접 만들어보면 감이 빨리 온다.

// 제네릭 클래스 선언
public class Box<T> {
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

 

Box<T> 라고 선언해두면, 이 클래스를 실제로 사용하는 시점에 T 자리에 원하는 타입을 넣을 수 있다.

Box<String> stringBox = new Box<>("hello");
Box<Integer> intBox = new Box<>(42);

String s = stringBox.getValue();  // "hello", 캐스팅 불필요
Integer i = intBox.getValue();    // 42, 캐스팅 불필요

 

Box<String> 으로 쓰면 컴파일러가 코드 안의 T 를 전부 String 으로 바꿔서 이해하고, Box<Integer> 로 쓰면 T 를 전부 Integer 로 바꿔서 이해한다. 클래스 코드는 하나만 작성했는데, 쓰는 곳에 따라 다른 타입을 다루는 클래스처럼 동작하는 것이다.

실무에서 공통으로 쓰는 클래스를 만들 때 T 를 자주 쓰게 된다. 응답 형식이나 페이징 처리처럼 "데이터 타입만 다르고 구조는 똑같은" 클래스를 매번 새로 만들기 귀찮으니까, T 하나로 묶어서 여러 군데서 재사용하는 식이다.


제네릭 메서드

클래스 전체가 아니라 메서드 하나에만 타입 파라미터를 선언할 수도 있다.

public class Util {
    // 반환 타입 앞에 <T> 선언
    public static <T> T getFirst(List<T> list) {
        if (list.isEmpty()) {
            return null;
        }
        return list.get(0);
    }
}
List<String> names = List.of("광혁", "철수", "영희");
String first = Util.getFirst(names);  // "광혁"

List<Integer> nums = List.of(1, 2, 3);
Integer firstNum = Util.getFirst(nums);  // 1

 

메서드 단위 제네릭은 static 유틸리티 메서드에서 자주 보인다. 클래스 전체에 타입을 고정시키지 않고, 메서드를 호출할 때마다 타입을 다르게 받고 싶을 때 쓴다.


바운디드 타입 파라미터

<T> 는 그 자체로는 아무 타입이나 받는다. 근데 "Number 계열만 받겠다" 처럼 특정 타입의 하위 타입으로 제한하고 싶을 때가 있다. 이때 extends 를 쓴다.

// Number 또는 Number의 하위 타입만 받겠다
public static <T extends Number> double sum(List<T> list) {
    double total = 0;
    for (T t : list) {
        total += t.doubleValue();  // Number의 메서드 사용 가능
    }
    return total;
}

 

<T extends Number> 라고 쓰면 Integer, Double, Long 처럼 Number 를 상속한 타입만 받을 수 있다. 그리고 T 가 Number 라는 게 보장되니까, 메서드 안에서 Number 가 가진 doubleValue() 같은 메서드를 바로 쓸 수 있다.

sum(List.of(1, 2, 3));       // OK (Integer extends Number)
sum(List.of(1.5, 2.5));      // OK (Double extends Number)
sum(List.of("a", "b"));      // 컴파일 에러 (String은 Number 아님)

 

인터페이스를 제한할 때도 extends 를 쓴다. 클래스를 상속받을 때는 extends, 인터페이스를 구현할 때는 implements 를 쓰는 게 보통이지만, 제네릭의 바운디드 타입에서는 둘 다 extends 로 쓴다.

public <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) >= 0 ? a : b;
}

와일드카드 <?>

이게 처음에 제일 헷갈리는 부분이다. 처음 배울 때 T 랑 비슷한거 같은데 뭔 차이지? 하고 그냥 넘겼다가 나~~~중에서야 찾아가면서 다시 공부했던 기억이 있다. 근데 사실 그렇게 많이는 안 쓰는거 같다...

? 는 "어떤 타입인지 모른다", 정확히는 "타입이 뭐든 상관없다"는 뜻이다. T 와 다른 점은, T 는 타입을 이름 붙여서 다른 곳(반환 타입이나 다른 매개변수)에 다시 활용할 수 있는 것이고, ? 는 그냥 "여기 타입은 신경 안 쓴다" 하고 끝내는 것이다.

// T 사용 - 타입을 이름 붙여서 반환 타입 등에 다시 활용
public <T> T getFirst(List<T> list) { ... }

// ? 사용 - 그냥 읽기만 할 때, 타입이 뭔지 중요하지 않을 때
public void printAll(List<?> list) {
    for (Object item : list) {
        System.out.println(item);
    }
}

 

printAll 은 리스트 안의 값을 출력만 하면 된다. 그 값이 String 이든 Integer 든 신경 쓸 필요가 없다. 이럴 때 ? 를 쓴다.

와일드카드에 경계 추가하기

와일드카드도 범위를 제한할 수 있는데, 방향이 두 가지다.

upper bounded wildcard (? extends T, 상한 경계 - T와 T의 하위 타입까지만 허용)

// Number 또는 Number의 하위 타입 리스트를 받겠다
public double sumAll(List<? extends Number> list) {
    double total = 0;
    for (Number n : list) {
        total += n.doubleValue();
    }
    return total;
}

 

List<Integer>, List<Double>, List<Long> 을 모두 받을 수 있다. 다만 읽기는 되는데 새 요소를 추가하는 건 막힌다. 정확히 어떤 하위 타입의 리스트인지 컴파일러가 확신할 수 없어서, 잘못된 타입이 들어갈 위험을 막으려고 쓰기를 금지하는 것이다.

lower bounded wildcard (? super T, 하한 경계 - T와 T의 상위 타입까지만 허용)

// Integer 또는 Integer의 상위 타입 리스트를 받겠다
public void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
    list.add(3);
}

 

List<Integer>, List<Number>, List<Object> 를 모두 받을 수 있다. Integer 보다 상위 타입이라는 게 보장되니까, Integer 값을 넣는 건 항상 안전하다.

이 두 개를 외우는 트릭이 있다. PECS (Producer Extends, Consumer Super) 라고 부른다.

  • 리스트에서 값을 꺼내서 쓸 때(Producer): ? extends T
  • 리스트에 값을 넣을 때(Consumer): ? super T

처음엔 그냥 이 문구로 외워두고 쓰다 보면 자연스럽게 익혀진다.


제네릭을 주로 쓰는 상황

실무에서 제네릭이 등장하는 패턴은 거의 정해져 있다.

컬렉션 (List, Map, Set)

List<User> users = new ArrayList<>();
Map<String, Order> orderMap = new HashMap<>();

 

제일 흔하게 마주치는 형태이고, 우리가 그냥 아무 생각 없이 사용하는 제네릭 패턴(?) 이라고 볼 수 있다.

컬렉션 안에 어떤 타입이 들어가는지 미리 명시해서, 꺼낼 때 캐스팅 없이 바로 쓰게 해준다.

그냥 List<User> 이렇게 쓰니까 당연한 문법처럼 느껴지는데, List 나 Map 도 결국 누군가가 제네릭으로 만들어놓은 인터페이스다. 자바독이나 IDE에서 List 등을 타고타고 올라가다보면 선언부가 이렇게 되어있다.

public interface List<E> extends Collection<E> {
    boolean add(E e);
    E get(int index);
    ...
}

 

여기 E 가 우리가 위에서 본 타입 파라미터 네이밍 컨벤션 그대로다(Element). Map 도 마찬가지로 K, V 를 쓴다.

public interface Map<K, V> {
    V get(Object key);
    V put(K key, V value);
    ...
}

 

List<User> 라고 쓰면 인터페이스 선언부의 E 자리에 User 가 들어가서, add(E e) 는 add(User e) 가 되고 get(int index) 의 반환 타입은 User 가 된다. 우리가 지금까지 만들어본 Box<T> 랑 원리가 완전히 같다. List, Map, Set 같은 표준 라이브러리도 똑같은 제네릭 문법으로 만들어진 인터페이스일 뿐이라는 거다. 헷갈리면 자바독 가서 직접 선언부 확인해보면 바로 감이 온다.

 

공통 응답/래퍼 클래스

public class ApiResponse<T> {
    private int status;
    private T data;
    private String message;
}

 

데이터 타입만 다르고 구조는 똑같은 클래스를 매번 새로 만들지 않고 하나로 처리할 때 쓴다. ApiResponse<UserDto>, ApiResponse<OrderDto> 처럼 안에 담기는 데이터 타입만 바꿔서 재사용한다. 회사에서 API 응답 형식을 통일할 때 이런 식으로 T 를 써봤는데, 한 번 만들어두니까 컨트롤러마다 비슷한 응답 클래스를 또 만들 일이 없어서 편했다.

저장소/DAO 패턴

public interface Repository<T, ID> {
    T findById(ID id);
    List<T> findAll();
    void save(T entity);
}

 

다루는 엔티티 타입과 ID 타입은 매번 다른데, CRUD(생성/조회/수정/삭제) 로직 자체는 거의 똑같다. 제네릭으로 만들어두면 엔티티마다 똑같은 코드를 또 짜지 않아도 된다. Spring Data JPA의 JpaRepository<T, ID> 도 이 패턴이다.

비교/정렬 로직

public <T extends Comparable<T>> T findMax(List<T> list) { ... }

 

타입이 달라도 "서로 비교가 가능하다"는 조건만 만족하면 동작하는 로직을 만들 때 쓴다.

유틸리티 메서드

public static <T> void swap(T[] arr, int i, int j) { ... }

 

타입과 무관하게 똑같은 동작을 하는 메서드. 배열 swap, null 체크, 캐스팅 헬퍼 같은 게 여기 속한다.


제네릭의 장단점

장점

타입 안전성이 가장 큰 장점이다. 잘못된 타입이 들어가는 걸 코드 작성 단계(컴파일 시점)에 바로 잡아준다. 실행 중에 ClassCastException 같은 에러가 터지는 걸 미리 막아주는 셈이다.

캐스팅이 사라진다. Object 로 받아서 매번 형변환하던 코드가 없어지니까 가독성도 올라간다.

코드 재사용성이 올라간다. 타입별로 거의 똑같은 클래스를 여러 개 만들 필요 없이, 하나의 클래스나 메서드로 다양한 타입을 처리한다. Repository<User, Long>, Repository<Order, String> 처럼 구조는 그대로 두고 타입만 바꿔서 쓴다.

 

단점

타입 소거(컴파일 후 런타임에는 타입 파라미터 정보가 사라지는 것, 바로 다음 섹션에서 자세히 다룬다) 때문에 생기는 제약이 꽤 있다. new T() 같은 코드를 못 쓰고, instanceof T 같은 검사도 못 한다.

코드가 복잡해 보일 수 있다. Map<String, List<Map<String, Object>>> 처럼 제네릭이 여러 겹 겹치면 처음 보는 사람은 읽기 부담스럽다. 와일드카드(<? extends T>, <? super T>) 까지 섞이면 진입 장벽이 더 올라간다.

배열과 같이 쓸 때 제약이 있다. new T[10] 같은 제네릭 배열 생성이 안 되기 때문에, 우회 방법(@SuppressWarnings("unchecked") 로 경고를 무시하는 캐스팅 등)을 써야 하는 경우가 생긴다.


제네릭 사용시 주의해야 할 점

제네릭에는 몇 가지 제약이 있는데, 이유를 모르고 보면 "왜 이게 안 되지" 하고 막힐 수 있다.

타입 파라미터로 인스턴스 생성 불가

public class Repo<T> {
    // 컴파일 에러
    T instance = new T();  // 안 됨
}

 

이게 왜 안 되는지 이해하려면 자바 제네릭이 동작하는 방식을 알아야 한다.

자바 컴파일러는 .java 코드를 .class 파일(바이트코드)로 바꾸는 과정에서 제네릭 타입 정보를 지워버린다. 이걸 타입 소거(Type Erasure) 라고 부른다. 즉 우리가 코드 짤 때 보는 T, List<String> 같은 타입 정보는 컴파일 시점에만 존재하고, 컴파일이 끝나서 실제로 프로그램이 돌아가는 시점(런타임)에는 사라진다.

// 우리가 작성한 코드
Box<String> box1 = new Box<>("hello");
Box<Integer> box2 = new Box<>(42);

// 컴파일러가 바이트코드로 바꾸면 사실상 이런 모습 (타입 정보 사라짐)
Box box1 = new Box("hello");
Box box2 = new Box(42);

 

List<String> 이든 List<Integer> 든 런타임에는 그냥 List 다. JVM(자바 프로그램을 실제로 실행하는 가상 머신)이 보기엔 둘이 똑같은 타입이라는 뜻이다.

이제 new T() 가 왜 안 되는지 보자. new T() 는 "T라는 타입의 인스턴스를 새로 만들어라"는 명령이다. 그런데 런타임에는 T 가 뭐였는지 정보 자체가 없다. Box<String> 으로 썼는지 Box<Integer> 로 썼는지 이미 지워진 상태라서, JVM은 T 자리에 뭘 넣어서 객체를 만들어야 할지 알 방법이 없다. 그래서 컴파일러가 애초에 이 코드를 막아버리는 것이다.

같은 이유로 T.class 같은 코드도 못 쓰고, instanceof T 같은 타입 검사도 못 한다. 전부 "런타임에 T가 뭔지 모른다"는 동일한 원인에서 나온다.

이걸 우회하고 싶으면 타입 정보를 직접 넘겨주는 방법을 쓴다. 자주 보이는 패턴이 Class<T> 를 매개변수로 받는 것이다.

public class Repo<T> {
    private Class<T> type;

    public Repo(Class<T> type) {
        this.type = type;
    }

    public T createInstance() throws Exception {
        return type.getDeclaredConstructor().newInstance();  // 리플렉션으로 생성
    }
}
Repo<User> repo = new Repo<>(User.class);
User user = repo.createInstance();

 

Class<T> 를 생성자로 직접 받아두면, 런타임에도 "이 타입은 User다" 라는 정보를 들고 있게 된다. 그래서 리플렉션(클래스 정보를 이용해서 객체를 동적으로 다루는 기능)을 통해 인스턴스를 만들 수 있다. 다만 이건 어디까지나 우회법이고, 일반적인 상황에서는 new T() 가 필요한 구조 자체를 다시 생각해보는 게 맞다.

static 멤버에 타입 파라미터 사용 불가

public class Box<T> {
    // 컴파일 에러
    static T defaultValue;  // 안 됨
}

 

static 멤버는 인스턴스 하나하나가 아니라 클래스 자체에 딱 하나만 존재한다. 그런데 T 는 인스턴스를 만들 때마다 달라질 수 있는 값이다. Box<String> 을 만들면 T 가 String 이고, Box<Integer> 를 만들면 T 가 Integer 다. 만약 static T defaultValue 가 허용된다면, Box<String> 과 Box<Integer> 가 같은 defaultValue 를 공유하게 되는데 타입이 서로 다르니 충돌이 난다. 그래서 애초에 막혀 있다.

어? 그런데 아까 제네릭 메서드 설명할 때 static 이 붙은 예시가 있었던 것 같은데, 싶을 수 있다.

public static <T> T getFirst(List<T> list) { ... }

 

이건 분명 static 인데 T 를 멀쩡하게 쓰고 있다. 방금 안 된다고 했는데 왜 되는 걸까.

차이는 이 T 가 어디 소속이냐다. Box<T> 의 T 는 클래스에 묶여있는 타입이다. 누군가 Box<String> 으로 쓰고 누군가 Box<Integer> 로 쓰는데, static 멤버는 그 둘 사이에서 단 하나만 존재해야 하니까 "어느 쪽 T냐"를 정할 수가 없어서 막힌 거였다.

반면 getFirst 의 <T> 는 메서드 이름 앞에 따로 선언되어 있다. 이건 클래스의 T 를 빌려쓰는 게 아니라, 이 메서드만의 T 를 새로 만든 것이다. 메서드가 호출될 때마다 그 순간에 맞는 타입으로 새로 정해진다.

String s = Util.getFirst(List.of("a", "b"));   // 이 호출에서는 T가 String
Integer i = Util.getFirst(List.of(1, 2, 3));   // 이 호출에서는 T가 Integer

 

static 멤버는 "클래스 전체에 고정된 하나의 값"이 문제였는데, 이 T 는 고정된 값이 아니라 호출될 때마다 새로 정해지는 값이라서 충돌이 안 난다. static 메서드 안에서 지역 변수를 매번 새로 받는 것과 비슷한 느낌이다.

정리하면, 안 되는 건 "클래스의 T를 static 멤버가 그대로 가져다 쓰는 것"이고, getFirst 는 "메서드 스스로 독립적인 T를 선언한 것"이라서 서로 다른 얘기였던 거다.

기본 타입 사용 불가

List<int> list = new ArrayList<>();    // 안 됨
List<Integer> list = new ArrayList<>(); // OK

 

제네릭의 타입 파라미터에는 참조 타입(객체로 다뤄지는 타입)만 들어갈 수 있다. int, double, char 같은 기본 타입(primitive type)은 들어갈 수 없고, 대신 그 타입을 객체로 감싼 래퍼 클래스(Integer, Double, Character)를 써야 한다.


정리

개념 요약

제네릭 클래스 <T> 타입을 파라미터로 받는 클래스. 재사용성 높아짐
제네릭 메서드 <T> 메서드 단위 타입 파라미터. static 유틸에서 자주 사용
바운디드 <T extends X> 특정 타입 이하로 제한. T의 메서드 사용 가능
와일드카드 <?> 타입 불특정. 읽기 전용 상황에서 사용
<? extends T> 상한 경계. T 하위 타입만. 읽기에 유리
<? super T> 하한 경계. T 상위 타입만. 쓰기에 유리
주요 사용처 컬렉션, 공통 응답 래퍼, Repository/DAO, 비교/정렬, 유틸 메서드
장점 타입 안전성, 캐스팅 제거, 코드 재사용성
단점 타입 소거로 인한 제약, 중첩 시 가독성 저하, 배열 생성 제약
타입 소거 컴파일 후 런타임엔 타입 정보 없음. new T() 불가 이유

 

 

반응형

'Java' 카테고리의 다른 글

[Java] HashMap 은 뭐고 어떻게 동작 하는 걸까?  (0) 2026.04.03
Java - Collection Framework란?  (0) 2025.04.24
Java - 면접 질문으로 다시 보는 Java의 List와 구현체(ArrayList 등)와 관계  (2) 2025.04.24
Java - for each문 직접 구현해보기  (0) 2025.04.24
Java - for each문  (0) 2025.04.24
'Java' 카테고리의 다른 글
  • [Java] HashMap 은 뭐고 어떻게 동작 하는 걸까?
  • Java - Collection Framework란?
  • Java - 면접 질문으로 다시 보는 Java의 List와 구현체(ArrayList 등)와 관계
  • Java - for each문 직접 구현해보기
fkqlaus
fkqlaus
안녕하세요 Java, Spring boot 공부하는 주니어 개발자입니다
  • fkqlaus
    개발자가 끄적끄적 블로그
    fkqlaus
  • 전체
    오늘
    어제
    • 분류 전체보기 (24)
      • Spring boot (3)
      • 프레임워크 (3)
      • Java (6)
      • DevOps (3)
      • DB (1)
      • CS (1)
      • GIS (1)
      • 알고리즘 문제풀이 (9)
      • 알고리즘 (0)
  • 인기 글

  • 태그

    iterator
    docker
    db
    SWEA
    코딩테스트
    개발자
    D2
    컴퓨터
    서버
    cs
    알고리즘
    DevOps
    완전탐색
    개발
    데이터베이스
    spring
    collection
    Java
    프로그래머스
    list
  • hELLO· Designed By정상우.v4.10.3
fkqlaus
[Java] 제네릭 (Generic) 완전 정복 T와 와일드 카드
상단으로

티스토리툴바