본문으로 건너뛰기
Still Curious
뒤로가기

React의 cache 함수는 왜 필요한가

사이드 프로젝트를 시작해서 기술 명세서를 작성하고 있었다. 예전부터 쓰던 next-auth를 쓸 생각이었는데, 찾아보니 더 이상 활발한 업데이트가 없고 better-auth로 넘어가라는 이야기가 보였다. 그래서 better-auth의 사용법을 이것저것 찾아보다가 GitHub에서 이슈 하나를 우연히 발견했다. React에서 import하는 cache라는 함수로 요청 단위 캐싱을 기본 제공해달라는 내용이었다. useMemouseCallback도 아닌, 처음 보는 이름이었다. 이슈를 읽으면서 이 함수가 왜 필요한지 맥락이 잡혔다. 이 글은 그 과정에서 정리한 내용이다.

데이터 페칭의 주체가 바뀌면서 생긴 비용

Page Router 시절에는 getServerSideProps에서 데이터를 한 번 가져와서 페이지 컴포넌트에 props로 내려줬다. 데이터 페칭 지점이 하나였고, 필요한 곳에는 props로 전달하면 됐다.

App Router의 서버 컴포넌트에서는 이야기가 다르다. 각 서버 컴포넌트가 독립적으로 async/await를 써서 데이터를 가져올 수 있다. 편리하지만, 문제도 생긴다.

// layout.tsx
async function Layout({ children }) {
  const user = await getUser(); // DB 조회 1회
  return (
    <>
      <nav>{user.name}</nav>
      <main>{children}</main>
    </>
  );
}

// page.tsx
async function Page() {
  const user = await getUser(); // DB 조회 또 1회
  return <h1>{user.name}의 대시보드</h1>;
}

LayoutPage가 각각 getUser()를 호출한다. 같은 유저 정보를 가져오는 건데 DB 쿼리가 두 번 날아간다. 컴포넌트가 서로 다른 파일에 있고 독립적으로 렌더링되니까, 이런 중복을 알아서 막아주는 장치가 없다. 인증 코드에서 이 문제가 특히 두드러진다. 현재 로그인한 유저 정보는 레이아웃, 네비게이션, 페이지 본문, 사이드바 등 여러 컴포넌트에서 필요하고, 매번 세션을 확인하고 DB를 조회하면 한 페이지 렌더링에 같은 쿼리가 서너 번씩 실행될 수 있다.

cache는 요청 단위로 중복을 흡수한다

cache는 이 중복을 제거한다.

import { cache } from "react";

export const getUser = cache(async () => {
  const session = await getSession();
  if (!session) return null;
  const user = await db.user.findUnique({ where: { id: session.userId } });
  return user;
});

cache로 감싼 함수는 같은 인자로 호출되면 실제 실행은 한 번만 일어나고, 이후 호출에서는 캐싱된 결과를 반환한다. getUser()를 레이아웃에서 호출하든 페이지에서 호출하든 DB 쿼리는 처음 한 번만 실행된다. 각 컴포넌트가 자신에게 필요한 데이터를 직접 요청하면서도, 실제로는 중복이 일어나지 않는다.

이 캐시의 수명은 하나의 서버 요청이 처리되는 동안으로 한정된다. 렌더링이 끝나면 캐시는 사라진다. 유저 A의 요청에서 캐싱된 결과가 유저 B의 요청에 재사용되는 일은 없고, 요청 간에 캐시가 공유되지 않으니 별도의 무효화 로직도 필요 없다. 인증 정보처럼 민감한 데이터에도 안심하고 쓸 수 있는 이유다. useMemo가 컴포넌트 단위의 메모이제이션이라면, cache는 요청 단위의 메모이제이션이다.

better-auth가 이 문제를 떠안지 않은 이유

앞서 언급한 better-auth의 GitHub 이슈 #781은 이 중복 문제를 정면으로 다뤘다. 작성자는 한 요청에서 getSession을 세 번 호출하면 DB 쿼리도 세 번 날아가는 코드를 그대로 보여주면서, better-auth/next-js 같은 프레임워크별 패키지에서 React cache가 적용된 API를 바로 내보내달라고 요청했다. 라이브러리가 알아서 감싸주면 사용자가 중복 제거를 신경 쓸 필요가 없다는 논리였다.

댓글에서는 cookieCache 옵션을 켰는데도 DB 호출이 계속된다는 혼란도 있었다1. 이 논의는 결국 빌트인 지원 없이 마무리됐다. better-auth는 프레임워크에 대해 최소한의 어댑터만 제공하는 방식으로 설계되어 있고, React 특화 코드를 라이브러리 내부에 포함하는 것은 이 원칙과 맞지 않는다는 입장이었다. 요청 단위 중복 제거는 애플리케이션 쪽에서 직접 처리해야 하는 영역으로 남아 있다.

이슈에서 공유된 차선책 패턴은 이렇게 생겼다.

import { cache } from "react";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export const getSession = cache(async () => {
  return auth.api.getSession({
    headers: await headers(),
  });
});lib/session.ts

