제네릭 메서드

이언덕·2026년 4월 19일

아이티센 부트캠프

목록 보기
59/115
post-thumbnail

제네릭이 왜 필요한가

제네릭은 타입을 지금 바로 확정하지 않고, 나중에 실제 타입을 넣어서 사용하는 문법이다.
쉽게 말하면 같은 구조의 코드를 타입만 바꿔서 다시 쓰고 싶을 때 사용하는 방식이다.


처음 보면 <T> 같은 문법이 낯설어서 어렵게 느껴질 수 있다.
하지만 제네릭은 문법부터 외우는 것보다, 왜 이런 문법이 필요한지를 먼저 이해하는 것이 더 중요하다.


제네릭이 없으면 어떤 문제가 생기는가

제네릭이 없으면 비슷한 클래스를 타입마다 따로 만들어야 하거나,
아예 Object로 받아서 모든 값을 한꺼번에 처리해야 한다.


그런데 Object로 처리하면 값을 넣을 때는 편해 보여도,
꺼낼 때는 다시 원래 타입으로 바꿔 주는 형변환이 필요하다.
이 과정이 번거롭고, 잘못 바꾸면 실행 중에 오류가 날 수 있다.


또 하나 중요한 점은 오류를 늦게 발견하게 된다는 것이다.
제네릭이 있으면 잘못된 타입 사용을 실행하기 전에, 즉 코드를 작성하거나 컴파일할 때 더 빨리 확인하기 쉽다.
반대로 제네릭이 없으면 프로그램이 돌아가다가 나중에야 문제가 드러날 수 있다.


즉, 제네릭이 없으면
구조는 같은데 타입만 다른 클래스가 늘어나기 쉽고,
값을 꺼낼 때도 더 조심해야 한다.


초보자 기준으로 먼저 잡아야 할 핵심

  • 구조는 같은데 타입만 다를 때 제네릭이 필요하다.
  • Object로 다루면 값을 꺼낼 때 형변환이 필요하다.
  • 잘못된 타입 사용을 컴파일 단계에서 빨리 막기 어렵다.

제네릭은 "아무거나 담게 하자"가 아니라, "같은 구조를 안전하게 다시 쓰자"에 가깝다.



예제로 먼저 보기: 제네릭이 없을 때

아래 코드는 제네릭이 없는 상자이다.
값을 저장할 때는 Object로 받고,
값을 꺼낼 때도 Object로 꺼낸다.
그래서 원하는 타입으로 다시 바꾸는 과정이 필요하다.

// exam01.java
class Box {
    private Object item; // 아무 객체나 저장할 수 있음

    public void setItem(Object item) {
        this.item = item; // 값 저장
    }

    public Object getItem() {
        return item; // Object 타입으로 꺼냄
    }
}

public class exam01 {
    public static void main(String[] args) {
        Box box = new Box(); // 제네릭이 없는 상자 생성
        box.setItem("사과"); // 문자열 저장

        String fruit = (String) box.getItem(); // 꺼낼 때 직접 형변환
        System.out.println(fruit);

        // Integer num = (Integer) box.getItem();
        // 실제로는 String이 들어 있는데 Integer로 바꾸려 하면 실행 중 오류 가능
    }
}
// 출력결과
// 사과

이 예제에서 중요한 점은 값을 꺼낼 때 String으로 직접 형변환했다는 점이다.
지금은 "사과"를 넣었기 때문에 문제가 없지만,
실제로 들어 있는 값과 다르게 형변환하면 실행 중 오류가 날 수 있다.


즉, 제네릭이 없으면 코드를 쓰는 사람이 타입을 끝까지 직접 기억하고 조심해야 한다.




제네릭 타입이란 무엇인가

제네릭 타입은 결정되지 않은 타입을 파라미터처럼 받아들이는 클래스나 인터페이스이다.
여기서 파라미터는 값이 아니라 타입 자리라고 이해하면 된다.


