자바

[JAVA] 제네릭, 와일드카드, PECS(미완)

seaniiio 2025. 3. 14. 16:48

제네릭을 왜 쓰지?

제네릭을 사용하면 데이터의 타입을 특정 타입으로 고정할 수 있고, 어떤 타입의 객체든 받아올 수 있도록 해준다.

예를 들면 아래와 같이 사용할 수 있다.

// 제네릭 클래스 Box
public class Box<T> {

    private List<T> values;

    public Box() {
        this.values = new ArrayList<>();
    }

    public void add(T t) {
        this.values.add(t);
    }

    public T get(int index) {
        return this.values.get(index);
    }
}

Box에는 어떤 타입이든 들어갈 수 있다. 다만 어떤 타입이든 타입 하나로 고정되기 때문에, 해당 타입만을 받을 수 있다.

Box<String> stringBox = new Box<>();
stringBox.add("집에");
stringBox.add("가고");
stringBox.add("싶다");

String firstValue = stringBox.get(0); // 집에

 

 

🤔 그런데 Object가 모든 객체의 상위 타입이니까, Object를 사용하면 되지 않을까? Object에는 어떤 타입이든 넣어줄 수 있는데 제네릭이랑 뭐가 다르지?

public class ObjectBox {

    private List<Object> values;

    public ObjectBox() {
        this.values = new ArrayList<>();
    }

    public void add(Object t) {
        this.values.add(t);
    }

    public Object get(int index) {
        return this.values.get(index);
    }
}

 

ObjectBox 클래스는 제네릭 클래스와 다르게 Object들을 저장한다. 따라서 어떤 타입이든 들어올 수는 있다.(자바의 모든 객체는 Object를 상속받기 때문)

ObjectBox objectBox = new ObjectBox();
objectBox.add("집에");
stringBox.add("가고");
objectBox.add("싶다");

String firstValueOfObjectBox = (String) objectBox.get(0);

 

Object를 사용하면, 값을 빼올 때 캐스팅이 필요하다. 현재 Box 내부의 value는 Object로 관리되고 있기 때문에, String을 받아오려면 캐스팅이 필요하다. 이보다 더 심각한 문제가 있는데, 타입 안전성이 떨어진다는 것이다.

 

예시 코드로 확인해보자.

ObjectBox stringBox = new ObjectBox();
stringBox.add("집에");
stringBox.add("가고");
stringBox.add("싶다");
stringBox.add(6);
stringBox.add("시에");

String value1 = (String) stringBox.get(0);
String value2 = (String) stringBox.get(1);
String value3 = (String) stringBox.get(2);
String value4 = (String) stringBox.get(3);
String value5 = (String) stringBox.get(4);

stringBox는 Object를 받기 때문에, String을 모으는 용도를 의도했으나, 6이라는 Integer도 받을 수 있다. 만약 stringBox에 String만 들어가있다고 착각해서, 내부의 모든 값을 String으로 캐스팅하면 어떻게 될까?

 

Integer를 String으로 잘못된 캐스팅해주고 있는데, 컴파일 타임에 잡을 수 없다. 즉, 잘못된 코드를 작성하는 상황이 발생할 수 있다. 이러한 경우가 타입 안전성이 떨어지는 예시이다.

 

그런데 제네릭을 이용하면 타입 안전성을 보장할 수 있다. 제네릭 타입을 사용한다면 타입이 고정이 되기 때문이다.

Box<String> stringBox = new Box<>();
stringBox.add("집에");
stringBox.add("가고");
stringBox.add("싶다");
stringBox.add(6); // 에러 발생!
stringBox.add("시에");

이 경우, 우리는 Box 내부에서 사용할 타입을 String이라고 명시해줬다. 그렇기 때문에 stringBox에는 String만 들어갈 수 있고, 다른 타입을 넣으려고 시도하는 순간 컴파일 에러가 발생한다. 

 

제네릭은 이렇게 타입 체크와 형변환을 생략할 수 있고, 타입 안전성을 보장할 수 있다는 장점이 있다.

 

제한된 타입 매개변수(Bounded Type Parameters)

제네릭을 사용하면, 말 그대로 어떤 타입이든 들어올 수 있다. 그런데, 제네릭으로 받아올 수 있는 타입을 제한하는 방법이 있다.

<T extends 상위클래스>

이런 형태로 사용하면 되는데, 이렇게 적으면 T에는 상위 클래스의 하위 타입만이 들어올 수 있다. 즉, 내가 제네릭으로 받아오고자 하는 타입을 제한할 수 있다.

 

예를 들어보자. "일단 뭐가 들어올지는 모르지만, 숫자 관련 타입(Integer, Double, Float ...)을 받아와야 하는 경우"가 있을 수 있다. 

public class BoundedExample {

