React로 서버 코드를 작성하다 보면, 한 번쯤은 설명하기 애매한 불편함을 마주하게 된다.
인증을 확인한 결과나 locale, 비용이 큰 계산 결과처럼 하나의 요청에 묶인 데이터를 여러 Server Component가 함께 써야 할 때가 있다. 모든 컴포넌트에 props로 전달하는 방법은 명시적이지만 반복이 생긴다. Client Context도 같은 문제를 해결하지는 못한다. 값을 필요로 하는 코드가 클라이언트 트리가 아니라 서버 전용 데이터 접근 계층에 있을 수 있기 때문이다.
이때 React의 cache API는 자연스러운 답처럼 보인다. React가 memoized function의 캐시를 서버 요청마다 무효화하므로, 가변 객체를 반환해 request-local store처럼 사용할 수 있다고 생각하기 쉽다.
하지만 그렇게 해석하면 API가 보장하는 범위를 넘어선다.
내가 지금 더 정확하다고 생각하는 경계는 다음과 같다.
cache는 React Server Component 렌더 안에서 요청마다 무효화되는 memoization을 제공한다. 범용적인 가변 request context를 제공하지는 않는다.
범위를 이렇게 좁혀도 충분히 유용하다. 오히려 동작을 이해하고 검증하기는 훨씬 쉬워진다.
먼저 구분해야 하는 두 가지 cache
이 글에서 다루는 것은 React의 cache(fn)이다. Next.js의 별도 지시어인 "use cache"와는 이름이 비슷하지만 역할이 다르다.
- React
cache(fn)는 Server Component 렌더 안에서 같은 함수와 같은 인자로 수행한 작업을 중복하지 않도록 한다. React는 이 memoization cache를 서버 요청마다 무효화한다. - Next.js
"use cache"는 route, component, function을 프레임워크가 캐시할 수 있다고 표시하는 지시어다. 직렬화된 입력으로 키를 만들고, 설정한 수명과 무효화 정책에 따라 결과를 재사용한다. 실행 환경에 따라 런타임 캐시의 지속 범위는 달라질 수 있지만, 요청 단위의 상태 저장소를 만드는 기능은 아니다.
Next.js의 "use cache" 문서는 cookies()나 headers() 같은 runtime API를 일반 "use cache" 경계 안에서 직접 읽지 말고, 바깥에서 필요한 값을 읽어 인자로 전달하라고 설명한다. 이 글에서 해결하려는 Server Component 렌더 안의 중복 제거와는 다른 설계 문제다.
React가 실제로 보장하는 것
React의 공식 cache 문서에 따르면 cache(fn)는 React Server Component에서 사용할 memoized function을 만든다.
중요한 경계는 다음과 같다.
- 같은 memoized function을 같은 인자로 호출하면 결과를 공유할 수 있다.
- React는 서버 요청마다 memoization cache를 무효화한다.
cache를 호출할 때마다 서로 다른 memoized function이 만들어진다. 여러 곳에서 결과를 공유하려면 하나의 wrapper를 export해 함께 사용해야 한다.- 같은 인자에서 발생한 오류도 캐시된다.
cache는 Server Component 전용이다.- 컴포넌트 렌더 밖에서 memoized function을 호출하면 React cache를 읽거나 갱신하지 않는다.
마지막 항목은 특히 놓치기 쉽다. 함수가 어느 파일에 선언됐는지가 캐시 여부를 정하는 것은 아니다. 그 함수가 React Server Component의 렌더 컨텍스트 안에서 호출되는지가 중요하다.
Server Component를 렌더하는 과정에서 호출된 공용 데이터 접근 함수는 같은 컨텍스트에 참여할 수 있다. 반면 Route Handler, Server Action, middleware, background job, module initialization에서 같은 함수를 호출한다고 해서 동일한 memoization이 적용된다고 가정해서는 안 된다.
따라서 “request-scoped”라는 표현은 React가 entry를 언제 버리는지 설명한다. 하나의 HTTP 요청을 처리하는 모든 서버 코드에서 접근 가능한 ambient store가 생긴다는 뜻은 아니다.
잘 맞는 사용법: 렌더당 하나의 불변 스냅샷
cache를 가장 안전하게 사용하는 방법은 값을 직접 파생해 반환하고, setter를 만들지 않는 것이다.
예를 들어 Next.js의 서버 전용 데이터 접근 모듈에서 요청의 세션을 검증하고, 렌더에 필요한 최소한의 read-only viewer snapshot만 반환할 수 있다.
// app/lib/viewer.ts
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { verifySessionToken } from "./session";
export type ViewerSnapshot = Readonly<{
userId: string;
role: "member" | "admin";
}>;
export const getViewerSnapshot = cache(
async (): Promise<ViewerSnapshot | null> => {
const token = (await cookies()).get("session")?.value;
if (!token) return null;
const session = await verifySessionToken(token);
if (!session) return null;
return Object.freeze({
userId: session.userId,
role: session.role,
});
},
);
같은 Server Component 렌더 안에서 두 컴포넌트가 이 함수를 호출하면 세션 검증 작업과 그 결과를 재사용할 수 있다. 다른 요청에서는 새로운 React cache가 사용된다.
여기에는 초기화 단계나 변경 단계가 없다. 값은 server-only 경계 안에서 요청 입력으로부터 직접 만들어진다. cache는 중복 작업만 줄인다. 세션을 만들거나 그 세션을 신뢰할 수 있게 해주는 기능은 아니다.
같은 패턴은 다음과 같은 경우에도 잘 맞는다.
- 여러 Server Component가 사용하는 데이터베이스 조회나 API 요청의 중복 제거
- 하나의 렌더 동안 일관된 데이터 스냅샷 공유
- 비용이 큰 결정론적 계산의 memoization
- fetch를 미리 시작한 뒤 트리 아래에서 같은 Promise 재사용
이는 React가 문서에서 설명하는 fetch, computation, snapshot sharing, preloading의 범위와도 맞는다.
가변 request store로 사용하지 않는 이유
cached function이 가변 컨테이너를 반환하게 만든 뒤 request-scoped cache manager처럼 다룰 수도 있다. 나는 이 패턴을 사용하지 않는 편이 맞다고 생각한다.
이 구조에서는 트리의 한 부분이 먼저 값을 쓰고, 다른 부분이 나중에 읽어야 한다. 곧 렌더 순서가 제어 흐름이 된다. 렌더 중에 컴포넌트 밖에서 만들어진 값을 변경한다는 점도 문제다.
React의 순수성 규칙이 중요한 이유는 렌더가 중단되거나 재개되고, 다시 실행될 수 있기 때문이다. 숨겨진 쓰기가 있으면 결과가 입력이 아니라 실행 이력에 따라 달라진다.
가변 컨테이너를 반환해도 API의 실제 경계를 벗어날 수는 없다. 같은 HTTP 요청 안이더라도 Server Component 렌더 밖의 호출은 memoized instance를 안정적으로 공유하지 못한다. React가 문서화한 것은 memoized result이지, 수명주기를 관리해주는 request store가 아니다.
인증 정보에서는 이런 모호함의 비용이 더 크다. 한 컴포넌트가 인증 사용자를 주입하고, 다른 코드가 나중에 ambient mutable value를 신뢰하게 만들고 싶지는 않다. 인증은 명시적인 서버 경계에서 직접 파생하고 검증해야 한다. 인가는 보호하는 데이터나 작업에 가까운 곳에서 다시 확인해야 한다.
Client Provider는 Server Component cache에 값을 쓰지 못한다
Client Component는 초기 페이지 로드에서 서버가 미리 렌더할 수 있다. 그러나 "use client"는 여전히 client module graph와 server module graph의 경계를 만든다. Next.js도 Server와 Client Component 문서에서 이 실행 환경을 구분한다. Client Component 코드는 브라우저에서 실행될 수 있다는 전제로 다뤄야 한다.
따라서 Client Provider가 server cache module을 import하고 setter를 호출해 Server Component용 상태를 초기화하는 구조는 성립하지 않는다. cache는 Server Component API이고, 데이터가 그 방향으로 흐르지 않는다.
반대 방향에는 공식적인 패턴이 있다. Server Component가 cached function을 호출한 뒤, 직렬화 가능한 데이터나 그 데이터의 Promise를 Client Provider에 전달할 수 있다. Provider는 Client Context를 통해 그 값을 Client Component에 제공한다.
이것은 서버에서 클라이언트로 데이터를 전달하는 방식이다. Provider가 React의 Server Component cache를 변경하는 방식은 아니다.
실제 request context가 필요할 때
어떤 경우에는 memoization이 진짜 요구사항이 아닐 수 있다. 로깅이나 tracing, framework-independent service layer가 비동기 서버 호출 흐름 전체에서 값을 명령형으로 읽어야 할 수 있다.
나는 먼저 프레임워크가 제공하는 request API를 사용한다. Next.js에서는 cookies(), headers(), Route Handler에 전달되는 Request 또는 NextRequest가 요청 경계를 드러낸다. 그 경계에서 입력을 읽고 작은 값만 domain code에 명시적으로 전달하는 방법이 덜 영리해 보여도 더 신뢰하기 쉽다.
Node.js의 요청 진입점을 직접 제어하고, 실제로 ambient server-only context가 필요하다면 AsyncLocalStorage를 검토할 수 있다.
import { AsyncLocalStorage } from "node:async_hooks";
type RequestContext = Readonly<{
requestId: string;
}>;
const requestContext = new AsyncLocalStorage<RequestContext>();
export function withRequestContext<T>(
context: RequestContext,
work: () => T,
): T {
return requestContext.run(Object.freeze(context), work);
}
export function getRequestContext(): RequestContext {
const context = requestContext.getStore();
if (!context) throw new Error("Request context is unavailable");
return context;
}
이 방식도 React에 바로 끼워 넣을 수 있는 범용 패턴은 아니다. 직접 제어하는 진입점에서 context를 만들어야 하고, 호환되는 Node.js runtime에 의존한다. Edge runtime이나 다른 서버 환경에서도 같을 것이라고 가정하면 안 된다.
Node 문서는 callback 기반 API나 custom thenable에서 드물게 context가 유실될 수 있다고 설명한다. 이런 경우에는 promisification이나 AsyncResource가 필요할 수 있다.
AsyncLocalStorage를 사용하더라도 request ID나 trace metadata처럼 좁고 불변인 값만 두는 편을 선호한다. 인증과 business state를 담은 가변 저장소로 만들면, 같은 숨은 의존성이 다른 계층에서 다시 생긴다.
보안 데이터에는 보이는 경계가 필요하다
Memoization은 authorization boundary가 아니다.
Next.js는 data security 가이드에서 서버 전용 데이터 접근 계층이 authorization을 확인하고, 안전한 최소 DTO만 반환하는 방식을 권장한다. cache로 중복 작업을 줄이더라도 이 구조는 유지해야 한다.
- 요청의 credential은 서버에서 검증한다.
- 보호되는 데이터나 mutation에 가까운 곳에서 authorization을 확인한다.
- 렌더에 필요한 필드만 반환한다.
- 권한이 필요한 모듈은 server-only로 제한한다.
- server-client 경계를 넘길 때는 클라이언트에 안전한 데이터만 명시적으로 전달한다.
cached viewer snapshot은 한 번의 렌더 동안 반복 조회를 줄이고 일관된 결과를 제공할 수 있다. 토큰을 담는 컨테이너나 authorization의 대체물, 명시적인 서버 경계를 우회하는 방법이 되어서는 안 된다.
결국 나눠서 봐야 하는 문제
“React에서 요청 단위 데이터를 어떻게 공유할까”라는 질문 안에는 서로 다른 문제가 섞여 있다.
- 여러 Server Component가 같은 불변 fetch 또는 계산 결과를 필요로 한다면 React
cache가 잘 맞는다. - Client Component가 UI state를 공유해야 한다면 Client Context를 사용하고, 서버에서는 안전한 입력만 전달한다.
- 코드가 실제 요청 입력을 필요로 한다면 프레임워크의 request API와 명시적인 인자를 사용한다.
- Node.js 서버에서 명령형 async context가 꼭 필요하다면 runtime과 propagation 제약을 확인한 뒤
AsyncLocalStorage를 검토한다. - 값이 민감한 데이터의 접근을 결정한다면 검증과 authorization을 서버 경계에 명시적으로 둔다.
React cache는 request-scoped state system보다 작은 도구이기 때문에 유용하다. Server Component가 한 번의 렌더 안에서 작업을 공유하고 같은 snapshot을 보게 해준다. 이를 요청 안에 숨은 가변 전역 상태가 아니라 그 정도의 도구로 다루는 편이 React가 실제로 제공하는 계약과 맞다.