본문 바로가기

다형성과 제네릭의 차이

부모 타입으로 받는 클래스가 왜 다운캐스팅에서 실패하는지 확인하고, Class 객체로 실행 중에 검사하는 방법과 비교해 제네릭이 넣은 타입과 꺼내는 타입의 관계를 컴파일 단계에서 지켜 주는 방식을 익힙니다.

앞 글에서는 개 병원과 고양이 병원의 중복을 Hospital<T extends Animal> 하나로 모았습니다. 그런데 제네릭을 쓰지 않아도 중복을 없앨 방법이 있어 보입니다. Dog와 Cat이 모두 Animal을 구현하니, 부모 타입 변수로 자식 객체 다루기에서 배운 다형성을 이용해 Animal을 받는 AnimalHospital 하나를 만들면 됩니다. 실제로 이름이나 크기처럼 Animal에 선언된 메서드는 이 병원에서 문제없이 호출할 수 있습니다.

하지만 이 방법으로는 표현할 수 없는 것이 하나 있습니다. '이 병원에 넣은 동물과 꺼낸 동물은 같은 종이다'라는 관계입니다. 이 글에서는 부모 타입으로 받는 병원이 이 관계를 잃어버릴 때 어떤 일이 생기는지 확인합니다. 이어서 실행 중에 타입을 검사해 관계를 지키는 방법을 시도해 보고, 마지막으로 제네릭이 같은 관계를 컴파일할 때 지켜 주는 방식과 비교합니다.

부모 타입 병원에는 무엇이든 들어간다

AnimalHospital은 필드도, set의 매개변수도, bigger의 반환 타입도 모두 Animal입니다. 다음 프로그램은 이 병원을 개 병원으로 쓰려고 dogHospital이라는 이름을 붙였지만, 실수로 고양이를 입원시킵니다.

lab/AnimalHospitalWrongSubtypeFailure.java
public final class AnimalHospitalWrongSubtypeFailure {
    public static void main(String[] args) {
        AnimalHospital dogHospital = new AnimalHospital();
        dogHospital.set(new Dog("dog", 40));
        dogHospital.set(new Cat("cat", 80));

        Dog biggerDog = (Dog) dogHospital.bigger(new Dog("other-dog", 60));
        System.out.println(biggerDog.name());
    }

    private static final class AnimalHospital {
        private Animal animal;
        void set(Animal animal) { this.animal = animal; }
        Animal bigger(Animal other) {
            return animal.size() >= other.size() ? animal : other;
        }
    }

    private interface Animal { String name(); int size(); }
    private record Dog(String name, int size) implements Animal { }
    private record Cat(String name, int size) implements Animal { }
}

이 프로그램은 문제없이 컴파일되지만, 실행하면 이름을 출력하기 전에 예외가 납니다. 스택 트레이스가 가리키는 7번째 줄은 bigger의 결과를 (Dog)로 캐스트하는 줄입니다. 고양이를 넣은 줄은 그보다 두 줄 앞인데, 오류는 넣을 때가 아니라 꺼낼 때 드러납니다.

Exception in thread "main" java.lang.ClassCastException: class AnimalHospitalWrongSubtypeFailure$Cat cannot be cast to class AnimalHospitalWrongSubtypeFailure$Dog (AnimalHospitalWrongSubtypeFailure$Cat and AnimalHospitalWrongSubtypeFailure$Dog are in unnamed module of loader 'app')
	at AnimalHospitalWrongSubtypeFailure.main(AnimalHospitalWrongSubtypeFailure.java:7)

실행 과정을 따라가 보겠습니다. 첫 set은 크기 40인 개를 입원시킵니다. 둘째 set의 매개변수 타입도 Animal이므로 고양이 역시 문제없이 들어가고, 앞의 개를 덮어씁니다. 이제 bigger는 입원한 고양이(80)와 새로 온 개(60)를 비교하고, 80 >= 60이 참이므로 고양이를 돌려줍니다. 더 큰 동물을 고른다는 규칙대로라면 올바른 결과입니다.

그런데 bigger의 반환 타입이 Animal이라서 Dog 변수에 담으려면 (Dog)로 다운캐스팅해야 하고, 돌려받은 객체는 고양이입니다. 그래서 7번째 줄에서 ClassCastException이 나고, 이름을 출력하는 8번째 줄은 실행되지 않습니다. 오류 메시지의 $Cat과 $Dog는 두 레코드가 AnimalHospitalWrongSubtypeFailure 안에 선언된 중첩 타입이라는 표시입니다.

반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다

Dog 40을 Cat 80으로 덮어쓴 뒤 Dog 60과 비교해 Cat을 선택하고 마지막 Dog cast에서 실패합니다. 같은 컨테이너의 쓰기·선택·실패를 이어 원인을 보여 줄 수 있습니다.

반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다

Dog40을 Cat80이 덮어쓴다. bigger는 Cat80과 Dog60에서 더 큰 Cat을 정상 선택하지만 호출자의 Dog 캐스팅은 실패하고 출력에는 도달하지 않는다.

실행 순서 · 저장에서 캐스팅까지

반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다

Animal 계약은 Dog 뒤의 Cat 저장을 허용합니다. 더 큰 Cat을 정상 선택한 뒤 호출자의 Dog 캐스팅에서 실패합니다.

