๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
Sonamu์—์„œ TanStack Query์˜ ํด๋ผ์ด์–ธํŠธ ์บ์‹ฑ๊ณผ SSR ์‘๋‹ต์˜ HTTP ์บ์‹ฑ์„ ์„ค์ •ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ด…๋‹ˆ๋‹ค.

์บ์‹œ ์ œ์–ด ๊ฐœ์š”

TanStack Query

ํด๋ผ์ด์–ธํŠธ ๋ฉ”๋ชจ๋ฆฌ ์บ์‹ฑstaleTime/gcTime

HTTP ์บ์‹œ

Cache-Control ํ—ค๋”CDN/๋ธŒ๋ผ์šฐ์ € ์บ์‹ฑ

์žฌ๊ฒ€์ฆ

์ž๋™/์ˆ˜๋™ ๊ฐฑ์‹ ์ตœ์‹  ๋ฐ์ดํ„ฐ ์œ ์ง€

์„ฑ๋Šฅ ์ตœ์ ํ™”

๋น ๋ฅธ ์‘๋‹ต์„œ๋ฒ„ ๋ถ€ํ•˜ ๊ฐ์†Œ

TanStack Query ํด๋ผ์ด์–ธํŠธ ์บ์‹ฑ

๊ธฐ๋ณธ ์„ค์ •

// entry-client.tsx
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // ์บ์‹œ ๊ด€๋ จ ์„ค์ •
      staleTime: 5000,             // 5์ดˆ (๋ฐ์ดํ„ฐ๊ฐ€ freshํ•œ ์‹œ๊ฐ„)
      gcTime: 5 * 60 * 1000,       // 5๋ถ„ (๊ฐ€๋น„์ง€ ์ปฌ๋ ‰์…˜ ์‹œ๊ฐ„)
      
      // ์žฌ๊ฒ€์ฆ ์„ค์ •
      refetchOnWindowFocus: false, // ์œˆ๋„์šฐ ํฌ์ปค์Šค ์‹œ ์žฌ๊ฒ€์ฆ ์•ˆ ํ•จ
      refetchOnMount: true,        // ๋งˆ์šดํŠธ ์‹œ ์žฌ๊ฒ€์ฆ
      refetchOnReconnect: true,    // ์žฌ์—ฐ๊ฒฐ ์‹œ ์žฌ๊ฒ€์ฆ
      
      // ์žฌ์‹œ๋„ ์„ค์ •
      retry: false,                // ์‹คํŒจ ์‹œ ์žฌ์‹œ๋„ ์•ˆ ํ•จ
    },
  },
});

staleTime vs gcTime

// staleTime: ๋ฐ์ดํ„ฐ๊ฐ€ "์‹ ์„ ํ•œ" ์‹œ๊ฐ„
// โ†’ ์ด ์‹œ๊ฐ„ ๋™์•ˆ์€ ์žฌ์š”์ฒญํ•˜์ง€ ์•Š์Œ
// โ†’ 0์ด๋ฉด ํ•ญ์ƒ stale (๊ธฐ๋ณธ๊ฐ’)

// gcTime: ์บ์‹œ์— ๋ณด๊ด€ํ•˜๋Š” ์‹œ๊ฐ„ (๊ตฌ cacheTime)
// โ†’ ์ด ์‹œ๊ฐ„ ๋™์•ˆ ๋ฉ”๋ชจ๋ฆฌ์— ์œ ์ง€
// โ†’ 5๋ถ„ (300000ms)์ด ๊ธฐ๋ณธ๊ฐ’

const { data } = useQuery({
  queryKey: ["user", userId],
  queryFn: fetchUser,
  staleTime: 5000,        // 5์ดˆ๊ฐ„ fresh
  gcTime: 10 * 60 * 1000, // 10๋ถ„๊ฐ„ ์บ์‹œ ์œ ์ง€
});

// ํƒ€์ž„๋ผ์ธ:
// 0์ดˆ: ๋ฐ์ดํ„ฐ fetch โ†’ fresh
// 5์ดˆ: stale ์ƒํƒœ๋กœ ์ „ํ™˜
//      (ํ•˜์ง€๋งŒ ์บ์‹œ์—๋Š” ์•„์ง ์žˆ์Œ)
//      ์žฌ์š”์ฒญ ์‹œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ refetch
// 10๋ถ„: ์บ์‹œ์—์„œ ์ œ๊ฑฐ (๊ฐ€๋น„์ง€ ์ปฌ๋ ‰์…˜)

