diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index e363f978..b9165300 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -63,7 +63,10 @@ public enum ApiErrorCode { MESSAGE_NOT_BOOKMARKABLE(HttpStatus.UNPROCESSABLE_ENTITY, "질문만 오답노트에 담을 수 있습니다."), VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "요청 값이 올바르지 않습니다."), + MALFORMED_REQUEST(HttpStatus.BAD_REQUEST, "요청 형식이 올바르지 않습니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + ENDPOINT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 경로입니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 요청 메서드입니다."), SYS_RATE_LIMITED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다."), SYS_DEPENDENCY_DOWN(HttpStatus.SERVICE_UNAVAILABLE, "필수 외부 의존성이 응답하지 않습니다."), diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java index aa0729b8..6dd27ca5 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/GlobalExceptionHandler.java @@ -16,9 +16,13 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; import org.springframework.validation.FieldError; +import org.springframework.http.converter.HttpMessageNotReadableException; +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 { @@ -33,6 +37,50 @@ public ResponseEntity handleDomainException(DomainException ex return buildResponse(errorCode, resolveMessage(exception, errorCode), exception.getDetails()); } + // ── 잘못된 요청은 4xx 로 끝낸다 ────────────────────────────────────────────── + // + // 아래 네 예외는 전부 미처리라 catch-all 로 떨어져 500 + ERROR 로그 + 스택트레이스를 + // 남겼다. 운영 로그의 SYS_INTERNAL_ERROR 11건이 **전부** 이것들이었고 진짜 서버 버그는 + // 0건이었다 — 즉 실제 장애가 나도 오타 URL·스캐너 트래픽에 묻혀 구분이 안 된다. + // 클라이언트 잘못은 클라이언트에게 알리고(4xx), 로그는 WARN 으로 낮춘다. + + // 본문 누락·깨진 JSON·필드 타입 불일치. 파싱 단계라 @Valid 가 돌기 전에 터진다. + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleUnreadableBody(HttpMessageNotReadableException exception) { + log.warn("Malformed request body. traceId={}, cause={}", + TraceContext.getTraceId(), rootCauseName(exception)); + return buildResponse(ApiErrorCode.MALFORMED_REQUEST, + ApiErrorCode.MALFORMED_REQUEST.getDefaultMessage(), null); + } + + // /api/sessions/abc 처럼 경로변수를 변환하지 못한 경우. + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException exception) { + log.warn("Path/param type mismatch. traceId={}, name={}", + TraceContext.getTraceId(), exception.getName()); + return buildResponse(ApiErrorCode.MALFORMED_REQUEST, + ApiErrorCode.MALFORMED_REQUEST.getDefaultMessage(), + Map.of("parameter", Objects.toString(exception.getName(), ""))); + } + + // 존재하지 않는 경로. 봇 스캔이 상시 들어오므로 이걸 500 으로 두면 로그가 오염된다. + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResource(NoResourceFoundException exception) { + log.debug("No handler for path. traceId={}, path={}", + TraceContext.getTraceId(), exception.getResourcePath()); + return buildResponse(ApiErrorCode.ENDPOINT_NOT_FOUND, + ApiErrorCode.ENDPOINT_NOT_FOUND.getDefaultMessage(), null); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupported( + HttpRequestMethodNotSupportedException exception) { + log.warn("Method not supported. traceId={}, method={}", + TraceContext.getTraceId(), exception.getMethod()); + return buildResponse(ApiErrorCode.METHOD_NOT_ALLOWED, + ApiErrorCode.METHOD_NOT_ALLOWED.getDefaultMessage(), null); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException exception) { List> errors = exception.getBindingResult() @@ -103,6 +151,15 @@ private ResponseEntity buildResponse( return ResponseEntity.status(errorCode.getStatus()).body(response); } + // 예외 타입만 남긴다 — 원문 메시지에는 파싱 실패한 사용자 입력 조각이 섞일 수 있다. + private static String rootCauseName(Throwable exception) { + Throwable cause = exception; + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + return cause.getClass().getSimpleName(); + } + private String resolveMessage(DomainException exception, ApiErrorCode errorCode) { String message = exception.getMessage(); if (message == null || message.isBlank()) { diff --git a/backend/src/test/java/com/stackup/stackup/common/exception/GlobalExceptionHandlerClientErrorTest.java b/backend/src/test/java/com/stackup/stackup/common/exception/GlobalExceptionHandlerClientErrorTest.java new file mode 100644 index 00000000..4725b9e1 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/exception/GlobalExceptionHandlerClientErrorTest.java @@ -0,0 +1,93 @@ +package com.stackup.stackup.common.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.common.response.ApiErrorResponse; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.mock.http.MockHttpInputMessage; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +// 잘못된 요청이 500 으로 새어 나가면 운영 로그에서 진짜 장애를 구분할 수 없다. +// 실제로 운영 로그의 SYS_INTERNAL_ERROR 11건이 전부 아래 네 종류였고 서버 버그는 0건이었다. +class GlobalExceptionHandlerClientErrorTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void 본문이_없거나_깨진_JSON_은_400() { + var exception = new HttpMessageNotReadableException( + "Required request body is missing", new MockHttpInputMessage(new byte[0])); + + ResponseEntity response = handler.handleUnreadableBody(exception); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().code()).isEqualTo("MALFORMED_REQUEST"); + } + + @Test + void 파싱_실패_응답에_사용자_입력이_섞이지_않는다() { + // 원문 메시지에는 깨진 본문 조각이 들어온다 — 그대로 돌려주면 입력 반사가 된다. + var exception = new HttpMessageNotReadableException( + "JSON parse error: ", new MockHttpInputMessage(new byte[0])); + + ResponseEntity response = handler.handleUnreadableBody(exception); + + assertThat(response.getBody().message()).doesNotContain("script"); + assertThat(response.getBody().message()) + .isEqualTo(ApiErrorCode.MALFORMED_REQUEST.getDefaultMessage()); + } + + @Test + void 경로변수_타입_불일치는_400_이고_파라미터명을_알려준다() { + var exception = new MethodArgumentTypeMismatchException( + "abc", Long.class, "sessionId", null, new NumberFormatException()); + + ResponseEntity response = handler.handleTypeMismatch(exception); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().code()).isEqualTo("MALFORMED_REQUEST"); + assertThat(response.getBody().details()).containsEntry("parameter", "sessionId"); + } + + @Test + void 없는_경로는_404() { + // 봇 스캔이 상시 들어온다 — 500 으로 두면 에러 로그가 통째로 오염된다. + var exception = new NoResourceFoundException(HttpMethod.GET, "/api/nope", "/api/nope"); + + ResponseEntity response = handler.handleNoResource(exception); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getBody().code()).isEqualTo("ENDPOINT_NOT_FOUND"); + } + + @Test + void 지원하지_않는_메서드는_405() { + var exception = new HttpRequestMethodNotSupportedException("TRACE"); + + ResponseEntity response = handler.handleMethodNotSupported(exception); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.METHOD_NOT_ALLOWED); + assertThat(response.getBody().code()).isEqualTo("METHOD_NOT_ALLOWED"); + } + + @Test + void 네_코드_모두_4xx_다() { + // 5xx 로 돌아가면 클라이언트 재시도 로직이 영원히 성공하지 못할 요청을 반복한다. + for (ApiErrorCode code : new ApiErrorCode[] { + ApiErrorCode.MALFORMED_REQUEST, + ApiErrorCode.ENDPOINT_NOT_FOUND, + ApiErrorCode.METHOD_NOT_ALLOWED, + ApiErrorCode.VALIDATION_ERROR, + }) { + assertThat(code.getStatus().is4xxClientError()) + .as("%s 는 4xx 여야 한다", code) + .isTrue(); + } + } +} diff --git a/docs/api-conventions.md b/docs/api-conventions.md index ade5b194..2fdef25a 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -207,6 +207,30 @@ GET /api/sessions?status=COMPLETED&jobCategory=BACKEND&from=2026-01-01&to=2026-0 --- +## 잘못된 요청의 상태 코드 + +클라이언트 잘못은 **반드시 4xx** 로 끝낸다. 5xx 로 새어 나가면 두 가지가 동시에 망가진다 — +클라이언트 재시도 로직이 영원히 성공하지 못할 요청을 반복하고, 운영 로그에서 진짜 장애가 +오타 URL·봇 스캔에 묻힌다. 실제로 2026-09-27 점검 시점 운영 로그의 `SYS_INTERNAL_ERROR` +11건이 **전부** 아래 네 종류였고 서버 버그는 0건이었다. + +| 상황 | 예외 | 응답 | +|---|---|---| +| 본문 누락·깨진 JSON·필드 타입 불일치 | `HttpMessageNotReadableException` | 400 `MALFORMED_REQUEST` | +| 경로변수 변환 실패 (`/api/sessions/abc`) | `MethodArgumentTypeMismatchException` | 400 `MALFORMED_REQUEST` | +| `@Valid` 위반 | `MethodArgumentNotValidException` | 400 `VALIDATION_ERROR` | +| 존재하지 않는 경로 | `NoResourceFoundException` | 404 `ENDPOINT_NOT_FOUND` | +| 지원하지 않는 메서드 | `HttpRequestMethodNotSupportedException` | 405 `METHOD_NOT_ALLOWED` | + +파싱 실패 응답에 **원문 예외 메시지를 싣지 않는다** — 깨진 본문 조각(= 사용자 입력)이 +그대로 반사된다. 고정 문구만 내보내고 원인은 서버 로그에 예외 타입으로만 남긴다. + +새 `@RequestBody` 엔드포인트를 추가할 때 이 표를 다시 볼 필요는 없다 — +`GlobalExceptionHandler` 가 전역으로 처리하고 회귀는 +`GlobalExceptionHandlerClientErrorTest` 가 고정한다. + +--- + ## 5. 에러 코드 카탈로그 > 신규 추가 시 본 카탈로그에 반드시 등록. 같은 코드를 두 도메인에서 사용 금지.