Published on

Java Stream에서 NPE·성능 잡는 flatMap/Optional 패턴

Authors

서버 코드에서 Stream을 쓰다 보면 두 가지 문제가 반복됩니다. 첫째는 NullPointerException(NPE)이고, 둘째는 "깔끔해 보이지만 느린" 파이프라인입니다. 특히 map 체인 중간에 null이 끼어 있거나, Optional을 잘못 스트림에 섞는 순간 코드가 지저분해지거나 성능이 흔들립니다.

이 글은 flatMapOptional을 조합해 NPE를 제거하면서도 불필요한 객체 생성과 중간 연산을 줄여 성능을 지키는 패턴을 정리합니다. (참고로 동시성이나 락이 얽힌 스트림 처리에서는 별도 고려가 필요합니다. 예를 들어 분산락/타임아웃 이슈는 Spring Boot Redis 분산락 데드락·타임아웃 해결 같은 주제와 맞물립니다.)

Stream에서 NPE가 터지는 대표 지점

NPE는 보통 아래 3군데에서 터집니다.

  1. 소스 컬렉션 자체가 null
  2. map 결과가 null인데 다음 단계에서 메서드를 호출
  3. 내부 리스트/필드가 null인데 flatMap(Collection::stream) 같은 형태로 스트리밍

안티패턴: map 체인에서 null이 스며듦

List<Order> orders = fetchOrders();

List<String> couponCodes = orders.stream()
    .map(Order::getCoupon)          // coupon이 null일 수 있음
    .map(Coupon::getCode)           // 여기서 NPE
    .toList();

이 코드는 Order.getCoupon()null이면 바로 깨집니다. filter(Objects::nonNull)을 끼우면 해결은 되지만, 단계가 늘어나고 실수도 잦습니다.

flatMap으로 "0개 또는 1개"를 표현하는 패턴

flatMap의 핵심은 한 요소를 0개 이상의 요소로 펼칠 수 있다는 점입니다. 이걸 이용하면 null"0개" 로 취급해 스트림에서 자연스럽게 제거할 수 있습니다.

패턴 1: Optional.stream()을 이용한 안전한 펼치기 (Java 9+)

Java 9부터 Optional.stream()이 생기면서, Optional을 스트림에 섞는 가장 깔끔한 방법이 열렸습니다.

List<String> couponCodes = orders.stream()
    .flatMap(order -> Optional.ofNullable(order.getCoupon()).stream())
    .map(Coupon::getCode)
    .toList();
  • order.getCoupon()null이면 Optional.empty()
  • Optional.empty().stream()은 빈 스트림
  • 결과적으로 null 쿠폰은 자동으로 제거

이 패턴은 filter(Objects::nonNull)보다 의도가 명확합니다. "있으면 1개, 없으면 0개"를 코드로 표현하기 때문입니다.

패턴 2: 내부 컬렉션이 null일 때 flatMap으로 방어

List<Order> orders = fetchOrders();

List<Item> allItems = orders.stream()
    .flatMap(order -> Optional.ofNullable(order.getItems())
        .stream()
        .flatMap(List::stream))
    .toList();

여기서 중요한 점은 order.getItems()null일 수 있다는 가정입니다.

  • Optional.ofNullable(order.getItems()).stream()List<Item>을 0개 또는 1개로 만들고
  • 그 다음 flatMap(List::stream)으로 아이템을 펼칩니다.

이 패턴은 "중첩 리스트" 처리에서 NPE를 구조적으로 차단합니다.

Optional을 Stream에서 잘못 쓰는 흔한 실수

실수 1: map으로 Optional을 만들고 끝내기

List<Optional<Coupon>> coupons = orders.stream()
    .map(o -> Optional.ofNullable(o.getCoupon()))
    .toList();

이건 보통 downstream에서 더 큰 혼란을 만듭니다. List<Optional<T>>는 대부분의 경우 사용성이 나쁘고, 결국 언젠가 get()을 호출하는 코드가 생깁니다.

해결책은 flatMap(Optional::stream)으로 애초에 평탄화하는 것입니다.

List<Coupon> coupons = orders.stream()
    .map(o -> Optional.ofNullable(o.getCoupon()))
    .flatMap(Optional::stream)
    .toList();

실수 2: orElse로 무거운 기본값을 매번 생성

orElseOptional이 비어 있어도 인자를 평가합니다.

Coupon coupon = Optional.ofNullable(order.getCoupon())
    .orElse(loadDefaultCouponFromDb()); // 항상 호출될 수 있음

대신 orElseGet을 써서 필요할 때만 생성/조회하게 만드세요.

Coupon coupon = Optional.ofNullable(order.getCoupon())
    .orElseGet(() -> loadDefaultCouponFromDb());

이 차이는 스트림 파이프라인에서 특히 크게 벌어집니다. orElse가 루프마다 무거운 객체를 만들거나 I/O를 태우면, 겉보기엔 함수형이지만 실제로는 성능 폭탄이 됩니다.

