Skip to main content

Java 설계자는 왜 List<Dog>을 List<Animal>로 받지 못하도록 했을까?

· 7 min read

이 글을 읽은 후엔 제네릭이 불변성을 가져야만 하는 이유, 자바가 제네릭을 런타임에 소거하는 이유, 그리고 와일드카드와 제네릭 메서드가 왜 필요한지 알게 될 것입니다.

Dog 객체는 Animal타입 변수에 담을 수 있고, 그렇게 하길 권장한다. 하지만 왜 List<Dog>List<Animal>로 받지 못하도록 설계했을까? 객체지향을 공부할 때는 다형성을 사용하라고 해놓고선 왜 언어를 개발할 때 그런 조취를 취하지 않은건지 궁금했다. 이번 포스트를 통해 이유를 파헤쳐보자.

제네릭(Generics)

제네릭은 클래스나 메서드 내부에서 사용할 타입을 외부에서 지정할 수 있도록 타입을 파라미터화한 방법을 말한다. 이는 코드의 재사용성을 높이고, 컴파일 타임에 강력한 타입 체크를 가능하게 한다.

JDK 1.5 이전에는 Object 타입을 사용하여 모든 객체를 다루었기 때문에, 꺼낼 때마다 명시적인 형변환이 필요했고 런타임에 ClassCastException이 발생할 위험이 존재했다. 제네릭은 이 문제를 컴파일 시점으로 끌어와 타입 안정성을 확보하고, 개발자가 의도한 타입만 컬렉션에 들어오도록 컴파일러가 감시하게 만든다.

공변성과 불변성

타입 시스템에는 변성(Variance) 라는 개념이 존재한다.

  • 공변성(Covariance): S가 T의 하위 타입일 때(S <= T), f(S) <= f(T)도 성립함을 의미
  • 불변성(Invariance): S <= T여도 f(S)와 f(T)는 아무런 관계가 없음.

자바에서 배열은 공변성을 가진다. DogAnimal의 자식이므로, Dog[]Animal[]의 하위 타입이다. 하지만 이는 치명적인 단점이 있다.

Dog[] dogs = new Dog[10];
Animal[] animals = dogs; // 공변성으로 인해 허용됨
animals[0] = new Cat(); // 컴파일 OK! (Cat은 Animal이니까)
// 런타임 에러: ArrayStoreException 발생!

배열은 런타임에 자신이 담을 수 있는 실제 타입(Dog)을 알고 있다. 따라서 Cat을 넣으려는 시도는 런타임에 감지되어 예외를 던진다.

제네릭이 공변성을 가진다면 문제는 동일하게 발생한다. 따라서 제네릭은 불변성을 가지게 된다. 즉, DogAnimal의 자식이어도, List<Dog>List<Animal>의 하위 타입이 아니며 둘은 전혀 상관없는 타입이다.

배열이 공변성을 가질 수 있었던 이유는 단순하다. 배열은 런타임에 자신의 원소 타입을 구체적으로(reified) 알고 있고, 그 덕분에 잘못된 저장을 저장 시점에 예외(ArrayStoreException)로 차단할 수 있다.

반면 Java 제네릭은 런타임에 타입 인자를 유지하지 않는다. 컴파일러는 List<Dog>List<Animal>을 모두 List로 소거(erasure) 해버리고, 필요한 캐스트는 사용 지점에 삽입한다. 결과적으로 런타임은 “이 리스트가 Dog 리스트인지”를 알 방법이 없기 때문에, 저장 시점에 타입을 검사할 수 없다.

Erasure(소거)

Java에서 말하는 소거는 컴파일 타임에만 타입 제약 조건을 검사하고, 컴파일 후 바이트코드에선 제네릭 타입 정보를 삭제하는 것을 말한다.

// [컴파일 전: 개발자가 작성한 코드]
List<String> list = new ArrayList<>();
list.add("Hello");
String value = list.get(0);

// [컴파일 후: 바이트코드]
List list = new ArrayList(); // 1. 제네릭 타입 소거 (List<String> -> List)
list.add("Hello");
String value = (String) list.get(0); // 2. 값을 꺼낼 때 캐스팅 코드 자동 삽입

즉, 우리가 작성한 List<String>이나 List<Integer>는 런타임에 모두 똑같은 List(Raw Type)이 된다.

컴파일러가 기계에게 제네릭을 설명하는 방법 2가지

제네릭을 제공하는 언어에서는 제네릭 타입을 번역하는 데 동질 번역과 이질 번역이라는 두 가지 접근 방식이 있다.

제네릭(매개변수화된 다형성)을 기계어로 번역하는 전략은 크게 두 가지이다.

  1. 이질 번역 (Heterogeneous Translation): 제네릭 타입 인자마다 별도의 전문화된 코드를 생성한다. C++ 템플릿이나 C# 제네릭이 이 방식을 사용한다. vector<int>vector<float>는 서로 다른 코드로 컴파일되며, 메모리 레이아웃도 최적화된다. 성능은 좋지만 코드 크기가 커질 수 있다.
  2. 동질 번역 (Homogeneous Translation): 제네릭 타입 인자가 무엇이든 하나의 클래스 파일을 공유한다. 모든 타입 파라미터를 가장 범용적인 타입(주로 Object)으로 간주하여 처리한다.

Java는 제네릭을 동질 번역한다.

Java는 제네릭을 동질 번역(homogeneous translation) 방식으로 변환한다. 제네릭은 컴파일 시점에 타입 검사를 수행하지만, 바이트코드를 생성할 때 List<String> 같은 제네릭 타입은 List로 소거(erasure)되며, <T extends Object> 같은 타입 변수는 자신의 상한(bound)의 소거 결과(이 경우 Object)로 소거된다.

  • List<String> —(소거)—> List
  • <T extends Object> —(소거)—> Object
  • <T extends Animal> —(소거)—> Animal

