TECH/웹개발

[Java] "for문은 이제 그만!" Java Stream API 실무 심화 가이드: 지연 연산부터 병렬 처리까지

런코리치 2026. 2. 6. 08:00

[Java] "데이터를 쿼리처럼 다루다" - Java Stream API 실무 심화 가이드

안녕하세요! 오늘은 자바 개발자의 필수 소양인 Stream API를 아주 깊게 파헤쳐 보겠습니다. 단순히 "코드가 짧아진다"는 장점을 넘어, 왜 현대 자바에서 스트림이 표준이 되었는지 그 내부 동작 원리와 실무 꿀팁을 정리했습니다.


1. 스트림의 철학: "어떻게"가 아니라 "무엇을"

전통적인 for-each 문은 외부 반복(External Iteration)입니다. 개발자가 직접 루프를 돌리고, 조건문을 걸고, 변수에 값을 담아야 하죠. 반면 스트림은 내부 반복(Internal Iteration)입니다.

"이 리스트에서 짝수만 골라서(filter), 제곱을 한 뒤(map), 리스트로 만들어줘(collect)"라고 선언만 하면 됩니다. 실제 반복은 자바 엔진이 알아서 처리합니다.


2. 스트림의 마법: 지연 연산 (Lazy Evaluation)

많은 분이 오해하는 것이 "스트림 문장을 만나는 즉시 데이터를 처리한다"는 점입니다. 하지만 스트림은 최종 연산이 호출되기 전까지는 아무 일도 하지 않습니다.

  • 중간 연산의 최적화: 만약 1,000만 개의 데이터가 있는데 filter()를 걸고 limit(10)을 걸었다면, 스트림은 1,000만 개를 다 거르지 않습니다. 딱 10개를 찾는 순간 연산을 즉시 종료합니다. 이를 Short-circuiting이라고 부릅니다.
  • 루프 합치기(Loop Fusion): 여러 개의 filter와 map이 연결되어 있어도 스트림은 이를 하나의 루프로 합쳐서 처리합니다. 성능 손실을 최소화하는 비결입니다.

3. 실무 핵심 문법 & 예시 (Step by Step)

실무에서 가장 많이 쓰이는 시나리오인 "객체 리스트 가공"을 예로 들어보겠습니다.

A. 필터링과 매핑 (가장 기본)

데이터를 거르고 필요한 부분만 쏙 빼낼 때 사용합니다.

Java
List<String> topPerformers = employees.stream()
    .filter(e -> e.getRating().equals("A")) // 성과가 A인 직원만 (Predicate)
    .map(Employee::getName)                // 이름 필드만 추출 (Function)
    .collect(Collectors.toList());

B. 데이터 그룹화 (Grouping)

SQL의 GROUP BY와 같습니다. 부서별로 급여 합계를 낼 때 아주 강력합니다.

Java
Map<String, Integer> sumByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDept,                  // 그룹화 기준 (부서)
        Collectors.summingInt(Employee::getSalary) // 합계 로직
    ));

C. Optional과 연계 (Null 안정성)

스트림의 결과가 없을 수도 있을 때 NullPointerException을 방지합니다.

Java
double avgSalary = employees.stream()
    .filter(e -> e.getDept().equals("R&D"))
    .mapToInt(Employee::getSalary)
    .average()                             // 결과가 없을 수도 있음 (OptionalDouble 반환)
    .orElse(0.0);                          // 데이터가 없으면 0.0 반환

4. 병렬 스트림(Parallel Stream)의 빛과 그림자

parallelStream()을 쓰면 쿼드코어, 옥타코어 CPU를 풀가동할 수 있습니다. 하지만 주의점이 있습니다.

  • 언제 써야 하나?: 요소의 개수가 최소 수만 개 이상이고, 요소당 처리 로직이 무거울 때 효과적입니다.
  • 왜 위험한가?:
    1. 순서 의존성: findFirst() 같은 작업은 병렬로 하면 순서를 맞추느라 오히려 더 느려집니다.
    2. Thread Pool 공유: 병렬 스트림은 공용 ForkJoinPool을 사용합니다. 한 곳에서 너무 무거운 병렬 스트림을 돌리면 시스템 전체의 다른 병렬 작업들이 멈출 수 있습니다.
    3. 상태 변경: 람다 내부에서 외부 변수 값을 바꾸면 멀티 스레드 환경에서 값이 꼬입니다.

5. 실무자라면 꼭 알아야 할 3가지 Golden Rules

  1. 디버깅 시 peek() 활용: 스트림 중간에 데이터가 어떻게 변하는지 보고 싶을 때 사용하세요.
  2. Java
    .filter(e -> e.getAge() > 30)
    .peek(e -> System.out.println("Filter 통과: " + e.getName())) // 로그 출력
    .map(...)
    
  3. Primitive 스트림 사용: Integer 대신 IntStream, LongStream을 쓰세요. 오토박싱(Auto-boxing) 비용을 줄여 성능을 획기적으로 높여줍니다.
  4. 스트림 재사용 금지: 한 번 collect나 forEach를 한 스트림 변수는 버려야 합니다. 다시 호출하면 에러가 납니다.

결론: 스트림은 '가독성'과 '효율성'의 조화

스트림을 잘 다루는 개발자는 코드가 간결할 뿐만 아니라, 데이터 처리 로직이 한눈에 들어옵니다. 아까 우리가 이야기했던 LEFT JOIN LATERAL이 SQL의 가독성과 성능을 높여주듯, Java Stream은 자바 코드의 수준을 한 단계 높여주는 도구입니다.

https://run-code-rich.tistory.com/entry/postgres-lateral-join-guide

 

[PostgreSQL] 단순 JOIN으로는 부족할 때? LATERAL JOIN 완벽 가이드

안녕하세요! 오늘은 SQL의 성능과 가독성을 한 단계 업그레이드해 줄 수 있는 강력한 기능, LATERAL JOIN에 대해 알아보려고 합니다.데이터베이스 쿼리를 짜다 보면 "A 테이블의 각 행을 읽으면서, 그

run-code-rich.tistory.com

오늘 정리한 내용을 바탕으로 여러분의 프로젝트에 직접 스트림을 적용해 보세요!