본문으로 건너뛰기

<Link>로 이동하면 어디까지 새로 불러올까요?

지난주에는 폼을 제출한 뒤 서버 함수가 실행되는 과정을 봤습니다. 이번에는 메뉴를 눌러 다른 페이지로 넘어가는 쪽입니다.

관리자 화면에서 프로필을 보다가 설정으로 이동한다고 해보겠습니다. 바뀌어야 하는 건 본문인데, 왼쪽 메뉴까지 처음부터 만들 필요는 없겠죠. 메뉴 안에 입력해 둔 내용이 있다면 그것도 남아 있었으면 합니다.

<Link>를 쓰면 새로고침 없이 이동한다는 건 알겠는데, 무엇을 그대로 두고 무엇을 새로 가져오는 걸까요? 같은 레이아웃을 쓰는 /dashboard/profile/dashboard/settings로 범위를 좁혀봤습니다.

먼저, 남길 화면부터 나눠보면

두 페이지에서 같이 쓸 메뉴를 dashboard/layout.tsx에 두고, 본문만 각각의 page.tsx에 둡니다. app/layout.tsx에는 공통 루트 레이아웃이 있다고 가정합니다.

app/dashboard/
├─ layout.tsx ← 공통 메뉴와 본문이 들어갈 자리
├─ profile/
│ └─ page.tsx ← 프로필 본문
└─ settings/
├─ page.tsx ← 설정 본문
└─ loading.tsx ← 설정 본문을 기다릴 때 표시

레이아웃에 임시 메모 입력칸도 하나 넣어보겠습니다. 배치용 CSS는 생략했습니다.

// app/dashboard/layout.tsx
import Link from 'next/link'
import type { ReactNode } from 'react'

export default function DashboardLayout({
children,
}: {
children: ReactNode
}) {
return (
<div>
<aside>
<label htmlFor="memo">임시 메모</label>
<input id="memo" type="text" />
<nav aria-label="대시보드 메뉴">
<Link href="/dashboard/profile">프로필</Link>{' '}
<Link href="/dashboard/settings">설정</Link>
</nav>
</aside>
<main>{children}</main>
</div>
)
}

프로필에서 설정으로 이동하면 children 자리에 들어가는 페이지가 바뀝니다. 반면 두 경로가 공유하는 레이아웃은 재사용합니다. 위 입력칸도 그대로 남으므로, 메뉴를 눌렀다는 이유만으로 메모가 초기화되지는 않습니다.

여기서 ‘유지’와 ‘재렌더링 안 함’을 같은 뜻으로 넓혀 잡으면 헷갈립니다. 레이아웃 안의 Client Component는 자기 상태나 현재 경로가 바뀌면 다시 렌더링될 수 있습니다. 공통 영역을 이동할 때마다 없앴다가 새로 만들지 않는다는 것에 가깝습니다. 경로를 읽는 메뉴라면 선택된 항목 표시도 바뀔 수 있고요.

<Link>가 미리 가져오는 것

이제 새 설정 본문이 어디서 오는지 볼 차례입니다.

일반 <a>를 별도 처리 없이 누르면 브라우저는 새 문서로 이동합니다. <Link>를 통한 앱 내부 이동에서는 Next.js 라우터가 기존 화면을 남겨두고 필요한 영역을 갱신합니다. 이것이 클라이언트 측 이동입니다.

그렇다고 Server Component까지 브라우저에서 실행하는 건 아닙니다. 서버에서 만든 결과를 받아 React가 화면에 반영합니다. 이때 쓰는 RSC Payload는 서버 컴포넌트의 결과와 클라이언트 컴포넌트 참조 등을 담은 UI 데이터입니다. 새 HTML 문서를 통째로 여는 대신 이 결과를 기존 화면과 연결하는 것입니다. 필요한 Client Component의 JavaScript도 아직 없다면 함께 가져와야 합니다.

그런데 이 요청이 꼭 클릭한 뒤에 시작되는 건 아닙니다. Next.js는 화면에 들어온 <Link>의 목적지를 미리 가져올 수 있습니다. 이것이 prefetch, 말 그대로 미리 가져오기입니다. 미리 받은 RSC 결과는 브라우저 메모리의 라우터 캐시에 보관합니다. 서버의 DB 조회 결과를 저장하는 캐시와는 다른 자리입니다.

클릭했을 때 사용할 결과가 충분히 준비돼 있다면 그것을 쓰고, 없거나 일부만 준비됐다면 부족한 결과를 서버에서 받습니다. 미리 가져오는 범위는 경로와 설정에 따라 달라서, 모든 링크의 다음 화면이 통째로 준비돼 있는 것은 아닙니다.

이동 전에 가능한 결과를 미리 가져와 브라우저의 라우터 캐시에 두고, 설정 Link를 클릭하면 사용할 결과가 준비됐는지 확인해 충분하면 공통 레이아웃을 유지한 채 설정 본문을 반영하고 부족하면 서버에서 나머지 결과를 받아 같은 화면에 반영하는 흐름도

미리 가져오기가 화면을 유지해 주는 건 아닙니다. <Link prefetch={false}>로 미리 가져오기만 꺼도, 같은 공유 레이아웃 안에서의 클라이언트 이동은 그대로입니다. 미리 가져오기는 그 이동에서 기다리는 시간을 줄여주는 쪽입니다.

새 본문이 늦게 준비된다면

설정 페이지에서 데이터를 읽는 데 시간이 걸릴 수도 있습니다. 다음 화면이 준비되지 않았다고 메뉴까지 함께 멈춰야 할까요?

이때 settings/loading.tsx에 기다리는 동안 보여줄 화면을 둡니다.

// app/dashboard/settings/loading.tsx
export default function Loading() {
return <p role="status">설정을 불러오는 중입니다.</p>
}

Next.js는 이 파일로 같은 폴더의 페이지와 그 아래를 Suspense로 감쌉니다. 여기서 Suspense는 내용이 준비되는 동안 대체 화면을 보여주는 경계입니다. 공통 메뉴는 밖에 남고, 설정 본문 자리에서 로딩 화면을 보여주다가 결과가 도착하면 바꿀 수 있습니다. 서버가 전체 결과를 기다리지 않고 준비된 부분부터 보내는 것이 스트리밍입니다.

다만 로딩 화면 자체도 아직 가져오지 못했다면 클릭 즉시 표시되지는 않을 수 있습니다. 또 이 파일이 같은 폴더의 layout.tsx까지 감싸는 건 아닙니다. 어디에서 느린 작업을 하는지에 따라 기다리는 범위도 달라집니다.

이 예제를 확인할 때는 메모를 입력하고 두 메뉴를 오가면서, 본문은 바뀌는데 입력칸은 남는지 보면 됩니다. 자동 prefetch는 개발 모드가 아닌 프로덕션에서 동작하므로, 요청 시점까지 보려면 next buildnext start로 실행하고 Network 탭을 열어보는 편이 낫습니다. 클릭 전의 요청과 클릭 후의 요청을 나눠서 보는 것입니다.

이번에는 <Link> 자체보다 메뉴를 어디에 두느냐가 먼저 눈에 들어왔습니다. 공통 메뉴는 레이아웃에, 바뀌는 본문은 페이지에, 본문을 기다리는 화면은 그 아래 로딩 경계에 두는 식입니다. 이렇게 나누고 나니 ‘새로고침 없는 이동’이라는 말보다, 설정 화면으로 옮길 때 무엇이 남고 무엇을 기다리는지로 이해하는 편이 더 구체적이었습니다.

참고 문서