Published on

Java Stream에서 ConcurrentModificationException 피하기

Authors

서버 애플리케이션에서 ListMap을 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 순회와 수정이 섞이지 않습니다.

MapentrySet().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);     // 원본 수정

이 방식은 예외는 피하지만, 성능/가독성 측면에서 removeIfcollect보다 불리할 때가 많습니다. 또한 원본이 매우 크면 복사 비용이 큽니다.

디버깅 팁: 예외가 “어디서” 터지는지 찾기

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)를 사용하고
  • 병렬에서는 공유 가변 상태를 제거

이 네 가지를 지키면 대부분의 케이스를 깔끔하게 해결할 수 있습니다.