예외(Exception)와 스프링 예외 처리(Exception Handling)

KDG: First things first!·2024년 9월 18일
0

Spring

목록 보기
2/5
post-thumbnail



예외(Exception)


예외(Exception)란?

일반적으로 예외란 예상과 다르게 일반 규칙이나 통례에서 벗어나는 일이 발생하는 경우를 의미한다.



예외처리 의의

만약 이러한 예외가 발생하였을 때 아무런 대비도 되어있지 않다면 큰 낭패를 볼 수 있다.

프로그래밍에서도 마찬가지로 프로그램 오류, 사용자의 실수, 사용자의 고의적 행위 등 여러 가지 원인으로 인한 예외가 발생하였을 때 이러한 예외에 어떻게 대처할지를 미리 준비해두어야 하고 이것을 예외 처리라고 한다.



예외 전파란?

하지만 회사에서 문제(예외)가 발생하였을 때 문제를 직면한 해당 사원의 힘만으로는 해결하기 힘들 때 더 상위직급의 사람에게 보고한 후 문제 처리를 맡겨야할 때가 종종 있다.

마찬가지로 프로그래밍에서도 예외가 발생하였을 때 즉석에서 처리하기보다는 더 처리가 용이한 상위 계층에서 처리하도록 하는 경우가 있는데 이처럼 예외를 바로 처리하지 않고 상위 계층으로 예외를 전파하는 행위를
예외 전파
라고 한다.



예외의 구조

예외의 최상위에는 모든 객체의 조상인 Object가 있고 그 아래에는 모든 예외의 조상인 Throwable이 있다.

이후 예외는 크게 Exception(체크 예외)와 RuntimeExcetpion(언체크 예외)으로 나뉘어진다.



체크 예외(Exception) 와 언체크 예외(RuntimeException)란?


체크 예외(Exception)


public void callCheckedException() throws Exception {
        throw new Exception();
    }

@GetMapping("/checked")
    public String checkedExceptionAPI() throws Exception {
        exceptionService.callCheckedException();
        return "checked";
    }
  • IOException, NoSuchAttributeException, SQLException 등 RuntimeExpcetion을 제외한 모든 Excpetion 클래스를 상속받은 하위 예외를 의미한다.
  • 반드시 try-catch, ExceptionHandler 등으로 예외를 처리하거나 바로 처리가 힘든 경우 throws를 통해 예외 처리를 다른 계층으로 미루어야 한다.
  • 컴파일 단계에서 검사하여 예외가 발생한다.(코드 작성 중 Red Line 발생)


언체크 예외(RuntimeException)



public void callUncheckedException() {
        throw new RuntimeException();
    }

@GetMapping("/unchecked")
    public String uncheckedExceptionAPI() {
        exceptionService.callUncheckedException();
        return "unchecked";
    }
  • IllegalArgumentException, NoSuchElementException, NullPointer Exception모든 RuntimeExpcetion 을 상속받은 하위 예외를 의미한다.

  • 예외를 tyr-catch로 잡아서 처리하거나 throws으로 던지는 등 명시적으로 예외처리를 하지 않아도 된다.(예외를 무시해도 된다는 말은 아니다.)

  • 컴파일 단계가 아니라 Runtime 환경에서 예외가 발생하기 때문에 실제 실행 전까지는 에러의 존재를 인지를 못할 수도 있다.



체크 예외(Exception) 와 언체크 예외(RuntimeException)의 차이

체크 예외와 언체크 예외의 가장 큰 차이인 예외처리 필수 유무는 무슨 뜻일까???

체크 예외든 언체크 예외든 모든 에러는 일종의 폭탄 돌리기와 같다.

결국 모든 에러는 잡아서 처리하거나, 처리할 수 없으면 밖으로 던져야 한다는 공통점을 갖는다.

다만 체크 예외는 try-catch 등으로 예외를 잡아서 처리하던가 그게 힘들다면 throws를 통해 예외를 던진다는 것을 코드 상에서 명시하지 않으면 Red Line을 통한 컴파일 예외가 발생하지만, 언체크 예외는 코드 상으로 명시하지 않아도 컴파일 에러가 발생하지 않는다는 차이점이 있을 뿐이다.
(언체크 예외는 예외를 처리하지 않으면 throws를 생략해도 자동으로 밖으로 던져진다.)


즉 체크 예외와 언체크 예외의 차이는 사실 예외를 처리할 수 없을 때 예외를 밖으로 던지는 부분을 필수로 선언해야 하는가 생략할 수 있는가의 차이다.


만약 예외가 처리되지 않고 계속 던져져 가장 마지막 계층에서도 처리되지 못한다면 자바 main() 쓰레드의 경우 예외 로그를 출력하면서 시스템이 종료된다.

웹 애플리케이션은 여러 사용자가 동시에 사용하기 때문에 특정 사용자의 특정 에러로 인하여 프로그램이 종료되어 버리는 것은 매우 바람직하지 못하기 때문에 결국 에러는 필수적으로 처리되어야만 한다.


[체크 예외 장단점]

장점:

  • 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 명시적으로 보여주는 훌륭한 안전 장치이다.

