JAVA의 가장 기본이 되는 내용

제네릭

cwchoiit 2024. 5. 8. 11:04
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 2편 | 김영한 - 인프런

김영한 | 자바 제네릭과 컬렉션 프레임워크를 실무 중심으로 깊이있게 학습합니다. 자료 구조에 대한 기본기도 함께 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전

www.inflearn.com

 

제네릭은 왜 필요한지부터 들어가보자. 다음과 같은 코드가 있다고 가정해보자.

 

IntegerBox

package org.example.generic;

public class IntegerBox {

    private Integer value;

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

    public Integer get() {
        return value;
    }
}

 

이건 단순히 정수형 타입의 값을 받아 저장할 수 있는 클래스이다.

 

StringBox

package org.example.generic;

public class StringBox {
    private String value;

    public String get() {
        return value;
    }

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

이건 단순히 문자열 타입의 값을 받아 저장할 수 있는 클래스이다.

 

얘네를 호출해서 사용해보는 코드를 보자.

BoxMain1

package org.example.generic;

public class BoxMain1 {
    public static void main(String[] args) {
        IntegerBox integerBox = new IntegerBox();
        integerBox.set(10);
        Integer i = integerBox.get();
        System.out.println("i = " + i);

        StringBox stringBox = new StringBox();
        stringBox.set("hello");
        String s = stringBox.get();
        System.out.println("s = " + s);
    }
}

 

실행결과

i = 10
s = hello

 

원하는 대로 코드가 동작했지만, 마음에 들지 않는다. 우선 두 클래스는 정확히 필드로 받는 값의 타입만 다르고 완전 동일하게 동작한다. 그럼 이거를 DoubleBox, BooleanBox, ... 해서 계속 만들어내기엔 너무 비효율적인거 같고 그렇다. 그래서 이걸 하나로 합칠 수 있는 가장 간단한 방법이 그 모든 타입의 부모인 Object이므로 한번 이렇게 코드를 작성해보자.

 

ObjectBox

package org.example.generic;

public class ObjectBox {
    private Object value;

    public Object get() {
        return value;
    }

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

 

이제는 어떤 타입으로 들어와도 모두 받아줄 수 있다. 다형성이 이렇게 위대하다.

 

BoxMain2

package org.example.generic;

public class BoxMain2 {
    public static void main(String[] args) {
        ObjectBox integerBox = new ObjectBox();
        integerBox.set(10);
        Integer integer = (Integer) integerBox.get();
        System.out.println("integer = " + integer);

        ObjectBox stringBox = new ObjectBox();
        stringBox.set("hello");
        String o = (String) stringBox.get();
        System.out.println("o = " + o);
    }
}

이렇게 ObjectBox를 사용하는 방법은 어떤 타입으로 들어와도 그 타입으로 다운캐스팅을 해서 사용하면 된다. 좋아보이지만 우선 두가지 문제가 있다.

 

  • 다운캐스팅이 귀찮다.
  • 잘못된 타입으로 캐스팅하는 경우 에러가 발생한다.

귀찮은거야 그렇다치고 이런 문제가 있을 수 있다.

 

문제가 되는 코드

package org.example.generic;

public class BoxMain2 {
    public static void main(String[] args) {
        ObjectBox integerBox = new ObjectBox();
        integerBox.set(10);
        Integer integer = (Integer) integerBox.get();
        System.out.println("integer = " + integer);

		// ... 엄청나게 긴 코드 라인 ... 

        integerBox.set("100");
        Integer integer2 = (Integer) integerBox.get();
        System.out.println("integer2 = " + integer2);
    }
}

보면, 엄청나게 긴 코드가 지난 후 누군가가 integerBox에 숫자가 아닌 문자를 집어넣었다. 근데 이름이 integerBox니까 또 다른 누군가는 당연하게 "이건 Integer로 다운캐스팅해야겠다!"라는 생각으로 Integer 타입으로 다운캐스팅 하는 순간 에러가 터져버린다. 

 

즉, 코드의 재사용성은 높였지만 코드의 타입 안정성이 많이 떨어져 버린것이다.

그렇다고 IntegerBox, StringBox를 사용하자니 코드의 타입 안정성이 높아지는 대신 코드의 재사용성이 너무 떨어지고 딜레마에 빠지는것이다. 

 

이 딜레마를 완벽하게 해결해주는 것이 바로 '제네릭'이다. 제네릭은 이런 이유로 탄생했다.

 

제네릭 사용

GenericBox

package org.example.generic;

public class GenericBox<T> {
    private T value;

