Published on

Java Stream에서 NPE·ConcurrentModification 한방에 끝내기

Authors

서버 코드에서 Stream을 쓰기 시작하면 생산성은 올라가지만, 운영에서 유독 자주 만나는 예외가 두 가지 있습니다. NullPointerException(NPE)과 ConcurrentModificationException(CME)입니다. 둘 다 “스트림이 나빠서”가 아니라, 스트림이 컬렉션/객체 그래프를 더 공격적으로 순회하고, 개발자가 부수효과(side effect) 를 넣기 쉬운 구조라서 표면화되는 경우가 많습니다.

이 글은 두 예외를 재현 코드로 확실히 이해하고, 실무에서 바로 적용 가능한 방지 패턴을 정리합니다. 특히 “한방에 끝내기”를 목표로, 아래 2가지를 기준으로 코드를 정리합니다.

  • 스트림 파이프라인은 가능한 순수 함수로 유지한다
  • 컬렉션 변경은 스트림 밖에서 하거나, 변경이 필요하면 안전한 방식으로 분리한다

운영에서 터지는 예외는 결국 자원 고갈/지연으로 이어지기 쉽습니다. DB 커넥션 대기까지 연쇄로 번지기 전에, 스트림 단계에서 막는 게 비용이 가장 적습니다. (관련해서는 Spring Boot HikariCP 커넥션 고갈 원인 8가지도 함께 보면 장애 전파 감각을 잡는 데 도움이 됩니다.)

1) Stream에서 NPE가 터지는 대표 케이스

NPE는 크게 3가지 루트로 나옵니다.

  1. 스트림 소스 자체가 null
  2. 스트림 요소(element)가 null
  3. 매핑 과정에서 중간 값이 null인데 메서드 호출(예: getName().length())

1-1. 스트림 소스가 null

가장 흔한 패턴입니다.

List<User> users = userRepository.findUsers(); // null을 반환하는 레거시
long activeCount = users.stream()
    .filter(User::isActive)
    .count();

usersnull이면 stream() 호출에서 바로 NPE가 납니다.

해결: Optional.ofNullable로 빈 스트림 대체

List<User> users = userRepository.findUsers();

long activeCount = Optional.ofNullable(users)
    .orElseGet(Collections::emptyList)
    .stream()
    .filter(User::isActive)
    .count();
  • 레포지토리/서비스는 가능하면 null 대신 빈 컬렉션을 반환하도록 계약을 바꾸는 게 최선입니다.
  • 계약을 못 바꾸는 구간에서는 위처럼 방어합니다.

1-2. 요소가 null인 리스트/셋

데이터 정제 단계가 약하면 리스트 안에 null이 섞입니다.

List<String> names = Arrays.asList("kim", null, "lee");

List<Integer> lengths = names.stream()
    .map(String::length) // null에서 NPE
    .toList();

해결: filter(Objects::nonNull)을 초반에 고정 패턴으로

List<Integer> lengths = names.stream()
    .filter(Objects::nonNull)
    .map(String::length)
    .toList();
  • nonNull 필터는 가능한 한 파이프라인 앞쪽에 둡니다.
  • 뒤쪽에 두면 이미 NPE가 터졌을 수 있습니다.

1-3. 중간 매핑 결과가 null

예를 들어 User는 있는데 profile이 없는 경우입니다.

List<User> users = List.of(new User("a", null));

List<String> cities = users.stream()
    .map(User::getProfile)   // null 가능
    .map(Profile::getCity)   // 여기서 NPE
    .toList();

해결 A: 단계별 filter(Objects::nonNull)

List<String> cities = users.stream()
    .map(User::getProfile)
    .filter(Objects::nonNull)
    .map(Profile::getCity)
    .filter(Objects::nonNull)
    .toList();

해결 B: flatMap으로 null을 0개 요소로 취급

List<String> cities = users.stream()
    .flatMap(u -> Optional.ofNullable(u.getProfile()).stream())
    .map(Profile::getCity)
    .filter(Objects::nonNull)
    .toList();
  • Optional.stream()Optional이 비었으면 빈 스트림, 있으면 1개 요소 스트림을 만듭니다.
  • “없으면 스킵”이라는 의도가 더 명확해집니다.

1-4. Collectors.toMap에서 발생하는 NPE/예외

toMapnull 키/값, 중복 키에서 예외가 쉽게 납니다.

