Lang/Java

Java NPE(NullPointerException)과 Spring JSpecify 도입

readyoun 2025. 3. 15. 02:30

십억 달러 실수에서 Spring의 혁신까지

어느날 아침의 악몽

회사 채팅방에 빨간 알림이 계속 올라옵니다. 주말 동안 배포한 새 기능에서 사용자들이 "오류가 발생했습니다" 화면을 보고 있다는 보고입니다. 로그를 열어보니 익숙한 그 녀석이 있습니다.

java.lang.NullPointerException
    at com.company.service.UserServiceImpl.processUserPreferences(UserServiceImpl.java:127)
    at com.company.controller.DashboardController.getUserDashboard(DashboardController.java:56)
    ...

 

가슴이 쿵 내려앉습니다. 테스트 환경에서는 멀쩡했는데, 프로덕션에서는 어떻게 이런 상황이 발생한 걸까요?

 

알다시피, NullPointerException(이하 NPE)은 자바 개발자가 가장 자주 마주치는 런타임 예외 중 하나입니다. 기껏 배포한 서비스가 멈춰버리는 끔찍한 상황을 만들기도 하죠.

NPE란 무엇인가?

reddit ProgrammerHumor

 

NPE는 프로그램이 실행 중에 갑자기 멈추는 가장 흔한 오류 중 하나입니다. 이는 '없는' 것을 사용하려고 할 때 발생합니다. 

실생활 비유

NPE는 마치 텅 빈 냉장고에서 음식을 꺼내려는 것과 비슷합니다. 냉장고(변수)가 비어있는데(null) 음식(데이터나 메서드)을 꺼내려고 하면 당연히 문제가 생깁니다. 

간단한 예시

String name = null;  // 이름이 없는 상태
System.out.println(name.length());  // 없는 이름의 길이를 알아보려고 함

 

 

'없는' 이름의 길이를 알아보려고 하기 때문에 NPE가 발생합니다.

고충.....

  1. 숨바꼭질하는 버그: NPE는 프로그램이 실행될 때만 나타납니다. 마치 숨바꼭질하는 버그처럼, 찾기가 어렵습니다. 
  2. 과도한 확인 작업: NPE를 피하려고 모든 것이 '있는지' 계속 확인해야 합니다. 마치 요리할 때마다 냉장고에 재료가 있는지 계속 확인하는 것과 비슷합니다. (너무 귀찮습니다..)

    if (person != null) { if (person.getName() != null) { System.out.println(person.getName().toUpperCase()); } }

  3. 예측 불가능: 때때로 NPE는 예상치 못한 곳에서 발생합니다. 마치 갑자기 바닥에 씽크홀이 생기는 것처럼요...
  4. 테스트의 어려움: 모든 가능한 '없는' 경우를 테스트하는 건 정말 힘듭니다. 결벽증 걸린 것처럼 집 구석구석을 쥐잡듯 뒤집어 점검하는 것 같습니다.

하지만 이런 고통스러운 경험이 곧 하하 그랬었지 하고 코-쓱하게 될 과거가 될지도 모릅니다.

 

최근 Spring 프레임워크가 JSpecify 어노테이션 도입 소식을 발표했거든요. 그 이야기를 들려드리기 전에, 먼저 이 악명 높은 예외의 근원으로 거슬러 올라가 봅시다.


십억 달러의 실수

2009년, 영국의 컴퓨터 과학자 토니 호어(Tony Hoare)는 한 컨퍼런스 연설에서 이런 고백을 했습니다.

"null 참조를 발명한 것은 1965년의 일입니다. 당시 저는 객체 지향 언어를 위한 첫 번째 종합적인 타입 시스템을 설계하고 있었습니다. 제 목표는 컴파일러가 자동으로 검사할 수 있는 모든 참조 사용이 절대적으로 안전하도록 보장하는 것이었죠. 하지만 저는 구현하기 쉽다는 유혹을 이기지 못하고 null 참조를 넣었습니다. 이로 인해 수많은 오류와 취약성, 시스템 충돌이 발생했으며, 지난 40년간 이로 인한 피해는 아마도 십억 달러에 달할 것입니다."

 

