HTTP 상태 코드와 리다이렉트, curl 로 직접 확인하기
HTTP 강좌 2. php -S 라우터로 2xx~5xx 를 돌려주고, 301/302/303 과 307/308 에서 POST 가 어떻게 되는지 curl -L 로 확인. 404 와 410, 200 으로 나가는 치명적 오류, 루프 실측.
시리즈: HTTP 강좌 2/6 (초급). 1강을 마치고 이제 API 가 무엇을 돌려줘야 하는지 정해야 하는 분, 또는 "리다이렉트했더니 POST 데이터가 사라졌다"는 문의를 받은 분을 위한 글입니다. 다 읽고 나면 자주 쓰는 상태 코드를 모두 돌려주는 작은 라우터를 직접 만들고, curl 이 301, 302, 303, 307, 308 을 POST 로 따라가는 모습을 눈으로 확인하게 됩니다.
상태 코드는 외워 둘 잡지식처럼 보이지만, 하나만 잘못 골라도 하루가 사라집니다. 결제 폼이 어느새 GET 으로 바뀌어 주문 데이터가 비어 있거나, 옛 URL 리다이렉트가 빙글빙글 돌거나, PHP 가 죽었는데 200 을 돌려줘서 모니터링이 "정상"이라고 보고하는 식입니다. 이 강좌에서는 각각을 내 컴퓨터에서 재현해서, 나중에 로그에서 바로 알아볼 수 있게 합니다. 모두 Apple Silicon Mac(M1 Pro)의 PHP 8.5.6(php -S)과 curl 8.7.1 로 실행했고, 경로는 ~/demo/ 로 바꿨습니다.
테스트용 라우터 만들기
php -S 는 PHP 의 개발용 서버입니다. 라우터 스크립트를 넘기면 모든 요청이 그 스크립트로 들어오므로 원하는 상태 코드를 마음대로 돌려줄 수 있습니다. ~/demo/router.php 로 저장합니다.
<?php
// php -S 127.0.0.1:18102 router.php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];
function redirect(int $code, string $to): void {
http_response_code($code);
header("Location: $to");
}
if (preg_match('#^/hop/(\d+)$#', $path, $m)) { // /hop/3 -> /hop/2 -> ... -> /hop/0
$n = (int)$m[1];
if ($n > 0) { redirect(301, '/hop/' . ($n - 1)); } else { echo "arrived\n"; }
exit;
}
switch ($path) {
case '/': echo "home\n"; break;
case '/old': redirect(301, '/new'); break; // moved for good
case '/temp': redirect(302, '/echo'); break; // temporary
case '/see': redirect(303, '/echo'); break; // "go GET this"
case '/t307': redirect(307, '/echo'); break; // temporary, keep method
case '/p308': redirect(308, '/echo'); break; // permanent, keep method
case '/new': echo "new page\n"; break;
case '/echo': echo "$method body=[" . file_get_contents('php://input') . "]\n"; break;
case '/loop-a': redirect(302, '/loop-b'); break;
case '/loop-b': redirect(302, '/loop-a'); break;
case '/gone': http_response_code(410); echo "deleted on purpose\n"; break;
case '/items':
if ($method !== 'POST') { http_response_code(405); header('Allow: POST'); break; }
http_response_code(201); header('Location: /items/42'); echo "{\"id\":42}\n"; break;
case '/items/42':
if ($method === 'DELETE') { http_response_code(204); break; }
echo "{\"id\":42}\n"; break;
case '/private': http_response_code(401); header('WWW-Authenticate: Bearer'); break;
case '/admin': http_response_code(403); echo "forbidden\n"; break;
case '/busy': http_response_code(503); header('Retry-After: 30'); echo "maintenance\n"; break;
case '/boom': throw new RuntimeException('db down');
case '/slow': sleep(3); echo "finally\n"; break;
default: http_response_code(404); echo "not found: $path\n";
}
cd ~/demo && php -S 127.0.0.1:18102 router.php
http_response_code() 는 상태를, header() 는 헤더를 정합니다. 둘 다 출력보다 먼저 호출해야 합니다(어기면 어떻게 되는지는 3강). 아래 출력은 짧게 보이려고, 의미가 있는 경우를 빼고 Date, Host, Connection, X-Powered-By 줄을 지웠습니다.
2xx: 성공에도 종류가 있다
성공이 200 하나만 있는 건 아닙니다. REST 스타일 리소스에서는 세 가지를 자주 씁니다.
$ curl -si -X POST -d '{"name":"a"}' -H 'Content-Type: application/json' http://127.0.0.1:18102/items
HTTP/1.1 201 Created
Host: 127.0.0.1:18102
Date: Thu, 12 Nov 2026 14:29:26 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Location: /items/42
Content-type: text/html; charset=UTF-8
{"id":42}
$ curl -si -X DELETE http://127.0.0.1:18102/items/42 | head -1
HTTP/1.1 204 No Content
- 201 Created 는 새 리소스가 생겼다는 뜻이고,
Location이 그 위치를 알려 줍니다. 클라이언트가 새 ID 를 추측하지 않아도 됩니다. - 204 No Content 는 "성공, 본문은 일부러 비움"입니다. 본문을 무조건
JSON.parse하는 클라이언트는 여기서 죽으니, 코드를 먼저 봐야 합니다.
전체 출력에는 이상한 점이 두 개 보입니다. php -S 는 응답에 Host 헤더를 붙입니다(운영 서버는 붙이지 않습니다). 그리고 JSON 을 보냈는데도 Content-type 이 기본값 text/html 그대로입니다. 실제 API 는 Content-Type: application/json 을 직접 지정해야 합니다(3강).
4xx: 클라이언트가 잘못 요청했다
$ curl -si http://127.0.0.1:18102/nope
HTTP/1.1 404 Not Found
Content-type: text/html; charset=UTF-8
not found: /nope
$ curl -si http://127.0.0.1:18102/gone
HTTP/1.1 410 Gone
Content-type: text/html; charset=UTF-8
deleted on purpose
$ curl -si http://127.0.0.1:18102/private
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-type: text/html; charset=UTF-8
$ curl -si http://127.0.0.1:18102/admin
HTTP/1.1 403 Forbidden
Content-type: text/html; charset=UTF-8
forbidden
$ curl -si http://127.0.0.1:18102/items | grep -E 'HTTP|Allow'
HTTP/1.1 405 Method Not Allowed
Allow: POST
신입이 자주 헷갈리는 짝입니다.
- 401 대 403. 401 은 "누군지 모르겠다", 즉 로그인하거나 토큰을 보내라는 뜻이고
WWW-Authenticate와 함께 보내는 것이 규칙입니다. 403 은 "누군지는 알지만 안 된다". 같은 토큰으로 재시도해도 소용없습니다. 앱에서 401 이면 토큰 갱신 후 재시도, 403 이면 권한 안내 화면으로 처리를 나누는 것이 보통입니다. - 404 대 410. 둘 다 "없다". 410 에는 "일부러 지웠고 다시 안 생긴다"가 더해집니다.
- 405 는 URL 은 있지만 그 메서드로는 안 된다는 뜻이고,
Allow에 쓸 수 있는 메서드가 나옵니다. 여기서 404 를 돌려주면 상대는 URL 오타를 찾느라 시간을 버립니다.
그 밖에 자주 보는 4xx: 400(깨진 JSON 같은 잘못된 요청), 409(아이디 중복 같은 충돌), 422(형식은 맞지만 내용이 잘못됨), 429(요청이 너무 많음, 보통 Retry-After 포함).
5xx, 그리고 200 으로 나가는 치명적 오류
/boom 은 아무도 잡지 않는 예외를 던집니다. Homebrew 기본 설정(display_errors 켜짐)에서의 실제 응답입니다.
$ curl -si http://127.0.0.1:18102/boom
HTTP/1.1 200 OK
Host: 127.0.0.1:18102
Date: Thu, 12 Nov 2026 14:32:09 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Content-type: text/html; charset=UTF-8
<br />
<b>Fatal error</b>: Uncaught RuntimeException: db down in ~/demo/router.php:38
Stack trace:
#0 {main}
thrown in <b>~/demo/router.php</b> on line <b>38</b><br />
흔한 실수: "상태 코드만 보면 페이지가 동작했는지 안다". 실제로는 치명적 오류가 200 OK 본문에 찍히고, 서버 경로까지 방문자 모두에게 보여 줍니다. curl -f 헬스 체크는 통과하고, 앱은 HTML 을 JSON 으로 파싱하다 실패하고, 검색 엔진이 오류 문구를 색인할 수도 있습니다. 해결: 내 컴퓨터 밖에서는 display_errors 를 끄고 오류는 로그로 보냅니다. php -d display_errors=0 -d log_errors=1 -S 127.0.0.1:18103 router.php 로 띄운 두 번째 서버에 같은 요청을 보내면 이렇게 됩니다.
$ curl -si http://127.0.0.1:18103/boom | head -1
HTTP/1.0 500 Internal Server Error
$ curl -fsS http://127.0.0.1:18103/boom; echo "exit=$?"
curl: (22) The requested URL returned error: 500
exit=22
오류는 원래 있어야 할 서버 로그로 갔습니다.
[Thu Nov 12 14:30:07 2026] PHP Fatal error: Uncaught RuntimeException: db down in ~/demo/router.php:38
(500 이 HTTP/1.0 으로 나간 것도 php -S 의 버릇입니다.) 계획된 점검이라면 503 Service Unavailable 과 Retry-After 로 클라이언트와 크롤러에 "나중에 오라"고 알립니다.
$ curl -si http://127.0.0.1:18102/busy
HTTP/1.1 503 Service Unavailable
Retry-After: 30
Content-type: text/html; charset=UTF-8
maintenance
3xx: 301, 302, 303, 307, 308 에 POST 보내기
리다이렉트는 3xx 상태와 Location 헤더의 조합입니다. curl 은 보여 주기만 하고 -L 이 없으면 따라가지 않습니다.
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:18102/old
301 http://127.0.0.1:18102/new
재미있는 건 POST 를 보낼 때입니다. 같은 폼 본문을 각 리다이렉트로 보내고 /echo 가 받은 것을 출력하게 했습니다.
$ for p in /old /temp /see /t307 /p308; do curl -sL -d 'name=mina' http://127.0.0.1:18102$p -w 'final=%{http_code} redirects=%{num_redirects}\n'; done
new page
final=200 redirects=1
GET body=[]
final=200 redirects=1
GET body=[]
final=200 redirects=1
POST body=[name=mina]
final=200 redirects=1
POST body=[name=mina]
final=200 redirects=1
-v 로 보면 바뀌는 순간이 보입니다.
> POST /temp HTTP/1.1
< HTTP/1.1 302 Found
< Location: /echo
* Issue another request to this URL: 'http://127.0.0.1:18102/echo'
* Switch from POST to GET
> GET /echo HTTP/1.1
301 도 같았습니다(* Switch from POST to GET 다음에 > GET /new).
| 코드 | 뜻 | 리다이렉트 뒤 메서드(curl -L, 브라우저) | 주 용도 |
|---|---|---|---|
| 301 | 영구 이동 | POST 가 GET 이 됨 | 옛 URL → 새 URL, 페이지의 http → https |
| 302 | 임시 이동 | POST 가 GET 이 됨 | 잠깐 우회 |
| 303 | 다른 곳 보기 | 항상 GET | 폼 POST 뒤(PRG 패턴) |
| 307 | 임시, 메서드 유지 | 그대로 | API 엔드포인트 임시 이동 |
| 308 | 영구, 메서드 유지 | 그대로 | API 엔드포인트 영구 이동 |
역사적인 이유가 있습니다. HTTP/1.0 은 301/302 에서 메서드를 바꾸는 걸 허용하지 않았지만 브라우저들이 POST 를 GET 으로 바꿔 버렸고, 이후 명세가 이를 인정했습니다. HTTP/1.1 에서 "일부러 GET 하라"는 303 과 "메서드를 지켜라"는 307 이 생겼고, 308(RFC 7538, 2015년)은 307 의 영구 버전입니다. 옛 API 경로로 POST 하는 모바일 앱에게는 동작하느냐, 본문이 조용히 사라지느냐의 차이입니다. Dart 클라이언트 사례는 Dart http 에서 POST 리다이렉트를 따라가지 않는 문제에 정리했습니다.
쇼핑몰 주문 완료 페이지에서 새로고침하면 "양식을 다시 제출하시겠습니까?"가 뜨고 주문이 두 번 들어가는 문제도 같은 뿌리입니다. POST 뒤에 303 으로 완료 페이지로 보내면, 새로고침해도 GET 만 반복됩니다. 이것이 PRG(Post/Redirect/Get) 패턴입니다.
흔한 실수: -X POST 로 메서드를 강제하기. 실제로는 curl 이 POST 라는 단어만 남기고 본문은 버립니다.
$ curl -sL -X POST -d 'name=mina' http://127.0.0.1:18102/temp
POST body=[]
-v 를 보면 * Switch from POST to GET 바로 뒤에 Content-Length 도 본문도 없는 > POST /echo HTTP/1.1 이 나갔습니다. 정상 클라이언트라면 절대 보내지 않을 요청입니다. 해결: -d 와 -X 를 같이 쓰지 마세요. 302 뒤에도 정말 다시 POST 하고 싶다면 --post302(또는 --post301, --post303)를 씁니다. 이쪽은 본문까지 유지되어 POST body=[name=mina] 였습니다.
페이지를 지울 때: 301, 404, 410 판단표
| 상황 | 돌려줄 것 | 이유 |
|---|---|---|
| 내용이 새 URL 로 옮겨짐 | 301(페이지), 308(POST 받는 API) | 클라이언트와 검색 엔진이 새 URL 로 옮겨 감 |
| 잠시 다른 곳에 있음 | 302, 307 | 옛 URL 이 계속 정식 주소 |
| 일부러 삭제, 대체 없음 | 410 | "다시 오지 마"를 명시 |
| 모르는 URL, 오타, 원래 없던 주소 | 404 | 정직한 기본값 |
| 삭제했으니 메인 페이지로 보냄 | 피하기 | 검색 엔진이 소프트 404 로 보기 쉽고 사용자도 헷갈림 |
| 점검 중 | 503 + Retry-After |
페이지 자체의 오류가 아님 |
검색 엔진 쪽은, Google 문서가 429 를 뺀 4xx 를 색인에서는 똑같이 취급한다고(URL 이 색인에서 빠짐) 설명합니다. Google 담당자가 410 이 조금 더 빨리 처리될 수 있다고 말한 적은 있지만, 우리가 잰 것은 아닙니다.
리다이렉트 루프와 타임아웃
서로를 가리키는 두 경로입니다.
$ curl -sSL --max-redirs 5 http://127.0.0.1:18102/loop-a -o /dev/null; echo "exit=$?"
curl: (47) Maximum (5) redirects followed
exit=47
$ curl -sSL http://127.0.0.1:18102/loop-a -o /dev/null -w 'redirects=%{num_redirects}\n'
curl: (47) Maximum (50) redirects followed
redirects=50
curl 의 기본 한도는 50번이고, 브라우저는 훨씬 일찍 포기하고 "리디렉션한 횟수가 너무 많습니다"를 띄웁니다(이번엔 실행 안 함). 실제 루프는 거의 두 규칙이 서로 어긋나서 생깁니다. 예를 들어 앱은 https 를 강제하는데 앞단 프록시가 앱에 http 로 말을 거는 경우, www 있는/없는 규칙이 서로를 가리키는 경우입니다. curl -sIL URL | grep -i -E '^(HTTP|location)' 로 홉을 하나씩 볼 수 있습니다.
다른 실패는 끝내 답하지 않는 서버입니다. /slow 는 3초 잡니다.
$ curl -sS --max-time 1 http://127.0.0.1:18102/slow
curl: (28) Operation timed out after 1001 milliseconds with 0 bytes received
--max-time 이 없으면 서버가 연결을 열어 두는 한 curl 은 계속 기다립니다. 스크립트에는 꼭 넣으세요.
실측: 리다이렉트 한 번의 비용
/hop/N 은 N 번 리다이렉트한 뒤 답합니다. 체인 길이마다 워밍업 1회 뒤 30번, curl -sL -w '%{time_total}' 의 중앙값입니다(127.0.0.1).
로컬에서도 홉 하나에 약 0.23 ms 가 늘었습니다. 홉마다 완전히 새 요청이 되고, php -S 는 매번 연결을 닫으니 TCP 연결도 새로 맺기 때문입니다. 인터넷에서는 홉마다 최소 한 번의 왕복이 더 듭니다. 우리 사이트에 다섯 번씩 요청한 결과입니다.
| 요청 | 5회 중앙값 | 범위 |
|---|---|---|
https://tlaboratory.net/ 직접 |
62.1 ms | 59.4~183.1 ms |
http://tlaboratory.net/ 를 -L 로(https 로 1번 리다이렉트) |
124.6 ms | 99.3~174.5 ms |
HTTP→HTTPS 리다이렉트 한 번으로 첫 요청 시간이 약 2배가 됐습니다. 링크는 최종 URL 로 바로 걸고, http:// → https:// → https://www. → /new-path/ 같은 연쇄는 피합니다.
솔직한 한계와 버전 메모
- 상태 코드와 리다이렉트의 의미는 지금 RFC 9110(2022년 6월)에 있습니다. 303 과 307 은 HTTP/1.1(1997년 RFC 2068, 1999년 RFC 2616)에서 들어왔고, 308 은 RFC 7538(2015년)이었다가 지금은 RFC 9110 에 포함됐습니다.
- POST→GET 전환은 curl 8.7.1 에서 확인했습니다. 브라우저도 301/302/303 에서는 GET 으로 바꾸고 307/308 에서는 메서드를 유지하지만, 브라우저는 실행하지 않았습니다. 브라우저는 301 과 308 을 오래 캐시하기도 해서, curl(아무것도 캐시하지 않음)로는 고쳐진 리다이렉트가 브라우저에서는 남아 보일 수 있습니다.
php -S(8.5.6)는 단일 프로세스이고 응답마다 연결을 닫습니다. 응답에Host헤더를 붙이고 500 을HTTP/1.0으로 보냈습니다. Apache, nginx, LiteSpeed 는 이런 세부가 다릅니다.display_errors가 켜져 있던 건 Homebrew 의 개발용php.ini때문입니다. 서버 기본값은 환경마다 다르니php -i | grep display_errors로 확인하세요.- tlaboratory.net 시간은 한 회선에서 다섯 번 잰 값입니다.
연습 문제
1. 모바일 앱이 /api/v1/save 로 JSON 을 POST 합니다. 엔드포인트를 /api/v2/save 로 옮길 때 옛 경로에서 무엇을 돌려줘야 하나요? 301 이 안 되는 이유도 쓰세요.
2. 모니터링이 1분마다 curl -s https://example.test/health 를 실행하고 종료 코드가 0 이 아니면 알림을 보냅니다. 한 시간 동안 PHP 치명적 오류가 떠 있었는데 알림이 한 번도 오지 않았습니다. 이유 두 가지와 고친 명령을 쓰세요.
3. 실행하지 말고 답하세요. curl -s -o /dev/null -w '%{http_code} %{num_redirects}\n' http://127.0.0.1:18102/hop/3 은 무엇을 출력하고, -L 을 붙이면 무엇이 달라지나요?
정답
1. 308 Permanent Redirect(임시라면 307). 301 이면 리다이렉트를 따라가는 클라이언트가 위 /old 처럼 POST 를 GET 으로 바꾸고 본문을 버립니다. 308 은 메서드와 본문이 살아남았습니다(POST body=[name=mina]). 또 옛 버전 앱은 POST 리다이렉트를 아예 따라가지 않을 수도 있으니, 서버 내부 리라이트가 어떤 리다이렉트보다 안전한 경우가 많습니다.
2. (a) -f 가 없으면 curl 은 어떤 HTTP 상태든 종료 코드 0. (b) display_errors 가 켜져 있어 치명적 오류가 상태 200 으로 나갔다. 둘 다 고칩니다. 서버에서 display_errors=0(죽으면 500 이 됨), 모니터링은 curl -fsS --max-time 10 https://example.test/health. 4xx/5xx 면 22, 응답이 없으면 28 이 됩니다.
3. 301 0. curl 은 첫 응답에서 멈춥니다. -L 을 붙이면 200 3 이 나오고 본문 arrived 는 /dev/null 로 갑니다.
다음 글
- 이전: HTTP 강좌 1/6: 요청, 응답, curl -v
- 다음: HTTP 강좌 3/6: 헤더, 쿠키, 세션
- 이후: ETag 와 Cache-Control 캐시, HTTPS 와 TLS, keep-alive 와 압축
- CDN 이 리다이렉트를 캐시하는 문제: CDN 이 API 응답과 리다이렉트를 캐시할 때
- 비 ASCII URL 에서 깨지는 리라이트: LiteSpeed .htaccess 리라이트와 비 ASCII URL
- PHP 쪽에서 본
php -S: PHP 함수, 폼, 내장 서버 - 전체 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
리다이렉트 뒤에 POST 가 GET 으로 바뀌는 이유는 뭔가요?
301, 302, 303 에서는 클라이언트가 POST 를 GET 으로 바꾸고 본문을 버리기 때문입니다. curl -L 은 Switch from POST to GET 을 출력했고 도착지에는 GET body=[] 가 왔습니다. 307, 308 에서는 메서드와 본문이 유지되어 POST body=[name=mina] 가 왔습니다. POST 를 살려야 하면 307 이나 308, 폼 제출 뒤 일부러 GET 으로 보내려면 303 을 씁니다.
삭제한 페이지는 404 와 410 중 무엇을 돌려줘야 하나요?
둘 다 '여기 없다'는 뜻입니다. 410 Gone 은 '일부러 지웠고 돌아오지 않는다'를 명시하고, 404 는 '지금 여기 아무것도 없다'일 뿐입니다. 내용이 옮겨졌다면 둘 다 아니고 새 URL 로 301 을 돌려줍니다. 모르는 URL 에 대한 기본 응답은 404 면 됩니다.
PHP 페이지가 죽었는데 curl 에는 200 OK 가 나옵니다. 왜죠?
display_errors 가 켜져 있으면 PHP 는 치명적 오류를 본문에 출력하고 상태는 200 그대로였습니다. 코드만 보는 모니터링은 성공으로 봅니다. display_errors=0 으로 바꾸자 같은 오류에서 500 Internal Server Error 가 나왔고 curl -f 는 종료 코드 22 로 끝났습니다.