    public T getValue() {
        return value;
    }

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

 

이 코드가 제네릭이다. 저렇게 `<T>` 라고 입력해주면 T로 들어오는 타입에 따라 필드 `value`의 타입이 결정된다. 이걸 사용할 땐 이렇게 사용하면 된다.

 

BoxMain3

package org.example.generic;

public class BoxMain3 {
    public static void main(String[] args) {
        GenericBox<Integer> integerBox = new GenericBox<>(); // 생성 시점에 타입이 결정
        integerBox.setValue(100);
        Integer integerValue = integerBox.getValue();
        System.out.println("value = " + integerValue);

        GenericBox<String> stringBox = new GenericBox<>();
        stringBox.setValue("Hello");
        String stringValue = stringBox.getValue();
        System.out.println("stringValue = " + stringValue);

        GenericBox<Double> doubleBox = new GenericBox<>();
        doubleBox.setValue(100.0);
        Double doubleValue = doubleBox.getValue();
        System.out.println("doubleValue = " + doubleValue);
    }
}

GenericBox 객체를 생성할 때 `GenericBox<Integer>`, `GenericBox<String>`, `GenericBox<Double>` 이렇게 작성해주면 이 객체의 `value` 필드의 타입이 결정된다. 

 

그러면 `setValue()` 메서드를 사용할 때 해당 타입이 아니면 컴파일 에러가 발생한다. 즉, 코드의 재사용성과 코드의 타입 안정성을 둘 다 해결했다. 제네릭의 핵심은 사용할 타입을 미리 결정하지 않는다는 점이다.

 

제네릭 용어

제네릭의 핵심은 사용할 타입을 미리 결정하지 않는것이라고 했다. 클래스 내부에서 사용하는 타입을 클래스를 정의하는 시점에 결정하는 것이 아니라 실제 사용하는 생성 시점에 타입을 결정하는 것이다. 이걸 쉽게 비유하자면 메서드의 매개변수와 인자의 관계와 비슷하다.

void customPrint(String param) {
	println(param);
}

메서드에서 필요한 값을 메서드를 정의하는 시점에 미리 결정하는 게 아니다. 실제 사용하는 시점으로 미룬다.

void main() {
    customPrint("hello");
    customPrint("hi");
}

 

그래서 위 코드처럼 실제 실행하는 시점에 "hello""hi"같은 값을 전달한다.

여기서 매개변수(Parameter)인자(Argument)의 차이를 좀 이해하면 좋겠다.

void customPrint(String param) {  <- 매개변수(Parameter)
	println(param);
}

void main() {
	customPrint("hello"); <- 인자(Argument)
}

 

  • 인자 또는 인수는 매개변수에 전달할 값을 말한다.
  • 매개변수는 메서드에서 사용할 값을 받을 때 받아줄 변수를 말한다.

 

제네릭도 앞서 말한 메서드의 매개변수와 인자의 관계와 비슷하게 작동한다. 제네릭 클래스를 정의할 때 내부에서 사용할 타입을 미리 결정하는 것이 아니라, 해당 클래스를 실제 사용하는 생성 시점에 내부에서 사용할 타입을 결정하는 것이다. 

 

대신 제네릭은 타입 매개변수타입 인자(인수)라는 표현을 사용하면 된다.

  • 타입 매개변수: GenericBox<T>에서 T
  • 타입 인자: GenericBox<Integer> integerBox = new GenericBox<>(); 에서 Integer

 

여기서 T는 정해진게 아니라 어떤 문자를 사용해도 상관은 없다. GenericBox<Hello>도 가능하다. 근데 일반적인 관례가 있다.

