메인 콘텐츠로 건너뛰기
Sonamu의 registerSSR를 사용하여 서버에서 데이터를 미리 로드하고 클라이언트로 전달하는 방법을 알아봅니다.

데이터 프리로딩 개요

registerSSR

라우트별 preload 설정백엔드 직접 호출

SSRQuery

타입 안전한 쿼리모델/메서드 지정

자동 주입

QueryClient에 자동 주입Hydration 처리

No HTTP

네트워크 오버헤드 없음빠른 응답

registerSSR 사용법

기본 구조

// api/src/application/sonamu.ts
import { registerSSR } from "sonamu";

registerSSR({
  path: "/users/:id",
  preload: (params) => [
    {
      modelName: "UserModel",
      methodName: "getUser",
      params: ["C", parseInt(params.id)],
      serviceKey: ["User", "getUser"],
    },
  ],
});
작동 과정:
  1. 서버에서 /users/123 요청 받음
  2. path 매칭: /users/:idparams = { id: "123" }
  3. preload 함수 실행 → SSRQuery[] 반환
  4. UserModel.getUser("C", 123) 백엔드 직접 호출 (HTTP 없음!)
  5. 결과를 QueryClient.setQueryData(["User", "getUser", "C", 123], result) 주입
  6. HTML + dehydratedState를 클라이언트로 전송
  7. 클라이언트가 hydrate하여 즉시 데이터 사용

SSRQuery 타입

type SSRQuery = {
  modelName: string;        // "UserModel" - 백엔드 모델 클래스명
  methodName: string;       // "getUser" - 모델 메서드명
  params: unknown[];        // ["C", 123] - 메서드 파라미터 (Context 제외)
  serviceKey: [string, string]; // ["User", "getUser"] - React Query queryKey 앞부분
};
중요: params백엔드 메서드의 파라미터 순서를 따릅니다 (Context 제외).

실전 예제

단일 데이터 로딩

사용자 상세 페이지에서 사용자 정보를 프리로드합니다.
// api/src/application/sonamu.ts
registerSSR({
  path: "/users/:id",
  preload: (params) => [
    {
      modelName: "UserModel",
      methodName: "getUser",
      params: ["C", parseInt(params.id)],  // subset, id 순서
      serviceKey: ["User", "getUser"],
    },
  ],
});
// web/src/routes/users/$id.tsx
import { createFileRoute } from "@tanstack/react-router";
import { UserService } from "@/services/services.generated";

export const Route = createFileRoute("/users/$id")({
  component: UserPage,
});

function UserPage() {
  const { id } = Route.useParams();
  
  // 서버에서 프리로드된 데이터를 자동으로 사용
  // isLoading: false (이미 데이터가 있음)
  const { data } = UserService.useUser("C", parseInt(id));

  return (
    <div>
      <h1>{data?.user.username}</h1>
      <p>{data?.user.email}</p>
      <p>Bio: {data?.user.bio}</p>
    </div>
  );
}

여러 데이터 동시 로딩

게시글 상세 페이지에서 게시글과 댓글을 동시에 프리로드합니다.
// api/src/application/sonamu.ts
registerSSR({
  path: "/posts/:id",
  preload: (params) => [
    // 게시글 데이터
    {
      modelName: "PostModel",
      methodName: "getPost",
      params: ["C", parseInt(params.id)],
      serviceKey: ["Post", "getPost"],
    },
    // 댓글 목록
    {
      modelName: "CommentModel",
      methodName: "getCommentsByPost",
      params: [parseInt(params.id)],
      serviceKey: ["Comment", "getCommentsByPost"],
    },
  ],
});
// web/src/routes/posts/$id.tsx
import { createFileRoute } from "@tanstack/react-router";
import { PostService, CommentService } from "@/services/services.generated";

export const Route = createFileRoute("/posts/$id")({
  component: PostPage,
});

function PostPage() {
  const { id } = Route.useParams();
  
  // 두 데이터 모두 프리로드되어 있음
  const { data: post } = PostService.usePost("C", parseInt(id));
  const { data: comments } = CommentService.useCommentsByPost(parseInt(id));

  return (
    <div>
      <article>
        <h1>{post?.post.title}</h1>
        <p>{post?.post.content}</p>
      </article>
      
      <section>
        <h2>Comments ({comments?.comments.length})</h2>
        {comments?.comments.map((comment) => (
          <div key={comment.id}>{comment.content}</div>
        ))}
      </section>
    </div>
  );
}