덮어쓴 환자와 뒤 캐스팅의 실패 연결 같은 원문에서 Dog 40 저장 뒤 Cat 80이 덮어씁니다. 새 Dog 60과의 80 >= 60 비교는 Cat을 반환합니다. 뒤 Dog 캐스팅은 Cat 객체를 변환하지 못해 ClassCastException이며 이름 출력은 실행하지 않습니다. 실선은 실제 저장·선택·실패의 순서입니다. 원문 · 값과 실패의 순서 첫 set 뒤 상태 Dog / 크기 40 둘째 set 덮어쓰기 Cat / 크기 80 새 Dog 60과 비교 80 >= 60은 true Cat 반환 Dog 캐스팅 실패 ClassCastException 이름 출력에는 못 감 실선: 같은 프로그램의 순서 큰 동물 선택과 Dog 계약은 별개
덮어쓴 환자와 뒤 캐스팅의 실패 연결 같은 원문에서 Dog 40 저장 뒤 Cat 80이 덮어씁니다. 새 Dog 60과의 80 >= 60 비교는 Cat을 반환합니다. 뒤 Dog 캐스팅은 Cat 객체를 변환하지 못해 ClassCastException이며 이름 출력은 실행하지 않습니다. 실선은 실제 저장·선택·실패의 순서입니다. 원문 · 값과 실패의 순서 첫 set 뒤 상태 Dog / 크기 40 둘째 set 덮어쓰기 Cat / 크기 80 새 Dog 60과 비교 80 >= 60은 true Cat 반환 Dog 캐스팅 실패 ClassCastException 이름 출력에는 못 감 실선: 같은 프로그램의 순서 큰 동물 선택과 Dog 계약은 별개
  1. 처음 저장

    실제로 저장하거나 선택한 값
    Dog("dog", 40)를 저장합니다.
    타입과 다음 동작
    set(Animal)이므로 Dog 입력이 허용됩니다.
  2. 다시 저장

    실제로 저장하거나 선택한 값
    Cat("cat", 80)이 앞의 Dog를 덮어씁니다.
    타입과 다음 동작
    set(Animal)은 Cat 입력도 허용합니다.
  3. 크기 비교

    실제로 저장하거나 선택한 값
    저장한 Cat 80과 대상 Dog 60을 비교합니다.
    80 >= 60이 참이어서 Cat을 반환합니다.
    타입과 다음 동작
    bigger의 정적 반환 타입은 Animal.
    실제 반환 객체는 저장했던 Cat입니다.
  4. 호출자의 캐스팅

    실제로 저장하거나 선택한 값
    (Dog)는 이미 반환된 Cat을 검사합니다.
    타입과 다음 동작
    ClassCastException으로 실패합니다.
    뒤의 이름 출력에 도달하지 않습니다.

캐스팅은 객체의 종을 바꾸지 않습니다. 여기서 비교 자체는 더 큰 동물을 골랐지만, 호출자가 요구한 Dog라는 구체 타입 관계는 Animal API가 보장하지 않았습니다.

이 예의 조건

  • 이름 출력은 cast 실패 뒤 미실행이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
5Dog
6크기 40
8Cat
9크기 80
원 도표의 설명과 주석
구분내용
전체 읽기 정보반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다 반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다 Dog 40을 Cat 80이 덮어쓴다. bigger는 Cat 80과 Dog 60에서 더 큰 Cat을 정상 선택하지만 호출자의 Dog 캐스팅은 실패하고 출력에는 도달하지 않는다. 실행 순서 · 저장에서 캐스팅까지 반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다 Animal 계약은 Dog 뒤의 Cat 저장을 허용합니다. 더 큰 Cat을 정상 선택한 뒤 호출자의 Dog 캐스팅에서 실패합니다. 처음 저장 실제로 저장하거나 선택한 값 Dog("dog", 40) 를 저장합니다. 타입과 다음 동작 set(Animal) 이므로 Dog 입력이 허용됩니다. 다시 저장 실제로 저장하거나 선택한 값 Cat("cat", 80) 이 앞의 Dog를 덮어씁니다. 타입과 다음 동작 set(Animal) 은 Cat 입력도 허용합니다. 크기 비교 실제로 저장하거나 선택한 값 저장한 Cat 80과 대상 Dog 60을 비교합니다. 80 >= 60 이 참이어서 Cat을 반환합니다. 타입과 다음 동작 bigger 의 정적 반환 타입은 Animal . 실제 반환 객체는 저장했던 Cat입니다. 호출자의 캐스팅 실제로 저장하거나 선택한 값 (Dog) 는 이미 반환된 Cat을 검사합니다. 타입과 다음 동작 ClassCastException 으로 실패합니다. 뒤의 이름 출력에 도달하지 않습니다. 캐스팅은 객체의 종을 바꾸지 않습니다. 여기서 비교 자체는 더 큰 동물을 골랐지만, 호출자가 요구한 Dog라는 구체 타입 관계는 Animal API가 보장하지 않았습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
최초 환자

Dog 40을 저장합니다.

  • 저장: Dog 40
  • 넓은 컨테이너 계약은 Dog만 보관하도록 제한하지 않는다.
덮어쓰기와 선택

Cat 80 저장 후 Dog 60과 비교합니다.

  • 저장: Cat 80
  • 비교: 80≥60 true
  • 반환: Cat
  • 업무의 큰 동물 선택이 Dog 타입을 보장하지 않는다.
소비 실패

반환 Cat을 Dog로 캐스팅합니다.

  • 결과: ClassCastException
  • 이름 출력: 미실행
  • 저장 경계의 타입 오염이 나중 소비 지점에서 드러난다.

반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다

Dog 40을 Cat 80으로 덮어쓴 뒤 Dog 60과 비교해 Cat을 선택하고 마지막 Dog cast에서 실패합니다. 같은 컨테이너의 쓰기·선택·실패를 이어 원인을 보여 줄 수 있습니다.

