스트림 API 기본 개념
스트림 API란?
스트림은 자바 8부터 추가된 기능으로 데이터의 흐름을 추상화해서 다루는 도구이다.
컬렉션 또는 배열 등의 요소들을 연산 파이프라인을 통해 연속적인 형태로 처리할 수 있게 해준다.
연산 파이프라인이란 여러 연산(중간 연산, 최종 연산)을 체이닝해서 데이터를 변환, 필터링, 계산하는 구조를 말한다.
자바가 제공하는 스트림 예제
import java.util.List;
import java.util.stream.Stream;
public class StreamStartMain {
public static void main(String[] args) {
List<String> names = List.of("Apple", "Banana", "Berry", "Tomato");
//"B"로 시작하는 이름만 필터 후 대문자로 바꿔서 리스트 수집
Stream<String> stream = names.stream(); //스트림 생성
List<String> result = stream.filter(name -> name.startsWith("B")) //중간 연산
.map(s -> s.toUpperCase()) //중간 연산
.toList(); //최종 연산
//외부 반복
for (String s : result) {
System.out.println(s);
}
//forEach 내부 반복
names.stream()
.filter(name -> name.startsWith("B"))
.map(s -> s.toUpperCase())
.forEach(x -> System.out.println(x));
//메서드 참조
names.stream()
.filter(name -> name.startsWith("B"))
.map(String::toUpperCase) //임의 객체의 인스턴스 메서드 참조(매개변수 참조)
.forEach(System.out::println); //특정 객체의 인스턴스 메서드 참조
}
}중간 연산은 데이터를 걸러내거나 형태를 변환하며, 최종 연산을 통해 최종 결과를 모으거나 실행할 수 있다.
스트림의 내부 반복을 어떻게 반복할지(for, while 루프 등) 직접 신경 쓰기보다는, 결과가 어떻게 변환되어야 하는지에만 집중할 수 있다. 이러한 특징을 선언형 프로그래밍 스타일이라 한다.
메서드 참조를 사용하여 람다식을 더 간결하게 표현하고 가독성을 높일 수 있다.
스트림에서 제공하는 다양한 중간 연산과 최종 연산을 통해 복잡한 데이터 처리 로직도 간단하고 선언적으로 구현할 수 있다.
스트림 API 특징
데이터 소스를 변경하지 않음
스트림에서 제공하는 연산들은 원본 컬렉션을 변경하지 않고 결과만 새로 생성한다.
일회성
한 번 사용된(소비) 스트림은 다시 사용할 수 없다. 필요하다면 새로 스트림을 생성해야 한다.
파이프라인 구성
중간 연산들이 이어지다가 최종 연산을 만나면 연산이 수행되고 종료된다.
지연 연산
중간 연산은 필요할 때까지 실제로 동작하지 않고 최종 연산이 실행될 때 한 번에 처리된다.
병렬 처리 용이
스트림으로부터 병렬 스트림을 쉽게 만들 수 있어 멀티코어 환경에서 병렬 연산을 비교적 단순한 코드로 작성할 수 있다.
1. 데이터 소스를 변경하지 않음 (Immutable)
import java.util.List;
public class ImmutableMain {
public static void main(String[] args) {
List<Integer> origin = List.of(1, 2, 3, 4, 5);
System.out.println("origin = " + origin);
List<Integer> filtered = origin.stream()
.filter(n -> n % 2 == 0)
.toList();
System.out.println("filtered = " + filtered);
System.out.println("origin = " + origin);
}
}origin = [1, 2, 3, 4, 5]
filtered = [2, 4]
origin = [1, 2, 3, 4, 5]2. 일회성
import java.util.List;
import java.util.stream.Stream;
public class DuplicateExecutionMain {
public static void main(String[] args) {
Stream<Integer> stream = Stream.of(1, 2, 3, 4);
stream.forEach(System.out::println); //1.최초 실행
stream.forEach(System.out::println); //2. 스트림 중복 실행, 오류 발생
//대안 : 대상 리스트를 스트림으로 새로 생성해서 사용
List<Integer> list = List.of(1, 2, 3, 4);
Stream.of(list).forEach(System.out::println);
Stream.of(list).forEach(System.out::println);
}
}1
2
3
4
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
at java.base/java.util.stream.AbstractPipeline.sourceStageSpliterator(AbstractPipeline.java:311)
at java.base/java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:807)
at stream.basic.DuplicateExecutionMain.main(DuplicateExecutionMain.java:11)스트림을 중복 실행하면
stream has already been operated upon or closed이라는 메시지와 함께 예외가 발생한다.하나의 리스트로 여러번 스트림을 통해 실행해야 한다면 스트림이 필요할 때마다 스트림을 새로 생성해서 사용해야 한다.
3. 파이프라인 구성
직접 만든 스트림과 자바가 제공하는 스트림이 어떻게 다른지 알아보자.
import java.util.List;
public class LazyEvalMain1 {
public static void main(String[] args) {
List<Integer> data = List.of(1, 2, 3, 4, 5, 6);
//짝수만을 골라 10을 곱해라
ex1(data);
ex2(data);
}
//직접 만든 스트림
private static void ex1(List<Integer> data) {
System.out.println("=== MyStreamV3 ===");
List<Integer> result = MyStreamV3.of(data)
.filter(n -> {
boolean isEven = n % 2 == 0;
System.out.println("filter() 실행: " + n + "(" + isEven + ")");
return isEven;
})
.map(n -> {
int mapped = n * 10;
System.out.println("map() 실행: " + n + " -> " + mapped);
return mapped;
})
.toList();
System.out.println("result = " + result);
}
//자바 스트림
private static void ex2(List<Integer> data) {
System.out.println("=== Java Stream ===");
List<Integer> result = data.stream()
.filter(n -> {
boolean isEven = n % 2 == 0;
System.out.println("filter() 실행: " + n + "(" + isEven + ")");
return isEven;
})
.map(n -> {
int mapped = n * 10;
System.out.println("map() 실행: " + n + " -> " + mapped);
return mapped;
})
.toList();
System.out.println("result = " + result);
}
}=== MyStreamV3 ===
filter() 실행: 1(false)
filter() 실행: 2(true)
filter() 실행: 3(false)
filter() 실행: 4(true)
filter() 실행: 5(false)
filter() 실행: 6(true)
map() 실행: 2 -> 20
map() 실행: 4 -> 40
map() 실행: 6 -> 60
result = [20, 40, 60]=== Java Stream ===
filter() 실행: 1(false)
filter() 실행: 2(true)
map() 실행: 2 -> 20
filter() 실행: 3(false)
filter() 실행: 4(true)
map() 실행: 4 -> 40
filter() 실행: 5(false)
filter() 실행: 6(true)
map() 실행: 6 -> 60
result = [20, 40, 60]직접 만든 스트림은 일괄 처리 방식이고, 자바의 스트림은 파이프라인 방식이다.
일괄 처리 (Batch Processing)
각 단계마다 결과물을 모아두고, 전체가 끝난 뒤에야 다음 단계로 넘어간다.
filter()를 모든 데이터에 대해 적용(일괄 처리)하고,그 결과를 한꺼번에 모아서 그 다음에
map()을 일괄 처리한다.
파이프라인 처리 (Pipeline Processing)
각 단계가 끝난 제품을 즉시 다음 단계로 넘기면서 단계들이 연결(체이닝) 되어 있는 형태이다.
filter()단계를 통과하면 해당 요소는 곧바로map()단계로 이어지고최종 결과를 가져야 하는 시점(최종 연산)이 되어서야 모든 단계가 완료된다.
즉 자바 스트림은 중간 단계에서 데이터를 모아서 한번에 처리하지 않고, 한 요소가 중간 연산을 통과하면 곧바로 다음 중간 연산으로 이어지는 파이프라인 형태를 가진다.
4. 지연 연산
자바 스트림은 toList()와 같은 최종 연산을 수행할 때만 작동한다.
import java.util.List;
public class LazyEvalMain2 {
public static void main(String[] args) {
List<Integer> data = List.of(1, 2, 3, 4, 5, 6);
//짝수만을 골라 10을 곱해라
ex1(data);
ex2(data);
}
//직접 만든 스트림
private static void ex1(List<Integer> data) {
System.out.println("=== MyStreamV3 ===");
MyStreamV3.of(data)
.filter(n -> {
boolean isEven = n % 2 == 0;
System.out.println("filter() 실행: " + n + "(" + isEven + ")");
return isEven;
})
.map(n -> {
int mapped = n * 10;
System.out.println("map() 실행: " + n + " -> " + mapped);
return mapped;
});
//toList()를 호출하지 않음
}
//자바 스트림
private static void ex2(List<Integer> data) {
System.out.println("=== Java Stream ===");
data.stream()
.filter(n -> {
boolean isEven = n % 2 == 0;
System.out.println("filter() 실행: " + n + "(" + isEven + ")");
return isEven;
})
.map(n -> {
int mapped = n * 10;
System.out.println("map() 실행: " + n + " -> " + mapped);
return mapped;
});
//toList()를 호출하지 않음
}
}=== MyStreamV3 ===
filter() 실행: 1(false)
filter() 실행: 2(true)
filter() 실행: 3(false)
filter() 실행: 4(true)
filter() 실행: 5(false)
filter() 실행: 6(true)
map() 실행: 2 -> 20
map() 실행: 4 -> 40
map() 실행: 6 -> 60=== Java Stream ===직접 만든 스트림은 최종 연산을 호출하지 않았는데도
filter()와map()이 바로바로 실행된다.반면 자바 스트림은 최종 연산이 호출되지 않으면 아무 일도 하지 않는 것을 확인할 수 있다.
중간 연산의 작업들은 파이프라인 설정을 해놓기만 하고, 정작 실제 연산은 최종 연산이 호출되기 전까지 전혀 진행되지 않는다.
즉 스트림은
filter,map과 같은 중간 연산을 호출할 때 전달한 람다를 내부에 저장만 해두고 실행하지는 않는다. 이후에 최종 연산 이 호출되면 그때 각각의 항목을 꺼내서 저장해둔 람다를 실행한다.
즉시 연산과 지연 연산
즉시 연산
직접 만든 스트림은 즉시(Eager) 연산을 사용하고 있다. 중간 연산이 호출될 때마다 바로 연산을 수행한다.
그 결과 최종 연산이 없어도
filter,map등이 즉시 동작해버려 필요 이상의 연산이 수행되곧 한다.
지연 연산
지연(Lazy) 연산은 꼭 필요할 때만 연산을 수행하도록 연산을 최대한 미룬다.
그래서 연산을 반드시 수행해야 하는 최종 연산을 만나야 가지고 있던 중간 연산들을 수행한다.
👆 지연 연산과 최적화
자바의 스트림은 지연 연산, 파이프라인 등 복잡하게 설계되어 있는데 이를 통해 어떤 최적화를 할 수 있는지 알아보자.
직접 만든 스트림 기능 추가
public class MyStreamV3<T> {
private final List<T> internalList;
//...
//추가
public T getFirst() {
return internalList.getFirst();
}
}import java.util.List;
public class LazyEvalMain3 {
public static void main(String[] args) {
List<Integer> data = List.of(1, 2, 3, 4, 5, 6);
//짝수를 찾아 10을 곱해라
//계산한 짝수 중에서 첫 번째 항목 하나만 찾아라
ex1(data);
ex2(data);
}
//직접 만든 스트림
private static void ex1(List<Integer> data) {
System.out.println("=== MyStreamV3 ===");
Integer result = MyStreamV3.of(data)
.filter(n -> {
boolean isEven = n % 2 == 0;
System.out.println("filter() 실행: " + n + "(" + isEven + ")");
return isEven;
})
.map(n -> {
int mapped = n * 10;
System.out.println("map() 실행: " + n + " -> " + mapped);
return mapped;
})
.getFirst();
System.out.println("result = " + result);
}
//자바 스트림
private static void ex2(List<Integer> data) {
System.out.println("=== Java Stream ===");
Integer result = data.stream()
.filter(n -> {
boolean isEven = n % 2 == 0;
System.out.println("filter() 실행: " + n + "(" + isEven + ")");
return isEven;
})
.map(n -> {
int mapped = n * 10;
System.out.println("map() 실행: " + n + " -> " + mapped);
return mapped;
})
.findFirst()
.get();
System.out.println("result = " + result);
}
}=== MyStreamV3 ===
filter() 실행: 1(false)
filter() 실행: 2(true)
filter() 실행: 3(false)
filter() 실행: 4(true)
filter() 실행: 5(false)
filter() 실행: 6(true)
map() 실행: 2 -> 20
map() 실행: 4 -> 40
map() 실행: 6 -> 60
result = 20=== Java Stream ===
filter() 실행: 1(false)
filter() 실행: 2(true)
map() 실행: 2 -> 20
result = 20직접 만든 스트림은 모든 요소에 대해 필터를 거치고 통과한 요소에 대해 map을 끝까지 수행한 후 결과 목록 중 첫 번째 원소를 꺼냈다. 결과적으로 총 9번의 연산이 발생했다. (filter 6번, map 3번)
자바의 스트림은
findFirst()라는 최종 연산을 만나면 조건을 만족하는 요소를 찾은 순간 연산을 멈추고 곧바로 결과를 반환해버린다. 결과적으로 총 3번의 연산이 발생했다. (filter 2번, map 1번)
이를 단축 평가(short-circuit) 라고 하며, 조건을 만족하는 결과를 찾으면 더 이상 연산을 진행하지 않는 방식이다. 이것은 지연 연산과 파이프라인 방식이 있기 때문에 가능한 최적화 중 하나이다.
정리하면 스트림 API의 핵심은 어떤 연산을 할지 파이프라인으로 정의해놓고, 최종 연산이 실행될 때 한번에 처리한다는 점이다. 이를 통해 필요한 시점에만 데이터를 처리하고, 필요 이상으로 처리하지 않는다는 효율성을 얻을 수 있다.
Last updated