Next.js에서 링크를 눌렀는데 로딩 뒤 이전 화면이 다시 나타나는 이유
페이지 이동을 빠르게 보이게 만드는 장치는 대개 눈에 잘 띄지 않습니다. 미리 다음 화면을 받아 두거나, 서버 응답을 기다리는 동안 스켈레톤을 보여주는 식입니다. 이번에는 그 두 가지가 한꺼번에 작동하면서 오히려 이동을 늦추고 화면 상태까지 헷갈리게 만들었습니다.
전체 글을 눌렀는데 첫 글이 돌아왔습니다
글 상세 화면에서 전체 글을 눌렀습니다. 목록 스켈레톤이 한참 보이다가 조금 전까지 읽던 글이 다시 나타났습니다. 그런데 사이드바에서는 전체 글이 선택된 상태였습니다. URL과 본문, 사이드바 중 무엇을 믿어야 할지 모르는 화면이 됐습니다.
처음에는 홈 라우트가 첫 번째 글 상세로 잘못 연결된 줄 알았습니다. 최신 글 정렬이나 슬러그 충돌도 의심했습니다. 홈 주소를 직접 요청해 보니 응답에는 정상적인 글 목록이 들어 있었습니다. 라우트가 다른 글을 반환한 문제는 아니었습니다.
로딩 화면을 만든 코드가 증상을 감췄습니다
링크를 누르면 서버 화면이 도착하기 전에 스켈레톤을 보여주는 컴포넌트가 있었습니다. 이 컴포넌트는 클릭한 주소를 상태에 저장하고, 10초가 지나면 탐색 상태를 지웠습니다.
setNavigation({ from: currentPath, to: targetPath })
setTimeout(() => {
setNavigation(null)
}, 10_000)10초 뒤에도 실제 경로가 바뀌지 않았다면 children은 여전히 기존 글입니다. 탐색 상태만 지우자 기존 글이 다시 화면에 나타났습니다.
사이드바는 별도의 낙관적 상태를 사용하고 있었습니다. 클릭하자마자 전체 글을 선택하고 실제 경로가 바뀌면 확정하는 구조였는데, 실패했을 때 되돌리는 경로가 없었습니다. 본문은 이전 글로 돌아가고 사이드바만 목적지를 가리킨 이유였습니다.
여기까지 보고 DB가 가끔 느린 문제라고 생각했습니다. 하지만 평소 홈 응답은 10초보다 훨씬 짧았습니다. 브라우저 네트워크 기록을 다시 보니 제가 누르지 않은 주소들이 계속 요청되고 있었습니다.
링크를 누르지 않았는데 요청이 계속 나갔습니다
Next.js의 Link는 화면에 들어온 링크를 프로덕션에서 자동으로 prefetch합니다. 사용자가 누를 가능성이 있는 다음 페이지의 React Server Component 데이터를 미리 받아 두는 기능입니다.
블로그 한 화면에는 링크가 많았습니다. 사이드바의 카테고리와 글, 본문의 태그, 홈 목록의 글 제목과 태그가 모두 Link였습니다. 글 하나를 열었을 뿐인데 네트워크 기록이 여러 _rsc 요청으로 채워졌고, 그중에는 나중에 취소된 요청도 섞여 있었습니다.
각 요청은 가벼운 정적 파일 조회가 아니었습니다. 동적 루트 레이아웃이 사이드바용 글 목록을 DB에서 읽고, 목적지 페이지도 필요한 글 데이터를 다시 읽었습니다. 제한된 DB 연결을 클릭하지 않은 페이지들이 먼저 사용하면서 실제로 누른 전체 글 요청도 같은 줄에 서게 됐습니다.
prefetch는 클릭을 빠르게 하려고 켜 둔 기능이었습니다. 이 구조에서는 링크 수와 서버 렌더 비용이 커서 반대로 실제 탐색과 경쟁하고 있었습니다.
많은 링크에서는 미리 받지 않게 했습니다
사이드바, 글 목록, 태그처럼 한 화면에 많이 나타나는 링크에는 자동 prefetch를 끄기로 했습니다.
<Link href={href} prefetch={false}>
{title}
</Link>링크 자체는 그대로라서 클라이언트 탐색과 접근성은 유지됩니다. 달라진 것은 화면에 보인다는 이유만으로 목적지 데이터를 요청하지 않는다는 점입니다.
프로덕션 빌드에서 다시 측정했습니다. 글 상세를 연 뒤 3초 동안 자동으로 발생한 RSC prefetch는 0건이었습니다. 전체 글을 실제로 눌렀을 때는 홈을 위한 RSC 요청 1건만 발생했고, 화면 전환은 595ms에 끝났습니다.
DB 결과는 60초 동안 재사용했습니다
prefetch를 꺼도 루트 레이아웃과 페이지가 요청마다 같은 목록을 다시 읽는 구조는 남아 있었습니다. 발행 목록은 초 단위로 바뀌는 데이터가 아니어서 unstable_cache로 60초 동안 재사용했습니다.
const queryPosts = unstable_cache(
loadPosts,
["blog-post-list-v1"],
{
revalidate: 60,
tags: ["blog-posts"],
},
)여기서 한 번 더 걸렸습니다. 캐시 경계를 지나면서 Date가 문자열이 될 수 있는데, 기존 화면은 toISOString()을 호출하고 있었습니다. 캐시에 넣기 전에는 ISO 문자열로 명시적으로 바꾸고, 반환할 때 다시 Date로 복원했습니다. 목록, 본문, 피드, 짧은 링크 조회에도 같은 원칙을 적용했습니다.
캐시가 준비된 뒤 홈 응답을 세 번 요청한 결과는 15.621ms, 10.315ms, 10.870ms였습니다. 이 수치는 로컬 프로덕션 빌드에서 측정했습니다.
10초 뒤에는 실패를 숨기지 않았습니다
사이드바의 선택 상태는 실제 pathname만 따르게 바꿨습니다. 클릭했지만 이동이 끝나지 않았다면 현재 글이 계속 선택됩니다. 아직 가지 않은 화면을 먼저 선택해 두지 않았습니다.
10초 타이머도 탐색 상태를 지우는 대신 timedOut 상태를 켜도록 바꿨습니다. 타임아웃 뒤에는 이전 화면이라는 사실을 알리는 안내와 다시 시도 버튼을 보여줍니다.
setTimeout(() => {
setTimedOut(true)
}, 10_000)RSC 응답을 의도적으로 멈춘 상태에서 다시 눌러 봤습니다. 기다리는 동안에는 목록 스켈레톤이 나타났습니다. 10초 뒤에는 기존 글 제목과 기존 사이드바 선택이 함께 유지됐고, 페이지 이동이 늦어 이전 화면을 유지하고 있다는 안내가 표시됐습니다. 예전처럼 기존 글이 새 목적지의 본문인 것처럼 나타나지는 않았습니다.
이번에 문제였던 것은 prefetch 하나나 스켈레톤 하나가 아니었습니다. 한 화면의 링크 수, 각 목적지가 수행하는 서버 작업, 실패했을 때 서로 다른 컴포넌트가 상태를 복구하는 방식이 맞물렸습니다. 다음에는 로딩 화면만 보고 이동이 느리다고 판단하지 않고, 사용자가 누르지 않은 RSC 요청과 현재 URL·본문·선택 상태가 함께 움직이는지부터 확인하려고 합니다.
함께 읽기
- Next.js 랜딩 페이지의 Lighthouse 점수가 폰트 하나로 해결되지 않는 이유성능 최적화에서 가장 당황스러운 순간은 개발 환경에서는 빠른데 배포 후 측정값만 크게 느릴 때입니다. 이번 랜딩 페이지가 그랬습니다. 개발 환경에서 관찰한 LCP는 약 1.68초였지만 PageSpeed Insights의 첫 측정은 5.5초까지 늘어났습니다. 이후 배포본을 다시 측정했을 때도 성능 점수 72점, LCP 4…
- Next.js는 운영에서 멀쩡한데 로컬 개발 서버에서만 UI가 먹통이 되는 이유Next.js 애플리케이션은 운영 빌드가 안정적이면 개발 서버도 같은 방식으로 움직일 것 같지만, 둘은 클라이언트 코드의 수명이 다릅니다. 제가 확인한 환경은 Next.js 16.2.7과 Turbopack이었고, 운영 사이트에서는 잘 눌리던 상단 메뉴가 로컬 개발 서버에서만 간헐적으로 먹통이 됐습니다.
- Google 번역을 켰더니 React 앱이 오류 화면으로 바뀐 이유한국어, 영어, 일본어, 중국어를 직접 제공하는 React 랜딩 페이지에서 예상하지 못한 문제가 생겼습니다. Chrome이 띄운 "이 페이지를 번역하시겠습니까?" 제안을 수락하면 번역이 시작되는 듯하다가, 잠시 뒤 사이트의 "일시적인 오류가 발생했습니다" 화면으로 바뀌었습니다.
- Next.js 앱에서 클릭이 한 박자 느리게 느껴질 때 살펴본 것들서버가 멀면 응답이 느린 건 어쩔 수 없습니다. 제 경우 오리진 응답 지연에 CDN 우회 경로까지 겹쳐 페이지 응답에 1초 가까이 걸리는 상황이었고, 네트워크 쪽은 당장 손댈 수 없었습니다. 그래서 응답 속도 대신 "눌렀을 때 반응하는 속도"를 올리는 쪽으로 방향을 잡았습니다. 느린 것과 느리게 느껴지는 것은 생각보다 …
- 스크롤바가 없는데 화면 오른쪽에 빈 여백이 생기는 이유웹 앱의 화면 가장자리는 평소에는 눈에 잘 들어오지 않습니다. 콘텐츠와 배경이 자연스럽게 이어지면 굳이 볼 이유가 없기 때문입니다. 그런데 여러 화면을 확인하던 중 오른쪽 끝에 비어 보이는 세로 띠가 계속 눈에 걸렸습니다. 어떤 화면에서는 나타나고, 어떤 화면에서는 덜 보였습니다.