헷갈리는 HTTP 상태 코드 쌍 정리 (401·403, 301·308)
401과 403, 400과 422, 301부터 308까지의 리다이렉트, 404와 410, 5xx 계열, 200에 에러를 담는 API, 429와 Retry-After를 실무 관점에서 구분합니다.
상태 코드가 틀리면 장애 감지도 틀어진다
API를 만들거나 연동할 때 상태 코드는 단순한 표시가 아닙니다. 클라이언트는 상태 코드를 보고 재시도할지, 로그인 창을 띄울지, 캐시에 저장할지를 결정하고, 모니터링 시스템은 5xx 비율로 장애를 판단합니다. 그래서 응답 본문에 에러 메시지가 있더라도 상태 코드가 맞지 않으면 여러 곳에서 잘못된 동작이 나옵니다.
이 글은 실무에서 자주 헷갈리는 코드 쌍을 기준으로 정리합니다. 각 코드의 전체 목록과 설명은 HTTP 상태 코드 도구에서 번호나 이름으로 찾아볼 수 있으므로, 여기서는 구분하는 기준에 집중합니다.
401과 403: 누구인지 모르는가, 알지만 허락하지 않는가
401 Unauthorized는 요청에 인증 정보가 없거나, 있더라도 유효하지 않다는 뜻입니다. 토큰이 만료되었거나, 헤더가 빠졌거나, 서명이 맞지 않는 경우가 여기에 해당합니다. 401 응답에는 어떤 방식으로 인증해야 하는지 알려 주는 WWW-Authenticate 헤더를 함께 보내는 것이 표준 방식입니다.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="orders-api"
403 Forbidden은 서버가 요청자를 알고 있고, 인증도 되었지만 이 리소스에 접근할 권한이 없다는 뜻입니다. 일반 사용자가 관리자 페이지를 호출하는 경우가 대표적입니다.
실무에서 가장 흔한 실수는 토큰 만료를 403으로 응답하는 것입니다. 이렇게 하면 프런트엔드는 "권한이 없습니다"라는 화면을 보여 주고 로그인 갱신을 시도하지 않습니다. 반대로 권한 부족을 401로 응답하면 클라이언트가 계속 토큰을 갱신하려다 무한 루프에 빠질 수 있습니다. 판단 기준은 "지금 요청한 사람이 인증되었는가"이며, 인증이 되었다면 "이 작업을 할 권한이 있는가"를 따로 묻습니다.
400과 422: 요청이 깨졌는가, 내용이 틀렸는가
400 Bad Request는 요청 자체를 해석할 수 없을 때 씁니다. JSON 문법이 깨졌거나, 필수 헤더가 형식에 맞지 않거나, 숫자 자리에 문자열이 들어온 경우입니다.
422는 요청의 형식은 맞지만 의미상 처리할 수 없을 때 씁니다. JSON은 정상적으로 파싱되었는데 email 값이 이메일 형식이 아니거나, 시작일이 종료일보다 늦은 경우가 여기에 해당합니다. 이 도구에서는 422를 "Unprocessable Entity"로 표시하며, 최신 HTTP 명세에서는 같은 의미를 "Unprocessable Content"로 부릅니다.
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{"errors":[{"field":"endDate","message":"종료일은 시작일보다 늦어야 합니다"}]}
두 코드를 나누면 클라이언트가 해야 할 일이 달라집니다. 400을 받으면 요청을 만드는 코드를 고쳐야 하고, 422를 받으면 사용자에게 어떤 입력이 잘못되었는지 보여 주면 됩니다. 폼 검증 에러를 모두 400으로 보내면 프런트엔드는 필드별 메시지를 만들기 어려워집니다.
301, 302, 307, 308: 주소는 옮겨졌고 요청 방식은 유지되는가
리다이렉트 코드는 두 질문으로 나눠 보면 쉽습니다. 첫째, 옮겨진 것이 영구적인가. 둘째, 요청 메서드를 그대로 유지해야 하는가.
- 301 Moved Permanently: 영구 이동입니다. 실무에서는 POST 요청에 대해서도 GET으로 바꿔 재요청하는 클라이언트가 많습니다.
- 302 Found: 임시 이동입니다. 301과 마찬가지로 많은 클라이언트가 POST를 GET으로 바꿉니다.
- 307 Temporary Redirect: 임시 이동이며 요청 메서드를 유지합니다. POST는 POST로 다시 보내야 합니다.
- 308 Permanent Redirect: 영구 이동이며 요청 메서드를 유지합니다.
예를 들어 /api/v1을 /api/v2로 옮기면서 POST 요청도 받아야 한다면 301이나 302보다 307이나 308이 맞습니다. 로그인 폼이 302로 돌아가면서 POST 본문이 사라지는 문제는 이 차이에서 생기는 경우가 많습니다. 검색 엔진에 영구 이전을 알리려면 301이나 308을 쓰고, 일시적인 점검 페이지로 보내려면 307이나 302를 씁니다.
404와 410: 없는 것인가, 지워진 것인가
404 Not Found는 해당 주소에 리소스가 없다는 뜻입니다. 오타일 수도 있고, 원래 없던 경로일 수도 있습니다. 410 Gone은 리소스가 예전에 있었고, 지금은 의도적으로 영구히 삭제되었다는 뜻입니다.
제품 페이지를 단종하면서 404를 주면, 검색 엔진과 외부 링크는 "언젠가 다시 생길 수도 있다"고 판단하고 색인을 오래 유지할 수 있습니다. 410을 주면 삭제 의도가 명확하게 전달됩니다. 다만 410은 의도적인 삭제에만 쓰고, 단순히 찾지 못한 경우에는 404를 씁니다.
500, 502, 503, 504: 누가 실패했는가
5xx는 서버 쪽 문제이므로 장애 대응의 출발점이 됩니다. 네 코드는 실패 지점을 나타냅니다.
- 500 Internal Server Error: 서버 내부에서 처리되지 않은 예외가 발생했습니다. 원인을 특정하지 못한 기본값에 가깝습니다.
- 502 Bad Gateway: 게이트웨이나 프록시가 업스트림 서버에서 잘못된 응답을 받았습니다. 업스트림이 죽었거나 연결 형식이 맞지 않는 경우입니다.
- 503 Service Unavailable: 서버가 일시적으로 요청을 처리할 수 없습니다. 점검, 과부하, 기동 중인 상태에 씁니다.
Retry-After헤더를 함께 주면 클라이언트가 언제 다시 시도할지 알 수 있습니다. - 504 Gateway Timeout: 프록시가 업스트림의 응답을 제한 시간 안에 받지 못했습니다. 업스트림이 느리거나 멈춘 경우입니다.
로드 밸런서 뒤의 서비스에서 502와 504가 같이 늘어나면 애플리케이션 자체보다 업스트림 연결이나 타임아웃 설정을 먼저 확인해야 합니다. 500이 늘어나면 애플리케이션 로그에서 예외를 찾는 것이 빠릅니다.
200에 에러 본문을 담는 API
가끔 다음과 같이 실패를 상태 200으로 감싸는 API를 만납니다.
HTTP/1.1 200 OK
Content-Type: application/json
{"ok": false, "error": "INVALID_TOKEN"}
이 방식은 여러 곳에서 문제를 일으킵니다. 로드 밸런서와 모니터링은 상태 코드만 보고 성공으로 기록하므로 장애 비율이 0으로 보입니다. 브라우저나 CDN은 200 응답을 캐시에 저장할 수 있습니다. 재시도 로직도 200을 성공으로 간주하므로 동작하지 않습니다. 가능하면 401, 422, 500처럼 실패를 나타내는 상태 코드를 쓰고, 본문에는 상세한 에러 코드를 담는 구조로 바꾸는 것이 좋습니다. 이미 외부에 공개된 API라면 호환성 때문에 바꾸기 어려울 수 있으므로, 최소한 문서에 실패 판단 기준을 명확히 적어 두어야 합니다.
429와 Retry-After
429 Too Many Requests는 클라이언트가 정해진 기간 동안 너무 많은 요청을 보냈다는 뜻입니다. 이 응답에는 Retry-After 헤더를 붙이는 것이 일반적입니다. 이 헤더의 값은 초 단위 숫자이거나 HTTP 날짜 형식입니다.
HTTP/1.1 429 Too Many Requests
Retry-After: 30
이 응답을 받은 클라이언트는 30초 뒤에 다시 요청해야 합니다. 429를 받고도 즉시 재시도를 반복하면 서버 부하가 더 커지므로, 재시도 간격을 Retry-After 값보다 짧게 잡지 않도록 해야 합니다. 429와 503을 혼동하는 경우가 많은데, 429는 특정 클라이언트의 요청 빈도가 문제일 때, 503은 서버 전체가 처리할 수 없을 때 씁니다.
점검 목록
- 인증 실패(401)와 권한 부족(403)이 구분되어 있는지 확인합니다.
- 형식 오류(400)와 의미 오류(422)가 구분되어 있는지 확인합니다.
- 리다이렉트에서 POST가 유지되어야 하는 경로에는 307이나 308을 썼는지 확인합니다.
- 삭제된 리소스에 404 대신 410이 필요한지 판단합니다.
- 5xx를 받았을 때 어느 계층이 실패했는지 502, 503, 504로 나눠 볼 수 있는지 확인합니다.
- 실패 응답이 200으로 나가고 있지 않은지 확인합니다.
- 429와 503에
Retry-After를 함께 보내는지 확인합니다.