[Java ] 개념노트 54 제한된 제네릭과 와일드카드 이해하기

[Java 개념노트 54] 제한된 제네릭과 와일드카드 이해하기

안녕하세요. Java 개념노트 시리즈 쉰네 번째 글입니다.

지난 글에서는 직접 타입 파라미터를 사용하는 제네릭 클래스제네릭 메서드를 만드는 방법을 정리했습니다. 이번 글에서는 제네릭을 조금 더 안전하고 유연하게 사용하는 방법인 제한된 제네릭와일드카드에 대해 알아보겠습니다.

제네릭을 사용하다 보면 <T extends Number>, <?>, <? extends Number>, <? super Integer> 같은 문법을 만나게 됩니다. 처음 보면 어렵게 보이지만 핵심은 간단합니다. 사용할 수 있는 타입의 범위를 제한하거나, 아직 정확히 모르는 타입을 유연하게 받기 위한 문법입니다.


1. 왜 타입을 제한할까?

지난 글에서 만든 Box<T>는 어떤 타입이든 담을 수 있었습니다.

class Box<T> {
    private T value;

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

    public T getValue() {
        return value;
    }
}

이 구조는 유연하지만, 어떤 상황에서는 너무 넓을 수 있습니다. 예를 들어 숫자 계산을 하는 상자라면 String이나 Product 같은 타입은 들어오면 안 됩니다. 숫자 타입만 받도록 제한하고 싶을 수 있습니다.

쉽게 말하면
제한된 제네릭은 “아무 타입이나 받지 말고, 특정 부모 타입을 기준으로 허용하자”는 문법입니다.

2. 제한된 제네릭이란?

제한된 제네릭은 타입 파라미터에 사용할 수 있는 타입의 범위를 제한하는 문법입니다.

자바에서는 extends 키워드를 사용해서 타입의 상한을 정할 수 있습니다.

class 클래스이름<T extends 부모타입> {
    // T는 부모타입 또는 그 자식 타입만 가능
}

예를 들어 숫자 타입만 허용하고 싶다면 다음처럼 작성할 수 있습니다.

class NumberBox<T extends Number> {
    private T value;
}

이제 T에는 Number 또는 Number를 상속받은 타입만 올 수 있습니다.

3. Number 클래스와 숫자 Wrapper 클래스

자바의 숫자 Wrapper 클래스 중 일부는 Number 클래스를 상속받습니다.

타입 Number의 자식인가? 설명
Integer 맞음 정수 Wrapper 클래스
Double 맞음 실수 Wrapper 클래스
Long 맞음 long Wrapper 클래스
Float 맞음 float Wrapper 클래스
String 아님 문자열 클래스

따라서 <T extends Number>라고 제한하면 Integer, Double, Long 같은 숫자 타입은 사용할 수 있지만 String은 사용할 수 없습니다.

4. NumberBox 만들기

숫자 타입만 받을 수 있는 NumberBox 클래스를 만들어보겠습니다.

class NumberBox<T extends Number> {
    private T value;

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

    public T getValue() {
        return value;
    }

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

T extends Number로 제한했기 때문에 value는 숫자 계열 타입입니다. 그래서 Number 클래스가 가진 doubleValue() 같은 메서드를 사용할 수 있습니다.

핵심 포인트
<T extends Number>처럼 제한하면 T를 Number 타입처럼 다룰 수 있습니다.

5. NumberBox 사용하기

NumberBoxInteger, Double 같은 숫자 타입으로 사용할 수 있습니다.

public class Main {
    public static void main(String[] args) {
        NumberBox<Integer> intBox = new NumberBox<>(100);
        NumberBox<Double> doubleBox = new NumberBox<>(3.14);

        System.out.println(intBox.getDoubleValue());
        System.out.println(doubleBox.getDoubleValue());
    }
}

class NumberBox<T extends Number> {
    private T value;

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

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

실행 결과는 다음과 같습니다.

100.0
3.14

IntegerDouble은 모두 Number의 자식 타입이므로 사용할 수 있습니다.

6. 제한에 맞지 않는 타입은 사용할 수 없다

NumberBox<T extends Number>는 숫자 타입만 허용합니다. 따라서 String은 사용할 수 없습니다.

NumberBox<Integer> intBox = new NumberBox<>(100);
NumberBox<Double> doubleBox = new NumberBox<>(3.14);

// NumberBox<String> stringBox = new NumberBox<>("Java"); // 오류 발생

StringNumber를 상속받지 않았기 때문에 타입 제한에 걸립니다. 이렇게 제한된 제네릭은 잘못된 타입 사용을 컴파일 단계에서 막아줍니다.

7. 제한된 제네릭 메서드

제네릭 클래스뿐만 아니라 제네릭 메서드에도 타입 제한을 걸 수 있습니다.

public static <T extends Number> void printNumber(T value) {
    System.out.println(value.doubleValue());
}

이 메서드는 Number 계열 타입만 받을 수 있습니다.

public class Main {
    public static void main(String[] args) {
        printNumber(100);
        printNumber(3.14);

        // printNumber("Java"); // 오류 발생
    }

