개발자로 후회없는 삶 살기

[문법] 제네릭과 와일드카드 사용법 본문

[백엔드]/[Java | 학습기록]

[문법] 제네릭과 와일드카드 사용법

몽이장쥰 2025. 2. 24. 17:59

서론

※ 아래 내용을 다룹니다.

  • 제네릭 타입
  • 제네릭 메서드
  • 와일드 카드
  • 타입이레이저
  • 각각의 사용 목적

 

본론

- 제네릭이 필요한 이유

public class IntegerBox {

    private Integer value;

    public Integer get() {
        return value;
    }

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

IntegerBox는 정수를 저장하는 박스이고, StringBox는 문자열을 저장하는 박스이다.

 

🚨 요구사항이 늘어난다면?

Boolean, Double 등 요구사항에 있는 타입의 Box를 전부 새로 만들어야 한다.

코드가 다 똑같이 생겼는데 데이터 타입만 달라 코드 중복이 발생한다.

 

-> 다형성을 통한 중복 해결

public class ObjectBox {

    private Object value;

    public Object get() {
        return value;
    }

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

다양한 데이터 타입을 담는다? 그렇다면, 모든 Type을 다 받을 수 있는 모두의 부모 Object 클래스로 바꿔서 코드의 중복을 해결할 수 있다. 부모-자식 관계로 다형적 참조로 모든 타입을 받을 수 있다.

 

-> 문제점

하지만, 모든 타입을 다 받을 수 있다보니, IntegerBox로 사용하고자 한 객체에 문자열을 넣을 수 있다. 잘 못 넣은 IntegerBox를 함수 인자로 주고 받는다면, 절대 해당 value에 어떤게 들어갔는지 모를 것이고 결국 Class 변환 예외가 발생한다. 또한 get으로 값을 가져올 때도 매번 위험하게 다운 캐스팅을 해줘야 한다.

 

-> 정리

Object 사용 X : 중복된 코드 발생
Object 사용 O : 기존 코드를 재사용. But, Type Safe 하지 않음 (다운 캐스팅 필수, 다른 타입의 값 입력 가능)

다형성으로 코드의 중복을 제거하고 기존 코드를 재사용할 수 있다. 근데 입력할 때 실수로 원하지 않는 타입이 들어갈 수 있는 타입 안전성 문제가 발생하여 코드 중복과 Type Safe의 딜레마가 발생한다. 둘 다 개발할 때 매우 안 좋은 결과를 일으킨다.

 

✅ Type Safe란?

높음 : 정확히 원하는 타입의 값을 가짐, 원하는 타입이 아닐 경우 컴파일러 오류
낮음 : 어떤 타입이 있을 지 모름.

Object를 사용하니 어떤 타입이 들어있을지 몰라서 Type Safe가 낮은 문제가 발생한 것이다. Type Safe하면 무조건 Type이 받아야 하는데 Object라서 잘못된 타입의 값이 전달될 수도 있다.

 

-> 제네릭 적용

public class GenericBox<T> {

    private T value;

    public T get() {
        return value;
    }

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

제네릭을 사용하면 코드의 중복과 Type Safe 모두를 해결할 수 있다. 코드를 작성할 때는 타입을 지정하지 않고 컴파일러가 객체가 생성될 때 Integer라면 Integer 타입으로 객체를 생성한다. 이는 코드를 정의하는 시점에 타입을 결정하는 것이 아닌, 코드를 사용하는 시점에 결정하여 재사용성을 높이는 것이다.

 

제네릭을 사용하면, 타입을 명시하여 정수에 문자열을 넣었을 때 컴파일 오류가 발생하는 것이 Type Safe하다. 또한, get을 해도 다운 케스팅이 필요없다.

 

- 용어와 관례

1) 타입 매개변수 : 메서드 매개변수처럼 타입을 받는 T
2) 타입 인자 : 메서드 인자처럼 타입에 넣는 Integer
3) 제네릭 타입 : 제네릭을 사용하는 클래스와 인터페이스

즉, 제네릭은 메서드에서 매개변수를 통해 재사용성을 높인 것처럼 타입 매개변수를 통해 타입의 재사용성을 늘린 것이다. (메서드 분리 = 코드의 재사용, 로직의 분리)

 

-> 제네릭 명명 관례

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

관례라서 이를 지키지 않아도 정상적으로 동작하나, 대문자와 관례를 따르는 것이 일반적이다.

 

public static void main(String[] args) {
    GenericBox genericBox = new GenericBox();
    GenericBox<Object> objectGenericBox = new GenericBox<>();
}

