🔄 자바 Stream API 가이드 - 생성, 연산, 장단점 정리
Stream API란?
Java 8에서 도입된 Stream API는 데이터 처리(필터링, 변환, 집계 등)를 함수형 스타일로 작성할 수 있도록 돕는 도구이다. 기존의 for 루프 기반 처리보다 간결하고 가독성 높은 코드를 작성할 수 있게 도와준다.
*Stream 동작 방식
Stream은 크게 중간 연산과 최종 연산으로 나뉜다.
중간 연산 (Intermediate Operation)
데이터를 변환, 필터링, 정렬하는 과정을 담당하며 Stream 파이프라인의 중간 단계에서 실행된다. 이 과정에서 지연 연산이 일어난다.
# 지연 연산 - 최종 연산이 호출되기 전까지 실제 처리가 이루어지지 않고 최종 연산 시점에 한 번에 실행되는 동작 방식
대표적인 중간 연산 메서드는 다음과 같다.
filter(Predicate) // 조건에 맞는 요소만 필터링한다.
map(Function) // 요소를 다른 형태로 변환한다.
flatMap(Function) // 중첩된 구조를 평탄화 한다.
sorted() / sorted(Comparator) // 요소를 정렬한다.
distinct() // 중복을 제거한다.
limit(long) // 앞에서부터 n개만 선택한다.
skip(long) // 앞에서부터 n개 건너뛴다.
peek(Consumer) // 요소를 소비하지 않고 중간에 확인한다.
최종 연산 (Terminal Operation)
중간 연산에서 정의한 처리 규칙을 실제로 실행하여 결과를 생성하는 단계로 최종 연산이 호출되는 순간 앞서 파이프라인에 쌓여 있던 중간 연산들이 한 번에 실행되고 스트림이 닫힌다. 최종 연산이 수행된 이후에는 해당 스트림을 다시 사용할 수 없다.
대표적인 최종 연산 메서드는 다음과 같다.
forEach(Consumer) // 각 요소에 대해 작업을 수행한다.
collect(Collector) // 결과를 컬렉션이나 다른 형식으로 변환한다.
reduce(BinaryOperator) // 요소를 하나의 값으로 집계한다.
count() // 요소의 개수를 반환한다.
anyMatch(Predicate) // 조건을 만족하는 요소가 하나라도 있는지 검사한다.
allMatch(Predicate) // 모든 요소가 조건을 만족하는지 검사한다.
noneMatch(Predicate) // 모든 요소가 조건을 만족하지 않는지 검사한다.
findFirst() // 첫 번째 요소를 반환한다.
findAny() // 요소 중 하나를 반환한다. (병렬 처리 시 주로 사용)
min(Comparator) / max(Comparator) // 최소값 / 최대값을 반환한다.
*Stream 생성 방법
Java에서는 다양한 방법으로 Stream을 생성할 수 있다.
// 1. 컬렉션에서 생성
List<String> list = List.of("Blue", "Cool", "Good");
Stream<String> stream1 = list.stream();
// 2. 배열에서 생성
String[] arr = {"This", "is", "Array"};
Stream<String> stream2 = Arrays.stream(arr);
// 3. 숫자 범위에서 생성
IntStream intStream = IntStream.range(1, 5);
LongStream longStream = LongStream.rangeClosed(1, 5);
// 4. 직접 생성
Stream<Double> randoms = Stream.generate(Math::random).limit(5);
// 5. 빌더 사용
Stream<String> buildStream = Stream.<String>builder()
.add("This")
.add("is")
.add("Builder")
.build();
스트림 생성부터 연산까지의 흐름은 다음과 같다.
List<String> names = List.of("BlueCool", "PYO", "MIN", "BlueMin");
// 길이가 4보다 큰 이름만 필터링 -> 대문자로 변환 -> 알파벳순 정렬 -> 출력
names.stream()
.filter(name -> name.length() > 4) // 중간 연산: 조건 필터링 (Predicate)
.map(String::toUpperCase) // 중간 연산: 문자열 대문자 변환 (Function)
.sorted() // 중간 연산: 알파벳순 정렬
.forEach(System.out::println); // 최종 연산: 요소 출력 (Consumer)
Stream API의 가장 큰 장점은 간결하고 가독성 높은 코드를 작성할 수 있다는 점이다. 기존의 반복문 기반 코드에 비해 처리 과정을 짧고 명확하게 표현할 수 있으며 filter(), map(), sorted()와 같은 연산을 체이닝하여 “무엇을 할지”에 집중하는 선언형 프로그래밍 스타일이 가능해진다.
Stream은 아주 편리하지만 모든 상황에서 항상 최선의 선택은 아니다. 우선 Stream은 1회성이므로 한 번 최종 연산을 수행하면 재사용이 불가하며 데이터 크기가 작거나 단순한 연산에서는 기존의 반복문보다 오히려 성능이 떨어질 수 있다.
특히 기본형이 아닌 경우에는 박싱(Boxing)과 언박싱(Unboxing) 과정에서 불필요한 오버헤드가 발생할 수 있고 연산이 체이닝되면 중간 과정의 디버깅이 어렵기 때문에 필요한 경우 peek() 등을 활용하여 중간 값을 확인하는 것이 중요하다.