    public static <T extends Number> void printNumber(T value) {
        System.out.println(value.doubleValue());
    }
}

실행 결과는 다음과 같습니다.

100.0
3.14

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

제네릭 제한에서 extends는 클래스뿐만 아니라 인터페이스에도 사용합니다.

interface Printable {
    void print();
}

class Printer<T extends Printable> {
    public void printItem(T item) {
        item.print();
    }
}

T extends PrintablePrintable을 구현한 타입만 사용할 수 있다는 뜻입니다. 인터페이스를 기준으로 제한할 때도 implements가 아니라 extends를 사용합니다.

주의
제네릭 타입 제한에서는 클래스 기준이든 인터페이스 기준이든 extends를 사용합니다.

9. 와일드카드란?

와일드카드는 제네릭에서 정확한 타입을 알 수 없거나, 여러 타입을 유연하게 받고 싶을 때 사용하는 문법입니다.

와일드카드는 물음표 ?로 표현합니다.

List<?>

List<?>는 “어떤 타입의 List인지는 정확히 모르지만, List이긴 하다”는 뜻입니다.

쉽게 말하면
와일드카드 ?는 “정확한 타입은 모르지만 어떤 타입이든 올 수 있다”는 표시입니다.

10. 제네릭은 상속 관계가 그대로 적용되지 않는다

IntegerNumber의 자식 타입입니다. 하지만 ArrayList<Integer>ArrayList<Number>의 자식이 되는 것은 아닙니다.

ArrayList<Integer> intList = new ArrayList<>();

// ArrayList<Number> numberList = intList; // 오류 발생

이 부분이 제네릭에서 자주 헷갈리는 지점입니다. IntegerNumber 사이에는 상속 관계가 있지만, ArrayList<Integer>ArrayList<Number> 사이에는 같은 방식의 상속 관계가 적용되지 않습니다.

11. 왜 ArrayList<Integer>를 ArrayList<Number>로 받을 수 없을까?

만약 ArrayList<Integer>ArrayList<Number>로 받을 수 있다고 가정해보겠습니다. 그러면 문제가 생길 수 있습니다.

ArrayList<Integer> intList = new ArrayList<>();

// 만약 아래 코드가 가능하다고 가정하면
// ArrayList<Number> numberList = intList;

// numberList.add(3.14); // Double 추가 가능해져 버림

// intList는 Integer만 담아야 하는데 Double이 들어갈 수 있음

이런 문제를 막기 위해 자바는 ArrayList<Integer>ArrayList<Number>에 바로 대입하지 못하게 합니다. 이럴 때 필요한 문법이 와일드카드입니다.

12. 비제한 와일드카드 <?>

<?>는 어떤 타입이든 받을 수 있는 와일드카드입니다. 정확한 타입은 모르지만, 값을 읽어서 출력하는 정도는 할 수 있습니다.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> names = new ArrayList<>();
        names.add("Java");
        names.add("Spring");

        ArrayList<Integer> scores = new ArrayList<>();
        scores.add(90);
        scores.add(80);

