반복문 대신 파이프라인
스트림은 값의 시퀀스를 처리하는 파이프라인입니다. 소스(보통 컬렉션)에서 시작해 "이것만 남겨라", "각각을 변환하라", "정렬하라" 같은 연산을 이어 붙이고, 마지막에 결과를 모아 마무리합니다. 임시 리스트와 그 안의 if가 있는 반복문을 작성하는 대신, 무엇을 원하는지 기술하고 순회는 스트림에 맡깁니다.
이제 람다를 익혔으니 스트림이 자연스럽게 이해됩니다. 모든 연산은 요소 하나에 대한 작업을 기술하는 람다(또는 메서드 참조)를 받습니다.
위에서 아래로 읽으세요: 이름을 가져와 긴 것만 남기고 대문자로 바꿔 리스트에 모은다. 원본 names 리스트는 절대 변경되지 않습니다 - 스트림은 새 결과를 만들고 소스는 그대로 둡니다.
스트림의 구조
모든 파이프라인에는 세 부분이 있습니다.
- 소스 -
list.stream(),Arrays.stream(array), 또는Stream.of(a, b, c). - 0개 이상의 중간 연산 -
filter,map,sorted,distinct,limit. 각각이 또 다른 스트림을 반환하므로 체인으로 이을 수 있습니다. - 정확히 하나의 종단 연산 -
collect,count,forEach,reduce,findFirst. 이것이 작업을 시작시키고 결과(또는 부수 효과)를 만들어 냅니다.
이 구분은 처음엔 누구나 놀라는 한 가지 규칙 때문에 중요합니다.
중간 연산은 지연 평가된다
중간 연산은 그 자체로는 아무것도 하지 않습니다. 무슨 일이 일어나야 하는지 기록만 합니다. 파이프라인은 종단 연산이 결과를 요청할 때 비로소 실행됩니다.
실행해 보세요: map 람다는 한 번도 실행되지 않습니다. 종단 연산을 추가하면 체인 전체가 살아납니다.
이 지연 평가는 맞서 싸울 별난 특성이 아니라, 스트림이 연산을 합치고 불필요한 작업을 건너뛰게 해 줍니다. 다만 종단 연산이 없는 파이프라인은 아무 일도 하지 않는다는 뜻이며, 이는 "왜 아무 일도 안 일어나지?"라는 흔한 버그입니다.
filter와 map: 남기기와 변환하기
이 둘이 대부분의 일을 떠맡습니다. filter는 Predicate(boolean을 반환하는 람다)를 받아 통과한 요소를 남깁니다. map은 Function을 받아 각 요소를 그것을 적용한 결과로 바꿉니다.
map은 요소의 타입을 바꿀 수 있다는 점에 주목하세요. 여기서는 String의 스트림이 Integer의 스트림이 됩니다. String::length는 메서드 참조로, 람다 w -> w.length()의 축약형입니다.
종단 연산은 결과를 만들어 낸다
스트림의 형태를 다 잡았다면, 종단 연산이 그것을 구체적인 무언가로 바꿉니다.
흔한 종단 연산: collect(리스트/세트/맵으로 모으기), count, forEach, anyMatch / allMatch / noneMatch, findFirst, min / max, 그리고 reduce. 종단 연산이 실행되고 나면 스트림은 소비된 상태가 되어 재사용할 수 없습니다. 새 파이프라인이 필요하면 list.stream()을 다시 호출하세요.
결과 모으기
Collectors와 함께 쓰는 collect는 결과를 만드는 주력 도구입니다. 가장 흔한 것은 Collectors.toList()입니다.
Collectors는 toSet(), joining(...), groupingBy(...), counting()도 제공합니다. 자바 16 이상에서는 collect(Collectors.toList())를 더 짧은 .toList()로 바꿀 수 있습니다(수정 불가능한 리스트를 반환합니다).
List<String> result = names.stream().map(String::toUpperCase).toList();
sorted, distinct, limit
이 중간 연산들은 결과를 모으기 전에 스트림의 형태를 다시 잡습니다.
인자가 없는 sorted()는 자연 순서를 사용합니다. 사용자 정의 순서가 필요하면 Comparator(여기서는 람다)를 넘기세요. 내림차순으로 정렬하려면 Comparator.reverseOrder()가 더 깔끔합니다.
reduce: 스트림을 하나의 값으로 접기
모든 요소를 하나의 결과로 합쳐야 할 때 - 합계, 곱, 가장 긴 문자열 - reduce가 범용 도구입니다. 시작값과 두 값을 합치는 함수를 넘겨줍니다.
단순한 합계와 평균에는 특화된 스트림이 더 명확합니다: nums.stream().mapToInt(Integer::intValue).sum(). 이미 만들어진 집계 수단이 없을 때 reduce에 손을 뻗으세요.
스트림이 모든 반복문을 대체하지는 않는다
스트림은 컬렉션을 결과로 변환할 때 빛을 발합니다. 반복문보다 자동으로 빠른 것은 아니며, 외부 상태를 변경하거나 까다로운 방식으로 일찍 빠져나와야 할 때는 어색해집니다. 좋은 기준: 파이프라인이 하나의 명확한 문장으로 읽히면 스트림을 쓰고, 공유 카운터나 index에 손을 뻗고 있다면 평범한 for 반복문이 정직하고 충분합니다.
또한 스트림은 일회용이라는 점을 기억하세요. 다음은 예외를 던집니다.
Stream<String> s = names.stream();
s.forEach(System.out::println);
s.count(); // IllegalStateException: stream has already been operated upon
다음: Optional
findFirst, min, max, 항등값 없는 reduce 같은 몇몇 스트림 종단 연산은 아무것도 찾지 못할 수 있어서 맨값을 그대로 반환하지 않습니다. 대신 Optional을 반환합니다. "값이 있을 수도, 없을 수도"를 담는 자바의 컨테이너로, 마침내 null을 반환하는 것에 대한 깔끔한 대안을 제공합니다. 그것이 다음 페이지입니다.
자주 묻는 질문
자바에서 스트림이란 무엇인가요?
스트림은 일련의 요소(보통 컬렉션에서 가져온)를 filter, map, sorted 같은 연산 체인을 거쳐 처리하고 collect나 count 같은 종단(terminal) 연산으로 마무리하는 파이프라인입니다. 데이터를 저장하지 않고 원본도 변경하지 않으며, 계산을 기술할 뿐입니다. list.stream()으로 하나를 만들고, 체인은 레시피처럼 위에서 아래로 읽습니다.
자바에서 스트림을 다시 리스트로 변환하려면 어떻게 하나요?
파이프라인을 종단 컬렉터로 마무리하세요: list.stream().filter(...).collect(Collectors.toList()). 자바 16+에서는 더 짧은 .toList()를 쓸 수 있는데, 수정 불가능한 리스트를 반환합니다. 종단 연산이 없으면 아무것도 실행되지 않습니다. filter나 map 같은 중간 연산은 지연 평가되기 때문입니다.
for 반복문 대신 스트림을 써야 할 때는 언제인가요?
컬렉션을 변환하거나 걸러서 결과를 만들 때 스트림을 선택하세요 - 파이프라인이 하나의 명확한 문장처럼 읽힙니다("이름을 가져와 긴 것만 남기고 대문자로 바꿔 리스트에 모은다"). 외부 상태를 변경해야 하거나, 복잡한 방식으로 일찍 빠져나와야 하거나, 명령형 단계로 쓰는 편이 더 단순할 때는 평범한 for 반복문을 유지하세요. 스트림은 순수한 속도가 아니라 명료함을 위한 것입니다.