[제네릭] 타입 매개변수 상한 이해하기

문상우·2024년 9월 21일
0

JAVA

목록 보기
2/2
post-thumbnail

Java 문법에서 많이 사용되는 Generic은 타입을 사용자가 원하는 시점에 원하는 타입으로 할당해서 사용할 수 있다는 장점이 있습니다. 이때, Generic<? extends Object> 표현을 많이 보셨을 텐데요, 이 표현이 정확히 왜 필요한지를 알아봅니다.

참고
김영한의 실전 자바 - 중급 2편

예시를 보면서 이해하는게 더 쉬으므로 김영한님께서 강의에서 사용하신 예시를 통해 설명해보겠습니다.

동물, 강아지, 고양이

먼저 동물 클래스를 생성합니다.

public class Animal {

    private String name;
    private int size;

    public Animal(String name, int size) {
        this.name = name;
        this.size = size;
    }

    public String getName() {
        return name;
    }

    public int getSize() {
        return size;
    }

    public void sound() {
        System.out.println("동물 울음 소리");
    }

    @Override
    public String toString() {
        return "Animal{" +
                "name='" + name + '\'' +
                ", size=" + size +
                '}';
    }

}
  • name, size 필드를 가지며, 이는 getter를 이용하여 외부로 반환될 수 있습니다.
  • sound() 메서드를 가지며 이는 "동물 울음 소리"를 출력합니다.
  • Object의 toString() 메서드를 오버라이딩 하여 toString() 호출 시 해당 클래스의 필드를 출력하도록 변경합니다.

다음은 동물을 상속받는 강아지와 고양이를 구현해보겠습니다.

public class Dog extends Animal {

    public Dog(String name, int size) {
        super(name, size);
    }

    @Override
    public void sound() {
        System.out.println("멍멍");
    }
    
}
public class Cat extends Animal {

    public Cat(String name, int size) {
        super(name, size);
    }

    @Override
    public void sound() {
        System.out.println("냐옹");
    }

}
  • Animal의 생성자를 이용하여 name, size의 필드를 가집니다.
  • Animal의 sound() 메서드를 오버라이드하여 동물 별로 다른 울음 소리를 출력합니다.

동물 병원

이번에는 동물 병원을 만들어보겠습니다.

요구사항 : 강아지 병원은 강아지만 치료할 수 있고, 고양이 병원은 고양이만 치료할 수 있습니다.

강아지 병원

public class DogHospital {

    private Dog animal;

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

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

    public Dog getBigger(Dog target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
    
}

고양이 병원

public class CatHospital {

    private Cat animal;

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

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

    public Cat getBigger(Cat target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
    
}
  • 각 병원 별로 알맞는 타입을 필드로 가집니다. 필드는 setter를 통해 전달받습니다.
  • checkup: 필드로 갖는 이름과 크기, 동물 별로 다른 짖는 소리를 출력합니다.
  • getBigger: 사이즈를 비교하여 더 큰 동물을 필드로 지정합니다.

테스트 코드를 작성해봅니다.

public class AnimalHospitalMainV0 {

    public static void main(String[] args) {
        DogHospital dogHospital = new DogHospital();
        CatHospital catHospital = new CatHospital();

        Dog dog = new Dog("강아지1", 100);
        Cat cat = new Cat("고양이1", 300);

        // 강아지 병원
        dogHospital.setAnimal(dog);
        dogHospital.checkup();

        // 고양이 병원
        catHospital.setAnimal(cat);
        catHospital.checkup();
    }
}

실행결과

> Task :AnimalHospitalMainV0.main()
동물 이름: 강아지1
동물 크기: 100
멍멍
동물 이름: 고양이1
동물 크기: 300
냐옹

위처럼 코드를 작성하게 되면 두 가지 특징을 가집니다.


  1. 장점 : 타입의 안정성이 지켜집니다.

    // case 1
    dogHospital.setAnimal(cat); // 다른 타입 입력시 컴파일 오류가 발생합니다.
    
