NPE 하나를 팀의 방어 체계로 바꾸기까지
2026년 8월 20일
원문에서 보기 ↗글. 김민우(Kyle) / 유저혜택개발팀

연차 날의 슬랙 알림
안녕하세요. 유저혜택개발팀 카일입니다.
연차 날 아침이었습니다. 느긋하게 커피를 마시며 오늘 하루를 어떻게 알차게 보낼지 행복한 고민을 하던 참이었습니다. 슬랙 알림 하나가 평화를 깨뜨렸습니다.
"쿠폰 복원이 안 돼요. 사용 취소한 쿠폰이 복구가 안 된다고 문의가 계속 들어와요.."
확인하자마자 로그를 조회했습니다. 그리고 화면 가득 적힌 문장을 만났습니다.
java.lang.NullPointerException

NPE(NullPointerException)은 값이 비어 있는(null) 객체 참조 변수의 메서드나 속성에 접근하려 할 때 발생하는 자바의 대표적인 예외입니다.
개발 단계에서는 흔히 접하는 에러지만, 운영 환경(Release)에서 만나는 NPE는 차원이 다릅니다. 며칠 전 제가 직접 수정했던 쿠폰 복원 로직이었기 때문입니다.
신규 발급 실패라면 "잠시 후 다시 시도해 주세요"라는 안내로 시간을 벌 수 있습니다.
하지만 쿠폰 복원 실패는 다릅니다. 이미 사용된 고객의 쿠폰이 사라진 상태에서 복구가 멈춰버린 것입니다. 고객 입장에서는 혜택이 증발한 셈이었습니다.
이 글은 그날의 NPE를 추적하며 던졌던 세 가지 질문에 대한 기록입니다.
- 왜 테스트 과정에서 아무도 잡지 못했을까?
- 애초에 NPE가 터질 수 없는 구조로 만들 수는 없었을까?
- 시스템이 스스로 기억하게 만들 방법은 없을까?
사건의 원인: 예산 제한 쿠폰과 복원 로직
문제의 원인을 파악하려면 먼저 쿠폰의 종류 와 복원 프로세스를 이해해야 합니다.

- 예산 제한 쿠폰: 총 예산이 소진되면 자동으로 발급이 중단되는 쿠폰입니다. 고객이사용할 때마다 DB와 Redis(인메모리 저장소)에서 예산이 차감됩니다.
- 일반 쿠폰: 예산 제한 없이 발급되는 쿠폰입니다.
- 복원 로직의 차이: 주문 취소 시 일반 쿠폰은 DB 상태만 되돌리면 되지만, 예산 제한 쿠폰은 차감했던 Redis의 예산 집계까지 되돌려야 합니다.

예산 제한 쿠폰은 예산 집계를 위해 전용 객체인 BudgetVerifyVO을 생성합니다. 이 객체는 Redis 기록의 보관 기간(TTL)을 계산하기 위해 쿠폰 발급 종료일(issueEndDate)을 가지고 있습니다.
1. 리팩토링이 삼켜버린 if문
RDBMS(DB)는 ACID 트랜잭션을 보장하는 최종 데이터 저장소이자 진실의 원천(Source of Truth)입니다.
Redis는 빠른 속도를 위한 캐시 저장소이므로, Redis를 먼저 수정했다가 DB 업데이트에 실패하면 DB에는 '쿠폰 사용 중'인데 Redis만 차감되는 정합성 깨짐이 발생합니다. 처리 순서를 'DB 업데이트 성공 후 Redis 처리'로 변경해야 했습니다.
이 과정에서 UseRedisService내부에 숨어있던 BudgetVerifyVO.of()객체 생성 로직을 호출부( CommonUseService**) 밖으로 꺼내게 되었습니다.**