예를 들어 다음이 있다고 하자:

class Box<T> {
private T t;

public T(T t) { this.t = t; }

public Box<T> copy() { return new Box<>(t); }

public T t() { return t; }
}

javac 컴파일러는 단 하나의 클래스 파일 Box.class를 생성하며, 이 클래스 파일은 와일드카드(Box<?>)와 raw type(Box)을 포함해 Box의 모든 인스턴스화에 대한 구현으로 동작한다. 필드, 메서드, 그리고 상위 타입(supertype) 디스크립터는 소거되며 타입 변수는 자신의 상한으로 소거되고, 제네릭 타입은 자신의 헤드(head)로 소거된다(List<String>List로 소거됨).

class Box {
private Object t;

public Box(Object t) { this.t = t; }

public Box copy() { return new Box(t); }

public Object t() { return t; }
}

만약 자바가 이질 번역을 택했다면 Box<Integer>, Box<String> 같은 각 타입 인자마다 클래스 파일이 별도로 생성되었을 것이다.

왜 멀쩡하고 좋은 타입 정보를 컴파일러로 하여금 버리게 했을까? 자바 언어 아키텍트인 Brian Goetz는 다음처럼 대답했습니다.

Erasure was the pragmatic compromise

소거는 프로그래밍적 타협이다. 런타임 비용을 줄이고, 구 버전의 코드와 호환성을 제공하기 위해 제네릭을 동질 번역하였다고 말한다. 2004년에 Java에 제네릭을 추가하기 위해 소거가 합리적이고 실용적인 선택이었음을 강조한다.

?, 와일드 카드

다시 처음의 문제로 돌아가 보자. 제네릭의 불변성은 타입 안정성을 지켜주지만, 유연함을 해친다.

public void printAnimals(List<Animal> animals) { ... }
// List<Dog>는 전달 불가. 너무 빡빡하다!

이 문제를 해결하기 위해 Java는 와일드카드(?)를 제공한다. 이는 "알 수 없는 타입"을 의미하며, 제네릭의 불변성 규칙을 유연하게 풀어주는 역할이다.

와일드카드는 단순히 ?만 사용하는 것이 아니라, extendssuper 키워드를 통해 타입을 받아들이는 범위를 정교하게 제어할 수 있다. 이를 한정적 와일드카드(Bounded Wildcards)라고 한다.

첫째, 상한 경계 와일드카드(<? extends T>) 는 T 혹은 T의 하위 타입만 받겠다는 선언이다. "이 리스트에 무엇이 들어있든 꺼내면 최소한 T임은 확실하다"는 보장을 준다. 따라서 데이터를 안전하게 꺼낼 때(Read) 유용하다.

둘째, 하한 경계 와일드카드(<? super T>) 는 T 혹은 T의 상위 타입만 받겠다는 선언이다. "이 리스트가 T의 부모 타입(Animal, Object) 리스트라면, T 타입의 객체는 자식이므로 안전하게 보관할 수 있다"는 논리다. 따라서 데이터를 넣을 때(Write) 유용하다.

<? extends Foo>는 왜 쓰는걸까? <Foo>로 써도 되지 않아?

바이트코드로 변환되면 어차피 타입 정보가 소거되어 <Foo>(정확히는 Foo 타입)로 변환되는 것은 맞다. 하지만 List<Animal>은 "오직 Animal 인스턴스만 담긴 리스트"이다. 따라서 List<Dog>을 대입하려 하면 컴파일러가 타입 불일치로 막아선다.

반면 List<? extends Animal>은 "Animal의 하위 타입 리스트라면 무엇이든 허용한다"는 유연한 공변(Covariant) 선언이다. 이 선언을 통해 우리는 List<Dog>이나 List<Cat>을 메서드 인자로 넘길 수 있게 된다.

그러면 <T extends Foo>는 왜 쓰는걸까? <Foo>로 써도 되지 않아?

단순히 Foo나 와일드카드를 사용하는 것과 제네릭 타입 <T extends Foo>의 결정적인 차이는 '타입의 보존(Capture)'에 있다. 와일드카드는 "무엇인지 모르지만 일단 Animal임"이라고 퉁치고 넘어가지만, 제네릭 메서드는 "들어온 그 구체적인 타입 T를 기억하겠다"는 의미다.

예를 들어 아래 메서드에 List<Dog>을 넣으면, 컴파일러는 TDog임을 인지(Capture)한다. 덕분에 메서드는 반환 타입으로 Animal이 아닌 Dog를 돌려줄 수 있다. 호출하는 쪽에서 번거로운 형변환(Casting)을 하지 않고도 원래의 구체적인 타입을 그대로 사용하려면 제네릭을 사용해야한다.

public <T extends Animal> T getFirst(List<T> list) {
return list.get(0); // 반환 타입이 T이므로, 호출자는 구체적인 타입을 바로 받음
}

제네릭을 알아보며

지금까지 제네릭을 배우면서 왜 List<Animal>List<Dog>을 받지 못하는지, 제네릭이 소거된다면 대체 왜 사용해야하는지 알아보았다. 긴 글을 요약하자면 다음과 같다.

  1. 제네릭이 공변성을 가지면 타입 안전성이 깨진다.
  2. 자바는 제네릭을 동질 번역하기 때문에 런타임에 타입 정보를 알 수 없다.
  3. 제네릭의 불변성 문제를 해결하기 위해 와일드카드와 제네릭 메서드를 제공한다.

출처