파일·리소스 처리 · Operations

정상 데이터가 없는 분기를 놓쳤을 때: 실패 결과 전용 PDF의 NPE 방어

실패 결과만 존재하는 작업에서도 성공 데이터가 항상 있을 것이라고 가정해 발생한 NullPointerException을 상태별 PDF 분기와 실패 범위 격리로 보완한 과정을 정리합니다.

JavaNullPointerExceptionPDFBatchException HandlingDefensive Programming

문제의 출발점

정상 처리 경로에서는 항상 존재하던 데이터가 실패 경로에서도 존재한다고 가정하면, 실제 업무 실패보다 후속 산출물 생성 오류가 더 큰 장애로 번질 수 있다.

이번 사례에서는 실패 결과 보고서만 생성해야 하는 작업에 성공 반응 데이터가 없었다. PDF 생성 분기가 성공 데이터를 전제로 접근하면서 NullPointerException이 발생했고, 한 건의 예외가 결과 처리 작업 전체를 중단시킬 수 있는 경로까지 이어졌다.

실패 결과만 있는 상태를 정상적인 입력으로 보지 못했다

결과 처리에는 성공 데이터가 있는 일반 경로와 실패 결과만 남는 경로가 있었다. 기존 PDF 생성 로직은 일반 경로에서 반복적으로 동작했기 때문에 “성공 반응 데이터 목록은 존재한다”는 전제가 코드에 숨어 있었다.

하지만 실패 결과 전용 PDF를 만들어야 하는 경우에는 그 목록이 존재하지 않을 수 있다.

일반 결과:
    정상 결과 데이터 존재
    성공 결과 PDF 생성

실패 결과만 존재:
    정상 결과 데이터 없음
    실패 결과 PDF 생성 필요

두 번째 상태도 업무적으로 처리해야 할 정상적인 분기지만, 코드에서는 첫 번째 상태의 예외처럼 취급되고 있었다.

NullPointerException은 원인보다 상태 모델의 빈틈을 보여 줬다

null이 발생한 위치에 조건문 한 줄을 추가하면 당장의 예외는 막을 수 있다. 그러나 데이터가 없는 이유를 구분하지 않으면 다음 문제가 남는다.

  • 실패 결과만 있어 비어 있는 정상 상태인지
  • 앞선 처리에서 데이터 설정이 누락된 결함인지
  • 결과 자체가 없어 PDF를 만들 필요가 없는 상태인지
  • 일부 결과만 만들어야 하는 부분 성공 상태인지

이번에 직접 확인된 범위는 성공 데이터가 없는 실패 결과 전용 상태다. 정상·부분 성공·전체 실패를 모두 구현했다는 뜻은 아니지만, 후속 산출물 코드는 입력을 먼저 상태로 분류해야 한다는 설계 교훈을 얻을 수 있다.

단순히 빈 컬렉션으로 바꾸지 않은 이유

모든 null을 빈 컬렉션으로 치환하면 반복문 예외는 사라진다.

for (Result item : emptyIfNull(normalResults)) {
    createNormalPage(item);
}

하지만 이 코드만으로는 왜 정상 결과가 없는지 드러나지 않는다. 실패 결과 PDF를 별도로 만들어야 한다면 아무 작업도 하지 않고 조용히 통과하는 것도 잘못된 결과다.

그래서 실제 개선은 두 축으로 이뤄졌다.

  • 실패 결과 전용 PDF 생성 분기를 추가하거나 보완
  • 예외 처리 경로에서 성공 데이터에 접근하기 전 null 검증

null을 무조건 빈 값으로 바꾸는 것이 아니라, 어떤 산출물을 만들어야 하는 상태인지 먼저 결정한 뒤 해당 분기로 들어가도록 한 것이다.

입력 상태를 먼저 판별하는 구조

아래 예시는 실제 내부 변수명과 PDF 소스를 제거한 pseudocode다.

state = 결과 상태 판별

if state == 정상 결과 존재:
    정상 결과 PDF 생성

else if state == 실패 결과만 존재:
    실패 결과 전용 PDF 생성

else:
    생성 가능한 결과가 없는 상태로 처리

null 또는 빈 데이터는 상태를 판별하는 입력이지, 그 자체가 최종 업무 판단은 아니다.

개념을 조금 더 확장하면 다음과 같이 생각할 수 있다.

입력 상태를 먼저 판별하는 구조 데이터 표
상태정상 결과 데이터실패 정보산출물 방향
정상있음없거나 비대상정상 결과 PDF
부분 성공일부 있음일부 있음업무 규칙에 따라 각각 처리
전체 실패없음있음실패 결과 전용 PDF
결과 없음없음없음생성 여부를 명시적으로 판단