// [리팩토링 전] 호출부 — CommonUseService
useRedisService.removeBudgetPolicyUsedUser(policy, issue);
// [리팩토링 전] UseRedisService 내부
public void removeBudgetPolicyUsedUser(Policy policy, Issue issue) {
if (!policy.isBudgetLimitPolicy()) { // 1. 예산 제한 쿠폰이 아니면 즉시 반환 (안전장치)
return;
}
BudgetVerifyVO vo = BudgetVerifyVO.of(policy, issue); // 2. 검증을 통과한 쿠폰만 안전하게 VO 생성
...

// [리팩토링 후] 호출부 — CommonUseService
// of() 메서드가 서비스 밖으로 나오면서, 예산 검증(if문)을 거치기도 전에 무조건 실행됨!
BudgetVerifyVO vo = BudgetVerifyVO.of(policy, issue);
... DB 롤백 코드 ...
useRedisService.removeBudgetPolicyUsedUser(policy, vo);
코드를 따라가 보면 null이 어디서 터졌는지 명확히 보입니다.

- BudgetVerifyVO생성 시 TTL 계산
public static BudgetVerifyVO of(Policy policy, Issue issue) {
return BudgetVerifyVO.builder()
...
.expire(RedisUtil.expire(getTtlExpireDate(policy)))
.build();
}
private static LocalDateTime getTtlExpireDate(Policy policy) {
return DateUtil.getLaterDate(
policy.getIssueEndDate(),
policy.getUseEndDate());
}
2. DateUtil유틸리티 함수 내부
public static LocalDateTime getLaterDate(LocalDateTime a, LocalDateTime b) {
return a.isBefore(b) ? b : a; // ← a가 null이므로 a.isBefore() 실행 시 NPE 발생!
}
기존의 if (policy.isBudgetLimitPolicy()) 문은 단순 조건문이 아니라, 내부 코드는 issueEndDate가 절대 null일 수 없다는 전제조건을 지켜주던 울타리 였습니다.
울타리가 사라지자 일반 쿠폰의 null이 유틸리티 함수까지 그대로 흘러 들어가 폭탄이 터진 것입니다.
조건문을 하나 놓쳤다고 시스템 전체가 흔들리는 구조를 과연 안전하다고 할 수 있을까요?
왜 아무도 잡지 못했을까?
이 버그는 우리가 안전하다고 믿고 있던 방어벽을 교묘하게 전부 통과했습니다.
컴파일러의 한계
전통적인 자바는 타입 시스템 수준에서 null 가능성을 강제하지 못합니다. 조건문을 누락하더라도 런타임에 실제 코드가 실행되기 전까지는 컴파일러가 이를 잡아낼 수 없습니다.
테스트 커버리지의 구멍
단위 테스트는 존재했지만, 비예산 쿠폰과 복원이 결합된 엣지 케이스까지는 테스트 커버리지가 미처 닿지 못했습니다.
코드 리뷰의 함정
메서드 내부가 여러 레이어로 감싸져 있어, 정적 검사만으로는 깊은 곳에 숨어있는 NPE 위험을 직관적으로 발견하기 어려웠습니다.
다음부터 더 주의 깊게 확인하자는 말은 답이 될 수 없었습니다. 어떻게 하면 구조와 시스템으로 null폭탄을 원천 차단할 수 있을까요?
2. NPE를 막기 위해 우리는 어떻게 설계해야 할까?
이번 사건의 본질은 Null이 아님은 예산 제한 쿠폰에서만 유효하다는 도메인 지식이 개발자의 머릿속에만 존재했다는 점입니다.
해법은 단순히 널 체크를 늘리는 것이 아닙니다. 그 지식을 구조에 녹여내어, 개발자가 깜빡하는 것이 불가능한 시스템을 만드는 것입니다.
[설계 1] 플래그 변수를 Optional과 도메인 객체로 승격시키기

조건 분기는 코드를 옮길 때 빠뜨리기 쉽지만, 타입은 생략할 수 없습니다.
기존 방식: 플래그와 null필드의 파편화
public class Policy {
private Boolean budgetFlag; // 예산 제한 여부
private Long budgetAmount; // budgetFlag=false면 null
}
개선 방식: 도메인 객체 응집 및 Optional반환
예산 관련 데이터를 Budget이라는 독립된 클래스로 묶고, 존재하지 않을 수 있음을 Optional 타입으로 명시합니다.
public class Policy {
@Embedded
private Budget budget; // 비예산 정책이면 null
public Optional<Budget> budget() { // 외부 계약을 Optional로 선언
return Optional.ofNullable(budget);
}
}
호출부: 예산 체크 누락이 불가능한 구조
// 반환 타입이 Optional이므로 체크를 빠뜨리고 값에 직접 접근하는 것이 불가능함
policy.budget()
.map(policy -> BudgetVerifyVO.of(policy, issue))
.ifPresent(vo -> useRedisService.removeBudgetPolicyUsedUser(policy, vo));
타입을 통한 상태 표현: 예산이 없을 수 있다는 사실을 boolean 플래그 대신 Optional<Budget>이라는 타입으로 표현했습니다.
실수 가능성 원천 차단: if (isBudgetLimitPolicy()) 같은 분기문은 리팩토링 시 놓치기 쉽습니다. Optional을 사용하면 .ifPresent()나 .map() 같은 체이닝 메서드를 거치지 않고는 접근할 수 없으므로, 예산 검증을 잊는 실수가 성립하지 않게 됩니다.
[설계 2] null 판단을 데이터의 주인 내부로

NPE가 터진 최종 지점은 DateUtil.getLaterDate(a, b) 유틸리티 함수였습니다.
그렇다면 DateUtil 내부에 if (a == null) 같은 방어 코드를 집어넣는 것이 해결책이었을까요?
그렇지 않습니다. 범용 유틸리티는 전달받은 null이 특정 정책을 뜻하는지, 단순 입력 데이터 오류인지 알 수 없습니다. 유틸 함수에 null 처리 로직을 넣으면 도메인 지식이 공통 유틸로 오염되고 맙니다.
비즈니스 규칙은 데이터의 주인이 직접 관리
'시작일'과 '종료일'을 IssuePeriod라는 값 객체(Value Object)로 묶어내면, null을 판단하는 주체가 데이터를 소유주에게 돌아갑니다.
@Embeddable
public class IssuePeriod {
private LocalDateTime startDate;
private LocalDateTime endDate; // null = 무제한 정책
/** 종료일과 후보일 중 더 늦은 날짜 반환 (무제한이면 후보일 반환) */
public LocalDateTime laterOf(LocalDateTime candidate) {
return Objects.isNull(endDate)
? candidate
: DateUtil.getLaterDate(endDate, candidate);
}
}
책임의 응집: "종료일이 null이면 무제한 기간이다"라는 규칙은 그 사실을 잘 아는 IssuePeriod 내부 한 곳에만 모이게 됩니다.
단순해진 호출부: 외부에서는 더 이상 if (endDate == null) 분기문을 작성할 필요 없이, period.laterOf(usableEndDate)라고 메시지만 던지면 됩니다.
[설계 3] null은 진입 시에서 즉시 정리하기

null은 도메인 내부에서 스스로 생겨나지 않습니다. DB의 Nullable 컬럼, 외부 API 응답, 클라이언트 요청 파라미터 등 시스템 외부에서 유입됩니다.
따라서 도메인 코드 곳곳에 if (x == null)을 많이 만드는 것이 아니라, 데이터가 유입되는 곳(Mapper, Adapter, 정적 팩토리)에서 한 번에 정리해야 합니다.
기본값 할당: null인 경우 도메인에 안전한 기본값을 채워서 들여보냅니다.
예외 발생: 필수 데이터가 null로 들어왔다면 예외를 던져 진입을 차단합니다.
명시적 포장: null일 수 있는 값은 경계에서 Optional이나 값 객체로 포장합니다.
이 원칙이 지켜지면 도메인 내부는 null을 의심할 필요가 없는 안전 구역이 됩니다.
// [경계 - Mapper] 외부 엔티티를 도메인 객체로 변환하는 길목
public class CouponMapper {
public static IssuePeriod toDomain(CouponEntity entity) {
// 1. 필수값이 null이면 즉시 진입 차단 (예외 발생)
if (entity.getStartDate() == null) {
// 예외 처리 혹은 안전한 도메인 객체로 포장
}
// 2. 검증을 마친 데이터만 안전한 도메인 객체(VO)로 포장하여 전달
return new IssuePeriod(entity.getStartDate(), entity.getEndDate());
}
}
Optional을 사용해두고 마지막에 .orElse(null)을 부르는 것은 책임을 다음 호출자에게 미루는 행위입니다. 결국 유틸리티 함수나 예상치 못한 지점에서 폭탄이 터지게 됩니다. null에 대한 결정은 항상 들어오는 입구에서 완결지어야 합니다.
[설계 4] 그래도 남는 null에는 이름표를

현실적인 제약도 분명 존재합니다. JPA 엔티티 필드에는 Optional을 선언할 수 없고, 레거시 코드를 하루아침에 전부 값 객체(VO)로 재조립할 수도 없습니다.
당장 지울 수 없어 시스템에 남겨둬야 하는 null이 있다면, 최소한 @Nullable어노테이션으로 명시적인 이름표를 붙여주어야 합니다.
엔티티: @Nullable을 통한 계약 명시
JPA 필드처럼 Optional을 쓸 수 없는 곳에서는 어노테이션을 통해 DB의 Nullable 상태를 명시합니다.
import javax.annotation.Nullable;
@Entity
@Table(name = "coupons")
public class CouponEntity {
@Id @GeneratedValue
private Long id;
// JPA 한계로 Optional은 못 쓰지만, null 가능성을 명시
@Nullable
private LocalDateTime endDate;
// 반환 타입에도 붙여 호출자에게 null 위험을 경고
@Nullable
public LocalDateTime getEndDate() {
return endDate;
}
}
암묵지 제거: 이 값은 없을 수 있다라는 비즈니스 맥락을 명시할 뿐만 아니라, Intellij나 SonarQube 같은 정적 분석 도구가 호출부의 NPE 위험을 컴파일/작성 단계에서 경고해 주도록 만듭니다.
3. 사람 대신 시스템이 기억하게 만드는 법
이번 사건의 가장 큰 교훈은 "개발자의 머릿속 지식은 언제든 누락될 수 있다"는 사실이었습니다.

제가 속한 유저혜택개발팀은 주니어부터 시니어까지 모든 팀원이 일관된 품질을 유지하도록 Claude Code 기반의 자동화 지침을 애플리케이션 저장소 내 SSOT(Single Source of Truth) 브랜치로 관리하고 있습니다.
장애를 겪으며 얻은 교훈이 개인의 메모로 파편화되지 않고, 팀 전체가 공유하는 시스템의 지식으로 한 곳에 계속 축적되는 구조입니다.
- SSOT 전용 브랜치 운영: USERBNFT-CLAUDE-SSOT-(작성자) 형태로 지침 변경을 기능 개발 브랜치와 분리해 PR 리뷰의 초점을 유지하고 이력을 명확히 추적합니다.
- 구현 작업의 에이전트 파이프라인화: /issue {지라 티켓} 명령 하나로 planner -> test-writer -> implementer -> code-reviewer -> ac-checker로 이어지는 5단계 사이클이 작동합니다.
장애 로그가 체크리스트가 되는 피드백 루프
이 사이클과 SSOT는 선순환 피드백 루프로 이어집니다. 장애 발생 시 주기적으로 /improve-issue 명령을 실행해 실행 로그의 장애 요인을 분석하고 파이프라인을 개선합니다.
이번 NPE 장애 역시 분석을 거쳐 implementer 정의 파일(.claude/agents/implementer.md)에 체크리스트로 커밋되었습니다. 이 파일은 사람이 읽는 문서가 아니라, /issue가 실행될 때마다 에이전트의 행동 지침으로 직접 주입되는 프롬프트입니다.
- 신규 도입 필드 NPE 방어 (CRITICAL — 누락 시 게이트 2 재실행 직접 유발):
본 작업이 응답/엔티티에 새로 도입한 필드를 다른 객체 참조로 매핑하는 경우,
참조 대상이 nullable 인지 즉시 확인한다.
- 가드 패턴: `Objects.nonNull(x) ? x.method() : null` 또는
`Objects.isNull(x) ? null : x.method()` 또는 private static 헬퍼 추출
- 확인 기준: 참조 객체의 게터가 `@Nullable`/`Optional`/필수 컬럼이 아닌 경우 → 반드시 가드.
도메인 엔티티 필드는 DB 스키마 NULL 허용 여부와 무관하게 일단 의심.
- 누락 사례 (마찰 유발): `Issue.getUsableEndDate().toLocalDate()`
→ usableEndDate 가 nullable 일 때 무가드 호출
### NPE 방어 및 null 안전 설계 원칙 (도메인 코드 작성 시 필수 준수)
- **[설계 1] 플래그 변수 삭제 및 Optional/도메인 객체 승격**:
`boolean isXxx` 플래그와 `nullable` 필드가 따로 놀지 않도록 독립된 도메인 객체(VO)로 응집하고 외부 반환 시 `Optional`을 사용한다.
- **[설계 2] null 판단 책임의 데이터 소유자 이관**:
범용 유틸리티(`DateUtil`)에 도메인 맥락이 섞인 `if (a == null)`을 넣지 않고, 데이터를 소유한 값 객체 내부에 null 판별 및 기본값 로직을 작성한다.
- **[설계 3] 시스템 경계(Boundary)에서의 즉시 null 정리**:
Mapper, Adapter, 정적 팩토리 등 데이터 유입 경로에서 `Objects.requireNonNull()`로 차단하거나 안전한 기본값으로 채워 들어보낸다.
- **[설계 4] 불가피한 null에 대한 명시적 계약 (@Nullable)**:
JPA 엔티티 한계 등으로 `Optional`을 쓰지 못하는 곳은 `@Nullable`을 명시하여 정적 분석 도구와 호출부에 위험성을 경고한다.
추상적인 null 조심하기 대신, 장애를 일으켰던 실제 누락 사례를 지침에 직접 명시하여 AI 에이전트의 판단 정확도를 높이고 재발을 시스템적으로 차단했습니다.
사이클이 실제로 null을 잡는 모습
/improve-issue명령으로 implementer.md에 추가된 체크리스트 규칙이 주입되어, implementer가 코드 작성 직후 NPE를 사전 차단하게 되는 구조입니다.
✅ 테스트 Green (12/12 통과)
🔧 리팩터링 내용
- NPE 방어 보강: usableEndDate
(신규 필드 매핑 시 nullable 확인 → Objects.isNull 가드 + 헬퍼 메서드 분리)
// implementer가 자기 검토 후 스스로 보강한 코드
private LocalDate toUsableEndDate(Issue issue) {
return Objects.isNull(issue.getUsableEndDate())
? null
: issue.getUsableEndDate().toLocalDate();
}
개발자는 게이트 요약만 확인하고 승인 여부를 결정합니다. null 탐지가 자동화된 구현 사이클 안에서 완결됩니다.
4. 마치며: 기억하는 팀에서, 기록되는 시스템으로
NPE가 가르쳐 준 것은 null 체크 요령이 아니었습니다. NPE는 개인의 부주의가 아니라 구조의 문제라는 사실이었습니다.
좋은 방어는 null에 대한 지식을 타입과 도메인 안에 넣어 깜빡하는 것 자체가 불가능하게 만드는 것입니다. 장애의 교훈이 회고 문서에만 머무는 순간 그 지식은 잊히기 시작합니다.
같은 실수를 두 번 하지 않는 팀은 실수를 시스템에 기록하는 팀이라고 생각합니다.
예전에는 그 기록의 형태가 문서뿐이었지만, 지금은 에이전트의 체크리스트로, 매 작업마다 로드되는 룰로, 사이클 안의 리뷰 기준으로 남길 수 있습니다. 그 기록 비용은 문서를 한 장 쓰는 것과 크게 다르지 않습니다.
긴 글 읽어주셔서 감사합니다.
NPE 하나를 팀의 방어 체계로 바꾸기까지 was originally published in 여기어때 기술블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.