JAVA

[Java] 스트림(Stream)에서 가변 변수 사용 시 발생하는 문제 및 해결 방법

Stark97 2024. 9. 20. 21:37
반응형
 
 
 

스트림에서 가변 변수를 사용하면 문제가 발생한다.

 

1. 스트림에서 가변 변수 사용의 문제점

스트림 내부에서 가변 변수를 사용하는 것은 여러 가지 문제를 초래할 수 있다.

  1. 동시성 문제
    • 스트림은 내부적으로 병렬 처리를 지원한다. 만약 가변 변수를 공유하게 되면 여러 스레드가 동시에 접근하여 데이터 무결성을 해칠 수 있다.

  2. 예측 불가능한 동작
    • 스트림의 연산은 지연(lazy) 실행되거나 최적화될 수 있어, 가변 상태는 연산의 결과를 예측하기 어렵게 만든다.

  3. 가독성 및 유지보수성 저하
    • 가변 상태를 사용하면 코드의 흐름을 추적하기 어려워져 버그를 유발하기 쉽다.

스트림의 설계 철학

  1. 스트림의 설계 철학은 함수형 프로그래밍에 기반을 두고 있다.
    • 함수형 프로그래밍은 상태를 변경하지 않는 순수 함수를 중심으로 작동하며, 이는 코드의 예측 가능성과 안정성을 높여준다. 순수 함수는 동일한 입력에 대해 항상 동일한 출력을 반환하며, 부작용이 없기 때문에 여러 스레드에서 안전하게 사용할 수 있다.

  2. 가변 변수를 사용한다면 어떻게 되는가?
    • 이러한 철학을 따름으로써 스트림은 복잡한 데이터 처리 작업을 간결하고 효율적으로 수행할 수 있다. 그러나 가변 변수를 사용하게 되면 상태를 변경하게 되므로, 함수형 프로그래밍의 핵심 원칙인 불변성과 순수 함수의 이점을 저해하게 된다. 결과적으로, 가변 변수를 사용하면 스트림의 설계 철학에 어긋나며, 스트림의 강력한 기능을 온전히 활용하기 어렵게 만든다.

스트림의 선언적 접근 방식이란? (4번 Atomic 목차를 위한 추가설명)

  • 스트림의 선언적 접근 방식은 무엇을 해야 하는지에 초점을 맞추는 프로그래밍 스타일을 말한다. 이는 어떻게 해야 하는지에 대한 세부적인 구현을 추상화하여, 코드의 가독성과 유지보수성을 높인다.

  • 예를 들어, 스트림을 사용하여 데이터를 필터링하고 변환하는 과정은 선언적 접근 방식으로, 개발자는 데이터 처리의 목표만을 명확히 설정하고, 내부적인 처리 방식은 스트림 API가 관리한다.

  • 반면, 전통적인 반복문과 조건문을 사용하는 코드는 명령형(Imperative) 프로그래밍에 속하며, 데이터 처리의 구체적인 과정을 직접 제어해야 합니다. 선언적 접근 방식을 유지하면 코드는 더욱 간결하고 직관적으로 되며, 함수형 프로그래밍의 장점을 극대화할 수 있다.

  • 따라서, Atomic 변수를 사용하여 가변 상태를 관리하는 방식은 스트림의 선언적 접근 방식을 벗어나며, 코드의 복잡성과 오류 발생 가능성을 증가시킬 수 있다.

 

2. 가변 변수 사용 시 발생하는 오류 예시

1. 간단한 가변 변수 사용

public class StreamMutableExample {

    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        int sum = 0;

        // 스트림 내부에서 가변 변수를 변경하려고 시도
        numbers.stream()
               .forEach(n -> sum += n); // 오류 발생

        System.out.println("합계: " + sum);
    }
    
}

오류 메시지 (컴파일 오류)

java: local variables referenced from a lambda expression must be final or effectively final
  • 자바의 람다 표현식은 외부 변수를 캡처할 때 해당 변수가 final이거나 효과적으로 final이어야 한다.
  • sum 변수를 스트림 내부에서 변경하려고 시도하면, 컴파일러가 이를 허용하지 않는다.
  • 인텔리제이에서는 sum 변수에 빨간 줄이 생길 것이고 'Convert to Atomic'을 추천할 것이다.

 

