dev.syw

타입 소거, 와일드카드, 경계, 가변성(공변/반공변)을 통해 안전하고 유연한 제네릭 API를 설계한다.

제네릭과 타입 시스템 심화

입문편에서 List<String>, Map<String, Integer> 같은 제네릭 컬렉션을 사용하는 법을 배웠습니다. 하지만 "왜 List<String>List<Object>에 넣을 수 없는지", "왜 캐스팅 없이도 타입이 맞는지", "와일드카드 ?는 언제 쓰는지"처럼 동작 원리에 관한 의문은 해소되지 않았을 수 있습니다.

이 강에서는 제네릭을 사용하는 것을 넘어 설계하고 이해하는 관점으로 전환합니다. 타입 소거가 런타임에서 어떤 한계를 만드는지, PECS 원칙이 왜 필요한지, 배열과 제네릭이 가변성 측면에서 어떻게 다른지를 깊이 있게 다루고, 재사용 가능한 라이브러리 수준의 API를 직접 설계해 봅니다.

학습 목표

  • **타입 소거(type erasure)**의 동작 원리와 런타임에서 발생하는 제약을 설명할 수 있다.
  • **와일드카드(? extends, ? super)**와 PECS 원칙을 적용해 유연한 메서드 시그니처를 작성할 수 있다.
  • 경계 타입 매개변수(<T extends ...>) 와 다중 경계를 올바르게 선언할 수 있다.
  • 공변·반공변·불변의 개념을 이해하고 배열과 제네릭의 차이를 설명할 수 있다.
  • 제네릭 메서드와 타입 추론을 활용해 재사용 가능한 API를 설계할 수 있다.

타입 소거(Type Erasure)의 동작 원리

Java의 제네릭은 컴파일 타임에만 존재합니다. 컴파일러는 타입 검사를 완료한 뒤 타입 매개변수를 모두 지워 버립니다. 이 과정을 **타입 소거(type erasure)**라고 합니다. 결과물인 바이트코드에는 T 자리에 상한 경계가 없으면 Object가, 있으면 그 경계 타입이 들어갑니다.

// 소스 코드
public class Box<T> {
    private T value;
    public T get() { return value; }
}

// 바이트코드 수준으로 변환된 모습 (javap 결과 요약)
public class Box {
    private Object value;    // T → Object
    public Object get() { return value; }
}

경계가 있는 경우 첫 번째 경계 타입으로 치환됩니다.

// <T extends Comparable<T>> 선언 시
public class SortedBox<T extends Comparable<T>> {
    private T value;
    public T get() { return value; }
}
// 바이트코드: T → Comparable

런타임 한계: 소거로 인해 불가능한 것들

타입 소거 때문에 런타임에는 제네릭 타입 정보가 사라지므로 다음과 같은 코드는 컴파일 오류 또는 논리 오류를 유발합니다.

public <T> void example(Object obj) {
    // ❌ 제네릭 타입으로 instanceof 검사 불가
    if (obj instanceof T) { }          // 컴파일 오류

    // ❌ 제네릭 타입으로 배열 생성 불가
    T[] arr = new T[10];               // 컴파일 오류

    // ❌ 제네릭 타입으로 직접 인스턴스 생성 불가
    T instance = new T();              // 컴파일 오류

    // ✅ 클래스 리터럴을 인자로 넘겨 우회
    // (다음 섹션에서 다룸)
}

⚠️ 주의List<String>List<Integer>는 런타임에 모두 동일한 List 클래스입니다. 따라서 list instanceof List<String> 같은 비교는 불가하며, 오버로딩도 둘을 구별하지 못합니다.

Class 리터럴로 타입 정보 복원하기

소거를 우회해 런타임에도 타입을 유지하려면 Class<T> 를 명시적으로 전달하는 패턴을 씁니다.

public class TypeSafeContainer<T> {
    private final Class<T> type;
    private T value;

    public TypeSafeContainer(Class<T> type) {
        this.type = type;
    }

