Next.js loading.tsx와 404: 버튼이 화면보다 먼저 살아나게 하기
관리 화면 왼쪽 위에 사이드바를 접고 펴는 ☰ 버튼이 있습니다. 폭 하나 바꾸는 버튼인데, 대시보드에서 누르면 2초 넘게 아무 반응이 없었습니다. 레이아웃만 바꾸는 일이 왜 이렇게 오래 걸리는지부터 들여다봤습니다. Next.js 16 App Router 로 만든 제조 공장의 생산 관리 앱입니다.
폭 하나 바꾸려고 대시보드를 다시 계산하던 버튼
코드를 열어 보니 이유가 바로 보였습니다.
"use server";
export async function toggleSidebar() {
const jar = await cookies();
const rail = jar.get(SIDEBAR_COOKIE)?.value === "rail";
jar.set(SIDEBAR_COOKIE, rail ? "full" : "rail", { path: "/admin" });
revalidatePath("/admin", "layout");
}쿠키를 서버 액션으로 바꾸고 revalidatePath 로 레이아웃을 다시 그리게 해 두었습니다. 쿠키를 서버가 읽어 처음부터 맞는 폭으로 그리려는 의도였고, 그 부분은 맞았습니다. 문제는 다시 그리는 범위였습니다. 레이아웃을 다시 그리면 그 아래 지금 보던 페이지도 다시 그립니다. 대시보드라면 차트 30여 개의 계산을 처음부터 다시 합니다. 폭을 바꾸는 데 서버 왕복이 필요한 이유가 없었습니다.
사이드바를 클라이언트 컴포넌트로 옮기고, 처음 폭만 서버가 쿠키에서 읽어 넘기게 했습니다. 누르면 useState 로 바로 바꾸고 document.cookie 에 적기만 합니다. 다음에 열 때는 서버가 그 쿠키를 읽으니 깜빡임도 없습니다. 접히기까지 0.14초, 서버 요청은 0건이 됐습니다.
페이지 계산을 기다리던 사이드바
그다음에 남은 문제가 더 컸습니다. 화면을 옮기면 새 화면이 다 도착할 때까지 사이드바 버튼도 눌리지 않았습니다. 제가 만든 다른 사내 앱은 누르자마자 버튼이 살아나고 내용만 조금 늦게 뜨는데, 이 앱은 그렇지 않았습니다.
처음엔 하이드레이션이 느리다고 생각했습니다. 서버가 보낸 HTML 에 자바스크립트를 붙이는 그 과정 말입니다. 버튼 요소에 React 속성(__reactProps)이 붙는 순간을 10ms 간격으로 지켜보며 쟀습니다.
| 개발 서버 | 첫 바이트 | 버튼이 살아난 때 |
|---|---|---|
| 대시보드 | 2,079ms | 2,541ms |
| 공정관리 | 1,976ms | 2,207ms |
| 외주관리 | 185ms | 258ms |
하이드레이션은 첫 바이트 뒤 수백 ms 안에 끝났습니다. 기다리는 시간 대부분은 첫 바이트, 즉 서버가 페이지 전체를 다 계산할 때까지였습니다. 페이지 계산이 끝나야 레이아웃까지 한 번에 나가니, 사이드바도 같이 기다린 것입니다.
틀을 먼저 보내고 내용은 준비되는 대로 흘려 보내면 됩니다. Next.js 에서는 loading.tsx 를 두면 그 아래 페이지를 Suspense 로 감싸 줍니다. 관리 화면 그룹에 loading.tsx 를 하나 두고 제목·숫자 칸·표 모양의 뼈대를 그렸습니다.
스모크 시험이 잡은 200 과 끊긴 이동
넣자마자 E2E 스모크 시험 두 개가 깨졌습니다.
하나는 공개 전이라 닫아 둔 매뉴얼 주소였습니다. 레이아웃에서 notFound() 를 부르는데 응답 상태가 404 가 아니라 200 이었습니다. 이유를 알고 나면 당연합니다. loading.tsx 가 있으면 뼈대를 담은 응답 머리가 먼저 나갑니다. 그 뒤에 내용 안에서 notFound() 가 불려도 이미 보낸 200 을 되돌릴 수 없습니다. 화면에는 「찾을 수 없음」이 뜨지만 상태 코드는 200 입니다.
다른 하나는 더 헷갈렸습니다. 여러 화면을 차례로 여는 시험에서 데이터관리 화면이 net::ERR_ABORTED 로 끊겼습니다. 데이터관리에는 문제가 없었습니다. 그 바로 앞에 연 화면이 예전 주소를 새 주소로 보내는 redirect() 페이지였습니다. 응답이 먼저 나가 버리니 이 돌려보내기가 서버의 307 이 아니라 브라우저 쪽 이동으로 바뀌었고, 그 이동이 시험의 다음 page.goto 를 끊고 있었습니다.
처음엔 loading.tsx 를 빼고 다른 길로 돌았습니다. 뼈대를 레이아웃에 늘 숨겨 두고, 링크를 누르는 순간 body 에 속성을 달아 CSS 로 내용 대신 뼈대를 보이게 했습니다. 상태 코드는 지켜졌지만 정작 처음 문제가 남았습니다. 응답은 여전히 페이지 전체가 계산된 뒤에야 나가니, 버튼도 여전히 그때까지 기다렸습니다.
결국 loading.tsx 로 돌아왔습니다. 대신 상태 코드가 꼭 필요한 두 곳만 페이지에 들어가기 전 앞문(Next.js 16 의 proxy.ts)으로 옮겼습니다. 닫아 둔 매뉴얼 주소는 앞문에서 404 로 돌리고, 예전 주소 돌려보내기도 앞문에서 합니다. 권한이 없는 화면을 돌려보내는 redirect() 는 페이지 안에 그대로 두었습니다. 내용을 그리기 전에 던지니 막힌 내용이 새지 않고, 관리 화면은 로그인 뒤라 검색 색인과도 상관이 없기 때문입니다. 시험 하나는 표의 줄 수를 너무 일찍 세고 있어서, 내용이 다 들어올 때까지 기다리도록 고쳤습니다.
누른 것이 눌렸다는 표시
내용이 늦게 와도 누른 것은 바로 알려야 합니다. 클릭을 캡처 단계에서 받아, 관리 화면 안으로 가는 링크면 그 링크에 data-pending 을 달고 화면 맨 위에 가는 진행 막대를 띄웁니다. 주소가 바뀌면 둘 다 걷습니다. 새 탭으로 열기, 내려받기, 바깥 주소, 같은 주소는 건드리지 않습니다. 같은 주소로 돌아오는 경우에는 걷을 계기가 없어서, 10초 뒤에 저절로 걷히게 해 두었습니다.
개발 서버의 2초와 운영의 0.04초
여기까지 해 놓고 운영과 같은 빌드(next build 후 next start)로 다시 쟀습니다.
| 운영 빌드 | 버튼이 살아난 때 | 내용이 뜬 때 |
|---|---|---|
| 대시보드 | 44ms | 343ms |
| 공정관리 | 75ms | 375ms |
| 수주대장 | 52ms | 499ms |
| 외주관리 | 42ms | 336ms |
같은 빌드로 바꾸기 전에는 대시보드 버튼이 625ms, 공정관리가 284ms 에 살아났습니다. 개발 서버에서는 같은 화면이 2초를 넘겼으니 숫자의 크기부터 다릅니다. 개발 서버는 요청마다 코드를 묶고 React 도 점검이 많은 개발용으로 돕니다. 처음 느리다고 느낀 것도 개발 서버였습니다. 그날 쟀던 숫자 가운데 상당수가 개발 서버 탓이었고, 운영 빌드로 먼저 쟀다면 어디부터 손댈지 더 빨리 정했을 겁니다.
남은 것도 있습니다. 화면을 연 직후, 자바스크립트가 붙기 전 수십 ms 사이에 ☰ 를 누르면 반응하지 않습니다. 사람 손으로는 거의 닿지 않는 틈이라 그대로 두었습니다.
함께 읽기
- Next.js 앱에서 클릭이 한 박자 느리게 느껴질 때 살펴본 것들서버가 멀면 응답이 느린 건 어쩔 수 없습니다. 제 경우 오리진 응답 지연에 CDN 우회 경로까지 겹쳐 페이지 응답에 1초 가까이 걸리는 상황이었고, 네트워크 쪽은 당장 손댈 수 없었습니다. 그래서 응답 속도 대신 "눌렀을 때 반응하는 속도"를 올리는 쪽으로 방향을 잡았습니다. 느린 것과 느리게 느껴지는 것은 생각보다 …
- Next.js 레이아웃과 라우트 그룹: loading.tsx 적용 범위Next.js 앱 라우터에서 한 폴더에는 이런 파일들이 함께 놓입니다.
- 클릭했는데 화면이 늦게 바뀌는 느낌을 줄이는 방법사용자가 버튼이나 링크를 눌렀는데 아무 반응이 없는 것처럼 보이면, 실제 로딩 시간이 길지 않아도 서비스가 느리다고 느낍니다. 이 문제는 단순한 성능 문제가 아니라 체감 성능과 피드백의 문제입니다.
- 가상 스크롤로 장부 화면 만들기: 느린 원인은 Intl.DateTimeFormat제조 공장 한 곳의 생산 관리 앱을 만들고 있습니다. 그 안에 수주대장이라는 화면이 있는데, 공장이 쓰던 생산 엑셀을 칸 순서까지 그대로 옮긴 표입니다. 1년치만 봐도 4천 줄이 넘습니다. 처음에는 100줄씩 쪽으로 나눠 두었습니다. 직접 써 보다가 이 부분은 확실히 엑셀보다 불편하다는 생각이 들었습니다. 엑셀은 수천 …
- 배포하면 CSS가 404: stale-while-revalidate 함정"모바일에서 사이트가 이상해요."