단점:

  • 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 하기 때문에 매우 번거롭다.

  • 예외를 처리하는 계층에 도달할 때까지 해당 체크 예외가 호출되어 이동하는 각 계층의 모든 메서드에 차례대로 throws로 예외를 던져야 해서 계층간에 의존관계가 성립되어 유지보수에 좋지 않다.


[언체크 예외 장점]

장점:

  • 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다.

  • throws를 던짐으로써 다른 클래스나 각 계층 간에 의존 관계가 형성되어 버린다는 체크 예외의 단점을 무시할 수 있다.


단점:

  • 체크 예외는 컴파일러를 통해 개발자가 예외를 누락하지 않게 방지해주지만 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다.


사용자 정의 예외 - 예외 선택


[체크 예외(Exception) 정의]


public class MyCheckedException extends Exception {
     public MyCheckedException(String message) {
         super(message);
     }
}

[언체크 예외(RuntimeException) 정의]


public class MyUnCheckedException extends RuntimeException {
     public MyUnCheckedException(String message) {
         super(message);
     }
}

Exception을 상속받은 클래스는 체크 예외가 되고,
RuntimeException을 상속받은 클래스는 언체크 예외가 된다.


그렇다면 어떤 예외를 사용해야 할까???


어떤 종류의 예외를 정의하여 사용할지는 비즈니스 계층에서의 복구 가능성으로 판단하여야 한다.



일반적으로 입력값이 잘못되는 등의 사용자의 잘못된 요청처럼 서버에서 복구가 힘든 에러에는 RuntimeExpcetion(언체크 예외)를 사용하고, 일시적인 네트워크 오류처럼 복구 가능성이 높은 예외에는 Exception(체크 예외)를 사용한다.


하지만 애플리케이션에서 발생하는 예외는 대부분 비즈니스 계층에서 직접 처리하기 어려운 예외들이다.
또한 체크 예외는 의존성 문제로 인하여 그렇게 권장되는 방식이 아니다.

그러므로 현업에서는 거의 대부분의 경우 RuntimeException을 상속받아 Unchecked Exception를 사용한다.


다만 기본적으로 언체크 예외를 사용하되 '계좌 이체 실패 예외', '결제시 포인트나 잔액 부족', '로그인 ID, PW 불일치 예외' 등 절대 누락되어서는 안 되는 중요한 예외 처리는 안전하게 체크 예외를 사용하면 개발자의 실수로 예외 처리가 누락되는 것을 방지할 수 있기 때문에 체크 예외 사용을 고려해볼 만하다.



스프링에서의 예외 처리 흐름


그렇다면 스프링에서는 이러한 예외들을 어떻게 처리하는 것일까??

우선 기본적인 스프링의 동작 흐름은 다음과 같다.

  1. WAS(톰캣)에서는 서블릿이 처리할 수 있게 HttpServletRequest(클라이언트의 요청 정보를 담고 있는 객체) , HttpServletResponse(서버가 클라이언트에게 응답할 데이터를 담는 객체)으로 HTTP 요청/응답을 객체화시켜서 서블릿에 넘겨준다.

    (WAS는 요청/응답 자체를 객체화하는 역할을 하고 세부적으로 요청/응답의 BODY를 객체화하는 것은 DispatcherServlet의 HttpMessageConverter의 역할이다.)

  2. 서블릿(DispathcherServlet)에서는 요청을 건네받으면 HandlerMapping을 통해 해당 요청을 처리할 수 있는 Controller를 찾고 HandlerAdapter를 통해 해당 Controller를 실행한다.

  3. Controller에서는 해당 요청을 처리하기 위한 비즈니스 로직을 실행한다.



스프링이 이러한 동작을 실행할 때 만약 예외가 발생한다면 어떻게 될까???


스프링에서 예외가 발생하였는데 예외를 처리하지 않으면 계속 그 다음의 상위계층으로 전파된다.
만약 클라이언트에게 전달되기 직전의 가장 마지막 계층인 WAS까지도 에러가 처리되지 않는다면 서버가 비정상적으로 종료된다.