즉, 클래스나 인터페이스를 만들 때 타입을 바로 못 박지 않고,
"여기에는 나중에 실제 타입이 들어온다"라고 비워 두는 방식이다.
이때 가장 자주 보는 형태가 <T>이다.

이 구조에서 중요한 점은 클래스명이나 인터페이스명 옆에 <A, B, ...>처럼 타입 자리가 붙는다는 점이다.
이 자리는 값을 넣는 칸이 아니라, 나중에 실제 타입으로 바뀔 자리이다.


타입 자리를 비워 둔다는 말은 무슨 뜻인가

예를 들어 Box<T>라고 선언하면 Box라는 구조는 이미 만들어져 있다.
하지만 그 안에 어떤 타입을 담을지는 아직 정하지 않은 상태이다.


나중에 Box<String>이라고 쓰면 T 자리에 String이 들어간다.
Box<Integer>라고 쓰면 T 자리에 Integer가 들어간다.
즉, 같은 Box 구조를 두고 실제 타입만 바꾸는 것이다.


이 흐름이 중요한 이유는 제네릭 타입이 클래스 여러 개를 새로 만드는 문법이 아니기 때문이다.
같은 구조를 여러 타입에 맞게 재사용하는 문법이라는 점이 핵심이다.


타입 파라미터는 어떻게 읽으면 쉬운가

  • T는 나중에 실제 타입이 들어올 자리이다.
  • E, K, V도 자주 쓰지만, 모두 타입 자리표시자라는 점은 같다.
  • 중요한 것은 글자 자체가 아니라 무슨 역할의 타입 자리인가이다.

예를 들어 Box<T>에서 T는 "상자 안에 들어갈 타입" 정도로 이해하면 된다.
그래서 T는 특별한 타입이 아니라,
실제 타입이 들어오기 전까지 임시로 적어 둔 이름표이다.



타입을 생략하면 왜 아쉬운가

제네릭은 실제 사용할 때 구체적인 타입을 적어 주는 것이 중요하다.
타입을 생략하면 겉으로는 편해 보일 수 있다.
하지만 그러면 다시 형변환이 많아지고,
잘못된 타입 사용을 더 늦게 발견하게 된다.


즉, 제네릭은 선언만 해 두는 것으로 끝나는 것이 아니라,
사용할 때 실제 타입을 정확히 적어 주어야 장점이 살아난다.



예제로 다시 보기: 제네릭 타입

이번에는 같은 상자 구조를 제네릭으로 바꿔 보자.
상자 안에 들어갈 타입을 T로 비워 두고,
실제로 사용할 때 String, Integer를 넣는다.

// exam02.java
class Box<T> {
    private T item; // 실제 타입이 나중에 결정됨

    public void setItem(T item) {
        this.item = item; // 결정된 타입의 값 저장
    }

    public T getItem() {
        return item; // 결정된 타입 그대로 반환
    }
}

public class exam02 {
    public static void main(String[] args) {
        Box<String> fruitBox = new Box<String>(); // 문자열 상자
        fruitBox.setItem("사과");
        String fruit = fruitBox.getItem(); // 형변환 없이 바로 사용
        System.out.println(fruit);

        Box<Integer> numberBox = new Box<Integer>(); // 정수 상자
        numberBox.setItem(100);
        int number = numberBox.getItem(); // 형변환 없이 바로 사용
        System.out.println(number);

        // fruitBox.setItem(100);
        // String 상자에는 숫자를 넣을 수 없으므로 컴파일 단계에서 막힘
    }
}
// 출력결과
// 사과
// 100

이제는 값을 꺼낼 때 형변환이 필요하지 않다.
이미 fruitBox는 String 전용 상자이고,
numberBox는 Integer 전용 상자이기 때문이다.


또 fruitBox.setItem(100)처럼 잘못된 사용은 실행 전에, 즉 컴파일 단계에서 바로 막힌다.
이 점이 제네릭 타입의 아주 큰 장점이다.


즉, 제네릭 타입을 사용하면
같은 구조는 유지하면서 타입은 더 정확하게 고정할 수 있다.


