JAVA/개념

Collection.forEach vs Stream.forEach

개발자성장기 2024. 11. 20. 13:59
반응형

 

 

Stream.forEach가 종단 연산이라는 것을 공부하다보니 여기까지 왔다.  

 

 

1. Collection.forEach란?

`collection.forEach`는 `Iterable` 인터페이스에서 제공하는 메서드로, 컬렉션의 모든 요소를 순회하면서 하나씩 작업을 수행합니다.

 

특징

- 내부 반복자(internal iterator)를 사용합니다.

- 순차적으로 요소를 처리합니다.

- 기본적으로 데이터 변경에 적합합니다.

 

public class Main {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));

        // Collection.forEach 사용
        names.forEach(name -> System.out.println("Hello, " + name + "!"));
    }
}

 

 

2. Stream.forEach란?

Stream.forEach는 Stream API에서 제공하는 종단 연산으로, 스트림의 모든 요소를 하나씩 처리합니다.

 

특징

  • 지연 실행(lazy evaluation): 스트림에서 중간 연산을 포함한 모든 작업은 forEach와 같은 종단 연산이 호출될 때 실행됩니다.
  • 병렬 스트림(parallel stream)에서 사용하면 병렬적으로 요소를 처리할 수 있습니다.
  • 데이터 변경에는 적합하지 않음: 데이터를 조회하거나 소비할 때 주로 사용합니다.

 

3. 병렬 처리의 차이

Stream.forEach는 병렬 처리에서 달라진다!

Stream.forEach를 병렬 스트림과 함께 사용할 때, 작업이 병렬적으로 처리됩니다. 이때 순서가 보장되지 않을 수 있습니다.

 

    List<String> names = List.of("A", "B", "C", "D", "E");

        // Collection.forEach 사용
        names.forEach(name -> System.out.println(Thread.currentThread().getName() + " processing " + name));
        System.out.println("------------");
        // 병렬 스트림에서 Stream.forEach 사용
        names.parallelStream().forEach(name -> System.out.println(Thread.currentThread().getName() + " processing " + name));
    }

 

코드를 여러 번 실행하면 `Collection.forEach()`가 삽입 순서대로 항목을 처리하는 반면,  `stream.parallelStream().forEach()`는 실행할 때마다 다른 결과를 생성하는 것을 볼 수 있습니다.

 

 

❗️만약 병렬처리를 하면서 순서를 유지하고 싶다면 `forEachOrdered`를 사용하시면 됩니다.

names.parallelStream()
	.forEachOrdered(name -> System.out.println(Thread.currentThread().getName() + " processing " + name));

 

4.컬렉션 수정

많은 컬렉션(ex: ArrayList or HashSet)은 반복하는 동안 구조적으로 수정되어서는 안 됩니다. 반복하는 동안 요소가 제거되거나 추가되면 `ConcurrentModification`예외가 발생합니다.

 

더욱이 컬렉션은 빠르게 실패하도록 설계되어 있기 때문에 수정이 있으면 즉시 예외가 발생합니다.

 

마찬가지로, 스트림 파이프라인을 실행하는 동안 요소를 추가하거나 제거하면 `ConcurrentModification`예외가  발생합니다. 그러나 이 예외는 나중에 throw됩니다.

 

먼저  `Collection.forEach()`먼저 보겠습니다.

public static void main(String[] args) {
        List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));

        Consumer<String> removeElement = s -> {
            System.out.println(s + " " + list.size());
            if (s != null && s.equals("A")) {
                list.remove("D");
            }
        };

        list.forEach(removeElement);
    }

 

`Collection.forEach()`는 실패 속도가 빠르므로 반복을 멈추고 다음 요소가 처리되기 전에 예외가 발생합니다 .

 

그럼 이번에는 위 코드에서 `stream.forEach()`를 사용해보도록 하겠습니다.

list.stream().forEach(removeElement);

 

forEach가 끝나고 예외를 발생시킵니다.

 

왜 예외가 나중에 발생할까?

stream().forEach에서 예외가 스트림 처리 이후 발생하는 이유는 다음과 같습니다

  1. 스트림의 비동기적 처리 가능성:
    • 스트림은 기본적으로 병렬 처리가 가능하도록 설계되었습니다. 예를 들어, `parallelStream()`을 사용하면 여러 스레드에서 동시에 작업이 수행될 수 있습니다.
    • `synchronized` 키워드가 없더라도 각 스레드는 독립적으로 데이터를 처리할 수 있지만, 공유된 컬렉션에서 구조적 변경이 발생하면 스플리터의 무결성이 깨져 나중에 검증 시 문제가 됩니다.
  2. Spliterator의 설계:
    • 스트림은 `Spliterator`를 사용하여 데이터를 분할하고 처리합니다. 이 과정에서 `Spliterator`는 컬렉션의 상태를 추적합니다.
    • 스트림이 종료된 후, 최종적으로 컬렉션의 변경 여부를 확인합니다. 이때 변경 사항이 발견되면 예외가 발생합니다.
  3. 스트림의 지연 평가:
    • 스트림은 결과를 요구받기 전까지 실제로 작업을 수행하지 않습니다.
    • 따라서 스트림의 모든 작업이 끝난 후에야 컬렉션 변경 여부를 검증합니다.

 

 

 

 

핵심 정리

  1. forEach의 동작:
    • List.forEach는 Iterator를 사용하여 컬렉션의 요소를 순차적으로 처리합니다.
    • 순회 중 컬렉션이 수정되면 즉시 `ConcurrentModificationException`을 발생시킵니다.
    • 컬렉션의 변경 여부는 `modCount`라는 필드를 통해 확인됩니다. `modCount`가 변경되면 즉시 예외가 발생합니다.
  2. stream().forEach의 동작:
    • stream().forEach는 스트림의 각 요소를 처리하며, 내부적으로 `Spliterator`를 사용합니다.
    • 스트림이 한 번에 처리된 후에 변경 여부를 확인합니다. 따라서 모든 요소를 처리한 다음에 예외가 발생할 수 있습니다.
    • 이 동작은 스트림이 지연 평가(lazy evaluation)로 동작하기 때문에 가능합니다.
    • 병렬로 처리할 때 순서가 보장되지 않습니다.

 

 

 

 

 

 

 

📚 Reference

- https://www.baeldung.com/java-collection-stream-foreach

- https://tecoble.techcourse.co.kr/post/2020-09-30-collection-stream-for-each/

반응형