2026-04-05·[디버깅 경험]
Next.js App Router에서 RSC Prefetch 중복 요청을 줄인 과정
같은 RSC prefetch 요청이 여러 번 반복되던 원인을 링크 복제와 수동 prefetch 호출에서 찾았다.
프로덕션 네트워크 탭을 뭔가 이상한 느낌에 열어봤는데, 같은 요청이 7번씩 반복되고 있었다. 솔직히 처음엔 내 눈을 의심했다.
네트워크 탭을 열어봤더니
서비스 메인 페이지의 네트워크 탭을 열었을 때, 총 62개의 요청 중 상당수가 중복이었다. RSC prefetch 요청만 따로 필터링해보니 패턴이 눈에 들어왔다.
GET /products/featured?_rsc=xxxxx ×7
GET /products/items?_rsc=xxxxx ×2
GET /products/regions?_rsc=xxxxx ×2
같은 URL에 _rsc 쿼리 파라미터가 붙은 요청이 7번이나 반복되고 있었다. 첫 번째 요청 이후에는 캐시에서 응답하는 게 아니라 실제로 서버까지 날아가는 요청들이었다.
동일 URL에 대한 RSC prefetch는 서버에서 React Server Component 트리를 직렬화한 페이로드를 응답한다. 7번 중복이면 동일한 서버 연산과 네트워크 전송이 7번 반복된다는 뜻이다.
원인은 같은 링크가 여러 번 렌더링된 구조였다
처음엔 Next.js 설정 문제인 줄 알았다. 원인을 좁혀가는 데 좀 걸렸는데, 결국 단서가 나온 건 DesktopMainBannerSection 컴포넌트였다.
DesktopMainBannerSection
이 컴포넌트는 Embla Carousel로 무한 루프 배너를 구현하고 있었다. Embla 자체는 loop 옵션에서 필요한 slide를 이동시키는 방식으로 루프 효과를 만든다. 그런데 우리 코드에는 별도 보정 로직이 하나 더 있었다. 배너 개수가 적을 때 MIN_BANNER_LENGTH=5 기준으로 배너 배열을 복제해서 렌더링 개수를 채우는 코드였다.
// 배너가 5개 미만이면 복제해서 채운다
const MIN_BANNER_LENGTH = 5;
const clonedBanners = banners.length < MIN_BANNER_LENGTH
? [...banners, ...banners, ...banners].slice(0, MIN_BANNER_LENGTH * 2)
: banners;
"아..." 싶었다. 문제는 복제된 배너 각각이 <CustomLink>로 렌더링된다는 점이었다.
// 각 배너 슬라이드
<CustomLink href={banner.href}>
<BannerImage ... />
</CustomLink>
그리고 CustomLink는 onMouseEnter에서 router.prefetch()를 직접 호출하고 있었다.
const CustomLink = ({ href, children }) => {
const router = useRouter();
return (
<Link
href={href}
onMouseEnter={() => router.prefetch(href)}
>
{children}
</Link>
);
};
Next.js의 <Link> 컴포넌트는 기본적으로 링크가 viewport에 들어오면 prefetch를 수행한다. 또 Next.js의 prefetch 스케줄러는 hover나 touch 같은 사용자 의도를 더 높은 우선순위로 다룬다. 여기에 우리 CustomLink는 onMouseEnter에서 router.prefetch()를 직접 호출하고 있었다. 즉 같은 href를 가진 Link가 여러 개 렌더링되고, 수동 prefetch까지 붙으면서 문제가 증폭될 수 있는 구조였다.
흐름을 정리하면 이렇다.
- 우리 코드가 배너 개수를 맞추기 위해 /products/featured 링크를 가진 배너를 여러 개로 복제
- 복제된 7개 슬라이드 각각이 <CustomLink>로 마운트됨
- 각 CustomLink가 독립적으로 onMouseEnter 이벤트를 리스닝
- 사용자가 배너 위에 마우스를 올리거나, viewport에 들어올 때마다 7번의 prefetch 트리거
Next.js App Router에서 <Link> 컴포넌트는 production에서 viewport 진입 시 prefetch를 수행한다. 그리고 hover 같은 사용자 의도는 prefetch 스케줄링에서 우선순위가 높다. 캐러셀 영역에 동일 URL을 가진 Link가 여러 개 존재하면, 각각이 독립적인 prefetch 후보가 된다.
RecentOrderGiftListItem
같은 맥락에서 gifts와 hometowns의 2x 중복도 확인됐다.
RecentOrderGiftListItem 컴포넌트에서 동일한 상품에 대해 두 개의 <CustomLink>가 렌더링되고 있었다.
// 이미지 링크
<CustomLink href={`/donation/${gift.slug}`}>
<GiftImage ... />
</CustomLink>
// 상품명 링크
<CustomLink href={`/donation/${gift.slug}`}>
{gift.name}
</CustomLink>
같은 URL에 대한 링크가 두 개이므로, prefetch도 2번 발생했다.
이렇게 고쳤다
DesktopMainBannerSection.tsx — 복제 배너 구분
핵심 아이디어는 "원본 배너"와 "복제 배너"를 구분해서, 복제 배너는 RSC prefetch를 유발하지 않는 <a> 태그로 렌더링하는 것이다.
// withFlag 헬퍼로 복제 여부를 표시
const withFlag = (items: Banner[], isDuplicate: boolean) =>
items.map(item => ({ ...item, isDuplicate }));
const displayBanners = [
...withFlag(banners, false), // 원본: CustomLink 사용
...withFlag(clonedBanners, true), // 복제: <a> 태그 사용
];
// 렌더링
{displayBanners.map((banner) =>
banner.isDuplicate ? (
// 복제 배너: prefetch 없는 일반 <a> 태그
<a href={banner.href} tabIndex={-1} aria-hidden>
<BannerImage ... />
</a>
) : (
// 원본 배너: CustomLink (prefetch 유지)
<CustomLink href={banner.href}>
<BannerImage ... />
</CustomLink>
)
)}
복제 배너에 aria-hidden과 tabIndex={-1}을 추가해 스크린리더와 키보드 접근성 문제도 함께 처리했다.
RecentOrderGiftListItem.tsx — 중복 링크 정리
상품명 링크는 prefetch가 굳이 필요하지 않은 보조적인 링크이므로, CustomLink 대신 Next.js Link의 prefetch={false} 옵션으로 교체했다.
// 이미지 링크: CustomLink 유지 (prefetch 활성)
<CustomLink href={`/donation/${gift.slug}`}>
<GiftImage ... />
</CustomLink>
// 상품명 링크: prefetch 비활성
<Link href={`/donation/${gift.slug}`} prefetch={false}>
{gift.name}
</Link>
수정 후 결과
수정 후 네트워크 탭을 다시 확인했다.
| URL | 수정 전 | 수정 후 |
|---|---|---|
| /products/featured | 7회 | 1회 |
| /products/items | 2회 | 1회 |
| /products/regions | 2회 | 1회 |
7회에서 1회로 줄어든 걸 보고 속이 좀 시원했다.
이번에 알게 된 것
캐러셀을 자연스럽게 보이게 하려고 같은 배너를 여러 번 렌더링해둔 게, 실제로 prefetch까지 영향을 줄 거라곤 생각 못 했다는 거다. 복제된 요소들이 같은 링크와 이벤트 리스너를 그대로 가지는 건 이론적으론 당연한 말인데, 막상 네트워크 탭에서 7번 찍힌 걸 보니 좀 허탈했다.
참고로 네트워크 탭에서 _rsc로 필터링하면 RSC 요청만 볼 수 있다. 이거 알고 나서 다른 페이지도 점검해봤다.
나도 검색을 꽤 했는데 정확히 같은 케이스는 못 찾았다. Embla + App Router 조합에서 생기는 좀 특수한 케이스인 것 같다.