Generic (2)

Huisu·2025년 6월 23일

ETC

목록 보기
9/12
post-thumbnail

자바의 공변성/반공변성

제네릭의 와일드카드를 배우기 앞서 선수 지식으로 알고 넘어가야 할 개념이 있다. 조금 난이도 있는 프로그래밍 부분을 학습하다 보면 자주 듣게 되는 공변성 (Covariance) / 반공변성 (Contravariance) 합쳐서 변성 (Variance) 이라는 개념이다.

변성은 타입의 상속 계층 관계에서 서로 다른 타입 간에 어떤 관계가 있는지를 나타내는 지표이다. 그리고 공변성은 서로 다른 타입간에 함께 변할 수 있다는 특징을 말한다. 이를 객체 지향 개념으로 표현하자면 Liskov 치환 원칙에 해당된다.

예를 들어 배열(Array)와 리스트(List)가 있다고 하자. 자바에서 각 변성의 특징은 다음과 같이 된다.

  • 공변: S가 T의 하위 타입이면, S[]는 T[]의 하위 타입이고 List< S>는 List< T>의 하위 타입이다.
  • 반공변: S가 T의 하위 타입이면, T[]는 S[]의 하위 타입이고 List< T>는 List< S>의 하위 타입이다.
  • 무공변/불공변: S와 T는 서로 관계가 없고, List< S>와 List< T>는 서로 다른 타입이다.

언뜻 보면 다형성의 업캐스팅과 다운캐스팅을 말하는 것과 비슷해 보인다. 이를 자바 코드로 나타내 보자면 다음과 같다.


// 공변성
ArrayList<Object> Covariance = new ArrayList<Integer>();

// 반공변성
ArrayList<Integer> Contravariance = new ArrayList<Object>();

그러나 배열과 달리 제네릭 예제 코드는 자바에서 돌아가지 않는다. 왜냐하면 자바는 일반적으로 제네릭 타입에 대해서 공변성/반공변성을 지원하지 않기 때문이다. 즉 자바의 제네릭은 무공변의 성질을 지난다고 정의할 수 있다.

제네릭은 공변성이 없다

객체 타입은 상하 관계가 있다

이 부분은 우리가 너무나도 잘 알고 있는 다형성(Polymorphism)의 성질의 예시이다.

Object 타입으로 선언한 parent 변수와 Integer 타입으로 선언한 child 변수가 있는데 객체지향 프로그래밍에선 이들끼리 서로 간의 캐스팅이 가능하다.


Object parent = new Object();
Integer child = new Integer(1);

parent = child; //다형성 (업캐스팅)

Object parent = new Integer();
Integer child;
child = (Integer) parent; //다형성 (다운캐스팅)

일반 클래스가 아닌 제네릭 클래스여도 똑같이 다형성이 적용되는 건 마찬가지이다.

대표적으로 컬렉션 프레임워크의 Collection과 그 하위인 ArrayList는 서로 조상-자손 상속 관계에 있기 때문에 캐스팅이 가능하다.


Collection<Integer> parent = new ArrayList<>();
ArrayList<Integer> child = new ArrayList<>();

parent = child;

제네릭 타입은 상하 관계가 없다

반면에 제네릭의 타입 파라미터끼리는 아무리 상속 관계에 놓인다고 한들 캐스팅이 불가능하다. 제네릭은 무공변이기 때문이다. 제네릭은 전달받은 딱 그 타입으로만 서로 캐스팅이 가능하다.


ArrayList<Object> parent = new ArrayList<>();
ArrayList<Integer> child = new ArrayList<>();

parent = child; // 업캐스팅 불가능
child = parent; // 다운캐스팅 불가능

즉 꺽쇠 괄호 부분을 제외한 원시 타입 부분은 공변성이 적용되지만 꺽쇠 괄호 안의 실제 타입 매개변수에 대해서는 공변성이 적용되지 않는다. 제네릭은 무공변이기 때문이다.

공변성이 없으면 나타나는 문제점

이 특징이 문제가 되는 이유는 매개변수로 제네릭을 사용할 때, 외부에서 대입되는 인자의 캐스팅 문제로 Error가 발생하기 때문이다.

