List에 String을 넣었는데 꺼낼 때 Integer로 캐스팅하면? 컴파일러는 아무 말 없고, 런타임에서야 ClassCastException이 터진다. 제네릭은 이 문제를 왜, 어떻게 해결하는 걸까?

제네릭이 왜 필요한가

제네릭 은 타입을 파라미터로 받아 컴파일 시점에 타입 안전성을 보장하는 기능이에요. 캐스팅 없이도 타입이 보장됩니다.

제네릭 없던 시절, 컬렉션은 전부 Object로 저장했어요.

JAVA
List list = new ArrayList();
list.add("hello");
list.add(123); // 컴파일 에러 안 남
String s = (String) list.get(1); // 런타임에 ClassCastException

뭘 넣든 컴파일러가 막지 않으니, 꺼낼 때마다 캐스팅해야 하고 잘못된 타입은 런타임에서야 터집니다. 제네릭을 적용하면 컴파일 시점에 차단돼요.

JAVA
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(123); // 컴파일 에러!
String s = list.get(0); // 캐스팅 불필요

제네릭 클래스 / 메서드 / 인터페이스

제네릭 클래스

JAVA
public class Box<T> {
    private T item;

    public void set(T item) { this.item = item; }
    public T get() { return item; }
}

Box<String> box = new Box<>();
box.set("apple");
String fruit = box.get();

T는 타입 파라미터입니다. 관례적으로 T(Type), E(Element), K(Key), V(Value), N(Number) 등을 써요. 그냥 관례일 뿐이고 아무 이름이나 써도 동작은 합니다.

제네릭 메서드

클래스가 아니라 메서드 단위로도 타입 파라미터를 선언할 수 있어요.

JAVA
public class Util {
    public static <T> T getFirst(List<T> list) {
        return list.get(0);
    }
}

String first = Util.getFirst(List.of("a", "b", "c")); // 타입 추론

반환 타입 앞에 <T>를 선언하는 게 포인트예요. 클래스 레벨 제네릭과는 별개의 타입 파라미터라서, 제네릭 클래스가 아니어도 쓸 수 있습니다.

제네릭 인터페이스

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

public class UserRepository implements Repository<User, Long> {
    @Override
    public User findById(Long id) { /* ... */ }

    @Override
    public void save(User entity) { /* ... */ }
}

Spring Data JPA의 JpaRepository<T, ID>가 대표적인 사례입니다. 이런 식으로 구현 클래스에서 구체적인 타입을 지정하면, 타입 안전한 CRUD를 바로 쓸 수 있어요.

바운디드 타입 파라미터

타입 파라미터에 제약을 걸 수도 있어요.

JAVA
public <T extends Number> double sum(List<T> list) {
    return list.stream().mapToDouble(Number::doubleValue).sum();
}

T extends Number이니까 Number의 하위 타입만 허용됩니다. String을 넣으려 하면 컴파일 에러가 나요. 여러 제약을 걸고 싶으면 &로 이어 붙이면 돼요.

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

클래스는 하나만, 인터페이스는 여러 개 가능합니다. 클래스가 있으면 맨 앞에 와야 해요.

타입 소거 (Type Erasure)

타입 소거 란 컴파일러가 제네릭 타입 정보를 확인한 뒤 바이트코드에서 지워버리는 것입니다. 런타임에는 제네릭 정보가 없어요.

JAVA
// 소스 코드
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0);

// 바이트코드 (개념적)
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // 컴파일러가 캐스팅 삽입

바운디드 타입이면 해당 바운드로, 아니면 Object로 대체돼요. Box<T extends Number>는 소거 후 Number item이 됩니다.

왜 이런 설계일까요? Java 5에서 제네릭을 추가할 때, Java 4 이하 코드와의 하위 호환성 을 유지해야 했기 때문입니다. ListList<String>이 런타임에 동일한 클래스여야 기존 코드가 깨지지 않아요.

타입 소거로 인한 제약

타입 소거 때문에 런타임에서 제네릭 타입 정보가 없습니다. 이게 여러 제약을 만들어요.

new T() 불가

JAVA
public class Factory<T> {
    public T create() {
        // return new T(); // 컴파일 에러! 타입 정보가 없어서 인스턴스 생성 불가
    }
}

런타임에 T가 뭔지 모르니까 생성자를 호출할 수 없어요. 우회하려면 Class<T>를 넘겨야 합니다.

JAVA
public class Factory<T> {
    private final Class<T> type;

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

    public T create() throws Exception {
        return type.getDeclaredConstructor().newInstance();
    }
}

instanceof T 불가

JAVA
public <T> boolean isTypeOf(Object obj) {
    // return obj instanceof T; // 컴파일 에러
}

마찬가지로 런타임에 T 정보가 없어요. Class<T>를 받아서 type.isInstance(obj)로 우회해야 합니다.

제네릭 배열 생성 불가

JAVA
// T[] arr = new T[10]; // 컴파일 에러

// 이것도 안 됨
// List<String>[] arr = new List<String>[10]; // 컴파일 에러

