공공부하자개발 · 영어 학습 노트
자바
고급모던 자바와 성능0/10 완료
  • 01제네릭과 와일드카드
  • 02멀티스레드와 동기화
  • 03람다식과 함수형 인터페이스
  • 04Stream API와 병렬 처리
  • 05Optional로 NPE 방지
  • 06메서드 활용 패턴 (고급)
  • 07Java 21 모던 문법
  • 08어노테이션·리플렉션·동적 프록시
  • 09CompletableFuture 심화와 가상 스레드 실전
  • 10JVM 메모리·GC·OOM 진단
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 고급 › 01 / 10

제네릭과 와일드카드

섹션 7진행 0 / 10
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

4. 응용 변형 예제

3장의 예제를 "다른 API 로", "재사용 가능한 유틸로", "실무 데이터에", "엣지 케이스에" 적용하면 제네릭 시그니처를 읽고 쓰는 감각이 굳어집니다. 각 변형은 앞 예제와 무엇이 다른지, 언제 그 방식을 택하는지에 초점을 둡니다.

변형 1: for 루프 집계를 스트림 + 바운디드 제네릭으로

예제 3 의 max 는 Iterator 와 for 로 직접 순회했습니다. 같은 시그니처를 스트림으로 옮기면 빈 컬렉션을 Optional 로 자연스럽게 표현할 수 있고, ToLongFunction<? super T> 처럼 함수형 인터페이스에도 PECS 를 적용하는 법이 보입니다. 집계 로직이 "키 추출 + 합산" 형태로 반복될 때 이 방식이 가장 짧습니다.

java
import java.util.*;
import java.util.function.*;
import java.util.stream.*;

public class GenericAggregateStream {
    // 예제 3 의 for 루프 max 를 스트림으로. 빈 컬렉션은 예외 대신 Optional.empty
    static <T extends Comparable<? super T>> Optional<T> max(Collection<? extends T> items) {
        return items.stream().<T>map(t -> t).max(Comparator.naturalOrder());   // <T>map: 캡처 타입을 T 로 넓힘
    }

    // 어떤 T 든 long 키를 뽑아 합산. ToLongFunction<? super T> 는 T 를 소비하므로 super (PECS)
    static <T> long sumBy(Collection<? extends T> items, ToLongFunction<? super T> key) {
        return items.stream().mapToLong(key).sum();
    }

    // 분류 키별 합계. 분류 함수는 T 를 받아 K 를 생산 → Function<? super T, ? extends K>
    static <T, K extends Comparable<? super K>> Map<K, Long> sumByKey(
            Collection<? extends T> items, Function<? super T, ? extends K> classifier, ToLongFunction<? super T> value) {
        return items.stream().collect(Collectors.groupingBy(classifier, TreeMap::new, Collectors.summingLong(value)));
    }

    record Order(String id, String customer, long amount) {}

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order("O1", "kim", 12_000), new Order("O2", "lee", 8_000), new Order("O3", "kim", 15_000));

        System.out.println(max(List.of(3, 9, 4)));
        System.out.println(max(List.<Integer>of()));                      // 빈 컬렉션
        System.out.println(sumBy(orders, Order::amount));
        System.out.println(sumByKey(orders, Order::customer, Order::amount));

        List<Number> numbers = List.of(1, 2.5, 3L);                       // Integer, Double, Long 혼합
        System.out.println(sumBy(numbers, Number::longValue));            // T = Number
    }
}
// 출력:
// Optional[9]
// Optional.empty
// 35000
// {kim=27000, lee=8000}
// 6

변형 2: 와일드카드 캡처 헬퍼 — List<?> 를 수정하는 유일한 방법

2.7 절에서 List<?> 에는 null 외에 아무것도 넣을 수 없다고 했습니다. 그런데 Collections.swap(List<?>, int, int) 은 분명 List<?> 를 받아 요소를 바꿉니다. 비결은 private 제네릭 헬퍼입니다.

공개 API 는 "무슨 리스트든 받는다"는 뜻으로 ? 를 쓰고, 내부에서 <T> 메서드로 넘겨 컴파일러가 ? 를 T 로 붙잡게(capture) 합니다. 호출자에게 타입 파라미터를 노출하고 싶지 않은 유틸 메서드에 이 패턴을 씁니다.