예를 들어 리스트를 인자로 받아 순회해 주는 print 메서드가 있다고 가정해 보자. 배열을 이용한 아래의 코드는 아무 문제 없다.

public static void print(Object[] arr) {
           for (Object o : arr) {
                     System.out.println(o);
           }
}

public static void main(String[] args) {
           Integer[] integers = {1, 2, 3};
           print(integers);
}

이번엔 배열이 아닌 리스트의 제네릭 객체로 넘겨보자. 그러면 메소드 호출 부분에서 컴파일 에러가 발생한다.

public static void print(List<Object> arr) {
           for (Object o : arr) {
                     System.out.println(o);
           }
}

public static void main(String[] args) {
           List<Integer> integers = Arrays.asList(1, 2, 3);
           print(integers);
}

배열 같은 경우 print 메서드의 매개변수로 argument가 넘어갈 때, Integer[]가 Object[]로 자연스럽게 업캐스팅이 된다. 하지만 List 제네릭 같은 경우 타입 파라미터가 오로지 똑같은 타입만 받기 때문에 캐스팅이 되지 않아 그런 것이다.

그렇다면 외부로부터 값을 받는 매개변수의 제네릭 타입 파라미터를 Integer로 고정된 타입으로 작성해 주어야 한다. 하지만 프로그램 실행부에서 반드시 Integer 타입만 들어온다는 보장도 없으며, 다른 타입도 받고 싶은 경우 메서드를 오버로딩하여 아주 긴 코딩을 해야 한다.

public static void print(List<Integer> arr) {
}

public static void print(List<Double> arr) {
}

public static void print(List<Number> arr) {
}

그럼 제네릭은 자바의 특징이라고 할 수 있는 객체지향을 전혀 이용하지 못하는 것이 되었는데, 결국 위와 같이 비효율적으로 코딩하게 둘 수는 없다. 바로 이것을 해결하기 위해 나온 것이 와일드카드이다.

제네릭 와일드카드

자바 제네릭을 이용해 프로그래밍할 때 간혹 클래스 정의문을 보다 보면 꺽쇠 괄호와 물음표 기호가 있는 것을 한 번쯤 본 적이 있을 것이다. 이 물음표가 와일드카드이며, 물음표의 의미답게 어떤 타입이든 될 수 있다는 뜻을 지니고 있다.

하지만 단순히 <?>로 사용하면 Object 타입과 다름이 없어지므로 보통 제네릭 타입 한정 연산자와 함께 쓰인다.

와일드 카드의 타입 범위를 제한하는 키워드는 extends와 더불어 super가 있다. 이 extends와 super 키워드는 클래스 상속 관계에서의 타입을 하위 타입으로만 제한할지, 상위 타입으로만 제한할지에 따라 쓰임새가 다르게 된다.

  •      - Unbounded wildcard, 비한정적 와일드카드
         - 제한 없음 (모든 타입이 가능)
    
  • ``` _ Upper Bounded Wildcards, 상한 경계 와일드카드 - 상위 클래스 제한 (U와 그 자손들만 가능) - 상한이 U라서 상한 경계라고 함 ```
  • ``` - Lower Bounded Wildcard, 하한 경계 와일드카드 - 하위 클래스 제한 (U와 그 조상들만 가능) - 하한이 U라서 하한 경계라고 함 ```

와일드카드의 공변성/반공변성

다음은 실제 ArrayList의 기능 중 일부 메서드만 추려서 별도로 재구상한 MyArrayList 제네릭 클래스 예제이다.

MyArrayList 생성자(Constructor)를 보면, 컬렉션을 받아 컬렉션을 순회하여 컬렉션 내에 들어 있는 모든 요소를 내부 배열 (Object[])에 넣어 제네릭 객체를 생산하는 역할을 한다.

MyArrayList의 clone 메서드를 보면, 빈 컬렉션을 받아 내부 배열을 순회하여 배열 내에 들어 있는 모든 요소를 컬렉션에 넣어 주는, 매개변수로 받은 제네릭 객체를 소비하는 역할을 한다.