제네릭 타입의 핵심은 "구조는 하나, 타입은 사용할 때 결정"이다.




제네릭 메서드란 무엇인가

제네릭 메서드는 메서드 자체가 타입 파라미터를 가지는 메서드이다.
즉, 클래스 전체가 제네릭일 수도 있지만, 어떤 경우에는 특정 메서드 하나만 제네릭으로 만들 수도 있다.


이 부분에서 가장 많이 헷갈리는 것이 제네릭 타입과 제네릭 메서드를 같은 것으로 보는 것이다.
비슷하지만 다르다.
제네릭 타입은 클래스나 인터페이스 수준에서 타입을 받는다.
반면 제네릭 메서드는 그 메서드 하나를 실행할 때 필요한 타입을 따로 받는다.

이 이미지에서 가장 중요한 부분은 리턴 타입 앞쪽에 <A, B, ...>처럼 타입 파라미터가 따로 선언된다는 점이다.
즉, public <T> ...처럼 쓰이면 "이 메서드는 타입을 하나 받아서 쓸 수 있다"는 뜻이다.


제네릭 타입과 무엇이 다른가

둘 다 타입을 늦게 정한다는 점은 같다.
하지만 타입을 받는 위치가 다르다.


제네릭 타입

  • 클래스나 인터페이스 전체가 타입을 받는다.
  • 객체를 만들 때 타입이 함께 정해진다.
  • 구조 전체가 그 타입의 영향을 받는다.



제네릭 메서드

  • 메서드 하나만 타입을 받는다.
  • 메서드를 호출할 때 타입이 정해진다.
  • 클래스 전체를 제네릭으로 만들지 않아도 된다.

즉, 클래스 전체에 적용되면 `제네릭 타입`, 특정 기능 하나에만 적용되면 `제네릭 메서드`라고 이해하면 된다.



클래스의 타입 파라미터와 메서드의 타입 파라미터는 다르다

같은 <T>를 쓴다고 해서 항상 같은 뜻은 아니다.
이 부분을 꼭 구분해야 한다.


예를 들어 class Box<T>의 T는 클래스 전체에서 쓰는 타입 자리이다.
반면 public static <T> Box<T> boxing(T t)의 T는 그 메서드 안에서만 쓰는 타입 자리이다.


즉, 둘 다 이름은 T지만 같은 것이 아니다.
겉으로는 같은 글자를 쓰고 있어도,
클래스의 T와 메서드의 T는 서로 별개의 타입 파라미터이다.


왜 이 구분이 중요한가

이 구분이 중요한 이유는 static과 바로 연결되기 때문이다.
static 메서드는 클래스 전체의 타입 파라미터를 직접 사용할 수 없다.
왜냐하면 static은 객체가 만들어지기 전에도 사용할 수 있기 때문이다.


하지만 static 메서드가 자기 자신만의 타입 파라미터를 새로 선언하는 것은 가능하다.
그래서 public static <T> ... 같은 형태가 자연스럽게 나오는 것이다.


즉, static 메서드 안의 제네릭은 클래스의 타입을 끌어다 쓰는 것이 아니라,
메서드가 자기 전용 타입 자리를 따로 하나 더 만든 것이라고 이해하면 된다.


제네릭 메서드는 꼭 제네릭 클래스 안에만 있는 것이 아니다

초보자는 제네릭 메서드를 처음 보면
"그럼 클래스도 꼭 제네릭이어야 하나?"라고 생각하기 쉽다.
하지만 그렇지 않다.


제네릭 메서드는 제네릭 클래스 안에도 만들 수 있고,
제네릭이 아닌 일반 클래스 안에도 만들 수 있다.
즉, 클래스 전체를 제네릭으로 만들지 않아도
특정 기능 하나만 제네릭으로 유연하게 만들 수 있다.


이 점 때문에 Util처럼 일반 클래스 안에 제네릭 메서드를 두는 예제가 자주 나온다.
구조 전체를 제네릭으로 만들 필요가 없고,
그 메서드 하나만 여러 타입에 맞게 동작하면 되기 때문이다.