반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다

Dog40을 Cat80이 덮어쓴다. bigger는 Cat80과 Dog60에서 더 큰 Cat을 정상 선택하지만 호출자의 Dog 캐스팅은 실패하고 출력에는 도달하지 않는다.

실행 순서 · 저장에서 캐스팅까지

반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다

Animal 계약은 Dog 뒤의 Cat 저장을 허용합니다. 더 큰 Cat을 정상 선택한 뒤 호출자의 Dog 캐스팅에서 실패합니다.

덮어쓴 환자와 뒤 캐스팅의 실패 연결 같은 원문에서 Dog 40 저장 뒤 Cat 80이 덮어씁니다. 새 Dog 60과의 80 >= 60 비교는 Cat을 반환합니다. 뒤 Dog 캐스팅은 Cat 객체를 변환하지 못해 ClassCastException이며 이름 출력은 실행하지 않습니다. 실선은 실제 저장·선택·실패의 순서입니다. 원문 · 값과 실패의 순서 첫 set 뒤 상태 Dog / 크기 40 둘째 set 덮어쓰기 Cat / 크기 80 새 Dog 60과 비교 80 >= 60은 true Cat 반환 Dog 캐스팅 실패 ClassCastException 이름 출력에는 못 감 실선: 같은 프로그램의 순서 큰 동물 선택과 Dog 계약은 별개
덮어쓴 환자와 뒤 캐스팅의 실패 연결 같은 원문에서 Dog 40 저장 뒤 Cat 80이 덮어씁니다. 새 Dog 60과의 80 >= 60 비교는 Cat을 반환합니다. 뒤 Dog 캐스팅은 Cat 객체를 변환하지 못해 ClassCastException이며 이름 출력은 실행하지 않습니다. 실선은 실제 저장·선택·실패의 순서입니다. 원문 · 값과 실패의 순서 첫 set 뒤 상태 Dog / 크기 40 둘째 set 덮어쓰기 Cat / 크기 80 새 Dog 60과 비교 80 >= 60은 true Cat 반환 Dog 캐스팅 실패 ClassCastException 이름 출력에는 못 감 실선: 같은 프로그램의 순서 큰 동물 선택과 Dog 계약은 별개
  1. 처음 저장

    실제로 저장하거나 선택한 값
    Dog("dog", 40)를 저장합니다.
    타입과 다음 동작
    set(Animal)이므로 Dog 입력이 허용됩니다.
  2. 다시 저장

    실제로 저장하거나 선택한 값
    Cat("cat", 80)이 앞의 Dog를 덮어씁니다.
    타입과 다음 동작
    set(Animal)은 Cat 입력도 허용합니다.
  3. 크기 비교

    실제로 저장하거나 선택한 값
    저장한 Cat 80과 대상 Dog 60을 비교합니다.
    80 >= 60이 참이어서 Cat을 반환합니다.
    타입과 다음 동작
    bigger의 정적 반환 타입은 Animal.
    실제 반환 객체는 저장했던 Cat입니다.
  4. 호출자의 캐스팅

    실제로 저장하거나 선택한 값
    (Dog)는 이미 반환된 Cat을 검사합니다.
    타입과 다음 동작
    ClassCastException으로 실패합니다.
    뒤의 이름 출력에 도달하지 않습니다.

캐스팅은 객체의 종을 바꾸지 않습니다. 여기서 비교 자체는 더 큰 동물을 골랐지만, 호출자가 요구한 Dog라는 구체 타입 관계는 Animal API가 보장하지 않았습니다.

이 예의 조건

  • 이름 출력은 cast 실패 뒤 미실행이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
5Dog
6크기 40
8Cat
9크기 80
원 도표의 설명과 주석
구분내용
전체 읽기 정보반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다 반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다 Dog 40을 Cat 80이 덮어쓴다. bigger는 Cat 80과 Dog 60에서 더 큰 Cat을 정상 선택하지만 호출자의 Dog 캐스팅은 실패하고 출력에는 도달하지 않는다. 실행 순서 · 저장에서 캐스팅까지 반환된 Cat은 Dog 캐스팅으로 바뀌지 않는다 Animal 계약은 Dog 뒤의 Cat 저장을 허용합니다. 더 큰 Cat을 정상 선택한 뒤 호출자의 Dog 캐스팅에서 실패합니다. 처음 저장 실제로 저장하거나 선택한 값 Dog("dog", 40) 를 저장합니다. 타입과 다음 동작 set(Animal) 이므로 Dog 입력이 허용됩니다. 다시 저장 실제로 저장하거나 선택한 값 Cat("cat", 80) 이 앞의 Dog를 덮어씁니다. 타입과 다음 동작 set(Animal) 은 Cat 입력도 허용합니다. 크기 비교 실제로 저장하거나 선택한 값 저장한 Cat 80과 대상 Dog 60을 비교합니다. 80 >= 60 이 참이어서 Cat을 반환합니다. 타입과 다음 동작 bigger 의 정적 반환 타입은 Animal . 실제 반환 객체는 저장했던 Cat입니다. 호출자의 캐스팅 실제로 저장하거나 선택한 값 (Dog) 는 이미 반환된 Cat을 검사합니다. 타입과 다음 동작 ClassCastException 으로 실패합니다. 뒤의 이름 출력에 도달하지 않습니다. 캐스팅은 객체의 종을 바꾸지 않습니다. 여기서 비교 자체는 더 큰 동물을 골랐지만, 호출자가 요구한 Dog라는 구체 타입 관계는 Animal API가 보장하지 않았습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
최초 환자

