JAVA

지연 연산 vs 즉시 연산: Kotlin과 Java Stream을 통한 최적화 전략 이해하기

경딩 2025. 1. 17. 13:05

지연 연산 (Lazy Evaluation) vs 즉시 연산 (Eager Evaluation)

스트림을 배우다 보면 지연연산이라는 특징을 접하게 된다. 지연연산을 종단연산을 실행하기 전까지 연산을 미루기 때문에 해당 연산을 즉시 실행되지 않는다. 그렇다면 지연 연산이란 무엇일까?

 

Lazy  vs Eager

 

지연 연산이란 간단히 말해 결괏값이 필요할 때까지 계산을 늦추는 기법이다. 즉, 눈앞에 코드가 주어졌을 때 곧바로 해당 코드를 실행하는 것이 아니라 실행결과가  필요해지는 시점에 실행을 하도록 하는 것이다. 다만, 이러한 방식으로 코드가 동작하기 위해 내부적으로 준비해 주는 작업이 필요하므로  무조건 효율적인 방식이라고 보기 어렵다.

 

이에 반해 어떠한 작업이 즉시 수행되다면 말 그대로 실행할 코드가 보이는 순간 곧바로 실행된다는 의미이다. 사실 특정 작업의 실행결과가 향후 필요하다는 점이 확실하다면 미리 실행해놓는 것이 기본적으로 성능 측면에서  우월하다.

 

Kotlin에서의 예시

Kotlin은 지연 연산과 즉시 연산 모두를 지원한다. 이를 통해 두 개념을 비교해 보겠다.

 

지연 연산 예제 (Sequence 사용)

package chapter05


class Ko {
    fun runSequenceExample() {
        sequenceOf(1, 2, 3, 4)
            .map { i ->
                println(i)
                i
            }
            .forEach { i -> println(i + 1) }
    }

}

fun main() {
    val example = Ko()
    example.runSequenceExample()  // Sequence 예제 실행

}

 

 

실행 결과

이 결과는 map 연상이 끝난 후 forEach 가 실행될것이라고 예상했지만 실제로는 map과 forEach가 번갈아가며 실행되는 것을 확인할 수 있다. 이는 Sequence 가 지연 연산을 사용하기 때문이다.

 

즉시 연산 예제 (List 사용)

package chapter05


class Ko {

    fun runListExample() {
        listOf(1, 2, 3, 4)
            .map { i ->
                println(i)
                i
            }
            .forEach { i -> println(i + 1) }
    }


}

fun main() {
    val example = Ko()
    example.runListExample()      // List 예제 실행

}

실행 결과

 

이 결과는 List가 즉시 연산을 사용하기 때문에, map 이 먼저 모든 요소에 대해 실행되고 난 후에 forEach 가 실행되는 것을 볼 수 있다.

 

자바의 Stream 은 종료함수가 호출되기전까지는 연산을 수행하지 않는 지연  로딩을 사용한다.

 

 

스트림의 최적화 전략

 

루프퓨전

루프류전(loop fusion)이란 파이프라인에서 연속적으로  체이닝 된 복수의 스트림 연산을 하나의 연산 과정으로 합치는 전략을 뜻한다.

 

기존 코드 (루프 두 개):

public class LoopFusionExample {
    public static void main(String[] args) {
        int[] numbers = {1, 2, 3, 4, 5};
        
        // 첫 번째 루프: 배열의 각 요소를 두 배로 만들기
        for (int i = 0; i < numbers.length; i++) {
            numbers[i] = numbers[i] * 2;
        }

        // 두 번째 루프: 배열의 각 요소 출력하기
        for (int i = 0; i < numbers.length; i++) {
            System.out.println(numbers[i]);
        }
    }
}

 

이 예제는 두개의 독립적인 루프가 사용된다.

 

  • 첫 번째 루프는 배열을 변경
  • 두 번째 루프는 배열을 출력

 

각각의 루프가 배열에 대해 한 번씩 순차적으로 실행되므로, 배열을 두 번 순회하게 된다.

 

지연 연산과 무한 스트림

무한 스트림의 모든 요소가 한번에 계산되거나 저장되지 않고, 필요한 순간에만 요소가 계산된다.  . 예를 들어, 우리가 첫 10개의 값만 필요하다고 하면, 무한 스트림에서 필요한 요소만을 지연 계산하여 반환합니다.

 

왜 무한 스트림이 가능할까?

무한 스트림은 이처럼 연산을 필요한 시점으로 미루는 방식으로 , 메모리나 cpu 리소스를 낭비하지않으면서 무한히 데이터를 처리할 수 있다. 스트림은 각 연산이 실제로 필요할때만 계산하므로 , 무한 스트림은 한번에 계산되지 않고, 연속적으로 필요한 연산이 이루어진다.

 

