- Published on
Java Stream에서 ConcurrentModificationException 피하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 애플리케이션에서 List나 Map을 Stream으로 처리하다가, 중간에 요소를 추가/삭제/갱신하는 순간 ConcurrentModificationException을 맞는 경우가 많습니다. 특히 “Stream으로 깔끔하게 리팩터링”하려던 코드에서 자주 터지는데, 원인은 단순합니다. Stream은 컬렉션을 순회(iteration)하는 동안 컬렉션의 구조적 변경(structural modification)을 허용하지 않는 경우가 많기 때문입니다.
이 글에서는 예외가 발생하는 전형적인 패턴을 먼저 보고, 그 다음에 의도(필터링, 삭제, 갱신, 병렬 처리) 별로 안전한 해결책을 코드 중심으로 정리합니다.
또한 운영 환경에서 이런 이슈는 예외 로그 폭주로 이어질 수 있으니, 장애 대응 관점에서 로그/리소스 관리도 같이 점검해보면 좋습니다. 관련해서는 journalctl 로그 폭주로 디스크 찰 때 10분 해결도 참고할 만합니다.
ConcurrentModificationException이 발생하는 이유
대부분의 JDK 컬렉션(예: ArrayList, HashMap)은 fail-fast iterator를 사용합니다. 컬렉션이 생성한 iterator(또는 Stream 내부에서 사용하는 spliterator)가 순회 중인데, 컬렉션이 구조적으로 변경되면 내부의 변경 카운터(modCount)가 달라지고, 다음 접근 시점에 예외를 던집니다.
여기서 “구조적 변경”은 보통 요소의 추가/삭제를 의미합니다. 단, Map에서 엔트리 삭제, List에서 remove 같은 작업은 구조적 변경이므로 위험합니다.
주의할 점:
- 단일 스레드여도 발생합니다. “Concurrent”라는 이름 때문에 멀티스레드에서만 난다고 착각하기 쉽습니다.
- Stream 파이프라인 중간에서 외부 컬렉션을 수정하면, 순회 시점(terminal operation 수행 시점)에 예외가 터질 수 있습니다.
가장 흔한 실패 패턴
1) Stream 순회 중 같은 컬렉션을 삭제
List<String> users = new ArrayList<>(List.of("a", "b", "c"));
users.stream()
.filter(u -> u.startsWith("b"))
.forEach(users::remove); // 위험
위 코드는 forEach에서 users를 직접 수정하므로, 대부분 ConcurrentModificationException이 발생합니다.
2) Stream에서 peek로 부수효과(side effect) 내기
List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4));
nums.stream()
.peek(n -> {
if (n % 2 == 0) nums.remove(n); // 위험
})
.count();
peek는 디버깅 용도로 설계된 연산에 가깝고, 부수효과를 넣기 시작하면 예외뿐 아니라 결과도 예측하기 어려워집니다.
3) parallelStream()에서 비동기적으로 공유 컬렉션 수정
List<Integer> nums = IntStream.range(0, 10_000).boxed().toList();
List<Integer> evens = new ArrayList<>();
nums.parallelStream()
.filter(n -> n % 2 == 0)
.forEach(evens::add); // 데이터 레이스 + 잠재적 예외
여기서는 ConcurrentModificationException이 아니라도 데이터 레이스로 인해 결과가 깨질 수 있습니다. 병렬에서는 특히 “공유 가변 상태”를 피해야 합니다.
안전한 해결책 1: 삭제/필터링은 filter + collect로 새 컬렉션 만들기
원본을 수정하려는 의도가 “조건에 맞는 것만 남기기”라면, 가장 깔끔한 방법은 새 컬렉션을 만들어 교체하는 것입니다.
List<String> users = new ArrayList<>(List.of("a", "b", "c"));
List<String> filtered = users.stream()
.filter(u -> !u.startsWith("b"))
.collect(java.util.stream.Collectors.toList());
users = filtered;
장점:
- 예외가 발생하지 않습니다.
- 함수형 스타일에 맞고, 읽기 쉽습니다.
단점:
- 컬렉션이 매우 크면 추가 메모리가 듭니다(대부분은 허용 가능한 비용).
Map도 동일하게 collect로 재구성
Map<String, Integer> scores = new HashMap<>();
scores.put("a", 10);
scores.put("b", 20);
scores.put("c", 30);
Map<String, Integer> kept = scores.entrySet().stream()
.filter(e -> e.getValue() >= 20)
.collect(java.util.stream.Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue
));
scores = kept;
안전한 해결책 2: “원본에서 제거”가 목적이면 removeIf 사용
리스트에서 조건에 맞는 요소를 제거하려면 Stream보다 removeIf가 더 직접적이고 안전합니다.
List<String> users = new ArrayList<>(List.of("a", "b", "c"));
users.removeIf(u -> u.startsWith("b"));
이 방식은 컬렉션 구현체가 제공하는 제거 로직을 사용하며, Stream 순회와 수정이 섞이지 않습니다.
Map은 entrySet().removeIf 패턴이 유용합니다.
Map<String, Integer> scores = new HashMap<>();
scores.put("a", 10);
scores.put("b", 20);
scores.entrySet().removeIf(e -> e.getValue() < 15);
안전한 해결책 3: 순회하며 삭제해야 하면 Iterator.remove() 사용
“조건을 보면서 하나씩 지우고, 추가 로직도 같이 수행” 같은 상황에서는 iterator 기반 제거가 정석입니다.
List<String> users = new ArrayList<>(List.of("a", "b", "c"));
for (Iterator<String> it = users.iterator(); it.hasNext(); ) {
String u = it.next();
if (u.startsWith("b")) {
it.remove(); // 안전
}
}
핵심은 컬렉션의 remove가 아니라 iterator의 remove를 호출하는 것입니다.
안전한 해결책 4: “수정”이라면 구조 변경을 피하고 값만 바꾸기
구조 변경(추가/삭제)이 아니라 “요소 내부 상태 변경”은 ConcurrentModificationException을 직접 유발하지는 않을 수 있습니다. 다만, 불변 객체를 쓰는 경우가 많아 실제로는 새 객체로 치환해야 합니다.
예: 객체를 새로 만들어 매핑
record User(String name, boolean active) {}
List<User> users = List.of(
new User("a", true),
new User("b", false)
);
List<User> activated = users.stream()
.map(u -> u.active() ? u : new User(u.name(), true))
.toList();
이 방식은 원본을 건드리지 않고 새 리스트를 만들어 안전합니다.
안전한 해결책 5: 병렬 처리에서는 공유 가변 컬렉션을 없애기
5-1) collect를 사용해 병렬 안전하게 모으기
List<Integer> nums = IntStream.range(0, 10_000).boxed().toList();
List<Integer> evens = nums.parallelStream()
.filter(n -> n % 2 == 0)
.collect(java.util.stream.Collectors.toList());
Collectors.toList()는 내부적으로 병렬 수집을 지원합니다(구현 상세는 JDK에 따라 다르지만, 핵심은 외부 공유 리스트에 add하지 않는다는 점입니다).
5-2) 정말 공유 컬렉션이 필요하면 동시성 컬렉션 고려
List<Integer> nums = IntStream.range(0, 10_000).boxed().toList();
List<Integer> evens = new java.util.concurrent.CopyOnWriteArrayList<>();
nums.parallelStream()
.filter(n -> n % 2 == 0)
.forEach(evens::add);
CopyOnWriteArrayList는 읽기 위주, 쓰기 적은 상황에 적합합니다. 쓰기가 많으면 비용이 큽니다.
ConcurrentHashMap도 자주 쓰입니다.
Map<String, Integer> counts = new java.util.concurrent.ConcurrentHashMap<>();
List<String> words = List.of("a", "b", "a");
words.parallelStream().forEach(w ->
counts.merge(w, 1, Integer::sum)
);
“Stream으로 삭제”를 꼭 해야 한다면: 복사본을 순회하기
레거시 코드에서 “원본 리스트에서 제거”를 해야 하는데, 구조상 Stream을 유지해야 한다면 최소한 순회 대상과 수정 대상을 분리하세요.
List<String> users = new ArrayList<>(List.of("a", "b", "c"));
new ArrayList<>(users).stream() // 복사본을 순회
.filter(u -> u.startsWith("b"))
.forEach(users::remove); // 원본 수정
이 방식은 예외는 피하지만, 성능/가독성 측면에서 removeIf나 collect보다 불리할 때가 많습니다. 또한 원본이 매우 크면 복사 비용이 큽니다.
디버깅 팁: 예외가 “어디서” 터지는지 찾기
Stream은 지연 평가(lazy evaluation)라서, 중간 연산에서 수정해도 실제 예외는 terminal operation(forEach, collect, count 등)에서 터질 수 있습니다. 따라서 스택 트레이스만 보고 “여기서 수정했나?”가 바로 안 보일 수 있습니다.
권장 접근:
peek에 로그를 넣기보다는, 중간 단계 결과를toList()로 끊어서 확인- 원본 컬렉션을 수정하는 코드가 있는지
forEach,peek, 람다 내부를 집중 점검 - 병렬 스트림이면 공유 상태(외부 리스트, 외부 맵, 외부 카운터)를 먼저 제거
운영에서 예외가 반복되면 로그가 빠르게 쌓여 디스크를 압박할 수 있습니다. 이런 상황에서는 journalctl 로그 폭주로 디스크 찰 때 10분 해결처럼 로그 로테이션/보관 정책을 함께 점검하는 게 안전합니다.
선택 가이드: 상황별 추천
- 조건에 맞는 것만 남기기:
stream().filter(...).toList()또는collect(toList()) - 조건에 맞는 것 삭제하기:
removeIf - 순회 중 삭제 + 복잡한 로직:
Iterator.remove() - 병렬 처리 결과 모으기: 외부 리스트
add금지,collect사용 - 멀티스레드 공유 자료구조 필요:
ConcurrentHashMap, 상황에 따라CopyOnWriteArrayList
마무리
ConcurrentModificationException은 “Stream이 나쁘다”기보다는 순회와 구조 변경을 섞었을 때 컬렉션이 안전장치로 터뜨리는 신호에 가깝습니다. Stream을 쓸 때는 원칙적으로
- 부수효과를 줄이고
- 원본을 직접 수정하지 말고
- 필요하면 컬렉션 API(
removeIf,Iterator.remove)를 사용하고 - 병렬에서는 공유 가변 상태를 제거
이 네 가지를 지키면 대부분의 케이스를 깔끔하게 해결할 수 있습니다.