Dog 40을 저장합니다.

  • 저장: Dog 40
  • 넓은 컨테이너 계약은 Dog만 보관하도록 제한하지 않는다.
덮어쓰기와 선택

Cat 80 저장 후 Dog 60과 비교합니다.

  • 저장: Cat 80
  • 비교: 80≥60 true
  • 반환: Cat
  • 업무의 큰 동물 선택이 Dog 타입을 보장하지 않는다.
소비 실패

반환 Cat을 Dog로 캐스팅합니다.

  • 결과: ClassCastException
  • 이름 출력: 미실행
  • 저장 경계의 타입 오염이 나중 소비 지점에서 드러난다.

이 장의 첫 글에서 본 Object 상자와 같은 모양의 사고입니다. 실수는 고양이를 넣은 5번째 줄에서 했는데, 예외는 값을 꺼내 캐스팅한 7번째 줄에서 납니다. Object보다 범위가 좁은 Animal을 썼으니 숫자나 문자열은 들어오지 않지만, Animal의 하위 타입끼리는 여전히 아무것이나 들어옵니다. 컴파일러가 아는 것은 '이 병원에는 동물이 들어 있다'까지이고, '개 병원에는 개만 들어 있다'는 사실은 dogHospital이라는 변수 이름에만 담겨 있습니다.

실수가 없어도 캐스팅은 남는다

다른 종을 넣는 실수만 조심하면 될까요? 다음 프로그램은 개만 넣어서 실수가 없는 경우입니다. 병원을 레코드로 짧게 쓰고, 입원한 개 d1(30)과 새로 온 개 d2(70)를 비교합니다.

src/PolymorphicHospitalObservation.java
public final class PolymorphicHospitalObservation {
    public static void main(String[] args) {
        AnimalHospital hospital = new AnimalHospital(new Dog("d1", 30));
        Animal result = hospital.bigger(new Dog("d2", 70));

        System.out.println("static-type=Animal");
        System.out.println("runtime-type=" + result.getClass().getSimpleName());
        System.out.println("name=" + result.name());
    }

    private record AnimalHospital(Animal patient) {
        Animal bigger(Animal other) {
            return patient.size() >= other.size() ? patient : other;
        }
    }

    private interface Animal { String name(); int size(); }
    private record Dog(String name, int size) implements Animal { }
}
static-type=Animal
runtime-type=Dog
name=d2

30 >= 70이 거짓이므로 bigger는 d2를 돌려줍니다. 첫 줄의 static-type=Animal은 계산한 값이 아니라, result 변수의 선언 타입이 Animal이라는 것을 보여 주려고 그대로 적어 둔 문자열입니다. 둘째 줄은 getClass()로 확인한 실제 객체의 클래스이고, 셋째 줄은 Animal에 선언된 name()을 호출한 결과입니다.

실제 객체는 개이지만, 컴파일러가 아는 result의 타입은 Animal입니다. 이름과 크기처럼 Animal의 기능만 쓴다면 이것으로 충분합니다.

하지만 개에만 있는 기능이 필요하면 이야기가 달라집니다. Dog dog = hospital.bigger(...);라고 쓰면 Animal cannot be converted to Dog라는 컴파일 오류가 나므로, 결국 (Dog)로 캐스팅하거나 instanceof로 확인한 뒤 꺼내야 합니다. 이 병원에는 개만 넣었다는 사실을 호출하는 쪽이 기억하고 믿어야 하는 것입니다. 그 믿음이 틀렸을 때 무슨 일이 생기는지는 앞 절에서 보았습니다.

그러니 부모 타입으로 받는 방법이 나쁜 것은 아닙니다. 공통 기능만 쓰는 곳이라면 가장 간단한 방법입니다. 이 방법이 막히는 것은 넣은 것과 같은 구체 타입을 꺼내서 써야 할 때입니다. 클래스를 설계할 때는 쓰는 쪽에 그런 요구가 있는지부터 따져 봐야 합니다.

Class 객체로 실행 중에 검사하기

제네릭 없이 이 관계를 지키는 방법도 있습니다. 병원을 만들 때 어떤 종을 받을지를 Class 객체로 넘겨받아, 들어오는 값을 직접 검사하는 것입니다. Dog.class처럼 클래스 이름 뒤에 .class를 붙이면 그 클래스를 나타내는 Class 객체를 얻을 수 있고, 이렇게 타입 정보를 전달하려고 넘기는 Class 객체를 흔히 타입 토큰(type token)이라고 부릅니다.

다음 CheckedHospital<T>는 제네릭 클래스이기도 합니다. 일반적인 입력은 set(T)로 받고, 타입을 모르는 Animal 값이 들어오는 길로 setUntyped(Animal)을 따로 두었습니다. 바깥 데이터를 읽어 만든 객체처럼, 어떤 종인지 실행해 봐야 아는 값이 들어오는 상황을 흉내 낸 것입니다.

src/RuntimeCheckedAnimalHospital.java
public final class RuntimeCheckedAnimalHospital {
    public static void main(String[] args) {
        CheckedHospital<Dog> dogs = new CheckedHospital<>(Dog.class);
        dogs.set(new Dog("dog", 50));
        System.out.println("stored=" + dogs.get().name());

        try {
            dogs.setUntyped(new Cat("cat", 30));
        } catch (IllegalArgumentException error) {
            System.out.println("rejected=" + error.getMessage());
        }
    }

    private static final class CheckedHospital<T extends Animal> {
        private final Class<T> type;
        private Animal value;

        CheckedHospital(Class<T> type) { this.type = type; }
        void set(T value) { this.value = value; }