    // case 2
    dogHospital.setAnimal(dog);
    Dog biggerDog = dogHospital.getBigger(new Dog("강아지2", 200));
    
    System.out.println("biggerDog: " + biggerDog);
    // 코드를 의도한대로 더 큰 강아지를 출력할 수 있습니다.
  2. 단점 : 코드의 재사용성이 떨어집니다.

    강아지 병원과 고양이 병원은 중복이 많습니다. 만약 새로운 동물이 추가되는 경우에 병원의 기능이 모두 똑같은 경우에도 그 동물만을 위한 병원을 만들어줘야 합니다.
    ex) class RabbitHospital { }

코드를 개선하기 위해 자바에서 많이 사용하는 다형성을 이용해보겠습니다.


다형성을 이용한 동물 병원

Dog, Cat은 Animal 이라는 부모 타입이 있으므로 다형성을 사용해 중복을 제거해봅니다.

public class AnimalHospitalV1 {

    private Animal animal;

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

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

    public Animal getBigger(Animal target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }

}
  • 다형성을 이용해 Dog, Cat을 담을 수 있는 Animal을 필드로 가집니다.

이제는 우리가 만든 재사용성이 좋은 코드를 테스트 코드에 적용시켜봅니다.

public class AnimalHospitalMainV1 {

    public static void main(String[] args) {
        AnimalHospitalV1 dogHospital = new AnimalHospitalV1();
        AnimalHospitalV1 catHospital = new AnimalHospitalV1();

        Dog dog = new Dog("강아지1", 100);
        Cat cat = new Cat("고양이1", 300);

        // 강아지 병원
        dogHospital.setAnimal(dog);
        dogHospital.checkup();

        // 고양이 병원
        catHospital.setAnimal(cat);
        catHospital.checkup();
    }
}

실행 결과

> Task :AnimalHospitalMainV1.main()
동물 이름: 강아지1
동물 크기: 100
멍멍
동물 이름: 고양이1
동물 크기: 300
냐옹

위 코드의 문제점을 살펴보겠습니다.

// case 1
dogHospital.setAnimal(cat) // 의도와 다른 타입이 들어갔음에도 컴파일 오류가 발생하지 않습니다.

// case 2
dogHospital.setAnimal(dog);
Dog biggerDog = (Dog) dogHospital.getBigger(new Dog("강아지2", 200));
// getBigger의 반환값은 Animal이기 때문에 강제 캐스팅이 필요합니다.

System.out.println("biggerDog: " + biggerDog);

이처럼 다형성을 이용해 코드를 작성하는 경우 우리가 의도하지 못한 상황이 발생할 수도 있습니다.
case2의 경우 강제 캐스팅 하는 대상이 예상하지 못한 타입이라면 치명적인 에러를 발생시킬 수도 있습니다.

  • 코드의 재사용성은 증가했습니다.
  • 타입 안정성은 지켜지지 않았습니다.

어떻게 해야 코드 재사용성을 증가시키면서 타입 안정성도 가져갈 수 있을까요 ?


그 답은 제네릭에 있습니다.


제네릭을 이용한 동물 병원

특정 클래스 안에서 사용할 파라미터의 타입을 인스턴스 생성 시점까지 지연시킬 수 있는 지네릭을 사용해서 동물 병원을 구현해봅니다.

제네릭을 사용해 이처럼 코드를 변경하니, Animal의 메서드를 사용하려고 하자 컴파일 에러가 발생합니다.
컴파일 에러가 발생하지 않더라도 이렇게 설계된다면 우리가 의도했던 동물 병원이 아닌 정수(Integer) 병원, 실수(Double) 병원, 문자열(String) 병원이 될 수도 있습니다. 아래처럼요.

AnimalHospitalV2<Integer> integerHospital = new AnimalHospitalV2<>();
AnimalHospitalV2<Double> doubleHospital = new AnimalHospitalV2<>();
AnimalHospitalV2<String> stringHospital = new AnimalHospitalV2<>();

이런 문제를 막기 위해, 그리고 개발자의 의도를 제네릭에 녹여낼 수 있도록 제네릭은 하나의 기능을 제공합니다.


제네릭 타입 매개변수 상한

제네릭의 타입 매개변수 상한이란, 제네릭에 지정할 수 있는 타입을 특정 타입으로 제한할 수 있음을 말합니다.

우리는 위에서 Animal 또는 Animal을 상속받은 Dog, Cat만 제네릭에 적용되길 원했습니다. 이런 경우에 우리는 타입 매개변수를 Animal로 제한하면 됩니다.

public class AnimalHospitalV3<T extends Animal> {

