빌드노트
count()로 개수를 찍은 Stream은 collect()에 다시 쓸 수 없다
로그용 count()도 Stream을 끝낸다. 같은 Stream의 collect()에서 난 예외를 확인하고, 한 번 수집한 목록의 size()로 개수를 기록하는 수정과 원본에서 새 Stream을 만들 조건을 설명한다.
Java에서 필터 결과의 개수를 로그로 찍으려고 count()를 호출한 뒤, 같은 Stream에 collect()를 호출하면 IllegalStateException이 날 수 있다. 로그용으로 호출해도 count()는 종료 연산이다. 개수를 구한 시점에 그 Stream의 처리는 끝났고, 다음 collect()에서 재사용이 드러난다.
결과 목록과 개수가 함께 필요하면 한 번만 수집하고 반환된 목록의 size()를 찍는다. 로그를 위해 원본을 다시 처리할 필요가 없다.
예외가 난 collect()보다 앞선 count()를 찾는다
다음은 Ada, Bob, Amy 중 A로 시작하는 이름을 고르는 코드다. JDK 17 이상에서 비교할 수 있으며 외부 라이브러리는 필요 없다. java.util.List와 java.util.stream.Collectors를 import한 Java 메서드 안에서 사용한다. 아래 실패 코드와 뒤의 수정 코드는 각각 따로 실행한다.
var names = List.of("Ada", "Bob", "Amy");
var selected = names.stream().filter(s -> s.startsWith("A"));
System.out.println("count=" + selected.count());
System.out.println("names=" + selected.collect(Collectors.toList()));
Windows와 OpenJDK 17.0.2에서 별도로 실행한 결과다. 실제 업무 장애 기록은 아니다.
count=2
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
스택 트레이스의 나머지는 생략했다. 개수는 출력됐지만 names=는 나오지 않았고, 예외 위치는 collect() 호출이었다. 이 코드에는 close()가 없다. 메시지의 closed만 보고 닫는 코드를 찾기보다, 같은 Stream에 앞서 실행한 종료 연산을 확인할 상황이다.
filter()는 조건을 이어 붙이는 중간 연산이고 count()와 collect()는 처리를 끝내는 종료 연산이다. Java 17 Stream 문서 (새 탭에서 열림)는 같은 Stream의 재사용을 허용하지 않는다. 구현이 재사용을 모두 감지할 수 있는 것은 아니므로, 예외가 없었던 코드도 재사용이 안전하다는 근거가 되지는 않는다.
목록을 만든 뒤 그 목록의 개수를 찍는다
앞의 로그용 count()를 없애고 다음처럼 바꾼다.
var names = List.of("Ada", "Bob", "Amy");
var result = names.stream().filter(s -> s.startsWith("A"))
.collect(Collectors.toList());
System.out.println("count=" + result.size());
System.out.println("names=" + result);
같은 입력으로 실행하니 예외 없이 count=2와 names=[Ada, Amy]가 출력됐다. 종료 연산은 collect() 한 번이다. 그 뒤의 size()는 Stream이 아니라 수집한 목록의 메서드이므로, 실제 반환할 목록의 개수를 읽는다.
이때 원본 names.size()로 바꾸면 의미가 달라진다. 원본에는 세 이름이 있고 필터를 통과한 결과에는 두 이름이 있다. 로그로 확인하려던 것이 필터 결과의 개수라면 result.size()를 사용해야 한다.
다시 처리해야 한다면 원본에서 시작한다
별도로 개수를 세고 원본을 다시 처리해야 할 이유가 있다면, 두 번째 처리는 names.stream()에서 새로 시작할 수 있다. Collection.stream() 문서 (새 탭에서 열림)는 컬렉션을 원본으로 하는 순차 Stream을 생성한다고 설명한다. 같은 목록에서 두 Stream을 각각 만들어 실행한 비교에서도 개수 2와 목록 [Ada, Amy]를 얻었다. 첫 Stream을 소비했다고 원본 목록이 지워진 것은 아니다.
다만 이 방식은 필터 처리를 반복한다. 두 처리 사이에 원본이 바뀌거나 조건이 외부 상태에 의존하면 같은 결과라고 가정할 수 없다. 새 변수에 기존 Stream을 대입하거나, 함수가 보관해 둔 Stream을 다시 반환하는 것도 새 생성이 아니다. 수정 뒤에도 같은 예외가 나면 두 번째 종료 연산에서 거슬러 올라가 Stream을 실제로 만드는 위치를 확인한다.
개수보다 지나가는 원소를 잠깐 보고 싶다면, 별도 count() 대신 최종 collect()로 이어지는 파이프라인의 filter() 뒤에 peek()로 로그를 넣을 수 있다. 다만 peek() 설명 (새 탭에서 열림)과 count() 설명 (새 탭에서 열림)에 따르면 원소 방문이 생략되는 경우 로그도 생략될 수 있다. 반드시 수행해야 할 저장·발송을 peek()에 맡기지 않는다.
실행으로 확인한 범위는 세 문자열을 사용하는 OpenJDK 17.0.2의 순차 Stream이다. 재사용 규칙은 Java 21 문서 (새 탭에서 열림)에서도 확인했지만 Java 21 실행, 병렬 처리, 파일·DB Stream, 큰 입력의 성능은 검증하지 않았다.