최종 연산과 기본형 스트림
count·findFirst·anyMatch·reduce·toList 같은 최종 연산이 무엇을 돌려주고 원소가 하나도 없을 때 어떻게 되는지 확인하고, IntStream으로 박싱 없이 합계와 통계를 구하는 방법을 배웁니다.
중간 연산을 아무리 이어 붙여도 최종 연산을 호출하기 전에는 아무 일도 일어나지 않습니다. 지연 연산과 스트림 만들기에서 본 것처럼, 파이프라인을 실제로 실행하는 것은 최종 연산입니다. 최종 연산은 실행 결과를 숫자 하나, boolean, Optional, 컬렉션, 배열 같은 형태로 돌려주고, 그 뒤로 스트림은 다시 쓸 수 없습니다. 같은 중간 연산 뒤라도 어떤 최종 연산을 붙이느냐에 따라 돌려받는 타입과 원본에서 읽는 원소 수가 달라집니다.
최종 연산을 고를 때 먼저 따져 볼 것은 '원소가 하나도 없으면 무엇을 돌려받는가'입니다. 빈 경우를 생각하지 않고 짠 코드는 테스트 데이터에서는 잘 돌다가, 실제 데이터에서 NoSuchElementException을 던지거나 있지도 않은 0이나 -2147483648 같은 값을 결과처럼 내보냅니다. 이 글은 최종 연산을 결과 형태별로 살펴보면서 빈 스트림에서의 동작을 함께 확인하고, 마지막으로 숫자를 다룰 때 쓰는 기본형 스트림을 다룹니다.
최종 연산마다 돌려주는 것
아래 코드는 같은 목록에 자주 쓰는 최종 연산 여섯 가지를 하나씩 적용합니다. 줄마다 values.stream()으로 스트림을 새로 만든다는 점을 눈여겨보세요.
import java.util.Comparator;
import java.util.List;
public final class TerminalOperationCatalog {
public static void main(String[] args) {
List<Integer> values = List.of(3, 7, 10, 12);
System.out.println("count=" + values.stream().filter(v -> v >= 7).count());
System.out.println("max=" + values.stream().max(Comparator.naturalOrder()).orElseThrow());
System.out.println("any-even=" + values.stream().anyMatch(v -> v % 2 == 0));
System.out.println("all-positive=" + values.stream().allMatch(v -> v > 0));
System.out.println("sum=" + values.stream().reduce(0, Integer::sum));
System.out.println("array-length=" + values.stream().toArray(Integer[]::new).length);
}
}여섯 줄은 모두 최종 연산의 반환값을 찍지만, 반환 타입은 long, Integer, boolean, 배열처럼 연산마다 다릅니다. 마지막 줄은 배열 자체 대신 그 길이를 찍었습니다.
count=3
max=12
any-even=true
all-positive=true
sum=32
array-length=4count는 7 이상인 7, 10, 12를 세어 long 값 3을 돌려줍니다. max는 결과를 바로 돌려주지 않고 Optional<Integer>에 담아 주는데, 원소가 없으면 최댓값도 없기 때문입니다. 여기서는 원소가 있다는 것을 알고 있으므로 orElseThrow()로 값을 꺼냈습니다. anyMatch와 allMatch는 boolean을, reduce는 원소를 모두 더한 값 32를, toArray는 원소를 담은 배열을 돌려줍니다.
줄마다 스트림을 새로 만든 이유는 스트림이 한 번만 소비될 수 있기 때문입니다. 최종 연산을 한 번 실행한 스트림에 다시 최종 연산을 호출하면 IllegalStateException: stream has already been operated upon or closed가 납니다. 이 내용은 스트림 파이프라인: 원본, 중간 연산, 최종 연산에서 다뤘습니다.
주요 최종 연산이 돌려주는 타입과, 원소가 하나도 없을 때의 결과를 정리하면 다음과 같습니다.
| 최종 연산 | 돌려주는 것 | 원소가 없을 때 |
|---|---|---|
count() | long | 0 |
min(비교자), max(비교자) | Optional<T> | 빈 Optional |
findFirst(), findAny() | Optional<T> | 빈 Optional |
anyMatch(조건) | boolean | false |
allMatch(조건), noneMatch(조건) | boolean | true |
reduce(항등값, 함수) | T | 항등값 |
reduce(함수) | Optional<T> | 빈 Optional |
toList() | List<T> | 빈 목록 |
toArray() | 배열 | 길이가 0인 배열 |
collect(수집기) | 수집기가 정한 결과 | 수집기마다 다름 |
forEach(동작) | 없음(void) | 동작을 한 번도 실행하지 않음 |
count와 reduce(항등값, 함수)는 빈 경우에도 0이나 항등값을 돌려줍니다. 반면 min, max, findFirst처럼 원소 하나를 골라야 하는 연산은 고를 원소가 없을 수 있으므로, Optional로 그 가능성을 타입에 드러냅니다. Java 16에 추가된 toList()는 수정할 수 없는 목록을 돌려주므로, 결과에 add를 호출하면 UnsupportedOperationException이 납니다. 예전부터 쓰던 collect(Collectors.toList())는 수정 가능 여부를 약속하지 않습니다. 수정할 수 없는 목록을 다루는 방법은 수정할 수 없는 목록과 정렬 결과 다루기에서, collect와 수집기는 다음 글에서 자세히 다룹니다.
빈 스트림에서 findFirst의 값을 꺼낼 때
findFirst()는 스트림의 첫 원소를 Optional에 담아 돌려주고, 원소가 없으면 빈 Optional을 돌려줍니다. 보통 filter 뒤에 붙여 조건을 만족하는 첫 원소를 찾는 데 씁니다. 아래 코드는 z로 시작하는 제목이 없는 목록에서 findFirst()의 결과에 바로 get()을 호출합니다.
import java.util.List;
public final class EmptyFindFirstFailure {
public static void main(String[] args) {
String value =
List.of("stream", "collector").stream()
.filter(title -> title.startsWith("z"))
.findFirst()
.get();
System.out.println(value);
}
}get()을 감싸는 try가 없으므로, 여기서 예외가 나면 스택 트레이스를 남기고 프로그램이 끝납니다. JDK 21에서 실행한 결과입니다.
Exception in thread "main" java.util.NoSuchElementException: No value present
at java.base/java.util.Optional.get(Optional.java:143)
at EmptyFindFirstFailure.main(EmptyFindFirstFailure.java:9)filter를 통과한 원소가 없으므로 findFirst()는 빈 Optional을 돌려줬고, get()은 값이 없는 Optional에서 호출되자 NoSuchElementException을 던졌습니다. 스택 트레이스의 둘째 줄은 예외가 Optional.get 안에서 났다는 것을, 셋째 줄은 그 get()을 호출한 곳이 예제 파일의 9번째 줄이라는 것을 알려 줍니다. get()은 '여기에는 반드시 값이 있다'고 가정하는 메서드인데, 그 가정을 확인하지 않고 호출한 것이 문제입니다.
값이 없는 경우를 어떻게 처리할지는 그 상황이 정상인지에 따라 정합니다. 아래 코드는 공개된 글 가운데 주어진 slug의 제목을 찾는 메서드로, 찾지 못하는 것이 정상적인 상황이라고 보고 결과를 Optional 그대로 돌려줍니다.
import java.util.List;
import java.util.Optional;
public final class PostLookupPipeline {
private record Post(String slug, String title, boolean published) {}
private static final List<Post> POSTS =
List.of(new Post("welcome", "Welcome", true), new Post("draft", "Draft", false));
static Optional<String> findPublishedTitle(String slug) {
return POSTS.stream()
.filter(Post::published)
.filter(post -> post.slug().equals(slug))
.map(Post::title)
.findFirst();
}
public static void main(String[] args) {
System.out.println(findPublishedTitle("welcome"));
System.out.println(findPublishedTitle("draft"));
System.out.println(findPublishedTitle("welcome").orElse("not-found"));
System.out.println(findPublishedTitle("draft").orElse("not-found"));
}
}findPublishedTitle의 반환값을 그대로 찍으면 Optional의 toString()이 나오고, orElse를 거치면 안에 든 문자열이나 기본값이 나옵니다. 출력의 위 두 줄과 아래 두 줄을 비교해 보세요.
Optional[Welcome]
Optional.empty
Welcome
not-foundwelcome은 공개된 글이라 두 filter를 모두 통과하고 map에서 제목 Welcome으로 바뀌어 Optional[Welcome]이 됩니다. draft는 글이 존재하지만 공개되지 않아 첫 filter에서 걸러지므로 Optional.empty입니다. 아래 두 줄은 orElse로 값이 없을 때 쓸 기본값을 정한 경우입니다. 스트림은 원소가 0개 이상인 흐름이고 Optional은 값이 0개 또는 1개인 상자이므로, findFirst는 '여러 개일 수 있는 원소'를 '많아야 하나인 결과'로 줄이는 연산이라고 볼 수 있습니다.
값이 없을 때의 처리 방법은 대략 세 가지로 나뉩니다. 없어도 정상이라면 orElse(기본값)이나 orElseGet(공급자)로 대신할 값을 정하거나, 이 예제처럼 Optional을 호출자에게 그대로 돌려줍니다. 반드시 있어야 하는데 없다면 버그나 잘못된 입력이므로 orElseThrow(() -> new IllegalStateException("..."))처럼 이유를 담은 예외를 던집니다. 마지막으로 null로 바꿔 돌려주는 방법은 피해야 합니다. 호출자가 null 검사를 다시 해야 하고, 빠뜨리면 엉뚱한 곳에서 NullPointerException이 납니다.
기본값을 고를 때도 주의할 점이 있습니다. 예제의 "not-found"는 실제 제목과 섞일 수 있는 문자열이라서, 정말로 제목이 not-found인 글이 생기면 '찾지 못함'과 구별할 수 없습니다. 그런 가능성이 있다면 Optional을 끝까지 유지하는 편이 낫습니다. 또 orElse의 인수는 값이 있어도 미리 계산되므로, 기본값을 만드는 데 비용이 크다면 필요할 때만 호출되는 orElseGet을 씁니다. Optional을 만들고 꺼내는 API는 33장에서 자세히 다룹니다.
findAny()는 findFirst()와 달리 만남 순서상 첫 원소가 아니라 아무 원소나 돌려줄 수 있어서, 병렬 스트림에서는 첫 원소를 기다리지 않는 만큼 더 빠를 수 있습니다. 두 연산의 차이는 지연 연산과 스트림 만들기에서 다뤘습니다. 한 가지 덧붙이면, 두 연산 모두 고른 원소가 null이면 Optional에 담을 수 없어서 NullPointerException을 던집니다.
답이 정해지면 멈추는 anyMatch·allMatch·noneMatch
anyMatch, allMatch, noneMatch는 조건을 검사해 boolean 하나를 돌려줍니다. 셋 다 지연 연산과 스트림 만들기에서 소개한 단락(short-circuiting) 연산으로, 답이 정해지는 순간 나머지 원소를 보지 않고 끝낼 수 있습니다. 아래 코드는 조건을 검사할 때마다 그 원소를 출력해서, 세 연산이 각각 어디까지 원소를 읽는지 보여 줍니다.
import java.util.List;
public final class ShortCircuitMatchTrace {
private static int seen(int value) {
System.out.print(value + " ");
return value;
}
public static void main(String[] args) {
List<Integer> values = List.of(1, 3, 4, 6, 8);
System.out.print("anyMatch(even): ");
boolean any = values.stream().anyMatch(v -> seen(v) % 2 == 0);
System.out.println("-> " + any);
System.out.print("allMatch(< 5): ");
boolean all = values.stream().allMatch(v -> seen(v) < 5);
System.out.println("-> " + all);
System.out.print("noneMatch(> 10): ");
boolean none = values.stream().noneMatch(v -> seen(v) > 10);
System.out.println("-> " + none);
}
}seen은 검사할 원소를 줄바꿈 없이 찍고 그 값을 그대로 돌려줍니다. 그래서 각 줄에서 -> 앞에 찍힌 숫자가 그 연산이 실제로 검사한 원소입니다.
anyMatch(even): 1 3 4 -> true
allMatch(< 5): 1 3 4 6 -> false
noneMatch(> 10): 1 3 4 6 8 -> trueanyMatch는 '짝수가 하나라도 있는가'를 묻습니다. 1과 3은 홀수이고 4에서 처음 조건이 참이 되므로, 그 순간 답이 true로 정해져 6과 8은 검사하지 않았습니다. allMatch는 '모두 5보다 작은가'를 묻습니다. 1, 3, 4는 조건을 만족하지만 6에서 처음 거짓이 되므로 답이 false로 정해지고 8은 검사하지 않았습니다. noneMatch는 '10보다 큰 원소가 하나도 없는가'를 묻는데, 그렇다고 답하려면 모든 원소를 확인해야 하므로 다섯 개를 모두 검사했습니다.
세 연산이 멈추는 규칙을 정리하면 다음과 같습니다. 일반적으로 '하나라도 있다'는 원소 하나로 확인할 수 있지만, '모두 그렇다'나 '하나도 없다'는 반례가 없다는 것을 끝까지 확인해야 합니다.
| 연산 | 중간에 멈추는 경우 | 끝까지 봐야 하는 경우 |
|---|---|---|
anyMatch | 조건이 참인 원소를 만나면 true | 참인 원소가 없으면 false |
allMatch | 조건이 거짓인 원소를 만나면 false | 모두 참이면 true |
noneMatch | 조건이 참인 원소를 만나면 false | 모두 거짓이면 true |
noneMatch에서는 조건식의 결과와 연산의 결과가 반대라는 점이 헷갈리기 쉽습니다. 조건이 참인 원소를 하나 만나면 '하나도 없다'는 주장이 깨지므로 연산은 false를 돌려줍니다.
출력에 보이는 검사 횟수(3번, 4번, 5번)는 이 순차 실행에서 일어난 일일 뿐입니다. Javadoc은 결과를 정하는 데 필요하지 않으면 모든 원소에 조건을 적용하지 않을 수도 있다고만 말할 뿐, 정확히 몇 번 호출하는지는 약속하지 않습니다. 병렬 스트림에서는 다른 스레드가 이미 뒤쪽 원소를 검사하고 있을 수 있어 호출 횟수와 순서가 더 달라집니다. 그러니 예제의 seen처럼 관찰용 출력이 아니라면, 조건식 안에서 횟수를 세거나 상태를 바꾸는 일에 의미를 두면 안 됩니다.
빈 스트림에서는 anyMatch가 false, allMatch와 noneMatch가 true를 돌려주며, 조건식은 한 번도 호출되지 않습니다. '모든 원소가 양수다'라는 주장을 반박할 원소가 하나도 없으니 참으로 보는 것인데, 이를 공허한 참(vacuous truth)이라고 합니다. 논리적으로는 맞지만 실수를 부르기 쉽습니다. 예를 들어 scores.stream().allMatch(s -> s >= 60)은 학생이 한 명도 없는 빈 목록에서도 true이므로, '전원 합격'이라고 출력하면 틀린 안내가 됩니다. '한 명 이상이고 모두 합격'을 확인하려면 !scores.isEmpty() && scores.stream().allMatch(s -> s >= 60)처럼 원소가 있는지 따로 검사해야 합니다.
reduce로 원소를 값 하나로 접기
reduce는 원소들을 차례로 결합해 값 하나로 만듭니다. 항등값(identity)과 누산 함수(accumulator)를 받는 reduce(identity, accumulator)는 Javadoc에 따르면 다음 반복문과 같은 결과를 냅니다. 다만 이 순서대로 실행된다는 제약은 없습니다.
T result = identity;
for (T element : 스트림의 원소)
result = accumulator.apply(result, element);
return result;List.of(5, 7, 9).stream().reduce(0, Integer::sum)을 이 반복문대로 따라가면 다음과 같습니다.
| 단계 | 이전 result | 원소 | 새 result |
|---|---|---|---|
| 시작 | 0 (항등값) | ||
| 1 | 0 | 5 | 5 |
| 2 | 5 | 7 | 12 |
| 3 | 12 | 9 | 21 |
이 반복문을 보면 항등값이 두 가지 역할을 한다는 것을 알 수 있습니다. 원소가 없으면 반복문이 한 번도 돌지 않으므로 항등값이 그대로 결과가 되고, 원소가 있으면 첫 원소와 결합되는 출발점이 됩니다. 그래서 Javadoc은 모든 t에 대해 accumulator.apply(identity, t)가 t와 같아야 한다고 요구합니다. 덧셈의 0, 곱셈의 1, 문자열 이어 붙이기의 빈 문자열이 이 조건을 만족합니다.
누산 함수에도 조건이 있습니다. 결합 법칙(associativity)을 만족해야 하고, 스트림의 원본을 건드리지 않아야 하며(non-interfering), 바깥 상태에 의존하지 않아야(stateless) 합니다. 결합 법칙은 세 값을 결합할 때 (a, b)를 먼저 묶든 (b, c)를 먼저 묶든 결과가 같다는 성질입니다. 이 조건이 필요한 이유는 앞에서 본 "이 순서대로 실행된다는 제약은 없다"는 말에 있습니다. 라이브러리가 원소를 다른 방식으로 묶어 계산해도 같은 답이 나와야 하기 때문입니다.
아래 코드는 이 규칙을 지킨 경우와 어긴 경우를 함께 보여 줍니다.
import java.util.List;
import java.util.Optional;
public final class SafeReductionExamples {
public static void main(String[] args) {
List<Integer> values = List.of(5, 7, 9);
int sum = values.stream().reduce(0, Integer::sum);
int wrongMin = values.stream().reduce(0, Integer::min);
Optional<Integer> min = values.stream().reduce(Integer::min);
Optional<Integer> emptyMin = List.<Integer>of().stream().reduce(Integer::min);
String joined =
List.of("stream", "terminal").stream()
.reduce("", (left, right) -> left.isEmpty() ? right : left + "," + right);
System.out.println("sum=" + sum);
System.out.println("wrongMin=" + wrongMin);
System.out.println("min=" + min + ", emptyMin=" + emptyMin);
System.out.println("joined=" + joined);
}
}sum과 wrongMin은 같은 항등값 0에서 출발합니다. 0은 덧셈의 결과를 바꾸지 않는 값인데, 최솟값 계산에서도 그런지 출력으로 확인해 보세요.
sum=21
wrongMin=0
min=Optional[5], emptyMin=Optional.empty
joined=stream,terminalwrongMin이 0인 이유는 0이 min의 항등값이 아니기 때문입니다. Math.min(0, 5)는 5가 아니라 0이므로, 항등값 0이 결과에 그대로 남아 데이터에 없는 값을 최솟값으로 내놓았습니다. 최솟값처럼 마땅한 항등값이 없는 연산에는 인수 하나짜리 reduce(Integer::min)을 씁니다. 이 형태는 첫 원소에서 출발하고 결과를 Optional로 돌려주므로, 빈 목록에서는 Optional.empty가 나옵니다. Integer.MAX_VALUE를 항등값으로 쓰면 계산은 맞지만, 빈 목록에서 2147483647이 나와 진짜 값과 구별할 수 없습니다.
마지막 줄에서 문자열을 잇는 누산 함수는 결과가 맞아 보이지만 결합 법칙을 지키지 않습니다. 이 함수를 f라고 하면, f는 왼쪽이 빈 문자열이면 오른쪽을 그대로 돌려주고 아니면 쉼표를 넣어 잇습니다. 원소에 빈 문자열이 섞인 "a", "", "b"를 두 가지 방식으로 묶어 보면 결과가 갈립니다.
중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다
a·빈 문자열·b의 두 괄호 묶음에서 중간값이 a, 또는 b가 되어 a,,b와 a,b로 갈립니다. 두 독립 계산의 중간값을 추적하면 결합법칙 위반이 결과 차이를 만드는 이유가 드러납니다.
SafeReductionExamples의 문자열 누산 함수 f를 a, 빈 문자열, b에 적용합니다.
결합 법칙을 깨는 반례
중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다
f(left, right)는 left가 비면 right를, 아니면 left + ',' + right를 반환합니다. a·빈 문자열·b를 묶는 순서에 따라 결과가 달라집니다.
왼쪽부터
- 중간 계산
- f("a", "") = "a," 뒤 f("a,", "b")
- 결과
- "a,,b"
오른쪽부터
- 중간 계산
- f("", "b") = "b" 뒤 f("a", "b")
- 결과
- "a,b"
f는 원문 문자열 누산 함수입니다. main은 비어 있지 않은 stream·terminal만 사용하며, 위 반례는 실행 관측이 아닌 함수식 계산입니다.
이 예의 조건
- 괄호 계산의 의존 관계이며 병렬 실측이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 함수 f는 왼쪽이 빈 문자열이면 오른쪽만 반환하고, 아니면 쉼표로 연결한다. 같은 a, 빈 문자열, b를 왼쪽부터 묶으면 f(a,empty)=a 콤마 뒤에 b를 연결하여 a,,b이고, 오른쪽부터 f(empty,b)=b를 만들면 f(a,b)=a,b이다. 각 경로는 괄호 계산의 의존 관계이며 시간순서나 병렬 실행을 실측한 결과가 아니다. 5개 노드와 4개 간선은 mobile에서도 동일하며 원 반례의 aux 계산식을 모두 보존한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 함수 f는 왼쪽이 빈 문자열이면 오른쪽만 반환하고, 아니면 쉼표로 연결한다. 같은 a, 빈 문자열, b를 왼쪽부터 묶으면 f(a,empty)=a 콤마 뒤에 b를 연결하여 a,,b이고, 오른쪽부터 f(empty,b)=b를 만들면 f(a,b)=a,b이다. 각 경로는 괄호 계산의 의존 관계이며 시간순서나 병렬 실행을 실측한 결과가 아니다. 5개 노드와 4개 간선은 mobile에서도 동일하며 원 반례의 aux 계산식을 모두 보존한다. |
| 전체 읽기 정보 | 중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다 중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다 SafeReductionExamples의 문자열 누산 함수 f를 a, 빈 문자열, b에 적용합니다. 결합 법칙을 깨는 반례 중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다 f(left, right)는 left가 비면 right를, 아니면 left + ',' + right를 반환합니다. a·빈 문자열·b를 묶는 순서에 따라 결과가 달라집니다. 왼쪽부터 중간 계산 f("a", "") = "a," 뒤 f("a,", "b") 결과 "a,,b" 오른쪽부터 중간 계산 f("", "b") = "b" 뒤 f("a", "b") 결과 "a,b" f는 원문 문자열 누산 함수입니다. main은 비어 있지 않은 stream·terminal만 사용하며, 위 반례는 실행 관측이 아닌 함수식 계산입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 입력 a, 빈 문자열, b를 두 괄호 방식에 둡니다. |
|
|
| 각 첫 묶음 왼쪽은 f(a,empty), 오른쪽은 f(empty,b)를 계산합니다. |
|
|
| 최종 반례 각 중간값에 남은 입력을 결합합니다. |
|
|
중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다
a·빈 문자열·b의 두 괄호 묶음에서 중간값이 a, 또는 b가 되어 a,,b와 a,b로 갈립니다. 두 독립 계산의 중간값을 추적하면 결합법칙 위반이 결과 차이를 만드는 이유가 드러납니다.
SafeReductionExamples의 문자열 누산 함수 f를 a, 빈 문자열, b에 적용합니다.
결합 법칙을 깨는 반례
중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다
f(left, right)는 left가 비면 right를, 아니면 left + ',' + right를 반환합니다. a·빈 문자열·b를 묶는 순서에 따라 결과가 달라집니다.
왼쪽부터
- 중간 계산
- f("a", "") = "a," 뒤 f("a,", "b")
- 결과
- "a,,b"
오른쪽부터
- 중간 계산
- f("", "b") = "b" 뒤 f("a", "b")
- 결과
- "a,b"
f는 원문 문자열 누산 함수입니다. main은 비어 있지 않은 stream·terminal만 사용하며, 위 반례는 실행 관측이 아닌 함수식 계산입니다.
이 예의 조건
- 괄호 계산의 의존 관계이며 병렬 실측이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 함수 f는 왼쪽이 빈 문자열이면 오른쪽만 반환하고, 아니면 쉼표로 연결한다. 같은 a, 빈 문자열, b를 왼쪽부터 묶으면 f(a,empty)=a 콤마 뒤에 b를 연결하여 a,,b이고, 오른쪽부터 f(empty,b)=b를 만들면 f(a,b)=a,b이다. 각 경로는 괄호 계산의 의존 관계이며 시간순서나 병렬 실행을 실측한 결과가 아니다. 5개 노드와 4개 간선은 mobile에서도 동일하며 원 반례의 aux 계산식을 모두 보존한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 함수 f는 왼쪽이 빈 문자열이면 오른쪽만 반환하고, 아니면 쉼표로 연결한다. 같은 a, 빈 문자열, b를 왼쪽부터 묶으면 f(a,empty)=a 콤마 뒤에 b를 연결하여 a,,b이고, 오른쪽부터 f(empty,b)=b를 만들면 f(a,b)=a,b이다. 각 경로는 괄호 계산의 의존 관계이며 시간순서나 병렬 실행을 실측한 결과가 아니다. 5개 노드와 4개 간선은 mobile에서도 동일하며 원 반례의 aux 계산식을 모두 보존한다. |
| 전체 읽기 정보 | 중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다 중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다 SafeReductionExamples의 문자열 누산 함수 f를 a, 빈 문자열, b에 적용합니다. 결합 법칙을 깨는 반례 중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다 f(left, right)는 left가 비면 right를, 아니면 left + ',' + right를 반환합니다. a·빈 문자열·b를 묶는 순서에 따라 결과가 달라집니다. 왼쪽부터 중간 계산 f("a", "") = "a," 뒤 f("a,", "b") 결과 "a,,b" 오른쪽부터 중간 계산 f("", "b") = "b" 뒤 f("a", "b") 결과 "a,b" f는 원문 문자열 누산 함수입니다. main은 비어 있지 않은 stream·terminal만 사용하며, 위 반례는 실행 관측이 아닌 함수식 계산입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 입력 a, 빈 문자열, b를 두 괄호 방식에 둡니다. |
|
|
| 각 첫 묶음 왼쪽은 f(a,empty), 오른쪽은 f(empty,b)를 계산합니다. |
|
|
| 최종 반례 각 중간값에 남은 입력을 결합합니다. |
|
|
왼쪽부터 묶으면 f("a", "")가 "a,"이고 f("a,", "b")가 "a,,b"입니다. 오른쪽부터 묶으면 f("", "b")가 "b"이고 f("a", "b")가 "a,b"입니다. 같은 세 원소인데 묶는 순서에 따라 결과가 다르므로 이 함수는 reduce에 쓸 수 없습니다.
뺄셈도 마찬가지로 (1 - 2) - 3은 -4, 1 - (2 - 3)은 2입니다. 지금 코드가 순차 스트림이라 문제가 드러나지 않을 뿐, 결합 법칙은 reduce를 쓰는 조건이므로 parallel()을 호출하지 않았다고 해서 지키지 않아도 되는 것은 아닙니다. 문자열을 구분자로 이어 붙이려면 의도가 이름에 드러나는 Collectors.joining(",")이나 String.join(",", 목록)을 씁니다.
더 깊이: 병렬로 실행하면 실제로 달라지는 결과
규칙을 어긴 reduce가 병렬 스트림에서 실제로 어떻게 되는지 확인해 보겠습니다. 앞의 문자열 함수 f와, 항등값 자리에 0 대신 10을 넣은 합계를 순차와 병렬로 각각 실행합니다.
import java.util.List;
import java.util.function.BinaryOperator;
import java.util.stream.Collectors;
public final class ParallelReduceCounterexamples {
public static void main(String[] args) {
BinaryOperator<String> f = (left, right) -> left.isEmpty() ? right : left + "," + right;
List<String> words = List.of("a", "", "b");
System.out.println("sequential=" + words.stream().reduce("", f));
System.out.println("parallel=" + words.parallelStream().reduce("", f));
System.out.println("joining=" + words.stream().collect(Collectors.joining(",")));
List<Integer> numbers = List.of(1, 2, 3);
System.out.println("sum from 10, sequential=" + numbers.stream().reduce(10, Integer::sum));
System.out.println("sum from 10, parallel=" + numbers.parallelStream().reduce(10, Integer::sum));
}
}병렬 스트림이 원소를 어떤 조각으로 나눌지는 구현과 실행 환경이 정하므로, parallel이 붙은 줄의 값은 다른 환경에서 달라질 수 있습니다. JDK 21에서 실행한 결과입니다.
sequential=a,,b
parallel=a,b
joining=a,,b
sum from 10, sequential=16
sum from 10, parallel=36병렬 스트림은 원소를 조각으로 나눠 조각마다 항등값에서 출발해 결합한 뒤, 조각의 결과를 다시 결합합니다. 세 원소가 각각 한 조각씩 처리되었다고 보면 출력이 그대로 설명됩니다. 문자열 쪽은 조각별 결과가 "a", "", "b"가 되고, 오른쪽 두 조각을 합친 f("", "b")가 "b", 마지막 f("a", "b")가 "a,b"가 되어 가운데 빈 문자열이 사라졌습니다. 순차 실행과 joining은 "a,,b"인데 병렬 실행만 다른 답을 낸 것입니다.
합계 쪽은 조각마다 10에서 출발하므로 11, 12, 13을 더한 36이 나왔습니다. 조각을 몇 개로 어떻게 나누는지는 구현과 실행 환경이 정하므로 다른 환경에서는 다른 값이 나올 수 있지만, 규칙을 지킨 reduce라면 어떻게 나눠도 같은 답이 나옵니다. 병렬 스트림과 결합 법칙은 병렬 스트림의 결합 법칙과 성능 측정에서 자세히 다룹니다.
기본형 스트림: IntStream, LongStream, DoubleStream
Stream<Integer>의 원소는 int가 아니라 Integer 객체입니다. 그래서 reduce(0, Integer::sum)은 단계마다 Integer에서 int를 꺼내 더하고, 그 결과를 다시 Integer로 감쌉니다. 이렇게 기본형과 래퍼 객체 사이를 오가는 것을 박싱과 언박싱이라고 합니다(래퍼 클래스와 박싱·언박싱). 박싱은 캐시 범위(-128부터 127까지)를 벗어난 값마다 새 객체를 만들기 때문에, 원소가 많아질수록 비용이 쌓입니다. 이 문제를 줄이려고 자바는 int, long, double을 그대로 다루는 IntStream, LongStream, DoubleStream을 따로 제공합니다.
기본형 스트림에는 숫자 계산에 필요한 최종 연산이 준비되어 있습니다. sum(), average(), min(), max(), 그리고 개수·합계·최솟값·최댓값·평균을 한 번에 구하는 summaryStatistics()가 있습니다. 객체 스트림에서 기본형 스트림으로는 mapToInt, mapToLong, mapToDouble로 바꾸고, 다시 객체 스트림이 필요하면 boxed()나 mapToObj를 씁니다.
import java.util.IntSummaryStatistics;
import java.util.List;
public final class PrimitiveBoardStatistics {
private record PostMetric(String title, int viewCount) {}
public static void main(String[] args) {
List<PostMetric> posts =
List.of(
new PostMetric("welcome", 45),
new PostMetric("maintenance", 60),
new PostMetric("question", 30));
IntSummaryStatistics stats =
posts.stream().mapToInt(PostMetric::viewCount).summaryStatistics();
double average = posts.stream().mapToInt(PostMetric::viewCount).average().orElse(0.0);
List<Integer> popularViewCounts =
posts.stream()
.mapToInt(PostMetric::viewCount)
.filter(value -> value >= 40)
.boxed()
.toList();
System.out.println(
"count="
+ stats.getCount()
+ ", sum="
+ stats.getSum()
+ ", min="
+ stats.getMin()
+ ", max="
+ stats.getMax()
+ ", avg="
+ average);
System.out.println(popularViewCounts);
}
}둘째 줄의 popularViewCounts는 IntStream에서 40 이상인 값만 남긴 뒤, boxed()로 다시 Integer 스트림으로 바꿔 목록에 모은 결과입니다. 기본형 스트림에는 int 목록이 없으므로 List로 모으려면 이렇게 박싱해야 합니다.
count=3, sum=135, min=30, max=60, avg=45.0
[45, 60]mapToInt(PostMetric::viewCount)가 Stream<PostMetric>을 IntStream으로 바꾸고, summaryStatistics()는 원소를 한 번 훑으면서 개수 3, 합계 135, 최솟값 30, 최댓값 60을 모두 구합니다. average()는 평균 45.0을 OptionalDouble에 담아 돌려주므로 orElse(0.0)으로 꺼냈습니다. 사실 stats.getAverage()로도 같은 평균을 얻을 수 있는데, 두 방법은 원소가 없을 때 다르게 동작합니다. 이 차이는 바로 아래에서 확인합니다.
세 번째 파이프라인에서는 40 이상인 조회 수만 남긴 뒤 boxed()로 Stream<Integer>로 되돌렸습니다. IntStream에는 toList()가 없으므로, 목록이 필요하면 이렇게 객체 스트림으로 바꿔서 모아야 합니다. 세 결과는 모두 posts.stream()으로 새로 만든 스트림에서 따로 계산한 것입니다.
원소가 없는 기본형 스트림에서는 각 연산이 무엇을 돌려줄까요? 아래 코드로 확인해 보겠습니다.
import java.util.stream.IntStream;
public final class EmptyIntStreamResults {
public static void main(String[] args) {
System.out.println("sum=" + IntStream.empty().sum());
System.out.println("average=" + IntStream.empty().average());
System.out.println("max=" + IntStream.empty().max());
System.out.println(IntStream.empty().summaryStatistics());
}
}네 줄은 빈 스트림에 서로 다른 최종 연산을 적용한 결과입니다. IntStream.empty()는 호출할 때마다 새 빈 스트림을 만들므로, 한 스트림을 두 번 쓰는 문제는 생기지 않습니다.
sum=0
average=OptionalDouble.empty
max=OptionalInt.empty
IntSummaryStatistics{count=0, sum=0, min=2147483647, average=0.000000, max=-2147483648}합계는 더한 것이 없으니 0이 자연스럽고, 평균과 최댓값은 정할 수 없으므로 빈 OptionalDouble과 빈 OptionalInt가 나왔습니다. 문제는 마지막 줄입니다. 빈 IntSummaryStatistics의 최솟값은 Integer.MAX_VALUE(2147483647), 최댓값은 Integer.MIN_VALUE(-2147483648), 평균은 0.0입니다. 통계 객체는 최솟값을 가장 큰 int에서 출발시켜 원소가 들어올 때마다 더 작은 값으로 바꾸는데, 원소가 하나도 들어오지 않아 출발값이 그대로 남은 것입니다. 이 값들은 실제 데이터가 아니므로 getMin()이나 getMax()를 쓰기 전에 getCount()가 0인지 먼저 확인해야 합니다. 연습 문제 2와 3에서 이 처리를 직접 해 봅니다.
더 깊이: 기본형 스트림과 반복문, 무엇이 더 빠를까
박싱이 없는 만큼 IntStream은 대체로 Stream<Integer>보다 할당이 적습니다. 성능에 민감한 숫자 계산이라면 기본형 배열을 도는 단순한 for 문이 JIT 컴파일러의 최적화와 연속된 메모리 접근 덕분에 더 유리할 수도 있습니다. 하지만 실제 차이는 데이터 크기, 분기 구조, 원본 타입, JIT가 충분히 예열되었는지 등에 따라 크게 달라지므로, '항상 무엇이 빠르다'고 단정할 수 없습니다.
차이를 알고 싶다면 측정해야 하는데, System.currentTimeMillis()로 한 번 재 본 결과로는 결론을 낼 수 없습니다. 예열 전의 느린 실행이 섞이고, 결과를 쓰지 않는 계산은 JIT가 아예 없애 버릴 수도 있기 때문입니다. JMH처럼 예열 반복, 별도 JVM 실행(fork), 죽은 코드 제거 방지를 제공하는 도구로 실제 입력 크기에 맞춰 측정해야 합니다. 이 내용은 JIT 예열과 벤치마크에서 다룹니다. 데이터가 작다면 그 차이가 전체 응답 시간에서 의미 없는 경우도 많으니, 읽기 쉬운 코드를 먼저 고르고 측정 결과가 필요를 보여 줄 때 바꾸는 편이 낫습니다.
forEach는 결과를 모으는 데 쓰지 않습니다
forEach는 값을 돌려주지 않고 원소마다 동작을 실행하는 최종 연산입니다. 결과가 없으니 쓸모는 출력, 전송 같은 부수 효과뿐인데, 그래서 결과 목록을 만들 때 다음처럼 바깥 목록에 원소를 쌓는 코드가 자주 보입니다.
List<String> titles = new ArrayList<>();
posts.stream().map(Post::title).forEach(titles::add); // 바깥 목록에 결과를 쌓는다순차 스트림에서는 이 코드가 동작합니다. 하지만 누군가 parallel()을 붙이면 여러 스레드가 ArrayList.add를 동시에 호출하게 되고, ArrayList는 스레드에 안전하지 않으므로 원소가 빠지거나 예외가 날 수 있습니다. 스트림 파이프라인: 원본, 중간 연산, 최종 연산에서 파이프라인 안에서 바깥 상태를 바꾸지 말라고 한 것과 같은 이유입니다. 결과가 필요하면 List<String> titles = posts.stream().map(Post::title).toList();처럼 최종 연산이 결과를 만들어 돌려주게 해야 합니다. 이렇게 하면 순차든 병렬이든 같은 결과를 얻습니다.
forEach의 실행 순서도 약속되지 않습니다. Javadoc은 이 연산의 동작이 명시적으로 비결정적이라고 밝힙니다. 병렬 스트림에서는 만남 순서를 지킨다고 약속하지 않으며, 각 원소의 동작이 어느 스레드에서 언제 실행될지도 라이브러리가 정합니다. 순차 예제에서 원소가 순서대로 출력됐다고 해서 그 순서를 믿고 코드를 짜면 안 됩니다. 순서가 필요하면 forEachOrdered를 쓰는데, 이 연산은 만남 순서가 있는 스트림에서 원소를 그 순서대로 하나씩 처리하므로 병렬 실행의 이점이 줄어듭니다.
forEach가 중간에 예외로 멈추면, 그 앞의 원소들에 대한 동작은 이미 실행된 뒤라 되돌릴 수 없습니다. 원소 전체를 한꺼번에 반영하거나 하나도 반영하지 않아야 하는 작업이라면, 먼저 최종 연산으로 결과를 모두 만든 다음 트랜잭션 안에서 한 번에 반영하는 구조가 안전합니다.
메서드가 Stream을 돌려줄 때 생각할 것
메서드가 결과 대신 Stream을 돌려주면, 최종 연산을 정확히 한 번 실행하는 일과 필요할 때 스트림을 닫는 일이 호출자에게 넘어갑니다. 메모리에 있는 컬렉션에서 만든 스트림이라면 호출자가 원하는 최종 연산을 고를 수 있어 오히려 편리합니다. 하지만 파일이나 데이터베이스 커서처럼 닫아야 하는 자원에 연결된 스트림이라면, 자원을 연 쪽에서 최종 연산까지 실행하고 List 같은 결과를 돌려주는 편이 안전합니다. 호출자가 스트림을 닫는 것을 잊으면 자원이 계속 열려 있기 때문입니다. 반대로 결과가 매우 크다면 모두 List로 만드는 데 드는 메모리를 따져 보고, 나눠서 처리하는 방법을 함께 검토해야 합니다.
정리
- 최종 연산은 파이프라인을 실행하고 결과를 돌려줍니다. 한 번 최종 연산을 실행한 스트림은 다시 쓸 수 없습니다.
- 결과가 없을 수 있는 연산(
findFirst,min,max, 인수 하나짜리reduce, 기본형 스트림의average)은Optional계열을 돌려줍니다. 확인 없이get()을 호출하지 말고, 상황에 맞게orElse,orElseGet,orElseThrow를 고르거나Optional을 그대로 돌려줍니다. anyMatch,allMatch,noneMatch는 답이 정해지면 멈춥니다. 빈 스트림에서allMatch와noneMatch는true이므로, '하나 이상'이 필요하면 따로 검사합니다.reduce의 항등값은 누산 함수에 넣어도 상대 값을 바꾸지 않는 값이어야 하고, 누산 함수는 결합 법칙을 지켜야 합니다. 마땅한 항등값이 없으면Optional을 돌려주는 형태를 씁니다.- 숫자는
IntStream같은 기본형 스트림으로 박싱 없이 계산합니다. 빈IntSummaryStatistics의 최솟값과 최댓값은 실제 값이 아니므로getCount()를 먼저 확인합니다.
연습 문제
- 빈
List<Integer>에서 양수 조건(v -> v > 0)으로anyMatch,allMatch,noneMatch를 호출한 결과와findFirst()의 결과를 쓰세요. 세boolean가운데true가 있다면 양수가 실제로 존재했다는 뜻일까요? - 점수 목록
55, 60, 78, 92, 40에서 60점 이상만 골라 개수, 평균, 최댓값을record Summary(long count, double average, int maximum)하나에 담아 출력하세요. 통과한 점수가 없으면 평균과 최댓값은 0으로 정합니다. 스트림은 한 번만 실행하세요. - 2번 풀이에
[60, 60, 59],[59], 빈 목록을 각각 넣으면 내부 통계 객체의 개수·합계·최댓값과 돌려주는Summary는 어떻게 되나요? 개수를 확인하지 않고 통계 객체의 최댓값을 그대로 돌려주면 어떤 숫자가 새어 나가나요? - 다음 가운데
reduce의 누산 함수로 쓸 수 있는 것을 고르고, 쓸 수 있다면 항등값으로 무엇을 쓸 수 있는지 답하세요. (1)Integer::sum(2)Integer::max(3)(a, b) -> a - b(4)String::concat
해설 보기
1번. anyMatch는 false, allMatch와 noneMatch는 true, findFirst()는 Optional.empty입니다. 세 연산 모두 조건식을 한 번도 호출하지 않습니다. 원소가 하나도 없으니 '모든 원소가 양수다'와 '양수인 원소가 하나도 없다'는 주장을 반박할 원소도 없어서 둘 다 참이 됩니다. 따라서 allMatch가 true라는 것만으로는 양수가 존재했다고 말할 수 없습니다. 실제로 값이 있는지는 anyMatch나 findFirst()의 결과, 또는 원소 개수로 판단해야 합니다.
2번. IntStream의 summaryStatistics() 한 번으로 개수, 평균, 최댓값을 모두 구한 뒤, 개수가 0이면 정해 둔 기본값으로 Summary를 만듭니다.
import java.util.IntSummaryStatistics;
import java.util.List;
public final class PassingScoreSummarySolution {
private record Summary(long count, double average, int maximum) {}
public static void main(String[] args) {
IntSummaryStatistics stats =
List.of(55, 60, 78, 92, 40).stream()
.mapToInt(Integer::intValue)
.filter(score -> score >= 60)
.summaryStatistics();
Summary summary =
stats.getCount() == 0
? new Summary(0, 0.0, 0)
: new Summary(stats.getCount(), stats.getAverage(), stats.getMax());
System.out.println(summary);
}
}Summary[count=3, average=76.66666666666667, maximum=92]통과 점수가 없을 때 최댓값 경계 상수를 외부 결과로 내보내지 않는다
55·60·78·92·40에서 통과한 값 60·78·92를 누산해 count 3/sum 230/max 92를 얻고 빈 경우에는 별도 정책값을 반환합니다. 선택·통계·최종화의 값 전환을 보여 줄 수 있습니다.
PassingScoreSummarySolution의 통계 누산 결과와 반환 Summary를 구분합니다.
내부 통계와 반환 정책
통과 점수가 없을 때 최대값 경계 상수를 외부 결과로 내보내지 않는다
count가 0이면 빈 통계의 max 경계값을 내보내지 않습니다. 반환 Summary의 평균과 최대값은 0으로 정합니다.
IntSummaryStatistics
- 60, 78, 92
- count 3 · sum 230 · max 92
- 통과 점수 없음
- count 0 · sum 0 · max Integer.MIN_VALUE
반환 Summary
- 60, 78, 92
- count 3 · average 약 76.67 · maximum 92
- 통과 점수 없음
- count 0 · average 0.0 · maximum 0
첫 행은 main의 고정 입력을 계산한 값이고, 둘째 행은 빈 통과 집합에 대한 코드 분기입니다. 빈 입력을 따로 실행한 기록은 아닙니다.
이 예의 조건
- 약 76.67은 표시할 때 반올림한 값이며 double 값을 소수 두 자리로 저장한다는 뜻이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 선별된 60,78,92를 누산하면 count 3, sum 230, max 92이고 count 0 조건이 아니어서 외부 Summary 3,230/3,92로 간다. 다른 입력에서 통과한 값이 0개면 같은 count 분기의 예 경로가 외부 0,0.0,0으로 끝난다. 원 입력이 동시에 두 결과가 되는 것이 아니라 통계의 형태와 외부 부재 정책을 6개 노드와 5개 간선으로 나타낸다. 약 76.67은 표시를 반올림한 값이며 실제 double이 두 자리로 저장된 것이 아니다. 모바일도 동일한 분기이다. |
| 14 | 3 · 약 76.67 · 92 |
| 구분 | 내용 |
|---|---|
| 설명 | 선별된 60,78,92를 누산하면 count 3, sum 230, max 92이고 count 0 조건이 아니어서 외부 Summary 3,230/3,92로 간다. 다른 입력에서 통과한 값이 0개면 같은 count 분기의 예 경로가 외부 0,0.0,0으로 끝난다. 원 입력이 동시에 두 결과가 되는 것이 아니라 통계의 형태와 외부 부재 정책을 6개 노드와 5개 간선으로 나타낸다. 약 76.67은 표시를 반올림한 값이며 실제 double이 두 자리로 저장된 것이 아니다. 모바일도 동일한 분기이다. |
| 전체 읽기 정보 | 통과 점수가 없을 때 최댓값 경계 상수를 외부 결과로 내보내지 않는다 통과 점수가 없을 때 최댓값 경계 상수를 외부 결과로 내보내지 않는다 PassingScoreSummarySolution의 통계 누산 결과와 반환 Summary를 구분합니다. 내부 통계와 반환 정책 통과 점수가 없을 때 최댓값 경계 상수를 외부 결과로 내보내지 않는다 count가 0이면 빈 통계의 max 경계값을 내보내지 않습니다. 반환 Summary의 평균과 최댓값은 0으로 정합니다. IntSummaryStatistics 60, 78, 92 count 3 · sum 230 · max 92 통과 점수 없음 count 0 · sum 0 · max Integer.MIN_VALUE 반환 Summary 60, 78, 92 count 3 · average 약 76.67 · maximum 92 통과 점수 없음 count 0 · average 0.0 · maximum 0 첫 행은 main의 고정 입력을 계산한 값이고, 둘째 행은 빈 통과 집합에 대한 코드 분기입니다. 빈 입력을 따로 실행한 기록은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 점수 선별 55,60,78,92,40에서 60 이상을 선택합니다. |
|
|
| 통계 누산 세 통과 값의 개수·합계·최대를 계산합니다. |
|
|
| 외부 정책 count 3은 정상 Summary, 별도 count 0은 빈 정책을 적용합니다. |
|
|
통과 점수가 없을 때 최댓값 경계 상수를 외부 결과로 내보내지 않는다
55·60·78·92·40에서 통과한 값 60·78·92를 누산해 count 3/sum 230/max 92를 얻고 빈 경우에는 별도 정책값을 반환합니다. 선택·통계·최종화의 값 전환을 보여 줄 수 있습니다.
PassingScoreSummarySolution의 통계 누산 결과와 반환 Summary를 구분합니다.
내부 통계와 반환 정책
통과 점수가 없을 때 최대값 경계 상수를 외부 결과로 내보내지 않는다
count가 0이면 빈 통계의 max 경계값을 내보내지 않습니다. 반환 Summary의 평균과 최대값은 0으로 정합니다.
IntSummaryStatistics
- 60, 78, 92
- count 3 · sum 230 · max 92
- 통과 점수 없음
- count 0 · sum 0 · max Integer.MIN_VALUE
반환 Summary
- 60, 78, 92
- count 3 · average 약 76.67 · maximum 92
- 통과 점수 없음
- count 0 · average 0.0 · maximum 0
첫 행은 main의 고정 입력을 계산한 값이고, 둘째 행은 빈 통과 집합에 대한 코드 분기입니다. 빈 입력을 따로 실행한 기록은 아닙니다.
이 예의 조건
- 약 76.67은 표시할 때 반올림한 값이며 double 값을 소수 두 자리로 저장한다는 뜻이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 선별된 60,78,92를 누산하면 count 3, sum 230, max 92이고 count 0 조건이 아니어서 외부 Summary 3,230/3,92로 간다. 다른 입력에서 통과한 값이 0개면 같은 count 분기의 예 경로가 외부 0,0.0,0으로 끝난다. 원 입력이 동시에 두 결과가 되는 것이 아니라 통계의 형태와 외부 부재 정책을 6개 노드와 5개 간선으로 나타낸다. 약 76.67은 표시를 반올림한 값이며 실제 double이 두 자리로 저장된 것이 아니다. 모바일도 동일한 분기이다. |
| 14 | 3 · 약 76.67 · 92 |
| 구분 | 내용 |
|---|---|
| 설명 | 선별된 60,78,92를 누산하면 count 3, sum 230, max 92이고 count 0 조건이 아니어서 외부 Summary 3,230/3,92로 간다. 다른 입력에서 통과한 값이 0개면 같은 count 분기의 예 경로가 외부 0,0.0,0으로 끝난다. 원 입력이 동시에 두 결과가 되는 것이 아니라 통계의 형태와 외부 부재 정책을 6개 노드와 5개 간선으로 나타낸다. 약 76.67은 표시를 반올림한 값이며 실제 double이 두 자리로 저장된 것이 아니다. 모바일도 동일한 분기이다. |
| 전체 읽기 정보 | 통과 점수가 없을 때 최댓값 경계 상수를 외부 결과로 내보내지 않는다 통과 점수가 없을 때 최댓값 경계 상수를 외부 결과로 내보내지 않는다 PassingScoreSummarySolution의 통계 누산 결과와 반환 Summary를 구분합니다. 내부 통계와 반환 정책 통과 점수가 없을 때 최댓값 경계 상수를 외부 결과로 내보내지 않는다 count가 0이면 빈 통계의 max 경계값을 내보내지 않습니다. 반환 Summary의 평균과 최댓값은 0으로 정합니다. IntSummaryStatistics 60, 78, 92 count 3 · sum 230 · max 92 통과 점수 없음 count 0 · sum 0 · max Integer.MIN_VALUE 반환 Summary 60, 78, 92 count 3 · average 약 76.67 · maximum 92 통과 점수 없음 count 0 · average 0.0 · maximum 0 첫 행은 main의 고정 입력을 계산한 값이고, 둘째 행은 빈 통과 집합에 대한 코드 분기입니다. 빈 입력을 따로 실행한 기록은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 점수 선별 55,60,78,92,40에서 60 이상을 선택합니다. |
|
|
| 통계 누산 세 통과 값의 개수·합계·최대를 계산합니다. |
|
|
| 외부 정책 count 3은 정상 Summary, 별도 count 0은 빈 정책을 적용합니다. |
|
|
60점 이상인 점수는 60, 78, 92이므로 개수 3, 합계 230, 최댓값 92입니다. 평균 230/3은 double로 76.66666666666667이고, 화면에 소수 둘째 자리까지 보여 줄 때만 76.67로 반올림합니다. 통계 객체는 내부 계산용이고, 바깥에 돌려주는 Summary에는 '통과자가 없으면 0'이라는 정책을 따로 적용했다는 점이 핵심입니다. 평균과 최댓값을 따로 구하려고 스트림을 두 번 실행하지 않아도 되는 것도 summaryStatistics()의 장점입니다.
3번. [60, 60, 59]는 통과 점수가 60, 60이므로 내부 통계는 개수 2, 합계 120, 최댓값 60이고 Summary[count=2, average=60.0, maximum=60]을 돌려줍니다. [59]와 빈 목록은 통과 점수가 없어서 내부 통계가 개수 0, 합계 0, 최댓값 Integer.MIN_VALUE가 되고, 개수 검사에 걸려 둘 다 Summary[count=0, average=0.0, maximum=0]을 돌려줍니다. 개수를 확인하지 않고 stats.getMax()를 그대로 돌려주면 최댓값 자리에 -2147483648이 나갑니다. 실제 점수도 아니고 문제에서 정한 0도 아닌 값이 결과에 섞이는 것입니다.
4번. (1) Integer::sum은 결합 법칙을 만족하고 항등값은 0입니다. (2) Integer::max도 결합 법칙을 만족합니다. 항등값으로는 Integer.MIN_VALUE가 조건을 만족하지만, 빈 스트림에서 그 값이 결과로 나오므로 보통은 항등값 없는 reduce(Integer::max)나 max()로 Optional을 받는 편이 낫습니다. (3) 뺄셈은 (1 - 2) - 3과 1 - (2 - 3)이 다르므로 결합 법칙을 만족하지 않아 쓸 수 없습니다. 또 0 - t가 t가 아니므로 0은 항등값도 아닙니다. (4) String::concat은 ("a" + "b") + "c"와 "a" + ("b" + "c")가 같으므로 결합 법칙을 만족하고, 항등값은 빈 문자열 ""입니다. 실제로 Stream.of("a", "b", "c").reduce("", String::concat)은 abc를 돌려줍니다. 다만 원소가 많을 때는 문자열을 매번 새로 만드는 비용이 커지므로 Collectors.joining()이 더 알맞습니다.
다음 글
Collectors로 List·Map 만들고 그룹으로 묶기에서는 최종 연산 collect와 수집기(Collector)를 다룹니다. 원소를 Map으로 모을 때 키가 겹치면 어떻게 되는지, groupingBy와 partitioningBy로 원소를 그룹으로 나누려면 어떻게 하는지를 실행 결과와 함께 확인합니다.