다이아몬드를 하지 않으면 타입에 Object가 들어가는 것과 비슷하다. Object를 쓰더라도 반드시 명시해야 하며, 제네릭이 없는 하위 버전의 자바와 호환되도록 하기 위해서 다이아몬드 안 쓰는 방식을 남겨 놓은 것일 뿐이다.

 

-> 제네릭 활용 예제

public class Box<T> {

    private T value;

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

    public T getValue() {
        return value;
    }
}

다양한 동물을 보관하는 박스가 있다.

 

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 +
                '}';
    }
}

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

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

Animal 클래스 객체와 하위 객체를 Box에 보관할 것이다.

 

이때, 제네릭에 Animal을 넣으면 자식도 Animal 제네릭 Box에 넣을 수 있다.

 

public class Box<Animal> {

    private Animal value;

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

    public Animal getValue() {
        return value;
    }
}

위와 같은 형태로 객체 생성 시점에 변환되므로, 다형적 참조가 가능하다.

 

- 타입 매개변수 제한

요구사항 : 개 병원은 개만 받을 수 있고, 고양이 병원은 고양이만 받을 수 있게 할 수 있을까? 

위에서 만든 Animal을 사용하여 병원을 만드는 예제를 통해서 상속에서의 제네릭 활용을 강화해보자.

 

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();
    }

고양이 병원은 고양이만 받기 위해서 Cat을 타입으로 넣어버렸다.

 

1) 코드의 재사용

하지만 이렇게 하면, Type Safe 하지만 동물의 종류가 늘어날 수록 코드의 중복이 늘어난다. 코드의 중복과 Type Safe를 동시에 막기 위해서 제네릭을 사용해서 병원을 만들면 이를 해결할 수 있을까?

 

🚨 2가지 문제 발생

맞다. 코드의 중복과 타입 안전성을 모두 막족할 수 있다. 하지만, 예상과 다르게 2가지 문제점이 발생한다. 하나는 제네릭 타입이라서 animal에 있는 메서드를 사용할 수 없다. 제네릭은 정의 시점이 아닌 생성 시점에 타입을 결정하고 컴파일러가 T를 채우는 것이기 때문에 정의 시점에는 T가 Animal인 것을 전혀 알 수 없다. 컴파일러는 T를 Object 타입이라고 가정하기에 Object가 제공하는 toString 등의 메서드만 호출할 수 있다.

 

또한 제네릭으로 정의하여 모든 타입을 다 받을 수 있기에 Animal 관련이 없는 클래스도 타입 인자로 넣을 수 있다.

 

-> 타입 매개변수 제한

Animal과 관련된 타입이라는 것을 명시하고 관련 타입만 인자로 받을 수 있도록 제한을 해야하는 상황이다.

 

extends 키워드를 사용하면 특정 타입으로 타입 매개변수를 제한할 수 있다.

 

-> 장점

1) Animal 관련 매개변수라서 getName()을 사용할 수 있다.
2) Integer, Double, Object는 못 넣고 Animal 관련 타입만 인자로 넣을 수 있다.

컴파일러가 T 인자를 예측할 수 있어서, Animal 관련 메서드를 사용할 수 있고 Animal 자식이 아닌 것을 체크하여 Integer를 못 넣게 막는 것이다.

 

- 제네릭 메서드

제네릭 메서드는 제네릭 타입과 전혀 다른 기능으로, 제네릭 타입이 아닌 클래스에서 해당 메서드에만 적용 가능한 제네릭을 사용할 수 있다.

 

-> 정의 방법

이 메서드가 제네릭 메서드라는 것을 알리기 위해 방법이 필요하다.

 

public static Object objMethod(Object object) {
    System.out.println("object = " + object);
    return object;
}

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

public static <T extends Number> T numberMethod(T t) {
    System.out.println("t = " + t);
    return t;
}

<> 다이아몬드를 메서드 반환 타입 앞에 붙여야만 제네릭 메서드를 사용할 수 있다. 그러면 메서드 안에서만 사용할 수 있는 제네릭이다. extends로 제네릭 타입처럼 메서드에도 제한을 줄 수 있다.

 

제네릭 메서드는 메서드 호출 시점에 타입을 전달하여 타입 추론이 일어난다. Obj 메서드는 Obj를 반환하여 다운케스팅이 필요하고 Type Safe하지 않지만, 제네릭 메서드는 타입을 명시하므로 컴파일러에 의해서 타입이 바뀌어서 실행이 되고 Integer를 반환한다. (반환을 T로 해서 Integer가 나오는 것, 무조건 Integer가 아니다.) Number 타입으로 제한한 메서드에 문자열을 넣으면 실행 시점에 타입이 전달되어 컴파일 에러가 발생한다.

 

- 정리

1. 제네릭 타입

