스프링에서 예외를 처리하기

2025. 3. 21. 20:06Develop/Spring

만약 서버에서 에러(=예외)가 난다면? -> 서버가 죽는다(=멈춤)

서버가 멈추면 안된다!

그렇다면 발생하는 에러들을 Handle(다뤄주어야함) 해주어어야한다. -> 그것이 Exception Handle

 

어떻게?

 

가장 기초적으로 해결하는 방법은 try catch를 사용해서 

"이러한 코드에서는 요런 에러가 발생하겠군.." 이라 생각하고 try로 감싼 후에 catch로 그 예외를 handle 해준다.

하지만 언제 다 모든 코드들을 이렇게 예외처리 해주겠나..

 

그래서 이렇게 2가지 측면으로 예외처리를 생각해 볼 수 있다.

 

"예외처리를 공통관심사로 빼서 따로 관리하자."

"무슨에러임을 알 수 있도록 정보를 더 전달해주자." -> 보안적으로? 위험할 수 도 있는데 여기서 말하는 무슨에러임을 알 수 있게한다는건 http status와 message 정도이다.

 

일단 기본적으로 스프링이 예외를 어떻게 처리하는지에 대해서 알아보자.

 

Spring은 만들어질 때(1.0)부터 에러 처리를 위한 BasicErrorController를 구현해두었고, 스프링 부트는 예외가 발생하면 기본적으로 /error로 에러 요청을 다시 전달하도록 WAS 설정을 해두었다. 그래서 별도의 설정이 없다면 예외 발생 시에 BasicErrorController로 에러 처리 요청이 전달된다. 참고로 이는 스프링 부트의 WebMvcAutoConfiguration를 통해 자동 설정이 되는 WAS의 설정이다.

WAS(톰캣) -> 필터 -> 서블릿(디스패처 서블릿) -> 인터셉터 -> 컨트롤러
컨트롤러(예외발생) -> 인터셉터 -> 서블릿(디스패처 서블릿) -> 필터 -> WAS(톰캣)

 

그리고 컨트롤러 하위에서 예외가 발생하였을 때, 별도의 예외 처리를 하지 않으면 WAS까지 에러가 전달된다. 그러면 WAS는 애플리케이션에서 처리를 못하는 예와라 exception이 올라왔다고 판단을 하고, 대응 작업을 진행한다.

WAS는 스프링 부트가 등록한 에러 설정(/error)에 맞게 요청을 전달하는데, 이러한 흐름을 총 정리하면 다음과 같다.

WAS(톰캣) -> 필터 -> 서블릿(디스패처 서블릿) -> 인터셉터 -> 컨트롤러
-> 컨트롤러(예외발생) -> 인터셉터 -> 서블릿(디스패처 서블릿) -> 필터 -> WAS(톰캣)
-> WAS(톰캣) -> 필터 -> 서블릿(디스패처 서블릿) -> 인터셉터 -> 컨트롤러(BasicErrorController)

 

그럼 여기서 잠깐, 우리 이런 메세지를 보지 않았었나?

굉장히 성의..? 없다. 어떠한 점이 잘못되었는지 알 수 없고 그냥 500번대 에러로 서버가 잘못했다고 나와있다.

{
    "timestamp": "2021-12-31T03:35:44.675+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/product/5000"
}

 

위는 기본 설정으로 받는 에러 응답인데, 나름 잘 갖추어져 있지만 클라이언트 입장에서 유용하지 못하다. 클라이언트는 “Item with id 5000 not found”라는 메세지와 함께 404 status로 에러 응답을 받으면 훨씬 유용할 것이다.

 

따라서 우리가 아무런 에러를 Handle해주지 않는다면 BasicErrorController를 통해서 에러가 처리되기 때문에 유의미한 에러 응답을 전달하지 못한다. (참고로 여기서 status가 500인 이유는 에러가 처리되지 않고 WAS가 에러를 전달받았기 때문이다. 그러니까 WAS까지 올라오지 않게 예외를 처리해줘야 한다.) 그러므로 우리는 별도의 에러 처리 전략을 통해 상황에 맞는 에러 응답을 제공해야 한다.

바로 여기서 "무슨에러임을 알 수 있도록 정보를 더 전달해주자." 측면이 적용된다.

 