제네릭 메서드의 타입은 언제 결정되는가

제네릭 메서드의 타입은 메서드를 호출할 때 전달한 값에 따라 결정된다.
이 말은 처음에는 조금 추상적으로 느껴질 수 있다.
그래서 흐름으로 이해하는 것이 좋다.

이 이미지처럼 같은 boxing() 메서드를 호출하더라도,
한 번은 숫자를 넣고,
한 번은 문자열을 넣을 수 있다.
그때마다 T는 전달된 값의 타입에 맞춰 구체적인 타입으로 정해진다.


흐름으로 보면 이렇게 된다

  • 메서드 안에 T라는 타입 자리를 준비해 둔다.
  • 메서드를 호출할 때 실제 값을 넣는다.
  • 전달된 값의 타입에 맞춰 T가 결정된다.
  • 그 호출에서는 결정된 타입 기준으로 메서드가 동작한다.

즉, 같은 메서드라도 호출할 때마다 다른 타입으로 안전하게 동작할 수 있다.

호출할 때 타입을 직접 적을 수도 있다

제네릭 메서드는 보통 컴파일러가 타입을 추론해 준다.
그래서 Util.boxing(100)처럼 써도 T가 Integer로 결정된다.


하지만 원하면 타입을 직접 적을 수도 있다.
예를 들면 아래처럼 쓸 수 있다.

  • Util.<Integer>boxing(100)
  • Util.<String>boxing("안녕하세요")

즉, 타입을 명시적으로 적는 방법도 있고, 전달한 값만 보고 컴파일러가 타입을 추론하게 맡기는 방법도 있다. 실제로는 추론에 맡기는 경우가 더 많다. 그래서 초보자 단계에서는 **직접 적을 수도 있지만 대부분은 생략해도 된다**고 이해하면 된다.

와일드카드와 어떻게 이어지는가

제네릭 메서드를 이해할 때 와일드카드와의 차이도 살짝 연결해 두면 좋다.
어떤 상황에서는 <? extends Fruit>처럼 와일드카드로 받을 수 있고,
어떤 상황에서는 <T extends Fruit>처럼 메서드의 타입 파라미터로 직접 선언할 수도 있다.


둘 다 범위를 제한한다는 점에서는 비슷해 보인다.
하지만 느낌은 다르다.
와일드카드는 이미 있는 제네릭 타입을 사용할 때 허용 범위를 조절하는 쪽에 가깝고,
제네릭 메서드는 메서드가 직접 자기 타입 파라미터를 선언해서 다루는 쪽에 가깝다.


즉, 둘은 경쟁 관계라기보다
상황에 따라 타입을 표현하는 방식이 다른 것이라고 이해하면 된다.


예제로 이해하기: 제네릭 메서드

아래 예제는 값을 받아서 Box<T>에 담아 돌려주는 제네릭 메서드이다.
숫자를 넣으면 숫자 상자가 만들어지고,
문자열을 넣으면 문자열 상자가 만들어진다.

// exam03.java
class Box<T> {
    private T item; // 값을 저장할 타입

    public void setItem(T item) {
        this.item = item; // 값 저장
    }

    public T getItem() {
        return item; // 값 반환
    }
}

class Util {
    public static <T> Box<T> boxing(T t) { // 메서드 자체가 제네릭
        Box<T> box = new Box<T>(); // 전달된 타입에 맞는 상자 생성
        box.setItem(t); // 값 저장
        return box; // 상자 반환
    }
}

public class exam03 {
    public static void main(String[] args) {
        Box<Integer> box1 = Util.boxing(100); // T가 Integer로 결정됨
        Box<String> box2 = Util.boxing("안녕하세요"); // T가 String으로 결정됨

        Box<Integer> box3 = Util.<Integer>boxing(200); // 타입을 직접 적을 수도 있음

        System.out.println(box1.getItem());
        System.out.println(box2.getItem());
        System.out.println(box3.getItem());
    }
}
// 출력결과
// 100
// 안녕하세요
// 200

