3593 단어
18 분
[ JAVA ] 제네릭(Generics), 와일드 카드(WildCard)

제네릭(Generics)#

제네릭은 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크(compile-time type check)를 해주는 기능이다. 객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 형변환의 번거로움이 줄어든다.

타입 안정성을 높인다는 것은 의도하지 않은 타입의 객체가 저장되는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 잘못 형변환되어 발생할 수 있는 오류를 줄여준다는 뜻이다. 일단 코드부터 보고 이해해보자.

예시 코드#

// 제네릭 클래스 정의
class Box<T> {
    private T item;

    public void setItem(T item) {
        this.item = item;
    }

    public T getItem() {
        return item;
    }
}

// 사용 예시
Box<String> stringBox = new Box<>();
stringBox.setItem("Hello, Generics!");
System.out.println(stringBox.getItem()); // Hello, Generics!

Box<Integer> intBox = new Box<>();
intBox.setItem(123);
System.out.println(intBox.getItem()); // 123

위 코드에서 Box<T>는 제네릭 클래스이다. <T>는 타입 파라미터로, T 자리에 어떤 타입이든 넣을 수 있다. 예를 들어, Box<String>TString 타입으로 대체되고, Box<Integer>TInteger로 대체된다. 덕분에 Box 클래스를 여러 타입에 대해 재사용할 수 있다.

제네릭 용어#

  • Box<T>: 제네릭 클래스. T의 Box, 또는 ‘T Box’라고 읽는다.
  • T: 타입 변수 혹은 타입 매개변수. (T는 타입 문자)
  • Box: 원시 타입(raw type)

타입 문자 `T`는 메서드 매개변수와 유사한 면이 있어 타입 매개변수에 타입을 지정하는 것을 '제네릭 타입 호출' 이라고 하고, 지정된 타입 `String`을 매개변수화된 타입(parameterized type)이라고 한다.

주의사항#

static 멤버에 제네릭 타입을 사용할 수 없다.#

제네릭 타입 T는 인스턴스 변수로 간주된다. 하지만 static 멤버는 클래스에 고정되어 있고, 인스턴스와 관계없이 동작하기 때문에 인스턴스 변수에 의존할 수 없다. 따라서, static 멤버에서 제네릭 타입을 사용할 수 없습니다.

class Box<T> {
    static T item; // 오류: static 멤버에 제네릭 타입 T를 사용할 수 없음
    static int compare(T t1, T t2) { ... } // 오류
}

static 멤버는 모든 인스턴스에서 동일하게 동작해야 하므로, Box<Apple>.itemBox<Grape>.item이 다를 수 없다. 따라서 static 멤버에 제네릭 타입을 사용할 수 없다.

제네릭 배열을 생성할 수 없다.#

제네릭 배열을 생성하는 것은 허용되지 않는다. 예를 들어, new T[]와 같이 제네릭 타입으로 배열을 생성할 수 없다. 제네릭 타입은 컴파일 시점에 구체적인 타입이 결정되지 않기 때문에, 배열의 타입을 정확하게 알 수 없어서 컴파일 에러가 발생한다.

class Box<T> {
    T[] itemArr; // OK: 배열 참조 변수 선언은 가능

    T[] toArray() {
        T[] tmpArr = new T[itemArr.length]; // 오류: 제네릭 배열 생성 불가
        return tmpArr;
    }
}

만약 정말 생성해야 한다면, 다음과 같은 방법이 있다.

  1. Reflection API의 Array.newInstance() 사용 Array.newInstance() 메서드를 통해 런타임에 타입을 지정하여 배열을 생성할 수 있다.
T[] tmpArr = (T[]) Array.newInstance(itemArr.getClass().getComponentType(), itemArr.length);
  1. Object 배열을 생성하고 형변환 Object 배열을 생성한 후 T[]로 형변환하는 방법도 사용할 수 있다. 다만, 이 경우 타입 안전성을 완전히 보장할 수는 없다.
T[] tmpArr = (T[]) new Object[itemArr.length];

instanceof 연산자는 제네릭 타입과 함께 사용할 수 없다.#

instanceof 연산자는 컴파일 시점에 타입을 알아야 하기 때문에, 제네릭 타입 변수 T와 함께 사용할 수 없다. 만약 제네릭 타입과 instanceof를 함께 사용해야 하는 경우에는 구체적인 타입으로 검사해야 한다.

