🚫 타입 매개 변수 제한

🎎 각각의 클래스를 생성

이제 동물 병원 클래스를 만들어 보자. 개 병원은 개만 받고, 고양이 병원은 고양이만 받도록 한다.

package generic.test.ex3;

import generic.animal.Dog;

public class DogHospital {

    private Dog animal;

    public void set(Dog animal) {
        this.animal = animal;
    }

    public void checkUp() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

	// 다른 개와의 크기 비교(더 큰 개를 반환)
    public Dog bigger(Dog target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}
package generic.test.ex3;

import generic.animal.Cat;

public class CatHospital {

    private Cat animal;

    public void set(Cat animal) {
        this.animal = animal;
    }

    public void checkUp() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    // 다른 고양이와의 크기 비교(더 큰 고양이를 반환)
    public Cat bigger(Cat target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}
package generic.test.ex3;

import generic.animal.Dog;
import generic.animal.Cat;

public class AnimalHospitalMainV0 {
    public static void main(String[] args) {

        DogHospital dogHospital = new DogHospital();
        CatHospital catHospital = new CatHospital();

        Dog dog = new Dog("아르", 300);
        Cat cat = new Cat("에단", 200);

        // 개 병원
        dogHospital.set(dog);
        dogHospital.checkUp();

        // 고양이 병원
        catHospital.set(cat);
        catHospital.checkUp();

        // 문제1: 개 병원에 고양이를 전달한다면?
        // dogHospital.set(cat);  // 컴파일 오류

        // 문제2: 개 타입 반환
        dogHospital.set(dog);
        Dog biggerDog = dogHospital.bigger(new Dog("이브", 350));
        System.out.println("biggerDog = " + biggerDog);
    }
}

/*
동물 이름: 아르
동물 크기: 300
멍멍
동물 이름: 에단
동물 크기: 200
야옹
biggerDog = Animal{name='이브', size=350}
*/

이처럼 개와 고양이 각각의 클래스를 만들었다. 각 클래스마다 타입이 명확하기 때문에 다른 타입을 전달받으면 위와 같이 컴파일 오류를 터뜨린다. 타입 안전성은 높아서 좋지만, 코드의 중복이 한눈에 봐도 너무 많다.

 

🧔 부모 타입으로 한번에 처리하기

그렇다면 아래와 같이 DogCat의 공통 부모인 Animal 타입으로 한 클래스에서 시도해보면 어떨까?

package generic.test.ex3;

import generic.animal.Animal;

public class AnimalHospitalV1 {

    private Animal animal;  // Animal 타입으로 받기

    public void set(Animal animal) {
        this.animal = animal;
    }