์ฟผ๋ฆฌ๋ณ„ ์บ์‹œ ์„ค์ •

Service Hook์— ์˜ต์…˜์„ ์ „๋‹ฌํ•˜์—ฌ ๊ฐœ๋ณ„ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
import { UserService } from "@/services/services.generated";

function UserProfile({ userId }: { userId: number }) {
  const { data } = UserService.useUser("C", userId, {
    // ์ด ์ฟผ๋ฆฌ๋งŒ์˜ ์„ค์ •
    staleTime: 10 * 60 * 1000,    // 10๋ถ„ fresh
    gcTime: 30 * 60 * 1000,       // 30๋ถ„ ์บ์‹œ ์œ ์ง€
    refetchOnWindowFocus: true,   // ํฌ์ปค์Šค ์‹œ ์žฌ๊ฒ€์ฆ
  });

  return <div>{data?.user.username}</div>;
}

๋ฐ์ดํ„ฐ ํŠน์„ฑ๋ณ„ ์บ์‹ฑ ์ „๋žต

// 1. ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ (์ฃผ์‹, ์ฑ„ํŒ… ๋“ฑ)
const { data } = useQuery({
  queryKey: ["stock", symbol],
  queryFn: fetchStock,
  staleTime: 0,             // ํ•ญ์ƒ stale
  refetchInterval: 5000,    // 5์ดˆ๋งˆ๋‹ค ์ž๋™ refetch
});

// 2. ์ž์ฃผ ๋ณ€๊ฒฝ๋˜๋Š” ๋ฐ์ดํ„ฐ (ํ”ผ๋“œ, ์•Œ๋ฆผ ๋“ฑ)
const { data } = useQuery({
  queryKey: ["feed"],
  queryFn: fetchFeed,
  staleTime: 60 * 1000,     // 1๋ถ„ fresh
  refetchOnWindowFocus: true,
});

// 3. ๊ฐ€๋” ๋ณ€๊ฒฝ๋˜๋Š” ๋ฐ์ดํ„ฐ (ํ”„๋กœํ•„, ์„ค์ • ๋“ฑ)
const { data } = useQuery({
  queryKey: ["profile"],
  queryFn: fetchProfile,
  staleTime: 5 * 60 * 1000, // 5๋ถ„ fresh
  refetchOnWindowFocus: false,
});

// 4. ๊ฑฐ์˜ ์•ˆ ๋ณ€ํ•˜๋Š” ๋ฐ์ดํ„ฐ (์นดํ…Œ๊ณ ๋ฆฌ, ๊ตญ๊ฐ€ ๋ชฉ๋ก ๋“ฑ)
const { data } = useQuery({
  queryKey: ["categories"],
  queryFn: fetchCategories,
  staleTime: Infinity,      // ์˜์›ํžˆ fresh
  gcTime: Infinity,         // ์˜์›ํžˆ ์บ์‹œ ์œ ์ง€
});

์ˆ˜๋™ ์บ์‹œ ์ œ์–ด

์บ์‹œ ๋ฌดํšจํ™”

"use client";

import { useQueryClient } from "@tanstack/react-query";
import { PostService } from "@/services/services.generated";

export function CreatePostForm() {
  const queryClient = useQueryClient();

  async function handleSubmit(data: any) {
    // ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ
    await PostService.createPost(data);

    // ๊ด€๋ จ ์บ์‹œ ๋ฌดํšจํ™”
    queryClient.invalidateQueries({
      queryKey: ["Post", "list"], // ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก ๋ฌดํšจํ™”
    });

    // ๋˜๋Š” ํŠน์ • ์ฟผ๋ฆฌ๋งŒ
    queryClient.invalidateQueries({
      queryKey: ["Post", "list", { page: 1 }],
    });
  }

  return <form onSubmit={handleSubmit}>...</form>;
}

์บ์‹œ ์ง์ ‘ ์—…๋ฐ์ดํŠธ

"use client";

import { useQueryClient } from "@tanstack/react-query";

