상태 코드는 장식이 아니라 프로토콜 계약이다
HTTP 상태 코드는 기계가 읽는 제어 흐름이며, 인터넷의 절반이 여기서 분기합니다. 브라우저는 리다이렉트를 따라갈지 오류 페이지를 보여줄지 결정하고, 캐시와 CDN은 응답을 저장할지 결정하며, 검색 크롤러는 URL을 색인에 유지할지 제거할지, HTTP 클라이언트 라이브러리는 재시도할지, 모니터링 시스템은 누군가를 호출할지 결정합니다. JSON 본문에 오류 메시지를 담고 200을 반환하는 것 — 가장 흔한 상태 코드 안티패턴 — 은 이 모든 시스템을 한꺼번에 무력화합니다. 오류가 캐시되고, 크롤러가 그것을 색인하고, 클라이언트 라이브러리는 성공을 보고하고, 장애가 진행되는 동안에도 오류율 대시보드는 초록색을 유지합니다.
권위 있는 정의는 RFC 9110 "HTTP Semantics"(2022)에 있습니다. 이전의 RFC 7231 계열을 통합·대체했습니다. 첫 자리 숫자가 계약을 담습니다. 1xx 정보, 2xx 성공, 3xx 리다이렉션, 4xx 클라이언트 잘못, 5xx 서버 잘못. 이 분류가 하중을 받치는 이유는 스펙의 명시적 규칙 때문입니다. 모르는 코드를 받은 클라이언트는 그 클래스의 x00으로 취급해야 합니다. 429를 들어본 적 없는 클라이언트는 이를 일반 400으로 처리해야 하고, 미지의 599는 500으로 취급됩니다. 새 코드가 배포된 소프트웨어를 깨뜨리지 않고 생태계에 들어올 수 있는 것이 이 덕분이며 — 잘못된 클래스에 사설 코드를 발명하는 것(예: "성공"을 470으로 보고)이 당신이 통제하지 못하는 중간 장비들을 깨뜨리는 이유이기도 합니다. 4xx/5xx 경계는 책임 배정이기도 합니다 — 누가 호출당할지를 결정하죠.
리다이렉트 제대로 하기 — 301, 302, 303, 307, 308
리다이렉트 코드가 다섯 개인 이유는 스펙과 브라우저 사이의 20년 묵은 불화 때문입니다. HTTP/1.0은 301과 302를 메서드 보존으로 정의했습니다. 302로 리다이렉트된 POST는 새 위치로 다시 POST되어야 했죠. 브라우저들은 이를 무시하고 메서드를 GET으로 바꿔 썼고, 이 동작은 너무 널리 퍼져 고칠 수 없었습니다. HTTP/1.1은 싸우는 대신 현실을 성문화했습니다. "저 다른 리소스를 GET하러 가라"는 뜻의 303 See Other가 만들어졌고(새로고침 시 폼 재제출을 막는 POST-redirect-GET 패턴의 중추), 307 Temporary Redirect가 원래의 엄격한 의미 — 같은 메서드, 같은 본문, 보장 — 를 복원했으며, RFC 7538이 2015년에 301의 메서드 보존 쌍둥이인 308 Permanent Redirect로 격자를 완성했습니다.
그래서 멘탈 모델은 2x2 행렬입니다. 영구 대 임시, 그리고 메서드 변경 가능 대 메서드 보존 필수의 교차죠. 301과 302는 레거시 모서리(메서드가 GET으로 변할 수 있음), 308과 307은 엄격한 모서리입니다. POST, PUT, DELETE를 받는 API 엔드포인트에서 이 구분은 겉치레가 아닙니다 — 업로드를 301로 리다이렉트하면 많은 클라이언트에서 본문 없는 GET으로 조용히 변환되어 요청 데이터가 증발합니다.
영구성에는 이빨이 있습니다. 브라우저는 301과 308을 공격적으로, 종종 만료 없이 캐시합니다. 잘못된 영구 리다이렉트는 서버가 전송을 멈춘 지 한참 뒤에도 로컬 캐시에서 계속 발사됩니다 — 사용자에게는 당신이 원격으로 촉발할 수 없는 캐시 삭제가 필요해집니다. 표준 조언: 위험한 리다이렉트는 먼저 302/307로 배포하고, 지켜본 뒤, 301/308로 승격하세요. 검색 엔진은 301/308을 "랭킹 신호를 새 URL로 이전"으로 취급하므로 도메인 이전과 HTTPS 전환에 올바른 선택이며, 이상적으로는 수개월간 유지해야 합니다.
method may → GET method preserved
permanent 301 Moved 308 Permanent Redirect
temporary 302 Found 307 Temporary Redirect
after POST 303 See Other → always GET (PRG pattern)
# Verify what a redirect really does:
curl -si -X POST https://example.com/old | head -3
HTTP/1.1 301 Moved Permanently
Location: https://example.com/new
# many clients will now GET /new — the POST body is gone401 대 403 대 404 — 인증, 인가, 그리고 숨기기
이름이 의미를 방해합니다. 401은 "Unauthorized"라고 적혀 있지만 실제 의미는 미인증입니다 — 서버가 당신이 누구인지 모른다는 뜻입니다. RFC 9110은 이 의도를 구조적으로 만듭니다. 401 응답은 클라이언트가 어떤 인증 방식을 써야 하는지 알려주는 WWW-Authenticate 헤더를 반드시 실어야 합니다. 401은 막다른 길이 아니라 자격 증명을 갖추고 다시 시도하라는 초대이기 때문입니다. 브라우저가 Basic 챌린지가 담긴 401에 로그인 대화상자를 띄우는 이유이자, 잘 만든 API 클라이언트가 401을 만료된 액세스 토큰 갱신과 요청 1회 재전송의 트리거로 취급하는 이유입니다.
403 Forbidden은 반대 상황입니다. 인증은 성공했고 신원도 알지만, 대답은 여전히 아니오입니다. 토큰은 유효하지만 스코프가 부족하고, 사용자는 실존하지만 관리자가 아니고, 리소스는 존재하지만 다른 사람의 것입니다. 결정적인 운영상 차이는, 같은 자격 증명으로 403을 재시도하는 것이 무의미하다는 점입니다 — 403에 반응해 토큰 갱신 루프를 도는 것은 인증 서버를 헛되이 두들기는 버그입니다.
스펙이 명시적으로 승인하는 세 번째 선택지가 있습니다. 거짓말입니다. 리소스에 대한 403은 그 리소스가 존재함을 확인해 주는데, 존재 자체가 비밀일 때가 있습니다 — 비공개 저장소 이름, 사용자 계정 유무, 내부 관리자 경로. RFC 9110은 리소스를 숨기고 싶은 서버가 403 대신 404를 반환하는 것을 허용하며, GitHub이 볼 수 없는 비공개 저장소에 정확히 이렇게 하는 것으로 유명합니다. 대가는 디버깅 가능성입니다(권한이 빠진 정당한 사용자가 혼란스러운 "not found"를 보게 됩니다). 그래서 이 선택은 보안 대 지원성의 진짜 트레이드오프이며, 의도를 갖고 결정하는 것이 최선입니다.
재시도와 레이트 리밋 — 429, 503, Retry-After
429 Too Many Requests(핵심 스펙이 아닌 RFC 6585에 정의)는 프로토콜의 배압(back-pressure) 밸브입니다. 요청은 이해되었고, 클라이언트가 그저 너무 빨리 보내고 있을 뿐입니다. 제대로 만든 429는 Retry-After 헤더를 싣습니다. 두 형식을 받는데 — 델타 초(Retry-After: 30) 또는 절대 HTTP-date — 클라이언트는 자기 일정대로 재시도하는 대신 이를 존중해야 합니다. 대부분의 실제 API는 쿼터 텔레메트리 헤더(X-RateLimit-Limit / Remaining / Reset 계열, RateLimit 필드로 표준화 진행 중)를 곁들여, 클라이언트가 벽에 부딪힌 뒤가 아니라 부딪히기 전에 스스로 속도를 조절하게 해줍니다.
재시도 여부는 세 가지 질문에 달렸습니다. 이 실패는 재시도 가능한가? 5xx는 대체로 예, 4xx는 대체로 아니오 — 요청 자체가 결함이므로 — 예외는 408, 429, 그리고 (조심스럽게) 425입니다. 이 메서드는 반복해도 안전한가? GET, PUT, DELETE는 멱등으로 정의됩니다. 전송 중 타임아웃된 POST는 보이지 않게 성공했을 수 있어서, 맹목적인 POST 재시도는 이중 결제 위험이 있습니다 — 결제 API들이 멱등성 키를 추가하는 이유죠. 재시도 간격은 어떻게? 지터(jitter)를 더한 지수 백오프입니다. 같은 고정 일정으로 재시도하는 클라이언트 무리는 파도로 재동기화되어 회복 중인 서버를 다시 짓누릅니다.
장애 중에 5xx 삼총사는 분류 지도입니다. 502 Bad Gateway: 프록시가 업스트림에 닿았지만 쓰레기를 받음 — 앱 프로세스를 보세요(크래시, OOM). 503 Service Unavailable: 의도적 거부 — 과부하, 점검, 헬스체크 실패. Retry-After가 포함될 수 있습니다. 504 Gateway Timeout: 업스트림이 제때 응답하지 않음 — 느린 쿼리, 데드락, 고갈된 커넥션 풀을 보세요.
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1751414460
Retry decision cheat sheet:
408, 429, 5xx → retry with exponential backoff + jitter
other 4xx → do not retry; fix the request
POST → only retry with an idempotency key
backoff: 1s → 2s → 4s → 8s (+ random jitter each step)상태 코드와 캐싱 — 304, ETag, 그리고 기본 캐시 가능 목록
304 Not Modified는 존재 이유 전부가 대역폭 절약인 유일한 상태 코드입니다. 흐름은 조건부 재검증입니다. 서버는 응답에 검증자(validator) — ETag(불투명한 버전 식별자) 또는 Last-Modified 날짜 — 를 붙이고, 캐시된 사본이 만료되면 클라이언트가 If-None-Match나 If-Modified-Since를 곁들여 다시 묻습니다. 리소스가 그대로라면 서버는 본문 없이 304로 답하고, 캐시는 자기 사본을 다시 신선한 것으로 표시하며, 페이로드는 회선을 건너지 않습니다. ETag에는 두 강도가 있습니다 — 약한 검증자(W/ 접두사)는 의미적 동등성을, 강한 검증자는 바이트 동일성을 약속하며 범위(range) 요청에 필수입니다 — 고전적 함정 하나: 파일 inode 메타데이터에서 유도되는 기본 ETag는 로드밸런서 뒤의 서버마다 달라서 재검증을 조용히 망가뜨립니다.
덜 알려졌지만 더 위험한 것: 일부 상태 코드는 기본적으로 캐시 가능합니다. RFC 9110은 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501을 휴리스틱 캐시 가능으로 지정합니다 — Cache-Control 헤더가 전혀 없어도 캐시가 저장할 수 있으며, Last-Modified 이후 경과 시간의 10분의 1 같은 휴리스틱으로 신선도를 추정합니다. 네, 404가 목록에 있습니다. 프로덕션 장애 모드: 배포가 끝나기 전에 요청이 도착하고, CDN이 404를 캐시하고, 파일이 존재하게 된 뒤에도 계속 404가 납니다 — 누군가 퍼지할 때까지요. 한편 302와 307은 휴리스틱 캐시 대상이 아닙니다. 임시 리다이렉트가 안전한 실험이고 영구 리다이렉트가 서약인 정확한 이유입니다.
교훈은 기본값에 절대 의존하지 말라는 것입니다. 오류를 포함한 모든 응답에 명시적 Cache-Control을 보내세요 — 404에는 60초 TTL, 5xx에는 no-store 같은 설정이 실수를 단명하게 만듭니다.
# First request — server tags the response
HTTP/1.1 200 OK
ETag: "v42-a1b2c3"
Cache-Control: max-age=3600
# One hour later — conditional revalidation
GET /app.js HTTP/1.1
If-None-Match: "v42-a1b2c3"
HTTP/1.1 304 Not Modified ← no body: bytes saved
ETag: "v42-a1b2c3"
# Cacheable WITHOUT any Cache-Control header (RFC 9110):
# 200 203 204 206 300 301 308 404 405 410 414 501