안동민 개발노트

본문 시작

다형성과 타입 관계

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

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

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

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


잘못된 하위 타입 캐스팅

병원 필드와 메서드가 모두 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 캐스팅이 멀리 떨어지면 원인을 추적하기 어렵습니다.

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

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

AnimalHospitalWrongSubtypeFailure에서 변수 이름은 dogHospital이지만, 입력과 반환의 계약은 Animal입니다.

반환된 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("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가 보장하지 않았습니다.


부모 타입 반환의 부담

실수로 다른 종을 넣지 않아도 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도 캐스팅 없이 호출합니다.

이 Hospital<Dog>에 Cat을 set하거나 bigger의 인수로 전달하는 호출은 컴파일 단계에서 거부됩니다.

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

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

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

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

아래는 서로 다른 세 원문의 실제 호출입니다. 컴파일 시 보장하는 타입과 실행 중 검사한 값을 구분합니다.

부모 타입·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 하위 타입을 담을 수 있습니다.


다형성 도구의 선택 기준

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

선택한 타입 인자를 입력과 반환에 함께 적용하려면 제네릭이 적합합니다. Hospital<Dog>는 Dog 계약을 유지하고, Hospital<Animal>을 선택하면 여러 Animal 하위 타입을 담을 수 있습니다.

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

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

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

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

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

이 예제의 Animal은 공통 이름·크기 계약만 제공하고 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<Robot>에는 Robot 계약에 맞는 값만 전달할 수 있습니다. Arena<Competitor>를 선택한다면 여러 Competitor 하위 타입을 함께 비교할 수 있습니다.