시작 절차와 규칙은 CONTRIBUTING.md를 따릅니다. 이 문서는 API 규약이 정의한 오류 응답 계약을 어떻게 구현하는가의 기준입니다.
모든 오류 응답은 API 규약의 공통 envelope를 지킵니다. 최상위는 { "success": false, "error": {…} }이며, error 안의 필드는 다음과 같습니다.
| 필드 | 의미 |
|---|---|
code |
클라이언트가 분기할 수 있는 안정적인 오류 코드 |
message |
호출자가 이해할 수 있는 오류 설명 |
fieldErrors |
검증(Bean Validation) 실패 시 필드별 위반을 담는 배열, 그 외에는 빈 배열 |
traceId |
로그와 요청을 잇는 추적 식별자 |
impact |
일부 code에만 실리는 추가 정보. 없으면 직렬화에서 생략되므로 항상 있다고 전제하지 않습니다 |
success/error envelope는 global/response/ApiResponse.fail(...)이 생성하며, global/exception/GlobalExceptionHandler의 각 분기가 이를 반환합니다.
Team-PinLog/docs의static/08_API_명세.md§5.6·§5.7이 규정한error.impact는Jira 작업(Record 삭제)에서 구현했습니다.ErrorResponse.Impact(recordDeleted, collectionIds)이며 지금은DELETE_CONFIRMATION_REQUIRED에만 실립니다. 다른code에서는null이라@JsonInclude(NON_NULL)로 응답에서 빠집니다.
이 문서는 이 계약을 한 곳에서 일관되게 생성하는 방법을 정의합니다. 컨트롤러마다 제각각 오류 응답을 만들지 않습니다.
- 오류 응답은 전역 핸들러 한 곳에서 생성합니다. 개별 컨트롤러에서 오류 JSON을 직접 조립하지 않습니다.
- 내부 구현을 노출하지 않습니다 — 스택 트레이스, SQL, 예외 클래스명, 원본 메시지를 응답 body에 담지 않습니다. 상세는 로그(
traceId로 연결)에만 남깁니다. message는 호출자를 위한 설명이고, 분기 기준은 항상code입니다. 클라이언트는message문자열에 의존하지 않습니다.- 새 오류를 추가할 때는 상태 코드 +
code+ 발생 조건 + 테스트를 함께 추가합니다.
도메인 오류는 공통 베이스 예외를 상속해, 오류 코드와 HTTP 상태를 예외가 들고 다니게 합니다. 핸들러는 이 정보를 응답으로 변환하기만 합니다.
- 베이스 예외는
global/exception에 둡니다(패키지 구조 규약). - 베이스 예외는 최소한 오류 코드와 HTTP 상태를 보유합니다.
- 도메인별 구체 예외는 해당 도메인에서 베이스를 상속합니다. 예:
record도메인의 "기록 없음"은404+ 안정 코드. - 예상 가능한 실패에는 구체 예외를 사용하고, 예상 밖 예외는 아래 "처리되지 않은 예외"로 흡수합니다.
code는 안정적이고 유일해야 합니다. 한 번 배포된 코드 값은 의미를 바꾸지 않습니다(클라이언트 분기가 깨짐).
- 코드는 한 곳(enum 등)에 모아 관리하고, 문자열을 흩뿌리지 않습니다.
매핑이 없는 4xx는
errorCodeOf의 폴백으로INVALID_INPUT(400 문구)이 됩니다. 그래서 레지스트리에 없는 상태 코드를 쓰는 PR은 대응하는ErrorCode를 먼저 추가해야 합니다 — 그렇지 않으면 상태 코드와code가 어긋나 클라이언트 분기가 깨집니다(예: 상태는403인데code는INVALID_INPUT). 지금 무엇이 있는지는 문서가 아니라ErrorCodeenum을 봅니다.
- 도메인을 접두어로 구분하는 것을 권장합니다. 예:
MEMBER_NOT_FOUND,RECORD_ACCESS_DENIED. - 코드마다 HTTP 상태와 발생 조건을 이 문서 또는 코드 주석에 기록합니다.
- 값 변경이 필요하면 옛 코드를 없애지 말고 새 코드를 추가한 뒤 마이그레이션합니다.
@RestControllerAdvice 한 곳에서 예외를 응답으로 변환합니다.
- 도메인 베이스 예외 → 예외가 든
code와 상태로 응답을 만듭니다. - 검증 실패(Bean Validation) →
400, 같은 오류 계약을 사용합니다(아래). - 처리되지 않은 예외 →
500, 일반code(예:INTERNAL_ERROR)와 무해한message만 반환하고, 원인은traceId와 함께 로그에 남깁니다.
@RestControllerAdvice 밖에서 직접 쓰여지는 응답 — Spring Security의 401/403 entry point·handler, Boot의 /error 폴백 — 은 이 공통 envelope를 거치지 않습니다. 인증 작업에서는 이런 컴포넌트도 ApiResponse.fail(...)을 직접 만들어 반환해야 합니다.
Bean Validation 실패는 API 규약대로 HTTP 400으로, 공통 오류 계약을 지켜 반환합니다.
- 필드 단위 위반을 담을 때도 최상위 형태(
code/message/traceId)는 동일하게 유지합니다. - 어떤 필드가 왜 실패했는지는 클라이언트가 다룰 수 있는 형태로 제공하되, 내부 구현은 노출하지 않습니다.
traceId는 응답과 로그를 잇는 식별자입니다. 요청마다 하나를 확보해 응답 계약과 로그에 같은 값을 사용합니다. 생성·전파 방식(요청 필터, MDC 등)은 로깅 규약에서 정의합니다.
| 상황 | 상태 |
|---|---|
| 검증 실패 | 400 |
| 미인증(쿠키 없음·만료, 회전 전 Refresh 재사용) | 401 (인증 도입 시, 인증 PR 계약) |
| CSRF 토큰 누락·불일치 | 403 — 403은 이 용도로만 씁니다 |
| 권한 부족 | 404 (리소스 은닉 정책 확정. 존재 여부를 노출하지 않습니다) |
| 리소스 없음 | 404 |
| 도메인 규칙 위반(충돌 등) | 409 등 상황에 맞는 4xx |
| 처리되지 않은 예외 | 500 |
403과 404가 한 상태 코드에 두 의미를 갖지 않도록 용도를 갈라 지킵니다 — 자원 접근 권한 실패는 404, CSRF 실패는 403입니다. 근거는 08_API_명세 §1이며 BD-21이 감수 항목으로 기록했습니다.
결정 배경: BD-13 403 대신 404를 쓰는 이유와 공개 경계 · BD-11 409에
error.impact를 실어 연쇄 삭제 범위를 알리는 이유 · BD-03 오류 응답 envelope
| 상황 | 상태 | code |
|---|---|---|
| 요청 body 파싱 실패(malformed JSON) | 400 |
INVALID_INPUT |
| 필수 query parameter 누락 | 400 |
INVALID_INPUT |
| 허용되지 않은 HTTP 메서드 | 405 |
METHOD_NOT_ALLOWED |
| 지원하지 않는 Content-Type | 415 |
UNSUPPORTED_MEDIA_TYPE |
GlobalExceptionHandler는 ResponseEntityExceptionHandler를 상속해 Spring이 이미 알고 있는
프레임워크 예외 → 상태 코드 매핑을 그대로 재사용하고, handleExceptionInternal에서 body만 공통
envelope로 교체합니다. 상태 코드 자체는 바꾸지 않으므로 위 표에 열거하지 않은 프레임워크 예외도
(예: Spring이 향후 버전에서 새로 던지는 예외) catch-all 500으로 뭉개지지 않고, 부모가 정한 상태로
응답합니다 — 다만 그 상태에 대응하는 ErrorCode가 레지스트리에 없으면 4xx는 INVALID_INPUT, 5xx는
INTERNAL_ERROR로 폴백합니다(자세한 근거는 BD-06).
오류 경로도 테스트합니다(테스트 규약).
- 새
code를 추가하면 상태 코드 +code+ 발생 조건을 검증하는 테스트를 함께 추가합니다. - 검증 실패가
400과 공통 계약으로 반환되는지 테스트합니다. - 처리되지 않은 예외가 내부 정보를 누출하지 않고
500계약으로 응답하는지 확인합니다.
- 오류 응답은 전역 핸들러 한 곳에서 생성한다
- 도메인 예외는 베이스를 상속하고 코드·상태를 보유한다
-
code는 레지스트리 한 곳에서 관리하고 안정적이다 - 스택 트레이스·SQL·예외 클래스명을 응답에 노출하지 않는다
- 검증 실패는
400+ 공통 계약으로 반환한다 - 새 오류마다 상태·코드·조건·테스트를 함께 추가한다