[☕JAVA] 제네릭(Generic)

[☕JAVA] 제네릭(Generic)

List<String>, Optional<T>, Map<K, V> — Java 코드 어디서나 보이는 꺾쇠 괄호. 제네릭을 모르면 Spring, 컬렉션, 람다 어느 것도 제대로 이해하기 어렵다. 제네릭의 탄생 배경부터 와일드카드, 타입 소거까지 완전히 정리해보자!


왜 제네릭이 필요한가? 🤔

Java 1.5 이전, 제네릭이 없던 시절의 코드를 먼저 보자.

1
2
3
4
5
6
// 제네릭 없던 시절
List list = new ArrayList();
list.add("hello");
list.add(42);                       // 아무 타입이나 다 들어감

String s = (String) list.get(1);   // 런타임 ClassCastException! 42는 String이 아님

문제는 list.add(42)가 컴파일될 때는 아무런 에러가 없다는 것이다. 문제는 실행 중에야 터진다.

제네릭은 이 타입 오류를 컴파일 타임으로 끌어올렸다.

1
2
3
4
5
6
// 제네릭 도입 후
List<String> list = new ArrayList<>();
list.add("hello");
list.add(42);        // 컴파일 에러! 여기서 즉시 잡힘

String s = list.get(0); // 형변환도 필요 없음

제네릭이 해결하는 두 가지 문제:

  • 타입 안전성: 잘못된 타입이 들어오면 컴파일 타임에 에러
  • 코드 재사용: 같은 로직을 Integer, String, User 등 어떤 타입에도 적용 가능

제네릭 도입 전후 비교

왼쪽(제네릭 없음): ListStringInteger를 모두 넣을 수 있고, 꺼낼 때 (String) 캐스팅 시도 시 런타임에 ClassCastException 발생한다.

오른쪽(제네릭 사용): List<String>42를 추가하려고 하면 컴파일 시점에 IDE가 빨간 밑줄로 에러 표시한다. 런타임 에러를 컴파일 에러로 앞당겨 타입 안전성을 보장한다.


타입 파라미터 기본 문법 📝

