React Suspense와 스트리밍, 그리고 404 상태 코드
이 블로그의 카테고리 페이지는 없는 카테고리 주소로 들어오면 「찾을 수 없음」 화면을 보여 줍니다. 코드에는 분명히 notFound() 가 있었습니다. 그런데 응답 상태 코드를 찍어 보니 이렇게 나왔습니다.
/blog/page/999 404
/blog/category/없는카테고리 200
/blog/tags/없는태그 200같은 notFound() 인데 한쪽은 404, 다른 두 곳은 200 이었습니다. 차이는 폴더에 있던 loading.tsx 파일 하나였습니다. 이 파일이 무엇을 하는지 따라가면 Suspense 에 닿습니다.
Suspense 는 로딩 스피너를 띄우는 컴포넌트라는 생각
<Suspense fallback={<Spinner />}> 를 처음 보면 「데이터를 불러오는 동안 스피너를 보여 주는 편의 컴포넌트」로 이해하기 쉽습니다. Next.js 의 loading.tsx 도 「로딩 화면을 만드는 파일」로 받아들입니다. 둘 다 화면에 무엇을 보여 줄지의 문제로 보입니다.
이 이해로는 두 가지가 설명되지 않습니다. 컴포넌트 안에서 useEffect 로 데이터를 불러와도 Suspense 의 fallback 이 뜨지 않는 이유, 그리고 로딩 화면 파일 하나가 HTTP 상태 코드를 바꾼 이유입니다. Suspense 는 스피너가 아니라 렌더링을 기다리는 경계이고, 서버에서는 응답을 나눠 보내는 경계이기도 합니다.
무엇이 Suspense 를 작동시키는가
Suspense 문서의 주의 사항 첫 줄은 이렇습니다.
Suspense does not detect when data is fetched inside an Effect or event handler.
Suspense 는 「무언가 로딩 중이다」를 스스로 알아채지 못합니다. 렌더링 도중에 컴포넌트가 아직 준비되지 않았다고 알릴 때만 작동합니다. 문서가 꼽는 경우는 lazy 로 컴포넌트 코드를 늦게 불러올 때, use 로 Promise 를 읽을 때(서버 컴포넌트에서 스트리밍되는 데이터나 Suspense 를 지원하는 프레임워크 포함), 우선순위가 지정된 스타일시트를 기다릴 때, 스트리밍 서버 렌더링에서 큰 경계의 HTML 이 아직 도착하지 않았을 때입니다.
useEffect 안의 fetch 는 렌더링이 끝난 뒤에 실행됩니다. 렌더링은 이미 「준비됐다」로 끝났으니 Suspense 가 끼어들 자리가 없습니다. 그 경우의 로딩 표시는 컴포넌트가 state 로 직접 관리해야 합니다. Suspense 를 쓰려면 데이터를 읽는 방식 자체가 렌더링 중에 「기다린다」고 말할 수 있어야 합니다.
경계 안은 한 단위로 함께 나타난다
Suspense 가 작동하면 React 는 그 경계 안의 트리를 통째로 fallback 으로 바꿉니다. 문서의 설명입니다.
By default, the whole tree inside Suspense is treated as a single unit.
경계 안에 컴포넌트가 셋 있고 그중 하나만 데이터를 기다려도, 셋 모두 fallback 에 가려졌다가 함께 나타납니다. 조금씩 따로 튀어나오는 화면보다 한 번에 나타나는 편이 자연스러운 곳이라면 이 동작이 맞습니다.
단계적으로 보여 주고 싶으면 경계를 중첩합니다.
<Suspense fallback={<PageSkeleton />}>
<ArticleHeader />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</Suspense>제목이 준비되면 바깥 경계가 열리고, 댓글은 안쪽 경계에서 자기 차례를 기다립니다. 어디에 경계를 두느냐가 곧 「무엇을 함께 보여 줄 것인가」의 설계입니다. 스피너 모양보다 이 결정이 화면 경험에 더 크게 영향을 준다고 봅니다.
이미 보이던 화면을 가리지 않는 방법
경계가 한 번 열린 뒤에 다시 기다릴 일이 생기면 어떻게 될까요. 탭을 바꿔 새 데이터를 불러오는 경우입니다. 문서에 따르면 기본적으로는 fallback 이 다시 나타나 이미 보던 내용을 가립니다. 방금까지 읽던 화면이 스켈레톤으로 바뀌는 셈입니다.
이 경우를 위해 문서는 예외를 둡니다. 그 업데이트가 startTransition 이나 useDeferredValue 로 일어났다면 React 는 새 내용이 준비될 때까지 이전 화면을 그대로 보여 줍니다. 같은 문서는 Suspense 를 지원하는 라우터가 페이지 이동 업데이트를 기본적으로 transition 으로 감쌀 것으로 기대된다고 적습니다. 이동 중에 이미 보이던 페이지가 로딩 화면으로 깜빡이지 않게 하려는 것입니다. transition 은 다음 글의 주제라 여기서는 이름만 짚어 둡니다.
서버에서 Suspense 는 응답을 나누는 경계
서버 렌더링에서 Suspense 는 역할이 하나 더 생깁니다. 경계 안이 준비되지 않았으면, 서버는 fallback 을 넣은 HTML 을 먼저 보내고 준비된 내용을 나중에 같은 응답으로 이어 보냅니다. 이것이 스트리밍 서버 렌더링입니다. 사용자는 느린 부분을 기다리지 않고 나머지 화면부터 봅니다.
Next.js 의 loading.tsx 는 이 구조를 파일 하나로 만들어 줍니다. 그 폴더의 페이지를 <Suspense fallback={<Loading />}> 로 감싸는 것과 같습니다.
여기서 HTTP 의 제약이 드러납니다. 응답 본문을 보내기 시작하려면 상태 코드와 헤더를 먼저 보내야 합니다. 한 번 보낸 상태 코드는 되돌릴 수 없습니다. Next.js 16 의 loading.tsx 문서는 이 점을 이렇게 적습니다.
The server can still communicate errors or issues to the client within the streamed content itself, for example, when using redirect or notFound. Because the response headers have already been sent to the client, the status code of the response cannot be updated.
fallback 이 스트리밍되기 시작한 뒤에 경계 안의 페이지가 notFound() 를 부르면, 브라우저에는 「찾을 수 없음」 화면이 그려지지만 상태 코드는 이미 나간 200 으로 남습니다. 문서에 따르면 Next.js 는 이때 HTML 에 noindex 를 넣어 검색엔진 색인은 막습니다. 다만 크롤러나 모니터링 도구에 따라 「soft 404」로 분류될 수 있습니다.
이 블로그에서 404 가 200 이 된 경로
처음의 세 주소로 돌아가겠습니다. /page/999 는 폴더에 loading.tsx 가 없어서, 페이지가 notFound() 를 부를 때까지 서버가 아무것도 보내지 않았습니다. 상태 코드를 404 로 정할 수 있었습니다.
카테고리 페이지 폴더에는 loading.tsx 가 있었습니다. 서버는 로딩 화면부터 보냈고, 그 뒤에 페이지가 카테고리를 찾지 못해 notFound() 를 불렀습니다. 태그 페이지는 한 단계가 더 있었습니다. 태그 폴더 자체의 loading.tsx 를 지운 뒤에도 200 이었는데, 원인은 상위 tags/ 폴더의 loading.tsx 였습니다. 태그 목록 페이지용으로 둔 파일이었지만, 문서대로 loading.tsx 는 같은 폴더의 페이지와 그 아래 모든 하위 경로를 감싸므로 개별 태그 페이지까지 경계 안에 넣고 있었습니다.
고친 방법은 둘입니다. 개별 카테고리와 태그 페이지에서는 loading.tsx 를 지웠습니다. 태그 목록 페이지의 로딩 화면은 라우트 그룹 폴더 tags/(index)/ 안으로 옮겨, 경계가 목록 페이지에만 걸리게 했습니다. 배포 뒤 세 주소 모두 404 를 돌려주는 것을 확인했고, 같은 일이 다시 생기지 않게 배포 점검 스크립트에 이 주소들의 404 검사를 더했습니다.
문서가 권하는 원칙은 이렇게 요약됩니다. 404 상태 코드가 필요하다면, 리소스가 있는지 확인하는 일을 스트리밍이 시작되기 전에 끝내야 합니다. 문서는 그 확인을 Suspense 경계보다 위에 두거나, 프록시 단계에서 처리하는 방법을 안내합니다.
경계를 둘 자리를 고르는 기준
Suspense 경계를 어디에 둘지는 이제 세 질문으로 정리할 수 있다고 봅니다.
| 질문 | 경계를 두는 쪽 |
|---|---|
| 이 부분이 늦어도 나머지를 먼저 보여 주고 싶은가 | 늦는 부분만 감싸는 안쪽 경계 |
| 이 부분이 함께 나타나야 자연스러운가 | 묶어서 감싸는 하나의 경계 |
| 경계 안에서 「없음」을 판단하는가 | 판단을 경계 밖으로 먼저 빼거나, 그 경로에는 경계를 두지 않음 |
마지막 줄이 스피너 관점에서는 보이지 않던 기준입니다. 경계는 화면 배치이면서 동시에 응답을 언제 보내기 시작하는지를 정합니다.
여기까지가 확실한 부분
Suspense 가 작동하는 경우, 효과 안의 데이터 요청을 감지하지 못한다는 점, 경계 안을 한 단위로 보여 준다는 점, transition 으로 일어난 업데이트는 이미 보이던 내용을 가리지 않는다는 점은 react.dev 문서 기준입니다. loading.tsx 와 스트리밍 뒤의 상태 코드, noindex 처리는 Next.js 16 문서 기준입니다. 이 블로그의 사례는 2026년 9월 18일 배포에서 확인한 상태 코드입니다. 스트리밍을 지원하는 다른 프레임워크에서도 상태 코드 제약은 같지만, 로딩 화면을 만드는 방식과 noindex 처리 여부는 다를 수 있습니다.
함께 읽기
- React useMemo와 useCallback은 언제 써야 할까?리뷰에서 이런 코드를 자주 만납니다.
- React Error Boundary와 try/catch의 차이대시보드에 위젯이 여섯 개 있습니다. 그중 매출 차트 위젯이 서버에서 예상과 다른 모양의 데이터를 받아 렌더링 중에 오류를 냅니다.
- React useActionState와 useOptimistic으로 폼 다루기견적 요청 폼을 보내는 코드입니다.
- React 커스텀 훅은 state를 공유할까?장바구니 아이콘과 장바구니 페이지가 모두 담긴 상품 수를 보여 줘야 해서, 로직을 커스텀 훅으로 뺐다고 해 보겠습니다.
- React Context와 리렌더링로그인한 사용자 정보와 장바구니를 앱 어디서나 쓰려고 컨텍스트 하나를 만들었다고 해 보겠습니다.