HTTP 상태 코드 올바르게 쓰기: 400 vs 422, 401 vs 403, 200 vs 201 vs 204 헷갈릴 때 보는 정리

에러인데 200에 success: false를 담고 있다면, 클라이언트와 모니터링 도구가 실패를 알 수 없다. 상황별로 어떤 상태 코드를 써야 하는지, 401과 403, 400과 422처럼 헷갈리는 코드의 차이, 301과 308처럼 POST가 GET으로 바뀌는 리다이렉트 함정까지 정리했다.

실패했는데 200 OK?

이런 API 응답을 본 적이 있을 것이다.

HTTP/1.1 200 OK

{ "success": false, "message": "존재하지 않는 사용자입니다" }

요청은 실패했는데 상태 코드는 "성공"이다. 이렇게 하면 문제가 줄줄이 생긴다.

  • 프론트엔드에서 fetch의 res.ok가 true라서, 모든 응답의 본문을 열어 success를 따로 확인해야 한다.
  • 에러 모니터링, 로드밸런서, CDN이 실패를 실패로 인식하지 못한다. 대시보드의 에러율은 0%인데 사용자는 계속 실패한다.
  • 실패 응답이 "성공"으로 캐시될 수도 있다.

HTTP 상태 코드는 응답 본문을 열어보지 않고도 결과가 어떤 종류인지 알려주는 약속이다. 브라우저, 프록시, 모니터링 도구, 클라이언트 라이브러리가 모두 이 숫자를 보고 동작을 정한다.


첫 자리만 알아도 절반은 맞힌다

범위의미누구 책임?
2xx성공-
3xx다른 곳으로 가라 (리다이렉트, 캐시 사용)-
4xx클라이언트가 잘못 요청함요청을 고쳐야 함
5xx서버가 처리하다 실패함서버를 고쳐야 함

가장 중요한 구분은 4xx와 5xx다. 입력값이 틀린 건 4xx, 서버 코드에서 예외가 터진 건 5xx다. 사용자의 잘못된 입력에 500을 돌려주면, 서버 장애 알림이 울려서 엉뚱한 사람이 새벽에 깬다.


성공: 200, 201, 204

코드언제예시
200 OK일반적인 성공, 본문 있음목록 조회, 수정 후 결과 반환
201 Created새 리소스를 만들었을 때회원가입, 게시글 작성
204 No Content성공했지만 돌려줄 본문이 없을 때삭제, 본문 없는 수정

201을 쓸 때는 새로 만든 리소스의 주소를 Location 헤더에 담아주는 것이 관례다.

HTTP/1.1 201 Created
Location: /users/42

{ "id": 42, "name": "kim" }

204 응답에는 본문이 없다. 프론트엔드에서 습관처럼 await res.json()을 부르면 SyntaxError가 난다. 삭제 API처럼 204를 돌려주는 요청은 res.status === 204를 먼저 확인하자.


가장 헷갈리는 4xx 구분

400 vs 422: 형식이 틀렸나, 내용이 틀렸나

코드의미예시
400 Bad Request요청 자체를 해석할 수 없음JSON 문법이 깨짐, 필수 파라미터 타입이 아예 다름
422 Unprocessable Content형식은 맞는데 내용이 규칙에 어긋남이메일 형식이 틀림, 비밀번호가 8자 미만

실무에서는 검증 실패를 전부 400으로 처리하는 팀도 많다. 그것도 틀린 건 아니다. 중요한 건 한 API 안에서 기준을 일관되게 지키는 것이다.

401 vs 403: 누군지 모르나, 알지만 안 되나

코드의미클라이언트가 할 일
401 Unauthorized누구인지 모름 (로그인 안 함, 토큰 만료)로그인 화면으로 보내거나 토큰 갱신
403 Forbidden누군지 알지만 권한이 없음권한 없음 안내 (다시 로그인해도 소용없음)

이름이 Unauthorized라서 헷갈리지만, 401은 실제로는 "인증(Authentication)이 안 됨"이라는 뜻이다. 이 둘을 섞어 쓰면, 권한이 없는 사용자가 로그인 화면으로 계속 튕겨 나가는 무한 루프가 생긴다.