    public void set(Object obj) {
        // ✅ Class.cast()로 런타임 타입 검사
        this.value = type.cast(obj);
    }

    public T get() { return value; }
}

// 사용
TypeSafeContainer<String> c = new TypeSafeContainer<>(String.class);
c.set("hello");
String s = c.get(); // 안전하게 꺼냄

와일드카드와 PECS 원칙

상한 와일드카드: ? extends T

List<? extends Number>는 "Number 또는 그 하위 타입의 List"를 의미합니다. 이 목록에서는 읽기(get) 는 안전하지만 쓰기(add) 는 금지됩니다. 컴파일러 입장에서 실제 타입이 Integer인지 Double인지 알 수 없기 때문입니다.

public double sum(List<? extends Number> list) {
    double total = 0;
    for (Number n : list) {  // ✅ Number로 읽기 가능
        total += n.doubleValue();
    }
    // list.add(1.5);        // ❌ 쓰기 불가 — 컴파일 오류
    return total;
}

하한 와일드카드: ? super T

List<? super Integer>는 "Integer 또는 그 상위 타입의 List"를 의미합니다. 이 목록에는 쓰기(add) 가 가능하지만 읽기(get) 결과는 Object로만 받을 수 있습니다.

public void fill(List<? super Integer> list, int count) {
    for (int i = 0; i < count; i++) {
        list.add(i);    // ✅ Integer 추가 가능
    }
    // Integer val = list.get(0); // ❌ 컴파일 오류 — Object로만 가능
    Object obj = list.get(0);     // ✅ Object로 읽기는 가능
}

PECS: Producer Extends, Consumer Super

조슈아 블로흐(Joshua Bloch)가 정립한 원칙입니다.

💡 TIPPECS: 매개변수가 데이터를 **생산(produce, 꺼냄)**하면 ? extends, 데이터를 **소비(consume, 삽입)**하면 ? super를 사용하세요.

실제 Collections.copy() 메서드가 이 원칙을 완벽하게 보여 줍니다.

// JDK 구현 (간략화)
public static <T> void copy(
        List<? super T> dest,    // Consumer — T를 받아 넣으므로 super
        List<? extends T> src    // Producer — T를 꺼내 주므로 extends
) {
    for (T item : src) {
        dest.add(item);
    }
}

// 사용 예시
List<Integer> integers = List.of(1, 2, 3);
List<Number>  numbers  = new ArrayList<>();
Collections.copy(numbers, integers); // ✅ Number super Integer, Integer extends Number
목적와일드카드읽기쓰기
데이터를 꺼내 읽을 때? extends TT로 가능불가
데이터를 삽입할 때? super TObject로만T 이하 가능
읽기/쓰기 모두 필요<T> (타입 매개변수)가능가능

경계 타입 매개변수와 다중 경계

단일 경계

<T extends Comparable<T>>처럼 타입 매개변수에 상한을 지정하면, 메서드 본문에서 해당 인터페이스/클래스의 멤버를 직접 호출할 수 있습니다.

public <T extends Comparable<T>> T max(List<T> list) {
    if (list.isEmpty()) throw new IllegalArgumentException("Empty list");
    T result = list.get(0);
    for (T item : list) {
        if (item.compareTo(result) > 0) {  // ✅ Comparable 메서드 직접 호출
            result = item;
        }
    }
    return result;
}

다중 경계

& 연산자로 여러 경계를 동시에 지정할 수 있습니다. 클래스는 첫 번째에, 인터페이스는 그 뒤에 위치해야 합니다.

// ✅ 클래스 먼저, 인터페이스는 & 뒤에
public <T extends Number & Comparable<T> & Cloneable> T clampedMax(List<T> list) {
    return max(list);
}

// ❌ 클래스가 두 개 올 수 없음 — 컴파일 오류
// public <T extends Integer & Long> ...

다중 경계는 여러 능력을 동시에 갖춘 타입만 허용해야 할 때 유용합니다. 예를 들어 직렬화 가능하면서 비교 가능한 엔티티를 다루는 저장소 추상화가 그 전형적인 사례입니다.

