404.html 이 없으면 어떤 정적 호스팅은 사이트를 싱글 페이지 앱으로 오인하고, 존재하지 않는 모든 주소에 index.html 을 200 OK 로 돌려준다. 지운 페이지가 전부 살아 있는 것처럼 보이게 된다.404.html file at the root, some static hosts assume the site is a Single Page Application (SPA) and serve index.html with 200 OK for every unknown URL—making deleted pages appear alive to crawlers.지운 글이 아직 열렸다Deleted Posts Were Still Opening
이 사이트에는 예전에 정리한 글이 여럿 있다. 원본을 지우고 사이트맵도 실제 파일만 훑도록 다시 만들었으니 끝난 줄 알았다. 그런데 옛 주소를 주소창에 그대로 쳐 봤더니 404 가 아니라 화면이 정상적으로 떴다. 심지어 존재할 리 없는 확장자를 붙여도 마찬가지였다.
This site had several old posts that I had archived and deleted. Since I had removed the HTML files and updated the sitemap generator to scan only existing files on disk, I assumed cleanup was done. Then I typed one of the old URLs into the browser address bar—and instead of a 404, a full page loaded. Appending a nonsense file extension did the exact same thing.
$ 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 이고, 내용은 홈페이지 전체였다. 주소만 다르고 본문은 같은 페이지가 무한히 만들어지는 상태다.
Both returned 200, and the response body was the entire home page. Every non-existent URL was generating a duplicate copy of the home page.
호스팅이 프로젝트 성격을 추측한다Static Hosting Infers Your Project Type
원인은 코드가 아니라 배포 쪽에 있었다. 이 사이트를 서비스하는 Cloudflare Pages 는 배포된 파일 목록을 보고 어떤 종류의 프로젝트인지 스스로 판단한다. 공식 문서에 그대로 적혀 있다.
The cause wasn't in my code; it was in how deployment worked. Cloudflare Pages inspects the deployed file tree and infers what kind of project you deployed. The official documentation states this directly:
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 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 에서는 이게 맞는 동작이다. 라우팅을 브라우저가 하니까 서버는 무조건 진입점을 돌려줘야 한다.
In short: if 404.html exists, Cloudflare treats the project as a multi-page static site and returns 404 for missing URLs. If 404.html is absent, it assumes the project is an SPA and serves index.html with 200 OK. For a React SPA where client-side routing handles URLs in the browser, that behavior makes sense.
문제는 이 사이트가 SPA 가 아니라는 것이다. 페이지마다 HTML 파일이 따로 있는 구조인데, 우연히 404.html 이 없어서 SPA 로 분류되어 있었다.
The problem is that this site is not an SPA. Every page has its own static HTML file, but because I hadn't created a 404.html, the host classified the site as an SPA.
왜 이게 최악인가Why This Hurts Search Indexing
단순히 "404 대신 홈이 뜬다" 로 끝나지 않는다. 크롤러 입장에서 무슨 일이 벌어지는지 보면 심각해진다.
This isn't just a cosmetic issue where visitors see the home page instead of an error screen. From a search crawler's perspective, the consequences are much worse.
| 기대한 동작 | 실제 동작 |
|---|---|
지운 글 주소 → 404 → 색인에서 제거 | 지운 글 주소 → 200 → 정상 페이지로 색인 유지 |
오타 주소 → 404 | 오타 주소 → 200 + 홈 내용 |
| 사이트 = 실제 페이지 수만큼 | 사이트 = 무한히 많은 중복 페이지 |
| Expected Behavior | Actual Behavior |
|---|---|
Deleted post URL → 404 → dropped from index | Deleted post URL → 200 → kept in index as a valid page |
Typo URL → 404 | Typo URL → 200 + home page content |
| Site size = actual page count | Site size = unbounded duplicate pages |
검색 엔진은 이런 페이지를 soft 404 로 분류한다. 서버는 정상이라는데 내용은 요청한 것과 무관한 경우다. 더 나쁜 건 지운 글이 많을수록 같은 본문을 가진 주소가 그 수만큼 생긴다는 점이다. 대량 중복 콘텐츠로 읽힌다.
Search engines classify these responses as soft 404s—the server claims 200 OK, but the content has nothing to do with the requested URL. Worse, every deleted post URL now serves identical content to the home page, looking like mass duplicate content.
검색 콘솔에서 옛 주소를 삭제 요청해도 소용이 없다. 삭제해도 그 주소가 여전히 200 을 돌려주니 다시 색인될 뿐이다. 순서가 반대였다. 먼저 404 를 주게 만들고, 그 다음에 삭제를 요청해야 한다.
Submitting URL removal requests in Google Search Console does not stick while the server still returns 200. As soon as the crawler re-visits the URL, it indexes it again. I had the order backward: the server must return 404 first, and only then should removal requests be submitted.
해결은 파일 하나The Fix Was a Single File
레포 루트에 404.html 을 만들어 두는 것으로 끝났다. 빌드 설정도, 대시보드도 건드리지 않았다. 파일이 존재한다는 사실 자체가 신호이기 때문이다.
Adding a 404.html file to the repository root solved the problem immediately. I didn't need to touch build settings or dashboard rules—the presence of the file itself is the signal.