2. 병렬 스트림에서의 가변 변수 사용

public class ParallelStreamExample {

    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        int[] sum = {0};

        // 병렬 스트림을 사용하여 가변 변수를 변경
        numbers.parallelStream()
               .forEach(n -> sum[0] += n); // 예측 불가능한 결과

        System.out.println("합계: " + sum[0]); // 출력 결과가 일관되지 않을 수 있음
    }
    
}
  • int 타입의 변수를 스트림 내부에서 변경하려고 할 때, 배열(int[])로 감싸는 방식으로 우회할 수 있다. 그러나 이는 권장되지 않는 패턴으로, 특히 병렬 스트림에서는 여러 스레드가 동시에 sum[0]을 변경하려 시도하면서 데이터 경합(Race Condition)이 발생할 수 있다. 그래서 결과적으로 합계가 정확하지 않을 수 있다.
  • 실제 테스트를 해보면 15 말고 13, 14가 나오는 경우가 있다. (계속해서 실행해야 한다.)

스트림 동작 시각화

+--------+      +---------+      +-------+      +-------+
|  데이터  | ---> | forEach | ---> |  합산  | ---> |  결과  |
+--------+      +---------+      +-------+      +-------+
  • 데이터: 시작점, 처리하고자 하는 숫자 리스트.
  • forEach: 각 요소를 순회하며 합산하려는 시도.
  • 합산: sum 변수에 값을 더함.
  • 결과: 최종 합계를 출력.

병렬 스트림에서의 경쟁 상태 (Race Condition)

  • 아래는 스트림 내부에서 가변 변수를 사용할 때 발생할 수 있는 동시성 문제를 간단하게 나타낸 다이어그램이다.
+-----------+          +-----------+        +-----------+
|  Thread 1 |          |  Thread 2 |        |  Thread 3 |
+-----------+          +-----------+        +-----------+
      |                      |                    |
      |    sum += n          |    sum += n        |    sum += n
      |                      |                    |
      +--------> sum <-------+--------> sum <-----+
            (경쟁 상태 발생)           (경쟁 상태 발생)
  • 여러 스레드가 동시에 sum 변수를 변경하려고 시도하면서 경쟁 상태(Race Condition)가 발생한다. 이는 최종 합계가 정확하지 않게 되는 원인이 된다. (그래서 위의 결과처럼 15가 아니라 13,14가 나오기도 한다.)

 

 

위와 같은 흐름에서 forEach는 각 요소를 순회하며 sum을 변경하지만,
병렬 스트림에서 이는 Race Condition 문제가 있어 안전하지 않다.



 

3. final int[]를 사용한 우회 방법

1. final int[] 사용 시도

  • 스트림 내부에서 외부 변수를 변경하려면, 해당 변수를 final로 선언해야 한다. 하지만 이는 실질적으로 변수를 변경할 수 없음을 의미하므로, 다른 접근 방식이 필요하다. 이를 위해 배열을 사용하여 우회할 수 있다.
public class StreamFinalExample {

    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        final int[] sum = {0};

        numbers.stream()
               .forEach(n -> sum[0] += n); // sum은 final이지만 변경 가능

        System.out.println("합계: " + sum[0]);
    }
    
}
  • sum을 final로 선언했지만, 배열의 요소는 변경 가능하다.
  • 이는 실질적인 불변성을 보장하지 않으므로 권장되지 않는다. (불변인데 내부 값은 바뀌므로 사실 이건 불변이 아니다.)
  • 또한, 병렬 스트림에서 사용할 경우 여전히 동시성 문제가 발생할 수 있다.

왜 int[]를 사용했는가?

  • 자바의 람다 표현식은 지역 변수 캡처 시 변경 불가능한 참조를 요구한다. 단순한 int는 불변이지만, 배열(int[])은 참조 자체는 final이지만 내부 요소는 변경 가능하다. 이는 우회 방법으로 사용될 수 있지만, 코드의 명확성과 안전성을 저하시킨다.

 

