PHP / 다른 페이지로 리디렉션(Redirection)하는 방법
PHP에서는 응답 본문을 출력하기 전에 header('Location: 이동주소', true, 상태코드)를 호출하고 exit로 실행을 끝내 리디렉션합니다. 일반 임시 이동은 302, POST 처리 후 결과를 GET으로 조회하게 할 때는 303을 선택할 수 있습니다. 이동 후에도 요청 방식과 본문을 유지해야 한다면 307처럼 목적에 맞는 상태 코드를 사용합니다.
기본 예제: 302로 다른 파일에 이동
PHP 8.x 명령행 환경을 준비하고 새 실습 폴더에 다음 두 파일을 저장합니다. 먼저 move.php의 전체 코드입니다. 경로 앞의 /는 현재 사이트의 최상위 경로를 뜻하므로 이 실습에서는 폴더 자체를 문서 루트로 사용합니다.
<?php
declare(strict_types=1);
header('Location: /destination.php', true, 302);
exit;
이동 결과를 확인할 destination.php의 전체 코드입니다. HTML 대신 text/plain으로 응답하므로 아래에서 받은 실습 값을 그대로 텍스트로 비교할 수 있습니다. 민감한 정보는 입력하지 마세요.
<?php
declare(strict_types=1);
header('Content-Type: text/plain; charset=UTF-8');
header('Cache-Control: no-store');
$note = $_POST['demo_note'] ?? '(없음)';
if (!is_string($note)) {
http_response_code(422);
exit('demo_note는 문자열이어야 합니다.');
}
echo '요청 방식: ', $_SERVER['REQUEST_METHOD'], PHP_EOL;
echo 'POST 값: ', $note, PHP_EOL;
php -S 127.0.0.1:8000
두 파일을 저장한 폴더에서 명령을 실행한 뒤 http://127.0.0.1:8000/move.php를 엽니다. 주소가 /destination.php로 바뀌고 “요청 방식: GET”, “POST 값: (없음)”이 표시됩니다. 포트가 사용 중이면 번호를 바꾸고, 종료할 때는 Ctrl+C를 누릅니다. 하위 폴더에 배치한다면 예제의 모든 /destination.php·/method-demo.php 경로를 실제 배치 경로에 맞춰 수정해야 합니다.
header()는 응답 헤더만 설정하며 PHP 스크립트 실행을 중단하지 않습니다. 이동할 조건을 충족한 뒤에도 아래의 저장·출력 코드가 실행되지 않도록 exit를 함께 사용합니다. Location을 보낼 때의 기본 동작과 출력 전 호출 조건은 header() 공식 문서를 참고하세요. 예제는 의도를 명확하게 하려고 302를 직접 지정했습니다.
302·303·307과 영구 이동 상태 코드 선택
| 상태 코드 | 주요 용도 | 요청 방식 관련 기준 |
|---|---|---|
| 302 Found | 일반적인 임시 이동 | POST를 GET으로 바꾸는 클라이언트 동작이 있으므로 POST 유지 보장에는 사용하지 않습니다. |
| 303 See Other | POST 처리 후 결과 페이지 조회 | POST 뒤 이동한 결과를 GET으로 조회하게 합니다. |
| 307 Temporary Redirect | 요청을 유지하는 임시 이동 | 기존 요청 방식과 본문을 유지합니다. |
| 301 Moved Permanently | 주소가 영구적으로 바뀐 경우 | 영구 이동이며 POST 유지가 필요하면 308을 검토합니다. |
| 308 Permanent Redirect | 요청을 유지하는 영구 이동 | 기존 요청 방식과 본문을 유지합니다. |
POST 뒤 결과 화면으로 이동하는 경우에는 303, 같은 본문을 다른 처리 주소로 다시 보내야 하는 경우에는 307의 의미를 구분하세요. 302의 관행적 동작에 기대어 처리하면 의도한 요청이 달라질 수 있습니다.
301과 308은 단순히 더 빠르게 이동시키는 코드가 아닙니다. 영구 이동은 브라우저·중간 캐시에 남을 수 있으므로 일시적인 로그인 결과나 실습에는 섣불리 적용하지 않습니다. 주소 이전과 요청 방식 유지 조건을 확인한 후 선택하세요.
POST 뒤 303과 307의 차이 직접 확인
같은 실습 폴더에 다음 전체 코드를 method-demo.php로 저장합니다. 앞에서 만든 destination.php를 그대로 사용합니다. 실습 값을 보낸 뒤 요청이 어떻게 바뀌는지만 비교하며 DB 저장·결제·메일 발송 등 실제 상태 변경은 수행하지 않습니다.
<?php
declare(strict_types=1);
header('Cache-Control: no-store');
$method = $_SERVER['REQUEST_METHOD'] ?? '';
if ($method === 'POST') {
$mode = $_POST['mode'] ?? null;
$note = $_POST['demo_note'] ?? null;
$codes = ['see-other' => 303, 'keep-post' => 307];
if (!is_string($mode) || !isset($codes[$mode])
|| !is_string($note) || strlen($note) > 120) {
http_response_code(422);
exit('실습 입력을 확인하세요.');
}
// 전송 방식 비교용이며 실제 데이터 저장 작업은 없습니다.
header('Location: /destination.php', true, $codes[$mode]);
exit;
}
if ($method !== 'GET') {
http_response_code(405);
header('Allow: GET, POST');
exit;
}
header('Content-Type: text/html; charset=UTF-8');
?>
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>303과 307 비교</title></head>
<body>
<form method="post" action="/method-demo.php">
<p><label>실습 값 <input name="demo_note" value="sample"></label></p>
<button name="mode" value="see-other">303으로 이동</button>
<button name="mode" value="keep-post">307로 이동</button>
</form>
</body>
</html>
- http://127.0.0.1:8000/method-demo.php를 열고 실습 값을 sample로 둡니다.
- 303으로 이동을 누르면 destination.php에 “요청 방식: GET”, “POST 값: (없음)”이 표시됩니다.
- method-demo.php를 다시 주소로 열고 같은 sample을 두고 307로 이동을 누릅니다.
- 이번에는 “요청 방식: POST”, “POST 값: sample”이 표시됩니다. 개발자 도구 Network의 Preserve log를 켜면 첫 응답의 303·307과 뒤 요청의 방식·Payload를 나란히 확인할 수 있습니다.
303은 POST-Redirect-GET 흐름에 사용할 수 있어 저장 결과 페이지에서 새로고침할 때 원래 POST를 다시 보내는 상황을 줄입니다. 다만 저장이 두 번 실행되는 모든 원인을 막는 장치는 아닙니다. 실제 저장 기능에는 입력 검증·권한·CSRF 방어와 업무상 중복 처리 방지를 별도로 구현해야 합니다.
307은 요청 본문도 다시 전송하므로 비밀번호나 토큰이 든 요청을 신뢰하지 않는 주소로 넘기면 안 됩니다. 전송 방식을 유지하는 것이 필요한 경우에만 사용하고, 대상이 기존 메서드와 본문을 받아 처리할 수 있는지도 확인하세요.
사용자 입력은 URL 대신 허용된 이동 키로 받기
로그인 후 돌아갈 주소 등을 $_GET['next']에서 받아 그대로 Location에 붙이면 공격자가 외부 사이트로 사용자를 유도하는 오픈 리디렉션 문제가 생길 수 있습니다. URL처럼 보이는지만 검사하거나 현재 Host 값을 무조건 붙이는 것으로 해결되지는 않습니다. OWASP 리디렉션 검증 지침은 서버에서 미리 정한 대상에 매핑하는 방식을 설명합니다.
다음 route.php는 외부 URL을 받지 않고 result·form이라는 키만 허용합니다. 같은 실습 폴더에 저장하고 /route.php?to=form으로 열면 양식으로, /route.php?to=result로 열면 결과 페이지로 이동합니다. to가 없으면 result를 사용하고 알 수 없는 키나 배열 입력은 400으로 거절합니다.
<?php
declare(strict_types=1);
$routes = [
'result' => '/destination.php',
'form' => '/method-demo.php',
];
$key = $_GET['to'] ?? 'result';
if (!is_string($key) || !isset($routes[$key])) {
http_response_code(400);
exit('허용되지 않은 이동 대상입니다.');
}
header('Location: ' . $routes[$key], true, 302);
exit;
이동 대상이 사용자마다 달라야 한다면 URL의 형식 검사에 더해 허용 호스트·경로와 해당 사용자의 접근 권한을 검토해야 합니다. 단순 실습에서는 위의 고정 목록이 구현 의도를 가장 명확하게 드러냅니다. 헤더에 외부 입력을 그대로 넣지 않아 줄바꿈 등을 통한 헤더 변조 경로도 줄일 수 있습니다.
이동하지 않거나 예상과 다른 페이지로 갈 때
- headers already sent가 보이면 최초 출력 파일과 위치를 확인합니다. <?php 앞의 BOM·공백, HTML, echo와 include 파일의 출력도 원인이 됩니다. 원인을 수정하고 헤더를 본문 출력보다 앞으로 옮깁니다.
- Network에서 원래 응답의 상태 코드와 Location을 확인합니다. 이동 후 최종 응답 200만 보면 중간의 리디렉션을 놓칠 수 있습니다.
- 실제 대상 파일이 있는지와 루트 기준 경로·하위 폴더를 확인합니다. 이동 대상이 다시 원래 주소로 보내면 순환 리디렉션이 생깁니다.
- 영구 이동을 시험한 뒤 계속 예전 주소로 간다면 브라우저·프록시 캐시와 서버 규칙을 점검합니다. 임시 코드로 바꾸는 것만으로 이미 저장된 캐시가 즉시 사라지지는 않을 수 있습니다.
sleep(5) 뒤에 header()를 호출하는 방식은 서버의 PHP 처리를 5초 동안 대기시킵니다. 사용자가 안내 페이지를 본 뒤 화면에서 5초가 지나는 카운트다운과는 다르며 요청 자원을 점유합니다. sleep()의 용도를 구분하고 단순 페이지 이동을 위해 불필요하게 서버를 재우지 마세요. 이미 표시된 화면에서 사용자에게 안내한 뒤 이동해야 한다면 클라이언트 쪽 이동과 일반 링크 대안을 검토할 수 있습니다.
응답 후에도 보호해야 할 콘텐츠를 출력하지 않고 실행을 종료하는지 다시 확인하세요. 리디렉션 자체는 접근 권한 검사를 대신하지 않으며, API 클라이언트는 이동을 자동으로 따르지 않을 수도 있으므로 호출 측에서 응답을 어떻게 처리하는지도 확인해야 합니다.




