Backend/Java

Java 스트림 성능 최적화 전략: 지연연산과 루프퓨전, 쇼트서킷

컴공오지마라 2024. 11. 25. 13:22

1. 스트림을 사용하는 흐름

  • 스트림 생성 ex) stream()
  • 중간 연산 ex) filter(), map()
  • 종단 연산 ex) toList()

Java 스트림은 스트림을 생성한 뒤 원하는 연산을 수행하고 결과를 컬렉션으로 반환하는 방식으로 사용된다.
스트림을 한번이라도 사용해보았다면 익숙한 내용이다. 아래 예제를 살펴보자

public List<String> getMostPopularCategories(int n) {
        return countBooksByCategory().entrySet().stream() // 스트림 생성
                .sorted(Comparator.comparingLong(Map.Entry<String, Long>::getValue).reversed())
                .map(Map.Entry::getKey)
                .limit(n)
                .toList(); // 종단연산
 }

 

코드의 흐름대로 스트림의 각 원소에 대해 sorted(), map() 이 순서대로 실행될 것 같지만 그렇지 않다.
모든 중간연산은 종단연산이 실행되는 시점에 실행된다.

2. 지연연산이란?

스트림 파이프라인이 실행되더라도 JVM 은 즉시 스트림 연산을 시작하지 않는다.
대신, 지연연산을 통해 실행되는 연산을 최소화 하고자 사전 작업을 수행한다. 사전 작업이란 스트림 파이프라인이 어떠한 중간연산과 최종연산으로 구성되어있는지 확인하면서 시작된다. 이러한 검사 결과를 바탕으로 JVM 은 최적화를 어떻게 진행할지 미리 계획한다. 이는 데이터를 효율적으로 처리하기 위해 연산 순서를 재구성하거나 불필요한 작업을 생략하는 등의 방식을 포함하며, 이러한 계획을 기반으로 스트림의 각 요소에 대한 연산을 수행한다.스트림에서 제공하는 대표적인 최적화 전략으로는 루프 퓨전(loop fusion)과 쇼트 서킷(short-circuit) 이 있다.

2.1 루프 퓨전 ( Loop fusion )

  • 파이프라인에서 연속적으로 연결된 여러 스트림 작업을 하나의 작업으로 병합하는 것
  • 개별 스트림 요소에 접근하는 하는 횟수를 최소화 → CPU 연산 횟수 최소화
Stream.of(new Task(1), new Task(2), new Task(3))
    .peek(Task::validate)
    .peek(Task::transform)
    .forEach(Task::save);

//Task 1의 validate 작업
//Task 1의 transform 작업
//Task 1의 save 작업
//Task 2의 validate 작업
//Task 2의 transform 작업
//Task 2의 save 작업
//Task 3의 validate 작업
//Task 3의 transform 작업
//Task 3의 save 작업

 

즉 아래와 같은 for-loop 코드와 동일하게 실행된다.

List<Task> tasks = Arrays.asList(new Task(1), new Task(2), new Task(3));
for (Task task : tasks) {
    task.validate();  // validate 작업 수행
    task.transform(); // transform 작업 수행
    task.save();      // save 작업 수행
}

 

루프 퓨전이 적용되지 않았다면 총 9번 접근을 해야했을 것이다. 그러나 루프 병합을 통해 스트림의 각 요소를 한 번씩만 순회하여 총 세번만 순회할 수 있었다. 즉 루프 퓨전은 개별 스트림 요소에 접근하는 횟수를 최소화하여 성능 개선에 중요한 역할을 한다.

2.2 루프 퓨전이 적용되지 않는 경우

  • 연산의 종류에 따라 스트림 파이프라인에 대한 루프 퓨전이 발생하지 않을 수도 있다
  • 대표적으로 상태를 알아야하는 스트림 연산에 대해 루프 퓨전이 적용되지 않는다
    • ex) sorted(), distinct(), limit()
    • 모든 스트림 요소의 값(상태)를 알아야 하므로, 전체 순회가 끝나기 전까지 다음 연산으로 넘길수 없다
List<Integer> result = Stream.of(3, 1, 2, 4)
    .filter(x -> x > 2)  // 필터링
    .sorted()           // 정렬
    .toList();

2.3 쇼트 서킷 ( Short circuit )

  • 일반적으로 쇼트서킷은 불필요한 연산을 의도적으로 생략하여 실행 속도를 향상시키는 기법
  • 스트림에서 쇼트서킷은 limit과 같은 연산을 통해 스트림의 일부 요소의 연산을 생략하는 것을 의미
Stream.of("Alice", "Bob", "Charlie", "Cake", "Coffee")
    .filter(name -> name.startsWith("C"))
    .peek(name -> System.out.println("Filtered: " + name)) // 필터링된 요소 출력
    .limit(1)
    .forEach(System.out::println); 


// 결과 
// Filtered: Charlie
// Charlie

 

예시에서 filter를 통해 3개의 항목이 필터링 된다. 이후 peek를 통해 각각을 출력한 후 종단연산을 수행할것 같지만 그렇지 않다. limit(1)로 인해 Cake, Coffee는 최종결과에 포함되지 않으므로 중간연산을 생략한 것이다.

3. 참고자료

GDG campus on Konkuk java 8 study

https://github.com/mjkhub/24-25-study-java-8

 

GitHub - mjkhub/24-25-study-java-8: modern java in action을 기반으로 한 스터디 레포입니다.

modern java in action을 기반으로 한 스터디 레포입니다. Contribute to mjkhub/24-25-study-java-8 development by creating an account on GitHub.

github.com

 

 

https://bugoverdose.github.io/development/stream-lazy-evaluation/

 

[Java] 스트림: 지연 연산과 최적화

Stream API의 주요 특성인 지연 연산의 의미, 그리고 이를 기반으로 일어나는 루프퓨전과 쇼트서킷에 대해 알아보자.

bugoverdose.github.io

 

'Backend > Java' 카테고리의 다른 글

CompletableFuture 자세히 들여다보기  (0) 2025.01.27