← Posts

API · REST · HTTP

상태 코드와 에러 응답

200 OK인데 본문에 error가 있는 응답은 왜 문제인가. 상태 코드를 고르는 기준과 에러 본문을 한 벌로 통일하는 법.

목차
  1. 200 OK인데 body에 error가 있다
  2. 상태 코드는 세 갈래로 먼저 갈린다
  3. 성공에도 종류가 있다
  4. 4xx는 요청을 고치면 통한다는 뜻이다
  5. 자주 갈리는 짝들
  6. 5xx는 클라이언트가 고칠 수 없다
  7. 고를 때 던지는 질문 셋
  8. 에러 바디는 코드로 말한다
  9. 형식은 한 벌로 통일한다
  10. 그래서 실무에선 어떻게 쓰이나
  11. 정리

요청을 어떻게 받을지는 정했다. 이제 그 결과를 어떻게 알릴지 정한다.

200 OK인데 body에 error가 있다

주문 생성 API를 호출한다. 재고가 없어서 실패했다. 그런데 응답이 이렇게 온다.

http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": false,
  "error": "재고가 부족합니다"
}

돌아가긴 한다. 클라이언트가 success를 보고 분기하면 된다. 그런데 이 방식은 곳곳에서 비용을 만든다.

첫째, 아무도 실패를 모른다. 로드밸런서, API 게이트웨이, 모니터링 도구, 브라우저 개발자도구는 전부 상태 코드를 보고 성공·실패를 센다. 이 API는 실패율이 영원히 0%다. 장애가 나도 그래프가 안 움직인다.

둘째, 클라이언트 코드가 두 겹이 된다. HTTP 실패도 처리해야 하고(네트워크 오류·504 같은 건 여전히 온다) 본문의 success도 봐야 한다. 실패를 확인하는 자리가 둘로 갈린다.

셋째, 판정이 계약에 안 드러난다. success인지 ok인지 result인지, 실패 메시지가 error인지 message인지를 엔드포인트마다 다르게 정할 수 있다. 그리고 실제로 다르게 정해진다.

HTTP는 이 판정을 위해 상태 코드를 이미 갖고 있다. 먼저 있는 걸 쓰고, 세부 사정만 본문에 담으면 된다.

상태 코드는 세 갈래로 먼저 갈린다

코드가 수십 개라 외우려 들면 지친다. 실제로 매일 쓰는 건 열 개 남짓이고, 그 전에 큰 갈래 셋만 알면 절반은 정해진다.

diagramdiagram

이 갈래가 곧 클라이언트가 무엇을 해야 하는지를 말해준다.

  • 4xx - 요청 자체에 문제가 있다. 고치기 전에는 몇 번을 보내도 같은 답이다. 사용자에게 알려주고 입력을 고치게 해야 한다.
  • 5xx - 요청은 멀쩡한데 서버가 처리하지 못했다. 잠시 뒤 다시 시도할 여지가 있다.

이 구분을 흐리면 클라이언트가 재시도 전략을 못 세운다. 서버 버그로 터진 예외를 400으로 내려주면 클라이언트는 “내 요청이 잘못됐다”고 판단해서, 고칠 게 없는데 사용자에게 입력을 고치라고 말한다. 3xx는 리다이렉션인데 API 응답에서는 드물어서 여기서는 다루지 않는다.

성공에도 종류가 있다

2xx를 200 하나로만 쓰는 API가 많은데, 몇 개만 구분해두면 클라이언트가 할 일이 명확해진다.

코드언제챙길 것
200 OK조회·수정이 성공하고 돌려줄 본문이 있다기본값
201 Created새 자원이 만들어졌다Location 헤더에 만들어진 자원의 URL
202 Accepted접수했지만 아직 처리 안 됐다진행 상태를 조회할 방법을 같이 준다
204 No Content성공했고 돌려줄 본문이 없다본문을 비운다. 삭제 응답에 흔하다

201이 실무에서 값을 하는 자리가 분명하다.

http
POST /orders
http
HTTP/1.1 201 Created
Location: /orders/77
Content-Type: application/json

{"id": 77, "status": "PENDING"}

Location을 주면 클라이언트가 만들어진 자원의 주소를 조합해내지 않아도 된다. 주소 규칙이 나중에 바뀌어도 클라이언트가 안 깨진다.

