Java 제네릭 - 변성(Variant), 타입소거(Type Erasure)

실무를 하다보면 제네릭을 생각보다 자주 사용하게 된다. 본 글에서는 변성, 타입소거와 같은 제네릭과 관련된 몇 가지 내용을 정리해보고자 한다.

이번 글에서는 다음과 같은 클래스 구조를 예시로 활용하였다.

Alt Image

public interface Coffee {

    String name();
}
public class Espresso implements Coffee {

    public String name() {
        return "에스프레소";
    }
}
public class Americano implements Coffee {

    public String name() {
        return "아메리카노";
    }
}



불공변 (Invariant)

Espresso, Americano는 Coffee의 구현체이다. 그리고 Coffee를 저장할 Store라는 클래스를 정의해보자.

public class Store<T extends Coffee> {

	private final List<T> inventory = new LinkedList();

	public int stockAll(List<T> stocks) {
			inventory.addAll(stocks);
			return inventory.size();
    }
}

Store는 <T extends Coffee> 타입 파라미터에 의해 다음과 같은 구조가 가능하다.

Alt Image

이제 Americano, Esprosso를 저장 할 수 있는 Store<Coffee> 를 정의하고, 에스프레소를 저장해보자.

Store<Coffee> coffeeStore = new Store<>();
List<Espresso> espressoInventory = List.of(new Espresso(), new Espresso());
coffeeStore.stockAll(espressoInventory);

Store<Coffee>는 클래스 계층 구조상 내부 inventory 변수에 에스프레소, 아메리카노를 모두 저장할 수 있다.

그런데 커피 스토어에 에스프레소를 저장하기 위해 stockAll 메서드를 호출하면 컴파일 에러가 발생한다.

coffeeStore.stockAll(espressoInventory);  // <- 컴파일 에러가 발생한다.

stockAll 의 파라미터는 List<T> 타입으로 정의되어 있고, 타입 T는 T extends Coffee 로 선언되어 있다.

public int stockAll(List<T> stocks) {
	inventory.addAll(stocks);
	return inventory.size();
}

인자로 넘겨준 것은 List<Espresso> 타입이었다. Espresso는 Coffee의 서브타입이고, 코드는 문제 없이 동작해야 할 것으로 보이지만 컴파일 에러가 발생한 것이다.

Alt Image

자바는 기본적으로 클래스간의 타입 관계가 제네릭 타입까지 전파되지 않는다. 그리고 이러한 특성을
불공변성(Invariant)이라 한다.

그럼 제네릭은 왜 불공변인걸까?

아래 또 다른 예시를 보자.

Alt Image

Coffee를 조금 더 확장한 음료(Beverage) 타입을 추가하고, 에이드(Ade) 라는 새로운 타입을 정의하였다.

public interface Beverage {
	// ...
}
public interface Coffee extends Beverage {

	// ...
}
public interface Ade extends Beverage {

	// ...
}
public class OrangeAde implements Ade {

    // ...
}

그리고 이제 제네릭을 활용하는 컬렉션 타입이 아닌, 배열을 사용해보자. 배열은 컬렉션 타입과 달리 공변(convariant)이 적용된다.

아래 코드는 배열을 사용하여 공변을 적용한 예시이다.

// 1. 커피 배열을 선언하고, 배열의 요소로 에스프레소, 아메리카노를 저장한다.
Coffee[] coffees = new Coffee[]{new Espresso(), new Americano()};

// 2. 커피의 상위 타입인 Beverage 배열 타입을 선언하고, 커피 배열을 할당한다.
Beverage[] beverages = coffees;

// 3. 오렌지 에이드를 하나 만들고, Beverage 배열의 첫번째 요소에 저장한다.
Ade ade = new OrangeAde();
beverages[0] = ade;

이 코드는 3. 라인에서 문제가 발생한다. 커피 배열 첫 인덱스에 오렌지 에이드를 저장하도록 시도하는 것이다.

Alt Image

Coffee와 Ade는 직접적인 관계가 없고, 타입 호환이 되지 않는다.

그런데 더 큰 문제는, 이 문제가 컴파일 타임에 드러나지 않는다는 것이다.
런타임 시점에 되고 나서야 비로소 실행 중 문제가 생기게 된다.

실제 코드를 실행해보면 다음과 같이 java.lang.ArrayStoreException 예외가 발생한다.

Alt Image

제네릭은 이러한 문제를 컴파일 타임에 사전에 방지하고자 불공변으로 동작하는 것이다.

그런데 Coffee는 Espresso, Americano와 명백한 타입 관계에 있고, Store의 타입 파라미터 역시 Coffee로 타입 한정을 하고 있다.

public class Store<T extends Coffee> {

	private final List<T> inventory = new LinkedList();

	public int stockAll(List<T> stocks) {
			inventory.addAll(stocks);
			return inventory.size();
    }
}
Store<Coffee> coffeeStore = new Store<>();
List<Espresso> espressoInventory = List.of(new Espresso(), new Espresso());

coffeeStore.stockAll(espressoInventory);  // <- 컴파일 에러가 발생한다.

즉, 타입 관계를 따져봤을 때 stockAll 메서드에 List<Espresso> 를 넘겨주고 동작한다고 해도 논리적으로 아무런 문제가 없다.

그럼 제네릭을 공변으로 만들려면 어떻게 해야할까?

공변 (Variant)

자바의 배열은 클래스간 상속 구조가 타입까지 확장되는 것을 확인하였다. 이것을 공변(Variant) 이라한다.

stockAll 메서드가 동작하게 만들려면, 불공변을 공변으로 만들어주면 된다.
즉, 클래스간의 관계가 제네릭 타입까지 확장되면 되는 것이다.

Alt Image

