Lazy Java Stream

주싱·2023년 3월 9일
0

더 나은 도구

목록 보기
5/13
post-custom-banner

1. 어설프게 작성된 코드

// 3보다 작은 첫 번째 항목을 찾는다. 
return IntStream.of(10, 5, 2, 20, 1) 
				        .filter(num -> num < 3) 
				        .findFirst();

Java Stream을 사용해 이와 같이 작성된 코드를 보며 의문점이 생겼습니다. 제 생각에는 코드가 다음과 같이 동작할 것 같았습니다.

  1. filter 연산에서 스트림의 모든 항목을 순회하며 3보다 작은 항목을 추출한다.
  2. findFirst 연산에서 추출된 항목 중 첫 번째 항목을 찾아서 반환한다.

코드 조각이 최종적으로 하는 일은 스트림에서 3보다 작은 첫 번째 항목을 찾는 일인데 filter 연산에서 불필요하게 전체 스트림을 순회하고 있다는 생각이 들었습니다. 제가 비효율적이고 어설픈 코드를 작성했음을 직감했습니다.

2. 상상 속 API 찾기

그래서 더 효율적인 Stream API가 있는지 찾기 시작했습니다. 예를 들면 아래와 같은 API가 있을 것이라 기대했습니다. 굳이 전체 스트림을 순회하지 않고, 조건을 만족하는 항목이 나올 때 까지만 스트림을 순회하는 방법입니다.

return IntStream.of(10, 5, 2, 20, 1) 
				        .findFirst(num -> num < 3); // 내 상상 속 API 

그런데 이상하게 이런 API가 없었습니다. 자바 언어 설계자들이 이런 기본적인 케이스를 생각하지 않았을 것 같은데 정말 이상했습니다.

3. 숨겨진 진짜 동작

그래서 집에 있는 모던 자바 인 액션 책을 꺼내서 Stream의 동작 원리에 대해 읽어 보기 시작했습니다. 중간 연산(Intermediate Operation), 게으름(Lazy), 끊어진 순회(Short Circuit) 같은 개념이 등장합니다. 책을 읽고 제가 작성한 코드의 동작은 실제로는 다음과 같음을 깨닫게 되었습니다.

  1. filter 연산에서 스트림을 순회하되, 3보다 작은 첫 번째 항목이 나올 때까지만 순회하며 조건에 맞는 항목을 추출한다. (결론적으로 3보다 작은 첫 번째 항목만 추출)
  2. findFirst 연산에서 추출된 항목 중 첫 번째 항목을 찾아서 반환한다.
return = IntStream.of(10, 5, 2, 20, 1)
	                .filter(num -> {
	                    System.out.println("filtering : " + num);
	                    return num < 3;
	                })
	                .findFirst();

그리고 진짜 그런지 테스트 코드를 작성해서 확인해 보았습니다. Stream을 순회할 때 로그를 남기도록 해서 스트림의 어디까지 순회하는지 확인하는 겁니다. 결과는 책을 읽고 이해한 것과 같았습니다. 제가 Stream API 원리를 이해하지 못하고 코드를 바라본 것이 맞았습니다. 처음 작성된 코드는 원래부터 효율적이게 동작하고 있었습니다.

filtering : 10
filtering : 5
filtering : 2 // 3 보다 작은 첫번 째 항목에서 순회가 끊어짐 (Short Circuit)

4. 이론적 배경

모던 자바 인 액션 책에서 읽은 관련된 이론적인 배경을 정리해 봅니다.

게으름(Lazy)

먼저 스트림 연산들은 중간 연산과 최종 연산으로 구분됩니다. 중간 연산은 호출 즉시 실행되지 않고 단지 파이프라인을 구성하는 일만 합니다. 실제로 최종 연산이 호출될 때 중간 연산을 포함한 전체 파이프라인이 실행됩니다. 이때 중간 연산이 호출된 즉시 실행되지 않고, 최종 연산이 호출될 때 뒤늦게 실행된다고 하여 스트림이 게으른(Lazy) 특성을 가진다고 표현합니다.

끊어진 순회 (Short Circuit)

‘게으르다’는 약간은 부정적인 단어가 사용되었지만 스트림은 이 게으른 특성 덕분에 전체 파이프라인 구성을 보고 최적화된 실행을 계획할 수 있습니다. 앞에서 살펴본 예와 같이 findFirst 같은 최종연산이 있으면 굳이 전체 스트림을 순회하지 않고 첫번째 항목을 찾을 때 까지만 순회하도록 실행을 계획하는 것입니다.

혼합된 루프(Fusion Loop)

스트림의 게으른 특성 때문에 효과를 발휘하는 또 다른 특성으로 혼합된 루프(Fusion Loop) 라는 기법도 있습니다. 중간 연산에서 filter, map 등의 순회 연산이 연속되는 경우 각각 루프를 도는 것이 아니라 하나의 루프로 중간 연산을 함께 수행하도록 하는 방법이라고 할 수 있습니다. 스트림을 쓰다보면 루프를 너무 많이 돌아서 비효율적인 것이 아닌가 내심 걱정은 되지만, 루프 몇 번 더 돈다고 성능에 큰 영향이 없다고 스스로를 안심시키곤 했는데 팩트를 벗어난 걱정이었습니다.

profile
소프트웨어 엔지니어, 일상
post-custom-banner

0개의 댓글