제네릭은 클래스/메서드를 정의할 때 타입을 나중에 결정할 수 있게 해주는 장치다. <T>가 그 자리표시자다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Box<T> {   // T는 "아직 정해지지 않은 타입"
    private T value;

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

// 사용 시점에 T가 확정됨
Box<String>  strBox = new Box<>();  // T = String
Box<Integer> intBox = new Box<>();  // T = Integer

strBox.set("hello");
intBox.set(42);

String  s = strBox.get(); // 형변환 없이 바로 String
Integer i = intBox.get(); // 형변환 없이 바로 Integer

타입 파라미터 이름 관례

기호의미대표 예시
TType — 일반적인 타입Box<T>
EElement — 컬렉션 원소List<E>
K, VKey, Value — 맵의 키/값Map<K, V>
NNumber — 숫자 타입<N extends Number>
RResult — 반환 타입Function<T, R>
?Wildcard — 미지의 타입List<?>

이름은 관례일 뿐 컴파일러는 신경 쓰지 않지만, 코드 가독성을 위해 따르는 것이 좋다.

타입 파라미터 동작 원리

중앙에 Box<T> 클래스 정의가 있고, 사용 시점에 T가 구체적인 타입으로 치환된다. 왼쪽은 Box<String>으로 T가 String이 되어 value 필드가 String 타입으로 동작하고, 오른쪽은 Box<Integer>로 T가 Integer가 되어 int 값을 저장한다. 하나의 제네릭 클래스로 모든 타입을 타입 안전하게 처리할 수 있다.


제네릭 클래스와 제네릭 메서드 🏗️

제네릭 클래스

타입 파라미터가 클래스 전체에 걸린다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 두 개의 타입 파라미터
public class Pair<A, B> {
    private final A first;
    private final B second;

    public Pair(A first, B second) {
        this.first = first;
        this.second = second;
    }

    public A getFirst()  { return first; }
    public B getSecond() { return second; }

    @Override
    public String toString() {
        return "(" + first + ", " + second + ")";
    }
}

Pair<String, Integer> p = new Pair<>("나이", 25);
System.out.println(p.getFirst());   // "나이"
System.out.println(p.getSecond());  // 25

제네릭 메서드

<T>를 반환 타입 앞에 선언하면, 그 메서드만의 타입 파라미터를 가질 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Utils {

    // <T>를 반환 타입 앞에 선언
    public static <T> T first(T[] array) {
        if (array == null || array.length == 0) return null;
        return array[0];
    }

    // T는 Comparable이어야 한다는 조건도 추가 가능
    public static <T extends Comparable<T>> T max(T a, T b) {
        return a.compareTo(b) >= 0 ? a : b;
    }
}

String[] names = {"Alice", "Bob", "Charlie"};
System.out.println(Utils.first(names));           // "Alice"
System.out.println(Utils.max(10, 25));            // 25
System.out.println(Utils.max("apple", "banana")); // "banana"

타입 경계 — Bounded Type Parameter 🔒

<T>는 모든 타입을 허용한다. 특정 타입만 허용하고 싶다면 extends로 범위를 제한할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// T는 Number 또는 그 하위 타입만 가능 (Integer, Double, Long ...)
public class NumberBox<T extends Number> {
    private T value;

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

    // Number의 메서드를 T에서 바로 호출 가능!
    public double doubleValue() {
        return value.doubleValue();
    }
}

NumberBox<Integer> ib = new NumberBox<>(42);
NumberBox<Double>  db = new NumberBox<>(3.14);
// NumberBox<String> sb = new NumberBox<>("hi"); // 컴파일 에러!

System.out.println(ib.doubleValue()); // 42.0
System.out.println(db.doubleValue()); // 3.14

다중 경계

클래스는 하나, 인터페이스는 여러 개 지정할 수 있다.

1
2
3
4
5
// T는 Animal의 하위 클래스이면서 Comparable과 Serializable을 구현해야 함
// 클래스가 있으면 클래스를 가장 먼저 쓴다
public <T extends Animal & Comparable<T> & Serializable> void process(T t) {
    // ...
}

와일드카드 — ?, extends, super 🃏

와일드카드는 제네릭에서 가장 헷갈리는 부분이다. 종류는 세 가지다.

비한정 와일드카드 List<?>

어떤 타입인지 상관없이 받아서, 읽기만 할 때 쓴다.

1
2
3
4
5
6
7
8
9
public static void printAll(List<?> list) {
    for (Object item : list) { // Object로만 받을 수 있음
        System.out.println(item);
    }
    // list.add("hello"); // 컴파일 에러! 어떤 타입인지 모르니 추가 불가
}

printAll(List.of(1, 2, 3));       // OK
printAll(List.of("a", "b", "c")); // OK

상한 와일드카드 List<? extends T>

T 또는 T의 하위 타입 리스트를 받는다. 읽기(꺼내기) 전용이다.

1
2
3
4
5
6
7
8
9
10
11
12
// Number 또는 Number의 하위 타입(Integer, Double ...) 리스트를 받아 합산
public static double sum(List<? extends Number> numbers) {
    double total = 0;
    for (Number n : numbers) {  // Number로 꺼낼 수 있음
        total += n.doubleValue();
    }
    return total;
    // numbers.add(1.0); // 컴파일 에러! 쓰기 불가
}

sum(List.of(1, 2, 3));       // Integer 리스트 OK
sum(List.of(1.5, 2.5, 3.0)); // Double 리스트 OK

왜 쓰기가 막힐까? 컴파일러 입장에서 List<? extends Number>List<Integer>일 수도, List<Double>일 수도 있다. 1.5를 넣으려 해도 그게 Integer 리스트였다면 타입 위반이 되기 때문에 애초에 막아버린다.


하한 와일드카드 List<? super T>

T 또는 T의 상위 타입 리스트를 받는다. 쓰기(넣기) 전용이다.

1
2
3
4
5
6
7
8
9
10
11
// Integer 또는 Integer의 상위 타입 리스트에 Integer 값을 추가
public static void addNumbers(List<? super Integer> list) {
    list.add(1); // Integer 추가 가능
    list.add(2);
    // Integer n = list.get(0); // 컴파일 에러! Object로만 읽힘
}

List<Number> numList = new ArrayList<>();
List<Object> objList = new ArrayList<>();
addNumbers(numList); // OK — Number는 Integer의 상위
addNumbers(objList); // OK — Object는 Integer의 상위

alt text

와일드카드 3종을 비교한 다이어그램이다. List<?>(비한정): 어떤 타입인지 모르므로 Object로만 읽을 수 있고 쓰기는 불가능하다. List<? extends Number>(상한): Number 또는 하위 타입이므로 Number로 읽을 수 있지만 쓰기는 불가능하다(Producer). List<? super Integer>(하한): Integer 또는 상위 타입이므로 Integer를 쓸 수 있지만 Object로만 읽힌다(Consumer).


PECS 원칙 📖

와일드카드 선택을 헷갈릴 때 외우는 원칙이다.

💡 PECS: Producer Extends, Consumer Super

  • 데이터를 꺼내 쓰는(생산하는) 쪽 → extends
  • 데이터를 집어넣는(소비하는) 쪽 → super

실제 코드로 보면:

1
2
3
4
5
6
7
8
9
10
11
// src에서 꺼내서(producer) → dst에 넣는(consumer) 복사 메서드
public static <T> void copy(List<? extends T> src,   // Producer: extends
                             List<? super T>   dst) { // Consumer: super
    for (T item : src) {
        dst.add(item);
    }
}

List<Integer> integers = List.of(1, 2, 3);
List<Number>  numbers  = new ArrayList<>();
copy(integers, numbers); // OK — Integer는 Number를 extends

alt text

alt text

PECS 원칙을 시각화한 다이어그램이다. copy(List<? extends T> src, List<? super T> dst) 메서드에서 src는 Producer(생산자)로 데이터를 꺼내므로 extends를 사용하고, dst는 Consumer(소비자)로 데이터를 받으므로 super를 사용한다. 구체적인 예시로 List<Integer>에서 List<Number>로 복사할 때, Integer 리스트는 데이터를 생산하고 Number 리스트는 데이터를 소비한다.


타입 소거 — Type Erasure 🔄

제네릭은 컴파일 타임 전용이다. 컴파일이 끝나면 타입 정보가 지워지고 Object(또는 상한 타입)로 대체된다.

1
2
3
4
5
6
7
8
9
10
// 소스 코드
List<String>  strList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();

// 런타임에서 실제 모습 (타입 정보 소거 후)
List strList = new ArrayList();
List intList = new ArrayList();

// 런타임에서 두 타입은 동일
System.out.println(strList.getClass() == intList.getClass()); // true!

타입 소거로 인한 제약

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. instanceof에 제네릭 타입 사용 불가
if (list instanceof List<String>) { } // 컴파일 에러!
if (list instanceof List)         { } // OK

// 2. 제네릭 타입으로 배열 생성 불가
T[] arr = new T[10];          // 컴파일 에러!
Object[] arr = new Object[10]; // OK (우회)

// 3. static 필드에 타입 파라미터 사용 불가
class Foo<T> {
    static T instance; // 컴파일 에러!
    // static은 클래스 단위, T는 인스턴스마다 달라서 충돌
}

왜 타입 소거를 선택했나

Java 1.5에서 제네릭을 도입할 때 이미 수많은 non-generic 코드(List, Map 등)가 존재했다. 타입 소거를 선택한 이유는 하위 호환성 때문이다. 제네릭 코드와 기존 코드가 함께 동작해야 했기 때문에 런타임은 건드리지 않고 컴파일러 수준에서만 처리하는 방식을 택했다.

alt text

alt text

타입 소거 과정을 보여주는 다이어그램이다. 왼쪽은 컴파일 전 소스 코드로 List<String>List<Integer>가 각각의 제네릭 타입 정보를 가지고 있다. 오른쪽은 컴파일 후 바이트코드로, 제네릭 타입 정보가 모두 지워지고 List로만 남는다. 두 타입이 런타임에서 getClass()로 비교하면 동일하다. 제네릭은 컴파일 타임에만 존재하고 런타임에는 타입 정보가 소거되어 하위 호환성을 유지한다.


실전 패턴 💼

Repository 패턴 (Spring Data JPA의 기반)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface Repository<T, ID> {
    Optional<T> findById(ID id);
    List<T> findAll();
    T save(T entity);
    void deleteById(ID id);
}

// User 엔티티, Long 타입 ID
public class UserRepository implements Repository<User, Long> {
    @Override
    public Optional<User> findById(Long id) {
        // DB에서 User 조회
        return Optional.empty();
    }
    // ...
}

API 응답 래퍼 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ApiResponse<T> {
    private final int    status;
    private final String message;
    private final T      data;

    private ApiResponse(int status, String message, T data) {
        this.status  = status;
        this.message = message;
        this.data    = data;
    }

    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(200, "OK", data);
    }

    public static <T> ApiResponse<T> error(int status, String message) {
        return new ApiResponse<>(status, message, null);
    }

    // getter...
}

