문제
문서 안의 링크를 눌렀을 때 문서가 늦게 열리면, 화면은 멈춘 것처럼 느껴질 수 있다.
특히 처음 여는 문서에서는 코드 묶음을 내려받고, 서버에서 다음 화면을 만들고, 필요한 데이터를 가져오는 시간이 한 번에 더해진다. 한 번 열었던 문서는 브라우저 캐시 덕분에 빨라지지만, 첫 이동의 인상은 좋지 않다.
핵심 아이디어
사용자가 링크를 누르기 전에도 다음 행동을 어느 정도 예상할 수 있다.
링크 위에 마우스를 올리거나 키보드로 링크에 초점을 맞춘 것은, 곧 그 링크를 열 가능성이 높다는 신호다. 이 신호를 사용자 의도로 본다.
이때 다음 화면에 필요한 데이터를 미리 가져오면, 실제 클릭 시점에는 이미 준비된 결과를 사용할 수 있다.
기존
링크 클릭
→ 다음 화면 준비 시작
→ 화면 표시
개선
링크 hover 또는 focus
→ 다음 화면 준비 시작
링크 클릭
→ 준비된 결과 표시
모든 링크를 미리 가져오면 안 되는 이유
처음 화면에 보이는 모든 링크를 사전에 가져오면 빠르게 보일 수 있다. 하지만 사용자가 열지 않을 페이지까지 네트워크와 서버 자원을 사용하게 된다.
문서 하나에 링크가 많다면 이 비용은 더 커진다. 특히 현재 열린 문서들의 조합에 따라 다음 주소가 달라지는 화면은, 가능한 주소가 많아질 수 있다.
그래서 prefetch는 넓게 적용하기보다 의도가 강한 순간에 좁게 적용하는 편이 좋다.
- 짧은 hover 뒤에만 시작하기
- keyboard focus에도 적용하기
- 이미 요청한 주소는 다시 요청하지 않기
- 모바일에서는 hover가 없으므로 같은 전략을 강제하지 않기
약간의 지연이 필요한 이유
사용자는 링크 위를 지나가면서 잠깐 마우스를 올릴 수 있다. 이때마다 요청을 보내면 대부분의 요청은 낭비가 된다.
그래서 hover 직후가 아니라 아주 짧은 시간 뒤에도 링크 위에 머물러 있을 때만 prefetch를 시작할 수 있다.
이 지연은 사용자에게 보이지 않지만, 우연한 hover와 실제 탐색 의도를 구분하는 데 도움을 준다.
미리보기와 사전 가져오기의 차이
링크 미리보기와 prefetch는 비슷해 보이지만 목적이 다르다.
미리보기는 사용자가 링크를 열기 전에 내용을 판단하도록 돕는다. prefetch는 실제 이동을 빠르게 만든다.
둘은 같은 hover와 focus 신호를 활용할 수 있다. 하지만 미리보기를 닫았을 때는 더 이상 필요하지 않은 요청을 취소하는 것이 좋다.
미리보기: 내용을 보여 주기 위한 요청
사전 가져오기: 다음 화면 이동을 준비하는 요청
닫기 동작은 왜 바로 반응해야 하는가
문서를 닫는 동작은 새 데이터를 기다릴 필요가 거의 없다. 사용자는 닫기 버튼을 눌렀을 때 화면에서 해당 패널이 즉시 사라지기를 기대한다.
그런데 주소 변경과 서버 응답이 끝날 때까지 기다린 뒤 패널을 닫으면, 닫기 동작도 느리게 느껴진다.
이 경우에는 화면 상태를 먼저 바꾸고, 주소와 서버 상태는 뒤에서 맞추는 방식이 적합하다. 이를 낙관적 갱신이라고 한다.
기존
닫기 클릭
→ 서버 응답 대기
→ 패널 제거
개선
닫기 클릭
→ 패널 제거
→ 주소와 서버 상태 동기화
낙관적 갱신은 사용자가 기대하는 결과가 분명하고, 실패했을 때 복구할 방법이 있을 때 사용하기 좋다.
'use client';
import { startTransition, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
type NotePanelProps = {
slugs: string[];
};
export const NotePanels = ({ slugs }: NotePanelProps) => {
const router = useRouter();
const [closedSlugs, setClosedSlugs] = useState<string[]>([]);
const visibleSlugs = slugs.filter((slug) => !closedSlugs.includes(slug));
const closePanel = (slug: string) => {
// 사용자가 기대하는 화면 변화는 먼저 반영한다.
setClosedSlugs((current) => [...current, slug]);
// 주소와 서버 화면 갱신은 급하지 않은 작업으로 뒤에 처리한다.
startTransition(() => {
const nextSlugs = slugs.filter((currentSlug) => currentSlug !== slug);
router.push(`/note/${nextSlugs.join('/')}`);
});
};
// 서버가 새 목록을 보내면 임시로 닫아 둔 상태를 정리한다.
useEffect(() => {
setClosedSlugs((current) => current.filter((slug) => slugs.includes(slug)));
}, [slugs]);
return (
<ul>
{visibleSlugs.map((slug) => (
<li key={slug}>
{slug}
<button type="button" onClick={() => closePanel(slug)}>
닫기
</button>
</li>
))}
</ul>
);
};
위 예시에서 중요한 점은 setClosedSlugs를 startTransition 바깥에서 실행하는 것이다. 패널을 숨기는 일은 사용자가 즉시 봐야 하는 작업이므로 우선 처리한다. 반면 주소 변경과 서버 화면 갱신은 뒤에서 처리해도 된다. 서버가 새 문서 목록을 보내면 임시 상태를 정리해 화면과 주소를 다시 일치시킨다.
확인할 지표
탐색 성능은 단순히 요청 시간이 짧은지만으로 판단하기 어렵다. 사용자가 클릭한 뒤 새 내용이 보일 때까지의 시간을 함께 보는 것이 좋다.
- 링크 클릭부터 다음 내용이 보일 때까지의 시간
- prefetch 요청 중 실제 클릭으로 이어진 비율
- 첫 문서 열기와 다시 열기의 시간 차이
- 닫기 클릭부터 패널이 사라질 때까지의 시간
prefetch는 항상 정답이 아니다. 실제 사용자가 자주 열지 않는 링크까지 미리 준비한다면, 비용만 늘고 체감 성능은 좋아지지 않을 수 있다.
정리
탐색을 빠르게 만드는 방법은 클릭 뒤의 작업을 무조건 줄이는 것만이 아니다. 사용자가 곧 할 가능성이 높은 행동을 미리 준비하는 것도 중요하다.
다만 prefetch는 사용자 의도가 분명한 순간에만 적용하고, 닫기처럼 즉시 결과를 보여 줄 수 있는 동작은 먼저 화면을 갱신하는 편이 자연스럽다.