스프링 부트 애플리케이션 실행 후 초기화 과정에서 예외 발생시 종료되는 이유는 뭘까?

lango·2025년 2월 28일
1

스프링(Spring)

목록 보기
6/6
post-thumbnail

들어가며

 최근 스프링 부트 애플리케이션이 초기화되는 시점에서 발생한 예외가 적절하게 처리되지 않아 서버가 종료되는 이슈가 있었어요.

 흥미로운 점은 초기화 과정에서 호출된 로직이 일반적인 런타임 환경에서 처리 중에는 정상적으로 동작하는 코드였고, 발생한 예외가 Unchecked Exception의 범주인 Runtime Exception임에도 불구하고 서버가 다운되는 현상이 발생했다는 점이에요. 왜 초기화 과정에서는 다른 결과가 나타날까요?

 이번 글에서는 스프링 부트 애플리케이션의 초기화 과정에서 Runtime Exception이 발생했을 때, 서버가 다운되는 이유에 대해서 정리한 내용을 공유해 봅니다.




문제 원인 살펴보기

1. CommandLineRunner 인터페이스의 구현체에서 초기화 로직 호출

 스프링 부트에서는 애플리케이션이 시작되고 트래픽을 받기 전, 특정 코드를 실행해야 할 경우 CommandLineRunner 또는 ApplicationRunner 인터페이스를 구현하도록 안내하고 있답니다. 발견한 이슈도 코드를 찾아보니 CommandLineRunner 인터페이스 구현체의 run 메서드의 초기화 로직에서 발생했어요.

2. 초기화 로직에서 Runtime Exception 발생 가능성이 있는 메서드 호출

// Ex. JPA 쿼리 메서드 단건 조회 및 예외 처리 구문
public XXX findById(...) {
	return xxxRepository.findById(...).orElseThrow(() -> new XxxException(...));
}

 문제가 되는 로직은 위 코드와 유사한 메서드의 형태로 Runtime Exception을 상속하는 Custom Exception을 던지는 간단한 코드였어요. 런타임 환경에서는 정상적으로 수행되던 메서드가 CommandLineRunner 인터페이스의 구현체 내 run 메서드에서 호출되었을 때 문제가 발생합니다.

3. Runtime Exception 발생시 서버 종료

처음엔 ExceptionHandler와 같은 예외 처리기가 발생한 Runtime Exception을 최종적으로 처리한다고 추측했지만, 아니었어요. 초기화 로직에서 Runtime Exception이 발생할 경우 곧 바로 Application run failed 로그를 출력하고 서버가 종료되어 버립니다.


 문제 원인을 간단하게 정리해볼까요?

👀 CommandLineRunner 인터페이스의 구현체의 초기화 로직(run 메서드)에서 예외가 처리되지 않았을 경우 애플리케이션은 실행을 중단하고 종료된다!


초기화 시점과 런타임 시점의 Runtime Exception 처리 과정

분명 런타임 시점에서는 정상적으로 예외가 처리되던 메서드인데, 초기화 시점에서 호출했다는 이유만으로 예외가 어째서 처리되지 않았을까요? 직접 런타임 시점과 초기화 시점에서 예시 코드를 통해 알아볼게요.

런타임 시점에서 Runtime Exception 발생시키기

@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이 발생됩니다. 그리고는 예외 핸들러인 CustomExceptionHandlerhandleRuntimeExceptions 메서드를 통해 예외가 처리되어 Http Status 500(INTERNAL_SERVER_ERROR) 예외 응답을 만들어 전송하게 됩니다.

 다음으로 /healthy 엔드포인트로 요청이 오면 동일하게 컨트롤러와 서비스의 getHealthCheck 메서드가 호출되어 애플리케이션 내부에서 Hello World! 문자열을 출력합니다.

 결과적으로 애플리케이션이 실행 중인 상태에서 /exception 요청만 실패하고 /healthy 요청은 성공합니다.

초기화 시점에서 Runtime Exception 발생시키기

@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() 메서드가 실행됩니다.

Step 1: SpringApplication.run()

 run() 메서드에서 this.callRunners(context, applicationArguments); 구문이 호출될 때는
contextCommandLineRunner 구현체를 포함한 모든 스프링 빈이 저장되어 있는 컨테이너인 context가 파라미터로 전달됩니다. context의 beanFactory 하위에서 빈으로 등록한 CustomCommandLineRunner를 찾아볼 수 있어요.