202는 비동기 처리에 쓴다. 정산 배치를 거는 요청처럼 접수는 됐지만 결과는 나중에 나오는 경우다. 이때 결과를 어디서 확인하는지를 응답에 담아주지 않으면 클라이언트는 “성공했다”만 알고 아무것도 못 한다.

참고

204를 쓸 때는 본문을 정말 비워야 한다. 204라고 해놓고 JSON을 실어 보내면 클라이언트 라이브러리에 따라 본문을 아예 안 읽어서 조용히 무시된다. 돌려줄 게 있으면 200을, 없으면 204를 쓴다.

4xx는 요청을 고치면 통한다는 뜻이다

자주 쓰는 것들만 추린다.

코드전형적인 상황
400 Bad Request요청 자체를 못 읽겠다JSON 문법 오류, 필수 파라미터 누락, 타입 불일치
401 Unauthorized누구인지 모르겠다자격 증명이 없거나 유효하지 않다
403 Forbidden누군지는 알겠는데 권한이 없다남의 주문에 접근
404 Not Found그 자원이 없다/orders/999999
409 Conflict지금 상태와 충돌한다이미 취소된 주문을 또 취소
422 Unprocessable Content형식은 맞는데 내용이 규칙을 어겼다잔액보다 큰 금액을 출금
429 Too Many Requests너무 자주 부른다호출 제한 초과
참고

401403은 이름이 헷갈린다. 401은 이름이 “Unauthorized”지만 실제 뜻은 인증되지 않음이고, 403인가 실패다. 자격 증명을 어떻게 만들고 검증하는지는 인증과 인가가 다루고, 여기서는 코드가 가리키는 상태만 구분한다.

자주 갈리는 짝들

표를 봐도 실제로 고를 때 막히는 자리가 있다. 갈리는 짝만 따로 본다.

400과 422. 가르는 기준은 서버가 요청을 읽어낼 수 있었는가다. JSON 문법이 깨졌거나 숫자 자리에 문자가 왔으면 읽지도 못했으니 400이다. 읽기는 다 읽었는데 업무 규칙에 걸리면(잔액 부족처럼) 422 쪽이다. 다만 둘을 굳이 안 가르고 검증 실패를 전부 400으로 통일하는 API도 많다. 그것도 나쁜 선택은 아니다. 중요한 건 팀에서 하나로 정하는 것이고, 그보다 더 중요한 건 어느 필드가 왜 틀렸는지를 본문에 담는 것이다. 코드만으로는 사용자에게 보여줄 게 없다.

409와 422. 이미 가입된 이메일은 어느 쪽인가. 값 자체는 멀쩡하니 422로 보이지만, 지금 저장된 상태와 부딪히는 것이라 409로 주는 곳도 많다. 이 자리는 정답이 갈리므로 팀에서 정해두면 된다. 갈라두면 유용한 기준 하나는 **“다시 보내면 될 일인가”**다. 값을 고쳐 다시 보내면 되는 건 422, 상대의 상태가 바뀌어야 하는 건 409에 가깝다.

404와 403. 남의 주문을 조회했을 때 무엇을 줄까. 403은 “그건 있는데 네 것이 아니다”를 알려준다. 그러면 상대는 ID를 하나씩 넣어보며 어떤 ID가 존재하는지 알아낼 수 있다. 그래서 존재 여부 자체를 감춰야 하는 자원은 404로 응답하기도 한다. 편의와 정보 노출 사이의 선택이라, 자원의 성격에 따라 정한다.

404와 409. 이미 삭제된 주문을 또 삭제하면 무엇을 줄까. 대상이 없으니 404가 자연스러워 보이지만, DELETE는 멱등이라 “이미 없다”는 게 원하던 결과이기도 하다. 이 이야기는 Idempotency 글의 멱등성은 응답이 아니라 상태다 대목이 다룬다. 반면 이미 배송이 시작된 주문을 취소하려는 것은 다르다. 자원은 있고 요청도 멀쩡한데 지금 상태로는 그 동작을 할 수 없는 것이라 409가 맞다.

5xx는 클라이언트가 고칠 수 없다

코드
500 Internal Server Error서버에서 예상 못 한 오류가 났다
502 Bad Gateway뒤에 있는 서버가 이상한 응답을 줬다
503 Service Unavailable지금은 처리할 수 없다. 점검 중이거나 과부하
504 Gateway Timeout뒤에 있는 서버가 제때 답하지 않았다