404로 존재 자체를 숨기기

다른 사람의 비공개 게시글처럼 존재한다는 사실 자체가 정보인 경우, 403 대신 404를 돌려주기도 한다. 403은 "그런 게 있긴 한데 넌 못 본다"고 알려주는 셈이기 때문이다. GitHub도 권한 없는 비공개 저장소에 404를 돌려준다.

그 밖에 자주 쓰는 4xx

코드언제예시
404 Not Found리소스가 없음삭제된 게시글
405 Method Not Allowed그 주소에서 지원하지 않는 메서드조회 전용 주소에 DELETE
409 Conflict현재 상태와 충돌이미 사용 중인 이메일로 가입, 동시 수정 충돌
429 Too Many Requests요청 횟수 제한 초과로그인 시도 과다 (Retry-After 헤더로 대기 시간 안내)

아래 미리보기에서 상황을 골라보자.


5xx: 서버 쪽 실패도 종류가 있다

코드의미예시
500 Internal Server Error서버 코드에서 예상 못 한 에러처리 안 된 예외
502 Bad Gateway중간 서버(프록시, 게이트웨이)가 뒤쪽 서버로부터 잘못된 응답을 받음뒤쪽 앱 서버가 죽어서 연결이 끊김
503 Service Unavailable서버가 일시적으로 요청을 처리할 수 없음점검 중, 과부하
504 Gateway Timeout중간 서버가 뒤쪽 서버의 응답을 기다리다 시간 초과느린 DB 쿼리로 앱 서버가 응답을 못 함

502, 504는 보통 직접 보내는 코드가 아니라, Nginx나 로드밸런서 같은 중간 서버가 대신 돌려주는 코드다. 이 코드가 보이면 "앱 서버 앞단과 앱 서버 사이"를 의심하면 된다.

500 응답 본문에 에러 스택이나 DB 쿼리를 그대로 담지 말자. 공격자에게 내부 구조를 알려주는 셈이다. 자세한 내용은 서버 로그에 남기고, 응답에는 추적용 ID 정도만 담는 게 좋다.


3xx 함정: POST가 GET으로 바뀌는 리다이렉트

리다이렉트 코드는 "어디로 가라"만 다른 게 아니라, 원래 요청의 메서드를 유지하는지가 다르다. 실제로 POST 요청(본문 name=kim)을 각 코드로 리다이렉트해보면 이렇게 된다.

코드의미리다이렉트 후 요청
301 Moved Permanently영구 이동GET, 본문 사라짐
302 Found임시 이동GET, 본문 사라짐
303 See Other다른 곳을 GET으로 조회하라GET (의도된 동작)
307 Temporary Redirect임시 이동, 메서드 유지POST, 본문 유지
308 Permanent Redirect영구 이동, 메서드 유지POST, 본문 유지

301과 302는 역사적인 이유로 브라우저가 POST를 GET으로 바꿔버린다. 그래서 API 주소를 옮기면서 301을 걸면, POST 요청의 본문이 조용히 사라진다.

  • 페이지 주소 이전(SEO): 301 또는 308
  • API 주소 이전: 메서드와 본문을 유지하는 308 (임시라면 307)
  • 폼 제출 후 결과 페이지로 보내기(새로고침 시 중복 제출 방지): 303

정리: 왜 알아두면 좋은가

상황상태 코드
조회 성공200
생성 성공201 + Location
성공, 본문 없음204
요청 형식 오류400
검증 실패422 (또는 일관되게 400)
로그인 필요 / 토큰 만료401
권한 없음403 (존재를 숨기려면 404)
중복, 상태 충돌409
요청 제한 초과429 + Retry-After
서버 예외500
일시적 점검, 과부하503
API 주소 이전308 (301은 POST를 GET으로 바꿈)

상태 코드를 제대로 쓰면 클라이언트는 본문을 열기 전에 다음 행동을 정할 수 있고(다시 로그인, 재시도, 입력 수정), 모니터링 도구는 실패를 실패로 센다. 에러 응답의 본문을 어떤 모양으로 줄지는 다음 글인 API 에러 응답 형식 설계에서 이어서 다룬다.