<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>dev-hpk 님의 블로그</title>
    <link>https://dev-hpk.tistory.com/</link>
    <description>dev-hpk 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Fri, 12 Jun 2026 22:53:15 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>dev-hpk</managingEditor>
    <image>
      <title>dev-hpk 님의 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/7315913/attach/5567b0995b7540ea8fe72875b0893ea3</url>
      <link>https://dev-hpk.tistory.com</link>
    </image>
    <item>
      <title>[맛길] Zustand + sessionStorage로 맛집 리스트 캐싱하기</title>
      <link>https://dev-hpk.tistory.com/192</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Zustand와 sessionStorage로 데이터를 캐싱하게 된 계기&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 맛길을 배포 후 지인들에게 피드백을 요청했습니다. 가장 많이 받은 피드백이 페이지를 이동할 때마다 이전 데이터가 사라져 불편하다는 내용이었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;피드백을 바탕으로 생각해보니 아래와 같은 문제가 있을 수 있을 것 같습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;페이지 전환이나 새로고침 시 기존 데이터를 다시 불러오기 위해 불필요한 API 요청이 발생&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;API 응답이 길어질 경우, UX(사용자 경험) 저하&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;길 찾기 기능에서 prop drilling 발생&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이 문제를 해결하기 위해 Zustand를 도입해 맛집 리스트와 위치 정보를 전역으로 관리하고, 맛집 리스트를 sessionStorage에 저장해 불필요한 API 호출을 줄이기로 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Zustand를 선택한 이유 : 상태 관리 라이브러리 비교&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;전역 상태 관리 방법으로는 Context API를 이용하거나 외부 상태 관리 라이브러리를 이용하는 방법이 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Context API&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;내장 기능으로 외부 라이브러리에 의존하지 않고 간단하게 사용 가능&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;상태가&amp;nbsp;변경될 때마다&amp;nbsp;해당 컨텍스트를&amp;nbsp;구독하고 있는 모든 컴포넌트가 리렌더링 되기 때문에 리렌더링 최적화 어려움&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Redux&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;강력한 상태관리 기능과 미들웨어라는&amp;nbsp;기능을&amp;nbsp;제공하여&amp;nbsp;비동기&amp;nbsp;작업을&amp;nbsp;효율적으로&amp;nbsp;관리해 줌&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;보일러 플레이트 코드가 많음 &amp;rarr; 단순한 기능인데도 여러 개의 파일을 만들고, action &amp;rarr; reducer &amp;rarr; store &amp;rarr; component로 연결하는 과정이 필요해 코드량이 많아지고 유지보수가 어려움&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Provider를 최상위에 두고, Redux의 상태가 변경되면 해당 상태를 구독하는 모든 컴포넌트가 리렌더링 되기 때문에 리렌더링 최적화 어려움&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Recoil&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Github 확인 결과 2025년 1월 2일부터 GitHub에서 &lt;b&gt;읽기 전용&lt;/b&gt; 상태로 전환되어 배재했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;React-Query&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;데이터 페칭과 캐싱을 위한 강력한 기능을 제공&lt;br /&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;주로 서버 상태 관리에 중점을 두고 있어, 클라이언트 측 상태 관리에는 적합하지 않을 수 있음&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Zustand&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;보일러 플레이트 코드가 적고 사용법이 간단&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;별도의 Provider 없이 상태와 상태를 변경하는 액션을 훅으로 정의하고, 어느 컴포넌트에서나 import하여 사용 가능&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;필요한 상태만 구독하여 사용할 수 있어 리렌더링을 최소화&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1321&quot; data-origin-height=&quot;866&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xZXJz/btsMKNOgexT/kIcdBoiNKkf0FFBTIiBdH0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xZXJz/btsMKNOgexT/kIcdBoiNKkf0FFBTIiBdH0/img.png&quot; data-alt=&quot;npm trends로 비교한 상태관리 라이브러리&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xZXJz/btsMKNOgexT/kIcdBoiNKkf0FFBTIiBdH0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxZXJz%2FbtsMKNOgexT%2FkIcdBoiNKkf0FFBTIiBdH0%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;1321&quot; height=&quot;866&quot; data-origin-width=&quot;1321&quot; data-origin-height=&quot;866&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;npm trends로 비교한 상태관리 라이브러리&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Zustand를 선택한 이유는 다음과 같습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;작은 번들 사이즈&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;적은 보일러플레이트 코드와 간단한 사용법&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Provider 없이 원하는 상태만 구독이 가능해 리렌더링을 최소화&amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;sessionStorage를 선택한 이유 : 클라이언트 캐싱 방법 비교&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;localStorage&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;데이터를 쉽게 저장하고 불러올 수 있으며, 브라우저를 종료해도 데이터가 유지됨(영구 저장)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;indexedDB&lt;/b&gt;&lt;/span&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;span style=&quot;background-color: #ffffff; color: #212529; text-align: left; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 많은 양의 구조화된 데이터를 클라이언트에 저장 가능&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: left;&quot;&gt;sessionStorage&lt;/span&gt;&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 데이터를 쉽게 저장하고 불러올 수 있으며, 세션 동안 데이터를 유지하고 세션이 종료되면 데이터가 삭제됨(임시 저장)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;맛집 리스트 데이터는 대량의 데이터가 아니기 때문에 indexedDB를 사용하는 것은 과한 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;처음 취지에 맞는 캐싱 방법은 localStorage와 sessionStorage인데, 세션 동안만 데이터를 유지하고 브라우저를 닫으면 데이터를 삭제하는 것이 자연스럽다고 생각해 sessionStorage를 선택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Zustand + sessionStorage 적용&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1741347522002&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { create } from 'zustand';
import { YoutubeData } from '@/app/types/youtube';

interface ListsStore {
  lists: Record&amp;lt;string, YoutubeData[]&amp;gt;;
  cursors: Record&amp;lt;string, number | null&amp;gt;;
  hasNexts: Record&amp;lt;string, boolean&amp;gt;;

  initializeState: () =&amp;gt; void;
  setLists: (channel: string, newLists: YoutubeData[]) =&amp;gt; void;
  setCursor: (channel: string, cursor: number | null) =&amp;gt; void;
  setHasNext: (channel: string, hasNext: boolean) =&amp;gt; void;
}

const useListsStore = create&amp;lt;ListsStore&amp;gt;((set) =&amp;gt; ({
  lists: {},
  cursors: {},
  hasNexts: {},

  // sessionStorage에서 상태를 초기화
  initializeState: () =&amp;gt; {
    const sessionStorageData = sessionStorage.getItem('youtubeData');
    if (sessionStorageData) {
      const { lists, cursors, hasNexts } = JSON.parse(sessionStorageData);
      set({ lists, cursors, hasNexts });
    }
  },
  // 채널의 목록을 설정하고 sessionStorage에 저장
  setLists: (channel, newLists) =&amp;gt;
    set((state) =&amp;gt; {
      const updatedLists = { ...state.lists, [channel]: newLists };
      sessionStorage.setItem(
        'youtubeData',
        JSON.stringify({ ...state, lists: updatedLists }),
      );
      return { lists: updatedLists };
    }),
  // 채널의 커서를 설정하고 sessionStorage에 저장
  setCursor: (channel, cursor) =&amp;gt;
    set((state) =&amp;gt; {
      const updatedCursors = { ...state.cursors, [channel]: cursor };
      sessionStorage.setItem(
        'youtubeData',
        JSON.stringify({ ...state, cursors: updatedCursors }),
      );
      return { cursors: updatedCursors };
    }),
  // 채널의 'hasNext' 값을 설정하고 sessionStorage에 저장
  setHasNext: (channel, hasNext) =&amp;gt;
    set((state) =&amp;gt; {
      const updatedHasNexts = { ...state.hasNexts, [channel]: hasNext };
      sessionStorage.setItem(
        'youtubeData',
        JSON.stringify({ ...state, hasNexts: updatedHasNexts }),
      );
      return { hasNexts: updatedHasNexts };
    }),
}));

export default useListsStore;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1741347773147&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useCallback, useEffect } from 'react';
import { ChannelResponse, YoutubeData } from '../types/youtube';
import axios from '@/app/lib/instance';
import useIntersectionObserver from '@/app/hooks/useIntersectionObserver';
import useListsStore from '@/app/stores/useListsStore';

const useFetchData = (
  channel: string,
  initialLists: YoutubeData[],
  initialHasNext: boolean,
  nextCursor: number | null,
) =&amp;gt; {
  const {
    lists,
    cursors,
    hasNexts,
    setLists,
    setCursor,
    setHasNext,
    initializeState,
  } = useListsStore();

  // 컴포넌트 마운트될 때 store 상태 초기화
  useEffect(() =&amp;gt; {
    initializeState();
  }, [initializeState]);
  // sessionStorage에 저장된 채널 데이터가 없는 경우 초기 데이터 설정
  useEffect(() =&amp;gt; {
    const sessionStorageData = sessionStorage.getItem('youtubeData');
    const hasStoreData = sessionStorageData
      ? JSON.parse(sessionStorageData)
      : null;

    if (!hasStoreData || !hasStoreData.lists[channel]) {
      if (!lists[channel]) {
        setLists(channel, initialLists);
      }
      if (cursors[channel] === undefined) {
        setCursor(channel, nextCursor);
      }
      if (hasNexts[channel] === undefined) {
        setHasNext(channel, initialHasNext);
      }
    }
  }, [
    channel,
    initialLists,
    initialHasNext,
    nextCursor,
    lists,
    cursors,
    hasNexts,
    setLists,
    setCursor,
    setHasNext,
  ]);

  const fetchData = useCallback(async () =&amp;gt; {
    if (!hasNexts[channel]) return;

    const { data } = await axios.get&amp;lt;ChannelResponse&amp;gt;(channel, {
      params: { limit: 12, cursor: cursors[channel] },
    });

    setLists(channel, [...(lists[channel] || []), ...data.lists]);
    setCursor(channel, data.nextCursor);
    setHasNext(channel, data.hasNext);
  }, [channel, lists, cursors, hasNexts, setLists, setCursor, setHasNext]);

  const endRef = useIntersectionObserver(fetchData, hasNexts[channel]);

  return {
    lists: lists[channel] || [],
    hasNext: hasNexts[channel] || false,
    endRef,
  };
};

export default useFetchData;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Zustand + sessionStorage 적용 결과&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;불필요한 API 요청 감소 : 페이지 이동이나 새로고침 시 sessionStorage의 데이터를 사용해 불필요한 API 요청을 최소화&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;UX(사용자 경험) 개선 : 페이지 이동이나 새로고침 시 이전에 불러온 데이터가 유지되어, 처음부터 다시 데이터를 불러오는 불편함을 없앰&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;

            &lt;figure class=&quot;unsupported component-kakaotv&quot; contenteditable=&quot;false&quot; style=&quot;background:#000;margin:16px 0;min-height:72px;padding:10px 16px;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;width:100%;max-width:100%;&quot;&gt;
                &lt;p contenteditable=&quot;false&quot; style=&quot;margin:0;color:#8a8a8a;font-size:13px;line-height:1.6;user-select:none;pointer-events:none;&quot;&gt;동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.&lt;/p&gt;
            &lt;/figure&gt;
        
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Next.js</category>
      <category>react</category>
      <category>sessionStorage</category>
      <category>typescript</category>
      <category>Zustand</category>
      <category>맛길</category>
      <category>상태 관리</category>
      <category>캐싱</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/192</guid>
      <comments>https://dev-hpk.tistory.com/192#entry192comment</comments>
      <pubDate>Fri, 7 Mar 2025 20:58:08 +0900</pubDate>
    </item>
    <item>
      <title>[맛길] 이미지 최적화 (LCP 최적화)</title>
      <link>https://dev-hpk.tistory.com/191</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;LCP는 가장 큰 콘텐츠가 화면에 렌더링 되는 시간을 측정한 지표입니다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;lighthouse로 측정한 결과 LCP(이미지)가 느림으로 측정되었습니다.&amp;nbsp;&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;LCP가 낮게 측정되었다는 것은 화면에 콘텐츠가 늦게 렌더링 된다는 얘기입니다. 이는 사용자 경험(UX)에 큰 영향을 줄 수 있고 이탈로까지 이어질 수 있습니다. Next.js에서 제공하는 Image 컴포넌트를 사용해 해당 이슈를 해결해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이슈를 해결하기 전에 html img 태그를 사용했을 때 결과를 먼저 측정해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;img 태그 (제약 없음, Slow 4G, 3G)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1060&quot; data-origin-height=&quot;70&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dw0l1U/btsMDYm68qO/BFgtez4CyALKdYwTFI3dVk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dw0l1U/btsMDYm68qO/BFgtez4CyALKdYwTFI3dVk/img.png&quot; data-alt=&quot;img 태그 - No Throttling&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dw0l1U/btsMDYm68qO/BFgtez4CyALKdYwTFI3dVk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdw0l1U%2FbtsMDYm68qO%2FBFgtez4CyALKdYwTFI3dVk%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;1060&quot; height=&quot;70&quot; data-origin-width=&quot;1060&quot; data-origin-height=&quot;70&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;img 태그 - No Throttling&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;858&quot; data-origin-height=&quot;59&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cn8DIc/btsMDAmutgC/m5yQ8bGu90XLJUYcSsE8vk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cn8DIc/btsMDAmutgC/m5yQ8bGu90XLJUYcSsE8vk/img.png&quot; data-alt=&quot;img 태그 - Slow 4G&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cn8DIc/btsMDAmutgC/m5yQ8bGu90XLJUYcSsE8vk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcn8DIc%2FbtsMDAmutgC%2Fm5yQ8bGu90XLJUYcSsE8vk%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;858&quot; height=&quot;59&quot; data-origin-width=&quot;858&quot; data-origin-height=&quot;59&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;img 태그 - Slow 4G&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1062&quot; data-origin-height=&quot;69&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lxzCJ/btsMEqwGnrg/SIJQEkCo6PZxgqTHpO3qQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lxzCJ/btsMEqwGnrg/SIJQEkCo6PZxgqTHpO3qQ0/img.png&quot; data-alt=&quot;img 태그 - 3G&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lxzCJ/btsMEqwGnrg/SIJQEkCo6PZxgqTHpO3qQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlxzCJ%2FbtsMEqwGnrg%2FSIJQEkCo6PZxgqTHpO3qQ0%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;1062&quot; height=&quot;69&quot; data-origin-width=&quot;1062&quot; data-origin-height=&quot;69&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;img 태그 - 3G&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;50kB의 작은 용량임에도 이미지를 내려받는데 4G 환경에서는 3.5초, 3G 환경에서는 12초가 소요됩니다. 이는 사용자에게 부정적인 경험을 줄 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 Next.js의 Image 컴포넌트를 사용해 이미지를 최적화해보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Image 컴포넌트 적용 및 결과&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1741261078096&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;Image
  src={thumbnail}
  className=&quot;object-cover transition-transform duration-300 will-change-transform group-hover:scale-105&quot;
  fill
  sizes={sizes}
  alt={alt}
  priority
  onLoad={() =&amp;gt; setIsLoading(false)}
/&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;자동 크기 조정 : fill 속성을 사용하여 다양한 화면 크기에서 비율을 유지하며 최적의 크기로 이미지를 표시했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;포맷 최적화: 최신 이미지 포맷(WebP)으로 자동 변환하여, 이미지 파일 크기를 줄이고 로딩 속도를 개선했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;불필요한 이미지 로드 방지 : sizes&amp;nbsp;속성을&amp;nbsp;설정하여&amp;nbsp;디바이스&amp;nbsp;크기에&amp;nbsp;맞는&amp;nbsp;이미지만&amp;nbsp;로드하도록&amp;nbsp;하여&amp;nbsp;데이터&amp;nbsp;사용량을&amp;nbsp;줄였습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;우선 로딩: priority 속성을 사용하여 페이지의 주요 콘텐츠가 빠르게 표시되도록 했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1052&quot; data-origin-height=&quot;65&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJx2Lv/btsMDjSONGB/6DErPR3SqpuWXQpAghgPi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJx2Lv/btsMDjSONGB/6DErPR3SqpuWXQpAghgPi1/img.png&quot; data-alt=&quot;Imae 컴포넌트 - No Throttling&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJx2Lv/btsMDjSONGB/6DErPR3SqpuWXQpAghgPi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJx2Lv%2FbtsMDjSONGB%2F6DErPR3SqpuWXQpAghgPi1%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;1052&quot; height=&quot;65&quot; data-origin-width=&quot;1052&quot; data-origin-height=&quot;65&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Imae 컴포넌트 - No Throttling&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1058&quot; data-origin-height=&quot;63&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/7sQTa/btsMC3ipMck/8PzxraYdCDfd007tGZCjG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/7sQTa/btsMC3ipMck/8PzxraYdCDfd007tGZCjG0/img.png&quot; data-alt=&quot;Image 컴포넌트 - Slow 4G&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/7sQTa/btsMC3ipMck/8PzxraYdCDfd007tGZCjG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F7sQTa%2FbtsMC3ipMck%2F8PzxraYdCDfd007tGZCjG0%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;1058&quot; height=&quot;63&quot; data-origin-width=&quot;1058&quot; data-origin-height=&quot;63&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Image 컴포넌트 - Slow 4G&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1058&quot; data-origin-height=&quot;61&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqHU0s/btsMCAU4BMM/8pcHTdxzEvQdD5f41df5mk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqHU0s/btsMCAU4BMM/8pcHTdxzEvQdD5f41df5mk/img.png&quot; data-alt=&quot;Image 컴포넌트 - 3G&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqHU0s/btsMCAU4BMM/8pcHTdxzEvQdD5f41df5mk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqHU0s%2FbtsMCAU4BMM%2F8pcHTdxzEvQdD5f41df5mk%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;1058&quot; height=&quot;61&quot; data-origin-width=&quot;1058&quot; data-origin-height=&quot;61&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Image 컴포넌트 - 3G&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Image 컴포넌트를 적용한 후의 성능 측정 결과는 다음과 같았습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 용량 : &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;50kB에서 12kB로 75% 감소&lt;/span&gt; &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이미지 로딩 속도 : No Throttling 기준 280ms에서 52ms로 약 80% 감소&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;913&quot; data-origin-height=&quot;662&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEMxjP/btsMDG1kw9f/NhqJx4sKSlBEKyTk3MUh40/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEMxjP/btsMDG1kw9f/NhqJx4sKSlBEKyTk3MUh40/img.png&quot; data-alt=&quot;img 태그 - lighthouse 측정 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEMxjP/btsMDG1kw9f/NhqJx4sKSlBEKyTk3MUh40/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEMxjP%2FbtsMDG1kw9f%2FNhqJx4sKSlBEKyTk3MUh40%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;913&quot; height=&quot;662&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;913&quot; data-origin-height=&quot;662&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;img 태그 - lighthouse 측정 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;882&quot; data-origin-height=&quot;543&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bi7kSN/btsMCBl8Tlb/3FGJpNGjz4dwYcHcjT8Qnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bi7kSN/btsMCBl8Tlb/3FGJpNGjz4dwYcHcjT8Qnk/img.png&quot; data-alt=&quot;Image 컴포넌트 - lighthouse 측정 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bi7kSN/btsMCBl8Tlb/3FGJpNGjz4dwYcHcjT8Qnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbi7kSN%2FbtsMCBl8Tlb%2F3FGJpNGjz4dwYcHcjT8Qnk%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;882&quot; height=&quot;543&quot; data-origin-width=&quot;882&quot; data-origin-height=&quot;543&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Image 컴포넌트 - lighthouse 측정 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;lighthouse의 성능&amp;nbsp;점수는&amp;nbsp;최적화&amp;nbsp;전&amp;nbsp;65점에서&amp;nbsp;최적화&amp;nbsp;후&amp;nbsp;81점으로&amp;nbsp;개선되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;a style=&quot;background-color: #e6f5ff; color: #0070d1; text-align: start;&quot; href=&quot;https://nextjs.org/docs/pages/building-your-application/optimizing/images&quot;&gt;참고 자료 - Next.js | Image Optimization&lt;/a&gt; &lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1741265999430&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Optimizing: Images | Next.js&quot; data-og-description=&quot;Optimize your images with the built-in &amp;#96;next/image&amp;#96; component.&quot; data-og-host=&quot;nextjs.org&quot; data-og-source-url=&quot;https://nextjs.org/docs/pages/building-your-application/optimizing/images&quot; data-og-url=&quot;https://nextjs.org/docs/pages/building-your-application/optimizing/images&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bzYd76/hyYncA7hx0/G4xFO0ARhtPauFZ1ZXmodk/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/5F7sC/hyYmRcKjLq/zJLCtxLiKH376aHe4KjiAK/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/bw1ct8/hyYmJlykpk/LI57EfPNA2jkQ7h5oYwqw1/img.png?width=1600&amp;amp;height=629&amp;amp;face=0_0_1600_629&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/pages/building-your-application/optimizing/images&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nextjs.org/docs/pages/building-your-application/optimizing/images&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bzYd76/hyYncA7hx0/G4xFO0ARhtPauFZ1ZXmodk/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/5F7sC/hyYmRcKjLq/zJLCtxLiKH376aHe4KjiAK/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/bw1ct8/hyYmJlykpk/LI57EfPNA2jkQ7h5oYwqw1/img.png?width=1600&amp;amp;height=629&amp;amp;face=0_0_1600_629');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Optimizing: Images | Next.js&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Optimize your images with the built-in `next/image` component.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;nextjs.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>LCP</category>
      <category>Lighthouse</category>
      <category>next image</category>
      <category>Next.js</category>
      <category>맛길</category>
      <category>이미지 최적화</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/191</guid>
      <comments>https://dev-hpk.tistory.com/191#entry191comment</comments>
      <pubDate>Thu, 6 Mar 2025 22:02:36 +0900</pubDate>
    </item>
    <item>
      <title>[맛길] 접근성, SEO 개선</title>
      <link>https://dev-hpk.tistory.com/190</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;사이트 배포 후 lighthouse를 이용해 접근성, SEO를 체크했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;캡처.PNG&quot; data-origin-width=&quot;622&quot; data-origin-height=&quot;394&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GuldZ/btsMxD5558n/bOkt9p3boqzI1iUJRYBpCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GuldZ/btsMxD5558n/bOkt9p3boqzI1iUJRYBpCK/img.png&quot; data-alt=&quot;접근성, 검색엔진 최적화 측정 지표&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GuldZ/btsMxD5558n/bOkt9p3boqzI1iUJRYBpCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGuldZ%2FbtsMxD5558n%2FbOkt9p3boqzI1iUJRYBpCK%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;622&quot; height=&quot;394&quot; data-filename=&quot;캡처.PNG&quot; data-origin-width=&quot;622&quot; data-origin-height=&quot;394&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;접근성, 검색엔진 최적화 측정 지표&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;분명 접근성과 SEO를 고려하면서 작업했다고 생각했는데 결과가 92점이네요. 낮은 점수는 아니지만 완성도 높은 서비스를 만들기 위해 개선 작업을 해보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;접근성 개선&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;캡처.PNG&quot; data-origin-width=&quot;722&quot; data-origin-height=&quot;727&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1280l/btsMyAOhF89/1X7ulebYVkrrgZ4JJ7Terk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1280l/btsMyAOhF89/1X7ulebYVkrrgZ4JJ7Terk/img.png&quot; data-alt=&quot;접근성 테스트 탈락 요소&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1280l/btsMyAOhF89/1X7ulebYVkrrgZ4JJ7Terk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1280l%2FbtsMyAOhF89%2F1X7ulebYVkrrgZ4JJ7Terk%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;722&quot; height=&quot;727&quot; data-filename=&quot;캡처.PNG&quot; data-origin-width=&quot;722&quot; data-origin-height=&quot;727&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;접근성 테스트 탈락 요소&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 사진은 접근성 테스트에서 통과하지 못한 요소들입니다. 차례대로 하나씩 살펴보면서 수정해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣ 텍스트 &amp;amp; 배경색 명암비&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;텍스트 색과 배경 색의 대비는 저시력자나 고령자도 인식할 수 있도록&amp;nbsp; 4.5:1 이상이어야 한다고 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존 버튼의 경우 배경색이 #059669, 텍스트가 #FFFFFF로 3.7:1입니다. 명암비 접근성 테스트 사이트를 이용해 접근성을 준수하도록 수정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;767&quot; data-origin-height=&quot;676&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pU6vA/btsMAIRH3a8/snyLILRaAnvCEX4hVER7Mk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pU6vA/btsMAIRH3a8/snyLILRaAnvCEX4hVER7Mk/img.png&quot; data-alt=&quot;명도비 접근성 테스트 사이트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pU6vA/btsMAIRH3a8/snyLILRaAnvCEX4hVER7Mk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpU6vA%2FbtsMAIRH3a8%2FsnyLILRaAnvCEX4hVER7Mk%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;461&quot; height=&quot;406&quot; data-origin-width=&quot;767&quot; data-origin-height=&quot;676&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;명도비 접근성 테스트 사이트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ 버튼 터치 영역 수정&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;버튼의 크기를 W3C에서 권장하는 터치 영역 크기인 44 x 44 px로 수정했습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;3️⃣ 제목 요소 내림차순으로 수정&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;페이지 구조가 h1 &amp;gt; h3 형태로 작업되어 있어, h3 태그를 h2 태그로 수정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;4️⃣ 시멘틱 마크업 적용 및 role 속성 삭제&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;헤더의 내비게이션 부분을 nav &amp;gt; div &amp;gt; a로 작업하고, 리스트라는 점을 명시하기 위해 div와 a 태그에 각각 'list', 'listitem' role을 적용했습니다. 시멘틱 태그(ul, li)를 이용해 구조를 개선하고 불필요한 role 속성을 삭제했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;SEO 개선&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;184&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/M1msg/btsMzeKNo66/jQChfvq9VQ17P9ELPeQTWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/M1msg/btsMzeKNo66/jQChfvq9VQ17P9ELPeQTWK/img.png&quot; data-alt=&quot;검색엔진 최적화 탈락 요소&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/M1msg/btsMzeKNo66/jQChfvq9VQ17P9ELPeQTWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FM1msg%2FbtsMzeKNo66%2FjQChfvq9VQ17P9ELPeQTWK%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;645&quot; height=&quot;184&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;184&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;검색엔진 최적화 탈락 요소&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #374151; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;SEO(Search Engine Optimization)를 위해서는 사이트맵(sitemap)과 robots.txt 파일을 생성해야 한다고 합니다. 사이트맵과 robots.txt가 무슨 역할을 하는지 찾아봤습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;사이트맵: &lt;span style=&quot;background-color: #ffffff; color: #374151; text-align: start;&quot;&gt;검색엔진의 크롤링을 돕기 위해 사이트에 어떤 페이지가 있는지 알려주는 역할&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #374151; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;robots.txt: 검색엔진이 크롤링할 수 있는 페이지를 제한하는 역할&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #374151; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;즉, 검색엔진이 불필요한 페이지 크롤링하지 않도록 해 빠르고 효율적으로 사이트를 인덱싱할 수 있게 해주는 역할이네요.&amp;nbsp; 프로젝트에 적용해 보겠습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣ robots.txt 추가&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Next.js 공식 문서의 설명에 따라 /app 디렉토리 하위에 robots.txt를 추가했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1740732256644&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;User-Agent: *
Allow: /

Sitemap: https://mat-gil.vercel.app/sitemap.xml&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;모든 크롤러를 허용했고, 프로젝트에 보호가 필요한 민감한 데이터가 존재하지 않아 Disallow는 따로 설정하지 않았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ 사이트맵(sitemap) 추가&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1740732998046&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// /app/sitemap.ts

import axios, { AxiosResponse } from 'axios';
import type { MetadataRoute } from 'next';
import { ChannelResponse, YoutubeData } from '@/app/types/youtube';
import path from 'path';
import { promises as fs } from 'fs';

// 채널 JSON 파일의 데이터 수 반환하는 함수
async function getChannelMaxCount(channel: string): Promise&amp;lt;number&amp;gt; {
  const filePath = path.join(process.cwd(), 'data', `${channel}.json`);
  const fileContents = await fs.readFile(filePath, 'utf-8');
  const data: YoutubeData[] = JSON.parse(fileContents);

  return data.length;
}

// 동적으로 각 채널의 경로를 생성하는 함수
async function getDynamicPathsFromAPI() {
  const channels = [
    'pungja',
    'seongsigyeong',
    'hongseokcheon',
    'haennim',
    'baekjongwon',
  ];
  const allPaths: MetadataRoute.Sitemap = [];

  const fetchChannelPaths = channels.map(async (channel) =&amp;gt; {
    const maxCount = await getChannelMaxCount(channel);
    let nextCursor: number | null = null;

    const response: AxiosResponse&amp;lt;ChannelResponse&amp;gt; = await axios.get(
      `https://mat-gil.vercel.app/api/${channel}`,
      {
        params: { cursor: nextCursor, limit: maxCount },
      },
    );
    const { lists, nextCursor: newNextCursor } = response.data;

    // 채널의 세부 페이지 URL 경로를 생성하여 allPaths 배열에 추가
    lists.forEach((item: YoutubeData) =&amp;gt; {
      allPaths.push({
        url: `https://mat-gil.vercel.app/${channel}/${item.position}`,
        lastModified: new Date(),
        changeFrequency: 'daily',
        priority: 0.7,
      });
    });

    nextCursor = newNextCursor;
  });

  // 빌드 타임에서 60초 초과 에러 발생
  // 모든 채널에 대해 병렬로 경로를 가져오도록 수정
  await Promise.all(fetchChannelPaths);

  return allPaths;
}

// sitemap 함수: 최종 Sitemap을 반환하는 함수
export default async function sitemap(): Promise&amp;lt;MetadataRoute.Sitemap&amp;gt; {
  const dynamicPaths = await getDynamicPathsFromAPI();

  return [
    {
      url: 'https://mat-gil.vercel.app',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    {
      url: 'https://mat-gil.vercel.app/pungja',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.7,
    },
    {
      url: 'https://mat-gil.vercel.app/seongsigyeong',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.7,
    },
    {
      url: 'https://mat-gil.vercel.app/hongseokcheon',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.7,
    },
    {
      url: 'https://mat-gil.vercel.app/haennim',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.7,
    },
    {
      url: 'https://mat-gil.vercel.app/baekjongwon',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.7,
    },
    ...dynamicPaths,
  ];
}&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 data-end=&quot;948&quot; data-start=&quot;898&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;getChannelMaxCount:&lt;/b&gt; 각 채널에 포함된 데이터의 개수를 반환.&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;1014&quot; data-start=&quot;949&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;getDynamicPathsFromAPI:&lt;/b&gt; 각 채널에 대해 데이터를 가져와 동적 사이트맵 경로를 생성.&lt;/span&gt;&lt;/li&gt;
&lt;li data-is-last-node=&quot;&quot; data-end=&quot;1062&quot; data-start=&quot;1015&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;sitemap:&lt;/b&gt; 기본 URL과 동적 경로를 합쳐 최종 사이트맵을 반환.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;/api/sitemap/route.ts&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1740734757348&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// /app/api/sitemap/route.ts

import { NextResponse } from 'next/server';
import sitemap from '@/app/sitemap';

const formatDate = (date: Date): string =&amp;gt; {
  return date.toISOString().split('T')[0];
};

export async function GET() {
  const sitemapData = await sitemap();

  const xmlHeader = `&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&amp;gt;\n`;
  const urlSetStart = `&amp;lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&amp;gt;\n`;
  const urlSetEnd = `&amp;lt;/urlset&amp;gt;\n`;

  const urls = sitemapData
    .map((entry) =&amp;gt; {
      return `&amp;lt;url&amp;gt;
      &amp;lt;loc&amp;gt;${entry.url}&amp;lt;/loc&amp;gt;
      &amp;lt;lastmod&amp;gt;${formatDate(new Date())}&amp;lt;/lastmod&amp;gt;
      &amp;lt;changefreq&amp;gt;${entry.changeFrequency}&amp;lt;/changefreq&amp;gt;
      &amp;lt;priority&amp;gt;${entry.priority}&amp;lt;/priority&amp;gt;
    &amp;lt;/url&amp;gt;`;
    })
    .join('\n');

  const sitemapXML = xmlHeader + urlSetStart + urls + urlSetEnd;

  return NextResponse.json(sitemapXML, {
    headers: { 'Content-Type': 'application/xml' },
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;434&quot; data-origin-height=&quot;990&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r0yY4/btsMzt8YP1d/0bnTZ1csi8cN51rKePaae0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r0yY4/btsMzt8YP1d/0bnTZ1csi8cN51rKePaae0/img.png&quot; data-alt=&quot;sitemap.xml&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r0yY4/btsMzt8YP1d/0bnTZ1csi8cN51rKePaae0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr0yY4%2FbtsMzt8YP1d%2F0bnTZ1csi8cN51rKePaae0%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;434&quot; height=&quot;990&quot; data-origin-width=&quot;434&quot; data-origin-height=&quot;990&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;sitemap.xml&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위의 동적인 사이트 맵을 생성하는 코드를 작성하기까지 여러번의 시행착오를 겪었습니다. 에러 메시지는&amp;nbsp; &lt;span style=&quot;background-color: #e6e6e6; color: #171717; text-align: start;&quot;&gt;it took more than 60 seconds&lt;/span&gt;&lt;span style=&quot;color: #171717; text-align: start;&quot;&gt;로 빌드하는데 시간이 오래 걸려서 발생하는 문제였습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;833&quot; data-origin-height=&quot;282&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cAwL7I/btsMArW3EjK/2GmmZUPTyQgyRwMZSEFgIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cAwL7I/btsMArW3EjK/2GmmZUPTyQgyRwMZSEFgIK/img.png&quot; data-alt=&quot;동적 사이트맵 추가 후 빌드 에러&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cAwL7I/btsMArW3EjK/2GmmZUPTyQgyRwMZSEFgIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcAwL7I%2FbtsMArW3EjK%2F2GmmZUPTyQgyRwMZSEFgIK%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;833&quot; height=&quot;282&quot; data-origin-width=&quot;833&quot; data-origin-height=&quot;282&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;동적 사이트맵 추가 후 빌드 에러&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;처음에는 빌드 시간이 길어지는 이유를 파악하는 데 어려움을 겪었고, 다양한 가능성을&amp;nbsp;&lt;/span&gt;검토해 봤습니다.&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;그 과정에서 API 요청, 데이터 처리 방식, 외부 리소스 불러오는 방식 등 여러 가지를 체크하며 최적화를 시도했습니다. 특히 API 호출에 대해 반복적으로 요청을 보내는 부분에서 속도가 느려지는 문제를 발견할 수 있었습니다.&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 결국, 이 문제를 해결하면서 빌드 과정에 대한 이해도가 높아졌고, 앞으로 유사한 문제가 발생했을 때 더 빠르게 대응할 수 있을 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;3️⃣ metaData 추가&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 검색 엔진이 페이지를 더 잘 인식하고 사용자에게 더 나은 검색 결과를 제공할 수 있도록, 페이지별로 고유한 제목(title)과 설명(description) 등의 정보를 동적으로 생성했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1740738442433&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// /app/[channel]/page.tsx

export async function generateMetadata({
  params,
}: {
  params: Promise&amp;lt;{ channel: string }&amp;gt;;
}): Promise&amp;lt;Metadata&amp;gt; {
  const { channel } = await params;

  const title = `맛길 | 맛집 추천 &amp;amp; 길찾기 | ${channelMap[channel]}`;
  const description = `${channelMap[channel]}의 인기 맛집 리스트! 유튜브 영상으로 검증된 맛집 위치와 메뉴를 확인하고, 길찾기 기능으로 가까운 맛집에 방문해보세요!`;
  const imageUrl = 'https://mat-gil.vercel.app/images/logo.png';

  return {
    title,
    description,
    openGraph: {
      title,
      description,
      url: `https://mat-gil.vercel.app/${channel}`,
      images: {
        url: imageUrl,
        width: 1200,
        height: 630,
      },
      type: 'website',
      siteName: '맛길',
    },
    twitter: {
      card: 'summary_large_image',
      title,
      description,
      images: { url: imageUrl },
    },
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1740738502127&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// /app/[channel]/[id]/page.tsx

export async function generateMetadata({
  params,
}: {
  params: Promise&amp;lt;{ channel: string; id: string }&amp;gt;;
}): Promise&amp;lt;Metadata&amp;gt; {
  const { channel, id } = await params;
  const {
    data: { list },
  } = await axios.get(`${channel}/${id}`);
  const { title: restaurant } = list;

  const title = `맛길 | 맛집 추천 &amp;amp; 길찾기 | ${channelMap[channel]}`;
  const description = `${restaurant}의 메뉴와 위치를 확인하고, 길찾기 기능을 통해 해당 매장까지 간편하게 찾아가세요!`;
  const imageUrl = 'https://mat-gil.vercel.app/images/logo.png';

  return {
    title,
    description,
    openGraph: {
      title,
      description,
      url: `https://mat-gil.vercel.app/${channel}/${id}`,
      images: [
        {
          url: imageUrl,
          width: 1200,
          height: 630,
        },
      ],
      type: 'website',
      siteName: '맛길',
    },
    twitter: {
      card: 'summary_large_image',
      title,
      description,
      images: { url: imageUrl },
    },
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;595&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btMDch/btsMyU6Wylf/IAOigwGnHuBmuaOwLEEdF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btMDch/btsMyU6Wylf/IAOigwGnHuBmuaOwLEEdF1/img.png&quot; data-alt=&quot;mat-gil.vercel.app/pungja - 메타 데이터&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btMDch/btsMyU6Wylf/IAOigwGnHuBmuaOwLEEdF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtMDch%2FbtsMyU6Wylf%2FIAOigwGnHuBmuaOwLEEdF1%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;595&quot; height=&quot;480&quot; data-origin-width=&quot;595&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;mat-gil.vercel.app/pungja - 메타 데이터&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;591&quot; data-origin-height=&quot;484&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dkAvSh/btsMAWbiGOH/Wd1iecktMYdu2ANUTQFX0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dkAvSh/btsMAWbiGOH/Wd1iecktMYdu2ANUTQFX0K/img.png&quot; data-alt=&quot;mat-gil.vercel.app/seongsigyeong/0 - 메타 데이터&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dkAvSh/btsMAWbiGOH/Wd1iecktMYdu2ANUTQFX0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdkAvSh%2FbtsMAWbiGOH%2FWd1iecktMYdu2ANUTQFX0K%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;591&quot; height=&quot;484&quot; data-origin-width=&quot;591&quot; data-origin-height=&quot;484&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;mat-gil.vercel.app/seongsigyeong/0 - 메타 데이터&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;메타 데이터가 페이지에 따라서 동적으로 잘 생성 됩니다. 추가로 openGraph도 설정했으니, 공유 디버거와 실제 링크 공유를 통해 미리보기 화면도 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;796&quot; data-origin-height=&quot;803&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qGjSg/btsMAFt86aq/KsO3lksvtFEx6O4ei98sak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qGjSg/btsMAFt86aq/KsO3lksvtFEx6O4ei98sak/img.png&quot; data-alt=&quot;kakao developers - 공유 디버거&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qGjSg/btsMAFt86aq/KsO3lksvtFEx6O4ei98sak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqGjSg%2FbtsMAFt86aq%2FKsO3lksvtFEx6O4ei98sak%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;796&quot; height=&quot;803&quot; data-origin-width=&quot;796&quot; data-origin-height=&quot;803&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;kakao developers - 공유 디버거&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;797&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KQe4L/btsMzZft2Ru/XkcOOInkUnONRn0kizStRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KQe4L/btsMzZft2Ru/XkcOOInkUnONRn0kizStRk/img.png&quot; data-alt=&quot;meta developers - 공유 디버거&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KQe4L/btsMzZft2Ru/XkcOOInkUnONRn0kizStRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKQe4L%2FbtsMzZft2Ru%2FXkcOOInkUnONRn0kizStRk%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;940&quot; height=&quot;797&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;797&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;meta developers - 공유 디버거&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1134&quot; data-origin-height=&quot;1016&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDEg2a/btsMx2q7bTk/KuGTkkIF6BVwmLTsWAi7a1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDEg2a/btsMx2q7bTk/KuGTkkIF6BVwmLTsWAi7a1/img.png&quot; data-alt=&quot;카카오톡 링크 공유 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDEg2a/btsMx2q7bTk/KuGTkkIF6BVwmLTsWAi7a1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDEg2a%2FbtsMx2q7bTk%2FKuGTkkIF6BVwmLTsWAi7a1%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;389&quot; height=&quot;349&quot; data-origin-width=&quot;1134&quot; data-origin-height=&quot;1016&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;카카오톡 링크 공유 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;접근성 &amp;amp; SEO 개선 결과&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;284&quot; data-origin-height=&quot;142&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0fRwI/btsMy3o8l0c/cBksfpS53Eip3fj8LCtuX1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0fRwI/btsMy3o8l0c/cBksfpS53Eip3fj8LCtuX1/img.png&quot; data-alt=&quot;접근성, SEO 개선 후 측정 지표&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0fRwI/btsMy3o8l0c/cBksfpS53Eip3fj8LCtuX1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0fRwI%2FbtsMy3o8l0c%2FcBksfpS53Eip3fj8LCtuX1%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;284&quot; height=&quot;142&quot; data-origin-width=&quot;284&quot; data-origin-height=&quot;142&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;접근성, SEO 개선 후 측정 지표&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;452&quot; data-origin-height=&quot;280&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/E2xiy/btsMFF1M9Nk/a8DMPw1kbCtcI10wKA4R2k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/E2xiy/btsMFF1M9Nk/a8DMPw1kbCtcI10wKA4R2k/img.png&quot; data-alt=&quot;구글 서치콘솔 - 평균 게재순위&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/E2xiy/btsMFF1M9Nk/a8DMPw1kbCtcI10wKA4R2k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FE2xiy%2FbtsMFF1M9Nk%2Fa8DMPw1kbCtcI10wKA4R2k%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;452&quot; height=&quot;280&quot; data-origin-width=&quot;452&quot; data-origin-height=&quot;280&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;구글 서치콘솔 - 평균 게재순위&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;672&quot; data-origin-height=&quot;318&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNWkB9/btsMMz9R9x1/KbbLcRJHyHrpWzD4n4FsH0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNWkB9/btsMMz9R9x1/KbbLcRJHyHrpWzD4n4FsH0/img.png&quot; data-alt=&quot;검색 결과 - 리치 스니펫&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNWkB9/btsMMz9R9x1/KbbLcRJHyHrpWzD4n4FsH0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNWkB9%2FbtsMMz9R9x1%2FKbbLcRJHyHrpWzD4n4FsH0%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;672&quot; height=&quot;318&quot; data-origin-width=&quot;672&quot; data-origin-height=&quot;318&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;검색 결과 - 리치 스니펫&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이번 프로젝트는 접근성과 SEO 개선을 목표로 많은 노력을 기울였으며, 이 과정에서 많은 지식을 습득하고 문제 해결 능력을 키우는 값진 경험을 하였습니다. 문제를 해결하며 얻은 지식은 단순히 기술적인 부분에 그치지 않고, 사용자 중심의 사고방식을 배우는 기회가 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Next.js</category>
      <category>Robots</category>
      <category>SEO</category>
      <category>sitemap</category>
      <category>검색엔진 최적화</category>
      <category>맛길</category>
      <category>사이드 프로젝트</category>
      <category>접근성</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/190</guid>
      <comments>https://dev-hpk.tistory.com/190#entry190comment</comments>
      <pubDate>Fri, 28 Feb 2025 20:13:06 +0900</pubDate>
    </item>
    <item>
      <title>[맛길] 안드로이드(AOS) 위치 정보(Geolocation API) 지연 문제 해결</title>
      <link>https://dev-hpk.tistory.com/189</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;사이트 배포 후 모바일 디바이스(AOS, IOS)에서 아래 브라우저들을 통해 길 찾기 기능을 테스트했습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Chrome (IOS, AOS)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Naver 앱 (IOS, AOS)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;카카오 인 앱 브라우저 (IOS, AOS)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Firefox (IOS, AOS)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Edge (IOS, AOS)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Opera (IOS, AOS)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;safari (IOS)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;삼성 인터넷(AOS)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;IOS에서는 길 찾기가 정상적으로 동작하는 반면, AOS( Android OS)의 경우 경로를 받기까지 매우 오래 걸리는 문제가 발생했습니다. 테스트에 사용한 기종이 오래된 갤럭시 S10이라는 의심이 들어, 가장 최신 기종인 갤럭시 s25로도 테스트해봤지만 결과는 같았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;혹시 서버에서 응답이 늦게 오는건 아닐까 하는 생각에 Vercel의 로그도 확인해 봤지만, 실행 시간(Excution Duration)이 모두 1초 미만의 짧은 시간입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;화면 캡처 2025-02-26 141110.png&quot; data-origin-width=&quot;847&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bA2tKE/btsMxnVpvM7/Yk6HjsvX55cwlBag3y7Zk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bA2tKE/btsMxnVpvM7/Yk6HjsvX55cwlBag3y7Zk1/img.png&quot; data-alt=&quot;Vercel의 서버 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bA2tKE/btsMxnVpvM7/Yk6HjsvX55cwlBag3y7Zk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbA2tKE%2FbtsMxnVpvM7%2FYk6HjsvX55cwlBag3y7Zk1%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;847&quot; height=&quot;420&quot; data-filename=&quot;화면 캡처 2025-02-26 141110.png&quot; data-origin-width=&quot;847&quot; data-origin-height=&quot;420&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Vercel의 서버 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;서버(getRoute API)에 이상이 없다면, 남은&amp;nbsp;문제는&amp;nbsp;AOS(Android&amp;nbsp;OS)에서&amp;nbsp;Geolocation&amp;nbsp;API의&amp;nbsp;응답이&amp;nbsp;지연되는&amp;nbsp;것입니다.&lt;/span&gt;&lt;/p&gt;

            &lt;figure class=&quot;unsupported component-kakaotv&quot; contenteditable=&quot;false&quot; style=&quot;background:#000;margin:16px 0;min-height:72px;padding:10px 16px;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;width:100%;max-width:100%;&quot;&gt;
                &lt;p contenteditable=&quot;false&quot; style=&quot;margin:0;color:#8a8a8a;font-size:13px;line-height:1.6;user-select:none;pointer-events:none;&quot;&gt;동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.&lt;/p&gt;
            &lt;/figure&gt;
        
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 영상은 Chrome inspector를 이용해서 디버깅한 갤럭시 s10으로 길 찾기 기능을 실행한 결과입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;영상에서 확인할 수 있듯이, 처음으로 경로 데이터를 요청할 때는 정상적으로 작동합니다. 그러나 이후에 길 찾기 기능을 이용하면 경로 요청이 매우 오랜 시간이 지난 후에야 이루어집니다. 영상을 2배속으로 편집해 비교적 짧게 느껴질 수 있지만, Geolocation API를 통해 위치 정보를 받아오는 데 약 25초가 소요되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;여러 브라우저로 테스트를 반복하던 중 Firefox로 테스트를 진행했을 때만 지연 없이 정상적으로 동작했고, 앱의 위치 액세스 권한을 확인하고 나서 차이를 알게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot_20250226_134859_Permission controller-min.jpg&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;2309&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAbNAZ/btsMvzimolz/6771DHS1lBVakbfwqHGS10/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAbNAZ/btsMvzimolz/6771DHS1lBVakbfwqHGS10/img.jpg&quot; data-alt=&quot;Firefox 위치 액세스 권한 설정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAbNAZ/btsMvzimolz/6771DHS1lBVakbfwqHGS10/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAbNAZ%2FbtsMvzimolz%2F6771DHS1lBVakbfwqHGS10%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;293&quot; height=&quot;470&quot; data-filename=&quot;Screenshot_20250226_134859_Permission controller-min.jpg&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;2309&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Firefox 위치 액세스 권한 설정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Firefox의 위치 액세스 설정에만 '정확한 위치 사용' 옵션이 없습니다. 이를 바탕으로 다른 브라우저에서도 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;'정확한 위치 사용'&lt;/span&gt; 옵션을 끄고 테스트해보니, 정확도는 떨어지지만 정상적으로 동작하는 것을 확인했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;상황을 개선하기 위해, 사용자에게 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;'정확한 위치 사용'&lt;/span&gt; 옵션을 활성화한 경우 위치 조회가 오래 걸릴 수 있다는 알림을 띄우는 방법을 고려했지만, 아래와 같은 이슈가 발생했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;화면 캡처 2025-02-26 143024-min.png&quot; data-origin-width=&quot;632&quot; data-origin-height=&quot;327&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bwWd0a/btsMyHyznd2/fZCsxKxkW28JKk1CK4UcXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bwWd0a/btsMyHyznd2/fZCsxKxkW28JKk1CK4UcXK/img.png&quot; data-alt=&quot;정확한 위치 사용 옵션 on/off 비교&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bwWd0a/btsMyHyznd2/fZCsxKxkW28JKk1CK4UcXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbwWd0a%2FbtsMyHyznd2%2FfZCsxKxkW28JKk1CK4UcXK%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;632&quot; height=&quot;327&quot; data-filename=&quot;화면 캡처 2025-02-26 143024-min.png&quot; data-origin-width=&quot;632&quot; data-origin-height=&quot;327&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;정확한 위치 사용 옵션 on/off 비교&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 사진은 정확한 위치 사용 옵션의 on/off 상태일 때 길 찾기 결과와, 네이버 지도에서 검색한 두 시작 지점의 자동차를 이용한 거리입니다.&amp;nbsp;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;'정확한 위치 사용'&lt;/span&gt; 옵션을 끄면 지연 없이 정상적으로 동작하지만, 사용자 입장에서는 내 위치가 아닌 다른 위치가 시작점으로 선택되기 때문에 신뢰성이 떨어져 서비스를 이용하지 않을 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;또한 아이폰에서는 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;'정확한 위치 사용'&lt;/span&gt; 옵션을 활성화해도 지연 없이 정상 동작하기 때문에, 이 방법은 갤럭시 유저들에게만 불편한 경험을 제공할 수 있으며, 완전한 해결 방법이 아닙니다. 따라서, 보다 효과적이고 포괄적인 해결책을 찾는 것이 필요해 보입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Geolocation API의 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;enableHighAccuracy&lt;/span&gt;와 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;maximumAge&lt;/span&gt;을 설정한 후 확인해보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;options-min.PNG&quot; data-origin-width=&quot;738&quot; data-origin-height=&quot;294&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RaTAF/btsMx9oT4Tv/5bfJK5RoR11YSsYueFh7q0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RaTAF/btsMx9oT4Tv/5bfJK5RoR11YSsYueFh7q0/img.png&quot; data-alt=&quot;Geolocation getCurrentPosition 옵션 - 출처 MDN&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RaTAF/btsMx9oT4Tv/5bfJK5RoR11YSsYueFh7q0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRaTAF%2FbtsMx9oT4Tv%2F5bfJK5RoR11YSsYueFh7q0%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;738&quot; height=&quot;294&quot; data-filename=&quot;options-min.PNG&quot; data-origin-width=&quot;738&quot; data-origin-height=&quot;294&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Geolocation getCurrentPosition 옵션 - 출처 MDN&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1740552824460&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const position = await new Promise&amp;lt;GeolocationPosition&amp;gt;(
  (resolve, reject) =&amp;gt; {
    // performance.now : 밀리초 단위의 고해상도 타임스탬프를 반환
    // 정확한 측정을 위해 Date.now 대신 사용
    const startTime = performance.now(); 

    navigator.geolocation.getCurrentPosition(
      (position) =&amp;gt; {
        const endTime = performance.now();

        console.log(
          `[디버깅] 위치 요청 성공 - 소요 시간: ${endTime - startTime}ms`,
        );
        console.log('[디버깅] 위치 정보', {
          latitude: position.coords.latitude,
          longitude: position.coords.longitude,
        });

        resolve(position);
      },
      (error) =&amp;gt; {
        reject(error);
      },
      {
        enableHighAccuracy: true, // 위치정보를 가장 높은 정확도로 수신
        maximumAge: 0, // default가 0이지만 캐싱하지 않기 위해 명시
      },
    );
  },
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Geolocation&amp;nbsp;API가&amp;nbsp;반환하는&amp;nbsp;위치&amp;nbsp;정보뿐만&amp;nbsp;아니라,&amp;nbsp;실제&amp;nbsp;Geolocation의&amp;nbsp;소요&amp;nbsp;시간을&amp;nbsp;로그로&amp;nbsp;기록하여&amp;nbsp;지연&amp;nbsp;문제가&amp;nbsp;해결되었는지&amp;nbsp;확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;aos_start-min.png&quot; data-origin-width=&quot;518&quot; data-origin-height=&quot;203&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vgKAQ/btsMwrD9iSe/N7CxQkFudIO0wAk4WLp7D0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vgKAQ/btsMwrD9iSe/N7CxQkFudIO0wAk4WLp7D0/img.png&quot; data-alt=&quot;최초 Geolocation API를 이용한 위치 정보 반환 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vgKAQ/btsMwrD9iSe/N7CxQkFudIO0wAk4WLp7D0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvgKAQ%2FbtsMwrD9iSe%2FN7CxQkFudIO0wAk4WLp7D0%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;432&quot; height=&quot;169&quot; data-filename=&quot;aos_start-min.png&quot; data-origin-width=&quot;518&quot; data-origin-height=&quot;203&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;최초 Geolocation API를 이용한 위치 정보 반환 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;aos_end-min.png&quot; data-origin-width=&quot;407&quot; data-origin-height=&quot;576&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/buXC6s/btsMvO7FmWp/z7vmDGs1X2kExKHdhSAMl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/buXC6s/btsMvO7FmWp/z7vmDGs1X2kExKHdhSAMl1/img.png&quot; data-alt=&quot;Geolocation API를 이용한 위치 정보 반환 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/buXC6s/btsMvO7FmWp/z7vmDGs1X2kExKHdhSAMl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbuXC6s%2FbtsMvO7FmWp%2Fz7vmDGs1X2kExKHdhSAMl1%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;350&quot; height=&quot;495&quot; data-filename=&quot;aos_end-min.png&quot; data-origin-width=&quot;407&quot; data-origin-height=&quot;576&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Geolocation API를 이용한 위치 정보 반환 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 사진은 Chrome inspector를 이용해 디버깅해본 결과입니다. 초기 Geolocation API의 응답을 받는 데 걸리는 시간은 1.5초입니다. 이후 여러 번 위치 정보를 요청했을 때 대략 3.5초 정도의 시간이 걸렸습니다. 3.5초도&amp;nbsp;짧은&amp;nbsp;시간은&amp;nbsp;아니지만,&amp;nbsp;초기&amp;nbsp;25초에&amp;nbsp;비하면&amp;nbsp;약&amp;nbsp;&lt;b&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;7배&amp;nbsp;정도의&amp;nbsp;시간이&amp;nbsp;단축&lt;/span&gt;&lt;/b&gt;된&amp;nbsp;것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;다른 브라우저들과 IOS에서 테스트해도 이상 없이 잘 동작합니다. 정확한 원인을 찾기 위해서 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;enableHighAccuracy&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;와&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;maximumAge&lt;/span&gt;을 하나씩 제거하면서 테스트를 진행해 보겠습니다. 공식 문서에 따르면 maximumAge 옵션은 default가 0이기 때문에 먼저 제거하고 테스트를 진행하겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;488&quot; data-origin-height=&quot;405&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rkhCE/btsMwb2zKgE/SARlu2DZP6KFfmBJawB2Nk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rkhCE/btsMwb2zKgE/SARlu2DZP6KFfmBJawB2Nk/img.png&quot; data-alt=&quot;maximumAge 옵션 제거 후 디버깅 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rkhCE/btsMwb2zKgE/SARlu2DZP6KFfmBJawB2Nk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrkhCE%2FbtsMwb2zKgE%2FSARlu2DZP6KFfmBJawB2Nk%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;439&quot; height=&quot;364&quot; data-origin-width=&quot;488&quot; data-origin-height=&quot;405&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;maximumAge 옵션 제거 후 디버깅 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;maximumAge 옵션을 제거해도 Geolocation API의 응답 지연 없이 잘 동작합니다. 결론적으로 문제의 원은&amp;nbsp; &lt;span style=&quot;background-color: #dddddd; color: #333333; text-align: start;&quot;&gt;enableHighAccuracy&lt;span style=&quot;background-color: #ffffff;&quot;&gt; 옵션이었던 것으로 보입니다. 해당 내용은 조사를 통해서 자세히 알아보도록 하겠습니다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이번&amp;nbsp;안드로이드(AOS)&amp;nbsp;위치&amp;nbsp;정보(Geolocation&amp;nbsp;API)&amp;nbsp;지연&amp;nbsp;문제를&amp;nbsp;해결하기&amp;nbsp;위해&amp;nbsp;며칠&amp;nbsp;동안&amp;nbsp;다양한&amp;nbsp;방법을&amp;nbsp;시도했는데,&amp;nbsp;결국&amp;nbsp;옵션&amp;nbsp;하나를&amp;nbsp;추가하는&amp;nbsp;것으로&amp;nbsp;해결되었다는&amp;nbsp;점이&amp;nbsp;조금&amp;nbsp;허탈하게&amp;nbsp;느껴지긴&amp;nbsp;하지만,&amp;nbsp;그&amp;nbsp;과정에서&amp;nbsp;많은&amp;nbsp;것을&amp;nbsp;배운&amp;nbsp;것&amp;nbsp;같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣ &lt;b&gt;플랫폼(OS, 브라우저) 별 특성을 고려하자&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;같은 코드라도 OS와 브라우저에 따라 다르게 동작할 수 있다는 점을 다시금 실감했습니다. &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;iOS와 AOS에서 geolocation 동작 방식이 다르다&lt;/b&gt;&lt;/span&gt;는 걸 사전에 인지했더라면 문제를 더 빠르게 해결할 수 있었을 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ &lt;b&gt;디버깅을 통한 문제 확인을 습관화하자&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;처음에는 네트워크 문제, 브라우저 문제 등을 의심하며 여러 가지 시도를 했지만, 문제를 해결하지 못했습니다. 이후&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt; 디버깅을 통해 문제를 직접 확인하고 정확한 원인을 파악해 해결&lt;/b&gt;&lt;/span&gt;할 수 있었습니다. 이를 통해 &lt;b&gt;디버깅&lt;/b&gt; 과정이 무엇보다 중요하다는 점을 다시 한번 깨달았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;MDN의 Geolocation 공식 문서에 따르면 &lt;span style=&quot;background-color: #dddddd; color: #333333; text-align: start;&quot;&gt;enableHighAccuracy&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&amp;nbsp;&lt;/span&gt; 옵션을 true로 설정하면 응답 속도가 느려지며 전력 소모량이 증가한다는 점에서 완벽한 해결책은 아니라고 생각합니다. 이러한&amp;nbsp;경험을&amp;nbsp;통해&amp;nbsp;앞으로도&amp;nbsp;플랫폼별&amp;nbsp;차이를&amp;nbsp;고려하면서&amp;nbsp;문제를&amp;nbsp;해결하는&amp;nbsp;능력을&amp;nbsp;더욱&amp;nbsp;키워야겠다는&amp;nbsp;생각이&amp;nbsp;들었습니다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>AoS</category>
      <category>geolocation</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>맛길</category>
      <category>사이드 프로젝트</category>
      <category>안드로이드</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/189</guid>
      <comments>https://dev-hpk.tistory.com/189#entry189comment</comments>
      <pubDate>Wed, 26 Feb 2025 17:12:57 +0900</pubDate>
    </item>
    <item>
      <title>[맛길] Naver Directions API를 이용한 길 찾기 기능 개발</title>
      <link>https://dev-hpk.tistory.com/188</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;현재 Naver Map API를 이용해 맛집 가게의 지도를 불러오고 마커를 찍는 기능까지 구현했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;379&quot; data-origin-height=&quot;695&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDQnZS/btsMrwc9SNi/Y1jUVBQi7I9Q0R8KywJ4B1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDQnZS/btsMrwc9SNi/Y1jUVBQi7I9Q0R8KywJ4B1/img.png&quot; data-alt=&quot;맛길 서비스&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDQnZS/btsMrwc9SNi/Y1jUVBQi7I9Q0R8KywJ4B1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDQnZS%2FbtsMrwc9SNi%2FY1jUVBQi7I9Q0R8KywJ4B1%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;379&quot; height=&quot;695&quot; data-origin-width=&quot;379&quot; data-origin-height=&quot;695&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;맛길 서비스&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;지인들에게 테스트와 피드백을 부탁했습니다.&amp;nbsp;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;s&gt;모바일 디바이스에서 터치로 화면을 움직일 때 지도가 움직여서 불편하다.&lt;/s&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;맛길 서비스 내에 지도에서 길 찾기 기능을 사용할 수 있으면 좋을 것 같다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;첫 번째 피드백을 토대로 테스트 해보니 모바일 디바이스에서 지도 영역의 인터랙션 때문에 화면을 스크롤하기가 불편했습니다. 이 부분은 사용자 입장에서 부정적인 경험이라고 생각해 인터랙션 옵션을 제거하는 방법으로 수정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;두 번째 피드백인 길찾기 기능입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;사용자 입장에서 생각해보니 맛길 서비스에서 맛집의 위치를 찾아도 별도의 길 찾기 서비스를 이용해야 한다는 점에서 번거로울 것 같습니다. 이런 불편함을 제거하고 사용자 경험을 개선하기 위해서는 맛길 서비스 자체에 길 찾기 기능을 추가하는 방법을 검토해봐야 할 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Naver Directions API&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;NAVER CLOUD PLATFORM을 확인해보니 입력 정보를 기반으로 자동차 통행 정보(소요 시간, 거리)를 조회하는 Directions 5 API를 제공하고 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1740102249254&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Directions 5 API&quot; data-og-description=&quot; &quot; data-og-host=&quot;api.ncloud-docs.com&quot; data-og-source-url=&quot;https://api.ncloud-docs.com/docs/ai-naver-mapsdirections-driving&quot; data-og-url=&quot;https://api.ncloud-docs.com/docs/ai-naver-mapsdirections-driving&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://api.ncloud-docs.com/docs/ai-naver-mapsdirections-driving&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://api.ncloud-docs.com/docs/ai-naver-mapsdirections-driving&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Directions 5 API&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;api.ncloud-docs.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;프로젝트에 길찾기 기능을 도입하기에 앞서 API 응답이 잘 받아와 지는지 먼저 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;길 찾기 API 구현&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;639&quot; data-origin-height=&quot;596&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cQIueL/btsMqxRvLWi/JixJFagcqJc1GvaWLJ4hrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cQIueL/btsMqxRvLWi/JixJFagcqJc1GvaWLJ4hrK/img.png&quot; data-alt=&quot;Naver Map API 공통 헤더&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cQIueL/btsMqxRvLWi/JixJFagcqJc1GvaWLJ4hrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcQIueL%2FbtsMqxRvLWi%2FJixJFagcqJc1GvaWLJ4hrK%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;639&quot; height=&quot;596&quot; data-origin-width=&quot;639&quot; data-origin-height=&quot;596&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Naver Map API 공통 헤더&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1740104309275&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const getRoute = async (start: string, goal: string) =&amp;gt; {
  const response = await axios.get(
    `https://naveropenapi.apigw.ntruss.com/map-direction/v1/driving/`,
    {
      params: { start, goal },
      headers: {
        'x-ncp-apigw-api-key-id': process.env.NEXT_PUBLIC_NAVER_CLIENT_ID,
        'x-ncp-apigw-api-key': process.env.NEXT_PUBLIC_NAVER_CLIENT_SECRET,
      },
    },
  );

  return response.data;
};

export default getRoute;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;789&quot; data-origin-height=&quot;113&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKbiUs/btsMo70yRE4/fBov0Yg0HN20jzw8rdZQI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKbiUs/btsMo70yRE4/fBov0Yg0HN20jzw8rdZQI0/img.png&quot; data-alt=&quot;에러 메시지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKbiUs/btsMo70yRE4/fBov0Yg0HN20jzw8rdZQI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKbiUs%2FbtsMo70yRE4%2FfBov0Yg0HN20jzw8rdZQI0%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;789&quot; height=&quot;113&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;789&quot; data-origin-height=&quot;113&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;에러 메시지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; CORS 에러(No 'Access-Control-Allow-Origin' header)&lt;/b&gt;가 발생했습니다. 에러 메시지에도 나와있듯이 로컬 주소인 3000번 포트에서 naveropenapi로 리소스를 요청했으니 당연한 결과입니다!&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;CORS : &lt;/b&gt;한 도메인이 도메인 간의 요청을 가진 다른 도메인의 리소스에 액세스 할 수 있게 해주는 보안 메커니즘&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;자세한 내용은 정리를 잘해주신 내용이 있어 첨부하니 블로그를 참고해 주세요.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;https://velog.io/@sebinn/CORS-%EB%B0%9C%EC%83%9D-%EC%9B%90%EC%9D%B8%EA%B3%BC-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;CORS 발생 원인과 해결 방법 - 출처 sebinnnnn.log&lt;/a&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;CORS 에러 해결&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;946&quot; data-origin-height=&quot;116&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsulfP/btsMp8xKK4j/YE82NR8jB929IgK43sl4K0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsulfP/btsMp8xKK4j/YE82NR8jB929IgK43sl4K0/img.png&quot; data-alt=&quot;Naver Cloud Platform - 답변 내용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsulfP/btsMp8xKK4j/YE82NR8jB929IgK43sl4K0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsulfP%2FbtsMp8xKK4j%2FYE82NR8jB929IgK43sl4K0%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;946&quot; height=&quot;116&quot; data-origin-width=&quot;946&quot; data-origin-height=&quot;116&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Naver Cloud Platform - 답변 내용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;네이버에서 Backend 서버에서 API를 호출하라고 방법을 제시하고 있습니다. 맛길 프로젝트는 Serverless Function을 사용하고 있으니 쉽게 해결할 수 있을 것 같습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Directions API 프록시 생성 - Vercel Serverless Function (/api/directions/routs.ts)&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1740106166526&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from 'axios';
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const start = searchParams.get('start');
  const goal = searchParams.get('goal');

  // 출발지나 목적지가 없는 경우 400 에러 반환
  if (!start || !goal) {
    return NextResponse.json(
      { error: '출발지와 목적지를 확인해주세요.' },
      { status: 400 },
    );
  }

  try {
    // Naver Directions API 호출
    const res = await axios.get(
      `https://naveropenapi.apigw.ntruss.com/map-direction/v1/driving`,
      {
        params: { start, goal },
        headers: {
          'x-ncp-apigw-api-key-id': process.env.NEXT_PUBLIC_NAVER_CLIENT_ID,
          'x-ncp-apigw-api-key': process.env.NEXT_PUBLIC_NAVER_CLIENT_SECRET,
        },
      },
    );

    return NextResponse.json(res.data, {
      status: 200,
      // CORS 설정 (모든 도메인에 대해 API 요청 허용)
      headers: { 'Access-Control-Allow-Origin': '*' },
    });
  } catch (error) {
    const errorMessage = (error as Error).message;
    return NextResponse.json(
      { error: `길찾기에 실패했습니다: ${errorMessage}` },
      { status: 500 },
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;클라이언트에서 Naver Directions API 호출&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1740109473035&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from '@/app/lib/instance';

const getRoute = async (start: string, goal: string) =&amp;gt; {
  const res = await axios.get('directions', {
    params: { start, goal },
  });

  return res.data;
};

export default getRoute;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;getRoute 함수의 start와 goal 파라미터에 출발지와 목적지의 [경도, 위도]를 입력하고 동작을 확인해 보겠습니다. 동작은 임시로 버튼을 하나 만들고 클릭 이벤트 핸들러에서 호출하도록 작업해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;813&quot; data-origin-height=&quot;110&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzBN9c/btsMqwFgJJw/xHRFKA1ibH3IzjmNsy9KD0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzBN9c/btsMqwFgJJw/xHRFKA1ibH3IzjmNsy9KD0/img.png&quot; data-alt=&quot;API 응답 헤더&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzBN9c/btsMqwFgJJw/xHRFKA1ibH3IzjmNsy9KD0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbzBN9c%2FbtsMqwFgJJw%2FxHRFKA1ibH3IzjmNsy9KD0%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;813&quot; height=&quot;110&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;813&quot; data-origin-height=&quot;110&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;API 응답 헤더&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;API 요청이 성공했습니다!&amp;nbsp; 응답으로 받은 데이터에서 길찾기에 사용할 정보를 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;740&quot; data-origin-height=&quot;336&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDP9ay/btsMrZTV4ZF/A7WV74CSMMXlbKqQ47NzHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDP9ay/btsMrZTV4ZF/A7WV74CSMMXlbKqQ47NzHk/img.png&quot; data-alt=&quot;API 응답 데이터&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDP9ay/btsMrZTV4ZF/A7WV74CSMMXlbKqQ47NzHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDP9ay%2FbtsMrZTV4ZF%2FA7WV74CSMMXlbKqQ47NzHk%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;740&quot; height=&quot;336&quot; data-origin-width=&quot;740&quot; data-origin-height=&quot;336&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;API 응답 데이터&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;path가 경로를 구성하는 (경도,위도) 좌표 배열이네요. 경로는 Naver Map API의 polyline을 이용해 그리면 될 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;395&quot; data-origin-height=&quot;394&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qXEym/btsMqw6nBcQ/ARzxMbAwB69kkpKD4XOwEK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qXEym/btsMqw6nBcQ/ARzxMbAwB69kkpKD4XOwEK/img.png&quot; data-alt=&quot;polyline 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qXEym/btsMqw6nBcQ/ARzxMbAwB69kkpKD4XOwEK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqXEym%2FbtsMqw6nBcQ%2FARzxMbAwB69kkpKD4XOwEK%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;395&quot; height=&quot;394&quot; data-origin-width=&quot;395&quot; data-origin-height=&quot;394&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;polyline 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;558&quot; data-origin-height=&quot;296&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ubGrz/btsMrvZVQvj/fguk9hhoUEOL8QYvK1ySvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ubGrz/btsMrvZVQvj/fguk9hhoUEOL8QYvK1ySvK/img.png&quot; data-alt=&quot;polyline 사용법&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ubGrz/btsMrvZVQvj/fguk9hhoUEOL8QYvK1ySvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FubGrz%2FbtsMrvZVQvj%2Ffguk9hhoUEOL8QYvK1ySvK%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;558&quot; height=&quot;296&quot; data-origin-width=&quot;558&quot; data-origin-height=&quot;296&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;polyline 사용법&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;distacne, duration, taxiFare 데이터도 사용자에게 맛집까지의 거리에 대한 정보를 안내하는데 큰 도움이 될 것 같습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;  &lt;b&gt;길찾기 서비스를 개발하면서 느낀 점&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;Directions API&lt;/b&gt;를 활용해 길 찾기 서비스를 구현하면서 단순히 API를 호출하는 것뿐만 아니라, &lt;b&gt;API 설계부터 CORS 문제 해결까지 직접 경험&lt;/b&gt;하면서 백엔드와의 통신 구조를 깊이 고민하게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1171&quot; data-start=&quot;1006&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1171&quot; data-start=&quot;1006&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;처음에는 클라이언트에서 직접 네이버 API를 호출했는데 브라우저에서 CORS 에러가 발생해 요청이 차단되었습니다. 네이버 API가 Access-Control-Allow-Origin 헤더를 제공하지 않기 때문에 이를 해결하려면 &lt;b&gt;프록시 서버를 활용하거나 서버리스 함수를 사용해야 했습니다&lt;/b&gt;. &lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1171&quot; data-start=&quot;1006&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Next.js의 API Routes를 이용해 /api/directions라는 서버 프록시를 만들고, 서버에서 네이버 API를 호출하도록 변경해 &lt;b&gt;CORS 문제&lt;/b&gt;를 해결할 수 있었습니다. 이 과정에서 웹 보안과 네트워크 통신에 대한 이해의 중요성을 다시 한번 느끼게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1171&quot; data-start=&quot;1006&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 단순히 화면을 만드는 것이 아니라, 백엔드와의 협업을 고려해 &lt;b&gt;API의 흐름을 설계하고 최적화하는 과정까지 고려하는 것이 진짜 프론트엔드 개발자의 역할&lt;/b&gt;이라는 걸 조금이나마 체감할 수 있는 좋은 경험이었던 것 같습니다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>CORS</category>
      <category>naver map api</category>
      <category>Next.js</category>
      <category>네이버 지도 api</category>
      <category>사이드 프로젝트</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/188</guid>
      <comments>https://dev-hpk.tistory.com/188#entry188comment</comments>
      <pubDate>Fri, 21 Feb 2025 14:07:24 +0900</pubDate>
    </item>
    <item>
      <title>[Coworkers] 접근 권한 관련 이슈 해결</title>
      <link>https://dev-hpk.tistory.com/187</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;멤버 관련 테스트 작업을 진행하던 중 이슈를 발견했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; &amp;nbsp;&lt;/b&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 팀의 멤버가 아닌 유저가 팀 페이지(/[teamid])와 할 일 목록(/[teamid]/[tasklist])에 접근 및 &lt;b&gt;추가/수정/삭제&lt;/b&gt;를 할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 팀의 멤버가 아닌 사용자가 해당 페이지에 접근할 수 있는 유일한 방법은 &lt;b&gt;URL을 직접 입력하는 것&lt;/b&gt;입니다. 하지만 URL을 직접 입력하여 접근한 후 수정/삭제 등의 작업을 시도할 가능성도 존재합니다. 이를 방지하기 위해 추가적인 보안 조치가 필요해 보입니다. &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;941&quot; data-origin-height=&quot;508&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcv7x9/btsMjoHKojx/lopGAgjKNQIN4Vl7uGi2u0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcv7x9/btsMjoHKojx/lopGAgjKNQIN4Vl7uGi2u0/img.png&quot; data-alt=&quot;접근 권한 이슈 - 팀 페이지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcv7x9/btsMjoHKojx/lopGAgjKNQIN4Vl7uGi2u0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbcv7x9%2FbtsMjoHKojx%2FlopGAgjKNQIN4Vl7uGi2u0%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;941&quot; height=&quot;508&quot; data-origin-width=&quot;941&quot; data-origin-height=&quot;508&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;접근 권한 이슈 - 팀 페이지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;957&quot; data-origin-height=&quot;568&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dholoL/btsMjB77aST/i1LwnZqCQZL3I4hcKsIXhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dholoL/btsMjB77aST/i1LwnZqCQZL3I4hcKsIXhK/img.png&quot; data-alt=&quot;권한 이슈 - 멤버 아닌 유저 할 일 생성 가능&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dholoL/btsMjB77aST/i1LwnZqCQZL3I4hcKsIXhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdholoL%2FbtsMjB77aST%2Fi1LwnZqCQZL3I4hcKsIXhK%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;957&quot; height=&quot;568&quot; data-origin-width=&quot;957&quot; data-origin-height=&quot;568&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;권한 이슈 - 멤버 아닌 유저 할 일 생성 가능&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1341&quot; data-origin-height=&quot;290&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cvsmvu/btsMjNG3PUJ/m0k4aPnU1vRVROjAVU4KK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cvsmvu/btsMjNG3PUJ/m0k4aPnU1vRVROjAVU4KK1/img.png&quot; data-alt=&quot;권한 이슈 - 멤버 아닌 유저 할 일 수정 가능&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cvsmvu/btsMjNG3PUJ/m0k4aPnU1vRVROjAVU4KK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcvsmvu%2FbtsMjNG3PUJ%2Fm0k4aPnU1vRVROjAVU4KK1%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;1341&quot; height=&quot;290&quot; data-origin-width=&quot;1341&quot; data-origin-height=&quot;290&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;권한 이슈 - 멤버 아닌 유저 할 일 수정 가능&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1433&quot; data-origin-height=&quot;339&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/F7eTu/btsMlmBp9qI/tT3b7ooc5u4wQXL9CT3kSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/F7eTu/btsMlmBp9qI/tT3b7ooc5u4wQXL9CT3kSK/img.png&quot; data-alt=&quot;권한 이슈 - 멤버 아닌 유저 할 일 삭제 가능&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/F7eTu/btsMlmBp9qI/tT3b7ooc5u4wQXL9CT3kSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FF7eTu%2FbtsMlmBp9qI%2FtT3b7ooc5u4wQXL9CT3kSK%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;1433&quot; height=&quot;339&quot; data-origin-width=&quot;1433&quot; data-origin-height=&quot;339&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;권한 이슈 - 멤버 아닌 유저 할 일 삭제 가능&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;요청/추가/수정/삭제 동작이 모두 가능한 것을 보니, 서버에서 API 요청 시 팀 멤버 여부를 확인하는 로직이 없는 것 같습니다. 프론트엔드에서 처리할 수 있는 방법을 생각해 본 결과 아래 두 가지 정도가 있을 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣ 멤버가 아닌 유저에게는 추가/수정/삭제가 보이지 않도록 수정&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ 페이지 접근 시 유저가 팀의 멤버인지 확인하도록 수정 (멤버가 아닌 경우: 접근 차단 알림 후 메인 페이지로 이동)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀원들에게 이슈를 논의한 결과 만장일치로 2️⃣번이 선택되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀의 멤버인지 확인하는 로직을 추가하기 전에 차단 프로세스를 미리 계획해보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  해결 프로세스 - 멤버 아닌 경우 차단&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣ 유저가 팀 페이지(/[teamid]), 할 일 목록 페이지(/[teamid]/[tasklist]) 페이지에 접근&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ 유저가 팀의 멤버인지 확인&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;3️⃣ 팀의 멤버가 아닐 경우 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;&quot;접근 제한: 팀의 멤버가 아닙니다!&quot;&lt;/b&gt;&lt;/span&gt; 등의 메시지 노출&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;4️⃣ 메인 페이지(/)로 리다이렉트&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;✨ 멤버 확인 및 차단 로직 추가&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;/app/[teamid]/page.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739616713792&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { user } = useSelector((state: RootState) =&amp;gt; state.auth);
const router = useRouter();

const {
  data: groupData,
  isLoading,
  error,
} = useQuery({
  queryKey: ['group', groupId],
  queryFn: () =&amp;gt;
    groupId
      ? getGroup({ id: groupId })
      : Promise.reject(new Error('No ID provided')),
  enabled: !!groupId,
  staleTime: 0,
  refetchOnMount: 'always',
});

if (
  !isLoading &amp;amp;&amp;amp;
  groupData &amp;amp;&amp;amp;
  !groupData.members.some(({ userId }) =&amp;gt; userId === Number(user?.id))
) {
  alert('접근제한: 팀의 멤버가 아닙니다!');
  router.replace('/');
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;useSelector((state: RootState) =&amp;gt; state.auth)&lt;/b&gt; : Redux에서 전역으로 관리하는 유저 데이터&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;!isLoading &lt;/b&gt;: react-query의 loading 상태일 때 groupData가 undefined이기 때문에 로딩이 종료된 후 실행하기 위해 추가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;!groupData.member.some&lt;/b&gt; : 팀 멤버 데이터 중 로그인 한 유저와 id가 같은 멤버가 없는 경우&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로직을 간단하게 살펴보면 페이지에 접근했을 때 서버에서 받아온 팀의 멤버 배열에 로그인한 유저의 id(Redux 전역 데이터)가 없는 경우 차단하고 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Coworkers-Chrome-2025-02-15-19-58-45.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/chwvEG/btsMkavct3M/ORFh3YddYmu4nLPg7kOmz1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/chwvEG/btsMkavct3M/ORFh3YddYmu4nLPg7kOmz1/img.gif&quot; data-alt=&quot;접근 제한 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/chwvEG/btsMkavct3M/ORFh3YddYmu4nLPg7kOmz1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/chwvEG/btsMkavct3M/ORFh3YddYmu4nLPg7kOmz1/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;Coworkers-Chrome-2025-02-15-19-58-45.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;접근 제한 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;접근 제한이 잘 동작하는데 에러가 발생하네요.. 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;801&quot; data-origin-height=&quot;50&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjBWbm/btsMksoTfqs/TjKhKYHYxtvjfsgDWKbbxK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjBWbm/btsMksoTfqs/TjKhKYHYxtvjfsgDWKbbxK/img.png&quot; data-alt=&quot;에러&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjBWbm/btsMksoTfqs/TjKhKYHYxtvjfsgDWKbbxK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjBWbm%2FbtsMksoTfqs%2FTjKhKYHYxtvjfsgDWKbbxK%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;801&quot; height=&quot;50&quot; data-origin-width=&quot;801&quot; data-origin-height=&quot;50&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;에러&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;에러 메시지 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;Cannot updatea component (`Router`) while rendering a different component (`TeamPage`)&lt;/span&gt;&lt;/b&gt;에 대해 찾아보니 렌더링 중에 Route를 해서 그렇다고 합니다. useEffect를 이용해 컴포넌트가 렌더링 된 후 로직이 동작하도록 수정하겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;/app/[teamid]/page.tsx - 수정&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739619068008&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  if (
    !isLoading &amp;amp;&amp;amp;
    groupData &amp;amp;&amp;amp;
    !groupData.members.some(({ userId }) =&amp;gt; userId === Number(user?.id))
  ) {
    alert('접근제한: 팀의 멤버가 아닙니다!');
    router.replace('/');
  }
}, [isLoading, groupData, user?.id, router]);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Coworkers-Chrome-2025-02-15-21-44-21.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k7Fn1/btsMjqeAkth/Nmoh04BWbTuYVBzHQNHZ5k/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k7Fn1/btsMjqeAkth/Nmoh04BWbTuYVBzHQNHZ5k/img.gif&quot; data-alt=&quot;접근 제한 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k7Fn1/btsMjqeAkth/Nmoh04BWbTuYVBzHQNHZ5k/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/k7Fn1/btsMjqeAkth/Nmoh04BWbTuYVBzHQNHZ5k/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;Coworkers-Chrome-2025-02-15-21-44-21.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;접근 제한 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀의 멤버가 아닌 유저가 접근하면 해결 프로세스에서 정의한 것처럼 잘 동작하네요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;다른 페이지에서도 적용하기 위해 커스텀 훅으로 분리하는 작업을 해보겠습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;컴포넌트의 이름은 팀의 멤버가 아니면 리다이렉트 하는 로직에 맞게 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;useRedirectIfNotMember&lt;/span&gt;&lt;/b&gt;로 하겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;useRedirectIfNotMember.ts&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739625378581&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useSelector } from 'react-redux';
import { RootState } from '@/app/stores/store';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { GroupResponse } from '../types/grouptask';

const useRedirectIfNotMember = ({
  isLoading,
  groupData,
}: {
  isLoading: boolean;
  groupData?: GroupResponse;
}) =&amp;gt; {
  const router = useRouter();
  const { user } = useSelector((state: RootState) =&amp;gt; state.auth);

  useEffect(() =&amp;gt; {
    if (
      !isLoading &amp;amp;&amp;amp;
      groupData &amp;amp;&amp;amp;
      !groupData.members.some(({ userId }) =&amp;gt; userId === Number(user?.id))
    ) {
      alert('접근제한: 팀의 멤버가 아닙니다!');
      router.replace('/');
    }
  }, [isLoading, groupData, router, user?.id]);
};

export default useRedirectIfNotMember;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;훅은 props로 데이터 요청 상태인 isLoading과 members를 담고 있는 groupData를 받았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;페이지에 적용해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739625491982&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useRedirectIfNotMember({
  isLoading,
  groupData,
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;커스텀 훅으로 분리하면서 코드가 짧아져 가독성도 좋아지고, 다른 페이지에서도 사용할 수 있게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;여기서 끝내면 조금 아쉬우니 성능적으로도 조금이나마 최적화를 진행해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;useRedirectIfNotMember.ts - 최적화&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739627895954&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useSelector } from 'react-redux';
import { RootState } from '@/app/stores/store';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { GroupResponse } from '../types/grouptask';

const useRedirectIfNotMember = ({
  isLoading,
  groupData,
}: {
  isLoading: boolean;
  groupData?: GroupResponse;
}) =&amp;gt; {
  const router = useRouter();
  const userId = useSelector((state: RootState) =&amp;gt; state.auth.user?.id);
  const [isRedirecting, setIsRedirecting] = useState(false);
  
  const redirect = useCallback(() =&amp;gt; {
    setIsRedirecting(true);
    alert('접근제한: 팀의 멤버가 아닙니다!');
    router.replace('/');
  }, [router])

  useEffect(() =&amp;gt; {
    if (
      !isLoading &amp;amp;&amp;amp;
      groupData &amp;amp;&amp;amp;
      !groupData.members.some(({ userId: id }) =&amp;gt; id === Number(userId))
    ) {
      redirect();
    }
  }, [isLoading, groupData, userId, redirect]);
};

export default useRedirectIfNotMember;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;const&amp;nbsp;userId&amp;nbsp;=&amp;nbsp;useSelector((state:&amp;nbsp;RootState)&amp;nbsp;=&amp;gt;&amp;nbsp;state.auth.user?.id);&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존 구조분해 할당으로 선언한 코드(&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;{user}&lt;/b&gt;&lt;/span&gt;)는 컴포넌트가 렌더링 될 때 Redux의 auth 전체의 변화를 감지했습니다. user.id만 조회함으로써 불필요한 리렌더링을 방지했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;redirect 함수 생성&lt;br /&gt;&lt;/b&gt;기존 코드는 컴포넌트가 렌더링 될 때마다 useEffect 내부의 로직을 새로 생성했습니다. 로직을 useCallback을 사용해 redirect 함수로 분리함으로써 렌더링 될 때마다 로직이 새로 생성되는 것을 방지했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;멤버 접근 제한 이슈 수정하면서 느낀 점&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 이번 경험을 통해 리액트에서 성능 최적화의 중요성과 useCallback, useSelector와 같은 훅을 효율적으로 사용하는 방법을 배웠습니다. 작은 성능 최적화가 사용자 경험에 큰 영향을 미칠 수 있다는 점에서, 앞으로는 더욱 세심하게 코드 최적화를 고려해야겠다는 생각을 하게 되었습니다. 또한, 멤버 접근 제한 프로세스를 설계하면서 사용자 경험을 고려한 기능 구현의 중요성을 깨닫게 되었고, 단순히 기술적인 구현에 그치지 않고 사용자가 어떻게 느낄지에 대한 고민이 필요하다는 점을 깊이 인식하게 되었습니다 &lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Next.js</category>
      <category>Trouble Shooting</category>
      <category>useCallback</category>
      <category>이슈</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/187</guid>
      <comments>https://dev-hpk.tistory.com/187#entry187comment</comments>
      <pubDate>Sat, 15 Feb 2025 23:53:48 +0900</pubDate>
    </item>
    <item>
      <title>[맛길] Naver Map API를 이용한 맛집 지도 추가</title>
      <link>https://dev-hpk.tistory.com/186</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 오늘은 맛집 상세 페이지에 지도를 추가해 보겠습니다. &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;지도는 Naver Map API를 이용해 볼 계획입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;0️⃣ Naver Map API 선택 이유&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;https://navermaps.github.io/maps.js.ncp/docs/tutorial-1-Conceptual-Overview.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Naver Map API v3 특징&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1156&quot; data-origin-height=&quot;317&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mxdVV/btsMgkZWUEL/3NMiBiKmbwNraFkBEBQwM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mxdVV/btsMgkZWUEL/3NMiBiKmbwNraFkBEBQwM0/img.png&quot; data-alt=&quot;Naver Map API v3 - 특징&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mxdVV/btsMgkZWUEL/3NMiBiKmbwNraFkBEBQwM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmxdVV%2FbtsMgkZWUEL%2F3NMiBiKmbwNraFkBEBQwM0%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;1156&quot; height=&quot;317&quot; data-origin-width=&quot;1156&quot; data-origin-height=&quot;317&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Naver Map API v3 - 특징&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 프레임워크에 의존하지 않고 독립적으로 동작하기 때문에 불필요한 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;의존성을 최소화&lt;/b&gt;&lt;/span&gt;할 수 있고,&amp;nbsp; &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;React &amp;amp; Next.js&lt;/b&gt;로 제작된 &lt;b&gt;맛길 &lt;/b&gt;프로젝트에 적합&lt;/span&gt;할 것 같아 선택했습니다.&amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;DOM 처리 및 웹 브라우저 호환 코드를 내장&lt;/b&gt;하고 있어 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;크로스 브라우징 이슈를 최소화&lt;/b&gt;&lt;/span&gt;하면서 손쉽게 지도 기능을 구현할 수 있을 거라 생각해 선택했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;별도의 CSS를 필요로 하지 않도록 설계&lt;/b&gt;된 내용을 보고, &lt;b&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;개발 부담을 줄일 수 있을 것 같아서&lt;/span&gt;&lt;/b&gt; 선택했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 모바일 환경에서도 &lt;b&gt;최적화된 성능&lt;/b&gt;을 제공하기 때문에 별도의 최적화 작업이 필요하지 않아 개발 부담을 줄일 수 있을 것 같아 선택했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;1-ncloud-접속-인증키-발급&quot; style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1️⃣ NAVER CLOUD PLATFORM 인증키 발급&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;https://console.ncloud.com/naver-service/application/create&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;NCloud - Application 등록&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;945&quot; data-origin-height=&quot;615&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Iqe9A/btsMgIlTXpe/oxpCUkxK4pzwzC6vAYYf81/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Iqe9A/btsMgIlTXpe/oxpCUkxK4pzwzC6vAYYf81/img.png&quot; data-alt=&quot;Application 등록&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Iqe9A/btsMgIlTXpe/oxpCUkxK4pzwzC6vAYYf81/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIqe9A%2FbtsMgIlTXpe%2FoxpCUkxK4pzwzC6vAYYf81%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;945&quot; height=&quot;615&quot; data-origin-width=&quot;945&quot; data-origin-height=&quot;615&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Application 등록&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Application 이름은 서비스와 동일하게 matgil로 설정하겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 맛길 서비스의 지도에서는 길 찾기 기능은 필요하지 않기 때문에 Directions 옵션은 선택하지 않았습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;905&quot; data-origin-height=&quot;228&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dyqClZ/btsMg4WiW0p/pwnANwQS2mIA0rBu0ksaKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dyqClZ/btsMg4WiW0p/pwnANwQS2mIA0rBu0ksaKK/img.png&quot; data-alt=&quot;Directions 제공 기능&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dyqClZ/btsMg4WiW0p/pwnANwQS2mIA0rBu0ksaKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdyqClZ%2FbtsMg4WiW0p%2FpwnANwQS2mIA0rBu0ksaKK%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;905&quot; height=&quot;228&quot; data-origin-width=&quot;905&quot; data-origin-height=&quot;228&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Directions 제공 기능&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Application 등록을 완료했고, 인증 정보 버튼을 클릭해 보니 Client ID가 생겼습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Client ID는 프로젝트에서 Naver Map API를 이용할 때 사용되는 API Key이기 때문에 외부 노출을 방지하기 위해 .env 파일에 저장했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1307&quot; data-origin-height=&quot;786&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qMskj/btsMhnuzsh4/zq8GC80HGX1XWL30Badxa0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qMskj/btsMhnuzsh4/zq8GC80HGX1XWL30Badxa0/img.png&quot; data-alt=&quot;Application 인증 정보&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qMskj/btsMhnuzsh4/zq8GC80HGX1XWL30Badxa0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqMskj%2FbtsMhnuzsh4%2Fzq8GC80HGX1XWL30Badxa0%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;1307&quot; height=&quot;786&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1307&quot; data-origin-height=&quot;786&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Application 인증 정보&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 id=&quot;1-ncloud-접속-인증키-발급&quot; style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2️⃣ Naver Map&amp;nbsp; API 타입 패키지 설치&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1739430557000&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm i -D @types/navermaps&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;3-nextscript를-이용하여-api-정보-불러오기&quot; style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 3️⃣ &lt;b&gt;Naver Map API 이용해 지도 컴포넌트 작업&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Map.tsx - 지도 컴포넌트&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739430911703&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import { useEffect, useRef } from 'react';
import Script from 'next/script';

function Map() {
  const mapRef = useRef&amp;lt;naver.maps.Map | null&amp;gt;(null);

  const initMap = (x: number, y: number) =&amp;gt; {
    const map = new naver.maps.Map('map', {
      center: new naver.maps.LatLng(x, y),
      zoom: 20,
    });

    new naver.maps.Marker({
      position: new naver.maps.LatLng(x, y),
      map: map,
    });

    mapRef.current = map;
  };

  useEffect(() =&amp;gt; {
    naver.maps.Service.geocode(
      {
        query: '통영시 무전1길 64-11 되뫼골부대찌개',
      },
      function (status, response) {
        if (status === naver.maps.Service.Status.ERROR) {
          console.error('지도 정보를 불러오는 중 에러가 발생했습니다.');
        }

        const result = response.v2.addresses[0];
        const x = Number(result.x);
        const y = Number(result.y);

        initMap(x, y);
      },
    );

    return () =&amp;gt; {
      if (mapRef.current) {
        mapRef.current.destroy();
      }
    };
  }, []);

  return (
    &amp;lt;&amp;gt;
      &amp;lt;Script
        type=&quot;text/javascript&quot;
        src={`https://oapi.map.naver.com/openapi/v3/maps.js?ncpClientId=${process.env.NEXT_PUBLIC_NAVER_CLIENT_ID}&amp;amp;submodules=geocoder`}
      /&amp;gt;
      &amp;lt;div id=&quot;map&quot; style={{ width: '100%', height: '400px' }} /&amp;gt;
    &amp;lt;/&amp;gt;
  );
}

export default Map;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;query의 주소는 지도가 잘 동작하는지 확인하기 위해 임시로 하드 코딩했습니다. 지도 동작 확인 후 prop으로 받도록 수정할 예정입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;DetailPage.tsx - 맛집 상세 페이지&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739431250943&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import Divider from '@/app/components/common/Divider';
import VideoPlayer from '@/app/components/detail/VideoPlayer';
import IconMarker from '@/app/components/icons/IconMarker';
import Map from '@/app/components/Map/Map';
import axios from '@/app/lib/instance';

async function DetailPage({
  params,
}: {
  params: { channel: string; id: string };
}) {
  const { channel, id } = await params;

  const {
    data: { list },
  } = await axios.get(`${channel}/${id}`);

  const { videoId, thumbnail, timeline, address } = list;

  return (
    &amp;lt;div className=&quot;max-w-[46.25rem] mx-auto px-3 py-5 text-white&quot;&amp;gt;
      &amp;lt;div className=&quot;relative w-full aspect-[1.75/1]&quot;&amp;gt;
        &amp;lt;VideoPlayer videoId={videoId} lazy={thumbnail} timeline={timeline} /&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;div className=&quot;mt-3 font-semibold line-clamp-2&quot;&amp;gt;{list.title}&amp;lt;/div&amp;gt;
      &amp;lt;Divider /&amp;gt;
      &amp;lt;div className=&quot;flex items-center gap-1&quot;&amp;gt;
        &amp;lt;IconMarker className=&quot;w-3.5 h-3.5&quot; /&amp;gt;
        {address}
      &amp;lt;/div&amp;gt;
      &amp;lt;Map /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default DetailPage;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;795&quot; data-origin-height=&quot;200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/33yl7/btsMhu1zbyQ/aiNKeVVwjYnEkvGlNpM9b0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/33yl7/btsMhu1zbyQ/aiNKeVVwjYnEkvGlNpM9b0/img.png&quot; data-alt=&quot;error - naver is not defined&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/33yl7/btsMhu1zbyQ/aiNKeVVwjYnEkvGlNpM9b0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F33yl7%2FbtsMhu1zbyQ%2FaiNKeVVwjYnEkvGlNpM9b0%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;795&quot; height=&quot;200&quot; data-origin-width=&quot;795&quot; data-origin-height=&quot;200&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;error - naver is not defined&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해보니 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;ReferenceError: naver is not defined&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&amp;nbsp;에러가 발생해 네이버 지도를 호출하지 못하고 있습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;646&quot; data-origin-height=&quot;448&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cXyMg7/btsMh4aqNlD/pux0CW7He9BVzs0dyRk0g1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cXyMg7/btsMh4aqNlD/pux0CW7He9BVzs0dyRk0g1/img.png&quot; data-alt=&quot;Script 컴포넌트 strategy 옵션&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cXyMg7/btsMh4aqNlD/pux0CW7He9BVzs0dyRk0g1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcXyMg7%2FbtsMh4aqNlD%2Fpux0CW7He9BVzs0dyRk0g1%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;646&quot; height=&quot;448&quot; data-origin-width=&quot;646&quot; data-origin-height=&quot;448&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Script 컴포넌트 strategy 옵션&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Next 공식 문서를 확인해 보니 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Script 컴포넌트의 strategy 옵션은&amp;nbsp;&lt;/span&gt;&lt;b&gt;스크립트 로딩 시점을 제어&lt;/b&gt;할 수 있는 4개의 옵션을 제공합니다. afterInteractive가 default로 설정되어 있고, 이 옵션은 hydration이 발생한 후 Script를 로드한다고 하네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;strategy=&quot;afterInteractive&quot;&lt;/b&gt;는 &lt;b&gt;hydration&lt;/b&gt;이 완료된 후에 스크립트를 로드하기 때문에,&amp;nbsp; naver 객체가 아직 정의되지 않아 naver is not defined 오류가 발생하는 것이었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Script 컴포넌트 - strategy props 수정&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739434734440&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;Script
  strategy=&quot;beforeInteractive&quot;
  type=&quot;text/javascript&quot;
  src={`https://oapi.map.naver.com/openapi/v3/maps.js?ncpClientId=${process.env.NEXT_PUBLIC_NAVER_CLIENT_ID}&amp;amp;submodules=geocoder`}
/&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;756&quot; data-origin-height=&quot;507&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dCye5r/btsMg5nCFBt/kdkhXhu2mGiItevaA0WUg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dCye5r/btsMg5nCFBt/kdkhXhu2mGiItevaA0WUg0/img.png&quot; data-alt=&quot;Naver Map API - 지도 생성 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dCye5r/btsMg5nCFBt/kdkhXhu2mGiItevaA0WUg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdCye5r%2FbtsMg5nCFBt%2FkdkhXhu2mGiItevaA0WUg0%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;756&quot; height=&quot;507&quot; data-origin-width=&quot;756&quot; data-origin-height=&quot;507&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Naver Map API - 지도 생성 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;지도를 로드하는것은 성공했지만, 정상적인 위치가 아니라 이상한 위치가 보이고 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;695&quot; data-origin-height=&quot;258&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oRrcg/btsMg4voKEm/1DOt95zUxqPOZS887FjIA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oRrcg/btsMg4voKEm/1DOt95zUxqPOZS887FjIA0/img.png&quot; data-alt=&quot;Naver Map API - geocode response&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oRrcg/btsMg4voKEm/1DOt95zUxqPOZS887FjIA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoRrcg%2FbtsMg4voKEm%2F1DOt95zUxqPOZS887FjIA0%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;695&quot; height=&quot;258&quot; data-origin-width=&quot;695&quot; data-origin-height=&quot;258&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Naver Map API - geocode response&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;응답을 확인해봐도 API 요청이 성공해 주소를 response로 받고 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;주소를 위도/경도 좌표(geocode)로 변환하는 로직의 문제는 아니기 때문에 지도를 생성하는 initMap 함수를 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739435432545&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const initMap = (x: number, y: number) =&amp;gt; {
  const map = new naver.maps.Map('map', {
    center: new naver.maps.LatLng(x, y),
    zoom: 20,
  });

  new naver.maps.Marker({
    position: new naver.maps.LatLng(x, y),
    map: map,
  });

  mapRef.current = map;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;여러 방법을 시도해보다가 도저히 해결될 기미가 보이지 않아 공식 문서를 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;775&quot; data-origin-height=&quot;370&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c012RR/btsMgJrIjRf/9fol7M3NRPGf7CZGWbIKdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c012RR/btsMgJrIjRf/9fol7M3NRPGf7CZGWbIKdk/img.png&quot; data-alt=&quot;naver.maps.LatLng 사용법 - 공식 문서&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c012RR/btsMgJrIjRf/9fol7M3NRPGf7CZGWbIKdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc012RR%2FbtsMgJrIjRf%2F9fol7M3NRPGf7CZGWbIKdk%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;775&quot; height=&quot;370&quot; data-origin-width=&quot;775&quot; data-origin-height=&quot;370&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;naver.maps.LatLng 사용법 - 공식 문서&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;567&quot; data-origin-height=&quot;75&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YdIoE/btsMhvM6WEU/MDdNIrrFNH6fg5KocovfqK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YdIoE/btsMhvM6WEU/MDdNIrrFNH6fg5KocovfqK/img.png&quot; data-alt=&quot;geocode - x,y 응답 값&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YdIoE/btsMhvM6WEU/MDdNIrrFNH6fg5KocovfqK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYdIoE%2FbtsMhvM6WEU%2FMDdNIrrFNH6fg5KocovfqK%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;567&quot; height=&quot;75&quot; data-origin-width=&quot;567&quot; data-origin-height=&quot;75&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;geocode - x,y 응답 값&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;geocode의 response로 받은 &lt;b&gt;x, y는 (경도, 위도)&lt;/b&gt;인데 naver.maps.LatLng의 매개변수는 &lt;b&gt;(위도, 경도)&lt;/b&gt;로 전달해야 하네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;API 사용 전 공식 문서를 꼼꼼히 읽는 습관이 아직도 부족하다는 것을 한 번 더 느끼게 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;매개변수 이름을 헷갈리지 않게 lat, lng으로 수정 후 동작을 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739437011257&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const initMap = (lat: number, lng: number) =&amp;gt; {
  const map = new naver.maps.Map('map', {
    center: new naver.maps.LatLng(lat, lng),
    zoom: 20,
  });

  new naver.maps.Marker({
    position: new naver.maps.LatLng(lat, lng),
    map: map,
  });

  mapRef.current = map;
};
  
useEffect(() =&amp;gt; {
  naver.maps.Service.geocode(
    {
      query: '통영시 무전1길 64-11 되뫼골부대찌개',
    },
    function (status, response) {
      if (status === naver.maps.Service.Status.ERROR) {
        console.error('지도 정보를 불러오는 중 에러가 발생했습니다.');
      }

      const result = response.v2.addresses[0];
      const lng = Number(result.x);
      const lat = Number(result.y);

      initMap(lat, lng);
    },
  );

  return () =&amp;gt; {
    if (mapRef.current) {
      mapRef.current.destroy();
    }
  };
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;444&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/el3xb8/btsMhfX1TJs/HC8F2aVijhk14T1Ud8yuNK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/el3xb8/btsMhfX1TJs/HC8F2aVijhk14T1Ud8yuNK/img.png&quot; data-alt=&quot;Naver Map API - 지도 생성 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/el3xb8/btsMhfX1TJs/HC8F2aVijhk14T1Ud8yuNK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fel3xb8%2FbtsMhfX1TJs%2FHC8F2aVijhk14T1Ud8yuNK%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;750&quot; height=&quot;444&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;444&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Naver Map API - 지도 생성 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 Naver Map API를 이용해 지도에 원하는 위치를 불러올 수 있게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아직 마커와 지도뿐이지만 추후 여러 기능들을 테스트해 보며 필요한 기능들을 추가해 보겠습니다!&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>Hydration</category>
      <category>naver map api</category>
      <category>Next.js</category>
      <category>네이버 지도 api</category>
      <category>사이드 프로젝트</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/186</guid>
      <comments>https://dev-hpk.tistory.com/186#entry186comment</comments>
      <pubDate>Thu, 13 Feb 2025 18:02:18 +0900</pubDate>
    </item>
    <item>
      <title>[Coworkers] IOS 이미지 업로드 이슈</title>
      <link>https://dev-hpk.tistory.com/185</link>
      <description>&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; &amp;nbsp;&lt;/b&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀 수정하기 관련해서 IOS 기기에서 이미지가 업로드되지 않는 이슈가 생겼습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;327&quot; data-origin-height=&quot;108&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjBUfx/btsMfNMQ9Sj/VQhbb7OHDiIHX9hv3tekmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjBUfx/btsMfNMQ9Sj/VQhbb7OHDiIHX9hv3tekmk/img.png&quot; data-alt=&quot;프로필 이미지 이슈&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjBUfx/btsMfNMQ9Sj/VQhbb7OHDiIHX9hv3tekmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjBUfx%2FbtsMfNMQ9Sj%2FVQhbb7OHDiIHX9hv3tekmk%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;327&quot; height=&quot;108&quot; data-origin-width=&quot;327&quot; data-origin-height=&quot;108&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;프로필 이미지 이슈&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;844&quot; data-origin-height=&quot;579&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/meR2l/btsMdKqS4tj/FIeCAMunL6CEM2JdH2xWjK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/meR2l/btsMdKqS4tj/FIeCAMunL6CEM2JdH2xWjK/img.png&quot; data-alt=&quot;배포 이슈&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/meR2l/btsMdKqS4tj/FIeCAMunL6CEM2JdH2xWjK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmeR2l%2FbtsMdKqS4tj%2FFIeCAMunL6CEM2JdH2xWjK%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;844&quot; height=&quot;579&quot; data-origin-width=&quot;844&quot; data-origin-height=&quot;579&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;배포 이슈&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;이미지 업로드 관련 코드&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;uploadImage.ts&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739285559192&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import postImage from '@/app/lib/image/postImage';

const uploadImage = async (profile: FileList) =&amp;gt; {
  if (!profile || !(profile[0] instanceof File)) return null;

  const formData = new FormData();
  formData.append('image', profile[0]);

  const { url } = await postImage(formData);
  return url;
};

export default uploadImage;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 코드는 react-hook-form의 profile 이미지를 받아서 서버에 업로드하는 함수입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;pc, 노트북, 갤럭시 핸드폰으로 확인했을 때 모두 정상적으로 동작합니다. safari 브라우저 문제인가 싶어 chrome으로도 확인했지만, ios에서는 uploadImage 함수가 정상적으로 동작하지 않습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;ios 기기로 매개변수 profile을 확인하기 위해 디버깅용 alert 코드를 추가해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739285987561&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import postImage from '@/app/lib/image/postImage';

const uploadImage = async (profile: FileList) =&amp;gt; {
  alert(profile);
  alert(profile[0]);
  if (!profile || !(profile[0] instanceof File)) return null;

  const formData = new FormData();
  formData.append('image', profile[0]);

  const { url } = await postImage(formData);
  return url;
};

export default uploadImage;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아래 같은 결과가 나옵니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739286025391&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[object FileList]
undefined&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;profile[0]이 undefined인 것을 보니 uploadImage 함수 문제가 아닌 것 같습니다. 프로필 이미지를 변경하는 input 컴포넌트를 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;ProfileUploader.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739286145569&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import Image from 'next/image';
import { useEffect, useState } from 'react';
import { FieldValues, UseFormRegister } from 'react-hook-form';
import IconProfileEdit from '@/app/components/icons/IconProfileEdit';
import IconProfile from '@/app/components/icons/IconProfile';

interface ProfileUploaderProps {
  initialImage?: string;
  register: UseFormRegister&amp;lt;FieldValues&amp;gt;;
}

function ProfileUploader({ initialImage, register }: ProfileUploaderProps) {
  const [profileImage, setProfileImage] = useState&amp;lt;string | null&amp;gt;(
    initialImage || '',
  );

  // 파일 처리하는 함수
  const handleFileChange = (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const file = e.target.files?.[0];
    if (file) {
      const url = URL.createObjectURL(file); // 미리보기 URL 생성
      setProfileImage(url); // 미리보기 이미지 업데이트
    }
  };

  useEffect(() =&amp;gt; {
    setProfileImage(initialImage || '');
  }, [initialImage]);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;span className=&quot;mb-3 inline-block&quot;&amp;gt;팀 프로필&amp;lt;/span&amp;gt;
      &amp;lt;label
        htmlFor=&quot;profile&quot;
        className=&quot;relative block h-16 w-16 cursor-pointer&quot;
      &amp;gt;
        &amp;lt;input
          id=&quot;profile&quot;
          className=&quot;sr-only&quot;
          type=&quot;file&quot;
          accept=&quot;image/*&quot;
          {...register('profile')}
          onChange={handleFileChange}
        /&amp;gt;
        {profileImage ? (
          &amp;lt;&amp;gt;
            &amp;lt;Image
              src={profileImage}
              className=&quot;rounded-full border-2 border-border-primary&quot;
              fill
              alt=&quot;프로필 이미지&quot;
            /&amp;gt;
            &amp;lt;IconProfileEdit className=&quot;absolute bottom-0 right-0&quot; /&amp;gt;
          &amp;lt;/&amp;gt;
        ) : (
          &amp;lt;IconProfile /&amp;gt;
        )}
      &amp;lt;/label&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default ProfileUploader;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;현재 코드는&amp;nbsp; onChange 핸들러와 register를 모두 처리하고 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;react-hook-form 공식 문서의 register를 확인해 보니 {...register('name')}이 onChange, onBlur, name, ref를 모두 처리하고 있다고 합니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;931&quot; data-origin-height=&quot;376&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpGDAR/btsMe1LUqe7/4HX5fHv1einhBnYrNR2HAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpGDAR/btsMe1LUqe7/4HX5fHv1einhBnYrNR2HAk/img.png&quot; data-alt=&quot;react-hook-form register 설명&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpGDAR/btsMe1LUqe7/4HX5fHv1einhBnYrNR2HAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpGDAR%2FbtsMe1LUqe7%2F4HX5fHv1einhBnYrNR2HAk%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;931&quot; height=&quot;376&quot; data-origin-width=&quot;931&quot; data-origin-height=&quot;376&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;react-hook-form register 설명&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이미지를 변경했을 때 미리보기 이미지도 변경해줘야 하니 register를 제거하는 방향으로 수정하겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;ProfileUploader.tsx - 수정 부분&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739286728650&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import Image from 'next/image';
import { useEffect, useState } from 'react';
import { FieldValues, UseFormSetValue } from 'react-hook-form';
import IconProfileEdit from '@/app/components/icons/IconProfileEdit';
import IconProfile from '@/app/components/icons/IconProfile';

interface ProfileUploaderProps {
  initialImage?: string;
  setValue: UseFormSetValue&amp;lt;FieldValues&amp;gt;;
}

function ProfileUploader({ initialImage, setValue }: ProfileUploaderProps) {
  const [profileImage, setProfileImage] = useState&amp;lt;string | null&amp;gt;(
    initialImage || '',
  );

  const handleFileChange = (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    if (e.target.files &amp;amp;&amp;amp; e.target.files.length &amp;gt; 0) {
      const file = e.target.files[0];
      const url = URL.createObjectURL(file); // 미리보기 URL 생성
      setProfileImage(url);
      setValue('profile', file);
    }
  };

  useEffect(() =&amp;gt; {
    setProfileImage(initialImage || '');
  }, [initialImage]);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;span className=&quot;mb-3 inline-block&quot;&amp;gt;팀 프로필&amp;lt;/span&amp;gt;
      &amp;lt;label
        htmlFor=&quot;profile&quot;
        className=&quot;relative block h-16 w-16 cursor-pointer&quot;
      &amp;gt;
        &amp;lt;input
          id=&quot;profile&quot;
          className=&quot;sr-only&quot;
          type=&quot;file&quot;
          accept=&quot;image/*&quot;
          onChange={handleFileChange}
        /&amp;gt;
        {profileImage ? (
          &amp;lt;&amp;gt;
            &amp;lt;Image
              src={profileImage}
              className=&quot;rounded-full border-2 border-border-primary&quot;
              fill
              alt=&quot;프로필 이미지&quot;
            /&amp;gt;
            &amp;lt;IconProfileEdit className=&quot;absolute bottom-0 right-0&quot; /&amp;gt;
          &amp;lt;/&amp;gt;
        ) : (
          &amp;lt;IconProfile /&amp;gt;
        )}
      &amp;lt;/label&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default ProfileUploader;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;setValue :&lt;/b&gt; 프로필 이미지 input 변경 시 react-hook-form의 fieldValue를 직접 수정하기 위해 props로 추가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;setValue('profile', file) :&lt;/b&gt; onChange 이벤트 발생 시 파일을 fieldValue로 설정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;input :&lt;/b&gt; register 옵션 삭제&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해 보니 이제는 ios뿐 아니라 모든 기기에서 이미지 업로드가 동작하지 않습니다. &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;매개변수 profile이 잘 전달되고 있는지 확인하기 위해 디버깅용 alert 코드를 추가해 보겠습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739287169202&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import postImage from '@/app/lib/image/postImage';

const uploadImage = async (profile: FileList) =&amp;gt; {
  alert(profile);
  if (!profile || !(profile[0] instanceof File)) return null;

  const formData = new FormData();
  formData.append('image', profile[0]);

  const { url } = await postImage(formData);
  return url;
};

export default uploadImage;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1739287190802&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[object File]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;왜 File이 출력되는지 확인하기 위해 handleFileChange 함수를 확인해 보고 이유를 알았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739287279922&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleFileChange = (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
  if (e.target.files &amp;amp;&amp;amp; e.target.files.length &amp;gt; 0) {
    const file = e.target.files[0];
    const url = URL.createObjectURL(file); // 미리보기 URL 생성
    setProfileImage(url);
    setValue('profile', file);
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;setValue('profile', file)&lt;/b&gt;로 &lt;b&gt;profile fieldValue에 files[0]인 파일을 저장&lt;/b&gt;했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;uploadImage.ts - 매개변수 타입 수정&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739287410957&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import postImage from '@/app/lib/image/postImage';

const uploadImage = async (profile: File) =&amp;gt; {
  if (!profile || !(profile instanceof File)) return null;

  const formData = new FormData();
  formData.append('image', profile);

  const { url } = await postImage(formData);
  return url;
};

export default uploadImage;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;매개변수 profile의 타입을 File로 수정 후 결과를 확인하니 정상적으로 잘 동작합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;이미지 업로드 이슈 수정하면서 느낀 점&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;ProfileUploader 컴포넌트에서 이미지 업로드가 iOS에서 동작하지 않는 문제가 발생했습니다. 처음에는 로직 자체에 문제가 있다고 생각했지만, 디버깅을 통해 react-hook-form의 register 사용법을 정확히 숙지하지 않았던 것이 원인임을 알게 되었습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이슈를 해결하면서 2가지 중요성을 깨달았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1️⃣ 공식 문서 분석의 중요성&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 공식 문서를 확인해 보니, register를 사용할 경우 이벤트 처리는 내부적으로 관리되므로 onChange를 따로 지정하면 예상치 못한 동작이 발생할 수 있다는 내용이 있었는데 놓쳤습니다. 앞으로는 공식 문서를 먼저 확인하고 정확한 사용법을 숙지한 후 적용해야겠습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ &lt;b&gt;디버깅의 중요성&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;alert(profile)&lt;/b&gt;, &lt;b&gt;alert(profile[0])&lt;/b&gt; 등의 과정을 거치면서 디버깅을 통해 문제의 원인을 단계적으로 추적하는 것이 얼마나 중요한지 다시금 실감했습니다. 단순히 &lt;b&gt;코드가 &quot;안 된다&quot;가 아니라 디버깅을 통해 어느 부분에서 왜 예상과 다르게 동작하는지를 분석하는 습관&lt;/b&gt;을 더욱 길러야겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;추가로 이번 경험을 통해 단순히 코드 문제를 해결하는 것뿐만 아니라 &lt;b&gt;같은 실수를 반복하지 않도록 학습하고 기록하는 것이 얼마나 중요한지 깨달았습니다.&lt;/b&gt; 다음번에 또 같은 문제를 마주한다면 이번 기록을 통해 더 빠르고 효율적으로 해결할 수 있을 것 같습니다!&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Next.js</category>
      <category>react-hook-form</category>
      <category>Trouble Shooting</category>
      <category>이슈</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/185</guid>
      <comments>https://dev-hpk.tistory.com/185#entry185comment</comments>
      <pubDate>Wed, 12 Feb 2025 00:39:11 +0900</pubDate>
    </item>
    <item>
      <title>[Coworkers] 리팩토링</title>
      <link>https://dev-hpk.tistory.com/184</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오늘은 1차 배포 테스트가 얼마 남지 않아서 리팩토링을 진행해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;계획 없이 리팩토링을 진행하면 결과물의 퀄리티가 떨어질 수 있을 것 같아, &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;작업을 시작하기 전에 &lt;b&gt;어떤 방향으로 개선할 것인지&lt;/b&gt; 계획을 세워보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;리팩토링 중점 사항&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1️⃣ 가독성&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;변수와 함수를 이름만 보고도 어떤 역할을 하는지 알 수 있도록 의미 있게 수정하겠습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;불필요한 로직이나 변수를 제거해 코드를 간결하게 유지하겠습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;코드를 간결하게 유지하기 위해 컴포넌트를 기능별로 적절히 분리하겠습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2️⃣ 재사용성&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;특정 UI가 반복적으로 사용되는 경우 컴포넌트화해 재사용하겠습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;자주 사용되는 로직이나 기능은 커스텀 훅, 유틸 함수로 분리해 재사용하겠습니다.&amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;3️⃣ 유지보수성&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;컴포넌트와 함수가 하나의 책임만 갖도록 수정하겠습니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; SOLID 원칙의 단일 책임원칙(Singe Responsibility Principle) 고려&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 다른 팀원들이 코드를 읽을 때를 고려해 복잡한 로직은 주석을 달아두겠습니다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;간단한 로직의 경우는 코드를 간결하게 유지하기 위해 함수의 이름을 의미 있게 작성하고 주석을 삭제하겠습니다.&amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;4️⃣ 사용자 경험(UX) 개선&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&amp;nbsp;사용자 입장에서 서비스를 테스트해 보며 불편사항을 최대한 줄일 수 있도록 수정하겠습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;5️⃣ 성능 개선&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;React Query를 이용해 불필요한 리렌더링을 방지해 성능을 개선하겠습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위에서 세운 계획을 최대한 지키면서 리팩토링을 시작해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;유저 로그인 정보 확인 - 커스텀 훅 분리&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Coworkers 서비스는 자유게시판을 제외하면 거의 모든 페이지가 로그인을 필요로 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738915276950&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const router = useRouter();
const { accessToken } = useSelector((state: RootState) =&amp;gt; state.auth);

useEffect(() =&amp;gt; {
  if (!accessToken) {
    alert('로그인 후 이용할 수 있습니다.');
    router.push('/login');
  }
}, [accessToken, router]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존 작업에서는 페이지마다 Redux에서 전역으로 관리하고 있는 accessToken을 확인했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;토큰이 없다면 로그인이 필요하다는 문구를 alert하고 로그인(/login) 페이지로 이동하도록 동작합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이 로직을 매번 작성하는 것은 계획했던 유지보수와 재사용성을 고려했을 때 좋지 않은 것 같아 커스텀 훅으로 분리하도록 하겠습니다. 우선 커스텀 훅을 만들기에 앞서 훅의 이름을 정해야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;의미 있는 이름을 정하기가 어려워 GPT에게 도움을 요청했는데 아래와 같은 이름을 작성해 줬습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; useRequireLoginRedirect&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; useRedirectToLoginIfNotAuthenticated&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;useLoginRedirect&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; useAuthRedirect&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;가장 직관적인 이름은 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;useRedirectToLoginIfNotAuthenticated&lt;/span&gt;&lt;/b&gt;이지만 너무 길어서 가독성이 떨어지는 것 같아, 간결하게 인증 후 리다이렉트 한다는 의미에서 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;useAuthRedirect&lt;/b&gt;&lt;/span&gt;를 사용하도록 하겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;useAuthRedirect.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738916243363&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useSelector } from 'react-redux';
import { RootState } from '@/app/stores/store';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

const useAuthRedirect = () =&amp;gt; {
  const router = useRouter();
  const { accessToken } = useSelector((store: RootState) =&amp;gt; store.auth);

  useEffect(() =&amp;gt; {
    if (!accessToken) {
      /* alert : 알림 방법 정해지기 전 임시 사용 */
      alert('로그인 후 이용할 수 있습니다.');
      router.push('/login');
    }
  }, [accessToken, router]);
};

export default useAuthRedirect;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;useSelector 변수명 수정 - 프로젝트 일관성 유지&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738916721615&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  const { user } = useSelector((store: RootState) =&amp;gt; store.auth);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;useSelector에서 사용하고 있는 변수명을 팀원마다 다르게 사용하고 있어 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;state&lt;/b&gt;&lt;/span&gt;로 통일했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738916981146&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  const { user } = useSelector((state: RootState) =&amp;gt; state.auth);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;사실 코드를 보면 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;store&lt;/b&gt;&lt;/span&gt;나 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;state&lt;/b&gt; &lt;/span&gt;어떤 이름으로 사용해도 실제 코드에서 사용되는 일이 없어서 큰 차이는 없을 것 같지만, 유지보수 측면에서 &lt;b&gt;일관성을 유지&lt;/b&gt;하기 위해 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;state&lt;/b&gt;&lt;/span&gt;로 수정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;중복 변환 코드 제거&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738917440596&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { teamid } = useParams();

queryKey: ['group', Number(teamid)],
queryFn: () =&amp;gt; getGroupById(Number(teamid)),
enabled: !!Number(teamid),

await patchGroup(Number(teamid), teamData);

queryClient.invalidateQueries({ queryKey: ['group', Number(teamid)] });
router.push(`/${Number(teamid)}`);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존에 작업했던 코드에서는 타입을 변환하는 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;Number(teamid)&lt;/b&gt;&lt;/span&gt;가 반복되고 있습니다. 가독성과 유지보수 측면에서 좋지 않은 코드인 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738917712196&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { teamid } = useParams();
const groupId = Number(teamid);

queryKey: ['group', groupId],
queryFn: () =&amp;gt; getGroupById(groupId),
enabled: !!groupId,

await patchGroup(groupId, teamData);

queryClient.invalidateQueries({ queryKey: ['group', groupId] });
router.push(`/${groupId}`);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;코드의 가독성과 유지보수성을 개선하기 위해 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;groupId&lt;/b&gt;&lt;/span&gt;를 선언해 타입 변환의 &lt;b&gt;중복을 제거&lt;/b&gt;했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;이미지 업로드 로직 - &lt;/b&gt;&lt;b&gt;유틸 함수 분리&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738918023123&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const mutation = useMutation({
    mutationFn: async ({ profile, name }: FieldValues) =&amp;gt; {
      let imageUrl: string | null = null;

      if (profile &amp;amp;&amp;amp; profile[0] instanceof File) {
        const formData = new FormData();
        formData.append('image', profile[0]);

        const { url } = await postImage(formData);
        imageUrl = url;
      }

      const teamData: GroupData = { name };
      if (imageUrl) teamData.image = imageUrl;

      await patchGroup(groupId, teamData);
    },
    onSuccess: () =&amp;gt; {
      queryClient.invalidateQueries({ queryKey: ['group', groupId] });
      router.push(`/${groupId}`);
    },
    onError: () =&amp;gt; {
      alert('팀 수정에 실패했습니다.');
    },
  });&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존 코드에서는 mutationFn에서 이미지 업로드 로직을 작성하고 사용하고 있습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;mutation 로직의 길이가 길어져 가독성도 떨어지고 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;코드를 읽어보기 전에는 어떤 동작을 하는지 알 수도 없습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;uploadImage.ts&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738918336110&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import postImage from '@/app/lib/image/postImage';

const uploadImage = async (profile: FileList) =&amp;gt; {
  if (!profile || !(profile[0] instanceof File)) return null;

  const formData = new FormData();
  formData.append('image', profile[0]);

  const { url } = await postImage(formData);
  return url;
};

export default uploadImage;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이미지를 업로드하는 기능만 담당하는 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;uploadImage 함수&lt;/span&gt;&lt;/b&gt;를 만들고 재사용을 위해 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/utils&lt;/b&gt;&lt;/span&gt;에 분리했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738918537307&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const mutation = useMutation({
  mutationFn: async ({ profile, name }: FieldValues) =&amp;gt; {
    const imageUrl = await uploadImage(profile);

    const teamData: GroupData = { name };
    if (imageUrl) teamData.image = imageUrl;

    await patchGroup(groupId, teamData);
  },
  onSuccess: () =&amp;gt; {
    queryClient.invalidateQueries({ queryKey: ['group', groupId] });
    router.push(`/${groupId}`);
  },
  onError: () =&amp;gt; {
    alert('팀 수정에 실패했습니다.');
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존 코드보다 간결해져 &lt;b&gt;가독성&lt;/b&gt;도 좋아졌고, 유틸 함수로 분리해 &lt;b&gt;유지보수성&lt;/b&gt;도 개선되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;React Query를 이용한 성능 개선&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738918899562&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const onSubmit = async ({ profile, name }: FieldValues) =&amp;gt; {
    let imageUrl: string | null = null;

    if (profile &amp;amp;&amp;amp; profile[0] instanceof File) {
      try {
        const formData = new FormData();

        formData.append('image', profile[0]);

        const { url } = await postImage(formData);

        imageUrl = url;
      } catch (error) {
        alert('이미지 업로드에 실패했습니다.');
      }
    }

    try {
      const teamData: GroupData = {
        name,
      };

      if (imageUrl) {
        teamData.image = imageUrl;
      }

      const { id } = await postGroup(teamData);

      router.push(`/${id}`);
    } catch (error) {
      alert('팀 생성에 실패했습니다.');
    }
  };&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존 팀 생성 로직의 경우 onSubmit 핸들러로 처리했습니다. 함수 내부를 보니 이미지 업로드 로직을 함수 내에서 작성해서 사용했었네요.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738919128954&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const mutation = useMutation({
  mutationFn: async ({ profile, name }: FieldValues) =&amp;gt; {
    const imageUrl = await uploadImage(profile);
    const teamData: GroupData = {
      name,
    };

    if (imageUrl) {
      teamData.image = imageUrl;
    }

    return postGroup(teamData);
  },
  onSuccess: ({ id }) =&amp;gt; {
    router.push(`/${id}`);
  },
  onError: () =&amp;gt; {
    alert('팀 생성에 실패했습니다.');
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;onSubmit을 React Query의 mutation으로 리팩토링하면서 많은 장점을 얻었습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;onSuccess, onError&lt;/b&gt;&lt;/span&gt; 상태를 활용해 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;try/catch&lt;/b&gt;&lt;/span&gt; 보다 직관적으로 상태를 관리&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; onSuccess&lt;/b&gt; 발생 시 팀 데이터를 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;리패치(invalidateQueries)&lt;/b&gt;&lt;/span&gt;해 자동으로 팀 목록을 업데이트&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; React Query는 &lt;b&gt;전역적인 상태 관리 및 데이터 캐싱을 지원&lt;/b&gt;하기 때문에&lt;b&gt;&amp;nbsp;다른 곳에서도 쉽게 재사용 가능&lt;/b&gt; &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;mutation.isLoading&lt;/span&gt;&lt;/b&gt;을 활용해 간단하게 중복 데이터 요청을 방지&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;React Query를 사용 안 했을 때는 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;const [isLoading, setIsLoading] = useState(false);&lt;/b&gt;&lt;/span&gt;로 loading 상태를 별도로 관리해야 했음&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;사용자 경험(UX) 개선 - 로딩 상태 추가&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위에서 작업했던 useAuthRedirect 커스텀 훅을 적용했을 때, 접근할 수 없는 화면이 렌더링 된 후 로그인(/login) 페이지로 이동하는 불편함이 있었습니다.&lt;/span&gt;&lt;/p&gt;

            &lt;figure class=&quot;unsupported component-kakaotv&quot; contenteditable=&quot;false&quot; style=&quot;background:#000;margin:16px 0;min-height:72px;padding:10px 16px;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;width:100%;max-width:100%;&quot;&gt;
                &lt;p contenteditable=&quot;false&quot; style=&quot;margin:0;color:#8a8a8a;font-size:13px;line-height:1.6;user-select:none;pointer-events:none;&quot;&gt;동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.&lt;/p&gt;
            &lt;/figure&gt;
        
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 영상은 로그인을 하지 않은 사용자가 팀 생성(/addteam) 페이지로 접근하는 경우입니다. 팀 생성 페이지가 보인 후 로그인 페이지로 이동하도록 동작하는 것이 저에게는 불편하다고 느껴졌습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저의 일방적인 의견일 수 있어, 주변 사람들에게 피드백을 요청했고 대부분이 화면이 깜빡거리는 것처럼 느껴진다고 답했습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;개선 방향에 대해서는 로그인(/login) 페이지로 바로 이동하는 것이 아니라, 로그인 정보를 확인하고 있다는 알림이나 로딩 화면이 있으면 좋겠다는 의견이 많아서 로딩 화면을 추가해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;useAuthRedirect 훅 수정 - 로딩 state 추가&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1738921196187&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useSelector } from 'react-redux';
import { RootState } from '@/app/stores/store';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

const useAuthRedirect = () =&amp;gt; {
  const router = useRouter();
  const { accessToken } = useSelector((state: RootState) =&amp;gt; state.auth);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() =&amp;gt; {
    if (!accessToken) {
      setTimeout(() =&amp;gt; {
        alert('로그인 후 이용할 수 있습니다.');
        router.push('/login');
      }, 300);
    } else {
      setIsLoading(false);
    }
  }, [accessToken, router]);

  return { isLoading };
};

export default useAuthRedirect;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로딩 화면을 추가하기 위해 기존 커스텀 훅에 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;isLoading state&lt;/b&gt;&lt;/span&gt;를 추가했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;

            &lt;figure class=&quot;unsupported component-kakaotv&quot; contenteditable=&quot;false&quot; style=&quot;background:#000;margin:16px 0;min-height:72px;padding:10px 16px;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;width:100%;max-width:100%;&quot;&gt;
                &lt;p contenteditable=&quot;false&quot; style=&quot;margin:0;color:#8a8a8a;font-size:13px;line-height:1.6;user-select:none;pointer-events:none;&quot;&gt;동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.&lt;/p&gt;
            &lt;/figure&gt;
        
&lt;pre id=&quot;code_1738921366283&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Page() {
  const { isLoading } = useAuthRedirect();
  
  if (isLoading)
    return (
      &amp;lt;div className=&quot;flex h-screen items-center justify-center bg-black text-white opacity-50&quot;&amp;gt;
        로그인 정보 확인중...
      &amp;lt;/div&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로딩 상태를 보여주는 부분도 여러 페이지에서 사용하니 컴포넌트로 분리하겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;AuthCheckLoading.tsx - 로그인 정보 확인 로딩 화면&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738921466279&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function AuthCheckLoading() {
  return (
    &amp;lt;div className=&quot;flex h-screen items-center justify-center bg-black text-white opacity-50&quot;&amp;gt;
      로그인 정보 확인중...
    &amp;lt;/div&amp;gt;
  );
}

export default AuthCheckLoading;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1738921512928&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Page() {
  const { isLoading } = useAuthRedirect();
  
  if (isLoading) return &amp;lt;AuthCheckLoading /&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;컴포넌트로 분리함으로써 추후 디자인 수정을 고려했을 때 유지보수성과 재사용성 모두 개선되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;사용자 경험(UX) 개선 - 뒤로 가기 이슈 수정&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로그인 페이지로 리다이렉트 이후 뒤로 가기 버튼을 누르게 되면, 다시 로그인이 필요한 페이지로 이동하게 되어 로그인 페이지로 이동하도록 동작하고 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;간단하게 순서도로 정리해 보겠습니다. 내용은 로그인 안 한 사용자 기준입니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀 생성(/addteam) 페이지 접근&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로딩 페이지 렌더링 후 로그인(/login) 페이지로 리다이렉트&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;뒤로 가기 버튼 클릭&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀 생성(/addteam) 페이지로 이동&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로딩 페이지 렌더링 후 로그인(/login) 페이지로 리다이렉트&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;검색을 해본 결과로 위 내용을 해결하기 위해서는&amp;nbsp;router.push와 router.replace의 차이를 알아야 할 것 같습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;router.push : 새로운 페이지로 이동하며 &lt;b&gt;브라우저 히스토리에 기록&lt;/b&gt;됨 (이전 페이지로 돌아가기 가능)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;router.replace : 현재 페이지를 새로운 페이지로 &lt;b&gt;대체&lt;/b&gt;하며 &lt;b&gt;브라우저 히스토리를 덮어씀&lt;/b&gt; (이전 페이지로 돌아가기 불가능)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1738922430026&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useSelector } from 'react-redux';
import { RootState } from '@/app/stores/store';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

const useAuthRedirect = () =&amp;gt; {
  const router = useRouter();
  const { accessToken } = useSelector((state: RootState) =&amp;gt; state.auth);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() =&amp;gt; {
    if (!accessToken) {
      setTimeout(() =&amp;gt; {
        alert('로그인 후 이용할 수 있습니다.');
        router.replace('/login');
      }, 300);
    } else {
      setIsLoading(false);
    }
  }, [accessToken, router]);

  return { isLoading };
};

export default useAuthRedirect;&lt;/code&gt;&lt;/pre&gt;

            &lt;figure class=&quot;unsupported component-kakaotv&quot; contenteditable=&quot;false&quot; style=&quot;background:#000;margin:16px 0;min-height:72px;padding:10px 16px;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;width:100%;max-width:100%;&quot;&gt;
                &lt;p contenteditable=&quot;false&quot; style=&quot;margin:0;color:#8a8a8a;font-size:13px;line-height:1.6;user-select:none;pointer-events:none;&quot;&gt;동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.&lt;/p&gt;
            &lt;/figure&gt;
        
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;리팩토링을 진행하면서 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;&lt;b&gt;유지보수하기 쉬운 코드가 &lt;/b&gt;좋은 코드&lt;/b&gt;&lt;/span&gt;라는 점을 다시 한번 느꼈습니다. 처음부터 작업을 진행할 때 계획이나 설계 없이 코드부터 짜다 보니, 리팩토링 할 때 변경해야 할 부분이 너무 많다는 것을 실감했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;앞으로는 꾸준히 리팩토링 하며 &lt;b&gt;더 읽기 쉽고, 유지보수하기 쉬운 코드를 작성하는 개발자&lt;/b&gt;로 성장하도록 노력해야겠습니다!&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Next.js</category>
      <category>리팩토링</category>
      <category>프로젝트</category>
      <category>협업</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/184</guid>
      <comments>https://dev-hpk.tistory.com/184#entry184comment</comments>
      <pubDate>Fri, 7 Feb 2025 19:12:27 +0900</pubDate>
    </item>
    <item>
      <title>[맛길] 상세 페이지 - Youtube 영상 추가</title>
      <link>https://dev-hpk.tistory.com/183</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오늘은 상세 페이지를 작업할 건데요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;처음 기획 당시 페이지 상단에 맛집 소개 Youtube 영상을 띄우기로 했었네요.. 어떤 방법이 있는지 찾아볼게요 &lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;&lt;b&gt;✨ 영상 추가 방법&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;&lt;b&gt;1️⃣ 기본 &amp;lt;iframe /&amp;gt; 태그 이용&lt;/b&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;477&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/br8sOW/btsL9Osg4Yb/rmj6Wxo2AGPJXRfwYiM6X0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/br8sOW/btsL9Osg4Yb/rmj6Wxo2AGPJXRfwYiM6X0/img.png&quot; data-alt=&quot;youtube 동영상 퍼가기 iframe 제공&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/br8sOW/btsL9Osg4Yb/rmj6Wxo2AGPJXRfwYiM6X0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbr8sOW%2FbtsL9Osg4Yb%2Frmj6Wxo2AGPJXRfwYiM6X0%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;1280&quot; height=&quot;477&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;477&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;youtube 동영상 퍼가기 iframe 제공&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;유튜브 영상에 자체적으로 퍼가기 기능을 제공하고 있네요  &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;iframe의 src를 보니 프로젝트에서 사용하는 데이터의 videoId를 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;embed/&lt;/b&gt;&lt;/span&gt; 뒤에 추가하면 될 것 같아요.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;&lt;b&gt;2️⃣ react-youtube 라이브러리 및 Youtube API 사용&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;747&quot; data-origin-height=&quot;555&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xTnik/btsL8ZOS2wY/9a9OCO6HRSb0omIAexDpOk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xTnik/btsL8ZOS2wY/9a9OCO6HRSb0omIAexDpOk/img.png&quot; data-alt=&quot;npm - react-youtue 사용법&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xTnik/btsL8ZOS2wY/9a9OCO6HRSb0omIAexDpOk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxTnik%2FbtsL8ZOS2wY%2F9a9OCO6HRSb0omIAexDpOk%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;747&quot; height=&quot;555&quot; data-origin-width=&quot;747&quot; data-origin-height=&quot;555&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;npm - react-youtue 사용법&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;사용법을 보니 videoId 부분에 Youtube API에서 요청을 통해 받아온 videoId를 넣어주면 되는 것 같아요. &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;원래라면 Youtube API를 연동해서 아래와 같은 과정을 거치겠죠 &amp;nbsp;&lt;/span&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 서버에서 Youtube로 API key를 포함한 데이터 요청&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 유튜브 서버에서 응답 &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 서버에서 클라이언트로 응답 전달&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt;저는 Yotube API를 통해 받은 데이터를 JSON 형태로 사용하고 있기 때문에 위 과정을 생략할 수 있어 로딩도 빠르고 API 제한에 걸릴 문제도 해결할 수 있겠네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;&lt;b&gt;3️⃣ react-player 라이브러리 사용&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;859&quot; data-origin-height=&quot;213&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sHH7d/btsL907bTO3/fThMSgV6P6d6Cl74rTbrjk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sHH7d/btsL907bTO3/fThMSgV6P6d6Cl74rTbrjk/img.png&quot; data-alt=&quot;ReactPlayer 소개&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sHH7d/btsL907bTO3/fThMSgV6P6d6Cl74rTbrjk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsHH7d%2FbtsL907bTO3%2FfThMSgV6P6d6Cl74rTbrjk%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;859&quot; height=&quot;213&quot; data-origin-width=&quot;859&quot; data-origin-height=&quot;213&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ReactPlayer 소개&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;react-player 라이브러리는 다양한 플레이어를 지원 라이브러리이며 제가 원하는 Youtube 영상 또한 지원하네요.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;509&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3gXmR/btsL80ttCfM/JODhuFJKISw1khhRkffPVk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3gXmR/btsL80ttCfM/JODhuFJKISw1khhRkffPVk/img.png&quot; data-alt=&quot;npm trends 다운로드 지표&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3gXmR/btsL80ttCfM/JODhuFJKISw1khhRkffPVk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3gXmR%2FbtsL80ttCfM%2FJODhuFJKISw1khhRkffPVk%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;1310&quot; height=&quot;509&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;509&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;npm trends 다운로드 지표&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;npm trends의 지난 1년간 다운로드 지표만 봐도 react-player가 react-youtube에 비해 압도적이네요. &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;뿐만 아니라 Next.js의 공식 문서에서도 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;react-player를&lt;/span&gt; 추천하는 third-party 플레이어 중 하나로 선정했어요.&lt;/span&gt;&lt;span&gt;&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/app/building-your-application/optimizing/videos#video-best-practices&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;&lt;b&gt;Next.js 공식 문서 - Video&lt;/b&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;657&quot; data-origin-height=&quot;335&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/n0Yws/btsMaxXJaZR/i41kOLuvjnvMpRBYfXWxzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/n0Yws/btsMaxXJaZR/i41kOLuvjnvMpRBYfXWxzK/img.png&quot; data-alt=&quot;Next.js 공식 문서 - Video&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/n0Yws/btsMaxXJaZR/i41kOLuvjnvMpRBYfXWxzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fn0Yws%2FbtsMaxXJaZR%2Fi41kOLuvjnvMpRBYfXWxzK%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;657&quot; height=&quot;335&quot; data-origin-width=&quot;657&quot; data-origin-height=&quot;335&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Next.js 공식 문서 - Video&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  Youtube 영상 추가&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저는 라이브러리 의존성을 줄이고 빠른 작업을 위해 기본 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;&amp;lt;iframe /&amp;gt; 태그&lt;/b&gt;&lt;/span&gt;를 이용해 작업을 진행하기로 결정했어요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;우선 유튜브 영상을 보여주는 iframe을 컴포넌트로 분리해서 작업해 볼게요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;VideoPlayer.tsx - 유튜브 영상 플레이어&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738841555467&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function VideoPlayer({ videoId }: { videoId: string; }) {
  return (
    &amp;lt;iframe
      width=&quot;100%&quot;
      height=&quot;100%&quot;
      src={`https://www.youtube.com/embed/${videoId}`}
      title=&quot;YouTube video player&quot;
      allow=&quot;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share&quot;
      allowFullScreen
    /&amp;gt;
  );
}

export default VideoPlayer;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;VideoPlayer 컴포넌트는 부모 컴포넌트로부터 videoId를 props로 받아서 src에 추가하는 형식으로 작업했어요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;541&quot; data-origin-height=&quot;399&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dHjCUs/btsL9tBYLqx/DKERRYGUnwqeyqeSNfFi41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dHjCUs/btsL9tBYLqx/DKERRYGUnwqeyqeSNfFi41/img.png&quot; data-alt=&quot;iframe embed 코드 확인 경로&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dHjCUs/btsL9tBYLqx/DKERRYGUnwqeyqeSNfFi41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdHjCUs%2FbtsL9tBYLqx%2FDKERRYGUnwqeyqeSNfFi41%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;541&quot; height=&quot;399&quot; data-origin-width=&quot;541&quot; data-origin-height=&quot;399&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;iframe embed 코드 확인 경로&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;iframe 코드는 유튜브 영상에서 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;공유 &amp;rarr; 퍼가기&lt;/span&gt;&lt;/b&gt;를 통해 제공하고 있어서 따로 작성할 필요 없이 가져다 사용했어요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해 봐야겠죠&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;671&quot; data-origin-height=&quot;646&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdAKDa/btsL8IfslqB/U2abE2CmtGkYB302QN2pKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdAKDa/btsL8IfslqB/U2abE2CmtGkYB302QN2pKk/img.png&quot; data-alt=&quot;유튜브 영상 적용 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdAKDa/btsL8IfslqB/U2abE2CmtGkYB302QN2pKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdAKDa%2FbtsL8IfslqB%2FU2abE2CmtGkYB302QN2pKk%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;671&quot; height=&quot;646&quot; data-origin-width=&quot;671&quot; data-origin-height=&quot;646&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;유튜브 영상 적용 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;영상은 잘 나오는데 한 가지&amp;nbsp; 불편한 점이 있네요 &amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;653&quot; data-origin-height=&quot;634&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qUFm3/btsL9oAEqzB/82RZRPXN1rkb6UbeXRCdNK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qUFm3/btsL9oAEqzB/82RZRPXN1rkb6UbeXRCdNK/img.png&quot; data-alt=&quot;영상 로딩 중 빈 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qUFm3/btsL9oAEqzB/82RZRPXN1rkb6UbeXRCdNK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqUFm3%2FbtsL9oAEqzB%2F82RZRPXN1rkb6UbeXRCdNK%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;653&quot; height=&quot;634&quot; data-origin-width=&quot;653&quot; data-origin-height=&quot;634&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;영상 로딩 중 빈 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 사진처럼 영상이 로드되기 전까지 빈 화면이 노출되다가 영상이 나타나서 화면이 깜빡이고 있어요. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;어떻게 처리해야 할지 고민할 필요 없겠네요! &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이전 프로젝트들에서 API 요청을 할 때 이미 수 차례 isLoading 상태를 관리해 봤잖아요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Loading 상태 추가 - thumbnail 데이터 이용&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738842997839&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import { useState } from 'react';
import Image from 'next/image';

function VideoPlayer({ videoId, lazy }: { videoId: string; lazy: string }) {
  const [isLoaded, setIsLoaded] = useState(false);

  const handleLoad = () =&amp;gt; {
    setIsLoaded(true);
  };
  
  return (
    &amp;lt;&amp;gt;
      {!isLoaded &amp;amp;&amp;amp; (
        &amp;lt;Image fill src={lazy} className=&quot;object-cover&quot; alt=&quot;Video Thumbnail&quot; /&amp;gt;
      )}
      &amp;lt;iframe
        width=&quot;100%&quot;
        height=&quot;100%&quot;
        src={`https://www.youtube.com/embed/${videoId}`}
        title=&quot;YouTube video player&quot;
        allow=&quot;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share&quot;
        allowFullScreen
        onLoad={handleLoad}
        loading=&quot;lazy&quot;
      /&amp;gt;
    &amp;lt;/&amp;gt;
  );
}

export default VideoPlayer;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;iframe의 onLoad 이벤트 핸들러를 추가해 로드가 되기 전까지 thumbnail을 보여주도록 작업했어요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해 볼게요&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;맛길-Chrome-2025-02-06-20-53-49.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUhr9y/btsMaw5x7zK/1G411HAaSPIWpLEgCYYKB0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUhr9y/btsMaw5x7zK/1G411HAaSPIWpLEgCYYKB0/img.gif&quot; data-alt=&quot;유튜브 영상 loading 처리&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUhr9y/btsMaw5x7zK/1G411HAaSPIWpLEgCYYKB0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bUhr9y/btsMaw5x7zK/1G411HAaSPIWpLEgCYYKB0/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;맛길-Chrome-2025-02-06-20-53-49.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;유튜브 영상 loading 처리&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;영상이 로딩 상태인 경우 영상의 Thumbnail로 대체하니 훨씬 자연스러워졌네요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이렇게 마무리되는 줄 알았는데 문제가 생겼어요. 느린 4G, 3G 등 네트워크 throttling 환경에서 &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;iframe에 등록한 onLoad 이벤트 핸들러가 동작하지 않는 경우가 종종 발생해서 무슨 문제인지 검색해 봐도 해결 방법이 나오지 않네요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;714&quot; data-origin-height=&quot;841&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cpKXR0/btsMah8BoaA/UeOA6IwbcU0txJzmvrGBh1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cpKXR0/btsMah8BoaA/UeOA6IwbcU0txJzmvrGBh1/img.png&quot; data-alt=&quot;onLoad 이슈 구글링&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cpKXR0/btsMah8BoaA/UeOA6IwbcU0txJzmvrGBh1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcpKXR0%2FbtsMah8BoaA%2FUeOA6IwbcU0txJzmvrGBh1%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;714&quot; height=&quot;841&quot; data-origin-width=&quot;714&quot; data-origin-height=&quot;841&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;onLoad 이슈 구글링&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;https://github.com/facebook/react/issues/6541#issuecomment-1174249634&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;깃헙의 이슈&lt;/a&gt;에 따르면, iframe의 로드 이벤트는 React가 리스너를 추가하기 전에 발생하는 경우가 있다고 하네요. 이로&amp;nbsp;인해&amp;nbsp;iframe의&amp;nbsp;onLoad&amp;nbsp;이벤트가&amp;nbsp;호출되지&amp;nbsp;않는&amp;nbsp;문제가&amp;nbsp;발생하는&amp;nbsp;것&amp;nbsp;같습니다.&amp;nbsp;이러한&amp;nbsp;현상은&amp;nbsp;React의&amp;nbsp;hydration&amp;nbsp;과정과&amp;nbsp;관련이&amp;nbsp;있는&amp;nbsp;것으로&amp;nbsp;보입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Hydration은 서버에서 전달받은 정적인 HTML에 이벤트 리스너를 연결하여 동적으로 만드는 과정입니다. 이 &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;과정을 살펴보면 서버에서 렌더링 된 html에 onLoad 이벤트를 추가하여 클라이언트로 전달합니다. 이때 onLoad 이벤트가 이미 실행된 상태일 수 있습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;만약 느린 네트워크 환경에서 비동기로 로드되는 유튜브 콘텐츠가 지연된다면, onLoad 이벤트가 종료된 후 iframe이 마운트 되어 onLoad 이벤트가 호출되지 않는 것처럼 보이게 되는 것입니다.&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;문제 원인이 hydration 과정에 있는지 확인하기 위해, iframe 요소를 동적으로 추가하는 코드를 통해 검증해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;iframe 동적으로 추가&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742190710566&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import { useEffect, useState, useRef } from 'react';

function Iframe() {
  const [isLoading, setIsLoading] = useState(true);
  const iframeRef = useRef(null);

  useEffect(() =&amp;gt; {
    const iframe = document.createElement('iframe');
    iframe.src = 'https://www.youtube.com/embed/he0KEFFhvvA';
    iframe.width = '500';
    iframe.height = '300';
    iframe.onload = () =&amp;gt; {
      console.log('iFrame Loaded');
      setIsLoading(false);
    };

    if (iframeRef.current) {
      iframeRef.current.appendChild(iframe);
    }

    return () =&amp;gt; {
      if (iframeRef.current) {
        iframeRef.current.removeChild(iframe);
      }
    };
  }, []);

  return (
    &amp;lt;&amp;gt;
      {isLoading &amp;amp;&amp;amp; (
        &amp;lt;div className=&quot;h-[300px] w-[500px] bg-white text-black&quot;&amp;gt;
          Loading...
        &amp;lt;/div&amp;gt;
      )}
      &amp;lt;div ref={iframeRef} /&amp;gt;
    &amp;lt;/&amp;gt;
  );
}

export default Iframe;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;맛길-_-맛집-추천-_amp_-길찾기-Chrome-2025-03-17-14-57-11.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k81mg/btsMMGHxXbm/lgf71TOhMkttpjr77KUXe0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k81mg/btsMMGHxXbm/lgf71TOhMkttpjr77KUXe0/img.gif&quot; data-alt=&quot;iframe 동적 추가 - 느린 네트워크 환경 onLoad 호출 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k81mg/btsMMGHxXbm/lgf71TOhMkttpjr77KUXe0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/k81mg/btsMMGHxXbm/lgf71TOhMkttpjr77KUXe0/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;맛길-_-맛집-추천-_amp_-길찾기-Chrome-2025-03-17-14-57-11.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;iframe 동적 추가 - 느린 네트워크 환경 onLoad 호출 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Network&amp;nbsp;Throttling을&amp;nbsp;통해&amp;nbsp;느린&amp;nbsp;네트워크&amp;nbsp;환경에서&amp;nbsp;테스트해 본&amp;nbsp;결과,&amp;nbsp;onLoad&amp;nbsp;이벤트가&amp;nbsp;정상적으로&amp;nbsp;동작하는&amp;nbsp;것을&amp;nbsp;확인했습니다.&amp;nbsp;이는&amp;nbsp;hydration&amp;nbsp;과정이&amp;nbsp;끝난&amp;nbsp;후&amp;nbsp;클라이언트에서&amp;nbsp;iframe을&amp;nbsp;추가했기&amp;nbsp;때문입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;따라서&amp;nbsp;문제의&amp;nbsp;원인은&amp;nbsp;iframe의&amp;nbsp;콘텐츠가&amp;nbsp;비동기로&amp;nbsp;로드되기&amp;nbsp;때문에,&amp;nbsp;느린&amp;nbsp;네트워크&amp;nbsp;환경에서는&amp;nbsp;마운트 되는&amp;nbsp;시점이&amp;nbsp;미뤄져&amp;nbsp;onLoad&amp;nbsp;이벤트가&amp;nbsp;이미&amp;nbsp;실행이&amp;nbsp;끝난&amp;nbsp;후에&amp;nbsp;iframe이&amp;nbsp;마운트 되므로&amp;nbsp;onLoad가&amp;nbsp;호출되지&amp;nbsp;않는&amp;nbsp;것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 방법을 채택하려고 했지만 몇 가지 문제가 있습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;페이지 로드 시점에 iframe을 동적으로 생성하는 추가적인 JavaScript 실행 필요 (초기 로드 성능 저하)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;iframe을 동적으로 생성하기 때문에 검색 엔진이 콘텐츠를 인식하지 못함 (접근성, SEO 문제)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;791&quot; data-origin-height=&quot;808&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgEvks/btsML8q5xgC/rUBPBavCutLYhifKbd8V10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgEvks/btsML8q5xgC/rUBPBavCutLYhifKbd8V10/img.png&quot; data-alt=&quot;iframe을 동적으로 추가한 Lighthouse 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgEvks/btsML8q5xgC/rUBPBavCutLYhifKbd8V10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgEvks%2FbtsML8q5xgC%2FrUBPBavCutLYhifKbd8V10%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;791&quot; height=&quot;808&quot; data-origin-width=&quot;791&quot; data-origin-height=&quot;808&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;iframe을 동적으로 추가한 Lighthouse 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;722&quot; data-origin-height=&quot;221&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxRC8O/btsMOs82CXV/a8NqZU6z3izOplK0kJ1pKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxRC8O/btsMOs82CXV/a8NqZU6z3izOplK0kJ1pKK/img.png&quot; data-alt=&quot;동적 iframe 성능 진단 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxRC8O/btsMOs82CXV/a8NqZU6z3izOplK0kJ1pKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxRC8O%2FbtsMOs82CXV%2Fa8NqZU6z3izOplK0kJ1pKK%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;722&quot; height=&quot;221&quot; data-origin-width=&quot;722&quot; data-origin-height=&quot;221&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;동적 iframe 성능 진단 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;760&quot; data-origin-height=&quot;801&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2oa3V/btsMN9oo1wb/eSlEb8pBfRAYBPx0VKukNK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2oa3V/btsMN9oo1wb/eSlEb8pBfRAYBPx0VKukNK/img.png&quot; data-alt=&quot;React-Player를 사용한 Lighthouse 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2oa3V/btsMN9oo1wb/eSlEb8pBfRAYBPx0VKukNK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2oa3V%2FbtsMN9oo1wb%2FeSlEb8pBfRAYBPx0VKukNK%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;760&quot; height=&quot;801&quot; data-origin-width=&quot;760&quot; data-origin-height=&quot;801&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;React-Player를 사용한 Lighthouse 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;713&quot; data-origin-height=&quot;173&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ccMpDo/btsMN7RInf7/9BhsKeTWkkpsBBsrG9w64k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ccMpDo/btsMN7RInf7/9BhsKeTWkkpsBBsrG9w64k/img.png&quot; data-alt=&quot;React-Player 성능 진단 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ccMpDo/btsMN7RInf7/9BhsKeTWkkpsBBsrG9w64k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FccMpDo%2FbtsMN7RInf7%2F9BhsKeTWkkpsBBsrG9w64k%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;713&quot; height=&quot;173&quot; data-origin-width=&quot;713&quot; data-origin-height=&quot;173&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;React-Player 성능 진단 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Player 라이브러리를 사용한 결과 다음과 같은 성과를 얻었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;성능(performance) 점수 : 76에서 85로 9점 향상&lt;/li&gt;
&lt;li&gt;TBT(Total Blocking Time) : 600ms에서 340ms로 개선&lt;/li&gt;
&lt;li&gt;접근성 점수 : 95에서 100으로 5점 향상&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;라이브러리 의존성을 줄이는 것도 좋지만, 효율적인 개발과 안정적인 서비스 제공을 위해 &lt;b&gt;React-Player&lt;/b&gt; 라이브러리를 사용해 이슈를 해결하기로 판단했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;⭐ React-Player 라이브러리 사용&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;react-player의 github 문서를 확인해 봤더니&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;onReady Prop&lt;/b&gt;&lt;/span&gt;을&amp;nbsp; 제공하고 있네요!&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;857&quot; data-origin-height=&quot;116&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ABlpZ/btsL8Wq5Mtd/tZL6SKTuTcoV25Faklex4K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ABlpZ/btsL8Wq5Mtd/tZL6SKTuTcoV25Faklex4K/img.png&quot; data-alt=&quot;react-player 문서&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ABlpZ/btsL8Wq5Mtd/tZL6SKTuTcoV25Faklex4K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FABlpZ%2FbtsL8Wq5Mtd%2FtZL6SKTuTcoV25Faklex4K%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;857&quot; height=&quot;116&quot; data-origin-width=&quot;857&quot; data-origin-height=&quot;116&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;react-player 문서&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;설명에 media가 로드되고 재생할 준비가 되면 호출된다고 하니 iframe 대신 react-player를 사용해 볼게요 &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738844722711&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import { useEffect, useState } from 'react';
import Image from 'next/image';
import ReactPlayer from 'react-player/lazy';

function VideoPlayer({ videoId, lazy }: { videoId: string; lazy: string }) {
  const [isLoaded, setIsLoaded] = useState(false);

  return (
    &amp;lt;&amp;gt;
      {!isLoaded &amp;amp;&amp;amp; (
        &amp;lt;Image fill src={lazy} className=&quot;object-cover&quot; alt=&quot;Video Thumbnail&quot; /&amp;gt;
      )}
      &amp;lt;ReactPlayer
        url={`https://www.youtube.com/watch?v=${videoId}`}
        controls
        width=&quot;100%&quot;
        height=&quot;100%&quot;
        onReady={() =&amp;gt; setIsLoaded(true)}
      /&amp;gt;
    &amp;lt;/&amp;gt;
  );
}

export default VideoPlayer;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;658&quot; data-origin-height=&quot;654&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJ6D6X/btsL8NOMyLJ/vwwZycHXUXPgKd5HvL7No0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJ6D6X/btsL8NOMyLJ/vwwZycHXUXPgKd5HvL7No0/img.png&quot; data-alt=&quot;react-player 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJ6D6X/btsL8NOMyLJ/vwwZycHXUXPgKd5HvL7No0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJ6D6X%2FbtsL8NOMyLJ%2FvwwZycHXUXPgKd5HvL7No0%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;658&quot; height=&quot;654&quot; data-origin-width=&quot;658&quot; data-origin-height=&quot;654&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;react-player 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;유튜브 영상은 잘 나오는데 한 가지 문제가 생겼어요&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  문제 상황 - Hydration Mismatch 에러&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;911&quot; data-origin-height=&quot;187&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k9pBz/btsL8T19YcZ/KQ6sabJj1IO2aBE1CTzHP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k9pBz/btsL8T19YcZ/KQ6sabJj1IO2aBE1CTzHP0/img.png&quot; data-alt=&quot;react-player hydration 에러&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k9pBz/btsL8T19YcZ/KQ6sabJj1IO2aBE1CTzHP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk9pBz%2FbtsL8T19YcZ%2FKQ6sabJj1IO2aBE1CTzHP0%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;911&quot; height=&quot;187&quot; data-origin-width=&quot;911&quot; data-origin-height=&quot;187&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;react-player hydration 에러&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 이 에러는 &lt;b&gt;Next.js에서 서버 사이드 렌더링(SSR)된 HTML과 클라이언트에서 실행된 React 컴포넌트의 결과가 다를 때&lt;/b&gt; 발생해요! &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;에러를 해결하려면 Hydration에 대한 이해가 필요해 보이네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Hydration은 수분 보충이라는 뜻을 가지고 있어요. &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;간단하게 설명하면 정적인 HTML(SSR)을 Hydration(수분보충)을 통해 동적(CSR)으로 만드는 과정이에요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;더 간단하게 설명하자면 서버에서 받은 HTML에 이벤트 리스너를 등록하는 작업이라고 할 수 있겠네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;다시 프로젝트로 돌아와서 에러를 살펴볼게요. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; font-family: 'Noto Sans Demilight', 'Noto Sans KR'; letter-spacing: 0px;&quot;&gt;React Player 라이브러리는 렌더링 시에 일부 DOM 엘리먼트를 &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;동적으로&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; font-family: 'Noto Sans Demilight', 'Noto Sans KR'; letter-spacing: 0px;&quot;&gt; 생성해요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;따라서 서버에서 미리 렌더링 된 HTML과 클라이언트에서 실행된 결과가 다를 수 있는 거죠. &lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이런 차이로 인해&amp;nbsp;&lt;/span&gt;&lt;b&gt;Next.js의 Hydration 과정에서 오류가 발생&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;하게 되는 거예요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이를 방지하려면, React Player를&amp;nbsp;&lt;/span&gt;&lt;b&gt;클라이언트에서만 렌더링하도록&lt;/b&gt; 설정해야겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;CSR 적용 - React Player 클라이언트에서만 렌더링&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738846200062&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import { useEffect, useState } from 'react';
import Image from 'next/image';
import ReactPlayer from 'react-player/lazy';

function VideoPlayer({ videoId, lazy }: { videoId: string; lazy: string }) {
  const [isLoaded, setIsLoaded] = useState(false);
  const [isClient, setIsClient] = useState(false);

  useEffect(() =&amp;gt; {
    if (typeof window !== 'undefined') {
      setIsClient(true);
    }
  }, []);

  return (
    &amp;lt;&amp;gt;
      {!isLoaded &amp;amp;&amp;amp; (
        &amp;lt;Image fill src={lazy} className=&quot;object-cover&quot; alt=&quot;Video Thumbnail&quot; /&amp;gt;
      )}
      {isClient &amp;amp;&amp;amp; (
        &amp;lt;ReactPlayer
          url={`https://www.youtube.com/watch?v=${videoId}`}
          controls
          width=&quot;100%&quot;
          height=&quot;100%&quot;
          onReady={() =&amp;gt; setIsLoaded(true)}
        /&amp;gt;
      )}
    &amp;lt;/&amp;gt;
  );
}

export default VideoPlayer;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;644&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/umkOt/btsMaliQzr9/KHtsHATRXSTtdrYP2LB1yk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/umkOt/btsMaliQzr9/KHtsHATRXSTtdrYP2LB1yk/img.png&quot; data-alt=&quot;hydration mismatch 에러 해결&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/umkOt/btsMaliQzr9/KHtsHATRXSTtdrYP2LB1yk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FumkOt%2FbtsMaliQzr9%2FKHtsHATRXSTtdrYP2LB1yk%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;1894&quot; height=&quot;644&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;644&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;hydration mismatch 에러 해결&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  느낀 점&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;YouTube 영상 추가 작업을 하면서 단순히 기능을 구현하는 걸 넘어, &lt;b&gt;&quot;왜 이렇게 동작하는 걸까?&quot;&lt;/b&gt; 하는 고민을 깊이 하게 되었어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;에러를 해결하는 과정에서 &lt;b&gt;SSR, Hydration, Lazy Loading&lt;/b&gt; 같은 프론트엔드 성능 최적화 개념들을 자연스럽게 익히게 됐는데요. 단순히 개념만 찾아보는 게 아니라, 실제 코드에서 적용하고 문제를 해결하는 과정이 앞으로 더 나은 방향을 고민하는 데 큰 도움이 될 것 같아요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 예전에는 &lt;b&gt;&quot;일단 동작하는 코드&quot;&lt;/b&gt;를 만드는 게 목표였다면, 이제는 &lt;b&gt;&quot;확장 가능하고 최적화된 코드&quot;&lt;/b&gt;를 고민하는 저를 보면서 개발자로서 한층 성장하고 있다는 느낌이 들어요.&amp;nbsp; &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;앞으로도 작은 문제 하나하나 깊이 탐구하면서, 더 탄탄한 개발자로 성장해 나가고 싶어요.&amp;nbsp; &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;해결해야 할 것들은 여전히 많지만, 그 과정 자체가 점점 더 재미있게 느껴지네요!&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>Hydration</category>
      <category>hydration mismatch</category>
      <category>iframe</category>
      <category>Next.js</category>
      <category>react-player</category>
      <category>사이드 프로젝트</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/183</guid>
      <comments>https://dev-hpk.tistory.com/183#entry183comment</comments>
      <pubDate>Thu, 6 Feb 2025 22:34:51 +0900</pubDate>
    </item>
    <item>
      <title>[맛길] 동적 라우팅(Dynamic Routing) 적용</title>
      <link>https://dev-hpk.tistory.com/182</link>
      <description>&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  문제 상황&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;프로젝트를 진행하면서 Youtube 채널을 추가하면서 점점 불편함을 느끼게 되었어요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;120&quot; data-origin-height=&quot;124&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8gwJY/btsL7RP56av/UPhvImBl0kxN2gK0pj1rhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8gwJY/btsL7RP56av/UPhvImBl0kxN2gK0pj1rhK/img.png&quot; data-alt=&quot;폴더 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8gwJY/btsL7RP56av/UPhvImBl0kxN2gK0pj1rhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8gwJY%2FbtsL7RP56av%2FUPhvImBl0kxN2gK0pj1rhK%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;120&quot; height=&quot;124&quot; data-origin-width=&quot;120&quot; data-origin-height=&quot;124&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;폴더 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위의 이미지처럼 채널이 추가될 때마다 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/app 폴더&lt;/b&gt;&lt;/span&gt; 하위에 폴더가 점점 늘어나 구조가 너무 복잡해지게 되더라고요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;왜 채널(라우트 페이지)마다 폴더를 만드는지 궁금하실 수 있겠네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;Next.js가 &lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px; background-color: #ffffff; color: #334155;&quot;&gt;파일 시스템 기반의 라우터를 사용하여&amp;nbsp;&lt;/span&gt;&lt;b&gt;폴더&lt;/b&gt;&lt;span style=&quot;letter-spacing: 0px; background-color: #ffffff; color: #334155;&quot;&gt;를 경로 정의에 사용하기 때문이에요.&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px; background-color: #ffffff; color: #334155;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;687&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dVVUSV/btsL7ta7CCx/AQQvZkgdbgj1C6aNazDDrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dVVUSV/btsL7ta7CCx/AQQvZkgdbgj1C6aNazDDrK/img.png&quot; data-alt=&quot;next.js 폴더 구조에 따른 경로&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dVVUSV/btsL7ta7CCx/AQQvZkgdbgj1C6aNazDDrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdVVUSV%2FbtsL7ta7CCx%2FAQQvZkgdbgj1C6aNazDDrK%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;1600&quot; height=&quot;687&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;687&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;next.js 폴더 구조에 따른 경로&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 예시를 보시면 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/app 폴더&lt;/b&gt;&lt;/span&gt; 하위의 폴더 이름이 경로로 사용되고 있어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;/{channel}/page.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738738619417&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from '@/app/lib/instance';
import ListContainer from '@/app/components/lists/ListContainer';

async function page() {
  const {
    data: { lists, hasNext },
  } = await axios.get('bjw');

  return &amp;lt;ListContainer channel=&quot;bjw&quot; hasNext={hasNext} lists={lists} /&amp;gt;;
}

export default page;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 코드는 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/bjw&lt;/b&gt;&lt;/span&gt; 페이지에 대한 page.tsx 파일인데요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;똑같은 코드를 각 채널(ssg, pungja, hsc...)의 page.tsx에 작업해줘야 하니 불쾌한 개발 경험인 거죠.. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;여기서 끝이면 그냥 채널마다 폴더를 생성할까도 고민했지만... 맛길은 Serverless Function을 이용한 프로젝트에요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;API Route도 채널마다 작업해줘야 하니 불쾌한 개발 경험이 2배에요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;220&quot; data-origin-height=&quot;222&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4xO8b/btsL7M2C4us/Wvh490tGMhHAChxHuQHLkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4xO8b/btsL7M2C4us/Wvh490tGMhHAChxHuQHLkK/img.png&quot; data-alt=&quot;api 폴더 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4xO8b/btsL7M2C4us/Wvh490tGMhHAChxHuQHLkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4xO8b%2FbtsL7M2C4us%2FWvh490tGMhHAChxHuQHLkK%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;220&quot; height=&quot;222&quot; data-origin-width=&quot;220&quot; data-origin-height=&quot;222&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;api 폴더 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1738740811822&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import path from 'path';
import { promises as fs } from 'fs';
import { RawYoutubeData, YoutubeData } from '@/app/types/youtube';

export async function GET(request: Request): Promise&amp;lt;Response&amp;gt; {
  try {
    // JSON 파일 경로 설정
    const filePath = path.join(process.cwd(), 'data', 'baekjongwon.json');

    // 파일 읽기
    const fileContents = await fs.readFile(filePath, 'utf-8');

    // JSON 파싱
    const rawData: RawYoutubeData[] = JSON.parse(fileContents);

    // 필요한 데이터만 추출
    const data: YoutubeData[] = rawData.map((item) =&amp;gt; ({
      id: item.id,
      position: item.snippet.position,
      title: item.snippet.title,
      thumbnailUrl: item.snippet.thumbnails.high.url,
    }));

    const url = new URL(request.url);
    const limitParam = url.searchParams.get('limit');
    const cursorParam = url.searchParams.get('cursor');

    const limit = limitParam ? parseInt(limitParam, 10) : 10;
    const cursor = cursorParam ? parseInt(cursorParam, 10) : null;

    // cursor가 있으면 해당 position 이후 데이터 필터링
    const filteredData =
      cursor !== null ? data.filter((item) =&amp;gt; item.position &amp;gt; cursor) : data;

    // 제한된 개수만큼 데이터 가져오기
    const limitedData = filteredData.slice(0, limit);

    // hasNext 설정 (더 가져올 데이터가 있는지 확인)
    const hasNext = filteredData.length &amp;gt; limit;

    return new Response(
      JSON.stringify({ success: true, lists: limitedData, hasNext }),
      {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  } catch (error) {
    console.error('데이터를 받아오는 중 에러가 발생했습니다:', error);
    return new Response(
      JSON.stringify({ success: false, message: '데이터 요청 실패' }),
      {
        status: 500,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;a style=&quot;background-color: #e6f5ff; color: #0070d1; text-align: start;&quot; href=&quot;https://nextjs.org/docs/pages/building-your-application/routing/dynamic-routes&quot;&gt;Next.js 공식 문서&lt;/a&gt;를 찾아보다가 해결할 수 있는 방법을 찾은 것 같아요❗&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;484&quot; data-origin-height=&quot;221&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rCLfb/btsL7ek0OTc/gvi8wwYsAYzGwDkqL0Nep1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rCLfb/btsL7ek0OTc/gvi8wwYsAYzGwDkqL0Nep1/img.png&quot; data-alt=&quot;Dynamic routes&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rCLfb/btsL7ek0OTc/gvi8wwYsAYzGwDkqL0Nep1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrCLfb%2FbtsL7ek0OTc%2Fgvi8wwYsAYzGwDkqL0Nep1%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;484&quot; height=&quot;221&quot; data-origin-width=&quot;484&quot; data-origin-height=&quot;221&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Dynamic routes&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;686&quot; data-origin-height=&quot;211&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQVZ6z/btsL7gv9mdv/Bw4mYCrk51u7MFRanc6991/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQVZ6z/btsL7gv9mdv/Bw4mYCrk51u7MFRanc6991/img.png&quot; data-alt=&quot;Dynamic routes 예시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQVZ6z/btsL7gv9mdv/Bw4mYCrk51u7MFRanc6991/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQVZ6z%2FbtsL7gv9mdv%2FBw4mYCrk51u7MFRanc6991%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;686&quot; height=&quot;211&quot; data-origin-width=&quot;686&quot; data-origin-height=&quot;211&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Dynamic routes 예시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Next.js에서 폴더 이름을 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;[ ]&lt;/span&gt;&lt;/b&gt;로 감싸서 Dynamic routes(동적 라우팅)를 할 수 있다고 하네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;프로젝트에 적용해 볼게요 &lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  문제 해결&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1️⃣ 채널(페이지) 폴더 구조 수정&amp;nbsp;&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;180&quot; data-origin-height=&quot;115&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tUjMm/btsL7FJddUc/Qcedd8vSTnQKuhPIOM2wXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tUjMm/btsL7FJddUc/Qcedd8vSTnQKuhPIOM2wXK/img.png&quot; data-alt=&quot;Dynamic routes 적용한 폴더 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tUjMm/btsL7FJddUc/Qcedd8vSTnQKuhPIOM2wXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtUjMm%2FbtsL7FJddUc%2FQcedd8vSTnQKuhPIOM2wXK%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;180&quot; height=&quot;115&quot; data-origin-width=&quot;180&quot; data-origin-height=&quot;115&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Dynamic routes 적용한 폴더 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;맛길 서비스는 아래 두 가지 페이지로 나뉠 예정이라 폴더 구조를 위 이미지처럼 수정했어요.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;/{channel} : 해당 채널의 목록을 보여주는 페이지&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;/{channel}/{id} : 채널 목록의 id에 해당하는 상세 페이지&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2️⃣ /app/[channel]/page.ts 파일 수정&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;공식 문서의 예시를 보면 params에 폴더 이름에&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;[ ]&lt;/b&gt;&lt;/span&gt;로 감싼&amp;nbsp;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #334155; text-align: start;&quot;&gt;&lt;b&gt;동적 세그먼트&lt;/b&gt;를&amp;nbsp;&lt;/span&gt;&lt;b&gt;params&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #334155; text-align: start;&quot;&gt;&lt;b&gt;&amp;nbsp;prop&lt;/b&gt;으로 사용할 수 있네요.&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #334155; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;동적 세그먼트(channel)&lt;/b&gt;를 params prop으로 받아서 서버 데이터를 요청하고 화면에 보여주도록 수정할게요❗&amp;nbsp; &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738740782867&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from '@/app/lib/instance';
import ListContainer from '@/app/components/lists/ListContainer';

async function ChannelHome({ params }: { params: { channel: string } }) {
  const { channel } = params;

  const {
    data: { lists, hasNext },
  } = await axios.get(channel);

  return &amp;lt;ListContainer channel={channel} hasNext={hasNext} lists={lists} /&amp;gt;;
}

export default ChannelHome;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해 볼까요&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;454&quot; data-origin-height=&quot;832&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWgjHW/btsL7Qw0CEa/krfjPOJLCEcQxqO5hEM3Bk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWgjHW/btsL7Qw0CEa/krfjPOJLCEcQxqO5hEM3Bk/img.png&quot; data-alt=&quot;Dynamic routes 적용 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWgjHW/btsL7Qw0CEa/krfjPOJLCEcQxqO5hEM3Bk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWgjHW%2FbtsL7Qw0CEa%2FkrfjPOJLCEcQxqO5hEM3Bk%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;454&quot; height=&quot;832&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;454&quot; data-origin-height=&quot;832&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Dynamic routes 적용 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1243&quot; data-origin-height=&quot;688&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b8m55C/btsL7CeI4rR/K7UzevcA9UPH0oFSj3KLyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b8m55C/btsL7CeI4rR/K7UzevcA9UPH0oFSj3KLyK/img.png&quot; data-alt=&quot;Dynamic routes 적용 후 SSR(Server Side Rendering) 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b8m55C/btsL7CeI4rR/K7UzevcA9UPH0oFSj3KLyK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb8m55C%2FbtsL7CeI4rR%2FK7UzevcA9UPH0oFSj3KLyK%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;1243&quot; height=&quot;688&quot; data-origin-width=&quot;1243&quot; data-origin-height=&quot;688&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Dynamic routes 적용 후 SSR(Server Side Rendering) 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;동적 라우팅이 잘 동작하네요 &lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;3️⃣ API Route&lt;/b&gt;&lt;b&gt;&amp;nbsp;폴더 구조 수정&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;225&quot; data-origin-height=&quot;91&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/t03JR/btsL75VgiFI/nKJYS1CF4BsbaAMzilVdmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/t03JR/btsL75VgiFI/nKJYS1CF4BsbaAMzilVdmk/img.png&quot; data-alt=&quot;Dynamic routes 적용한 폴더 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/t03JR/btsL75VgiFI/nKJYS1CF4BsbaAMzilVdmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Ft03JR%2FbtsL75VgiFI%2FnKJYS1CF4BsbaAMzilVdmk%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;225&quot; height=&quot;91&quot; data-origin-width=&quot;225&quot; data-origin-height=&quot;91&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Dynamic routes 적용한 폴더 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;채널(페이지)과 마찬가지로 리스트 페이지와 세부 페이지를 구분하기 위해 폴더 구조를 위 이미지처럼 수정했어요.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;/{channel} : 해당 채널의 목록 데이터를 요청하는 &lt;b&gt;엔드 포인트(end point)&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;/{channel}/{id} : 채널 목록의 id에 해당하는 데이터를 요청하는 &lt;b&gt;엔드 포인트(end point)&lt;/b&gt; &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; &lt;b&gt;4️⃣&lt;/b&gt; /api/[channel]/route.ts 파일 수정&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존 api 호출 함수에서는 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;const filePath = path.join(process.cwd(), 'data', 'baekjongwon.json');&lt;/span&gt;&lt;/b&gt; 형식으로 json 파일을 직접 입력해서 사용하는데요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이 부분도&amp;nbsp;&lt;b&gt;동적 세그먼트(channel)&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #334155; text-align: start;&quot;&gt;를 이용하도록 수정해 볼게요.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738743032590&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import path from 'path';
import { promises as fs } from 'fs';
import { RawYoutubeData, YoutubeData } from '@/app/types/youtube';

export async function GET(request: Request): Promise&amp;lt;Response&amp;gt; {
  try {
    // URL에서 파일 이름 추출 (예: /api/pungja -&amp;gt; pungja.json)
    const url = new URL(request.url);
    const pathname = url.pathname; // /api/{filename}
    const filename = pathname.split('/').pop(); // {filename}
    const jsonFileName = `${filename}.json`;

    // JSON 파일 경로 설정
    const filePath = path.join(process.cwd(), 'data', jsonFileName);

    // 파일 읽기
    const fileContents = await fs.readFile(filePath, 'utf-8');

    // JSON 파싱
    const rawData: RawYoutubeData[] = JSON.parse(fileContents);

    // 필요한 데이터만 추출
    const data: YoutubeData[] = rawData.map((item) =&amp;gt; ({
      id: item.id,
      position: item.snippet.position,
      title: item.snippet.title,
      thumbnailUrl: item.snippet.thumbnails.high.url,
    }));

    const limitParam = url.searchParams.get('limit');
    const cursorParam = url.searchParams.get('cursor');

    const limit = limitParam ? parseInt(limitParam, 10) : 10;
    const cursor = cursorParam ? parseInt(cursorParam, 10) : null;

    // cursor가 있으면 해당 position 이후 데이터 필터링
    const filteredData =
      cursor !== null ? data.filter((item) =&amp;gt; item.position &amp;gt; cursor) : data;

    // 제한된 개수만큼 데이터 가져오기
    const limitedData = filteredData.slice(0, limit);

    // hasNext 설정 (더 가져올 데이터가 있는지 확인)
    const hasNext = filteredData.length &amp;gt; limit;

    return new Response(
      JSON.stringify({ success: true, lists: limitedData, hasNext }),
      {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  } catch (error) {
    console.error('데이터를 받아오는 중 에러가 발생했습니다:', error);
    return new Response(
      JSON.stringify({ success: false, message: '데이터 요청 실패' }),
      {
        status: 500,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;const filename = pathname.split('/').pop();&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Dynamic route에서 url의 형태가 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/서버주소/{channel}&lt;/b&gt;&lt;/span&gt;일 것이기 때문에 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/&lt;/b&gt;&lt;/span&gt; 기준으로 나눈 배열의 마지막 요소를 &lt;b&gt;filename&lt;/b&gt;으로 사용했어요.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해 볼까요&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;445&quot; data-origin-height=&quot;826&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/E5rwI/btsL8XWvz6Z/AHfqOsPOdQRwJklesyW7bK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/E5rwI/btsL8XWvz6Z/AHfqOsPOdQRwJklesyW7bK/img.png&quot; data-alt=&quot;API Route에 Dynamic routes 적용한 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/E5rwI/btsL8XWvz6Z/AHfqOsPOdQRwJklesyW7bK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FE5rwI%2FbtsL8XWvz6Z%2FAHfqOsPOdQRwJklesyW7bK%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;445&quot; height=&quot;826&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;445&quot; data-origin-height=&quot;826&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;API Route에 Dynamic routes 적용한 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;837&quot; data-origin-height=&quot;149&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uIktq/btsL7ps9OMl/ZFm9kdCl9x8aVjHxEr80Ok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uIktq/btsL7ps9OMl/ZFm9kdCl9x8aVjHxEr80Ok/img.png&quot; data-alt=&quot;API Route에 Dynamic routes 적용한 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uIktq/btsL7ps9OMl/ZFm9kdCl9x8aVjHxEr80Ok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuIktq%2FbtsL7ps9OMl%2FZFm9kdCl9x8aVjHxEr80Ok%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;837&quot; height=&quot;149&quot; data-origin-width=&quot;837&quot; data-origin-height=&quot;149&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;API Route에 Dynamic routes 적용한 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;5️⃣&amp;nbsp;/api/[channel]/[id]/route.ts 파일 수정&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738743619839&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import path from 'path';
import { promises as fs } from 'fs';
import { RawYoutubeData, YoutubeData } from '@/app/types/youtube';

export async function GET(request: Request): Promise&amp;lt;Response&amp;gt; {
  try {
    const url = new URL(request.url);

    const pathname = url.pathname.split('/');
    const channel = pathname[pathname.length - 2]; // channel 부분
    const id = pathname[pathname.length - 1]; // id 부분

    if (!channel || !id) {
      return new Response(
        JSON.stringify({
          success: false,
          message: 'Invalid request parameters.',
        }),
        { status: 400, headers: { 'Content-Type': 'application/json' } },
      );
    }

    // JSON 파일 경로 설정
    const filePath = path.join(process.cwd(), 'data', `${channel}.json`);

    // 파일 읽기
    const fileContents = await fs.readFile(filePath, 'utf-8');

    // JSON 파싱 및 데이터 매핑
    const rawData: RawYoutubeData[] = JSON.parse(fileContents);

    const data: YoutubeData[] = rawData.map((item) =&amp;gt; ({
      id: item.id,
      position: item.snippet.position,
      title: item.snippet.title,
      thumbnailUrl: item.snippet.thumbnails.high.url,
    }));

    // id에 해당하는 데이터 찾기
    const target = data.find((item) =&amp;gt; item.position === Number(id));

    if (!target) {
      return new Response(
        JSON.stringify({ success: false, message: 'Item not found.' }),
        { status: 404, headers: { 'Content-Type': 'application/json' } },
      );
    }

    return new Response(JSON.stringify({ success: true, list: target }), {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
    });
  } catch (error) {
    console.error('데이터를 받아오는 중 에러가 발생했습니다:', error);
    return new Response(
      JSON.stringify({ success: false, message: '데이터 요청 실패' }),
      {
        status: 500,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;const channel = pathname[pathname.length - 2];&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;Dynamic route에서 url의 형태가&amp;nbsp;&lt;/span&gt;&lt;b&gt;/서버주소/{channel}/{id}&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;일 것이기 때문에&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&amp;nbsp;기준으로 나눈 배열의 마지막에서 두 번째 요소를 &lt;b&gt;channel&lt;/b&gt;로 사용했어요.&lt;/span&gt; &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;const id = pathname[pathname.length - 1];&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&amp;nbsp;기준으로 나눈 배열의 마지막 요소를&lt;b&gt; id&lt;/b&gt;로 사용했어요.&lt;/span&gt; &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해 볼까요&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;422&quot; data-origin-height=&quot;814&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MQuhD/btsL8LIPWZb/4C1dXaKrPreFCFW6cu0tE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MQuhD/btsL8LIPWZb/4C1dXaKrPreFCFW6cu0tE1/img.png&quot; data-alt=&quot;/{channel}/{id} 확인 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MQuhD/btsL8LIPWZb/4C1dXaKrPreFCFW6cu0tE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMQuhD%2FbtsL8LIPWZb%2F4C1dXaKrPreFCFW6cu0tE1%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;422&quot; height=&quot;814&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;422&quot; data-origin-height=&quot;814&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;/{channel}/{id} 확인 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1738743930220&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from '@/app/lib/instance';

async function DetailPage({
  params,
}: {
  params: { channel: string; id: string };
}) {
  const { channel, id } = params;

  const {
    data: { list },
  } = await axios.get(`${channel}/${id}`);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;div className=&quot;text-white&quot;&amp;gt;{list.title}&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default DetailPage;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;확인을 위해 임시로 title만 추가해 둔 상태라 빈약하지만 잘 동작하네요   &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;동적 라우팅(Dynamic Routing) 적용은 모두 끝났지만 한 가지 문제가 더 생겼어요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1228&quot; data-origin-height=&quot;97&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Zh6Ss/btsL7fRO77q/8HLyn1JdNLDDq1IkJT1mk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Zh6Ss/btsL7fRO77q/8HLyn1JdNLDDq1IkJT1mk1/img.png&quot; data-alt=&quot;에러 메시지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Zh6Ss/btsL7fRO77q/8HLyn1JdNLDDq1IkJT1mk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZh6Ss%2FbtsL7fRO77q%2F8HLyn1JdNLDDq1IkJT1mk1%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;1228&quot; height=&quot;97&quot; data-origin-width=&quot;1228&quot; data-origin-height=&quot;97&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;에러 메시지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1186&quot; data-origin-height=&quot;109&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0EovQ/btsL7Bz63Tc/F5NUJIk02ecvQNrRUzrF41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0EovQ/btsL7Bz63Tc/F5NUJIk02ecvQNrRUzrF41/img.png&quot; data-alt=&quot;에러 메시지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0EovQ/btsL7Bz63Tc/F5NUJIk02ecvQNrRUzrF41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0EovQ%2FbtsL7Bz63Tc%2FF5NUJIk02ecvQNrRUzrF41%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;1186&quot; height=&quot;109&quot; data-origin-width=&quot;1186&quot; data-origin-height=&quot;109&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;에러 메시지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;메시지를 읽어보니 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;params&lt;/b&gt;&lt;/span&gt;의 properties를 사용하려면 await을 사용해야 한다는 내용 같아요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;자세한 내용을 알아보라고 공식 문서의 url을 줬으니 확인해 봐야겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/messages/sync-dynamic-apis#why-this-warning-occurred&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Next.js 공식 문서&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;907&quot; data-origin-height=&quot;706&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ddW5bF/btsL8m3AdvL/taiLgMoIqvDrt932P0bXSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ddW5bF/btsL8m3AdvL/taiLgMoIqvDrt932P0bXSK/img.png&quot; data-alt=&quot;에러 발생 이유&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ddW5bF/btsL8m3AdvL/taiLgMoIqvDrt932P0bXSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FddW5bF%2FbtsL8m3AdvL%2FtaiLgMoIqvDrt932P0bXSK%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;907&quot; height=&quot;706&quot; data-origin-width=&quot;907&quot; data-origin-height=&quot;706&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;에러 발생 이유&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Next 15 버전으로 업데이트되면서 &lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;params&lt;/b&gt;&lt;/span&gt;와 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;searchParams&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #212529; text-align: start;&quot;&gt;를 비동기적으로 접근해야 한다고 하네요.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;가장 아래쪽 설명을 살펴보니 여전히 동기적으로도 접근이 가능하지만 warning을 발생시킨다고 해요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;755&quot; data-origin-height=&quot;72&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgILy7/btsL7wMnHq9/0HkoII1MFblDVN1rnKJKt0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgILy7/btsL7wMnHq9/0HkoII1MFblDVN1rnKJKt0/img.png&quot; data-alt=&quot;console warning 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgILy7/btsL7wMnHq9/0HkoII1MFblDVN1rnKJKt0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgILy7%2FbtsL7wMnHq9%2F0HkoII1MFblDVN1rnKJKt0%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;755&quot; height=&quot;72&quot; data-origin-width=&quot;755&quot; data-origin-height=&quot;72&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;console warning 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;프로젝트로 돌아와 다시 확인해 보니 Error가 아니라 Warning이을 발생시키고 있네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;수정하지 않아도 정상적으로 동작하니 그냥 넘어갈까도 생각했지만 가장 마지막 문구가 조금 신경 쓰이네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;추후 업데이트 될 버전들에서 아래 코드 같은&amp;nbsp; 동기적인 접근이 예상대로 동작하지 않을 수 있다고 해요.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738745266031&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const Page = ({ params }: { params: { channel: string } }) =&amp;gt; {
  const {channel} = params;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;해결 방법은 두 가지가 있네요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;875&quot; data-origin-height=&quot;656&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BBjRK/btsL7t9WbKf/8w12ACoCxo4rx3xrU0uWk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BBjRK/btsL7t9WbKf/8w12ACoCxo4rx3xrU0uWk1/img.png&quot; data-alt=&quot;에러 해결 방법&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BBjRK/btsL7t9WbKf/8w12ACoCxo4rx3xrU0uWk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBBjRK%2FbtsL7t9WbKf%2F8w12ACoCxo4rx3xrU0uWk1%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;875&quot; height=&quot;656&quot; data-origin-width=&quot;875&quot; data-origin-height=&quot;656&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;에러 해결 방법&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저는 Server Component에서 발생한 에러이기 때문에 await을 선언해 해결했어요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;✨ 동적 라우팅(Dynamic Routing)을 적용하면서 느낀 점&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1️⃣ &lt;span style=&quot;color: #333333;&quot;&gt;확장성과 재사용성&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;동적 라우팅의 적용이 추후 channel을 추가하는 확장성과 재사용성 측면에서 큰 강점을 가져왔어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;동적 라우팅을 적용하기 전이었다면 매번 추가적인 코드를 작성해야 했지만, 동적 라우팅을 적용함으로써 별도의 코드 변경 없이 기존 구조만으로 데이터를 channel을 추가할 수 있게 되었어요.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ &lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;유지보수 효율성 증가&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 하나의 공통된 라우팅 파일(route.ts)에서 동적으로 파일 경로를 결정하고 데이터를 처리함으로써 코드 중복을 크게 줄일 수 있게 되었어요. &lt;b&gt;JSON 파일 이름&lt;/b&gt;이나 &lt;b&gt;id &lt;/b&gt;같은 가변적인 요소를 동적으로 처리함으로써, 기존에 각각 작성해야 했던 반복적인 로직을 통합할 수 있게 되어 유지보수 효율성을 증가시킬 수 있게 되었어요.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;3️⃣ &lt;b&gt;지속적인 학습의 중요성&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; Next.js 뿐만 아니라 여러 라이브러리와 프레임워크들이 빠르게 발전하며 웹 개발의 새로운 표준을 제시하고 있어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 업데이트 내용을 알지 못해 에러를 경험하고 나니 개발자로서 최신 기술 트렌드를 꾸준히 학습하고, 변화에 유연하게 대응하는 자세를 가져야겠다는 생각을 하게 되네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;프론트엔드 개발자로서 성장해 나가기 위해 공식 문서와 참고 자료들을 바탕으로 꾸준히 학습해야겠습니다 &lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>api routes</category>
      <category>Dynamic Routing</category>
      <category>Next.js</category>
      <category>동적 라우팅</category>
      <category>사이드 프로젝트</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/182</guid>
      <comments>https://dev-hpk.tistory.com/182#entry182comment</comments>
      <pubDate>Wed, 5 Feb 2025 18:16:46 +0900</pubDate>
    </item>
    <item>
      <title>[Coworkers] 할 일 리스트 페이지 작업 (협업)</title>
      <link>https://dev-hpk.tistory.com/181</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오전 스크럼 회의에서 할 일 리스트 페이지를 작업하시는 팀원분께서 어떻게 구현해야 할지 모르겠다고 도움을 요청하셨어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀 회의 때 밤을 새고 작업을 하셔도 해결을 못하셨다고 하시는데 안 도와드릴 수 없죠  &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; letter-spacing: 0px;&quot;&gt;제 작업은 거의 마무리 단계라 잠시 뒤로 미뤄두고 도움을 드리기로 했어요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;933&quot; data-origin-height=&quot;112&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cci4z9/btsL7xKk8d3/Ti4MTqwda2EVxpHs3mkb20/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cci4z9/btsL7xKk8d3/Ti4MTqwda2EVxpHs3mkb20/img.png&quot; data-alt=&quot;협업 요청 DM&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cci4z9/btsL7xKk8d3/Ti4MTqwda2EVxpHs3mkb20/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcci4z9%2FbtsL7xKk8d3%2FTi4MTqwda2EVxpHs3mkb20%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;933&quot; height=&quot;112&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;933&quot; data-origin-height=&quot;112&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;협업 요청 DM&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;일단 문제 상황을 먼저 확인해 봐야겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  문제 상황&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;760&quot; data-origin-height=&quot;766&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wjSJH/btsL7jZUYtA/XMzxhwNqgGY2oK1ZGgRGyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wjSJH/btsL7jZUYtA/XMzxhwNqgGY2oK1ZGgRGyk/img.png&quot; data-alt=&quot;리스트 페이지 UI&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wjSJH/btsL7jZUYtA/XMzxhwNqgGY2oK1ZGgRGyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwjSJH%2FbtsL7jZUYtA%2FXMzxhwNqgGY2oK1ZGgRGyk%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;760&quot; height=&quot;766&quot; data-origin-width=&quot;760&quot; data-origin-height=&quot;766&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;리스트 페이지 UI&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Swagger의 API 문서가 너무 복잡해서 어떤 API를 사용해야 할지 모르겠다고 하시네요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1417&quot; data-origin-height=&quot;451&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfxPNm/btsL7DjxjKe/x4tCRRY2lDcq4RZFaDPip0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfxPNm/btsL7DjxjKe/x4tCRRY2lDcq4RZFaDPip0/img.png&quot; data-alt=&quot;swagger api 문서&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfxPNm/btsL7DjxjKe/x4tCRRY2lDcq4RZFaDPip0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfxPNm%2FbtsL7DjxjKe%2Fx4tCRRY2lDcq4RZFaDPip0%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;1417&quot; height=&quot;451&quot; data-origin-width=&quot;1417&quot; data-origin-height=&quot;451&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;swagger api 문서&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1459&quot; data-origin-height=&quot;365&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ef5HVv/btsL7OFb6jJ/8i6a65VABGcKe9zmo4j6jK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ef5HVv/btsL7OFb6jJ/8i6a65VABGcKe9zmo4j6jK/img.png&quot; data-alt=&quot;swagger api 문서&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ef5HVv/btsL7OFb6jJ/8i6a65VABGcKe9zmo4j6jK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fef5HVv%2FbtsL7OFb6jJ%2F8i6a65VABGcKe9zmo4j6jK%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;1459&quot; height=&quot;365&quot; data-origin-width=&quot;1459&quot; data-origin-height=&quot;365&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;swagger api 문서&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;다시 보니 정말 헷갈릴만 하네요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;다른 API 문서들을 찾아보니 아래처럼 설명을 잘 작성해두기도 했네요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;489&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b43PIV/btsL7derZ7q/nVDoQfmBmfUDSMrFmMJwvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b43PIV/btsL7derZ7q/nVDoQfmBmfUDSMrFmMJwvK/img.png&quot; data-alt=&quot;swagger 예시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b43PIV/btsL7derZ7q/nVDoQfmBmfUDSMrFmMJwvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb43PIV%2FbtsL7derZ7q%2FnVDoQfmBmfUDSMrFmMJwvK%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;1024&quot; height=&quot;489&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;489&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;swagger 예시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;280&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rRLv9/btsL7b1Zp7S/CZkN39Mywz3n5ogyuJUi91/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rRLv9/btsL7b1Zp7S/CZkN39Mywz3n5ogyuJUi91/img.jpg&quot; data-alt=&quot;swagger 예시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rRLv9/btsL7b1Zp7S/CZkN39Mywz3n5ogyuJUi91/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrRLv9%2FbtsL7b1Zp7S%2FCZkN39Mywz3n5ogyuJUi91%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;280&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;280&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;swagger 예시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아쉽지만 언제 어떤 환경에서 개발할지 알 수 없으니, 주어진 환경에 맞게 개발할 줄 알아야 개발자로 성장할 수 있겠죠!&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  문제 해결&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1️⃣ 폴더 구조 수정&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;326&quot; data-origin-height=&quot;228&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btTTJF/btsL6tWu19h/i88cZfLWiTClyPUsJu65f0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btTTJF/btsL6tWu19h/i88cZfLWiTClyPUsJu65f0/img.png&quot; data-alt=&quot;수정 전 폴더 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btTTJF/btsL6tWu19h/i88cZfLWiTClyPUsJu65f0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtTTJF%2FbtsL6tWu19h%2Fi88cZfLWiTClyPUsJu65f0%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;326&quot; height=&quot;228&quot; data-origin-width=&quot;326&quot; data-origin-height=&quot;228&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;수정 전 폴더 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;우선 폴더 구조가 잘 못 되어있네요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;tasklist는 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;taskListId&lt;/b&gt;&lt;/span&gt;에 해당하는 taskLists를 서버에 요청하고 렌더링 하는 페이지입니다. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;동적 라우팅으로 바꿔줘야겠죠.. &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;233&quot; data-origin-height=&quot;143&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Lbp7B/btsL6nIUjIC/CDNxcUVQtltQ8jF4pvBtDK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Lbp7B/btsL6nIUjIC/CDNxcUVQtltQ8jF4pvBtDK/img.png&quot; data-alt=&quot;수정 후 폴더 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Lbp7B/btsL6nIUjIC/CDNxcUVQtltQ8jF4pvBtDK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLbp7B%2FbtsL6nIUjIC%2FCDNxcUVQtltQ8jF4pvBtDK%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;233&quot; height=&quot;143&quot; data-origin-width=&quot;233&quot; data-origin-height=&quot;143&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;수정 후 폴더 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 프로젝트 요구사항에 맞게 동작할 수 있겠네요.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀 페이지(/{teamid}) &amp;rarr; 할 일 목록 클릭 &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;할 일 리스트 페이지(/{teamid}/{tasklist})&amp;nbsp; &amp;rarr; 할 일 카드 클릭&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;할 일 상세 페이지/{teamid}/{tasklist}/{taskid}&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2️⃣ team, tasklist, task 관련 id를 임시 상수에서 데이터로 수정&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;작업 내용을 확인해 보니 리스트 페이지 관련 id(teamId, taskListId, taskId)가 모두 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;1771, 2861 등&lt;/span&gt;&lt;/b&gt; 상수로 작업되어 있어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아마 어느 API에서 id를 받아와야 하는지 어려우셔서 이렇게 작업해 두신 것 같아요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;포기하시지 않고 상수 id를 이용해서라도 기능 구현하려고 노력하신 모습 너무 멋있네요! &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저도 수정 작업 시작해 보겠습니다&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;/[teamid]/[tasklist]/[taskid]/page.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1259&quot; data-origin-height=&quot;713&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AZUNO/btsL8bmxr6A/AcBZ3k49okk7TavcgbkwTK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AZUNO/btsL8bmxr6A/AcBZ3k49okk7TavcgbkwTK/img.png&quot; data-alt=&quot;page.tsx 코드 수정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AZUNO/btsL8bmxr6A/AcBZ3k49okk7TavcgbkwTK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAZUNO%2FbtsL8bmxr6A%2FAcBZ3k49okk7TavcgbkwTK%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;1259&quot; height=&quot;713&quot; data-origin-width=&quot;1259&quot; data-origin-height=&quot;713&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;page.tsx 코드 수정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;폴더 구조를&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/[teamid]/[taskid]/page.tsx&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;에서&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/[teamid]/[tasklist]/[taskid]/page.tsx&lt;/b&gt;&lt;/span&gt;로 수정해 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;useParams() 훅&lt;/b&gt;&lt;/span&gt;을 이용해 &lt;b&gt;teamid, tasklist, taskid&lt;/b&gt;를 사용해 상수 id를 대체할게요.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738668651904&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { teamid, tasklist, taskid } = useParams();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;TaskCardList.tsx&lt;/b&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;627&quot; data-origin-height=&quot;718&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbPvjj/btsL7kdvKLU/7OzNwHpQf0hcWpKiwKi5d0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbPvjj/btsL7kdvKLU/7OzNwHpQf0hcWpKiwKi5d0/img.png&quot; data-alt=&quot;상수 id 삭제, id props 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbPvjj/btsL7kdvKLU/7OzNwHpQf0hcWpKiwKi5d0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbPvjj%2FbtsL7kdvKLU%2F7OzNwHpQf0hcWpKiwKi5d0%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;627&quot; height=&quot;718&quot; data-origin-width=&quot;627&quot; data-origin-height=&quot;718&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;상수 id 삭제, id props 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;275&quot; data-origin-height=&quot;120&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HxOMg/btsL7FIpToy/T04dXuiI0xDfxKXI9WKkKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HxOMg/btsL7FIpToy/T04dXuiI0xDfxKXI9WKkKk/img.png&quot; data-alt=&quot;상수 id 삭제, id props 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HxOMg/btsL7FIpToy/T04dXuiI0xDfxKXI9WKkKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHxOMg%2FbtsL7FIpToy%2FT04dXuiI0xDfxKXI9WKkKk%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;275&quot; height=&quot;120&quot; data-origin-width=&quot;275&quot; data-origin-height=&quot;120&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;상수 id 삭제, id props 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;TaskCard.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;597&quot; data-origin-height=&quot;569&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHLVT8/btsL7RojT2g/ZQHipygQFlxymWL8uxb8e0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHLVT8/btsL7RojT2g/ZQHipygQFlxymWL8uxb8e0/img.png&quot; data-alt=&quot;상수 id 삭제, id props 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHLVT8/btsL7RojT2g/ZQHipygQFlxymWL8uxb8e0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHLVT8%2FbtsL7RojT2g%2FZQHipygQFlxymWL8uxb8e0%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;597&quot; height=&quot;569&quot; data-origin-width=&quot;597&quot; data-origin-height=&quot;569&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;상수 id 삭제, id props 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;TaskCardDropdown.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;624&quot; data-origin-height=&quot;542&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGrDJM/btsL6Ism2vW/AhQIkS0168DlZoI1u5P4c0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGrDJM/btsL6Ism2vW/AhQIkS0168DlZoI1u5P4c0/img.png&quot; data-alt=&quot;상수 id 삭제, id props 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGrDJM/btsL6Ism2vW/AhQIkS0168DlZoI1u5P4c0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGrDJM%2FbtsL6Ism2vW%2FAhQIkS0168DlZoI1u5P4c0%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;624&quot; height=&quot;542&quot; data-origin-width=&quot;624&quot; data-origin-height=&quot;542&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;상수 id 삭제, id props 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;597&quot; data-origin-height=&quot;168&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SZdLG/btsL8bGPDyc/QYhxLMp7XmeeWjl0GdG9ok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SZdLG/btsL8bGPDyc/QYhxLMp7XmeeWjl0GdG9ok/img.png&quot; data-alt=&quot;컴포넌트 이름 수정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SZdLG/btsL8bGPDyc/QYhxLMp7XmeeWjl0GdG9ok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSZdLG%2FbtsL8bGPDyc%2FQYhxLMp7XmeeWjl0GdG9ok%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;597&quot; height=&quot;168&quot; data-origin-width=&quot;597&quot; data-origin-height=&quot;168&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;컴포넌트 이름 수정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;TaskCardDropdown 컴포넌트는 파일 이름과 컴포넌트 이름을 다르게 작업하셨어요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아마 Icon 컴포넌트와 이름이 겹쳐서 그러신 거 같은데 컴포넌트와 파일 이름을 같게 수정했어요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;파일 이름과 컴포넌트 이름이 같아야 파일 이름만 보고도 import 할 수 있어 협업에 편할 것 같더라고요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;TaskDetailDropdown.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;578&quot; data-origin-height=&quot;625&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/u6EJ6/btsL6miSsro/JGAJv6YcxrWlBtQ3J1JwV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/u6EJ6/btsL6miSsro/JGAJv6YcxrWlBtQ3J1JwV1/img.png&quot; data-alt=&quot;상수 id 삭제, id props 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/u6EJ6/btsL6miSsro/JGAJv6YcxrWlBtQ3J1JwV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fu6EJ6%2FbtsL6miSsro%2FJGAJv6YcxrWlBtQ3J1JwV1%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;578&quot; height=&quot;625&quot; data-origin-width=&quot;578&quot; data-origin-height=&quot;625&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;상수 id 삭제, id props 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;299&quot; data-origin-height=&quot;136&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CB6uG/btsL6zvwu13/AAgbJVSo2bSqAI0BvJBtck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CB6uG/btsL6zvwu13/AAgbJVSo2bSqAI0BvJBtck/img.png&quot; data-alt=&quot;props로 서버에서 받은 id 전달&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CB6uG/btsL6zvwu13/AAgbJVSo2bSqAI0BvJBtck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCB6uG%2FbtsL6zvwu13%2FAAgbJVSo2bSqAI0BvJBtck%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;299&quot; height=&quot;136&quot; data-origin-width=&quot;299&quot; data-origin-height=&quot;136&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;props로 서버에서 받은 id 전달&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;수정한 부분이 더 있지만 너무 길어져 commit 기록으로 대신할게요!&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;656&quot; data-origin-height=&quot;277&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cr0uNF/btsL5Z2Hp35/fJjHIJicpyv5sCuUOJn5W0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cr0uNF/btsL5Z2Hp35/fJjHIJicpyv5sCuUOJn5W0/img.png&quot; data-alt=&quot;동적 id 수정 commit&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cr0uNF/btsL5Z2Hp35/fJjHIJicpyv5sCuUOJn5W0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcr0uNF%2FbtsL5Z2Hp35%2FfJjHIJicpyv5sCuUOJn5W0%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;656&quot; height=&quot;277&quot; data-origin-width=&quot;656&quot; data-origin-height=&quot;277&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;동적 id 수정 commit&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;3️⃣ taskLists API 연동&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;648&quot; data-origin-height=&quot;117&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yvMpx/btsL7MtR08R/YtBsoQRZeDnDXS0s5aKFc0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yvMpx/btsL7MtR08R/YtBsoQRZeDnDXS0s5aKFc0/img.png&quot; data-alt=&quot;Group - GET&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yvMpx/btsL7MtR08R/YtBsoQRZeDnDXS0s5aKFc0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyvMpx%2FbtsL7MtR08R%2FYtBsoQRZeDnDXS0s5aKFc0%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;648&quot; height=&quot;117&quot; data-origin-width=&quot;648&quot; data-origin-height=&quot;117&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Group - GET&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;724&quot; data-origin-height=&quot;596&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVtVMJ/btsL7itda9f/d0VuK1ZnS5m0c1wRHn5LH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVtVMJ/btsL7itda9f/d0VuK1ZnS5m0c1wRHn5LH1/img.png&quot; data-alt=&quot;GET Group response&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVtVMJ/btsL7itda9f/d0VuK1ZnS5m0c1wRHn5LH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVtVMJ%2FbtsL7itda9f%2Fd0VuK1ZnS5m0c1wRHn5LH1%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;724&quot; height=&quot;596&quot; data-origin-width=&quot;724&quot; data-origin-height=&quot;596&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;GET Group response&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;swagger API 문서를 확인해 보니 Group 데이터를 GET 요청해야 taskLists를 response로 전달해 주네요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이름만 보고 TaskList 확인하고 있었는데 여기 숨어있었다니 &lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;getGroupById - id에 해당하는 group 데이터 요청하는 함수&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738669982041&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from '@/app/lib/instance';

type GroupResponse = {
  image?: string | null;
  name: string;
  taskLists: any[];
};

const getGroupById = async (id: number): Promise&amp;lt;GroupResponse&amp;gt; =&amp;gt; {
  const res = await axios.get&amp;lt;GroupResponse&amp;gt;(`/groups/${id}`, {
    headers: {
      Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`,
    },
  });

  return res.data;
};

export default getGroupById;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;taskLists의 타입을 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;any[]&lt;/span&gt;&lt;/b&gt;로 설정해서 조금 불편하실 수 있을 것 같네요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이 부분은 task 관련 작업하신 팀원분께서 타입을 설정해 두셨다고 하는데, 제가 작성한 코드가 아니다 보니 찾기가 어려워 팀원분께서 작업하겠다고 하셨어요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;/[teamid]/[tasklist]/page.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738670553499&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useQuery } from '@tanstack/react-query';
import getGroupById from '@/app/lib/group/getGroupById';
import Link from 'next/link';

function TaskListPage() {
  {/* ...기존 코드 생략... */}
  const { data, isLoading } = useQuery({
    queryKey: ['tasklists', tasklist],
    queryFn: () =&amp;gt; getGroupById(Number(teamid)),
  });


  return (
    {/* ...기존 마크업 생략... */}
    &amp;lt;div&amp;gt;
      {data?.taskLists &amp;amp;&amp;amp;
        data?.taskLists.map((list) =&amp;gt; (
          &amp;lt;Link key={list.id} href={`/${teamid}/${list.id}`}&amp;gt;
            {list.name}
          &amp;lt;/Link&amp;gt;
        ))}
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;taskLists를 불러오는 작업까지 모두 끝났습니다. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 결과를 확인해 볼게요&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;850&quot; data-origin-height=&quot;176&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d5DQQe/btsL6YBIypb/HU9WQ1H1jNivJc4tfDMSwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d5DQQe/btsL6YBIypb/HU9WQ1H1jNivJc4tfDMSwK/img.png&quot; data-alt=&quot;taskList 호출 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d5DQQe/btsL6YBIypb/HU9WQ1H1jNivJc4tfDMSwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd5DQQe%2FbtsL6YBIypb%2FHU9WQ1H1jNivJc4tfDMSwK%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;850&quot; height=&quot;176&quot; data-origin-width=&quot;850&quot; data-origin-height=&quot;176&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;taskList 호출 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;895&quot; data-origin-height=&quot;232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AR4nA/btsL7EpeZLN/H4elsDpdvpzrJB9vAtPCpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AR4nA/btsL7EpeZLN/H4elsDpdvpzrJB9vAtPCpk/img.png&quot; data-alt=&quot;console 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AR4nA/btsL7EpeZLN/H4elsDpdvpzrJB9vAtPCpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAR4nA%2FbtsL7EpeZLN%2FH4elsDpdvpzrJB9vAtPCpk%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;895&quot; height=&quot;232&quot; data-origin-width=&quot;895&quot; data-origin-height=&quot;232&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;console 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;할 일 목록(taskList) 데이터가 잘 받아져 오네요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; letter-spacing: 0px;&quot;&gt;UI 스타일 작업은 직접 하신 다고 하셔서 여기까지 작업하고 넘겨드렸어요&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  협업을 하면서 느낀 점&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;b&gt;1️⃣ 일관된 네이밍 컨벤션과 명확한 코드 구조의 중요성&lt;/b&gt; &lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;작업을 하면서 익숙하지 않은 코드 구조를 이해하는 데 시간이 많이 걸렸고, 파일 이름과 컴포넌트 이름이 다른 부분이 있어 파악하느라 수정이 쉽지 않아 힘들기도 했지만 이런 과정에서&lt;b&gt; 일관된 네이밍 컨벤션과 명확한 코드 구조의 중요성&lt;/b&gt;을 몸소 체감했어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저도 협업을 하면서 체감했으니 다른 팀원들도 제 코드를 보고 같은 생각을 했겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;앞으로는 처음 제 코드를 보는 사람도 빠르게 이해할 수 있도록 하기 위해 네이밍과 폴더 구조를 깔끔하게 정리해야겠네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&quot;내 코드가 다른 사람에게 어떻게 읽힐지&quot;&lt;/b&gt;를 한번 더 생각하면서 말이죠 &lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;b&gt;2️⃣ 협업에서 소통의 중요성&lt;/b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;팀원과의 의견 조율과 문제 해결 과정&lt;/b&gt;을 경험하면서 협업의 본질에 대해 다시 한번 생각해 보게 되었어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존 할 일 카드 컴포넌트의 동작이 아래 두 가지로 나뉘어 있었어요.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Click 이벤트 : &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;Drawer&lt;/b&gt;&lt;/span&gt; 컴포넌트 open&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;keyDown 이벤트(Enter, Space Bar) : &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/[teamid]/[tasklist]/[task.id]&lt;/b&gt;&lt;/span&gt;로 라우팅&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Click과 keyDown 이벤트의 동작 방식이 다른 것이 조금 의아해서 질문을 드렸어요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;436&quot; data-origin-height=&quot;598&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/szuEd/btsL7y3DV7z/70TgexRYPsWAp5GpF1Rpj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/szuEd/btsL7y3DV7z/70TgexRYPsWAp5GpF1Rpj0/img.png&quot; data-alt=&quot;기능 구현 관련 논의&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/szuEd/btsL7y3DV7z/70TgexRYPsWAp5GpF1Rpj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FszuEd%2FbtsL7y3DV7z%2F70TgexRYPsWAp5GpF1Rpj0%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;436&quot; height=&quot;598&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;436&quot; data-origin-height=&quot;598&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;기능 구현 관련 논의&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;423&quot; data-origin-height=&quot;331&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqEhv7/btsL7rwM5lJ/JCX9VX9gNzQADr2ZDoLgY0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqEhv7/btsL7rwM5lJ/JCX9VX9gNzQADr2ZDoLgY0/img.png&quot; data-alt=&quot;기능 구현 관련 논의&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqEhv7/btsL7rwM5lJ/JCX9VX9gNzQADr2ZDoLgY0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqEhv7%2FbtsL7rwM5lJ%2FJCX9VX9gNzQADr2ZDoLgY0%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;423&quot; height=&quot;331&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;423&quot; data-origin-height=&quot;331&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;기능 구현 관련 논의&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;의도하신 부분이 아니라 에러가 발생해서 keyDown 이벤트를 추가하셨다고 하셔서 음성 채팅으로 수정 방법을 논의했어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;소통 없이 제가 혼자 판단했다면 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;/[teamid]/[tasklist]/[task.id]&lt;/b&gt;&lt;/span&gt;로 라우팅 처리하도록 수정했을 것 같아요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그런데 팀원분과 함께 고민하니 의견이 하나둘씩 늘어가고 피드백을 더 해가며 점점 발전해 최종적으로 사용자 경험까지 고려한 더 나은 방향을 찾을 수 있었어요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>App Router</category>
      <category>Next.js</category>
      <category>동적 라우팅</category>
      <category>프로젝트</category>
      <category>협업</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/181</guid>
      <comments>https://dev-hpk.tistory.com/181#entry181comment</comments>
      <pubDate>Tue, 4 Feb 2025 21:58:13 +0900</pubDate>
    </item>
    <item>
      <title>[Coworkers] Axios interceptor 적용 (token 적용, refresh token을 이용한 토큰 재발급)</title>
      <link>https://dev-hpk.tistory.com/180</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;드디어 로그인 기능이 구현되었습니다. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그동안 로그인 기능이 구현되지 않아서 swagger에서 직접 로그인 후 response로 받은 토큰을 env 파일에 저장 후 사용했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738566040520&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const res = await instance.get&amp;lt;Task[]&amp;gt;(
    `/groups/${groupId}/task-lists/${taskListId}/tasks`,
    {
      params: { date },
      headers: {
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`,
      },
    },
  );&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  문제 상황&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; API 요청 함수를 만들 때마다 항상 header에 토큰을 적용해 주는 부분에서 중복 코드에 대한 불편함을 느꼈어요.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;개발을 하면서 아래 사진과 같은 401 Unauthorized 서버 에러가 자주 발생해서 다시 로그인하느라 불편함을 느꼈어요.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;613&quot; data-origin-height=&quot;76&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnCqvt/btsL6ahoQrT/jhEd5miYKEpW7NDCGglaT1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnCqvt/btsL6ahoQrT/jhEd5miYKEpW7NDCGglaT1/img.png&quot; data-alt=&quot;401 Unauthorized 에러&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnCqvt/btsL6ahoQrT/jhEd5miYKEpW7NDCGglaT1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnCqvt%2FbtsL6ahoQrT%2FjhEd5miYKEpW7NDCGglaT1%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;613&quot; height=&quot;76&quot; data-origin-width=&quot;613&quot; data-origin-height=&quot;76&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;401 Unauthorized 에러&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/HTTP/Status/401&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;401 Unauthorized&lt;/a&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;HTTP(하이퍼텍스트&amp;nbsp;전송&amp;nbsp;프로토콜)&amp;nbsp;401&amp;nbsp;Unauthorized&amp;nbsp;응답&amp;nbsp;상태&amp;nbsp;코드는&amp;nbsp;요청된&amp;nbsp;리소스에&amp;nbsp;대한&amp;nbsp;유효한&amp;nbsp;인증&amp;nbsp;자격&amp;nbsp;증명이&amp;nbsp;없기&amp;nbsp;때문에&amp;nbsp;클라이언트&amp;nbsp;요청이&amp;nbsp;완료되지&amp;nbsp;않았음을&amp;nbsp;나타냅니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;출처 - MDN&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;MDN을 참고해 보니 401 에러는 인증 자격 증명이 없기 때문, 즉 Access Token이 만료되어 발생하는 에러네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Access Token의 만료 시간을 직접 측정하는 것은 너무 비효율 적이겠죠..&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;JWT(JSON Web Token)을 디코딩해 주는 사이트를 통해 Access Token의 유효 시간을 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1738567025922&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;JWT.IO&quot; data-og-description=&quot;JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.&quot; data-og-host=&quot;jwt.io&quot; data-og-source-url=&quot;https://jwt.io/&quot; data-og-url=&quot;http://jwt.io/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cbxmwj/hyX7XkeiK6/TbBQbu47qIP2Z0TOSZa14K/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/deD43h/hyX7X5uRgd/RukCDhocoqg0kfVkknY5S1/img.png?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512&quot;&gt;&lt;a href=&quot;https://jwt.io/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://jwt.io/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cbxmwj/hyX7XkeiK6/TbBQbu47qIP2Z0TOSZa14K/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/deD43h/hyX7X5uRgd/RukCDhocoqg0kfVkknY5S1/img.png?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;JWT.IO&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;jwt.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;596&quot; data-origin-height=&quot;477&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RhHlJ/btsL4wMYcQD/KT6oXRwlLP2t8wQAvQjwL1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RhHlJ/btsL4wMYcQD/KT6oXRwlLP2t8wQAvQjwL1/img.png&quot; data-alt=&quot;JWT 토큰 디코딩 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RhHlJ/btsL4wMYcQD/KT6oXRwlLP2t8wQAvQjwL1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRhHlJ%2FbtsL4wMYcQD%2FKT6oXRwlLP2t8wQAvQjwL1%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;596&quot; height=&quot;477&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;596&quot; data-origin-height=&quot;477&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;JWT 토큰 디코딩 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;3시 58분에 발급한 Access Token을 입력하니 exp(expiration time)가 4시 58분으로 나오네요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;서버에서 발급해 주는 토큰이 1시간 동안만 유효한 거네요 &lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  문제 해결 방법&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;두 가지 문제를 모두 해결할 수 있는 방법을 찾다가 axios에서 제공하는 인터셉터(interceptor) API를 사용하기로 했어요 &lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; axios의 &lt;b&gt;interceptor&lt;/b&gt; API는 요청(request) 또는 응답(response)이 처리되기 전(then&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: left;&quot;&gt;과&amp;nbsp;&lt;/span&gt;catch&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: left;&quot;&gt;로 넘어가기 전&lt;/span&gt;)에 가로채어 추가적인 로직을 수행할 수 있도록 도와주는 기능이라고 해요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;https://axios-http.com/kr/docs/interceptors&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;사용법 - axios 공식 문서 참조&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738567673620&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 요청 인터셉터 추가하기
axios.interceptors.request.use(function (config) {
    // 요청이 전달되기 전에 작업 수행
    return config;
  }, function (error) {
    // 요청 오류가 있는 작업 수행
    return Promise.reject(error);
  });

// 응답 인터셉터 추가하기
axios.interceptors.response.use(function (response) {
    // 2xx 범위에 있는 상태 코드는 이 함수를 트리거 합니다.
    // 응답 데이터가 있는 작업 수행
    return response;
  }, function (error) {
    // 2xx 외의 범위에 있는 상태 코드는 이 함수를 트리거 합니다.
    // 응답 오류가 있는 작업 수행
    return Promise.reject(error);
  });&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;공식 문서의 설명만 보고는 무슨 일을 할 수 있을지 의문이 들어 찾아보니, 아래와 같은 기능에 주로 사용된다고 해요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣ 요청이 서버로 전달되기 전에 request 헤더에 Authorization을 추가&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ 요청이 서버로 전달되기 전에 console에 로그를 기록&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;3️⃣ 응답 데이터 변환&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;4️⃣ 401(Unauthorized) 에러 발생 시 Refresh Token을 이용해 Access Token을 재발급하고 요청 재실행&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 1️⃣, 4️⃣번이 저희 프로젝트에 해당되겠네요. 프로젝트에 적용해 볼게요 &lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  Axios Interceptor 적용&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;axios instance 코드&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738568383264&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const instance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_SERVER_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1️⃣ request(요청) interceptor 적용하기&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738568413179&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 요청 인터셉터: Access Token 추가
instance.interceptors.request.use((config) =&amp;gt; {
  // Redux store에서 전역으로 관리하는 state의 token을 참조
  const state = store.getState();
  const token = state.auth.accessToken;

  // 토큰이 있는 경우 모든 API 요청이 서버로 전달 되기 전에 헤더에 Authorization 토큰 추가
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  return config;
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;request interceptor에 Authorization 헤더를 추가함으로써 API 요청 함수에 일일이 Authorization 헤더를 추가해줘야 하는 불편함을 해결할 수 있게 되었습니다!&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt; 2️⃣ response(응답) interceptor 적용하기&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;response interceptor는 로그인을 담당하시는 팀원분께서 이미 작업을 해두셨습니다. 코드를 확인해 볼게요.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;ToeknInterceptor.ts - response interceptor&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738568794744&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import instance from '@/app/lib/instance';
import handleTokenRefresh from '@/app/utils/handleTokenRefresh';

// 응답 인터셉터: 401 에러 발생 시, 액세스토큰 갱신
instance.interceptors.response.use(
  (response) =&amp;gt; response,
  async (error) =&amp;gt; {
    if (error.response?.status === 401) {
      return handleTokenRefresh(error.config);
    }
    return Promise.reject(error);
  },
);

export default instance;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;handleTokenRefresh - Access Token 재발급 및 실패한 요청 재실행&amp;nbsp;함수&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738568810082&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios, { AxiosRequestConfig } from 'axios';
import { store } from '@/app/stores/store';
import postRefreshApi from '@/app/lib/auth/postRefreshApi';
import { setAccessToken } from '@/app/stores/auth/authSlice';

let isRefreshing = false;
let refreshSubscribers: ((token: string) =&amp;gt; void)[] = [];

const onAccessTokenFetched = (token: string) =&amp;gt; {
  // 저장된 모든 요청 콜백을 실행
  console.log(
    '[디버깅] 새로운 액세스 토큰을 받아 대기 중인 요청을 처리합니다.',
  );
  refreshSubscribers.forEach((callback) =&amp;gt; callback(token));
  refreshSubscribers = [];
};

const handleTokenRefresh = async (errorConfig: AxiosRequestConfig) =&amp;gt; {
  const state = store.getState();
  const { refreshToken } = state.auth;

  if (!refreshToken) {
    console.log(
      '[ERROR] 리프레시 토큰이 없습니다. 액세스 토큰을 갱신할 수 없습니다.',
    );
    // 리프레쉬토큰이 없으면 토큰 갱신을 할 수 없으므로 에러를 반환
    return Promise.reject(errorConfig);
  }

  if (!isRefreshing) {
    console.log('[디버깅] 토큰 갱신 프로세스를 시작합니다.');
    // 만약 토큰 갱신이 진행 중이지 않으면
    isRefreshing = true;

    try {
      console.log('[디버깅] 액세스 토큰을 갱신하기 위한 요청을 보냅니다.');
      // 리프레쉬토큰을 사용해 새로운 액세스토큰을 요청
      const { accessToken } = await postRefreshApi({ refreshToken });

      console.log('[디버깅] 새 액세스 토큰을 받았습니다: ', accessToken);

      // Redux 상태에 새로운 액세스토큰 저장
      store.dispatch(setAccessToken(accessToken));

      // 대기 중인 모든 요청 콜백을 실행
      onAccessTokenFetched(accessToken);
    } catch (refreshError) {
      console.error('[ERROR] 토큰 갱신에 실패했습니다:', refreshError);
      // 리프레쉬 토큰으로 갱신이 실패하면 에러를 반환
      return await Promise.reject(refreshError);
    } finally {
      console.log('[디버깅] 토큰 갱신 프로세스가 완료되었습니다.');
      isRefreshing = false;
    }
  }

  // 이미 토큰 갱신 중이라면 대기 중인 요청을 저장하고, 갱신된 토큰을 가지고 요청을 재시도
  console.log(
    '[디버깅] 토큰 갱신이 진행 중이므로 요청을 대기 큐에 추가합니다.',
  );
  return new Promise((resolve) =&amp;gt; {
    refreshSubscribers.push((token: string) =&amp;gt; {
      console.log('[디버깅] 새로운 토큰으로 요청을 재시도합니다.');
      const newConfig = {
        ...errorConfig,
        headers: {
          ...errorConfig.headers,
          Authorization: `Bearer ${token}`,
        },
      };
      resolve(axios.request(newConfig));
    });
  });
};

export default handleTokenRefresh;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;postRefreshApi - Refresh Token을 이용해 Access Token을 재발급하는 함수&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738568907023&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import instance from '@/app/lib/instance';

interface RefreshTokenRequest {
  refreshToken: string;
}

interface RefreshTokenResponse {
  accessToken: string;
}

const postRefreshApi = async (
  refreshTokenRequest: RefreshTokenRequest,
): Promise&amp;lt;RefreshTokenResponse&amp;gt; =&amp;gt; {
  const response = await instance.post&amp;lt;RefreshTokenResponse&amp;gt;(
    '/auth/refresh',
    refreshTokenRequest,
  );
  return response.data;
};

export default postRefreshApi;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;코드가 너무 복잡해 프로세스 흐름을 이해하느라 시간을 많이 썼네요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;제 코드를 보면서 팀원분들도 똑같은 생각을 했겠죠? &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;직관적인 코드를 작성해야겠다는 반성을 하게 되네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;반성은 나중에 하기로 하고 다시 프로젝트에 집중해 볼게요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저희가 아까 Access Token의 유효 기간이 1시간인 것을 확인했죠? &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;401 에러를 확인하기 위해 1시간을 기다리는 것은 너무 비효율적입니다. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저는 이미 만료된 Access Token을 갖고 있으니 이것을 활용해 볼게요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1084&quot; data-origin-height=&quot;44&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eatwpA/btsL4MaUXPI/aAz4DlG9z7nalS6l3XEU00/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eatwpA/btsL4MaUXPI/aAz4DlG9z7nalS6l3XEU00/img.png&quot; data-alt=&quot;local storage에 저장 된 Access Token&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eatwpA/btsL4MaUXPI/aAz4DlG9z7nalS6l3XEU00/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeatwpA%2FbtsL4MaUXPI%2FaAz4DlG9z7nalS6l3XEU00%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;1084&quot; height=&quot;44&quot; data-origin-width=&quot;1084&quot; data-origin-height=&quot;44&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;local storage에 저장 된 Access Token&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로컬 스토리지에 저장된 Access Token을 이미 만료된 Access Token으로 교체할게요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해 볼까요&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;537&quot; data-origin-height=&quot;44&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dEfPxK/btsL58YfsDC/2NUkWKJDwaDppMypcfOlA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dEfPxK/btsL58YfsDC/2NUkWKJDwaDppMypcfOlA1/img.png&quot; data-alt=&quot;401 에러 발생&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dEfPxK/btsL58YfsDC/2NUkWKJDwaDppMypcfOlA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdEfPxK%2FbtsL58YfsDC%2F2NUkWKJDwaDppMypcfOlA1%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;537&quot; height=&quot;44&quot; data-origin-width=&quot;537&quot; data-origin-height=&quot;44&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;401 에러 발생&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;401 에러가 발생했네요. 그런데 한 가지 문제가 있습니다! &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;분명 팀원분께서 response interceptor에 401 에러가 발생하면 Refresh Token을 이용해 Access Token을 재발급하는 로직을 작성했는데 Refresh Token API 요청을 보내지 않네요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;627&quot; data-origin-height=&quot;197&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mzuMD/btsL5qSBZM4/faWIDKbcW6OvmMtKBhdS00/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mzuMD/btsL5qSBZM4/faWIDKbcW6OvmMtKBhdS00/img.png&quot; data-alt=&quot;console 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mzuMD/btsL5qSBZM4/faWIDKbcW6OvmMtKBhdS00/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmzuMD%2FbtsL5qSBZM4%2FfaWIDKbcW6OvmMtKBhdS00%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;627&quot; height=&quot;197&quot; data-origin-width=&quot;627&quot; data-origin-height=&quot;197&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;console 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;console이 깔끔한 것을 보니 respone interceptor가 적용 안된 것 같아요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;우선 response interceptor부터 적용되도록 수정해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; response interceptor 적용 문제 해결&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;곰곰이 생각해 보니 프로젝트에서 API를 요청할 때 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;instance.ts&lt;/span&gt;&lt;/b&gt;를 import해서 사용하고 있는데, &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;T&lt;span style=&quot;color: #353638; text-align: left;&quot;&gt;oeknInterceptor.ts&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;에서 response interceptor를 적용하고 export 하셨네요.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;response interceptor를 적용만 해두고 사용을 안 하고 있었던 거죠 &lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;instance.ts&lt;/span&gt;&lt;/b&gt;에 병합해서 수정해 볼게요.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738570269646&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from 'axios';
import handleTokenRefresh from '@/app/utils/handleTokenRefresh';
import { store } from '@/app/stores/store';

const instance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_SERVER_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

// 요청 인터셉터: Access Token 추가
instance.interceptors.request.use((config) =&amp;gt; {
  const state = store.getState();
  const token = state.auth.accessToken;

  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  return config;
});

instance.interceptors.response.use(
  (response) =&amp;gt; response,
  (error) =&amp;gt; {
    if (error.response?.status === 401) {
      return handleTokenRefresh(error.config);
    }
    return Promise.reject(error);
  },
);

export default instance;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해 볼까요     &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;571&quot; data-origin-height=&quot;60&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cbSHfo/btsL4FbU2bY/z7w4fJ9hDBv4DwPHh7P8s1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cbSHfo/btsL4FbU2bY/z7w4fJ9hDBv4DwPHh7P8s1/img.png&quot; data-alt=&quot;interceptor 병합 후 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cbSHfo/btsL4FbU2bY/z7w4fJ9hDBv4DwPHh7P8s1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcbSHfo%2FbtsL4FbU2bY%2Fz7w4fJ9hDBv4DwPHh7P8s1%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;571&quot; height=&quot;60&quot; data-origin-width=&quot;571&quot; data-origin-height=&quot;60&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;interceptor 병합 후 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Access Token을 재발급 요청하는 부분까지는 잘 동작하는데, 실패했던 1845번 데이터를 다시 요청하지 않고 있어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;console을 확인해 보며 프로세스가 어떻게 잘못되었는지 확인해 봐야겠네요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;734&quot; data-origin-height=&quot;229&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1kPKK/btsL53bN8Tg/1DkaHtjYv49jay3XpsrkDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1kPKK/btsL53bN8Tg/1DkaHtjYv49jay3XpsrkDk/img.png&quot; data-alt=&quot;병합 후 console 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1kPKK/btsL53bN8Tg/1DkaHtjYv49jay3XpsrkDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1kPKK%2FbtsL53bN8Tg%2F1DkaHtjYv49jay3XpsrkDk%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;734&quot; height=&quot;229&quot; data-origin-width=&quot;734&quot; data-origin-height=&quot;229&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;병합 후 console 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;502&quot; data-origin-height=&quot;327&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eku0lL/btsL6H6TeDj/jYePBzlq2Z7IpbhA5OtyN0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eku0lL/btsL6H6TeDj/jYePBzlq2Z7IpbhA5OtyN0/img.png&quot; data-alt=&quot;401 Response 처리 로직 return 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eku0lL/btsL6H6TeDj/jYePBzlq2Z7IpbhA5OtyN0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Feku0lL%2FbtsL6H6TeDj%2FjYePBzlq2Z7IpbhA5OtyN0%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;502&quot; height=&quot;327&quot; data-origin-width=&quot;502&quot; data-origin-height=&quot;327&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;401 Response 처리 로직 return 코드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;console에 &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;'[디버깅] 새로운 토큰으로 요청을 재시도합니다.'&lt;/span&gt;&lt;/b&gt; 메시지가 없는 것을 보니 return 하고 있는 Promise의 문제인 것 같네요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Promise.resolve에 대해 검색을 통해 문제를 찾았어요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;우선 아래 코드의 실행 과정을 확인해 볼게요 &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1738571538361&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;resolve(axios.request(newConfig));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣ axios.request(newConfig) 실행 - 비동기 작업&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ resolve() 실행 - Promise를 완료 처리&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;3️⃣ axios.request(newConfig)는 비동기라 아직 완료되지 않았는데, Promise가 완료되어 request 요청 실행 X&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과적으로 비동기 처리가 올바르게 되지 않아서 실패한 요청을 재시도하지 않았어요.&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;실패한 요청 재시도 문제 해결 (비동기 처리 수정)&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1738572385673&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const retryRequest = new Promise((resolve, reject) =&amp;gt; {
    refreshSubscribers.push((token: string) =&amp;gt; {
      console.log('[디버깅] 새로운 토큰으로 요청을 재시도합니다.');
      const newConfig = {
        ...errorConfig,
        headers: {
          ...errorConfig.headers,
          Authorization: `Bearer ${token}`,
        },
      };
      // 토큰 갱신 후 재시도할 요청을 실행
      axios.request(newConfig).then(resolve).catch(reject);
    });
  });

return retryRequest;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해 볼까요&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp; &amp;nbsp;&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;119&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dorbCH/btsL5BGCsJF/nYlb7r3Nh0VJTqONAdvII0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dorbCH/btsL5BGCsJF/nYlb7r3Nh0VJTqONAdvII0/img.png&quot; data-alt=&quot;비동기 로직 수정 후 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dorbCH/btsL5BGCsJF/nYlb7r3Nh0VJTqONAdvII0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdorbCH%2FbtsL5BGCsJF%2FnYlb7r3Nh0VJTqONAdvII0%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;614&quot; height=&quot;119&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;119&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;비동기 로직 수정 후 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;721&quot; data-origin-height=&quot;189&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCm7tL/btsL5uOkVNA/xBXyPfT81FPyKMlcEQ71yK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCm7tL/btsL5uOkVNA/xBXyPfT81FPyKMlcEQ71yK/img.png&quot; data-alt=&quot;비동기 로직 수정 후 console 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCm7tL/btsL5uOkVNA/xBXyPfT81FPyKMlcEQ71yK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCm7tL%2FbtsL5uOkVNA%2FxBXyPfT81FPyKMlcEQ71yK%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;721&quot; height=&quot;189&quot; data-origin-width=&quot;721&quot; data-origin-height=&quot;189&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;비동기 로직 수정 후 console 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 이번 문제를 해결하면서 &lt;b&gt;비동기 함수의 흐름을 명확하게 이해하는 것의 중요성&lt;/b&gt;을 다시 한번 깨달았어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 특히, &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;resolve(axios.request(newConfig))&lt;/span&gt;&lt;/b&gt;가 왜 요청을 재시도하지 못하는지를 깊이 파고들면서 resolve()가 &lt;b&gt;Promise의 상태를 결정&lt;/b&gt;한다는 점을 다시 확인할 수 있었어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;추가적으로 다른 팀원이 작성한 코드를 이해하는데 많은 시간이 들었지만, 좋은 점을 하나 배워가게 되었어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;console.log()를 적절한 위치에 배치해&amp;nbsp; 프로세스의 흐름을 빠르게 파악할 수 있도록 돕는 방법이에요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이번에 interceptor를 작업하면서 console.log()를 작성해 주신 부분 덕분에 프로세스의 흐름을 빠르게 파악할 수 있었어요!&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>axios</category>
      <category>interceptor</category>
      <category>Next.js</category>
      <category>Refresh Token</category>
      <category>인터셉터</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/180</guid>
      <comments>https://dev-hpk.tistory.com/180#entry180comment</comments>
      <pubDate>Mon, 3 Feb 2025 17:59:31 +0900</pubDate>
    </item>
    <item>
      <title>[Coworkers] 클립보드 복사 (Trouble Shooting)</title>
      <link>https://dev-hpk.tistory.com/179</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오늘 작업에는 클립보드를 이용하는 부분이 있습니다❗&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;419&quot; data-origin-height=&quot;245&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/03VCO/btsL3pSKmgi/BAqsGKfRCvhUUdhGNZhHrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/03VCO/btsL3pSKmgi/BAqsGKfRCvhUUdhGNZhHrK/img.png&quot; data-alt=&quot;멤버 초대 모달 UI&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/03VCO/btsL3pSKmgi/BAqsGKfRCvhUUdhGNZhHrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F03VCO%2FbtsL3pSKmgi%2FBAqsGKfRCvhUUdhGNZhHrK%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;419&quot; height=&quot;245&quot; data-origin-width=&quot;419&quot; data-origin-height=&quot;245&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;멤버 초대 모달 UI&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;424&quot; data-origin-height=&quot;298&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/C4WCQ/btsL18dpcWz/1bSvKk8mL1D7RDo32rUHK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/C4WCQ/btsL18dpcWz/1bSvKk8mL1D7RDo32rUHK1/img.png&quot; data-alt=&quot;유저 정보 모달 UI&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/C4WCQ/btsL18dpcWz/1bSvKk8mL1D7RDo32rUHK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FC4WCQ%2FbtsL18dpcWz%2F1bSvKk8mL1D7RDo32rUHK1%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;424&quot; height=&quot;298&quot; data-origin-width=&quot;424&quot; data-origin-height=&quot;298&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;유저 정보 모달 UI&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;멤버 초대 모달의 '링크 복사하기' 버튼을 클릭하면 URL을 클립보드에 복사하고, 유저 정보 모달의 '이메일 복사하기' 버튼을 클릭하면 유저의 이메일을 복사해요✨&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;클립보드를 어떻게 구현해야 할까요  &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;라이브러리를 찾아보기 전에 저의 해답지인 MDN을 찾아볼게요!&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;77&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cRrv6G/btsL02kDVco/rcTz1TeYInaR8VYG6MU4t0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cRrv6G/btsL02kDVco/rcTz1TeYInaR8VYG6MU4t0/img.png&quot; data-alt=&quot;Clipboard API 설명 - 출처 MDN&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cRrv6G/btsL02kDVco/rcTz1TeYInaR8VYG6MU4t0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcRrv6G%2FbtsL02kDVco%2FrcTz1TeYInaR8VYG6MU4t0%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;800&quot; height=&quot;77&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;77&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Clipboard API 설명 - 출처 MDN&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Clipboard API의 writeText() 메서드를 사용하면 특정 text를 클립보드에 저장하고 Promise를 반환한다고 하네요 &lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Clipboard API - writeText 사용법&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1737956608929&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;async function writeClipboardText(text) {
  try {
    await navigator.clipboard.writeText(text);
  } catch (error) {
    console.error(error.message);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;링크 복사 - writeText 적용&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1737957237519&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleClick = async () =&amp;gt; {
  await navigator.clipboard.writeText(`${SERVER_URL}?token=${token}`);
  closeModal();
};

&amp;lt;Button className=&quot;w-full text-text-inverse&quot; onClick={handleClick}&amp;gt;
  링크 복사하기
&amp;lt;/Button&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;이메일 복사 - writeText 적용&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1737957275706&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleClick = async () =&amp;gt; {
  await navigator.clipboard.writeText(member.userEmail);
  closeModal();
};

&amp;lt;Button className=&quot;w-full text-text-inverse&quot; onClick={handleClick}&amp;gt;
  이메일 복사하기
&amp;lt;/Button&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;결과 - 클립보드 복사&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Coworkers-Chrome-2025-01-27-15-07-54.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wWPiq/btsL1gKihEm/CL1vKVprn38Z0fBEmsznb1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wWPiq/btsL1gKihEm/CL1vKVprn38Z0fBEmsznb1/img.gif&quot; data-alt=&quot;클립보드 적용 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wWPiq/btsL1gKihEm/CL1vKVprn38Z0fBEmsznb1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/wWPiq/btsL1gKihEm/CL1vKVprn38Z0fBEmsznb1/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;Coworkers-Chrome-2025-01-27-15-07-54.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;클립보드 적용 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;PC에서 링크와 이메일 모두 잘 복사되네요! &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;하지만 여기서 끝내면 안 되겠죠. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;PC에서 잘 동작하던 기능들이 OS와 브라우저에 따라서 제대로 동작하지 않은 경우를 경험했잖아요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아니나 다를까 노트북과 아이폰 Safari 브라우저로 확인했더니 에러가 발생하네요 &lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  문제 상황&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;676&quot; data-origin-height=&quot;75&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xvon9/btsL1to9FJ5/gaOBffKKKKgxLNlk3jmZr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xvon9/btsL1to9FJ5/gaOBffKKKKgxLNlk3jmZr0/img.png&quot; data-alt=&quot;Clipboard API 에러&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xvon9/btsL1to9FJ5/gaOBffKKKKgxLNlk3jmZr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fxvon9%2FbtsL1to9FJ5%2FgaOBffKKKKgxLNlk3jmZr0%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;676&quot; height=&quot;75&quot; data-origin-width=&quot;676&quot; data-origin-height=&quot;75&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Clipboard API 에러&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;MDN의 cilpboard API를 확인해 보니 localhost나 HTTPS 환경에서만 사용할 수 있다고 하네요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;812&quot; data-origin-height=&quot;197&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dpM2ds/btsL03w63LX/dv1YE4JD45Soo1vqhWDIg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dpM2ds/btsL03w63LX/dv1YE4JD45Soo1vqhWDIg0/img.png&quot; data-alt=&quot;clipboard API - MDN&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dpM2ds/btsL03w63LX/dv1YE4JD45Soo1vqhWDIg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdpM2ds%2FbtsL03w63LX%2Fdv1YE4JD45Soo1vqhWDIg0%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;812&quot; height=&quot;197&quot; data-origin-width=&quot;812&quot; data-origin-height=&quot;197&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;clipboard API - MDN&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;코드를 통해 직접 확인해 봐야겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1737959592915&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleClick = async () =&amp;gt; {
    if (!navigator.clipboard) {
      alert('현재 브라우저에서 클립보드 복사를 지원하지 않습니다.');
      return;
    }
    await navigator.clipboard.writeText(member.userEmail);
    closeModal();
  };&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;572&quot; data-origin-height=&quot;182&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbDc9E/btsL23PZKp9/oojWOcr58ga3hJQwoiWcNK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbDc9E/btsL23PZKp9/oojWOcr58ga3hJQwoiWcNK/img.png&quot; data-alt=&quot;에러 확인 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbDc9E/btsL23PZKp9/oojWOcr58ga3hJQwoiWcNK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbDc9E%2FbtsL23PZKp9%2FoojWOcr58ga3hJQwoiWcNK%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;572&quot; height=&quot;182&quot; data-origin-width=&quot;572&quot; data-origin-height=&quot;182&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;에러 확인 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  문제 해결 방법&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1️⃣ &lt;s&gt;Document.execCommand()&lt;/s&gt; : &lt;/b&gt;지원 중단&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2️⃣ react-copy-to-clipboard&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ react-copy-to-clipboard 라이브러리를 사용하면 라이브러리에서 제공하는 간단한 컴포넌트를 이용해 복사 기능을 구현할 수 있지만, 저희 프로젝트는 React 19 버전이라 사용이 불가능했어요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;538&quot; data-origin-height=&quot;130&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dbxD9q/btsL3jdZFC4/YaGdrtfNBlt9SXHlikFT5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dbxD9q/btsL3jdZFC4/YaGdrtfNBlt9SXHlikFT5k/img.png&quot; data-alt=&quot;react-copy-to-clipboard 라이브러리 pr&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dbxD9q/btsL3jdZFC4/YaGdrtfNBlt9SXHlikFT5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdbxD9q%2FbtsL3jdZFC4%2FYaGdrtfNBlt9SXHlikFT5k%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;538&quot; height=&quot;130&quot; data-origin-width=&quot;538&quot; data-origin-height=&quot;130&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;react-copy-to-clipboard 라이브러리 pr&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;811&quot; data-origin-height=&quot;254&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5WADl/btsL1dUkrFl/LzKOVXaR9gSVd72KmtrGn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5WADl/btsL1dUkrFl/LzKOVXaR9gSVd72KmtrGn0/img.png&quot; data-alt=&quot;execCommand() 지원중단&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5WADl/btsL1dUkrFl/LzKOVXaR9gSVd72KmtrGn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5WADl%2FbtsL1dUkrFl%2FLzKOVXaR9gSVd72KmtrGn0%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;811&quot; height=&quot;254&quot; data-origin-width=&quot;811&quot; data-origin-height=&quot;254&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;execCommand() 지원중단&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;안타깝게도 위 메서드는 지원 중단되어 권장되지 않는다고 하네요  &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;하지만 저에게는 방법이 없습니다. 찾아보니 execCommand() 메서드가 아직 많은 브라우저에서 동작하네요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;777&quot; data-origin-height=&quot;448&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YCV6K/btsL3hgbGUc/sRSmAREtkPNEMoOmkgkxak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YCV6K/btsL3hgbGUc/sRSmAREtkPNEMoOmkgkxak/img.png&quot; data-alt=&quot;execCommand 브라우저 호환성 표&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YCV6K/btsL3hgbGUc/sRSmAREtkPNEMoOmkgkxak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYCV6K%2FbtsL3hgbGUc%2FsRSmAREtkPNEMoOmkgkxak%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;777&quot; height=&quot;448&quot; data-origin-width=&quot;777&quot; data-origin-height=&quot;448&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;execCommand 브라우저 호환성 표&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  문제 해결&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;execCommand를 이용한 텍스트 복사 함수&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1737962781823&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const copyWithExecCommand = (text: string) =&amp;gt; {
  const textArea = document.createElement('textarea');
  textArea.value = text;
  document.body.appendChild(textArea);
  textArea.select();
  document.execCommand('copy');
  document.body.removeChild(textArea);
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;링크 복사 - writeText 적용&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1737962833931&quot; class=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;const handleClick = async () =&amp;gt; {
  if (navigator.clipboard &amp;amp;&amp;amp; navigator.clipboard.writeText) {
    await navigator.clipboard.writeText(`${SERVER_URL}?token=${token}`);
  } else {
    copyWithExecCommand(`${SERVER_URL}?token=${token}`);
  }
  closeModal();
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;이메일 복사 - writeText 적용&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1737962840168&quot; class=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;const handleClick = async () =&amp;gt; {
  if (navigator.clipboard &amp;amp;&amp;amp; navigator.clipboard.writeText) {
    await navigator.clipboard.writeText(member.userEmail);
  } else {
    copyWithExecCommand(member.userEmail);
  }

  closeModal();
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;결과 - 클립보드 복사&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Coworkers-Chrome-2025-01-27-16-49-54.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdSLi5/btsL2dMixVx/Q5aXv4JgK3NtdCKij78Akk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdSLi5/btsL2dMixVx/Q5aXv4JgK3NtdCKij78Akk/img.gif&quot; data-alt=&quot;문제 해결 후 클립보드 복사&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdSLi5/btsL2dMixVx/Q5aXv4JgK3NtdCKij78Akk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bdSLi5/btsL2dMixVx/Q5aXv4JgK3NtdCKij78Akk/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;Coworkers-Chrome-2025-01-27-16-49-54.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;문제 해결 후 클립보드 복사&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt; navigator.clipboard&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;를 호환하는 https에서는 기존과 동일하게 동작합니다.&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;navigator.clipboard&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;를 호환하지 않는 &lt;/span&gt;http에서는 아래 과정을 거치게 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;값을 저장하기 위한&amp;nbsp;textarea라는 요소를 만들고 value 속성에 복사할 값을 할당&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;textarea 요소를 html body에 추가하고 선택&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;execCommand&amp;nbsp;메서드를 사용해 클립보드에 텍스트를 복사&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;textarea 요소를 html body에서 삭제&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;Document.execCommand()&lt;/b&gt;가 지원 중단되었다고는 하지만, 덕분에 navigator.clipbaord를 호환하지 않는 http 환경에서도 안전하게 클립보드 복사 기능을 구현할 수 있게 되었어요 &lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>clipboard api</category>
      <category>execCommand</category>
      <category>navigator api</category>
      <category>Next.js</category>
      <category>TS</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/179</guid>
      <comments>https://dev-hpk.tistory.com/179#entry179comment</comments>
      <pubDate>Mon, 27 Jan 2025 17:07:33 +0900</pubDate>
    </item>
    <item>
      <title>[맛길] API Routes를 이용한 API 구축</title>
      <link>https://dev-hpk.tistory.com/178</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;지난번에 Youtube Data API를 사용해 열심히 JSON 데이터를 만들었습니다. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;처음 사용해 보는 API다 보니 공식문서를 여러 번 읽는 것도 힘들었지만, 이제 빛을 볼 시간입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;프로젝트에 JSON 데이터를 추가하고 사용해 볼게요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;230&quot; data-origin-height=&quot;118&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzbFuS/btsL11q5pEe/DyktiJhnF50WuQ1lvsO4A0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzbFuS/btsL11q5pEe/DyktiJhnF50WuQ1lvsO4A0/img.png&quot; data-alt=&quot;youtube 영상 데이터 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzbFuS/btsL11q5pEe/DyktiJhnF50WuQ1lvsO4A0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbzbFuS%2FbtsL11q5pEe%2FDyktiJhnF50WuQ1lvsO4A0%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;230&quot; height=&quot;118&quot; data-origin-width=&quot;230&quot; data-origin-height=&quot;118&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;youtube 영상 데이터 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;맛길 프로젝트의 기획 포스팅을 보지 않으셨다면, 왜 프로젝트에 JSON 데이터를 일일이 추가하는지 의문이 드실 수 있겠네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이번 프로젝트는 CRUD나 로그인 기능을 제외했기 때문에 firebase나 supabase&amp;nbsp; 같은 실시간 DB 서버를 사용하는 것은 조금 과한 것 같아요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그래서 저는 Next.js에서 지원하는 API Routes를 사용해 볼 겁니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;API Routes는 Next.js에서 &lt;b&gt;Serverless API Endpoint&lt;/b&gt;를 만들 수 있게 해주는 기능이라고 하는데요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;글로만 보면 무슨 말인지 잘 모르겠으니 직접 확인해 보겠습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  API Routes를 활용한 공용 API 구축&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;230&quot; data-origin-height=&quot;250&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b07RPk/btsL1YBhONz/EC435WfK7iYXlstmqGxsj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b07RPk/btsL1YBhONz/EC435WfK7iYXlstmqGxsj0/img.png&quot; data-alt=&quot;api routes 폴더 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b07RPk/btsL1YBhONz/EC435WfK7iYXlstmqGxsj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb07RPk%2FbtsL1YBhONz%2FEC435WfK7iYXlstmqGxsj0%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;230&quot; height=&quot;250&quot; data-origin-width=&quot;230&quot; data-origin-height=&quot;250&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;api routes 폴더 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;폴더 구조는 위에 보시는 사진처럼 작업했어요. &lt;/span&gt;&lt;b&gt;Serverless API Endpoint&lt;/b&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; letter-spacing: 0px;&quot;&gt;를 만들 수 있게 해주는 기능이라고 하는데 코드를 통해 알아봐야겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1737722405648&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import path from 'path';
import { promises as fs } from 'fs';
import { RawYoutubeData, YoutubeData } from '@/app/types/youtube';

export async function GET(request: Request): Promise&amp;lt;Response&amp;gt; {
  try {
    // JSON 파일 경로 설정
    // 'data/seongsigyeong.json' 파일의 경로를 생성
    const filePath = path.join(process.cwd(), 'data', 'seongsigyeong.json');

    // 파일 읽기
    const fileContents = await fs.readFile(filePath, 'utf-8');

    // JSON 파싱
    // JSON 데이터를 RawYoutubeData[] 타입의 JavaScript 객체로 변환
    const rawData: RawYoutubeData[] = JSON.parse(fileContents);

    // rawData를 순회하며 필요한 속성만 추출하여 새로운 배열 반환
    const data: YoutubeData[] = rawData.map((item) =&amp;gt; ({
      id: item.id,
      position: item.snippet.position,
      title: item.snippet.title,
      thumbnailUrl: item.snippet.thumbnails.high.url,
    }));

    // 클라이언트에서 'limit' 파라미터를 전달받아 데이터 개수 제한 설정
    const url = new URL(request.url);
    const limitParam = url.searchParams.get('limit');
    const limit = limitParam ? parseInt(limitParam, 10) : data.length;

    const limitedData = data.slice(0, limit);
	
    // 성공 응답 반환
    // 제한된 데이터를 JSON 형태로 응답하며, HTTP 상태 코드는 200으로 설정합니다.
    return new Response(JSON.stringify({ success: true, lists: limitedData }), {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
    });
  } catch (error) {
    // 에러 처리
    // 에러 메시지 출력하며, HTTP 상태 코드를 500 (서버 오류)로 설정
    console.error('데이터를 받아오는 중 에러가 발생했습니다:', error);
    return new Response(
      JSON.stringify({ success: false, message: '데이터 요청 실패' }),
      {
        status: 500,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Next.js 공식 문서나 다른 글들을 찾아보니 함수 이름을 handler를 많이 사용하는데, 저는 역할을 명확하게 나타내기 위해 HTTP Method를 이름으로 사용했어요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;limit 파라미터는 서버 사이드에서 화면에 보여줄 데이터의 개수를 제한하기 위해서 설정했어요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;API 설계를 처음해보다 보니 일단 개수를 제한하는 파라미터만 추가했지만 추후 작업을 통해 더 많은 기능을 추가해 보겠습니다 &lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;✨ 데이터 요청&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;API도 완성했으니 위에서 정의한 Endpoint에 데이터를 요청해 봐야겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1737725690737&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from '@/app/lib/instance';
import ListContainer from '@/app/components/lists/ListContainer';

async function page() {
  const {
    data: { lists },
  } = await axios.get('ssg');

  return &amp;lt;ListContainer lists={lists} /&amp;gt;;
}

export default page;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;421&quot; data-origin-height=&quot;780&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhFnGK/btsL1A8qB8i/JwlFg2AoPKEGoxMdxr4H9K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhFnGK/btsL1A8qB8i/JwlFg2AoPKEGoxMdxr4H9K/img.png&quot; data-alt=&quot;데이터 요청 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhFnGK/btsL1A8qB8i/JwlFg2AoPKEGoxMdxr4H9K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhFnGK%2FbtsL1A8qB8i%2FJwlFg2AoPKEGoxMdxr4H9K%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;421&quot; height=&quot;780&quot; data-origin-width=&quot;421&quot; data-origin-height=&quot;780&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;데이터 요청 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1131&quot; data-origin-height=&quot;652&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tRdOt/btsL1YIalux/VyJv5cgg6OZHxf6Zp90x80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tRdOt/btsL1YIalux/VyJv5cgg6OZHxf6Zp90x80/img.png&quot; data-alt=&quot;SSR (Server Side Rendering)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tRdOt/btsL1YIalux/VyJv5cgg6OZHxf6Zp90x80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtRdOt%2FbtsL1YIalux%2FVyJv5cgg6OZHxf6Zp90x80%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;1131&quot; height=&quot;652&quot; data-origin-width=&quot;1131&quot; data-origin-height=&quot;652&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;SSR (Server Side Rendering)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;데이터 요청에 성공했어요! &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;SSR(Server Side Rendering)도 잘 동작하네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 프로젝트를 시작하려고 기획할 때마다 db가 없어서 고민이었는데 이제 API Routes를 이용해 간단한 서버 정도는 직접 만들어서 사용하면 되겠네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;API Routes가 serverless function이라 실시간 통신 같은 live connection에는 적절하지 않다는 점은 조금 아쉬워요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1737726604248&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[맛길] 데이터 채우기 - Youtube Data API&quot; data-og-description=&quot;기획 단계에서 Serverless Function을 이용해 유튜브 영상과 상세 내용을 관리하기로 했습니다❗&amp;nbsp;흠.. 영상 정보를 어떻게 저장할까요 직접 JSON 파일에 작성하기에는 양이 너무 많아 비효율적일 것&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/170&quot; data-og-url=&quot;https://dev-hpk.tistory.com/170&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/e6Eq7/hyX4vH3FmW/u8yR0OLyMfDFzZKFkOrbFk/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/MO4Za/hyX7XJCQhn/kawxB4APx9ihcKI9BECri0/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/5CL8U/hyX7XQoOAv/yKiPyFLMJzvaCkBKwRkTmK/img.png?width=1538&amp;amp;height=768&amp;amp;face=0_0_1538_768&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/170&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/170&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/e6Eq7/hyX4vH3FmW/u8yR0OLyMfDFzZKFkOrbFk/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/MO4Za/hyX7XJCQhn/kawxB4APx9ihcKI9BECri0/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/5CL8U/hyX7XQoOAv/yKiPyFLMJzvaCkBKwRkTmK/img.png?width=1538&amp;amp;height=768&amp;amp;face=0_0_1538_768');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[맛길] 데이터 채우기 - Youtube Data API&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;기획 단계에서 Serverless Function을 이용해 유튜브 영상과 상세 내용을 관리하기로 했습니다❗&amp;nbsp;흠.. 영상 정보를 어떻게 저장할까요 직접 JSON 파일에 작성하기에는 양이 너무 많아 비효율적일 것&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1737726614579&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[맛길] 프로젝트 주제 및 기술 스택 선정&quot; data-og-description=&quot;  사이드 프로젝트 시작 계기팀 프로젝트를 진행하면서 소통과 협업의 기술을 배울 수 있어 프론트앤드 개발자로서 한 걸음 더 가까워진 것 같아요.그런데 프로젝트 후반으로 갈 수록 저를 포&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/169&quot; data-og-url=&quot;https://dev-hpk.tistory.com/169&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/e6ULn/hyX4rS8FJz/2zVQtgR4NyntKsikqTE5B0/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/yOtkj/hyX4pViYeU/KATKQZFPQCpFEjjo3t09fk/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/EKfdV/hyX707qHG7/Jxtbc89n5ORxPxFaaTT7P0/img.png?width=435&amp;amp;height=668&amp;amp;face=0_0_435_668&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/169&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/169&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/e6ULn/hyX4rS8FJz/2zVQtgR4NyntKsikqTE5B0/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/yOtkj/hyX4pViYeU/KATKQZFPQCpFEjjo3t09fk/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/EKfdV/hyX707qHG7/Jxtbc89n5ORxPxFaaTT7P0/img.png?width=435&amp;amp;height=668&amp;amp;face=0_0_435_668');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[맛길] 프로젝트 주제 및 기술 스택 선정&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  사이드 프로젝트 시작 계기팀 프로젝트를 진행하면서 소통과 협업의 기술을 배울 수 있어 프론트앤드 개발자로서 한 걸음 더 가까워진 것 같아요.그런데 프로젝트 후반으로 갈 수록 저를 포&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>api routes</category>
      <category>Next.js</category>
      <category>serverless function</category>
      <category>사이드 프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/178</guid>
      <comments>https://dev-hpk.tistory.com/178#entry178comment</comments>
      <pubDate>Fri, 24 Jan 2025 22:52:20 +0900</pubDate>
    </item>
    <item>
      <title>[Coworkers] 팀 생성 페이지 작업 (feat. 빌드 타임 에러 수정)</title>
      <link>https://dev-hpk.tistory.com/177</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이미지 업로드 작업에서 너무 많은 시간을 써버렸네요..  &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이미지 업로드 문제를 해결하기 위해 React Hook Form을 정독했으니 팀 생성은 이제 아무것도 아닙니다! &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;우선 팀 생성 API를 먼저 작업해 볼게요.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;팀 생성 API&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1737632847993&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from '@/app/lib/instance';

export interface PostGroupData {
  profile?: string; // 프로필 이미지는 선택 사항
  name: string;
}

const postGroup = async (data: PostGroupData) =&amp;gt; {
  const res = await axios.post('groups', data, {
    headers: {
      Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`,
    },
  });

  return res;
};

export default postGroup;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Authorization : 로그인이 아직 미완성이라 임의로 .env에 토큰을 저장해서 사용&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Form onSubmit 핸들러&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1737633181691&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const onSubmit = async ({ profile, name }: FieldValues) =&amp;gt; {
    let imageUrl: string | null = null;

    // 이미지 업로드 - 프로필 이미지 선택 안한 경우 생략
    if (profile &amp;amp;&amp;amp; profile[0] instanceof File) {
      try {
        const formData = new FormData();

        formData.append('image', profile[0]);

        const {
          data: { url },
        } = await postImage(formData);

        imageUrl = url;
      } catch (error) {
        alert('이미지 업로드에 실패했습니다.');
      }
    }

    // 팀 생성
    try {
      const teamData: PostGroupData = {
        name,
      };

      if (imageUrl) {
        teamData.profile = imageUrl;
      }

      await postGroup(teamData);
      alert('팀이 성공적으로 생성되었습니다!');
    } catch (error) {
      alert('팀 생성에 실패했습니다.');
    }
  };&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;profile &amp;amp;&amp;amp; profile[0] instanceof File : profile(폼 이미지)이 존재하고 File 객체인지 확인&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; if (imageUrl) {teamData.profile = imageUrl;} : 이미지를 선택한 경우만 teamData에 profile을 추가&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #222222; text-align: start;&quot;&gt;결과를 확인해 봐야겠죠  &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;323&quot; data-origin-height=&quot;111&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/O8XWo/btsLZhHIDTW/PP1lNfPYabJoZBt4tzefk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/O8XWo/btsLZhHIDTW/PP1lNfPYabJoZBt4tzefk1/img.png&quot; data-alt=&quot;팀 생성 요청&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/O8XWo/btsLZhHIDTW/PP1lNfPYabJoZBt4tzefk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FO8XWo%2FbtsLZhHIDTW%2FPP1lNfPYabJoZBt4tzefk1%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;323&quot; height=&quot;111&quot; data-origin-width=&quot;323&quot; data-origin-height=&quot;111&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;팀 생성 요청&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;649&quot; data-origin-height=&quot;139&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Nmc8x/btsLZj6stUb/e4MqWNm3SciJPwZNnOZLz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Nmc8x/btsLZj6stUb/e4MqWNm3SciJPwZNnOZLz1/img.png&quot; data-alt=&quot;팀 생성 요청 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Nmc8x/btsLZj6stUb/e4MqWNm3SciJPwZNnOZLz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNmc8x%2FbtsLZj6stUb%2Fe4MqWNm3SciJPwZNnOZLz1%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;649&quot; height=&quot;139&quot; data-origin-width=&quot;649&quot; data-origin-height=&quot;139&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;팀 생성 요청 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #222222; text-align: start;&quot;&gt;이미지와 팀 이름 모두 정상적으로 적용되어 팀을 생성했네요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;page의 코드가 150줄이네요. 그다지 긴 것 같진 않지만 그래도 가독성과 재사용성을 고려해 분리해 볼게요✨&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;page.tsx&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1737634257792&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Page() {
  const router = useRouter();

  const onSubmit = async ({ profile, name }: FieldValues) =&amp;gt; {
    /* 생략 */
  };

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;div className=&quot;mx-auto mt-[3.75rem] max-w-[23.4375rem] px-4 pt-[4.5rem] tablet:w-[28.75rem] tablet:px-0 tablet:pt-[6.25rem]&quot;&amp;gt;
        &amp;lt;h2 className=&quot;mb-6 text-center text-2xl font-medium text-text-primary tablet:mb-20&quot;&amp;gt;
          팀 생성하기
        &amp;lt;/h2&amp;gt;
        &amp;lt;TeamForm onSubmit={onSubmit}&amp;gt;생성하기&amp;lt;/TeamForm&amp;gt;
        &amp;lt;div className=&quot;mt-6 text-center text-md text-text-primary tablet:text-lg&quot;&amp;gt;
          팀 이름은 회사명이나 모임 이름 등으로 설정하면 좋아요.
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default Page;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;TeamForm.tsx&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1737634371111&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface TeamFormProps {
  onSubmit: (data: FieldValues) =&amp;gt; Promise&amp;lt;void&amp;gt;;
}

function TeamForm({ children, onSubmit }: PropsWithChildren&amp;lt;TeamFormProps&amp;gt;) {
  const method = useForm();
  const { register, handleSubmit } = method;

  return (
    &amp;lt;FormProvider {...method}&amp;gt;
      &amp;lt;form className=&quot;flex flex-col gap-6&quot; onSubmit={handleSubmit(onSubmit)}&amp;gt;
        &amp;lt;ProfileUploader register={register} /&amp;gt;
        &amp;lt;Input
          name=&quot;name&quot;
          title=&quot;팀 이름&quot;
          type=&quot;text&quot;
          placeholder=&quot;팀 이름을 입력해주세요.&quot;
          validationRules={{
            required: '이름을 입력해주세요.',
            minLength: {
              value: 1,
              message: '이름은 최소 1글자 이상입니다.',
            },
            maxLength: {
              value: 30,
              message: '이름은 최대 30글자까지 입력 가능합니다.',
            },
            validate: (value) =&amp;gt;
              value.trim() !== '' || '팀 이름에 공백만 입력할 수 없습니다.',
          }}
          autoComplete=&quot;off&quot;
        /&amp;gt;
      &amp;lt;/form&amp;gt;
      &amp;lt;Button
        variant=&quot;primary&quot;
        className=&quot;mt-10 w-full text-white&quot;
        onClick={handleSubmit(onSubmit)}
      &amp;gt;
        {children}
      &amp;lt;/Button&amp;gt;
    &amp;lt;/FormProvider&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;ProfileUploader&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1737634424692&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function ProfileUploader({ register }: ProfileUploaderProps) {
  const [profileImage, setProfileImage] = useState('');

  // 파일 처리하는 함수
  const handleFileChange = (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const file = e.target.files?.[0];
    if (file) {
      const url = URL.createObjectURL(file); // 미리보기 URL 생성
      setProfileImage(url); // 미리보기 이미지 업데이트
    }
  };

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;span className=&quot;mb-3 inline-block&quot;&amp;gt;팀 프로필&amp;lt;/span&amp;gt;
      &amp;lt;label
        htmlFor=&quot;profile&quot;
        className=&quot;relative block h-16 w-16 cursor-pointer&quot;
      &amp;gt;
        &amp;lt;input
          id=&quot;profile&quot;
          className=&quot;sr-only&quot;
          type=&quot;file&quot;
          accept=&quot;image/*&quot;
          {...register('profile')}
          onChange={handleFileChange}
        /&amp;gt;
        {profileImage ? (
          &amp;lt;&amp;gt;
            &amp;lt;Image
              src={profileImage}
              className=&quot;rounded-full border-2 border-border-primary&quot;
              fill
              alt=&quot;프로필 이미지&quot;
            /&amp;gt;
            &amp;lt;IconProfileEdit className=&quot;absolute bottom-0 right-0&quot; /&amp;gt;
          &amp;lt;/&amp;gt;
        ) : (
          &amp;lt;IconProfile /&amp;gt;
        )}
      &amp;lt;/label&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이대로 마무리되면 좋겠지만 배포 단계에서 에러가 발생했습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1214&quot; data-origin-height=&quot;550&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/z40r9/btsLXWSmzGB/pHGeLgOCEmgnAYluhLadTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/z40r9/btsLXWSmzGB/pHGeLgOCEmgnAYluhLadTk/img.png&quot; data-alt=&quot;배포 에러&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/z40r9/btsLXWSmzGB/pHGeLgOCEmgnAYluhLadTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fz40r9%2FbtsLXWSmzGB%2FpHGeLgOCEmgnAYluhLadTk%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;1214&quot; height=&quot;550&quot; data-origin-width=&quot;1214&quot; data-origin-height=&quot;550&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;배포 에러&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;에러 메시지를 보니 postIamge의 response 타입이 제대로 정의되지 않은 것 같아요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;함수의 return 타입을 정의해줄게요 &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1737634647518&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type PostImageResponse = {
  url: string;
};
const postImage = async (img: FormData): Promise&amp;lt;PostImageResponse&amp;gt; =&amp;gt; {
  const res = await axios.post('images/upload', img, {
    headers: {
      'Content-Type': 'multipart/form-data',
      Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`,
    },
  });

  return res.data;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1737634704526&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type PostGroupResponse = {
  id: string;
};

const postGroup = async (data: PostGroupData): Promise&amp;lt;PostGroupResponse&amp;gt; =&amp;gt; {
  const res = await axios.post('groups', data, {
    headers: {
      Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`,
    },
  });

  return res.data;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아직도 에러가 발생하네요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1349&quot; data-origin-height=&quot;804&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bp4ZLz/btsLYvfF6C8/gmRZuSKPdIDxPnldEqiLXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bp4ZLz/btsLYvfF6C8/gmRZuSKPdIDxPnldEqiLXK/img.png&quot; data-alt=&quot;배포 에러&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bp4ZLz/btsLYvfF6C8/gmRZuSKPdIDxPnldEqiLXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbp4ZLz%2FbtsLYvfF6C8%2FgmRZuSKPdIDxPnldEqiLXK%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;1349&quot; height=&quot;804&quot; data-origin-width=&quot;1349&quot; data-origin-height=&quot;804&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;배포 에러&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 이번에는 에러 메시지가 바뀐 것 같아요. axios 요청의 응답이 unknown 타입이라 PostGroupResponse 타입에 할당할 수 없다고 하네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;함수의 반환 타입으로 Promise&amp;lt;PostGroupResponse&amp;gt;라는 비동기 타입을 반환하는 것으로 해결이 안 되는 것 같네요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그럼 적용해 볼 수 있는 방법은 axios 요청의 response 타입을 직접 정의해 주는 것뿐이네요 &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1737635443737&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type PostGroupResponse = {
  id: string;
};

const postGroup = async (data: PostGroupData): Promise&amp;lt;PostGroupResponse&amp;gt; =&amp;gt; {
  const res = await axios.post&amp;lt;PostGroupResponse&amp;gt;('groups', data, {
    headers: {
      Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`,
    },
  });

  return res.data;
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1737635462225&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const postImage = async (img: FormData): Promise&amp;lt;PostImageResponse&amp;gt; =&amp;gt; {
  const res = await axios.post&amp;lt;PostImageResponse&amp;gt;('images/upload', img, {
    headers: {
      'Content-Type': 'multipart/form-data',
      Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`,
    },
  });

  return res.data;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1212&quot; data-origin-height=&quot;97&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EQpTs/btsLXqeYAtu/ruI8zDa3dclGIDZv9SPD1K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EQpTs/btsLXqeYAtu/ruI8zDa3dclGIDZv9SPD1K/img.png&quot; data-alt=&quot;배포 에러 해결&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EQpTs/btsLXqeYAtu/ruI8zDa3dclGIDZv9SPD1K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEQpTs%2FbtsLXqeYAtu%2FruI8zDa3dclGIDZv9SPD1K%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;1212&quot; height=&quot;97&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1212&quot; data-origin-height=&quot;97&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;배포 에러 해결&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이전 프로젝트에서는 axios 요청의 response 타입을 &lt;b&gt;&amp;lt;T&amp;gt;&lt;/b&gt; 형태로 명시하지 않아도 에러가 발생하지 않아서 잘 몰랐네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;함수의 반환 타입에 &lt;b&gt;Promise&amp;lt;T&amp;gt;&amp;nbsp;&lt;/b&gt;형태만 지정해 주면 알아서 타입을 추론한다고 생각했는데, 지금까지 any 타입으로 추론해서 에러가 발생 안 했던 거라니 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이전에는 런타임에서만 오류를 신경 썼기 때문에 빌드 타임에서 발생하는 오류에 대해서는 잘 몰랐어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 그런데 이번에 타입을 명시하지 않아 빌드 타임에서 오류가 발생하고, 해결을 위해 많은 시간을 투자하고 보니 &lt;b&gt;타입을 명확하게 지정&lt;/b&gt;하는 것이 얼마나 중요한지 확실히 느꼈습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;추가로 &lt;b&gt;배포 주기를 짧게&lt;/b&gt; 잡는 것도 좋은 방법이라는 생각이 들었어요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;배포 주기를 짧게 잡으면 &lt;b&gt;문제를 빠르게 발견하고 수정&lt;/b&gt;하는 것이 가능하니, 문제가 커지기 전에 발견하고 대응할 수 있어 &lt;b&gt;안정적이고 효율적인 개발&lt;/b&gt;이 가능할 것 같아요!&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>Build</category>
      <category>Next.js</category>
      <category>react hook form</category>
      <category>TS</category>
      <category>type error</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/177</guid>
      <comments>https://dev-hpk.tistory.com/177#entry177comment</comments>
      <pubDate>Thu, 23 Jan 2025 21:44:09 +0900</pubDate>
    </item>
    <item>
      <title>[Coworkers] 팀 생성 페이지 작업 (API 연동)</title>
      <link>https://dev-hpk.tistory.com/176</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;공통 컴포넌트 작업을 마무리하고, 오늘부터는 페이지 작업 시작입니다. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀 생성 페이지를 먼저 작업해 보겠습니다!&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;작업 목표&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;450&quot; data-origin-height=&quot;202&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cqn5S0/btsLVLPANLE/reLgrLVc8FAdtbBgSWbkCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cqn5S0/btsLVLPANLE/reLgrLVc8FAdtbBgSWbkCk/img.png&quot; data-alt=&quot;팀 생성 페이지 작업 목표&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cqn5S0/btsLVLPANLE/reLgrLVc8FAdtbBgSWbkCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcqn5S0%2FbtsLVLPANLE%2FreLgrLVc8FAdtbBgSWbkCk%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;450&quot; height=&quot;202&quot; data-origin-width=&quot;450&quot; data-origin-height=&quot;202&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;팀 생성 페이지 작업 목표&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;작업에 앞서 피그마 디자인을 먼저 확인해 볼게요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;430&quot; data-origin-height=&quot;316&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/leSQE/btsLVPEqcNG/BNaxtBWHMqs8oSceELVzkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/leSQE/btsLVPEqcNG/BNaxtBWHMqs8oSceELVzkk/img.png&quot; data-alt=&quot;팀 생성 페이지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/leSQE/btsLVPEqcNG/BNaxtBWHMqs8oSceELVzkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FleSQE%2FbtsLVPEqcNG%2FBNaxtBWHMqs8oSceELVzkk%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;430&quot; height=&quot;316&quot; data-origin-width=&quot;430&quot; data-origin-height=&quot;316&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;팀 생성 페이지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀 이름 Input이 팀원분이 공통으로 작업해 주신 Input 컴포넌트와 유사하게 생겼으니 공통 Input을 사용하면 되겠네요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;578&quot; data-origin-height=&quot;553&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/twGF3/btsLUt3IBiF/2oUeoviHKs9vo1YUODggJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/twGF3/btsLUt3IBiF/2oUeoviHKs9vo1YUODggJK/img.png&quot; data-alt=&quot;공통 Input 예시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/twGF3/btsLUt3IBiF/2oUeoviHKs9vo1YUODggJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtwGF3%2FbtsLUt3IBiF%2F2oUeoviHKs9vo1YUODggJK%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;578&quot; height=&quot;553&quot; data-origin-width=&quot;578&quot; data-origin-height=&quot;553&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;공통 Input 예시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;공통 Input 코드&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1737463204705&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;import { MouseEvent, ReactNode, useState } from 'react';
import { useFormContext, RegisterOptions } from 'react-hook-form';
import IconVisibility from '@/app/components/icons/IconVisibility';
import IconInVisibility from '@/app/components/icons/IconInVisibility';

type AuthInputProps = {
  name: string; // 필드 이름 (폼 데이터의 키)
  title: string; // 라벨 제목
  type: string; // input 타입 (예: text, password 등)
  placeholder: string; // 플레이스홀더
  autoComplete: string; // 자동 완성 옵션
  validationRules?: RegisterOptions; // react-hook-form 유효성 검증 규칙
  backgroundColor?: string; // 입력 필드 배경색
  customButton?: ReactNode; // 추가 버튼 컴포넌트
};

function AuthInput({
  name,
  title,
  type = 'text',
  placeholder,
  autoComplete,
  validationRules,
  backgroundColor = 'bg-background-secondary',
  customButton,
}: AuthInputProps) {
  const [isVisibleToggle, setIsVisibleToggle] = useState(false);
  const {
    register,
    formState: { errors },
  } = useFormContext();

  const isPassword = type === 'password';
  const inputType = isPassword ? (isVisibleToggle ? 'text' : 'password') : type;

  const handleToggleClick = (e: MouseEvent&amp;lt;HTMLButtonElement&amp;gt;) =&amp;gt; {
    e.preventDefault();
    setIsVisibleToggle(!isVisibleToggle);
  };

  const inputBorderClass = errors[name]
    ? 'border-status-danger' // 에러시 border 색상
    : 'border-[#F8FAFC1A]'; // 기본 border 색상

  return (
    &amp;lt;div className=&quot;flex flex-col gap-3&quot;&amp;gt;
      &amp;lt;label className=&quot;text-text-primary text-base font-medium&quot; htmlFor={name}&amp;gt;
        {title}
      &amp;lt;/label&amp;gt;

      &amp;lt;div className=&quot;relative&quot;&amp;gt;
        &amp;lt;input
          className={`focus:border-interaction-focus placeholder:text-text-danger text-text-primary h-full w-full rounded-xl border px-4 py-[0.85rem] placeholder:text-lg focus:outline-none ${backgroundColor} ${inputBorderClass}`}
          {...register(name, validationRules)}
          type={inputType}
          id={name}
          placeholder={placeholder}
          autoComplete={autoComplete}
        /&amp;gt;
        {isPassword &amp;amp;&amp;amp; customButton &amp;amp;&amp;amp; (
          &amp;lt;div className=&quot;absolute right-4 top-3 z-20&quot;&amp;gt;{customButton}&amp;lt;/div&amp;gt;
        )}
        {isPassword &amp;amp;&amp;amp; !customButton &amp;amp;&amp;amp; (
          &amp;lt;button
            className=&quot;absolute right-4 top-3 z-10&quot;
            type=&quot;button&quot;
            onClick={handleToggleClick}
          &amp;gt;
            {isVisibleToggle ? &amp;lt;IconVisibility /&amp;gt; : &amp;lt;IconInVisibility /&amp;gt;}
          &amp;lt;/button&amp;gt;
        )}
      &amp;lt;/div&amp;gt;

      {errors[name] &amp;amp;&amp;amp; (
        &amp;lt;span className=&quot;text-status-danger text-sm&quot;&amp;gt;
          {errors[name]?.message as string}
        &amp;lt;/span&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  );
}

export default AuthInput;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;컴포넌트 이름을 보니 로그인, 회원가입에 관련된 Input만 고려하신 것 같네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;공통 컴포넌트를 사용하지 말아야 하나 고민했지만, React Hook Form을 사용해 작업해 주셔서 공통 Input을 사용하는 게 효율적일 것 같아 사용해야겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저번 코드 리뷰 때 배운 점을 바탕으로 팀 회의 때 &lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;명확하고 부드러운&lt;/span&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;&amp;nbsp;소통으로 컴포넌트와 변수의 이름을 바꾸는 내용을 건의해 보겠습니다✨&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;axios를 추가하고 instance를 세팅해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Axios Instance 생성&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1737463830414&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from 'axios';

const instance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_SERVER_URL,
  headers: {
    'Content-Type': 'application/json',
    // 로그인 기능이 구현되면 TOKEN 추가하겠습니다.
    // Authorization: `Bearer ${}`
  },
});

export default instance;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 axios도 설정했으니, api 작업을 시작해 볼게요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀 생성 API는 body에 image와 name을 필요로 하는데요, image는 필수는 아니지만&amp;nbsp; &lt;b&gt;http://&lt;/b&gt; 또는 &lt;b&gt;https://&lt;/b&gt;로 시작하는 문자열을 받네요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;522&quot; data-origin-height=&quot;452&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWFzw5/btsLUXi2f2H/qPiv8FT3oGuI3HDDoANJg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWFzw5/btsLUXi2f2H/qPiv8FT3oGuI3HDDoANJg0/img.png&quot; data-alt=&quot;팀 생성 API&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWFzw5/btsLUXi2f2H/qPiv8FT3oGuI3HDDoANJg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWFzw5%2FbtsLUXi2f2H%2FqPiv8FT3oGuI3HDDoANJg0%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;522&quot; height=&quot;452&quot; data-origin-width=&quot;522&quot; data-origin-height=&quot;452&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;팀 생성 API&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;435&quot; data-origin-height=&quot;183&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ci2pjc/btsLUGV7Po2/bqvgDuj1KRvnkfsDI1WoXk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ci2pjc/btsLUGV7Po2/bqvgDuj1KRvnkfsDI1WoXk/img.png&quot; data-alt=&quot;팀 생성 스키마&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ci2pjc/btsLUGV7Po2/bqvgDuj1KRvnkfsDI1WoXk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fci2pjc%2FbtsLUGV7Po2%2FbqvgDuj1KRvnkfsDI1WoXk%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;435&quot; height=&quot;183&quot; data-origin-width=&quot;435&quot; data-origin-height=&quot;183&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;팀 생성 스키마&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이미지를 첨부했을 때 정해진 패턴으로 변경해 줄 수 있게 Image 업로드 API를 먼저 작업해 볼게요.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;팀 생성 페이지 구조 및 Image 업로드 API&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1737466650190&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;&amp;lt;FormProvider {...method}&amp;gt;
  &amp;lt;form className=&quot;flex flex-col gap-6&quot; onSubmit={handleSubmit(onSubmit)}&amp;gt;
    &amp;lt;div&amp;gt;
      &amp;lt;span className=&quot;mb-3 inline-block&quot;&amp;gt;팀 프로필&amp;lt;/span&amp;gt;
      &amp;lt;label
        htmlFor=&quot;profile&quot;
        className=&quot;relative block h-16 w-16 cursor-pointer&quot;
      &amp;gt;
        &amp;lt;input
          id=&quot;profile&quot;
          {/* 화면에 표시되지 않지만 파일 선택을 위해 sr-only 추가 */}
          className=&quot;sr-only&quot;
          type=&quot;file&quot;
          accept=&quot;image/*&quot;
          {...register('profile')}
          onChange={handleFileChange}
        /&amp;gt;
        {profileImage ? (
          &amp;lt;Image src={profileImage} fill alt=&quot;profile image&quot; /&amp;gt;
        ) : (
          &amp;lt;IconProfile /&amp;gt;
        )}
      &amp;lt;/label&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;AuthInput
      name=&quot;name&quot;
      title=&quot;팀 이름&quot;
      type=&quot;text&quot;
      placeholder=&quot;팀 이름을 입력해주세요.&quot;
      autoComplete=&quot;off&quot;
    /&amp;gt;
  &amp;lt;/form&amp;gt;
&amp;lt;/FormProvider&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;공통 Input을 &lt;b&gt;useFormContext&lt;/b&gt;로 작업해 주셔서 form 상태를 관리하기 위해&amp;nbsp;&lt;b&gt;FormProvider&lt;/b&gt;를 사용했어요❗&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1737465558836&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from '@/app/lib/instance';

const uploadImage = (imageData: FormData) =&amp;gt; {
  const res = axios.post('images/upload', imageData, {
    headers: {
      'Content-Type': 'multipart/form-data',
      Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`,
    },
  });

  return res;
};

export { uploadImage };&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Content-Type : React Hook Form의 Form 데이터를 사용하기 위해 multipart/form-data로 설정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Authorization : 로그인 기능이 아직 구현 전이라 .env에 인증 토큰을 임의로 저장하고 사용&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1737466942408&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 파일 처리하는 함수
  const handleFileChange = (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const file = e.target.files?.[0];
    if (file) {
      const url = URL.createObjectURL(file); // 미리보기 URL 생성
      setProfileImage(url); // 미리보기 이미지 업데이트

      setValue('profile', file); // form data 설정
    }
  };

  const onSubmit = async (data: PostGroupData) =&amp;gt; {
    try {
      const formData = new FormData();
     
     formData.append('image', data.profile);

      const img = await uploadImage(formData);
    } catch (error) {
      alert(`팀 생성에 실패했습니다`);
    }
    return null;
  };​&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;API를 완성했으니 결과를 확인해 봐야겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;171&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dptunU/btsLVFIFhpY/iOvAT4FI5RkIcSFok56C5K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dptunU/btsLVFIFhpY/iOvAT4FI5RkIcSFok56C5K/img.png&quot; data-alt=&quot;이미지 업로드 요청 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dptunU/btsLVFIFhpY/iOvAT4FI5RkIcSFok56C5K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdptunU%2FbtsLVFIFhpY%2FiOvAT4FI5RkIcSFok56C5K%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;894&quot; height=&quot;171&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;171&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이미지 업로드 요청 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;무슨 에러인지 응답과 페이로드로 서버에 전달한 데이터를 확인해 볼게요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;355&quot; data-origin-height=&quot;116&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bV9GWM/btsLVE32FZm/LWjLF6DYlHTYrmK4tCfYYK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bV9GWM/btsLVE32FZm/LWjLF6DYlHTYrmK4tCfYYK/img.png&quot; data-alt=&quot;에러 응답&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bV9GWM/btsLVE32FZm/LWjLF6DYlHTYrmK4tCfYYK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbV9GWM%2FbtsLVE32FZm%2FLWjLF6DYlHTYrmK4tCfYYK%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;355&quot; height=&quot;116&quot; data-origin-width=&quot;355&quot; data-origin-height=&quot;116&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;에러 응답&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;339&quot; data-origin-height=&quot;94&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vwDC9/btsLVfXXTVZ/rmk0c5aR2v5z2Fe5JHzY7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vwDC9/btsLVfXXTVZ/rmk0c5aR2v5z2Fe5JHzY7K/img.png&quot; data-alt=&quot;페이로드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vwDC9/btsLVfXXTVZ/rmk0c5aR2v5z2Fe5JHzY7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvwDC9%2FbtsLVfXXTVZ%2Frmk0c5aR2v5z2Fe5JHzY7K%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;339&quot; height=&quot;94&quot; data-origin-width=&quot;339&quot; data-origin-height=&quot;94&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;페이로드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;image에 File이 아니라 FileList가 보내지고 있네요  &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;확인해 보니 아래 두 경우에 data.profile의 값이 FileList입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣ 이미지를 선택하지 않은 경우&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ 이미지를 선택한 여러 번 선택한 경우(이미 선택한 상태에서 변경하는 경우)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣번 문제는 간단한 조건문을 통해 해결할 수 있을 것 같아요.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1737468268880&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if (data.profile &amp;amp;&amp;amp; data.profile instanceof File) {
  const formData = new FormData();

  formData.append('image', data.profile);

  const img = await uploadImage(formData);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;data.profile : formData의 profile 데이터가 있는지 확인&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;data.profile instanceof File : formData의 profile 데이터가 File 객체인지 확인&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Coworkers-Chrome-2025-01-21-23-07-41.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFjQTG/btsLVdZ9JTK/3xfxlaw83nencpQO3IlLJ0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFjQTG/btsLVdZ9JTK/3xfxlaw83nencpQO3IlLJ0/img.gif&quot; data-alt=&quot;이미지 없는 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFjQTG/btsLVdZ9JTK/3xfxlaw83nencpQO3IlLJ0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bFjQTG/btsLVdZ9JTK/3xfxlaw83nencpQO3IlLJ0/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;Coworkers-Chrome-2025-01-21-23-07-41.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이미지 없는 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이미지를 선택하지 않은 경우 API 요청을 안 하도록 잘 적용되었네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣번 문제를 해결하려고 이미지를 추가하던 중 디버깅을 해보니 해결된 게 아니었네요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;190&quot; data-origin-height=&quot;58&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhpGf7/btsLVNNWskF/32yZnIRKR4je6TdWWaNYp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhpGf7/btsLVNNWskF/32yZnIRKR4je6TdWWaNYp0/img.png&quot; data-alt=&quot;formData - 프로필 이미지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhpGf7/btsLVNNWskF/32yZnIRKR4je6TdWWaNYp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhpGf7%2FbtsLVNNWskF%2F32yZnIRKR4je6TdWWaNYp0%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;190&quot; height=&quot;58&quot; data-origin-width=&quot;190&quot; data-origin-height=&quot;58&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;formData - 프로필 이미지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;profile가 FileList여서 이미지 업로드 API가 동작을 안 하고 있었어요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;코드를 살펴보다가 의문이 드는 부분이 생겼습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1737522312057&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// handleFileChange
setValue('profile', file);

// 파일 업로드 input
&amp;lt;input
  id=&quot;profile&quot;
  className=&quot;sr-only&quot;
  type=&quot;file&quot;
  accept=&quot;image/*&quot;
  {...register('profile')}
  onChange={handleFileChange}
/&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 이미 파일 업로드 input에 register로 Form State를 연결했는데, 파일 change 핸들러에 setValue로 register filed의 profile 값을 다시 변경해주고 있네요... &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아마 register를 등록하기 전에 setValue로 관리하던 로직을 삭제하지 않은 것 같아요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;삭제하고 다시 테스트해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;757&quot; data-origin-height=&quot;151&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baTPMK/btsLWsvPFHo/5o0Eb3nk0sB8ecpmWlDYk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baTPMK/btsLWsvPFHo/5o0Eb3nk0sB8ecpmWlDYk0/img.png&quot; data-alt=&quot;이미지 업로드 성공&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baTPMK/btsLWsvPFHo/5o0Eb3nk0sB8ecpmWlDYk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaTPMK%2FbtsLWsvPFHo%2F5o0Eb3nk0sB8ecpmWlDYk0%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;757&quot; height=&quot;151&quot; data-origin-width=&quot;757&quot; data-origin-height=&quot;151&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이미지 업로드 성공&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로직을 꼼꼼하게 확인하지 못해 발생한 문제로 너무 많은 시간을 투자한 것 같아 아쉬운 마음이 드네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이번 경험을 통해 React Hook Form의 공식 문서를 깊이 이해하는 계기가 되었고, 앞으로는 더 꼼꼼하게 코드를 점검할 자신감이 생긴 것 같습니다 &lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>Next.js</category>
      <category>react hook form</category>
      <category>TS</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/176</guid>
      <comments>https://dev-hpk.tistory.com/176#entry176comment</comments>
      <pubDate>Wed, 22 Jan 2025 14:20:00 +0900</pubDate>
    </item>
    <item>
      <title>[Coworkers] Trouble Shooting - 코드 리뷰</title>
      <link>https://dev-hpk.tistory.com/175</link>
      <description>&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;T&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;rouble Shooting&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  상황&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀원의 PR(pull request)에서 발견된 버그와 개선 사항 및 수정 방법에 대해 리뷰를 작성했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;반응형 화면과 이벤트 관련 테스트도 진행하면서 코멘트를 열심히 남겼는데요.. &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;748&quot; data-origin-height=&quot;631&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgzRlH/btsLT2ynjqJ/50YgRPRMcUy2K2mZsKjHB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgzRlH/btsLT2ynjqJ/50YgRPRMcUy2K2mZsKjHB1/img.png&quot; data-alt=&quot;코드 리뷰 코멘트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgzRlH/btsLT2ynjqJ/50YgRPRMcUy2K2mZsKjHB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgzRlH%2FbtsLT2ynjqJ%2F50YgRPRMcUy2K2mZsKjHB1%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;748&quot; height=&quot;631&quot; data-origin-width=&quot;748&quot; data-origin-height=&quot;631&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;코드 리뷰 코멘트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;754&quot; data-origin-height=&quot;576&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcM6V4/btsLTGoGHDn/6HoPFI4uUheNN7xwNkTvPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcM6V4/btsLTGoGHDn/6HoPFI4uUheNN7xwNkTvPk/img.png&quot; data-alt=&quot;코드 리뷰 코멘트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcM6V4/btsLTGoGHDn/6HoPFI4uUheNN7xwNkTvPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcM6V4%2FbtsLTGoGHDn%2F6HoPFI4uUheNN7xwNkTvPk%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;754&quot; height=&quot;576&quot; data-origin-width=&quot;754&quot; data-origin-height=&quot;576&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;코드 리뷰 코멘트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;리뷰에 대한 피드백 반영이나 확인 없이 PR이 머지되었습니다... &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;363&quot; data-origin-height=&quot;46&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJTIFL/btsLUt3jMfO/OdlEsbQ9UwrjWuc8pGU8RK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJTIFL/btsLUt3jMfO/OdlEsbQ9UwrjWuc8pGU8RK/img.png&quot; data-alt=&quot;pr 머지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJTIFL/btsLUt3jMfO/OdlEsbQ9UwrjWuc8pGU8RK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJTIFL%2FbtsLUt3jMfO%2FOdlEsbQ9UwrjWuc8pGU8RK%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;363&quot; height=&quot;46&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;363&quot; data-origin-height=&quot;46&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;pr 머지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  무엇이 문제였을까요?&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;문제 상황이 왜 발생했는지 고민해 봤을 때 아래 3가지 정도가 있는 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1️⃣ 리뷰 프로세스의 불명확함&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2️⃣ 의사소통 부족&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;3️⃣ 팀의 리뷰 문화 미정립&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;다른 리뷰 코멘트는 확인하시고 응답하신 것을 보면 2️⃣번은 아닌 것 같습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;373&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NC5ig/btsLVvlvBjG/N9O926eKKyCd6GS93owzO1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NC5ig/btsLVvlvBjG/N9O926eKKyCd6GS93owzO1/img.png&quot; data-alt=&quot;피드백에 대한 응답&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NC5ig/btsLVvlvBjG/N9O926eKKyCd6GS93owzO1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNC5ig%2FbtsLVvlvBjG%2FN9O926eKKyCd6GS93owzO1%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;720&quot; height=&quot;373&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;373&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;피드백에 대한 응답&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;706&quot; data-origin-height=&quot;83&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tHs3J/btsLUeL6oQT/pKQUVCGL7FxwXCIPUuq8Q0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tHs3J/btsLUeL6oQT/pKQUVCGL7FxwXCIPUuq8Q0/img.png&quot; data-alt=&quot;피드백에 대한 응답&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tHs3J/btsLUeL6oQT/pKQUVCGL7FxwXCIPUuq8Q0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtHs3J%2FbtsLUeL6oQT%2FpKQUVCGL7FxwXCIPUuq8Q0%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;706&quot; height=&quot;83&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;706&quot; data-origin-height=&quot;83&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;피드백에 대한 응답&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  배운 점&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1️⃣ 의사소통과 피드백 방법의 개선 필요성&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;다시 확인해보니 제가 남긴 리뷰가 모호하거나 딱딱한 어조로 작성되어 요청보다는 단순한 의견으로 받아들여졌을 수 있을 것 같습니다. 앞으로는 코드 리뷰를 남기기 전에 아래 내용을 상기시켜야겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;피드백은 단순히 문제를 지적하는 것이 아니라, 수정 방향과 기대 결과를 명확히 제시해야 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;긍정적인 어조와 이모지 등을 활용해 팀원의 기여를 격려하는 것도 중요하다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ &lt;b&gt;리뷰 프로세스 강화 필요성&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; PR 승인 기준, 피드백 확인을 필수화 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 리뷰를 단순히 참고용이 아닌 필수 반영 사항으로 만들기 위해 팀 차원의 리뷰 정책을 논의한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;
&lt;div data-message-model-slug=&quot;gpt-4o&quot; data-message-id=&quot;27d2b6e4-f7ef-47a0-a79d-c2825be91296&quot; data-message-author-role=&quot;assistant&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이번 경험을 통해 리뷰의 어조와 전달 방식이 팀원 간 협업에 미치는 영향을 깊이 깨닫게 되었어요.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;리뷰는 단순히 문제를 지적하기보다 서로 배우고 함께 성장할 수 있는 과정임을 다시 한번 깨달았어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;리뷰 프로세스를 강화는 것도 팀 회의에서 소통을 통해 해결해야 할 문제네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;명확하고 유연한 소통 방식으로 의견을 제시한다면 팀원분들도 긍정적으로 받아주실 거라고 생각합니다 &amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;앞으로는 더 명확하고 부드러운 소통으로 팀원들과 협업하며 프로젝트 잘 마무리하고 좋은 결과물 만들어 갔으면 좋겠네요!&lt;/span&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Trouble Shooting</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/175</guid>
      <comments>https://dev-hpk.tistory.com/175#entry175comment</comments>
      <pubDate>Tue, 21 Jan 2025 15:03:42 +0900</pubDate>
    </item>
    <item>
      <title>[Coworkers] 공통 컴포넌트 Modal</title>
      <link>https://dev-hpk.tistory.com/174</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;고급 프로젝트 Coworkers의 시작입니다. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저는 Modal 공통 컴포넌트를 맡게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;작업을 시작하기 전에 지난 프로젝트에서 팀원분께서 만들어주신 Modal 컴포넌트를 사용할 때 좋았던 점들과 불편했던 점들을 생각해 봤어요 &lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;좋은 점&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Modal의 Container만 만들어두고 Content는 children으로 받아 사용할 수 있어 편했음&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Modal의 dim 영역을 클릭했을 때 닫히는 기능이 있어 편했음&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;불편한 점&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Modal 컴포넌트를 사용하는 페이지에서 모달의 open 관련 State를 선언하고 관리해야 해서 불편했음&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;DOM 계층의 Depth가 깊어지면 z-index 관련해서 신경 쓸 부분이 많아 불편했음&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Modal의 dim과 컨텐츠가 각각 Dim, Modal 컴포넌트로 분리되어 있어 불편했음&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 내용을 고려하면서 작업 목표를 아래처럼 작성했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;339&quot; data-origin-height=&quot;132&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/S8J10/btsLSMBRRa8/QIHYkl2CvKW0u7HKq3UAt1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/S8J10/btsLSMBRRa8/QIHYkl2CvKW0u7HKq3UAt1/img.png&quot; data-alt=&quot;modal 작업 목표&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/S8J10/btsLSMBRRa8/QIHYkl2CvKW0u7HKq3UAt1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FS8J10%2FbtsLSMBRRa8%2FQIHYkl2CvKW0u7HKq3UAt1%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;339&quot; height=&quot;132&quot; data-origin-width=&quot;339&quot; data-origin-height=&quot;132&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;modal 작업 목표&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;useModal 커스텀 훅&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1737352826600&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useState } from 'react';

interface ModalHookProps {
  initialState?: boolean;
}

function useModal({ initialState = false }: ModalHookProps = {}) {
  const [isOpen, setIsOpen] = useState(initialState);

  const openModal = () =&amp;gt; setIsOpen(true);
  const closeModal = () =&amp;gt; setIsOpen(false);

  return { isOpen, openModal, closeModal };
}

export default useModal;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;initialState : 모달의 초기 상태가 꼭 닫혀있는 것은 아닌 것 같아서 추가했습니다. 웹이나 앱을 이용하다 보면 광고나 공지 같은 모달이 열려있는 상태로 진입하는 경우를 많이 볼 수 있잖아요 &amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Modal 컴포넌트&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1737353351261&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import IconClose from '@/app/components/icons/IconClose';

interface ModalProps {
  hasCloseBtn?: boolean;
  portalRoot?: HTMLElement;
  closeModal: () =&amp;gt; void;
}

function Modal({
  hasCloseBtn = false,
  portalRoot,
  closeModal,
  children,
}: PropsWithChildren&amp;lt;ModalProps&amp;gt;) {
  const [isMounted, setIsMounted] = useState(false);
  const modalRef = useRef&amp;lt;HTMLDivElement | null&amp;gt;(null);

  useEffect(() =&amp;gt; {
    setIsMounted(true);

    // 모달 외부 클릭 시 닫히도록 이벤트 처리
    const handleClickOutside = (event: MouseEvent) =&amp;gt; {
      if (modalRef.current &amp;amp;&amp;amp; modalRef.current.contains(event.target as Node)) {
        closeModal();
      }
    };

    // 마운트 시 이벤트 리스너 추가
    document.addEventListener('mousedown', handleClickOutside);

    // clean-up : 언마운트 시 이벤트 리스너 제거
    return () =&amp;gt; {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [closeModal]);

  if (!isMounted) return null;

  return createPortal(
    &amp;lt;div className=&quot;fixed inset-0 z-50 flex flex-col items-center justify-center tablet:justify-end&quot;&amp;gt;
      {/* 모달 dim 영역 - 클릭 시 모달 닫힘 */}
      &amp;lt;div ref={modalRef} className=&quot;absolute inset-0 bg-black opacity-50&quot; /&amp;gt;
      &amp;lt;div className=&quot;relative flex w-96 flex-col items-center rounded-xl bg-background-secondary pb-8 pt-12 tablet:w-full tablet:rounded-b-none&quot;&amp;gt;
        {hasCloseBtn &amp;amp;&amp;amp; (
          &amp;lt;button
            type=&quot;button&quot;
            className=&quot;absolute right-4 top-4 h-6 w-6&quot;
            title=&quot;모달 닫기&quot;
            onClick={closeModal}
          &amp;gt;
            &amp;lt;IconClose /&amp;gt;
          &amp;lt;/button&amp;gt;
        )}
        {children}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;,
    portalRoot || document.body,
  );
}

export default Modal;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Props&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;hasCloseBtn : 닫기 버튼(X) 유무 설정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;portalRoot : Modal을 렌더링 할 상위 DOM 노드, body 하위가 아닌 다른 곳에 렌더링 할 경우를 고려해 prop으로 추가했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;closeModal : Modal 닫기 함수&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;children : Modal 내부에 렌더링 될 컨텐츠&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;CreatePortal()&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;portalRoot || document.body : portalRoot prop을 지정하지 않으면 기본적으로 body 하위에 모달을 렌더링 하도록 설정했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;ModalRef&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존에는 dim 영역 &amp;lt;div&amp;gt;의 onClick 이벤트 핸들러에 closeModal()을 사용했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;dropdown 작업을 하신 팀원분이 useRef를 이용해 닫기 이벤트를 처리하셔서, 공통으로 뺄 수 있을 것 같아 논의 후 수정했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;if&amp;nbsp;(!isMounted)&amp;nbsp;return&amp;nbsp;null;&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;SSR 환경에서 internal server error(500)이 발생하는 문제를 해결하기 위해, &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;modal이 마운트 되기 전에 null을 리턴하도록 했습니다.&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  작업 완료 &amp;amp; 테스트&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Coworkers-Chrome-2025-01-18-22-59-01.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dKYifb/btsLS2EwcDt/KLZNnK6M1dAg4ehMSux2P0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dKYifb/btsLS2EwcDt/KLZNnK6M1dAg4ehMSux2P0/img.gif&quot; data-alt=&quot;모달 동작 테스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dKYifb/btsLS2EwcDt/KLZNnK6M1dAg4ehMSux2P0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/dKYifb/btsLS2EwcDt/KLZNnK6M1dAg4ehMSux2P0/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;Coworkers-Chrome-2025-01-18-22-59-01.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;모달 동작 테스트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Modal이 body 하위에 잘 렌더링 되고 dim 영역 클릭 시 닫히는 기능도 잘 동작합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이렇게 마무리하나 싶었는데, 스크럼 회의 때 애니메이션을 추가했으면 좋겠다는 의견이 나왔어요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;의견을 듣고 Modal을 동작시켜 보니 좀 심심해 보이긴 하네요 &lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;수정 전&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1737354932097&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export function Home() {
    const {isOpen, openModal, closeModal} = useModal();
    
    const handleClose = () =&amp;gt; {
    	/* TODO */
    	closeModal();
    }
    
    return (
      &amp;lt;div&amp;gt;
        {isOpen &amp;amp;&amp;amp; (&amp;lt;Modal closeModal={handleClose}&amp;gt;모달 컨텐츠&amp;lt;/Modal&amp;gt;)}
      &amp;lt;/div&amp;gt;
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존 Modal은 아래 코드처럼 조건부로 렌더링을 관리했어요. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그런데 이 방법으로는 애니메이션을 추가해도 적용이 안되네요.. 다른 방법을 찾아야 할 것 같습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;여러 프레임워크의 Modal 컴포넌트를 찾아보다가 Bootstrap의 Modal이 가장 적합한 것 같아 참고하기로 했습니다!&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;수정 후&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1737355276782&quot; class=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;export function Home() {
    const {isOpen, openModal, closeModal} = useModal();
        
    const handleClose = () =&amp;gt; {
    	/* TODO */
    	closeModal();
    }
    
    return (
      &amp;lt;div&amp;gt;
        &amp;lt;Modal isOpen={isOpen} closeModal={handleClose}&amp;gt;모달 컨텐츠&amp;lt;/Modal&amp;gt;
      &amp;lt;/div&amp;gt;
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1737355461336&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import IconClose from '@/app/components/icons/IconClose';

interface ModalProps {
  hasCloseBtn?: boolean;
  portalRoot?: HTMLElement;
  isOpen: boolean;
  closeModal: () =&amp;gt; void;
}

function Modal({
  hasCloseBtn = false,
  portalRoot, // Modal 렌더링할 상위 DOM 노드
  closeModal,
  isOpen,
  children,
}: PropsWithChildren&amp;lt;ModalProps&amp;gt;) {
  const [renderModal, setRenderModal] = useState(isOpen);
  const modalRef = useRef&amp;lt;HTMLDivElement | null&amp;gt;(null);

  useEffect(() =&amp;gt; {
    if (isOpen) {
      setRenderModal(true); // isOpen이 true면 렌더링 활성화
    }
  }, [isOpen]);

  const handleAnimationEnd = () =&amp;gt; {
    if (!isOpen) {
      setRenderModal(false); // 애니메이션 종료 후 렌더링 중단
    }
  };

  useEffect(() =&amp;gt; {
    // 모달 외부 클릭 시 닫히도록 이벤트 처리
    const handleClickOutside = (event: MouseEvent) =&amp;gt; {
      if (modalRef.current &amp;amp;&amp;amp; modalRef.current.contains(event.target as Node)) {
        closeModal();
      }
    };

    // 마운트 시 이벤트 리스너 추가
    document.addEventListener('mousedown', handleClickOutside);

    // clean-up : 언마운트 시 이벤트 리스너 제거
    return () =&amp;gt; {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [closeModal]);

  if (!renderModal) return null;

  return createPortal(
    &amp;lt;div
      className={`tablet:justify-center fixed inset-0 z-50 flex flex-col items-center justify-end transition-opacity ${
        isOpen ? 'opacity-100' : 'pointer-events-none hidden opacity-0'
      }`}
      style={{ display: renderModal ? 'flex' : 'none' }}
    &amp;gt;
      &amp;lt;div ref={modalRef} className=&quot;absolute inset-0 bg-black opacity-50&quot; /&amp;gt;
      &amp;lt;div
        className={`tablet:w-96 tablet:rounded-b-xl relative flex max-h-[80%] w-full transform flex-col items-center overflow-y-hidden rounded-t-xl bg-background-secondary pb-8 pt-12 transition-transform ${isOpen ? 'translate-y-0' : 'translate-y-4'}`}
        onTransitionEnd={handleAnimationEnd}
      &amp;gt;
        {hasCloseBtn &amp;amp;&amp;amp; (
          &amp;lt;button
            type=&quot;button&quot;
            className=&quot;absolute right-4 top-4 h-6 w-6&quot;
            title=&quot;모달 닫기&quot;
            onClick={closeModal}
          &amp;gt;
            &amp;lt;IconClose /&amp;gt;
          &amp;lt;/button&amp;gt;
        )}
        {children}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;,
    portalRoot || document.body,
  );
}

export default Modal;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Props&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;isOpen : Modal 렌더링과 애니메이션 관리를 위해 prop으로 추가&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;renderModal&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Modal의 렌더링 여부를 결정하기 위한 State로 isOpen prop의 값에 따라 토글 됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;onTransitionEnd={handleAnimationEnd}&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;애니메이션이 종료된 후 렌더링을 중단하기 위해 추가&lt;/span&gt;&lt;br /&gt;
&lt;pre id=&quot;code_1737356012395&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;const handleAnimationEnd = () =&amp;gt; {
    if (!isOpen) {
      setRenderModal(false); // 애니메이션 종료 후 렌더링 중단
    }
  };&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  애니메이션 동작 화면&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Coworkers-Chrome-2025-01-20-13-18-51.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btwtSC/btsLTpl9Zc9/Fsk2VgVzqY6ho2yQmlqMP0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btwtSC/btsLTpl9Zc9/Fsk2VgVzqY6ho2yQmlqMP0/img.gif&quot; data-alt=&quot;모달 애니메이션 적용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btwtSC/btsLTpl9Zc9/Fsk2VgVzqY6ho2yQmlqMP0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/btwtSC/btsLTpl9Zc9/Fsk2VgVzqY6ho2yQmlqMP0/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;Coworkers-Chrome-2025-01-20-13-18-51.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;모달 애니메이션 적용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;tailwind CSS를 처음 접하다 보니 작업 속도가 많이 느리고, 애니메이션이나 UX 적인 부분을 작업 초반에 고려하지 못해 많은 수정을 했네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그래도 여러 번 수정 끝에 좋은 결과물을 만들어 내고, 팀원들에게 긍정적인 코드 리뷰를 받아서 기분이 좋네요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;772&quot; data-origin-height=&quot;352&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/caHD71/btsLSQYPqJr/DthIqgF4paZHmZW0k06UP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/caHD71/btsLSQYPqJr/DthIqgF4paZHmZW0k06UP0/img.png&quot; data-alt=&quot;코드 리뷰&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/caHD71/btsLSQYPqJr/DthIqgF4paZHmZW0k06UP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcaHD71%2FbtsLSQYPqJr%2FDthIqgF4paZHmZW0k06UP0%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;772&quot; height=&quot;352&quot; data-origin-width=&quot;772&quot; data-origin-height=&quot;352&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;코드 리뷰&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;766&quot; data-origin-height=&quot;442&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bO67xl/btsLUvltS65/onrabvn33sw7NFheWaRrWk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bO67xl/btsLUvltS65/onrabvn33sw7NFheWaRrWk/img.png&quot; data-alt=&quot;코드 리뷰&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bO67xl/btsLUvltS65/onrabvn33sw7NFheWaRrWk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbO67xl%2FbtsLUvltS65%2Fonrabvn33sw7NFheWaRrWk%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;766&quot; height=&quot;442&quot; data-origin-width=&quot;766&quot; data-origin-height=&quot;442&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;코드 리뷰&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 저도 많이 부족한데 하나 배워 가신다니 머쓱하지만 더 열심히 해야겠다는 의지가 불타오르네요  &lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>custom hook</category>
      <category>Modal</category>
      <category>Next.js</category>
      <category>React Portal</category>
      <category>tailwind</category>
      <category>TS</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/174</guid>
      <comments>https://dev-hpk.tistory.com/174#entry174comment</comments>
      <pubDate>Mon, 20 Jan 2025 16:10:42 +0900</pubDate>
    </item>
    <item>
      <title>[맛길] 데이터 채우기 - Youtube Data API</title>
      <link>https://dev-hpk.tistory.com/170</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기획 단계에서 Serverless Function을 이용해 유튜브 영상과 상세 내용을 관리하기로 했습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;흠.. 영상 정보를 어떻게 저장할까요? &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;직접 JSON 파일에 작성하기에는 양이 너무 많아 비효율적일 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;구글링을 통해 크롤링과&amp;nbsp; Youtube Data API를 알게 되었습니다. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;크롤링이 더 간단한 것 같지만, 프론트엔드 개발자라면 API를 사용하는 게 당연하겠죠!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;사실 크롤링이 유튜브 정책 위반이라는 내용도 있고, 크롤링 관련 재판 사례들이 있는 것 같아서 선택한 것도 없진 않습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1538&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KuTu8/btsLTo0NsKy/O9axHst8PDkYGW4kBkd45k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KuTu8/btsLTo0NsKy/O9axHst8PDkYGW4kBkd45k/img.png&quot; data-alt=&quot;Youtube Data API&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KuTu8/btsLTo0NsKy/O9axHst8PDkYGW4kBkd45k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKuTu8%2FbtsLTo0NsKy%2FO9axHst8PDkYGW4kBkd45k%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;1538&quot; height=&quot;768&quot; data-origin-width=&quot;1538&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Youtube Data API&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Youtube Data API 탐색기를 통해서 query에 &quot;먹을텐데&quot;를 검색하니 아래와 같은 결과가 나왔네요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;625&quot; data-origin-height=&quot;595&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cnzEu0/btsLRPrOwnp/oesk3EMF6MieN0tIq5idr1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cnzEu0/btsLRPrOwnp/oesk3EMF6MieN0tIq5idr1/img.png&quot; data-alt=&quot;&amp;quot;먹을텐데&amp;quot; GET 요청 response&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cnzEu0/btsLRPrOwnp/oesk3EMF6MieN0tIq5idr1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcnzEu0%2FbtsLRPrOwnp%2Foesk3EMF6MieN0tIq5idr1%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;625&quot; height=&quot;595&quot; data-origin-width=&quot;625&quot; data-origin-height=&quot;595&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&quot;먹을텐데&quot; GET 요청 response&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;재생목록의 영상을 가져오려면 아래&amp;nbsp; 2단계를 거쳐야해요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣ 재생목록 API에 GET 메서드를 통해 playlistId를 알아낸다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ PlaylistItems API에 GET 메서드의 query Param으로 playlistId를 추가해, 영상 정보를 알아낸다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;420&quot; data-origin-height=&quot;97&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dbGqaN/btsLTomb46r/lCJ8sV8RMtTaf9ulI59Jk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dbGqaN/btsLTomb46r/lCJ8sV8RMtTaf9ulI59Jk1/img.png&quot; data-alt=&quot;데이터 요청 - 브라우저 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dbGqaN/btsLTomb46r/lCJ8sV8RMtTaf9ulI59Jk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdbGqaN%2FbtsLTomb46r%2FlCJ8sV8RMtTaf9ulI59Jk1%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;420&quot; height=&quot;97&quot; data-origin-width=&quot;420&quot; data-origin-height=&quot;97&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;데이터 요청 - 브라우저 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;데이터를 JSON으로 저장하는 용도이기 때문에 UI는 간단하게 input과 button으로만 구성했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1737192841918&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const input_search = document.getElementById(&quot;search_input&quot;);
const btn_search = document.getElementById(&quot;search_btn&quot;);

// 채널 이름으로 채널 ID를 찾고 재생목록을 가져오는 함수
async function getChannelId(name) {
  try {
    const { data, status } = await axios.get(
      &quot;https://www.googleapis.com/youtube/v3/search&quot;,
      {
        params: {
          part: &quot;snippet&quot;,
          key: API_KEY,
          maxResults: 50,
          type: &quot;channel&quot;,
          q: name,
        },
      }
    );
    
    // 채널 ID 추출 및 재생목록 검색 함수 호출
    if (status === 200 &amp;amp;&amp;amp; data.items.length &amp;gt; 0) {
      const channelId = data.items[0].snippet.channelId;
      getPlaylistId(channelId, name);
    }
  } catch (error) {
    console.log(error);
  }
}

// 채널 ID로 해당 채널의 재생목록을 가져오는 함수
async function getPlaylistId(channelId, search) {
  try {
    const {
      data: { items },
    } = await axios.get(&quot;https://www.googleapis.com/youtube/v3/playlists&quot;, {
      params: {
        part: &quot;snippet&quot;,
        key: API_KEY,
        channelId,
        maxResults: 50,
      },
    });

    // 재생목록을 검색어를 찾으면 해당 재생목록 ID 추출
    for (let i = 0; i &amp;lt; items.length; i++) {
      if (items[i].snippet.title.includes(search)) {
        const playlistId = items[i].id;
        getPlaylistVideos(playlistId); // 재생목록 영상 가져오기 함수 호출
        break;
      }
    }
  } catch (error) {
    console.log(error);
  }
}

// 재생목록 ID로 해당 재생목록의 영상들을 가져오는 함수
async function getPlaylistVideos(playlistId, pageToken) {
  try {
    const { data } = await axios.get(
      &quot;https://www.googleapis.com/youtube/v3/playlistItems&quot;,
      {
        params: {
          part: &quot;snippet&quot;,
          key: API_KEY,
          playlistId,
          maxResults: 50,
          pageToken,
        },
      }
    );
    const json = JSON.stringify(data.items, null, 2);

    // JSON 파일 다운로드 생성
    const blob = new Blob([json], { type: &quot;application/json&quot; });
    const url = URL.createObjectURL(blob);

    // 링크 생성 후 자동 클릭
    const a = document.createElement(&quot;a&quot;);
    a.href = url;
    a.download = &quot;youtube_data.json&quot;; // 파일 이름 지정
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
  } catch (error) {
    console.log(error);
  }
}

btn_search.addEventListener(&quot;click&quot;, () =&amp;gt; {
  const channelName = input_search.value;
  getChannelId(channelName);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;잘 실행되는지 확인해 보겠습니다.&lt;/span&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;304&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bkH1Pt/btsLRQqHWVe/qbDXqOk1nzeeiSm0eD2jRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bkH1Pt/btsLRQqHWVe/qbDXqOk1nzeeiSm0eD2jRk/img.png&quot; data-alt=&quot;API 요청 성공&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bkH1Pt/btsLRQqHWVe/qbDXqOk1nzeeiSm0eD2jRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbkH1Pt%2FbtsLRQqHWVe%2FqbDXqOk1nzeeiSm0eD2jRk%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;304&quot; data-origin-width=&quot;1208&quot; data-origin-height=&quot;304&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;API 요청 성공&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;58&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8bLXP/btsLSRColit/EVJk7dctYfEYfZKuScjEQ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8bLXP/btsLSRColit/EVJk7dctYfEYfZKuScjEQ1/img.png&quot; data-alt=&quot;response로 JSON 데이터 저장 성공&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8bLXP/btsLSRColit/EVJk7dctYfEYfZKuScjEQ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8bLXP%2FbtsLSRColit%2FEVJk7dctYfEYfZKuScjEQ1%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;873&quot; height=&quot;58&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;58&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;response로 JSON 데이터 저장 성공&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;655&quot; data-origin-height=&quot;708&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbVtTQ/btsLTo7BMIO/elNtt14R1dnemukpNYNMW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbVtTQ/btsLTo7BMIO/elNtt14R1dnemukpNYNMW1/img.png&quot; data-alt=&quot;JSON 데이터&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbVtTQ/btsLTo7BMIO/elNtt14R1dnemukpNYNMW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbVtTQ%2FbtsLTo7BMIO%2FelNtt14R1dnemukpNYNMW1%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;655&quot; height=&quot;708&quot; data-origin-width=&quot;655&quot; data-origin-height=&quot;708&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;JSON 데이터&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;JSON으로 데이터가 잘 저장되었습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Youtube Data API의 공식 문서를 참고하며, 실제 HTTP 요청을 통해 원하는 데이터를 찾다 보니 시간을 너무 많이 쓴 것 같아요. 조금 지친 것 같지만 API 문서를 파악하는 능력을 길렀으니 만족합니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 검색 키워드로 JSON 데이터를 채워 프로젝트를 진행할 일만 남았으니까 프로젝트 마무리까지 열심히 달려보겠습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1737200153352&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[맛길] 프로젝트 주제 및 기술 스택 선정&quot; data-og-description=&quot;  사이드 프로젝트 시작 계기팀 프로젝트를 진행하면서 소통과 협업의 기술을 배울 수 있어 프론트앤드 개발자로서 한 걸음 더 가까워진 것 같아요.그런데 프로젝트 후반으로 갈 수록 저를 포&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/169&quot; data-og-url=&quot;https://dev-hpk.tistory.com/169&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/ZhlHr/hyX4uHOmp5/o0TSLuX12mS6IlTNSp6Le1/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/dW91ZR/hyX0zKNvyp/2lI9TTLVOWkgRnmvhjDLsK/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/uw3cg/hyX0AbRwiU/Yk2QydIHESlkRFigGceQsK/img.png?width=435&amp;amp;height=668&amp;amp;face=0_0_435_668&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/169&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/169&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/ZhlHr/hyX4uHOmp5/o0TSLuX12mS6IlTNSp6Le1/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/dW91ZR/hyX0zKNvyp/2lI9TTLVOWkgRnmvhjDLsK/img.png?width=800&amp;amp;height=513&amp;amp;face=0_0_800_513,https://scrap.kakaocdn.net/dn/uw3cg/hyX0AbRwiU/Yk2QydIHESlkRFigGceQsK/img.png?width=435&amp;amp;height=668&amp;amp;face=0_0_435_668');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[맛길] 프로젝트 주제 및 기술 스택 선정&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  사이드 프로젝트 시작 계기팀 프로젝트를 진행하면서 소통과 협업의 기술을 배울 수 있어 프론트앤드 개발자로서 한 걸음 더 가까워진 것 같아요.그런데 프로젝트 후반으로 갈 수록 저를 포&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>fe</category>
      <category>Next.js</category>
      <category>Youtube Data API</category>
      <category>사이드 프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/170</guid>
      <comments>https://dev-hpk.tistory.com/170#entry170comment</comments>
      <pubDate>Sat, 18 Jan 2025 20:34:28 +0900</pubDate>
    </item>
    <item>
      <title>[맛길] 프로젝트 주제 및 기술 스택 선정</title>
      <link>https://dev-hpk.tistory.com/169</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;  사이드 프로젝트 시작 계기&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;팀 프로젝트를 진행하면서 소통과 협업의 기술을 배울 수 있어 프론트앤드 개발자로서 한 걸음 더 가까워진 것 같아요&lt;span style=&quot;text-align: start;&quot;&gt;.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;그런데 프로젝트 후반으로 갈 수록 저를 포함한 팀원들의 체력과 의욕이 떨어져 기능을 하나씩 빼게 되었습니다. 추가 &lt;span style=&quot;text-align: start;&quot;&gt;기능을 구현하고 싶은 제 입장에서는 프로젝트에 대해 만족을 못하고 있는 느낌이 들었어요 &lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; letter-spacing: 0px; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;제가 원하는대로 만들 수 있는 사이드 프로젝트를 한 번 해보면 팀원과의 협업의 소중함도 느끼고 문제 상황도 혼자 해결해 보면서 성장할 것 같아요. &lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그래서 개인 사이드 프로젝트를 시작하기로 결심했습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;498&quot; data-origin-height=&quot;498&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/etlYX8/btsLPNOB0Cc/PSGYD6ukEYVHVouNRjuroK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/etlYX8/btsLPNOB0Cc/PSGYD6ukEYVHVouNRjuroK/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/etlYX8/btsLPNOB0Cc/PSGYD6ukEYVHVouNRjuroK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/etlYX8/btsLPNOB0Cc/PSGYD6ukEYVHVouNRjuroK/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;263&quot; height=&quot;263&quot; data-origin-width=&quot;498&quot; data-origin-height=&quot;498&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;  사이드 프로젝트 주제 선정&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;막상 프로젝트를 시작하려고 하니 주제 선정부터 쉽지 않았어요.. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: justify; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;일상생활을 하면서 많은 사람들이 느꼈던 &lt;b&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;불편&lt;/span&gt;&lt;/b&gt; 또는 이런 것이 있으면 좋을 것 같다고 생각한 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;필요&lt;/b&gt;&lt;/span&gt;에 의해 주제를 선정하면 좋다고 하네요. 그런데 다른 사람들 관점에서 주제를 선정하면 프로젝트 중간에 제가 흥미가 사라져 의욕이 떨어질 것 같아요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: justify; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그래서 저를 포함한 많은 사람들이 같이 겪고 있는 불편 혹은 필요가 무엇인지 생각해 보다가 맛집 찾기가 떠올랐습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;그런데 네이버, 카카오 지도를 이용해 맛집을 보여주기만 하는건 의미가 없을 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;실제로 저는 네이버 지도를 많이 이용하는데, 가보고 싶은 맛집을 ⭐로 등록해도 다음번에 보면 메뉴가 눈에 잘 안 들어오고 리뷰를 읽기가 귀찮더라고요 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;304&quot; data-origin-height=&quot;432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5WjpF/btsLSkDX0jJ/eyPp4ZjpLhd8hSIiAICtA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5WjpF/btsLSkDX0jJ/eyPp4ZjpLhd8hSIiAICtA0/img.png&quot; data-alt=&quot;네이버 지도 맛집 리스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5WjpF/btsLSkDX0jJ/eyPp4ZjpLhd8hSIiAICtA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5WjpF%2FbtsLSkDX0jJ%2FeyPp4ZjpLhd8hSIiAICtA0%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;304&quot; height=&quot;432&quot; data-origin-width=&quot;304&quot; data-origin-height=&quot;432&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;네이버 지도 맛집 리스트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;주제도 정했으니 기능은 나중에 추가되거나 삭제될 수 있지만 일단 적어보겠습니다❗&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;✨ 프로젝트 기능 선정&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;CRUD, 로그인 기능도 구현하면 좋겠지만, 제가 검증이 안된 다른 사람의 서비스에 계정을 생성한다고 생각해 보니 뭔가 찜찜하네요 &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;제 서비스를 누가 볼지는 모르겠지만, 저도 사용자 입장을 고려해 CRUD, 로그인 기능은 빼도록 할게요. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;(아쉽지만 CRUD, 간편 로그인, 간편 회원가입 기능은 이번에 진행하는 고급 팀 프로젝트에서 해볼게요 )&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;1️⃣ 맛집 소개 리스트 페이지&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;저는 맛집을 검색할 때 별점(⭐)도 중요하게 생각하지만, 실제 방문자들이 남긴 가게와 메뉴 사진이 포함된 리뷰를 주로 참고해요. 그런데 매번 리뷰를 읽는 건 귀찮은 일이잖아요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;그래서 저는 사람들이 가장 쉽게 접할 수 있고 자주 이용하는 매체를 고민하다가 유튜브를 떠올렸어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;유튜브에 맛집을 방문해서 소개하는 먹방 컨텐츠(또간집, 먹을텐데, 스트리트 푸드파이터, 줄 서는 식당 등)가 많잖아요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;영상 썸네일, 가게 이름, 음식 카테고리, 지역 정보가 포함된 카드 형식의 리스트를 보여주겠습니다✨&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;452&quot; data-origin-height=&quot;604&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oaOoZ/btsLP8FFFFi/QFl5PASeC6e6HOX57z5wB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oaOoZ/btsLP8FFFFi/QFl5PASeC6e6HOX57z5wB1/img.png&quot; data-alt=&quot;리스트 페이지 예시 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oaOoZ/btsLP8FFFFi/QFl5PASeC6e6HOX57z5wB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoaOoZ%2FbtsLP8FFFFi%2FQFl5PASeC6e6HOX57z5wB1%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;452&quot; height=&quot;604&quot; data-origin-width=&quot;452&quot; data-origin-height=&quot;604&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;리스트 페이지 예시 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;디자인 능력이 없어 온라인 툴로 간단하게 예시를 만들었더니 퀄리티가 너무 떨어지네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;대충 어떤식으로 구현할 건지 느낌만 봐주세요❗&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;2️⃣ 맛집 소개 상세 페이지&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;썸네일에 이끌려 맛집을 클릭했다면, 자세한 정보도 보여줘야겠죠❗&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;상세 페이지에는 유튜브 영상을 추가할게요. 추가로 영상을 재생하기 귀찮거나 어려운 사용자도 있을 수 있으니 텍스트로도 잘 요약해야겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;418&quot; data-origin-height=&quot;628&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zGTql/btsLRFCdB2U/UghcSmPo1dRl1qpUTVb34k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zGTql/btsLRFCdB2U/UghcSmPo1dRl1qpUTVb34k/img.png&quot; data-alt=&quot;맛집 상세 페이지 예시 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zGTql/btsLRFCdB2U/UghcSmPo1dRl1qpUTVb34k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzGTql%2FbtsLRFCdB2U%2FUghcSmPo1dRl1qpUTVb34k%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;418&quot; height=&quot;628&quot; data-origin-width=&quot;418&quot; data-origin-height=&quot;628&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;맛집 상세 페이지 예시 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;이렇게 보니까 화면의 정보가 너무 빈약한 것 같네요.. 추가할 정보들은 고민해 보도록 할게요 &lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;3️⃣&amp;nbsp;맛집 지도 페이지&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;서비스의 핵심인 지도 페이지입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;위에서 소개한 리스트와 상세 페이지만 있다면, 서비스를 이용하지 않고 유튜브로 직접 검색하겠죠❗&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;2️⃣번 상세 페이지의 &quot;지도로 볼래요&quot; 버튼을 누르면 해당 가게를 지도로 표시해 주겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;435&quot; data-origin-height=&quot;668&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/clRAnl/btsLQB1Szmb/Dm4kzJnfP1junruXFGvUL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/clRAnl/btsLQB1Szmb/Dm4kzJnfP1junruXFGvUL0/img.png&quot; data-alt=&quot;지도 기능 예시 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/clRAnl/btsLQB1Szmb/Dm4kzJnfP1junruXFGvUL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FclRAnl%2FbtsLQB1Szmb%2FDm4kzJnfP1junruXFGvUL0%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;435&quot; height=&quot;668&quot; data-origin-width=&quot;435&quot; data-origin-height=&quot;668&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;지도 기능 예시 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;  기술 스택 선정&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;Next.js&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;Next.js는 React 기반의 프레임워크라 React의 장점을 활용하면서, &lt;b&gt;서버 사이드 렌더링(SSR)&lt;/b&gt;을 지원합니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;서버 사이드 렌더링(SSR)&lt;/b&gt;으로 초기 렌더링 속도를 향상해 사용자 경험을 개선하고, SEO 최적화를 통해 검색 엔진 친화적인 서비스를 개발할 수 있어 선택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;Tailwind CSS&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;Tailwind CSS는 Utility-First 컨셉을 가진 CSS 프레임워크입니다.&lt;/span&gt; HTML 코드 안에 스타일 코드가 포함되어 있기 때문에 HTML과 CSS 파일을 별도로 관리할 필요가 없습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Tailwind는 미리 정의된 className을 제공하기 때문에 일관된 스타일을 유지할 수 있고, className을 고민하는 시간을 줄여주기 때문에 선택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;추가로 Tailwind CSS는 CSS in JS 방식과 다르게 빌드 타임에 처리되기 때문에 성능 최적화와 작은 CSS 번들 크기 유지에 좋다고 하네요 &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;TypeScript &amp;amp; Prettier &amp;amp; ESLint&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;혼자 하는 프로젝트여도 TypeScript, Prettier, ESlint를 사용하면 더 나은 코딩 습관을 익힐 수 있고, 시간이 지나 프로젝트에 새로운 기능을 추가하거나 리팩터링 할 때 완성도 높은 코드가 더 유지보수 하기 쉽다고 생각해 선택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;Next.js Serverless Function&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서는 단순 데이터 제공만 하기 때문에 &lt;b&gt;비용, 관리, 성능&lt;/b&gt; 면에서 Next.js Serverless Function이 가장 적합하다고 생각해 선택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Firebase를 사용할까도 많이 고민했지만, 실시간 데이터베이스와 인증 기능이 이번 프로젝트에서는 과도한 기능일 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;Serverless Function 장점&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt; 별도의 백엔드 환경을 구축하거나 관리할 필요 없이 데이터 관련 API를 /api 디렉토리에서 바로 작성하고 사용할 수 있어 유지보수가 쉽습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;Serverless Function은 &lt;b&gt;서버 사이드 렌더링(&lt;b&gt;SSR&lt;/b&gt;) &lt;/b&gt;및 &lt;b&gt;정적 사이트 생성(SSG)&lt;/b&gt;과 자연스럽게 통합되어, 동적 데이터를 렌더링 하면서도 빠른 페이지 로딩 속도를 유지할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt; Vercel에서 Next.js 프로젝트를 배포하면, 서버리스 함수가 자동으로 설정 및 배포됩니다. 별도의 서버 관리 없이 즉시 사용할 수 있어 배포가 매우 간단합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>fe</category>
      <category>Next.js</category>
      <category>사이드 프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/169</guid>
      <comments>https://dev-hpk.tistory.com/169#entry169comment</comments>
      <pubDate>Fri, 17 Jan 2025 16:58:21 +0900</pubDate>
    </item>
    <item>
      <title>[Git] Pull Request에 Template 자동 적용하기 (feat. 이슈 close)</title>
      <link>https://dev-hpk.tistory.com/167</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이전에 Create Issue Branch(Github Action)을 활용해 이슈 생성 시 feature bracnh 생성은 자동화했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이슈를 생성하고 작업을 할 때는 매우 편리하지만&amp;nbsp;PR(pull request)을 생성하고 Merge 할 때 2가지 불편함을 느꼈습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣ PR(pull request)를 생성할 때마다 정해둔 형식에 맞게 PR 본문을 작성해야 함&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ PR(pull request)이 Merge 되어도 Issue가 Open 상태로 남아있어 직접 Close 해줘야 함&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;914&quot; data-origin-height=&quot;611&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GrvNU/btsLNMWI6oi/DkUyggdXVgyRFKsGAJ8m60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GrvNU/btsLNMWI6oi/DkUyggdXVgyRFKsGAJ8m60/img.png&quot; data-alt=&quot;PR(pull request) 본문 형식&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GrvNU/btsLNMWI6oi/DkUyggdXVgyRFKsGAJ8m60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGrvNU%2FbtsLNMWI6oi%2FDkUyggdXVgyRFKsGAJ8m60%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;914&quot; height=&quot;611&quot; data-origin-width=&quot;914&quot; data-origin-height=&quot;611&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;PR(pull request) 본문 형식&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;756&quot; data-origin-height=&quot;377&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zHyqU/btsLNw0PYfE/MRiixWcw6Yql05XwCi13JK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zHyqU/btsLNw0PYfE/MRiixWcw6Yql05XwCi13JK/img.png&quot; data-alt=&quot;Merge 된 PR(pull request)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zHyqU/btsLNw0PYfE/MRiixWcw6Yql05XwCi13JK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzHyqU%2FbtsLNw0PYfE%2FMRiixWcw6Yql05XwCi13JK%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;756&quot; height=&quot;377&quot; data-origin-width=&quot;756&quot; data-origin-height=&quot;377&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Merge 된 PR(pull request)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;974&quot; data-origin-height=&quot;787&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2BZPJ/btsLPxcouvi/l86BKx57cMVRPjHsYbtvtk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2BZPJ/btsLPxcouvi/l86BKx57cMVRPjHsYbtvtk/img.png&quot; data-alt=&quot;PR(pull request)이 Merge 되어도 Open 상태인 Issue&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2BZPJ/btsLPxcouvi/l86BKx57cMVRPjHsYbtvtk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2BZPJ%2FbtsLPxcouvi%2Fl86BKx57cMVRPjHsYbtvtk%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;974&quot; height=&quot;787&quot; data-origin-width=&quot;974&quot; data-origin-height=&quot;787&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;PR(pull request)이 Merge 되어도 Open 상태인 Issue&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 두 내용을 자동화할 수 있는 방법을 구글링 해보던 중 Github PR Template를 찾게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;PR Template은 왜 필요할까 &lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;PR Template을 만들어 Repository에 추가하면 PR을 할 때 PR body에 template의 내용이 자동으로 추가됨&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;PR Template을 사용하면 &lt;/span&gt;PR의 내용을 표준화해서 일관성 있는 좋은 품질의 Pull Request를 유지할 수 있게 됨&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 두 내용만 봐도 PR Template를 사용하면 자동화를 통해 매번 PR 본문 형식을 작성하는 번거로움을 줄일 수 있겠네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저는 조금 더 욕심을 내서 PR이 Merge 되었을 때 해당 이슈도 Close 해주는 작업을 추가해보고 싶었습니다. 검색을 통해 확인해 보니 Create Issue Branch에서 했던 것처럼 workflow를 추가하는 방법이 있지만 정확한 적용 방법이 없네요.. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;여기서 포기할 수 없습니다! &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;자동화가 너무 편리하니까요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;수 차례 검색을 통해 &lt;span style=&quot;color: #555555; text-align: start;&quot;&gt;Pull Request에서 작성하는 메시지에서 &lt;span style=&quot;color: #555555; text-align: start;&quot;&gt;issu&lt;/span&gt;&lt;span style=&quot;color: #555555; text-align: start;&quot;&gt;e에 연결이 &lt;/span&gt;지원되는 키워드를 찾았습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1736925690740&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Linking a pull request to an issue - GitHub Docs&quot; data-og-description=&quot;You can link an issue to a pull request manually or using a supported keyword in the pull request description, that is, the summary text added by the author when they created the pull request. When you link a pull request to the issue the pull request addr&quot; data-og-host=&quot;docs-internal.github.com&quot; data-og-source-url=&quot;https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue&quot; data-og-url=&quot;https://docs-internal.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/PE1yI/hyX4rw8iqY/Qna8ZShcCiTlX9482K0Pk0/img.png?width=1200&amp;amp;height=1200&amp;amp;face=0_0_1200_1200,https://scrap.kakaocdn.net/dn/bSgo9X/hyX4qSwuZX/GtkiRluMgwCKTDUYfvkGak/img.png?width=706&amp;amp;height=840&amp;amp;face=0_0_706_840,https://scrap.kakaocdn.net/dn/5zUEX/hyX4AgxkVc/Ny37nrtJckjpZZhnB8zKjk/img.png?width=309&amp;amp;height=460&amp;amp;face=0_0_309_460&quot;&gt;&lt;a href=&quot;https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/PE1yI/hyX4rw8iqY/Qna8ZShcCiTlX9482K0Pk0/img.png?width=1200&amp;amp;height=1200&amp;amp;face=0_0_1200_1200,https://scrap.kakaocdn.net/dn/bSgo9X/hyX4qSwuZX/GtkiRluMgwCKTDUYfvkGak/img.png?width=706&amp;amp;height=840&amp;amp;face=0_0_706_840,https://scrap.kakaocdn.net/dn/5zUEX/hyX4AgxkVc/Ny37nrtJckjpZZhnB8zKjk/img.png?width=309&amp;amp;height=460&amp;amp;face=0_0_309_460');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Linking a pull request to an issue - GitHub Docs&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;You can link an issue to a pull request manually or using a supported keyword in the pull request description, that is, the summary text added by the author when they created the pull request. When you link a pull request to the issue the pull request addr&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs-internal.github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 PR Template에 적용 후 테스트 해보겠습니다✨&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;PR Template 적용&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1️⃣&lt;/b&gt; .github/ 디렉토리에 &lt;b&gt;pull_request_template.md&lt;/b&gt; 파일 추가&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ pull_request_template.md 파일에 아래 내용 추가&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736925972407&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;## #️⃣연관된 이슈

&amp;gt; ex) #이슈번호, #이슈번호

close #'이슈번호'

##  작업 내용

&amp;gt; 이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능)

### 스크린샷 (선택)

##  리뷰 요구사항(선택)

&amp;gt; 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요
&amp;gt;
&amp;gt; ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요?&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;PR 생성 후 template 적용 확인&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;909&quot; data-origin-height=&quot;664&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c2hDzG/btsLOp7DOqC/P5TACxmfOZ16Npe30ip0Y1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c2hDzG/btsLOp7DOqC/P5TACxmfOZ16Npe30ip0Y1/img.png&quot; data-alt=&quot;PR Template 적용 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c2hDzG/btsLOp7DOqC/P5TACxmfOZ16Npe30ip0Y1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc2hDzG%2FbtsLOp7DOqC%2FP5TACxmfOZ16Npe30ip0Y1%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;909&quot; height=&quot;664&quot; data-origin-width=&quot;909&quot; data-origin-height=&quot;664&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;PR Template 적용 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #222222; text-align: start;&quot;&gt;PR을 생성하니 pull_request_template.md에 작성해 둔 형식이 잘 적용되었네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #222222; text-align: start;&quot;&gt;close #'이슈번호'에 이슈 번호를 지정하고 이슈가 Close 되는지 테스트해 보겠습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;360&quot; data-origin-height=&quot;450&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dOHn8t/btsLOX3UNI3/Il3shIOe2kKk0fhlyIRnp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dOHn8t/btsLOX3UNI3/Il3shIOe2kKk0fhlyIRnp0/img.png&quot; data-alt=&quot;이슈 Close 테스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dOHn8t/btsLOX3UNI3/Il3shIOe2kKk0fhlyIRnp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdOHn8t%2FbtsLOX3UNI3%2FIl3shIOe2kKk0fhlyIRnp0%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;360&quot; height=&quot;450&quot; data-origin-width=&quot;360&quot; data-origin-height=&quot;450&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이슈 Close 테스트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;312&quot; data-origin-height=&quot;261&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CWAKt/btsLNXKDHsV/EAgSaRQPKIVtqpOX6gKYc0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CWAKt/btsLNXKDHsV/EAgSaRQPKIVtqpOX6gKYc0/img.png&quot; data-alt=&quot;이슈 Close 테스트 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CWAKt/btsLNXKDHsV/EAgSaRQPKIVtqpOX6gKYc0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCWAKt%2FbtsLNXKDHsV%2FEAgSaRQPKIVtqpOX6gKYc0%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;312&quot; height=&quot;261&quot; data-origin-width=&quot;312&quot; data-origin-height=&quot;261&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이슈 Close 테스트 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;PR을 Merge 해도 이슈가 Open 상태로 남아있네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;검색을 통해 아래와 같은 방법들을 시도해 봤지만 모두 이슈를 Close하는데 실패했습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;close 키워드를 PR Template 최상단에서 사용&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;close 키워드 대신 closes, closed 키워드 사용&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;검색으로는 도저히 해결할 방법을 찾을 수 없어 Git Docs를 확인하던 중 아래 내용을 찾게 되었고 마지막으로 시도해 보기로 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1736929751019&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Changing the default branch - GitHub Enterprise Server 3.10 Docs&quot; data-og-description=&quot;If you have more than one branch in your repository, you can configure any branch as the default branch.&quot; data-og-host=&quot;docs-internal.github.com&quot; data-og-source-url=&quot;https://docs.github.com/en/enterprise-server@3.10/repositories/configuring-branches-and-merges-in-your-repository/managing-branches-in-your-repository/changing-the-default-branch#changing-the-default-branch&quot; data-og-url=&quot;https://docs-internal.github.com/en/enterprise-server@3.10/repositories/configuring-branches-and-merges-in-your-repository/managing-branches-in-your-repository/changing-the-default-branch&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bkYDHn/hyX4oApJjF/rrParlSn34Vi7ZZBCKWki1/img.png?width=1200&amp;amp;height=1200&amp;amp;face=0_0_1200_1200,https://scrap.kakaocdn.net/dn/dboujw/hyX0xMxS74/pHgTeKxSvxE9QbkS5Uiaxk/img.png?width=2196&amp;amp;height=216&amp;amp;face=0_0_2196_216&quot;&gt;&lt;a href=&quot;https://docs.github.com/en/enterprise-server@3.10/repositories/configuring-branches-and-merges-in-your-repository/managing-branches-in-your-repository/changing-the-default-branch#changing-the-default-branch&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.github.com/en/enterprise-server@3.10/repositories/configuring-branches-and-merges-in-your-repository/managing-branches-in-your-repository/changing-the-default-branch#changing-the-default-branch&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bkYDHn/hyX4oApJjF/rrParlSn34Vi7ZZBCKWki1/img.png?width=1200&amp;amp;height=1200&amp;amp;face=0_0_1200_1200,https://scrap.kakaocdn.net/dn/dboujw/hyX0xMxS74/pHgTeKxSvxE9QbkS5Uiaxk/img.png?width=2196&amp;amp;height=216&amp;amp;face=0_0_2196_216');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Changing the default branch - GitHub Enterprise Server 3.10 Docs&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;If you have more than one branch in your repository, you can configure any branch as the default branch.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs-internal.github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Issue를 관리하는 키워드가 PR(pull request)이 repository&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start;&quot;&gt;의 Default branch&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start;&quot;&gt;를 대상으로 할 때만 해석된다고 하네요.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Default branch를 그럼 feature로 변경 후 테스트 해보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;779&quot; data-origin-height=&quot;188&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBQpKf/btsLOT8kJoX/gK2KytmqAZnwvD9gWxycDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBQpKf/btsLOT8kJoX/gK2KytmqAZnwvD9gWxycDk/img.png&quot; data-alt=&quot;Default branch를 feature로 변경&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBQpKf/btsLOT8kJoX/gK2KytmqAZnwvD9gWxycDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBQpKf%2FbtsLOT8kJoX%2FgK2KytmqAZnwvD9gWxycDk%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;779&quot; height=&quot;188&quot; data-origin-width=&quot;779&quot; data-origin-height=&quot;188&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Default branch를 feature로 변경&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;899&quot; data-origin-height=&quot;678&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o41Ut/btsLNNnY8uV/KBmYpEIjJgWZB5kyw6YKr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o41Ut/btsLNNnY8uV/KBmYpEIjJgWZB5kyw6YKr0/img.png&quot; data-alt=&quot;13번 이슈 Close 테스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o41Ut/btsLNNnY8uV/KBmYpEIjJgWZB5kyw6YKr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo41Ut%2FbtsLNNnY8uV%2FKBmYpEIjJgWZB5kyw6YKr0%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;899&quot; height=&quot;678&quot; data-origin-width=&quot;899&quot; data-origin-height=&quot;678&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;13번 이슈 Close 테스트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1259&quot; data-origin-height=&quot;581&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LH1uL/btsLPznXk0K/qKTuK2drDZlUfzOjhSp141/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LH1uL/btsLPznXk0K/qKTuK2drDZlUfzOjhSp141/img.png&quot; data-alt=&quot;PR Merge 결과 이슈 Close 성공&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LH1uL/btsLPznXk0K/qKTuK2drDZlUfzOjhSp141/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLH1uL%2FbtsLPznXk0K%2FqKTuK2drDZlUfzOjhSp141%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;1259&quot; height=&quot;581&quot; data-origin-width=&quot;1259&quot; data-origin-height=&quot;581&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;PR Merge 결과 이슈 Close 성공&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;&quot;Successfully merging a pull request may close this issue&quot;&lt;/b&gt;&lt;/span&gt;라는 메시지와 함께 이슈가 Close 된 것을 확인했습니다   &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;PR Template 적용과 이슈 자동 종료 설정을 진행하면서 많은 시행착오를 겪었습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;PR을 Merge 할 때 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;예상한 대로 &lt;/span&gt;이슈가 자동으로 닫히지 않아 많은 시간과 노력이 들어갔고, 자동화를 포기하고 수동으로 이슈를 닫는 것이 낫지 않나 하는 생각도 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;하지만 이번 경험을 통해 GitHub의 PR Template와 이슈 관련 키워드 설정에 대해 더 깊이 이해할 수 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;앞으로 진행할 프로젝트에서는 더 빠르고 수월하게 설정을 적용할 수 있으니, 매번 정해둔 형식에 맞게 PR 본문을 작성하고 이슈를 Close하는 번거로움이 사라지겠네요✨&lt;/span&gt;&lt;/p&gt;</description>
      <category>Git</category>
      <category>Git</category>
      <category>GitHub</category>
      <category>PR template</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/167</guid>
      <comments>https://dev-hpk.tistory.com/167#entry167comment</comments>
      <pubDate>Thu, 16 Jan 2025 13:22:27 +0900</pubDate>
    </item>
    <item>
      <title>[Next] Event handlers cannot be passed to client component props 오류 해결</title>
      <link>https://dev-hpk.tistory.com/168</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; Next.js에서 button 요소에 onClick 이벤트 핸들러를 전달하려고 하는데 아래와 같은 에러가 발생했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736936408429&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleClick = async () =&amp;gt; {
    const query = await getDoc(doc(db, &quot;사용자&quot;, &quot;zSen5y9LJazULo2C3atN&quot;));
    console.log(query.data());
  };

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;button
        className='bg-black text-white p-2 rounded-md'
        onClick={handleClick}
      &amp;gt;
        데이터 가져오기
      &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;970&quot; data-origin-height=&quot;297&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHd0ar/btsLPDDOtj3/2mCqEtmzUDIFilKaatl5gK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHd0ar/btsLPDDOtj3/2mCqEtmzUDIFilKaatl5gK/img.png&quot; data-alt=&quot;onClick 이벤트 핸들러 등록 에러&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHd0ar/btsLPDDOtj3/2mCqEtmzUDIFilKaatl5gK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcHd0ar%2FbtsLPDDOtj3%2F2mCqEtmzUDIFilKaatl5gK%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;970&quot; height=&quot;297&quot; data-origin-width=&quot;970&quot; data-origin-height=&quot;297&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;onClick 이벤트 핸들러 등록 에러&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존에 진행하던 프로젝트에서는 마주한 적 없던 에러인데 뭐가 문제일까요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;곰곰이 생각해 보니 다른 점은 라우팅 방식뿐이네요. 기존에는 Page Router 방식을 사용했지만 이번 프로젝트는 App Router 방식을 사용했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;App Router 방식이 등장하면서 getStaticProps,&amp;nbsp;getServerSideProps 같은 메서드가 사라지고, &amp;nbsp;fetch로 통합이 되었다고 합니다 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;291&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qVNan/btsLP7q80Zi/BxctgSAzcAtNuA0hQhJey1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qVNan/btsLP7q80Zi/BxctgSAzcAtNuA0hQhJey1/img.png&quot; data-alt=&quot;Page Router 방식&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qVNan/btsLP7q80Zi/BxctgSAzcAtNuA0hQhJey1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqVNan%2FbtsLP7q80Zi%2FBxctgSAzcAtNuA0hQhJey1%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;291&quot; height=&quot;420&quot; data-origin-width=&quot;291&quot; data-origin-height=&quot;420&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Page Router 방식&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;292&quot; data-origin-height=&quot;330&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/V7hhy/btsLNMW5qPG/IkQupVanA8HHz16b1GSlxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/V7hhy/btsLNMW5qPG/IkQupVanA8HHz16b1GSlxk/img.png&quot; data-alt=&quot;App Router 방식&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/V7hhy/btsLNMW5qPG/IkQupVanA8HHz16b1GSlxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FV7hhy%2FbtsLNMW5qPG%2FIkQupVanA8HHz16b1GSlxk%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;292&quot; height=&quot;330&quot; data-origin-width=&quot;292&quot; data-origin-height=&quot;330&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;App Router 방식&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;App Router 방식에서는 CSR(Client Side Rendering)을 어떻게 해야 할까요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Next.js 공식 페이지의 ai에게 물어본 결과 아래처럼 응답해 줬습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;667&quot; data-origin-height=&quot;506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tGpAQ/btsLNWFa194/1rftZq4Nz4LECBLySkDXy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tGpAQ/btsLNWFa194/1rftZq4Nz4LECBLySkDXy1/img.png&quot; data-alt=&quot;Next.js 공식 페이지 ai의 답변&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tGpAQ/btsLNWFa194/1rftZq4Nz4LECBLySkDXy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtGpAQ%2FbtsLNWFa194%2F1rftZq4Nz4LECBLySkDXy1%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;667&quot; height=&quot;506&quot; data-origin-width=&quot;667&quot; data-origin-height=&quot;506&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Next.js 공식 페이지 ai의 답변&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;간략하게 요약해 볼게요❗&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;use client란?&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;use client&lt;/span&gt; 디렉티브는 Next.js 애플리케이션에서 &lt;b&gt;어떤 컴포넌트가 클라이언트 사이드에서 렌더링 되어야 하는지&lt;/b&gt; 명시적으로 정의하는 데 사용됩니다. &lt;span style=&quot;background-color: #dddddd;&quot;&gt;use client&lt;/span&gt;를 파일에 추가하면, 해당 파일에서 import하는 모든 컴포넌트가 클라이언트 번들에 포함됩니다. 따라서 모든 컴포넌트에 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;use client&lt;/span&gt;를 추가할 필요는 없습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;언제 use client를 사용해야 하나요?&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;다음과 같은 경우에 use client를 사용해야 합니다:&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;인터랙티브 컴포넌트&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;useState, useEffect와 같은 React 훅을 사용해야 할 때&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;클라이언트 사이드에서 상호작용이 필요한 컴포넌트&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;브라우저 전용 API&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;localStorage, window 객체, geolocation 등 브라우저에서만 사용할 수 있는 API를 접근해야 할 때&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이벤트 리스너 등을 사용할 때&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;서드파티 라이브러리&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;브라우저 API나 DOM 조작에 의존하는 라이브러리를 사용할 때&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;예를 들어, react-dom과 같은 라이브러리를 사용하는 컴포넌트&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;언제 use client를 사용하지 말아야 하나요?&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;정적 콘텐츠&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오직 정적 콘텐츠만 렌더링하는 컴포넌트&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;서버에서 완전히 렌더링 할 수 있는 컴포넌트&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;데이터 페칭&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;서버 컴포넌트에서 서버 사이드 데이터를 페칭 하는 것을 선호&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;서버 작업은 서버 액션 또는 API 라우트를 통해 수행&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결론적으로 저는 클라이언트 사이드에서 버튼을 눌렀을 때 데이터를 비동기적으로 요청해야 하니까 CSR을 위해 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;use client&lt;/span&gt;를 추가해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736941932768&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import db from &quot;@/firebase/firestore&quot;;
import { doc, getDoc } from &quot;firebase/firestore&quot;;

export default function Home() {
  const handleClick = async () =&amp;gt; {
    const query = await getDoc(doc(db, &quot;사용자&quot;, &quot;zSen5y9LJazULo2C3atN&quot;));
    console.log(query);
  };

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;button
        className='bg-black text-white p-2 rounded-md'
        onClick={handleClick}
      &amp;gt;
        데이터 가져오기
      &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;707&quot; data-origin-height=&quot;197&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c1wUMB/btsLODLFDSh/k6eZTEJCUub1bMLF7KgF9K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c1wUMB/btsLODLFDSh/k6eZTEJCUub1bMLF7KgF9K/img.png&quot; data-alt=&quot;use client 추가 후 에러 해결&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c1wUMB/btsLODLFDSh/k6eZTEJCUub1bMLF7KgF9K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc1wUMB%2FbtsLODLFDSh%2Fk6eZTEJCUub1bMLF7KgF9K%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;707&quot; height=&quot;197&quot; data-origin-width=&quot;707&quot; data-origin-height=&quot;197&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;use client 추가 후 에러 해결&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;use client&lt;/span&gt;를 추가해 주니 에러 없이 잘 동작하네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이번 에러를 통해 Next의 공식 문서도 정독해 보고 Next에 대한 많은 내용을 검색해 보게 되었습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; App Router가 도입되면서부터는 서버 컴포넌트에서 클라이언트 이벤트 핸들러를 사용할 수 없다는 것을 알게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; Next.js는 지속적으로 발전하고 있기 때문에, 이런 기능이나 제약이 바뀔 가능성도 충분히 있을 것 같습니다  &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그래서 앞으로는 Next.js의 업데이트를 계속 따라가며, 최신 변화와 새로운 기능들을 놓치지 않기 위해 공식 문서를 주기적으로 확인할 계획입니다!&lt;/span&gt;&lt;/p&gt;</description>
      <category>Next</category>
      <category>App Router</category>
      <category>CSR</category>
      <category>fe</category>
      <category>Next.js</category>
      <category>SSR</category>
      <category>use client</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/168</guid>
      <comments>https://dev-hpk.tistory.com/168#entry168comment</comments>
      <pubDate>Wed, 15 Jan 2025 21:21:20 +0900</pubDate>
    </item>
    <item>
      <title>[Git] Github Action을 활용한 Issue 및 feature branch 생성 자동화</title>
      <link>https://dev-hpk.tistory.com/166</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;프로젝트를 진행하면서 Git Flow 전략을 토대로 개발을 하기 위해 각 Issue 별로 develop 브랜치에서 feature 브랜치를 따서 사용했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;매번 Issue 생성 &amp;rarr; 브랜치 생성의 과정을 반복하다 보니 번거롭다는 생각이 들어 자동화해 보자는 생각에 구글링 하던 중 Jira와 Github Action을 찾게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #292a2e; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Jira와 Github을 연동하면 Jira Issue에서 바로 Github branch를 생성하고 Issue와 관련된 코드 변경 사항을 추적할 수 있다고 합니다. &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;784&quot; data-origin-height=&quot;502&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baOvIX/btsLPj59Bps/N2IMwdZhsg916GBemZCB40/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baOvIX/btsLPj59Bps/N2IMwdZhsg916GBemZCB40/img.png&quot; data-alt=&quot;출처 - https://lesstif.atlassian.net/wiki/spaces/JIRA/pages/1019052090/Jira+Issue+Code&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baOvIX/btsLPj59Bps/N2IMwdZhsg916GBemZCB40/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaOvIX%2FbtsLPj59Bps%2FN2IMwdZhsg916GBemZCB40%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;784&quot; height=&quot;502&quot; data-origin-width=&quot;784&quot; data-origin-height=&quot;502&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처 - https://lesstif.atlassian.net/wiki/spaces/JIRA/pages/1019052090/Jira+Issue+Code&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;color: #292a2e; text-align: start;&quot;&gt;그런데 최대 10명의 사용자와&amp;nbsp;&amp;nbsp;&lt;b&gt;2GB&lt;/b&gt;&amp;nbsp;저장소 제공이라는 제한 사항이 있네요 &lt;/span&gt;&lt;span style=&quot;color: #292a2e; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저의 경우 주로 소규모 프로젝트를 진행해하기 때문에, 복잡한 프로젝트 관리보다는 간단한 이슈 관리 목적으로&amp;nbsp;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;Create Issue Branch(Github Action)을 사용하게 되었습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Create Issue Branch&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Create Issue Branch는 Issue 생성 시, BranchName으로 정해진 문법에 따라 자동으로 신규 Branch를 생성해 주는 Github Action입니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Create Issue Branch를 이용하면, PR(Pull Request) 후 merge 진행 시 자동 Issue close 역시 가능합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;설치 방법은 아래 링크를 참조했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1736916845529&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;Create Issue Branch - GitHub Marketplace&quot; data-og-description=&quot;GitHub action that creates a new branch after assigning an issue&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/marketplace/actions/create-issue-branch#installation&quot; data-og-url=&quot;https://github.com/marketplace/actions/create-issue-branch&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dqNvfK/hyX4uUUcFQ/J9CcU5OfKfGxlPCwtPqgH1/img.jpg?width=400&amp;amp;height=400&amp;amp;face=70_70_328_352,https://scrap.kakaocdn.net/dn/gN8y5/hyX0mK0D0Z/GyGTeNe2jtiijo48v8oUuK/img.jpg?width=400&amp;amp;height=400&amp;amp;face=70_70_328_352,https://scrap.kakaocdn.net/dn/UBzeT/hyX4yJLTbU/lONkymFX9uKU3v66iKkhq1/img.png?width=1626&amp;amp;height=870&amp;amp;face=0_0_1626_870&quot;&gt;&lt;a href=&quot;https://github.com/marketplace/actions/create-issue-branch#installation&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/marketplace/actions/create-issue-branch#installation&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dqNvfK/hyX4uUUcFQ/J9CcU5OfKfGxlPCwtPqgH1/img.jpg?width=400&amp;amp;height=400&amp;amp;face=70_70_328_352,https://scrap.kakaocdn.net/dn/gN8y5/hyX0mK0D0Z/GyGTeNe2jtiijo48v8oUuK/img.jpg?width=400&amp;amp;height=400&amp;amp;face=70_70_328_352,https://scrap.kakaocdn.net/dn/UBzeT/hyX4yJLTbU/lONkymFX9uKU3v66iKkhq1/img.png?width=1626&amp;amp;height=870&amp;amp;face=0_0_1626_870');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Create Issue Branch - GitHub Marketplace&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;GitHub action that creates a new branch after assigning an issue&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;설치 방법은 아래 두 가지 있다고 하네요&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1️⃣ repository에 Create Issue Branch app 설치하기&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2️⃣ &lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;Github Action을 통해 사용하기 (YAML config에 워크플로우를 제어할 코드를 작성)&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저는 2번을 적용해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;issue-branch.yml 파일 추가 및 적용&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1736917893041&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;name: Create Feature Branch on Issue Creation

on:
  issues:
    types: [opened]

jobs:
  create-branch:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v2
        with:
          token: ${{ secrets.TOKEN }}
          ref: feature

      - name: Create feature branch
        env:
          ISSUE_NUMBER: ${{ github.event.issue.number }}
          ISSUE_TITLE: ${{ github.event.issue.title }}
        run: |
          # 브랜치 이름 생성 (특수문자 및 공백 처리)
          ISSUE_TITLE_CLEAN=&quot;${ISSUE_TITLE// /-}&quot;  # 공백을 '-'로 대체
          ISSUE_TITLE_CLEAN=&quot;${ISSUE_TITLE_CLEAN//[^a-zA-Z0-9가-힣_-]/}&quot; # 영문, 한글, 숫자, '_'만 남김

          BRANCH_NAME=&quot;#${ISSUE_NUMBER}_${ISSUE_TITLE_CLEAN}&quot;

          # 'feature' 브랜치를 기준으로 새 브랜치 생성 및 푸시
          git checkout feature
          git pull origin feature  # 최신 상태로 업데이트
          git checkout -b &quot;$BRANCH_NAME&quot;
          git push origin &quot;$BRANCH_NAME&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;name&lt;/b&gt; : Create Feature Branch on Issue Creation을 워크플로우 이름으로 설정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;on.issues.types&lt;/b&gt; : 새로운 이슈(opened)가 생성될 때 워크플로우를 실행하도록 트리거 설정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;ISSUE_NUMBER&lt;/b&gt; : 생성된 이슈 번호를 저장&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;ISSUE_TITLE&lt;/b&gt; : 생성된 이슈 제목을 저장&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;BRANCH_NAME&lt;/b&gt; : [이슈 번호]_[필터링 된 이슈 제목] 형태로 브랜치 이름을 생성 &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;(ex. 이슈 테스트&amp;nbsp;&amp;rarr; #1_이슈-테스트)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;git checkout feature &amp;amp; git pull origin featrue&lt;/b&gt; : feature 브랜치로 이동 후 최신 상태로 업데이트&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;git checkout -b &quot;$BRANCH_NAME&quot;&lt;/b&gt; : 생성한 브랜치 이름으로 브랜치 생성&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;git push origin &quot;$BRANCH_NAME&quot;&lt;/b&gt; : 원격 저장소에 생성한 브랜치를 푸시(push)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 적용도 끝났으니 직접 이슈를 생성해 보면서 테스트해 보겠습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1490&quot; data-origin-height=&quot;728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kB7UD/btsLM1tbFfx/x4NBx6XQn05g5FrsIjMgA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kB7UD/btsLM1tbFfx/x4NBx6XQn05g5FrsIjMgA0/img.png&quot; data-alt=&quot;테스트 이슈 생성&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kB7UD/btsLM1tbFfx/x4NBx6XQn05g5FrsIjMgA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkB7UD%2FbtsLM1tbFfx%2Fx4NBx6XQn05g5FrsIjMgA0%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;1490&quot; height=&quot;728&quot; data-origin-width=&quot;1490&quot; data-origin-height=&quot;728&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;테스트 이슈 생성&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;355&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpCkXh/btsLM9xP4Tp/js317nkwmliWlXBtRXL1t1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpCkXh/btsLM9xP4Tp/js317nkwmliWlXBtRXL1t1/img.png&quot; data-alt=&quot;이슈 생성 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpCkXh/btsLM9xP4Tp/js317nkwmliWlXBtRXL1t1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpCkXh%2FbtsLM9xP4Tp%2Fjs317nkwmliWlXBtRXL1t1%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;355&quot; height=&quot;420&quot; data-origin-width=&quot;355&quot; data-origin-height=&quot;420&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이슈 생성 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이슈를 생성했지만, 브랜치는 생성되지 않았습니다.. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;수 차례 시도에도 이슈만 생성되고 브랜치는 생성되지 않는데 뭐가 문제일까요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;포기하는 마음으로 Actions 탭을 확인해 보니, 이슈 생성에 대한 에러를 확인할 수 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;848&quot; data-origin-height=&quot;639&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/w3zho/btsLOZNYaQM/OvokhC48VWnl36QrT3rq0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/w3zho/btsLOZNYaQM/OvokhC48VWnl36QrT3rq0K/img.png&quot; data-alt=&quot;Create Issue Branch 에러&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/w3zho/btsLOZNYaQM/OvokhC48VWnl36QrT3rq0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fw3zho%2FbtsLOZNYaQM%2FOvokhC48VWnl36QrT3rq0K%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;848&quot; height=&quot;639&quot; data-origin-width=&quot;848&quot; data-origin-height=&quot;639&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Create Issue Branch 에러&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1736920802158&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;with:
  token: ${{ secrets.TOKEN }}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;token으로 secrets.TOKEN을 사용했는데, 정작 TOKEN을 설정해주지 않았네요.&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Token은 &lt;a href=&quot;https://github.com/settings/tokens&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;GitHub Personal Access Token 설정&lt;/a&gt;&lt;a href=&quot;https://github.com/settings/tokens&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt; 페이지&lt;/a&gt;에서 생성할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;TOKEN이라는 이름으로 토큰을 생성하고 repository에 추가해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;810&quot; data-origin-height=&quot;446&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4q1B1/btsLPzaamOv/XLisXwzJ0fnJ8SdSq9IVek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4q1B1/btsLPzaamOv/XLisXwzJ0fnJ8SdSq9IVek/img.png&quot; data-alt=&quot;github personal access token 생성&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4q1B1/btsLPzaamOv/XLisXwzJ0fnJ8SdSq9IVek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4q1B1%2FbtsLPzaamOv%2FXLisXwzJ0fnJ8SdSq9IVek%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;810&quot; height=&quot;446&quot; data-origin-width=&quot;810&quot; data-origin-height=&quot;446&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;github personal access token 생성&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1123&quot; data-origin-height=&quot;704&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dj3FaS/btsLO60rNAn/rzB02aAqm7ovKhoaxkUH8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dj3FaS/btsLO60rNAn/rzB02aAqm7ovKhoaxkUH8k/img.png&quot; data-alt=&quot;토큰 생성 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dj3FaS/btsLO60rNAn/rzB02aAqm7ovKhoaxkUH8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdj3FaS%2FbtsLO60rNAn%2FrzB02aAqm7ovKhoaxkUH8k%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;782&quot; height=&quot;490&quot; data-origin-width=&quot;1123&quot; data-origin-height=&quot;704&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;토큰 생성 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;TOKEN 적용도 끝났으니 다시 이슈 생성 테스트를 해보겠습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;486&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxPXuH/btsLPzaaxtj/UyWglWWtsb7GSTXfwjijg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxPXuH/btsLPzaaxtj/UyWglWWtsb7GSTXfwjijg0/img.png&quot; data-alt=&quot;Create Issue Branch Retry&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxPXuH/btsLPzaaxtj/UyWglWWtsb7GSTXfwjijg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcxPXuH%2FbtsLPzaaxtj%2FUyWglWWtsb7GSTXfwjijg0%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;894&quot; height=&quot;486&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;486&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Create Issue Branch Retry&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;341&quot; data-origin-height=&quot;458&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Z0Zxj/btsLNBt5PY3/afboaRKKcb9WbVaBmNgbl0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Z0Zxj/btsLNBt5PY3/afboaRKKcb9WbVaBmNgbl0/img.png&quot; data-alt=&quot;이슈 생성 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Z0Zxj/btsLNBt5PY3/afboaRKKcb9WbVaBmNgbl0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZ0Zxj%2FbtsLNBt5PY3%2FafboaRKKcb9WbVaBmNgbl0%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;341&quot; height=&quot;458&quot; data-origin-width=&quot;341&quot; data-origin-height=&quot;458&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이슈 생성 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이슈를 생성하니 [이슈 번호]_[이슈 타이틀] 형태로 브랜치가 잘 생성되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;앞으로 진행할 팀 프로젝트에 적용하면 매번 이슈에 해당하는 브랜치를 직접 생성하는 번거로움이 사라지겠네요✨&lt;/span&gt;&lt;/p&gt;</description>
      <category>Git</category>
      <category>create issue branch</category>
      <category>Git</category>
      <category>GitHub</category>
      <category>github action</category>
      <category>자동화</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/166</guid>
      <comments>https://dev-hpk.tistory.com/166#entry166comment</comments>
      <pubDate>Wed, 15 Jan 2025 15:23:24 +0900</pubDate>
    </item>
    <item>
      <title>[CS] SQL Injection(SQL 삽입)이란?</title>
      <link>https://dev-hpk.tistory.com/165</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;539&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cq8f2d/btsLMaoR9Ep/YuUPvK9eGMJNECTqEMRiKK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cq8f2d/btsLMaoR9Ep/YuUPvK9eGMJNECTqEMRiKK/img.jpg&quot; data-alt=&quot;SQL Injection 커버 이미지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cq8f2d/btsLMaoR9Ep/YuUPvK9eGMJNECTqEMRiKK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcq8f2d%2FbtsLMaoR9Ep%2FYuUPvK9eGMJNECTqEMRiKK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;729&quot; height=&quot;384&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;539&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;SQL Injection 커버 이미지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;SQL Injection(SQL 삽입)&lt;/b&gt; &lt;/span&gt;&lt;/h3&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;SQL Injection은 공격자가 애플리케이션의 데이터베이스를 악용하기 위해 SQL 쿼리를 조작하는 보안 취약점입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;공격자가 입력 필드나 URL 매개변수를 통해 악의적인 SQL 코드를 삽입하여 데이터베이스의 민감한 정보를 탈취하거나, 데이터를 삭제 및 수정하며, 심지어 시스템을 제어하는 행위로 이어질 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;SQL&lt;/b&gt; &lt;/span&gt;&lt;/h3&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;SQL(Structured Query Language)은 &lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;관계형 데이터베이스 시스템에서 자료를 관리 및 처리하기 위해 설계된 언어&lt;/span&gt;입니다. SQL은 데이터베이스의 데이터를 조회, 삽입, 수정, 삭제하는 데 사용되며, 관계형 데이터베이스 관리 시스템(RDBMS)에서 핵심적인 역할을 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt; SQL Injection 유형 및 동작 원리&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;SQL Injection은 다양한 형태로 발생할 수 있으며, 각각 고유한 동작 방식과 피해를 발생시킵니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;1. Error based SQL Injection&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;가장 일반적인 형태로 사용자 입력을 통해 악의적인 쿼리를 삽입하는 방식입니다. 이 유형의 동작 원리는 사용자가 입력한 값이 그대로 SQL 쿼리에 삽입될 때 발생합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1736752411389&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM users WHERE username = 'admin' AND password = 'password';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;위 쿼리가 아래와 같은 Spring 코드로 작성되었다고 생각해 봅시다 &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736752368452&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;String username = request.getParameter(&quot;username&quot;);
String password = request.getParameter(&quot;password&quot;);
String query = &quot;SELECT * FROM users WHERE username = '&quot; + username + &quot;' AND password = '&quot; + password + &quot;';&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;공격자는 아래와 같은 방법으로 공격할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;1️⃣ username 필드에 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;' OR 1=1 --&lt;/span&gt; 를 입력합니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;2️⃣ 실제 실행되는 쿼리가 다음과 같이 변경됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;3️⃣ SELECT * FROM users WHERE username = ' ' OR 1=1 --AND password = ' ';&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;WHERE절에 있는 싱글 쿼터('')를 닫아주게 되고, OR 1=1로 WHERE절을 참을 만듭니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt; --를 이용해 &lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;뒤의 모든 쿼리문을 주석 처리합니다.&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;4️⃣&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;결과적으로 &lt;/span&gt;SELECT * FROM users를 통해&lt;b&gt; users 테이블에 있는 모든 user 정보에 대해 접근할 수 있습니다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-pm-slice=&quot;1 1 []&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;2. Blind&lt;/b&gt;&amp;nbsp;&lt;b&gt;SQL Injection&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;공격자가 데이터베이스에서 반환된 결과를 직접 확인할 수 없을 때 발생합니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;데이터 베이스로부터 특정한 값이나 데이터를 전달받지 않고, 단순히 &lt;/span&gt;참/거짓을 판단하는 쿼리를 이용하여 서버의 데이터를 추론&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;하는 공격 기법입니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;공격자는 테이블에 특정 컬럼이 존재하는지 확인합니다. 이를 위해 다음과 같은 참/거짓 쿼리를 사용합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt; 1️⃣ 컬럼 존재 여부 확인&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736754150622&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM users WHERE id = 1 AND EXISTS (SELECT column_name FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'username');&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-pm-slice=&quot;3 3 []&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;결과가 참이면 username 컬럼이 존재함을 의미합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;결과가 거짓이면 username 컬럼이 없음을 나타냅니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;2️⃣ 결과가 거짓이라면 컬럼 이름을 추론하는 과정을 아래와 같이 반복합니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736754233942&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM users WHERE id = 1 AND EXISTS (SELECT column_name FROM information_schema.columns WHERE table_name = 'users' AND column_name LIKE 'a%');&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-pm-slice=&quot;3 3 []&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;LIKE 'a%'는 컬럼 이름이 a로 시작하는지 확인합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;공격자는 알파벳을 한 글자씩 변경하며 컬럼 이름 전체를 추론합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;3️⃣ 컬럼 이름을 식별했다면, 다음과 같은 쿼리를 사용해 해당 컬럼의 데이터를 추출할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736754340071&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT username FROM users WHERE id = 1;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;3. Union-Based&lt;/b&gt;&amp;nbsp;&lt;b&gt;SQL Injection&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;UNION SQL 연산자를 사용하여 여러 쿼리의 결과를 결합하여 데이터를 추출하는 방식입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; UNION 하려는 두 테이블의 컬럼 수와 데이터 형식은 같아야 합니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1736755053157&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT username, password FROM users WHERE id = 1 UNION SELECT version(), database();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;공격자는 users 테이블에서 uswername과 password 컬럼 데이터를 조회하는 SELECT 구문에&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt; 추가&amp;nbsp;정보를 요청하는 쿼리문을 겹합해 보냅니다. 이 방법을 통해 공격자는 &lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;데이터베이스의 추가 정보를 포함한 결과를 획득할 수 있습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot; data-pm-slice=&quot;1 1 []&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt; SQL Injection 방어 방법&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;h4 id=&quot;1-입력값-검증&quot; style=&quot;background-color: #ffffff; color: #24292e; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;1. 입력값 검증&lt;/span&gt;&lt;/h4&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;모든 사용자 입력값을 철저히 검증하여 허용된 값만 처리되도록 합니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #24292e; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;/, &amp;ndash;, &amp;lsquo;, &amp;ldquo;, ?, #, (, ), ;, @, =, +&lt;/span&gt; 와 같은 특수 기호 필터링&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;DB Query에 동적으로 영향을 주는&lt;/span&gt;&amp;nbsp; union, select&amp;nbsp;같은 &lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;명령어 필터링&lt;/span&gt; &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;2. &lt;/span&gt;최소 권한 액세스 시행&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;사용자에게 역할에 필요한 만큼의 &lt;/span&gt;최소한의 권한만 부여해 &lt;span style=&quot;color: #222222;&quot;&gt;SQL Injection 피해를 최소화합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;3. &lt;/span&gt;Prepared Statement 및 매개변수화된 쿼리 사용&lt;/span&gt;&lt;/h4&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;Prepared Statement는 SQL 쿼리의 구조와 값을 분리하여 쿼리를 실행하는 방식입니다. 이를 통해 사용자 입력값이 SQL 코드로 해석되지 않도록 방지합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 3 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;654&quot; data-origin-height=&quot;438&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNgxxK/btsLL7lnuUj/92rcGfwTt6Tz1ZHDcq3N91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNgxxK/btsLL7lnuUj/92rcGfwTt6Tz1ZHDcq3N91/img.png&quot; data-alt=&quot;Prepared Statement 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNgxxK/btsLL7lnuUj/92rcGfwTt6Tz1ZHDcq3N91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNgxxK%2FbtsLL7lnuUj%2F92rcGfwTt6Tz1ZHDcq3N91%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;654&quot; height=&quot;438&quot; data-origin-width=&quot;654&quot; data-origin-height=&quot;438&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Prepared Statement 코드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 3 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;동작 방식&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;SQL 쿼리와 값의 분리:&lt;/b&gt; 쿼리 작성 시 ?와 같은 플레이스홀더를 사용하여 입력값이 삽입될 위치를 지정합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;입력값 바인딩:&lt;/b&gt; 플레이스홀더에 입력값을 매개변수로 바인딩합니다. 이 과정에서 입력값은 SQL 코드가 아닌 데이터로 처리됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;쿼리 실행:&lt;/b&gt; 바인딩된 값이 포함된 쿼리를 안전하게 실행합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>CS</category>
      <category>CS</category>
      <category>Hacking</category>
      <category>sql injection</category>
      <category>sql 삽입</category>
      <category>Web</category>
      <category>보안 취약점</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/165</guid>
      <comments>https://dev-hpk.tistory.com/165#entry165comment</comments>
      <pubDate>Mon, 13 Jan 2025 17:16:24 +0900</pubDate>
    </item>
    <item>
      <title>[CS] CSRF(Cross-Site Request Forgery)란?</title>
      <link>https://dev-hpk.tistory.com/164</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/J6ZRs/btsLKGglX9k/Md9SkkU19sPV018Z74xkA0/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/J6ZRs/btsLKGglX9k/Md9SkkU19sPV018Z74xkA0/img.webp&quot; data-alt=&quot;CSRF(Cross-Site Request Forgrey) 이미지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/J6ZRs/btsLKGglX9k/Md9SkkU19sPV018Z74xkA0/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJ6ZRs%2FbtsLKGglX9k%2FMd9SkkU19sPV018Z74xkA0%2Fimg.webp&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;485&quot; height=&quot;485&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;CSRF(Cross-Site Request Forgrey) 이미지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;CSRF (Cross-Site Request Forgrey)&lt;/b&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;CSRF(Cross-Site Request Forgery)는 공격자가 사용자의 권한을 도용하여 웹 애플리케이션에 비정상적인 요청을 보내는 공격 기법입니다. 사용자(희생자)는 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹 사이트에 요청하게 됩니다.&amp;nbsp;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;송금이나, 비밀번호 변경, 권한이 필요한 작업 등의 요청을 악의적으로 보내겠죠!&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;XSS(Cross-Site-Scripting)와 어떻게 다른가&lt;/b&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;CSRF와 XSS는 웹 애플리케이션의 보안 취약점을 이용하는 공격 기법입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;그럼 둘은 어떤 차이가 있을까요?&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;공격 목적&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;XSS : &lt;span style=&quot;text-align: start;&quot;&gt;사용자 PC에서 스크립트를 실행해 사용자의 정보를 탈취&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;CSRF : 요청을 위조해 사용자(희생자) 몰래 송금, 비밀번호 변경, 권한이 필요한 작업 등 특정 행위를 수행&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;공격 방법&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;XSS :&lt;/b&gt; 입력 폼에 악성 스크립트를 삽입해 사용자의 브라우저에서 스크립트를 실행시키는 방법으로 공격&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;CSRF :&lt;/b&gt; 사용자의 인증 정보(세션 ID)를 악용해 특정 웹 사이트에 공격자가 의도한 행위를 요청해 서버에서 스크립트를 실행시키는 방법으로 공격&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;의존성&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;xss :&lt;/b&gt; 로그인 여부(인증된 세션 여부)와 무관하게 공격이 가능&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;CSRF :&lt;/b&gt; 사용자가 특정 도메인(웹 사이트)에 로그인한 상태(인증된 세션이 있는 상태)여야 공격이 가능&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;지금까지 살펴본 내용으로는 이해가 어려운 부분이 많네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;CSRF 공격 시나리오를 살펴보면서 자세히 알아보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;CSRF 공격 시나리오&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;683&quot; data-origin-height=&quot;425&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHQmFJ/btsLK1YUdNL/evWtnUqBFcEjMz58vJFzFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHQmFJ/btsLK1YUdNL/evWtnUqBFcEjMz58vJFzFk/img.png&quot; data-alt=&quot;CSRF 공격 시나리오&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHQmFJ/btsLK1YUdNL/evWtnUqBFcEjMz58vJFzFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHQmFJ%2FbtsLK1YUdNL%2FevWtnUqBFcEjMz58vJFzFk%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;683&quot; height=&quot;425&quot; data-origin-width=&quot;683&quot; data-origin-height=&quot;425&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;CSRF 공격 시나리오&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;1. 사용자(희생자)가 특정 웹 사이트에 로그인합니다. 이때 서버는 세션 ID를 생성하고 쿠키를 통해 클라이언트에게 전송하고 인증 쿠키는 사용자의 브라우저에 저장됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;2. 공격자가 CSRF 공격을 위한 악성 스크립트가 삽입 된 &lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;웹 사이트나 이메일을 만듭니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;3. 사용자(희생자)가&lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt; 악성 웹 사이트나 이메일에 접속합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;4. 피싱 사이트는 특정 웹 사이트에 공격자가 지정한 악의적인 요청을 보냅니다. 이때 브라우저는 요청에 세션 쿠키(인증 정보)를 포함해 전송합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;5. 서버는 요청이 정상적인 사용자로부터 온 요청으로 간주하고 처리합니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;CSRF는 다수 중 특정 도메인에 로그인한 사람이 있다면 그들 모두가 공격 대상이 되는 공격 방식입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;간단한 예제를 통해 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;CSRF&amp;nbsp; 시연 예제&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt; 서버는 Node.js와 Express를 이용해 간단하게 만들었습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt; 악성 사용자가 사용자(희생자)의 쿠키(인증 정보)를 악용할 수 있는 시나리오를 구현하기 위해 Cookie에 임의로 testCookie라는 값을 저장해 두었습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;599&quot; data-origin-height=&quot;298&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sPV2E/btsLKrRlvRW/BwkMfVUzz8BDloTfypAcyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sPV2E/btsLKrRlvRW/BwkMfVUzz8BDloTfypAcyK/img.png&quot; data-alt=&quot;시연을 위한 테스트 쿠키&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sPV2E/btsLKrRlvRW/BwkMfVUzz8BDloTfypAcyK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsPV2E%2FbtsLKrRlvRW%2FBwkMfVUzz8BDloTfypAcyK%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;599&quot; height=&quot;298&quot; data-origin-width=&quot;599&quot; data-origin-height=&quot;298&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;시연을 위한 테스트 쿠키&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;게시판에 악성 사용자가 무료 React 강의를 등록했다고 가정해 보겠습니다. 다른 강의들은 모두 유료인데 하나만 무료라니 사용자 입장에서 안 누를 수 없겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;895&quot; data-origin-height=&quot;437&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BXWdK/btsLI2Fhoyu/4Ukul2KNk5VKE0FwKwtCDK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BXWdK/btsLI2Fhoyu/4Ukul2KNk5VKE0FwKwtCDK/img.png&quot; data-alt=&quot;악성 사용자가 등록한 악성 게시글&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BXWdK/btsLI2Fhoyu/4Ukul2KNk5VKE0FwKwtCDK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBXWdK%2FbtsLI2Fhoyu%2F4Ukul2KNk5VKE0FwKwtCDK%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;895&quot; height=&quot;437&quot; data-origin-width=&quot;895&quot; data-origin-height=&quot;437&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;악성 사용자가 등록한 악성 게시글&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;게시글을 클릭해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;게시판-Chrome-2025-01-10-16-16-59.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dX6rjf/btsLKnVQtqJ/AW0oHglLL1My0vrBASUafK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dX6rjf/btsLKnVQtqJ/AW0oHglLL1My0vrBASUafK/img.gif&quot; data-alt=&quot;악성 게시글 클릭&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dX6rjf/btsLKnVQtqJ/AW0oHglLL1My0vrBASUafK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/dX6rjf/btsLKnVQtqJ/AW0oHglLL1My0vrBASUafK/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;게시판-Chrome-2025-01-10-16-16-59.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;악성 게시글 클릭&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt; 링크를 클릭해 사이트로 이동하면 쿠키에 등록된 testCookie가 나타나고 있네요  &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt; 지금은 간단한 시연 예시를 위해 테스트로 저장해둔 쿠키를 화면에 나타냈지만, 실제 CSRF 공격이었다면 생각만 해도 아찔하죠..  &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;CSRF&amp;nbsp; 공격 방지를 위해 사용자가 할 수 있는 노력&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;사용자가 CSRF 공격을 직접 막을 수는 없지만 피해를 예방하기 위해 아래와 같은 노력을 해 볼 수 있습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;의심스러운 링크&amp;nbsp; 클릭하지 않기 : 출처가 불분명한 웹 사이트의 링크는 클릭하지 않도록 합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;로그아웃 습관화 : 웹 사이트 사용을 종료하면 로그아웃을 통해 세션을 종료합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;동시에 여러 웹 사이트 사용하지 않기 : &lt;span style=&quot;text-align: start;&quot;&gt;여러 웹사이트를 동시에 사용하는 경우 현재 화면에 표시되지 않는 웹사이트에서 크로스 사이트 요청 위조 공격이 발생하고 있다는 사실을 알아차리지 못할 수 있습니다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;XSS&amp;nbsp; 공격 방지 방법&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;h4 id=&quot;--%--%EB%AC%B-%EC%-E%--%EC%--%B-%--%ED%--%--%ED%--%B-%EB%A-%--&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;1. Referer 검증&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;요청의 Referer 헤더를 확인해 신뢰할 수 있는 출처에서 온 요청인지 검증합니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Referer 요청 헤더는 현재 요청을 보낸 페이지의 절대 혹은 부분 주소를 포함합니다. 만약 링크를 타고 들어왔다면 해당 링크를 포함하고 있는 페이지의 주소가, 다른 도메인에 리소스 요청을 보내는 경우라면 해당 리소스를 사용하는 페이지의 주소가 이 헤더에 포함됩니다.&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/Referer&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;출처: MDN&lt;/a&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2. SameSite&amp;nbsp;쿠키&amp;nbsp;설정&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-pm-slice=&quot;1 1 [&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:1677,&amp;quot;end&amp;quot;:1749},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:1677,&amp;quot;end&amp;quot;:1749}]&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;SameSite 속성을 Lax 또는 Strict로 설정하여, 크로스사이트 요청에 쿠키가 포함되지 않도록 설정합니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-pm-slice=&quot;1 1 [&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:1677,&amp;quot;end&amp;quot;:1749},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:1677,&amp;quot;end&amp;quot;:1749}]&quot; data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;SameSite 속성은 사이트 간 요청과 함께 쿠키가 전송될지를 제어하여 사이트 간 요청 위조 공격(CSRF)에 대한 일부 보호를 제공합니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Strict : 쿠키를 설정한 동일한 사이트에서 발생하는 요청에만 쿠키를 전송합니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Lax : 이미지 또는 프레임을 불러오는 요청과 같은 사이트 간 요청은 쿠키가 전송되지 않는 것을 의미합니다. 하지만 사용자가 링크를 따라갈 때처럼 외부 사이트에서 원래 사이트로 이동할 때는 쿠키를 전송합니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;출처 : MDN&lt;/a&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;3.&amp;nbsp; CSRF&amp;nbsp;토큰&amp;nbsp;사용&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-pm-slice=&quot;1 1 [&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:1574,&amp;quot;end&amp;quot;:1649},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:1574,&amp;quot;end&amp;quot;:1619}]&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;모든 민감한 요청에 대해 CSRF 토큰을 포함하고, 서버에서 이를 검증합니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-pm-slice=&quot;1 1 [&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:1574,&amp;quot;end&amp;quot;:1649},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:1574,&amp;quot;end&amp;quot;:1619}]&quot; data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;CSRF 토큰은 서버에 들어온 요청이 실제 서버에서 허용한 요청이 맞는지 확인하기 위한 토큰입니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;CSRF 토큰은 무작위로 생성된 고유한 값입니다. 이 토큰은 사용자의 브라우저에 저장되며, 사용자가 웹 사이트에 요청을 보낼 때마다 서버에 전송됩니다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #3d4144; text-align: start;&quot;&gt;요청하는 페이지에&amp;nbsp;&lt;/span&gt;hidden&lt;span style=&quot;background-color: #ffffff; color: #3d4144; text-align: start;&quot;&gt;&amp;nbsp;타입 input 태그를 이용해 토큰 값을 함께 전달하면 서버에서 세션에 저장된 CSRF 토큰 값과 요청 파라미터에 담긴 토큰 값을 비교한다고 합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #263238; color: #eeffff; text-align: start;&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// 세션에 설정
session.setAttribute(&quot;CSRF_TOKEN&quot;, UUID.randomUUID().toString());
// 페이지 내 hidden 값으로 설정
model.addAttribute(&quot;CSRF_TOKEN&quot;, session.getAttribute(&quot;CSRF_TOKEN&quot;));&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;html xml&quot; style=&quot;background-color: #263238; color: #eeffff; text-align: start;&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;&amp;lt;form action=&quot;http://server-host:port/path&quot; method=&quot;POST&quot;&amp;gt;
    &amp;lt;input type=&quot;hidden&quot; name=&quot;_csrf&quot; value=&quot;${CSRF_TOKEN}&quot;/&amp;gt;
    &amp;lt;!-- ... --&amp;gt;
&amp;lt;/form&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1736495338490&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[CS] 반사형 XSS (Reflected Cross-Site-Scripting)&quot; data-og-description=&quot;반사형 XSS (Reflected Cross-Site-Scripting) 악성 사용자가 악성 스크립트가 담긴 URL을 만들어 일반 사용자에게 전달하는 패턴입니다.&amp;nbsp;악성 사용자는 URL 주소 뒤에 붙은 쿼리에 악성 스크립트를 작성&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/163&quot; data-og-url=&quot;https://dev-hpk.tistory.com/163&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cEzN06/hyX0sJ5R9J/36hAWojV02Nwkg4fzJ1HU0/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/k4q8a/hyXWCHEyQU/O9olMrI7h19DRttv2W7PMk/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/jUXxM/hyXWC8Dw2U/D3I3hJ1QxNPWNj001sPGy1/img.png?width=1140&amp;amp;height=602&amp;amp;face=0_0_1140_602&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/163&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/163&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cEzN06/hyX0sJ5R9J/36hAWojV02Nwkg4fzJ1HU0/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/k4q8a/hyXWCHEyQU/O9olMrI7h19DRttv2W7PMk/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/jUXxM/hyXWC8Dw2U/D3I3hJ1QxNPWNj001sPGy1/img.png?width=1140&amp;amp;height=602&amp;amp;face=0_0_1140_602');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[CS] 반사형 XSS (Reflected Cross-Site-Scripting)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;반사형 XSS (Reflected Cross-Site-Scripting) 악성 사용자가 악성 스크립트가 담긴 URL을 만들어 일반 사용자에게 전달하는 패턴입니다.&amp;nbsp;악성 사용자는 URL 주소 뒤에 붙은 쿼리에 악성 스크립트를 작성&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1736495345494&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[CS] 저장형 XSS (Stored Cross-Site-Scripting)&quot; data-og-description=&quot;저장형 XSS (Stored Cross-Site-Scripting) 저장형 XSS는 공격자가 악성 스크립트를 포함한 데이터를 서버에 보내 저장하고,&amp;nbsp;이후 사용자가 해당 데이터를 요청하면 서버에 저장된 악성 스크립트가 사&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/162&quot; data-og-url=&quot;https://dev-hpk.tistory.com/162&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/r154x/hyXWrMQQQP/uSexA6Gj4SlIAofDTLiLiK/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/3zyXA/hyX0n9RZqU/ZfGtgJeCeBp3aAwqXZRMZ1/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dMBRO2/hyX0p0UFm8/2snTtiXNuay6kHNYbiu8L1/img.png?width=1280&amp;amp;height=579&amp;amp;face=0_0_1280_579&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/162&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/162&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/r154x/hyXWrMQQQP/uSexA6Gj4SlIAofDTLiLiK/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/3zyXA/hyX0n9RZqU/ZfGtgJeCeBp3aAwqXZRMZ1/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dMBRO2/hyX0p0UFm8/2snTtiXNuay6kHNYbiu8L1/img.png?width=1280&amp;amp;height=579&amp;amp;face=0_0_1280_579');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[CS] 저장형 XSS (Stored Cross-Site-Scripting)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;저장형 XSS (Stored Cross-Site-Scripting) 저장형 XSS는 공격자가 악성 스크립트를 포함한 데이터를 서버에 보내 저장하고,&amp;nbsp;이후 사용자가 해당 데이터를 요청하면 서버에 저장된 악성 스크립트가 사&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>CS</category>
      <category>cross-site request forgrey</category>
      <category>CS</category>
      <category>CSRF</category>
      <category>Hacking</category>
      <category>Web</category>
      <category>보안 취약점</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/164</guid>
      <comments>https://dev-hpk.tistory.com/164#entry164comment</comments>
      <pubDate>Sat, 11 Jan 2025 12:33:27 +0900</pubDate>
    </item>
    <item>
      <title>[CS] 반사형 XSS (Reflected Cross-Site-Scripting)</title>
      <link>https://dev-hpk.tistory.com/163</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9job3/btsLIjUi8AX/i8gkUVdaSR8uONYWRi8CUK/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9job3/btsLIjUi8AX/i8gkUVdaSR8uONYWRi8CUK/img.webp&quot; data-alt=&quot;반사형 XSS 이미지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9job3/btsLIjUi8AX/i8gkUVdaSR8uONYWRi8CUK/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9job3%2FbtsLIjUi8AX%2Fi8gkUVdaSR8uONYWRi8CUK%2Fimg.webp&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;504&quot; height=&quot;504&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;반사형 XSS 이미지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 id=&quot;HEADING_2_4&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;반사형 XSS (Reflected Cross-Site-Scripting) &lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;악성 사용자가 악성 스크립트가 담긴 URL을 만들어 일반 사용자에게 전달하는 패턴입니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;악성 사용자는 URL 주소 뒤에 붙은 쿼리에 악성 스크립트를 작성하여 전달합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 반사형 XSS는 URL 주소에 포함된 코드를 응답 HTML에 그대로 출력하기 때문에 반사형 XSS라고 합니다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;URL 주소에 악성&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt; 스크립트가 포함된 경우에만 발생하기 때문에 지속성이 없어 비지속성 XSS라고도 합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;반사형 XSS&amp;nbsp; 공격 시나리오&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;602&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KH1Kh/btsLHT9F7zM/KHd3nhejK9cDbLZkyL5eO1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KH1Kh/btsLHT9F7zM/KHd3nhejK9cDbLZkyL5eO1/img.png&quot; data-alt=&quot;반사형 xss 공격 시나리오&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KH1Kh/btsLHT9F7zM/KHd3nhejK9cDbLZkyL5eO1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKH1Kh%2FbtsLHT9F7zM%2FKHd3nhejK9cDbLZkyL5eO1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1140&quot; height=&quot;602&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;602&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;반사형 xss 공격 시나리오&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; background-color: #ffffff; color: #3d4144; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;악성 사용자가 보안이 취약한 사이트에서 사용자 정보를 빼돌릴 수 있는 스크립트가 담긴 URL을 만들어 일반 사용자에게 메일로 전달합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;사용자는 메일을 통해 전달받은 URL 링크를 클릭합니다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;사용자 브라우저에서 보안이 취약한 사이트로 요청을 전달합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;사용자의 브라우저에서 응답 메시지를 실행하면서 악성 스크립트가 실행됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;악성 스크립트를 통해 사용자 정보가 악의적인 사용자에게 전달됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-pm-slice=&quot;1 1 []&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;이 공격은 사용자가 URL을 누른 것 만으로 실행될 수 있어 위험하지만, 그 피해가 URL을 클릭한 사용자에게만 제한되며 URL을 클릭하지 않으면 지속성이 없습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-pm-slice=&quot;1 1 []&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-pm-slice=&quot;1 1 []&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;간단한 예제를 통해 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-pm-slice=&quot;1 1 []&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;저장형 XSS&amp;nbsp; 시연 예제&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt; 서버는 Node.js와 Express를 이용해 간단하게 만들었습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt; 악성 사용자가 유저의 정보를 탈취하는 시나리오를 구현하기 위해 localStorage에 임의로 userToken이라는 값을 저장해 두었습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;411&quot; data-origin-height=&quot;79&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3KVUZ/btsLIonxwiP/7h5jWHrHF7Fv3psGcxqN71/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3KVUZ/btsLIonxwiP/7h5jWHrHF7Fv3psGcxqN71/img.png&quot; data-alt=&quot;localStorage에 저장된 유저 토큰&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3KVUZ/btsLIonxwiP/7h5jWHrHF7Fv3psGcxqN71/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3KVUZ%2FbtsLIonxwiP%2F7h5jWHrHF7Fv3psGcxqN71%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;411&quot; height=&quot;79&quot; data-origin-width=&quot;411&quot; data-origin-height=&quot;79&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;localStorage에 저장된 유저 토큰&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;악성 유저가 보안이 취약한 사이트에 &lt;span style=&quot;text-align: start;&quot;&gt;로컬 스토리지의 userToken을 alert 하는 코드를 전송합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1009&quot; data-origin-height=&quot;421&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bo6BXS/btsLG5vGUva/9dGckQDBQ7zV48C6JnXO5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bo6BXS/btsLG5vGUva/9dGckQDBQ7zV48C6JnXO5k/img.png&quot; data-alt=&quot;XSS 시연&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bo6BXS/btsLG5vGUva/9dGckQDBQ7zV48C6JnXO5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbo6BXS%2FbtsLG5vGUva%2F9dGckQDBQ7zV48C6JnXO5k%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;1009&quot; height=&quot;421&quot; data-origin-width=&quot;1009&quot; data-origin-height=&quot;421&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;XSS 시연&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1736415631077&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;img src=&quot;&quot; onerror=&quot;alert(localStorage.getItem('userToken'))&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt;src가 없는 이미지 태그를 서버에 전송해 임의로 onerror 이벤트가 발생하게 되어 로컬 스토리지의 userToken이 alert 하도록 하는 코드네요  &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1195&quot; data-origin-height=&quot;381&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Kf08B/btsLJAmYdQp/dFuYe2EbpM8zA1nE2mB7EK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Kf08B/btsLJAmYdQp/dFuYe2EbpM8zA1nE2mB7EK/img.png&quot; data-alt=&quot;로컬 스토리지의 토큰을 alert하는 URL&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Kf08B/btsLJAmYdQp/dFuYe2EbpM8zA1nE2mB7EK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKf08B%2FbtsLJAmYdQp%2FdFuYe2EbpM8zA1nE2mB7EK%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;1195&quot; height=&quot;381&quot; data-origin-width=&quot;1195&quot; data-origin-height=&quot;381&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;로컬 스토리지의 토큰을 alert하는 URL&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;사용자의 로컬 스토리지에 저장된 토큰을 alert하는 스크립트를 포함한 URL을 만들었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;해당 URL은 query에 script가 노출되어 있으니 사용자가 알아보지 못하게 간단한 주소로 바꿔 볼게요❗&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1341&quot; data-origin-height=&quot;307&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ckh2SN/btsLH95fi7P/U7qZOkP9VqakNmJ1DeLxR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ckh2SN/btsLH95fi7P/U7qZOkP9VqakNmJ1DeLxR0/img.png&quot; data-alt=&quot;URL 단축 전&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ckh2SN/btsLH95fi7P/U7qZOkP9VqakNmJ1DeLxR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fckh2SN%2FbtsLH95fi7P%2FU7qZOkP9VqakNmJ1DeLxR0%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;1341&quot; height=&quot;307&quot; data-origin-width=&quot;1341&quot; data-origin-height=&quot;307&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;URL 단축 전&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1321&quot; data-origin-height=&quot;289&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NNpgt/btsLJEpnbXN/cjLEYQ9r7ky3kJje20fAx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NNpgt/btsLJEpnbXN/cjLEYQ9r7ky3kJje20fAx0/img.png&quot; data-alt=&quot;단축 후 URL&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NNpgt/btsLJEpnbXN/cjLEYQ9r7ky3kJje20fAx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNNpgt%2FbtsLJEpnbXN%2FcjLEYQ9r7ky3kJje20fAx0%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;1321&quot; height=&quot;289&quot; data-origin-width=&quot;1321&quot; data-origin-height=&quot;289&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;단축 후 URL&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;reflect-_1_.gif&quot; data-origin-width=&quot;1654&quot; data-origin-height=&quot;896&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ApPnz/btsLJh84adM/yKA9InpQ1LWX5FfgiomVh0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ApPnz/btsLJh84adM/yKA9InpQ1LWX5FfgiomVh0/img.gif&quot; data-alt=&quot;Reflect XSS 시연&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ApPnz/btsLJh84adM/yKA9InpQ1LWX5FfgiomVh0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/ApPnz/btsLJh84adM/yKA9InpQ1LWX5FfgiomVh0/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;1654&quot; height=&quot;896&quot; data-filename=&quot;reflect-_1_.gif&quot; data-origin-width=&quot;1654&quot; data-origin-height=&quot;896&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Reflect XSS 시연&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt;링크를 클릭해 사이트로 이동하면 userToken이 alert으로 나타나고 있네요  &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt; 지금은 간단한 시연 예시를 위해 로컬 스토리지에 저장된 토큰을 alert으로 나타냈지만, 쿠키나 사용자의 입력을 탈취해 악성 유저에게 전송하는 코드가 있었다면 생각만 해도 아찔하죠..  &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;XSS&amp;nbsp; 공격 방지 기법&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;h4 id=&quot;--%--%EB%AC%B-%EC%-E%--%EC%--%B-%--%ED%--%--%ED%--%B-%EB%A-%--&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;1. 문자열 필터링&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;XSS 공격은 스크립트를 삽입하는 방식으로 발생합니다. 따라서 스크립트 태그에 자주 사용되는 &amp;lt;&amp;nbsp;, &amp;gt;과 같은 문자를 필터링해 주는 방법으로 방어할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;예시&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736419518255&quot; class=&quot;javascript&quot; style=&quot;background-color: #000000; color: #000000; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;function filterInput(input) {
    return input.replace(/[&amp;lt;&amp;gt;&amp;amp;&quot;']/g, function(match) {
        return {
            '&amp;lt;': '&amp;amp;lt;',
            '&amp;gt;': '&amp;amp;gt;',
            '&amp;amp;': '&amp;amp;amp;',
            '&quot;': '&amp;amp;quot;',
            &quot;'&quot;: '&amp;amp;#x27;'
        }[match];
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;2.&amp;nbsp;콘텐츠&amp;nbsp;보안&amp;nbsp;정책&amp;nbsp;(CSP)&amp;nbsp;적용&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;a style=&quot;color: #333333;&quot; href=&quot;https://developer.mozilla.org/ko/docs/Web/HTTP/CSP&quot;&gt;CSP (Content Security Policy)&lt;/a&gt;를 설정하여 외부 스크립트나 인라인 스크립트의 실행을 제한합니다. 이를 통해 악성 스크립트가 실행되지 않도록 방어할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;HTTP 헤더를 사용한 CSP 지정&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736419518258&quot; class=&quot;pgsql&quot; style=&quot;background-color: #000000; color: #000000; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;/* 사이트 자체의 출처(하위 도메인 제외) 콘텐츠만 허용 */
Content-Security-Policy: default-src 'self'
/* 신뢰할 수 있는 도메인 및 모든 하위 도메인의 콘텐츠를 허용 */
Content-Security-Policy: default-src 'self' example.com *.example.com&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;meta 태그를 사용한 CSP 지정&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736419518259&quot; class=&quot;csp&quot; style=&quot;background-color: #000000; color: #000000; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;&amp;lt;meta
  http-equiv=&quot;Content-Security-Policy&quot;
  content=&quot;default-src 'self'; img-src https://*; child-src 'none';&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-pm-slice=&quot;1 3 []&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;3. 데이터베이스 필터링&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-pm-slice=&quot;1 3 []&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;데이터베이스에 저장하기 전에 입력값을 정규화하거나 필터링하여 악성 코드가 저장되지 않도록 방지합니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-pm-slice=&quot;1 3 []&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;4.&lt;/b&gt;&amp;nbsp;&lt;b&gt;HttpOnly 쿠키 사용&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-pm-slice=&quot;1 1 [&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970}]&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;세션 쿠키에 HttpOnly 속성을 설정하여 JavaScript를 통한 쿠키 접근을 차단합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-pm-slice=&quot;1 1 [&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970}]&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1736419688252&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[CS] XSS(Cross Site Scripting)란?&quot; data-og-description=&quot;XSS(Cross Site Scripting)는 웹 해킹 공격 중 하나입니다. 웹 서버 사용자에 대한 입력값 검증이 미흡할 때 발생하는 취약점으로, 주로 여러 사용자가 보는 게시판이나 메일 등을 통해 악성 스크립트(Ja&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/161&quot; data-og-url=&quot;https://dev-hpk.tistory.com/161&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/d27eKd/hyXWBV5IAj/9rIWgUTcR2iqjFK0MbF4Ck/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/4A065/hyXWnwM8uf/jGJoLoIS4BkF3OtsPAmNbk/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/biZmLf/hyXWr0fwxJ/h2weK7dG8TOg9qfkx27ZF0/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/161&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/161&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/d27eKd/hyXWBV5IAj/9rIWgUTcR2iqjFK0MbF4Ck/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/4A065/hyXWnwM8uf/jGJoLoIS4BkF3OtsPAmNbk/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/biZmLf/hyXWr0fwxJ/h2weK7dG8TOg9qfkx27ZF0/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[CS] XSS(Cross Site Scripting)란?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;XSS(Cross Site Scripting)는 웹 해킹 공격 중 하나입니다. 웹 서버 사용자에 대한 입력값 검증이 미흡할 때 발생하는 취약점으로, 주로 여러 사용자가 보는 게시판이나 메일 등을 통해 악성 스크립트(Ja&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1736419696754&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[CS] 저장형 XSS (Stored Cross-Site-Scripting)&quot; data-og-description=&quot;저장형 XSS (Stored Cross-Site-Scripting) 저장형 XSS는 공격자가 악성 스크립트를 포함한 데이터를 서버에 보내 저장하고,&amp;nbsp;이후 사용자가 해당 데이터를 요청하면 서버에 저장된 악성 스크립트가 사&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/162&quot; data-og-url=&quot;https://dev-hpk.tistory.com/162&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/5w5qv/hyX0y4vwWE/VkCRTkdyufOSdHVqQ8UPVK/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/tcTEy/hyX0kSJpRA/krlp6OIFhSKU95pZTc9VZK/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/coD1wj/hyXWuJsGaG/fUGIyVH6Rv2A7nhEhXlk8K/img.png?width=1280&amp;amp;height=579&amp;amp;face=0_0_1280_579&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/162&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/162&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/5w5qv/hyX0y4vwWE/VkCRTkdyufOSdHVqQ8UPVK/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/tcTEy/hyX0kSJpRA/krlp6OIFhSKU95pZTc9VZK/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/coD1wj/hyXWuJsGaG/fUGIyVH6Rv2A7nhEhXlk8K/img.png?width=1280&amp;amp;height=579&amp;amp;face=0_0_1280_579');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[CS] 저장형 XSS (Stored Cross-Site-Scripting)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;저장형 XSS (Stored Cross-Site-Scripting) 저장형 XSS는 공격자가 악성 스크립트를 포함한 데이터를 서버에 보내 저장하고,&amp;nbsp;이후 사용자가 해당 데이터를 요청하면 서버에 저장된 악성 스크립트가 사&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>CS</category>
      <category>cross-site-scripting</category>
      <category>CS</category>
      <category>Hacking</category>
      <category>Reflected XSS</category>
      <category>Web</category>
      <category>XSS</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/163</guid>
      <comments>https://dev-hpk.tistory.com/163#entry163comment</comments>
      <pubDate>Fri, 10 Jan 2025 14:23:37 +0900</pubDate>
    </item>
    <item>
      <title>[CS] 저장형 XSS (Stored Cross-Site-Scripting)</title>
      <link>https://dev-hpk.tistory.com/162</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NSWED/btsLHBnK7tM/g4vE0lpqQ1XNnPpeinKb11/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NSWED/btsLHBnK7tM/g4vE0lpqQ1XNnPpeinKb11/img.webp&quot; data-alt=&quot;xss(cross-site-scripting) 이미지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NSWED/btsLHBnK7tM/g4vE0lpqQ1XNnPpeinKb11/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNSWED%2FbtsLHBnK7tM%2Fg4vE0lpqQ1XNnPpeinKb11%2Fimg.webp&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;477&quot; height=&quot;477&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;xss(cross-site-scripting) 이미지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 id=&quot;HEADING_2_4&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;저장형 XSS (Stored Cross-Site-Scripting) &lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;저장형 XSS는 공격자가 악성 스크립트를 포함한 데이터를 서버에 보내 저장하고,&amp;nbsp;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;이후 사용자가 해당 데이터를 요청하면 서버에 저장된 악성 스크립트가 사용자 측에서 동작하는&lt;/span&gt;&amp;nbsp;패턴입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;악성 스크립트를 포함하는 데이터가 서버에 저장되므로 저장형 XSS라고 합니다. 저장형 XSS는 반사형 XSS와 달리 한 번의 공격으로 끝나는 것이 아니라 정상적인 요청을 하는 사용자에게도 피해를 줄 수 있어 지속형 XSS라고도 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;서버에서 악성 스크립트를 제거하지 않으면 지속적인 공격이 발생하기 때문에 3가지 XSS 공격 패턴 중 가장 위험한 패턴이라고 볼 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;저장형 XSS&amp;nbsp; 공격 시나리오&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;579&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2VLVc/btsLHQ5Xg4y/JoHewfQFik8NPfDQkn90nk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2VLVc/btsLHQ5Xg4y/JoHewfQFik8NPfDQkn90nk/img.png&quot; data-alt=&quot;저장형 xss 공격 시나리오&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2VLVc/btsLHQ5Xg4y/JoHewfQFik8NPfDQkn90nk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2VLVc%2FbtsLHQ5Xg4y%2FJoHewfQFik8NPfDQkn90nk%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;1280&quot; height=&quot;579&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;579&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;저장형 xss 공격 시나리오&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; background-color: #ffffff; color: #3d4144; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;악성 사용자가 보안이 취약한 사이트에 악성 스크립트를 포함한 데이터를 전송합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;보안이 취약한 사이트는 악성 스크립트가 포함된 데이터를 DB에 저장합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;사용자는 악성 사용자가 전송한 데이터, 즉 악성 스크립트가 포함된 데이터를 서버에 요청합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: left; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;서버는 악성 스크립트가 포함된 응답 메시지를 브라우저에 전달합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;사용자의 브라우저에서 응답 메시지를 실행하면서 악성 스크립트가 실행됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;악성 스크립트를 통해 사용자 정보가 악의적인 사용자에게 전달됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;이 공격은 사용자가 별다른 행동을 하지 않아도 페이지 방문만으로 실행될 수 있어, 다른 유형의 XSS보다 더 위험합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;간단한 예제를 통해 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;저장형 XSS&amp;nbsp; 시연 예제&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;서버는 Node.js와 Express를 이용해 간단하게 만들었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;서버에서 가져온 데이터를 이용해&amp;nbsp;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;페이지를 동적으로 만들기 위해&amp;nbsp;&lt;a style=&quot;color: #333333;&quot; href=&quot;https://ejs.co/&quot;&gt;EJS(Embedded&amp;nbsp;JavaScript&amp;nbsp;templates)&lt;/a&gt;를 사용했습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;악성 사용자가 유저의 정보를 탈취하는 시나리오를 구현하기 위해 localStorage에 임의로 userToken이라는 값을 저장해 두었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;340&quot; data-origin-height=&quot;77&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/osGxW/btsLJAG6pCc/cobYm2tEotFtowkUAU1aG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/osGxW/btsLJAG6pCc/cobYm2tEotFtowkUAU1aG0/img.png&quot; data-alt=&quot;localStorage에 저장된 유저 토큰&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/osGxW/btsLJAG6pCc/cobYm2tEotFtowkUAU1aG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FosGxW%2FbtsLJAG6pCc%2FcobYm2tEotFtowkUAU1aG0%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;340&quot; height=&quot;77&quot; data-origin-width=&quot;340&quot; data-origin-height=&quot;77&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;localStorage에 저장된 유저 토큰&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;저장형-XSS-테스트-Chrome-2025-01-09-16-18-59.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rmSz8/btsLJEW4qUR/F6rqD25tYubUgAypXCDkQk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rmSz8/btsLJEW4qUR/F6rqD25tYubUgAypXCDkQk/img.gif&quot; data-alt=&quot;저장형 XSS 공격 시연&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rmSz8/btsLJEW4qUR/F6rqD25tYubUgAypXCDkQk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/rmSz8/btsLJEW4qUR/F6rqD25tYubUgAypXCDkQk/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;저장형-XSS-테스트-Chrome-2025-01-09-16-18-59.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;저장형 XSS 공격 시연&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;악성 사용자가 input에 입력한 내용은 아래와 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736407890627&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;img src=&quot;&quot; onerror=&quot;alert(localStorage.getItem('userToken'))&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;src가 없는 이미지 태그를 서버에 전송해 임의로 onerror 이벤트가 발생하게 되어 /article을 조회할 때마다 로컬 스토리지의 userToken이 alert으로 나타나고 있네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;지금은 간단한 시연 예시를 위해 로컬 스토리지에 저장된 토큰을 alert으로 나타냈지만, 쿠키나 사용자의 입력을 탈취해 악성 유저에게 전송하는 코드가 있었다면 생각만 해도 아찔하죠.. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #3c4659; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;저장형 XSS&amp;nbsp; 공격 방지 기법&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;h4 id=&quot;--%--%EB%AC%B-%EC%-E%--%EC%--%B-%--%ED%--%--%ED%--%B-%EB%A-%--&quot; style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1. 문자열 필터링&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;XSS 공격은 스크립트를 삽입하는 방식으로 발생합니다. 따라서 스크립트 태그에 자주 사용되는 &amp;lt;&amp;nbsp;, &amp;gt;과 같은 문자를 필터링해 주는 방법으로 방어할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;예시&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736410163084&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function filterInput(input) {
    return input.replace(/[&amp;lt;&amp;gt;&amp;amp;&quot;']/g, function(match) {
        return {
            '&amp;lt;': '&amp;amp;lt;',
            '&amp;gt;': '&amp;amp;gt;',
            '&amp;amp;': '&amp;amp;amp;',
            '&quot;': '&amp;amp;quot;',
            &quot;'&quot;: '&amp;amp;#x27;'
        }[match];
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #000000; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;2.&amp;nbsp;콘텐츠&amp;nbsp;보안&amp;nbsp;정책&amp;nbsp;(CSP)&amp;nbsp;적용&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;a style=&quot;color: #333333;&quot; href=&quot;https://developer.mozilla.org/ko/docs/Web/HTTP/CSP&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;CSP (Content Security Policy)&lt;/a&gt;를 설정하여 외부 스크립트나 인라인 스크립트의 실행을 제한합니다. 이를 통해 악성 스크립트가 실행되지 않도록 방어할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;HTTP 헤더를 사용한 CSP 지정&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736409927688&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/* 사이트 자체의 출처(하위 도메인 제외) 콘텐츠만 허용 */
Content-Security-Policy: default-src 'self'
/* 신뢰할 수 있는 도메인 및 모든 하위 도메인의 콘텐츠를 허용 */
Content-Security-Policy: default-src 'self' example.com *.example.com&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;meta 태그를 사용한 CSP 지정&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736410094106&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;meta
  http-equiv=&quot;Content-Security-Policy&quot;
  content=&quot;default-src 'self'; img-src https://*; child-src 'none';&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-pm-slice=&quot;1 3 []&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;3. 데이터베이스 필터링&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-pm-slice=&quot;1 3 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;데이터베이스에 저장하기 전에 입력값을 정규화하거나 필터링하여 악성 코드가 저장되지 않도록 방지합니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-pm-slice=&quot;1 3 []&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;4.&lt;/b&gt; &lt;b&gt;HttpOnly 쿠키 사용&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-pm-slice=&quot;1 1 [&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970}]&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;세션 쿠키에 HttpOnly 속성을 설정하여 JavaScript를 통한 쿠키 접근을 차단합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 [&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970}]&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 [&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970}]&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 [&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:1848,&amp;quot;end&amp;quot;:1970}]&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;Stored XSS는 데이터 저장소와 연계되어 있어, 공격자의 악성 스크립트가 여러 사용자에게 영향을 미칠 수 있는 치명적인 취약점이기 때문에 더욱 각별한 방어 전략이 필요해 보입니다!&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1736410418291&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[CS] XSS(Cross Site Scripting)란?&quot; data-og-description=&quot;XSS(Cross Site Scripting)는 웹 해킹 공격 중 하나입니다. 웹 서버 사용자에 대한 입력값 검증이 미흡할 때 발생하는 취약점으로, 주로 여러 사용자가 보는 게시판이나 메일 등을 통해 악성 스크립트(Ja&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/161&quot; data-og-url=&quot;https://dev-hpk.tistory.com/161&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/d27eKd/hyXWBV5IAj/9rIWgUTcR2iqjFK0MbF4Ck/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/4A065/hyXWnwM8uf/jGJoLoIS4BkF3OtsPAmNbk/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/biZmLf/hyXWr0fwxJ/h2weK7dG8TOg9qfkx27ZF0/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/161&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/161&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/d27eKd/hyXWBV5IAj/9rIWgUTcR2iqjFK0MbF4Ck/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/4A065/hyXWnwM8uf/jGJoLoIS4BkF3OtsPAmNbk/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/biZmLf/hyXWr0fwxJ/h2weK7dG8TOg9qfkx27ZF0/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[CS] XSS(Cross Site Scripting)란?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;XSS(Cross Site Scripting)는 웹 해킹 공격 중 하나입니다. 웹 서버 사용자에 대한 입력값 검증이 미흡할 때 발생하는 취약점으로, 주로 여러 사용자가 보는 게시판이나 메일 등을 통해 악성 스크립트(Ja&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>CS</category>
      <category>cross-site-scripting</category>
      <category>CS</category>
      <category>Hacking</category>
      <category>stored xss</category>
      <category>Web</category>
      <category>XSS</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/162</guid>
      <comments>https://dev-hpk.tistory.com/162#entry162comment</comments>
      <pubDate>Thu, 9 Jan 2025 17:14:36 +0900</pubDate>
    </item>
    <item>
      <title>[CS] XSS(Cross Site Scripting)란?</title>
      <link>https://dev-hpk.tistory.com/161</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oTLDo/btsLHoPxM2W/GdHlzflUqK7uOf0egcRqYK/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oTLDo/btsLHoPxM2W/GdHlzflUqK7uOf0egcRqYK/img.webp&quot; data-alt=&quot;xss(Cross Site Script) 이미지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oTLDo/btsLHoPxM2W/GdHlzflUqK7uOf0egcRqYK/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoTLDo%2FbtsLHoPxM2W%2FGdHlzflUqK7uOf0egcRqYK%2Fimg.webp&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;486&quot; height=&quot;486&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;xss(Cross Site Script) 이미지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;XSS(Cross Site Scripting)는 웹 해킹 공격 중 하나입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 웹 서버 사용자에 대한 입력값 검증이 미흡할 때 발생하는 취약점으로, 주로 여러 사용자가 보는 게시판이나 메일 등을 통해 악성 스크립트(JavaScript 같은 스크립트 코드)를 삽입하는 공격 기법입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;XSS는 대부분 웹 해킹 공격과 다르게 사용자(클라이언트)를 대상으로 하는 공격 기법입니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;일반적으로 사용자 쿠키/세션 값 탈취, 키보드 입력값 탈취 등이 가능하며, 피싱 사이트와 같은 악성 사이트로의 접근 유도가 가능해 사용자에게 직접적인 피해를 줄 수 있습니다.&lt;/span&gt; &lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;   XSS의 공격 유형&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;XSS는 공격 방법에 따라 유형이 크게 3가지로 나뉩니다.&lt;/span&gt; &lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;Reflected XSS (반사형 크로스사이트스크립트)&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;웹 응용 프로그램의 지정된 변수를 이용할 때 발생하는 취약점을 이용하는 공격으로, 악성 스크립트가 데이터베이스와 같은 저장소에 별도로 저장되지 않고 사용자의 화면에 즉시 출력됩니다. 주로 이메일, 메신저 등에 포함된 URL을 통해 공격이 이루어지고 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Stored XSS (저장형 크로스사이트스크립트)&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;취약점이 있는 웹 서버에 악성 스크립트를 저장하는 공격 방법입니다. 공격자는 악성 스크립트가 포함된 게시글을 작성하여 게시판 등 사용자가 접근할 수 있는 페이지에 업로드합니다. 이후 사용자가 해당 게시글을 요청하면 서버에 저장된 악성 스크립트가 사용자 측에서 동작하게 됩니다.&lt;/span&gt; &lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;DOM Based XSS (DOM 기반 크로스사이트스크립트)&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;DOM 구조를 이용하여 요소들을 수정하거나 추가하는 등 동적 행위를 할 때 접근하는 JavaScript에 악성 스크립트를 삽입하여 클라이언트 측 브라우저에서 악성 스크립트가 실행되도록 하는 공격 방법입니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;DOM(Document Object Model): 브라우저가 웹 페이지를 렌더링 하는 데 사용하는 모델로 HTML 및 XML 문서에 접근하기 위한 인터페이스&lt;/span&gt;&lt;/blockquote&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;DOM Based XSS는 Reflected XSS와 같이 동적 페이지를 구성하는 과정 상에서 발생되는 XSS 공격이지만, &lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;두 공격 사이에 차이점은 악성 스크립트가 심어지는 시점에서 찾을 수 있습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;Reflected XSS는 서버 측에서 동적 페이지를 구성하는 환경에서 발생되는 XSS인 &lt;span style=&quot;text-align: start;&quot;&gt;반면에&amp;nbsp;&lt;/span&gt;DOM Based XSS는 클라이언트 측에서 사용자 입력 값을 통해 동적 페이지를 구성하는 환경에서 발생되는 XSS입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;즉, 요청이 서버로 전송되지 않고 클라이언트 브라우저에서 공격이 이루어지는 특징을 가지고 있습니다. ​&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt; XSS 공격의 위험성&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;세션 하이재킹 :&lt;/b&gt; XSS를 통해 세션 쿠키를 훔친 후, 해당 세션을 사용하여 피해자의 계정에 로그인할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;쿠키 탈취 :&lt;/b&gt; 공격자는 document.cookie를 통해 피해자의 쿠키를 가져와 악성 서버로 전송할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;피싱 공격 :&lt;/b&gt; XSS를 사용하여 피해자의 브라우저에 가짜 로그인 페이지를 삽입하거나, 악성 사이트로 리디렉션 할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;악성 코드 실행 :&lt;/b&gt; 공격자는 악성 JavaScript 코드를 입력란이나 URL을 통해 삽입합니다. 이 코드가 실행되면 쿠키 정보나 세션 정보를 훔칠 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;✨사용자가 XSS 공격을 방어할 수 있는 방법&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;링크 확인 :&lt;/b&gt; 사용자는 다른 사용자로 부터 받은 링크를 클릭하기 전에 &lt;span style=&quot;text-align: start;&quot;&gt;.com, .net, .org 등의 뒤에 있는 링크 전체를 살펴봐야 합니다.&lt;span style=&quot;text-align: start;&quot;&gt; XSS 공격을 트리거하는 링크에는 합법적인 URL이 포함되어 있기 때문에 합법적인 것처럼 보이는 경우가 많지만,&amp;nbsp; &lt;span style=&quot;text-align: start;&quot;&gt;페이지 주소 뒤의 예상치 못한 텍스트는 악성 코드일 수 있습니다.&lt;/span&gt; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;b&gt;의심스러운 메일 주의 : &lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;보낸 사람의 이메일 주소를 확인하고, 신뢰할 수 없는 발신자의 이메일은 열지 않도록 주의해야 합니다. 특히 예상하지 못한 첨부파일이나 링크가 포함된 이메일은 더 주의해야 합니다. 추가로 일부 이메일 클라이언트에서 제공하는 미리 보기 창에서 악성 스크립트가 실행될 수 있기 때문에 미리 보기 기능을 비활성화해 주시는 것도 좋은 방법입니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;✨개발자가 XSS 공격을 방어할 수 있는 방법&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;입력 유효성 검사 : &lt;/b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;개발자는 &amp;lt;script&amp;gt; 태그처럼 XSS 공격에서 흔히 사용되는 태그나 문자를 명시적으로 거부하는 유효성 검사 규칙을 설정할 수 있습니다. 추가로 &lt;span style=&quot;text-align: start;&quot;&gt;사용자가 댓글, 게시물, 양식 입력에 HTML을 사용하지 못하도록 막을 수도 있습니다. HTML 콘텐츠는 악성 스크립트를 게시하거나 악성 코드가 포함된 URL의 링크를 숨기는 수단이 될 수 있기 때문입니다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;HttpOnly 쿠키 : &lt;/b&gt;쿠키에 HttpOnly 속성을 설정하면 JavaScript에서 쿠키를 접근할 수 없으므로, 세션 쿠키를 안전하게 보호할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333;&quot;&gt;&lt;b&gt;동적 스크립트 로딩 차단 :&lt;/b&gt; 외부 스크립트가 동적으로 로드되지 않도록 방지합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;입력 값 필터링 : &lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;악성 스크립트가 삽입되어도 동작하지 않도록 스크립트에 사용되는 &amp;lt;, &amp;gt;, &amp;lsquo;, &amp;ldquo; 등의 특수문자는 &amp;amp;It;, &amp;amp;gt; 등의 HTML Entity로 치환하여 입력합니다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>CS</category>
      <category>cross-site-scripting</category>
      <category>CS</category>
      <category>Hacking</category>
      <category>Web</category>
      <category>XSS</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/161</guid>
      <comments>https://dev-hpk.tistory.com/161#entry161comment</comments>
      <pubDate>Thu, 9 Jan 2025 15:33:41 +0900</pubDate>
    </item>
    <item>
      <title>[React] React Portal을 이용한 모달(Modal) 구현</title>
      <link>https://dev-hpk.tistory.com/160</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;최근 프로젝트에서 모달 컴포넌트를 사용하는데 많은 불편함을 느껴 React Portal을 사용하게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;제가 프로젝트를 진행하면서 겪은 불편함을 간단하게 정리했으니 필요하신 분들은 아래 예시를 확인해 주세요 &lt;/span&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;어떤 불편함이 있었냐고요 &lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아래 사진은 chrome의 개발자 도구를 통해 확인한 모달입니다.&lt;/span&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;525&quot; data-origin-height=&quot;123&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d5Ppmk/btsLIB61tBR/zFtF4KUhzMbhmzJFJ3hh51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d5Ppmk/btsLIB61tBR/zFtF4KUhzMbhmzJFJ3hh51/img.png&quot; data-alt=&quot;modal의 depth가 깊어지는 문제&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d5Ppmk/btsLIB61tBR/zFtF4KUhzMbhmzJFJ3hh51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd5Ppmk%2FbtsLIB61tBR%2FzFtF4KUhzMbhmzJFJ3hh51%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;525&quot; height=&quot;123&quot; data-origin-width=&quot;525&quot; data-origin-height=&quot;123&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;modal의 depth가 깊어지는 문제&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;597&quot; data-origin-height=&quot;299&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c0Tyue/btsLGZ2z74v/8q46hLgL6uFREO4gwnwlT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c0Tyue/btsLGZ2z74v/8q46hLgL6uFREO4gwnwlT0/img.png&quot; data-alt=&quot;modal의 depth가 깊어지는 문제&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c0Tyue/btsLGZ2z74v/8q46hLgL6uFREO4gwnwlT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc0Tyue%2FbtsLGZ2z74v%2F8q46hLgL6uFREO4gwnwlT0%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;531&quot; height=&quot;266&quot; data-origin-width=&quot;597&quot; data-origin-height=&quot;299&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;modal의 depth가 깊어지는 문제&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;단순히 detph가 깊어지는데 무슨 불편함이 있는지 궁금하실 수 있겠네요❗&lt;/span&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;764&quot; data-origin-height=&quot;537&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/55gb9/btsLHpz2MDE/jK47IwO0nTt4fW6Uk6GPP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/55gb9/btsLHpz2MDE/jK47IwO0nTt4fW6Uk6GPP0/img.png&quot; data-alt=&quot;modal의 depth에 의한 z-index 문제&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/55gb9/btsLHpz2MDE/jK47IwO0nTt4fW6Uk6GPP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F55gb9%2FbtsLHpz2MDE%2FjK47IwO0nTt4fW6Uk6GPP0%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;669&quot; height=&quot;470&quot; data-origin-width=&quot;764&quot; data-origin-height=&quot;537&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;modal의 depth에 의한 z-index 문제&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 예시를 보시면 DOM의 계층 구조에 의해 모달의 z-index에 문제가 생긴 경우입니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736322252505&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;import styled from &quot;styled-components&quot;;

function PortalExample() {
  const Title = styled.h1`
    position: fixed;
    top: 0;
    left: 0;
    z-index: 1;

    background-color: #fff;
  `;

  return &amp;lt;Title&amp;gt;Portal 예시입니다!&amp;lt;/Title&amp;gt;;
}

function App() {
  const Container = styled.div`
    position: relative;

    width: 100vw;
    height: 100vh;
  `;

  const Background = styled.div`
    position: absolute;
    top: 0;
    left: 0;
    z-index: 1;

    width: 100%;
    height: 100%;

    background-color: #000;
  `;

  return (
    &amp;lt;Container&amp;gt;
      React Portal Example
      &amp;lt;Background /&amp;gt;
      &amp;lt;PortalExample /&amp;gt;
    &amp;lt;/Container&amp;gt;
  );
}

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;802&quot; data-origin-height=&quot;284&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btZknD/btsLHpUkIpD/O8auKXbAyGIqMOJIa44LBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btZknD/btsLHpUkIpD/O8auKXbAyGIqMOJIa44LBk/img.png&quot; data-alt=&quot;계층 구조에 의한 z-index의 불편함&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btZknD/btsLHpUkIpD/O8auKXbAyGIqMOJIa44LBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtZknD%2FbtsLHpUkIpD%2FO8auKXbAyGIqMOJIa44LBk%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;802&quot; height=&quot;284&quot; data-origin-width=&quot;802&quot; data-origin-height=&quot;284&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;계층 구조에 의한 z-index의 불편함&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;h1과 div의 z-index가 1로 같은데, 계층 구조에 의해 h1이 안 보이네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;h1을 상위 계층으로 끌어올리면 어떻게 될까요 &lt;/span&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;817&quot; data-origin-height=&quot;192&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dJeiqA/btsLHsXt8Us/GqhYZ3WDknBKJCbNAlkVi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dJeiqA/btsLHsXt8Us/GqhYZ3WDknBKJCbNAlkVi1/img.png&quot; data-alt=&quot;상위 계층으로 h1을 올려 z-index를 해결&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dJeiqA/btsLHsXt8Us/GqhYZ3WDknBKJCbNAlkVi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdJeiqA%2FbtsLHsXt8Us%2FGqhYZ3WDknBKJCbNAlkVi1%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;817&quot; height=&quot;192&quot; data-origin-width=&quot;817&quot; data-origin-height=&quot;192&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;상위 계층으로 h1을 올려 z-index를 해결&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;상위 계층으로 끌어올리니 z-index 문제가 해결되었네요❗&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;상위 계층으로 요소를 끌어올린다고 z-index가 더 작아도 되는 것은 아닙니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;하지만 z-index 문제를 우회하거나 이벤트 버블링을 제어하는 등 어느 정도 유리한 점은 있겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;React Portal을 선택한 이유 &lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;React로 프로젝트를 진행하다 보면 부모 DOM 계층구조를 무시하고 다른 DOM 노드에 컴포넌트를 렌더링해야 할 때가 있습니다.&amp;nbsp;제 경우에는 모달을 body 하위에 렌더링 하기 위해 React Portal을 선택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;React Portal이란?&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;React Portal은 React 컴포넌트를 DOM 트리의 다른 위치에 렌더링 할 수 있도록 해주는 기능입니다. 일반적인 React 컴포넌트는 부모 컴포넌트의 DOM 트리에 렌더링 되지만, Portal을 사용하면 부모 DOM 트리 외부에 컴포넌트를 렌더링 할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot; data-pm-slice=&quot;1 1 []&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;React Portal 사용법&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p id=&quot;createportal&quot; style=&quot;background-color: #ffffff; color: #23272f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;createPortal(children, domNode)&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #23272f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;: createPortal&lt;span style=&quot;background-color: #ffffff; color: #23272f; text-align: start;&quot;&gt;을 호출하여 일부 JSX와 렌더링할 DOM 노드를 전달합니다.&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736325913037&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { createPortal } from 'react-dom';

{createPortal(
    &amp;lt;p&amp;gt;This child is placed in the document body.&amp;lt;/p&amp;gt;,
    document.body
)}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #23272f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;children : &lt;/b&gt;렌더링할 React 노드 (React 엘리먼트, 문자열, 숫자 등).&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;domNode : &lt;/b&gt;렌더링 할 DOM 노드&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 Modal을 만들고 React Portal을 사용해 보겠습니다!&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Modal 컴포넌트 생성&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1736325009509&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { createPortal } from &quot;react-dom&quot;;
import styled from &quot;styled-components&quot;;

function Modal({ children }) {
  const Modal = styled.div`
    position: fixed;
    top: 0;
    bottom: 0;
    z-index: 100;

    display: flex;
    align-items: center;
    justify-content: center;

    width: 100%;
    height: 100%;
  `;

  const ModalOverlay = styled.div`
    position: absolute;
    top: 0;
    left: 0;

    width: 100%;
    height: 100%;

    background-color: rgba(0, 0, 0, 0.5);
  `;

  const ModalContent = styled.div`
    overflow: hidden;
    position: relative;
    z-index: 1;

    max-width: 1000px;
    width: 80%;
    max-height: 80%;
    height: fit-content;

    padding: 20px;

    border-radius: 2rem;
    background-color: #fff;
  `;

  return createPortal(
    &amp;lt;Modal&amp;gt;
      &amp;lt;ModalOverlay /&amp;gt;
      &amp;lt;ModalContent&amp;gt;{children}&amp;lt;/ModalContent&amp;gt;
    &amp;lt;/Modal&amp;gt;,
    document.body
  );
}
export default Modal;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Modal 컴포넌트를 App 컴포넌트에서 import 해 사용해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;어떤 결과가 나올까요 &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736325735365&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function App() {
  const Container = styled.div`
    position: relative;

    width: 100vw;
    height: 100vh;
  `;

  return (
    &amp;lt;Container&amp;gt;
      React Portal Example
      &amp;lt;Modal&amp;gt;모달 컴포넌트&amp;lt;/Modal&amp;gt;
    &amp;lt;/Container&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1141&quot; data-origin-height=&quot;518&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bS2FQi/btsLGdtGn08/k8Kfj9xC4KhleQ73zCdQH0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bS2FQi/btsLGdtGn08/k8Kfj9xC4KhleQ73zCdQH0/img.png&quot; data-alt=&quot;모달 컴포넌트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bS2FQi/btsLGdtGn08/k8Kfj9xC4KhleQ73zCdQH0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbS2FQi%2FbtsLGdtGn08%2Fk8Kfj9xC4KhleQ73zCdQH0%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;1141&quot; height=&quot;518&quot; data-origin-width=&quot;1141&quot; data-origin-height=&quot;518&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;모달 컴포넌트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Modal 컴포넌트가 body 하위에 잘 렌더링 되었네요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;모달이 렌더링 되는 것을 확인했으니, 열고 닫는 기능도 추가해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;모달은 자주 사용하는 컴포넌트이다 보니 커스텀 훅으로 만들어 볼게요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;useModal 커스텀 훅 생성&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1736326197808&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useState } from &quot;react&quot;;

function useModal() {
  const [isOpen, setIsOpen] = useState(false); // 모달의 열고 닫힘을 관리하기 위한 state

  const openModal = () =&amp;gt; setIsOpen(true); // 모달 열기
  const closeModal = () =&amp;gt; setIsOpen(false); // 모달 닫기

  return {
    isOpen,
    openModal,
    closeModal,
  };
}

export default useModal;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;useModal 커스텀 훅을 만들었으니, Modal에 적용해야겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736326488194&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { createPortal } from &quot;react-dom&quot;;
import styled from &quot;styled-components&quot;;

function Modal({ children, isOpen, closeModal }) {
  {/* 스타일은 생략 하겠습니다. */}

  if (!isOpen) return null; // isOpen이 false인 경우 null을 리턴, 모달 렌더링 X

  return createPortal(
    &amp;lt;Modal&amp;gt;
      &amp;lt;ModalOverlay onClick={closeModal} /&amp;gt; // Dim 처리 된 영역을 클리하면 모달이 닫히도록 클릭 이벤트 추가
      &amp;lt;ModalContent&amp;gt;{children}&amp;lt;/ModalContent&amp;gt;
    &amp;lt;/Modal&amp;gt;,
    document.body
  );
}
export default Modal;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;pre id=&quot;code_1736326861543&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function App() {
  const { isOpen, openModal, closeModal } = useModal();

  {/* 스타일 생략 */}

  return (
    &amp;lt;Container&amp;gt;
      &amp;lt;button type='button' onClick={openModal}&amp;gt;
        모달 열기
      &amp;lt;/button&amp;gt;
      &amp;lt;Modal isOpen={isOpen} closeModal={closeModal}&amp;gt;
        모달 컴포넌트
      &amp;lt;/Modal&amp;gt;
    &amp;lt;/Container&amp;gt;
  );
}

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;React-App-Chrome-2025-01-08-17-58-51.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yRqo3/btsLGjf0SQE/hqT9wkdPUxAu52I6IReUkk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yRqo3/btsLGjf0SQE/hqT9wkdPUxAu52I6IReUkk/img.gif&quot; data-alt=&quot;useModal 커스텀 훅 적용 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yRqo3/btsLGjf0SQE/hqT9wkdPUxAu52I6IReUkk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/yRqo3/btsLGjf0SQE/hqT9wkdPUxAu52I6IReUkk/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;React-App-Chrome-2025-01-08-17-58-51.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;useModal 커스텀 훅 적용 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;모달을 열고 닫는 기능이 잘 동작하네요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;앞으로 진행하는 프로젝트에서 유용하게 사용할 수 있을 것 같습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;React Portal을 사용해 보고 느낀 점&lt;/b&gt; &lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;유연한 DOM 구조 : &lt;/b&gt;DOM 계층을 벗어나 렌더링이 가능해 UI 구현이 매우 편리하다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;CSS 스타일 충돌 최소화 : &lt;/b&gt;부모 컴포넌트의 스타일 영향을 받지 않아 독립적인 디자인이 가능해 편리하다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;학습 곡선이 낮음 : &lt;/b&gt;React에서 기본으로 제공하는 기능이고, API가 간단해서 처음 사용해도 쉽게 적응할 수 있었다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1736327619118&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[React] Emotion - React 컴포넌트 스타일링&quot; data-og-description=&quot;Emotion Emotion은 리액트에서 스타일을 관리하기 위한 CSS-in-JS 라이브러리로, 자바스크립트 코드 내에서 직접 CSS를 작성하고 적용할 수 있게 해 줍니다. Emotion 외에도 Styled-Components가 주로 사용됩&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/159&quot; data-og-url=&quot;https://dev-hpk.tistory.com/159&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/q8Kty/hyX0o8uJiu/eZNcrCNghX2PkKCxaZKH3K/img.png?width=225&amp;amp;height=225&amp;amp;face=0_0_225_225,https://scrap.kakaocdn.net/dn/oLLBU/hyXWoh1o2H/pdPtiK6yvbt7dskXcmeLy0/img.png?width=225&amp;amp;height=225&amp;amp;face=0_0_225_225,https://scrap.kakaocdn.net/dn/Zhghe/hyXWobfBx6/Eso9JZwNGIIKehGUCKs65K/img.png?width=1611&amp;amp;height=774&amp;amp;face=0_0_1611_774&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/159&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/159&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/q8Kty/hyX0o8uJiu/eZNcrCNghX2PkKCxaZKH3K/img.png?width=225&amp;amp;height=225&amp;amp;face=0_0_225_225,https://scrap.kakaocdn.net/dn/oLLBU/hyXWoh1o2H/pdPtiK6yvbt7dskXcmeLy0/img.png?width=225&amp;amp;height=225&amp;amp;face=0_0_225_225,https://scrap.kakaocdn.net/dn/Zhghe/hyXWobfBx6/Eso9JZwNGIIKehGUCKs65K/img.png?width=1611&amp;amp;height=774&amp;amp;face=0_0_1611_774');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[React] Emotion - React 컴포넌트 스타일링&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Emotion Emotion은 리액트에서 스타일을 관리하기 위한 CSS-in-JS 라이브러리로, 자바스크립트 코드 내에서 직접 CSS를 작성하고 적용할 수 있게 해 줍니다. Emotion 외에도 Styled-Components가 주로 사용됩&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1736327627982&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[JS] finally 이해하기&quot; data-og-description=&quot;자바스크립트에서 try - catch - finally 구문은 예외 처리를 위한 강력한 도구입니다. 이 구문에서 finally 블록은 예외 발생 여부와 관계없이 항상 실행되는 코드 블록으로, 주로 리소스 정리나 마무&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/158&quot; data-og-url=&quot;https://dev-hpk.tistory.com/158&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/etfWYA/hyXWncniGu/mCUsSx7WPhc0M2PZTjO3l1/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/Rc9fK/hyXWwG9OTM/lGGbZsxpPjKKHANKlT1170/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/Nhv37/hyX0vGzmkp/rYGLzN6RNpotryojbFEiXk/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/158&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/158&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/etfWYA/hyXWncniGu/mCUsSx7WPhc0M2PZTjO3l1/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/Rc9fK/hyXWwG9OTM/lGGbZsxpPjKKHANKlT1170/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/Nhv37/hyX0vGzmkp/rYGLzN6RNpotryojbFEiXk/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[JS] finally 이해하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;자바스크립트에서 try - catch - finally 구문은 예외 처리를 위한 강력한 도구입니다. 이 구문에서 finally 블록은 예외 발생 여부와 관계없이 항상 실행되는 코드 블록으로, 주로 리소스 정리나 마무&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React</category>
      <category>createPortal</category>
      <category>portal</category>
      <category>react</category>
      <category>React.js</category>
      <category>리액트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/160</guid>
      <comments>https://dev-hpk.tistory.com/160#entry160comment</comments>
      <pubDate>Wed, 8 Jan 2025 18:15:21 +0900</pubDate>
    </item>
    <item>
      <title>[React] Emotion - React 컴포넌트 스타일링</title>
      <link>https://dev-hpk.tistory.com/159</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Emotion&lt;/b&gt; &lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Emotion은 리액트에서 스타일을 관리하기 위한 CSS-in-JS 라이브러리로, 자바스크립트 코드 내에서 직접 CSS를 작성하고 적용할 수 있게 해 줍니다. Emotion 외에도&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt; Styled-Components가 주로 사용됩니다.&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #006dd7; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a style=&quot;color: #006dd7;&quot; href=&quot;https://2024.stateofcss.com/en-US/tools/#css_in_js&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;2024 css-in-js 사용 추세&lt;/span&gt;&lt;/b&gt;&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1611&quot; data-origin-height=&quot;774&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dmZS17/btsLDY399pt/Ukryj61SyZ5ZgXZk0zbgn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dmZS17/btsLDY399pt/Ukryj61SyZ5ZgXZk0zbgn0/img.png&quot; data-alt=&quot;2024 css-in-js 사용 추세&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dmZS17/btsLDY399pt/Ukryj61SyZ5ZgXZk0zbgn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdmZS17%2FbtsLDY399pt%2FUkryj61SyZ5ZgXZk0zbgn0%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;1611&quot; height=&quot;774&quot; data-origin-width=&quot;1611&quot; data-origin-height=&quot;774&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;2024 css-in-js 사용 추세&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://npmtrends.com/@emotion/core-vs-styled-components&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;지난 2년간 npm 다운로드 기록&lt;/a&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1314&quot; data-origin-height=&quot;537&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FUsHf/btsLDYpEsLH/OmrFSUciCyq9Ux4c3smj7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FUsHf/btsLDYpEsLH/OmrFSUciCyq9Ux4c3smj7K/img.png&quot; data-alt=&quot;지난 2년간 npm 라이브러리 다운로드 기록&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FUsHf/btsLDYpEsLH/OmrFSUciCyq9Ux4c3smj7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFUsHf%2FbtsLDYpEsLH%2FOmrFSUciCyq9Ux4c3smj7K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1314&quot; height=&quot;537&quot; data-origin-width=&quot;1314&quot; data-origin-height=&quot;537&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;지난 2년간 npm 라이브러리 다운로드 기록&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Styled-Components 대신 Emotion을 선택한 이유&lt;/b&gt; &lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Emotion을 선택한 첫 번째 이유는 styled-components는 이전 미션과 프로젝트를 통해 많이 경험해 봤기 때문에 새로운 라이브러리를 경험해 보고 싶어서입니다. 트렌드를 따라가는 것도 좋지만 여러 라이브러리를 사용해 보면 왜 특정 라이브러리가 많이 사용되는지 비교해 볼 수 있을 것 같습니다 &amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;두 번째 이유는 자주 참조하는 &lt;a href=&quot;https://toss.tech/article/toss-frontend-chapter#:~:text=Emotion%3A%20CSS%EB%A5%BC%20%EB%8B%A4%EB%A3%A8%EA%B8%B0%20%EC%9C%84%ED%95%B4%20emotion%20%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC%EB%A5%BC%20%EC%82%AC%EC%9A%A9%ED%95%98%EA%B3%A0%20%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4.%C2%A0CSS%20Prop%EC%9C%BC%EB%A1%9C%20%EC%83%9D%EC%82%B0%EC%A0%81%EC%9C%BC%EB%A1%9C%20%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%84%20%EB%8B%A4%EB%A3%B0%20%EC%88%98%20%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4.%20%EC%84%9C%EB%B2%84%20%EC%82%AC%EC%9D%B4%EB%93%9C%20%EB%A0%8C%EB%8D%94%EB%A7%81%EC%9D%84%20%ED%96%88%EC%9D%84%20%EB%95%8C%20%EC%B2%AB%20%EB%A0%8C%EB%8D%94%EC%97%90%20%ED%8F%AC%ED%95%A8%EB%90%98%EB%8A%94%20Critical%20CSS%EB%A7%8C%EC%9D%84%20HTML%EC%97%90%20%ED%8F%AC%ED%95%A8%ED%95%B4%EC%A4%8C%EC%9C%BC%EB%A1%9C%EC%8D%A8%20%EB%8D%94%20%EB%B9%A0%EB%A5%B4%EA%B2%8C%20%ED%99%94%EB%A9%B4%EC%9D%84%20%EB%B3%B4%EC%97%AC%EC%A4%84%20%EC%88%98%20%EC%9E%88%EB%8F%84%EB%A1%9D%20%EB%8F%84%EC%99%80%EC%A3%BC%EA%B8%B0%EB%8F%84%20%ED%95%A9%EB%8B%88%EB%8B%A4&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;toss&lt;/a&gt;에서 Emotion을 주 기술로 사용하고 있다는 점입니다. Emotion을 활용해봄으로써 실제 기업에서 사용되는 기술을 배우고, 그 효율성이나 장점들을 직접 경험할 수 있는 좋은 기회라고 생각했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 내용은 개인적인 이유이니, Emotion을 사용했을 때 장점도 알아봐야겠죠?&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; &lt;/b&gt;&lt;b&gt;Emotion의 장점&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;동적 스타일링 지원 :&lt;/b&gt; 컴포넌트의 props나 state에 따라 스타일을 동적으로 변경할 수 있어, 다양한 상태에 따른 스타일링이 가능합니다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;b&gt;서버 사이드 렌더링(SSR) 지원 &lt;/b&gt;:&lt;/b&gt; Emotion은 Next.js와 같은 서버 사이드 렌더링 환경에서 별도의 설정 없이 동작해 SSR을 구현할 때 편리합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;작은 번들 크기 :&lt;/b&gt; Emotion은 Styled-Components에 비해 번들 크기가 작아, 애플리케이션의 로딩 속도를 향상할&amp;nbsp;수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1112&quot; data-origin-height=&quot;650&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b7ZQP7/btsLFcN25aF/bEQHaexZKZ0JKIvza7JuJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b7ZQP7/btsLFcN25aF/bEQHaexZKZ0JKIvza7JuJ0/img.png&quot; data-alt=&quot;styled-components 번들 크기&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b7ZQP7/btsLFcN25aF/bEQHaexZKZ0JKIvza7JuJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb7ZQP7%2FbtsLFcN25aF%2FbEQHaexZKZ0JKIvza7JuJ0%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;475&quot; height=&quot;278&quot; data-origin-width=&quot;1112&quot; data-origin-height=&quot;650&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;styled-components 번들 크기&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;662&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bOhjnA/btsLGfQxafq/9uekHCfVraZEthF2VnQd4k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bOhjnA/btsLGfQxafq/9uekHCfVraZEthF2VnQd4k/img.png&quot; data-alt=&quot;emotion 번들 크기&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bOhjnA/btsLGfQxafq/9uekHCfVraZEthF2VnQd4k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbOhjnA%2FbtsLGfQxafq%2F9uekHCfVraZEthF2VnQd4k%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;510&quot; height=&quot;289&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;662&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;emotion 번들 크기&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 프로젝트에 emotion을 적용해 보겠습니다❗&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;✨&lt;/b&gt;&lt;b&gt;Emotion 설치 및 import&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1736169363659&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm install @emotion/react @emotion/styled&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;설치가 완료되면 아래와 같이 import 해서 사용할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736169813399&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { css } from '@emotion/react';
import styled from '@emotion/styled';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;import 까지 끝났으니 이제 emotion/react와 emotion/styled를 사용하는 방법을 각각 예시로 확인해 보겠습니다 &lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;@emotion/react 사용 예제&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;@emotion/react를 사용하여 스타일을 정의하고 css prop을 사용하여 리액트 컴포넌트에 적용하는 방식입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;https://github.com/toss/slash/blob/main/packages/react/emotion-utils/src/FullHeight.tsx&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;toss github&lt;/a&gt;을 확인해보니 이 방법을 채택했네요!&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736170346820&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';

function App() {
  return (
    &amp;lt;div
      css={css`
        background-color: #000000;
        padding: 20px;
        border-radius: 8px;
      `}
    &amp;gt;
      &amp;lt;h1 
      	css={css`
      	  color: #ffffff;
          font-size: 30px;
          font-weight: bold;
      	`}&amp;gt;
      	Emotion React!
      &amp;lt;/h1&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; /** @jsxImportSource @emotion/react */ 는&lt;span style=&quot;text-align: start;&quot;&gt; jsx 파일이 css prop을 읽을 수 있게 하기 위해 선언합니다.&lt;/span&gt; &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;css prop을 사용하여 인라인으로 스타일을 정의하고 HTML 요소에 바로 적용합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;각 요소마다 css prop을 사용해 스타일을 개별적으로 지정할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;@emotion/styled 사용 예제&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;@emotion/styled는 styled-components와 비슷한 방식으로 리액트 컴포넌트를 스타일링할 수 있게 해주는 API입니다. styled 함수는 스타일링 된 컴포넌트를 반환하고, 이 컴포넌트를 JSX에서 사용할 수 있습니다. 이는 리액트의 컴포넌트와 스타일을 결합하는 선언적인 방식입니다.&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736170539635&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import styled from '@emotion/styled';

const Wrapper = styled.div`
  background-color: lightblue;
  padding: 20px;
  border-radius: 8px;
`;

const Title = styled.h1`
  color: #ffffff;
  font-size: 30px;
  font-weight: bold;
`;

function App() {
  return (
    &amp;lt;Wrapper&amp;gt;
      &amp;lt;Title&amp;gt;Emotion Styled!&amp;lt;/Title&amp;gt;
    &amp;lt;/Wrapper&amp;gt;
  );
}

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;styled.div와 styled.h1을 사용하여 스타일링 된 Wrapper와 Title 컴포넌트를 생성합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;styled를 사용하면 해당 컴포넌트를 재사용 가능하고 선언적으로 스타일링할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 두 방법 모두 &lt;b&gt;동적 스타일링&lt;/b&gt;이 가능하고, 애플리케이션의 요구사항에 맞는 스타일링 방식을 선택하시면 될 것 같네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저는 Styled-Components와 유사한 점과 스타일링된 컴포넌트를 생성하고 여러 곳에서 재사용할 수 있다는 점에서 @emotion/styled 방식을 선택했습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;프로젝트에 적용해 보니 컴포넌트를 재사용할 수 있고 동적으로 스타일링할 수 있다는 점에서 너무 만족스러웠어요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;@emotion/styled 예시: &lt;/b&gt;&lt;b&gt;동적 스타일링&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1736171947223&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import styled from '@emotion/styled';
import { useState } from 'react';

// styled-components로 테마에 따라 스타일 변경
const Container = styled.div&amp;lt;{ darkMode: boolean }&amp;gt;`
  background-color: ${(props) =&amp;gt; (props.darkMode ? '#000' : '#fff')};
  color: ${(props) =&amp;gt; (props.darkMode ? '#fff' : '#000')};
  padding: 20px;
  border-radius: 8px;
  transition: background-color 0.3s, color 0.3s;
`;

const Button = styled.button`
  background-color: blue;
  color: white;
  border: none;
  padding: 10px 20px;
  cursor: pointer;
  border-radius: 4px;

  &amp;amp;:hover {
    background-color: green;
  }
`;

function App() {
  const [darkMode, setDarkMode] = useState(false);

  return (
    &amp;lt;Container darkMode={darkMode}&amp;gt;
      &amp;lt;h1&amp;gt;{darkMode ? 'Dark Mode' : 'Light Mode'}&amp;lt;/h1&amp;gt;
      &amp;lt;Button onClick={() =&amp;gt; setDarkMode(!darkMode)}&amp;gt;Toggle Theme&amp;lt;/Button&amp;gt;
    &amp;lt;/Container&amp;gt;
  );
}

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Container는 &lt;b&gt;darkMode prop&lt;/b&gt;을 사용하여 스타일을 동적으로 변경합니다. darkMode가 true일 때 검정 배경색을, false일 때 하얀 배경색을 적용합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Button 컴포넌트는 클릭 시 darkMode 상태를 토글하여, 전체 UI의 테마를 변경합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;@emotion/styled 예시: 재사용 가능한 컴포넌트&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1736172077567&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import styled from '@emotion/styled';

// 스타일링된 Button 컴포넌트 생성
const Button = styled.button&amp;lt;{ primary?: boolean }&amp;gt;`
  background-color: ${(props) =&amp;gt; (props.primary ? 'blue' : 'gray')};
  color: white;
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
  transition: background-color 0.3s;

  &amp;amp;:hover {
    background-color: ${(props) =&amp;gt; (props.primary ? 'darkblue' : 'darkgray')};
  }
`;

function App() {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;Button primary&amp;gt;Primary Button&amp;lt;/Button&amp;gt;
      &amp;lt;Button&amp;gt;Secondary Button&amp;lt;/Button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Button 컴포넌트는 styled.button을 사용하여 버튼 스타일을 정의합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;primary prop을 통해 버튼의 배경색을 동적으로 변경할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;primary prop이 true이면 파란색 배경, false이면 회색 배경이 적용됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;@emotion/styled를 사용하여 컴포넌트를 정의하고, 이 컴포넌트를 여러 곳에서 재사용할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Emotion을 사용해보고 느낀 점&lt;/b&gt; &lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Emotion을 사용해 보면서 단점을 딱히 느끼지 못했습니다. 오히려 좋은 점이 훨씬 많았는데요, 간단하게 정리해 보겠습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;props와 state를 이용한 동적 스타일링이 가능해 편리하다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;scss 문법을 사용할 수 있어 편리하다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;scss 문법에 js 함수, 조건문을 활용할 수 있어 편리하다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;전역 스타일링과 테마 관리 기능을 제공해 다크 모드나 사용자 정의 스타일을 구현하는데 편리하다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Emotion을 어떻게 사용했는지 궁금하신 분들을 위해 아래 제 github의 Design-System 레포지토리를 남겨두겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;부족하지만 참고해 보시고, 피드백도 남겨주신다면 감사하겠습니다 &lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1736172161174&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;design-system/src/components at develop &amp;middot; hpk5802/design-system&quot; data-og-description=&quot;Contribute to hpk5802/design-system development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/hpk5802/design-system/tree/develop/src/components&quot; data-og-url=&quot;https://github.com/hpk5802/design-system/tree/develop/src/components&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/kLMJ7/hyXWwtl4DP/tqHJhL0zjNhacVbJ1rFWOk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/nKWHl/hyXWqs6EEI/luy7PieAv8gUFYzgjjJfQk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/hpk5802/design-system/tree/develop/src/components&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/hpk5802/design-system/tree/develop/src/components&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/kLMJ7/hyXWwtl4DP/tqHJhL0zjNhacVbJ1rFWOk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/nKWHl/hyXWqs6EEI/luy7PieAv8gUFYzgjjJfQk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;design-system/src/components at develop &amp;middot; hpk5802/design-system&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to hpk5802/design-system development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1736173296369&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[React] 리액트 라이프사이클(Lifecycle)과 useEffect&quot; data-og-description=&quot;React는 컴포넌트 기반의 라이브러리로, 모든 컴포넌트는 생명주기(Lifecycle)를 가집니다. 생명주기는 컴포넌트가 생성, 업데이트, 제거되는 과정을 의미하며 이를 활용해 컴포넌트의 특정 시점에 &quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/156&quot; data-og-url=&quot;https://dev-hpk.tistory.com/156&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cqGuFD/hyXWxy0OIW/0nG8a2XQFB4IIzNAm7LdRk/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/CF5VW/hyXWohK1GH/NRrJEuL8IcZSWAq1D8M7m0/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/NtI2a/hyXWAvJMus/meGC7ULJT9gkKq9SBQaXy0/img.png?width=1280&amp;amp;height=810&amp;amp;face=0_0_1280_810&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/156&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/156&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cqGuFD/hyXWxy0OIW/0nG8a2XQFB4IIzNAm7LdRk/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/CF5VW/hyXWohK1GH/NRrJEuL8IcZSWAq1D8M7m0/img.png?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/NtI2a/hyXWAvJMus/meGC7ULJT9gkKq9SBQaXy0/img.png?width=1280&amp;amp;height=810&amp;amp;face=0_0_1280_810');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[React] 리액트 라이프사이클(Lifecycle)과 useEffect&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;React는 컴포넌트 기반의 라이브러리로, 모든 컴포넌트는 생명주기(Lifecycle)를 가집니다. 생명주기는 컴포넌트가 생성, 업데이트, 제거되는 과정을 의미하며 이를 활용해 컴포넌트의 특정 시점에&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1736173313294&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[React] React Hook Form 라이브러리로 Form 간편하게 관리하기&quot; data-og-description=&quot;React 애플리케이션에서 폼 관리는 매우 빈번한 작업입니다. React State를 이용해 폼 상태를 관리하고, 유효성을 검증하며, 성능을 최적화하는 것은 번거로울 수 있습니다. React Hook Form은 이러한 작&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/121&quot; data-og-url=&quot;https://dev-hpk.tistory.com/121&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/lDpyx/hyXWxThxby/Dqi0LKXjGal2x4fsUVkJX1/img.png?width=800&amp;amp;height=335&amp;amp;face=0_0_800_335,https://scrap.kakaocdn.net/dn/AGwPo/hyXWx6QLjG/SrwGls4RI1MOJIRDcXHetK/img.png?width=800&amp;amp;height=335&amp;amp;face=0_0_800_335,https://scrap.kakaocdn.net/dn/c1Lw9f/hyXWtQUSlt/6x619pVkLl0t784Jft13Uk/img.png?width=824&amp;amp;height=346&amp;amp;face=0_0_824_346&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/121&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/121&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/lDpyx/hyXWxThxby/Dqi0LKXjGal2x4fsUVkJX1/img.png?width=800&amp;amp;height=335&amp;amp;face=0_0_800_335,https://scrap.kakaocdn.net/dn/AGwPo/hyXWx6QLjG/SrwGls4RI1MOJIRDcXHetK/img.png?width=800&amp;amp;height=335&amp;amp;face=0_0_800_335,https://scrap.kakaocdn.net/dn/c1Lw9f/hyXWtQUSlt/6x619pVkLl0t784Jft13Uk/img.png?width=824&amp;amp;height=346&amp;amp;face=0_0_824_346');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[React] React Hook Form 라이브러리로 Form 간편하게 관리하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;React 애플리케이션에서 폼 관리는 매우 빈번한 작업입니다. React State를 이용해 폼 상태를 관리하고, 유효성을 검증하며, 성능을 최적화하는 것은 번거로울 수 있습니다. React Hook Form은 이러한 작&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>React</category>
      <category>CSS</category>
      <category>css-in-js</category>
      <category>emotion</category>
      <category>emotion/styled</category>
      <category>fe</category>
      <category>react</category>
      <category>React.js</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/159</guid>
      <comments>https://dev-hpk.tistory.com/159#entry159comment</comments>
      <pubDate>Mon, 6 Jan 2025 23:25:18 +0900</pubDate>
    </item>
    <item>
      <title>[JS] finally 이해하기</title>
      <link>https://dev-hpk.tistory.com/158</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;자바스크립트에서 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;try - catch - finally&lt;/b&gt;&lt;/span&gt; 구문은 예외 처리를 위한 강력한 도구입니다. 이 구문에서 finally 블록은 예외 발생 여부와 관계없이 항상 실행되는 코드 블록으로, 주로 리소스 정리나 마무리 작업에 사용됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;finally?&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; finally&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;는 일반적으로&amp;nbsp;&lt;/span&gt;try&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;나&amp;nbsp;&lt;/span&gt;catch&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&amp;nbsp;뒤에 붙으면서,&amp;nbsp;&lt;/span&gt;try&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;나&amp;nbsp;&lt;/span&gt;catch&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&amp;nbsp;문의 동작이 모두 완료되었을 때&amp;nbsp;&lt;/span&gt;&lt;b&gt;무조건&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&amp;nbsp;실행되는 코드를 작성하기 위해 사용합니다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;finally 블록은 선택 사항이기 때문에 생략하는 경우가 많습니다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;color: #212529;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;finally가 어떻게 동작하는지 간단하게 짚고 넘어가겠습니다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;color: #212529;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;지금부터 주목해야 할 점은 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;무조건 실행되는 코드&lt;/b&gt;&lt;/span&gt;입니다. 예시로 확인해보겠습니다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #212529;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;예시 (1)&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1735891142142&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;try {
  console.log('Try');
} catch(error) {
  console.log('Catch');
} finally {
  console.log('Finally');
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1735891427987&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Try
Finally&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; try 블록&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;에서 에러가 발생하지 않았기 때문에,&amp;nbsp;&lt;/span&gt;catch&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&amp;nbsp;블록은 실행하지 않고&amp;nbsp;&lt;/span&gt;finally&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&amp;nbsp;블록을 바로 실행하는 모습을 보여줍니다.&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;예시 (2)&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1735891473914&quot; class=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;try {
  console.log('Try');
  throw new Error();
} catch(error) {
  console.log('Catch');
} finally {
  console.log('Finally');
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1735891593397&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Try
Catch
Finally&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; try 블록&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;에서 에러가 발생했기 때문에,&amp;nbsp;&lt;/span&gt;catch&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&amp;nbsp;블럭을 실행하고&amp;nbsp;&lt;/span&gt;finally&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&amp;nbsp;블럭을 실행하는 모습을 보여줍니다.&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;finally 구문이 무조건 실행되긴 하지만, 여기까지 봐서는 아직 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;무조건 실행되는 코드&lt;/b&gt;&lt;/span&gt;에 대한 의문이 풀리지 않았죠?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;궁금증을 해결하기 위해 try-catch-finally 구문을 함수에 넣어 실행해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;함수에 &lt;/b&gt;&lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;try - catch - finally&lt;/span&gt; 구문 넣기&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1735891912718&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function test() {
  try {
    console.log('try');
  } catch(error) {
    console.log('catch');
  } finally {
    console.log('finally');
  }
}

test();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 코드를 실행하면 어떤 결과가 나올까요❓&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1735892249889&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Try
Finally&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위에서 봤던 예시 코드를 test라는 함수에 넣은 것이니 간단하죠 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제부터 finally의 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;무조건 실행되는 코드&lt;/b&gt;&lt;/span&gt;에 대한 내용을 직접 확인해 보겠습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;확인에 앞서 함수의 return 명령문에 대해서 간략하게 설명하겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;return 명령문&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;함수 실행을 종료하고, 주어진 값을 함수 호출 지점으로 반환합니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;(출처: &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/return&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN return&lt;/a&gt;)&amp;nbsp;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;함수 실행을 종료하는 return 명령문이 있어도 finally 구문이 실행되는지 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1735893210635&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function test() {
  try {
    return 'try';
  } catch(error) {
    return 'error';
  } finally {
    return 'finally';
  }
}

console.log(test());&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;return 명령문이 함수 실행을 종료시킨다고 했으니 try가 반환될까요?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;finally가 무조건 실행되는 코드라고 했으니 finally가 반환될까요?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;어떤 결과가 출력될지 예상이 되시나요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아래 결과를 확인해 보시죠.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1735893460139&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;finally&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;try 블록에서 return 명령문에 의해 함수 실행을 종료하고 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;try&lt;/b&gt;&lt;/span&gt;를 반환해야 하는데, &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;finally&lt;/b&gt;&lt;/span&gt;가 반환되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;여기서 의문이 생기죠. 왜 return 명령문이 있는데 함수 실행이 종료되지 않고 finally 구문이 실행되었을까요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아래 내용은 &lt;a href=&quot;https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html#prod-ReturnStatement&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;ECMAScript&lt;/a&gt;에 정의된 return 명령문 내용입니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;A return&amp;nbsp;statement causes a function to cease execution and, in most cases, returns a value to the caller. If&amp;nbsp;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Expression is omitted, the return value is undefined. Otherwise, the return value is the value of Expression. A&amp;nbsp;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;return statement may not actually return a value to the caller depending on surrounding context. For example, in a try block, a return statement's Completion Record may be replaced with another Completion Record during evaluation of the finally block.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;간단하게 요약하면 아래 내용과 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;return명령문은 주변 컨텍스트에 따라 호출자에게 실제로 값을 반환하지 않을 수 있습니다. 예를 들어, try 블록의&amp;nbsp;return 명령문의 Completion Record가 finally 블록의 return 명령문의 Completion Record으로 대체될 수 있습니다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;시작부터 강조했던 finally는 무조건 실행되는 코드가 검증되었네요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;여기서 한 가지 궁금증이 더 생겼습니다. finally 구문이 throw 문을 만나도 실행될까요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;try - catch - finally&lt;/span&gt;&amp;nbsp;구문에 throw로 예외 발생시키기&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 확인에 앞서 throw에 대해서 간략하게 설명하겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;throw 문은 사용자 정의 예외를 발생(throw)할 수 있습니다. 예외가 발생하면 현재 함수의 실행이 중지되고 (throw 이후의 명령문은 실행되지 않습니다.), 제어 흐름은 콜스택의 첫 번째 catch 블록으로 전달됩니다. 호출자 함수 사이에 catch 블록이 없으면 프로그램이 종료됩니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;(출처: &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/throw&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN throw&lt;/a&gt;)&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;예외 처리 후 함수 실행을 종료하는 throw 명령문이 있어도 finally 구문이 실행되는지 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1735895060255&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function test() {
  try {
    throw new Error();
  } catch (e) {
    throw new Error();
  } finally {
    return 'finally';
  }
}

console.log(test());&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;throw 문이 함수 실행을 종료시킨다고 했으니 에러를 발생시키고 종료될까요?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;finally가 무조건 실행되는 코드라고 했으니 finally가 반환될까요?&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1735895234304&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;finally&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;catch 블록에서 throw 문이 실행되었지만 다음&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;&amp;nbsp;&lt;span style=&quot;color: #333333;&quot;&gt;catch 블록이 없어 프로그램이 종료되어야 하지만, &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;finally&lt;/b&gt;&lt;/span&gt;가 반환되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이로써 finally는 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;무조건 실행되는 코드&lt;/b&gt;&lt;/span&gt;라는 사실이 확인되었습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그럼 finally를 어떻게 사용하면 좋을까요 &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;finally 활용 예시&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;메모리 누수 방지 :&lt;/b&gt; 비동기 작업 후 생성된 임시 변수나 데이터 구조를 정리하거나, 동적으로 추가한 이벤트 리스너를 작업이 끝난 후 제거하여 메모리 누수를 방지할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;네트워크 연결 종료 : &lt;/b&gt;네트워크 요청을 수행한 후, 예외 발생 여부와 관계없이 연결을 종료하여 리소스를 정리하는 데 finally 블록을 활용할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; 외부 API 호출 후 상태 복원 :&lt;/b&gt; 외부 API를 호출하여 상태를 변경한 후, 예외 발생 여부와 관계없이 원래 상태로 복원해야 하는 경우에 finally 블록을 사용하여 상태 복원을 보장할 수 있습니다. &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;a5&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; 추천글&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;figure id=&quot;og_1735896523459&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[JS] 이벤트 루프(Event Loop)란?&quot; data-og-description=&quot;자바스크립트는 단일 스레드 기반 언어로 한 번에 하나의 작업만 처리할 수 있습니다. 하지만 비동기 작업을 지원하며 동시에 여러 작업이 진행되는 것처럼 보이게 합니다. 이러한 비동기 처리&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/149&quot; data-og-url=&quot;https://dev-hpk.tistory.com/149&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b6KSuD/hyXSApHUux/vWBLtKvZKarYjpWhxRkkx1/img.gif?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/bnY05m/hyXWsKnP0s/yLEsHpvPoXntVCIRkabfe0/img.gif?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/dggWm6/hyXWAhkitw/aKEo67EAu3x6thCglQxJcK/img.png?width=1292&amp;amp;height=883&amp;amp;face=0_0_1292_883&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/149&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/149&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b6KSuD/hyXSApHUux/vWBLtKvZKarYjpWhxRkkx1/img.gif?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/bnY05m/hyXWsKnP0s/yLEsHpvPoXntVCIRkabfe0/img.gif?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/dggWm6/hyXWAhkitw/aKEo67EAu3x6thCglQxJcK/img.png?width=1292&amp;amp;height=883&amp;amp;face=0_0_1292_883');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[JS] 이벤트 루프(Event Loop)란?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;자바스크립트는 단일 스레드 기반 언어로 한 번에 하나의 작업만 처리할 수 있습니다. 하지만 비동기 작업을 지원하며 동시에 여러 작업이 진행되는 것처럼 보이게 합니다. 이러한 비동기 처리&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1735896533113&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[JS] Reflow와 Repaint&quot; data-og-description=&quot;웹 성능 최적화에서 중요한 개념 중 하나가 바로 Reflow(리플로우)와 Repaint(리페인트)입니다. 이 두 가지는 브라우저가 화면에 콘텐츠를 렌더링 하는 과정에서 발생하며, 잘못된 코딩 습관은 Reflow&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/150&quot; data-og-url=&quot;https://dev-hpk.tistory.com/150&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/RJm24/hyXSwt6maw/YPpXNdikf4wm5cNioffDb1/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/zXPaR/hyXWrEGzWf/jjA9Lqm54Er4c5krddEUh1/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/tkxKw/hyXWr5MahF/XwHTyCeJ7k4vZ1mv9JSnAK/img.png?width=1280&amp;amp;height=605&amp;amp;face=0_0_1280_605&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/150&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/150&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/RJm24/hyXSwt6maw/YPpXNdikf4wm5cNioffDb1/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/zXPaR/hyXWrEGzWf/jjA9Lqm54Er4c5krddEUh1/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/tkxKw/hyXWr5MahF/XwHTyCeJ7k4vZ1mv9JSnAK/img.png?width=1280&amp;amp;height=605&amp;amp;face=0_0_1280_605');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[JS] Reflow와 Repaint&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;웹 성능 최적화에서 중요한 개념 중 하나가 바로 Reflow(리플로우)와 Repaint(리페인트)입니다. 이 두 가지는 브라우저가 화면에 콘텐츠를 렌더링 하는 과정에서 발생하며, 잘못된 코딩 습관은 Reflow&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JavaScript</category>
      <category>Catch</category>
      <category>finally</category>
      <category>javascript</category>
      <category>js</category>
      <category>try</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/158</guid>
      <comments>https://dev-hpk.tistory.com/158#entry158comment</comments>
      <pubDate>Fri, 3 Jan 2025 18:30:31 +0900</pubDate>
    </item>
    <item>
      <title>[Taskify] Trouble Shooting - 동적 페이지 라우팅</title>
      <link>https://dev-hpk.tistory.com/157</link>
      <description>&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;b&gt;문제 상황 : 동적 라우팅 ([id].tsx) 페이지에서 새로고침 하면 404 page로 이동한다.&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;dev-taskify.netlify.app_dashboard_12838-Chrome-2024-12-28-13-59-23.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qKrNR/btsLBd6W4U1/quO8Zyat2Ihuok8J93btyK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qKrNR/btsLBd6W4U1/quO8Zyat2Ihuok8J93btyK/img.gif&quot; data-alt=&quot;새로고침 시 404 페이지 이동 이슈&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qKrNR/btsLBd6W4U1/quO8Zyat2Ihuok8J93btyK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/qKrNR/btsLBd6W4U1/quO8Zyat2Ihuok8J93btyK/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;dev-taskify.netlify.app_dashboard_12838-Chrome-2024-12-28-13-59-23.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;새로고침 시 404 페이지 이동 이슈&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 원인을 찾지 못해서 vercel 배포 환경 설정이 잘 못 되었는지 확인하기 위해 로컬에서도 빌드를 진행했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;485&quot; data-origin-height=&quot;236&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b7eDg6/btsLAcm9SEg/fKNdztsy5yB2ojbwCULM41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b7eDg6/btsLAcm9SEg/fKNdztsy5yB2ojbwCULM41/img.png&quot; data-alt=&quot;빌드 결과 폴더 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b7eDg6/btsLAcm9SEg/fKNdztsy5yB2ojbwCULM41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb7eDg6%2FbtsLAcm9SEg%2FfKNdztsy5yB2ojbwCULM41%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;485&quot; height=&quot;236&quot; data-origin-width=&quot;485&quot; data-origin-height=&quot;236&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;빌드 결과 폴더 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드 후 next/static 폴더의 리소스를 비교했지만 로컬과 vercel 모두 [id]로 동적 파일을 생성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어디서부터 잘못된 걸까요.&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  해결 방법&lt;/b&gt;&lt;/span&gt;&lt;/h4&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;getServerSideProps 선언해 SSR(Server Side &lt;span style=&quot;background-color: #ffffff; color: #001d35; text-align: left;&quot;&gt;Rendering)로 동작하도록 수정&lt;br /&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #001d35; text-align: left;&quot;&gt;실제로 SSR로 수정하진 않고, getServerSideProps를 사용함으로써 SSR로 동작하도록만 하는 편법이라고 하네요.. &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #001d35; text-align: left;&quot;&gt;getServerSideProps 실제로 사용해서 SSR로 수정하기&lt;/span&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #001d35; text-align: left;&quot;&gt;서버에서 페이지를 렌더링하고 HTMl을 브라우저에 전달하는 정석적인 방법❗&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #001d35; text-align: left;&quot;&gt;getStaticPaths 선언&lt;br /&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;getStaticPaths를 선언하면&lt;span&gt; &lt;/span&gt;[id].tsx&lt;span&gt;&amp;nbsp;&lt;/span&gt;형태의 동적 라우팅 페이지를 빌드 시에 static하게 생성한다고 합니다.&lt;br /&gt;
&lt;pre id=&quot;code_1735362708056&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;export async function getStaticPaths() {
    return {
        paths:[
            {
                params:{postId: '1'},
            },
            {
                params:{postId: '2'},
            },
            {
                params:{postId: '3'},
            },
        ],
        fallback: false,
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;489&quot; data-origin-height=&quot;166&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biQnqA/btsLAWdgNNI/bzST5VvzWFnd9uAmpuF4mK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biQnqA/btsLAWdgNNI/bzST5VvzWFnd9uAmpuF4mK/img.png&quot; data-alt=&quot;빌드 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biQnqA/btsLAWdgNNI/bzST5VvzWFnd9uAmpuF4mK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbiQnqA%2FbtsLAWdgNNI%2FbzST5VvzWFnd9uAmpuF4mK%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;489&quot; height=&quot;166&quot; data-origin-width=&quot;489&quot; data-origin-height=&quot;166&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;빌드 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/li&gt;
&lt;li&gt;프로젝트 특성상 dashboard의 수가 매우 많습니다. 따라서 빌드 시에 html과 json 파일이 dashboard의 수만큼 많이 생기기 때문에 비효율적이라고 하네요.. &lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;동적 페이지 라우팅(/dashboard/[id].tsx)을 사용하지 않고 router.query()를 이용해 dashboardId를 받아 데이터를 요청한다. (&lt;a href=&quot;https://velog.io/@sj_dev_js/Next.js-%EB%8F%99%EC%A0%81-%EB%9D%BC%EC%9A%B0%ED%8C%85%EB%90%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%B0%B0%ED%8F%AC%EC%8B%9C-404-%EC%97%90%EB%9F%AC&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;참고&lt;/a&gt;)
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Next.js에서 제공하는 동적 페이지 라우팅을 일부로 사용하지 않는 방법은 Next.js를 선택한 이유를 퇴색시키는 방법인 것 같아 최후의 방법으로 미루기로 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;✨ 최종 해결 과정&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 이슈를 해결하기 위해 pr을 올려 배포 테스트를 하는 방법보다 로컬에서 적용 후 빌드해 확인하는 방법이 효율적일 것 같아 각자 로컬에서 확인하는 방법을 선택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 최신 상태를 반영하기 위해 dev 브랜치로 이동후 pull을 실행 후 빌드를 진행했습니다.&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;879&quot; data-origin-height=&quot;265&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cAgoQY/btsLATgzebg/SGEB140pHSPH9XzVi7a6u1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cAgoQY/btsLATgzebg/SGEB140pHSPH9XzVi7a6u1/img.png&quot; data-alt=&quot;빌드 에러&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cAgoQY/btsLATgzebg/SGEB140pHSPH9XzVi7a6u1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcAgoQY%2FbtsLATgzebg%2FSGEB140pHSPH9XzVi7a6u1%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;879&quot; height=&quot;265&quot; data-origin-width=&quot;879&quot; data-origin-height=&quot;265&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;빌드 에러&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에 빌드된 서버를 실행할 때는 보이지 않던 에러가 보입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;output: export❓❓&lt;br /&gt;&lt;/b&gt;&lt;br /&gt;next.config.ts에서 output: 'export'는 Next.js 애플리케이션을&amp;nbsp;정적 HTML 파일로 빌드하도록 설정합니다. 이 설정을 적용하면 모든 페이지를 정적으로 렌더링 하여 파일 시스템에 HTML, CSS, JS 형태로 저장합니다. 정적 배포가 가능한 CDN이나 정적 사이트 호스팅 서비스(Netlify, Vercel 등)에서 사용할 수 있도록 만들어주는 설정입니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인해 보니 팀장님께서 Netlify와 Vercel에 배포를 하기 위해 추가한 설정이라고 하네요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정적 HTML 파일로 빌드할 경우, &lt;b&gt;동적 라우팅&lt;/b&gt; 페이지는 정적 파일로 생성되지 않으므로 서버 측에서 해당 경로를 처리할 수 없어 새로고침 시 404 에러가 발생했던 것이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;next.config.ts 파일에서 output: export 옵션을 삭제 후 확인해 보겠습니다!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12838-Chrome-2024-12-28-14-46-07.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cqRvDP/btsLzesKUjf/cP0inwAxWr3Nqgkx118Npk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cqRvDP/btsLzesKUjf/cP0inwAxWr3Nqgkx118Npk/img.gif&quot; data-alt=&quot;에러 해결&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cqRvDP/btsLzesKUjf/cP0inwAxWr3Nqgkx118Npk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cqRvDP/btsLzesKUjf/cP0inwAxWr3Nqgkx118Npk/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;localhost_3000_dashboard_12838-Chrome-2024-12-28-14-46-07.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;에러 해결&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 발표가 얼마 남지 않았습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트는 팀원들과의 소통도 원활했고, 개발도 잘 진행되어서 정말 뿌듯합니다.&lt;br /&gt;발표도 성공적으로 마무리해서 좋은 결과로 이어졌으면 좋겠네요 &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1735365288671&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 1차 배포 테스트(2) - 사용자 편의성 개선&quot; data-og-description=&quot;오늘은 저번 이슈 해결에 이어서 사용자 편의성 개선 작업을 해보려고 합니다.  개선 사항 : select 메뉴 버튼이나 옵션 바깥 부분을 클릭했을 때 select 메뉴가 닫히면 좋을 것 같다.멘토님께 피&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/155&quot; data-og-url=&quot;https://dev-hpk.tistory.com/155&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/6wb7u/hyXSq7tQfM/Vk50mu6UojTebHDlqK8LZK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/jM1B2/hyXSxMjiVu/Fdxk30hWBUCtKkNwbKaOpK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/KEocH/hyXSFwNmGH/hLxJSmpDYpkD2H8pOGtcX1/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/155&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/155&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/6wb7u/hyXSq7tQfM/Vk50mu6UojTebHDlqK8LZK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/jM1B2/hyXSxMjiVu/Fdxk30hWBUCtKkNwbKaOpK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/KEocH/hyXSFwNmGH/hLxJSmpDYpkD2H8pOGtcX1/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 1차 배포 테스트(2) - 사용자 편의성 개선&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 저번 이슈 해결에 이어서 사용자 편의성 개선 작업을 해보려고 합니다.  개선 사항 : select 메뉴 버튼이나 옵션 바깥 부분을 클릭했을 때 select 메뉴가 닫히면 좋을 것 같다.멘토님께 피&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1735365294790&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 1차 배포 테스트 - Trouble shooting&quot; data-og-description=&quot;멘토님과 1차 배포 테스트를 진행했습니다.버그를 하나씩 찾아내시는데 제가 작업한 페이지도 버그가 많네요 테스트하면서 나온 이슈들을 정리해 보면 아래와 같습니다.버그를 하나씩 살펴&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/154&quot; data-og-url=&quot;https://dev-hpk.tistory.com/154&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/6mKKX/hyXSDsds3H/E38IB0BUYt8coDv8PyX1ik/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/JxY2w/hyXSFcu1qo/aYyLKn8xfUCc3Dk8p58HKk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/mITsF/hyXSE5IGyX/k5qHDZTgSc9KDs92AmLjbk/img.png?width=1422&amp;amp;height=376&amp;amp;face=0_0_1422_376&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/154&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/154&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/6mKKX/hyXSDsds3H/E38IB0BUYt8coDv8PyX1ik/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/JxY2w/hyXSFcu1qo/aYyLKn8xfUCc3Dk8p58HKk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/mITsF/hyXSE5IGyX/k5qHDZTgSc9KDs92AmLjbk/img.png?width=1422&amp;amp;height=376&amp;amp;face=0_0_1422_376');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 1차 배포 테스트 - Trouble shooting&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;멘토님과 1차 배포 테스트를 진행했습니다.버그를 하나씩 찾아내시는데 제가 작업한 페이지도 버그가 많네요 테스트하면서 나온 이슈들을 정리해 보면 아래와 같습니다.버그를 하나씩 살펴&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>Page Router</category>
      <category>static export</category>
      <category>동적 라우팅</category>
      <category>페이지 라우터</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/157</guid>
      <comments>https://dev-hpk.tistory.com/157#entry157comment</comments>
      <pubDate>Sat, 28 Dec 2024 14:56:15 +0900</pubDate>
    </item>
    <item>
      <title>[React] 리액트 라이프사이클(Lifecycle)과 useEffect</title>
      <link>https://dev-hpk.tistory.com/156</link>
      <description>&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;React는 컴포넌트 기반의 라이브러리로, 모든 컴포넌트는 생명주기(Lifecycle)를 가집니다. 생명주기는 컴포넌트가 생성, 업데이트, 제거되는 과정을 의미하며 이를 활용해 컴포넌트의 특정 시점에 원하는 동작을 정의할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;목차&lt;/b&gt;&lt;/span&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a1&quot;&gt; 1. 리액트의 라이프사이클 단계&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a2&quot;&gt; 2. 함수형 컴포넌트와 useEffect&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a3&quot;&gt; 3. useEffect 사용 시 주의할 점&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a4&quot;&gt;추천글&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위의 목차를 클릭하면 해당 글로 자동 이동 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;a1&quot; style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1. 리액트의 라이프사이클 단계&lt;/span&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;810&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0ymMP/btsLyxLEWF8/R4D4t3a6vxgw2AU0pBpAW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0ymMP/btsLyxLEWF8/R4D4t3a6vxgw2AU0pBpAW0/img.png&quot; data-alt=&quot;React 라이프사이클&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0ymMP/btsLyxLEWF8/R4D4t3a6vxgw2AU0pBpAW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0ymMP%2FbtsLyxLEWF8%2FR4D4t3a6vxgw2AU0pBpAW0%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;1280&quot; height=&quot;810&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;810&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;React 라이프사이클&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 사진은 Class 방식의 라이프 사이클입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;함수형 방식에서는 컴포넌트의 라이프사이클은 크게 세 가지로 나눌 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;583&quot; data-origin-height=&quot;527&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4to9J/btsLxjAKRE9/yiAOhA9DXoGu0m90z2lgF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4to9J/btsLxjAKRE9/yiAOhA9DXoGu0m90z2lgF0/img.png&quot; data-alt=&quot;컴포넌트 라이프사이클&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4to9J/btsLxjAKRE9/yiAOhA9DXoGu0m90z2lgF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4to9J%2FbtsLxjAKRE9%2FyiAOhA9DXoGu0m90z2lgF0%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;583&quot; height=&quot;527&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;583&quot; data-origin-height=&quot;527&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;컴포넌트 라이프사이클&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1. 마운트 (Mount)&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-pm-slice=&quot;3 3 []&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;정의:&lt;/b&gt; 컴포넌트가 생성되고 DOM에 추가되는 단계&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;주요 메서드:&lt;/b&gt; componentDidMount (클래스 컴포넌트 기준)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;주요 작업:&lt;/b&gt; API 호출, 이벤트 리스너 등록 등 초기화 작업.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2. 업데이트 (Update)&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-spread=&quot;false&quot; data-pm-slice=&quot;3 3 []&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;정의:&lt;/b&gt; 컴포넌트의&amp;nbsp;상태(state)나&amp;nbsp;속성(props)이&amp;nbsp;변경되어&amp;nbsp;다시&amp;nbsp;렌더링 되는&amp;nbsp;단계&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;주요 메서드:&lt;/b&gt;&amp;nbsp;componentDidUpdate&amp;nbsp;(클래스 컴포넌트 기준)&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;componentDidUpdate 메서드는 첫 번째 렌더링에서는 호출되지 않습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;주요 작업:&lt;/b&gt; 상태&amp;nbsp;변화에&amp;nbsp;따른&amp;nbsp;DOM&amp;nbsp;조작&amp;nbsp;또는&amp;nbsp;부수&amp;nbsp;효과&amp;nbsp;실행&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;3. 언마운트 (UnMount)&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-pm-slice=&quot;3 3 []&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;정의:&lt;/b&gt;&amp;nbsp;컴포넌트가 DOM에서 제거되는 단계&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;주요 메서드:&lt;/b&gt;&amp;nbsp;componentWillUnmount&amp;nbsp;(클래스 컴포넌트 기준)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;주요 작업:&lt;/b&gt; 이벤트&amp;nbsp;리스너&amp;nbsp;제거,&amp;nbsp;타이머&amp;nbsp;정리&amp;nbsp;등&amp;nbsp;클린업&amp;nbsp;작업&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;a2&quot; style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2. 함수형 컴포넌트와 useEffect&lt;/span&gt;&lt;/h2&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;React 16.8부터 도입된 Hooks는 함수형 컴포넌트에서도 상태 관리와 라이프사이클 관리가 가능하게 합니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;useEffect는 &lt;b&gt;클래스 컴포넌트의 라이프사이클 메서드&lt;/b&gt;(&lt;b&gt;componentDidMount&lt;/b&gt;, &lt;b&gt;componentDidUpdate&lt;/b&gt;, &lt;b&gt;componentWillUnmount&lt;/b&gt;)를 통합한 역할을 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;리액트에서&lt;b&gt; 사이드 이팩트(&lt;/b&gt;&lt;b&gt;side effect)&lt;/b&gt;&amp;nbsp;는 외부에서 데이터나 상태를 변경하는 것인데, 이를 수행하는 것이 바로&amp;nbsp;&lt;b&gt;useEffect&lt;/b&gt; 훅입니다. 즉, 리액트에서&amp;nbsp;&lt;b&gt;useEffect는&lt;/b&gt;&lt;b&gt; 사이트 이팩트(side effect)를&lt;/b&gt; 실행하고 싶을 때 사용하는 함수라고 할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;useEffect 기본 구조&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1735204793423&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  // 사이드 이펙트
  return () =&amp;gt; {
    // 클린업 코드
  };
}, [dependency]);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-pm-slice=&quot;3 5 []&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;첫 번째 인자:&lt;/b&gt; 사이드 이팩트(side effect)를 실행할 함수(callback) [필수]&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;두 번째 인자:&lt;/b&gt; 의존성 배열(dependency array) [필수]&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;배열 안의 값이 변경될 때만 효과(callback)가 실행됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;클린업 코드:&lt;/b&gt; useEffect에서 반환(return)하는 함수입니다. [옵션]&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&amp;nbsp;컴포넌트가 언마운트 될 때 실행됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;장점: 메모리 누수 방지, 성능 향상, 안정성 향상&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;의존성 배열에 따른 동작&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJCGQ7/btsLzsb5Tox/GIA0KVjoNfjPUdudqaLqI1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJCGQ7/btsLzsb5Tox/GIA0KVjoNfjPUdudqaLqI1/img.png&quot; data-alt=&quot;의존성 배열에 따른 동작&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJCGQ7/btsLzsb5Tox/GIA0KVjoNfjPUdudqaLqI1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJCGQ7%2FbtsLzsb5Tox%2FGIA0KVjoNfjPUdudqaLqI1%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;1280&quot; height=&quot;720&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;의존성 배열에 따른 동작&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;빈 배열 (&lt;/b&gt;&lt;b&gt;[]&lt;/b&gt;&lt;b&gt;)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;컴포넌트가 처음 마운트될 때만 실행됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;주로 API 호출, 초기화 작업에 사용합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1735205525168&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  console.log(&quot;컴포넌트 마운트&quot;);
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;dependencies에 값이 있는 배열 (&lt;/b&gt;&lt;b&gt;[value]&lt;/b&gt;&lt;b&gt;)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;dependencies 배열에 포함된 값이 변경될 때 실행됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;특정 상태나 속성 변화에 따른 작업을 처리합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1735205631148&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  console.log(`${value} 상태가 변경되어 실행`);
}, [value]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;배열 생략&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;컴포넌트가 렌더링 될 때마다 실행됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;일반적으로 의도하지 않은 성능 문제를 유발할 수 있어 주의가 필요합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1735205665054&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  console.log(&quot;렌더링&quot;);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;a3&quot; style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;3. useEffect 사용 시 주의할 점&lt;/span&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-pm-slice=&quot;3 5 []&quot; data-spread=&quot;true&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;무한 루프 방지: &lt;/b&gt;의존성 배열을 정확히 설정하지 않으면 무한 렌더링이 발생할 수 있습니다. 특히 HTTP Method와 같은 요청이 반복되면 서버에 과부하를 일으킬 수 있어 조심해야 합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;클린업 누락 방지: &lt;/b&gt;컴포넌트가 언마운트 되거나 의존성이 변경될 때 클린업 함수를 실행하지 않으면 메모리 누수가 발생할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;비동기 작업 처리: &lt;/b&gt;비동기 작업은 async/await를 직접 useEffect&lt;/span&gt;&lt;span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 콜백에 사용할 수 없으므로 내부 함수로 분리해야 합니다.&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;
&lt;pre id=&quot;code_1735205945595&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  const fetchData = async () =&amp;gt; {
    const response = await fetch(&quot;https://api.example.com/&quot;);
    const data = await response.json();
    console.log(data);
  };

  fetchData();
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;span&gt;&lt;/span&gt;&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;a4&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 추천글&lt;/b&gt;&lt;/h3&gt;
&lt;figure id=&quot;og_1735206009305&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[React] React Hook Form 라이브러리로 Form 간편하게 관리하기&quot; data-og-description=&quot;React 애플리케이션에서 폼 관리는 매우 빈번한 작업입니다. React State를 이용해 폼 상태를 관리하고, 유효성을 검증하며, 성능을 최적화하는 것은 번거로울 수 있습니다. React Hook Form은 이러한 작&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/121&quot; data-og-url=&quot;https://dev-hpk.tistory.com/121&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bq2QXK/hyXSrdLJf6/S3gXQVsfiaJWuKNRa3kBI0/img.png?width=800&amp;amp;height=335&amp;amp;face=0_0_800_335,https://scrap.kakaocdn.net/dn/eatCWd/hyXSpfXanh/uX0kOD59CosnO6w10hJcok/img.png?width=800&amp;amp;height=335&amp;amp;face=0_0_800_335,https://scrap.kakaocdn.net/dn/bb6LiT/hyXSpmIWdH/Yjfl4wOfaWfWScKzrKq4YK/img.png?width=824&amp;amp;height=346&amp;amp;face=0_0_824_346&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/121&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/121&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bq2QXK/hyXSrdLJf6/S3gXQVsfiaJWuKNRa3kBI0/img.png?width=800&amp;amp;height=335&amp;amp;face=0_0_800_335,https://scrap.kakaocdn.net/dn/eatCWd/hyXSpfXanh/uX0kOD59CosnO6w10hJcok/img.png?width=800&amp;amp;height=335&amp;amp;face=0_0_800_335,https://scrap.kakaocdn.net/dn/bb6LiT/hyXSpmIWdH/Yjfl4wOfaWfWScKzrKq4YK/img.png?width=824&amp;amp;height=346&amp;amp;face=0_0_824_346');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[React] React Hook Form 라이브러리로 Form 간편하게 관리하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;React 애플리케이션에서 폼 관리는 매우 빈번한 작업입니다. React State를 이용해 폼 상태를 관리하고, 유효성을 검증하며, 성능을 최적화하는 것은 번거로울 수 있습니다. React Hook Form은 이러한 작&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1735206020493&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[React] IntersectionObserver를 통한 스크롤 이벤트&quot; data-og-description=&quot;throttle과 IntersectionObserver API를 활용하여 Infinity Scroll을 라이브러리 없이 구현했습니다. 직접 구현하면서 여러 모듈을 조합하여 성능을 최적화하고, 부드럽게 데이터를 로드하기 위해 상당한 고&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/80&quot; data-og-url=&quot;https://dev-hpk.tistory.com/80&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/ZcOZ7/hyXSDrKvt0/tRNH1ugT5utUEAdSBFlHX0/img.gif?width=600&amp;amp;height=325&amp;amp;face=217_126_346_176,https://scrap.kakaocdn.net/dn/FslGc/hyXSAaJIr7/gmR9MSJ7V2MlEcSWpq7XlK/img.gif?width=600&amp;amp;height=325&amp;amp;face=217_126_346_176,https://scrap.kakaocdn.net/dn/o84oH/hyXSw7cJW6/PQlOH7IcYR828Q4Y4odb60/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/80&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/80&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/ZcOZ7/hyXSDrKvt0/tRNH1ugT5utUEAdSBFlHX0/img.gif?width=600&amp;amp;height=325&amp;amp;face=217_126_346_176,https://scrap.kakaocdn.net/dn/FslGc/hyXSAaJIr7/gmR9MSJ7V2MlEcSWpq7XlK/img.gif?width=600&amp;amp;height=325&amp;amp;face=217_126_346_176,https://scrap.kakaocdn.net/dn/o84oH/hyXSw7cJW6/PQlOH7IcYR828Q4Y4odb60/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[React] IntersectionObserver를 통한 스크롤 이벤트&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;throttle과 IntersectionObserver API를 활용하여 Infinity Scroll을 라이브러리 없이 구현했습니다. 직접 구현하면서 여러 모듈을 조합하여 성능을 최적화하고, 부드럽게 데이터를 로드하기 위해 상당한 고&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React</category>
      <category>Hook</category>
      <category>LifeCycle</category>
      <category>react</category>
      <category>React.js</category>
      <category>useEffect</category>
      <category>개발</category>
      <category>라이프사이클</category>
      <category>생명주기</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/156</guid>
      <comments>https://dev-hpk.tistory.com/156#entry156comment</comments>
      <pubDate>Thu, 26 Dec 2024 18:43:02 +0900</pubDate>
    </item>
    <item>
      <title>[Taskify] 1차 배포 테스트(2) - 사용자 편의성 개선</title>
      <link>https://dev-hpk.tistory.com/155</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오늘은 저번 이슈 해결에 이어서 사용자 편의성 개선 작업을 해보려고 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  개선&lt;/b&gt;&lt;b&gt; 사항 : select 메뉴 버튼이나 옵션 바깥 부분을 클릭했을 때 select 메뉴가 닫히면 좋을 것 같다.&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;멘토님께 피드백을 받고 구글, 네이버 같은 사이트들을 확인해 봤는데 모두 select 메뉴 바깥 부분을 클릭하면 닫히도록 동작하네요 &lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  수정 코드 및 결과&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1735200022530&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/* import, type 등 생략... */

function Dropdown({
  children,
  menus,
  onMenuClick,
}: PropsWithChildren&amp;lt;DropdownProps&amp;gt;) {
  const { isOpen, toggleDropdown, closeDropdown } = useDropdown();
  const ref = useRef&amp;lt;HTMLDivElement | null&amp;gt;();

  const handleMenuClick = (value: string) =&amp;gt; {
    onMenuClick(value);
    closeDropdown();
  };

  useEffect(() =&amp;gt; {
    const handleClickOutside = (e: Event) =&amp;gt; {
      const target = e.target as HTMLDivElement;
       // ref(dropdown 컴포넌트) 외부 클릭 시 closeDropdown 호출해 닫기
      if (ref.current &amp;amp;&amp;amp; !ref.current.contains(target)) closeDropdown();
    };

    document.addEventListener('click', handleClickOutside); // 클릭 이벤트 리스너 등록

    // 컴포넌트 언마운트 시 이벤트 리스너 제거하도록 cleanup 함수 등록
    return () =&amp;gt; {
      document.removeEventListener('click', handleClickOutside); 
    };
  }, [ref]);

  return (
    &amp;lt;div
      ref={ref} // 드롭다운 컴포넌트를 ref로 참조
      className={styles.dropdown}
      onClick={(e) =&amp;gt; e.stopPropagation()} // 드롭다운 내부 클릭 시 이벤트 전파 방지
    &amp;gt;
      &amp;lt;button
        type=&quot;button&quot;
        className={styles['btn-dropdown']}
        onClick={toggleDropdown}
      &amp;gt;
        {children}
      &amp;lt;/button&amp;gt;
      {isOpen &amp;amp;&amp;amp; (
        &amp;lt;ul className={styles['dropdown-menus']}&amp;gt;
          {menus.map(({ label, value }) =&amp;gt; (
            &amp;lt;li key={`btn_${value}`}&amp;gt;
              &amp;lt;button
                type=&quot;button&quot;
                className={styles['dropdown-menu']}
                onClick={() =&amp;gt; handleMenuClick(value)}
              &amp;gt;
                {label}
              &amp;lt;/button&amp;gt;
            &amp;lt;/li&amp;gt;
          ))}
        &amp;lt;/ul&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  );
}

export default Dropdown;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-26-13-03-13.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/buka8o/btsLxhv46Yt/DpOKQQouygCh4R09FmMyl1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/buka8o/btsLxhv46Yt/DpOKQQouygCh4R09FmMyl1/img.gif&quot; data-alt=&quot;드롭다운 외부 클릭 시 닫힘&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/buka8o/btsLxhv46Yt/DpOKQQouygCh4R09FmMyl1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/buka8o/btsLxhv46Yt/DpOKQQouygCh4R09FmMyl1/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-26-13-03-13.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;드롭다운 외부 클릭 시 닫힘&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  개선&lt;/b&gt;&lt;b&gt;&amp;nbsp;사항 : 새로고침하거나 화면에 진입할 때 깜빡거리는 이슈&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;검색해 보니 &lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;useEffect는 컴포넌트들이 render와 paint 된 후 실행되고 &lt;/span&gt;&lt;span style=&quot;background-color: #f6e199; color: #212529; text-align: start;&quot;&gt;비동기적(asynchronous)&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;으로 실행된다고 합니다. 즉 paint 된 후 실행되기 때문에 useEffect 내부에 dom에 영향을 주는 코드가 있을 경우 사용자 입장에서는 화면의&lt;/span&gt; 깜빡임을 보게 된다고 하네요.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12838-Chrome-2024-12-26-16-41-22.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BGTvB/btsLz8xlcgZ/tnmR8XSULFeH7dkezKIRTK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BGTvB/btsLz8xlcgZ/tnmR8XSULFeH7dkezKIRTK/img.gif&quot; data-alt=&quot;렌더링 시 화면 깜빡이는 이슈&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BGTvB/btsLz8xlcgZ/tnmR8XSULFeH7dkezKIRTK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/BGTvB/btsLz8xlcgZ/tnmR8XSULFeH7dkezKIRTK/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;localhost_3000_dashboard_12838-Chrome-2024-12-26-16-41-22.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;렌더링 시 화면 깜빡이는 이슈&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; ❓ 해결 방법&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1. useEffect 대신 useLayoutEffect 사용&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;useEffect와 useLayoutEffect는 effect가 호출되는 타이밍이 다르고 하네요.&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;useEffect&lt;/span&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;렌더링 유발(상태 변경 또는 상위 렌더링)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;React가 컴포넌트 렌더링&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;화면이 시각적으로 업데이트(paint)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;useEffect&amp;nbsp;실행&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;useLayoutEffect&lt;/span&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;렌더링 유발(상태 변경 또는 상위 렌더링)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;React가 컴포넌트 렌더링&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;useLayoutEffect&amp;nbsp;실행, React는 완료될 때까지 기다림&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;화면이 시각적으로 업데이트(paint)&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2. Skeleton UI 적용&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;서버에서 데이터를 가져오는 동안 빈 화면이 노출되면 사용자는 콘텐츠를 기다리다가 쉽게 지치고 지루함을 느껴 사이트를 떠나게 되겠죠 &lt;/span&gt;&lt;br /&gt;&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; Skeleton UI는 실제 콘텐츠가 들어가게 될 자리를 잠시 대신할 빈 껍데기입니다. &lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;일반적인 패턴은 흰색 배경과 반짝이는 CSS 애니메이션을 적용한다고 하네요.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1138&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TGVO2/btsLxY3NV5C/BMNAX5YmOd2JdyMlQMDSn1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TGVO2/btsLxY3NV5C/BMNAX5YmOd2JdyMlQMDSn1/img.gif&quot; data-alt=&quot;Skeleton UI&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TGVO2/btsLxY3NV5C/BMNAX5YmOd2JdyMlQMDSn1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/TGVO2/btsLxY3NV5C/BMNAX5YmOd2JdyMlQMDSn1/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;436&quot; height=&quot;662&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1138&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Skeleton UI&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;저희 팀은 회의 끝에 &lt;a href=&quot;https://www.airbnb.co.kr/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;airbnb&lt;/a&gt;를 참고해 Skeleton UI를 적용하기로 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  코드 및 결과&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;UI 구조와 css는 기존 컬럼과 카드의 코드를 사용했기 때문에 생략했습니다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1735201664725&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const [columns, setColumns] = useState&amp;lt;ColumnType[] | null&amp;gt;(null);

const fetchColumns = useCallback(async () =&amp;gt; {
    const dashboardId = Number(query.id);

    if (!dashboardId) return;

    try {
      const { data, result } = await getColumns({
        teamId: '11-6',
        dashboardId,
      });

      if (result === 'SUCCESS') {
        setColumns(data);
      } else {
        setColumns([]);
      }
    } catch (error) {
      console.error('컬럼 조회 실패 : ', error);
      setColumns([]);
    }
  }, [query.id]);&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1735201581795&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  columns === null
  ? Array.from({ length: 4 }).map((_, index) =&amp;gt; (
      &amp;lt;SkeletonColumn key={`skeleton_${index}`} /&amp;gt;
  ))
  : columns.map(({ id, title }) =&amp;gt; (
      &amp;lt;Column
        key={`column_${id}`}
        columnId={id}
        columnTitle={title}
        setColumns={setColumns}
      /&amp;gt;
    ))
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1735202022984&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const fetchCards = useCallback(
    async ({
      cursor,
      size = 4,
      reset = false,
    }: {
      cursor?: number;
      size?: number;
      reset?: boolean;
    } = {}) =&amp;gt; {
      if (isLoading) return;
      setIsLoading(true);
      try {
        const response = await getCards({
          teamId: '11-6',
          size,
          columnId: targetId,
          cursorId: cursor,
        });

        const { cards, totalCount, cursorId } = response;

        setColumnData((prev) =&amp;gt; ({
          cards: reset ? cards : [...prev.cards, ...cards],
          totalCount,
          cursorId,
        }));
      } catch (error) {
        console.error('컬럼 조회 실패 : ', error);
      } finally {
        setIsLoading(false);
      }
    },
    [targetId],
  );&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1735202055228&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  isLoading
  ? Array.from({ length: 4 }).map((_, index) =&amp;gt; (
      &amp;lt;SkeletonCard key={`skeleton_${index}`} /&amp;gt;
    ))
  : columnData.cards.map(({
      imageUrl,
      id,
      title,
      tags,
      dueDate,
      assignee: { nickname, profileImageUrl },
    }) =&amp;gt; (
      &amp;lt;Card
        key={`card_${id}`}
        imageUrl={imageUrl}
        id={id}
        title={title}
        tags={tags}
        dueDate={dueDate}
        nickname={nickname}
        profileImage={profileImageUrl}
        columnTitle={columnTitle}
        columnId={columnId}
        setColumnData={setColumnData}
        onUpdate={handleUpdate}
      /&amp;gt;
    ),
 )}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Skeleton UI 관련 CSS&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1735202258204&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SkeletonColumn.module.css
.title-text,
.column-size,
.btn-edit-column,
.btn-add {
  background-color: var(--gray-medium);
  animation: loading 2s infinite linear;
}

@keyframes loading {
  0% {
    opacity: 0.99;
  }
  50% {
    opacity: 0.5;
  }
  100% {
    opacity: 0.99;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1735202278350&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SkeletonCard.module.css
.card-image,
.card-title,
.card-tags,
.icon-calendar,
.date,
.badge {
  background-color: var(--gray-medium);
  animation: loading 2s infinite linear;
}

@keyframes loading {
  0% {
    opacity: 0.99;
  }
  50% {
    opacity: 0.5;
  }
  100% {
    opacity: 0.99;
  }
}&lt;/code&gt;&lt;/pre&gt;

            &lt;figure class=&quot;unsupported component-kakaotv&quot; contenteditable=&quot;false&quot; style=&quot;background:#000;margin:16px 0;min-height:72px;padding:10px 16px;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;width:100%;max-width:100%;&quot;&gt;
                &lt;p contenteditable=&quot;false&quot; style=&quot;margin:0;color:#8a8a8a;font-size:13px;line-height:1.6;user-select:none;pointer-events:none;&quot;&gt;동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.&lt;/p&gt;
            &lt;/figure&gt;
        
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;돌이켜보면 저도 사이트에 접속했을 때 데이터가 늦게 로드되어 빈 화면이 보이면 바로 닫아버린 경험이 많았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이번 작업을 통해 사용자 입장에서 &lt;b&gt;&quot;어떻게 하면 더 자연스럽고 편리하게 느껴질까?&quot;&lt;/b&gt;를 깊이 고민하게 되었고, 이를 통해 &lt;b&gt;사용자 경험(UX)&lt;/b&gt;이 개발에서 얼마나 중요한지 다시금 깨달을 수 있었습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1735202996228&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 1차 배포 테스트 - Trouble shooting&quot; data-og-description=&quot;멘토님과 1차 배포 테스트를 진행했습니다.버그를 하나씩 찾아내시는데 제가 작업한 페이지도 버그가 많네요 테스트하면서 나온 이슈들을 정리해 보면 아래와 같습니다.버그를 하나씩 살펴&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/154&quot; data-og-url=&quot;https://dev-hpk.tistory.com/154&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bb9bh9/hyXSq0dYyz/qmDUvaSD9kg6Za4JFYKXUK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/DIvgE/hyXSwsBhqv/KoKwmwtCHre4rgmGSRJ0oK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/fOsIp/hyXSAIyHLp/JDiSmxT8W43VCE05rxMkO0/img.png?width=1422&amp;amp;height=376&amp;amp;face=0_0_1422_376&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/154&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/154&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bb9bh9/hyXSq0dYyz/qmDUvaSD9kg6Za4JFYKXUK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/DIvgE/hyXSwsBhqv/KoKwmwtCHre4rgmGSRJ0oK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/fOsIp/hyXSAIyHLp/JDiSmxT8W43VCE05rxMkO0/img.png?width=1422&amp;amp;height=376&amp;amp;face=0_0_1422_376');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 1차 배포 테스트 - Trouble shooting&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;멘토님과 1차 배포 테스트를 진행했습니다.버그를 하나씩 찾아내시는데 제가 작업한 페이지도 버그가 많네요 테스트하면서 나온 이슈들을 정리해 보면 아래와 같습니다.버그를 하나씩 살펴&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1735203003834&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] Tag 이슈 수정&quot; data-og-description=&quot;카드의 태그 관련 이슈가 발생했습니다.카드 생성 POST API에 태그 색상과 관련된 속성이 없어서 생긴 문제인데 확인해 보겠습니다.&amp;nbsp;  문제 상황화면이 리렌더링 될 때마다 태그의 색상이 랜덤 &quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/152&quot; data-og-url=&quot;https://dev-hpk.tistory.com/152&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/ZPXxe/hyXSCM8yeW/zf30ED2l3khg9MVnbcL5FK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cnRcKs/hyXSxZlSrn/OczzT3DfSDZ12kL3xOQDFk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bgsSNz/hyXSwF8NEN/JX0LE77vl4MQrYsFKft041/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/152&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/152&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/ZPXxe/hyXSCM8yeW/zf30ED2l3khg9MVnbcL5FK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cnRcKs/hyXSxZlSrn/OczzT3DfSDZ12kL3xOQDFk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bgsSNz/hyXSwF8NEN/JX0LE77vl4MQrYsFKft041/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] Tag 이슈 수정&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;카드의 태그 관련 이슈가 발생했습니다.카드 생성 POST API에 태그 색상과 관련된 속성이 없어서 생긴 문제인데 확인해 보겠습니다.&amp;nbsp;  문제 상황화면이 리렌더링 될 때마다 태그의 색상이 랜덤&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>react</category>
      <category>Skeleton</category>
      <category>skeleton ui</category>
      <category>개발</category>
      <category>스켈레톤</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/155</guid>
      <comments>https://dev-hpk.tistory.com/155#entry155comment</comments>
      <pubDate>Thu, 26 Dec 2024 17:49:18 +0900</pubDate>
    </item>
    <item>
      <title>[Taskify] 1차 배포 테스트 - Trouble shooting</title>
      <link>https://dev-hpk.tistory.com/154</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;멘토님과 1차 배포 테스트를 진행했습니다. 버그를 하나씩 찾아내시는데 제가 작업한 페이지도 버그가 많네요 &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;545&quot; data-origin-height=&quot;869&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3Ewp2/btsLxOMu8V0/B4ZkIKnQq0yvUet5qIPpKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3Ewp2/btsLxOMu8V0/B4ZkIKnQq0yvUet5qIPpKk/img.png&quot; data-alt=&quot;이슈&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3Ewp2/btsLxOMu8V0/B4ZkIKnQq0yvUet5qIPpKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3Ewp2%2FbtsLxOMu8V0%2FB4ZkIKnQq0yvUet5qIPpKk%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;545&quot; height=&quot;869&quot; data-origin-width=&quot;545&quot; data-origin-height=&quot;869&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이슈&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1422&quot; data-origin-height=&quot;376&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dPyLmd/btsLw1MvQfn/QPB5DOpolQ5FWZNHfviOQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dPyLmd/btsLw1MvQfn/QPB5DOpolQ5FWZNHfviOQk/img.png&quot; data-alt=&quot;이슈&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dPyLmd/btsLw1MvQfn/QPB5DOpolQ5FWZNHfviOQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdPyLmd%2FbtsLw1MvQfn%2FQPB5DOpolQ5FWZNHfviOQk%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;1422&quot; height=&quot;376&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1422&quot; data-origin-height=&quot;376&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이슈&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버그를 하나씩 살펴보면서 수정해 보겠습니다!&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  &lt;/b&gt;&lt;/span&gt;&lt;b&gt;문제 상황 : Todo 상세 모달의 타이틀이 길어지면 메뉴를 버튼 영역을 침범하고 UI가 깨짐&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;709&quot; data-origin-height=&quot;118&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uZgFJ/btsLvyLya94/6Z9OQn5edttoBwOxns3lmK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uZgFJ/btsLvyLya94/6Z9OQn5edttoBwOxns3lmK/img.png&quot; data-alt=&quot;UI 깨짐 이슈&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uZgFJ/btsLvyLya94/6Z9OQn5edttoBwOxns3lmK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuZgFJ%2FbtsLvyLya94%2F6Z9OQn5edttoBwOxns3lmK%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;709&quot; height=&quot;118&quot; data-origin-width=&quot;709&quot; data-origin-height=&quot;118&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UI 깨짐 이슈&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타이틀이 길어지는 경우 버튼 영역을 침범할 뿐 아니라, UI 적으로도 모달 헤더가 너무 길어 보여서 어색해 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모달 타이틀이 저렇게 긴 경우가 있을까 싶지만... 모든 상황을 고려해야겠죠 &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Figma, 기획서에 타이틀이 길어지는 경우에 관련된 정의가 없어 고민해 본 결과 타이틀을 1줄만 노출하고 길어지면 말 줄임(...) 처리하는 게 가장 자연스러운 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀원분들도 모두 말 줄임 처리가 좋다고 하셔서 바로 수정해 보겠습니다!&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  수정 코드 및 결과&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1735035212352&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.title {
  /*기존 스타일 생략*/
  margin-right: 84px; // 버튼 영역(84px)만큼 margin 적용

  /* 텍스트가 타이틀 너비를 넘어가면 말 줄임(...) 처리 */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;757&quot; data-origin-height=&quot;85&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/08jAH/btsLxsC2ggD/uxFjlbcsoqTxmwytqdrayk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/08jAH/btsLxsC2ggD/uxFjlbcsoqTxmwytqdrayk/img.png&quot; data-alt=&quot;UI 깨짐 이슈 수정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/08jAH/btsLxsC2ggD/uxFjlbcsoqTxmwytqdrayk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F08jAH%2FbtsLxsC2ggD%2FuxFjlbcsoqTxmwytqdrayk%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;757&quot; height=&quot;85&quot; data-origin-width=&quot;757&quot; data-origin-height=&quot;85&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UI 깨짐 이슈 수정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;b&gt;문제 상황 : 칼럼 타이틀이 길어지면 영역 밖으로 벗어남&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;605&quot; data-origin-height=&quot;171&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dV2ePx/btsLvwtpH7x/cGKx1l25V5tDypq4Kzik60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dV2ePx/btsLvwtpH7x/cGKx1l25V5tDypq4Kzik60/img.png&quot; data-alt=&quot;UI 깨짐 이슈&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dV2ePx/btsLvwtpH7x/cGKx1l25V5tDypq4Kzik60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdV2ePx%2FbtsLvwtpH7x%2FcGKx1l25V5tDypq4Kzik60%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;605&quot; height=&quot;171&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;605&quot; data-origin-height=&quot;171&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UI 깨짐 이슈&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 이슈도 모달 타이틀과 같은 맥락이라 말 줄임(...) 처리하기로 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정하려고 확대해서 자세히 보는데 타이틀 앞에 점(dot)도 위치가 가운데가 아니네요... &lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  수정 코드 및 결과&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1735035770651&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.column-title {
  /*기존 스타일 생략*/
  /* 30px = 컬럼 수정 버튼(24px) + 여백(6px) */
  width: calc(100% - 30px);
}

.column-title .title-text {
  /*기존 스타일 생략*/
  /* 텍스트가 타이틀 너비를 넘어가면 말 줄임(...) 처리 */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;431&quot; data-origin-height=&quot;159&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ceM2zf/btsLyahzugr/sMS9PFoKkXKkuttYGvqRf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ceM2zf/btsLyahzugr/sMS9PFoKkXKkuttYGvqRf1/img.png&quot; data-alt=&quot;UI 깨짐 이슈 수정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ceM2zf/btsLyahzugr/sMS9PFoKkXKkuttYGvqRf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FceM2zf%2FbtsLyahzugr%2FsMS9PFoKkXKkuttYGvqRf1%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;431&quot; height=&quot;159&quot; data-origin-width=&quot;431&quot; data-origin-height=&quot;159&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UI 깨짐 이슈 수정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;b&gt;문제 상황 : &lt;/b&gt;Chip 공통 컴포넌트 사용하는 영역(할 일 카드, 할 일 수정 모달) UI 깨짐 수정&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;717&quot; data-origin-height=&quot;137&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JbDBG/btsLw0mCdrC/tXQjYBOI2S2C0ZdShoK1Lk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JbDBG/btsLw0mCdrC/tXQjYBOI2S2C0ZdShoK1Lk/img.png&quot; data-alt=&quot;UI 깨짐 이슈&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JbDBG/btsLw0mCdrC/tXQjYBOI2S2C0ZdShoK1Lk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJbDBG%2FbtsLw0mCdrC%2FtXQjYBOI2S2C0ZdShoK1Lk%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;717&quot; height=&quot;137&quot; data-origin-width=&quot;717&quot; data-origin-height=&quot;137&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UI 깨짐 이슈&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;632&quot; data-origin-height=&quot;298&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RJaT8/btsLv5IWDKM/b16KkjrVqy25sjKrvU5a31/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RJaT8/btsLv5IWDKM/b16KkjrVqy25sjKrvU5a31/img.png&quot; data-alt=&quot;UI 깨짐 이슈&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RJaT8/btsLv5IWDKM/b16KkjrVqy25sjKrvU5a31/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRJaT8%2FbtsLv5IWDKM%2Fb16KkjrVqy25sjKrvU5a31%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;632&quot; height=&quot;298&quot; data-origin-width=&quot;632&quot; data-origin-height=&quot;298&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UI 깨짐 이슈&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 공통 컴포넌트를 설계할 때 저렇게 컴포넌트의 사이즈는 페이지에서 커스텀하도록 만들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트를 만들고 사용 방법을 문서화하기보다는 주석을 남기고, 예시 버튼들이 있는 테스트 페이지를 만들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 방법이 더 효율적일 거라고 생각했는데, 주석을 읽지 않고 복붙 하는 상황이 생겨서 이슈가 발생했네요.. 수정해 보겠습니다 &lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  수정 코드 및 결과&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1735036822781&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/* 할 일 카드 모달 Chip 수정 */
.status .status-text {
  /*기존 스타일 생략*/
  max-width: 80px;

  /* 텍스트가 타이틀 너비를 넘어가면 말 줄임(...) 처리 */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1735036931268&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/* 할 일 수정 모달 Chip 수정 */
.status {
  overflow: hidden;
}

.status-content {
  /* 30px = 옵션 체크 아이콘(22px) + 여백(8px) */
  width: calc(100% - 30px);
}

.status-select-text { 
  /* 텍스트가 타이틀 너비를 넘어가면 말 줄임(...) 처리 */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;755&quot; data-origin-height=&quot;172&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ptIjl/btsLv5CiGo9/W2G1gZrkDcdvUbdYpkja90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ptIjl/btsLv5CiGo9/W2G1gZrkDcdvUbdYpkja90/img.png&quot; data-alt=&quot;UI 깨짐 이슈 수정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ptIjl/btsLv5CiGo9/W2G1gZrkDcdvUbdYpkja90/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FptIjl%2FbtsLv5CiGo9%2FW2G1gZrkDcdvUbdYpkja90%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;755&quot; height=&quot;172&quot; data-origin-width=&quot;755&quot; data-origin-height=&quot;172&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UI 깨짐 이슈 수정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;337&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJp1cz/btsLvSQEOiZ/EALOzwjlhzO6eUmt74bA01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJp1cz/btsLvSQEOiZ/EALOzwjlhzO6eUmt74bA01/img.png&quot; data-alt=&quot;UI 깨짐 이슈 수정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJp1cz/btsLvSQEOiZ/EALOzwjlhzO6eUmt74bA01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJp1cz%2FbtsLvSQEOiZ%2FEALOzwjlhzO6eUmt74bA01%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;645&quot; height=&quot;337&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;337&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UI 깨짐 이슈 수정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;border: 10px solid #009a87; border-radius: 0px; background-color: #ffffff; padding: 15px 30px; margin: 0;&quot;&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; top: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p style=&quot;text-align: left; font-weight: bold;&quot; data-ke-size=&quot;size18&quot;&gt;느낀 점&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;테스트 후 발견된 버그와 이슈를 수정하면서 개발 단계에서 더욱 꼼꼼하고 세심하게 작업해야 한다는 점을 다시 한번 느꼈습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이슈 대부분이 스타일 관련 내용인 걸 보니, 마크업과 CSS를 특히 더 신경 써서 작업해야 할 것 같습니다.. &lt;/p&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; bottom: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1735038374505&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] Tag 이슈 수정&quot; data-og-description=&quot;카드의 태그 관련 이슈가 발생했습니다.카드 생성 POST API에 태그 색상과 관련된 속성이 없어서 생긴 문제인데 확인해 보겠습니다.&amp;nbsp;  문제 상황화면이 리렌더링 될 때마다 태그의 색상이 랜덤 &quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/152&quot; data-og-url=&quot;https://dev-hpk.tistory.com/152&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/h2WmV/hyXOgLmiL7/ZA84V2g718oiEOOkmjmZj0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/eT22J/hyXOp9ojtL/i7HkfracIOX1eX8evDE3M1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/eUki2/hyXOmdNlyU/kF1IzrJkob435FFL1Rgmt0/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/152&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/152&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/h2WmV/hyXOgLmiL7/ZA84V2g718oiEOOkmjmZj0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/eT22J/hyXOp9ojtL/i7HkfracIOX1eX8evDE3M1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/eUki2/hyXOmdNlyU/kF1IzrJkob435FFL1Rgmt0/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] Tag 이슈 수정&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;카드의 태그 관련 이슈가 발생했습니다.카드 생성 POST API에 태그 색상과 관련된 속성이 없어서 생긴 문제인데 확인해 보겠습니다.&amp;nbsp;  문제 상황화면이 리렌더링 될 때마다 태그의 색상이 랜덤&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1735038378875&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 이미지 확장자 제한 추가&quot; data-og-description=&quot;오전 스크럼 회의 때 카드 이미지에 대한 이슈가 있었습니다. 담당 팀원분이 바쁜 관계로 제가 수정하기로 했습니다.&amp;nbsp; 제가 작성한 로직은 아니지만, 서로서로 돕는 게 팀이죠  &amp;nbsp;   문제 상&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/151&quot; data-og-url=&quot;https://dev-hpk.tistory.com/151&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/Uzxcr/hyXSsDpjdv/Qvr0snO9zxwniCntqwARQ1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/y64nj/hyXSxdD7oP/P4aY0CKIQTtjtaCFrU8w70/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/fWWBf/hyXOpn1irV/RZ5yBAI658HGeBcLvOLdJ1/img.png?width=1746&amp;amp;height=994&amp;amp;face=0_0_1746_994&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/151&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/151&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/Uzxcr/hyXSsDpjdv/Qvr0snO9zxwniCntqwARQ1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/y64nj/hyXSxdD7oP/P4aY0CKIQTtjtaCFrU8w70/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/fWWBf/hyXOpn1irV/RZ5yBAI658HGeBcLvOLdJ1/img.png?width=1746&amp;amp;height=994&amp;amp;face=0_0_1746_994');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 이미지 확장자 제한 추가&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;오전 스크럼 회의 때 카드 이미지에 대한 이슈가 있었습니다. 담당 팀원분이 바쁜 관계로 제가 수정하기로 했습니다.&amp;nbsp; 제가 작성한 로직은 아니지만, 서로서로 돕는 게 팀이죠  &amp;nbsp;   문제 상&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>react</category>
      <category>개발</category>
      <category>배포</category>
      <category>테스트</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/154</guid>
      <comments>https://dev-hpk.tistory.com/154#entry154comment</comments>
      <pubDate>Tue, 24 Dec 2024 20:06:27 +0900</pubDate>
    </item>
    <item>
      <title>주소창에 www.google.com을 검색하면 일어나는 일</title>
      <link>https://dev-hpk.tistory.com/153</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;820&quot; data-origin-height=&quot;619&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rMBbg/btsLuMA8VLG/9XjObIKksy8ZGttcxF05r1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rMBbg/btsLuMA8VLG/9XjObIKksy8ZGttcxF05r1/img.png&quot; data-alt=&quot;도메인 입력 시 브라우저에 보여지는 과정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rMBbg/btsLuMA8VLG/9XjObIKksy8ZGttcxF05r1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrMBbg%2FbtsLuMA8VLG%2F9XjObIKksy8ZGttcxF05r1%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;820&quot; height=&quot;619&quot; data-origin-width=&quot;820&quot; data-origin-height=&quot;619&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;도메인 입력 시 브라우저에 보여지는 과정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1. 입력한 URL 주소 중, 도메인 이름에 해당하는 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;google.com&lt;/span&gt;을 DNS 서버에서 검색합니다.&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;웹 브라우저는 DNS 서버에 검색하기 전에&amp;nbsp;&lt;b&gt;캐싱된 DNS 기록&lt;/b&gt;을 통해 해당 도메인 주소와&amp;nbsp;&lt;b&gt;대응하는 IP 주소&lt;/b&gt;를 확인합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;해당 도메인 이름에 맞는&amp;nbsp;&lt;b&gt;IP 주소가 존재&lt;/b&gt;하면, DNS 서버에 도메인 이름에 해당하는 IP 주소를 요청하지 않고&amp;nbsp;&lt;b&gt;캐싱된 IP 주소&lt;/b&gt;를 바로 반환합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;일치하는 주소가 없다면 DNS 서버에 도메인 이름에 해당하는 IP 주소를 요청합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;640&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUwTk0/btsLuZ72TX7/uww2pw3P5nQfIxl4hwN6Z0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUwTk0/btsLuZ72TX7/uww2pw3P5nQfIxl4hwN6Z0/img.png&quot; data-alt=&quot;DNS 검색&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUwTk0/btsLuZ72TX7/uww2pw3P5nQfIxl4hwN6Z0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUwTk0%2FbtsLuZ72TX7%2Fuww2pw3P5nQfIxl4hwN6Z0%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;1280&quot; height=&quot;640&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;640&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DNS 검색&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2. DNS가 웹브라우저에게 찾는 사이트의 IP주소와 사용자가 입력한 URL 정보를 함께 전달합니다.&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; DNS query는 현재 DNS서버에 원하는 IP주소가 존재하지 않으면 다른 DNS 서버를 방문하는 과정을 반복해 IP주소를 찾습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 해당 도메인 이름에 맞는 IP주소로 변환하는 과정은 점(.)을 기준으로 계층적으로 구분하여 구성됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;탐색 순서는 뒤에서부터 해당 도메인 이름에 맞는 지역 DNS를 탐색하며, root DNS 서버가 나올 때까지 거꾸로 탐색합니다. &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;964&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TdTR2/btsLtJrARcK/9s32hKAK3WWNWkpEGq9bL1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TdTR2/btsLtJrARcK/9s32hKAK3WWNWkpEGq9bL1/img.png&quot; data-alt=&quot;DNS 탐색&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TdTR2/btsLtJrARcK/9s32hKAK3WWNWkpEGq9bL1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTdTR2%2FbtsLtJrARcK%2F9s32hKAK3WWNWkpEGq9bL1%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;964&quot; height=&quot;480&quot; data-origin-width=&quot;964&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DNS 탐색&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;3. 전달받은 IP주소를 이용해&amp;nbsp; 웹 서버에게 해당 웹 사이트에 맞는 html문서를 요청합니다.&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;브라우저는&lt;b&gt; HTTP&amp;nbsp;프로토콜&lt;/b&gt;을 사용하여 요청 메시지를 생성하고 HTTP 요청 메시지는&lt;b&gt; TCP/IP 프로토콜&lt;/b&gt;을 사용하여 서버로 전송됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;4. 서버는 response&amp;nbsp;메시지를 생성하여 다시 브라우저에게 데이터를 전송합니다.&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;5. 브라우저는&amp;nbsp;response를&amp;nbsp;받아&amp;nbsp;파싱하여&amp;nbsp;화면에&amp;nbsp;렌더링 합니다.&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;939&quot; data-origin-height=&quot;358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oZLVE/btsLs9xivOO/bt4BusJyRPUNWXhDazEpUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oZLVE/btsLs9xivOO/bt4BusJyRPUNWXhDazEpUK/img.png&quot; data-alt=&quot;도메인 입력 시 브라우저 렌더링 과정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oZLVE/btsLs9xivOO/bt4BusJyRPUNWXhDazEpUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoZLVE%2FbtsLs9xivOO%2Fbt4BusJyRPUNWXhDazEpUK%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;939&quot; height=&quot;358&quot; data-origin-width=&quot;939&quot; data-origin-height=&quot;358&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;도메인 입력 시 브라우저 렌더링 과정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt; 용어 정리&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;URL : &lt;/b&gt;&lt;/b&gt;&lt;/b&gt;URL(Uniform Resource Locator)은 통합 자원 지시자로 인터넷에서 특정 리소스(웹 페이지, 파일 등)를 찾기 위한 주소 체계입니다. URL을 통해 인터넷상의 모든 리소스를 요청할 수 있으며, HTTP, FTP 등의 자원 요청도 가능합니다. URL은 아래와 같은 요소로 구성됩니다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;프로토콜&lt;/b&gt;: 리소스를 어떻게 접근할지 정의 (예: http, https)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;도메인 이름&lt;/b&gt;: 사람에게 읽기 쉬운 주소 (예: www.google.com)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;경로(Path)&lt;/b&gt;: 서버 내 특정 리소스 위치 (예: /search)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;쿼리(Query)&lt;/b&gt;: 추가 요청 데이터 (예: ?q=example)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;포트 번호&lt;/b&gt; (선택 사항): 통신할 서버의 포트 (예: :80)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;b&gt;DNS : &lt;/b&gt;&lt;/b&gt;도메인 이름 시스템(DNS)은 사람이 읽을 수 있는 도메인 이름(https://www.google.com)을 IP 주소(142.250.185.14)로 변환하는 시스템입니다.&amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&amp;nbsp;&lt;b&gt;&lt;b&gt;IP 주소 : &lt;/b&gt;&lt;/b&gt;IP 주소는 인터넷에 연결된 각 장치를 식별하는 고유 번호로&amp;nbsp;장치 간 통신을 위해 출발지와 목적지를 식별하기 위해 사용됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;TCP : &lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: left;&quot;&gt;TCP (전송 제어 프로토콜)는 두 개의 호스트를 연결하고 데이터 스트림을 교환하게 해주는 중요한 네트워크 프로토콜로 데이터가 손실되거나 순서가 뒤바뀌지 않도록 보장합니다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;프로토콜 :&lt;/b&gt; 네트워크에서 데이터가 어떻게 전송되고 처리될지 규정하는 약속이나 규칙입니다. 종류로는 HTTP, HTTPS 등이 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;response :&lt;/b&gt; 서버가 브라우저(또는 클라이언트)의 요청(Request)을 처리한 후 반환하는 데이터입니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;파싱 :&lt;/b&gt; 데이터를 읽고 분석하여 구조화된 형태로 변환하는 과정입니다. 브라우저가 서버에서 받은 데이터를 이해하고, 렌더링 하거나 추가 작업을 수행할 수 있도록 합니다. &lt;/span&gt;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;HTML 파싱 : HTML 문서를 읽고 DOM(Document Object Model)을 생성합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;CSS 파싱 : CSS를 읽고 CSSOM(CSS Object Model)을 생성합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>CS</category>
      <category>CS</category>
      <category>개발</category>
      <category>브라우저</category>
      <category>브라우저 동작</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/153</guid>
      <comments>https://dev-hpk.tistory.com/153#entry153comment</comments>
      <pubDate>Mon, 23 Dec 2024 16:27:51 +0900</pubDate>
    </item>
    <item>
      <title>[Taskify] Tag 이슈 수정</title>
      <link>https://dev-hpk.tistory.com/152</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;카드의 태그 관련 이슈가 발생했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;카드 생성 POST API에 태그 색상과 관련된 속성이 없어서 생긴 문제인데 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  문제 상황&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;화면이 리렌더링 될 때마다 태그의 색상이 랜덤 하게 변경됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-23-13-46-40.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bokcCa/btsLrVM3dnU/p3qfUQW90Fe4acqdMdwit0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bokcCa/btsLrVM3dnU/p3qfUQW90Fe4acqdMdwit0/img.gif&quot; data-alt=&quot;문제 상황&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bokcCa/btsLrVM3dnU/p3qfUQW90Fe4acqdMdwit0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bokcCa/btsLrVM3dnU/p3qfUQW90Fe4acqdMdwit0/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-23-13-46-40.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;문제 상황&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;카드를 생성하는 POST API에 태그 색상에 대한 옵션이 없어서 발생한 문제입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;처음 작업할 때 태그 컴포넌트가 렌더링 될 때 정해진 5개 색상 중 랜덤하게 설정되도록 만들었거든요... &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734930966098&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;const getTagColor = (styles: Record&amp;lt;string, string&amp;gt;): string =&amp;gt; {
  if (!bgTag || bgTag.length === 0) return '';
  const idx = Math.floor(Math.random() * bgTag.length);
  return styles[bgTag[idx]];
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1734930942598&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Chip({ children, chipType }: PropsWithChildren&amp;lt;ChipProps&amp;gt;) {
  const className = clsx(
    styles[chipType],
    chipType === 'tag' &amp;amp;&amp;amp; getTagColor(styles),
  );

  return (
    &amp;lt;span className={className}&amp;gt;
      {children}
    &amp;lt;/span&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;스크럼 회의 때 많은 논의를 했고 결론이 나진 않았지만, 다음과 같은 의견들이 나왔습니다.&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;태그의 배경 색상을 제거하자.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;카드 정보를 Redux로 전역 관리하자.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;CSS의 nth-child() 선택자로 태그의 순서에 따라 색상을 결정하자.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;의견이 좁혀지지 않았고, 그 이유는 다음과 같았습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;태그의 배경 색상을 제거하면 기획 요건을 충족하지 못하니 기획서대로 구현하자.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;카드 정보를 Redux로 전역 관리하는 것은 불필요한 리소스를 증가시키는 것 같다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;CSS의 nth-child() 선택자를 사용하면, 태그를 삭제하면 색상이 변경되어 일관성이 없는 것 같다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  해결 방법&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;현재 태그(Chip 컴포넌트)가 렌더링 될 때 랜덤으로 색상을 생성하는 유틸 함수를 이용하면 될 것 같다는 아이디어가 생각나서 팀원분들께 적용해 보겠다고 말씀드렸습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;서버에 카드 생성(POST) 요청을 보낼 때 태그만 보내는 것이 아니라 태그에 색상을 추가해서 보내는 것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;간단히 말하자면 태그를 생성하면 태그 문자열 뒤에 랜덤 한 색상을 추가하는 거죠!&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;&lt;b&gt;Chip 컴포넌트&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734931897296&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const getTagColor = (): string =&amp;gt; {
  if (!bgTag || bgTag.length === 0) return '';
  const idx = Math.floor(Math.random() * bgTag.length);
  return bgTag[idx];
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1734931945278&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Chip({ children, chipType, color }: PropsWithChildren&amp;lt;ChipProps&amp;gt;) {
  const className = clsx(styles[chipType], chipType === 'tag' &amp;amp;&amp;amp; styles[color]);

  return (
    &amp;lt;span className={className}&amp;gt;
      {children}
    &amp;lt;/span&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존 Chip 컴포넌트와 다르게 color를 props로 받아오게 수정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;태그(Chip 컴포넌트)를 렌더링 하는 페이지들도 수정해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Chip 컴포넌트 렌더링 페이지&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734932213781&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleTagInput = (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const newTag = e.target.value.trim();
    const color = getTagColor();
    const coloredTag = `${newTag} ${color}`;

    if (!newTag || tags.includes(newTag)) return; // 빈 문자열 또는 중복 태그 방지
    onAddTag(coloredTag);
    e.target.value = '';
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;const coloredTag = `${newTag} ${color}` : 태그를 템플릿 리터럴(``)을 사용해 태그 색상 형식으로 tag 배열에 저장했습니다. tag 배열은 카드 생성(POST) 요청에 request body에 포함되어 전송됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1734932281893&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{tags.map((tag) =&amp;gt; {
    const [tagText, tagColor] = tag.split(' ');
	return (
    	&amp;lt;Chip key={`${cardId}_tag_${tag}`} chipType=&quot;tag&quot; color={tagColor}&amp;gt;
            {tagText}
        &amp;lt;/Chip&amp;gt;
    );
})}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;const [tagText, tagColor] = tag.split(' ') : 서버에서 받은 tag 데이터를 공백을 기준으로 text와 color로 구조 분해 할당 했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;해결한 줄 알았지만, 이 방법도 문제가 있네요. 사용자가 태그를 입력할 때 공백을 추가해서 보내는 경우를 고려 못했습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;125&quot; data-origin-height=&quot;136&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cIDQsD/btsLs9qtB2P/D5OCJVnFgC46DnvkftLckk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cIDQsD/btsLs9qtB2P/D5OCJVnFgC46DnvkftLckk/img.png&quot; data-alt=&quot;테스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cIDQsD/btsLs9qtB2P/D5OCJVnFgC46DnvkftLckk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcIDQsD%2FbtsLs9qtB2P%2FD5OCJVnFgC46DnvkftLckk%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;125&quot; height=&quot;136&quot; data-origin-width=&quot;125&quot; data-origin-height=&quot;136&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;테스트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;공백을 추가해서 입력하면 아래와 같이 나옵니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;132&quot; data-origin-height=&quot;51&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GPZZ9/btsLt5HSnIZ/KsqfCZUKmi43GigKJvLmbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GPZZ9/btsLt5HSnIZ/KsqfCZUKmi43GigKJvLmbk/img.png&quot; data-alt=&quot;테스트 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GPZZ9/btsLt5HSnIZ/KsqfCZUKmi43GigKJvLmbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGPZZ9%2FbtsLt5HSnIZ%2FKsqfCZUKmi43GigKJvLmbk%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;132&quot; height=&quot;51&quot; data-origin-width=&quot;132&quot; data-origin-height=&quot;51&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;테스트 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;273&quot; data-origin-height=&quot;68&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmpTgj/btsLtGImXAT/FSRevYxrORWvvtgqtBfRy0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmpTgj/btsLtGImXAT/FSRevYxrORWvvtgqtBfRy0/img.png&quot; data-alt=&quot;테스트 결과 console&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmpTgj/btsLtGImXAT/FSRevYxrORWvvtgqtBfRy0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmpTgj%2FbtsLtGImXAT%2FFSRevYxrORWvvtgqtBfRy0%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;273&quot; height=&quot;68&quot; data-origin-width=&quot;273&quot; data-origin-height=&quot;68&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;테스트 결과 console&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;✨ 최종 해결 방법&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;사용자가 태그에 사용하지 않을 것 같은 특수 문자를 구분자로 사용하겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;태그 입력 시 구분자로 사용한 특수 문자를 포함 못하게 하는 것도 필수겠죠?&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734933218408&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleTagInput = (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const newTag = e.target.value.trim();
    const color = getTagColor();
    const coloredTag = `${newTag}^${color}`;

    if (!newTag || tags.includes(newTag) || newTag.includes('^')) return; // 빈 문자열 또는 중복 태그 방지
    onAddTag(coloredTag);
    e.target.value = '';
  };&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1734933255346&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{tags.map((tag) =&amp;gt; {
    const [tagText, tagColor] = tag.split('^');
	return (
    	&amp;lt;Chip key={`${cardId}_tag_${tag}`} chipType=&quot;tag&quot; color={tagColor}&amp;gt;
            {tagText}
        &amp;lt;/Chip&amp;gt;
    );
})}&lt;/code&gt;&lt;/pre&gt;

            &lt;figure class=&quot;unsupported component-kakaotv&quot; contenteditable=&quot;false&quot; style=&quot;background:#000;margin:16px 0;min-height:72px;padding:10px 16px;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;width:100%;max-width:100%;&quot;&gt;
                &lt;p contenteditable=&quot;false&quot; style=&quot;margin:0;color:#8a8a8a;font-size:13px;line-height:1.6;user-select:none;pointer-events:none;&quot;&gt;동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.&lt;/p&gt;
            &lt;/figure&gt;
        
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734933620194&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 이미지 확장자 제한 추가&quot; data-og-description=&quot;오전 스크럼 회의 때 카드 이미지에 대한 이슈가 있었습니다. 담당 팀원분이 바쁜 관계로 제가 수정하기로 했습니다.&amp;nbsp; 제가 작성한 로직은 아니지만, 서로서로 돕는 게 팀이죠  &amp;nbsp;   문제 상&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/151&quot; data-og-url=&quot;https://dev-hpk.tistory.com/151&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cdnU9w/hyXSwr4NFI/we1KjtAXfkcAeQrdtD9w7K/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/fajhW/hyXSCZ7Slk/ieV0CpjA49TDIHi4TMrqK0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/wozuS/hyXOf6AGFy/ydWVKc12nmQvEwWSyQDsCk/img.png?width=1746&amp;amp;height=994&amp;amp;face=0_0_1746_994&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/151&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/151&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cdnU9w/hyXSwr4NFI/we1KjtAXfkcAeQrdtD9w7K/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/fajhW/hyXSCZ7Slk/ieV0CpjA49TDIHi4TMrqK0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/wozuS/hyXOf6AGFy/ydWVKc12nmQvEwWSyQDsCk/img.png?width=1746&amp;amp;height=994&amp;amp;face=0_0_1746_994');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 이미지 확장자 제한 추가&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;오전 스크럼 회의 때 카드 이미지에 대한 이슈가 있었습니다. 담당 팀원분이 바쁜 관계로 제가 수정하기로 했습니다.&amp;nbsp; 제가 작성한 로직은 아니지만, 서로서로 돕는 게 팀이죠  &amp;nbsp;   문제 상&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734933632248&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 할 일 카드 모달 컴포넌트 (feat. optimistic update)&quot; data-og-description=&quot;오늘은 대시보드 상세에서 카드의 상세 모달을 작업해 봤습니다! 우선 전반적인 코드를 먼저 보여드리고 작업하면서 있었던 문제들과 해결한 방법에 대해서 설명해 볼게요!(컴포넌트 구조보다 &quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/148&quot; data-og-url=&quot;https://dev-hpk.tistory.com/148&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/z5yEf/hyXSCZ7WTv/2xK4FnckZiF3GkFBSrb590/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bTjMqo/hyXOpVGeks/12dtrTYC1jeqRwJjIH6wEK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/qL38y/hyXOf6AGUa/CK1swcyGharevMa3Ac4o61/img.png?width=596&amp;amp;height=615&amp;amp;face=0_0_596_615&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/148&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/148&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/z5yEf/hyXSCZ7WTv/2xK4FnckZiF3GkFBSrb590/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bTjMqo/hyXOpVGeks/12dtrTYC1jeqRwJjIH6wEK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/qL38y/hyXOf6AGUa/CK1swcyGharevMa3Ac4o61/img.png?width=596&amp;amp;height=615&amp;amp;face=0_0_596_615');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 할 일 카드 모달 컴포넌트 (feat. optimistic update)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 대시보드 상세에서 카드의 상세 모달을 작업해 봤습니다! 우선 전반적인 코드를 먼저 보여드리고 작업하면서 있었던 문제들과 해결한 방법에 대해서 설명해 볼게요!(컴포넌트 구조보다&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>js</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>Post</category>
      <category>react</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/152</guid>
      <comments>https://dev-hpk.tistory.com/152#entry152comment</comments>
      <pubDate>Mon, 23 Dec 2024 14:59:21 +0900</pubDate>
    </item>
    <item>
      <title>[Taskify] 이미지 확장자 제한 추가</title>
      <link>https://dev-hpk.tistory.com/151</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오전 스크럼 회의 때 카드 이미지에 대한 이슈가 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 담당 팀원분이 바쁜 관계로 제가 수정하기로 했습니다.&amp;nbsp; 제가 작성한 로직은 아니지만, 서로서로 돕는 게 팀이죠  &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;   문제 상황&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 이미지를 등록한 상태로 카드를 생성했지만, 화면에 보이는 카드는 이미지가 없이 렌더링 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;520&quot; data-origin-height=&quot;772&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/C5eVN/btsLp5WWW6M/0EaDvYYKnwmek9ghypvTf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/C5eVN/btsLp5WWW6M/0EaDvYYKnwmek9ghypvTf0/img.png&quot; data-alt=&quot;할 일 카드 생성 모달&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/C5eVN/btsLp5WWW6M/0EaDvYYKnwmek9ghypvTf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FC5eVN%2FbtsLp5WWW6M%2F0EaDvYYKnwmek9ghypvTf0%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;449&quot; height=&quot;667&quot; data-origin-width=&quot;520&quot; data-origin-height=&quot;772&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;할 일 카드 생성 모달&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;334&quot; data-origin-height=&quot;147&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cIadGL/btsLrhoPN5N/Fn78ksfUOZyWVDo9kRFWOk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cIadGL/btsLrhoPN5N/Fn78ksfUOZyWVDo9kRFWOk/img.png&quot; data-alt=&quot;할 일 카드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cIadGL/btsLrhoPN5N/Fn78ksfUOZyWVDo9kRFWOk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcIadGL%2FbtsLrhoPN5N%2FFn78ksfUOZyWVDo9kRFWOk%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;334&quot; height=&quot;147&quot; data-origin-width=&quot;334&quot; data-origin-height=&quot;147&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;할 일 카드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;  해결 방법&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&amp;lt;input type='file' /&amp;gt;&lt;/span&gt;&lt;/b&gt;에 accept 속성을 사용해 이미지 확장자를 제한해 보겠습니다❗&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;테스트해 보니 svg 형식을 업로드하면 이미지가 안 보이는 것 같습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;gif, jpg, png 확장자의 경우 잘 렌더링 되는 것을 확인했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1734762996934&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;input
    type=&quot;file&quot;
    id=&quot;profile-image&quot;
    onChange={onImageChange}
    className={styles[`img-input`]}
    accept=&quot;.gif, .jpg, .png&quot;
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과를 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12794 - Chrome 2024-12-21 오전 11_45_47.png&quot; data-origin-width=&quot;1746&quot; data-origin-height=&quot;994&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/budfgJ/btsLsf40edZ/JdchP2SW5IBXyEHxOFZkv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/budfgJ/btsLsf40edZ/JdchP2SW5IBXyEHxOFZkv0/img.png&quot; data-alt=&quot;이미지 파일 선택 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/budfgJ/btsLsf40edZ/JdchP2SW5IBXyEHxOFZkv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbudfgJ%2FbtsLsf40edZ%2FJdchP2SW5IBXyEHxOFZkv0%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;1746&quot; height=&quot;994&quot; data-filename=&quot;localhost_3000_dashboard_12794 - Chrome 2024-12-21 오전 11_45_47.png&quot; data-origin-width=&quot;1746&quot; data-origin-height=&quot;994&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이미지 파일 선택 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;깔끔하게 해결이면 좋겠지만, 파일 선택창 하단에 사용자 지정 파일을 선택할 수 있게 되어있네요.. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;한 번 눌러봐야겠죠?&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12794 - Chrome 2024-12-21 오전 11_45_55.png&quot; data-origin-width=&quot;1724&quot; data-origin-height=&quot;875&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzWNmP/btsLtbt81wn/Y9KNj0YeehEZhjgA0eEue1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzWNmP/btsLtbt81wn/Y9KNj0YeehEZhjgA0eEue1/img.png&quot; data-alt=&quot;확장자 변경&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzWNmP/btsLtbt81wn/Y9KNj0YeehEZhjgA0eEue1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbzWNmP%2FbtsLtbt81wn%2FY9KNj0YeehEZhjgA0eEue1%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;1724&quot; height=&quot;875&quot; data-filename=&quot;localhost_3000_dashboard_12794 - Chrome 2024-12-21 오전 11_45_55.png&quot; data-origin-width=&quot;1724&quot; data-origin-height=&quot;875&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;확장자 변경&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;슬픈 예감은 항상 틀린 적이 없네요. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;사용자가 파일 형식을 모든 파일로 변경하면 accept 속성이 무용지물입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;✨ 최종 해결 방법&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로그인 기능을 구현할 때 Input의 value들을 검증하는 로직을 작성했던 기억이 떠올랐습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&amp;lt;input type='file' /&amp;gt;&lt;/span&gt;&amp;nbsp;&lt;/b&gt;도 같은 input이니 onChange 이벤트가 발생했을 때 파일 확장자를 검사하면 되겠죠?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;파일 Input 핸들러 - value 검증 추가 전 코드&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734763512995&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleImageChange = (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const file = e.target.files?.[0];
    
    if (file) {
      setImage(file);
      const imgURL = URL.createObjectURL(file);
      setPreview(imgURL);
    }
  };&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;음... 어떤 식으로 처리해야 할지 고민이네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;일단 파일이 어떤 형식인지 console로 확인해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;510&quot; data-origin-height=&quot;149&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nviak/btsLtej7C6y/H3bp5lHKYmFN6i3ZmlG570/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nviak/btsLtej7C6y/H3bp5lHKYmFN6i3ZmlG570/img.png&quot; data-alt=&quot;파일 확장자 console 디버깅&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nviak/btsLtej7C6y/H3bp5lHKYmFN6i3ZmlG570/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fnviak%2FbtsLtej7C6y%2FH3bp5lHKYmFN6i3ZmlG570%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;510&quot; height=&quot;149&quot; data-origin-width=&quot;510&quot; data-origin-height=&quot;149&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;파일 확장자 console 디버깅&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;name에 파일 이름과 확장자가 있네요. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;알고리즘 풀이에서 수도 없이 연습했던 문자열 처리 실력을 보여줄 때가 된 것 같습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;파일 Input 핸들러 - value 검증 추가 코드&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734764148774&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleImageChange = (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const file = e.target.files?.[0];

    if (file) {
      const allowedExtensions = ['png', 'gif', 'jpg'];

      // 이미지 확장자
      const imgFileExtension = file.name.split('.').pop()?.toLowerCase();

      // 확장자 검증
      if (!imgFileExtension || !allowedExtensions.includes(imgFileExtension)) {
        alert('허용되지 않는 파일 형식입니다 (png, gif, jpg만 등록 가능)');
        return;
      }

      setImage(file);
      const imgURL = URL.createObjectURL(file);
      setPreview(imgURL);
    }
  };&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; 결과&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_edited_localhost_3000_dashboard_12794 - Chrome 2024-12-21 오전 11_46_03.png&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;850&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pn2wk/btsLrFwb8qc/fLzOG3OMebH80KkN0Kkjs0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pn2wk/btsLrFwb8qc/fLzOG3OMebH80KkN0Kkjs0/img.png&quot; data-alt=&quot;확장자 제한 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pn2wk/btsLrFwb8qc/fLzOG3OMebH80KkN0Kkjs0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fpn2wk%2FbtsLrFwb8qc%2FfLzOG3OMebH80KkN0Kkjs0%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;894&quot; height=&quot;850&quot; data-filename=&quot;edited_edited_localhost_3000_dashboard_12794 - Chrome 2024-12-21 오전 11_46_03.png&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;850&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;확장자 제한 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;사용자가 모든 파일 형식을 선택하고 gif, jpg, png 외 다른 파일을 추가하려고 하면 정상적으로 alert을 출력합니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;border: 10px solid #009a87; border-radius: 0px; background-color: #ffffff; padding: 15px 30px; margin: 0;&quot;&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; top: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p style=&quot;text-align: left; font-weight: bold;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;느낀 점&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;input 처리 시에는 항상 검증(validation)이 필요한지 고려하는 습관을 들여야겠다고 느꼈습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기획서에 명시된 기능만 테스트하는 데 그치지 않고 에러가 발생할 수 있는 예외 상황도 꼼꼼히 테스트해야겠다고 생각했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; bottom: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734764843503&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 무한스크롤 - 해결&quot; data-og-description=&quot;무한 스크롤 관련된 문제로 라이브러리를 사용해야 하나 고민이 많았습니다. 우선 문제 상황과 지금까지 시도한 방법들을 간단하게 소개해보겠습니다.문제 상황PC로 확인했을 때는 잘 동작하던&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/147&quot; data-og-url=&quot;https://dev-hpk.tistory.com/147&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/wc0ZY/hyXOkfs3gO/jSCwihhi5G4DB1xgahh750/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dtBg0B/hyXOk0Q2Ne/WXCye4592CRap2yaJHkH81/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bb9vvZ/hyXOpOBO5R/8wXdCR3QG4kKxfBkfAzv5K/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/147&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/147&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/wc0ZY/hyXOkfs3gO/jSCwihhi5G4DB1xgahh750/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dtBg0B/hyXOk0Q2Ne/WXCye4592CRap2yaJHkH81/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bb9vvZ/hyXOpOBO5R/8wXdCR3QG4kKxfBkfAzv5K/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 무한스크롤 - 해결&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;무한 스크롤 관련된 문제로 라이브러리를 사용해야 하나 고민이 많았습니다. 우선 문제 상황과 지금까지 시도한 방법들을 간단하게 소개해보겠습니다.문제 상황PC로 확인했을 때는 잘 동작하던&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734764849685&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 대시보드 상세 - 무한 스크롤&quot; data-og-description=&quot;라이브러리 없이 무한 스크롤 구현하기!!!어떤 방식으로 구현할까 고민하다가 Intersection Observer API라는 좋은 기능을 찾았습니다. Intersection Observer API는 상위 요소 또는 최상위 문서의&amp;nbsp;viewport와 대&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/146&quot; data-og-url=&quot;https://dev-hpk.tistory.com/146&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/XYvAk/hyXOfkW6w8/v3RC7mJdIBfdEgQmfWRJC0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cWmysM/hyXOpuiWOW/1DrTO78ZqkRcjIlebtvLN1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/b48zV3/hyXOmqL5cq/TEnZUVLXZ6BxlxWGcVJeMK/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/146&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/146&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/XYvAk/hyXOfkW6w8/v3RC7mJdIBfdEgQmfWRJC0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cWmysM/hyXOpuiWOW/1DrTO78ZqkRcjIlebtvLN1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/b48zV3/hyXOmqL5cq/TEnZUVLXZ6BxlxWGcVJeMK/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 대시보드 상세 - 무한 스크롤&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;라이브러리 없이 무한 스크롤 구현하기!!!어떤 방식으로 구현할까 고민하다가 Intersection Observer API라는 좋은 기능을 찾았습니다. Intersection Observer API는 상위 요소 또는 최상위 문서의&amp;nbsp;viewport와 대&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>file</category>
      <category>input</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>react</category>
      <category>TS</category>
      <category>typescript</category>
      <category>validation</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/151</guid>
      <comments>https://dev-hpk.tistory.com/151#entry151comment</comments>
      <pubDate>Sat, 21 Dec 2024 16:06:56 +0900</pubDate>
    </item>
    <item>
      <title>[JS] Reflow와 Repaint</title>
      <link>https://dev-hpk.tistory.com/150</link>
      <description>&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;웹 성능 최적화에서 중요한 개념 중 하나가 바로 &lt;b&gt;Reflow(리플로우)&lt;/b&gt;와 &lt;b&gt;Repaint(리페인트)&lt;/b&gt;입니다. 이 두 가지는 브라우저가 화면에 콘텐츠를 렌더링 하는 과정에서 발생하며, 잘못된 코딩 습관은 Reflow와 Repaint를 빈번하게 발생시켜 성능 저하로 이어질 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;827&quot; data-origin-height=&quot;238&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PLKIj/btsLqyLicDR/KyEY5WOmadykFKwjVWxCIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PLKIj/btsLqyLicDR/KyEY5WOmadykFKwjVWxCIK/img.png&quot; data-alt=&quot;브라우저 렌더링 과정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PLKIj/btsLqyLicDR/KyEY5WOmadykFKwjVWxCIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPLKIj%2FbtsLqyLicDR%2FKyEY5WOmadykFKwjVWxCIK%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;827&quot; height=&quot;238&quot; data-origin-width=&quot;827&quot; data-origin-height=&quot;238&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;브라우저 렌더링 과정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;목차&lt;/b&gt;&lt;/span&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a1&quot;&gt; 1. 브라우저 렌더링 과정&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a2&quot;&gt; 2. Reflow란?&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a3&quot;&gt; 3. Repaint란?&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a4&quot;&gt; 4. Reflow와 Repaint의 상호작용&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a5&quot;&gt; 5. Reflow와 Repaint 최소화 - 성능 최적화&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a6&quot;&gt;추천글&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위의 목차를 클릭하면 해당 글로 자동 이동 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;a1&quot; style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1. 브라우저 렌더링 과정&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Reflow와 Repaint를 알기 위해서는 우선 브라우저 렌더링 과정을 알아야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;939&quot; data-origin-height=&quot;358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UCVeq/btsLqOHgn7e/xxAjcB6cxXndbV3DNY8kPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UCVeq/btsLqOHgn7e/xxAjcB6cxXndbV3DNY8kPk/img.png&quot; data-alt=&quot;브라우저 렌더링 과정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UCVeq/btsLqOHgn7e/xxAjcB6cxXndbV3DNY8kPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUCVeq%2FbtsLqOHgn7e%2FxxAjcB6cxXndbV3DNY8kPk%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;939&quot; height=&quot;358&quot; data-origin-width=&quot;939&quot; data-origin-height=&quot;358&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;브라우저 렌더링 과정&lt;/figcaption&gt;
&lt;/figure&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;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;HTML 파일을 파싱해 DOM 트리를 생성합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;CSS 파일을 파싱해 CSSOM 트리를 생성합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 생성된 DOM과 CSSOM으로 Render 트리를 생성합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Layout : &lt;span style=&quot;text-align: start;&quot;&gt;레이아웃은 렌더 트리를&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;이용해 브라우저의 화면에 요소들을 배치하는 과정입니다. 이때 각 요소의 크기, 위치, 간격 등을 계산합니다.&lt;/span&gt; &lt;br /&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;Paint : 브라우저는 렌더링 된 요소들을 화면에 그립니다. 이 과정에서 브라우저는 CSS 스타일, 배경, 그림자, 그림 등을 고려하며, 여러 계층으로 구성된 렌더링 요소들을 하나의 이미지로 합치는 과정도 포함됩니다.&lt;br /&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;브라우저 렌더링 과정은 컴퓨터 사양, 브라우저 종류 등에 따라 속도가 다르게 나타날 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;같은 웹 페이지라도 누군가는 매우 느리게 그려지겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;a2&quot; style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2. Reflow란?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Reflow는 브라우저가 DOM(Document Object Model)과 CSSOM(CSS Object Model)을 기반으로 웹 페이지의 레이아웃을 계산하고 요소들의 위치와 크기를 다시 배치하는 과정입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Reflow는 주로 웹 페이지 내에서 요소의 위치, 크기의 변화가 있을 때 발생합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Reflow가 발생하는 상황&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;DOM 요소가 추가되거나 제거될 때&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;브라우저 창의 크기가 조정될 때 (반응형 디자인)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;스타일 속성이 변경될 때 &lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;크기 관련 속성 : width, height, margin, padding 등&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위치 관련 속성 : position, top, left 등&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;레이아웃 관련 속성 : display, flex 등&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;폰트 관련 속성 : font-size, font-weight 등&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;예시 코드&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734759254046&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// DOM 요소 추가로 인한 Reflow 발생
const newDiv = document.createElement('div');
document.body.appendChild(newDiv);

// 스타일 변경으로 인한 Reflow
newDiv.style.width = '100px';
newDiv.style.height = '100px';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Reflow는 매우 비용이 큰 작업입니다. DOM 구조가 복잡할수록 Reflow에 소요되는 시간도 길어집니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Reflow는 웹 성능 저하의 주요 원인 중 하나로 꼽힙니다. Reflow가 적게 발생하도록 웹을 개발해야겠죠 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;a3&quot; style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;3. Repaint란?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Repaint는 브라우저가 DOM 요소의 시각적 스타일을 다시 그리는 과정입니다. Repaint는 레이아웃을 변경하지 않지만 요소의 색상, 배경, 그림자 등의 스타일 속성이 변경될 때 발생합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Repaint가 발생하는 상황&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-pm-slice=&quot;1 1 []&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;색상 관련 속성 변경 : color, background-color 등&lt;/span&gt;&lt;/li&gt;
&lt;li data-pm-slice=&quot;1 1 []&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;테두리 관련 속성 변경 : border-color, border-radius 등&amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;예시 코드&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734759285510&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 색상 변경으로 인한 Repaint
const element = document.getElementById('example');
element.style.backgroundColor = 'blue';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Repaint는 Reflow에 비해 비교적 덜 비용이 드는 작업이지만, 빈번하게 발생하면 역시 성능 저하를 유발할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;a4&quot; style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;4. Reflow와 Repaint의 상호작용&lt;/span&gt;&lt;/h2&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Reflow&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;와&amp;nbsp;&lt;/span&gt;Repaint&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;는 둘 다&amp;nbsp;&lt;/span&gt;Render Tree&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;가 변경되어 발생한다는 점은 같습니다. 그러나 둘은 차이가 있습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;605&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blHH9Z/btsLshobwpX/WrZEEgXmC9BRtDEKkjSZl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blHH9Z/btsLshobwpX/WrZEEgXmC9BRtDEKkjSZl1/img.png&quot; data-alt=&quot;Reflow가 발생하는 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blHH9Z/btsLshobwpX/WrZEEgXmC9BRtDEKkjSZl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblHH9Z%2FbtsLshobwpX%2FWrZEEgXmC9BRtDEKkjSZl1%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;1280&quot; height=&quot;605&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;605&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Reflow가 발생하는 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;573&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Nrlst/btsLrZgTmOf/rK2y1HBCa1q6zPQQoxX5K0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Nrlst/btsLrZgTmOf/rK2y1HBCa1q6zPQQoxX5K0/img.png&quot; data-alt=&quot;Repaint가 발생하는 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Nrlst/btsLrZgTmOf/rK2y1HBCa1q6zPQQoxX5K0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNrlst%2FbtsLrZgTmOf%2FrK2y1HBCa1q6zPQQoxX5K0%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;1280&quot; height=&quot;573&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;573&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Repaint가 발생하는 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 그림에서 볼 수 있듯이 Reflow는 항상 Repaint를 유발하지만, Repaint는 Reflow를 유발하지 않습니다. Reflow가 레이아웃 계산을 포함하기 때문에 시각적 요소의 변경(=Repaint)을 동반하지만, Repaint는 단순히 시각적 변경만 처리하기 때문입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;예시 코드&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734759546951&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Reflow와 Repaint가 모두 발생
const element = document.getElementById('example');
element.style.width = '200px'; // Reflow 발생 -&amp;gt; Repaint 발생

// Repaint만 발생
element.style.backgroundColor = 'red'; // Reflow 없이 Repaint만 발생&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;요약하자면,&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&amp;nbsp;화면의 &lt;b&gt;구조가 변경될 때는&amp;nbsp;&lt;/b&gt;&lt;/span&gt;&lt;b&gt;Reflow와&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;Repaint&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&lt;b&gt;가 모두 발생&lt;/b&gt;하지만 그 외의 변경은&amp;nbsp;&lt;/span&gt;Repaint&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;만 발생한다고 보시면 됩니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;a5&quot; style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;5.&amp;nbsp; Reflow와 Repaint 최소화 - 성능 최적화&lt;/span&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Reflow 최소화&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1. CSS 레이아웃 최적화&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-pm-slice=&quot;1 1 [&amp;quot;ordered_list&amp;quot;,{&amp;quot;spread&amp;quot;:true,&amp;quot;startingNumber&amp;quot;:1,&amp;quot;start&amp;quot;:2126,&amp;quot;end&amp;quot;:2598},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:2126,&amp;quot;end&amp;quot;:2221},&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:2149,&amp;quot;end&amp;quot;:2221},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:2149,&amp;quot;end&amp;quot;:2183}]&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;고정된 레이아웃 설계를 통해 레이아웃 변경을 최소화합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2. DOM 조작 최소화&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;여러 번 DOM을 수정하지 말고, 한 번에 변경 사항을 적용합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;3. 불필요한 노드는 display: none으로 지정하기&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #212529; text-align: left;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;visibility: hidden은 Layout 공간을 차지하기 때문에 Reflow의 대상이 됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;하지만 display: none은 Layout 공간을 차지하지 않아 Render Tree에서 아예 제외됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;4. 인라인 스타일 지양하기 &lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;인라인 스타일은 HTML이 파싱 될 때 레이아웃에 영향을 주어 추가적인 Reflow를 발생시킵니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;5. 프레임 줄이기&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;요소가 이동하는 순간마다 Reflow, Repaint가 발생하게 됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;따라서 트랜지션(transition), 애니메이션 주기나 효과를 간소화하면 성능을 개선할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;6. 애니메이션 최적화&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-pm-slice=&quot;1 1 [&amp;quot;ordered_list&amp;quot;,{&amp;quot;spread&amp;quot;:true,&amp;quot;startingNumber&amp;quot;:1,&amp;quot;start&amp;quot;:2126,&amp;quot;end&amp;quot;:2598},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:2517,&amp;quot;end&amp;quot;:2598},&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:2537,&amp;quot;end&amp;quot;:2598},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:2537,&amp;quot;end&amp;quot;:2598}]&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;transform과 opacity 속성을 사용해 레이아웃 계산을 방지합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이 두 속성은 GPU 가속을 사용할 수 있어,&amp;nbsp;reflow를 일으키지 않고&amp;nbsp;repaint만 발생시키므로 CPU 자원을 적게 사용할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Repaint 최소화&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1. CSS 스타일 변경 최소화&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;배경색, 테두리, 그림자 등 시각적 스타일 변경을 최소화합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;2. &lt;/b&gt;&lt;b&gt;visibility&lt;/b&gt;&lt;b&gt;와 &lt;/b&gt;&lt;b&gt;display&lt;/b&gt;&lt;b&gt; 구분해 사용&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-pm-slice=&quot;1 1 [&amp;quot;ordered_list&amp;quot;,{&amp;quot;spread&amp;quot;:true,&amp;quot;startingNumber&amp;quot;:1,&amp;quot;start&amp;quot;:2622,&amp;quot;end&amp;quot;:3035},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:2682,&amp;quot;end&amp;quot;:2794},&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:2722,&amp;quot;end&amp;quot;:2794},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:2722,&amp;quot;end&amp;quot;:2794}]&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;요소를 숨길 때 display: none 대신 visibility: hidden을 사용하면 Repaint만 발생합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;3. will-change 속성&amp;nbsp;사용&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-pm-slice=&quot;1 1 [&amp;quot;ordered_list&amp;quot;,{&amp;quot;spread&amp;quot;:true,&amp;quot;startingNumber&amp;quot;:1,&amp;quot;start&amp;quot;:2622,&amp;quot;end&amp;quot;:3035},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:2884,&amp;quot;end&amp;quot;:2968},&amp;quot;list&amp;quot;,{&amp;quot;spread&amp;quot;:false,&amp;quot;start&amp;quot;:2901,&amp;quot;end&amp;quot;:2968},&amp;quot;regular_list_item&amp;quot;,{&amp;quot;start&amp;quot;:2901,&amp;quot;end&amp;quot;:2968}]&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;CSS 속성 will-change를 사용해 필요한 경우 별도의 레이어를 생성하여 Repaint 성능을 개선합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;4. &lt;/b&gt;&lt;b&gt;대량 스타일 변경 관리&lt;/b&gt;&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;여러 스타일 변경을 한 번에 처리하거나 CSS 클래스 추가로 대체합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;border: 10px solid #009a87; border-radius: 0px; background-color: #ffffff; padding: 15px 30px; margin: 0;&quot;&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; top: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Reflow와 Repaint는 브라우저가 화면을 렌더링 하는 데 필수적인 과정입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Reflow와 Repaint를 적절히 관리하지 못하면 웹 성능에 악영향을 끼칠 수 있습니다. Reflow 최적화와 Repaint 최적화는 각각 다른 접근이 필요하며, 이를 잘 이해하고 적용하면 성능이 크게 개선될 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; bottom: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;a6&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; 추천글&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;figure id=&quot;og_1734761449360&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[JS] 이벤트 루프(Event Loop)란?&quot; data-og-description=&quot;자바스크립트는 단일 스레드 기반 언어로 한 번에 하나의 작업만 처리할 수 있습니다. 하지만 비동기 작업을 지원하며 동시에 여러 작업이 진행되는 것처럼 보이게 합니다. 이러한 비동기 처리&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/149&quot; data-og-url=&quot;https://dev-hpk.tistory.com/149&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/thndV/hyXOc2NqZz/bHaKZhRI0GlboNIPaEqAb0/img.gif?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/cd3pEu/hyXOivbHMS/BkF5X7gn4b8rHkGSRldrfK/img.gif?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/qDYuF/hyXOlrUTJL/fopCL6G3FJMfnMVpPTFta0/img.png?width=1292&amp;amp;height=883&amp;amp;face=0_0_1292_883&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/149&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/149&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/thndV/hyXOc2NqZz/bHaKZhRI0GlboNIPaEqAb0/img.gif?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/cd3pEu/hyXOivbHMS/BkF5X7gn4b8rHkGSRldrfK/img.gif?width=800&amp;amp;height=450&amp;amp;face=0_0_800_450,https://scrap.kakaocdn.net/dn/qDYuF/hyXOlrUTJL/fopCL6G3FJMfnMVpPTFta0/img.png?width=1292&amp;amp;height=883&amp;amp;face=0_0_1292_883');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[JS] 이벤트 루프(Event Loop)란?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;자바스크립트는 단일 스레드 기반 언어로 한 번에 하나의 작업만 처리할 수 있습니다. 하지만 비동기 작업을 지원하며 동시에 여러 작업이 진행되는 것처럼 보이게 합니다. 이러한 비동기 처리&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734761469496&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Next] Next.js 이미지 최적화(Image 컴포넌트)&quot; data-og-description=&quot;Next.js는 사용자 경험과 개발자 생산성을 극대화하기 위해 설계된 React 프레임워크입니다. 그중에서도 Image 컴포넌트는 최적화된 이미지 관리를 위한 강력한 도구로, 성능 개선과 효율적인 이미&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/118&quot; data-og-url=&quot;https://dev-hpk.tistory.com/118&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/3p4vw/hyXOhwhTbJ/MvkvggDjmeuwLpmNwGuAYk/img.png?width=720&amp;amp;height=720&amp;amp;face=0_0_720_720,https://scrap.kakaocdn.net/dn/bhpx59/hyXOg5csch/wfK6HGPzYKVFKos3s83bU0/img.png?width=720&amp;amp;height=720&amp;amp;face=0_0_720_720,https://scrap.kakaocdn.net/dn/bEUTZO/hyXOj1WU7x/UHWaxrUoarzZJniTu8JWeK/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/118&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/118&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/3p4vw/hyXOhwhTbJ/MvkvggDjmeuwLpmNwGuAYk/img.png?width=720&amp;amp;height=720&amp;amp;face=0_0_720_720,https://scrap.kakaocdn.net/dn/bhpx59/hyXOg5csch/wfK6HGPzYKVFKos3s83bU0/img.png?width=720&amp;amp;height=720&amp;amp;face=0_0_720_720,https://scrap.kakaocdn.net/dn/bEUTZO/hyXOj1WU7x/UHWaxrUoarzZJniTu8JWeK/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Next] Next.js 이미지 최적화(Image 컴포넌트)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Next.js는 사용자 경험과 개발자 생산성을 극대화하기 위해 설계된 React 프레임워크입니다. 그중에서도 Image 컴포넌트는 최적화된 이미지 관리를 위한 강력한 도구로, 성능 개선과 효율적인 이미&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JavaScript</category>
      <category>javascript</category>
      <category>js</category>
      <category>Reflow</category>
      <category>render</category>
      <category>repaint</category>
      <category>개발</category>
      <category>브라우저 렌더링</category>
      <category>자바스크립트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/150</guid>
      <comments>https://dev-hpk.tistory.com/150#entry150comment</comments>
      <pubDate>Sat, 21 Dec 2024 15:13:48 +0900</pubDate>
    </item>
    <item>
      <title>[Taskify] 할 일 카드 모달 컴포넌트 (feat. optimistic update)</title>
      <link>https://dev-hpk.tistory.com/148</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;596&quot; data-origin-height=&quot;615&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/y8hRx/btsLpJxIJaF/iYxAnyAdpAOuDgkx1yQcFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/y8hRx/btsLpJxIJaF/iYxAnyAdpAOuDgkx1yQcFK/img.png&quot; data-alt=&quot;할 일 카드 모달&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/y8hRx/btsLpJxIJaF/iYxAnyAdpAOuDgkx1yQcFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fy8hRx%2FbtsLpJxIJaF%2FiYxAnyAdpAOuDgkx1yQcFK%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;596&quot; height=&quot;615&quot; data-origin-width=&quot;596&quot; data-origin-height=&quot;615&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;할 일 카드 모달&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 오늘은 대시보드 상세에서 카드의 상세 모달을 작업해 봤습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #222222; text-align: start;&quot;&gt; 우선 전반적인 코드를 먼저 보여드리고 작업하면서 있었던 문제들과 해결한 방법에 대해서 설명해 볼게요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;(컴포넌트 구조보다 기능적인 부분을 보고 싶으시다면 아래 링크를 눌러주세요&amp;darr;)&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a style=&quot;color: #222222;&quot; href=&quot;#a1&quot;&gt;기능 코드&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;DetailCardModal.tsx (할 일 카드 모달)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기능이 많아 컴포넌트는 아직 분리하지 못했습니다... import 부분은 생략했으니 이해해 주세요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;빠른 시일 내로 리팩토링 할 예정이니 코드 블록이 불편하시다면 아래 Github PR을 확인해 주세요!!&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1734591001065&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;#92 모달 할 일 카드 by hpk5802 &amp;middot; Pull Request #98 &amp;middot; codeit-sprint-part3-6team/project&quot; data-og-description=&quot;이슈 번호 close #92 변경 사항 요약 공통 dropdown 추가 댓글 무한 스크롤 추가 카드 삭제 기능 추가 댓글 관련 기능 추가 (댓글 추가, 삭제, 수정) 카드, 댓글 관리 작성자 확인 추가 테스트 결과 카드&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/codeit-sprint-part3-6team/project/pull/98/files&quot; data-og-url=&quot;https://github.com/codeit-sprint-part3-6team/project/pull/98&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/u2IWz/hyXOldRbLm/WCkUIVdUC91vJCcBgO6zMK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/hNZYf/hyXOeFMmKu/pZjQ2cUBJe6ZsY8qXmaNUk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/codeit-sprint-part3-6team/project/pull/98/files&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/codeit-sprint-part3-6team/project/pull/98/files&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/u2IWz/hyXOldRbLm/WCkUIVdUC91vJCcBgO6zMK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/hNZYf/hyXOeFMmKu/pZjQ2cUBJe6ZsY8qXmaNUk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;#92 모달 할 일 카드 by hpk5802 &amp;middot; Pull Request #98 &amp;middot; codeit-sprint-part3-6team/project&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이슈 번호 close #92 변경 사항 요약 공통 dropdown 추가 댓글 무한 스크롤 추가 카드 삭제 기능 추가 댓글 관련 기능 추가 (댓글 추가, 삭제, 수정) 카드, 댓글 관리 작성자 확인 추가 테스트 결과 카드&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;pre id=&quot;code_1734590528207&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface DetailCardModalProps {
  title: string;
  cardId: number;
  columnTitle: string;
  closeModal: () =&amp;gt; void;
  setColumnData: React.Dispatch&amp;lt;React.SetStateAction&amp;lt;GetCardsResponse&amp;gt;&amp;gt;;
}

function DetailCardModal({
  title,
  cardId,
  columnTitle,
  closeModal,
  setColumnData,
}: DetailCardModalProps) {
  const {
    user: { id },
  } = useSelector((state: RootState) =&amp;gt; state.userInfo); // 카드의 작성자 확인을 위해 Redux에서 유저 id를 가져옴
  const [card, setCard] = useState&amp;lt;Card | null&amp;gt;(null);
  const {
    commentsResponse,
    addComment,
    loadMoreComments,
    removeComment,
    updateComment,
    isSubmitting,
  } = useComments(cardId, null); // 
  const [newComment, setNewComment] = useState('');

  // 카드 삭제 함수
  const handleCardDelete = async () =&amp;gt; {
    try {
      await deleteCard(cardId);
      alert('카드가 삭제되었습니다.');
      setColumnData((prev) =&amp;gt; ({
        ...prev,
        cards: prev.cards.filter((columnCard) =&amp;gt; columnCard.id !== cardId), // 삭제된 카드 제외
      }));
    } catch (error) {
      console.error('카드 삭제 오류:', error);
    }
  };

  const handleMenuClick = async (value: string) =&amp;gt; {
    closeModal();
    // 수정하기 모달은 완성 전이라 임시로 alert 처리했습니다.
    if (value === 'edit') alert('수정하기 모달 오픈');
    else if (value === 'delete') {
      await handleCardDelete();
    }
  };

  const fetchData = async () =&amp;gt; {
    try {
      const cardDetail = await getCardDetail({ cardId });
      setCard(cardDetail);
      loadMoreComments();
    } catch (error) {
      console.error('데이터 요청 실패:', error);
    }
  };

  const handleObserver = useCallback(
    async ([entry]) =&amp;gt; {
      if (entry.isIntersecting &amp;amp;&amp;amp; commentsResponse?.cursorId) {
        loadMoreComments(commentsResponse.cursorId);
      }
    },
    [commentsResponse?.cursorId, loadMoreComments],
  );

  const endPoint = useIntersectionObserver(handleObserver);

  useEffect(() =&amp;gt; {
    fetchData();
  }, [cardId]);

  if (!card || !commentsResponse) return null;

  return (
    &amp;lt;div className={styles.container}&amp;gt;
      &amp;lt;h2 className={styles.title}&amp;gt;{title}&amp;lt;/h2&amp;gt;
      &amp;lt;div className={styles['btn-section']}&amp;gt;
        {id === card.assignee.id &amp;amp;&amp;amp; (
          &amp;lt;Dropdown
            menus={[
              { label: '수정하기', value: 'edit' },
              { label: '삭제하기', value: 'delete' },
            ]}
            onMenuClick={handleMenuClick}
          &amp;gt;
            &amp;lt;KebabIcon className={styles['icon-kebab']} /&amp;gt;
          &amp;lt;/Dropdown&amp;gt;
        )}
        &amp;lt;button
          type=&quot;button&quot;
          className={styles['btn-close']}
          onClick={closeModal}
        &amp;gt;
          &amp;lt;CloseIcon className={styles['icon-close']} /&amp;gt;
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;div className={styles['author-section']}&amp;gt;
        &amp;lt;div&amp;gt;
          &amp;lt;div className={styles['author-title']}&amp;gt;담당자&amp;lt;/div&amp;gt;
          &amp;lt;UserProfile
            type=&quot;todo-detail&quot;
            profileImageUrl={card.assignee.profileImageUrl}
            nickname={card.assignee.nickname}
          /&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div&amp;gt;
          &amp;lt;div className={styles['author-title']}&amp;gt;마감일&amp;lt;/div&amp;gt;
          &amp;lt;span className={styles['author-content']}&amp;gt;
            {formatDate(card.dueDate, true)}
          &amp;lt;/span&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;div className={styles['content-section']}&amp;gt;
        &amp;lt;div className={styles['chip-section']}&amp;gt;
          &amp;lt;div className={styles.status}&amp;gt;
            &amp;lt;Chip chipType=&quot;status&quot;&amp;gt;{columnTitle}&amp;lt;/Chip&amp;gt;
          &amp;lt;/div&amp;gt;
          &amp;lt;span className={styles.bar} /&amp;gt;
          &amp;lt;div className={styles.tags}&amp;gt;
            {card.tags.map((tag) =&amp;gt; (
              &amp;lt;Chip key={`${cardId}_tag_${tag}`} chipType=&quot;tag&quot;&amp;gt;
                {tag}
              &amp;lt;/Chip&amp;gt;
            ))}
          &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;p className={styles.description}&amp;gt;{card.description}&amp;lt;/p&amp;gt;
        &amp;lt;CardImage
          image={card.imageUrl}
          name={`${card.title} 이미지`}
          className={styles['card-image']}
        /&amp;gt;
        &amp;lt;div className={styles['comment-input-section']}&amp;gt;
          &amp;lt;label htmlFor=&quot;comment&quot; className={styles['comment-label']}&amp;gt;
            댓글
          &amp;lt;/label&amp;gt;
          &amp;lt;textarea
            className={styles['comment-input']}
            name=&quot;comment&quot;
            id=&quot;comment&quot;
            value={newComment}
            onChange={(e) =&amp;gt; setNewComment(e.target.value)}
            placeholder=&quot;댓글 작성하기&quot;
          /&amp;gt;
          &amp;lt;button
            type=&quot;button&quot;
            className={styles['btn-add-comment']}
            onClick={() =&amp;gt; {
              addComment(newComment, card.columnId, card.dashboardId);
              setNewComment('');
            }}
            disabled={isSubmitting || !newComment.trim()}
          &amp;gt;
            입력
          &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div className=&quot;comment-section&quot;&amp;gt;
          {commentsResponse.comments.map(
            ({
              id: commentId,
              author: { id: authorId, nickname, profileImageUrl },
              createdAt,
              content,
            }) =&amp;gt; (
              &amp;lt;Comment
                key={`comment_${commentId}`}
                commentId={commentId}
                profileImageUrl={profileImageUrl}
                authorId={authorId}
                nickname={nickname}
                createdAt={createdAt}
                content={content}
                removeComment={removeComment}
                updateComment={updateComment}
              /&amp;gt;
            ),
          )}
        &amp;lt;/div&amp;gt;
        {commentsResponse.cursorId &amp;amp;&amp;amp; (
          &amp;lt;div ref={endPoint} className={styles['end-point']} /&amp;gt;
        )}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default DetailCardModal;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Comment.tsx (댓글)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734591276688&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import UserProfile from '@/components/common/userprofile/UserProfile';
import styles from '@/components/dashboard/comment/Comment.module.css';
import { RootState } from '@/redux/store';
import formatDate from '@/utils/formatDate';
import { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';

interface CommentProps {
  commentId: number;
  authorId: number;
  nickname: string;
  profileImageUrl: string | null;
  createdAt: string;
  content: string;
  removeComment: (commentId: number) =&amp;gt; void;
  updateComment: (commentId: number, newContent: string) =&amp;gt; void;
}

function Comment({
  commentId,
  authorId,
  nickname,
  profileImageUrl,
  createdAt,
  content,
  removeComment,
  updateComment,
}: CommentProps) {
  const {
    user: { id }, // 댓글의 작성자 확인을 위해 Redux에서 유저 id를 가져옴
  } = useSelector((state: RootState) =&amp;gt; state.userInfo);
  const [isEditing, setIsEditing] = useState(false);
  const [editedContent, setEditedContent] = useState(content);

  const handleSave = () =&amp;gt; {
    if (editedContent.trim() !== content) {
      updateComment(commentId, editedContent);
    }
    setIsEditing(false);
  };

  const handleCancel = () =&amp;gt; {
    setEditedContent(content); // 수정 취소 시 원래 내용으로 되돌리기
    setIsEditing(false);
  };

  const handleDelete = useCallback(() =&amp;gt; {
    removeComment(commentId);
  }, [removeComment, commentId]);

  return (
    &amp;lt;div className={styles.comment}&amp;gt;
      &amp;lt;UserProfile
        type=&quot;todo-detail&quot;
        profileImageUrl={profileImageUrl}
        onlyImg
        nickname={nickname}
      /&amp;gt;
      &amp;lt;div className={styles.wrap}&amp;gt;
        &amp;lt;div className={styles['title-section']}&amp;gt;
          &amp;lt;span className={styles.nickname}&amp;gt;{nickname}&amp;lt;/span&amp;gt;
          &amp;lt;span className={styles.date}&amp;gt;{formatDate(createdAt, true)}&amp;lt;/span&amp;gt;
        &amp;lt;/div&amp;gt;
        {isEditing ? (
          &amp;lt;div className={styles['content-edit']}&amp;gt;
            &amp;lt;textarea
              className={styles['content-textarea']}
              value={editedContent}
              onChange={(e) =&amp;gt; setEditedContent(e.target.value)}
            /&amp;gt;
            &amp;lt;button
              type=&quot;button&quot;
              className={styles['btn-cancel']}
              onClick={handleCancel}
            &amp;gt;
              취소
            &amp;lt;/button&amp;gt;
            &amp;lt;button
              type=&quot;button&quot;
              className={styles['btn-save']}
              onClick={handleSave}
            &amp;gt;
              저장
            &amp;lt;/button&amp;gt;
          &amp;lt;/div&amp;gt;
        ) : (
          &amp;lt;div className={styles.content}&amp;gt;{content}&amp;lt;/div&amp;gt;
        )}
        {!isEditing &amp;amp;&amp;amp; id === authorId &amp;amp;&amp;amp; (
          &amp;lt;div&amp;gt;
            &amp;lt;button
              type=&quot;button&quot;
              className={styles.edit}
              onClick={() =&amp;gt; setIsEditing(true)}
            &amp;gt;
              수정
            &amp;lt;/button&amp;gt;
            &amp;lt;button
              type=&quot;button&quot;
              className={styles.delete}
              onClick={handleDelete}
            &amp;gt;
              삭제
            &amp;lt;/button&amp;gt;
          &amp;lt;/div&amp;gt;
        )}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default Comment;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p id=&quot;a1&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;useComment.ts (댓글 관련 커스텀 훅 - 추가, 삭제, 수정)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734591848633&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useState } from 'react';
import { Comment as CommentType, GetCommentsResponse } from '@/type/comment';
import postComment from '@/lib/dashboard/postComment';
import getComments from '@/lib/dashboard/getComments';
import deleteComment from '@/lib/dashboard/deleteComment';
import putComment from '@/lib/dashboard/putComment';

const useComments = (
  cardId: number, // 댓글 관련 카드 ID
  initialComments: GetCommentsResponse | null, // 초기 댓글 데이터
) =&amp;gt; {
  const [commentsResponse, setCommentsResponse] =
    useState&amp;lt;GetCommentsResponse | null&amp;gt;(initialComments);
  // 댓글 작업(추가, 수정, 삭제) 중 상태를 확인하기 위한 플래그
  const [isSubmitting, setIsSubmitting] = useState(false);

  // 댓글 추가
  const addComment = async (
    content: string,
    columnId: number,
    dashboardId: number,
  ) =&amp;gt; {
    if (!content.trim()) return; // 공백이면 종료

    setIsSubmitting(true); // 작업 상태 true로 변경
    try {
      // 댓글 추가 API 호출
      const addedComment: CommentType = await postComment({
        content,
        cardId,
        columnId,
        dashboardId,
      });
      // 새로운 댓글 state 가장 앞에 추가
      setCommentsResponse((prev) =&amp;gt; ({
        ...prev,
        comments: [addedComment, ...(prev?.comments || [])],
      }));
    } catch (error) {
      console.error('댓글 추가 실패:', error);
    } finally {
      setIsSubmitting(false);
    }
  };

  // 댓글 불러오기(무한 스크롤에서 사용)
  const loadMoreComments = async (cursorId?: number) =&amp;gt; {
    try {
      // 댓글 요청 API 호출
      const newCommentsResponse = await getComments({ cardId, cursorId });
      // 댓글 state 가장 앞에 서버에서 불러온 댓글 추가
      setCommentsResponse((prev) =&amp;gt; ({
        ...newCommentsResponse,
        comments: [...(prev?.comments || []), ...newCommentsResponse.comments],
      }));
    } catch (error) {
      console.error('댓글을 불러오는데 실패했습니다:', error);
    }
  };

  // 댓글 삭제
  const removeComment = async (commentId: number) =&amp;gt; {
    try {
      // 댓글 삭제 API 호출
      await deleteComment(commentId);
      // Array.filter 메서드를 통해 해당 댓글 삭제
      setCommentsResponse((prev) =&amp;gt; ({
        ...prev,
        comments:
          prev?.comments.filter((comment) =&amp;gt; comment.id !== commentId) || [],
      }));
    } catch (error) {
      console.error('댓글 삭제 실패:', error);
    }
  };

  // 댓글 수정
  const updateComment = async (commentId: number, newContent: string) =&amp;gt; {
    try {
      // 댓글 수정 API 호출
      const updatedComment = await putComment({
        commentId,
        content: newContent,
      });
      // 댓글의 content를 수정한 댓글로 갱신
      setCommentsResponse((prev) =&amp;gt;
        prev
          ? {
              ...prev,
              comments: prev.comments.map((comment) =&amp;gt;
                comment.id === commentId
                  ? { ...comment, content: updatedComment.content }
                  : comment,
              ),
            }
          : null,
      );
    } catch (error) {
      console.error('댓글 수정 실패:', error);
    }
  };

  return {
    commentsResponse,
    addComment,
    loadMoreComments,
    removeComment,
    updateComment,
    isSubmitting,
  };
};

export default useComments;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;댓글 기능에 전반적으로 기존 state를 유지하면서 추가하거나 삭제한 데이터를 처리하는 로직이 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734592894843&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;setCommentsResponse((prev) =&amp;gt; ({
    ...prev,
    comments: [addedComment, ...(prev?.comments || [])],
}));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위 로직을 왜 추가했는지 궁금하실 수 있을 것 같아서 적어보겠습니다!!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;초기 작업 시 위 로직 없이 API만 호출하고 종료함.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;네트워크 탭을 확인해 보니 request가 정상적으로 완료됨.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;새로고침을 하기 전까지 화면에 데이터가 업데이트되지 않음 &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;생각해 본 해결 방안&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;댓글 추가 및 삭제 후 request가 정상적으로 완료되면 댓글 불러오기 API를 호출해 화면의 데이터를 갱신한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;댓글 추가 및 삭제 후 프론트에서 데이터를 직접 수정해 화면의 데이터를 갱신한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;많은 고민 끝에 저는 2번을 선택했습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;무한 스크롤로 데이터를 요청하다 보니 데이터를 처음부터 다시 불러오는 방법이 사용자에게 불쾌한 경험일 수 있다고 생각했기 때문입니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;검색해 보니 이런 방법을 낙관적 업데이트(Optimistic Update)라고 하네요.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #000000; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;낙관적 업데이트: 서버에서 응답을 받을 때까지 기다리지 않고 사용자에게 빠른 피드백을 제공해,&amp;nbsp; &lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;사용자 경험을 개선&lt;/span&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;적용 화면&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;댓글 추가&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-18-22-23-44.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0xa6l/btsLnxsl6jp/ZW9nAYAGPm3Rgpt41YFqj0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0xa6l/btsLnxsl6jp/ZW9nAYAGPm3Rgpt41YFqj0/img.gif&quot; data-alt=&quot;댓글 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0xa6l/btsLnxsl6jp/ZW9nAYAGPm3Rgpt41YFqj0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/b0xa6l/btsLnxsl6jp/ZW9nAYAGPm3Rgpt41YFqj0/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-18-22-23-44.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;댓글 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;댓글 삭제&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-19-16-40-29.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dFiNr0/btsLpOTpkX4/BmEkajpNAz4YzyuI96cFl0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dFiNr0/btsLpOTpkX4/BmEkajpNAz4YzyuI96cFl0/img.gif&quot; data-alt=&quot;댓글 삭제&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dFiNr0/btsLpOTpkX4/BmEkajpNAz4YzyuI96cFl0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/dFiNr0/btsLpOTpkX4/BmEkajpNAz4YzyuI96cFl0/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-19-16-40-29.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;댓글 삭제&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;댓글 수정 (취소)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-18-22-24-08.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UeFcO/btsLnx6TEdv/pkrgP3UIUXU6aKJbxI7mZ1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UeFcO/btsLnx6TEdv/pkrgP3UIUXU6aKJbxI7mZ1/img.gif&quot; data-alt=&quot;댓글 수정 (취소)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UeFcO/btsLnx6TEdv/pkrgP3UIUXU6aKJbxI7mZ1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/UeFcO/btsLnx6TEdv/pkrgP3UIUXU6aKJbxI7mZ1/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-18-22-24-08.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;댓글 수정 (취소)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;댓글 수정 (저장)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-18-22-23-58.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dCu9AQ/btsLoJFjNWE/dccVxbIu1wU3K85Lmm03t0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dCu9AQ/btsLoJFjNWE/dccVxbIu1wU3K85Lmm03t0/img.gif&quot; data-alt=&quot;댓글 수정 (저장)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dCu9AQ/btsLoJFjNWE/dccVxbIu1wU3K85Lmm03t0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/dCu9AQ/btsLoJFjNWE/dccVxbIu1wU3K85Lmm03t0/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-18-22-23-58.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;댓글 수정 (저장)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;카드 삭제&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-18-22-24-35.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cy4fQc/btsLo9juNki/oAY4O36A2ykLKbgmlekvr1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cy4fQc/btsLo9juNki/oAY4O36A2ykLKbgmlekvr1/img.gif&quot; data-alt=&quot;카드 삭제&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cy4fQc/btsLo9juNki/oAY4O36A2ykLKbgmlekvr1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cy4fQc/btsLo9juNki/oAY4O36A2ykLKbgmlekvr1/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-18-22-24-35.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;카드 삭제&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;작업 후 멘토님께 코드 리뷰를 받았습니다. 내용을 정리해 보자면 아래와 같습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;무한 스크롤과 사용자 경험을 고려해 낙관적 업데이트를 적용한 점에서  &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;낙관적 업데이트도 좋지만 API 요청 후 request가 완료되면 서버에서 데이터를 다시 호출하는 게 좋다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;서버에 데이터를 다시 호출하는 동작은 크게 무리가 되지 않는다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;데이터 추가, 삭제, 업데이트 같은 기능을 수행하는 동안 다른 사람에 의해 서버의 데이터가 바뀔 수 있다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;프론트에서 낙관적 업데이트를 통해 데이터를 변경하면 서버와 싱크가 맞지 않아 순수하지 못한 데이터일 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;무한 스크롤을 고려하면 낙관적 업데이트로 처리하는 것도 좋은 것 같다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오늘도 프로젝트를 진행하면서 낙관적 업데이트라는 새로운 지식을 얻어가네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;멘토님께서 해주신 피드백을 바탕으로 데이터를 다시 호출하는 방법도 고려해 봐야겠네요!&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734595327992&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 무한스크롤 - 해결&quot; data-og-description=&quot;무한 스크롤 관련된 문제로 라이브러리를 사용해야 하나 고민이 많았습니다. 우선 문제 상황과 지금까지 시도한 방법들을 간단하게 소개해보겠습니다.문제 상황PC로 확인했을 때는 잘 동작하던&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/147&quot; data-og-url=&quot;https://dev-hpk.tistory.com/147&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/Q4JsF/hyXOb3o3ot/XAj8kiSAFJi1KQwbYqIPUk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cbW66l/hyXOpN6mFO/xiaksI9BJhc5EHGGBK8iE0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/vmpTy/hyXOb3o3go/KYhx9Lgt6xRT8Kmdmlsnlk/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/147&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/147&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/Q4JsF/hyXOb3o3ot/XAj8kiSAFJi1KQwbYqIPUk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cbW66l/hyXOpN6mFO/xiaksI9BJhc5EHGGBK8iE0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/vmpTy/hyXOb3o3go/KYhx9Lgt6xRT8Kmdmlsnlk/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 무한스크롤 - 해결&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;무한 스크롤 관련된 문제로 라이브러리를 사용해야 하나 고민이 많았습니다. 우선 문제 상황과 지금까지 시도한 방법들을 간단하게 소개해보겠습니다.문제 상황PC로 확인했을 때는 잘 동작하던&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734595327083&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 대시보드 상세 - 무한 스크롤&quot; data-og-description=&quot;라이브러리 없이 무한 스크롤 구현하기!!!어떤 방식으로 구현할까 고민하다가 Intersection Observer API라는 좋은 기능을 찾았습니다. Intersection Observer API는 상위 요소 또는 최상위 문서의&amp;nbsp;viewport와 대&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/146&quot; data-og-url=&quot;https://dev-hpk.tistory.com/146&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/yQely/hyXOh3zIwt/KaSvzBElY170zCGfDxOKPk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/b1Wn3v/hyXOqlWQWJ/J2JgzkM0yYTWfDj24wNtJk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/lLO1h/hyXOmRlxxQ/8HXPHfT6zNb7pWCjF2Zsi0/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/146&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/146&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/yQely/hyXOh3zIwt/KaSvzBElY170zCGfDxOKPk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/b1Wn3v/hyXOqlWQWJ/J2JgzkM0yYTWfDj24wNtJk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/lLO1h/hyXOmRlxxQ/8HXPHfT6zNb7pWCjF2Zsi0/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 대시보드 상세 - 무한 스크롤&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;라이브러리 없이 무한 스크롤 구현하기!!!어떤 방식으로 구현할까 고민하다가 Intersection Observer API라는 좋은 기능을 찾았습니다. Intersection Observer API는 상위 요소 또는 최상위 문서의&amp;nbsp;viewport와 대&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>API</category>
      <category>HTTP Method</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>optimistic update</category>
      <category>react</category>
      <category>개발</category>
      <category>낙관적 업데이트</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/148</guid>
      <comments>https://dev-hpk.tistory.com/148#entry148comment</comments>
      <pubDate>Fri, 20 Dec 2024 14:25:57 +0900</pubDate>
    </item>
    <item>
      <title>[JS] 이벤트 루프(Event Loop)란?</title>
      <link>https://dev-hpk.tistory.com/149</link>
      <description>&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;자바스크립트는 &lt;b&gt;단일 스레드 기반 언어로 한 번에 하나의 작업만 처리&lt;/b&gt;할 수 있습니다. 하지만 &lt;b&gt;비동기 작업을 지원하며 동시에 여러 작업이 진행되는 것처럼 보이게&lt;/b&gt; 합니다. 이러한 비동기 처리를 가능하게 하는 핵심 메커니즘이 바로 &lt;b&gt;이벤트 루프(Event Loop)&lt;/b&gt;입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;880&quot; data-origin-height=&quot;495&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcLwjP/btsLobJCXn3/TaP5MoW3h9ObLDlUA34lxK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcLwjP/btsLobJCXn3/TaP5MoW3h9ObLDlUA34lxK/img.gif&quot; data-alt=&quot;이벤트 루프&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcLwjP/btsLobJCXn3/TaP5MoW3h9ObLDlUA34lxK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bcLwjP/btsLobJCXn3/TaP5MoW3h9ObLDlUA34lxK/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;817&quot; height=&quot;460&quot; data-origin-width=&quot;880&quot; data-origin-height=&quot;495&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이벤트 루프&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;목차&lt;/b&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;#a1&quot;&gt; 1. 싱글 스레드란?&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;#a2&quot;&gt; 2. 블로킹 &amp;amp; 논블로킹&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;#a3&quot;&gt; 3. 이벤트 루프의 필요성&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;#a4&quot;&gt; 4. 마이크로 태스크와 매크로 태스크&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;#a5&quot;&gt;추천글&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 목차를 클릭하면 해당 글로 자동 이동 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;1. 싱글 스레드란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;스레드&lt;/b&gt;는 프로세스의 실행 단위입니다.&amp;nbsp;싱글 스레드라는 것은 말 그대로 스레드가 하나만 존재해 한번에 하나의 프로세스만 실행할 수 있다는 의미입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;자바스크립트는 싱글 스레드 언어로 하나의 호출 스택(Call Stack)을 사용해 코드를 실행합니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp;호출 스택&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: 함수의 호출을 관리하는 구조로, LIFO(Last In First Out) 방식으로 동작합니다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출 스택(Call Stack) 예시&lt;/p&gt;
&lt;pre id=&quot;code_1734661217746&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function foo() {
  console.log('foo');
}

function bar() {
  foo();
  console.log('bar');
}

bar();&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;bar() 함수가 호출되어 호출 스택에 추가됩니다.&lt;/li&gt;
&lt;li&gt;bar() 내부에서 foo()가 호출되어 foo()가 호출 스택에 추가됩니다.&lt;/li&gt;
&lt;li&gt;foo()의 실행이 종료되면 스택에서 제거되고 bar()의 나머지 코드인 console.log('bar')가 실행됩니다.&lt;/li&gt;
&lt;li&gt;bar()의 실행이 종료되면 스택에서 제거되고 호출 스택이 비워집니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;2. 블로킹(Blocking) &amp;amp; 논블로킹(Non-Blocking)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바스크립트는 싱글 스레드 언어로 호출 스택의 작업을 하나씩 순차적으로 진행한다고 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 호출 스택에 매우 오래 걸리는 작업이 들어가면 어떻게 될까요 &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출 스택에 있는 모든 작업들이 멈춰있다가 해당 작업이 끝난 후에 동작할 것입니다. 이런 경우를 블로킹이라고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 우리는 웹에서 작업의&lt;span style=&quot;background-color: #f8f9fa; color: #212529; text-align: start;&quot;&gt; 완료 여부와 상관없이 여러 작업을 동시에 실행할 수 있습니다. 이런 경우를 논블로킹이라고 합니다.&lt;/span&gt;&lt;span style=&quot;background-color: #f8f9fa; color: #212529; text-align: start;&quot;&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #f8f9fa; color: #212529; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;블로킹 &amp;amp; 논블로킹 (출처: PoiemaWeb)&lt;/span&gt;&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;409&quot; data-origin-height=&quot;370&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCDkos/btsLp9Q5mWM/KOMzObKGfuaNj52StTQdik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCDkos/btsLp9Q5mWM/KOMzObKGfuaNj52StTQdik/img.png&quot; data-alt=&quot;동기와 비동기&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCDkos/btsLp9Q5mWM/KOMzObKGfuaNj52StTQdik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCDkos%2FbtsLp9Q5mWM%2FKOMzObKGfuaNj52StTQdik%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;409&quot; height=&quot;370&quot; data-origin-width=&quot;409&quot; data-origin-height=&quot;370&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;동기와 비동기&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바스크립트는 싱글 스레드 언어로 한 번에 하나의 작업만 실행할 수 있지만, 비동기 처리를 통해 기다림 없이 여러 작업을 블로킹 없이 처리할 수 있습니다.&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;3. 이벤트 루프의 필요성&lt;/h2&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;싱글 스레드 환경에서 비동기 작업(타이머, 네트워크 요청)은 어떻게 처리될까요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;여기서 이벤트 루프가 등장합니다. &lt;/span&gt;&lt;span&gt;비동기 작업은 브라우저 또는 Node.js 환경의 백그라운드(Background)에서 처리되고, 완료된 작업은 태스크 큐(Task Queue)에 전달됩니다. 이벤트 루프는 호출 스택이 비어 있는지 확인하고, 비어 있다면 태스크 큐의 작업을 호출 스택으로 이동시킵니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-pm-slice=&quot;1 1 []&quot;&gt;콜 스택 : &lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: left;&quot;&gt;현재 실행 중인 작업들이 쌓이는 곳&lt;/span&gt;&lt;/li&gt;
&lt;li data-pm-slice=&quot;1 1 []&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: left;&quot;&gt; 태스크 큐 :&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: left;&quot;&gt;&amp;nbsp;비동기 작업이 완료되면 그 결과를 대기시키는 곳&lt;/span&gt; &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: left;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1292&quot; data-origin-height=&quot;883&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qBUm0/btsLoohGp7E/YbJgZsv3dzJR85iDeyI5a1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qBUm0/btsLoohGp7E/YbJgZsv3dzJR85iDeyI5a1/img.png&quot; data-alt=&quot;이벤트 루프 동작 원리&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qBUm0/btsLoohGp7E/YbJgZsv3dzJR85iDeyI5a1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqBUm0%2FbtsLoohGp7E%2FYbJgZsv3dzJR85iDeyI5a1%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;1292&quot; height=&quot;883&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1292&quot; data-origin-height=&quot;883&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이벤트 루프 동작 원리&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;이벤트 루프의 동작 원리&lt;/b&gt;&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-pm-slice=&quot;3 3 []&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;호출 스택(Call Stack)&lt;/b&gt; &lt;/span&gt;&lt;span&gt;&lt;b&gt;:&lt;/b&gt; 현재 실행 중인 함수의 실행 컨텍스트를 관리합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;백그라운드 &lt;/b&gt;&lt;/span&gt;&lt;span&gt;&lt;b&gt;:&lt;/b&gt; 비동기 작업(타이머, 네트워크 요청)을 처리합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;태스크 큐(Task Queue) &lt;/b&gt;&lt;/span&gt;&lt;span&gt;&lt;b&gt;:&lt;/b&gt; 백그라운드에서 완료된 작업의 콜백 함수가 대기하는 공간입니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;이벤트 루프(Event Loop) &lt;/b&gt;&lt;/span&gt;&lt;span&gt;&lt;b&gt;:&lt;/b&gt; 호출 스택이 비어 있는지 확인하고, 태스크 큐에서 작업을 호출 스택으로 옮깁니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;예시&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734663251143&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;console.log('Start');

setTimeout(() =&amp;gt; {
  console.log('Timeout');
}, 1000);

console.log('End');

// 출력 결과
Start
End
Timeout&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-pm-slice=&quot;3 3 []&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span&gt;console.log('Start')&lt;/span&gt;&lt;span&gt;가 호출 스택에서 실행됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;setTimeout&lt;/span&gt;&lt;span&gt;은 비동기 작업으로 백그라운드에 전달됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;console.log('End')&lt;/span&gt;&lt;span&gt;가 호출 스택에서 실행됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;1초 후, 타이머의 콜백이 태스크 큐로 이동합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;호출 스택이 비어 있으면 이벤트 루프가 태스크 큐의 콜백을 호출 스택으로 이동시켜 실행합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;4. 마이크로 태스크와 매크로 태스크&lt;/h2&gt;
&lt;p data-pm-slice=&quot;1 3 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;태스크(Task)는 실행 우선순위에 따라 두 가지로 나뉩니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;매크로 태스크(Macro Task)&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: setTimeout, setInterval, setImmediate, I/O 작업 등이 포함됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;마이크로 태스크(Micro Task)&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: Promise의 &lt;/span&gt;&lt;span&gt;then&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;catch&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;finally&lt;/span&gt;&lt;span&gt;, async/await 등이 포함됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그럼 둘 중에 어떤 게 더 우선순위가 높나요❓&lt;/span&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;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;우선순위가 높습니다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;이벤트 루프는 콜 스택이 비어있는 시점에 마이크로태스크 큐에 있는 모든 작업들을 먼저 처리하고 매크로태스크의 작업을 실행합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;b&gt;실행 순서 예시&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734663655020&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;console.log('Start');

setTimeout(() =&amp;gt; {
  console.log('Timeout');
}, 0);

Promise.resolve().then(() =&amp;gt; {
  console.log('Promise');
});

console.log('End');

// 출력 결과
Start
End
Promise // Promise가 SetTimeout보다 먼저 실행
Timeout&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-pm-slice=&quot;3 3 []&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;호출 스택에 console.log('Start')가 추가되어 바로 실행됩니다.&lt;br /&gt;실행이 끝난 후, 호출 스택에서 제거됩니다.&lt;/li&gt;
&lt;li&gt;setTimeout 함수가 호출 스택에 추가됩니다.&lt;br /&gt;이 함수는 브라우저나 Node.js의 백그라운드 환경으로 전달되어 타이머가 설정됩니다.&lt;br /&gt;setTimeout 자체는 실행을 마친 뒤 호출 스택에서 제거됩니다.&lt;br /&gt;타이머가 만료되면, 해당 콜백이 &lt;b&gt;매크로 태스크 큐(Macro Task Queue)&lt;/b&gt;에 저장됩니다.&lt;/li&gt;
&lt;li&gt;Promise.resolve().then이 호출 스택에 추가됩니다. &lt;br /&gt;then에 전달된 콜백 함수는 &lt;b&gt;마이크로 태스크 큐(Micro Task Queue)&lt;/b&gt;에 저장됩니다.&lt;br /&gt;Promise 처리 자체는 완료된 뒤 호출 스택에서 제거됩니다.&lt;/li&gt;
&lt;li&gt;호출 스택에 console.log('End')가 추가되어 바로 실행됩니다.&lt;br /&gt;실행이 끝난 후, 호출 스택에서 제거됩니다.&lt;/li&gt;
&lt;li&gt;호출 스택이 비어 있는지 이벤트 루프가 확인합니다 &lt;br /&gt;먼저 &lt;b&gt;마이크로 태스크 큐&lt;/b&gt;를 확인하고 대기 중인 Promise의 콜백을 호출 스택으로 이동시켜 실행합니다.&lt;br /&gt;Promise 콜백이 실행되어 &quot;Promise&quot;가 출력되고 호출 스택에서 제거됩니다.&lt;/li&gt;
&lt;li&gt;이벤트 루프는 &lt;b&gt;마이크로 태스크 큐&lt;/b&gt;의 작업을 모두 완료해&amp;nbsp;&lt;b&gt;매크로 태스크 큐&lt;/b&gt;를 확인합니다.&lt;br /&gt;setTimeout의 콜백이 호출 스택으로 이동 후 실행되어 &quot;Timeout&quot;이 출력되고 호출 스택에서 제거됩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;border: 10px solid #009a87; border-radius: 0px; background-color: #ffffff; padding: 15px 30px; margin: 0;&quot;&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; top: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p style=&quot;text-align: left; font-weight: bold;&quot; data-ke-size=&quot;size18&quot;&gt;요약&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-pm-slice=&quot;3 3 []&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;자바스크립트는 단일 스레드 기반으로 동작하며, 비동기 작업 처리를 위해 이벤트 루프를 사용합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;이벤트 루프는 호출 스택과 태스크 큐를 관리하여 비동기 작업을 실행합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;태스크는 매크로 태스크와 마이크로 태스크로 구분되며, 마이크로 태스크가 우선 실행됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; bottom: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;a5&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 추천글&lt;/b&gt;&lt;/h3&gt;
&lt;figure id=&quot;og_1734664547930&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[JS] Promise.all 과 Promise.allSettled 차이&quot; data-og-description=&quot;JavaScript의 비동기 처리를 할 때, 여러 Promise를 동시에 처리해야 하는 경우가 많습니다. 이때 자주 사용하는 두 가지 메서드가 Promise.all과 Promise.allSettled입니다.목차 1. Promise.all 2. Promise.allSettled 3. &quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/135&quot; data-og-url=&quot;https://dev-hpk.tistory.com/135&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bh3C2f/hyXOcanLDB/rnRBvmoVyXrkaAbVedioj1/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bSpa4P/hyXOpAOw11/QyTsQZ4M5klFMaa4dXWlg0/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/YbkQr/hyXOnbVp2L/1O0a51lETztzOdKu5rkgk1/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/135&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/135&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bh3C2f/hyXOcanLDB/rnRBvmoVyXrkaAbVedioj1/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bSpa4P/hyXOpAOw11/QyTsQZ4M5klFMaa4dXWlg0/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/YbkQr/hyXOnbVp2L/1O0a51lETztzOdKu5rkgk1/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[JS] Promise.all 과 Promise.allSettled 차이&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;JavaScript의 비동기 처리를 할 때, 여러 Promise를 동시에 처리해야 하는 경우가 많습니다. 이때 자주 사용하는 두 가지 메서드가 Promise.all과 Promise.allSettled입니다.목차 1. Promise.all 2. Promise.allSettled 3.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734664563886&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Axios] interceptors 적용 - 토큰 재발급&quot; data-og-description=&quot;저번 포스팅에서 retryFetch라는 메서드를 만들어서 요청을 보내고, response의 status를 확인해 토큰을 재발급했다.const retryFetch = async ( url: string, options: RequestInit): Promise =&amp;gt; { const response = await fetch(url, o&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/136&quot; data-og-url=&quot;https://dev-hpk.tistory.com/136&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b8dc8j/hyXOezfXJD/IECryfau3I7gLrgYkYYJ21/img.gif?width=400&amp;amp;height=288&amp;amp;face=0_0_400_288,https://scrap.kakaocdn.net/dn/brVycm/hyXOl6f36q/fY9F2BmDkbgWLvLIDfkUb0/img.gif?width=400&amp;amp;height=288&amp;amp;face=0_0_400_288,https://scrap.kakaocdn.net/dn/qBVG5/hyXOfkEMKJ/ee5qQpTsk3dpxBdoNC2DY1/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/136&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/136&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b8dc8j/hyXOezfXJD/IECryfau3I7gLrgYkYYJ21/img.gif?width=400&amp;amp;height=288&amp;amp;face=0_0_400_288,https://scrap.kakaocdn.net/dn/brVycm/hyXOl6f36q/fY9F2BmDkbgWLvLIDfkUb0/img.gif?width=400&amp;amp;height=288&amp;amp;face=0_0_400_288,https://scrap.kakaocdn.net/dn/qBVG5/hyXOfkEMKJ/ee5qQpTsk3dpxBdoNC2DY1/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Axios] interceptors 적용 - 토큰 재발급&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;저번 포스팅에서 retryFetch라는 메서드를 만들어서 요청을 보내고, response의 status를 확인해 토큰을 재발급했다.const retryFetch = async ( url: string, options: RequestInit): Promise =&amp;gt; { const response = await fetch(url, o&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JavaScript</category>
      <category>event loop</category>
      <category>javascript</category>
      <category>js</category>
      <category>블로킹</category>
      <category>비동기</category>
      <category>싱글 스레드</category>
      <category>이벤트 루프</category>
      <category>자바스크립트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/149</guid>
      <comments>https://dev-hpk.tistory.com/149#entry149comment</comments>
      <pubDate>Fri, 20 Dec 2024 12:17:57 +0900</pubDate>
    </item>
    <item>
      <title>[Taskify] 무한스크롤 - 해결</title>
      <link>https://dev-hpk.tistory.com/147</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;592&quot; data-origin-height=&quot;89&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvK5TN/btsLnzXFuqH/YxdIGzXl8r0a8kOzmkxC70/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvK5TN/btsLnzXFuqH/YxdIGzXl8r0a8kOzmkxC70/img.png&quot; data-alt=&quot;이슈 내용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvK5TN/btsLnzXFuqH/YxdIGzXl8r0a8kOzmkxC70/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbvK5TN%2FbtsLnzXFuqH%2FYxdIGzXl8r0a8kOzmkxC70%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;592&quot; height=&quot;89&quot; data-origin-width=&quot;592&quot; data-origin-height=&quot;89&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이슈 내용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000;&quot;&gt;무한 스크롤 관련된 문제로 라이브러리를 사용해야 하나 고민이 많았습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000;&quot;&gt;우선 문제 상황과 지금까지 시도한 방법들을 간단하게 소개해보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000;&quot;&gt;문제 상황&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000;&quot;&gt;PC로 확인했을 때는 잘 동작하던 Intersection Observer API가 13인치 노트북으로 확인했더니 작동하지 않는다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000;&quot;&gt;브라우저의 크기를 변경한 상태(확대 및 축소)에서 Intersection Observer API가 작동하지 않는다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000;&quot;&gt;(브라우저의 확대 및 축소 기능을 사용하는 경우까지는 고려하지 않아도 된다고 피드백 받음!!)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000;&quot;&gt;특정 사이즈에서 Intersection Observer API가 의도한 대로 동작하지 않아 무한 스크롤을 마음대로 동작시킨다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000;&quot;&gt;&lt;b&gt;시도한 방법&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000;&quot;&gt; resize 이벤트&lt;span style=&quot;text-align: left;&quot;&gt;를 추가해서 브라우저 사이즈가 변경될 때마다 IntersectionObserver를 갱신한다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000; text-align: left;&quot;&gt; &lt;span style=&quot;text-align: left;&quot;&gt;root 요소의 사이즈가 변경될 때 옵저버를 갱신한다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000; text-align: left;&quot;&gt; &lt;span style=&quot;text-align: left;&quot;&gt;DOM이 업데이트가 완료되면 Intersection Observer가 실행될 수 있도록 수정한다.&lt;br /&gt;(isLoading 플래그를 추가해 비동기 데이터 처리가 끝나면 Intersection Observer가 동작하도록)&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000; text-align: start;&quot;&gt;mdn의 Intersection Observer API를 다시 정독하다가 해결 방법이 번뜩 떠올라버렸습니다!!!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000; text-align: start;&quot;&gt;그것은 바로... &lt;a style=&quot;color: #000000; text-align: start;&quot; href=&quot;https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API#threshold&quot;&gt;threshold&lt;/a&gt; 값을 수정하는 것입니다!! 너무 간단한데 이걸로 해결이 가능할까요?&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;MDN - Intersection Observer API의 threshold 설명&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000;&quot;&gt;관찰자의 콜백이 무조건 실행되어야 하는 대상의 가시성 백분율을 나타내는 숫자 또는 숫자 배열입니다. 만약 가시성이 50% 지점을 넘는 경우만 감지하고 싶다면, 0.5를 지정하여 사용할 수 있습니다. 만약 가시성이 25%만큼 넘어갈 때마다 콜백을 실행하고 싶다면, [0, 0.25, 0.5, 0.75, 1]을 지정하여 사용할 수 있습니다. 기본 값은 0입니다. (1 픽셀이라도 보이면, 콜백이 실행됩니다.) 1.0의 값은 모든 픽셀이 가시 상태가 될 때까지 임계값이 통과되지 않는다는 것을 의미합니다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #000000;&quot;&gt;바로 적용해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734586690333&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useEffect, useRef } from 'react';

function useIntersectionObserver(callback: IntersectionObserverCallback) {
  const observerRef = useRef&amp;lt;IntersectionObserver | null&amp;gt;(null);
  const elementRef = useRef&amp;lt;HTMLDivElement | null&amp;gt;(null);

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

    observerRef.current = new IntersectionObserver(callback, {
      root: elementRef.current.parentNode as Element,
      // 0일 때는 교차점이 한 번만 발생해도 실행
      // 요소의 경계가 보이기 시작하거나 사라지는 시점에서 콜백을 호출
      threshold: 0, 
    });

    observerRef.current.observe(elementRef.current);

    return () =&amp;gt; observerRef.current?.disconnect();
  }, [callback]);

  return elementRef;
}

export default useIntersectionObserver;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-19-14-41-15.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Mmxlw/btsLpG8XDZ7/VMVIS4hxINN67jO6SWkSWk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Mmxlw/btsLpG8XDZ7/VMVIS4hxINN67jO6SWkSWk/img.gif&quot; data-alt=&quot;무한 스크롤 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Mmxlw/btsLpG8XDZ7/VMVIS4hxINN67jO6SWkSWk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/Mmxlw/btsLpG8XDZ7/VMVIS4hxINN67jO6SWkSWk/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-19-14-41-15.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;무한 스크롤 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이전 작업 때처럼 에러가 발생하는 상황을 방지하기 위해 PC 2대, 노트북, 모바일(아이폰 &amp;amp; 갤럭시)에서 무한 스크롤 동작을 확인했습니다. 모두 정상 동작하네요!!&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;수정할 부분도 많고 아직 완벽하진 않지만 라이브러리 없이 무한 스크롤을 구현해 봤다는 사실에 아주 큰 성취감을 느꼈습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;앞으로도 다른 기능을 구현할 때 라이브러리를 사용하기보다는 직접 구현해 볼 수 있는 용기가 생긴 것 같아 아주 뿌듯하네요 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;추가로.. 새로운 기능들 적용해 보기 전에 공식 문서나 참고 자료들 꼼꼼히 읽는 습관을 들여야 할 것 같습니다.. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;남은 프로젝트 잘 마무리하고 개발자로 한층 더 성장해 나가겠습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734589919583&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 대시보드 상세 - 무한 스크롤&quot; data-og-description=&quot;라이브러리 없이 무한 스크롤 구현하기!!!어떤 방식으로 구현할까 고민하다가 Intersection Observer API라는 좋은 기능을 찾았습니다. Intersection Observer API는 상위 요소 또는 최상위 문서의&amp;nbsp;viewport와 대&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/146&quot; data-og-url=&quot;https://dev-hpk.tistory.com/146&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/yQely/hyXOh3zIwt/KaSvzBElY170zCGfDxOKPk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/b1Wn3v/hyXOqlWQWJ/J2JgzkM0yYTWfDj24wNtJk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/lLO1h/hyXOmRlxxQ/8HXPHfT6zNb7pWCjF2Zsi0/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/146&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/146&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/yQely/hyXOh3zIwt/KaSvzBElY170zCGfDxOKPk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/b1Wn3v/hyXOqlWQWJ/J2JgzkM0yYTWfDj24wNtJk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/lLO1h/hyXOmRlxxQ/8HXPHfT6zNb7pWCjF2Zsi0/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 대시보드 상세 - 무한 스크롤&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;라이브러리 없이 무한 스크롤 구현하기!!!어떤 방식으로 구현할까 고민하다가 Intersection Observer API라는 좋은 기능을 찾았습니다. Intersection Observer API는 상위 요소 또는 최상위 문서의&amp;nbsp;viewport와 대&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734589924802&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 대시보드 상세 페이지&quot; data-og-description=&quot;오늘은 대시보드 페이지를 개발해 봤습니다.우선 전반적인 코드를 먼저 보여드리고 작업하면서 있었던 문제들과 해결한 방법에 대해서 설명해 볼게요!&amp;nbsp;GET Columnsimport axios from '@/lib/instance';import &quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/145&quot; data-og-url=&quot;https://dev-hpk.tistory.com/145&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/B6uwG/hyXOh3zIRs/FHJHJHH1cJi3J997Cm7YV0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/xfFV3/hyXOfkpAnh/D2EWLZdq1OkfCLKa2jd7lk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bdh2L1/hyXOinTmhY/R1kfs3sVY3Au1VOkkhJRo1/img.png?width=1769&amp;amp;height=844&amp;amp;face=0_0_1769_844&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/145&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/145&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/B6uwG/hyXOh3zIRs/FHJHJHH1cJi3J997Cm7YV0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/xfFV3/hyXOfkpAnh/D2EWLZdq1OkfCLKa2jd7lk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bdh2L1/hyXOinTmhY/R1kfs3sVY3Au1VOkkhJRo1/img.png?width=1769&amp;amp;height=844&amp;amp;face=0_0_1769_844');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 대시보드 상세 페이지&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 대시보드 페이지를 개발해 봤습니다.우선 전반적인 코드를 먼저 보여드리고 작업하면서 있었던 문제들과 해결한 방법에 대해서 설명해 볼게요!&amp;nbsp;GET Columnsimport axios from '@/lib/instance';import&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Infinity Scroll</category>
      <category>IntersectionObserver</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>react</category>
      <category>개발</category>
      <category>무한 스크롤</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/147</guid>
      <comments>https://dev-hpk.tistory.com/147#entry147comment</comments>
      <pubDate>Thu, 19 Dec 2024 14:59:47 +0900</pubDate>
    </item>
    <item>
      <title>[Taskify] 대시보드 상세 - 무한 스크롤</title>
      <link>https://dev-hpk.tistory.com/146</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;라이브러리 없이 무한 스크롤 구현하기!!!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;어떤 방식으로 구현할까 고민하다가 Intersection Observer API라는 좋은 기능을 찾았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #000000; font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; Intersection Observer API는 상위 요소 또는 최상위 문서의&amp;nbsp;&lt;a style=&quot;color: #000000;&quot; href=&quot;https://developer.mozilla.org/ko/docs/Glossary/Viewport&quot;&gt;viewport&lt;/a&gt;와 대상 요소 사이의 변화를 비동기적으로 관찰할 수 있는 수단을 제공합니다.&lt;br /&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기본 사용법&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734535194485&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let options = {
  root: document.querySelector(&quot;#scrollArea&quot;),
  rootMargin: &quot;0px&quot;,
  threshold: 1.0,
};

let observer = new IntersectionObserver(callback, options);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;컬럼과 댓글을 불러오는데 모두 사용하기 위해 커스텀 훅으로 관리하도록 만들어 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734535262057&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useEffect, useRef } from 'react';

function useIntersectionObserver(callback: IntersectionObserverCallback) {
  // IntersectionObserver 인스턴스를 저장하는 ref
  const observerRef = useRef&amp;lt;IntersectionObserver | null&amp;gt;(null);
  // 관찰할 DOM 요소를 저장하는 ref
  const elementRef = useRef&amp;lt;HTMLDivElement | null&amp;gt;(null);

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

    observerRef.current = new IntersectionObserver(callback, {
      // 관찰 기준을 현재 요소의 부모로 설정
      root: elementRef.current.parentNode as Element,
      threshold: 0.95,
    });

    observerRef.current.observe(elementRef.current);

    // 컴포넌트 언마운트 시 Observer 연결 해제
    return () =&amp;gt; observerRef.current?.disconnect();
  }, [callback]);

  return elementRef;
}

export default useIntersectionObserver;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 준비는 끝났습니다. 컴포넌트에 적용해 봅시다!!&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-19-00-14-03.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wryyg/btsLmSCTtd9/D780laID2rS5H3TiQfZvZk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wryyg/btsLmSCTtd9/D780laID2rS5H3TiQfZvZk/img.gif&quot; data-alt=&quot;무한 스크롤 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wryyg/btsLmSCTtd9/D780laID2rS5H3TiQfZvZk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/wryyg/btsLmSCTtd9/D780laID2rS5H3TiQfZvZk/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;1920&quot; height=&quot;1040&quot; data-filename=&quot;localhost_3000_dashboard_12794-Chrome-2024-12-19-00-14-03.gif&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;무한 스크롤 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;PC 2개로 확인해 본 결과 반응형까지 모두 완벽하게 동작합니다!!!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이렇게 끝났으면 좋았을 텐데... 팀원 중 한 분이 테스트하다가 문제가 생겼다고 연락을 주셨습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;592&quot; data-origin-height=&quot;89&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oi7XZ/btsLocNXluX/YOckT7VdQZiKDaIUbmtVS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oi7XZ/btsLocNXluX/YOckT7VdQZiKDaIUbmtVS0/img.png&quot; data-alt=&quot;이슈 공유&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oi7XZ/btsLocNXluX/YOckT7VdQZiKDaIUbmtVS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Foi7XZ%2FbtsLocNXluX%2FYOckT7VdQZiKDaIUbmtVS0%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;579&quot; height=&quot;87&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;592&quot; data-origin-height=&quot;89&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이슈 공유&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이상이 없길 바라는 마음으로 혹시 몰라 노트북으로 확인해 보니 &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;일부 사이즈 화면에서 Intersection Observer API가 동작하지 않았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;일단 문제가 될 수 있는 상황부터 차근차근 찾아보았습니다. 고려해 볼 상황은 아래와 같습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; Viewport나 Root의 변경 감지 실패&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;문제 상황 :&lt;/b&gt; 브라우저나 디바이스 사이즈가 변경되었을 때, IntersectionObserver는 &lt;b&gt;자동으로 다시 계산되지 않을 수 있습니다&lt;/b&gt;.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;해결 방법 :&lt;/b&gt; &lt;b&gt;resize 이벤트&lt;/b&gt;를 추가해서 브라우저 사이즈가 변경될 때마다 IntersectionObserver를 다시 생성하거나 갱신합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;적용 결과 :&lt;/b&gt; 실패... &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; root 또는 target이 올바르게 설정되지 않음&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;문제 상황 :&lt;/b&gt; &lt;b&gt;root&lt;/b&gt; 요소를 명시적으로 설정했다면, 디바이스 사이즈가 변경될 때 &lt;b&gt;root 요소의 사이즈&lt;/b&gt;가 달라질 수 있습니다. 이로 인해 IntersectionObserver가 예상과 다르게 동작할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;해결 방법 : root 요소의 사이즈가 변경될 때 옵저버를 갱신합니다.&lt;/span&gt;&lt;br /&gt;
&lt;pre id=&quot;code_1734537050247&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;const endRef = useIntersectionObserver(handleObserver, rootRef);&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;적용 결과 :&lt;/b&gt; 실패... &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; 비동기적 렌더링으로 타이밍 문제가 발생&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;문제 상황 : &lt;/b&gt;API 요청으로 데이터를 받아와 DOM에 업데이트가 완료되기 전에 IntersectionObserver가 실행될 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;해결 방법 :&lt;/b&gt; DOM이 업데이트가 완료되면 Intersection Observer가 실행될 수 있도록 수정합니다.&lt;/span&gt;&lt;br /&gt;
&lt;pre id=&quot;code_1734537731667&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;const handleObserver = useCallback(
    ([entry]) =&amp;gt; {
      if (entry.isIntersecting &amp;amp;&amp;amp; columnData.cursorId &amp;amp;&amp;amp; !isLoading)
        fetchCards(columnData.cursorId);
    },
    [fetchCards, columnData.cursorId, isLoading],
  );&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;적용 결과 : 실패... &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;알아본 결과 내에서는 모두 해결하지 못했습니다... &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;멘토님께 피드백을 받은 결과 현업에서는 성능과 효율 측면에서 라이브러리를 사용하신다고 답변을 해주셨습니다.&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1734537893528&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;react-intersection-observer&quot; data-og-description=&quot;Monitor if a component is inside the viewport, using IntersectionObserver API. Latest version: 9.14.0, last published: 4 days ago. Start using react-intersection-observer in your project by running &amp;#96;npm i react-intersection-observer&amp;#96;. There are 1175 other &quot; data-og-host=&quot;www.npmjs.com&quot; data-og-source-url=&quot;https://www.npmjs.com/package/react-intersection-observer&quot; data-og-url=&quot;https://www.npmjs.com/package/react-intersection-observer&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bsb1Vp/hyXOcVmbPv/XPerT7b1YpbDOfq2C2GWjK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/react-intersection-observer&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.npmjs.com/package/react-intersection-observer&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bsb1Vp/hyXOcVmbPv/XPerT7b1YpbDOfq2C2GWjK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;react-intersection-observer&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Monitor if a component is inside the viewport, using IntersectionObserver API. Latest version: 9.14.0, last published: 4 days ago. Start using react-intersection-observer in your project by running `npm i react-intersection-observer`. There are 1175 other&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.npmjs.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;라이브러리 없이 무한 스크롤을 구현해 봤다는 점에서 큰 성취감을 얻었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;노트북을 제외한 대부분의 PC에서는 작동을 한다는 점에서는 만족하지만 모든 디바이스와 브라우저를 만족시키지 못한 점에서 아쉬움이 너무 커서 이 문제는 라이브러리가 어떤 방식으로 동작하는지 확인 후 수정하겠습니다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734538120190&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 대시보드 상세 페이지&quot; data-og-description=&quot;오늘은 대시보드 페이지를 개발해 봤습니다.우선 전반적인 코드를 먼저 보여드리고 작업하면서 있었던 문제들과 해결한 방법에 대해서 설명해 볼게요!&amp;nbsp;GET Columnsimport axios from '@/lib/instance';import &quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/145&quot; data-og-url=&quot;https://dev-hpk.tistory.com/145&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/4DMKk/hyXOh3qq4y/KnkwAMa1N0QzbMF4tAsun0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dfLNrN/hyXOdzYvTA/nprFYTN9BiT8sLBDw8Lh8K/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cbRSSd/hyXOno3JKz/F4KlYaoMgY7SS3VekVI1QK/img.png?width=1769&amp;amp;height=844&amp;amp;face=0_0_1769_844&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/145&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/145&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/4DMKk/hyXOh3qq4y/KnkwAMa1N0QzbMF4tAsun0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dfLNrN/hyXOdzYvTA/nprFYTN9BiT8sLBDw8Lh8K/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cbRSSd/hyXOno3JKz/F4KlYaoMgY7SS3VekVI1QK/img.png?width=1769&amp;amp;height=844&amp;amp;face=0_0_1769_844');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 대시보드 상세 페이지&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 대시보드 페이지를 개발해 봤습니다.우선 전반적인 코드를 먼저 보여드리고 작업하면서 있었던 문제들과 해결한 방법에 대해서 설명해 볼게요!&amp;nbsp;GET Columnsimport axios from '@/lib/instance';import&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734538125299&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] Chip(공통 컴포넌트) 추가&quot; data-og-description=&quot;오늘은 공통 컴포넌트 Chip을 개발해 보겠습니다.// Chip.tsimport { ReactNode } from 'react';type ChipType = 'tag' | 'status' | 'status-option';export interface ChipProps { children: ReactNode; chipType: ChipType;}export const bgTag = ['oran&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/144&quot; data-og-url=&quot;https://dev-hpk.tistory.com/144&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/czZc5m/hyXOfYQK9a/1IOtmtLcLABeeW92J2235K/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/XPgnW/hyXOeezAse/IT7XUZj89K0EZp6F9joOlk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dGj766/hyXOlxXus2/99QKosRly1ZnFxiUYCbe0k/img.png?width=424&amp;amp;height=668&amp;amp;face=0_0_424_668&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/144&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/144&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/czZc5m/hyXOfYQK9a/1IOtmtLcLABeeW92J2235K/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/XPgnW/hyXOeezAse/IT7XUZj89K0EZp6F9joOlk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dGj766/hyXOlxXus2/99QKosRly1ZnFxiUYCbe0k/img.png?width=424&amp;amp;height=668&amp;amp;face=0_0_424_668');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] Chip(공통 컴포넌트) 추가&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 공통 컴포넌트 Chip을 개발해 보겠습니다.// Chip.tsimport { ReactNode } from 'react';type ChipType = 'tag' | 'status' | 'status-option';export interface ChipProps { children: ReactNode; chipType: ChipType;}export const bgTag = ['oran&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Infinity Scroll</category>
      <category>IntersectionObserver</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>react</category>
      <category>typescript</category>
      <category>개발</category>
      <category>무한 스크롤</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/146</guid>
      <comments>https://dev-hpk.tistory.com/146#entry146comment</comments>
      <pubDate>Thu, 19 Dec 2024 01:09:42 +0900</pubDate>
    </item>
    <item>
      <title>[Taskify] 대시보드 상세 페이지</title>
      <link>https://dev-hpk.tistory.com/145</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;917&quot; data-origin-height=&quot;568&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/u09zr/btsLl2E5tU7/6zLViMJqbun7CX1c8gf2g0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/u09zr/btsLl2E5tU7/6zLViMJqbun7CX1c8gf2g0/img.png&quot; data-alt=&quot;대시보드 상세 페이지 UI&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/u09zr/btsLl2E5tU7/6zLViMJqbun7CX1c8gf2g0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fu09zr%2FbtsLl2E5tU7%2F6zLViMJqbun7CX1c8gf2g0%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;917&quot; height=&quot;568&quot; data-origin-width=&quot;917&quot; data-origin-height=&quot;568&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;대시보드 상세 페이지 UI&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오늘은 대시보드 페이지를 개발해 봤습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;우선 전반적인 코드를 먼저 보여드리고 작업하면서 있었던 문제들과 해결한 방법에 대해서 설명해 볼게요!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;GET Columns&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734428447186&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from '@/lib/instance';
import { GetColumnParams, GetColumnsResponse } from '@/type/column';

const getColumns = async ({
  teamId,
  dashboardId,
}: GetColumnParams): Promise&amp;lt;GetColumnsResponse&amp;gt; =&amp;gt; {
  try {
    const response = await axios.get(`/${teamId}/columns/`, {
      params: {
        dashboardId,
      },
    });

    if (response.status === 200) return response.data;
    throw new Error('컬럼을 불러오는 데 실패했습니다.');
  } catch (error) {
    console.error('컬럼 조회 실패 : ', error);
    throw error;
  }
};

export default getColumns;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;GET Cards&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734428467720&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from '@/lib/instance';
import { GetCardParams, GetCardsResponse } from '@/type/card';

const getCards = async ({
  teamId,
  size = 4,
  cursorId = null,
  columnId,
}: GetCardParams): Promise&amp;lt;GetCardsResponse&amp;gt; =&amp;gt; {
  try {
    const response = await axios.get(`/${teamId}/cards/`, {
      params: {
        size,
        cursorId,
        columnId,
      },
    });

    if (response.status === 200) return response.data;
    throw new Error('카드 목록을 불러오는 데 실패했습니다.');
  } catch (error) {
    console.error('카드 목록 조회 실패 : ', error);
    throw error;
  }
};

export default getCards;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;/dashboards/[id].tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734428797276&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import Sidebar from '@/components/common/sidebar/Sidebar';
import CDSButton from '@/components/common/button/CDSButton';
import getColumns from '@/lib/dashboard/getColumns';
import { useEffect, useState, useCallback } from 'react';
import styles from '@/pages/dashboards/Dashboard.module.css';
import Column from '@/components/dashboard/column/Column';
import { Column as ColumnType } from '@/type/column';
import { useRouter } from 'next/router';

function DashBoard() {
  const { query } = useRouter();
  const [columns, setColumns] = useState&amp;lt;ColumnType[]&amp;gt;([]);

  const fetchColumns = useCallback(async () =&amp;gt; {
    const dashboardId = Number(query.id);
    if (!dashboardId) return;
    try {
      const { data, result } = await getColumns({
        teamId: '11-6',
        dashboardId,
      });

      if (result === 'SUCCESS') {
        setColumns(data);
      }
    } catch (error) {
      console.error('컬럼 조회 실패 : ', error);
    }
  }, [query.id]);

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

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;Sidebar /&amp;gt;
      &amp;lt;div className={styles.container}&amp;gt;
        {columns.map(({ id, title }) =&amp;gt; (
          &amp;lt;Column key={`column_${id}`} targetId={id} columnTitle={title} /&amp;gt;
        ))}
        &amp;lt;div className={styles['add-column']}&amp;gt;
          &amp;lt;CDSButton btnType=&quot;column&quot;&amp;gt;새로운 컬럼 추가하기&amp;lt;/CDSButton&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default DashBoard;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Column.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734428872062&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import styles from '@/components/dashboard/column/Column.module.css';
import CDSButton from '@/components/common/button/CDSButton';
import Card from '@/components/dashboard/card/Card';
import { useCallback, useEffect, useRef } from 'react';
import SettingIcon from 'public/ic/ic_setting.svg';
import Link from 'next/link';
import useIntersectionObserver from '@/hooks/useIntersectionObserver';
import useColumnData from '@/hooks/useColumnData';

interface ColumnProp {
  targetId: number;
  columnTitle: string;
}

function Column({ targetId, columnTitle }: ColumnProp) {
  const { columnData, fetchCards } = useColumnData(targetId);

  const handleObserver = useCallback(
    ([entry]) =&amp;gt; {
      if (entry.isIntersecting &amp;amp;&amp;amp; columnData.cursorId)
        fetchCards(columnData.cursorId);
    },
    [fetchCards, columnData.cursorId],
  );

  const endPoint = useIntersectionObserver(handleObserver);

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

  return (
    &amp;lt;div className={styles.column}&amp;gt;
      &amp;lt;div className={styles['column-title-section']}&amp;gt;
        &amp;lt;div className={styles['column-title']}&amp;gt;
          {columnTitle}
          &amp;lt;span className={styles['column-size']}&amp;gt;{columnData.totalCount}&amp;lt;/span&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;Link
          href={`/dashboard/${targetId}/edit`}
          className={styles['btn-edit-column']}
        &amp;gt;
          &amp;lt;SettingIcon className={styles['icon-setting']} /&amp;gt;
        &amp;lt;/Link&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;CDSButton btnType=&quot;todo&quot; /&amp;gt;
      &amp;lt;div className={styles['card-section']}&amp;gt;
        {columnData.cards.map(
          ({ imageUrl, id, title, tags, dueDate, assignee: { nickname } }) =&amp;gt; (
            &amp;lt;Card
              key={`card_${id}`}
              imageUrl={imageUrl}
              id={id}
              title={title}
              tags={tags}
              dueDate={dueDate}
              nickname={nickname}
            /&amp;gt;
          ),
        )}
        {columnData.cursorId &amp;amp;&amp;amp; (
          &amp;lt;div ref={endPoint} className={styles['end-point']} /&amp;gt;
        )}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default Column;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Card.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734429024212&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import styles from '@/components/dashboard/card/Card.module.css';
import Chip from '@/components/common/chip/Chip';
import CardImage from '@/components/dashboard/card/CardImage';
import CalendarIcon from 'public/ic/ic_calendar.svg';
import formatDate from '@/utils/formatDate';

interface CardProps {
  imageUrl: string;
  id: number;
  title: string;
  tags: string[];
  dueDate: string;
  nickname: string;
}

function Card({ imageUrl, id, title, tags, dueDate, nickname }: CardProps) {
  const fomattedDueDate = formatDate(dueDate);
  const nameInitial = nickname[0].toUpperCase();

  return (
    &amp;lt;button type=&quot;button&quot; className={styles.card}&amp;gt;
      &amp;lt;CardImage image={imageUrl} name={title} /&amp;gt;
      &amp;lt;div className={styles['content-section']}&amp;gt;
        &amp;lt;div className={styles['card-title']}&amp;gt;{title}&amp;lt;/div&amp;gt;
        &amp;lt;div className={styles['description-section']}&amp;gt;
          &amp;lt;div className={styles['card-tags']}&amp;gt;
            {tags.map((tag) =&amp;gt; (
              &amp;lt;Chip key={`${id}_tag_${tag}`} chipType=&quot;tag&quot;&amp;gt;
                {tag}
              &amp;lt;/Chip&amp;gt;
            ))}
          &amp;lt;/div&amp;gt;
          &amp;lt;div className={styles['card-date']}&amp;gt;
            &amp;lt;CalendarIcon className={styles['icon-calendar']} /&amp;gt;
            &amp;lt;span className={styles.date}&amp;gt;{fomattedDueDate}&amp;lt;/span&amp;gt;
          &amp;lt;/div&amp;gt;
          &amp;lt;div className={styles.badge}&amp;gt;{nameInitial}&amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/button&amp;gt;
  );
}

export default Card;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;실행 결과&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1769&quot; data-origin-height=&quot;844&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wKTAO/btsLk9LKRvT/Nrz3nfxN0hr61EgYXbWFy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wKTAO/btsLk9LKRvT/Nrz3nfxN0hr61EgYXbWFy1/img.png&quot; data-alt=&quot;실행 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wKTAO/btsLk9LKRvT/Nrz3nfxN0hr61EgYXbWFy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwKTAO%2FbtsLk9LKRvT%2FNrz3nfxN0hr61EgYXbWFy1%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;1769&quot; height=&quot;844&quot; data-origin-width=&quot;1769&quot; data-origin-height=&quot;844&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;실행 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;실행 결과를 보시면 데이터가 잘 호출되고 화면도 잘 나오는 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;하지만 네트워크 탭을 자세히 보면 같은 네트워크 요청이 2번씩 반복되는 것을 볼 수 있습니다,&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;332&quot; data-origin-height=&quot;166&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZhYqH/btsLmVZoqY3/koT6PuqJ4AKATjnZ2iItFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZhYqH/btsLmVZoqY3/koT6PuqJ4AKATjnZ2iItFk/img.png&quot; data-alt=&quot;네트워크 요청&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZhYqH/btsLmVZoqY3/koT6PuqJ4AKATjnZ2iItFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZhYqH%2FbtsLmVZoqY3%2FkoT6PuqJ4AKATjnZ2iItFk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;332&quot; height=&quot;166&quot; data-origin-width=&quot;332&quot; data-origin-height=&quot;166&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;네트워크 요청&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;분명 데이터를 한 번만 요청했는데, 왜 두 번이나 요청을 보낸 거지...?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Card 데이터를 요청하는 코드로 돌아가 간단한 테스트를 해봤습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734429427812&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Column.tsx
useEffect(() =&amp;gt; {
    console.log('render');
    fetchCards();
}, [fetchCards]);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;49&quot; data-origin-height=&quot;43&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yvVvI/btsLmkL8GPM/jkyRg4eWze7VUF7f9pVaRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yvVvI/btsLmkL8GPM/jkyRg4eWze7VUF7f9pVaRK/img.png&quot; data-alt=&quot;console 디버깅 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yvVvI/btsLmkL8GPM/jkyRg4eWze7VUF7f9pVaRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyvVvI%2FbtsLmkL8GPM%2FjkyRg4eWze7VUF7f9pVaRK%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;75&quot; height=&quot;66&quot; data-origin-width=&quot;49&quot; data-origin-height=&quot;43&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;console 디버깅 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;console.log도 2번 실행되네요. 이제 문제는 useEffect에 있다는 것이 확실해졌습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;문제 발생 원인&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; Next.js에서 useEffect의 콜백이 두 번 실행되는 이유는 &lt;b&gt;React Strict Mode&lt;/b&gt; 때문이었습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; React의 &lt;b&gt;Strict Mode&lt;/b&gt;는 개발 모드에서 &lt;b&gt;useEffect&lt;/b&gt;나 &lt;b&gt;componentDidMount&lt;/b&gt;와 같은 라이프사이클 훅이 두 번 실행되도록 만들어 상태 업데이트나 렌더링 시 발생할 수 있는 사이드 이펙트를 미리 검출한다고 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;문제 해결 방법&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;next.config.ts 파일의&amp;nbsp; reactStrictMode를 fasle로 변경합니다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734430164151&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const nextConfig: NextConfig = {
  /* config options here */
  reactStrictMode: false,
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이제 서버를 다시 시작하면 ✨해결 완료✨&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;368&quot; data-origin-height=&quot;141&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bg7IeC/btsLlHBblur/pt3Gey9gyf4dqg4ceg18Q1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bg7IeC/btsLlHBblur/pt3Gey9gyf4dqg4ceg18Q1/img.png&quot; data-alt=&quot;문제 해결&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bg7IeC/btsLlHBblur/pt3Gey9gyf4dqg4ceg18Q1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbg7IeC%2FbtsLlHBblur%2Fpt3Gey9gyf4dqg4ceg18Q1%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;368&quot; height=&quot;141&quot; data-origin-width=&quot;368&quot; data-origin-height=&quot;141&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;문제 해결&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그런데 Strict Mode의 역할을 찾아보고 나서 뭔가 계속 찝찝합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;개발 모드에서만 더블 렌더링이 발생하고 배포 후에는 더블 렌더링이 발생하지 않는다고 하니 reactStrictMode를 true로 설정한 상태로 작업을 해보겠습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Flag를 사용해 더블 렌더링 체크하기&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1734431209302&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const isFirstRender = useRef(true); // StrictMode 때문에 api 2번 요청해서 임시로 추가

useEffect(() =&amp;gt; {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }

    fetchCards();
}, [fetchCards]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; 라이프사이클 훅이 2번 실행되기 때문에 첫 번째 렌더링 때는 아무 동작도 하지 않도록 하기 위해 isFirstRender이라는 Ref 객체를 선언했습니다. useEffect를 보시면 isFirstRender.current라는 조건문으로 첫 번째 렌더링인 경우 아무 동작도 하지 않고 return 해버리도록 만들었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결과는...&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;387&quot; data-origin-height=&quot;103&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/csttFL/btsLmqMiFPj/pMBoWQmDBOETiL4BsZLCik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/csttFL/btsLmqMiFPj/pMBoWQmDBOETiL4BsZLCik/img.png&quot; data-alt=&quot;더블 렌더링 체크 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/csttFL/btsLmqMiFPj/pMBoWQmDBOETiL4BsZLCik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcsttFL%2FbtsLmqMiFPj%2FpMBoWQmDBOETiL4BsZLCik%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;387&quot; height=&quot;103&quot; data-origin-width=&quot;387&quot; data-origin-height=&quot;103&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;더블 렌더링 체크 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Strict Mode를 false로 설정했을 때와 동일하게 1번만 요청합니다!!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;최종 배포를 할 때 코드를 삭제해줘야 하는 번거로움이 있지만, 그래도 Strict Mode의 여러 장점을 활용할 수 있으니 이 방법이 좋을 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734431930386&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] Chip(공통 컴포넌트) 추가&quot; data-og-description=&quot;오늘은 공통 컴포넌트 Chip을 개발해 보겠습니다.// Chip.tsimport { ReactNode } from 'react';type ChipType = 'tag' | 'status' | 'status-option';export interface ChipProps { children: ReactNode; chipType: ChipType;}export const bgTag = ['oran&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/144&quot; data-og-url=&quot;https://dev-hpk.tistory.com/144&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/iJvwW/hyXOqsgZ0R/GPuK8zHEK2k7aYKPo2OH40/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/ebDj0b/hyXKkN0zId/96FK1iJjukseyQ2OAkChs0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bShnX4/hyXOmJ8G5w/ZW2cI5ZCARGywjMK2s5j60/img.png?width=424&amp;amp;height=668&amp;amp;face=0_0_424_668&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/144&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/144&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/iJvwW/hyXOqsgZ0R/GPuK8zHEK2k7aYKPo2OH40/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/ebDj0b/hyXKkN0zId/96FK1iJjukseyQ2OAkChs0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bShnX4/hyXOmJ8G5w/ZW2cI5ZCARGywjMK2s5j60/img.png?width=424&amp;amp;height=668&amp;amp;face=0_0_424_668');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] Chip(공통 컴포넌트) 추가&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 공통 컴포넌트 Chip을 개발해 보겠습니다.// Chip.tsimport { ReactNode } from 'react';type ChipType = 'tag' | 'status' | 'status-option';export interface ChipProps { children: ReactNode; chipType: ChipType;}export const bgTag = ['oran&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734431936384&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 버튼(공통 컴포넌트) 추가&quot; data-og-description=&quot;프로젝트에서 공통으로 사용하는 버튼 컴포넌트 개발을 맡게 되었습니다.아래 보이는 버튼들을 모두 하나의 버튼 컴포넌트로 관리해야 한다니... 일단 도전!// Button.tsximport styles from &amp;quot;./Button.module&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/143&quot; data-og-url=&quot;https://dev-hpk.tistory.com/143&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/lDEcu/hyXOcngmr4/s4N0Rmw4axKWkuhbvjyqj0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/TsOme/hyXKoCRZ7I/A3PUX8WeYI5hMXqDUYyzZk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dsRMVa/hyXOpmAXbs/k4x3oP8YMnkvpyLpNmzMgK/img.png?width=697&amp;amp;height=1397&amp;amp;face=0_0_697_1397&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/143&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/143&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/lDEcu/hyXOcngmr4/s4N0Rmw4axKWkuhbvjyqj0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/TsOme/hyXKoCRZ7I/A3PUX8WeYI5hMXqDUYyzZk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dsRMVa/hyXOpmAXbs/k4x3oP8YMnkvpyLpNmzMgK/img.png?width=697&amp;amp;height=1397&amp;amp;face=0_0_697_1397');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 버튼(공통 컴포넌트) 추가&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트에서 공통으로 사용하는 버튼 컴포넌트 개발을 맡게 되었습니다.아래 보이는 버튼들을 모두 하나의 버튼 컴포넌트로 관리해야 한다니... 일단 도전!// Button.tsximport styles from &quot;./Button.module&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>axios</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>react</category>
      <category>strict mode</category>
      <category>TS</category>
      <category>typescript</category>
      <category>개발</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/145</guid>
      <comments>https://dev-hpk.tistory.com/145#entry145comment</comments>
      <pubDate>Tue, 17 Dec 2024 19:37:56 +0900</pubDate>
    </item>
    <item>
      <title>[Taskify] Chip(공통 컴포넌트) 추가</title>
      <link>https://dev-hpk.tistory.com/144</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;558&quot; data-origin-height=&quot;496&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEcvo4/btsLj2ZOPC8/TFwDBPxRSxGOZKOTiUCgkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEcvo4/btsLj2ZOPC8/TFwDBPxRSxGOZKOTiUCgkk/img.png&quot; data-alt=&quot;공통 Chip 컴포넌트 UI&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEcvo4/btsLj2ZOPC8/TFwDBPxRSxGOZKOTiUCgkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEcvo4%2FbtsLj2ZOPC8%2FTFwDBPxRSxGOZKOTiUCgkk%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;558&quot; height=&quot;496&quot; data-origin-width=&quot;558&quot; data-origin-height=&quot;496&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;공통 Chip 컴포넌트 UI&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오늘은 공통 컴포넌트 Chip을 개발해 보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734351660685&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Chip.ts
import { ReactNode } from 'react';

type ChipType = 'tag' | 'status' | 'status-option';

export interface ChipProps {
  children: ReactNode;
  chipType: ChipType;
}

export const bgTag = ['orange', 'green', 'pink', 'blue'];&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1734351613385&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Chip.tsx
import clsx from 'clsx';
import { ChipProps } from '@/type/chip';
import { PropsWithChildren } from 'react';
import styles from './Chip.module.css';
import getTagColor from './helper';

/**
 * Chip 컴포넌트
 * - 다양한 유형의 Chip을 렌더링하는 공통 컴포넌트.
 * - chipType(['tag', 'status', 'status-option'])에 따라 스타일과 Dot 렌더링 여부를 결정합니다.
 *
 * @param {string} props.chipType - Chip의 타입을 지정
 * @param {React.ReactNode} props.children - Chip 내부에 렌더링할 내용
 * @returns {JSX.Element} Chip 컴포넌트의
 */
function Chip({ children, chipType }: PropsWithChildren&amp;lt;ChipProps&amp;gt;) {
  /**
   * renderDot: status인 타입인 경우 점을 렌더링
   * - 조건: chipType.startsWith('status') -&amp;gt; status | status-option
   * @returns {JSX.Element} - 점 span 태그
   */
  const renderDot = () =&amp;gt;
    chipType.startsWith('status') &amp;amp;&amp;amp; &amp;lt;span className={styles.dot} /&amp;gt;;

  const className = clsx(
    styles[chipType],
    chipType === 'tag' &amp;amp;&amp;amp; getTagColor(styles),
  );

  return (
    &amp;lt;span className={className}&amp;gt;
      {renderDot()}
      {children}
    &amp;lt;/span&amp;gt;
  );
}

export default Chip;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이번엔 아주 간단한 컴포넌트라 따로 파일을 분리할 필요가 없습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그런데 한 가지만 문제가 생겼습니다. chipType이 'tag'인 경우 색상을 지정해줘야 한다는 것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;424&quot; data-origin-height=&quot;668&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bz63nf/btsLlQjmbQ7/Pdyf5ulvfGbhObckYMo0H1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bz63nf/btsLlQjmbQ7/Pdyf5ulvfGbhObckYMo0H1/img.png&quot; data-alt=&quot;할 일 생성 모달&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bz63nf/btsLlQjmbQ7/Pdyf5ulvfGbhObckYMo0H1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbz63nf%2FbtsLlQjmbQ7%2FPdyf5ulvfGbhObckYMo0H1%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;424&quot; height=&quot;668&quot; data-origin-width=&quot;424&quot; data-origin-height=&quot;668&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;할 일 생성 모달&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;할 일 생성 모달에서 태그 칸에 텍스트를 입력 후 엔터를 누르면 텍스트가 태그 형식으로 바뀌는 구조입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그런데 태그가 특정 몇 개로 정의된 것도 아니고 태그마다 배경색을 지정해 줄 수 있는 방법이 없습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오전 스크럼 때 팀원들에게 이슈를 공유하고 피그마에 정의된 4가지 색상 중 생성 시 랜덤하게 적용하는 방향으로 마무리되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;랜덤으로 색상 추가하는 로직&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734352187970&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { bgTag } from '@/type/chip';

/**
 * 랜덤한 tag 클래스를 반환하는 함수
 * - bgTag 배열에서 랜덤하게 선택
 *
 * @param {Record&amp;lt;string, string&amp;gt;} styles - 스타일 객체
 * @returns {string} 랜덤 배경색 클래스 이름
 */
const getTagColor = (styles: Record&amp;lt;string, string&amp;gt;): string =&amp;gt; {
  if (!bgTag || bgTag.length === 0) return '';
  const idx = Math.floor(Math.random() * bgTag.length);
  return styles[bgTag[idx]];
};

export default getTagColor;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;결과물&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;250&quot; data-origin-height=&quot;109&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brQ3YB/btsLlp7lC6x/nVLYEVBPlsjAWHVBkGXp80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brQ3YB/btsLlp7lC6x/nVLYEVBPlsjAWHVBkGXp80/img.png&quot; data-alt=&quot;랜덤 색상 로직 결과물&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brQ3YB/btsLlp7lC6x/nVLYEVBPlsjAWHVBkGXp80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrQ3YB%2FbtsLlp7lC6x%2FnVLYEVBPlsjAWHVBkGXp80%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;250&quot; height=&quot;109&quot; data-origin-width=&quot;250&quot; data-origin-height=&quot;109&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;랜덤 색상 로직 결과물&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Chip 컴포넌트를 제작하고 멘토님께 한 가지 코드 리뷰를 받았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;타입스크립트에서 JSDoc으로 주석을 작성하는 것이 불필요한 중복 작업일 수 있다는 것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그 외에도 다른 문제점들이 있을 것 같아서 추가로 검색해 보니 다음과 같은 문제점들이 있었습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;중복성 :&lt;/b&gt; 타입스크립트는 이미 자체적으로 타입을 정의할 수 있는 기능을 제공하는데, JSDoc을 사용하면 타입을 다시 주석 형식으로 작성해야 하므로 중복된 작업이 발생합니다. TypeScript에서 타입을 명시적으로 정의하는 것이 더 효율적일 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;오류 가능성 :&lt;/b&gt; JSDoc에서 잘못된 타입 주석을 작성하면, 타입 검사가 제대로 이루어지지 않아서 런타임 오류가 발생할 수 있습니다. TypeScript의 정적 타입 검사 시스템을 사용하는 것보다 오류를 추적하기 어려운 경우가 많습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;디버깅 어려움 :&lt;/b&gt; JSDoc에서 작성된 타입 정보는 TypeScript 컴파일러에 의해 실제 타입으로 변환되지 않기 때문에, 디버깅 시 타입 관련 문제를 추적하는 데 어려움이 있을 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;자동 완성 및 인텔리센스 제한 :&lt;/b&gt; TypeScript는 타입 정보를 활용하여 자동 완성 기능이나 코드 인텔리센스를 제공하지만, JSDoc으로 제공된 타입 정보는 TypeScript 타입 시스템만큼 완벽하게 인식되지 않아 자동 완성이 제한될 수 있습니다. &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;팀원들에게 제 코드를 쉽게 알아볼 수 있도록 작성한 주석이 오히려 타입스크립트의 고유 시스템을 완전히 활용하는데 방해가 된다니..&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;지금부터는 JSDoc을 사용하지 않고 코드 자체가 직관적이고 이해하기 쉽도록 작성해야겠습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;함수와 변수 이름을 명확하게 작성하기&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;의미가 명확한 이름을 사용하여 코드의 목적과 동작을 직관적으로 전달.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;약어 사용을 지양하고, 가능하면 완전한 단어를 사용.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;불필요한 복잡성 피하기&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로직을 간결하게 작성해 이해하기 쉬운 코드를 작성.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;중복 코드 제거 및 코드 간결화.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;코드의 목적 명확하게 전달&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;함수나 변수의 역할을 분명히 하여 코드를 읽는 사람이 쉽게 이해할 수 있도록 작성.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;함수 하나가 하나의 명확한 작업만 하도록 작성(단일 책임).&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;주석 최소화&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;코드 자체로 의도가 명확하게 전달되도록 작성해 주석을 최소화.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;주석이 필요한 경우 중요한 부분에만 간결하게 작성.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;가독성 유지&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;코드 블록 간 적절한 개행을 통해 일관된 스타일로 가독성을 높임.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;코드 길이를 적절히 유지하여 한눈에 파악할 수 있도록 작성.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734432048292&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Taskify] 버튼(공통 컴포넌트) 추가&quot; data-og-description=&quot;프로젝트에서 공통으로 사용하는 버튼 컴포넌트 개발을 맡게 되었습니다.아래 보이는 버튼들을 모두 하나의 버튼 컴포넌트로 관리해야 한다니... 일단 도전!// Button.tsximport styles from &amp;quot;./Button.module&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/143&quot; data-og-url=&quot;https://dev-hpk.tistory.com/143&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/lDEcu/hyXOcngmr4/s4N0Rmw4axKWkuhbvjyqj0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/TsOme/hyXKoCRZ7I/A3PUX8WeYI5hMXqDUYyzZk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dsRMVa/hyXOpmAXbs/k4x3oP8YMnkvpyLpNmzMgK/img.png?width=697&amp;amp;height=1397&amp;amp;face=0_0_697_1397&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/143&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/143&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/lDEcu/hyXOcngmr4/s4N0Rmw4axKWkuhbvjyqj0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/TsOme/hyXKoCRZ7I/A3PUX8WeYI5hMXqDUYyzZk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dsRMVa/hyXOpmAXbs/k4x3oP8YMnkvpyLpNmzMgK/img.png?width=697&amp;amp;height=1397&amp;amp;face=0_0_697_1397');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[Taskify] 버튼(공통 컴포넌트) 추가&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트에서 공통으로 사용하는 버튼 컴포넌트 개발을 맡게 되었습니다.아래 보이는 버튼들을 모두 하나의 버튼 컴포넌트로 관리해야 한다니... 일단 도전!// Button.tsximport styles from &quot;./Button.module&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734432059439&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[프로젝트] Taskify (태스키파이) 소개&quot; data-og-description=&quot;드디어 중급 프로젝트 시작이다!!&amp;nbsp;지난 초급 프로젝트는 팀원들 모두 첫 프로젝트를 진행하는 상황이라 소통과 일정 관리, 그리고 업무 분담 면에서 많은 어려움이 있었습니다. 작업이 겹치거&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/141&quot; data-og-url=&quot;https://dev-hpk.tistory.com/141&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/7SR4W/hyXKjBzRGK/JAjuojjxWadca6127cEi30/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/oNopn/hyXOlR1YAw/0F72AlkHcMZX5nUZ3f2A01/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bAkF9V/hyXOjfBeE5/rqZnSRB45j3nOX3NOy1p31/img.png?width=1658&amp;amp;height=845&amp;amp;face=0_0_1658_845&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/141&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/141&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/7SR4W/hyXKjBzRGK/JAjuojjxWadca6127cEi30/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/oNopn/hyXOlR1YAw/0F72AlkHcMZX5nUZ3f2A01/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bAkF9V/hyXOjfBeE5/rqZnSRB45j3nOX3NOy1p31/img.png?width=1658&amp;amp;height=845&amp;amp;face=0_0_1658_845');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[프로젝트] Taskify (태스키파이) 소개&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;드디어 중급 프로젝트 시작이다!!&amp;nbsp;지난 초급 프로젝트는 팀원들 모두 첫 프로젝트를 진행하는 상황이라 소통과 일정 관리, 그리고 업무 분담 면에서 많은 어려움이 있었습니다. 작업이 겹치거&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>react</category>
      <category>TS</category>
      <category>typescript</category>
      <category>개발</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/144</guid>
      <comments>https://dev-hpk.tistory.com/144#entry144comment</comments>
      <pubDate>Mon, 16 Dec 2024 21:48:54 +0900</pubDate>
    </item>
    <item>
      <title>[Taskify] 버튼(공통 컴포넌트) 추가</title>
      <link>https://dev-hpk.tistory.com/143</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;프로젝트에서 공통으로 사용하는 버튼 컴포넌트 개발을 맡게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아래 보이는 버튼들을 모두 하나의 버튼 컴포넌트로 관리해야 한다니... 일단 도전!&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1127&quot; data-origin-height=&quot;624&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sfgAm/btsLhjUzQhZ/xczWiKmkJSgkJf480Lifl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sfgAm/btsLhjUzQhZ/xczWiKmkJSgkJf480Lifl1/img.png&quot; data-alt=&quot;버튼 UI&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sfgAm/btsLhjUzQhZ/xczWiKmkJSgkJf480Lifl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsfgAm%2FbtsLhjUzQhZ%2FxczWiKmkJSgkJf480Lifl1%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;1127&quot; height=&quot;624&quot; data-origin-width=&quot;1127&quot; data-origin-height=&quot;624&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;버튼 UI&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1734066107854&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Button.tsx
import styles from &quot;./Button.module.css&quot;;
import { PropsWithChildren, ReactNode } from &quot;react&quot;;
import clsx from &quot;clsx&quot;;

interface ButtonProps {
  children: ReactNode;
  classes?: string | string[];
  disabled?: boolean;
  handler: () =&amp;gt; void;
}

function Button({
  children,
  classes,
  disabled,
  handler,
}: PropsWithChildren&amp;lt;ButtonProps&amp;gt;) {
  const btnClass = classes
    ? Array.isArray(classes)
      ? classes.map((className) =&amp;gt; styles[className] || className)
      : [styles[classes] || classes]
    : [];

  return (
    &amp;lt;button
      className={clsx(styles.button, ...btnClass)}
      onClick={handler}
      disabled={disabled}
    &amp;gt;
      {children}
    &amp;lt;/button&amp;gt;
  );
}

export default Button;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Button을 사용하는 페이지에서 props로 스타일, disabled, handler를 전달하도록 만들어 봤습니다.&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;classes&lt;/b&gt;: 클래스 이름 or 클래스 이름 배열로 styles 객체와 매핑해 Module Css에 선언한 스타일을 적용하기 위한 prop&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;disalbed&lt;/b&gt;: 버튼의 disabled 상태를 버튼을 사용하는 페이지에서 관리할 수 있게 하기 위한 prop&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;handler&lt;/b&gt;: 버튼을 클릭했을 때 동작을 처리할 클릭 이벤트 핸들러&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;작업을 마치고 Button 컴포넌트를 사용하다가 문제를 발견했습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;버튼의 스타일을 커스텀하기 위해서 클래스를 직접 추가해줘야 한다는 것입니다.. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;예시 코드)&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734067026908&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;Button classes={['colored', 'border', 'font-3xl-bold', 'round', ....]} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;멘토님께 질문도 해보고 디자인 라이브러리들을 참고해 본 결과 Button을 감싸는 상위 컴포넌트를 추가하기로 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;추가로 class를 직접 넣는 방법 대신 정해진 디자인을 사용하는 방법을 선택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;수정한 코드&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734067329010&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { ButtonHTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import generateClassNames from '@/utils/generateClassNames';
import styles from './Button.module.css';

const types = {
  normal: {
    classes: ['normal', 'border'],
  },
  normal_colored: {
    classes: ['normal', 'colored'],
  },
  delete: {
    classes: ['delete', 'border'],
  },
  cancel: {
    classes: ['cancle', 'border'],
  },
  edit: {
    classes: ['edit', 'border'],
  },
  modal: {
    classes: ['modal', 'border'],
  },
  modal_colored: {
    classes: ['modal', 'colored'],
  },
  modal_single: {
    classes: ['modal', 'single', 'colored'],
  },
  auth: {
    classes: ['auth', 'colored'],
  },
  column: {
    classes: ['column', 'border'],
  },
  todo: {
    classes: ['todo', 'border'],
  },
  dashboard_add: {
    classes: ['dashboard', 'add', 'border'],
  },
  dashboard_delete: {
    classes: ['dashboard', 'delete', 'border'],
  },
  dashboard_card: {
    classes: ['dashboard', 'card', 'border'],
  },
};

interface ButtonProps extends ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt; {
  children: ReactNode;
  classes: string[];
}

function Button({
  children,
  classes,
  ...props
}: PropsWithChildren&amp;lt;ButtonProps&amp;gt;) {
  const classNames = generateClassNames(classes, styles);
  return (
    &amp;lt;button
      type=&quot;button&quot;
      className={clsx(styles.button, classNames)}
      {...props}
    &amp;gt;
      {children}
    &amp;lt;/button&amp;gt;
  );
}

type BadgeColor = 'green' | 'purple' | 'orange' | 'blue' | 'pink';

interface CDSButtonProps extends ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt; {
  btnType: keyof typeof types;
  badge?: BadgeColor;
  owner?: boolean;
}

function CDSButton({
  children,
  btnType,
  badge,
  owner,
  ...props
}: CDSButtonProps) {
  return (
    &amp;lt;Button classes={types[btnType].classes} {...props}&amp;gt;
      &amp;lt;span className={styles.button_content}&amp;gt;
        {btnType === 'dashboard_card' &amp;amp;&amp;amp; badge &amp;amp;&amp;amp; (
          &amp;lt;span className={clsx(styles.badge, styles[badge])} /&amp;gt;
        )}
        {children}
        {['column', 'todo', 'dashboard_add'].includes(btnType) &amp;amp;&amp;amp; (
          &amp;lt;img className={styles.icon_plus} src=&quot;ic/ic_chip.svg&quot; alt=&quot;icon&quot; /&amp;gt;
        )}
        {btnType === 'dashboard_card' &amp;amp;&amp;amp; owner &amp;amp;&amp;amp; (
          &amp;lt;img
            className={styles.icon_crown}
            src=&quot;ic/ic_crown.svg&quot;
            alt=&quot;ic_crown&quot;
          /&amp;gt;
        )}
      &amp;lt;/span&amp;gt;
    &amp;lt;/Button&amp;gt;
  );
}

CDSButton.defaultProps = {
  badge: null,
  owner: false,
};

export default CDSButton;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;코드 설명을 먼저 하려고 했는데 너무 길어서... 파일별로 분리한 후 설명하겠습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span&gt;코드 분리&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;/type/button.ts&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734067797942&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { ButtonHTMLAttributes, ReactNode } from 'react';

// BadgeColor: 배지 색상 타입.
// 허용되는 색상: 'green', 'purple', 'orange', 'blue', 'pink'
export type BadgeColor = 'green' | 'purple' | 'orange' | 'blue' | 'pink';

// 버튼의 타입과 관련된 클래스 리스트.
export const types = {
  normal: {
    classes: ['normal', 'border'],
  },
  normal_colored: {
    classes: ['normal', 'colored'],
  },
  delete: {
    classes: ['delete', 'border'],
  },
  cancel: {
    classes: ['cancle', 'border'],
  },
  edit: {
    classes: ['edit', 'border'],
  },
  modal: {
    classes: ['modal', 'border'],
  },
  modal_colored: {
    classes: ['modal', 'colored'],
  },
  modal_single: {
    classes: ['modal', 'single', 'colored'],
  },
  auth: {
    classes: ['auth', 'colored'],
  },
  column: {
    classes: ['column', 'border'],
  },
  todo: {
    classes: ['todo', 'border'],
  },
  dashboard_add: {
    classes: ['dashboard', 'add', 'border'],
  },
  dashboard_delete: {
    classes: ['dashboard', 'delete', 'border'],
  },
  dashboard_card: {
    classes: ['dashboard', 'card', 'border'],
  },
};

export interface ButtonProps extends ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt; {
  children: ReactNode;
  classes: string[];
}

export interface CDSButtonProps
  extends ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt; {
  btnType: keyof typeof types;
  badge?: BadgeColor;
  owner?: boolean;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;ButtonHTMLAttributes&lt;/b&gt;: Button Html 요소가 가질 수 있는 모든 속성 타입을 제공합니다. 기존에 disabled나, handler 같은 prop을 사용하지 않아도 되기 때문에 상속받아서 사용했습니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;React.ComponentProps &amp;lt;'button'&amp;gt;&lt;/b&gt;&lt;/span&gt;을 사용하면 ButtonHTMLAttributes와 동일한 동작을 더 간결하게 할 수 있다고 해서 추후 더 공부해 보기로 하겠습니다.&amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;types&lt;/b&gt;: Button 컴포넌트를 사용할 때 클래스를 직접 넣어주지 않아도 type만 설정하면 사용할 수 있도록 타입을 선언했습니다. 버튼 타입이 추가되거나 스타일이 바뀌어도 유지보수 측면에서도 유리하겠죠?&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; &lt;b&gt;/utils/generateClassNames.ts&lt;/b&gt; &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734068681755&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * 각 클래스 이름에 해당하는 스타일을 매핑하는 유틸 함수
 *
 * @param {string | string[]} classes - 클래스 이름 문자열 또는 문자열 배열
 * @param {Record&amp;lt;string, string&amp;gt;} styles - 클래스 이름에 대한 스타일을 매핑한 객체
 * @returns {string[]} 스타일을 매핑한 클래스 이름 배열
 *
 * - `classes`가 문자열일 경우 해당 클래스에 해당하는 스타일을 반환하고,
 * - `classes`가 배열일 경우 배열 내 각 클래스에 대해 스타일을 적용한 결과를 반환합니다.
 * - module.css에 해당 클래스가 없으면 원래의 클래스 이름을 그대로 반환합니다.
 */
const generateClassNames = (
  classes: string | string[],
  styles: Record&amp;lt;string, string&amp;gt;,
): string[] =&amp;gt; {
  if (!classes) return [];
  return Array.isArray(classes)
    ? classes.map((className) =&amp;gt; styles[className] || className)
    : [styles[classes] || classes];
};

export default generateClassNames;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;CDSButton.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734068738441&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import clsx from 'clsx';
import { CDSButtonProps, types } from '@/type/button';
import styles from './Button.module.css';
import Button from './Button';

/**
 * CDSButton: 다양한 버튼 유형을 처리하는 공통 버튼 컴포넌트.
 *
 * @param {CDSButtonProps} props - 커스텀 버튼 속성
 * @returns {JSX.Element} - 버튼 컴포넌트
 */
function CDSButton({
  children,
  btnType,
  badge,
  owner,
  ...props
}: CDSButtonProps) {
  /**
   * renderPlusIcon: 특정 버튼 유형에서 + 아이콘 렌더링
   * - 대상: 'column', 'todo', 'dashboard_add' 타입
   * @returns {JSX.Element} - + 아이콘
   */
  const renderPlusIcon = () =&amp;gt;
    ['column', 'todo', 'dashboard_add'].includes(btnType) &amp;amp;&amp;amp; (
      &amp;lt;img className={styles.icon_plus} src=&quot;ic/ic_chip.svg&quot; alt=&quot;icon&quot; /&amp;gt;
    );

  /**
   * renderOwnerIcon: 대시보드 카드 유형에서 소유인 경우 왕관 아이콘 렌더링
   * - 조건: btnType === 'dashboard_card' &amp;amp;&amp;amp; owner === true
   * @returns {JSX.Element} - 왕관 아이콘
   */
  const renderOwnerIcon = () =&amp;gt;
    btnType === 'dashboard_card' &amp;amp;&amp;amp;
    owner &amp;amp;&amp;amp; (
      &amp;lt;img className={styles.icon_crown} src=&quot;ic/ic_crown.svg&quot; alt=&quot;ic_crown&quot; /&amp;gt;
    );

  /**
   * renderBadge: 대시보드 카드 유형에서 색상 배지 렌더링
   * - 조건: btnType === 'dashboard_card' &amp;amp;&amp;amp; badge !== null
   * @returns {JSX.Element} - 배지 span 태그
   */
  const renderBadge = () =&amp;gt;
    btnType === 'dashboard_card' &amp;amp;&amp;amp;
    badge &amp;amp;&amp;amp; &amp;lt;span className={clsx(styles.badge, styles[badge])} /&amp;gt;;

  return (
    &amp;lt;Button classes={types[btnType].classes} {...props}&amp;gt;
      &amp;lt;span className={styles.button_content}&amp;gt;
        {renderBadge()}
        {children}
        {renderPlusIcon()}
        {renderOwnerIcon()}
      &amp;lt;/span&amp;gt;
    &amp;lt;/Button&amp;gt;
  );
}

CDSButton.defaultProps = {
  badge: null,
  owner: false,
};

export default CDSButton;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;CDSButton 컴포넌트는 처음에 아래 코드처럼 조건부 렌더링으로 처리했었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;작업을 마치고 보니 가독성 측면에서 너무 떨어지고, 조건이 추가되면 유지보수하기 어려울 것 같아서 개별 함수로 분리했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;svg 아이콘은 아직 SVG 컴포넌트 방식과 SVGR 중 결정 전이라 임의로 img 태그 사용했습니다...!&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734068795703&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;...
return (
    &amp;lt;Button classes={types[btnType].classes} {...props}&amp;gt;
      &amp;lt;span className={styles.button_content}&amp;gt;
        {btnType === 'dashboard_card' &amp;amp;&amp;amp; badge &amp;amp;&amp;amp; (
          &amp;lt;span className={clsx(styles.badge, styles[badge])} /&amp;gt;
        )}
        {children}
        {['column', 'todo', 'dashboard_add'].includes(btnType) &amp;amp;&amp;amp; (
          &amp;lt;img className={styles.icon_plus} src=&quot;ic/ic_chip.svg&quot; alt=&quot;icon&quot; /&amp;gt;
        )}
        {btnType === 'dashboard_card' &amp;amp;&amp;amp; owner &amp;amp;&amp;amp; (
          &amp;lt;img
            className={styles.icon_crown}
            src=&quot;ic/ic_crown.svg&quot;
            alt=&quot;ic_crown&quot;
          /&amp;gt;
        )}
      &amp;lt;/span&amp;gt;
    &amp;lt;/Button&amp;gt;
  );
  ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Button.tsx&lt;/b&gt; &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734069162812&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import generateClassNames from '@/utils/generateClassNames';
import { ButtonProps } from '@/type/button';
import styles from './Button.module.css';

/**
 * Button: 공통 버튼 컴포넌트
 * @param {ReactNode} children - 버튼 내부의 콘텐츠
 * @returns {JSX.Element} - 버튼 컴포넌트
 */
function Button({
  children,
  classes,
  ...props
}: PropsWithChildren&amp;lt;ButtonProps&amp;gt;) {
  const classNames = generateClassNames(classes, styles);
  return (
    &amp;lt;button
      type=&quot;button&quot;
      className={clsx(styles.button, classNames)}
      {...props}
    &amp;gt;
      {children}
    &amp;lt;/button&amp;gt;
  );
}

export default Button;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;완성하고 보니, 컴포넌트를 만드는 시간보다 리팩토링에 쓴 시간이 더 많은 것 같네요... &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;리팩토링도 끝났겠다 이제 결과물을 공개하겠습니다..! 다른 페이지에서 바로 가져다 쓸 수 있게 테스트 페이지에 작업했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_localhost-3000-test_button.png&quot; data-origin-width=&quot;697&quot; data-origin-height=&quot;1397&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ciSJ0Z/btsLg4KtxrH/RHLGpUj5Rv3uQxxNVAXeVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ciSJ0Z/btsLg4KtxrH/RHLGpUj5Rv3uQxxNVAXeVK/img.png&quot; data-alt=&quot;공통 버튼 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ciSJ0Z/btsLg4KtxrH/RHLGpUj5Rv3uQxxNVAXeVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FciSJ0Z%2FbtsLg4KtxrH%2FRHLGpUj5Rv3uQxxNVAXeVK%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;697&quot; height=&quot;1397&quot; data-filename=&quot;edited_localhost-3000-test_button.png&quot; data-origin-width=&quot;697&quot; data-origin-height=&quot;1397&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;공통 버튼 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;border: 10px solid #009a87; border-radius: 0px; background-color: #ffffff; padding: 15px 30px; margin: 0;&quot;&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; top: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p style=&quot;text-align: left; font-weight: bold;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;느낀 점&lt;/span&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;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; 초기 설계의 중요성&lt;/b&gt; : 초기 잘못된 코드 구조 때문에 리팩토링을 반복하면서 초기 설계가 얼마나 중요한지 알게 되었습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; 코드 품질 향상에 대한 새로운 관점&lt;/b&gt; : 리팩토링 과정에서 코드 품질 향상에 집중하다 보니 가독성과 유지보수성을 고려하게 되었고, 이 과정에서 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: left;&quot;&gt;단순히 동작하는 코드와 유지보수 가능한 코드의 차이를 깨닫게 되었습니다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt; 협업의 중요성&lt;/b&gt; : 멘토님과 팀원들과의 코드 리뷰를 통해 개선 방향을 명확히 하고 더 나은 코드를 작성할 수 있었습니다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;코드 설명을 문서화하자&lt;/b&gt; : 팀원들이 제가 만든 컴포넌트를 쉽게 이해하게 하기 위해 jsdoc을 꼼꼼히 작성했습니다. 이를 통해 팀원들과의 협업 능력을 향상시키는 계기가 되었을 뿐 아니라 제 코드의 구조와 의도를 다시 한 번 검토하며 스스로 학습할 수 있는 좋은 기회가 되었습니다. &lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; bottom: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734432092339&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[프로젝트] Taskify (태스키파이) 소개&quot; data-og-description=&quot;드디어 중급 프로젝트 시작이다!!&amp;nbsp;지난 초급 프로젝트는 팀원들 모두 첫 프로젝트를 진행하는 상황이라 소통과 일정 관리, 그리고 업무 분담 면에서 많은 어려움이 있었습니다. 작업이 겹치거&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/141&quot; data-og-url=&quot;https://dev-hpk.tistory.com/141&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/7SR4W/hyXKjBzRGK/JAjuojjxWadca6127cEi30/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/oNopn/hyXOlR1YAw/0F72AlkHcMZX5nUZ3f2A01/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bAkF9V/hyXOjfBeE5/rqZnSRB45j3nOX3NOy1p31/img.png?width=1658&amp;amp;height=845&amp;amp;face=0_0_1658_845&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/141&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/141&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/7SR4W/hyXKjBzRGK/JAjuojjxWadca6127cEi30/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/oNopn/hyXOlR1YAw/0F72AlkHcMZX5nUZ3f2A01/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bAkF9V/hyXOjfBeE5/rqZnSRB45j3nOX3NOy1p31/img.png?width=1658&amp;amp;height=845&amp;amp;face=0_0_1658_845');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[프로젝트] Taskify (태스키파이) 소개&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;드디어 중급 프로젝트 시작이다!!&amp;nbsp;지난 초급 프로젝트는 팀원들 모두 첫 프로젝트를 진행하는 상황이라 소통과 일정 관리, 그리고 업무 분담 면에서 많은 어려움이 있었습니다. 작업이 겹치거&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Next</category>
      <category>nextjs</category>
      <category>react</category>
      <category>TS</category>
      <category>typescript</category>
      <category>개발</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/143</guid>
      <comments>https://dev-hpk.tistory.com/143#entry143comment</comments>
      <pubDate>Fri, 13 Dec 2024 15:11:39 +0900</pubDate>
    </item>
    <item>
      <title>[SVGR] Next.js에서 SVGR 사용하기</title>
      <link>https://dev-hpk.tistory.com/142</link>
      <description>&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&amp;nbsp;Next.js에서 SVGR을 사용하면 SVG 파일을 React 컴포넌트처럼 사용할 수 있습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734063490448&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;SVGR - Transforms SVG into React Components. - SVGR&quot; data-og-description=&quot;Transforms SVG into React Components.&quot; data-og-host=&quot;react-svgr.com&quot; data-og-source-url=&quot;https://react-svgr.com/docs/next/&quot; data-og-url=&quot;https://react-svgr.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bmYgwW/hyXOnhrIZs/VZXTnh2nV320cti9OMnMNK/img.jpg?width=1280&amp;amp;height=640&amp;amp;face=0_0_1280_640&quot;&gt;&lt;a href=&quot;https://react-svgr.com/docs/next/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://react-svgr.com/docs/next/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bmYgwW/hyXOnhrIZs/VZXTnh2nV320cti9OMnMNK/img.jpg?width=1280&amp;amp;height=640&amp;amp;face=0_0_1280_640');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;SVGR - Transforms SVG into React Components. - SVGR&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Transforms SVG into React Components.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;react-svgr.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;목차&lt;/b&gt;&lt;/span&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a1&quot;&gt; 1. SVGR이란?&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a2&quot;&gt; 2. Next.js에 SVGR 설정하기&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a3&quot;&gt; 3. SVGR 사용 예시&lt;/a&gt;&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;a href=&quot;#a5&quot;&gt;추천글&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;위의 목차를 클릭하면 해당 글로 자동 이동 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1. SVGR이란?&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;SVG는 벡터 기반의 이미지 파일로, 용량이 작고 크기를 바꿔도 이미지가 깨지지 않기 때문에 웹에서 자주 사용되는 이미지 포맷입니다. Next.js 기반 프로젝트에서 &lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;SVG를 &lt;/span&gt;사용하는 방법은 크게 2가지입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;svg 파일의 경로를 img 파일의 src 속성에 넣어 img 태그와 함께 사용&lt;/span&gt;&lt;br /&gt;
&lt;pre id=&quot;code_1734063755698&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;&amp;lt;img src='~.svg' /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; background-color: #ffffff; color: #353638; letter-spacing: 0px;&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;svg 파일을 JSX 형태인 리액트 컴포넌트로 만들어 사용&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;
&lt;pre id=&quot;code_1734063819750&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const PlusIcon = ({ props }) =&amp;gt; (
  &amp;lt;svg
    xmlns=&quot;http://www.w3.org/2000/svg&quot;
    width=&quot;48&quot;
    height=&quot;48&quot;
    viewBox=&quot;0 -960 960 960&quot;
    {...props}
  &amp;gt;
    ...
  &amp;lt;/svg&amp;gt;
);

export default PlusIcon;&lt;/code&gt;&lt;/pre&gt;
&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; background-color: #ffffff; color: #353638; letter-spacing: 0px;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;두 방법 모두 장&amp;middot;단점이 있지만, svg를 리액트 컴포넌트 형태로 불러와 사용하면 props를 통해 크기, 색상 등을 커스텀할 수 있어 svg를 보다 다룰 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아이콘이 많아질수록 &lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;일일이 컴포넌트를 만들어주는&amp;nbsp;&lt;/span&gt;작업을 하는 것은 굉장히 힘들고 비효율적인 일입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;svg 내부에 미리 지정되어 있는 속성들이 있다면 props를 통해 svg를 커스텀하기 위해 &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;여러 가지 후처리가 필요합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;SVGR은 이런 작업을 자동으로 처리해 주는 라이브러리입니다. &lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;span&gt;SVGR은 여러 가지 환경에서 단독으로도 사용할 수 있지만,&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;프로젝트에서 사용할 때는 주로 @svgr/webpack이라는 웹팩 로더를 사용하여 작업합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2. Next.js에 SVGR 설정하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;설치&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734064214075&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm install --save-dev @svgr/webpack
# or use yarn
yarn add --dev @svgr/webpack&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;next.config.js에 webpack 설정 추가&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734064287790&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import type { NextConfig } from &quot;next&quot;;

const nextConfig: NextConfig = {
  /* config options here */
  reactStrictMode: true,
  webpack(config) {
    config.module.rules.push({
      test: /\.svg$/, // SVG 파일을 찾습니다.
      use: [
        {
          loader: &quot;@svgr/webpack&quot;, // SVGR 로더 사용
          options: {
            icon: true, // 기본적으로 SVG를 아이콘 크기로 조정
          },
        },
      ],
    });

    return config;
  },
};

export default nextConfig;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;타입스크립트를 사용하신다면 아래와 같은 에러가 발생할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_index.tsx - next_ex - Visual Studio Code 2024-12-13 오후 1_33_01.png&quot; data-origin-width=&quot;672&quot; data-origin-height=&quot;100&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ciFoJ9/btsLge7zlCS/h557h0TQc7EXTgs0OVCPC1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ciFoJ9/btsLge7zlCS/h557h0TQc7EXTgs0OVCPC1/img.png&quot; data-alt=&quot;타입 에러&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ciFoJ9/btsLge7zlCS/h557h0TQc7EXTgs0OVCPC1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FciFoJ9%2FbtsLge7zlCS%2Fh557h0TQc7EXTgs0OVCPC1%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;672&quot; height=&quot;100&quot; data-filename=&quot;edited_index.tsx - next_ex - Visual Studio Code 2024-12-13 오후 1_33_01.png&quot; data-origin-width=&quot;672&quot; data-origin-height=&quot;100&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;타입 에러&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이는 TypeScript 상에서 .svg로 불러오는 컴포넌트에 대한 타입이 지정되어있지 않기 때문에 발생하는 문제입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;global.d.ts에 .svg 파일에 대한 타입을 선언해 주세요.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734064516405&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//global.d.ts
declare module &quot;*.svg&quot; {
  const content: React.FunctionComponent&amp;lt;React.SVGAttributes&amp;lt;SVGElement&amp;gt;&amp;gt;;
  export default content;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1734064529511&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// tsconfig.json
{
  ....
  &quot;include&quot;: [
    &quot;global.d.ts&quot;,
    &quot;next-env.d.ts&quot;,
    &quot;**/*.ts&quot;,
    &quot;**/*.tsx&quot;,
    &quot;.next/types/**/*.ts&quot;,
  ],
  &quot;exclude&quot;: [&quot;node_modules&quot;]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;3. SVGR 사용 예시&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt; React의 컴포넌트와 동일하게 import 하셔서 사용하시면 됩니다. &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734064680629&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import Chip from &quot;@/public/ic_chip.svg&quot;;

export default function Home() {
	return &amp;lt;Chip width={30} height={30}/&amp;gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;a5&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 추천글&lt;/b&gt;&lt;/h3&gt;
&lt;figure id=&quot;og_1734064785315&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[프로젝트] Taskify (태스키파이) 소개&quot; data-og-description=&quot;드디어 중급 프로젝트 시작이다!!&amp;nbsp;지난 초급 프로젝트는 팀원들 모두 첫 프로젝트를 진행하는 상황이라 소통과 일정 관리, 그리고 업무 분담 면에서 많은 어려움이 있었습니다. 작업이 겹치거&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/141&quot; data-og-url=&quot;https://dev-hpk.tistory.com/141&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/NxHzj/hyXKsxXJTB/BEBiFIe78K7czlVbjaOOzk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bOADrq/hyXKreLoke/XlRN4zf004K5oMkfMuWFYK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/c8RVFy/hyXKjugC3w/vw9Ch00HXxMubPFC1Nrs3k/img.png?width=1658&amp;amp;height=845&amp;amp;face=0_0_1658_845&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/141&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/141&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/NxHzj/hyXKsxXJTB/BEBiFIe78K7czlVbjaOOzk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bOADrq/hyXKreLoke/XlRN4zf004K5oMkfMuWFYK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/c8RVFy/hyXKjugC3w/vw9Ch00HXxMubPFC1Nrs3k/img.png?width=1658&amp;amp;height=845&amp;amp;face=0_0_1658_845');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[프로젝트] Taskify (태스키파이) 소개&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;드디어 중급 프로젝트 시작이다!!&amp;nbsp;지난 초급 프로젝트는 팀원들 모두 첫 프로젝트를 진행하는 상황이라 소통과 일정 관리, 그리고 업무 분담 면에서 많은 어려움이 있었습니다. 작업이 겹치거&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1734064802770&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;프레임워크(Framework)와 라이브러리(Library)의 차이&quot; data-og-description=&quot;개발에서는&amp;nbsp;프레임워크와 라이브러리라는 용어가 자주 등장합니다. 이 둘은 개발 효율성을 높이고 코드 품질을 향상시키기 위해 사용되지만, 그 개념과 활용 방식에서 명확한 차이가 있습니다&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/127&quot; data-og-url=&quot;https://dev-hpk.tistory.com/127&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dH8fjC/hyXKqz9ch1/r2vuVyKt4XhE3WEwft0uT1/img.jpg?width=284&amp;amp;height=177&amp;amp;face=0_0_284_177,https://scrap.kakaocdn.net/dn/c6Fa8W/hyXOekvDle/EakgkL8NrzPxBxKghCdJ90/img.jpg?width=284&amp;amp;height=177&amp;amp;face=0_0_284_177,https://scrap.kakaocdn.net/dn/bFemeg/hyXKks7Vlw/xc56TK0v1ZG8lci9IdpauK/img.jpg?width=284&amp;amp;height=177&amp;amp;face=0_0_284_177&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/127&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/127&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dH8fjC/hyXKqz9ch1/r2vuVyKt4XhE3WEwft0uT1/img.jpg?width=284&amp;amp;height=177&amp;amp;face=0_0_284_177,https://scrap.kakaocdn.net/dn/c6Fa8W/hyXOekvDle/EakgkL8NrzPxBxKghCdJ90/img.jpg?width=284&amp;amp;height=177&amp;amp;face=0_0_284_177,https://scrap.kakaocdn.net/dn/bFemeg/hyXKks7Vlw/xc56TK0v1ZG8lci9IdpauK/img.jpg?width=284&amp;amp;height=177&amp;amp;face=0_0_284_177');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;프레임워크(Framework)와 라이브러리(Library)의 차이&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;개발에서는&amp;nbsp;프레임워크와 라이브러리라는 용어가 자주 등장합니다. 이 둘은 개발 효율성을 높이고 코드 품질을 향상시키기 위해 사용되지만, 그 개념과 활용 방식에서 명확한 차이가 있습니다&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>ETC</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>react</category>
      <category>svgr</category>
      <category>개발</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/142</guid>
      <comments>https://dev-hpk.tistory.com/142#entry142comment</comments>
      <pubDate>Fri, 13 Dec 2024 13:42:03 +0900</pubDate>
    </item>
    <item>
      <title>[프로젝트] Taskify (태스키파이) 소개</title>
      <link>https://dev-hpk.tistory.com/141</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;드디어 중급 프로젝트 시작이다!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 초급 프로젝트는 팀원들 모두 첫 프로젝트를 진행하는 상황이라 소통과 일정 관리, 그리고 업무 분담 면에서 많은 어려움이 있었습니다. 작업이 겹치거나 누락되는 경우도 있었고, 서로의 의견을 조율하다가 오전을 모두 사용한 적도 있었습니다. 이런 경험은 당시에는 힘들었지만 팀 프로젝트에서 무엇이 중요한지를 배우는 값진 기회였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중급 프로젝트를 시작하며 설레는 마음도 크지만 긴장감도 함께 느껴집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 경험을 바탕으로 소통을 더욱 원활히 하고 일정 관리와 업무 분담을 철저히 계획해 프로젝트를 성공적으로 마무리하며 팀원들과 함께 좋은 결과를 만들어내고 싶습니다. 이번에는 더 나은 협업을 통해 성장과 성취를 모두 이루는 뜻깊은 시간을 기대해 봅니다✨✨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;기술 스택&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;735&quot; data-origin-height=&quot;476&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dFxxCN/btsLgdAhYjT/5Eve6K3gquQHrKACTyMYDK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dFxxCN/btsLgdAhYjT/5Eve6K3gquQHrKACTyMYDK/img.png&quot; data-alt=&quot;기술 스택&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dFxxCN/btsLgdAhYjT/5Eve6K3gquQHrKACTyMYDK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdFxxCN%2FbtsLgdAhYjT%2F5Eve6K3gquQHrKACTyMYDK%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;735&quot; height=&quot;476&quot; data-origin-width=&quot;735&quot; data-origin-height=&quot;476&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;기술 스택&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;b&gt;Next.js&lt;/b&gt;&lt;/b&gt;: &lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;리액트는 SPA(Single Page Application)이고, CSR(Client Side Rendering) 기반으로 앱의 첫 로딩시간이 길고, SEO 가 좋지 않다는 단점이 있다. 이를 해결하기 위해 SSR(서버 사이드 렌더링), SSG(정적 사이트 생성), 파일 기반 라우팅, 성능 최적화 등 다양한 기능을 기본적으로 제공하는 Next.js를 선택했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TypeSciprt&lt;/b&gt;: ts는&amp;nbsp; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;컴파일 과정에서 오류를 잡아내기 때문에 오류를 잡아내기 쉽고, 협업 시 팀원들이 작성한 코드에 타입이 명시되어 흐름을 쉽게 파악할 수 있기 때문에 선택했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;b&gt;Module Css&lt;/b&gt;: 컴포넌트 단위로 파일을 분리하고 스타일을 적용할 수 있어 클래스 이름 충돌을 방지해 주기 때문에 선택했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;Redux&lt;/b&gt;:&amp;nbsp; Redux는 많은 대규모 프로젝트에서 사용되고 있는 상태 관리 도구이기 때문에 학습을 통해 최신 트렌드와 함께 실제 프로젝트에서의 활용 방법을 익히기 위해 선택했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Vercel&lt;/b&gt;: &lt;span style=&quot;color: #000000;&quot;&gt;Next.js 프로젝트와의 뛰어난 호환성을 제공하며 간편하게 배포와 호스팅이 가능하기 때문에 선택했습니다. &lt;/span&gt;(빠른 배포 속도 &amp;amp; 무료)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Notion&lt;/b&gt;: 팀원들과 &lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;함께 Notion 페이지를 관리하며 프로젝트에 맡겨진 업무, 진행 현황 등을 실시간으로 공유하고 피드백을 받을 수 있어 선택했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;네이밍 컨벤션&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;디렉터리 &amp;amp; 파일명 &lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;케밥 케이스(kebab-case) &lt;b&gt;ex) item-category&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 컴포넌트 &lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;파스칼 케이스(PascalCase) &lt;b&gt;ex) ItemComponent&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 변수명 &lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;카멜 케이스(camelCase) &lt;b&gt;ex) itemData&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 커스텀훅 &lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;use + 파스칼 케이스(PascalCase) &lt;b&gt;ex) useItemData.tsx&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 이미지 &amp;amp; 아이콘 &lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;img, ic + 스네이크 케이스(snake_case) &lt;b&gt;ex) img_item.svg ic_item.svg&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt; className &lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;케밥 케이스(kebab-case) &lt;b&gt;ex) button button-primary&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt; id &lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;파스칼 케이스(PascalCase) &lt;b&gt;ex) Button ButtonPrimary&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;효율적으로 PR 관리하기&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; 1. GitHub에서 이슈 생성하기&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Labels, Assignees, 및 Projects 추가&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Labels&lt;/b&gt;: 이슈의 성격 예) bug, enhancement, documentation.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Assignees&lt;/b&gt;: 해당 이슈를 담당할 사람&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Projects&lt;/b&gt;: &amp;lsquo;프로젝트 현황&amp;rsquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1658&quot; data-origin-height=&quot;845&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kOQRv/btsLhuOljEU/okweVsXi9oKrGVIrbbvnw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kOQRv/btsLhuOljEU/okweVsXi9oKrGVIrbbvnw1/img.png&quot; data-alt=&quot;이슈 생성&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kOQRv/btsLhuOljEU/okweVsXi9oKrGVIrbbvnw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkOQRv%2FbtsLhuOljEU%2FokweVsXi9oKrGVIrbbvnw1%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;737&quot; height=&quot;845&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1658&quot; data-origin-height=&quot;845&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이슈 생성&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Submit new issue&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&quot;Submit new issue&quot;&lt;/b&gt; 버튼을 클릭하여 이슈를 생성합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. 이슈 생성 시 &lt;span style=&quot;color: #eb5757; background-color: #dddddd;&quot; data-token-index=&quot;0&quot;&gt;#00_제목&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot; data-token-index=&quot;0&quot;&gt;으로&lt;/span&gt; 브랜치 자동 생성&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이슈 생성 시 자동생성 된 브랜치를 fetch를 통해 가져옴&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;터미널에서 작업을 위해 새로운 브랜치를 생성합니다. 브랜치명에 이슈 번호를 포함하여 추적이 쉽게 만듭니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Git Flow&lt;/b&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;작업의 성격에 따라 브랜치를 나눌 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br /&gt;
&lt;table style=&quot;border-collapse: collapse; width: 89.6985%; height: 171px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; ⭐️main(master)&amp;nbsp;&lt;br /&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;서비스를 직접 배포하는 역할을 하는 브랜치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;⭐️feature(기능)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;각 기능 별 개발&amp;nbsp;브랜치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;develop(개발)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;feature에서 개발된 내용을 가지고 있는&amp;nbsp;브랜치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;release(배포)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;배포를 하기 전 내용을 QA(품질 검사) 하기 위한 브랜치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;hotfix(빨리 고치기)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;main 브랜치로 배포를 하고 나서 버그가 생겼을 때 빨리 고치기 위한 브랜치&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;feature 브랜치는 주로 'feat/{기능_이름}'으로 명명 &lt;br /&gt;ex) feature/login&lt;/li&gt;
&lt;li&gt;이슈 생성 시 자동으로 &amp;lsquo;feature/#번호_제목&amp;rsquo;으로 자동 생성됨 &lt;br /&gt;git fetch로 origin(원격 저장소)의 내용을 local로 가져오기&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;자동생성된 브랜치로 이동 후 작업&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;git checkout &amp;lsquo;자동 생성된 branch&amp;rsquo; : 자동생성된 branch로 이동하기
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ex) git checkout feature/#50_PR-템플릿-만들기&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;코드 작업 및 커밋&lt;br /&gt;
&lt;pre id=&quot;code_1734007625438&quot; class=&quot;bash&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;git add .
gitmoji -c&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원격 저장소로 푸시&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로컬 브랜치를 원격 저장소에 푸시합니다.&lt;br /&gt;
&lt;pre id=&quot;code_1734007706498&quot; class=&quot;bash&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;git push origin &amp;lsquo;자동 생성된 branch&amp;rsquo;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; 3. &lt;span data-token-index=&quot;1&quot;&gt;Pull Request (PR) 작성하기&lt;/span&gt; &lt;/b&gt;&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;GitHub에서 Pull Request 생성&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GitHub 저장소에서 **Pull requests** 탭을 클릭하고, &lt;b&gt;New pull request&lt;/b&gt; 버튼을 누릅니다.&lt;/li&gt;
&lt;li&gt;비교할 브랜치를 선택하여 PR을 만듭니다.&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;base : develop ,&lt;/li&gt;
&lt;li&gt;compare : 이때까지 작업한 branch&lt;br /&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1072&quot; data-origin-height=&quot;226&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cfrDMA/btsLgohslT8/Wvdj8AknU0LrJu6apTKmR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cfrDMA/btsLgohslT8/Wvdj8AknU0LrJu6apTKmR1/img.png&quot; data-alt=&quot;pr 생성&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cfrDMA/btsLgohslT8/Wvdj8AknU0LrJu6apTKmR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcfrDMA%2FbtsLgohslT8%2FWvdj8AknU0LrJu6apTKmR1%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;1072&quot; height=&quot;226&quot; data-origin-width=&quot;1072&quot; data-origin-height=&quot;226&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;pr 생성&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;b&gt; &lt;span data-token-index=&quot;0&quot;&gt;PR document 작성&lt;br /&gt;&lt;/span&gt;&lt;/b&gt;&lt;/b&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1604&quot; data-origin-height=&quot;784&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/m3bgv/btsLfHomgGe/CrZleYK1BLGHpo6QQvsuck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/m3bgv/btsLfHomgGe/CrZleYK1BLGHpo6QQvsuck/img.png&quot; data-alt=&quot;pr 내용 작성&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/m3bgv/btsLfHomgGe/CrZleYK1BLGHpo6QQvsuck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fm3bgv%2FbtsLfHomgGe%2FCrZleYK1BLGHpo6QQvsuck%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;1604&quot; height=&quot;784&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1604&quot; data-origin-height=&quot;784&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;pr 내용 작성&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;span data-token-index=&quot;0&quot;&gt;PR 설명&lt;/span&gt;에는 다음 내용을 포함합니다:&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;/span&gt;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;이슈 번호&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PR 본문에 이슈 연결 키워드 + #이슈번호 작성
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;본문에 작성하면, 해당 이슈에서 PR이 연결된 것을 확인할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이슈 연결 키워드&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;close / closes / closed&lt;/b&gt;: &lt;b&gt;완료된 상태로 단순히 닫는다&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;fix / fixes / fixed&lt;/b&gt;: 이슈의 &lt;b&gt;문제나 버그를 수정하여 완료&lt;/b&gt;하는 경우&lt;/li&gt;
&lt;li&gt;&lt;b&gt;resolve / resolves / resolved&lt;/b&gt;: 이슈의 &lt;b&gt;해결을 완료&lt;/b&gt;하는 의미로, 문제나 과제를 &lt;b&gt;종결짓는&lt;/b&gt; 뉘앙스&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;변경 사항 요약&lt;/b&gt;: 어떤 수정이 있었는지 요약.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트 사항&lt;/b&gt;: 변경 사항을 테스트한 내용 (필요시).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt; &lt;span data-token-index=&quot;0&quot;&gt;Create pull request 버튼 클릭 하여 PR 생성&lt;/span&gt; &lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;4. merge 및 다음 작업 과정&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;팀원들이 확인 및 답글을 달면 &lt;span style=&quot;background-color: #409d00; color: #ffffff;&quot;&gt;Merge pull request&lt;/span&gt;&amp;nbsp;버튼 활성화&lt;/li&gt;
&lt;li&gt;merge를 하게 되면 이슈가 closed 되고, project 탭의 작업&amp;nbsp; 상태도 done으로 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소개 끝났으니 힘내서 프로젝트 진행해 보겠습니다 &lt;/p&gt;</description>
      <category>프로젝트/Next+TypeScript</category>
      <category>Git</category>
      <category>GitHub</category>
      <category>Next</category>
      <category>Next.js</category>
      <category>react</category>
      <category>React.js</category>
      <category>Redux</category>
      <category>TS</category>
      <category>typescript</category>
      <category>프로젝트</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/141</guid>
      <comments>https://dev-hpk.tistory.com/141#entry141comment</comments>
      <pubDate>Thu, 12 Dec 2024 21:59:58 +0900</pubDate>
    </item>
    <item>
      <title>[JS] for ...of와 for ...in</title>
      <link>https://dev-hpk.tistory.com/140</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript에서는 배열, 객체, 기타 데이터 구조를 순회(iterate)하는 다양한 방법을 제공합니다. 그중 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;for... of&lt;/b&gt;&lt;/span&gt;와&amp;nbsp;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;for...in&lt;/b&gt;&lt;/span&gt;은 이름이 비슷해서 종종 혼동되지만, 작동 방식과 사용 목적이 전혀 다릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;목차&lt;/b&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;#a1&quot;&gt; 1. for...of&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;#a2&quot;&gt; 2. for ...in&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;#a3&quot;&gt; 3. 주요 차이점&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;#a4&quot;&gt;추천글&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 목차를 클릭하면 해당 글로 자동 이동 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;a1&quot; style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;1. for...of&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;for...of&lt;/b&gt;&lt;/span&gt;는 이터러블 객체(&lt;b&gt;배열&lt;/b&gt;, 문자열, Set, Map 등)의 &lt;b&gt;값&lt;/b&gt;을 순회합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주로 &lt;b&gt;배열이나 기타 이터러블 객체&lt;/b&gt;의 값에 접근할 때 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 예시&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1733902267352&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const numArray = [1, 2, 3];
for (const num of numArray) {
    console.log(num); // 1, 2, 3
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과가 배열의 값(value)으로 잘 출력되네요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 배열에 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;for ...in&lt;/b&gt;&lt;/span&gt;을 사용하면 어떤 결과가 나올까요?&lt;/p&gt;
&lt;pre id=&quot;code_1733902692838&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const numArray = [1, 2, 3];
for (const num in numArray) {
  console.log(num); // 0, 1, 2
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러가 발생하지 않고 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;0 1 2&lt;/b&gt;&lt;/span&gt;라는 값이 출력되었습니다. 자바스크립트는&lt;span style=&quot;background-color: #ffffff; color: #323232; text-align: justify;&quot;&gt;&amp;nbsp;배열도&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Object&lt;span style=&quot;background-color: #ffffff; color: #323232; text-align: justify;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;타입으로 인식하기 때문에 결과가 나온 것입니다. 다만&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #323232; text-align: justify;&quot;&gt; 해당 배열의 &lt;b&gt;index&lt;/b&gt;가 출력되는 걸 확인할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;a2&quot; style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;2. for ...in&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;for ...in&lt;/b&gt;&lt;/span&gt;은 객체의 열거 가능한 &lt;b&gt;속성(keys)&lt;/b&gt;을 순회합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주로 &lt;b&gt;객체&lt;/b&gt;의 속성을 확인하거나 순회할 때 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 예시&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1733902467526&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const obj = { a: 1, b: 2, c: 3 };
for (const key in obj) {
    console.log(key); // &quot;a&quot;, &quot;b&quot;, &quot;c&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;결과가 객체의 &lt;b&gt;속성(keys)&lt;/b&gt;으로 잘 출력되네요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그럼 객체에&lt;span&gt;&amp;nbsp;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;for... of을&lt;/b&gt;&lt;/span&gt;&lt;/span&gt; 사용하면 어떤 결과가 나올까요?&lt;/p&gt;
&lt;pre id=&quot;code_1733902966019&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const obj = { a: 1, b: 2, c: 3 };
for (const key of obj) {
    console.log(key);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;240&quot; data-origin-height=&quot;21&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oA5rJ/btsLd78mhyh/kX6kMaaaOKKB8FzSSf3YU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oA5rJ/btsLd78mhyh/kX6kMaaaOKKB8FzSSf3YU1/img.png&quot; data-alt=&quot;for...of 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oA5rJ/btsLd78mhyh/kX6kMaaaOKKB8FzSSf3YU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoA5rJ%2FbtsLd78mhyh%2FkX6kMaaaOKKB8FzSSf3YU1%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;251&quot; height=&quot;22&quot; data-origin-width=&quot;240&quot; data-origin-height=&quot;21&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;for...of 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;obj 객체가 반복 가능한 객체가 아니라고 TypeError를 출력되는 걸 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;a3&quot; style=&quot;padding: 0.4em 1em 0.4em 0.5em; margin: 0.5em 0em; color: #000; border-left: 8px solid #009a87; border-bottom: 2px #009a87 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;3. 주요 차이점&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 17.8682%;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.4264%;&quot;&gt;&lt;b&gt;for ...in&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 46.7053%;&quot;&gt;&lt;b&gt;for ...of&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 17.8682%;&quot;&gt;&lt;b&gt;순회 대상&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.4264%;&quot;&gt;객체의 열거 가능한 속성(key)&lt;/td&gt;
&lt;td style=&quot;width: 46.7053%;&quot;&gt;이터러블(iterable) 객체의 값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 17.8682%;&quot;&gt;&lt;b&gt;출력 내용&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.4264%;&quot;&gt;객체의 속성(key)&lt;/td&gt;
&lt;td style=&quot;width: 46.7053%;&quot;&gt;이터러블(iterable) 객체의 값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 17.8682%;&quot;&gt;&lt;b&gt;사용 대상&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.4264%;&quot;&gt;객체&lt;/td&gt;
&lt;td style=&quot;width: 46.7053%;&quot;&gt;배열, 문자열, Set, Map 등 이터러블(iterable) 객체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 17.8682%;&quot;&gt;&lt;b&gt;상속 속성 포함 여부&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.4264%;&quot;&gt;상속된 속성도 포함&lt;/td&gt;
&lt;td style=&quot;width: 46.7053%;&quot;&gt;포함하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p id=&quot;a4&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;차이 예시&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1733903477339&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const array = [10, 20, 30];
array.customProperty = &quot;Hello&quot;;

// for ...in
for (const key in array) {
    console.log(key); // &quot;0&quot;, &quot;1&quot;, &quot;2&quot;, &quot;customProperty&quot;
}

// for ...of
for (const value of array) {
    console.log(value); // 10, 20, 30
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;for...in&lt;/b&gt;&lt;/span&gt;은 배열의 인덱스와 추가된 속성(customProperty)을 모두 순회합니다.&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;for... of는&lt;/b&gt;&lt;/span&gt; 배열의 값만 순회하며, 추가 속성은 무시합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;border: 10px solid #009a87; border-radius: 0px; background-color: #ffffff; padding: 15px 30px; margin: 0;&quot;&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; top: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p style=&quot;text-align: left; font-weight: bold;&quot; data-ke-size=&quot;size18&quot;&gt;정리&lt;/p&gt;
&lt;p style=&quot;text-align: left; font-weight: bold;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;for...in&lt;/b&gt;&lt;/span&gt;은 객체의 속성(key)을 순회하며, 배열에서는 예기치 않은 속성까지 포함될 수 있으므로 주의가 필요합니다.&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;&lt;b&gt;for...of&lt;/b&gt;&lt;/span&gt;는 이터러블 객체의 값만 순회하므로 배열, 문자열, Set, Map 등에서&amp;nbsp; 더욱 간단하고 직관적인 사용이 가능합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;width: 98%; height: 12px; background-color: #ffffff; display: block; position: relative; bottom: -26px; margin: 0 auto;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;a4&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 추천글&lt;/b&gt;&lt;/h3&gt;
&lt;figure id=&quot;og_1733903601933&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[JS] 자바스크립트의 == 와 === 의 차이&quot; data-og-description=&quot;자바스크립트를 다룰 때 ==와 ===는 매우 자주 등장하는 연산자입니다. 하지만 둘의 차이를 정확히 이해하지 못하면 의도치 않은 버그가 발생할 수 있습니다.&amp;nbsp;목차 1. == (느슨한 동등 연산자 - Loos&quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/139&quot; data-og-url=&quot;https://dev-hpk.tistory.com/139&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bOlTSB/hyXKmRyzx3/Xp7G6zWN5s3m6o4wfAa5gk/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/bmOLEf/hyXKpHvyvb/KtISMib0UhCkc3v5UG83iK/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/ccEIqW/hyXKykbxm9/lD0JaJ2WShAFK9kIEWPEXk/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/139&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/139&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bOlTSB/hyXKmRyzx3/Xp7G6zWN5s3m6o4wfAa5gk/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/bmOLEf/hyXKpHvyvb/KtISMib0UhCkc3v5UG83iK/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/ccEIqW/hyXKykbxm9/lD0JaJ2WShAFK9kIEWPEXk/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[JS] 자바스크립트의 == 와 === 의 차이&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;자바스크립트를 다룰 때 ==와 ===는 매우 자주 등장하는 연산자입니다. 하지만 둘의 차이를 정확히 이해하지 못하면 의도치 않은 버그가 발생할 수 있습니다.&amp;nbsp;목차 1. == (느슨한 동등 연산자 - Loos&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1733903609067&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[JS] Promise.all 과 Promise.allSettled 차이&quot; data-og-description=&quot;JavaScript의 비동기 처리를 할 때, 여러 Promise를 동시에 처리해야 하는 경우가 많습니다. 이때 자주 사용하는 두 가지 메서드가 Promise.all과 Promise.allSettled입니다.목차 1. Promise.all 2. Promise.allSettled 3. &quot; data-og-host=&quot;dev-hpk.tistory.com&quot; data-og-source-url=&quot;https://dev-hpk.tistory.com/135&quot; data-og-url=&quot;https://dev-hpk.tistory.com/135&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/iBtWU/hyXKryxPkm/Id3C6k0fiLmZT0oc9OsKu0/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dXLp7o/hyXKld2VEe/KogCdWMO3ZR6Klnh3iRZU0/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/HJ46Q/hyXKnv9BGx/xr2XPK1DDLhuPd0kHKAVeK/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://dev-hpk.tistory.com/135&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev-hpk.tistory.com/135&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/iBtWU/hyXKryxPkm/Id3C6k0fiLmZT0oc9OsKu0/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dXLp7o/hyXKld2VEe/KogCdWMO3ZR6Klnh3iRZU0/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/HJ46Q/hyXKnv9BGx/xr2XPK1DDLhuPd0kHKAVeK/img.jpg?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[JS] Promise.all 과 Promise.allSettled 차이&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;JavaScript의 비동기 처리를 할 때, 여러 Promise를 동시에 처리해야 하는 경우가 많습니다. 이때 자주 사용하는 두 가지 메서드가 Promise.all과 Promise.allSettled입니다.목차 1. Promise.all 2. Promise.allSettled 3.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev-hpk.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JavaScript</category>
      <category>for</category>
      <category>for ...in</category>
      <category>for ...of</category>
      <category>javascript</category>
      <category>js</category>
      <category>개발</category>
      <category>프론트엔드</category>
      <author>dev-hpk</author>
      <guid isPermaLink="true">https://dev-hpk.tistory.com/140</guid>
      <comments>https://dev-hpk.tistory.com/140#entry140comment</comments>
      <pubDate>Wed, 11 Dec 2024 16:55:21 +0900</pubDate>
    </item>
  </channel>
</rss>