하지만 그렇게하는 것을 효율적으로 하기 위해서 "예외처리를 공통관심사로 빼서 따로 관리하자." 측면이 적용된다. 왜냐하면 앞서 말했든 모든 에러를 try catch로 할 수 없는 노릇이니 공통관심사로 빼서 전역적으로 관리하는 것이 훨씬 보기좋고 편리하기 때문이다.

 

그럼 어떻게 전역적으로 관리할 수 있을가??

 

@ControllerAdvice와 @RestControllerAdvice

두 어노테이션은 각각 @Controller 어노테이션, @RestController 어노테이션이 붙은 컨트롤러에서 발생하는 예외(@ExceptionHandler)를 AOP를 적용해 예외를 전역적으로 처리할 수 있는 어노테이션이다. 

@Controller와 RestController가 붙은 컨트롤러에 대해서 전역적으로 Exception Handler를 적용해준다고 생각하면 된다.

두 개의 차이는 @Controller와 RestController와 같은데, @RestControllerAdvice는 @ControllerAdvice와 달리 @ResponseBody가 붙어 있어 응답을 Json으로 내려준다는 점에서 다르다.

 

멍청한 질문 같기는 한데, 그럼 컨트롤러에게 Exception handler를 적용해준다는데 Exception handler가 뭐지? -> 그냥 예외를 처리해준다는 뜻이다. ( @ExceptionHandler 어노테이션을 설명하는게 아니고 그냥 예외를 처리해준다는 뜻)

 

전체적인 흐름을 본다면 다양한 컨트롤러들에서 다양한 에러들이 발생할 것이다. 그럼 일단 @ControllerAdvice 혹은 @RestControllerAdvice어노테이션이 붙은 GlobalExceptionHandler 클래스로 온다.

@ExceptionHandler는 표지판 같은 것이다. 뫄뫄한 에러는 이쪽으로 오세요. 솨솨한 에러는 이쪽으로 오세요. 

@ExceptionHandler(예외클래스)와 같은 형식으로 되어있다. 괄호안에 해당하는 예외들은 해당 예외의 @ExceptionHandler가 붙은 메서드로 가서 예외handle 당하는 것이다.

 

꼭 GlabalExeptionHandler로 빼지 않고 이렇게 @ExceptionHandler를 컨트롤러 코드안의 메서드에 붙여서 예외를 처리할 수도 있다. 그럼 발생한 예외는 컨트롤러를 나가기 전 @ExceptionHandler이 붙은 메서드에게 잡힐 것이다. 

@RestController
@RequiredArgsConstructor
public class ProductController {

  private final ProductService productService;
  
  @GetMapping("/product/{id}")
  public Response getProduct(@PathVariable String id){
    return productService.getProduct(id);
  }

  @ExceptionHandler(NoSuchElementFoundException.class)
  public ResponseEntity<String> handleNoSuchElementFoundException(NoSuchElementFoundException exception) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(exception.getMessage());
  }
}

 

@ExceptionHandler는 Exception 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있다. 만약 ExceptionHandler 어노테이션에 예외 클래스를 지정하지 않는다면, 파라미터에 설정된 에러 클래스를 처리하게 된다. 또한 @ResponseStatus(에서 http상태를 변경해주는 어노테이션)와도 결합가능한데,  만약 ResponseEntity에서도 status를 지정하고 @ResponseStatus도 있다면 ResponseEntity가 우선순위를 갖는다.

 

@ExceptionHandler를 사용 시에 주의할 점은 @ExceptionHandler에 등록된 예외 클래스와 파라미터로 받는 예와 클래스가 동일해야 한다는 것이다. 만약 값이 다르다면 스프링은 컴파일 시점에 에러를 내지 않다가 런타임 시점에 에러를 발생시킨다. 내가 정신이 나가서 어노테이션 옆 괄호안에 쓰는 예외 클래스와 파라미터로 받는 예외클래스를 다르게 적어준다면, 예외가 handle 되지 않는다. 

 

@ExceptionHandler는 컨트롤러에 구현하므로 특정 컨트롤러에서만 발생하는 예외만 처리된다. 하지만 컨트롤러에 에러 처리 코드가 섞이며, 에러 처리 코드가 중복될 가능성이 높다. 그래서 스프링은 전역적으로 예외를 처리할 수 있는 좋은 기술을 제공해준다.

 

