RSC 하이드레이션: 서버는 왜 BFF를 건너뛰는가
- 1편에서 BFF를 깔았지만, 정작 RSC 효용은 0이었다 —
'use client'파일 289개, 데이터는 전부 클라이언트에서만 페칭됐다. - 같은 백엔드로 가는 문이 두 개다. 클라이언트는 BFF를 거치고(쿠키→JWT), 서버(RSC)는 BFF를 건너뛰어 백엔드에 직접 들어간다(세션→JWT 직접 주입).
- 둘을 잇는 끈은 단 하나 — 공유 query key. 서버 prefetch →
dehydrate→HydrationBoundary→ 클라이언트useQuery가 같은 key로 즉시 캐시 히트(refetch 0). - 경계는 말이 아니라 폴더 + lint로 강제한다:
lib/{server,client,shared}+_client/컨벤션 +lint:client-boundary.
1.1편 이후의 문제 — BFF만으론 RSC가 안 켜진다
1편에서 getServerSession()으로 서버가 인증 컨텍스트를 읽을 수 있게 됐다. 그런데
마이그레이션 직후 코드를 보면 RSC의 실익이 거의 없었다.
features/에'use client'파일이 289개 (+ 랜딩 30개). RSC 효용 ≈ 0%.page.tsx는 RSC + Suspense thin shell이지만, 내부 hub 컴포넌트 (AiLabHub등)가 전부 client라 데이터 페칭이 클라이언트에서만 일어났다.lib/가 server/client 경계를 폴더로 강제하지 않아, 잘못된 import가 빌드 시점에 안 잡혔다.
결국 사용자는 빈 셸을 먼저 받고, JS가 로드된 뒤에야 데이터 요청이 나갔다. BFF를 깔아 서버가 인증된 패칭을 할 수 있게 됐는데도, 그 패칭을 서버에서 시작하는 코드가 없었던 것이다.
2.핵심 통찰 — 같은 백엔드, 문이 두 개
NullVest web에는 같은 FastAPI 백엔드로 가는 데이터 페칭 경로가 둘 있고, 그게 정확히 클라이언트 경로와 서버 경로다.
서버 페처는 coreServiceFetch()로 백엔드에 직접 들어간다.
/api/v1/core BFF 라우트를 거치지 않는다.
import 'server-only'; import { coreServiceFetch } from '@/lib/server/api-fetch'; // 모델 목록은 mutation 빈도 높음 → cache: 'no-store' // (라이프사이클 WS 이벤트로 client invalidate되므로 server cache 불필요) export async function fetchAiModelsOnServer(opts, init?): Promise<AiModelListResponse> { const res = await coreServiceFetch('/ai-models', { // ← 백엔드 직접. BFF(/api/v1/core) 안 거침 cache: 'no-store', signal: init?.signal, }); if (!res.ok) throw new Error(`ai-lab.listModels failed: ${res.status}`); return res.json(); }
대조적으로 클라이언트 훅은 같은 데이터를 BFF를 통해 가져온다.
'use client'; export function useAiModels(options?) { return useQuery({ queryKey: aiLabKeys.modelsList(includeArchived, includeDeleted), // ← 서버와 동일 key queryFn: () => aiLabApi.listModels({ ... }), // → fetch('/api/v1/core/ai-models') = BFF 경유 }); }
BFF의 존재 이유는 브라우저를 위한 것이다 — JS가 토큰을 못 읽게 쿠키를 봉인하고, 그 쿠키를
JWT로 번역해 주는 것(1편). 그런데 RSC는 이미 서버에서 세션을 직접 쥐고 있다. 굳이 자기
프로세스의 HTTP 라우트(/api/v1/core)를 다시 호출할 이유가 없다 — 한 hop만
낭비될 뿐이다. 그래서 1편의 buildBackendHeaders()로 만든 똑같은 trust 헤더를
들고 백엔드로 server-to-server로 직행한다. 두 문은 다른 입구지만, 백엔드가 받는 신원 헤더는 동일하다.
3.핸드오프 — prefetch → dehydrate → hydrate
서버가 데이터를 미리 받아도, 클라이언트가 그걸 못 이어받으면 똑같이 다시 페칭한다(더블 페치). TanStack Query의
dehydrate/hydrate가 이 핸드오프를 담당한다. 출발점은 요청별 QueryClient다.
import 'server-only'; import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query'; // 요청별 인스턴스(cache 누수 차단, ADR-0035 D5) export function getServerQueryClient(): QueryClient { return new QueryClient({ defaultOptions: { queries: { staleTime: 5_000, // hydration 직후 client refetch 방지 refetchOnWindowFocus: false, // (1s 미만이면 SSR 데이터가 곧장 stale → SSR 효용 소멸) }, }, }); } export { HydrationBoundary, dehydrate };
prefetch 헬퍼는 이 client에 데이터를 채운다. 이때 쓰는 queryKey가 핸드오프의 전부다 — 잠시 후 다시 본다.
import 'server-only'; import { aiLabKeys } from '@/features/ai-lab/api/keys'; import { fetchAiModelsOnServer } from '@/features/ai-lab/server/queries'; export async function prefetchAiLabHub(qc: QueryClient, init?): Promise<void> { await qc.prefetchQuery({ queryKey: aiLabKeys.modelsList(false, false), // ← 클라이언트 useQuery와 정확히 같아야 함 queryFn: ({ signal }) => fetchAiModelsOnServer({ includeArchived: false, includeDeleted: false }, { signal: init?.signal ?? signal }), staleTime: 5_000, }); }
page.tsx는 이 셋을 조립한다. getServerQueryClient로 비우고 →
prefetch로 채우고 → dehydrate로 직렬화해
HydrationBoundary에 실어 HTML에 박는다.
export default async function DashboardAiLabPage({ params }: Props) { const { locale } = await params; const qc = getServerQueryClient(); // prefetchQuery는 reject하지 않는다 → try-catch 불필요. 실패 시 client가 재페칭. // 느린 백엔드가 SSR을 막지 않도록 3초 타임아웃. await prefetchAiLabHub(qc, { signal: AbortSignal.timeout(3_000) }); return ( <HydrationBoundary state={dehydrate(qc)}> {/* 채워진 캐시를 HTML로 직렬화 */} <Suspense> <AiLabHub locale={locale} /> {/* client 컴포넌트 — 같은 key로 캐시 히트 */} </Suspense> </HydrationBoundary> ); }
ADR 예시엔 try/catch가 있지만, 실제 코드엔 없다. prefetchQuery는
의도적으로 reject하지 않기 때문이다 — 에러는 캐시 상태로만 남고, 클라이언트가 hydration 후
알아서 재페칭한다. 그래서 prefetch 실패가 페이지 렌더를 막지 않는다. UX 차단 0.
4.단 하나의 끈 — 공유 query key
서버 prefetch와 클라이언트 useQuery가 캐시를 공유하는 유일한 조건은 queryKey가 정확히 같다는 것이다.
그래서 key는 양쪽이 import하는 단일 출처(api/keys.ts)에 둔다 — 순수 객체라
'use client' 마커 없이 RSC와 client 둘 다 안전하게 import한다.
export const aiLabKeys = { all: ['ai-lab'] as const, models: () => [...aiLabKeys.all, 'models'] as const, modelsList: (includeArchived, includeDeleted) => [...aiLabKeys.models(), 'list', { archived: includeArchived, deleted: includeDeleted }] as const, model: (modelId) => [...aiLabKeys.models(), modelId] as const, summary: () => [...aiLabKeys.all, 'summary'] as const, };
서버 prefetch는 aiLabKeys.modelsList(false, false)로 채우고, 클라이언트 훅도
aiLabKeys.modelsList(...)로 읽는다. 한 글자라도 어긋나면 캐시 미스 → 더블 페치가
되어 SSR 효용이 사라진다. 그래서 같은 함수 호출로 강제하는 것이다.
덤으로, 이 key 구조는 WebSocket live-sync와도 맞물린다. modelsList가
models()의 하위 key라, 라이프사이클 이벤트가
invalidateQueries({ queryKey: aiLabKeys.models() })를 부르면
prefix 매칭으로 hydrate된 캐시까지 한 번에 무효화된다.
5.경계를 코드로 강제하기
RSC와 client가 한 폴더에 섞이면, 실수로 client 코드가 RSC 모듈 그래프에 끌려 들어가 빌드가 깨진다. 그래서 경계를 말이 아니라 구조와 lint로 강제한다.
lib/3분할 —server/(import 'server-only'),client/(DOM/auth 의존),shared/(양쪽 안전, DOM 의존 0)._client/컨벤션 —'use client'컴포넌트는 전부components/_client/아래로 격리.components/직속에는 RSC-safe presentation만 남는다.
// `_client/` 경계를 강제하는 도메인. CI 보장을 조용히 끄지 못하게 // 환경 변수가 아니라 하드코딩한다. const ENFORCED_DOMAINS = new Set([ 'ai-lab', 'billing', 'bots', 'org', 'marketplace', 'backtests', 'optimizations', 'strategies', 'landing', ]); // features/<domain>/components/ 직속 파일이 'use client'로 시작하면 빌드 실패 // (client component는 components/_client/ 아래에 있어야 함. 예외: page/error/loading 등) if (trimmed.startsWith("'use client'")) { errors.push(`${rel}: 'use client' file must live under components/_client/`); }
client-only 패키지는 쓰지 않았다
처음엔 client-only 마커로 막으려 했지만, Next.js의 RSC↔CC 모듈 그래프 분석이
정상적인 패턴(layout.tsx(RSC) → sidebar.tsx('use client')
→ http-client.ts)에서도 transitive deps를 RSC 그래프로 끌어와 가드를 부당하게 fail시켰다.
그래서 패키지 가드 대신 폴더 컨벤션 + 자체 lint 스크립트로 대체했다.
6.cache는 도메인이 정한다
서버 페치의 cache 정책은 일률적이지 않다. 데이터 성격에 따라 도메인별로 정하고,
그 사유를 코드 주석에 남긴다(ADR-0035 D6).
| 데이터 성격 | 정책 | 예시 |
|---|---|---|
| mutation 빈도 높음 (WS로 갱신) | cache: 'no-store' | AI Lab 모델 목록, 백테스트 잡 |
| 거의 정적 | force-cache / revalidate: N | marketplace 카탈로그, billing 플랜 정의 |
AI Lab 모델 목록이 'no-store'인 건, 학습/삭제/복구 라이프사이클이 WebSocket으로 끊임없이
들어와 server cache가 즉시 stale해지기 때문이다. 캐싱해 봐야 곧바로 invalidate되니 의미가 없다.
7.정리
두 편을 관통하는 한 문장: BFF는 브라우저의 문, RSC 직접 페치는 서버의 문이고, 둘 다 같은 trust 헤더로 같은 백엔드에 도착한다. BFF(1편)가 "토큰을 어떻게 안전하게 보관하고 백엔드에 전달하나"를 풀었다면, RSC 하이드레이션(2편)은 "그 인증을 서버 렌더 시점에 어떻게 활용하나"를 푼다.
| 구분 | 클라이언트 경로 | 서버(RSC) 경로 |
|---|---|---|
| 입구 | BFF /api/v1/core/* | coreServiceFetch() 직접 |
| 인증 출처 | 쿠키 → BFF가 JWT 변환 | 서버 세션 → JWT 직접 주입 |
| 언제 | 상호작용 · mutation · 라이브 갱신 | 초기 렌더 prefetch |
| 이어주는 끈 | 공유 query key (features/*/api/keys.ts) + dehydrate/hydrate | |
결과: 초기 데이터가 HTML에 실려 와 빈 셸 → 페칭 → 렌더의 워터폴이 사라지고, hydration 직후 추가 요청도 없다(staleTime 5s). 그러면서 WebSocket live-sync는 prefix invalidation으로 그대로 동작한다. BFF 채택의 실익이 비로소 화면에서 드러나는 지점이다.
'프로그래밍' 카테고리의 다른 글
| Next.js를 BFF로: 토큰을 브라우저에서 지우는 stateless JWE 세션 (0) | 2026.05.30 |
|---|
댓글