Published on

Java Stream에서 NPE 없이 null 안전 처리 6패턴

Authors

서버 사이드 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)xnull 이면 빈 스트림, 아니면 단일 요소 스트림을 만들어줍니다. 파이프라인 어딘가에서 “값이 있을 수도/없을 수도”를 스트림 레벨에서 자연스럽게 처리할 수 있습니다.

언제 쓰나

  • 중간 단계에서 객체 하나가 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() 호출 시점입니다. listnull 이면 즉시 터집니다. 컬렉션은 “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 는 명확히 제약이 큼), toMapnull 키/값에서 예외가 발생할 수 있습니다. 안전하게 키를 정규화하세요.

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 으로 합산
  • 금액 null0 으로 치환

실무 체크리스트: 어디에서 null을 “끊을” 것인가

  1. 입력 경계에서 정규화: API 응답/DB 조회 결과를 DTO로 옮길 때 컬렉션은 빈 컬렉션, 숫자는 0 또는 Optional 등 팀 규칙을 정합니다.
  2. 스트림 초입에서 한 번 제거: stream().filter(Objects::nonNull) 을 초입에 두면 파이프라인 전체가 안전해집니다. 다만 “중간 변환 결과가 null”인 케이스는 별도로 처리해야 합니다.
  3. null이 나올 수 있는 변환 지점에 Stream.ofNullable/Optional.stream() 배치: NPE의 진원지에서 끊는 게 유지보수에 유리합니다.
  4. 정렬/그룹/맵 수집은 별도 주의: sorted, groupingBy, toMap 은 null 처리 정책을 명시하지 않으면 운영에서 터지기 쉽습니다.

마무리: 6패턴 요약

  • 패턴 1: Stream.ofNullable 로 객체 null 을 빈 스트림으로
  • 패턴 2: 컬렉션 nullemptyIfNull 로 빈 컬렉션 정규화
  • 패턴 3: Optional.stream() 으로 Optional을 스트림에 합류
  • 패턴 4: map 대신 flatMap 으로 변환 단계에서 null 제거
  • 패턴 5: Objects.requireNonNullElse 로 기본값 주입
  • 패턴 6: Comparator.nullsLast 와 키 정규화로 정렬/그룹/맵 수집 NPE 차단

이 6가지를 팀 컨벤션으로 묶어두면, Stream을 쓰면서도 null 로 인한 예외를 “디버깅 이슈”가 아니라 “설계로 예방되는 문제”로 바꿀 수 있습니다.