Web/Java

[Java] for-loop 와 stream.forEach() 는 다르다.

Java8 에서 Stream이 도입되었습니다.

 

Stream으로 인해 기존의 코드를 더 깔끔하고 가독성있게 바꿀 수 있게 되었습니다.

// for-loop
for (String item : list) {
    System.out.println(item);
}

// stream forEach
list.stream().forEach(System.out::println);

 

 

하지만 모든 for-loop를 Stream의 forEach로 바꿔도 될까요?

 

" 아닙니다. "

 

 

 

크게 세가지로 모든 for-loop를 forEach로 바꾸지 말아야 할 이유를 소개합니다.

 

 

 

첫번째로 확인해야 할 것은 "굳이 forEach를 사용해야 하는가" 입니다.

 

list.stream().forEach(item -> {
    if (item.equals("apple")) {
        item = item.replace("a", "A");
    }
});

위와 같이 Stream의 forEach를 사용한 코드가 있습니다.

 

 

위의 코드 처럼 조건을 확인하는 기능은 Stream의 filter를 이용하면 됩니다.

list.stream().filter(item -> item.equals("apple"))
    .forEach(item -> item = item.replace("a", "A"));

 

이미 존재하는 Stream의 API를 사용하지 않고 forEach로만 해결하는 방식은 '덜' 스트림 답습니다.

 

 

 

두번째로는 Stream의 forEach는 모든 요소를 돌기 때문에 비효율적일 수 있습니다.

list.stream().forEach(item -> {
    if (item.equals("apple")) {
        item = item.replace("a", "A");
        return;
    }
});


//for-loop로 짠 경우
for (String item : list) {
    if (item.equals("apple")) {
        item = item.replace("a", "A");
        break;
    }
}

 

만약에 조건이 만족되면 로직을 수행하고 해당 루프를 종료하고 싶다고 생각해 보겠습니다.

 

Stream의 forEach로 작성한 코드는 각 수행에 대해 다음 수행을 막을 뿐, 결국 모든 요소의 조건을 확인한 후에야 종료합니다.

반면에, for-loop로 짠 경우 break; 로 인해 다른 요소의 조건검사를 하지 않고 바로 수행이 끝납니다.

 

 

 

 

 

세번째로는 forEach 안의 람다에서 상태를 수정하지 말아야 합니다.

스트림 병렬화에 대한 공식 문서의 Side-effects 항목을 참고하면, forEach 내부에 로직이 있으면 동시성 보장이 어려워지고 가독성이 떨어질 위험이 있다고 말합니다.

 

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

@Test
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);
        }
    };
    assertThatThrownBy(() -> nums.forEach(removeIfEven))
        .isInstanceOf(ConcurrentModificationException.class);
}

 

 

 

 

반면에 strem().forEach()는 어떻게 되는지 확인해보겠습니다.

@Test
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);
        }
    };
    assertThatThrownBy(() -> nums.stream().forEach(removeIfEven))
        .isInstanceOf(NullPointerException.class);
}

요소가 삭제되었는데도 끝까지 돌다가 결국 null을 만나 NullPointerException이 발생하는 것을 볼 수 있습니다.

 

추가로, Collections.java에서 forEach에는 synchronized가 붙어있지만 stream()에는 붙어있지 않습니다.

 

결론적으로 말하면, thread-safe하지 않은 stream().forEach()는 반복 도중에 다른 쓰레드에 의해 수정될 수 있고 바로 예외가 발생하지 않고 요소를 끝까지 반복하기도 합니다.

 

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

 

 

 

 

결론

stream().forEach()에서는 객체의 데이터를 다루지 말고 출력용으로만 사용하자.

list.stream().forEach(System.out::println);

 

 

 

 

 

 

 

참고자료

https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html#StreamOps

https://tecoble.techcourse.co.kr/post/2020-05-14-foreach-vs-forloop/

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

https://dundung.tistory.com/247