스트림 동작 흐름 시각화

+--------+      +---------+      +-------------+      +-------+
|  데이터  | ---> | forEach | ---> | sum[0] += n | ---> |  결과  |
+--------+      +---------+      +-------------+      +-------+
  • sum: final int[] sum = {0};으로 선언하여 변경 가능하도록 한다.
  • forEach: 각 요소를 순회하며 sum[0]을 변경한다.
  • 결과: 최종 합계를 출력한다.

 

 

이 방식은 간단하지만, 여전히 병렬 스트림에서 동시성 문제가 발생할 수 있다.


 

4. Atomic 변수를 사용한 안전한 접근

1. AtomicInteger를 사용한 합계 계산

  • Atomic 클래스는 원자적(atomic) 연산을 제공하여, 멀티스레드 환경에서 안전하게 변수를 변경할 수 있게 해 준다. 이를 통해 스트림 내부에서 가변 변수를 사용할 때 발생할 수 있는 동시성 문제를 해결할 수 있다.
public class StreamAtomicExample {

    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        AtomicInteger sum = new AtomicInteger(0);

        numbers.parallelStream()
                .forEach(sum::addAndGet); // 안전하게 합계 계산

        System.out.println("합계: " + sum.get()); // 출력: 합계: 15
    }
    
}
  • AtomicInteger는 내부적으로 원자적 연산을 제공하여, 여러 스레드가 동시에 addAndGet을 호출해도 데이터 무결성이 유지된다. 이는 병렬 스트림에서도 정확한 합계를 보장한다. 그러나, 이 방식은 코드의 복잡성을 증가시키며, 스트림의 집계 기능을 사용하는 것보다 덜 직관적이다.

 

왜 AtomicInteger를 사용했는가?

  1. 원자적 연산
    • AtomicInteger는 내부적으로 락을 사용하거나 비동기적인 방식으로 원자적 연산을 보장하여, 여러 스레드가 동시에 접근해도 데이터 무결성을 유지한다.
      .
  2. 스레드 안전성
    • 병렬 스트림에서는 여러 스레드가 동시에 연산을 수행할 수 있으므로, Atomic 클래스를 사용하면 스레드 안전한 코드 작성을 도와준다.

스트림 동작 흐름 시각화

+--------+      +----------------+      +---------+      +------------------+
|  데이터  | ---> | parallelStream | ---> | forEach | ---> | sum.addAndGet(n) | ---> | 결과 |
+--------+      +----------------+      +---------+      +------------------+
  • parallelStream: 병렬 처리 시작.
  • forEach: 각 요소를 순회하며 AtomicInteger의 addAndGet을 호출.
  • sum: AtomicInteger를 통해 안전하게 합계 계산.
  • 결과: 최종 합계를 출력.

병렬 스트림에서의 경쟁 상태 (Race Condition)

+-----------+            +-----------+             +-----------+
|  Thread 1 |            |  Thread 2 |             |  Thread 3 |
+-----------+            +-----------+             +-----------+
      |                        |                         |
      | sum.addAndGet(n)       | sum.addAndGet(n)        | sum.addAndGet(n)
      |                        |                         |
      +----------------> AtomicInteger <----+----> AtomicInteger
                        (원자적 연산 보장)            (원자적 연산 보장)
  • AtomicInteger의 addAndGet 메서드는 원자적 연산을 제공하여, 여러 스레드가 동시에 접근해도 데이터 무결성을 유지한다. 따라서 경쟁 상태가 발생하지 않으며, 정확한 합계를 보장한다.

 

 

이 방식은 병렬 스트림에서 안전하게 합계를 계산할 수 있지만,
여전히 스트림의 선언적 접근 방식을 벗어난다.


 

5. 스트림의 동작 흐름 시각화

스트림 처리 흐름

  • 스트림의 각 동작 단계가 어떻게 진행되는지 시각적으로 이해해 보자. 아래 다이어그램은 스트림이 데이터를 처리하는 전반적인 흐름을 보여준다.
