제네릭과 타입 파라미터

제네릭은 클래스/인터페이스/메서드가 다룰 타입을 타입 파라미터로 받아, 컴파일 시점에 타입을 강하게 체크한다.

타입 파라미터는 관례적으로 T, E, K, V 같은 단일 대문자를 많이 쓴다.

  • T : Type
  • E : Element
  • K : Key
  • V : Value

사용하는 이유

  • 컴파일 타임 타입 안정성: 잘못된 타입 사용을 컴파일 단계에서 막는다.
  • 캐스팅 제거: 컬렉션에서 꺼낼 때 불필요한 캐스팅과 런타임 예외 위험을 줄인다.

raw type을 쓰면 컴파일러가 타입을 제대로 검사하지 못해, 나중에 꺼내 쓸 때 캐스팅이 필요하고 런타임 오류 가능성이 커진다.

제네릭 클래스/인터페이스

선언부에 하나 이상의 타입 파라미터를 두면 제네릭 클래스/인터페이스라 부른다.

 

타입 파라미터 도입으로 타입 변수(Type Variable)를 필드/생성자/메서드 같은 멤버 수준에서 사용할 수 있다.

class Box<T> {
    private T item;
    public void set(T v) { this.item = v; }
    public T get() { return item; }
}

다만 정적 컨텍스트에서는 클래스 타입 파라미터를 사용할 수 없다.

class Box<T> {
    static T x; // 컴파일 에러
    static <T> T ok(T v) { return v; } // OK. 제네릭 메서드의 T는 클래스의 T와 별개
}

클래스의 T인스턴스마다 결정되는데, 정적 멤버는 클래스 레벨에서 공유되므로 어떤 T 를 써야 하는지 결정할 수 없다.

제네릭 인터페이스 구현 패턴

크게 두 가지 방법이 있다.

  1. 타입 고정: 인터페이스의 타입 파라미터를 구현 시점에 구체 타입으로 확정
  2. 제네릭 전파: 구현 클래스도 타입 파라미터를 선언해 그대로 전달
interface Repository<T> {
    void save(T entity);
    T findById(String id);
}

// 타입 고정
class UserRepository implements Repository<User> {
    public void save(User u) { ... }
    public User findById(String id) { ... }
}

// 제네릭 전파
class TestRepository<T> implements Repository<T> {
    public void save(T e) { ... }
    public T findById(String id) { ... }
}
  • 타입 고정은 UserRepository가 User 전용으로 동작하게 만든다.
  • 제네릭 전파는 TestRepository<User>, TestRepository<Order> 처럼 재사용 가능한 범용적인 구현체가 된다.

멀티 타입 파라미터와 다이아몬드 연산자

타입을 한 개뿐만 아니라 여러 개를 선언할 수도 있다.

class Pair<K, V> {
    K key;
    V value;
}

 

다이아몬드 연산자 <> : 타입 파라미터를 유추해서 자동으로 설정해준다. (자바 7부터 도입)

// 다이아몬드 없을 때
List<String> a = new ArrayList<String>();

// 다이아몬드 사용
List<String> b = new ArrayList<>();

제네릭 메서드

자체적으로 매개 타입과 리턴 타입으로 타입 파라미터를 갖는 메서드를 말한다.

메서드 선언부(리턴 타입 앞)에 <> 기호를 붙이고 이 기호 안에 타입 파라미터를 선언하면 된다.

public <타입파라미터, ...> 리턴타입 메서드명(매개변수, ...) { ... }

이런 메서드를 호출하는 방법

  • 타입 파라미터 명시
  • 인자를 보고 타입 추론
// 명시적 타입 지정
리턴타입 변수 = 클래스.<구체적인 타입>메서드명(인자);    // static 메서드
리턴타입 변수 = 객체참조.<구체적인 타입>메서드명(인자);  // instance 메서드

// 타입 추론
리턴타입 변수 = 메서드명(인자);

제한된 타입 파라미터(Bounded Type Parameters)

타입 파라미터에 제약을 걸어 특정 타입의 하위 타입만 허용할 수 있다

<T extends 상위타입>

여기서 T 는 상위 타입이거나 상위 타입의 하위 타입 또는 구현 클래스만 올 수 있다.

클래스, 인터페이스, 메서드 모든 곳에 적용 가능하다.

 

왜 필요하지? ⇒ 제약이 없으면 T 의 상위 타입은 Object 로, 사용 가능한 것은 Object 의 멤버로 제한된다. 바운드를 주면 해당 타입의 멤버 사용이 가능해진다.

class Box<T extends Number> {
    T value;
    double toDouble() {
        return value.doubleValue(); // Number 메서드 사용 가능
    }
}

만약에 위 예시 처럼 Number 로 제한을 걸면, 메서드에서 해당 타입과 관련된 멤버를 사용할 수 있다.

 