만들면서 두 가지를 의도적으로 다르게 했다.
While building 404.html, I made two deliberate choices compared to regular pages:
- 광고 스크립트를 넣지 않았다. 오류 페이지에 광고를 싣는 것은 대부분의 광고 정책에서 금지된다. 내용이 없는 화면에 광고만 뜨는 상황을 막기 위한 조항이다.
noindex, follow를 걸었다. 404 페이지 자체가 검색 결과에 뜰 이유는 없지만, 그 안의 링크는 따라가도 괜찮다.
- Excluded all ad scripts. Serving ads on error pages violates most ad network policies, which prohibit placing ads on screens with no publisher content.
- Set
noindex, follow. The 404 page itself should never appear in search results, but crawlers should still follow the navigation links back into the site.
덤으로 알게 된 것: 배포 제외 목록이 없다Bonus Discovery: There Is No Deploy Ignore List
확인하는 김에 다른 주소도 쳐 봤다. /README.md 를 열었더니 레포의 README 가 그대로 나왔다. 빌드 스크립트와 글 원본 JSON 을 사이트와 같은 저장소에 두고 있었는데, 그게 전부 공개되고 있었다.
While testing URLs in the address bar, I also tried /README.md—and the repository's raw README appeared in the browser. I kept my Python build scripts and raw JSON content in the same repository as the static site, and all of them were being published publicly.
_ 로 시작하는 폴더는 배포에서 빠진다고 알고 있었던 게 원인이다. 그건 Jekyll 을 거치는 호스팅의 동작이지 일반 규칙이 아니다. 정적 파일을 그대로 올리는 호스팅에서는 이름이 무엇이든 그대로 올라간다.
I had assumed folders starting with _ were automatically excluded from deployment. Again, that is a Jekyll behavior, not a general static hosting rule. Hosts that deploy a directory as-is publish every file regardless of prefix.
Disallow 를 적어 뒀으니 괜찮다고 생각했는데, 그건 크롤러에게 하는 부탁이지 접근 차단이 아니다. 주소를 아는 사람은 그냥 열 수 있다. 확인하는 유일한 방법은 브라우저에 직접 쳐 보는 것이었다.Disallow in robots.txt, but that is only a request to well-behaved crawlers, not access blocking. Anyone who knows the path can still open it. Checking directly in the browser address bar was the only way to catch it.제외 목록을 지정하는 기능이 아예 없어서 — 자동으로 빠지는 건 node_modules 정도다 — 빌드 명령에서 지우는 방법을 썼다. 배포용 복제본에서만 지워지므로 저장소는 그대로다.
Because the deploy environment only strips node_modules and .git by default, I added a cleanup step to the Cloudflare build command so non-public folders are removed from the deploy artifact while remaining untouched in Git.
rm -rf _content _tools _docs _trash README.md build_all.bat preview.bat firestore.rules CNAME .gitignore
정리Takeaways
- 정적 호스팅은 파일의 존재 여부로 사이트 성격을 추측한다.
404.html하나가 있고 없고에 따라 없는 주소의 응답이 200 과 404 로 갈린다. - soft 404 는 색인에서 지우려는 노력을 통째로 무력화한다. 삭제 요청보다 404 를 주는 게 먼저다.
- 배포에서 무엇이 빠지는지는 호스팅마다 다르다. 옛 호스팅의 상식을 그대로 들고 오면 조용히 틀린다.
- 확인은 결국 주소창에 직접 쳐 보는 것이다. 설정 화면과 문서를 아무리 읽어도, 실제 응답을 본 것과는 다르다.
- Static hosts infer site architecture from which files exist. Whether
404.htmlis present determines whether unknown URLs return200or404. - Soft 404s undo index cleanup. Make the server return a real
404before requesting URL removals in Search Console. - What gets excluded during deployment depends on the hosting provider. Carrying over assumptions from Jekyll-based hosts leads to quiet leaks.
- The final check is always typing URLs into the address bar and checking HTTP headers. Reading docs and dashboard settings does not replace inspecting live responses.
3편에서 손으로 HTML 을 고치던 걸 그만뒀고, 이번에는 배포가 무엇을 하고 있는지 확인하지 않고 있었다는 걸 알았다. 자동화를 붙였다고 끝이 아니라, 그 자동화가 실제로 무엇을 내보내는지는 따로 봐야 했다.
In Part 3, I stopped editing HTML by hand; in Part 4, I learned that I hadn't actually checked what the deployment pipeline was serving. Automating the build is only half the job—you also have to verify what the host exposes.