호어가 "십억 달러의 실수"라고 부른 이 null 참조는 자바를 포함한 많은 현대 프로그래밍 언어에 영향을 미쳤습니다. 왜 이런 단순한 개념이 그토록 큰 문제를 일으키는 걸까요?

NPE가 특별히 위험한 이유

일반적인 컴파일 오류와 달리 NPE는 런타임에 발생합니다.

 

즉, 코드는 문법적으로 완벽해 보이고 컴파일도 잘 되지만, 프로그램이 실행 중일 때 갑자기 발생합니다.

최악의 경우 실제 사용자가 서비스를 이용하는 도중에도 나타납니다. 

 

아래 코드를 한번 살펴봅시다.

public String getUserDisplayName(Long userId) {
    User user = userRepository.findById(userId).orElse(null);
    return user.getDisplayName();
}

 

이 코드는 컴파일러 입장에서는 아무 문제가 없습니다.

 

하지만 userId에 해당하는 사용자가 데이터베이스에 없다면?

user는 null이 되고, 그 상태에서 getDisplayName()을 호출하면 NPE가 발생합니다.

 

더 교묘한 경우도 있습니다. 

public void processOrder(Order order) {
    if (order.getCustomer().getAddress().getCountry().equals("Korea")) {
        applyDomesticShipping(order);
    } else {
        applyInternationalShipping(order);
    }
}

 

여기서 order, getCustomer(), getAddress() 등 어느 하나라도 null이면 NPE가 발생합니다. 메서드 체인이 길어질수록 잠재적 NPE 위험은 당연히 기하급수적으로 증가합니다. 

null의 모호한 의미

null의 진짜 문제는 그 의미가 너무 모호하다는 점입니다. null은 다음 중 어떤 의미일까요?

  1. 값이 아직 설정되지 않았다
  2. 값이 의도적으로 비어있다
  3. 오류가 발생했다
  4. 관련 정보가 없다
  5. 그냥 개발자가 실수로 값을 설정하지 않았다

정답은 "1~5 전부 될 수 있다"입니다. 이게 문제입니다. 

 

동일한 표현 null이 완전히 다른 상황을 나타낼 수 있어, 코드를 읽는 개발자는 null의 실제 의미를 파악하기 위해 더 많은 맥락을 이해해야 합니다.

 

이러면 아무리 어디서 NPE가 발생하는지 알아도 디버깅하기 어렵습니다. 전 그렇습니다. 특히 다른 사람이 작성한 코드를 볼 때는 더합니다. 

기존의 대응 방식과 그 한계

선배 자바 개발자들은 NPE를 방지하기 위해 다양한 방법을 시도해 왔습니다.

1. 방어적 프로그래밍 (null 체크의 향연)

public String getUserDisplayName(Long userId) {
    User user = userRepository.findById(userId).orElse(null);
    if (user != null) {
        String displayName = user.getDisplayName();
        if (displayName != null) {
            return displayName;
        }
    }
    return "Guest";
}

 

이런 코드는 안전하지만, null 체크가 늘어날수록 코드의 가독성은 급격히 떨어집니다. 핵심 비즈니스 로직이 null 체크에 파묻혀 버렸습니다.. (Optional을 알기 전에는 정말 이렇게 일일이 했던 기억이..ㅠㅠ) 

2. Java 8의 Optional

Java 8에서 null 참조 문제를 해결하려고 Optional을 도입하기도 했습니다.

public String getUserDisplayName(Long userId) {
    return userRepository.findById(userId)
            .map(User::getDisplayName)
            .orElse("Guest");
}

 

Optonal의 O도 보이지 않는데 뭔 말이냐구요? Optional은 여기서 패시브입니다. 

 

  1. userRepository.findById(userId) 메서드는 Spring Data JPA 리포지토리의 표준 메서드인데, 이 메서드는 Optional<User>를 반환합니다. (Spring Data JPA의 리포지토리 인터페이스 구현 참고) 
  2. .map(...) 메서드는 Java 스트림 API에도 있지만, 여기서는 Optional 클래스의 메서드입니다. Optional에 값이 있으면 그 값을 변환합니다.
  3. .orElse("Guest") - 이 메서드는 Optional 클래스의 메서드로, Optional이 비어있을 때 대체 값을 제공합니다.