배열은 런타임에 타입 정보를 갖고 있어서(ArrayStoreException 던지려고), 타입이 소거되는 제네릭과 충돌합니다. 배열 대신 List<List<String>> 같은 컬렉션을 쓰는 게 맞아요.

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

JAVA
// 컴파일 에러 — 소거 후 둘 다 process(List)가 됨
public void process(List<String> list) { }
public void process(List<Integer> list) { }

소거되면 시그니처가 동일해지니까 오버로딩이 안 됩니다.

와일드카드

제네릭 타입을 좀 더 유연하게 쓰고 싶을 때 와일드카드 ?를 씁니다.

Unbounded 와일드카드: ?

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

어떤 타입이든 받을 수 있어요. 단, list.add()null 말고는 못 넣습니다. 타입을 모르니까 안전하게 막아둔 거예요.

상한 와일드카드: ? extends T

JAVA
public double sum(List<? extends Number> list) {
    double total = 0;
    for (Number n : list) {
        total += n.doubleValue();
    }
    return total;
}

sum(List.of(1, 2, 3));       // List<Integer> OK
sum(List.of(1.1, 2.2, 3.3)); // List<Double> OK

Number의 하위 타입만 허용해요. 읽기는 Number로 가능하지만, 쓰기는 안 됩니다. 컴파일러 입장에서 List<Integer>인지 List<Double>인지 모르니까, 아무것도 넣을 수 없게 막는 거예요.

하한 와일드카드: ? super T

JAVA
public void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
    list.add(3);
}

List<Number> numbers = new ArrayList<>();
addNumbers(numbers); // OK — Number는 Integer의 상위 타입

List<Object> objects = new ArrayList<>();
addNumbers(objects); // OK — Object도 Integer의 상위 타입

Integer의 상위 타입만 허용합니다. 쓰기는 Integer로 가능하지만, 읽을 때는 Object로밖에 못 받아요.

PECS — Producer Extends, Consumer Super

Joshua Bloch가 Effective Java에서 제시한 원칙입니다. 외우기 쉬운데 의미가 확실해요.

  • 데이터를 꺼내서 쓰는 쪽(Producer)? extends T
  • ** 데이터를 집어넣는 쪽(Consumer)** → ? super T

Collections.copy가 교과서적인 예시입니다.

JAVA
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (int i = 0; i < src.size(); i++) {
        dest.set(i, src.get(i));
    }
}

src에서 데이터를 ** 꺼내니까 **(Producer) extends, dest에 데이터를 ** 넣으니까 **(Consumer) super입니다.

실제 사용 예를 볼게요.

JAVA
List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = new ArrayList<>(List.of(0.0, 0.0, 0.0));

Collections.copy(nums, ints); // Integer → Number로 복사

List<Integer>List<? extends Number>에 해당하고(Producer), List<Number>List<? super Integer>에 해당합니다(Consumer). PECS가 딱 맞아떨어져요.

반환 타입에는 와일드카드를 쓰지 않습니다. List<?>를 리턴하면 호출 쪽에서 아무것도 할 수 없어요. 와일드카드는 ** 파라미터에서만** 쓰는 게 원칙입니다.

제네릭과 상속: 불공변(Invariant)

StringObject의 하위 타입이지만, List<String>List<Object>의 하위 타입이 ** 아닙니다.** 제네릭은 불공변(invariant)이에요.

이유는 단순합니다. 만약 허용하면 List<Object>로 취급된 List<String>Integer를 넣을 수 있게 되어 타입 안전성이 깨져요. 공변이 필요하면 와일드카드(? extends)를 쓰면 됩니다.

배열은 공변이다. String[]Object[]의 하위 타입이므로 잘못된 타입을 넣으면 런타임에 ArrayStoreException이 발생한다. 제네릭은 같은 실수를 컴파일 타임에 잡는다.

리플렉션과 제네릭

타입 소거 때문에 런타임에 제네릭 정보가 없다고 했는데, 완전히 없는 건 아닙니다. ** 클래스 선언부에 적힌 제네릭 정보 **는 바이트코드에 메타데이터로 남아 있어요.

ParameterizedType

JAVA
public class StringList extends ArrayList<String> { }

Type superclass = StringList.class.getGenericSuperclass();
if (superclass instanceof ParameterizedType) {
    ParameterizedType pt = (ParameterizedType) superclass;
    Type[] typeArgs = pt.getActualTypeArguments();
    System.out.println(typeArgs[0]); // class java.lang.String
}

StringListArrayList<String>을 상속하면서 타입 인자를 고정했기 때문에, 이 정보가 클래스 메타데이터에 기록됩니다. 리플렉션으로 꺼낼 수 있어요.

TypeToken 패턴 (Super Type Token)

이걸 응용한 게 TypeToken 패턴이에요. Gson이 대표적으로 씁니다.

JAVA
Type type = new TypeToken<List<String>>() {}.getType();
List<String> list = gson.fromJson(json, type);

new TypeToken<List<String>>() {}이 하는 일은, 익명 클래스를 만들어서 TypeToken<List<String>>을 상속하는 거예요. 상속 관계에 적힌 타입 인자 정보는 소거되지 않으니까, 런타임에도 List<String>이라는 정보를 가져올 수 있습니다.

