From fd4a98c71b059271f5d4dbe0a55e903709f0cfc8 Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 14:53:39 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=9A=94=EC=B2=AD=20=EC=98=A4=EB=A5=98=EB=A5=BC=20?= =?UTF-8?q?500=20=EB=8C=80=EC=8B=A0=20=EC=95=8C=EB=A7=9E=EC=9D=80=204xx?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=91=EB=8B=B5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 405·415·406·헤더 누락 등 Spring MVC 예외는 자신의 4xx 상태로 응답한다(이전에는 전부 500과 ERROR 로그). - 필수 쿼리 파라미터 누락은 파라미터 이름을 담아 400, 경로·쿼리 값 형식 오류(숫자 아닌 id, 없는 enum 값)와 없는 필드로의 정렬도 400으로 응답한다. - 도메인 예외의 getMessage()가 예외 타입 메시지를 돌려줘, 로그에 message=null이 남던 문제를 고친다. - 응답 본문 형식({message})은 그대로다. --- .../global/exception/BcsdException.java | 6 +++ .../exception/GlobalExceptionHandler.java | 50 +++++++++++++++++++ .../global/BcsdExceptionTest.java | 17 +++++++ .../ClientErrorStatusIntegrationTest.java | 47 +++++++++++++++++ .../media/AdminImageIntegrationTest.java | 28 +++++++++++ 5 files changed, 148 insertions(+) create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/global/BcsdExceptionTest.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/global/ClientErrorStatusIntegrationTest.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/BcsdException.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/BcsdException.java index 6c2164d..1666cc0 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/BcsdException.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/BcsdException.java @@ -6,4 +6,10 @@ protected BcsdException() { } public abstract BcsdExceptionType getExceptionType(); + + /** 로그에 예외 메시지가 남도록 예외 타입의 메시지를 돌려준다. */ + @Override + public String getMessage() { + return getExceptionType().getMessage(); + } } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalExceptionHandler.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalExceptionHandler.java index 554c3d5..8ec506b 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalExceptionHandler.java @@ -3,11 +3,15 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.ConstraintViolationException; +import org.springframework.beans.TypeMismatchException; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.core.PropertyReferenceException; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.resource.NoResourceFoundException; @@ -59,13 +63,59 @@ public ResponseEntity handleNoResourceFoundException(HttpServletR return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("요청한 API를 찾을 수 없습니다.")); } + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameterException( + HttpServletRequest request, + MissingServletRequestParameterException e + ) { + logRequest(request, e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("필수 요청 파라미터 '%s'가 없습니다.".formatted(e.getParameterName()))); + } + + /** 경로·쿼리 값의 형식 오류(숫자가 아닌 id, 없는 enum 값, 잘못된 날짜 등). */ + @ExceptionHandler(TypeMismatchException.class) + public ResponseEntity handleTypeMismatchException(HttpServletRequest request, + TypeMismatchException e) { + logRequest(request, e); + String name = e.getPropertyName() != null ? e.getPropertyName() : "요청 값"; + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("'%s'의 형식이 올바르지 않습니다.".formatted(name))); + } + + /** 정렬 파라미터에 없는 필드를 지정한 경우. */ + @ExceptionHandler(PropertyReferenceException.class) + public ResponseEntity handlePropertyReferenceException(HttpServletRequest request, + PropertyReferenceException e) { + logRequest(request, e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("정렬할 수 없는 필드입니다: " + e.getPropertyName())); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(HttpServletRequest request, Exception e) { + // 405·415·406, 헤더 누락 등 Spring MVC 예외는 스스로 4xx 상태를 갖고 있다. 이를 500으로 바꾸지 않는다. + if (e instanceof org.springframework.web.ErrorResponse springError + && springError.getStatusCode().is4xxClientError()) { + logRequest(request, e); + HttpStatusCode status = springError.getStatusCode(); + return ResponseEntity.status(status).body(new ErrorResponse(clientErrorMessage(status))); + } log.error("[{}] 예상하지 못한 예외가 발생했습니다. uri: {} {}", request.getMethod(), request.getRequestURI(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse("알 수 없는 오류가 발생했습니다.")); } + private static String clientErrorMessage(HttpStatusCode status) { + return switch (status.value()) { + case 404 -> "요청한 API를 찾을 수 없습니다."; + case 405 -> "지원하지 않는 HTTP 메서드입니다."; + case 406 -> "지원하지 않는 응답 형식입니다."; + case 415 -> "지원하지 않는 Content-Type입니다."; + default -> "잘못된 요청입니다."; + }; + } + private void logRequest(HttpServletRequest request, Exception e) { log.warn("잘못된 요청입니다. uri: {} {} exception={} message={}", request.getMethod(), request.getRequestURI(), e.getClass().getSimpleName(), e.getMessage()); diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/BcsdExceptionTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/BcsdExceptionTest.java new file mode 100644 index 0000000..a682bff --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/BcsdExceptionTest.java @@ -0,0 +1,17 @@ +package com.bcsdlab.bcsdinternalapiv2.global; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bcsdlab.bcsdinternalapiv2.game.exception.GameException; +import com.bcsdlab.bcsdinternalapiv2.game.exception.GameExceptionType; +import org.junit.jupiter.api.Test; + +class BcsdExceptionTest { + + @Test + void 도메인_예외의_메시지는_예외_타입의_메시지다() { + GameException exception = new GameException(GameExceptionType.GAME_NOT_FOUND); + + assertThat(exception.getMessage()).isEqualTo(GameExceptionType.GAME_NOT_FOUND.getMessage()); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/ClientErrorStatusIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/ClientErrorStatusIntegrationTest.java new file mode 100644 index 0000000..e7e1ef5 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/ClientErrorStatusIntegrationTest.java @@ -0,0 +1,47 @@ +package com.bcsdlab.bcsdinternalapiv2.global; + +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.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +/** 클라이언트가 잘못 보낸 요청은 500이 아니라 알맞은 4xx와 {message} 본문으로 응답한다. */ +class ClientErrorStatusIntegrationTest extends IntegrationTestSupport { + + @Test + void 지원하지_않는_HTTP_메서드는_405다() throws Exception { + mockMvc.perform(delete("/health")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(jsonPath("$.message").value("지원하지 않는 HTTP 메서드입니다.")); + } + + @Test + void 지원하지_않는_Content_Type은_415다() throws Exception { + mockMvc.perform(post("/v1/games/builds/1/webhook") + .contentType(MediaType.TEXT_PLAIN) + .content("status=ACTIVE")) + .andExpect(status().isUnsupportedMediaType()) + .andExpect(jsonPath("$.message").value("지원하지 않는 Content-Type입니다.")); + } + + @Test + void 숫자가_아닌_경로_id는_400이다() throws Exception { + mockMvc.perform(post("/v1/games/builds/abc/webhook") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"ACTIVE\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("'buildId'의 형식이 올바르지 않습니다.")); + } + + @Test + void 필수_쿼리_파라미터가_없으면_400이다() throws Exception { + mockMvc.perform(get("/v1/activities")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("필수 요청 파라미터 'category'가 없습니다.")); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImageIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImageIntegrationTest.java index ff580b7..d302eaa 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImageIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImageIntegrationTest.java @@ -220,4 +220,32 @@ void setUp() throws Exception { assertThat(imageAssetRepository.findAll()).singleElement() .satisfies(image -> assertThat(image.getUploadedBy()).isNull()); } + + @Test + @DisplayName("라이브러리 조회에 purpose가 없으면 500이 아니라 400이다") + void purpose_없는_라이브러리_조회는_400() throws Exception { + mockMvc.perform(get("/v1/admin/images") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("필수 요청 파라미터 'purpose'가 없습니다.")); + } + + @Test + @DisplayName("라이브러리 조회에 없는 purpose 값을 주면 500이 아니라 400이다") + void 없는_purpose_값은_400() throws Exception { + mockMvc.perform(get("/v1/admin/images") + .header("Authorization", "Bearer " + adminToken) + .queryParam("purpose", "NOT_A_PURPOSE")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("라이브러리 조회에 없는 필드로 정렬하면 500이 아니라 400이다") + void 없는_필드_정렬은_400() throws Exception { + mockMvc.perform(get("/v1/admin/images") + .header("Authorization", "Bearer " + adminToken) + .queryParam("purpose", "TRACK_HERO") + .queryParam("sort", "noSuchField,desc")) + .andExpect(status().isBadRequest()); + } }