HTTP 상태 코드 — 서버가 하는 약속과 그 수신자
앞 글에서 메서드를 이렇게 정리했다. 서버에 내리는 명령이 아니라, 요청의 내용을 볼 수 없는 참여자들에게 하는 약속이라고. 상태 코드는 그 문장의 반대편이다.
메서드가 클라이언트가 하는 약속이라면, 상태 코드는 서버가 하는 약속이다. 그리고 이 약속의 수신자 역시 사람이 아니다.
에러 메시지는 사람이 읽는다. "존재하지 않는 사용자입니다"는 화면에 띄우라고 만든 문장이다. 하지만 그 앞에 붙는 세 자리 숫자는 다르다. 그 숫자를 읽는 쪽은 대부분 코드다.
- 브라우저 — 리다이렉트를 캐시할지, 새로고침 때 재제출 경고를 띄울지, 인증 창을 띄울지
- 프록시·CDN — 이 응답을 저장해도 되는지
- 검색 엔진 크롤러 — 이 URL의 색인을 유지할지, 새 주소로 옮길지, 지울지
- HTTP 클라이언트 라이브러리와 게이트웨이 — 재시도할지 즉시 포기할지
- 모니터링·알람 — 에러율에 집계할지, 담당자를 깨울지
세 자리 숫자 하나가 이 판단들을 전부 좌우한다. 바디에 아무리 친절한 설명을 담아도 이들은 읽지 않는다.
순서는 이렇다 — 첫 자리가 무엇을 계약하는가(정의) → 왜 코드를 정확히 골라야 하는가(이유) → 경계에서 갈리는 짝들에서 무엇을 고르는가(판단).
메서드 쪽 이야기는 HTTP 메서드 — 안전성, 멱등성, 그리고 중간 참여자들과의 약속에서 다뤘다. 이 글은 그 짝이다.
1. 정의 — 첫 자리가 계약이다
1-1. 실질적인 의미는 첫 자리에 있다
상태 코드는 세 자리지만, 수신자가 반드시 이해해야 하는 것은 첫 자리뿐이다. 스펙도 그렇게 설계했다. 처음 보는 코드를 만나면 첫 자리로 대역을 판단해 처리하면 된다. 418을 모르는 클라이언트도 "4로 시작하니 내 요청이 잘못됐구나"까지는 안다.
| 대역 | 뜻 | 수신자가 해야 할 일 |
|---|---|---|
| 1xx | 진행 중 | 계속 기다린다 (101 Switching Protocols가 여기 있다) |
| 2xx | 성공 | 결과를 받아 쓴다 |
| 3xx | 추가 동작이 필요하다 | 대개 다른 주소로 다시 요청한다 |
| 4xx | 클라이언트 쪽 문제다 | 요청을 고치기 전엔 다시 보내지 마라 |
| 5xx | 서버 쪽 문제다 | 똑같이 다시 보내도 된다 |
101은 HTTP 연결을 다른 프로토콜로 갈아타는 신호다. WebSocket 핸드셰이크가 이 코드를 쓴다. 상세는 WebSocket에서 다룬다. 3xx 중304는 캐시 검증에 쓰이는 코드로, 동작은 브라우저 캐싱에 있다.
1-2. 4xx와 5xx — 둘 다 실패인데 지시가 정반대다
표의 마지막 두 줄이 이 글의 첫 번째 핵심이다. 4xx와 5xx는 "실패"라는 점에서 같지만, 수신자에게 내리는 지시가 정반대다.
- 4xx = "네가 뭔가 잘못 보냈다" → 요청을 고치기 전에는 백 번 보내도 결과가 같다. 재시도는 낭비다.
- 5xx = "요청은 멀쩡한데 내 쪽 문제다" → 잠시 후 똑같은 요청이 성공할 수 있다. 재시도할 만하다.
그래서 자동 재시도 로직은 거의 예외 없이 5xx만 재시도하고 4xx는 즉시 포기한다. 앞 글에서 본 재시도 참여자들 — 클라이언트 라이브러리, 로드밸런서, API 게이트웨이, 서비스 메시 — 이 전부 이 구분을 입력값으로 쓴다.
메서드와 상태 코드가 재시도 판단에서 어떻게 맞물리는지 보면 관계가 선명해진다.
메서드는 "다시 보내도 되는 종류의 요청인가"를 말하고, 상태 코드는 "지금 다시 보낼 만한 상황인가"를 말한다.
둘 다 참일 때만 재시도가 일어난다. PUT에 503이면 재시도하고, POST에 503이면 대개 포기하며, 어떤 메서드든 400이면 포기한다.
1-3. 오해 정정 ① — "어디서 터졌나"로 가르는 것이 아니다
여기서 가장 자주 어긋나는 판단이 있다. 4xx/5xx를 예외가 발생한 위치로 가르는 것이다. "클라이언트가 보낸 JSON을 파싱하다 서버 코드에서 예외가 났으니 500 아닌가?"
아니다. 예외는 언제나 서버에서 터진다. 클라이언트 컴퓨터에서 터질 리가 없다. 터진 위치는 판단 기준이 될 수 없다.
기준은 하나다.
"코드가 어디서 터졌나"가 아니라 "요청을 고치면 성공할 수 있나"다.
깨진 JSON을 보내서 파싱 예외가 났다면, 클라이언트가 JSON을 고쳐 보내면 성공한다. 그러니 4xx다. 반대로 요청은 완벽한데 DB 커넥션 풀이 고갈돼 실패했다면, 클라이언트가 요청을 어떻게 고쳐도 소용없다. 그러니 5xx다.
이 기준이 중요한 이유는 코드가 곧 지시이기 때문이다. 잘못된 요청에 500을 주면 클라이언트는 "서버가 잠깐 아픈가 보다" 하고 똑같은 깨진 요청을 재시도한다. 성공할 리 없는 요청이 몇 번씩 더 날아오고, 서버의 에러 알람은 클라이언트 버그 때문에 울린다.
2. 이유 — 코드는 사람이 아니라 기계를 향한 지시다
2-1. 오해 정정 ② — 실패를 200으로 감싸는 설계
실무에서 가장 흔하게 만나는 안티패턴부터 보자.
HTTP/1.1 200 OK
Content-Type: application/json
{ "success": false, "error": "존재하지 않는 사용자입니다" }
HTTP로는 성공이고, 바디 안에서만 실패다. 언뜻 "에러 형식을 통일했다"처럼 보이지만, 실제로는 앞에서 나열한 수신자들과의 대화를 전부 끊어버린 것이다.
- 프론트엔드는
res.ok를 믿을 수 없다. 모든 응답에서 바디를 파싱해success필드를 확인해야 한다. 실수로 한 곳에서 빠뜨리면 에러가 성공으로 처리되고, 그 버그는 조용히 지나간다 - 모니터링 도구는 에러율을 0%로 집계한다. 대시보드는 완벽하게 정상인데 사용자는 아무것도 못 하는 상태가 된다. 서버 로그를 뒤지기 전에는 장애를 알아차릴 수 없다
- 재시도 로직이 동작하지 않는다. 정말 서버 문제로 실패한 요청도 200으로 포장돼 있으면 어떤 참여자도 재시도하지 않는다
- 프록시·CDN이 이 응답을 캐시할 수 있다. 200은 캐시 가능한 응답이다. 잠깐의 실패 응답이 캐시에 박혀 한동안 모두에게 서빙되는 일이 실제로 벌어진다
제대로 쓰면 이렇게 된다.
HTTP/1.1 404 Not Found
Content-Type: application/json
{ "code": "USER_NOT_FOUND", "message": "존재하지 않는 사용자입니다" }
바디는 그대로 두고 코드만 바꿨을 뿐인데, 위의 네 가지가 전부 정상 동작한다. 결론은 이렇다.
실패를 200으로 감싸는 것은 상태 코드라는 공용 채널을 버리고, 우리끼리만 아는 규칙을 새로 만드는 일이다.
우리 팀의 프론트엔드는 그 규칙을 안다. 하지만 브라우저도, CDN도, 게이트웨이도, 모니터링 도구도 모른다. 그들은 계속 200을 성공으로 취급한다.
2-2. 오해 정정 ③ — 서버 오류를 전부 500으로 던지는 것
5xx 안에서도 갈라야 할 것이 있다. 흔히 서버에서 문제가 생기면 반사적으로 500을 던지는데, 5xx에는 **"내 잘못"과 "내가 의존하는 것의 문제"**를 구분하는 코드가 따로 있다.
| 코드 | 뜻 |
|---|---|
500 Internal Server Error | 예상하지 못한 오류가 났다. 코드에 문제가 있다는 신호 |
502 Bad Gateway | 내가 호출한 상위 서버가 이상한 응답을 줬다 |
503 Service Unavailable | 지금은 처리할 수 없다. 과부하이거나 점검 중이다 |
504 Gateway Timeout | 내가 호출한 상위 서버가 제때 응답하지 않았다 |
구분해야 하는 이유가 두 가지 있다.
① 500은 알람이어야 한다
500은 "예상하지 못한 오류"라는 뜻이다. 그래서 대개 500이 튀면 개발자가 즉시 확인하도록 알람이 걸려 있다. 그런데 외부 API 장애나 일시적 과부하까지 전부 500으로 던지면, 알람이 남의 서비스 사정으로 가득 찬다.
그러면 어떻게 되는가. 알람이 자주 울리면 사람은 알람을 무시하게 되고, 진짜 코드 버그로 인한 500이 그 소음에 묻힌다. 코드를 뭉뚱그린 대가를 장애 대응 시간으로 치르는 셈이다.
502·504로 나눠두면 대시보드만 보고도 "우리 코드 문제인가, 의존하는 쪽 문제인가"가 갈린다. 봐야 할 로그도, 연락할 곳도 달라진다.
② 503은 대화를 할 수 있다
503에는 Retry-After 헤더를 붙일 수 있다.
HTTP/1.1 503 Service Unavailable
Retry-After: 30
Content-Type: application/json
{ "code": "MAINTENANCE", "message": "잠시 후 다시 시도해 주세요" }
이 한 줄이 하는 일이 크다. 서버가 이미 힘든 상태에서 클라이언트들이 각자 판단으로 재시도하면, 요청이 한꺼번에 몰려 회복 중인 서버를 다시 무너뜨린다. Retry-After는 "30초 뒤에 오라"고 지정해 그 쏠림을 막는 장치다.
500을 던지면 이 대화 자체가 불가능하다. 클라이언트는 언제 다시 와야 할지 알 수 없고, 서버는 알려줄 방법이 없다.
같은 헤더가 429 Too Many Requests에도 쓰인다. 요청량 제한에 걸렸을 때 "언제 풀리는지"를 알려주는 것이다.
2-3. 그래서 상태 코드는 문서가 아니라 운영 도구다
2-1과 2-2를 합치면 하나의 결론이 나온다. 상태 코드를 고르는 일은 API 문서를 예쁘게 만드는 일이 아니다.
상태 코드는 문서화가 아니라 운영 도구다.
에러율 집계, 알람 분류, 재시도 정책, 캐시 여부, 색인 유지 — 운영에서 자동으로 돌아가는 판단들이 전부 이 세 자리를 입력으로 받는다. 코드를 뭉뚱그리면 그 판단들이 전부 뭉개진다. 그리고 그 대가는 대개 장애가 났을 때 청구된다.
3. 판단 — 경계에서 무엇을 고르는가
여기서부터가 본체다. 모든 코드를 훑는 대신, 갈림길에서 헷갈리는 네 개의 짝만 본다.
3-1. 200 vs 201 vs 204 — 성공에도 종류가 있다
전부 성공이지만 클라이언트가 이어서 할 일이 다르다.
201 Created — 새 리소스를 만들었다
HTTP/1.1 201 Created
Location: /orders/42
Content-Type: application/json
{ "id": 42, "item": "keyboard", "qty": 1 }
핵심은 코드보다 Location 헤더다. 앞 글에서 POST /orders는 "어디에 생길지 클라이언트가 모른다"고 했는데, 그 질문에 대한 답이 이 헤더다. 서버가 정한 주소를 응답으로 알려주는 것이고, 그래서 201은 Location과 짝으로 다닌다.
204 No Content — 성공했고 돌려줄 것이 없다
HTTP/1.1 204 No Content
DELETE 성공, 설정 저장처럼 결과로 보여줄 표현이 없을 때 쓴다. 204에는 바디를 넣으면 안 된다. 클라이언트는 바디를 읽으려는 시도조차 하지 않아도 된다는 것이 이 코드의 계약이다.
202 Accepted — 접수했지만 아직 처리하지 않았다
요청을 큐에 넣고 나중에 처리하는 비동기 작업에 쓴다. 200이 "다 했다"라면 202는 "받아뒀다"다. 둘을 구분하지 않으면 클라이언트는 작업이 끝난 줄 알고 다음 화면으로 넘어간다. 처리 상태를 확인할 URL을 함께 주는 것이 관례다.
HTTP/1.1 202 Accepted
Location: /jobs/9f2c1a
Content-Type: application/json
{ "jobId": "9f2c1a", "status": "queued" }
전부 200으로 통일하면 무엇을 잃는가
성공은 성공이니 200 하나면 되지 않느냐고 할 수 있다. 잃는 것은 이것이다. 클라이언트가 "만들어졌는가 / 바디를 읽어야 하는가 / 아직 처리 중인가"를 바디를 열어봐야만 알 수 있게 된다. 2-1절에서 본 것과 같은 구조다. 코드로 전달할 수 있는 정보를 굳이 바디 안으로 밀어 넣고, 그 규칙을 아는 클라이언트만 제대로 동작하게 만드는 것이다.
3-2. 400 vs 422 — 알아듣지 못했는가, 알아들었지만 받아들일 수 없는가
둘 다 "네 요청이 잘못됐다"인데, 어디서 걸렸는지가 다르다.
400 Bad Request — 요청을 해석하지 못했다
JSON 문법이 깨졌거나, 필수 필드가 없거나, 숫자 자리에 문자열이 왔거나. 서버가 요청을 이해하는 단계에서 실패한 경우다.
422 Unprocessable Content — 해석은 됐는데 내용이 규칙에 어긋난다
문법은 완벽하고 파싱도 끝났다. 그런데 값이 업무 규칙에 맞지 않는다.
{ "email": "not-an-email", "startAt": "2026-08-10", "endAt": "2026-08-01" }
형식상 흠잡을 데 없는 JSON이다. 다만 이메일이 이메일 형식이 아니고, 종료일이 시작일보다 빠르다. 읽는 데는 성공했지만 처리할 수 없는 것이고, 그것을 구분해 알려주는 코드가 422다.
실무에서는 둘을 엄격히 나누지 않고 400으로 통일하는 API도 많다. 그것도 하나의 선택이다. 중요한 것은 둘 중 무엇을 고르든,
- 일관되게 쓰는 것 — 같은 종류의 실패가 어떤 엔드포인트에서는 400, 어떤 곳에서는 422로 나오면 클라이언트는 결국 둘 다 뭉뚱그려 처리하게 된다
- 어떤 필드가 왜 틀렸는지 바디에 담는 것 — 코드는 대역을 알려줄 뿐, 사용자에게 보여줄 문장은 바디의 몫이다
같은 4xx 안에서 자주 쓰이는 두 개를 덧붙이면,
409 Conflict— 요청 자체는 유효한데 현재 상태와 충돌한다. 이미 처리 중인 요청이 있거나, 다른 사람이 먼저 수정했거나. 앞 글에서Idempotency-Key가 선점돼 있을 때 돌려준 코드가 이것이다429 Too Many Requests— 요청량 제한.Retry-After와 함께 쓴다
3-3. 401 vs 403 — 이름과 의미가 어긋나 있다
오해 정정 ④ — 401의 이름은 틀렸다
401의 공식 이름은 Unauthorized인데, 실제 의미는 **"인증되지 않았다(Unauthenticated)"**다. 이름과 뜻이 어긋나 있고, 이 어긋남이 401과 403을 헷갈리게 만드는 주범이다. 스펙 문서 자체가 이 이름이 부정확하다는 것을 인정하고 있을 정도다.
의미대로 다시 쓰면 이렇게 된다.
| 코드 | 실제 의미 | 서버가 하는 말 |
|---|---|---|
401 | 인증 실패 | 네가 누구인지 모르겠다 |
403 | 인가 실패 | 네가 누구인지는 알겠는데, 그럴 권한이 없다 |
401은 WWW-Authenticate 헤더를 함께 보내야 한다. 스펙이 요구하는 사항이고, 이 헤더가 401의 성격을 잘 보여준다.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token"
Content-Type: application/json
{ "code": "TOKEN_EXPIRED", "message": "인증이 필요합니다" }
401은 "실패했다"로 끝나지 않고 "이렇게 인증해서 다시 오라"는 지시까지 담는다. 그래서 클라이언트가 이어서 할 행동이 정해져 있다. 반면 403은 다시 인증해봐야 결과가 같으므로 그런 지시가 없다.
여기서 판단 기준이 나온다.
다시 인증하면 해결되는가? 그렇다면 401, 아니라면 403이다.
토큰 만료가 401인 이유가 이것이다. 토큰을 갱신하면 해결되니까. 반대로 일반 사용자가 관리자 API를 호출한 경우는 403이다. 아무리 다시 로그인해도 관리자가 되지는 않는다.
인증과 인가의 개념 구분 자체는 인증 vs 인가에서 다룬다. 여기서는 코드를 고르는 데 필요한 만큼만 본다.
403이 흘리는 정보 — 왜 404로 감추는가
여기서 이 글의 판단 파트를 대표하는 사례가 나온다. 403은 정직한 응답인데, 그 정직함이 정보를 흘린다.
GET /projects/1024에 403이 돌아왔다고 하자. 클라이언트는 권한이 없다는 것만 알게 될까? 아니다. 1024번 프로젝트가 존재한다는 사실도 함께 알게 된다. 없는 것에 대해서는 404가 왔을 테니까.
이 차이를 반복하면 지도가 그려진다. ID를 1부터 순서대로 넣어보면 응답 코드만으로 어떤 리소스가 존재하는지 전부 알아낼 수 있다. 유효한 ID 범위, 내부 구조, 리소스의 대략적인 규모까지 드러난다. 이것이 열거 공격이다. 내용은 하나도 못 봤지만, 목록은 손에 넣은 셈이다.
그래서 민감한 영역에서는 권한 없는 사용자에게 존재 자체를 숨기고 404를 준다. 일부 코드 호스팅 서비스가 접근 권한 없는 비공개 저장소에 403이 아니라 404를 돌려주는 것이 대표적이다. "없다"고 답하면 있는지 없는지조차 알 수 없다.
다만 이것은 공짜가 아니다. 트레이드오프가 분명하다.
| 403으로 정직하게 | 404로 숨기고 | |
|---|---|---|
| 정당한 사용자 | "권한이 없구나" — 요청할 곳을 안다 | "주소를 잘못 쳤나?" — 원인을 알 수 없다 |
| 공격자 | 존재 여부를 알아낼 수 있다 | 아무것도 알 수 없다 |
| 지원 대응 | 문의가 명확하다 | "안 보인다"는 문의가 늘어난다 |
404로 숨기면 권한 문제인지 오타인지 정당한 사용자도 구분할 수 없다. 그래서 모든 곳에 적용할 일이 아니라, 존재 여부 자체가 비밀인 영역에만 적용한다. 일반적인 곳에서는 403으로 솔직하게 답하는 편이 낫다.
여기서 정답은 하나가 아니다. 무엇을 지킬 것이냐에 따라 갈린다. 정직함을 택할 것인가, 정보 은닉을 택할 것인가. 상태 코드를 고르는 일이 단순한 규칙 암기가 아닌 이유가 이 지점에 있다.
열거 공격을 비롯한 취약점 전반은 XSS vs CSRF를 비롯한 웹 취약점 글에서 다룬다.
3-4. 301 vs 302 — 되돌릴 수 없는 실수
마지막 짝이 가장 위험하다. 흔히 "301은 영구, 302는 임시"로 외우는데, 그 말은 결과를 설명하지 못한다. 실제 차이는 이것이다.
브라우저가 그 지시를 기억하느냐 아니냐.
- 302 — 이번만 따라간다. 다음에 같은 URL을 요청하면 원래 주소로 다시 물어본다
- 301 — 브라우저가 캐시에 저장한다. 그 뒤로는 서버에 물어보지 않고 곧바로 새 주소로 간다
이 차이에서 결정적인 결과가 나온다.
<!-- IMAGE: 위쪽은 302 — 1차 요청과 2차 요청이 모두 서버에 도달해 매번 리다이렉트 응답을 받는 시퀀스. 아래쪽은 301 — 1차 응답을 브라우저가 캐시에 기록한 뒤 2차부터는 스스로 처리해 요청이 서버에 닿지 않는 시퀀스. "확신이 없으면 302를 쓴다"로 마무리 -->301을 한 번 내보내면, 2차 요청부터 서버는 그 요청을 볼 수조차 없다.
실수로 301을 내보냈다고 하자. 잘못을 발견하고 서버 코드를 고쳐 배포해도, 이미 응답을 받은 사용자들은 돌아오지 않는다. 그들의 브라우저는 서버에 물어보지 않기 때문이다. 남의 브라우저 캐시를 지울 방법은 없고, 만료 시점도 브라우저가 정하는데 길면 몇 달이다.
301은 배포라기보다 되돌릴 수 없는 통보에 가깝다.
그래서 실무 원칙은 간단하다.
확신이 없으면 302를 쓴다.
나중에 영구 이전이 확실해지면 301로 올릴 수 있다. 반대는 불가능하다. 한쪽만 되돌릴 수 있는 선택이라면 되돌릴 수 있는 쪽에서 시작하는 것이 맞다.
검색 엔진에서도 갈린다
크롤러 역시 이 코드를 다르게 해석한다.
- 301 — 색인을 새 URL로 옮기고, 원래 URL이 쌓아온 순위 자산을 새 주소로 넘긴다
- 302 — 원래 URL을 색인에 유지한다. 새 주소는 임시 목적지로만 본다
그래서 두 방향의 사고가 모두 가능하다. 도메인을 이전하면서 302를 쓰면 순위가 새 도메인으로 넘어가지 않고, 임시 점검 페이지에 301을 쓰면 원래 페이지가 색인에서 빠져버린다. 둘 다 코드 한 글자 차이로 생기는 일이다.
307과 308이 따로 있는 이유
스펙상 리다이렉트는 메서드를 유지해야 한다. POST 요청을 리다이렉트했으면 새 주소에도 POST로 가야 한다는 뜻이다. 그런데 초기 브라우저들이 301·302를 받으면 POST를 GET으로 바꿔서 다시 보냈다. 명백한 스펙 위반이었다.
문제는 그 무렵 이미 웹 전체가 그 동작에 의존하고 있었다는 것이다. 폼을 POST로 제출한 뒤 결과 페이지로 리다이렉트하는 흐름이 전부 "POST가 GET으로 바뀐다"를 전제로 돌아가고 있었다. 이제 와서 스펙대로 고치면 멀쩡히 동작하던 사이트들이 깨진다.
그래서 기존 코드의 동작은 그대로 두고, 메서드를 반드시 보존하는 코드를 새로 만들었다.
| 기존 | 메서드 보존 | 뜻 |
|---|---|---|
302 | 307 Temporary Redirect | 임시 |
301 | 308 Permanent Redirect | 영구 |
정리하면, POST를 리다이렉트해야 한다면 307이나 308을 써야 한다. 302를 쓰면 바디가 사라지고 GET으로 바뀐 요청이 새 주소에 도착한다.
3-5. 받는 쪽에서 코드로 분기한다
지금까지의 구분이 실제로 어떻게 쓰이는지, 클라이언트 쪽 코드로 보면 한눈에 들어온다.
async function request(path: string, init: RequestInit, retries = 2): Promise<Response> {
const res = await fetch(`https://api.example.com${path}`, init);
// 5xx — 요청은 멀쩡하다. 잠시 후 성공할 수 있으므로 재시도한다
if (res.status >= 500 && retries > 0) {
// 서버가 시점을 지정했으면 그것을 따른다. 아니면 지수 백오프
const after = Number(res.headers.get('Retry-After'));
await delay(Number.isFinite(after) && after > 0 ? after * 1000 : 2 ** (2 - retries) * 500);
return request(path, init, retries - 1);
}
// 401 — 인증 실패. 다시 인증하면 해결되는 종류이므로 갱신 후 한 번 더
if (res.status === 401) {
const renewed = await refreshToken();
if (!renewed) {
logout(); // 갱신까지 실패했다면 더 해볼 것이 없다
throw new AuthError();
}
return request(path, withToken(init, renewed), retries);
}
// 403 — 권한 부족. 재인증해도 결과가 같으므로 재시도하지 않는다
// 그 밖의 4xx — 요청을 고치기 전에는 몇 번을 보내도 같다
if (res.status >= 400) {
throw await toApiError(res);
}
return res;
}
눈여겨볼 것은 이 분기의 어디에도 응답 바디가 등장하지 않는다는 점이다. 재시도할지, 토큰을 갱신할지, 즉시 포기할지가 전부 세 자리 숫자만으로 결정된다. 2-1절에서 실패를 200으로 감싸면 안 된다고 한 이유가 여기서 드러난다. 모든 응답이 200이면 이 함수는 한 줄도 동작하지 않는다.
3-6. 정리
이 글을 다섯 줄로 압축하면 이렇다.
- 첫 자리가 계약이다. 나머지 두 자리는 그 안의 세부 사유일 뿐이다.
- 4xx와 5xx는 지시가 정반대다. 기준은 "어디서 터졌나"가 아니라 **"요청을 고치면 성공할 수 있나"**다.
- 실패를 200으로 감싸면 공용 채널을 버리는 것이다. 모니터링·재시도·캐시 판단이 전부 무력해진다.
- 401은 "누구인지 모르겠다", 403은 "권한이 없다". 다시 인증해서 해결되면 401이다. 그리고 403의 정직함이 정보를 흘릴 때는 404로 감추는 선택지가 있다.
- 301은 되돌릴 수 없다. 브라우저가 기억해버리므로, 확신이 없으면 302에서 시작한다.
처음의 문장으로 돌아가면 이렇게 된다.
상태 코드는 사용자에게 보여줄 메시지가 아니라, 브라우저·프록시·크롤러·재시도 로직·모니터링에게 내리는 지시다. 그들은 바디를 읽지 않는다. 세 자리 숫자만 읽고, 그 숫자대로 움직인다.
관련 글
- HTTP 메서드 — 안전성, 멱등성, 그리고 중간 참여자들과의 약속 — 이 글의 짝. 클라이언트가 하는 약속, 그리고 4xx/5xx 구분을 입력값으로 쓰는 재시도 참여자들.
- HTTP — 무엇을 정하는 규약인가 — 상태 코드가 놓이는 자리, 즉 응답 시작 줄.
- HTTP의 특징 — 비연결성과 무상태 — 매 응답이 스스로 결과를 말해야 하는 이유.
- 브라우저 캐싱 —
304와 조건부 요청, 그리고 3xx 응답이 캐시되는 방식. - WebSocket —
101 Switching Protocols가 실제로 쓰이는 자리. - 인증 vs 인가 — 401과 403이 각각 어디에 대응하는가.
- XSS vs CSRF — 응답이 정보를 흘린다는 관점.
- REST API — 코드와 리소스를 조합해 인터페이스를 설계하는 이야기.