    public void checkUp() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public Animal bigger(Animal target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}
package generic.test.ex3;

import generic.animal.Cat;
import generic.animal.Dog;

public class AnimalHospitalMainV1 {
    public static void main(String[] args) {

        AnimalHospitalV1 dogHospital = new AnimalHospitalV1();
        AnimalHospitalV1 catHospital = new AnimalHospitalV1();

        Dog dog = new Dog("아르", 300);
        Cat cat = new Cat("에단", 200);

        // 개 병원
        dogHospital.set(dog);
        dogHospital.checkUp();

        // 고양이 병원
        catHospital.set(cat);
        catHospital.checkUp();

        // 문제1: 개 병원에 고양이를 전달한다면?
        dogHospital.set(cat);  // 매개 변수 체크 실패: 컴파일 오류가 발생하지 않는다.

        // 문제2: 개 타입 반환, 캐스팅 필요
        dogHospital.set(dog);
        Dog biggerDog = (Dog) dogHospital.bigger(new Dog("이브", 350));
        System.out.println("biggerDog = " + biggerDog);
    }
}

/*
동물 이름: 아르
동물 크기: 300
멍멍
동물 이름: 에단
동물 크기: 200
야옹
biggerDog = Animal{name='이브', size=350}
*/

AnimalHospitalV1 클래스에서 사용되는 checkUp(), bigger() 메서드와 그 안의 animal.getName(), animal.getSize(), animal.sound() 같은 메서드들은 모두 Animal 타입에서 제공되고 있는 메서드이므로, 보다시피 호출해도 아무 문제가 없다. 성공적으로 코드의 중복을 제거했다.

다만, dogHospital.bigger(new Dog("이브", 350)) 부분을 보면, 반환 타입이 Animal인데 Dog타입인 biggerDog에 쑤셔 넣는다? 불가능하다… 따라서 반드시 다운 캐스팅을 해줘야 컴파일 오류가 발생하지 않는다. 추가로, 개발자가 실수로 개를 반환해야 하는 상황에서 고양이를 입력한다면 캐스팅 예외가 발생할 위험이 있다.

 

🎭 제네릭 도입

그럼 제네릭을 도입해보자. 전에 학습했듯이 제네릭을 사용하면 코드 안전성은 물론이고, 코드의 중복까지 잡을 수 있었다.

package generic.test.ex3;

public class AnimalHospitalV2<T> {

    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    public void checkUp() {
        // 메서드를 정의하는 시점에는 T의 타입을 알 수 없다. Object의 기능만 사용 가능하다.
        animal.toString();
        animal.equals(null);

//        System.out.println("동물 이름: " + animal.getName());
//        System.out.println("동물 크기: " + animal.getSize());
//        animal.sound();
    }