파라미터 가공

URL 파라미터를 가공하여 사용할 수 있습니다.
// api/src/application/sonamu.ts
registerSSR({
  path: "/categories/:slug/posts",
  preload: (params) => {
    // slug를 id로 변환하는 로직
    const categoryId = getCategoryIdBySlug(params.slug);
    
    return [
      {
        modelName: "CategoryModel",
        methodName: "getCategory",
        params: [categoryId],
        serviceKey: ["Category", "getCategory"],
      },
      {
        modelName: "PostModel",
        methodName: "getPostsByCategory",
        params: [categoryId, { page: 1, pageSize: 20 }],
        serviceKey: ["Post", "getPostsByCategory"],
      },
    ];
  },
});

조건부 프리로딩

특정 조건에 따라 다른 데이터를 로드할 수 있습니다.
// api/src/application/sonamu.ts
registerSSR({
  path: "/dashboard/:tab",
  preload: (params) => {
    const queries: SSRQuery[] = [
      // 공통 데이터: 사용자 정보
      {
        modelName: "UserModel",
        methodName: "getCurrentUser",
        params: [],
        serviceKey: ["User", "getCurrentUser"],
      },
    ];

    // 탭에 따라 추가 데이터 로드
    if (params.tab === "posts") {
      queries.push({
        modelName: "PostModel",
        methodName: "getMyPosts",
        params: [{ page: 1, pageSize: 20 }],
        serviceKey: ["Post", "getMyPosts"],
      });
    } else if (params.tab === "settings") {
      queries.push({
        modelName: "SettingsModel",
        methodName: "getUserSettings",
        params: [],
        serviceKey: ["Settings", "getUserSettings"],
      });
    }

    return queries;
  },
});

쿼리 키 매칭

프리로드된 데이터가 클라이언트의 useQuery와 매칭되려면 queryKey가 정확히 일치해야 합니다.

올바른 매칭

// 서버: registerSSR
{
  modelName: "UserModel",
  methodName: "getUser",
  params: ["C", 123],
  serviceKey: ["User", "getUser"],  // ["User", "getUser", "C", 123]로 저장됨
}

// 클라이언트: useQuery
UserService.useUser("C", 123);  // queryKey: ["User", "getUser", "C", 123]
// ✅ 매칭 성공!

잘못된 매칭

// 서버: Subset "C"로 프리로드
{
  params: ["C", 123],
  serviceKey: ["User", "getUser"],
}

// 클라이언트: Subset "A" 요청
UserService.useUser("A", 123);  // queryKey: ["User", "getUser", "A", 123]
// ❌ 매칭 실패 - 클라이언트가 다시 API 호출

SSRRoute 옵션

disableHydrate

Hydration을 비활성화하고 클라이언트에서 새로 렌더링합니다.
registerSSR({
  path: "/admin/report",
  preload: (params) => [/* ... */],
  disableHydrate: true,  // Hydration 비활성화
});
사용 사례:
  • 서버/클라이언트 렌더링 결과가 다를 수 있는 경우
  • 실시간 데이터가 중요한 경우
  • Hydration mismatch 해결

cacheControl

SSR 응답의 Cache-Control 헤더를 설정합니다.
registerSSR({
  path: "/posts/:id",
  preload: (params) => [/* ... */],
  cacheControl: {
    maxAge: 3600,        // 1시간 캐시
    sMaxAge: 7200,       // CDN에서 2시간 캐시
    staleWhileRevalidate: 86400,  // 1일간 stale 컨텐츠 제공 가능
  },
});

내부 동작 원리

1. 서버 렌더링 과정