class MyArrayList<T> {
           Object[] element = new Object[5];
           int index = 0;

           // 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화하는 생성자
           public MyArrayList(Collection<T> in) {
                     for(T elem : in) {
                                element[index++] = elem;
                     }
           }

           // 외부로부터 리스트를 받아와 내부 배열의 요소를 모두 매개변수에 추가해 주는 메서드
           public void clone(Collection<T> out) {
                     for(Object elem : element) {
                                out.add((T) elem);
                     }
           }

           @Override
           public String toString() {
                     return Arrays.toString(element); // 배열 요소 출력 요청
           }
}

이 코드는 유연하지 않다.

MyArrayList에 정수도 받고 실수도 받게 하기 위해 제네릭 타입 매개변수에 Number를 지정해 주었다. 그리고 Integer 매개변수화 타입의 Collection 객체를 MyArrayList 생성자로 넣어 주었다.

public static void main(String[] args) {
           // MyArrayList의 제네릭 T 타입은 Number
           MyArrayList<Number> list;

           // MyArrayList 생성하기
           Collection<Integer> col = Arrays.asList(1, 2, 3);
           list = new MyArrayList<>(col);
           
           //  MyArrayList 출력
           System.out.println(list);
}

위 코드를 실행하면 컴파일 에러가 난다. 그 이유는 매개변수는 Collection 타입으로 받는데, Collection 객체를 전달해 주었기 때문이다. Integer은 Number를 상속받은 부모-자식 관계지만, 제네릭에서는 공변성이 없어 이 관계가 성립되지 않게 되는 것이다.

class MyArrayList<T> {
           Object[] element = new Object[5];
           int index = 0;
           
           public MyArrayList(Collection<Number> in) {
                     for(Number elem : in) {
                                element[index++] = elem;
                     }
           }
}

그래서 하는 수 없이 col 객체의 제네릭 타입을 똑같이 Collection로 맞춰 주고 나서야 MyArrayList 인스턴스를 생산할 수 있게 된다.

이번에는 MyArrayList의 clone 메서드에 빈 LinkedList를 인자로 줘서 MyArrayList에 들어 있는 원소들을 복사하고자 한다. 이때 String, Number 등 모든 타입의 데이터를 받을 수 있게 하기 위해 Object 타입 파라미터로 설정하였다.

public static void main(String[] args) {
           // MyArrayList의 제네릭 T 타입은 Number
           MyArrayList<Number> list;

           // MyArrayList 생성하기
           Collection<Number> col = Arrays.asList(1, 2, 3);
           list = new MyArrayList<>(col);

           //  LinkedList에 MyArrayList 요소들 복사하기
           List<Object> temp = new LinkedList<>();
           temp = list.clone(temp);

           // LinkedList 출력
           System.out.println(temp);
}

역시나 결과는 컴파일 에러이다. 제네릭은 반공변 역시 성립되지 않기 때문에 Collection에 Collection가 들어가는 행위는 성립되지 않는다.

이처럼 자바의 제네릭은 기본적으로 공변, 반공변을 지원하지 않지만, <? extends T>, <? super T> 와일드카드를 이용하면 컴파일러 트릭을 통해 공변, 반공변이 적용되도록 설정할 수 있다. 둘을 정리하자면 다음과 같다.

  • 상한 경계 와일드카드 <? extends T>: 공변성 적용
  • 하한 경계 와일드카드 <? super T>: 반공변성 적용

상한 경계 와일드카드 (공변)

MyArrayList를 설계한 개발자의 의도는 Collection과 Collection 객체를 생성자의 인수로 모두 받아 배열에 넣어 보고 싶은 것이다. 이를 위해 제네릭에 상한 경계 와일드카드를 적용시킨다.

class MyArrayList<T> {
           Object[] element = new Object[5];
           int index = 0;