  • E - Element
  • K - Key
  • N - Number
  • T - Type
  • V - Value

그리고 아래처럼 한번에 여러 타입 매개변수를 선언할 수 있다.

class Data<K, V> {...}

 

타입인자로 기본형은 사용 불가하다.

타입 인자로 int, double, boolean 이런 기본형은 사용할 수 없다. 대신에 래퍼 클래스를 사용하면 된다.

 

제네릭 Raw Type

결론을 먼저 말하면 "제네릭을 사용해놓고 Raw Type을 사용하지마라"이다. Raw Type이 뭐냐면 다음 코드를 보자.

GenericBox rawBox = new GenericBox();
rawBox.setValue(100);

 

이렇게 <>로 타입 인자를 작성하지 않는 경우를 Raw Type이라고 한다. 

제네릭 타입을 사용할 때는 항상 <>를 사용해서 사용 시점에 타입을 지정해야 한다. 그럼 왜 저런 Raw Type을 지원하는 걸까? 자바의 제네릭이 자바가 처음 등장할 때부터 있던것이 아니라 자바가 오랜기간 사용된 이후에 등장했기 때문에 제네릭이 없던 시절의 과거 코드와의 호환이 필요했다. 그래서 어쩔 수 없이 이런 Raw Type을 지원한다.

 

 

그럼 이제 제네릭도 알았고 막 여기저기 제네릭을 써서 좀 더 재활용 가능하고 중복을 제거하게 코드를 막 작성하고 싶은 욕구가 생긴다.

과연 순탄할까?

 

제네릭의 문제

동물 병원을 만들어보자.

 

Animal

package org.example.generic.animal;

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 void setName(String name) {
        this.name = name;
    }

    public int getSize() {
        return size;
    }

    public void setSize(int size) {
        this.size = size;
    }

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

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

 

Dog

package org.example.generic.animal;

public class Dog extends Animal {

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

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

 

Cat

package org.example.generic.animal;

public class Cat extends Animal {

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

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

 

이렇게 Animal, Dog, Cat 클래스가 있다. Animal은 부모 클래스이고 Dog, Cat이 각각 자식 클래스로 만들어졌다.

이제 개 전용 병원과 고양이 전용 병원을 만들어보자.

 

DogHospital

package org.example.generic.ex3;

import org.example.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;
    }
}

 

CatHospital

package org.example.generic.ex3;

import org.example.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;
    }
}

 

DogHospital, CatHospitalDogCat이냐의 차이만 있고 코드가 모두 똑같이 생겼다. 벌써 막 중복을 제거하고 싶다. 일단 참고 진행해보자.

 

AnimalHospitalMainV0

package org.example.generic.ex3;

import org.example.generic.animal.Cat;
import org.example.generic.animal.Dog;

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.set(dog);
        dogHospital.checkup();

        catHospital.set(cat);
        catHospital.checkup();

        Dog biggerDog = dogHospital.bigger(new Dog("멍멍2", 200));
        System.out.println("biggerDog = " + biggerDog);
    }
}

 

이렇게 DogHospital, CatHospital을 나눠 만들었기 때문에 혹여나 DogHospitalCat 타입을 넣으려고 해도 컴파일 에러가 발생하고 반대도 마찬가지다. 즉, 코드의 타입 안정성이 뛰어난 코드이다. 그러나, 재사용성이 너무 떨어진다. 두 병원은 어떤 동물을 받느냐의 차이만 있고 나머지가 완전히 동일하다.

 

그럼 이 중복을 해결하기 위해 다형성을 활용해보자. Dog, Cat 모두 Animal을 상속받기 때문에 가능해보인다.

 

AnimalHospitalV1

package org.example.generic.ex3;

import org.example.generic.animal.Animal;

public class AnimalHospitalV1 {
    private 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;
    }
}

 

AnimalHospitalMainV1

