404를 301로 정리하다 캐시에 속은 이야기 — 리다이렉션은 걸려 있었다

2026년 10월 27일 · AI 자동화, 개발 일지

리디렉션을 거는 세 가지 방법과 각각 걸린 지점

앞 글에서 애드센스에 가치 낮은 콘텐츠로 반려당한 얘기를 적었다. 그 뒤로는 색인이 왜 안 늘어나는지를 파고 있다.

이번에 한 건 주소 정리다. 운영 중인 다른 사이트에서 서치콘솔 커버리지 리포트를 받아 원인 중 코드로 고칠 수 있는 것만 처리했고, 이 블로그에서는 글을 합치면서 생긴 옛 주소를 새 글로 넘겼다. 둘 다 결국 301 리다이렉션 작업인데 걸린 지점이 완전히 달랐다. 한쪽은 리디렉션이 301이 아니었고, 다른 쪽은 301이 맞는데도 그렇게 안 보였다.

같은 글이 두 주소로 갈려 있었다

다른 사이트 쪽 얘기부터. 구글이 아는 주소 89개 중 색인된 건 22개였다. 미색인 67개의 내역에서 눈에 걸린 건 리디렉션이 포함된 페이지 20건과 찾을 수 없음 1건이었다.

반대로 크롤링됨 – 현재 색인 생성되지 않음은 0건이었다. 긁어보고 품질 미달로 버린 페이지가 하나도 없다는 뜻이다. 글이 나빠서 색인이 안 되는 게 아니라 크롤이 도달을 못 하고 있었다.

주소부터 갈려 있었다. 내부 링크 13곳이 끝슬래시 없이 하드코딩돼 있었고, 렌더링 기준으로 세면 293회였다. 서버는 슬래시 없는 요청을 308로 슬래시 버전에 붙여준다. 그러니까 사이트 안에서 눌리는 링크가 전부 리디렉션을 한 번 거쳐 들어가고 있었다.

canonical과 og:url, 사이트맵은 이미 전부 슬래시였다. 링크만 체계에서 벗어나 있었다. 몇 달 전에 끝슬래시를 한쪽으로 통일한 적이 있는데, 그때 템플릿 문자열로 만들어지는 경로는 같이 고쳐졌고 고정 페이지로 하드코딩된 경로는 빠졌다. 한 번 통일했다고 통일된 상태가 유지되는 게 아니었다.

없어진 주소 수십 건이 404로 남아 있었다

git 이력을 보니 글 23편을 삭제하거나 이름을 바꿨는데 리디렉션이 걸린 건 3편뿐이었다. 라이브에서 확인하니 21개 주소가 404였다. 서치콘솔이 404를 1건만 잡은 건 나머지를 아직 재크롤하지 않았기 때문이고, 그대로 두면 숫자는 계속 오른다.

더 당황한 건 걸려 있다고 믿었던 3건이다. 빌드 설정 파일에 리디렉션 규칙을 적어뒀는데, 정적 빌드에서는 그게 301이 아니라 meta refresh가 박힌 HTML로 뽑혔다. 응답 코드는 200이다. 브라우저는 따라가니까 사람이 보기에는 리디렉션처럼 동작하지만, 링크 신호는 제대로 넘어가지 않는다. 설정 파일에 규칙이 적혀 있는 것만 보고 301이 나가고 있다고 생각한 것이다.

그래서 규칙을 호스팅이 읽는 리디렉션 파일 쪽으로 몰았다. 출발지 23개를 슬래시와 비슬래시 두 형태씩 46개 규칙으로 적었다. 대체할 글이 없는 5편은 404로 두지 않고 같은 묶음의 목록 페이지로 보냈다. 관련 없는 글로 보내면 soft 404로 잡힐 위험이 있지만, 같은 주제의 허브는 거기서 개별 글로 흐른다. 404로 방치하면 그동안 쌓은 신호를 그냥 버리는 셈이다.

빌드 설정 쪽 규칙은 제거했다. 두 방식을 같이 두면 정적 파일로 뽑힌 스텁 HTML이 먼저 잡혀서 301이 영영 안 나간다. 한쪽으로 몰아야 했다.

검증은 돌아가는 쪽으로 했다. 목적지 23개가 실제로 존재하는지, 출발지가 살아있는 페이지를 잡아먹지 않는지 교차로 확인했다. 슬래시 없는 내부 링크는 293회에서 0건이 됐다. 다만 실제 301 응답을 보는 건 배포 뒤에만 가능하다.

이 블로그에서는 리디렉션이 저장되지 않았다

이쪽은 이 블로그 얘기다. 얇은 글 17편을 5편으로 합쳤고, 없어진 옛 주소 16개를 새 글로 보내야 했다.

옛 주소는 전부 한글 슬러그였다. 게다가 길이 제한에 걸려 단어 중간에서 잘려 있었다. 손으로 옮기다 한 글자가 틀리면 그 규칙만 조용히 안 먹는다.

쓰고 있는 SEO 플러그인에 리디렉션 기능이 있어서 API로 16개를 넣었다. 응답은 200이었고 생성되었다는 메시지까지 돌아왔다. 그런데 응답에 붙은 id가 빈 문자열이었다. 관리자 화면 목록에도 한 건도 안 떴다.

