Backend/Java

Java 8의 혁명: 람다(Lambda)와 스트림(Stream) API 실전 활용

미니임 2026. 3. 3. 23:57

 

자바의 역사에서 8 버전의 등장은 단순한 업데이트를 넘어선 '패러다임의 전환'이었습니다. 명령형 프로그래밍(Imperative Programming)의 정점에 서 있던 자바에 함수형 프로그래밍(Functional Programming)의 옷을 입혔기 때문입니다.

멀티코어 프로세서가 대중화된 현대 컴퓨팅 환경에서 데이터를 병렬로 처리하고, 코드의 가독성을 높여 유지보수 비용을 줄이는 것은 선택이 아닌 필수입니다. 그 중심에 바로 **람다(Lambda)**와 **스트림(Stream)**이 있습니다.


1. 핵심 개념 Deep Dive: 왜 람다와 스트림인가?

람다(Lambda): 익명 함수의 간결함

람다는 이름이 없는 함수, 즉 **익명 함수(Anonymous Function)**를 지칭합니다. 자바에서는 '함수형 인터페이스'를 구현하는 객체를 짧은 식(Expression)으로 표현한 것입니다.

  • 비유: 람다는 요리 레시피를 적은 두꺼운 책(익명 클래스) 대신, 포스트잇에 핵심 조리법만 적어 전달하는 것과 같습니다. "누가" 하는지보다 "무엇을" 하는지에 집중합니다.

스트림(Stream): 데이터의 흐름

스트림은 배열이나 컬렉션 같은 데이터 소스를 추상화하여, 데이터를 일관되게 다룰 수 있게 해주는 '흐름'입니다. 원본 데이터를 변경하지 않고, 단지 파이프라인을 통해 가공할 뿐입니다.

  • 비유: 공장의 컨베이어 벨트를 생각해보세요. 원재료(Data)가 벨트 위를 지나가면, 각 공정(Intermediate Operations)에서 깎고 조립한 뒤, 마지막에 완성품(Terminal Operation)으로 포장되어 나옵니다.

2. 실전 Hands-on: 이커머스 주문 필터링 및 통계

흔한 예제 대신, 실제 이커머스 서비스에서 **'특정 금액 이상의 주문을 찾고, 카테고리별로 합산 금액을 구하는 로직'**을 구현해 보겠습니다.

2.1. 비즈니스 모델 및 데이터 준비

먼저 Order 객체가 리스트로 존재한다고 가정합니다.

Java
 
public record Order(Long id, String category, double price, boolean isPaid) {}

List<Order> orders = List.of(
    new Order(1L, "Electronics", 1200.50, true),
    new Order(2L, "Books", 45.00, true),
    new Order(3L, "Electronics", 800.00, false), // 미결제
    new Order(4L, "Home", 150.00, true),
    new Order(5L, "Electronics", 300.00, true)
);

2.2. 스트림을 활용한 데이터 처리

과거의 for-loop와 if 문은 로직이 복잡해질수록 가독성이 떨어지지만, 스트림은 선언적으로 읽힙니다.

Java
 
import java.util.Map;
import java.util.stream.Collectors;

// 전자제품 중 결제가 완료된 상품의 총합을 카테고리별로 그룹화
Map<String, Double> categoryTotal = orders.stream()
    // 1. 중간 연산: 결제 완료된 주문만 필터링 (람다 활용)
    .filter(order -> order.isPaid()) 
    
    // 2. 중간 연산: 특정 금액(100.0) 이상의 주문만 선별
    .filter(order -> order.price() >= 100.0)
    
    // 3. 최종 연산: 카테고리별로 그룹화하여 가격 합산
    .collect(Collectors.groupingBy(
        Order::category, // 메서드 참조 (Method Reference)
        Collectors.summingDouble(Order::price)
    ));

categoryTotal.forEach((category, total) -> 
    System.out.println(category + ": $" + total));

2.3. 핵심 로직 해설

  • filter(order -> ...): 조건에 맞는 요소만 다음 단계로 전달합니다. 내부적으로는 Predicate 인터페이스를 구현합니다.
  • Collectors.groupingBy: SQL의 GROUP BY와 유사한 기능을 수행하며, 스트림의 결과를 맵(Map) 형태로 변환합니다.
  • 성능 Tip: 데이터 양이 수백만 건 이상이라면 .stream() 대신 .parallelStream()을 고려할 수 있습니다. 이때 내부적으로 ForkJoinPool을 사용하여 병렬 처리를 수행합니다.

3. 트러블슈팅: 흔히 하는 실수와 해결책

상황: 스트림 재사용 시 오류

스트림은 일회용입니다. 한 번 최종 연산(Terminal Operation)이 수행되면 닫힙니다.

Java
 
Stream<Order> stream = orders.stream().filter(Order::isPaid);
stream.count(); // 정상
stream.collect(Collectors.toList()); // IllegalStateException 발생!
  • Solution: 스트림을 변수에 담아두지 말고, 파이프라인을 즉시 실행하거나 필요할 때마다 새 스트림을 생성하세요.

상황: 외부 변수 참조 (Variable Capture)

람다 내부에서는 외부에 선언된 변수 중 final이거나 effectively final(수정되지 않는) 변수만 참조할 수 있습니다.

  • Reason: 멀티스레드 환경에서 데이터 일관성을 보장하기 위함입니다.

4. Trade-offs: 고려해야 할 사항

람다와 스트림이 만능은 아닙니다. 도입 전 다음 사항을 검토해야 합니다.

항목 장점 단점
가독성 비즈니스 로직이 한눈에 들어옴 단순 루프의 경우 오히려 과한 엔지니어링이 될 수 있음
디버깅 코드 라인이 줄어듦 스택 트레이스가 복잡하며 중단점(Breakpoint) 설정이 까다로움
성능 병렬 처리(parallelStream)가 용이함 오버헤드로 인해 작은 데이터셋에서는 일반 for 문보다 느릴 수 있음

특히, 스트림 내에서의 복잡한 계산량 $C$가 데이터 개수 $n$에 비해 매우 작다면, 일반적인 루프가 더 효율적일 수 있습니다. 연산 비용은 대략 다음과 같은 관계를 갖습니다.

$$T = n \times (O_{stream} + O_{calc})$$

여기서 $O_{stream}$은 스트림 파이프라인 구축에 드는 오버헤드입니다.


결론: 코드는 읽기 쉬워야 한다

자바 8의 변화는 결국 "어떻게(How)" 구현할 것인가에서 "무엇을(What)" 달성할 것인가로 개발자의 시선을 옮겨주었습니다. 람다를 통해 행동을 파라미터화하고, 스트림을 통해 데이터의 변환 과정을 명확히 기술함으로써 우리는 더 견고한 코드를 작성할 수 있게 되었습니다.

오늘 작성한 코드 중 복잡한 for 문과 중첩 if 문이 있다면, 이를 스트림 파이프라인으로 리팩토링해 보는 것은 어떨까요?

반응형