auth.api.getSession은 호출마다 쿠키를 파싱하고 DB를 조회한다. cache로 감싼 함수를 공용 모듈로 내보내면, 어디서 import하든 한 요청 안에서는 처음 한 번만 실행된다.

fetch에는 이미 있고, 나머지에는 없던 것

fetch를 사용하는 경우에는 이미 자동 중복 제거가 동작한다. 같은 URL로 같은 옵션의 fetch를 여러 번 호출하면 실제 네트워크 요청은 한 번만 일어난다. React가 제공하는 요청 메모이제이션 기능이고, GET 메서드에만 적용된다.

그런데 모든 데이터가 fetch를 통해 오는 건 아니다. ORM으로 DB에 직접 쿼리를 날리거나 외부 SDK를 통해 데이터를 가져오는 경우에는 이 자동 중복 제거가 적용되지 않는다. cache는 이 빈자리를 채운다. fetch를 쓸 수 없는 데이터 소스에 대해 같은 수준의 중복 제거를 제공한다.

같은 값을 넘겨도 캐시 미스가 나는 이유

cache는 인자의 캐시 히트 여부를 Object.is로 판단한다. 프리미티브 값은 값이 같으면 캐시 히트가 되지만, 객체는 같은 참조여야 한다.

const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});

await getUser("user-1"); // 실행
await getUser("user-1"); // 캐시 히트 (프리미티브 값이라 동일 판정)

const getMetrics = cache(async (point: { x: number; y: number }) => {
  return calculateMetrics(point);
});

await getMetrics({ x: 10, y: 20 }); // 실행
await getMetrics({ x: 10, y: 20 }); // 캐시 미스 (새 객체라 참조가 다름)

가능하면 인자를 프리미티브 값으로 분리해서 넘기는 게 안전하다. 위의 better-auth 예시에서 { headers: await headers() }를 인자로 넘기지 않고 인자 없는 함수로 감싸는 이유도 여기에 있다. await headers()는 호출할 때마다 새 객체를 반환하기 때문에, 그대로 넘기면 캐시 미스가 계속 발생한다.

이 참조 비교 방식은 에러에도 적용된다. cache로 감싼 함수가 에러를 던지면 그 에러도 캐싱된다. 같은 인자로 다시 호출해도 함수를 재실행하지 않고 캐싱된 에러를 다시 던진다. 일시적인 네트워크 장애 같은 에러가 요청 내내 반복될 수 있으므로, 에러가 발생할 수 있는 함수에서는 이 동작을 염두에 둬야 한다.

캐시 단위도 주의가 필요하다. cache를 같은 함수에 여러 번 감싸면 각각 별도의 캐시를 가진다. 하나의 함수에 cache를 한 번만 적용하고, 그 결과를 여러 컴포넌트에서 import해서 쓰는 게 의도대로 동작하는 패턴이다.

‘use cache’와 이름만 같다

Next.js 16에서 안정화된 'use cache' 디렉티브는 이름이 비슷하지만 React의 cache 함수와는 완전히 다른 메커니즘이다.

앞에서 살펴본 것처럼 React cache는 하나의 요청 안에서만 유효하고, 렌더링이 끝나면 사라진다. 반면 'use cache'는 요청 간에 지속되는 캐싱이다. 한 번 캐싱된 결과가 다른 유저의 다른 요청에서도 재사용될 수 있고, cacheLife로 수명을 설정하거나 cacheTag로 무효화를 제어할 수 있다.

또한 'use cache' 경계 안에서는 React cache가 격리된 스코프로 동작한다. 외부에서 cache로 저장한 값이 'use cache' 내부에서는 보이지 않는다. 두 기능을 함께 쓸 때는 이 격리를 인지하고 있어야 한다.

낯선 이름이 가리킨 것

처음 cache를 봤을 때는 useMemouseCallback도 아닌 낯선 이름이 눈에 걸렸을 뿐이었다. 돌이켜보면 낯선 건 이름이 아니라 문제 자체였다. 서버 컴포넌트가 데이터 페칭을 컴포넌트 안으로 가져오면서, 같은 데이터를 여러 곳에서 독립적으로 요청하는 상황이 생겼다. 클라이언트 컴포넌트에서는 존재하지 않던 종류의 중복이다. useMemo가 컴포넌트 안의 계산을 다시 하지 않기 위한 함수라면, cache는 하나의 요청 안의 데이터 페칭을 다시 하지 않기 위한 함수다. 풀어야 할 문제가 달라졌으니 도구도 달라진 거였다.

각주

  1. cookieCache 옵션이 이 문제를 해결한다고 생각할 수 있는데, 둘은 해결하는 영역이 다르다. cookieCache는 세션 데이터를 서명된 쿠키에 저장해서 요청 간 DB 조회를 줄이는 것이고, cache()는 하나의 요청 안에서 같은 함수의 중복 실행을 제거한다. cookieCache가 활성화되어 있어도, 같은 렌더링 패스 내에서 여러 컴포넌트가 getSession()을 각각 호출하면 쿠키 파싱과 서명 검증 로직이 매번 반복된다.