    // T는 Number 또는 그 하위 타입만 가능
    public static <T extends Number> double sum(T num1, T num2) {
        return num1.doubleValue() + num2.doubleValue();
    }

    public static void main(String[] args) {
        System.out.println(sum(5, 10)); // 15.0

        System.out.println(sum(5.5, 10.5)); // 16.0

        // 아래는 컴파일 오류
        // System.out.println(sum("Hello", "World"));
    }
}

 

이 경우 상위 타입을 Number로 제한해 줄 수 있다. 그렇게 하면 Number의 하위 클래스인 Integer, Double은 들어올 수 있으나 Number를 extends하지 않는 String은 들어올 수 없다.

 

와일드카드는 왜 쓰지?

와일드카드는 제네릭의 타입을 유연하게 처리할 수 있게 해주는 기법으로, 특정 타입을 지정하지 않고 범위를 확장하거나 축소할 수 있다. 와일드카드는 주로 메서드의 매개변수나 반환값에서 사용된다.

public void printList(List<?> list) {
    for (Object obj : list) {
        System.out.println(obj);
    }
}

위 예시에서 List<?>는 어떤 타입의 리스트든 받을 수 있다는 의미이다. 즉, List<String>, List<Integer> 모두 가능하지만, List<?>로 선언되었기 때문에 해당 리스트에 값을 추가하거나 수정할 수는 없다.

 

뭐든 받을 수 있는건 T나 ?나 똑같은데 뭐가 다른걸까?

 

❗️제네릭은 List<T>에 List<String>을 받으면 T가 String으로 정해지지만, 와일드카드는 List<?>에 List<String>을 받아도 타입이 정해지지 않는다.

- T: 어떤 타입이든 받을 수 있다. 한 번 받으면 그 타입으로 고정된다. (이후에는 그 정해진 타입에 맞는 객체만 처리한다.)

- ?: 어떤 타입이든 들어올 수 있는데, 뭔지는 모른다.(뭐가 들어와도 상관이 없다.) 고정되지 않는다.

 

그런데 이렇게만 보면 와일드카드는 별로 쓸모가 없는 것 같은데 왜 생긴걸까? 그냥 제네릭을 쓰면 되는 게 아닐까?

 

챗 지피티에게 와일드카드를 왜 사용하는지 물어봤다.

와일드카드는 결국, 제네릭을 유연하게 다루기 위해 탄생했다고 한다.

 

그런데 제네릭이 어디가 안 유연한걸까? 제네릭은 모든 타입을 받을 수 있기에 매우 유연한 게 아닌가?

제네릭은 사실 불공변이다. 그래서 와일드카드를 이용하면 이를 개선할 수 있다.

불공변은 뭐지?

공변타입 계층에서 자식 타입을 부모 타입으로 바꿀 수 있는 경우를 말한다.

예를 들면 다음과 같다.

Object object = "123"

String을 상위 타입인 Object에 넣을 수 있다.

 

그렇다변 불공변은 자식 타입을 부모 타입으로 바꿀 수 없는 경우일 것이다.

List<Object> objects = new ArrayList<String>(); // 🔥 컴파일 에러 발생!

Object가 String의 상위 타입인데 왜 넣을 수 없을까?

제네릭은 불공변이기 때문이다. <Object>에 <String>을 넣을 수 없다.

 

제네릭은 타입 안전성을 보장하기 위해 불공변으로 설계되었다. 제네릭에서는 하위 타입의 객체를 상위 타입의 변수에 넣을 수 없도록 제한하는데, 이를 통해 런타임에서 타입 캐스팅 문제를 방지할 수 있다.

 

❓그런데 제네릭이 불공변이기 때문에 불편함(유연성 떨어짐)이 생길 수 있다.

List<Number> numberList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();

// numberList를 integerList에 할당하려고 하면 컴파일 에러 발생
// integerList = numberList;

 

그런데 와일드카드를 사용하면 불공변을 개선할 수 있다. 

List<? extends Number> list = new ArrayList<Integer>();
Number number = list.get(0);  // 읽기는 가능
// list.add(10);  // 불가능 - 쓰기는 불가능

List<Integer>는 List<Number>로 변환할 수 없지만(불공변),

List<Integer> List<? extends Number>로 변환 가능하다.(불공변 개선)

 

와일드카드는 제네릭과 달리 고정되지 않고, 어떤 타입이든 들어올 수 있다는 의미이기 때문에, 단순히 <?> 로 사용하면 Object 타입과 다름이 없어진다.

 

그래서 와일드카드는 제네릭 타입의 범위를 제한하는 용도로 사용한다. 상위 제한은 extends, 하위 제한은 super로 할 수 있다. 그리고 이 내용은 PECS로 이어지는데....

 

PECS

 

to be continued...