데이터 소스
    |
    v
  스트림 생성
    |
    v
  중간 연산 (filter, map 등)
    |
    v
  최종 연산 (forEach, collect 등)
    |
    v
  결과 처리

병렬 스트림에서 AtomicInteger 사용 흐름 (위의 목차에서 테스트한 코드)

데이터 소스: [1, 2, 3, 4, 5]
    |
    v
스트림 생성: parallelStream()
    |
    v
중간 연산: 없음
    |
    v
최종 연산: forEach(n -> sum.addAndGet(n))
    |
    v
결과 처리: sum.get() 출력
  • 데이터 소스: 숫자 리스트 [1, 2, 3, 4, 5].
  • 스트림 생성: parallelStream()을 통해 병렬 스트림 생성.
  • 최종 연산: 각 요소를 순회하며 sum.addAndGet(n)을 호출.
  • 결과 처리: 최종 합계를 출력.

 

 

이 흐름을 통해 병렬 스트림에서 AtomicInteger를 사용하여 안전하게 합계를 계산하는 과정을 이해할 수 있다. 하지만 아직 가장 최선의 방법이 남아있다.


6. 최선의 접근 방식: 스트림의 집계 기능 활용

집계 기능 사용 전에 기존 코드를 다시 보자

  • 기존 코드는 int sum = 0; 을 선언해 두고 stream 내에서 sum의 값을 변경했었다. 그래서 오류가 발생했다.
public class StreamMutableExample {

    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        int sum = 0;

        // 스트림 내부에서 가변 변수를 변경하려고 시도
        numbers.stream()
               .forEach(n -> sum += n); // 오류 발생

        System.out.println("합계: " + sum);
    }
    
}

1. sum 메서드 사용

  • 가변 변수를 사용하는 대신, 스트림이 제공하는 집계(aggregation) 기능을 활용하면 더욱 안전하고 간결한 코드를 작성할 수 있다. 스트림의 집계 기능은 내부적으로 최적화되어 있으며, 가변 상태를 필요로 하지 않다.
public class StreamSumExample {

    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        int sum = numbers.stream()
                         .mapToInt(Integer::intValue)
                         .sum();

        System.out.println("합계: " + sum); // 출력: 합계: 15
    }
    
}
  • mapToInt는 각 요소를 int로 변환하고, sum 메서드는 이를 합산하여 최종 결과를 반환한다.
  • 가변 변수를 사용할 필요 없이 간결하게 합계를 계산할 수 있다.

스트림 처리 흐름 시각화

  • sum 메서드를 통해 값들을 합산하여 최종 결과를 반환하는 과정이다.
데이터 소스: [1, 2, 3, 4, 5]
    |
    v
스트림 생성: stream()
    |
    v
중간 연산: mapToInt(Integer::intValue)
    |
    v
최종 연산: sum()
    |
    v
결과 처리: 합계 출력
  • 데이터 소스: 숫자 리스트 [1, 2, 3, 4, 5].
  • 스트림 생성: stream()을 통해 스트림 생성.
  • 중간 연산: mapToInt로 각 요소를 int로 변환.
  • 최종 연산: sum()으로 합계 계산.
  • 결과 처리: 최종 합계를 출력.

 

2. reduce 메서드 사용

public class StreamReduceExample {

    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        int sum = numbers.stream()
                         .reduce(0, Integer::sum);

        System.out.println("합계: " + sum); // 출력: 합계: 15
    }
    
}
  • reduce 메서드는 초기값과 누산기(accumulator)를 받아 스트림의 모든 요소를 결합한다.
  • 이 방법 역시 가변 변수를 사용하지 않고 합계를 계산할 수 있다.

 

 

이 방식은 가장 간결하고 안전한 방법으로, 스트림의 강력한 집계 기능을 활용한다.
마지막으로 조금 더 심화된 상황을 가정하고 집계 함수를 사용해 보자.



7. 실전 스트림 가변 변수 사용 문제

마지막 비교를 해보자. 조금 복잡한 로직을 통해 문제점을 파악해 보자.

  • 주문 목록을 처리하여 총매출과 카테고리별 매출을 동시에 계산하려고 할 때, 가변 변수를 잘못 사용하면 문제가 발생한다.