           // 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화하는 생성자
           public MyArrayList(Collection<? extends T> in) {
                     for(T elem : in) {
                                element[index++] = elem;
                     }
           }
           ...

}
public static void main(String[] args) {

           // MyArrayList의 제네릭 T 타입은 Number
           MyArrayList<Number> list;

           // MyArrayList 생성하기
           Collection<Integer> col = Arrays.asList(1, 2, 3);
           list = new MyArrayList<>(col);

           // MyArrayList 출력
           System.out.println(list);
}

그러면 공변 성질이 적용되어 컴파일 에러 없이 정상적으로 MyArrayList에 요소가 들어가 생산되었음을 확인할 수 있다.

C -> C<? extends Number> -> C(? extends Object>

즉 Integer가 Object의 하위 타입을 경우 C는 C<? extends Object>의 하위 타입이 되는 것이다.

ArrayList<? extends Object> parent = new ArrayList<>();
ArrayList<? extends Integer> child = new ArrayList<>();
parent = child;

하한 경계 와일드카드 (반공변)

MyArrayList의 clone 메서드를 설계한 개발자의 의도는 MyArrayList의 제네릭 타입 파라미터가 무엇이든 인자로 받은 컬렉션 매개변수에 요소들을 모두 넣고 싶은 것이다. 이를 위해 제네릭에 하한 경계 와일드카드를 적용시킨다.

class MyArrayList<T> {
           Object[] element = new Object[5];
           int index = 0;

           // 외부로부터 리스트를 받아와 내부 배열의 요소를 모두 매개변수에 추가해 주는 메서드
           public void clone(Collection<? super T> out) {
                     for(Object elem : element) {
                                out.add((T) elem);
                     }
           }
           ...

}
public static void main(String[] args) {

           // MyArrayList의 제네릭 T 타입은 Number
           MyArrayList<Number> list = new MyArrayList<>(Arrays.asList(1, 2, 3, 4, 5));

           // LinkedList에 MyArrayList 요소를 복사하기
           List<Object> temp = new LinkedList<>();
           temp = list.clone(temp);

           // LinkedList 출력
           System.out.println(temp);
}

그러면 반공변 성질이 적용되어 컴파일 에러 없이 정상적으로 MyArrayList에 요소가 들어가 소비되었음을 알 수 있다.

C -> C<? super Number> -> C<? super Integer>

ArrayList<? super Object> parent = new ArrayList<>();
ArrayList<? super Integer> child = new ArrayList<>();
child = parent;

어디다 써야 할지도 애매한 이런 거꾸로의 상속 관계는 위의 사례와 같이 인스턴스화된 클래스의 제네릭보다 상위 타입의 데이터를 적재해야 할 때 반공변을 사용한다고 이해하면 된다. 이러한 제네릭의 변성을 적절하게 사용하면 유연한 코드를 작성할 수 있게 된다.

자바의 제네릭은 기본적으로 변성이 없지만, 한정적 와일드카드 타입을 통해 타입의 공변성 또는 반공변성을 지정할 수 있다. 이렇게 타입 매개변수 지점에 변성을 정하는 자바의 방식을 사용지점 변성(use-site variance)라 한다. 자바와 같이 JVM을

비한정적 와일드카드

제네릭에 extends, super 범위 따지지 않고 심플하게 비한정적 와일드카드로만 구성하는 방법을 살펴보자.

어떠한 타입도 올 수 있다는 것은 치트키이지만, 동시에 어떠한 타입도 올 수 있는 문제 때문에 매개변수를 꺼내거나 저장하는 로직은 논리적 에러가 발생한다.

public MyArrayList(Collection<?> in) {
           for(T elem : in) {
                     element[index++] = elem;
           }
}

public void clone(Collection<?> out) {
           for(Object elem : element) {
                     out.add((T) elem);
           }
}

하지만 extends, super를 통해 와일드카드의 경계를 정해 주면, 타입의 범위 내에서 추론을 하기 때문에 경고는 발생할지라도 오류는 나지 않게 된다.

와일드카드 꺼내기/넣기 제약

와일드카드 경계 범위

다음과 같이 클래스 타입의 상속 관계가 있다고 가정해 보자.

class Box<T> {
           ...
}

class Food {}
class Fruit extends Food{}
class Vegetable extends Food{}
class Apple extends Fruit{}
class Banana extends Fruit{}
class Carrot extends Vegetable{}

상한 경계 <? extends U>

  • 타입 매개변수의 범위는 U 클래스이거나 U를 상속한 하위 클래스 (U와 U의 자손 타입만 가능)
  • 상한의 뜻: 타입의 최고 한도는 U라는 의미 (최대 U 이하)
Box<? extends Fruit> box1 = new Box<Fruit>();
Box<? extends Fruit> box2 = new Box<Apple>();
Box<? extends Fruit> box3 = new Box<Banana>();

하한 경계 <? super U>

  • 타입 매개변수의 범위는 U 클래스이거나 U가 상속한 상위 클래스 (U와 U의 조상 타입만 가능)
  • 하한의 뜻: 타입의 최저 한도는 U라는 의미 (최소 U 이상)
Box<? extends Fruit> box1 = new Box<Fruit>();
Box<? extends Fruit> box2 = new Box<Food>();
Box<? extends Fruit> box3 = new Box<Object>();

비경계 <?>

  • 타입 매개변수의 범위는 제한이 없음 (모두 가능)
Box<?> box1 = new Box<Fruit>();
Box<?> box2 = new Box<Food>();
Box<?> box3 = new Box<Apple>();

와일드카드 경계 꺼내기/넣기 고찰

와일드카드를 하한 경계로 지정했는가 상한 경계로 지정했는가에 따라 제네릭 타입 객체에 원소를 꺼내거나 집어넣기까지 복잡한 제약이 걸린다. 실무에 임하는 현직자들도 자주 헷갈리는 부분이며, 왜 제약이 걸리는지 논리적으로 타입을 투론하는 심도 있는 고민이 필요하다.

제네릭 타입 클래스로는 가장 익숙한 List 자료형을 이용해 예를 들어보자.

List<? extends U>

  • GET: 안전하게 꺼내려면 U 타입으로 받아야 함
  • SET: 어떠한 타입의 자료도 넣을 수 없음 (null만 삽입 가능)
  • 꺼낸 타입은 U / 저장은 NO

매서드의 매개변수 제네릭 타임이 <?extends Fruit>이라는 것은 Apple, Banana, Fruit 타입을 전달받아 내부에서 다룰 수 있다는 말이다.

class FruitBox {

           // 안전하게 꺼내려면 Fruit 타입으로만 받아야 한다
           Fruit f1 = item.get(0);
           Apple f2 = (Apple) item.get(0); // 잠재적 Error
           Banana f3 = (Banana) item.get(0); // 잠재적 Error
}

public class Main {
           public static void main(String[] args) {
                     List<Banana> bananas = new ArrayList<> (
                                Arrays.asList(new Banana(), new Banana(), new Banana())
                     );
                     FruitBox.method(bananas);
           }
}

그렇다면 꺼내는 것을 왜 Fruit 타입으로 받아야 할까?

  • 만일 매개변수에 List타입으로 들어올 경우 Apple f2에 형제 캐스팅이 불가능
  • 만일 매개변수에 List 타입으로 들어올 경우 Apple f2에서 다운 캐스팅이 불가능
  • 이러한 논리 오류로 와일드카드 최상위 범위인 Fruit으로만 안전하게 꺼낼 수 있음
class FruitBox {
           public static void method(List<? extends Fruit> item) {
                     item.add(new Fruit()); // Error
                     item.add(new Apple()); // Error
                     item.add(new Banana()); // Errpr

                     item.add(null);
           }
}

왜 null 말고 무엇도 저장할 수 없을까?

  • 만일 매개변수에 List 타입으로 들어올 경우 형제 객체인 new Apple() 저장이 불가능
  • 만일 매개변수에 List 타입으로 들어올 경우는 문제가 없겠지만 위의 논리 오류로 인해 그냥 컴파일 에러 처리
  • 따라서 만일 매개변수에 값을 넣고 싶다면 무조건 super 와일드카드 사용

List<? super U>

  • GET: 안전하게 꺼내려면 Object 타입으로만 받아야 함
  • SET: U와 U의 자손 타입만 넣을 수 있음
  • 꺼낸 타입은 Object / 저장은 U와 그의 자손만

메서드의 매개변수 제네릭 타입이 <? super Fruit> 라는 것은 Fruit, Food, Object 타입을 전달받아 내부에서 다룰 수 있다는 말이다.

class FruitBox {
           public static void method(List<? superFruit> item) {
                     // 안전하게 꺼내려면 Object 타입으로만 받아야 함
                     Object f1 = item.get(0);

                     Food f2 = (Food) item.get(0); // 잠재적 Error
                     Fruit f3 = (Fruit) item.get(0); // 잠재적 Error
                     Apple f4 = (Apple) item.get(0); // 잠재적 Error
                     Banana f5 = (Banana) item.get(0); // 잠재적 Error
           }
}

public class Main {
           public static void main(String[] args) {
                     List<Food> foods = new ArrayList<> (
                                Arrays.asList(new Food(), new Food(), new Food())
                     );
                     FruitBox.method(foods);
           }
}

왜 꺼내는 것을 Object 타입으로 받아야 할까?

  • 매개변수에 List 타입으로 들어올 경우 Fruit f3에 캐스팅이 불가능
  • 이러한 논리 오류로 와일드카드 최상위 범위인 Object 타입으로만 안전하게 꺼낼 수 있음
class FruitBox {
           public static void method(List<? super Fruit> item) {
                     // 저장은 무조건  Fruit와 그의 자손 타입만
                     item.add(new Fruit());
                     item.add(new Apple());
                     item.add(new Banana());

                     item.add(new Object()) // Error
           }
}

저장은 왜 Fruit와 그의 자손 타입만 올 수 있을까?

  • 만일 매개변수에 List 타입으로 들어올 경우 new Food()는 저장이 불가능
  • 따라서 어떠한 타입이 와도 업캐스팅 가능 상한인 Fruit 타입으로만 제한

List<?>

  • GET: 안전하게 꺼내려면 Object의 타입으로만 받아야 한다 (super 특징)
  • SET: 어떠한 타입의 자료도 넣을 수 없음 (extends 특징)
  • 꺼낸 타입은 Object / 저장은 NO

매서드의 매개변수의 제네릭 타입이 <?> 라는 것은 모든 타입을 전달받아 내부에서 다룰 수 있다는 것이다.

class FruitBox {
           public static void method(List<?t> item) {
                     // 안전하게 꺼내려면 Object 타입으로만 받아야 함
                     Object f1 = item.get(0);

                     Food f2 = (Food) item.get(0); // 잠재적 Error
                     Fruit f3 = (Fruit) item.get(0); // 잠재적 Error
                     Apple f4 = (Apple) item.get(0); // 잠재적 Error
                     Banana f5 = (Banana) item.get(0); // 잠재적 Error
           }
}
class FruitBox {
           public static void method(List<? extends Fruit> item) {
                     item.add(new Fruit()); // Error
                     item.add(new Apple()); // Error
                     item.add(new Banana()); // Errpr
                     item.add(null);
           }
}

와일드카드 extends / super 사용 시기

언제 어디서 어느 때에 와일드카드 <? extends T>와 <? super T>를 사용해야 할지 헷갈릴 수 있다. 이는 현업에서도 자주 고민되는 사항이다.

PECS 공식

PECS란, Producer-Extends / Consumer-Super 라는 단어의 약자인데 다음을 의미한다.

  • 외부에서 온 데이터를 생산 (Producer)한다면 <? extends T>를 사용 (하위 타입으로 제한)
  • 외부에서 온 데이터를 소비 (Consumer) 한다면 <? super T)를 사용 (상위 타입으로 제한)