        void setUntyped(Animal value) {
            if (!type.isInstance(value)) {
                throw new IllegalArgumentException("expected " + type.getSimpleName());
            }
            this.value = value;
        }

        T get() { return type.cast(value); }
    }

    private interface Animal { String name(); int size(); }
    private record Dog(String name, int size) implements Animal { }
    private record Cat(String name, int size) implements Animal { }
}
stored=dog
rejected=expected Dog

왼쪽 변수가 CheckedHospital<Dog>이고 생성자에 넘긴 Dog.class의 타입도 Class<Dog>이므로 T는 Dog입니다. 첫 줄은 set(T)로 개를 넣고 get()으로 꺼낸 결과입니다. get()은 Animal 필드에 담긴 값을 type.cast(value)로 T 타입으로 바꿔 돌려줍니다. cast는 값이 null이 아니면서 그 타입도 아니면 ClassCastException을 던지는 메서드인데, 여기서는 개를 넣었으니 그대로 통과합니다.

둘째 줄이 이 예제의 핵심입니다. setUntyped의 매개변수 타입은 Animal이므로 고양이를 넘기는 코드는 컴파일됩니다. 대신 메서드 안에서 type.isInstance(value)로 실행 중에 검사합니다.

isInstance는 instanceof 연산자와 같은 검사를 Class 객체로 하는 메서드로, 값이 null이 아니고 그 타입으로 캐스팅할 수 있을 때 true를 돌려줍니다. 고양이는 Dog가 아니므로 false가 되고, 병원은 값을 저장하기 전에 IllegalArgumentException을 던집니다. main이 이 예외를 잡아 rejected=expected Dog를 출력합니다.

앞의 AnimalHospital과 비교하면 나아진 점이 분명합니다. 잘못된 값이 들어오는 순간에 거부되므로, 엉뚱한 곳에서 ClassCastException이 나는 대신 값을 넣은 곳에서 원인이 드러납니다. 그래도 이것은 실행 중의 검사입니다. 코드가 그 줄을 실제로 실행해야 문제를 알 수 있고, 테스트가 그 경로를 지나가지 않으면 문제가 그대로 남습니다.

게다가 병원은 isInstance와 cast를 직접 호출해야 합니다. 컴파일러가 해 줄 수 있는 검사를 손으로 다시 하는 셈입니다. 같은 병원의 set(T)에 고양이를 넘기면 Cat cannot be converted to Dog라는 컴파일 오류가 나는데, 이 차이가 바로 다음 절의 주제입니다.

Class 객체를 이용한 검사가 제 역할을 하는 곳은 따로 있습니다. 파일이나 네트워크에서 읽은 데이터로 객체를 만들 때, 외부 플러그인을 불러올 때처럼 어떤 타입인지가 실행해 봐야 정해지는 곳입니다. 이런 곳에서는 컴파일러가 미리 알 수 있는 것이 없으므로, 실행 중에 확인하는 것이 유일한 방법입니다.

제네릭은 넣은 타입을 끝까지 유지한다

이제 앞 글의 제네릭 병원으로 같은 문제를 다시 보겠습니다. Hospital<T>의 필드, set의 매개변수, bigger의 매개변수와 반환 타입을 모두 같은 T로 묶으면, 병원을 만들 때 고른 타입이 모든 메서드에 똑같이 적용됩니다. 이번에는 개 레코드에 품종(breed)을 더해, 개에만 있는 정보를 꺼낼 수 있는지 확인합니다.

src/TypePreservingAnimalHospital.java
public final class TypePreservingAnimalHospital {
    public static void main(String[] args) {
        Hospital<Dog> dogs = new Hospital<>();
        dogs.set(new Dog("d1", 35, "herding"));
        Dog bigger = dogs.bigger(new Dog("d2", 65, "retriever"));

        System.out.println("name=" + bigger.name());
        System.out.println("breed=" + bigger.breed());
    }

    private static final class Hospital<T extends Animal> {
        private T animal;
        void set(T animal) { this.animal = animal; }
        T bigger(T other) { return animal.size() >= other.size() ? animal : other; }
    }

    private interface Animal { String name(); int size(); }
    private record Dog(String name, int size, String breed) implements Animal { }
}
name=d2
breed=retriever

35 >= 65가 거짓이므로 d2가 선택됩니다. dogs가 Hospital<Dog>이므로 bigger의 반환 타입은 Dog이고, 결과를 캐스팅 없이 Dog bigger에 담아 개에만 있는 breed()까지 호출합니다. 그래서 둘째 줄에 breed=retriever가 나옵니다.

같은 병원에 고양이를 넣어 보면 차이가 분명해집니다. dogs.set(new Cat("cat", 80))이라고 쓰든 dogs.bigger(new Cat("c2", 10))이라고 쓰든, 두 줄 모두 Cat cannot be converted to Dog라는 컴파일 오류가 납니다. 첫 절의 AnimalHospital에서는 실행 중에 엉뚱한 줄에서 드러나던 실수가, 이제는 컴파일할 때 실수한 바로 그 줄에서 드러납니다. Class 객체를 넘길 필요도, isInstance나 cast를 호출할 필요도 없습니다.

여기서 다형성과 제네릭이 맡는 일을 나눠 볼 수 있습니다. T extends Animal의 Animal은 병원이 모든 동물에게 기대할 수 있는 공통 기능, 즉 size()를 정합니다. 이것은 다형성의 역할입니다. 제네릭은 그 위에 '이 병원에서 넣는 것과 꺼내는 것은 같은 T'라는 관계를 더합니다. 그래서 두 기능은 서로 경쟁하는 것이 아니라 함께 쓰입니다.

