TestForge Blog

Spring Boot NullPointerException 원인 분석과 예방 패턴

Spring Boot 개발에서 자주 발생하는 NPE 원인 7가지와 Optional, 방어적 코딩, 테스트로 근본적으로 예방하는 방법.

TestForge Team ·

NPE가 위험한 이유

java.lang.NullPointerException: Cannot invoke
"com.example.UserService.getUser(Long)" because "this.userService" is null

스택 트레이스만으로는 null인지 파악하기 어렵습니다.
Java 14+의 Helpful NPE는 메시지가 상세해졌지만, 예방이 최선입니다.

원인 1: @Autowired 필드 주입 + 생성자 직접 호출

// 위험
@Service
public class OrderService {
    @Autowired
    private UserService userService;  // 필드 주입
    
    // 테스트에서 new OrderService()로 생성하면 userService = null
}

// 안전: 생성자 주입
@Service
@RequiredArgsConstructor  // Lombok
public class OrderService {
    private final UserService userService;  // null 불가능
}

생성자 주입을 사용하면:

  • 의존성이 명시적
  • 테스트에서 new OrderService(mockUserService) 가능
  • final 필드 → NPE 원천 차단

원인 2: JPA Optional 무시

// 위험
User user = userRepository.findById(id).get();  // 없으면 NoSuchElementException
// 또는
User user = userRepository.findByEmail(email);  // null 반환 가능

// 안전
User user = userRepository.findById(id)
    .orElseThrow(() -> new UserNotFoundException("User not found: " + id));

// 또는 null safe 처리
Optional<User> optUser = userRepository.findByEmail(email);
optUser.ifPresent(u -> sendWelcomeEmail(u.getEmail()));

원인 3: Map.get() 결과 미확인

Map<String, Config> configMap = getConfigMap();

// 위험
String value = configMap.get("key").getValue();  // null.getValue() → NPE

// 안전
Config config = configMap.get("key");
if (config != null) {
    String value = config.getValue();
}

// 또는
String value = Optional.ofNullable(configMap.get("key"))
    .map(Config::getValue)
    .orElse("default");

// 또는
Config config = configMap.getOrDefault("key", Config.defaultConfig());

원인 4: 외부 API 응답 필드 무시

// 위험
ExternalResponse response = apiClient.call();
String name = response.getData().getUser().getName();  // 연쇄 NPE

// 안전
String name = Optional.ofNullable(response)
    .map(ExternalResponse::getData)
    .map(Data::getUser)
    .map(User::getName)
    .orElse("Unknown");

원인 5: 컬렉션 반환값

// 위험: null 반환
public List<Order> getOrders(Long userId) {
    if (!userExists(userId)) return null;  // 호출자가 null 체크 안 하면 NPE
    // ...
}

// 안전: 빈 컬렉션 반환
public List<Order> getOrders(Long userId) {
    if (!userExists(userId)) return Collections.emptyList();
    // ...
}

규칙: 컬렉션을 반환하는 메서드는 절대 null을 반환하지 않는다.

원인 6: @Value 주입 실패

@Value("${app.secret}")
private String secret;

// 프로퍼티가 없으면 BeanCreationException (null이 아닌 예외)
// 하지만 기본값 설정 없으면 운영 환경에서 문제
// 안전: 기본값 또는 필수 표시
@Value("${app.secret:}")
private String secret;  // 없으면 빈 문자열

// 필수 값은 @NotBlank로 검증
@NotBlank
@Value("${app.secret}")
private String secret;

원인 7: @RequestParam / @PathVariable 처리

// 위험
@GetMapping("/users")
public ResponseEntity<?> getUsers(@RequestParam String email) {
    User user = userService.findByEmail(email);
    return ResponseEntity.ok(user.toDto());  // user가 null이면 NPE
}

// 안전
@GetMapping("/users")
public ResponseEntity<?> getUsers(@RequestParam String email) {
    return userService.findByEmail(email)
        .map(user -> ResponseEntity.ok(user.toDto()))
        .orElse(ResponseEntity.notFound().build());
}

예방: Null 분석 도구

<!-- SpotBugs + Find Bugs -->
<dependency>
    <groupId>com.github.spotbugs</groupId>
    <artifactId>spotbugs-annotations</artifactId>
    <scope>provided</artifactId>
</dependency>
import javax.annotation.Nonnull;
import javax.annotation.Nullable;

public class UserService {
    @Nonnull
    public User getUser(@Nonnull Long id) { ... }
    
    @Nullable
    public User findByEmail(@Nonnull String email) { ... }
}

IDE(IntelliJ)가 @Nullable 반환값을 null 체크 없이 사용하면 경고를 표시합니다.

빠른 예방 규칙

  1. 생성자 주입 사용 (@RequiredArgsConstructor)
  2. Optional.orElseThrow / orElse 적극 활용
  3. 컬렉션 반환 시 null 대신 빈 컬렉션
  4. 외부 데이터는 Optional 체인으로 처리
  5. @Nonnull / @Nullable 어노테이션으로 의도 명시
  6. 단위 테스트에서 null 케이스 반드시 포함
// 테스트 예시
@Test
void getUser_notFound_throwsException() {
    when(userRepository.findById(999L)).thenReturn(Optional.empty());
    
    assertThatThrownBy(() -> userService.getUser(999L))
        .isInstanceOf(UserNotFoundException.class)
        .hasMessageContaining("999");
}