3장의 예제를 "다른 API 로", "재사용 가능한 유틸로", "실무 데이터에", "엣지 케이스에" 적용하면 제네릭 시그니처를 읽고 쓰는 감각이 굳어집니다. 각 변형은 앞 예제와 무엇이 다른지, 언제 그 방식을 택하는지에 초점을 둡니다.
예제 3 의 max 는 Iterator 와 for 로 직접 순회했습니다. 같은 시그니처를 스트림으로 옮기면 빈 컬렉션을 Optional 로 자연스럽게 표현할 수 있고, ToLongFunction<? super T> 처럼 함수형 인터페이스에도 PECS 를 적용하는 법이 보입니다. 집계 로직이 "키 추출 + 합산" 형태로 반복될 때 이 방식이 가장 짧습니다.
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}
// 6List<?> 를 수정하는 유일한 방법2.7 절에서 List<?> 에는 null 외에 아무것도 넣을 수 없다고 했습니다. 그런데 Collections.swap(List<?>, int, int) 은 분명 List<?> 를 받아 요소를 바꿉니다. 비결은 private 제네릭 헬퍼입니다.
공개 API 는 "무슨 리스트든 받는다"는 뜻으로 ? 를 쓰고, 내부에서 <T> 메서드로 넘겨 컴파일러가 ? 를 T 로 붙잡게(capture) 합니다. 호출자에게 타입 파라미터를 노출하고 싶지 않은 유틸 메서드에 이 패턴을 씁니다.
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]2.3 절의 Class<T> 토큰을 Map 의 키 에 심으면, 값 타입이 제각각인 설정 저장소를 캐스팅 없이 쓸 수 있습니다(이펙티브 자바의 "타입 안전 이종 컨테이너").
Map<String, Object> 로 두고 꺼낼 때마다 (Integer) 캐스팅하던 코드의 대체품이며, 설정·요청 속성·플러그인 레지스트리처럼 "키마다 타입이 다른" 저장소에 적합합니다. Class.cast 를 put 에도 넣어 raw type 우회까지 막는 것이 포인트입니다.
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 는 그대로 8080Result<T, E> 에 flatMap / recover 를 더해 주문 → 검증 → 정산 체인예제 4 의 Result 는 map 만 있어서 "다음 단계도 실패할 수 있는" 경우 Result<Result<..>> 가 생깁니다. flatMap 을 추가하면 조회·검증·정산처럼 각 단계가 실패할 수 있는 파이프라인 을 예외 없이 이을 수 있고, recover 로 실패를 기본값으로 바꿉니다. 실패 사유를 호출자에게 값으로 돌려줘야 하는 서비스 계층(배치 정산, API 응답)에 이 형태가 어울립니다.
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]예제 3 의 sum / fillSequence 는 producer 와 consumer 를 따로 보여줬습니다. 실무 유틸은 둘이 한 메서드에 함께 옵니다.
mergeSorted 는 두 producer(? extends T), 비교자(? super T), 결과 consumer(? super T) 를 동시에 받아, List<Integer> 와 List<Double> 을 List<Number> 로 합칠 수 있습니다.
반환 타입 대신 dest 를 받는 이유는 6번 실수(와일드카드 반환 금지)를 피하면서 호출자가 컬렉션 타입을 고르게 하기 위해서입니다.
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]