<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Hyun Dev</title>
    <link>https://hy-un.tistory.com/</link>
    <description>Frond-End Developer</description>
    <language>ko</language>
    <pubDate>Wed, 8 Apr 2026 16:46:02 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Hyun Dev</managingEditor>
    <image>
      <title>Hyun Dev</title>
      <url>https://tistory1.daumcdn.net/tistory/6962421/attach/703e852e9d2f46b990042b99f2434101</url>
      <link>https://hy-un.tistory.com</link>
    </image>
    <item>
      <title>ESLint로 리액트 import 순서 자동 정리하기</title>
      <link>https://hy-un.tistory.com/entry/ESLint%EB%A1%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8-import-%EC%88%9C%EC%84%9C-%EC%9E%90%EB%8F%99-%EC%A0%95%EB%A6%AC%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;React 프로젝트를 진행하다 보면 import 순서가 제각각이라서 보기 불편해질 때가 많습니다.&lt;br /&gt;처음에는 수동으로 정리하려 했는데, 이걸 매번 사람이 맞추는 건 너무 번거롭고 비효율적이라 방법을 찾다가 ESLint로 해결하게 됐습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;435&quot; data-start=&quot;416&quot; data-ke-size=&quot;size26&quot;&gt;1. 필요한 라이브러리 설치&lt;/h2&gt;
&lt;p data-end=&quot;476&quot; data-start=&quot;437&quot; data-ke-size=&quot;size16&quot;&gt;먼저, eslint-plugin-import 플러그리를 설치합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1748535308749&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm install eslint-plugin-import --save-dev&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. eslint.config.js 설정&lt;/h2&gt;
&lt;pre id=&quot;code_1748535685703&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// eslint.config.js
import importPlugin from 'eslint-plugin-import';

export default [
  {
    /* ... 기타 설정 생략 ... */
    plugins: {
      /* ... 기타 플러그인 생략 ... */
      import: importPlugin, // ESLint가 이 플러그인을 인식함
    },

    rules: {
      /* ... 기타 룰 생략 ... */
      'import/order': [
        'warn', // 경고로 띄움 (에러는 아님)
        {
          groups: [
            'external', // 외부 라이브러리 (예: react, axios)
            'internal', // 프로젝트 내부 import (@/components 등)
            ['parent', 'sibling', 'index'], // 상대경로 import (../, ./)
          ],
          pathGroups: [
            {
              pattern: '@/**', // @로 시작하는 경로를 internal로 인식
              group: 'internal',
            },
          ],
          'newlines-between': 'always-and-inside-groups', // 그룹 간 줄바꿈
          alphabetize: {
            order: 'asc', // 알파벳순 정렬
            caseInsensitive: true, // 대소문자 구분 X
          },
        },
      ],
    },
  },
];&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;1541&quot; data-start=&quot;1522&quot; data-ke-size=&quot;size26&quot;&gt;3. import 정렬 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위처럼 설정하면, import 순서는 다음과 같이 정렬됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1748536043873&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;1. external &amp;ndash; 외부 라이브러리
예: react, react-dom, axios 등

2. internal &amp;ndash; 프로젝트 내부 경로 (절대 경로)
예: @/hooks, @/components 등
@/** 경로는 내부 import로 인식되도록 설정해줍니다.

3. parent, sibling, index &amp;ndash; 상대 경로
예: ../, ./, 같은 폴더의 index.ts&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;1774&quot; data-start=&quot;1735&quot;&gt;@/** 경로는 internal로 인식되게 별도로 설정해줍니다.&lt;/li&gt;
&lt;li data-end=&quot;1832&quot; data-start=&quot;1776&quot;&gt;각 그룹 간에는 줄바꿈이 자동으로 들어가고, 같은 그룹 내에서는 알파벳 순서로 정렬됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;1851&quot; data-start=&quot;1839&quot; data-ke-size=&quot;size26&quot;&gt;4. 사용 방법&lt;/h2&gt;
&lt;p data-end=&quot;1881&quot; data-start=&quot;1853&quot; data-ke-size=&quot;size16&quot;&gt;import 순서 오류 확인만 하고 싶다면&lt;/p&gt;
&lt;pre id=&quot;code_1748535900954&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npx eslint src&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동으로 정렬까지 하고 싶다면&lt;/p&gt;
&lt;pre id=&quot;code_1748535921405&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npx eslint src --fix&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-end=&quot;2122&quot; data-start=&quot;2035&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 설정해두면 수동으로 import 순서 맞출 필요 없이, eslint --fix 한 번이면 다 정리돼서 훨씬 편해집니다.&lt;/p&gt;
&lt;p data-end=&quot;2265&quot; data-start=&quot;2124&quot; data-ke-size=&quot;size16&quot;&gt;추가적으로 쓸 수 있는 옵션도 많으니 &lt;a href=&quot;https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/order.md&quot; data-end=&quot;2254&quot; data-start=&quot;2145&quot;&gt;eslint-plugin-import 공식 문서&lt;/a&gt; 참고해도 좋습니다.&lt;/p&gt;</description>
      <category>React/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/97</guid>
      <comments>https://hy-un.tistory.com/entry/ESLint%EB%A1%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8-import-%EC%88%9C%EC%84%9C-%EC%9E%90%EB%8F%99-%EC%A0%95%EB%A6%AC%ED%95%98%EA%B8%B0#entry97comment</comments>
      <pubDate>Fri, 30 May 2025 02:03:24 +0900</pubDate>
    </item>
    <item>
      <title>URL 기반 모달 라우팅 구현</title>
      <link>https://hy-un.tistory.com/entry/URL-%EA%B8%B0%EB%B0%98-%EB%AA%A8%EB%8B%AC-%EB%9D%BC%EC%9A%B0%ED%8C%85-%EA%B5%AC%ED%98%84</link>
      <description>&lt;p data-end=&quot;531&quot; data-start=&quot;336&quot; data-ke-size=&quot;size16&quot;&gt;X(구 트위터) 사이트를 클론코딩하다가, 회원가입이나 로그인 버튼을 누르면 모달이 뜨고 URL이 '/i/flow/signup', '/i/flow/login'으로 바뀌는 걸 봤습니다.&lt;br /&gt;처음엔&amp;nbsp;그냥&amp;nbsp;페이지&amp;nbsp;이동인&amp;nbsp;줄&amp;nbsp;알았는데,&amp;nbsp;실제로는&amp;nbsp;화면은&amp;nbsp;그대로고&amp;nbsp;모달만&amp;nbsp;올라오는&amp;nbsp;구조였습니다.&lt;br /&gt;&lt;br /&gt;생각해보니 이 방식이 꽤 괜찮은 것 같았습니다.&lt;br /&gt;특히 모바일에선 실수로 뒤로가기를 눌렀을 때, 모달만 닫히고 기존 페이지는 그대로 유지돼서, 사용자&amp;nbsp;입장에서&amp;nbsp;훨씬&amp;nbsp;안정적인&amp;nbsp;흐름을&amp;nbsp;만들&amp;nbsp;수&amp;nbsp;있다고&amp;nbsp;생각했습니다.&lt;/p&gt;
&lt;p data-end=&quot;531&quot; data-start=&quot;336&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;사용자 입장에서 꽤 괜찮은 흐름이라고 판단했고, 그래서&amp;nbsp;저도&amp;nbsp;이&amp;nbsp;구조를&amp;nbsp;비슷하게&amp;nbsp;구현해봤습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;780&quot; data-start=&quot;763&quot; data-ke-size=&quot;size26&quot;&gt;경로 기반으로 모달 열기&lt;/h2&gt;
&lt;p data-end=&quot;861&quot; data-start=&quot;782&quot; data-ke-size=&quot;size16&quot;&gt;아래처럼 버튼을 눌렀을 때 navigate()를 사용해 URL을 바꾸면서 현재 위치 정보를 state로 같이 넘기도록 만들었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1747970748280&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const navigate = useNavigate();
const location = useLocation();

const openModal = (path: string) =&amp;gt; {
  navigate(path, {
    state: { backgroundLocation: location },
  });
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼엔 이렇게 연결했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1747970765825&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;button onClick={() =&amp;gt; openModal('/signup')}&amp;gt;회원가입&amp;lt;/button&amp;gt;
&amp;lt;button onClick={() =&amp;gt; openModal('/login')}&amp;gt;로그인&amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 backgroundLocation이라는 이름은 제가 임의로 지은 것입니다.&lt;br /&gt;다른 이름을 써도 상관없고, 중요한 건 현재 경로를 넘기는 것입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;라우터 구성&lt;/h2&gt;
&lt;p data-end=&quot;1333&quot; data-start=&quot;1303&quot; data-ke-size=&quot;size16&quot;&gt;App.tsx에서는 아래처럼 라우터를 구성했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1747970811828&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const location = useLocation();
const state = location.state;

return (
    &amp;lt;&amp;gt;
        &amp;lt;Routes location={state?.backgroundLocation || location}&amp;gt;
            &amp;lt;Route path='/' element={&amp;lt;AppLayout /&amp;gt;}&amp;gt;
                &amp;lt;Route index element={&amp;lt;HomePage /&amp;gt;} /&amp;gt;
            &amp;lt;/Route&amp;gt;
        &amp;lt;/Routes&amp;gt;

        {location.pathname === '/signup' &amp;amp;&amp;amp; &amp;lt;SignUpModal /&amp;gt;}
        {location.pathname === '/login' &amp;amp;&amp;amp; &amp;lt;LoginModal /&amp;gt;}
    &amp;lt;/&amp;gt;
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;1973&quot; data-start=&quot;1765&quot; data-ke-size=&quot;size16&quot;&gt;중요한 건 &amp;lt;Routes location={...}&amp;gt; 부분입니다.&lt;br /&gt;React Router는 기본적으로 현재 location을 기준으로 라우팅을 하지만, 우리는 이전 경로를 backgroundLocation으로 넘겨줬기 때문에 실제 URL이 /signup이어도 화면은 '/' 기준으로 라우팅됩니다.&lt;br /&gt;그 위에 모달만 조건부로 띄우는 방식입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;2208&quot; data-start=&quot;2185&quot; data-ke-size=&quot;size26&quot;&gt;모달 닫기 시 navigate(-1) 처리&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;2453&quot; data-end=&quot;2503&quot; data-ke-size=&quot;size16&quot;&gt;모달에 닫기 버튼이 있다면 아래처럼 navigate(-1)을 반드시 호출해줘야 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1747971783196&quot; class=&quot;coffeescript&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 handleClose = () =&amp;gt; {
  modalRef.current?.close();
  navigate(-1);
};&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;2593&quot; data-end=&quot;2739&quot; data-ke-size=&quot;size16&quot;&gt;이걸 넣지 않으면 모달은 닫히지만 URL은 그대로 /signup에 남아 있고,&lt;br /&gt;뒤로가기를 누를 때 다시 모달이 열리는 등 흐름이 꼬일 수 있습니다.&lt;br /&gt;navigate(-1)을 호출해주면 주소도 함께 되돌아가기 때문에 자연스럽게 닫히는 구조가 됩니다&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;2208&quot; data-start=&quot;2185&quot; data-ke-size=&quot;size26&quot;&gt;모달 열기 처리 (Daisy UI)&lt;/h2&gt;
&lt;p data-end=&quot;2286&quot; data-start=&quot;2210&quot; data-ke-size=&quot;size16&quot;&gt;저는 Daisy UI 모달을 사용했기 때문에 showModal()을 수동으로 호출해줘야 했습니다.&lt;br /&gt;하지만 Daisy UI처럼 &amp;lt;dialog&amp;gt; 태그를 사용하는 방식이 아니라면, 이런 코드는 필요하지 않을 수도 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1747970885495&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  const modal = modalRef.current;
  if (modal &amp;amp;&amp;amp; !modal.open) {
    modal.showModal();
  }
}, []);&lt;/code&gt;&lt;/pre&gt;</description>
      <category>React/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/95</guid>
      <comments>https://hy-un.tistory.com/entry/URL-%EA%B8%B0%EB%B0%98-%EB%AA%A8%EB%8B%AC-%EB%9D%BC%EC%9A%B0%ED%8C%85-%EA%B5%AC%ED%98%84#entry95comment</comments>
      <pubDate>Fri, 23 May 2025 12:46:48 +0900</pubDate>
    </item>
    <item>
      <title>Zustand 리렌더링 최적화 하기 + Redux Toolkit 최적화</title>
      <link>https://hy-un.tistory.com/entry/Zustand-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%93%B0%EB%8A%94-%EC%83%81%ED%83%9C%EA%B4%80%EB%A6%AC-%ED%8C%A8%ED%84%B4</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근 프로젝트를 진행하면서 다양한 상태관리 라이브러리를 사용해봤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그중에서도 가장 자주 사용한 건 Zustand였고, 최근에는 Redux Toolkit도 경험해봤습니다.&lt;/p&gt;
&lt;p data-end=&quot;370&quot; data-start=&quot;268&quot; data-ke-size=&quot;size16&quot;&gt;사실 React에서는 Context API만으로도 상태 공유가 가능합니다.&lt;/p&gt;
&lt;p data-end=&quot;370&quot; data-start=&quot;268&quot; data-ke-size=&quot;size16&quot;&gt;그런데도 사람들이 굳이 Zustand나 Redux 같은 상태관리 라이브러리를 사용하는 이유는 뭘까요?&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;394&quot; data-start=&quot;372&quot; data-ke-size=&quot;size26&quot;&gt;Context API를 피하는 이유&lt;/h2&gt;
&lt;p data-end=&quot;529&quot; data-start=&quot;396&quot; data-ke-size=&quot;size16&quot;&gt;Context API는 Provider로 컴포넌트를 감싸서 상태를 공유할 수 있게 해줍니다. 문제는 하나의 값만 바뀌더라도 value 전체가 바뀐 것으로 간주되어, 이를 구독하고 있는 모든 컴포넌트가 리렌더링된다는 점입니다.&lt;/p&gt;
&lt;p data-end=&quot;660&quot; data-start=&quot;531&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하려면 memoization, context 분리, useMemo 등을 활용해야 하지만, 솔직히 번거롭고 복잡한 작업이 많습니다.&lt;/p&gt;
&lt;p data-end=&quot;660&quot; data-start=&quot;531&quot; data-ke-size=&quot;size16&quot;&gt;그래서 자연스럽게 Zustand 같은 라이브러리로 눈이 가게 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;689&quot; data-start=&quot;662&quot; data-ke-size=&quot;size26&quot;&gt;그런데 나 Zustand 잘못 쓰고 있었더라&lt;/h2&gt;
&lt;p data-end=&quot;798&quot; data-start=&quot;691&quot; data-ke-size=&quot;size16&quot;&gt;Zustand는 리렌더를 최소화해서 성능 최적화에 유리하다고 알려져 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;798&quot; data-start=&quot;691&quot; data-ke-size=&quot;size16&quot;&gt;저도 이 장점 때문에 사용해왔습니다. 그런데 최근에야 깨달았어요. 제가 Zustand를 잘못 쓰고 있었다는 걸요.&lt;/p&gt;
&lt;p data-end=&quot;830&quot; data-start=&quot;800&quot; data-ke-size=&quot;size16&quot;&gt;예전에는 아래처럼 상태를 한번에 가져와서 사용했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1747506067151&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { checkAuth, isCheckingAuth, accessToken } = useAuthStore();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;겉보기에는 깔끔해 보이지만, 이 방식은 실제로 성능상 좋지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이유는 Zustand가 상태 변경 여부를 판단할 때 객체의 내부 값이 아닌 참조값을 기준으로 비교하기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 checkAuth, isCheckingAuth, accessToken을 구조분해 할당으로 한 번에 가져오면, 이 중 하나라도 값이 바뀌면 전체 객체의 참조가 변경됩니다.&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;만약 다른 컴포넌트에서 여기서 꺼내오지도 않은 authUser를&lt;/p&gt;
&lt;pre id=&quot;code_1747518852034&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { authUser } = useAuthStore();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;450&quot; data-start=&quot;361&quot; data-ke-size=&quot;size16&quot;&gt;처럼 가져왔다면, 이 컴포넌트는 상태 전체를 구독하는 것과 같아서,&lt;/p&gt;
&lt;p data-end=&quot;450&quot; data-start=&quot;361&quot; data-ke-size=&quot;size16&quot;&gt;위 컴포넌트에서 isCheckingAuth가 변경되는 등 전혀 관련 없는 상태 변화에도 불필요하게 리렌더링됩니다.&lt;/p&gt;
&lt;p data-end=&quot;450&quot; data-start=&quot;361&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;450&quot; data-start=&quot;361&quot; data-ke-size=&quot;size16&quot;&gt;즉, 객체를 통째로 구독하는 방식은 내부 값 하나만 바뀌어도 전체가 바뀐 것으로 간주되기 때문에, Zustand의 성능 최적화 이점을 제대로 누릴 수 없습니다.&lt;/p&gt;
&lt;p data-end=&quot;1194&quot; data-start=&quot;1055&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1194&quot; data-start=&quot;1055&quot; data-ke-size=&quot;size16&quot;&gt;Redux Toolkit에서도 동일한 원칙이 적용됩니다.&lt;/p&gt;
&lt;p data-end=&quot;326&quot; data-start=&quot;309&quot; data-ke-size=&quot;size16&quot;&gt;최근까지 다음처럼 주로 썼습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1747507260608&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { accessToken, isCheckingAuth } = useSelector(
  (state: RootState) =&amp;gt; state.auth
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마찬가지로 이 방식도 성능 면에서는 비효율적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;왜냐하면 state.auth 객체 자체가 참조 변경으로 간주되기 때문에, 내부 필드가 하나만 바뀌어도 이 값을 사용하는 모든 컴포넌트가 리렌더링되기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;1206&quot; data-start=&quot;1196&quot; data-ke-size=&quot;size26&quot;&gt;올바른 사용법&lt;/h2&gt;
&lt;p data-end=&quot;1257&quot; data-start=&quot;1208&quot; data-ke-size=&quot;size16&quot;&gt;리렌더링을 최소화하려면 아래처럼 각각의 상태를 선택자로 분리해서 사용하는 것이 좋습니다.&lt;br /&gt;이렇게 하면 오직 선택한 값이 변경될 때만 컴포넌트가 리렌더링됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1747506130675&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const checkAuth = useAuthStore(state =&amp;gt; state.checkAuth);
const isCheckingAuth = useAuthStore(state =&amp;gt; state.isCheckingAuth);
const accessToken = useAuthStore(state =&amp;gt; state.accessToken);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Redux Toolkit을 쓸 때도 다음과 같이 필드 단위로 셀렉터를 분리하는 것이 좋습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1747507652063&quot; class=&quot;pf&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 accessToken = useSelector((state: RootState) =&amp;gt; state.auth.accessToken);
const isCheckingAuth = useSelector((state: RootState) =&amp;gt; state.auth.isCheckingAuth);&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그런데 매번 이렇게 분리하는 게 귀찮다?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용하는 값이 많아질수록 위처럼 하나하나 나눠서 쓰는 건 비효율적일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이럴 때 사용할 수 있는 게 바로 useShallow입니다.&lt;/p&gt;
&lt;p data-end=&quot;1745&quot; data-start=&quot;1640&quot; data-ke-size=&quot;size16&quot;&gt;Zustand는 공식적으로 useShallow이라는 훅을 제공합니다.&lt;/p&gt;
&lt;p data-end=&quot;1745&quot; data-start=&quot;1640&quot; data-ke-size=&quot;size16&quot;&gt;이 훅을 활용하면 여러 값을 객체 형태로 한번에 가져오면서도, 내부 값이 변경될 때만 리렌더가 발생하도록 해줍니다.&lt;/p&gt;
&lt;p data-end=&quot;1745&quot; data-start=&quot;1640&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://zustand.docs.pmnd.rs/hooks/use-shallow&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://zustand.docs.pmnd.rs/hooks/use-shallow&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1747506178742&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;useShallow ⚛️ - Zustand&quot; data-og-description=&quot;How to memoize selector functions&quot; data-og-host=&quot;zustand.docs.pmnd.rs&quot; data-og-source-url=&quot;https://zustand.docs.pmnd.rs/hooks/use-shallow&quot; data-og-url=&quot;https://zustand.docs.pmnd.rs/hooks/use-shallow&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/DovjR/hyYU3pAFuL/VpwDKlkPWUmcAPkx3lDE90/img.jpg?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/iSqyl/hyYW4nzH5f/NNXNFkT5ZGY3cYNtEDE4ZK/img.jpg?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://zustand.docs.pmnd.rs/hooks/use-shallow&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://zustand.docs.pmnd.rs/hooks/use-shallow&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/DovjR/hyYU3pAFuL/VpwDKlkPWUmcAPkx3lDE90/img.jpg?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/iSqyl/hyYW4nzH5f/NNXNFkT5ZGY3cYNtEDE4ZK/img.jpg?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;useShallow ⚛️ - Zustand&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;How to memoize selector functions&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;zustand.docs.pmnd.rs&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;pre id=&quot;code_1747506185987&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useShallow } from 'zustand/shallow';

const { checkAuth, isCheckingAuth, accessToken } = useAuthStore(
  useShallow((state) =&amp;gt; ({
    checkAuth: state.checkAuth,
    isCheckingAuth: state.isCheckingAuth,
    accessToken: state.accessToken,
  }))
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 checkAuth, isCheckingAuth, accessToken 중 실제로 변경된 값이 있을 때만 해당 컴포넌트가 리렌더링됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Redux에서는 useSelector와 함께 shallowEqual을 사용하면 비슷한 방식으로 최적화할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 accessToken이나 isCheckingAuth 중 하나만 변경될 경우에만 해당 컴포넌트가 리렌더링됩니다.&lt;br /&gt;즉, Redux에서도 객체 전체가 아니라 내부 값 변경 여부를 기준으로 리렌더링 여부를 판단하게 되는 셈입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1747507383800&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { shallowEqual, useSelector } from 'react-redux';

const { accessToken, isCheckingAuth } = useSelector(
  (state: RootState) =&amp;gt; ({
    accessToken: state.auth.accessToken,
    isCheckingAuth: state.auth.isCheckingAuth,
  }),
  shallowEqual
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-05-18 오전 3.07.16.png&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dz62K2/btsN2Ob991e/iNSpjwucyEgwAUTZCLKqy0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dz62K2/btsN2Ob991e/iNSpjwucyEgwAUTZCLKqy0/img.png&quot; data-alt=&quot;변경 전&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dz62K2/btsN2Ob991e/iNSpjwucyEgwAUTZCLKqy0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdz62K2%2FbtsN2Ob991e%2FiNSpjwucyEgwAUTZCLKqy0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2914&quot; height=&quot;556&quot; data-filename=&quot;스크린샷 2025-05-18 오전 3.07.16.png&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;556&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;변경 전&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-05-18 오전 3.06.34.png&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;304&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rrIEX/btsN2lOMsiH/ergkCFkGmxF3y3kO10uSC1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rrIEX/btsN2lOMsiH/ergkCFkGmxF3y3kO10uSC1/img.png&quot; data-alt=&quot;변경 후&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rrIEX/btsN2lOMsiH/ergkCFkGmxF3y3kO10uSC1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrrIEX%2FbtsN2lOMsiH%2FergkCFkGmxF3y3kO10uSC1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2914&quot; height=&quot;304&quot; data-filename=&quot;스크린샷 2025-05-18 오전 3.06.34.png&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;304&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;br /&gt;여러 값을 한꺼번에 가져와야 할 때는 Zustand에서는 useShallow 훅을, Redux Toolkit에서는 shallowEqual을 사용해 얕은 비교를 적용하는 것이 성능 최적화에 도움이 됩니다.&lt;/p&gt;</description>
      <category>React/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/94</guid>
      <comments>https://hy-un.tistory.com/entry/Zustand-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%93%B0%EB%8A%94-%EC%83%81%ED%83%9C%EA%B4%80%EB%A6%AC-%ED%8C%A8%ED%84%B4#entry94comment</comments>
      <pubDate>Sun, 18 May 2025 03:29:10 +0900</pubDate>
    </item>
    <item>
      <title>Socket.IO를 이용한 채팅 및 온라인/오프라인 실시간 상태 반영</title>
      <link>https://hy-un.tistory.com/entry/SocketIO%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%B1%84%ED%8C%85-%EB%B0%8F-%EC%98%A8%EB%9D%BC%EC%9D%B8%EC%98%A4%ED%94%84%EB%9D%BC%EC%9D%B8-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%83%81%ED%83%9C-%EB%B0%98%EC%98%81</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://socket.io/docs/v4/server-initialization/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://socket.io/docs/v4/server-initialization/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1746107044816&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;Server Initialization | Socket.IO&quot; data-og-description=&quot;Once you have installed the Socket.IO server library, you can now init the server. The complete list of options can be found here.&quot; data-og-host=&quot;socket.io&quot; data-og-source-url=&quot;https://socket.io/docs/v4/server-initialization/&quot; data-og-url=&quot;https://socket.io/docs/v4/server-initialization/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://socket.io/docs/v4/server-initialization/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://socket.io/docs/v4/server-initialization/&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;Server Initialization | Socket.IO&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Once you have installed the Socket.IO server library, you can now init the server. The complete list of options can be found here.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;socket.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채팅 앱에서는 유저가 메시지를 실시간으로 주고받는 것뿐만 아니라, 누가 온라인인지도 즉시 보여주는 기능이 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해 Socket.IO를 도입했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버(Node.js) 에서는 socket.io 라이브러리를 사용하였고, 클라리언트(React) 에서는 socket.io-client 라이브러리를 사용하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-05-01 오후 11.38.35.png&quot; data-origin-width=&quot;2320&quot; data-origin-height=&quot;1438&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cgmYa6/btsNGKvNxYh/3bPWCVoSahgYqzqMidFMk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cgmYa6/btsNGKvNxYh/3bPWCVoSahgYqzqMidFMk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cgmYa6/btsNGKvNxYh/3bPWCVoSahgYqzqMidFMk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcgmYa6%2FbtsNGKvNxYh%2F3bPWCVoSahgYqzqMidFMk1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2320&quot; height=&quot;1438&quot; data-filename=&quot;스크린샷 2025-05-01 오후 11.38.35.png&quot; data-origin-width=&quot;2320&quot; data-origin-height=&quot;1438&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;socket.js: 소켓 서버 설정&lt;/h2&gt;
&lt;p data-end=&quot;458&quot; data-start=&quot;407&quot; data-ke-size=&quot;size16&quot;&gt;socket.js 파일을 새로 만들어 기본 서버 위에 Socket.IO 서버를 올립니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746108832778&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Server } from 'socket.io';
import http from 'http';
import express from 'express';
import jwt from 'jsonwebtoken';

const app = express();
const server = http.createServer(app);

const io = new Server(server, {
  cors: {
    origin: ['http://localhost:5173'],
  },
});

export function getReceiverSocketId(userId) {
  return userSocketMap[userId];
}

const userSocketMap = {};

io.on('connection', (socket) =&amp;gt; {
  const token = socket.handshake.auth.token;
  if (!token) {
    console.log('토큰이 제공되지 않았습니다.');
    return socket.disconnect();
  }

  try {
    const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
    const userId = decoded.id;

    if (userId) userSocketMap[userId] = socket.id;

    io.emit('getOnlineUsers', Object.keys(userSocketMap));

    socket.on('disconnect', () =&amp;gt; {
      console.log('disconnected', socket.id);
      delete userSocketMap[userId];
      io.emit('getOnlineUsers', Object.keys(userSocketMap));
    });
  } catch (error) {
    console.error('유효하지 않은 토큰:', error);
    return socket.disconnect();
  }
});

export { io, app, server };&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;1390&quot; data-start=&quot;1336&quot;&gt;Server는 Socket.IO 서버 객체이며, http 서버 위에 올려야 작동합니다.&lt;/li&gt;
&lt;li data-end=&quot;1453&quot; data-start=&quot;1391&quot;&gt;userSocketMap은 현재 온라인 상태인 유저들의 socket.id를 userId 기준으로 저장합니다.&lt;/li&gt;
&lt;li data-end=&quot;1522&quot; data-start=&quot;1454&quot;&gt;handshake.query.userId를 통해 클라이언트가 접속 시 전달한 userId를 확인할 수 있습니다.&lt;/li&gt;
&lt;li data-end=&quot;1593&quot; data-start=&quot;1523&quot;&gt;io.emit('getOnlineUsers', ...)를 통해 현재 접속 중인 모든 유저에게 온라인 목록을 전송합니다.&lt;/li&gt;
&lt;li data-end=&quot;1646&quot; data-start=&quot;1594&quot;&gt;연결이 끊기면 해당 유저를 userSocketMap에서 제거하고 다시 전체에 알립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;index.js 수정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 express로만 서버를 실행했다면, 이제는 socket.js에서 만든 server 객체로 실행해야 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746108124457&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { app, server } from './lib/socket.js';

// 기존 app.listen &amp;rarr; server.listen 으로 변경
server.listen(PORT, () =&amp;gt; {
    console.log('Server running on PORT:', PORT);
    connectToDB();
});&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-end=&quot;1979&quot; data-start=&quot;1948&quot; data-ke-size=&quot;size26&quot;&gt;메시지 전송 시 소켓 사용 (메시지 컨트롤러)&lt;/h2&gt;
&lt;p data-end=&quot;2035&quot; data-start=&quot;1981&quot; data-ke-size=&quot;size16&quot;&gt;유저가 메시지를 전송할 때, 해당 메시지를 받는 유저가 현재 접속 중이라면 실시간으로 전달합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746108454248&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const receiverSocketId = getReceiverSocketId(receiverId);
if (receiverSocketId) {
    io.to(receiverSocketId).emit('newMessage', newMessage);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2298&quot; data-start=&quot;2192&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2239&quot; data-start=&quot;2192&quot;&gt;getReceiverSocketId()를 통해 수신자의 소켓 ID를 얻습니다.&lt;/li&gt;
&lt;li data-end=&quot;2298&quot; data-start=&quot;2240&quot;&gt;io.to(socketId).emit(...)은 특정 유저에게만 메시지를 전송합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-end=&quot;2341&quot; data-start=&quot;2305&quot; data-ke-size=&quot;size26&quot;&gt;클라이언트 측: 소켓 연결 (zustand)&lt;/h2&gt;
&lt;h3 data-end=&quot;2359&quot; data-start=&quot;2343&quot; data-ke-size=&quot;size23&quot;&gt;useAuthStore&lt;/h3&gt;
&lt;p data-end=&quot;2400&quot; data-start=&quot;2361&quot; data-ke-size=&quot;size16&quot;&gt;유저가 로그인할 때 소켓 연결을 열고, 로그아웃할 때는 연결을 끊는 함수를 만들어 두었으며, 로그인/로그아웃 시 해당 함수를 호출해주면 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746108331402&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;connectSocket: () =&amp;gt; {
  const { authUser, accessToken } = get();
  if (!authUser || !accessToken || get().socket?.connected) return;

  const socket = io(BASE_URL, {
    auth: {
      token: accessToken,
    },
  });
  socket.connect();

  set({ socket });

  socket.on('getOnlineUsers', (userIds) =&amp;gt; {
    set({ onlineUsers: userIds });
  });
},

disconnectSocket: () =&amp;gt; {
  if (get().socket?.connected) get().socket.disconnect();
},&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;useChatStore&lt;/h3&gt;
&lt;p data-end=&quot;2999&quot; data-start=&quot;2961&quot; data-ke-size=&quot;size16&quot;&gt;선택된 유저로부터 새로운 메시지를 수신할 때만 상태를 업데이트합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746108377544&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;subscribeToMessages: () =&amp;gt; {
  const { selectedUser } = get();
  if (!selectedUser) return;

  const socket = useAuthStore.getState().socket;

  socket?.on('newMessage', (newMessage) =&amp;gt; {
    const isMessageSentFromSelectedUser = newMessage.senderId === selectedUser._id;
    if (!isMessageSentFromSelectedUser) return;

    set({
      messages: [...get().messages, newMessage],
    });
  });
},

unsubscribeFromMessages: () =&amp;gt; {
  const socket = useAuthStore.getState().socket;
  socket?.off('newMessage');
},&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Node.js/Chat App</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/93</guid>
      <comments>https://hy-un.tistory.com/entry/SocketIO%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%B1%84%ED%8C%85-%EB%B0%8F-%EC%98%A8%EB%9D%BC%EC%9D%B8%EC%98%A4%ED%94%84%EB%9D%BC%EC%9D%B8-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%83%81%ED%83%9C-%EB%B0%98%EC%98%81#entry93comment</comments>
      <pubDate>Thu, 1 May 2025 23:38:48 +0900</pubDate>
    </item>
    <item>
      <title>Cloudinary를 이용한 프로필 업데이트 기능 구현</title>
      <link>https://hy-un.tistory.com/entry/Cloudinary%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%ED%94%84%EB%A1%9C%ED%95%84-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 회원가입 시 또는 이후에 프로필 사진을 업로드할 수 있도록 하기 위해, 이미지 저장소로 Cloudinary를 연동하는 기능을 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://cloudinary.com/documentation/node_integration&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://cloudinary.com/documentation/node_integration&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1745923349342&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;Node.js SDK &amp;ndash; Node.js Upload + Image, Video Transformations | Cloudinary&quot; data-og-description=&quot;The Cloudinary Node.js SDK provides simple, yet comprehensive image and video upload, transformation, optimization, and delivery capabilities through the Cloudinary APIs, that you can implement using code that integrates seamlessly with your existing Node.&quot; data-og-host=&quot;cloudinary.com&quot; data-og-source-url=&quot;https://cloudinary.com/documentation/node_integration&quot; data-og-url=&quot;https://cloudinary.com/documentation/node_integration&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://cloudinary.com/documentation/node_integration&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://cloudinary.com/documentation/node_integration&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;Node.js SDK &amp;ndash; Node.js Upload + Image, Video Transformations | Cloudinary&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The Cloudinary Node.js SDK provides simple, yet comprehensive image and video upload, transformation, optimization, and delivery capabilities through the Cloudinary APIs, that you can implement using code that integrates seamlessly with your existing Node.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;cloudinary.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 패키지를 설치합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1745923624242&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm install cloudinary&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Cloudinary 설정 및 환경변수 구성&lt;/h2&gt;
&lt;p data-end=&quot;666&quot; data-start=&quot;630&quot; data-ke-size=&quot;size16&quot;&gt;.env 파일에 Cloudinary API 정보를 추가합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1745923678326&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cloudinary를 설정하는 모듈은 아래와 같이 작성했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1745923695936&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// lib/cloudinary.js
import { v2 as cloudinary } from 'cloudinary';
import { config } from 'dotenv';

config();

cloudinary.config({
    cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
    api_key: process.env.CLOUDINARY_API_KEY,
    api_secret: process.env.CLOUDINARY_API_SECRET,
});

export default cloudinary;&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;1615&quot; data-start=&quot;1591&quot; data-ke-size=&quot;size26&quot;&gt;프로필 업데이트 라우트 설정&lt;/h2&gt;
&lt;pre id=&quot;code_1745923815708&quot; class=&quot;gams&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;router.put('/update-profile', protectRoute, updateProfile);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-end=&quot;1615&quot; data-start=&quot;1591&quot; data-ke-size=&quot;size26&quot;&gt;프로필 이미지 업로드 기능 구현&lt;/h2&gt;
&lt;p data-end=&quot;1690&quot; data-start=&quot;1617&quot; data-ke-size=&quot;size16&quot;&gt;이미지를 base64 형식으로 클라이언트에서 전송받아 Cloudinary에 업로드한 후, 사용자 정보에 이미지 URL을 저장합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1745923836169&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const updateProfile = async (req, res) =&amp;gt; {
    try {
        const { profilePic } = req.body;
        const userId = req.user._id;

        if (!profilePic) {
            return res
                .status(400)
                .json({ message: '프로필 사진이 필요합니다.' });
        }

        const uploadResponse = await cloudinary.uploader.upload(profilePic);

        const updatedUser = await User.findByIdAndUpdate(
            userId,
            {
                profilePic: uploadResponse.secure_url,
            },
            { new: true }
        );

        res.status(200).json(updatedUser);
    } catch (error) {
        console.log('프로필 업데이트 중 오류 발생:', error);
        res.status(500).json({ message: '서버 오류가 발생했습니다.' });
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Postman 테스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;임의의 base64 데이터를 포함해 요청을 보내면, 아래와 같이 프로필이 정상적으로 업데이트되는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-04-29 오후 7.51.53.png&quot; data-origin-width=&quot;2076&quot; data-origin-height=&quot;1452&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSWTBf/btsNFU4He3d/KJkrfj4G2BBOSVlh8GcSwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSWTBf/btsNFU4He3d/KJkrfj4G2BBOSVlh8GcSwK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSWTBf/btsNFU4He3d/KJkrfj4G2BBOSVlh8GcSwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSWTBf%2FbtsNFU4He3d%2FKJkrfj4G2BBOSVlh8GcSwK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2076&quot; height=&quot;1452&quot; data-filename=&quot;스크린샷 2025-04-29 오후 7.51.53.png&quot; data-origin-width=&quot;2076&quot; data-origin-height=&quot;1452&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같이 Cloudinary에도 이미지가 정상적으로 추가된 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-04-29 오후 8.06.37.png&quot; data-origin-width=&quot;2220&quot; data-origin-height=&quot;1652&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYiFT4/btsNDMnmtFF/dzkDuSnHtSTeBeDHQeIl9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYiFT4/btsNDMnmtFF/dzkDuSnHtSTeBeDHQeIl9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYiFT4/btsNDMnmtFF/dzkDuSnHtSTeBeDHQeIl9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYiFT4%2FbtsNDMnmtFF%2FdzkDuSnHtSTeBeDHQeIl9k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2220&quot; height=&quot;1652&quot; data-filename=&quot;스크린샷 2025-04-29 오후 8.06.37.png&quot; data-origin-width=&quot;2220&quot; data-origin-height=&quot;1652&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Node.js/Chat App</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/92</guid>
      <comments>https://hy-un.tistory.com/entry/Cloudinary%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%ED%94%84%EB%A1%9C%ED%95%84-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84#entry92comment</comments>
      <pubDate>Tue, 29 Apr 2025 19:55:44 +0900</pubDate>
    </item>
    <item>
      <title>Google 로그인 구현 (OAuth 2.0 + JWT + Zustand)</title>
      <link>https://hy-un.tistory.com/entry/Google-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84-OAuth-20-JWT-Zustand</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;MERN todo 프로젝트에서 Google OAuth 2.0을 활용한 로그인 기능을 구현했습니다.&lt;br /&gt;@react-oauth/google 라이브러리를 활용하여 클라이언트에서 Google 로그인 버튼을 만들고, 서버에서는 Google API를 이용해 사용자 정보를 받아와 JWT를 발급하는 방식으로 처리했습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;279&quot; data-start=&quot;254&quot; data-ke-size=&quot;size26&quot;&gt;1. Google OAuth 로그인 흐름&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1423&quot; data-start=&quot;264&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;454&quot; data-start=&quot;264&quot;&gt;&lt;b&gt;클라이언트에서 useGoogleLogin 훅을 이용해 Google 로그인 요청&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;454&quot; data-start=&quot;322&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;388&quot; data-start=&quot;322&quot;&gt;@react-oauth/google 라이브러리에서 제공하는 useGoogleLogin 훅을 사용했습니다.&lt;/li&gt;
&lt;li data-end=&quot;454&quot; data-start=&quot;392&quot;&gt;이 훅을 이용하면 Google 로그인 창을 띄우고, 로그인 성공 시 code를 반환받을 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;625&quot; data-start=&quot;456&quot;&gt;&lt;b&gt;Google에서 code를 반환하면 이를 서버로 전송&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;625&quot; data-start=&quot;500&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;559&quot; data-start=&quot;500&quot;&gt;code는 Google OAuth 서버에서 발급하는 인증 코드로, 이를 백엔드로 보내야 합니다.&lt;/li&gt;
&lt;li data-end=&quot;625&quot; data-start=&quot;563&quot;&gt;클라이언트에서는 response.code를 POST 요청의 body에 담아 서버로 전송합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;935&quot; data-start=&quot;627&quot;&gt;&lt;b&gt;서버에서 Google API에 요청해 access_token을 받음&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;935&quot; data-start=&quot;679&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;743&quot; data-start=&quot;679&quot;&gt;서버에서는 &lt;a href=&quot;https://oauth2.googleapis.com/token&quot;&gt;https://oauth2.googleapis.com/token&lt;/a&gt; 엔드포인트에 요청을 보냅니다.&lt;/li&gt;
&lt;li data-end=&quot;803&quot; data-start=&quot;747&quot;&gt;이 요청을 통해 access_token과 refresh_token을 받을 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1120&quot; data-start=&quot;937&quot;&gt;&lt;b&gt;access_token을 사용해 Google 사용자 정보를 가져옴&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1120&quot; data-start=&quot;988&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1034&quot; data-start=&quot;988&quot;&gt;access_token은 Google API에 접근하기 위한 토큰입니다.&lt;/li&gt;
&lt;li data-end=&quot;1120&quot; data-start=&quot;1038&quot;&gt;이를 이용해 &lt;a href=&quot;https://www.googleapis.com/oauth2/v2/userinfo&quot;&gt;https://www.googleapis.com/oauth2/v2/userinfo&lt;/a&gt; 엔드포인트에서 사용자 정보를 가져왔습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1252&quot; data-start=&quot;1122&quot;&gt;&lt;b&gt;DB에서 해당 사용자가 있는지 확인하고 없으면 새로 생성&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1252&quot; data-start=&quot;1166&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1226&quot; data-start=&quot;1166&quot;&gt;받은 사용자 정보(email, name, avatar 등)를 활용해 DB에서 해당 사용자를 찾습니다.&lt;/li&gt;
&lt;li data-end=&quot;1252&quot; data-start=&quot;1230&quot;&gt;없다면 새로 생성하여 저장합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1423&quot; data-start=&quot;1254&quot;&gt;&lt;b&gt;서비스 전용 accessToken과 refreshToken을 생성하여 반환&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1423&quot; data-start=&quot;1315&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1357&quot; data-start=&quot;1315&quot;&gt;accessToken은 클라이언트에서 API 요청 시 사용됩니다.&lt;/li&gt;
&lt;li data-end=&quot;1423&quot; data-start=&quot;1361&quot;&gt;refreshToken은 httpOnly 쿠키에 저장하여 자동 로그인 및 토큰 갱신에 활용됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;703&quot; data-start=&quot;656&quot; data-ke-size=&quot;size26&quot;&gt;2. 서버: Google 로그인 API (/api/user/google)&lt;/h2&gt;
&lt;p data-end=&quot;790&quot; data-start=&quot;704&quot; data-ke-size=&quot;size16&quot;&gt;서버에서는 express와 fetch를 활용하여 Google API에 요청하고, JWT를 생성하여 클라이언트에서 사용할 수 있도록 했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1740644582385&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export async function googleLogin(req, res, next) {
    try {
        const { code } = req.body;

        if (!code) {
            return res
                .status(400)
                .json({ message: 'Google 인증 코드가 없습니다.' });
        }

        const tokenResponse = await fetch(
            'https://oauth2.googleapis.com/token',
            {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({
                    client_id: process.env.GOOGLE_CLIENT_ID,
                    client_secret: process.env.GOOGLE_CLIENT_SECRET,
                    code,
                    grant_type: 'authorization_code',
                    redirect_uri: process.env.GOOGLE_REDIRECT_URI,
                }).toString(),
            }
        );

        const tokenData = await tokenResponse.json();
        const { access_token } = tokenData;

        if (!access_token) {
            return res.status(400).json({
                message: 'Google Access Token을 가져오지 못했습니다.',
            });
        }

        const userInfoResponse = await fetch(
            'https://www.googleapis.com/oauth2/v2/userinfo',
            {
                headers: { Authorization: `Bearer ${access_token}` },
            }
        );

        const userInfo = await userInfoResponse.json();
        const { id: googleId, email, name, picture } = userInfo;

        await connectToDB();

        let user = await User.findOne({ email });

        if (!user) {
            user = new User({
                googleId,
                email,
                name,
                avatar: picture,
            });
            await user.save();
        }

        const accessToken = generateAccessToken(user._id);
        const refreshToken = generateRefreshToken(user._id);

        res.cookie('refresh_token', refreshToken, {
            httpOnly: true,
            secure: true,
            sameSite: 'strict',
            maxAge: 24 * 60 * 60 * 1000,
        });

        res.json({
            _id: user.id,
            email: user.email,
            name: user.name,
            avatar: user.avatar,
            access_token: accessToken,
        });
    } catch (error) {
        console.error('Google 로그인 에러:', error);
        next(error);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 클라이언트: Google 로그인 버튼 &amp;amp; API 요청&lt;/h2&gt;
&lt;p data-end=&quot;3622&quot; data-start=&quot;3446&quot; data-ke-size=&quot;size16&quot;&gt;@react-oauth/google 라이브러리에서 제공하는 useGoogleLogin 훅을 사용하여 Google 로그인 기능을 구현했습니다.&lt;/p&gt;
&lt;p data-end=&quot;3622&quot; data-start=&quot;3446&quot; data-ke-size=&quot;size16&quot;&gt;이 훅을 이용하면 로그인 창을 띄우고, 로그인 성공 시 code를 반환받을 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;3622&quot; data-start=&quot;3446&quot; data-ke-size=&quot;size16&quot;&gt;아래는 로그인 페이지에서 Google 로그인 버튼을 구현한 코드입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746304002763&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useAuthStore } from '@/store/useAuthStore';
import { useGoogleLogin } from '@react-oauth/google';
import toast from 'react-hot-toast';
import { Button } from './ui/button';
import { Loader2 } from 'lucide-react';

export default function GoogleLoginButton() {
  const googleLogin = useAuthStore((state) =&amp;gt; state.googleLogin);
  const isLoggingIn = useAuthStore((state) =&amp;gt; state.isLoggingIn);

  const googleLoginHandler = useGoogleLogin({
    flow: 'auth-code',
    onSuccess: async (response) =&amp;gt; {
      const code = response.code;
      await googleLogin(code);
    },
    onError: () =&amp;gt; {
      toast.error('Google 로그인 실패');
    },
  });

  return (
    &amp;lt;Button
      type='button'
      className='flex items-center w-full gap-4 px-12 mb-4 bg-transparent rounded-full cursor-pointer'
      disabled={isLoggingIn}
      variant='outline'
      onClick={() =&amp;gt; googleLoginHandler()}
    &amp;gt;
      &amp;lt;img src='/google.png' alt='google' className='w-5' /&amp;gt;
      {isLoggingIn ? (
        &amp;lt;&amp;gt;
          &amp;lt;Loader2 className='size-5 animate-spin' /&amp;gt;
          Loading...
        &amp;lt;/&amp;gt;
      ) : (
        'Google 계정으로 로그인'
      )}
    &amp;lt;/Button&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-end=&quot;4913&quot; data-start=&quot;4871&quot; data-ke-size=&quot;size26&quot;&gt;4. 클라이언트: Google Client ID 설정 및 로그인 처리&lt;/h2&gt;
&lt;p data-end=&quot;5069&quot; data-start=&quot;4915&quot; data-ke-size=&quot;size16&quot;&gt;로그인 페이지에서 Google 로그인 버튼을 렌더링하려면, GoogleOAuthProvider로 감싸야 합니다.&lt;/p&gt;
&lt;p data-end=&quot;5069&quot; data-start=&quot;4915&quot; data-ke-size=&quot;size16&quot;&gt;또한, 클라이언트에서 googleClientId를 백엔드에서 받아오는 방법을 사용하여, 배포 환경에 맞게 Google OAuth 2.0을 처리합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746304040243&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useEffect } from 'react';
import { useAuthStore } from '@/store/useAuthStore';
import { Loader } from 'lucide-react';
import { GoogleOAuthProvider } from '@react-oauth/google';
import GoogleLoginButton from './GoogleLoginButton';
import { useShallow } from 'zustand/shallow';

export function LoginPage() {
  const { login, isLoggingIn, googleClientId, getGoogleClientId } =
    useAuthStore(
      useShallow((state) =&amp;gt; ({
        login: state.login,
        isLoggingIn: state.isLoggingIn,
        googleClientId: state.googleClientId,
        getGoogleClientId: state.getGoogleClientId,
      }))
    );

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

  if (!googleClientId)
    return (
      &amp;lt;div className='flex items-center justify-center h-screen'&amp;gt;
        &amp;lt;Loader className='size-10 animate-spin' /&amp;gt;
      &amp;lt;/div&amp;gt;
    );

  return (
    &amp;lt;GoogleOAuthProvider clientId={googleClientId}&amp;gt;
      &amp;lt;GoogleLoginButton /&amp;gt;
    &amp;lt;/GoogleOAuthProvider&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Node.js/Todo</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/88</guid>
      <comments>https://hy-un.tistory.com/entry/Google-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84-OAuth-20-JWT-Zustand#entry88comment</comments>
      <pubDate>Thu, 27 Feb 2025 17:35:35 +0900</pubDate>
    </item>
    <item>
      <title>회원가입 및 로그인 기능 구현</title>
      <link>https://hy-un.tistory.com/entry/%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EB%B0%8F-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k6L0d/btsNJ4gvTmF/uxcbGZpKtDZ9WCd7xqzxRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k6L0d/btsNJ4gvTmF/uxcbGZpKtDZ9WCd7xqzxRk/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1650&quot; data-filename=&quot;스크린샷 2025-05-04 오전 5.02.44.png&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k6L0d/btsNJ4gvTmF/uxcbGZpKtDZ9WCd7xqzxRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk6L0d%2FbtsNJ4gvTmF%2FuxcbGZpKtDZ9WCd7xqzxRk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2940&quot; height=&quot;1650&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c2j7ab/btsNJ3IFeJd/P3exN7aA3qz68SaCCC0ZNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c2j7ab/btsNJ3IFeJd/P3exN7aA3qz68SaCCC0ZNk/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1650&quot; data-filename=&quot;스크린샷 2025-05-04 오전 5.02.50.png&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c2j7ab/btsNJ3IFeJd/P3exN7aA3qz68SaCCC0ZNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc2j7ab%2FbtsNJ3IFeJd%2FP3exN7aA3qz68SaCCC0ZNk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2940&quot; height=&quot;1650&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;64&quot; data-start=&quot;48&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드에서 회원가입과 로그인 기능을 구현하면서 zustand 상태 관리와 axios 인스턴스를 활용하여 API 요청을 효율적으로 처리했습니다. 아래는 그 전체적인 흐름입니다.&lt;/p&gt;
&lt;h2 data-end=&quot;248&quot; data-start=&quot;225&quot; data-ke-size=&quot;size26&quot;&gt;1. Axios 인스턴스 설정&lt;/h2&gt;
&lt;p data-end=&quot;369&quot; data-start=&quot;250&quot; data-ke-size=&quot;size16&quot;&gt;먼저 API 요청에 사용할 axios 인스턴스를 생성합니다. 개발/배포 환경에 따라 baseURL을 다르게 설정하고, 쿠키를 함께 전송할 수 있도록 withCredentials: true 옵션도 추가합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746302925545&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from 'axios';

export const axiosInstance = axios.create({
  baseURL:
    import.meta.env.MODE === 'development'
      ? 'http://localhost:3000/api'
      : '/api',
  withCredentials: true,
});&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;638&quot; data-start=&quot;618&quot; data-ke-size=&quot;size26&quot;&gt;2. 회원가입 기능 구현&lt;/h2&gt;
&lt;p data-end=&quot;742&quot; data-start=&quot;640&quot; data-ke-size=&quot;size16&quot;&gt;회원가입 요청은 zustand의 스토어 내부에 signup 함수를 정의해서 처리했습니다. 요청 전후 상태를 관리하기 위해 isSigningUp 값을 함께 사용하고 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746302956862&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;signup: async (data) =&amp;gt; {
  set({ isSigningUp: true });
  try {
    const res = await axiosInstance.post('/auth/register', data);
    if (res.status === 201) {
      toast.success('회원가입이 완료되었습니다.');
      return true;
    }
    return false;
  } catch (error) {
    const err = error as AxiosError&amp;lt;{ message: string }&amp;gt;;
    const errorMessage = err.response?.data?.message || 'Unknown error occurred';
    toast.error(errorMessage);
    return false;
  } finally {
    set({ isSigningUp: false });
  }
},&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입 폼에서는 handleSubmit 함수 내에서 유효성 검사 후 signup 함수를 호출하며, 성공 시 로그인 페이지로 이동합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746302982970&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleSubmit = async (e: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
  e.preventDefault();
  const isValid = validateForm();
  if (isValid === true) {
    const isSuccess = await signup(formData);
    if (isSuccess) navigate('/login');
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-end=&quot;1645&quot; data-start=&quot;1626&quot; data-ke-size=&quot;size26&quot;&gt;3. 로그인 기능 구현&lt;/h2&gt;
&lt;p data-end=&quot;1712&quot; data-start=&quot;1647&quot; data-ke-size=&quot;size16&quot;&gt;로그인 기능 역시 zustand 내부에서 처리하며, 성공 시 반환된 accessToken을 상태로 저장합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746303021117&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;login: async (data) =&amp;gt; {
  set({ isLoggingIn: true });
  try {
    const res = await axiosInstance.post('/auth/login', data);
    set({ accessToken: res.data.accessToken });
    toast.success('로그인 성공');
  } catch (error) {
    const err = error as AxiosError&amp;lt;{ message: string }&amp;gt;;
    const errorMessage = err.response?.data?.message || 'Unknown error occurred';
    toast.error(errorMessage);
  } finally {
    set({ isLoggingIn: false });
  }
},&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 로그인 유지 기능 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 상태를 유지하려면 주기적으로 서버에 토큰을 확인하고, 이를 통해 사용자가 여전히 인증된 상태인지를 확인해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해 /auth/refresh-token 엔드포인트를 호출하여 쿠키에 저장된 refreshToken을 기반으로 새로운 accessToken을 받아옵니다.&lt;/p&gt;
&lt;h3 data-end=&quot;338&quot; data-start=&quot;313&quot; data-ke-size=&quot;size23&quot;&gt;1. checkAuth 함수 구현&lt;/h3&gt;
&lt;p data-end=&quot;434&quot; data-start=&quot;340&quot; data-ke-size=&quot;size16&quot;&gt;checkAuth 함수는 앱이 로드될 때마다 서버로부터 새로운 액세스 토큰을 받아와 상태를 업데이트합니다. 이 함수는 useAuthStore 내부에서 정의됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746303173044&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;checkAuth: async () =&amp;gt; {
  set({ isCheckingAuth: true });
  try {
    const res = await axiosInstance.get('/auth/refresh-token');
    set({ accessToken: res.data.accessToken });
  } catch (error) {
    console.log('Error in checkAuth: ', error);
    set({ accessToken: null });
  } finally {
    set({ isCheckingAuth: false });
  }
},&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;checkAuth 함수는 로그인 상태를 확인하는 비동기 요청을 보내고, 서버에서 accessToken을 성공적으로 반환하면 이를 상태로 저장합니다. 만약 오류가 발생하면, accessToken을 null로 설정하여 로그아웃 상태를 만듭니다.&lt;/p&gt;
&lt;h3 data-end=&quot;980&quot; data-start=&quot;950&quot; data-ke-size=&quot;size23&quot;&gt;2. useEffect로 로그인 유지 처리&lt;/h3&gt;
&lt;p data-end=&quot;1075&quot; data-start=&quot;982&quot; data-ke-size=&quot;size16&quot;&gt;앱이 시작될 때마다 checkAuth를 호출하여 로그인 상태를 유지합니다. useEffect 훅을 사용하여 초기 렌더링 시에 checkAuth를 실행합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746303278277&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Navigate, Route, Routes } from 'react-router-dom';
import HomePage from './pages/HomePage';
import SignupPage from './pages/SignupPage';
import LoginPage from './pages/LoginPage';
import { Toaster } from 'react-hot-toast';
import { useEffect } from 'react';
import { useAuthStore } from './store/useAuthStore';
import { useShallow } from 'zustand/shallow';
import { Loader } from 'lucide-react';

function App() {
  const { checkAuth, isCheckingAuth, accessToken } = useAuthStore(
    useShallow((state) =&amp;gt; ({
      checkAuth: state.checkAuth,
      isCheckingAuth: state.isCheckingAuth,
      accessToken: state.accessToken,
    }))
  );

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

  if (isCheckingAuth &amp;amp;&amp;amp; !accessToken)
    return (
      &amp;lt;div className='flex items-center justify-center h-screen'&amp;gt;
        &amp;lt;Loader className='size-10 animate-spin' /&amp;gt;
      &amp;lt;/div&amp;gt;
    );

  return (
    &amp;lt;&amp;gt;
      &amp;lt;Routes&amp;gt;
        &amp;lt;Route
          path='/'
          element={accessToken ? &amp;lt;HomePage /&amp;gt; : &amp;lt;Navigate to='/login' /&amp;gt;}
        /&amp;gt;
        &amp;lt;Route
          path='/signup'
          element={!accessToken ? &amp;lt;SignupPage /&amp;gt; : &amp;lt;Navigate to='/' /&amp;gt;}
        /&amp;gt;
        &amp;lt;Route
          path='/login'
          element={!accessToken ? &amp;lt;LoginPage /&amp;gt; : &amp;lt;Navigate to='/' /&amp;gt;}
        /&amp;gt;
      &amp;lt;/Routes&amp;gt;
      &amp;lt;Toaster /&amp;gt;
    &amp;lt;/&amp;gt;
  );
}

export default App;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Node.js/Todo</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/86</guid>
      <comments>https://hy-un.tistory.com/entry/%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EB%B0%8F-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84#entry86comment</comments>
      <pubDate>Thu, 20 Feb 2025 07:34:07 +0900</pubDate>
    </item>
    <item>
      <title>Todo CRUD API 구현하기</title>
      <link>https://hy-un.tistory.com/entry/Todo-CRUD-API-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 Todo CRUD 기능을 구현한 내용을 정리했습니다.&lt;br /&gt;사용자의 할 일 목록을 추가, 조회, 수정, 삭제할 수 있도록 API를 만들었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 전체 목록 조회 (GET /todo)&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1739909619044&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const getAllTodos = async (req, res) =&amp;gt; {
    try {
        const todos = await Todo.find({ userId: req.user._id });

        if (!todos || todos.length === 0) {
            return res.status(404).json({
                message: '등록된 할 일이 없습니다.',
            });
        }

        res.status(200).json({
            message: '할 일 목록을 성공적으로 불러왔습니다.',
            todos,
        });
    } catch (error) {
        console.error('할 일 목록 가져오기 오류:', error);
        res.status(500).json({
            message: '서버 오류가 발생했습니다.',
        });
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2678&quot; data-start=&quot;2571&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2678&quot; data-start=&quot;2620&quot;&gt;find({ userID: req.user.id })를 사용해 해당 유저의 todo만 조회한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-05-03 오전 5.42.01.png&quot; data-origin-width=&quot;2086&quot; data-origin-height=&quot;1534&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cgOlIv/btsNKuS0njm/em1QlpGXePSxgkbi1ql7VK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cgOlIv/btsNKuS0njm/em1QlpGXePSxgkbi1ql7VK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cgOlIv/btsNKuS0njm/em1QlpGXePSxgkbi1ql7VK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcgOlIv%2FbtsNKuS0njm%2Fem1QlpGXePSxgkbi1ql7VK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2086&quot; height=&quot;1534&quot; data-filename=&quot;스크린샷 2025-05-03 오전 5.42.01.png&quot; data-origin-width=&quot;2086&quot; data-origin-height=&quot;1534&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 할 일 추가 (POST /todo)&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1739909635990&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const addTodo = async (req, res) =&amp;gt; {
    const { title } = req.body;

    try {
        if (!title) {
            return res.status(400).json({
                message: '할 일 내용을 입력해 주세요.',
            });
        }

        const newTodo = new Todo({
            userId: req.user._id,
            title,
        });

        await newTodo.save();

        res.status(201).json({
            message: '할 일이 성공적으로 추가되었습니다.',
        });
    } catch (error) {
        console.error('할 일 추가 오류:', error);
        res.status(500).json({
            message: '서버 오류가 발생했습니다.',
        });
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2803&quot; data-start=&quot;2711&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2776&quot; data-start=&quot;2746&quot;&gt;새로운 Todo 객체를 생성한 후 DB에 저장.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bx8w5s/btsNJxiVpKO/fR9qA2NnxucxUfnPWDUOd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bx8w5s/btsNJxiVpKO/fR9qA2NnxucxUfnPWDUOd0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2086&quot; data-origin-height=&quot;1534&quot; data-filename=&quot;스크린샷 2025-05-03 오전 5.44.23.png&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bx8w5s/btsNJxiVpKO/fR9qA2NnxucxUfnPWDUOd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbx8w5s%2FbtsNJxiVpKO%2FfR9qA2NnxucxUfnPWDUOd0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2086&quot; height=&quot;1534&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VGLDC/btsNLqCv7r0/io8GU6ZlH8Sl6VtZ52a541/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VGLDC/btsNLqCv7r0/io8GU6ZlH8Sl6VtZ52a541/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2086&quot; data-origin-height=&quot;1534&quot; data-filename=&quot;스크린샷 2025-05-03 오전 5.44.30.png&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VGLDC/btsNLqCv7r0/io8GU6ZlH8Sl6VtZ52a541/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVGLDC%2FbtsNLqCv7r0%2Fio8GU6ZlH8Sl6VtZ52a541%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2086&quot; height=&quot;1534&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 할 일 수정 (PUT /todo/:id)&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1739909670833&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const updateTodo = async (req, res) =&amp;gt; {
    const { id } = req.params;
    const { title, isCompleted } = req.body;

    try {
        const todo = await Todo.findById(id);

        if (todo.userId.toString() !== req.user._id.toString()) {
            return res.status(403).json({
                message: '권한이 없습니다.',
            });
        }

        todo.title = title || todo.title;

        if (isCompleted !== undefined) {
            todo.isCompleted = isCompleted;
        }

        await todo.save();

        res.status(200).json({
            message: '할 일이 업데이트 되었습니다.',
        });
    } catch (error) {
        console.error('할 일 업데이트 오류:', error);
        res.status(500).json({
            message: '서버 오류가 발생했습니다.',
        });
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3148&quot; data-start=&quot;3001&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3045&quot; data-start=&quot;3001&quot;&gt;findById로 해당 Todo를 찾고, 사용자 검증 후 수정 진행.&lt;/li&gt;
&lt;li data-end=&quot;3111&quot; data-start=&quot;3048&quot;&gt;req.body.title이 있으면 제목 수정, isCompleted 값이 있으면 완료 여부 변경.&lt;/li&gt;
&lt;li data-end=&quot;3148&quot; data-start=&quot;3114&quot;&gt;수정 후 &quot;할 일이 업데이트 되었습니다.&quot; 메시지 반환.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lenh3/btsNJiM4qBq/5JjfC49zUOTdDb3bwkS8Pk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lenh3/btsNJiM4qBq/5JjfC49zUOTdDb3bwkS8Pk/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2086&quot; data-origin-height=&quot;1534&quot; data-filename=&quot;스크린샷 2025-05-03 오전 5.47.03.png&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lenh3/btsNJiM4qBq/5JjfC49zUOTdDb3bwkS8Pk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Flenh3%2FbtsNJiM4qBq%2F5JjfC49zUOTdDb3bwkS8Pk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2086&quot; height=&quot;1534&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pF833/btsNJYAiLMJ/Z7aH2bF9wTKTmiWuj01OA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pF833/btsNJYAiLMJ/Z7aH2bF9wTKTmiWuj01OA1/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2086&quot; data-origin-height=&quot;1534&quot; data-filename=&quot;스크린샷 2025-05-03 오전 5.47.12.png&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pF833/btsNJYAiLMJ/Z7aH2bF9wTKTmiWuj01OA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpF833%2FbtsNJYAiLMJ%2FZ7aH2bF9wTKTmiWuj01OA1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2086&quot; height=&quot;1534&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 할 일 삭제 (DELETE /todo/:id)&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1739909695659&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const deleteTodo = async (req, res) =&amp;gt; {
    const { id } = req.params;

    try {
        const todo = await Todo.deleteOne({
            _id: id,
            userId: req.user._id,
        });

        if (!todo) {
            return res.status(404).json({
                message: '할 일을 찾을 수 없습니다.',
            });
        }

        res.status(200).json({
            message: '할 일이 삭제되었습니다.',
        });
    } catch (error) {
        console.error('할 일 삭제 오류:', error);
        res.status(500).json({
            message: '서버 오류가 발생했습니다.',
        });
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3320&quot; data-start=&quot;3187&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3233&quot; data-start=&quot;3187&quot;&gt;deleteOne({ _id, userID })를 사용해 Todo 삭제.&lt;/li&gt;
&lt;li data-end=&quot;3320&quot; data-start=&quot;3283&quot;&gt;삭제 성공 시 &quot;할 일이 삭제되었습니다.&quot; 메시지 반환.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPO8gh/btsNLBKA2j0/xehNoWsHMNRqkKWZfRWWok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPO8gh/btsNLBKA2j0/xehNoWsHMNRqkKWZfRWWok/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2086&quot; data-origin-height=&quot;1534&quot; data-filename=&quot;스크린샷 2025-05-03 오전 5.48.04.png&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPO8gh/btsNLBKA2j0/xehNoWsHMNRqkKWZfRWWok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPO8gh%2FbtsNLBKA2j0%2FxehNoWsHMNRqkKWZfRWWok%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2086&quot; height=&quot;1534&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cBg3Ep/btsNLurcVIh/D9HkAWHMkODvsL5QfKqmcK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cBg3Ep/btsNLurcVIh/D9HkAWHMkODvsL5QfKqmcK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2086&quot; data-origin-height=&quot;1534&quot; data-filename=&quot;스크린샷 2025-05-03 오전 5.48.11.png&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cBg3Ep/btsNLurcVIh/D9HkAWHMkODvsL5QfKqmcK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcBg3Ep%2FbtsNLurcVIh%2FD9HkAWHMkODvsL5QfKqmcK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2086&quot; height=&quot;1534&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 코드&lt;/h2&gt;
&lt;pre id=&quot;code_1739909714538&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 Todo from '../models/todo.model.js';

export const getAllTodos = async (req, res) =&amp;gt; {
    try {
        const todos = await Todo.find({ userId: req.user._id });

        if (!todos) {
            return res.status(404).json({
                message: '할 일 목록 가져오기 오류',
            });
        }

        res.status(200).json({
            message: '할 일 목록을 가져왔습니다.',
            todos,
        });
    } catch (error) {
        console.error('할 일 목록 가져오기 오류:', error);
        res.status(500).json({
            message: '서버 오류가 발생했습니다.',
        });
    }
};

export const addTodo = async (req, res) =&amp;gt; {
    const { title } = req.body;

    try {
        if (!title) {
            return res.status(400).json({
                message: '내용을 입력해 주세요.',
            });
        }

        const newTodo = new Todo({
            userId: req.user._id,
            title,
        });

        await newTodo.save();

        res.status(201).json({
            message: '할 일이 추가되었습니다.',
        });
    } catch (error) {
        console.error('할 일 추가 오류:', error);
        res.status(500).json({
            message: '서버 오류가 발생했습니다.',
        });
    }
};

export const updateTodo = async (req, res) =&amp;gt; {
    const { id } = req.params;
    const { title, isCompleted } = req.body;

    try {
        const todo = await Todo.findById(id);

        if (todo.userId.toString() !== req.user._id.toString()) {
            return res.status(403).json({
                message: '권한이 없습니다.',
            });
        }

        todo.title = title || todo.title;

        if (isCompleted !== undefined) {
            todo.isCompleted = isCompleted;
        }

        await todo.save();

        res.status(200).json({
            message: '할 일이 업데이트 되었습니다.',
        });
    } catch (error) {
        console.error('할 일 업데이트 오류:', error);
        res.status(500).json({
            message: '서버 오류가 발생했습니다.',
        });
    }
};

export const deleteTodo = async (req, res) =&amp;gt; {
    const { id } = req.params;

    try {
        const todo = await Todo.deleteOne({
            _id: id,
            userId: req.user._id,
        });

        if (!todo) {
            return res.status(404).json({
                message: '할 일을 찾을 수 없습니다.',
            });
        }

        res.status(200).json({
            message: '할 일이 삭제되었습니다.',
        });
    } catch (error) {
        console.error('할 일 삭제 오류:', error);
        res.status(500).json({
            message: '서버 오류가 발생했습니다.',
        });
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Node.js/Todo</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/84</guid>
      <comments>https://hy-un.tistory.com/entry/Todo-CRUD-API-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#entry84comment</comments>
      <pubDate>Wed, 19 Feb 2025 05:17:10 +0900</pubDate>
    </item>
    <item>
      <title>JWT 인증 방식 개선: 쿠키 기반에서 토큰 기반으로 전환</title>
      <link>https://hy-un.tistory.com/entry/%EC%BF%A0%ED%82%A4-%EA%B8%B0%EB%B0%98-JWT-%EC%9D%B8%EC%A6%9D-%EB%B0%8F-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83-%EA%B8%B0%EB%8A%A5</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;기존에는 JWT를 이용해 Access Token을 생성한 후 쿠키에 저장하여 인증하는 방식으로 로그인 및 Todo 관련 API를 Protected Route 로 처리하였습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;161&quot; data-end=&quot;227&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;하지만 정보를 찾아본 결과 쿠키 기반 인증 방식에는 보안 취약점이 있기 때문에 이를 개선하고자 토큰 기반 인증 방식으로 변경하였습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot; data-start=&quot;229&quot; data-end=&quot;250&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;쿠키 기반 인증의 보안 문제&lt;/span&gt;&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot; data-start=&quot;251&quot; data-end=&quot;448&quot;&gt;
&lt;li data-start=&quot;251&quot; data-end=&quot;305&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;쿠키는 오랜 시간 유지될 수 있음 &amp;rarr; 토큰이 탈취되면 장기간 악용될 가능성이 있음&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;306&quot; data-end=&quot;367&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;자바스크립트로 쿠키 접근 가능 &amp;rarr; XSS(Cross-Site Scripting) 공격에 취약함&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;368&quot; data-end=&quot;448&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;CSRF(Cross-Site Request Forgery) 공격 가능 &amp;rarr; 악성 사이트에서 사용자의 인증된 요청을 위장할 수 있음&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;450&quot; data-end=&quot;491&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이러한 이유로 보안상 쿠키 기반 인증은 많이 사용되지 않는 방식이라고 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot; data-start=&quot;493&quot; data-end=&quot;515&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;토큰 기반 인증 방식으로 변경&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;516&quot; data-end=&quot;639&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;토큰 기반 인증으로 변경하기 위해 여러 가지 방식을 조사한 결과, Access Token을 브라우저의 Local Storage나 Session Storage에 저장하는 것도 보안에 취약하다는 점을 알게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot; data-start=&quot;641&quot; data-end=&quot;699&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;1. Access Token을 Local Storage / Session Storage에 저장하면?&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;700&quot; data-end=&quot;862&quot;&gt;
&lt;li data-start=&quot;700&quot; data-end=&quot;788&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;XSS(Cross-Site Scripting) 공격에 취약함&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;rarr; 악성 스크립트가 브라우저에서 실행되면 저장된 토큰을 탈취할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;789&quot; data-end=&quot;862&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Local Storage는 브라우저를 껐다 켜도 유지됨&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;rarr; 해커가 한 번 탈취하면 장기간 사용할 수 있습니다.&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; data-start=&quot;864&quot; data-end=&quot;914&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;2. 가장 안전한 방식: Access Token을 메모리(Private 변수)에 저장&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;915&quot; data-end=&quot;1011&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이러한 보안 문제를 방지하기 위해 Access Token은 클라이언트의 메모리(자바스크립트의 Private 변수)에 저장하는 것이 가장 안전하다고 판단하였습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1013&quot; data-end=&quot;1042&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;메모리(Private 변수) 저장의 장점&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;1043&quot; data-end=&quot;1093&quot;&gt;
&lt;li data-start=&quot;1043&quot; data-end=&quot;1071&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;XSS 공격으로 토큰을 탈취할 수 없습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;1072&quot; data-end=&quot;1093&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;CSRF 공격 위험이 없습니다.&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; data-start=&quot;1095&quot; data-end=&quot;1208&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;하지만 메모리에 저장된 토큰은 새로고침 시 사라지는 단점이 있습니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이를 해결하기 위해 Refresh Token을 이용해 Access Token을 갱신하는 API를 추가하였습니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot; data-start=&quot;1210&quot; data-end=&quot;1241&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;3. Refresh Token을 활용한 로그인 유지&lt;/span&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot; data-start=&quot;1242&quot; data-end=&quot;1420&quot;&gt;
&lt;li data-start=&quot;1242&quot; data-end=&quot;1285&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Access Token은 클라이언트 메모리에 저장함&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;1286&quot; data-end=&quot;1342&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Refresh Token은 HTTPOnly 쿠키에 저장함 (클라이언트에서 접근 불가)&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;1343&quot; data-end=&quot;1420&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Access Token이 만료되거나 새로고침하면, Refresh Token을 이용해 새로운 Access Token을 발급함&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;우선, 서버와 클라이언트 측 모두에서 해당 방식으로 구현해 본 적이 없었기 때문에 전체적인 흐름을 먼저 파악하였습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot; data-start=&quot;1492&quot; data-end=&quot;2046&quot;&gt;
&lt;li data-start=&quot;1492&quot; data-end=&quot;1673&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;로그인 시&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;1510&quot; data-end=&quot;1673&quot;&gt;
&lt;li data-start=&quot;1510&quot; data-end=&quot;1573&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Access Token(짧은 유효기간)과 Refresh Token(긴 유효기간)을 함께 발급&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;1577&quot; data-end=&quot;1631&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Access Token은 클라이언트 측에서 zustand를 이용해 메모리(Private 변수)에 저장&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;1635&quot; data-end=&quot;1673&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Refresh Token은 서버 측에서 HTTPOnly 쿠키에 저장&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-start=&quot;1675&quot; data-end=&quot;1764&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;API 요청 시&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;1696&quot; data-end=&quot;1764&quot;&gt;
&lt;li data-start=&quot;1696&quot; data-end=&quot;1764&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;요청할 때마다 zustand에 저장된 Access Token을 Authorization 헤더에 포함하여 전송&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-start=&quot;1766&quot; data-end=&quot;1918&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Access Token 만료 시&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;1796&quot; data-end=&quot;1918&quot;&gt;
&lt;li data-start=&quot;1796&quot; data-end=&quot;1867&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Access Token이 없거나 만료되면, Refresh Token을 이용해 새로운 Access Token을 발급&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;1871&quot; data-end=&quot;1918&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;새로운 Access Token을 받아 zustand 상태를 갱신하여 인증 유지&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-start=&quot;1920&quot; data-end=&quot;2046&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;새로고침 시&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;1939&quot; data-end=&quot;2046&quot;&gt;
&lt;li data-start=&quot;1939&quot; data-end=&quot;1973&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;zustand에 저장된 Access Token은 사라짐&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;1977&quot; data-end=&quot;2046&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Refresh Token을 이용해 자동으로 새로운 Access Token을 발급 및 저장하여 로그인 상태 유지&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;서버 측 변경 및 추가점&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1. 로그인 API 수정&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;95&quot; data-end=&quot;215&quot;&gt;기존 로그인 API에서는 JWT를 생성한 후 쿠키에 저장하는 방식이었지만, 토큰 기반 인증 방식으로 변경하기 위해 Access Token과 Refresh Token을 함께 발급하는 방식으로 수정하였습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;95&quot; data-end=&quot;215&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot; data-start=&quot;217&quot; data-end=&quot;243&quot;&gt;&lt;b&gt;변경된 로그인 API 동작 방식&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot; data-start=&quot;244&quot; data-end=&quot;439&quot;&gt;
&lt;li data-start=&quot;244&quot; data-end=&quot;278&quot;&gt;로그인 요청이 들어오면, 이메일과 비밀번호를 확인합니다.&lt;/li&gt;
&lt;li data-start=&quot;279&quot; data-end=&quot;337&quot;&gt;유효한 사용자인 경우 Access Token과 Refresh Token을 생성합니다.&lt;/li&gt;
&lt;li data-start=&quot;338&quot; data-end=&quot;388&quot;&gt;Refresh Token은 HTTPOnly 쿠키에 저장하여 보안성을 높입니다.&lt;/li&gt;
&lt;li data-start=&quot;389&quot; data-end=&quot;439&quot;&gt;Access Token은 클라이언트에서 활용할 수 있도록 JSON 형태로 응답합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1746303329380&quot; class=&quot;pgsql&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 const login = async (req, res) =&amp;gt; {
    const { email, password } = req.body;

    try {
        const user = await User.findOne({ email });
        if (!user || !(await bcrypt.compare(password, user.password))) {
            return res.status(400).json({ message: '이메일 또는 비밀번호가 올바르지 않습니다.' });
        }

        const accessToken = generateAccessToken(user._id);
        const refreshToken = generateRefreshToken(user._id);

        res.cookie('refresh-token', refreshToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'strict',
            maxAge: 7 * 24 * 60 * 60 * 1000,
        });

        res.json({
            accessToken,
            user: {
                id: user._id,
                email: user.email,
            },
        });
    } catch (error) {
        console.error('로그인 오류:', error);
        res.status(500).json({ message: '서버 오류가 발생했습니다.' });
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot; data-start=&quot;1833&quot; data-end=&quot;1868&quot;&gt;2. 로그아웃 API 수정&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1833&quot; data-end=&quot;1868&quot;&gt;로그아웃 API는 기존에 access_token을 clearCookie 하던 방식에서, refresh_token을 clearCookie 하도록 변경되었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746303329381&quot; class=&quot;dart&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 const logout = async (req, res) =&amp;gt; {
    res.clearCookie('refresh-token', {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
    });

    res.status(200).json({ message: '로그아웃되었습니다.' });
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot; data-start=&quot;1833&quot; data-end=&quot;1868&quot;&gt;3. Access Token 재발급 기능 추가&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;기존에는 Access Token이 만료되면 별도의 처리 없이 로그아웃되었지만, 이제 Refresh Token을 이용해 새로운 Access Token을 발급할 수 있도록 API를 추가하였습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;HTTPOnly 쿠키에 저장된 refresh_token을 이용하여 새로운 Access Token을 재발급할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746303329382&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 const refreshAccessToken = async (req, res) =&amp;gt; {
    const refreshToken = req.cookies['refresh-token'];

    try {
        if (!refreshToken) {
            return res.status(401).json({
                message: 'Refresh Token이 없습니다. 다시 로그인해주세요.',
            });
        }

        const decoded = jwt.verify(
            refreshToken,
            process.env.REFRESH_TOKEN_SECRET
        );

        if (!decoded) {
            return res.status(403).json({
                message: 'Refresh Token이 유효하지 않습니다.',
            });
        }

        const user = await User.findById(decoded.id);

        if (!user) {
            return res.status(404).json({
                message: '사용자를 찾을 수 없습니다.',
            });
        }

        const newAccessToken = generateAccessToken(user._id);

        res.json({
            accessToken: newAccessToken,
        });
    } catch (error) {
        console.error('Access Token 갱신 오류:', error);
        res.status(500).json({
            message: '서버 오류가 발생했습니다.',
        });
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;4. Auth 라우트 수정&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;기존 auth 라우트에 refreshAccessToken 엔드포인트를 추가하였습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이제 클라이언트는 이 엔드포인트를 통해 새로운 Access Token을 발급받을 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746303329383&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 express from 'express';
import { register, login, logout, refreshAccessToken } from '../controllers/auth.controller.js';

const router = express.Router();

router.post('/login', login);

router.post('/register', register);

router.post('/logout', logout);

router.get('/refresh-token', refreshAccessToken); // 추가

export default router;&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;5. Protected Route 인증 방식 변경&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;4050&quot; data-end=&quot;4167&quot;&gt;기존에는 req.cookies.access_token을 확인했지만,&lt;br /&gt;이제 요청 헤더의 Authorization에 Bearer {access_token}을 포함하여 인증하는 방식으로 변경하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746303329383&quot; class=&quot;routeros&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 jwt from 'jsonwebtoken';
import User from '../models/user.model.js';

export const protectRoute = async (req, res, next) =&amp;gt; {
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer')) {
        return res.status(401).json({ message: '토큰이 없습니다.' });
    }

    const token = authHeader.split(' ')[1];

    try {
        const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
        const user = await User.findById(decoded.id);

        if (!user) {
            return res.status(401).json({ message: '사용자를 찾을 수 없습니다.' });
        }

        req.user = user;
        next();
    } catch (error) {
        return res.status(401).json({ message: '토큰이 유효하지 않습니다.' });
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;1629&quot; data-end=&quot;1841&quot;&gt;
&lt;li data-start=&quot;1629&quot; data-end=&quot;1715&quot;&gt;Bearer 접두어를 확인하여 올바른 형식의 토큰인지 검사.&lt;/li&gt;
&lt;li data-start=&quot;1754&quot; data-end=&quot;1815&quot;&gt;jwt.verify()를 사용하여 토큰을 해독하고, 인증된 사용자의 정보를 req.user에 저장.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이제 Protected Route에 대한 API 요청 시 Authorization 헤더에 Bearer {access_token}을 포함해야 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1746303329384&quot; class=&quot;less&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;// 예제
fetch('http://localhost:3000/api/todos', {
    method: 'GET',
    headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
    },
    credentials: 'include',
});&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Node.js/Todo</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/83</guid>
      <comments>https://hy-un.tistory.com/entry/%EC%BF%A0%ED%82%A4-%EA%B8%B0%EB%B0%98-JWT-%EC%9D%B8%EC%A6%9D-%EB%B0%8F-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83-%EA%B8%B0%EB%8A%A5#entry83comment</comments>
      <pubDate>Tue, 18 Feb 2025 11:26:04 +0900</pubDate>
    </item>
    <item>
      <title>MongoDB 연결 및 회원가입 구현</title>
      <link>https://hy-un.tistory.com/entry/%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EA%B5%AC%ED%98%84-%EB%B0%8F-DB-%EC%97%B0%EA%B2%B0-%EC%84%A4%EC%A0%95</link>
      <description>&lt;p data-end=&quot;185&quot; data-start=&quot;49&quot; data-ke-size=&quot;size16&quot;&gt;이번 글에서는 Mongoose를 이용해 데이터베이스 모델을 정의하고, 회원가입 API를 작성 한 내용을 정리하겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1.MongoDB 스키마 정의&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, MongoDB에서 사용할 데이터 모델(스키마)을 정의합니다.&lt;br /&gt;데이터는 MongoDB에 저장되며, 이를 Mongoose의 Schema를 통해 관리합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1-1.models/user.model.js&lt;/h3&gt;
&lt;pre id=&quot;code_1739756712664&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import mongoose from 'mongoose';

const userSchema = new mongoose.Schema({
    googleId: {
        type: String,
        unique: true,
        sparse: true,
    },

    email: {
        type: String,
        required: true,
        unique: true,
    },
    password: {
        type: String,
        required: true,
        minlength: 6,
    },
});

const User = mongoose.model('User', userSchema);

export default User;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1-2.models/todo.model.js&lt;/h3&gt;
&lt;pre id=&quot;code_1739756171464&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import mongoose from 'mongoose';

const todoSchema = new mongoose.Schema({
    userId: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User',
        required: true,
    },
    title: {
        type: String,
        required: true,
    },

    isCompleted: {
        type: Boolean,
        default: false,
    },
});

const Todo = mongoose.model('Todo', todoSchema);

export default Todo;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;userID: MongoDB의 ObjectId 타입으로 저장되며, User 모델을 참조함 (ref: 'User')&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2.MongoDB 연결&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;데이터베이스에 연결하기 위해 MongoDB의 URI를 환경변수에서 가져와 연결합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1739756207150&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import mongoose from 'mongoose';

const connection = { isConnected: null };

export const connectToDB = async () =&amp;gt; {
    try {
        if (connection.isConnected) {
            return;
        }
        const db = await mongoose.connect(process.env.MONGO_URI);
        connection.isConnected = db.connections[0].readyState;
    } catch (error) {
        console.log(&quot;데이터베이스에 연결할 수 없습니다: &quot;, error);
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2238&quot; data-start=&quot;2145&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2189&quot; data-start=&quot;2145&quot;&gt;.env 파일에서 MONGO_URI를 가져와 MongoDB에 연결&lt;/li&gt;
&lt;li data-end=&quot;2214&quot; data-start=&quot;2190&quot;&gt;이미 연결된 경우 재연결 방지&lt;/li&gt;
&lt;li data-end=&quot;2238&quot; data-start=&quot;2215&quot;&gt;연결 실패 시 에러 메시지 출력&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3.controllers/auth.controller.js: 회원가입 API 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 이메일과 비밀번호를 입력하면 이를 DB에 저장하는 기능입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 req.body에서 클라이언트가 보낸 JSON 데이터(email, password)를 사용할 수 있도록,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;app.js에 app.use(express.json()); 한 줄을 추가해야 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1739756242751&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { createError } from '../utils/error.js';
import { connectToDB } from '../utils/connect.js';
import User from '../models/userModel.js';
import bcrypt from 'bcryptjs';

export const register = async (req, res) =&amp;gt; {
    const { email, password, passwordCheck } = req.body;

    try {
        if (!email || !password || !passwordCheck) {
            return res
                .status(400)
                .json({ message: '모든 필수 항목을 입력해 주세요.' });
        }

        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

        if (!emailRegex.test(email)) {
            return res.status(400).json({
                message: '이메일 형식이 올바르지 않습니다.',
            });
        }

        if (password.length &amp;lt; 6) {
            return res
                .status(400)
                .json({ message: '비밀번호는 최소 6자 이상이어야 합니다.' });
        }

        if (password !== passwordCheck) {
            return res.status(400).json({
                message: '비밀번호와 비밀번호 확인이 일치하지 않습니다.',
            });
        }

        const existingUser = await User.findOne({ email });
        if (existingUser) {
            return res
                .status(400)
                .json({ message: '이미 존재하는 이메일입니다.' });
        }

        const salt = await bcrypt.genSalt(10);
        const hashedPassword = await bcrypt.hash(password, salt);

        const newUser = new User({
            email,
            password: hashedPassword,
        });
        await newUser.save();

        res.status(201).json({
            message: '회원가입이 완료되었습니다.',
        });
    } catch (error) {
        console.error('회원가입 오류:', error);
        res.status(500).json({
            message: '서버 오류가 발생했습니다.',
        });
    }
};

export const login = async(req, res) =&amp;gt; {};

export const logout = async(req, res) =&amp;gt; {};&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;327&quot; data-start=&quot;196&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;250&quot; data-start=&quot;196&quot;&gt;bcrypt.genSaltSync(10); &amp;rarr; 랜덤한 솔트(Salt) 값을 생성&lt;/li&gt;
&lt;li data-end=&quot;327&quot; data-start=&quot;251&quot;&gt;bcrypt.hashSync(req.body.password, salt); &amp;rarr; 솔트를 사용하여 비밀번호를 해싱(암호화)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 newUser.save()를 통해 MongoDB에 사용자 정보를 저장합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4.Postman으로 회원가입 테스트 및 DB 확인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/H3g47/btsNKuFr541/w7wvkr4QIHtEtjkMhwpDE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/H3g47/btsNKuFr541/w7wvkr4QIHtEtjkMhwpDE0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2186&quot; data-origin-height=&quot;1534&quot; data-filename=&quot;스크린샷 2025-05-03 오전 4.52.15.png&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/H3g47/btsNKuFr541/w7wvkr4QIHtEtjkMhwpDE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FH3g47%2FbtsNKuFr541%2Fw7wvkr4QIHtEtjkMhwpDE0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2186&quot; height=&quot;1534&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KQyZ6/btsNJ0LHrFq/0RHMydUhk6a3vM2bLJZ5ZK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KQyZ6/btsNJ0LHrFq/0RHMydUhk6a3vM2bLJZ5ZK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2186&quot; data-origin-height=&quot;1534&quot; data-filename=&quot;스크린샷 2025-05-03 오전 4.52.22.png&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KQyZ6/btsNJ0LHrFq/0RHMydUhk6a3vM2bLJZ5ZK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKQyZ6%2FbtsNJ0LHrFq%2F0RHMydUhk6a3vM2bLJZ5ZK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2186&quot; height=&quot;1534&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;Postman 테스트&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;MongoDB&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-05-03 오전 4.54.40.png&quot; data-origin-width=&quot;2232&quot; data-origin-height=&quot;224&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUf1Ls/btsNKdjF2DX/bHEvSY7oxX6sFb40h28Hpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUf1Ls/btsNKdjF2DX/bHEvSY7oxX6sFb40h28Hpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUf1Ls/btsNKdjF2DX/bHEvSY7oxX6sFb40h28Hpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUf1Ls%2FbtsNKdjF2DX%2FbHEvSY7oxX6sFb40h28Hpk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2232&quot; height=&quot;224&quot; data-filename=&quot;스크린샷 2025-05-03 오전 4.54.40.png&quot; data-origin-width=&quot;2232&quot; data-origin-height=&quot;224&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Node.js/Todo</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/82</guid>
      <comments>https://hy-un.tistory.com/entry/%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EA%B5%AC%ED%98%84-%EB%B0%8F-DB-%EC%97%B0%EA%B2%B0-%EC%84%A4%EC%A0%95#entry82comment</comments>
      <pubDate>Mon, 17 Feb 2025 11:56:38 +0900</pubDate>
    </item>
    <item>
      <title>Node.js 서버 초기 설정 및 라우팅 구성</title>
      <link>https://hy-un.tistory.com/entry/Nodejs-%EC%84%9C%EB%B2%84-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95-%EB%B0%8F-%EB%9D%BC%EC%9A%B0%ED%8C%85-%EA%B5%AC%EC%84%B1</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;MERN 스택을 활용하여 간단한 Todo 앱을 만들려고 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 포스팅에서는 &lt;/span&gt;&lt;span&gt;Node.js 초기 프로젝트 설정과 Auth 및 Todo 라우팅 구성, 그리고 controllers 폴더까지&lt;/span&gt;&lt;span&gt; 구현하는 과정을 정리합니다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;1. 프로젝트 초기화&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;먼저 프로젝트를 초기화하기 위해 &lt;/span&gt;&lt;span&gt;npm init -y&lt;/span&gt;&lt;span&gt; 명령을 실행하여 &lt;/span&gt;&lt;span&gt;package.json&lt;/span&gt;&lt;span&gt;을 생성합니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739750190040&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;todo&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;main&quot;: &quot;index.js&quot;,
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;nodemon src/index.js&quot;,
  },
  &quot;keywords&quot;: [],
  &quot;author&quot;: &quot;&quot;,
  &quot;license&quot;: &quot;ISC&quot;,
  &quot;description&quot;: &quot;&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-pm-slice=&quot;3 1 []&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;nodemon 설치 후 scripts&lt;/span&gt;&lt;span&gt;에 &lt;/span&gt;&lt;span&gt;&quot;dev&quot;: &quot;nodemon src/index.js&quot;&lt;/span&gt;&lt;span&gt; 추가 (저장 시 자동 반영)&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;span&gt;npm run dev&lt;/span&gt;&lt;span&gt;를 실행하면 &lt;/span&gt;&lt;span&gt;nodemon&lt;/span&gt;&lt;span&gt;이 적용되어 코드 변경 시 자동으로 서버가 재시작됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;span&gt;필요한 패키지를 설치하고 &lt;/span&gt;&lt;span&gt;ES 모듈(&lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; 방식)을 사용하기 위해&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;type: module&lt;/span&gt;&lt;span&gt;을 추가합니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1739750263730&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;name&quot;: &quot;backend&quot;,
    &quot;version&quot;: &quot;1.0.0&quot;,
    &quot;main&quot;: &quot;index.js&quot;,
    &quot;scripts&quot;: {
        &quot;dev&quot;: &quot;nodemon src/index.js&quot;
    },
    &quot;keywords&quot;: [],
    &quot;author&quot;: &quot;&quot;,
    &quot;type&quot;: &quot;module&quot;,
    &quot;license&quot;: &quot;ISC&quot;,
    &quot;description&quot;: &quot;&quot;,
    &quot;dependencies&quot;: {
        &quot;bcryptjs&quot;: &quot;^3.0.2&quot;,
        &quot;cookie-parser&quot;: &quot;^1.4.7&quot;,
        &quot;cors&quot;: &quot;^2.8.5&quot;,
        &quot;dotenv&quot;: &quot;^16.5.0&quot;,
        &quot;express&quot;: &quot;^5.1.0&quot;,
        &quot;jsonwebtoken&quot;: &quot;^9.0.2&quot;,
        &quot;mongoose&quot;: &quot;^8.14.1&quot;
    },
    &quot;devDependencies&quot;: {
        &quot;nodemon&quot;: &quot;^3.1.10&quot;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2. Express 서버 설정 (app.js)&lt;/span&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1739750291985&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import express from 'express';

import authRoutes from './routes/auth.route.js';
import todoRoutes from './routes/todo.route.js';

import dotenv from 'dotenv';

dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;

app.use('/api/auth', AuthRoute);
app.use('/api/todo', TodoRoute);

app.listen(PORT, () =&amp;gt; {
    console.log(`Server is running on port ${PORT}`);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재는 &lt;/span&gt;&lt;span&gt;auth 및 todo 라우터 설정을&lt;/span&gt;&lt;span&gt;&amp;nbsp;구성한 상태입니다.&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3. Auth 라우터 설정 (routes/auth.route.js)&lt;/span&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1739750351406&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import express from 'express';
import { register, login, logout } from '../controllers/auth.controller.js';

const router = express.Router();

router.post('/login', login);
router.post('/register', register);
router.post('/logout', logout);

export default router;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;컨트롤러 파일 (controllers/auth.controller.js)&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;controllers/&amp;nbsp;폴더는&amp;nbsp;라우터에서 요청을 처리하는 함수들을 모아두는 곳입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1739750401307&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const register = async (req, res) =&amp;gt; {};

export const login = async (req, res) =&amp;gt; {};

export const logout = async (req, res) =&amp;gt; {};&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 컨트롤러 내부 로직은 작성하지 않았으며, &lt;/span&gt;&lt;span&gt;Postman으로 간단한 API 테스트만 진행&lt;/span&gt;&lt;span&gt;했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&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&gt;5. Todo 라우터 설정 (routes/todo.route.js)&lt;/span&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1739750415296&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import express from 'express';
import { getAllTodos, getTodo, updateTodo, deleteTodo, addTodo } from '../controllers/todo.controller.js';

const router = express.Router();

router.get('/', getAllTodos); // 모든 Todo 조회

router.post('/', addTodo); // Todo 추가

router.put('/:id', updateTodo); // 특정 Todo 수정

router.get('/:id', getTodo); // 특정 Todo 조회

router.delete('/:id', deleteTodo); // 특정 Todo 삭제

export default router;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러 파일(controllers/todo.controller.js)&lt;/p&gt;
&lt;pre id=&quot;code_1739750439189&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const getAllTodos = async (req, res) =&amp;gt; {};

export const addTodo = async (req, res) =&amp;gt; {};

export const updateTodo = async (req, res) =&amp;gt; {};

export const getTodo = async (req, res) =&amp;gt; {};

export const deleteTodo = async (req, res) =&amp;gt; {};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 &lt;/span&gt;&lt;span&gt;controllers/todo.controller.js&lt;/span&gt;&lt;span&gt;도 컨트롤러 로직은 작성하지 않았고, &lt;/span&gt;&lt;span&gt;Postman을 이용해 라우팅이 정상적으로 동작하는지 테스트&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;</description>
      <category>Node.js/Todo</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/81</guid>
      <comments>https://hy-un.tistory.com/entry/Nodejs-%EC%84%9C%EB%B2%84-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95-%EB%B0%8F-%EB%9D%BC%EC%9A%B0%ED%8C%85-%EA%B5%AC%EC%84%B1#entry81comment</comments>
      <pubDate>Mon, 17 Feb 2025 09:36:12 +0900</pubDate>
    </item>
    <item>
      <title>쿠키와 세션을 활용한 로그인 구현 및 보안 개선</title>
      <link>https://hy-un.tistory.com/entry/%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%9A%94%EC%B2%AD%EA%B3%BC-%EC%BF%A0%ED%82%A4%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B4%ED%95%B4</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 클라이언트 요청의 한계&lt;/h4&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;IP 주소나 브라우저 정보를 통해 일부 확인할 수 있지만, 같은 IP를 여러 컴퓨터가 공유하거나 한 컴퓨터를 여러 사람이 사용할 수도 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 로그인으로 해결하기&lt;/h4&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;li&gt;서버는 응답 시 쿠키를 전송하고, 브라우저는 이를 저장해 요청 시마다 자동으로 쿠키를 포함해 보냅니다.&lt;/li&gt;
&lt;li&gt;서버는 요청에 포함된 쿠키를 읽어 사용자를 식별합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 쿠키의 특징&lt;/h4&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;li&gt;쿠키는 요청 헤더에 담겨 전송되고, 브라우저는 응답 헤더에 따라 쿠키를 저장합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;쿠키 기반 간단한 로그인 구현(개선 전)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;HTML 코드&lt;/h3&gt;
&lt;pre id=&quot;code_1737375734697&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;쿠키 &amp;amp; 세션 이해하기&amp;lt;/title&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;form action=&quot;/login&quot;&amp;gt;
        &amp;lt;input type=&quot;text&quot; id=&quot;name&quot; name=&quot;name&quot; placeholder=&quot;이름을 입력하세요&quot; required&amp;gt;
        &amp;lt;button type=&quot;submit&quot;&amp;gt;로그인&amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Node.js 서버 코드&lt;/h3&gt;
&lt;pre id=&quot;code_1737375749120&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const http = require('http');
const fs = require('fs').promises;
const path = require('path');

const PORT = 8080;
const COOKIE_PATH = '/';

// 쿠키 파싱 함수
const parseCookies = (cookie = '') =&amp;gt;
    cookie
        .split(';')
        .map((v) =&amp;gt; v.split('='))
        .reduce((acc, [k, v]) =&amp;gt; {
            acc[k.trim()] = decodeURIComponent(v);
            return acc;
        }, {});

// 서버 생성
const server = http.createServer(async (request, response) =&amp;gt; {
    const cookies = parseCookies(request.headers.cookie);

    if (request.url.startsWith('/login')) {
        handleLogin(request, response);
    } else if (cookies.name) {
        greetUser(response, cookies.name);
    } else {
        serveHtml(response);
    }
});

// 로그인 요청 처리
const handleLogin = (request, response) =&amp;gt; {
    const url = new URL(request.url, `http://localhost:${PORT}`);
    const name = url.searchParams.get('name');

    if (!name) {
        response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
        response.end('이름이 제공되지 않았습니다.');
        return;
    }

    const expires = new Date();
    expires.setMinutes(expires.getMinutes() + 5);

    response.writeHead(302, {
        Location: '/',
        'Set-Cookie': `name=${encodeURIComponent(name)}; Expires=${expires.toGMTString()}; HttpOnly; Path=${COOKIE_PATH}`,
    });
    response.end();
};

// 사용자 환영 메시지
const greetUser = (response, name) =&amp;gt; {
    response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
    response.end(`${name}님 안녕하세요.`);
};

// HTML 파일 제공
const serveHtml = async (response) =&amp;gt; {
    try {
        const filePath = path.join(__dirname, 'cookie.html');
        const data = await fs.readFile(filePath);

        response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
        response.end(data);
    } catch (err) {
        response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
        response.end(`파일 읽기 실패: ${err.message}`);
    }
};

// 서버 시작
server.listen(PORT, () =&amp;gt; {
    console.log(`${PORT}번 포트에서 서버가 실행 중입니다.`);
});&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;h3 data-message-model-slug=&quot;gpt-4o&quot; data-message-id=&quot;56ca4870-4f26-4bb0-8c9b-fc624cf6c827&quot; data-message-author-role=&quot;assistant&quot; data-ke-size=&quot;size23&quot;&gt;1. parseCookies 함수&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;parseCookies는 쿠키 문자열을 객체로 변환하는 함수입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 쿠키 문자열이 아래와 같다면&lt;/p&gt;
&lt;pre id=&quot;code_1737377399348&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;name=John; age=30&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같은 객체를 반환합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1737377409285&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{ name: &quot;John&quot;, age: &quot;30&quot; }&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 쿠키 옵션&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;HttpOnly: JavaScript가 쿠키에 접근하지 못하도록 설정.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이를 통해 XSS 공격을 방지할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Expires: 쿠키의 만료 시간을 지정.
&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;세션 쿠키로 사용하려면 Expires를 설정하지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Path: 쿠키가 유효한 경로를 지정.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본값은 쿠키를 설정한 경로이며, 특정 경로에서만 쿠키가 유효하도록 설정할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠키와 관련된 상세한 옵션이나 사용 방법은 아래에서 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ko.javascript.info/cookie&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ko.javascript.info/cookie&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1737377466805&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;쿠키와 document.cookie&quot; data-og-description=&quot;&quot; data-og-host=&quot;ko.javascript.info&quot; data-og-source-url=&quot;https://ko.javascript.info/cookie&quot; data-og-url=&quot;https://ko.javascript.info/cookie&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/eoan8D/hyX4vUBfjh/VPsb2qI9QYkAQ1Gnf3f0I1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/8qGrB/hyX4rq7S6p/Ronls6mqaPJgKlxvpKv8qk/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512&quot;&gt;&lt;a href=&quot;https://ko.javascript.info/cookie&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ko.javascript.info/cookie&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/eoan8D/hyX4vUBfjh/VPsb2qI9QYkAQ1Gnf3f0I1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/8qGrB/hyX4rq7S6p/Ronls6mqaPJgKlxvpKv8qk/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_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;쿠키와 document.cookie&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;ko.javascript.info&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠키 기반의 로그인 방식은 몇 가지 보안 문제를 가질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저의 Application 탭에서 쿠키가 쉽게 노출됩니다.&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-01-20 오후 9.31.42.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;618&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxnBw7/btsLSMoJhut/GpZh8U2bMHlsCY5tinSTnK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxnBw7/btsLSMoJhut/GpZh8U2bMHlsCY5tinSTnK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxnBw7/btsLSMoJhut/GpZh8U2bMHlsCY5tinSTnK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcxnBw7%2FbtsLSMoJhut%2FGpZh8U2bMHlsCY5tinSTnK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;151&quot; data-filename=&quot;스크린샷 2025-01-20 오후 9.31.42.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;618&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;민감한 정보를 쿠키에 저장하지 않고, 서버가 관리하도록 설계해야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쿠키에는 민감한 데이터를 저장하지 않고, 세션 ID만 저장합니다.&lt;/li&gt;
&lt;li&gt;세션 ID를 통해 사용자를 식별하고, 실제 민감한 정보는 서버에서만 관리합니다.&lt;/li&gt;
&lt;li&gt;브라우저는 세션 ID를 이용해 서버와 통신하며, 모든 민감한 데이터는 서버의 세션에 저장됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;세션 기반 로그인 코드(개선 후)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;민감한 정보를 쿠키에 저장하지 않고, 세션 ID를 쿠키에 저장하며 사용자 정보를 서버에서 관리합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1737377669384&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const http = require('http');
const fs = require('fs').promises;
const path = require('path');

const PORT = 8080;
const COOKIE_PATH = '/';
const session = {};

// 쿠키 파싱 함수
const parseCookies = (cookie = '') =&amp;gt;
    cookie
        .split(';')
        .map((v) =&amp;gt; v.split('='))
        .reduce((acc, [k, v]) =&amp;gt; {
            acc[k.trim()] = decodeURIComponent(v);
            return acc;
        }, {});

// 서버 생성
const server = http.createServer(async (request, response) =&amp;gt; {
    const cookies = parseCookies(request.headers.cookie);

    if (request.url.startsWith('/login')) {
        handleLogin(request, response);
    } else if (cookies.session &amp;amp;&amp;amp; session[cookies.session]?.expires &amp;gt; new Date()) {
        greetUser(response, cookies.session);
    } else {
        serveHtml(response);
    }
});

// 로그인 요청 처리
const handleLogin = (request, response) =&amp;gt; {
    const url = new URL(request.url, `http://localhost:${PORT}`);
    const name = url.searchParams.get('name');

    if (!name) {
        response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
        response.end('이름이 제공되지 않았습니다.');
        return;
    }

    const sessionId = String(Date.now());
    const expires = new Date();
    expires.setMinutes(expires.getMinutes() + 5);

    session[sessionId] = { name, expires };

    response.writeHead(302, {
        Location: '/',
        'Set-Cookie': `session=${sessionId}; Expires=${expires.toGMTString()}; HttpOnly; Path=${COOKIE_PATH}`,
    });
    response.end();
};

// 사용자 환영 메시지
const greetUser = (response, sessionId) =&amp;gt; {
    const { name } = session[sessionId];
    response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8;' });
    response.end(`${name}님 안녕하세요.`);
};

// HTML 파일 제공
const serveHtml = async (response) =&amp;gt; {
    try {
        const filePath = path.join(__dirname, 'cookie.html');
        const data = await fs.readFile(filePath);
        response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
        response.end(data);
    } catch (err) {
        response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
        response.end(`파일 읽기 실패: ${err.message}`);
    }
};

// 서버 시작
server.listen(PORT, () =&amp;gt; {
    console.log(`${PORT}번 포트에서 서버가 실행 중입니다.`);
});&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;스크린샷 2025-01-20 오후 9.54.13.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;618&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zUfC6/btsLSTn4TOG/zik2EcfefkAufWLyVNlmw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zUfC6/btsLSTn4TOG/zik2EcfefkAufWLyVNlmw0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zUfC6/btsLSTn4TOG/zik2EcfefkAufWLyVNlmw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzUfC6%2FbtsLSTn4TOG%2Fzik2EcfefkAufWLyVNlmw0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;151&quot; data-filename=&quot;스크린샷 2025-01-20 오후 9.54.13.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;618&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>Node.js/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/80</guid>
      <comments>https://hy-un.tistory.com/entry/%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%9A%94%EC%B2%AD%EA%B3%BC-%EC%BF%A0%ED%82%A4%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B4%ED%95%B4#entry80comment</comments>
      <pubDate>Mon, 20 Jan 2025 22:05:38 +0900</pubDate>
    </item>
    <item>
      <title>Node.js로 RESTful Server 만들어보기</title>
      <link>https://hy-un.tistory.com/entry/Nodejs%EB%A1%9C-RESTful-Server-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B0</link>
      <description>&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;HTML 코드는 간단하게 작성되어 있어, 이 블로그 글에 첨부하지 않을 예정입니다.&lt;br /&gt;아래 깃허브 주소를 통해 확인해주세요.&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;&lt;a href=&quot;https://github.com/GangHyun95/restful-api-practice&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/GangHyun95/restful-api-practice&lt;/a&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1736766540556&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;GitHub - GangHyun95/restful-api-practice: practice&quot; data-og-description=&quot;practice. Contribute to GangHyun95/restful-api-practice development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/GangHyun95/restful-api-practice&quot; data-og-url=&quot;https://github.com/GangHyun95/restful-api-practice&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/SkuNe/hyX0uPkGo4/mqVe2yQS0mIJWkl6TQ8JXK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/oPaF2/hyX0y5gJVw/c3IoOBlX2SUuD1Lommp6wk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/GangHyun95/restful-api-practice&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/GangHyun95/restful-api-practice&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/SkuNe/hyX0uPkGo4/mqVe2yQS0mIJWkl6TQ8JXK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/oPaF2/hyX0y5gJVw/c3IoOBlX2SUuD1Lommp6wk/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;GitHub - GangHyun95/restful-api-practice: practice&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;practice. Contribute to GangHyun95/restful-api-practice 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;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span&gt;RESTful Server란?&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RESTful Server는 REST 규칙을 잘 준수하는 API 서버를 의미합니다. REST는 자원을 URL로 표현하고, HTTP 메서드(GET, POST, PUT, DELETE 등)를 활용하여 CRUD(Create, Read, Update, Delete) 작업을 수행하는 구조를 따릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;/users URL에 GET 요청을 보내면 모든 사용자 목록을 조회할 수 있습니다.&lt;/li&gt;
&lt;li&gt;/user/:id URL에 PUT 요청을 보내면 특정 사용자를 수정할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;서버 코드&lt;/b&gt;&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;GET 요청&lt;/b&gt;: 사용자 목록 제공&lt;/li&gt;
&lt;li&gt;&lt;b&gt;POST 요청&lt;/b&gt;: 새 사용자 추가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PUT 요청&lt;/b&gt;: 사용자 정보 수정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DELETE 요청&lt;/b&gt;: 사용자 삭제&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1736766673504&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 http = require('http');
const fs = require('fs').promises;
const path = require('path');

const users = {}; // 사용자 데이터를 저장할 메모리 객체

const sendResponse = (res, statusCode, contentType, data) =&amp;gt; {
    res.writeHead(statusCode, { 'Content-Type': `${contentType}; charset=utf-8` });
    res.end(data);
};

const handleFileRequest = async (res, filePath, contentType) =&amp;gt; {
    try {
        const data = await fs.readFile(filePath);
        sendResponse(res, 200, contentType, data);
    } catch (error) {
        sendResponse(res, 404, 'text/plain', 'NOT FOUND');
    }
};

// GET 요청 처리
const handleGetRequest = async (req, res) =&amp;gt; {
    if (req.url === '/') {
        return handleFileRequest(res, path.join(__dirname, 'restFront.html'), 'text/html');
    } else if (req.url === '/users') {
        return sendResponse(res, 200, 'application/json', JSON.stringify(users));
    }
    return handleFileRequest(res, path.join(__dirname, req.url), 'application/octet-stream');
};

// POST 요청 처리
const handlePostRequest = (req, res) =&amp;gt; {
    if (req.url === '/user') {
        let body = '';
        req.on('data', (data) =&amp;gt; {
            body += data;
        });
        req.on('end', () =&amp;gt; {
            try {
                const { name } = JSON.parse(body);
                const id = Date.now();
                users[id] = name;
                sendResponse(res, 201, 'text/plain', '등록 성공');
            } catch (error) {
                sendResponse(res, 400, 'text/plain', 'Invalid JSON');
            }
        });
    } else {
        sendResponse(res, 404, 'text/plain', 'NOT FOUND');
    }
};

// PUT 요청 처리
const handlePutRequest = (req, res) =&amp;gt; {
    if (req.url.startsWith('/user/')) {
        const key = req.url.split('/')[2];
        let body = '';
        req.on('data', (data) =&amp;gt; {
            body += data;
        });
        req.on('end', () =&amp;gt; {
            try {
                const { name } = JSON.parse(body);
                if (users[key]) {
                    users[key] = name;
                    sendResponse(res, 200, 'application/json', JSON.stringify(users));
                } else {
                    sendResponse(res, 404, 'text/plain', 'User not found');
                }
            } catch (error) {
                sendResponse(res, 400, 'text/plain', 'Invalid JSON');
            }
        });
    } else {
        sendResponse(res, 404, 'text/plain', 'NOT FOUND');
    }
};

// DELETE 요청 처리
const handleDeleteRequest = (req, res) =&amp;gt; {
    if (req.url.startsWith('/user/')) {
        const key = req.url.split('/')[2];
        if (users[key]) {
            delete users[key];
            sendResponse(res, 200, 'application/json', JSON.stringify(users));
        } else {
            sendResponse(res, 404, 'text/plain', 'User not found');
        }
    } else {
        sendResponse(res, 404, 'text/plain', 'NOT FOUND');
    }
};

http.createServer(async (req, res) =&amp;gt; {
    try {
        console.log(req.method, req.url);

        if (req.method === 'GET') {
            await handleGetRequest(req, res);
        } else if (req.method === 'POST') {
            handlePostRequest(req, res);
        } else if (req.method === 'PUT') {
            handlePutRequest(req, res);
        } else if (req.method === 'DELETE') {
            handleDeleteRequest(req, res);
        } else {
            sendResponse(res, 405, 'text/plain', 'Method Not Allowed');
        }
    } catch (error) {
        console.error(error);
        sendResponse(res, 500, 'text/plain', 'Internal Server Error');
    }
}).listen(8080, () =&amp;gt; {
    console.log('8080번 포트에서 서버 대기 중입니다.');&lt;/code&gt;&lt;/pre&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&gt;req.on('data', ...)&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&gt;Node.js의 HTTP 서버는 요청 데이터를 스트림(Stream)으로 처리합니다.&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&gt;클라이언트에서 전송한 데이터가 한 번에 도착하지 않고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;조각(chunk)&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;단위로 나누어 도착합니다. 이 데이터를 처리하기 위해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;req.on('data', ...)&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이벤트를 사용합니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1736766673510&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;let body = '';
req.on('data', (data) =&amp;gt; {
    body += data; // 들어오는 데이터 조각을 누적
});
req.on('end', () =&amp;gt; {
    console.log('데이터 수신 완료:', body);
});&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot; data-spread=&quot;false&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;큰 데이터 처리&lt;/b&gt;&lt;/span&gt;&lt;span&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;: 데이터를 조각 단위로 처리하여, I/O 작업의 효율성을 극대화합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;클라이언트 코드&lt;/b&gt;&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;사용자 목록 조회 (GET)&lt;/li&gt;
&lt;li&gt;사용자 추가 (POST)&lt;/li&gt;
&lt;li&gt;사용자 수정 (PUT)&lt;/li&gt;
&lt;li&gt;사용자 삭제 (DELETE)&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1736765336815&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// GET
async function fetchUsers() {
    try {
        const response = await fetch('/users');
        if (!response.ok) {
            throw new Error('Failed to fetch users: ' + response.statusText);
        }
        return await response.json();
    } catch (error) {
        console.error(error);
        alert('사용자 목록을 가져오는 데 실패했습니다.');
        return {};
    }
}

// PUT 
async function updateUser(key, name) {
    try {
        const response = await fetch(`/user/${key}`, {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ name }),
        });
        if (!response.ok) {
            throw new Error('Failed to update user: ' + response.statusText);
        }
    } catch (error) {
        console.error(error);
        alert('사용자 수정에 실패했습니다.');
    }
}

// DELETE
async function deleteUser(key) {
    try {
        const response = await fetch(`/user/${key}`, {
            method: 'DELETE',
        });
        if (!response.ok) {
            throw new Error('Failed to delete user: ' + response.statusText);
        }
    } catch (error) {
        console.error(error);
        alert('사용자 삭제에 실패했습니다.');
    }
}

// POST
async function addUser(name) {
    try {
        const response = await fetch('/user', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ name }),
        });
        if (!response.ok) {
            throw new Error('Failed to add user: ' + response.statusText);
        }
    } catch (error) {
        console.error(error);
        alert('사용자 추가에 실패했습니다.');
    }
}

function createUserElement(key, name) {
    const userDiv = document.createElement('div');
    userDiv.innerHTML = `
        &amp;lt;span&amp;gt;${name}&amp;lt;/span&amp;gt;
        &amp;lt;button class=&quot;edit-button&quot;&amp;gt;수정&amp;lt;/button&amp;gt;
        &amp;lt;button class=&quot;delete-button&quot;&amp;gt;삭제&amp;lt;/button&amp;gt;
    `;

    const editButton = userDiv.querySelector('.edit-button');
    editButton.addEventListener('click', async () =&amp;gt; {
        const newName = prompt('바꿀 이름을 입력하세요.', name);
        if (!newName) {
            return alert('이름을 반드시 입력하셔야 합니다.');
        }
        await updateUser(key, newName);
        await renderUserList();
    });

    const deleteButton = userDiv.querySelector('.delete-button');
    deleteButton.addEventListener('click', async () =&amp;gt; {
        if (confirm('정말로 삭제하시겠습니까?')) {
            await deleteUser(key);
            await renderUserList();
        }
    });

    return userDiv;
}

async function renderUserList() {
    const users = await fetchUsers();
    const list = document.querySelector('#list');
    list.innerHTML = '';

    Object.entries(users).forEach(([key, name]) =&amp;gt; {
        const userElement = createUserElement(key, name);
        list.appendChild(userElement);
    });
}

document.querySelector('#form').addEventListener('submit', async (e) =&amp;gt; {
    e.preventDefault();
    const name = e.target.username.value.trim();
    if (!name) {
        return alert('이름을 입력하세요.');
    }
    await addUser(name);
    await renderUserList();
    e.target.username.value = '';
});

document.addEventListener('DOMContentLoaded', renderUserList);&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Node.js/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/78</guid>
      <comments>https://hy-un.tistory.com/entry/Nodejs%EB%A1%9C-RESTful-Server-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B0#entry78comment</comments>
      <pubDate>Mon, 13 Jan 2025 20:05:09 +0900</pubDate>
    </item>
    <item>
      <title>Node.js로 HTTP 서버 만들어보기</title>
      <link>https://hy-un.tistory.com/entry/Nodejs%EB%A1%9C-HTTP-%EC%84%9C%EB%B2%84-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B0</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. HTTP 프로토콜이란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP(HyperText Transfer Protocol)는 클라이언트(브라우저)와 서버가 요청(Request)과 응답(Response)을 주고받는 공통된 언어입니다.&lt;br /&gt;예를 들어, 사용자가 서버에 메인 페이지를 요청하면, 서버는 이 요청을 이해하고 적절한 데이터를 응답으로 전달합니다.&lt;br /&gt;서버 프로그래밍을 할 때는 반드시 HTTP 프로토콜 규약을 따라야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Node.js에서 HTTP 서버 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node.js에서는 http 모듈을 사용하여 간단하게 HTTP 서버를 만들 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1736147888905&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const http = require('http');

const server = http.createServer((req, res) =&amp;gt; {
    // 응답 데이터 전송
    res.write('&amp;lt;h1&amp;gt;Hello Node!&amp;lt;/h1&amp;gt;');
    res.write('&amp;lt;p&amp;gt;Hello Server&amp;lt;/p&amp;gt;');
    res.end('&amp;lt;p&amp;gt;Bye&amp;lt;/p&amp;gt;');
});

server.listen(8080); // 서버 8080 포트로 실행

server.on('listening', () =&amp;gt; {
    console.log('8080번 포트에서 서버 대기 중입니다.');
});

server.on('error', (error) =&amp;gt; {
    console.error(error);
});&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;http.createServer: 클라이언트의 요청이 들어올 때 실행됩니다.&lt;/li&gt;
&lt;li&gt;res.write: 응답 데이터를 스트림 형태로 클라이언트에 전달합니다.&lt;/li&gt;
&lt;li&gt;res.end: 응답을 종료합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Content-Type과 헤더 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 브라우저에서는 응답 데이터를 HTML로 처리하지 못하는 경우가 있습니다.&lt;br /&gt;이를 방지하려면 응답 헤더에 Content-Type을 설정해야 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1736147923538&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;b&gt;Content-Type&lt;/b&gt;: 데이터 형식을 지정합니다. (text/html은 HTML 문서를 의미)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;charset&lt;/b&gt;: 데이터의 문자 인코딩을 설정합니다. (utf-8은 한글 지원)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. HTML 코드 분리하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTML 코드를 res.write로 작성하면 유지보수가 어렵고 비효율적입니다.&lt;br /&gt;이를 개선하기 위해 HTML 파일을 별도로 작성하고, Node.js의 fs 모듈을 사용하여 파일을 읽는 방식으로 변경합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;HTML 파일 예시 (server1.html)&lt;/h4&gt;
&lt;pre id=&quot;code_1736147950721&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;title&amp;gt;Node.js 웹 서버&amp;lt;/title&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;h1&amp;gt;Node.js 웹 서버&amp;lt;/h1&amp;gt;
    &amp;lt;p&amp;gt;공부 중&amp;lt;/p&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Node.js 코드 수정&lt;/h4&gt;
&lt;pre id=&quot;code_1736147970283&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const http = require('http');
const fs = require('fs').promises;
const path = require('path');

const server = http.createServer(async (req, res) =&amp;gt; {
    try {
        // HTML 파일 읽기
        const filePath = path.join(__dirname, 'server1.html');
        const data = await fs.readFile(filePath);

        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end(data);
    } catch (error) {
        console.error(error);

        // 에러 응답
        res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
        res.end('파일을 읽는 중 오류가 발생했습니다.');
    }
});

server.listen(8080);

server.on('listening', () =&amp;gt; {
    console.log('8080번 포트에서 서버 대기 중입니다.');
});

server.on('error', (error) =&amp;gt; {
    console.error(error);
});&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Node.js/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/77</guid>
      <comments>https://hy-un.tistory.com/entry/Nodejs%EB%A1%9C-HTTP-%EC%84%9C%EB%B2%84-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B0#entry77comment</comments>
      <pubDate>Mon, 6 Jan 2025 16:22:06 +0900</pubDate>
    </item>
    <item>
      <title>Node.js 란?</title>
      <link>https://hy-un.tistory.com/entry/Nodejs-%EB%9E%80</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Node.js는 크롬 V8 자바스크립트 엔진을 기반으로 빌드된 자바스크립트 런타임입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node.js의 등장으로 자바스크립트는 HTML과 브라우저의 종속성에서 벗어나, 브라우저 없이도 실행 가능한 언어로 확장되었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Node.js란 ?&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;자바스크립트와 Node.js의 차이&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;Node.js는 자바스크립트 런타임으로, 자바스크립트로 작성된 프로그램을 실행할 수 있는 환경을 제공합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Node.js는 서버인가요?&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;아닙니다. Node.js는 서버 역할도 수행할 수 있는 자바스크립트 런타임입니다.&lt;/li&gt;
&lt;li&gt;Node.js는 HTTP, HTTPS, HTTP/2 등 서버 실행에 필요한 모듈을 제공하며, 이를 활용해 자바스크립트로 작성된 서버를 실행할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Node.js의 핵심 구성 요소&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;V8 엔진&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;/li&gt;
&lt;li&gt;&lt;b&gt;libuv&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이벤트 기반, 논블로킹 I/O 모델을 구현한 라이브러리입니다.&lt;/li&gt;
&lt;li&gt;Node.js의 주요 특징인 비동기 I/O와 이벤트 루프를 지원합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Node.js의 주요 특징&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;논블로킹 I/O 모델&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;li&gt;일부 작업(I/O)은 백그라운드에서 병렬로 처리되고, 나머지 작업은 싱글 스레드로 실행됩니다.&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;Node.js는 싱글 스레드 기반으로 동작합니다.&lt;/li&gt;
&lt;li&gt;그러나 내부적으로 멀티 스레드를 활용하며, 프로그래머가 직접 다룰 수 있는 스레드는 하나뿐입니다.&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;Node.js는 멀티 스레드 대신 멀티 프로세스를 활용하는 경우가 많습니다.&lt;/li&gt;
&lt;li&gt;프로세스: 운영체제에서 할당하는 작업의 단위 (프로세스 간 작업 공유 불가).&lt;/li&gt;
&lt;li&gt;스레드: 프로세스 내에서 실행되는 작업의 단위 (부모 프로세스의 자원을 공유).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;싱글 스레드와 논블로킹 I/O의 장단점&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&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;장점:
&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;CPU와 메모리 자원을 적게 사용합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;단점:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;블로킹 작업 발생 시 다른 작업이 모두 대기 상태가 됩니다.&lt;/li&gt;
&lt;li&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;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;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;단점:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프로그래밍 난이도가 높습니다.&lt;/li&gt;
&lt;li&gt;새로운 스레드 생성이나 유휴 스레드 관리에 비용이 발생합니다.&lt;/li&gt;
&lt;li&gt;스레드 수만큼 자원을 많이 사용합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Node.js 서버의 특징&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Node.js는 서버를 구성할 수 있는 다양한 모듈을 제공합니다.&lt;/li&gt;
&lt;li&gt;HTTP/HTTPS 모듈을 활용해 서버를 손쉽게 구축할 수 있으며, 논블로킹 I/O 모델로 높은 성능을 발휘합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실행 예제와 결과&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동기적 실행 예제&lt;/h3&gt;
&lt;pre id=&quot;code_1736036932930&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function longRunningTask() {
  console.log('작업 끝');
}
console.log('시작');
longRunningTask();
console.log('다음 작업');&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1736036990718&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;시작
작업 끝
다음 작업&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;모든 작업이 순차적으로 실행됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;하나의 작업이 완료될 때까지 다음 작업은 대기합니다(블로킹 방식).&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;비동기 실행 예제&lt;/span&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1736037020462&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function longRunningTask() {
  console.log('작업 끝');
}
console.log('시작');
setTimeout(longRunningTask, 0);
console.log('다음 작업');&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1736037036306&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;시작
다음 작업
작업 끝&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;JavaScript는 기본적으로 동기적으로 코드를 실행하지만, 비동기 코드를 만나면 작업을 Web API 또는 Node.js 환경(libuv)으로 넘깁니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Web API 또는 libuv는 타이머, 네트워크 요청 등의 작업을 백그라운드에서 처리합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이후 콜 스택이 비게 되면, 이벤트 루프가 콜백 큐에서 작업을 가져와 콜 스택에 올려 실행합니다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>Node.js/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/76</guid>
      <comments>https://hy-un.tistory.com/entry/Nodejs-%EB%9E%80#entry76comment</comments>
      <pubDate>Sun, 5 Jan 2025 09:34:54 +0900</pubDate>
    </item>
    <item>
      <title>Zustand로 상태 관리, 포켓몬 검색 기능 구현하기</title>
      <link>https://hy-un.tistory.com/entry/Zustand%EB%A1%9C-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC-%ED%8F%AC%EC%BC%93%EB%AA%AC-%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
      <description>&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;스크린샷 2025-01-02 오전 10.12.06.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1644&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMsh4M/btsLC49F85q/VePUigLMGeAOT19Zn5hymk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMsh4M/btsLC49F85q/VePUigLMGeAOT19Zn5hymk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMsh4M/btsLC49F85q/VePUigLMGeAOT19Zn5hymk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMsh4M%2FbtsLC49F85q%2FVePUigLMGeAOT19Zn5hymk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;403&quot; data-filename=&quot;스크린샷 2025-01-02 오전 10.12.06.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1644&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;POKE API에서는 기본적으로 검색 기능을 제공하지 않습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 전체 포켓몬 리스트를 가져와 자체적으로 필터링하여 검색 기능을 구현하기로 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;검색 기능을 구현하면서, 검색 결과에 대한 페이지네이션, 필터 기능 등을 추가 구현하기 위해 포켓몬 데이터를 여러 컴포넌트에서 재사용해야 하는 상황이 발생했습니다.&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;상위 루트에 데이터를 정의하고 props로 내려주는 방식보다는, 전역 상태 관리를 통해 구현하는 것이 가독성 면에서 더 좋고 관리하기도 편리할 것이라고 판단하여 Zustand를 사용했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;또한, 기존에 모든 타입 지정과 서버 API 요청 등을 하나의 파일에 작성하던 방식을 개선하여, 다음과 같이 파일을 분리했습니다.&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&gt;&lt;b&gt;types.ts&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 타입 정의.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;api.ts&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 서버 API 요청.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;usePokemonStore.ts&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: Zustand로 상태 관리.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;1. 타입 정의 (&lt;/span&gt;&lt;span&gt;types.ts&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;포켓몬과 관련된 데이터를 다루기 위해 필요한 타입을 정의했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1735779523707&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// lib/types.ts

export type Pokemon = {
    name: string;
    url: string;
};

export type PokemonDetail = {
    id: number;
    name: string;
    abilities: AbilitiesType[];
    types: PokemonType[];
    stats: StatsType[];
    height: number;
    weight: number;
    base_experience: number;
    cries: {
        legacy: string;
        latest: string;
    };
    sprites: {
        front_default: string;
        other?: {
            home?: {
                front_default?: string;
                front_shiny?: string;
            };
            showdown?: {
                front_default?: string;
            };
        };
    };
};

type PokemonType = {
    type: {
        name: string;
        url: string;
    };
};

type AbilitiesType = {
    ability: {
        name: string;
        url: string;
    };
};

type StatsType = {
    base_stat: number;
    stat: {
        name: string;
        url: string;
    };
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2. API 요청 (&lt;/span&gt;&lt;span&gt;api.ts&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;POKE API와 통신하는 모든 함수를 정리했습니다. 이 파일은 API 호출의 책임만을 가지며, 다른 파일과의 의존성을 최소화했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1735779539246&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// lib/api.ts

import { Pokemon, PokemonDetail } from './types';

const pokemonBaseUrl = 'https://pokeapi.co/api/v2';

export async function fetchPokemon(page = 1): Promise&amp;lt;{ count: number; results: Pokemon[] }&amp;gt; {
    const offset = (page - 1) * 20;
    const response = await fetch(`${pokemonBaseUrl}/pokemon?limit=20&amp;amp;offset=${offset}`);
    if (!response.ok) {
        throw new Error('포켓몬 목록을 가져오는 데 실패했습니다.');
    }
    const data = await response.json();
    return { count: data.count, results: data.results };
}

export async function fetchAllPokemon(): Promise&amp;lt;Pokemon[]&amp;gt; {
    const response = await fetch(`${pokemonBaseUrl}/pokemon?limit=1118`);
    if (!response.ok) {
        throw new Error('전체 포켓몬 목록을 가져오는 데 실패했습니다.');
    }
    const data = await response.json();
    return data.results;
}

export async function fetchPokemonDetails(pokemonList: { url: string }[]): Promise&amp;lt;PokemonDetail[]&amp;gt; {
    const details = await Promise.all(
        pokemonList.map(async (pokemon) =&amp;gt; {
            const response = await fetch(pokemon.url);
            if (!response.ok) {
                throw new Error(`포켓몬 상세 정보를 가져오는 데 실패했습니다. URL: ${pokemon.url}`);
            }
            return await response.json();
        })
    );
    return details;
}

export async function fetchPokemonByName(name: string): Promise&amp;lt;PokemonDetail&amp;gt; {
    const response = await fetch(`${pokemonBaseUrl}/pokemon/${name}`);
    if (!response.ok) {
        throw new Error(`포켓몬(${name}) 정보를 가져오는 데 실패했습니다.`);
    }
    return await response.json();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;b&gt;fetchPokemon&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 페이지 번호를 기반으로 포켓몬 리스트를 가져옵니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;b&gt;fetchAllPokemon&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 전체 포켓몬 리스트를 가져옵니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;b&gt;fetchPokemonDetails&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 포켓몬 상세 정보를 가져옵니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;b&gt;fetchPokemonByName&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 특정 이름의 포켓몬 정보를 가져옵니다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3. Zustand로 상태 관리 (&lt;/span&gt;&lt;span&gt;usePokemonStore.ts&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Zustand를 활용하여 포켓몬 데이터를 효율적으로 관리했습니다. 각종 데이터 요청 및 검색 로직을 하나의 스토어로 통합했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1735779432541&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// store/usePokemonStore.ts

import { create } from 'zustand';
import { fetchAllPokemon, fetchPokemon, fetchPokemonDetails } from '@/lib/api';
import { Pokemon, PokemonDetail } from '@/lib/types';

type PokemonStore = {
    count: number;
    loading: boolean;
    currentPage: number;
    pokemonList: Pokemon[];
    allPokemon: Pokemon[];
    searchQuery: string;
    pokemonListDetails: Record&amp;lt;number | string, PokemonDetail[]&amp;gt;;
    fetchPokemon: (page: number) =&amp;gt; Promise&amp;lt;void&amp;gt;;
    fetchAllPokemon: () =&amp;gt; Promise&amp;lt;void&amp;gt;;
    fetchPokemonDetails: (page: number) =&amp;gt; Promise&amp;lt;void&amp;gt;;
    searchPokemon: (query: string) =&amp;gt; void;
    updateSearchQuery: (query: string) =&amp;gt; void;
};

let debounceTimer: NodeJS.Timeout | null = null;

export const usePokemonStore = create&amp;lt;PokemonStore&amp;gt;((set, get) =&amp;gt; ({
    count: 0,
    loading: false,
    currentPage: 1,
    pokemonList: [],
    allPokemon: [],
    pokemonListDetails: {},
    searchQuery: '',

    fetchPokemon: async (page = 1) =&amp;gt; {
        set({ loading: true });
        try {
            const data = await fetchPokemon(page);
            set({
                count: data.count,
                pokemonList: data.results,
                currentPage: page,
                loading: false,
            });
        } catch (error) {
            console.error('Failed to fetch Pok&amp;eacute;mon list:', error);
            set({ loading: false });
        }
    },

    fetchAllPokemon: async () =&amp;gt; {
        set({ loading: true });
        try {
            const results = await fetchAllPokemon();
            set({ allPokemon: results, loading: false });
        } catch (error) {
            console.error('Failed to fetch all Pok&amp;eacute;mon:', error);
            set({ loading: false });
        }
    },

    fetchPokemonDetails: async (page = 1) =&amp;gt; {
        const { pokemonList, pokemonListDetails } = get();
        if (pokemonListDetails[page]) return;

        set({ loading: true });
        try {
            const details = await fetchPokemonDetails(
                pokemonList.map((pokemon) =&amp;gt; ({ url: pokemon.url }))
            );
            set((state) =&amp;gt; ({
                pokemonListDetails: {
                    ...state.pokemonListDetails,
                    [page]: details,
                },
                loading: false,
            }));
        } catch (error) {
            console.error('Failed to fetch Pok&amp;eacute;mon details:', error);
            set({ loading: false });
        }
    },

    searchPokemon: async (query) =&amp;gt; {
        const { allPokemon } = get();

        if (!query) {
            set((state) =&amp;gt; ({
                pokemonListDetails: {
                    ...state.pokemonListDetails,
                    search: [],
                },
                searchQuery: '',
            }));
            return;
        }

        set({ loading: true });
        try {
            const filteredPokemon = allPokemon.filter((pokemon) =&amp;gt;
                pokemon.name.toLowerCase().includes(query.toLowerCase())
            );

            const filteredDetails = await fetchPokemonDetails(
                filteredPokemon.map((pokemon) =&amp;gt; ({ url: pokemon.url }))
            );

            set((state) =&amp;gt; ({
                pokemonListDetails: {
                    ...state.pokemonListDetails,
                    search: filteredDetails,
                },
                loading: false,
            }));
        } catch (error) {
            console.error('Error occurred during Pok&amp;eacute;mon search:', error);
            set({ loading: false });
        }
    },

    updateSearchQuery: (query) =&amp;gt; {
        set({ searchQuery: query });
        if (debounceTimer) clearTimeout(debounceTimer);

        debounceTimer = setTimeout(() =&amp;gt; {
            const { searchPokemon } = get();
            searchPokemon(query);
        }, 500);
    },
}));&lt;/code&gt;&lt;/pre&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;ul style=&quot;list-style-type: disc;&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;검색 시 디바운스를 적용하여 사용자가 입력을 멈춘 후 일정 시간(500ms) 이후에만 API 요청을 보내도록 최적화했습니다.&lt;/span&gt;&lt;span&gt;&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;span&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&gt;fetchPokemonDetails&lt;/span&gt;&lt;span&gt; 함수에서 각 페이지별로 데이터를 캐싱하여, 사용자가 다른 페이지로 이동했다가 다시 돌아오는 경우 API를 다시 호출하지 않고 캐싱된 데이터를 재사용하도록 했습니다.&lt;/span&gt;&lt;span&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Next.js/Pokemon</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/74</guid>
      <comments>https://hy-un.tistory.com/entry/Zustand%EB%A1%9C-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC-%ED%8F%AC%EC%BC%93%EB%AA%AC-%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#entry74comment</comments>
      <pubDate>Thu, 2 Jan 2025 11:30:08 +0900</pubDate>
    </item>
    <item>
      <title>MongoDB와 Prisma를 활용한 북마크/좋아요 기능 구현</title>
      <link>https://hy-un.tistory.com/entry/MongoDB%EC%99%80-Prisma%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%B6%81%EB%A7%88%ED%81%AC%EC%A2%8B%EC%95%84%EC%9A%94-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-01-01 오후 6.43.26.png&quot; data-origin-width=&quot;2938&quot; data-origin-height=&quot;1404&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/H9QYa/btsLABafbXU/KqBzi6zpmOLPdxlfwkD1lk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/H9QYa/btsLABafbXU/KqBzi6zpmOLPdxlfwkD1lk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/H9QYa/btsLABafbXU/KqBzi6zpmOLPdxlfwkD1lk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FH9QYa%2FbtsLABafbXU%2FKqBzi6zpmOLPdxlfwkD1lk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;344&quot; data-filename=&quot;스크린샷 2025-01-01 오후 6.43.26.png&quot; data-origin-width=&quot;2938&quot; data-origin-height=&quot;1404&quot;/&gt;&lt;/span&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;아무래도 데이터를 저장하고 관리하려면 데이터베이스가 필요할 것 같아서, 학습도 할 겸 Prisma와 MongoDB를 사용하기로 결정했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Prisma와 MongoDB 설정&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prisma는 MongoDB와 같은 NoSQL 데이터베이스에서도 편리하게 데이터를 다룰 수 있도록 해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해 schema.prisma 파일을 다음과 같이 작성했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735723770882&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// schema.prisma
generator client {
  provider = &quot;prisma-client-js&quot;
}

datasource db {
  provider = &quot;mongodb&quot;
  url      = env(&quot;DATABASE_URL&quot;)
}

model User {
  id          String    @id @default(cuid()) @map(&quot;_id&quot;)
  auth0Id     String    @unique
  bookmarks   String[]
  liked       String[]  
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;bookmarks와 liked 필드는 문자열 배열로 정의하여 포켓몬 이름을 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prisma 클라이언트를 생성하기 위해 npx prisma generate 명령어를 실행했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Prisma 클라이언트 설정&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PrismaClient를 여러 인스턴스로 생성하는 문제를 방지하기 위해 전역으로 관리합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735723828386&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { PrismaClient } from '@prisma/client';

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient();

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

export default prisma;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 환경에서는 Prisma 클라이언트를 재사용하고, 배포 환경에서는 새 인스턴스를 생성합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;API 엔드포인트 구현&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;POST 요청: 북마크/좋아요 토글&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 좋아요 또는 북마크를 클릭할 때 데이터를 추가하거나 제거하는 API입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735723877573&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/utils/connect';

export async function POST(req: NextRequest) {
    try {
        const { userId, pokemon, action } = await req.json();

        if (!['bookmark', 'like'].includes(action)) {
            return NextResponse.json(
                { message: '잘못된 작업입니다. (action이 bookmark 또는 like 여야 합니다.)' },
                { status: 400 }
            );
        }

        let user = await prisma.user.findUnique({
            where: { auth0Id: userId },
        });

        if (!user) {
            user = await prisma.user.create({
                data: {
                    auth0Id: userId,
                    bookmarks: [],
                    liked: [],
                },
            });
        }

        const fieldToUpdate = action === 'bookmark' ? 'bookmarks' : 'liked';
        const currentItems = user[fieldToUpdate];
        const updatedItems = currentItems.includes(pokemon)
            ? currentItems.filter((item) =&amp;gt; item !== pokemon)
            : [...currentItems, pokemon];

        await prisma.user.update({
            where: { auth0Id: userId },
            data: { [fieldToUpdate]: updatedItems },
        });

        return NextResponse.json({
            toggledOff: currentItems.includes(pokemon),
            success: true,
            message: `성공적으로 ${pokemon}를 ${action === 'bookmark' ? '북마크' : '좋아요'} 처리했습니다.`,
        });
    } catch (error) {
        console.log('링크 또는 북마크 처리 중 오류 발생', error);

        return NextResponse.json(
            { message: '요청을 처리하는 동안 오류가 발생했습니다.' },
            { status: 500 }
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prisma.user.findUnique로 유저를 조회하고, 존재하지 않으면 새로 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요/북마크 배열에서 항목을 추가하거나 제거한 후, 업데이트합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;GET 요청: 사용자 데이터 반환&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 사용자의 정보를 반환하는 API입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735723951814&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/utils/connect';

export async function GET(req: NextRequest, { params }: { params: { userId: string } }) {
    try {
        const { userId } = params;

        if (!userId) {
            return NextResponse.json(
                { message: '유효하지 않은 사용자입니다.' },
                { status: 400 }
            );
        }

        const user = await prisma.user.findUnique({
            where: { auth0Id: userId },
        });

        if (!user) {
            return NextResponse.json(
                { message: '사용자를 찾을 수 없습니다.' },
                { status: 404 }
            );
        }

        return NextResponse.json(user);
    } catch (error) {
        console.log('Error', error);
        return NextResponse.json({ message: 'Error' }, { status: 500 });
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Zustand를 활용한 상태 관리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Zustand를 사용하여 사용자 데이터를 상태로 관리했습니다. API 요청으로 데이터를 가져오거나, 좋아요와 북마크 작업을 수행합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735724003220&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const useUserStore = create&amp;lt;UserStore&amp;gt;((set, get) =&amp;gt; ({
    userDetails: null,

    fetchUserDetails: async (userId) =&amp;gt; {
        try {
            const response = await fetch(`/api/user/${userId}`);
            if (!response.ok) {
                throw new Error('사용자 정보를 가져오는 데 실패했습니다.');
            }
            const data = await response.json();
            set({ userDetails: data });
        } catch (error) {
            console.error('사용자 정보를 가져오는 중 오류가 발생했습니다:', error);
        }
    },

    performAction: async (userId, pokemon, action) =&amp;gt; {
        try {
            set((state) =&amp;gt; {
                if (!state.userDetails) return state;

                const updatedBookmarks =
                    action === 'bookmark'
                        ? state.userDetails.bookmarks.includes(pokemon)
                            ? state.userDetails.bookmarks.filter((p) =&amp;gt; p !== pokemon)
                            : [...state.userDetails.bookmarks, pokemon]
                        : state.userDetails.bookmarks;

                const updatedLikes =
                    action === 'like'
                        ? state.userDetails.liked.includes(pokemon)
                            ? state.userDetails.liked.filter((p) =&amp;gt; p !== pokemon)
                            : [...state.userDetails.liked, pokemon]
                        : state.userDetails.liked;

                return {
                    userDetails: {
                        ...state.userDetails,
                        bookmarks: updatedBookmarks,
                        liked: updatedLikes,
                    },
                };
            });

            const response = await fetch('/api/pokemon', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ userId, pokemon, action }),
            });

            if (!response.ok) {
                throw new Error('요청을 수행하는 데 실패했습니다.');
            }
        } catch (error) {
            console.error('요청을 수행하는 중 오류가 발생했습니다:', error);
            const { fetchUserDetails } = get();
            await fetchUserDetails(userId);
        }
    },
}));&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;</description>
      <category>Next.js/Pokemon</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/73</guid>
      <comments>https://hy-un.tistory.com/entry/MongoDB%EC%99%80-Prisma%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%B6%81%EB%A7%88%ED%81%AC%EC%A2%8B%EC%95%84%EC%9A%94-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84#entry73comment</comments>
      <pubDate>Wed, 1 Jan 2025 18:43:39 +0900</pubDate>
    </item>
    <item>
      <title>PokeAPI와 페이지네이션 기능 구현하기</title>
      <link>https://hy-un.tistory.com/entry/PokeAPI%EC%99%80-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-12-30 오후 6.18.17.png&quot; data-origin-width=&quot;2928&quot; data-origin-height=&quot;1574&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cziSdM/btsLBxj2Nf5/HHEs9gknadKqZNgAVdHZc0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cziSdM/btsLBxj2Nf5/HHEs9gknadKqZNgAVdHZc0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cziSdM/btsLBxj2Nf5/HHEs9gknadKqZNgAVdHZc0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcziSdM%2FbtsLBxj2Nf5%2FHHEs9gknadKqZNgAVdHZc0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;1574&quot; data-filename=&quot;스크린샷 2024-12-30 오후 6.18.17.png&quot; data-origin-width=&quot;2928&quot; data-origin-height=&quot;1574&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PokeAPI는 총 1302개의 포켓몬 데이터를 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 1302개의 데이터를 한 번에 보여주는 것은 비효율적이여서 이를 해결하기 위해 페이지네이션 기능을 구현했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;PokeAPI 데이터 가져오기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PokeAPI는 전체 포켓몬의 개수를 count로 제공합니다. 이를 활용해 데이터를 20개씩 나눠서 가져오도록 했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735551755616&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export async function fetchPokemon(page = 1): Promise&amp;lt;{ count: number; results: Pokemon[] }&amp;gt; {
    const offset = (page - 1) * 20;
    const response = await fetch(`${pokemonBaseUrl}/pokemon?limit=20&amp;amp;offset=${offset}`);
    if (!response.ok) {
        throw new Error('포켓몬 목록을 가져오는 데 실패했습니다.');
    }
    const data = await response.json();
    return { count: data.count, results: data.results };
}&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;b&gt;limit&lt;/b&gt;: 한 페이지에 표시할 포켓몬의 수 (여기서는 20으로 설정)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;offset&lt;/b&gt;: 페이지 번호에 따라 가져올 데이터의 시작점. (page - 1) * limit으로 계산&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;maxVisiblePages&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지네이션에서 한 번에 보여질 페이지 번호의 최대 개수를 제한하기 위해 maxVisiblePages를 선언했습니다. 여기서는 5로 설정했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735551828702&quot; class=&quot;angelscript&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const maxVisiblePages = 5;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;getPageNumbers 함수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;getPageNumbers 함수는 페이지네이션 번호와 생략 기호(...)를 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수는 최대 maxVisiblePages개의 페이지 번호를 표시하고, 필요하면 생략 기호를 포함합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735551838052&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const getPageNumbers = () =&amp;gt; {
    const pages: (number | '...')[] = [];
    pages.push(1);

    if (currentPage &amp;lt; maxVisiblePages) {
        for (let i = 2; i &amp;lt;= Math.min(totalPages - 1, maxVisiblePages); i++) {
            pages.push(i);
        }
    } else {
        const half = Math.floor((maxVisiblePages - 1) / 2);
        const startPage = Math.max(2, currentPage - half);
        const endPage = Math.min(totalPages - 1, currentPage + half);

        if (startPage &amp;gt; 2) {
            pages.push('...');
        }

        for (let i = startPage; i &amp;lt;= endPage; i++) {
            pages.push(i);
        }
    }

    if (currentPage + Math.floor((maxVisiblePages - 1) / 2) &amp;lt; totalPages - 1) {
        pages.push('...');
    }

    pages.push(totalPages);

    return pages;
};&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;maxVisiblePages로 한 번에 보여질 최대 페이지 번호를 제한.&lt;/li&gt;
&lt;li&gt;첫 번째 페이지 번호(1)는 항상 추가.&lt;/li&gt;
&lt;li&gt;currentPage가 maxVisiblePages보다 작으면 순차적으로 번호를 추가.&lt;/li&gt;
&lt;li&gt;currentPage가 중앙에 위치하도록 앞뒤로 균형 있게 페이지 번호를 추가.&lt;/li&gt;
&lt;li&gt;필요한 경우 생략 기호(...)를 삽입.&lt;/li&gt;
&lt;li&gt;마지막 페이지 번호(totalPages)를 추가.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;전체 코드&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1735551874649&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import Link from 'next/link';

type Props = {
    currentPage: number;
    totalPages: number;
};

export default function Pagination({ currentPage, totalPages }: Props) {
    const maxVisiblePages = 5;

    const getPageNumbers = () =&amp;gt; {
        const pages: (number | '...')[] = [];
        pages.push(1);

        if (currentPage &amp;lt; maxVisiblePages) {
            for (let i = 2; i &amp;lt;= Math.min(totalPages - 1, maxVisiblePages); i++) {
                pages.push(i);
            }
        } else {
            const half = Math.floor((maxVisiblePages - 1) / 2);
            const startPage = Math.max(2, currentPage - half);
            const endPage = Math.min(totalPages - 1, currentPage + half);

            if (startPage &amp;gt; 2) {
                pages.push('...');
            }

            for (let i = startPage; i &amp;lt;= endPage; i++) {
                pages.push(i);
            }
        }

        if (currentPage + Math.floor((maxVisiblePages - 1) / 2) &amp;lt; totalPages - 1) {
            pages.push('...');
        }

        pages.push(totalPages);

        return pages;
    };

    const pageNumbers = getPageNumbers();

    return (
        &amp;lt;div className=&quot;mb-10 flex justify-center items-center gap-2&quot;&amp;gt;
            {pageNumbers.map((page, index) =&amp;gt; {
                if (page === '...') {
                    return (
                        &amp;lt;span key={`ellipsis-${index}`} className=&quot;text-gray-500 px-2&quot;&amp;gt;
                            ...
                        &amp;lt;/span&amp;gt;
                    );
                }
                return (
                    &amp;lt;Link
                        key={page}
                        href={`/?page=${page}`}
                        className={`py-2 px-4 w-10 h-10 flex items-center justify-center rounded-full ${
                            page === currentPage
                                ? 'bg-purple text-white font-bold'
                                : 'bg-gray-200 text-black hover:bg-purple/30'
                        }`}
                    &amp;gt;
                        {page}
                    &amp;lt;/Link&amp;gt;
                );
            })}
        &amp;lt;/div&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Next.js/Pokemon</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/72</guid>
      <comments>https://hy-un.tistory.com/entry/PokeAPI%EC%99%80-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#entry72comment</comments>
      <pubDate>Mon, 30 Dec 2024 18:45:19 +0900</pubDate>
    </item>
    <item>
      <title>Next.js 15.1.2 -&amp;gt; 14.2.21로 다운그레이드</title>
      <link>https://hy-un.tistory.com/entry/Nextjs-1512-14221%EB%A1%9C-%EB%8B%A4%EC%9A%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Auth0를 사용하여 로그인 상태를 서버 컴포넌트에서 확인하기 위해 getSession을 호출했더니 아래와 같은 오류가 발생했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Route&amp;nbsp;&quot;/api/auth/[auth0]&quot;&amp;nbsp;used&amp;nbsp;`params.auth0`.&amp;nbsp;`params`&amp;nbsp;should&amp;nbsp;be&amp;nbsp;awaited&amp;nbsp;before&amp;nbsp;using&amp;nbsp;its&amp;nbsp;properties&lt;/b&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;a href=&quot;https://stackoverflow.com/questions/79270854/route-api-auth-auth0-used-params-auth0-params-should-be-awaited-before&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://stackoverflow.com/questions/79270854/route-api-auth-auth0-used-params-auth0-params-should-be-awaited-before&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1735299757955&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;Route &amp;quot;/api/auth/[auth0]&amp;quot; used &amp;#96;params.auth0&amp;#96;. &amp;#96;params&amp;#96; should be awaited before using its properties&quot; data-og-description=&quot;I'm working on a Next.js 15 and Auth0 with package @auth0/nextjs-auth0 app using dynamic API route handler. When access /api/auth/login I got the following error: Route &amp;quot;/api/auth/[auth0]&amp;quot;...&quot; data-og-host=&quot;stackoverflow.com&quot; data-og-source-url=&quot;https://stackoverflow.com/questions/79270854/route-api-auth-auth0-used-params-auth0-params-should-be-awaited-before&quot; data-og-url=&quot;https://stackoverflow.com/questions/79270854/route-api-auth-auth0-used-params-auth0-params-should-be-awaited-before&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/edcrNi/hyXSpAzB8q/78SpglfemaA5VItwsHXmk0/img.png?width=316&amp;amp;height=316&amp;amp;face=0_0_316_316&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/79270854/route-api-auth-auth0-used-params-auth0-params-should-be-awaited-before&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://stackoverflow.com/questions/79270854/route-api-auth-auth0-used-params-auth0-params-should-be-awaited-before&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/edcrNi/hyXSpAzB8q/78SpglfemaA5VItwsHXmk0/img.png?width=316&amp;amp;height=316&amp;amp;face=0_0_316_316');&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;Route &quot;/api/auth/[auth0]&quot; used `params.auth0`. `params` should be awaited before using its properties&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;I'm working on a Next.js 15 and Auth0 with package @auth0/nextjs-auth0 app using dynamic API route handler. When access /api/auth/login I got the following error: Route &quot;/api/auth/[auth0]&quot;...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;stackoverflow.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색을 통해 알아본 결과, Auth0의 현재 버전이 Next.js 최신 버전을 지원하지 않아서 발생한 문제로 추측됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이에 Next.js를 다운그레이드하여 문제를 해결했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;Next.js 및 React 버전 다운그레이드&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js를 15.x에서 14.2.21로, React를 19에서 18로 다운그레이드했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735299256415&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm install next@14.2.21 react@18 react-dom@18&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;다운그레이드 후, package.json 확인&lt;/p&gt;
&lt;pre id=&quot;code_1735299301904&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;dependencies&quot;: {
  &quot;next&quot;: &quot;14.2.21&quot;,
  &quot;react&quot;: &quot;^18.2.0&quot;,
  &quot;react-dom&quot;: &quot;^18.2.0&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;b&gt;Next.js 설정 파일 변경&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1) next.config.ts &amp;rarr; next.config.js&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 14에서는 next.config.ts을 지원하지 않으므로, 설정 파일을 next.config.js 로 변경합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내용은 아래와 같이 수정합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735299338177&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/** @type {import('next').NextConfig} */
const nextConfig = {};

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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2) Turbopack 제거&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;package.json의 스크립트에서 Turbopack 관련 옵션을 제거했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735299454877&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;scripts&quot;: {
  &quot;dev&quot;: &quot;next dev&quot;,
  &quot;build&quot;: &quot;next build&quot;,
  &quot;start&quot;: &quot;next start&quot;,
  &quot;lint&quot;: &quot;next lint&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. &lt;b&gt;폰트 관련 오류&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 15.1.2에서 app 폴더 아래의 layout.tsx에 기본 폰트로 다음과 같은 코드가 사용됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735300053540&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Geist, Geist_Mono } from 'next/font/google';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Geist와 Geist_Mono는 next/font/google에서 지원되지 않아 오류가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우, 다른 폰트를 사용하면 문제가 해결됩니다. 예를 들어, Google Fonts에서 지원하는 폰트로 대체할 수 있습니다.&lt;/p&gt;</description>
      <category>Next.js/Pokemon</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/70</guid>
      <comments>https://hy-un.tistory.com/entry/Nextjs-1512-14221%EB%A1%9C-%EB%8B%A4%EC%9A%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C#entry70comment</comments>
      <pubDate>Fri, 27 Dec 2024 20:47:57 +0900</pubDate>
    </item>
    <item>
      <title>Next.js와 Auth0를 이용한 간단한 로그인 기능 구현하기</title>
      <link>https://hy-un.tistory.com/entry/Nextjs%EC%99%80-Auth0%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EA%B0%84%EB%8B%A8%ED%95%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://auth0.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://auth0.com/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1734943150253&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;Auth0: Secure access for everyone. But not just anyone.&quot; data-og-description=&quot;Rapidly integrate authentication and authorization for web, mobile, and legacy applications so you can focus on your core business.&quot; data-og-host=&quot;auth0.com&quot; data-og-source-url=&quot;https://auth0.com/&quot; data-og-url=&quot;https://auth0.com/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/eTIi1/hyXSwMpgwR/4FY05k4VctKDJwAKlsUApK/img.jpg?width=1600&amp;amp;height=840&amp;amp;face=0_0_1600_840,https://scrap.kakaocdn.net/dn/bCSLMb/hyXSrYD0jU/V8PJV23iZKWLbZB3fwLLxk/img.png?width=1176&amp;amp;height=1056&amp;amp;face=0_0_1176_1056,https://scrap.kakaocdn.net/dn/bgWJbd/hyXOoWPrLD/AA9Ixff0CMEiESfCYonUi1/img.png?width=1453&amp;amp;height=849&amp;amp;face=256_333_305_387&quot;&gt;&lt;a href=&quot;https://auth0.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://auth0.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/eTIi1/hyXSwMpgwR/4FY05k4VctKDJwAKlsUApK/img.jpg?width=1600&amp;amp;height=840&amp;amp;face=0_0_1600_840,https://scrap.kakaocdn.net/dn/bCSLMb/hyXSrYD0jU/V8PJV23iZKWLbZB3fwLLxk/img.png?width=1176&amp;amp;height=1056&amp;amp;face=0_0_1176_1056,https://scrap.kakaocdn.net/dn/bgWJbd/hyXOoWPrLD/AA9Ixff0CMEiESfCYonUi1/img.png?width=1453&amp;amp;height=849&amp;amp;face=256_333_305_387');&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;Auth0: Secure access for everyone. But not just anyone.&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Rapidly integrate authentication and authorization for web, mobile, and legacy applications so you can focus on your core business.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;auth0.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Pokemon API를 활용한 포켓몬 사이트를 구현하면서 간단한 로그인 기능도 추가하기 위해 Auth0를 사용했습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 글에서는 Auth0를 설정하고 Next.js 프로젝트에 통합하는 과정을 정리해보겠습니다.&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;h3 data-pm-slice=&quot;1 3 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;Auth0 설정하기&lt;/span&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot; data-spread=&quot;true&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;애플리케이션 유형 선택&lt;/b&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Next.js를 사용하기 때문에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;Single Page Web Applications&lt;/span&gt;&lt;span&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;span&gt; &lt;/span&gt;새로운 애플리케이션을 생성합니다. 저는 &quot;Pokemon&quot;이라는 이름으로 어플리케이션을 만들었습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;Allowed Callback URLs 설정&lt;/b&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Auth0 대시보드에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;Settings&lt;/span&gt;&lt;span&gt;로 이동한 후,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;Allowed Callback URLs&lt;/span&gt;&lt;span&gt;에 아래의 값을 추가합니다.&lt;/span&gt;&lt;span&gt;이 URL은 Auth0가 인증 후 리디렉션할 경로를 지정합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;http://localhost:3000/api/auth/callback&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;Next.js 프로젝트에 Auth0 통합하기&lt;/span&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;1. Auth0 관련 패키지 설치&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Auth0를 Next.js에 통합하려면 다음 패키지를 설치합니다:&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734943400620&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm install @auth0/nextjs-auth0&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;2. 환경 변수 설정&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;.env.local&lt;/span&gt;&lt;span&gt; 파일에 Auth0 설정 값을 추가합니다:&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734943435272&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;AUTH0_SECRET='use [openssl rand -hex 32] to generate a 32 bytes value'
AUTH0_BASE_URL='http://localhost:3000'
AUTH0_ISSUER_BASE_URL='https://YOUR_DOMAIN.auth0.com'
AUTH0_CLIENT_ID='YOUR_CLIENT_ID'
AUTH0_CLIENT_SECRET='YOUR_CLIENT_SECRET'&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&gt;AUTH0_SECRET&lt;/span&gt;&lt;span&gt;: 32바이트 길이의 랜덤 문자열을 생성하려면 터미널에서 다음 명령어를 실행합니다:&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1734943808540&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;openssl rand -hex 32&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&gt;나머지 값은 Auth0 애플리케이션 설정에서 복사하여 사용합니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;3. API 라우트 추가&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;app/api/auth/[auth0]/route.ts&lt;/span&gt;&lt;span&gt; 파일을 생성하고 다음과 같이 작성합니다:&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734943862682&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { handleAuth } from '@auth0/nextjs-auth0';

export const GET = handleAuth();&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;4. 레이아웃 설정&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;app/layout.tsx&lt;/span&gt;&lt;span&gt; 파일에서 Auth0의 &lt;/span&gt;&lt;span&gt;UserProvider&lt;/span&gt;&lt;span&gt;로 애플리케이션을 감쌉니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734943498431&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { UserProvider } from '@auth0/nextjs-auth0/client';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &amp;lt;html lang=&quot;en&quot;&amp;gt;
      &amp;lt;UserProvider&amp;gt;
        &amp;lt;body&amp;gt;{children}&amp;lt;/body&amp;gt;
      &amp;lt;/UserProvider&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}&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;h3 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;&lt;span&gt;이미지 호스트 문제 해결하기&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-12-23 오후 6.04.50.png&quot; data-origin-width=&quot;1922&quot; data-origin-height=&quot;714&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/J0TBH/btsLskTRLhH/xkMgPJMfuTkQdzk2zKXn21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/J0TBH/btsLskTRLhH/xkMgPJMfuTkQdzk2zKXn21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/J0TBH/btsLskTRLhH/xkMgPJMfuTkQdzk2zKXn21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJ0TBH%2FbtsLskTRLhH%2FxkMgPJMfuTkQdzk2zKXn21%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;267&quot; data-filename=&quot;스크린샷 2024-12-23 오후 6.04.50.png&quot; data-origin-width=&quot;1922&quot; data-origin-height=&quot;714&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Next.js에서 &lt;/span&gt;&lt;span&gt;next/image&lt;/span&gt;&lt;span&gt;를 사용할 때 외부 호스트의 이미지를 로드하려면 &lt;/span&gt;&lt;span&gt;next.config.js&lt;/span&gt;&lt;span&gt; 파일에 해당 호스트를 추가해야 합니다. 다음과 같이 설정합니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1734944738563&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 = {
  images: {
    domains: ['lh3.googleusercontent.com'],
  }
  /* config options here */
};

export default nextConfig;&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;h3 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;로그인 상태에 따른 UI 변경&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 컴포넌트에서는 Auth0의 useUser 훅을, 서버 컴포넌트에서는 getSession을 사용하여 로그인 상태를 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 기반으로 로그인 여부에 따라 헤더 UI를 다르게 표시할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-12-23 오후 5.55.59.png&quot; data-origin-width=&quot;2848&quot; data-origin-height=&quot;402&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b7dGAM/btsLshQpvfs/afYsynk05EmmyDTmWjkTNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b7dGAM/btsLshQpvfs/afYsynk05EmmyDTmWjkTNk/img.png&quot; data-alt=&quot;useAuth의 user 출력&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b7dGAM/btsLshQpvfs/afYsynk05EmmyDTmWjkTNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb7dGAM%2FbtsLshQpvfs%2FafYsynk05EmmyDTmWjkTNk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;102&quot; data-filename=&quot;스크린샷 2024-12-23 오후 5.55.59.png&quot; data-origin-width=&quot;2848&quot; data-origin-height=&quot;402&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;useAuth의 user 출력&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1734943925649&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import { useUser } from '@auth0/nextjs-auth0/client';

export default function Header() {
    const { user, isLoading } = useUser();
    console.log(user);

    return &amp;lt;header&amp;gt;&amp;lt;/header&amp;gt;;
}&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;pre id=&quot;code_1735298050604&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { getSession } from '@auth0/nextjs-auth0';

export default async function Header() {
    const session = await getSession();
    const user = session?.user;

    return &amp;lt;header&amp;gt;&amp;lt;/header&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;div data-message-model-slug=&quot;gpt-4o&quot; data-message-id=&quot;d3b7f753-207e-4bb7-b7d7-11a834fdf750&quot; data-message-author-role=&quot;assistant&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 Next.js가 15버전인 경우, getSession을 사용하면 다음과 같은 에러가 발생합니다.&lt;br /&gt;&lt;b&gt;Route &quot;/api/auth/[auth0]&quot; used params.auth0. params should be awaited before using its properties&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 Auth0의 현재 버전이 Next.js 15를 완전히 지원하지 않아서 발생한 문제로 추측됩니다.&lt;br /&gt;현재는 Next.js를 14버전으로 다운그레이드하면 오류가 사라집니다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span data-state=&quot;closed&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span data-state=&quot;closed&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>Next.js/Pokemon</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/69</guid>
      <comments>https://hy-un.tistory.com/entry/Nextjs%EC%99%80-Auth0%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EA%B0%84%EB%8B%A8%ED%95%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#entry69comment</comments>
      <pubDate>Mon, 23 Dec 2024 17:59:51 +0900</pubDate>
    </item>
    <item>
      <title>특정 줄에서 no-unused-vars 오류 해결 방법</title>
      <link>https://hy-un.tistory.com/entry/%ED%8A%B9%EC%A0%95-%EC%A4%84%EC%97%90%EC%84%9C-no-unused-vars-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;타입스크립트를 사용하는 Next.js 프로젝트에서 특정 라이브러리를 사용할 때 한 가지 오류가 발생했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같이 props에서 변수를 할당하려고 했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1730868261751&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { children, className, node ...rest } = props;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 ref를 할당하지 않으면 오류가 발생했습니다. 정확한 이유는 알 수 없었지만, ref를 포함하니 오류가 사라졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 빌드 시점에서 ref와 node가 실제로 사용되지 않기 때문에 ESLint의 no-unused-vars 규칙에 걸려 빌드가 실패했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해, // eslint-disable-next-line @typescript-eslint/no-unused-vars 주석을 추가하여 특정 줄에서만 no-unused-vars 규칙을 무시하도록 했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1730868291125&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ref, children, className, node, ...rest } = props;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JavaScript</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/68</guid>
      <comments>https://hy-un.tistory.com/entry/%ED%8A%B9%EC%A0%95-%EC%A4%84%EC%97%90%EC%84%9C-no-unused-vars-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95#entry68comment</comments>
      <pubDate>Wed, 6 Nov 2024 13:46:14 +0900</pubDate>
    </item>
    <item>
      <title>Next.js 에서 폰트 설정하기</title>
      <link>https://hy-un.tistory.com/entry/Nextjs-%EC%97%90%EC%84%9C-%ED%8F%B0%ED%8A%B8-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;폰트를 설정하는 방법은 크게 두 가지가 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 구글 폰트 사용하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구글 폰트는 next/font/google에서 원하는 폰트를 불러와 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필요한 폰트, 서브셋(subsets), 두께(weight), 그리고 디스플레이(display) 옵션을 지정할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1729706487108&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const notoSans = Noto_Sans({
  weight: ['400', '700'],
  subsets: ['latin'],
  display: 'swap',
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;subsets&lt;/b&gt;: 폰트가 지원하는 문자셋을 선택합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;weight&lt;/b&gt;: 400(보통)과 700(굵은)을 적용해 다양한 weight을 지원합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;display&lt;/b&gt;: swap으로 설정해, 폰트가 로드되지 않은 상태에서도 텍스트가 표시되며, 로드 후 폰트가 교체됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 로컬 폰트 사용하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 폰트를 사용할 때는 next/font/local을 통해 폰트 파일을 직접 경로로 지정해 사용할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1729706535200&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const pretendard = localFont({
  src: './fonts/PretendardVariable.woff2',
  display: 'swap',
  weight: '45 920',
  variable: '--font-pretendard',
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;src&lt;/b&gt;: 로컬 폰트 파일 경로를 지정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;display&lt;/b&gt;: 구글 폰트와 마찬가지로 swap을 사용해 폰트가 로드되지 않아도 기본 텍스트가 표시되도록 설정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;weight&lt;/b&gt;: 여기서는 45부터 920까지 가변적인 폰트 두께를 지정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;variable&lt;/b&gt;: CSS 변수 --font-pretendard로 폰트를 설정해주며, Tailwind CSS에서 쉽게 사용할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1729706734097&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import type { Config } from 'tailwindcss';

const config: Config = {
    content: [
    	// ...생략
    ],
    theme: {
        extend: {
            fontFamily: {
                pretendard: ['var(--font-pretendard)'],
            },
            // ...생략
        },
    },
    plugins: [],
};
export default config;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 적용 순서&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;body 태그에서 우선적으로 적용할 폰트를 설정할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, notoSans가 먼저 적용되고, 실패하면 pretendard가 적용됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1729706582231&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;body className={`${notoSans.className} ${pretendard.variable} bg-background text-foreground`}&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폰트를 className으로 적용하면 기본적으로 HTML 요소에 바로 스타일이 적용되며, variable을 사용할 때는 CSS 변수를 통해 Tailwind CSS와 같은 프레임워크에서 폰트 관련 스타일을 손쉽게 사용할 수 있습니다.&lt;/p&gt;</description>
      <category>Next.js/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/67</guid>
      <comments>https://hy-un.tistory.com/entry/Nextjs-%EC%97%90%EC%84%9C-%ED%8F%B0%ED%8A%B8-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0#entry67comment</comments>
      <pubDate>Thu, 24 Oct 2024 08:09:42 +0900</pubDate>
    </item>
    <item>
      <title>텍스트에 linear-gradient 적용하기</title>
      <link>https://hy-un.tistory.com/entry/%ED%85%8D%EC%8A%A4%ED%8A%B8%EC%97%90-linear-gradient-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-10-24 오전 2.49.20.png&quot; data-origin-width=&quot;742&quot; data-origin-height=&quot;130&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/czUhMD/btsKhwfv7aU/8QlAur2iPTJL7ahnwNV7X0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/czUhMD/btsKhwfv7aU/8QlAur2iPTJL7ahnwNV7X0/img.png&quot; data-alt=&quot;그라데이션 효과가 적용된 텍스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/czUhMD/btsKhwfv7aU/8QlAur2iPTJL7ahnwNV7X0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FczUhMD%2FbtsKhwfv7aU%2F8QlAur2iPTJL7ahnwNV7X0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;742&quot; height=&quot;130&quot; data-filename=&quot;스크린샷 2024-10-24 오전 2.49.20.png&quot; data-origin-width=&quot;742&quot; data-origin-height=&quot;130&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;그라데이션 효과가 적용된 텍스트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;텍스트에 Linear-gradient 적용하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텍스트에 그라데이션 효과를 주기 위해 linear-gradient를 사용하여 다음과 같이 스타일을 적용할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1729705053958&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.gradient-text {
    display: inline-block;
    background-image: linear-gradient(90deg, #0ea5ea, #0bd1d1 51%);
    -webkit-text-fill-color: transparent;
    -webkit-background-clip: text;
    background-clip: text;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. display: inline-block;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텍스트 요소는 일반적으로 h1, p처럼 블록 요소이기 때문에 너비가 전체 화면을 차지합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;inline-block으로 변경해서 텍스트의 크기만큼만 너비를 차지하게 만들어줘서 그라데이션 효과가 텍스트에만 적용되게 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. background-image&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;linear-gradient(90deg, #0ea5ea, #0bd1d1 51%)로 그라데이션을 지정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;90도는 그라데이션이 왼쪽에서 오른쪽으로 적용된다는 의미이며, 첫 번째 색상이 51%까지 차지하고, 나머지 49%는 두 번째 색상으로 채워집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. -webkit-text-fill-color: transparent;&lt;/h3&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;h3 data-ke-size=&quot;size23&quot;&gt;4. -webkit-background-clip: text;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 속성은 배경 이미지를 텍스트에만 클립(잘라내기)하여 배경 이미지가 텍스트 부분에만 적용되도록 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. background-clip: text;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-webkit- 접두사가 없는 background-clip: text;는 같은 기능을 하며, 다른 브라우저에서도 동일하게 그라데이션이 텍스트에 적용되도록 해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 설정하면 텍스트에만 그라데이션이 적용된 스타일을 만들 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;버튼 그라데이션 효과와 호버 애니메이션&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Oct-24-2024 02-52-04.gif&quot; data-origin-width=&quot;144&quot; data-origin-height=&quot;56&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dsV5Wz/btsKhPy4egR/UtI051d2O1e2NgV8GkGAFK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dsV5Wz/btsKhPy4egR/UtI051d2O1e2NgV8GkGAFK/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dsV5Wz/btsKhPy4egR/UtI051d2O1e2NgV8GkGAFK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/dsV5Wz/btsKhPy4egR/UtI051d2O1e2NgV8GkGAFK/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;144&quot; height=&quot;56&quot; data-filename=&quot;Oct-24-2024 02-52-04.gif&quot; data-origin-width=&quot;144&quot; data-origin-height=&quot;56&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그라데이션이 적용된 버튼에 애니메이션 효과를 줄 때 다양한 방법이 있지만, background-size와 background-position을 사용하면 그라데이션이 이동하는 듯한 효과를 연출할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1729705573502&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.gradient-btn {
    background-image: linear-gradient(90deg, #0ea5ea, #0bd1d1 51%, #0ea5ea);
    background-size: 200%;
    background-position: left;
    transition: background-position 0.5s ease;
}

.gradient-btn:hover {
    background-position: right;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. background-image&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;linear-gradient(90deg, #0ea5ea, #0bd1d1 51%, #0ea5ea)는 첫 번째 색상인 #0ea5ea로 시작해서 51% 지점까지 적용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이후로는 두 번째 색상인 #0bd1d1이 나타나고, 마지막에는 다시 첫 번째 색상 #0ea5ea가 나타납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 그라데이션이 대칭적으로 적용되어 양쪽 끝이 동일한 색상으로 마무리됩니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. background-size&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;background-size: 200%는 배경 이미지의 크기를 200%로 늘려주어, 그라데이션이 더 크게 표현되고 배경이 움직일 수 있도록 공간을 확보해 줍니다. 이를 통해 애니메이션 시 배경이 자연스럽게 이동하는 효과를 줄 수 있습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. background-position&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 배경 위치를 left로 설정하여 배경이 왼쪽에서 시작하도록 합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. hover 시&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hover 상태에서는 background-position: right로 변경되어 배경이 오른쪽으로 이동합니다. 이로 인해 그라데이션이 움직이는 것처럼 보이는 시각적 효과가 나타납니다.&lt;/p&gt;</description>
      <category>HTML &amp;amp; CSS</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/66</guid>
      <comments>https://hy-un.tistory.com/entry/%ED%85%8D%EC%8A%A4%ED%8A%B8%EC%97%90-linear-gradient-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0#entry66comment</comments>
      <pubDate>Thu, 24 Oct 2024 07:41:59 +0900</pubDate>
    </item>
    <item>
      <title>SSG를 ISR로 전환하는 방법, fetch로 SSG, SSR, ISR 처리하기</title>
      <link>https://hy-un.tistory.com/entry/SSG%EB%A5%BC-ISR%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-fetch%EB%A1%9C-SSG-SSR-ISR-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;SSG -&amp;gt; ISR 으로 전환하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSG는 빌드 시점에 정적 HTML을 생성하는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 ISR로 전환하면, 정적 페이지의 성능을 유지하면서도 주기적으로 데이터를 새로 가져와 페이지를 업데이트할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ISR로 전환하려면 revalidate 옵션을 추가하여 페이지가 일정 시간마다 다시 생성되도록 설정할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727342752138&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 3초마다 페이지 재생성
export const revalidate = 3;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Next.js에서 fetch 사용하기: SSG, ISR, SSR 결정하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서는 서버 컴포넌트에서 fetch를 사용할 수 있습니다. fetch 함수는 두 번째 옵션에 따라 SSG, ISR, SSR 방식을 결정합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. SSG&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fetch 호출 시 두 번째 옵션을 제공하지 않는다면, 기본적으로 SSG로 동작하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지는 빌드 시점에 정적 HTML로 생성되고 이후에는 재생성되지 않습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727343148311&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const res = await fetch('https://api.adviceslip.com/advice');
const data = await res.json();
const advice = data.slip.advice;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. ISR&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ISR 방식으로 페이지를 주기적으로 재생성하고 싶다면, revalidate 옵션을 추가하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 3초마다 페이지를 다시 생성하고 싶다면 아래와 같이 revalidate 값을 설정할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727343162561&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const res = await fetch('https://api.adviceslip.com/advice', {
  next: { revalidate: 3 },
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. SSR&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSR은 요청 시마다 서버에서 HTML 페이지를 다시 생성하는 방식입니다. cache: 'no-store' 옵션을 사용하면 페이지는 항상 서버에서 새로 렌더링됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727415819501&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const res = await fetch('https://api.adviceslip.com/advice', {
  cache: 'no-store',
});&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;또는 revalidate: 0으로 설정하면,&amp;nbsp; SSR처럼 동작하게 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727343219418&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const res = await fetch('https://api.adviceslip.com/advice', {
  next: { revalidate: 0 },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Next.js/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/65</guid>
      <comments>https://hy-un.tistory.com/entry/SSG%EB%A5%BC-ISR%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-fetch%EB%A1%9C-SSG-SSR-ISR-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0#entry65comment</comments>
      <pubDate>Fri, 27 Sep 2024 14:46:11 +0900</pubDate>
    </item>
    <item>
      <title>서버 컴포넌트 vs 클라이언트 컴포넌트</title>
      <link>https://hy-un.tistory.com/entry/%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-vs-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/app/building-your-application/rendering/client-components&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://nextjs.org/docs/app/building-your-application/rendering/client-components&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1727332245496&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;Rendering: Client Components | Next.js&quot; data-og-description=&quot;Learn how to use Client Components to render parts of your application on the client.&quot; data-og-host=&quot;nextjs.org&quot; data-og-source-url=&quot;https://nextjs.org/docs/app/building-your-application/rendering/client-components&quot; data-og-url=&quot;https://nextjs.org/docs/app/building-your-application/rendering/client-components&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/ciRP7n/hyXaDSUdXl/jnyXw4X7aCnKOrU55y9OKK/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/bUaLwL/hyW6JtILwa/yn79rpBkgOkkKRJbCKAqu1/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/bqWMEv/hyW6Jgbvnd/k25MjKq2ghfvyo0VRkVycK/img.png?width=1600&amp;amp;height=1325&amp;amp;face=0_0_1600_1325&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/app/building-your-application/rendering/client-components&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nextjs.org/docs/app/building-your-application/rendering/client-components&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/ciRP7n/hyXaDSUdXl/jnyXw4X7aCnKOrU55y9OKK/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/bUaLwL/hyW6JtILwa/yn79rpBkgOkkKRJbCKAqu1/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/bqWMEv/hyW6Jgbvnd/k25MjKq2ghfvyo0VRkVycK/img.png?width=1600&amp;amp;height=1325&amp;amp;face=0_0_1600_1325');&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;Rendering: Client Components | Next.js&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Learn how to use Client Components to render parts of your application on the client.&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;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서는 모든 컴포넌트가 기본적으로 서버 컴포넌트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도로 설정하지 않으면 컴포넌트는 서버에서 실행되며, 예를 들어 console.log를 사용할 경우 콘솔이 브라우저 콘솔이 아닌 서버 터미널에 출력됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 서버 컴포넌트가 서버에서 실행되기 때문에 발생하는 현상으로, 브라우저는 서버에서 렌더링된 HTML만 받아서 화면에 표시하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 서버 컴포넌트는 서버에서만 동작하기 때문에 브라우저에서 제공하는 API(Web API)를 사용할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 서버 측에서 Node.js API를 사용하여 파일 시스템에 접근하거나 데이터베이스와 상호작용하는 작업은 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, 서버 컴포넌트에서는 React의 상태 관리 훅(useState, useEffect 등)을 사용할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 사용자의 이벤트 처리나 상태 관리를 해야 한다면, 해당 부분은 클라이언트 컴포넌트로 만들어야 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;클라이언트 컴포넌트를 사용해야 할 때&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 컴포넌트를 사용할 필요가 있는 경우, 해당 컴포넌트 최상단에 use client 를 명시합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727331219400&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 { useState } from &quot;react&quot;;

export default function Input() {
    const [text, setText] = useState(&quot;&quot;);
    return (
        &amp;lt;&amp;gt;
            &amp;lt;input
                type=&quot;text&quot;
                value={text}
                onChange={(e) =&amp;gt; setText(e.target.value)}
                className=&quot;border&quot;
            /&amp;gt;
            {text}
        &amp;lt;/&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 것은 클라이언트 컴포넌트를 만든다고 해서 그것이 클라이언트 사이드 렌더링을 의미하는 것은 아니라는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js는 가능한 한 모든 컴포넌트를 정적으로 생성하여, 사용자가 페이지를 요청하면 정적인 HTML을 먼저 보내줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 컴포넌트는 사용자의 이벤트 처리나 브라우저에서 실행되어야 하는 코드를 포함하는 부분입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 컴포넌트는 클라이언트에만 관련된 코드를 포함하며, 정적인 HTML이 먼저 로드된 후, 리액트 라이브러리와 함께 브라우저에서 실행되어야 할 추가적인 자바스크립트 코드들이 다운로드되어 하이드레이션이 이루어집니다.&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;</description>
      <category>Next.js/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/64</guid>
      <comments>https://hy-un.tistory.com/entry/%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-vs-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8#entry64comment</comments>
      <pubDate>Thu, 26 Sep 2024 16:12:25 +0900</pubDate>
    </item>
    <item>
      <title>Next.js에서의 라우팅</title>
      <link>https://hy-un.tistory.com/entry/Nextjs%EC%97%90%EC%84%9C%EC%9D%98-%EB%9D%BC%EC%9A%B0%ED%8C%85</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1727241914154&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;Routing: Pages and Layouts | Next.js&quot; data-og-description=&quot;Create your first page and shared layout with the Pages Router.&quot; data-og-host=&quot;nextjs.org&quot; data-og-source-url=&quot;https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts&quot; data-og-url=&quot;https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bVkVLn/hyXaExlYHD/1fYCSQBEV5D3TKkoC3YwLk/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/ZD1e7/hyXaxkHeLB/rc6FxCMlmLs8EH2pNMhHD0/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bVkVLn/hyXaExlYHD/1fYCSQBEV5D3TKkoC3YwLk/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/ZD1e7/hyXaxkHeLB/rc6FxCMlmLs8EH2pNMhHD0/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441');&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;Routing: Pages and Layouts | Next.js&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Create your first page and shared layout with the Pages Router.&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;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React에서는 라우팅을 설정할 때 react-router-dom 같은 라이브러리를 주로 사용하지만, 반드시 이 라이브러리를 사용할 필요는 없고, 원하는 방식으로 자유롭게 라우팅을 설정할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면, Next.js는 프레임워크이기 때문에 정해진 라우팅 방식을 사용해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서는 별도로 라우터를 설정하는게 아닌 폴더 및 파일 구조에 따라 자동으로 라우팅이 설정이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴더 구조만으로 경로가 정의되며, 정적 라우팅과 동적 라우팅을 모두 지원합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1.&amp;nbsp; 정적 라우팅&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서 정적 라우팅은 app폴더 안에 경로에 해당하는 폴더를 만들고, 그 안에 page.tsx 파일을 생성하면 Next.js가 해당 경로를 자동으로 인식하여 라우팅을 처리합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;폴더 구조&lt;/h3&gt;
&lt;pre id=&quot;code_1727239190029&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;app
└── posts
 &amp;nbsp;&amp;nbsp; ├── javascript
 &amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp; └── page.tsx
 &amp;nbsp;&amp;nbsp; ├── page.tsx
 &amp;nbsp;&amp;nbsp; └── react
 &amp;nbsp;&amp;nbsp;     └── page.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1727239168650&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function PostPage() {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;포스트 페이지&amp;lt;/h1&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default function JavaScriptCategoryPage() {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;JavaScript 카테고리의 글&amp;lt;/h1&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default function ReactCategoryPage() {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;React 카테고리의 글&amp;lt;/h1&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 폴더를 구성하면 /posts/javascript, /posts/react와 같은 경로로 접근할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js는 이 폴더 구조를 바탕으로 자동으로 라우팅을 설정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 카테고리의 수가 매우 많아지면, 각각의 카테고리마다 폴더와 파일을 생성하는 것은 비효율적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 경우를 해결하기 위해 Next.js는 동적 라우팅을 지원합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2.&amp;nbsp; 동적 라우팅&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 라우팅을 사용하면, 경로가 동적으로 변하는 경우에도 하나의 페이지에서 처리할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 라우팅을 구현하려면 폴더명에 대괄호를 사용하여 변수를 정의합니다. 일반적으로 [slug] 같은 이름을 사용하는 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, category 경로 아래에 동적 라우팅을 설정하려면 [category] 폴더를 생성합니다.(변수명은 자유)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;폴더 구조&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1727239426862&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;app
└── posts
 &amp;nbsp;&amp;nbsp; ├── [category]
 &amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp; └── page.tsx
 &amp;nbsp;&amp;nbsp; └── page.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 구조에서는 /posts/javascript, /posts/react와 같이 URL의 마지막 부분이 동적으로 바뀔 수 있으며, 그 값을 Next.js가 자동으로 params 객체를 통해 전달합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727239819539&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type Props = {
  params: {
    category: string;
  };
};

export default function Page({ params }: Props) {
  console.log(params);
  return &amp;lt;div&amp;gt;{params.category}&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 통해 params 객체 안에 있는 category 값을 받아와서 화면에 출력할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 /posts/javascript 경로로 접속하면 params.category는 javascript 값을 가지게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;props를 출력해 보면 다음과 같습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727239843842&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;params&quot;: {
    &quot;category&quot;: &quot;javascript&quot;
  },
  &quot;searchParams&quot;: {}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3.&amp;nbsp; generateStaticParams&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Frame 1 (1).png&quot; data-origin-width=&quot;1146&quot; data-origin-height=&quot;373&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DLihV/btsJMeza0jc/RjjREt2e5fXSum8RFzjjU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DLihV/btsJMeza0jc/RjjREt2e5fXSum8RFzjjU0/img.png&quot; data-alt=&quot;generateStaticParams 사용 전 (좌) generateStaticParams 사용 후 (우)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DLihV/btsJMeza0jc/RjjREt2e5fXSum8RFzjjU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDLihV%2FbtsJMeza0jc%2FRjjREt2e5fXSum8RFzjjU0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;234&quot; data-filename=&quot;Frame 1 (1).png&quot; data-origin-width=&quot;1146&quot; data-origin-height=&quot;373&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;generateStaticParams 사용 전 (좌) generateStaticParams 사용 후 (우)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 라우팅은 기본적으로 SSR 방식으로 처리되지만, generateStaticParams를 사용하면 특정 경로에 한해 SSG처럼 정적 페이지를 미리 생성할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;generateStaticParams는 Next.js에서 정해진 함수 이름이며, 동적 라우트 페이지에서 어떤 경로를 미리 생성할지 지정하는 역할을 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727239878918&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type Props = {
    params: {
        category: string;
    };
};
export default function page({ params }: Props) {
    console.log(params);
    return &amp;lt;div&amp;gt;{params.category}&amp;lt;/div&amp;gt;;
}

export function generateStaticParams() {
    const categories = [&quot;javascript&quot;, &quot;react&quot;];
    return categories.map((category) =&amp;gt; ({
        category: category,
    }));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수는 빌드 시점에 javascript와 react 경로에 대해 미리 정적 페이지를 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, generateStaticParams를 사용하면 동적 라우팅 페이지도 특정 경로에 대해 미리 렌더링된 페이지를 제공할 수 있어 SSG처럼 동작하게 됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4.&amp;nbsp; NotFound 페이지 설정&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서 404 Not Found 페이지를 지정하려면 app 폴더 안에 not-found파일을 생성해주면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 Next.js에서 정한 규칙으로, 이 파일을 통해 에러 페이지를 커스터마이징할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727242564409&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// app/not-found.tsx
export default function NotFound() {
    return (
        &amp;lt;div&amp;gt;
            &amp;lt;h1&amp;gt;404 - 페이지를 찾을 수 없습니다&amp;lt;/h1&amp;gt;
            &amp;lt;p&amp;gt;요청한 페이지를 찾을 수 없습니다. 다시 홈으로 돌아가 주세요.&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 레이아웃 페이지 설정&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React를 사용할 때는 react-router-dom의 Outlet을 이용해, 헤더나 사이드바와 같은 공통 요소를 Outlet 바깥에 두고, 내용이 바뀌는 부분은 Outlet으로 처리했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Next.js에서는 layout.tsx 파일을 통해 이와 같은 기능을 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js는 폴더 기반 라우팅을 지원하므로, 공통 레이아웃을 설정할 수 있는 구조를 가지고 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;전체 레이아웃 수정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;app 폴더 바로 아래에 있는 layout.tsx 파일은 전체 페이지에 적용되는 레이아웃을 관리할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 파일 안에서 children 속성을 이용하여, 그 안에 페이지별로 다른 내용이 렌더링되도록 할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;특정 경로에서만 레이아웃 적용하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 경로에서만 공통 레이아웃을 설정하고 싶을 때는, 그 경로 안에 layout.tsx 파일을 생성하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, posts 경로 안에서만 공통 레이아웃을 적용하려면, posts 폴더에 layout.tsx 파일을 생성하여 관리할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1727246314717&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;posts
├── [category]
│&amp;nbsp;&amp;nbsp; └── page.tsx
├── layout.tsx
└── page.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1727246275584&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import Link from &quot;next/link&quot;;

export default function PostLayout({
    children,
}: {
    children: React.ReactNode;
}) {
    return (
        &amp;lt;&amp;gt;
            &amp;lt;nav className=&quot;p-4 mt-2 flex gap-2 border&quot;&amp;gt;
                &amp;lt;LinkButton href=&quot;/posts/&quot;&amp;gt;전체&amp;lt;/LinkButton&amp;gt;
                &amp;lt;LinkButton href=&quot;/posts/javascript&quot;&amp;gt;JavaScript&amp;lt;/LinkButton&amp;gt;
                &amp;lt;LinkButton href=&quot;/posts/react&quot;&amp;gt;React&amp;lt;/LinkButton&amp;gt;
            &amp;lt;/nav&amp;gt;
            &amp;lt;section&amp;gt;{children}&amp;lt;/section&amp;gt;
        &amp;lt;/&amp;gt;
    );
}

function LinkButton({
    href,
    children,
}: {
    href: string;
    children: React.ReactNode;
}) {
    return (
        &amp;lt;Link href={href} className=&quot;px-2&quot;&amp;gt;
            {children}
        &amp;lt;/Link&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 예시에서는 posts 경로 안에서 공통으로 사용되는 네비게이션을 설정했고, 각 경로에 따라 다른 콘텐츠가 children 속성으로 표시됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;layout.tsx를 활용하면 특정 경로에만 공통 요소를 적용할 수 있으며, 다른 경로는 영향을 받지 않도록 설정할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드에서 사용된 Link 태그는 Next.js에서 제공되며, a 태그와 유사한 기능을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Link 태그를 사용하는 이유는, Link 태그가 뷰포트에 들어오면 자동으로 링크된 페이지의 데이터를 pre-fetching하는 기능 덕분에 페이지 전환 속도가 빠르고 최적화에 유리해서 Link 태그를 사용합니다.&lt;/p&gt;</description>
      <category>Next.js/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/63</guid>
      <comments>https://hy-un.tistory.com/entry/Nextjs%EC%97%90%EC%84%9C%EC%9D%98-%EB%9D%BC%EC%9A%B0%ED%8C%85#entry63comment</comments>
      <pubDate>Wed, 25 Sep 2024 15:58:10 +0900</pubDate>
    </item>
    <item>
      <title>create-next-app으로 Next.js 프로젝트 시작하기</title>
      <link>https://hy-un.tistory.com/entry/create-next-app%EC%9C%BC%EB%A1%9C-Nextjs-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/getting-started/installation&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://nextjs.org/docs/getting-started/installation&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1727152951835&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;Getting Started: Installation | Next.js&quot; data-og-description=&quot;Create a new Next.js application with &amp;#96;create-next-app&amp;#96;. Set up TypeScript, styles, and configure your &amp;#96;next.config.js&amp;#96; file.&quot; data-og-host=&quot;nextjs.org&quot; data-og-source-url=&quot;https://nextjs.org/docs/getting-started/installation&quot; data-og-url=&quot;https://nextjs.org/docs/getting-started/installation&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bftsqW/hyW6EMfxIh/mVePh4WOrX2ysDf15rKqt0/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/bHX5hz/hyW6HPKtgF/15g1yrOoq40LWLBKZUvz2K/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/BJs4I/hyW6GQOUQW/KjpjL9kdwlta5wQp98imq0/img.png?width=1600&amp;amp;height=363&amp;amp;face=0_0_1600_363&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/getting-started/installation&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nextjs.org/docs/getting-started/installation&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bftsqW/hyW6EMfxIh/mVePh4WOrX2ysDf15rKqt0/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/bHX5hz/hyW6HPKtgF/15g1yrOoq40LWLBKZUvz2K/img.png?width=843&amp;amp;height=441&amp;amp;face=0_0_843_441,https://scrap.kakaocdn.net/dn/BJs4I/hyW6GQOUQW/KjpjL9kdwlta5wQp98imq0/img.png?width=1600&amp;amp;height=363&amp;amp;face=0_0_1600_363');&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;Getting Started: Installation | Next.js&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Create a new Next.js application with `create-next-app`. Set up TypeScript, styles, and configure your `next.config.js` file.&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;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 npx create-next-app@latest를 사용하여 Next.js 프로젝트를 설치하는 과정과 주요 프로젝트 구조를 간략하게 작성하겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-09-24 오후 3.52.29.png&quot; data-origin-width=&quot;1364&quot; data-origin-height=&quot;966&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dLnskF/btsJKThOK75/XtIu4D50siE0Q4MclrvgZK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dLnskF/btsJKThOK75/XtIu4D50siE0Q4MclrvgZK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dLnskF/btsJKThOK75/XtIu4D50siE0Q4MclrvgZK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdLnskF%2FbtsJKThOK75%2FXtIu4D50siE0Q4MclrvgZK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;510&quot; data-filename=&quot;스크린샷 2024-09-24 오후 3.52.29.png&quot; data-origin-width=&quot;1364&quot; data-origin-height=&quot;966&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;설치 과정 및 설정&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1727161281279&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npx create-next-app@latest       # Next.js 최신 버전 프로젝트 생성 명령어 실행
✔ What is your project named? &amp;hellip; my-app  # 프로젝트 이름 설정
✔ Would you like to use TypeScript? &amp;hellip; No / Yes  # TypeScript 사용 여부
✔ Would you like to use ESLint? &amp;hellip; No / Yes  # ESLint 사용 여부
✔ Would you like to use Tailwind CSS? &amp;hellip; No / Yes  # Tailwind CSS 사용 여부
✔ Would you like to use `src/` directory? &amp;hellip; No / Yes  # src/ 디렉터리 사용 여부
✔ Would you like to use App Router? (recommended) &amp;hellip; No / Yes  # App Router 사용 여부
✔ Would you like to customize the default import alias (@/*)? &amp;hellip; No / Yes  # Import Alias 설정 여부
✔ What import alias would you like configured? &amp;hellip; @/*  # Import alias 값 설정&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 순서대로 뜨면 각 항목에 대해 원하는 옵션을 선택하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-09-24 오후 4.03.32.png&quot; data-origin-width=&quot;2272&quot; data-origin-height=&quot;1760&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ckGoZu/btsJKfeRiJS/vg82tnQ8tNUomNvZcwf9m0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ckGoZu/btsJKfeRiJS/vg82tnQ8tNUomNvZcwf9m0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ckGoZu/btsJKfeRiJS/vg82tnQ8tNUomNvZcwf9m0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FckGoZu%2FbtsJKfeRiJS%2Fvg82tnQ8tNUomNvZcwf9m0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;558&quot; data-filename=&quot;스크린샷 2024-09-24 오후 4.03.32.png&quot; data-origin-width=&quot;2272&quot; data-origin-height=&quot;1760&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Next.js 주요 프로젝트 구조 설명&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 프로젝트를 생성하면 아래와 같은 파일이 생성됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. &lt;b&gt;src/app/page.tsx&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 파일은 홈페이지의 첫 번째 페이지 컴포넌트를 정의하는 파일입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서 app 디렉터리를 사용하는 경우, 이 page.tsx 파일은 기본적으로 루트 경로(/)에 해당하는 페이지로 사용됩니다. 즉, http://localhost:3000/으로 접속했을 때 보여지는 페이지가 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 파일 안에는 React 컴포넌트로 구성된 JSX 코드가 들어가며, 원하는 UI를 구성할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. &lt;b&gt;src/app/layout.tsx&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;layout.tsx 파일은 Next.js의 레이아웃 컴포넌트를 정의합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 페이지에 공통으로 적용되는 레이아웃을 정의할 수 있으며, header, footer, navbar 등 모든 페이지에 적용될 UI 요소들을 이 파일에서 관리할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;page.tsx의 내용이 이 레이아웃 안에서 렌더링됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, layout.tsx에서는 메타데이터 및 폰트 설정도 관리할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메타데이터 설정: metadata 객체를 사용하여 페이지 제목, 설명, Open Graph 등의 메타 정보를 정의할 수 있습니다. 이 메타데이터는 페이지의 &amp;lt;head&amp;gt; 태그에 자동으로 추가됩니다.&lt;/li&gt;
&lt;li&gt;폰트 설정: localFont를 통해 로컬 폰트를 불러와 페이지에 적용할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1727154583861&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import type { Metadata } from &quot;next&quot;;
import localFont from &quot;next/font/local&quot;;

const customFont = localFont({
  src: &quot;./fonts/CustomFont.woff&quot;,
  variable: &quot;--font-custom&quot;,
});

export const metadata: Metadata = {
  title: &quot;My Website&quot;,
  description: &quot;A great website built with Next.js&quot;,
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    &amp;lt;html lang=&quot;en&quot;&amp;gt;
      &amp;lt;body className={customFont.variable}&amp;gt;
        {children}
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-09-24 오후 2.20.49.png&quot; data-origin-width=&quot;376&quot; data-origin-height=&quot;220&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDq3N6/btsJJWsQ9gE/S7f8hkDVMZnVycurSl36x0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDq3N6/btsJJWsQ9gE/S7f8hkDVMZnVycurSl36x0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDq3N6/btsJJWsQ9gE/S7f8hkDVMZnVycurSl36x0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDq3N6%2FbtsJJWsQ9gE%2FS7f8hkDVMZnVycurSl36x0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;376&quot; height=&quot;220&quot; data-filename=&quot;스크린샷 2024-09-24 오후 2.20.49.png&quot; data-origin-width=&quot;376&quot; data-origin-height=&quot;220&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Next.js 주요 명령어&lt;/b&gt;&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;dev: 개발 모드&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;next dev는 개발할 때 사용하는 명령어로, 실시간으로 코드 변경 사항을 확인할 수 있는 개발용 서버를 실행합니다.&lt;/li&gt;
&lt;li&gt;개발 모드에서는 SSG로 설정된 페이지도 항상 SSR처럼 동작합니다. 이는 별도의 빌드 과정을 거치지 않기 때문에, 미리 생성된 정적 HTML 파일이 없고, 변경 사항을 즉각 반영해야 하기 때문입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;build: 프로젝트 빌드&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;next build는 프로젝트를 배포하기 전에 코드와 페이지를 빌드하는 명령어입니다.&lt;/li&gt;
&lt;li&gt;개발 중에 SSG와 SSR 동작 방식을 실제로 확인하고 싶을 때, 빌드를 통해 프로젝트를 미리 생성된 정적 페이지로 구성할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;start: 빌드 후 실행&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;next start는 프로젝트가 배포된 후, 빌드된 파일을 서버에서 실행하는 명령어입니다.&lt;/li&gt;
&lt;li&gt;배포 전 실제로 프로젝트가 어떻게 동작하는지 궁금할 때, 개발 중에도 빌드 후 start 명령어로 실행해볼 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>Next.js/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/61</guid>
      <comments>https://hy-un.tistory.com/entry/create-next-app%EC%9C%BC%EB%A1%9C-Nextjs-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0#entry61comment</comments>
      <pubDate>Tue, 24 Sep 2024 14:28:19 +0900</pubDate>
    </item>
    <item>
      <title>Next.js란? 그리고 CSR, SSR, SSG, ISR 이해하기</title>
      <link>https://hy-un.tistory.com/entry/Nextjs%EB%9E%80-%EA%B7%B8%EB%A6%AC%EA%B3%A0-CSR-SSR-SSG-ISR-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Next.js 란?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js는 리액트를 기반으로 웹 애플리케이션을 개발하기 위한 프레임워크입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리액트는 컴포넌트 단위로 UI를 구성하며 주로 단일 페이지 애플리케이션(SPA)과 클라이언트 사이드 렌더링(CSR)을 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 리액트만으로는 SEO 최적화, 초기 로딩 속도, 서버 측 렌더링 등 다양한 요구 사항을 해결하기에 한계가 있는데, Next.js는 이를 보완하여 다양한 렌더링 방식을 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 사이드 렌더링(SSR), 정적 사이트 생성(SSG), 클라이언트 사이드 렌더링(CSR)을 모두 지원하며, 이들을 결합한 하이브리드 렌더링으로 성능 최적화와 SEO 개선을 동시에 이룰 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 기능 덕분에 Next.js는 복잡한 웹 애플리케이션뿐만 아니라, 서버와 클라이언트를 모두 다루는 풀스택 애플리케이션 개발에도 적합한 도구로 자리 잡았습니다.&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;b&gt;CSR (Client Side Rendering)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSR은 리액트가 주로 사용하는 렌더링 방식으로, 클라이언트에서 모든 렌더링을 담당하는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저가 웹 페이지에 접속하면 클라이언트가 서버에 요청을 보내고, 서버는 텅 빈 HTML을 클라이언트에 전달합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 브라우저는 리액트 라이브러리와 우리가 작성한 자바스크립트 소스 코드를 다운로드해 메모리에 탑재하고, 리액트가 실행되면서 빈 HTML 페이지에 콘텐츠를 렌더링합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;- 장점&lt;/b&gt;&lt;/h3&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;- 단점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바스크립트가 로드된 후, createRoot와 같은 함수로 리액트 컴포넌트가 동적으로 root 요소에 결합되면서 전체 UI가 렌더링됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이로 인해 초기 로딩 속도가 느려질 수 있으며, 검색 엔진 크롤러가 빈 HTML을 탐색하기 때문에 SEO 최적화에도 불리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, 자바스크립트가 비활성화된 환경에서는 웹 애플리케이션이 동작하지 않으므로, CSR 방식은 SEO 및 접근성 측면에서 제약이 있을 수 있습니다.&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;b&gt;SSG (Static&amp;nbsp;Site&amp;nbsp;Generation)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSG (Static Site Generation) 은 빌드 시점에 HTML을 미리 생성해 서버에 배포하는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지가 자주 변하지 않는 경우 유용하며, 브라우저에서 해당 페이지에 접속하면 사전 생성된 HTML 파일을 빠르게 로드할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;- 장점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSG는 미리 렌더링된 HTML 파일을 서버 또는 CDN에 캐시해 두기 때문에 페이지 로딩 속도가 매우 빠릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, 자바스크립트가 없어도 HTML 파일을 통해 기본적인 컨텐츠를 사용자에게 제공할 수 있어 SEO 최적화에도 유리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 측에서 모든 코드를 다운로드하지 않기 때문에 보안성도 더 높습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;- 단점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사전에 생성된 HTML 파일이므로, 빌드 후 데이터를 실시간으로 갱신하는 것이 불가능합니다.&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;ISR (Incremental Static Regeneration)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ISR은 SSG의 단점을 보완한 방식으로, SSG처럼 사전 렌더링을 하지만 일정 주기로 페이지를 갱신하여 최신 데이터를 반영합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 주기에 따라 페이지를 다시 렌더링하고, 변경된 데이터를 반영합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ISR은 동적인 데이터를 어느 정도 반영하면서도 SSG의 장점을 유지할 수 있는 방식입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;- 장점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSG의 모든 장점을 유지하면서도 정해진 주기에 따라 데이터를 갱신할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 자주 변경되지 않는 데이터에는 적합하며, 자바스크립트 의존성 없이도 SEO 최적화가 가능하고, 보안에도 유리합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;- 단점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSG와 마찬가지로 실시간 데이터가 필요하거나 사용자 맞춤형 데이터를 제공해야 하는 경우에는 적합하지 않습니다.&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;SSR (Server Side Rendering)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSR은 사용자가 페이지에 접속할 때마다 서버에서 해당 페이지를 렌더링하여 HTML 파일을 제공하는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 페이지 요청 시 서버에서 즉시 HTML 파일을 생성해 사용자에게 제공하므로, 항상 최신 데이터를 반영할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;- 장점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 즉시 렌더링하여 실시간으로 최신 데이터를 제공할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, 사용자 맞춤형 데이터를 처리하는 데 유리하며, SEO 최적화에 적합합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;- 단점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSR은 요청이 있을 때마다 서버에서 페이지를 렌더링하므로 서버에 부하가 많이 걸릴 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 수가 많을수록 서버의 부담이 커질 수 있으며, 페이지 로딩 속도가 SSG나 ISR보다는 느릴 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 있을 때마다 서버에서 페이지를 렌더링하므로, 생성된 HTML 파일이 매번 다르게 생성됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때문에 정적 파일과 달리 동일한 HTML 파일을 재사용할 수 없어, CDN에 캐시하기가 어렵습니다.&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;b&gt;하이브리드 렌더링&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js는 다양한 렌더링 방식을 혼합하여 사용할 수 있는 하이브리드 웹앱을 지원합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 페이지의 특성에 맞게 최적의 렌더링 방식을 적용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 자주 변경되지 않는 정적인 콘텐츠는 SSG로 처리하고, 실시간 데이터가 필요한 페이지는 SSR을 사용하며, 주기적으로 업데이트되는 데이터는 ISR로 처리할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;한 페이지 내에서도 특정 섹션은 SSR로, 다른 섹션은 CSR로 렌더링할 수 있습니다. 이를 통해 성능과 사용자 경험을 극대화하면서도 개발자는 각기 다른 페이지 요구에 맞는 최적의 솔루션을 제공할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Hydration (하이드레이션)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서 페이지가 처음 로드될 때는 먼저 서버에서 생성된 HTML 파일이 클라이언트로 전송되어 UI를 즉시 표시합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 자바스크립트 코드가 로드되면, HTML 위에 리액트 컴포넌트가 결합되어 동적인 기능이 활성화됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정을 하이드레이션이라고 하며, 먼저 HTML을 보여주고 그 후에 리액트로 동적인 인터랙션을 처리할 수 있도록 하는 방식입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TTV(Time To View)와 TTI(Time To Interact)는 웹 성능에서 중요한 지표로, 하이드레이션은 TTV를 줄여 페이지를 빠르게 표시할 수 있게 하고, 자바스크립트가 로드되는 대로 TTI를 최적화해 사용자 인터랙션을 가능하게 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;어떤 렌더링 방식을 사용할지 결정하는 방법&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;사용자 맞춤 데이터가 필요한가&lt;/b&gt;: 사용자별로 달라지는 데이터를 제공해야 한다면 CSR 또는 SSR을 사용합니다. 사용자별로 민감한 데이터가 포함된 페이지는 보안을 고려해 서버에서 렌더링하는 SSR이 적합할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터가 자주 업데이트되는가&lt;/b&gt;: 데이터가 자주 업데이트되지 않는 경우에는 SSG 또는 ISR을 사용할 수 있습니다. 데이터가 주기적으로 변경된다면 ISR, 실시간 변경이 필요하다면 SSR을 사용하는 것이 좋습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SEO가 중요한가&lt;/b&gt;: SEO 최적화가 필요하다면 CSR보다는 SSR, SSG 또는 ISR을 사용하는 것이 적합합니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Next.js/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/60</guid>
      <comments>https://hy-un.tistory.com/entry/Nextjs%EB%9E%80-%EA%B7%B8%EB%A6%AC%EA%B3%A0-CSR-SSR-SSG-ISR-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0#entry60comment</comments>
      <pubDate>Mon, 23 Sep 2024 17:47:43 +0900</pubDate>
    </item>
    <item>
      <title>Sticky 요소의 너비를 화면 꽉 채우기</title>
      <link>https://hy-un.tistory.com/entry/Sticky-%EC%9A%94%EC%86%8C%EC%9D%98-%EB%84%88%EB%B9%84%EB%A5%BC-%ED%99%94%EB%A9%B4-%EA%BD%89-%EC%B1%84%EC%9A%B0%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;포트폴리오 사이트 프로젝트에서 화면이 768px 이하로 줄어들면, 탭 메뉴가 최상단에서 레이아웃의 중앙으로 배치되도록 했고,position sticky로 변경되도록 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 탭 메뉴의 상위 부모 컨테이너에 padding: 10px이 적용되어 있어, fixed로 되어 있는 헤더와 통일감을 주기 위해 탭 메뉴의 너비를 화면 전체로 설정하고 싶었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 다음과 같은 CSS 코드를 사용했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726992622510&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@media screen and (max-width: 768px) {
    .list {
        position: sticky;
        top: var(--header-height);
        z-index: 10;
        width: 100vw;
        margin-left: calc(-50vw + 50%);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;width: 100vw&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;100vw는 뷰포트 너비(Viewport Width) 전체를 차지하는 값입니다. 즉, 화면 전체의 너비만큼 요소를 확장시킵니다.&lt;/li&gt;
&lt;li&gt;부모 요소에 패딩이 있더라도, 요소 자체는 화면 전체를 차지하게 설정할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;margin-left: calc(-50vw + 50%)&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;-50vw: 요소를 화면의 절반만큼 왼쪽으로 이동시킵니다. 이 값은 100vw로 확장된 요소가 부모 컨테이너의 패딩을 무시하고, 화면 전체에 걸치도록 만드는 역할을 합니다.&lt;/li&gt;
&lt;li&gt;+50%: 화면 너비에 맞추어 요소가 완전히 중앙에 오도록 조정합니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>HTML &amp;amp; CSS</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/57</guid>
      <comments>https://hy-un.tistory.com/entry/Sticky-%EC%9A%94%EC%86%8C%EC%9D%98-%EB%84%88%EB%B9%84%EB%A5%BC-%ED%99%94%EB%A9%B4-%EA%BD%89-%EC%B1%84%EC%9A%B0%EA%B8%B0#entry57comment</comments>
      <pubDate>Sun, 22 Sep 2024 17:12:54 +0900</pubDate>
    </item>
    <item>
      <title>useNavigationType으로 탭 메뉴 클릭 시 스크롤 이동</title>
      <link>https://hy-un.tistory.com/entry/useNavigationType%EC%9C%BC%EB%A1%9C-%ED%83%AD-%EB%A9%94%EB%89%B4-%ED%81%B4%EB%A6%AD-%EC%8B%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%9D%B4%EB%8F%99</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Frame 7.png&quot; data-origin-width=&quot;1550&quot; data-origin-height=&quot;1316&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHFko0/btsJHp9TB8q/WQcOjGBw9xqdneJpAoULUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHFko0/btsJHp9TB8q/WQcOjGBw9xqdneJpAoULUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHFko0/btsJHp9TB8q/WQcOjGBw9xqdneJpAoULUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHFko0%2FbtsJHp9TB8q%2FWQcOjGBw9xqdneJpAoULUk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;611&quot; data-filename=&quot;Frame 7.png&quot; data-origin-width=&quot;1550&quot; data-origin-height=&quot;1316&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포트폴리오 사이트를 768px 이하의 화면에서는 사이드바에 있던 프로필을 화면 상단으로 이동시키고, 탭 메뉴를 가운데로 배치했습니다. 그러나 탭을 전환할 때마다 탭 메뉴로 다시 스크롤해야 하는 불편함이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 탭 메뉴를 고정(sticky)하고, 탭 클릭 시 자동으로 탭 메뉴 위치로 스크롤이 이동하도록 구현했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;스크롤 로직 구현&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React의 useRef와 useEffect를 활용해 스크롤 동작을 제어했고, useNavigationType을 사용하여 PUSH 방식의 네비게이션에서만 스크롤이 동작하도록 설정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 페이지 새로고침이나 첫 진입 시에는 스크롤이 발생하지 않도록 했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726991979242&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const navigationType = useNavigationType();
const ref = useRef&amp;lt;HTMLDivElement&amp;gt;(null);
const [paddingTop, setPaddingTop] = useState(0);

useEffect(() =&amp;gt; {
    const root = document.querySelector(&quot;#root&quot;);
    if (root) {
        setPaddingTop(parseInt(window.getComputedStyle(root).paddingTop, 10));
    }
}, []);

useEffect(() =&amp;gt; {
    if (window.innerWidth &amp;lt;= 768 &amp;amp;&amp;amp; navigationType === 'PUSH' &amp;amp;&amp;amp; ref.current) {
        const bottom = ref.current.getBoundingClientRect().bottom + window.scrollY - paddingTop;
        window.scrollTo({
            top: bottom,
            behavior: 'smooth',
        });
    }
}, [location.pathname, navigationType]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useNavigationType은 세 가지 네비게이션 타입을 제공합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;POP: 브라우저 뒤로가기/앞으로가기 버튼 클릭 시 발생합니다.&lt;/li&gt;
&lt;li&gt;REPLACE: 페이지 경로가 교체될 때 발생합니다.&lt;/li&gt;
&lt;li&gt;PUSH: 새로운 경로로 이동할 때 발생합니다.&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>React/Portfolio</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/56</guid>
      <comments>https://hy-un.tistory.com/entry/useNavigationType%EC%9C%BC%EB%A1%9C-%ED%83%AD-%EB%A9%94%EB%89%B4-%ED%81%B4%EB%A6%AD-%EC%8B%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%9D%B4%EB%8F%99#entry56comment</comments>
      <pubDate>Sun, 22 Sep 2024 17:08:53 +0900</pubDate>
    </item>
    <item>
      <title>홈 화면에 텍스트 타이핑 효과 구현하기</title>
      <link>https://hy-un.tistory.com/entry/%ED%99%88-%ED%99%94%EB%A9%B4%EC%97%90-%ED%85%8D%EC%8A%A4%ED%8A%B8-%ED%83%80%EC%9D%B4%ED%95%91-%ED%9A%A8%EA%B3%BC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;text.gif&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;628&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bl2TZQ/btsJE8NZoqh/2HtkvMUKRlvIyvvWEENA3k/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bl2TZQ/btsJE8NZoqh/2HtkvMUKRlvIyvvWEENA3k/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bl2TZQ/btsJE8NZoqh/2HtkvMUKRlvIyvvWEENA3k/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bl2TZQ/btsJE8NZoqh/2HtkvMUKRlvIyvvWEENA3k/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;720&quot; height=&quot;526&quot; data-filename=&quot;text.gif&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;628&quot;/&gt;&lt;/span&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;그래서 사이트가 처음 열릴 때 제일 먼저 보여지는 Home 컴포넌트에 동적인 애니메이션을 주어, 조금이라도 인상을 주기로 했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;타이핑 효과 구현 원리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, 저는 주요 기술 스택인 Javascript, TypeScript, React, Next.js를 동적으로 표시하고자 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 Next.js는 다루지 못하지만, 포트폴리오 작업이 끝난 후 배우고 학습 할 예정이기 때문에 일단 넣어뒀습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기술 이름들이 화면에 타이핑되었다가, 다시 지워지고, 다시 타이핑되는 패턴을 반복하게 만들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드를 통해 각 텍스트가 타이핑되고, 삭제되는 과정을 단계별로 관리했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726750646133&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const [displayText, setDisplayText] = useState(''); // 보여질 텍스트
const [isDeleting, setIsDeleting] = useState(false); // 삭제 중인지
const [loopNum, setLoopNum] = useState(0);
const [typingSpeed, setTypingSpeed] = useState(50);

const dynamicTexts = [&quot;Javascript&quot;, &quot;TypeScript&quot;, &quot;React&quot;, &quot;Next.js&quot;];
const currentText = dynamicTexts[loopNum % dynamicTexts.length];

useEffect(() =&amp;gt; {
    const handleTyping = () =&amp;gt; {
        if (!isDeleting &amp;amp;&amp;amp; displayText.length &amp;lt; currentText.length) {
            // 타이핑 중
            setDisplayText(currentText.substring(0, displayText.length + 1));
        } else if (isDeleting &amp;amp;&amp;amp; displayText.length &amp;gt; 0) {
            // 삭제 중
            setDisplayText(currentText.substring(0, displayText.length - 1));
        } else if (!isDeleting &amp;amp;&amp;amp; displayText.length === currentText.length) {
            // 타이핑 완료 후 대기
            setTimeout(() =&amp;gt; setIsDeleting(true), 1500);
        } else if (isDeleting &amp;amp;&amp;amp; displayText.length === 0) {
            // 삭제 완료 후 다음 텍스트로
            setIsDeleting(false);
            setLoopNum(loopNum + 1);
        }
    };

    const timer = setTimeout(handleTyping, typingSpeed);

    return () =&amp;gt; clearTimeout(timer);
}, [displayText, isDeleting]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useEffect를 사용하여 타이핑과 삭제 과정이 주기적으로 실행되도록 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 표시된 텍스트가 완전히 타이핑되면 일정 시간 후에 삭제되고, 그 후 다음 텍스트가 타이핑되는 순환 구조입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;loopNum을 사용하여 dynamicTexts 배열에서 현재 표시할 텍스트를 관리합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;UI 적용&lt;/h2&gt;
&lt;pre id=&quot;code_1726750690583&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;                &amp;lt;div className={styles.heading}&amp;gt;
                    &amp;lt;p&amp;gt;I work with&amp;lt;/p&amp;gt;
                    &amp;lt;div&amp;gt;
                        &amp;lt;span className={globals['text-primary']}&amp;gt;
                            {displayText}
                        &amp;lt;/span&amp;gt;
                        &amp;lt;span className={styles.cursor}&amp;gt;|&amp;lt;/span&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;CSS로 깜빡이는 커서 효과&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;keyframe을 이용하여 cursor 클래스에 1초 간격으로 커서가 사라졌다가 다시 나타나는 효과를 줬습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726750713295&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.cursor {
    display: inline-block;
    width: 1px;
    color: var(--primary);
    animation: blink 1s step-end infinite;
}

@keyframes blink {
    50% {
        opacity: 0;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React/Portfolio</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/55</guid>
      <comments>https://hy-un.tistory.com/entry/%ED%99%88-%ED%99%94%EB%A9%B4%EC%97%90-%ED%85%8D%EC%8A%A4%ED%8A%B8-%ED%83%80%EC%9D%B4%ED%95%91-%ED%9A%A8%EA%B3%BC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#entry55comment</comments>
      <pubDate>Thu, 19 Sep 2024 22:19:37 +0900</pubDate>
    </item>
    <item>
      <title>RSS를 이용해 티스토리 블로그 글 가져오기</title>
      <link>https://hy-un.tistory.com/entry/RSS%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%B4-%ED%8B%B0%EC%8A%A4%ED%86%A0%EB%A6%AC-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B8%80-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-09-17 오후 9.29.25.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1660&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WdcUL/btsJFvHJOgP/MlHdD1sOMKTpMMflXBDDz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WdcUL/btsJFvHJOgP/MlHdD1sOMKTpMMflXBDDz0/img.png&quot; data-alt=&quot;현재까지 진행중인 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WdcUL/btsJFvHJOgP/MlHdD1sOMKTpMMflXBDDz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWdcUL%2FbtsJFvHJOgP%2FMlHdD1sOMKTpMMflXBDDz0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;407&quot; data-filename=&quot;스크린샷 2024-09-17 오후 9.29.25.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1660&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;포트폴리오에 티스토리 블로그 글을 가져와서 Blog 컴포넌트를 꾸미려고 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에 Tistory Open API를 사용해 구현한 사례를 본 적이 있어, 이를 활용하려고 했는데 검색해보니 Tistory Open API가 서비스 종료되었다는 소식을 접하게 됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 다른 방법을 찾아보다가 RSS를 통해 블로그 글을 가져오는 방법을 알게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 RSS를 사용하여 구현을 진행했을 때 RSSTOJSON의 제한으로 인해 한 번에 10개의 데이터만 가져올 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래는 페이지네이션 기능도 넣으려 했으나, 이 제한 때문에 구현하지 못한 것이 아쉬웠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로 Next.js를 학습하여 RSSTOJSON을 사용하지 않고, RSS를 직접 파싱해서 페이지네이션까지 완벽하게 구현해보고 싶습니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;RSS란?&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;RSS(Really Simple Syndication)는 웹사이트의 콘텐츠를 자동으로 업데이트해주는 표준 XML 형식 파일입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이를 통해 특정 웹사이트나 블로그의 최신 글이나 업데이트 정보를 확인할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어, 티스토리 블로그는 &lt;a href=&quot;https://블로그이름.tistory.com/rss&quot;&gt;https://블로그이름.tistory.com/rss&lt;/a&gt; 형식의 URL로 RSS 피드를 제공합니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;RSSToJson&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://rss2json.com/docs&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://rss2json.com/docs&lt;/a&gt;&lt;/b&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1726573654733&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;API Documentation - rss2json.com&quot; data-og-description=&quot;order_dir No String Order direction of feed items to return,Possible values : asc or desc,Default value : desc . (api_key is required to use this parameter)&quot; data-og-host=&quot;rss2json.com&quot; data-og-source-url=&quot;https://rss2json.com/docs&quot; data-og-url=&quot;https://rss2json.com/docs&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cjbsPK/hyW6ziCr5X/bVObRbqHcdZ9azMn0uPe4k/img.png?width=231&amp;amp;height=231&amp;amp;face=0_0_231_231&quot;&gt;&lt;a href=&quot;https://rss2json.com/docs&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://rss2json.com/docs&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cjbsPK/hyW6ziCr5X/bVObRbqHcdZ9azMn0uPe4k/img.png?width=231&amp;amp;height=231&amp;amp;face=0_0_231_231');&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;API Documentation - rss2json.com&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;order_dir No String Order direction of feed items to return,Possible values : asc or desc,Default value : desc . (api_key is required to use this parameter)&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;rss2json.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;티스토리의 RSS 피드를 직접 호출하려고 시도하면 CORS에러가 발생합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이러한 문제를 해결하기 위해 rssToJson  API를 사용할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;rssToJson은 RSS 피드를 JSON 형식으로 변환해주는 서비스로,&amp;nbsp;이를 통해 CORS 문제를 우회하고 데이터를 더 쉽게 처리할 수 있습니다.&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;다음은 useQuery를 사용하여 티스토리 블로그의 RSS 피드를 가져오는 코드입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726574022349&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const {
    data: posts,
} = useQuery({
    queryKey: ['posts'],
    queryFn: async () =&amp;gt; {
        const response = await axios.get(
            `https://api.rss2json.com/v1/api.json?rss_url=${rssUrl}&amp;amp;api_key=${apiKey}`
        );
        return response.data.items;
    },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RSS 피드 URL에는 가끔 특수문자나 공백이 포함될 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 특수문자로 인해 URL이 제대로 인식되지 않거나 오류가 발생할 수 있으므로, encodeURIComponent를 사용하여 URL을 인코딩해주는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문서에서도 이를 권장하고 있으므로 사전에 문제를 방지하기 위해 인코딩을 사용해야 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726574145798&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const rssUrl = encodeURIComponent('https://hy-un.tistory.com/rss'); &lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;티스토리 블로그 데이터 처리하기&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-09-17 오후 9.06.56.png&quot; data-origin-width=&quot;2850&quot; data-origin-height=&quot;444&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmF5kx/btsJEqm2zEB/NUUA4kYrE5lKzYWPxNF901/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmF5kx/btsJEqm2zEB/NUUA4kYrE5lKzYWPxNF901/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmF5kx/btsJEqm2zEB/NUUA4kYrE5lKzYWPxNF901/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmF5kx%2FbtsJEqm2zEB%2FNUUA4kYrE5lKzYWPxNF901%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;112&quot; data-filename=&quot;스크린샷 2024-09-17 오후 9.06.56.png&quot; data-origin-width=&quot;2850&quot; data-origin-height=&quot;444&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RSS 피드를 통해 티스토리 블로그 데이터를 가져오다 보니 몇 가지 처리해야 할 부분들이 있었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. pubDate 시간 변환&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;티스토리에서 제공되는 pubDate 값은 UTC 시간으로 표시되기 때문에, 한국 시간과 다릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 적절히 변환해주는 처리가 필요하며, 이를 해결하기 위해 pubDate를 변환하는 함수를 구현했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726575115911&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const formattedDate = useCallback((dateString: string): string =&amp;gt; {
    const date = new Date(new Date(dateString).getTime() - new Date().getTimezoneOffset() * 60000);

    return [
        date.getFullYear(),
        (date.getMonth() + 1).toString().padStart(2, &quot;0&quot;),
        date.getDate().toString().padStart(2, &quot;0&quot;)
    ].join('.');
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;getTimezoneOffset을 사용하여 시간을 변환하는 로직에 대한 자세한 설명은 아래 블로그 포스트를 참고해주시면 됩니다.&lt;/p&gt;
&lt;figure id=&quot;og_1726575198228&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;자바스크립트 Date 객체 정리 및 관련 함수&quot; data-og-description=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Date&amp;nbsp;Date - JavaScript | MDNJavaScript Date 객체는 시간의 한 점을 플랫폼에 종속되지 않는 형태로 나타냅니다. Date 객체는 1970년 1월 1일 UTC(협&quot; data-og-host=&quot;hy-un.tistory.com&quot; data-og-source-url=&quot;https://hy-un.tistory.com/entry/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-Date-%EA%B0%9D%EC%B2%B4-%EC%A0%95%EB%A6%AC-%EB%B0%8F-%EA%B4%80%EB%A0%A8-%ED%95%A8%EC%88%98#getTimezoneOffset-1&quot; data-og-url=&quot;https://hy-un.tistory.com/entry/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-Date-%EA%B0%9D%EC%B2%B4-%EC%A0%95%EB%A6%AC-%EB%B0%8F-%EA%B4%80%EB%A0%A8-%ED%95%A8%EC%88%98&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/idMiG/hyW2SKSsgl/bxmJioWo4MpbK2sArr0mP1/img.jpg?width=750&amp;amp;height=750&amp;amp;face=0_0_750_750,https://scrap.kakaocdn.net/dn/CFuB7/hyW6A9FtXY/p08rvl5zatWbMO8nR9QSB0/img.jpg?width=750&amp;amp;height=750&amp;amp;face=0_0_750_750&quot;&gt;&lt;a href=&quot;https://hy-un.tistory.com/entry/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-Date-%EA%B0%9D%EC%B2%B4-%EC%A0%95%EB%A6%AC-%EB%B0%8F-%EA%B4%80%EB%A0%A8-%ED%95%A8%EC%88%98#getTimezoneOffset-1&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://hy-un.tistory.com/entry/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-Date-%EA%B0%9D%EC%B2%B4-%EC%A0%95%EB%A6%AC-%EB%B0%8F-%EA%B4%80%EB%A0%A8-%ED%95%A8%EC%88%98#getTimezoneOffset-1&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/idMiG/hyW2SKSsgl/bxmJioWo4MpbK2sArr0mP1/img.jpg?width=750&amp;amp;height=750&amp;amp;face=0_0_750_750,https://scrap.kakaocdn.net/dn/CFuB7/hyW6A9FtXY/p08rvl5zatWbMO8nR9QSB0/img.jpg?width=750&amp;amp;height=750&amp;amp;face=0_0_750_750');&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;자바스크립트 Date 객체 정리 및 관련 함수&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Date&amp;nbsp;Date - JavaScript | MDNJavaScript Date 객체는 시간의 한 점을 플랫폼에 종속되지 않는 형태로 나타냅니다. Date 객체는 1970년 1월 1일 UTC(협&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;hy-un.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. description의 HTML 태그 처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;description 필드는 HTML 태그가 포함된 상태로 제공되기 때문에 이를 처리하기 위해 DOMParser를 사용했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726575615447&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const parser = new DOMParser();
const doc = parser.parseFromString(post.description, 'text/html');
const strText = doc.body.textContent || '';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 parser.parseFromString(post.description, 'text/html'); 는 HTML 문자열을 파싱해서 DOM 객체로 변환해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 description에 포함된 HTML 태그들이 DOM 구조로 파싱되며, 이후 textContent를 통해 순수한 텍스트만 추출할 수 있습니다.&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;3. 빈 문자열로 오는 thumbnail&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;티스토리 RSS 피드에서 thumbnail 필드가 빈 문자열로 옵니다. 빈 값이 오면 이미지가 표시되지 않기 때문에, 해결 방법을 찾아야 하지만, 아직 적절한 방법을 찾지 못했습니다.&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;</description>
      <category>React/Portfolio</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/54</guid>
      <comments>https://hy-un.tistory.com/entry/RSS%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%B4-%ED%8B%B0%EC%8A%A4%ED%86%A0%EB%A6%AC-%EB%B8%94%EB%A1%9C%EA%B7%B8-%EA%B8%80-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0#entry54comment</comments>
      <pubDate>Tue, 17 Sep 2024 21:43:02 +0900</pubDate>
    </item>
    <item>
      <title>React Router NavLink로 메뉴 활성 상태 간편하게 처리하기</title>
      <link>https://hy-un.tistory.com/entry/React-Router-NavLink%EB%A1%9C-%EB%A9%94%EB%89%B4-%ED%99%9C%EC%84%B1-%EC%83%81%ED%83%9C-%EA%B0%84%ED%8E%B8%ED%95%98%EA%B2%8C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;포트폴리오를 만드는 중, 페이지 이동을 위한 탭 메뉴에서 현재 경로에 맞는 메뉴에만 다른 스타일을 적용하려고 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 location.pathname을 사용해 각 경로에 맞춰 active 클래스를 추가하려 했었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 이렇게 하면 경로를 추가하거나 변경할 때마다 해당 경로가 맞는지 일일이 확인해야 하는 번거로움이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 /home 경로로 이동할 때는 pathname에 'home'이 포함되어 있는지를 확인하는 조건문을 넣고, 또 다른 경로가 추가되면 그에 맞춰 코드를 수정해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다가&amp;nbsp; React Router에서 제공하는 NavLink  컴포넌트를 알게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 컴포넌트를 사용하면 현재 경로에 맞는 메뉴에 자동으로 스타일을 적용할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NavLink는 내부적으로 경로를 비교해 active 상태를 관리해 주기 때문에, 제가 일일이 pathname을 확인할 필요가 없어진 것입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;NavLink 사용법&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NavLink의 기본 사용법은 매우 간단합니다. 아래처럼 to 속성에 원하는 경로를 지정해주고, className 속성에 함수를 전달하면 경로가 활성화되었을 때 해당 클래스가 적용됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726459790105&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { NavLink, useLocation } from 'react-router-dom';

export default function TabList() {
    const location = useLocation();

    const isRoot = location.pathname === '/';

    return (
        &amp;lt;div className={styles.list}&amp;gt;
            &amp;lt;NavLink
                to=&quot;/home&quot;
                className={({ isActive }) =&amp;gt; 
                    `${styles.item} ${(isActive || isRoot) ? styles.active : ''}`
                }
            &amp;gt;
                &amp;lt;span&amp;gt;Home&amp;lt;/span&amp;gt;
            &amp;lt;/NavLink&amp;gt;
            &amp;lt;NavLink
                to=&quot;/projects&quot;
                className={({ isActive }) =&amp;gt; 
                    `${styles.item} ${isActive ? styles.active : ''}`
                }
            &amp;gt;
                &amp;lt;span&amp;gt;Projects&amp;lt;/span&amp;gt;
            &amp;lt;/NavLink&amp;gt;
            &amp;lt;NavLink
                to=&quot;/blog&quot;
                className={({ isActive }) =&amp;gt; 
                    `${styles.item} ${isActive ? styles.active : ''}`
                }
            &amp;gt;
                &amp;lt;span&amp;gt;Blog&amp;lt;/span&amp;gt;
            &amp;lt;/NavLink&amp;gt;
            &amp;lt;NavLink
                to=&quot;/temp&quot;
                className={({ isActive }) =&amp;gt; 
                    `${styles.item} ${isActive ? styles.active : ''}`
                }
            &amp;gt;
                &amp;lt;span&amp;gt;Temp&amp;lt;/span&amp;gt;
            &amp;lt;/NavLink&amp;gt;
        &amp;lt;/div&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;URL 파라미터가 있는 경우 end 속성 사용&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 경로에 URL 파라미터가 없어서 isActive만을 사용했지만, URL 파라미터를 무시하고 경로의 정확한 일치만을 원할 경우 end 속성을 추가하여 사용하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 /projects/123처럼 URL 파라미터가 경로에 붙는 상황에서는 end 속성을 사용하여 정확한 경로 일치만 활성화할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726459876139&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;NavLink to=&quot;/projects&quot; end&amp;gt;
    Projects
&amp;lt;/NavLink&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 end 속성을 추가하면 /projects/123와 같은 경로는 활성화되지 않으며, /projects 경로에만 active 클래스가 적용됩니다.&lt;/p&gt;</description>
      <category>React/Portfolio</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/52</guid>
      <comments>https://hy-un.tistory.com/entry/React-Router-NavLink%EB%A1%9C-%EB%A9%94%EB%89%B4-%ED%99%9C%EC%84%B1-%EC%83%81%ED%83%9C-%EA%B0%84%ED%8E%B8%ED%95%98%EA%B2%8C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0#entry52comment</comments>
      <pubDate>Mon, 16 Sep 2024 13:13:27 +0900</pubDate>
    </item>
    <item>
      <title>OpenWeatherMap API로 날씨 Web 만들기 - 마무리</title>
      <link>https://hy-un.tistory.com/entry/OpenWeatherMap-API%EB%A1%9C-%EB%82%A0%EC%94%A8-Web-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EB%A7%88%EB%AC%B4%EB%A6%AC</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;이 프로젝트의 전체 코드는 GitHub에서 확인하실 수 있습니다.&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/GangHyun95/weather&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/GangHyun95/weather&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1726210049578&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;GitHub - GangHyun95/weather: OpenWeather Api&quot; data-og-description=&quot;OpenWeather Api. Contribute to GangHyun95/weather development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/GangHyun95/weather&quot; data-og-url=&quot;https://github.com/GangHyun95/weather&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/hlKHg/hyW2T9ZCaI/mMGYudbKLsfa5AFiJXyDrK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/GangHyun95/weather&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/GangHyun95/weather&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/hlKHg/hyW2T9ZCaI/mMGYudbKLsfa5AFiJXyDrK/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;GitHub - GangHyun95/weather: OpenWeather Api&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;OpenWeather Api. Contribute to GangHyun95/weather 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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;screencapture-localhost-3000-2024-09-13-14_57_04.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;2172&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZaaiP/btsJCIua6JC/SjVkMvbt7CnCmfeF9mjZCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZaaiP/btsJCIua6JC/SjVkMvbt7CnCmfeF9mjZCK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZaaiP/btsJCIua6JC/SjVkMvbt7CnCmfeF9mjZCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZaaiP%2FbtsJCIua6JC%2FSjVkMvbt7CnCmfeF9mjZCK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;532&quot; data-filename=&quot;screencapture-localhost-3000-2024-09-13-14_57_04.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;2172&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저번 포스팅에서 프로젝트를 마무리하려 했지만, Vercel로 배포한 후 이미지 관련 문제가 발생해서 이번에 추가로 작성하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트를 Vercel로 배포한 후, 데스크탑에서는 아이콘이 정상적으로 보였지만, 모바일 기기에서는 아이콘이 뜨지 않고 alt 텍스트만 출력되는 문제가 발생했습니다. OpenWeatherMap에서 제공하는 아이콘을 그대로 사용하고 있었으며, 경로는 다음과 같이 설정되어 있었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726208171182&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const iconUrl = `http://openweathermap.org/img/wn/${icon}.png`;
&amp;lt;img
  src={iconUrl}
  width=&quot;48&quot;
  height=&quot;48&quot;
  alt={description}
  className={styles.img}
  title={description}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 해결 해 보려고 휴대폰을 노트북에 연결해 개발자 도구로 확인해봤지만, 특별한 에러 메시지는 나타나지 않아서 문제가 뭔지는 사실 모르겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenWeatherMap에서 제공하는 아이콘 중 night 테마의 아이콘들이 웹사이트 배경색과 겹쳐 보이기도 해서, 아이콘을 교체할지 고민하고 있었는데, 모바일에서 이미지가 제대로 뜨지 않는 상황까지 겹치면서 로컬 이미지를 사용하기로 결정했습니다.&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;b&gt;로컬 이미지 경로 설정&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_edited_스크린샷 2024-09-13 오후 3.23.21.png&quot; data-origin-width=&quot;1220&quot; data-origin-height=&quot;1271&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ooMLB/btsJCCnnkdr/lbOM23iUZWGixQihRLNAaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ooMLB/btsJCCnnkdr/lbOM23iUZWGixQihRLNAaK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ooMLB/btsJCCnnkdr/lbOM23iUZWGixQihRLNAaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FooMLB%2FbtsJCCnnkdr%2FlbOM23iUZWGixQihRLNAaK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;480&quot; height=&quot;500&quot; data-filename=&quot;edited_edited_스크린샷 2024-09-13 오후 3.23.21.png&quot; data-origin-width=&quot;1220&quot; data-origin-height=&quot;1271&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenWeatherMap API에서는 위와 같은 형식으로 아이콘 파일을 제공합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;저는 로컬 프로젝트의 src 폴더에 동일한 파일명 형식을 유지하면서 새로운 아이콘으로 저장했습니다. 이렇게 함으로써 API에서 받아오는 아이콘 이름을 그대로 사용할 수 있도록 했습니다.&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 data-ke-size=&quot;size16&quot;&gt;이미지를 로컬 src 폴더에 저장하고, 이미지 경로를 설정하는 방법에는 두 가지가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;import : 이미지를 정적으로 불러올 때 사용하는 방식입니다. 예를 들어, 특정 아이콘을 직접 지정할 경우 아래와 같이 사용합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726208326010&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import iconImage from '../../assets/images/icon.png';

&amp;lt;img src={iconImage} alt=&quot;Icon&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 이 방식은 이미지 파일명이 고정되어 있을 때 적합합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;require : 이미지 경로가 동적으로 변경될 경우 사용합니다. 프로젝트에서 사용하는 아이콘 파일 이름이 동적으로 변경되기 때문에 require 방식을 선택했습니다. 예를 들어, 아이콘 파일 이름이 API에서 받아오는 icon 값에 따라 변경되는 경우 아래와 같이 설정할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726208366063&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const iconUrl = require(`../../assets/images/${icon}.png`);
&amp;lt;img src={iconUrl} alt={description} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 로컬 이미지를 적용하여, 모바일 기기에서도 정상적으로 아이콘이 표시되도록 수정했습니다.&lt;/p&gt;</description>
      <category>React/Weather</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/51</guid>
      <comments>https://hy-un.tistory.com/entry/OpenWeatherMap-API%EB%A1%9C-%EB%82%A0%EC%94%A8-Web-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EB%A7%88%EB%AC%B4%EB%A6%AC#entry51comment</comments>
      <pubDate>Fri, 13 Sep 2024 15:28:52 +0900</pubDate>
    </item>
    <item>
      <title>자바스크립트 Date 객체 정리 및 관련 함수</title>
      <link>https://hy-un.tistory.com/entry/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-Date-%EA%B0%9D%EC%B2%B4-%EC%A0%95%EB%A6%AC-%EB%B0%8F-%EA%B4%80%EB%A0%A8-%ED%95%A8%EC%88%98</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Date&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Date&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1726203455081&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;Date - JavaScript | MDN&quot; data-og-description=&quot;JavaScript Date 객체는 시간의 한 점을 플랫폼에 종속되지 않는 형태로 나타냅니다. Date 객체는 1970년 1월 1일 UTC(협정 세계시) 자정과의 시간 차이를 밀리초로 나타내는 정수 값을 담습니다.&quot; data-og-host=&quot;developer.mozilla.org&quot; data-og-source-url=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Date&quot; data-og-url=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Date&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/GMt55/hyW21mDTKi/Dn8kOYcDOttxnOywNX95kK/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Date&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Date&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/GMt55/hyW21mDTKi/Dn8kOYcDOttxnOywNX95kK/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080');&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;Date - JavaScript | MDN&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;JavaScript Date 객체는 시간의 한 점을 플랫폼에 종속되지 않는 형태로 나타냅니다. Date 객체는 1970년 1월 1일 UTC(협정 세계시) 자정과의 시간 차이를 밀리초로 나타내는 정수 값을 담습니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.mozilla.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 개의 개인 프로젝트를 진행하면서 Date 객체를 자주 사용하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자주 쓰이는 만큼, 이번 기회에 Date 객체를 정리하고, 이를 활용해 구현한 메서드들도 함께 정리해보려 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Date 객체란?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Date 객체는 자바스크립트에서 날짜와 시간을 표현하기 위한 기본 객체입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 날짜와 시간을 처리하며, Unix 시간을 기반으로 동작합니다. 1970년 1월 1일 자정 이후 경과된 밀리초를 기준으로 계산됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Date 객체 생성 방법&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1726178122713&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 현재 날짜와 시간으로 생성
const now = new Date();

// 특정 날짜와 시간을 문자열로 지정하여 생성
const specificDate = new Date('2024-09-13T10:00:00');

// 연도, 월, 일, 시간, 분, 초를 인자로 전달하여 생성
const specificTime = new Date(2024, 8, 13, 10, 30, 45); // 2024년 9월 13일 10시 30분 45초

// 시간 정보 없이 연도, 월, 일만 지정하여 생성 (시간은 기본적으로 00:00:00으로 설정됨)
const onlyDate = new Date(2024, 8, 13); // 2024년 9월 13일

// 연도와 월만 지정하여 생성 (일은 기본적으로 1일로 설정됨, 시간은 00:00:00)
const onlyYearMonth = new Date(2024, 8); // 2024년 9월

// 밀리초 단위로 생성 (1970년 1월 1일 이후 경과된 밀리초 값 사용)
const dateFromMillis = new Date(1600000000000);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Date 객체의 주요 메서드들&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;날짜 및 시간 정보 가져오기&lt;/h3&gt;
&lt;pre id=&quot;code_1726178252632&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 현재 연도를 가져옵니다.
const year = now.getFullYear();

// 현재 월을 가져옵니다 (0: 1월, 11: 12월).
const month = now.getMonth();

// 현재 일을 가져옵니다.
const date = now.getDate();

// 현재 요일을 가져옵니다 (0: 일요일, 6: 토요일).
const day = now.getDay();

// 현재 시간을 가져옵니다 (0~23).
const hours = now.getHours();

// 현재 분을 가져옵니다 (0~59).
const minutes = now.getMinutes();

// 현재 초를 가져옵니다 (0~59).
const seconds = now.getSeconds();&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;UTC 시간 정보 가져오기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 시간은 사용자의 시스템이 설정된 시간대를 기준으로 값을 반환합니다. 각 국가나 지역의 시간대에 따라 다를 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UTC 시간은 협정 세계시(UTC)를 기준으로, 사용자의 시스템 시간대와 상관없이 동일한 시간을 반환합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726178324197&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const utcYear = now.getUTCFullYear();
const utcMonth = now.getUTCMonth();
const utcDate = now.getUTCDate();
const utcDay = now.getUTCDay();&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;날짜 및 시간 설정하기&lt;/h3&gt;
&lt;pre id=&quot;code_1726178355201&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const newDate = new Date();
newDate.setFullYear(2025); // 연도를 2025년으로 설정
newDate.setMonth(11); // 월을 12월로 설정 (0부터 시작하므로 11이 12월을 의미)
newDate.setDate(25); // 날짜를 25일로 설정
console.log(newDate);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;getTimezoneOffset&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: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;getTimezoneOffset() 메서드는 현재 로컬 시간과 UTC 시간 간의 차이를 분 단위로 반환합니다.&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: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이 값은 양수 또는 음수로 반환되며, 이를 이용해 로컬 시간에서 UTC 시간으로 변환하거나, 반대로 UTC 시간에서 로컬 시간으로 변환할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1726193268940&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const now = new Date();
const timezoneOffset = now.getTimezoneOffset(); // 분 단위 반환
console.log(timezoneOffset); // 한국의 경우 UTC+9, -540 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;getTimezoneOffset()으로 받은 값은 분 단위이기 때문에, 밀리초로 변환하려면 60000(1분 = 60000 밀리초)을 곱합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 값을 사용해 시간을 보정하면, UTC 기준의 시간을 현지 시간으로 변환할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726193304993&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function getLocalTime(date: Date) {
    return new Date(date.getTime() - date.getTimezoneOffset() * 60000);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;프로젝트에서 사용한 Date 객체 관련 함수들&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해당 년,월의 일자 얻기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;달력을 구현할 때 사용한 메서드입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전달된 연도와 월을 기반으로 해당 월의 모든 일자를 배열로 반환합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 주의 경우, 첫 번째 날이 시작되기 전에 빈 칸을 채워 달력의 정렬을 맞출 수 있도록 구현했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726179844530&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function getMonthDays(year: number, month: number) {
    const days: (number | null)[] = [];
    const date = new Date(year, month, 1);
    const firstDayOfWeek = date.getDay();

    // 첫 번째 날 이전의 빈 칸 채우기
    for (let i = 0; i &amp;lt; firstDayOfWeek; i++) {
        days.push(null);
    }

    // 해당 월의 날짜 추가
    while (date.getMonth() === month) {
        days.push(date.getDate());
        date.setDate(date.getDate() + 1);
    }
    return days;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주어진 시간과 현재 시간의 차이 계산 및 상대적 시간 표시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;YouTube Data API에서 각 비디오의 생성 시기는 &quot;2024-09-10T19:30:06Z&quot;와 같은 문자열로 제공됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문자열을 사용해 새로운 Date 객체를 생성하고, 현재 시간과의 차이를 계산하는 로직입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;diffInSeconds는 getTime()으로 밀리초 단위로 반환되므로, 초 단위로 변환하기 위해 1000으로 나눴습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;secondsInMinute, secondsInHour, secondsInDay, secondsInWeek, secondsInMonth, secondsInYear는 각각 1분, 1시간, 1일, 1주, 1개월, 1년을 초 단위로 표현한 값입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 값들을 바탕으로 Math.floor를 사용해 정수로 반올림한 후, &quot;몇 분 전&quot;, &quot;몇 시간 전&quot;, &quot;몇 일 전&quot;과 같은 형식으로 시간 차이를 포맷합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726184247488&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export function formatDateTime(dateString: string): string {
    const now = new Date();
    const targetDate = new Date(dateString);
    const diffInSeconds = Math.floor((now.getTime() - targetDate.getTime()) / 1000);

    const secondsInMinute = 60;
    const secondsInHour = 60 * secondsInMinute;
    const secondsInDay = 24 * secondsInHour;
    const secondsInWeek = 7 * secondsInDay;
    const secondsInMonth = 30 * secondsInDay;
    const secondsInYear = 365 * secondsInDay;

    if (diffInSeconds &amp;lt; secondsInHour) {
        const minutes = Math.floor(diffInSeconds / secondsInMinute);
        return `${minutes}분 전`;
    } else if (diffInSeconds &amp;lt; secondsInDay) {
        const hours = Math.floor(diffInSeconds / secondsInHour);
        return `${hours}시간 전`;
    } else if (diffInSeconds &amp;lt; secondsInWeek) {
        const days = Math.floor(diffInSeconds / secondsInDay);
        return `${days}일 전`;
    } else if (diffInSeconds &amp;lt; 14 * secondsInDay) {
        const weeks = Math.floor(diffInSeconds / secondsInWeek);
        return `${weeks}주 전`;
    } else if (diffInSeconds &amp;lt; secondsInMonth) {
        const days = Math.floor(diffInSeconds / secondsInDay);
        return `${days}일 전`;
    } else if (diffInSeconds &amp;lt; secondsInYear) {
        const months = Math.floor(diffInSeconds / secondsInMonth);
        return `${months}개월 전`;
    } else {
        const years = Math.floor(diffInSeconds / secondsInYear);
        return `${years}년 전`;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&quot;YYYY-MM-DD&quot; 형태의 문자열로 포맷하기&lt;/h3&gt;
&lt;pre id=&quot;code_1726265230602&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export function formatISODate(date: Date) {
    return new Date(date.getTime() - date.getTimezoneOffset() * 60000)
        .toISOString()
        .split(&quot;T&quot;)[0];
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;YYYY-MM-DD&quot; 형식의 문자열로 날짜를 포맷하기 위해 formatISODate 함수를 작성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;getFullYear, getMonth, getDate를 사용하는 것이 더 직관적이지만, 학습을 위해 getTimezoneOffset()과 toISOString()을 사용해 변환해보았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;getTimezoneOffset()은 로컬 시간과 UTC 시간의 차이를 분 단위로 반환하며, 이를 밀리초로 변환하기 위해 * 60000을 곱했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 로컬 시간과의 차이를 보정한 후 toISOString()을 사용하면, UTC 기준으로 &quot;2024-09-24T15:00&quot;과 같은 형식의 문자열을 반환합니다. 여기서 필요한 날짜 부분만 추출해 &quot;YYYY-MM-DD&quot; 형식으로 포맷했습니다.&lt;/p&gt;</description>
      <category>JavaScript</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/50</guid>
      <comments>https://hy-un.tistory.com/entry/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-Date-%EA%B0%9D%EC%B2%B4-%EC%A0%95%EB%A6%AC-%EB%B0%8F-%EA%B4%80%EB%A0%A8-%ED%95%A8%EC%88%98#entry50comment</comments>
      <pubDate>Fri, 13 Sep 2024 09:43:26 +0900</pubDate>
    </item>
    <item>
      <title>OpenWeatherMap API로 날씨 Web 만들기 - 3</title>
      <link>https://hy-un.tistory.com/entry/OpenWeatherMap-API%EB%A1%9C-%EB%82%A0%EC%94%A8-Web-%EB%A7%8C%EB%93%A4%EA%B8%B0-3</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;screencapture-localhost-3000-2024-09-11-16_22_23.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;2172&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ddiics/btsJyIn26og/6IQADhqDcyPXLXB8fKIkQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ddiics/btsJyIn26og/6IQADhqDcyPXLXB8fKIkQk/img.png&quot; data-alt=&quot;전체 레이아웃&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ddiics/btsJyIn26og/6IQADhqDcyPXLXB8fKIkQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fddiics%2FbtsJyIn26og%2F6IQADhqDcyPXLXB8fKIkQk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;532&quot; data-filename=&quot;screencapture-localhost-3000-2024-09-11-16_22_23.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;2172&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;스크린샷 2024-09-11 오후 4.22.45.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1660&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mDxIY/btsJy1Vgojn/liBSPNxc0nxWU58An5ZOL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mDxIY/btsJy1Vgojn/liBSPNxc0nxWU58An5ZOL0/img.png&quot; data-alt=&quot;검색어 입력시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mDxIY/btsJy1Vgojn/liBSPNxc0nxWU58An5ZOL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmDxIY%2FbtsJy1Vgojn%2FliBSPNxc0nxWU58An5ZOL0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;407&quot; data-filename=&quot;스크린샷 2024-09-11 오후 4.22.45.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1660&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;edited_스크린샷 2024-09-11 오후 9.53.39.png&quot; data-origin-width=&quot;1004&quot; data-origin-height=&quot;124&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bow9zy/btsJzEFoSCd/ZKdPWfw0UUJCjk0846YLK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bow9zy/btsJzEFoSCd/ZKdPWfw0UUJCjk0846YLK0/img.png&quot; data-alt=&quot;검색 중일 때 로딩 스피너&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bow9zy/btsJzEFoSCd/ZKdPWfw0UUJCjk0846YLK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbow9zy%2FbtsJzEFoSCd%2FZKdPWfw0UUJCjk0846YLK0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;89&quot; data-filename=&quot;edited_스크린샷 2024-09-11 오후 9.53.39.png&quot; data-origin-width=&quot;1004&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenWeatherMap API에는 도시 이름과 국가 코드를 함께 입력하거나, 위도와 경도 좌표, 도시 ID, 우편번호로 검색할 수 있는 방법이 있지만, 사용자는 이런 복잡한 방법으로 검색을 하지 않기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다가 OpenWeatherMap의 Direct Geocoding API를 찾았고, 이 API에선 검색어가 포함된 여러 도시와 그 좌표가 함께 반환되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 활용해 검색어에 맞는 도시 리스트를 보여주고, 선택된 도시의 위도와 경도로 Recoil에 저장된 location 값을 변경하는 방식으로 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 시간대별 날씨를 보여주는 HourlyForecast 컴포넌트를 추가했고, 기존에는 단일 온도만 표시되던 Forecast컴포넌트에서 최고온도와 최저온도를 함께 보여주도록 변경했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에서는 위 내용들을 어떻게 구현했는지 작성하겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;weatherAPI&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WeatherAPI에는 위에서 설명한 Direct Geocoding API 요청을 추가했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726040784111&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;async getCityCoords(query: string) {
    const response = await axios.get(&quot;https://api.openweathermap.org/geo/1.0/direct&quot;, {
        params: {
            q: query,
            limit: 5,
            appid: process.env.REACT_APP_WEATHER_API_KEY,
        },
    });
    return response.data;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Header 컴포넌트&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1726041532355&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface CityResult {
    name: string;
    country: string;
    lat: number;
    lon: number;
    local_names: {
        ko: string;
    };
}

export default function Header() {
    const [location, setLocation] = useRecoilState(locationState);
    const [text, setText] = useState(&quot;&quot;);
    const [isFocused, setIsFocused] = useState(false);
    const [isExpanded, setIsExpanded] = useState(false);
    const [searchResults, setSearchResults] = useState&amp;lt;CityResult[]&amp;gt;([]);
    const defaultLocation = { lat: 37.5145, lon: 127.0495 };
    const [debouncedText, setDebouncedText] = useState(text);
    const [isLoading, setIsLoading] = useState(false);

    const { mutate: searchCities } = useMutation({
        mutationFn: async (query: string) =&amp;gt; {
            const response = await WeatherApi.getCityCoords(query);
            return response;
        },
        onSuccess: (data: CityResult[]) =&amp;gt; {
            setSearchResults(data);
            setIsLoading(false);
        },
        onError: (error: Error) =&amp;gt; {
            console.error(&quot;검색 중 오류 발생:&quot;, error);
            setIsLoading(false);
        },
    });

    const handleInputChange = (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
        const value = e.target.value.trim();
        setText(e.target.value);
        setIsExpanded(value.length &amp;gt; 0);
        setIsLoading(value.length &amp;gt; 0);
    };

    useEffect(() =&amp;gt; {
        if (debouncedText) {
            searchCities(debouncedText);
        }
    }, [debouncedText]);

    useEffect(() =&amp;gt; {
        const handler = setTimeout(() =&amp;gt; {
            setDebouncedText(text);
        }, 500);

        return () =&amp;gt; {
            clearTimeout(handler);
        };
    }, [text]);

    const handleFocus = () =&amp;gt; {
        setIsFocused(true);
    };

    const handleBlur = () =&amp;gt; {
        setIsFocused(false);
    };

    // 기존에 있던 getLocation 함수 정의 생략

    const handleResultClick = (lat: number, lon: number) =&amp;gt; {
        setLocation({ lat: lat, lon: lon });
        setText('');
        setIsExpanded(false);
    };

    return (
        &amp;lt;header className={styles.header}&amp;gt;
            &amp;lt;div className={styles.container}&amp;gt;
            	{/* 로고 생략 */}
                &amp;lt;form
                    className={`${styles.form} ${isLoading ? styles.searching : &quot;&quot;} ${(isFocused &amp;amp;&amp;amp; isExpanded) ? styles.expanded : ''}`}
                    onSubmit={handleSubmit}
                &amp;gt;
                    &amp;lt;div&amp;gt;
                        &amp;lt;input
                            className={styles.input}
                            type=&quot;search&quot;
                            name=&quot;search&quot;
                            placeholder=&quot;검색&quot;
                            autoComplete=&quot;off&quot;
                            value={text}
                            onChange={handleInputChange}
                            onFocus={handleFocus}
                            onBlur={handleBlur}
                        /&amp;gt;
                        &amp;lt;button className={`${styles[&quot;search-icon&quot;]} ${styles.icon}`}&amp;gt;
                            &amp;lt;IoIosSearch /&amp;gt;
                        &amp;lt;/button&amp;gt;
                    &amp;lt;/div&amp;gt;
                    &amp;lt;div className={`${styles.result} ${isFocused &amp;amp;&amp;amp; isExpanded ? styles.active : &quot;&quot;}`}&amp;gt;
                        &amp;lt;ul className={styles.list}&amp;gt;
                            {searchResults.map((result, index) =&amp;gt; {
                                const { lat, lon } = result;
                                const name = result?.local_names?.ko || result.name;
                                return (
                                    &amp;lt;li key={index} className={styles.item} onMouseDown={() =&amp;gt; handleResultClick(lat, lon)}&amp;gt;
                                        &amp;lt;span className={styles.icon}&amp;gt;
                                            &amp;lt;CiLocationOn /&amp;gt;
                                        &amp;lt;/span&amp;gt;
                                        &amp;lt;div&amp;gt;
                                            &amp;lt;p className={styles.title}&amp;gt;{name}&amp;lt;/p&amp;gt;
                                            &amp;lt;p className={styles.label}&amp;gt;{result.country}&amp;lt;/p&amp;gt;
                                        &amp;lt;/div&amp;gt;
                                    &amp;lt;/li&amp;gt;
                                );
                            })}
                        &amp;lt;/ul&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/form&amp;gt;
                &amp;lt;div className={styles.right}&amp;gt;
                    {/* 생략 */}
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/header&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;디바운스(Debounce) 기능&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 input의 onChange 이벤트가 발생할 때마다 서버에 요청을 보내는 방식으로 구현했었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이렇게 하면, 예를 들어 &quot;부산&quot;이라는 도시를 입력할 때 각 글자가 입력될 때마다 서버 요청이 발생하여, 총 5번의 요청이 이루어졌습니다.&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;이를 통해, 불필요한 서버 요청을 줄이고 사용자가 입력을 멈추고 500ms 동안 추가 입력이 없을 때에만 요청이 발생하도록 구현했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726057875780&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
    if (debouncedText) {
        searchCities(debouncedText); // debouncedText가 업데이트되면 서버 요청
    }
}, [debouncedText]);

useEffect(() =&amp;gt; {
    const handler = setTimeout(() =&amp;gt; {
        setDebouncedText(text); // 500ms 후에 debouncedText에 text 값 적용
    }, 500);

    return () =&amp;gt; {
        clearTimeout(handler); // 새로운 입력이 있을 경우 이전 타이머를 취소
    };
}, [text]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;text가 변화할 때마다 바로 서버 요청을 보내는 대신, debouncedText가 일정 시간이 지난 후에만 서버 요청을 보내도록 구현했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;useMutation 사용&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 useQuery를 사용하려 했지만, useQuery는 주로 페이지 로드 시점이나 특정 시점에서 데이터를 가져오고 캐싱하는 데 적합합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 input의 onChange 이벤트처럼 연속적으로 서버 요청을 보내는 경우에는 적합하지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서, 이런 경우에 더 적합한 useMutation을 선택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useMutation은 주로 서버에 데이터를 보내는 작업(POST, PUT, DELETE)에 사용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여기에서는 사용자가 입력할 때마다 서버에 데이터를 요청하고, 그에 따라 검색 결과를 리스트로 보여주기 위해 useMutation을 사용했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726058096178&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { mutate: searchCities } = useMutation({
    mutationFn: async (query: string) =&amp;gt; {
        const response = await WeatherApi.getCityCoords(query);
        return response;
    },
    onSuccess: (data: CityResult[]) =&amp;gt; {
        setSearchResults(data); // 요청 성공 시 결과를 상태에 저장
        setIsLoading(false);    // 로딩 상태 해제
    },
    onError: (error: Error) =&amp;gt; {
        console.error(&quot;검색 중 오류 발생:&quot;, error); // 요청 실패 시 에러 처리
        setIsLoading(false);    // 로딩 상태 해제
    },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;mutate:&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 mutate는 useMutation에서 서버 요청을 트리거하는 함수입니다. mutate가 실행되면, mutationFn에 정의된 비동기 함수가 실행되어 서버로 도시 좌표 검색 요청이 전송됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;mutationFn&lt;/b&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;여기서는 WeatherApi.getCityCoords(query)를 사용해 도시 이름으로 좌표를 검색하는 API 호출을 수행합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;onSuccess&lt;/b&gt;:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 성공하면 실행되는 함수입니다. 여기서는 받아온 검색 결과 데이터를 setSearchResults를 통해 상태에 저장하고, 로딩 상태를 해제합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;onError&lt;/b&gt;:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 실패하면 실행되는 함수입니다. 요청 중 에러가 발생할 경우 콘솔에 에러 메시지를 출력하고, 로딩 상태를 해제합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;handleResultClick&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1726059508865&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleResultClick = (lat: number, lon: number) =&amp;gt; {
    setLocation({ lat: lat, lon: lon });
    setText('');
    setIsExpanded(false);
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 리스트에서 항목을 클릭하면, Recoil의 locationState가 해당 도시의 위도와 경도로 변경됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 되면, MainContent 컴포넌트에서도 동일한 locationState를 사용하므로 리렌더링이 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;onClick 대신 onMouseDown을 사용한 이유는, CSS로 리스트가 focus-within 상태가 아닐 때 보이지 않도록 설정했기 때문입니다. onClick을 사용하면 클릭 이벤트가 발생하기 전에 포커스가 먼저 사라지기 때문에, 클릭이 제대로 인식되지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;onMouseDown은 포커스가 풀리기 전에 이벤트가 발생하므로 문제를 해결할 수 있었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;HourlyForecast 컴포넌트&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1726060669143&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function HourlyForecast({ forecastData }: { forecastData: IForeCast }) {
    const filteredData = forecastData.list.filter((_, index) =&amp;gt; index &amp;lt; 8);

    return (
        &amp;lt;section className={styles.section}&amp;gt;
            &amp;lt;h2 className={styles.title}&amp;gt;시간대별 날씨&amp;lt;/h2&amp;gt;
            &amp;lt;div className={styles.container}&amp;gt;
                &amp;lt;ul className={styles.list}&amp;gt;
                    {filteredData.map((forecast) =&amp;gt; {
                        const {
                            dt,
                            main: { temp },
                            weather,
                        } = forecast;
                        const [{ icon, description }] = weather;
                        return (
                            &amp;lt;li key={dt} className={styles.item}&amp;gt;
                                &amp;lt;Card size=&quot;small&quot; className={styles[&quot;forecast-card&quot;]}&amp;gt;
                                    &amp;lt;p className={styles.text}&amp;gt;{getHours(dt)}&amp;lt;/p&amp;gt;
                                    &amp;lt;img
                                        src={`http://openweathermap.org/img/wn/${icon}.png`}
                                        width=&quot;48&quot;
                                        height=&quot;48&quot;
                                        alt={description}
                                        className={styles.img}
                                        title={description}
                                    /&amp;gt;
                                    &amp;lt;p className={styles.text}&amp;gt;{`${Math.round(temp)}&amp;deg;`}&amp;lt;/p&amp;gt;
                                &amp;lt;/Card&amp;gt;
                            &amp;lt;/li&amp;gt;
                        );
                    })}
                &amp;lt;/ul&amp;gt;
                &amp;lt;ul className={styles.list}&amp;gt;
                    {filteredData.map((forecast) =&amp;gt; {
                        const {
                            dt,
                            weather,
                            wind: { deg, speed },
                        } = forecast;
                        const [{ description }] = weather;
                        return (
                            &amp;lt;li key={dt} className={styles.item}&amp;gt;
                                &amp;lt;Card size=&quot;small&quot; className={styles[&quot;forecast-card&quot;]}&amp;gt;
                                    &amp;lt;p className={styles.text}&amp;gt;{getHours(dt)}&amp;lt;/p&amp;gt;
                                    &amp;lt;img
                                        src={directionImg}
                                        width=&quot;48&quot;
                                        height=&quot;48&quot;
                                        alt={description}
                                        className={styles.img}
                                        title={description}
                                        style={{ transform: `rotate(${deg - 180}deg)` }}
                                    /&amp;gt;
                                    &amp;lt;p className={styles.text}&amp;gt;{Math.round(speed * 3600 / 1000)} km/h&amp;lt;/p&amp;gt;
                                &amp;lt;/Card&amp;gt;
                            &amp;lt;/li&amp;gt;
                        );
                    })}
                &amp;lt;/ul&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/section&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;getHours 함수&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1726059832945&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const getHours = (timeUnix: number) =&amp;gt; {
    const date = new Date(timeUnix * 1000);
    const hours = date.getHours();
    const period = hours &amp;gt;= 12 ? &quot;PM&quot; : &quot;AM&quot;;
    const formattedHours = hours % 12 === 0 ? 12 : hours % 12;
    return `${formattedHours} ${period}`;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 포스팅, 전전 포스팅과 동일한 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;timeUnix를 밀리초 단위로 변환하여 Date 객체로 만듭니다. 그 후, 이를 AM/PM 형식으로 시간만 추출해 포맷팅하여 반환합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;풍향 표시 및 풍속 계산&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1726059885230&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{filteredData.map((forecast) =&amp;gt; {
    const {
        dt,
        weather,
        wind: { deg, speed },
    } = forecast;
    const [{ description }] = weather;
    return (
        &amp;lt;li key={dt} className={styles.item}&amp;gt;
            &amp;lt;Card size=&quot;small&quot; className={styles[&quot;forecast-card&quot;]}&amp;gt;
                &amp;lt;p className={styles.text}&amp;gt;{getHours(dt)}&amp;lt;/p&amp;gt;
                &amp;lt;img
                    src={directionImg}
                    width=&quot;48&quot;
                    height=&quot;48&quot;
                    alt={description}
                    className={styles.img}
                    title={description}
                    style={{ transform: `rotate(${deg - 180}deg)` }}
                /&amp;gt;
                &amp;lt;p className={styles.text}&amp;gt;{Math.round(speed * 3600 / 1000)} km/h&amp;lt;/p&amp;gt;
            &amp;lt;/Card&amp;gt;
        &amp;lt;/li&amp;gt;
    );
})}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;풍향 이미지 회전:&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바람의 방향을 시각적으로 보여주기 위해 transform: rotate 속성을 사용해 바람의 각도에 따라 이미지를 회전시킵니다.&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;풍속은 m/s 단위로 제공되는데, 이를 km/h로 변환하기 위해 아래와 같은 계산식을 사용합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726060617002&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Math.round(speed * 3600 / 1000)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Forecast 컴포넌트 최고 온도 / 최저 온도 표시&lt;/b&gt;&lt;/h2&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;forecastData 안에 temp_max와 temp_min이 이미 정의되어 있지만, 일정 인덱스 이후부터는 temp_max와 temp_min이 동일한 값으로 반환되는 문제가 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 날짜별로 그날의 최고 온도와 최저 온도를 추출해 표시해 주었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1726060971496&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const dailyForecast: Array&amp;lt;{
    date: string;
    temp_min: number;
    temp_max: number;
    weather: { icon: string; description: string };
}&amp;gt; = [];

const { city: { timezone } } = forecastData;

const now = new Date();
const today = new Date(now.getTime() - now.getTimezoneOffset() * 60000)
    .toISOString()
    .split(&quot;T&quot;)[0];

forecastData.list.forEach((forecast) =&amp;gt; {
    const { dt } = forecast;
    
    const newDate = new Date((dt + timezone) * 1000);
    const localTime = new Date(newDate.getTime());

    const date = localTime.toISOString().split(&quot;T&quot;)[0];

    if (date === today) return;

    const existingDay = dailyForecast.find((item) =&amp;gt; item.date === date);

    if (!existingDay) {
        dailyForecast.push({
            date,
            temp_min: forecast.main.temp_min,
            temp_max: forecast.main.temp_max,
            weather: forecast.weather[0],
        });
    } else {
        existingDay.temp_min = Math.min(existingDay.temp_min, forecast.main.temp_min);
        existingDay.temp_max = Math.max(existingDay.temp_max, forecast.main.temp_max);
    }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;timezone은 API에서 제공하는 값으로, UTC와의 시간 차이를 초 단위로 나타냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 toISOString을 사용하여 날짜를 &quot;YYYY-MM-DD&quot; 형식으로 출력하기 위해 사용했으며, timezone 사용 없이 getFullYear, getMonth, getDate 메서드를 활용해 동일한 결과를 얻을 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;forecastData.list의 3시간 간격 데이터를 날짜별로 그룹화해, 각 날짜의 최고 및 최저 온도를 dailyForecast 배열에 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘을 제외한 이후 날짜만 처리하며, 각 날짜에 대해 온도가 갱신됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Math.max와 Math.min을 사용해 최고, 최저 온도를 업데이트하고, 이를 기반으로 날짜별 날씨 정보를 요약해 표시합니다.&lt;/p&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>React/Weather</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/49</guid>
      <comments>https://hy-un.tistory.com/entry/OpenWeatherMap-API%EB%A1%9C-%EB%82%A0%EC%94%A8-Web-%EB%A7%8C%EB%93%A4%EA%B8%B0-3#entry49comment</comments>
      <pubDate>Wed, 11 Sep 2024 22:31:29 +0900</pubDate>
    </item>
    <item>
      <title>OpenWeatherMap API로 날씨 Web 만들기 - 2</title>
      <link>https://hy-un.tistory.com/entry/OpenWeatherMap-API%EB%A1%9C-%EB%82%A0%EC%94%A8-Web-%EB%A7%8C%EB%93%A4%EA%B8%B0-2</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-09-10 오전 6.45.59.png&quot; data-origin-width=&quot;2938&quot; data-origin-height=&quot;1662&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c8m2N8/btsJvrtCNbC/ShXkIkAKGHikSEPJ9jpjf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c8m2N8/btsJvrtCNbC/ShXkIkAKGHikSEPJ9jpjf0/img.png&quot; data-alt=&quot;현재까지 진행된 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c8m2N8/btsJvrtCNbC/ShXkIkAKGHikSEPJ9jpjf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc8m2N8%2FbtsJvrtCNbC%2FShXkIkAKGHikSEPJ9jpjf0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;407&quot; data-filename=&quot;스크린샷 2024-09-10 오전 6.45.59.png&quot; data-origin-width=&quot;2938&quot; data-origin-height=&quot;1662&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;우선 이전 포스팅에서 사용자가 위치 동의를 거부하거나, 위도와 경도를 제대로 받아오지 못했을 때를 대비해서 recoil 기본값으로 서울 강남 좌표를 설정했었는데요.&lt;/p&gt;
&lt;pre id=&quot;code_1725918991060&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 개선 전 코드
import { atom } from &quot;recoil&quot;;

export const locationState = atom({
    key: &quot;locationState&quot;,
    default: { lat: 37.5145, lon: 127.0495 },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하니 처음 페이지가 열릴 때 무조건 서울 강남 정보가 렌더링됐다가, 나중에 현재 위치로 바뀌는 상황이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위도와 경도를 성공적으로 받아왔을 때도, 서울 강남이 먼저 표시되고 나서야 현재 위치로 변경되는 부분이 조금 부자연스러웠습니다.&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;그리고 오늘의 주요 정보를 보여주는 Highlights 컴포넌트도 추가했는데, 이 컴포넌트는 HTML 과 CSS의 비중이 더 크기 때문에, 특별히 다룰 내용은 많지 않습니다.&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;b&gt;locationState default값 변경&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위치 정보를 불러온 후에만 값이 설정되도록 하기 위해 초기 lat, lon 값을 null로 설정하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725919874793&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { atom } from &quot;recoil&quot;;

export const locationState = atom&amp;lt;{ lat: number | null; lon: number | null }&amp;gt;({
    key: &quot;locationState&quot;,
    default: { lat: null, lon: null },
});&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Header 컴포넌트 수정&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 Header 컴포넌트에서 Geolocation API를 통해 위도와 경도 정보를 성공적으로 받아오면 이를 locationState에 업데이트하는 역할만 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기능은 그대로 유지하면서, 이제 위치 정보 불러오기에 실패했을 경우에는 기본값(defaultLocation)을 사용해 locationState를 업데이트하는 로직으로 변경했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725920539409&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const defaultLocation = { lat: 37.5145, lon: 127.0495 };

const getLocation = (showAlert = false) =&amp;gt; {
    if (navigator.geolocation) {
        navigator.geolocation.getCurrentPosition(
            (position) =&amp;gt; {
                const { latitude, longitude } = position.coords;
                setLocation({ lat: latitude, lon: longitude }); // 위치 정보를 받아오면 그대로 업데이트
            },
            (error) =&amp;gt; {
                if (error.code === error.PERMISSION_DENIED &amp;amp;&amp;amp; showAlert) {
                    alert(&quot;위치 정보 접근이 차단되었습니다. 브라우저 설정을 확인해주세요.&quot;);
                }
                console.error(&quot;위치 정보를 가져오는 데 실패했습니다.&quot;, error);
                setLocation(defaultLocation); // 실패 시 기본값 설정
            }
        );
    } else {
        console.error(&quot;이 브라우저는 Geolocation을 지원하지 않습니다.&quot;);
        setLocation(defaultLocation); // Geolocation을 지원하지 않으면 기본값 설정
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;MainContent컴포넌트 수정&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존의 useQueries에 location.lat와 location.lon이 모두 있을 때만 쿼리가 활성화되도록 enabled 옵션을 추가했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725921026967&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;enabled: !!location.lat &amp;amp;&amp;amp; !!location.lon,&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 위도와 경도 값이 없을 경우 쿼리가 실행되지 않게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 TypeScript에서는 여전히 location.lat와 location.lon이 null일 수 있다고 경고를 띄우기 때문에, 이를 해결하기 위해 queryFn 내부에서도 조건문을 추가했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725921055600&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;queryFn: () =&amp;gt;
    location.lat &amp;amp;&amp;amp; location.lon
        ? WeatherApi.getCurrentWeather(location.lat, location.lon)
        : Promise.reject(new Error(&quot;위치 정보 없음&quot;)),&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1725920598115&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const [location] = useRecoilState(locationState);

const results = useQueries({
    queries: [
        {
            queryKey: [&quot;currentWeather&quot;, location.lat, location.lon],
            queryFn: () =&amp;gt;
                location.lat &amp;amp;&amp;amp; location.lon
                    ? WeatherApi.getCurrentWeather(location.lat, location.lon)
                    : Promise.reject(new Error(&quot;위치 정보 없음&quot;)),
            staleTime: 60000,
            gcTime: 1000 * 60 * 10,
            refetchOnWindowFocus: false,
            refetchOnMount: false,
            refetchOnReconnect: false,
            enabled: !!location.lat &amp;amp;&amp;amp; !!location.lon,
        },
        {/* 생략 */}
    ],
});&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Weather Api&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 Highlights 컴포넌트를 구현하면서, 대기 오염 지수도 함께 표시하려고 Weather API에 새로운 메서드를 추가했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 메서드는 위도와 경도를 받아서 대기 오염 데이터를 가져오는 역할을 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725921476551&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;async getAirPollution(lat: number, lon: number) {
    const response = await this.httpClient.get(&quot;air_pollution&quot;, {
        params: {
            lat: lat,
            lon: lon,
        }
    });
    return response.data;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Highlight 컴포넌트&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Highlights 컴포넌트에서는 이 API로 받아온 대기 오염 데이터를 활용해 미세먼지, 이산화황(SO₂), 이산화질소(NO₂), 오존(O₃) 등 주요 대기 오염 정보를 표시합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존의 날씨 정보도 함께 구성하여 오늘의 주요 정보 섹션을 만들었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725921216150&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface HighlightsProps {
    airPollution: IAirPollution;
    currentWeather: ICurrentWeather;
}

export default function Highlights({airPollution, currentWeather}: HighlightsProps) {
    const {
        main: { aqi },
        components: { pm2_5, so2, no2, o3 },
    } = airPollution.list[0];

    const {
        main: { humidity, pressure, sea_level, feels_like },
        sys: { sunrise, sunset },
        visibility,
    } = currentWeather;

    console.log(pm2_5,so2, no2, o3 );
    return (
        &amp;lt;section className={styles.section}&amp;gt;
            &amp;lt;Card size=&quot;large&quot;&amp;gt;
                &amp;lt;h2 className={styles.title}&amp;gt;오늘의 주요 정보&amp;lt;/h2&amp;gt;
                &amp;lt;div className={styles.grid}&amp;gt;
                    &amp;lt;Card size=&quot;small&quot; className={`${styles[&quot;highlight-card&quot;]} ${styles[&quot;one&quot;]}`}&amp;gt;
                        &amp;lt;h3 className={styles.heading}&amp;gt;대기 오염 지수&amp;lt;/h3&amp;gt;
                        &amp;lt;div className={styles.wrapper}&amp;gt;
                            &amp;lt;span className={styles.icon}&amp;gt;
                                &amp;lt;MdAir /&amp;gt;
                            &amp;lt;/span&amp;gt;
                            &amp;lt;ul className={styles.list}&amp;gt;
                                &amp;lt;li className={styles.item}&amp;gt;
                                    &amp;lt;p className={styles.label}&amp;gt;미세먼지&amp;lt;/p&amp;gt;
                                    &amp;lt;p className={styles.title}&amp;gt;{pm2_5.toFixed(2)}&amp;lt;/p&amp;gt;
                                &amp;lt;/li&amp;gt;

                                &amp;lt;li className={styles.item}&amp;gt;
                                    &amp;lt;p className={styles.label}&amp;gt;이산화황&amp;lt;/p&amp;gt;
                                    &amp;lt;p className={styles.title}&amp;gt;{so2.toFixed(2)}&amp;lt;/p&amp;gt;
                                &amp;lt;/li&amp;gt;

                                &amp;lt;li className={styles.item}&amp;gt;
                                    &amp;lt;p className={styles.label}&amp;gt;이산화질소&amp;lt;/p&amp;gt;
                                    &amp;lt;p className={styles.title}&amp;gt;{no2.toFixed(2)}&amp;lt;/p&amp;gt;
                                &amp;lt;/li&amp;gt;

                                &amp;lt;li className={styles.item}&amp;gt;
                                    &amp;lt;p className={styles.label}&amp;gt;오존&amp;lt;/p&amp;gt;
                                    &amp;lt;p className={styles.title}&amp;gt;{o3.toFixed(2)}&amp;lt;/p&amp;gt;
                                &amp;lt;/li&amp;gt;
                            &amp;lt;/ul&amp;gt;
                        &amp;lt;/div&amp;gt;
                    &amp;lt;/Card&amp;gt;
                    &amp;lt;Card size=&quot;small&quot; className={`${styles[&quot;highlight-card&quot;]} ${styles[&quot;two&quot;]}`}&amp;gt;
                        &amp;lt;h3 className={styles.heading}&amp;gt;일출 &amp;amp; 일몰&amp;lt;/h3&amp;gt;
                        &amp;lt;div className={styles.list}&amp;gt;
                            &amp;lt;div className={styles.item}&amp;gt;
                                &amp;lt;span className={styles.icon}&amp;gt;
                                    &amp;lt;FiSunrise /&amp;gt;
                                &amp;lt;/span&amp;gt;
                                &amp;lt;div&amp;gt;
                                    &amp;lt;p className={styles.label}&amp;gt;일출&amp;lt;/p&amp;gt;
                                    &amp;lt;p className={styles.title}&amp;gt;{getTime(sunrise)}&amp;lt;/p&amp;gt;
                                &amp;lt;/div&amp;gt;
                            &amp;lt;/div&amp;gt;
                            &amp;lt;div className={styles.item}&amp;gt;
                                &amp;lt;span className={styles.icon}&amp;gt;
                                    &amp;lt;FiSunset /&amp;gt;
                                &amp;lt;/span&amp;gt;
                                &amp;lt;div&amp;gt;
                                    &amp;lt;p className={styles.label}&amp;gt;일몰&amp;lt;/p&amp;gt;
                                    &amp;lt;p className={styles.title}&amp;gt;{getTime(sunset)}&amp;lt;/p&amp;gt;
                                &amp;lt;/div&amp;gt;
                            &amp;lt;/div&amp;gt;
                        &amp;lt;/div&amp;gt;
                    &amp;lt;/Card&amp;gt;
                    {/* 나머지 카드 생략 */}
                &amp;lt;/div&amp;gt;
            &amp;lt;/Card&amp;gt;
        &amp;lt;/section&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;일출 및 일몰 시간 처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;날씨 정보를 표시하는 Highlights 컴포넌트에서, 일출과 일몰 시간을 표시하기 위해 시간을 변환하는 getTime 함수를 추가했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 포스팅에서 작성한 getDate 함수와 원리는 비슷합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;timeUnix를 1000으로 나눠 밀리초 단위로 변환한 후, Date 객체를 생성하여 원하는 형태의 문자열로 출력하는 방식입니다&lt;/p&gt;
&lt;pre id=&quot;code_1725921234608&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const getTime = function (timeUnix: number): string {
    const date = new Date(timeUnix * 1000);
    const hours = date.getHours();
    const minutes = date.getMinutes();
    const period = hours &amp;gt;= 12 ? &quot;PM&quot; : &quot;AM&quot;;
    const formattedHours = hours % 12 === 0 ? 12 : hours % 12;
    return `${formattedHours}:${minutes} ${period}`;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React/Weather</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/48</guid>
      <comments>https://hy-un.tistory.com/entry/OpenWeatherMap-API%EB%A1%9C-%EB%82%A0%EC%94%A8-Web-%EB%A7%8C%EB%93%A4%EA%B8%B0-2#entry48comment</comments>
      <pubDate>Tue, 10 Sep 2024 08:03:44 +0900</pubDate>
    </item>
    <item>
      <title>OpenWeatherMap API로 날씨 Web 만들기 - 1</title>
      <link>https://hy-un.tistory.com/entry/OpenWeatherMap-API%EB%A1%9C-%EB%82%A0%EC%94%A8-Web-%EB%A7%8C%EB%93%A4%EA%B8%B0-1</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-09-09 오전 5.57.04.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1666&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/v3bl5/btsJvv9EXAN/WGfNSf27vvvyeAadFDxkHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/v3bl5/btsJvv9EXAN/WGfNSf27vvvyeAadFDxkHk/img.png&quot; data-alt=&quot;현재까지 진행된 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/v3bl5/btsJvv9EXAN/WGfNSf27vvvyeAadFDxkHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fv3bl5%2FbtsJvv9EXAN%2FWGfNSf27vvvyeAadFDxkHk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;408&quot; data-filename=&quot;스크린샷 2024-09-09 오전 5.57.04.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1666&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;이번 프로젝트에서는 OpenWeatherMap API를 활용하여 날씨 정보를 확인할 수 있는 웹을 만들어보려고 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&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 data-ke-size=&quot;size16&quot;&gt;날씨 정보를 얻기 위해서는 위도와 경도가 필요해서 Geolocation Web Api를 사용하여 위도와 경도를 받아왔고, 위도와 경도 정보는 프로젝트 내 여러 곳에서 활용될 거 같아서 Recoil을 사용해 전역으로 상태를 관리했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 Context API가 아닌 다른 상태 관리 라이브러리를 사용해 보고 싶어서 한 번 사용해 봤지만, 위도와 경도 저장은 너무 간단한 작업이라 그냥 Context API로 교체할지 고민 중입니다. &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 OpenWeatherMap API에서 한국 도시명이 제공되지 않았었는데, 다행히도 reverseGeoCoding 기능이 API에 포함되어 있어, 이를 이용해 위도와 경도로 한국 도시명을 받아와서 표시하였습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Header 컴포넌트&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Header컴포넌트에는 사용자가 검색을 통해 장소를 입력할 수 있는 input과 내 위치 찾기 버튼을 통해 사용자의 위도와 경도를 얻을 수 있는 버튼이 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 검색을 통한 장소 입력 (현재 미구현)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색을 통해 장소를 입력하는 기능은 아직 구현되지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenWeatherMap API는 도시 이름을 &lt;b&gt;q=Seoul,KR&lt;/b&gt; 형식으로 요청해야 하는데, 국가명을 어떻게 붙일지와 단순히 영단어로만 검색할 것이 아니라 한글 등 다양한 입력을 어떻게 처리할지에 대해 아직 고민 중입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 내 위치 찾기 버튼&lt;/h3&gt;
&lt;pre id=&quot;code_1725820595975&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Header() {
    const [location, setLocation] = useRecoilState(locationState);
    const [text, setText] = useState(&quot;&quot;);

    const getLocation = (showAlert = false) =&amp;gt; {
        if (navigator.geolocation) {
            navigator.geolocation.getCurrentPosition(
                (position) =&amp;gt; {
                    const { latitude, longitude } = position.coords;
                    setLocation({ lat: latitude, lon: longitude });
                },
                (error) =&amp;gt; {
                    if (error.code === error.PERMISSION_DENIED &amp;amp;&amp;amp; showAlert) {
                        alert(&quot;위치 정보 접근이 차단되었습니다. 브라우저 설정을 확인해주세요.&quot;);
                    } else {
                        console.error(&quot;위치 정보를 가져오는 데 실패했습니다.&quot;, error);
                    }
                }
            );
        } else {
            console.error(&quot;이 브라우저는 Geolocation을 지원하지 않습니다.&quot;);
        }
    };

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

    const handleSubmit = (e: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
        e.preventDefault();
    };

    const handleLocationClick = () =&amp;gt; {
        getLocation(true);
    };

    return (
        &amp;lt;header className={styles.header}&amp;gt;
            &amp;lt;div className={styles.container}&amp;gt;
                &amp;lt;button className={styles.logo}&amp;gt;
                    &amp;lt;TiWeatherPartlySunny /&amp;gt;
                    &amp;lt;span&amp;gt;weather&amp;lt;/span&amp;gt;
                &amp;lt;/button&amp;gt;
                &amp;lt;form className={styles.form} onSubmit={handleSubmit}&amp;gt;
                    &amp;lt;input
                        className={styles.input}
                        type=&quot;search&quot;
                        name=&quot;search&quot;
                        placeholder=&quot;검색&quot;
                        autoComplete=&quot;off&quot;
                        value={text}
                        onChange={(e) =&amp;gt; setText(e.target.value)}
                    /&amp;gt;
                    &amp;lt;button className={styles.icon}&amp;gt;
                        &amp;lt;IoIosSearch /&amp;gt;
                    &amp;lt;/button&amp;gt;
                &amp;lt;/form&amp;gt;
                &amp;lt;div className={styles.right}&amp;gt;
                    &amp;lt;button className={styles.primary} onClick={handleLocationClick}&amp;gt;
                        &amp;lt;span className={styles.icon}&amp;gt;
                            &amp;lt;TbCurrentLocation /&amp;gt;
                        &amp;lt;/span&amp;gt;
                        &amp;lt;span className={styles.span}&amp;gt;내 위치 찾기&amp;lt;/span&amp;gt;
                    &amp;lt;/button&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/header&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 위치 찾기 버튼을 누르면 Geolocation Api를 사용해 현재 위치를 기반으로 위도와 경도를 가져 올 수 있게 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 말한 대로, 위도와 경도는 프로젝트 내에서 자주 사용될 것 같아서 Recoil을 통해 전역으로 상태를 관리합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725820813418&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { atom } from &quot;recoil&quot;;

export const locationState = atom({
    key: &quot;locationState&quot;,
    default: { lat: 37.5145, lon: 127.0495 },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 서울 강남의 위도, 경도를 default 값으로 지정했습니다. 사용자가 위치 정보 제공에 동의하지 않을 경우를 대비한 설정입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;WeatherApi&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 요청하는 부분은 WeatherApi 모듈로 분리했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725820918023&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from &quot;axios&quot;;

const WeatherApi = {
    httpClient: axios.create({
        baseURL: &quot;https://api.openweathermap.org/data/2.5&quot;,
        params: {
            appid: process.env.REACT_APP_WEATHER_API_KEY,
            units: &quot;metric&quot;,
            lang: &quot;kr&quot;,
        },
    }),

    async getCurrentWeather(lat: number, lon: number) {
        const response = await this.httpClient.get(&quot;weather&quot;, {
            params: {
                lat: lat,
                lon: lon,
            },
        });
        return response.data;
    },

    async getForecast(lat: number, lon: number) {
        const response = await this.httpClient.get(&quot;forecast&quot;, {
            params: {
                lat: lat,
                lon: lon,
            },
        });
        return response.data;
    },

    async getReverseGeo(lat: number, lon: number) {
        const response = await axios.get(&quot;https://api.openweathermap.org/geo/1.0/reverse&quot;, {
            params: {
                lat: lat,
                lon: lon,
                limit: 5,
                lang: &quot;kr&quot;,
                appid: process.env.REACT_APP_WEATHER_API_KEY,
            },
        });
        return response.data[0].local_names['ko'];
    },
};

export default WeatherApi;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;getCurrentWeather&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;getForecast&lt;/b&gt;: 해당 위치의 5일치 3시간 간격 날씨 예보를 반환하는 함수입니다.&lt;/p&gt;
&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;getReverseGeo&lt;/b&gt;: 전달한 위도와 경도를 바탕으로 해당 위치의 도시명을 반환하는 함수입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;MainContent 컴포넌트&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MainContent 컴포넌트는 useQueries를 사용하여 한 번에 여러 API 요청을 병렬로 처리하는 방식으로 구성되어 있습니다. OpenWeatherMap API를 통해 현재 위치의 날씨 정보, 5일간의 날씨 예보, 그리고 reverse geocoding을 이용한 도시 이름을 가져옵니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725821190188&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function MainContent() {
    const [location, setLocation] = useRecoilState(locationState);
    const results = useQueries({
        queries: [
            {
                queryKey: [&quot;currentWeather&quot;, location.lat, location.lon],
                queryFn: () =&amp;gt;
                    WeatherApi.getCurrentWeather(location.lat, location.lon),
                staleTime: 60000,
                gcTime: 1000 * 60 * 10,
                refetchOnWindowFocus: false,
                refetchOnMount: false,
                refetchOnReconnect: false,
            },
            {
                queryKey: [&quot;forecast&quot;, location.lat, location.lon],
                queryFn: () =&amp;gt;
                    WeatherApi.getForecast(location.lat, location.lon),
                staleTime: 60000,
                gcTime: 1000 * 60 * 10,
                refetchOnWindowFocus: false,
                refetchOnMount: false,
                refetchOnReconnect: false,
            },
            {
                queryKey: [&quot;city&quot;, location.lat, location.lon],
                queryFn: () =&amp;gt;
                    WeatherApi.getReverseGeo(location.lat, location.lon),
                staleTime: 60000,
                gcTime: 1000 * 60 * 10,
                refetchOnWindowFocus: false,
                refetchOnMount: false,
                refetchOnReconnect: false,
            },
        ],
    });
    const [{ data: currentWeather }, { data: forecastData }, { data: city }] =
        results;

    const isLoading = results[0].isLoading || results[1].isLoading || results[2].isLoading;

    if (isLoading) {
        return &amp;lt;p&amp;gt;Loading...&amp;lt;/p&amp;gt;;
    }

    return (
        &amp;lt;main&amp;gt;
            &amp;lt;article className={styles.container}&amp;gt;
                &amp;lt;div className={styles.left}&amp;gt;
                    &amp;lt;CurrentWeather
                        currentWeather={currentWeather}
                        city={city}
                    /&amp;gt;
                    &amp;lt;Forecast forecastData={forecastData} /&amp;gt;
                &amp;lt;/div&amp;gt;
                &amp;lt;div className={styles.right}&amp;gt;right&amp;lt;/div&amp;gt;
            &amp;lt;/article&amp;gt;
        &amp;lt;/main&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;CurrentWeather 컴포넌트&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CurrentWeather 컴포넌트는 MainContent에서 전달받은 날씨 데이터와 도시명을 바탕으로 사용자에게 현재 날씨 정보를 표시합니다. 날씨 상태, 온도, 날짜, 그리고 도시명을 화면에 렌더링합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725821244461&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function CurrentWeather({
    currentWeather,
    city,
}: {
    currentWeather: ICurrentWeather;
    city: string;
}) {
    const {
        weather,
        dt: dataUnix,
        sys: { country },
        main: { temp },
    } = currentWeather;

    const [{ icon, description }] = weather;
    const iconUrl = `http://openweathermap.org/img/wn/${icon}.png`;
    return (
        &amp;lt;section className={styles.section}&amp;gt;
            &amp;lt;Card size=&quot;large&quot;&amp;gt;
                &amp;lt;h2 className={styles.title}&amp;gt;현재&amp;lt;/h2&amp;gt;
                &amp;lt;div className={styles.wrapper}&amp;gt;
                    &amp;lt;p className={styles.heading}&amp;gt;
                        {`${Math.round(temp)}&amp;deg;`}&amp;lt;sup&amp;gt;c&amp;lt;/sup&amp;gt;
                    &amp;lt;/p&amp;gt;
                    &amp;lt;img
                        src={iconUrl}
                        width=&quot;64&quot;
                        height=&quot;64&quot;
                        alt=&quot;clouds&quot;
                        className={styles.img}
                    /&amp;gt;
                &amp;lt;/div&amp;gt;
                &amp;lt;p className={styles.desc}&amp;gt;{description}&amp;lt;/p&amp;gt;
                &amp;lt;ul className={styles.list}&amp;gt;
                    &amp;lt;li className={styles.item}&amp;gt;
                        &amp;lt;span className={styles.icon}&amp;gt;
                            &amp;lt;IoCalendarClearOutline /&amp;gt;
                        &amp;lt;/span&amp;gt;
                        &amp;lt;p className={styles.text}&amp;gt;
                            {getDate(dataUnix)}
                        &amp;lt;/p&amp;gt;
                    &amp;lt;/li&amp;gt;
                    &amp;lt;li className={styles.item}&amp;gt;
                        &amp;lt;span className={styles.icon}&amp;gt;
                            &amp;lt;IoLocationOutline /&amp;gt;
                        &amp;lt;/span&amp;gt;
                        &amp;lt;p className={styles.text}&amp;gt;{city}, {country}&amp;lt;/p&amp;gt;
                    &amp;lt;/li&amp;gt;
                &amp;lt;/ul&amp;gt;
            &amp;lt;/Card&amp;gt;
        &amp;lt;/section&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;getDate 메서드&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;API가 제공하는 dateUnix는 초 단위로 반환되지만,&lt;span&gt;&amp;nbsp;&lt;/span&gt;JavaScriptDate 객체는 밀리초 단위로 만들어야 하기 때문에 dateUnix에 *1000을 해주어 변환했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725821277689&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const weekDayNames = [
    &quot;일&quot;,
    &quot;월&quot;,
    &quot;화&quot;,
    &quot;수&quot;,
    &quot;목&quot;,
    &quot;금&quot;,
    &quot;토&quot;,
];

export const monthNames = [
    &quot;1월&quot;,
    &quot;2월&quot;,
    &quot;3월&quot;,
    &quot;4월&quot;,
    &quot;5월&quot;,
    &quot;6월&quot;,
    &quot;7월&quot;,
    &quot;8월&quot;,
    &quot;9월&quot;,
    &quot;10월&quot;,
    &quot;11월&quot;,
    &quot;12월&quot;,
];

export const getDate = function (dateUnix: number): string {
    const date = new Date(dateUnix * 1000);
    const weekDayName = weekDayNames[date.getDay()];
    const monthName = monthNames[date.getMonth()];
    return `${monthName} ${date.getDate()}일 ${weekDayName}요일`;
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Forecast 컴포넌트&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Forecast 컴포넌트는 OpenWeatherMap API에서 제공하는 5일간의 날씨 예보 데이터를 렌더링하는 역할을 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;getForecast 요청 시, OpenWeatherMap API는 5일간의 날씨 데이터를 3시간 간격으로 총 40개의 항목을 제공합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이를 5개로 줄이기 위해, 8번째마다 데이터를 선택하여 각 날의 예보를 표시하도록 filteredData로 필터링을 적용했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725828219456&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Forecast({
    forecastData,
}: {
    forecastData: IForeCast;
}) {
    const filteredData = forecastData.list.filter((_, index) =&amp;gt; index % 8 === 7);

    return (
        &amp;lt;section className={styles.section}&amp;gt;
            &amp;lt;h2 className={styles.title}&amp;gt;날씨예보&amp;lt;/h2&amp;gt;
            &amp;lt;Card size=&quot;large&quot;&amp;gt;
                &amp;lt;ul className={styles.list}&amp;gt;
                    {filteredData.map((forecast, index) =&amp;gt; {
                        const {
                            main: { temp_max },
                            weather,
                            dt_txt,
                        } = forecast;
                        const [{ icon, description }] = weather;
                        const date = new Date(dt_txt);

                        return (
                            &amp;lt;li key={index} className={styles.item}&amp;gt;
                                &amp;lt;div className={styles.wrapper}&amp;gt;
                                    &amp;lt;img
                                        src={`http://openweathermap.org/img/wn/${icon}.png`}
                                        width=&quot;36&quot;
                                        height=&quot;36&quot;
                                        alt={description}
                                        className={styles.img}
                                        title={description}
                                    /&amp;gt;
                                    &amp;lt;span className={styles.span}&amp;gt;
                                        &amp;lt;p className={styles.temp}&amp;gt;
                                            {`${Math.round(temp_max)}&amp;deg;`}
                                        &amp;lt;/p&amp;gt;
                                    &amp;lt;/span&amp;gt;
                                &amp;lt;/div&amp;gt;
                                &amp;lt;p className={styles.label}&amp;gt;
                                    {monthNames[date.getUTCMonth()]} {date.getDate()}일
                                &amp;lt;/p&amp;gt;
                                &amp;lt;p className={styles.label}&amp;gt;
                                    {weekDayNames[date.getUTCDay()]}
                                &amp;lt;/p&amp;gt;
                            &amp;lt;/li&amp;gt;
                        );
                    })}
                &amp;lt;/ul&amp;gt;
            &amp;lt;/Card&amp;gt;
        &amp;lt;/section&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Card 컴포넌트&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Card형태의 레이아웃이 많이 사용될 예정이라 다양한 레이아웃에서 사용할 수 있도록 크기(size)에 따라 클래스를 다르게 적용할 수 있도록 설계되었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;large와 small 두 가지 크기 옵션을 제공하여 재사용성을 높였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725821348179&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Card({
    children,
    size = &quot;large&quot;,
}: {
    children: ReactNode;
    size?: &quot;large&quot; | &quot;small&quot;;
}) {
    const sizeClassMap: { [key: string]: string } = {
        large: &quot;card-lg&quot;,
        small: &quot;card-sm&quot;,
    };

    const className = sizeClassMap[size] || &quot;&quot;;
    return &amp;lt;div className={`${styles.card} ${styles[className]}`}&amp;gt;{children}&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;</description>
      <category>React/Weather</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/47</guid>
      <comments>https://hy-un.tistory.com/entry/OpenWeatherMap-API%EB%A1%9C-%EB%82%A0%EC%94%A8-Web-%EB%A7%8C%EB%93%A4%EA%B8%B0-1#entry47comment</comments>
      <pubDate>Mon, 9 Sep 2024 07:50:51 +0900</pubDate>
    </item>
    <item>
      <title>Geolocation API로 실시간 위치 정보 가져오기</title>
      <link>https://hy-un.tistory.com/entry/Geolocation-API%EB%A1%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%9C%84%EC%B9%98-%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1725789006806&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;Geolocation API - Web APIs | MDN&quot; data-og-description=&quot;The Geolocation API allows the user to provide their location to web applications if they so desire. For privacy reasons, the user is asked for permission to report location information.&quot; data-og-host=&quot;developer.mozilla.org&quot; data-og-source-url=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API&quot; data-og-url=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/QtLme/hyW2TOWRTg/2GxcETzmxtb7volQanMGRK/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/QtLme/hyW2TOWRTg/2GxcETzmxtb7volQanMGRK/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080');&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;Geolocation API - Web APIs | MDN&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The Geolocation API allows the user to provide their location to web applications if they so desire. For privacy reasons, the user is asked for permission to report location information.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.mozilla.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. Geolocation API란?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Geolocation API는 웹사이트가 사용자의 기기에서 위치 정보를 요청할 수 있도록 해주는 기능입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위치 정보는 사용자의 허락을 받아야만 가져올 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 위치 정보 가져오기: getCurrentPosition()&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위치 정보를 한 번만 가져오려면 getCurrentPosition() 메서드를 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 메서드는 성공 시 실행될 함수(필수), 실패 시 실행될 함수(필수), 그리고 추가 설정 옵션(선택)을 인수로 받습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725789022934&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;navigator.geolocation.getCurrentPosition(
    (position) =&amp;gt; {
        console.log(&quot;위도: &quot; + position.coords.latitude);
        console.log(&quot;경도: &quot; + position.coords.longitude);
    },
    (error) =&amp;gt; {
        console.error(&quot;오류 발생: &quot;, error);
    }
);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 위치 정보 객체 (position) 자세히 알아보기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성공적으로 위치 정보를 가져오면 position 객체가 반환됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 객체에는 사용자 기기의 여러 위치 정보가 들어 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;coords.latitude&lt;/b&gt;: 위도 (ex: 37.5172)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;coords.longitude&lt;/b&gt;: 경도 (ex: 127.0473)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;coords.altitude&lt;/b&gt;: 고도, 미터 단위 (사용할 수 없으면 null).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;coords.accuracy&lt;/b&gt;: 위치의 정확도, 미터 단위.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;coords.altitudeAccuracy&lt;/b&gt;: 고도의 정확도 (사용할 수 없으면 null).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;coords.heading&lt;/b&gt;: 기기가 가리키고 있는 방향 (정지 상태일 경우 null).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;coords.speed&lt;/b&gt;: 기기의 이동 속도, m/s 단위 (정지 상태일 경우 null)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1725789099974&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;navigator.geolocation.getCurrentPosition(
    (position) =&amp;gt; {
        const { latitude, longitude, accuracy, altitude, heading, speed } = position.coords;
        console.log(`위도: ${latitude}, 경도: ${longitude}`);
        console.log(`정확도: ${accuracy}m`);
        console.log(`고도: ${altitude !== null ? altitude + &quot;m&quot; : &quot;정보 없음&quot;}`);
        console.log(`방향: ${heading !== null ? heading + &quot;&amp;deg;&quot; : &quot;정지 상태&quot;}`);
        console.log(`속도: ${speed !== null ? speed + &quot;m/s&quot; : &quot;정지 상태&quot;}`);
    }
);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 오류 처리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위치 정보를 가져오는 중 발생하는 오류는 크게 세 가지로 나눌 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;PERMISSION_DENIED&lt;/b&gt;: 사용자가 위치 정보 접근을 거부한 경우.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;POSITION_UNAVAILABLE&lt;/b&gt;: 기기의 위치 정보를 사용할 수 없는 경우.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TIMEOUT&lt;/b&gt;: 위치 정보를 가져오는 시간이 초과된 경우.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1725789131073&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;navigator.geolocation.getCurrentPosition(
    (position) =&amp;gt; {
        // 위치 정보를 성공적으로 가져옴
    },
    (error) =&amp;gt; {
        switch (error.code) {
            case error.PERMISSION_DENIED:
                alert(&quot;위치 정보 접근이 거부되었습니다.&quot;);
                break;
            case error.POSITION_UNAVAILABLE:
                alert(&quot;위치 정보를 사용할 수 없습니다.&quot;);
                break;
            case error.TIMEOUT:
                alert(&quot;위치 정보를 가져오는 데 시간이 너무 오래 걸렸습니다.&quot;);
                break;
            default:
                alert(&quot;알 수 없는 오류가 발생했습니다.&quot;);
        }
    }
);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 위치 요청 시 옵션 설정하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;getCurrentPosition() 메서드는 추가적인 옵션을 설정할 수 있습니다.&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;enableHighAccuracy&lt;/b&gt;: 위치 정보의 정확도를 높일지 여부(기본값 false)&lt;br /&gt;true로 설정하면 더 정확한 위치 정보를 제공하지만, 배터리 소모량이 증가할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;timeout&lt;/b&gt;: 위치 정보를 가져오는 데 걸리는 최대 시간 (밀리초 단위).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;maximumAge&lt;/b&gt;: 이전 위치 정보를 얼마 동안 캐시할지 (밀리초 단위). 0으로 설정 시 항상 최신 정보를 요청합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1725789168055&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;navigator.geolocation.getCurrentPosition(
    (position) =&amp;gt; {
        console.log(`위도: ${position.coords.latitude}, 경도: ${position.coords.longitude}`);
    },
    (error) =&amp;gt; {
        console.error(&quot;위치 정보를 가져오는 중 오류 발생: &quot;, error);
    },
    {
        enableHighAccuracy: true,  // 고정밀도 위치 요청
        timeout: 5000,             // 5초 이내에 위치 정보를 가져오지 않으면 오류 처리
        maximumAge: 0              // 캐시된 위치 정보 사용 안 함
    }
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JavaScript</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/46</guid>
      <comments>https://hy-un.tistory.com/entry/Geolocation-API%EB%A1%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%9C%84%EC%B9%98-%EC%A0%95%EB%B3%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0#entry46comment</comments>
      <pubDate>Sun, 8 Sep 2024 18:58:46 +0900</pubDate>
    </item>
    <item>
      <title>YouTube Data API를 이용한 Youtube Clone - 7</title>
      <link>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-7</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;이 프로젝트의 전체 코드는 GitHub에서 확인하실 수 있습니다.&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&amp;darr;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1f1f1f; text-align: left;&quot;&gt;&lt;a href=&quot;https://github.com/GangHyun95/youtube&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/GangHyun95/youtube&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1726210021582&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;GitHub - GangHyun95/youtube: with typescript&quot; data-og-description=&quot;with typescript. Contribute to GangHyun95/youtube development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/GangHyun95/youtube&quot; data-og-url=&quot;https://github.com/GangHyun95/youtube&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dgJ1tz/hyW2XYQ8zR/mrrRiy6I8DlXq6Q4IMvjNk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/GangHyun95/youtube&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/GangHyun95/youtube&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dgJ1tz/hyW2XYQ8zR/mrrRiy6I8DlXq6Q4IMvjNk/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;GitHub - GangHyun95/youtube: with typescript&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;with typescript. Contribute to GangHyun95/youtube 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;프로젝트에서 VideoDetail 페이지로 이동할 때 useNavigate를 통해 데이터를 전달하는 방식을 사용했 었는데요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VideoCard와 RelatedVideoList에서 사용하는 데이터의 출처가 다르다 보니 문제가 발생했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에서는 이 문제를 해결하는 과정과 VideoCard에 마우스를 올렸을 때&amp;nbsp;해당 비디오ID의 iframe을 생성하여 미리보기 영상이 재생되는 것처럼 보이도록 구현한 방법을 다루겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;VideoDetail 페이지 문제의 원인&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. VideoCard&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;YouTube의 video 엔드포인트를 사용해 데이터를 요청하고, 이때 statistics 정보를 포함했습니다. 또한, 요청한 데이터에 채널 정보를 추가하여 반환했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2. RelatedVideoList&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;YouTube의 playlists 엔드포인트를 사용했는데, 이 엔드포인트에서는 statistics 정보나 채널 정보가 포함되지 않았습니다. &lt;br /&gt;&lt;br /&gt;이 차이로 인해 VideoDetail 페이지에서 필요한 데이터가 없어서 오류가 발생했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결 과정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 useNavigate로 데이터를 전달하지 않고, VideoDetail 페이지가 마운트될 때마다 useQuery로 무조건 데이터를 요청하는 방식으로 수정하려고 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다가 React Query의 enabled 속성을 알게 되었고 이걸 이용하면 특정 조건에서만 데이터를 요청할 수 있다는 걸 알았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 useLocation을 통해 전달된 데이터가 없을 때만 useQuery가 활성화되도록 코드를 수정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 useNavigate로 전달된 데이터가 있으면 그걸 쓰고 없을 때만 API를 호출해서 비디오 데이터를 받아오게 처리할 수 있었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725654129359&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { videoId } = useParams();
const location = useLocation();
const stateVideo = location.state?.video;

const { data: fetchedVideo, isLoading } = useQuery({
    queryKey: [&quot;video&quot;, videoId],
    queryFn: () =&amp;gt; YoutubeApi.getVideoById(videoId || &quot;&quot;),
    staleTime: 60000,
    gcTime: 1000 * 60 * 10,
    refetchOnWindowFocus: false,
    refetchOnMount: false,
    refetchOnReconnect: false,
    enabled: !stateVideo,
});

const video = stateVideo || fetchedVideo;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;단일 비디오 ID로 데이터 요청 함수 추가&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;YouTube API에서 단일 비디오 ID로 비디오 데이터와 함께 채널 정보를 추가해서 반환하는 함수를 추가했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725653091397&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;async getVideoById(videoId: string) {
    const response = await this.httpClient.get(&quot;videos&quot;, {
        params: {
            part: &quot;snippet,statistics&quot;,
            id: videoId,
        },
    });

    const channelId = response.data.items[0].snippet.channelId;

    const channelResponse = await this.httpClient.get(&quot;channels&quot;, {
        params: {
            part: &quot;snippet,contentDetails,statistics&quot;,
            id: channelId,
        },
    });

    return {
        ...response.data.items[0],
        channelDetails: channelResponse.data.items[0],
    };
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;VideoCard의 마우스 hover 미리보기 기능&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VideoCard에 마우스를 올리면 해당 비디오ID의 iframe을 썸네일 위에 생성하고 재생시켜 마치 미리보기 영상이 재생되는 것처럼 구현하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725654645186&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const [isHovered, setIsHovered] = useState(false);

const handleMouseEnter = () =&amp;gt; {
    setIsHovered(true);
};

const handleMouseLeave = () =&amp;gt; {
    setIsHovered(false);
};

return (
    &amp;lt;li
        className={cardClassName}
        ref={ref}
        onClick={() =&amp;gt; navigate(`/videos/watch/${video.id}`, { state: { video } })}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
    &amp;gt;
        &amp;lt;section className={styles['img-container']}&amp;gt;
            &amp;lt;img
                className={styles.img}
                src={thumbnails?.maxres?.url || (keyword ? thumbnails.high.url : thumbnails.medium.url)}
                alt={title}
            /&amp;gt;
            {isHovered &amp;amp;&amp;amp; (
                &amp;lt;iframe
                    className={styles.video}
                    src={`https://www.youtube.com/embed/${video.id}?autoplay=1&amp;amp;mute=1&amp;amp;controls=0&amp;amp;playlist=${video.id}`}
                    frameBorder=&quot;0&quot;
                    allow=&quot;autoplay; encrypted-media&quot;
                    allowFullScreen
                    title={title}
                &amp;gt;&amp;lt;/iframe&amp;gt;
            )}
        &amp;lt;/section&amp;gt;
    &amp;lt;/li&amp;gt;
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;
&lt;div data-message-id=&quot;646cb772-9395-4e9a-9337-995da4168074&quot; data-message-author-role=&quot;assistant&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React/Youtube</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/45</guid>
      <comments>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-7#entry45comment</comments>
      <pubDate>Sat, 7 Sep 2024 05:41:14 +0900</pubDate>
    </item>
    <item>
      <title>YouTube Data API를 이용한 Youtube Clone - 6</title>
      <link>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-6</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;저번 포스팅에서는 YoutubeApi의 getPopularVideos 나 getVideosByKeyword 메서드에서 channelsResponse 데이터를 모두 불러온 뒤 채널 썸네일만 따로 추출해 리턴하는 방식을 사용했지만, 구독자 수나 relatedPlaylist와 같은 정보도 필요해지면서 데이터를 따로 추출해서 리턴하기 보다는 불러온 채널에 관한 정보를 그대로 리턴하는 방식으로 변경했습니다.&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;그리고 VideoDetail 컴포넌트의 구현 과정에서 CommentList에 무한 스크롤 기능이 필요하여서 구현하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현하고 보니 Videos 컴포넌트에서 이미 구현했던 무한 스크롤 로직과 동일하여서 무한 스크롤 관련 로직을 커스텀 훅으로 분리하였습니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. YoutubeApi 변경 및 추가&lt;/b&gt;&lt;/h2&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;변경&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 설명한 대로, getVideosByKeyword 메서드에서 channelsResponse에 포함된 채널 정보를 그대로 반환하도록 변경했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;getPopularVideos도 동일한 방식으로 변경했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725455130425&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;    async getVideosByKeyword(keyword: string, pageToken: string) {
        const response = await this.httpClient.get(&quot;search&quot;, {
            params: {
                part: &quot;snippet&quot;,
                maxResults: 10,
                q: keyword,
                pageToken: pageToken,
            },
        });

        const videoIds = response.data.items.map((item: any) =&amp;gt; item.id.videoId).join(',');
        const channelIds = Array.from(new Set(response.data.items.map((item: Video) =&amp;gt; item.snippet.channelId)));

        const videosResponse = await this.httpClient.get(&quot;videos&quot;, {
            params: {
                part: &quot;snippet,statistics&quot;,
                id: videoIds,
            },
        });

        const channelsResponse = await this.httpClient.get(&quot;channels&quot;, {
            params: {
                part: &quot;snippet,contentDetails,statistics&quot;,
                id: channelIds.join(','),
            },
        });

        const channelDetails: { [key: string]: ChannelDetail } = {};

        channelsResponse.data.items.forEach((channel: ChannelDetail) =&amp;gt; {
            channelDetails[channel.id] = channel
        });

        return {
            items: videosResponse.data.items.map((item: any) =&amp;gt; ({
                ...item,
                channelDetails: channelDetails[item.snippet.channelId],
            })),
            nextPageToken: response.data.nextPageToken,
        };
    },&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;추가&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1725455130426&quot; class=&quot;cs&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;async getComments(videoId: string, pageToken: string | undefined) {
    const response = await this.httpClient.get(&quot;commentThreads&quot;, {
        params: {
            part: &quot;snippet&quot;,
            videoId: videoId,
            maxResults: 10,
            pageToken: pageToken,
        },
    });
    return response.data;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;VideoDetail 컴포넌트의 CommentList에서 필요한 댓글 정보를 불러오는 요청을 추가했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;비디오 ID와 무한 스크롤을 위한 페이지 토큰을 매개변수로 받아 commentThreads API에서 댓글 데이터를 가져옵니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. VideoDetail 컴포넌트&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VideoDetail 컴포넌트에서는 비디오 상세 정보와 함께 댓글 목록과 관련 비디오를 보여줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비디오 설명이 길 경우 toggle 기능을 추가하여, 더보기 또는 간략히 버튼을 통해 설명을 펼치거나 접을 수 있도록 구현했습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000;&quot; data-ke-size=&quot;size23&quot;&gt;toggle 기능&lt;/h3&gt;
&lt;pre id=&quot;code_1725455130427&quot; class=&quot;reasonml&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const [expanded, setExpanded] = useState(false);
const [isTruncated, setIsTruncated] = useState(false);
const descRef = useRef&amp;lt;HTMLPreElement&amp;gt;(null);

useEffect(() =&amp;gt; {
    if (descRef.current) {
        setIsTruncated(descRef.current.scrollHeight &amp;gt; descRef.current.clientHeight);
    }
}, [description]);

const toggleDesc = () =&amp;gt; {
    setExpanded(!expanded);
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설명이  긴 경우(isTruncated)와 설명이 펼쳐졌는지 여부(expanded)를 관리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useRef를 이용해 description 요소의 높이를 체크하여 scrollHeight와 clientHeight를 비교, 설명이 잘리는지를 판단합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;scrollHeight: 요소의 실제 콘텐츠가 차지하는 전체 높이로, 화면에 보이지 않는 부분도 포함됩니다.&lt;/li&gt;
&lt;li&gt;clientHeight: 화면에 보이는 영역의 높이로, 스크롤 없이 보여지는 부분만 포함됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1725455130427&quot; class=&quot;html xml&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;&amp;lt;div className={`${styles.desc} ${isTruncated ? styles.cursor : ''}`} onClick={isTruncated ? toggleDesc : undefined}&amp;gt;
    &amp;lt;div className={styles.count}&amp;gt;
        &amp;lt;span className={styles.bold}&amp;gt;조회수 {formatViewCount(viewCount)}&amp;lt;/span&amp;gt;
        &amp;lt;span className={styles.bold}&amp;gt;{formatDateTime(publishedAt)}&amp;lt;/span&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;pre ref={descRef} className={expanded ? styles.expanded : styles.collapsed}&amp;gt;
        {description}
    &amp;lt;/pre&amp;gt;
    {isTruncated &amp;amp;&amp;amp; (
        &amp;lt;span className={styles.toggle}&amp;gt;
            {expanded ? '간략히' : '더보기'}
        &amp;lt;/span&amp;gt;
    )}
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;isTruncated 값에 따라 cursor클래스를 통해 cursor-pointer가 적용되며, 클릭 시 toggleDesc 함수가 실행되어 설명이 펼쳐지거나 축소됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그에 따라 더보기 와 간략히 버튼이 표시됩니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;3. 무한스크롤 로직 분리&lt;/h2&gt;
&lt;pre id=&quot;code_1725455130428&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;import { useInfiniteQuery } from &quot;@tanstack/react-query&quot;;
import { useCallback, useRef } from &quot;react&quot;;

interface InfiniteScrollOptions&amp;lt;T&amp;gt; {
    queryKey: string[];
    queryFn: (context: { pageParam?: string }) =&amp;gt; Promise&amp;lt;T&amp;gt;;
    getNextPageParam: (lastPage: T) =&amp;gt; string | undefined;
    staleTime?: number;
    gcTime?: number;
    refetchOnWindowFocus?: boolean;
    refetchOnMount?: boolean;
    refetchOnReconnect?: boolean;
    initialPageParam?: string;
}

export function useInfiniteScroll&amp;lt;T&amp;gt;({
    queryKey,
    queryFn,
    getNextPageParam,
    staleTime = 60000,
    gcTime = 1000 * 60 * 10,
    refetchOnWindowFocus = false,
    refetchOnMount = false,
    refetchOnReconnect = false,
    initialPageParam = &quot;&quot;,
}: InfiniteScrollOptions&amp;lt;T&amp;gt;) {
    const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
        useInfiniteQuery({
            queryKey,
            queryFn,
            getNextPageParam,
            staleTime,
            gcTime,
            refetchOnWindowFocus,
            refetchOnMount,
            refetchOnReconnect,
            initialPageParam,
        });

    const observer = useRef&amp;lt;IntersectionObserver | null&amp;gt;(null);
    const lastElementRef = useCallback(
        (node: HTMLElement | null) =&amp;gt; {
            if (isFetchingNextPage) return;
            if (observer.current) observer.current.disconnect();
            observer.current = new IntersectionObserver((entries) =&amp;gt; {
                if (entries[0].isIntersecting &amp;amp;&amp;amp; hasNextPage) {
                    fetchNextPage();
                }
            });
            if (node) observer.current.observe(node);
        },
        [isFetchingNextPage, fetchNextPage, hasNextPage]
    );

    return { data, lastElementRef, isFetchingNextPage };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무한 스크롤 관련 로직에 대한 설명은 유튜브 프로젝트 4번째 포스팅에서 다루었기 때문에, 이 글에서는 생략하겠습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000;&quot; data-ke-size=&quot;size23&quot;&gt;제네릭 타입 &amp;lt;T&amp;gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useInfiniteScroll&amp;lt;T&amp;gt;의 옆에 있는 &amp;lt;T&amp;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;이 경우에는 API에서 반환된 데이터를 포함할 타입을 유연하게 정의하는 것입니다.&lt;br /&gt;T는 API 호출 결과로 반환되는 데이터의 타입으로, 예를 들어 댓글 목록 또는 비디오 목록과 같은 데이터를 나타낼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 제네릭을 사용함으로써, 무한 스크롤을 다양한 데이터 타입에 대해 재사용할 수 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000;&quot; data-ke-size=&quot;size23&quot;&gt;InfiniteScrollOptions&amp;lt;T&amp;gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 InfiniteScrollOptions&amp;lt;T&amp;gt;의 제네릭 &amp;lt;T&amp;gt;도 위와 동일한 개념으로, 이 훅을 사용할 때 받을 데이터의 타입을 정의합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 댓글의 데이터를 받는다면 Comment[], 비디오의 데이터를 받는다면 Video[] 등의 타입을 전달하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 훅은 아래 3가지 값을 반환합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;data: API 호출을 통해 페칭한 데이터&lt;/li&gt;
&lt;li&gt;lastElementRef: IntersectionObserver와 연결된 마지막 요소의 참조를 반환하는 함수&lt;/li&gt;
&lt;li&gt;isFetchingNextPage: 다음 페이지 데이터를 가져오는 중인지 여부를 나타내는 상태&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000;&quot; data-ke-size=&quot;size23&quot;&gt;사용 예시&lt;/h3&gt;
&lt;pre id=&quot;code_1725455130429&quot; class=&quot;reasonml&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;    const { data: comments, lastElementRef, isFetchingNextPage } = useInfiniteScroll&amp;lt;{
        items: Comment[],
        nextPageToken?: string
    }&amp;gt;({
        queryKey: [&quot;comments&quot;, videoId],
        queryFn: async ({ pageParam }) =&amp;gt; {
            return YoutubeApi.getComments(videoId, pageParam);
        },
        getNextPageParam: (lastPage) =&amp;gt; lastPage.nextPageToken || undefined,
    });&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1725455130430&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;    const { data: videos, lastElementRef, isFetchingNextPage } = useInfiniteScroll&amp;lt;{
        items: Video[],
        nextPageToken?: string,
    }&amp;gt;({
        queryKey: [&quot;videos&quot;, keyword || &quot;&quot;],
        queryFn: async ({ pageParam }) =&amp;gt; {
            return YoutubeApi.search(keyword || &quot;&quot;, pageParam);
        },
        getNextPageParam: (lastPage) =&amp;gt; lastPage.nextPageToken || undefined,
    });&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div data-message-author-role=&quot;assistant&quot; data-message-id=&quot;652663ee-b2ba-4ff1-aedd-e457fab6bb25&quot;&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>React/Youtube</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/44</guid>
      <comments>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-6#entry44comment</comments>
      <pubDate>Wed, 4 Sep 2024 20:19:16 +0900</pubDate>
    </item>
    <item>
      <title>YouTube Data API를 이용한 Youtube Clone - 5</title>
      <link>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-5</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 Youtube Data API에서 비디오 데이터를 가져올 때, videos API를 통해 요청하면 채널 썸네일 URL이 제공되지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 비디오 데이터에서 채널 ID를 추출한 후 별도로 채널 정보를 요청하여 썸네일 URL을 가져오는 로직을 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, VideoDetail 라우터를 설정하여 비디오 카드를 클릭했을 때 해당 상세 페이지로 이동하며, 데이터를 함께 전달하는 기능을 구현했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. youtubeAPI 수정&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Youtube Data API 하루 할당량을 조금이라도 아끼려고, new Set()을 사용하여 중복된 채널 ID를 제거 한 후 채널 정보를 요청했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725253035725&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from &quot;axios&quot;;

const YoutubeApi = {
    httpClient: axios.create({
        baseURL: &quot;https://www.googleapis.com/youtube/v3&quot;,
        params: { key: process.env.REACT_APP_YOUTUBE_API_KEY },
    }),

    async getVideosByKeyword(keyword: string, pageToken: string) {
        const response = await this.httpClient.get(&quot;search&quot;, {
            params: {
                part: &quot;snippet&quot;,
                maxResults: 20,
                q: keyword,
                pageToken: pageToken,
            },
        });

        const videoIds = response.data.items.map((item: any) =&amp;gt; item.id.videoId).join(',');
        const channelIds = Array.from(new Set(response.data.items.map((item: any) =&amp;gt; item.snippet.channelId)));

        const videosResponse = await this.httpClient.get(&quot;videos&quot;, {
            params: {
                part: &quot;snippet,statistics&quot;,
                id: videoIds,
            },
        });

        // 채널 썸네일을 가져오기 위한 요청
        const channelsResponse = await this.httpClient.get(&quot;channels&quot;, {
            params: {
                part: &quot;snippet&quot;,
                id: channelIds.join(','),
            },
        });

        // 채널 썸네일을 매핑
        const channelThumbnails: { [key: string]: string } = {};

        channelsResponse.data.items.forEach((channel: any) =&amp;gt; {
            channelThumbnails[channel.id] = channel.snippet.thumbnails.default.url;
        });

        // 비디오 정보와 채널 썸네일을 결합하여 반환
        return {
            items: videosResponse.data.items.map((item: any) =&amp;gt; ({
                ...item,
                id: item.id,
                channelThumbnail: channelThumbnails[item.snippet.channelId],
            })),
            nextPageToken: response.data.nextPageToken,
        };
    },
};

export default YoutubeApi;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생략했지만 getPopularVideos 함수에서도  동일하게 중복된 채널 ID를 제거한 후 채널 정보를 요청하도록 코드를 수정했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. Videos 컴포넌트에서 스타일 적용&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Videos 컴포넌트에서는 검색어(keyword)의 유무에 따라 리스트 또는 그리드 형식으로 비디오를 표시하도록 스타일을 다르게 적용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 무한 스크롤이 구현되어 있어 그리드 형식에서는 조금만 내려도 무한 스크롤로 인해 다시 API 요청이 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 데이터는 할당량이 많이 소모되기 때문에 이를 보완하기 위해 리스트 형식으로 변경하여 스크롤을 더 많이 내려야 API 요청이 이루어지도록 하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725253352122&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Videos() {
    const listStyle = keyword ? styles.list : styles.grid;

    return (
        &amp;lt;ul className={listStyle}&amp;gt;
            ...
        &amp;lt;/ul&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. VideoCard 컴포넌트의 스타일 및 기능 구현&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VideoCard 컴포넌트에서도 마찬가지로 검색어 유무에 따라 다른 스타일을 적용하고, li 요소를 클릭했을 때 비디오 상세 페이지로 이동하도록 navigate 함수를 활용해 라우팅을 추가했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때, 비디오 데이터를 함께 전달하여 상세 페이지에서 사용할 수 있게 했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725253444046&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const VideoCard = ({ video }, ref) =&amp;gt; {
    const cardClassName = keyword ? styles['card-search'] : styles.card;
    const navigate = useNavigate();

    return (
        &amp;lt;li
            className={cardClassName}
            ref={ref}
            onClick={() =&amp;gt; navigate(`videos/watch/${video.id}`, { state: { video } })}
        &amp;gt;
            ...
        &amp;lt;/li&amp;gt;
    );
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1725253914305&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function VideoDetail() {
    const { state: { video } } = useLocation();
    console.log(video);
    return &amp;lt;div&amp;gt;VideoDetail&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VideoDetail 페이지에서는 전달받은 비디오 데이터를 단순히 콘솔에 출력만 해두었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 이 데이터를 기반으로 UI를 추가할 예정입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725253805844&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.img-container {
    position: relative;
    background-color: #f0f0f0;
    border-radius: 16px;
    overflow: hidden;
    transition: border-radius 0.5s, transform 0.3s;
}

.card .img-container {
    width: 100%;
    padding-top: 56.25%;
}

.card-search .img-container {
    width: 36%;
    padding-top: 20.25%;
}

/* ... */

.card-search {
    display: flex;
    gap: 22px;
    justify-content: center;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.card 클래스와 .card-search 클래스는 카드 레이아웃과 검색 시 적용되는 레이아웃을 다르게 적용했습니다.&lt;/p&gt;</description>
      <category>React/Youtube</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/43</guid>
      <comments>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-5#entry43comment</comments>
      <pubDate>Mon, 2 Sep 2024 14:20:55 +0900</pubDate>
    </item>
    <item>
      <title>YouTube Data API를 이용한 Youtube Clone - 4</title>
      <link>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-4</link>
      <description>&lt;div&gt;프로젝트를 진행하면서, 페이지네이션 같은 기능이 필요하다고 생각했습니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유튜브 사이트에서도 무한 스크롤을 통해 데이터를 로드하고 있어, 저도 무한 스크롤을 적용해보기로 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해 useInfiniteQuery와 IntersectionObserver를 사용하여 무한 스크롤 기능을 구현했습니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. YoutubeApi 변경&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;YouTube Data API는 많은 데이터를 페이지 단위로 나눠서 제공하는데, 이 때 사용되는 것이 pageToken과 nextPageToken입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 매개변수들을 사용하면, API 요청 시 특정 페이지의 데이터를 가져올 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725145839389&quot; class=&quot;cs&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;import axios from &quot;axios&quot;;

const YoutubeApi = {
    httpClient: axios.create({
        baseURL: &quot;https://www.googleapis.com/youtube/v3&quot;,
        params: { key: process.env.REACT_APP_YOUTUBE_API_KEY },
    }),

    search(keyword: string | undefined, pageToken: string = &quot;&quot;) {
        return keyword ? this.getVideosByKeyword(keyword, pageToken) : this.getPopularVideos(pageToken);
    },

    async getVideosByKeyword(keyword: string, pageToken: string) {
        const response = await this.httpClient.get(&quot;search&quot;, {
            params: {
                part: &quot;snippet&quot;,
                maxResults: 25,
                q: keyword,
                pageToken: pageToken,
            },
        });

        const videoIds = response.data.items.map((item: any) =&amp;gt; item.id.videoId).join(',');
        const videosResponse = await this.httpClient.get(&quot;videos&quot;, {
            params: {
                part: &quot;snippet,statistics&quot;,
                id: videoIds,
            },
        });

        return {
            items: videosResponse.data.items.map((item: any) =&amp;gt; ({
                ...item,
                id: item.id,
            })),
            nextPageToken: response.data.nextPageToken,
        }
    },

    async getPopularVideos(pageToken: string) {
        const response = await this.httpClient.get(&quot;videos&quot;, {
            params: {
                part: &quot;snippet, statistics&quot;,
                maxResults: 25,
                chart: &quot;mostPopular&quot;,
                regionCode: &quot;KR&quot;,
                pageToken: pageToken,
            },
        });

        return {
            items: response.data.items,
            nextPageToken: response.data.nextPageToken,
        }
    },
};

export default YoutubeApi;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;pageToken과 nextPageToken의 역할&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;pageToken:&lt;/b&gt; pageToken은 현재 요청하는 페이지를 지정하는 토큰입니다. API 요청 시 이 값을 포함하면, 해당 토큰에 맞는 페이지 데이터를 가져옵니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;nextPageToken:&lt;/b&gt; nextPageToken은 API 응답에서 제공되며, 다음 페이지의 데이터를 요청할 때 사용할 수 있는 토큰입니다. 응답받은 데이터의 마지막에 위치한 토큰으로, 이를 통해 이어지는 페이지의 데이터를 가져올 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;2. Videos 컴포넌트에서 무한 스크롤 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 컴포넌트에서는 useInfiniteQuery와 IntersectionObserver를 사용하여 무한 스크롤 기능을 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 데이터를 로드하는 과정에서 단순히 Loading 텍스트를 표시하고 있지만, 추후에는 로딩 스피너나 다른 스타일을 적용할 계획입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725145839393&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;import { useParams } from &quot;react-router-dom&quot;;
import styles from &quot;./Videos.module.css&quot;;
import { useInfiniteQuery } from &quot;@tanstack/react-query&quot;;
import VideoCard from &quot;../../components/VideoCard/VideoCard&quot;;
import { Video } from &quot;../../../public/types&quot;;
import YoutubeApi from &quot;../../api/youtubeApi&quot;;
import { useCallback, useRef } from &quot;react&quot;;

export default function Videos() {
    const { keyword } = useParams();
    const {
        data: videos,
        fetchNextPage,
        hasNextPage,
        isFetchingNextPage,
    } = useInfiniteQuery({
        queryKey: [&quot;videos&quot;, keyword],
        queryFn: async ({ pageParam = &quot;&quot; }) =&amp;gt; {
            return YoutubeApi.search(keyword, pageParam);
        },
        getNextPageParam: (lastPage) =&amp;gt; lastPage.nextPageToken || undefined,
        staleTime: 60000,
        gcTime: 1000 * 60 * 10,
        refetchOnWindowFocus: false,
        refetchOnMount: false,
        refetchOnReconnect: false,
        initialPageParam: &quot;&quot;,
    });

    const observer = useRef&amp;lt;IntersectionObserver | null&amp;gt;(null);
    const lastVideoElementRef = useCallback(
        (node: HTMLElement | null) =&amp;gt; {
            if (isFetchingNextPage) return;
            if (observer.current) observer.current.disconnect();
            observer.current = new IntersectionObserver((entries) =&amp;gt; {
                if (entries[0].isIntersecting &amp;amp;&amp;amp; hasNextPage) {
                    fetchNextPage();
                }
            });
            if (node) observer.current.observe(node);
        },
        [isFetchingNextPage, fetchNextPage, hasNextPage]
    );

    return (
        &amp;lt;&amp;gt;
            &amp;lt;ul className={styles.grid}&amp;gt;
                {videos?.pages.map((page, pageIndex) =&amp;gt; 
                    page.items.map((video: Video, index: number) =&amp;gt; {
                        if (pageIndex === videos.pages.length - 1 &amp;amp;&amp;amp; index === page.items.length - 1) {
                            return (
                                &amp;lt;VideoCard ref={lastVideoElementRef} key={video.id} video={video}/&amp;gt;
                            );
                        } else {
                            return &amp;lt;VideoCard key={video.id} video={video}/&amp;gt;
                        }
                    })
                )}
                {isFetchingNextPage &amp;amp;&amp;amp; &amp;lt;p&amp;gt;Loading...&amp;lt;/p&amp;gt;}
            &amp;lt;/ul&amp;gt;
        &amp;lt;/&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;useInfiniteQuery의 사용&lt;/b&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;queryKey: 쿼리의 고유 키로, 검색어와 함께 설정되어 동일한 키워드로 검색된 데이터가 캐시될 수 있습니다.&lt;/li&gt;
&lt;li&gt;queryFn: 데이터를 가져오는 함수로, YoutubeApi.search를 사용하여 keyword와 pageToken을 기반으로 결과를 가져옵니다.&lt;/li&gt;
&lt;li&gt;getNextPageParam: nextPageToken을 추출하여 다음 페이지 데이터를 가져올 수 있게 합니다. lastPage는 가장 최근에 가져온 데이터의 페이지를 나타냅니다. nextPageToken이 존재하지 않으면 undefined를 반환하여 더 이상 가져올 페이지가 없다는 것을 나타냅니다.&lt;/li&gt;
&lt;li&gt;initialPageParam: 초기 페이지의 요청을 위한 파라미터로, 빈 문자열로 설정하여 첫 페이지를 가져옵니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;IntersectionObserver를 이용한 무한 스크롤&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. useRef를 통한 observer 저장&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;observer는 useRef를 통해 저장됩니다. useRef를 사용하면 컴포넌트가 리렌더링되더라도 동일한 observer 인스턴스를 유지할 수 있습니다. useRef는 .current 프로퍼티를 통해 값에 접근할 수 있습니다.&lt;/p&gt;
&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;2. observe.disconnect()의 사용 이유&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;즉, 한 번 관찰된 요소가 화면에 다시 나타날 때마다 데이터가 중복으로 로드될 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 방지하기 위해 disconnect()를 사용하여 기존에 관찰하던 요소들의 관찰을 중지시키고, 새로운 마지막 요소만을 관찰하게 합니다.&lt;/p&gt;
&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;3. useCallback의 사용 이유&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;disconnect()가 제대로 작동하려면, lastVideoElementRef 함수가 컴포넌트가 리렌더링될 때마다 새로 생성되지 않도록 해야 합니다. 만약 useCallback을 사용하지 않으면, 컴포넌트가 리렌더링될 때마다 lastVideoElementRef 함수가 새로 생성되고, 이로 인해 ref가 변경되면서 IntersectionObserver가 새로운 함수에 연결된 요소를 관찰하게 됩니다. 이 과정에서 disconnect()가 제대로 작동하지 않게 되어, 무한 스크롤이 정상적으로 동작하지 않습니다.&lt;/p&gt;
&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;4. 마지막 요소에만 ref를 전달하는 이유&lt;/b&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;이 때문에 페이지의 마지막 인덱스에 해당하는 요소에만 ref를 전달하여, 그 요소가 화면에 나타날 때만 IntersectionObserver가 동작하도록 합니다. 이를 통해 효율적으로 데이터를 불러올 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. VideoCard 컴포넌트에서 forwardRef 사용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VideoCard 컴포넌트는 각 동영상의 정보를 표시합니다. 여기서 중요한 점은 forwardRef를 사용하여 부모 컴포넌트에서 ref를 전달받아 DOM 요소에 접근할 수 있도록 하는 것입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725145877345&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import React, { forwardRef } from &quot;react&quot;;
import { Video } from &quot;../../../public/types&quot;;
import styles from &quot;./VideoCard.module.css&quot;;
import { formatDateTime, formatViewCount } from &quot;../../util&quot;;

const VideoCard = forwardRef&amp;lt;HTMLLIElement, { video: Video }&amp;gt;(({ video }, ref) =&amp;gt; {
    const { title, channelTitle, thumbnails, publishedAt } = video.snippet;
    const viewCount = parseInt(video.statistics?.viewCount || &quot;0&quot;, 10);

    return (
        &amp;lt;li className={styles.card} ref={ref}&amp;gt;
            &amp;lt;div className={styles['img-container']}&amp;gt;
                &amp;lt;img
                    className={styles.img}
                    src={thumbnails.medium.url}
                    alt={title}
                /&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;h3 className={styles.title}&amp;gt;{title}&amp;lt;/h3&amp;gt;
            &amp;lt;p className={styles.text}&amp;gt;{channelTitle}&amp;lt;/p&amp;gt;
            &amp;lt;div className={styles.flex}&amp;gt;
                &amp;lt;p className={styles.text}&amp;gt;
                    조회수 {formatViewCount(viewCount)}
                &amp;lt;/p&amp;gt;
                &amp;lt;p className={styles.text}&amp;gt;{formatDateTime(publishedAt)}&amp;lt;/p&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/li&amp;gt;
    );
});

export default VideoCard;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;forwardRef :&lt;/b&gt; 부모 컴포넌트에서 ref를 전달받아 자식 컴포넌트에서 DOM 요소에 접근하려면 forwardRef가 필요합니다. 만약 forwardRef를 사용하지 않고 일반 함수형 컴포넌트로 작성할 경우, ref를 직접 받을 수 없기 때문에 오류가 발생합니다.&lt;/p&gt;
&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;HTMLLIElement 타입 :&lt;/b&gt; &amp;lt;li&amp;gt; 요소의 타입을 명확히 지정하기 위해 HTMLLIElement를 사용합니다. HTMLElement로 설정하면 &amp;lt;li&amp;gt; 요소와 관련된 특정 속성을 활용할 수 없기 때문에 오류가 발생할 수 있습니다.&lt;/p&gt;</description>
      <category>React/Youtube</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/42</guid>
      <comments>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-4#entry42comment</comments>
      <pubDate>Sun, 1 Sep 2024 13:07:34 +0900</pubDate>
    </item>
    <item>
      <title>React Query로 페이징된 데이터 로드 구현하기</title>
      <link>https://hy-un.tistory.com/entry/React-Query%EB%A1%9C-%ED%8E%98%EC%9D%B4%EC%A7%95%EB%90%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%A1%9C%EB%93%9C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://tanstack.com/query/v4/docs/framework/react/reference/useInfiniteQuery&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://tanstack.com/query/v4/docs/framework/react/reference/useInfiniteQuery&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1725113973638&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;useInfiniteQuery | TanStack Query React Docs&quot; data-og-description=&quot;This ad helps to keep us from burning out and rage-quitting OSS just *that* much more, so chill.  &quot; data-og-host=&quot;tanstack.com&quot; data-og-source-url=&quot;https://tanstack.com/query/v4/docs/framework/react/reference/useInfiniteQuery&quot; data-og-url=&quot;https://tanstack.com/query/v4/docs/framework/react/reference/useInfiniteQuery&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cjg1b1/hyWVVuayQm/v8kJp9wq4Seg4ZsKNQEreK/img.png?width=3000&amp;amp;height=1704&amp;amp;face=0_0_3000_1704&quot;&gt;&lt;a href=&quot;https://tanstack.com/query/v4/docs/framework/react/reference/useInfiniteQuery&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://tanstack.com/query/v4/docs/framework/react/reference/useInfiniteQuery&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cjg1b1/hyWVVuayQm/v8kJp9wq4Seg4ZsKNQEreK/img.png?width=3000&amp;amp;height=1704&amp;amp;face=0_0_3000_1704');&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;useInfiniteQuery | TanStack Query React Docs&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;This ad helps to keep us from burning out and rage-quitting OSS just *that* much more, so chill.  &lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;tanstack.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useInfiniteQuery는 React Query에서 제공하는 훅 중 하나로, 페이징된 데이터를 손쉽게 가져오고 관리할 수 있게 해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 useQuery와 유사하지만, 여러 페이지의 데이터를 관리할 수 있는 추가 기능을 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 활용하면 무한 스크롤과 같은 기능을 쉽게 구현할 수 있습니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 기본 사용법&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useInfiniteQuery를 사용하면 데이터를 페이지 단위로 나누어 불러오고, 사용자가 요청할 때마다 새로운 페이지의 데이터를 가져올 수 있습니다.&lt;/p&gt;
&lt;h4 style=&quot;color: #000000;&quot; data-ke-size=&quot;size20&quot;&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;b&gt;queryFn&lt;/b&gt;: 데이터를 가져오는 함수로, 반드시 Promise를 반환해야 합니다. 이 함수는 pageParam이라는 인자를 받아 특정 페이지의 데이터를 가져옵니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;getNextPageParam&lt;/b&gt;: 다음 페이지의 파라미터를 결정하는 함수입니다. lastPage와 allPages를 인자로 받아 다음 페이지의 파라미터를 반환합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;getPreviousPageParam&lt;/b&gt;: 이전 페이지의 파라미터를 결정하는 함수입니다. firstPage와 allPages를 인자로 받아 이전 페이지의 파라미터를 반환합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000;&quot; data-ke-size=&quot;size20&quot;&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;b&gt;data.pages&lt;/b&gt;: 불러온 모든 페이지의 데이터 배열입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;fetchNextPage&lt;/b&gt;: 다음 페이지를 가져오는 함수입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;fetchPreviousPage&lt;/b&gt;: 이전 페이지를 가져오는 함수입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;hasNextPage&lt;/b&gt;: 다음 페이지가 있는지 여부를 나타내는 boolean 값입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;hasPreviousPage&lt;/b&gt;: 이전 페이지가 있는지 여부를 나타내는 boolean 값입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;isFetchingNextPage&lt;/b&gt;: 다음 페이지를 가져오는 중인지 여부를 나타내는 boolean 값입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;isFetchingPreviousPage&lt;/b&gt;: 이전 페이지를 가져오는 중인지 여부를 나타내는 boolean 값입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 예제 코드&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 JSON 데이터를 이용해 pageSize와 같은 페이지 처리를 프론트엔드에서 구현했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;모든 데이터를 한 번에 클라이언트로 가져온 후, 프론트엔드에서 페이지 단위로 슬라이스하여 표시하는 방식입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;실제 환경에서는 서버가 페이지 번호와 pageSize를 받아 필요한 데이터만 응답으로 보내는 것이 일반적입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 예제는 프론트엔드에서 페이지네이션의 기본 개념과 React Query의 useInfiniteQuery 훅 사용법을 익히기 위한 것입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;실제 데이터를 사용한 무한 스크롤 예제는 현재 진행 중인 개인 유튜브 클론 프로젝트에서 YouTube Data API를 활용해 구현할 예정입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725113612267&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;import React from &quot;react&quot;;
import { useInfiniteQuery } from &quot;@tanstack/react-query&quot;;

// 현재 페이지 번호를 기반으로 데이터를 가져오는 비동기 함수
const fetchVideos = async ({ pageParam = 1 }) =&amp;gt; {
  const response = await fetch('/data/videos.json');
  
  if (!response.ok) {
    throw new Error('네트워크 오류');
  }
  
  // 전체 데이터
  const allData = await response.json();

  // pageSize -&amp;gt; 한 페이지에 표시할 데이터의 수
  // start, end -&amp;gt; 데이터의 시작 인덱스와 끝 인덱스를 계산
  const pageSize = 5;
  const start = (pageParam - 1) * pageSize;
  const end = start + pageSize;

  const videoData = allData.flatMap(page =&amp;gt; page.videos);
  
  // 현재 페이지에 해당하는 데이터를 잘라냄
  const pageData = videoData.slice(start, end);
  
  // nextCursor -&amp;gt; 다음 페이지가 있는지 여부
  const nextCursor = end &amp;lt; videoData.length ? pageParam + 1 : null;

  // 현재 페이지에 해당하는 데이터와 다음 페이지의 커서를 반환
  return {
    videos: pageData,
    nextCursor,
  };
};

const VideoList = () =&amp;gt; {
  const {
    data,                 // 페칭된 데이터
    fetchNextPage,        // 다음 페이지를 가져오는 함수
    hasNextPage,          // 다음 페이지가 있는지 여부
    isFetchingNextPage,   // 다음 페이지를 가져오는 중인지 여부
    isLoading,            // 초기 데이터 로딩 중인지 여부
    isError,              // 에러 발생 여부
    error,                // 발생한 에러 객체
  } = useInfiniteQuery({
    queryKey: ['videos'],
    queryFn: fetchVideos,
    // lastPage는 직전의 로드된 페이지 데이터
    // 다음 페이지의 파라미터를 반환하여, 다음 데이터를 로드할 수 있도록 돕는 함수
    getNextPageParam: (lastPage) =&amp;gt; lastPage.nextCursor,
  });

  if (isLoading) {
    return &amp;lt;div&amp;gt;Loading...&amp;lt;/div&amp;gt;;
  }

  if (isError) {
    return &amp;lt;div&amp;gt;Error: {error.message}&amp;lt;/div&amp;gt;;
  }

  return (
    &amp;lt;div&amp;gt;
      {data.pages.map((page, pageIndex) =&amp;gt; (
        &amp;lt;div key={pageIndex}&amp;gt;
          {page.videos.map((video, videoIndex) =&amp;gt; (
            &amp;lt;div key={video.id || videoIndex} style={{ marginBottom: '20px' }}&amp;gt;
              &amp;lt;h3&amp;gt;{video.title}&amp;lt;/h3&amp;gt;
              &amp;lt;p&amp;gt;{video.description}&amp;lt;/p&amp;gt;
              &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;업로드 날짜:&amp;lt;/strong&amp;gt; {video.uploadDate}&amp;lt;/p&amp;gt;
            &amp;lt;/div&amp;gt;
          ))}
        &amp;lt;/div&amp;gt;
      ))}
      &amp;lt;button
        onClick={() =&amp;gt; fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      &amp;gt;
        {isFetchingNextPage ? '비디오 로딩 중...' : hasNextPage ? '더 많은 비디오 로드' : '더 이상 비디오 없음'}
      &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};

export default VideoList;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 예제에서 사용한 json 데이터 입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725113612271&quot; class=&quot;json&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;[
    {
        &quot;videos&quot;: [
            {
                &quot;id&quot;: 1,
                &quot;title&quot;: &quot;HTML 소개&quot;,
                &quot;description&quot;: &quot;HTML 기본 개념&quot;,
                &quot;uploadDate&quot;: &quot;2024-08-30&quot;
            },
            {
                &quot;id&quot;: 2,
                &quot;title&quot;: &quot;CSS 기초&quot;,
                &quot;description&quot;: &quot;CSS 기본 사용법&quot;,
                &quot;uploadDate&quot;: &quot;2024-08-25&quot;
            },
            {
                &quot;id&quot;: 3,
                &quot;title&quot;: &quot;JavaScript 기초&quot;,
                &quot;description&quot;: &quot;JavaScript 기본 개념&quot;,
                &quot;uploadDate&quot;: &quot;2024-08-20&quot;
            },
            {
                &quot;id&quot;: 4,
                &quot;title&quot;: &quot;React 소개&quot;,
                &quot;description&quot;: &quot;React 개요&quot;,
                &quot;uploadDate&quot;: &quot;2024-08-15&quot;
            },
            {
                &quot;id&quot;: 5,
                &quot;title&quot;: &quot;useState &amp;amp; useEffect&quot;,
                &quot;description&quot;: &quot;React 훅 사용법&quot;,
                &quot;uploadDate&quot;: &quot;2024-08-10&quot;
            }
        ],
        &quot;nextCursor&quot;: 2
    },
    {
        &quot;videos&quot;: [
            {
                &quot;id&quot;: 6,
                &quot;title&quot;: &quot;Flexbox&quot;,
                &quot;description&quot;: &quot;CSS Flexbox 레이아웃&quot;,
                &quot;uploadDate&quot;: &quot;2024-08-05&quot;
            },
            {
                &quot;id&quot;: 7,
                &quot;title&quot;: &quot;Grid&quot;,
                &quot;description&quot;: &quot;CSS Grid 레이아웃&quot;,
                &quot;uploadDate&quot;: &quot;2024-08-01&quot;
            },
            {
                &quot;id&quot;: 8,
                &quot;title&quot;: &quot;JavaScript ES6+&quot;,
                &quot;description&quot;: &quot;최신 JavaScript 문법&quot;,
                &quot;uploadDate&quot;: &quot;2024-07-28&quot;
            },
            {
                &quot;id&quot;: 9,
                &quot;title&quot;: &quot;React 컴포넌트&quot;,
                &quot;description&quot;: &quot;React 컴포넌트 사용법&quot;,
                &quot;uploadDate&quot;: &quot;2024-07-24&quot;
            },
            {
                &quot;id&quot;: 10,
                &quot;title&quot;: &quot;useReducer&quot;,
                &quot;description&quot;: &quot;복잡한 상태 관리&quot;,
                &quot;uploadDate&quot;: &quot;2024-07-20&quot;
            }
        ],
        &quot;nextCursor&quot;: 3
    },
    {
        &quot;videos&quot;: [
            {
                &quot;id&quot;: 11,
                &quot;title&quot;: &quot;HTML5 기능&quot;,
                &quot;description&quot;: &quot;HTML5 새로운 기능&quot;,
                &quot;uploadDate&quot;: &quot;2024-07-15&quot;
            },
            {
                &quot;id&quot;: 12,
                &quot;title&quot;: &quot;CSS 애니메이션&quot;,
                &quot;description&quot;: &quot;CSS 애니메이션 사용법&quot;,
                &quot;uploadDate&quot;: &quot;2024-07-10&quot;
            },
            {
                &quot;id&quot;: 13,
                &quot;title&quot;: &quot;비동기 처리&quot;,
                &quot;description&quot;: &quot;JavaScript 비동기 처리&quot;,
                &quot;uploadDate&quot;: &quot;2024-07-05&quot;
            },
            {
                &quot;id&quot;: 14,
                &quot;title&quot;: &quot;Context API&quot;,
                &quot;description&quot;: &quot;React 전역 상태 관리&quot;,
                &quot;uploadDate&quot;: &quot;2024-07-01&quot;
            },
            {
                &quot;id&quot;: 15,
                &quot;title&quot;: &quot;React Router&quot;,
                &quot;description&quot;: &quot;React 라우팅&quot;,
                &quot;uploadDate&quot;: &quot;2024-06-25&quot;
            }
        ],
        &quot;nextCursor&quot;: null
    }
]&lt;/code&gt;&lt;/pre&gt;</description>
      <category>React/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/41</guid>
      <comments>https://hy-un.tistory.com/entry/React-Query%EB%A1%9C-%ED%8E%98%EC%9D%B4%EC%A7%95%EB%90%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%A1%9C%EB%93%9C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#entry41comment</comments>
      <pubDate>Sat, 31 Aug 2024 23:21:48 +0900</pubDate>
    </item>
    <item>
      <title>Web API IntersectionObserver를 활용한 스크롤 관리 방법</title>
      <link>https://hy-un.tistory.com/entry/Web-API-IntersectionObserver%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B4%80%EB%A6%AC-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver&lt;/a&gt;&lt;/b&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1725099885947&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;IntersectionObserver - Web API | MDN&quot; data-og-description=&quot;Intersection Observer API의 IntersectionObserver 인터페이스는 대상 요소와 상위 요소, 또는 대상 요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우 이를 비동기적으로 감지할 수 있는 &quot; data-og-host=&quot;developer.mozilla.org&quot; data-og-source-url=&quot;https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver&quot; data-og-url=&quot;https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bw2vKW/hyWV4dxvOZ/9ywGfUCLkJwPQUNtphrlM1/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bw2vKW/hyWV4dxvOZ/9ywGfUCLkJwPQUNtphrlM1/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080');&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;IntersectionObserver - Web API | MDN&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Intersection Observer API의 IntersectionObserver 인터페이스는 대상 요소와 상위 요소, 또는 대상 요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우 이를 비동기적으로 감지할 수 있는&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.mozilla.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. IntersectionObserver란?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IntersectionObserver는 웹 API로, 특정 요소가 뷰포트(화면 영역) 안에 들어오거나 나가는 시점을 감지하고, 이에 따라 콜백 함수를 실행할 수 있는 기능을 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무한 스크롤, 레이지 로딩, 애니메이션 트리거 등 다양한 상황에서 활용할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. IntersectionObserver 사용하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IntersectionObserver를 사용하기 위해서는 먼저 observer 인스턴스를 생성해야 합니다. 이 인스턴스를 통해 특정 요소를 관찰할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 Observer 인스턴스 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IntersectionObserver 인스턴스를 생성할 때, 첫 번째 인자는 콜백 함수이며, 두 번째 인자는 옵션 객체입니다. 옵션 객체는 필수는 아니며, 필요에 따라 전달할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725099536972&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const observer = new IntersectionObserver(callbackFunction, options);&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;b&gt;콜백 함수(callbackFunction):&lt;/b&gt; 요소가 화면에 들어오거나 나갈 때 호출됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;옵션 객체(options):&lt;/b&gt; 관찰에 대한 설정을 정의할 수 있으며, 이는 생략할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 요소 관찰하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;observer 인스턴스를 생성한 후, 특정 요소를 관찰하도록 명령할 수 있습니다. 이를 위해 observe 메소드를 사용합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725099570186&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;observer.observe(element); // element는 관찰할 DOM 요소&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 설정하면, element가 화면에 진입하거나 사라질 때마다 지정한 콜백 함수가 호출됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 콜백 함수와 entries&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콜백 함수는 entries와 observer 두 가지 인자를 받습니다. entries는 관찰 중인 각 요소에 대한 정보를 담은 배열이며, 배열의 각 항목을 entry라고 합니다. entry는 다양한 속성을 통해 관찰 대상 요소의 상태를 설명합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725099601271&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const callbackFunction = (entries, observer) =&amp;gt; {
  entries.forEach(entry =&amp;gt; {
    console.log(entry); // entry의 다양한 속성을 확인 가능
  });
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 entry의 주요 속성들&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;boundingClientRect:&lt;/b&gt; 관찰 대상 요소의 경계 사각형 정보입니다. 요소의 크기와 위치를 나타내는 DOMRect 객체를 반환합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;intersectionRatio:&lt;/b&gt; 관찰 대상 요소가 뷰포트와 얼마나 겹치는지를 비율로 나타냅니다. 0은 겹치지 않음을, 1.0은 완전히 겹침을 의미합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;intersectionRect:&lt;/b&gt; 관찰 대상 요소와 뷰포트가 겹치는 부분의 DOMRect 객체를 반환합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;isIntersecting:&lt;/b&gt; 요소가 뷰포트에 들어오고 있는 중인지, 나가고 있는 중인지를 나타냅니다. 이 값이 true이면 요소가 현재 뷰포트에 들어오고 있는 중이라는 의미이고, false이면 요소가 뷰포트에서 나가고 있는 중이라는 뜻입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;rootBounds:&lt;/b&gt; 뷰포트의 경계를 나타내는 DOMRect 객체를 반환합니다. 관찰자가 특정 요소에 대해 root 옵션을 설정했을 경우, 그 root 요소의 경계를 나타냅니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. threshold와 isIntersecting를 이용한 예제&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 isIntersecting과 threshold를 활용한 간단한 예제입니다. 이 예제는 요소가 화면에 50% 이상 보일 때 텍스트를 변경합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725099653637&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const callbackFunction = (entries, observer) =&amp;gt; {
  entries.forEach(entry =&amp;gt; {
    if (entry.isIntersecting &amp;amp;&amp;amp; entry.intersectionRatio &amp;gt;= 0.5) {
      entry.target.textContent = &quot;화면에 50% 이상 보이고 있습니다!&quot;;
      entry.target.style.backgroundColor = &quot;#000&quot;;
      entry.target.style.color = &quot;#fff&quot;;
    } else {
      entry.target.textContent = &quot;50% 이하만 보이고 있습니다.&quot;;
      entry.target.style.backgroundColor = &quot;#fff&quot;;
      entry.target.style.color = &quot;#000&quot;;
    }
  });
};

const options = {
  threshold: [0.5] // 요소가 50% 이상 보일 때 콜백이 실행됨
};

const observer = new IntersectionObserver(callbackFunction, options);
const targetElement = document.querySelector('#targetElement');

observer.observe(targetElement);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 옵션 객체의 세부 설명&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵션 객체는 IntersectionObserver의 동작 방식을 세밀하게 설정할 수 있습니다.&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;root:&lt;/b&gt; 교차할 뷰포트의 기준 요소를 설정합니다. 기본값은 null이며, 이는 브라우저의 뷰포트가 기준이 됨을 의미합니다. 특정 요소를 기준으로 설정할 수도 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;rootMargin:&lt;/b&gt; 뷰포트의 크기를 조정할 수 있는 여백을 설정합니다. CSS와 유사한 형식으로 작성하며, 예를 들어 rootMargin: '0px 0px -50px 0px'는 뷰포트 아래쪽에 50픽셀의 여유를 둡니다. 이 경우, 요소가 실제 화면에 보이기 50픽셀 전에 콜백이 실행될 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;threshold:&lt;/b&gt; 요소가 얼마나 보일 때 콜백을 실행할지를 설정합니다. threshold: 0은 요소가 1%라도 보이면 실행되며, threshold: 1.0은 요소가 완전히 보여야 실행됩니다. 배열로 여러 개의 값을 설정할 수도 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1725100096317&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const options = {
  threshold: [0, 0.5, 1.0] // 요소가 0%, 50%, 100% 보일 때마다 콜백 실행
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 관찰 중지: unobserve&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;unobserve 메서드는 IntersectionObserver가 특정 요소에 대한 관찰을 중지하도록 하는 기능을 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 한 번 로드된 콘텐츠에 대해서는 추가적인 감지가 필요 없을 때 unobserve를 사용하여 관찰을 중지할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725100437755&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;observer.unobserve(targetElement);&lt;/code&gt;&lt;/pre&gt;</description>
      <category>JavaScript</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/39</guid>
      <comments>https://hy-un.tistory.com/entry/Web-API-IntersectionObserver%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B4%80%EB%A6%AC-%EB%B0%A9%EB%B2%95#entry39comment</comments>
      <pubDate>Sat, 31 Aug 2024 21:28:51 +0900</pubDate>
    </item>
    <item>
      <title>useRef로 DOM 요소 참조 및 관리하기</title>
      <link>https://hy-un.tistory.com/entry/useRef%EB%A1%9C-DOM-%EC%9A%94%EC%86%8C-%EC%B0%B8%EC%A1%B0-%EB%B0%8F-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근에 개인 프로젝트로 유튜브 클론 코딩을 진행하면서 무한 스크롤 기능을 추가하고 싶었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색을 통해 다양한 무한 스크롤을 구현한 코드를 살펴본 결과, 대부분의 코드에서 IntersectionObserver와 useRef를 활용한 방식을 사용하고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에서는 useRef에 대해 작성해보도록 하겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. useRef란?&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useRef는 리액트(React)에서 제공하는 훅으로, 다음과 같은 역할을 합니다&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;DOM 요소 참조:&lt;/b&gt; useRef를 사용하면 특정 DOM 요소를 직접 참조할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;값 유지:&lt;/b&gt; 컴포넌트가 리렌더링되더라도 useRef로 저장한 값은 초기화되지 않고 유지됩니다. 이는 state와 달리 값이 변경되더라도 컴포넌트를 리렌더링하지 않기 때문에 불필요한 렌더링을 방지할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;값 변경 추적:&lt;/b&gt; useRef는 state처럼 변경을 추적하지 않으므로, 값이 즉시 업데이트되더라도 리렌더링이 발생하지 않습니다. 이는 특정 값의 변경을 컴포넌트의 렌더링에 영향을 주지 않고 관리할 때 유용합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. useRef 기본 사용법&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 useRef의 간단한 사용 예시입니다. 이 예제는 버튼을 클릭하면 input 요소에 포커스를 맞추는 동작을 보여줍니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725095835356&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import React, { useRef } from 'react';

function FocusInput() {
  const inputRef = useRef(null);

  const handleFocus = () =&amp;gt; {
    inputRef.current.focus();
  };

  const handleBlur = () =&amp;gt; {
    inputRef.current.blur();
  };

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;input ref={inputRef} type=&quot;text&quot; placeholder=&quot;포커스를 맞추세요&quot; /&amp;gt;
      &amp;lt;button onClick={handleFocus}&amp;gt;포커스 맞추기&amp;lt;/button&amp;gt;
      &amp;lt;button onClick={handleBlur}&amp;gt;포커스 해제&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default FocusInput;&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;b&gt;useRef(null):&lt;/b&gt; useRef를 호출할 때 null을 초기값으로 전달합니다. 이는 해당 참조가 아직 DOM 요소에 연결되지 않았음을 의미합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;inputRef.current:&lt;/b&gt; useRef로 생성된 inputRef 객체의 current 속성은 현재 참조하고 있는 DOM 요소를 나타냅니다. 이 current 속성을 통해 우리는 DOM 요소에 접근할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3.&lt;span&gt;&amp;nbsp;&lt;/span&gt;자식 컴포넌트에서 ref를 전달받아 사용하기 (forwardRef)&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;리액트에서 ref를 자식 컴포넌트에 전달하여 그 컴포넌트 내부의 DOM 요소에 접근하고자 할 때, forwardRef가 필요합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;forwardRef는 부모 컴포넌트로부터 전달된 ref를 자식 컴포넌트의 특정 DOM 요소에 연결할 수 있도록 해줍니다.&lt;/p&gt;
&lt;pre id=&quot;code_1725149661451&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import React, { forwardRef } from 'react';

const Example = forwardRef((props, ref) =&amp;gt; (
  &amp;lt;input ref={ref} type=&quot;text&quot; placeholder=&quot;포커스를 맞추세요&quot; /&amp;gt;
));

export default Example;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 무한 스크롤에 사용될 useRef&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무한 스크롤을 구현할 때, 로딩 지점이 되는 특정 DOM 요소(예: 페이지의 하단)를 useRef로 참조하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런 다음, 이 참조된 요소를 IntersectionObserver와 결합하여, 해당 요소가 화면에 나타날 때마다 새로운 데이터를 불러오는 방식으로 무한 스크롤을 구현할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 useRef는 관찰 대상인 DOM 요소를 정확히 참조하는 데 중요한 역할을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IntersectionObserver는 useRef.current로 참조된 요소가 뷰포트에 들어오거나 나가는 시점을 감지하여, 그에 맞춰 데이터를 로드하는 트리거로 설정할 수 있게 됩니다.&lt;/p&gt;</description>
      <category>React/정리</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/38</guid>
      <comments>https://hy-un.tistory.com/entry/useRef%EB%A1%9C-DOM-%EC%9A%94%EC%86%8C-%EC%B0%B8%EC%A1%B0-%EB%B0%8F-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0#entry38comment</comments>
      <pubDate>Sat, 31 Aug 2024 18:18:03 +0900</pubDate>
    </item>
    <item>
      <title>CSS로 텍스트 줄 수 제한하기 -webkit-line-clamp</title>
      <link>https://hy-un.tistory.com/entry/CSS%EB%A1%9C-%ED%85%8D%EC%8A%A4%ED%8A%B8-%EC%A4%84-%EC%88%98-%EC%A0%9C%ED%95%9C%ED%95%98%EA%B8%B0-webkit-line-clamp</link>
      <description>&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;텍스트를 제한된 줄 수 안에 표시하기 위해 CSS의 -webkit-line-clamp 속성을 사용합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 속성은 주로 WebKit 기반의 브라우저(예: Chrome, Safari)에서 지원되며, 여러 줄에 걸친 텍스트를 클램핑하는 데 사용됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1724880710326&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.title {
    font-size: 16px;
    font-weight: bold;
    color: var(--color-title);
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    text-overflow: ellipsis;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;1. -webkit-line-clamp 속성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-webkit-line-clamp 속성은 텍스트가 몇 줄까지만 표시될지를 지정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드에서는 -webkit-line-clamp: 2;로 설정되어 있어, 텍스트가 최대 두 줄까지만 표시됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 텍스트가 두 줄을 초과하면, 나머지 텍스트는 자동으로 생략되고 끝에 ...이 추가됩니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. -webkit-box-orient 속성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-webkit-box-orient는 텍스트의 방향을 설정하는 속성입니다. vertical로 설정하면 텍스트가 수직 방향으로 표시되며, 여러 줄의 텍스트를 구성할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 속성은 -webkit-line-clamp와 함께 사용되어 텍스트가 지정된 줄 수를 초과하지 않도록 제어합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. overflow: hidden 및 text-overflow: ellipsis 속성&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;overflow: hidden&lt;/b&gt;: 텍스트가 지정된 영역을 넘어서지 않도록 숨깁니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;text-overflow: ellipsis&lt;/b&gt;: 숨겨진 텍스트의 끝에 ...을 표시하여, 텍스트가 잘렸음을 시각적으로 나타냅니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>HTML &amp;amp; CSS</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/36</guid>
      <comments>https://hy-un.tistory.com/entry/CSS%EB%A1%9C-%ED%85%8D%EC%8A%A4%ED%8A%B8-%EC%A4%84-%EC%88%98-%EC%A0%9C%ED%95%9C%ED%95%98%EA%B8%B0-webkit-line-clamp#entry36comment</comments>
      <pubDate>Thu, 29 Aug 2024 07:07:28 +0900</pubDate>
    </item>
    <item>
      <title>CSS로 이미지 비율 유지하기</title>
      <link>https://hy-un.tistory.com/entry/CSS%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%B9%84%EC%9C%A8-%EC%9C%A0%EC%A7%80%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;개인 프로젝트를 진행하는 도중, 이미지가 완전히 로드되기 전에 이미지 영역이 빈 상태로 남아 있어 레이아웃이 잠시 동안 무너지는 문제가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 방지하기 위해, 단순히 &amp;lt;img&amp;gt; 태그에 이미지만 넣는 것이 아니라, 이미지가 로드되기 전에도 일정한 영역을 차지하도록 설정하여 레이아웃을 안정 적으로 유지할 수 있도록 하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1724880350084&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div className={styles['img-container']}&amp;gt;
    &amp;lt;img
        className={styles.img}
        src={thumbnails.medium.url}
        alt={title}
    /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1724880361209&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.img-container {
    width: 100%;
    padding-top: 56.25%; /* 16:9 비율을 유지 */
    position: relative;
    background-color: #f0f0f0;
    overflow: hidden;
}

.img {
    width: 100%;
    height: 100%;
    position: absolute;
    top: 0;
    left: 0;
    object-fit: cover;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;img-container 클래스는 이미지가 로드되기 전에 영역을 확보하기 위해 설정되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;padding-top: 56.25%는 16:9 비율을 유지하면서 컨테이너의 높이를 설정해 줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;background-color 속성으로 배경색을 설정해, 이미지가 로드되기 전에도 일정한 색상으로 영역을 표시합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;img 클래스는 이미지를 컨테이너에 맞추어 렌더링하기 위한 설정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;position: absolute와 object-fit: cover를 사용해 이미지가 컨테이너를 가득 채우도록 했습니다.&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;비율을 계산할 때 16:9 비율을 적용하고 싶다면, 9를 16으로 나눈 값을 너비에 곱하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 너비가 50%일 때 9:16 비율을 적용하려면 9 / 16 * 50을 계산하여 28.125가 됩니다. 다른 비율도 동일한 방식으로 계산할 수 있습니다.&lt;/p&gt;</description>
      <category>HTML &amp;amp; CSS</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/35</guid>
      <comments>https://hy-un.tistory.com/entry/CSS%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%B9%84%EC%9C%A8-%EC%9C%A0%EC%A7%80%ED%95%98%EA%B8%B0#entry35comment</comments>
      <pubDate>Thu, 29 Aug 2024 06:37:16 +0900</pubDate>
    </item>
    <item>
      <title>YouTube Data API를 이용한 Youtube Clone - 3</title>
      <link>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-3</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에서는 React Query와 Axios를 활용해 유튜브 API로부터 데이터를 받아와 비디오 리스트와 비디오 카드를 구현하는 과정을 작성하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-08-29 오전 5.56.45.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1666&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0aYzh/btsJj9Zutx1/ZUXCMQCYKZZbLuYkaWZFyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0aYzh/btsJj9Zutx1/ZUXCMQCYKZZbLuYkaWZFyk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0aYzh/btsJj9Zutx1/ZUXCMQCYKZZbLuYkaWZFyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0aYzh%2FbtsJj9Zutx1%2FZUXCMQCYKZZbLuYkaWZFyk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/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;425&quot; data-filename=&quot;스크린샷 2024-08-29 오전 5.56.45.png&quot; data-origin-width=&quot;2940&quot; data-origin-height=&quot;1666&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. React Query 설정 및 클라이언트 구성&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Query를 사용하기 위해서 QueryClient를 생성하고, QueryClientProvider로 애플리케이션을 감싸서 클라이언트를 전역에서 사용할 수 있도록 설정하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1724858306261&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Outlet } from &quot;react-router-dom&quot;;
import SearchHeader from &quot;./components/SearchHeader/SearchHeader&quot;;
import { DarkModeProvider } from &quot;./context/DarkModeContext&quot;;
import { QueryClient, QueryClientProvider } from &quot;@tanstack/react-query&quot;;

const queryClient = new QueryClient();

function App() {
    return (
        &amp;lt;DarkModeProvider&amp;gt;
            &amp;lt;SearchHeader /&amp;gt;
            &amp;lt;QueryClientProvider client={queryClient}&amp;gt;
                &amp;lt;Outlet /&amp;gt;
            &amp;lt;/QueryClientProvider&amp;gt;
        &amp;lt;/DarkModeProvider&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. Youtube API 요청 모듈화&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;YouTube API와 상호작용하기 위해 Axios를 사용하여 API 요청을 처리하는 모듈을 작성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 요청 관련 로직을 하나의 객체로 모듈화함으로써, 코드의 재사용성과 관리 용이성을 높였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1724858346464&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import axios from &quot;axios&quot;;

const YoutubeApi = {
    httpClient: axios.create({
        baseURL: &quot;https://www.googleapis.com/youtube/v3&quot;,
        params: { key: process.env.REACT_APP_YOUTUBE_API_KEY },
    }),

    search(keyword: string | undefined) {
        return keyword ? this.getVideosByKeyword(keyword) : this.getPopularVideos();
    },

    async getVideosByKeyword(keyword: string) {
        const response = await this.httpClient.get(&quot;search&quot;, {
            params: {
                part: &quot;snippet&quot;,
                maxResults: 25,
                q: keyword,
            },
        });

        const videoIds = response.data.items.map((item: any) =&amp;gt; item.id.videoId).join(',');
        const videosResponse = await this.httpClient.get(&quot;videos&quot;, {
            params: {
                part: &quot;snippet,statistics&quot;,
                id: videoIds,
            },
        });

        return videosResponse.data.items.map((item: any) =&amp;gt; ({
            ...item,
            id: item.id,
        }));
    },

    async getPopularVideos() {
        const response = await this.httpClient.get(&quot;videos&quot;, {
            params: {
                part: &quot;snippet, statistics&quot;,
                maxResults: 25,
                chart: &quot;mostPopular&quot;,
                regionCode: &quot;KR&quot;,
            },
        });
        return response.data.items;
    },
};

export default YoutubeApi;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;YouTube API에서 조회수, 좋아요 수, 댓글 수 등의 통계를 가져오기 위해서는 statistics 정보를 요청해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;videos 엔드포인트에서는 part에 statistics를 추가하기만 하면 해당 정보를 얻을 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 search 엔드포인트에서는 동일한 방식으로 part에 statistics를 추가하면 Bad Request가 발생하여서, 두 단계에 걸쳐 API를 호출하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, search 엔드포인트를 사용해 키워드로 비디오를 검색하면, 비디오의 snippet 정보(제목, 설명, 썸네일 등)만 반환됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 비디오의 통계 정보를 가져오기 위해서 각 비디오의 ID를 추출한 후, 그 ID들을 사용해 videos 엔드포인트에서 추가로 요청을 하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1724877629535&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const videoIds = response.data.items.map((item: any) =&amp;gt; item.id.videoId).join(',');&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에서 videoIds를 추출한 이유는 videos 엔드포인트에 여러 비디오 ID를 한 번에 전달하기 위해서입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;YouTube API는 비디오 ID를 쉼표로 구분된 문자열로 전달할 수 있으며, 이 방식으로 여러 비디오의 정보를 한 번에 요청할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 비디오 리스트 및 비디오 카드 컴포넌트 구현&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Query를 활용해 유튜브 API로부터 데이터를 받아와 비디오 리스트와 비디오 카드 컴포넌트를 구현하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1724858401616&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useParams } from &quot;react-router-dom&quot;;
import styles from &quot;./Videos.module.css&quot;;
import { useQuery } from &quot;@tanstack/react-query&quot;;
import VideoCard from &quot;../../components/VideoCard/VideoCard&quot;;
import { Video } from &quot;../../../public/types&quot;;
import YoutubeApi from &quot;../../api/youtubeApi&quot;;

export default function Videos() {
    const { keyword } = useParams();
    const {
        isLoading,
        error,
        data: videos,
    } = useQuery&amp;lt;Video[]&amp;gt;({
        queryKey: [&quot;videos&quot;, keyword],
        queryFn: () =&amp;gt; YoutubeApi.search(keyword),
        staleTime: 60000,
        gcTime: 1000 * 60 * 10,
        refetchOnWindowFocus: false,
        refetchOnMount: false,
        refetchOnReconnect: false,
    });

    return (
        &amp;lt;&amp;gt;
            {videos &amp;amp;&amp;amp; (
                &amp;lt;ul className={styles.grid}&amp;gt;
                    {videos.map((video: Video) =&amp;gt; (
                        &amp;lt;VideoCard key={video.id} video={video} /&amp;gt;
                    ))}
                &amp;lt;/ul&amp;gt;
            )}
        &amp;lt;/&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1724877374657&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import React from &quot;react&quot;;
import { Video } from &quot;../../../public/types&quot;;
import styles from &quot;./VideoCard.module.css&quot;;
import { formatDateTime, formatViewCount } from &quot;../../util&quot;;

export default function VideoCard({ video }: { video: Video }) {
    const { title, channelTitle, thumbnails, publishedAt } = video.snippet;
    const viewCount = parseInt(video.statistics?.viewCount || &quot;0&quot;, 10);

    return (
        &amp;lt;li className={styles.card}&amp;gt;
            &amp;lt;div className={styles['img-container']}&amp;gt;
                &amp;lt;img
                    className={styles.img}
                    src={thumbnails.medium.url}
                    alt={title}
                /&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;h3 className={styles.title}&amp;gt;{title}&amp;lt;/h3&amp;gt;
            &amp;lt;p className={styles.text}&amp;gt;{channelTitle}&amp;lt;/p&amp;gt;
            &amp;lt;div className={styles.flex}&amp;gt;
                &amp;lt;p className={styles.text}&amp;gt;
                    조회수 {formatViewCount(viewCount)}
                &amp;lt;/p&amp;gt;
                &amp;lt;p className={styles.text}&amp;gt;{formatDateTime(publishedAt)}&amp;lt;/p&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/li&amp;gt;
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4.&amp;nbsp; 유틸리티 함수 구현&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1724877402408&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export function formatViewCount(viewCount: number): string {
    if (viewCount &amp;gt;= 100000000) {
        return `${(viewCount / 100000000).toFixed(1)}억회`;
    } else if (viewCount &amp;gt;= 10000) {
        return `${(viewCount / 10000).toFixed(0)}만회`;
    } else if (viewCount &amp;gt;= 1000) {
        return `${(viewCount / 1000).toFixed(1)}천회`;
    } else {
        return `${viewCount}회`;
    }
}

export function formatDateTime(dateString: string): string {
    const now = new Date();
    const targetDate = new Date(dateString);
    const diffInSeconds = Math.floor((now.getTime() - targetDate.getTime()) / 1000);

    const secondsInMinute = 60;
    const secondsInHour = 60 * secondsInMinute;
    const secondsInDay = 24 * secondsInHour;
    const secondsInWeek = 7 * secondsInDay;
    const secondsInMonth = 30 * secondsInDay;
    const secondsInYear = 365 * secondsInDay;

    if (diffInSeconds &amp;lt; secondsInHour) {
        const minutes = Math.floor(diffInSeconds / secondsInMinute);
        return `${minutes}분 전`;
    } else if (diffInSeconds &amp;lt; secondsInDay) {
        const hours = Math.floor(diffInSeconds / secondsInHour);
        return `${hours}시간 전`;
    } else if (diffInSeconds &amp;lt; secondsInWeek) {
        const days = Math.floor(diffInSeconds / secondsInDay);
        return `${days}일 전`;
    } else if (diffInSeconds &amp;lt; 14 * secondsInDay) {
        const weeks = Math.floor(diffInSeconds / secondsInWeek);
        return `${weeks}주 전`;
    } else if (diffInSeconds &amp;lt; secondsInMonth) {
        const days = Math.floor(diffInSeconds / secondsInDay);
        return `${days}일 전`;
    } else if (diffInSeconds &amp;lt; secondsInYear) {
        const months = Math.floor(diffInSeconds / secondsInMonth);
        return `${months}개월 전`;
    } else {
        const years = Math.floor(diffInSeconds / secondsInYear);
        return `${years}년 전`;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;- formatViewCount 함수&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1724878233171&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export function formatViewCount(viewCount: number): string {
    if (viewCount &amp;gt;= 100000000) {
        return `${(viewCount / 100000000).toFixed(1)}억회`;
    } else if (viewCount &amp;gt;= 10000) {
        return `${(viewCount / 10000).toFixed(0)}만회`;
    } else if (viewCount &amp;gt;= 1000) {
        return `${(viewCount / 1000).toFixed(1)}천회`;
    } else {
        return `${viewCount}회`;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수는 조회수를 한국어로 원하는 문자열 형태로 변환하는 유틸리티 함수입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;- formatDateTime 함수&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1724878317005&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export function formatDateTime(dateString: string): string {
    const now = new Date();
    const targetDate = new Date(dateString);
    const diffInSeconds = Math.floor((now.getTime() - targetDate.getTime()) / 1000);

    const secondsInMinute = 60;
    const secondsInHour = 60 * secondsInMinute;
    const secondsInDay = 24 * secondsInHour;
    const secondsInWeek = 7 * secondsInDay;
    const secondsInMonth = 30 * secondsInDay;
    const secondsInYear = 365 * secondsInDay;

    if (diffInSeconds &amp;lt; secondsInHour) {
        const minutes = Math.floor(diffInSeconds / secondsInMinute);
        return `${minutes}분 전`;
    } else if (diffInSeconds &amp;lt; secondsInDay) {
        const hours = Math.floor(diffInSeconds / secondsInHour);
        return `${hours}시간 전`;
    } else if (diffInSeconds &amp;lt; secondsInWeek) {
        const days = Math.floor(diffInSeconds / secondsInDay);
        return `${days}일 전`;
    } else if (diffInSeconds &amp;lt; 14 * secondsInDay) {
        const weeks = Math.floor(diffInSeconds / secondsInWeek);
        return `${weeks}주 전`;
    } else if (diffInSeconds &amp;lt; secondsInMonth) {
        const days = Math.floor(diffInSeconds / secondsInDay);
        return `${days}일 전`;
    } else if (diffInSeconds &amp;lt; secondsInYear) {
        const months = Math.floor(diffInSeconds / secondsInMonth);
        return `${months}개월 전`;
    } else {
        const years = Math.floor(diffInSeconds / secondsInYear);
        return `${years}년 전`;
    }
}&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;diffInSeconds는 now와 targetDate 사이의 시간 차이를 밀리초로 계산한 후, 초 단위로 변환한 값입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 시간 단위를 초 단위로 변환하여 변수로 정의합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;secondsInMinute, secondsInHour, secondsInDay, secondsInWeek, secondsInMonth, secondsInYear는 각각 1분, 1시간, 1일, 1주, 1개월, 1년을 초 단위로 표현한 것입니다.&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;b&gt;5. 타입 정의&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API로부터 받아온 데이터의 타입을 정의했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 필요할 것 같은 데이터에만 타입을 지정했으며, 추후에 필요에 따라 타입을 추가하거나 불필요한 타입은 줄일 계획입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1724858418394&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface VideoSnippet {
    publishedAt: string;
    title: string;
    description: string;
    thumbnails: {
        default: {
            url: string;
            width: number;
            height: number;
        };
        medium: {
            url: string;
            width: number;
            height: number;
        };
        high: {
            url: string;
            width: number;
            height: number;
        };
    };
    channelTitle: string;
}

interface VideoStatistics {
    viewCount: string;
    likeCount: string;
    favoriteCount: string;
    commentCount: string;
}

export interface Video {
    id: string;
    snippet: VideoSnippet;
    statistics: VideoStatistics;
}&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;</description>
      <category>React/Youtube</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/34</guid>
      <comments>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-3#entry34comment</comments>
      <pubDate>Thu, 29 Aug 2024 06:13:50 +0900</pubDate>
    </item>
    <item>
      <title>YouTube Data API를 이용한 Youtube Clone - 2</title>
      <link>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-2</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에서는 searchHeader에서 음성을 통한 검색 기능을 추가한 내용을 작성하겠습니다.&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-filename=&quot;edited_스크린샷 2024-08-28 오후 4.06.05.png&quot; data-origin-width=&quot;1011&quot; data-origin-height=&quot;376&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lHcEt/btsJi3FvTLE/tOtiUMVgKBHr7tEb7OjdXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lHcEt/btsJi3FvTLE/tOtiUMVgKBHr7tEb7OjdXK/img.png&quot; data-alt=&quot;음성 인식 모달&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lHcEt/btsJi3FvTLE/tOtiUMVgKBHr7tEb7OjdXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlHcEt%2FbtsJi3FvTLE%2FtOtiUMVgKBHr7tEb7OjdXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;235&quot; data-filename=&quot;edited_스크린샷 2024-08-28 오후 4.06.05.png&quot; data-origin-width=&quot;1011&quot; data-origin-height=&quot;376&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;edited_스크린샷 2024-08-28 오후 4.06.14.png&quot; data-origin-width=&quot;1016&quot; data-origin-height=&quot;414&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpdcKU/btsJirmws5h/Iw7dVTRDtHn3yIcwUco0U0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpdcKU/btsJirmws5h/Iw7dVTRDtHn3yIcwUco0U0/img.png&quot; data-alt=&quot;음성 인식 실패 시 모달 내용 변경&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpdcKU/btsJirmws5h/Iw7dVTRDtHn3yIcwUco0U0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpdcKU%2FbtsJirmws5h%2FIw7dVTRDtHn3yIcwUco0U0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;261&quot; data-filename=&quot;edited_스크린샷 2024-08-28 오후 4.06.14.png&quot; data-origin-width=&quot;1016&quot; data-origin-height=&quot;414&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;음성 인식 실패 시 모달 내용 변경&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. SearchHeader의 음성 명령 처리 함수&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;searchHeader 컴포넌트에서는 음성 명령을 처리하는 handleVoiceCommand 함수를 추가했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수는 사용자가 음성으로 입력한 명령을 받아 검색 결과 페이지로 이동시킵니다.&lt;/p&gt;
&lt;pre id=&quot;code_1724827625754&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleVoiceCommand = (command: string) =&amp;gt; {
    setText(command);
    navigate(`/videos/${command}`);
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. Modal 컴포넌트에서 음성 인식 처리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Modal 컴포넌트는 사용자가 음성 인식 버튼을 눌렀을 때 뜨는 모달 창입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모달이 열리면 음성 인식이 시작되고, 사용자가 말을 멈추면 인식된 명령어를 handleVoiceCommand 함수에 전달합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1724827664419&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
    if (isOpen) {
        startListening();
    } else {
        stopListening();
    }

    return () =&amp;gt; {
        stopListening();
    };
}, [isOpen]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useEffect를 통해 모달이 열리면 음성 인식을 시작하고 모달이 닫히면 음성 인식을 중지하게 했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. startListening&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1724827680145&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const startListening = () =&amp;gt; {
    const SpeechRecognition =
        (window as any).SpeechRecognition ||
        (window as any).webkitSpeechRecognition;
    if (SpeechRecognition) {
        const recognition = new SpeechRecognition();
        recognition.lang = &quot;ko-KR&quot;;
        recognition.interimResults = false;
        recognition.maxAlternatives = 1;

        recognition.onstart = () =&amp;gt; setListening(true);
        recognition.onend = () =&amp;gt; setListening(false);
        recognition.onerror = (event: any) =&amp;gt; {
            console.error(&quot;Speech recognition error&quot;, event.error);
        };
        recognition.onresult = (event: any) =&amp;gt; {
            const command = event.results[0][0].transcript;
            onVoiceCommand(command);
            onClose();
        };

        recognition.start();
    } else {
        console.warn(&quot;이 브라우저에서는 음성 인식이 지원되지 않습니다.&quot;);
    }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SpeechRecognition 객체&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;window as any를 사용하여 SpeechRecognition API를 가져옵니다. &lt;br /&gt;여기서 any 타입을 사용한 이유는, 해당 API의 타입을 지정하려 하니 TypeScript에서 오류가 발생했기 때문입니다.&lt;br /&gt;(Window &amp;amp; typeof globalThis 형식에 'SpeechRecognition' 속성이 없습니다) 따라서 우선 any로 처리해뒀습니다.&lt;/li&gt;
&lt;li&gt;recognition.lang = &quot;ko-KR&quot;: 음성 인식 언어를 한국어로 설정합니다.&lt;/li&gt;
&lt;li&gt;recognition.onresult: 음성 인식 결과를 처리하여 command에 저장하고, handleVoiceCommand 함수를 호출합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1724827993287&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;SpeechRecognition - Web APIs | MDN&quot; data-og-description=&quot;The SpeechRecognition interface of the Web Speech API is the controller interface for the recognition service; this also handles the SpeechRecognitionEvent sent from the recognition service.&quot; data-og-host=&quot;developer.mozilla.org&quot; data-og-source-url=&quot;https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition&quot; data-og-url=&quot;https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b7Z8cz/hyWV62B44z/ey4dBwNWPtttl9wY53fBy1/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b7Z8cz/hyWV62B44z/ey4dBwNWPtttl9wY53fBy1/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080');&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;SpeechRecognition - Web APIs | MDN&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The SpeechRecognition interface of the Web Speech API is the controller interface for the recognition service; this also handles the SpeechRecognitionEvent sent from the recognition service.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.mozilla.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. &lt;b&gt;stopListening&lt;/b&gt;&lt;/h2&gt;
&lt;pre id=&quot;code_1724827709875&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const stopListening = () =&amp;gt; {
    const SpeechRecognition =
        (window as any).SpeechRecognition ||
        (window as any).webkitSpeechRecognition;
    if (SpeechRecognition) {
        const recognition = new SpeechRecognition();
        recognition.stop();
    }
};&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;b&gt;stopListening&lt;/b&gt;: 현재 진행 중인 음성 인식을 중단합니다. 이 함수는 모달이 닫히거나 음성 인식을 중단해야 할 때 호출됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React/Youtube</category>
      <author>Hyun Dev</author>
      <guid isPermaLink="true">https://hy-un.tistory.com/33</guid>
      <comments>https://hy-un.tistory.com/entry/YouTube-Data-API%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Youtube-Clone-2#entry33comment</comments>
      <pubDate>Wed, 28 Aug 2024 15:55:39 +0900</pubDate>
    </item>
  </channel>
</rss>