여러 개의 제약도 걸 수 있다.

// 클래스가 먼저 와야 하고, 인터페이스는 뒤에 (클래스는 최대 1개)
public class MultiBox<T extends Number & Comparable<T> & Serializable> {
    // T는 Number를 상속하고, Comparable과 Serializable을 구현해야 함
}

와일드카드

와일드카드인 ? 는 알 수 없는 타입을 나타내며, 제네릭 타입을 파라미터로 받는 메서드에서 유연성을 제공한다.

변성(Variance)

  • A <: B : A는 B의 하위 타입(subtype)이다.
    • Integer <: Number
    • ArrayList<String> <: List<String>
  • 공변(covariant): A <: B 일 때, G<A> <: G<B> 처럼 취급
    • 배열은 대표적인 공변이다. ⇒ A[] <: B[]
  • 반공변(contravariant): A <: B 일 때, G<B> <: G<A> 처럼 취급
  • 불공변(invariant): 서로 관계 없음
    • 제네릭은 기본적으로 불공변

세 가지 종류가 있다.

비제한 와일드카드(Unbounded Wildcard)

사용: ?

어떤 타입이든 허용한다. 원소 타입을 알 수 없어서 원소는 Object 로만 안전하게 다룰 수 있다

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

원소 타입에 의존하지 않고 컬렉션을 다루고 싶을 때(예: 순회/출력/크기 확인 등) 사용할 수 있다.

상한 와일드카드(Upper Bounded Wildcard)

사용: ? extends T

원소 타입은 T 또는 T 의 하위 타입을 허용한다. ⇒ 공변성 제공

읽기 중심일 때 유리하다.

public double sumOfList(List<? extends Number> list) {
    double sum = 0.0;
    for (Number n : list) {
        sum += n.doubleValue();
    }
    return sum;
}

sumOfList(Arrays.asList(1, 2, 3));           // List<Integer>
sumOfList(Arrays.asList(1.5, 2.5, 3.5));     // List<Double>
sumOfList(Arrays.asList(new BigDecimal(1))); // List<BigDecimal>
  • List<?>, List<? extends T>: 읽기 OK, addnull 만 가능

하한 와일드카드(Lower Bounded Wildcard)

사용: ? super T

원소 타입이 T 또는 T 의 상위 타입을 허용한다. ⇒ 반공변성 제공

읽는 건 제한되지만, 쓰기가 가능하다.

public void addIntegers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
    list.add(3);
}

addIntegers(new ArrayList<Integer>());
addIntegers(new ArrayList<Number>());
addIntegers(new ArrayList<Object>());

// --------------

List<? super Integer> list = new ArrayList<Number>();

list.add(1);              // OK
list.add(2);              // OK
Integer i = list.get(0);  // 컴파일 에러
Object o = list.get(0);   // OK - Object로만 읽기 가능
  • List<? super T>: 쓰기 OK, 읽기는 Object로만 안전

PECS 원칙

⇒ Producer Extends, Consumer Super

Producer와 Consumer를 컬렉션 입장에서 생각하면 된다.

  • Producer ⇒ 데이터를 생산해서 제공한다. (우리는 읽기 get). ? extends T 사용
  • Consumer ⇒ 데이터를 받아서 소비한다. (우리가 쓰기 add). ? super T 사용
// Producer: 컬렉션이 데이터를 생산해서 우리에게 준다
List<? extends Number> producer = ...;
Number n = producer.get(0);  // 컬렉션 -> 우리 (읽기)

// Consumer: 컬렉션이 우리가 생산한 데이터를 소비한다
List<? super Integer> consumer = ...;
consumer.add(526);            // 우리 -> 컬렉션 (쓰기)
  • ? extends Number: Number의 하위 타입 중 뭔지 모름
    • 꺼내면 최소한 Number 는 보장됨 → 읽기 안전
    • 넣으려면? List<Integer>일 수도, List<Double>일 수도 있어서 뭘 넣어야 할지 모름 → 쓰기 불가
  • ? super Integer: Integer의 상위 타입 중 뭔지 모름
    • Integer 넣으면 NumberObject든 다 받을 수 있음 → 쓰기 안전
    • 꺼내면? Object로만 보장됨 → 읽기 제한

타입 소거(Type Erasure)

먼저 용어를 정리하고 가자

  • 매개변수화된 타입(Parameterized Type): 제네릭 클래스나 인터페이스에 구체적인 타입 인자(Type Arguments)를 제공해 만든 타입. (예: List<String>, Map<Integer, String>)

타입 소거는 매개변수화된 타입과 타입 변수를 포함한 타입을, 파라미터화되지 않은 타입으로 변환하는 매핑이다.

