한 줄 요약404.html 이 없으면 어떤 정적 호스팅은 사이트를 싱글 페이지 앱으로 오인하고, 존재하지 않는 모든 주소에 index.html200 OK 로 돌려준다. 지운 페이지가 전부 살아 있는 것처럼 보이게 된다.

지운 글이 아직 열렸다

이 사이트에는 예전에 정리한 글이 여럿 있다. 원본을 지우고 사이트맵도 실제 파일만 훑도록 다시 만들었으니 끝난 줄 알았다. 그런데 옛 주소를 주소창에 그대로 쳐 봤더니 404 가 아니라 화면이 정상적으로 떴다. 심지어 존재할 리 없는 확장자를 붙여도 마찬가지였다.

$ curl -sI https://www.lysc.kr/blog/posts/post-65.html | head -1
HTTP/2 200

$ curl -sI https://www.lysc.kr/this-file-does-not-exist.bat | head -1
HTTP/2 200

둘 다 200 이고, 내용은 홈페이지 전체였다. 주소만 다르고 본문은 같은 페이지가 무한히 만들어지는 상태다.

호스팅이 프로젝트 성격을 추측한다

원인은 코드가 아니라 배포 쪽에 있었다. 이 사이트를 서비스하는 Cloudflare Pages 는 배포된 파일 목록을 보고 어떤 종류의 프로젝트인지 스스로 판단한다. 공식 문서에 그대로 적혀 있다.

Cloudflare 문서"Pages would automatically attempt to determine the type of project you deployed. It would look for 404.html and index.html files as signals for whether the project was likely a Single Page Application (SPA) or if it should serve custom 404 pages."*

404.html 이 있으면 "여러 페이지짜리 사이트"로 보고 없는 주소에 404 를 준다. 없으면 "싱글 페이지 앱"으로 보고 index.html 을 200 으로 준다. 리액트 같은 SPA 에서는 이게 맞는 동작이다. 라우팅을 브라우저가 하니까 서버는 무조건 진입점을 돌려줘야 한다.

문제는 이 사이트가 SPA 가 아니라는 것이다. 페이지마다 HTML 파일이 따로 있는 구조인데, 우연히 404.html 이 없어서 SPA 로 분류되어 있었다.

왜 이게 최악인가

단순히 "404 대신 홈이 뜬다" 로 끝나지 않는다. 크롤러 입장에서 무슨 일이 벌어지는지 보면 심각해진다.

기대한 동작실제 동작
지운 글 주소 → 404 → 색인에서 제거지운 글 주소 → 200정상 페이지로 색인 유지
오타 주소 → 404오타 주소 → 200 + 홈 내용
사이트 = 실제 페이지 수만큼사이트 = 무한히 많은 중복 페이지

검색 엔진은 이런 페이지를 soft 404 로 분류한다. 서버는 정상이라는데 내용은 요청한 것과 무관한 경우다. 더 나쁜 건 지운 글이 많을수록 같은 본문을 가진 주소가 그 수만큼 생긴다는 점이다. 대량 중복 콘텐츠로 읽힌다.

검색 콘솔에서 옛 주소를 삭제 요청해도 소용이 없다. 삭제해도 그 주소가 여전히 200 을 돌려주니 다시 색인될 뿐이다. 순서가 반대였다. 먼저 404 를 주게 만들고, 그 다음에 삭제를 요청해야 한다.

해결은 파일 하나

레포 루트에 404.html 을 만들어 두는 것으로 끝났다. 빌드 설정도, 대시보드도 건드리지 않았다. 파일이 존재한다는 사실 자체가 신호이기 때문이다.

lysc.kr 의 404 페이지
만들어 둔 404 페이지. 사이트 디자인을 그대로 쓰고, 홈·개발 일지·게임으로 가는 길을 열어 뒀다.

만들면서 두 가지를 의도적으로 다르게 했다.

덤으로 알게 된 것: 배포 제외 목록이 없다

확인하는 김에 다른 주소도 쳐 봤다. /README.md 를 열었더니 레포의 README 가 그대로 나왔다. 빌드 스크립트와 글 원본 JSON 을 사이트와 같은 저장소에 두고 있었는데, 그게 전부 공개되고 있었다.

_ 로 시작하는 폴더는 배포에서 빠진다고 알고 있었던 게 원인이다. 그건 Jekyll 을 거치는 호스팅의 동작이지 일반 규칙이 아니다. 정적 파일을 그대로 올리는 호스팅에서는 이름이 무엇이든 그대로 올라간다.

robots.txt 는 차단이 아니다Disallow 를 적어 뒀으니 괜찮다고 생각했는데, 그건 크롤러에게 하는 부탁이지 접근 차단이 아니다. 주소를 아는 사람은 그냥 열 수 있다. 확인하는 유일한 방법은 브라우저에 직접 쳐 보는 것이었다.

제외 목록을 지정하는 기능이 아예 없어서 — 자동으로 빠지는 건 node_modules 정도다 — 빌드 명령에서 지우는 방법을 썼다. 배포용 복제본에서만 지워지므로 저장소는 그대로다.

rm -rf _content _tools _docs _trash README.md build_all.bat preview.bat firestore.rules CNAME .gitignore

정리

3편에서 손으로 HTML 을 고치던 걸 그만뒀고, 이번에는 배포가 무엇을 하고 있는지 확인하지 않고 있었다는 걸 알았다. 자동화를 붙였다고 끝이 아니라, 그 자동화가 실제로 무엇을 내보내는지는 따로 봐야 했다.