public interface Repository<T extends Serializable & Comparable<T>> {
    void save(T entity);
    T findMax();
}

제네릭 메서드와 타입 추론

제네릭 메서드 선언 위치

타입 매개변수는 반환 타입 앞에 선언합니다. 이는 제네릭 클래스와 독립적으로 동작하므로, 일반 클래스 안에도 자유롭게 선언할 수 있습니다.

public class CollectionUtils {

    // <T>가 반환 타입 앞에 위치
    public static <T> List<T> repeat(T element, int times) {
        List<T> result = new ArrayList<>();
        for (int i = 0; i < times; i++) {
            result.add(element);
        }
        return result;
    }

    // 두 개의 타입 매개변수
    public static <K, V> Map<V, K> invertMap(Map<K, V> map) {
        Map<V, K> inverted = new HashMap<>();
        map.forEach((k, v) -> inverted.put(v, k));
        return inverted;
    }
}

타입 추론

Java 8 이상에서는 컴파일러가 문맥으로부터 타입 인수를 추론합니다. 대부분의 경우 명시적으로 적을 필요가 없습니다.

// ✅ 컴파일러가 T=String으로 추론
List<String> words = CollectionUtils.repeat("hello", 3);

// ✅ 명시적 타입 인수 지정 (복잡한 중첩 제네릭 등 일부 상황에서 필요)
List<String> words2 = CollectionUtils.<String>repeat("world", 2);

⚠️ 주의 — 타입 추론이 실패하면 컴파일러는 Object로 추론하거나 오류를 냅니다. 반환 타입만으로 추론이 필요한 경우 명시적 타입 인수를 지정하세요.

재귀적 타입 경계 (Recursive Type Bound)

자기 자신을 참조하는 타입 경계로, 인터페이스 Comparable<T> 구현 강제에 자주 쓰입니다.

// T가 자기 자신과 비교 가능함을 강제
public <T extends Comparable<T>> T min(T a, T b) {
    return a.compareTo(b) <= 0 ? a : b;
}

// 빌더 패턴에서의 재귀적 경계 — 하위 클래스 빌더가 자신의 타입을 반환하도록 강제
public abstract class Builder<T, B extends Builder<T, B>> {
    @SuppressWarnings("unchecked")
    protected B self() { return (B) this; }

    public abstract T build();
}

공변·반공변·불변과 배열 vs 제네릭

가변성 개념

타입 계층과 컨테이너 계층의 관계를 **가변성(variance)**이라 합니다.

가변성의미예시
공변(covariant)Sub extends Super 이면 Container<Sub>Container<Super>의 하위 타입배열
반공변(contravariant)반대 방향으로 계층 형성? super T
불변(invariant)계층 관계 없음List<T>

배열은 공변이다 — 그리고 그것이 문제다

Java 배열은 공변입니다. String[]Object[]의 하위 타입으로 취급됩니다. 덕분에 편리하지만, 런타임 타입 안전성이 깨질 수 있습니다.

String[] strings = new String[3];
Object[] objects = strings;   // ✅ 컴파일 OK — 공변 허용

objects[0] = 42;              // ❌ 런타임 ArrayStoreException!
// 실제 배열은 String[]인데 Integer를 넣으려 하므로 예외 발생

제네릭은 불변이다 — 그래서 더 안전하다

List<String>List<Object>의 하위 타입이 아닙니다. 이 불변성 덕분에 컴파일 타임에 타입 오류를 잡습니다.

List<String> strings = new ArrayList<>();
// List<Object> objects = strings;  // ❌ 컴파일 오류 — 불변

// 만약 허용된다면 어떻게 될까?
// objects.add(42);  // Integer가 들어가지만 strings는 String만 있어야 함 → 런타임 폭탄

와일드카드로 유연성 확보

제네릭의 불변성을 완화하되 안전성을 유지하는 것이 와일드카드의 역할입니다.