export function LikeButton({ postId }: { postId: number }) {
  const queryClient = useQueryClient();

  async function handleLike() {
    // 1. ํ˜„์žฌ ์บ์‹œ ๊ฐ€์ ธ์˜ค๊ธฐ
    const queryKey = ["Post", "getPost", "C", postId];
    const previousPost = queryClient.getQueryData(queryKey);

    // 2. ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ
    queryClient.setQueryData(queryKey, (old: any) => ({
      ...old,
      post: {
        ...old.post,
        likes: old.post.likes + 1,
      },
    }));

    try {
      // 3. ์„œ๋ฒ„์— ์š”์ฒญ
      await PostService.likePost(postId);
    } catch (error) {
      // 4. ์‹คํŒจ ์‹œ ๋กค๋ฐฑ
      queryClient.setQueryData(queryKey, previousPost);
    }
  }

  return <button onClick={handleLike}>Like</button>;
}

์บ์‹œ ์กฐํšŒ

const queryClient = useQueryClient();

// ๋‹จ์ผ ์ฟผ๋ฆฌ ์กฐํšŒ
const userData = queryClient.getQueryData(["User", "getUser", "C", 123]);

// ๋ชจ๋“  ์ฟผ๋ฆฌ ์กฐํšŒ
const allQueries = queryClient.getQueryCache().getAll();

// ํŠน์ • ํŒจํ„ด ์ฟผ๋ฆฌ ์กฐํšŒ
const postQueries = queryClient.getQueryCache()
  .findAll({ queryKey: ["Post"] });

SSR Cache-Control

registerSSR์—์„œ HTTP Cache-Control ํ—ค๋”๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ฐœ๋ณ„ ๋ผ์šฐํŠธ ์บ์‹ฑ

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

registerSSR({
  path: "/posts/:id",
  preload: (params) => [/* ... */],
  cacheControl: {
    maxAge: 3600,                // 1์‹œ๊ฐ„ (๋ธŒ๋ผ์šฐ์ € ์บ์‹œ)
    sMaxAge: 7200,               // 2์‹œ๊ฐ„ (CDN ์บ์‹œ)
    staleWhileRevalidate: 86400, // 1์ผ๊ฐ„ stale ์ปจํ…์ธ  ์ œ๊ณต ๊ฐ€๋Šฅ
    public: true,                // ๊ณต๊ฐœ ์บ์‹œ ํ—ˆ์šฉ
  },
});
์ƒ์„ฑ๋˜๋Š” ํ—ค๋”:
Cache-Control: public, max-age=3600, s-maxage=7200, stale-while-revalidate=86400

์บ์‹ฑ ์ „๋žต ์˜ˆ์‹œ

// 1. ์ •์  ์ปจํ…์ธ  (๊ธด ์บ์‹ฑ)
registerSSR({
  path: "/about",
  cacheControl: {
    maxAge: 86400,    // 1์ผ
    sMaxAge: 604800,  // 7์ผ (CDN)
    public: true,
  },
});

// 2. ๋™์  ์ปจํ…์ธ  (์งง์€ ์บ์‹ฑ)
registerSSR({
  path: "/posts/:id",
  cacheControl: {
    maxAge: 300,      // 5๋ถ„
    sMaxAge: 600,     // 10๋ถ„ (CDN)
    staleWhileRevalidate: 3600,
    public: true,
  },
});

// 3. ๊ฐœ์ธํ™” ์ปจํ…์ธ  (์บ์‹ฑ ์•ˆ ํ•จ)
registerSSR({
  path: "/dashboard",
  cacheControl: {
    noStore: true,    // ์บ์‹œ ์™„์ „ ๋น„ํ™œ์„ฑํ™”
    private: true,    // ๊ฐœ์ธ ์ „์šฉ
  },
});

// 4. ์บ์‹œ ์—†์Œ (๋งค๋ฒˆ ์žฌ๊ฒ€์ฆ)
registerSSR({
  path: "/live-feed",
  cacheControl: {
    noCache: true,    // ์žฌ๊ฒ€์ฆ ํ•„์ˆ˜
  },
});

CacheControlConfig ํƒ€์ž…