java
import java.util.*;

public class WildcardCaptureHelper {
    // 공개 API 는 List<?> — "무슨 리스트든 받는다". 내부는 제네릭 헬퍼로 ? 를 T 로 붙잡는다(와일드카드 캡처)
    static void swap(List<?> list, int i, int j) { swapHelper(list, i, j); }

    private static <T> void swapHelper(List<T> list, int i, int j) {
        T tmp = list.get(i);               // T 로 읽고
        list.set(i, list.get(j));          // T 로 쓴다 — List<?> 에 직접 set 하면 컴파일 에러
        list.set(j, tmp);
    }

    static void reverse(List<?> list) {
        for (int i = 0, j = list.size() - 1; i < j; i++, j--) swap(list, i, j);
    }

    static void rotateLeft(List<?> list) { rotateHelper(list); }
    private static <T> void rotateHelper(List<T> list) {
        if (list.isEmpty()) return;        // 엣지 케이스: 빈 리스트
        list.add(list.remove(0));
    }

    public static void main(String[] args) {
        List<String> names = new ArrayList<>(List.of("kim", "lee", "park"));
        List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4));
        List<Object> empty = new ArrayList<>();

        swap(names, 0, 2);   System.out.println(names);
        reverse(nums);       System.out.println(nums);
        rotateLeft(names);   System.out.println(names);
        reverse(empty); rotateLeft(empty);
        System.out.println(empty);

        List<?> unknown = names;
        // unknown.set(0, "x");   // 컴파일 에러: capture of ? 에 String 을 넣을 수 없음
        swap(unknown, 0, 1);      // 헬퍼를 거치면 가능
        System.out.println(unknown);
    }
}
// 출력:
// [park, lee, kim]
// [4, 3, 2, 1]
// [lee, kim, park]
// []
// [kim, lee, park]

변형 3: 타입 토큰으로 만드는 타입 안전 이종 컨테이너

2.3 절의 Class<T> 토큰을 Map 의 키 에 심으면, 값 타입이 제각각인 설정 저장소를 캐스팅 없이 쓸 수 있습니다(이펙티브 자바의 "타입 안전 이종 컨테이너").

Map<String, Object> 로 두고 꺼낼 때마다 (Integer) 캐스팅하던 코드의 대체품이며, 설정·요청 속성·플러그인 레지스트리처럼 "키마다 타입이 다른" 저장소에 적합합니다. Class.cast 를 put 에도 넣어 raw type 우회까지 막는 것이 포인트입니다.

java
import java.util.*;

public class TypeSafeContainer {
    // 키에 타입 토큰을 넣는다: Key<Integer> 와 Key<String> 은 컴파일러가 구분하는 서로 다른 타입
    record Key<T>(String name, Class<T> type) {
        static <T> Key<T> of(String name, Class<T> type) { return new Key<>(name, type); }
    }

    static class Config {
        private final Map<Key<?>, Object> values = new HashMap<>();     // 값은 Object 로 저장

        <T> void put(Key<T> key, T value) { values.put(key, key.type().cast(value)); }   // 런타임에도 한 번 더 검사
        <T> T get(Key<T> key) { return key.type().cast(values.get(key)); }               // 꺼낼 때 캐스팅 불필요
        <T> T getOrDefault(Key<T> key, T def) { T v = get(key); return v == null ? def : v; }
    }

    static final Key<Integer> PORT  = Key.of("port", Integer.class);
    static final Key<String>  HOST  = Key.of("host", String.class);
    static final Key<Boolean> DEBUG = Key.of("debug", Boolean.class);

