- Published on
Java Stream에서 NPE 없이 null 안전 처리 6패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Java에서 Stream은 컬렉션 처리의 표준이 됐지만, 파이프라인 중간에 null 이 섞이는 순간 NullPointerException 이 아주 쉽게 발생합니다. 특히 외부 API 응답, 레거시 DB 매핑, 부분 업데이트(패치) 같은 현실적인 입력은 null 을 “정상 값”처럼 포함하는 경우가 많습니다.
이 글에서는 Stream을 포기하지 않고도 NPE를 구조적으로 차단하는 6가지 패턴을 소개합니다. 핵심은 “null 을 스트림에 흘려보내지 않기”와 “필요하면 초입에서 정규화(normalize)하기”입니다.
참고로 성능/안정성 문제는 결국 운영 이슈로 이어집니다. 운영 관점의 디버깅/최적화 글로는 GitHub Actions 캐시로 Node.js CI 2배 빠르게, 실패 디버깅, Spring Security JWT 401 - JWK 캐시와 kid 불일치 해결도 함께 보면 “재현 어려운 문제를 구조로 푸는 방식”에 도움이 됩니다.
준비: 예제 도메인
예제는 주문 목록에서 고객 이름, 태그 등을 가공하는 상황을 가정합니다.
import java.util.*;
import java.util.stream.*;
class Order {
private final Customer customer; // null 가능
private final List<String> tags; // null 가능
private final Integer amount; // null 가능
Order(Customer customer, List<String> tags, Integer amount) {
this.customer = customer;
this.tags = tags;
this.amount = amount;
}
Customer getCustomer() { return customer; }
List<String> getTags() { return tags; }
Integer getAmount() { return amount; }
}
class Customer {
private final String name; // null 가능
Customer(String name) { this.name = name; }
String getName() { return name; }
}
아래 패턴들은 서로 배타적이지 않습니다. 실무에서는 2~3개를 조합해 “입력 정규화 + 파이프라인 안전장치” 형태로 쓰는 경우가 가장 많습니다.
패턴 1) Stream.ofNullable 로 null을 0개 요소로 취급
Java 9부터 Stream.ofNullable(x) 는 x 가 null 이면 빈 스트림, 아니면 단일 요소 스트림을 만들어줍니다. 파이프라인 어딘가에서 “값이 있을 수도/없을 수도”를 스트림 레벨에서 자연스럽게 처리할 수 있습니다.
언제 쓰나
- 중간 단계에서 객체 하나가
null일 수 있음 filter(Objects::nonNull)를 반복하고 싶지 않음
List<Order> orders = List.of(
new Order(new Customer("Alice"), List.of("vip"), 100),
new Order(null, List.of("new"), 50),
new Order(new Customer(null), null, null)
);
List<String> names = orders.stream()
.flatMap(o -> Stream.ofNullable(o.getCustomer()))
.map(Customer::getName)
.flatMap(Stream::ofNullable)
.toList();
// 결과: ["Alice"]
포인트는 flatMap(Stream::ofNullable) 을 이용해 null 을 “요소 0개”로 바꿔버리는 것입니다. 이렇게 하면 이후 단계에서 NPE가 날 여지가 크게 줄어듭니다.
패턴 2) 컬렉션 null은 초입에서 빈 컬렉션으로 정규화
Stream에서 가장 흔한 NPE는 list.stream() 호출 시점입니다. list 가 null 이면 즉시 터집니다. 컬렉션은 “null 대신 빈 컬렉션”으로 정규화하는 게 가장 단순하고 강력합니다.
static <T> List<T> emptyIfNull(List<T> list) {
return list == null ? List.of() : list;
}
List<String> allTags = orders.stream()
.flatMap(o -> emptyIfNull(o.getTags()).stream())
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.distinct()
.toList();
팁
- 반환 타입이
List가 아니라Collection이면Collections.emptyList()같은 표준 빈 컬렉션을 써도 됩니다. - 가능하다면 “DTO/도메인 생성 시점”에
tags를 빈 리스트로 고정해두면(불변 컬렉션 포함) 파이프라인 전체가 단순해집니다.
패턴 3) Optional.stream() 으로 Optional을 스트림에 자연스럽게 합류
Optional 을 쓰고 있다면 optional.stream() (Java 9+)이 깔끔합니다. 값이 있으면 1개 요소 스트림, 없으면 0개 요소 스트림입니다.
static Optional<Customer> customerOf(Order o) {
return Optional.ofNullable(o.getCustomer());
}
List<String> names2 = orders.stream()
.flatMap(o -> customerOf(o).stream())
.map(Customer::getName)
.flatMap(n -> Optional.ofNullable(n).stream())
.toList();
주의
Optional은 “반환 타입”에 쓰는 것이 권장되고, 필드로 들고 다니는 건 호불호가 큽니다.- 그럼에도
Optional.stream()은 파이프라인에서 null 제거를 선언적으로 표현하는 데 효과적입니다.
패턴 4) map 대신 flatMap 으로 중간 null을 제거하며 변환
map 은 변환 결과가 null 일 수 있으면 위험합니다. 예를 들어 map(Customer::getName) 결과가 null 이면, 다음 단계에서 s.length() 같은 호출이 등장하는 순간 NPE가 납니다.
이럴 때는 “변환 + null 제거”를 한 번에 처리하는 flatMap 패턴이 유용합니다.
List<Integer> nameLengths = orders.stream()
.flatMap(o -> Stream.ofNullable(o.getCustomer()))
.flatMap(c -> Stream.ofNullable(c.getName()))
.map(String::length)
.toList();
이 방식은 파이프라인이 길어질수록 효과가 큽니다. filter(Objects::nonNull) 을 중간중간 넣는 것보다, “null이 나올 수 있는 변환 지점”에서 바로 제거하는 편이 유지보수에 유리합니다.
패턴 5) 기본값을 명시적으로 주입: Objects.requireNonNullElse / ...Get
비즈니스 로직상 null 을 “없음”으로 처리하되, 결과는 반드시 값이 있어야 할 때가 있습니다. 이때는 기본값을 주입해 NPE를 차단합니다.
Objects.requireNonNullElse(value, defaultValue)Objects.requireNonNullElseGet(value, supplier)(기본값 생성 비용이 있을 때)
import java.util.Objects;
List<Integer> safeAmounts = orders.stream()
.map(o -> Objects.requireNonNullElse(o.getAmount(), 0))
.filter(a -> a > 0)
.toList();
문자열도 마찬가지입니다.
List<String> safeNames = orders.stream()
.map(Order::getCustomer)
.map(c -> c == null ? null : c.getName())
.map(n -> Objects.requireNonNullElse(n, "(unknown)"))
.toList();
언제 쓰나
- 결과 스키마가
null을 허용하지 않음(예: CSV 컬럼, 로그 포맷, UI 표) - 집계/정렬 등에서
null이 섞이면 의미가 애매해짐
패턴 6) 비교/정렬/그룹핑에서 null 안전한 Comparator 와 키 정규화
Stream에서 NPE는 map 뿐 아니라 sorted, groupingBy, toMap 같은 “키/비교” 단계에서도 자주 발생합니다.
6-1) Comparator.nullsFirst / nullsLast
import static java.util.Comparator.*;
List<Order> sorted = orders.stream()
.sorted(comparing(
(Order o) -> {
Customer c = o.getCustomer();
return c == null ? null : c.getName();
},
nullsLast(naturalOrder())
))
.toList();
- 이름이
null인 고객은 맨 뒤로 보냄 naturalOrder()는 문자열 기본 정렬
6-2) groupingBy 키가 null이면 예외가 날 수 있음: 키를 정규화
Collectors.groupingBy 는 구현/버전에 따라 null 키를 허용하지 않는 경우가 많고(특히 groupingByConcurrent 는 명확히 제약이 큼), toMap 은 null 키/값에서 예외가 발생할 수 있습니다. 안전하게 키를 정규화하세요.
import java.util.stream.Collectors;
Map<String, List<Order>> byCustomer = orders.stream()
.collect(Collectors.groupingBy(o -> {
Customer c = o.getCustomer();
String name = (c == null) ? null : c.getName();
return name == null ? "(unknown)" : name;
}));
6-3) toMap 은 충돌/널에 특히 취약: merge 함수와 기본값을 함께
Map<String, Integer> amountByName = orders.stream()
.flatMap(o -> Stream.ofNullable(o.getCustomer())
.flatMap(c -> Stream.ofNullable(c.getName())
.map(name -> Map.entry(name, Objects.requireNonNullElse(o.getAmount(), 0)))
)
)
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
Integer::sum
));
- 고객 이름이 중복되면
Integer::sum으로 합산 - 금액
null은0으로 치환
실무 체크리스트: 어디에서 null을 “끊을” 것인가
- 입력 경계에서 정규화: API 응답/DB 조회 결과를 DTO로 옮길 때 컬렉션은 빈 컬렉션, 숫자는
0또는Optional등 팀 규칙을 정합니다. - 스트림 초입에서 한 번 제거:
stream().filter(Objects::nonNull)을 초입에 두면 파이프라인 전체가 안전해집니다. 다만 “중간 변환 결과가 null”인 케이스는 별도로 처리해야 합니다. - null이 나올 수 있는 변환 지점에
Stream.ofNullable/Optional.stream()배치: NPE의 진원지에서 끊는 게 유지보수에 유리합니다. - 정렬/그룹/맵 수집은 별도 주의:
sorted,groupingBy,toMap은 null 처리 정책을 명시하지 않으면 운영에서 터지기 쉽습니다.
마무리: 6패턴 요약
- 패턴 1:
Stream.ofNullable로 객체null을 빈 스트림으로 - 패턴 2: 컬렉션
null은emptyIfNull로 빈 컬렉션 정규화 - 패턴 3:
Optional.stream()으로 Optional을 스트림에 합류 - 패턴 4:
map대신flatMap으로 변환 단계에서 null 제거 - 패턴 5:
Objects.requireNonNullElse로 기본값 주입 - 패턴 6:
Comparator.nullsLast와 키 정규화로 정렬/그룹/맵 수집 NPE 차단
이 6가지를 팀 컨벤션으로 묶어두면, Stream을 쓰면서도 null 로 인한 예외를 “디버깅 이슈”가 아니라 “설계로 예방되는 문제”로 바꿀 수 있습니다.