type CacheControlConfig = {
  maxAge?: number;              // ๋ธŒ๋ผ์šฐ์ € ์บ์‹œ ์‹œ๊ฐ„ (์ดˆ)
  sMaxAge?: number;             // CDN ์บ์‹œ ์‹œ๊ฐ„ (์ดˆ)
  staleWhileRevalidate?: number; // stale ์ปจํ…์ธ  ์ œ๊ณต ์‹œ๊ฐ„ (์ดˆ)
  staleIfError?: number;        // ์—๋Ÿฌ ์‹œ stale ์ปจํ…์ธ  ์ œ๊ณต ์‹œ๊ฐ„ (์ดˆ)
  public?: boolean;             // ๊ณต๊ฐœ ์บ์‹œ ํ—ˆ์šฉ
  private?: boolean;            // ๊ฐœ์ธ ์ „์šฉ (CDN ์บ์‹œ ์•ˆ ํ•จ)
  noCache?: boolean;            // ์žฌ๊ฒ€์ฆ ํ•„์ˆ˜
  noStore?: boolean;            // ์บ์‹œ ์™„์ „ ๋น„ํ™œ์„ฑํ™”
  mustRevalidate?: boolean;     // ์žฌ๊ฒ€์ฆ ๊ฐ•์ œ
  immutable?: boolean;          // ๋ถˆ๋ณ€ ๋ฆฌ์†Œ์Šค
};

์ „์—ญ ์บ์‹ฑ ํ•ธ๋“ค๋Ÿฌ

๋ชจ๋“  SSR ๋ผ์šฐํŠธ์— ์ ์šฉ๋˜๋Š” ์ „์—ญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
// api/src/application/sonamu.ts
import { SonamuFastifyConfig } from "sonamu";

const config: SonamuFastifyConfig = {
  cacheControlHandler: (req) => {
    // SSR ์š”์ฒญ ์ •๋ณด
    const { type, url, path, method } = req;

    // ์ •์  ํŽ˜์ด์ง€
    if (path === "/about" || path === "/terms") {
      return {
        maxAge: 86400,    // 1์ผ
        public: true,
      };
    }

    // ๋™์  ํŽ˜์ด์ง€
    if (path.startsWith("/posts/")) {
      return {
        maxAge: 300,      // 5๋ถ„
        staleWhileRevalidate: 3600,
        public: true,
      };
    }

    // ๊ธฐ๋ณธ๊ฐ’: ์บ์‹ฑ ์•ˆ ํ•จ
    return {
      noCache: true,
    };
  },
};

ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ์บ์‹ฑ

SSR๊ณผ ํด๋ผ์ด์–ธํŠธ ์บ์‹ฑ์„ ํ•จ๊ป˜ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
// ์„œ๋ฒ„: HTTP ์บ์‹ฑ (1์‹œ๊ฐ„)
registerSSR({
  path: "/posts/:id",
  cacheControl: {
    maxAge: 3600,
  },
});

// ํด๋ผ์ด์–ธํŠธ: ๋ฉ”๋ชจ๋ฆฌ ์บ์‹ฑ (5๋ถ„)
const { data } = PostService.usePost("C", postId, {
  staleTime: 5 * 60 * 1000,
});
์บ์‹ฑ ๋ ˆ์ด์–ด:
  1. ๋ธŒ๋ผ์šฐ์ € HTTP ์บ์‹œ: 1์‹œ๊ฐ„ (max-age=3600)
  2. TanStack Query ๋ฉ”๋ชจ๋ฆฌ: 5๋ถ„ (staleTime)
  3. ์„œ๋ฒ„ SSR ๋ Œ๋”๋ง: ์บ์‹œ ๋ฏธ์Šค ์‹œ ๋ Œ๋”๋ง
ํ๋ฆ„:
  1. ์ฒซ ๋ฐฉ๋ฌธ: SSR โ†’ HTML + ๋ฐ์ดํ„ฐ โ†’ ๋ธŒ๋ผ์šฐ์ € ์บ์‹œ ์ €์žฅ
  2. 5๋ถ„ ์ด๋‚ด ์žฌ๋ฐฉ๋ฌธ: TanStack Query ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ ์‚ฌ์šฉ
  3. 5๋ถ„~1์‹œ๊ฐ„: ๋ธŒ๋ผ์šฐ์ € HTTP ์บ์‹œ ์‚ฌ์šฉ
  4. 1์‹œ๊ฐ„ ์ดํ›„: ์„œ๋ฒ„์— ์ƒˆ๋กœ ์š”์ฒญ

๊ฐœ๋ฐœ ํ™˜๊ฒฝ ๋””๋ฒ„๊น…

React Query Devtools

// web/src/routes/__root.tsx
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

function RootComponent() {
  return (
    <html>
      <body>
        <QueryClientProvider client={queryClient}>
          <Outlet />
          <ReactQueryDevtools initialIsOpen={false} />
        </QueryClientProvider>
      </body>
    </html>
  );
}

์บ์‹œ ์ƒํƒœ ํ™•์ธ