애플리케이션에서 에러가 발생한다면 기본적으로 스프링 부트는 발생한 예외를 처리하기 위해 사용자가 등록한 예외 처리 핸들러(@ExceptionHandler, @(Rest)COntrollerAdvice 등`)을 탐색하지만, 만약 예외 처리 핸들러가 존재하지 않아 응답 직전 계층인 WAS에서도 에러 처리가 안된다면 에러로 인한 서버의 비정상적인 종료를 막기 위해 WAS에서 "/error" 경로로 서블릿에게 요청을 보내고 서블릿은 해당 경로로 매핑되어 있는 BasicErrorController로 해당 요청을 처리하여 에러로 인한 서버의 비정상적인 종료를 방지한다.

BasicErrorController는 오류가 발생하면 예외 상황에 맞는 응답을 생성하여 에러 속성 정보(에러 메시지, HTTP 상태 코드, 예외, 트레이스 등)를 담아 클라이언트에게 전달하는 역할을 하는 Controller이다.



기본적으로 서버에 요청이 들어오면 WAS - (필터) - 서블릿 - (인터셉터) - 컨트롤러 경로를 거치기 때문에 서블릿 직전에 존재하는 직접 URI를 확인하는 필터를 만들어 등록하면 예외 미처리시 정말로 WAS에서 /error 경로로 서블릿에 요청을 보내는지 확인해볼 수 있다.



@Slf4j 
public class CustomFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request; // URI 등 HTTP 요청에 대한 세부 정보 확인 위해 HttpServletRequest로 타입 캐스팅
        String requestURI = httpRequest.getRequestURI(); // 요청 URI
        log.info("::: 필터 호출 - {}:::", requestURI); // 요청 URI를 로깅(필터가 호출될 때 어떤 요청이 들어왔는지 확인하기 위해)
        chain.doFilter(request, response); // 다음 필터 또는 서블릿으로 요청과 응답을 전달
    }
}

해당 필터에 전달된 요청의 URI를 확인하는 필터를 정의한 후


@Configuration
public class WebConfig {

    @Bean
    FilterRegistrationBean addFilter() {
        FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
        filterFilterRegistrationBean.setFilter(new CustomFilter()); // 생성한 CustomFilter를 Filter로 등록
        filterFilterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR); // 필터를 어떤 요청에 적용할지 설정(REQUEST, ERROR)
        filterFilterRegistrationBean.addUrlPatterns("/*"); // 필터를 적용할 경로(/*: 모든 경로)
        return filterFilterRegistrationBean; // 
    }
}

필터를 등록하고

이제 예외를 발생시키면 "::: 필터 호출 - /요청 URI""::: 필터 호출 - /error" 두 개의 로깅을 확인할 수 있을 것이다.


하지만 이러한 기본 메커니즘은 서블릿을 거쳐 WAS로 갔다가 다시 서블릿으로 요청을 보내야 하고 1개의 요청을 더 발생시키기 때문에 비효율적이다.


그렇다면 어떻게 예외 처리를 효율적으로 할 수 있을까???



ExceptionResolver를 통한 예외 처리


ExcpetionResolver를 사용하면 위와 같은 요청이 추가로 생성되지 않고 효율적으로 문제를 처리할 수 있다.

ExceptionResolver는 스프링 MVC에서 예외 처리 흐름을 제어하기 위해 사용되는 인터페이스이다.
애플리케이션에서 발생한 예외를 처리하고, 그에 따른 적절한 응답을 생성하는 역할을 한다.
예외가 발생하면 이를 적절하게 처리하고, 클라이언트에게 응답을 반환하는 메커니즘을 갖는다.


컨트롤러에서 예외가 발생하고 이 예외가 처리되지 않으면 서블릿에게 전달된다.

그럼 서블릿은 WAS에게 해당 예외를 넘기기 이전에 ExcpetionResolver를 호출하여 해당 예외 처리가 가능한지 확인한다.


스프링 부트에서 제공하는 ExceptionResolver는 위와 같은 3가지 종류의 ExceptionResolver가 구현되어 있고 순서대로 우선순위를 가진다.

1번부터 예외를 처리할 수 있는지 확인하고 못하면 2번이, 2번이 못하면 3번이 처리할 수 있는지 확인하고 3번도 못하면 WAS로 넘어가고 결국BasicErrorController가 호출된다.


현업에서는 보통 ExceptionHandlerExcetpionResolver를 사용해서 예외처리한다.



ExceptionHandler 사용하기


ExceptionHandler는 @RestControllerAdvice 또는 @ControllerAdvice을 활용해 구현할 수 있다.


@ControllerAdvice@RestControllerAdvice는 스프링 MVC에서 글로벌 예외 처리와 글로벌 데이터 바인딩을 처리하는 데 사용되는 두 가지 중요한 어노테이션이다.

이들 어노테이션을 사용하면 애플리케이션 전역에서 공통적으로 처리해야 하는 로직을 한 곳에 모아두는 것이 가능하다.

즉, 한 클래스 안에서 모든 예외 처리를 하는 것이
가능하다.


[사용자 정의 예외 예시]


public class DuplicateUserNameException extends RuntimeException {
   public DuplicateUserNameException() {
       super("이미 존재하는 이름입니다.");
   }
}

[@ExceptionHandler를 통한 예외 처리 예시]


@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(DuplicateUserNameException.class) // 해당 예외 발생시 해당 예외 처리 메서드 실행
    public ResponseEntity<String> handleUserNameExceptions(DuplicateUserNameException ex) { // 클래스 이름 : handle + 예외 이름 
        return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); // 예외 발생시 메시지와 상태코드 반환
    }
    
// 다른 예외 처리 메서드들    
   
}

이러한 방식으로 @RestContoller와 @ExceptionHandler를 사용하면 한 자리에서 손쉽게 예외 처리가 가능하다.

또한 WAS를 거치지 않고 서블릿에서 모든 예외 처리가 끝나기 때문에 BasicErrorController를 향한 요청이 추가로 발생하지 않아 효율적이다.

profile
알고리즘, 자료구조 블로그: https://gyun97.github.io/

0개의 댓글