플러그인에 버그가 있다고 단정하지는 않겠다. 내 환경에서는 저장되지 않았다는 것까지만 확실하다. 보여주고 싶은 화면이긴 한데 스크린샷은 올리지 않는다. 관리자 화면에는 도메인이 찍히고, 운영 중인 사이트를 이 시리즈에서 노출하지 않기로 해뒀다. 대신 손으로 등록할 목록을 파일로 만들어 뒀다. 목적지별로 묶어 규칙 6개로 줄여뒀는데 결국 쓰지 않았다.

워드프레스가 옛 주소를 기억한다

우회는 플러그인 밖에 있었다. 워드프레스는 글의 슬러그를 바꾸면 직전 슬러그를 글에 따로 적어두고, 그 주소로 요청이 오면 현재 주소로 301을 낸다. 리디렉션 규칙을 만들 필요가 없다는 뜻이다.

# 1. 합친 글의 슬러그를 옛 주소로 바꿔 저장
slug = "바이브코딩으로-게임-만들기-7-발사체-베이스-클래"
# 2. 워드프레스가 직전 슬러그를 글에 적어둔다
# 3. 원래 슬러그로 되돌려 저장
slug = "godot4-combat-projectile-hitbox"
# 이제 1의 주소로 들어오면 3의 주소로 301

한 글에 옛 주소가 여럿 붙는 경우는 이 과정을 주소 수만큼 반복했다. 그렇게 글 6편에 주소 16개를 붙였다.

301이 안 걸린 게 아니라 캐시가 옛 응답을 주고 있었다

여기서 한참 헤맸다. 다 붙여놓고 옛 주소를 열어봤는데 301이 안 나왔다.

슬러그를 잘못 넣었나 싶어 다시 확인했다. 한글이 퍼센트 인코딩된 형태로 들어가야 하나, 잘린 슬러그가 문제인가, 비공개로 돌린 원글이 아직 그 주소를 잡고 있나. 짚을 데는 다 짚었다.

원인은 캐시였다. 이 사이트는 정적 캐시 플러그인을 쓴다. 옛 주소에 대한 응답이 이미 캐시에 들어가 있었고, 요청은 워드프레스까지 가지 않고 캐시에서 되돌아왔다. 안에서 301을 만들어둔 것과 무관하게 바뀌기 전 응답이 계속 나오고 있었던 것이다.

캐시를 비우자 16건 전부 301이 됐다. 고친 건 아무것도 없다. 애초에 고칠 게 없었다.

그 뒤로 발행 절차에 캐시 비우기를 끼워 넣었다. 글을 올리거나 메타를 바꾼 다음 캐시를 비우지 않으면 확인 자체가 거짓말을 한다. 안전장치로는 합친 글마다 정규 주소를 고정해뒀다. 옛 주소가 어떤 경로로 긁히더라도 어느 쪽이 본문인지는 한 군데서만 선언되게 둔 것이다.

같은 집안의 함정이 하나 더 있었다

캐시와 같은 종류로 당한 게 하나 더 있다. 이 사이트의 타임존이 UTC다. 예약 발행 시각을 한국 시각으로 넘기면 저장된 값은 아홉 시간 이른 시각으로 보인다. 한국 시각 오전 아홉 시 예약은 자정으로 찍힌다.

처음엔 예약이 잘못 걸린 줄 알고 다시 넣었다. 실제 발행은 의도한 시각에 됐다. 값이 틀린 게 아니라 내가 보는 기준이 다른 것이었다.

캐시도 타임존도 같은 얘기다. 내가 넣은 값과 화면이 보여주는 값 사이에 변환이나 저장이 한 겹 끼어 있으면, 그 겹을 모르는 동안에는 확인이 확인이 아니다. 둘 다 운영 규칙을 적어둔 문서에 넣었다. 다음에 또 당하지 않으려면 기억이 아니라 문서에 있어야 한다.

정리하면

리다이렉션 작업에서 제일 오래 걸린 건 규칙을 만드는 일이 아니라 규칙이 실제로 동작하는지 확인하는 일이었다.

설정 파일에 리디렉션을 써뒀다고 301이 나가는 게 아니었다. 정적 빌드에서는 200짜리 HTML이 나왔다. API가 200과 성공 메시지를 돌려줬다고 저장된 것도 아니었다. 응답의 id가 비어 있었다. 반대로 301이 제대로 걸렸는데도 안 걸린 것처럼 보이는 경우가 있었다. 캐시가 옛 응답을 들고 있었다.

세 번 다 중간 신호를 결과로 믿은 게 문제였다. 설정 파일, 응답 코드, 브라우저에 뜬 화면은 전부 간접 신호다. 301은 응답 헤더를 직접 보는 것 말고 확인할 방법이 없고, 그마저도 캐시를 비운 뒤에 봐야 맞다.

끝슬래시 쪽에서 남은 것도 비슷하다. 한 번 통일했다고 통일된 게 아니었다. 템플릿으로 만들어지는 경로만 고쳐졌고 하드코딩된 경로는 몇 달 동안 그대로 남아 있었다. 규칙을 정하는 것과 규칙이 지켜지는 것은 다른 일이라, 지켜지는지 세어보는 검사가 따로 있어야 했다.