여기서 API 설계자가 정할 게 둘이다.

5xx 본문에 내부 사정을 담지 않는다. 스택 트레이스나 SQL 문이 그대로 나가는 응답이 의외로 흔하다. 클라이언트가 할 수 있는 일이 없는데 정보만 새어 나간다. 대신 추적 ID를 준다. 사용자가 “이 번호로 문의드립니다”라고 말할 수 있고, 서버 로그에서 그 ID로 바로 찾을 수 있다.

재시도 가능성을 알려준다. 503이라면 Retry-After 헤더로 언제 다시 오라고 말할 수 있다.

그리고 여기서 앞에서 본 멱등성이 다시 걸린다.

diagramdiagram

5xx를 받았다고 해서 서버가 아무 일도 안 한 게 아니다. 처리는 끝났는데 응답만 못 돌아온 경우가 섞여 있다. 그래서 5xx 뒤의 재시도가 안전하려면 그 엔드포인트가 멱등해야 한다. 그 장치는 POST를 멱등하게 만드는 Idempotency Key 대목이 다룬다.

고를 때 던지는 질문 셋

표를 외우는 대신 질문 순서를 외우는 편이 실전에 낫다.

diagramdiagram

세 질문이다. 처리됐나 → 안 됐다면 클라이언트가 고칠 수 있나 → 고칠 수 있다면 무엇이 문제인가. 이 순서로 내려오면 대부분 답이 나오고, 애매한 자리는 앞의 “갈리는 짝”에서 팀 규칙으로 정한 대로 간다.

고민이 길어지면 덜 구체적인 쪽으로 내려가는 것도 답이다. 422인지 409인지 30분 논쟁하느니 400으로 통일하고 본문의 에러 코드로 구분하는 편이 낫다. 클라이언트가 실제로 분기하는 건 대개 상태 코드가 아니라 본문의 에러 코드다.

에러 바디는 코드로 말한다

상태 코드는 갈래만 알려준다. “왜 실패했는지”는 본문이 말해야 한다. 그런데 본문에 이렇게만 담으면 클라이언트가 할 수 있는 게 없다.

json
{"message": "재고가 부족합니다"}

클라이언트가 재고 부족일 때만 다른 화면을 띄우려면 문자열을 비교해야 한다. 그리고 어느 날 메시지를 “재고가 모자랍니다”로 고치면 그 분기가 조용히 죽는다. 사람이 읽으라고 만든 문자열을 기계가 분기에 쓰게 만든 것이 문제다.

그래서 기계가 읽을 코드사람이 읽을 메시지를 나눈다.

json
{
  "code": "OUT_OF_STOCK",
  "message": "재고가 부족합니다",
  "traceId": "a1b2c3d4"
}
  • code - 클라이언트가 분기에 쓴다. 한 번 정하면 못 바꾸는 계약이다.
  • message - 사람이 읽는다. 언제든 바꿔도 되고, 다국어 대응이 필요하면 클라이언트가 code로 자기 문구를 고르면 된다.
  • traceId - 문의와 로그를 잇는다.

검증 실패처럼 여러 곳이 한꺼번에 틀린 경우는 필드별로 담는다.

json
{
  "code": "VALIDATION_FAILED",
  "message": "입력값을 확인해주세요",
  "errors": [
    {"field": "email", "code": "INVALID_FORMAT", "message": "이메일 형식이 아닙니다"},
    {"field": "password", "code": "TOO_SHORT", "message": "8자 이상이어야 합니다"}
  ]
}

첫 번째 오류에서 멈추고 하나만 돌려주면, 사용자는 폼을 제출할 때마다 하나씩 고치게 된다. 틀린 걸 한 번에 다 알려주는 게 훨씬 낫다.

형식은 한 벌로 통일한다

여기까지 정했어도, 엔드포인트마다 각자 에러를 만들면 다시 어그러진다. Spring에서는 예외를 한 곳에서 잡아 형식을 맞추는 방법이 있다.