아래 도표는 이 글의 세 방법, 즉 부모 타입 병원, Class 객체로 검사하는 병원, Hospital<Dog>가 각각 어디서 무엇을 검사하는지 비교합니다.

부모 타입·Class 검사·Dog 계약은 어디서 작동하는가

부모 타입 비교, Class 토큰을 쓰는 Dog 병원, Dog로 지정한 제네릭 병원의 입력 검사와 실제 결과를 비교한다. 토큰 병원의 넓은 입력은 Animal이며 Object 수신이나 생성 factory는 없다.

타입 경계 · 컴파일과 실행

부모 타입·Class 검사·Dog 계약은 어디서 작동하는가

부모 Animal API, Class 토큰 병원, Hospital<Dog>의 계약을 비교합니다. 세 프로그램의 관찰이며 토큰 검사와 컴파일 계약은 구분합니다.

부모 관찰·Class 검사·T 반환의 역할 서로 다른 세 원문의 계약입니다. Animal 반환 선언은 실제 Dog 선택과 별개이며 부모 API로 이름을 읽습니다. Dog 토큰은 setUntyped의 Cat을 런타임에 거부합니다. Hospital<Dog>의 T 반환은 Dog 고유 breed를 읽게 합니다. 실선은 각 프로그램의 실제 입력·검사·반환·출력이며 영역 사이의 순차 개선 실행이 아닙니다. 원문 A · 부모 API 환자 비교 d1 / 30, d2 / 70 반환 선언 Animal 실제 선택 객체는 Dog 이름 관찰 static-type=Animal runtime-type=Dog name=d2 원문 B · Class 검사 Dog 토큰으로 저장 set(Dog) 정상 stored=dog setUntyped에 Cat isInstance는 false 예외 처리 출력 rejected=expected Dog 원문 C · T 반환 Hospital<Dog> 비교 d1 / 35, d2 / 65 T는 Dog로 유지 선택은 d2 고유 멤버 출력 name=d2 breed=retriever 각 영역은 서로 다른 원문 Class 검사와 정적 T 계약 구별 실선: 실제 선택·검사·출력
부모 관찰·Class 검사·T 반환의 역할 서로 다른 세 원문의 계약입니다. Animal 반환 선언은 실제 Dog 선택과 별개이며 부모 API로 이름을 읽습니다. Dog 토큰은 setUntyped의 Cat을 런타임에 거부합니다. Hospital<Dog>의 T 반환은 Dog 고유 breed를 읽게 합니다. 실선은 각 프로그램의 실제 입력·검사·반환·출력이며 영역 사이의 순차 개선 실행이 아닙니다. 원문 A · 부모 API 환자 비교 d1 / 30, d2 / 70 반환 선언 Animal 실제 선택 객체는 Dog 이름 관찰 static-type=Animal runtime-type=Dog name=d2 원문 B · Class 검사 Dog 토큰으로 저장 set(Dog) 정상 stored=dog setUntyped에 Cat isInstance는 false 예외 처리 출력 rejected=expected Dog 원문 C · T 반환 Hospital<Dog> 비교 d1 / 35, d2 / 65 T는 Dog로 유지 선택은 d2 고유 멤버 출력 name=d2 breed=retriever 각 영역은 서로 다른 원문 Class 검사와 정적 T 계약 구별 실선: 실제 선택·검사·출력
부모 타입·Class 검사·Dog 계약은 어디서 작동하는가
원문 API입력·반환의 경계실제 호출에서 관찰한 것
부모 타입 비교AnimalHospital.bigger(Animal)은 Animal을 반환합니다.d1(30)과 d2(70) 중 d2를 선택합니다.
변수의 정적 타입: Animal
실제 객체: Dog
토큰 병원의 일반 입출력CheckedHospital<Dog>의 set(T)는 Dog 계약입니다.
get()은 저장된 Animal을 Dog.class의 cast로 검사해 반환합니다.
Dog를 저장한 뒤 읽습니다.
stored=dog
토큰 병원의 넓은 입력setUntyped(Animal)은 Cat 인수를 컴파일 시 허용합니다.
Dog.class.isInstance가 실행 중 거짓을 반환합니다.
저장 전에 IllegalArgumentException을 던지고 main이 잡습니다.
rejected=expected Dog
Dog로 지정한 제네릭 비교Hospital<Dog>의 저장·비교 인수·반환에 Dog 계약이 적용됩니다.d1(35)과 d2(65) 중 d2를 선택합니다.
name=d2
breed=retriever
breed()를 명시적 캐스팅 없이 호출합니다.
부모 타입 비교
입력·반환의 경계
AnimalHospital.bigger(Animal)은 Animal을 반환합니다.
실제 호출에서 관찰한 것
d1(30)과 d2(70) 중 d2를 선택합니다.
변수의 정적 타입: Animal
실제 객체: Dog
토큰 병원의 일반 입출력
입력·반환의 경계
CheckedHospital<Dog>의 set(T)는 Dog 계약입니다.
get()은 저장된 Animal을 Dog.class의 cast로 검사해 반환합니다.
실제 호출에서 관찰한 것
Dog를 저장한 뒤 읽습니다.
stored=dog
토큰 병원의 넓은 입력
입력·반환의 경계
setUntyped(Animal)은 Cat 인수를 컴파일 시 허용합니다.
Dog.class.isInstance가 실행 중 거짓을 반환합니다.
실제 호출에서 관찰한 것
저장 전에 IllegalArgumentException을 던지고 main이 잡습니다.
rejected=expected Dog
Dog로 지정한 제네릭 비교
입력·반환의 경계
Hospital<Dog>의 저장·비교 인수·반환에 Dog 계약이 적용됩니다.
실제 호출에서 관찰한 것
d1(35)과 d2(65) 중 d2를 선택합니다.
name=d2
breed=retriever
breed()를 명시적 캐스팅 없이 호출합니다.