자바의 공변성 / 반공변성에서 사용한 예제를 다시 보겠다.

class MyArrayList<T> {
           Object[] element = new Object[5];
           int index = 0;
           
           // 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화하는 생성자
           public MyArrayList(Collection<? extends T> in) {
                     for(T elem : in) {
                                element[index++] = elem;
                     }
           }

           // 외부로부터 리스트를 받아와 내부 배열의 요소를 모두 매개변수에 추가해 주는 메서드
           public void clone(Collection<? super T> out) {
                     for(Object elem : element) {
                                out.add((T) elem);
                     }
           }
}

Producers Extend

위의 예제에서 extends가 쓰이는 곳은 생성자 메서드의 매개변수 부분이다. 즉 외부에서 온 데이터를 매개변수에 담아 for문으로 순화하여 MyArrayList를 인스턴스화하는 생산자 역할을 하고 있다고 볼 수 있다.

class MyArrayList<T> {
           Object[] element = new Object[5];
           int index = 0;
           
           // 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화하는 생성자
           public MyArrayList(Collection<? extends T> in) {
                     for(T elem : in) {
                                element[index++] = elem;
                     }
           }
           ...

}

Consumers Super

외부에서 리스트를 받아 요소를 복사하여 적재하는 clone 메서드의 매개변수에는 super 와일드카드 키워드가 쓰였다. 즉 MyArrayList의 내부 배열을 소비하여 매개변수 리스트에 적재하는 행위를 하고 있다고 볼 수 있다.

