한 줄 요약404.html 이 없으면 어떤 정적 호스팅은 사이트를 싱글 페이지 앱으로 오인하고, 존재하지 않는 모든 주소에 index.html 을 200 OK 로 돌려준다. 지운 페이지가 전부 살아 있는 것처럼 보이게 된다.
TL;DRWithout a 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:

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."
Cloudflare Docs"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 에서는 이게 맞는 동작이다. 라우팅을 브라우저가 하니까 서버는 무조건 진입점을 돌려줘야 한다.

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 + 홈 내용
사이트 = 실제 페이지 수만큼사이트 = 무한히 많은 중복 페이지
Swipe horizontally to view the full table
Expected BehaviorActual Behavior
Deleted post URL → 404 → dropped from indexDeleted post URL → 200 → kept in index as a valid page
Typo URL → 404Typo URL → 200 + home page content
Site size = actual page countSite 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.

lysc.kr 의 404 페이지
만들어 둔 404 페이지. 사이트 디자인을 그대로 쓰고, 홈·개발 일지·게임으로 가는 길을 열어 뒀다.The custom 404 page. It uses the site's Neo-Brutalist theme and links back to Home, Devlog, and Games.

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

While building 404.html, I made two deliberate choices compared to regular pages:

덤으로 알게 된 것: 배포 제외 목록이 없다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.

robots.txt 는 차단이 아니다Disallow 를 적어 뒀으니 괜찮다고 생각했는데, 그건 크롤러에게 하는 부탁이지 접근 차단이 아니다. 주소를 아는 사람은 그냥 열 수 있다. 확인하는 유일한 방법은 브라우저에 직접 쳐 보는 것이었다.
`robots.txt` Is Not Access ControlI had listed those directories under 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

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.