Java Basics (+) Generic의 와일드 카드, 공변성/반공변성

Wonho Kim·2025년 12월 16일

Java Basics

목록 보기
6/6

해당 포스팅은 Inpa Dev님과 Stranger's Lab님의 게시물을 기준으로 공부한 내용을 작성한 것입니다.

자바의 공변성/반공변성

제네릭의 와일드카드를 배우기 전에 미리 알아야 할 개념이 있다.

바로 공변성(Covariance)/반공변성(Contravariance)라는 개념이고, 이 둘을 합쳐서 변성(Variance)라고 한다.

변성(Variance)은 타입의 상속 계층 관계에서 서로 다른 타입 간에 어떤 관계가 있는지 나타내는 지표이다.

공변성(Covariance)은 서로 다른 타입 간에 함께 변할 수 있는 특징을 말한다.

우리가 SOLID 객체 지향 5대 원칙을 학습하면, 리스코프(Liskov) 치환 원칙이라는 개념이 있다.

변성은 우리가 학습하였던 리스코프 치환 원칙을 준수하는 개념에 해당한다.

너무 어려운 용어로 적어놔서 이해가 잘 안간다...

리스코프 치환 원칙을 다시 리마인드 해보면, 프로그램 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다는 원칙이다.

쉽게 말해서 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 다형성 개념을 말하는 것이다.

이제 용어를 다시 구체적으로 제네릭 타입을 예시로 들어 생각해보자.

- 공변: S가 T의 하위 타입이면, List<S>는 List<T>의 하위 타입이다.
- 반공변: S가 T의 하위 타입이면, List<T>는 List<S>의 하위 타입이다. (공변의 반대)
- 무공변: List<S>와 List<T>는 서로 다른 타입이다.

즉 코드로 표현하면 다음과 같다.

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

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

하지만 아쉽게도 위와 같은 코드는 자바에서 돌아가지 않는다. 왜냐면 자바는 일반적으로 제네릭 타입에 대해서 공변성/반공변성을 지원하지 않기 때문이다.

즉, 제네릭은 무공변(공변성이 없음)의 성질을 가지고 있다.

우리가 지금까지 무의식적으로 당연하게 사용해온 CollectionArrayList 클래스 간의 상하관계를 생각해보자.

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

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

해당 코드는 당연히 다형성 원리에 의해 정상적으로 업캐스팅이 가능하다. ArrayList의 상위 클래스에 Collection 클래스가 존재하기 때문이다.

하지만 아래와 같은 코드를 생각해보자.

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

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

제네릭 타입이 적힌 <> 끼리(타입 파라미터 끼리)는 타입이 아무리 상속 관계에 놓인다 한들 캐스팅이 불가능하다. 제네릭은 무공변 상태이기 때문에, 전달받은 딱 그 타입으로만 캐스팅이 가능하다는 것이다.

이러한 특징이 문제되는 이유는 아래와 같이 외부에서 대입되는 인자의 캐스팅 문제로 인해 애로사항이 발생하기 때문이다.

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

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

print 메서드에 아무리 List<Object> 타입으로 선언해도, 제네릭은 무공변이므로 업캐스팅이 발생하지 않고 컴파일 에러가 발생한다.

이러한 문제점을 해결하기 위해 와일드 카드가 등장하게 되었다.

Generic의 와일드 카드

자바 라이브러리를 뜯어서 공부하다 보면, 가끔 ? 와 같이 물음표 기호가 있는 것을 확인해본 적이 있을 것이다.

이 물음표를 와일드 카드(Wild Card)라고 하며, 물음표라는 이름 답게 어떤 타입이든 될 수 있다.는 의미이다.

하지만 그냥 <?>를 쓰면 Object 타입과 다름이 없다. 따라서 보통 제네릭 타입 한정 연산자를 함께 사용한다.

  • <?>: 비한정적 와일드 카드, 모든 타입 가능
  • <? extends U>: 상한 경계 와일드 카드, U와 그 자손들만 가능하도록 설정
  • <? super U>: 하한 경계 와일드 카드, U와 그 조상들만 가능하도록 설정

이해를 위해 아래 그림을 통해 extendssuper 키워드를 이해해보도록 하자.