class MyArrayList<T> {
           Object[] element = new Object[5];
           int index = 0;

           ...

           // 외부로부터 리스트를 받아와 내부 배열의 요소를 모두 매개변수에 추가해 주는 메서드
           public void clone(Collection<? super T> out) {
                     for(Object elem : element) {
                                out.add((T) elem);
                     }
           }
}

혼동할 수 있는 와일드카드 표현

와일드카드는 설계가 아닌 사용을 위한 것

주니어 개발자들이 가장 많이 착각하는 것 중 하나가 와일드카드를 어디서나 사용할 수 있다고 믿는 것이다. 하지만 와일드카드는 아래와 같이 클래스나 인터페이스에서 제네릭을 설계할 때는 아예 사용할 수 없다.

class Sample<? extends T> {} // Error

와일드카드는 이미 만들어진 제네릭 클래스나 메서드를 사용할 때 이용하는 것으로 보면 된다. 즉 생성하는 것이 아니라 변수나 매개변수에 어떤 객체 타입의 파라미터를 받을 때, 그 범위를 한정해 주는 용도이다.

<T extends 타입> 와 <? extends U> 차이점

바로 위에서 언급한 것처럼 와일드카드는 제네릭 클래스를 만들 때 사용하는 것이 아니라, 이미 만들어진 제네릭 클래스를 사용할 때 타입을 지정하는 것이다.