부모 타입 예제의 static-type=Animal은 소스의 변수 선언을 설명하는 출력 문자열이며 런타임 반사 결과가 아닙니다. 토큰 예제는 Animal 입력을 검사하며 Object 수신이나 factory 생성은 없습니다. Hospital<Animal>을 선택하면 여러 Animal 하위 타입을 담을 수 있습니다.

더 깊이: 제네릭으로 얻는 보장의 한계

제네릭의 타입 검사는 컴파일할 때 일어나고, 이 장의 첫 글에서 본 것처럼 실행 중인 객체는 자기 타입 인자를 기억하지 않습니다. 그래서 컴파일러가 확인할 수 없는 경로가 끼어들면 보장도 함께 사라집니다. 예를 들어 앞의 CheckedHospital에서 get()을 type.cast(value) 대신 return (T) value;로 쓰면, 컴파일러는 이 캐스트가 실행 중에 확인되지 않는다는 unchecked 경고를 냅니다(-Xlint:unchecked로 컴파일하면 unchecked cast라고 표시됩니다). 실행 중인 코드는 T가 Dog라는 것을 모르기 때문입니다. 이렇게 고친 병원에 setUntyped의 검사까지 빼서 고양이를 넣으면, get()은 예외 없이 고양이를 돌려줍니다. 예외는 꺼낸 값을 Dog dog = dogs.get();처럼 Dog 변수에 담는 호출한 쪽의 줄에서야 납니다.

raw 타입과 unchecked 경고를 피하는 코드에서는 제네릭이 지켜 주는 관계를 믿어도 됩니다. 반대로 바깥에서 들어오는 데이터는 처음 들어오는 곳에서 Class 객체나 명시적인 종류 값으로 검사하고, 검사를 통과한 뒤에는 Hospital<Dog>처럼 타입이 정해진 객체로 옮겨 다룹니다. 그러면 실행 중의 검사는 입구 한 곳에만 있고, 안쪽 코드는 컴파일러의 검사를 받습니다.

부모 타입, Class 객체, 제네릭 중에서 고르기

세 방법은 각자 맞는 자리가 있습니다. 여러 종을 한 목록에 섞어 담고 공통 기능만 호출한다면 List<Animal> 같은 부모 타입이 맞습니다. 이때는 꺼낸 값이 어떤 종인지 몰라도 되므로 잃을 것이 없습니다.

한 객체 안에서 넣은 타입과 꺼내는 타입이 같아야 한다면 제네릭이 맞습니다. Hospital<Dog>는 개만 받고 개를 돌려주며, 종을 섞어야 할 때는 Hospital<Animal>을 고르면 됩니다. 어떤 타입인지 실행해 봐야 알 수 있는 바깥 데이터를 다룬다면 Class 객체나 명시적인 종류 값으로 검사해야 합니다.

제네릭을 쓴다고 바깥 데이터까지 믿을 수 있게 되는 것은 아닙니다. JSON의 종류 필드, 데이터베이스에서 읽은 행, 네트워크로 받은 메시지는 컴파일러가 본 적이 없는 값이므로, 프로그램으로 들어오는 곳에서 검사해야 합니다. 검사를 통과한 뒤에는 앞 절의 '더 깊이'에서 말한 것처럼 타입이 정해진 객체로 옮겨, 안쪽 코드에서 캐스팅을 되풀이하지 않게 합니다.

상한으로 쓰는 공통 부모를 편하게 쓰려고 모든 기능을 그 부모에 올려서도 안 됩니다. 이 글의 Animal은 이름과 크기만 제공하고, 개의 품종 같은 정보는 Dog에 남겨 두었습니다. 만약 breed()를 Animal에 올리면 품종이 없는 고양이도 의미 없는 breed()를 구현해야 합니다. 개의 정보가 필요하면 Hospital<Dog>처럼 타입을 정해서 꺼내면 되므로, 부모에 올릴 이유가 없습니다.

정리

  • 부모 타입 하나로 받는 클래스는 중복을 없애고 공통 기능을 호출할 수 있게 해 주지만, '넣은 것과 꺼낸 것이 같은 구체 타입'이라는 관계는 표현하지 못합니다. 그래서 다른 하위 타입이 섞여 들어가도 컴파일러가 막지 못하고, 꺼낼 때의 다운캐스팅에서 실행 중에 실패합니다.
  • 실수가 없더라도 반환 타입이 부모 타입이면, 하위 타입의 기능을 쓰려고 할 때마다 캐스팅이 필요합니다.
  • Class 객체를 넘겨받아 isInstance와 cast로 검사하면 잘못된 값을 들어오는 순간에 거부할 수 있지만, 실행 중의 검사입니다. 어떤 타입인지 실행해 봐야 아는 바깥 데이터를 다룰 때 씁니다.
  • 제네릭은 필드, 매개변수, 반환 타입을 같은 T로 묶어 넣은 타입을 끝까지 유지합니다. 다른 타입을 넣는 실수는 컴파일 오류가 되고, 꺼낸 값은 캐스팅 없이 구체 타입으로 씁니다.
  • 상한 T extends Animal이 공통 기능을 정하고, 제네릭이 같은 타입이라는 관계를 더합니다. 두 기능은 함께 쓰입니다.

