2-1. HTTP 의미론 — 메서드, 상태 코드, 표현
한 줄 요약: 메서드의 속성과 상태 코드를 "이름 모를 중간자들과 맺는 계약"으로 이해하고, 어떤 요청을 재시도·캐시·프리페치해도 안전한지를 RFC 9110을 근거로 판단할 수 있다.
학습 목표
- 메서드의 안전성(safe)·멱등성(idempotent)·캐시 가능성(cacheable)이 각각 브라우저·프록시·크롤러의 어떤 자동 동작을 허용하는지 설명할 수 있다.
- 상태 코드 오용(200에 에러 바디, 401/403 혼용 등)이 어느 중간 계층의 동작을 망가뜨리는지 추적할 수 있다.
- 리다이렉트 상태 코드(301/302/303/307/308)를 메서드 보존 관점에서 구분하고 상황에 맞게 선택할 수 있다.
- 하나의 URL이 여러 표현(representation)으로 협상되는 과정을
curl로 재현하고, 이것이 캐싱과 충돌하는 지점을 설명할 수 있다. - HTTP가 의미론과 전송의 두 계층으로 분리된 구조임을 설명하고, 이 분리가 프로토콜 버전 진화를 가능하게 한 이유를 말할 수 있다.
배경: 왜 이것이 존재하는가
백엔드 경험자에게 HTTP 의미론은 새 지식이 아니다. 메서드를 고르고, 상태 코드를 반환하고, 헤더를 읽어 봤다. 그런데 그 경험은 대부분 양 끝단의 관점이다 — 내 서버와 내 클라이언트가 합의한 규약. 이 문서가 다루는 것은 그 사이에 있는 것들이다.
요청 하나가 서버에 닿기까지 브라우저의 HTTP 캐시, 회사 프록시, CDN 엣지, 로드밸런서를 지난다. 이들은 애플리케이션의 비즈니스 로직을 전혀 모른다. 이들이 아는 것은 오직 HTTP 메시지에 적힌 것 — 메서드, 상태 코드, 헤더 — 뿐이고, 스펙이 부여한 의미를 근거로 자율적으로 행동한다. 요청을 대신 재시도하고, 응답을 저장했다가 서버에 묻지 않고 재사용하고, 사용자가 클릭하기 전에 미리 요청을 보낸다. 즉 메서드와 상태 코드는 문서화 관례가 아니라, 작성자가 만난 적 없는 소프트웨어들이 집행하는 계약이다. 이 계약을 어기면 어긴 쪽이 대가를 치른다 — 그것도 한참 뒤에, 전혀 다른 계층에서.
스펙 문서의 구조 자체가 이 관점을 반영한다. HTTP는 1999년 RFC 2616 하나로 정의되어 있다가, 2014년에 RFC 7230~7235로 분해되었고, 2022년에 현행 체계로 재편되었다:
- RFC 9110 (HTTP Semantics) — 메서드, 상태 코드, 헤더, 표현. 버전 독립.
- RFC 9111 (HTTP Caching) — 캐싱 모델 (2-2에서 다룬다).
- RFC 9112 / 9113 / 9114 — HTTP/1.1, HTTP/2, HTTP/3. 같은 의미론을 서로 다른 방식으로 전송하는 방법.
의미론과 전송의 분리는 편집상의 정리가 아니라 설계 결정이다. HTTP/2와 HTTP/3는 와이어 포맷을 완전히 갈아치웠지만 메서드·상태 코드·헤더의 의미는 한 글자도 바꾸지 않았다. 그래서 애플리케이션 코드 수정 없이 프로토콜 버전을 올릴 수 있다. 이 Phase의 목차가 그대로 이 구조다: 이 문서와 2-2, 2-3이 의미론 계층을, 2-4와 2-5가 전송 계층을 다룬다.
핵심 개념
메시지라는 추상 — 의미론은 직렬화가 아니다
RFC 9110이 정의하는 요청과 응답은 특정 텍스트 포맷이 아니라 추상 구조다.
요청 = 메서드 + 대상 URI + 헤더 필드들 + (바디)
응답 = 상태 코드 + 헤더 필드들 + (바디)HTTP/1.1은 이것을 사람이 읽을 수 있는 텍스트로 직렬화하고, HTTP/2·3는 바이너리 프레임으로 직렬화한다. 같은 요청을 두 버전으로 보내 보면 차이가 드러난다.
# HTTP/1.1 — 텍스트 직렬화. 요청 줄과 헤더가 그대로 보인다
curl -v --http1.1 https://www.google.com/ -o /dev/null 2>&1 | grep '^[>|<]'
# > GET / HTTP/1.1
# > Host: www.google.com
# < HTTP/1.1 200 OK
# HTTP/2 — 같은 의미론이 의사 헤더(pseudo-header)로 표현된다
curl -v --http2 https://www.google.com/ -o /dev/null 2>&1 | grep '^[>|<]'
# > GET / HTTP/2
# > [실제 와이어에서는 :method: GET, :path: /, :authority: www.google.com 프레임]
# < HTTP/2 200주목할 것은 HTTP/2 응답에 OK 같은 이유 구절(reason phrase)이 없다는 점이다. 이유 구절은 HTTP/1.1의 직렬화 세부였을 뿐 의미론이 아니었고, 어떤 소프트웨어도 그것에 의존하면 안 됐기 때문에 HTTP/2에서 그냥 삭제되었다. "의미론에 속한 것만 버전을 넘어 살아남는다"는 원칙을 보여주는 작은 사례다.
메서드의 3속성 — 중간자가 집행하는 계약
RFC 9110은 메서드를 세 가지 속성으로 분류한다. 이 분류가 이 문서에서 가장 중요한 내용이다.
| 속성 | 정의 | 해당 메서드 | 이 속성을 근거로 중간자가 하는 일 |
|---|---|---|---|
| 안전(safe) | 서버 상태를 바꾸지 않는다(읽기 전용) | GET, HEAD, OPTIONS | 프리페치, 크롤링, 링크 미리보기 — 사용자 의사와 무관하게 요청을 보내도 된다 |
| 멱등(idempotent) | 1번 보내나 N번 보내나 결과가 같다 | safe 전부 + PUT, DELETE | 응답을 못 받았을 때 자동 재시도 — 중복 실행을 걱정하지 않는다 |
| 캐시 가능(cacheable) | 응답을 저장해 재사용할 수 있다 | GET, HEAD (POST는 조건부) | 서버에 묻지 않고 저장된 응답으로 대답 |
포함 관계에 주의한다: 안전하면 멱등하고, 멱등하다고 안전하지는 않다. DELETE는 상태를 바꾸므로 안전하지 않지만, 같은 리소스를 두 번 지워도 결과는 "지워진 상태"로 같으므로 멱등하다.
핵심은 이 속성이 구현 사실의 서술이 아니라 서버가 하는 약속이라는 점이다. GET 핸들러 안에서 실제로 무엇을 하든, GET을 쓰는 순간 "이 요청은 아무 부작용이 없다"고 온 세상에 선언한 것이다. 그리고 세상은 그 선언을 믿고 행동한다.
- 브라우저는 멱등성을 재시도의 근거로 쓴다. keep-alive 연결로 GET을 보냈는데 서버가 응답 전에 연결을 끊으면, 브라우저는 새 연결로 조용히 재전송한다. POST였다면 재전송하지 않는다 — 첫 요청이 처리됐는지 알 수 없고, 비멱등 요청의 중복 실행은 위험하기 때문이다. 뒤로 가기나 새로고침으로 POST 응답 페이지에 돌아갈 때만 "양식을 다시 제출하시겠습니까?" 경고가 뜨는 것도 같은 이유다. GET 결과 페이지에서는 그 경고를 본 적이 없을 것이다.
- 프리페처는 안전성을 근거로 쓴다.
<link rel="prefetch">, 크롤러, 메신저의 링크 미리보기는 전부 "GET은 보내도 아무 일도 일어나지 않는다"는 전제 위에서 동작한다. 2005년 Google Web Accelerator 사건이 이 계약 위반의 고전적 사례다. 이 프록시는 페이지의 링크를 미리 받아 두려고 눈에 보이는 GET 링크를 전부 따라갔는데, 당시 많은 웹 앱이 삭제 버튼을<a href="/items/42/delete">로 만들어 두었다. 프리페처가 방문하는 것만으로 사용자 데이터가 삭제됐다. 잘못은 프리페처가 아니라 GET에 부작용을 넣은 쪽에 있다 — 스펙상 그 요청은 안전하다고 선언되어 있었다. - 캐시는 캐시 가능성을 근거로 쓴다. GET 응답만 저장 대상이 된다. 검색처럼 의미상 읽기인 작업을 (바디가 필요해서) POST로 구현하면 HTTP 계층의 캐시를 전부 포기하는 것이다.
관찰 절차: DevTools Network 패널에서 아무 폼 제출 후 새로고침하면 브라우저의 재제출 경고를 볼 수 있다. 반대로 GET 기반 검색 결과 페이지는 경고 없이 재요청된다. 같은 UI 동작이 메서드 속성 하나로 갈린다.
상태 코드 — 기계가 읽는 결과 보고
상태 코드의 1차 소비자는 사람이 아니라 소프트웨어다. 브라우저는 3xx를 보고 자동 이동하고, 캐시는 200/301/404 등 일부 코드만 저장하며, 모니터링은 5xx 비율로 알람을 울리고, 재시도 로직은 502/503을 보고 다시 시도한다. 클래스 구조가 이것을 가능하게 한다 — 처음 보는 코드라도 첫 자리만 보고 행동할 수 있다(모르는 4xx는 400처럼 취급한다).
실무에서 갈리는 지점 몇 가지를 스펙 기준으로 정리한다.
- 401 vs 403: 401 Unauthorized의 실제 의미는 "인증되지 않았다(unauthenticated)"다. 이름이 오해를 부르지만, 스펙상 401 응답은
WWW-Authenticate헤더로 인증 방법을 알려 줘야 하고, "자격 증명을 제시하면 달라질 수 있다"는 뜻이다. 403 Forbidden은 "누군지 알겠는데 안 된다" — 재인증해도 소용없다. 이 구분이 중요한 이유는 클라이언트의 자동 동작이 갈리기 때문이다: 401은 토큰 갱신 → 재시도 로직의 트리거가 되고, 403은 되면 안 된다(무한 갱신 루프의 흔한 원인). - 404 vs 410: 410 Gone은 "있었는데 의도적으로 사라졌다"는 추가 정보를 준다. 검색 엔진은 410을 보면 색인을 더 빨리 제거한다 — 상태 코드의 소비자가 브라우저만이 아니라는 예다.
- 5xx의 의미: 502/503/504는 "서버 쪽 일시적 문제"라는 신호이고,
Retry-After헤더와 함께 재시도 정책의 입력이 된다. 반면 500을 만능으로 쓰면 이 신호가 사라진다.
가장 흔하고 대가가 큰 오용은 모든 응답을 200으로 보내고 바디에 성공 여부를 담는 것이다. 무엇이 망가지는지는 실무 관점 절에서 따로 다룬다.
리다이렉트 — 메서드 보존 30년사
리다이렉트 코드가 5개나 되는 이유는 역사적 실패의 누적이다. HTTP/1.0의 302는 스펙상 "리다이렉트 시 메서드를 유지하라"였지만, 주요 브라우저들이 전부 POST를 GET으로 바꿔서 따라갔다. 스펙과 현실이 갈라지자 HTTP/1.1은 스펙을 고치는 대신 코드를 늘렸다: "GET으로 바꿔서 가라"를 명시한 303과, "반드시 메서드를 유지하라"를 명시한 307을 신설하고, 302는 현실(대부분 GET으로 변환)을 인정했다. 301에는 오랫동안 영구 + 메서드 유지 조합이 없어서 2015년 RFC 7538이 308을 추가했다.
| 코드 | 의미 | 메서드 | 브라우저·캐시의 행동 |
|---|---|---|---|
| 301 | 영구 이동 | GET으로 변환될 수 있음 | 캐시됨. 검색 엔진이 색인 이전 |
| 302 | 임시 이동 | GET으로 변환될 수 있음 | 기본적으로 캐시 안 됨 |
| 303 | 다른 곳을 보라 | 반드시 GET으로 | POST 처리 결과 페이지로 보낼 때 |
| 307 | 임시 이동 | 반드시 유지 | 메서드·바디 그대로 재전송 |
| 308 | 영구 이동 | 반드시 유지 | 301의 메서드 보존판. 캐시됨 |
선택 기준은 두 축이다 — 영구인가(캐시·색인에 영향), 메서드를 유지해야 하는가. API의 엔드포인트 이전이라면 POST가 GET으로 둔갑하면 안 되므로 307/308이어야 하고, 폼 제출 후 결과 페이지로 보내는 PRG(Post/Redirect/Get) 패턴이라면 303이 정확한 코드다.
# 리다이렉트 체인에서 메서드가 유지되는지 직접 관찰한다
# -L: 리다이렉트 추적, -d: POST 바디
curl -vL -d 'q=test' https://httpbin.org/redirect-to?url=/anything\&status_code=302 \
-o /dev/null 2>&1 | grep '^> \(GET\|POST\)'
# > POST /redirect-to?url=/anything HTTP/2 ← 첫 요청은 POST
# > GET /anything HTTP/2 ← 302를 따라가며 GET으로 변환됐다
# status_code=307로 바꾸면 두 번째 요청도 POST로 나간다부수 효과 하나: 301은 캐시된다. 잘못된 URL로 301을 한번 배포하면 사용자 브라우저에 저장되어, 서버에서 고쳐도 그 사용자는 계속 옛 목적지로 간다. "영구"라는 선언의 비가역성 — 이 패턴은 2-2의 max-age, 2-5의 HSTS에서 반복해서 만난다.
표현과 콘텐츠 협상 — 하나의 URL, 여러 개의 바이트
RFC 9110에서 URL이 가리키는 것은 파일이 아니라 리소스(resource)라는 추상이고, 실제로 전송되는 바이트는 그 리소스의 한 표현(representation)이다. 같은 /products/42가 JSON으로도, HTML로도, gzip 압축으로도, 한국어로도 전송될 수 있다 — 전부 같은 리소스의 다른 표현이다.
어느 표현을 받을지는 협상으로 정해진다. 클라이언트가 선호를 선언하면(Accept, Accept-Encoding, Accept-Language), 서버가 그중에서 고르고 무엇을 골랐는지 알려 준다(Content-Type, Content-Encoding, Content-Language).
# 같은 URL에 다른 Accept-Encoding — 다른 바이트가 돌아온다
curl -sI https://developer.mozilla.org/ -H 'Accept-Encoding: identity' | grep -i 'content-\(encoding\|length\)'
# content-length: 96813 ← 무압축
curl -sI https://developer.mozilla.org/ -H 'Accept-Encoding: br' | grep -i 'content-\(encoding\|length\)'
# content-encoding: br ← Brotli 압축본. 크기가 수분의 1로 준다선호에는 품질값(q값)으로 가중치를 준다. 브라우저가 보내는 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 같은 헤더가 그것이다 — "HTML이 제일 좋고(기본 q=1), XML은 0.9, 아무거나 0.8".
여기서 캐시 관점의 문제가 하나 생긴다. 중간의 캐시가 /products/42의 gzip 응답을 저장해 뒀는데, 다음 클라이언트는 Brotli를 원한다면? URL만으로는 응답을 재사용해도 되는지 판단할 수 없다. 서버는 Vary: Accept-Encoding으로 "이 응답은 저 요청 헤더에 따라 달라진다"고 알려야 하고, 캐시는 그 헤더까지 키에 포함해야 한다. 협상이 캐시 키를 어떻게 확장하고 언제 캐시를 무력화하는지는 2-2에서 본격적으로 다룬다.
헤더 필드의 규율 — X- 접두사가 폐기된 이유
헤더 필드 이름은 대소문자를 구분하지 않고(HTTP/2·3에서는 아예 소문자로 정규화된다), IANA 레지스트리에 등록된다. 한때 비표준 헤더에 X- 접두사를 붙이는 관례가 있었지만 RFC 6648(2012)이 공식적으로 폐기했다. 이유가 교훈적이다: 실험용 헤더가 널리 쓰이게 되면 표준화 시점에 이름을 바꿀 수 없다. 이미 배포된 수많은 구현이 옛 이름을 보내고 있기 때문이다. 그 결과 X-Forwarded-For는 표준화되면서 Forwarded(RFC 7239)라는 다른 이름으로 정의됐고, 십수 년이 지난 지금도 두 헤더가 병존한다. "일단 배포된 프로토콜 요소는 회수할 수 없다"는, 이 Phase에서 반복될 주제의 첫 등장이다.
실무 관점
"전부 200" 설계의 실제 비용
모든 실패를 200 OK + {"success": false, "error": ...}로 응답하는 API는 실무에서 흔하다. 이것은 무지의 소산일 수도 있지만, 의도적 선택일 수도 있다 — 어느 쪽이든 무엇을 포기하는지는 알고 해야 한다.
| 망가지는 계층 | 증상 |
|---|---|
| HTTP 캐시 | 에러 응답이 200이므로 캐시 가능 판정을 받아 에러가 캐시될 수 있다. 정상 복구 후에도 사용자는 캐시된 에러를 본다 |
| 모니터링·로드밸런서 | 상태 코드 기반 에러율 집계에 아무것도 잡히지 않는다. 장애가 나도 대시보드는 전부 초록색이다 |
| 재시도·서킷 브레이커 | 503을 보고 동작하는 인프라 계층 재시도가 전혀 개입하지 못한다 |
| 클라이언트 코드 | response.ok 분기가 무의미해지고, 바디를 파싱해야 성공 여부를 안다 — 에러 처리 경로가 이원화된다 |
의도적으로 이 길을 가는 대표 사례가 GraphQL이다(단일 엔드포인트 POST + 200에 errors 배열). GraphQL은 HTTP를 의미론까지 쓰는 게 아니라 터널로만 쓰기로 한 설계이고, 그 대가로 HTTP 캐시를 포기하는 대신 자체 캐시 계층(클라이언트 정규화 캐시)을 다시 만들었다. 포기하는 것이 무엇인지 알고, 그것을 대체할 계획이 있다면 유효한 선택이다. 문제는 대체 계획 없이 관성으로 200을 쓰는 경우다.
재시도 설계 — 계약을 지킬 수 없을 때
멱등성 계약의 실무적 딜레마: 결제 생성처럼 본질적으로 비멱등한 POST는 네트워크 오류 시 재시도할 수 없다 — 첫 요청이 처리됐는지 모르기 때문이다. 그렇다고 재시도를 포기하면 사용자는 "다시 시도해 주세요"를 만난다. 업계의 해법은 애플리케이션 계층에서 멱등성을 만들어 주는 것이다: 클라이언트가 요청마다 고유 키를 생성해 보내고(Idempotency-Key 헤더, IETF 표준화 진행 중), 서버가 같은 키의 재요청에 첫 결과를 재생한다. Stripe 등 결제 API가 이 패턴의 대표다. HTTP가 PUT/DELETE에 공짜로 주는 성질을, POST에서는 직접 구현해야 한다는 것 — 메서드 선택이 재시도 정책 설계와 직결된다.
프론트엔드에서 만나는 지점
- 흔한 오해와 달리
fetch()는 404나 500에서 reject되지 않는다. 네트워크 계층의 실패(연결 불가, CORS 차단)만 reject이고, "HTTP 응답이 도착했다"면 상태 코드가 무엇이든 resolve다. 이 구분은 전송과 의미론의 분리를 그대로 반영한 API 설계인데, 상세는 Phase 3-8에서 다룬다. - 검색·필터처럼 의미상 읽기인 요청을 GET + 쿼리 스트링으로 설계하면 URL 공유, 뒤로 가기, HTTP 캐시가 전부 공짜로 따라온다. POST로 만들면 셋 다 직접 구현해야 한다. "바디가 편해서 POST"는 이 셋을 지불하는 선택이다.
더 깊이
의사 헤더 — 요청 줄의 해체
HTTP/1.1의 요청 줄(GET /path HTTP/1.1)과 상태 줄(HTTP/1.1 200 OK)은 HTTP/2·3에서 사라지고, 그 구성 요소가 :method, :scheme, :authority, :path, :status라는 의사 헤더(pseudo-header)로 분해되어 일반 헤더와 같은 방식으로 압축·전송된다. Host 헤더가 :authority로 흡수된 것, 이유 구절이 삭제된 것 모두 이 재설계의 일부다. 의미론(9110)이 "요청에는 메서드와 대상이 있다"고만 정의하고 직렬화를 강제하지 않았기에 가능했던 해체다. DevTools Network 패널에서 HTTP/2 사이트의 요청 헤더를 보면 :authority:로 시작하는 의사 헤더들을 직접 확인할 수 있다.
GET의 바디 — 스펙의 회색 지대
스펙은 GET 요청의 바디를 금지하지 않는다. 대신 "정의된 의미가 없다(no defined semantics)"고 말하는데, 이것이 금지보다 나쁜 결과를 낳는다: 어떤 중간자는 바디를 버리고, 어떤 라이브러리는 보내지 못하게 막고, 어떤 서버는 읽는다. 동작이 구현마다 갈리므로 사실상 쓸 수 없다. Elasticsearch가 검색 바디를 위해 GET과 POST를 둘 다 받는 것이 이 회색 지대의 흔적이다. IETF에서는 "바디를 갖는 안전한 메서드"로 QUERY 메서드가 표준화 진행 중이다 — 회색 지대는 결국 새 프로토콜 요소로 해소된다.
구조화 필드 — 헤더 문법의 세대 교체
전통적 헤더 값은 각자 고유 문법을 가진 문자열이라 파서를 헤더마다 새로 짜야 했고, 이것이 수많은 파싱 불일치 버그(보안 문제 포함)의 원인이었다. RFC 8941(현 RFC 9651, Structured Field Values)은 리스트·딕셔너리·정수·불리언 등 공통 타입 체계를 정의했고, 신규 헤더들(Priority(2-4에서 만난다), Fetch 메타데이터 계열 Sec-Fetch-* 등)이 이 문법으로 정의되고 있다. 헤더가 "문자열"에서 "타입 있는 데이터"로 이행하는 중이다.
정리
- HTTP는 의미론(RFC 9110, 버전 독립)과 전송(9112~9114)의 두 계층으로 분리되어 있고, 이 분리 덕분에 애플리케이션 코드 변경 없이 전송 계층이 세 번 교체될 수 있었다.
- 메서드의 safe/idempotent/cacheable 속성은 분류가 아니라 선언이다. 브라우저의 자동 재시도, 프리페치, 캐시가 이 선언을 근거로 동작하므로, 선언과 구현이 다르면(부작용 있는 GET) 언젠가 중간자가 그 틈을 밟는다.
- 상태 코드의 1차 소비자는 기계다. "전부 200" 설계는 캐시·모니터링·재시도라는 세 계층의 자동 동작을 동시에 포기하는 선택이다.
- 리다이렉트 코드 5개는 "영구인가 × 메서드를 유지하는가"의 조합이며, 302의 스펙-현실 불일치를 코드 추가(303/307/308)로 수습한 역사의 산물이다.
- URL은 리소스를, 전송 바이트는 표현을 가리킨다. 협상으로 표현이 갈라지는 순간 캐시 키 문제가 생기고(
Vary), 이것이 다음 문서(2-2)의 출발점이다.
확인 문제
1. 주문 확정 API를 PUT /orders/42/confirm으로 설계했다. 이 핸들러는 호출될 때마다 확정 알림 메일을 발송한다. 어느 날 일부 사용자에게 같은 메일이 두 통씩 발송되는 일이 보고됐는데, 애플리케이션 코드 어디에도 두 번 호출하는 곳이 없다. 무슨 일이 일어났을 수 있고, 근본 원인은 무엇인가?
정답과 해설
PUT은 스펙상 멱등하다고 선언된 메서드이므로, 브라우저·HTTP 클라이언트 라이브러리·프록시는 응답을 받지 못하면(keep-alive 연결이 응답 전에 끊기는 등) 자동으로 재전송할 수 있다. 재전송된 요청이 핸들러를 다시 실행해 메일이 두 번 나갔다. 근본 원인은 재시도한 쪽이 아니라 비멱등 구현(호출마다 메일 발송)에 멱등 메서드를 붙인 것이다. "주문을 확정 상태로 만든다"는 멱등하게 구현할 수 있지만(이미 확정이면 아무것도 안 함), 부수 동작인 메일 발송까지 포함하면 멱등성이 깨진다. 해법: 상태 전이 시에만 메일을 보내도록 구현을 멱등하게 만들거나, 비멱등 동작임을 인정하고 POST + Idempotency-Key로 설계한다.
2. 게시글 작성 폼을 POST로 제출하면 서버가 302로 글 상세 페이지로 리다이렉트한다. 사용자가 상세 페이지에서 새로고침을 눌렀을 때 무슨 일이 일어나는가? 만약 서버가 302 대신 200으로 상세 페이지 HTML을 바로 응답했다면 어떻게 달라지는가?
정답과 해설
302(실질적으로 303처럼 동작)를 따라가면 브라우저의 현재 문서는 GET으로 받은 상세 페이지다. 새로고침은 마지막 요청인 GET을 반복하므로 아무 문제 없이 페이지만 다시 로드된다. 이것이 PRG(Post/Redirect/Get) 패턴의 목적이다. 반면 서버가 POST에 200으로 바로 HTML을 응답했다면 현재 문서가 "POST의 응답"이므로, 새로고침은 POST 재전송을 의미하고 브라우저는 "양식을 다시 제출하시겠습니까?" 경고를 띄운다. 사용자가 확인을 누르면 글이 중복 작성된다. 참고로 이 용도에 스펙이 정확히 마련해 둔 코드는 303 See Other("결과를 GET으로 보라")다 — 302가 관행적으로 같게 동작해서 널리 쓰일 뿐이다.
3. 사내 API가 인증 실패, 권한 부족, 리소스 없음을 전부 200 OK + {"code": "AUTH_FAILED"} 형태로 응답한다. 이 API 앞에 CDN을 붙이고 GET 응답에 Cache-Control: max-age=60을 설정했더니 이상한 장애가 발생했다. 어떤 시나리오가 가능한가?
정답과 해설
토큰이 만료된 사용자 A가 GET /api/products를 호출하면 응답은 200 + {"code": "AUTH_FAILED"}다. CDN은 상태 코드만 보고 판단하므로 이것을 정상 응답으로 60초간 캐시한다. 이후 60초 동안 유효한 토큰을 가진 다른 사용자들도 같은 URL에서 캐시된 "인증 실패" 바디를 받는다. 401이었다면 CDN은 애초에 캐시하지 않았다(4xx는 기본적으로 캐시 대상이 아니고, 401은 명시적으로 캐시 불가). 에러를 200으로 감싸는 순간 "에러는 캐시하지 않는다"는 캐시 계층의 기본 동작을 전부 잃는다. (추가 문제: 인증에 따라 응답이 달라지는 리소스를 공유 캐시에 태우려면 Vary: Authorization이나 private 지정이 필요한데, 이것은 2-2에서 다룬다.)
참고 자료
- RFC 9110 — HTTP Semantics — 이 문서 전체의 1차 자료. 특히 §9.2(메서드 속성), §15(상태 코드), §12(콘텐츠 협상).
- RFC 9112 — HTTP/1.1 — 텍스트 직렬화 세부. 의미론과 무엇이 분리됐는지 대조하며 읽으면 좋다.
- MDN — HTTP response status codes — 상태 코드별 브라우저 동작 요약. 스펙 확인 전 빠른 참조용.
- RFC 6648 — Deprecating the "X-" Prefix — X- 폐기의 근거. "배포된 것은 회수할 수 없다" 원칙의 공식 문서.
- The Idempotency-Key HTTP Header Field (IETF draft) — 비멱등 요청의 안전한 재시도 패턴 표준화 현황.
- httpbin.org — 리다이렉트·상태 코드·헤더 동작을 curl로 실험할 수 있는 에코 서비스.