본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
13장 : 제네릭·와일드카드·소거

다형성과 타입 관계

부모 타입 병원이 Cat을 받아 놓고 Dog로 다운캐스팅해 실패하는 흐름을 재현하고 입력·저장·반환의 동일 타입 관계를 제네릭 계약으로 복원합니다.

Dog와 Cat이 모두 Animal의 자식이라면 AnimalHospital 하나로 중복을 없앨 수 있어 보입니다.

실제로 이름·크기·sound 같은 공통 메서드는 부모 타입으로 호출할 수 있습니다.

하지만 “이 병원에 넣은 구체 타입과 꺼내는 구체 타입이 같다”는 관계는 단순한 부모 타입 하나로 표현되지 않습니다.


잘못된 하위 타입 캐스팅

병원 필드와 메서드가 모두 Animal이므로 어느 동물이든 전달됩니다.

호출자는 개 병원이라고 생각하지만 Cat을 넣는 실수를 컴파일러가 막지 못하고, 반환된 Animal을 Dog로 다운캐스팅할 때 실패합니다.

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 { }
}
실행 결과의 핵심
java.lang.ClassCastException:
class AnimalHospitalWrongSubtypeFailure$Cat cannot be cast to class AnimalHospitalWrongSubtypeFailure$Dog

Animal 다형성은 공통 행동 호출에는 성공했지만 컨테이너의 타입 일관성을 잃었습니다.

입력 시점의 Cat과 실패 시점의 Dog 캐스팅이 멀리 떨어지면 원인을 추적하기 어렵습니다.


부모 타입 반환의 부담

실수로 다른 종을 넣지 않아도 bigger()의 반환 타입은 Animal입니다.

호출자는 실제로 Dog만 들어 있다는 사실을 코드 밖의 관례로 믿고 캐스팅해야 합니다.

API가 보장해야 할 지식을 소비자의 주석과 기억에 맡긴 셈입니다.

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

부모 기능만 필요하다면 이 반환도 충분합니다.

그러나 Dog 고유 메서드를 이어서 호출해야 한다면 캐스팅이 필요합니다.

구체 타입을 보존하는 요구가 있는지 먼저 확인해야 합니다.


Class 토큰의 런타임 검사

병원 생성 시 허용 타입의 Class를 받아 입력을 검사할 수 있습니다.

잘못된 값은 set 지점에서 빠르게 거부하지만 여전히 실행 중 검사이고, 반환 시 cast 로직도 병원 내부에 남습니다.

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

Class 토큰은 역직렬화나 외부 플러그인처럼 런타임 타입 검사가 본질인 경계에서 유용합니다.

일반 소스 코드의 타입 관계를 표현하려고 매번 토큰과 cast를 쓰는 것은 컴파일러가 할 일을 수동으로 반복합니다.


제네릭의 타입 관계 보존

Hospital<T>의 필드, set 인수, bigger 인수, 반환을 모두 T로 묶으면 생성 시 선택한 타입이 전체 API에 유지됩니다.

Animal 상한을 두면 공통 size도 호출할 수 있습니다.

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

반환 정적 타입이 Dog이므로 고유한 breed도 캐스팅 없이 호출합니다.

Cat을 set하거나 bigger에 전달하면 저장 지점에서 컴파일 오류가 납니다.

다형성은 공통 행동의 상한을 제공하고 제네릭은 같은 T 관계를 보존합니다.

두 기능은 경쟁이 아니라 결합 관계입니다.


다형성 도구의 선택 기준

여러 종을 한 컬렉션에 섞어 공통 행동만 호출하면 List<Animal> 같은 부모 타입이 적합합니다.

한 컨테이너 인스턴스가 하나의 구체 타입을 유지하고 반환까지 보존해야 하면 제네릭이 적합합니다.

외부 데이터가 어떤 타입인지 실행 중 판별하고 객체를 만들어야 하면 Class 토큰이나 명시적 타입 식별자가 필요합니다.

제네릭을 사용해도 런타임 데이터가 신뢰할 수 있다는 뜻은 아닙니다.

JSON의 종류 필드, DB 행, 네트워크 메시지는 입력 경계에서 검증해야 합니다.

검증 뒤 내부에서는 Hospital<Dog>처럼 안전한 타입으로 바꿔 캐스팅을 반복하지 않습니다.

공통 부모에 모든 구체 기능을 올려놓아 상한을 편하게 만들지 않습니다.

Animal은 모든 동물이 진짜 공유하는 이름·크기·sound만 제공하고 Dog의 breed 같은 정보는 Dog에 남깁니다.

상한이 비대해지면 다른 구현이 의미 없는 메서드를 구현하게 됩니다.


연습 문제

Arena<T extends Competitor>를 만들고 현재 참가자와 도전자의 score를 비교해 T를 반환하세요.

Arena<Robot>의 결과에서 Robot 고유 serial을 캐스팅 없이 출력합니다.

해설 보기
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

상한은 비교에 필요한 score만 제공하고 T 반환은 Robot의 구체 정보를 유지합니다.

다른 경쟁 타입을 같은 Arena 인스턴스에 섞을 수 없습니다.