스트림(Stream)은 Java 8에서 컬랙션과 배열을 반복적으로 처리할 때 코드의 가독성을 향상시키고 병렬 처리를 쉽게 하기 위해 새로 도입된 api이다. 우리는 데이터를 처리할 때, 알고리즘 로직을 작성하기 보다는 어떤 작업을 원하는지 '선언형(Declarative)'으로 작성함으로써 보다 직관적인 코드를 작성할 수 있다.
선언형과 명령형
선언형과 명령형을 비교하여 어떤 것이 다른지 직접 코드로 비교해보자
먼저 우리가 일반적으로 사용하는 명령형 코드이다
public class Example {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> evenNumbers = new ArrayList<>();
// 명령형 방식: 반복문을 사용하여 짝수만 추출
for (Integer number : numbers) {
if (number % 2 == 0) {
evenNumbers.add(number);
}
}
System.out.println("Even numbers: " + evenNumbers);
}
}
명령형은 (1,2,3,4,5)를 원소로 가진 리스트에서 짝수만 추출하기 위해 반복문(for)을 사용하여 리스트의 각 원소들을 비교하였고, 원소마다 조건문(if)을 사용하여 조건을 만족하면 리스트에 추가하여 짝수만 추출하였다.
즉, 어떤 조건을 만족하는 대상(what)에 따라, 어떻게(how) 처리할지를 직접 작성해주었다.
이 코드를 선언형으로 바꿔보자
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class Example {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// 선언형 방식: 스트림과 중간 연산(filter) 및 최종 연산(collect)을 사용하여 짝수만 필터링
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println("Even numbers: " + evenNumbers);
}
}
반면에 선언형은, 중간 연산과 최종 연산을 통해서 간단하게 필터링 조건과 마무리 형태만을 나타내 주었다.
데이터를 가공하기 위해서 반복문을 써야하고, 조건문을 걸어주며, 조건을 만족했을 때 어떻게 처리해야 하는지에 대한 구현을 구체적으로 명시하지 않고 추상화하여 표현 한 것이다.
이를 통해 선언형이 어떻게 더 간결하고 가독성 좋은 코드를 작성할 수 있게 해주는지 비교가 되었을 것이다.
Stream
그럼 이제 본격적으로 스트림에 대해서 알아보자
스트림(Stream)의 특징
- 데이터를 순차적, 혹은 병렬적으로 실행할 수 있다
- Collection과 Stream의 가장 큰 차이점은 '데이터를 계산하는 시점'이다
- 컬렉션의 모든 요소는, 컬렉션에 추가하기 전에 계산되어야 한다 (여러 연산이 중첩된 경우, 각 연산 이전에 모두 계산되어야 한다)
- 스트림은 요청할때만 요소를 계산한다(지연 연산을 뜻한다)
- 외부 반복을 하는 Collection과 다르게 내부 반복을 통해 효율적으로 계산을 할 수 있다
- 외부 반복
- 개발자가 반복문(for, while)과 조건문(if) 등을 사용하여 직접 반복을 제어하는 방식 - 내부 반복
- 반복문으로 명시하지 않아도 라이브러리나 API에 의해 자동으로 반복이 이루어지기에, 개발자가 반복을 직접 제어하지 않는다
- 외부 반복
- 중간 연산과 최종 연산으로 구성되어있다
- 중간 연산에서 여러 개의 조건이 중첩되었을 때, 모든 조건을 확인하기 이전에 값이 결정이 나면 더 이상 불필요한 실행을 하지 않는 쇼트 서킷(short-circuit)을 통해 실행 속도을 향상시킨다
- 중간 연산이 최종 연산을 만나기 전까지 실제로 연산이 이루어지지 않는 지연 연산(Lazy Evaluation)을 통해 불필요한 연산을 줄여서 실행 속도를 향상시킨다
- 각 중간 연산별로 결과를 구해놓는 것이 아니라, 중간 연산이 조건문이 되어서 각 연산별로 이루어진다고 생각하면 된다
- 다음을 참고하자 : https://dororongju.tistory.com/137
중간 연산(intermediate operations)
: 모두 스트림을 반환하고, 최종 연산을 만났을 때 연산이 이루어진다
- filter(Predicate) : Predicate를 인자로 받아, 결과가 true인 요소만 포함하는 스트림을 반환
- distinct() : 중복된 요소를 제거
- limit(n) : 주어진 사이즈 이하 크기를 갖는 스트림을 반환
- skip(n) : 처음 요소 n개를 제외한 스트림을 반환
- map(Function) : 각 요소를 주어진 매핑 함수의 result로 구성된 스트림을 반환
- flatMap() : 각 요소를 스트림으로 매핑하고, 평면화된 하나의 스트림으로 반환
- sorted() : 요소들을 정렬
최종 연산(terminal operations)
- 조건 검사
- (boolean) allMatch(Predicate) : 모든 스트림 요소가 Predicate와 일치하는지를 반환
- (boolean) anyMatch(Predicate) : 하나라도 일치하는 요소가 있다면 true를 반환
- (boolean) noneMatch(Predicate) : 매치되는 요소가 하나도 없다면 true를 반환
- 연산
- reduce(binary operator, initial value) : 모든 스트림 요소를 하나의 값으로 처리해서 반환, 초기값은 생략 가능
- (Long) count : 스트림 요소 개수를 반환
- (void) forEach() : 스트림의 각 요소에 대해 주어진 작업을 수행
- 검색
- (Optional) findAny() : 현재 스트림에서 임의의 요소를 반환
- (Optional) findFirst() : 스트림의 첫번째 요소를 반환
- (Optional<T>) min(comparator) : 스트림에서 가장 작은 요소를 반환
- (Optional<T>) max(comparator) : 스트림에서 가장 큰 요소를 반환
- 컬렉션
- (Collection) collect() : 스트림을 reduce하여 list, map, 정수 형식 컬렉션을 반환
// map()
List<String> names = Arrays.asList("Sehoon", "Songwoo", "Chan", "Youngsuk", "Dajung");
names.stream()
.map(name -> name.toUpperCase())
.forEach(name -> System.out.println(name));
// filter()
List<String> startsWithN = names.stream()
.filter(name -> name.startsWith("S"))
.collect(Collectors.toList());
// reduce()
Stream<Integer> numbers = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Optional<Integer> sum = numbers.reduce((x, y) -> x + y);
sum.ifPresent(s -> System.out.println("sum: " + s));
// collect()
System.out.println(names.stream()
.map(String::toUpperCase)
.collect(Collectors.joining(", ")));
Reference
- 스트림(Stream)이란 무엇인가?: https://zangzangs.tistory.com/171
- Lazy Evaluation 이란?: https://dororongju.tistory.com/137