<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>eui-jin.log</title>
        <link>https://velog.io/</link>
        <description>안녕하세요. 프론트엔드 개발 공부를 하고 있습니다.</description>
        <lastBuildDate>Sat, 03 May 2025 07:41:15 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. eui-jin.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/eui-jin" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[에러 처리 개선 과정]]></title>
            <link>https://velog.io/@eui-jin/%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@eui-jin/%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Sat, 03 May 2025 07:41:15 GMT</pubDate>
            <description><![CDATA[<h2 id="에러-처리에-대한-문제점-인식">에러 처리에 대한 문제점 인식</h2>
<p>최근 포트폴리오 기반의 면접을 진행하던 중 에러 처리 방식의 확장성과 공통화 여부에 대한 질문을 받았습니다.
그에 대한 대답을 하며, 제가 구현한 API 요청 로직에서 개선할 여지가 많다는 점을 인식하게 되었습니다.</p>
<h2 id="기존-api-구조-분석">기존 API 구조 분석</h2>
<p>첫 번째로 <strong>현재 구조는 다소 분산</strong>되어 있었습니다. 
이전에 리펙토링했던 대로 각각의 역할을 가진 4개의 API 함수로 구성되어 있습니다.</p>
<p><code>GET</code> 요청을 담당하는 함수는 다음과 같이 작성되었습니다.</p>
<pre><code class="language-ts">export async function GET&lt;T&gt;(url: string): Promise&lt;T&gt; {
  try {
    const response = await axiosInstance.get(url);
    return response.data;
  } catch (error) {
    throw new Error(error instanceof Error ? error.message : String(error));
  }
}</code></pre>
<p><code>POST</code> 요청을 담당하는 함수는 다음과 같이 작성되었습니다.</p>
<pre><code class="language-ts">export async function POST&lt;T, U&gt;(url: string, data?: U): Promise&lt;T&gt; {
  try {
    const response = await axiosInstance.post(url, data);
    return response.data;
  } catch (error) {
    throw new Error(error instanceof Error ? error.message : String(error));
  }
}</code></pre>
<p>각 메서드는 네트워크 요청, 응답 데이터 반환, 에러 처리 등 거의 동일한 흐름을 갖고 있었으며 이로 인해 같은 패턴의 코드가 여러 파일에 중복되어 유지보수가 어렵고 에러 처리 기준을 일관되게 적용하기 힘들었습니다.</p>
<br />

<p>두 번째로 <strong>에러 처리 방식이 너무 단순</strong>해서 개선할 필요성을 느꼈습니다.</p>
<p>기존에는 에러의 상태나 종류를 구분하지 않고 발생한 에러를 그대로 다시 던지는 방식이었습니다.</p>
<p>그렇게 되면 <code>GET</code>요청처럼 <code>useQuery</code>를 사용하는 경우 UI 상에서 에러 메시지를 처리하거나 사용자에게 알리는 로직이 빠져있었습니다. 특히 <code>Tanstack Query v5</code> 에서는 에러 처리를 <code>QueryClient</code>의 공통 설정으로 이동하는 방식이 권장되기 때문에 기존 구조로는 사용자에게 적절한 피드백이 어려웠습니다.</p>
<br />

<p>이러한 두 가지의 문제를 해결하기 위해 개선하였습니다.</p>
<h2 id="개선-과정">개선 과정</h2>
<h3 id="api-함수-통합">API 함수 통합</h3>
<p>중복을 제거하고 일관된 에러 처리와 확장 가능한 구조를 만들기 위해 모든 <code>HTTP</code> 요청을 처리할 수 있는 공통 <code>request</code>함수로 통합하였습니다.</p>
<pre><code class="language-ts">async function request&lt;T&gt;({
  method,
  url,
  data,
  token,
}: RequestOptionsProps): Promise&lt;T&gt; {
  try {
    if (token) {
      setAuthToken(token);
    }

    const response = await axiosInstance.request&lt;T&gt;({
      method,
      url,
      data,
    });

    return response.data;
  } catch (error) {
    handleHttpError(error);
    throw error;
  }
}</code></pre>
<p>여기서 <code>token</code>은 <code>GET</code> 요청을 서버사이드에서 사용할 때 필요하기 때문에 포함시켰습니다.</p>
<p>위에서 구현한 <code>request</code>를 간편하게 사용하기 위해서 역할별로 객체로 나누어 정의했습니다.</p>
<pre><code class="language-ts">export const API = {
  get: &lt;T&gt;(url: string, token?: string) =&gt;
    request&lt;T&gt;({ method: &#39;GET&#39;, url, token }),
  post: &lt;T, U&gt;(url: string, data?: U) =&gt;
    request&lt;T&gt;({ method: &#39;POST&#39;, url, data }),
  put: &lt;T, U&gt;(url: string, data: U) =&gt; request&lt;T&gt;({ method: &#39;PUT&#39;, url, data }),
  delete: &lt;T&gt;(url: string) =&gt; request&lt;T&gt;({ method: &#39;DELETE&#39;, url }),
};</code></pre>
<p>이로서 기존에 사용하던 API 함수들과 사용방식은 거의 동일하지만 내부적으로는 공통 <code>request</code>함수로 로직이 통합되어 있어 유지보수성과 재사용성이 크게 향상되었습니다.</p>
<h3 id="공통-에러-처리-함수">공통 에러 처리 함수</h3>
<p><code>handleHttpError</code>라는 함수를 만들어 에러가 발생했을 때 공통적으로 에러를 처리할 수 있도록 했습니다.</p>
<pre><code class="language-ts">import { AxiosError } from &#39;axios&#39;;

import { notify } from &#39;@/store/useToastStore&#39;;

export function handleHttpError(error: unknown) {
  if (!(error instanceof AxiosError)) {
    notify(&#39;error&#39;, &#39;에러가 발생했습니다.&#39;, 3000);
    return;
  }

  const status = error.response?.status;
  const message = error.response?.data?.message || &#39;&#39;;

  switch (status) {
    case 401:
      notify(&#39;info&#39;, &#39;로그아웃 되었습니다.&#39;, 3000);
      break;
    case 500:
      notify(&#39;error&#39;, &#39;서버에서 오류가 발생하였습니다.&#39;, 3000);
      break;
    default:
      notify(&#39;error&#39;, message, 3000);
      break;
  }
}</code></pre>
<p><code>handleHttpError</code> 함수는 에러가 <code>AxsosError</code> 인스턴스인지 확인한 뒤, HTTP 상태 코드에 따라 적절한 사용자 메시지를 표시합니다. </p>
<p>예를 들어 <code>401</code>에러는 인증 만료로 간주해 로그아웃 안내 메시지를 보여주고 <code>500</code>에러는 서버 오류 메시지를 표시합니다. 그 외의 에러는 서버에서 내려준 메시지를 그대로 사용자에게 전달합니다.</p>
<h3 id="queryclient-공통-에러-처리">QueryClient 공통 에러 처리</h3>
<p><code>useQuery</code> 에서 발생한 에러를 처리하기 위해서 <code>QueryClient</code> 객체에서 공통적인 에러 처리를 할 수 있도록 <code>queryCache</code> 부분에 <code>onError</code>를 추가하였습니다.</p>
<pre><code class="language-ts">function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: DEFAULT_STALE_TIME,
        refetchOnWindowFocus: false,
        refetchOnReconnect: false,
        retry: 0,
      },
      dehydrate: {
        shouldDehydrateQuery: (query) =&gt;
          defaultShouldDehydrateQuery(query) ||
          query.state.status === &#39;pending&#39;,
      },
    },
    queryCache: new QueryCache({
      onError: (error: unknown) =&gt; {
        handleHttpError(error);
      },
    }),
  });
}</code></pre>
<p>이렇게 구현하여 잘 통합했다고 생각했지만 예상치 못한 문제가 발생해 추가 수정이 필요했습니다.</p>
<h2 id="에러-처리-중복-문제-발생">에러 처리 중복 문제 발생</h2>
<h3 id="문제-정의">문제 정의</h3>
<p>현재 공통 API에서는 <code>handleHttpError</code>를 통해 에러를 처리하고 있지만, <code>GET</code> 요청의 경우 <code>QueryClient</code>에서 설정한 공통 에러 핸들러(<code>onError</code>)가 한 번 더 실행되면서 에러 처리가 중복되는 문제가 발생했습니다.</p>
<p>또한 <code>useMutation</code>을 사용하는 요청에서는 각 <code>onError</code> 설정에 따라 또 한 번 에러 처리가 이루어지게 되어, 동일한 에러에 대해 여러 번 토스트 메시지가 표시되는 현상이 나타났습니다.</p>
<p>결국은 동일한 에러에 대해 <code>request</code> 내부와 <code>Tanstack Query</code>의 에러 핸들러가 각각 토스트 메시지를 표시하면서 사용자에게 동일한 에러 메시지가 중복 노출되는 문제가 발생했습니다.</p>
<h3 id="해결-방안">해결 방안</h3>
<p>따라서 공통 API 내에서의 에러 처리는 에러를 파싱하는 역할만 하도록 수정했습니다.</p>
<pre><code class="language-ts">  } catch (error) {
    if (!(error instanceof AxiosError)) {
      throw new Error(&#39;오류가 발생했습니다.&#39;);
    }

    const status = error.response?.status;
    const message = error.response?.data.message || error.message;

    throw new Error(JSON.stringify({ status, message }));
  }
</code></pre>
<p>이렇게 수정하면 <code>useQuery</code> 부분은 <code>QueryClient</code> 에서 설정한 공통된 에러 처리로 토스트 메시지를 보여주고 <code>useMutation</code> 부분은 각자의 <code>onError</code> 에서 파싱된 에러 객체를 받아 설정한 토스트 메시지를 보여주게 됩니다.</p>
<h2 id="느낀점">느낀점</h2>
<p>이번 경험을 통해 에러 처리와 공통 로직 수정에 대해서 <strong>부분적</strong>으로만 이해하고 있었다는 것을 알게되었습니다. 실제로 공통화한다고 해서 끝나는 게 아니라 상황에 따라 어떻게 처리할지도 함께 고민해야 한다는 걸 배웠고 기능 분리나 재사용성 외에도 <strong>흐름 제어</strong>와 <strong>사용자 경험</strong>까지 고려한 설계가 필요하다는 걸 체감했습니다.</p>
<p>이번에 적용한 개선 방향이 정말 올바른 방향인지는 잘 모르겠습니다. 실제 서비스 상황에서의 다양한 예외 케이스를 더 많이 경험해봐야 진짜로 견고한 구조라고 말할 수 있을 것 같습니다.</p>
<p>또한 <code>ErrorBoundary</code>를 실제 프로젝트에 적용해본 경험이 없어, 다음 프로젝트에서는 컴포넌트 단위의 예외 상황도 포괄적으로 처리할 수 있도록 도입해볼 것입니다.</p>
<p>앞으로는 사용자 경험을 해치지 않으면서도 예외 상황을 안정적으로 처리할 수 있는 구조와, 도메인의 특성을 고려한 에러 메시지 설계까지 함께 고민해야될 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Tanstack Query와 SSR을 활용한 대시보드 페이지 리팩토링]]></title>
            <link>https://velog.io/@eui-jin/Tanstack-Query%EC%99%80-SSR%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81</link>
            <guid>https://velog.io/@eui-jin/Tanstack-Query%EC%99%80-SSR%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81</guid>
            <pubDate>Sat, 08 Mar 2025 09:17:31 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이전에 만들었던 공통 API 함수를 리펙토링 하였다.</p>
</blockquote>
<h2 id="대시보드-페이지">대시보드 페이지</h2>
<p>현재 대시보드 페이지는 이렇게 생겼다.</p>
<img src="https://velog.velcdn.com/images/eui-jin/post/fdb4aa03-146d-46d4-8adf-daf8014cb402/image.png" width="400px" />

<p>대시보드는 4개의 주요 컴포넌트로 이루어져 있고, 각각의 컴포넌트가 별도의 API 요청을 수행하여 데이터를 가져오도록 되어 있다.</p>
<pre><code class="language-tsx">export default function DashBoardPage() {
  return (
    &lt;&gt;
      &lt;Header title=&quot;대시보드&quot; /&gt;
      &lt;PageContainer&gt;
        &lt;Follower /&gt;
        &lt;RecentTodos /&gt;
        &lt;MyProgress /&gt;
        &lt;GoalList /&gt;
      &lt;/PageContainer&gt;
    &lt;/&gt;
  );
}
</code></pre>
<h3 id="현재-문제점">현재 문제점</h3>
<p>기존에는 <code>isLoading</code>을 활용하여 데이터를 불러오는 동안 로딩 UI (스켈레톤 UI)를 보여주고 있다. 하지만 이 방식에는 두가지 문제가 있다.</p>
<p><strong>1. 클라이언트에서만 로딩 상태를 관리해야 한다.</strong></p>
<ul>
<li>서버에서 렌더링된 화면에는 데이터가 포함되지 않기 때문에, 브라우저가 API 요청을 보내 데이터를 가져올 때까지 빈 화면이 표시된다.</li>
</ul>
<p><strong>2. 코드의 가독성이 저해된다는 점이다.</strong></p>
<ul>
<li><code>isLoading</code>이 컴포넌트 내부 곳곳에서 사용되고 있어, 코드가 한눈에 이해되지 않을 수 있다.</li>
</ul>
<p>이를 해결하기 위해 <code>useSuspenseQuery</code>를 활용한다면 SSR에서 데이터를 미리 패칭하여 첫 화면부터 데이터를 포함할 수 있고, 코드도 더 간결해진다.</p>
<h3 id="최근-등록한-할일-부분-적용">최근 등록한 할일 부분 적용</h3>
<p>이 중에서 <strong>최근 등록한 할일</strong>을 담당하는 <code>RecentTodos</code> 컴포넌트는 다음과 같이 구현되어 있다.</p>
<pre><code class="language-tsx">&#39;use client&#39;;

// ...

export const RecentTodos = () =&gt; {
  const { todos, isLoading } = useRecentTodosQuery();
  const { goals } = useGoalsQuery();

  const hasTodos = todos.length &gt; 0;
  const hasGoals = goals.length &gt; 0;

  return (
    &lt;DashboardItemContainer title=&quot;최근 등록한 할일&quot; className=&quot;relative&quot;&gt;
      {isLoading &amp;&amp; &lt;TodoListSkeleton /&gt;}

      {!isLoading &amp;&amp; !hasGoals &amp;&amp; (
        // ...
      )}

      {!isLoading &amp;&amp; hasGoals &amp;&amp; !hasTodos &amp;&amp; (
        // ...
      )}

      {!isLoading &amp;&amp; hasGoals &amp;&amp; hasTodos &amp;&amp; (
        // ...
      )}
    &lt;/DashboardItemContainer&gt;
  );
};
</code></pre>
<p>다른 컴포넌트에서도 같은 방식으로 로딩 상태를 관리하고 있어, <code>isLoading</code>을 사용하지 않고 Suspense를 활용하는 방식으로 리팩토링하려고 한다.</p>
<p>먼저, 최근 등록한 할일을 불러오는 <code>useRecentTodosQuery</code>는 <code>useQuery</code>를 사용해 API 요청을 보낸다.</p>
<pre><code class="language-ts">// ...

export const recentTodosOptions = (): UseQueryOptions&lt;
  RecentTodosResponse,
  AxiosError
&gt; =&gt; ({
  queryKey: [QUERY_KEYS.RECENT_TODOS],
  queryFn: () =&gt;
    GET&lt;RecentTodosResponse&gt;(
      `${API_ENDPOINTS.TODOS.GET_ALL}?lastTodoId=0&amp;size=3`,
    ),
});

export const useRecentTodosQuery = () =&gt; {
  const { data, ...etc } = useQuery(recentTodosOptions());
  const todos = data?.data.content ?? [];

  return { todos, ...etc };
};
</code></pre>
<p>여기 <code>useQuery</code>를 <code>useSuspenseQuery</code>로만 변경하기만 하면 된다.</p>
<p>하지만 에러가 발생했다.</p>
<blockquote>
<p>Uncaught Error: Switched to client rendering because the server rendering errored: document is not defined</p>
</blockquote>
<p>이 에러는 서버 렌더링 중에서 <code>document</code> 객체를 참조면서, 강제로 클라이언트 렌더링으로 변경되었다는 의미이다.</p>
<p>그 원인은 <code>useSuspenseQuery</code>로 변경하면서 브라우저에서만 접근 가능한 <code>document.cookie</code>를 SSR에서도 호출하려고 했기 때문이다.</p>
<p>현재 프로젝트에서는 <code>axiosInstance</code>를 커스텀하여 사용하고 있었고, 토큰 값을 헤더에 주입하기 위해서 브라우저의 쿠키에서 값을 가져오고 있었다.
이 작업에서 <code>document.cookie</code>를 사용했기 때문에 SSR 환경에서는 오류가 발생한 것이다.</p>
<p>이를 해결하기 위해, 서버에서는 <code>document</code> 객체를 참조할 수 없도록 클라이언트 환경에서만 토큰을 주입할 수 있도록 수정했다.</p>
<pre><code class="language-ts">axiosInstance.interceptors.request.use(
  (config) =&gt; {
    if (typeof window !== &#39;undefined&#39;) {
        // 쿠키 설정 코드
    }

    return config;
  },
  (error) =&gt; {
    return Promise.reject(error);
  },
);</code></pre>
<p>그렇지만 이렇게 해도 여전히 같은 에러가 발생했다.</p>
<p>이제 Tanstack Query의 Hydration API를 활용해서 SSR에서 데이터를 미리 패칭하는 방식을 적용해보려고 한다.</p>
<h3 id="hydration-api-적용">Hydration API 적용</h3>
<p>먼저 이 블로그를 참고해 <code>ServerFetchBoundary</code>라는 컴포넌트를 만들었다.</p>
<p><a href="https://velog.io/@sik02/Next.js%EC%97%90%EC%84%9C-react-query%EB%A5%BC-%EC%99%9C-%EC%8D%A8">https://velog.io/@sik02/Next.js에서-react-query를-왜-써</a></p>
<p>SSR은 적용했지만, 새로고침 했을 때 기대했던 결과가 나오지 않았다.
클라이언트에서 요청했을 때와 동일하게 로딩 UI가 나타나고 SSR이 적용되었음에도 UI가 거의 같은 속도로 렌더링되었다..</p>
<p>이유를 찾아보니, <code>RecentTodos</code> 컴포넌트에 목표(goals)를 받아오는 쿼리도 있었고 이 부분은 여전히 <code>useQuery</code>를 사용하고 있었다. <code>useSuspenseQuery</code>로 변경한 후에 적용이 되었다.</p>
<blockquote>
<p>로딩의 결과는 이렇게 나타난다.</p>
</blockquote>
<p>변경 전</p>
<img src="https://velog.velcdn.com/images/eui-jin/post/743fad9e-89cd-4d54-894d-2c4735132a0b/image.gif" width="370px" />

<p>변경 후</p>
<img src="https://velog.velcdn.com/images/eui-jin/post/bd31842c-0d39-4cc5-b6da-27386fc19473/image.gif" width="370px" />

<h3 id="내-진행-상황-부분-적용">내 진행 상황 부분 적용</h3>
<p><code>MyProgress</code> 컴포넌트에도 SSR을 적용했다.</p>
<p>하지만 이 컴포넌트는 애니메이션이 포함되어 있었다. 애니메이션이 <code>useEffect</code>에서 실행되기 때문에 SSR에서는 애니메이션이 동작하지 않았고, 클라이언트 렌더링과 차이가 없었다.</p>
<p>다시 클라이언트 렌더링 방식으로 되돌렸다.</p>
<h3 id="팔로워-현황-목표-별-할-일-부분-적용">팔로워 현황, 목표 별 할 일 부분 적용</h3>
<p>이 두 컴포넌트는 무한 스크롤을 위한 <code>useInfiniteQuery</code>가 적용되어 있다.</p>
<p><code>useSuspenseInfiniteQuery</code>로 변경하고 위에서 했던 작업과 비슷하게 적용해보았지만, 에러가 발생하였다.</p>
<blockquote>
<p>TypeError: Cannot read properties of undefined (reading &#39;length&#39;)</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/eui-jin/post/4462ba15-18cb-4f57-baac-221839352f67/image.png" alt=""></p>
<pre><code class="language-ts">// ...

export const todosOfGoalsOptions = (
  token?: string,
): UseSuspenseInfiniteQueryOptions&lt;
  TodosOfGoalsResponse,
  AxiosError,
  BaseInfiniteQueryResponse&lt;TodosOfGoalsResponse[]&gt;
&gt; =&gt; ({
  queryKey: [QUERY_KEYS.TODOS_OF_GOALS],
  queryFn: ({ pageParam = 0 }) =&gt;
    GET&lt;TodosOfGoalsResponse&gt;(
      `${API_ENDPOINTS.TODOS.GET_GOALS}?lastGoalId=${pageParam}&amp;size=5`,
      token,
    ),
  getNextPageParam: (lastPage) =&gt; {
    const nextCursor = lastPage.data.nextCursor;
    return nextCursor !== 0 ? nextCursor : undefined;
  },
  initialPageParam: 0,
});

export const useTodosOfGoalsQuery = () =&gt; {
  const { data, ...etc } = useSuspenseInfiniteQuery(todosOfGoalsOptions());
  const goals = data?.pages.flatMap((page) =&gt; page.data.content) ?? [];

  return { goals, ...etc };
};
</code></pre>
<p>에러가 <code>getNextPageParam</code>에서 발생했다. 처음에는 서버에서 받아온 데이터의 타입과 <code>getNextPageParam</code>이 요구하는 타입이 다르다고 생각해, 받아온 데이터의 형태를 수정하고 다시 해보았지만 해결되지 않았다.</p>
<p>그래서 <code>getNextPageParam</code>의 타입을 확인해보았다.</p>
<pre><code class="language-ts">// getNextPageParam
interface InfiniteQueryPageParamsOptions&lt;TQueryFnData = unknown, TPageParam = unknown&gt; extends InitialPageParam&lt;TPageParam&gt; {
    /**
     * This function can be set to automatically get the previous cursor for infinite queries.
     * The result will also be used to determine the value of `hasPreviousPage`.
     */
    getPreviousPageParam?: GetPreviousPageParamFunction&lt;TPageParam, TQueryFnData&gt;;
    /**
     * This function can be set to automatically get the next cursor for infinite queries.
     * The result will also be used to determine the value of `hasNextPage`.
     */
    getNextPageParam: GetNextPageParamFunction&lt;TPageParam, TQueryFnData&gt;;
}

// GetNextPAgeParamFuction
type GetNextPageParamFunction&lt;TPageParam, TQueryFnData = unknown&gt; = (lastPage: TQueryFnData, allPages: Array&lt;TQueryFnData&gt;, lastPageParam: TPageParam, allPageParams: Array&lt;TPageParam&gt;) =&gt; TPageParam | undefined | null;</code></pre>
<p>하지만, 내가 발생한 에러와 공식 타입의 정의가 달라, 문제 해결이 쉽지 않았다.
결국은 클라이언트 렌더링 방식을 유지하기로 했다. 이후에 더 깊이 공부한 후에 다시 적용해볼 예정이다.</p>
<h2 id="결론">결론</h2>
<p>대시보드 페이지에서 최근 등록한 할일 부분만 서버사이드 렌더링(SSR)을 적용하고, 나머지 부분은 클라이언트 렌더링(CSR)을 유지했다.</p>
<p>SSR과 SCR의 차이점인 <strong>초기 로딩 속도</strong>에서 큰 차이를 체감할 수 있었다. 각 기술의 장단점을 비교하며 프로젝트에 어떻게 적용할지에 대한 인사이트를 얻을 수 있었다.</p>
<p>로딩 속도를 개선한 경험은 사용자 경험의 중요성을 다시 한번 깨닫게 해주었고 문제 해결 과정에서 Tanstack Query와 SSR에 대해 더 깊이 이해할 수 있었다.</p>
<p>아직 해결하지 못한 에러를 수정하기 위해 더 많은 학습이 필요하다는 것을 느꼈고 문제를 해결하는 능력을 키우기 위해 지속적으로 공부해야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[코드 중복 제거를 위한 공통 API 함수 설계와 활용]]></title>
            <link>https://velog.io/@eui-jin/%EC%BD%94%EB%93%9C-%EC%A4%91%EB%B3%B5-%EC%A0%9C%EA%B1%B0%EB%A5%BC-%EC%9C%84%ED%95%9C-%EA%B3%B5%ED%86%B5-API-%ED%95%A8%EC%88%98-%EC%84%A4%EA%B3%84%EC%99%80-%ED%99%9C%EC%9A%A9</link>
            <guid>https://velog.io/@eui-jin/%EC%BD%94%EB%93%9C-%EC%A4%91%EB%B3%B5-%EC%A0%9C%EA%B1%B0%EB%A5%BC-%EC%9C%84%ED%95%9C-%EA%B3%B5%ED%86%B5-API-%ED%95%A8%EC%88%98-%EC%84%A4%EA%B3%84%EC%99%80-%ED%99%9C%EC%9A%A9</guid>
            <pubDate>Wed, 22 Jan 2025 06:59:22 GMT</pubDate>
            <description><![CDATA[<p>현재 프로젝트에서 api를 모두 <strong>Axios</strong>와 <strong>Tanstack Query</strong>를 활용해서 호출하고 있다.</p>
<h2 id="과정">과정</h2>
<p>예를 들어 목표를 호출하는 함수를 만드려면 새로운 파일을 만들고 <code>axiosInstance</code>를 활용해 api를 호출하는 로직을 작성하고 있다.</p>
<p><code>apis</code> 폴더 안에 API 호출 로직을 작성한다.</p>
<p><code>/apis/Goals/getGoals.tsx</code></p>
<pre><code class="language-ts">export const getGoals = async () =&gt; {
  try {
    const response = await axiosInstance.get(API_ENDPOINTS.GOAL.GOALS);
    return response.data;
  } catch (error) {
    console.error(&quot;목표 불러오기 에러&quot;,error)
    throw error
  }
}</code></pre>
<p>이후 <code>/hooks/apis/goals/useGoalsQuery.tsx</code>파일에서 <code>useQuery</code>를 사용해 API 호출을 처리하는 커스텀 훅을 만든다.</p>
<pre><code class="language-ts">const goalsOptions: UseQueryOptions&lt;GoalsResponse, AxiosError&gt; = {
  queryKey: [QUERY_KEYS.GOALS],
  queryFn: () =&gt; getGoals(),
};

export const useGoalsQuery = () =&gt; {
  const { data, ...etc } = useQuery(goalsOptions);
  const goals = data?.data ?? [];

  return { goals, ...etc };
};</code></pre>
<p>현재 방식은 API 호출과 관련된 파일 구조가 복잡해지고 하나의 파일안에서 작성하기엔 가독성이 좋지 않아서 방법을 고민해보았다.</p>
<h3 id="개선-방안">개선 방안</h3>
<p>API 통신에서 사용되는 <code>GET</code>, <code>POST</code>, <code>PUT</code>, <code>DELETE</code>를 각각 함수로 만들어 재사용 가능하도록 만들었다.</p>
<p>각각의 응답 및 반환 타입을 제네릭 타입으로 설정하였고 공통된 에러처리 로직을 추가해 코드 중복을 줄이고 가독성을 높였다.</p>
<p><code>/apis/service/httpMethid.ts</code></p>
<pre><code class="language-ts">import axiosInstance from &#39;@/lib/axiosInstance&#39;;

export async function GET&lt;T&gt;(url: string): Promise&lt;T&gt; {
  try {
    const response = await axiosInstance.get(url);
    return response.data;
  } catch (error) {
    throw new Error(error instanceof Error ? error.message : String(error));
  }
}

export async function POST&lt;T, U&gt;(url: string, data?: U): Promise&lt;T&gt; {
  try {
    const response = await axiosInstance.post(url, data);
    return response.data;
  } catch (error) {
    throw new Error(error instanceof Error ? error.message : String(error));
  }
}

export async function PUT&lt;T, U&gt;(url: string, data: U): Promise&lt;T&gt; {
  try {
    const response = await axiosInstance.put(url, data);
    return response.data;
  } catch (error) {
    throw new Error(error instanceof Error ? error.message : String(error));
  }
}

export async function DELETE&lt;T&gt;(url: string): Promise&lt;T&gt; {
  try {
    const response = await axiosInstance.delete(url);
    return response.data;
  } catch (error) {
    throw new Error(error instanceof Error ? error.message : String(error));
  }
}
</code></pre>
<p>기존에는 동일한 로직을 여러 파일에서 반복적으로 작성해야 했던 문제를 개선하고 공통 함수로 중복 코드를 줄일 수 있었다.</p>
<p><code>/hooks/apis/goals/useGoalsQuery.tsx</code></p>
<pre><code class="language-ts">const goalsOptions: UseQueryOptions&lt;GoalsResponse, AxiosError&gt; = {
  queryKey: [QUERY_KEYS.GOALS],
  queryFn: () =&gt; GET&lt;GoalsResponse&gt;(API_ENDPOINTS.GOAL.GOALS),
};

export const useGoalsQuery = () =&gt; {
  const { data, ...etc } = useQuery(goalsOptions);
  const goals = data?.data ?? [];

  return { goals, ...etc };
};
</code></pre>
<h3 id="한계점">한계점</h3>
<p><strong>서버 사이드 렌더링(SSR)에서 API 호출이 불가능하다.</strong></p>
<p>현재 구조에서는 커스텀된 <code>axiosInstance</code>를 사용해 요청을 보낼 때 브라우저 환경에서만 접근할 수 있는 쿠키를 활용해 토큰을 주입하고 있다.
SEO가 중요한 페이지일 경우엔 서버 사이드로 데이터 패칭을 통해서 불러와야할 경우엔 새로운 인스턴스를 만들어서 사용해야하는 한계점이 있다.</p>
<h2 id="결론">결론</h2>
<p>기존 방식은 API 호출 로직과 Tanstack Query hook을 각각 작성해야 했기 때문에 불필요한 중복 코드가 발생했다.</p>
<p>공통 HTTP 메서드 함수를 사용하게 되면서 API 호출 로직의 중복을 제거하고 각 도메인별로 간단하게 hook을 작성할 수 있게되었다.</p>
<p>결과적으로 코드의 양을 줄이고 새로운 API를 추가하는 데 필요한 시간을 단축시킬 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Tanstack Query 무한 스크롤]]></title>
            <link>https://velog.io/@eui-jin/Tanstack-Query-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4</link>
            <guid>https://velog.io/@eui-jin/Tanstack-Query-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4</guid>
            <pubDate>Tue, 31 Dec 2024 08:37:06 GMT</pubDate>
            <description><![CDATA[<h1 id="무한-스크롤이란">무한 스크롤이란?</h1>
<p>콘텐츠를 한 번에 로드하지 않고, 사용자가 페이지를 아래로 스크롤할 때 필요한 만큼만 추가로 로드하는 방식</p>
<h3 id="특징">특징</h3>
<ul>
<li>스크롤 동작만으로 콘텐츠가 자동으로 로드</li>
<li>추가적인 조작 없이 콘텐츠 탐색 가능</li>
<li>데이터 로드 타이밍과 로딩 상태 표시가 중요</li>
</ul>
<h3 id="장점">장점</h3>
<ul>
<li>사용자 경험 향상: 스크롤만으로 새로운 콘텐츠를 볼 수 있어 간편함</li>
<li>탐색 시간 단축: 여러 페이지를 오가며 데이터를 확인할 필요가 없음</li>
<li>모바일 친화적: 작은 화면에서 페이지 버튼 대신 자연스러운 탐색 가능</li>
</ul>
<h1 id="적용">적용</h1>
<h2 id="목표-상세-데이터-불러오기-코드">목표 상세 데이터 불러오기 코드</h2>
<pre><code class="language-js">const GoalsDetailOptions = (): UseInfiniteQueryOptions&lt;
  GoalsDetailResponse,
  AxiosError,
  BaseInfiniteQueryResponse&lt;GoalsDetailResponse[]&gt;
&gt; =&gt; ({
  queryKey: [QUERY_KEYS.ALL_GOALS],
  queryFn: ({ pageParam = 0 }) =&gt;
    GET&lt;GoalsDetailResponse&gt;(
      `${API_ENDPOINTS.GOAL.ALL_GOALS}?lastGoalId=${pageParam}&amp;size=5`,
    ),
  getNextPageParam: (lastPage) =&gt; {
    const nextCursor = lastPage.data.nextCursor;
    return nextCursor !== 0 ? nextCursor : undefined;
  },
  initialPageParam: 0,
});

export const useGoalsDetailQuery = () =&gt; {
  const { data, ...etc } = useInfiniteQuery(GoalsDetailOptions());
  const goals = data?.pages.flatMap((page) =&gt; page.data.content) ?? [];

  return { goals, ...etc };
};
</code></pre>
<h3 id="queryfn">queryFn</h3>
<p><code>lastGoalId</code>값을 포함해 5개의 데이터를 불러온다.
<code>size</code> 값에 따라 몇 개의 목표를 가져올 것인지 설정한다.</p>
<h3 id="getnextpageparam">getNextPageParam</h3>
<p>마지막 데이터의 <code>nextCursor</code> 값을 가지고 <code>pageParam</code> 값을 설정해준다.
<code>nextCurosr</code>의 값이 <code>null</code>이면 <code>pageParam</code>을 <code>undefined</code>로 설정하여 추가 요청을 불러오지 않는다.</p>
<h3 id="initialpageparam">initialPageParam</h3>
<p>초기 <code>pageParam</code>값을 설정한다.</p>
<h2 id="공통-무한-스크롤-custom-hook">공통 무한 스크롤 custom hook</h2>
<pre><code class="language-js">import { useEffect, useRef } from &#39;react&#39;;

interface InfiniteScrollProps {
  fetchNextPage: () =&gt; void;
  isLoading: boolean;
}

export const useInfiniteScroll = ({
  fetchNextPage,
  isLoading,
}: InfiniteScrollProps) =&gt; {
  const observerRef = useRef&lt;HTMLDivElement | null&gt;(null);

  useEffect(() =&gt; {
    if (!observerRef.current) return;

    const observerCallback: IntersectionObserverCallback = (entries) =&gt; {
      const lastEntry = entries[0];
      if (lastEntry.isIntersecting &amp;&amp; !isLoading) {
        fetchNextPage();
      }
    };

    const observer = new IntersectionObserver(observerCallback, {
      threshold: 1.0,
    });

    if (observerRef.current) {
      observer.observe(observerRef.current);
    }

    observer.observe(observerRef.current);

    return () =&gt; observer.disconnect();
  }, [fetchNextPage, isLoading]);

  return {
    observerRef,
  };
};
</code></pre>
<p><code>Intersection Observer</code>를 사용해 무한 스크롤을 구현하였다.</p>
<ul>
<li><code>observerCallBack</code>을 통해 마지막 요소가 뷰포트에 들어왔는지 감지</li>
<li><code>isintersecting</code> 값이 <code>true</code>이고, 데이터 로드 중이 아니라면  <code>fetchNextPage</code>를 호출</li>
</ul>
<br />
<br /><br /><br /><br />

<blockquote>
<h3 id="참고">참고</h3>
<p>올리브영 테크블로그
<a href="https://oliveyoung.tech/2023-10-04/useInfiniteQuery-scroll/">https://oliveyoung.tech/2023-10-04/useInfiniteQuery-scroll/</a>
무한 스크롤 장단점
<a href="https://brunch.co.kr/@joohyup1001/50">https://brunch.co.kr/@joohyup1001/50</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[스켈레톤 로딩 지연]]></title>
            <link>https://velog.io/@eui-jin/%EC%8A%A4%EC%BC%88%EB%A0%88%ED%86%A4-%EB%A1%9C%EB%94%A9-%EC%A7%80%EC%97%B0</link>
            <guid>https://velog.io/@eui-jin/%EC%8A%A4%EC%BC%88%EB%A0%88%ED%86%A4-%EB%A1%9C%EB%94%A9-%EC%A7%80%EC%97%B0</guid>
            <pubDate>Tue, 24 Dec 2024 08:26:46 GMT</pubDate>
            <description><![CDATA[<h2 id="스켈레톤-로딩이란">스켈레톤 로딩이란?</h2>
<p>빈 화면 대신, 어떤 형식으로 콘텐츠가 자리잡을지 구조적으로 미리 보여주는 로딩이다.
사용자에게 로딩을 납득할 수 있는 충분한 정보를 제공한다.</p>
<h3 id="장점">장점</h3>
<p>체감 로딩 시간이 짧다.</p>
<h3 id="스피너-로딩과의-차이점">스피너 로딩과의 차이점</h3>
<ul>
<li>대부분의 스피너 로딩은 짧은 로딩 시간에 사용한다.</li>
<li>동적인 요소로 사용자가 지루함을 느끼지 않게 한다.</li>
<li>체감 로딩 시간이 길다.</li>
</ul>
<h2 id="progress-indicator">Progress Indicator</h2>
<p>UX 리서치 그룹 닐슨 노먼의 지침
<a href="https://www.nngroup.com/articles/progress-indicators/">https://www.nngroup.com/articles/progress-indicators/</a></p>
<blockquote>
<p><strong>주요지침</strong></p>
<p>약 1초 이상 걸리는 작업에는 Progress indicator를 사용
Loop Animation은 빠른 동작에만 사용
Percent-done Animation은 10초 이상 걸리는 작업에 사용
Static Indicator는 사용 X</p>
</blockquote>
<p>이 글에서 진행 상태 표시기의 유형은 두 가지로 나누어진다.</p>
<p><strong>불확정적 로딩(Indeterminate Loading)</strong></p>
<ul>
<li>진행 시간이 예측 불가능할 때 사용.</li>
<li>보통 회전하는 스피너(spinner) 형태.</li>
<li>사용자가 작업이 진행 중이라는 사실만 알 수 있음.</li>
<li>하지만 정확한 완료 예상 시간을 제공하지 않으므로 답답함을 유발할 수도 있음.</li>
</ul>
<p><strong>확정적 로딩(Determinate Loading)</strong></p>
<ul>
<li>예상 완료 시간을 제공할 수 있을 때 사용.</li>
<li>보통 프로그래스 바(progress bar) 형태.</li>
<li>완료 예상 시간을 보여줘 사용자의 기대 관리를 돕고, 신뢰감을 형성.</li>
</ul>
<blockquote>
<p>그중에서 <strong>스켈레톤 로딩</strong>은 불확정적 로딩에 들어간다. 왜냐하면 &quot;현재 데이터를 불러오는 중&quot;이라는 시각적 힌트를 제공하지만, 정확한 진행률을 보여주지는 않기 때문이다.</p>
</blockquote>
<p><strong>UX 최적화를 위해 스켈레톤 로딩을 적용하는 방법</strong></p>
<ul>
<li>실제 콘텐츠와 유사한 형태로 디자인할 것</li>
<li>너무 오래 표시되지 않도록 할 것</li>
<li>중요한 정보는 먼저 로딩할 것</li>
<li>부드러운 페이드 인 효과 적용</li>
</ul>
<p>결론적으로 스켈레톤 로딩을 적용하게 되면 사용자가 콘텐츠의 구조를 빠르게 파악할 수 있고, 스피너보다 기다리는 시간을 짧게 느끼게 할 수 있다.</p>
<h2 id="카카오페이의-스켈레톤-지연-시간">카카오페이의 스켈레톤 지연 시간</h2>
<p>카카오페이의 어떤 서비스의 네트워크 요청 응답 시간 지표에서 평균적으로 <code>110ms</code>의 지연 이후 API 응답을 받고 있고, 75%의 사용자들은 <code>192ms</code> 이내에 응답을 받고 있다.</p>
<p>그렇기 때문에 <code>200ms</code> 정도를 지연시켜 사용자에게 노출한다면 약 75%의 사용자는 덜그럭 거리지 스켈레톤을 보여주지 않을 수 있다.
그렇지만 덜그럭거림을 느끼지 못하던 15%의 사용자는 덜그럭거리는 스켈레톤 뷰를 보게 된다는 단점이 있다.</p>
<p>그럼에도 카카오페이에서는 스켈레톤을 <code>200ms</code>를 지연시켜 사용자 경험을 향상시켰다.</p>
<h2 id="내-프로젝트에-적용">내 프로젝트에 적용</h2>
<p>위의 글을 바탕으로 사용자의 프로필을 불러오는 로딩에 조건부 스켈레톤 로딩을 적용시켰다.
네트워크 텝에서 <code>No throttling</code>일 경우 평균적으로 <code>86ms</code>,<code>Fast 4G</code> 일 경우 <code>186ms</code>정도가 나오기 때문에 로딩 지연을 <code>200ms</code>로 지정해두었다.</p>
<blockquote>
<p>현재 Suspense가 적용되지 않는 문제가 발생해 useQuery의 isLoading을 사용해 적용시켰다. 추후에 이 문제가 해결되면 Suspense 컴포넌트로 변경할 예정이다.</p>
</blockquote>
<pre><code class="language-js">export const Profile = () =&gt; {
  const { email, name, profile, isLoading } = useUserQuery();
  const [showSkeleton, setShowSkeleton] = useState(false);

  useEffect(() =&gt; {
    if (isLoading) {
      const timer = setTimeout(() =&gt; setShowSkeleton(true), 200);
      return () =&gt; clearTimeout(timer);
    }

    setShowSkeleton(false);
  }, [isLoading]);

  return (
    &lt;div className=&quot;flex min-h-53 w-full gap-8 p-16&quot;&gt;
      {showSkeleton ? (
        &lt;&gt;
          &lt;Skeleton className=&quot;size-37 rounded-8&quot; /&gt;
          &lt;div className=&quot;flex flex-col gap-8 py-4&quot;&gt;
            &lt;Skeleton className=&quot;h-12 w-30 rounded-12&quot; /&gt;
            &lt;Skeleton className=&quot;h-10 w-70 rounded-12&quot; /&gt;
          &lt;/div&gt;
        &lt;/&gt;
      ) : (
        &lt;&gt;
          {!isLoading &amp;&amp; (
            &lt;Image
              src={profile}
              alt=&quot;profile picture&quot;
              width={37}
              height={37}
              className=&quot;shrink-0 rounded-8&quot;
              priority
            /&gt;
          )}
          &lt;div className=&quot;flex w-full justify-between&quot;&gt;
            &lt;div&gt;
              &lt;p className=&quot;text-sm-medium&quot;&gt;{name}&lt;/p&gt;
              &lt;p className=&quot;text-xs-medium&quot;&gt;{email}&lt;/p&gt;
            &lt;/div&gt;
            &lt;button className=&quot;text-xs-normal&quot;&gt;로그아웃&lt;/button&gt;
          &lt;/div&gt;
        &lt;/&gt;
      )}
    &lt;/div&gt;
  );
};
</code></pre>
<p><code>useEffect</code> 내부에서 <code>setTimeout</code>을 사용해 스켈레톤 로딩을 <code>200ms</code> 지연시켜서 스켈레톤 로딩을 적용시켰다.</p>
<div style="display: flex; justify-content: space-around;">
  <div>
    <p>No throttling</p>
    <img src="https://velog.velcdn.com/images/eui-jin/post/05ffb454-a1e8-4e0e-b0d3-046b44008ae2/image.gif" width="280px" />
  </div>

  <div>
    <p>Fast 4G</p>
    <img src="https://velog.velcdn.com/images/eui-jin/post/0ad58167-54d4-4187-a459-5bbb7573c80e/image.gif" width="280px" />
  </div>
</div>


<blockquote>
</blockquote>
<p>관련 PR 주소
<a href="https://github.com/slid-todo/front/pull/130">https://github.com/slid-todo/front/pull/130</a></p>
<blockquote>
</blockquote>
<h2 id="결론">결론</h2>
<p>스켈레톤 로딩은 사용자가 콘텐츠의 구조를 미리 파악할 수 있도록 도와주고, 스피너보다 체감 대기 시간을 줄여 UX를 향상시키는 효과적인 방법이다.</p>
<p>그러나 모든 경우에 적합한 것은 아니며, 네트워크 응답 속도와 사용자 경험을 고려해 적절한 지연 시간을 설정하는 것이 중요하다. 카카오페이처럼 로딩 지연을 적용하면 불필요한 스켈레톤 표시를 줄이면서도 원활한 사용자 경험을 제공할 수 있다.</p>
<p>결과적으로 스켈레톤 로딩을 적절하게 활용하면 UX를 개선할 수 있지만, 적용 방식과 시점을 신중하게 고려해야 한다.</p>
<h3 id="참고">참고</h3>
<p><a href="https://medium.com/prnd/%EC%8A%A4%EC%BC%88%EB%A0%88%ED%86%A4-%EB%A1%9C%EB%94%A9-%EC%96%B8%EC%A0%9C-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C-%ED%97%A4%EC%9D%B4%EB%94%9C%EB%9F%AC-ux-%EC%8A%A4%ED%84%B0%EB%94%94-00d2cc323b17">헤이딜러 UX 스터디</a></p>
<p><a href="https://tech.kakaopay.com/post/skeleton-ui-idea/#firebase-performance-monitoring">카카오페이 기술 블로그</a></p>
<p><a href="https://zindex.tistory.com/302">스켈레톤 로딩</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Tanstack Query 낙관적 업데이트]]></title>
            <link>https://velog.io/@eui-jin/Tanstack-Query-%EB%82%99%EA%B4%80%EC%A0%81-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8</link>
            <guid>https://velog.io/@eui-jin/Tanstack-Query-%EB%82%99%EA%B4%80%EC%A0%81-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8</guid>
            <pubDate>Tue, 24 Dec 2024 05:58:42 GMT</pubDate>
            <description><![CDATA[<h2 id="낙관적-업데이트란">낙관적 업데이트란?</h2>
<p>사용자가 데이터 변경 작업을 수행했을 때, 실제 서버 응답을 기다리지 않고 UI를 즉시 업데이트 하는 방법이다.</p>
<ul>
<li>사용자 경험 개선</li>
<li>애플리케이션이 더 빠르고 반응성이 좋아보이게 만듬</li>
</ul>
<h3 id="일반적인-흐름">일반적인 흐름</h3>
<blockquote>
</blockquote>
<ol>
<li>사용자가 데이터를 변경하는 작업 수행</li>
<li>UI에서 변경된 결과를 즉시 반영</li>
<li>서버에 변경 요청을 전송</li>
<li>서버 응답이 성공인 경우, UI 유지</li>
<li>서버 응답이 실패인 경우, 이전 상태로 롤백하거나 에러처리</li>
</ol>
<h2 id="적용">적용</h2>
<h3 id="기존-목표-불러오기-코드">기존 목표 불러오기 코드</h3>
<pre><code class="language-js">export const useSidebarGoalsMutation = () =&gt; {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: postSidebarGoals,
    onSuccess: () =&gt; {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.SIDEBAR_GOALS] });
      queryClient.invalidateQueries({
        queryKey: [QUERY_KEYS.TODOS_OF_GOALS],
      });
    },
    onError: (error) =&gt; {
      console.error(error.message);
      notify(&#39;error&#39;, &#39;목표 등록에 실패했습니다.&#39;, 3000);
    },
  });
};
</code></pre>
<p>기존 코드에 낙관적 업데이트를 적용하기 위해서
onMutate을 작성하고 onSuccess 대신 성공, 실패 여부에 관계 없이 실행하기 위해 onSettled를 작성
그에 맞는 onError 코드도 변경하였다.</p>
<h3 id="낙관적-업데이트-적용한-코드">낙관적 업데이트 적용한 코드</h3>
<pre><code class="language-js">
export const useSidebarGoalsMutation = () =&gt; {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (postData: PostGoalTypes) =&gt;
      POST&lt;GoalsResponse, PostGoalTypes&gt;(API_ENDPOINTS.GOAL.GOALS, postData),
    onMutate: async ({ title }) =&gt; {
      const prev = queryClient.getQueriesData({ queryKey: [QUERY_KEYS.GOALS] });

      await queryClient.cancelQueries({ queryKey: [QUERY_KEYS.GOALS] });
      queryClient.setQueryData([QUERY_KEYS.GOALS], (oldData: GoalsResponse) =&gt; {
        if (!oldData?.data) return oldData;

        return {
          ...oldData,
          data: [
            ...oldData.data,
            {
              goalId: Date.now(),
              goalTitle: title,
              color: &#39;#848484&#39;,
              createAt: new Date().toISOString(),
            },
          ],
        };
      });

      return { prev };
    },
    onSettled: () =&gt; {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GOALS] });
      queryClient.invalidateQueries({
        queryKey: [QUERY_KEYS.TODOS_OF_GOALS],
      });
    },
    onError: (error, newGoal, context) =&gt; {
      queryClient.setQueriesData(
        { queryKey: [QUERY_KEYS.GOALS] },
        context?.prev,
      );
      console.error(error.message);
      notify(&#39;error&#39;, `목표 등록에 실패했습니다.\n ${newGoal.title}`, 3000);
    },
  });
};</code></pre>
<h3 id="onmutate">onMutate</h3>
<p><code>서버에 요청을 보내기 전에 실행한다.</code></p>
<p>해당 쿼리의 기존 데이터를 <code>prev</code> 로 저장
<code>cancelQueries</code>로 목표 관련 쿼리를 중단해 기존 데이터와 서버 응답의 충돌을 방지
기존 값 (<code>oldData</code>) 에 새로운 데이터를 추가해 UI를 즉시 갱신
<code>prev</code>를 리턴해준다.</p>
<h3 id="onsettled">onSettled</h3>
<p><code>서버 요청이 성공하거나 실패한 후에 실행한다.</code></p>
<p>onSettled로 관련된 쿼리 키들을 invalidate하여 최신 데이터를 가져오도록 함</p>
<h3 id="onerror">onError</h3>
<p><code>서버의 응답이 실패하면 실행한다.</code></p>
<p><code>onMutate</code>에서 저장한 <code>prev</code>를 사용해 쿼리를 복구한다.</p>
<ul>
<li>context는 onMutate에서 반환된 값이다.</li>
</ul>
<p>에러 메시지를 통해 사용자에게 알린다.</p>
<h3 id="결과">결과</h3>
<img src="https://velog.velcdn.com/images/eui-jin/post/e43c64d6-a801-460e-b2d4-7cdcb46f6d33/image.gif" width="373px">

<blockquote>
<p>PR 주소: <a href="https://github.com/slid-todo/front/pull/116">https://github.com/slid-todo/front/pull/116</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Zustand 렌더링 최적화]]></title>
            <link>https://velog.io/@eui-jin/Zustand-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@eui-jin/Zustand-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Sun, 15 Dec 2024 08:29:08 GMT</pubDate>
            <description><![CDATA[<h2 id="zustand-의-기본-사용법">Zustand 의 기본 사용법</h2>
<p>zustand는 Store를 만들어 전역변수를 간편하게 사용할 수 있는 라이브러리이다.</p>
<p>zustand를 사용해서 Store에서 상태를 가져오는 방식이 2가지 있다.</p>
<pre><code class="language-js">const { value } = useStore();
const value = useStore((state) =&gt; state.value);</code></pre>
<h2 id="selector-function이란">Selector function이란?</h2>
<p>기본적으로 React 컴포넌트가 상태를 가져오면 상태 변화가 있을 때마다 해당 컴포넌트가 다시 렌더링 된다.
하지만 Zustand는 상태의 특정 부분만 선택적으로 가져올 수 있기 때문에 상태 변화가 특정 부분에서만 영향을 미치도록 제한할 수 있다.</p>
<h3 id="기본적인-zustand-사용">기본적인 zustand 사용</h3>
<pre><code class="language-js">
export const Sidebar = () =&gt; {
  // ...
  const { toggleIsNew } = useNewGoalsStore(); // 전체 상태를 가져온다.

  // ...

    &lt;MenuItem
      icon={&lt;FaFlag className=&quot;size-28 p-4&quot; /&gt;}
      label=&quot;목표&quot;
      addButton={
        &lt;SidebarButton type=&quot;default&quot; onClick={toggleIsNew}&gt;
          새 목표
        &lt;/SidebarButton&gt;
      }
    /&gt;

  //...</code></pre>
<img src="https://velog.velcdn.com/images/eui-jin/post/413b0518-6b92-4936-8246-27dd5040ecfd/image.gif" width="373px">


<h3 id="불필요한-리렌더링-방지-zustand-사용">불필요한 리렌더링 방지 zustand 사용</h3>
<pre><code class="language-js">export const Sidebar = () =&gt; {
  // ...
  const handleToggle = useNewGoalsStore((state) =&gt; state.toggleIsNew); // 특정 상태만 가져온다.

  // ...

    &lt;MenuItem
      icon={&lt;FaFlag className=&quot;size-28 p-4&quot; /&gt;}
      label=&quot;목표&quot;
      addButton={
        &lt;SidebarButton type=&quot;default&quot; onClick={handleToggle}&gt;
          새 목표
        &lt;/SidebarButton&gt;
      }
    /&gt;

  // ...</code></pre>
<img src="https://velog.velcdn.com/images/eui-jin/post/9b8cbe27-5f2c-437c-b6f4-a37fe3dae9bd/image.gif" width="373px">

<br />

<blockquote>
<h3 id="참고">참고</h3>
<p>Zustand 공식문서
<a href="https://zustand-demo.pmnd.rs/">https://zustand-demo.pmnd.rs/</a></p>
<p>selector 관련 zustand 공식 github
<a href="https://github.com/pmndrs/zustand#Recipes">https://github.com/pmndrs/zustand#Recipes</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Promise 병렬처리]]></title>
            <link>https://velog.io/@eui-jin/Promise</link>
            <guid>https://velog.io/@eui-jin/Promise</guid>
            <pubDate>Wed, 11 Dec 2024 03:24:43 GMT</pubDate>
            <description><![CDATA[<h2 id="promise-란">Promise 란?</h2>
<p>프로미스가 생성된 시점에는 알려지지 않았을 수도 있는 값을 위한 대리자로, 비동기 연산이 종료된 이후에 결과 값과 실패 사유를 처리하기 위한 처리기이다.
프로미스를 사용하면 비동기 메서드에서 동기 메서드처럼 값을 반환할 수 있다.</p>
<h3 id="promise의-상태">Promise의 상태</h3>
<ul>
<li>대기(pending): 이행하지도, 거부하지도 않은 초기 상태.</li>
<li>이행(fulfilled): 연산이 성공적으로 완료됨.</li>
<li>거부(rejected): 연산이 실패함.</li>
</ul>
<h2 id="promiseall">Promise.all()</h2>
<p>순회 가능한 객체에 주어진 모든 프로미스가 이행한 후, 혹은 프로미스가 주어지지 않았을 때 이행하는 Promise를 반환한다.</p>
<h3 id="예시">예시</h3>
<p>순회 가능한 객체에 프로미스가 들어가도 되고 프로미스가 들어가도 된다.</p>
<pre><code class="language-js">const p = Promise.all([1, 2, 3]); // 모든 값이 즉시 이행됨
const p2 = Promise.all([1, 2, 3, Promise.resolve(444)]); // 444 포함, 모든 값이 이행됨
const p3 = Promise.all([1, 2, 3, Promise.reject(555)]); // 555로 거부됨

setTimeout(() =&gt; {
  console.log(p);  // fulfilled: [1, 2, 3]
  console.log(p2); // fulfilled: [1, 2, 3, 444]
  console.log(p3); // rejected: 555
});
</code></pre>
<h3 id="거부">거부</h3>
<p>하나라도 거부하면 Promise.all()은 즉시 거부한다.</p>
<pre><code class="language-js">var p1 = new Promise((resolve, reject) =&gt; {
  setTimeout(() =&gt; resolve(&quot;하나&quot;), 1000);
});
var p2 = new Promise((resolve, reject) =&gt; {
  setTimeout(() =&gt; resolve(&quot;둘&quot;), 2000);
});
var p3 = new Promise((resolve, reject) =&gt; {
  reject(new Error(&quot;거부&quot;));
});

Promise.all([p1, p2, p3, p4, p5])
  .then((values) =&gt; {
    console.log(values);
  })
  .catch((error) =&gt; {
    console.log(error.message);
  });

// &quot;거부&quot;
</code></pre>
<h2 id="promiseallsettled">Promise.allSettled()</h2>
<p>주어진 모든 프로미스를 이행하거나 거부한 후에 각 프로미스의 대한 결과를 나타내는 객체 배열을 반환한다.</p>
<h3 id="promiseall-과의-차이">Promise.all() 과의 차이</h3>
<p>Promise.all()을 사용하면 하나라도 거부 당했을 때 즉시 거부되지만
Promise.allSettedl()는 각 프로미스의 실행 결과를 보존하고 모든 프로미스를 실행한다.</p>
<h3 id="예시-1">예시</h3>
<p>각각의 프로미스 결과값을 보존한다.</p>
<pre><code class="language-js">const p1 = Promise.resolve(1)
const p2 = new Promise((resolve, reject) =&gt;
  setTimeout(reject, 100, &#39;a&#39;),
);

Promise.allSetted([p1, p2]).then((results) =&gt; {
    results.forEach((result) =&gt; console.log(result)
})
// &quot;fulfilled&quot;
// &quot;rejected&quot;

</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[React.forwardRef]]></title>
            <link>https://velog.io/@eui-jin/React.forwardRef</link>
            <guid>https://velog.io/@eui-jin/React.forwardRef</guid>
            <pubDate>Tue, 03 Dec 2024 09:28:23 GMT</pubDate>
            <description><![CDATA[<h2 id="forwardref-란">forwardRef 란</h2>
<p>컴포넌트가 ref를 받아 하위 컴포넌트로 전달하도록하려면 forwardRef를 사용해야한다.</p>
<p><strong>예시 코드</strong></p>
<pre><code class="language-jsx">import { forwardRef } from &#39;react&#39;;

const MyInput = forwardRef(function MyInput(props, ref) {
  // ...
});</code></pre>
<h2 id="사용법-input">사용법 (Input)</h2>
<p>ex) <code>input</code>에 <code>focus</code>할 때 사용하는 방법</p>
<p>Edit 버튼을 클릭하면 자동으로 input에 focus된다</p>
<pre><code class="language-jsx">import { useRef } from &#39;react&#39;;
import MyInput from &#39;./MyInput.js&#39;;

export default function Form() {
  const ref = useRef(null);

  function handleClick() {
    ref.current.focus();
  }

  return (
    &lt;form&gt;
      &lt;MyInput label=&quot;Enter your name:&quot; ref={ref} /&gt;
      &lt;button type=&quot;button&quot; onClick={handleClick}&gt;
        Edit
      &lt;/button&gt;
    &lt;/form&gt;
  );
}
</code></pre>
<br />
<br />


<h3 id="주의점">주의점</h3>
<blockquote>
<p><strong>ref를 과도하게 사용하지 마세요.</strong> 노드로 스크롤 하기, 노드에 포커스하기, 애니메이션 트리거하기, 텍스트 선택하기 등 prop로 표현할 수 없는 필수적인 동작에만 ref를 사용해야 합니다.
<strong>prop로 무언가를 표현할 수 있다면 ref를 사용해서는 안 됩니다.</strong> 예를 들어 <code>Modal</code> 컴포넌트에서 <code>{ open, close }</code>와 같은 명령형 핸들을 노출하는 대신 <code>&lt;Modal isOpen={isOpen} /&gt;</code>과 같이 prop <code>isOpen</code>을 사용하는 것이 더 좋습니다. <a href="https://ko.react.dev/learn/synchronizing-with-effects">Effects</a>는 props를 통해 명령형 동작을 노출하는 데 도움이 될 수 있습니다.
<a href="https://ko.react.dev/reference/react/forwardRef#usage">https://ko.react.dev/reference/react/forwardRef#usage</a></p>
</blockquote>
<br />
<br />

<h2 id="커스텀-input">커스텀 Input</h2>
<p>현재 Input 컴포넌트를 만들 때 forwardRef를 사용해서 ref를 받아올 수 있는 Input으로 만들었다.</p>
<pre><code class="language-jsx">import { forwardRef } from &#39;react&#39;;

import { cn } from &#39;@/utils/className&#39;;

export type InputProps = React.InputHTMLAttributes&lt;HTMLInputElement&gt;;

export const Input = forwardRef&lt;HTMLInputElement, InputProps&gt;(
  ({ className, type = &#39;text&#39;, ...props }, ref) =&gt; {
    const inputClass = cn(
      &#39;w-full px-24 py-12 outline-none bg-white rounded-12 text-sm-normal md:text-base-normal&#39;,
      className,
    );

    return &lt;input ref={ref} type={type} className={inputClass} {...props} /&gt;;
  },
);

Input.displayName = &#39;Input&#39;;
</code></pre>
<p>이때 Input.displayName을 지정해야하는 이유는</p>
<p>eslint-plugin-react 에서 <code>Component definition is missing display name</code> 에러를 내준다.</p>
<p>이 컴포넌트의 이름이 있어야하는데 export로만 반환하면 이름이 지정되어있지 않아서 나타내는 에러이다.</p>
<p><strong>해결 방법</strong></p>
<ol>
<li>export default로 반환</li>
<li>displayName으로 이름 지정</li>
</ol>
<br />
<br />

<h3 id="참고">참고</h3>
<p>React 공식문서 (한글)
<a href="https://ko.react.dev/reference/react/forwardRef#usage">https://ko.react.dev/reference/react/forwardRef#usage</a></p>
<p>Component definition is missing display name 에러
<a href="https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/display-name.md">https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/display-name.md</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React.FC]]></title>
            <link>https://velog.io/@eui-jin/React.FC</link>
            <guid>https://velog.io/@eui-jin/React.FC</guid>
            <pubDate>Tue, 03 Dec 2024 08:16:15 GMT</pubDate>
            <description><![CDATA[<h2 id="fc란">FC란</h2>
<p><code>Function Component</code> 타입의 줄임말이다.</p>
<h3 id="reactfc">React.FC</h3>
<pre><code class="language-jsx">interface FunctionComponent&lt;P = {}&gt; {
    (props: PropsWithChildren&lt;P&gt;, context?: any): ReactElement&lt;any, any&gt; | null;
    propTypes?: WeakValidationMap&lt;P&gt;;
    contextTypes?: ValidationMap&lt;any&gt;;
    defaultProps?: Partial&lt;P&gt;;
    displayName?: string;
}

type FC&lt;P = {}&gt; = FunctionComponent&lt;P&gt;;
</code></pre>
<ul>
<li><code>PropsWithChildren&lt;P&gt;</code>를 사용하여 children props를 포함한다.</li>
<li><code>defaultProps</code>, <code>propTypes</code>, <code>displayName</code>의 prop로 포함</li>
</ul>
<h3 id="장점">장점</h3>
<ul>
<li>props의 타입을 간단하게 정의 가능하다.</li>
<li><code>defaultProps</code>, <code>propTypes</code>, <code>displayName</code> 등의 정적 속성을 쉽게 사용 가능하다.</li>
</ul>
<h3 id="단점">단점</h3>
<ul>
<li><code>children</code>이 항상 포함된다.</li>
<li>컴포넌트의 타입 선언이 더 불명확해질 수 있다.</li>
</ul>
<h2 id="가장-큰-단점">가장 큰 단점</h2>
<p><code>React.FC</code>를 사용할 경우 <code>props</code>에 자동으로 <code>children</code>이 옵셔널로 들어가있다.</p>
<p>→ 단점인 이유는 <code>children</code>의 명확하지 않은 타입 지정이기 때문이다.</p>
<p>→ 모든 컴포넌트를 <code>React.FC</code>로 선언했을 때 <code>children</code>이 필요하지 않은 컴포넌트에도 명시를 해놓지 않았기 때문에 <code>children</code>을 사용할 수 있는 문제가 발생한다.</p>
<h3 id="컴포넌트-변경">컴포넌트 변경</h3>
<p>현재 만들어둔 Chip 컴포넌트는 이렇게 구성되어 있다.</p>
<pre><code class="language-jsx">export const Chip: React.FC&lt;ChipProps&gt; = ({
  className,
  variant = &#39;default&#39;,
  size = &#39;sm&#39;,
  children,
  ...props
}) =&gt; {
  const chipClass = cn(
    chipStyles.base,
    chipStyles.variant[variant],
    chipStyles.size[size],
    className,
  );

  return (
    &lt;button className={chipClass} {...props}&gt;
      &lt;div className=&quot;size-18 rounded-6 border border-slate-200 bg-white&quot;&gt;
        {variant === &#39;active&#39; &amp;&amp; &lt;FaCheck className=&quot;size-16 text-blue-600&quot; /&gt;}
      &lt;/div&gt;
      {children}
    &lt;/button&gt;
  );
};
</code></pre>
<p>이러한 방식으로 바꿔야 <code>children</code>에 대한 명확한 타입 지정이 가능하다.</p>
<pre><code class="language-jsx">export const Chip = ({
  className,
  variant = &#39;default&#39;,
  size = &#39;sm&#39;,
  children,
  ...props
}: ChipProps) =&gt; {
  const chipClass = cn(
    chipStyles.base,
    chipStyles.variant[variant],
    chipStyles.size[size],
    className,
  );

  return (
    &lt;button className={chipClass} {...props}&gt;
      &lt;div className=&quot;size-18 rounded-6 border border-slate-200 bg-white&quot;&gt;
        {variant === &#39;active&#39; &amp;&amp; &lt;FaCheck className=&quot;size-16 text-blue-600&quot; /&gt;}
      &lt;/div&gt;
      {children}
    &lt;/button&gt;
  );
};
</code></pre>
<h3 id="참고">참고</h3>
<p>React.FC 정의
<a href="https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts">https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Jest]]></title>
            <link>https://velog.io/@eui-jin/Jest</link>
            <guid>https://velog.io/@eui-jin/Jest</guid>
            <pubDate>Tue, 26 Nov 2024 08:58:58 GMT</pubDate>
            <description><![CDATA[<h2 id="jest-란">Jest 란</h2>
<p><code>Jest</code>는 페이스북에서 만들어서 React와 더불어 많은 자바스크립트 개발자들로 부터 좋은 반응을 얻고 있는 테스팅 라이브러리다.</p>
<h2 id="tobe-vs-toequal">toBe vs toEqual</h2>
<p><code>toBe</code>는 참조값(주소값)을 비교하는 함수이다.
<code>toEqual</code>은 값? 을 비교하는 함수이다.</p>
<ul>
<li>객체를 비교할 때 객체는 각자의 메모리 주소를 가지고 있기 때문에 내용이 같더라도 참조값이 다르기 때문에 <code>toBe</code>는 다르다고 나온다.</li>
<li><code>toEqual</code>은 실제 값을 비교하기 때문에 객체 내부의 값이 동일하다면 같다고 나온다.<ul>
<li><code>toStrictEqual</code>: <code>undefined</code>를 허용하지 않는다.</li>
</ul>
</li>
<li><code>number</code>, <code>boolean</code>, <code>string</code>에는 참조값이 정해져 있어서 차이가 없다.</li>
<li>배열도 객체와 동일한듯?</li>
</ul>
<pre><code class="language-jsx">describe(&#39;to be vs to equal&#39;, () =&gt; {
  test(&#39;test&#39;, () =&gt; {
    const a = [1];
    expect(a).toBe([1]); // false
  });

  test(&#39;test&#39;, () =&gt; {
    const a = [1];
    expect(a).toEqual([1]); // true
  });

  test(&#39;test&#39;, () =&gt; {
    expect([1]).toBe([1]); // false
  });
});</code></pre>
<ul>
<li><p>toBe 코드</p>
<pre><code class="language-jsx">  getJasmineRequireObj().toBe = function(j$) {
    /**
     * {@link expect} the actual value to be `===` to the expected value.
     * @function
     * @name matchers#toBe
     * @since 1.3.0
     * @param {Object} expected - The expected value to compare against.
     * @example
     * expect(thing).toBe(realThing);
     */
    function toBe(matchersUtil) {
      var tip = &#39; Tip: To check for deep equality, use .toEqual() instead of .toBe().&#39;;

      return {
        compare: function(actual, expected) {
          var result = {
            pass: actual === expected
          };

          if (typeof expected === &#39;object&#39;) {
            result.message = matchersUtil.buildFailureMessage(&#39;toBe&#39;, result.pass, actual, expected) + tip;
          }

          return result;
        }
      };
    }

    return toBe;
  };</code></pre>
</li>
<li><p>toEqual 코드</p>
<pre><code class="language-jsx">  getJasmineRequireObj().toEqual = function(j$) {
    /**
     * {@link expect} the actual value to be equal to the expected, using deep equality comparison.
     * @function
     * @name matchers#toEqual
     * @since 1.3.0
     * @param {Object} expected - Expected value
     * @example
     * expect(bigObject).toEqual({&quot;foo&quot;: [&#39;bar&#39;, &#39;baz&#39;]});
     */
    function toEqual(matchersUtil) {
      return {
        compare: function(actual, expected) {
          var result = {
              pass: false
            },
            diffBuilder = j$.DiffBuilder({prettyPrinter: matchersUtil.pp});

          result.pass = matchersUtil.equals(actual, expected, diffBuilder);

          // TODO: only set error message if test fails
          result.message = diffBuilder.getMessage();

          return result;
        }
      };
    }

    return toEqual;
  };
</code></pre>
</li>
</ul>
<h2 id="jest로-promise-테스트">Jest로 Promise 테스트</h2>
<p><strong>1. Promise 객체로 받는 경우</strong></p>
<pre><code class="language-jsx">test(&#39;the data is peanut butter&#39;, () =&gt; {
  return fetchData().then(data =&gt; {
    expect(data).toBe(&#39;peanut butter&#39;);
  });
});</code></pre>
<p>→ return을 해야하는 이유: return을 사용해야 jest는 <code>promise</code>가 <code>resolve</code> 될 때까지 기다리기 때문이다.</p>
<p>.resolves/.rejects 사용하면</p>
<pre><code class="language-jsx">test(&#39;the data is peanut butter&#39;, () =&gt; {
  return expect(fetchData()).resolves.toBe(&#39;peanut butter&#39;);
});

test(&#39;the fetch fails with an error&#39;, () =&gt; {
  return expect(fetchData()).rejects.toMatch(&#39;error&#39;);
});</code></pre>
<p>반드시 return을 해야한다.
→ return이 없으면 data를 fetch 하고 콜백을 실행하기 전에 테스트가 완료됨</p>
<p><strong>2. async/await</strong></p>
<pre><code class="language-jsx">test(&#39;the data is peanut butter&#39;, async () =&gt; {
  const data = await fetchData();
  expect(data).toBe(&#39;peanut butter&#39;);
});

test(&#39;the fetch fails with an error&#39;, async () =&gt; {
  expect.assertions(1);
  try {
    await fetchData();
  } catch (error) {
    expect(error).toMatch(&#39;error&#39;);
  }
});</code></pre>
<pre><code class="language-jsx">test(&#39;the data is peanut butter&#39;, async () =&gt; {
  await expect(fetchData()).resolves.toBe(&#39;peanut butter&#39;);
});

test(&#39;the fetch fails with an error&#39;, async () =&gt; {
  await expect(fetchData()).rejects.toMatch(&#39;error&#39;);
});</code></pre>
<p>올바른 promise는 <code>reloves</code>로 풀고 <code>toBe</code>로 비교한다.
에러 promise는 <code>rejects</code>로 받고 <code>toMatch</code>로 비교한다.
→ toMatch는 정규식 비교
→ toBe로 비교하면 안되나?
→ toThrow 쓸듯</p>
<p>toThrow 예시 코드</p>
<pre><code class="language-jsx">test(&quot;Test description&quot;, () =&gt; {
  const t = () =&gt; {
    throw new TypeError(&quot;UNKNOWN ERROR&quot;);
  };
  // 타입에러 객체 확인
  expect(t).toThrow(TypeError);
  // 에러 메시지 확인
  expect(t).toThrow(&quot;UNKNOWN ERROR&quot;);
});

// toThrow 사용
test(&quot;test&quot;, () =&gt; {
    expect(() =&gt; {
        throw new Error(&quot;ERROR&quot;)
    }).toThrow(&quot;ERROR&quot;)
})</code></pre>
<h2 id="mock-function">Mock function</h2>
<h3 id="jestjn">jest.jn()</h3>
<p>mockReturnValue(value)</p>
<pre><code class="language-jsx">const mockFn = jest.fn()

mockFn() // undefined
mockFn(1) // undefined

mockFn.mockReturnValue(1)
mockFn() // 1

mockFn.mockReturnValue(&#39;a&#39;)
mockFn() // a</code></pre>
<p>mockInplemetation(value)</p>
<ul>
<li>모킹 함수를 구현 (동작이 가능한)</li>
</ul>
<pre><code class="language-jsx">const mockFn = jest.fn();

mockFn.mockImplementation((name) =&gt; `hi ${name}`);
console.log(mockFn(&#39;a&#39;)); // hi a

const mockFn = jest.fn((name) =&gt; `hi ${name}`) // 가능</code></pre>
<p>mockResolvedValue(value) / mockRejectedValue(value)</p>
<ul>
<li>비동기 상황에서 resolve / reject 값을 받는다</li>
</ul>
<pre><code class="language-jsx">test(&#39;resolve test&#39;, async () =&gt; {
  const mockFn = jest.fn().mockResolvedValue(1);

  expect(await mockFn()).toEqual(1);
});

test(&#39;reject test&#39;, async () =&gt; {
  const mockFn = jest.fn().mockRejectedValue(new Error(&#39;error&#39;));

  expect(mockFn()).rejects.toThrow(&#39;error&#39;);
});</code></pre>
<p><code>toBeCalled</code>랑 <code>toHaveBeenCalled</code>랑 똑같음
<code>toHaveBeenCalled</code>: mock 함수가 호출 되었는지 확인
<code>toHaveBeenCalledTimes</code>: 함수가 몇번 호출 되었는지 확인
<code>toHqveBeenCalledWith</code>: 함수가 특정 인자(props)를 포함해서 호출이 되었는지 확인</p>
<h3 id="jestmock">jest.mock</h3>
<p>jest.fn()과 비슷하지만 그룹을 한꺼번에 모킹 처리할 때 사용한다.</p>
<h2 id="spy-function">Spy function</h2>
<h3 id="jestspyon">jest.spyOn</h3>
<p>헤당 함수의 호출 여부와 어떻게 호출 되었는지 확인한다.</p>
<pre><code class="language-jsx">  test(&#39;spyOn test&#39;, () =&gt; {
    const calculator = {
      add: (a, b) =&gt; a + b,
    };

    const spyFn = jest.spyOn(calculator, &#39;add&#39;);

    const result = calculator.add(1, 2);

    expect(spyFn).toHaveBeenCalled();
    expect(spyFn).toHaveBeenCalledTimes(1);
    expect(spyFn).toHaveBeenCalledWith(1, 2);
    expect(result).toBe(3);
  });</code></pre>
<h3 id="추가로-학습해야될-부분">추가로 학습해야될 부분</h3>
<p>Promise
<a href="https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise">https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise</a>
Javascript 동작 원리
<a href="https://velog.io/@rnjsrntkd95/Call-Stack-Event-loop-Task-Queue">https://velog.io/@rnjsrntkd95/Call-Stack-Event-loop-Task-Queue</a></p>
<h3 id="참고">참고</h3>
<p>toBe, toEqual
<a href="https://stackoverflow.com/questions/22413009/jasmine-javascript-testing-tobe-vs-toequal">https://stackoverflow.com/questions/22413009/jasmine-javascript-testing-tobe-vs-toequal</a>
jest promise
<a href="https://jestjs.io/docs/asynchronous#asyncawait">https://jestjs.io/docs/asynchronous#asyncawait</a>
jest mock
<a href="https://inpa.tistory.com/entry/JEST-%F0%9F%93%9A-%EB%AA%A8%ED%82%B9-mocking-jestfn-jestspyOn">https://inpa.tistory.com/entry/JEST-📚-모킹-mocking-jestfn-jestspyOn</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[단위 테스트]]></title>
            <link>https://velog.io/@eui-jin/%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@eui-jin/%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Tue, 26 Nov 2024 08:46:20 GMT</pubDate>
            <description><![CDATA[<h2 id="단위-테스트란">단위 테스트란</h2>
<p> 컴퓨터 프로그래밍에서 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차
 모든 함수와 메소드에 대한 테스트 케이스(Test case)를 작성하는 절차</p>
<h2 id="효과적인-단위-테스트-코드-작성법">효과적인 단위 테스트 코드 작성법</h2>
<h3 id="first-원칙">F.I.R.S.T 원칙</h3>
<blockquote>
<p>Fast: 단위 테스트는 빨라야 한다.
Isolated: 단위 테스트는 실행할 때마다 같은 결과를 만들어야 한다.
Self-validation: 단위 테스트는 스스로 테스트를 통과했는지 아닌지 판단할 수 있어야 한다.
Timely: 단위 테스트는 프로덕션 코드가 테스트에 성공하기 전에 구현되어야 한다 (TDD)
Thorough: 단위 테스트는 성공적인 흐름뿐만 아니라 가능한 모든 에러나 비정상적인 흐름에 대응해   야 한다.</p>
</blockquote>
<h3 id="given-when-then">Given-When-Then</h3>
<ul>
<li>Given: 테스트를 하기 위해 세팅하는 주어진 환경</li>
<li>When: 테스트를 하기 위한 조건</li>
<li>Then: 예상결과를 나타내며 의도대로 동작하는지 검증 및 확인</li>
</ul>
<pre><code class="language-js">  it(&#39;should clear the value and onClear is triggered&#39;, async () =&gt; {
    // Given
    const mockFn = jest.fn();
    render(&lt;Input label=&quot;my input&quot; isClearabl onClear={mockFn} /&gt;);

    // When
    const clearBtn = screen.getByRole(&#39;button&#39;);
    fireEvent.click(clearBtn);

    // Then
    const input = screen.getByLabelText(&#39;my input&#39;);
    expect(input.value).toBe(&#39;&#39;);
    expect(mockFn).toHaveBeenCalledTimes(1);
  });</code></pre>
<h3 id="테스트-케이스의-목적을-명확히">테스트 케이스의 목적을 명확히</h3>
<p>테스트의 목적: 버튼을 눌렀을 때 좋아하는 과일이 바나나로 바뀐 문구가 노출되는지 검증</p>
<pre><code class="language-jsx">// 첫 번째 테스트 코드
it(&#39;바나나 문자열로 updateWord 호출 시 word가 바나나로 변경된다&#39;, () =&gt; {
  const { result } = renderHook(() =&gt; useStore());

  act(() =&gt; {
    result.current.updateWord(&#39;바나나&#39;);
  });

  expect(result.current.word).toBe(&#39;바나나&#39;);
});</code></pre>
<p>내부적으로 가지는 단어라는 상태가 올바르게 변경되는지 테스트 (내부에서 사용하는 zustand 라이브러리 동작 검증)</p>
<pre><code class="language-jsx">// 두 번째 테스트 코드
it(&#39;버튼 클릭 시 좋아하는 과일이 바나나로 바뀐다&#39;, async () =&gt; {
  const user = userEvent.setup();
  render(&lt;WordWithButton /&gt;);

  await user.click(screen.getByRole(&#39;button&#39;, { name: &#39;좋아하는 과일 바꾸기&#39; }));

  expect(screen.getByText(/바나나/i)).toBeInTheDocument();
});</code></pre>
<p>화면 내에 ‘바나나’라는 단어가 있는지 테스트</p>
<pre><code class="language-jsx">// 세 번째 테스트 코드
it(&#39;버튼 클릭 시 heading 영역의 문구가 바나나를 좋아한다는 내용으로 변경된다&#39;, async () =&gt; {
  const user = userEvent.setup();
  render(&lt;WordWithButton /&gt;);

  await user.click(screen.getByRole(&#39;button&#39;, { name: &#39;좋아하는 과일 바꾸기&#39; }));

  expect(screen.getByRole(&#39;heading&#39;, { name: &#39;나는 바나나를 좋아한다!&#39; })).toBeInTheDocument();
});</code></pre>
<p>버튼을 눌렀을 때 heading 영역의 문구가 바나나를 좋아한다는 내용으로 변경됨을 테스트</p>
<h3 id="참고">참고</h3>
<p>배민 기술블로그 (단위 테스트)
<a href="https://techblog.woowahan.com/17404/">https://techblog.woowahan.com/17404/</a></p>
]]></description>
        </item>
    </channel>
</rss>