package org.example.generic.ex3;

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

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.set(dog);
        dogHospital.checkup();

        catHospital.set(cat);
        catHospital.checkup();

        // dogHospital.set(cat); 타입 안정성 실패

        // Dog biggerDog = dogHospital.bigger(new Dog("멍멍2", 200)); 컴파일 에러
        Dog biggerDog = (Dog) dogHospital.bigger(new Dog("멍멍2", 200));
        System.out.println("biggerDog = " + biggerDog);
    }
}

 

이렇게 두 동물을 모두 받을 수 있는 AnimalHospital을 만들었다. 중복은 제거됐지만 문제가 있다.

  • 개병원에는 개만 받고 싶지만 고양이를 받을 수 있다. dogHospital.set(cat);
  • 개병원에서 더 큰 개를 찾아서 Dog로 반환할 수 없고 Animal로 반환해야 하니 다운캐스팅이 필요하다.

다운캐스팅은 하면 된다 치더라도 만약 누군가 개병원에 고양이를 받게 했을 땐 캐스팅 에러가 발생한다.

 

그럼 이거 제네릭을 사용해보면 어떻게 될까?

 

AnimalHospitalV2

package org.example.generic.ex3;

public class AnimalHospitalV2<T> {
    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(); 컴파일 에러
        animal.toString();
    }

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

 

더 심각한 문제가 발생했다. 제네릭을 사용하니까 컴파일러는 타입 T가 어떤 타입인지 알 수 없다.

즉, Animal이 들어올지 아닐지 알 수 없고 Animal 타입이 아니라면 getName(), getSize()같은 메서드를 사용할 수 없다.

 

그리고 심지어 이런 문제도 있다.

AnimalHospitalMainV2

package org.example.generic.ex3;

import org.example.generic.animal.Cat;
import org.example.generic.animal.Dog;

public class AnimalHospitalMainV2 {
    public static void main(String[] args) {
        AnimalHospitalV2<Dog> dogHospital = new AnimalHospitalV2<>();
        AnimalHospitalV2<Cat> catHospital = new AnimalHospitalV2<>();
        AnimalHospitalV2<Integer> integerHospital = new AnimalHospitalV2<>();
        AnimalHospitalV2<Double> doubleHospital = new AnimalHospitalV2<>();
    }
}

Integer, Double과 같은 타입도 이 병원이 받아들이고 있다. 왜? 제네릭이니까. 논리적으로 문제가 발생중이다.

 

그럼 결국 제네릭은 사용할 수가 없나? 제네릭을 사용하니 다음과 같은 문제가 발생한다.

  • Dog, Cat 타입도 받아들일 수 있지만, 말도 안되는 타입(Integer, Double, Boolean, ..)도 받아들일 수 있다.
  • 컴파일러 입장에선 클래스만 놓고 봤을 때 타입 T의 정보를 알 수 없으니 Animal 클래스가 가지는 메서드를 사용할 수 없다.

그럼 필드 타입이 AnimalAnimalHospital이 최선일까? 다운캐스팅도 해야하고, 원치 않는 타입의 동물이 들어와도 가능한데 그럴경우 다운 캐스팅시 문제가 발생할 수도 있는 이게 최선일까? 아닌거 같다.

 

저 두개의 문제를 해결하려면 T의 타입을 Animal로 한정지으면 모든 문제가 해결된다.

 

제네릭의 문제 돌파

AnimalHospitalV3

package org.example.generic.ex3;

import org.example.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라는 타입 매개변수에게 제한을 걸어두는 것이다. "넌 AnimalAnimal 하위의 타입으로만 가능하다"라고.

그럼 컴파일 시점에 T타입을 명확하게 구분짓지 않아도 적어도 Animal 클래스가 가지는 메서드들은 사용할 수가 있는것이다.

 

AnimalHospitalMainV3

package org.example.generic.ex3;

import org.example.generic.animal.Cat;
import org.example.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("멍멍1", 100);
        Cat cat = new Cat("냐옹1", 300);

        dogHospital.set(dog);
        dogHospital.checkup();

        catHospital.set(cat);
        catHospital.checkup();

        Dog biggerDog = dogHospital.bigger(new Dog("멍멍2", 200));
        System.out.println("biggerDog = " + biggerDog);
    }
}

 

