IT·리눅스/Java

Java Stream API 실전 - map, filter, collect 제대로 쓰기

jmineekim 2026. 7. 10. 10:00

Stream API가 처음 나왔을 때 실무에서 잘못 쓰인 걸 정말 많이 봤어요. 열 줄이면 될 for문을 세 줄짜리 .stream().filter().map().collect()로 바꾸긴 했는데, 안에서 다시 forEach로 리스트에 add를 하고 있어서 사실상 for문에 껍데기만 씌운 코드. 저도 처음엔 그렇게 썼습니다. Stream을 제대로 쓴다는 건 파이프라인처럼 데이터 변환 흐름을 표현한다는 뜻이고, 그때 진짜로 코드가 짧아지고 읽기도 편해집니다. 오늘은 실무에서 자주 쓰는 map·filter·collect 패턴과 자주 하는 실수를 정리해볼게요.

개념 / 원리

Stream은 컬렉션이 아닙니다. "데이터를 흘려보내는 파이프라인"에 가까워요. 컬렉션은 데이터를 담아두는 그릇, Stream은 그 데이터가 어떻게 변형될지의 흐름 설명입니다.

Stream 연산은 크게 두 종류로 나뉩니다.

종류 예시 특징
Intermediate filter, map, sorted, distinct, limit 새 Stream 반환. lazy — 실제 실행 안 함
Terminal collect, forEach, count, reduce, findFirst 결과를 만들어냄. 이 시점에 파이프라인 실행

lazy라는 게 중요해요. .filter()를 여러 번 걸어도 실제 필터링은 terminal 연산이 호출될 때까지 안 일어납니다. 그래서 .limit(10)이 있으면 앞의 map/filter가 딱 10개 나올 만큼만 돌아요.

또 한 가지, Stream은 재사용 불가입니다. 한 번 terminal 연산을 호출하면 그 Stream은 소진(consumed)돼서 두 번 못 씁니다. 파이썬 제너레이터와 같은 특성이에요.

실전 예제

가장 흔한 세 가지 패턴을 봅시다.

record Order(Long id, String status, int amount, String userId) {}

List<Order> orders = List.of(
    new Order(1L, "PAID",     10000, "u1"),
    new Order(2L, "PENDING",   5000, "u1"),
    new Order(3L, "PAID",     30000, "u2"),
    new Order(4L, "CANCELED", 20000, "u3"),
    new Order(5L, "PAID",      8000, "u2")
);

① 필터링 + 변환 + 합계

int totalPaid = orders.stream()
    .filter(o -> o.status().equals("PAID"))
    .mapToInt(Order::amount)          // int 스트림으로 (박싱 회피)
    .sum();
// 48000

mapToInt가 포인트예요. map(Order::amount)으로 하면 Stream가 돼서 박싱·언박싱이 발생합니다. 숫자 집계는 primitive stream(IntStream, LongStream, DoubleStream)을 쓰는 게 훨씬 빠릅니다.

② 그룹핑

Collectors.groupingBy가 실무에서 for문을 가장 많이 대체합니다.

Map<String, List<Order>> byUser = orders.stream()
    .filter(o -> o.status().equals("PAID"))
    .collect(Collectors.groupingBy(Order::userId));
// {u1=[Order[1,...]], u2=[Order[3,...], Order[5,...]]}

그룹별 합계까지 한 번에.

Map<String, Integer> paidAmountByUser = orders.stream()
    .filter(o -> o.status().equals("PAID"))
    .collect(Collectors.groupingBy(
        Order::userId,
        Collectors.summingInt(Order::amount)
    ));
// {u1=10000, u2=38000}

두 번째 인자에 다운스트림 컬렉터를 넘기는 이 패턴은 익혀두면 정말 자주 씁니다.

③ Map으로 변환

Map<Long, Order> byId = orders.stream()
    .collect(Collectors.toMap(Order::id, o -> o));

키 중복 가능성이 있으면 세 번째 인자로 병합 함수를 반드시 지정하세요. 안 그러면 IllegalStateException이 터집니다.

Map<String, Integer> maxByUser = orders.stream()
    .collect(Collectors.toMap(
        Order::userId,
        Order::amount,
        Integer::max          // 중복 키 만나면 큰 값으로
    ));

주의할 점

첫째, Stream 내부에서 리스트에 add하지 마세요. 이게 가장 흔한 안티패턴입니다.

List<String> result = new ArrayList<>();
orders.stream().filter(...).forEach(o -> result.add(o.userId()));   // 나쁨

collect(Collectors.toList())로 결과를 만드는 게 정석이에요. forEach는 사이드 이펙트가 필요한 경우(로깅, 이벤트 발행 등)에만 쓰세요.

둘째, parallelStream() 아무 데나 쓰지 마세요. 공용 ForkJoinPool을 쓰기 때문에 웹 요청 스레드에서 남발하면 다른 요청이 대기하고, 결과 순서 보장·스레드 안전 문제도 새로 생깁니다. 순수 CPU 연산에 데이터가 수만 건 이상일 때만 검토하세요.

셋째, map은 반드시 **부작용 없는 변환으로.** DB 조회나 예외를 던지는 로직을 map 안에 넣으면 lazy 특성과 엮여 디버깅이 지옥이 됩니다. side effect는 파이프라인 밖에서 처리하세요.

넷째, Optional 반환에는 orElse vs orElseGet 주의. orElse(new HeavyObject())는 값이 있어도 인자를 매번 만듭니다. 비용 큰 기본값은 orElseGet(() -> new HeavyObject())로 지연 평가하세요.

다섯째, Stream을 두 번 쓰지 마세요. terminal 연산 하나 하면 그 Stream은 끝. 재사용하려면 매번 list.stream()으로 새로 만드세요.

마무리

정리하면 세 줄입니다.

  • Stream은 lazy 파이프라인이라 terminal 연산 전까지는 실행 안 되고, 한 번 소진되면 재사용 불가.
  • 숫자 집계엔 mapToInt·IntStream, 그룹 통계엔 Collectors.groupingBy + 다운스트림 컬렉터가 강력.
  • Stream 안에서 리스트에 add하거나 사이드 이펙트 넣지 말고, parallelStream은 CPU 바운드 대량 데이터에만.
반응형