    public static void main(String[] args) {
        Config c = new Config();
        c.put(PORT, 8080);
        c.put(HOST, "localhost");
        // c.put(PORT, "8080");        // 컴파일 에러: String 은 Integer 가 아님

        int port = c.get(PORT);        // (Integer) 캐스팅 없음
        String host = c.get(HOST);
        System.out.println(host + ":" + port);
        System.out.println(c.getOrDefault(DEBUG, false));

        // raw type 으로 검사를 우회해 넣으려 해도 put 안의 Class.cast 가 즉시 막는다
        try {
            @SuppressWarnings({"rawtypes", "unchecked"})
            Key raw = PORT;
            c.put(raw, "not-a-number");
        } catch (ClassCastException e) {
            System.out.println("put 에서 차단: " + e.getMessage());
        }
        System.out.println("port 는 그대로 " + c.get(PORT));
    }
}
// 출력:
// localhost:8080
// false
// put 에서 차단: Cannot cast java.lang.String to java.lang.Integer
// port 는 그대로 8080

변형 4: Result<T, E> 에 flatMap / recover 를 더해 주문 → 검증 → 정산 체인

예제 4 의 Result 는 map 만 있어서 "다음 단계도 실패할 수 있는" 경우 Result<Result<..>> 가 생깁니다. flatMap 을 추가하면 조회·검증·정산처럼 각 단계가 실패할 수 있는 파이프라인 을 예외 없이 이을 수 있고, recover 로 실패를 기본값으로 바꿉니다. 실패 사유를 호출자에게 값으로 돌려줘야 하는 서비스 계층(배치 정산, API 응답)에 이 형태가 어울립니다.

java
import java.util.*;
import java.util.function.*;
import java.util.stream.Stream;

public class ResultChain {
    sealed interface Result<T, E> permits Ok, Err {
        static <T, E> Result<T, E> ok(T v)  { return new Ok<>(v); }
        static <T, E> Result<T, E> err(E e) { return new Err<>(e); }

        default <R> Result<R, E> map(Function<? super T, ? extends R> f) {
            return switch (this) {
                case Ok<T, E> ok   -> new Ok<>(f.apply(ok.value()));
                case Err<T, E> err -> new Err<>(err.error());
            };
        }
        // 다음 단계도 실패할 수 있을 때: Result<Result<..>> 중첩을 막는다 (Optional.flatMap 과 같은 역할)
        default <R> Result<R, E> flatMap(Function<? super T, Result<R, E>> f) {
            return switch (this) {
                case Ok<T, E> ok   -> f.apply(ok.value());
                case Err<T, E> err -> new Err<>(err.error());
            };
        }
        // 실패를 값으로 복구: 에러 E 를 받아 T 를 만든다
        default T recover(Function<? super E, ? extends T> f) {
            return switch (this) {
                case Ok<T, E> ok   -> ok.value();
                case Err<T, E> err -> f.apply(err.error());
            };
        }
    }
    record Ok<T, E>(T value) implements Result<T, E> {}
    record Err<T, E>(E error) implements Result<T, E> {}

    record Order(String id, String customer, long amount) {}
    record Settlement(String orderId, long amount, long fee, long payout) {}

    static final Map<String, Order> DB = Map.of(
        "ORD-1", new Order("ORD-1", "kim", 100_000),
        "ORD-2", new Order("ORD-2", "lee", 0));

    static Result<Order, String> findOrder(String id) {
        Order o = DB.get(id);
        return o == null ? Result.err("주문 없음: " + id) : Result.ok(o);
    }
    static Result<Order, String> validate(Order o) {
        return o.amount() > 0 ? Result.ok(o) : Result.err("금액 0원: " + o.id());
    }
    static Result<Settlement, String> settle(Order o) {
        long fee = o.amount() * 3 / 100;                       // 수수료 3%
        return Result.ok(new Settlement(o.id(), o.amount(), fee, o.amount() - fee));
    }