모든 문제가 다 해결됐다.

  • 이제 개 병원에 고양이를 넣을 수 없다. 개 병원은 제네릭의 타입 매개변수가 Dog이기 때문에 Cat 타입을 넣을 수 없다.
  • 개 병원으로 더 큰 개가 누구인지 찾는 메서드에서도 제네릭의 타입 매개변수가 Dog이기 때문에 Dog를 반환하게 된다. 그러므로 다운캐스팅이 필요없다.
  • 제네릭의 타입 매개변수가 Animal 하위로 한정됐기 때문에 Integer, Double 타입을 받을 수 없다. 
이것을 '제네릭의 타입 매개변수 제한'이라고 한다.

 

 

제네릭 메서드

지금까지 살펴본 건 제네릭 타입이었고 이제는 제네릭 메서드를 알아보자. 

참고로 제네릭 타입은 클래스나 인터페이스를 제네릭으로 선언하면 그게 제네릭 타입이다.

 

GenericMethod

package org.example.generic.ex4;

public class GenericMethod {

    public static Object objMethod(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 genericNumberMethod(T t) {
        System.out.println("bound generic print = " + t);
        return t;
    }
}

 

이 코드가 바로 제네릭 메서드이다. 제네릭 메서드는 반환타입 앞에 <>가 붙는다. 반환타입 앞에 <>가 붙어서 "전 제네릭 메서드입니다"를 알려준다. 그리고 그 다음 반환타입이 나오는데, 위 코드에선 반환타입을 전부 제네릭으로 선언한 타입 T로 결정한다. 그리고 파라미터도 T 타입의 매개변수를 받는다. 

 

이제 저 메서드를 사용하는 쪽에선 어떻게 해야할까?

MethodMain1

package org.example.generic.ex4;

public class MethodMain1 {

    public static void main(String[] args) {
        Object o = GenericMethod.objMethod(1);

        GenericMethod.<Integer>genericMethod(1);
        Integer integerValue = GenericMethod.<Integer>genericNumberMethod(1);
        Double doubleValue = GenericMethod.<Double>genericNumberMethod(1.0);
    }
}

 

이런식으로 호출할 때 타입을 알려주면 된다. 호출할 때 <>로 어떤 타입인지를 전달하는데 이걸 명시적 타입 인자 전달이라고 한다. (근데 이것도 나중엔 필요없다.) 

 

 

제네릭 메서드는 인스턴스 메서드, static 메서드 모두 가능하다.

package org.example.generic.ex4;

public class Box<T> {
    private T value;
    
    private <V> V instanceMethod(V v) {
        return v;
    }

    private static <Z> Z staticMethod(Z z) {
        return z;
    }
}

 

근데 제네릭 타입(클래스 옆에 붙은 제네릭)은 static 메서드에 타입 매개변수로 사용할 수가 없다. 그 이유는 제네릭 타입은 객체를 생성하는 시점에 타입이 정해진다. 그런데 static 메서드는 인스턴스 단위가 아니라 클래스 단위로 작동하기 때문에 제네릭 타입과는 무관하다. 따라서 static 메서드에 제네릭을 도입하려면 제네릭 메서드를 사용해야 한다. 아래 코드를 보자.
class Box<T> {
    T instanceMethod(T t) {} //가능
    static T staticMethod(T t) {} //불가능
    static <Z> Z staticGenericMethod(Z z) {} //가능 (제네릭 메서드)
}

 

 

제네릭 메서드 타입 추론

이건 뭐냐면 위에서 명시적 타입 인자 전달을 말하면서 제네릭 메서드를 호출할 때 타입을 알려주는데 사실 이게 여간 귀찮다. 그래서 생략해도 된다. 생략해도 컴파일러가 들어오는 값으로 타입을 추론해낸다. 다음 코드처럼.

package org.example.generic.ex4;

public class MethodMain1 {

