filter·map·flatMap과 상태 보유 중간 연산
filter·map·flatMap으로 원소를 고르고 바꾸고 펼치는 방법과, distinct·skip·limit·takeWhile처럼 앞서 지나간 원소를 기억하는 상태 보유 연산이 결과와 실행 순서를 어떻게 바꾸는지 배웁니다.
지연 연산과 스트림 만들기에서 중간 연산은 최종 연산이 원소를 요청하기 전까지 아무 일도 하지 않는다는 것을 확인했습니다. 이 글에서는 중간 연산 하나하나가 원소에 실제로 무슨 일을 하는지 살펴봅니다. 연산 이름을 외우는 것보다 각 단계를 지날 때 원소가 몇 개이고, 어떤 타입이며, 어떤 순서인지를 묻는 습관이 더 쓸모 있습니다. 파이프라인이 예상과 다른 결과를 낸다면 대개 이 셋 가운데 하나를 잘못 짐작한 것이기 때문입니다.
자주 틀리는 지점은 두 곳입니다. 하나는 map과 flatMap을 헷갈려 원소의 타입과 개수를 잘못 예상하는 것이고, 다른 하나는 sorted나 distinct처럼 앞서 지나간 원소를 기억해야 하는 연산이 실행 순서와 결과를 어떻게 바꾸는지 놓치는 것입니다. 두 가지를 차례로 실제 출력과 함께 확인하겠습니다.
map으로는 중첩 목록이 펼쳐지지 않습니다
게시판 분류(Category)마다 태그 목록이 있고, 모든 분류의 태그를 목록 하나로 모으고 싶다고 합시다. 분류에서 태그를 꺼내는 일이니 map(Category::tags)를 먼저 떠올리기 쉽습니다. 아래 코드는 같은 원본에 map과 flatMap을 각각 적용하고, 결과 목록과 크기를 출력합니다.
import java.util.List;
public final class NestedMapShapeBug {
private record Category(String name, List<String> tags) {}
public static void main(String[] args) {
List<Category> categories =
List.of(
new Category("notice", List.of("admin", "event")),
new Category("free", List.of("question", "answer")));
List<List<String>> nested = categories.stream().map(Category::tags).toList();
List<String> flattened =
categories.stream().flatMap(category -> category.tags().stream()).toList();
System.out.println("map: " + nested + " size=" + nested.size());
System.out.println("flatMap: " + flattened + " size=" + flattened.size());
}
}두 파이프라인은 같은 categories에서 출발하고, 차이는 map과 flatMap 한 단계뿐입니다. 각 줄 끝의 size는 결과 목록에 원소가 몇 개 들어 있는지를 알려 줍니다.
map: [[admin, event], [question, answer]] size=2
flatMap: [admin, event, question, answer] size=4map의 결과는 태그 네 개가 아니라 태그 목록 두 개입니다. map은 원소 하나를 함수에 넣고 나온 값 하나로 바꾸는데, 여기서 함수 Category::tags가 돌려주는 값이 List<String>이기 때문입니다. 그래서 스트림은 Stream<List<String>>이 되고, 이를 모은 결과도 List<List<String>>입니다. 반면 flatMap에 넘긴 함수는 분류마다 태그의 스트림을 돌려주고, flatMap은 그 하위 스트림들의 내용을 차례로 이어 붙여 Stream<String> 하나를 만듭니다.
NestedMapShapeBug의 두 수집 결과를 타입·원소 수·내용으로 비교합니다.
결과 목록의 원소 단위
같은 태그 원본에서 바깥 목록의 원소가 달라진다
map은 태그 목록 두 개를 남깁니다. flatMap은 그 안의 태그 네 개를 같은 수준으로 이어 붙입니다.
map(Category::tags)
- 목록의 원소와 개수
- List<String> 두 개
- 원문 입력의 결과
- [[admin, event], [question, answer]]
flatMap(tags.stream())
- 목록의 원소와 개수
- String 네 개
- 원문 입력의 결과
- [admin, event, question, answer]
고정 입력과 연산 규칙으로 풀이한 결과이며 새 실행 관측이 아닙니다.
두 메서드의 선언을 나란히 놓으면 차이가 분명해집니다.
<R> Stream<R> map(Function<? super T, ? extends R> mapper)
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)map의 함수는 원소 하나마다 R 값 하나를 돌려주고, flatMap의 함수는 원소 하나마다 R의 스트림을 돌려줍니다. 그 스트림에 원소가 몇 개 들어 있든 내용물만 바깥 스트림으로 옮겨집니다. 그래서 map은 원소 수를 그대로 유지하고, flatMap은 원소 수를 늘리거나 줄일 수 있습니다. 태그 목록이 비어 있는 분류는 flatMap을 지나면 아무 원소도 남기지 않습니다.
그렇다고 map의 중첩 결과가 늘 틀린 것은 아닙니다. 화면에 분류별로 태그를 따로 보여 줘야 한다면 List<List<String>>이 바로 원하는 모양입니다. 반대로 전체 태그에서 중복을 없애거나 태그별 개수를 세려면 모든 태그가 같은 수준에 있어야 하므로 flatMap이 맞습니다. 다만 평탄하게 펼치면 '이 태그가 어느 분류에서 왔는가'라는 정보가 사라집니다. 그 관계가 필요하다면 flatMap 안에서 태그를 분류 이름과 함께 record로 묶어야 하는데, 이 방법은 연습 문제 1에서 직접 만들어 봅니다.
flatMap에는 알아 둘 규칙이 두 가지 더 있습니다. 하위 스트림은 내용이 바깥 스트림으로 옮겨진 뒤 자동으로 닫히고, 함수가 스트림 대신 null을 돌려주면 빈 스트림으로 취급됩니다. 그런데 두 번째 규칙을 '태그 목록이 null이어도 괜찮다'는 뜻으로 받아들이면 안 됩니다. category.tags()가 null이면 flatMap이 반환값을 받아 보기도 전에 .stream() 호출에서 NullPointerException이 납니다. 빈 목록은 아무 문제 없이 빈 하위 스트림이 됩니다.
단계마다 원소가 어떻게 바뀌는지 따라가기
이번에는 filter, map, flatMap을 한 파이프라인에 이어 보겠습니다. 원본은 분류:제목,제목 형식의 문자열이고, 공지(notice) 분류의 제목만 하나씩 따로 뽑아내는 것이 목표입니다. 두 번째 파이프라인은 같은 원본에서 분류 이름과 제목의 첫 글자를 모두 모으는데, 여기에는 flatMap 대신 Java 16에 추가된 mapMulti를 썼습니다.
import java.util.Arrays;
import java.util.List;
public final class IntermediateShapeCatalog {
public static void main(String[] args) {
List<String> source =
List.of("notice:welcome,maintenance", "free:question,answer", "notice:event");
List<String> noticeTitles =
source.stream()
.filter(row -> row.startsWith("notice:"))
.map(row -> row.substring(row.indexOf(':') + 1))
.flatMap(value -> Arrays.stream(value.split(",")))
.toList();
List<Character> initials =
source.stream()
.<Character>mapMulti(
(row, out) -> {
for (String part : row.split("[:,]")) {
if (!part.isBlank()) {
out.accept(part.charAt(0));
}
}
})
.toList();
System.out.println(noticeTitles);
System.out.println(initials);
}
}mapMulti는 원소 하나를 받아, 람다 안에서 out.accept를 부른 횟수만큼 원소를 내보냅니다. 앞의 <Character>는 내보낼 원소의 타입을 컴파일러에 직접 알려 주는 타입 인수입니다.
[welcome, maintenance, event]
[n, w, m, f, q, a, n, e]첫 번째 파이프라인에서 각 단계가 다음 단계로 넘기는 원소를 정리하면 다음과 같습니다.
| 단계 | 다음 단계로 넘어가는 원소 | 개수 |
|---|---|---|
| 원본 | "notice:welcome,maintenance", "free:question,answer", "notice:event" | 3 |
filter | "notice:welcome,maintenance", "notice:event" | 2 |
map | "welcome,maintenance", "event" | 2 |
flatMap | "welcome", "maintenance", "event" | 3 |
filter는 조건이 true인 원소만 통과시키므로 개수만 줄고 타입은 그대로입니다. map은 각 문자열을 콜론 뒤의 부분 문자열로 바꾸므로 개수는 그대로이고 내용만 달라집니다. flatMap은 쉼표로 나눈 배열을 스트림으로 만들어 이어 붙이므로, 첫 원소는 둘로 늘고 두 번째 원소는 하나로 남아 모두 3개가 됩니다. 이 표는 단계별로 넘어간 원소를 모아서 보여 줄 뿐이고, 실제로 표의 한 줄씩 차례로 실행되지는 않습니다. 실제로는 원소 하나가 filter부터 flatMap까지 끝까지 지나간 뒤에 다음 원소가 출발하는데, 이 순서는 뒤에서 출력으로 직접 확인합니다.
mapMulti: 스트림을 만들지 않고 여러 값 내보내기
mapMulti에 넘기는 함수는 스트림을 돌려주지 않습니다. 대신 원소(row)와 함께 값을 받아 줄 Consumer(out)를 받아서, 내보낼 값이 생길 때마다 out.accept(...)를 호출합니다. 한 번도 호출하지 않으면 그 원소는 아무것도 남기지 않고, 여러 번 호출하면 호출한 만큼 원소가 생깁니다. 예제에서 "free:question,answer"는 free, question, answer로 나뉘어 첫 글자 f, q, a를 내보냈고, 세 행을 합쳐 문자 8개가 남았습니다. 첫 번째 파이프라인과 달리 filter가 없으므로 free 분류도 결과에 들어갑니다.
Javadoc은 mapMulti가 flatMap보다 나은 경우로 두 가지를 듭니다. 원소 하나를 적은 수(0개일 수도 있습니다)의 원소로 바꿀 때는 원소마다 새 Stream 객체를 만드는 비용을 아낄 수 있습니다. 또 결과를 스트림 형태로 돌려주는 것보다 반복문으로 하나씩 내보내는 편이 쓰기 쉬울 때도 있습니다. 예제처럼 for와 if로 값을 골라 내보내는 코드가 그런 경우입니다.
.<Character>mapMulti(...)처럼 타입 인수를 직접 적은 데도 이유가 있습니다. 람다만 보고는 out이 어떤 타입의 값을 받는지 컴파일러가 알아낼 수 없어서, 타입 인수를 빼면 결과의 원소 타입이 Object로 추론됩니다. 실제로 <Character>를 지우고 컴파일하면 다음 오류가 납니다.
IntermediateShapeCatalog.java:24: error: incompatible types: List<Object> cannot be converted to List<Character>
.toList();
^
1 error람다 매개변수의 타입을 (String row, Consumer<Character> out)처럼 직접 적어도 같은 문제를 해결할 수 있습니다. 그리고 out은 함수가 실행되는 동안에만 써야 합니다. 나중에 쓰려고 필드에 보관했다가 함수가 끝난 뒤에 호출하면, Javadoc의 표현대로 결과가 정의되지 않습니다.
문자열을 나누고 고르는 규칙이 복잡해지거나 형식이 틀린 행까지 처리해야 한다면, 그 규칙을 파이프라인 안의 람다에 모두 밀어 넣지 않는 편이 좋습니다. 한 행을 해석하는 메서드를 따로 만들고 파이프라인에서는 그 메서드를 호출하기만 하면, 해석 규칙을 따로 테스트할 수 있고 파이프라인도 읽기 쉬워집니다.
상태 보유 연산은 무엇을 기억하는가
스트림 파이프라인: 원본, 중간 연산, 최종 연산에서는 원소 하나만 보고 일을 끝내는 filter, map 같은 연산을 무상태(stateless) 연산, 앞서 지나간 원소를 기억해야 하는 sorted 같은 연산을 상태 보유(stateful) 연산이라고 불렀습니다. 그리고 sorted가 원소를 모두 모은 뒤에야 첫 원소를 내보내기 때문에, 원소가 하나씩 흘러가던 흐름이 그 지점에서 끊긴다는 것을 실행 순서로 확인했습니다. 그런데 상태 보유 연산이 모두 sorted처럼 원소 전부를 모으는 것은 아닙니다. 연산마다 기억하는 것이 다르고, 그에 따라 원소를 내보내는 시점도 다릅니다.
아래 코드는 distinct, skip, limit을 차례로 이어 붙이고, 원본에서 원소를 꺼낼 때와 최종 연산에 원소가 도착할 때를 출력합니다. 꺼낼 때의 출력에 쓴 peek은 원소를 바꾸지 않고 지나가는 원소마다 주어진 동작만 실행하는 중간 연산으로, 이런 추적에 쓰기 좋습니다.
import java.util.List;
public final class StatefulOpsTrace {
public static void main(String[] args) {
List.of("b", "a", "b", "c", "d", "e").stream()
.peek(word -> System.out.println("peek " + word))
.distinct()
.skip(1)
.limit(2)
.forEach(word -> System.out.println("forEach " + word));
}
}peek 줄은 원본에서 원소를 꺼낼 때마다, forEach 줄은 원소가 세 연산을 모두 통과했을 때 찍힙니다. 원본 원소 여섯 개 가운데 몇 개가 꺼내지는지 세어 보세요.
peek b
peek a
forEach a
peek b
peek c
forEach c원본에는 원소가 여섯 개 있지만 peek은 네 번만 출력됐고, 최종 연산에 도착한 것은 a와 c 두 개뿐입니다. 원소마다 각 연산이 무엇을 기억하고 어떻게 판단했는지 따라가면 다음과 같습니다. 괄호 안은 그 원소를 처리한 뒤 각 연산이 기억하는 내용입니다.
| 원본 원소 | distinct | skip(1) | limit(2) | forEach |
|---|---|---|---|---|
b | 처음 봄, 통과 (b) | 첫 원소라 버림 (1개 건너뜀) | ||
a | 처음 봄, 통과 (b, a) | 통과 | 통과 (1개 넘김) | a |
b | 이미 봄, 버림 (b, a) | |||
c | 처음 봄, 통과 (b, a, c) | 통과 | 통과 (2개 넘김), 더 받지 않음 | c |
d, e | 꺼내지 않음 |
distinct는 지금까지 본 원소의 집합을 기억합니다. 그래서 처음 보는 b, a, c는 받자마자 다음 단계로 넘기고 두 번째 b만 버립니다. 원소를 붙잡아 두지 않으므로, a는 원본에서 꺼낸 바로 다음 줄에서 forEach에 도착합니다. 다만 이것은 순차 스트림에서의 동작이고, 병렬 스트림에서 순서를 지키며 중복을 없앨 때는 distinct도 원소를 모아 두어야 합니다. 이 내용은 글 뒷부분의 더 깊이 단락에서 다룹니다.
skip(1)이 기억하는 것은 지금까지 건너뛴 개수뿐이어서, 첫 원소 b를 버린 뒤로는 모두 통과시킵니다. limit(2)는 지금까지 넘긴 개수를 세다가 2개를 채우면 더 받지 않겠다고 알립니다. limit은 단락 연산이므로 원본의 d와 e는 꺼내지도 않았고, 그래서 두 원소에서는 peek도 실행되지 않았습니다.
Javadoc이 각 연산에 붙여 둔 분류를 정리하면 다음과 같습니다. 단락(short-circuiting) 연산은 필요한 만큼 원소를 받으면 원본을 더 읽지 않고 끝낼 수 있는 연산으로, 지연 연산과 스트림 만들기에서 최종 연산의 예와 함께 소개했습니다.
| 연산 | 분류 | 단락 | 기억해야 하는 것 |
|---|---|---|---|
filter, map, flatMap, mapMulti, peek | 무상태 | 아니요 | 없음 |
distinct | 상태 보유 | 아니요 | 지금까지 본 원소 |
sorted | 상태 보유 | 아니요 | 모든 원소 |
skip | 상태 보유 | 아니요 | 지금까지 건너뛴 개수 |
limit | 상태 보유 | 예 | 지금까지 넘긴 개수 |
dropWhile | 상태 보유 | 아니요 | 아직 버리는 중인지 |
takeWhile | 상태 보유 | 예 | 조건이 이미 깨졌는지 |
표준 연산에 없는 상태 보유 연산이 필요할 때도 있습니다. 원소를 세 개씩 묶는 일처럼 앞 원소를 기억해야 하는 연산은 Java 24부터 Stream.gather로 직접 정의할 수 있는데, 이 기능은 스트림 파이프라인: 원본, 중간 연산, 최종 연산의 더 깊이 단락에서 소개했습니다.
distinct·sorted·skip·limit를 이어 붙이기
상태 보유 연산을 여러 개 이어 쓰는 예를 보겠습니다. 게시글 조회 기록에서 중복 기록을 없애고, 조회 수가 많은 순으로 정렬한 뒤, 1위를 건너뛰고 2위와 3위를 고릅니다. 조회 수가 같으면 제목의 사전순으로 정렬합니다.
import java.util.Comparator;
import java.util.List;
public final class StatefulIntermediateOperations {
private record PostMetric(String title, int viewCount) {}
public static void main(String[] args) {
List<PostMetric> source =
List.of(
new PostMetric("welcome", 40),
new PostMetric("question", 20),
new PostMetric("welcome", 40),
new PostMetric("event", 30));
List<PostMetric> result =
source.stream()
.distinct()
.sorted(
Comparator.comparingInt(PostMetric::viewCount)
.reversed()
.thenComparing(PostMetric::title))
.skip(1)
.limit(2)
.toList();
System.out.println(result);
}
}distinct는 equals로 중복을 판단하는데, 레코드의 equals는 모든 구성 요소가 같을 때 true입니다. 그래서 제목과 조회 수가 같은 두 welcome 기록은 같은 원소로 취급됩니다.
[PostMetric[title=event, viewCount=30], PostMetric[title=question, viewCount=20]]단계마다 남는 원소를 표로 따라가 보면 결과가 나온 과정을 알 수 있습니다.
| 단계 | 남는 원소 (제목, 조회 수) |
|---|---|
| 원본 | (welcome, 40), (question, 20), (welcome, 40), (event, 30) |
distinct() | (welcome, 40), (question, 20), (event, 30) |
sorted(...) | (welcome, 40), (event, 30), (question, 20) |
skip(1) | (event, 30), (question, 20) |
limit(2) | (event, 30), (question, 20) |
distinct는 두 원소가 같은지를 equals로 판단합니다. OpenJDK 구현은 이 판단을 해시 집합으로 처리하므로, hashCode가 equals와 맞게 구현되어 있지 않으면 중복을 놓칠 수 있습니다. record는 모든 구성 요소를 비교하는 equals와 hashCode를 자동으로 만들어 주므로, 두 PostMetric("welcome", 40)은 서로 다른 객체이지만 같은 원소로 보고 하나만 남깁니다.
같은 필드를 가진 일반 클래스였다면 결과가 달랐을 것입니다. equals를 재정의하지 않은 클래스는 같은 객체일 때만 같다고 보므로 두 welcome이 모두 남습니다. 이 규칙은 HashSet에 넣을 객체의 equals와 hashCode에서 자세히 다룹니다.
sorted에 넘긴 비교자(Comparator)는 순서만 정할 뿐, distinct의 중복 판단에는 쓰이지 않습니다. 조회 수만 비교하는 비교자로 만든 TreeSet은 비교 결과가 0인 원소를 같은 원소로 보기 때문에, (a, 20)과 (b, 20)을 넣으면 하나만 남습니다. 같은 두 원소에 distinct를 적용하면 equals가 다르므로 둘 다 남습니다.
비교자 없이 sorted()를 호출하면 원소가 Comparable이어야 합니다. PostMetric처럼 Comparable이 아닌 원소라면 최종 연산을 실행할 때 ClassCastException이 납니다. 원소가 하나뿐이면 비교할 일이 없어 예외 없이 지나가기도 하므로, 테스트 데이터가 작으면 이 실수를 놓치기 쉽습니다. 비교자를 만드는 방법은 Comparable과 Comparator를 참고하세요.
연산의 순서도 결과의 의미를 바꿉니다. 지금 코드는 '정렬한 뒤 앞에서 고르기'이므로 전체에서 2위와 3위를 구합니다. skip(1).limit(2)를 sorted 앞으로 옮기면 '원본 순서대로 고른 두 개를 정렬하기'가 됩니다. 이 입력에서는 distinct 뒤 원본 순서로 고른 두 원소가 마침 (question, 20)과 (event, 30)이라서, 정렬하고 나면 같은 결과가 나옵니다. 하지만 입력이 조금만 달라져도 결과가 갈리므로(연습 문제 3), 결과가 같아 보인다는 이유로 연산 순서를 바꾸면 안 됩니다.
takeWhile과 dropWhile: 조건이 처음 깨지는 곳에서 나누기
Java 9에 추가된 takeWhile과 dropWhile은 원소를 앞에서부터 차례로 검사하다가, 조건이 처음 false가 되는 지점을 기준으로 스트림을 나눕니다. takeWhile은 그 앞부분(접두부)만 남기고, dropWhile은 그 앞부분을 버린 뒤 나머지를 모두 남깁니다. 조건이 처음 깨지는 위치가 기준이라는 점에서, 위치와 상관없이 모든 원소를 검사하는 filter와 다릅니다.
import java.util.List;
public final class PrefixIntermediateOperations {
public static void main(String[] args) {
List<Integer> source = List.of(2, 4, 6, 7, 8, 10);
System.out.println("take=" + source.stream().takeWhile(value -> value % 2 == 0).toList());
System.out.println("drop=" + source.stream().dropWhile(value -> value % 2 == 0).toList());
System.out.println("filter=" + source.stream().filter(value -> value % 2 == 0).toList());
}
}세 파이프라인은 같은 짝수 조건을 쓰고, 다른 것은 그 조건을 적용하는 연산뿐입니다. 원본에서 7 뒤에 짝수 8과 10이 더 있다는 점을 기억하고 출력을 보세요.
take=[2, 4, 6]
drop=[7, 8, 10]
filter=[2, 4, 6, 8, 10]세 줄은 모두 같은 원본에서 새 스트림을 만들어 계산한 것입니다. 2, 4, 6은 짝수 조건이 true이고, 7에서 처음으로 false가 됩니다. 그래서 takeWhile은 7 앞까지인 [2, 4, 6]을 남기고, 7을 만나는 순간 원본을 더 읽지 않습니다. dropWhile은 2, 4, 6을 버린 뒤 7부터는 조건을 더 검사하지 않고 모두 넘기므로, 짝수인 8과 10도 7과 함께 남습니다. filter는 7을 걸러 낸 뒤에도 계속 검사하므로 뒤쪽의 8과 10까지 골라 냅니다.
첫 홀수 7 뒤의 짝수를 남기는지가 다르다
순서가 있는 같은 입력의 7에서 takeWhile은 멈추고 dropWhile은 남은 7·8·10을 포함하며 filter는 뒤의 짝수도 선택합니다. 현재 위치와 경계/선택 상태를 비교하면 접두부 조건을 이해하기 쉽습니다.
순서 있는 원본 2, 4, 6, 7, 8, 10에 같은 짝수 조건을 적용합니다.
첫 false와 남는 값
첫 홀수 7 뒤의 짝수를 남기는지가 다르다
순서 있는 원본은 2·4·6·7·8·10입니다. 첫 false인 7을 기준으로 접두부를 다루는 연산과 전체 조건 선별을 비교합니다.
takeWhile
- 선택 기준
- 첫 false 이전 접두부
- 남는 값
- 2, 4, 6
dropWhile
- 선택 기준
- 그 접두부를 버린 나머지
- 남는 값
- 7, 8, 10
filter
- 선택 기준
- 위치와 무관하게 조건을 만족하는 값
- 남는 값
- 2, 4, 6, 8, 10
이 값들은 원문의 순서 있는 List로 계산했습니다. 순서 없는 스트림에서 takeWhile·dropWhile은 같은 접두부 계약을 제공하지 않습니다.
이 예의 조건
- 순서가 있는 같은 List를 각 operation마다 독립 Stream으로 처리합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 순서 있는 List 2,4,6,7,8,10의 각 operation은 독립된 Stream에 적용한다. takeWhile은 첫 false인 7 이전의 2,4,6만, dropWhile은 7이 포함된 7,8,10을, filter는 7 뒤에도 계속 조건을 판단해 2,4,6,8,10을 만든다. 같은 원본의 세 경로와 각 결과를 7개 노드와 6개 간선으로 나타내며 모바일도 동일하다. 세 operation을 이미 소비된 하나의 Stream에 연속해서 재사용한다는 뜻이 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 순서 있는 List 2,4,6,7,8,10의 각 operation은 독립된 Stream에 적용한다. takeWhile은 첫 false인 7 이전의 2,4,6만, dropWhile은 7이 포함된 7,8,10을, filter는 7 뒤에도 계속 조건을 판단해 2,4,6,8,10을 만든다. 같은 원본의 세 경로와 각 결과를 7개 노드와 6개 간선으로 나타내며 모바일도 동일하다. 세 operation을 이미 소비된 하나의 Stream에 연속해서 재사용한다는 뜻이 아니다. |
| 전체 읽기 정보 | 첫 홀수 7 뒤의 짝수를 남기는지가 다르다 첫 홀수 7 뒤의 짝수를 남기는지가 다르다 순서 있는 원본 2, 4, 6, 7, 8, 10에 같은 짝수 조건을 적용합니다. 첫 false와 남는 값 첫 홀수 7 뒤의 짝수를 남기는지가 다르다 순서 있는 원본은 2·4·6·7·8·10입니다. 첫 false인 7을 기준으로 접두부를 다루는 연산과 전체 조건 선별을 비교합니다. takeWhile 선택 기준 첫 false 이전 접두부 남는 값 2, 4, 6 dropWhile 선택 기준 그 접두부를 버린 나머지 남는 값 7, 8, 10 filter 선택 기준 위치와 무관하게 조건을 만족하는 값 남는 값 2, 4, 6, 8, 10 이 값들은 원문의 순서 있는 List로 계산했습니다. 순서 없는 스트림에서 takeWhile·dropWhile은 같은 접두부 계약을 제공하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 참 접두부 2,4,6은 짝수 조건의 결과가 true입니다. |
|
|
| 첫 false 7에서 접두부 조건이 끝납니다. |
|
|
| 뒤 원소 8,10에 전체 filter는 계속 조건을 적용합니다. |
|
|
첫 홀수 7 뒤의 짝수를 남기는지가 다르다
순서가 있는 같은 입력의 7에서 takeWhile은 멈추고 dropWhile은 남은 7·8·10을 포함하며 filter는 뒤의 짝수도 선택합니다. 현재 위치와 경계/선택 상태를 비교하면 접두부 조건을 이해하기 쉽습니다.
순서 있는 원본 2, 4, 6, 7, 8, 10에 같은 짝수 조건을 적용합니다.
첫 false와 남는 값
첫 홀수 7 뒤의 짝수를 남기는지가 다르다
순서 있는 원본은 2·4·6·7·8·10입니다. 첫 false인 7을 기준으로 접두부를 다루는 연산과 전체 조건 선별을 비교합니다.
takeWhile
- 선택 기준
- 첫 false 이전 접두부
- 남는 값
- 2, 4, 6
dropWhile
- 선택 기준
- 그 접두부를 버린 나머지
- 남는 값
- 7, 8, 10
filter
- 선택 기준
- 위치와 무관하게 조건을 만족하는 값
- 남는 값
- 2, 4, 6, 8, 10
이 값들은 원문의 순서 있는 List로 계산했습니다. 순서 없는 스트림에서 takeWhile·dropWhile은 같은 접두부 계약을 제공하지 않습니다.
이 예의 조건
- 순서가 있는 같은 List를 각 operation마다 독립 Stream으로 처리합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 순서 있는 List 2,4,6,7,8,10의 각 operation은 독립된 Stream에 적용한다. takeWhile은 첫 false인 7 이전의 2,4,6만, dropWhile은 7이 포함된 7,8,10을, filter는 7 뒤에도 계속 조건을 판단해 2,4,6,8,10을 만든다. 같은 원본의 세 경로와 각 결과를 7개 노드와 6개 간선으로 나타내며 모바일도 동일하다. 세 operation을 이미 소비된 하나의 Stream에 연속해서 재사용한다는 뜻이 아니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 순서 있는 List 2,4,6,7,8,10의 각 operation은 독립된 Stream에 적용한다. takeWhile은 첫 false인 7 이전의 2,4,6만, dropWhile은 7이 포함된 7,8,10을, filter는 7 뒤에도 계속 조건을 판단해 2,4,6,8,10을 만든다. 같은 원본의 세 경로와 각 결과를 7개 노드와 6개 간선으로 나타내며 모바일도 동일하다. 세 operation을 이미 소비된 하나의 Stream에 연속해서 재사용한다는 뜻이 아니다. |
| 전체 읽기 정보 | 첫 홀수 7 뒤의 짝수를 남기는지가 다르다 첫 홀수 7 뒤의 짝수를 남기는지가 다르다 순서 있는 원본 2, 4, 6, 7, 8, 10에 같은 짝수 조건을 적용합니다. 첫 false와 남는 값 첫 홀수 7 뒤의 짝수를 남기는지가 다르다 순서 있는 원본은 2·4·6·7·8·10입니다. 첫 false인 7을 기준으로 접두부를 다루는 연산과 전체 조건 선별을 비교합니다. takeWhile 선택 기준 첫 false 이전 접두부 남는 값 2, 4, 6 dropWhile 선택 기준 그 접두부를 버린 나머지 남는 값 7, 8, 10 filter 선택 기준 위치와 무관하게 조건을 만족하는 값 남는 값 2, 4, 6, 8, 10 이 값들은 원문의 순서 있는 List로 계산했습니다. 순서 없는 스트림에서 takeWhile·dropWhile은 같은 접두부 계약을 제공하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 참 접두부 2,4,6은 짝수 조건의 결과가 true입니다. |
|
|
| 첫 false 7에서 접두부 조건이 끝납니다. |
|
|
| 뒤 원소 8,10에 전체 filter는 계속 조건을 적용합니다. |
|
|
'앞부분'이라는 말이 뜻을 가지려면 스트림에 정해진 순서가 있어야 합니다. 이를 만남 순서(encounter order)라고 하는데, List에서 만든 스트림은 목록의 순서가 곧 만남 순서입니다. 만남 순서가 있다는 것과 값이 정렬되어 있다는 것은 다른 이야기입니다. 위 원본은 우연히 오름차순이지만, takeWhile이 보는 것은 값의 크기가 아니라 원소가 나오는 순서입니다.
반대로 HashSet에서 만든 스트림처럼 만남 순서가 없는 스트림에서는, 일부 원소만 조건을 만족할 때 어떤 원소를 가져가거나 버릴지 정해져 있지 않습니다. Javadoc은 이 경우 조건을 만족하는 원소 가운데 아무 부분집합(빈 집합 포함)이나 고를 수 있다고 적고 있습니다.
takeWhile은 단락 연산이므로 무한 스트림을 멈추는 데도 쓸 수 있습니다. 지연 연산과 스트림 만들기에서 두 배씩 커지는 무한 수열을 takeWhile(value -> value <= 64)로 멈춘 것이 그 예입니다. 같은 자리에 filter(value -> value <= 64)를 쓰면 프로그램이 정상적으로 끝나지 않습니다. filter는 뒤에 조건을 만족하는 원소가 또 나올 수 있는지 알 수 없으므로 무한한 원본을 끝까지 읽으려 하기 때문입니다.
peek은 디버깅용입니다
peek의 Javadoc은 이 연산이 주로 디버깅을 돕기 위해, 즉 원소가 파이프라인의 어느 지점을 지나가는지 보기 위해 있다고 설명합니다. 앞의 추적처럼 실행 순서를 들여다볼 때는 편리하지만, 원본의 모든 원소에 대해 실행된다는 약속은 없습니다. 방금 본 추적에서도 limit이 원소를 더 받지 않자 d와 e에서는 peek이 실행되지 않았습니다. 지연 연산과 스트림 만들기에서 본 것처럼, 원본의 크기를 이미 아는 경우 count()는 원소를 하나도 꺼내지 않고 답을 돌려주므로 peek이 한 번도 실행되지 않기도 합니다.
그래서 꼭 실행되어야 하는 일을 peek에 넣으면 안 됩니다. 데이터 저장, 반드시 남겨야 하는 지표 기록, 입력 검증 같은 일은 파이프라인 밖에서 하거나 최종 연산의 결과를 받아서 처리해야 합니다. peek에서 원소 객체의 상태를 바꾸는 것도 피해야 합니다. 같은 객체를 참조하는 다른 코드에서도 값이 바뀌고, 병렬 스트림에서는 여러 스레드가 동시에 객체를 고치는 경합이 생길 수 있습니다. 원소를 바꾼 결과가 필요하다면 map에서 새 record를 만들어 돌려주세요.
중간 연산의 순서를 정할 때 확인할 것
연산의 순서를 바꿔도 결과가 같다면 비용이 적게 드는 순서를 고를 수 있습니다. 지연 연산과 스트림 만들기에서 본 것처럼, 값싸고 많이 걸러 내는 filter를 비싼 map보다 앞에 두면 map이 호출되는 횟수가 줄어듭니다. 다만 filter의 조건이 map의 결과를 봐야 한다면 순서를 바꿀 수 없습니다.
반대로 순서를 바꾸면 결과가 달라지는 조합도 있습니다. distinct는 그 위치에 도착한 값을 기준으로 중복을 판단하므로, 변환 앞에 두느냐 뒤에 두느냐에 따라 결과가 바뀝니다. Stream.of("Apple", "apple")에 map(String::toLowerCase)를 적용한 뒤 distinct()를 하면 [apple]이 남지만, distinct()를 먼저 하면 두 문자열이 서로 다르므로 [apple, apple]이 됩니다. sorted와 limit도 앞에서 본 것처럼 '정렬한 뒤 앞에서 n개'와 '앞에서 n개를 고른 뒤 정렬'이 다른 질문입니다. 그러니 순서를 정할 때는 먼저 요구 사항이 어느 쪽인지부터 확인해야 합니다.
더 깊이: 병렬 스트림에서 상태 보유 연산이 비싼 이유
병렬 스트림은 원본을 여러 조각으로 나눠 여러 스레드가 동시에 처리합니다. 무상태 연산은 조각마다 따로 처리하면 그만이지만, 만남 순서를 지켜야 하는 상태 보유 연산은 조각 사이의 조율이 필요합니다. 예를 들어 limit(n)은 아무 원소 n개가 아니라 만남 순서상 처음 n개를 돌려줘야 하므로, 앞쪽 조각의 결과가 정해질 때까지 뒤쪽 조각의 결과를 붙잡아 두어야 할 수 있습니다. Javadoc은 limit과 skip이 순차 파이프라인에서는 대체로 싸지만 순서 있는 병렬 파이프라인에서는 n이 클수록 상당히 비쌀 수 있다고 설명합니다. distinct도 병렬에서 순서를 지키려면 전체를 모으는 장벽(full barrier)처럼 동작해야 해서 버퍼링 비용이 크다고 적고 있습니다.
결과의 순서가 정말 중요하지 않다면 unordered()로 순서 제약을 풀어 이런 연산을 훨씬 빠르게 만들 수 있습니다. 예를 들어 결과를 Set에 모으거나 합계만 구한다면 순서는 의미가 없습니다. 반대로 순서가 필요한데 병렬 실행이 느리다면 sequential()로 순차 실행하는 편이 나을 수 있다는 것이 Javadoc의 조언입니다. 병렬 스트림이 실제로 작업을 나누는 방식은 작업 훔치기와 병렬 스트림에서 다룹니다.
정리
- 중간 연산을 읽을 때는 단계마다 원소의 개수, 타입, 순서가 어떻게 바뀌는지 따라갑니다.
filter는 개수를,map은 값과 타입을,flatMap은 개수와 중첩 구조를 바꿉니다. map의 함수가 목록을 돌려주면 결과는 목록의 스트림이 됩니다. 내용물을 같은 수준으로 펼치려면 하위 스트림을 돌려주는flatMap이나 값을 하나씩 내보내는mapMulti를 씁니다. 펼친 뒤에도 출처가 필요하면 펼치기 전에record로 묶습니다.- 무상태 연산은 원소를 하나씩 끝까지 흘려보냅니다. 상태 보유 연산은 연산마다 기억하는 것이 다릅니다.
sorted는 모든 원소를 모은 뒤에야 첫 원소를 내보내지만,distinct는 본 원소의 집합만 기억하고 처음 보는 원소는 바로 넘깁니다.limit과takeWhile은 필요한 만큼만 읽고 멈추는 단락 연산입니다. takeWhile과dropWhile은 조건이 처음 깨지는 위치를 기준으로 나누므로, 모든 원소를 검사하는filter와 결과가 다릅니다.peek은 디버깅용입니다. 최적화로 실행되지 않을 수 있으므로 꼭 필요한 일을 넣지 않습니다.
연습 문제
Category(String name, List<String> slugs)목록을 받아, 분류 이름과slug를 함께 담은Row(String category, String slug)목록을 만드세요.slugs가 빈 분류는 결과에 아무 행도 남기지 않아야 합니다. 결과는slug의 사전순으로,slug가 같으면 분류 이름순으로 정렬하세요. 입력은notice:[welcome, event],empty:[],free:[question]입니다.- 1번의 입력을
notice:[same, b],empty:[],free:[same, a]로 바꿉니다. (1)map(Category::slugs)로 모은 목록의 크기, (2) 1번 풀이로 만든Row목록, (3)slug문자열만flatMap으로 펼친 뒤distinct()를 적용한 결과를 각각 구하세요. (3)의 결과로same이 어느 분류에서 왔는지 알 수 있나요? - 본문
StatefulIntermediateOperations의 입력을 (a, 10), (b, 40), (c, 30), (d, 20)으로 바꿉니다. 본문 순서(distinct→sorted→skip(1)→limit(2))와skip(1)·limit(2)를sorted앞으로 옮긴 순서의 결과를 각각 구하고, 왜 다른지 설명하세요. - 순서 있는 목록
[7, 2, 4]와[2, 4]에 짝수 조건으로takeWhile,dropWhile,filter를 각각 적용한 결과를 쓰세요. 결과가 빈 목록이 되는 경우가 오류가 아닌 이유도 설명하세요.
해설 보기
1번. 출처 관계를 잃지 않으려면 flatMap 안에서, 즉 아직 category를 손에 쥐고 있을 때 각 slug를 Row로 바꿔야 합니다. flatMap이 끝난 뒤에는 원소가 문자열뿐이라 어느 분류에서 왔는지 알 수 없습니다.
import java.util.Comparator;
import java.util.List;
public final class CategoryPostRowsSolution {
private record Category(String name, List<String> slugs) {}
private record Row(String category, String slug) {}
public static void main(String[] args) {
List<Category> categories =
List.of(
new Category("notice", List.of("welcome", "event")),
new Category("empty", List.of()),
new Category("free", List.of("question")));
List<Row> rows =
categories.stream()
.flatMap(
category ->
category.slugs().stream()
.map(slug -> new Row(category.name(), slug)))
.sorted(Comparator.comparing(Row::slug).thenComparing(Row::category))
.toList();
System.out.println(rows);
}
}[Row[category=notice, slug=event], Row[category=free, slug=question], Row[category=notice, slug=welcome]]empty 분류는 하위 스트림이 비어 있어서 flatMap을 지나며 자연스럽게 사라집니다. 따로 filter로 빈 분류를 걸러 낼 필요가 없습니다. 남은 세 행은 slug 기준으로 event, question, welcome 순서로 정렬됩니다.
2번. (1) map의 결과에서는 빈 목록도 원소 하나이므로 크기는 3입니다. (2) 1번 풀이를 그대로 적용하면 [Row[category=free, slug=a], Row[category=notice, slug=b], Row[category=free, slug=same], Row[category=notice, slug=same]]입니다. same이 두 번 나오지만 비교자가 slug가 같을 때 분류 이름을 비교하므로 free가 notice보다 앞에 옵니다. (3) 문자열만 펼치면 [same, b, same, a]가 되고, distinct()를 적용하면 [same, b, a]로 줄어듭니다. 남은 same이 어느 분류의 것인지는 알 수 없습니다. 출처가 필요하다면 1번처럼 펼치기 전에 묶어야 합니다.
3번. 본문 순서에서는 먼저 정렬하므로 (b, 40), (c, 30), (d, 20), (a, 10)이 되고, 1위를 건너뛴 두 개인 [(c, 30), (d, 20)]이 결과입니다. skip(1)·limit(2)를 먼저 하면 원본 순서에서 (a, 10)을 건너뛰고 (b, 40), (c, 30)을 고른 뒤 그 둘만 정렬하므로 [(b, 40), (c, 30)]이 됩니다. 앞의 것은 '전체에서 2위와 3위'이고 뒤의 것은 '원본의 두 번째와 세 번째를 조회 수 순으로'입니다. 본문 입력에서 두 순서의 결과가 같았던 것은 원본의 두 번째와 세 번째 원소가 마침 2위와 3위였기 때문입니다.
4번. [7, 2, 4]는 take=[], drop=[7, 2, 4], filter=[2, 4]입니다. 첫 원소 7부터 조건이 false이므로 조건을 만족하는 접두부가 비어 있고, takeWhile은 아무것도 가져가지 않으며 dropWhile은 아무것도 버리지 않습니다. 뒤에 짝수 2와 4가 있어도 takeWhile은 이미 멈췄으므로 상관없습니다. [2, 4]는 take=[2, 4], drop=[], filter=[2, 4]입니다. 모든 원소가 조건을 만족하므로 접두부가 목록 전체입니다. 빈 결과는 '조건을 만족하는 접두부가 0개'라는 정상적인 답이고, 스트림은 원소가 0개인 경우도 정상적으로 다룹니다.
다음 글
최종 연산과 기본형 스트림에서는 파이프라인의 끝에 오는 최종 연산을 다룹니다. count, findFirst, anyMatch, reduce가 각각 무엇을 돌려주고 원소가 하나도 없을 때 어떻게 되는지 확인하고, IntStream 같은 기본형 스트림으로 박싱 없이 합계와 통계를 구합니다.