Map<String, User> map = users.stream()
    .collect(Collectors.toMap(User::getEmail, u -> u));
  • getEmail()null이면 NullPointerException 또는 IllegalStateException 성격의 예외가 발생할 수 있습니다.
  • 이메일 중복이면 IllegalStateException: Duplicate key.

해결: null/중복을 정책으로 처리

Map<String, User> map = users.stream()
    .filter(Objects::nonNull)
    .filter(u -> u.getEmail() != null)
    .collect(Collectors.toMap(
        User::getEmail,
        u -> u,
        (a, b) -> a // 중복 시 첫 값 유지(정책)
    ));

중복 정책은 서비스 요구사항에 맞춰 (a, b) -> b(마지막 값), 또는 병합 로직으로 바꿉니다.

2) ConcurrentModificationException이 터지는 이유

CME는 대부분 “순회 중인 컬렉션을 구조적으로 변경”할 때 발생합니다. 스트림은 내부적으로 Iterator/Spliterator를 사용해 순회하는데, 순회 도중 리스트/맵의 구조가 바뀌면 fail-fast로 예외를 던집니다.

2-1. 스트림 순회 중 컬렉션을 수정하는 최악의 패턴

List<String> list = new ArrayList<>(List.of("a", "b", "c"));

list.stream().forEach(s -> {
    if (s.equals("b")) {
        list.remove(s); // CME 가능
    }
});
  • forEach 안에서 원본 리스트를 수정하면 거의 확정적으로 터집니다.
  • 더 위험한 건, 데이터 크기/타이밍에 따라 “가끔만” 터져서 재현이 어렵다는 점입니다.

2-2. parallelStream에서 공유 상태를 변경

List<Integer> nums = IntStream.range(0, 1000).boxed().toList();
List<Integer> out = new ArrayList<>();

nums.parallelStream().forEach(out::add); // 레이스/데이터 손상/예외 가능
  • ArrayList는 스레드 안전하지 않습니다.
  • 예외가 안 나더라도 결과가 틀릴 수 있습니다.

3) CME “한방에” 막는 실전 패턴

핵심은 이겁니다.

  • 스트림은 읽기 전용으로 쓰고
  • 변경은 별도의 단계에서 수행하거나
  • 변경이 필요하면 안전한 API로 수행한다

3-1. 삭제는 removeIf로 끝내기

“조건에 맞는 요소를 삭제”는 스트림이 아니라 컬렉션 API가 정답입니다.

List<String> list = new ArrayList<>(List.of("a", "b", "c"));
list.removeIf(s -> s.equals("b"));
  • 내부적으로 안전하게 순회/삭제를 처리합니다.
  • 가독성도 스트림보다 좋습니다.

3-2. 수정/치환은 replaceAll

List<String> list = new ArrayList<>(List.of("a", "b"));
list.replaceAll(String::toUpperCase);

3-3. “필터링 결과를 새 컬렉션으로” 만들고 교체

원본을 건드리지 않고 새 리스트를 만듭니다.

List<User> filtered = users.stream()
    .filter(Objects::nonNull)
    .filter(User::isActive)
    .toList();

users = new ArrayList<>(filtered); // 필요 시 교체
  • 스트림의 가장 안전한 사용법입니다.
  • 메모리는 조금 더 쓰지만, 예외/부수효과 비용보다 대개 쌉니다.

3-4. 정말 필요하면 Iterator로 명시적 삭제

스트림을 고집하지 말고, 삭제는 정석대로 갑니다.

Iterator<User> it = users.iterator();
while (it.hasNext()) {
    User u = it.next();
    if (u == null || !u.isActive()) {
        it.remove(); // 안전
    }
}
  • “수정하면서 순회” 자체가 요구사항이면 이게 가장 명확합니다.

3-5. parallelStream에서는 무조건 collect로 모으기

공유 리스트에 add하지 말고, 수집기로 모읍니다.

List<Integer> out = nums.parallelStream()
    .map(n -> n * 2)
    .toList();

병렬에서 누적이 필요하면 Collectors.toList()/toSet()/groupingByConcurrent 같은 안전한 수집을 씁니다.

Map<Integer, List<Integer>> grouped = nums.parallelStream()
    .collect(Collectors.groupingByConcurrent(n -> n % 10));

4) NPE “한방에” 줄이는 스트림 설계 규칙

4-1. 컬렉션은 절대 null로 반환하지 않기

가장 큰 효과를 내는 규칙입니다.

  • 메서드 계약: “없으면 빈 리스트”
  • DTO 필드: 기본값을 빈 컬렉션으로
