* 이 글은 Yes, the Next.js Router Cache is Actually Good 를 번역하였습니다.
TL;DR
- Next.js의 라우터 캐시는 논란이 있지만, 유용한 기능입니다.
- 라우터 캐시는 서버 부하를 줄이고, 사용자 경험(UX)을 개선하며, "허용 가능한" 정도의 오래된 데이터를 제공하는 것을 목표로 합니다.
- 사용자가 시작하는 변경 작업(mutation)의 경우 서버 액션(server actions)을 사용해야 합니다.
- 항상 최신 데이터가 필요한 경우에는 클라이언트 측 데이터 페칭을 사용하십시오.
- staleTimes와 router.refresh는 예외적으로 사용할 수 있는 방법이지만, 라우터 캐시와 관련하여 대부분의 경우 더 나은 해결책이 존재합니다.
The Controversy Known as the Next.js Router Cache
라우터 캐시는 클라이언트 측 데이터 페칭에는 영향을 주지 않습니다. 이 글의 나머지 부분에서는 명시하지 않는 한 서버 측 데이터 페칭(서버 컴포넌트 사용)을 전제로 합니다.
라우터 캐시는 기본적으로 특정 시간 동안 클라이언트에서 경로의 콘텐츠를 저장합니다. 따라서 이 시간 내에 클라이언트 측 네비게이션(e.g., <Link> 태그 사용 시)으로 이동할 경우, 해당 페이지가 동적으로 렌더링되더라도 서버에서 데이터를 가져오는 대신 캐시에서 페이지 콘텐츠가 제공됩니다. 정적 렌더링된 페이지의 경우 기본적으로 이 기간은 5분이고, 동적 렌더링된 페이지는 30초입니다.
이는 대부분의 Next.js 개발자에게 예상치 못한 동작일 것입니다. pages 라우터에서는 동적 페이지가 항상 요청 시점에 렌더링되므로(즉, 클라이언트 측 캐시 시간 프레임이 0) 페이지가 항상 최신 데이터를 가져온다는 것을 보장했습니다.
반면 app 라우터에서는 데이터가 최대 30초 동안 오래된 상태일 수 있습니다. 처음에는 이러한 데이터의 "오래된 상태(stale)" 가능성이 당황스러울 수 있습니다. 페이지를 명시적으로 동적으로 설정했다면 항상 최신 데이터로 동적이어야 하지 않을까요? 그리고 14.2.0 버전 이전에는 이러한 값을 구성하는 것이 불가능했습니다. 사용할 수 있는 유일한 방법은 다음과 같은 무효화(invalidation) 방법을 이용하는 것이었습니다.
1. 서버 액션 내에서 revalidatePath 또는 revalidateTag를 사용하여 특정 페이지나 태그를 무효화할 수 있습니다. 그러나 revalidatePath나 revalidateTag를 라우트 핸들러에서 호출하는 것은 라우터 캐시에 아무런 영향을 미치지 않습니다.
2. 클라이언트에서 모든 라우터 캐시를 수동으로 삭제하려면 router.refresh를 사용하십시오. 이는 캐시를 무효화하는 간단한 방법이지만, 다소 임시방편적이고 우아하지 못한 방식입니다. 사실, 이 방법은 임시방편에 불과하므로, 예외적인 경우에만 사용하는 것이 좋습니다.
라우터 캐시가 너무 논란이 되자, Next.js 팀은 라우터 캐시 지속 시간을 수동으로 설정할 수 있는 staleTimes 옵션을 도입하기로 결정했습니다. 이 옵션은 14.2.0 버전부터 사용할 수 있으며, 작성 시점에서는 여전히 실험적 기능으로 표시되어 있습니다. 이를 통해 동적 페이지를 진정으로 동적으로 만들 수 있게 되었습니다. 좋은 소식이죠?
몇 개월 전, 제가 아직 반(反) 라우터 캐시 입장이었을 때는 그렇게 생각했을 것입니다. 하지만 이후 앱 라우터를 사용하면서 이제는 라우터 캐시가 좋은 기능이며, Next.js 팀이 이 기능을 포함시킬 만한 충분한 이유가 있다고 확신하게 되었습니다. staleTimes 역시 router.refresh처럼 예외적인 경우에만 고려해야 할 옵션입니다. 이유를 알아보도록 하죠.
Argument For the Router Cache: Spam Tab Switching
사용자 설정 페이지가 여러 탭으로 나누어져 있다고 가정해봅시다. 예를 들어 /settings/account와 /settings/billing 같은 페이지가 있습니다. 이러한 페이지들은 사용자 데이터를 다루기 때문에 동적으로 렌더링해야 합니다. (클라이언트 측 데이터 페칭은 라우터 캐시에 영향을 받지 않으므로 이 옵션은 고려하지 않겠습니다.)
라우터 캐시가 없다고 가정할 때, 사용자가 탭 간을 전환할 때마다 페이지 콘텐츠는 서버에서 매번 가져오게 됩니다. 일반적으로 이는 문제가 되지 않지만, 사용자가 빠르게 탭을 전환하는 경우(예를 들어, 내비게이션 사이드바에서 실수로 잘못된 링크를 클릭한 경우), 서버는 요청 폭탄을 맞게 되어 서버 부하가 증가하게 됩니다.
사용자가 설정을 변경하지 않는다면 페이지 데이터는 동일하게 유지될 것입니다. 따라서 페이지 데이터를 한 번만 가져와 일정 시간 동안 캐시하고, 사용자가 다시 돌아왔을 때는 서버를 다시 호출하는 대신 캐시된 데이터를 제공하는 것이 유리합니다.
이러한 서버 호출은 매달 청구되는 비용에 영향을 미칩니다. Vercel에 호스팅하는 경우, 이러한 동적 페이지 요청은 각각 하나의 서버리스 함수 호출로 계산될 가능성이 큽니다. 사이트는 이러한 불필요한 비캐시 서버 호출로 인해 불필요한 추가 비용을 지출하게 될 수 있습니다.
또한 사용자 경험 측면에서도, 페이지를 즉시 제공할 수 있다면 사용자가 페이지 로딩을 몇 초 기다리는 것보다 훨씬 더 부드러운 경험을 할 수 있습니다. 이는 이전 페이지로 즉시 돌아갈 수 있는 bfcache와 비슷합니다.
이것이 라우터 캐시가 있는 이유입니다. 팀의 일원이 아니기 때문에 확실히 말할 수는 없지만, 이 캐시의 아이디어는 서버 부하를 줄이고, 즉시 네비게이션을 통해 bfcache와 같은 사용자 경험 개선을 가능하게 하기 위한 필요성에서 비롯된 것이라고 강하게 믿습니다.
Vercel이 호스팅 제공업체라는 점에서, 이들이 고객들로부터 이러한 불필요한 비캐시 요청으로 인한 비용에 대한 불만을 많이 들어왔을 가능성도 있습니다. 이에 따라 고객들이 이런 상황을 방지하도록 해야 할 필요성을 느꼈을 것이라는 주장도 가능합니다.
Solutions to the Potential of State Data
하지만 기본값으로 30초의 캐시 지속 시간은 동적 페이지에 오래된 데이터를 표시할 가능성이 있음을 의미합니다. 두 가지 경우를 살펴보겠습니다.
경우 1: 제3자 데이터 업데이트 – 허용 가능한 오래된 데이터
대시보드 페이지를 예로 들어보겠습니다. 이 페이지의 데이터는 외부 소스로부터 매우 자주 업데이트된다고 가정합니다. 예를 들어 EUR/USD 환율, Twitter 팔로워 수, 혹은 사용자가 또 다른 스캠코인에 투자하여 잃은 금액 등을 보여줄 수 있습니다.
이 경우, 사용자가 페이지를 이동했다가 30초 이내에 다시 돌아오면 오래된 데이터를 볼 가능성이 큽니다. 이는 예상된 상황입니다.
Next.js는 데이터가 다른 곳에서 업데이트되었는지 알 수 없지만, 사용자가 데이터를 직접 수정하지 않았다는 것은 알고 있습니다. 따라서 서버 부하를 줄이기 위해(앞서 설명한 내용 참조), Next.js는 이러한 경우를 "허용 가능한 오래된 데이터"로 간주합니다. 즉, 오래될 수는 있지만 허용 가능한 수준이라는 것입니다.
그래서 캐시 지속 시간이 30초로 설정된 것이지 30분이 아닌 것입니다. 30분이나 뒤처진 대시보드는 심각할 수 있지만, 팔로워 수가 30초 정도 뒤처지는 것은 큰 문제가 아닐 것입니다. 완전히 최신 상태로 보고 싶다면 페이지를 새로 열고 30초를 기다린 후에 30초 정도 지난 데이터를 보게 될 것입니다.
물론 30초가 허용되지 않는 경우도 많이 있습니다. 이러한 경우 서버 측 렌더링은 좋은 선택이 아닙니다. 대신 Tanstack Query나 SWR과 같은 클라이언트 측 데이터 페칭을 사용하여 원하는 대로 데이터를 항상 최신 상태로 유지하는 것이 좋습니다(10초마다 업데이트하거나, 탭이 포커스될 때마다 등).
가능하다면 WebSocket과 같은 기술을 사용해 완전히 실시간 데이터로 전환하는 것도 고려해야 합니다. 항상 최신 데이터를 필요로 하는 경우에는 서버 측 렌더링이 최선의 해결책이 아닙니다.
경우 2: 사용자 데이터 업데이트 – 잘못된 방식으로 수행된 변이(Mutation)
앞서 언급한 설정 페이지 예를 다시 살펴보겠습니다. 사용자가 자신의 바이오(bio)를 업데이트하고자 한다고 가정합니다. /settings/account 페이지에 가서 바이오를 업데이트한 후 다른 페이지로 이동하거나 리디렉션됩니다. 이제 /settings/account 페이지는 30초 동안 캐시됩니다. 사용자가 30초 이내에 /settings/account 페이지로 다시 돌아가면, 업데이트된 바이오가 아닌 이전 바이오가 표시됩니다. 이런 일이 생기면 당황스럽겠죠!
이 원인은 아마도 fetch("/api/users", { method: "PATCH" }) 같은 방식으로 변이 요청을 보냈기 때문일 것입니다.
Next.js는 사용자가 페이지에서 무언가를 업데이트했는지 알 수 없고, 단지 서버에 무언가를 하기 위해 HTTP 요청을 보냈다는 것만 알 수 있습니다. 따라서 Next.js는 여전히 이 페이지를 "허용 가능한 오래된 데이터"로 간주하고, 캐시된 바이오 데이터를 제공합니다.
따라서 위의 PATCH와 같은 수동 데이터 업데이트 요청은 서버 측 렌더링된 페이지를 업데이트하는 올바른 방법이 아닙니다.
대신 서버 액션(server actions)을 사용해야 하며, 필요한 경우 revalidatePath나 revalidateTag를 함께 사용해야 합니다. 이 방법은 "선택"이 아니라 "필수"입니다. 서버 액션이 Next.js 라우터와 매우 밀접하게 결합되어 있기 때문에, Next.js는 "무언가가 변경되었다"는 것을 알고, 서버 액션 내에서 호출된 revalidatePath나 revalidateTag 함수에 따라 클라이언트 측 캐시를 무효화할 수 있습니다.
서버 액션 + revalidatePath/revalidateTag를 사용하면 Next.js는 데이터가 더 이상 허용 가능한 오래된 데이터가 아니라고 판단합니다.
새로운 데이터가 있다는 것이 보장되므로 캐시를 무효화하고 다시 새로운 데이터를 요청합니다.
수동 REST 스타일의 데이터 업데이트, tRPC, Tanstack Query의 변이 메서드 등은 서버 측 렌더링된 데이터를 업데이트하는 올바른 방법이 아닙니다. 클라이언트 측 렌더링된 데이터를 업데이트하려는 경우에는 괜찮지만, 서버 컴포넌트에서 가져온 데이터를 업데이트하려면 반드시 서버 액션을 사용해야 합니다. 다른 대안은 없습니다.
Conclusion
이제 라우터 캐시가 세 가지 기반 위에 구축되었음을 알 수 있습니다. 서버 부하 감소의 필요성, UX 개선, 그리고 일부 데이터는 허용 가능한 수준에서 오래될 수 있다는 개념입니다.
이는 전통적으로 기대되는 프레임워크의 동작 방식과는 다르기 때문에, 모든 사람에게 큰 놀라움을 줍니다. 하지만 보시다시피, 이는 좋은 기능입니다. Next.js 팀이 이 기능을 포함시킨 데는 충분한 이유가 있으며, 제가 팀의 아이디어를 충분히 이해하여 잘 설명해 드릴 수 있었기를 바랍니다.
또한, 클라이언트 측 데이터 페칭을 서버 측 렌더링으로 완전히 대체하는 것은 좋은 아이디어가 아님을 알 수 있습니다. 항상 최신 상태여야 하는 데이터의 경우 클라이언트 측 데이터 페칭이 적합한 방법입니다.
< 참고자료 >
[사이트] #Midum
<Next.js> Yes, the Next.js Router Cache is Actually Good