java
@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handle(BusinessException e) {
        return ResponseEntity
                .status(e.getStatus())                       // 상태 코드는 예외가 안다
                .body(new ErrorResponse(e.getCode(), e.getMessage(), MDC.get("traceId")));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        var errors = e.getBindingResult().getFieldErrors().stream()
                .map(f -> new FieldError(f.getField(), f.getCode(), f.getDefaultMessage()))
                .toList();
        return ResponseEntity.badRequest()
                .body(ErrorResponse.validation(errors));     // 형식이 한 곳에서 만들어진다
    }
}

이 자리가 에러 계약이 사는 곳이다. 컨트롤러는 예외를 던지기만 하고, 응답 모양은 여기서 한 번 정해진다. API는 약속이다에서 DTO가 성공 응답의 계약이 사는 자리였던 것과 같은 구조다.

이 처리가 요청 흐름의 어디에 끼는지는 Middleware 글에서 본 그림 그대로다. 안쪽에서 터진 예외를 바깥 층이 잡아 공통 형식으로 바꾼다.

참고

에러 응답 형식에는 표준안도 있다. RFC 9457의 Problem Details로, type·title·status·detail·instance 필드를 쓰고 Content-Typeapplication/problem+json으로 준다. 여러 조직의 API를 붙이는 상황이라면 표준을 따르는 값이 크다. 사내 API 한 벌이라면 위처럼 직접 정한 형식도 충분하다. 어느 쪽이든 한 벌로 통일하는 것이 형식 자체보다 중요하다.

그래서 실무에선 어떻게 쓰이나

에러 코드 목록을 문서에 둔다. code 값은 계약이라 클라이언트가 분기에 쓴다. 목록이 없으면 클라이언트는 실제로 받아본 값만 알고, 못 받아본 값은 처리하지 않는다. 그리고 모르는 코드가 왔을 때의 기본 동작을 정해두게 한다. 새 코드를 추가하는 건 안전한 변경이지만, 클라이언트가 모르는 코드에서 죽어버리면 안전하지 않게 된다.

상태 코드를 지표로 쓴다. 처음의 “200에 error” 방식을 버리면 얻는 게 이것이다. 4xx 비율이 튀면 클라이언트 쪽 변경이나 잘못된 사용을, 5xx 비율이 튀면 서버 장애를 가리킨다. 알림도 대체로 여기에 건다.

5xx를 4xx로 감추지 않는다. 예외를 전부 잡아서 400으로 내려버리면 지표상 서버는 늘 건강하다. 반대로 클라이언트 입력 오류를 500으로 흘리면 알림이 계속 울려 아무도 안 보게 된다. 갈래를 정확히 유지하는 것 자체가 운영 도구다.

게이트웨이가 만드는 응답도 계약이다. 앞단의 프록시나 게이트웨이가 타임아웃·호출 제한에서 내려주는 504·429는 우리 형식이 아니라 그 도구의 기본 형식으로 나간다. 클라이언트 입장에서는 같은 API의 응답이라 JSON을 기대했는데 HTML을 받는 일이 생긴다. 앞단에서 내려가는 에러 형식도 맞춰두는 편이 좋다.

정리

왜 200에 error가 나쁜가지표가 실패를 못 세고, 클라이언트 분기가 두 겹이 되고, 판정이 계약에 안 드러난다
큰 갈래2xx 성공 / 4xx 요청을 고치면 통한다 / 5xx 서버 문제
성공200 · 201(+Location) · 202(진행 조회 수단) · 204(본문 없음)
고르는 순서처리됐나 → 클라이언트가 고칠 수 있나 → 무엇이 문제인가
갈리는 짝400/422는 읽어낼 수 있었나, 404/403은 존재를 감출 것인가, 409는 상태 충돌
5xx내부 사정 대신 추적 ID. 처리는 끝났을 수도 있어서 재시도는 멱등이 전제
에러 본문기계용 code + 사람용 message + traceId. 검증 실패는 필드별로 전부
통일@RestControllerAdvice 같은 한 자리에서 형식을 만든다. 게이트웨이 응답까지

응답의 겉면은 정해졌다. 남은 건 그 안에 담기는 것, 즉 본문의 형식이다. 지금까지 아무 설명 없이 JSON을 써왔는데 왜 하필 JSON인지, 그리고 그 선택이 무엇을 대가로 치르는지는 아직 안 봤다.

다음 글은 그 자리다. 큰 정수가 클라이언트에서 조용히 깨지는 장면부터 시작한다.

관련 글