스트림에 대해서 정리해보자!
스트림에는 굉장히 많은 메서드가 있고 책에서도 굉장히 많은 스트림 메서드를 다루고 있다. 그러나 모든 메서드를 알아야하는 것은 아니니 중요하고 자주 쓰이는 메서드 위주로 정리해보려고 한다.
스트림은 컬렉션 데이터, 스트림 데이터, 문자열등의 데이터를 간단하게 다루는 기능이다.
그런데 스트림의 간단이라는 것은 정확히 무엇을 뜻하는 것일까?
아래의 구체적인 상황으로 이해해보자!
//(1) `for`문으로 구현
for (Cosmetic cosmetic : cosmetics) {
if (cosmetic.getDiscountRate() > 0) {
discountedItems.add(cosmetic);
}
}
//(2) `stream`으로 구현
cosmetics.stream()
.filter(c -> c.getDiscountRate() > 0)
.toList;
(1) `for`문으로 구현
for (Cosmetic cosmetic : cosmetics) {
if (cosmetic.getDiscountRate() > 0) {
if (discountedPrice >= 10000) {
discountedItems.add(cosmetic);
}
}
}
//(2) `stream`으로 구현
cosmetics.stream()
.filter(c -> c.getDiscountRate() > 0)
.filter(c -> c.getDiscountedPrice() >= 10000)
.toList();
직접 if
문과 add
등으로 연산을 제어하는 1번보다, 간결하게 표현되는 2번의 방식이 가독성이 좋다.
특히 중첩문이 없다는 것도 장점이다!
스트림은 선언적(선언형)이라는 특징을 가진다.
선언적이라는 것은 마치 sql 질의문처럼, 어떻게 와 같은 자세한 코드없이 이루어진다.
세부구현에 대한 코드가 없기 때문에 중요한 부분에 더 집중할 수 있게 되는 것이다.
위의 예시를 보면 스트림의 사용예시에서는 if
문과 같은 제어블록이 사용되지 않은 것을 볼 수 있다.
즉, 사람이 이해하기에 더 간단한 고수준의 특징을 가지는 것이다.
그래서 이런 스트림을 고수준 빌딩 블록이라고 한다.
저수준과 고수준의 뜻을 더 알아보자
- 저수준:
How
에 집중해서 더 세세하게 많은 것들을 제어- 고수준:
What
에 집중해서 무엇을 하는지에 대한 것에 집중, 더 추상화된 수준
아주 완벽한 예시는 아니지만 stream
을 사용했을때, 중첩문 없이도, filter()
메서드 하나만 더 추가해서 구현할 수 있었다.
코드 구조가 유지되고, 조건을 추가하는 것이 쉬워 유지보수에 더 편하다.
스트림은 데이터 소스를 이어붙인 스트림 연산으로 처리하는 연속적인 값의 일회성 객체이다.
스트림은 데이터를 한번만 소비하는 일회성객체이다.
그러나 위의 예시에서는 마치 여러번 소비를 하는 것처럼 느껴질 것이다.
filter
는 스트림을 반환하는 중간연산이기 때문이다.
filter(), map(), sorted(), distinct() //중간연산의 예시
count(), forEach(), collect(), reduce() //최종연산의 예시
다시 위에서 봤던 for
문과 stream
을 비교해보자.
stream
을 사용하지 않을 때는 직접 for
문이나 while
문으로 반복을 명시적으로 제어해야했다.
그러나 stream
은 반복제어자를 사용하지 않고 반복을 실행한다.
이 개념을 내부 반복이라고 한다.
스트림의 특징 중 하나인 lazy
는 중간연산이 최종 연산 호출 전에는 실행되지 않는다는 것이다.
List<String> names = Arrays.asList("Kim", "Park", "Lee", "Choi");
Stream<String> stream = names.stream()
.filter(name -> {
System.out.println("(1) 필터링: " + name);
return name.length() > 2;
})
.map(name -> {
System.out.println("(2) 매핑: " + name);
return name.toUpperCase();
});
System.out.println("(3) 중간 연산 정의 완료"); // 여기까지 아무 연산도 실행되지 않음
// 최종 연산 호출 - 이때 모든 연산이 실제로 수행됨
List<String> result = stream.toList);
여기서 최종 연산은 toList
이고 filter
와 map
은 중간연산에 해당한다.
중간연산은 최종 호출 전에는 수행되지 않기때문에 3번이 출력되고 1번,2번이 실행되는 것을 확인할 수 있다.
🖥️ 출력화면
(3)중간 연산 정의 완료
(1) 필터링: Kim
(2) 매핑: Kim
(1) 필터링: Park
(2) 매핑: Park
(1) 필터링: Lee
(2) 매핑: Lee
(1) 필터링: Choi
(2) 매핑: Choi
그렇다면 왜 이런 Lazy한 계산을 지원하는 걸까?
stream.filter(...) // 이 시점에는 실행되지 않음
.map(...) // 이것도 실행되지 않음
.count(); // 이때 모든 연산이 실행됨
stream.filter(expensive) // 비싼 연산
.limit(5) // 5개만 필요
.forEach(print); // 5개 찾으면 바로 종료
Stream.iterate(1, n -> n + 1) // 무한 스트림
.filter(n -> n % 2 == 0) // 짝수 필터링
.limit(5) // 5개로 제한
.forEach(System.out::println);
그런데 for문으로도 할 수 있는 거 아니야?? 라는 생각이 들 수도 있다.
물론 가능하다. 하지만 for문보다 stream은 위에서 이야기했듯이 세부적인 제어문 없이 무엇을 할지에 초점 맞춰서 코드를 짤 수 있다.
덕분에 가독성이 올라가고, 변경되는 코드에 더 유연하게 대응할 수 있다는 장점을 가진다.