- Published on
Java Stream에서 NPE·ConcurrentModification 한방에 끝내기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 Stream을 쓰기 시작하면 생산성은 올라가지만, 운영에서 유독 자주 만나는 예외가 두 가지 있습니다. NullPointerException(NPE)과 ConcurrentModificationException(CME)입니다. 둘 다 “스트림이 나빠서”가 아니라, 스트림이 컬렉션/객체 그래프를 더 공격적으로 순회하고, 개발자가 부수효과(side effect) 를 넣기 쉬운 구조라서 표면화되는 경우가 많습니다.
이 글은 두 예외를 재현 코드로 확실히 이해하고, 실무에서 바로 적용 가능한 방지 패턴을 정리합니다. 특히 “한방에 끝내기”를 목표로, 아래 2가지를 기준으로 코드를 정리합니다.
- 스트림 파이프라인은 가능한 순수 함수로 유지한다
- 컬렉션 변경은 스트림 밖에서 하거나, 변경이 필요하면 안전한 방식으로 분리한다
운영에서 터지는 예외는 결국 자원 고갈/지연으로 이어지기 쉽습니다. DB 커넥션 대기까지 연쇄로 번지기 전에, 스트림 단계에서 막는 게 비용이 가장 적습니다. (관련해서는 Spring Boot HikariCP 커넥션 고갈 원인 8가지도 함께 보면 장애 전파 감각을 잡는 데 도움이 됩니다.)
1) Stream에서 NPE가 터지는 대표 케이스
NPE는 크게 3가지 루트로 나옵니다.
- 스트림 소스 자체가
null - 스트림 요소(element)가
null - 매핑 과정에서 중간 값이
null인데 메서드 호출(예:getName().length())
1-1. 스트림 소스가 null
가장 흔한 패턴입니다.
List<User> users = userRepository.findUsers(); // null을 반환하는 레거시
long activeCount = users.stream()
.filter(User::isActive)
.count();
users가 null이면 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/예외
toMap은 null 키/값, 중복 키에서 예외가 쉽게 납니다.
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 관련 장애의 대부분은 사라집니다.