// sonamu/src/ssr/renderer.ts (간략화)
export async function renderSSR(url: string, route: SSRRoute, params: Record<string, string>) {
  // 1. preload 실행
  const preloadConfig = route.preload ? route.preload(params) : [];
  const preloadedData: PreloadedData[] = [];

  // 2. 각 SSRQuery 실행
  for (const { modelName, methodName, params: apiParams, serviceKey } of preloadConfig) {
    const api = Sonamu.syncer.apis.find(
      (a) => a.modelName === modelName && a.methodName === methodName,
    );

    // 3. 백엔드 API 직접 호출 (HTTP 없음!)
    const result = await Sonamu.invokeApiForSSR(api, apiParams);
    
    // 4. PreloadedData 저장
    preloadedData.push({
      queryKey: [...serviceKey, ...apiParams],
      data: result,
    });
  }

  // 5. entry-server.generated.tsx의 render() 호출
  const { html, dehydratedState } = await render(url, preloadedData);

  // 6. HTML에 데이터 주입
  const ssrDataScript = `<script>window.__SONAMU_SSR__ = ${JSON.stringify(dehydratedState)};</script>`;
  
  return html.replace("</body>", `${ssrDataScript}\n</body>`);
}

2. entry-server에서 데이터 주입

// entry-server.generated.tsx
export async function render(url: string, preloadedData: PreloadedData[] = []) {
  const queryClient = new QueryClient();

  // PreloadedData를 QueryClient에 주입
  for (const { queryKey, data } of preloadedData) {
    queryClient.setQueryData(queryKey, data);
  }

  // Dehydrate (직렬화)
  const dehydratedState = dehydrate(queryClient);

  // React 렌더링
  const appHtml = renderToString(<RouterProvider router={router} />);

  return { html: appHtml, dehydratedState };
}

3. 클라이언트 Hydration

// entry-client.tsx
// window.__SONAMU_SSR__에서 데이터 복원
const dehydratedState = window.__SONAMU_SSR__;

if (dehydratedState) {
  // QueryClient에 hydrate
  hydrate(queryClient, dehydratedState);
}

// React Hydration
ReactDOM.hydrateRoot(document, <RouterProvider router={router} />);

에러 처리

프리로드 실패 처리

registerSSR({
  path: "/posts/:id",
  preload: (params) => {
    try {
      return [
        {
          modelName: "PostModel",
          methodName: "getPost",
          params: ["C", parseInt(params.id)],
          serviceKey: ["Post", "getPost"],
        },
      ];
    } catch (error) {
      // 파라미터 파싱 실패 등
      console.error("Preload error:", error);
      return [];
    }
  },
});
서버 로그:
Failed to preload PostModel.getPost: Post not found
개별 쿼리 실패는 전체 SSR을 중단시키지 않습니다. 실패한 데이터만 클라이언트에서 다시 로드됩니다.

성능 최적화

1. Subset 활용

필요한 필드만 로드하여 전송 크기를 줄입니다.
// ❌ 전체 필드 (느림)
params: ["C", userId]  // 모든 필드

// ✅ 필요한 필드만 (빠름)
params: ["A", userId]  // id, username, email만

2. 병렬 로딩

여러 쿼리를 동시에 실행합니다 (자동으로 병렬 처리됨).
preload: (params) => [
  // 이 쿼리들은 병렬로 실행됨
  { modelName: "UserModel", methodName: "getUser", ... },
  { modelName: "PostModel", methodName: "getPosts", ... },
  { modelName: "CommentModel", methodName: "getComments", ... },
]

3. 조건부 로딩

필요한 데이터만 선택적으로 로드합니다.
preload: (params) => {
  const queries = [];
  
  // 기본 데이터
  queries.push({ modelName: "PageModel", methodName: "getPage", ... });
  
  // 인증된 사용자만
  if (hasAuth(params)) {
    queries.push({ modelName: "UserModel", methodName: "getProfile", ... });
  }
  
  return queries;
}

주의사항

registerSSR 사용 시 주의사항:
  1. queryKey 정확히 매칭: 서버와 클라이언트의 queryKey가 동일해야 함
  2. params 순서 주의: 백엔드 메서드 파라미터 순서를 정확히 따라야 함
  3. Context 제외: params에 Context는 포함하지 않습니다
  4. 타입 변환 주의: parseInt(params.id) 등 타입 변환 필요
  5. 에러는 로그로: 개별 쿼리 실패가 전체 SSR을 중단시키지 않음

다음 단계