짧은 답
- 301 Moved Permanently: 예전 URL은 영영 없어졌다는 뜻입니다. 브라우저가 기억해 둘 수 있고, 검색엔진은 새 URL로 관심을 옮깁니다.
- 302 Found: 진짜 주소는 여전히 예전 URL이고, 지금만 다른 곳으로 보낸다는 뜻입니다. 따로 지시하지 않으면 아무것도 기억되지 않고, 검색엔진은 예전 URL을 유지합니다.
1년 뒤에도 검색 결과에 예전 URL이 보이면 곤란하다면 301입니다. 예전 URL이 나중에 다시 살아나야 한다면(점검 페이지, 캠페인, 지역·로그인에 따른 리다이렉트) 302입니다.
나머지는 실제로 문제를 일으키는 세부 사항입니다. 캐시, 요청 메서드, 구글이 각각을 어떻게 다루는지, 그리고 체인입니다.
서버가 실제로 무엇을 보내는지 확인하기
설정 파일을 믿지 말고 응답을 읽으세요. curl로:
curl -s -o /dev/null -D - http://example.com/old-page
HTTP/1.1 301 Moved Permanently
Server: nginx
Location: https://example.com/old-page
-D -는 응답 헤더를 출력하고 -o /dev/null은 본문을 버립니다. 체인 전체를 따라가려면 -L을 붙이고 걸러 냅니다.
curl -sL -o /dev/null -D - http://example.com/old-page | grep -iE '^(HTTP/|location:)'
HTTP/1.1 301 Moved Permanently
Location: https://example.com/old-page
HTTP/2 301
location: https://www.example.com/old-page
HTTP/2 302
location: https://www.example.com/new-page
HTTP/2 200
hop이 세 번이고, 영구와 임시가 섞여 있습니다. 이게 왜 문제인지는 아래에서 다룹니다.
이 용도로는 curl -I를 피하세요. HEAD 요청을 보내는데, HEAD와 GET에 다르게 응답하는 애플리케이션이 있습니다. 브라우저에서는 개발자 도구 → Network에서 “Preserve log”를 켜면 hop마다 상태 코드가 보입니다.
HTTP 점검은 같은 일을 우리 네트워크 밖에서 합니다. hop마다 상태 코드와 Location을 보여 주고, 리다이렉트가 세 번 이상이면 경고합니다.
캐시: 잘못 건 301을 되돌리기 어려운 이유
사람들이 가장 많이 당하는 부분입니다.
RFC 9110은 301과 308을 기본적으로 캐시 가능한 응답으로 정합니다. 응답에 Cache-Control이나 Expires가 없으면 브라우저는 리다이렉트를 저장해 두고 다시 쓸 수 있고, 실제로 오래 들고 있습니다. 다음 방문 때 브라우저는 서버에 묻지도 않고 바로 대상 URL로 갑니다.
그래서 301을 실수로 걸었거나 마음을 바꿨을 때, 서버를 고치면 아직 방문하지 않은 사람에게만 고쳐집니다. 이미 301을 받은 사람은 캐시 항목이 사라질 때까지 계속 리다이렉트됩니다. 방문자의 브라우저에 들어가 그 항목을 지울 방법은 없습니다.
302와 307은 반대입니다. 명시적인 캐시 헤더를 붙이지 않는 한 캐시되지 않습니다. 서버를 바꾸면 다음 요청부터 바로 반영됩니다.
실무 규칙은 이렇습니다.
- 영구 리다이렉트를 아직 시험 중이라면 302를 쓰거나, 301에 짧은 수명을 주세요:
Cache-Control: max-age=3600. 확신이 서면 그때 평범한 301로 바꿉니다. - 디버깅할 때 가장 믿을 수 없는 증인은 내 브라우저입니다. curl을 쓰거나, 개발자 도구에서 “Disable cache”를 켜거나, 시크릿 창을 쓰세요.
- CDN과 리버스 프록시도 리다이렉트를 캐시할 수 있습니다. 리다이렉트를 바꿨다면 CDN에서도 해당 URL을 퍼지하세요.
예전 301에 갇힌 방문자는 그 사이트의 인터넷 사용 기록(캐시)을 지우면 풀립니다. 공개 사이트라면 기다리는 수밖에 없습니다. 그래서 301은 내보내기 전에 제대로 정해야 합니다.
메서드: 301·302 대 307·308 (그리고 303)
301과 302에는 오래된 특이점이 있습니다. 원래 명세는 같은 메서드로 다시 요청하라고 했지만, 브라우저들은 리다이렉트를 따라갈 때 POST를 GET으로 바꿨고, 지금 명세는 이를 허용합니다. 그래서 폼을 POST한 URL이 다른 URL로 301하면, 거기에는 본문 없는 GET으로 도착합니다. curl도 -L에서 같은 동작을 하며, --post301·--post302를 줘야 POST를 유지합니다.
해결책으로 새 코드 두 개가 생겼습니다.
| 영구 | 임시 | |
|---|---|---|
| POST를 GET으로 바꿀 수 있음 | 301 | 302 |
| 메서드와 본문을 그대로 유지해야 함 | 308 | 307 |
그리고 하나 더, 303 See Other는 명시적으로 “이제 이 다른 URL을 GET하라”는 뜻입니다. 폼 POST가 성공한 뒤의 올바른 응답이고(POST/redirect/GET 패턴), 새로고침해도 폼이 다시 제출되지 않게 해 줍니다.
언제 무엇을 쓸지:
- 웹 페이지 이동: 301이면 충분하고 어디서나 통합니다. 308도 현재 브라우저와 검색엔진에서 동작합니다.
- API 엔드포인트나 POST·PUT을 받는 주소 이동: 영구면 308, 임시면 307입니다. 301·302를 쓰면 클라이언트가 조용히 POST를 GET으로 바꿔 본문을 잃을 수 있습니다.
- 폼 제출 처리 후: 303입니다.
크롬 개발자 도구에서 서버가 보낸 적 없는 http:// → https:// 307 Internal Redirect가 보일 때가 있습니다. HSTS 때문입니다. 브라우저가 이 사이트는 HTTPS 전용이라는 것을 이미 알고 있어 스스로 요청을 바꾼 것이고, 개발자 도구에 Non-Authoritative-Reason: HSTS로 표시됩니다. 동작 방식은 HSTS preload 체크리스트에서 다룹니다.
구글은 각각을 어떻게 다루나
구글 문서는 301과 308을 영구 리다이렉트로, 302와 307을 임시 리다이렉트로 분류합니다.
- 영구: 대상 URL을 대표(canonical) URL로 삼으라는 강한 신호입니다. 시간이 지나면 예전 URL이 색인에서 빠지고 새 URL이 그 자리를 차지합니다.
- 임시: 약한 신호입니다. 구글은 대체로 원래 URL을 대표 URL로 유지하고 검색 결과에도 계속 보여 줍니다.
구글은 리다이렉트 자체로 PageRank가 줄지는 않는다고 공개적으로 밝힌 바 있습니다. 그러니 “302는 링크 가치가 샌다”는 옛 조언으로 생각할 문제가 아닙니다. 진짜 차이는 검색 결과에 어느 URL이 남느냐입니다.
주의할 점 두 가지:
- 오래 유지되는 302. 구글은 오래 걸려 있는 302를 결국 영구 리다이렉트처럼 다룰 수 있다고 말한 적이 있습니다. 기댈 만한 기능은 아닙니다. 영구 이동이면 처음부터 301로 말해서 색인을 바로잡으세요.
- 전부 홈페이지로 리다이렉트. 섹션을 없애면서 그 URL을 모두
/로 301하면, 구글이 이를 이전이 아니라 soft 404로 볼 수 있습니다. 예전 URL마다 가장 가까운 대응 페이지로 보내고, 대응 페이지가 없는 URL은 404나 410을 돌려주세요.
도메인이나 URL 구조를 옮길 때 구글은 리다이렉트를 오래, 대체로 최소 1년은 유지하라고 권합니다. 예전 메일, PDF, 남의 사이트에 걸린 링크는 그보다 훨씬 오래 들어오므로 굳이 지울 이유가 별로 없습니다. 이전 작업의 나머지는 도메인 이전 체크리스트에 정리돼 있습니다.
301·302 헤더는 신호 중 하나일 뿐입니다. rel="canonical", 내부 링크, 사이트맵이 여전히 예전 URL을 가리키면 서로 모순되는 신호를 보내는 셈입니다. 모두 최종 URL로 맞추세요.
뜻밖의 302는 어디서 오나
301을 기대했는데 302가 보인다면, 대개 몰랐던 기본값 때문입니다.
| 위치 | 기본값 | 영구로 바꾸려면 |
|---|---|---|
Apache Redirect /old /new | 302 | Redirect 301 /old /new (또는 permanent) |
Apache RewriteRule ... [R] | 302 | [R=301,L] |
nginx rewrite ... redirect | 302 | rewrite ... permanent 또는 return 301 ... |
PHP header('Location: ...') | 302 | header('Location: ...', true, 301) |
Express res.redirect(url) | 302 | res.redirect(301, url) |
Django redirect(...) | 302 | redirect(..., permanent=True) |
Rails redirect_to | 302 | redirect_to ..., status: :moved_permanently |
Next.js redirects() | permanent: false면 307 | permanent: true면 308 |
nginx에서 HTTP → HTTPS 리다이렉트는 보통 이렇게 씁니다.
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
nginx -T로 실제 적용된 설정 전체를 덤프한 뒤 return 30과 rewrite를 grep하면, 잊고 있던 include 파일 속 규칙까지 모든 리다이렉트를 한 번에 볼 수 있습니다.
리다이렉트 체인
체인은 리다이렉트가 여러 번 이어지는 것입니다. http://example.com → https://example.com → https://www.example.com → https://www.example.com/ko/. hop마다 페이지 로딩이 시작되기 전에 왕복이 한 번씩 더 들고, 모바일 네트워크에서는 그게 쌓입니다.
한도도 있습니다. 브라우저가 따르는 Fetch 표준은 리다이렉트가 20번을 넘으면 포기합니다. 구글 문서에 따르면 Googlebot은 최대 10 hop까지 따라가고, 그 뒤에는 리다이렉트 오류로 보고합니다. 어느 한도에 닿기 훨씬 전부터 시간을 낭비하고 있는 셈입니다.
체인은 보통 계층마다 규칙을 하나씩 더하면서 길어집니다. CDN이 HTTPS를 강제하고, 웹 서버가 www를 붙이고, 애플리케이션이 언어 접두사를 붙이고, 오래된 SEO 플러그인이 끝 슬래시를 붙입니다. 첫 hop이 곧장 최종 URL로 가게 만들어 고칩니다.
- HTTPS 전환과 호스트 정규화를 한 규칙으로 합치세요. 요청을 가장 먼저 보는 계층에서
http://example.com/page→https://www.example.com/page로 바로 보냅니다. (예외: 도메인을 HSTS preload 목록에 등록할 계획이라면http://는 먼저 같은 호스트의https://로 가야 합니다.) - 리다이렉트 대상을 없앨 때는 hop을 하나 더 붙이지 말고, 예전 리다이렉트가 새 최종 URL을 가리키도록 고치세요.
- 내부 링크를 고쳐서 내 페이지가 리다이렉트되는 URL로 링크하는 일이 없게 하세요.
체인이 출발점으로 되돌아오면 루프가 되고, 브라우저는 ERR_TOO_MANY_REDIRECTS를 보여 줍니다. CDN과 오리진이 엇갈리는 경우를 포함해 그 상황은 리다이렉트 루프 가이드에서 다룹니다.
빠른 결정표
- URL이 영영 바뀌었고 페이지 요청이 GET이면: 301
- URL이 영영 바뀌었고 클라이언트가 POST로 요청하면: 308
- 잠시 돌려보내는 것이고 원래 URL이 돌아오면: 302 (GET 이외의 요청이 섞이면 307)
- 폼을 처리한 뒤: 303
- 영구인지 아직 모르겠다면: 지금은 302, 확신이 서면 301. 302에서 301로 가는 건 쉽지만, 캐시된 301을 되돌리는 건 어렵습니다.
내 도메인에서 바로 확인
무료, 가입 불필요. 이 가이드가 다루는 항목을 바로 검사하고 조치 방법을 알려드립니다.