    public T bigger(T target) {
        // 컴파일 오류
//        return animal.getSize() > target.getSize() ? animal : target;
        return null;
    }
}

결론부터 말하면, 컴파일 오류가 발생한다.

위와 같이 제네릭 타입을 선언하면 자바 컴파일러는 제네릭 타입(T)에 뭐가 들어올지 알 수가 없다. 지금 이 상황에서는 당연히 Animal 타입이 들어오길 기대했지만, 코드 어디에서도 Animal에 대한 정보를 찾을 수 없다.

// 자바 컴파일러: 어떤 타입이 들어온다는겨...?
AnimalHospitalV2<Dog> dogHospital = new AnimalHospitalV2<>();  // 이거?
AnimalHospitalV2<Cat> catHospital = new AnimalHospitalV2<>();  // 이거?
AnimalHospitalV2<Integer> integerHospital = new AnimalHospitalV2<>();  // 이거?
AnimalHospitalV2<Object> objectHospital = new AnimalHospitalV2<>();  // 이거?

그렇기 때문에 자바 컴파일러는 일단 TObject 타입으로 가정한다. 그래서 Object의 기능만 사용 가능한 것이다. 그리고 위의 예시처럼 막말로 Integer 타입이나 Object와 같은 동물과는 아무 관련이 없는 타입이 들어와도 아무 문제가 없다. 최소한 Animal과 관련된 타입으로 제한할 필요성이 있는 것이다.

 

🚧 타입 매개 변수를 관련된 타입으로 제한

따라서 아래 코드처럼 처리하면 된다.

package generic.test.ex3;

import generic.animal.Animal;

public class AnimalHospitalV3<T extends Animal> {

    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    public void checkUp() {
        // 컴파일 오류가 나지 않음
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public T bigger(T target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

<T extends Animal>와 같이 AnimalAnimal의 자식들만 받도록 상한을 막아버리면 된다. 위의 코드를 보면, Animal과 관련된 메서드를 사용해도 컴파일 오류가 발생하지 않는 것을 볼 수 있다. 이런 식으로 자바 컴파일러는 T에 입력될 수 있는 값의 범위를 예측할 수 있다.

 

package generic.test.ex3;

import generic.animal.Cat;
import generic.animal.Dog;

public class AnimalHospitalMainV3 {
    public static void main(String[] args) {

        AnimalHospitalV3<Dog> dogHospital = new AnimalHospitalV3();
        AnimalHospitalV3<Cat> catHospital = new AnimalHospitalV3();

        Dog dog = new Dog("아르", 300);
        Cat cat = new Cat("에단", 200);

        // 개 병원
        dogHospital.set(dog);
        dogHospital.checkUp();

        // 고양이 병원
        catHospital.set(cat);
        catHospital.checkUp();

        dogHospital.set(dog);
        Dog biggerDog = dogHospital.bigger(new Dog("이브", 350));  // 다운 캐스팅 필요 없음
        System.out.println("biggerDog = " + biggerDog);
    }
}

/*
동물 이름: 아르
동물 크기: 300
멍멍
동물 이름: 에단
동물 크기: 200
야옹
biggerDog = Animal{name='이브', size=350}
*/

AnimalHospitalV3<Dog> dogHospital = new AnimalHospitalV3()처럼 타입에 Dog가 들어왔네? DogAnimal의 자식인가…? 맞네! Dog로 다 바꾸자. AnimalHospitalV3<Cat> catHospital = new AnimalHospitalV3()에서의 CatAnimal의 자식이네. ㅇㅋ 통과… 그리고 dogHospital.bigger(new Dog("이브", 350))는 어차피 Dog로 반환되기 때문에 다운 캐스팅도 할 필요가 없다.

근데 만약 Animal과 아무 관련이 없는, 예를 들어 Integer 타입이 들어온다?? 아래처럼 바로 컴파일 오류로 꾸짖어 준다.

이로써 실수로 타입을 잘못 반환해도 컴파일 오류가 바로 알 수 있고, 다운 캐스팅도 할 필요 없어졌으며, 캐스팅 예외도 해결할 수 있다. 추가로, 타입 매개 변수의 상한을 막아 관련된 타입만 들어올 수 있게 처리했다.

“제네릭에 타입 매개 변수의 상한을 제한해서 타입 안전성을 지키면서도 상위 타입의 원하는 기능까지 사용할 수 있게 된다.”


☄ 제네릭 메서드

제네릭 메서드는 해당 메서드를 호출하기 직전에 제네릭 타입을 딱 결정하고 호출하는 것이다. 아래 코드를 보자.

package generic.test.ex4;

public class GenericMethod {

    public static Object objectMethod(Object object) {
        System.out.println("Object print: " + object);
        return object;
    }

    public static <T> T genericMethod(T t) {
        System.out.println("Generic print: " + t);
        return t;
    }

    public static <T extends Number> T numberMethod(T t) {
        System.out.println("Bound print: " + t);
        return t;
    }
}
package generic.test.ex4;

public class MethodMain1 {
    public static void main(String[] args) {

        Integer i = 10;
        Object object = GenericMethod.objectMethod(i);

        // 타입 인자(Type Argument) 명시적 전달
        System.out.println("명시적 타입 인자 전달");
        Integer result = GenericMethod.<Integer>genericMethod(i);  // 타입 인자는 생략 가능

        Integer integerValue = GenericMethod.<Integer>numberMethod(10);  // 타입 인자는 생략 가능
        Double doubleValue = GenericMethod.<Double>numberMethod(30.0);  // 타입 인자는 생략 가능

    }
}

/*
Object print: 10
명시적 타입 인자 전달
Object print: 10
result = 10
bound print: 10
bound print: 30.0
*/

GenericMethod.<Integer>genericMethod(i) 부분을 보면, GenericMethodTInteger로 바뀌어서 실행되는 것을 볼 수 있다. + <Integer>는 타입 추론에 의해 생략 가능하다.

 

🤔 제네릭 타입 vs 제네릭 메서드

  • 제네릭 타입
    • 정의: GenericClass<T> 처럼 클래스나 인터페이스 옆에 <> 안에 정의한다.
    • 타입 인자 전달: 객체를 생성하는 시점에 타입 인자가 결정된다.
  • 제네릭 메서드
    • 정의: <T> T genericMethod(T t)처럼 메서드 앞부분에 <T>를 명시해서 난 제네릭 메서드라는 걸 외쳐준다.
    • 타입 인자 전달: 메서드를 호출하는 시점에 결정하고 호출한다.

 

이처럼 제네릭 메서드의 핵심은 메서드를 호출하는 시점에 타입 인자를 전달해서 타입을 지정하는 것이다. 그리고 제네릭 메서드는 인스턴스 메서드static 메서드에 모두 적용할 수 있다.

class Box<T> {
	static <V> V staticMethod2(V t) {}  // static 메서드에 제네릭 메서드 도입
	<Z> Z instanceMethod2(Z z) {}  // 인스턴스 메서드에 제네릭 메서드 도입 가능
}

 

🤔 참고 사항

제네릭 타입은 static 메서드에 타입 매개 변수를 사용할 수 없다. 제네릭 타입은 객체를 생성하는 시점에 타입이 정해진다고 했다. 근데 static 메서드는 인스턴스 단위가 아니라 클래스 단위로 작동하기 때문에 제네릭 타입과는 무관하다. 따라서 static 메서드에 제네릭을 도입하려면 제네릭 메서드를 사용해야 한다.

class Box<T> {
	T instanceMethod(T t) {}  // 가능
	static T staticMethod1(T t) {}  // 제네릭 타입의 T 사용 불가능
}

위와 같이 인스턴스 메서드에는 쓸 수 있는데, 객체 생성과 아무 관련이 없는 static 메서드에서는 사용할 수 없다.

 

🤔 타입 매개 변수 제한

제네릭 메서드도 타입 매개 변수를 제한할 수 있다. 아래 코드 타입 매개 변수를 Number로 제한한 것이다.

public static <T extends Number> T numberMethod(T t) {}

// GenericMethod.numberMethod("hello");  // Number의 자식이 아니므로 컴파일 오류...

이렇게 하면 NumberInteger, Double, Long과 같은 Number의 자식만 받을 수 있다.


🖋 제네릭 메서드 활용

아까 제네릭 타입으로 만들었던 AnimalHospitalV3의 주요 기능을 제네릭 메서드로 다시 만들어보자.

package generic.test.ex4;

import generic.animal.Animal;

public class AnimalMethod {

    // 제네릭 메서드로 재정의
    public static <T extends Animal> void checkUp(T t) {
        System.out.println("동물 이름: " + t.getName());
        System.out.println("동물 크기: " + t.getSize());
        t.sound();
    }

	// 제네릭 메서드로 재정의
    public static <T extends Animal> T bigger(T t1, T t2) {
        return t1.getSize() > t2.getSize() ? t1 : t2;
    }
}
package generic.test.ex4;

import generic.animal.Cat;
import generic.animal.Dog;

public class MethodMain2 {
    public static void main(String[] args) {

        Dog dog = new Dog("아르", 200);
        Cat cat = new Cat("에단", 150);

        AnimalMethod.checkUp(dog);
        AnimalMethod.checkUp(cat);

        Dog biggerDog = new Dog("이브", 300);
        Dog bigger = AnimalMethod.bigger(dog, biggerDog);  // Dog와 Cat을 비교하는 건 불가능
        System.out.println("bigger = " + bigger);
    }
}

/*
동물 이름: 아르
동물 크기: 200
멍멍
동물 이름: 에단
동물 크기: 150
야옹
bigger = Animal{name='이브', size=300}
*/

static 메서드제네릭 메서드만 적용할 수 있지만, 인스턴스 메서드는 제네릭 타입도 제네릭 메서드도 둘 다 적용할 수 있다. 그렇다면, 만약 제네릭 타입과 제네릭 메서드의 타입 매개 변수를 같은 이름으로 사용하면 어떻게 될까?

package generic.test.ex4;

import generic.animal.Animal;

// 제네릭 타입 설정
public class ComplexBox<T extends Animal> {

    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    // 제네릭 메서드 설정
    public <T> T printAndReturn(T t) {
        System.out.println("animal.className: " + animal.getClass().getName());
        System.out.println("t.className: " + t.getClass().getName());
        return t;
    }
}
package generic.test.ex4;

import generic.animal.Animal;
import generic.animal.Cat;
import generic.animal.Dog;

public class MethodMain3 {
    public static void main(String[] args) {

        Dog dog = new Dog("아르", 200);
        Cat cat = new Cat("에단", 150);

        ComplexBox<Dog> hospital = new ComplexBox<>();
        hospital.set(dog);

        Cat returnCat = hospital.printAndReturn(cat);
        System.out.println("returnCat = " + returnCat);
    }
}

/*
animal.className: generic.animal.Dog
t.className: generic.animal.Cat
returnCat = Animal{name='에단', size=150}
*/

결론부터 말하자면, 제네릭 타입보다 제네릭 메서드가 높은 우선순위를 가진다. 따라서 위의 printAndReturn()은 제네릭 타입과는 무관하며 제네릭 메서드가 적용된다. 그리고 위에서 적용된 제네릭 메서드의 타입 매개 변수(T)는 현재 상한이 없다. 따라서 Object 타입으로 취급되며, t.getName()과 같은 Animal에 있는 기능은 사용할 수 없다. 이처럼 둘의 이름이 겹치면 그냥 둘 중 하나를 다른 이름으로 변경하는 것이 속 편하다.


🃏 와일드 카드

와일드 카드를 사용하면 제네릭 타입을 좀 더 편리하게 사용할 수 있다. 와일드 카드는 "여러 타입이 들어올 수 있는 특수문자" 를 말한다.

package generic.test.ex5;

public class Box<T> {

    private T value;

    public void set(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}
package generic.test.ex5;

import generic.animal.Animal;

public class WildcardEx {

    static <T> void printGenericV1(Box<T> box) {
        System.out.println("T = " + box.get());
    }

    // 와일드 카드
    static void printWildcardV1(Box<?> box) {
        System.out.println("? = " + box.get());
    }

    static <T extends Animal> void printGenericV2(Box<T> box) {
        T t = box.get();
        System.out.println("이름: " + t.getName());
    }

    // 와일드 카드
    static void printWildcardV2(Box<? extends Animal> box) {
        Animal animal = box.get();
        System.out.println("이름: " + animal.getName());
    }

    static <T extends Animal> T printAndReturnGeneric(Box<T> box) {
        T t = box.get();
        System.out.println("이름: " + t.getName());
        return t;
    }

    // 와일드 카드
    static Animal printAndReturnWildcard(Box<? extends Animal> box) {
        Animal animal = box.get();
        System.out.println("이름: " + animal.getName());
        return animal;
    }
}

제네릭 메서드는 나중에 제네릭 타입(T)이 DogCat으로 바뀌는 복잡한 과정을 거치지만, 와일드 카드의 경우, 일반 메서드에 그냥 매개 변수를 받아서 사용한다. 대신, ?에 아무거나 들어올 수 있다.

 

package generic.test.ex5;

import generic.animal.Animal;
import generic.animal.Cat;
import generic.animal.Dog;

public class WildcardMain1 {
    public static void main(String[] args) {

        Box<Object> objectBox = new Box<>();
        Box<Dog> dogBox = new Box<>();
        Box<Cat> catBox = new Box<>();

        dogBox.set(new Dog("아르", 200));

        // 인자에 dogBox를 넣음으로써 타입 추론 가능
        WildcardEx.printGenericV1(dogBox);
        WildcardEx.printWildcardV1(dogBox);
        WildcardEx.printGenericV2(dogBox);
        WildcardEx.printWildcardV2(dogBox);
        Dog dog = WildcardEx.printAndReturnGeneric(dogBox);
        Animal animal = WildcardEx.printAndReturnWildcard(dogBox);

    }
}

/*
T = Animal{name='아르', size=200}
? = Animal{name='아르', size=200}
이름: 아르
이름: 아르
이름: 아르
이름: 아르
*/

 

“와일드 카드는 제네릭 타입이나, 제네릭 메서드를 선언하는 것이 아니다. 와일드 카드는 이미 만들어진 제네릭 타입을 단순하게 활용할 때 사용한다.”

 

<비제한 와일드 카드>

// 제네릭 메서드
// Box<Dog> dogBox를 전달한다. 타입 T는 Dog가 되는 것이다.
static <T> void printGenericV1(Box<?> box) {
	System.out.println("T = " + box.get());
}

//  일반적인 메서드
//  Box<Dog> dogBox를 전달한다. 와일드 카드 ?는 모든 타입을 받을 수 있다.
static void printWildcardV1(Box<?> box) {
	System.out.println("? = " + box.get());
}

위의 두 메서드는 비슷한 기능을 하는 코드이지만, 첫번째는 제네릭 메서드, 두번째는 일반적인 메서드에 와일드 카드를 사용했다. 앞서 말했다시피 와일드 카드는 제네릭 타입이나 제네릭 메서드를 정의할 때 사용하는 것이 아닌, Box<Dog>, Box<Cat>처럼 타입 인자가 정해진 제네릭 타입을 전달 받아서 활용할 때 사용한다. 아무튼 위처럼 ?만 사용해서 제한 없이 모든 타입을 다 받을 수 있는 와일드 카드를 “비제한 와일드 카드” 라고 한다.

 

<제네릭 메서드 실행 예시>

// 1. 전달
printGenericV1(dogBox)

// 2. 제네릭 타입 결정 dogBox는 Box<Dog> 타입, 타입 추론 -> T의 타입은 Dog
static <T> void printGenericV1(Box<T> box) {
	System.out.println("T = " + box.get());
}

// 3. 타입 인자 결정
static <Dog> void printGenericV1(Box<Dog> box) {
	System.out.println("T = " + box.get());
}

// 4. 최종 실행 메서드
static void printGenericV1(Box<Dog> box) {
	System.out.println("T = " + box.get());
}

 

<와일드 카드 실행 예시>

// 1. 전달
printWildcardV1(dogBox)

// 이것은 제네릭 메서드가 아닌, 일반적인 메서드다.
// 2. 최종 실행 메서드, 와일드 카드 "?"는 모든 타입을 받을 수 있다.
static void printWildcardV1(Box<?> box) {
	System.out.println("? = " + box.get());	
}

printGenericV1() 제네릭 메서드를 보면, 타입 매개 변수가 존재한다. 그리고 특정 시점에 타입 매개 변수에 타입 인자를 전달해서 타입을 결정해야 한다. 반면, printWildcardV1()을 보면, 와일드 카드는 일반적인 메서드에 사용할 수 있고 단순히 매개 변수로 제네릭 타입을 받는 것 뿐이다. 와일드 카드는 제네릭 메서드처럼 복잡하게 작동하지 않는다. 그냥 일반 메서드에 제네릭 타입을 받을 수 있는 매개 변수가 하나 있다고 생각하면 편하다. 따라서 제네릭 타입이나 제네릭 메서드를 꼭 사용해야 하는 상황이 아니라면, 와일드 카드의 사용이 권장된다.

 

🌫 상한 와일드 카드

static <T extends Animal> void printGenericV2(Box<T> box) {
	T t = box.get();
	System.out.println("이름 = " + t.getName());
}

static void printWildcardV2(Box<? extends Animal> box) {
	Animal animal = box.get();
	System.out.println("이름 = " + animal.getName());
}

와일드 카드도 제네릭 메서드와 마찬가지로, 상한을 제한할 수 있다. 위의 코드에서는 <? extends Animal>로 지정했다. 이제 Animal과 그 자식 타입만 입력을 받으며, 다른 타입이 오면 컴파일 오류가 발생한다. 아무튼 box.get()을 통해 꺼낼 수 있는 타입의 최대 부모는 Animal이 되고, Animal 타입의 기능을 호출할 수 있다.

 

🤔 그럼 제네릭 메서드가 왜 필요한거지?

와일드 카드만으로는 안 될 때가 있다. 와일드 카드는 제네릭을 정의할 때 사용하는 것이 아니라고 했다. Box<Dog>, Box<Cat>처럼 타입 인자가 전달된 제네릭 타입을 활용할 때 사용하는데, 아래와 같은 문제에 처할 때가 있다.

static <T extends Animal> T printAndReturnGeneric(Box<T> box) {
	T t = box.get();
	System.out.println("이름 = " + t.getName());
	return t;
}

static Animal printAndReturnWildcard(Box<? extends Animal> box) {
	Animal animal = box.get();
	System.out.println("이름 = " + animal.getName());
	return animal;
}

printAndReturnGeneric(Box<T> box)의 경우, Dog 타입으로 명확하게 반환받을 수 있는 반면, printAndReturnWildcard(Box<? extends Animal> box)의 경우에는 전달한 타입을 명확하게 반환할 수 없다. 여기서는 Animal 타입으로 반환한다. 어쩔 수 없이 다운 캐스팅이 필요하다.

 

이처럼 메서드의 타입들을 특정 시점에 변경하려면 제네릭 타입이나, 제네릭 메서드를 사용해야 한다. 와일드 카드는 이미 만들어진 제네릭 타입을 전달 받아서 활용할 때 사용된다. 따라서 메서드의 타입들을 타입 인자를 통해 변경할 수 없다. 정리하자면, 제네릭 타입이나 제네릭 메서드가 반드시 필요한 상황이라면 <T>를 사용하고, 그렇지 않은 상황이면 와일드 카드를 사용하는 것을 권장한다.

 

🎢 하한 와일드 카드

제네릭 타입/메서드와는 달리, 와일드 카드는 상한 뿐만 아니라 하한도 정할 수 있다. 아래 코드를 보자.

package generic.test.ex5;

import generic.animal.Animal;
import generic.animal.Cat;
import generic.animal.Dog;

public class WildcardMain2 {
    public static void main(String[] args) {

        Box<Object> objectBox = new Box<>();
        Box<Animal> animalBox = new Box<>();
        Box<Dog> dogBox = new Box<>();
        Box<Cat> catBox = new Box<>();

        // Animal 포함 상위 타입 전달 가능
        writeBox(objectBox);
        writeBox(animalBox);
//        writeBox(dogBox);
//        writeBox(catBox);

    }

	// "?" 가 Animal보다 높아야 한다.
    static void writeBox(Box<? super Animal> box) {
        box.set(new Dog("이브", 300));
    }
}

위처럼 Animal의 하위 타입들을 입력하면 컴파일 오류가 발생하는 것을 확인할 수 있다.


💨 타입 이레이저

제네릭은 컴파일 단계에서만 사용되고, 컴파일 이후에는 제네릭 정보가 사라진다. 제네릭에 사용한 타입 매개 변수가 모두 사라진다는 말이다. 대략적인 코드로 이해해보자.

// GenericBox.java
public class GenericBox<T> {
		
	private T value;
		
	public void set(T value) {
		this.value = value;
	}
		
	public T get() {
		return value;
	}
}
// Main.java
void main() {
	GenericBox<Integer> box = new GenericBox<Integer>();
	box.set(10);
	Integer result = box.get();
}

이렇게 하면 컴파일러는 컴파일 시점에 타입 매개 변수와 타입 인자를 포함한 제네릭 정보를 활용해서 new GenericBox<Integer>()에 대해 아래와 같이 이해한다.

public class GenericBox<Integer> {
		
	private Integer value;
		
	public void set(Integer value) {
		this.value = value;
	}
		
	public Integer get() {
		return value;
	}
}

 

이제 컴파일이 모두 끝나면 자바는 제네릭과 관련된 정보를 삭제한다. 아래 바이트 코드에 생성된 정보를 살펴보자.

// GenericBox.class
public class GenericBox {
		
	private Object value;
		
	// 상한 제한 없이 선언했기 때문에 T는 Object로 변환
	public void set(Object value) {
		this.value = value;
	}
		
	public Object get() {
		return value;
	}
}
// Main.class (바이트 코드)
void main() {
	GenericBox box = new GenericBox();
	box.set(10);
	Integer result = (Integer) box.get();  // 컴파일러가 캐스팅 추가
}

위처럼 Object 타입의 box10을 넣는다고 치자. 넣는 건 문제가 아니다. 값을 불러올 때가 문제인 것이다. 결론부터 말하면, 값을 반환 받는 부분은 Object로 받으면 안 된다. 따라서 컴파일러는 제네릭에서 타입 인자로 지정한 Integer로 캐스팅하는 코드를 추가해준다.

 

그럼 아래와 같이 타입 매개 변수를 제한해보자.

<컴파일 전>

// AnimalHospitalV3.java
package generic.test.ex3;

import generic.animal.Animal;

public class AnimalHospitalV3<T extends Animal> {

    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    public void checkUp() {
        // 컴파일 오류가 나지 않음
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public T bigger(T target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

T의 타입 정보가 제거된다 하더라도 상한으로 지정한 Animal 타입으로 대체되기 때문에 Animal 타입의 기능을 사용하는데 아무 문제가 없다.

 

AnimalHospitalV3 hospital = new AnimalHospitalV3();
...
Dog dog = (Dog) animalHospitalV3.bigger(new Dog());

사용 예시 코드를 보면, 반환 받는 부분을 Animal로 받으면 캐스팅 예외가 발생하기 때문에 컴파일러가 타입 인자로 지정한 Dog로 캐스팅하는 코드를 넣어준다.

제네릭은 쉽게 생각하면, 개발자가 직접 캐스팅해야 할 코드를 컴파일러가 대신 처리해주는 것이다.자바는 컴파일 시점에 제네릭을 사용한 코드에 문제가 없는지 완벽하게 검증하기 때문에 컴파일러가 추가하는 다운 캐스팅에는 문제가 발생하지 않는다. 이처럼 자바의 제네릭 타입은 컴파일 시점에만 존재하고, 런타임 시에는 제네릭 정보가 지워지는데, 이것을 “타입 이레이저” 라고 부르는 것이다.

 

💥 타입 이레이저의 한계

컴파일 이후에는 제네릭 정보가 존재하지 않는다고 했다. 따라서 런타임에 타입을 활용하는 아래와 같은 코드들은 사용할 수 없다.

class EraserBox<T> {
		
	public boolean instanceCheck(Object param) {
		return param instanceof T;
	}
		
	public T create() {
		return new T();
	}
}

 

<런타임에 사용 시>

class EraserBox {
		
	public boolean instanceCheck(Object param) {
		return param instanceof Object;  // 오류
	}
		
	public T create() {
		return new Object();  // 오류
	}
}

 

이처럼 타입 매개 변수는 런타임에 모두 Object 타입이 된다. instanceof는 항상 Object와 비교하게 되는데 그럼 항상 참이 반환될 것이다. 그렇기 때문에 자바는 타입 매개 변수에 instanceof를 허용하지 않는다. 그리고 new T는 항상 new Object가 된다. 이는 개발자의 의도와 아예 다르다. 따라서 타입 매개 변수에는 new도 허용하지 않는 것이다.

profile
도메인을 이해하는 백엔드 개발자(feat. OOP)

0개의 댓글