당신의 사이트가 사람 눈에는 멀쩡하다. 필터를 바꾸면 지도가 따라 움직이고, 없는 글을 열면 「찾을 수 없습니다」가 뜨고, 홈은 한국어로 잘 열린다. 그런데 검색 유입은 몇 달째 바닥이고, 분석 도구의 페이지뷰는 이상하게 부풀어 있다. 화면을 아무리 들여다봐도 고장 난 곳이 없다.
검색엔진과 분석 도구는 무엇을 읽나
검색엔진과 분석 도구는 화면을 보지 않고 주소(URL), HTTP 상태 코드, 리다이렉트의 종류, 브라우저 기록 변경을 글자 그대로 센다. 사람에게는 같은 화면이 기계에게는 수만 개의 서로 다른 페이지일 수 있고, 사람에게 「없음」인 화면이 기계에게는 「정상」일 수 있다. 그래서 화면을 맞게 만드는 일과 기계에게 맞는 신호를 보내는 일은 따로 해야 하고, 따로 확인해야 한다.
이 차이는 조용하다. 오류가 나지 않고, 화면에도 안 보이고, 결과(색인 누락, 크롤 낭비, 부푼 지표)는 몇 주에서 몇 달 뒤에야 숫자로 나타난다.

화면 상태를 쿼리스트링에 담으면 무엇이 새나
지도 좌표·확대 단계·필터를 ?lat=…&zoom=… 같은 쿼리스트링으로 링크하면, 사람에게는 같은 지도지만 검색엔진에게는 조합 수만큼의 고유 URL이 생긴다. 검색엔진은 그 URL들을 하나씩 크롤하느라 크롤 예산을 쓰고, 정작 새로 올린 콘텐츠는 늦게 온다.
- 서버가 알 필요 없는 화면 상태는 해시(
#lat=…)에 싣는다. 해시는 서버로 가지 않아 URL이 하나로 남는다 - 상태를 쿼리로 둬야 한다면 그 페이지에 canonical을 두어 대표 주소를 하나로 밝힌다
- 새 페이지 유형을 만들 때마다 canonical을 확인 목록에 넣는다
언어별 주소로 보낼 때 왜 307이 아니라 308인가
루트 / 를 기본 언어 /ko 로 임시(302·307) 리다이렉트하면, 검색엔진은 임시 이동을 「원래 주소는 / 다」로 읽는다. 그래서 / 를 대표 URL로 고르고 실제 내용이 있는 /ko 는 색인하지 않을 수 있다. 구글 문서는 영구 리다이렉트(301·308)를 「대상이 대표 주소다」라는 강한 신호로, 임시 리다이렉트를 약한 신호로 다룬다고 적는다.
- 언어 없는 경로에서 언어 경로로 늘 보낸다면 영구(308)로 보낸다
- hreflang(언어별 대응 주소 표시)은 실제로 열리는 언어만 적고
x-default를 넣는다. 없는 언어 주소가 404면 신호 묶음 전체가 흔들린다 - 사이트맵의
lastmod를 빌드 시각으로 찍지 않는다. 매일 모든 쪽이 바뀐 사이트가 되어 그 신호가 무의미해진다
「찾을 수 없음」 화면이 왜 200을 보내나
스트리밍 렌더링(먼저 껍데기를 보내고 내용을 나중에 채우는 방식)에서는 응답 헤더가 첫 바이트와 함께 이미 나간다. 그 뒤 데이터를 조회하다 「없음」을 알게 되면, 화면에는 404 안내를 그릴 수 있어도 상태 코드는 이미 200으로 확정돼 있다. 검색엔진은 이것을 정상 페이지로 보거나 소프트 404로 분류하고, 지운 글·태그의 옛 주소를 색인에 오래 남긴다. Next.js App Router의 loading.tsx 경계 안에서 notFound() 를 부를 때가 대표적인 예다(Next.js 문서의 스트리밍과 상태 코드 설명).
- 지워서 비는 주소는 상위 목록으로 영구 리다이렉트(308)하는 쪽이 신호가 가장 분명하다
- 진짜 404가 필요하면 「없음」 판정을 스트리밍 경계 밖, 첫 바이트 전에 끝낸다
noindex만 믿지 않는다. 상태 코드를curl -I로 직접 확인한다
주소만 바꿨는데 왜 페이지뷰가 늘어나나
입력값을 URL에 실시간으로 반영하는 계산기에서 history.replaceState 를 부르면, GA4의 향상된 측정 중 「브라우저 기록 이벤트에 따른 페이지 변경」이 그 호출을 페이지뷰로 센다. 사람은 같은 화면에서 숫자를 고쳤을 뿐인데 지표에는 입력 한 글자마다 페이지뷰가 하나씩 쌓인다.
- 분석 콘솔에서 그 항목을 끄거나
- 코드에서 막으려면, 분석 스크립트가
history를 감싸기 전(모듈 로드 시점)의history.replaceState를.bind(history)로 잡아 두고 조용한 주소 갱신에는 그것만 쓴다 - 끝났다고 말하기 전에 실제 전송을 캡처해 「입력하는 동안 page_view 0건」을 확인한다
화면을 만들 때 쓰는 확인 목록
새 화면이나 라우팅 변경을 내보내기 전에, 사람이 아니라 기계의 눈으로 한 번 더 본다.
- 이 화면의 상태 변화가 새 URL을 만드나? 만들면 그 URL들은 canonical로 하나로 묶였나?
- 리다이렉트는 영구인가 임시인가? 늘 그쪽으로 보낸다면 308인가?
- 「없음」 화면의 상태 코드는 실제로 404인가(혹은 308인가)?
curl -I로 확인했나? - 주소를 바꾸는 코드가 분석 도구에 페이지뷰로 잡히나? 실제 전송을 캡처했나?
- 고친 효과를 검색 콘솔 숫자로 판정한다면, 그 숫자가 언제 긁은 결과인지(마지막 크롤 시각) 먼저 봤나?
에이전트에게 맡길 때는 이렇게 적어 둘 수 있다.
라우팅·리다이렉트·URL 갱신을 바꿨으면, 화면이 아니라 신호로 검증한다:
- 바뀐 경로마다 `curl -I` 로 상태 코드와 Location 을 적는다(404/308/200 이 의도와 같은가)
- 화면 상태가 쿼리스트링을 만들면 canonical 이 있는지 확인한다
- history.pushState/replaceState 를 부르면 분석 전송을 캡처해 page_view 수를 적는다
화면이 맞다는 것만으로 "끝났다"고 보고하지 않는다.
우리가 겪은 일
- 크롤 낭비: 한 지역 정보 서비스에서 지도 상태를 쿼리스트링으로 링크했더니 검색 콘솔에 「크롤링됨 - 현재 색인이 생성되지 않음」이 약 1.5만 건 쌓였다. URL 목록을 내려받아 보고서야 지도 좌표 조합이 원인임을 알았다.
- 임시 리다이렉트: 두 언어 사이트가
/를 307로 기본 언어 경로에 보내는 동안 6개월간 검색 클릭이 1회였다. 영구 리다이렉트와 hreflang 정리로 바꿨다. - 200짜리 404: 지운 항목의 주소가 검색 결과에 남아 브랜드명 검색에서 「없음」 화면이 떴다. 응답 상태 코드를 보니 200이었다 — 스트리밍 경계 안에서 「없음」을 판정하고 있었다.
- 부푼 페이지뷰: 입력마다 주소를 갱신하는 계산기에서 한 글자가 페이지뷰 하나였다. 원본
replaceState를 잡아 쓴 뒤 실제 전송 캡처로 세 글자 입력 동안 page_view 0건을 확인했다. - 덧붙여, 고친 뒤 검색 콘솔 숫자가 안 움직여 「효과 없음」으로 적을 뻔했는데, 그 숫자는 구글이 아직 다시 오지 않은 상태였다. URL 검사의 마지막 크롤 시각을 먼저 봐야 했다.
네 경우 모두 화면은 처음부터 맞았다. 틀린 것은 기계에게 간 신호였고, 그것은 화면을 보는 검사로는 한 번도 잡히지 않았다.
이전 글반드시 끝나야 하는 일은 화면 박자에 매지 말고, 벽시계와 상한에 맨다