// List<Integer>, List<Double> 모두 받아서 읽기 전용으로 처리
public void printAll(List<? extends Number> list) {
    list.forEach(System.out::println);
}

List<Integer> ints    = List.of(1, 2, 3);
List<Double>  doubles = List.of(1.1, 2.2);

printAll(ints);    // ✅
printAll(doubles); // ✅

💡 TIP — 배열 대신 제네릭 컬렉션을 사용하는 이유 중 하나가 불변성으로 인한 컴파일 타임 안전성입니다. 배열은 런타임 예외(ArrayStoreException)로만 잡히는 오류를 제네릭은 컴파일 때 잡아 줍니다.


제네릭으로 재사용 가능한 라이브러리 API 설계하기

지금까지 배운 개념을 실제 API 설계에 적용해 봅니다. 페이징(paging)을 지원하는 범용 결과 래퍼 클래스와 변환 유틸리티를 설계합니다.

제네릭 결과 래퍼 (Result Wrapper)

/**
 * 페이징 결과를 감싸는 범용 컨테이너.
 * T: 원소 타입
 */
public final class Page<T> {
    private final List<T> content;
    private final int pageNumber;
    private final int pageSize;
    private final long totalElements;

    private Page(List<T> content, int pageNumber, int pageSize, long totalElements) {
        this.content       = List.copyOf(content);
        this.pageNumber    = pageNumber;
        this.pageSize      = pageSize;
        this.totalElements = totalElements;
    }

    // 정적 팩토리 메서드 — 타입 추론 활용
    public static <T> Page<T> of(List<T> content, int pageNumber, int pageSize, long totalElements) {
        return new Page<>(content, pageNumber, pageSize, totalElements);
    }

    // ✅ 제네릭 메서드: Page<T> → Page<R> 변환
    public <R> Page<R> map(java.util.function.Function<? super T, ? extends R> mapper) {
        List<R> mapped = content.stream()
                                .map(mapper)
                                .collect(java.util.stream.Collectors.toList());
        return new Page<>(mapped, pageNumber, pageSize, totalElements);
    }

    public List<T> getContent()          { return content; }
    public int     getPageNumber()       { return pageNumber; }
    public int     getPageSize()         { return pageSize; }
    public long    getTotalElements()    { return totalElements; }
    public int     getTotalPages()       { return (int) Math.ceil((double) totalElements / pageSize); }
}

PECS를 적용한 변환 유틸리티

public final class Transformer {

    private Transformer() {}

    /**
     * src 리스트의 각 원소를 mapper로 변환해 dest에 추가.
     * PECS: src는 생산자(extends), dest는 소비자(super)
     */
    public static <T, R> void transform(
            List<? extends T>                       src,
            List<? super R>                         dest,
            java.util.function.Function<T, R>       mapper
    ) {
        for (T item : src) {
            dest.add(mapper.apply(item));
        }
    }

    /**
     * 여러 소스 리스트를 병합.
     * 각 소스는 T의 하위 타입일 수 있으므로 ? extends T.
     */
    @SafeVarargs
    public static <T> List<T> concat(List<? extends T>... sources) {
        List<T> result = new ArrayList<>();
        for (List<? extends T> source : sources) {
            result.addAll(source);
        }
        return result;
    }
}

경계 타입 매개변수를 활용한 정렬 가능한 저장소

/**
 * 요소를 저장하고 최솟값·최댓값을 반환하는 저장소.
 * T는 반드시 Comparable을 구현해야 함.
 */
public class SortedStore<T extends Comparable<T>> {
    private final List<T> items = new ArrayList<>();

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

    public Optional<T> min() {
        return items.stream().min(Comparator.naturalOrder());
    }

    public Optional<T> max() {
        return items.stream().max(Comparator.naturalOrder());
    }

    // 상한 와일드카드로 외부에서 T의 하위 타입 목록도 받을 수 있음
    public void addAll(Collection<? extends T> others) {
        items.addAll(others);
    }
}

전체 사용 예시

