Generic과 Wildcard의 이해 : 타입 안정성 및 다형성

zayn_noh·2026년 5월 18일

Java 개념 정립

목록 보기
4/8
post-thumbnail

Java에서 표준 라이브러리 내 클래스, 인터페이스들의 구현 코드를 살펴보면
다음과 같은 문자를 볼 수 있다.

// HashMap 클래스
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    ...
    
// List 인터페이스
public interface List<E> extends SequencedCollection<E> {
	...
    
// Comparable 인터페이스
public interface Comparable<T> {
	...

이번 포스팅에서는 JDK5에서 처음 도입되었으며
이제는 모른다면 Java API문서조차 제대로 읽기 힘든,

제네릭(Generic)에 대하여 학습하며,
제네릭의 다형성을 위해 고안된 와일드카드(Wildcard)를 배우고

List<T>
List<?>
List<Object>

최종적으로 Object클래스와 함께 위 코드를 비교-이해함을 목표로 한다.


제네릭(Generic) : T

Generic은 간단하게 데이터 타입을 클래스나 메서드가 사용할 때까지 지정하지않고
나중에 지정해줄 수 있도록 해주는 타입 매개변수이다.

사용할 때라 함은 런타임 시에 데이터 타입이 결정된다는 것일까?
그렇다면 런타임 다형성이라 볼 수 있지 않을까?

아니다. 제네릭의 경우 컴파일 시점에 타입이 고정되며,
이를 통해 잘못된 타입이 들어오는 것을 사전에 막을 수 있다.

즉, 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에
컴파일 시의 타입 체크(compile-time type check)를 해주는 기능이라 할 수 있다.


제네릭(Generic)의 필요성

다음 코드는 인자로 전달받은 두 객체의 total값을 비교하는 클래스의 예시이다.

class TotalComparator implements Comparator {
	public int compare(Object o1, Object o2) {
    	if (o1 instanceof Student && o2 instanceof Student) {
        	Student s1 = (Student) o1;
            Student s2 = (Student) o2;
            
            return Integer.compare(s1.getTotal(), s2.getTotal());
        }
        
        return -1;
    }
}

위 코드에서 비교 메서드의 매개변수로 Object 타입을 지정한 이유는
모든 타입을 비교할 수 있게 하기 위해서이다.

이를 통해 매개변수의 타입이 다른 무수히 많은 버전의 compare() 오버로딩을 막는다.

이처럼 매개변수로 Object클래스의 타입을 지정하면
메서드 오버로딩으로 인한 코드의 중복을 제거할 수 있지만

실제 타입 정보를 알 수 없기에
컴파일러의 주요 기능인 타입 체크가 동작되지 않는다는 단점이 있다.

때문에 위 코드에선 instanceof로 실제 타입을 체크하고,
관련 로직을 수행하는 코드를 추가로 작성한 것이다.

물론 위 코드에서 최상위 클래스 Object가 아닌 실제 클래스 타입을 매개변수로 지정하면
다음처럼 깔끔하게 코드를 수정할 수 있다.

public int compare(Student s1, Student s2) {
	return Integer.compare(s1.getTotal(), s2.getTotal());
}

다만 이러한 방식을 통해 구현한 compare() 메서드는
오직 Student 객체를 매개변수로 넘겨주었을 때만 컴파일 시 문제없이 진행될 것이다.

이에 어떤 타입의 객체도 올 수 있으나,
컴파일 시점에 잘못된 타입의 객체가 들어오는 것을 사전에 막아주기까지 하는
제네릭(Generic)의 필요성을 느낄 수 있는 것이다.

어떻게 제네릭을 사용하도록 TotalComparator 클래스를 수정할 수 있을까?


제네릭(Generic)의 사용법

제네릭타입은 클래스메서드에 선언할 수 있다.

1) 클래스에 선언하는 경우

// 제네릭 사용 전
class Box {
	Object item;
    
    void setItem(Object item) { this. item = item; }
    Object getItem() { return this.item; }
}

// 제네릭 사용
class Box<T> {
	T item;
    
    void setItem(T item) { this.item = item; }
    T getItem() { return this.item; }
}

이때 Box<T>T를 타입변수라 하며, 타입변수 T는 T가 아닌 다른 것을 사용해도 된다.

관례적으로 다음과 같은 타입변수들이 주로 쓰인다.
- T : Type
- E : Element
- K : Key
- V : Value
- N : Number

  1. 지네릭 클래스에서 static멤버에는 타입변수 T사용할 수 없다.
class Box<T> {
	static T item; // error
	static int compare(T t1, T t2) {...} // error
}
  1. 제네릭 타입의 배열생성하는 것도 허용되지 않는다.

    제네릭 배열 타입의 참조변수를 선언하는 것은 가능하지만
T[] itemArr;

다음의 예시처럼 배열을 생성하는 것은 안 된다.

T[] toArray() {
	T[] tmpArr = new T[itemArr.length]; // error
}

이에 대한 자세한 설명은 제네릭의 제약사항과 타입소거에서 설명하도록 한다.

이제 다음 Box<T> 제네릭 클래스의 실제 객체를 만드는 경우를 살펴보자

class Box<T> {
	ArrayList<T> list = new ArrayList<T>();
    
