이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 좋은 API 에러 응답은 HTTP 상태 코드와 애플리케이션 오류 코드를 어떻게 나누는가?
- 검증, 인증, 권한, 리소스 없음, 비즈니스 충돌, 시스템 장애는 어떤 기준으로 구분하는가?
- 클라이언트가 오래 의존해도 되는 오류 계약을 만들려면 어떤 정보를 숨기고 어떤 정보를 고정해야 하는가?
개요
예외 처리의 최종 결과는 API 에러 응답이다. 좋은 에러 응답은 서버 개발자가 보기 편한 메시지가 아니라 클라이언트가 실패를 예측 가능하게 처리할 수 있는 계약이다.
HTTP 상태 코드는 실패의 큰 범주를 표현한다. 애플리케이션 오류 코드는 그 범주 안에서 구체적인 실패 원인을 표현한다. 메시지는 사람이 이해하기 위한 설명이고, 상세 정보는 복구에 필요한 최소 데이터다.
Spring MVC는 ProblemDetail과 ErrorResponse를 통해 RFC 9457 계열 오류 응답을 지원한다. 이 표준 모델을 API 계약으로 삼을 수도 있고, 아래처럼 서비스 전용 오류 DTO를 둘 수도 있다. 중요한 것은 한 API 안에서 둘을 우연히 섞지 않는 것이다.
상태 코드 선택
400 Bad Request는 요청 문법이나 입력 값이 잘못된 경우에 적합하다. 검증 실패, 타입 변환 실패, 필수 파라미터 누락이 여기에 들어간다.
401 Unauthorized는 인증이 필요하거나 인증에 실패한 경우다. 이름과 달리 “인증되지 않음”에 가깝다. 403 Forbidden은 인증은 되었지만 권한이 부족한 경우다.
404 Not Found는 리소스가 없거나 공개하면 안 되는 리소스를 숨기려는 경우에 쓰인다. 409 Conflict는 현재 리소스 상태와 요청이 충돌할 때 유용하다. 이미 취소된 주문을 다시 취소하는 상황은 409가 400보다 의미가 분명할 수 있다.
422 Unprocessable Content를 검증 실패에 쓰는 팀도 있지만 Spring MVC 기본 검증 실패는 흔히 400으로 처리된다. 중요한 것은 특정 상태 코드를 외우는 것보다 팀의 API 계약 안에서 같은 실패를 같은 상태와 코드로 일관되게 내리는 것이다.
500 Internal Server Error는 클라이언트가 수정할 수 없는 서버 내부 실패다. 이때 응답에는 내부 구현 세부 사항을 숨기고, 로그에는 원인을 추적할 수 있는 정보를 남긴다.
응답 모델
public record ApiError(
String code,
String message,
List<ApiErrorDetail> details,
String traceId
) {
}
public record ApiErrorDetail(
String target,
String code,
String message
) {
}code는 안정적인 애플리케이션 오류 코드다. message는 사용자나 개발자가 읽을 설명이다. details는 필드 오류나 추가 맥락을 담는다. traceId는 운영 로그와 사용자의 오류 신고를 연결한다.
중요한 것은 메시지가 아니라 코드가 계약이라는 점이다. 메시지는 번역, UX, 정책에 따라 바뀔 수 있다. 클라이언트가 메시지 문자열을 파싱해 분기하면 작은 문구 변경도 장애가 된다.
예외 매핑
public enum ErrorCode {
VALIDATION_FAILED(HttpStatus.BAD_REQUEST),
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND),
ORDER_ALREADY_CANCELED(HttpStatus.CONFLICT),
INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR);
private final HttpStatus status;
ErrorCode(HttpStatus status) {
this.status = status;
}
public HttpStatus status() {
return status;
}
}오류 코드를 enum으로 관리하면 상태 코드와 코드 문자열의 관계를 한곳에서 확인할 수 있다. 다만 enum이 너무 커지면 도메인별 코드 클래스로 나누는 편이 낫다.
보안과 관측
에러 응답은 공격자에게도 보인다. SQL, 테이블명, 서버 파일 경로, 스택트레이스, 인증 토큰, 내부 클래스명은 응답에서 제거해야 한다. 대신 내부 로그에는 충분한 컨텍스트를 남겨야 한다.
관측을 위해 trace id나 request id를 응답에 포함하면 사용자 문의와 서버 로그를 연결하기 쉽다. 단, trace id 자체가 민감 정보를 포함하지 않게 해야 한다.
실전 팁
- 클라이언트 분기 기준은
code로 고정하고message는 변경 가능하게 둔다. - Problem Details를 쓰기로 했다면
type,title,status,detail,instance와 확장 속성에 애플리케이션 오류 코드를 어떻게 담을지 정한다. - 검증 오류는 필드별 detail을 제공하되 민감 값은 제외한다.
- 인증과 권한 실패는 Spring Security의 entry point, access denied handler와 연결해 일관성을 맞춘다.
- 예상하지 못한 예외는 사용자에게 일반 메시지를 주고, 내부 로그와 trace id로 추적한다.
- 오류 코드 목록은 문서화하고 테스트로 상태 코드 매핑을 고정한다.
위험 신호!
- 프론트엔드가 오류 메시지 문자열을 파싱한다.
- 모든 비즈니스 실패가
400과BAD_REQUEST코드 하나로 내려간다. - 같은 API에서 어떤 오류는 ProblemDetail, 어떤 오류는 커스텀 DTO로 내려가 클라이언트 처리가 흔들린다.
- 운영 응답에 내부 예외 클래스명과 스택트레이스가 포함된다.
- 같은 실패가 상황에 따라 서로 다른 오류 코드로 내려간다.
- trace id 없이 사용자 문의와 서버 로그를 연결해야 한다.
확인 질문
확인 질문
- HTTP 상태 코드와 애플리케이션 오류 코드의 차이는 무엇인가?
- HTTP 상태 코드는 실패의 큰 범주이고, 애플리케이션 오류 코드는 서비스 내부의 구체적인 실패 원인이다.
- 클라이언트가 메시지 문자열에 의존하면 왜 위험한가?
- 메시지는 UX와 번역에 따라 바뀔 수 있어 문자열 변경이 클라이언트 장애로 이어질 수 있기 때문이다.
- 예상하지 못한 서버 예외 응답에는 어떤 정보가 들어가야 하는가?
- 내부 상세 대신 일반 오류 메시지와 추적 가능한 trace id 정도를 넣고, 자세한 원인은 서버 로그에 남긴다.