if (obj instanceof T) { // 오류: T와 함께 instanceof 사용 불가
    ...
}

제네릭 타입 변수 T는 컴파일 타임에 어떤 타입인지 알 수 없기 때문에, instanceof 연산자와 함께 사용할 수 없다.

타입 변수 제한하기#

제네릭이 타입 안정성을 제공하고 유연성이 높다고 하더라도, 원하지 않는 상황이 있을 수 있다. 특정 타입만 제한하고 싶은 상황에서는 어떻게 하면 좋을까?

상위 타입 제한 (Bounded Type Parameters)#

타입 매개변수 T에 특정 클래스나 인터페이스의 자손 클래스만 허용할 수 있다. 이렇게 하면, 특정 타입의 하위 클래스들만 대입할 수 있어 잘못된 타입의 객체가 사용되는 것을 방지할 수 있다.

class FruitBox<T extends Fruit> {
    ArrayList<T> list = new ArrayList<T>();

    public void add(T item) {
        list.add(item);
    }
}

위 코드에서 FruitBox<T extends Fruit>T에 Fruit 클래스의 자손 클래스들만 허용하겠다는 의미이다. 이를 통해 과일만 담는 상자를 만들 수 있다.

  • 사용예시
FruitBox<Apple> appleBox = new FruitBox<Apple>(); // OK. Apple은 Fruit의 자손
FruitBox<Toy> toyBox = new FruitBox<Toy>(); // 에러. Toy는 Fruit의 자손이 아님

다형성으로 인한 유연성#

매개변수화된 타입이 부모 클래스를 가지면, 자손 클래스의 객체도 담을 수 있다. 예를 들어, FruitBox<Fruit>는 Fruit의 자손 클래스들도 모두 담을 수 있다.

FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
fruitBox.add(new Apple()); // OK. Apple은 Fruit의 자손
fruitBox.add(new Grape()); // OK. Grape는 Fruit의 자손

이와 같이, FruitBox<Fruit>는 다양한 과일 객체들을 담을 수 있는 다목적 과일 상자로 사용할 수 있다.

인터페이스를 상속한 타입 제한#

제네릭 타입 매개변수에 인터페이스를 구현한 타입만 허용하도록 할 수도 있다. extends 키워드를 사용하여 클래스가 아니라 인터페이스를 상속받도록 제한할 때도 extends를 사용한다. implements를 사용하지 않는다는 점에 주의해야 한다.

interface Eatable {}

class FruitBox<T extends Eatable> {
    ArrayList<T> list = new ArrayList<T>();
}

이 경우, TEatable 인터페이스를 구현한 클래스만 사용할 수 있다.

복수 상위 타입 제한#

제네릭 타입 매개변수에 여러 조건을 동시에 지정할 수 있다. 예를 들어, 특정 클래스의 자손이면서 특정 인터페이스를 구현한 타입만 허용하도록 & 기호를 사용해 결합할 수 있다.

class FruitBox<T extends Fruit & Eatable> {
    ArrayList<T> list = new ArrayList<T>();
}

위의 예제에서는 T가 Fruit의 자손이면서 Eatable 인터페이스를 구현한 클래스만 허용한다.

와일드 카드(WildCard)#

와일드카드(wildcard)는 컴퓨터나 일상에서 무언가를 대표하거나 대체할 수 있는 기호나 문자를 말한다. 예를 들어 검색 엔진에서 *? 같은 기호를 사용해 특정 단어나 문자를 대신할 때가 있다. *는 어떤 문자열이든 대체할 수 있고, ?는 특정 위치의 단일 문자를 대체할 수 있다. 즉, 와일드카드는 범용적이고 가변적인 요소를 표현할 때 자주 사용되며, ‘어떤 것도 될 수 있는’ 것이라고 이해할 수 있다.

와일드카드를 왜 사용할까?#

다음과 같은 상황을 가정해보자. 매개변수에 과일박스를 대입하면 주스를 만들어서 반환하는 Juicer라는 클래스가 있고, 이 클래스에는 과일을 주스로 만들어서 반환하는 makeJuice()라는 static 메서드가 있다.