이 예제에서 중요한 점은 boxing() 메서드가 여러 번 호출되었지만,
그 호출마다 T가 한 번 정해지고 끝나는 것이 아니라 각 호출에 맞게 새로 결정된다는 점이다.


즉, 제네릭 메서드는 메서드 하나를 여러 타입에 맞게 유연하게 재사용하는 방법이다.


이 예제에서 꼭 같이 봐야 할 점

  • Util은 제네릭 클래스가 아니라 일반 클래스이다.
  • 그런데도 boxing() 메서드 하나는 제네릭으로 만들 수 있다.
  • Util.boxing(100)처럼 타입 추론에 맡길 수도 있다.
  • Util.<Integer>boxing(200)처럼 타입을 직접 적을 수도 있다.

즉, 제네릭 메서드는 클래스 전체 구조보다 **그 메서드 하나가 여러 타입에 맞게 동작해야 할 때 쓰는 도구**라고 이해하면 된다.

복잡한 선언은 어떻게 읽으면 좋은가

제네릭 메서드는 간단할 때는 쉽지만,
타입 제한과 와일드카드가 같이 들어가면 갑자기 어려워 보일 수 있다.
예를 들어 아래처럼 생긴 선언을 보면 처음에는 겁부터 날 수 있다.

  • public static <T> void sort(List<T> list, Comparator<? super T> c)

이럴 때는 한 번에 전부 읽으려고 하지 않는 것이 좋다.
먼저 큰 구조만 본다.

  • public static <T> void sort(...)
  • 이 메서드는 static 제네릭 메서드이다.
  • T라는 타입 자리를 하나 받아서 사용한다.

그다음 매개변수를 본다.

  • List<T> list는 T 타입 요소를 담은 리스트이다.
  • Comparator<? super T> c는 T나 그보다 더 위쪽 기준으로 비교할 수 있는 비교 도구이다.

즉, 복잡한 선언은
반환형 앞의 타입 파라미터 → 매개변수 구조 → 와일드카드 범위 순서로 끊어서 읽으면 훨씬 쉽다.
한 줄 전체를 한 번에 외우려고 하면 오히려 더 막힌다.


제네릭 메서드를 읽을 때는 문장을 통째로 외우지 말고, 먼저 메서드의 큰 구조를 보고 그다음 타입 제한과 와일드카드를 붙여서 읽는 습관이 중요하다.




제한된 타입 파라미터란 무엇인가

제한된 타입 파라미터는 모든 타입을 다 받지 않고, 특정 상위 타입과 그 하위 타입만 받도록 제한한 타입 파라미터이다.
쉽게 말하면 제네릭에 범위를 주는 방식이다.


제네릭은 여러 타입을 받아서 재사용할 수 있다는 점이 장점이다.
하지만 어떤 경우에는 아무 타입이나 다 받으면 오히려 문제가 생긴다.
왜냐하면 메서드 안에서 반드시 써야 하는 기능이 있을 수 있기 때문이다.


예를 들어 숫자끼리 비교하는 기능을 만든다고 하자.
이 기능 안에서는 숫자를 실수값으로 바꾸는 기능이 필요할 수 있다.
그런데 아무 타입이나 다 허용해 버리면 문자열이나 전혀 관계없는 객체도 들어올 수 있다.
그러면 메서드 안에서 필요한 기능을 안전하게 사용할 수 없다.
그래서 "적어도 이 계열의 타입만 받아라"라고 범위를 좁히는 것이다.

이 이미지의 public <T extends Number>는 T 자리에 Number 계열만 올 수 있다는 뜻이다.
즉, 숫자와 관련된 공통 기능을 안전하게 사용하기 위해 제한을 둔 것이다.


왜 제한이 필요한가

제네릭은 원래 여러 타입을 유연하게 받기 위한 문법이다.
그런데 유연하다는 이유로 아무 타입이나 다 허용하면,
정작 메서드 안에서 필요한 기능을 쓸 수 없게 되는 경우가 있다.


