<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>ssund의 기술블로그</title>
    <link>https://ssund.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sat, 23 May 2026 00:43:18 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>ssund</managingEditor>
    <item>
      <title>TanStack Query의 gcTime과 staleTime 비교</title>
      <link>https://ssund.tistory.com/181</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;TanStack Query(React Query)에서 &lt;b&gt;gcTime&lt;/b&gt;과 &lt;b&gt;staleTime&lt;/b&gt;은 캐싱 전략을 제어하는 서로 다른 목적을 가진 옵션입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용할 때마다 헷갈리는 두 개념 정리해보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;staleTime (신선도 시간)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;데이터가 &quot;신선한(fresh)&quot; 상태로 유지되는 시간&lt;/b&gt;을 의미합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;기본값&lt;/b&gt;: 0 (즉시 stale 상태가 됨)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;역할&lt;/b&gt;: 이 시간 동안은 데이터가 fresh 상태로 간주되어, 같은 쿼리를 다시 호출해도 네트워크 요청을 하지 않고 캐시된 데이터를 반환합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용 예시&lt;/b&gt;: 자주 변경되지 않는 데이터(예: 사용자 프로필, 설정값)는 staleTime을 길게 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  staleTime: 5 * 60 * 1000, // 5분 동안 fresh 상태 유지
});&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;gcTime (가비지 컬렉션 시간)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용되지 않는 캐시 데이터가 메모리에서 제거되기까지의 시간&lt;/b&gt;입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;기본값&lt;/b&gt;: 5분 (300,000ms)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이전 이름&lt;/b&gt;: cacheTime (v5에서 gcTime으로 변경됨)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;역할&lt;/b&gt;: 쿼리가 더 이상 활성 observer가 없을 때(언마운트 등), 이 시간 동안 캐시를 메모리에 보관합니다. 이 시간이 지나면 가비지 컬렉션 대상이 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용 예시&lt;/b&gt;: 사용자가 페이지를 왔다갔다 할 때 빠른 재접근을 위해 캐시 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;const { data } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  gcTime: 10 * 60 * 1000, // 10분 동안 캐시 유지
});&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;주요 차이점&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;staleTime&lt;/th&gt;
&lt;th&gt;gcTime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;목적&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;데이터 신선도 판단&lt;/td&gt;
&lt;td&gt;메모리 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;영향&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;리페칭 여부 결정&lt;/td&gt;
&lt;td&gt;캐시 삭제 시점 결정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;기본값&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;0ms&lt;/td&gt;
&lt;td&gt;5분&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;측정 시작&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;데이터 fetch 시점부터&lt;/td&gt;
&lt;td&gt;마지막 observer 제거 시점부터(언마운트 시점)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 사용 패턴&lt;/h2&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// 1. 거의 변하지 않는 정적 데이터
const { data: categories } = useQuery({
  queryKey: ['categories'],
  queryFn: fetchCategories,
  staleTime: Infinity, // 절대 stale 상태가 되지 않음
  gcTime: 10 * 60 * 1000, // 10분간 캐시 보관
});

// 2. 실시간성이 중요한 데이터
const { data: notifications } = useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  staleTime: 0, // 항상 stale (기본값)
  gcTime: 0, // 즉시 삭제
});

// 3. 균형잡힌 설정
const { data: posts } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 30 * 1000, // 30초간 fresh
  gcTime: 5 * 60 * 1000, // 5분간 캐시 유지
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 &lt;b&gt;staleTime &amp;le; gcTime&lt;/b&gt; 관계를 유지하는 것이 좋습니다.&lt;br /&gt;staleTime이 gcTime보다 크면 데이터가 fresh 상태인데 캐시에서 삭제되는 비효율이 발생할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태가 fresh인데 캐시가 없는경우 다시 refetch해야합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;체크 순서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지 마운트&lt;br /&gt;&amp;darr;&lt;br /&gt;[1] 캐시 있음?&lt;br /&gt;&amp;darr; &lt;b&gt;YES&lt;/b&gt;&lt;br /&gt;[2] fresh 상태? (staleTime 체크)&lt;br /&gt;&amp;darr; &lt;b&gt;NO (stale)&lt;/b&gt;&lt;br /&gt;[3] 캐시 데이터 즉시 반환&lt;br /&gt;&amp;darr;&lt;br /&gt;[4] 백그라운드 리페칭 시작&lt;br /&gt;&amp;darr;&lt;br /&gt;[5] 새 데이터 도착 시 업데이트&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;stale 상태여도 캐시가 있으면 &lt;b&gt;캐시데이터를 즉시 보여주고(UX 좋음) + 뒤에서 업데이트(데이터 최신성 유지)&lt;/b&gt;하는 것이 TanStack Query의 강력한 장점입니다!&lt;/p&gt;</description>
      <category>React</category>
      <author>ssund</author>
      <guid isPermaLink="true">https://ssund.tistory.com/181</guid>
      <comments>https://ssund.tistory.com/181#entry181comment</comments>
      <pubDate>Tue, 30 Dec 2025 15:17:45 +0900</pubDate>
    </item>
    <item>
      <title>Next.js App Router의 실제 사용 경험과 한계점</title>
      <link>https://ssund.tistory.com/180</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js App Router와 React 서버 컴포넌트를 도입하면서 더 빠른 렌더링을 기대했지만, &lt;br /&gt;실제 개발 과정에서 여러 제약사항을 마주하게 되었습니다. 이론적 장점과 실무의 간극에 대해 정리하겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버 컴포넌트의 기대했던 장점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js의 &lt;b&gt;App Router&lt;/b&gt;를 선택한 장점은 분명했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 성능 최적화&lt;/b&gt;: JavaScript 번들 크기 감소로 초기 로딩 속도가 개선되고, 저사양 환경에서도 나은 사용자 경험을 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 직접적인 데이터 접근&lt;/b&gt;: API 레이어 없이 데이터베이스나 파일 시스템에 직접 접근할 수 있어 코드가 간결해지고 응답 시간이 단축됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 보안 강화&lt;/b&gt;: 민감한 로직과 API 키가 서버에서만 실행되어 클라이언트에 노출되지 않습니다. 다만 React 19에서 &lt;a href=&quot;https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components&quot;&gt;서버 컴포넌트 DoS 공격과 소스코드 노출 취약점&lt;/a&gt;이 발견되면서 이 부분에 대한 우려가 생겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 자동 코드 분할과 SEO&lt;/b&gt;: 효율적인 코드 분할과 서버 렌더링된 콘텐츠로 검색 엔진 최적화에 유리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. 스트리밍 지원&lt;/b&gt;: React Suspense를 활용해 점진적으로 콘텐츠를 전송할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실무에서 마주한 4가지 주요 문제&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 낙관적 업데이트의 부재&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 컴포넌트는 마운팅 후 상태를 가질 수 없는 구조입니다. &lt;br /&gt;데이터를 fetch해서 렌더링하는 역할만 하기 때문에, 사용자 인터랙션에 즉각 반응해야 하는 UI는 결국 클라이언트 컴포넌트로 분리해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 서버 컴포넌트는 데이터만 전달
