Spring Boot 4의 JSpecify + NullAway로 NullPointerException을 빌드 타임에 잡은 경험
Spring Framework 7이 도입한 JSpecify 기반 null-safety 어노테이션과 NullAway를 실무 프로젝트에 적용하여, 런타임에 터지던 NPE를 빌드 단계에서 차단한 과정과 결과를 정리합니다.

운영 중인 서비스에서 NullPointerException이 터지면, 원인을 찾는 것보다 "왜 이걸 미리 못 잡았지?"라는 자괴감이 더 클 때가 있습니다.
저도 비슷한 경험이 있었습니다. 어느 날 Sentry에 NPE 알림이 올라왔는데, 원인은 외부 API 응답의 특정 필드가 null로 내려온 것이었습니다. 코드 리뷰에서도, 단위 테스트에서도, 통합 테스트에서도 잡지 못한 케이스였습니다. 해당 필드가 null일 수 있다는 사실을 아무도 명시하지 않았기 때문입니다.
Java에서 null은 늘 이런 식이었습니다. 타입 시스템이 "이 값은 null일 수 있다"를 표현하지 못하니, 개발자의 기억과 주의력에 의존할 수밖에 없었습니다. Kotlin의 String?처럼 컴파일러가 강제해 주는 장치가 Java에는 없었던 거죠.
그러던 중 Spring Framework 7 / Spring Boot 4가 기존의 @Nullable 어노테이션을 JSpecify 기반으로 전면 교체했다는 소식을 접했습니다. 그리고 이 JSpecify 어노테이션을 NullAway라는 정적 분석 도구와 함께 쓰면, 빌드 시점에 NPE 가능성을 컴파일 에러로 잡아준다는 것을 알게 되었습니다.
JSpecify가 뭐가 다른 걸까
Java 생태계에는 null-safety 어노테이션이 너무 많았습니다. javax.annotation.Nullable, org.jetbrains.annotations.Nullable, org.springframework.lang.Nullable, edu.umd.cs.findbugs.annotations.Nullable 등 선택지만 다섯 개가 넘었습니다.
문제는 이 어노테이션들이 서로 호환되지 않았다는 점입니다. 라이브러리 A는 JetBrains 어노테이션을 쓰고, 라이브러리 B는 JSR-305을 쓰면, 둘을 함께 사용하는 프로젝트에서는 null-safety 정보가 뒤섞였습니다.
JSpecify(org.jspecify)는 이 혼란을 정리하기 위해 Google, JetBrains, Oracle, Spring 팀 등이 함께 만든 표준입니다. Spring Framework 7에서는 기존의 org.springframework.lang.Nullable을 org.jspecify.annotations.Nullable로 교체했습니다.
import org.springframework.lang.Nullable;
import org.springframework.lang.NonNull;
public interface UserRepository {
@Nullable
User findByEmail(String email);
}import org.jspecify.annotations.Nullable;
import org.jspecify.annotations.NonNull;
public interface UserRepository {
@Nullable User findByEmail(String email);
}표면적으로는 import 경로만 바뀐 것처럼 보입니다. 하지만 핵심적인 차이가 하나 있습니다. JSpecify는 패키지 레벨에서 기본 nullness를 선언할 수 있습니다.
@NullMarked
package com.example.order.domain;
import org.jspecify.annotations.NullMarked;@NullMarked를 선언하면, 해당 패키지의 모든 타입 참조는 기본적으로 non-null로 간주됩니다. null이 될 수 있는 곳에만 @Nullable을 명시하면 되니, 어노테이션 양이 크게 줄어듭니다. 마치 "이 건물의 모든 문은 잠겨 있고, 열어둘 문만 표시한다"는 규칙과 비슷합니다.
NullAway 도입 — 어노테이션만으로는 부족했다
JSpecify 어노테이션을 열심히 달아도, 그 자체만으로는 컴파일 에러가 나지 않습니다. 어노테이션은 "이 값은 null일 수 있다"는 메타데이터일 뿐, 이를 검사하는 도구가 별도로 필요합니다.
처음에는 IDE의 경고만으로 충분할 거라고 생각했습니다. IntelliJ가 노란 밑줄을 그어주니까요. 하지만 현실은 달랐습니다. IDE 경고는 무시하기 너무 쉬웠고, CI/CD 파이프라인에서는 아예 동작하지 않았습니다.
결국 NullAway를 도입하기로 했습니다. Uber에서 만든 이 도구는 Error Prone 플러그인 위에서 동작하며, JSpecify 어노테이션을 기반으로 null-safety 위반을 컴파일 에러로 보고합니다.
Gradle 설정
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.0'
id 'net.ltgt.errorprone' version '4.1.0'
}
dependencies {
// JSpecify 어노테이션
implementation 'org.jspecify:jspecify:1.0.0'
// NullAway + Error Prone
errorprone 'com.google.errorprone:error_prone_core:2.36.0'
errorprone 'com.uber.nullaway:nullaway:0.12.3'
}
tasks.withType(JavaCompile).configureEach {
options.errorprone {
option("NullAway:AnnotatedPackages", "com.example")
option("NullAway:JSpecifyMode")
error("NullAway") // 경고가 아닌 에러로 처리
}
}핵심은 세 가지입니다.
AnnotatedPackages— NullAway가 검사할 패키지 범위를 지정합니다.JSpecifyMode— JSpecify 어노테이션을 인식하도록 활성화합니다.error("NullAway")— 위반 사항을 경고가 아닌 컴파일 에러로 올립니다.
처음 빌드를 돌렸을 때 벌어진 일
설정을 마치고 ./gradlew build를 실행한 순간, 빌드가 실패했습니다. 에러가 무려 47개.
솔직히 당황했습니다. 잘 돌아가던 코드인데 갑자기 47개의 문제가 있다니. 하지만 하나하나 살펴보니 "왜 이걸 지금까지 방치했지?"라는 생각이 드는 것들이 대부분이었습니다.
// 에러: findByEmail의 반환 타입이 @Nullable인데, null 체크 없이 사용
User user = userRepository.findByEmail(email);
String name = user.getName(); // NullAway 에러 발생!이런 코드는 "보통은 null이 아니니까 괜찮겠지"라는 낙관에 기대고 있었습니다. 실제로 99%의 경우에는 문제없이 동작했을 겁니다. 하지만 나머지 1%가 새벽 3시에 장애 알림으로 찾아오는 게 문제였죠.
수정 전후 비교
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| null 체크 | 개발자 판단에 의존 | 컴파일러가 강제 |
| NPE 발견 시점 | 런타임 (운영 환경) | 빌드 타임 (CI 단계) |
| 어노테이션 표준 | Spring, JetBrains 등 혼용 | JSpecify 단일 표준 |
| 패키지 기본값 | 없음 (모든 곳에 명시) | @NullMarked로 non-null 기본 |
| CI 통합 | 불가 (IDE 경고만) | Error Prone + NullAway로 빌드 실패 |
실제 코드에 적용한 과정
47개의 에러를 한 번에 고치려고 하면 압도당합니다. 저는 패키지 단위로 점진적으로 적용하는 전략을 택했습니다.
1단계: 도메인 레이어부터 시작
가장 먼저 domain 패키지에 @NullMarked를 적용했습니다. 도메인 객체는 비즈니스 규칙의 핵심이고, null이 허용되는 필드가 명확하기 때문입니다.
@NullMarked
package com.example.order.domain;
import org.jspecify.annotations.NullMarked;public class Order {
private Long id;
private String orderNumber;
private @Nullable String memo; // 메모는 선택 사항
private @Nullable LocalDate cancelledAt; // 취소일은 없을 수 있음
private OrderStatus status;
// NullAway 덕분에 memo를 사용하는 곳에서는
// 반드시 null 체크가 강제됩니다
}2단계: 서비스 레이어로 확장
서비스 레이어에서는 Repository 반환값의 nullability가 핵심이었습니다.
public OrderResponse getOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
// orElseThrow 이후이므로 order는 non-null — NullAway가 이를 인식합니다
return OrderResponse.from(order);
}
public @Nullable Order findLatestOrder(Long userId) {
// 최근 주문이 없을 수 있으므로 @Nullable 명시
return orderRepository.findTopByUserIdOrderByCreatedAtDesc(userId);
}이전에 예외 처리 전략을 설계할 때 orElseThrow로 비즈니스 예외를 던지는 패턴을 정해 뒀던 것이 여기서 도움이 되었습니다. Optional에서 값을 꺼내는 시점이 명확해지니, NullAway도 그 이후의 코드가 안전하다는 것을 알 수 있었습니다.
3단계: 외부 연동 레이어
가장 많은 에러가 나온 곳이 외부 API 연동 코드였습니다. 외부 시스템의 응답은 어떤 필드든 null이 될 수 있기 때문입니다.
public record PaymentGatewayResponse(
String transactionId,
@Nullable String failureReason,
@Nullable String receiptUrl,
PaymentStatus status
) {}외부 응답을 내부 도메인으로 변환하는 매퍼(Mapper)에서 null 체크가 자연스럽게 강제되었습니다. 이전이라면 "이 필드가 null일 수도 있다"를 Javadoc이나 주석으로 남기거나, 아예 잊어버리는 경우가 많았습니다.
적용 과정에서 만난 함정들
순탄하기만 했던 것은 아닙니다. 몇 가지 실수와 시행착오가 있었습니다.
함정 1: 제네릭 타입의 nullability
// List<@Nullable String>과 List<String>은 다른 타입으로 취급됩니다
public List<String> getActiveUserNames() {
return users.stream()
.map(User::getNickname) // getNickname()이 @Nullable이면 에러!
.toList();
}JSpecify의 JSpecifyMode에서는 제네릭 타입 인자의 nullability도 검사합니다. List<String>은 "null이 아닌 String만 담는 리스트"를 의미하므로, @Nullable인 값을 그대로 넣으면 에러가 발생합니다. filter(Objects::nonNull)을 추가하거나, 반환 타입을 List<@Nullable String>으로 바꿔야 했습니다.
함정 2: 테스트 코드에서의 적용 범위
테스트 코드에도 NullAway를 적용할지 고민했습니다. 결론적으로 테스트 코드에는 적용하지 않았습니다.
tasks.named('compileTestJava').configure {
options.errorprone {
disable("NullAway")
}
}테스트에서는 의도적으로 null을 전달해서 예외 처리를 검증하는 경우가 많기 때문입니다. Testcontainers 기반 통합 테스트에서도 경계값 테스트를 위해 null을 넘기는 케이스가 있었는데, NullAway가 이를 막아버리면 오히려 테스트 커버리지가 떨어질 수 있었습니다.
함정 3: 서드파티 라이브러리와의 충돌
아직 JSpecify를 채택하지 않은 라이브러리를 사용할 때, NullAway는 해당 라이브러리의 반환값을 non-null로 가정합니다. 이게 실제로는 null을 반환하는 경우 오탐(false negative)이 발생할 수 있습니다.
이 문제는 외부 라이브러리를 감싸는 래퍼(Wrapper) 클래스에서 명시적으로 @Nullable을 선언하는 방식으로 해결했습니다.
public @Nullable String fetchExternalData(String key) {
// 서드파티 라이브러리는 null을 반환할 수 있지만
// 해당 라이브러리에 JSpecify 어노테이션이 없음
String result = thirdPartyClient.getData(key);
return result; // 래퍼에서 @Nullable로 명시
}도입 후 한 달간의 변화
NullAway를 적용하고 한 달이 지난 후, 체감되는 변화가 몇 가지 있었습니다.
NPE 관련 Sentry 알림이 확연히 줄었습니다. 물론 NullAway가 모든 NPE를 잡아주는 것은 아닙니다. 리플렉션이나 직렬화/역직렬화 과정에서 발생하는 NPE는 여전히 런타임에서만 잡을 수 있습니다. 하지만 "개발자의 실수로 null 체크를 빠뜨린" 유형의 NPE는 거의 사라졌습니다.
코드 리뷰가 빨라졌습니다. 이전에는 "이 메서드가 null을 반환할 수 있나요?"라는 질문이 리뷰 코멘트에 자주 등장했습니다. 이제는 시그니처만 보면 알 수 있으니, 비즈니스 로직에 집중할 수 있게 되었습니다.
신규 팀원 온보딩이 편해졌습니다. @Nullable이 붙어 있지 않은 파라미터에 null을 넘기면 빌드가 깨지니, "이 프로젝트에서 null을 다루는 규칙"을 문서 대신 컴파일러가 가르쳐 줍니다.
빌드 시간에 대한 걱정도 있었는데, NullAway는 타입 체커가 아닌 가벼운 어노테이션 프로세서 수준으로 동작하기 때문에, 빌드 시간 증가는 약 5% 이내로 미미했습니다. 배포 전 체크리스트에 "NullAway 에러 0건 확인" 항목을 하나 추가하는 정도면 충분했습니다.
기존 프로젝트에 점진적으로 적용하는 팁
새 프로젝트라면 처음부터 @NullMarked를 최상위 패키지에 적용하면 됩니다. 하지만 기존 프로젝트에 한꺼번에 적용하면 수백 개의 에러가 쏟아질 수 있습니다.
저는 이렇게 접근했습니다.
- NullAway를 경고(warn) 모드로 먼저 켠다 —
error("NullAway")대신warn("NullAway")로 시작해서 전체 규모를 파악합니다. - 핵심 도메인 패키지부터
@NullMarked적용 — 에러 수가 적고 영향이 큰 곳부터 시작합니다. - 패키지 하나를 완전히 정리한 뒤 다음으로 이동 — 반쯤 적용된 상태가 가장 혼란스럽습니다.
- CI에서 error 모드로 전환 — 팀 전체가 동의한 시점에 경고를 에러로 올립니다.
마무리
처음에는 "어노테이션 하나 더 다는 게 뭐가 그렇게 대단한가"라고 생각했습니다. 하지만 NullAway와 결합된 JSpecify 어노테이션은 단순한 문서화를 넘어, 컴파일러가 null 계약(contract)을 강제하는 장치가 되었습니다.
Java가 Kotlin처럼 언어 차원에서 null-safety를 지원하지 않는 것은 여전히 아쉽습니다. 하지만 JSpecify + NullAway 조합은 그 간극을 상당 부분 메워줍니다. 특히 Spring Framework 7이 자체 어노테이션 대신 JSpecify를 채택하면서, Spring 생태계 전체가 하나의 null-safety 표준으로 수렴하고 있다는 점이 의미 있었습니다.
47개의 빌드 에러를 고치는 데는 이틀이 걸렸습니다. 하지만 그 이틀이 앞으로 새벽에 울릴 수도 있었던 수많은 장애 알림을 미리 막아줬다고 생각하면, 충분히 가치 있는 투자였습니다.