자바 제네릭(Generic) 완전 정복 — A to Z

revo·2026년 2월 12일

자바

목록 보기
12/30

0. 한 줄 정의

제네릭은 타입을 클래스 설계 시점이 아니라 사용 시점에 결정하여, 컴파일 타임에 타입 안전성을 보장하는 문법이다.


1. 왜 제네릭이 필요했는가

제네릭이 등장하기 전, 자바는 모든 객체를 Object로 다뤘다.

문제 코드

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList list = new ArrayList();
        list.add("hello");
        list.add(10);  // 컴파일 에러 없음

        String s = (String) list.get(1); // 런타임 에러
    }
}

문제점

  1. 잘못된 타입을 넣어도 컴파일러가 막지 못한다.
  2. 값을 꺼낼 때마다 강제 형변환이 필요하다.
  3. 형변환 실수 시 런타임 에러 발생.

→ 에러는 반드시 컴파일 타임에 잡는 것이 최선이다.


2. 제네릭 기본 문법

제네릭 클래스 정의

class Box<T> {
    T value;
}

T는 타입 파라미터(Type Parameter)다.
실제 타입은 객체 생성 시 결정된다.

사용 예

public class Main {
    public static void main(String[] args) {
        Box<String> box = new Box<>();
        box.value = "hello";

        // box.value = 10; // 컴파일 에러
    }
}

3. 타입 안전성과 형변환 제거

class Box<T> {
    private T value;

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

    public T get() {
        return value;
    }
}

사용:

Box<Integer> box = new Box<>();
box.set(100);

int num = box.get();  // 형변환 없음

형변환이 사라지고 타입 안전성이 확보된다.


4. 기본형이 안 되는 이유

Box<int> box = new Box<>();  // 컴파일 에러

제네릭은 참조형만 가능하다.

해결 방법

Box<Integer> box = new Box<>();
기본형래퍼 클래스
intInteger
doubleDouble
charCharacter

5. 여러 타입 파라미터 사용

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

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

사용:

Pair<String, Integer> p = new Pair<>("age", 20);

6. 타입 제한 (Bounded Type)

class Box<T extends Number> {
    T value;

    public double toDouble() {
        return value.doubleValue();
    }
}

사용:

Box<Integer> box = new Box<>();
box.value = 10;
System.out.println(box.toDouble());

extends는 클래스와 인터페이스 모두에 사용된다.


7. 메서드 제네릭

class Util {
    public static <T> void print(T value) {
        System.out.println(value);
    }
}

사용:

Util.print("hello");
Util.print(100);

8. 다이아몬드 연산자

Box<String> box = new Box<>();

타입 추론이 적용된다.


9. 와일드카드(?) 완전 정복


9-1. 왜 와일드카드가 필요한가

public static void printList(List<Number> list) {
    for (Number n : list) {
        System.out.println(n);
    }
}
List<Integer> list = List.of(1, 2, 3);
printList(list);  // 컴파일 에러

왜 에러일까?

IntegerNumber의 자식이지만

List<Integer> ≠ List<Number>

제네릭은 불공변(Invariant) 이다.

이를 해결하기 위해 와일드카드가 등장한다.


9-2. 무제한 와일드카드 <?>

public static void printList(List<?> list) {
    for (Object obj : list) {
        System.out.println(obj);
    }
}

특징:

  • 읽기 가능 (Object로)
  • 쓰기 거의 불가능
list.add("hello"); // 컴파일 에러

타입이 무엇인지 모르기 때문이다.


9-3. 상한 제한 <? extends T>

상한 제한이란?

특정 타입 이하(자식 방향) 로만 허용하는 제한

즉,

<? extends Number>

  • Number
  • Number의 자식 타입

만 허용한다는 뜻이다.


예제

public static void printNumbers(List<? extends Number> list) {
    for (Number n : list) {
        System.out.println(n);
    }
}

허용:

* List<Integer>
* List<Double>
* List<Float>

특징

읽기 가능

Number n = list.get(0);

모든 요소가 Number의 자식이므로 안전하다.