    public static void main(String[] args) {
        for (String id : List.of("ORD-1", "ORD-2", "ORD-9")) {
            Result<Settlement, String> r = findOrder(id).flatMap(ResultChain::validate).flatMap(ResultChain::settle);
            System.out.println(switch (r) {
                case Ok<Settlement, String> ok   -> "정산 " + ok.value();
                case Err<Settlement, String> err -> "실패 " + err.error();
            });
        }

        long payout = findOrder("ORD-9").flatMap(ResultChain::settle).map(Settlement::payout).recover(e -> 0L);
        System.out.println("recover → " + payout);

        // 여러 결과 중 실패 사유만 수집
        List<String> errors = Stream.of("ORD-1", "ORD-2", "ORD-9")
            .map(ResultChain::findOrder)
            .map(r -> r.flatMap(ResultChain::validate))
            .flatMap(r -> r instanceof Err<Order, String> e ? Stream.of(e.error()) : Stream.empty())
            .toList();
        System.out.println(errors);
    }
}
// 출력:
// 정산 Settlement[orderId=ORD-1, amount=100000, fee=3000, payout=97000]
// 실패 금액 0원: ORD-2
// 실패 주문 없음: ORD-9
// recover → 0
// [금액 0원: ORD-2, 주문 없음: ORD-9]

변형 5: PECS 4개 자리를 한 시그니처에 — 정렬 병합과 가변 인자 addAll

예제 3 의 sum / fillSequence 는 producer 와 consumer 를 따로 보여줬습니다. 실무 유틸은 둘이 한 메서드에 함께 옵니다.

mergeSorted 는 두 producer(? extends T), 비교자(? super T), 결과 consumer(? super T) 를 동시에 받아, List<Integer> 와 List<Double> 을 List<Number> 로 합칠 수 있습니다.

반환 타입 대신 dest 를 받는 이유는 6번 실수(와일드카드 반환 금지)를 피하면서 호출자가 컬렉션 타입을 고르게 하기 위해서입니다.

java
import java.util.*;

public class PecsMerge {
    // 정렬된 두 producer(? extends T) 를 하나의 consumer(? super T) 로 병합. 반환 대신 dest 를 채운다
    static <T> void mergeSorted(List<? extends T> a, List<? extends T> b,
                                Comparator<? super T> cmp, Collection<? super T> dest) {
        int i = 0, j = 0;
        while (i < a.size() && j < b.size()) {
            if (cmp.compare(a.get(i), b.get(j)) <= 0) dest.add(a.get(i++));
            else dest.add(b.get(j++));
        }
        while (i < a.size()) dest.add(a.get(i++));
        while (j < b.size()) dest.add(b.get(j++));
    }

    // 가변 인자 + consumer: Collections.addAll 과 같은 시그니처. @SafeVarargs 로 힙 오염 경고 억제
    @SafeVarargs
    static <T> void addAll(Collection<? super T> dest, T... items) { for (T t : items) dest.add(t); }

    record Payment(String id, long amount) {}

    public static void main(String[] args) {
        List<Integer> ints = List.of(1, 4, 9);
        List<Double> doubles = List.of(2.5, 3.5, 10.0);
        List<Number> merged = new ArrayList<>();
        mergeSorted(ints, doubles, Comparator.comparingDouble(Number::doubleValue), merged);   // T = Number
        System.out.println(merged);

        List<Object> sink = new ArrayList<>();
        mergeSorted(ints, List.<Integer>of(), Integer::compare, sink);   // 한쪽이 비어도 동작, dest 는 List<Object>
        addAll(sink, "x", "y");                                          // T = String, dest 는 ? super String
        System.out.println(sink);

        List<Payment> card = List.of(new Payment("C1", 100), new Payment("C2", 300));
        List<Payment> bank = List.of(new Payment("B1", 200));
        List<Payment> all = new ArrayList<>();
        mergeSorted(card, bank, Comparator.comparingLong(Payment::amount), all);
        System.out.println(all.stream().map(Payment::id).toList());
    }
}
// 출력:
// [1, 2.5, 3.5, 4, 9, 10.0]
// [1, 4, 9, x, y]
// [C1, B1, C2]
응용 변형 예제
  • 변형 1: for 루프 집계를 스트림 + 바운디드 제네릭으로
  • 변형 2: 와일드카드 캡처 헬퍼 — List<?> 를 수정하는 유일한 방법
  • 변형 3: 타입 토큰으로 만드는 타입 안전 이종 컨테이너
  • 변형 4: Result<T, E> 에 flatMap / recover 를 더해 주문 → 검증 → 정산 체인
  • 변형 5: PECS 4개 자리를 한 시그니처에 — 정렬 병합과 가변 인자 addAll
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)