From f5b78507b1facfdabbea4d0f62356aae89e76456 Mon Sep 17 00:00:00 2001 From: Minsu Kim Date: Tue, 23 Jul 2024 16:13:33 +0900 Subject: [PATCH] =?UTF-8?q?=08=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EB=B0=8F=20=EC=A0=84=EC=97=AD=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=9E=91=EC=84=B1=20(issue=20?= =?UTF-8?q?#102)=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: config 파일 패키지화 * feat: 커스텀 예외 작성 * feat: 전역 예외 핸들러 작성 * feat: type mismatch exception handler 추가 * test: 전역 예외 핸들러 테스트 --- .../support/{ => config}/CorsConfig.java | 2 +- .../support/{ => config}/SwaggerConfig.java | 2 +- .../support/exception/DevelupException.java | 22 +++ .../support/exception/ExceptionDetail.java | 10 ++ .../support/exception/ExceptionResponse.java | 19 +++ .../support/exception/ExceptionType.java | 24 ++++ .../exception/GlobalExceptionHandler.java | 81 +++++++++++ .../exception/GlobalExceptionHandlerTest.java | 135 ++++++++++++++++++ 8 files changed, 293 insertions(+), 2 deletions(-) rename backend/src/main/java/develup/support/{ => config}/CorsConfig.java (94%) rename backend/src/main/java/develup/support/{ => config}/SwaggerConfig.java (95%) create mode 100644 backend/src/main/java/develup/support/exception/DevelupException.java create mode 100644 backend/src/main/java/develup/support/exception/ExceptionDetail.java create mode 100644 backend/src/main/java/develup/support/exception/ExceptionResponse.java create mode 100644 backend/src/main/java/develup/support/exception/ExceptionType.java create mode 100644 backend/src/main/java/develup/support/exception/GlobalExceptionHandler.java create mode 100644 backend/src/test/java/develup/support/exception/GlobalExceptionHandlerTest.java diff --git a/backend/src/main/java/develup/support/CorsConfig.java b/backend/src/main/java/develup/support/config/CorsConfig.java similarity index 94% rename from backend/src/main/java/develup/support/CorsConfig.java rename to backend/src/main/java/develup/support/config/CorsConfig.java index 6faab6d2d..97ccb263c 100644 --- a/backend/src/main/java/develup/support/CorsConfig.java +++ b/backend/src/main/java/develup/support/config/CorsConfig.java @@ -1,4 +1,4 @@ -package develup.support; +package develup.support.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; diff --git a/backend/src/main/java/develup/support/SwaggerConfig.java b/backend/src/main/java/develup/support/config/SwaggerConfig.java similarity index 95% rename from backend/src/main/java/develup/support/SwaggerConfig.java rename to backend/src/main/java/develup/support/config/SwaggerConfig.java index 407eea1ee..6314793ed 100644 --- a/backend/src/main/java/develup/support/SwaggerConfig.java +++ b/backend/src/main/java/develup/support/config/SwaggerConfig.java @@ -1,4 +1,4 @@ -package develup.support; +package develup.support.config; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; diff --git a/backend/src/main/java/develup/support/exception/DevelupException.java b/backend/src/main/java/develup/support/exception/DevelupException.java new file mode 100644 index 000000000..2b100e3fc --- /dev/null +++ b/backend/src/main/java/develup/support/exception/DevelupException.java @@ -0,0 +1,22 @@ +package develup.support.exception; + +import org.springframework.http.HttpStatus; + +public class DevelupException extends RuntimeException { + + private final ExceptionType exceptionType; + + public DevelupException(ExceptionType exceptionType) { + super(exceptionType.getMessage()); + this.exceptionType = exceptionType; + } + + public DevelupException(ExceptionType exceptionType, Throwable cause) { + super(exceptionType.getMessage(), cause); + this.exceptionType = exceptionType; + } + + public HttpStatus getStatus() { + return exceptionType.getStatus(); + } +} diff --git a/backend/src/main/java/develup/support/exception/ExceptionDetail.java b/backend/src/main/java/develup/support/exception/ExceptionDetail.java new file mode 100644 index 000000000..77e96894d --- /dev/null +++ b/backend/src/main/java/develup/support/exception/ExceptionDetail.java @@ -0,0 +1,10 @@ +package develup.support.exception; + +import org.springframework.validation.FieldError; + +public record ExceptionDetail(String field, String message) { + + public ExceptionDetail(FieldError error) { + this(error.getField(), error.getDefaultMessage()); + } +} diff --git a/backend/src/main/java/develup/support/exception/ExceptionResponse.java b/backend/src/main/java/develup/support/exception/ExceptionResponse.java new file mode 100644 index 000000000..a71913108 --- /dev/null +++ b/backend/src/main/java/develup/support/exception/ExceptionResponse.java @@ -0,0 +1,19 @@ +package develup.support.exception; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.springframework.validation.FieldError; + +public record ExceptionResponse(String message, List details) { + + private static final String INVALID_VALUE_MESSAGE = "올바른 값을 입력해주세요."; + + public ExceptionResponse(String message) { + this(message, Collections.emptyList()); + } + + public ExceptionResponse(FieldError[] errors) { + this(INVALID_VALUE_MESSAGE, Arrays.stream(errors).map(ExceptionDetail::new).toList()); + } +} diff --git a/backend/src/main/java/develup/support/exception/ExceptionType.java b/backend/src/main/java/develup/support/exception/ExceptionType.java new file mode 100644 index 000000000..42f6025d9 --- /dev/null +++ b/backend/src/main/java/develup/support/exception/ExceptionType.java @@ -0,0 +1,24 @@ +package develup.support.exception; + +import org.springframework.http.HttpStatus; + +public enum ExceptionType { + + ; + + private final HttpStatus status; + private final String message; + + ExceptionType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + public HttpStatus getStatus() { + return status; + } + + public String getMessage() { + return message; + } +} diff --git a/backend/src/main/java/develup/support/exception/GlobalExceptionHandler.java b/backend/src/main/java/develup/support/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..e75179dfa --- /dev/null +++ b/backend/src/main/java/develup/support/exception/GlobalExceptionHandler.java @@ -0,0 +1,81 @@ +package develup.support.exception; + +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handle(MethodArgumentNotValidException e) { + log.warn("[MethodArgumentNotValidException] {}", e.getMessage(), e); + + BindingResult bindingResult = e.getBindingResult(); + List fieldError = bindingResult.getFieldErrors(); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ExceptionResponse(fieldError.toArray(FieldError[]::new))); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handle(MethodArgumentTypeMismatchException e) { + log.warn("[MethodArgumentTypeMismatchException] {}", e.getMessage(), e); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ExceptionResponse("요청 값의 타입이 잘못되었습니다.")); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handle(HttpRequestMethodNotSupportedException e) { + log.warn("[HttpRequestMethodNotSupportedException] {}", e.getMessage(), e); + + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .body(new ExceptionResponse("지원하지 않는 HTTP 메서드입니다.")); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handle(NoResourceFoundException e) { + log.warn("[NoResourceFoundException] {}", e.getMessage(), e); + + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ExceptionResponse("요청하신 리소스를 찾을 수 없습니다.")); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handle(HttpMessageNotReadableException e) { + log.warn("[HttpMessageNotReadableException] {}", e.getMessage(), e); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ExceptionResponse("요청을 읽을 수 없습니다.")); + } + + @ExceptionHandler(DevelupException.class) + public ResponseEntity handle(DevelupException e) { + log.warn("[DevelupException] {}", e.getMessage(), e); + + return ResponseEntity.status(e.getStatus()) + .body(new ExceptionResponse(e.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handle(Exception e) { + log.error("[Exception] {}", e.getMessage(), e); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ExceptionResponse("서버 오류가 발생했습니다.")); + } +} diff --git a/backend/src/test/java/develup/support/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/develup/support/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 000000000..087e38eb9 --- /dev/null +++ b/backend/src/test/java/develup/support/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,135 @@ +package develup.support.exception; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import develup.auth.AuthService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@WebMvcTest(GlobalExceptionHandlerTest.TestHandler.class) +class GlobalExceptionHandlerTest { + + @MockBean + TestHandler target; + + @MockBean + AuthService authService; + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Test + @DisplayName("DTO를 검증 예외를 처리한다.") + void methodArgumentNotValidException() throws Exception { + mockMvc.perform( + post("/") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TestHandler.TestRequest("", ""))) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("올바른 값을 입력해주세요.")) + .andExpect(jsonPath("$.details[?(@.field == 'name')]").exists()) + .andExpect(jsonPath("$.details[?(@.message == '이름은 필수 값입니다.')]").exists()) + .andExpect(jsonPath("$.details[?(@.field == 'email')]").exists()) + .andExpect(jsonPath("$.details[?(@.message == '이메일은 필수 값입니다.')]").exists()); + } + + @Test + @DisplayName("존재하지 않는 리소스 요청을 처리한다.") + void unknownResource() throws Exception { + mockMvc.perform(get("/unknown/")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("요청하신 리소스를 찾을 수 없습니다.")); + } + + @Test + @DisplayName("존재하지 않는 HTTP 메서드 요청을 처리한다.") + void unknownMethod() throws Exception { + mockMvc.perform(delete("/")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(jsonPath("$.message").value("지원하지 않는 HTTP 메서드입니다.")); + } + + @Test + @DisplayName("읽을 수 없는 HTTP 메시지를 처리한다.") + void notReadable() throws Exception { + mockMvc.perform( + post("/") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString("}{")) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("요청을 읽을 수 없습니다.")); + } + + @Test + @DisplayName("타입이 일치하지 않는 경우를 처리한다.") + void typeMismatch() throws Exception { + mockMvc.perform(get("/abc")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("요청 값의 타입이 잘못되었습니다.")); + } + + @Test + @DisplayName("예기치 못한 예외를 처리한다.") + void unExpectedException() throws Exception { + when(target.get()).thenThrow(new UnexpectedException()); + + mockMvc.perform(get("/")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("서버 오류가 발생했습니다.")); + } + + private static class UnexpectedException extends RuntimeException { + } + + @Controller + static class TestHandler { + + @GetMapping + ResponseEntity get() { + return ResponseEntity.ok("sample"); + } + + @GetMapping("/{id}") + ResponseEntity get(@PathVariable Long id) { + return ResponseEntity.ok("sample"); + } + + @PostMapping + ResponseEntity post(@Valid @RequestBody TestRequest request) { + return ResponseEntity.noContent().build(); + } + + record TestRequest( + @NotBlank(message = "이름은 필수 값입니다.") + String name, + + @NotBlank(message = "이메일은 필수 값입니다.") + String email + ) { + } + } +}