<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Jiseong's Web Dev blog</title>
    <link>https://jijiseong.tistory.com/</link>
    <description>Frontend developer</description>
    <language>ko</language>
    <pubDate>Fri, 9 Oct 2026 07:53:40 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>jijiseong</managingEditor>
    <item>
      <title>React Server Component 깊게 이해하기</title>
      <link>https://jijiseong.tistory.com/11</link>
      <description>&lt;h1&gt;Intro&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React는 왜 RSC(React Server Component) 방식을 사용할까? RSC가 해결하려고하는 문제는 무엇일까? Server Side Rendering과 RSC는 다른 것일까? Next.js에서 RSC를 렌더링하는 과정, RCC(React Client Component)를 렌더링하는 과정에 대해 자세히 살펴보자.&lt;/p&gt;
&lt;h1&gt;SSR? RSC?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 SSR과 RSC의 개념에 대해 정확히 짚어보자. SSR은 요청 시점에 &lt;b&gt;서버에서 HTML을 생성해 클라이언트로 전달&lt;/b&gt;하는 렌더링 방식이다. 서버가 데이터를 fetch하고 HTML을 완성해서 클라이언트로 전달한다. 이후 클라이언트는 JS 번들을 다운로드 받아 실행하고 Hydration 과정을 거쳐 인터렉션을 더한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RSC는 &lt;b&gt;서버에서 실행되는 컴포넌트&lt;/b&gt;이다. 서버 컴포넌트는 JS 번들에 포함되지 않는다. 서버에서 모든 렌더링 로직을 실행하기 때문이다. &lt;b&gt;JS 번들 최적화&lt;/b&gt;에서 이점이 있다. RSC는 SSR과 함께 동작하는 컴포넌트이다. RSC는 서버에서 실행하기 때문에 &lt;b&gt;Hydration 과정이 필요없기도하다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSR은 &quot;HTML을 언제 생성하느냐&quot;에 관한 기술이고, RSC는 &quot;컴포넌트를 어디서 실행하느냐&quot;에 관한 기술이다.&lt;/p&gt;
&lt;h1&gt;렌더링 과정을 단계별로 살펴보자&lt;/h1&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/page.tsx
console.log(&quot;[Home]&quot;);

// SSG 비활성을 위해
export const dynamic = &quot;force-dynamic&quot;;

