이 사이트에는 원래 /ko/tests/fitness-type 이라는 페이지가 있었습니다. 지금은 /ko/lab/fitness-type/ 입니다. tests 라는 이름이 "검사"인지 "테스트 코드"인지 모호했고, 나중에 실험적인 페이지를 더 넣을 자리도 필요했기 때문입니다.

로컬에서는 폴더를 옮기고 빌드를 돌리면 끝입니다. 링크를 몇 개 고치고 나면 사이트는 완벽하게 동작합니다. 문제는 로컬에 없는 것들입니다.

옛 URL 을 알고 있는 것들: 검색엔진 색인 다른 사이트가 걸어둔 링크 이미 공유된 카카오톡·슬랙 메시지 브라우저 북마크와 방문 기록

이 중 어느 것도 저장소를 보지 않습니다. 파일을 옮겨도 그들은 여전히 옛 주소로 찾아옵니다.

1. 301 과 302 는 취향의 문제가 아니다

리다이렉트를 쓸 때 가장 먼저 고르는 값이 영구인가 임시인가 입니다. 이게 실제로 무엇을 결정하는지가 헷갈리기 쉽습니다.

301 (permanent) "이 주소는 이제 저쪽이다" → 검색엔진이 색인을 새 URL 로 옮긴다 → 브라우저가 결과를 캐시한다 (강하게) 302 (temporary) "지금은 저쪽으로 보내지만 주소는 여기다" → 검색엔진이 색인을 옛 URL 에 유지한다 → 브라우저가 매번 다시 물어본다

핵심은 브라우저 캐시 쪽입니다. 301 은 브라우저가 아주 오래 기억합니다. 잘못된 301 을 한 번 내보내면, 그걸 고친 뒤에도 이미 받아간 사용자는 캐시를 직접 지우기 전까지 계속 틀린 곳으로 갑니다. 서버를 고쳐도 소용이 없습니다.

확신이 없으면 302 로 시작하세요.

302 를 301 로 바꾸는 건 언제든 됩니다. 반대는 이미 배포된 캐시를 회수할 방법이 없습니다.

2. 고를 수 있는 코드는 둘이 아니라 다섯이다

301 과 302 만 놓고 고민하는 경우가 많은데, 실제로 쓸 수 있는 코드는 더 있습니다. 이전 작업에서 마주치는 건 이 다섯 개입니다.

코드 의미 색인 브라우저 캐시 메서드 301 영구 이동 새 URL 무기한(강함) POST→GET 로 바뀔 수 있음 308 영구 이동 (메서드 보존) 새 URL 무기한(강함) 그대로 유지 302 임시 이동 옛 URL 캐시 안 함 POST→GET 로 바뀔 수 있음 307 임시 이동 (메서드 보존) 옛 URL 캐시 안 함 그대로 유지 410 영구 삭제 색인 제거 - -

가로 두 줄씩 짝입니다. 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 입니다.

