화면은 Not Found, 응답은 200이었습니다 — Next.js loading.tsx와 Soft 404
Next.js 16 App Router로 운영하는 이 블로그의 Search Console 색인 보고서에 Soft 404가 다섯 건 있었습니다. 모두 삭제했거나 없는 글의 주소였습니다. 요청해 보니 화면 제목만 "Not Found"일 뿐, 최종 응답은 다섯 주소 모두 200이었습니다.
결론부터 적자면 원인은 loading.tsx였습니다. 스트리밍이 시작되면 상태 코드 200이 먼저 나가기 때문에, 그 뒤에 호출한 notFound()는 화면만 바꿀 수 있었습니다. 이 글은 그 원리와, 글 상세만 스트리밍 바깥으로 옮겨 진짜 404를 돌려주게 된 과정입니다.
Soft 404는 화면과 응답이 다른 말을 하는 상태입니다
화면은 "없는 페이지"라고 말하는데 HTTP 응답은 정상이라고 말하는 상태를 Soft 404라고 부릅니다. 사람은 화면을 읽고 크롤러는 상태 코드를 읽으니, 눈으로는 구분되지 않습니다.
솔직히 급한 불은 아니었습니다. 스트리밍된 404 화면에는 Next.js가 noindex를 넣어 주기 때문에 색인될 일은 없었습니다. 다만 Search Console에는 오류로 남고 검증 대상이 됩니다. 상태 코드가 정확해야 보고서가 실제를 그대로 말해 줍니다.
Next.js App Router에서 loading.tsx가 있으면 상태 코드가 먼저 나갑니다
loading.tsx를 두면 그 구간의 페이지가 Suspense 경계로 감싸집니다. 데이터를 기다리는 동안 대체 화면(fallback)을 먼저 보여 주기 위해서인데, 이 대체 화면이 렌더되는 순간 응답 본문의 스트리밍이 시작됩니다.
HTTP 응답은 상태 코드와 헤더가 본문보다 먼저 나갑니다. 본문을 흘려보내려면 헤더부터 확정해 보내야 하고, 그 시점의 상태 코드는 200입니다. 그 뒤에 페이지가 글이 없다는 것을 알아내고 notFound()를 불러도 이미 보낸 헤더는 되돌릴 수 없습니다.
Node.js의 "Cannot set headers after they are sent"와 같은 제약인데, 여기서는 예외 대신 화면만 not-found로 바뀝니다. Next.js 공식 문서 loading.js의 Status Codes 절에 적힌 그대로입니다(16.3.5 버전 기준).
Because the response headers have already been sent to the client, the status code of the response cannot be updated.
문서는 이어서, 일부 크롤러는 이런 응답을 soft 404로 분류할 수 있다고 적습니다. 그리고 404 상태가 꼭 필요하다면 본문이 스트리밍되기 전에 리소스가 있는지 확인하고, notFound()는 Suspense 경계와 suspend할 수 있는 await보다 앞에 두라고 안내합니다.
원인은 하나 더 있었습니다. 메타데이터를 만드는 generateMetadata가 글이 없을 때 notFound()를 부르지 않고 제목만 "Not Found"로 돌려주고 있었습니다. 404를 알리는 대신 404처럼 보이게만 하고 있던 셈입니다.
글 상세만 스트리밍 바깥으로 옮겨 Soft 404를 없앴습니다
방향은 하나였습니다. 글이 있는지를 스트리밍이 시작되기 전에 결정해야 합니다.
문서가 제시하는 proxy(미들웨어) 선확인은 아무리 가볍게 만들어도 모든 요청에 조회가 하나씩 더 붙습니다. loading.tsx를 아예 지우면 목록을 비롯한 다른 화면의 로딩 경험까지 잃습니다. 결국 영향 범위가 가장 좁은 쪽을 골랐습니다. 글 상세 라우트만 loading.tsx가 없는 별도의 라우트 그룹으로 옮기는 것입니다.
주소는 그대로 두었고, 새 그룹의 레이아웃은 기존 레이아웃을 다시 내보내(re-export) 화면 구성이 달라지지 않게 했습니다. 단순화하면 이런 모양입니다(그룹 이름은 예시입니다).
app/
├─ (browse)/ ← loading.tsx가 있는 그룹
│ ├─ layout.tsx
│ ├─ loading.tsx
│ └─ blog/page.tsx ← 글 목록
└─ (article)/ ← 새 그룹, loading.tsx 없음
├─ layout.tsx ← (browse)의 layout을 re-export
└─ blog/[slug]/page.tsx ← 글 상세
이제 글 상세는 loading.tsx가 만들던 Suspense 경계 바깥에 있어, 글이 없다는 판정이 스트리밍 시작 전에 끝나고 서버가 진짜 404를 보낼 수 있습니다. generateMetadata에서도 글이 없으면 notFound()를 호출하도록 바꿨습니다.
없는 글과 일시 장애는 구분해야 합니다
404를 정확히 돌려주기 시작하면 반대쪽 위험이 생깁니다. API가 잠깐 응답하지 못한 것까지 "없는 글"로 처리하면 그 판정이 캐시되어 멀쩡한 글이 404로 굳을 수 있습니다. "없다"는 결과를 캐시하는 것을 네거티브 캐싱이라고 부르는데, 문제는 없다는 답 자체가 아니라 그 답이 틀렸을 때입니다. 그래서 404나 410을 받았을 때만 없는 글로 보고, 타임아웃이나 5xx는 예외로 올렸습니다.
async function getPost(slug: string) {
const res = await fetch(`${API_URL}/posts/${slug}`);
if (res.status === 404 || res.status === 410) return null; // 정말 없는 글
if (!res.ok) throw new Error(`API ${res.status}`); // 일시 장애는 예외로
return res.json();
}
// 페이지와 generateMetadata 양쪽에서
const post = await getPost(slug);
if (!post) notFound(); // 제목만 바꾸지 말고 여기서도 404
제목이 아니라 최종 상태 코드를 확인했습니다
볼 것은 화면이 아니라 리디렉션을 끝까지 따라간 뒤의 최종 상태 코드였습니다. 사람이 보는 응답과 크롤러가 받는 응답이 같다고 가정하지 않으려고, 조건을 바꿔 가며 같은 다섯 주소를 요청했습니다.
| 바꿔 본 조건 | 확인한 것 |
|---|---|
| 사용자 에이전트: 브라우저, Googlebot, Twitterbot | 셋 모두 최종 404인가 |
| 캐시: 비어 있을 때(cold), 채워져 있을 때(warm) | 새로 만든 응답도, 캐시에서 나간 응답도 404인가 |
| www나 언어 경로(/ko/)처럼 리디렉션을 거치는 주소 | 리디렉션 뒤의 최종 응답이 404인가 |
www 주소가 어떻게 정규화되는지는 예전에 Traefik으로 www 리디렉션을 설정한 글에 적어 두었습니다. 배포 후 다섯 주소 모두 최종 응답이 404가 되었고, Search Console에서 "수정 결과 확인"을 눌러 유효성 검사도 시작했습니다.
다만 시작됐다는 것이 통과했다는 뜻은 아닙니다. Google 안내로는 보통 2주 정도 걸리고 더 길어질 수도 있으니, 지금은 "검사가 시작됨" 상태입니다. 덧붙이자면 이 작업은 없는 글을 없다고 정확히 답하게 만드는 일이지, 삭제한 글을 검색 결과에 되살리려는 일이 아닙니다.
크롤러가 읽는 응답 두 가지도 함께 고쳤습니다
하나는 robots.txt에서 /_next/가 차단되어 있던 것입니다. Next.js의 정적 JS와 CSS가 이 경로로 나가니, 렌더링에 필요한 리소스를 제 손으로 막고 있던 셈입니다. Google의 검색 개발자 가이드도 중요한 리소스가 막혀 있으면 페이지를 제대로 크롤링하지 못할 수 있다고 안내합니다. /_next/만 풀고 나머지 차단은 그대로 두었으며, Googlebot으로 JS·CSS가 200인 것을 확인했습니다.
다른 하나는 구조화 데이터입니다. 글 페이지 BlogPosting의 발행자 로고(publisher.logo)가 없는 이미지를 가리키고 있어, 실제로 있는 정사각형 아이콘으로 바꾸고 이미지가 200인지, 크기가 선언한 값과 맞는지, 글 HTML이 새 주소를 가리키는지 확인했습니다.
사람은 화면을 읽고, 크롤러는 상태 코드를 읽습니다
돌이켜 보면 세 문제는 닮아 있었습니다. 사람이 보기에는 셋 다 아무 문제가 없었고, 어긋난 것은 전부 크롤러가 읽는 쪽이었습니다. 그래서 고쳐졌는지도 실제로 요청을 보내 응답을 확인해야만 알 수 있었습니다. notFound()라는 이름은 404를 약속하는 것처럼 보이지만, 그 약속은 헤더가 아직 나가기 전에만 지킬 수 있었다는 생각이 들었습니다.
Next.js App Router로 블로그를 운영한다면 Soft 404가 생길 자리를 한 번 점검해 볼 만합니다.
loading.tsx아래에서notFound()를 부르는 동적 경로. 태그·카테고리·시리즈처럼 slug로 찾는 페이지도 같은 점검 대상입니다.generateMetadata가 제목만 바꿔 돌려주고 있지는 않은지- "없음" 판정이 404·410으로 좁혀져 있는지
- 확인은 화면 제목이 아니라 최종 상태 코드로. 사용자 에이전트와 캐시 상태, 리디렉션을 바꿔 가며
- robots.txt가 막는 경로와 구조화 데이터가 가리키는 주소가 실제로 열리는지
이번 작업에서는 사이트맵과 ISR 재검증도 함께 손봤고, Search Console과 GA4를 읽으며 "접수"와 "완료"를 구분하는 일도 있었습니다. 그 이야기는 이어지는 글에서 따로 적겠습니다. 이 블로그가 ISR을 쓰게 된 배경은 예전 글에 있습니다.
관련 글
내가 Next.js ISR을 선택한 이유: 블로그 SEO, 그 고민의 시작과 해결
Next.js ISR을 선택하여 블로그 SEO 문제를 해결하는 방법을 알아보세요. React CSR의 한계를 극복하고, 검색 엔진 최적화와 소셜 미리보기를 완벽 지원하는 ISR의 핵심 원리를 소개합니다.
Next.js + NestJS로 광고 차단 우회 조회수 카운터 만들기 (2편)
first-party 엔드포인트 하나로 광고 차단기를 우회하는 조회수 API를 만들었습니다. Next.js BlogViewTracker 확장, NestJS ViewsModule의 isbot·$inc·24시간 디바운스, view_logs 컬렉션 설계까지 정리했습니다.
Next.js 배포 시 빈 캐시 문제 해결: 런타임 워밍에서 빌드 타임 정적 생성으로
Next.js + Coolify 환경에서 배포 직후 빈 캐시와 no available server 에러가 발생하는 문제를 해결한 경험. 런타임 캐시 워밍의 한계를 겪고, 빌드 타임 정적 생성으로 전환하여 근본적으로 해결했습니다.