최근 스프링 부트 애플리케이션이 초기화되는 시점에서 발생한 예외가 적절하게 처리되지 않아 서버가 종료되는 이슈가 있었어요.
흥미로운 점은 초기화 과정에서 호출된 로직이 일반적인 런타임 환경에서 처리 중에는 정상적으로 동작하는 코드였고, 발생한 예외가 Unchecked Exception의 범주인 Runtime Exception임에도 불구하고 서버가 다운되는 현상이 발생했다는 점이에요. 왜 초기화 과정에서는 다른 결과가 나타날까요?
이번 글에서는 스프링 부트 애플리케이션의 초기화 과정에서 Runtime Exception이 발생했을 때, 서버가 다운되는 이유에 대해서 정리한 내용을 공유해 봅니다.
스프링 부트에서는 애플리케이션이 시작되고 트래픽을 받기 전, 특정 코드를 실행해야 할 경우 CommandLineRunner
또는 ApplicationRunner
인터페이스를 구현하도록 안내하고 있답니다. 발견한 이슈도 코드를 찾아보니 CommandLineRunner 인터페이스 구현체의 run 메서드의 초기화 로직에서 발생했어요.
// Ex. JPA 쿼리 메서드 단건 조회 및 예외 처리 구문
public XXX findById(...) {
return xxxRepository.findById(...).orElseThrow(() -> new XxxException(...));
}
문제가 되는 로직은 위 코드와 유사한 메서드의 형태로 Runtime Exception을 상속하는 Custom Exception을 던지는 간단한 코드였어요. 런타임 환경에서는 정상적으로 수행되던 메서드가 CommandLineRunner 인터페이스의 구현체 내 run 메서드에서 호출되었을 때 문제가 발생합니다.
처음엔 ExceptionHandler와 같은 예외 처리기가 발생한 Runtime Exception을 최종적으로 처리한다고 추측했지만, 아니었어요. 초기화 로직에서 Runtime Exception이 발생할 경우 곧 바로 Application run failed
로그를 출력하고 서버가 종료되어 버립니다.
문제 원인을 간단하게 정리해볼까요?
👀 CommandLineRunner 인터페이스의 구현체의 초기화 로직(run 메서드)에서 예외가 처리되지 않았을 경우 애플리케이션은 실행을 중단하고 종료된다!
분명 런타임 시점에서는 정상적으로 예외가 처리되던 메서드인데, 초기화 시점에서 호출했다는 이유만으로 예외가 어째서 처리되지 않았을까요? 직접 런타임 시점과 초기화 시점에서 예시 코드를 통해 알아볼게요.
@Slf4j
@RestControllerAdvice
public class CustomExceptionHandler {
@ExceptionHandler(RuntimeException.class)
protected ResponseEntity<ErrorResponse> handleRuntimeExceptions(
RuntimeException ex
) {
log.error("Unexpected runtime exception occurred: ", ex);
ErrorResponse errorResponse = createErrorResponse(ex);
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
private ErrorResponse createErrorResponse(RuntimeException ex) {
return ErrorResponse.create(
ex,
HttpStatus.INTERNAL_SERVER_ERROR,
"서버 내부 오류가 발생했습니다,"
);
}
}
@RestController
@RequiredArgsConstructor
public class CustomController {
private final CustomService customService;
@GetMapping("/exception")
public void throwForcedRuntimeException() {
customService.throwForcedRuntimeException();
}
@GetMapping("/healthy")
public void getHealthCheck() {
customService.getHealthCheck();
}
}
@Service
public class CustomService {
public void throwForcedRuntimeException() {
System.out.println("Forced Runtime Exception Occurrence");
throw new RuntimeException("Exception occurred");
}
public void getHealthCheck() {
System.out.println("Hello World!");
}
}
위 코드와 같이 웹 계층에서 /exception 요청에 대한 로직에서는 명시적으로 Runtime Exception을 발생시키고 있고, 같은 계층의 CustomExceptionHandler에서 Runtime Exception을 처리하도록 구성했어요. 여기서 문제!
🤔 Q1. 서버가 실행 중일 때,
GET /exception
요청을 보낸 후GET /healthy
요청을 보낼 경우Hello World!
문자열이 출력될까요?
정답은 O입니다.
/exception
엔드포인트로 첫 요청이 들어오면 컨트롤러와 서비스의 throwForcedRuntimeException
메서드가 호출될 테고, 이 때 명시적으로 RuntimeException
이 발생됩니다. 그리고는 예외 핸들러인 CustomExceptionHandler
의 handleRuntimeExceptions
메서드를 통해 예외가 처리되어 Http Status 500(INTERNAL_SERVER_ERROR) 예외 응답을 만들어 전송하게 됩니다.
다음으로 /healthy
엔드포인트로 요청이 오면 동일하게 컨트롤러와 서비스의 getHealthCheck
메서드가 호출되어 애플리케이션 내부에서 Hello World!
문자열을 출력합니다.
결과적으로 애플리케이션이 실행 중인 상태에서 /exception
요청만 실패하고 /healthy
요청은 성공합니다.
@Component
@RequiredArgsConstructor
public class CustomCommandLineRunner implements CommandLineRunner {
private final CustomService customService;
@Override
public void run(String... args) throws Exception {
// Runtime Excedption 발생
customService.createException();
// 정상 요청 호출
customService.getHealthCheck();
}
}
이번엔 CommandLineRunner 인터페이스 구현체의 초기화 로직에서 같은 시나리오대로 Runtime Exception을 발생시키는 메서드를 호출 한 후, Hello World! 문자열을 출력하는 메서드를 호출했어요. 또 문제 나갑니다!
🤔 Q2. 서버 실행 후
CustomCommandLineRunner.run()
메서드를 통해 초기화 로직이 수행되면Hello World!
문자열이 출력될까요?
정답은 X에요.
어째서 Hello World!
문자열이 출력되지 않는 걸까요? 초기화 시점과 런타임 시점의 예외 처리 방식에는 중요한 차이가 있기 때문입니다.
스프링 애플리케이션을 실행한 후의 초기화 과정(여기서는 CommandLineRunner 인터페이스의 구현체)에서 발생한 예외는 @ExceptionHandler
가 처리할 수 없어요. @ExceptionHandler
는 웹 계층(컨트롤러)에서 발생하는 예외만 처리할 수 있으며, 초기화 과정에서는 웹 컨텍스트가 완전히 구성되기 전이므로 예외 처리 메커니즘이 작동하지 않습니다.
따라서 CustomCommandLineRunner.run()
메서드에서 발생한 RuntimeException
은 처리되지 않고 상위 계층인 SpringApplication.run()
메서드로 전파되어 애플리케이션 시작 과정을 실패로 처리하고 중단시키게 됩니다. 그렇게 예외 발생 후의 코드인 customService.getHealthCheck()
가 실행되지 않아 "Hello World!" 문자열은 출력될 수 없죠.
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomCommandLineRunner implements CommandLineRunner {
private final CustomService customService;
@Override
public void run(String... args) throws Exception {
try {
// Runtime Exception 발생
customService.throwForcedRuntimeException();
} catch (RuntimeException ex) {
// 예외 로깅 후 애플리케이션이 계속 실행되도록 처리
log.error("Initialization exception occurred: ", ex);
}
// 정상 요청 호출 (이제 실행됨)
customService.getHealthCheck();
}
}
마지막 문제입니다!
🤔 Q3. 서버 실행 후
CustomCommandLineRunner.run()
메서드를 통해 초기화 로직이 수행되면Hello World!
문자열이 출력될까요?
정답은 O입니다.
초기화 과정에서는 결국, 어떤 예외든 처리되지 않는다면 애플리케이션 시작이 중단되어 실행되지 않는다는 것을 앞에서 알게 되었어요. 그러면 어떻게 Runtime Exception에 대해서 처리할 수 있을까요? 초기화 시점에서는 try-catch
블록을 사용하여 예외를 직접 처리해야 합니다.
여기서 Runtime Exception 가능성이 동일한 메서드를 런타임 시점에서 호출하는 것과, 초기화 시점에서 호출하는 것의 가장 주요한 차이점에 대해서 알 수 있었어요.
런타임 시점의 예외는 해당 요청/트랜잭션에만 영향을 미치는 반면, 초기화 시점에서 발생한 예외는 애플리케이션 전반에 걸친 치명적인 영향을 주는 것이니 초기화 로직에서 발생하는 예외는 신중하게 고민하여 처리를 해야겠죠?
SpringApplication
클래스 소스 코드를 보며 CommandLineRunner나 ApplicationRunner 인터페이스를 활용하여 초기화 로직을 다룰 때 발생한 예외가 어떻게 전파되는지 살펴볼게요.
@Component
@RequiredArgsConstructor
public class CustomCommandLineRunner implements CommandLineRunner {
private final CustomService customService;
@Override
public void run(String... args) throws Exception {
customService.createException();
customService.getHealthCheck();
}
}
다음과 같이 CommandLineRunner
인터페이스를 구현한 CustomCommandLineRunner
구현체 클래스에서는 @Component
어노테이션을 통해 컴포넌트 스캔의 대상이 되며 애플리케이션 실행 시점에 오버라이딩한 run()
메서드가 실행됩니다.
run()
메서드에서 this.callRunners(context, applicationArguments);
구문이 호출될 때는
contextCommandLineRunner
구현체를 포함한 모든 스프링 빈이 저장되어 있는 컨테이너인 context가 파라미터로 전달됩니다. context의 beanFactory 하위에서 빈으로 등록한 CustomCommandLineRunner
를 찾아볼 수 있어요.
CustomCommandLineRunner
를 가져올 수 있는 거겠죠!callRunner
메서드를 호출합니다.CommandLineRunner
인터페이스의 인스턴스인지 확인해요. 여기서는 CustomCommandLineRunner
는 CommandLineRunner
를 구현했으므로 이 조건을 만족하겠죠? 그리고 CommandLineRunner
타입의 빈에 대해 람다 표현식을 통해 run 메서드를 호출하게 됩니다. 이를 통해 CustomCommandLineRunner
에서 오버라이딩한 run() 메서드가 실행되는 것이죠.IllegalStateException
을 던지게 됩니다. 이 예외는 상위 호출자에게 전파되게 되어요. callRunner
메서드에서 발생한 IllegalStateException
이 별도로 예외를 처리하지 않은 callRunners
메서드를 지나 최상위 호출자인 run
메서드로 전파됩니다. 그렇게 catch 블록이 실행되고 handleRunFailure
메서드로 예외를 전달하게 됩니다.
Application run failed
로그를 남기게 됩니다.context.close()
를 호출하여 애플리케이션 컨텍스트를 종료시킵니다.
- SpringApplication.run() 메서드 호출
- callRunners 메서드에서 CommandLineRunner 빈 탐색
- callRunner 메서드에서 CommandLineRunner의 구현체인 CustomCommandLineRunner 클래스의 run() 메서드 호출
- RuntimeException 발생시 IllegalStateException으로 래핑되어 상위 호출자에게 예외 전파
- SpringApplication.run() 메서드로 예외가 전파되어 catch 블록에서 예외 처리 수행
- 스프링 부트 애플리케이션 종료 코드 설정 및 보고 후 애플리케이션 컨텍스트 종료 처리
- RuntimeException으로 래핑된 예외는 JVM에 의해 처리되어 실제 애플리케이션 중단
결국, 이슈의 원인은 런타임 시점에서는 HTTP 요청을 처리하는 웹 계층에서 예외가 정상적으로 처리되었지만, 초기화 시점에는 Spring MVC의 웹 계층에 대한 예외 처리를 보장받을 수 없기 떄문에 애플리케이션이 종료된되었다는 것인데요. 이를 통해 초기화 시점의 예외 처리 방식이 런타임 시점과는 다르다는 점을 확인할 수 있었어요.
오랜만에 스프링 부트 코드를 까보는 시간을 가진 것 같아요. 사실 스프링 부트를 주력 프레임워크로 다루고 있지만, 스프링 부트 애플리케이션 초기화 시점과 런타임 시점에서의 예외 처리 전략도 몰랐다는 사실을 인정하고 돌아볼 수 있었던 기회였어요.
⏳ 이번 글은 3일동안 6시간을 투자하여 작성했습니다.