Step 2: SpringApplication.callRunners()

  1. 스프링 컨텍스트에서 Runner 인터페이스(CommandLineRunner 포함)를 구현한 모든 빈의 이름을 가져옵니다.
  2. 각 Runner 빈 이름에 대한 빈 인스턴스를 가져옵니다. 여기서 앞서 만든 CustomCommandLineRunner를 가져올 수 있는 거겠죠!
  3. 가져온 Runner(CustomCommandLineRunner 포함)에 대해 callRunner 메서드를 호출합니다.

Step 3: SpringApplication.callRunner()

  1. runner 객체가 CommandLineRunner 인터페이스의 인스턴스인지 확인해요. 여기서는 CustomCommandLineRunnerCommandLineRunner를 구현했으므로 이 조건을 만족하겠죠? 그리고 CommandLineRunner 타입의 빈에 대해 람다 표현식을 통해 run 메서드를 호출하게 됩니다. 이를 통해 CustomCommandLineRunner에서 오버라이딩한 run() 메서드가 실행되는 것이죠.
  2. Generic callRunner 메서드에서는 제네릭 타입 T로 받는 CommandLineRunner에 대해 콜백을 실행하는데, 예외가 발생하면 "Failed to execute CommandLineRunner"와 같은 메시지와 예외를 포함한 IllegalStateException을 던지게 됩니다. 이 예외는 상위 호출자에게 전파되게 되어요.

Step 4: SpringApplication.run()의 catch 블록 호출

 callRunner 메서드에서 발생한 IllegalStateException이 별도로 예외를 처리하지 않은 callRunners 메서드를 지나 최상위 호출자인 run 메서드로 전파됩니다. 그렇게 catch 블록이 실행되고 handleRunFailure 메서드로 예외를 전달하게 됩니다.

Step 5: SpringApplication.handleRunFailure()

  1. callRunner에서 전파된 IllegalStateException은 handleRunFailure 메서드의 예외 처리 로직에 의해 handleExitCode 메서드를 호출하여 종료 코드를 설정합니다.
  2. 그리고 finally 블록에서 reportFailure 메서드를 통해 예외를 보고하는데, 실제 서버 로그에 Application run failed 로그를 남기게 됩니다.
  3. context.close()를 호출하여 애플리케이션 컨텍스트를 종료시킵니다.
  4. 마지막으로 전파된 IllegalStateException이 RuntimeException 타입이므로 RuntimeException 타입으로 캐스팅되어 반환됩니다.

SpringApplication.run 메서드의 예외처리 흐름 정리

  1. SpringApplication.run() 메서드 호출
  2. callRunners 메서드에서 CommandLineRunner 빈 탐색
  3. callRunner 메서드에서 CommandLineRunner의 구현체인 CustomCommandLineRunner 클래스의 run() 메서드 호출
  4. RuntimeException 발생시 IllegalStateException으로 래핑되어 상위 호출자에게 예외 전파
  5. SpringApplication.run() 메서드로 예외가 전파되어 catch 블록에서 예외 처리 수행
  6. 스프링 부트 애플리케이션 종료 코드 설정 및 보고 후 애플리케이션 컨텍스트 종료 처리
  7. RuntimeException으로 래핑된 예외는 JVM에 의해 처리되어 실제 애플리케이션 중단

 결국, 이슈의 원인은 런타임 시점에서는 HTTP 요청을 처리하는 웹 계층에서 예외가 정상적으로 처리되었지만, 초기화 시점에는 Spring MVC의 웹 계층에 대한 예외 처리를 보장받을 수 없기 떄문에 애플리케이션이 종료된되었다는 것인데요. 이를 통해 초기화 시점의 예외 처리 방식이 런타임 시점과는 다르다는 점을 확인할 수 있었어요.

마치며

 오랜만에 스프링 부트 코드를 까보는 시간을 가진 것 같아요. 사실 스프링 부트를 주력 프레임워크로 다루고 있지만, 스프링 부트 애플리케이션 초기화 시점과 런타임 시점에서의 예외 처리 전략도 몰랐다는 사실을 인정하고 돌아볼 수 있었던 기회였어요.

⏳ 이번 글은 3일동안 6시간을 투자하여 작성했습니다.


참고자료

profile
찍어 먹기보단 부어 먹기를 좋아하는 개발자

0개의 댓글

관련 채용 정보