Spring의 ParameterizedTypeReference도 같은 원리예요.

JAVA
ResponseEntity<List<User>> response = restTemplate.exchange(
    "/api/users",
    HttpMethod.GET,
    null,
    new ParameterizedTypeReference<List<User>>() {}
);

실무 활용

제네릭 리포지토리 패턴

생성자에서 Super Type Token 원리로 Class<T>를 추출합니다. JPA EntityManagerClass 객체가 필요한데, 매번 파라미터로 받지 않아도 돼요.

JAVA
public abstract class BaseRepository<T, ID> {
    private final Class<T> entityType;

    @SuppressWarnings("unchecked")
    protected BaseRepository() {
        ParameterizedType pt = (ParameterizedType)
            getClass().getGenericSuperclass();
        this.entityType = (Class<T>) pt.getActualTypeArguments()[0];
    }
}

// UserRepository extends BaseRepository<User, Long>
// → entityType은 자동으로 User.class

타입 안전한 빌더

리플렉션과 제네릭을 조합하면 범용 빌더를 만들 수 있어요. build(Class<T>)Class 객체를 넘겨 인스턴스를 생성하고, 리플렉션으로 필드를 설정합니다. 실무에서는 Lombok @Builder를 쓰지만, 제네릭과 리플렉션의 조합을 이해하기 좋은 예시예요.

제네릭 이벤트 핸들러

이벤트 타입별로 핸들러를 분리할 때 제네릭이 진가를 발휘해요. Class<T>를 키로 쓰면서 타입 안전성을 확보합니다.

JAVA
public class EventBus {
    private final Map<Class<?>, List<EventHandler<?>>> handlers
        = new HashMap<>();

    public <T extends Event> void register(
            Class<T> eventType, EventHandler<T> handler) {
        handlers.computeIfAbsent(eventType, k -> new ArrayList<>())
                .add(handler);
    }
}

주의할 점

브릿지 메서드

타입 소거 후 시그니처가 달라진 오버라이딩 메서드를 연결하기 위해 컴파일러가 자동 생성하는 메서드입니다.

JAVA
public class StringBox implements Box<String> {
    @Override
    public String get() { return "hello"; }
}

소거 후 Box 인터페이스의 get()Object get()이 됩니다. 그런데 StringBoxString get()을 구현했어요. 시그니처가 다르니까 오버라이딩이 아닌 셈이에요.

이걸 맞추기 위해 컴파일러가 브릿지 메서드를 끼워 넣습니다.

JAVA
// 컴파일러가 생성하는 브릿지 메서드
public Object get() {
    return get(); // String get()을 호출
}

javap -c StringBox로 바이트코드를 뜯어보면 실제로 확인할 수 있어요. isBridge() 메서드로 리플렉션에서 구별도 가능합니다.

제네릭과 Enum

Enum은 제네릭을 쓸 수 없어요. enum Status<T> { ... } 이런 건 불가능합니다. 왜냐하면 Enum 상수는 각각이 Enum 클래스의 인스턴스인데, 상수마다 다른 타입 파라미터를 가질 수 없기 때문이에요.

하지만 Enum이 제네릭 인터페이스를 구현하는 건 가능합니다.

JAVA
public enum JsonParser implements Parser<JsonNode> {
    INSTANCE;

    @Override
    public JsonNode parse(String input) { /* ... */ }
}

raw 타입 사용 금지

List(raw)를 쓰면 제네릭의 이점이 전부 사라집니다. Comparable<Student>를 쓰면 compareTo 파라미터가 Student로 고정되어 캐스팅이 필요 없어요. 같은 맥락에서 Class<T>도 raw Class보다 Class<String> 형태로 쓰는 것이 안전합니다.

재귀적 타입 바운드

T extends Comparable<T>는 "자기 자신과 비교 가능한 타입"이라는 뜻이에요. 컬렉션에서 최대값을 구하는 제네릭 메서드를 만들 때 이 패턴이 필요합니다.

JAVA
public static <T extends Comparable<T>> T max(Collection<T> c) {
    Iterator<T> it = c.iterator();
    T result = it.next();
    while (it.hasNext()) {
        T t = it.next();
        if (t.compareTo(result) > 0) result = t;
    }
    return result;
}

TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.

정리

항목핵심
제네릭의 목적컴파일 시점 타입 안전성 + 캐스팅 제거
타입 소거런타임에 제네릭 정보 없음. 하위 호환성 때문
소거로 인한 제약new T(), instanceof T, 제네릭 배열 생성, 오버로딩 모두 불가
? extends T데이터를 꺼내는 쪽(Producer). 읽기만 가능
? super T데이터를 넣는 쪽(Consumer). 쓰기 가능
PECSProducer Extends, Consumer Super
불공변List<String>List<Object>의 하위 타입이 아님
Super Type Token상속 관계에 적힌 타입 인자는 소거되지 않아 리플렉션으로 추출 가능

다음 글에서는 ** 람다와 스트림 **을 다룹니다. 컬렉션을 더 간결하고 선언적으로 처리하는 방법이 궁금하다면 이어서 봐주세요.

댓글 로딩 중...