        printList(names);
        printList(scores);
    }

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

실행 결과는 다음과 같습니다.

Java
Spring
90
80

printList()ArrayList<String>도 받고, ArrayList<Integer>도 받을 수 있습니다. 다만 정확한 타입을 모르기 때문에 값을 꺼낼 때는 보통 Object로 다룹니다.

13. <?>에는 값을 추가하기 어렵다

ArrayList<?>는 어떤 타입의 리스트인지 정확히 모릅니다. 그래서 특정 값을 추가하기 어렵습니다.

public static void addValue(ArrayList<?> list) {
    // list.add("Java"); // 오류 발생
    // list.add(100);    // 오류 발생
}

이 리스트가 ArrayList<String>인지, ArrayList<Integer>인지, ArrayList<Product>인지 알 수 없기 때문에 자바는 값 추가를 막습니다.

단순히 여러 타입의 리스트를 읽고 출력하는 용도라면 <?>가 유용합니다.

14. 상한 와일드카드 <? extends 타입>

<? extends 타입>은 해당 타입 또는 그 자식 타입을 받을 수 있다는 뜻입니다.

ArrayList<? extends Number>

이 코드는 Number 또는 Number의 자식 타입을 담은 ArrayList를 받을 수 있다는 뜻입니다.

전달 가능 이유
ArrayList<Integer> Integer는 Number의 자식
ArrayList<Double> Double은 Number의 자식
ArrayList<Number> Number 자신
ArrayList<String> 전달 불가, String은 Number 계열이 아님

15. <? extends Number> 예제

숫자 리스트의 합계를 구하는 메서드를 만들어보겠습니다.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<Integer> intList = new ArrayList<>();
        intList.add(10);
        intList.add(20);

        ArrayList<Double> doubleList = new ArrayList<>();
        doubleList.add(1.5);
        doubleList.add(2.5);

        System.out.println(sum(intList));
        System.out.println(sum(doubleList));
    }

    public static double sum(ArrayList<? extends Number> list) {
        double total = 0;

        for (Number number : list) {
            total += number.doubleValue();
        }

        return total;
    }
}

실행 결과는 다음과 같습니다.

30.0
4.0

sum() 메서드는 ArrayList<Integer>ArrayList<Double>을 모두 받을 수 있습니다. 숫자 계열 타입만 받도록 제한했기 때문에 각 값을 Number로 꺼내서 doubleValue()를 사용할 수 있습니다.

16. extends 와일드카드는 읽기에 강하다

<? extends Number>는 Number 계열 값을 읽어오는 데 좋습니다. 하지만 값을 추가하는 것은 제한됩니다.

public static void addNumber(ArrayList<? extends Number> list) {
    // list.add(10);   // 오류 발생
    // list.add(3.14); // 오류 발생
}

이유는 간단합니다. 이 리스트가 실제로 ArrayList<Integer>인지, ArrayList<Double>인지 알 수 없기 때문입니다.

ArrayList<? extends Number> list

실제 타입이 ArrayList<Integer>일 수도 있음
실제 타입이 ArrayList<Double>일 수도 있음

따라서 Integer를 넣어도 되는지,
Double을 넣어도 되는지 확정할 수 없음

기억하기
<? extends 타입>은 값을 읽는 데 적합하지만, 값을 추가하는 데는 제한이 큽니다.

17. 하한 와일드카드 <? super 타입>

<? super 타입>은 해당 타입 또는 그 부모 타입을 받을 수 있다는 뜻입니다.

ArrayList<? super Integer>

이 코드는 Integer 또는 Integer의 부모 타입을 담는 ArrayList를 받을 수 있다는 뜻입니다.

전달 가능 이유
ArrayList<Integer> Integer 자신
ArrayList<Number> Number는 Integer의 부모
ArrayList<Object> Object는 모든 클래스의 부모
ArrayList<Double> 전달 불가, Double은 Integer의 부모가 아님

18. <? super Integer> 예제