예를 들어 doubleValue() 같은 기능은 숫자 계열에서만 기대할 수 있다.
그런데 String까지 들어오면 이 기능을 사용할 수 없다.
그래서 애초에 받을 수 있는 타입 범위를 줄여 두는 것이다.


제한을 두면 좋은 점

  • 메서드 안에서 필요한 기능을 더 안전하게 사용할 수 있다.
  • 전혀 관계없는 타입이 들어오는 것을 미리 막을 수 있다.
  • 잘못된 사용을 컴파일 단계에서 더 빨리 발견할 수 있다.

즉, 제한된 타입 파라미터는 제네릭의 자유를 없애는 것이 아니라, **필요한 방향으로만 자유를 남겨 두는 방식**이다.



extends는 여기서 무슨 뜻인가

여기서 extends는 초보자가 흔히 떠올리는 "클래스 상속만 의미한다"로 좁게 이해하면 안 된다.
제네릭에서는 상위 타입 기준으로 허용 범위를 제한한다는 뜻으로 이해하는 것이 맞다.


그래서 클래스뿐 아니라 인터페이스를 기준으로도 제한을 걸 수 있다.
핵심은 "T는 아무 타입이 아니라, 적어도 이 상위 타입 계열 안에 있어야 한다"는 점이다.



예제로 이해하기: 숫자 계열만 받기

아래 예제는 두 값을 받아서 같은 숫자인지 비교하는 메서드이다.
여기서는 doubleValue()를 사용해야 하므로,
아무 타입이나 받지 않고 Number 계열만 받도록 제한했다.

// exam04.java
class Util {
    public static <T extends Number> boolean compare(T t1, T t2) {
        double v1 = t1.doubleValue(); // Number 계열의 공통 기능 사용
        double v2 = t2.doubleValue(); // Number 계열의 공통 기능 사용
        return v1 == v2; // 값 비교
    }
}

public class exam04 {
    public static void main(String[] args) {
        System.out.println(Util.compare(10, 10.0)); // Integer와 Double 비교
        System.out.println(Util.compare(3, 5)); // 서로 다른 값 비교

        // System.out.println(Util.compare("10", "10"));
        // String은 Number 계열이 아니므로 애초에 사용할 수 없음
    }
}
// 출력결과
// true
// false

이 예제의 핵심은 compare()가 여러 숫자 타입을 받아도 된다는 점이다.
Integer, Double처럼 실제 타입은 서로 다르더라도, 둘 다 Number 계열이므로 이 메서드에서 허용된다.


하지만 String처럼 전혀 다른 계열은 처음부터 막힌다.
그래서 메서드 안에서 필요한 기능을 안전하게 쓸 수 있다.


제한된 타입 파라미터는 "아무거나 받기"보다 "필요한 기능을 안전하게 쓰기"를 더 중요하게 보는 문법이다.




와일드카드란 무엇인가

와일드카드는 제네릭 타입을 사용할 때 타입 하나를 딱 고정하지 않고, 일정 범위 안의 타입을 허용하기 위해 쓰는 문법이다.
쉽게 말하면 "정확히 이 타입만"이라고 못 박는 대신, "이 범위 안이면 된다"라고 열어 두는 방식이다.


와일드카드는 ? 기호로 표시한다.
이 ?는 타입을 하나로 확정하지 않았다는 뜻이다.
즉, 어떤 타입으로 만들어진 제네릭 객체가 올지는 모르지만 받을 수는 있다는 의미에 가깝다.


여기서 중요한 점은 ?가 "아무 값이나 마음대로 넣어도 된다"는 뜻은 아니라는 것이다.
정확한 타입을 모르기 때문에 받을 수는 있지만, 사용할 때는 더 조심해야 한다.
이 점을 먼저 잡아 두면 ?를 훨씬 정확하게 이해할 수 있다.



와일드카드가 필요한 이유