즉 <T extends 타입>은 제네릭 클래스를 정의할 때 적어 주는 것이고, <? extends 타입>은 이미 만들어진 제네릭 클래스를 인스턴스화하여 사용할 때, 타입 파라미터에 적어 주는 것이다.

<T super 타입>은 왜 없을까

와일드카드에 <T extends 타입>은 있지만 <T super 타입>은 없는 걸 볼 수 있다.

<T extends 타입>은 정의할 때 제네릭 타입 범위를 제한하기 위해 사용하는데, <T super 타입>이 된다면 무수히 많은 자바 클래스와 인터페이스가 올 수 있다는 뜻이기 때문에 Object와 다르지 않아 굳이 쓸 이유가 없다.

<?>와 < Object>는 다르다

비경계 와일드카드가 모든 타입이 들어올 수 있으니 Object와 같은 것이 아닐지 헷갈릴 수 있다. 하지만 List와 List는 다른 것이다. 왜냐하면 List에는 Object의 하위 타입을 모두 넣을 수 있지만, List에는 오직 null만 넣을 수 있기 때문이다.

이는 타입 안정성을 지키기 위한 제네릭의 특성으로, 만약 List<?>에 모든 타입을 넣을 수 있게 한다면, List에 Double을 추가하게 되는 모순이 발생할 수 있다.

2개의 댓글

comment-user-thumbnail
2025년 6월 24일

자바 개발자는 아니지만 이해가 너무너무 잘 되는 글이네요!! 자바스크립크, 타입스크립트에도 비슷한 개념이 있을지 공부해봐야겠다는 생각이 들게 하는 글이네요 ^_^

1개의 답글