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를 끄기로 했습니다.


{title}


링크 자체는 그대로라서 클라이언트 탐색과 접근성은 유지됩니다. 달라진 것은 화면에 보인다는 이유만으로 목적지 데이터를 요청하지 않는다는 점입니다.

프로덕션 빌드에서 다시 측정했습니다. 글 상세를 연 뒤 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·본문·선택 상태가 함께 움직이는지부터 확인하려고 합니다.