제네릭 타입은 보통 Box<String>처럼 구체적인 타입을 넣어서 사용한다.
그런데 어떤 경우에는 꼭 하나의 타입으로만 못 박고 싶지 않을 때가 있다.


예를 들어 "Student 계열이면 된다"거나,
"Worker보다 위쪽 타입도 허용하고 싶다" 같은 상황이 있다.
이럴 때 와일드카드를 사용한다.
즉, 와일드카드는 사용할 때 허용 범위를 조절하는 문법이다.



와일드카드의 세 가지 형태

그냥 ?

그냥 ?는 타입을 하나로 고정하지 않은 상태이다.
즉, 어떤 타입으로 만들어진 제네릭 객체가 올지는 모르지만 받을 수 있다는 뜻이다.


다만 정확한 타입을 모르기 때문에,
구체적으로 값을 다루는 일은 조심해야 한다.
받을 수 있는 범위는 넓지만, 그만큼 다루는 방법은 제한적이 된다.



? extends 타입

? extends Student처럼 쓰면,
Student와 그 하위 타입까지 허용한다는 뜻이다.
즉, 기준 타입보다 더 구체적인 쪽까지 받을 수 있도록 열어 둔 범위이다.



? super 타입

? super Worker처럼 쓰면,
Worker와 그 상위 타입까지 허용한다는 뜻이다.
이번에는 아래쪽이 아니라 위쪽 범위를 보는 것이다.



방향으로 정리하면 더 쉽다

  • ? extends 타입 : 그 타입과 아래쪽
  • ? super 타입 : 그 타입과 위쪽
  • ? : 타입을 하나로 고정하지 않은 넓은 상태

이 방향만 정확히 잡으면 와일드카드가 훨씬 덜 헷갈린다.



그림으로 보면 더 쉬운 이유

이 그림에서 파란 영역은 ? extends Student를 보여 준다.
즉, Student 자신과 그 아래에 있는 HighStudent, MiddleStudent까지 포함한다.


반대로 갈색 영역은 ? super Worker를 보여 준다.
즉, Worker 자신과 그 위에 있는 Person까지 포함한다.


그리고 그냥 ?는 한쪽 방향으로 제한을 두지 않고,
타입을 넓게 열어 두는 형태로 이해하면 된다.



예제로 이해하기: ?, ? extends, ? super

와일드카드는 그림만 보면 이해한 것 같아도,
코드로 보면 훨씬 또렷해진다.
아래 예제는 신청자 타입에 따라 신청 가능한 과정을 구분하는 예제이다.

// exam05.java
class Person {
    String name; // 이름 저장

    Person(String name) {
        this.name = name; // 이름 초기화
    }
}

class Worker extends Person {
    Worker(String name) {
        super(name); // 부모 생성자 호출
    }
}

class Student extends Person {
    Student(String name) {
        super(name); // 부모 생성자 호출
    }
}

class HighStudent extends Student {
    HighStudent(String name) {
        super(name); // 부모 생성자 호출
    }
}

class MiddleStudent extends Student {
    MiddleStudent(String name) {
        super(name); // 부모 생성자 호출
    }
}

class Applicant<T extends Person> {
    private T kind; // 신청자 타입 저장

    Applicant(T kind) {
        this.kind = kind; // 신청자 저장
    }

    public String getName() {
        return kind.name; // 이름 반환
    }
}

class Course {
    public static void registerAll(Applicant<?> applicant) {
        System.out.println(applicant.getName() + " : 전체 과정 신청 가능");
    }

    public static void registerStudent(Applicant<? extends Student> applicant) {
        System.out.println(applicant.getName() + " : 학생 과정 신청 가능");
    }

    public static void registerWorker(Applicant<? super Worker> applicant) {
        System.out.println(applicant.getName() + " : 직장인 과정 신청 가능");
    }
}