쓰기 불가

list.add(10);  // 컴파일 에러

리스트가 실제로 List<Double>일 수도 있기 때문이다.

Integer를 넣으면 타입이 깨질 수 있다.


결론

extends읽기 전용 (Producer)


9-4. 하한 제한 <? super T>

하한 제한이란?

특정 타입 이상(부모 방향) 으로만 허용하는 제한

즉,

<? super Integer>

  • Integer
  • Integer의 부모 타입

만 허용한다는 뜻이다.


예제

public static void addInteger(List<? super Integer> list) {
    list.add(10);
}

허용:

* List<Integer>
* List<Number>
* List<Object>

특징

쓰기 가능

list.add(20);

Integer는 부모 타입에 항상 들어갈 수 있다.


읽기 제한

Object obj = list.get(0);  // Object로만 가능

리스트가 정확히 어떤 타입인지 모르기 때문이다.


결론

super쓰기 전용 (Consumer)


좋아.
기존 구조는 그대로 두고, 그 아래에 아주 간단하게 보충 설명만 추가해줄게.


9-5. PECS 원칙

반드시 기억해야 할 원칙:

Producer Extends, Consumer Super

  • 데이터를 꺼내는 쪽 → extends
  • 데이터를 넣는 쪽 → super

왜 그럴까?

  • extends해당 타입 또는 그 자식을 보장한다.
    그래서 꺼낼 때 최소한 그 타입으로 안전하게 받을 수 있다.

    List<? extends Number> list;
    Number n = list.get(0);  // 안전
  • super해당 타입 또는 그 부모를 보장한다.
    그래서 그 타입의 값을 안전하게 넣을 수 있다.

    List<? super Integer> list;
    list.add(10);  // 항상 안전

한 줄 정리:

  • 꺼낼 때는 “이 타입 이하” 보장이 필요 → extends
  • 넣을 때는 “이 타입 이상” 보장이 필요 → super

9-6. 실전 예제 (copy 메서드)

잘못된 설계

public static void copy(List<Number> dest, List<Number> src) {
    for (Number n : src) {
        dest.add(n);
    }
}

왜 확장성이 없는가?

이 메서드는 List만 받는다.

즉:

  • List<Integer>는 전달 불가
  • List<Double>는 전달 불가
  • List<Object>는 전달 불가

실제 사용 환경에서는 이런 상황이 매우 흔하다.

List<Object> dest = new ArrayList<>();
List<Integer> src = List.of(1, 2, 3);

copy(dest, src);  // 컴파일 에러

Integer는 Number의 자식이지만 제네릭은 불공변이기 때문에 막힌다.

즉,

상속 관계를 전혀 활용하지 못하는 API가 된다.

이게 확장성이 없는 이유다.


올바른 설계

public static void copy(
        List<? super Number> dest,
        List<? extends Number> src) {

    for (Number n : src) {
        dest.add(n);
    }
}

왜 이게 확장성 있는가?

  • src는 Number의 자식 타입이면 무엇이든 가능
  • dest는 Number를 담을 수 있는 부모 타입이면 가능

사용:

List<Object> dest = new ArrayList<>();
List<Integer> src = List.of(1, 2, 3);

copy(dest, src);  // 정상 동작

이제 상속 관계를 제대로 활용하는 API가 된다.

이게 제네릭 설계의 핵심이다.


9-7. 읽기 vs 쓰기 정리

문법의미읽기쓰기
<?>타입 모름Object로 가능거의 불가
<? extends T>T 또는 자식가능불가
<? super T>T 또는 부모Object로 제한가능

10. 타입 소거(Type Erasure)

제네릭은 컴파일 타임에만 존재한다.

Box<String> box1 = new Box<>();
Box<Integer> box2 = new Box<>();

런타임에는 둘 다 단순한 Box다.

그래서 불가능:

if (box1 instanceof Box<String>) { } // 에러

11. 컬렉션과 제네릭

Map<String, Integer> map = new HashMap<>();
map.put("age", 20);

int age = map.get("age");

형변환이 사라진다.

0개의 댓글