이 사이트에는 원래 /ko/tests/fitness-type 이라는 페이지가 있었습니다. 지금은 /ko/lab/fitness-type/ 입니다. tests 라는 이름이 "검사"인지 "테스트 코드"인지 모호했고, 나중에 실험적인 페이지를 더 넣을 자리도 필요했기 때문입니다.
로컬에서는 폴더를 옮기고 빌드를 돌리면 끝입니다. 링크를 몇 개 고치고 나면 사이트는 완벽하게 동작합니다. 문제는 로컬에 없는 것들입니다.
이 중 어느 것도 저장소를 보지 않습니다. 파일을 옮겨도 그들은 여전히 옛 주소로 찾아옵니다.
1. 301 과 302 는 취향의 문제가 아니다
리다이렉트를 쓸 때 가장 먼저 고르는 값이 영구인가 임시인가 입니다. 이게 실제로 무엇을 결정하는지가 헷갈리기 쉽습니다.
핵심은 브라우저 캐시 쪽입니다. 301 은 브라우저가 아주 오래 기억합니다. 잘못된 301 을 한 번 내보내면, 그걸 고친 뒤에도 이미 받아간 사용자는 캐시를 직접 지우기 전까지 계속 틀린 곳으로 갑니다. 서버를 고쳐도 소용이 없습니다.
302 를 301 로 바꾸는 건 언제든 됩니다. 반대는 이미 배포된 캐시를 회수할 방법이 없습니다.
2. 고를 수 있는 코드는 둘이 아니라 다섯이다
301 과 302 만 놓고 고민하는 경우가 많은데, 실제로 쓸 수 있는 코드는 더 있습니다. 이전 작업에서 마주치는 건 이 다섯 개입니다.
가로 두 줄씩 짝입니다. 301/308 은 캐시 의미가 같고 메서드 처리만 다릅니다. 302/307 도 마찬가지입니다. 301 과 302 가 만들어질 당시 규격은 메서드를 유지하라고 했지만 브라우저들이 POST 를 GET 으로 바꿔버렸고, 그 현실을 인정하고 "이번엔 진짜 유지한다"고 못 박은 게 307/308 입니다.
정적 사이트에서 리다이렉트되는 경로는 거의 전부 GET 이라 메서드 차이가 드러날 일이 없습니다. 그래서 이 사이트에서는 301/302 냐 307/308 이냐를 고민한 적이 없습니다. 다만 설정 파일에 적는 값과 실제로 나가는 코드는 다를 수 있습니다.
permanent 는 상태 코드를 직접 쓰는 값이 아닙니다.
Vercel 은 "permanent": true 를 308 로, false 를 307 로 내보냅니다. 캐시 의미는 각각 301·302 와 같지만 응답에 찍히는 숫자는 다릅니다. 설정 파일만 읽고 "301 이 나간다"고 단정하지 말고 curl -I 로 실제 코드를 한 번 봐야 합니다.
410 은 성격이 다릅니다. 리다이렉트가 아니라 "이 페이지는 옮겨간 게 아니라 없어졌다"는 선언입니다. 갈 곳이 없는 페이지를 억지로 홈으로 301 시키면 검색엔진은 그 홈을 옛 URL 의 대체물로 이해합니다. 대체물이 없으면 404 나 410 이 정직한 답입니다. 실무에서 둘의 차이는 크지 않고 — 404 도 반복되면 결국 색인에서 빠집니다 — 정적 호스팅에서는 410 을 내보낼 방법이 없는 경우가 많아 404 로 두게 됩니다.
3. 그래서 루트 경로만 302 다
이 사이트의 리다이렉트는 대부분 301 인데, 딱 하나 / 만 302 입니다.
루트는 보는 사람에 따라 목적지가 달라지기 때문입니다. 한국에서 접속하면 /ko/, 그 외에는 /en/ 으로 갑니다. 여기에 301 을 쓰면 한국에서 한 번 접속한 브라우저는 그 뒤로 영원히 /ko/ 로 갑니다. 서버 판정이 아예 실행되지 않습니다. 해외로 나가도, 언어 설정을 바꿔도 마찬가지입니다.
판정이 매번 달라져야 하는 리다이렉트는 캐시되면 안 됩니다. 301 은 "이 주소는 옮겨졌다"는 뜻이지 "지금은 저기로 가라"는 뜻이 아닙니다.
4. 슬래시 하나가 URL 두 개를 만든다
리다이렉트를 처음 넣고 나서 /ko/tests/fitness-type 은 잘 되는데 /ko/tests/fitness-type/ 은 404 였습니다.
사람 눈에는 같은 주소지만 HTTP 에서는 완전히 다른 두 개의 경로입니다. 그리고 옛 URL 이 어디에 어떤 형태로 남아 있는지는 알 수 없습니다. 검색엔진은 슬래시 있는 쪽을, 누군가의 블로그는 없는 쪽을 걸어뒀을 수 있습니다.
둘 다 적었습니다. 규칙이 두 배가 되지만 둘 중 어느 쪽이 남아 있는지 추측하는 것보다 낫습니다. 도착지는 하나로 통일합니다 — 슬래시 있는 쪽으로 보낼지 없는 쪽으로 보낼지는 취향이지만, 사이트 전체에서 한 방향이어야 색인이 갈라지지 않습니다.
같은 페이지가 갈라지는 다른 경우들
슬래시는 그중 하나일 뿐입니다. 사람 눈에 같아 보이는데 HTTP 가 다르게 보는 경우를 모아두면 이렇습니다.
위 다섯 줄과 아래 두 줄은 대응 수단이 다릅니다. 앞의 다섯은 서버가 하나의 정답 주소로 보내야 하는 것들이고, 뒤의 둘은 그럴 수 없거나 그럴 필요가 없는 것들입니다.
쿼리스트링이 특히 헷갈립니다. ?utm_source=kakao 가 붙은 주소는 내용이 같은데 URL 은 다른 전형적인 경우인데, 이걸 리다이렉트로 떼어내면 안 됩니다. 붙어 온 파라미터를 서버가 지워버리면 그 값을 읽어야 하는 쪽 — 유입 분석이든 앱으로 넘기는 딥링크든 — 이 같이 죽습니다. 이건 리다이렉트가 아니라 canonical 로 "원본은 파라미터 없는 쪽"이라고 선언해서 처리할 문제입니다.
프래그먼트(# 뒤)는 아예 서버로 전송되지 않습니다. 리다이렉트 규칙에 적을 수도 없고 적을 필요도 없습니다. 대신 리다이렉트를 타면 프래그먼트가 유지되는지는 브라우저마다 다릅니다 — 앵커까지 살아야 하는 링크를 옮길 때는 실제로 열어봐야 합니다.
대소문자는 조건이 하나 더 붙습니다. 호스트 이름은 대소문자를 구분하지 않지만 경로는 구분합니다. 그래서 /Lab/ 은 이론상 별개의 주소인데, 이걸 전부 규칙으로 적어두는 건 현실적이지 않습니다. 옛 경로에 대문자가 섞여 나간 적이 있는지만 확인하고, 없으면 넘어갑니다.
5. 한글 경로도 남아 있었다
목록에는 이런 것도 있습니다.
아주 초기에 잠깐 존재했던 경로입니다. 지금 기준으로는 쓰지 않을 형태지만, 이미 나간 URL 은 취향과 무관하게 살아 있습니다.
리다이렉트 목록이 지저분해 보인다고 정리하고 싶어질 때가 있는데, 그건 목록이 지저분한 게 아니라 과거가 지저분한 것입니다. 규칙 한 줄을 지우는 순간 그 URL 로 오던 사람은 404 를 봅니다. 지우는 기준은 "보기 싫어서"가 아니라 "충분히 오래 유지했고 유입이 0 인가" 여야 합니다.
그래서 언제 지워도 되나
"충분히 오래"와 "0" 을 각각 숫자로 바꿔두면 판단이 매번 흔들리지 않습니다. 이 사이트에서 쓰는 기준입니다.
기간을 12 개월로 잡은 이유는 검색엔진이 색인을 옮기는 데 걸리는 시간이 배포 시각과 무관하기 때문입니다. 트래픽이 적은 페이지는 재크롤링 주기 자체가 길어서, 리다이렉트를 넣고 몇 주가 지나도 옛 URL 이 검색 결과에 그대로 떠 있는 경우가 흔합니다. 그 상태에서 규칙을 지우면 검색 결과를 클릭한 사람이 404 를 봅니다.
마지막 줄이 조용히 사고를 냅니다. 규칙 A 가 B 로 보내고 B 가 C 로 보내는 상태에서 B 를 지우면 A 는 남아 있는데 목적지가 사라집니다. 지우기 전에 그 경로를 목적지로 삼는 다른 규칙이 있는지 먼저 봅니다.
반대로 "규칙이 너무 많아졌다"는 그 자체로는 지울 이유가 되지 않습니다. vercel.json 의 리다이렉트는 1,024 개까지 들어가고, 이 사이트는 두 자릿수입니다. 실제 한계는 파일 크기가 아니라 사람이 목록을 읽고 이해할 수 있는가 쪽이고, 그건 지워서가 아니라 주석과 순서로 해결하는 문제입니다.
6. 리다이렉트만으로는 절반이다
경로를 옮기면 리다이렉트 말고도 같이 움직여야 하는 게 있습니다. 이걸 빠뜨리면 사이트는 정상인데 검색엔진만 혼란스러운 상태가 됩니다.
- canonical — 새 URL 을 가리켜야 합니다. 옛 URL 로 들어온 요청이 리다이렉트되어 도착한 페이지가 여전히 옛 주소를 canonical 로 선언하고 있으면, 검색엔진은 "옮겨졌다"와 "여기가 원본이다"라는 모순된 신호를 동시에 받습니다.
- hreflang — canonical 과 짝입니다. 한쪽만 고치면 ko 는 새 주소, en 은 옛 주소를 가리키는 상태가 됩니다.
- sitemap — 옛 URL 이 남아 있으면 안 됩니다. 리다이렉트되는 URL 을 sitemap 에 계속 올리는 건 검색엔진에게 "이 주소를 색인하라"고 말하면서 동시에 "이 주소는 저쪽이다"라고 답하는 셈입니다.
- 내부 링크 — 사이트 안에서는 처음부터 새 주소를 씁니다. 자기 사이트 안에서 리다이렉트를 거치는 링크는 그냥 낭비입니다.
이 사이트에서 마지막 세 번째가 특히 걸리기 쉽습니다. 글의 URL 은 컬렉션에서 자동으로 sitemap 에 들어가지만, 글이 아닌 페이지는 staticPages.js 라는 손으로 관리하는 목록에서 옵니다. 페이지를 옮기면 그 목록을 같이 고쳐야 하는데, 빌드 가드가 잡아주는 항목에 이건 들어 있지 않습니다.
7. 체인은 설정 파일에 보이지 않는다
규칙 하나하나는 다 맞는데 이어 붙이면 틀리는 경우가 있습니다. 리다이렉트 체인입니다.
이전 작업 중에 슬래시 통일 규칙을 나중에 넣으면 이 모양이 됩니다. 설정 파일에서는 두 줄이 그냥 나란히 있을 뿐이라 읽어서는 보이지 않습니다. 체인이 되는지는 요청을 실제로 보내봐야 압니다.
-L 이 끝까지 따라가고 -I 가 헤더만 받습니다. 홉이 몇 개인지, 마지막이 200 인지, 중간에 location 이 예상과 다른 곳을 가리키지는 않는지가 이 출력 하나에 다 나옵니다.
홉 예산은 이렇게 잡습니다.
고치는 방법은 규칙을 지우는 게 아니라 출발지의 목적지를 최종 도착지로 직접 바꾸는 것입니다. 위 예라면 첫 규칙의 목적지를 슬래시 붙은 형태로 고칩니다. 그러면 중간 규칙은 남아 있되 아무도 지나가지 않게 됩니다 — 그건 그것대로 필요합니다. 슬래시 없는 형태로 들어오는 외부 링크가 여전히 있을 수 있으니까요.
내부 링크에서 홉이 생기는 건 다른 문제입니다. 자기 사이트 안에서 자기 리다이렉트를 타는 링크는 느릴 뿐 아니라 "우리가 아직 안 고쳤다"는 신호입니다. 옮긴 뒤에 저장소 전체를 옛 경로로 한 번 검색해서 남은 게 없는지 봅니다.
8. 옮기기 전에 정하는 것
이번 이전에서 얻은 실질적인 순서는 이렇습니다.
3번과 4번을 나눈 게 핵심입니다. 리다이렉트가 먼저 살아 있으면 파일이 옮겨지는 순간의 공백이 없습니다. 반대로 하면 배포 사이 몇 초에서 몇 분 동안 옛 주소도 새 주소도 아무 데도 닿지 않습니다.
6번은 반드시 브라우저에서 직접 합니다. 리다이렉트가 또 다른 리다이렉트를 타는 체인이 생기기 쉽고, 설정 파일만 봐서는 안 보입니다. 체인은 느릴 뿐 아니라 중간 고리가 하나 사라지면 통째로 끊깁니다.
기능이 아니라서 테스트도 리뷰도 잘 안 붙습니다. 그래서 조용히 깨지고, 깨진 걸 몇 달 뒤 검색 유입이 줄어든 다음에야 알게 됩니다.
9. 옮긴 뒤에 실제로 확인한 것
순서를 지켜도 빠뜨리는 건 생깁니다. 배포 후에 하나씩 짚은 목록입니다.
마지막 줄이 이 사이트에서 실제로 걸린 항목입니다. 글의 URL 은 컬렉션에서 자동으로 sitemap 에 들어가지만 글이 아닌 페이지는 staticPages.js 라는 손으로 관리하는 목록에서 옵니다. 페이지를 옮기면 리다이렉트는 기억나는데 이 목록은 기억나지 않습니다.
이게 왜 유독 잘 빠지느냐면, 빌드가 잡아주는 항목이 아니기 때문입니다. 이 저장소의 빌드는 ko/en 글 대칭도, 글 사이 링크의 게시 순서도 실패시켜서 알려주는데 — 불변식을 빌드가 강제하는 방식이 대체로 잘 작동하는 이유입니다 — staticPages.js 만은 검사 대상 밖입니다. 사람이 기억해야 하는 항목은 결국 언젠가 빠집니다. 그래서 목록으로 적어두는 것 말고는 방법이 없습니다.
10. 자주 묻는 것
잘못된 301 을 이미 내보냈습니다. 되돌릴 방법이 없나요?
캐시를 회수할 방법은 없습니다. 서버 설정을 고쳐도 이미 301 을 받아간 브라우저는 서버에 다시 묻지 않습니다. 실질적인 회복 수단은 하나뿐입니다 — 잘못 보낸 목적지에서 원래 가야 할 곳으로 다시 리다이렉트를 걸어두는 것. 체인이 하나 늘지만 사용자는 결국 맞는 곳에 도착합니다. 이런 일을 줄이려면 처음부터 302 로 시작하는 게 낫습니다.
리다이렉트 대신 canonical 만 걸면 안 되나요?
둘은 강제력이 다릅니다. canonical 은 검색엔진에게 주는 힌트고 무시될 수 있습니다. 리다이렉트는 요청 자체를 돌립니다. 기준은 간단합니다 — 옛 주소에 여전히 페이지가 살아 있으면 canonical, 없어졌으면 리다이렉트. 없어진 주소에 canonical 을 걸 자리 자체가 없습니다.
옛 URL 을 sitemap 에 남겨두면 재색인이 빨라지나요?
아닙니다. 리다이렉트되는 URL 을 sitemap 에 올리는 건 "이 주소를 색인하라"고 말한 뒤 크롤러가 오면 "저쪽이다"라고 답하는 것입니다. sitemap 에는 200 으로 응답하는 주소만 넣습니다. 재색인을 앞당기고 싶으면 새 URL 쪽의 lastmod 를 정확히 유지하는 게 낫습니다.
리다이렉트 규칙은 몇 개까지 괜찮나요?
vercel.json 기준으로 1,024 개까지 들어갑니다. 이 사이트는 두 자릿수라 한계와는 거리가 멉니다. 개수보다 먼저 문제가 되는 건 규칙 사이의 순서와 겹침입니다. 넓은 패턴이 위에 있으면 아래의 구체적인 규칙이 영영 실행되지 않습니다.
페이지가 아예 없어졌을 때는 어디로 보내나요?
대체 페이지가 있으면 그쪽으로 301, 없으면 아무 데도 보내지 않습니다. 갈 곳 없는 페이지를 홈으로 301 하면 검색엔진은 홈을 그 URL 의 후속으로 이해하고, 옛 주소로 들어온 사람은 자기가 찾던 게 왜 없는지 모르는 채 홈을 봅니다. 404 가 불친절해 보여도 정확한 답입니다.
ko 만 먼저 옮기고 en 은 나중에 해도 되나요?
안 됩니다. canonical 과 hreflang 은 서로를 가리키는 짝이라 한쪽만 옮기면 ko 는 새 주소를, en 은 옛 주소를 원본이라고 주장하는 상태가 됩니다. 이 저장소는 글의 ko/en 대칭은 빌드가 강제하지만 글이 아닌 페이지는 검사하지 않습니다. 랜딩이나 lab 페이지를 옮길 때는 사람이 양쪽을 같이 처리해야 합니다.
11. 정리
- 목적지가 사람마다 달라지면 302, 주소 자체가 옮겨졌으면 301. 301 은 회수할 수 없습니다.
- 슬래시 있는 형태와 없는 형태를 둘 다 적는다. 어느 쪽이 밖에 남아 있는지는 모릅니다.
- URL 이 갈라지는 축은 슬래시 말고도 다섯 개 더 있다. 프로토콜·호스트·대소문자·기본 문서는 리다이렉트로, 쿼리스트링은 canonical 로 처리합니다.
- 부끄러운 옛 경로도 유입이 0 이 되기 전까지 유지한다. 최소 12 개월, 백링크 0 개, 다른 규칙의 목적지가 아닐 것.
- 체인은 설정 파일을 읽어서는 안 보인다.
curl -sIL로 홉 수를 직접 세고, 2 홉이 넘으면 출발지를 최종 도착지로 다시 연결합니다. - 리다이렉트를 먼저 배포하고 파일을 나중에 옮긴다. 공백이 생기지 않습니다.
- canonical · hreflang · sitemap · 내부 링크를 같이 옮긴다. 리다이렉트만으로는 절반입니다.
URL 은 코드가 아니라 사이트가 외부와 맺은 계약에 가깝습니다. 안에서 파일을 어떻게 배치하든 자유지만, 한 번 밖으로 나간 주소는 내 것이 아닙니다. 옮기는 건 언제든 할 수 있고, 옮긴 사실을 알려주는 책임만 남는 것입니다.