public class exam05 {
    public static void main(String[] args) {
        Applicant<Person> person = new Applicant<Person>(new Person("김사람"));
        Applicant<Worker> worker = new Applicant<Worker>(new Worker("이직장"));
        Applicant<Student> student = new Applicant<Student>(new Student("박학생"));
        Applicant<HighStudent> high = new Applicant<HighStudent>(new HighStudent("최고등"));

        Course.registerAll(person); // ? 이므로 모두 가능
        Course.registerAll(worker);
        Course.registerAll(student);

        Course.registerStudent(student); // Student 가능
        Course.registerStudent(high); // Student의 하위 타입도 가능

        Course.registerWorker(person); // Worker의 상위 타입인 Person 가능
        Course.registerWorker(worker); // Worker 자신도 가능

        // Course.registerStudent(worker);
        // Worker는 Student 계열이 아니므로 불가

        // Course.registerWorker(student);
        // Student는 Worker의 상위 타입이 아니므로 불가
    }
}
// 출력결과
// 김사람 : 전체 과정 신청 가능
// 이직장 : 전체 과정 신청 가능
// 박학생 : 전체 과정 신청 가능
// 박학생 : 학생 과정 신청 가능
// 최고등 : 학생 과정 신청 가능
// 김사람 : 직장인 과정 신청 가능
// 이직장 : 직장인 과정 신청 가능

이 예제에서 registerAll()은 <?>이므로 타입을 하나로 고정하지 않고 넓게 받을 수 있다.
그래서 Person, Worker, Student처럼 서로 다른 타입으로 만들어진 신청자도 받을 수 있다.


registerStudent()는 <? extends Student>이므로 Student와 그 아래쪽만 받을 수 있다.
그래서 Student, HighStudent는 가능하지만 Worker는 안 된다.


registerWorker()는 <? super Worker>이므로 Worker와 그 위쪽만 받을 수 있다.
그래서 Worker, Person은 가능하지만 Student는 안 된다.


와일드카드는 타입을 새로 선언하는 문법이 아니라, 이미 있는 제네릭 타입을 사용할 때 허용 범위를 정하는 문법이다.



제한된 타입 파라미터와 왜 헷갈리는가

둘 다 범위를 다룬다는 점에서는 비슷해 보여서 많이 헷갈린다.
하지만 쓰는 시점이 다르다.


제한된 타입 파라미터

  • 타입 파라미터를 선언할 때 범위를 제한한다.
  • 예: <T extends Number>



와일드카드

  • 이미 있는 제네릭 타입을 사용할 때 허용 범위를 조절한다.
  • 예: <? extends Student>, <? super Worker>

즉, 제한된 타입 파라미터는 **선언 시점의 제한**이고, 와일드카드는 **사용 시점의 허용 범위 조절**이라고 보면 된다.




지금 단계에서 꼭 잡아야 할 핵심

제네릭은 문법이 많아 보여도,
결국 아래 흐름으로 정리된다.


제네릭은 같은 구조를 여러 타입에 맞게 다시 쓰기 위해 등장했다.
제네릭 타입은 클래스나 인터페이스 수준에서 타입을 나중에 정하는 방식이다.
제네릭 메서드는 메서드 하나만 타입을 유연하게 받게 만드는 방식이다.
제한된 타입 파라미터는 필요한 범위까지만 타입을 받도록 막아 주는 방식이다.
와일드카드는 제네릭 타입을 사용할 때 허용 범위를 조절하는 방식이다.


제네릭 전체 흐름 한 번에 정리

  • 타입을 늦게 정한다.
  • 그래도 안전하게 정한다.
  • 필요하면 범위를 제한한다.
  • 사용할 때는 범위를 열어 둘 수도 있다.

이 네 줄이 제네릭 전체를 관통하는 핵심이다. 문법이 여러 개로 나뉘어 보여도 결국 같은 방향을 보고 있다.
결국 제네릭은 "아무거나 받는 편의"가 아니라 "필요한 타입을 정확하고 안전하게 다루기 위한 설계"이다. 이 흐름이 잡히면, 제네릭 문법이 따로따로 보이지 않고 하나의 이유로 연결되어 보이게 된다.

0개의 댓글