// 사용
ApiResponse<User>       userResp = ApiResponse.success(new User("Alice"));
ApiResponse<List<User>> listResp = ApiResponse.success(List.of(...));
ApiResponse<Void>       errResp  = ApiResponse.error(404, "Not found");

제네릭 유틸리티 메서드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class CollectionUtils {

    // null-safe 첫 원소
    public static <T> Optional<T> firstOrEmpty(List<T> list) {
        if (list == null || list.isEmpty()) return Optional.empty();
        return Optional.of(list.get(0));
    }

    // 두 리스트 합치기
    public static <T> List<T> concat(List<T> a, List<T> b) {
        List<T> result = new ArrayList<>(a);
        result.addAll(b);
        return result;
    }

    // 특정 조건을 만족하는 원소만 필터링 (람다와 연계)
    public static <T> List<T> filter(List<T> list, java.util.function.Predicate<T> pred) {
        return list.stream()
                   .filter(pred)
                   .collect(java.util.stream.Collectors.toList());
    }
}

// 사용
List<String> names  = List.of("Alice", "Bob", "Charlie");
List<String> result = CollectionUtils.filter(names, s -> s.startsWith("A"));
// → ["Alice"]

핵심 정리 📌

제네릭의 목적:

  • 타입 오류를 컴파일 타임에 잡는다
  • 불필요한 형변환(casting)을 제거한다
  • 같은 로직을 다양한 타입에 재사용할 수 있다

문법 핵심:

  • <T> — 클래스/메서드에 타입 파라미터 선언
  • <T extends SomeClass> — T는 SomeClass 또는 그 하위 타입만 허용
  • <T extends Class & Interface> — 다중 경계 (클래스 먼저, 인터페이스는 여러 개 가능)

와일드카드:

  • <?> — 아무 타입 (읽기 전용, Object로 반환)
  • <? extends T> — T 또는 하위 타입 (읽기 전용, T로 반환)
  • <? super T> — T 또는 상위 타입 (쓰기 가능)
  • PECS: Producer → extends, Consumer → super

타입 소거:

  • 제네릭은 컴파일 타임에만 존재, 런타임에는 Object로 소거
  • instanceof List<String> 불가, new T[] 불가, static 필드에 T 불가
  • 하위 호환성을 위한 설계 결정