class Juicer {
	static Juice makeJuice(FruitBox<Fruit> box){
		String tmp = "";
		for(Fruit f : box.getList()) tmp += f + " ";
		return new Juice(tmp);
	}
}

Juicer 클래스는 제네릭 클래스도 아니고, 제네릭이라고 해도 static 메서드에는 타입 매개변수 T를 매개변수에 사용할 수 없으므로 아예 제네릭을 적용하지 않던가, 타입 매개변수 대신 특정 타입을 지정해줘야 한다.

FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox - new FruitBox<Apple>();

System.out.println(Juicer.makeJuice(fruitBox)); // OK. FruitBox<Fruit>
System.out.println(Juicer.makeJuice(appleBox)); // error. FruitBox<Apple>

보이는 바와 같이, static 메서드가 제네릭 타입을 FruitBox<Fruit> 으로 고정해놓게 되면, 에러가 발생한다. 이 문제를 해결하려면 메서드 오버로딩을 생각해볼 수 있다.

class Juicer {
	static Juice makeJuice(FruitBox<Fruit> box){
		String tmp = "";
		for(Fruit f : box.getList()) tmp += f + " ";
		return new Juice(tmp);
	}
	
	static Juice makeJuice(FruitBox<Apple> box){
		String tmp = "";
		for(Fruit f : box.getList()) tmp += f + " ";
		return new Juice(tmp);
	}
}

하지만 다음과 같이 오버로딩을 하게 되면 컴파일 오류가 발생한다. 제네릭 타입이 다른것만으로는 오버로딩이 되지 않기 때문이다. 제네릭 타입은 컴파일러가 컴파일 시에만 사용하고 제거해린다. 그래서 위의 메서드는 메서드 오버로딩이 아니라 메서드 중복 정의가 되어버린다.

이런 상황에서 유동적으로 제네릭타입을 사용할 수 있게 하기 위해 사용되는 것이 와일드카드이다.

Java의 와일드카드#

전에 와일드카드는 범용적이고 가변적인 요소를 표현할 때 자주 사용되며, ‘어떤 것도 될 수 있는’ 것이라고 이해할 수 있다고 했었다.

Java의 와일드카드는 이런 개념을 제네릭 타입에 적용한 것이다. Java의 제네릭에서 와일드카드는 ?로 표현되며, 제네릭 타입을 더욱 유연하게 사용하도록 해 준다. ?는 특정 클래스나 인터페이스 타입에 구애받지 않고, 다양한 타입을 받아들일 수 있도록 해주는 역할을 한다. 다음과 같이 사용된다.

  1. <? extends T>: 와일드 카드의 상한 제한. T 타입이나 그 자손들만 허용한다.
  2. <? super T>: 와일드 카드의 하한 제한. T 타입이나 그 조상들만 허용한다.
  3. <?>: 제한 없음. 모든 타입을 허용한다. <? extends Object>와 동일하다.

와일드 카드의 상한 제한(<? extends T>)#

이제 기존에 문제가 되었었던 코드를 다시 보자.

class Juicer {
	static Juice makeJuice(FruitBox<Fruit> box){
		String tmp = "";
		for(Fruit f : box.getList()) tmp += f + " ";
		return new Juice(tmp);
	}
	
	static Juice makeJuice(FruitBox<Apple> box){
		String tmp = "";
		for(Fruit f : box.getList()) tmp += f + " ";
		return new Juice(tmp);
	}
}

타입 매개변수가 다른 이 코드를 다음과 같이 표현하게 되면, 컴파일 에러가 사라지고 제대로 동작하게 된다.

class Juicer {
	static Juice makeJuice(FruitBox<? extends Fruit> box){
		String tmp = "";
		for(Fruit f : box.getList()) tmp += f + " ";
		return new Juice(tmp);
	}
}

기존 코드를 보면, Juicer 클래스의 makeJuice 메서드는 FruitBox<Fruit>FruitBox<Apple> 두 가지를 각각 처리하고 있다. 하지만 타입 매개변수를 FruitBox<? extends Fruit>로 사용하면 두 가지를 하나의 메서드로 합칠 수 있다.

와일드 카드의 하한제한(<? super T>)#