<? super Integer>를 사용하면 Integer 값을 안전하게 추가할 수 있습니다.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<Integer> intList = new ArrayList<>();
        ArrayList<Number> numberList = new ArrayList<>();
        ArrayList<Object> objectList = new ArrayList<>();

        addIntegers(intList);
        addIntegers(numberList);
        addIntegers(objectList);

        System.out.println(intList);
        System.out.println(numberList);
        System.out.println(objectList);
    }

    public static void addIntegers(ArrayList<? super Integer> list) {
        list.add(10);
        list.add(20);
    }
}

실행 결과는 다음과 비슷합니다.

[10, 20]
[10, 20]
[10, 20]

ArrayList<Integer>, ArrayList<Number>, ArrayList<Object> 모두 Integer 값을 담을 수 있으므로 메서드에 전달할 수 있습니다.

19. super 와일드카드는 추가에 강하다

<? super Integer>는 Integer 값을 추가하는 데 적합합니다. 하지만 값을 꺼낼 때는 정확한 타입을 알기 어렵기 때문에 보통 Object로 꺼냅니다.

public static void readValues(ArrayList<? super Integer> list) {
    Object value = list.get(0);

    System.out.println(value);
}

실제 리스트가 ArrayList<Integer>인지, ArrayList<Number>인지, ArrayList<Object>인지 알 수 없기 때문에 꺼낼 때는 Object로 받는 것이 안전합니다.

20. extends와 super 비교

두 와일드카드는 방향이 반대입니다.

구분 <? extends 타입> <? super 타입>
방향 타입 또는 자식 타입 타입 또는 부모 타입
예시 <? extends Number> <? super Integer>
강한 쪽 읽기 추가
읽을 때 상한 타입으로 읽기 가능 보통 Object로 읽음
추가할 때 대부분 제한됨 기준 타입 값 추가 가능

21. PECS로 기억하기

와일드카드 방향은 PECS라는 말로 기억할 수 있습니다.

Producer Extends
Consumer Super

처음에는 영어 표현이 어렵게 느껴질 수 있으므로 아래처럼 이해하면 충분합니다.

  • 값을 주로 읽어오기만 한다면 extends
  • 값을 주로 넣어야 한다면 super

즉, extends는 읽기 중심, super는 추가 중심으로 기억하면 됩니다.

22. 타입 파라미터와 와일드카드 차이

<T><?>는 비슷해 보이지만 목적이 다릅니다.

구분 타입 파라미터 T 와일드카드 ?
형태 <T> <?>
의미 메서드나 클래스 안에서 타입 이름처럼 사용 정확히 모르는 타입을 표현
타입 재사용 가능 어렵다
예시 public <T> T get(T value) printList(List<?> list)

메서드 안에서 같은 타입을 여러 번 연결해서 사용해야 한다면 T가 더 적합합니다. 단순히 어떤 타입의 리스트든 받아서 읽기만 하면 된다면 ?가 간단할 수 있습니다.

23. 전체 예제

이번에는 제한된 제네릭과 와일드카드를 함께 사용하는 예제를 보겠습니다.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<Integer> intList = new ArrayList<>();
        intList.add(10);
        intList.add(20);

        ArrayList<Double> doubleList = new ArrayList<>();
        doubleList.add(1.5);
        doubleList.add(2.5);

        printAnyList(intList);
        printAnyList(doubleList);

        System.out.println("정수 합계: " + sumNumbers(intList));
        System.out.println("실수 합계: " + sumNumbers(doubleList));

        ArrayList<Number> numberList = new ArrayList<>();
        addIntegerValues(numberList);

        System.out.println(numberList);
    }

    public static void printAnyList(ArrayList<?> list) {
        for (Object item : list) {
            System.out.println(item);
        }
    }

    public static double sumNumbers(ArrayList<? extends Number> list) {
        double total = 0;

        for (Number number : list) {
            total += number.doubleValue();
        }

        return total;
    }

    public static void addIntegerValues(ArrayList<? super Integer> list) {
        list.add(100);
        list.add(200);
    }
}

실행 결과는 다음과 같습니다.

10
20
1.5
2.5
정수 합계: 30.0
실수 합계: 4.0
[100, 200]

printAnyList()는 어떤 타입의 리스트든 출력합니다. sumNumbers()는 Number 계열 리스트만 받아서 합계를 구합니다. addIntegerValues()는 Integer를 담을 수 있는 리스트에 값을 추가합니다.

