Language

스트림은 느긋하고 싶다

YATTA! 2024. 10. 13. 20:52

Stream API는 2014년 Java8과 함께 혜성처럼 등장했습니다. ☄️

그리고 현재까지 자바 개발자들의 사랑을 듬뿍 받고 있죠. 스트림은 병렬처리, 무한 데이터 등 많은 것을 지원해줍니다. 하지만 누군가가 그 중 꼭 알아야 할 스트림의 특징이 뭐야?라고 묻는다면 전 딱 이렇게 말할 것 같습니다. 스트림은 느긋하다고.

 

Streams are lazy. computation on the source data is only performed when the terminal operation is initiated, and source elements are consumed only as needed.

 

purpose
지연 연산의 특징을 알아보고 Stream의 동작 방식에 대해 이해합니다.

 


 

어느날, 개발자 A씨가 Stream에게 일을 하나 시켰습니다. 너 map()이랑 filter()을 사용해서 데이터를 좀 바꿔놔봐.

names.stream()
    .filter(name -> name.length() > 3)
    .map(String::toUpperCase);

 

A씨는 Stream에게 일 다 했냐고 물어보는데, Stream은 묵묵무답입니다. 기억은 하고 있다고 하네요. 어리둥절한 A씨는 다시 한 번 물어봅니다. 언제 할건데...?

 

💬 Stream: 최종 연산을 주셔야 하죠....

 

Stream은 중간 연산과 최종 연산으로 나누어져 있습니다. 그리고 최종 연산이 없다면 연산을 하지 않습니다. 이게 Stream의 지연 연산입니다. 스트림을 통해 데이터를 처리할 때 중간 연산이 여러 개 있더라도 이들은 즉시 실행되지 않고 최종 연산이 호출될 때 한번에 실행됩니다. A씨는 중간 연산만 주었기 때문에 Stream이 실행을 하지 않았던 것입니다.

 

1. 중간 연산과 최종 연산을 구분해보아요

 

중간 연산과 최종 연산은 응답값으로 확인할 수 있습니다. Stream을 응답하는 것은 중간 연산이고, 그 외의 것을 응답한다면 대부분 최종 연산입니다. Docs에서도 확인이 가능합니다. (This is a terminal operation or Returns new Stream으로 구분 가능)

 

중간 연산은 스트림을 변환하거나 필터 합니다.  

filter(), map(), sorted(), limit() 등이 있습니다. Stream을 반환하고, 데이터의 실제 처리는 하지 않습니다.

 

최종 연산은 스트림의 데이터를 처리하여 결과를 반환합니다.

forEach(), collect(), count(), reduce() 등이 있습니다. 최종 연산이 호출되면 스트림의 중간 연산들이 실행됩니다. Stream 파이프라인 전체가 단 한 번의 루프로 처리됩니다.

 

2. 중간 연산으로 끝내면 어떻게 되는데?

 

A씨처럼 중간 연산만으로 스트림을 끝내게 되면, 실제로 어떠한 연산도 수행되지 않습니다.

중간 연산까지만 작성해도 코드가 돌돌 돌아는 가지만 리턴값이 Stream<>이라는 걸 알 수 있을거예요. 이 스트림 객체는 연산의 파이프라인만 담고 있는 상태이고, 내부적으로 뭔가 연산을 처리하고 있지는 않아요.

 

저는 Stream 객체를 보고 '저장소'나 '기록장'이 떠올랐어요. 최종 연산을 수행하기 전까지는 그냥 기록만 하고 있을 뿐이라는 것이 아주 중요한 포인트인 것 같습니다. 📝

 

👇 아래 코드의 출력이 어떻게 될지 상상해보셔도 좋을 것 같아요. 

List<String> list = Arrays.asList("a", "b", "c", "d");

Stream<String> mappedStream = list.stream()
				.map(element -> {
                                    System.out.println("Mapping: " + element);
                                    return element.toUpperCase();
                     		});

System.out.println(mappedStream);

 

결과: java.util.stream.ReferencePipeline$3@6acbcfc0

 

중간 연산 map()에서 정의 된 println 문장은 출력되지 않았습니다. 이로 인해 중간 연산으로 끝낼 시 연산 정보만 Stream에 기록되고, 데이터에 접근하거나 연산을 수행하지 않음을 알 수 있습니다. (=> Stream은 연산 정보만 가지고 있음.)

최종 연산이 호출되어야 비로소 Stream의 모든 중간 연산이 한 번에 실행됩니다.

 

어라... 근데... 한 번에 실행 된다는 게 무슨 뜻인가요?

 

3. Stream 연산 동작 방식

 

이번에도 코드를 보면 이해가 쉬울 것 같아요. 👇 아래 코드는 어떻게 출력될지 생각해볼까요? 

List<String> list = Arrays.asList("a", "b", "c", "d");

list.stream()
     .map(e -> {
         System.out.println("Map: " + e);
         return e.toUpperCase();
     })
     .filter(e -> {
         System.out.println("Filter: " + e);
         return e.equals("A");
     })
     .collect(Collectors.toList());

 

💭 우선 map이 실행되어서 -> list에 있는 값들이 대문자로 변하고(upperCase) -> 그 다음에 필터가 수행이 될 거야.

 

라고 생각하셨다면 아쉽지만 땡입니다. 저도 처음엔 그렇게 생각했고, 개발자 A씨도 그렇게 생각했을거니까 당황하실 필요는 없어요. 위의 생각대로라면 map과 filter은 두 번의 루프를 돌면서 순차적으로 실행되어야 합니다. 하지만 Stream은 그렇게 실행되지 않아요. 위에서도 언급했지만, Stream 파이프라인은 전체가 단 한 번의 루프로 처리됩니다.

 

모든 요소를 한번에 map()으로 변환 후에 filter()을 거치는 게 아니라, 각각의 요소가 연속적으로 map()과 filter()을 통과하게 됩니다. 아래 실행 결과를 보면 이해가 단번에 되실거예요.

코드의 실행 결과

 

4. 우리가 지연 연산을 알아야 하는 이유

 

중간 연산과 최종 연산이 무엇인지, Stream의 동작 방식이 어떻게 되는지를 알아봤습니다.

 

지연 연산과 스트림의 동작 방식을 잘 알아둔다면 불필요한 연산을 줄이고 성능을 최적화 할 수 있습니다. 중간 연산들의 호출 순서도 어떻게 하는 게 좋을지 감이 오실 것이라 생각해요. (filter()보다 map()을 먼저 사용한다면 불필요한 연산을 줄일 수 있음)

💡 만약 지연 연산의 장점을 더 알아보고 싶으신 분들은 무한 스트림을 공부해보시는 것을 추천드려요.

 

마무리하며

 

저는 스트림의 동작 방식이 스트림을 이해하는 데 많은 도움이 되었는데, 읽으신 분들에게도 조금이나마 도움이 되었으면 좋겠습니다.

 

원래 'Stream을 사용하는 사람을 위한 지침서'라는 주제로 글을 쓰려고 했습니다. (기존 클래스와 다른점, 왜 스트림이어야만 하는지 등 ...)
그런데 쓰다보니 알아야할 내용들이 너무 많더라구요. 그래서 우선 스트림의 지연 로딩 방식에 대해서만 글을 쓰게 되었습니다.
추후엔 원래 쓰려고 했던 글도 간소화해서 업로드 할 것 같습니다. 끝까지 읽어주셔서 감사합니다. ✨

'Language' 카테고리의 다른 글

왜 'a'는 String type이 될 수 없을까? feat. SCP  (0) 2024.10.27