연습 문제

  1. Arena<T extends Competitor>를 만들어 현재 참가자와 도전자의 점수를 비교해 이긴 쪽을 T로 돌려주세요. Competitor는 name()과 score()를 제공하고, Robot은 name, score, serial 순서의 값을 갖습니다. Arena는 생성자에서 현재 참가자를 받아 보관하고, winner(challenger)는 점수가 큰 쪽을 돌려줍니다. 동점이면 현재 참가자를 돌려주고, 돌려주기만 할 뿐 보관한 참가자를 바꾸지는 않습니다. 현재 참가자 R1/70/1001과 도전자 R2/90/1002로 winner=이름과 serial=번호 두 줄을 출력하되, serial()은 캐스팅 없이 호출해야 합니다. 다 만들었다면 도전자를 R3/70/1003으로 바꿨을 때 출력될 이름과 serial을 예상해 보세요.

  2. 다음 두 줄에서 첫 줄은 컴파일되고 둘째 줄은 컴파일 오류가 납니다. 이유를 설명하세요. Hospital은 이 글의 Hospital<T extends Animal>이고, mixed는 Hospital<Animal>로 만든 병원이며 이미 동물 한 마리가 입원해 있습니다.

    Animal fromMixed = mixed.bigger(new Cat("c1", 50));
    Dog fromMixedAsDog = mixed.bigger(new Dog("d1", 60));
  3. 다음 세 상황에 부모 타입, Class 객체 검사, 제네릭 가운데 무엇이 맞는지 고르고 이유를 적으세요.

    • 게시판의 모든 첨부 파일을 한 목록에 담아 크기의 합계를 구한다.
    • 설정 파일에 적힌 클래스 이름으로 객체를 만든 뒤, 그 객체가 Plugin을 구현했는지 확인한다.
    • 이미지 첨부만 모아 두는 보관함에서 꺼낸 값의 가로·세로 크기를 읽는다.
해설 보기

1번. 앞 글의 Hospital과 같은 구조로, 비교에 필요한 score()는 상한 Competitor에서 얻고 반환 타입은 T로 둡니다.

src/TypeRelationshipExercise.java
public final class TypeRelationshipExercise {
    public static void main(String[] args) {
        Arena<Robot> arena = new Arena<>(new Robot("R1", 70, 1001));
        Robot winner = arena.winner(new Robot("R2", 90, 1002));
        System.out.println("winner=" + winner.name());
        System.out.println("serial=" + winner.serial());
    }

    private static final class Arena<T extends Competitor> {
        private final T current;
        Arena(T current) { this.current = current; }
        T winner(T challenger) {
            return current.score() >= challenger.score() ? current : challenger;
        }
    }

    private interface Competitor { String name(); int score(); }
    private record Robot(String name, int score, int serial) implements Competitor { }
}
winner=R2
serial=1002

70 >= 90이 거짓이므로 도전자 R2가 이깁니다. arena가 Arena<Robot>이므로 winner의 반환 타입은 Robot이고, Competitor에는 없는 serial()도 캐스팅 없이 호출할 수 있습니다. 상한 Competitor는 비교에 필요한 score()만 제공하고, 구체 타입의 정보는 T가 유지하는 셈입니다.

도전자를 R3/70/1003으로 바꾸면 70 >= 70이 참이므로 현재 참가자 R1이 이기고, winner=R1과 serial=1001이 출력됩니다. serial의 크기는 승패와 상관이 없습니다. serial()을 호출할 수 있는 것은 반환 타입이 Robot이기 때문이지, serial이 비교에 쓰였기 때문이 아닙니다. 여러 종류의 참가자를 함께 겨루게 하고 싶다면 Arena<Competitor>를 만들면 되고, 그때는 결과도 Competitor로만 다룰 수 있습니다.

2번. mixed는 Hospital<Animal>이므로 T가 Animal이고, bigger의 반환 타입도 Animal입니다. 첫 줄은 Animal을 Animal 변수에 담으므로 컴파일됩니다. 둘째 줄은 실제로 개를 넘겼더라도 반환 타입이 Animal이라서 Dog 변수에 담을 수 없고, Animal cannot be converted to Dog 오류가 납니다. 컴파일러는 이 병원에 고양이가 입원해 있을 수도 있다는 것을 알기 때문입니다.

개를 캐스팅 없이 꺼내고 싶다면 병원을 처음부터 Hospital<Dog>로 만들어야 합니다. 제네릭은 넣는 쪽의 타입 인자를 고른 만큼만 꺼내는 쪽에 돌려줍니다.

3번. 첫째는 부모 타입이 맞습니다. 모든 첨부 파일이 공통으로 가진 크기만 읽으므로, 꺼낸 값이 어떤 종류인지 몰라도 잃을 것이 없습니다.

둘째는 Class 객체 검사가 맞습니다. 어떤 클래스의 객체가 만들어질지는 설정 파일을 읽어 봐야 알 수 있으므로, 컴파일러는 미리 검사할 수 없습니다. 만든 객체를 Plugin.class.isInstance(...)처럼 실행 중에 확인해야 합니다. 셋째는 제네릭이 맞습니다. 보관함을 이미지 타입으로 정해 두면 이미지가 아닌 첨부를 넣는 실수는 컴파일 오류가 되고, 꺼낸 값의 가로·세로 크기를 캐스팅 없이 읽을 수 있습니다.

다음 글

타입 상한과 메서드 추론에서는 이 장에서 계속 써 온 T extends Animal을 자세히 살펴봅니다. 상한이 없을 때 왜 size()를 호출할 수 없는지 확인하고, 클래스 전체가 아니라 메서드 하나에만 타입 매개변수를 두는 제네릭 메서드와 그 타입 추론을 배웁니다.