{ "source": "/", "has": [{ "type": "header", "key": "x-vercel-ip-country", "value": "KR" }], "destination": "/ko/", "permanent": false // 302 }, { "source": "/", "destination": "/en/", "permanent": false // 302 }

루트는 보는 사람에 따라 목적지가 달라지기 때문입니다. 한국에서 접속하면 /ko/, 그 외에는 /en/ 으로 갑니다. 여기에 301 을 쓰면 한국에서 한 번 접속한 브라우저는 그 뒤로 영원히 /ko/ 로 갑니다. 서버 판정이 아예 실행되지 않습니다. 해외로 나가도, 언어 설정을 바꿔도 마찬가지입니다.

판정이 매번 달라져야 하는 리다이렉트는 캐시되면 안 됩니다. 301 은 "이 주소는 옮겨졌다"는 뜻이지 "지금은 저기로 가라"는 뜻이 아닙니다.

4. 슬래시 하나가 URL 두 개를 만든다

리다이렉트를 처음 넣고 나서 /ko/tests/fitness-type 은 잘 되는데 /ko/tests/fitness-type/ 은 404 였습니다.

사람 눈에는 같은 주소지만 HTTP 에서는 완전히 다른 두 개의 경로입니다. 그리고 옛 URL 이 어디에 어떤 형태로 남아 있는지는 알 수 없습니다. 검색엔진은 슬래시 있는 쪽을, 누군가의 블로그는 없는 쪽을 걸어뒀을 수 있습니다.

{ "source": "/ko/tests/fitness-type", "destination": "/ko/lab/fitness-type/", "permanent": true }, { "source": "/ko/tests/fitness-type/", "destination": "/ko/lab/fitness-type/", "permanent": true }

둘 다 적었습니다. 규칙이 두 배가 되지만 둘 중 어느 쪽이 남아 있는지 추측하는 것보다 낫습니다. 도착지는 하나로 통일합니다 — 슬래시 있는 쪽으로 보낼지 없는 쪽으로 보낼지는 취향이지만, 사이트 전체에서 한 방향이어야 색인이 갈라지지 않습니다.

같은 페이지가 갈라지는 다른 경우들

슬래시는 그중 하나일 뿐입니다. 사람 눈에 같아 보이는데 HTTP 가 다르게 보는 경우를 모아두면 이렇습니다.

갈라지는 축 예시 해결 수단 프로토콜 http:// vs https:// 리다이렉트 (301) 호스트 ceanlab.com vs www.ceanlab.com 리다이렉트 (301) 후행 슬래시 /lab/x vs /lab/x/ 리다이렉트 (301) 경로 대소문자 /Lab/x vs /lab/x 리다이렉트 (301) 기본 문서 /lab/x/ vs /lab/x/index.html 리다이렉트 (301) 쿼리스트링 /lab/x/?utm_source=kakao canonical 프래그먼트 /lab/x/#result 아무것도 안 해도 됨

위 다섯 줄과 아래 두 줄은 대응 수단이 다릅니다. 앞의 다섯은 서버가 하나의 정답 주소로 보내야 하는 것들이고, 뒤의 둘은 그럴 수 없거나 그럴 필요가 없는 것들입니다.

쿼리스트링이 특히 헷갈립니다. ?utm_source=kakao 가 붙은 주소는 내용이 같은데 URL 은 다른 전형적인 경우인데, 이걸 리다이렉트로 떼어내면 안 됩니다. 붙어 온 파라미터를 서버가 지워버리면 그 값을 읽어야 하는 쪽 — 유입 분석이든 앱으로 넘기는 딥링크든 — 이 같이 죽습니다. 이건 리다이렉트가 아니라 canonical 로 "원본은 파라미터 없는 쪽"이라고 선언해서 처리할 문제입니다.

프래그먼트(# 뒤)는 아예 서버로 전송되지 않습니다. 리다이렉트 규칙에 적을 수도 없고 적을 필요도 없습니다. 대신 리다이렉트를 타면 프래그먼트가 유지되는지는 브라우저마다 다릅니다 — 앵커까지 살아야 하는 링크를 옮길 때는 실제로 열어봐야 합니다.

대소문자는 조건이 하나 더 붙습니다. 호스트 이름은 대소문자를 구분하지 않지만 경로는 구분합니다. 그래서 /Lab/ 은 이론상 별개의 주소인데, 이걸 전부 규칙으로 적어두는 건 현실적이지 않습니다. 옛 경로에 대문자가 섞여 나간 적이 있는지만 확인하고, 없으면 넘어갑니다.

5. 한글 경로도 남아 있었다

목록에는 이런 것도 있습니다.

{ "source": "/홈", "destination": "/ko/", "permanent": true }

아주 초기에 잠깐 존재했던 경로입니다. 지금 기준으로는 쓰지 않을 형태지만, 이미 나간 URL 은 취향과 무관하게 살아 있습니다.

리다이렉트 목록이 지저분해 보인다고 정리하고 싶어질 때가 있는데, 그건 목록이 지저분한 게 아니라 과거가 지저분한 것입니다. 규칙 한 줄을 지우는 순간 그 URL 로 오던 사람은 404 를 봅니다. 지우는 기준은 "보기 싫어서"가 아니라 "충분히 오래 유지했고 유입이 0 인가" 여야 합니다.

그래서 언제 지워도 되나

"충분히 오래"와 "0" 을 각각 숫자로 바꿔두면 판단이 매번 흔들리지 않습니다. 이 사이트에서 쓰는 기준입니다.

확인 항목 지워도 되는 조건 유지 기간 최소 12 개월 Search Console 노출 최근 90 일 0 회 서버 로그 유입 최근 90 일 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. 체인은 설정 파일에 보이지 않는다

규칙 하나하나는 다 맞는데 이어 붙이면 틀리는 경우가 있습니다. 리다이렉트 체인입니다.

규칙 목록 (각각은 정상) /ko/tests/fitness-type → /ko/lab/fitness-type /ko/lab/fitness-type → /ko/lab/fitness-type/ 실제 요청이 겪는 것 /ko/tests/fitness-type → 301 → /ko/lab/fitness-type → 301 → /ko/lab/fitness-type/ → 200 (2 홉)

이전 작업 중에 슬래시 통일 규칙을 나중에 넣으면 이 모양이 됩니다. 설정 파일에서는 두 줄이 그냥 나란히 있을 뿐이라 읽어서는 보이지 않습니다. 체인이 되는지는 요청을 실제로 보내봐야 압니다.

curl -sIL https://ceanlab.com/ko/tests/fitness-type \ | grep -i '^HTTP\|^location' HTTP/2 301 location: /ko/lab/fitness-type HTTP/2 301 location: /ko/lab/fitness-type/ HTTP/2 200

-L 이 끝까지 따라가고 -I 가 헤더만 받습니다. 홉이 몇 개인지, 마지막이 200 인지, 중간에 location 이 예상과 다른 곳을 가리키지는 않는지가 이 출력 하나에 다 나옵니다.

홉 예산은 이렇게 잡습니다.

0 홉 가장 좋다 — 내부 링크는 전부 여기여야 한다 1 홉 정상 — 외부에서 옛 URL 로 들어오는 경우 2 홉 고칠 수 있으면 고친다 (출발지를 최종 목적지로 직접 연결) 3 홉+ 고친다 — 크롤러가 중간에 포기할 수 있다 5 홉+ 사실상 끊긴 것으로 본다

고치는 방법은 규칙을 지우는 게 아니라 출발지의 목적지를 최종 도착지로 직접 바꾸는 것입니다. 위 예라면 첫 규칙의 목적지를 슬래시 붙은 형태로 고칩니다. 그러면 중간 규칙은 남아 있되 아무도 지나가지 않게 됩니다 — 그건 그것대로 필요합니다. 슬래시 없는 형태로 들어오는 외부 링크가 여전히 있을 수 있으니까요.

내부 링크에서 홉이 생기는 건 다른 문제입니다. 자기 사이트 안에서 자기 리다이렉트를 타는 링크는 느릴 뿐 아니라 "우리가 아직 안 고쳤다"는 신호입니다. 옮긴 뒤에 저장소 전체를 옛 경로로 한 번 검색해서 남은 게 없는지 봅니다.

8. 옮기기 전에 정하는 것

이번 이전에서 얻은 실질적인 순서는 이렇습니다.

1. 새 경로를 정한다 — 앞으로 3년을 버틸 이름인가 2. 옛 경로를 전부 적는다 — 슬래시 있는 형태·없는 형태 모두 3. 리다이렉트를 먼저 넣고 배포한다 (아직 파일은 그대로) 4. 파일을 옮기고 내부 링크를 고친다 5. canonical · hreflang · sitemap 을 새 주소로 맞춘다 6. 옛 URL 을 직접 열어본다 — 최종 도착지까지

3번과 4번을 나눈 게 핵심입니다. 리다이렉트가 먼저 살아 있으면 파일이 옮겨지는 순간의 공백이 없습니다. 반대로 하면 배포 사이 몇 초에서 몇 분 동안 옛 주소도 새 주소도 아무 데도 닿지 않습니다.

6번은 반드시 브라우저에서 직접 합니다. 리다이렉트가 또 다른 리다이렉트를 타는 체인이 생기기 쉽고, 설정 파일만 봐서는 안 보입니다. 체인은 느릴 뿐 아니라 중간 고리가 하나 사라지면 통째로 끊깁니다.

리다이렉트는 새 코드가 아니라 옛 약속입니다.

기능이 아니라서 테스트도 리뷰도 잘 안 붙습니다. 그래서 조용히 깨지고, 깨진 걸 몇 달 뒤 검색 유입이 줄어든 다음에야 알게 됩니다.

9. 옮긴 뒤에 실제로 확인한 것

순서를 지켜도 빠뜨리는 건 생깁니다. 배포 후에 하나씩 짚은 목록입니다.

□ 옛 URL 전부가 최종 200 에 닿는가 curl -sIL (홉 수까지) □ 옛 URL 의 슬래시 있는 형태도 되는가 위와 같은 방법으로 한 번 더 □ 새 URL 이 자기 자신을 canonical 로 쓰는가 페이지 소스 확인 □ ko / en 의 canonical·hreflang 이 짝인가 양쪽 소스를 나란히 비교 □ sitemap.xml 에 옛 URL 이 없는가 빌드 산출물에서 검색 □ sitemap.xml 에 새 URL 이 있는가 staticPages.js 반영 여부 □ 저장소에 옛 경로 문자열이 남았는가 전체 검색 □ 리다이렉트 목적지가 sitemap 에 있는 주소인가 두 목록 대조

마지막 줄이 이 사이트에서 실제로 걸린 항목입니다. 글의 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 은 코드가 아니라 사이트가 외부와 맺은 계약에 가깝습니다. 안에서 파일을 어떻게 배치하든 자유지만, 한 번 밖으로 나간 주소는 내 것이 아닙니다. 옮기는 건 언제든 할 수 있고, 옮긴 사실을 알려주는 책임만 남는 것입니다.