본문 바로가기
web

[JAVA] Collection forEach vs Stream forEach

by 뽀리님 2024. 6. 11.

Java8 에서 Stream이 도입된지는 꽤 오래전 일이다.

 

개발하면서 문득 궁금해서 검색하다가 정리해보는 글.

Collection.forEach 메서드로 반복할 때와 Stream.forEach 메서드로 반복할 때는 무슨 차이가 있을까?


 

✔️ 동시성 문제

Collection.forEach의 경우엔 수정을 감지한 즉시 ConcurrentModificationException을 던지며 프로그램을 멈춘다. ConcurrentModificationException이란 한 오브젝트에 대해 허가되지 않은 변경이 동시적으로 이루어질 때 발생한다. 대표적으로 Collection이 반복되는 동안 Collection을 수정할 때 발생한다.아래의 코드는 List의 element가 짝수이면 remove 하는 Consumer를 forEach로 돌린 것이다. 코드와 테스트 결과를 보자.

 

 

 

✓ Collection forEach

@Test
public void test() {
    List<Integer> nums = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6));
    Consumer<Integer> removeIfEven = num -> {
        System.out.println(num);
        if (num % 2 == 0) {
            nums.remove(num);
        }
    };
    //collection for-each
    assertThatThrownBy(() -> nums.forEach(removeIfEven))
            .isInstanceOf(ConcurrentModificationException.class);
}

 

컬렉션 결과

  

 

Collection.forEach의 경우에는 fail-fast 이므로 반복을 중지하고 다음 요소가 처리되기 전에 예외를 확인한다.

 

 

List의 첫 번째 짝수 2가 지워지자 바로 ConcurrentModificationException이 발생하는 것을 볼 수 있다. 그렇다면 Stream.forEach의 경우엔 어떨까?

 

✓ Stream() forEach

@Test
public void streamForeachTest() {
    List<Integer> nums = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6));
    Consumer<Integer> removeIfEven = num -> {
        System.out.println(num);
        if (num % 2 == 0) {
            nums.remove(num);
        }
    };
    // stream API for-each
    assertThatThrownBy(() -> nums.stream().forEach(removeIfEven))
            .isInstanceOf(NullPointerException.class);
}

스트림 결과

 

Collection.forEach처럼 Collection이 수정되자마자 예외를 던지는 것이 아니라 무조건 리스트를 끝까지 돌고 예외를 던진다. 또 던지는 예외가 ConcurrentModificationException이 아니라 NullPointerException이라는 차이점이 있다.

 

 

// Collection Iterable.java
default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}

// Stream.java
void forEach(Consumer<? super T> action);

 

Collection.forEach는 따로 객체를 생성하지 않고 forEach 메서드를 호출한다. forEach 메서드는 Iterable 인터페이스의 default 메서드인데, Collection 인터페이스에서 Iterable 인터페이스를 상속하고 있기에 바로 호출할 수 있는 것이다.

반면에 Stream.forEach는 Collection 인터페이스의 default 메서드 stream()으로 Stream 객체를 생성해야만 forEach를 호출할 수 있다.



위의 예제처럼 단순 반복이 목적이라면 Stream.forEach는 stream()으로 생성된 Stream 객체가 버려지는 오버헤드가 있기에, filter, map 등의 Stream 기능들과 함께 사용할 때만 Stream.forEach를 사용하고 나머지의 경우엔 Collection.forEach를 쓰는 것이 좋아 보인다.

 

 

@Override
    public void forEach(Consumer<? super E> consumer) {
        synchronized (mutex) {c.forEach(consumer);}
    }

    @Override
    public Spliterator<E> spliterator() {
        return c.spliterator(); // Must be manually synched by user!
    }


Collection.forEach는 일반적으로 해당 컬렉션의 Iterator를 사용하고 Stream.forEach는 해당 컬렉션의 spliterator를 사용한다. Collections.java에서 보면 아래의 코드처럼 Collection.forEach에는 synchronized 키워드가 붙어있고 Stream.forEach를 위해 필요한 spliterator 메서드는 안붙어있는 것을 확인할 수 있다.

 

결론적으로 Collection.forEach는 락이 걸려있기에 멀티쓰레드에서 더 안전하다. 

반면에 Stream.forEach는 반복 도중에 다른 쓰레드에 의해 수정될 수 있고, 무조건 요소의 끝까지 반복을 돌게 된다. 

이 과정에서 일관성 없는 동작이 발생하고 예상치 못한 에러가 발생할 확률이 높다.

 

 

✔️ 결론

결국 반복을 위해 존재하는 Collection.forEach와 Stream.forEach의 차이는 정말 미묘하다.

단지 Stream.forEach는 Stream의 컨셉에 맞게 병렬 프로그래밍(parallelStream)에 특화된 반복을 위해 있는 것뿐이다.

일반적인 반복의 경우엔 thread-safe 한 Collection.forEach를 쓰는게 좋아보인다.

 

 

 

 

 

참고:

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