그림과 같이 A, B, C, D, E가 상속 관계를 가지고 있다고 생각해보자.

그러면 extends 키워드를 사용하여 아래와 같이 구성할 수 있다.

<T extends B>	// B와 C타입만 올 수 있음
<T extends E>	// E타입만 올 수 있음
<T extends A>	// A, B, C, D, E 타입이 올 수 있음
 
<? extends B>	// B와 C타입만 올 수 있음
<? extends E>	// E타입만 올 수 있음
<? extends A>	// A, B, C, D, E 타입이 올 수 있음

주석 설명과 같이, 상한 한계, 즉 extends 뒤에 오는 타입이 최상위 타입으로 정해진다.

그렇다면 타입 변수를 지정한 <T extends B><? extends B>는 무슨 차이가 있을까?

이에 대한 주요 차이점은 제네릭 클래스/메서드를 선언하냐 아니면 제네릭 타입을 사용하냐에 따라 다르다.

public class ClassName <K extends Number> { 
	... 
}
 
public class Main {
	public static void main(String[] args) {
 
		ClassName<Double> a1 = new ClassName<Double>();	// OK!
 
		ClassName<String> a2 = new ClassName<String>();	// error!
	}
}

이 코드는 특정 타입 및 그 하위 타입만 제한하도록 설계하고 싶을 경우 사용한다.

즉, Integer는 Number 클래스를 상속받으므로 가능하지만, String은 Number 클래스와는 완전 별개이므로 에러를 발생시킨다.

static void readOnly(List<? extends B> list) {
    B b = list.get(0);   // 읽기는 OK (최소 B로는 보장)
    // list.add(new B()); // ❌ 보통 추가는 불가 (정확한 타입을 모르니까)
}

이 코드는 어떤 제네릭 타입을 사용(주로 매개변수 타입 위치) 곳에 사용한다.

즉, 받아들이는 범위를 넓히고 싶을 때 사용한다는 것이다.

이제 super의 경우를 생각해보자.

<K super B>	// B와 A타입만 올 수 있음
<K super E>	// E, D, A타입만 올 수 있음
<K super A>	// A타입만 올 수 있음
 
<? super B>	// B와 A타입만 올 수 있음
<? super E>	// E, D, A타입만 올 수 있음
<? super A>	// A타입만 올 수 있음

superextends와 반대로 최하위 타입으로 한계를 정한다.

우리가 보통 구현체를 지정하는 하위 상속 관계(부모에서 자식으로 향하는 관계)에 익숙하다 보니 이와 같은 반대 방향은 낮설게 느껴질 수 있다.

이와 같이 거꾸로 가는 상속 관계는 어디에 사용할 수 있길래 존재하는 것일까?

제네릭 타입에 대한 객체 비교

가장 대표적인 예시로 제네릭 타입에 대한 객체 비교가 있다.

아래 코드를 생각해보자.

public class ClassName <E extends Comparable<? super E>> { ... }

너무 길어서 헷갈리니, E extends Comparable부터 분석해보자.

public class SaltClass <E extends Comparable<E>> { ... }
 
public class Student implements Comparable<Student> {
	@Override
	public int compareTo(Student o) { ... };
}
 
public class Main {
	public static void main(String[] args) {
		SaltClass<Student> a = new SaltClass<Student>();
	}
}

SaltClass의 E는 Student가 되어야 하는데, Comparable<Student>의 하위 타입이어야 한다. 자바에서 어떤 클래스가 인터페이스의 하위 타입이라는 뜻은 곧 해당 인터페이스를 구현한 구현체라는 말과 동일하다.

class Student implements Comparable<Student>

그렇다면 왜 Comparable<E>가 아니라 <? super E>일까?

super E는 E를 포함하여 상위 타입 객체들이 올 수 있다. 만약 Student가 아닌 더 큰 범주의 Person 클래스를 둔다면 어떻게 될까?

코드로 확인해보자.

public class SaltClass <E extends Comparable<E>> { ... }	// Error가능성 있음
public class SaltClass <E extends Comparable<? super E>> { ... }	// 안전성이 높음
 
public class Person {...}
 
