From c22e80d0a27ac552e5dca5c1e89bc7f98e3376f1 Mon Sep 17 00:00:00 2001 From: redjungi Date: Thu, 3 Sep 2026 18:06:54 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EC=A0=80=EC=9E=A5=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EB=AA=A9=EB=A1=9D=20=EA=B2=BD=EB=A1=9C=EC=99=80=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B3=84=EC=95=BD=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트가 쓰던 /me/saved-styles 와 { items: [...] } 응답을 페이지네이션 도입 과정에서 말없이 /saved-styles 와 PageResponse 로 바꿔 클라이언트가 깨졌다. 후보는 본인 것만 다루므로 /me 아래가 맞고, 상한이 20개라 페이지를 나눌 이유도 없다. --- .../controller/SavedStyleController.java | 19 +++++++------------ .../style/service/SavedStyleService.java | 18 +++++++----------- .../style/port/in/SavedStyleUseCase.java | 5 +---- 3 files changed, 15 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/heddy/adapter/in/web/style/controller/SavedStyleController.java b/src/main/java/com/heddy/adapter/in/web/style/controller/SavedStyleController.java index 02eaf59..c782f15 100644 --- a/src/main/java/com/heddy/adapter/in/web/style/controller/SavedStyleController.java +++ b/src/main/java/com/heddy/adapter/in/web/style/controller/SavedStyleController.java @@ -2,12 +2,12 @@ import com.heddy.adapter.in.web.style.dto.SaveStyleRequest; import com.heddy.adapter.in.web.style.dto.SavedStyleResponse; +import com.heddy.adapter.in.web.style.dto.SavedStylesResponse; import com.heddy.adapter.in.web.style.dto.UpdateSavedStyleRequest; import com.heddy.domain.style.port.in.SavedStyleUseCase; import com.heddy.global.docs.ApiDocs; import com.heddy.global.filter.RequestIdFilter; import com.heddy.global.response.ApiResponse; -import com.heddy.global.response.PageResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -26,13 +26,12 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.RequestParam; import java.util.UUID; @RestController @RequiredArgsConstructor -@RequestMapping("/saved-styles") +@RequestMapping("/me/saved-styles") @Tag(name = "저장한 후보 스타일", description = "AR 로 시연한 스타일을 보관하고 다시 꺼내 쓴다") @SecurityRequirement(name = "bearerAuth") public class SavedStyleController { @@ -43,19 +42,15 @@ public class SavedStyleController { @ApiDocs.Ok @ApiDocs.Authenticated @Operation(summary = "저장한 후보 스타일 목록", - description = "최신 저장순으로 돌려줍니다. 이미지 URL 은 저장값이 아니라 조회 시점에 " - + "짧은 만료의 Presigned GET 으로 발급합니다.") - public ApiResponse> list( + description = "최신 저장순으로 전부 돌려줍니다. 보관함은 최대 20개라 페이지를 " + + "나누지 않습니다. 이미지 URL 은 저장값이 아니라 조회 시점에 짧은 만료의 " + + "Presigned GET 으로 발급합니다.") + public ApiResponse list( @AuthenticationPrincipal UUID userId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, HttpServletRequest servletRequest ) { - SavedStyleUseCase.Page result = savedStyleUseCase.list(userId, page, size); return ApiResponse.success( - PageResponse.of( - result.items().stream().map(SavedStyleResponse::from).toList(), - page, size, result.totalElements()), + SavedStylesResponse.from(savedStyleUseCase.list(userId)), RequestIdFilter.get(servletRequest)); } diff --git a/src/main/java/com/heddy/application/style/service/SavedStyleService.java b/src/main/java/com/heddy/application/style/service/SavedStyleService.java index f0990c8..1d9e6d3 100644 --- a/src/main/java/com/heddy/application/style/service/SavedStyleService.java +++ b/src/main/java/com/heddy/application/style/service/SavedStyleService.java @@ -40,7 +40,6 @@ public class SavedStyleService implements SavedStyleUseCase { private static final int MAX_SAVED_STYLES = 20; - private static final int MAX_PAGE_SIZE = 100; private final SavedStyleRepositoryPort savedStyleRepositoryPort; private final HairColorRepositoryPort hairColorRepositoryPort; @@ -49,24 +48,21 @@ public class SavedStyleService implements SavedStyleUseCase { private final FileStoragePort fileStoragePort; private final ShareRepositoryPort shareRepositoryPort; + /** + * 보관함은 {@link #MAX_SAVED_STYLES} 개가 상한이라 한 번에 다 내려도 부담이 없다. + * 페이지를 나누면 화면이 얻는 것 없이 계약만 복잡해져 목록을 통째로 돌려준다. + */ @Override - public Page list(UUID requesterId, int page, int size) { - if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { - throw new ApplicationException(ErrorCode.INVALID_REQUEST); - } - List all = savedStyleRepositoryPort.findAllByUserId(requesterId); - int fromIndex = (int) Math.min((long) page * size, all.size()); - int toIndex = Math.min(fromIndex + size, all.size()); - List savedStyles = all.subList(fromIndex, toIndex); + public List list(UUID requesterId) { + List savedStyles = savedStyleRepositoryPort.findAllByUserId(requesterId); Map colors = colorsOf(savedStyles); Map thumbnails = thumbnailFileIdsOf(savedStyles); - List items = savedStyles.stream() + return savedStyles.stream() .map(style -> new Item( style, style.colorId() == null ? null : colors.get(style.colorId()), imageUrl(style, thumbnails))) .toList(); - return new Page(items, all.size()); } @Override diff --git a/src/main/java/com/heddy/domain/style/port/in/SavedStyleUseCase.java b/src/main/java/com/heddy/domain/style/port/in/SavedStyleUseCase.java index 3e3977c..ac206cd 100644 --- a/src/main/java/com/heddy/domain/style/port/in/SavedStyleUseCase.java +++ b/src/main/java/com/heddy/domain/style/port/in/SavedStyleUseCase.java @@ -10,7 +10,7 @@ /** 저장한 후보 스타일 보관함. AR로 시연한 스타일을 담아 두고 다시 꺼내 쓰는 자리다. */ public interface SavedStyleUseCase { - Page list(UUID requesterId, int page, int size); + List list(UUID requesterId); Item save(SaveCommand command); @@ -21,9 +21,6 @@ public interface SavedStyleUseCase { record Item(SavedStyle savedStyle, HairColor color, URI imageUrl) { } - record Page(List items, long totalElements) { - } - record SaveCommand( UUID requesterId, UUID hairstyleId, From bbbe4a1a198967db021187c424cf550842588c49 Mon Sep 17 00:00:00 2001 From: redjungi Date: Thu, 3 Sep 2026 18:06:54 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test:=20=EC=A0=80=EC=9E=A5=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EB=AA=A9=EB=A1=9D=20=EA=B3=84=EC=95=BD=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../style/SavedStyleApiIntegrationTest.java | 61 +++++++++---------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java index 20f0961..4c55920 100644 --- a/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java @@ -72,7 +72,7 @@ void savesACandidateAndReadsItBackWithColorAndSignedCapture() throws Exception { UUID hairstyleId = insertHairstyle("남자 다운펌", null); UUID captureId = readyCapture(USER_ID); - String created = mockMvc.perform(post("/saved-styles") + String created = mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -90,7 +90,7 @@ void savesACandidateAndReadsItBackWithColorAndSignedCapture() throws Exception { String savedStyleId = new ObjectMapper() .readTree(created).path("data").path("saved_style_id").asText(); - mockMvc.perform(get("/saved-styles") + mockMvc.perform(get("/me/saved-styles") .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items.length()").value(1)) @@ -105,7 +105,7 @@ void fallsBackToTheCatalogThumbnailWhenThereIsNoCapture() throws Exception { UUID thumbnailFileId = readyCapture(null); UUID hairstyleId = insertHairstyle("레이어드 컷", thumbnailFileId); - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"}".formatted(hairstyleId))) @@ -122,7 +122,7 @@ void refusesCatalogEntriesAndCapturesThatAreNotUsable() throws Exception { UUID retired = insertHairstyle("단종 스타일", null); jdbcTemplate.update("UPDATE hairstyle_assets SET active = FALSE WHERE hairstyle_id = ?", retired); - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"}".formatted(retired))) @@ -130,7 +130,7 @@ void refusesCatalogEntriesAndCapturesThatAreNotUsable() throws Exception { .andExpect(jsonPath("$.error.code").value("RESOURCE_NOT_FOUND")); // 남의 캡처 - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\",\"capture_id\":\"%s\"}" @@ -139,7 +139,7 @@ void refusesCatalogEntriesAndCapturesThatAreNotUsable() throws Exception { .andExpect(jsonPath("$.error.code").value("FORBIDDEN_RESOURCE")); // 업로드가 끝나지 않은 캡처 - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\",\"capture_id\":\"%s\"}" @@ -152,7 +152,7 @@ void refusesCatalogEntriesAndCapturesThatAreNotUsable() throws Exception { void deletesOwnCandidateAndDropsItFromSharesWithoutBreakingTheLink() throws Exception { UUID hairstyleId = insertHairstyle("남자 다운펌", null); UUID captureId = readyCapture(USER_ID); - String created = mockMvc.perform(post("/saved-styles") + String created = mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\",\"capture_id\":\"%s\"}" @@ -163,7 +163,7 @@ void deletesOwnCandidateAndDropsItFromSharesWithoutBreakingTheLink() throws Exce .readTree(created).path("data").path("saved_style_id").asText()); UUID shareId = insertShareCarrying(savedStyleId); - mockMvc.perform(delete("/saved-styles/" + savedStyleId) + mockMvc.perform(delete("/me/saved-styles/" + savedStyleId) .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isNoContent()); @@ -183,7 +183,7 @@ void deletesOwnCandidateAndDropsItFromSharesWithoutBreakingTheLink() throws Exce @Test void hidesAnotherUsersCandidateBehindTheSameNotFound() throws Exception { UUID hairstyleId = insertHairstyle("남자 다운펌", null); - String created = mockMvc.perform(post("/saved-styles") + String created = mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(OTHER_USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"}".formatted(hairstyleId))) @@ -192,12 +192,12 @@ void hidesAnotherUsersCandidateBehindTheSameNotFound() throws Exception { String foreignId = new ObjectMapper() .readTree(created).path("data").path("saved_style_id").asText(); - mockMvc.perform(delete("/saved-styles/" + foreignId) + mockMvc.perform(delete("/me/saved-styles/" + foreignId) .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.error.code").value("RESOURCE_NOT_FOUND")); - mockMvc.perform(get("/saved-styles") + mockMvc.perform(get("/me/saved-styles") .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items.length()").value(0)); @@ -208,21 +208,21 @@ void updatesAndClearsMemoOnlyForTheOwner() throws Exception { UUID hairstyleId = insertHairstyle("레이어드 컷", null); UUID savedStyleId = saveCandidate(USER_ID, hairstyleId, null); - mockMvc.perform(patch("/saved-styles/" + savedStyleId) + mockMvc.perform(patch("/me/saved-styles/" + savedStyleId) .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"memo\":\"다음 상담 때 보여주기\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.memo").value("다음 상담 때 보여주기")); - mockMvc.perform(patch("/saved-styles/" + savedStyleId) + mockMvc.perform(patch("/me/saved-styles/" + savedStyleId) .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"memo\":null}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.memo").doesNotExist()); - mockMvc.perform(patch("/saved-styles/" + savedStyleId) + mockMvc.perform(patch("/me/saved-styles/" + savedStyleId) .with(authentication(userAuthentication(OTHER_USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"memo\":\"가져오기\"}")) @@ -235,7 +235,7 @@ void rejectsDuplicateCombinationAndTwentyFirstCandidate() throws Exception { UUID duplicatedHairstyleId = insertHairstyle("중복 후보", null); saveCandidate(USER_ID, duplicatedHairstyleId, NATURAL_BLACK); - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\",\"color_id\":\"%s\"}" @@ -246,7 +246,7 @@ void rejectsDuplicateCombinationAndTwentyFirstCandidate() throws Exception { for (int index = 1; index < 20; index++) { saveCandidate(USER_ID, insertHairstyle("후보 " + index, null), null); } - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"}" @@ -256,22 +256,21 @@ void rejectsDuplicateCombinationAndTwentyFirstCandidate() throws Exception { } @Test - void returnsPagedCandidatesAndRejectsAnEmptyPatch() throws Exception { - saveCandidate(USER_ID, insertHairstyle("후보 하나", null), null); + void returnsEveryCandidateNewestFirstAndRejectsAnEmptyPatch() throws Exception { + UUID first = saveCandidate(USER_ID, insertHairstyle("후보 하나", null), null); UUID second = saveCandidate(USER_ID, insertHairstyle("후보 둘", null), null); - mockMvc.perform(get("/saved-styles") - .with(authentication(userAuthentication(USER_ID))) - .param("page", "0") - .param("size", "1")) + mockMvc.perform(get("/me/saved-styles") + .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items.length()").value(2)) .andExpect(jsonPath("$.data.items[0].saved_style_id") .value(second.toString())) - .andExpect(jsonPath("$.data.page.total_elements").value(2)) - .andExpect(jsonPath("$.data.page.total_pages").value(2)); + .andExpect(jsonPath("$.data.items[1].saved_style_id") + .value(first.toString())) + .andExpect(jsonPath("$.data.page").doesNotExist()); - mockMvc.perform(patch("/saved-styles/" + second) + mockMvc.perform(patch("/me/saved-styles/" + second) .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{}")) @@ -281,15 +280,15 @@ void returnsPagedCandidatesAndRejectsAnEmptyPatch() throws Exception { @Test void requiresAuthenticationForEveryEndpoint() throws Exception { - mockMvc.perform(get("/saved-styles")) + mockMvc.perform(get("/me/saved-styles")) .andExpect(status().isUnauthorized()); - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"}".formatted(UUID.randomUUID()))) .andExpect(status().isUnauthorized()); - mockMvc.perform(delete("/saved-styles/" + UUID.randomUUID())) + mockMvc.perform(delete("/me/saved-styles/" + UUID.randomUUID())) .andExpect(status().isUnauthorized()); - mockMvc.perform(patch("/saved-styles/" + UUID.randomUUID()) + mockMvc.perform(patch("/me/saved-styles/" + UUID.randomUUID()) .contentType(MediaType.APPLICATION_JSON) .content("{\"memo\":null}")) .andExpect(status().isUnauthorized()); @@ -297,7 +296,7 @@ void requiresAuthenticationForEveryEndpoint() throws Exception { private UUID saveCandidate(UUID userId, UUID hairstyleId, UUID colorId) throws Exception { String colorField = colorId == null ? "" : ",\"color_id\":\"" + colorId + "\""; - String created = mockMvc.perform(post("/saved-styles") + String created = mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(userId))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"%s}" From f8417b8c5e61411d42d42a846dc03f7be3978745 Mon Sep 17 00:00:00 2001 From: redjungi Date: Thu, 3 Sep 2026 19:13:55 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=AF=B8=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9D=91=EB=8B=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 경로는 있고 메서드만 없는 요청에 붙일 코드가 없어 500 과 구분되지 않았다. --- src/main/java/com/heddy/global/error/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/heddy/global/error/ErrorCode.java b/src/main/java/com/heddy/global/error/ErrorCode.java index 94b97fc..929eea6 100644 --- a/src/main/java/com/heddy/global/error/ErrorCode.java +++ b/src/main/java/com/heddy/global/error/ErrorCode.java @@ -8,6 +8,7 @@ public enum ErrorCode { AUTHENTICATION_REQUIRED(HttpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "인증이 필요합니다."), FORBIDDEN_RESOURCE(HttpStatus.FORBIDDEN, "FORBIDDEN_RESOURCE", "접근 권한이 없습니다."), RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND", "리소스를 찾을 수 없습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "지원하지 않는 요청 메서드입니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다."), FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "FILE_TOO_LARGE", "허용된 파일 크기를 초과했습니다."), FILE_CONTENT_TYPE_NOT_ALLOWED(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "FILE_CONTENT_TYPE_NOT_ALLOWED", "허용되지 않는 파일 형식입니다."), From abdd77c9ecd6f6fd213f158a18e44a370005247d Mon Sep 17 00:00:00 2001 From: redjungi Date: Thu, 3 Sep 2026 19:13:55 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EC=97=86=EB=8A=94=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EC=99=80=20=EB=AF=B8=EC=A7=80=EC=9B=90=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=A5=BC=20404=C2=B7405=20=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit catch-all Exception 핸들러가 스프링의 NoResourceFoundException 과 HttpRequestMethodNotSupportedException 까지 삼켜 경로 오타가 서버 장애로 보고됐다. 클라이언트는 code 로 분기하므로 재시도할 상황인지 요청을 고칠 상황인지 구분할 수 없었고, 오류 로그도 이 소음에 묻혔다. --- .../global/error/GlobalExceptionHandler.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/main/java/com/heddy/global/error/GlobalExceptionHandler.java b/src/main/java/com/heddy/global/error/GlobalExceptionHandler.java index bdc2409..175f5c0 100644 --- a/src/main/java/com/heddy/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/heddy/global/error/GlobalExceptionHandler.java @@ -6,12 +6,15 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.List; @@ -69,6 +72,36 @@ ResponseEntity handleInvalidRequest(Exception exception, HttpS errorCode.code(), errorCode.message(), RequestIdFilter.get(request))); } + /** + * 매칭되는 핸들러가 없는 요청. 아래 {@code Exception} 핸들러가 함께 삼키면 경로 오타가 + * 500 으로 보고돼 클라이언트는 재시도할 상황인지 요청을 고칠 상황인지 구분할 수 없고, + * 오류 로그도 이 소음에 묻힌다. 서버가 아니라 요청의 문제이므로 로그를 남기지 않는다. + * + *

정적 리소스 핸들러가 있으면 {@code NoResourceFoundException} 이, + * {@code throw-exception-if-no-handler-found} 로 동작하면 + * {@code NoHandlerFoundException} 이 올라온다. 설정에 기대지 않도록 둘 다 받는다. + */ + @ExceptionHandler({NoResourceFoundException.class, NoHandlerFoundException.class}) + ResponseEntity handleNotFound(Exception exception, HttpServletRequest request) { + ErrorCode errorCode = ErrorCode.RESOURCE_NOT_FOUND; + return ResponseEntity.status(errorCode.status()) + .body(ApiErrorResponse.of( + errorCode.code(), errorCode.message(), RequestIdFilter.get(request))); + } + + /** 경로는 있으나 메서드가 없는 경우. 스프링이 계산해 둔 Allow 헤더를 그대로 실어 보낸다. */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + ResponseEntity handleMethodNotAllowed( + HttpRequestMethodNotSupportedException exception, + HttpServletRequest request + ) { + ErrorCode errorCode = ErrorCode.METHOD_NOT_ALLOWED; + return ResponseEntity.status(errorCode.status()) + .headers(exception.getHeaders()) + .body(ApiErrorResponse.of( + errorCode.code(), errorCode.message(), RequestIdFilter.get(request))); + } + @ExceptionHandler(Exception.class) ResponseEntity handleUnexpected(Exception exception, HttpServletRequest request) { log.error("Unexpected error", exception); From 77b553d58d5e491f5c854367b5d15f54da660806 Mon Sep 17 00:00:00 2001 From: redjungi Date: Thu, 3 Sep 2026 19:13:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?test:=20=EC=97=86=EB=8A=94=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=C2=B7=EB=AF=B8=EC=A7=80=EC=9B=90=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9D=91=EB=8B=B5=20=EA=B3=84=EC=95=BD=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotFoundHandlingIntegrationTest.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/test/java/com/heddy/global/error/NotFoundHandlingIntegrationTest.java diff --git a/src/test/java/com/heddy/global/error/NotFoundHandlingIntegrationTest.java b/src/test/java/com/heddy/global/error/NotFoundHandlingIntegrationTest.java new file mode 100644 index 0000000..aa6ae14 --- /dev/null +++ b/src/test/java/com/heddy/global/error/NotFoundHandlingIntegrationTest.java @@ -0,0 +1,55 @@ +package com.heddy.global.error; + +import com.heddy.support.PostgresIntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +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.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 잘못 부른 요청은 서버 장애가 아니다. 인증 필터가 먼저 401 을 돌려주는 탓에 토큰 없이 + * 호출할 때는 드러나지 않으므로, 인증된 요청으로 계약을 고정한다. + */ +@AutoConfigureMockMvc +class NotFoundHandlingIntegrationTest extends PostgresIntegrationTest { + + private static final UUID USER_ID = UUID.fromString( + "88000000-0000-4000-8000-0000000000ff"); + + @Autowired + private MockMvc mockMvc; + + @Test + void answersUnmappedPathWithNotFound() throws Exception { + mockMvc.perform(get("/me/does-not-exist") + .with(authentication(userAuthentication()))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("RESOURCE_NOT_FOUND")) + .andExpect(jsonPath("$.request_id").isNotEmpty()); + } + + @Test + void answersUnsupportedMethodWithMethodNotAllowedAndAllowHeader() throws Exception { + // /me/consents 는 GET 만 받는다. 경로는 있고 메서드만 없는 상황이어야 405 다. + mockMvc.perform(post("/me/consents") + .with(authentication(userAuthentication()))) + .andExpect(status().isMethodNotAllowed()) + .andExpect(header().exists("Allow")) + .andExpect(jsonPath("$.error.code").value("METHOD_NOT_ALLOWED")); + } + + private UsernamePasswordAuthenticationToken userAuthentication() { + return new UsernamePasswordAuthenticationToken(USER_ID, null, List.of()); + } +}