Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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, "필수 외부 의존성이 응답하지 않습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,6 +37,50 @@ public ResponseEntity<ApiErrorResponse> 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<ApiErrorResponse> 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<ApiErrorResponse> 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<ApiErrorResponse> 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<ApiErrorResponse> 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<ApiErrorResponse> handleMethodArgumentNotValid(MethodArgumentNotValidException exception) {
List<Map<String, Object>> errors = exception.getBindingResult()
Expand Down Expand Up @@ -103,6 +151,15 @@ private ResponseEntity<ApiErrorResponse> 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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ApiErrorResponse> 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: <script>alert(1)</script>", new MockHttpInputMessage(new byte[0]));

ResponseEntity<ApiErrorResponse> 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<ApiErrorResponse> 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<ApiErrorResponse> 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<ApiErrorResponse> 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();
}
}
}
24 changes: 24 additions & 0 deletions docs/api-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 에러 코드 카탈로그

> 신규 추가 시 본 카탈로그에 반드시 등록. 같은 코드를 두 도메인에서 사용 금지.
Expand Down
Loading