
Java 8에서 Stream API가 등장한 이후, 컬렉션 데이터를 처리하는 방식은 크게 변화했습니다. 기존의 반복문 중심 코딩에서 벗어나 데이터를 선언형으로 처리할 수 있게 되었으며, Spring Boot 기반의 백엔드 개발에서는 Entity 조회, DTO 변환, 그룹화, 집계 등 다양한 상황에서 Stream을 활용하고 있습니다.
이번 글에서는 Stream의 개념과 동작 원리부터 실무에서 가장 많이 사용하는 Intermediate Operation과 Terminal Operation까지 알아보겠습니다.
Java 8 이전에는 컬렉션을 처리하기 위해 대부분 for문이나 Iterator를 사용했습니다.
List<String> names = new ArrayList<>();
for (User user : users) {
if (user.isActive()) {
names.add(user.getName());
}
}
위 코드는 문제가 있는 코드는 아닙니다. 하지만 조건이 늘어나고 데이터 변환 과정이 많아질수록 코드가 길어지고, 무엇을 처리하는 코드인지 한눈에 파악하기 어려워지는 문제가 있습니다.
Java 8에서는 이러한 문제를 해결하기 위해 Stream API를 도입했습니다.
동일한 코드를 Stream으로 작성하면 다음과 같습니다.
List<String> names = users.stream()
.filter(User::isActive)
.map(User::getName)
.toList();
코드를 읽는 순간
라는 의도를 쉽게 이해할 수 있습니다.
이처럼 Stream은 "어떻게 반복할 것인가" 보다 "무엇을 처리할 것인가" 에 집중하도록 만들어진 API입니다.
Stream은 컬렉션이나 배열의 데이터를 선언형으로 처리하기 위한 API입니다.
여기서 중요한 점은 Stream은 데이터를 저장하는 자료구조가 아니라 데이터를 처리하는 파이프라인(Pipeline) 이라는 것입니다.
예를 들어 다음 코드를 살펴보겠습니다.
List<String> names = users.stream()
.filter(User::isActive)
.map(User::getName)
.sorted()
.toList();
위 코드는 다음과 같은 파이프라인으로 동작합니다.
users
│
stream()
│
filter()
│
map()
│
sorted()
│
toList()
각 단계마다 데이터를 조금씩 가공하여 최종 결과를 만들어냅니다.
Stream은 다음과 같은 특징을 가지고 있습니다.
하나씩 살펴보겠습니다.
List나 Set은 데이터를 저장하는 자료구조입니다.
List<User> users = List.of(...);
반면 Stream은 데이터를 저장하지 않습니다.
Stream<User> stream = users.stream();
여기서 생성된 Stream은 데이터를 복사하거나 별도로 저장하는 것이 아니라 users를 어떻게 처리할 것인지에 대한 작업 흐름만 생성합니다.
즉, Stream은 데이터를 담는 객체가 아니라 데이터를 처리하기 위한 객체입니다.
Stream은 원본 데이터를 수정하지 않습니다.
List<String> names = List.of("Kim", "Lee", "Park");
List<String> result = names.stream()
.map(String::toUpperCase)
.toList();
System.out.println(names);
실행 결과
[Kim, Lee, Park]
원본 List는 그대로 유지됩니다.
가공된 데이터는 새로운 컬렉션으로 반환됩니다.
이러한 특성 덕분에 Side Effect가 줄어들고 코드의 안정성이 높아집니다.
기존의 for문은 개발자가 반복을 직접 제어하는 외부 반복(External Iteration) 입니다.
for (User user : users) {
}
반면 Stream은 반복 자체를 Stream 내부에서 수행합니다.
users.stream()
.filter(User::isActive)
.map(User::getName)
.toList();
개발자는 반복을 신경 쓰지 않고 데이터를 어떻게 처리할 것인지만 작성하면 됩니다.
이것이 선언형 프로그래밍의 가장 큰 장점입니다.
Stream의 가장 중요한 특징 중 하나가 Lazy Evaluation(지연 연산) 입니다.
다음 코드를 살펴보겠습니다.
users.stream()
.filter(user -> {
System.out.println(user.getName());
return user.isActive();
});
위 코드는 아무것도 출력되지 않습니다.
왜냐하면 Stream은 최종 연산이 호출되기 전까지 실제 연산을 수행하지 않기 때문입니다.
다음처럼 toList()를 호출해야 비로소 실행됩니다.
users.stream()
.filter(user -> {
System.out.println(user.getName());
return user.isActive();
})
.toList();
이러한 방식 덕분에 불필요한 연산을 줄이고 성능을 최적화할 수 있습니다.
Stream은 한 번 최종 연산이 수행되면 종료됩니다.
Stream<User> stream = users.stream();
stream.count();
stream.toList();
실행하면 다음과 같은 예외가 발생합니다.
java.lang.IllegalStateException:
stream has already been operated upon or closed
필요하다면 새로운 Stream을 다시 생성해야 합니다.
users.stream().count();
users.stream().toList();
Stream은 크게 세 단계로 구성됩니다.
Source
│
Intermediate Operation
│
Intermediate Operation
│
Terminal Operation
예제를 보면 이해하기 쉽습니다.
List<String> result = users.stream()
.filter(User::isActive)
.map(User::getName)
.sorted()
.toList();
각 단계는 다음과 같습니다.
| 단계 | 설명 |
|---|---|
| Source | 데이터의 시작점(List, Set, Array 등) |
| Intermediate Operation | 데이터를 가공하는 중간 연산 |
| Terminal Operation | 최종 결과를 생성하는 연산 |
실제로는 다음과 같이 동작합니다.
List<User>
│
stream()
│
filter()
│
map()
│
sorted()
│
toList()
│
List<String>
중간 연산은 여러 개를 연결할 수 있지만, 최종 연산은 하나만 수행됩니다.
Intermediate Operation은 데이터를 변환하거나 필터링하는 연산입니다.
가장 큰 특징은 새로운 Stream을 반환한다는 것입니다.
또한 최종 연산이 호출되기 전까지는 실제 실행되지 않습니다.
실무에서 가장 많이 사용하는 중간 연산들을 살펴보겠습니다.
조건에 맞는 데이터만 선택할 때 사용합니다.
List<User> activeUsers = users.stream()
.filter(User::isActive)
.toList();
여러 조건도 쉽게 작성할 수 있습니다.
List<User> result = users.stream()
.filter(user -> user.isActive())
.filter(user -> user.getAge() >= 20)
.toList();
실무에서는 다음과 같은 상황에서 자주 사용합니다.
Stream에서 가장 많이 사용하는 API입니다.
데이터를 다른 형태로 변환합니다.
예를 들어 Entity를 DTO로 변환할 수 있습니다.
List<UserResponse> response = users.stream()
.map(UserResponse::from)
.toList();
필드 하나만 추출할 수도 있습니다.
List<String> names = users.stream()
.map(User::getName)
.toList();
실무에서는 대부분 다음과 같은 용도로 사용됩니다.
Spring Boot 프로젝트에서도 가장 많이 사용하는 Stream API라고 해도 과언이 아닙니다.
처음에는 이해하기 어렵지만 실무에서는 매우 자주 사용하는 API입니다.
map()은 데이터를 그대로 변환합니다.
User
↓
UserDto
반면 flatMap()은 중첩된 구조를 하나의 Stream으로 평탄화(Flatten) 합니다.
예를 들어 다음과 같은 데이터가 있다고 가정하겠습니다.
List<List<String>> list = List.of(
List.of("A", "B"),
List.of("C", "D")
);
map()을 사용하면 결과는 그대로 중첩됩니다.
[[A, B], [C, D]]
하지만 flatMap()을 사용하면
List<String> result = list.stream()
.flatMap(List::stream)
.toList();
결과는 다음과 같습니다.
[A, B, C, D]
실무에서는 다음과 같은 상황에서 자주 사용됩니다.
중복을 제거합니다.
List<String> names = users.stream()
.map(User::getName)
.distinct()
.toList();
주의할 점은 객체의 경우 equals()와 hashCode()를 기준으로 중복 여부를 판단합니다.
데이터를 정렬합니다.
users.stream()
.sorted(Comparator.comparing(User::getName))
.toList();
내림차순 정렬은 다음과 같이 사용할 수 있습니다.
users.stream()
.sorted(
Comparator.comparing(User::getCreatedAt)
.reversed()
)
.toList();
실무에서는 조회 결과를 최신순으로 정렬하거나 이름순으로 정렬하는 경우에 자주 사용합니다.
peek()는 Stream의 데이터를 중간에서 확인하기 위한 API입니다.
users.stream()
.peek(System.out::println)
.filter(User::isActive)
.toList();
이름 때문에 데이터를 수정하는 용도로 사용하는 경우가 있는데 권장되지 않습니다.
.peek(user -> user.setName("TEST"))
이처럼 Side Effect를 발생시키는 코드는 예측하기 어렵고 유지보수성을 떨어뜨립니다.
peek()는 디버깅 용도로만 사용하는 것이 좋습니다.
지정한 개수만 조회합니다.
List<User> result = users.stream()
.limit(10)
.toList();
Top N 조회와 같이 일부 데이터만 필요한 경우 유용합니다.
앞의 데이터를 건너뜁니다.
List<User> result = users.stream()
.skip(20)
.limit(10)
.toList();
페이징과 유사한 로직을 구현할 때 사용할 수 있지만, 실제 데이터베이스 조회에서는 LIMIT과 OFFSET을 사용하는 것이 더 효율적입니다.
지금까지 살펴본 filter(), map(), sorted()와 같은 연산은 모두 Intermediate Operation(중간 연산) 입니다.
중간 연산은 새로운 Stream을 반환할 뿐 실제 데이터 처리는 수행하지 않습니다.
반면 Terminal Operation(최종 연산) 이 호출되는 순간 지금까지 연결했던 모든 Stream 파이프라인이 실행되고 결과가 생성됩니다.
실무에서 가장 많이 사용하는 최종 연산들을 하나씩 살펴보겠습니다.
Java 16부터 추가된 toList()는 가장 많이 사용하는 최종 연산입니다.
List<UserResponse> responses = users.stream()
.map(UserResponse::from)
.toList();
기존에는 대부분 다음과 같이 작성했습니다.
List<UserResponse> responses = users.stream()
.map(UserResponse::from)
.collect(Collectors.toList());
현재는 특별한 이유가 없다면 toList()를 사용하는 것이 더 간결합니다.
다만 두 메서드는 반환되는 List의 특성이 다릅니다.
| 메서드 | 특징 |
|---|---|
| toList() | 수정 불가능한 List 반환 |
| Collectors.toList() | 구현체가 보장되지 않으며 일반적으로 수정 가능한 List 반환 |
예를 들어
List<String> list = users.stream()
.map(User::getName)
.toList();
list.add("Kim");
실행 시 UnsupportedOperationException이 발생합니다.
수정이 필요한 List라면 다음과 같이 작성하는 것이 좋습니다.
List<String> list = new ArrayList<>(
users.stream()
.map(User::getName)
.toList()
);
collect()는 Stream에서 가장 강력한 최종 연산입니다.
단순히 List를 만드는 것뿐만 아니라
등 다양한 작업을 수행할 수 있습니다.
예를 들어 이름만 List로 생성하는 경우
List<String> names = users.stream()
.map(User::getName)
.collect(Collectors.toList());
또는 상태별 그룹화도 가능합니다.
Map<OrderStatus, List<Order>> result =
orders.stream()
.collect(Collectors.groupingBy(Order::getStatus));
실무에서는 Collectors와 함께 가장 많이 사용하는 API입니다.
모든 요소를 순회합니다.
users.stream()
.forEach(System.out::println);
하지만 forEach() 내부에서 상태를 변경하는 것은 권장하지 않습니다.
예를 들어
users.stream()
.forEach(user -> user.setName("TEST"));
또는
users.stream()
.forEach(userRepository::save);
이러한 코드는 Side Effect를 발생시키므로 유지보수성이 떨어질 수 있습니다.
forEach()는 출력이나 로그 작성과 같이 부수 효과가 필요한 경우에 사용하는 것이 좋습니다.
데이터 개수를 반환합니다.
long count = users.stream()
.filter(User::isActive)
.count();
SQL의 COUNT()와 비슷한 역할을 수행합니다.
조건을 만족하는 데이터가 하나라도 존재하는지 확인합니다.
boolean exists = users.stream()
.anyMatch(User::isActive);
실무에서는 존재 여부를 확인할 때 매우 자주 사용합니다.
예를 들어
등을 확인할 수 있습니다.
모든 데이터가 조건을 만족하는지 확인합니다.
boolean result = users.stream()
.allMatch(User::isActive);
하나라도 조건을 만족하지 않으면 false를 반환합니다.
조건을 만족하는 데이터가 하나도 없는지 확인합니다.
boolean result = users.stream()
.noneMatch(User::isDeleted);
삭제된 사용자가 없는지 확인하는 등의 용도로 자주 사용됩니다.
첫 번째 데이터를 Optional로 반환합니다.
Optional<User> user = users.stream()
.filter(User::isActive)
.findFirst();
Optional과 함께 사용하는 경우가 대부분입니다.
User user = users.stream()
.filter(User::isActive)
.findFirst()
.orElseThrow();
조건을 만족하는 아무 데이터나 반환합니다.
Optional<User> user = users.stream()
.findAny();
일반 Stream에서는 findFirst()와 거의 동일하게 동작하지만,
parallelStream()에서는 더 빠른 결과를 얻기 위해 임의의 데이터를 반환할 수 있습니다.
최소값과 최대값을 반환합니다.
User oldest = users.stream()
.max(Comparator.comparing(User::getAge))
.orElseThrow();
최신 주문 조회도 자주 사용하는 패턴입니다.
Order latest = orders.stream()
.max(Comparator.comparing(Order::getCreatedAt))
.orElseThrow();
Stream의 모든 데이터를 하나의 값으로 합칩니다.
예를 들어 총합을 계산하는 경우
int sum = numbers.stream()
.reduce(0, Integer::sum);
문자열을 연결하는 것도 가능합니다.
String result = names.stream()
.reduce("", String::concat);
다만 숫자의 합계나 평균 계산은 IntStream이나 Collectors를 사용하는 것이 더 직관적인 경우가 많습니다.
실무에서 Stream을 사용한다면 Collectors는 반드시 익혀야 하는 API입니다.
단순히 List를 생성하는 것뿐만 아니라 데이터를 그룹화하거나 Map으로 변환하는 등 다양한 작업을 수행할 수 있습니다.
가장 많이 사용하는 Collector입니다.
주문 상태별로 그룹화하는 예제를 살펴보겠습니다.
Map<OrderStatus, List<Order>> ordersByStatus =
orders.stream()
.collect(Collectors.groupingBy(Order::getStatus));
결과는 다음과 같습니다.
READY
├─ Order1
├─ Order2
COMPLETE
├─ Order3
├─ Order4
상태별 조회, 카테고리별 조회 등에서 매우 자주 사용됩니다.
boolean 값을 기준으로 두 그룹으로 분리합니다.
Map<Boolean, List<User>> result =
users.stream()
.collect(Collectors.partitioningBy(User::isActive));
결과
true
├─ User1
├─ User2
false
├─ User3
실무에서 정말 많이 사용하는 Collector입니다.
Map<Long, User> userMap =
users.stream()
.collect(Collectors.toMap(
User::getId,
Function.identity()
));
List를 Map으로 변경하면 조회 성능을 크게 향상시킬 수 있습니다.
기존 방식
User user = users.stream()
.filter(it -> it.getId().equals(id))
.findFirst()
.orElse(null);
Map으로 변경하면
User user = userMap.get(id);
반복 조회가 많은 경우 매우 효과적인 방법입니다.
문자열을 하나로 연결합니다.
String names = users.stream()
.map(User::getName)
.collect(Collectors.joining(", "));
결과
Kim, Lee, Park
개수를 반환합니다.
Long count = users.stream()
.collect(Collectors.counting());
통계 정보를 한 번에 계산합니다.
IntSummaryStatistics statistics =
users.stream()
.collect(Collectors.summarizingInt(User::getAge));
다음 정보를 모두 얻을 수 있습니다.
List<UserResponse> responses = users.stream()
.map(UserResponse::from)
.toList();
List<User> activeUsers = users.stream()
.filter(User::isActive)
.toList();
List<Item> items = orders.stream()
.flatMap(order -> order.getItems().stream())
.toList();
Map<OrderStatus, List<Order>> result =
orders.stream()
.collect(Collectors.groupingBy(Order::getStatus));
Map<Long, User> userMap =
users.stream()
.collect(Collectors.toMap(
User::getId,
Function.identity()
));
Order latest = orders.stream()
.max(Comparator.comparing(Order::getCreatedAt))
.orElseThrow();
최종 연산이 수행되면 Stream은 종료됩니다.
필요하다면 새로운 Stream을 생성해야 합니다.
데이터가 적은 경우에는 일반 for문이 더 빠른 경우도 있습니다.
가독성과 유지보수성을 우선으로 고려하고, 성능이 중요한 구간에서는 실제 측정을 통해 선택하는 것이 좋습니다.
병렬 Stream은 CPU 연산이 많은 작업에서는 도움이 될 수 있지만, 작은 데이터나 I/O 작업에서는 오히려 성능이 저하될 수 있습니다.
무분별한 사용보다는 충분한 성능 테스트를 거친 후 적용하는 것을 권장합니다.
peek()나 forEach() 내부에서 객체를 수정하거나 데이터베이스를 호출하는 코드는 예측하기 어려운 결과를 만들 수 있습니다.
가능하면 Stream은 데이터를 변환하는 역할에만 집중하도록 작성하는 것이 좋습니다.
Stream은 단순히 for문을 대체하기 위한 API가 아닙니다.
컬렉션 데이터를 선언형으로 처리하여 코드의 가독성과 유지보수성을 높이고, 데이터 처리 과정을 하나의 파이프라인으로 표현할 수 있도록 도와주는 API입니다.
실무에서는 모든 Stream API를 사용하는 것이 아니라 filter(), map(), flatMap(), toList(), groupingBy(), toMap() 정도가 대부분의 비즈니스 로직을 차지합니다.
따라서 모든 API를 암기하기보다 어떤 상황에서 어떤 연산을 사용하면 코드가 더 명확해지는지를 이해하는 것이 중요합니다.