24. 제한된 제네릭과 와일드카드 사용 시 자주 하는 실수

제한된 제네릭과 와일드카드를 처음 배울 때는 아래와 같은 실수를 자주 합니다.

실수 문제점 해결 방법
ArrayList<Integer>ArrayList<Number>로 생각 제네릭 타입 사이에는 상속 관계가 그대로 적용되지 않음 <? extends Number> 사용 고려
<? extends Number>에 값을 추가하려 함 실제 타입이 무엇인지 몰라 추가가 제한됨 읽기 중심으로 사용
<? super Integer>에서 값을 Integer로 바로 꺼내려 함 실제 부모 타입이 무엇인지 몰라 Object로 읽는 것이 안전 꺼낼 때는 Object 기준으로 생각
extendssuper 방향 혼동 읽기와 추가 목적이 섞임 읽기는 extends, 추가는 super로 기억
인터페이스 제한에 implements를 사용하려 함 제네릭 제한에서는 인터페이스도 extends 사용 <T extends Interface> 형태 사용

25. 직접 연습해보기

아래 코드를 직접 작성하고 실행해보세요.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<Integer> scores = new ArrayList<>();
        scores.add(90);
        scores.add(80);
        scores.add(70);

        ArrayList<Double> rates = new ArrayList<>();
        rates.add(1.5);
        rates.add(2.5);

        printList(scores);
        printList(rates);

        System.out.println("점수 합계: " + sum(scores));
        System.out.println("비율 합계: " + sum(rates));

        ArrayList<Number> numbers = new ArrayList<>();

        addDefaultScores(numbers);

        System.out.println("기본 점수 목록: " + numbers);
    }

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

    public static double sum(ArrayList<? extends Number> list) {
        double total = 0;

        for (Number number : list) {
            total += number.doubleValue();
        }

        return total;
    }

    public static void addDefaultScores(ArrayList<? super Integer> list) {
        list.add(60);
        list.add(70);
        list.add(80);
    }
}

실행 결과는 다음과 같습니다.

90
80
70
1.5
2.5
점수 합계: 240.0
비율 합계: 4.0
기본 점수 목록: [60, 70, 80]

26. 이번 글 정리

이번 글에서는 제네릭의 타입 범위를 제한하는 제한된 제네릭과 유연한 타입 표현인 와일드카드에 대해 정리했습니다. 핵심 내용은 다음과 같습니다.

  • 제한된 제네릭은 타입 파라미터에 사용할 수 있는 타입 범위를 제한한다.
  • <T extends Number>는 Number 또는 Number의 자식 타입만 허용한다.
  • 제네릭 제한에서는 클래스와 인터페이스 모두 extends를 사용한다.
  • <?>는 정확한 타입을 모르는 와일드카드이다.
  • ArrayList<Integer>ArrayList<Number>의 자식 타입이 아니다.
  • <? extends 타입>은 해당 타입 또는 자식 타입을 받을 수 있다.
  • <? extends Number>는 Number 계열 값을 읽을 때 유용하다.
  • <? extends 타입>은 값을 추가하는 데 제한이 크다.
  • <? super 타입>은 해당 타입 또는 부모 타입을 받을 수 있다.
  • <? super Integer>는 Integer 값을 추가할 때 유용하다.
  • 읽기 중심이면 extends, 추가 중심이면 super를 먼저 떠올리면 좋다.
  • 타입 파라미터 T는 메서드나 클래스 안에서 타입을 재사용할 때 쓰고, 와일드카드 ?는 정확히 모르는 타입을 표현할 때 쓴다.

한 줄 요약
제한된 제네릭은 사용할 타입의 범위를 정하는 문법이고, 와일드카드는 정확한 타입을 모르거나 여러 제네릭 타입을 유연하게 받기 위한 문법입니다.

다음 글 예고
다음 글에서는 [Java 개념노트 55] 람다식이란 무엇인가?라는 주제로 메서드처럼 동작하는 코드를 더 짧고 간결하게 표현하는 방법을 정리해보겠습니다.

GWDEVELBlog Java 개념노트 시리즈