1) 객체 생성 시점에 타입을 전달
2) 클래스에 정의

 

2. 제네릭 메서드

1) 메서드 호출 시점에 인자 전달 및 컴파일러가 T를 타입으로 변경
2) 인스턴스, static 레벨 메서드 모두에 적용 가능, static 메서드는 아직 제네릭 타입의 제네릭이 생성되지 않아서 사용 불가
3) 메서드 반환값 앞에 다이아몬드를 달아야만 정의 가능

 

-> 동물 병원 예제에 제네릭 메서드 적용

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;
}

 

 

check랑 bigger를 제네릭 타입이 아닌 클래스에서 제네릭 메서드로 활용하는 예제를 알아보자. 두 메서드 반환 타입 앞에 <T>를 두면 제네릭 메서드가 된다. 인자로 들어오는 T를 Object가 아닌 Animal이라고 명시를 하기 위해 제한을 하면 Animal의 메서드 기능을 사용할 수 있다.

 

bigger 메서드는 2개의 인자에 동일한 T를 사용했기 때문에 개와 고양이를 넣고 반환타입을 Animal로 하면 메서드 호출 시점에 모든 T가 Animal로 변하여 Animal의 자식인 Dog와 Cat을 인자로 넣어도, 오류가 발생하지 않지만, 반환 타입을 Dog로 하면 T가 전부 Dog로 변하여, Cat을 넣으면 제네릭에 추론된 타입이 Dog인데 틀린 타입이 왔다는 오류가 발생한다.

 

- 제네릭의 우선순위

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

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

    public <T> T printAndReturn(T t) {
        System.out.println("animal.getClass() = " + animal.getClass().getName());
        System.out.println("t.getClass() = " + t.getClass().getName());
        return t;
    }
}

static 메서드는 불가능하지만, 인스턴스 제네릭 메서드는 제네릭 타입의 제네릭을 사용할 수 있다. 만약 둘 다 같은 이름(T)의 제네릭을 사용하면 어떤 것이 우선순위를 가지는지 알아보자. 제네릭 타입의 제네릭을 출력하고 제네릭 메서드의 제네릭을 출력해보자.

 

결과는 제네릭 타입보다 메서드가 높은 우선 순위를 가져서 메서드의 t는 고양이가 나온다. 항상 프로그래밍에서는 좀 더 구체적인게 우선순위가 높다. 이 T는 제네릭 메서드의 T라서 클래스에 제한이 붙어있어도 제한된 게 아니다. 하지만, 실무에서는 이렇게 모호하게 하면 안되고 무조건 다르게 해야한다.

 

- 와일드카드

제네릭을 쉽게 사용할 수 있는 방법으로, * 혹은 ? 처럼 어떤 타입도 받을 수 있다는 의미의 특수 문자를 의미한다. 여러 타입이 들어올 수 있다는 특수 문자라고 보면 된다.

 

-> 예제

public class Box<T> {

    private T value;

    public T getValue() {
        return value;
    }

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

예제에서 사용할 제네릭 타입을 정한다.

 

public class WildcardEx {

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

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

    static <T extends Animal> T printAnrReturnGeneric(Box<T> box) {
        T t = box.getValue();
        System.out.println("이름 = " + t.getName());
        return t;
    }
}
V1 : Box<T>라는 제네릭 타입을 받고 값을 꺼내서 출력
V2 : Box의 T에 Animal로 제한을 걸었다.
Return : V2에서 return만 더한다.

와일드카드를 사용하기 전 비교 대상으로 제네릭 메서드 3개를 작성했다. 

 

-> 실행

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

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

    WildcardEx.<Dog>printGenericV1(dogBox);
    WildcardEx.<Cat>printGenericV1(catBox);
}

제네릭 메서드의 인자로 Box<Dog>를 넣으면 T를 명시한 것 처럼 Box<T>를 해도 Dog로 제네릭이 추론된다.

 

-> 와일드 카드 적용

제네릭 메서드랑 와일드카드는 비슷하게 동작하다.

 

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

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

제네릭을 사용하지 않고 와일드카드를 쓰면 제네릭 타입의 Box가 들어올 수 있는데 ? 는 모든 타입인 Dog, Cat 전부 들어올 수 있고 제한을 안 붙인 V1 메서드와 비슷하다.

 

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

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

와일드카드에서도 제한을 둘 수 있는데, T가 제거되고 바로 Animal로 사용할 수 있다.

 

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

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

V3 메서드를 와일드 카드로 변환한 모습이다. 역시 T가 제거되고 Animal을 바로 사용하는데, 이는 제네릭 메서드가 아닌 일반 메서드이기 때문이다.

 

-> 설명