public class Main {
    public static void main(String[] args) {
        // Page 사용
        List<String> names = List.of("Alice", "Bob", "Carol");
        Page<String> namePage = Page.of(names, 0, 10, 3L);

        // map으로 String → Integer 변환 (타입 안전)
        Page<Integer> lengthPage = namePage.map(String::length);
        lengthPage.getContent().forEach(System.out::println); // 5, 3, 5

        // Transformer 사용
        List<Integer> numbers  = List.of(1, 2, 3);
        List<Number>  numStore = new ArrayList<>();
        Transformer.transform(numbers, numStore, n -> n * 2.0); // Integer → Double → Number에 저장

        // SortedStore 사용
        SortedStore<Integer> store = new SortedStore<>();
        store.add(5);
        store.add(3);
        store.add(9);
        System.out.println(store.min()); // Optional[3]
        System.out.println(store.max()); // Optional[9]
    }
}

요약

  • 타입 소거: 제네릭 타입 정보는 컴파일 후 사라지며, 런타임에는 instanceof T, new T[], new T() 등이 불가합니다. Class<T> 인자로 우회할 수 있습니다.
  • 와일드카드: ? extends T는 읽기(생산자)용, ? super T는 쓰기(소비자)용이며, 이것이 PECS 원칙입니다.
  • 경계 타입 매개변수: <T extends A & B>로 다중 경계를 지정하면 T가 A와 B의 능력을 모두 갖도록 강제할 수 있습니다. 클래스는 첫 번째에만 올 수 있습니다.
  • 가변성: 배열은 공변(런타임 ArrayStoreException 위험), 제네릭은 불변(컴파일 타임 안전). 와일드카드로 필요한 만큼 유연성을 선택적으로 허용합니다.
  • 제네릭 메서드: 타입 매개변수를 반환 타입 앞에 선언하며, 컴파일러가 문맥에서 타입 인수를 추론합니다. 재귀적 경계로 자기 참조 제약을 걸 수 있습니다.
  • API 설계 원칙: 입력은 최대한 유연하게(? extends/? super), 반환 타입은 최대한 구체적으로 설계하면 사용자 친화적인 제네릭 API가 됩니다.

연습문제

  1. 아래 코드가 컴파일 오류를 일으키는 이유를 설명하고, 컴파일되도록 수정하세요.

    public void addNumbers(List<Number> list) {
        list.add(1);
    }
    
    List<Integer> ints = new ArrayList<>();
    addNumbers(ints); // 오류
    

    힌트: PECS에서 소비자에 해당하는 와일드카드는 무엇인가요?

  2. 다음 제네릭 메서드를 완성하세요. 두 List 중에서 원소의 합이 더 큰 리스트를 반환하되, List<Integer>List<Double> 모두 인자로 받을 수 있어야 합니다.

    public ??? largerSum(List<??? extends Number> a, List<??? extends Number> b) {
        // ...
    }
    

    힌트: 반환 타입을 ? extends Number 와일드카드로 선언하면 두 입력 리스트 중 하나의 참조를 그대로 반환할 수 있습니다. 다만 호출부에서는 구체 타입(List<Integer>/List<Double>)이 아니라 Number 수준으로만 원소를 다룰 수 있다는 점에 유의하세요.

  3. 다음 코드에서 런타임 예외가 발생하는 시점과 이유를 설명하고, 제네릭으로 재작성해 컴파일 타임에 오류를 잡도록 하세요.

    Object[] arr = new String[3];
    arr[0] = 100; // 어느 시점에 어떤 예외?
    

    힌트: 배열의 공변성이 이 문제의 핵심입니다.

  4. TypeSafeMap 클래스를 구현하세요. 키는 Class<T>이고 값은 T 타입인 이종 컨테이너(heterogeneous container)로, put(Class<T>, T)get(Class<T>) 메서드를 제공해야 합니다.

    힌트: 내부 저장소는 Map<Class<?>, Object>를 쓰고, getClass.cast()로 안전하게 캐스팅하세요.


💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Java 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.