1. forEach 메서드: 요소에 대한 최종 동작
세부로 들어가기 전에 스트림의 터미널 연산과 중간 연산이 무엇인지 다시 한번 짚고 넘어가겠습니다.
- 중간 연산(예: filter, map, distinct, peek) — 새로운 스트림을 반환하며, 보통 터미널 연산이 호출되기 전에는 실행되지 않습니다.
- 터미널 연산(예: forEach, collect, count, anyMatch) — 스트림 요소의 처리를 실제로 수행하고 결과를 반환합니다(또는 forEach처럼 아무 것도 반환하지 않을 수도 있습니다).
터미널 연산 이후 스트림은 닫힌 것으로 간주되어 더 이상 다른 연산을 적용할 수 없습니다. 이미 먹어 버린 아이스크림을 더 먹으려는 것과 같습니다 — 불가능합니다. 스트림은 이미 “소비”되었습니다.
이제 부수 효과를 다룰 때 중요한 두 메서드인 forEach와 peek를 살펴보겠습니다.
forEach는 무엇을 하나요?
forEach는 스트림의 터미널 연산으로, 스트림의 각 요소에 대해 지정된 동작을 수행합니다. 보통 화면 출력, 로그 기록, 통계 집계 등과 같은 부수 효과(side effects)에 사용됩니다.
메서드 시그니처:
void forEach(Consumer<? super T> action)
Consumer<T>는 하나의 인수를 받고 아무 값도 반환하지 않는 함수형 인터페이스입니다(예: System.out::println).
예: 목록의 모든 요소 출력
사용자 이름 목록이 있다고 가정해 봅시다:
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
Stream API를 사용하면 모든 사용자를 화면에 출력하는 것은 아주 간단합니다:
users.stream().forEach(System.out::println);
결과:
Anna
Boris
Alex
Alina
Dmitry
람다 표현식을 사용할 수도 있습니다:
users.stream().forEach(name -> System.out.println("사용자: " + name));
결과:
사용자: Anna
사용자: Boris
사용자: Alex
사용자: Alina
사용자: Dmitry
중요: forEach는 스트림을 종료합니다
forEach를 호출하면 스트림이 “닫힙니다”. 이후에는 체인을 계속할 수 없습니다:
users.stream()
.filter(name -> name.startsWith("A"))
.forEach(System.out::println)
.map(String::toUpperCase); // 오류! 스트림은 이미 닫혔습니다.
forEach 이후에 무언가를 호출하려 하면 컴파일 오류가 발생합니다. 터미널 연산은 void를 반환할 뿐 새로운 스트림을 반환하지 않기 때문입니다.
2. peek 메서드: 들여다보기만 하고 간섭하지 않기
peek는 중간 연산입니다. 처리의 특정 단계에서 각 요소에 대해 동작을 수행하되, 요소 자체를 변경하지 않고 스트림을 종료하지 않습니다.
메서드 시그니처:
Stream<T> peek(Consumer<? super T> action)
- peek는 새로운 스트림을 반환하며, 각 요소에 대해 action 동작이 수행됩니다.
- 주로 디버깅, 로깅, 스트림 상태 모니터링에 사용됩니다.
예: 필터링 후 로깅
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
List<Integer> nameLengths = users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("필터 통과: " + name))
.map(String::length)
.collect(Collectors.toList());
콘솔 출력:
필터 통과: Anna
필터 통과: Alex
필터 통과: Alina
nameLengths의 내용:
[4, 4, 5]
peek를 사용하기 좋은 곳
- 연산 체인을 디버깅할 때: 각 단계에서 무엇이 일어나는지 확인.
- 통계 수집(예: 요소 개수 세기).
- 중간 단계의 데이터 로깅.
중요: peek는 스트림 요소를 변경하는 데 사용해서는 안 됩니다. 변환에는 map이 있습니다. peek는 “훔쳐보기”이지 “개입하기”가 아닙니다.
3. forEach vs peek: 무엇이 다른가?
| 메서드 | 연산 유형 | 적용 시점 | 체인 계속 가능? | 적합한 용도 |
|---|---|---|---|---|
|
터미널 | 스트림 처리의 맨 마지막 | 아니요 | 최종 동작(출력, 로깅, DB 기록) |
|
중간 | 연산 체인의 중간 | 예 | 디버깅, 중간 로깅, 카운팅 |
예: 사용상의 차이
// forEach 예시
users.stream()
.filter(name -> name.startsWith("A"))
.map(String::toUpperCase)
.forEach(System.out::println); // 여기서 스트림이 종료됩니다
// peek 예시
users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("필터 통과: " + name))
.map(String::toUpperCase)
.collect(Collectors.toList()); // 체인을 계속할 수 있습니다
기억해 둘 점
- forEach는 돌아올 수 없는 지점입니다. 그 이후에는 스트림으로 아무 것도 할 수 없습니다.
- peek는 터미널 연산이 호출되지 않으면 동작 실행이 보장되지 않습니다. 중간 연산만 나열하면 아무 일도 일어나지 않습니다.
4. 헷갈리기 쉬운 점: forEach가 항상 최선은 아니다!
컬렉션을 변경하는 데 forEach를 쓰면 안 되는 이유
많은 초보자가 forEach로 컬렉션의 요소나 컬렉션 자체(예: 요소 삭제)를 변경하려고 합니다. 그러나 이는 좋지 않은 관행입니다. 스트림은 원본 컬렉션을 수정하도록 설계되지 않았습니다.
잘못된 사용 예:
List<String> names = new ArrayList<>(List.of("Anna", "Boris", "Alex"));
names.stream().forEach(name -> {
if (name.startsWith("A")) {
names.remove(name); // ConcurrentModificationException이 발생할 수 있습니다!
}
});
결과: 런타임 오류 — 스트림 순회 중에는 컬렉션을 변경할 수 없습니다(ConcurrentModificationException).
그렇다면 forEach는 언제 써야 할까?
- 화면 출력(예: 보고서 출력).
- 로그 기록.
- 외부 서비스 호출(예: 이메일 발송).
- 통계 수집(예: 카운터 증가).
5. 다시 한 번 peek: 오직 디버깅용!
peek로 요소를 변경하고 싶은 유혹이 큽니다. 예를 들어 사용자 나이를 늘리는 코드:
users.stream()
.peek(user -> user.setAge(user.getAge() + 1)) // 나쁨!
.collect(Collectors.toList());
왜 나쁠까?
- Stream API의 선언적 스타일과 순수성을 해칩니다.
- 이런 코드는 유지보수와 테스트가 어렵습니다.
- 중간 연산에서의 부수 효과는 예상치 못한 버그로 이어질 수 있습니다.
데이터 변환에는 map을 사용하는 편이 좋습니다:
List<User> olderUsers = users.stream()
.map(user -> new User(user.getName(), user.getAge() + 1))
.collect(Collectors.toList());
도식: forEach와 peek의 차이
users.stream()
.filter(...) // 중간 연산
.peek(...) // 중간 연산, "엿보기"
.map(...) // 중간 연산
.forEach(...) // 터미널 연산, "동작 수행"
설명:
— forEach 이전의 모든 것은 조합·재배열·추가가 가능합니다.
— forEach 이후에는 스트림이 닫힙니다.
6. forEach와 peek 사용 시 흔한 실수
오류 №1: peek로 데이터를 변경한다. peek는 관찰용이지 변경용이 아닙니다. 변환에는 map을 사용하세요.
오류 №2: peek가 항상 실행된다고 기대한다. peek는 그 뒤에 터미널 연산(collect, forEach, count 등)이 있을 때에만 실행됩니다. 터미널 연산이 없으면 아무 일도 일어나지 않습니다.
오류 №3: forEach 이후에 스트림 체인을 계속하려 한다. forEach는 터미널 연산입니다. 이후에는 다른 스트림 메서드를 호출할 수 없습니다.
오류 №4: forEach 내부에서 컬렉션을 수정한다. forEach로 순회하는 동안 원본 컬렉션을 변경(삭제 또는 추가)하는 것은 ConcurrentModificationException으로 가는 지름길입니다.
오류 №5: 결과 수집에 forEach 대신 collect를 사용하지 않는다. 요소를 새로운 컬렉션으로 모으려면 collect(Collectors.toList())를 사용하고, forEach로 수동 추가를 하지 마세요. 이는 선언적 스타일을 해칠 뿐 아니라 멀티스레드 시나리오에서 오류로 이어질 수 있습니다.
GO TO FULL VERSION