404.html 이 없으면 어떤 정적 호스팅은 사이트를 싱글 페이지 앱으로 오인하고, 존재하지 않는 모든 주소에 index.html 을 200 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 는 배포된 파일 목록을 보고 어떤 종류의 프로젝트인지 스스로 판단한다. 공식 문서에 그대로 적혀 있다.
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 을 만들어 두는 것으로 끝났다. 빌드 설정도, 대시보드도 건드리지 않았다. 파일이 존재한다는 사실 자체가 신호이기 때문이다.

만들면서 두 가지를 의도적으로 다르게 했다.
- 광고 스크립트를 넣지 않았다. 오류 페이지에 광고를 싣는 것은 대부분의 광고 정책에서 금지된다. 내용이 없는 화면에 광고만 뜨는 상황을 막기 위한 조항이다.
noindex, follow를 걸었다. 404 페이지 자체가 검색 결과에 뜰 이유는 없지만, 그 안의 링크는 따라가도 괜찮다.
덤으로 알게 된 것: 배포 제외 목록이 없다
확인하는 김에 다른 주소도 쳐 봤다. /README.md 를 열었더니 레포의 README 가 그대로 나왔다. 빌드 스크립트와 글 원본 JSON 을 사이트와 같은 저장소에 두고 있었는데, 그게 전부 공개되고 있었다.
_ 로 시작하는 폴더는 배포에서 빠진다고 알고 있었던 게 원인이다. 그건 Jekyll 을 거치는 호스팅의 동작이지 일반 규칙이 아니다. 정적 파일을 그대로 올리는 호스팅에서는 이름이 무엇이든 그대로 올라간다.
Disallow 를 적어 뒀으니 괜찮다고 생각했는데, 그건 크롤러에게 하는 부탁이지 접근 차단이 아니다. 주소를 아는 사람은 그냥 열 수 있다. 확인하는 유일한 방법은 브라우저에 직접 쳐 보는 것이었다.제외 목록을 지정하는 기능이 아예 없어서 — 자동으로 빠지는 건 node_modules 정도다 — 빌드 명령에서 지우는 방법을 썼다. 배포용 복제본에서만 지워지므로 저장소는 그대로다.
rm -rf _content _tools _docs _trash README.md build_all.bat preview.bat firestore.rules CNAME .gitignore
정리
- 정적 호스팅은 파일의 존재 여부로 사이트 성격을 추측한다.
404.html하나가 있고 없고에 따라 없는 주소의 응답이 200 과 404 로 갈린다. - soft 404 는 색인에서 지우려는 노력을 통째로 무력화한다. 삭제 요청보다 404 를 주는 게 먼저다.
- 배포에서 무엇이 빠지는지는 호스팅마다 다르다. 옛 호스팅의 상식을 그대로 들고 오면 조용히 틀린다.
- 확인은 결국 주소창에 직접 쳐 보는 것이다. 설정 화면과 문서를 아무리 읽어도, 실제 응답을 본 것과는 다르다.
3편에서 손으로 HTML 을 고치던 걸 그만뒀고, 이번에는 배포가 무엇을 하고 있는지 확인하지 않고 있었다는 걸 알았다. 자동화를 붙였다고 끝이 아니라, 그 자동화가 실제로 무엇을 내보내는지는 따로 봐야 했다.