export default async function Home() {
  console.log(&quot;[Home] Page Render&quot;);
  return (
    &amp;lt;div className=&quot;size-full h-100 justify-center flex items-center flex-col gap-8&quot;&amp;gt;
      &amp;lt;h1&amp;gt;Home&amp;lt;/h1&amp;gt;
      &amp;lt;LikeButton label=&quot;Click&quot; /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/components/like-button.tsx
&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;

export default function LikeButton({ label }: { label: string }) {
  console.log(&quot;[LikeButton] render&quot;);
  const [like, setLike] = useState(true);

  useEffect(() =&amp;gt; {
    console.log(&quot;[LikeButton] mounted&quot;);
  }, []);

  return (
    &amp;lt;button onClick={() =&amp;gt; setLike((cur) =&amp;gt; !cur)}&amp;gt;
      {label}
      {like ? &quot;like&quot; : &quot;dislike&quot;}
    &amp;lt;/button&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/ 으로 접속하면 어떤 일이 일어날까?&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Server Side Rendering&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 브라우저는 서버로 요청을 보낸다. SSR이 시작된다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// server
[Home]
[Home] Page Render
[LikeButton]
[LikeButton] render

// browser
[LikeButton]
[LikeButton] render
[LikeButton] mounted&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그를 확인해보면, RSC든 RCC든 상관없이 모두 서버에서 실행된다. 이후 브라우저에서 RCC가 실행되고, 마운트된다. 실행되는 과정에서 RSC와 RCC는 RSC Payload로 변환된다. 서버에서 실행되는 과정에서 일어나는 일을 더 자세히 살펴보자.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RSC Payload는 Next.js에서 설명하는 용어이고, React 내부적으로는 React Flight protocol이라는 serialization 포맷을 사용한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 공식 문서에 따르면 아래와 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Server Components are rendered into a special data format called the React Server Component Payload (RSC Payload).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 컴포넌트는 RSC Payload 라는 특별한 데이터로 렌더링된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Client Components and the RSC Payload are used to pre-render HTML.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 컴포넌트와 RSC Payload는 HTML pre render를 위해 사용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 RSC Payload를 생성하기 위해 실행하는 것이다. RSC Payload는 RSC Tree를 표현하기 위한 데이터 포맷이다. client 사이드에서 React 내부적으로 사용하는 데이터이고, 이는 browser의 DOM 업데이트를 위한 것이다. 아래와 같은 정보들을 포함한다.&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;클라이언트 컴포넌트가 어디에 렌더링될 지 참조하는 placeholder, 이 컴포넌트들이 참조하는 JS 파일들&lt;/li&gt;
&lt;li&gt;서버 컴포넌트에서 클라이언트 컴포넌트로 전달하는 props&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSR의 결과물은 HTML 문서이다. 정확히 어떤 데이터가 나오는지 개발자 도구에서 네트워크 탭을 살펴보자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2216&quot; data-origin-height=&quot;1772&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/m8jHq/dJMcaakfw6E/5iz77kzeL5iq9TGWHB8W2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/m8jHq/dJMcaakfw6E/5iz77kzeL5iq9TGWHB8W2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/m8jHq/dJMcaakfw6E/5iz77kzeL5iq9TGWHB8W2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fm8jHq%2FdJMcaakfw6E%2F5iz77kzeL5iq9TGWHB8W2K%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;2216&quot; height=&quot;1772&quot; data-origin-width=&quot;2216&quot; data-origin-height=&quot;1772&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;body를 보면 &lt;code&gt;&amp;lt;h1/&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;button/&amp;gt;&lt;/code&gt; 들이 렌더링 된 것을 볼 수 있다. 또, 아래쪽의 script 태그 부분에서 RSC Payload 관련 스크립트를 찾을 수 있다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;&amp;lt;script&amp;gt;
  self.__next_f.push([
    1,
    `
    d:I[
      51810,
      [&quot;/_next/static/chunks/33f2145b8edcf2b7.js&quot;],
      &quot;default&quot;
    ]

    4:[
      &quot;$&quot;,
      &quot;div&quot;,
      null,
      {
        &quot;className&quot;: &quot;size-full h-100 justify-center flex items-center flex-col gap-8&quot;,
        &quot;children&quot;: [
          [
            &quot;$&quot;,
            &quot;h1&quot;,
            null,
            {
              &quot;children&quot;: &quot;Home&quot;
            }
          ],
          [
            &quot;$&quot;,
            &quot;$Ld&quot;,
            null,
            {
              &quot;label&quot;: &quot;Click&quot;
            }
          ]
        ]
      }
    ]
    `,
  ]);
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RSC Payload를 담고있는 script의 내용을 살펴보자. &lt;code&gt;h1&lt;/code&gt;, &lt;code&gt;Home&lt;/code&gt;, &lt;code&gt;label&lt;/code&gt;, &lt;code&gt;Click&lt;/code&gt; 등의 키워드를 보면 내가 작성한 서버 컴포넌트와 클라이언트 컴포넌트 내용들인 것을 알 수 있다. GPT의 도움을 받아 해석을 부탁했다. 몇가지 정리해보면,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;구조는 기본적으로 [&quot;$&quot;, type, key, props] 입니다.&lt;/li&gt;
&lt;li&gt;&quot;$&quot; 는 React element marker입니다.&lt;/li&gt;
&lt;li&gt;&quot;$Ld&quot; 는 Client Component reference입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 Client Component는 참조만 한다는 것을 확인할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Client Side&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 서버에서 생성한 HTML 문서와 JS 번들을 다운로드 받아 Hydration 해야한다. 네트워크 탭에서 JS 번들을 한 번 찾아보자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1714&quot; data-origin-height=&quot;1020&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cghlQt/dJMcadgX4WW/Vow48iIvTFlSQD9mSRMask/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cghlQt/dJMcadgX4WW/Vow48iIvTFlSQD9mSRMask/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cghlQt/dJMcadgX4WW/Vow48iIvTFlSQD9mSRMask/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcghlQt%2FdJMcadgX4WW%2FVow48iIvTFlSQD9mSRMask%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;1714&quot; height=&quot;1020&quot; data-origin-width=&quot;1714&quot; data-origin-height=&quot;1020&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 컴포넌트인 LikeButton 컴포넌트의 코드를 확인할 수 있다. Next.js 문서에서는 세가지를 설명한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTML is used to immediately show a fast non-interactive preview of the route to the user.&lt;br /&gt;이미 클라이언트 컴포넌트도 SSR 과정에서 HTML에 포함된다. 따라서 non-interactive preview를 즉시 보여준다.&lt;/li&gt;
&lt;li&gt;RSC Payload is used to reconcile the Client and Server Component trees.&lt;br /&gt;RSC Payload는 Client and Server Component tree를 reconcilation(재조정, diffing 알고리즘을 통한 렌더링 과정)을 하기 위해 사용된다.&lt;/li&gt;
&lt;li&gt;JavaScript is used to hydrate Client Components and make the application interactive.&lt;br /&gt;네트워크 탭에서 확인한 다운로드된 JS 번들은 클라이언트 컴포넌트에 hydration 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;요약하자면&lt;/h1&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;브라우저 요청&lt;/li&gt;
&lt;li&gt;서버에서 Server Component 실행&lt;/li&gt;
&lt;li&gt;RSC Payload 생성&lt;/li&gt;
&lt;li&gt;RSC Payload + Client Component로 HTML 생성&lt;/li&gt;
&lt;li&gt;HTML 브라우저 전송&lt;/li&gt;
&lt;li&gt;JS bundle 다운로드&lt;/li&gt;
&lt;li&gt;Hydration&lt;/li&gt;
&lt;li&gt;RSC Payload 기반 React tree 구성&lt;/li&gt;
&lt;li&gt;Client Component 실행 (마운트)&lt;/li&gt;
&lt;li&gt;Interactive UI 완성&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;느낀 점&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSR, CSR, RSC, RCC에 대해 어느 정도 이해하고 있다고 생각했다. 하지만 RSC가 실제로 언제 어떻게 동작하는지 디버깅하며 파고들다 보니, 내가 알고 있던 것은 깊이가 부족하다고 느껴졌다. 공식 문서를 읽고, 실제 네트워크 응답과 실행 로그를 확인하면서 렌더링 과정이 어떻게 이어지는지 조금 더 명확하게 이해할 수 있었다. 기술을 이해할 때 &quot;왜 이렇게 동작할까?&quot;라는 질문을 계속 던지는 것이 얼마나 중요한지 다시 한 번 느낀 계기였다.&lt;/p&gt;</description>
      <category>React</category>
      <category>hydration</category>
      <category>RCC</category>
      <category>react server component</category>
      <category>RSC</category>
      <category>rsc payload</category>
      <category>SSR</category>
      <author>jijiseong</author>
      <guid isPermaLink="true">https://jijiseong.tistory.com/11</guid>
      <comments>https://jijiseong.tistory.com/11#entry11comment</comments>
      <pubDate>Sun, 15 Mar 2026 14:27:57 +0900</pubDate>
    </item>
    <item>
      <title>SSR에서 자동 로그인이 깨지는 순간</title>
      <link>https://jijiseong.tistory.com/10</link>
      <description>&lt;h1&gt;개요, 쉬워 보이지만 너무 어려운 자동로그인&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동 로그인 구현은 단순해 보이지만, Fullstack Framework(Next.js, SvelteKit 등) 환경으로 넘어오면 이야기가 달라진다. 특히 보안을 위해 RTR(Refresh Token Rotation) 방식을 채택했을 때 발생하는 &lt;b&gt;'병렬 요청에 의한 토큰 무효화'&lt;/b&gt; 문제는 굉장히 까다롭다. Fullstack Framework는 서버 사이드가 있다. 서버사이드에서는 이를 어떻게 관리할 것인지, 보안적인 관점에서도 살펴볼 것이다.&lt;/p&gt;
&lt;h1&gt;RTR 방식의 문제점&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드는 보안을 위해 RTR(Refresh Token Rotation) 방식을 사용하고있다. 프론트에서 병렬 요청이 발생할 때 문제가 발생한다. 단 시간에 여러 refresh 요청이 들어오면 첫 요청만 성공하게 되고, 이후 요청의 refresh token은 만료되었으므로 실패하게된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해 프론트엔드에서는 병렬 요청에서 refresh 하는 로직을 다듬는다. 첫 번째 토큰 갱신 요청이 나가는 동안 나머지 요청들을 Queue에 담아 일시 정지시킨다. CSR 위주의 앱이라면 문제가 되지 않는다. SSR등 서버사이드 로직이 개입했을 때, 문제가 발생한다.&lt;/p&gt;
&lt;h1&gt;Server는 Stateless하다.&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Avoid shared state on the server&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://svelte.dev/docs/kit/state-management#Avoid-shared-state-on-the-server&quot;&gt;Svelte-kit 문서 중 Avoid shared state on the server&lt;/a&gt;의 일부이다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;브라우저는 상태(State)를 가집니다. 사용자가 애플리케이션과 상호작용하는 동안 데이터가 메모리에 저장되기 때문이죠. 반면, 서버는 상태가 없습니다(Stateless). 응답의 내용은 오직 요청의 내용에 의해서만 결정되어야 합니다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;svelte-kit 뿐만 아니라 모든 서버에 대해 해당되는 내용이다. 브라우저는 사용자가 애플리케이션과 상호작용하는 동안 데이터가 메모리에 저장되는 반면 서버는 각각의 request마다 구분된다. 한 번의 request가 끝날 때마다 메모리는 초기화된다고 생각하면 된다.(물론 DB는 아니다)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버 사이드에서 상태를 관리 했을 때, 보안적인 문제점 예시&lt;/h2&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;'use client'

export const failedRequests = useStore([]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 처럼 실패한 요청을 관리하는 Queue를 클라이언트 사이드에 정의한다면, 유저마다(브라우저마다) 자동으로 격리된다. 유저 개개인의 브라우저 메모리 상에 존재하므로 서로 다른 유저가 같은 Queue를 사용하는 경우가 전혀 존재하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같이 Server Side module level에서 singleton으로 관리하였다면, 보안 취약점으로 이어질 수 있다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;// refresh.ts
export const failedRequests = [];&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A, B 유저 동시에 401 응답을 받았을 때 &lt;code&gt;failedRequests&lt;/code&gt;는 서버상의 메모리에 딱 하나 존재하므로 &lt;code&gt;[{A's request}, {B's request}]&lt;/code&gt; queue를 공유하게된다. A의 자동로그인이 완료되었을때 failedRequests를 retry한다면, A는 B의 정보를 조회할 수있다.&lt;/p&gt;
&lt;h1&gt;자동로그인 처리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server는 Stateless 하다는 사실은 곧 isRefreshing, failedRequests 상태 등을 관리할 수 없고, &lt;b&gt;병렬 요청에대해 자동 로그인 처리를 할 방안이 없다&lt;/b&gt;는 것이다.&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;middleware 등에서 주기적으로 token을 갱신한다.&lt;/li&gt;
&lt;li&gt;클라이언트 사이드에서 병렬 요청에 대한 처리는 완벽히 한다.&lt;/li&gt;
&lt;li&gt;서버 사이드에서 병렬 요청에 대한 처리는 포기한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;middleware, proxy 등 에서 'accessToken 만료 10분전'등의 조건을 정해두고, 미리 갱신 한다면 401 요청이 발생하기 전에 로그인을 연장 할 수 있다. 페이지 이동 기준으로만 실행해야한다. 한 페이지에서 여러 요청이 발생할 때, 모두 토큰 갱신 로직을 실행하여 병렬 요청이 발생하는 상황을 없애야한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export function middleware() {
  if (isRouteChanged) {
    // token 연장
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 사이드에서는 전역 상태 등으로 관리하면 된다. 브라우저 마다 격리가되는 부분이라 크게 신경쓸 부분없다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;'use client'

export const refreshManager = useStore({
  isRefreshing: false,
  failedRequests: [],
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 &quot;서버 사이드에서 병렬 요청에 대한 처리는 포기한다.&quot;에서 UX가 저하될 수 있다. 아래에서 더 알아보자.&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;b&gt;Auth Server를 따로 분리하는 것&lt;/b&gt;이라고 생각한다. Redis 등 캐싱 서버를 활용해 병렬 요청이 들어와도 이를 쿠키(token) 값 등으로 구분하여 lock을 걸 수 있다. 하지만 너무 비용적인 문제가 크다. 배보다 배꼽이 커지는 상황 같다. 물론 대규모 서비스라면 도입해야한다고 본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현실적으로 트레이드 오프가 필요하다. 문제는 RTR에서부터 발생한다. 병렬 요청이 발생해도 정상적인 로그인이 가능다하면, 프론트쪽에서 머리아프게 고민 안해도 된다. 백엔드에서 짧은 기간 동안 access/refresh token을 유효한 것으로 인정해주는 방식(Grace Period)을 설정한다면, 쉽게 해결할 수 있다. 물론 보안의 허점은 살짝 올라갈 수 있다. 보안, UX, DX, 인프라 비용 등을 모두 따졌을 때, 작은 서비스에서는 Grace Period 방식이 적합할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grace Period는 1초 ~ 30초 등 유예기간을 설정하는 것이다. 3개의 요청(R1, R2, R3)이 거의 동시에 들어왔을 때, R1이 먼저 토큰을 갱신하더라도 R2와 R3가 들고 있는 '방금 막 만료된 토큰'을 백엔드가 유효한 것으로 처리해 준다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 토큰 갱신 요청 처리 (백엔드)
function refreshTokens(refreshToken) {
  const tokenData = db.find(refreshToken);

  // 1. 이미 사용된 토큰인 경우
  if (tokenData.isUsed) {
    const isWithinGracePeriod = Date.now() - tokenData.usedAt &amp;lt; 10 * 1000; // 10초 내

    if (isWithinGracePeriod) {
      //   병렬 요청으로 판단: 기존에 발급했던 새 토큰을 다시 반환
      return tokenData.lastIssuedTokens;
    }

    //   유예 기간 만료: 진짜 보안 위협으로 간주하고 세션 파기
    throw new SessionRevokedError();
  }

  // 2. 처음 갱신 시도 시
  const newTokens = generateNewTokens();
  db.update(refreshToken, {
    isUsed: true,
    usedAt: Date.now(),
    lastIssuedTokens: newTokens // 유예 기간용 백업
  });

  return newTokens;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;느낀 점&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에 대해 기초적인 관점에서 다시 돌아봤다. 서버는 stateless 하다는 것, 서버의 메모리를 공유하면 취약점이 발생할 수 있다는 것. 자동 로그인에서의 에러는 누구의 책임인가에 대해서도 생각해 보는 시간이었다. 이제야 자동로그인에 대해 제대로 이해가 되는 것 같다. 기회가 된다면 auth server를 구축하는 것도 해보고싶다.&lt;/p&gt;</description>
      <category>Frontend</category>
      <category>401</category>
      <category>retry</category>
      <category>rtr</category>
      <category>로그인</category>
      <category>자동로그인</category>
      <author>jijiseong</author>
      <guid isPermaLink="true">https://jijiseong.tistory.com/10</guid>
      <comments>https://jijiseong.tistory.com/10#entry10comment</comments>
      <pubDate>Wed, 4 Mar 2026 15:22:28 +0900</pubDate>
    </item>
    <item>
      <title>RadixUI의 asChild, BaseUI의 render</title>
      <link>https://jijiseong.tistory.com/9</link>
      <description>&lt;h1&gt;Intro&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 Shadcn은 BaseUI를 도입했다.&lt;br /&gt;BaseUI의 멤버는 Radix, Floating UI, Material UI 의 creator 들이다.&lt;br /&gt;BaseUI는 RadixUI보다 더 복잡한 컴포넌트를 제공한다. (ComboBox, Autocomplete, etc..)&lt;br /&gt;7명의 멤버가 full-time으로 관리하고 있다는 점도 강조한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RadixUI와 BaseUI 사이 가장 눈에 띈 차이점은 컴포넌트를 composition하는 방식이 다르다는 점이었다.&lt;br /&gt;RadixUI는 asChild 패턴을 대중화시켰다.&lt;br /&gt;많은 라이브러리가 이 패턴을 도입하였다.&lt;br /&gt;BaseUI는 비교적 최근 개발된 라이브러리이고 왜 asChild 방식을 채택하지 않았는지 궁금증이 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BaseUI Issue 게시판에 이와 관련된 흥미로운 주제가 올라왔다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/mui/base-ui/issues/3983&quot;&gt;Deprecate render prop and use asChild with children instead #3983&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 위 이슈에서 언급된 여러 이유들을 정리해본다.&lt;/p&gt;
&lt;h1&gt;Radix asChild vs BaseUI render&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 일반적으로 component composition을 할 때, 코드를 비교해보자.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function RadixUIExample() {
  return (
    &amp;lt;Button asChild&amp;gt;
      &amp;lt;a href=&quot;/profile&quot;&amp;gt;Go to profile&amp;lt;/a&amp;gt;
    &amp;lt;/Button&amp;gt;
  );
}

function BaseUIExample() {
  return (
    &amp;lt;Button
      render={(props) =&amp;gt; (
        &amp;lt;a {...props} href=&quot;/profile&quot;&amp;gt;
          Go to profile
        &amp;lt;/a&amp;gt;
      )}
    /&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 이 패턴을 봤을 때, 개인적인 생각으론 asChild가 더 심플하다고 느꼈다.&lt;br /&gt;하지만 asChild에 너무 익숙해진 탓이 아닐까도 생각했다.&lt;/p&gt;
&lt;h1&gt;render prop 의 문제점&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이슈에서 제안하는 render 대신 asChild 사용에 대한 주장을 정리해보자.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;type safe 하지 않은 코드&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;No type safety, contradictory state, this is valid tsx code:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;django&quot;&gt;&lt;code&gt;&amp;lt;Badge render={&amp;lt;code&amp;gt;Will this be rendered?&amp;lt;/code&amp;gt;}&amp;gt;
  &amp;lt;div&amp;gt;Or maybe this? Who knows...&amp;lt;/div&amp;gt;
&amp;lt;/Badge&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드만 봐서는 code가 렌더링될 지, div가 렌더링될 지 예측하기 힘들다. 실제로는 code가 렌더링된다. 그냥 우선순위가 그러하다. 학습하지 않은 개발자는 어떤 것이 우선순위를 가질지 모른다. 반면 asChild를 사용한다면 타입 에러를 일으켜 문제를 조기에 찾을 수 있다. children prop이 중복으로 2개 넘겨지면서 ts error를 일으킨다.&lt;/p&gt;
&lt;pre class=&quot;django&quot;&gt;&lt;code&gt;&amp;lt;Badge asChild children={&amp;lt;code&amp;gt;Will this be rendered?&amp;lt;/code&amp;gt;}&amp;gt;
  &amp;lt;div&amp;gt;Or maybe this? Who knows...&amp;lt;/div&amp;gt;
&amp;lt;/Badge&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;asChild 라는 패턴을 학습하지 않은 개발자라면 RadixUI를 사용했더라도 예측하기 힘들거라는 답변이 달렸다. 학습하지않은 상태라면 어떤 패턴이든 예측이 어려울 것 같다. 이는 라이브러리의 러닝 커브에 관한 부분이라 생각하고 어쩔 수 없는 영역인 것 같다. 하지만 type-safe 관점에서는 한 번도 생각해 본적 없다. type-safe 관점에서는 확실히 asChild 패턴이 이점을 가지는 것 같다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가독성의 문제&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;More cluttered jsx, the render prop make jsx shift more to the right with formatting.&lt;/li&gt;
&lt;li&gt;More cognitive load, with asChild prop and children it is more natural read and understand jsx.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;Button asChild&amp;gt;
  &amp;lt;a href=&quot;/profile&quot;&amp;gt;Go to profile&amp;lt;/a&amp;gt;
&amp;lt;/Button&amp;gt;

&amp;lt;Button
  render={(props) =&amp;gt; (
    &amp;lt;a {...props} href=&quot;/profile&quot;&amp;gt;
      Go to profile
    &amp;lt;/a&amp;gt;
  )}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 복잡한 jsx, 더 복잡한 formating을 만든다. 따라서 jsx를 이해하는데 더 복잡하다. 한마디로 가독성이 떨어진다고 주장한다.&lt;br /&gt;나도 처음 render 방식의 코드를 보고 든 생각이 이 부분이다. 들여쓰기가 많아지고 가독성에서 차이가 보인다.&lt;/p&gt;
&lt;h1&gt;BaseUI는 왜 render prop 방식을 택했을까?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Base UI는 Radix의 asChild 패턴 대신, render prop 기반의 API를 선택했다.&lt;br /&gt;이는 단순한 취향 문제가 아니라 DX, 성능, API 일관성을 종합적으로 고려한 결정이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;더 간결한 jsx&lt;/h2&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;// render 
&amp;lt;Menu.Item render={&amp;lt;MyMenuItem /&amp;gt;} /&amp;gt;


// asChild
&amp;lt;Menu.Item asChild&amp;gt;
  &amp;lt;MyMenuItem /&amp;gt;
&amp;lt;/Menu.Item&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;불필요한 줄 수를 줄이고, 트리 구조를 단순하게 나타낸다고 주장한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;render 함수는 더 명확하고 빠르고 단순하다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;asChild는 boolean인 반면 render를 사용하면 의도를 더 명확히 전달 할 수 있다.&lt;br /&gt;또, asChild는 내부적으로 React.cloneElement에 의존하는 경우가 많다.&lt;br /&gt;legaycy API에 의존하면 성능적으로 느려질 수 있다고 주장한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개발자의 실수를 줄인다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;asChild는 깜빡하는 경우가 있다. 이는 nested button 등의 문제로 이어질 수 있다.&lt;br /&gt;render prop을 통해 children을 일관성 있게 처리한다면 이러한 실수를 막고 DX를 높인다고 주장한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;느낀점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Issue 작성자가 주장하는 내용, Base UI 팀에서 render prop 방식을 선택한 이유 모두 타당하다고 생각한다.&lt;br /&gt;특히 Issue에서 제안한 type-safe 관점의 문제 제기는 개인적으로 한 번도 깊게 고민해보지 않았던 지점이라 인상 깊었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또, BaseUI에서 언급한 cloneElement의 사용 지양에 관한 내용도 인상 깊었다.&lt;br /&gt;이번 이슈와 Base UI의 설계를 살펴보면서, 컴포넌트 API를 설계할 때 단순한 사용성이나 표현력뿐만 아니라 타입 안정성, 일관성, 그리고 실수하기 어려운 구조를 만드는 것이 얼마나 중요한지 다시 한 번 생각하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사소한 DX 관련 내용이지만, 사소한 것들이 모여서 라이브러리의 위상이 높아지는 것 같다.&lt;/p&gt;</description>
      <category>React</category>
      <category>base</category>
      <category>RADIx</category>
      <category>UI</category>
      <author>jijiseong</author>
      <guid isPermaLink="true">https://jijiseong.tistory.com/9</guid>
      <comments>https://jijiseong.tistory.com/9#entry9comment</comments>
      <pubDate>Sat, 7 Feb 2026 02:14:19 +0900</pubDate>
    </item>
    <item>
      <title>useEffect 남용을 줄이자.</title>
      <link>https://jijiseong.tistory.com/7</link>
      <description>&lt;h1&gt;useEffect 남용의 문제&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React는 useEffect를 남용하게 쉽게 만들어뒀다는 생각이 들었다.&lt;/p&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;react의 상태에 반응해서 실행시키고싶다.&lt;/li&gt;
&lt;li&gt;서로다른 내부 상태를 동기화 하고싶다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 잘 못 되었는지, 왜 사용하면 안 되는지 이해해보자.&lt;/p&gt;
&lt;h1&gt;  useEffect란?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 문제를 해결하기 위해서는 근본적으로 useEffect가 무엇인지 알아야한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ko.react.dev/reference/react/useEffect&quot;&gt;React useEffect&lt;/a&gt;&lt;br /&gt;useEffect는 외부 시스템과 컴포넌트를 동기화하는 React Hook입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서에 따르면 &lt;b&gt;외부 시스템&lt;/b&gt;과 연동하는 용도라고 첫 줄에 나와있다.&lt;br /&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;DOM 제어&lt;/li&gt;
&lt;li&gt;전역 이벤트 리스너 등록/해제&lt;/li&gt;
&lt;li&gt;서드파티 라이브러리와 연동&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 DOM 제어는 한 번 더 생각해야할 요소이다.&lt;br /&gt;가능하면 react 상태를 통해 관리하는게 좋다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ko.react.dev/learn/manipulating-the-dom-with-refs#accessing-another-components-dom-nodes&quot;&gt;React useRef&lt;/a&gt;&lt;br /&gt;Ref는 일종의 탈출구입니다. 다른 컴포넌트의 DOM 노드를 수동으로 조작하면 코드를 불안정하게 만들 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;useEffect 잘못 된 사용과 문제점&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;내부 state를 서로 동기화하기 위한 useEffect&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;
function Example() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  useEffect(() =&amp;gt; {
   setCount2(count);
  }, [count]);

  return &amp;lt;button onclick={() =&amp;gt; setCount1(cur =&amp;gt; cur+1)}/&amp;gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;count1&lt;/code&gt;과 &lt;code&gt;count2&lt;/code&gt;는 같은 값을 가지도록 useEffect를 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제점 1. 불필요한 리렌더링이 일어난다.&lt;br /&gt;&lt;code&gt;count&lt;/code&gt;가 업데이트 -&amp;gt; 리렌더링 -&amp;gt; &lt;code&gt;count2&lt;/code&gt; 업데이트 -&amp;gt; 리렌더링&lt;br /&gt;리액트의 배치처리를 활용 못하게되어 리렌더링이 2회로 늘어난다.&lt;br /&gt;지금은 간단한 예시지만, 이러한 패턴이 여러 개 쌓이게된다면 디버깅이 매우 어려워지고, 복잡한 로직이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제점 2. 항상 같은 값이 아닐 수도있다.&lt;br /&gt;count1가 업데이트 된 이후 count2가 업데이트되는 순서가 생기기때문에, 항상 같은 값이 아닐 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;
function Example() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  const setCount = (value: number) =&amp;gt; {
    setCount1(value);
    setCount2(value);
  }

  return &amp;lt;button onclick={() =&amp;gt; setCount(count1 + 1)}/&amp;gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;
function Example() {
  const [count1, setCount1] = useState(0);
  const count2 = count1
  return &amp;lt;button onclick={() =&amp;gt; setCount1(cur =&amp;gt; cur+1)}/&amp;gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 말고도 다양한 방법이 있을 것이다.&lt;br /&gt;배치처리를 사용하는 방법, 상태 선언을 아예 지우는 방법 등 상황에 맞게 사용하자.&lt;br /&gt;첫번 째 예시의 경우 React가 자동으로 배치처리하기 때문에 &lt;code&gt;count1&lt;/code&gt;, &lt;code&gt;count2&lt;/code&gt;상태는 동시에 업데이트된다.&lt;br /&gt;따라서 리렌더링이 1회만 발생한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마운트 시 1회만 실행하기&lt;/h2&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;
function Example() {

  useEffect(() =&amp;gt; {
    fetch('http://example.com');
  }, []);

  return &amp;lt;&amp;gt;&amp;lt;/&amp;gt;
}&lt;/code&gt;&lt;/pre&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;function Example() {
  const [page, setPage] = useState(0);

  useEffect(() =&amp;gt; {
    fetch(`http://example.com?page=${page}`);
  }, [page]);

  return &amp;lt;button onclick={setCount}/&amp;gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1회만 실행 시키고싶다고해서 page를 의존성 배열에서 지우는것 보다, 리액트 라이프사이클에서 벗어나도록 컴포넌트 외부에서 let 변수를 선언하는것이 올바르다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;
let isFetched = false;

function Example() {
  const [page, setPage] = useState(0);


  // ✅
  useEffect(() =&amp;gt; {
    if(!isFetched){
      fetch(`http://example.com?page=${page}`).then(() =&amp;gt; isFetched = true);
    }
  }, [page]);

  // ❌
  // useEffect(() =&amp;gt; {
  //   fetch(`http://example.com?page=${page}`);
  // }, []);

  return &amp;lt;&amp;gt;&amp;lt;/&amp;gt;
}&lt;/code&gt;&lt;/pre&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;code&gt;react-hooks/exhaustive-deps&lt;/code&gt; 룰을 위반 했다는 경고가 발생할 것이다.&lt;br /&gt;최신 상태가 아닌 오래된 상태 값을 사용하기 때문에 발생하는 경고이다.&lt;br /&gt;이를 해결하기 위해 강제로 무시하도록 주석을 달게 될 것이다.&lt;br /&gt;지금 당장 동작한다고해도 다른 개발자들이 본다면 lint룰을 지키기위해 리팩토링할거다.&lt;br /&gt;툭 건들면 터지게 되는 코드가 될 것이다.&lt;/p&gt;
&lt;h1&gt;이외에도&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이외에도 많은 상황에서 useEffect를 제거할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ko.react.dev/learn/you-might-not-need-an-effect&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ko.react.dev/learn/you-might-not-need-an-effect&lt;/a&gt;&lt;/p&gt;</description>
      <category>React</category>
      <category>react</category>
      <category>useEffect</category>
      <author>jijiseong</author>
      <guid isPermaLink="true">https://jijiseong.tistory.com/7</guid>
      <comments>https://jijiseong.tistory.com/7#entry7comment</comments>
      <pubDate>Sun, 2 Mar 2025 13:04:42 +0900</pubDate>
    </item>
    <item>
      <title>선언적으로 에러처리 아주 깔끔하게 하기 (Next.js 15)</title>
      <link>https://jijiseong.tistory.com/6</link>
      <description>&lt;h1&gt;  Intro&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;class CustomError extends Error {  
  public code: string;  

  constructor({ message, code }: { message: string; code: string }) {  
    super(message);  
    this.code = code;  
  }  
}  

export default APIError;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;error.tsx 는 왜 Error를 상속한 CustomError을 받을 수 없는걸까?&lt;/p&gt;
&lt;p&gt;error.tsx 에다가 백엔드에서 정의한 code 값을 넘겨서, 그 값에 따라 정교하게 처리하고싶은데...&lt;/p&gt;
&lt;p&gt;에러 처리는 위임하여 역할을 분리하는게 좋다했는데...&lt;/p&gt;
&lt;h1&gt;  error.tsx&lt;/h1&gt;
&lt;p&gt;먼저, &lt;a href=&quot;https://nextjs.org/docs/app/api-reference/file-conventions/error&quot;&gt;공식문서&lt;/a&gt;를 통해 error.tsx에 대해 &lt;strong&gt;정확히&lt;/strong&gt; 알아보자.&lt;br&gt;error.tsx는 React Error Boundary를 사용하여 page 컴포넌트를 래핑한다.&lt;br&gt;자식 컴포넌트에서 throw 된 Error들을 받아 처리할 수 있다.&lt;/p&gt;
&lt;h1&gt;  props&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;error&lt;/code&gt;, &lt;code&gt;reset&lt;/code&gt; 을 받는다.&lt;br&gt;공식문서를 살펴보면, 정확히 &lt;code&gt;An instance of an Error object&lt;/code&gt;라고 명시되어있다.&lt;br&gt;Error를 상속한 객체를 throw 를 해도 그 속의 Error만 받는다.&lt;/p&gt;
&lt;h1&gt;  Error Handling&lt;/h1&gt;
&lt;p&gt;Next.js 15 버전으로 업데이트 되면서 &lt;code&gt;expected errors and uncaught exceptions&lt;/code&gt; 두 가지로 구분한다고 더 강조하고있다.&lt;br&gt;(14버전 문서에서도 &lt;code&gt;handle unexpected runtime errors&lt;/code&gt; 라는 문장이 있긴 하지만, 더욱 강조 되어있다.)&lt;br&gt;나는 이 때까지 error.js에서 예상한 에러, 예상치 못한 에러 모두를 처리해야하는 것으로 착각했다.&lt;br&gt;공식문서를 꼼꼼히 읽자 ㅠㅠ.&lt;/p&gt;
&lt;p&gt;Next.js 15, React 19 버전 기준으로 아래 처럼 관리하라고한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;expected error는 return value로 관리하라&lt;br&gt;react 19 버전의 API &lt;code&gt;useFormState&lt;/code&gt;는 error를 return value로 관리하도록 디자인되어있다. (try-catch 를 통한 throw Error는 지양!)&lt;/li&gt;
&lt;li&gt;unexpected error는 throw하여 error.js 에서 처리하라&lt;br&gt;error.jsx 또는 global-error.jsx 를 사용해 예상치 못한 에러를 처리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;에러를 return value로 관리하는 것, try-catch를 지양하는 것에대해 의문을 품었다.&lt;br&gt;React는 선언적 프로그래밍을 지향한다.&lt;br&gt;에러를 return value로 관리하면 선언형 패턴으로 관리할 수 있다.&lt;/p&gt;
&lt;p&gt;반면 try-catch를 사용하면 명령형 패턴으로 관리하게된다.&lt;br&gt;React가 지향하는 바와 엇갈리게된다.&lt;br&gt;(개인적으로 try-catch의 들여쓰기가 너무 못생겼다고 생각한다.)&lt;/p&gt;
&lt;h1&gt; ‍  examples&lt;/h1&gt;
&lt;p&gt;react 19 버전의 API &lt;code&gt;useFormState&lt;/code&gt;를 사용할때 예제이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;#39;use server&amp;#39;

import { redirect } from &amp;#39;next/navigation&amp;#39;

export async function createUser(prevState: any, formData: FormData) {
  const res = await fetch(&amp;#39;https://...&amp;#39;)
  const json = await res.json()

  if (!res.ok) {
    return { message: &amp;#39;Please enter a valid email&amp;#39;, code: 101 }
  }

  redirect(&amp;#39;/dashboard&amp;#39;)
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그러면 아래처럼 사용할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function Form() {
  const { data } = useFormStatus();
  data.message // will be updated to &amp;quot;Please enter a valid email&amp;quot; after running the action.
  return (
    &amp;lt;form action={createUser}&amp;gt;
      &amp;lt;Submit /&amp;gt;
    &amp;lt;/form&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;tanstack-qeury를 사용할때 예제이다.&lt;br&gt;React 19 버전이 아니더라도, tanstack-query 라이브러리를 사용한다면 아래처럼 처리할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function createUser(prevState: any, formData: FormData) {
  const res = await fetch(&amp;#39;https://...&amp;#39;)
  const json = await res.json()

  const EXPECTED_ERROR_CODES = [101, 102, ...]
  if (EXPECTED_ERROR_CODES.includes(json.code)) {
    throw new CustomError({message: json.message, code: json.code})
  }
  if (!res.ok){
    throw new Error(&amp;quot;Unexpected Error!: Can not create user.&amp;quot;)
  }

  //...
}

function useCreateUser() {
  return(
    useMutation({
      mutationFn: createUser
      onError: (error) =&amp;gt; { 
          if(!(error instanceof CustomError)) return;
        // 여기서 error.code 에 따라 예상가능한 에러처리
      }
      // unexpect error 라면 throw
      throwOnError: (error) =&amp;gt; !(error instanceof CustomError)
    })
  )
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;예상치 못한 에러는 Error 객체로, 예상한 에러는 code를 포함해 CustomError 객체로 throw 하는 방식이다.&lt;/p&gt;
&lt;h1&gt;후기&lt;/h1&gt;
&lt;p&gt;필요한 API만 찾아 선택적으로 읽는 것이 습관이 들었다.&lt;br&gt;요즘 공식문서를 꼼꼼하게 읽어봐야겠다고 생각이 많이든다.&lt;/p&gt;</description>
      <category>React</category>
      <category>error</category>
      <category>next.js 15</category>
      <category>react 19</category>
      <author>jijiseong</author>
      <guid isPermaLink="true">https://jijiseong.tistory.com/6</guid>
      <comments>https://jijiseong.tistory.com/6#entry6comment</comments>
      <pubDate>Tue, 29 Oct 2024 21:27:10 +0900</pubDate>
    </item>
    <item>
      <title>웹뷰에서 소셜 로그인시 문제 해결하기</title>
      <link>https://jijiseong.tistory.com/5</link>
      <description>&lt;h1&gt;  Intro&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹뷰에서 소셜로그인을 구현하는게 이렇게 어려울줄이야!&lt;br /&gt;웹뷰 환경에서 소셜로그인에 대한 레퍼런스도 적어서 힘들었다.&lt;/p&gt;
&lt;h1&gt; ️ Environment&lt;/h1&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;web: next.js&lt;/li&gt;
&lt;li&gt;app: react-natvie, expo&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RN 앱에서 웹뷰 브라우저를 사용해 web을 띄운 애플리케이션이다.&lt;/p&gt;
&lt;h1&gt;  문제&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개발 환경 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 소셜로그인의 &lt;code&gt;redirect_url&lt;/code&gt;은 localhost, 배포된 도메인을 등록해두었다.&lt;br /&gt;실제 모바일 기기에서는 localhost로 접속할 수 없고 내부 IP(192.0.140.1 등)로 접속해야한다.&lt;br /&gt;그러면 &lt;code&gt;redirect_url&lt;/code&gt; missmatch 에러가 뜰 것이다. 내부 IP가 등록되어 있지 않으니까!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자 개개인의 내부 IP를 등록하는 것은 말이 안된다.&lt;br /&gt;내부 IP는 자동으로 언제 바뀔지 모른다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Kakao Login&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kakao Login을 사용할 때는, kakaotalk 애플리케이션과 연동이 안되는 문제가 있었다.&lt;br /&gt;모바일 애플리케이션에서 카카오톡과 시작하기 버튼이 나타나지 않았다.&lt;br /&gt;ID, PW를 일일이 쳐야한다면 UX가 너무너무너무 나빠진다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;390&quot; data-origin-height=&quot;643&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/L8BJK/btsJDVOekWR/NTT6RMQNJNpbK1I3dH1rM1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/L8BJK/btsJDVOekWR/NTT6RMQNJNpbK1I3dH1rM1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/L8BJK/btsJDVOekWR/NTT6RMQNJNpbK1I3dH1rM1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FL8BJK%2FbtsJDVOekWR%2FNTT6RMQNJNpbK1I3dH1rM1%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;390&quot; height=&quot;643&quot; data-origin-width=&quot;390&quot; data-origin-height=&quot;643&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Google Login&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;403 error: disallowed_useragent&lt;/code&gt;&lt;br /&gt;Google 에서는 webview 브라우저 자체를 차단한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;380&quot; data-origin-height=&quot;484&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/daYaCi/btsJDs6PCTX/q3cle5QyqTVgk7rtx8x0OK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/daYaCi/btsJDs6PCTX/q3cle5QyqTVgk7rtx8x0OK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/daYaCi/btsJDs6PCTX/q3cle5QyqTVgk7rtx8x0OK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdaYaCi%2FbtsJDs6PCTX%2Fq3cle5QyqTVgk7rtx8x0OK%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;380&quot; height=&quot;484&quot; data-origin-width=&quot;380&quot; data-origin-height=&quot;484&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 블로그들은 userAgent를 강제로 바꾸어 우회하는 방법을 소개했다.&lt;br /&gt;하지만 userAgent를 바꿔버리면 다양한 문제가 발생할 수 있다.&lt;br /&gt;어떤 OS를 사용하는지, 어떤 기기인지 식별할 수 없게된다.&lt;br /&gt;이러한 정보들로 최적화된 콘텐츠를 제공할텐데, 그럴 수 없게된다.&lt;br /&gt;절대 바꾸지말자&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;  해결&lt;/h1&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;가상 기기에서는 localhost 서버로 접근이 가능하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Google, Kakao Login&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 브라우저를 사용하면 Google, Kakao 문제 모두 해결된다.&lt;br /&gt;웹뷰가 아닌 외부 브라우저를 띄우게 된다면 카카오톡과 연동하기 버튼이 나타나고,&lt;br /&gt;chrome, safari 등의 브라우저는 Google에서 당연히 차단하지 않는다.&lt;/p&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;2208&quot; data-origin-height=&quot;1148&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/m8qgX/btsJDWTLGPI/4P0EZTVFh2TG6huPFF43V0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/m8qgX/btsJDWTLGPI/4P0EZTVFh2TG6huPFF43V0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/m8qgX/btsJDWTLGPI/4P0EZTVFh2TG6huPFF43V0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fm8qgX%2FbtsJDWTLGPI%2F4P0EZTVFh2TG6huPFF43V0%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;2208&quot; height=&quot;1148&quot; data-origin-width=&quot;2208&quot; data-origin-height=&quot;1148&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부브라우저에서 소셜로그인 완료시 받은 code 또는 token을 웹뷰로 넘겨받아야한다.&lt;/p&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;외부 브라우저 &amp;rarr; 앱&lt;/li&gt;
&lt;li&gt;앱 &amp;rarr; 웹뷰&lt;/li&gt;
&lt;/ul&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;br /&gt;같은 브라우저 끼리는 &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Window/message_event&quot;&gt;message event&lt;/a&gt; 통신이 가능하지만, 다른 브라우저라면 불가능하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;외부 브라우저 &amp;rarr; 앱&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.expo.dev/guides/linking/#linking-to-your-app&quot;&gt;deep link&lt;/a&gt;를 사용하면 통신할 수 있다.&lt;br /&gt;공식 문서에 자세하게 설명 되어있으니 간단하게만 짚고가자.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// app.config.js or app.json
{
  &quot;expo&quot;: {
    &quot;scheme&quot;: &quot;myapp&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 설정해두면, &lt;code&gt;myapp://&lt;/code&gt; 으로 redirect 시키면 앱으로 이동한다.&lt;br /&gt;query parameter를 사용해 소셜로그인 후 받은 코드 또는 토큰을 앱으로 전송할 수 있다.&lt;br /&gt;&lt;code&gt;myapp://?code=code&lt;/code&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;앱 &amp;rarr; 웹뷰&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;message event로 통신이 가능하다.&lt;br /&gt;이것도 아래 블로그에 잘 설명해두었다.&lt;br /&gt;&lt;a href=&quot;https://velog.io/@imzzuu/React-Native-%EC%9B%B9%EB%B7%B0-%ED%86%B5%EC%8B%A0%ED%95%98%EA%B8%B0&quot;&gt;RN-웹뷰-통신하기&lt;/a&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;pre class=&quot;less&quot;&gt;&lt;code&gt;// app
webviewRef.current.postMessage(&quot;메시지입니다.&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹에서 이렇게 받을 수 있다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// web
window.addEventListner(&quot;message&quot;, handler);&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;  느낀점&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아주 간단한 Oauth라고 생각했지만, Webview에서 예상치 못한 문제를 여러번 만났다.&lt;br /&gt;처음엔 'webview에서 Oauth 자체가 불가능한가?' 생각도 들었지만, 하나 하나 해결할 때마다 쾌감이  &lt;br /&gt;-끝-&lt;/p&gt;</description>
      <category>React</category>
      <author>jijiseong</author>
      <guid isPermaLink="true">https://jijiseong.tistory.com/5</guid>
      <comments>https://jijiseong.tistory.com/5#entry5comment</comments>
      <pubDate>Sun, 15 Sep 2024 23:19:13 +0900</pubDate>
    </item>
    <item>
      <title>optimistic update를 사용하면서 고민했던 상황</title>
      <link>https://jijiseong.tistory.com/4</link>
      <description>&lt;h1&gt;  Intro&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 프로젝트에는 찜 기능이있다.&lt;br /&gt;찜하기 기능 수행 후, react-query의 &lt;code&gt;queryClient.invalidateQueries&lt;/code&gt;를 사용해 페이지를 재요청하는 로직이 있다.&lt;br /&gt;찜 버튼 상태를 업데이트 하기 위해서이다.&lt;br /&gt;하지만, 백엔드에서, 겨우 찜하기 하나 한다고 페이지 전체를 재요청하는건 비효율적이고, 서버에 부하가 올 수 있다고 피드백을 받았다.&lt;/p&gt;
&lt;h1&gt;  기존 로직의 문제점&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3182&quot; data-origin-height=&quot;2132&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPdeZA/btsHSvcGJpM/3yNX1BLhzppEODdtoglbO0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPdeZA/btsHSvcGJpM/3yNX1BLhzppEODdtoglbO0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPdeZA/btsHSvcGJpM/3yNX1BLhzppEODdtoglbO0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPdeZA%2FbtsHSvcGJpM%2F3yNX1BLhzppEODdtoglbO0%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;3182&quot; height=&quot;2132&quot; data-origin-width=&quot;3182&quot; data-origin-height=&quot;2132&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;[
    {
      id: 1,
      name: 사과,
      price: 5000,
      isWished: false  // -&amp;gt; 찜하기 수행후 true 로 업데이트 예정
    },
      {
      id: 2,
      name: 사과,
      price: 5000,
      isWished: false
    },
      ...
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 페이지에 여섯 개의 상품이 있다고 가정하자.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;가장 왼쪽 위 상품(id: 1)을 찜한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id: 1&lt;/code&gt; 인 상품의 isWished를 업데이트하기 위해서 react-query의 cache를 invalidate 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찜한 상품과 상관없는 나머지 상품들도 다시 요청 받아오는 비효율적인 로직이 생긴다.&lt;br /&gt;백엔드에 부하 역시 유발할 수 있다.&lt;/p&gt;
&lt;h1&gt;  해결 과정&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시도 1. 프론트엔드의 상태로만 찜 버튼 색을 제어하자&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드에서 받은 isWished 값은 useState의 초기값에만 사용하고,&lt;br /&gt;이후에는 클라이언트단의 isWished 상태를 사용한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function Product() {
    const [isWished, setIsWished] = useState(백엔드.isWished);

    return (
        // ...
        &amp;lt;HeartButton active={isWished} /&amp;gt;
        // ...
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제점이있다.&lt;br /&gt;react-query 상태에 저장된 값은 업데이트 되지않는다&lt;br /&gt;다른 페이지에서 이 데이터를 사용하게 된다면, 유저에게 stale한 데이터를 제공하게 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시도 2. react-query의 상태를 수정하자.&lt;/h2&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;1208&quot; data-origin-height=&quot;894&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUKAlQ/btsHTFL2vzE/zBX4MJXSP3ajJZ2BgRFkDK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUKAlQ/btsHTFL2vzE/zBX4MJXSP3ajJZ2BgRFkDK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUKAlQ/btsHTFL2vzE/zBX4MJXSP3ajJZ2BgRFkDK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUKAlQ%2FbtsHTFL2vzE%2FzBX4MJXSP3ajJZ2BgRFkDK%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;1208&quot; height=&quot;894&quot; data-origin-width=&quot;1208&quot; data-origin-height=&quot;894&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀원분들의 도움으로 setQueriesData를 사용하면 어느정도 해결이 될 것 같은 생각이 들었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;고민 1. 하지만 결국엔 에러상황도 고려하여야 하므로 invalidate를 해야하지 않나?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찜하기 요청 수행 후 요청이 에러가 난다면 이전 상태로 되돌려야한다는 생각이 강했다.&lt;br /&gt;유저에게 무조건 올바른, 최신의 데이터를 보여줘야한다고 생각했기때문이다.&lt;br /&gt;하지만 팀원분들의 '에러의 경우는 고려하지 않아도 되지않나'라는 식의 의견을 듣고 생각이 바뀌었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 상황의 경우 토스트 팝업만 띄워주고, 찜 버튼의 색은 크게 신경 쓰지 않아도 될 것 같았다.&lt;br /&gt;에러 상황을 토스트 팝업으로 인지 시키는 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코드로 구현해보자.&lt;/h2&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;useMutateion({
    mutateFn,
    onSuccess,
    onError,
    onMutate: () =&amp;gt; {
      queryClient.setQueriesData(
        { queryKey: storeQueryKey.lists() },
        (oldData) =&amp;gt;
          updateInfiniteQueryCache(oldData, { key: 'boardId', value: productId }, { isWished: true })
      );    
    }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;queryClient.setQueriesData&lt;/code&gt;의 첫번째 인자에는 &lt;a href=&quot;https://tanstack.com/query/latest/docs/framework/react/guides/filters&quot;&gt;QueryFilter&lt;/a&gt;를 받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두번째 인자로 update function을 받는다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import { Cursor } from '@/shared/types/response';
import { InfiniteData } from '@tanstack/react-query';


export const updateInfiniteQueryCache = &amp;lt;T&amp;gt;(
  oldData: InfiniteData&amp;lt;Cursor&amp;lt;T[]&amp;gt;&amp;gt; | undefined,
  target: { value: number; key: keyof T },
  newItem: Partial&amp;lt;T&amp;gt;
) =&amp;gt; {
  if (!oldData) return oldData;

  const newData = { ...oldData };
  newData.pages = newData.pages?.map((page) =&amp;gt; {
    const itemIndex = page.content.findIndex((item) =&amp;gt; item[target.key] === target.value);
    if (itemIndex !== -1) {
      const updatedPage = { ...page };
      updatedPage.content = [...page.content];
      updatedPage.content[itemIndex] = {
        ...updatedPage.content[itemIndex],
        ...newItem
      };
      return updatedPage;
    }
    return page;
  });

  return newData;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InfiniteQuery를 사용한 데이터를 수정할 수 있는 함수를 구현했다.&lt;br /&gt;모든 페이지를 순환하면서 target.key, target.value를 기준으로 타겟 아이템의 인덱스를 찾는 로직이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인터페이스 부터 살펴보자.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;export const updateInfiniteQueryCache = &amp;lt;T&amp;gt;(
  oldData: InfiniteData&amp;lt;Cursor&amp;lt;T[]&amp;gt;&amp;gt; | undefined,
  target: { value: number; key: keyof T },
  newItem: T
) =&amp;gt; {
	//...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;T 는 InfiniteQueryData의 한 페이지의 한 요소의 인터페이스이다. 예를 들면 상품 오브젝트의 인터페이스가 될 수 있다.&lt;br /&gt;&lt;code&gt;target.value&lt;/code&gt;는 id 값이 들어와야하므로 number&lt;br /&gt;&lt;code&gt;target.key&lt;/code&gt;는 상품 오브젝트의 key값을 뜻한다.&lt;br /&gt;&lt;code&gt;target&lt;/code&gt; 값으로 여러 페이지를 순환하면서 특정 요소를 찾아낸다.&lt;br /&gt;newItem은 수정한 T 오브젝트이다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;  const newData = { ...oldData };
  newData.pages = newData.pages?.map((page) =&amp;gt; {&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값을 수정하기위해 복사하고, 모든 페이지를 순환한다.&lt;/p&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;const itemIndex = page.content.findIndex((item) =&amp;gt; item[target.key] === target.value);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;page에서, target 값을 기반으로 item의 index를 찾아낸다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;    if (itemIndex !== -1) {
      const updatedPage = { ...page };
      updatedPage.content = [...page.content];
      updatedPage.content[itemIndex] = {
        ...updatedPage.content[itemIndex],
        ...newItem
      };
      return updatedPage;
    }
    return page;
  });

  return newData;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;itemIndex를 찾았다면 새로운 값으로 업데이트하여 반환한다.&lt;/p&gt;</description>
      <category>React</category>
      <category>react-query</category>
      <category>setquriesdata</category>
      <category>좋아요</category>
      <category>찜하기</category>
      <author>jijiseong</author>
      <guid isPermaLink="true">https://jijiseong.tistory.com/4</guid>
      <comments>https://jijiseong.tistory.com/4#entry4comment</comments>
      <pubDate>Sun, 9 Jun 2024 04:32:59 +0900</pubDate>
    </item>
    <item>
      <title>Dropdown, 합성 컴포넌트 패턴으로 구현하기</title>
      <link>https://jijiseong.tistory.com/2</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ezgif-4-91d5061193.gif&quot; data-origin-width=&quot;152&quot; data-origin-height=&quot;404&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0B7Zb/btsHIvc3Eb7/BDgtVIcaGKPRt1CjQvsms1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0B7Zb/btsHIvc3Eb7/BDgtVIcaGKPRt1CjQvsms1/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0B7Zb/btsHIvc3Eb7/BDgtVIcaGKPRt1CjQvsms1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/0B7Zb/btsHIvc3Eb7/BDgtVIcaGKPRt1CjQvsms1/img.gif&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;152&quot; height=&quot;404&quot; data-filename=&quot;ezgif-4-91d5061193.gif&quot; data-origin-width=&quot;152&quot; data-origin-height=&quot;404&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;  Intro&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 Shadcn, NextUI, MUI 등등 UI 라이브러리를 많이 사용해 보고 있다.&lt;br /&gt;사용하다보니, 확실히 내가 직접 구현한 컴포넌트보다 사용성이 좋다고 느껴졌다.&lt;br /&gt;라이브러리들의 사용성과 비슷하게 구현해보고 싶었다.&lt;/p&gt;
&lt;h1&gt;  라이브러리를 살펴보자&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Shadcn - Dropdown&lt;/h2&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;DropdownMenu&amp;gt;
  &amp;lt;DropdownMenuTrigger&amp;gt;Open&amp;lt;/DropdownMenuTrigger&amp;gt;
  &amp;lt;DropdownMenuContent&amp;gt;
    &amp;lt;DropdownMenuLabel&amp;gt;My Account&amp;lt;/DropdownMenuLabel&amp;gt;
    &amp;lt;DropdownMenuSeparator /&amp;gt;
    &amp;lt;DropdownMenuItem&amp;gt;Profile&amp;lt;/DropdownMenuItem&amp;gt;
    &amp;lt;DropdownMenuItem&amp;gt;Billing&amp;lt;/DropdownMenuItem&amp;gt;
    &amp;lt;DropdownMenuItem&amp;gt;Team&amp;lt;/DropdownMenuItem&amp;gt;
    &amp;lt;DropdownMenuItem&amp;gt;Subscription&amp;lt;/DropdownMenuItem&amp;gt;
  &amp;lt;/DropdownMenuContent&amp;gt;
&amp;lt;/DropdownMenu&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;Dropdown&lt;/li&gt;
&lt;li&gt;DropdownMenuTrigger&lt;/li&gt;
&lt;li&gt;DropdownMenuLabel&lt;/li&gt;
&lt;li&gt;DropdwonMenuItem&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네 가지의 컴포넌트로 구성 되어 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NextUI - Dropdown&lt;/h2&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;Dropdown&amp;gt;
  &amp;lt;DropdownTrigger&amp;gt;
    &amp;lt;Button variant=&quot;bordered&quot;&amp;gt;
      Open Menu
    &amp;lt;/Button&amp;gt;
  &amp;lt;/DropdownTrigger&amp;gt;
  &amp;lt;DropdownMenu aria-label=&quot;Static Actions&quot;&amp;gt;
    &amp;lt;DropdownItem key=&quot;new&quot;&amp;gt;New file&amp;lt;/DropdownItem&amp;gt;
    &amp;lt;DropdownItem key=&quot;copy&quot;&amp;gt;Copy link&amp;lt;/DropdownItem&amp;gt;
    &amp;lt;DropdownItem key=&quot;edit&quot;&amp;gt;Edit file&amp;lt;/DropdownItem&amp;gt;
    &amp;lt;DropdownItem key=&quot;delete&quot; className=&quot;text-danger&quot; color=&quot;danger&quot;&amp;gt;
      Delete file
    &amp;lt;/DropdownItem&amp;gt;
  &amp;lt;/DropdownMenu&amp;gt;
&amp;lt;/Dropdown&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;Dropdown&lt;/li&gt;
&lt;li&gt;DropdownTrigger&lt;/li&gt;
&lt;li&gt;DropdownMenu&lt;/li&gt;
&lt;li&gt;DropdwonItem&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;공통점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메뉴를 open할 수 있는 Trigger, open하면 보이는 Menu로 구성되어있다.&lt;br /&gt;또한 커스텀이 쉽다.&lt;/p&gt;
&lt;h1&gt; ️ 설계를 해보자.&lt;/h1&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;Dropdown&amp;gt;
    &amp;lt;Dropdown.Trigger&amp;gt;
        트리거
    &amp;lt;/Dropdown.Trigger&amp;gt;
    &amp;lt;Dropdown.Content&amp;gt;
        &amp;lt;Dropdown.Item&amp;gt;메뉴1&amp;lt;/Dropdown.Item&amp;gt;
        &amp;lt;Dropdown.Item&amp;gt;메뉴2&amp;lt;/Dropdown.Item&amp;gt;
        &amp;lt;Dropdown.Item&amp;gt;메뉴3&amp;lt;/Dropdown.Item&amp;gt;
    &amp;lt;/Dropdown.Content&amp;gt;
&amp;lt;/Dropdown&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;합성컴포넌트를 사용하는 방향으로 설계를 했다.&lt;br /&gt;Dropdown만 import를 하더라도 Dropdown 전체를 구성할 수 있도록 하기위해서다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;&amp;lt;Dropdown/&amp;gt;&lt;/code&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&amp;lt;Dropdown/&amp;gt;&lt;/code&gt; 컴포넌트의 역할은 Trigger, Content 컴포넌트의 위치를 잡아주는 역할이다.&lt;br /&gt;또한, open 상태를 가지며 이 상태에 따라 Content를 숨길 수 있어야한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;interface&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스타일 변경이 자유롭도록 className을 받는다.&lt;br /&gt;children은 당연히 받아야한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;interface Props {
  children?: ReactNode;
  className?: string;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;&amp;lt;Dropdown.Trigger/&amp;gt;&lt;/code&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&amp;lt;Dropdown.Trigger/&amp;gt;&lt;/code&gt;의 역할은 open 상태를 변경할 수 있어야한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;interface&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;trigger를 눌렀을 때, 특정 동작을 할 수 있도록 onclick을 추가로 받는다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;interface Props {
  children?: ReactNode;
  onClick?: () =&amp;gt; void;
  className?: string;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;&amp;lt;Dropdonw.Content/&amp;gt;&lt;/code&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&amp;lt;Dropdown.Item /&amp;gt;&lt;/code&gt;을 식별하여 렌더링해야한다.&lt;br /&gt;다른 컴포넌트가 children으로 들어온다면, 무시한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;interface&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;interface Props {
  children?: ReactNode;
  className?: string;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;&amp;lt;Dropdown.Item /&amp;gt;&lt;/code&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&amp;lt;Dropdown.Item /&amp;gt;&lt;/code&gt;을 클릭했을 때, 특정 동작을 수행할 수 있어야한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;interface&lt;/h3&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;interface Props {
  children?: ReactNode;
  onClick?: MouseEventHandler&amp;lt;HTMLButtonElement&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;  특정 컴포넌트를 식별하는 방법&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&amp;lt;Dropdown /&amp;gt;&lt;/code&gt; 에서는 Trigger, Content 컴포넌트를 식별,&lt;br /&gt;&lt;code&gt;&amp;lt;Dropdonw.Content /&amp;gt;&lt;/code&gt; 에서는 Item 컴포넌트를 식별할 수 있어야한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;interface Props {
  children: ReactNode;
  target: JSX.Element;
}

const getComponentFromChildren = ({ children, target }: Props) =&amp;gt; {
  const childrenArray = Children.toArray(children);
  const targetComponent = childrenArray.filter(
    (child) =&amp;gt; isValidElement(child) &amp;amp;&amp;amp; child.type === target.type
  );

  return targetComponent;
};&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;children을 배열로 변환&lt;/li&gt;
&lt;li&gt;JSX.Element의 type을 비교하여 filter한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수를 사용해 특정 컴포넌트를 찾아보자.&lt;/p&gt;
&lt;h1&gt;  구현하기&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;&amp;lt;Dropdown /&amp;gt;&lt;/code&gt;&lt;/h2&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;
const Dropdown = ({ children, className }: Props) =&amp;gt; {
  const { isActive: isOpen, toggle, setIsActive } = useToggle();
  const triggerRef = useRef&amp;lt;HTMLDivElement&amp;gt;(null);
  const dropdownTrigger = getComponentFromChildren({ children, target: &amp;lt;DropdownTrigger /&amp;gt; });
  const dropdownContent = getComponentFromChildren({ children, target: &amp;lt;DropdownContent /&amp;gt; });

  return (
    &amp;lt;div className={twMerge('relative inline-block', className)}&amp;gt;
      &amp;lt;div aria-hidden=&quot;true&quot; role=&quot;button&quot; ref={triggerRef} className=&quot;w-full&quot; onClick={toggle}&amp;gt;
        {dropdownTrigger}
      &amp;lt;/div&amp;gt;
      {isOpen &amp;amp;&amp;amp; &amp;lt;div className=&quot;absolute left-1/2 -translate-x-1/2&quot;&amp;gt;{dropdownContent}&amp;lt;/div&amp;gt;}
    &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;getComponentFromChildren&lt;/code&gt; 함수를 사용해 Trigger, Content를 식별하여 원하는 위치에 렌더링한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;dropdownTrigger&lt;/code&gt;를 감싸는 div에는 onClick으로 isOpen 상태를 제어할 수 있도록한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;dropdonwContent&lt;/code&gt;는 isOpen이 true일 때만 렌더링한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;aria-hidden 속성을 준 이유?&lt;/b&gt;&lt;br /&gt;&lt;b&gt;스크린리더가 접근하는 것은 원치 않지만, 시각적으로 디자인을 주기 위해서 보여지게 하고 싶은경우에 사용한다.&lt;/b&gt;&lt;br /&gt;div에 onClick을 부여하면, 사용자는 키보드로 제어할 수 없게된다.&lt;br /&gt;그래서 button을 써야하지만, dropdownTrigger가 button 컴포넌트이기 때문에, button 컴포넌트가 중첩된다면&lt;br /&gt;키보드 tab으로 제어했을때, 감싸는 button -&amp;gt; dropdwonTrigger 순으로 접근된다.&lt;br /&gt;aria-hidden 속성을 true로 준다면,스크린 리더로 접근할 수 없게된다.&lt;br /&gt;감싸는 button을 접근하지 못하게 하더라도 이벤트 버블링이 발생하기 때문에, 괜찮다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;  useEffect(() =&amp;gt; {
    const handleWindowClick = (e: MouseEvent) =&amp;gt; {
      const isInsideClick =
        triggerRef.current &amp;amp;&amp;amp; e.target instanceof Element &amp;amp;&amp;amp; triggerRef.current.contains(e.target);
      if (!isInsideClick) {
        setIsActive(false);
      }
    };

    window.addEventListener('click', handleWindowClick);
    return () =&amp;gt; window.removeEventListener('click', handleWindowClick);
  }, [setIsActive]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dropdown 컴포넌트 바깥을 클릭했을 때, 메뉴가 닫히게 하기위해 window에 click 이벤트를 추가해준다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;&amp;lt;DropdownTrigger /&amp;gt;&lt;/code&gt;&lt;/h2&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const DropdownTrigger = ({ children, onClick, className }: Props) =&amp;gt; (
  &amp;lt;button type=&quot;button&quot; onClick={onClick} className={twMerge('w-full', className)}&amp;gt;
    {children}
  &amp;lt;/button&amp;gt;
);

export default DropdownTrigger;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별거없다.&lt;br /&gt;onClick 받는 button 역할만 하면 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;&amp;lt;DropdownContent/&amp;gt;&lt;/code&gt;&lt;/h2&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const DropdownContent = ({ children, className }: Props) =&amp;gt; {
  const dropdownItem = getComponentFromChildren({ children, target: &amp;lt;DropdownItem /&amp;gt; });

  return (
    &amp;lt;div
      className={twMerge(
        'flex flex-col items-center justify-center divide-y divide-gray-100 min-w-[72px] rounded-[10px] overflow-hidden shadow',
        className
      )}
    &amp;gt;
      {dropdownItem}
    &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;getComponentFromChildren&lt;/code&gt;함수로 children에서 Item을 식별하여 렌더링한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;&amp;lt;DropdownItem /&amp;gt;&lt;/code&gt;&lt;/h2&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const DropdownItem = ({ children, onClick }: Props) =&amp;gt; (
  &amp;lt;button
    className=&quot;hover:bg-redGray-50 bg-white w-full text-center  p-[10px] h-full typo-body-12-regular&quot;
    type=&quot;button&quot;
    onClick={onClick}
  &amp;gt;
    {children}
  &amp;lt;/button&amp;gt;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얘도 별거없다.&lt;br /&gt;button이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;합성 컴포넌트로 접근할 수 있게하자.&lt;/h2&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;// Dropdown.tsx

export default Object.assign(Dropdown, {
  Trigger: DropdownTrigger,
  Content: DropdownContent,
  Item: DropdownItem
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Object.assign 함수 사용하면 끝!&lt;/p&gt;
&lt;h1&gt;  후기&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브러리를 참고하며 구현해보니, 코드 수준이 올라간것 같다!&lt;br /&gt;다른 컴포넌트들도 하나씩 구현해봐야겠다!&lt;/p&gt;</description>
      <category>React</category>
      <category>Dropdown</category>
      <category>MUI</category>
      <category>next ui</category>
      <category>react</category>
      <category>shadcn</category>
      <category>UI</category>
      <author>jijiseong</author>
      <guid isPermaLink="true">https://jijiseong.tistory.com/2</guid>
      <comments>https://jijiseong.tistory.com/2#entry2comment</comments>
      <pubDate>Mon, 3 Jun 2024 12:05:49 +0900</pubDate>
    </item>
    <item>
      <title>반응형으로 말줄임 CSS + 더보기 버튼 구현하기</title>
      <link>https://jijiseong.tistory.com/1</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1218&quot; data-origin-height=&quot;156&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yyLnA/btsHIZEE2Vl/Queb7RJishKkrwgllV9SKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yyLnA/btsHIZEE2Vl/Queb7RJishKkrwgllV9SKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yyLnA/btsHIZEE2Vl/Queb7RJishKkrwgllV9SKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyyLnA%2FbtsHIZEE2Vl%2FQueb7RJishKkrwgllV9SKK%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;1218&quot; height=&quot;156&quot; data-origin-width=&quot;1218&quot; data-origin-height=&quot;156&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;  Intro&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디자인 요구사항을 모두 만족하면서 구현하기가 생각보다 까다로웠다.&lt;br /&gt;3분이면 될줄 알았지만 며칠동안.. 구현했다.ㅠㅠ&lt;/p&gt;
&lt;h1&gt;  디자인 요구사항&lt;/h1&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;3줄이 넘어가면 말줄임 처리&lt;/li&gt;
&lt;li&gt;더보기 버튼이 오른쪽 아래에 위치&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요구사항도 정말 간단하다.&lt;/p&gt;
&lt;h1&gt;  3줄 이상일 경우 말줄임 처리&lt;/h1&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;&amp;lt;div className=&quot;line-clamp-3&quot;&amp;gt;
  &amp;lt;p ref={commentRef}&amp;gt;
    {comment}
  &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;끝. ㅋㅋ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;tailwind를 사용하기 때문에 line-clamp-3 클래스로 간단하게 구현가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;css를 자세히 들여다보면 아래와 같다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;.line-clamp-3 {
    overflow: hidden;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 3;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;  3줄이 넘어가는지 어떻게 판단할까?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3줄이 넘어가는 경우만 더보기 버튼을 노출시켜야하기 때문에,&lt;br /&gt;3줄이 넘어가는지 js로 알 수 있어야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;속임수가 필요하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;말줄임을 한 상태의 높이를 알아내기&lt;/li&gt;
&lt;li&gt;말줄임을 하지 않은 원본 상태의 높이를 알아내기&lt;/li&gt;
&lt;li&gt;두 높이를 비교해 높이가 다르다면 3줄이 넘어간다고 판단!&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 말줄임을 한 상태의 높이를 알아내기&lt;/h2&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;&amp;lt;div className=&quot;line-clamp-3&quot;&amp;gt;
  &amp;lt;p ref={commentRef}&amp;gt;
    {comment}
  &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;useRef를 사용해 &lt;code&gt;commentRef.current.clientHeight&lt;/code&gt;로 알아내면된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 말줄임을 하지 않은 원본 상태의 높이를 알아내기&lt;/h2&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;&amp;lt;&amp;gt;
  &amp;lt;div className=&quot;line-clamp-3&quot;&amp;gt;
    &amp;lt;p ref={commentRef}&amp;gt;
      {comment}
    &amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div className=&quot;overflow-hidden h-0&quot;&amp;gt;
    &amp;lt;p ref={originalCommentRef}&amp;gt;{comment}&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;comment&lt;/code&gt;를 감싸는 div태그에 h-0, overflow-hidden class를 부여했다.&lt;br /&gt;이러면 &lt;code&gt;originalCommentRef&lt;/code&gt;는 말줄임 처리되지 않은 상태의 높이를 가지며,&lt;br /&gt;화면에는 보이지 않게 될것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 두 높이를 비교하고 더보기 버튼 노출&lt;/h2&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const [isEllipsed, setIsEllipsed] = useState(false);
const commentRef = useRef&amp;lt;HTMLParagraphElement&amp;gt;(null);
const originalCommentRef = useRef&amp;lt;HTMLParagraphElement&amp;gt;(null);

useEffect(() =&amp;gt; {
  if (!originalCommentRef.current || !commentRef.current) return;
  const { clientHeight: originalHeight } = originalCommentRef.current;
  const { clientHeight: commentHeight } = commentRef.current;
  setIsEllipsed(originalHeight !== commentHeight);
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;말줄임 여부를 &lt;code&gt;isEllipsed&lt;/code&gt; 상태로 저장한다.&lt;br /&gt;&lt;code&gt;isEllipsed&lt;/code&gt;가 true 일때만 노출시키면 되겠다!&lt;/p&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;&amp;lt;&amp;gt;
  &amp;lt;div className=&quot;line-clamp-3&quot;&amp;gt;
    &amp;lt;p ref={commentRef}&amp;gt;
      {comment}
    &amp;lt;/p&amp;gt;
    {isEllipsed &amp;amp;&amp;amp; 
      &amp;lt;button&amp;gt;더보기&amp;lt;/button&amp;gt;
    }
  &amp;lt;/div&amp;gt;
  &amp;lt;div className=&quot;overflow-hidden h-0&quot;&amp;gt;
    &amp;lt;p ref={originalCommentRef}&amp;gt;{comment}&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이렇게&lt;code&gt;&amp;lt;p/&amp;gt;&lt;/code&gt;태그 밑에 렌더링한다면 line-clamp-3에 의해서 숨김처림 된다.&lt;/p&gt;
&lt;h1&gt;  어떻게 더보기 버튼을 말줄임 표시 바로 뒤에 배치할까?&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;float&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;css &lt;code&gt;float&lt;/code&gt; 속성을 활용해야한다!&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;&amp;gt;
  &amp;lt;div className=&quot;line-clamp-3&quot;&amp;gt;
    {isEllipsed &amp;amp;&amp;amp; 
      &amp;lt;button className=&quot;float-right&quot;&amp;gt;더보기&amp;lt;/button&amp;gt;
    }
    &amp;lt;p ref={commentRef}&amp;gt;
      {comment}
    &amp;lt;/p&amp;gt;

  &amp;lt;/div&amp;gt;
  &amp;lt;div className=&quot;overflow-hidden h-0&quot;&amp;gt;
    &amp;lt;p ref={originalCommentRef}&amp;gt;{comment}&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;comment&lt;/code&gt;보다 앞쪽에 렌더링 시키고, float-right 속성을 부여해주면 comment의 오른쪽에 배치된다!&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/jijiseong/post/ac80025e-5d07-4ceb-b85a-c3164aea18f8/image.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;margin을 준 뒤 아래쪽으로 내려보자&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;button className=&quot;float-right mt-[42px]&quot;&amp;gt;더보기&amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;42px을 준 이유는 comment의 line-height가 21px이기 때문이다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/jijiseong/post/92ee2872-9855-42f4-9f1d-dbdc37776d76/image.png&quot; alt=&quot;&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;code&gt;shape-outside:border-box&lt;/code&gt;로 해결해 주면 끝&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;button className=&quot;float-right mt-[42px] [shape-outside:border-box]&quot;&amp;gt;더보기&amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/jijiseong/post/9fa31bcb-40b5-4cfe-afb0-54ba04c4623d/image.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;  반응형 기능도 추가해보자&lt;/h1&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;  useEffect(() =&amp;gt; {
    const handleMoreButton = () =&amp;gt; {
      if (!originalCommentRef.current || !commentRef.current) return;
      const { clientHeight: originalHeight } = originalCommentRef.current;
      const { clientHeight: commentHeight } = commentRef.current;
      setIsEllipsed(originalHeight !== commentHeight);
    };

    handleMoreButton();
    window.addEventListener('resize', handleMoreButton);
    return () =&amp;gt; window.addEventListener('resize', handleMoreButton);
  }, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;handleMoreButton 함수로 만들어주고, resize 이벤트를 추가해주면 끝이다!&lt;/p&gt;</description>
      <category>React</category>
      <category>CSS</category>
      <category>react</category>
      <category>Tailwind</category>
      <category>말줄임</category>
      <author>jijiseong</author>
      <guid isPermaLink="true">https://jijiseong.tistory.com/1</guid>
      <comments>https://jijiseong.tistory.com/1#entry1comment</comments>
      <pubDate>Fri, 31 May 2024 03:29:11 +0900</pubDate>
    </item>
  </channel>
</rss>