import java.util.Arrays;
import java.util.List;

class Order {
    
    String category;
    int amount;

    public Order(String category, int amount) {
        this.category = category;
        this.amount = amount;
    }

    public String getCategory() {
        return category;
    }

    public int getAmount() {
        return amount;
    }
    
}
public class StreamMutableMistakeExample {
    
    public static void main(String[] args) {
        List<Order> orders = Arrays.asList(
            new Order("Electronics", 1200),
            new Order("Books", 300),
            new Order("Electronics", 800),
            new Order("Clothing", 500),
            new Order("Books", 150)
        );

        int totalSales = 0;
        // 카테고리별 매출을 저장할 가변 맵
        Map<String, Integer> categorySales = new HashMap<>();

        // 스트림을 사용하여 총 매출과 카테고리별 매출 계산 시도
        orders.stream().forEach(order -> {
            totalSales += order.getAmount(); // 총 매출 누적
            categorySales.put(order.getCategory(),
                categorySales.getOrDefault(order.getCategory(), 0) + order.getAmount()); // 카테고리별 매출 누적
        });

        System.out.println("총 매출: " + totalSales);
        System.out.println("카테고리별 매출: " + categorySales);
    }
    
}

컴파일 오류 발생

  • 위 코드에서 totalSales += order.getAmount(); 부분을 실행하면 다음과 같은 컴파일 오류가 발생한다.
java: local variables referenced from a lambda expression must be final or effectively final
  • 자바의 람다 표현식은 지역 변수 캡처 시 해당 변수가 final이거나 효과적으로 final이어야 한다. 이는 가변 변수를 사용하여 예기치 않은 부작용을 방지하기 위한 것이다.

스트림의 집계 활용: Collectors.groupingBy와 Collectors.summingInt 사용

  • 스트림의 집계를 활용하여 문제를 해결해 보자.
public class StreamMutableMistakeSolveExample {
    
    public static void main(String[] args) {
        List<Order> orders = Arrays.asList(
                new Order("Electronics", 1200),
                new Order("Books", 300),
                new Order("Electronics", 800),
                new Order("Clothing", 500),
                new Order("Books", 150)
        );

        // 총 매출 계산
        int totalSales = orders.stream()
                .mapToInt(Order::getAmount)
                .sum();

        // 카테고리별 매출 계산
        Map<String, Integer> categorySales = orders.stream()
                .collect(Collectors.groupingBy(
                        Order::getCategory,
                        Collectors.summingInt(Order::getAmount)
                ));

        System.out.println("총 매출: " + totalSales);
        System.out.println("카테고리별 매출: " + categorySales);
    }

}
  • 총매출 계산: mapToInt와 sum을 사용하여 총매출을 계산한다.
  • 카테고리별 매출 계산: Collectors.groupingBy와 Collectors.summingInt를 사용하여 카테고리별 매출을 계산한다.
  • 가변 변수 사용 불필요: 모든 연산이 스트림 내부에서 선언적으로 처리된다.

출력

  • 이전과는 달리 문제없이 결괏값이 출력된다.
총 매출: 2950
카테고리별 매출: {Clothing=500, Electronics=2000, Books=450}

 

스트림 동작 흐름 시각화

데이터 소스: [Order1, Order2, Order3, Order4, Order5]
    |
    v
스트림 생성: stream()
    |
    v
중간 연산: groupingBy(Order::getCategory, summingInt(Order::getAmount))
    |
    v
최종 연산: collect()
    |
    v
결과 처리: 카테고리별 매출 맵 생성
  • 데이터 소스: 주문 리스트 [Order1, Order2, Order3, Order4, Order5].
  • 스트림 생성: stream()을 통해 스트림 생성.
  • 중간 연산: groupingBy와 summingInt를 사용하여 카테고리별 매출 합산.
  • 최종 연산: collect()를 사용하여 결과를 맵으로 수집.
  • 결과 처리: 최종 매출 정보를 출력.

 

 

반응형