성능 관점: flatMapOptional은 언제 비용이 되나

Optional.ofNullable(x).stream()은 매우 편리하지만, "초미세" 성능을 따지면 Optional 객체가 생깁니다. 다만 대부분의 서버 애플리케이션에서 병목은 DB/네트워크/락/JSON 직렬화 쪽인 경우가 많아, 이 정도 비용은 가독성과 안전성으로 상쇄되는 경우가 많습니다.

그럼에도 대량 데이터(수백만 건)에서 GC 압박이 문제가 된다면, 아래 대안을 고려할 수 있습니다.

대안 1: Stream.ofNullable 사용 (Java 9+)

Stream.ofNullable은 값이 null이면 빈 스트림, 아니면 단일 요소 스트림을 만듭니다.

List<String> couponCodes = orders.stream()
    .flatMap(o -> java.util.stream.Stream.ofNullable(o.getCoupon()))
    .map(Coupon::getCode)
    .toList();
  • Optional 객체를 만들지 않는다는 점에서 더 직접적입니다.
  • "nullable을 0..1 스트림으로" 라는 의도가 깔끔하게 드러납니다.

대안 2: filter(Objects::nonNull) + map 단순화

가장 전통적인 방법도 여전히 유효합니다.

List<String> couponCodes = orders.stream()
    .map(Order::getCoupon)
    .filter(java.util.Objects::nonNull)
    .map(Coupon::getCode)
    .toList();

이 방식은 Optional을 만들지 않지만, "0..1"의 의미가 filter로 분산됩니다. 팀 컨벤션에 따라 선택하세요.

실전 패턴: 중첩 구조에서 NPE 없이 ID 수집하기

예를 들어 아래 같은 모델을 생각해봅시다.

  • UserProfile이 없을 수 있음
  • Profile에는 List<Device>가 없을 수 있음
  • DevicedeviceId가 비어 있을 수 있음

flatMap을 제대로 쓰면 NPE를 원천 차단하면서도 파이프라인이 과도하게 복잡해지지 않습니다.

record Device(String deviceId) {}
record Profile(List<Device> devices) {}
record User(Profile profile) {}

List<User> users = fetchUsers();

List<String> deviceIds = users.stream()
    .flatMap(u -> java.util.stream.Stream.ofNullable(u.profile()))
    .flatMap(p -> java.util.stream.Stream.ofNullable(p.devices()))
    .flatMap(List::stream)
    .map(Device::deviceId)
    .filter(id -> id != null && !id.isBlank())
    .distinct()
    .toList();

포인트는 다음과 같습니다.

  • Stream.ofNullable로 nullable 필드를 스트림화
  • 리스트는 flatMap(List::stream)으로 펼침
  • 문자열 검증은 마지막에 모아서 수행
  • distinct()는 비용이 있으니 정말 필요할 때만 (내부적으로 set을 사용)

스트림 파이프라인 최적화 체크리스트

1) filter는 가능한 앞단에

조건으로 걸러낼 수 있다면 빨리 걸러야 다음 연산이 줄어듭니다.

List<Order> valid = orders.stream()
    .filter(o -> o.getStatus() == Status.PAID)
    .toList();

2) sorted()는 매우 비싸다

정렬은 전체를 메모리에 올리고 비교를 반복합니다. 정말 필요한지 확인하세요.

3) peek()로 로깅하다가 성능/부작용 만들지 말기

peek()는 디버깅용입니다. 프로덕션 로깅/메트릭을 넣으면 파이프라인 의미가 흐려지고, 최적화도 어려워집니다.

4) 병렬 스트림은 만능이 아니다

parallelStream()은 작업이 CPU 바운드이고 충분히 큰 데이터일 때만 이점이 있습니다. 락/IO가 섞이면 오히려 느려질 수 있습니다. (락 경합/교착 같은 이슈는 Spring Boot 3 가상스레드에서 HikariCP 교착 해결처럼 다른 층위의 문제로 확대되기도 합니다.)

팀 컨벤션으로 굳히기 좋은 결론

Java Stream에서 NPE와 성능 문제를 동시에 줄이려면, "nullable을 스트림에서 어떻게 모델링할 것인가"를 팀 규칙으로 정하는 게 효과가 큽니다.

  • Java 9+라면 Stream.ofNullable(x) 또는 Optional.ofNullable(x).stream() 중 하나를 표준으로 채택
  • List<Optional<T>>는 만들지 않기 (대부분 downstream을 망침)
  • 무거운 기본값은 orElseGet으로 지연 평가
  • 중첩 구조는 flatMap으로 단계적으로 평탄화해서 NPE를 구조적으로 제거

스트림은 "짧아서 예쁜 코드"가 목표가 아니라, 안전하게 읽히고 실제로 빠른 코드가 목표입니다. flatMapOptional을 "0개 또는 1개"라는 데이터 모델링 도구로 사용하면, NPE를 방어하는 코드가 곧 비즈니스 로직을 설명하는 코드로 바뀝니다.