와일드 카드의 하한 제한(<? super T>)은 특정 타입(T)과 그 상위 클래스만 허용하도록 할 때 사용한다. 이는 상위 타입에서 하위 타입으로 값을 추가하거나 읽어올 때 유용하다.

예제 코드에서는 makeJuice 메서드에서 Box<? super Apple>을 파라미터로 받는다. 이로 인해 Box<Fruit>Box<Apple> 모두 허용되며, Apple의 상위 클래스인 Fruit을 처리할 수 있게 된다.

아래 코드로 예시를 보자.

import java.util.ArrayList;

class Fruit {
    public String toString() { return "Fruit"; }
}

class Apple extends Fruit {
    public String toString() { return "Apple"; }
}

class Juice {
    String name;
    Juice(String name) { this.name = name + "Juice"; }
    public String toString() { return name; }
}

Apple은 Fruit을 상속받고 있다.

class Juicer {
    static Juice makeJuice(Box<? super Apple> box) {
        StringBuilder tmp = new StringBuilder();
        for (Object f : box.getList())
            tmp.append(f).append(" ");
        return new Juice(tmp.toString().trim());
    }
}

class FruitBoxExample {
    public static void main(String[] args) {
        Box<Fruit> fruitBox = new Box<>();
        Box<Apple> appleBox = new Box<>();

        fruitBox.add(new Apple());
        appleBox.add(new Apple());

        System.out.println(Juicer.makeJuice(fruitBox));
        System.out.println(Juicer.makeJuice(appleBox));
    }
}

class Box<T> {
    private ArrayList<T> list = new ArrayList<>();
    void add(T item) { list.add(item); }
    ArrayList<T> getList() { return list; }
}

이제 Juicer에서는 Box에 Apple의 상위 타입만 저장할 수 있게 된다. 예제에서는 makeJuice 메서드 호출 시 Box<Fruit>Box<Apple> 모두 사용할 수 있으므로 유연성이 높아진다.

제한이 없는 경우(<?>)#

제한이 없는 와일드 카드(<?>)는 타입에 구애받지 않고 모든 타입을 받을 수 있어 가장 일반적인 형태의 와일드 카드이다. 이 경우, 메서드나 클래스에서 특정 타입을 정해두지 않고 사용해야 하거나 타입을 알 필요가 없는 경우에 유용하다. 주로 읽기 전용으로 사용하거나 존재 여부만 확인할 때 많이 활용된다.

다음 예제를 보자.

import java.util.ArrayList;

class Box<T> {
    private ArrayList<T> list = new ArrayList<>();
    void add(T item) { list.add(item); }
    ArrayList<T> getList() { return list; }
}

class FruitBoxExample {
    public static void printBox(Box<?> box) {
        for (Object item : box.getList()) {
            System.out.print(item + " ");
        }
        System.out.println();
    }

    public static void main(String[] args) {
        Box<Apple> appleBox = new Box<>();
        Box<Banana> bananaBox = new Box<>();

        appleBox.add(new Apple());
        appleBox.add(new Apple());
        
        bananaBox.add(new Banana());
        bananaBox.add(new Banana());

        printBox(appleBox);   // Apple Apple
        printBox(bananaBox);  // Banana Banana
    }
}

class Fruit {
    public String toString() { return "Fruit"; }
}

class Apple extends Fruit {
    public String toString() { return "Apple"; }
}

class Banana extends Fruit {
    public String toString() { return "Banana"; }
}

이 코드에서 printBox 메서드는 Box<?> 타입을 받는다. <?>를 사용했기 때문에 Box<Apple>, Box<Banana> 등 타입에 상관없이 Box 객체라면 모두 받을 수 있다.

printBox 메서드 내에서는 box.getList()의 요소들을 Object 타입으로 읽기만 하므로, 실제 Box에 어떤 타입의 요소가 들어있는지 신경 쓰지 않아도 된다. 이를 통해 다양한 타입의 Box 객체를 출력할 수 있으며, 타입별로 메서드를 오버로딩하지 않고도 동일한 로직을 적용할 수 있는 유연성을 제공하게 된다.

출처

[ JAVA ] 제네릭(Generics), 와일드 카드(WildCard)
https://blog-full-of-desire-v3.vercel.app/posts/java/-java--generics/
저자
SpeculatingWook
게시일
2024-11-14
라이선스
CC BY-NC-SA 4.0