확실히 개선되었지만, 여전히 한계가 있습니다.

  1. Optional은 메서드 반환 값에만 주로 사용되고, 필드나 메서드 매개변수로는 권장되지 않습니다.
  2. 기존 API와의 호환성 문제가 있습니다.
  3. Optional 자체가 null이 될 수 있는 아이러니한 상황도 있습니다.

3. 정적 분석 도구

FindBugs, Sonar, IntelliJ IDEA 등의 도구가 잠재적인 NPE를 감지해주긴 하지만, 사실 코드 작성 후 별도로 실행해야 하는 외부 도구입니다. 그리고 항상 모든 문제를 찾아내지는 못합니다. 그래서 그냥 Kotlin 쓰라고 합니다들. 


Spring이 드디어... JSpecify NullAway 도입 

최근 Spring 프레임워크는 이 오랜 문제에 대한 새로운 해결책을 도입했습니다. JSpecify 어노테이션과 NullAway를 통한 명시적 null-safety 지원입니다.

JSpecify 프로젝트란?

JSpecify는 Google, 스프링 제작사 Pivotal(현 VMware), Oracle 등이 참여하는 공동 프로젝트인데요. 자바 코드에서 null 가능성을 명시적으로 표현하기 위한 표준화된 어노테이션 세트를 제공합니다. 이전에도 @Nullable, @NonNull 같은 어노테이션이 있었지만, 각 라이브러리마다 다른 구현을 사용해 혼란스러웠다고 합니다. 

Spring의 JSpecify 도입

Spring은 이제 다음과 같은 JSpecify 어노테이션을 공식 지원해서 표준화합니다.

  • @NonNull: 이 값은 절대 null이 될 수 없습니다.
  • @Nullable: 이 값은 null이 될 수 있습니다.

이 두 가지 간단한 어노테이션이 코드를 어떻게 바꾸는지 살펴보겠습니다.

// 전통적인 방식
public User findUserById(Long userId) {
    // userId가 null이면? 반환값이 null일 수도 있을까?
    // 문서를 찾아보거나 코드를 따라가봐야 알 수 있음
    return userRepository.findById(userId).orElse(null);
}

// JSpecify 어노테이션 사용
public @Nullable User findUserById(@NonNull Long userId) {
    // 이제 명확합니다:
    // 1. userId는 null이 될 수 없습니다 (호출자의 책임)
    // 2. 반환값은 null이 될 수 있습니다 (호출자가 처리해야 함)
    return userRepository.findById(userId).orElse(null);
}

 

이것은 단순한 문서화 수준이 아닙니다.

JSpecify 어노테이션과 함께 Spring은 NullAway라는 도구를 도입해 빌드 시점에 이러한 규약을 검사하게 됩니다. 

NullAway: 빌드 시점의 안전망

NullAwayUber에서 개발한 오픈소스 도구로, 자바 컴파일 과정에서 null 관련 오류를 감지합니다.

 

즉, NullAway는 앞서 언급한 문제들을 미리 찾아내는 도구입니다. 프로그램을 실행하기 전에 "여기 문제가 있을 수 있어요!"라고 알려주는 거죠. 굉장히 유용한 알림이가 되겠네요. 

 

예를 들어,

@NonNull User user = findUserById(null); // 컴파일 오류: null은 @NonNull 매개변수에 전달될 수 없습니다

@Nullable User user = findUserById(123L);
String name = user.getName(); // 컴파일 오류: @Nullable 객체에 대한 메서드 호출 전에 null 체크가 필요합니다

 

이렇게 하면 런타임에 발생할 NPE를 컴파일 시점에 잡아낼 수 있습니다. 런타임 오류가 컴파일 시점 오류로 바뀌는 것이죠.

 

자동차가 충돌한 후에 에어백이 터지는 것이 아니라, 충돌이 일어나기 전에 위험을 감지하고 미리 알려주는 것과 같다고 비유할 수 있겠네요.

아무튼 사고가 터지기 전에 미리 알려주는 겁니다. 

실제 개발에 적용해보기

