본문 바로가기
프로그래밍

RSC 하이드레이션: 서버는 왜 BFF를 건너뛰는가

by me_in_sk 2026. 5. 30.
반응형
NullVest Engineering 프론트엔드 아키텍처 · 2/2

RSC 하이드레이션: 서버는 왜 BFF를 건너뛰는가

2026-05-16 · ADR-0035 대상 코드 features/*/server · lib/server/react-query
TL;DR
  • 1편에서 BFF를 깔았지만, 정작 RSC 효용은 0이었다 — 'use client' 파일 289개, 데이터는 전부 클라이언트에서만 페칭됐다.
  • 같은 백엔드로 가는 문이 두 개다. 클라이언트는 BFF를 거치고(쿠키→JWT), 서버(RSC)는 BFF를 건너뛰어 백엔드에 직접 들어간다(세션→JWT 직접 주입).
  • 둘을 잇는 끈은 단 하나 — 공유 query key. 서버 prefetch → dehydrateHydrationBoundary → 클라이언트 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 백엔드로 가는 데이터 페칭 경로가 둘 있고, 그게 정확히 클라이언트 경로서버 경로다.

브라우저 (Client Component) └─ useAiModels() ──▶ /api/v1/core/ai-models ──▶ [ BFF ] ──▶ core-service (쿠키 → JWT 변환) 서버 (RSC · 초기 렌더) └─ prefetchAiLabHub() ──▶ coreServiceFetch('/ai-models') ──▶ core-service (세션 → JWT 직접 주입 · BFF 우회)

서버 페처는 coreServiceFetch()로 백엔드에 직접 들어간다. /api/v1/core BFF 라우트를 거치지 않는다.

features/ai-lab/server/queries.tsTypeScript · RSC
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를 통해 가져온다.

features/ai-lab/api/queries.tsTypeScript · Client
'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를 건너뛰나

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다.

lib/server/react-query.tsTypeScript · RSC
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가 핸드오프의 전부다 — 잠시 후 다시 본다.

features/ai-lab/server/prefetch.tsTypeScript · RSC
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에 박는다.

app/[locale]/(app)/dashboard/ai-lab/page.tsxTSX · RSC
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>
  );
}
디테일 — 왜 try-catch가 없나

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한다.

features/ai-lab/api/keys.tsTypeScript · shared
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와도 맞물린다. modelsListmodels()의 하위 key라, 라이프사이클 이벤트가 invalidateQueries({ queryKey: aiLabKeys.models() })를 부르면 prefix 매칭으로 hydrate된 캐시까지 한 번에 무효화된다.

5.경계를 코드로 강제하기

RSC와 client가 한 폴더에 섞이면, 실수로 client 코드가 RSC 모듈 그래프에 끌려 들어가 빌드가 깨진다. 그래서 경계를 말이 아니라 구조와 lint로 강제한다.

  1. lib/ 3분할server/(import 'server-only'), client/(DOM/auth 의존), shared/(양쪽 안전, DOM 의존 0).
  2. _client/ 컨벤션'use client' 컴포넌트는 전부 components/_client/ 아래로 격리. components/ 직속에는 RSC-safe presentation만 남는다.
scripts/lint-client-boundary.mjsNode · CI
// `_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: Nmarketplace 카탈로그, 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 채택의 실익이 비로소 화면에서 드러나는 지점이다.


시리즈 완결

프론트엔드 아키텍처 2부작

1편 — Next.js를 BFF로: stateless JWE 세션
2편 — RSC 하이드레이션: 서버는 왜 BFF를 건너뛰는가 (현재 글)

NullVest Engineering Blog · 프론트엔드 아키텍처 시리즈 2/2

반응형

댓글