public List<User> findUsers() {
    List<User> result = legacyCall();
    return result == null ? Collections.emptyList() : result;
}

4-2. 파이프라인 첫 줄에 filter(Objects::nonNull)을 습관화

요소 null 가능성이 1%라도 있으면 초반에 제거합니다.

List<String> emails = users.stream()
    .filter(Objects::nonNull)
    .map(User::getEmail)
    .filter(Objects::nonNull)
    .toList();

4-3. peek로 상태 변경/로깅 남발 금지

peek는 디버깅 용도입니다. 상태를 바꾸면 추적이 어려워집니다.

// 나쁜 예: peek에서 객체 상태 변경
users.stream()
    .peek(u -> u.setActive(true))
    .toList();

대신 map으로 새 객체를 만들거나(불변 선호), 스트림 밖에서 명시적으로 처리합니다.

4-4. null을 값으로 넣지 말고 “없음”은 구조로 표현

  • 가능하면 Optional을 필드로 들고 다니기보다는, 경계에서만 Optional을 사용
  • 외부 입력/레거시 응답처럼 null이 들어오는 지점에서 바로 정리

5) 실전 예제: NPE + CME를 동시에 없애는 리팩터링

문제 코드 (운영에서 자주 보는 형태)

  • 사용자 리스트에 null이 섞임
  • 스트림에서 돌면서 조건에 맞으면 원본 리스트를 삭제
void normalize(List<User> users) {
    users.stream().forEach(u -> {
        if (u.getProfile().getCity().isBlank()) { // NPE 가능
            users.remove(u); // CME 가능
        }
    });
}

개선 코드 1: 삭제는 removeIf, null-safe는 단계적으로

void normalize(List<User> users) {
    if (users == null) return;

    users.removeIf(u -> {
        if (u == null) return true;
        Profile p = u.getProfile();
        if (p == null) return true;
        String city = p.getCity();
        return city == null || city.isBlank();
    });
}
  • NPE 제거
  • CME 제거
  • 의도가 명확: “조건에 맞으면 삭제”

개선 코드 2: 불변에 가깝게 만들고 결과를 반환

List<User> normalized(List<User> users) {
    return Optional.ofNullable(users)
        .orElseGet(Collections::emptyList)
        .stream()
        .filter(Objects::nonNull)
        .filter(u -> {
            Profile p = u.getProfile();
            if (p == null) return false;
            String city = p.getCity();
            return city != null && !city.isBlank();
        })
        .toList();
}
  • 호출자가 원본을 바꾸고 싶으면 새 리스트로 교체
  • 테스트/추론이 쉬워집니다

6) 체크리스트: 스트림 예외를 설계로 막기

NPE 방지 체크

  • 컬렉션 반환은 null 금지, 빈 컬렉션 사용
  • 파이프라인 초반에 filter(Objects::nonNull) 배치
  • toMap은 중복/null 키 정책을 명시
  • 중간 객체 접근은 flatMap(Optional...stream()) 또는 단계적 null 필터

CME 방지 체크

  • 스트림 forEach 안에서 원본 컬렉션 수정 금지
  • 삭제는 removeIf, 치환은 replaceAll
  • 병렬 처리에서 공유 상태 변경 금지, collect로 수집

운영 장애는 대개 “예외 한 번”으로 끝나지 않고, 재시도/큐 적체/커넥션 대기 같은 연쇄 반응을 만듭니다. 성능/안정성 관점에서 스트림을 다룰 때도 같은 감각이 필요합니다. 이런 관점은 Spring Boot 3 가상스레드 도입 후 Deadlock·TPS 저하 진단처럼 동시성 이슈를 다루는 글과 함께 보면 더 빠르게 체화됩니다.

7) 결론: “스트림은 읽기, 변경은 컬렉션 API”

Stream은 데이터 변환/집계에 매우 강력하지만, null과 변경(side effect)에 취약한 면이 있습니다. 따라서 팀 규칙을 아래처럼 단순하게 가져가면 NPE와 CME를 동시에 크게 줄일 수 있습니다.

  • 스트림 파이프라인에서는 원본 변경 금지
  • 삭제/수정은 removeIf/replaceAll 같은 컬렉션 전용 API 사용
  • null은 “입구에서 제거”하고, toMap은 “정책을 코드로 명시”

이 3가지만 지켜도 Stream 관련 장애의 대부분은 사라집니다.