JSpecify와 NullAway를 프로젝트에 도입하면 어떤 변화가 생길까요?

1. 코드의 의도가 명확해집니다

public interface UserService {
    @Nullable User findByEmail(@NonNull String email);
    void updateProfile(@NonNull User user, @Nullable Address newAddress);
    @NonNull List<Order> getRecentOrders(@NonNull User user, @Nullable Integer limit);
}

 

인터페이스만 봐도 각 메서드가 null을 어떻게 처리하는지 명확히 알 수 있습니다. 확실히 새로운 팀원이 합류하거나 오랜 시간이 지난 후 코드를 다시 볼 때 특히 유용하겠네요. 

2. IDE 지원으로 개발 경험이 향상됩니다

IntelliJ IDEA, Eclipse, VS Code 등 대부분의 요즘 IDE는 이런 어노테이션을 인식하고 잠재적인 null 오류를 실시간으로 표시해 줍니다. 코드를 작성하는 순간에 바로 피드백을 받을 수 있게 되는 거죠. 생각만 해도 좋네요. 

3. 점진적으로 도입할 수 있습니다

기존 대규모 프로젝트에 갑자기 모든 null 체크를 추가하는 것은 불가능할 겁니다(가능하다면 알려주세요 일단 전 안 돼요). JSpecify와 NullAway의 장점은 점진적으로 도입할 수 있다는 점입니다. 중요한 코어 모듈부터 시작해서 천천히 확장해 나갈 수 있습니다.

전망: null이 없는 세상? 

Spring의 JSpecify 도입은 자바 생태계가 null의 문제를 더 진지하게 받아들이고 있다는 신호라는 해석이라고 하던데요. 

 

이러한 움직임은 Kotlin이나 Rust 같은 현대 언어들이 처음부터 null 안전성을 핵심 기능으로 포함한 것과 같은 맥락입니다.

 

관심 있는 분들은 아시다시피, Kotlin의 경우 타입 시스템 자체에 null 가능성이 통합되어 있습니다. 

// Kotlin에서는 기본적으로 모든 타입이 non-null
fun getUserName(userId: Long): String { ... }

// null이 될 수 있다면 타입 뒤에 ?를 붙임
fun findUser(email: String): User? { ... }

// null 가능성이 있는 값 사용 시 컴파일러가 안전한 접근을 강제함
val user: User? = findUser("email@example.com")
val name = user?.name // 안전한 호출(Safe call): user가 null이면 name도 null

 

Spring의 JSpecify 도입은 자바에서도 이와 유사한 안전성을 제공하려는 움직임이라는 것 같습니다. 물론 언어 자체에 내장된 기능보다는 제한적이지만, 기존 코드베이스와의 호환성을 유지하면서도 null 안전성을 높일 수 있는 실용적인 접근법으로 보입니다. 

결론: 십억 달러의 실수를 넘어서

토니 호어가 십억 달러의 실수라고 부른 null 참조는 50년이 넘는 시간 동안 개발자들을 괴롭혀 왔습니다. 하지만 이제 Spring이 JSpecify 도입으로 null 조기감지를 표준화한다고 하니 당장은 새로운 Java를 쓰지 않더라도 희망이 보입니다. 

 

NPE는 당분간 완전히 사라지지는 않겠지만, 적어도 이걸로 그 발생 빈도를 크게 줄일 수 있게 될 것 같습니다. 더 안정적인 소프트웨어, 더 적은 야간 긴급 수정, 그리고 궁극적으로 더 행복한 개발자(와 사용자)를 상상하게 되네요 후후 

 

JSpecify와 같은 도구를 프로젝트에 도입하는 건, 단순 기술 개선 수준이 아니라 개발 문화 변화 또한 따라오지 않을까 싶습니다. 이제 "이 값이 null일 수 있을까?"라는 질문은 더 이상 추측의 영역이 아니라, 코드 자체가 명확히 대답해주는 질문이 되겠습니다. 

 

주말 중에 채팅방에 빨간 알림이 뜨지 않기를 바랍니다. 혹시 뜨더라도 그게 이제 NPE는 아니기를 바라봅니다. 

 


참고자료