리디렉션은 단순해 보입니다. 서버가 HTTP 상태 코드와 Location 헤더로 응답하면 클라이언트가 그 주소로 이동합니다. 하지만 브라우저가 리디렉션을 캐시해도 되는지, POST가 POST로 유지되는지, 검색엔진이 어떤 URL을 색인할 가능성이 높은지는 이 세 자리 숫자가 결정합니다.
이 가이드에서는 RFC 9110에 정의된 3xx 계열, 코드끼리 겹치는 부분이 생긴 역사적 배경, 상태 코드 없이 이루어지는 리디렉션, 그리고 주요 서버에서 알맞은 코드를 설정하는 방법을 다룹니다.
3xx 계열의 의미
HTTP 의미론(semantics)을 정의한 현행 명세인 RFC 9110에 따르면, 3xx 상태 코드는 “요청을 이행하려면 사용자 에이전트가 추가 조치를 취해야 함”을 나타냅니다. 15.4절은 이 코드들을 네 가지 리디렉션 유형으로 나눕니다.
- 301, 302, 307, 308: 리소스를
Location에 지정된 다른 URI에서 이용할 수 있습니다. - 300: 클라이언트가 리소스의 여러 표현 중에서 선택할 수 있습니다.
- 303: 다른 리소스가 요청에 대한 간접 응답을 제공합니다.
- 304: 클라이언트가 캐시에 이미 있는 사본을 재사용할 수 있습니다.
실제로 자동 리디렉션으로 동작하는 코드는 301, 302, 303, 307, 308뿐입니다. 브라우저가 구현하는 Fetch Standard도 정확히 이 다섯 코드를 “redirect status(리디렉션 상태)”라고 부릅니다. 300은 보통 일반 페이지처럼 표시되고, 304는 클라이언트에 캐시된 사본을 사용하라고 알립니다.
Location 값은 /new-page나 ../shop/ 같은 상대 참조일 수 있습니다. 클라이언트는 이를 방금 요청한 URL을 기준으로 해석합니다(RFC 9110, 10.2.2절). Location에 프래그먼트가 없으면 원래 프래그먼트가 유지됩니다. 예를 들어 /guide#install 링크가 /docs/guide로 리디렉션되면 최종 주소는 /docs/guide#install이 됩니다.
실제 리디렉션 코드 5가지 중 어느 것이 알맞은지는 두 가지 질문으로 정해집니다.
- 이전이 영구적인가, 임시적인가? 영구 리디렉션은 캐시될 수 있으며, 검색엔진에 기존 URL 대신 새 URL을 색인하라는 신호를 보냅니다.
- 요청 메서드를 유지해야 하는가? 원래 요청이 일반 GET이 아니라 POST, PUT, DELETE일 때 중요합니다.
리디렉션 상태 코드 한눈에 보기
| 코드 | 이름 | 영구 | 메서드 유지 | 기본 캐시 가능 | 주요 용도 | Google 검색 |
|---|---|---|---|---|---|---|
| 300 | Multiple Choices | – | – | 예 | 여러 표현 중 선택 제공(드묾) | – |
| 301 | Moved Permanently | 예 | 아니요: POST가 GET으로 바뀔 수 있음 | 예 | 페이지·사이트 이전, HTTPS 전환, 표준 호스트 통일 | 영구(강한 신호) |
| 302 | Found | 아니요 | 아니요: POST가 GET으로 바뀔 수 있음 | 아니요 | 단기 리디렉션, 로그인 요구 화면, A/B 테스트 | 임시(약한 신호) |
| 303 | See Other | 아니요 | 아니요: GET으로 바뀜 | 아니요 | 폼 제출 후 결과 페이지 | 임시 |
| 304 | Not Modified | 리디렉션 아님 | – | – | 조건부 요청에 대한 응답 | 콘텐츠 변경 없음 |
| 305 | Use Proxy | 사용 중단됨 | – | – | 사용 금지 | – |
| 306 | (Unused) | 예약됨 | – | – | 사용 금지 | – |
| 307 | Temporary Redirect | 아니요 | 예 | 아니요 | 모든 메서드의 임시 리디렉션 | 임시 |
| 308 | Permanent Redirect | 예 | 예 | 예 | API, 폼 등 모든 URL의 영구 이전 | 영구(강한 신호) |
“기본 캐시 가능”은 RFC 9110, 15.1절에 따라 휴리스틱 캐시가 가능한(heuristically cacheable) 코드라는 뜻입니다. 즉 응답에 명시적인 유효 기간(freshness) 정보가 없어도 캐시가 서버에 확인하지 않고 응답을 재사용할 수 있습니다. 물론 어떤 응답이든 Cache-Control로 캐시 가능 여부를 직접 지정할 수 있습니다. Google 검색 열은 리디렉션과 HTTP 상태 코드에 관한 Google 문서 내용을 요약한 것입니다.
코드별 상세 설명
300 Multiple Choices
리소스에 다른 언어나 파일 형식처럼 여러 표현이 있고 클라이언트가 그중 하나를 고를 수 있다는 뜻입니다. 본문에는 보통 선택지 목록이 들어가지만 정해진 형식은 없습니다. Location에 권장하는 선택지를 지정할 수는 있으나, 브라우저가 이를 따라간다고 기대할 수는 없습니다. 실제로 300은 거의 쓰이지 않으며, 서버 측 콘텐츠 협상이나 링크가 담긴 일반 페이지가 더 효과적입니다.
301 Moved Permanently
대상 리소스에 “새로운 영구 URI가 할당되었으며, 이후 이 리소스를 참조할 때는 함께 제공된 URI 중 하나를 사용해야 함”을 나타냅니다. 페이지 이전, 도메인 변경, HTTP에서 HTTPS로의 전환, 표준 호스트 리디렉션에 쓰는 표준 코드입니다.
두 가지를 알아 두어야 합니다. 첫째, 301은 휴리스틱 캐시가 가능하므로 브라우저가 이를 기억하고 더 이상 서버에 묻지 않을 수 있습니다(아래 캐싱 섹션 참고). 둘째, 역사적인 이유로 클라이언트는 301을 따라갈 때 POST를 GET으로 바꿀 수 있으며, 실제로 브라우저는 그렇게 합니다. GET으로 불러오는 페이지라면 상관없지만, 폼 엔드포인트나 API에는 308을 사용하세요.
302 Found
대상 리소스가 “일시적으로 다른 URI에 있음”을 나타내므로, 클라이언트는 이후 요청에도 원래 URL을 계속 사용해야 합니다. 302는 기본적으로 캐시되지 않습니다. 일주일 동안 카테고리 페이지 대신 보여 주는 세일 페이지, 로그인 요구 화면, 지역별 라우팅, A/B 테스트처럼 짧게 유지되는 상황에 사용하세요.
301과 마찬가지로 302도 POST를 GET으로 바꾸는 것을 허용합니다. 많은 프레임워크가 코드를 지정하지 않으면 302를 보내기 때문에, 영구 이전이 실수로 302가 되는 일이 매우 흔합니다.
303 See Other
303은 원래 요청에 대한 간접 응답 역할을 하는 다른 리소스로 클라이언트를 보내며, 클라이언트는 그 리소스를 GET(또는 HEAD)으로 가져옵니다. PRG(Post/Redirect/Get) 패턴을 위해 만들어진 코드입니다. 서버는 폼 제출을 처리한 뒤 확인 페이지나 결과 페이지의 URL과 함께 303으로 응답합니다. 그러면 그 페이지를 새로고침해도 폼이 다시 제출되지 않고 무해한 GET 요청만 반복됩니다.
303은 기본적으로 캐시되지 않으며, 원래 URL이 옮겨졌다는 의미도 담고 있지 않습니다. Google은 303을 302와 같이 취급합니다.
304 Not Modified(리디렉션 아님)
304는 3xx 번호를 달고 있지만 클라이언트를 다른 곳으로 보내지 않습니다. 이 코드는 조건부 GET 또는 HEAD 요청, 즉 ETag 값을 담은 If-None-Match나 날짜를 담은 If-Modified-Since 헤더가 포함된 요청에 대한 응답입니다. 리소스가 바뀌지 않았으면 서버는 본문 없이 304로 응답하고, 클라이언트는 캐시에 있는 사본을 사용합니다.
GET /styles.css HTTP/1.1
Host: example.com
If-None-Match: "5f1c-3a2b"
HTTP/1.1 304 Not Modified
ETag: "5f1c-3a2b"
Cache-Control: max-age=3600리디렉션 검사 도구는 조건 없는 요청을 보내므로 보통 304를 볼 일이 없습니다. TrueURL에서 304를 테스트하려면 일반 검사를 실행한 뒤 응답 헤더에서 ETag 값을 복사하고, 사용자 지정 요청 헤더에서 이 값을 If-None-Match 헤더로 보내세요. Googlebot에게 304는 마지막 크롤링 이후 콘텐츠가 바뀌지 않았다는 뜻입니다.
305 Use Proxy와 306(Unused)
305는 클라이언트에 Location에 지정된 프록시를 거쳐 요청을 다시 보내라고 지시하는 코드였습니다. 응답 하나로 클라이언트의 프록시 설정을 바꿀 수 있게 하는 것은 보안 위험이므로 RFC 7231에서 이 코드를 사용 중단(deprecated)했으며, 현재 브라우저는 이 코드에 반응하지 않습니다.
306은 이전 버전의 명세에 정의되었던 코드로(흔히 Switch Proxy로 표기됩니다), 지금은 예약되어 있습니다. 두 코드 모두 현대 애플리케이션에서 쓸 일이 없으며, 이 코드가 보인다면 무언가 잘못 설정된 것입니다.
307 Temporary Redirect
307은 302와 의미가 같지만 한 가지가 더해집니다. 클라이언트가 요청 메서드를 바꾸면 안 됩니다. POST는 같은 본문과 함께 POST 그대로 새 URL로 다시 전송됩니다. 그래서 API와 폼 엔드포인트의 임시 리디렉션에는 307이 알맞습니다.
Chrome DevTools에서 http://에서 https://로 가는 307 Internal Redirect로 307을 보게 될 수도 있습니다. 이는 서버가 보낸 응답이 아닙니다. 대개 브라우저가 저장해 둔 해당 사이트의 HSTS(Strict-Transport-Security) 정책 때문에 브라우저가 스스로 요청을 업그레이드한 것입니다.
308 Permanent Redirect
308은 301에 대응하는, 메서드를 유지하는 코드입니다. 영구적이고 휴리스틱 캐시가 가능하며, 클라이언트는 메서드를 바꾸면 안 됩니다. 2014년 실험적 문서인 RFC 7238에서 처음 발표되었고, 2015년 RFC 7538로 표준이 되었으며, 현재는 RFC 9110에 포함되어 있습니다.
POST, PUT, DELETE 요청을 받는 엔드포인트를 영구 이전할 때는 308을 사용하세요. 일반 페이지라면 301과 308 중 어느 것을 써도 되며, Google은 둘 다 영구 리디렉션으로 취급합니다.
303, 307, 308이 생긴 이유
301/302와 303/307/308이 서로 겹치는 것은 역사적인 우연의 결과입니다.
HTTP/1.0(RFC 1945, 1996년)은 301 Moved Permanently와 302 Moved Temporarily를 정의했습니다. 사용자 에이전트는 사용자의 확인 없이 POST를 자동으로 리디렉션해서는 안 된다고 규정하면서, 일부 사용자 에이전트가 POST를 GET으로 잘못 바꾼다는 점도 이미 지적했습니다. 그런데도 브라우저는 계속 그렇게 동작했습니다.
HTTP/1.1은 해석의 여지가 없는 코드 두 개를 추가했습니다. RFC 2068(1997년)은 항상 GET으로 바꾸는 303 See Other를 도입했습니다. RFC 2616(1999년)은 항상 메서드를 유지하는 307 Temporary Redirect를 추가하고, 302의 이름을 Found로 바꾸었으며, 대부분의 사용자 에이전트가 302를 303처럼 처리한다는 사실을 명시했습니다.
그래도 메서드를 유지하는 영구 리디렉션은 여전히 없었습니다. 이 빈자리는 15년 뒤 308이 채웠습니다. 그사이 명세도 현실을 받아들여, RFC 7231(2014년)부터는 사용자 에이전트가 301이나 302 이후 POST를 GET으로 바꿔도 되도록 허용했습니다.
현재 브라우저는 Fetch Standard의 다음 규칙을 따릅니다.
| 상태 코드 | 원래 메서드 | 브라우저가 새 URL에 보내는 요청 |
|---|---|---|
| 301, 302 | POST | 본문 없는 GET |
| 301, 302 | 그 밖의 모든 메서드 | 같은 메서드 |
| 303 | GET, HEAD를 제외한 모든 메서드 | 본문 없는 GET |
| 303 | GET 또는 HEAD | 같은 메서드 |
| 307, 308 | 모든 메서드 | 같은 메서드와 본문 |
301이나 302 이후 바뀌는 것은 POST뿐이며, PUT과 DELETE는 메서드가 유지됩니다. 하지만 HTTP 라이브러리마다 동작이 다르므로 이에 의존하면 깨지기 쉽습니다. 메서드가 중요할 때는 항상 307이나 308을 사용하세요.
3xx 상태 코드 없는 리디렉션
meta refresh(메타 새로고침)
<meta http-equiv="refresh" content="0; url=https://example.com/new-page">페이지 자체는 200 OK로 전달되며, 브라우저는 페이지를 불러온 뒤 태그를 따라갑니다. 숫자는 초 단위 지연 시간입니다. Google은 즉시 실행되는 meta refresh는 영구 리디렉션으로, 지연된 meta refresh는 임시 리디렉션으로 해석합니다.
시간 지연이 있는 새로고침은 접근성에도 좋지 않습니다. WCAG는 즉시 실행되는 meta refresh를 클라이언트 측 리디렉션으로 인정하지만(기법 H76), 시간 지연이 있는 경우는 실패 사례(F40)로 봅니다. 사용자가 제어할 수 없는 타이머에 따라 페이지가 바뀌기 때문입니다.
JavaScript 리디렉션
<script>
window.location.replace('https://example.com/new-page');
</script>location.replace()는 리디렉션하는 페이지를 세션 기록에 남기지 않으므로, 뒤로 가기 버튼을 눌러도 다시 리디렉션으로 튕겨 나가지 않습니다. 반면 location.href에 값을 할당하면 기록에 남습니다.
Google은 페이지를 크롤링한 다음 렌더링할 때만 JavaScript를 실행하며, 렌더링은 실패할 수도 있습니다. Google 문서는 서버 측 리디렉션과 meta refresh 리디렉션을 쓸 수 없을 때만 JavaScript 리디렉션을 사용하라고 권장합니다. 다른 봇과 도구 중에는 JavaScript를 아예 실행하지 않는 것이 많습니다.
Refresh 헤더
HTTP/1.1 200 OK
Content-Type: text/html
Refresh: 0; url=https://example.com/new-pageRefresh 헤더는 어떤 HTTP RFC에도 정의되어 있지 않습니다. 브라우저는 수십 년 동안 이 헤더를 지원해 왔고, HTML Standard는 이를 meta refresh 태그와 동등하게 취급합니다. 하지만 curl을 비롯한 많은 HTTP 클라이언트와 도구가 이 헤더를 무시하므로 실제 3xx 리디렉션을 사용하는 것이 좋습니다.
리디렉션을 올바르게 구현하는 방법
nginx
# 두 호스트의 모든 http:// 요청을 한 번의 홉으로 표준 HTTPS URL에 보냄
server {
listen 80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
# https://www.example.com -> https://example.com
server {
listen 443 ssl;
server_name www.example.com;
ssl_certificate /etc/ssl/example.com.crt;
ssl_certificate_key /etc/ssl/example.com.key;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
# ssl_certificate와 ssl_certificate_key는 위와 같음
# 옮긴 페이지 하나
location = /old-page {
return 301 /new-page;
}
# 섹션 전체(나머지 경로 유지)
location /blog/ {
rewrite ^/blog/(.*)$ /articles/$1 permanent;
}
}$request_uri에는 원래 경로와 쿼리 문자열이 들어 있으므로 잃어버리는 정보가 없습니다. rewrite ... permanent는 301을, rewrite ... redirect는 302를 보냅니다. 307이나 308을 보내려면 해당 코드와 함께 return을 사용하세요.
Apache (.htaccess)
# mod_alias: 페이지 하나와 폴더 전체(나머지 경로는 뒤에 붙음)
Redirect 301 /old-page https://example.com/new-page
Redirect 301 /blog/ https://example.com/articles/
# 정규 표현식을 쓰는 mod_alias
RedirectMatch 301 ^/products/([0-9]+)\.html$ https://example.com/p/$1
# mod_rewrite: 모든 http:// 요청과 www 요청을 한 번의 홉으로 https://example.com에 보냄
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]상태 코드 없이 쓴 Redirect는 302를 보내므로 항상 코드를 적으세요. 일반 HTTP로 Apache에 접속하는 로드 밸런서나 CDN 뒤에서는 %{HTTPS}가 항상 off여서 마지막 규칙이 루프를 일으킵니다. 이때는 %{HTTP:X-Forwarded-Proto}를 대신 검사하세요. mod_alias와 mod_rewrite는 서로 독립적으로 동작하며, 파일 안의 줄 순서로 어느 모듈이 먼저 실행될지 정해지지 않습니다. 이 예시의 http://www.example.com/old-page처럼 두 모듈의 규칙에 모두 일치하는 URL은 홉을 두 번 거칠 수 있습니다. 한 번에 리디렉션해야 하는 URL은 관련 규칙을 모두 한 모듈에 두세요.
Cloudflare
Cloudflare는 요청이 서버에 닿기 전에 엣지에서 리디렉션할 수 있습니다.
Single Redirects(대시보드의 Redirect Rules 섹션)는 필터 표현식으로 요청을 찾아 정적 또는 동적 대상으로 301, 302, 303, 307, 308 중 하나와 함께 보냅니다. www에서 루트 도메인(apex)으로 보내는 일반적인 규칙은 다음과 같이 설정합니다.
When incoming requests match: (http.host eq "www.example.com") URL redirect type: Dynamic Expression: concat("https://example.com", http.request.uri.path) Status code: 301 Preserve query string: OnBulk Redirects는 CSV로 업로드한 마이그레이션 매핑표처럼 소스 URL과 대상 URL의 긴 목록을 처리합니다. 항목마다 상태 코드와 쿼리 문자열 유지 같은 옵션을 따로 지정할 수 있으며, Bulk Redirect Rule로 목록을 활성화합니다.
Workers에서는
Response.redirect()로 코드에서 리디렉션을 만들 수 있습니다.
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === '/old-page') {
url.pathname = '/new-page'; // 쿼리 문자열은 유지됨
return Response.redirect(url.toString(), 301);
}
return fetch(request);
},
};Response.redirect()의 기본값은 302이며, 301, 302, 303, 307, 308만 받습니다. 그 밖의 코드를 넘기면 RangeError가 발생합니다.
Node.js와 Express
import express from 'express';
const app = express();
app.set('trust proxy', 1); // 로드 밸런서나 CDN 뒤에서 실행할 때
// HTTPS와 표준 호스트를 한 번의 홉으로 처리
app.use((req, res, next) => {
if (req.secure && req.hostname === 'example.com') return next();
res.redirect(301, `https://example.com${req.originalUrl}`);
});
// 옮긴 페이지 하나
app.get('/old-page', (req, res) => {
res.redirect(301, '/new-page');
});상태 코드 없이 res.redirect(url)을 호출하면 302를 보냅니다. Express 5는 res.redirect(status, url) 순서만 지원합니다. 순수 Node.js에서는 상태 코드와 헤더를 직접 설정하세요.
res.writeHead(301, { Location: 'https://example.com/new-page' });
res.end();PHP
<?php
// 출력을 보내기 전에 실행해야 합니다.
header('Location: https://example.com/new-page', true, 301);
exit;Location 헤더만 보내면 PHP는 302를 보내므로(이미 201이나 3xx 상태가 설정된 경우는 제외), 세 번째 인수로 코드를 전달하고 그 뒤의 코드가 실행되지 않도록 exit하세요. WordPress의 wp_redirect()와 wp_safe_redirect()도 기본값이 302이므로 두 번째 인수로 301을 전달하세요.
알맞은 리디렉션 코드 고르기
- 페이지를 영구 이전했고 GET으로 불러오는 일반 페이지인 경우: 301을 사용하세요. 308도 똑같이 쓸 수 있습니다.
- 영구 이전이고 API, 폼 액션, 웹훅처럼 클라이언트가 POST, PUT, DELETE를 보낼 수 있는 경우: 308을 사용하세요.
- 임시 리디렉션이고 GET만 고려하면 되는 경우: 302를 사용하세요.
- 임시 리디렉션이고 메서드를 유지해야 하는 경우: 307을 사용하세요.
- 폼 등의 POST 요청을 처리했고 사용자에게 결과 페이지를 보여 줘야 하는 경우: 303을 사용하세요.
- HTTP에서 HTTPS로: 301이나 308을 사용하고, 다음부터 브라우저가 안전하지 않은 요청을 건너뛰도록 HTTPS 사이트에서
Strict-Transport-Security헤더를 보내세요. - 콘텐츠가 삭제되었고 대체할 페이지가 없는 경우: 리디렉션하지 마세요. 404나 410을 반환하세요. 홈페이지처럼 관련 없는 페이지로 리디렉션하면 Google이 소프트 404로 처리할 수 있습니다.
- 300, 305, 306: 사용하지 마세요. 304는 조건부 요청에 대해 서버나 캐시가 생성하는 코드이므로 리디렉션 코드로 고를 대상이 아닙니다.
301과 308의 캐싱 함정
301과 308은 휴리스틱 캐시가 가능하므로 브라우저가 이를 저장하고 더 이상 서버에 묻지 않을 수 있습니다. Chromium 기반 브라우저는 캐싱 헤더가 없는 301이나 308을 캐시 항목이 밀려나거나 사용자가 캐시를 지울 때까지 보관합니다. 잘못된 영구 리디렉션은 서버를 고친 뒤에도 오랫동안 이미 방문했던 사람들을 엉뚱한 곳으로 보낼 수 있으며, 방문자의 브라우저에서 이를 확실하게 되돌릴 방법은 없습니다.
안전하게 적용하려면 다음을 지키세요.
- 새 규칙은 먼저 302나 307로 테스트하고, 목적지가 확정되면 301이나 308로 바꾸세요.
- 또는 영구 리디렉션을 적용하는 동안 캐시될 수 있는 기간을 제한하세요.
location = /old-page {
add_header Cache-Control "max-age=3600";
return 301 https://example.com/new-page;
}- 테스트할 때는 시크릿 창이나 Disable cache(캐시 사용 중지)를 켠 DevTools, 또는 브라우저 캐시를 전혀 쓰지 않는 서버 측 검사 도구를 사용하세요.
- CDN과 프록시도 리디렉션을 저장할 수 있으므로, 리디렉션을 변경한 뒤에는 CDN과 프록시의 캐시를 비우세요.
- 캐시된 A → B 리디렉션을 B에서 A로 다시 리디렉션해서 되돌리려 하지 마세요. 첫 번째 리디렉션을 캐시한 브라우저의 방문자는 루프에 빠질 수 있습니다.
리디렉션 테스트하기
어떤 플랫폼을 쓰든 결과는 외부에서 확인하세요. 301 리디렉션 검사는 영구 이전의 모든 홉이 301이나 308인지 확인하고, 리디렉션 체인 검사는 각 홉을 Location 값과 헤더까지 포함해 보여 주며, HTTPS 리디렉션 검사는 프로토콜과 호스트 조합 4가지를 한 번에 테스트합니다. Meta Refresh 검사는 헤더만 보는 도구가 놓치는 클라이언트 측 리디렉션을 찾아내고, 리디렉션 지연 시간 측정은 홉마다 걸리는 시간을 측정합니다.
터미널에서는 curl -sIL https://example.com/old-page로 모든 홉의 헤더를 출력할 수 있습니다. 명령줄, DevTools, 흔한 리디렉션 문제는 URL 리디렉션 확인 방법 가이드에서 자세히 다룹니다.