부분 성공과 결과 없음까지 이번에 모두 구현했다는 의미는 아니다. 정상 데이터 존재 여부 하나만으로 전체 상태를 추정하지 않기 위한 재사용 가능한 모델이다.

한 건의 실패가 작업 전체로 전파되는 경로를 줄였다

PDF 생성은 결과 처리 파이프라인의 후속 단계였다. 한 건의 실패 결과에서 예외가 바깥으로 전파되면 이후 처리할 다른 결과까지 멈출 수 있었다.

이번 수정에서는 실패 결과 전용 분기와 null 검증을 보완해, 확인된 예외가 작업 모듈 전체 중단으로 이어지지 않도록 방어했다.

여기서 중요한 것은 예외를 무시하는 것과 실패 범위를 격리하는 것이 다르다는 점이다.

  • 예외를 삼키면 무엇이 실패했는지 상위 흐름이 알 수 없다.
  • 무조건 재시도하면 같은 입력에서 같은 오류가 반복될 수 있다.
  • 전체 작업을 중단하면 독립적으로 처리 가능한 다른 결과까지 영향을 받는다.
  • 개별 산출물의 실패 범위를 정하면 상위 흐름이 계속할지 중단할지 판단할 수 있다.

실제 내부 로깅·상태 저장 방식은 공개 근거에 포함하지 않고, 확인된 전체 중단 방어 범위만 설명한다.

개념적인 실패 범위 격리

아래 코드는 작업 단위를 분리하는 원칙을 설명하기 위한 예시이며 실제 예외 처리 소스가 아니다.

for (ResultUnit unit : workItems) {
    try {
        PdfInput input = classify(unit);
        createPdfByState(input);
    } catch (Exception exception) {
        markCurrentUnitAsFailed(exception);
        continueNextUnit();
    }
}

이 예시에서 중요한 질문은 catch가 있느냐가 아니다.

  • 현재 단위의 실패를 식별할 수 있는가
  • 실패 사실이 관찰 가능한 형태로 남는가
  • 다음 단위가 독립적으로 처리 가능한가
  • 트랜잭션이나 외부 부수효과가 이미 일부 완료되지는 않았는가

이 조건 없이 catch만 추가하면 안전한 계속 처리가 아니라 오류 은폐가 될 수 있다.

검증 기록은 하나의 시나리오를 단계별로 반복했다

확인된 테스트 기록은 2행 Y다. 하지만 서로 다른 두 시나리오가 아니었다.

검증 기록은 하나의 시나리오를 단계별로 반복했다 데이터 표
고유 시나리오반복 단계결과
1개2단계2행 Y

같은 실패 결과 전용 PDF 시나리오를 단계별로 반복해 수정 경로를 확인했고, 최종 완료 상태도 확인됐다. 따라서 “2개의 고유 테스트 시나리오로 모든 PDF 오류를 검증했다”고 표현하지 않는다.

남는 한계

이번 수정은 성공 데이터가 없는 실패 결과 전용 PDF 경로와 그 예외 전파를 보완한 것이다.

  • PDF 라이브러리 자체의 결함을 수정한 것이 아니다.
  • 모든 PDF 생성 오류를 해결한 것이 아니다.
  • 전체 배치 프레임워크를 재설계한 것이 아니다.
  • 모든 null을 빈 컬렉션으로 바꾸는 일반 규칙을 만든 것이 아니다.

파일 시스템, 폰트, 잘못된 템플릿, 손상된 이미지처럼 PDF 생성에는 다른 실패 원인이 존재할 수 있다. 각각은 입력 상태와 자원 경계를 별도로 확인해야 한다.

예외를 잡은 뒤 정상 반환해 트랜잭션 의미까지 바꾸는 문제는 try-catch가 Spring Transaction Rollback을 막을 때에서 다른 사례로 다룬다.

정리

성공 데이터가 없다는 사실은 언제나 비정상은 아니다. 전체 실패처럼 업무적으로 예상 가능한 상태라면 그 상태에 맞는 산출물과 종료 방식을 정의해야 한다.

이번 개선의 핵심은 null 검사 한 줄이 아니었다. PDF를 만들기 전에 입력 상태를 구분하고, 실패 결과 전용 경로를 명시하며, 한 건의 산출물 실패가 독립적인 다음 작업까지 중단시키는 범위를 줄인 것이었다.