Stream API: map, filter, reduce 및 collect를 예제로 알아보기
1. 개요
Stream API는 Java 8부터 나타났으며 컬렉션을 처리할 때 필수 불가결한 도구가 되었습니다. 이 글은 map, filter, reduce, collect 4가지 가장 중요한 연산을 Order와 Customer의 구체적인 예제로 이해하도록 도와줍니다. 이 글을 다 읽으면 어떤 연산을 언제 사용할지, 어떻게 조합할지, 그리고 stream을 다룰 때 흔한 실수를 피하는 방법을 알게 될 것입니다.
2. 샘플 데이터 준비
각 연산을 살펴보기 전에 이 글 전체에서 사용할 데이터 세트가 필요합니다.
public record Order(String id, String product, int quantity, double price, String status) {
public double total() {
return quantity * price;
}
}
import java.util.List;
public class OrderData {
public static List<Order> sample() {
return List.of(
new Order("O1", "Laptop", 1, 15_000_000, "COMPLETED"),
new Order("O2", "Mouse", 3, 150_000, "COMPLETED"),
new Order("O3", "Keyboard", 2, 400_000, "CANCELLED"),
new Order("O4", "Monitor", 1, 3_500_000, "COMPLETED"),
new Order("O5", "Laptop", 1, 14_000_000, "PENDING")
);
}
}
Order는 record입니다 – 간결하고 불변이며 stream 데모에 적합합니다. total() 메서드는 각 주문의 총액을 계산하며, 아래에서 여러 번 재사용됩니다.
3. filter: 조건에 따라 요소 필터링
filter는 Predicate<T>를 받아 조건을 만족하는 요소만 유지합니다.
List<Order> completedOrders = OrderData.sample().stream()
.filter(order -> order.status().equals("COMPLETED"))
.toList();
completedOrders.forEach(o -> System.out.println(o.id() + " - " + o.product()));
stream()은List<Order>를Stream<Order>로 변환합니다.filter는status가COMPLETED인 주문만 유지합니다.toList()(Java 16+)는 결과를 불변List로 모아줍니다. 이는collect(Collectors.toList())를 대체합니다.
O1 - Laptop
O2 - Mouse
O4 - Monitor
주의:
filter는 원본 데이터를 변경하지 않습니다. Stream은 lazy합니다 – 터미널 연산(예:toList(),forEach())이 없으면 앞의filter/map은 실행되지 않습니다.
3.1. 여러 filter 조건 결합하기
List<Order> expensiveCompleted = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.filter(o -> o.total() > 1_000_000)
.toList();
연속된 두 개의 filter는 AND 조건과 동등합니다. 하나의 filter에 &&를 사용해서 쓸 수도 있지만, 조건이 복잡할 때는 따로 나누는 것이 코드를 읽기 쉽게 만듭니다.
4. map: 요소 변환하기
map은 Function<T, R>을 받아 각 T 타입의 요소를 R 타입의 요소로 변환합니다.
List<String> productNames = OrderData.sample().stream()
.map(Order::product)
.distinct()
.toList();
System.out.println(productNames);
Order::product는 메서드 레퍼런스이며,order -> order.product()와 동등합니다.distinct()는 중복 요소를 제거합니다(equals()기준).
[Laptop, Mouse, Keyboard, Monitor]
4.1. map이 완전히 다른 타입을 반환하기
map의 강점은 데이터 타입을 변환하는 것이며, 단순히 필드를 추출하는 것이 아닙니다.
List<Double> totals = OrderData.sample().stream()
.map(Order::total)
.toList();
System.out.println(totals);
[15000000.0, 450000.0, 800000.0, 3500000.0, 14000000.0]
주의:
map을forEach와 혼동하지 마세요.map은 새로운 stream을 반환하고 부작용이 없습니다.forEach는 작업을 수행(출력, DB 저장…)하는 데 사용되고 아무것도 반환하지 않습니다.
5. reduce: 여러 요소를 하나의 값으로 집계하기
reduce는 identity(초기값)와 BinaryOperator에 기반하여 전체 stream을 단일 값으로 통합합니다.
double totalRevenue = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.map(Order::total)
.reduce(0.0, Double::sum);
System.out.println(totalRevenue);
0.0은 identity입니다 – stream이 비었을 때의 초기값입니다.Double::sum은 accumulator이며, 각 값을 누적합니다.
1.8950000E7
5.1. identity가 없는 reduce
identity를 제공하지 않으면 reduce는 Optional<T>를 반환합니다. stream이 비어있을 수 있기 때문입니다.
Optional<Order> mostExpensive = OrderData.sample().stream()
.reduce((o1, o2) -> o1.total() > o2.total() ? o1 : o2);
mostExpensive.ifPresent(o -> System.out.println(o.id() + ": " + o.total()));
O1: 1.5E7
주의: 최대값/최소값을 찾는 목표라면, Java는
Stream.max(Comparator)및Stream.min(Comparator)를 제공합니다 –reduce를 사용하는 것보다 짧고 의도가 명확합니다. 복잡한 집계 로직이 필요할 때만reduce를 사용하세요.
6. collect: 결과를 데이터 구조로 수집하기
collect는 toList()보다 유연하며, 결과를 Map, Set으로 모으거나 groupBy/partition을 수행할 수 있습니다.
6.1. Map으로 수집하기
import java.util.Map;
import java.util.stream.Collectors;
Map<String, Double> totalByOrderId = OrderData.sample().stream()
.collect(Collectors.toMap(Order::id, Order::total));
System.out.println(totalByOrderId);
Collectors.toMap은 두 개의 함수를 받습니다: key 추출 함수와 value 추출 함수입니다.- 중복 key가 있으면
toMap은IllegalStateException을 throw합니다 – 데이터에 key 중복 가능성이 있으면 merge 함수를 추가로 전달해야 합니다.
{O1=1.5E7, O2=450000.0, O3=800000.0, O4=3500000.0, O5=1.4E7}
6.2. groupingBy – 조건에 따라 그룹 나누기
Map<String, List<Order>> ordersByStatus = OrderData.sample().stream()
.collect(Collectors.groupingBy(Order::status));
ordersByStatus.forEach((status, orders) ->
System.out.println(status + ": " + orders.size() + " order(s)"));
COMPLETED: 3 order(s)
CANCELLED: 1 order(s)
PENDING: 1 order(s)
6.3. groupingBy과 downstream collector 결합하기
각 status 그룹별로 총 매출을 계산할 수 있으며, 그룹을 만든 후 다시 루프할 필요가 없습니다.
Map<String, Double> revenueByStatus = OrderData.sample().stream()
.collect(Collectors.groupingBy(
Order::status,
Collectors.summingDouble(Order::total)
));
System.out.println(revenueByStatus);
{COMPLETED=18950000.0, CANCELLED=800000.0, PENDING=14000000.0}
downstream collector(summingDouble, counting, averagingDouble…)를 사용하는 groupingBy는 각 그룹마다 reduce를 수동으로 작성할 필요가 없게 해줍니다.
7. 네 가지 연산을 모두 pipeline에 결합하기
실제 사례입니다: 완료된 주문 중 가치가 100만 이상인 상품의 이름을 모아서, 총액 기준 내림차순으로 정렬한 목록을 얻기.
List<String> topProducts = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.filter(o -> o.total() > 1_000_000)
.sorted(Comparator.comparingDouble(Order::total).reversed())
.map(Order::product)
.toList();
System.out.println(topProducts);
[Laptop, Monitor]
연산의 순서는 여기서 중요합니다: 처리해야 할 요소 수를 줄이기 위해 filter를 먼저, 불필요한 정렬을 피하기 위해 sorted를 필터링 후, 필요한 필드만 추출하기 위해 map을 마지막에 사용합니다.
8. Pipeline을 위한 테스트 작성하기
Stream pipeline에 있는 비즈니스 로직에 대해 항상 테스트를 작성하세요. 한 줄의 긴 코드 안에 “숨겨진” 채로 아무도 검증하지 않게 두지 마세요.
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderAnalyticsTest {
@Test
void shouldCalculateTotalRevenueForCompletedOrders() {
double totalRevenue = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.map(Order::total)
.reduce(0.0, Double::sum);
assertThat(totalRevenue).isEqualTo(18_950_000.0);
}
@Test
void shouldGroupOrdersByStatus() {
var grouped = OrderData.sample().stream()
.collect(java.util.stream.Collectors.groupingBy(Order::status));
assertThat(grouped)
.containsKey("COMPLETED")
.extractingByKey("COMPLETED", org.assertj.core.api.InstanceOfAssertFactories.list(Order.class))
.hasSize(3);
}
}
첫 번째 테스트는 reduce 결과의 총액이 올바른지 검증합니다. 두 번째 테스트는 groupingBy가 COMPLETED 그룹에서 올바른 수의 요소를 만드는지 검증합니다.
9. 흔한 실수들
- stream이 필요할 때 일반 루프가 더 명확한 경우: 로직에 많은 부작용(로그, 외부 변수 업데이트, 복잡한 조건의 예외 발생)이 있으면, 전통적인
for루프가 stream보다 읽기 쉽습니다. - 이미 consume한 Stream을 재사용하기: stream은 한 번만 실행될 수 있습니다. 같은 stream에
collect()또는forEach()를 두 번 호출하면IllegalStateException: stream has already been operated upon or closed를 throw합니다. - reduce에서 identity를 잘못 선택하기: 잘못된 identity를 사용하면(예: 합계 계산할 때
0대신1), 오류가 발생하지 않지만 결과만 잘못됩니다. - toMap에서 key가 중복되기: 섹션 6.1에서 언급한 대로, merge 함수를 추가해야 합니다:
Collectors.toMap(keyFn, valueFn, (v1, v2) -> v1). - map에서 부작용 사용하기:
map/filter에 전달되는 함수 내에서 외부 state를 변경하지 마세요. Stream은 병렬로 실행될 수 있으며, 부작용은 디버그하기 어려운 race condition을 일으킵니다.
주의: pipeline에 4-5개 이상의 연산이 연결되어 있고 읽기 어렵다면, 모든 것을 긴 chain으로 나열하는 대신 명확한 이름의 개별 메서드(
filterCompleted,calculateRevenue…)로 나누세요.
10. 결론
filter, map, reduce, collect는 Java에서 함수형 스타일로 데이터를 처리하는 기본 도구 모음입니다. filter는 선택하고, map은 변환하고, reduce는 단일 값으로 통합하며, collect는 결과를 Map이나 조건별 그룹 같은 더 복잡한 구조로 유연하게 수집합니다. 연산의 순서를 이해하고 위의 흔한 실수를 피한다면, 간결하고 의도가 명확하며 테스트하기 쉬운 데이터 처리 pipeline을 작성할 수 있을 것입니다.