    void add(T item) { list.add(item); }
	T get(int i) { return list.get(i); }
    ArrayList<T> getList() { return list; }
    int size() { return list.size(); }
    public String toString() { return list.toString(); }
}

실제 객체 생성 및 사용

Box<Apple> appleBox = new Box<Apple>(); // ok
Box<Apple> appleBox = new Box<>(); // ok
Box<Apple> appleBox = new Box<Grape>(); // error

제네릭 클래스의 객체를 생성할 때는,
참조변수와 생성자에 대입된 타입(매개변수화된 타입)이 일치해야한다.
일치하지 않다면 에러가 발생한다.

  1. 생성 시에 상속관계에 있다 하여도 에러가 발생한다.
Box<Fruit> appleBox = new Box<Apple>(); // error
  1. 다음과 같은 메서드의 매개변수로 상속관계에 있는 객체는 사용 가능하다.
Box<Fruit> fruitBox = new Box<Fruit>();
fruitBox.add(new Fruit()); // ok
fruitBox.add(new Apple()); // ok, 다형성
  1. 제네릭 클래스끼리 상속되어있고, 대입된 타입이 같은 것도 가능하다.
Box<Apple> appleBox = new FruitBox<Apple>(); // ok, 다형성

이는 상속을 통한 다형성 구현이라는 객체지향의 개념과도 일치한다.


* 제네릭 제한 사용

타입 변수 T에 지정할 수 있는 타입을 다음처럼 추가 제한할 수 있다.

T extends Fruit

  • Fruit클래스와 이를 상속받은 클래스

    T extends Eatable
  • 인터페이스의 구현체 역시 extends를 사용한다.

    동시에 제한을 두고 싶다면 &기호를 통해 연결하여 준다.
class FruitBox<T extends Fruit & Eatable> { ... }

2) 메서드에 선언하는 경우

static <T> void sort(List<T> list, Comparator<? super T> c)

위 코드는 Collections.sort() 메서드의 선언부이다.
이를 제네릭 메서드(Generic Method)라 하며,
제네릭 타입의 선언은 반환 타입 바로 앞에 위치한다.
( 제네릭 메서드는 제네릭 클래스가 아니어도 선언 가능하다 )

class FruitBox<T> {
	...
    static <T> void sort(List<T> list, Comparator<? super T> c) {
	    ...
    }
}

위 코드를 보았을 때, 얼핏보면 제네릭 클래스의 선언부 FruitBox<T>T
sort()메서드의 인자의 매개변수화된 타입변수 T로 쓰는 것 같지만

T는 문자만 같을 뿐 서로 다른 것이다.

또한 sort() 함수를 보면 static 제어자가 붙은 것을 알 수 있는데
static멤버에는 앞서 살펴본 것처럼 타입변수를 사용할 수 없지만
이처럼 제네릭 메서드의 경우에는 선언하고 사용하는 것이 가능하다.


Wildcard : ?

<? extends T> <? super T> <?>
와일드카드(Wildcard)는 제네릭의 다형성을 위한 개념이다.

와일드카드를 사용하면 하나의 참조변수로 대입된 타입이 다른 여러 객체를 다룰 수 있다.
단, 참조변수에만 와일드카드의 사용이 가능하다.
-> 객체를 생성하고자 할 때는, 명확한 하나의 타입이 지정되어야 하기 때문이다.

FruitBox<? extends Fruit> box = new FruitBox<Fruit>(); // ok
FruitBox<? extends Fruit> box = new FruitBox<Apple>(); // ok
FruitBox<? extends Fruit> box = new FruitBox<Grape>(); // ok
  • Fruit 자손 클래스들을 타입변수로 FruitBox 제네릭 클래스를 생성 가능하다.
FruitBox<? super Fruit> box = new FruitBox<Fruit>(); // ok
FruitBox<? super Fruit> box = new FruitBox<Object>(); // ok
  • Fruit클래스와 Fruit 클래스의 조상인 Object클래스 역시 타입변수로 대입이 가능하다.

Object와의 비교

다시 처음으로 돌아가 아래 코드가 의미하는 것이 무엇인지 확인하여 보자

List<T>
List<?>
List<Object>

- List<T>의 경우,
메서드가 아닌 List클래스의 타입변수로 T가 작성되어있는 것을 보아

class A<T> {
	List<T> list;
    ...
    void setList(List<T> list) { this.list = list; }
    List<T> getList() { return this.list; }
}

위 예시처럼 제네릭 클래스에서 static이 아닌 멤버변수,
static이 아닌 메서드의 반환값 혹은 매개변수 코드의 일부라 생각할 수 있다.


List<?>의 경우

앞선 List<T>와 기능적으로 동일하나, 와일드카드로 작성된 것임을 알 수 있다.
<?>의 경우 <? extends Object>와 동일하며

List<? extends T> list;처럼 멤버 변수에 작성할 경우

클래스의 타입 변수 T에 따른 특정 클래스 혹은
이를 상속한 클래스의 객체가 list의 요소로 들어갈 수 있는 것이다.


List<Object>의 경우

List<Object> list = new ArrayList<Object>();

위와 같이 생성된 list에는 Object 클래스를 상속받은 모든 클래스가
list 객체에 들어갈 수 있으며

Object 클래스는 모든 클래스의 조상이기에
대부분의 클래스가 자동 업캐스팅되어 list의 요소로 들어갈 것이다.

즉, List<T>에 타입 변수를 Object클래스 타입으로 한 것과 동일하다.

profile
개발 및 IT기술에 대해 정리하고 기록합니다.

0개의 댓글