그래서 나온 것이 아까 보았던 @ControllerAdvice와 @RestControllerAdvice

 

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(NoSuchElementFoundException.class)
    protected ResponseEntity<?> handleIllegalArgumentException(NoSuchElementFoundException e) {
        final ErrorResponse errorResponse = ErrorResponse.builder()
                .code("Item Not Found")
                .message(e.getMessage()).build();

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }
}

 

우리는 이러한 ControllerAdvice를 이용함으로써 다음과 같은 이점을 누릴 수 있다.

  • 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능함
  • 직접 정의한 에러 응답을 일관성있게 클라이언트에게 내려줄 수 있음
  • 별도의 try-catch문이 없어 코드의 가독성이 높아짐

하지만 ControllerAdvice를 사용할 때에는 항상 다음의 내용들을 주의해야 한다. 여러 ControllerAdvice가 있을 때 @Order 어노테이션으로 순서를 지정하지 않는다면 Spring은 ControllerAdvice를 임의의 순서로 에러를 처리할 수 있다. 그러므로 일관된 예외 응답을 위해서는 이러한 점에 주의해야 한다.

  • 한 프로젝트당 하나의 ControllerAdvice만 관리하는 것이 좋다.
  • 만약 여러 ControllerAdvice가 필요하다면 basePackages나 annotations 등을 지정해야 한다.
  • 직접 구현한 Exception 클래스들은 한 공간에서 관리한다.

따라서 이러한 예외처리의 흐름도를 보자면 @ControllerAdvice와 @RestControllerAdvice를 사용하는 것이 가장 좋아보인다.

 

그럼 이제 어떻게 쓸까?

package org.example.expert.config;

import org.example.expert.domain.auth.exception.AuthException;
import org.example.expert.domain.common.exception.InvalidRequestException;
import org.example.expert.domain.common.exception.ServerException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(InvalidRequestException.class)
    public ResponseEntity<Map<String, Object>> invalidRequestExceptionException(InvalidRequestException ex) {
        HttpStatus status = HttpStatus.BAD_REQUEST;
        return getErrorResponse(status, ex.getMessage());
    }

    @ExceptionHandler(AuthException.class)
    public ResponseEntity<Map<String, Object>> handleAuthException(AuthException ex) {
        HttpStatus status = HttpStatus.UNAUTHORIZED;
        return getErrorResponse(status, ex.getMessage());
    }

    @ExceptionHandler(ServerException.class)
    public ResponseEntity<Map<String, Object>> handleServerException(ServerException ex) {
        HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
        return getErrorResponse(status, ex.getMessage());
    }

    public ResponseEntity<Map<String, Object>> getErrorResponse(HttpStatus status, String message) {
        Map<String, Object> errorResponse = new HashMap<>();
        errorResponse.put("status", status.name());
        errorResponse.put("code", status.value());
        errorResponse.put("message", message);

        return new ResponseEntity<>(errorResponse, status);
    }
}

이렇게 GlobalExceptionHandler를 정의해두고 @RestControllerAdvice 어노테이션을 붙여준다. 응답을 json으로만 보내겠다는 뜻이다.

발생할 각각의 예외들에 대해서 @ExceptionHandler를 이용해서 어떻게 예외처리 할 것인지 구분해주며 예외처리 메서드를 구현해준다. 

 

여기서 errorResponse를 만드는 부분은 공통적으로 반복되기 때문에 따로 메서드로 빼서 구현해주면 깔끔하다. 

getErrorResponse 함수에서 Http 상태와 message를 파라미터로 받아서 HashMap 자료구조에 "status"(status의 이름), "code"(status의 값), "message"(message)형태로 저장한다.

그리고 ResponseEntity<>(errorResponse, status); 응답으로 우리가 만든 HastMap 자료구조 에러응답과 http 상태를 같이 전달해주면 JSON응답으로 바꿔져서 전달된다.

 

보통 기본적으로 발생하는 에러들은 테스트코드를 작성하며 해결하겠지만 런타임으로 돌아가다가 발생되는 에러들은 이렇게 처리해주어야 한다.