즉, 제네릭은 컴파일 타임에만 존재하고, 런타임에는 타입 정보가 소거된다.

⇒ Java 5 이전 코드와의 하위 호환성 목적

// 원본 코드
public class Box<T> {
    private T item;

    public T get() {
        return item;
    }

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

// 타입 소거 후
public class Box {
    private Object item;

    public Object get() {
        return item;
    }

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

제한된 타입 파라미터의 경우, 소거 될 때 Object 대신 첫 번째 바운드로 대체된다.

// 컴파일 전
public class Box<T extends Number> {
    private T item;
}

// 타입 소거 후 -> 바운드 타입으로 대체
public class Box {
    private Number item;
}

여러 개의 제한된 타입이 있을 경우 역시 첫 번째 바운드로 대체된다.

// 컴파일 전
public class MultiBox<T extends Number & Comparable<T>> {
    private T item;
}

// 타입 소거 후 -> 첫 번째 바운드(Number)로 대체
public class MultiBox {
    private Number item;
}

브릿지 메서드

타입 소거로 인해 메서드 시그니처가 변경되면 다형성이 깨질 수 있기 때문에, 컴파일러가 synthetic bridge 메서드라는 것을 생성한다.

public class Box<T> {
    private T item;

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

    public T get() {
        return item;
    }
}

public class StringBox extends Box<String> {
    @Override
    public void set(String item) {  // 오버라이드 의도
        super.set(item);
    }

    @Override
    public String get() {  // 공변 반환 타입
        return super.get();
    }
}

이런 코드가 있을 때, 타입이 소거가 됐을 때 메서드를 보면

// Box의 소거된 메서드
public void set(Object item)
public Object get()

// StringBox의 메서드
public void set(String item)
public String get()

시그니처가 달라져서 오버라이드가 아닌 오버로드가 된다.

 

그래서 컴파일러는 아래와 같이 메서드를 만들어준다.

// 컴파일러가 StringBox에 자동 생성
public class StringBox extends Box {

    // 실제 구현 메서드
    public void set(String item) {
        super.set(item);
    }

    public String get() {
        return super.get();
    }

    // 브릿지 메서드 (synthetic)
    public void set(Object item) {
        set((String) item);  // 실제 메서드로 위임
    }

        // 브릿지 메서드 (synthetic)
        // 소스 레벨에서는 같은 get()처럼 보이지만,
        // 바이트코드에서는 디스크립터가 다름:
        //   - 브릿지: get()Ljava/lang/Object;
        //   - 실제:   get()Ljava/lang/String;
        // JVM은 이름 + 디스크립터로 메서드를 구분하므로 무한 재귀가 아님
        public Object get() {
            return get();
        }
}

(참고: 위 코드는 바이트코드에 생성되는 브릿지 메서드를 의사코드로 표현한 것이며, 자바 소스 코드로 그대로 작성할 수 없다.)

힙 오염(Heap Pollution)

매개변수화된 타입 변수가, 그 매개변수화된 타입이 아닌 객체를 참조하게 되는 현상을 의미한다.

프로그램이 컴파일 타임에 비검사(unchecked) 경고를 발생시키는 어떤 연산을 수행했을 때 나타날 수 있다.

 

힙 오염이 생긴 근본적인 원인은 타입 소거다.

why? 타입 소거 → 런타임에 제네릭 타입 정보 없음 → 잘못된 타입 삽입 감지 불가 → 힙 오염

 

몇 가지 예시를 보면서 이해해보자.

 

Raw 타입을 사용하는 경우

List<String> strings = new ArrayList<>();

List raw = strings;        // unchecked
raw.add(526);               // OK -> 힙 오염 발생

String s = strings.get(0); // ClassCastException 발생

List<String>Integer(526)가 들어가 힙 오염이 발생한다.

 

Unchecked cast로 인한 힙 오염

List<Integer> ints = new ArrayList<Integer>();
ints.add(1);

Object obj = ints;

List<String> strings = (List<String>) obj; // unchecked cast 경고
strings.add("hello");                      // OK -> 힙 오염 발생

for (Integer i : ints) {
    System.out.println(i);
}

// 결과
1
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')

 

가변인자(Varargs) 사용으로 인한 힙 오염

static void pollution(List<String>... lists) {
    Object[] arr = lists;        // 런타임에는 List[]로 보임
    arr[0] = List.of(526);       // 힙 오염
    String s = lists[0].get(0);  // ClassCastException (Integer -> String)
}

List<String>... 는 컴파일 시 타입 인자가 소거되어 파라미터가 List[] 로 취급된다.

 

만약 가변인자에 대한 힙 오염이 없음을 개발자가 보장한다면, @SafeVarargs 어노테이션을 사용할 수 있다.

@SafeVarargs
static <T> List<T> safe(T... elements) { ... }

Reifiable Types