    public static void main(String[] args) {
        Object o = GenericMethod.objMethod(1);

        GenericMethod.genericMethod(1);
        Integer integerValue = GenericMethod.genericNumberMethod(1);
        Double doubleValue = GenericMethod.genericNumberMethod(1.0);
    }
}

 

 

제네릭 타입과 제네릭 메서드의 우선순위

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

 

ComplexBox

package org.example.generic.ex4;

import org.example.generic.animal.Animal;

public class ComplexBox<T extends Animal> {
    private T animal;

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

    public T get() {
        return 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;
    }
}

 

ComplexBox라는 클래스는 제네릭 타입 <T extends Animal>이다. 

여기서 인스턴스 메서드 printAndReturn()은 제네릭 메서드로 선언했다. 근데 제네릭 타입과 제네릭 메서드의 타입 매개변수가 같은 T로 이루어져있다. 그럼 우선순위는 누가 더 높은지를 알고자 하는 것이다.

 

MethodMain3

package org.example.generic.ex4;

import org.example.generic.animal.Cat;
import org.example.generic.animal.Dog;

public class MethodMain3 {
    public static void main(String[] args) {
        Dog dog = new Dog("멍멍", 30);
        Cat cat = new Cat("냐옹", 20);

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

        complexBox.get().sound();

        complexBox.printAndReturn(cat);
    }
}

실행결과

멍멍
animal.className = org.example.generic.animal.Dog
t.className: org.example.generic.animal.Cat

 

ComplexBox는 제네릭 타입이고 타입 매개변수 TDog로 선언했다. 그런데 printAndReturn은 제네릭 메서드이다. 제네릭 메서드의 타입 매개변수 T는 제네릭 타입의 타입 매개변수 T와 아무런 상관이 없다. 즉, 우선순위는 제네릭 메서드의 타입 매개변수가 더 높은것이다.

 

개발 세상에서는 느낀게 항상 우선순위는 더 디테일한 쪽이 우선순위가 높다. 

 

그래서 실행결과는 당연하게도 인자로 넘긴 Cat 타입으로 찍힌다. 근데 저렇게 모호하게 같은 T로 작성하지 말자. 혼란을 주는 코드는 나쁜코드!

 

와일드카드

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

WildCardEx

package org.example.generic.ex5;

import org.example.generic.animal.Animal;

public class WildCardEx {

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

