전역 예외 처리를 해야하는 이유
Spring은 에러 처리를 위한 BasicErrorController를 구현해뒀다. 그래서 예외가 발생한다면 기본적으로 /error로 에러 요청을 다시 전달하도록 WAS 설정을 해놨다.
이것은 Springboot의 WebMvcAutoConfiguration을 통해서 자동으로 이루어진다.
일번적 요청의 흐름은 다음과 같이 진행된다.
- WAS(톰캣)
- 필터
- 서블릿 (디스패처 서블릿)
- 인터셉터
- 컨트롤러
예외가 발생했을 때 별도의 처리를 하지 않았다면 WAS까지 에러가 전달된다. 이때 WAS는 애플리케이션에서 처리를 못하는 예외라 exception이 올라왔다 판단, 대응 작업을 진행한다.
- 컨트롤러(예외 발생)
- 인터셉터
- 서블릿 (디스패처 서블릿)
- 필터
- WAS
WAS는 스프링 부트가 등록한 에러 설정에 맞게 요청을 전달하는데 정리하면 다음과 같다.
- WAS(톰켓)
- 필터
- 서블릿(디스패처)
- 인터셉터
- 컨트롤러
- 컨트롤러(예외발생)
- 인터셉터
- 서블릿(디스패처)
- 필터
- WAS
- 필터
- 서블릿(디스패처)
- 인터셉터
- 컨트롤러(BasicErrorController)
여기서 말하는 주요 문제는 에러 컨트롤러를 한번더 호출된다는 것이다.
스프링에서 에러 처리를 하는 종류로는 다음과 같다.
- ResponseStatus
에러 HTTP 상태를 변경하도록 도와주는 어노테이션
BasicErrorController에 의한 응답이라 WAS까지 예외를 전달시킨다. - ResponseStatusException
ResponseEntitiy를 사용하는 방식
ResponseStatusExceptionResolver가 에러를 처리하게 된다.
이는 예외 처리를 빠르게 할수 있고 좀더 세밀한 예외 처리를 할수 있다는 장점이 있으나
직접 예외 처리를 프로그래밍해서 같은 코드가 중복될 확률이 높고 Spring내부의 예외를 처리하기 어렵다.
예외가 WAS까지 전달된다. - ExceptionHandler
어노테이션을 사용해서 에러를 처리한다. 이것또한 ExceptionHandlerExceptionResolver에 의해 처리가 된다.
에러 응답을 자유롭게 다룰수 있다는 장점이 있다.
하지만 에러 처리 코드가 중복이 될 확률이 높다. - ControllerAdvice & RestControllerAdvice
전역적으로 ExceptionHandler를 사용할수 있는 어노테이션이다. 두개의 차이는 ResponseBody가 붙어 있어서 응답을 Json으로 내려준다는 점에서 다르다.
이는 여러 컨트롤러에 대해서 전역적으로 Handler를 적용해준다. 즉 에러 처리를 위임할 수 있다.
basePackages등을 사용해서 특정 패키지에만 적용시키는 것도 가능하다.
장점
하나의 클래스로 모든 컨트롤러에 대해 전역적으로 에외 처리 가능
직접 정의한 에러 응답을 일관성 있게 클라이언트에게 내려줄 수 있음
별도의 try-catch문 없이 코드의 가독성 올라감
이런 장점을 지키기 위해서 프로젝트당 하나의 ControllerAdvice만 사용하는 것이 좋다.
다음 그림을 보면 스프링이 에러처리를 어떻게 하는지 중요한데 BasicErrorController를 거처 에러가 출력되지 않게 함으로써 2번 컨트롤러로 요청이 전달되지 않게 하는 방법을 알아봤다.
RestController 전역 예외 처리 사용 방법
1. 예외 코드 정의하기
public interface ExceptionCode {
String name();
HttpStatus getHttpStatus();
String getMessage();
}
예외 코드로 어떤것을 보내줄지 정의한다.
위 내용을 Json으로 보내주게 될텐데, 다른 보내줄수 있는 내용들은 담음과 같다.
- errorCode
구체적인 에러 코드를 지정할 수 있는 속성, 구체적인 에러를 클라이언트에 알려주고 싶으면 사용 - timestamp
예외가 발생한 시간, 디버깅시 도움 줄 수 있 - details
예외에 대한 추가 세부 정보 포함 가능 - path
예외가 발생한 URL 경로를 포함하여, 디버깅 시 어느 경로에서 오류가 발생했는지를 확인 - solution
예외가 발생했을 때 클라이언트에게 제공할 수 있는 해결 방법을 포함
그리고 발생할 수 있는 에러 코드들을 다음과 같이 정의할 수 있다.
코드가 길기 떄문에 접은 글로 대체하겠다.
모든 에러 코드를 만들진 않았고 주요 에러 코드만 만들었다.
@Getter
@RequiredArgsConstructor
public enum CommonExceptionCode implements ExceptionCode{
/**
* 4** client
*/
// 400
BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 매개 변수가 포함됨"),
// 401
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다"),
// 403
FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다"),
// 404
NOT_FOUND(HttpStatus.NOT_FOUND, "리소스를 찾을 수 없습니다"),
// 408
REQUEST_TIMEOUT(HttpStatus.REQUEST_TIMEOUT, "요청 시간이 초과되었습니다"),
// 415
UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원되지 않는 미디어 타입입니다"),
/**
* 5** Server Error
*/
// 500
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류입니다"),
// 502
BAD_GATEWAY(HttpStatus.BAD_GATEWAY, "잘못된 게이트웨이입니다"),
// 503
SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "서비스를 사용할 수 없습니다"),
// 504
GATEWAY_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "게이트웨이 시간 초과입니다"),
// 505
HTTP_VERSION_NOT_SUPPORTED(HttpStatus.HTTP_VERSION_NOT_SUPPORTED, "HTTP 버전을 지원하지 않습니다"),
// 507
INSUFFICIENT_STORAGE(HttpStatus.INSUFFICIENT_STORAGE, "저장 공간이 부족합니다"),
// 511
NETWORK_AUTHENTICATION_REQUIRED(HttpStatus.NETWORK_AUTHENTICATION_REQUIRED, "네트워크 인증이 필요합니다");
private final HttpStatus httpStatus;
private final String message;
}
@Getter
@RequiredArgsConstructor
public class RestApiException extends RuntimeException {
private final ExceptionCode exceptionCode;
}
발생한 에외를 처리해줄 예외 클래스를 선언해야 한다.
런타임 예외를 사용할 것이다.
체크 예외는 처리가 강제되기 떄문에 개발자가 처리를 해놨을꺼라 생각 체크 예외가 아닌 런타임 예외만 사용한다. 자세한건 여기서 참고해도 된다.
2. 예외 응답 클래스 생성하기
우리는 이제 클라이언트에게 건내줄 에러를 만들어야 한다. 아까 정의한 에러 코드를 기반으로 제작하면 된다.
@Getter
@Builder
@RequiredArgsConstructor
public class ExceptionResponse {
private final String code;
private final String message;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private final List<ValidationException> errors;
@Getter
@Builder
@RequiredArgsConstructor
public static class ValidationException {
private final String field;
private final String message;
public static ValidationException of(final FieldError fieldError) {
return ValidationException.builder()
.field(fieldError.getField())
.message(fieldError.getDefaultMessage())
.build();
}
}
}
@Valid를 사용했을때 에러가 발생한 필드를 알려주기 위한 ValidationException
또한 만약 예외가 없다면 응답으로 내려가지 않도록 JsonInclude어노테이션을 추가했다.
3. 예외 응답 클래스 헨들러 Utill 생성하기
여러 예외 응답 방법을 다 처리하기 위해서 Utill로 일관된 구조를 만들어 놓는다.
public class ExceptionHandlerUtil {
public static ResponseEntity<Object> handleExceptionInternal(final ExceptionCode exceptionCode) {
return ResponseEntity.status(exceptionCode.getHttpStatus())
.body(makeErrorResponse(exceptionCode));
}
public static ResponseEntity<Object> handleExceptionInternal(final ExceptionCode exceptionCode, final String message) {
return ResponseEntity.status(exceptionCode.getHttpStatus())
.body(makeErrorResponse(exceptionCode, message));
}
private static ExceptionResponse makeErrorResponse(final ExceptionCode exceptionCode) {
return ExceptionResponse.builder()
.code(exceptionCode.name())
.message(exceptionCode.getMessage())
.build();
}
private static ExceptionResponse makeErrorResponse(final ExceptionCode exceptionCode, final String message) {
return ExceptionResponse.builder()
.code(exceptionCode.name())
.message(message)
.build();
}
}
4. 예외 응답 클래스 헨들러 생성하기
지금까지 사용한 모든 것들을 이용해서 핸들러를 생성하면 된다. 이때 필요한 방식대로 적으면 된다. 다음은 예시이니 참고하기만 하면 된다.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
// RestApiException 예외를 처리하는 핸들러
@ExceptionHandler(RestApiException.class)
public ResponseEntity<Object> handleRestApiException(RestApiException e) {
log.error("RestApiException: {}", e.getExceptionCode().getMessage(), e);
return ExceptionHandlerUtil.handleExceptionInternal(e.getExceptionCode());
}
// 모든 예외를 처리하는 핸들러
@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleAllExceptions(Exception e) {
log.error("Exception: {}", e.getMessage(), e);
return ExceptionHandlerUtil.handleExceptionInternal(CommonExceptionCode.INTERNAL_SERVER_ERROR);
}
// 잘못된 인자가 전달될 때 발생하는 예외를 처리하는 핸들러
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Object> handleIllegalArgumentException(IllegalArgumentException e) {
log.error("IllegalArgumentException: {}", e.getMessage(), e);
return ExceptionHandlerUtil.handleExceptionInternal(CommonExceptionCode.BAD_REQUEST, e.getMessage());
}
// 지원되지 않는 작업이 시도될 때 발생하는 예외를 처리하는 핸들러
@ExceptionHandler(UnsupportedOperationException.class)
public ResponseEntity<Object> handleUnsupportedOperationException(UnsupportedOperationException e) {
log.error("UnsupportedOperationException: {}", e.getMessage(), e);
return ExceptionHandlerUtil.handleExceptionInternal(CommonExceptionCode.UNSUPPORTED_MEDIA_TYPE, e.getMessage());
}
}
log를 통해서 개발자도 알수 있도록 했고 utill을 이용해서 값들을 받을 수 있도록 했다.
이렇게되면 전역 예외 처리가 마무리 된것이다.
Test Code 작성
1. TestController 생성
test에 필요한 api들을 호출할수 있도록 TestController들을 생성한다.
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/restApiException")
public void throwRestApiException() {
throw new RestApiException(CommonExceptionCode.BAD_REQUEST);
}
@GetMapping("/restApiException2")
public void throwRestApiException2() {
throw new RestApiException(CommonExceptionCode.UNAUTHORIZED);
}
@GetMapping("/restApiException3")
public void throwRestApiException3() {
throw new RestApiException(CommonExceptionCode.FORBIDDEN);
}
@GetMapping("/restApiException4")
public void throwRestApiException4() {
throw new RestApiException(CommonExceptionCode.NOT_FOUND);
}
@GetMapping("/illegalArgumentException")
public void throwIllegalArgumentException() {
throw new IllegalArgumentException("잘못된 인자입니다");
}
@GetMapping("/illegalArgumentException2")
public void throwIllegalArgumentException2(@RequestParam String param) {
}
@GetMapping("/unsupportedOperationException")
public void throwUnsupportedOperationException() {
throw new UnsupportedOperationException("지원되지 않는 작업입니다");
}
@GetMapping("/unsupportedOperationException2")
public void throwUnsupportedOperationException2() {
List<String> singletonList = Collections.singletonList("item");
singletonList.remove(0);
}
@GetMapping("/exception")
public void throwException() {
throw new RuntimeException("내부 서버 오류입니다");
}
@GetMapping("/exception2")
public void throwException2() {
String str = null;
str.length();
}
}
필자는 다음과 같이 진행했다. 각각에 예외에 따라 2가지 방향으로 검사했다.
- throw로 각각의 예외를 던져서 반응하는지 확인
- 실제 예외 상황을 하드코딩으로 구현해서 반응하는지 확인
2. 전역 예외 처리 Test 코드 생성
앞에서 해당한 controller에서 어떤 값을 return해야 test를 통과하는지 작성했다.
@SpringBootTest
class GlobalExceptionHandlerTest {
@Autowired
private WebApplicationContext webApplicationContext;
private MockMvc mockMvc;
@BeforeEach
public void setup() {
this.mockMvc = MockMvcBuilders
.webAppContextSetup(webApplicationContext)
.build();
}
@Test
void whenRestApiException_thenReturnsBadRequest() throws Exception {
mockMvc.perform(get("/test/restApiException")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("잘못된 매개 변수가 포함됨"));
}
@Test
void whenRestApiException2_thenReturnsUnauthorized() throws Exception {
mockMvc.perform(get("/test/restApiException2")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("UNAUTHORIZED"))
.andExpect(jsonPath("$.message").value("인증이 필요합니다"));
}
@Test
void whenRestApiException3_thenReturnsForbidden() throws Exception {
mockMvc.perform(get("/test/restApiException3")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("FORBIDDEN"))
.andExpect(jsonPath("$.message").value("접근 권한이 없습니다"));
}
@Test
void whenRestApiException4_thenReturnsNotFound() throws Exception {
mockMvc.perform(get("/test/restApiException4")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("NOT_FOUND"))
.andExpect(jsonPath("$.message").value("리소스를 찾을 수 없습니다"));
}
@Test
void whenException_thenReturnsInternalServerError() throws Exception {
mockMvc.perform(get("/test/exception")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_SERVER_ERROR"))
.andExpect(jsonPath("$.message").value("서버 내부 오류입니다"));
}
@Test
void whenException2_thenReturnsInternalServerError() throws Exception {
mockMvc.perform(get("/test/exception2")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_SERVER_ERROR"))
.andExpect(jsonPath("$.message").value("서버 내부 오류입니다"));
}
@Test
void whenIllegalArgumentException_thenReturnsBadRequest() throws Exception {
mockMvc.perform(get("/test/illegalArgumentException")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("잘못된 인자입니다"));
}
@Test
void whenIllegalArgumentException2_thenReturnsBadRequest() throws Exception {
mockMvc.perform(get("/test/illegalArgumentException2")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest());
}
@Test
void whenUnsupportedOperationException_thenReturnsUnsupportedMediaType() throws Exception {
mockMvc.perform(get("/test/unsupportedOperationException")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isUnsupportedMediaType())
.andExpect(jsonPath("$.code").value("UNSUPPORTED_MEDIA_TYPE"))
.andExpect(jsonPath("$.message").value("지원되지 않는 작업입니다"));
}
@Test
void whenUnsupportedOperationException2_thenReturnsUnsupportedMediaType() throws Exception {
mockMvc.perform(get("/test/unsupportedOperationException2")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isUnsupportedMediaType());
}
}
'Spring > Spring 기초' 카테고리의 다른 글
[Spring/기초] Valid 예외 처리 + 404, 405 (2) (0) | 2024.09.13 |
---|---|
[Spring/기초] api 공통 응답 포맷 + 예외 처리 합치기 (2) (0) | 2024.07.15 |
[Spring/기초] RestController 완벽 정리 (return type, 파라미터, 추가 개념 및 기능) (0) | 2024.06.23 |
[Spring/기초] 환경 변수 파일 사용하기 (env.properties) (0) | 2024.05.28 |
[Spring/기초] Service (0) | 2024.02.07 |
Coding, Software, Computer Science 내가 공부한 것들 잘 이해했는지, 설명할 수 있는지 적는 공간