"use client";

import { useQueryClient } from "@tanstack/react-query";

export function CacheDebugger() {
  const queryClient = useQueryClient();

  function showCache() {
    const cache = queryClient.getQueryCache();
    console.log("Total queries:", cache.getAll().length);

    cache.getAll().forEach((query) => {
      console.log({
        queryKey: query.queryKey,
        state: query.state.status,
        dataUpdatedAt: new Date(query.state.dataUpdatedAt),
        staleTime: query.options.staleTime,
      });
    });
  }

  return <button onClick={showCache}>Show Cache</button>;
}

HTTP ์บ์‹œ ํ—ค๋” ํ™•์ธ

๋ธŒ๋ผ์šฐ์ € ๊ฐœ๋ฐœ์ž ๋„๊ตฌ โ†’ Network ํƒญ์—์„œ ์‘๋‹ต ํ—ค๋” ํ™•์ธ:
Cache-Control: public, max-age=3600, s-maxage=7200, stale-while-revalidate=86400
Age: 1234
X-Cache: HIT

์„ฑ๋Šฅ ์ตœ์ ํ™” ์ „๋žต

1. Subset ํ™œ์šฉ

ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ์บ์‹œํ•˜์—ฌ ๋ฉ”๋ชจ๋ฆฌ ์ ˆ์•ฝ:
// โŒ ์ „์ฒด ํ•„๋“œ ์บ์‹œ (ํผ)
UserService.useUser("C", userId, {
  staleTime: 5 * 60 * 1000,
});

// โœ… ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ์บ์‹œ (์ž‘์Œ)
UserService.useUser("A", userId, {
  staleTime: 5 * 60 * 1000,
});

2. ์„ ํƒ์  ์žฌ๊ฒ€์ฆ

์ค‘์š”ํ•œ ๋ฐ์ดํ„ฐ๋งŒ ์ ๊ทน์ ์œผ๋กœ ์žฌ๊ฒ€์ฆ:
// ์ค‘์š”ํ•œ ๋ฐ์ดํ„ฐ
const { data: user } = UserService.useUser("C", userId, {
  refetchOnWindowFocus: true,
  refetchOnMount: true,
});

// ๋œ ์ค‘์š”ํ•œ ๋ฐ์ดํ„ฐ
const { data: posts } = PostService.usePosts("A", {
  refetchOnWindowFocus: false,
  refetchOnMount: false,
});

3. ์บ์‹œ ํ”„๋ฆฌํŽ˜์นญ

๋‹ค์Œ์— ํ•„์š”ํ•  ๋ฐ์ดํ„ฐ๋ฅผ ๋ฏธ๋ฆฌ ์บ์‹ฑ:
const queryClient = useQueryClient();

function prefetchPost(postId: number) {
  queryClient.prefetchQuery({
    queryKey: ["Post", "getPost", "C", postId],
    queryFn: () => PostService.getPost("C", postId),
  });
}

// ๋งํฌ์— ๋งˆ์šฐ์Šค ์˜ฌ๋ฆฌ๋ฉด ํ”„๋ฆฌํŽ˜์นญ
<Link
  to={`/posts/${postId}`}
  onMouseEnter={() => prefetchPost(postId)}
>
  Read more
</Link>

์ฃผ์˜์‚ฌํ•ญ

์บ์‹œ ์‚ฌ์šฉ ์‹œ ์ฃผ์˜์‚ฌํ•ญ:
  1. staleTime ์„ค์ • ํ•„์ˆ˜: ๊ธฐ๋ณธ๊ฐ’ 0์€ ํ•ญ์ƒ refetch
  2. ๊ฐœ์ธ ๋ฐ์ดํ„ฐ๋Š” private: public ์บ์‹œ ๊ธˆ์ง€
  3. ๋ฏผ๊ฐํ•œ ๋ฐ์ดํ„ฐ: noStore๋กœ ์บ์‹ฑ ๋น„ํ™œ์„ฑํ™”
  4. SSR cacheControl: ๊ฐœ๋ณ„ ์„ค์ •์ด ์ „์—ญ ํ•ธ๋“ค๋Ÿฌ๋ณด๋‹ค ์šฐ์„ 
  5. ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ: gcTime์œผ๋กœ ๋ถˆํ•„์š”ํ•œ ์บ์‹œ ์ œ๊ฑฐ

๋‹ค์Œ ๋‹จ๊ณ„