    static void printWildCardV1(Box<?> box) {
        System.out.println("? = " + box.getValue());
    }

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

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

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

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

저기서 <?>로 작성한 3가지 메서드가 와일드 카드를 사용하고 있다. 3가지 메서드들을 보면 알겠지만 제네릭 메서드를 선언한 게 아니다.

`?`로 어떤 타입이든 받을 수 있는 와일드카드를 사용해서 타입의 인자가 정해진 제네릭 타입을 전달 받아 활용할 때 사용한다.

 

여기서 단순히 <?> 말고 <? extends Animal>이 있는데 이 때 <?>비제한 와일드카드라고 한다.

정말 모든 타입을 다 받아줄 수 있는 것. Box<Integer>, Box<Dog>, Box<Object> 처럼 말이다. 

 

WildCardMain1

package org.example.generic.ex5;

import org.example.generic.animal.Cat;
import org.example.generic.animal.Dog;

public class WildCardMain1 {
    public static void main(String[] args) {
        Box<Object> oBox = new Box<>();
        Box<Dog> dogBox = new Box<>();
        Box<Cat> catBox = new Box<>();

        dogBox.setValue(new Dog("멍멍", 100));

        WildCardEx.printGenericV1(dogBox);
        WildCardEx.printWildCardV1(dogBox);

        WildCardEx.printGenericV2(dogBox);
        WildCardEx.printWildCardV2(dogBox);

        WildCardEx.printAndReturnGeneric(dogBox);
        WildCardEx.printAndReturnWildCard(dogBox);
    }
}

실행결과

T = Animal{name='멍멍', size=100}
? = Animal{name='멍멍', size=100}
이름 = 멍멍
이름 = 멍멍
이름 = 멍멍
이름 = 멍멍

 

상한 와일드카드

비제한 와일드카드 말고 상한 와일드카드가 있다. 말 그대로 상한을 두는것이다. 위에서 이미 봤다.

`<? extends Animal>`이게 상한 와일드카드이다.

 

들어오는 여러 타입중에 Animal 하위까지만 가능하게 설정하는 것이다.

 

 

그럼 제네릭도 있고 뭐 와일드카드도 있고 뭘 사용하는게 좋을까?

제네릭을 반드시 사용해야 하는 경우라면 제네릭 타입이나 제네릭 메서드를 사용하고, 그게 아니라면 와일드카드를 사용하는 것을 권장한다. 와일드카드는 제네릭 타입이나 제네릭 메서드를 선언하는게 아니기 때문에 타입 추론을 하고 등등의 복잡한 과정이 전혀 필요가 없다. 그저 이미 타입이 지정이 된 제네릭을 활용할뿐이다.

 

근데, 제네릭을 반드시 사용해야 하는 경우라는건 어떤거냐면 이런거다.

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

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

첫번째 메서드는 제네릭 메서드이고 반환타입이 T이다. 그리고 받는 파라미터도 Box<T>이다. 그럼 이걸 호출하는 쪽에서는 내가 어떤 타입을 받을지 명확하게 명시할 수 있다. 더 정확히는 명확하게 명시하는 것에서 끝이아니라 그 타입이 아니면 아예 컴파일 에러가 발생한다.

Dog dog = WildCardEx.printAndReturnGeneric(dogBox);
Cat cat = WildCardEx.printAndReturnGeneric(catBox);

 

 

근데 두번째 메서드는 제네릭 메서드가 아니라서 반환 타입은 딱 하나로 지정될 수 밖에 없다. 동적으로 바뀌는게 아니다.

그래서 다음과 같이 Animal 타입의 반환값이 나오고 이것을 딱 원하는 DogCat으로 사용하기 위해선 다운캐스팅이 필요하다.

Animal dog = WildCardEx.printAndReturnWildCard(dogBox);
Animal cat = WildCardEx.printAndReturnWildCard(catBox);

 

 

하한 와일드카드

상한이 있다면 하한 와일드카드도 있다. 아래 코드가 그렇다.

static void writeBox(Box<? super Animal> box) {
    System.out.println("Hello");
}

저렇게 `<? super Animal>`이렇게 작성하면, ?Animal 포함 그 이상으로만 가능하다.

그래서 Dog, Cat 둘 다 올 수 없다. 

참고로 이 하한 와일드카드는 제네릭에는 사용할 수 없다. 말도 하한 와일드카드니까 그냥 참고로 알아두면 좋다.

 

타입 이레이저

제네릭이 결국 컴파일러에 의해 컴파일되면 어떤 모양을 할까? 제네릭은 자바 컴파일 단계에서만 사용되고 컴파일 이후에는 제네릭 정보가 삭제된다. 쉽게 말해서 컴파일 전인 .java 파일에는 제네릭의 타입 매개변수가 존재하지만, 컴파일 이후인 자바 바이트코드 .class 파일에는 타입 매개변수가 존재하지 않게 된다.

 

Box.java

package org.example.generic.ex5;

public class Box<T> {
    private T value;

    public T getValue() {
        return value;
    }

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

 

이렇게 선언한 제네릭 타입을 사용할 때 이렇게 사용한다.

BoxEraserMain.java

package org.example.generic.ex5;

public class BoxEraserMain {
    public static void main(String[] args) {
        Box<Integer> integerBox = new Box<>();
        integerBox.setValue(10);
        Integer value = integerBox.getValue();
    }
}

 

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

package org.example.generic.ex5;

public class Box<Integer> {
    private Integer value;

    public Integer getValue() {
        return value;
    }

    public void setValue(Integer value) {
        this.value = value;
    }
}

 

컴파일이 끝나면 자바는 제네릭과 관련된 정보를 삭제한다. 그래서 .class 파일에 생성된 정보는 다음과 같다.

Box.class

package org.example.generic.ex5;

public class Box {
    private Object value;