  • reify 뜻 : <추상 개념 등을> 구체[구상]화하다, 구체화하여 생각하다 (네이버 사전)

타입 소거 과정에서 정보 손실이 없어, 런타임에 타입 정보가 완전히 보존되는 타입을 말한다.

 

종류

  • 원시 타입(primitive type)
    • int, long, float, double, boolean, char, byte, short
  • 제네릭이 아닌 일반 클래스/인터페이스
    • String, Integer, Object, Runnable, Exception 등
  • Raw type → 타입 인자 없이 사용된 제네릭 타입
    • List, Map, ArrayList 등
  • 모든 타입 파라미터가 Unbounded Wildcard인 파라미터화된 타입
    • List<?>, Map 등
  • 중첩된 경우, 외부 타입과 내부 타입이 모두 reifiable이어야 한다.
    • Outer.Inner, Outer<?>.Inner<?>

Non-Reifiable Types

타입 소거로 인해 런타임에 타입 정보가 부분적으로 손실되는 타입을 말한다.

 

종류

  • 타입 변수
  • Bounded Wildcard를 포함한 파라미터화된 타입
  • 구체적인 타입 인자를 가진 파라미터화된 타입

Non-Reifiable 타입으로 캐스팅하면 unchecked cast 경고가 발생한다.

⇒ 런타임 시 잠재적으로 ClassCastException 이 발생 가능하기 때문

예를 들어, 타입 소거가 발생하면 List<String>List<Integer> 는 구분이 안된다. 둘 다 List 로 보인다.

 

Non-Reifiable 타입의 배열은 생성할 수 없다.

List<String>[] stringLists = new List<String>[10]; // 불가

⇒ 배열의 공변성과 타입 안정성이 충돌하기 때문.

 

배열은 자신이 실제로 어떤 타입인지 런타임에 알고 있어서 잘못된 타입 저장을 할 경우, ArrayStoreException 을 발생시킨다.

 

그런데 Non-Reifiable 타입은 소거되어 런타임에 구분할 수 없다.

// 만약 허용된다고 가정하면
List<String>[] stringLists = new List<String>[10];

// 배열은 공변이므로 Object[]로 참조 가능
Object[] objects = stringLists;

// 런타임에는 List 로만 보임 (타입 소거)
// ArrayStoreException 없이 잘못된 타입을 저장할 수 있다.
objects[0] = new ArrayList<Integer>();

참고로 List<?>[]처럼 reifiable인 경우는 배열 생성이 가능하다.

제네릭 제약사항

타입 소거로 인해 발생하는 제약들이 있다.

 

타입 파라미터로 인스턴스 생성 불가

public <T> void create() {
    T obj = new T();           // 컴파일 에러
    T[] arr = new T[10];       // 컴파일 에러
}

런타임에 TObject 로 소거되면서 실제 어떤 클래스의 생성자를 호출해야 하는지 알 수 없다. new T() 는 소거 후 new Object()가 되어버리는데, 이는 의도한 타입과 다르게 된다.

배열도 마찬가지로 런타임에 배열의 실제 컴포넌트 타입을 알아야 생성할 수 있는데, T 가 소거되면 이 정보를 알 수 없다.

 

instanceof 에 매개변수화된 타입 사용 불가

if (obj instanceof List<String>) { }  // 컴파일 에러
if (obj instanceof List<?>) { }       // OK (Unbounded는 가능)

instanceof 는 런타임에 타입을 검사하는데, 타입 소거로 인해 List<String>List<Integer> 는 런타임에 둘 다 List 로만 보인다. 구분이 불가능하므로 컴파일러가 허용하지 않는다.

List<?> 는 reifiable 타입이라 런타임에도 List 인지 확인만 하면 돼서 허용된다.

 

제네릭 예외 클래스 불가

class MyException<T> extends Exception { }  // 컴파일 에러

JVM에서 예외를 처리할 때, catch 블록에서의 예외 타입을 런타임에 매칭한다. 타입 소거로 인해 MyException<String>MyException<Integer> 가 구분되지 않으므로, 어떤 catch 블록을 실행해야 할지 결정할 수 없다.

 

정적 컨텍스트에서 클래스 타입 파라미터 사용 불가

class Box<T> {
    static T item;              // 컴파일 에러
    static void print(T t) { }  // 컴파일 에러
}

클래스의 타입 파라미터 T 는 인스턴스 생성 시점에 결정된다. 정적 멤버는 인스턴스 없이 클래스 레벨에서 공유되므로, 어떤 T 를 사용해야 하는지 알 수 없다.

 

만약 위 코드가 만약 허용된다고 해보자

Box<String> stringBox = new Box<>();
Box<Integer> intBox = new Box<>();

이런 코드가 있을 때 Box.itemString 인가? Integer 인가?