와일드 카드는 제네릭 타입이나 메서드처럼 제네릭을 정의하는 것이 전혀 아니고, 이미 정의된 제네릭 타입을 효과적으로 사용하는 방법이다. 제네릭을 쉽게 쓸 수 있게 도와주는 도구다. Box라는 제네릭 타입이 있을 때 그것을 가져다 쓸 때 사용하는 것이다.

와일드 카드를 사용한 메서드는 보통의 일반적인 메서드로, ?는 Box의 제네릭으로 어떠한 타입이든 들어올 수 있음을 의미하고 제한도 걸 수 있다. 제네릭 메서드는 메서드 호출 시점에 제네릭이 정해지는데, 와일드카드를 사용한 메서드는 일반 메서드라서 제네릭이 정해지는 과정이 존재하지 않는다.

 

✅ 제네릭 메서드 vs 와일드카드

필수적으로 제네릭을 사용해야 하는 것이 아니라면, 보다 단순한 와일드 카드를 사용하는 것을 권장한다.

 

🚨 와일드 카드가 안 될 때

그렇다면, 굳이 제네릭 메서드를 왜 사용해야 할까 싶지만, 와일드카드로는 안 되는 경우가 존재한다.

 

와일드카드는 제네릭을 활용하는 것이지, 정의하는 것이 아니라서 런타임에 동적으로 명확한 타입을 지정할 수 없다. 따라서 Dog나 Cat 등 명확한 정보로 타입을 지정할 수 없고 상한인 Animal이 반환되어 다운 캐스팅을 해야한다. 코드의 중복은 막았으나 type safe하지 않아서 런타임에 명확한 타입을 정의해야 하는 경우에는 제네릭을 사용해야 한다.

 

-> 하한 와일드 카드

제네릭 타입이나 메서드는 하한 제한이 불가능하지만, 와일드카드는 가장 밑으로 들어올 수 있는 타입을 제한할 수 있다. super Animal을 하한으로 하면 ?에 들어오는 것은 Animal 이상 타입이 들어와야 해서 Animal이나 Object는 가능하지만 Dog와 Cat 타입은 못 들어온다.

 

- 타입 이레이저

제네릭의 컴파일 이후 런타임까지 동작 과정을 알아보면, 타입 이레이저를 알아야 한다. 제네릭은 컴파일 단계에서만 사용되고, 컴파일 이후에는 제네릭 정보가 삭제된다.

 

.java 파일

위와 같은 제네릭 타입을 정의하고 Integer 타입 매개변수로 사용을 하면 컴파일 타임에 타입이 전부 적용되고, 실행 가능한지 검증이 된다.

 

.class 파일

하지만, 컴파일이 끝난 후 런타임에서 사용하는 class 파일에는 제네릭 정보가 전부 사라지고 Object로 수정되고 다이아몬드도 사라진다. 따라서 Obj 기능만 사용할 수 있다.

 

제네릭을 사용하는 입장에서의 코드도 class 파일이 되면 new GenericBox에 다이아몬드가 사라지고 반환을 T로 했기 때문에 Object가 반환되어 Integer로 다운 케스팅을 해야한다.

 

-> 타입 매개변수 제한의 경우

.java 파일 / .class 파일

타입제한을 둔 경우에는 T가 상한인 Animal이 되며, 즉 컴파일이 끝나면 가장 높게 들어올 수 있는 타입으로 제네릭을 다 바꾸고 그래서 animal의 기능을 전부 다 사용할 수 있는 것이다.

 

-> 이렇게 동작하는 이유

처음부터 자바에 제네릭이 있었던 것이 아니므로, 기존 코드의 변경 없이 제네릭을 도입하고자 했고, 따라서 java 코더와 jvm을 수정하지 말고 컴파일러만 수정을 한 것이다. 컴파일러가 제네릭 타입을 실제 타입으로 변환하고 검증도 완료한 후 이것을 삭제하면 jvm이 바로 사용할 수 있도록 한 것이다. (컴파일러만 수정하고 기존 코드는 동일)

 

🚨 타입 이레이저 방식의 한계

컴파일 이후엔 제네릭의 타입 정보가 존재하지 않는다. .class로 자바를 실행하는 런타임에는 T가 타입으로 변경된 타입 정보가 모두 사라진다.

 

이러한 방식 때문에 제네릭을 사용할 수 없는 곳이 있다. instanceof는 항상 Object와 비교를 하여 참을 반환하고 new 도 Object를 생성한다. 이레이저가 다 날려버린다. 따라서, 이 두 코드는 컴파일 에러가 발생한다. 런타임에 T를 사용해야 하는 것은 제네릭을 사용할 수 없다.

Comments