타입별 클래스 중복을 제네릭으로 줄이기
개 병원과 고양이 병원처럼 타입마다 복사한 클래스가 왜 서로 다른 규칙을 갖게 되는지 확인하고, 공통 알고리즘은 제네릭 클래스 하나로 모으고 종마다 다른 동작은 따로 넘겨받는 방법을 익힙니다.
동물 병원 프로그램을 만든다고 해 봅시다. 개 병원은 개만, 고양이 병원은 고양이만 받아야 하고, 두 병원 모두 입원한 동물과 새로 온 동물 가운데 더 큰 쪽을 골라내는 기능이 필요합니다. 앞 글의 IntegerBox와 StringBox처럼 종마다 클래스를 하나씩 만들면 이 요구는 쉽게 지킬 수 있습니다. 그런데 앞 글에서는 이 방법의 단점을 '코드가 복제된다'는 말로만 넘겼습니다.
이 글에서는 복제된 코드가 실제로 어떤 사고를 내는지 직접 확인합니다. 그다음 종마다 만든 클래스의 장점은 그대로 두고, 같은 알고리즘만 제네릭 클래스 하나로 모읍니다. 마지막으로 종마다 정말로 다른 동작은 어떻게 다룰지, 언제 하나로 합치고 언제 따로 둘지를 정리합니다.
종마다 만든 병원은 다른 종을 받지 않는다
먼저 종마다 만든 병원 클래스가 무엇을 잘하는지부터 보겠습니다. 개 병원 DogHospital의 set 메서드는 매개변수 타입이 Dog입니다. 다음 프로그램은 이 개 병원에 고양이를 입원시키려 합니다.
public final class WrongSpeciesHospitalFailure {
public static void main(String[] args) {
DogHospital hospital = new DogHospital();
hospital.set(new Cat("cat", 40));
}
private static final class DogHospital {
private Dog animal;
void set(Dog animal) { this.animal = animal; }
}
private record Dog(String name, int size) { }
private record Cat(String name, int size) { }
}이 파일은 컴파일되지 않습니다. javac lab/WrongSpeciesHospitalFailure.java를 실행하면 다음 오류가 납니다.
lab/WrongSpeciesHospitalFailure.java:4: error: incompatible types: Cat cannot be converted to Dog
hospital.set(new Cat("cat", 40));
^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error메서드 선언 set(Dog animal)이 곧 '이 병원은 개만 받는다'는 규칙이므로, 다른 종을 넣는 실수는 실행하기도 전에 막힙니다. 꺼내는 쪽도 마찬가지입니다. 병원이 돌려주는 값의 타입이 처음부터 Dog이니 캐스팅할 필요가 없고, 앞 글의 Object 상자처럼 엉뚱한 값을 꺼내다가 ClassCastException이 날 일도 없습니다. 종마다 클래스를 만드는 방법은 적어도 타입 면에서는 안전합니다.
복사한 코드는 따로 고쳐지다가 어긋난다
문제는 두 병원의 코드가 거의 똑같다는 데 있습니다. 고양이 병원이 필요해지면 보통 개 병원을 복사한 뒤 Dog를 Cat으로 바꿉니다. 다음 프로그램은 그렇게 만든 두 병원에 같은 질문을 던집니다. 입원한 동물과 새로 온 동물 가운데 어느 쪽이 더 큰지 묻는 것입니다.
public final class DuplicatedHospitalPolicyDriftBug {
public static void main(String[] args) {
DogHospital dogs = new DogHospital();
dogs.set(new Dog("small-dog", 30));
System.out.println("dog=" + dogs.bigger(new Dog("big-dog", 70)).name());
CatHospital cats = new CatHospital();
cats.set(new Cat("small-cat", 20));
System.out.println("cat=" + cats.bigger(new Cat("big-cat", 60)).name());
}
private static final class DogHospital {
private Dog animal;
void set(Dog animal) { this.animal = animal; }
Dog bigger(Dog target) { return animal.size() > target.size() ? animal : target; }
}
private static final class CatHospital {
private Cat animal;
void set(Cat animal) { this.animal = animal; }
Cat bigger(Cat target) { return animal.size() < target.size() ? animal : target; }
}
private record Dog(String name, int size) { }
private record Cat(String name, int size) { }
}두 병원 모두 작은 동물을 먼저 입원시키고 큰 동물과 비교했으니, 기대하는 답은 big-dog와 big-cat입니다. 그런데 실제 출력은 다릅니다.
dog=big-dog
cat=small-catbigger는 조건 연산자 조건 ? 값1 : 값2로 입원한 동물(animal)과 비교 대상(target) 가운데 하나를 고릅니다. 조건이 참이면 입원한 동물을, 거짓이면 비교 대상을 돌려줍니다. 두 병원에서 이 조건이 어떻게 계산되는지 나란히 놓아 보겠습니다.
| 병원 | 입원한 동물 | 비교 대상 | 조건식 | 조건 | 돌려준 동물 |
|---|---|---|---|---|---|
DogHospital | small-dog, 30 | big-dog, 70 | 30 > 70 | 거짓 | big-dog |
CatHospital | small-cat, 20 | big-cat, 60 | 20 < 60 | 참 | small-cat |
개 병원의 조건 30 > 70은 거짓이므로 비교 대상인 big-dog가 선택됩니다. 고양이 병원은 코드를 복사하다가 비교 연산자가 <로 바뀌었습니다. 그래서 조건 20 < 60이 참이 되고, 더 작은 small-cat이 선택됩니다.
이 버그는 컴파일러의 타입 검사로는 잡을 수 없습니다. CatHospital의 bigger는 여전히 Cat만 받고 Cat을 돌려주므로 타입으로 보면 아무 문제가 없습니다. 깨진 것은 '더 큰 동물을 고른다'는 업무 규칙인데, 타입 검사는 비교 연산자가 그 규칙에 맞는지까지 따지지 않습니다. 실행 결과를 눈으로 확인하거나 테스트하지 않으면 그대로 배포됩니다.
이 예제는 복사하다 생긴 실수지만, 처음에는 똑같이 복사했더라도 시간이 지나며 같은 사고가 납니다. 예를 들어 같은 크기일 때 어느 쪽을 고를지 규칙이 바뀌었는데 개 병원만 고치고 고양이 병원을 잊으면, 두 병원은 똑같은 상황에서 서로 다른 동물을 고르게 됩니다. 그래서 중복의 진짜 비용은 줄 수가 아닙니다. 같은 이유로 바뀌어야 하는 코드가 여러 곳에 흩어져 있으면, 그 코드들이 서로 다르게 바뀔 수 있다는 것이 비용입니다.
아래 도표는 이 절과 앞 절의 두 프로그램을 함께 보여 줍니다. 앞의 두 영역은 타입은 맞지만 고른 동물이 어긋나는 경우이고, 마지막 영역은 컴파일러가 다른 종의 입력을 막는 경우입니다.
개 병원의 30>70은 거짓, 고양이 병원의 20<60은 참이므로 작은 고양이가 선택된다. 별도 원문의 Cat 인수는 Dog 전용 set에서 컴파일 거부된다.
타입 안전 · 비교 정책
타입 보호와 더 큰 동물 선택은 별개다
타입이 맞아도 더 큰 동물을 고르는 비교식은 틀릴 수 있습니다. 실제 두 비교와 별도 컴파일 실패를 함께 봅니다.
개 비교
- 검사 또는 비교식
DogHospital: 저장한 크기 30, 비교 대상 70.30 > 70은 거짓입니다.- 실제 선택과 결과
- 대상
big-dog를 선택합니다.dog=big-dog
고양이 비교
- 검사 또는 비교식
CatHospital: 저장한 크기 20, 비교 대상 60.20 < 60은 참입니다.- 실제 선택과 결과
- 저장한
small-cat을 선택합니다.cat=small-cat컴파일은 성공하지만 더 큰 동물이라는 요구를 어깁니다.
다른 종 입력
- 검사 또는 비교식
- 별도 원문에서
DogHospital.set(Dog)에Cat을 전달합니다. - 실제 선택과 결과
- 컴파일 단계에서 거부합니다.
Cat은Dog인수로 변환할 수 없습니다.
앞의 두 행은 DuplicatedHospitalPolicyDriftBug의 출력이고, 마지막 행은 WrongSpeciesHospitalFailure의 컴파일 결과입니다. 타입 검사는 비교 연산자가 업무 요구에 맞는지까지 증명하지 않습니다.
정리하면 우리가 원하는 것은 두 가지입니다. 개 병원은 개만, 고양이 병원은 고양이만 받는 타입 안전성은 지키고, 더 큰 동물을 고르는 알고리즘은 한 곳에만 둡니다.
같은 것과 다른 것 나누기
두 병원을 하나로 합치려면 먼저 무엇이 같고 무엇이 다른지 나눠야 합니다. 두 병원은 동물 한 마리를 입원시켜 보관하고, 새로 온 동물과 크기를 비교해 더 큰 쪽을 돌려줍니다. 이 흐름은 개든 고양이든 똑같습니다. 다른 것은 두 가지입니다. 하나는 다루는 구체적인 타입, 즉 Dog인지 Cat인지이고, 다른 하나는 개의 예방접종 확인이나 고양이의 실내 생활 확인처럼 종마다 고유한 동작입니다.
각각을 다루는 도구가 따로 있습니다. 타입이 다른 것은 앞 글에서 배운 타입 매개변수로 해결합니다. Hospital<T>라고 선언하고 개 병원은 Hospital<Dog>, 고양이 병원은 Hospital<Cat>으로 쓰면 됩니다. 종마다 다른 동작은 이 글의 뒷부분에서 객체로 넘겨받는 방법을 봅니다.
그런데 공통 알고리즘에는 조건이 하나 붙습니다. 크기를 비교하려면 어떤 타입이 오든 크기를 알려 주는 size() 메서드가 있어야 합니다. 그래서 두 종이 함께 구현할 인터페이스 Animal을 만들고, 이름과 크기를 알려 주는 메서드를 선언합니다. 병원 알고리즘은 이 인터페이스에 있는 메서드만 사용합니다.
제네릭 병원 하나로 합치기
다음 프로그램은 두 병원을 Hospital<T extends Animal> 하나로 합칩니다. main은 같은 Hospital 클래스로 Hospital<Dog>와 Hospital<Cat>을 하나씩 만들고, 각 병원에서 더 큰 동물을 고릅니다. 클래스 선언의 <T extends Animal>은 처음 보는 문법인데, 그 뜻은 실행 결과를 본 뒤에 설명합니다.
public final class BoundedHospitalPreview {
public static void main(String[] args) {
Hospital<Dog> dogs = new Hospital<>();
dogs.set(new Dog("d1", 30));
Dog biggerDog = dogs.bigger(new Dog("d2", 80));
Hospital<Cat> cats = new Hospital<>();
cats.set(new Cat("c1", 70));
Cat biggerCat = cats.bigger(new Cat("c2", 40));
System.out.println("dog=" + biggerDog.name());
System.out.println("cat=" + biggerCat.name());
}
private static final class Hospital<T extends Animal> {
private T animal;
void set(T animal) { this.animal = animal; }
T bigger(T target) { return animal.size() > target.size() ? animal : target; }
}
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 { }
}dog=d2
cat=c1개 병원에서는 30 > 80이 거짓이라 비교 대상 d2가, 고양이 병원에서는 70 > 40이 참이라 입원한 c1이 선택됩니다. 두 결과 모두 더 큰 동물입니다. 이제 비교식은 Hospital 안의 한 줄에만 있으므로, 앞 절처럼 한쪽 종만 다르게 고쳐질 수가 없습니다. 같은 크기일 때 어느 쪽을 고를지도 이 한 줄이 정합니다. >는 크기가 같으면 거짓이므로 비교 대상을 돌려주는데, 이 규칙을 바꾸고 싶다면 이 줄 하나만 고치면 됩니다.
새로 나온 것은 <T extends Animal>입니다. 이것은 'T 자리에는 Animal이거나 Animal을 구현한 타입만 올 수 있다'는 제한입니다. 이렇게 제한해 두었기 때문에 컴파일러는 T가 무엇이든 size()가 있다는 것을 알고, 클래스 안에서 animal.size()를 호출하도록 허락합니다. 제한 없이 Hospital<T>라고만 쓰면 T에 Integer나 String이 올 수도 있으므로 animal.size()가 컴파일 오류가 됩니다. 이 제한을 타입 상한(upper bound)이라고 부르며, 자세한 규칙은 이 장의 타입 상한을 다루는 글에서 배웁니다.
타입 안전성도 그대로입니다. dogs는 Hospital<Dog>이므로 set과 bigger는 Dog만 받고 Dog를 돌려줍니다. 그래서 dogs.bigger(...)의 결과를 캐스팅 없이 Dog biggerDog에 담을 수 있고, dogs.set(new Cat("c1", 70))이라고 쓰면 앞의 DogHospital과 똑같이 Cat cannot be converted to Dog 컴파일 오류가 납니다. 반대로 종을 섞어 받는 병원이 필요하다면 Hospital<Animal>로 만들면 됩니다. 이때는 Dog와 Cat을 모두 넣을 수 있는 대신, 꺼낸 결과도 Animal로만 다룰 수 있습니다.
종마다 다른 검진은 밖에서 넘겨받기
이번에는 정말로 종마다 다른 동작을 다뤄 봅시다. 개를 검진할 때는 예방접종 번호를 확인하고, 고양이는 실내에서 사는지를 확인한다고 합니다. 이런 차이를 Hospital 안에 if로 넣기 시작하면, 종이 늘어날 때마다 병원 클래스를 고쳐야 하고 다른 종의 규칙이 한 클래스 안에 뒤섞입니다.
그 대신 '검진한다'는 동작 자체를 객체로 만들어 병원에 넘겨줄 수 있습니다. 다음 프로그램에서 Checkup<T>는 T 타입의 동물을 받아 검진 결과 문자열을 돌려주는 메서드 하나만 가진 인터페이스입니다. 병원은 생성될 때 이 검진 정책을 받아 두었다가 checkup()이 호출되면 그대로 실행합니다.
public final class HospitalWithSpeciesPolicy {
public static void main(String[] args) {
Hospital<Dog> dogs = new Hospital<>(dog -> "dog-check=" + dog.vaccineId());
dogs.admit(new Dog("dori", 55, "VAC-7"));
System.out.println(dogs.checkup());
Hospital<Cat> cats = new Hospital<>(cat -> "cat-check=indoor:" + cat.indoor());
cats.admit(new Cat("nabi", 20, true));
System.out.println(cats.checkup());
}
private static final class Hospital<T extends Sized> {
private final Checkup<T> policy;
private T patient;
Hospital(Checkup<T> policy) { this.policy = policy; }
void admit(T patient) { this.patient = patient; }
String checkup() { return policy.inspect(patient); }
T bigger(T other) { return patient.size() > other.size() ? patient : other; }
}
private interface Sized { int size(); }
private interface Checkup<T> { String inspect(T target); }
private record Dog(String name, int size, String vaccineId) implements Sized { }
private record Cat(String name, int size, boolean indoor) implements Sized { }
}dog-check=VAC-7
cat-check=indoor:truenew Hospital<>(...)의 괄호 안에 있는 dog -> "dog-check=" + dog.vaccineId()는 람다식(lambda expression)입니다. 구현할 메서드가 하나뿐인 인터페이스의 객체를 짧게 만드는 문법으로, 화살표 왼쪽은 메서드의 매개변수이고 오른쪽은 돌려줄 값입니다. 람다는 람다와 함수형 인터페이스를 다루는 장에서 자세히 배우니, 지금은 익명 클래스로 쓴 다음 코드와 같은 일을 한다고 이해하면 충분합니다.
new Checkup<Dog>() {
@Override
public String inspect(Dog dog) {
return "dog-check=" + dog.vaccineId();
}
}컴파일러는 dog가 Dog라는 것을 앞 글에서 본 타입 추론으로 알아냅니다. 왼쪽 변수가 Hospital<Dog>이므로 다이아몬드의 T는 Dog가 되고, 생성자가 받는 정책은 Checkup<Dog>가 됩니다. 그래서 람다의 매개변수 dog는 Dog이고, Dog에만 있는 vaccineId()를 캐스팅 없이 호출할 수 있습니다. 고양이 병원에서는 같은 이유로 cat이 Cat이 되어 indoor()를 호출합니다. 반대로 고양이용 정책 Checkup<Cat>을 Hospital<Dog>에 넘기면, T를 Dog와 Cat 둘 다로 정할 수는 없으므로 컴파일 오류가 납니다.
실행 결과의 첫 줄은 개 병원이 받은 정책이 dori의 예방접종 번호를 읽은 것이고, 둘째 줄은 고양이 병원의 정책이 nabi의 실내 생활 여부를 읽은 것입니다. 두 병원은 같은 Hospital 클래스이고, 다른 것은 생성할 때 넘겨받은 정책뿐입니다. bigger는 앞 절의 공통 알고리즘을 그대로 둔 것이라 이 예제에서는 호출하지 않았습니다. 이렇게 병원은 보관과 비교처럼 모든 종에 공통인 일만 맡고, 종마다 다른 검진은 바깥에서 넘겨받습니다. 제네릭은 모든 종을 똑같이 만드는 것이 아니라, 종마다 다른 부분을 분리하면서도 '이 병원의 환자는 Dog'라는 타입 관계를 끝까지 유지해 줍니다.
더 깊이: 상속으로 나눌까, 객체로 넘길까
종마다 다른 부분을 분리하는 방법이 이것만 있는 것은 아닙니다. Hospital<T>를 추상 클래스로 만들고 checkup()을 추상 메서드로 남긴 뒤, DogHospital extends Hospital<Dog>처럼 종마다 하위 클래스를 두는 방법도 있습니다. 공통 알고리즘은 부모에, 달라지는 단계는 자식에 두는 이 구조를 템플릿 메서드(template method) 패턴이라고 합니다. 이 방법은 종마다 다시 클래스가 생기지만, 그 클래스에는 정말로 다른 부분만 들어갑니다.
이 글처럼 정책 객체를 넘기는 방법은 전략 패턴을 제네릭과 합친 것입니다. 하위 클래스를 만들지 않아도 되고, 같은 개 병원이라도 상황에 따라 다른 검진 정책을 끼울 수 있습니다. JDK도 이 방식을 자주 씁니다. 예를 들어 List의 sort 메서드는 정렬 알고리즘을 하나만 두고, 원소를 어떤 순서로 놓을지는 Comparator 객체로 넘겨받습니다. 달라지는 부분이 크고 상태까지 가진다면 하위 클래스가, 메서드 하나로 끝나는 작은 규칙이라면 정책 객체가 대체로 다루기 쉽습니다.
하나로 합칠지 따로 둘지 판단하기
모든 중복을 제네릭으로 합쳐야 하는 것은 아닙니다. 공통 알고리즘이 바뀔 때 모든 타입이 늘 함께 바뀌어야 하고 타입만 다르다면, 하나로 합치는 이점이 큽니다. 반대로 클래스 이름만 비슷할 뿐 업무 규칙과 필드가 타입마다 따로 변한다면, 억지로 합친 클래스에는 if와 instanceof 검사가 늘어날 뿐입니다. 판단이 서지 않을 때는 다음 질문을 차례로 던져 보세요.
- 보관하고, 꺼내고, 비교하는 순서가 모든 타입에서 같은가?
- 공통 알고리즘이 타입에게 요구하는 최소한의 메서드는 무엇인가? (이 글에서는
size()하나였습니다.) - 한 객체 안에서 넣는 타입과 꺼내는 타입이 같아야 하는가?
- 타입마다 다른 동작을 정책 객체나 하위 클래스로 떼어 낼 수 있는가?
- 새 종을 추가할 때 기존 알고리즘을 고치지 않아도 되는가?
합친 뒤에는 테스트도 나눠서 생각합니다. 크기가 같을 때 누구를 고르는지 같은 공통 알고리즘의 규칙은 모든 타입에 똑같이 적용되어야 하므로, 같은 테스트를 여러 타입으로 돌려 확인합니다. 종마다 다른 검진 정책은 그 정책만 따로 테스트합니다. 하나로 합치면 버그를 한 곳에서 고칠 수 있지만, 그 한 곳에 생긴 버그는 모든 종에 한꺼번에 퍼집니다. 그래서 공통 알고리즘의 테스트는 합치기 전보다 더 중요해집니다.
정리
- 타입마다 클래스를 만들면 메서드의 매개변수와 반환 타입이 곧 규칙이 되어, 다른 타입의 입력은 컴파일할 때 막힙니다.
- 대신 같은 알고리즘이 여러 클래스에 복사되므로, 한쪽만 고쳐지거나 잘못 복사되면 같은 상황에서 서로 다른 결과를 냅니다. 타입 검사는 이런 업무 규칙의 어긋남을 잡지 못합니다.
Hospital<T extends Animal>처럼 공통 알고리즘을 제네릭 클래스 하나로 모으면, 알고리즘은 한 곳에만 있고Hospital<Dog>와Hospital<Cat>은 여전히 자기 타입만 받습니다.extends Animal은 T에size()가 있다는 것을 컴파일러에게 알려 줍니다.- 종마다 다른 동작은
Checkup<T>같은 정책 객체로 넘겨받으면, 공통 클래스를 고치지 않고도 종마다 다르게 처리할 수 있습니다. - 타입만 다르고 알고리즘이 같을 때 합칩니다. 합친 뒤에는 공통 알고리즘의 테스트가 더 중요해집니다.
연습 문제
Hospital<T extends Sized>를 만들어, 크기가 같을 때는 이미 입원해 있던 환자를 돌려주도록 하세요.Sized는name()과size()를 제공하고,Hospital은 생성자에서 입원할 환자를 받으며,bigger(other)는 고른 동물을 돌려줄 뿐 입원한 환자를 바꾸지 않습니다. 공통 메서드는 한 번만 작성하고, 개는 first-dog 50과 second-dog 50, 고양이는 first-cat 40과 second-cat 40으로 확인하세요. 다 만들었다면 고양이의 비교 대상만 second-cat 41로 바꿨을 때 무엇이 반환될지, 이어서 third-cat 39와 비교하면 무엇이 반환될지 예상해 보세요.- 다음 네 가지 요구를
Hospital<T>안에 둘 것과Checkup<T>정책으로 넘길 것으로 나누고, 그렇게 나눈 이유를 설명하세요.- 더 큰 동물 고르기
- 개의 예방접종 번호 확인
- 환자 한 마리 보관하기
- 고양이의 실내 생활 여부 확인
DuplicatedHospitalPolicyDriftBug의<버그는Hospital<T extends Animal>로 합친 뒤에는 다시 생길 수 없습니다. 그 이유를 설명하고, 대신 합친 구조에서 더 조심해야 할 점은 무엇인지 적으세요.
해설 보기
1번. 동률일 때 입원한 환자를 돌려주려면 조건식이 크기가 같을 때도 참이어야 하므로 >=를 씁니다.
public final class HospitalTiePolicyExercise {
public static void main(String[] args) {
Hospital<Dog> dogs = new Hospital<>(new Dog("first-dog", 50));
Hospital<Cat> cats = new Hospital<>(new Cat("first-cat", 40));
System.out.println(dogs.bigger(new Dog("second-dog", 50)).name());
System.out.println(cats.bigger(new Cat("second-cat", 40)).name());
}
private static final class Hospital<T extends Sized> {
private final T patient;
Hospital(T patient) { this.patient = patient; }
T bigger(T other) { return patient.size() >= other.size() ? patient : other; }
}
private interface Sized { String name(); int size(); }
private record Dog(String name, int size) implements Sized { }
private record Cat(String name, int size) implements Sized { }
}first-dog
first-cat50 >= 50과 40 >= 40이 모두 참이므로 두 병원 모두 입원해 있던 환자를 돌려줍니다. 동률 규칙이 bigger 한 줄에만 있으므로 개와 고양이의 결과가 어긋날 수 없고, 요구가 '새 환자 우선'으로 바뀌어도 >=를 >로 바꾸는 것으로 끝납니다.
비교 대상을 second-cat 41로 바꾸면 40 >= 41이 거짓이므로 second-cat이 반환됩니다. 하지만 bigger는 고른 동물을 돌려줄 뿐 patient 필드를 바꾸지 않습니다(필드가 final이라 바꿀 수도 없습니다). 그래서 이어서 third-cat 39와 비교하면 여전히 first-cat 40과 비교하게 되고, 40 >= 39가 참이라 first-cat이 반환됩니다. 이긴 동물을 다음 비교의 기준으로 삼고 싶다면, 입원한 환자를 바꾸는 메서드를 따로 요구하고 구현해야 합니다.
2번. 더 큰 동물 고르기와 환자 한 마리 보관하기는 Hospital<T> 안에 둡니다. 두 일은 개든 고양이든 순서가 똑같고, 필요한 것도 공통 인터페이스의 size()뿐이기 때문입니다. 개의 예방접종 번호 확인과 고양이의 실내 생활 여부 확인은 Checkup<T> 정책으로 넘깁니다. 이 둘은 특정 종에만 있는 정보(vaccineId(), indoor())를 읽으므로, Hospital이 직접 하려면 종을 묻는 if나 instanceof가 필요합니다. 정책으로 넘기면 새 종이 생겨도 Hospital은 고치지 않고 정책만 새로 만들면 됩니다.
3번. 비교식이 Hospital 클래스의 bigger 한 줄에만 있기 때문입니다. Hospital<Dog>와 Hospital<Cat>은 같은 클래스의 같은 메서드를 실행하므로, 한 종만 다른 연산자를 쓰는 상황이 생길 수 없습니다. 대신 그 한 줄이 틀리면 모든 종이 한꺼번에 틀립니다. 예를 들어 >를 실수로 <로 바꾸면 개 병원도 고양이 병원도 작은 동물을 고릅니다. 그래서 크기가 다를 때와 같을 때 누구를 고르는지 같은 공통 규칙을 테스트로 고정해 두는 것이 합치기 전보다 더 중요합니다.
다음 글
Hospital<T>를 만드는 대신 Animal 타입 하나로 받는 AnimalHospital을 만들어도 중복은 사라집니다. 다형성과 제네릭의 차이에서는 그 방법이 어디서 막히는지 확인하고, 제네릭이 부모 타입만으로는 표현할 수 없는 '넣은 타입과 꺼내는 타입이 같다'는 관계를 어떻게 지켜 주는지 살펴봅니다.