public class Student extends Person implements Comparable<Person> {
	@Override
	public int compareTo(Person o) { ... }; // Person 타입으로 업캐스팅
}
 
public class Main {
	public static void main(String[] args) {
		SaltClass<Student> a = new SaltClass<Student>();
	}
}

이와 같이 StudentPerson을 상속받고, compareTo 메서드가 Person 타입으로 업캐스팅 한다고 생각해보자.

만약 <E extends Comparable<E>>라면 SaltClass<Student> 객체가 타입 파라미터로 Student를 주지만, Comparable 입장에서는 상위 타입인 Person으로 비교하기 때문에 제대로 정렬되지 않고 오류가 발생할 수 있다.

하지만 <E extends Comparable<? super E>> 로 설계하면 E 타입과 E 타입의 부모 클래스가 Comparable을 의무적으로 구현해야 한다는 뜻이므로 업캐스팅이 발생하여도 안정성을 보장받을 수 있다.

즉, E는 E 자기 자신 및 조상 티압과 비교할 수 있는 E가 되는 것이다...

마지막으로 <?>는 아래와 같다.

public class ClassName { ... }
public class ClassName extends Object { ... } 

한마디로 <?><? extends Object>와 같다. 즉, 어떤 타입이던 상관 없디 다 받겠다는 얘기이다.

String이던 Integer든 다른 어떤 타입이든 신경 안쓰겠다는 의미가 된다...

보통 데이터 형식이 아닌 '기능' 자체를 설계하고 싶을 때 사용한다.

MyArrayList로 알아보는 공변성/반공변성

실제 ArrayList의 기능 중 일부 메서드만 추려서 별도로 재구성한 MyArrayList를 만들어보며 제네릭 클래스를 이해해보자.

먼저 아래 코드를 생각해보자.

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); // 배열 요소들 출력
    }
}

생성자를 보면 컬렉션을 받아 컬렉션을 순회하며 컬렉션 내에 들어있는 모든 요소를 내부 배열(element 배열)에 추가하여 제네릭 객체를 생산하는 역할을 한다.

clone 메서드는 빈 컬렉션을 받아서 내부 배열을 순회하여 배열 내에 들어있는 모든 요소를 컬렉션에 넣어주는, 즉 제네릭 객체를 소비하는 역할을 한다.

이 코드는 유연하지 않다. 왜냐하면 아래와 같이 제네릭 T 타입을 Number로 하고, MyArrayList를 Integer로 생성하면 오류가 발생한다.

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

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

하지만 결과는 컴파일 에러이다. 왜냐하면 MyArrayList 입장에서는 Number 타입을 필요로 하는데, Integer를 제공받아기 때문이고, 제네릭 타입 매개변수는 무공변이기 떄문이다.

clone 메서드 사용 역시 불가능하다. 아래 코드를 생각해보자.

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

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

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

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

제네릭은 반공변 역시 성립하지 않기 때문에, Number 타입에 Object 타입을 대입하는 것 역시 불가능하다. 따라서 이 코드 역시 컴파일 에러가 발생한다.

따라서 상한 경계, 하한 경계를 통해 이 문제를 해결해보자.

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);
        }
    }

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

생성자에는 MyArrayList(Collection<? extends T> in)을 통해 공변성을 적용하였다. 따라서 아래 코드는 이제 컴파일 에러 없이 정상적으로 동작한다.

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

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

즉, Integer가 Number의 하위 타입이므로 업캐스팅되어 적용된 것이다.

반대로 clone 메서드에는 (Collection<? super T> out)을 통해 반공변성을 적용하였다. 따라서 아래 코드는 이제 컴파일 에러 없이 정상적으로 동작한다.

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); // [1, 2, 3, 4, 5]
}

즉, Object가 Integer의 상위 타입이므로, 다운캐스팅되어 적용된 것이다.

여기서 한가지 궁금한 점이 생길 수 있다.

그냥 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);
    }
}

이렇게 구성하면 어떤 타입도 올 수 있다는 점이 치트키이지만, 동시에 어떠한 타입도 올 수 있으므로 결국 매개변수를 꺼내거나 저장하는 로직을 실행하려고 하면 논리적 에러가 발생한다.

profile
기록하는 감자

0개의 댓글