export default async function PostsPage() {
    const posts = await fetchPosts();
    return &amp;lt;PostList posts={posts} /&amp;gt;; // 클라이언트 컴포넌트로 위임
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 대부분의 인터랙티브한 페이지는 &lt;code&gt;'use client'&lt;/code&gt;를 선언하게 되고, 서버 컴포넌트의 이점을 충분히 활용하기 어렵습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 페이지 이동마다 반복되는 서버 요청&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라우터 이동 시마다 서버에서 데이터를 다시 fetch합니다. &lt;br /&gt;클라이언트 메모리에 이전 페이지 데이터가 남아있어도 서버에 재요청합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/posts/page.tsx
export default async function PostsPage() {
    const posts = await fetchPosts(); 
    return (
        &amp;lt;div&amp;gt;
            {posts.map(p =&amp;gt; (
                &amp;lt;Link href={`/post/${p.id}`}&amp;gt;
                    &amp;lt;h2&amp;gt;{p.title}&amp;lt;/h2&amp;gt;
                &amp;lt;/Link&amp;gt;
            ))}
        &amp;lt;/div&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실제 동작 흐름&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;/posts&lt;/code&gt; 방문 &amp;rarr; &lt;code&gt;fetchPosts()&lt;/code&gt; 실행&lt;/li&gt;
&lt;li&gt;게시물 클릭 &amp;rarr; &lt;code&gt;/post/1&lt;/code&gt; 이동&lt;/li&gt;
&lt;li&gt;뒤로가기 &amp;rarr; 서버에서&amp;nbsp;&lt;b&gt;&lt;code&gt;fetchPosts()&lt;/code&gt; 재실행&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;불과 몇 초 전에 봤던 목록임에도 서버에 다시 요청하고 로딩 상태를 표시합니다. &lt;br /&gt;정적 콘텐츠(블로그 등)는 디스크 캐시로 해결되지만, 동적 콘텐츠(세션, 쿠키 사용)는 매번 로딩이 발생합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 동적 콘텐츠 예시
export default async function HomePage() {
    const session = await getSession(); // 쿠키 의존
    const posts = await fetchPosts(session.userId); // 사용자별 데이터

    return &amp;lt;div&amp;gt;
        &amp;lt;h1&amp;gt;{session.user.name}님의 피드&amp;lt;/h1&amp;gt;
        {posts.map(post =&amp;gt; &amp;lt;Post post={post} /&amp;gt;)}
    &amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 큰 문제는 페이지 간 데이터 공유가 불가능하다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 목록(&lt;code&gt;/users&lt;/code&gt;)에서 이미 로드한 Alice의 정보를, 프로필 페이지(&lt;code&gt;/user/alice&lt;/code&gt;)에서 재사용할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 이전 페이지에 대한 정보가 없기 때문에 동일한 데이터를 다시 fetch해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 레이아웃의 제약사항&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;layout.tsx&lt;/code&gt;는 렌더링 결과가 캐싱되지만, 동적 API(cookies, headers 등)를 사용하면 캐싱이 무효화됩니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/layout.tsx
export default function RootLayout({ children }) {
    // ❌ 불가능한 작업들
    // const cookies = useCookies();
    // const pathname = usePathname();

    // children에게 props 전달 불가
    return (
        &amp;lt;html&amp;gt;
            &amp;lt;body&amp;gt;
                &amp;lt;Header /&amp;gt; {/* user 정보를 어떻게 가져올까? */}
                {children}
            &amp;lt;/body&amp;gt;
        &amp;lt;/html&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;두 가지 해결 방법&lt;/b&gt; 모두 trade-off가 있습니다:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;서버 컴포넌트에서 동적 API 사용&lt;/b&gt;: 렌더링 결과 캐싱 불가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;클라이언트 컴포넌트로 분리&lt;/b&gt;: 하이드레이션 중 깜빡임 발생&lt;br /&gt;&lt;br /&gt;서버:&amp;nbsp;user=null&amp;nbsp;&amp;rarr;&amp;nbsp;로그아웃&amp;nbsp;UI&lt;br /&gt;클라이언트&amp;nbsp;마운트:&amp;nbsp;user=null&amp;nbsp;&amp;rarr;&amp;nbsp;로그아웃&amp;nbsp;UI&amp;nbsp;&amp;nbsp;&lt;br /&gt;fetch&amp;nbsp;완료:&amp;nbsp;user=Alice&amp;nbsp;&amp;rarr;&amp;nbsp;로그인&amp;nbsp;UI&amp;nbsp;(깜빡임!)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;또한 Layout에서 children으로 props를 전달할 수 없어, 같은 데이터가 필요하면 각 컴포넌트에서 API를 재호출해야 합니다. &lt;br /&gt;Next.js의 &lt;b&gt;Request Memoization&lt;/b&gt;이 중복 호출을 방지해주지만, 여러 페이지에서 사용하다 보면 관리가 복잡해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 중복 전송되는 콘텐츠&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 URL을 직접 입력하거나 새로고침할 때, 동일한 콘텐츠가 HTML과 RSC 페이로드로 두 번 전송됩니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/post/[id]/page.tsx
export default async function PostPage({ params }) {
    const post = await fetchPost(params.id);
    return (
        &amp;lt;article&amp;gt;
            &amp;lt;h1&amp;gt;{post.title}&amp;lt;/h1&amp;gt;
            &amp;lt;p&amp;gt;{post.content}&amp;lt;/p&amp;gt;
        &amp;lt;/article&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실제 응답 구조&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;&amp;lt;!-- 1차: HTML --&amp;gt;
&amp;lt;article&amp;gt;
    &amp;lt;h1&amp;gt;Next.js의 문제점&amp;lt;/h1&amp;gt;
    &amp;lt;p&amp;gt;서버 컴포넌트는 매우 답답하게 느껴집니다...&amp;lt;/p&amp;gt;
&amp;lt;/article&amp;gt;

&amp;lt;!-- 2차: RSC 페이로드 (동일한 내용) --&amp;gt;
&amp;lt;script&amp;gt;
    self.__next_f.push([1, `
        [&quot;$&quot;,&quot;h1&quot;,null,{&quot;children&quot;:&quot;Next.js의 문제점&quot;}],
        [&quot;$&quot;,&quot;p&quot;,null,{&quot;children&quot;:&quot;서버 컴포넌트는...&quot;&quot;}]
    `]);
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 두 번 보내야 할까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Suspense 경계&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 클라이언트 컴포넌트 위치&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- props 데이터&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 비동기 상태 등 React가 하이드레이션에 필요한 정보&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Suspense를 활용한 스트리밍에서는 이 문제가 더 심화됩니다. 각 청크마다 HTML과 RSC 페이로드가 모두 전송되어, 동일한 텍스트가 여러 번 중복됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App Router는 분명 강력한 기능을 제공하지만, 실제 개발에서는 이론과 다른 제약사항들이 존재했습니다. 특히 인터랙티브한 애플리케이션일수록 클라이언트 컴포넌트로 회귀하게 되어, 서버 컴포넌트의 이점을 누릴 수 없었던 것 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 특성에 따라 Pages Router가 더 적합할 수 있으니 trade-off를 확인하고 선택하는 것이 좋을 것 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;참고: &lt;a href=&quot;https://paperclover.net/blog/webdev/one-year-next-app-router.ko&quot;&gt;Why I'm not using NextJS's App Router (1년 후기)&lt;/a&gt;&lt;/i&gt;&lt;/p&gt;</description>
      <category>Next.js</category>
      <category>app-router</category>
      <category>nextjs</category>
      <category>서버컴포넌트</category>
      <author>ssund</author>
      <guid isPermaLink="true">https://ssund.tistory.com/180</guid>
      <comments>https://ssund.tistory.com/180#entry180comment</comments>
      <pubDate>Tue, 30 Dec 2025 12:07:24 +0900</pubDate>
    </item>
    <item>
      <title>특정 문자가 이모지로 치환되어 보이는 이슈</title>
      <link>https://ssund.tistory.com/179</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;특정 문자열이 브라우저에서 &lt;span&gt;&lt;b&gt;이모지로 치환되어 보이는 이슈&lt;/b&gt;&lt;/span&gt;를 겪었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;span&gt;&quot;Balloon&quot;&lt;/span&gt;이라는 문자열이 텍스트 그대로 출력되지 않고, 말풍선 모양 이모지로 렌더링되는 현상이 발생했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;332&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnT7oA/btsQcnFeiBw/57bknXX0GS28WIE24x2Pm0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnT7oA/btsQcnFeiBw/57bknXX0GS28WIE24x2Pm0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnT7oA/btsQcnFeiBw/57bknXX0GS28WIE24x2Pm0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnT7oA%2FbtsQcnFeiBw%2F57bknXX0GS28WIE24x2Pm0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;332&quot; height=&quot;720&quot; data-origin-width=&quot;332&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 상품 등록 과정에서 유니코드가 잘못 들어간 게 아닌가 의심했지만, 데이터에는 문제가 없었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;확인해 보니 &lt;/span&gt;&lt;b&gt;렌더링 시점에 브라우저가 문자열을 자동으로 이모지로 변환&lt;/b&gt;&lt;span&gt;하고 있었고, 이는 적용 중인 &lt;/span&gt;&lt;b&gt;Noto Sans 폰트&lt;/b&gt;&lt;span&gt;에서 발생하는 문제였습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하는 방법은 크게 두 가지였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;첫번째. 이모지로 치환되지 않는 다른 폰트를 사용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두번째. 브라우저의 자동 치환 기능을 비활성화&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 폰트를 변경하는 것은 서비스 전체 디자인에 영향을 줄 수 있기에 채택하지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 CSS 속성으로 제어하는 방법을 적용했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1756650546612&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;font-variant-ligatures: none;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;b&gt;font-variant-ligatures란?&lt;/b&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;font-variant-ligatures&lt;/span&gt;&lt;span&gt;는 &lt;/span&gt;폰트에서 리거처(ligature) 기능을 사용할지 여부&lt;span&gt;를 제어하는 CSS 속성입니다.&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;리거처란, &lt;/span&gt;&lt;b&gt;두 개 이상의 글자를 하나의 합성된 글리프로 표시하는 기능&lt;/b&gt;&lt;span&gt;입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 영어에서 &lt;span&gt;fi&lt;/span&gt;나 &lt;span&gt;fl&lt;/span&gt; 같은 조합은 보통 이렇게 바뀔 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동으로 합쳐지면서 더 보기 좋게 표현되는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일부 폰트(Noto Sans 포함)는 이 기능이 &lt;span&gt;&lt;b&gt;특정 단어를 이모지로 치환하는 동작&lt;/b&gt;&lt;/span&gt;과도 연결되어 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 결과 &lt;span&gt;&quot;Balloon&quot;&lt;/span&gt; 같은 단어가 텍스트 대신 이모지처럼 보이는 현상이 발생하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;font-variant-ligatures: none;&lt;/span&gt;&lt;/b&gt; 속성을 적용하면 &lt;span&gt;&lt;b&gt;리거처를 전부 비활성화&lt;/b&gt;&lt;/span&gt;하여 브라우저가 임의로 글자를 합치거나 이모지로 변환하지 못하도록 막을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정을 적용한 뒤에는 &lt;span&gt;&quot;Balloon&quot;&lt;/span&gt; 같은 문자열이 정상적으로 텍스트로만 출력되도록 수정할 수 있었습니다.&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;6732CE03-B7D9-45DE-8972-BC0D19461264_4_5005_c.jpeg&quot; data-origin-width=&quot;360&quot; data-origin-height=&quot;778&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bRk47D/btsQeqnvirJ/ugrz0FkT76vG3xzyhtj45k/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bRk47D/btsQeqnvirJ/ugrz0FkT76vG3xzyhtj45k/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bRk47D/btsQeqnvirJ/ugrz0FkT76vG3xzyhtj45k/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbRk47D%2FbtsQeqnvirJ%2Fugrz0FkT76vG3xzyhtj45k%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;778&quot; data-filename=&quot;6732CE03-B7D9-45DE-8972-BC0D19461264_4_5005_c.jpeg&quot; data-origin-width=&quot;360&quot; data-origin-height=&quot;778&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Tips</category>
      <author>ssund</author>
      <guid isPermaLink="true">https://ssund.tistory.com/179</guid>
      <comments>https://ssund.tistory.com/179#entry179comment</comments>
      <pubDate>Sun, 31 Aug 2025 23:33:14 +0900</pubDate>
    </item>
    <item>
      <title>Full Route Cache를 활용한 성능 최적화</title>
      <link>https://ssund.tistory.com/178</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;진행 배경&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초기 렌더링 개선과 SEO를 위해 RSC(React Server Component)를 사용하기 위해 Next14 App Router적용,&lt;br /&gt;렌더링이 서버에서 처리되면서 서버비용이 많이 들어감&lt;/li&gt;
&lt;li&gt;트래픽이 많은경우 CPU 부하가 심해짐&lt;/li&gt;
&lt;li&gt;상세페이지 레이아웃, 상품정보는 자주 변경되는 정보가 아님&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;웹서버의 성능이 중요한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얼마나 빠르고, 효율적으로 사용자들에게 콘텐츠를 제공하느냐를 의미&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;빠른로딩으로 사용자 경험 향상&lt;/li&gt;
&lt;li&gt;빠른사이트는 SEO유리&lt;/li&gt;
&lt;li&gt;네트워크 사용량 감소, 서버 요청 최소화로 인프라 비용 절감&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;개선할점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상품스펙에 관련 된 상품정보는 자주 변경되는 정보가 아니기 때문에 상품페이지는 Full Route Cache를 적용해서 캐시된 HTML을 적용한다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Full&amp;nbsp;Route&amp;nbsp;Cache란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js는 4가지 캐시 매커니즘이 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Request Memoiztion - 동일한 요청(fetch 등)이 같은 렌더링 트리 안에서 여러 번 호출될 때, &lt;span&gt;&lt;b&gt;메모리에서 즉시 재사용&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Data Cache - 서버에서 실행된 &lt;span&gt;fetch&lt;/span&gt; 결과를 &lt;span&gt;&lt;b&gt;디스크/인프라 레벨&lt;/b&gt;&lt;/span&gt;에 캐싱&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Full Route Cache - &lt;span&gt;&lt;b&gt;정적 생성(SSG)&lt;/b&gt;&lt;/span&gt; 결과를 전역 CDN에 저장, 전 세계적으로 캐싱&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Router Cache - 특정 &lt;span&gt;&lt;b&gt;라우트의 전체 렌더링 결과&lt;/b&gt;&lt;/span&gt;(HTML + 데이터)를 캐싱&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 사용하는 캐시는 &lt;b&gt;Full Route Cache&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 렌더링 과정에서 웹 서버의 리소스를 대부분 사용하게 되는데,&lt;br /&gt;Full Route Cache는 서버 렌더링 결과를 재사용함으로서 서버비용을 줄일 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1348&quot; data-origin-height=&quot;608&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IySQ5/btsPI9FXUrp/W2qutKP7PnKOkI96SekKy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IySQ5/btsPI9FXUrp/W2qutKP7PnKOkI96SekKy1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IySQ5/btsPI9FXUrp/W2qutKP7PnKOkI96SekKy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIySQ5%2FbtsPI9FXUrp%2FW2qutKP7PnKOkI96SekKy1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1348&quot; height=&quot;608&quot; data-origin-width=&quot;1348&quot; data-origin-height=&quot;608&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1314&quot; data-origin-height=&quot;746&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/M6Adz/btsPGi47d1b/LAkGLVW6mLwWzku988whBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/M6Adz/btsPGi47d1b/LAkGLVW6mLwWzku988whBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/M6Adz/btsPGi47d1b/LAkGLVW6mLwWzku988whBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FM6Adz%2FbtsPGi47d1b%2FLAkGLVW6mLwWzku988whBk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1314&quot; height=&quot;746&quot; data-origin-width=&quot;1314&quot; data-origin-height=&quot;746&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;변경해야하는 부분&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 해당 라우트는 정적 캐시 대상으로 설정해야함&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;export const dynamicParams = true;
export const revalidate = 86000;
export const dynamic = 'force-static';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;dynamicParams&lt;/b&gt;&lt;br /&gt;&lt;span&gt;dynamicParams를 &lt;b&gt;true&lt;/b&gt;로 설정하면 generateStaticParams로 미리 생성되지 않은 동적 세그먼트도 런타임에 접근이 가능해집니다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;거기에 revalidate 속성도 있다면 ISR로 작동해서 그 시간동안 캐시를 유지하고, 요청 시 페이지를 새로 생성하고 캐시합니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;revalidate&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;레이아웃이나 페이지의 기본 재검증 시간을 설정&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;false : 리소스 무제한 캐시 &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;0 : 항상 동적으로 렌더링&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;number: (초) 레이아웃이나 페이지의 기본 재검증 빈도를 n초로 설정&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;dynamic&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;레이아웃이나 페이지의 동적 동작을 완전히 정적이거나, 동적으로 변경&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;// 'auto' | 'force-dynamic' | 'error' | 'force-static'&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;force-static : 정적 렌더링을 강제하고, 레이아웃이나 페이지의 데이터를 캐시&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;2. &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: left;&quot;&gt;서버 컴포넌트 내 동적 데이터 제거 &lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Cookies(), headers() 등과 같은 &lt;b&gt;Dynamic Funciton&lt;/b&gt;은 request 기반이라 SSR처리가 되서 관련한 코드는 클라이언트로 변경해줘야한다&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: left;&quot;&gt;- middleware에서 진행했던 특정 패스 다른 패스로 redirect시키는 로직 클라이언트 컴포넌트로 수정&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1754437607358&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// as is 
if ((pathname.includes('/today') &amp;amp;&amp;amp; searchParams.get('tab')) || (pathname.includes('/release') &amp;amp;&amp;amp; searchParams.get('tab'))) {
	return NextResponse.redirect(`${origin}${pathname}`);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1754437653806&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// to be
'use client';

import { useEffect } from 'react';

import { usePathname, useSearchParams, useRouter } from 'next/navigation';

const RedirectHandler = () =&amp;gt; {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const router = useRouter();

  useEffect(() =&amp;gt; {
    const origin = window.location.origin;
    const tab = searchParams.get('tab');
    if ((pathname.includes('/today') &amp;amp;&amp;amp; tab) || (pathname.includes('/release') &amp;amp;&amp;amp; tab)) {
      router.replace(`${origin}${pathname}`);
      return;
    }
  }, [pathname, searchParams, router]);

  return null;
};

export default RedirectHandler;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Next.js middleware에서 처리했던 로그인 검증 로직 authProvider를 만들어서 처리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인이 필요한 패스에 authProvider를 만들어서 &lt;b&gt;HOC 디자인 패턴&lt;/b&gt;으로 작동할 수 있게 해줍니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754437721494&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// as is 
const token = request.cookies.get(LOGIN_UUID);
const currentPath = pathname;

if (!token &amp;amp;&amp;amp; PROTECTED_ROUTES.includes(currentPath)) {
  const url = request.nextUrl.clone();
  url.searchParams.set('nextUrl', currentPath);
  url.pathname = encodeURI(`/login`);

  return NextResponse.redirect(url);
}

if (token &amp;amp;&amp;amp; PUBLIC_ROUTES.includes(currentPath)) {
  const url = request.nextUrl.clone();

  url.pathname = searchParams.get('nextUrl') || '/';
  url.searchParams.delete('nextUrl');

  return NextResponse.redirect(url);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1754437766815&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// to be 
export const AuthProvider = ({ children }: { children: React.ReactNode }) =&amp;gt; {
  const [isLoading, setIsLoading] = useState(true);
  const [isAuthenticated, setIsAuthenticated] = useState(false);

  const pathname = usePathname();

  useEffect(() =&amp;gt; {
    const token = getCookie(LOGIN_UUID);

    setIsAuthenticated(!!token);
    setIsLoading(false);
  }, []);

  const requestLogin = () =&amp;gt; {
    const url = new URL(window.location.href);
    url.searchParams.set('nextUrl', pathname);
    url.pathname = encodeURI(`/login`);
    redirect(url.toString());
  };

  if (isLoading) return &amp;lt;&amp;gt;&amp;lt;/&amp;gt;;

  if (!isAuthenticated) {
    requestLogin();
    return &amp;lt;&amp;gt;&amp;lt;/&amp;gt;;
  }

  if (isAuthenticated) {
    return &amp;lt;&amp;gt;{children}&amp;lt;/&amp;gt;;
  }

  return &amp;lt;&amp;gt;&amp;lt;/&amp;gt;;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;span&gt;앱전용 레이아웃(앱에서는 헤더, 푸터가 없음 등)들도 클라이언트에서 처리해주도록 수정해준다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;(단점은 마운트 될때 깜빡임이 있을수 있음)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: left;&quot;&gt;상세페이지 같은 동적 라우트를 처리하기위해 generateStaticParams()를 사용해 static한 파일로 만들어줍니다.&lt;/span&gt;&lt;br /&gt;상세페이지가 너무 광범위하기때문에 dynamicParams, revalidate를 사용해서 동적으로 생성, ISR로 작동하게하여 그 시간동안 캐시를 유지하고, 요청 시 페이지를 새로 생성하고 캐시합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. Full Route Cache를 적용하는 페이지 내 서버fetch는 next.revalidate 값을 설정해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;html에 적용한 revalidate시간보다 크커나 같아야한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;캐시 적용 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시가 HIT 된 것을 확인하기 위해 간단한 서버 컴포넌트를 추가해줍니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;function DebugCache({path}: {path: string}) {
  return (
    &amp;lt;div&amp;gt;
      {format(new Date(), 'yyyy-MM-dd-HH:mm:ss')}
      &amp;lt;RevalidateButton path={path} /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;'use client'
function RevalidateButton({ path }: {path: string}) {
  return &amp;lt;button onClick={() =&amp;gt; revalidateFullRouteCache(path)}&amp;gt;revalidate&amp;lt;/button&amp;gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;'use server'
import { revalidatePath } from 'next/cache';

export async function revalidateFullRouteCache(path) {
  if (path) {
    revalidatePath(path, 'layout');
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;한번 받아온 html과 RSC는 cache &lt;b&gt;HIT&lt;/b&gt; 처리가 되는것을 확인할수있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1136&quot; data-origin-height=&quot;446&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nepFi/btsPGu5wIrv/QuNtGtkZVHKqw2K93EtiJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nepFi/btsPGu5wIrv/QuNtGtkZVHKqw2K93EtiJ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nepFi/btsPGu5wIrv/QuNtGtkZVHKqw2K93EtiJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnepFi%2FbtsPGu5wIrv%2FQuNtGtkZVHKqw2K93EtiJ1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1136&quot; height=&quot;446&quot; data-origin-width=&quot;1136&quot; data-origin-height=&quot;446&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; font-size: 1.44em; letter-spacing: -1px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;응답시간과 TPS&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답시간: 클라이언트가 서버에 요청을 하고 그 요청에 대한 응답을 받을 때까지 걸린 시간을 의미&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TPS:&lt;/b&gt; 서버가 초당 처리할 수 있는 요청의 개수, TPS가 높을수록 초당 처리할 수 있는 요청의 수가 많다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 Apache JMeter를 사용하여 부하테스트를 진행하였고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 시나리오: 1초에 50번의 부하를 10번 반복하도록 하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TPS(서버)가 캐시전보다 50%정도 개선된걸 확인할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[적용 전]&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;666&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dkep14/btsPGpXsnIU/StLXfkhPQW5KlXhRk04u20/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dkep14/btsPGpXsnIU/StLXfkhPQW5KlXhRk04u20/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dkep14/btsPGpXsnIU/StLXfkhPQW5KlXhRk04u20/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdkep14%2FbtsPGpXsnIU%2FStLXfkhPQW5KlXhRk04u20%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1140&quot; height=&quot;666&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;666&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;subunit&quot;&gt;&lt;code&gt;Label - Sampler 이름
Samples - requset 개수
Average - 응답 평균
Min - 응답 최소
Max - 응답 최대
Std. Dev. - 응답 표준편차
Error % - 에러율
Throughput - 시간당 처리량
Received KB/sec - 시간당(sec) 받은 데이터(KB)
Sent KB/sec - 시간당(sec) 보낸 데이터(KB)
Avg. Bytes - 평균 바이트&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;124&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxoQZk/btsPJuQAVyi/DKznGEBjviYeSn47kAzRv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxoQZk/btsPJuQAVyi/DKznGEBjviYeSn47kAzRv0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxoQZk/btsPJuQAVyi/DKznGEBjviYeSn47kAzRv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcxoQZk%2FbtsPJuQAVyi%2FDKznGEBjviYeSn47kAzRv0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1586&quot; height=&quot;124&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;124&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[적용 후]&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;912&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NHA0z/btsPHHjoYw5/Od18U7nQmPuyvpCSEvCFNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NHA0z/btsPHHjoYw5/Od18U7nQmPuyvpCSEvCFNk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NHA0z/btsPHHjoYw5/Od18U7nQmPuyvpCSEvCFNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNHA0z%2FbtsPHHjoYw5%2FOd18U7nQmPuyvpCSEvCFNk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1586&quot; height=&quot;912&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;912&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;176&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DnaXW/btsPF7wgCvC/Nup1e7KQaQ6VCSvNLexSV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DnaXW/btsPF7wgCvC/Nup1e7KQaQ6VCSvNLexSV1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DnaXW/btsPF7wgCvC/Nup1e7KQaQ6VCSvNLexSV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDnaXW%2FbtsPF7wgCvC%2FNup1e7KQaQ6VCSvNLexSV1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1586&quot; height=&quot;176&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;176&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;참고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://fe-developers.kakaoent.com/2024/240418-optimizing-nextjs-cache/&quot;&gt;https://fe-developers.kakaoent.com/2024/240418-optimizing-nextjs-cache/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://leeggmin.tistory.com/10&quot;&gt;https://leeggmin.tistory.com/10&lt;/a&gt; - jmeter 사용방법&lt;/p&gt;</description>
      <category>Next.js</category>
      <author>ssund</author>
      <guid isPermaLink="true">https://ssund.tistory.com/178</guid>
      <comments>https://ssund.tistory.com/178#entry178comment</comments>
      <pubDate>Wed, 6 Aug 2025 09:16:40 +0900</pubDate>
    </item>
    <item>
      <title>Server Component와 Client Component 차이</title>
      <link>https://ssund.tistory.com/177</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;컴포넌트?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터(props)를 인자로 받아서, jsx를 리턴하는 자바스크립트 함수를 컴포넌트라고 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. &lt;b&gt;실행 환경&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서버 컴포넌트&lt;/b&gt;&lt;br /&gt;서버에서 렌더링되며, 클라이언트(브라우저)로 전송되는 &lt;b&gt;HTML&lt;/b&gt; 또는 &lt;b&gt;React Server Component Payload(RSC Payload)&lt;/b&gt; 형태로 제공됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트 측 JavaScript 번들 크기를 줄여 성능을 최적화합니다.&lt;/li&gt;
&lt;li&gt;기본적으로 Next.js 14의 모든 컴포넌트는 서버 컴포넌트로 간주됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;클라이언트 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트(브라우저)에서 렌더링되며, 서버에서 사전 렌더링된 후 클라이언트에서 &quot;하이드레이션(hydration)&quot; 과정을 거쳐 인터랙티브해집니다.&lt;/li&gt;
&lt;li&gt;'use client' 지시어를 파일 상단에 추가하여 정의합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. &lt;b&gt;주요 특징&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서버 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;데이터 페칭:&lt;/b&gt; 서버에서 데이터베이스나 API에 직접 접근 가능. 클라이언트 요청 수를 줄이고, API 키와 같은 민감한 데이터를 안전하게 처리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능&lt;/b&gt;: 서버에서 HTML을 생성해 초기 로드 속도(First Contentful Paint, FCP)를 개선하고, 클라이언트로 전송되는 JavaScript 양을 최소화합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐싱&lt;/b&gt;: 서버에서 렌더링된 결과를 캐싱하여 성능과 비용을 최적화할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;제한사항&lt;/b&gt;: React 훅(useState, useEffect)이나 이벤트 핸들러(onClick)를 사용할 수 없으며, 브라우저 API(예: localStorage)에 접근 불가.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;클라이언트 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;인터랙티비티:&lt;/b&gt; React 훅(useState, useEffect)과 이벤트 핸들러를 사용하여 사용자 인터랙션을 처리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;브라우저 API&lt;/b&gt;: localStorage, geolocation 같은 브라우저 API에 접근 가능.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;제한사항&lt;/b&gt;: 클라이언트로 전송되는 JavaScript 번들 크기가 커질 수 있으며, 서버에서 직접 데이터 페칭이 불가능하므로 Server Actions나 API 호출이 필요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. &lt;b&gt;사용 사례&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서버 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정적 콘텐츠나 데이터 페칭이 필요한 컴포넌트(예: 블로그 포스트, 제품 목록).&lt;/li&gt;
&lt;li&gt;SEO가 중요한 페이지(검색 엔진 봇이 렌더링된 HTML을 바로 인덱싱 가능).&lt;/li&gt;
&lt;li&gt;민감한 데이터를 서버에서 처리해야 하는 경우(예: API 키, 데이터베이스 쿼리).&lt;/li&gt;
&lt;li&gt;인터렉션이 없는 UI 적합&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;클라이언트 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자 입력(폼, 버튼 클릭)이나 동적 UI(모달, 드롭다운)를 처리하는 경우.&lt;/li&gt;
&lt;li&gt;브라우저 API를 사용하는 기능(예: 로컬 스토리지, geolocation).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. &lt;b&gt;컴포넌트 간 상호작용&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 컴포넌트는 클라이언트 컴포넌트를 자식으로 포함할 수 있다.&lt;/li&gt;
&lt;li&gt;클라이언트 컴포넌트는 서버컴포넌트를 직접 import 해서 사용할 수 없고, 서버 컴포넌트를 props나 자식으로 받아서 사용 할 수있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. &lt;b&gt;데이터 페칭 방식&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서버 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;fetch나 React의 cache 함수를 사용해 서버에서 데이터를 가져옵니다.&lt;/li&gt;
&lt;li&gt;캐싱과 성능 최적화가 용이하며, 클라이언트 요청을 줄입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;클라이언트 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Server Actions를 사용하거나, SWR 같은 라이브러리로 클라이언트에서 데이터를 페칭합니다.&lt;/li&gt;
&lt;li&gt;사용자 인터랙션(예: &quot;장바구니에 추가&quot; 버튼 클릭)에 따라 데이터를 업데이트할 때 유용.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. &lt;b&gt;렌더링 흐름&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서버 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버에서 렌더링된 HTML이 클라이언트로 전송되어 즉시 표시.&lt;/li&gt;
&lt;li&gt;RSC Payload를 통해 클라이언트와 서버 간 트리를 조정.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;클라이언트 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버에서 사전 렌더링된 HTML이 클라이언트로 전송된 후, JavaScript 번들을 다운로드하고 하이드레이션 과정을 거쳐 인터랙티브해짐.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. &lt;b&gt;장단점 요약&lt;/b&gt;&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;b&gt;특성&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;서버컴포넌트&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;클라이언트 컴포넌트&lt;/b&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;렌더링 위치&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;서버&lt;/td&gt;
&lt;td&gt;클라이언트 (사전 렌더링 후 하이드레이션)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;React 훅 사용&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;브라우저 API&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;데이터 페칭&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;서버에서 직접 가능&lt;/td&gt;
&lt;td&gt;Server Actions 또는 API 호출 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;성능&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;적은 JavaScript, 빠른 초기 로드&lt;/td&gt;
&lt;td&gt;JavaScript 번들 크기 증가 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;보안&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;민감 데이터 처리 적합&lt;/td&gt;
&lt;td&gt;클라이언트로 노출 가능성 주의&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;사용 사례&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;정적 UI, SEO, 데이터 페칭&lt;/td&gt;
&lt;td&gt;인터랙티브 UI, 사용자 이벤트 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description>
      <category>Next.js</category>
      <author>ssund</author>
      <guid isPermaLink="true">https://ssund.tistory.com/177</guid>
      <comments>https://ssund.tistory.com/177#entry177comment</comments>
      <pubDate>Fri, 25 Jul 2025 09:54:39 +0900</pubDate>
    </item>
    <item>
      <title>Next 스트리밍 with Suspense</title>
      <link>https://ssund.tistory.com/176</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Next 앱라우터는 &lt;b&gt;Suspense&lt;/b&gt;를 통한 스트리밍을 지원한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;streaming이란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React와 Next.js에서 스트리밍이 작동하는 방식을 알아보려면 서버사이드렌더링(SSR)을 이해해야한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;서버에서 모든 데이터를 fetch한다.&lt;/li&gt;
&lt;li&gt;데이터를 바탕으로한 HTML을 렌더링한다.&lt;/li&gt;
&lt;li&gt;클라이언트로 HTML, CSS, Javascript를 전송한다.&lt;/li&gt;
&lt;li&gt;HTML, CSS를 통해 인터페이스를 표현한다. (상호작용 안되는)&lt;/li&gt;
&lt;li&gt;React hydration을 통해 상호작용이 될 수 있도록 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1318&quot; data-origin-height=&quot;522&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tVPnh/btsPwJ9qulp/Xgg0iWkgdXOnIr6da6aIw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tVPnh/btsPwJ9qulp/Xgg0iWkgdXOnIr6da6aIw1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tVPnh/btsPwJ9qulp/Xgg0iWkgdXOnIr6da6aIw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtVPnh%2FbtsPwJ9qulp%2FXgg0iWkgdXOnIr6da6aIw1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1318&quot; height=&quot;522&quot; data-origin-width=&quot;1318&quot; data-origin-height=&quot;522&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;streaming을 사용하게 된 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 모든데이터를 가져오고 작업이 완료되므로 순차적이고 차단적이다.&lt;br /&gt;컨텐츠를 다운받기 전 빈화면이 보일 수있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1316&quot; data-origin-height=&quot;634&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/du3bQ9/btsPxDHyDV7/ihgT0JkBrnduveKiSRtL1k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/du3bQ9/btsPxDHyDV7/ihgT0JkBrnduveKiSRtL1k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/du3bQ9/btsPxDHyDV7/ihgT0JkBrnduveKiSRtL1k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdu3bQ9%2FbtsPxDHyDV7%2FihgT0JkBrnduveKiSRtL1k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1316&quot; height=&quot;634&quot; data-origin-width=&quot;1316&quot; data-origin-height=&quot;634&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;streaming을 사용하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지의 HTML을 작은조각으로 나누고 점진적으로 서버에서 클라이언트에 전송할 수있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 모든 데이터를 기다리지 않고 일부를 빨리 표시할 수있게 된다. &lt;b&gt;빠른 TTFB&lt;/b&gt;(Time To First Byte)와 &lt;b&gt;점진적 콘텐츠 표시&lt;/b&gt;를 가능하게 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React.Suspense는 아직 준비되지 않은 컴포넌트의 fallback UI를 먼저 보여준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 준비되면 점직적으로 완성된 UI로 대체된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1314&quot; data-origin-height=&quot;660&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qPnRi/btsPyaEE6vn/aVkRQjW5fvDPSwK9OHtoYK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qPnRi/btsPyaEE6vn/aVkRQjW5fvDPSwK9OHtoYK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qPnRi/btsPyaEE6vn/aVkRQjW5fvDPSwK9OHtoYK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqPnRi%2FbtsPyaEE6vn%2FaVkRQjW5fvDPSwK9OHtoYK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1314&quot; height=&quot;660&quot; data-origin-width=&quot;1314&quot; data-origin-height=&quot;660&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1300&quot; data-origin-height=&quot;618&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgfcQY/btsPydVCjKs/u8rjrtkpHXymRbaCn9Em0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgfcQY/btsPydVCjKs/u8rjrtkpHXymRbaCn9Em0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgfcQY/btsPydVCjKs/u8rjrtkpHXymRbaCn9Em0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgfcQY%2FbtsPydVCjKs%2Fu8rjrtkpHXymRbaCn9Em0k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1300&quot; height=&quot;618&quot; data-origin-width=&quot;1300&quot; data-origin-height=&quot;618&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;seo&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;generateMetaData Next.js 클라이언트에 UI를 스트리밍하기 전에 내부 데이터 가져오기가 완료될 때까지 기다립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 스트리밍된 응답의 첫 번째 부분에 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;태그가 포함됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스트리밍은 서버에서 렌더링되므로 SEO에 영향을 미치지 않습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;streaming작동원리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;suspense는 React Server Components + 스트리밍 렌더링을 통해 서버와 계속 통신하며 데이터를 점진적으로 가져온다.&lt;/li&gt;
&lt;li&gt;모든 데이터가 준비될때까지 기다리지 않고, 준비된 부분부터 클라이언트에 전달한다.&lt;/li&gt;
&lt;li&gt;서버와의 통신은 HTTP스트리밍 방식으로 일어나며, Next.js가 내부적으로 처리한다.&lt;/li&gt;
&lt;li&gt;클라이언트는 이걸 받아서 하이드레이션 없이 조립한다. (서버 컴포넌트는 상호작용이 없기 때문에 하이드레이션이 필요없다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// page.tsx (RSC)
import ProductInfo from './ProductInfo';
import Reviews from './Reviews';

export default function Page() {
  return (
    &amp;lt;&amp;gt;
      &amp;lt;h1&amp;gt;상품 상세&amp;lt;/h1&amp;gt;
      &amp;lt;Suspense fallback={&amp;lt;div&amp;gt;상품 정보를 불러오는 중...&amp;lt;/div&amp;gt;}&amp;gt;
        &amp;lt;ProductInfo /&amp;gt;
      &amp;lt;/Suspense&amp;gt;
      &amp;lt;Suspense fallback={&amp;lt;div&amp;gt;리뷰를 불러오는 중...&amp;lt;/div&amp;gt;}&amp;gt;
        &amp;lt;Reviews /&amp;gt;
      &amp;lt;/Suspense&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ProductInfo, Reviews가 동시에 서버에서 렌더링되며, 준비된 컴포넌트부터 서버에서 HTML로 전달된다.&lt;/li&gt;
&lt;li&gt;네트워크 탭에서 확인하면 HTML이 조각조각 오는것을 확인할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;streaming 과정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 초기 HTML틀과 Suspense fallback을 먼저 클라이언트로 전송&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html&amp;gt;
  &amp;lt;head&amp;gt;...&amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;
    &amp;lt;div id=&quot;__next&quot;&amp;gt;
      &amp;lt;h1&amp;gt;상품 정보&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;상품 로딩 중...&amp;lt;/p&amp;gt;
      &amp;lt;p&amp;gt;리뷰 로딩 중...&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. flight payload&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 JSON payload를 스트리밍으로 이어서 보냅니다.&lt;/p&gt;
&lt;pre class=&quot;nimrod&quot;&gt;&lt;code&gt;&amp;lt;script&amp;gt;
self.__next_f.push([
  &quot;rsc&quot;, 
  [
    [null, &quot;product-stream-id&quot;, {... 렌더링된 Product 컴포넌트 데이터 ...}],
    [null, &quot;review-stream-id&quot;, {... 렌더링된 Reviews 컴포넌트 데이터 ...}]
  ]
])
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 데이터는 react runtime이 읽어 조립하는 &lt;b&gt;RSC payload&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;self.__next_f.push(...)는 Next.js 내부에서 사용하는 Flight 데이터 푸시 방식입니다.&lt;/li&gt;
&lt;li&gt;각 컴포넌트는 React가 hydration없이 직접 브라우저에 DOM으로 삽입합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크롬 개발자 도구 &amp;gt; Network &amp;gt; document 혹은 /_next/data/... 또는 /_next/flight 요청을 보면 확인할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;스트리밍 확인방법&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;개발자 도구 &amp;gt; 네트워크 탭에서 http 요청이 이렇게 오는것을 확인할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Content-Type: text/x-component; charset=utf-8
Transfer-Encoding: chunked&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;streaming효과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Suspense를 사용하면 점진적 로딩, 사용자 경험 향상, 성능 최적화등을 효과적으로 구현할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TTFB&lt;/b&gt;(브라우저가 서버로부터 첫 번째 바이트의 응답을 받기 걸린 시간), &lt;b&gt;FCP&lt;/b&gt;(First Contentful Paint), &lt;b&gt;TTI&lt;/b&gt;(Time to Interactive) 를 개선하는데 도움이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고 url&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/14/app/building-your-application/routing/loading-ui-and-streaming&quot;&gt;https://nextjs.org/docs/14/app/building-your-application/routing/loading-ui-and-streaming&lt;/a&gt;&lt;/p&gt;</description>
      <category>Next.js</category>
      <author>ssund</author>
      <guid isPermaLink="true">https://ssund.tistory.com/176</guid>
      <comments>https://ssund.tistory.com/176#entry176comment</comments>
      <pubDate>Thu, 24 Jul 2025 14:38:13 +0900</pubDate>
    </item>
    <item>
      <title>persistQueryClient를 활용해 초기렌더링 개선하기</title>
      <link>https://ssund.tistory.com/175</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 배경&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Query를 사용하면 캐싱이 가능하지만, 기본적으로 브라우저 메모리에 저장되므로 새 창을 열거나 새로고침하면 캐시 데이터가 유지되지 않습니다. 특히, 슈프라이즈 앱처럼 페이지 이동 시 새 창이 열리는 구조에서는 react-query 캐시를 활용할 수 없는 문제가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 &lt;code&gt;persistQueryClient&lt;/code&gt; 를 사용하여 캐시를 로컬 스토리지에 저장하고, 새 창이나 새로고침 시에도 캐시 데이터를 활용할 수 있도록 최적화하였습니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;persistQueryClient?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;persistQueryClient는 React Query에서 제공하는 기능으로, &lt;b&gt;쿼리 클라이언트(QueryClient)&lt;/b&gt;의 상태(예: 캐시된 데이터)를 브라우저의 &lt;b&gt;로컬 스토리지&lt;/b&gt;, &lt;b&gt;세션 스토리지&lt;/b&gt; 등에 영구적으로 저장(persist)할 수 있게 해주는 기능입니다. 이렇게 하면 사용자가 페이지를 새로 고치거나 브라우저를 닫았다가 다시 열어도 캐시된 데이터를 복구할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 구현 방법&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) 패키지 설치&lt;/h3&gt;
&lt;pre class=&quot;shell&quot; data-ke-language=&quot;shell&quot;&gt;&lt;code&gt;npm install @tanstack/react-query-persist-client @tanstack/query-async-storage-persister&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) ReactQueryProvider 수정&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;기존 코드&lt;/b&gt; (QueryClientProvider 사용)&lt;/h4&gt;
&lt;pre class=&quot;html xml&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;&amp;lt;QueryClientProvider client={queryClient}&amp;gt;
    {children}
&amp;lt;/QueryClientProvider&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;변경 후 코드&lt;/b&gt; (PersistQueryClientProvider 사용)&lt;/h4&gt;
&lt;pre class=&quot;html xml&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;&amp;lt;PersistQueryClientProvider
  client={queryClient}
  persistOptions={{
    persister: asyncStoragePersister,
    dehydrateOptions: {
      shouldDehydrateQuery: (query) =&amp;gt; query.options.meta?.persist === true,
    },
  }}
&amp;gt;
  {children}
&amp;lt;/PersistQueryClientProvider&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;meta.persist: true&lt;/code&gt;가 설정된 데이터만 스토리지에 저장하도록 설정&lt;/li&gt;
&lt;li&gt;불필요한 데이터 저장을 방지하여 저장 공간 절약&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3) &lt;code&gt;useQuery&lt;/code&gt; 옵션 수정&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;const { data: product, isPending } = useQuery({
  queryKey: ['getProductDetailByPermalink', permalink],
  queryFn: () =&amp;gt; getProductDetailByPermalink(permalink),
  enabled: !!permalink,
  meta: { persist: true },
});&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;meta: { persist: true }&lt;/code&gt; 설정을 추가하여 필요한 데이터만 로컬 스토리지에 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬스토리지에 저장 된 데이터를 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1564&quot; data-origin-height=&quot;70&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPsvGW/btsO7yOhTKg/CFxsCM9KywxvT3KOGgkOtk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPsvGW/btsO7yOhTKg/CFxsCM9KywxvT3KOGgkOtk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPsvGW/btsO7yOhTKg/CFxsCM9KywxvT3KOGgkOtk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPsvGW%2FbtsO7yOhTKg%2FCFxsCM9KywxvT3KOGgkOtk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1564&quot; height=&quot;70&quot; data-origin-width=&quot;1564&quot; data-origin-height=&quot;70&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 해결해야 했던 문제 및 대응 방법&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Next.js 14에서 &lt;code&gt;window&lt;/code&gt; 사용 이슈&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js14는 서버 사이드에서도 실행되기 때문에 window.localStorage를 직접 참조하면 undefined 오류가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 방지하기 위해, 클라이언트 환경에서만 localStorage를 설정하도록 처리했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1580&quot; data-origin-height=&quot;506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WNbnR/btsO67DfbRF/5aCv95vI7eY404EfvZxdXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WNbnR/btsO67DfbRF/5aCv95vI7eY404EfvZxdXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WNbnR/btsO67DfbRF/5aCv95vI7eY404EfvZxdXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWNbnR%2FbtsO67DfbRF%2F5aCv95vI7eY404EfvZxdXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1580&quot; height=&quot;506&quot; data-origin-width=&quot;1580&quot; data-origin-height=&quot;506&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 실행하는 경우 undefined, 클라이언트에서 저장소 지정을 해주는것으로 했습니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;const ReactQueryProvider = ({ children }: { children: React.ReactNode }) =&amp;gt; {
  const persister = createSyncStoragePersister({
    storage: typeof window !== 'undefined' ? window.localStorage : undefined,
  });

  return (
    &amp;lt;PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister: persister,
        dehydrateOptions: {
          shouldDehydrateQuery: (query) =&amp;gt; {
            return query.options.meta?.persist === true;
          },
        },
      }}
    &amp;gt;
      {children}
      {process.env.NODE_ENV !== 'production' &amp;amp;&amp;amp; &amp;lt;ReactQueryDevtools initialIsOpen={false} /&amp;gt;}
    &amp;lt;/PersistQueryClientProvider&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;persistQueryClient를 적용 후,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한번 확인한 상품데이터는 로컬스토리에 저장 되어 2번째 방문시 빠르게 노출이 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;persistQueryClient 적용전&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1384&quot; data-origin-height=&quot;140&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dc3otc/btsO7EN7RLq/PUjFnBf5wZ87zsjQXa47L0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dc3otc/btsO7EN7RLq/PUjFnBf5wZ87zsjQXa47L0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dc3otc/btsO7EN7RLq/PUjFnBf5wZ87zsjQXa47L0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdc3otc%2FbtsO7EN7RLq%2FPUjFnBf5wZ87zsjQXa47L0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1384&quot; height=&quot;140&quot; data-origin-width=&quot;1384&quot; data-origin-height=&quot;140&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;persistQueryClient 적용후&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;146&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bV4alP/btsO9aZkdJ6/xz8ZXnw7ZHUAIfYa3XkA6k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bV4alP/btsO9aZkdJ6/xz8ZXnw7ZHUAIfYa3XkA6k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bV4alP/btsO9aZkdJ6/xz8ZXnw7ZHUAIfYa3XkA6k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbV4alP%2FbtsO9aZkdJ6%2Fxz8ZXnw7ZHUAIfYa3XkA6k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1160&quot; height=&quot;146&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;146&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 로딩 시간이 약 2초에서 0.1초로 약 &lt;b&gt;95%&lt;/b&gt; 빨라졌습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 추가 개선: placeholderData를 활용한 초기 렌더링 개선&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장된 캐시가 있는 상품은 렌더링 시간이 많이 개선이 되었지만&lt;br /&gt;캐시가 없는 상품은 기존과 동일하게 2초 정도의 렌더링 시간이 걸렸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;홈 화면에서 상품 리스트를 불러올 때 상세페이지에 필요한 &lt;b&gt;일부 데이터(예: 이름, 상품코드 등)&lt;/b&gt;를 포함하고 있기 때문에, 이 데이터를 react-query의 &lt;b&gt;placeholderData&lt;/b&gt;로 활용하여 초기 렌더링 속도를 더욱 개선할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드와 같이 상품 permalink를 딕셔너리 형태로 로컬스토리지에 상품리스트 데이터를 저장해줍니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;supreme-x-nike-air-max-dn-black&quot;: {},
  &quot;air-jordan-4-retro-bred-reimagined&quot;: {},
  &quot;asics-gel-kayano-14-cream-black-1201a019-108&quot;: {}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상세페이지에서 서버 데이터를 받아오기 전에 상품리스트에서 받아온 데이터를 활용하여 초기데이터로 사용하고&lt;br /&gt;데이터가 없는곳은 스켈레톤 처리를 하여 데이터 fetch가 완료되면 렌더링이 되도록 하였습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;const placeholder = JSON.parse(localStorage.getItem('products') || '{}')?.[permalink];

const { data: product, isPending } = useQuery({
  queryKey: ['getProductDetailByPermalink', permalink],
  queryFn: () =&amp;gt; getProductDetailByPermalink(permalink),
  enabled: !!permalink,
  meta: { persist: true },
  placeholderData: placeholder || {},
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;부분데이터 placeholderData로 적용후&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1434&quot; data-origin-height=&quot;238&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eqJ0pN/btsO89lX89B/MHwfXI5Wq32JRtnXOKotAK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eqJ0pN/btsO89lX89B/MHwfXI5Wq32JRtnXOKotAK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eqJ0pN/btsO89lX89B/MHwfXI5Wq32JRtnXOKotAK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeqJ0pN%2FbtsO89lX89B%2FMHwfXI5Wq32JRtnXOKotAK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1434&quot; height=&quot;238&quot; data-origin-width=&quot;1434&quot; data-origin-height=&quot;238&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 데이터가 렌더링 되진않지만 persistQueryClient 캐시가 있는 경우보다 약 &lt;b&gt;73.33%&lt;/b&gt;정도 빨라졌습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 추가개선: placeholderData 용량 관리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 상품리스트 placeholderData를 로컬스토리지에 저장하고 있는데&lt;br /&gt;로컬스토리지 데이터는 도메인 당 5MB로 한정되어있어 데이터가 쌓이는경우 저장이 안되는 이슈가 있을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;placeholderData를 최대 1MB로 용량을 제한하고&lt;br /&gt;저장하는 시점에 로컬스토리지 사이즈를 확인하고 1MB가 넘는경우 오래된 데이터를 삭제하고 저장하도록 하였습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;export function getByteSize(str: string) {
  return new Blob([str]).size;
}

const STORAGE_KEY = 'products';
const MAX_SIZE = 1 * 1048576; // 1MB

export function saveDataWithLimit(newValue: any) {
  let data = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');

  data = { ...data, ...newValue };
  let keys = Object.keys(data);

  let size = getByteSize(JSON.stringify(data));

  while (size &amp;gt; MAX_SIZE &amp;amp;&amp;amp; keys.length &amp;gt; 0) {
    const oldestKey = keys.shift() as string;
    delete data[oldestKey];
    size = getByteSize(JSON.stringify(data));
  }

  localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 추가개선: placeholderData 만료일 지정&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;placeholderData 만료일을 지정하지 않으면 오래된 데이터가 placeholder Data로 들어오는 경우가 있을수 있기때문에&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만료일 2일로 정하고 만료일 이 후에 상품리스트 페이지에 들어오는경우 만료된 데이터를 삭제하고 새로운 데이터를 fetch해서 다시 set할 수 있도록 했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;export function getByteSize(str: string) {
  return new Blob([str]).size;
}

const STORAGE_KEY = 'products';
const MAX_SIZE = 1000000; // 1MB
const EXPIRY_BUFFER_DAYS = 2;

export function isExpired(expiryDate: string): boolean {
  const now = new Date();
  const expiry = new Date(expiryDate);
  expiry.setDate(expiry.getDate() + EXPIRY_BUFFER_DAYS);
  return now &amp;gt; expiry;
}

export function saveDataWithLimit(newValue: any) {
  let data = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');

  // 데이터의 만료일을 체크하는 로직 추가
  for (const key in data) {
    if (data[key]?.expiry &amp;amp;&amp;amp; isExpired(data[key].expiry)) {
      delete data[key];
    }
  }

  data = { ...data, ...newValue };

  let keys = Object.keys(data);
  let size = getByteSize(JSON.stringify(data));

  while (size &amp;gt; MAX_SIZE &amp;amp;&amp;amp; keys.length &amp;gt; 0) {
    const oldestKey = keys.shift() as string;
    delete data[oldestKey];
    size = getByteSize(JSON.stringify(data));
  }

  localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만료일 이 후 placeholder data를 사용하는 페이지로 바로 들어오는 경우&lt;br /&gt;placeholder 데이터의 만료일을 확인하고 만료일이 지난경우 삭제하는 로직을 넣어두었습니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;const getStoredProducts = (): Record&amp;lt;string, any&amp;gt; =&amp;gt; {
  return JSON.parse(localStorage.getItem('products') || '{}');
};

const saveStoredProducts = (products: Record&amp;lt;string, any&amp;gt;) =&amp;gt; {
  localStorage.setItem('products', JSON.stringify(products));
};

const getValidPlaceholder = (permalink: string) =&amp;gt; {
  const products = getStoredProducts();
  const placeholder = products?.[permalink];

  if (!placeholder || isExpired(placeholder.expiry)) {
    delete products[permalink];
    saveStoredProducts(products);
    return undefined;
  }

  return placeholder.value;
};

const { data: product, isPending } = useQuery({
  queryKey: ['getProductDetailByPermalink', permalink],
  queryFn: () =&amp;gt; getProductDetailByPermalink(permalink),
  enabled: !!permalink,
  meta: { persist: true },
  staleTime: 1000 * 60 * 60,
  placeholderData: getValidPlaceholder(permalink),
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저코드를 적용하고 오류가 있었는데. 요청한 스크립트가 취소되는 현상이였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1570&quot; data-origin-height=&quot;240&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZwxS2/btsO76xcOQ2/5XfJeCh2SZ020HyHwZtC20/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZwxS2/btsO76xcOQ2/5XfJeCh2SZ020HyHwZtC20/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZwxS2/btsO76xcOQ2/5XfJeCh2SZ020HyHwZtC20/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZwxS2%2FbtsO76xcOQ2%2F5XfJeCh2SZ020HyHwZtC20%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1570&quot; height=&quot;240&quot; data-origin-width=&quot;1570&quot; data-origin-height=&quot;240&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알고보니 localstorage가 없는경우 placeholder데이터에 set해서 오류가 나는것 같아보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useEffect로 localStorage가 있는지 체크하고 set 하는 것으로 변경했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. 전체 동작 흐름&lt;/b&gt;&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;홈/목록 페이지에서 상품 리스트를 fetch&lt;/li&gt;
&lt;li&gt;상세 페이지 진입 시:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;홈에서 가져온 데이터가 있을 경우 &amp;rarr; placeholderData로 우선 렌더링&lt;/li&gt;
&lt;li&gt;persistQueryClient에 저장된 캐시가 있을 경우 &amp;rarr; 복원하여 렌더링&lt;/li&gt;
&lt;li&gt;위 조건이 만족되지 않으면 &amp;rarr; 스켈레톤 렌더링&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;상품상세 API 호출 후 최신 데이터로 갱신&lt;br /&gt;(캐시 데이터와 api로 가져온 데이터가 동일하다면 리렌더링 하지 않음)&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;결론&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;persistQueryClient를 통해 React Query의 캐시 데이터를 영속화하여 UX를 개선할 수 있었습니다.&lt;/li&gt;
&lt;li&gt;여기에 placeholderData를 활용한 초기 렌더링 최적화를 더해, &lt;b&gt;새 창 진입 또는 새로고침 시에도 더 빠르고 부드러운 사용자 경험&lt;/b&gt;을 제공하게 되었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/th&gt;
&lt;th&gt;&lt;b&gt;데이터 로딩 시간&lt;/b&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;홈 상품리스트에서 저장 된 placeholder 데이터가 있는경우&lt;/td&gt;
&lt;td&gt;32ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;persistQueryClient 캐시가 있는 경우&lt;/td&gt;
&lt;td&gt;78ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;상품상세 API fetch&lt;/td&gt;
&lt;td&gt;1924ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description>
      <category>Next.js</category>
      <author>ssund</author>
      <guid isPermaLink="true">https://ssund.tistory.com/175</guid>
      <comments>https://ssund.tistory.com/175#entry175comment</comments>
      <pubDate>Tue, 8 Jul 2025 08:21:24 +0900</pubDate>
    </item>
    <item>
      <title>클로저</title>
      <link>https://ssund.tistory.com/173</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;함수와 렉시컬환경의 조합&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수가 생성 될 당시의 외부변수를 기억, 생성 이후에도 계속 접근 가능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부변수의 은닉화 (외부함수의 변수를 변경할 수 없다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수 내부 렉시컬환경에서 변수가 없는경우 하나씩 외부로 나가서 변수를 찾게된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부함수 -&amp;gt; 외부함수 -&amp;gt; ... -&amp;gt; 전역환경&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1735783742046&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let one;
one = 1;

function addOne(num) {
  console.log(num + one); //인수 5 + 전역변수 one값을 가져온다
}

addOne(5);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수가 생성 될 당시의 변수를 기억해서&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후에 계속 사용할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1735783879581&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function makeAdder(x) {
  return function (y) {
    return x + y;
  };
}

const add3 = makeAdder(3);
console.log(add3(2)); // 5

const add10 = makeAdder(10);
console.log(add10(5)); // 15
console.log(add3(1)); // 4&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;장단점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클로저의 장점은 함수 내부의 상태를 유지할 수 있다는 점입니다.&lt;br /&gt;이는 비동기 처리나 콜백 함수에서 매우 유용하게 사용됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #505860; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;클로저의 단점은 메모리 누수가 발생할 수 있다는 점입니다. &lt;br /&gt;클로저는 함수 내부의 변수를 계속해서 참조하기 때문에, 메모리 누수가 발생할 수 있습니다.&lt;/p&gt;</description>
      <category>javascript</category>
      <category>JavaScript</category>
      <category>클로저</category>
      <author>ssund</author>
      <guid isPermaLink="true">https://ssund.tistory.com/173</guid>
      <comments>https://ssund.tistory.com/173#entry173comment</comments>
      <pubDate>Thu, 2 Jan 2025 11:14:12 +0900</pubDate>
    </item>
    <item>
      <title>ios 비동기로 input에 focus</title>
      <link>https://ssund.tistory.com/172</link>
      <description>&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;프로젝트에서 특정 버튼을 클릭 시 사용자정보를 fetch해서 response에 따라 &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;비동기로 input에 focus하는 로직을 구현했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;안드, 웹에서는 정상작동했지만 ios에서 input에 focus가 안되는 이슈가 있었습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;찾아보니 Ios는 사용자 인터렉션에 의해서만 input focus를 시킬 수 있다고 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;해결방법이라기보다 제가 사용한 꼼수는 &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;ios경우 버튼클릭 시 인풋에 포커스가 가게하고 &lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;비동기로 response받아서 이후 프로세스 처리하는 것으로 진행했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;사실 ios의 경우 활성화가 되면안되는경우도 활성화가 되었다가 비활성화 된다는 현상이 있었지만 &lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;사용성을 생각했을 때 키보드에 포커스가 가는게 더 낫다 생각을 했기때문에 진행했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;참고 &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;a href=&quot;https://gist.github.com/cathyxz/73739c1bdea7d7011abb236541dc9aaa&quot;&gt;https://gist.github.com/cathyxz/73739c1bdea7d7011abb236541dc9aaa&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;</description>
      <category>Tips</category>
      <author>ssund</author>
      <guid isPermaLink="true">https://ssund.tistory.com/172</guid>
      <comments>https://ssund.tistory.com/172#entry172comment</comments>
      <pubDate>Fri, 27 Dec 2024 09:51:57 +0900</pubDate>
    </item>
    <item>
      <title>Next.js with CSS-in-JS</title>
      <link>https://ssund.tistory.com/170</link>
      <description>&lt;ol style=&quot;list-style-type: decimal; background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;순수 css&lt;/b&gt; &lt;br /&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;별다른 처리가 필요하지 않아 서버컴포넌트에서 호환문제가 발생하지 않는다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span&gt;css 전처리기&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;br /&gt;&lt;/span&gt;&lt;span&gt;sass, less, postcss등 css 전처리기는 빌드타임에 순수css로 컴파일된다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;css modules&lt;/b&gt; &lt;br /&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;빌드타임에 css가 생성된다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;Tailwind css&lt;/b&gt; &lt;br /&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;빌드 타임에 css가 생성된다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span&gt;CSS-in-JS&lt;/span&gt;&lt;/b&gt;&lt;span&gt; &lt;br /&gt;&lt;/span&gt;&lt;span&gt;자바스크립트를 사용하여 스타일 정의를 한다. &lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;런타임에 동적으로 스타일이 적용된다. &lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;빌드타임에 css가 완성되냐, 런타임에서 js통해 동적으로 css가 렌더링 되냐&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Next.js 서버컴포넌트 호환성을 결정해준다. &lt;/span&gt;&lt;/p&gt;</description>
      <category>Next.js</category>
      <author>ssund</author>
      <guid isPermaLink="true">https://ssund.tistory.com/170</guid>
      <comments>https://ssund.tistory.com/170#entry170comment</comments>
      <pubDate>Thu, 26 Dec 2024 14:30:44 +0900</pubDate>
    </item>
  </channel>
</rss>