타입간의 관계를 만들어주기 위해서는 상한 와일드 카드(Upper Bounded Wildcards)를 사용하면 된다.

stockAll 메서드를 다음과 같이 변경해보자.

public int stockAll(List<? extends T> stocks) {
	inventory.addAll(stocks);
	return inventory.size();
}

컴파일 시 문제가 발생했던 코드에 더 이상 문제가 발생하지 않는다.

Store<Coffee> coffeeStore = new Store<>();
List<Espresso> espressoInventory = List.of(new Espresso(), new Espresso());

coffeeStore.stockAll(espressoInventory);


상한 와일드 카드(Upper Bounded Wildcard) 제약

앞서 와일드 카드를 사용해서 타입간의 관계를 만들었고, 공변하도록 변경하였다.
그런데 상한 와일드 카드를 사용하면 값을 꺼낼 수만 있다. 는 한가지 제약사항이 생긴다.

즉, stockAll 메서드의 파라미터인 stocks에는 아무것도 저장할 수가 없게 되는 것이다.

그럼 만약 stocks에 값을 저장할 수 있게 된다면 어떤 문제가 발생할까?

Store에 저장되어 있는 커피를 모두 판매하는 sellAll 메서드를 정의해보자.

public void sellAll(List<? extends T> basket) {
	basket.addAll(inventory);
	this.inventory.clear();
}

이 메서드는 인자로 넘어온 바구니(basket) 컬렉션에 커피를 모두 저장하는 기능을 한다.

이제 이 메서드가 동작하는 상황을 가정하고, 어떤 문제가 발생하는지 살펴보자.

// 1.
Store<Coffee> coffeeStore = new Store<>();

// 2.
List<Espresso> espressoBasket = List.of();

// 3.
coffeeStore.sellAll(espressoBasket);

코드는 다음과 같이 동작한다.

  1. 에스프레소, 커피를 모두 판매하는 스토어를 생성한다.
  2. 에스프레소만을 담아 둘 리스트를 선언한다.
  3. 2.에서 생성한 리스트에 스토어에 저장되어 있는 커피를 저장한다.

여기서 문제는 3. 에서 발생한다.

스토어는 커피 타입 모두를 저장할 수 있는 Store<Coffee> 로 선언되어 있고, 바구니에는 에스프레소만
담을 수 있다.

만약 스토어에 에스프레소 뿐만 아니라 아메리카노가 저장되어 있다면, 에스프레소 바구니에 이를 저장하는 과정에서 문제가 생긴다.

Alt Image

따라서 공변이 되면 생산(조회)만 할 수 있다.는 제약이 생기는 것이다.

반공변 (Contra Variant)

앞서 살펴본 것과 같이 자바의 상한 와일드 카드는 데이터를 생산(조회) 할 수 밖에 없다는 한계를 가진다.

그런데 만약 다음과 같은 상황은 어떨까?

Store<Espresso> espressoStore = new Store<>();

List<Espresso> basket = List.of(new Espresso(), new Espresso());

espressoStore.sellAll(basket);

에스프레소만을 판매하는 Store<Espresso> 스토어에서 에스프레소 바구니에 에스프레소를 저장하려 한다.

이는 논리적으로 아무런 문제가 없다.

Alt Image
그런데 해당 코드 역시 실행하려고 하면 동일하게 컴파일 에러가 발생한다.

자바에서는 하한 와일드 카드(Lower Bounded Wildcard) 를 사용하면 이 문제를 해결할 수 있다.

sellAll 메서드의 타입을 아래와 같이 List<? super T> 로 변경해보자.

public void sellAll(List<? super T> basket) {
	basket.addAll(inventory);
	this.inventory.clear();
}

위와 같이 특정 타입의 부모 타입을 허용하는 특성을 적용하면, 이제는 동작이 가능한 것을 확인할 수 있다. 그리고 이러한 관계를 반공변성(Contra Variant) 이라고 한다.

하한 와일드 카드는 값을 소비(저장)만 할 수 있다.는 상한 와일드 카드와 정 반대의 제약사항을 가진다.

타입소거 (Type Erasure)

앞서 배열에서 변성이 동작하는 사례를 보며, 컴파일 타임이 아닌 런타임에 에러가 발생하는 것을 확인하였다.

제네릭은 컴파일 타임에 엄격한 타입 검사를 수행하여 이러한 런타임 에러를 사전에 차단한다.

그런데 제네릭에 명시했던 타입에 대한 정보는 컴파일 타임까지만 유지되고 컴파일 이후 런타임에는 타입에 관한 정보가 모두 사라지는데, 이를 타입소거(Type Erasure)라 한다.

Alt Image

타입소거로 인해 바이트 코드에는 타입 파라미터에 대한 정보가 남지 않고 모두 Object로 취급된다.

아래 코드와 같이 동일한 클래스, 컬렉션을 대상으로 메서드 오버로딩이 불가능한 것도 같은 이유다.

public class Store<T extends Coffee> {

    // ...

    public void stockAll(List<Espresso> inventory) {
		//...
    }

    public void stockAll(List<Americano> inventory) {
        // ...
    }
}

List<Espresso>, List<Americano> 모두 컴파일 이후에는 동일한 List 가 되기 때문에, 런타임에는 구분할 수 없는 것이다.

@Test
void typeEraserTest() {
	List<Espresso> espressoList = new ArrayList<>();
	List<Americano> americanoList = new ArrayList<>();

	/**
	 * [Result]
	 *  - List<String> class : class java.util.ArrayList
	 *  - List<Integer> class : class java.util.ArrayList
	 */
	System.out.println("List<Espresso> class : " + stringList.getClass());
	System.out.println("List<Americano> class : " + integerList.getClass());

	then(stringList.getClass()).isEqualTo(integerList.getClass());
}


References