    private T animal;

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

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

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

}

여기서의 핵심은 입니다. 이는 말 자체로 T는 Animal을 상속한 타입만 들어올 수 있다는 말과 같고, 여기에는 Animal 자체도 들어올 수 있습니다.

// 사용 가능한 T 인자
AnimalHospitalV3<Animal>
AnimalHospitalV3<Dog>
AnimalHospitalV3<Cat>

이제 Java의 컴파일러는 위 코드를 보면서 T에 입력될 수 있는 값의 범위를 예측할 수 있습니다. 말인 즉, Animal 또는 Animal을 상속한 자식들만 들어올 수 있음을 미리 알고 있기 때문에, Animal이 갖고 있는 메서드인 getName(), getSize(), sound() 메서드를 사용해도 컴파일 에러를 발생시키지 않을 수 있습니다.


최종 테스트를 진행해봅니다.

public class AnimalHospitalMainV2 {

    public static void main(String[] args) {
        AnimalHospitalV3<Dog> dogHospital = new AnimalHospitalV3<>();
        AnimalHospitalV3<Cat> catHospital = new AnimalHospitalV3<>();

        Dog dog = new Dog("강아지1", 100);
        Cat cat = new Cat("고양이1", 300);

        // 강아지 병원
        dogHospital.setAnimal(dog);
        dogHospital.checkup();

        // 고양이 병원
        catHospital.setAnimal(cat);
        catHospital.checkup();
    }
}

실행결과

> Task :AnimalHospitalMainV2.main()
동물 이름: 강아지1
동물 크기: 100
멍멍
동물 이름: 고양이1
동물 크기: 300
냐옹

원하던 결과를 얻을 수 얻었습니다.


전에 문제가 되던 case를 살펴보겠습니다.

// case 1
dogHospital.setAnimal(cat); 

// case 2
dogHospital.setAnimal(dog);
Dog biggerDog = dogHospital.getBigger(new Dog("멍멍이2", 200));

Case 1

  • 강아지 병원에 고양이를 전달할 수 있었던 타입 안정성 문제를 해결했습니다.

Case 2

  • Animal 타입을 반환하여 강제 캐스팅을 해야 하는 문제를 해결했습니다.
  • 실수로 Cat을 넣어 강제 캐스팅 시에 발생하던 예외를 막을 수 있습니다.

제네릭 도입 문제

  • 타입 매개변수를 통해 어떤 타입이든 들어오던 문제를 해결했습니다.
  • 어떤 타입이 들어올지 몰라 개발자가 T에 들어올 것이라고 예상되는 타입의 내부 메서드를 사용하는 경우 컴파일 에러를 발생시키던 상황을 해결했습니다.

결론

우리는 제네릭을 특정 클래스 안에서 사용할 파라미터의 타입을 인스턴스 생성 시점에 지정하고 사용하기 위해 이용합니다. 이러한 장점을 살려 코드 재사용성을 높일 수 있으며, 추가로 타입 매개변수 상한을 사용하게 되면 일반 제네릭을 사용할 때 지켜지지 못할 수도 있는 타입 안정성을 지키며 개발할 수 있습니다.
사용할 타입이 명확한 경우에는 타입 매개변수 상한을 사용하여 타입 안정성을 지키며 코드 재사용성을 높이는 개발을 해가시길 바랍니다.


우리는 제네릭 타입 매개변수 상한을 통해 제네릭과 타입 안정성이라는 두 마리의 토끼를 동시에 잡을 수 있습니다. - 김영한

profile
평범한 대학생의 코딩일기

0개의 댓글