상태 코드와 헤더
상태 코드의 다섯 범주와 모르는 코드를 다루는 규칙, 자주 쓰는 코드의 뜻을 정리하고, 성공 응답마다 다른 다음 행동과 메서드를 지키는 리다이렉트를 구별합니다. 오류 응답에 함께 보내야 하는 헤더와 오류 본문의 표준 형식, 콘텐츠 협상과 보안 헤더 같은 주요 헤더도 함께 봅니다.
응답을 받으면 가장 먼저 상태 코드(status code)를 봅니다. 200은 성공, 404는 없음, 500은 서버 오류 정도는 누구나 알지만, 클라이언트가 그다음에 무엇을 해야 하는지는 코드마다 다릅니다. 202를 받고 일이 끝났다고 표시하거나, 401과 403을 섞어 쓰거나, 리다이렉트로 POST가 GET으로 바뀌는 것을 모르면 찾기 어려운 버그가 생깁니다. 이 글에서는 상태 코드의 체계와 함께, 응답의 의미를 완성하는 헤더를 살펴봅니다.
첫 자리가 범주를 정한다
상태 코드는 세 자리 숫자이고, 첫 자리가 응답의 범주를 나타냅니다(RFC 9110 §15).
| 범주 | 뜻 | 예 |
|---|---|---|
| 1xx 정보 | 처리 중에 보내는 임시 응답 | 100 Continue, 103 Early Hints |
| 2xx 성공 | 요청을 받아 이해하고 받아들였다 | 200 OK, 201 Created, 204 No Content |
| 3xx 리다이렉션 | 요청을 끝내려면 추가 동작이 필요하다 | 301, 304 Not Modified, 308 |
| 4xx 클라이언트 오류 | 요청에 문제가 있다 | 400, 401, 404, 429 |
| 5xx 서버 오류 | 서버가 올바른 요청을 처리하지 못했다 | 500, 502, 503 |
상태 코드는 새로 등록될 수 있어서 클라이언트가 모든 코드를 알 수는 없습니다. 그래서 RFC 9110은 클라이언트가 적어도 첫 자리의 범주는 이해하고, 모르는 코드는 그 범주의 x00 코드처럼 다루라고 정합니다. 다음 코드는 Python의 HTTPStatus로 코드의 이름을 찾고, 모르는 코드는 이 규칙대로 다룹니다.
from http import HTTPStatus
def describe(code):
try:
status = HTTPStatus(code)
return f"{code} {status.phrase}"
except ValueError:
base = HTTPStatus(code // 100 * 100) # 모르는 코드는 같은 범주의 x00으로
return f"{code}: 모르는 코드이므로 {base.value} {base.phrase}처럼 다룬다"
for code in [201, 304, 429, 499, 599]:
print(describe(code))201 Created
304 Not Modified
429 Too Many Requests
499: 모르는 코드이므로 400 Bad Request처럼 다룬다
599: 모르는 코드이므로 500 Internal Server Error처럼 다룬다499는 일부 웹 서버가 "클라이언트가 응답을 기다리다 연결을 끊었다"는 뜻으로 접속 기록에 남기는 비표준 코드여서 표준 목록에는 없습니다. 표준 코드의 목록은 IANA의 등록부에서 관리합니다.
더 깊이: 최종 응답보다 먼저 오는 1xx 응답
1xx는 최종 응답이 아니라 그 전에 오는 임시 응답이어서, 요청 하나에 1xx가 몇 번 온 뒤 최종 응답이 하나 옵니다. RFC 9110은 클라이언트가 기대하지 않았더라도 최종 응답 앞의 1xx를 읽어 낼 수 있어야 한다고 정하고, HTTP/1.0에는 1xx가 없었으므로 HTTP/1.0 클라이언트에는 보내지 말라고 합니다. 1xx 응답은 헤더 구역이 끝나면 끝나며 본문을 담지 않습니다.
100 Continue는 큰 본문을 보내기 전에 서버의 뜻을 묻는 데 씁니다. 클라이언트가 헤더에 Expect: 100-continue를 넣어 보내면, 서버는 헤더만 보고 결론을 낼 수 있는 요청이라면 401이나 405 같은 최종 응답을 바로 돌려주고, 그렇지 않으면 100을 보내 본문을 보내라고 알립니다. 그래서 인증 실패로 끝날 거대한 업로드라면 본문을 보내기 전에 알 수 있습니다. 다만 중간에 HTTP/1.0 프록시가 있으면 100이 오지 않을 수 있으므로, 클라이언트는 한없이 기다리지 말고 얼마 뒤에는 본문을 보내기 시작해도 됩니다.
103 Early Hints(RFC 8297)는 서버가 최종 응답을 만드는 동안 그 응답에 들어갈 Link 헤더 같은 힌트를 먼저 보내, 브라우저가 CSS나 글꼴을 미리 받기 시작하게 합니다. 힌트일 뿐이어서 최종 응답의 헤더를 대신하지는 않습니다. 이 RFC는 실험(Experimental) 문서로 나왔지만, 2025년 2월 IESG가 승인해 제안 표준(Proposed Standard)으로 바뀌었습니다. RFC는 1xx를 잘못 처리하는 HTTP/1.1 클라이언트가 같은 연결의 다음 응답까지 엉뚱하게 해석할 수 있다고 보고, 클라이언트가 이를 제대로 다룬다는 것을 모르면 HTTP/1.1로는 103을 보내지 않을 수도 있다고 적습니다.
자주 보는 상태 코드
| 코드 | 이름 | 쓰는 때 |
|---|---|---|
| 200 | OK | 요청이 성공했고 결과를 본문에 담았다 |
| 201 | Created | 새 자원을 만들었다. Location으로 위치를 알린다 |
| 202 | Accepted | 요청을 접수했지만 처리는 아직 끝나지 않았다 |
| 204 | No Content | 성공했고 돌려줄 본문이 없다 |
| 301, 308 | Moved Permanently, Permanent Redirect | 자원이 영구히 다른 URL로 옮겨 갔다 |
| 302, 303, 307 | Found, See Other, Temporary Redirect | 지금은 다른 URL에서 찾아야 한다 |
| 304 | Not Modified | 조건부 요청에서 바뀐 것이 없으니 가진 사본을 쓰라 |
| 400 | Bad Request | 요청의 형식이 잘못되었다 |
| 401 | Unauthorized | 인증이 필요하거나 인증 정보가 올바르지 않다 |
| 403 | Forbidden | 요청을 이해했지만 허락하지 않는다 |
| 404 | Not Found | 자원이 없다, 또는 있다는 사실을 알리고 싶지 않다 |
| 405 | Method Not Allowed | 이 자원은 그 메서드를 지원하지 않는다 |
| 409 | Conflict | 자원의 현재 상태와 충돌한다 |
| 412 | Precondition Failed | 요청에 건 조건이 맞지 않는다 |
| 422 | Unprocessable Content | 형식은 맞지만 내용을 처리할 수 없다 |
| 429 | Too Many Requests | 정해진 시간 안의 요청 수를 넘었다 |
| 500 | Internal Server Error | 서버 안에서 예상하지 못한 오류가 났다 |
| 502 | Bad Gateway | 게이트웨이나 프록시가 뒤쪽 서버에서 올바르지 않은 응답을 받았다 |
| 503 | Service Unavailable | 과부하나 점검으로 지금은 처리할 수 없다 |
| 504 | Gateway Timeout | 게이트웨이나 프록시가 뒤쪽 서버의 응답을 제때 받지 못했다 |
401과 403은 자주 헷갈립니다. 401은 "누구인지 모르겠으니 인증하라"는 뜻이고, 403은 "요청을 이해했지만 들어줄 수 없다"는 뜻입니다. 403이 인증에 성공했다는 뜻은 아니어서, 인증 없이도 허락하지 않는 자원이라면 403을 쓸 수 있습니다. 502와 504는 응답한 쪽이 뒤쪽 서버와 통신하는 프록시라는 점이 같고, 뒤쪽 서버가 이상한 응답을 했는지(502) 아예 늦었는지(504)가 다릅니다. 이 두 코드를 받으면 애플리케이션 서버보다 앞단의 로드 밸런서나 프록시의 기록부터 봅니다.
다음 행동이 다른 성공 응답
2xx는 모두 성공이지만, 클라이언트가 그다음에 해야 할 일은 코드마다 다릅니다.
HTTP · 응답 다음 행동
202 접수와 201 생성 완료는 다음 행동이 다르다
성공 계열의 첫 자리만으로 업무 처리 완료를 판단하지 않습니다. 코드의 의미와 응답 헤더·API 계약을 함께 읽고, 본문이 없는 두 응답 204와 304도 구분합니다.
201 · 생성 완료
요청으로 새 리소스가 생성됐습니다.
다음 확인: Location으로 새 리소스 URI를 알릴 수 있습니다.
202 · 처리 접수
접수했지만 처리는 아직 끝나지 않았습니다.
다음 확인: API 계약이 제공하는 상태 확인 방법으로 완료 여부를 확인합니다.
204 · 성공, 응답 본문 없음
요청 처리에 성공했고 응답 본문을 보내지 않습니다.
다음 확인: 응답 본문을 파싱하려 하지 않고 필요한 응답 헤더를 읽습니다.
304 · 조건부 GET에서 변경 없음
응답 본문을 다시 보내는 대신 저장된 표현을 재사용합니다.
다음 확인: 응답 메타데이터를 반영하고 보유한 캐시 본문을 사용합니다.
네 경우의 의미 비교이며 연속 상태 전이도는 아닙니다. 202가 후속 성공을 보장하거나 상태 조회 URI가 항상 특정 헤더에 온다는 뜻은 아닙니다.
201은 자원이 이미 만들어졌다는 뜻이므로, Location에 적힌 주소로 새 자원을 가리킬 수 있습니다. 202는 접수만 했다는 뜻이어서, 보고서 생성처럼 오래 걸리는 작업이라면 API가 알려 주는 작업 상태 주소를 나중에 조회해 끝났는지 확인해야 합니다. 202를 받은 순간 "완료"라고 표시하면 안 되고, 202가 나중의 성공을 보장하지도 않습니다. 204는 성공했지만 본문이 없다는 뜻이므로, 클라이언트가 빈 본문을 JSON으로 읽으려 하면 오류가 납니다. 이때의 오류는 클라이언트의 해석 문제이지 서버가 실패한 것이 아닙니다. 304는 성공 범주는 아니지만 본문이 없는 또 하나의 응답으로, 9장 4편에서 보는 캐시의 재검증에 씁니다.
메서드를 지키는 리다이렉트
리다이렉트 응답은 Location에 새 주소를 담아 보내고, 클라이언트는 그 주소로 다시 요청합니다. 이때 메서드가 바뀔 수 있다는 점을 알아야 합니다. 오래전 브라우저들이 301과 302를 받으면 POST를 GET으로 바꿔 다시 요청했고, 이 동작이 굳어져 지금도 허용됩니다. 그래서 메서드를 지켜야 하는 경우를 위해 307과 308이 따로 생겼습니다.
| 코드 | 영구 여부 | 다시 요청할 때의 메서드 | 쓰는 곳 |
|---|---|---|---|
| 301 | 영구 | POST가 GET으로 바뀔 수 있다 | 웹 페이지의 주소 변경 |
| 308 | 영구 | 그대로 지킨다 | API의 주소 변경 |
| 302 | 임시 | POST가 GET으로 바뀔 수 있다 | 오래된 관례의 임시 이동 |
| 303 | 임시 | GET으로 바꾼다(HEAD는 그대로 둔다) | 양식을 제출한 뒤 결과 페이지로 보내기 |
| 307 | 임시 | 그대로 지킨다 | 메서드와 본문을 유지해야 하는 임시 이동 |
303은 메서드를 바꾸는 것이 목적인 코드입니다. 주문 양식을 POST로 제출한 뒤 303으로 결과 페이지를 GET하게 하면, 사용자가 새로 고침을 눌러도 주문이 다시 제출되지 않습니다. 이 방식을 PRG(Post/Redirect/Get) 패턴이라고 합니다. 301과 308은 브라우저와 검색 엔진이 오래 기억하므로, 잘못 설정하면 되돌리기 어렵다는 점도 주의합니다.
오류 응답에 함께 보내는 것
어떤 상태 코드는 특정 헤더와 짝을 이룹니다. 401에는 어떤 방식으로 인증해야 하는지 알려 주는 WWW-Authenticate를, 405에는 지원하는 메서드의 목록인 Allow를 반드시 함께 보내야 합니다. 429와 503에는 언제 다시 시도하면 되는지 알려 주는 Retry-After를 보낼 수 있습니다. 다만 Retry-After의 시간이 지나면 성공한다는 보장은 없으므로, 클라이언트는 다시 시도할 때마다 간격을 늘리고 횟수에 한도를 둡니다.
오류의 자세한 내용은 본문에 담습니다. API마다 모양이 제각각이면 클라이언트가 다루기 어려우므로, RFC 9457은 application/problem+json이라는 표준 형식을 정했습니다.
{
"type": "https://api.example.com/problems/insufficient-stock",
"title": "재고가 부족합니다",
"status": 409,
"detail": "상품 1042의 재고는 2개인데 5개를 주문했습니다.",
"instance": "/orders/8812"
}type은 오류의 종류를 가리키는 URI로 클라이언트가 분기할 때 쓰고, title은 사람이 읽을 요약, detail은 이번 요청에 대한 설명, instance는 이번에 일어난 오류 하나를 가리키는 URI입니다. 클라이언트는 title의 문장이 아니라 type으로 오류를 구별해야 문구를 바꿔도 코드가 깨지지 않습니다.
헤더는 메시지의 설명서
헤더는 메시지에 대한 정보를 담아 그 메시지를 어떻게 다룰지 알려 줍니다. 쓰임에 따라 묶으면 다음과 같습니다.
| 묶음 | 헤더 | 하는 일 |
|---|---|---|
| 본문의 형식 | Content-Type, Content-Length, Content-Encoding | 본문의 미디어 타입과 길이, gzip 같은 압축 방식 |
| 콘텐츠 협상 | Accept, Accept-Language, Accept-Encoding | 클라이언트가 받을 수 있는 형식과 언어, 압축 |
| 인증 | Authorization, WWW-Authenticate | 인증 정보를 보내고, 필요한 인증 방식을 알린다 |
| 캐시 | Cache-Control, ETag, Last-Modified | 응답을 저장하고 다시 쓰는 규칙(9장 4편) |
| 상태 유지 | Cookie, Set-Cookie | 쿠키를 주고받는다(9장 3편) |
| 보안 정책 | Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options | HTTPS 강제, 실행할 수 있는 스크립트의 출처 제한, 형식 추측 금지 |
콘텐츠 협상(content negotiation)은 같은 URL에 여러 표현이 있을 때 클라이언트가 원하는 것을 고르는 방식입니다. 클라이언트는 원하는 정도를 0부터 1까지의 q 값으로 적어 보내고, 서버는 가진 것 가운데 가장 높은 것을 고릅니다. 다음 코드는 이를 단순하게 흉내 냅니다.
def parse_accept(header):
choices = []
for part in header.split(","):
value, _, params = part.strip().partition(";")
q = 1.0 # q를 적지 않으면 1
if params.strip().startswith("q="):
q = float(params.strip()[2:])
choices.append((q, value.strip()))
return sorted(choices, key=lambda c: -c[0]) # q가 큰 순서, 같으면 적힌 순서
def negotiate(header, available):
for q, lang in parse_accept(header):
if q > 0 and lang in available: # q=0은 "받지 않겠다"는 뜻
return lang
return None
header = "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7"
print(parse_accept(header))
print(negotiate(header, {"en", "ja"}))
print(negotiate(header, {"ko", "en"}))[(1.0, 'ko-KR'), (0.9, 'ko'), (0.8, 'en-US'), (0.7, 'en')]
en
ko서버에 영어와 일본어만 있으면 목록에서 처음 맞는 en을, 한국어가 있으면 ko를 고릅니다. 실제 언어 협상은 ko-KR을 원하는 사용자에게 ko를 주는 식으로 앞부분이 맞는 언어도 고려합니다. 같은 URL이 요청 헤더에 따라 다른 응답을 준다면 서버는 Vary: Accept-Language를 보내 캐시가 언어마다 따로 저장하게 해야 합니다. 그러지 않으면 한국어 사용자의 응답이 캐시되어 영어 사용자에게 가는 사고가 납니다.
User-Agent처럼 클라이언트가 마음대로 적을 수 있는 헤더는 통계에는 쓸 수 있어도 보안 판단의 기준으로 삼으면 안 됩니다. 헤더를 직접 보려면 curl -i로 응답 헤더를 본문과 함께, curl -I로 헤더만, curl -v로 보낸 요청 헤더(>)와 받은 응답 헤더(<)를 모두 볼 수 있습니다.
정리
- 상태 코드의 첫 자리는 정보, 성공, 리다이렉션, 클라이언트 오류, 서버 오류의 범주를 뜻하고, 모르는 코드는 같은 범주의 x00처럼 다룹니다.
- 201은 생성 완료, 202는 접수만, 204는 본문 없는 성공이어서 클라이언트가 다음에 할 일이 다릅니다.
- 401은 인증이 필요하다는 뜻이고 403은 허락하지 않는다는 뜻이며, 502와 504는 프록시 뒤쪽 서버의 문제를 알립니다.
- 301과 302는 POST가 GET으로 바뀔 수 있으므로 메서드를 지키려면 308과 307을 쓰고, 양식 제출 뒤에는 303으로 결과 페이지를 보여 줍니다.
- 401에는
WWW-Authenticate, 405에는Allow를 함께 보내고, 오류 본문은application/problem+json형식으로 통일할 수 있습니다. - 콘텐츠 협상으로 표현을 고르는 서버는
Vary로 캐시에 그 사실을 알려야 합니다.
연습 문제
1. 204와 파싱 오류, 403과 인증
한 클라이언트가 삭제 요청에 204와 빈 본문을 받고 JSON 파싱 예외를 냈습니다. 서버의 삭제가 실패했다고 말할 수 있나요? 다른 요청이 403을 받았다면, 로그인은 성공했는데 권한만 부족한 것이라고 확정할 수 있나요?
해설 보기
삭제는 성공했습니다. 204는 서버가 요청을 처리했고 돌려줄 본문이 없다는 뜻이므로, 파싱 예외는 없는 본문을 읽으려 한 클라이언트의 문제입니다. 상태 코드를 먼저 보고 204라면 본문을 읽지 않도록 고쳐야 합니다. 403은 요청을 허락하지 않는다는 뜻일 뿐, 인증에 성공했다는 뜻을 담고 있지 않습니다. 인증 없이 접근할 수 없는 자원에 서버가 401 대신 403을 줄 수도 있으므로, 서버의 정책과 오류 본문의 설명을 함께 확인해야 합니다.
2. 알맞은 리다이렉트 고르기
다음 두 경우에 어떤 리다이렉트 코드를 쓰면 좋을까요? (가) 회원 가입 양식을 POST로 제출받아 처리한 뒤 "가입 완료" 페이지를 보여 주려 한다. (나) API의 POST /v1/orders를 POST /v2/orders로 영구히 옮기면서, 예전 주소로 오는 요청도 본문과 함께 새 주소로 보내고 싶다.
해설 보기
(가)는 303이 맞습니다. 브라우저는 결과 페이지를 GET으로 요청하므로, 사용자가 새로 고침을 해도 가입 양식이 다시 제출되지 않습니다. (나)는 308이 맞습니다. 영구 이동이면서 메서드와 본문을 지키므로, 클라이언트는 새 주소에도 POST로 같은 본문을 보냅니다. 301을 쓰면 일부 클라이언트가 GET으로 바꿔 보내 본문이 사라질 수 있습니다. 다만 영구 리다이렉트는 클라이언트가 오래 기억하므로, 되돌릴 가능성이 있다면 307로 먼저 운영해 보는 것도 방법입니다.
3. q 값을 읽기
본문의 negotiate에 "en;q=0.5, ko"라는 헤더와 {"en", "ko"}를 넣으면 무엇을 고르나요? 헤더가 "ko;q=0"이고 서버에 {"ko"}만 있다면 어떻게 되며, 서버는 어떻게 응답하면 좋을까요?
해설 보기
ko에는 q가 없으므로 1이고, en은 0.5이므로 정렬하면 ko가 앞에 와서 ko를 고릅니다. 적힌 순서가 아니라 q 값이 우선입니다. "ko;q=0"은 한국어를 받지 않겠다는 뜻이므로 q > 0 조건에 걸려 아무것도 고르지 못하고 None이 됩니다. 이때 서버는 받을 수 있는 표현이 없다는 406 Not Acceptable로 답하거나, 많은 서버가 그러듯 기본 언어의 응답을 그대로 보낼 수 있습니다. 어느 쪽을 택할지는 API가 정해 문서에 밝혀 둡니다.
다음 글 쿠키와 세션에서는 상태를 기억하지 않는 HTTP 위에서 로그인 같은 상태를 이어 가는 쿠키와 세션, 토큰의 동작과 보안 속성을 살펴봅니다.