루프 퓨전된 코드 (루프 하나):

루프 퓨전을 사용하면 두 개의 루프를 하나로 합칠 수 있습니다. 이렇게 하면 배열을 한 번만 순회하면서 값을 변경하고 출력할 수 있습니다.

public class LoopFusionExample {
    public static void main(String[] args) {
        int[] numbers = {1, 2, 3, 4, 5};
        
        // 루프 퓨전: 배열을 한 번만 순회하면서 값을 두 배로 만들고 출력하기
        for (int i = 0; i < numbers.length; i++) {
            numbers[i] = numbers[i] * 2; // 배열 값 두 배로 만들기
            System.out.println(numbers[i]); // 배열 값 출력하기
        }
    }
}

 

루프 퓨전의 장점:

  • 성능 향상: 배열을 두 번 순회하는 대신 한 번만 순회하기 때문에 시간 복잡도가 개선이 된다.
  • 메모리 접근 최적화 : 동일한 데이터를 반복적으로 접근하는 비용을 줄인다.
  • 코드 간결화: 루프를 합침으로써 코드가 더 간결해지고 가독성이 좋아진다.

스트림과 루프 퓨전

스트림 연산에서도 루프 퓨전이 적용될 수 있다. 예를 들어, 여러 스트림 연산을 체이닝 할 때 연산을 합쳐서 최적화한다.

import java.util.stream.Stream;

class Main {

    public static void main(String[] args) throws Exception {
        Stream.of(1, 2, 3)
                .map(it -> {
                    System.out.println("map " + it + " -> ");
                    return it;
                })
                .filter(it -> {
                    System.out.println("filter " + it + " -> ");
                    return true;
                })
                .forEach(it -> {
                    System.out.println("forEach " + it + " -> ");
                    System.out.println();
                });

    }

}

결과

map 1 -> 
filter 1 -> 
forEach 1 -> 

map 2 -> 
filter 2 -> 
forEach 2 -> 

map 3 -> 
filter 3 -> 
forEach 3 ->

 

루프퓨전은 개별 스트림 요소에 접근하는 횟수를 줄여  최적화하는 방식이다.

루프퓨전 없었더라면  map에서 1, 2, 3을 순회하고 filter에서 1, 2, 3을 순회하고 forEach 에서 1, 2, 3을 순회하는 총 9번 요소에 접근해야 한다.

 

그러나 루프퓨전이 적용되었기 때문에 1 이 mapfilterforEach 다 돌고 2가 다 돌고 3이 다 도는 방식으로 각 한 번씩 총 3번만 접근된다는 것을 알 수 있다.

 

단 상태연산인 sorted()에서는 루프퓨전이 중지된다.

 

Short-circuit

쇼트서킷은 불필요한 연산을 건너뛰는 기법이다. 예를 들어, 스트림에서 limit을 사용하면 조건에 맞는 요소가 충분히 처리되면 더 이상의 연산을 수행하지 않는다.

import java.util.stream.Stream;

class Main {

    public static void main(String[] args) throws Exception {
        Stream.of(1, 2, 3)
                .map(it -> {
                    System.out.println("map " + it + " -> ");
                    return it;
                })
                .filter(it -> {
                    System.out.println("filter " + it + " -> ");
                    return true;
                })
                .limit(2)
                .forEach(it -> {
                    System.out.println("forEach " + it + " -> ");
                });
    }

}

 

map 1 -> 
filter 1 -> 
forEach 1 -> 
map 2 -> 
filter 2 -> 
forEach 2 ->

 

결과를 보면 요소 3  스트림 연산을 수행하지 않는다. 그 이유는 limit(2) 연산으로 3번째 요소는 수행되지 않는다. 

이처럼 불필요한 연산을 의도적으로 수행하지 않음으로써 실행속도를 높이는 기법이 쇼트서킷이다.

 

스트림과 for 문은 언제 쓸까?

스트림은 가독성이 좋고,  변환, 필터링 정렬 등 다양한 연산을 연속적으로 연결하고 싶을 때 유용하다.

무엇을 하겠다는 선언적 코드에 유용하며 병렬처리가 가능하다.

 

for 문은 스트림보다 코드가 더 복잡하지만 디버깅과 구체적인 제어가 가능하므로 성능이 중요하거나 세밀한 제어가 필요할 경우 for문을 쓰는 것이 유용하다.

'JAVA' 카테고리의 다른 글

Runnable과 Callable  (0) 2025.01.12
ReentrantLock  (0) 2025.01.07
Java에서의 지연 초기화,지연 평가 Optional 활용: orElse vs orElseGet  (0) 2025.01.06
고급 동기화 - concurrent.Lock  (1) 2025.01.05
생산자 소비자 문제1  (1) 2024.12.31