예시 코드에서는 크게 사용자의 잘못된 요청, 서버의 잘못, 권한 잘못으로, 3가지로 나누어서 생각했다. 이 3가지는 아주 기본적인 예외들로 더 다양하고 복잡한 로직이 생길 수록 다양한 예외들이 생긴다. 그럼 상황에 맞춰서 커스텀 예외를 만들어주고 그 예외를 처리해주면 된다.  

 

커스텀예외는 어떻게 만들지?

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

먼저 우리가 만들 예외 이름을 만든다. -> InvalidRequestException 처럼

그리고 RuntimeException을 상속받는다. -> 이 예외는 uncheked exception이라는 뜻이다. 

String message를 파라미터로 받는다. -> 이 예외를 사용할 때 구체적인 상황을 message에 적는다. 

생성자를 정의한다.

suepr(message) -> RuntimeException의 생성자를 message를 넘겨주면서 호출한다. 이름은 InvalidRequestException 지만 RuntimeException을 상속받아서 RuntimeException으로 만들겠다는 뜻이다. 

 

 

checked? unchecked?

Checked Exception 

✔️ 컴파일러가 예외 처리를 강제하는 예외
✔️ 예외 처리를 하지 않으면 컴파일 에러 발생
✔️ 반드시 try-catch 블록으로 감싸거나 throws 키워드로 선언해야 함
✔️ 파일 입출력, 네트워크, 데이터베이스 관련 예외 등이 대표적

대표적인 Checked Exception 예시

  • IOException → 파일을 읽거나 쓸 때 발생
  • SQLException → 데이터베이스 처리 중 발생
  • InterruptedException → 스레드가 중단될 때 발생

Unchecked Exception 

✔️ 컴파일러가 예외 처리를 강제하지 않는 예외
✔️ 예외 처리를 하지 않아도 컴파일 에러 없음
✔️ 실행 중에 발생하면 프로그램이 비정상 종료될 수 있음
✔️ 프로그래머의 실수(버그)로 인해 발생하는 경우가 많음

대표적인 Unchecked Exception 예시

  • NullPointerException → null 값을 참조할 때 발생
  • ArrayIndexOutOfBoundsException → 배열 인덱스 초과 접근 시 발생
  • ArithmeticException → 0으로 나누는 연산 시 발생
  • ClassCastException → 잘못된 형변환 시 발생

예외를 언제 Checked, 언제 Unchecked로 만들어야 할까?

Checked Exception이 적절한 경우

  • 프로그램이 외부 자원(파일, DB, 네트워크 등)에 접근하는 경우
  • 예외 처리가 필수적으로 요구되는 경우 (예: 파일이 존재하는지 확인)

Unchecked Exception이 적절한 경우

  • 프로그래머의 실수(버그) 로 인해 발생하는 경우
  • NullPointerException, IndexOutOfBoundsException 같은 일반적인 버그 상황

✔️ 일반적인 원칙

  • 예외 처리가 필수적인 경우 → Checked Exception
  • 예외 처리를 강제할 필요가 없는 경우 → Unchecked Exception (RuntimeException)

 

 

 

 

 

 

이 블로그를 참고했다. 스프링에서 예외처리 흐름이 어떻게 되는지, 어떻게 하면 예외처리 코드를 깔끔하고 효율적이게 짤 수 있는지 나와있다. 한번씩 보면 좋을 것 같다.

https://mangkyu.tistory.com/204

 

[Spring] 스프링의 다양한 예외 처리 방법(ExceptionHandler, ControllerAdvice 등) 완벽하게 이해하기 - (1/2)

예외 처리는 애플리케이션을 만드는데 매우 중요한 부분을 차지한다. Spring 프레임워크는 매우 다양한 에러 처리 방법을 제공하는데, 어떠한 방법들이 있고 가장 좋은 방법(Best Practice)은 무엇인

mangkyu.tistory.com

 

'Develop > Spring' 카테고리의 다른 글

컴파일? 빌드? Gradle? gradle wrapper? gradlew?  (0) 2025.03.21
QueryDSL의 BooleanExpression, Projections, Pagination  (0) 2025.03.14
QueryDSL  (0) 2025.03.14
AOP?  (0) 2025.03.12
테스트 코드  (0) 2025.03.12