    public Object getValue() {
        return value;
    }

    public void setValue(Object value) {
        this.value = value;
    }
}

 

선언한 타입 매개변수는 전부 Object로 변환된다. 그리고 이것을 호출한 Main쪽은 이렇게 변한다.

BoxEraserMain.class

package org.example.generic.ex5;

public class BoxEraserMain {
    public static void main(String[] args) {
        Box integerBox = new Box();
        integerBox.setValue(10);
        Integer value = (Integer) integerBox.getValue(); // 컴파일러가 캐스팅 추가
    }
}

 

값을 반환 받는 부분을 자바 컴파일러가 자동으로 캐스팅을 해준다. 이렇게 추가된 코드는 자바 컴파일러가 이미 컴파일 시점에 검증을 했고 완벽하다고 판단했기 때문에 문제가 발생하지 않는다.

 

타입 매개변수 제한의 경우는 어떨까?

 

컴파일 전엔 이렇게 생긴 코드이다.

AnimalHospitalV3.java

package org.example.generic.ex3;

import org.example.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;
    }
}

 

컴파일 후엔 이렇게 변한다.

AnimalHospitalV3.class

package org.example.generic.ex3;

import org.example.generic.animal.Animal;

public class AnimalHospitalV3 {
    private 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;
    }
}


T의 타입 정보가 제거됐지만 상한으로 지정한 Animal 타입으로 대체되기 때문에 Animal 타입의 메서드를 사용하는데는 아무런 문제가 없다. 그럼 이거를 가져다가 사용하는 Main 코드는 어떻게 변할까?

 

컴파일 전

Main.java

AnimalHospitalV3<Dog> animalHospitalV3 = new AnimalHospitalV3<>();
Dog dog = animalHospitalV3.getBigger(new Dog());

 

컴파일 후

Main.class

AnimalHospitalV3 animalHospitalV3 = new AnimalHospitalV3();
...
Dog dog = (Dog) animalHospitalV3.getBigger(new Dog());

우선 제네릭 타입을 넣는 것은 사라지고 일반적인 객체를 생성하는 코드와 똑같아진다. 그리고 이렇게 생성을 하고 getBigger()Dog 타입이 파라미터로 들어가면 Animal 타입으로 리턴하는 게 아니라 Dog로 컴파일러가 캐스팅을 해준다. 캐스팅을 해도 아무런 문제가 되지 않는것은 위에서 말한 이유랑 동일하다. 이미 자바 컴파일러가 컴파일 시점에 코드에 문제가 없는지 완벽하게 검증하기 때문에 다운 캐스팅을 해도 아무런 문제가 발생하지 않는다.

 

이게 결론이다. 이거 말하려고 여기까지 왔다.

그래서! 이렇게 자바 컴파일러가 타입 이레이저라는 기술을 사용하기 때문에 제네릭의 타입 정보가 컴파일 이후에는 존재하지 않는다. .class로 자바를 실행하는 런타임에는 지정한 Box<Integer>, Box<String> 의 타입 정보가 모두 제거가 된다는 말이다.

따라서 런타임에 타입을 활용하는 다음과 같은 코드는 작성할 수 없다.

Box.java

class Box<T> {
    public boolean instanceCheck(Object param) {
    	return param instanceof T; // 오류
    }	
    public void create() {
    	return new T(); // 오류
    }
}

 

왜냐? 이 코드가 컴파일 된 이후 .class 파일은 이렇게 생겼기 때문이다.

Box.class

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

 

  • 이런 코드라면 instanceCheck()는 어떤 param이 들어와도 참이된다. 개발자는 이런 결과를 원하진 않았을 것이다.
  • new T()는 항상 new Object()가 되버린다. 이것 역시 개발자는 원하지 않았을 것이다.

그래서, 제네릭에서는 타입 매개변수에 `instanceof`와 `new`를 허용하지 않는다.

 

728x90
반응형
LIST