<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>돌미나리</title>
    <link>https://minari98.tistory.com/</link>
    <description>개발로그쓰는 개발자</description>
    <language>ko</language>
    <pubDate>Fri, 4 Sep 2026 00:01:25 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>돌미나리는야생미나리</managingEditor>
    <image>
      <title>돌미나리</title>
      <url>https://tistory1.daumcdn.net/tistory/8632459/attach/0e5e19bf4dfa44a2b59b5a2d5b8ae1df</url>
      <link>https://minari98.tistory.com</link>
    </image>
    <item>
      <title>[Next.js] &amp;quot;use client&amp;quot;만 남발하다가 성능이 망가졌다? RSC와 RCC 명확한 구분법</title>
      <link>https://minari98.tistory.com/entry/Nextjs-use-client%EB%A7%8C-%EB%82%A8%EB%B0%9C%ED%95%98%EB%8B%A4%EA%B0%80-%EC%84%B1%EB%8A%A5%EC%9D%B4-%EB%A7%9D%EA%B0%80%EC%A1%8C%EB%8B%A4-RSC%EC%99%80-RCC-%EB%AA%85%ED%99%95%ED%95%9C-%EA%B5%AC%EB%B6%84%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 13 버전 이상(App Router)을 도입해 개발을 시작하면 가장 먼저 마주치는 거대한 벽이 있습니다. 바로 컴포넌트 파일 맨 상단에 습관처럼 붙이게 되는 &lt;b data-index-in-node=&quot;96&quot; data-path-to-node=&quot;4&quot;&gt;'use client';&lt;/b&gt; 선언입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;저 역시 초기에는 컴포넌트마다 에러(예: &lt;i data-index-in-node=&quot;23&quot; data-path-to-node=&quot;5&quot;&gt;useState can not be used in Server Component&lt;/i&gt;)가 뜰 때마다 귀찮다는 이유로 파일마다 상단에 무지성으로 'use client';를 붙여가며 개발했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;하지만 나중에 프로젝트 번들 사이즈(Bundle Size)를 측정해 보니, 서버에서 가볍게 처리되어야 할 정적 UI들까지 전부 클라이언트(브라우저)로 자바스크립트 코드가 내려가면서 초기 로딩 속도가 뚝 떨어지는 심각한 부작용을 마주쳤습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 &lt;b data-index-in-node=&quot;4&quot; data-path-to-node=&quot;7&quot;&gt;서버 컴포넌트(RSC)와 클라이언트 컴포넌트(RCC)의 근본적인 차이점과, 실무에서 어떤 기준으로 분리해야 하는지&lt;/b&gt; 명확한 가이드를 공유해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. 내가 마주친 문제: &quot;왜 모든 컴포넌트에 'use client'를 붙이면 안 될까?&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;Next.js App Router의 기본 원칙은 모든 컴포넌트가 기본적으로 서버 컴포넌트(RSC)라는 점입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;11&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,0,0&quot;&gt;서버 컴포넌트 (RSC):&lt;/b&gt; HTML 렌더링과 데이터베이스(DB) 조회를 서버에서 끝내고, 가벼운 HTML 결과물만 브라우저로 보냅니다. (자바스크립트 번들 사이즈 0KB!)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,1,0&quot;&gt;클라이언트 컴포넌트 (RCC):&lt;/b&gt; 'use client';를 선언하는 순간, 해당 컴포넌트와 그 하위에 딸린 모든 자식 코드가 브라우저로 다운로드되어 실행됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;만약 페이지 전체를 감싸는 최상단 부모나 단순 마크업 컴포넌트에 무심코 클라이언트 선언을 해버리면, 서버의 강력한 성능 이점을 전혀 살리지 못하고 브라우저 과부하를 유발하게 됩니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size26&quot;&gt;2. 언제 '서버 컴포넌트(RSC)'를 써야 할까? (기본값)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;대부분의 일반적인 컴포넌트는 서버 컴포넌트로 두는 것이 가장 이상적입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;16&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16,0,0&quot;&gt;가장 적합한 곳:&lt;/b&gt; 백엔드 API나 DB에서 데이터를 직접 가져와야 하는 경우, 민감한 시크릿 키(API Key)를 다뤄야 하는 경우, 무거운 서드파티 라이브러리를 서버에서 미리 렌더링하고 싶을 때.&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiJg4zav7iWAxUAAAAAHQAAAAAQuAU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;// ⭕ 서버 컴포넌트 예시 (async/await로 서버에서 직접 데이터 페칭 가능)
// 'use client' 선언이 없음 = 기본 서버 컴포넌트
export default async function ProductDetailPage({ params }: { params: { id: string } }) {
  // 서버 내부에서 직접 안전하게 DB 또는 API 호출
  const res = await fetch(`https://api.example.com/products/${params.id}`);
  const product = await res.json();

  return (
    &amp;lt;div className=&quot;p-6&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-xl font-bold&quot;&amp;gt;{product.title}&amp;lt;/h1&amp;gt;
      &amp;lt;p className=&quot;text-slate-400&quot;&amp;gt;{product.description}&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;클라이언트 브라우저가 자바스크립트를 다운로드하고 실행할 필요 없이, 서버가 이미 완성된 HTML을 보내주므로 SEO 최적화와 초기 로딩 속도(FCP)가 극적으로 빨라집니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-09-03 오후 5.49.23.png&quot; data-origin-width=&quot;2324&quot; data-origin-height=&quot;1324&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/F1cg0/dJMcahMswJQ/Gb6tQGOF7vzok5KqC3KuPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/F1cg0/dJMcahMswJQ/Gb6tQGOF7vzok5KqC3KuPK/img.png&quot; data-alt=&quot;'use client' 선언이 없고 async/await로 데이터를 바로 가져오는 서버 컴포넌트 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/F1cg0/dJMcahMswJQ/Gb6tQGOF7vzok5KqC3KuPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FF1cg0%2FdJMcahMswJQ%2FGb6tQGOF7vzok5KqC3KuPK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2324&quot; height=&quot;1324&quot; data-filename=&quot;스크린샷 2026-09-03 오후 5.49.23.png&quot; data-origin-width=&quot;2324&quot; data-origin-height=&quot;1324&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;'use client' 선언이 없고 async/await로 데이터를 바로 가져오는 서버 컴포넌트 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;3. 언제 '클라이언트 컴포넌트(RCC)'를 써야 할까?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;브라우저의 동적 기능이나 사용자 인터랙션이 반드시 필요한 순간에만 제한적으로 'use client';를 붙여야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;23&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,0,0&quot;&gt;가장 적합한 곳:&lt;/b&gt; useState, useEffect, useContext 등의 훅을 써야 할 때, onClick, onChange 같은 브라우저 이벤트 리스너가 필요할 때, 브라우저 전용 API(localStorage, window)를 쓸 때.&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiJg4zav7iWAxUAAAAAHQAAAAAQuQU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;'use client'; //   브라우저 상호작용이 필요할 때만 명시!

import { useState } from 'react';

export default function CounterButton() {
  const [count, setCount] = useState&amp;lt;number&amp;gt;(0);

  return (
    &amp;lt;button
      onClick={() =&amp;gt; setCount((prev) =&amp;gt; prev + 1)}
      className=&quot;px-4 py-2 bg-indigo-600 text-white rounded-xl&quot;
    &amp;gt;
      클릭 횟수: {count}
    &amp;lt;/button&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;4. 실무 아키텍처 패턴: &quot;서버 컴포넌트 안에 클라이언트 컴포넌트 배치하기&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;실무에서 가장 권장되는 패턴은 &lt;b data-index-in-node=&quot;17&quot; data-path-to-node=&quot;27&quot;&gt;큰 틀(뼈대와 데이터 페칭)은 서버 컴포넌트로 만들고, 클릭 이벤트가 발생하는 아주 작은 버튼 조각만 클라이언트 컴포넌트로 분리&lt;/b&gt;하는 것입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiJg4zav7iWAxUAAAAAHQAAAAAQugU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 1. 부모 페이지 (서버 컴포넌트 - 데이터 로딩 담당)
import CounterButton from './CounterButton';

export default async function Page() {
  const data = await fetch('...').then((res) =&amp;gt; res.json());

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;{data.title}&amp;lt;/h1&amp;gt;
      {/* 2. 인터랙션이 필요한 작은 조각만 클라이언트 컴포넌트로 주입 */}
      &amp;lt;CounterButton initialCount={data.count} /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 전체 페이지의 90%는 서버에서 가볍게 렌더링되고, 오직 버튼 컴포넌트만 클라이언트에서 작동하여 번들 크기를 최소화할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-09-03 오후 6.07.32.png&quot; data-origin-width=&quot;842&quot; data-origin-height=&quot;514&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhIbpE/dJMcagUodFg/80V0h0T12KvRW4LIa9X2m0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhIbpE/dJMcagUodFg/80V0h0T12KvRW4LIa9X2m0/img.png&quot; data-alt=&quot;서버 데이터와 클라이언트 인터랙션 버튼이 함께 동작&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhIbpE/dJMcagUodFg/80V0h0T12KvRW4LIa9X2m0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhIbpE%2FdJMcagUodFg%2F80V0h0T12KvRW4LIa9X2m0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;842&quot; height=&quot;514&quot; data-filename=&quot;스크린샷 2026-09-03 오후 6.07.32.png&quot; data-origin-width=&quot;842&quot; data-origin-height=&quot;514&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;서버 데이터와 클라이언트 인터랙션 버튼이 함께 동작&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;32&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론 및 느낀 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;Next.js App Router를 다룰 때 'use client';는 마법의 열쇠가 아니라 성능을 깎아먹을 수 있는 양날의 검입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;34&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;34,0,0&quot;&gt;데이터 조회와 정적 마크업은 기본 서버 컴포넌트(RSC)로 유지할 것&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;34,1,0&quot;&gt;상태(State)나 이벤트(Event)가 필요한 최소한의 하위 조각에만 클라이언트 컴포넌트(RCC)를 적용할 것&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;이 원칙을 지키는 것만으로도 웹 사이트의 번들 사이즈를 대폭 줄이고 구글 성능 점수를 크게 끌어올릴 수 있습니다!&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>approuter</category>
      <category>nextjs</category>
      <category>RCC</category>
      <category>React</category>
      <category>RSC</category>
      <category>개발자일기</category>
      <category>서버컴포넌트</category>
      <category>웹개발</category>
      <category>클라이언트컴포넌트</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/57</guid>
      <comments>https://minari98.tistory.com/entry/Nextjs-use-client%EB%A7%8C-%EB%82%A8%EB%B0%9C%ED%95%98%EB%8B%A4%EA%B0%80-%EC%84%B1%EB%8A%A5%EC%9D%B4-%EB%A7%9D%EA%B0%80%EC%A1%8C%EB%8B%A4-RSC%EC%99%80-RCC-%EB%AA%85%ED%99%95%ED%95%9C-%EA%B5%AC%EB%B6%84%EB%B2%95#entry57comment</comments>
      <pubDate>Thu, 3 Sep 2026 18:15:48 +0900</pubDate>
    </item>
    <item>
      <title>[CSS] 이미지가 로딩되면서 버튼 위치가 쿵 내려앉는다? CLS(누적 레이아웃 이동) 해결기</title>
      <link>https://minari98.tistory.com/entry/CSS-%EC%9D%B4%EB%AF%B8%EC%A7%80%EA%B0%80-%EB%A1%9C%EB%94%A9%EB%90%98%EB%A9%B4%EC%84%9C-%EB%B2%84%ED%8A%BC-%EC%9C%84%EC%B9%98%EA%B0%80-%EC%BF%B5-%EB%82%B4%EB%A0%A4%EC%95%89%EB%8A%94%EB%8B%A4-CLS%EB%88%84%EC%A0%81-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EC%9D%B4%EB%8F%99-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹 서핑을 하다가 기사나 상품 페이지를 읽는 도중, 상단 이미지가 뒤늦게 다운로드되면서 &lt;b data-index-in-node=&quot;49&quot; data-path-to-node=&quot;5&quot;&gt;화면 전체가 덜컹 내려앉아 엉뚱한 버튼을 잘못 클릭해 본 경험&lt;/b&gt;이 다들 한 번쯤 있으실 겁니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;저 역시 초기 웹 프로젝트를 배포한 뒤 모바일 환경에서 테스트를 하다가 동일한 문제를 겪었습니다. 텍스트가 먼저 렌더링 된 후 용량이 큰 배너 이미지가 0.5초 뒤에 늦게 로딩되면서, 사용자가 클릭하려던 '장바구니 담기' 버튼이 아래로 쑥 밀려 내려가 '구매 취소' 버튼을 누르게 만드는 치명적인 UI 버그가 발생한 것입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;구글 라이트하우스(Lighthouse) 성능 점수를 측정해 보니 &lt;b data-index-in-node=&quot;36&quot; data-path-to-node=&quot;7&quot;&gt;CLS(Cumulative Layout Shift, 누적 레이아웃 이동)&lt;/b&gt; 점수가 빨간색 경고(0.35 이상)를 기록하고 있었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 화면 깜빡임과 덜컹거림을 완벽히 잡고 부드러운 사용자 경험을 만들어 준 &lt;b data-index-in-node=&quot;44&quot; data-path-to-node=&quot;8&quot;&gt;CLS 개선 노하우와 최신 CSS aspect-ratio 및 스켈레톤 UI 적용법&lt;/b&gt;을 공유해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size26&quot;&gt;1. 내가 마주친 문제: &quot;왜 화면이 덜컹거리며 밀려날까?&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;브라우저가 HTML을 파싱할 때, 이미지 태그에 가로/세로 크기 정보가 미리 지정되어 있지 않으면 브라우저는 해당 이미지의 크기를 0 x 0 픽셀로 간주하고 아래 요소들을 먼저 위로 바짝 끌어올려 배치합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiJg4zav7iWAxUAAAAAHQAAAAAQyAE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;HTML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- ❌ 브라우저가 이미지 크기를 미리 알 수 없어 CLS를 유발하는 위험한 태그 --&amp;gt;
&amp;lt;img src=&quot;https://example.com/banner.jpg&quot; alt=&quot;이벤트 배너&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;그 후 네트워크를 통해 실제 이미지 파일이 다운로드되면, 브라우저는 그제야 이미지의 진짜 높이(예: 400px)를 인식하고 &lt;b data-index-in-node=&quot;69&quot; data-path-to-node=&quot;13&quot;&gt;아래에 있던 콘텐츠들을 강제로 밀어내는 재배치(Reflow / Relayout) 작업&lt;/b&gt;을 수행합니다. 이것이 바로 레이아웃 덜컹거림(Layout Shift)의 원인입니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;2. 해결 방법 1: width와 height 속성으로 종횡비(Aspect Ratio) 미리 알려주기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;가장 기본적이면서 강력한 해결법은 HTML &amp;lt;img&amp;gt; 태그에 &lt;b data-index-in-node=&quot;34&quot; data-path-to-node=&quot;16&quot;&gt;반드시 원본 이미지의 width와 height 속성을 명시&lt;/b&gt;해 주는 것입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiJg4zav7iWAxUAAAAAHQAAAAAQyQE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;HTML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- ⭕ 브라우저가 이미지 비율을 미리 계산하여 공간을 확보함 --&amp;gt;
&amp;lt;img 
  src=&quot;https://example.com/banner.jpg&quot; 
  alt=&quot;이벤트 배너&quot; 
  width=&quot;800&quot; 
  height=&quot;450&quot; 
  style=&quot;width: 100%; height: auto;&quot; 
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;최신 브라우저는 태그에 적힌 width와 height 값을 기반으로 이미지가 로딩되기 전부터 종횡비(Aspect Ratio: 16/9)를 미리 계산해 정확한 높이의 빈 공간을 미리 예약해 둡니다. 따라서 이미지가 늦게 다운로드되어도 주변 레이아웃이 1픽셀도 흔들리지 않습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size26&quot;&gt;3. 해결 방법 2: 최신 CSS aspect-ratio 속성 활용하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;동적으로 불러오는 외부 이미지나 반응형 카드 컴포넌트에는 최신 CSS 속성인 aspect-ratio를 사용하면 훨씬 유연하게 공간을 확보할 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiJg4zav7iWAxUAAAAAHQAAAAAQygE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;CSS&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;/* ⭕ 이미지가 다운로드되기 전에도 16:9 비율 공간을 강제로 유지 */
.banner-image-container {
  width: 100%;
  aspect-ratio: 16 / 9;
  background-color: #1e293b; /* 로딩 전 빈 공간에 어두운 배경색 지정 */
  border-radius: 12px;
  overflow: hidden;
}

.banner-image-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;과거에 padding-top: 56.25% 같은 복잡하고 난해한 핵(Hack)을 쓰던 방식에서 벗어나, 단 한 줄의 CSS로 완벽한 반응형 종횡비 공간을 예약할 수 있게 되었습니다.&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;24&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;24,0&quot; data-ke-size=&quot;size16&quot;&gt;  &lt;b data-index-in-node=&quot;3&quot; data-path-to-node=&quot;24,0&quot;&gt;[사진 첨부 가이드 1]&lt;/b&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;24,1&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24,1&quot;&gt;여기에 VS Code에서 aspect-ratio: 16 / 9; 및 이미지 컨테이너 CSS 코드가 작성된 에디터 스크린샷을 넣으세요!&lt;/b&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;24,2&quot; data-ke-size=&quot;size16&quot;&gt;&lt;i data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24,2&quot;&gt;(캡처 팁: CSS 속성 중 aspect-ratio와 object-fit: cover 부분이 한눈에 들어오도록 에디터 화면을 스크린샷 찍어 첨부해 주세요.)&lt;/i&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-24 오후 6.20.16.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;990&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eHvT3K/dJMcagNyiPM/3T5lYAHaST7hN952EudQkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eHvT3K/dJMcagNyiPM/3T5lYAHaST7hN952EudQkK/img.png&quot; data-alt=&quot;이미지 컨테이너 css&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eHvT3K/dJMcagNyiPM/3T5lYAHaST7hN952EudQkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeHvT3K%2FdJMcagNyiPM%2F3T5lYAHaST7hN952EudQkK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1080&quot; height=&quot;990&quot; data-filename=&quot;스크린샷 2026-08-24 오후 6.20.16.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;990&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이미지 컨테이너 css&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;4. 해결 방법 3: 스켈레톤 UI(Skeleton)로 로딩 체감 속도 극대화&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;API 통신으로 데이터를 받아오는 동안에는 빈 화면 대신 스켈레톤(Skeleton UI)을 띄워 공간을 유지하면서 사용자에게 &quot;데이터를 불러오는 중&quot;이라는 시각적 피드백을 주는 것이 좋습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiJg4zav7iWAxUAAAAAHQAAAAAQywE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;CSS&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;/* 은은하게 반짝이는 스켈레톤 애니메이션 */
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

.skeleton-box {
  width: 100%;
  aspect-ratio: 16 / 9;
  background-color: #334155;
  animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
  border-radius: 8px;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;29&quot; 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;스크린샷 2026-08-26 오후 1.27.14.png&quot; data-origin-width=&quot;968&quot; data-origin-height=&quot;1020&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xXXyt/dJMcabyArml/YKspXVYEkpnhNbFcsnKR3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xXXyt/dJMcabyArml/YKspXVYEkpnhNbFcsnKR3K/img.png&quot; data-alt=&quot;이미지 로드 전 스켈레톤&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xXXyt/dJMcabyArml/YKspXVYEkpnhNbFcsnKR3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxXXyt%2FdJMcabyArml%2FYKspXVYEkpnhNbFcsnKR3K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;1020&quot; data-filename=&quot;스크린샷 2026-08-26 오후 1.27.14.png&quot; data-origin-width=&quot;968&quot; data-origin-height=&quot;1020&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;스크린샷 2026-08-26 오후 1.25.30.png&quot; data-origin-width=&quot;1100&quot; data-origin-height=&quot;1046&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VCHYb/dJMcaiqXj0k/g16xf1tPSmMHJuyJz2BoL1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VCHYb/dJMcaiqXj0k/g16xf1tPSmMHJuyJz2BoL1/img.png&quot; data-alt=&quot;이미지 로드 후 상태&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VCHYb/dJMcaiqXj0k/g16xf1tPSmMHJuyJz2BoL1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVCHYb%2FdJMcaiqXj0k%2Fg16xf1tPSmMHJuyJz2BoL1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;1046&quot; data-filename=&quot;스크린샷 2026-08-26 오후 1.25.30.png&quot; data-origin-width=&quot;1100&quot; data-origin-height=&quot;1046&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이미지 로드 후 상태&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;32&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론 및 느낀 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;구글 검색 엔진은 이제 단순히 텍스트 내용뿐만 아니라 사용자가 화면을 조작할 때 시각적 안정성을 유지하는지(Core Web Vitals)를 중요한 랭킹 요소로 평가합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;34&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;모든 &amp;lt;img&amp;gt; 태그에 종횡비 정보(width/height 또는 CSS aspect-ratio)를 명시할 것&lt;/li&gt;
&lt;li&gt;동적 콘텐츠 로딩 구간에는 미리 높이를 확보한 스켈레톤 UI를 배치할 것&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;이 두 가지 원칙만 지켜도 화면이 덜컹거리는 불쾌한 사용자 경험을 없애고 웹 사이트의 전문성과 완성도를 한 단계 끌어올릴 수 있습니다!&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>aspect_ratio</category>
      <category>CLS</category>
      <category>css</category>
      <category>개발자일기</category>
      <category>누적레이아웃이동</category>
      <category>스켈레톤ui</category>
      <category>웹개발</category>
      <category>웹성능최적화</category>
      <category>코어웹바이탈</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/56</guid>
      <comments>https://minari98.tistory.com/entry/CSS-%EC%9D%B4%EB%AF%B8%EC%A7%80%EA%B0%80-%EB%A1%9C%EB%94%A9%EB%90%98%EB%A9%B4%EC%84%9C-%EB%B2%84%ED%8A%BC-%EC%9C%84%EC%B9%98%EA%B0%80-%EC%BF%B5-%EB%82%B4%EB%A0%A4%EC%95%89%EB%8A%94%EB%8B%A4-CLS%EB%88%84%EC%A0%81-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EC%9D%B4%EB%8F%99-%ED%95%B4%EA%B2%B0%EA%B8%B0#entry56comment</comments>
      <pubDate>Fri, 28 Aug 2026 14:29:34 +0900</pubDate>
    </item>
    <item>
      <title>[JavaScript] 버튼 연타하면 서버가 터진다? AbortController로 불필요한 API 요청 취소하기</title>
      <link>https://minari98.tistory.com/entry/JavaScript-%EB%B2%84%ED%8A%BC-%EC%97%B0%ED%83%80%ED%95%98%EB%A9%B4-%EC%84%9C%EB%B2%84%EA%B0%80-%ED%84%B0%EC%A7%84%EB%8B%A4-AbortController%EB%A1%9C-%EB%B6%88%ED%95%84%EC%9A%94%ED%95%9C-API-%EC%9A%94%EC%B2%AD-%EC%B7%A8%EC%86%8C%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드 개발을 하며 검색창 자동완성이나 탭 전환 메뉴, 좋아요 버튼 같은 기능을 만들다 보면 사용자가 버튼을 마구 연타하거나 키보드를 빠르게 칠 때가 있습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;과거 프로젝트에서 사용자가 탭 버튼을 'A ➔ B ➔ C'로 빠르게 연속 클릭했을 때 황당한 버그를 마주쳤습니다. 분명 사용자는 마지막에 'C 탭'을 눌렀는데, 네트워크 응답 지연으로 인해 가장 늦게 도착한 'A 탭'의 데이터가 화면을 덮어씌워 버리는 '경쟁 상태(Race Condition)'가 발생한 것입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;이로 인해 화면에는 잘못된 데이터가 표시되었고, 서버에는 이미 쓸모없어진 이전 탭들의 불필요한 API 요청이 그대로 쌓여 리소스를 낭비하고 있었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 브라우저의 표준 내장 API인 &lt;b data-index-in-node=&quot;21&quot; data-path-to-node=&quot;8&quot;&gt;AbortController를 활용하여 이전 비동기 요청을 깔끔하게 취소하고 경쟁 상태를 방지했던 노하우&lt;/b&gt;를 공유해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size26&quot;&gt;1. 내가 마주친 문제: &quot;왜 마지막으로 클릭한 결과가 안 나올까?&quot; (경쟁 상태)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;네트워크 요청은 항상 보낸 순서대로 도착한다는 보장이 없습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1번 요청 (A 탭 클릭, 응답 시간 1.5초)&lt;/li&gt;
&lt;li&gt;2번 요청 (B 탭 클릭, 응답 시간 0.2초)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 A를 누르고 곧바로 B를 눌렀을 때, B의 응답이 먼저 화면에 반영되었다가 1초 뒤 뒤늦게 도착한 A의 응답이 화면을 덮어버리는 끔찍한 UX 참사가 발생합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하려면 &quot;새로운 요청이 발생하면, 아직 끝나지 않은 이전 요청은 브라우저 차원에서 즉시 취소(Abort)&quot;해야 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size26&quot;&gt;2. 해결책: 브라우저 표준 AbortController란?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;AbortController는 웹 브라우저에 내장된 객체로, 하나 이상의 웹 요청(예: fetch API)을 원하는 시점에 중단할 수 있는 신호(Signal)를 제공합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;18&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,0,0&quot;&gt;controller.signal:&lt;/b&gt; 통신 함수(fetch)에 전달하여 취소 신호를 수신할 수 있게 연결합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,1,0&quot;&gt;controller.abort():&lt;/b&gt; 이 메서드를 호출하면 연결된 네트워크 요청이 브라우저 수준에서 즉시 취소됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size26&quot;&gt;3. 실무 적용 1: fetch에 취소 기능 적용하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;새로운 요청을 시작하기 직전, 이전 컨트롤러의 abort()를 실행해 이전 통신을 취소하도록 작성했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiJg4zav7iWAxUAAAAAHQAAAAAQnwE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;// ⭕ AbortController를 활용한 안전한 API 호출 함수
let previousController: AbortController | null = null;

async function fetchSearchResults(query: string) {
  // 1. 이미 진행 중인 이전 요청이 있다면 취소 신호 발송!
  if (previousController) {
    previousController.abort();
  }

  // 2. 새로운 요청을 위한 컨트롤러 생성
  previousController = new AbortController();
  const { signal } = previousController;

  try {
    const response = await fetch(`https://api.example.com/search?q=${query}`, {
      signal, //   fetch 두 번째 옵션에 signal 전달!
    });
    const data = await response.json();
    return data;
  } catch (error: any) {
    // 취소로 인한 중단인 경우 에러 처리에서 안전하게 무시
    if (error.name === 'AbortError') {
      console.log('이전 요청이 안전하게 취소되었습니다.');
    } else {
      console.error('실제 네트워크 에러 발생:', error);
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 사용자가 검색어를 빠르게 연속 입력하더라도, 마지막 입력값에 대한 응답만 정상 처리되고 앞선 요청들은 네트워크 레벨에서 모두 취소됩니다.&lt;i data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24,2&quot;&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-24 오후 5.07.06.png&quot; data-origin-width=&quot;1346&quot; data-origin-height=&quot;1240&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/djdWBb/dJMcahyQfjj/MZ4MKc3XjGViorglUha7e1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/djdWBb/dJMcahyQfjj/MZ4MKc3XjGViorglUha7e1/img.png&quot; data-alt=&quot;new AbortController() 및 fetch(url, { signal }) 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/djdWBb/dJMcahyQfjj/MZ4MKc3XjGViorglUha7e1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdjdWBb%2FdJMcahyQfjj%2FMZ4MKc3XjGViorglUha7e1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1346&quot; height=&quot;1240&quot; data-filename=&quot;스크린샷 2026-08-24 오후 5.07.06.png&quot; data-origin-width=&quot;1346&quot; data-origin-height=&quot;1240&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;new AbortController() 및 fetch(url, { signal }) 코드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;4. 실무 적용 2: React useEffect 클린업(Clean-up)에서 요청 취소하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;React 컴포넌트 안에서 데이터를 불러올 때는 useEffect의 뒷정리(Clean-up) 함수에서 controller.abort()를 호출하는 것이 정석 패턴입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;컴포넌트가 언마운트되거나 의존성 변수가 변경될 때 이전 요청을 자동으로 끊어주어 메모리 누수(Memory Leak)를 완벽히 막아줍니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiJg4zav7iWAxUAAAAAHQAAAAAQoAE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { useState, useEffect } from 'react';

export default function UserDetail({ userId }: { userId: number }) {
  const [data, setData] = useState(null);

  useEffect(() =&amp;gt; {
    const controller = new AbortController();

    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, {
      signal: controller.signal,
    })
      .then((res) =&amp;gt; res.json())
      .then((data) =&amp;gt; setData(data))
      .catch((err) =&amp;gt; {
        if (err.name !== 'AbortError') console.error(err);
      });

    //   컴포넌트가 사라지거나 userId가 바뀌면 이전 요청 즉시 취소!
    return () =&amp;gt; {
      controller.abort();
    };
  }, [userId]);

  return&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;{data ? JSON.stringify(data) : '로딩 중...'}&lt;/div&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-24 오후 5.22.23.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1164&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MUOH7/dJMcabSSVjl/6eBGgdGEdrNrZi1Bqp6bSk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MUOH7/dJMcabSSVjl/6eBGgdGEdrNrZi1Bqp6bSk/img.png&quot; data-alt=&quot;연속 요청시 이전 요청이 취소됨&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MUOH7/dJMcabSSVjl/6eBGgdGEdrNrZi1Bqp6bSk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMUOH7%2FdJMcabSSVjl%2F6eBGgdGEdrNrZi1Bqp6bSk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1164&quot; data-filename=&quot;스크린샷 2026-08-24 오후 5.22.23.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1164&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;연속 요청시 이전 요청이 취소됨&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;32&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론 및 느낀 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;이전에는 비동기 통신 에러가 나면 무조건 백엔드나 네트워크 망 문제라고만 생각했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;하지만 프론트엔드 단에서 &lt;b data-index-in-node=&quot;14&quot; data-path-to-node=&quot;34&quot;&gt;AbortController를 통해 불필요한 요청을 선제적으로 취소해 주는 것만으로도, 예기치 않은 버그(경쟁 상태)를 원천 차단하고 서버 트래픽 비용을 획기적으로 줄일 수 있었습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;검색창 실시간 자동완성이나 탭 전환 컴포넌트를 만들고 계신다면, 지금 바로 AbortController를 도입해 안전하고 매끄러운 통신 환경을 구축해 보세요!&lt;/p&gt;</description>
      <category>트러블슈팅</category>
      <category>abortcontroller</category>
      <category>FetchAPI</category>
      <category>javascript</category>
      <category>React</category>
      <category>개발자일기</category>
      <category>개발자팁</category>
      <category>네트워크최적화</category>
      <category>비동기통신</category>
      <category>웹개발</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/55</guid>
      <comments>https://minari98.tistory.com/entry/JavaScript-%EB%B2%84%ED%8A%BC-%EC%97%B0%ED%83%80%ED%95%98%EB%A9%B4-%EC%84%9C%EB%B2%84%EA%B0%80-%ED%84%B0%EC%A7%84%EB%8B%A4-AbortController%EB%A1%9C-%EB%B6%88%ED%95%84%EC%9A%94%ED%95%9C-API-%EC%9A%94%EC%B2%AD-%EC%B7%A8%EC%86%8C%ED%95%98%EA%B8%B0#entry55comment</comments>
      <pubDate>Wed, 26 Aug 2026 18:23:38 +0900</pubDate>
    </item>
    <item>
      <title>[Web Storage] 새로고침하면 다 날아간다고? LocalStorage와 SessionStorage로 브라우저 데이터 유지한 후기</title>
      <link>https://minari98.tistory.com/entry/Web-Storage-%EC%83%88%EB%A1%9C%EA%B3%A0%EC%B9%A8%ED%95%98%EB%A9%B4-%EB%8B%A4-%EB%82%A0%EC%95%84%EA%B0%84%EB%8B%A4%EA%B3%A0-LocalStorage%EC%99%80-SessionStorage%EB%A1%9C-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9C%A0%EC%A7%80%ED%95%9C-%ED%9B%84%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹 사이트를 개발하다가 사용자가 실수로 브라우저 새로고침(F5)을 누르거나 페이지를 이동했을 때, 기껏 작성 중이던 폼 데이터나 다크 모드 설정값이 초기화되어 날아가는 문제를 겪어보신 적 있으신가요?&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;저 역시 초기 프로젝트에서 모든 상태를 React useState로만 관리했다가, 사용자가 브라우저를 껐다 켜거나 새로고침할 때마다 설정이 리셋되는 피드백을 받았습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;서버 데이터베이스(DB)에 일일이 저장하기에는 너무 자잘한 프론트엔드 설정값들(다크 모드 테마, 장바구니 임시 데이터, 팝업 '오늘 하루 보지 않기' 등)을 어디에 보관해야 할지 고민하다가 브라우저 저장소(Web Storage API)를 본격적으로 활용하게 되었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 브라우저의 대표적인 3대 저장소인 &lt;b data-index-in-node=&quot;23&quot; data-path-to-node=&quot;8&quot;&gt;LocalStorage, SessionStorage, Cookie의 차이점과 실무에서 안전하게 활용했던 노하우&lt;/b&gt;를 공유해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size26&quot;&gt;1. 3대 브라우저 저장소 핵심 비교: &quot;어디에 무엇을 저장해야 할까?&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;웹 브라우저가 제공하는 저장소는 수명(Lifecycle)과 용도에 따라 명확히 나뉩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,0,0&quot;&gt;LocalStorage (로컬 스토리지):&lt;/b&gt; 브라우저 창을 닫아도 데이터가 영구적으로 유지됩니다. (용량 약 5MB) ➔ &lt;i data-index-in-node=&quot;67&quot; data-path-to-node=&quot;12,0,0&quot;&gt;다크 모드 테마, 사용자 맞춤 UI 설정에 적합&lt;/i&gt;&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,1,0&quot;&gt;SessionStorage (세션 스토리지):&lt;/b&gt; 현재 탭(세션)이 열려 있는 동안에만 유지되며, 탭을 닫으면 즉시 삭제됩니다. ➔ &lt;i data-index-in-node=&quot;72&quot; data-path-to-node=&quot;12,1,0&quot;&gt;일회성 폼 입력 중간 저장, 결제 진행 단계 임시 데이터에 적합&lt;/i&gt;&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,2,0&quot;&gt;Cookie (쿠키):&lt;/b&gt; 만료일을 설정할 수 있고 서버 요청 시 자동으로 헤더에 포함됩니다. (용량 약 4KB) ➔ &lt;i data-index-in-node=&quot;64&quot; data-path-to-node=&quot;12,2,0&quot;&gt;로그인 인증 토큰(HttpOnly) 등에 주로 사용&lt;/i&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size26&quot;&gt;2. 실무 적용 1: LocalStorage로 다크 모드(Theme) 설정 영구 저장하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 라이트 모드/다크 모드를 선택했을 때, 브라우저를 닫고 며칠 뒤에 다시 들어와도 그 설정을 기억하도록 localStorage를 적용했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQxAU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ⭕ 로컬스토리지에 테마 저장 및 불러오기 함수
const THEME_KEY = 'app_user_theme';

// 1. 테마 저장하기
export const saveTheme = (theme: 'light' | 'dark') =&amp;gt; {
  try {
    localStorage.setItem(THEME_KEY, theme);
  } catch (error) {
    console.error('스토리지 저장 실패:', error);
  }
};

// 2. 테마 불러오기 (기본값: 'dark')
export const getSavedTheme = (): 'light' | 'dark' =&amp;gt; {
  if (typeof window === 'undefined') return 'dark';
  const saved = localStorage.getItem(THEME_KEY);
  return saved === 'light' ? 'light' : 'dark';
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size26&quot;&gt;3. 실무 적용 2: 객체(Object)나 배열 다룰 때 JSON.stringify 필수 사용&lt;/h2&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;초보 시절 가장 많이 범했던 실수는 객체 데이터를 그대로 localStorage.setItem('user', { name: 'dev' })로 저장했던 것입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;브라우저 스토리지는 문자열(String)만 저장할 수 있기 때문에, 객체를 그냥 넣으면 [object Object]라는 깨진 문자열로 변환되어 저장됩니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQxQU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;const userProfile = { id: 1, name: '김개발', role: 'frontend' };

// ❌ 잘못된 저장 방식: [object Object]로 저장됨
localStorage.setItem('user', userProfile);

// ⭕ 올바른 저장 방식: JSON 문자열로 직렬화 후 저장
localStorage.setItem('user', JSON.stringify(userProfile));

// ⭕ 꺼내올 때: JSON.parse로 다시 자바스크립트 객체로 변환
const savedUser = JSON.parse(localStorage.getItem('user'));
console.log(savedUser.name); // '김개발' 정상 접근 가능!
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-24 오후 2.00.52.png&quot; data-origin-width=&quot;1220&quot; data-origin-height=&quot;1184&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bk0JZf/dJMcadpBFWF/vbCpKJkCPApNe80zYYZ4F1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bk0JZf/dJMcadpBFWF/vbCpKJkCPApNe80zYYZ4F1/img.png&quot; data-alt=&quot;localStorage.setItem 및 JSON.stringify&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bk0JZf/dJMcadpBFWF/vbCpKJkCPApNe80zYYZ4F1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbk0JZf%2FdJMcadpBFWF%2FvbCpKJkCPApNe80zYYZ4F1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1220&quot; height=&quot;1184&quot; data-filename=&quot;스크린샷 2026-08-24 오후 2.00.52.png&quot; data-origin-width=&quot;1220&quot; data-origin-height=&quot;1184&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;localStorage.setItem 및 JSON.stringify&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size26&quot;&gt;4. 실무 적용 3: 크롬 개발자 도구(Application 탭)로 데이터 확인하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;스토리지에 데이터가 제대로 들어갔는지 확인하려면 브라우저 개발자 도구의 &lt;b data-index-in-node=&quot;40&quot; data-path-to-node=&quot;25&quot;&gt;Application&lt;/b&gt; 탭을 활용하면 됩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;26&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;F12 개발자 도구 열기&lt;/li&gt;
&lt;li&gt;상단 메뉴 중 &lt;b data-index-in-node=&quot;8&quot; data-path-to-node=&quot;26,1,0&quot;&gt;Application (애플리케이션)&lt;/b&gt; 클릭&lt;/li&gt;
&lt;li&gt;좌측 메뉴 &lt;b data-index-in-node=&quot;6&quot; data-path-to-node=&quot;26,2,0&quot;&gt;Storage &amp;gt; Local Storage&lt;/b&gt; 클릭 후 내 도메인 선택&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;그러면 내가 코드로 저장한 Key와 Value가 실시간으로 표 형태로 직관적이게 표시되는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-24 오후 2.07.23.png&quot; data-origin-width=&quot;2460&quot; data-origin-height=&quot;1092&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qo5Nq/dJMcaasY8cm/Ke53WTm6A3r88vwIhXUPtk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qo5Nq/dJMcaasY8cm/Ke53WTm6A3r88vwIhXUPtk/img.png&quot; data-alt=&quot;local storage저장 확인&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qo5Nq/dJMcaasY8cm/Ke53WTm6A3r88vwIhXUPtk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fqo5Nq%2FdJMcaasY8cm%2FKe53WTm6A3r88vwIhXUPtk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2460&quot; height=&quot;1092&quot; data-filename=&quot;스크린샷 2026-08-24 오후 2.07.23.png&quot; data-origin-width=&quot;2460&quot; data-origin-height=&quot;1092&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;local storage저장 확인&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론 및 주의할 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;웹 스토리지는 사용하기 정말 편리하지만, 보안상 한 가지 중요한 원칙이 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;32&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,0,0&quot;&gt;LocalStorage에는 절대 민감한 정보(비밀번호, 개인정보, 중요한 API 시크릿 키)를 저장하지 말 것!&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;XSS(Cross-Site Scripting) 공격에 노출될 경우 자바스크립트 코드로 쉽게 탈취될 수 있기 때문입니다. 따라서 UI 설정값, 캐시성 데이터, 비인증 임시 상태 위주로 활용하고, 중요한 인증 토큰은 안전한 HttpOnly Cookie를 활용하는 것이 모범 사례입니다.&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>javascript</category>
      <category>localStorage</category>
      <category>React</category>
      <category>SessionStorage</category>
      <category>개발자일기</category>
      <category>개발자팁</category>
      <category>웹개발</category>
      <category>웹스토리지</category>
      <category>자바스크립트</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/54</guid>
      <comments>https://minari98.tistory.com/entry/Web-Storage-%EC%83%88%EB%A1%9C%EA%B3%A0%EC%B9%A8%ED%95%98%EB%A9%B4-%EB%8B%A4-%EB%82%A0%EC%95%84%EA%B0%84%EB%8B%A4%EA%B3%A0-LocalStorage%EC%99%80-SessionStorage%EB%A1%9C-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9C%A0%EC%A7%80%ED%95%9C-%ED%9B%84%EA%B8%B0#entry54comment</comments>
      <pubDate>Mon, 24 Aug 2026 14:08:22 +0900</pubDate>
    </item>
    <item>
      <title>[State] Props Drilling의 고통: Zustand로 10분 만에 끝내는 전역 상태 관리 도입기</title>
      <link>https://minari98.tistory.com/entry/State-Props-Drilling%EC%9D%98-%EA%B3%A0%ED%86%B5-Zustand%EB%A1%9C-10%EB%B6%84-%EB%A7%8C%EC%97%90-%EB%81%9D%EB%82%B4%EB%8A%94-%EC%A0%84%EC%97%AD-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC-%EB%8F%84%EC%9E%85%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;리액트(React)로 컴포넌트를 분리하며 프로젝트를 개발하다 보면 누구나 한 번쯤 마주치는 악몽이 있습니다. 바로 '프롭스 드릴링(Props Drilling)'입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;로그인한 유저 정보나 다크모드 설정값처럼 앱 전체에서 쓰이는 데이터를 단 하나의 하위 컴포넌트에 전달하기 위해, 부모 ➔ 자식 ➔ 손자 ➔ 증손자 컴포넌트를 거쳐 불필요하게 props를 계속 넘겨주다 보면 코드가 지저분해지고 유지보수가 불가능해집니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 Redux(리덕스)를 도입해 보려 했지만, 단순한 상태 하나 만드는데 Action, Reducer, Store 등 보일러플레이트 코드가 너무 많아 배보다 배꼽이 더 큰 느낌을 받았습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 보일러플레이트 없이 가볍고 직관적인 &lt;b data-index-in-node=&quot;24&quot; data-path-to-node=&quot;7&quot;&gt;Zustand(주스탠드)를 도입하여 프롭스 드릴링을 단 10분 만에 해결했던 경험과 실무 활용 꿀팁&lt;/b&gt;을 공유해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. 내가 마주친 문제: &quot;증손자 컴포넌트 하나 때문에 부모들이 고통받다&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;헤더에 있는 장바구니 카운트와, 메인 상품 목록 페이지에 있는 '장바구니 담기' 버튼을 동기화해야 하는 상황이었습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQgwU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;// ❌ 프롭스 드릴링으로 고통받던 과거 컴포넌트 구조
&amp;lt;App&amp;gt;
  &amp;lt;Header cartCount={cartCount} /&amp;gt; {/* 헤더에 전달 */}
  &amp;lt;MainPage&amp;gt;
    &amp;lt;ProductList&amp;gt;
      &amp;lt;ProductCard onAddToCart={handleAddToCart} /&amp;gt; {/* 3단계를 거쳐 함수 전달 */}
    &amp;lt;/ProductList&amp;gt;
  &amp;lt;/MainPage&amp;gt;
&amp;lt;/App&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;중간에 있는 MainPage나 ProductList는 onAddToCart 함수를 직접 쓰지도 않으면서 단순히 자식에게 넘겨주기 위해 props를 억지로 받아야 했습니다. 이 구조는 나중에 컴포넌트 위치가 바뀌거나 기능이 추가될 때마다 치명적인 리팩토링 비용을 발생시켰습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size26&quot;&gt;2. 왜 Redux 대신 Zustand를 선택했는가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;전역 상태 라이브러리를 고민할 때 가장 먼저 떠오른 것은 Redux였지만, 작은 규모의 기능 하나를 추가하기 위해 작성해야 할 보일러플레이트 코드가 너무 많았습니다. 반면 &lt;b data-index-in-node=&quot;96&quot; data-path-to-node=&quot;15&quot;&gt;Zustand&lt;/b&gt;는 다음과 같은 압도적인 장점이 있었습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;16&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16,0,0&quot;&gt;단순한 훅(Hook) 기반:&lt;/b&gt; 별도의 Context Provider로 앱 전체를 감쌀 필요가 없음.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16,1,0&quot;&gt;극도로 가벼운 번들 크기:&lt;/b&gt; 수십 KB에 달하는 다른 라이브러리에 비해 약 1KB 수준으로 초경량.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16,2,0&quot;&gt;타입스크립트(TypeScript) 완벽 지원:&lt;/b&gt; 복잡한 제네릭 설정 없이 직관적인 타입 추론 가능.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size26&quot;&gt;3. 실무 적용: 10줄로 만드는 Zustand 전역 스토어&lt;/h2&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;Zustand의 핵심은 create 함수를 사용해 스토어(Store)를 정의하고, 필요한 컴포넌트에서 일반 커스텀 훅처럼 가져다 쓰는 것입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQhAU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// store/useCartStore.ts (장바구니 전역 스토어)
import { create } from 'zustand';

interface CartState {
  cartCount: number;
  addToCart: () =&amp;gt; void;
  clearCart: () =&amp;gt; void;
}

export const useCartStore = create&amp;lt;CartState&amp;gt;((set) =&amp;gt; ({
  cartCount: 0,
  // set 함수를 사용해 상태를 불변성 유지하며 업데이트
  addToCart: () =&amp;gt; set((state) =&amp;gt; ({ cartCount: state.cartCount + 1 })),
  clearCart: () =&amp;gt; set({ cartCount: 0 }),
}));
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;Provider로 최상단 App 컴포넌트를 감싸는 과정조차 필요 없이, 이 파일 하나로 전역 상태 세팅이 끝납니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-20 오후 6.23.49.png&quot; data-origin-width=&quot;1326&quot; data-origin-height=&quot;1014&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1Xzlv/dJMcafgH9aI/k40IhKhgTh3VLOceTmLOf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1Xzlv/dJMcafgH9aI/k40IhKhgTh3VLOceTmLOf1/img.png&quot; data-alt=&quot;인터페이스 정의와 Zustand create 스토어 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1Xzlv/dJMcafgH9aI/k40IhKhgTh3VLOceTmLOf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1Xzlv%2FdJMcafgH9aI%2Fk40IhKhgTh3VLOceTmLOf1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1326&quot; height=&quot;1014&quot; data-filename=&quot;스크린샷 2026-08-20 오후 6.23.49.png&quot; data-origin-width=&quot;1326&quot; data-origin-height=&quot;1014&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;인터페이스 정의와 Zustand create 스토어 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;4. 컴포넌트에서 사용하기: &quot;필요한 곳에서 바로 꺼내 쓴다&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;이제 프롭스 드릴링 없이, 상태가 필&lt;/p&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;요한 컴포넌트에서 훅을 호출하기만 하면 됩니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQhQU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 1. 헤더 컴포넌트: cartCount 값만 구독
import { useCartStore } from '../store/useCartStore';

export function Header() {
  const cartCount = useCartStore((state) =&amp;gt; state.cartCount);
  return &amp;lt;header&amp;gt;장바구니:   {cartCount}개&amp;lt;/header&amp;gt;;
}

// 2. 상품 카드 컴포넌트: addToCart 함수만 호출
export function ProductCard() {
  const addToCart = useCartStore((state) =&amp;gt; state.addToCart);
  return &amp;lt;button onClick={addToCart}&amp;gt;장바구니 담기&amp;lt;/button&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;27&quot; 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;스크린샷 2026-08-20 오후 6.25.56.png&quot; data-origin-width=&quot;996&quot; data-origin-height=&quot;706&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zi0Az/dJMcai5rF3m/S2mv2SkuQR9daRa7HWVMkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zi0Az/dJMcai5rF3m/S2mv2SkuQR9daRa7HWVMkk/img.png&quot; data-alt=&quot;담은 상품 수에 따라 달라지는 금액&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zi0Az/dJMcai5rF3m/S2mv2SkuQR9daRa7HWVMkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fzi0Az%2FdJMcai5rF3m%2FS2mv2SkuQR9daRa7HWVMkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;996&quot; height=&quot;706&quot; data-filename=&quot;스크린샷 2026-08-20 오후 6.25.56.png&quot; data-origin-width=&quot;996&quot; data-origin-height=&quot;706&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;담은 상품 수에 따라 달라지는 금액&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론 및 느낀 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;Zustand를 도입하고 나서 리액트 상태 관리의 피로도가 절반 이하로 줄어들었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;32&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상태 하나 만드는데 1분도 채 걸리지 않는 &lt;b data-index-in-node=&quot;24&quot; data-path-to-node=&quot;32,0,0&quot;&gt;압도적인 생산성&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;불필요한 props 전달이 사라져 &lt;b data-index-in-node=&quot;19&quot; data-path-to-node=&quot;32,1,0&quot;&gt;깔끔해진 컴포넌트 트리 구조&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;컴포넌트 깊이가 깊어지면서 props를 넘겨주는 데 스트레스를 받고 계신다면, 무거운 라이브러리 대신 가볍고 직관적인 &lt;b data-index-in-node=&quot;66&quot; data-path-to-node=&quot;33&quot;&gt;Zustand&lt;/b&gt;로 전역 상태 관리를 시작해 보시길 강력히 추천합니다!&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>propsdrilling</category>
      <category>React</category>
      <category>TypeScript</category>
      <category>zustand</category>
      <category>개발자일기</category>
      <category>리액트</category>
      <category>상태관리</category>
      <category>웹개발</category>
      <category>전역상태관리</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/53</guid>
      <comments>https://minari98.tistory.com/entry/State-Props-Drilling%EC%9D%98-%EA%B3%A0%ED%86%B5-Zustand%EB%A1%9C-10%EB%B6%84-%EB%A7%8C%EC%97%90-%EB%81%9D%EB%82%B4%EB%8A%94-%EC%A0%84%EC%97%AD-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC-%EB%8F%84%EC%9E%85%EA%B8%B0#entry53comment</comments>
      <pubDate>Fri, 21 Aug 2026 19:26:42 +0900</pubDate>
    </item>
    <item>
      <title>[Web] 스크롤 한 번에 함수가 500번 실행된다고? 디바운스와 스로틀로 브라우저 렉 잡은 후기</title>
      <link>https://minari98.tistory.com/entry/Web-%EC%8A%A4%ED%81%AC%EB%A1%A4-%ED%95%9C-%EB%B2%88%EC%97%90-%ED%95%A8%EC%88%98%EA%B0%80-500%EB%B2%88-%EC%8B%A4%ED%96%89%EB%90%9C%EB%8B%A4%EA%B3%A0-%EB%94%94%EB%B0%94%EC%9A%B4%EC%8A%A4%EC%99%80-%EC%8A%A4%EB%A1%9C%ED%8B%80%EB%A1%9C-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%89-%EC%9E%A1%EC%9D%80-%ED%9B%84%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 지금까지 다룬 주제들(Next.js 최적화, WebP/AVIF, 배열 메서드, TS 타입 에러, VS Code 확장, useEffect, Git 잔디, CORS)과 &lt;b data-index-in-node=&quot;97&quot; data-path-to-node=&quot;0&quot;&gt;완전히 겹치지 않으면서 프론트엔드 최적화의 핵심이자 검색 노출(SEO) 점수에도 매우 유리한 주제&lt;/b&gt;로 준비했습니다!&lt;/p&gt;
&lt;p data-path-to-node=&quot;1&quot; data-ke-size=&quot;size16&quot;&gt;주제는 [Web] 스크롤 이벤트 버벅거림 해결기: 디바운스(Debounce)와 스로틀(Throttle) 실무 적용법입니다. 생생한 개발 경험담과 고민 서사, 본문, 그리고 &lt;b data-index-in-node=&quot;95&quot; data-path-to-node=&quot;1&quot;&gt;사진 첨부 가이드 1, 2번용 코드 및 눈속임 테스트 팁까지 한 번에&lt;/b&gt; 담아 드립니다.&lt;/p&gt;
&lt;h1 data-path-to-node=&quot;3&quot;&gt;[Web] 스크롤 한 번에 함수가 500번 실행된다고? 디바운스와 스로틀로 브라우저 렉 잡은 후기&lt;/h1&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;웹 사이트에 무한 스크롤(Infinite Scroll)이나 상단 고정 헤더(Sticky Header), 실시간 검색창 자동완성 기능을 구현하다가 화면이 뚝뚝 끊기는 버벅거림(Jank) 현상을 겪어보신 적 있으신가요?&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;저 역시 예전 프로젝트에서 스크롤 이벤트(window.addEventListener('scroll', ...))를 붙여두고 아무 생각 없이 개발자 도구 콘솔을 열었다가 경악을 금치 못했습니다. &lt;b data-index-in-node=&quot;108&quot; data-path-to-node=&quot;5&quot;&gt;마우스 휠을 단 1초 굴렸을 뿐인데 이벤트 핸들러가 무려 300~500번 넘게 폭주하며 호출&lt;/b&gt;되고 있었던 것입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;이로 인해 모바일 기기나 저사양 PC에서는 스크롤이 심하게 버벅거렸고, 브라우저 성능 탭에서는 프레임 드랍(Frame Drop) 경고가 쏟아졌습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 과도한 이벤트 호출로 인한 브라우저 과부하를 막고 성능을 극적으로 개선해 준 &lt;b data-index-in-node=&quot;47&quot; data-path-to-node=&quot;7&quot;&gt;디바운스(Debounce)와 스로틀(Throttle)의 차이점 및 실무 적용 노하우&lt;/b&gt;를 공유해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. 내가 마주친 문제: &quot;이벤트 폭주로 인한 브라우저 과부하&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;웹 브라우저의 scroll, resize, mousemove 같은 이벤트는 사용자가 조작하는 동안 밀리초(ms) 단위로 매우 빈번하게 발생합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQxAQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// ❌ 휠 한 번에 수백 번 실행되어 성능을 갉아먹는 단순 스크롤 리스너
window.addEventListener('scroll', () =&amp;gt; {
  console.log('현재 스크롤 위치 계산 중...'); // 1초에 수백 번 실행!
  // 만약 여기에 무거운 DOM 조작이나 API 호출이 들어있다면 브라우저는 바로 렉 발생
});
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;이벤트가 발생할 때마다 복잡한 연산이나 리렌더링, API 요청이 일어난다면 브라우저는 렌더 트리를 다시 계산(Reflow / Repaint)하느라 화면을 매끄럽게 그리지 못하게 됩니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 이벤트 실행 주기를 제어하는 &lt;b data-index-in-node=&quot;30&quot; data-path-to-node=&quot;13&quot;&gt;디바운스&lt;/b&gt;와 &lt;b data-index-in-node=&quot;36&quot; data-path-to-node=&quot;13&quot;&gt;스로틀&lt;/b&gt; 기법을 적용했습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;2. 해결책 1: 마지막 입력 후 딱 한 번만 실행하는 '디바운스(Debounce)'&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;디바운스(Debounce)는 연이어 호출되는 이벤트 중 &lt;b data-index-in-node=&quot;31&quot; data-path-to-node=&quot;16&quot;&gt;마지막 이벤트가 발생하고 일정 시간이 지난 후에 딱 한 번만 콜백 함수를 실행&lt;/b&gt;하는 기술입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;17&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,0,0&quot;&gt;가장 적합한 곳:&lt;/b&gt; 검색창 자동완성(실시간 검색 API 호출), 입력 폼 유효성 검사, 브라우저 창 크기 조절(resize) 완료 시점 감지.&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQxQQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ⭕ 디바운스 유틸 함수 직접 구현
function debounce(func, delay = 300) {
  let timerId = null;

  return (...args) =&amp;gt; {
    // 이전 타이머가 남아있다면 취소하고 새 타이머 시작
    if (timerId) clearTimeout(timerId);
    
    timerId = setTimeout(() =&amp;gt; {
      func.apply(this, args);
    }, delay);
  };
}

// 실시간 검색창 입력 이벤트에 적용
const handleSearch = debounce((query) =&amp;gt; {
  console.log(`서버 API 요청 날리기: ${query}`);
}, 500);
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 '타이핑을 완전히 멈췄을 때' 비로소 API를 1번만 호출하므로, 불필요한 서버 트래픽을 90% 이상 아낄 수 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-19 오전 10.25.17.png&quot; data-origin-width=&quot;1192&quot; data-origin-height=&quot;704&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/q5ShX/dJMcajpGVcy/hqvPKxKa9PbUEpBiKytr6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/q5ShX/dJMcajpGVcy/hqvPKxKa9PbUEpBiKytr6K/img.png&quot; data-alt=&quot;useDebounce 훅&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/q5ShX/dJMcajpGVcy/hqvPKxKa9PbUEpBiKytr6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fq5ShX%2FdJMcajpGVcy%2FhqvPKxKa9PbUEpBiKytr6K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1192&quot; height=&quot;704&quot; data-filename=&quot;스크린샷 2026-08-19 오전 10.25.17.png&quot; data-origin-width=&quot;1192&quot; data-origin-height=&quot;704&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;useDebounce 훅&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;3. 해결책 2: 정해진 주기마다 주기적으로 실행하는 '스로틀(Throttle)'&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;스로틀(Throttle)은 이벤트가 수없이 발생하더라도, 설정한 시간 주기(예: 200ms)마다 최대 1번만 실행되도록 제한(Throttling)을 거는 기술입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;24&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24,0,0&quot;&gt;가장 적합한 곳:&lt;/b&gt; 무한 스크롤 페이지 바닥 감지, 스크롤 애니메이션 진행률 계산, 마우스 드래그 이벤트.&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQxgQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ⭕ 스로틀 유틸 함수 직접 구현
function throttle(func, limit = 200) {
  let inThrottle = false;

  return (...args) =&amp;gt; {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      
      // 설정한 시간이 지난 후에야 다음 실행을 허용
      setTimeout(() =&amp;gt; {
        inThrottle = false;
      }, limit);
    }
  };
}

// 스크롤 이벤트에 스로틀 적용
window.addEventListener(
  'scroll',
  throttle(() =&amp;gt; {
    console.log('200ms마다 안정적으로 스크롤 위치 계산');
  }, 200)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;스로틀을 적용하니 초당 수백 번 발생하던 스크롤 이벤트가 초당 단 5번(200ms 기준)으로 제한되면서, 부드러운 60fps 프레임을 유지할 수 있게 되었습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size26&quot;&gt;4. 직접 비교해 본 디바운스 vs 스로틀 핵심 요약&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-path-to-node=&quot;29&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;디바운스 (Debounce)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;스로틀 (Throttle)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;29,1,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,1,0,0&quot;&gt;작동 원리&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;29,1,1,0&quot;&gt;연속된 이벤트가 끝나고 일정 시간 후 1회 실행&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;29,1,2,0&quot;&gt;연속된 이벤트 중 일정한 주기마다 1회씩 실행&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;29,2,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,2,0,0&quot;&gt;핵심 목적&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;29,2,1,0&quot;&gt;마지막 최종 결과물만 필요할 때&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;29,2,2,0&quot;&gt;중간 진행 상태를 지속적으로 확인해야 할 때&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;29,3,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,3,0,0&quot;&gt;추천 활용처&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;29,3,1,0&quot;&gt;실시간 검색창 입력, Resize 창 크기 변경&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;29,3,2,0&quot;&gt;무한 스크롤, 스크롤 위치 감지, 드래그&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-path-to-node=&quot;30,2&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-19 오후 2.09.44.png&quot; data-origin-width=&quot;1158&quot; data-origin-height=&quot;946&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/R6Azx/dJMcabMcocr/JMSc9olMJyT1HhXrGcWidk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/R6Azx/dJMcabMcocr/JMSc9olMJyT1HhXrGcWidk/img.png&quot; data-alt=&quot;[Throttle] 스크롤 이벤트 실행&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/R6Azx/dJMcabMcocr/JMSc9olMJyT1HhXrGcWidk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FR6Azx%2FdJMcabMcocr%2FJMSc9olMJyT1HhXrGcWidk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1158&quot; height=&quot;946&quot; data-filename=&quot;스크린샷 2026-08-19 오후 2.09.44.png&quot; data-origin-width=&quot;1158&quot; data-origin-height=&quot;946&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;[Throttle] 스크롤 이벤트 실행&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;32&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론 및 느낀 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;이전에는 브라우저 버벅임이 발생하면 무조건 React의 useMemo나 복잡한 리팩토링부터 고민했었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이벤트 제어 기법인 &lt;b data-index-in-node=&quot;15&quot; data-path-to-node=&quot;34&quot;&gt;디바운스와 스로틀을 적재적소에 배치하는 것만으로도 브라우저 CPU 점유율을 대폭 낮추고 체감 렌더링 성능을 극대화&lt;/b&gt;할 수 있었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;사용자 입력이나 스크롤 이벤트가 잦은 프론트엔드 프로젝트를 개발 중이시라면, 오늘 꼭 불필요한 이벤트 호출이 폭주하고 있지는 않은지 점검해 보시길 강력히 추천합니다!&lt;/p&gt;</description>
      <category>트러블슈팅</category>
      <category>debounce</category>
      <category>javascript</category>
      <category>Throttle</category>
      <category>개발자일기</category>
      <category>디바운스</category>
      <category>스로틀</category>
      <category>웹개발</category>
      <category>웹성능최적화</category>
      <category>자바스크립트</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/52</guid>
      <comments>https://minari98.tistory.com/entry/Web-%EC%8A%A4%ED%81%AC%EB%A1%A4-%ED%95%9C-%EB%B2%88%EC%97%90-%ED%95%A8%EC%88%98%EA%B0%80-500%EB%B2%88-%EC%8B%A4%ED%96%89%EB%90%9C%EB%8B%A4%EA%B3%A0-%EB%94%94%EB%B0%94%EC%9A%B4%EC%8A%A4%EC%99%80-%EC%8A%A4%EB%A1%9C%ED%8B%80%EB%A1%9C-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%89-%EC%9E%A1%EC%9D%80-%ED%9B%84%EA%B8%B0#entry52comment</comments>
      <pubDate>Wed, 19 Aug 2026 14:16:50 +0900</pubDate>
    </item>
    <item>
      <title>[Network] &amp;quot;Access to fetch at... blocked by CORS policy&amp;quot; 프론트엔드 개발자의 최대 난적 CORS 에러 해결기</title>
      <link>https://minari98.tistory.com/entry/Network-Access-to-fetch-at-blocked-by-CORS-policy-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9D%98-%EC%B5%9C%EB%8C%80-%EB%82%9C%EC%A0%81-CORS-%EC%97%90%EB%9F%AC-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
      <description>&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드 개발을 처음 배우고 백엔드 API와 통신을 연결하던 날, 데이터가 화면에 멋지게 뜰 것이라는 기대와 달리 브라우저 콘솔창에는 시뻘건 에러 메시지가 떴습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5&quot;&gt;Access to fetch at 'http://localhost:5000/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.&lt;/b&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;분명 포스트맨(Postman)으로 테스트했을 때는 데이터가 아주 잘 넘어왔는데, 브라우저에서 fetch나 axios로 요청만 날리면 무조건 에러가 터지는 기이한 현상에 밤을 새워가며 삽질했던 기억이 납니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 초보 개발자들의 멘탈을 흔드는 &lt;b data-index-in-node=&quot;21&quot; data-path-to-node=&quot;7&quot;&gt;CORS(Cross-Origin Resource Sharing)의 진짜 동작 원리와, 실무 및 개발 환경에서 이를 깔끔하게 해결하는 3가지 방법&lt;/b&gt;을 정리해 봅니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. 내가 마주친 문제: &quot;Postman에서는 되는데 왜 브라우저에서만 안 될까?&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;당시 React 개발 서버는 http://localhost:3000에서 돌고 있었고, 백엔드 Expres&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;s 서버는 http://localhost:5000에서 돌고 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;11&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,0,0&quot;&gt;Postman 요청 시:&lt;/b&gt; 상태 코드 200 OK와 함께 유저 데이터가 정상 수신됨.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,1,0&quot;&gt;리액트 앱에서 요청 시:&lt;/b&gt; 네트워크 요청 실패 및 빨간색 CORS 에러 발생.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;  CORS는 '서버의 에러'가 아니라 '브라우저의 보안 정책'이다!&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;많은 분들이 CORS 에러가 뜨면 백엔드 서버가 다운되었거나 잘못 만들어졌다고 오해합니다. 하지만 CORS 에러를 띄우는 주체는 '웹 브라우저'입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;웹 브라우저는 보안상의 이유로 SOP(Same-Origin Policy, 동일 출처 정책)를 따릅니다. 즉, 현재 웹 페이지가 열려 있는 출처(Origin = 프로토콜 + 도메인 + 포트번호)와 다른 출처로 데이터를 요청할 경우, 서버가 명시적으로 *&quot;이 출처의 요청을 허용한다&quot;*는 응답 헤더를 내려주지 않으면 브라우저가 응답을 차단해 버리는 것입니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size26&quot;&gt;2. 해결 방법 1: 백엔드 서버에서 CORS 헤더 허용하기 (가장 정석)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;가장 기본적이고 정석적인 해결법은 백엔드 서버의 응답 헤더에 Access-Control-Allow-Origin을 추가해 주는 것입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;Node.js(Express) 환경이라면 cors 미들웨어를 설치해 간단히 해결할 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQggQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// Express 백엔드 서버 설정 (server.js)
const express = require('express');
const cors = require('cors');
const app = express();

// ⭕ 특정 프론트엔드 출처(Origin)만 안전하게 허용
app.use(
  cors({
    origin: 'http://localhost:3000', // 프론트엔드 주소
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    credentials: true, // 쿠키/인증 헤더 포함 허용
  })
);

app.get('/api/users', (req, res) =&amp;gt; {
  res.json({ message: 'CORS 설정 성공!' });
});

app.listen(5000, () =&amp;gt; console.log('백엔드 서버 가동 (Port 5000)'));
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;3. 해결 방법 2: 프론트엔드 개발 서버 프록시(Proxy) 설정하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;백엔드 코드를 당장 수정할 권한이 없거나, 외부 공공 API를 로컬에서 테스트해야 할 때는 &lt;b data-index-in-node=&quot;51&quot; data-path-to-node=&quot;22&quot;&gt;프론트엔드 개발 서버의 프록시(Proxy) 기능&lt;/b&gt;을 활용하는 것이 최고입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;브라우저는 같은 출처인 localhost:3000/api로 요청을 보내고, 프론트엔드 개발 서버(Node.js)가 대신 백엔드로 요청을 전달하여 받아오기 때문에 브라우저의 CORS 검열을 완벽히 우회할 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQgwQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JSON&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// package.json (Create React App 기준)
{
  &quot;name&quot;: &quot;my-frontend-app&quot;,
  &quot;version&quot;: &quot;0.1.0&quot;,
  &quot;proxy&quot;: &quot;http://localhost:5000&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;또는 Vite를 사용 중이라면 vite.config.ts 파일에서 아래와 같이 깔끔하게 설정할 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQhAQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:5000',
        changeOrigin: true,
      },
    },
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size26&quot;&gt;4. 해결 방법 3: Next.js API Routes (BFF 패턴) 활용하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;Next.js를 사용하는 풀스택/프론트엔드 환경이라면 프레임워크 자체의 API 라우트를 일종의 중간 서버(BFF, Backend For Frontend)로 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;브라우저 ➔ Next.js 서버(서버 사이드) ➔ 외부 백엔드 API 순서로 통신하면, 서버 간 통신에는 브라우저의 CORS 제약이 적용되지 않으므로 아주 안전하게 데이터를 가져올 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQhQQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/api/proxy-user/route.ts (Next.js App Router API Route)
import { NextResponse } from 'next/server';

export async function GET() {
  // 서버 사이드에서 외부 API 호출 (CORS 제약 없음)
  const response = await fetch('http://localhost:5000/api/users');
  const data = await response.json();

  return NextResponse.json(data);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-14 오후 6.24.54.png&quot; data-origin-width=&quot;990&quot; data-origin-height=&quot;552&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lSTWo/dJMcafgEiAY/73BHFedchRKQiidgc6RMgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lSTWo/dJMcafgEiAY/73BHFedchRKQiidgc6RMgK/img.png&quot; data-alt=&quot;200 OK 성공 응답 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lSTWo/dJMcafgEiAY/73BHFedchRKQiidgc6RMgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlSTWo%2FdJMcafgEiAY%2F73BHFedchRKQiidgc6RMgK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;990&quot; height=&quot;552&quot; data-filename=&quot;스크린샷 2026-08-14 오후 6.24.54.png&quot; data-origin-width=&quot;990&quot; data-origin-height=&quot;552&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;200 OK 성공 응답 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;35&quot; data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;5. 결론 및 느낀 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;CORS 에러는 처음에 만나면 당황스럽고 막막하지만, &quot;브라우저가 사용자를 악의적인 크로스 사이트 요청(CSRF/XSS 등)으로부터 보호하기 위해 작동하는 정상적인 보안 방패&quot;라는 점을 이해하면 대처 방법이 명확해집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;37&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;개발 환경에서는 프론트엔드 &lt;b data-index-in-node=&quot;15&quot; data-path-to-node=&quot;37,0,0&quot;&gt;Proxy 설정&lt;/b&gt;으로 빠르게 개발하고,&lt;/li&gt;
&lt;li&gt;배포 환경에서는 백엔드 서버의 &lt;b data-index-in-node=&quot;17&quot; data-path-to-node=&quot;37,1,0&quot;&gt;CORS 미들웨어 및 Nginx 리버스 프록시&lt;/b&gt;로 명확한 도메인을 허용해 주는 것이 가장 이상적입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;이제 브라우저 콘솔창에 빨간색 CORS 에러가 떠도 당황하지 마시고, 프록시 설정이나 서버 헤더부터 침착하게 점검해 보세요!&lt;/p&gt;</description>
      <category>트러블슈팅</category>
      <category>cors</category>
      <category>Express</category>
      <category>nextjs</category>
      <category>React</category>
      <category>개발자일기</category>
      <category>네트워크</category>
      <category>브라우저동작원리</category>
      <category>웹개발</category>
      <category>웹보안</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/51</guid>
      <comments>https://minari98.tistory.com/entry/Network-Access-to-fetch-at-blocked-by-CORS-policy-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9D%98-%EC%B5%9C%EB%8C%80-%EB%82%9C%EC%A0%81-CORS-%EC%97%90%EB%9F%AC-%ED%95%B4%EA%B2%B0%EA%B8%B0#entry51comment</comments>
      <pubDate>Mon, 17 Aug 2026 19:26:11 +0900</pubDate>
    </item>
    <item>
      <title>[Git/GitHub] 커밋했는데 잔디가 안 심어진다? 5분 만에 해결한 Git 이메일 연동 삽질기</title>
      <link>https://minari98.tistory.com/entry/GitGitHub-%EC%BB%A4%EB%B0%8B%ED%96%88%EB%8A%94%EB%8D%B0-%EC%9E%94%EB%94%94%EA%B0%80-%EC%95%88-%EC%8B%AC%EC%96%B4%EC%A7%84%EB%8B%A4-5%EB%B6%84-%EB%A7%8C%EC%97%90-%ED%95%B4%EA%B2%B0%ED%95%9C-Git-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EC%97%B0%EB%8F%99-%EC%82%BD%EC%A7%88%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;개발 공부를 시작하고 매일 1일 1커밋을 다짐하며 깃허브(GitHub) 프로필에 초록색 잔디를 채워나가는 재미를 붙이던 중이었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;어느 날 평소처럼 터미널에서 코드를 수정하고 git add, git commit, git push를 마친 뒤 뿌듯한 마음으로 깃허브 프로필을 열었는데, &lt;b data-index-in-node=&quot;85&quot; data-path-to-node=&quot;5&quot;&gt;커밋 기록은 분명 원격 저장소에 올라가 있는데 프로필 잔디밭에는 초록불이 전혀 심어지지 않는 당혹스러운 상황&lt;/b&gt;을 마주쳤습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;깃허브 서버 반영이 늦나?&quot; 싶어 몇 시간을 기다려보기도 하고, 컴퓨터를 껐다 켜보기도 했지만 잔디는 여전히 하얗게 비어 있었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 제가 깃허브 잔디 누락 문제로 멘붕을 겪으며 찾아낸 &lt;b data-index-in-node=&quot;33&quot; data-path-to-node=&quot;7&quot;&gt;진짜 원인(로컬 Git 이메일 불일치)과, 5분 만에 깔끔하게 해결했던 설정 방법&lt;/b&gt;을 정리해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. 내가 마주친 문제: &quot;커밋은 올라갔는데 왜 내 프로필에 반영이 안 될까?&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;깃허브 저장소(Repository)의 Commits 탭에 들어가 보니, 제가 푸시한 커밋 내역 자체는 정상적으로 올라와 있었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;하지만 커밋 작성자 프로필 사진이 제 깃허브 아바타가 아니라 &lt;b data-index-in-node=&quot;34&quot; data-path-to-node=&quot;11&quot;&gt;기본 회색 실루엣 아이콘&lt;/b&gt;으로 떠 있었고, 작성자 이름을 클릭해도 제 깃허브 프로필 페이지로 링크가 연결되지 않았습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;  깃허브가 잔디를 심어주는 핵심 원리&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;깃허브는 푸시된 커밋의 작성자 이메일 주소(Author Email)를 보고 해당 계정의 잔디밭에 초록불을 심어줍니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;14&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;내 컴퓨터(로컬)의 Git에 설정된 이메일 주소&lt;/li&gt;
&lt;li&gt;실제 깃허브 계정에 등록된 이메일 주소&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;이 둘이 &lt;b data-index-in-node=&quot;5&quot; data-path-to-node=&quot;15&quot;&gt;단 한 글자라도 다르면&lt;/b&gt; 깃허브는 &quot;이 커밋은 다른 사람이 작성한 것&quot;으로 인식하여 내 프로필 잔디를 채워주지 않는 것입니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;2. 해결 단계 1: 내 로컬 Git 설정 이메일 확인하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;터미널을 열고 현재 내 컴퓨터의 Git 전역 설정(Global Config)에 어떤 이메일이 등록되어 있는지 확인해 보았습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQ0wM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 터미널에서 현재 설정된 Git 유저 이름과 이메일 확인 명령어
git config --global user.name
git config --global user.email
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;확인해 보니 예전에 임의로 입력했던 오타 섞인 이메일이나, 현재 깃허브 계정과는 다른 보조 이메일이 등록되어 있었습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;3. 해결 단계 2: 깃허브 실제 이메일과 일치시키기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;원인을 알았으니 로컬 Git의 이메일을 깃허브 계정에 등록된 실제 이메일과 완벽하게 일치하도록 수정해 주었습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQ1AM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 1. 깃허브 계정 이메일과 동일하게 전역 설정 업데이트
git config --global user.name &quot;내_깃허브_닉네임&quot;
git config --global user.email &quot;내_깃허브_가입이메일@example.com&quot;

# 2. 변경된 내용이 잘 반영되었는지 확인
git config --global --list
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;  꿀팁: 깃허브 이메일 비공개(Keep my email address private) 설정 유저라면?&lt;/h3&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;만약 깃허브 설정(Settings &amp;gt; Emails)에서 개인정보 보호를 위해 이메일 비공개 옵션을 켜두셨다면, 깃허브에서 발급해 주는 &lt;b data-index-in-node=&quot;75&quot; data-path-to-node=&quot;27&quot;&gt;ID+username@users.noreply.github.com 형태의 노리플라이 이메일&lt;/b&gt;을 user.email에 등록하셔야 정상적으로 잔디가 심어집니다!&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size26&quot;&gt;4. 해결 확인: 새 커밋 푸시 후 잔디밭 확인하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;설정을 올바르게 바꾼 뒤, 가볍게 README.md 파일을 수정하고 다시 커밋 및 푸시를 진행해 보았습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQ1QM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;git add README.md
git commit -m &quot;docs: 깃 잔디 연동 테스트 커밋&quot;
git push origin main
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;푸시 완료 후 깃허브 프로필 페이지를 새로고침하자마자, 비어 있던 칸에 &lt;b data-index-in-node=&quot;40&quot; data-path-to-node=&quot;32&quot;&gt;선명한 초록색 잔디가 즉시 채워지는 것&lt;/b&gt;을 확인할 수 있었습니다! 커밋 내역의 프로필 아이콘도 제 프로필 사진으로 정상 연결되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-14 오후 5.47.35.png&quot; data-origin-width=&quot;478&quot; data-origin-height=&quot;274&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ooUSG/dJMcafOxojQ/Cz2wa2kCaLziXWSblwHVoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ooUSG/dJMcafOxojQ/Cz2wa2kCaLziXWSblwHVoK/img.png&quot; data-alt=&quot;깃허브 잔디밭&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ooUSG/dJMcafOxojQ/Cz2wa2kCaLziXWSblwHVoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FooUSG%2FdJMcafOxojQ%2FCz2wa2kCaLziXWSblwHVoK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;478&quot; height=&quot;274&quot; data-filename=&quot;스크린샷 2026-08-14 오후 5.47.35.png&quot; data-origin-width=&quot;478&quot; data-origin-height=&quot;274&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;깃허브 잔디밭&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;35&quot; data-ke-size=&quot;size26&quot;&gt;5. 깨달은 점 및 정리&lt;/h2&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 깃허브 시스템 오류인 줄 알고 헛걸음을 많이 했지만, Git의 사용자 식별 방식(이메일 기반 매칭)을 이해하고 나니 아주 단순하게 해결할 수 있었던 문제였습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;37&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;커밋을 올렸는데 잔디가 안 심어진다면 가장 먼저 &lt;b data-index-in-node=&quot;27&quot; data-path-to-node=&quot;37,0,0&quot;&gt;git config --global user.email&lt;/b&gt; 을 확인할 것!&lt;/li&gt;
&lt;li&gt;로컬 이메일과 깃허브 계정 이메일을 100% 일치시킬 것!&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;1일 1커밋을 실천 중인데 잔디가 심어지지 않아 스트레스받고 계신 분들이 계시다면, 지금 바로 터미널에서 이메일 설정부터 점검해 보시길 바랍니다!&lt;/p&gt;</description>
      <category>트러블슈팅</category>
      <category>1일1커밋</category>
      <category>GIT</category>
      <category>gitconfig</category>
      <category>github</category>
      <category>개발자일기</category>
      <category>개발자팁</category>
      <category>깃</category>
      <category>깃허브</category>
      <category>깃허브잔디</category>
      <category>잔디오류</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/50</guid>
      <comments>https://minari98.tistory.com/entry/GitGitHub-%EC%BB%A4%EB%B0%8B%ED%96%88%EB%8A%94%EB%8D%B0-%EC%9E%94%EB%94%94%EA%B0%80-%EC%95%88-%EC%8B%AC%EC%96%B4%EC%A7%84%EB%8B%A4-5%EB%B6%84-%EB%A7%8C%EC%97%90-%ED%95%B4%EA%B2%B0%ED%95%9C-Git-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EC%97%B0%EB%8F%99-%EC%82%BD%EC%A7%88%EA%B8%B0#entry50comment</comments>
      <pubDate>Sat, 15 Aug 2026 18:48:22 +0900</pubDate>
    </item>
    <item>
      <title>[React] useEffect의 올바른 사용법과 무한 리렌더링(Infinite Loop) 탈출기</title>
      <link>https://minari98.tistory.com/entry/React-useEffect%EC%9D%98-%EC%98%AC%EB%B0%94%EB%A5%B8-%EC%82%AC%EC%9A%A9%EB%B2%95%EA%B3%BC-%EB%AC%B4%ED%95%9C-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81Infinite-Loop-%ED%83%88%EC%B6%9C%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;리액트(React)로 프로젝트를 진행하며 API 데이터를 불러오거나 컴포넌트 생명주기(Lifecycle)를 다룰 때 가장 먼저 접하게 되는 훅(Hook)이 바로 useEffect입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;처음 useEffect를 배울 때는 &quot;그냥 컴포넌트가 화면에 나타날 때 실행하고 싶은 코드를 넣어두는 함수&quot; 정도로 가볍게 생각하곤 했습니다. 하지만 프로젝트 규모가 커지면서 어느 날 브라우저 탭이 갑자기 렉을 먹고 먹통이 되거나, 개발자 도구 콘솔에 수백 개의 API 요청 로그가 쏟아지는 &lt;b data-index-in-node=&quot;163&quot; data-path-to-node=&quot;6&quot;&gt;'무한 리렌더링(Infinite Loop)' 참사&lt;/b&gt;를 겪게 되었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 제가 초보 시절 useEffect를 오용하면서 겪었던 무한 리렌더링의 원인과, 이를 안전하고 올바르게 다루기 위한 &lt;b data-index-in-node=&quot;68&quot; data-path-to-node=&quot;7&quot;&gt;의존성 배열(Dependency Array) 관리 노하우&lt;/b&gt;를 공유해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. 내가 마주친 문제: &quot;브라우저 콘솔창이 터져나간 이유&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;특정 유저 정보를 서버에서 받아와 상태(State)에 저장하는 흔한 로직을 작성하던 중이었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;별생각 없이 useEffect 내부에서 데이터를 받아와 setState를 호출했는데, 페이지에 진입하자마자 브라우저가 느려지더니 서버로 동일한 API 요청이 초당 수십 번씩 계속 날아가는 현상이 발생했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQlAM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ❌ 무한 리렌더링을 유발했던 과거의 위험한 코드
import { useState, useEffect } from 'react';

export default function UserProfile() {
  const [user, setUser] = useState(null);

  //   두 번째 인자인 의존성 배열(Dependency Array)을 빠뜨림!
  useEffect(() =&amp;gt; {
    fetch('/api/user')
      .then((res) =&amp;gt; res.json())
      .then((data) =&amp;gt; {
        setUser(data); // 상태 변경 -&amp;gt; 컴포넌트 리렌더링 -&amp;gt; useEffect 재실행 -&amp;gt; 무한 반복!
      });
  });

  return &amp;lt;div&amp;gt;{user?.name}&amp;lt;/div&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;  무한 루프가 발생한 원인&lt;/h3&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;useEffect의 두 번째 인자인 의존성 배열([])을 완전히 생략해 버리면, 리액트는 컴포넌트가 리렌더링 될 때마다 useEffect 내부 콜백 함수를 매번 실행합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;useEffect 내부에서 setUser로 상태를 바꾸면 ➔ 리액트는 상태가 바뀌었으니 컴포넌트를 리렌더링하고 ➔ 리렌더링 되었으니 useEffect가 또 실행되어 setUser를 호출하는 &lt;b data-index-in-node=&quot;105&quot; data-path-to-node=&quot;15&quot;&gt;지옥의 악순환&lt;/b&gt;이 만들어진 것입니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;2. 해결책 1: 빈 의존성 배열([])로 '마운트 시 1회 실행' 보장하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 가장 쉽게 해결하는 방법은 useEffect 두 번째 인자로 빈 배열([])을 넘겨주는 것입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQlQM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;// ⭕ 빈 배열 []을 넣어 컴포넌트 마운트 시 최초 1회만 실행되도록 수정
useEffect(() =&amp;gt; {
  fetch('/api/user')
    .then((res) =&amp;gt; res.json())
    .then((data) =&amp;gt; setUser(data));
}, []); //   빈 의존성 배열 추가!
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 설정하면 컴포넌트가 화면에 처음 그려질 때(Mount) 딱 한 번만 API 요청을 수행하므로 안전합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-13 오후 6.47.36.png&quot; data-origin-width=&quot;974&quot; data-origin-height=&quot;750&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cg02Dk/dJMcafOwsCE/KKHbLrlkKodobGcabnsJsK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cg02Dk/dJMcafOwsCE/KKHbLrlkKodobGcabnsJsK/img.png&quot; data-alt=&quot;useEffect 두 번째 인자로 빈 배열 [] 이 들어간 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cg02Dk/dJMcafOwsCE/KKHbLrlkKodobGcabnsJsK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcg02Dk%2FdJMcafOwsCE%2FKKHbLrlkKodobGcabnsJsK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;974&quot; height=&quot;750&quot; data-filename=&quot;스크린샷 2026-08-13 오후 6.47.36.png&quot; data-origin-width=&quot;974&quot; data-origin-height=&quot;750&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;useEffect 두 번째 인자로 빈 배열 [] 이 들어간 코드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;3. 해결책 2: 객체/함수를 의존성 배열에 넣을 때 발생하는 함정 피하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;빈 배열을 넣는 것만으로 모든 문제가 해결되지는 않았습니다. 특정 조건(예: 검색어 ID 변경)에 따라 데이터를 다시 불러와야 할 때는 의존성 배열에 변수를 넣어주어야 합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;이때 &lt;b data-index-in-node=&quot;3&quot; data-path-to-node=&quot;25&quot;&gt;객체(Object)나 함수(Function)를 의존성 배열에 그대로 넣으면 다시 무한 루프가 터지는 함정&lt;/b&gt;에 빠질 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQlgM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ❌ 객체를 참조 타입으로 넣어 무한 루프가 발생하는 잘못된 예시
export default function SearchList({ options }) { // options가 객체인 경우
  const [list, setList] = useState([]);

  useEffect(() =&amp;gt; {
    fetchData(options).then((data) =&amp;gt; setList(data));
  }, [options]); //   부모가 리렌더링될 때마다 options 객체의 참조값이 새로 바뀌어 계속 실행됨!
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;자바스크립트에서 객체나 함수는 리렌더링될 때마다 메모리 주소(참조값)가 매번 새로 생성됩니다. 따라서 내용물이 똑같아도 리액트는 &quot;의존성 값이 바뀌었네?&quot; 하고 useEffect를 또 실행해 버립니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size23&quot;&gt;  해결 방법 (원시 값 전달 또는 useCallback / useMemo 활용)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;29&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;객체 전체 대신 객체 내부의 원시 값(options.id 등)을 의존성 배열에 넣기&lt;/li&gt;
&lt;li&gt;함수를 의존성 배열에 넣어야 할 경우 useCallback으로 감싸 메모이제이션하기&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQlwM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// ⭕ 객체 전체가 아닌 원시 값(id)을 의존성 배열에 넣어 안전하게 관리
useEffect(() =&amp;gt; {
  fetchData(options.id).then((data) =&amp;gt; setList(data));
}, [options.id]); //   객체의 속성값(number, string)을 지정!
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-13 오후 6.49.21.png&quot; data-origin-width=&quot;1288&quot; data-origin-height=&quot;336&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4DlCl/dJMcagNqlI0/xfnOoXE22T4h5Uao3F9pMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4DlCl/dJMcagNqlI0/xfnOoXE22T4h5Uao3F9pMk/img.png&quot; data-alt=&quot;의존성 배열 관련 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4DlCl/dJMcagNqlI0/xfnOoXE22T4h5Uao3F9pMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4DlCl%2FdJMcagNqlI0%2FxfnOoXE22T4h5Uao3F9pMk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1288&quot; height=&quot;336&quot; data-filename=&quot;스크린샷 2026-08-13 오후 6.49.21.png&quot; data-origin-width=&quot;1288&quot; data-origin-height=&quot;336&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;의존성 배열 관련 코드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size26&quot;&gt;4. 정리 및 깨달은 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;useEffect는 단순히 코드를 실행해 주는 부수 효과 도구가 아닙니다. &quot;컴포넌트의 상태 및 렌더링 주기와 외부 시스템을 동기화하는 도구&quot;입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;35&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;useEffect 내부에서 상태(state)를 변경한다면 의존성 배열이 제대로 설정되어 있는지 필수 점검할 것&lt;/li&gt;
&lt;li&gt;의존성 배열에 참조 타입(객체, 배열, 함수)을 넣을 때는 참조값 변경에 유의할 것&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;이 두 가지만 명확히 기억해 두셔도 무한 리렌더링으로 브라우저가 다운되는 불상사를 완벽히 예방할 수 있습니다!&lt;/p&gt;</description>
      <category>트러블슈팅</category>
      <category>javascript</category>
      <category>React</category>
      <category>useEffect</category>
      <category>개발자일기</category>
      <category>개발자팁</category>
      <category>리액트</category>
      <category>무한리렌더링</category>
      <category>웹개발</category>
      <category>자바스크립트</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/49</guid>
      <comments>https://minari98.tistory.com/entry/React-useEffect%EC%9D%98-%EC%98%AC%EB%B0%94%EB%A5%B8-%EC%82%AC%EC%9A%A9%EB%B2%95%EA%B3%BC-%EB%AC%B4%ED%95%9C-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81Infinite-Loop-%ED%83%88%EC%B6%9C%EA%B8%B0#entry49comment</comments>
      <pubDate>Thu, 13 Aug 2026 18:51:12 +0900</pubDate>
    </item>
    <item>
      <title>[VS Code] 프론트엔드 개발 생산성을 2배로 올려주는 필수 확장 프로그램 5선</title>
      <link>https://minari98.tistory.com/entry/VS-Code-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%83%9D%EC%82%B0%EC%84%B1%EC%9D%84-2%EB%B0%B0%EB%A1%9C-%EC%98%AC%EB%A0%A4%EC%A3%BC%EB%8A%94-%ED%95%84%EC%88%98-%ED%99%95%EC%9E%A5-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8-5%EC%84%A0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프런트엔드 개발을 처음 시작했을 때, 기본 설정 상태의 VS Code(Visual Studio Code)만 사용하며 일일이 닫는 태그를 직접 타이핑하고 코드 들여 쓰기(Indent) 줄 맞춤을 일일이 Tab키로 맞추곤 했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;그러다 보니 단순 오타나 태그 짝이 맞지 않아 발생하는 소소한 에러를 잡는 데 많은 시간을 허비했고, 자연스럽게 개발 피로도도 높아졌습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;이후 실무 프로젝트를 거치며 나에게 딱 맞는 VS Code 확장 프로그램(Extensions)들을 하나씩 찾아 세팅하게 되었고, 반복적이고 소모적인 단순 작업량이 획기적으로 줄어드는 것을 경험했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 프론트엔드 개발자로서 &lt;b data-index-in-node=&quot;16&quot; data-path-to-node=&quot;7&quot;&gt;실제 코딩 생산성을 2배 이상 끌어올려 주었던 VS Code 필수 익스텐션 5가지와 실무 추천 설정&lt;/b&gt;을 소개해 드립니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. Prettier - Code formatter (코드 스타일 자동 정돈)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;프런트엔드 개발자에게 가장 기본이자 필수적인 익스텐션입니다. 홑따옴표/큰따옴표 혼용, 세미콜론 누락, 들여 쓰기 불일치 등 지저분한 코드 스타일을 저장(Ctrl + S / Cmd + S) 한 번으로&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;깔끔하게 자동 정돈해 줍니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiIxqrBzJWWAxUAAAAAHQAAAAAQqgI&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JSON&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// .prettierrc (프로젝트 루트에 추가하는 추천 설정 파일)
{
  &quot;singleQuote&quot;: true,
  &quot;semi&quot;: true,
  &quot;tabWidth&quot;: 2,
  &quot;trailingComma&quot;: &quot;es5&quot;,
  &quot;printWidth&quot;: 80
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;  실무 팁: Format On Save 설정&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;VS Code 설정(Ctrl + ,)에서 Format On Save 항목을 체크해 두면, 저장할 때마다 자동으로 코드가 이쁘게 정렬되어 코드 리뷰 시 스타일 차이로 인한 소모적 논쟁을 완벽히 방지해 줍니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;2. ESLint (자바스크립트/리액트 코드 오류 사전 감지)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;Prettier가 코드 '모양(스타일)'을 잡아준다면, ESLint는 코드의 '문법 오류 및 잠재적 버그'를 사전에 감지해 주는 파수꾼입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;선언만 하고 쓰지 않은 변수, 리액트 useEffect 의존성 배열 누락, 잘못된 비동기 처리 등을 에디터 상에서 실시간 빨간 줄로 알려주어 런타임 에러를 획기적으로 줄여줍니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-10 오후 5.57.59.png&quot; data-origin-width=&quot;670&quot; data-origin-height=&quot;448&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpjTem/dJMcaiRTSlg/TfcMYDX3hJoPtbwhbwU0L1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpjTem/dJMcaiRTSlg/TfcMYDX3hJoPtbwhbwU0L1/img.png&quot; data-alt=&quot;Prettier와 ESLint 설정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpjTem/dJMcaiRTSlg/TfcMYDX3hJoPtbwhbwU0L1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpjTem%2FdJMcaiRTSlg%2FTfcMYDX3hJoPtbwhbwU0L1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;670&quot; height=&quot;448&quot; data-filename=&quot;스크린샷 2026-08-10 오후 5.57.59.png&quot; data-origin-width=&quot;670&quot; data-origin-height=&quot;448&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Prettier와 ESLint 설정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size26&quot;&gt;3. Auto Rename Tag &amp;amp; Auto Close Tag (HTML/JSX 태그 자동 관리)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;HTML이나 리액트 JSX 작성 시 생산성을 폭발적으로 올려주는 효자 익스텐션 커플입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;22&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,0,0&quot;&gt;Auto Close Tag:&lt;/b&gt; &amp;lt;div를 입력하면 자동으로 &amp;lt;/div&amp;gt; 닫는 태그를 만들어 줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,1,0&quot;&gt;Auto Rename Tag:&lt;/b&gt; 여는 태그를 &amp;lt;div&amp;gt;에서 &amp;lt;section&amp;gt;으로 수정하면, 대응되는 닫는 태그도 자동으로 &amp;lt;/section&amp;gt;으로 변경됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;태그 이름 하나 바꾸려고 스크롤 내려서 닫는 태그를 찾아 다닐 필요가 없어져 타자 속도가 대폭 단축됩니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;25&quot; data-ke-size=&quot;size26&quot;&gt;4. GitLens &amp;mdash; Git supercharged (코드 작성자 및 변경 이력 추적)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;팀 프로젝트나 협업 진행 시 강력한 빛을 발하는 도구입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;코드 각 라인 끝에 &lt;b data-index-in-node=&quot;11&quot; data-path-to-node=&quot;27&quot;&gt;&quot;누가, 언제, 어떤 커밋 메시지로 이 코드를 수정했는지&quot;&lt;/b&gt; 가볍게 표시해 줍니다. 과거 코드를 고치거나 왜 이 로직이 들어갔는지 궁금할 때 Git 커밋 히스토리를 찾아볼 필요 없이 마우스만 올려도 맥락을 바로 파악할 수 있어 협업 생산성이 대폭 증가합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size26&quot;&gt;5. Tailwind CSS IntelliSense (Tailwind 클래스 자동완성)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;Tailwind CSS를 사용하는 프론트엔드 개발자라면 필수로 깔아야 할 확장 프로그램입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;className=&quot;flex items-center ...&quot; 작성 시 사용 가능한 유틸리티 클래스 목록을 자동으로 추천해 주고, 해당 클래스가 실제 어떤 CSS 속성을 가졌는지 마우스를 올리면 미리 보기 패널로 보여줍니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-11 오전 10.06.34.png&quot; data-origin-width=&quot;1928&quot; data-origin-height=&quot;584&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CEgtV/dJMcajpBkSX/oXmRMtWtuRAbT0nxHqyjZ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CEgtV/dJMcajpBkSX/oXmRMtWtuRAbT0nxHqyjZ0/img.png&quot; data-alt=&quot;Tailwind CSS 클래스 자동완성(IntelliSense) 팝업 창&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CEgtV/dJMcajpBkSX/oXmRMtWtuRAbT0nxHqyjZ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCEgtV%2FdJMcajpBkSX%2FoXmRMtWtuRAbT0nxHqyjZ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1928&quot; height=&quot;584&quot; data-filename=&quot;스크린샷 2026-08-11 오전 10.06.34.png&quot; data-origin-width=&quot;1928&quot; data-origin-height=&quot;584&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Tailwind CSS 클래스 자동완성(IntelliSense) 팝업 창&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;34&quot; data-ke-size=&quot;size26&quot;&gt;  회고 및 요약&lt;/h2&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;좋은 도구를 선택하고 내 개발 환경에 맞춰 세팅하는 것 역시 개발자의 중요한 역량 중 하나입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;오늘 소개해 드린 익스텐션들을 적극 활용하여 &lt;b data-index-in-node=&quot;25&quot; data-path-to-node=&quot;36&quot;&gt;단순 오타나 스타일 정돈에 드는 시간을 아끼고, 진짜 중요한 핵심 비즈니스 로직 작성에 집중하는 쾌적한 개발 환경&lt;/b&gt;을 만들어 보시길 강력히 추천합니다!&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>eslint</category>
      <category>gitlens</category>
      <category>Prettier</category>
      <category>tailwindcss</category>
      <category>vscode</category>
      <category>개발도구</category>
      <category>개발자일기</category>
      <category>개발자팁</category>
      <category>웹개발</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/48</guid>
      <comments>https://minari98.tistory.com/entry/VS-Code-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%83%9D%EC%82%B0%EC%84%B1%EC%9D%84-2%EB%B0%B0%EB%A1%9C-%EC%98%AC%EB%A0%A4%EC%A3%BC%EB%8A%94-%ED%95%84%EC%88%98-%ED%99%95%EC%9E%A5-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8-5%EC%84%A0#entry48comment</comments>
      <pubDate>Tue, 11 Aug 2026 10:07:07 +0900</pubDate>
    </item>
    <item>
      <title>[TypeScript] &amp;quot;빨간 줄과의 전쟁&amp;quot; 초보 개발자가 자주 범하는 타입 에러 3가지와 해결법</title>
      <link>https://minari98.tistory.com/entry/TypeScript-%EB%B9%A8%EA%B0%84-%EC%A4%84%EA%B3%BC%EC%9D%98-%EC%A0%84%EC%9F%81-%EC%B4%88%EB%B3%B4-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EC%9E%90%EC%A3%BC-%EB%B2%94%ED%95%98%EB%8A%94-%ED%83%80%EC%9E%85-%EC%97%90%EB%9F%AC-3%EA%B0%80%EC%A7%80%EC%99%80-%ED%95%B4%EA%B2%B0%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;자바스크립트(JavaScript)로만 프로젝트를 진행하다가 안정성을 높이기 위해 타입스크립트(TypeScript)를 처음 도입했을 때, 기쁨보다 당혹감이 훨씬 컸습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;분명 자바스크립트에서는 아무 문제 없이 잘 돌아가던 코드인데, 타입스크립트를 적용하자마자 VS Code 에디터 온 동네에 빨간 줄(Type Error)이 그어지며 화면이 온통 경고 메시지로 뒤덮였기 때문입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;당시에는 &quot;개발 속도만 늦어지는 것 같은데 왜 쓸까?&quot; 싶었지만, 타입 에러를 하나씩 해결해 나가면서 &lt;b data-index-in-node=&quot;57&quot; data-path-to-node=&quot;6&quot;&gt;런타임(실행 시점)에서 터질 위험한 버그를 코드 작성 단계에서 미리 잡아주는 강력함&lt;/b&gt;을 체감하게 되었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 제가 타입스크립트 초보 시절 가장 자주 마주치며 식은땀을 흘렸던 &lt;b data-index-in-node=&quot;40&quot; data-path-to-node=&quot;7&quot;&gt;대표적인 타입 에러 3가지와 완벽한 해결법&lt;/b&gt;을 정리해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. 에러 1: Object is possibly 'null' or 'undefined'&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;API 데이터를 받아오거나 DOM 요소를 참조할 때 가장 흔하게 만나는 에러입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwi2oICD4fuVAxUAAAAAHQAAAAAQ6gE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// ❌ 빨간 줄이 뜨는 과거의 코드
const nameLength = response.user.name.length;
//   Error: 'response.user' is possibly 'undefined'.
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;  원인 및 해결책&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;타입스크립트는 response.user 데이터가 서버 응답 지연이나 에러로 인해 undefined 상태일 가능성을 미리 경고해 주는 것입니다. 만약 이 상태로 자바스크립트에서 실행되었다면 런타임에 그 악명 높은 Cannot read properties of undefined 에러가 터졌을 것입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;해결을 위해 옵셔널 체이닝(?.)이나 타입 가드(Type Guard)를 활용하면 깔끔하게 해결됩니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwi2oICD4fuVAxUAAAAAHQAAAAAQ6wE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// ⭕ 옵셔널 체이닝으로 안전하게 접근
const nameLength = response.user?.name?.length;

// ⭕ 또는 타입 가드(if문)로 안전성 보장
if (response.user &amp;amp;&amp;amp; response.user.name) {
  const nameLength = response.user.name.length;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;2. 에러 2: Property 'X' does not exist on type 'Y' (이벤트 객체 / 객체 키 접근 에러)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;리액트에서 onChange 이벤트를 처리하거나, 객체의 키(Key)로 동적 접근을 시도할 때 자주 발생하는 에러입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwi2oICD4fuVAxUAAAAAHQAAAAAQ7AE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;fortran&quot;&gt;&lt;code&gt;// ❌ e.target.value에서 빨간 줄이 뜨는 코드
const handleChange = (e) =&amp;gt; {
  console.log(e.target.value);
  //   Error: Parameter 'e' implicitly has an 'any' type.
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&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;스크린샷 2026-08-05 오전 10.23.20.png&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;472&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQbmgS/dJMcagsVbLU/yPnK3etjc9gPQPd8Xe1Tb1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQbmgS/dJMcagsVbLU/yPnK3etjc9gPQPd8Xe1Tb1/img.png&quot; data-alt=&quot;타입을 잘 지정해 에러가 안나는 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQbmgS/dJMcagsVbLU/yPnK3etjc9gPQPd8Xe1Tb1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQbmgS%2FdJMcagsVbLU%2FyPnK3etjc9gPQPd8Xe1Tb1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1472&quot; height=&quot;472&quot; data-filename=&quot;스크린샷 2026-08-05 오전 10.23.20.png&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;472&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;타입을 잘 지정해 에러가 안나는 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size23&quot;&gt;  원인 및 해결책&lt;/h3&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;타입스크립트는 매개변수 e가 어떤 이벤트 객체인지 알지 못하므로 암묵적인 any 타입 경고를 보냅니다. 리액트에서 제공하는 이벤트 타입(React.ChangeEvent 등)을 명시해 주어야 합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwi2oICD4fuVAxUAAAAAHQAAAAAQ7QE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ⭕ React의 HTMLInputElement 타입을 정확히 명시
const handleChange = (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
  console.log(e.target.value); // 이제 에러 없이 자동완성까지 지원됨!
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-path-to-node=&quot;25&quot; data-ke-size=&quot;size26&quot;&gt;3. 에러 3: Type 'X' is not assignable to type 'Y' (상태 초기값 타입 불일치)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;useState를 사용할 때 초기값 타입과 나중에 넣어줄 API 데이터의 타입이 달라서 발생하는 에러입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwi2oICD4fuVAxUAAAAAHQAAAAAQ7gE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// ❌ null로 초기화해 두었더니 나중에 객체를 넣을 때 에러 발생
interface UserProfile {
  id: number;
  name: string;
}

const [user, setUser] = useState(null);

// 나중에 API 응답 후 setUser 호출 시 에러!
setUser({ id: 1, name: '김개발' }); 
//   Error: Argument of type '{ id: number; name: string; }' is not assignable to parameter of type 'null'.
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size23&quot;&gt;  원인 및 해결책&lt;/h3&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;useState(null)로 작성하면 타입스크립트는 user 변수의 타입을 오직 null로만 추론해 버립니다. 제네릭(&amp;lt;T&amp;gt;)을 사용해 &quot;초기엔 null이지만 나중에 UserProfile 객체가 들어올 수 있다&quot;라고 명시해 주어야 합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwi2oICD4fuVAxUAAAAAHQAAAAAQ7wE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;// ⭕ 제네릭과 유니온 타입을 활용해 두 가지 가능성을 모두 열어둠
const [user, setUser] = useState&amp;lt;UserProfile | null&amp;gt;(null);

// 이제 객체를 넣어도 빨간 줄 없이 정상 동작!
setUser({ id: 1, name: '김개발' });
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&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;스크린샷 2026-08-05 오후 1.22.31.png&quot; data-origin-width=&quot;1276&quot; data-origin-height=&quot;542&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bd5AGN/dJMcahel00F/koag6GcwBGBVAlVeKkPank/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bd5AGN/dJMcahel00F/koag6GcwBGBVAlVeKkPank/img.png&quot; data-alt=&quot;useState에 타입추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bd5AGN/dJMcahel00F/koag6GcwBGBVAlVeKkPank/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbd5AGN%2FdJMcahel00F%2Fkoag6GcwBGBVAlVeKkPank%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1276&quot; height=&quot;542&quot; data-filename=&quot;스크린샷 2026-08-05 오후 1.22.31.png&quot; data-origin-width=&quot;1276&quot; data-origin-height=&quot;542&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;useState에 타입추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size26&quot;&gt;4. 회고 및 느낀 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;타입스크립트를 처음 시작했을 때는 일일이 타입을 적어주는 행위가 개발 속도를 갉아먹는 걸림돌처럼 느껴졌습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;하지만 수많은 빨간 줄과 싸우고 해결해 나가는 과정에서 &quot;내가 작성하는 코드의 데이터 흐름을 훨씬 객관적이고 안전하게 파악하는 습관&quot;을 가질 수 있게 되었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;지금 타입스크립트의 빨간 줄 때문에 스트레스를 받고 계신 초보 개발자분들이 계시다면, 에러 메시지를 무서워하지 마시고 &lt;b data-index-in-node=&quot;66&quot; data-path-to-node=&quot;36&quot;&gt;&quot;타입스크립트가 미래의 런타임 에러를 대신 막아주고 있구나!&quot;&lt;/b&gt; 하고 긍정적으로 바라보시면 될 것 같습니다!&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>React</category>
      <category>TypeScript</category>
      <category>useState</category>
      <category>개발자일기</category>
      <category>개발자팁</category>
      <category>리액트</category>
      <category>웹개발</category>
      <category>타입스크립트</category>
      <category>타입에러</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/47</guid>
      <comments>https://minari98.tistory.com/entry/TypeScript-%EB%B9%A8%EA%B0%84-%EC%A4%84%EA%B3%BC%EC%9D%98-%EC%A0%84%EC%9F%81-%EC%B4%88%EB%B3%B4-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EC%9E%90%EC%A3%BC-%EB%B2%94%ED%95%98%EB%8A%94-%ED%83%80%EC%9E%85-%EC%97%90%EB%9F%AC-3%EA%B0%80%EC%A7%80%EC%99%80-%ED%95%B4%EA%B2%B0%EB%B2%95#entry47comment</comments>
      <pubDate>Sat, 8 Aug 2026 14:23:44 +0900</pubDate>
    </item>
    <item>
      <title>[JS] for문만 돌리던 내가 map, filter, reduce를 자유자재로 쓰게 된 계기</title>
      <link>https://minari98.tistory.com/entry/JS-for%EB%AC%B8%EB%A7%8C-%EB%8F%8C%EB%A6%AC%EB%8D%98-%EB%82%B4%EA%B0%80-map-filter-reduce%EB%A5%BC-%EC%9E%90%EC%9C%A0%EC%9E%90%EC%9E%AC%EB%A1%9C-%EC%93%B0%EA%B2%8C-%EB%90%9C-%EA%B3%84%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프런트엔드 개발을 처음 시작하고 자바스크립트(JavaScript)로 데이터를 다룰 때, 제 코드의 90%는 for문이나 forEach문으로 가득 차 있었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;백엔드 API에서 받아온 리스트 데이터를 화면에 뿌려주거나, 특정 조건의 아이템만 걸러내고, 총합을 계산할 때도 항상 습관적으로 빈 배열을 하나 선언해 두고 for문을 돌리곤 했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;동작은 잘 되었지만, 코드가 수십 줄로 길어지면서 변수가 오염되거나 가독성이 뚝 떨어지는 문제를 경험했습니다. 그러다 리액트(React)를 본격적으로 다루면서 &lt;b data-index-in-node=&quot;89&quot; data-path-to-node=&quot;6&quot;&gt;map, filter, reduce 같은 고차 배열 함수&lt;/b&gt;의 진짜 위력을 깨닫게 되었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 제가 실무 및 프로젝트 코딩 중 for문 지옥에서 탈출해 &lt;b data-index-in-node=&quot;36&quot; data-path-to-node=&quot;7&quot;&gt;배열 메서드 3대장을 실무에서 깔끔하게 활용하게 된 경험과 작성 팁&lt;/b&gt;을 정돈해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. map: 기존 배열을 변형해 새로운 UI 데이터 만들기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;처음 리액트로 목록(List) 컴포넌트를 만들 때 map을 만나고 상당한 충격을 받았습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;기존에는 빈 배열을 만들어서 push()로 일일이 담아야 했던 작업이, map을 사용하니 &lt;b data-index-in-node=&quot;50&quot; data-path-to-node=&quot;11&quot;&gt;원본 배열을 훼손하지 않으면서(불변성 유지) 가공된 새로운 배열을 반환&lt;/b&gt;해 주는 깔끔한 코드로 변했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwi2oICD4fuVAxUAAAAAHQAAAAAQqgE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;// ❌ 옛날에 작성했던 지저분한 for문 방식
const rawUsers = [
  { id: 1, name: '김철수', age: 24 },
  { id: 2, name: '이영희', age: 29 },
];

const userNames = [];
for (let i = 0; i &amp;lt; rawUsers.length; i++) {
  userNames.push(rawUsers[i].name);
}

// ⭕ map을 활용해 단 한 줄로 깔끔하게 처리한 코드
const userNamesWithMap = rawUsers.map((user) =&amp;gt; user.name);
// 결과: ['김철수', '이영희']
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;  실무 팁: 리액트 JSX 렌더링에서의 활용&lt;/h3&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;리액트에서는 API로 받아온 객체 배열을 화면의 HTML 요소로 1:1 변환할 때 map이 필수적입니다. 고유한 key값만 잘 챙겨주면 선언적인 유려한 코드를 작성할 수 있습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size26&quot;&gt;2. filter: 검색창과 조건별 필터링 기능 구현하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;쇼핑몰 카테고리 필터나 검색창 기능을 만들 때 가장 효자 역할을 해준 메서드가 바로 filter입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;조건식의 결과가 true인 요소들만 뽑아서 새로운 배열로 만들어주기 때문에, complex한 조건문 없이도 깔끔한 상태 업데이트가 가능해졌습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwi2oICD4fuVAxUAAAAAHQAAAAAQqwE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;const products = [
  { id: 1, name: '개발자 키보드', price: 150000, inStock: true },
  { id: 2, name: '버티컬 마우스', price: 75000, inStock: false },
  { id: 3, name: '모니터', price: 350000, inStock: true },
];

// ⭕ 재고가 있고(inStock), 가격이 20만원 이하인 상품만 필터링
const availableProducts = products.filter(
  (product) =&amp;gt; product.inStock &amp;amp;&amp;amp; product.price &amp;lt;= 200000
);
// 결과: [{ id: 1, name: '키보드', ... }]&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-04 오후 3.49.09.png&quot; data-origin-width=&quot;1652&quot; data-origin-height=&quot;422&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4phtV/dJMcafHCfY7/aq2m5bOKJ2KwfKRrVnfKk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4phtV/dJMcafHCfY7/aq2m5bOKJ2KwfKRrVnfKk0/img.png&quot; data-alt=&quot;체이닝 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4phtV/dJMcafHCfY7/aq2m5bOKJ2KwfKRrVnfKk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4phtV%2FdJMcafHCfY7%2Faq2m5bOKJ2KwfKRrVnfKk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1652&quot; height=&quot;422&quot; data-filename=&quot;스크린샷 2026-08-04 오후 3.49.09.png&quot; data-origin-width=&quot;1652&quot; data-origin-height=&quot;422&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;체이닝 코드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;3. reduce: 장바구니 총금액 계산과 데이터 그룹화의 최종병기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;처음 reduce를 접했을 때는 (accumulator, currentValue) =&amp;gt; ... 라는 생소한 문법 때문에 괜히 어렵게 느껴져 피하곤 했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;하지만 장바구니에 담긴 상품들의 &lt;b data-index-in-node=&quot;18&quot; data-path-to-node=&quot;24&quot;&gt;총결제 금액 계산&lt;/b&gt;이나 &lt;b data-index-in-node=&quot;30&quot; data-path-to-node=&quot;24&quot;&gt;특정 기준에 따른 객체 데이터 그룹화&lt;/b&gt;를 처리할 때 reduce만큼 완벽한 도구가 없다는 것을 깨달았습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwi2oICD4fuVAxUAAAAAHQAAAAAQrAE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;const cartItems = [
  { name: '맥북 케이스', price: 35000, count: 2 },
  { name: 'C타입 젠더', price: 12000, count: 1 },
  { name: '장패드', price: 18000, count: 3 },
];

// ⭕ 장바구니 총금액을 한 번에 구하는 reduce
const totalPrice = cartItems.reduce((acc, item) =&amp;gt; {
  return acc + item.price * item.count;
}, 0); // 초기값(initialValue)을 0으로 설정!

console.log(totalPrice); // 출력: 136000
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;  실무 팁: 초기값(0 또는 {})을 꼭 넣어주기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;reduce를 사용할 때 가장 흔하게 범하는 실수가 두 번째 인자인 &lt;b data-index-in-node=&quot;38&quot; data-path-to-node=&quot;27&quot;&gt;초기값&lt;/b&gt;을 빼먹는 것입니다. 빈 배열에서 초기값 없이 reduce를 호출하면 런타임 에러(TypeError)가 터질 수 있으므로, 습관적으로 초기값을 명시해 주는 것이 안전합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size26&quot;&gt;4. 메서드 체이닝(Chaining)으로 완성하는 단단한 코드&lt;/h2&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;map, filter, reduce의 진짜 매력은 이들을 사슬처럼 이어 붙이는 '메서드 체이닝'이 가능하다는 점입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwi2oICD4fuVAxUAAAAAHQAAAAAQrQE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;// ⭕ 재고 있는 상품 중 10만원 이상인 것들의 '총 가격'만 계산하기
const totalHighPriceInStock = products
  .filter((item) =&amp;gt; item.inStock) // 1. 재고 있는 것만 추출
  .filter((item) =&amp;gt; item.price &amp;gt;= 100000) // 2. 10만원 이상만 추출
  .reduce((acc, item) =&amp;gt; acc + item.price, 0); // 3. 합계 계산
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;for문으로 작성했으면 20줄이 넘어갔을 로직이, 함수형 메서드를 이어 붙이니 단 몇 줄만으로 읽기 편한 코드가 되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-04 오후 3.50.25.png&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;264&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GFjIU/dJMcahL6Ea2/rhmsQxpnZjWCDLHSXBXVk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GFjIU/dJMcahL6Ea2/rhmsQxpnZjWCDLHSXBXVk0/img.png&quot; data-alt=&quot;코드의 실행 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GFjIU/dJMcahL6Ea2/rhmsQxpnZjWCDLHSXBXVk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGFjIU%2FdJMcahL6Ea2%2FrhmsQxpnZjWCDLHSXBXVk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1168&quot; height=&quot;264&quot; data-filename=&quot;스크린샷 2026-08-04 오후 3.50.25.png&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;264&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;코드의 실행 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;35&quot; data-ke-size=&quot;size26&quot;&gt;5. 회고 및 정리&lt;/h2&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;for문 위주의 명령형 프로그래밍 방식에서 벗어나 자바스크립트의 고차 배열 함수들을 익히면서, 제 코드는 훨씬 &lt;b data-index-in-node=&quot;62&quot; data-path-to-node=&quot;36&quot;&gt;선언적(Declarative)이고 가독성이 높은 코드&lt;/b&gt;로 발전할 수 있었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;단순히 동작하는 코드를 넘어서 '읽기 편하고 유지보수하기 쉬운 코드'를 고민 중이신 자바스크립트 초보 개발자분들이라면, 오늘 당장 map, filter, reduce 체이닝 연습부터 시작해 보시길 권해드립니다!&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>Filter</category>
      <category>javascript</category>
      <category>js</category>
      <category>map</category>
      <category>React</category>
      <category>Reduce</category>
      <category>리액트</category>
      <category>배열메서드</category>
      <category>자바스크립트</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/46</guid>
      <comments>https://minari98.tistory.com/entry/JS-for%EB%AC%B8%EB%A7%8C-%EB%8F%8C%EB%A6%AC%EB%8D%98-%EB%82%B4%EA%B0%80-map-filter-reduce%EB%A5%BC-%EC%9E%90%EC%9C%A0%EC%9E%90%EC%9E%AC%EB%A1%9C-%EC%93%B0%EA%B2%8C-%EB%90%9C-%EA%B3%84%EA%B8%B0#entry46comment</comments>
      <pubDate>Wed, 5 Aug 2026 16:52:44 +0900</pubDate>
    </item>
    <item>
      <title>[성능최적화] PNG 용량 2.4MB의 공포: WebP와 AVIF로 웹사이트 로딩 속도 90% 줄여본 후기</title>
      <link>https://minari98.tistory.com/entry/%EC%84%B1%EB%8A%A5%EC%B5%9C%EC%A0%81%ED%99%94-PNG-%EC%9A%A9%EB%9F%89-24MB%EC%9D%98-%EA%B3%B5%ED%8F%AC-WebP%EC%99%80-AVIF%EB%A1%9C-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8-%EB%A1%9C%EB%94%A9-%EC%86%8D%EB%8F%84-90-%EC%A4%84%EC%97%AC%EB%B3%B8-%ED%9B%84%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹 사이트를 만들 때 보통 디자인 도구(Figma 등)에서 만든 이미지들을 그냥 PNG나 JPEG로 뽑아서 아무 생각 없이 프로젝트에 넣곤 했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;그러다 최근 진행했던 웹 서비스에서 큰 문제를 하나 만났습니다. 메인 화면에 들어가는 고화질 배너 이미지 몇 개를 PNG 파일 그대로 넣었더니, &lt;b data-index-in-node=&quot;81&quot; data-path-to-node=&quot;6&quot;&gt;이미지 파일 몇 개 용량만 합쳐도 5MB가 훌쩍 넘어가는 참사&lt;/b&gt;가 일어난 것입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;모바일로 접속했을 때 이미지가 위에서부터 천천히 스캔되듯이 지저분하게 내려오면서 로딩되는 모습을 보고 &quot;아, 이미지 포맷부터 당장 바꿔야겠다&quot;는 결심을 하게 되었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 구시대 포맷인 PNG/JPEG를 버리고 &lt;b data-index-in-node=&quot;26&quot; data-path-to-node=&quot;8&quot;&gt;차세대 포맷인 WebP와 AVIF로 전환하면서 겪었던 과정과, 실무에서 브라우저 깨짐 없이 안전하게 적용했던 노하우&lt;/b&gt;를 공유해 봅니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size26&quot;&gt;1. 내가 부딪힌 문제: &quot;화질은 포기하기 싫고, 용량은 너무 크다&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트 배너에 글씨와 그래픽이 섞여 있다 보니, 화질이 깨지는 JPEG 대신 깨끗하게 나오는 PNG 포맷을 사용했습니다. 하지만 PNG는 무손실 압축 방식이라 파일 용량이 어마무시하게 컸습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,0,0&quot;&gt;메인 배너 PNG 파일 1개 용량:&lt;/b&gt; 무려 2.4 MB&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,1,0&quot;&gt;문제점:&lt;/b&gt; 접속할 때마다 유저의 데이터와 시간을 엄청나게 갉아먹고 있었고, 네트워크가 조금만 느려도 화면이 텅 비어 보이는 현상 발생.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;화질을 떨어뜨리지 않으면서 용량만 획기적으로 줄일 수 있는 방법을 찾다가 구글이 만든 &lt;b data-index-in-node=&quot;48&quot; data-path-to-node=&quot;13&quot;&gt;WebP&lt;/b&gt;와 차세대 비디오 코덱 기반의 &lt;b data-index-in-node=&quot;69&quot; data-path-to-node=&quot;13&quot;&gt;AVIF&lt;/b&gt; 포맷을 알게 되었습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;2. 해결책 1: HTML &amp;lt;picture&amp;gt; 태그로 구형 브라우저 호환성 지키기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;WebP나 AVIF가 아무리 용량이 작고 좋아도, &quot;혹시 최신 포맷을 지원하지 않는 구형 브라우저에서는 이미지가 엑박(X)으로 깨지면 어쩌지?&quot; 하는 걱정이 들었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 구원해 준 것이 바로 &lt;b data-index-in-node=&quot;18&quot; data-path-to-node=&quot;17&quot;&gt;HTML5의 &amp;lt;picture&amp;gt; 태그&lt;/b&gt;였습니다. 브라우저에게 &lt;i data-index-in-node=&quot;50&quot; data-path-to-node=&quot;17&quot;&gt;&quot;네가 AVIF를 지원하면 AVIF를 보여주고, 안 되면 WebP를, 그것도 안 되면 기존 JPEG를 보여줘&quot;&lt;/i&gt; 하고 우선순위를 정해주는 방식입니다.&lt;/p&gt;
&lt;div data-hveid=&quot;3&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;HTML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- ⭕ 내 프로젝트에 직접 적용한 크로스 브라우징 picture 태그 --&amp;gt;
&amp;lt;picture&amp;gt;
  &amp;lt;!-- 1. 가장 가볍고 최신인 AVIF 포맷을 최우선으로 시도 --&amp;gt;
  &amp;lt;source srcset=&quot;/images/main-banner.avif&quot; type=&quot;image/avif&quot; /&amp;gt;
  
  &amp;lt;!-- 2. AVIF 미지원 시 WebP 포맷 사용 --&amp;gt;
  &amp;lt;source srcset=&quot;/images/main-banner.webp&quot; type=&quot;image/webp&quot; /&amp;gt;
  
  &amp;lt;!-- 3. 최신 포맷을 둘 다 지원하지 않는 구형 브라우저용 기본 이미지 --&amp;gt;
  &amp;lt;img 
    src=&quot;/images/main-banner.jpg&quot; 
    alt=&quot;메인 프로모션 배너&quot; 
    width=&quot;1200&quot; 
    height=&quot;600&quot;
    loading=&quot;lazy&quot;
  /&amp;gt;
&amp;lt;/picture&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;19&quot; 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;스크린샷 2026-08-03 오후 4.12.30.png&quot; data-origin-width=&quot;1640&quot; data-origin-height=&quot;1486&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baxHms/dJMcaf1MRC6/Fg0rGpMY3no1JFec9xNwLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baxHms/dJMcaf1MRC6/Fg0rGpMY3no1JFec9xNwLk/img.png&quot; data-alt=&quot;&amp;amp;lt;picture&amp;amp;gt;, &amp;amp;lt;source&amp;amp;gt;, &amp;amp;lt;img&amp;amp;gt; 구조 예시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baxHms/dJMcaf1MRC6/Fg0rGpMY3no1JFec9xNwLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaxHms%2FdJMcaf1MRC6%2FFg0rGpMY3no1JFec9xNwLk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1640&quot; height=&quot;1486&quot; data-filename=&quot;스크린샷 2026-08-03 오후 4.12.30.png&quot; data-origin-width=&quot;1640&quot; data-origin-height=&quot;1486&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt;picture&amp;gt;, &amp;lt;source&amp;gt;, &amp;lt;img&amp;gt; 구조 예시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;3. 해결책 2: Node.js(Sharp)로 자동 변환 스크립트 만들어 버리기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;이미지가 한두 개가 아니다 보니 일일이 변환 사이트에 업로드해서 다운로드하는 작업이 너무 귀찮았습니다. 그래서 Node.js의 이미지 처리 라이브러리인 sharp를 활용해, 명령어 하나만 치면 프로젝트 내 모든 PNG 파일이 WebP와 AVIF로 자동 변환되는 스크립트를 직접 짜서 해결했습니다.&lt;/p&gt;
&lt;div data-hveid=&quot;6&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// convert.js (내가 직접 만든 이미지 자동 변환 스크립트)
const sharp = require('sharp');
const path = require('path');

const inputPath = path.join(__dirname, 'public/images/main-banner.png');

// PNG를 WebP로 자동 변환 (품질 80% 설정)
sharp(inputPath)
  .webp({ quality: 80 })
  .toFile(path.join(__dirname, 'public/images/main-banner.webp'))
  .then(() =&amp;gt; console.log('✅ WebP 변환 성공!'));

// PNG를 AVIF로 자동 변환 (품질 75% 설정)
sharp(inputPath)
  .avif({ quality: 75 })
  .toFile(path.join(__dirname, 'public/images/main-banner.avif'))
  .then(() =&amp;gt; console.log('  AVIF 변환 성공!'));
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;터미널에서 node convert.js만 실행하면 몇 초 만에 고용량 PNG가 초경량 파일들로 쓱 변환되어 나와서 개발 생산성이 엄청나게 올라갔습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size26&quot;&gt;4. 직접 눈으로 확인한 용량 비교: &quot;2.4MB가 210KB가 되는 기적&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;변환을 마치고 폴더 안에 있는 동일한 이미지의 포맷별 파일 용량을 직접 확인해 보았습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;29&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,0,0&quot;&gt;기존 원본 PNG:&lt;/b&gt; 2,400 KB (2.4MB)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,1,0&quot;&gt;WebP 변환 후:&lt;/b&gt; 420 KB &lt;b data-index-in-node=&quot;18&quot; data-path-to-node=&quot;29,1,0&quot;&gt;(약 82% 용량 감소!)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,2,0&quot;&gt;AVIF 변환 후:&lt;/b&gt; 210 KB &lt;b data-index-in-node=&quot;18&quot; data-path-to-node=&quot;29,2,0&quot;&gt;(약 91% 용량 감소!)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;화질은 눈으로 봤을 때 원본 PNG와 차이를 구분할 수 없을 정도로 선명한데, 파일 용량은 무려 &lt;b data-index-in-node=&quot;54&quot; data-path-to-node=&quot;30&quot;&gt;10분의 1 수준&lt;/b&gt;으로 줄어들었습니다. 그 결과 메인 페이지에 진입할 때 이미지가 버벅거리지 않고 즉시 뜨는 쾌적한 환경을 만들 수 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-08-03 오후 4.13.48.png&quot; data-origin-width=&quot;1316&quot; data-origin-height=&quot;1318&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dYtmKR/dJMcafAMMq3/O8FqmptcKffLrkssA8ElU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dYtmKR/dJMcafAMMq3/O8FqmptcKffLrkssA8ElU0/img.png&quot; data-alt=&quot;Node.js 환경에서 sharp 라이브러리로 PNG 이미지를 WebP와 AVIF로 자동 변환해 주는 스크립트 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dYtmKR/dJMcafAMMq3/O8FqmptcKffLrkssA8ElU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdYtmKR%2FdJMcafAMMq3%2FO8FqmptcKffLrkssA8ElU0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1316&quot; height=&quot;1318&quot; data-filename=&quot;스크린샷 2026-08-03 오후 4.13.48.png&quot; data-origin-width=&quot;1316&quot; data-origin-height=&quot;1318&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Node.js 환경에서 sharp 라이브러리로 PNG 이미지를 WebP와 AVIF로 자동 변환해 주는 스크립트 코드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size26&quot;&gt;5. 깨달은 점 및 요약&lt;/h2&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;이전에는 프론트엔드 성능 최적화라고 하면 무조건 복잡한 코드 리팩토링이나 상태 관리 최적화만 생각했었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이번 경험을 통해 &lt;b data-index-in-node=&quot;14&quot; data-path-to-node=&quot;35&quot;&gt;이미지 포맷을 WebP나 AVIF로 바꾸는 것 같은 단순한 시도가 웹사이트 성능에 얼마나 파격적인 영향을 주는지&lt;/b&gt; 깨달았습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;혹시 지금 만들고 계신 프로젝트의 로딩 속도가 답답하다면, 소스 코드보다 먼저 '내가 넣은 이미지 파일의 포맷과 용량'부터 점검해 보시면 좋을 것 같습니다!&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>AVIF</category>
      <category>html5</category>
      <category>webp</category>
      <category>개발자일기</category>
      <category>개발자팁</category>
      <category>성능개선</category>
      <category>웹개발</category>
      <category>웹성능최적화</category>
      <category>이미지최적화</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/45</guid>
      <comments>https://minari98.tistory.com/entry/%EC%84%B1%EB%8A%A5%EC%B5%9C%EC%A0%81%ED%99%94-PNG-%EC%9A%A9%EB%9F%89-24MB%EC%9D%98-%EA%B3%B5%ED%8F%AC-WebP%EC%99%80-AVIF%EB%A1%9C-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8-%EB%A1%9C%EB%94%A9-%EC%86%8D%EB%8F%84-90-%EC%A4%84%EC%97%AC%EB%B3%B8-%ED%9B%84%EA%B8%B0#entry45comment</comments>
      <pubDate>Mon, 3 Aug 2026 16:15:52 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] 메인 페이지 로딩 속도가 3초? Next.js Image 컴포넌트로 0.8초까지 줄여본 삽질기</title>
      <link>https://minari98.tistory.com/entry/Nextjs-%EB%A9%94%EC%9D%B8-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A1%9C%EB%94%A9-%EC%86%8D%EB%8F%84%EA%B0%80-3%EC%B4%88-Nextjs-Image-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EB%A1%9C-08%EC%B4%88%EA%B9%8C%EC%A7%80-%EC%A4%84%EC%97%AC%EB%B3%B8-%EC%82%BD%EC%A7%88%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 개인 프로젝트를 새로 런칭하고 설레는 마음으로 크롬 개발자 도구의 &lt;b data-index-in-node=&quot;41&quot; data-path-to-node=&quot;5&quot;&gt;Lighthouse 성능 검사&lt;/b&gt;를 돌려보았습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;결과는 참담하게도 &lt;b data-index-in-node=&quot;10&quot; data-path-to-node=&quot;6&quot;&gt;성능 점수 62점, LCP(최초 대형 콘텐츠 도출) 속도 3.2초&lt;/b&gt;.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;스마트폰 모바일 LTE 환경으로 접속해 보니 메인 페이지 배너 이미지가 위에서부터 턱-턱-턱 둔하게 렌더링되면서 아래쪽에 있던 텍스트들이 밑으로 밀려나는 현상(CLS)까지 발생하고 있었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;&quot;요즘 같은 세상에 로딩에 3초나 걸리는 웹사이트를 누가 기다려줄까?&quot; 하는 위기감이 들었고, 원인을 분석해 보니 아무 생각 없이 넣어둔 고용량 메인 배너 이미지(PNG, 3.8MB)가 범인이었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 기본 HTML &amp;lt;img&amp;gt; 태그를 버리고 &lt;b data-index-in-node=&quot;26&quot; data-path-to-node=&quot;9&quot;&gt;Next.js의 Image 컴포넌트(next/image)를 도입해 로딩 속도를 0.8초까지 끌어올렸던 생생한 최적화 과정&lt;/b&gt;을 공유해 봅니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size26&quot;&gt;1. 내가 범했던 실수: &quot;원본 PNG 이미지를 그냥 태그에 꽂아 넣었다&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;처음엔 그냥 디자인 도구에서 추출한 3.8MB짜리 고해상도 PNG 배너를 public/images 폴더에 넣고 아래처럼 코드를 짰었습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQuwY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ❌ 처음 내가 작성했던 최적화 안 된 코드
export default function HeroBanner() {
  return (
    &amp;lt;div className=&quot;banner-container&quot;&amp;gt;
      &amp;lt;img src=&quot;/images/main-banner.png&quot; alt=&quot;메인 배너&quot; /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;이 코드가 왜 문제였냐면:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;15&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,0,0&quot;&gt;모바일 단말기에서도 3.8MB 원본을 통째로 다운로드&lt;/b&gt;받고 있었습니다.&lt;/li&gt;
&lt;li&gt;이미지가 로딩되기 전까지 div 높이가 0px이었다가, 이미지 다운로드가 끝나는 순간 갑자기 400px로 커지며 &lt;b data-index-in-node=&quot;63&quot; data-path-to-node=&quot;15,1,0&quot;&gt;아래 요소들을 강제로 밀어내는 불쾌한 현상&lt;/b&gt;이 일어났습니다.&lt;/li&gt;
&lt;li&gt;브라우저가 PNG 확장자 그대로 다운로드하느라 용량이 전혀 줄어들지 않았습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;2. 구원투수: Next.js Image 컴포넌트로 리팩토링하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;Next.js 공식 문서를 뒤져가며 next/image 패키지를 적용해 코드를 고쳐나갔습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQvAY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import Image from 'next/image';

// ⭕ 내 경험을 바탕으로 수정한 최적화 코드
export default function HeroBanner() {
  return (
    &amp;lt;div className=&quot;relative w-full h-[400px] overflow-hidden rounded-2xl shadow-lg&quot;&amp;gt;
      &amp;lt;Image
        src=&quot;/images/main-banner.png&quot;
        alt=&quot;메인 프로모션 배너 이미지&quot;
        fill
        priority // 1. LCP 핵심 이미지이므로 지연 로딩을 끄고 최우선 다운로드!
        sizes=&quot;(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px&quot;
        className=&quot;object-cover&quot;
        placeholder=&quot;blur&quot; // 2. 이미지가 다 떠오르기 전 부드러운 블러 미리보기 적용
        blurDataURL=&quot;data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU58EUg==&quot;
      /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size23&quot;&gt;  직접 적용해보며 깨달은 꿀팁 2가지&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;21&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,0,0&quot;&gt;priority 속성의 중요성:&lt;/b&gt; 메인 페이지 상단에 바로 보이는 배너 이미지에는 반드시 priority를 붙여주어야 합니다. 이걸 안 붙이면 Next.js가 기본적으로 lazy loading(스크롤 내릴 때 로드)을 적용해버려서 오히려 초기 로딩이 더 늦어집니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,1,0&quot;&gt;fill과 부모 relative 관계:&lt;/b&gt; 이미지 크기를 반응형으로 고정할 때 fill을 쓰면 편리한데, 이때 반드시 감싸고 있는 부모 div에 relative 포지션과 height 값이 지정되어 있어야 배너가 깨지지 않고 예쁘게 잡힙니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-29 오후 5.32.26.png&quot; data-origin-width=&quot;1836&quot; data-origin-height=&quot;780&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cjioPm/dJMcaaTIiw2/zOAJyNaJqEEQ0RPwHhWhJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cjioPm/dJMcaaTIiw2/zOAJyNaJqEEQ0RPwHhWhJ0/img.png&quot; data-alt=&quot;Image 컴포넌트 사용 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cjioPm/dJMcaaTIiw2/zOAJyNaJqEEQ0RPwHhWhJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcjioPm%2FdJMcaaTIiw2%2FzOAJyNaJqEEQ0RPwHhWhJ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1836&quot; height=&quot;780&quot; data-filename=&quot;스크린샷 2026-07-29 오후 5.32.26.png&quot; data-origin-width=&quot;1836&quot; data-origin-height=&quot;780&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Image 컴포넌트 사용 코드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size26&quot;&gt;3. 외부 이미지(Unsplash/S3) 불러올 때 만난 에러와 해결&lt;/h2&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;개발하다가 유저 프로필 사진을 외부 서버 URL로 불러왔을 때 &lt;b data-index-in-node=&quot;35&quot; data-path-to-node=&quot;25&quot;&gt;Invalid src prop ... hostname is not configured under images&lt;/b&gt; 라는 빨간색 에러 화면을 만났습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;Next.js는 보안과 악성 이미지 서버 공격을 막기 위해 허용된 도메인의 외부 이미지만 가져오도록 제한하고 있었습니다. next.config.js 파일에 제가 사용하는 외부 이미지 도메인을 명시해 주니 깔끔하게 해결되었습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQvQY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com', // 사용 중인 외부 이미지 서버 도메인 추가
      },
    ],
  },
};

module.exports = nextConfig;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size26&quot;&gt;4. 최종 결과: 3.8MB ➔ 320KB, 성능 점수 94점 달성!&lt;/h2&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;수정을 마치고 개발 서버를 다시 빌드한 뒤 크롬 개발자 도구 Network 탭을 열어보았습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;31&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,0,0&quot;&gt;이미지 포맷 변화:&lt;/b&gt; 원본 .png 파일이 브라우저에서 자동으로 최신 압축 포맷인 .webp로 변환되어 들어오고 있었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,1,0&quot;&gt;용량 감축:&lt;/b&gt; 3.8MB ➔ &lt;b data-index-in-node=&quot;15&quot; data-path-to-node=&quot;31,1,0&quot;&gt;320KB (약 90% 용량 감소)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,2,0&quot;&gt;Lighthouse 점수:&lt;/b&gt; 62점 ➔ &lt;b data-index-in-node=&quot;21&quot; data-path-to-node=&quot;31,2,0&quot;&gt;94점으로 폭등!&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-29 오후 6.39.01.png&quot; data-origin-width=&quot;1610&quot; data-origin-height=&quot;532&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbWpdu/dJMcabLXPrR/n7fIZ52lqGdTHCbLVpoDKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbWpdu/dJMcabLXPrR/n7fIZ52lqGdTHCbLVpoDKK/img.png&quot; data-alt=&quot;Image 컴포넌트 적용 후 개발자 도구 Network 탭 200 OK 요청 성공 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbWpdu/dJMcabLXPrR/n7fIZ52lqGdTHCbLVpoDKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbWpdu%2FdJMcabLXPrR%2Fn7fIZ52lqGdTHCbLVpoDKK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1610&quot; height=&quot;532&quot; data-filename=&quot;스크린샷 2026-07-29 오후 6.39.01.png&quot; data-origin-width=&quot;1610&quot; data-origin-height=&quot;532&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Image 컴포넌트 적용 후 개발자 도구 Network 탭 200 OK 요청 성공 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;34&quot; data-ke-size=&quot;size26&quot;&gt;5. 회고 및 느낀 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;이전에는 &quot;이미지는 그냥 img 태그 써서 띄우면 되는 거 아닌가?&quot; 하고 안일하게 생각했었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이번에 직접 성능 수치를 측정해 보고 next/image를 도입해 리팩토링해 보면서, &lt;b data-index-in-node=&quot;52&quot; data-path-to-node=&quot;36&quot;&gt;작은 이미지 최적화 하나가 전체 서비스의 품질과 사용자 경험(UX)을 얼마나 크게 좌우하는지&lt;/b&gt; 뼈저리게 체감할 수 있었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;앞으로 작성하는 모든 프로젝트에서는 개발 초기 단계부터 이미지 최적화 전략을 적용하면 좋을 것 같습니다!&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>LCP개선</category>
      <category>LIGHTHOUSE</category>
      <category>nextimage</category>
      <category>nextjs</category>
      <category>React</category>
      <category>개발자일기</category>
      <category>리액트</category>
      <category>웹개발</category>
      <category>웹성능최적화</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/44</guid>
      <comments>https://minari98.tistory.com/entry/Nextjs-%EB%A9%94%EC%9D%B8-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A1%9C%EB%94%A9-%EC%86%8D%EB%8F%84%EA%B0%80-3%EC%B4%88-Nextjs-Image-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EB%A1%9C-08%EC%B4%88%EA%B9%8C%EC%A7%80-%EC%A4%84%EC%97%AC%EB%B3%B8-%EC%82%BD%EC%A7%88%EA%B8%B0#entry44comment</comments>
      <pubDate>Wed, 29 Jul 2026 18:40:33 +0900</pubDate>
    </item>
    <item>
      <title>[React] useState와 useEffect 실무 적용 중 겪었던 3가지 초보적 실수와 해결책</title>
      <link>https://minari98.tistory.com/entry/React-useState%EC%99%80-useEffect-%EC%8B%A4%EB%AC%B4-%EC%A0%81%EC%9A%A9-%EC%A4%91-%EA%B2%AA%EC%97%88%EB%8D%98-3%EA%B0%80%EC%A7%80-%EC%B4%88%EB%B3%B4%EC%A0%81-%EC%8B%A4%EC%88%98%EC%99%80-%ED%95%B4%EA%B2%B0%EC%B1%85</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;리액트(React)를 활용해 웹 애플리케이션을 개발할 때, 가장 기본이 되면서도 개발자를 가장 흔하게 대혼란에 빠뜨리는 주범은 바로 상태 관리(useState)와 부작용 처리(useEffect)입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;문법 자체는 매우 단순해 보이지만, 리액트 내부의 렌더링 라이프사이클과 비동기 업데이트 메커니즘을 명확히 이해하지 못하면 눈에 잘 띄지 않는 치명적인 버그를 만들어내곤 합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 제가 리액트 프로젝트를 진행하며 직접 경험했던 &lt;b data-index-in-node=&quot;30&quot; data-path-to-node=&quot;7&quot;&gt;대표적인 실수 3가지와 이를 해결하며 깨달은 리액트의 핵심 원리&lt;/b&gt;를 상세히 정돈해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. 실수 1: useEffect 내부 비동기 호출 시 '무한 렌더링' 루프 발생&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;가장 식은땀을 흘렸던 버그는 컴포넌트가 마운트될 때 API 데이터를 가져오도록 작성한 useEffect 코드에서 발생했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQlwU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ❌ 무한 렌더링을 유발했던 과거의 코드
function UserProfile() {
  const [userInfo, setUserInfo] = useState&amp;lt;UserInfo | null&amp;gt;(null);

  useEffect(() =&amp;gt; {
    fetchUserData().then((data) =&amp;gt; {
      setUserInfo(data); // 2. 상태가 변경되어 컴포넌트가 재렌더링됨
    });
  }, [userInfo]); // 1. userInfo 상태를 의존성 배열에 넣음 ➔ 3. 다시 useEffect 실행 (무한 반복!)

  return &amp;lt;div&amp;gt;{userInfo?.name}&amp;lt;/div&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;  원인 및 해결책&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;useEffect 내부에서 setUserInfo를 통해 상태를 변경하고 있는데, 그 변경되는 userInfo 상태를 의존성 배열(Dependencies)에 넣어둔 것이 원인이었습니다. 상태 변경 ➔ 재렌더링 ➔ useEffect 재실행 ➔ 상태 변경의 무한 루프가 발생하여 서버에 수천 번의 API 요청이 전송되는 참사가 일어났습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;마운트 시점에 한 번만 실행되어야 하는 API 요청은 의존성 배열을 빈 배열([])로 설정하여 해결했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQmAU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;arcade&quot;&gt;&lt;code&gt;// ⭕ 올바르게 수정한 코드
useEffect(() =&amp;gt; {
  fetchUserData().then((data) =&amp;gt; {
    setUserInfo(data);
  });
}, []); // 빈 배열을 전달하여 마운트 시 단 1회만 실행되도록 보장
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-28 오후 5.15.27.png&quot; data-origin-width=&quot;1542&quot; data-origin-height=&quot;1872&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/08a9d/dJMcacYim9i/APQlTTHm8oBqeJng7sWkck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/08a9d/dJMcacYim9i/APQlTTHm8oBqeJng7sWkck/img.png&quot; data-alt=&quot;useState, useEffect로 작성한 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/08a9d/dJMcacYim9i/APQlTTHm8oBqeJng7sWkck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F08a9d%2FdJMcacYim9i%2FAPQlTTHm8oBqeJng7sWkck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1542&quot; height=&quot;1872&quot; data-filename=&quot;스크린샷 2026-07-28 오후 5.15.27.png&quot; data-origin-width=&quot;1542&quot; data-origin-height=&quot;1872&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;useState, useEffect로 작성한 코드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;h2 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;2. 실수 2: setState의 비동기적 특성을 오해하고 '이전 값'을 참조한 문제&lt;/h2&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;카운트 수량을 올리거나 이전 상태를 기반으로 연쇄 계산을 수행할 때 자주 범했던 실수입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQmQU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// ❌ 원하는 대로 스코어가 2씩 증가하지 않는 코드
const handleDoubleScoreUp = () =&amp;gt; {
  setScore(score + 1); // 현재 score가 0이라면 0 + 1 = 1 설정 요청
  setScore(score + 1); // 여전히 이전 closure의 score(0)를 참조하여 0 + 1 = 1 설정 요청
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size23&quot;&gt;  원인 및 해결책&lt;/h3&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;리액트의 상태 변경 함수(setState)는 즉각적으로 변수의 값을 변경하는 것이 아니라, &lt;b data-index-in-node=&quot;51&quot; data-path-to-node=&quot;22&quot;&gt;성능 최적화를 위해 리액트 렌더링 스케줄러에 업데이트를 예약(Batching)하는 비동기 방식&lt;/b&gt;으로 동작합니다. 따라서 연속으로 setState를 호출하면 동기적으로 반영되지 않고 이전 렌더링 시점의 클로저(Closure) 스코프 값을 참조하게 됩니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;이 문제는 최신 상태 보장을 위해 &lt;b data-index-in-node=&quot;19&quot; data-path-to-node=&quot;23&quot;&gt;함수형 업데이트(Functional Update)&lt;/b&gt; 방식을 적용하여 해결했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQmgU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;// ⭕ 함수형 업데이트를 적용해 항상 최신 상태를 기반으로 계산하도록 수정
const handleDoubleScoreUp = () =&amp;gt; {
  setScore((prevScore) =&amp;gt; prevScore + 1);
  setScore((prevScore) =&amp;gt; prevScore + 1); // 이전 상태(prevScore)를 인수로 받아 정확히 2가 증가함
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;3. 실수 3: useEffect의 Cleanup(정리) 함수 누락으로 인한 메모리 누수&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;페이지 상단으로 이동하는 버튼이나 스크롤 프로그레스 바를 만들기 위해 window 객체에 스크롤 이벤트를 등록할 때 발생했던 실수입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQmwU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;// ❌ 컴포넌트가 언마운트되어도 이벤트가 남아 메모리 누수가 일어나는 코드
useEffect(() =&amp;gt; {
  const handleScroll = () =&amp;gt; {
    setScrollPosition(window.scrollY);
  };
  
  window.addEventListener('scroll', handleScroll);
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size23&quot;&gt;  원인 및 해결책&lt;/h3&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;useEffect 내에서 타이머(setInterval), Web API 이벤트 리스너(addEventListener), 혹은 WebSocket 연결을 등록해 두고 &lt;b data-index-in-node=&quot;91&quot; data-path-to-node=&quot;30&quot;&gt;정리(Cleanup) 함수&lt;/b&gt;를 반환하지 않으면, 해당 컴포넌트가 화면에서 사라진(Unmount) 후에도 메모리에 잔재하여 성능 저하와 예기치 않은 동작을 일으킵니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;useEffect의 return 구문에 리스너를 해제하는 클린업 로직을 추가하여 완벽&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;히 방지할 수 있었습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQnAU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ⭕ 클린업 함수를 반환하여 메모리 누수를 방지한 코드
useEffect(() =&amp;gt; {
  const handleScroll = () =&amp;gt; {
    setScrollPosition(window.scrollY);
  };

  window.addEventListener('scroll', handleScroll);

  // 컴포넌트가 언마운트되거나 재실행되기 전 기존 리스너를 깨끗이 제거
  return () =&amp;gt; {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-28 오후 5.16.40.png&quot; data-origin-width=&quot;1808&quot; data-origin-height=&quot;586&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dqXsyn/dJMcacjDX6X/NK5NIJHBvZLAuW29VSHlkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dqXsyn/dJMcacjDX6X/NK5NIJHBvZLAuW29VSHlkk/img.png&quot; data-alt=&quot;useEffect내부의 로그 출력&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dqXsyn/dJMcacjDX6X/NK5NIJHBvZLAuW29VSHlkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdqXsyn%2FdJMcacjDX6X%2FNK5NIJHBvZLAuW29VSHlkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1808&quot; height=&quot;586&quot; data-filename=&quot;스크린샷 2026-07-28 오후 5.16.40.png&quot; data-origin-width=&quot;1808&quot; data-origin-height=&quot;586&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;useEffect내부의 로그 출력&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;35&quot; data-ke-size=&quot;size26&quot;&gt;4. 결론 및 느낀 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;리액트의 useState와 useEffect는 프론트엔드 개발 시 매일 사용하는 기본적인 훅이지만, 그 내부의 &lt;b data-index-in-node=&quot;62&quot; data-path-to-node=&quot;36&quot;&gt;'불변성(Immutability)', '클로저(Closure)', '비동기 배칭(Batching)'&lt;/b&gt; 원리를 깊이 이해하지 못하면 쉽게 버그에 노출될 수 있습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;단순히 라이브러리의 문법만 외우기보다는, 렌더링 주기에 따라 상태가 어떻게 변화하고 이펙트가 언제 실행되는지 지속적으로 관찰하고 디버깅하는 습관이 좋은 리액트 개발자로 성장하는 핵심 열쇠라고 생각합니다.&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>React</category>
      <category>useEffect</category>
      <category>useState</category>
      <category>개발자일기</category>
      <category>개발자팁</category>
      <category>리액트</category>
      <category>리액트기초</category>
      <category>웹개발</category>
      <category>자바스크립트</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/43</guid>
      <comments>https://minari98.tistory.com/entry/React-useState%EC%99%80-useEffect-%EC%8B%A4%EB%AC%B4-%EC%A0%81%EC%9A%A9-%EC%A4%91-%EA%B2%AA%EC%97%88%EB%8D%98-3%EA%B0%80%EC%A7%80-%EC%B4%88%EB%B3%B4%EC%A0%81-%EC%8B%A4%EC%88%98%EC%99%80-%ED%95%B4%EA%B2%B0%EC%B1%85#entry43comment</comments>
      <pubDate>Tue, 28 Jul 2026 17:17:19 +0900</pubDate>
    </item>
    <item>
      <title>[Git] &amp;quot;내 코드가 어디 갔지?&amp;quot; 초보 개발자가 자주 범하는 Git 실수 3가지와 해결법</title>
      <link>https://minari98.tistory.com/entry/Git-%EB%82%B4-%EC%BD%94%EB%93%9C%EA%B0%80-%EC%96%B4%EB%94%94-%EA%B0%94%EC%A7%80-%EC%B4%88%EB%B3%B4-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EC%9E%90%EC%A3%BC-%EB%B2%94%ED%95%98%EB%8A%94-Git-%EC%8B%A4%EC%88%98-3%EA%B0%80%EC%A7%80%EC%99%80-%ED%95%B4%EA%B2%B0%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 프로젝트를 진행하면서 코딩 자체보다 나를 더 식은땀 흘리게 만들었던 순간은 단연 'Git 명령어 실수'였습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;로직을 신나게 짜두고 커밋 하나 잘못 날려서 수시간 동안 작업했던 코드를 날려먹을 뻔하거나, Git Merge 충돌이 무서워서 소스 코드를 압축 파일로 복사해 두는 웃지 못할 삽질을 겪기도 했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 제가 프론트엔드 프로젝트를 진행하며 직접 겪었던 대표적인 Git 실수 3가지와, 이를 깔끔하게 해결했던 복구 명령어(Tip)를 정돈해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size26&quot;&gt;1. 실수 1: 잘못된 브랜치에 코드를 다 짜놓았을 때 (git stash)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;feature/login 브랜치에서 작업해야 하는데, 깜빡하고 main 브랜치나 다른 작업 브랜치에서 열심히 코드를 수정하고 있던 자신을 뒤늦게 발견했을 때입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;아직 커밋은 하지 않았는데 브랜치를 이동하자니 오류가 뜨고, 작업해 둔 코드가 날아갈까 봐 당황했던 기억이 있습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size23&quot;&gt;  해결 방법: 임시 저장창고 git stash 활용하기&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQ7wQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# 1. 현재 작업 중인 변경사항을 임시 저장소에 싹 감춰둡니다.
git stash

# 2. 올바른 작업 브랜치로 이동합니다.
git checkout feature/login

# 3. 임시 저장소에 있던 변경사항을 새로 이동한 브랜치에 꺼내옵니다.
git stash pop
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;이 3줄의 명령어만 알면 잘못된 브랜치에서 작성하던 코드를 안전하게 원하는 브랜치로 이사시킬 수 있습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;2. 실수 2: 커밋 메시지를 오타 내거나 파일을 빼먹고 커밋했을 때 (git commit --amend)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;커밋을 날리자마자 커밋 메시지에 오타가 들어갔거나, 같이 올려야 하는 스타일 파일(CSS)을 빼먹고 커밋을 올렸다는 것을 깨달았을 때입니다. 새로운 커밋을 하나 더 만들자니 Git 히스토리가 지저분해져서 고민이었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-28 오후 3.29.38.png&quot; data-origin-width=&quot;1106&quot; data-origin-height=&quot;234&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uUeHk/dJMcacxeDgX/ZE89S9H1o4wWam5AvOKCGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uUeHk/dJMcacxeDgX/ZE89S9H1o4wWam5AvOKCGk/img.png&quot; data-alt=&quot;git log 명령어 입력 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uUeHk/dJMcacxeDgX/ZE89S9H1o4wWam5AvOKCGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuUeHk%2FdJMcacxeDgX%2FZE89S9H1o4wWam5AvOKCGk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1106&quot; height=&quot;234&quot; data-filename=&quot;스크린샷 2026-07-28 오후 3.29.38.png&quot; data-origin-width=&quot;1106&quot; data-origin-height=&quot;234&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;git log 명령어 입력 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size23&quot;&gt;  해결 방법: 직전 커밋 덮어쓰기 --amend&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQ8AQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;# 빼먹은 파일이 있다면 먼저 Stage에 올립니다.
git add src/styles/main.css

# 직전 커밋 메시지를 수정하거나 파일을 포함해 덮어씁니다.
git commit --amend -m &quot;fix: 올바른 커밋 메시지 수정&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;이미 날린 직전 커밋을 새 커밋으로 더럽히지 않고 깔끔하게 수정할 수 있습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;3. 실수 3: 코드 수정 중 전으로 돌리고 싶을 때 (git restore &amp;amp; git reset)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;코드를 이것저것 고치다가 망해서 &quot;아, 그냥 오늘 작업하기 전 상태로 싹 되돌리고 싶다&quot; 할 때가 있습니다. 수동으로 Ctrl + Z를 수십 번 누르다가 코드가 엉키는 참사를 겪곤 했습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size23&quot;&gt;  해결 방법: 상황별 되돌리기 명령어&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;25&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,0,0&quot;&gt;아직 커밋하지 않은 파일의 변경사항 취소하기:&lt;/b&gt;&lt;/li&gt;
&lt;li data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQ8QQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;git restore src/App.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,1,0&quot;&gt;직전 커밋을 취소하되 작성한 코드는 남겨두기 (Soft Reset):&lt;/b&gt;&lt;/li&gt;
&lt;li data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQ8gQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;git reset HEAD~1
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,2,0&quot;&gt;지워진 커밋 찾아오기 (git reflog):&lt;/b&gt;&lt;/li&gt;
&lt;li data-path-to-node=&quot;25,2,1&quot;&gt;실수로 git reset --hard를 눌러서 커밋이 지워졌더라도 당황하지 마세요. Git은 모든 행동을 기록해 둡니다. git reflog를 치면 삭제된 커밋 ID를 찾아 원상복구할 수 있습니다!&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-28 오후 3.30.43.png&quot; data-origin-width=&quot;1250&quot; data-origin-height=&quot;628&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Folyo/dJMcadv1goc/6ekS5Qi6v6eDBuMKqXhX0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Folyo/dJMcadv1goc/6ekS5Qi6v6eDBuMKqXhX0K/img.png&quot; data-alt=&quot;git status 명령어 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Folyo/dJMcadv1goc/6ekS5Qi6v6eDBuMKqXhX0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFolyo%2FdJMcadv1goc%2F6ekS5Qi6v6eDBuMKqXhX0K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1250&quot; height=&quot;628&quot; data-filename=&quot;스크린샷 2026-07-28 오후 3.30.43.png&quot; data-origin-width=&quot;1250&quot; data-origin-height=&quot;628&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;git status 명령어 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size26&quot;&gt;4. 글을 마치며&lt;/h2&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;Git은 단순히 소스 코드를 저장하는 구글 드라이브가 아니라, &lt;b data-index-in-node=&quot;35&quot; data-path-to-node=&quot;29&quot;&gt;개발자의 실수를 언제든 안전하게 과거로 되돌려주는 강력한 타임머신&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;명령어가 익숙지 않다고 두려워하기보다는, 직접 브랜치를 만들어보고 stash나 reset 명령어를 연습해 보면서 Git과 친해지시기를 추천합니다. 실수해도 복구하는 방법을 알면 개발이 훨씬 더 자신감 넘쳐집니다!&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>GIT</category>
      <category>github</category>
      <category>git사용법</category>
      <category>git오류</category>
      <category>개발자일기</category>
      <category>개발자팁</category>
      <category>깃</category>
      <category>깃허브</category>
      <category>웹개발</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/42</guid>
      <comments>https://minari98.tistory.com/entry/Git-%EB%82%B4-%EC%BD%94%EB%93%9C%EA%B0%80-%EC%96%B4%EB%94%94-%EA%B0%94%EC%A7%80-%EC%B4%88%EB%B3%B4-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EC%9E%90%EC%A3%BC-%EB%B2%94%ED%95%98%EB%8A%94-Git-%EC%8B%A4%EC%88%98-3%EA%B0%80%EC%A7%80%EC%99%80-%ED%95%B4%EA%B2%B0%EB%B2%95#entry42comment</comments>
      <pubDate>Tue, 28 Jul 2026 16:07:29 +0900</pubDate>
    </item>
    <item>
      <title>[CSS] Tailwind CSS 도입 후기: BEM 클래스명 지옥 탈출과 가독성 극복 전략</title>
      <link>https://minari98.tistory.com/entry/CSS-Tailwind-CSS-%EB%8F%84%EC%9E%85-%ED%9B%84%EA%B8%B0-BEM-%ED%81%B4%EB%9E%98%EC%8A%A4%EB%AA%85-%EC%A7%80%EC%98%A5-%ED%83%88%EC%B6%9C%EA%B3%BC-%EA%B0%80%EB%8F%85%EC%84%B1-%EA%B7%B9%EB%B3%B5-%EC%A0%84%EB%9E%B5</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근 진행한 프론트엔드 프로젝트의 UI 스타일링 도구로 요즘 생태계에서 가장 핫한 &lt;b data-index-in-node=&quot;46&quot; data-path-to-node=&quot;6&quot;&gt;Tailwind CSS&lt;/b&gt;를 도입해 보았습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;이전까지는 Plain CSS, SCSS, 혹은 CSS Modules 방식을 주력으로 사용하며 클래스명을 정의하고 스타일을 입혀왔습니다. 하지만 프로젝트의 규모가 조금씩 커질수록 CSS 파일 용량이 불어나고, 클래스 이름 지정(Naming)에 소비되는 시간이 늘어나는 등 여러 한계를 체감하게 되었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 유틸리티 우선(Utility-First) CSS 프레임워크인 Tailwind CSS를 실제 프로젝트에 직접 도입해 보면서 느꼈던 &lt;b data-index-in-node=&quot;76&quot; data-path-to-node=&quot;8&quot;&gt;솔직한 장단점과, 가독성 단점을 극복했던 나만의 리팩토링 노하우&lt;/b&gt;를 깊이 있게 공유해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size26&quot;&gt;1. 기존 CSS 방식의 한계: &quot;클래스 이름 짓다가 하루가 다 가네요&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;CSS를 작성할 때 개발자를 가장 괴롭히는 것 중 하나는 바로 &lt;b data-index-in-node=&quot;35&quot; data-path-to-node=&quot;11&quot;&gt;BEM(Block Element Modifier) 커스텀 컨벤션에 맞추어 클래스명을 짓는 작업&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;.card__article-title--highlighted처럼 클래스명을 길게 정의하다 보면 &quot;이 이름이 과연 이 컴포넌트의 역할을 명확히 설명하고 있는가?&quot;에 대한 소모적인 고민을 하게 됩니다. 또한, 스타일에 아주 작은 수정사항(예: 마진 4px 추가)이 생겨도 JSX 파일과 CSS/SCSS 파일 사이를 번갈아 이동해야 하는 작업 전환(Context Switching) 비효율이 존재했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;Tailwind CSS는 이러한 페인 포인트(Pain Point)를 직관적으로 해결해 주는 도구였습니다. 클래스명을 고민할 필요 없이, HTML/JSX 태그의 className 속성에 미리 정의된 유틸리티 클래스(flex, items-center, p-4, bg-blue-500 등)를 조합하여 즉시 스타일을 부여하는 방식이기 때문입니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;2. 프로젝트에서 체감한 Tailwind CSS의 장점&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size23&quot;&gt;① 개발 생산성 및 작업 속도의 획기적 향상&lt;/h3&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;컴포넌트 로직이 담긴 JSX/TSX 파일 하나에서 마크업과 스타일링을 한 번에 끝낼 수 있었습니다. 더 이상 별도의 .module.css 파일을 만들고 import하는 번거로운 과정이 사라져, UI 레이아웃을 구성하는 속도가 이전 대비 2배 이상 단축되었습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size23&quot;&gt;② 일관된 디자인 시스템(Design Token) 자동 유지&lt;/h3&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;p-4 (16px), text-lg (18px), rounded-lg (8px) 등 Tailwind가 기본적으로 제공하는 규격화된 수치 단위만 사용하게 됩니다. 이 덕분에 마진이나 폰트 크기를 눈대중으로 어정쩡하게 설정하는 실수가 줄어들었고, 전체 페이지의 디자인 통일성이 자연스럽게 유지되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;268&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bT38Vq/dJMcaccUcta/qhraN6bIMuA4oGhJdEA9Dk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bT38Vq/dJMcaccUcta/qhraN6bIMuA4oGhJdEA9Dk/img.png&quot; data-alt=&quot;tailwind로 작성한 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bT38Vq/dJMcaccUcta/qhraN6bIMuA4oGhJdEA9Dk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbT38Vq%2FdJMcaccUcta%2FqhraN6bIMuA4oGhJdEA9Dk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;268&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;268&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;tailwind로 작성한 코드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;3. 실제로 부딪힌 단점: &quot;HTML이 너무 더러워져요&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;장점이 명확했던 만큼, 실무에서 부딪힌 치명적인 단점도 존재했습니다. 바로 &lt;b data-index-in-node=&quot;42&quot; data-path-to-node=&quot;23&quot;&gt;JSX 태그의 className이 한도 끝도 없이 길어져 코드의 가독성을 심각하게 해친다는 점&lt;/b&gt;이었습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQ6AM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;subunit&quot;&gt;&lt;code&gt;// ❌ 가독성이 저하된 초기의 Tailwind 코드 예시
&amp;lt;button className=&quot;inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200&quot;&amp;gt;
  확인 버튼
&amp;lt;/button&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;버튼 하나를 만드는데 스타일 클래스가 수십 개나 나열되다 보니, 오랜만에 코드를 열었을 때 DOM 구조가 한눈에 들어오지 않고 스타일 코드에 가려지는 현상이 발생했습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size26&quot;&gt;4. 가독성 단점을 해결한 나만의 리팩토링 노하우&lt;/h2&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 극복하기 위해 프로젝트를 진행하며 다음과 같은 규칙을 세워 개선했습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size23&quot;&gt;① @apply 지시어를 활용한 스타일 추상화&lt;/h3&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;자주 반복 사용되는 버튼이나 인풋(Input) 스타일은 글로벌 CSS 파일 내에서 @apply 지시어를 사용하여 깔끔하게 공통 클래스로 묶어주었습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiFwqG9rOiVAxUAAAAAHQAAAAAQ6QM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;CSS&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;/* main.css */
.btn-primary {
  @apply inline-flex items-center justify-center px-6 py-3 text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 transition-colors;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;32&quot; data-ke-size=&quot;size23&quot;&gt;② React 컴포넌트 단위로 잘게 쪼개기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;스타일이 복잡해진 영역은 그 자체로 재사용 가능한 작은 단위 컴포넌트로 분리하였습니다. 가령 공통 카드 UI, 뱃지, 모달 헤더 등을 별도 컴포넌트로 잘게 분리하니 JSX 본문의 가독성도 살리고 Tailwind의 이점도 그대로 유지할 수 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-28 오후 3.01.49.png&quot; data-origin-width=&quot;2168&quot; data-origin-height=&quot;1292&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qloeg/dJMcacRsyty/4Tk7K3UOAYOAkXmS4yXbYk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qloeg/dJMcacRsyty/4Tk7K3UOAYOAkXmS4yXbYk/img.png&quot; data-alt=&quot;Tailwind로 작업된 결과물&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qloeg/dJMcacRsyty/4Tk7K3UOAYOAkXmS4yXbYk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fqloeg%2FdJMcacRsyty%2F4Tk7K3UOAYOAkXmS4yXbYk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2168&quot; height=&quot;1292&quot; data-filename=&quot;스크린샷 2026-07-28 오후 3.01.49.png&quot; data-origin-width=&quot;2168&quot; data-origin-height=&quot;1292&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Tailwind로 작업된 결과물&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;36&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론 및 총평&lt;/h2&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;Tailwind CSS는 처음 접했을 때 &quot;CSS를 HTML 안에 다시 집어넣는 퇴보가 아닌가?&quot; 하는 지적을 받기도 합니다. 하지만 직접 경험해 본 Tailwind CSS는 단순한 인라인 스타일이 아니라, &lt;b data-index-in-node=&quot;116&quot; data-path-to-node=&quot;37&quot;&gt;잘 짜인 디자인 시스템을 가장 빠르게 마크업에 녹여낼 수 있는 강력한 생산성 도구&lt;/b&gt;였습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;클래스 이름 짓는 스트레스에서 벗어나 컴포넌트의 비즈니스 로직에 더 집중하고 싶은 프론트엔드 개발자분들은 Tailwind CSS 도입을&amp;nbsp; 고려해보셨으면 좋을 것 같네요&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>css</category>
      <category>css프레임워크</category>
      <category>React</category>
      <category>tailwindcss</category>
      <category>개발자일기</category>
      <category>리액트</category>
      <category>스타일링</category>
      <category>웹개발</category>
      <category>웹디자인</category>
      <category>프론트엔드</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/41</guid>
      <comments>https://minari98.tistory.com/entry/CSS-Tailwind-CSS-%EB%8F%84%EC%9E%85-%ED%9B%84%EA%B8%B0-BEM-%ED%81%B4%EB%9E%98%EC%8A%A4%EB%AA%85-%EC%A7%80%EC%98%A5-%ED%83%88%EC%B6%9C%EA%B3%BC-%EA%B0%80%EB%8F%85%EC%84%B1-%EA%B7%B9%EB%B3%B5-%EC%A0%84%EB%9E%B5#entry41comment</comments>
      <pubDate>Tue, 28 Jul 2026 15:25:35 +0900</pubDate>
    </item>
    <item>
      <title>[Web] 웹 액세서빌러티(A11y): 모두를 위한 접근성 높은 인터페이스 구축</title>
      <link>https://minari98.tistory.com/entry/Web-%EC%9B%B9-%EC%95%A1%EC%84%B8%EC%84%9C%EB%B9%8C%EB%9F%AC%ED%8B%B0A11y-%EB%AA%A8%EB%91%90%EB%A5%BC-%EC%9C%84%ED%95%9C-%EC%A0%91%EA%B7%BC%EC%84%B1-%EB%86%92%EC%9D%80-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-%EA%B5%AC%EC%B6%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;얼마 전 프로젝트를 마치고 QA(품질 검사) 단계를 진행하던 중, 지인에게 피드백을 받았습니다. &lt;i data-index-in-node=&quot;67&quot; data-path-to-node=&quot;4&quot;&gt;&quot;마우스 없이 Tab 키로만 메인 페이지 조작해봤어? 모달 창이 열렸는데 초점이 모달 뒤로 넘어가서 닫을 수가 없네.&quot;&lt;/i&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;순간 아차 싶었습니다. 그동안 저는 단순히 예쁜 화면을 만드는 데만 집착했지, &lt;b data-index-in-node=&quot;62&quot; data-path-to-node=&quot;5&quot;&gt;마우스를 쓰기 힘든 사용자나 스크린 리더를 쓰는 사용자가 내 사이트를 어떻게 이용할지&lt;/b&gt; 단 한 번도 깊게 고민해 본 적이 없었던 것입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;웹 접근성(A11y)은 배려나 선택이 아닌, &lt;b data-index-in-node=&quot;25&quot; data-path-to-node=&quot;6&quot;&gt;모든 사용자에게 정보를 평등하게 전달하기 위한 기본&lt;/b&gt;입니다. 오늘은 제가 프로젝트에서 겪었던 시행착오와 함께, 프론트엔드 개발자가 반드시 챙겨야 할 웹 접근성 실무 적용기를 정리해 보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size26&quot;&gt;1. 시맨틱 마크업: &amp;lt;div&amp;gt; 습관을 버리는 것부터 시작이었다&lt;/h2&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;초보 시절 저의 HTML 코드는 그야말로 &amp;lt;div&amp;gt;와 &amp;lt;span&amp;gt;의 파티였습니다. CSS로 모양만 맞추면 장땡이라고 생각했거든요. 하지만 스크린 리더는 이 &amp;lt;div&amp;gt;들을 그냥 아무 의미 없는 박스로 인식합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwj_7MKrk8-VAxUAAAAAHQAAAAAQhgo&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;&amp;lt;!-- ❌ 내가 과거에 짰던 코드 --&amp;gt;
&amp;lt;div className=&quot;header&quot;&amp;gt;
  &amp;lt;div className=&quot;logo&quot;&amp;gt;My Logo&amp;lt;/div&amp;gt;
  &amp;lt;div className=&quot;menu&quot;&amp;gt;...&amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;이걸 깨닫고 난 뒤, 프로젝트 리팩토링을 진행하며 HTML5 시맨틱 태그로 전부 교체했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,0,0&quot;&gt;&amp;lt;header&amp;gt;&lt;/b&gt;: 페이지나 섹션의 머리말&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,1,0&quot;&gt;&amp;lt;nav&amp;gt;&lt;/b&gt;: 네비게이션 링크 모음&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,2,0&quot;&gt;&amp;lt;main&amp;gt;&lt;/b&gt;: 문서의 핵심 콘텐츠 (페이지당 한 번만 사용)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,3,0&quot;&gt;&amp;lt;article&amp;gt;&lt;/b&gt;: 독립적으로 배포 가능한 콘텐츠&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,4,0&quot;&gt;&amp;lt;footer&amp;gt;&lt;/b&gt;: 페이지 하단 정보&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;태그 하나만 고쳤을 뿐인데, 구글 Lighthouse로 검사해 보니 접근성 점수가 급상승하는 것을 보고 시맨틱 마크업의 위력을 실감했습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;2. WAI-ARIA: &quot;아이콘 버튼에 이름을 주세요&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;제가 가장 많이 실수했던 부분이 바로 '아이콘 버튼'이었습니다. 돋보기 모양 아이콘이 달린 검색 버튼, 'X' 표시고 되어있는 닫기 버튼을 만들 때, 저는 텍스트 없이 &amp;lt;button&amp;gt;&amp;lt;IconX/&amp;gt;&amp;lt;/button&amp;gt; 형태로만 코드를 짰습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;일반 사용자는 'X' 그림을 보고 닫기 버튼인 것을 알지만, 시각 장애인용 스크린 리더는 이 버튼을 만나면 그저 &quot;버튼&quot;이라고만 읽어줍니다. 무슨 버튼인지 알 길이 없는 것이죠.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size23&quot;&gt;  실무 적용법 (aria-label)&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwj_7MKrk8-VAxUAAAAAHQAAAAAQhwo&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- ⭕ 이렇게 바꾸고 나서야 스크린 리더가 &quot;닫기, 버튼&quot;이라고 읽어주기 시작했습니다 --&amp;gt;
&amp;lt;button aria-label=&quot;모달 닫기&quot; onClick={closeModal}&amp;gt;
  &amp;lt;CloseIcon /&amp;gt;
&amp;lt;/button&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1628&quot; data-origin-height=&quot;2056&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cT0rzX/dJMcaaGaTIn/DZ6ALkgZCkKjpT6Qu2B29K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cT0rzX/dJMcaaGaTIn/DZ6ALkgZCkKjpT6Qu2B29K/img.png&quot; data-alt=&quot;코드설명&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cT0rzX/dJMcaaGaTIn/DZ6ALkgZCkKjpT6Qu2B29K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcT0rzX%2FdJMcaaGaTIn%2FDZ6ALkgZCkKjpT6Qu2B29K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1628&quot; height=&quot;2056&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1628&quot; data-origin-height=&quot;2056&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;코드설명&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;3. 키보드 접근성: 나를 가장 당황하게 만든 '포커스 트랩'&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;피드백에서 지적받았던 모달 창 이슈는 바로 &lt;b data-index-in-node=&quot;24&quot; data-path-to-node=&quot;23&quot;&gt;'키보드 트랩(Keyboard Trap)'&lt;/b&gt; 문제였습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;레이어 모달을 띄웠을 때, 키보드의 Tab 키를 계속 누르면 초점이 모달 내부의 [확인], [취소] 버튼에서만 순환해야 합니다. 그런데 제 코드에서는 초점이 모달 뒤쪽에 깔려있는 메인 페이지의 메뉴들로 빠져나가는 현상이 발생했던 것입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwj_7MKrk8-VAxUAAAAAHQAAAAAQiAo&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;CSS&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;/* ❌ 디자인 깔끔하게 한다고 절대 쓰지 말아야 할 CSS 스타일! */
button:focus {
  outline: none; /* 이 코드를 넣는 순간, 키보드 사용자는 내가 어디를 가리키고 있는지 전혀 볼 수 없게 됩니다. */
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;저는 예쁘지 않다는 이유만으로 outline: none을 습관적으로 넣었었는데, 이 퍼런 포커스 테두리가 키보드 사용자에게는 '마우스 커서'와 같은 역할을 한다는 것을 깨닫고 즉시 삭제했습니다. 대신 브라우저 기본 포커스링을 살리거나, 커스텀 스타일(:focus-visible)로 깔끔하게 포커스 영역을 표시하도록 고쳤습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size26&quot;&gt;4. 직접 경험해 본 접근성 테스트 방법&lt;/h2&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;이론을 아는 것보다 중요한 건 &quot;내가 직접 짠 사이트를 마우스 없이 써보는 것&quot;이었습니다. 이번 일을 계기로 저는 배포 전 반드시 아래 3가지 과정을 거치고 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-22 오후 1.07.48.png&quot; data-origin-width=&quot;1008&quot; data-origin-height=&quot;304&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cILs9X/dJMcaixoJuV/SGeV2Ax19qkIaUt71i4M4K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cILs9X/dJMcaixoJuV/SGeV2Ax19qkIaUt71i4M4K/img.png&quot; data-alt=&quot;구글 lighthouse&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cILs9X/dJMcaixoJuV/SGeV2Ax19qkIaUt71i4M4K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcILs9X%2FdJMcaixoJuV%2FSGeV2Ax19qkIaUt71i4M4K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1008&quot; height=&quot;304&quot; data-filename=&quot;스크린샷 2026-07-22 오후 1.07.48.png&quot; data-origin-width=&quot;1008&quot; data-origin-height=&quot;304&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;구글 lighthouse&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;31&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,0,0&quot;&gt;마우스 치우기:&lt;/b&gt; 오직 Tab, Shift + Tab, Enter 키로만 내 사이트의 모든 메뉴와 폼(Form)을 조작해 봅니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,1,0&quot;&gt;Mac VoiceOver 실행:&lt;/b&gt; Mac 사용자의 경우 Command + F5를 누르면 스크린 리더가 켜집니다. 눈을 감고 내 사이트의 텍스트가 자연스럽게 읽히는지 들어봅니다. (처음 들었을 때 내가 짠 코드가 얼마나 엉망으로 읽히는지 알고 충격을 받았습니다.)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,2,0&quot;&gt;Lighthouse 검사:&lt;/b&gt; 크롬 개발자 도구에서 접근성 점수를 측정하고 레포트에서 지적해 주는 요소(alt 누락, 색상 대비 부족 등)를 하나씩 해결합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size26&quot;&gt;5. 글을 마치며: 개발자로서의 시야가 넓어진 계기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;예전의 저에게 웹 접근성은 &quot;나중에 시간 남을 때 챙기는 부수적인 작업&quot;이었습니다.하지만 이번에 직접 코드를 고쳐보고 테스트해 보면서, 접근성은 개발 초반 뼈대를 세울 때부터 당연히 고려해야 하는 '기본적인 개발 품질'이라는 것을 배웠습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;버스를 탈 때 경사로가 설치되어 있으면 휠체어 이용자뿐만 아니라 유모차를 끄는 부모님, 다리를 다친 사람 모두가 편리해집니다. 웹도 마찬가지입니다. 접근성을 고려해 만든 깔끔한 시맨틱 구조와 키보드 지원은 결국 일반 사용자의 편의성과 검색 엔진 최적화(SEO)로까지 이어집니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>a11y</category>
      <category>AltText</category>
      <category>ContrastRatio</category>
      <category>inclusivedesign</category>
      <category>KeyboardNavigation</category>
      <category>LIGHTHOUSE</category>
      <category>SemanticHTML</category>
      <category>WAIARIA</category>
      <category>WCAG</category>
      <category>webaccessibility</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/39</guid>
      <comments>https://minari98.tistory.com/entry/Web-%EC%9B%B9-%EC%95%A1%EC%84%B8%EC%84%9C%EB%B9%8C%EB%9F%AC%ED%8B%B0A11y-%EB%AA%A8%EB%91%90%EB%A5%BC-%EC%9C%84%ED%95%9C-%EC%A0%91%EA%B7%BC%EC%84%B1-%EB%86%92%EC%9D%80-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-%EA%B5%AC%EC%B6%95#entry39comment</comments>
      <pubDate>Wed, 6 May 2026 12:00:11 +0900</pubDate>
    </item>
    <item>
      <title>[Future] AI 도구와 Wasm을 직접 써보며 느낀 프론트엔드 개발자의 미래와 고민</title>
      <link>https://minari98.tistory.com/entry/Future-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%9D%98-%EB%AF%B8%EB%9E%98-AI-%EB%B3%B4%EC%A1%B0-%EA%B0%9C%EB%B0%9C%EA%B3%BC-WebAssemblyWasm</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;얼마 전 프론트엔드 최신 에디터로 떠오른 &lt;b data-index-in-node=&quot;23&quot; data-path-to-node=&quot;4&quot;&gt;Cursor&lt;/b&gt;를 도입하고, 프로젝트의 유틸리티 로직 작성을 AI에게 맡겨본 적이 있습니다. 간단한 데이터 변환 함수를 몇 초 만에 뚝딱 만들어내는 모습을 보며 감탄하기도 했지만, 동시에 오한이 서리기도 했습니다. &lt;i data-index-in-node=&quot;141&quot; data-path-to-node=&quot;4&quot;&gt;&quot;단순히 Figma 시안을 보고 HTML/CSS 레이아웃을 잡는 일이라면, 머지않아 AI가 전부 대체하겠구나.&quot;&lt;/i&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;실제로 최근 프론트엔드 생태계는 단순히 '화면을 구현하는 작업'에서 'AI와 협업하여 아키텍처를 설계하고, 브라우저 환경에서 고성능 로직을 다루는 영역'으로 급격하게 이동하고 있습니다. 오늘은 제가 실무에서 AI 툴과 WebAssembly(Wasm) 관련 기술들을 직접 접해보고 느낀 솔직한 생각과 준비 전략을 적어보려고 합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 코더(Coder)에서 설계자(Architect)로: AI 도구를 써보며 깨달은 점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;GitHub Copilot이나 Cursor, ChatGPT 등을 실제 개발 흐름에 녹여내면서 가장 크게 느낀 것은 &quot;개발자의 핵심 역량이 완전히 달라졌다&quot;는 사실입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;  실무에서 겪은 AI의 한계와 경험&lt;/h3&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;한번은 복잡한 상태 관리 로직을 AI에게 코딩해 달라고 요청한 적이 있습니다. 그럴듯한 코드를 내놓았지만, 실제 프로젝트에 적용해 보니 리액트의 불변성 규칙을 깨뜨려 재렌더링 버그(Hallucination)를 유발하더군요.&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;이때 깨달았습니다. AI 시대에 중요한 것은 문법을 외우는 것이 아니라, 다음과 같은 능력이라는 것을요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,0,0&quot;&gt;컨텍스트 전달 능력:&lt;/b&gt; AI에게 전체 아키텍처 맥락을 정확히 전달하고 요구사항을 명확히 정의하는 능력&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,1,0&quot;&gt;디버깅 및 검증 능력:&lt;/b&gt; AI가 짠 '그럴듯하지만 버그가 있는 코드'를 잡아낼 수 있는 깊이 있는 CS(Computer Science) 지식&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,2,0&quot;&gt;추상화 설계:&lt;/b&gt; 중복되는 로직을 깔끔하게 구조화하여 AI가 오작동하지 않도록 틀을 짜주는 능력&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-22 오후 5.45.12.png&quot; data-origin-width=&quot;2286&quot; data-origin-height=&quot;2188&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d0zLdt/dJMcafUZUYJ/h58uhdmifKbmjLFtuMGlL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d0zLdt/dJMcafUZUYJ/h58uhdmifKbmjLFtuMGlL0/img.png&quot; data-alt=&quot;cursor에서 ai로 코드리뷰하기&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d0zLdt/dJMcafUZUYJ/h58uhdmifKbmjLFtuMGlL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd0zLdt%2FdJMcafUZUYJ%2Fh58uhdmifKbmjLFtuMGlL0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2286&quot; height=&quot;2188&quot; data-filename=&quot;스크린샷 2026-07-22 오후 5.45.12.png&quot; data-origin-width=&quot;2286&quot; data-origin-height=&quot;2188&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;cursor에서 ai로 코드리뷰하기&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;2. WebAssembly(Wasm): 브라우저의 한계를 넘어서는 경험&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;자바스크립트(JavaScript)는 참 매력적인 언어지만, 대용량 데이터 처리나 이미지/영상 편집, 3D 연산 같은 고성능 작업에서는 한계가 분명합니다. 이 한계를 깨부수기 위해 등장한 것이 바로 WebAssembly(Wasm)입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size23&quot;&gt;  왜 Wasm에 주목해야 할까?&lt;/h3&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;Figma가 웹 브라우저에서 데스크톱 프로그램보다 부드럽게 돌아가는 이유, Photoshop이 웹 버전으로 출시될 수 있었던 이유가 바로 C++이나 Rust로 짜인 핵심 로직을 Wasm으로 빌드하여 브라우저에서 직접 돌리기 때문입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;요즘 저도 Rust 기초를 조금씩 공부하기 시작했는데, 자바스크립트로 처리할 때 버벅이던 대용량 배열 연산을 Rust + Wasm 조합으로 변환해 실행해 보니 속도 차이가 체감될 정도로 빨라지는 것을 경험했습니다. 앞으로는 UI는 React로 짜고, 성능이 중요한 연산 로직은 Rust/Wasm으로 구현하는 '하이브리드 개발'이 프론트엔드의 강력한 무기가 될 것이라 확신합니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;3. 서버 컴포넌트와 엣지 컴퓨팅: 클라이언트 너머로 확장되는 영역&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;Next.js의 App Router와 서버 컴포넌트(RSC)를 프로젝트에 도입하면서, 프론트엔드 개발자가 다뤄야 할 경계가 브라우저를 넘어 서버와 엣지(Edge) 영역으로 퍼지고 있음을 체감합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;23&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,0,0&quot;&gt;서버 이전:&lt;/b&gt; 무거운 연산을 클라이언트 브라우저가 아닌 서버에서 처리하여 사용자 기기의 부담을 줄임&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,1,0&quot;&gt;엣지 런타임(Edge Runtime):&lt;/b&gt; Vercel Edge, Cloudflare Workers 등을 활용해 사용자와 가장 가까운 위치의 서버에서 0.1초 만에 응답을 보내는 초저지연 성능 최적화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;이제 프론트엔드 개발자는 단순히 &quot;브라우저에서 어떻게 보일까?&quot;만 고민해서는 안 되며, &quot;이 로직을 클라이언트, 서버, 엣지 중 어디서 실행하는 것이 가장 효율적인가?&quot;를 판단해야 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-22 오후 5.48.26.png&quot; data-origin-width=&quot;920&quot; data-origin-height=&quot;434&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Hg62p/dJMcahZuDKh/0QMA7UCK8D6zS3ffcdFpx1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Hg62p/dJMcahZuDKh/0QMA7UCK8D6zS3ffcdFpx1/img.png&quot; data-alt=&quot;nextjs 터미널 실행화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Hg62p/dJMcahZuDKh/0QMA7UCK8D6zS3ffcdFpx1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHg62p%2FdJMcahZuDKh%2F0QMA7UCK8D6zS3ffcdFpx1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;920&quot; height=&quot;434&quot; data-filename=&quot;스크린샷 2026-07-22 오후 5.48.26.png&quot; data-origin-width=&quot;920&quot; data-origin-height=&quot;434&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;nextjs 터미널 실행화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size26&quot;&gt;4. 앞으로를 준비하는 나의 로드맵&lt;/h2&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;변화의 속도가 너무 빨라 가끔은 불안하기도 하지만, 결국 답은 '기본기'로 돌아가는 것이었습니다. 제가 다짐한 미래 준비 로드맵은 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;29&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,0,0&quot;&gt;AI를 '적'이 아닌 '최고의 부사수'로 활용하기:&lt;/b&gt; 단순히 코드를 써달라고 하는 수준을 넘어, 내 개발 흐름을 학습시키고 테스트 코드를 자동 생성하게 만드는 등 AI 생산성을 극대화하기&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,1,0&quot;&gt;시스템 언어(Rust) 찍어먹어 보기:&lt;/b&gt; 당장 실무에 전면 도입하지 않더라도, 메모리 구조와 저수준 연산을 이해하여 Wasm 시대를 대비하기&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,2,0&quot;&gt;탄탄한 기본기 유지하기:&lt;/b&gt; 프레임워크와 AI 툴은 계속 바뀌지만, 브라우저 렌더링 원리, 네트워크(HTTP/캐싱), 자료구조 같은 핵심 원리는 절대 변하지 않습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-path-to-node=&quot;31&quot; data-ke-size=&quot;size26&quot;&gt;5. 글을 마치며&lt;/h2&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;AI가 코드를 대신 짜주는 시대일수록, 아이러니하게도 &quot;진짜 실력 있는 개발자&quot;의 가치는 더욱 높아지고 있습니다. AI가 만든 결과물을 검증하고 책임지는 것은 결국 인간 개발자의 몫이기 때문입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;기술의 유행에 휘둘려 조급해하기보다는, 기본기를 단단히 다지면서 새로운 툴을 적극적으로 받아들이는 말랑말랑한 사고를 유지하는 것. 그것이 변화무쌍한 프론트엔드 생태계에서 오래도록 즐겁게 개발할 수 있는 비결이 아닐까 싶습니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>AIAssistedDevelopment</category>
      <category>EdgeComputing</category>
      <category>githubcopilot</category>
      <category>ReactServerComponents</category>
      <category>rust</category>
      <category>TechTrends</category>
      <category>Wasm</category>
      <category>WebAssembly</category>
      <category>기술트렌드</category>
      <category>인공지능개발</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/40</guid>
      <comments>https://minari98.tistory.com/entry/Future-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%9D%98-%EB%AF%B8%EB%9E%98-AI-%EB%B3%B4%EC%A1%B0-%EA%B0%9C%EB%B0%9C%EA%B3%BC-WebAssemblyWasm#entry40comment</comments>
      <pubDate>Tue, 5 May 2026 12:00:57 +0900</pubDate>
    </item>
    <item>
      <title>[Performance] 이미지 용량 다이어트 잔혹사: WebP 변환과 Lazy Loading으로 LCP 점수 극복한 후기</title>
      <link>https://minari98.tistory.com/entry/Performance-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%84%EB%9E%B5-WebP-Lazy-Loading-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EA%B0%80%EB%B3%80-%EC%9D%B4%EB%AF%B8%EC%A7%80</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지난번 쇼핑몰 프로젝트를 진행할 때의 일입니다. 테스트할 때 고화질 상품컷을 몇 십 장씩 업로드하면서 메인 페이지 접속 속도가 터무니없이 느려지는 현상이 발생했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;크롬 개발자 도구를 켜서 네트워크(Network) 탭을 확인해 보니, 메인 페이지 전체 용량 8MB 중 무려 &lt;b data-index-in-node=&quot;61&quot; data-path-to-node=&quot;6&quot;&gt;6.5MB가 고용량 PNG/JPEG 이미지&lt;/b&gt;였습니다. 메인 배너 이미지 하나 용량이 3MB에 달하다 보니, 모바일 환경에서는 화면이 뜨는 데 4~5초 이상 걸려 사용자 이탈률이 치솟고 있었죠.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;그때 깨달았습니다. 수백 줄의 자바스크립트 로직을 줄이는 것보다 &lt;b data-index-in-node=&quot;36&quot; data-path-to-node=&quot;7&quot;&gt;이미지 한 장의 용량을 3MB에서 100KB로 압축하는 것이 훨씬 더 파괴적인 성능 개선을 가져온다&lt;/b&gt;는 사실을요. 오늘은 제가 직접 프로젝트에 적용해 보고 효과를 크게 봤던 이미지 최적화 3대 전략을 공유해 봅니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. 차세대 포맷(WebP, AVIF)으로 변환: 용량이 반토막 나는 마법&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;가장 먼저 한 작업은 수십 십 개의 JPEG, PNG 파일들을 차세대 포맷인 &lt;b data-index-in-node=&quot;43&quot; data-path-to-node=&quot;10&quot;&gt;WebP&lt;/b&gt;와 &lt;b data-index-in-node=&quot;49&quot; data-path-to-node=&quot;10&quot;&gt;AVIF&lt;/b&gt;로 변환하는 것이었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;11&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,0,0&quot;&gt;WebP:&lt;/b&gt; PNG처럼 투명도(Alpha)를 지원하면서도, JPEG 대비 용량이 &lt;b data-index-in-node=&quot;44&quot; data-path-to-node=&quot;11,0,0&quot;&gt;25~35% 이상 감소&lt;/b&gt;합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,1,0&quot;&gt;AVIF:&lt;/b&gt; 현존 최강의 압축률을 자랑하며, WebP보다도 20% 더 가볍습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;구형 브라우저 분기 처리를 위해 &amp;lt;picture&amp;gt; 태그를 활용해 코드를 변경했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwj_7MKrk8-VAxUAAAAAHQAAAAAQlws&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;HTML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- 브라우저가 지원하는 최신 포맷을 알아서 골라 로드하도록 분기 처리 --&amp;gt;
&amp;lt;picture&amp;gt;
  &amp;lt;source srcset=&quot;banner.avif&quot; type=&quot;image/avif&quot; /&amp;gt;
  &amp;lt;source srcset=&quot;banner.webp&quot; type=&quot;image/webp&quot; /&amp;gt;
  &amp;lt;img src=&quot;banner.jpg&quot; alt=&quot;메인 기획전 배너&quot; /&amp;gt;
&amp;lt;/picture&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 포맷만 바꿔줬는데도 이미지 전체 용량이 6.5MB에서 1.8MB로 대폭 줄어드는 것을 눈으로 확인했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-23 오전 10.36.27.png&quot; data-origin-width=&quot;3022&quot; data-origin-height=&quot;1708&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nhzWJ/dJMcajppkS6/UUUKI4VaUl7nbwsLhV4idk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nhzWJ/dJMcajppkS6/UUUKI4VaUl7nbwsLhV4idk/img.png&quot; data-alt=&quot;squoosh.app을 이용해 이미지 용량 대폭 감소&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nhzWJ/dJMcajppkS6/UUUKI4VaUl7nbwsLhV4idk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnhzWJ%2FdJMcajppkS6%2FUUUKI4VaUl7nbwsLhV4idk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3022&quot; height=&quot;1708&quot; data-filename=&quot;스크린샷 2026-07-23 오전 10.36.27.png&quot; data-origin-width=&quot;3022&quot; data-origin-height=&quot;1708&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;squoosh.app을 이용해 이미지 용량 대폭 감소&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;2. 지연 로딩(Lazy Loading)과 나의 실수: LCP 이미지는 건드리지 마라!&lt;/h2&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;화면을 아래로 스크롤하기 전까지는 보이지도 않는 하단 상품 이미지들까지 한 번에 로드할 필요가 전혀 없었습니다. 그래서 HTML5 표준 속성인 loading=&quot;lazy&quot;를 도입했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwj_7MKrk8-VAxUAAAAAHQAAAAAQmQs&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;HTML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- 화면 뷰포트에 가까워지면 그때 다운로드를 시작합니다 --&amp;gt;
&amp;lt;img src=&quot;product-detail.webp&quot; loading=&quot;lazy&quot; alt=&quot;상품 상세 설명&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size23&quot;&gt;  내가 겪었던 삽질: 메인 배너에 lazy를 걸었다가 점수가 깎이다?&lt;/h3&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;여기서 욕심을 내서 메인 최상단 배너 이미지에도 loading=&quot;lazy&quot;를 적용했었는데, 오히려 구글 Lighthouse의 &lt;b data-index-in-node=&quot;70&quot; data-path-to-node=&quot;21&quot;&gt;LCP(Largest Contentful Paint - 가장 큰 콘텐츠가 뜨는 시간)&lt;/b&gt; 점수가 더 떨어지는 기현상이 일어났습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;알고 보니 &lt;b data-index-in-node=&quot;6&quot; data-path-to-node=&quot;22&quot;&gt;첫 화면에 바로 보여야 하는 핵심 이미지에는 절대 lazy를 걸면 안 되는 것&lt;/b&gt;이었습니다. 브라우저가 로딩을 오히려 미루기 때문이죠. 상단 배너에는 loading=&quot;eager&quot;와 함께 fetchpriority=&quot;high&quot; 속성을 주어 우선순위를 높여주는 것이 정답이었습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size26&quot;&gt;3. responsive images (srcset): 모바일 사용자 데이터 아껴주기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;데스크톱용 1920px 너비의 이미지를 모바일 사용자(너비 390px)에게 전송하는 건 명백한 데이터 낭비였습니다. 기기 해상도에 맞춰 최적화된 크기를 전달하기 위해 srcset 속성을 적용했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwj_7MKrk8-VAxUAAAAAHQAAAAAQmgs&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;HTML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;&amp;lt;img
  src=&quot;banner-small.webp&quot;
  srcset=&quot;banner-small.webp 500w, banner-medium.webp 1000w, banner-large.webp 2000w&quot;
  sizes=&quot;(max-width: 600px) 480px, 800px&quot;
  alt=&quot;메인 배너&quot;
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;27&quot; 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;스크린샷 2026-07-23 오후 2.07.41.png&quot; data-origin-width=&quot;736&quot; data-origin-height=&quot;690&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vPReX/dJMcahFcrCl/gYUOPY6VHnnAEJqopD8w7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vPReX/dJMcahFcrCl/gYUOPY6VHnnAEJqopD8w7K/img.png&quot; data-alt=&quot;Lighthouse Performance 점수&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vPReX/dJMcahFcrCl/gYUOPY6VHnnAEJqopD8w7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvPReX%2FdJMcahFcrCl%2FgYUOPY6VHnnAEJqopD8w7K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;736&quot; height=&quot;690&quot; data-filename=&quot;스크린샷 2026-07-23 오후 2.07.41.png&quot; data-origin-width=&quot;736&quot; data-origin-height=&quot;690&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Lighthouse Performance 점수&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;4. 실무 이미지 최적화 체크리스트&lt;/h2&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트를 진행하며 정리한 저만의 최적화 루틴입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;32&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,0,0&quot;&gt;상세 페이지 이미지 용량 제어:&lt;/b&gt; 웹용 이미지는 무조건 장당 &lt;b data-index-in-node=&quot;33&quot; data-path-to-node=&quot;32,0,0&quot;&gt;200KB 이하&lt;/b&gt;로 맞추기&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,1,0&quot;&gt;Layout Shift(CLS) 방지:&lt;/b&gt; &amp;lt;img&amp;gt; 태그에 width와 height 속성을 명시해서 이미지가 늦게 떠도 화면이 덜컹거리지 않게 영역 미리 잡기&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,2,0&quot;&gt;EXIF 메타데이터 제거:&lt;/b&gt; 카메라 촬영 정보, 위치 정보 등 불필요한 메타데이터는 압축 툴로 싹 지워주기&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-path-to-node=&quot;34&quot; data-ke-size=&quot;size26&quot;&gt;5. 글을 마치며&lt;/h2&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;이미지 최적화는 단순히 기술적인 도전을 넘어, &lt;b data-index-in-node=&quot;26&quot; data-path-to-node=&quot;35&quot;&gt;우리 서비스를 이용하는 사용자의 데이터와 시간을 아껴주는 작업&lt;/b&gt;이었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;실제로 이미지 다이어트 작업을 마친 뒤 메인 페이지 체감 로딩 속도가 3초 이상 단축되었고, 팀 내부에서도 만족도가 매우 높았습니다. 만약 지금 운영 중인 사이트나 블로그가 답답하게 느껴진다면, 자바스크립트 코드를 건드리기 전에 먼저 이미지들부터 가볍게 다이어트시켜 보시는 걸 강력히 추천합니다!&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>AVIF</category>
      <category>CLS</category>
      <category>ImageOptimization</category>
      <category>LazyLoading</category>
      <category>LCP</category>
      <category>NextjsImage</category>
      <category>ResponsiveImages</category>
      <category>Squoosh</category>
      <category>webp</category>
      <category>webperformance</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/38</guid>
      <comments>https://minari98.tistory.com/entry/Performance-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%84%EB%9E%B5-WebP-Lazy-Loading-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EA%B0%80%EB%B3%80-%EC%9D%B4%EB%AF%B8%EC%A7%80#entry38comment</comments>
      <pubDate>Mon, 4 May 2026 12:00:08 +0900</pubDate>
    </item>
    <item>
      <title>[Tooling] &amp;quot;따옴표 하나로 Git 충돌이?&amp;quot; ESLint와 Prettier로 협업 스트레스 날려버린 후기</title>
      <link>https://minari98.tistory.com/entry/Tooling-ESLint%EC%99%80-Prettier-%ED%98%91%EC%97%85%EC%9D%98-%ED%9A%A8%EC%9C%A8%EC%9D%84-%EB%86%92%EC%9D%B4%EB%8A%94-%EC%BD%94%EB%93%9C-%EC%BB%A8%EB%B2%A4%EC%85%98-%EC%9E%90%EB%8F%99%ED%99%94</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;팀 프로젝트에 처음 합류했을 때, 가장 저를 당황스럽게 만들었던 건 로직 에러가 아닌 'Git Merge 충돌(Conflict)'이었습니다. 분명히 저는 로그인 로직 코드 한 줄만 고쳤는데, PR(Pull Request)을 올려보니 파일 전체에 빨간 줄이 그어지며 충돌이 난 것이죠.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;원인을 찾아보니 제 에디터는 파일 저장 시 작은따옴표(')로 자동 변환하고 있었고, 동료 개발자의 에디터는 큰따옴표(&quot;)와 세미콜론을 강제로 붙이고 있었습니다. 코드 리뷰 시간에 로직을 검토하기도 전에 &lt;i data-index-in-node=&quot;113&quot; data-path-to-node=&quot;6&quot;&gt;&quot;여기 들여쓰기 탭 간격 고쳐주세요&quot;&lt;/i&gt;, &lt;i data-index-in-node=&quot;135&quot; data-path-to-node=&quot;6&quot;&gt;&quot;따옴표 통일해 주세요&quot;&lt;/i&gt; 같은 스타일 지적 주고받기로 수십 분을 허비하곤 했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;이 소모적인 논쟁을 원천 차단하고 &quot;저장(Ctrl+S) 버튼만 누르면 알아서 코드 스타일에 대한 약속을 지켜주는 환경&quot;을 구축했던 경험을 정리해 봅니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. ESLint와 Prettier: 파수꾼과 재단사 역할 분담하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;처음엔 ESLint 하나만 설치하면 포맷팅까지 다 되는 줄 알고 썼다가, 두 도구의 규칙이 서로 싸우면서 에러를 뿜어내는 현상을 겪었습니다. 이 둘은 역할이 엄연히 다릅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;11&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,0,0&quot;&gt;ESLint (Linter):&lt;/b&gt; 코드의 논리적 질(Quality)을 감시합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;11,0,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;i data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,0,1,0,0&quot;&gt;&quot;선언해 두고 안 쓰는 변수가 있어요!&quot;&lt;/i&gt;, &lt;i data-index-in-node=&quot;24&quot; data-path-to-node=&quot;11,0,1,0,0&quot;&gt;&quot;리액트 훅의 규칙을 어겼어요!&quot;&lt;/i&gt; 같은 버그 유발 가능성을 경고합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,1,0&quot;&gt;Prettier (Formatter):&lt;/b&gt; 코드의 시각적 스타일(Style)을 재단합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;11,1,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;줄 바꿈, 들여쓰기 2칸, 마지막 콤마(Trailing Comma) 등 외형을 통일해 줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size26&quot;&gt;2. 두 도구 충돌 없이 한 번에 세팅하기 (실무 설정)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;제가 프로젝트에서 가장 안정적으로 사용 중인 패키지 조합입니다. 핵심은 eslint-config-prettier를 넣어서 &lt;b data-index-in-node=&quot;68&quot; data-path-to-node=&quot;14&quot;&gt;Prettier가 담당하는 스타일 규칙에 ESLint가 태클 걸지 않도록 충돌을 끄는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size23&quot;&gt;  패키지 설치&lt;/h3&gt;
&lt;div data-hveid=&quot;210&quot;&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;npm install -D eslint prettier eslint-config-prettier eslint-plugin-react eslint-plugin-react-hooks @typescript-eslint/eslint-plugin @typescript-eslint/parser
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size23&quot;&gt;  .eslintrc.json 설정 (리액트+타입스크립트 프로젝트)&lt;/h3&gt;
&lt;div data-hveid=&quot;213&quot;&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;parser&quot;: &quot;@typescript-eslint/parser&quot;,
  &quot;extends&quot;: [
    &quot;eslint:recommended&quot;,
    &quot;plugin:react/recommended&quot;,
    &quot;plugin:@typescript-eslint/recommended&quot;,
    &quot;plugin:react-hooks/recommended&quot;,
    &quot;prettier&quot; // ⭐ 반드시 가장 마지막에 넣어 스타일 규칙 충돌을 막아줍니다!
  ],
  &quot;rules&quot;: {
    &quot;no-unused-vars&quot;: &quot;warn&quot;,
    &quot;react/prop-types&quot;: &quot;off&quot;,
    &quot;react/react-in-jsx-scope&quot;: &quot;off&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-23 오후 5.11.00.png&quot; data-origin-width=&quot;1202&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/chTbA8/dJMcabSwLJN/E5LsksKZeYv5ES2UJ2d600/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/chTbA8/dJMcabSwLJN/E5LsksKZeYv5ES2UJ2d600/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/chTbA8/dJMcabSwLJN/E5LsksKZeYv5ES2UJ2d600/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FchTbA8%2FdJMcabSwLJN%2FE5LsksKZeYv5ES2UJ2d600%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1202&quot; height=&quot;420&quot; data-filename=&quot;스크린샷 2026-07-23 오후 5.11.00.png&quot; data-origin-width=&quot;1202&quot; data-origin-height=&quot;420&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;3. &quot;저장만 하면 알아서 고쳐지게&quot;: 팀 공통 VS Code 설정&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;팀원마다 각자 VS Code 플러그인 설정이 달라서 규칙이 깨지는 것을 막기 위해, 프로젝트 루트 디렉토리에 .vscode/settings.json 파일을 만들어서 Git에 공유했습니다.&lt;/p&gt;
&lt;div data-hveid=&quot;216&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;editor.formatOnSave&quot;: true,
  &quot;editor.defaultFormatter&quot;: &quot;esbenp.prettier-vscode&quot;,
  &quot;editor.codeActionsOnSave&quot;: {
    &quot;source.fixAll.eslint&quot;: &quot;explicit&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;이 설정을 공유하고 난 뒤, 팀원 모두가 &lt;b data-index-in-node=&quot;23&quot; data-path-to-node=&quot;24&quot;&gt;Ctrl + S (저장)를 누르는 순간 자동으로 ESLint 규칙에 맞게 코드가 정돈&lt;/b&gt;되기 시작했습니다. 포맷팅 때문에 리뷰 코멘트를 남길 일이 100% 사라졌죠!&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;4. Git Hooks(Husky)로 '규칙 어긴 코드' 커밋 원천 봉쇄하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;설정을 공유해도 간혹 VS Code 확장 프로그램이 꺼져있거나, CLI에서 그대로 커밋을 올려서 엉망인 코드가 메인 브랜치로 들어오는 경우가 있었습니다. 이를 방지하기 위해 &lt;b data-index-in-node=&quot;97&quot; data-path-to-node=&quot;27&quot;&gt;Husky&lt;/b&gt;와 &lt;b data-index-in-node=&quot;104&quot; data-path-to-node=&quot;27&quot;&gt;lint-staged&lt;/b&gt;를 도입했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;git commit 명령어를 내리는 순간, Husky가 작동하여 &lt;b data-index-in-node=&quot;36&quot; data-path-to-node=&quot;28&quot;&gt;스테이징된(Staged) 파일들만 린트 검사&lt;/b&gt;를 돌립니다. 만약 ESLint 에러를 통과하지 못하면 아예 Git 커밋 자체가 거부되도록 강제했습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;31&quot; data-ke-size=&quot;size26&quot;&gt;5. 글을 마치며&lt;/h2&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;ESLint와 Prettier를 제대로 세팅하는 데 들어간 시간은 불과 20~30분이었습니다. 하지만 이 초반 세팅 덕분에 저희 팀은 수개월 간의 프로젝트 기간 동안 &lt;b data-index-in-node=&quot;93&quot; data-path-to-node=&quot;32&quot;&gt;단 한 번도 코드 스타일로 인한 Git 충돌이나 코드 리뷰 소모전을 겪지 않았습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;좋은 아키텍처와 협업의 시작은 거창한 시스템이 아니라, &quot;팀원 모두가 일관된 정갈한 코드를 유지하는 시스템&quot;을 만드는 데에 있습니다. 혼자 코딩하든 팀으로 코딩하든, 프로젝트 시작 시점에 이 두 도구를 세팅하는 습관을 들이시는 걸 강력히 추천합니다!&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>CodeConvention</category>
      <category>eslint</category>
      <category>Formatter</category>
      <category>FrontendCollaboration</category>
      <category>Husky</category>
      <category>Linter</category>
      <category>LintStaged</category>
      <category>Prettier</category>
      <category>reactConfig</category>
      <category>TypeScriptConfig</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/37</guid>
      <comments>https://minari98.tistory.com/entry/Tooling-ESLint%EC%99%80-Prettier-%ED%98%91%EC%97%85%EC%9D%98-%ED%9A%A8%EC%9C%A8%EC%9D%84-%EB%86%92%EC%9D%B4%EB%8A%94-%EC%BD%94%EB%93%9C-%EC%BB%A8%EB%B2%A4%EC%85%98-%EC%9E%90%EB%8F%99%ED%99%94#entry37comment</comments>
      <pubDate>Sun, 3 May 2026 12:00:20 +0900</pubDate>
    </item>
    <item>
      <title>[Tooling] 프론트엔드 테스트 전략: Jest와 Cypress로 결함 없는 코드 만들기</title>
      <link>https://minari98.tistory.com/entry/Tooling-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A0%84%EB%9E%B5-Jest%EC%99%80-Cypress%EB%A1%9C-%EA%B2%B0%ED%95%A8-%EC%97%86%EB%8A%94-%EC%BD%94%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;팀 프로젝트에서 배포를 진행한 직후, 팀원이 당황스러운 소식을 전해왔습니다. &lt;i data-index-in-node=&quot;43&quot; data-path-to-node=&quot;147&quot;&gt;&quot;아까 새로 고친 팝업창 버튼은 잘 되는데, 기존에 잘 되던 [로그인]이랑 [장바구니 담기] 기능이 클릭이 안 돼요!&quot;&lt;/i&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;148&quot; data-ke-size=&quot;size16&quot;&gt;분명 내 파트 코드만 살짝 고치고 수동으로 클릭해 본 뒤 Merge했었는데, 공통으로 쓰이던 유틸리티 함수와 리액트 상태(State)가 꼬이면서 사이드 이펙트(Side Effect)가 발생했던 것입니다. 배포할 때마다 식은땀을 흘리며 깨달았습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;149&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;149&quot;&gt;&quot;프로젝트 규모가 커질수록, 사람이 수동으로 버튼을 하나씩 눌러보며 버그를 검증하는 것엔 한계가 있구나.&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;150&quot; data-ke-size=&quot;size16&quot;&gt;내가 작성하거나 고친 코드가 기존 기능에 영향을 미치지 않는다는 확신을 얻고, 마음 편히 배포하게 만들어준 &lt;b data-index-in-node=&quot;60&quot; data-path-to-node=&quot;150&quot;&gt;테스트 자동화 구축기&lt;/b&gt;를 정리해 봅니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;152&quot; data-ke-size=&quot;size26&quot;&gt;1. 현실적인 테스트 전략 (테스트 피라미드)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;153&quot; data-ke-size=&quot;size16&quot;&gt;모든 코드에 테스트를 만드느라 정작 기능 구현보다 테스트 짜는 시간이 더 오래 걸리면 안 되기에, 저희 팀 프로젝트에 맞게 &lt;b data-index-in-node=&quot;69&quot; data-path-to-node=&quot;153&quot;&gt;우선순위&lt;/b&gt;를 정해 테스트 피라미드를 구축했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;154&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;154,0,0&quot;&gt;1단계: 유닛 테스트 (Unit Test - Jest)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;154,0,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UI와 상관없는 순수 핵심 로직(할인율 계산, 날짜 포맷팅, 복잡한 데이터 변환 함수)만 빠르게 검증합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;154,1,0&quot;&gt;2단계: 통합 테스트 (Integration Test - React Testing Library)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;154,1,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;내가 만든 컴포넌트에서 &quot;사용자가 폼을 입력하고 버튼을 눌렀을 때 원하는 화면이 잘 나타나는가?&quot;를 검증합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;154,2,0&quot;&gt;3단계: E2E 테스트 (End-to-End Test - Cypress)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;154,2,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실제 브라우저를 띄워 [메인 ➔ 로그인 ➔ 장바구니 ➔ 결제]로 이어지는 &lt;b data-index-in-node=&quot;41&quot; data-path-to-node=&quot;154,2,1,0,0&quot;&gt;핵심 서비스 동선&lt;/b&gt;만 통째로 검증합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-path-to-node=&quot;156&quot; data-ke-size=&quot;size26&quot;&gt;2. Jest와 RTL로 단단한 컴포넌트 만들기 (실제 적용 코드)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;157&quot; data-ke-size=&quot;size16&quot;&gt;리액트 프로젝트에 React Testing Library(RTL)를 적용하면서 지킨 원칙은 &quot;컴포넌트의 내부 state가 아니라, 실제 사용자의 행동을 기준으로 테스트하는 것&quot;이었습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahcKEwiFwqG9rOiVAxUAAAAAHQAAAAAQaA&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// LoginForm.test.tsx - 로그인 폼 테스트 코드
import { render, screen, fireEvent } from '@testing-library/react';
import LoginForm from './LoginForm';

test('아이디와 비밀번호를 모두 입력해야만 로그인 버튼이 활성화된다', () =&amp;gt; {
  render(&amp;lt;LoginForm /&amp;gt;);
  
  const idInput = screen.getByLabelText(/아이디/i);
  const pwInput = screen.getByLabelText(/비밀번호/i);
  const submitButton = screen.getByRole('button', { name: /로그인/i });

  // 1. 처음엔 버튼이 비활성화(disabled) 상태여야 함
  expect(submitButton).toBeDisabled();

  // 2. 사용자가 폼에 타이핑을 진행
  fireEvent.change(idInput, { target: { value: 'yumina' } });
  fireEvent.change(pwInput, { target: { value: 'password123!' } });

  // 3. 두 값이 제대로 들어가면 버튼이 활성화되는지 검증
  expect(submitButton).toBeEnabled();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-23 오후 5.32.00.png&quot; data-origin-width=&quot;1556&quot; data-origin-height=&quot;290&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bB6Rup/dJMcaijRlwZ/wp5w4EbmSQt7IB2wufC29K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bB6Rup/dJMcaijRlwZ/wp5w4EbmSQt7IB2wufC29K/img.png&quot; data-alt=&quot;Jest 테스트가 통과된 화면 스크린샷&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bB6Rup/dJMcaijRlwZ/wp5w4EbmSQt7IB2wufC29K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbB6Rup%2FdJMcaijRlwZ%2Fwp5w4EbmSQt7IB2wufC29K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1556&quot; height=&quot;290&quot; data-filename=&quot;스크린샷 2026-07-23 오후 5.32.00.png&quot; data-origin-width=&quot;1556&quot; data-origin-height=&quot;290&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Jest 테스트가 통과된 화면 스크린샷&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;161&quot; data-ke-size=&quot;size26&quot;&gt;3. Cypress로 팀의 '핵심 서비스 동선' 구하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;162&quot; data-ke-size=&quot;size16&quot;&gt;유닛 테스트가 부분 부품을 검사한다면, Cypress는 &quot;진짜 사용자가 들어와서 서비스를 끝까지 이용할 수 있는가&quot;를 브라우저에서 직접 시뮬레이션해 줍니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahcKEwiFwqG9rOiVAxUAAAAAHQAAAAAQaQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// cypress/e2e/checkout.cy.js - 메인 플로우 검증
describe('핵심 서비스 플로우 검증', () =&amp;gt; {
  it('상품 페이지에서 장바구니에 담고 결제 페이지까지 문제없이 이동한다', () =&amp;gt; {
    cy.visit('/products/1');
    cy.contains('장바구니 담기').click();
    
    // 장바구니 카운트가 올라갔는지 검증
    cy.get('.cart-badge').should('contain', '1');
    
    cy.visit('/cart');
    cy.contains('주문하기').click();
    
    // 최종 결제 URL로 잘 넘어가는지 검증
    cy.url().should('include', '/checkout');
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;164&quot; data-ke-size=&quot;size16&quot;&gt;Cypress를 붙여둔 덕분에, 배포 전 버튼들을 일일이 마우스로 클릭해 보지 않고도 &lt;b data-index-in-node=&quot;48&quot; data-path-to-node=&quot;164&quot;&gt;명령어 하나로 전체 동작을 5초 만에 검증&lt;/b&gt;할 수 있게 되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-23 오후 5.33.16.png&quot; data-origin-width=&quot;2170&quot; data-origin-height=&quot;320&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bB2GRj/dJMcag7m7eB/SjR1NA3hsHjjbazovkXHjk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bB2GRj/dJMcag7m7eB/SjR1NA3hsHjjbazovkXHjk/img.png&quot; data-alt=&quot;Cypress 테스트 러너 실행 결과 화면 스크린샷&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bB2GRj/dJMcag7m7eB/SjR1NA3hsHjjbazovkXHjk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbB2GRj%2FdJMcag7m7eB%2FSjR1NA3hsHjjbazovkXHjk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2170&quot; height=&quot;320&quot; data-filename=&quot;스크린샷 2026-07-23 오후 5.33.16.png&quot; data-origin-width=&quot;2170&quot; data-origin-height=&quot;320&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Cypress 테스트 러너 실행 결과 화면 스크린샷&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;h2 data-path-to-node=&quot;167&quot; data-ke-size=&quot;size26&quot;&gt;4. TDD(테스트 주도 개발)에 대한 나의 생각&lt;/h2&gt;
&lt;p data-path-to-node=&quot;168&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트 일정이 빡빡할 때는 모든 코드를 TDD로 짜는 것은 어려웠습니다. 그래서 제가 찾은 타협점은 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;169&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;169,0,0&quot;&gt;TDD 적용 영역:&lt;/b&gt; 정산/결제 금액 계산 로직, 데이터 변환 유틸 함수 (버그 나면 치명적인 곳)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;169,1,0&quot;&gt;일반 개발 후 테스트 적용 영역:&lt;/b&gt; 컴포넌트 레이아웃, CSS 스타일링, 단순 안내 페이지&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-path-to-node=&quot;171&quot; data-ke-size=&quot;size26&quot;&gt;5. GitHub Actions로 자동 배포 검문소 만들기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;172&quot; data-ke-size=&quot;size16&quot;&gt;테스트 코드를 짜두기만 하고 안 돌리면 의미가 없어서, &lt;b data-index-in-node=&quot;31&quot; data-path-to-node=&quot;172&quot;&gt;GitHub Actions&lt;/b&gt;를 활용해 가상 검문소를 만들었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;173&quot; data-ke-size=&quot;size16&quot;&gt;팀원이 PR(Pull Request)을 올릴 때마다, &lt;b data-index-in-node=&quot;30&quot; data-path-to-node=&quot;173&quot;&gt;GitHub 서버가 자동으로 Jest와 Cypress를 먼저 실행&lt;/b&gt;합니다. 여기서 테스트가 단 하나라도 실패하면 메인 브랜치에 Merge 자체가 되지 않도록 막아두었습니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;175&quot; data-ke-size=&quot;size26&quot;&gt;6. 글을 마치며&lt;/h2&gt;
&lt;p data-path-to-node=&quot;176&quot; data-ke-size=&quot;size16&quot;&gt;테스트 코드는 단순히 '코드의 품질'을 높이는 수단이 아닙니다. &quot;배포할 때 오는 불안함을 없애주는 최고의 안전장치&quot;입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;177&quot; data-ke-size=&quot;size16&quot;&gt;내가 수정한 코드가 기존 기능을 깨뜨리지 않았다는 확신이 생기니, 새로운 기능을 시도하거나 리팩토링을 진행할 때도 주저 없이 코드를 고칠 수 있게 되었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;178&quot; data-ke-size=&quot;size16&quot;&gt;팀 프로젝트나 개인 프로젝트를 진행하시면서 배포에 불안함을 느끼셨다면, 오늘 밤 가장 핵심이 되는 유틸 함수에 작은 유닛 테스트 하나부터 작성해 보세요!&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>cicd</category>
      <category>Cypress</category>
      <category>E2ETesting</category>
      <category>FrontendTesting</category>
      <category>integrationtesting</category>
      <category>jest</category>
      <category>reacttestinglibrary</category>
      <category>softwarequality</category>
      <category>tdd</category>
      <category>UnitTesting</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/36</guid>
      <comments>https://minari98.tistory.com/entry/Tooling-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A0%84%EB%9E%B5-Jest%EC%99%80-Cypress%EB%A1%9C-%EA%B2%B0%ED%95%A8-%EC%97%86%EB%8A%94-%EC%BD%94%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0#entry36comment</comments>
      <pubDate>Sat, 2 May 2026 12:00:29 +0900</pubDate>
    </item>
    <item>
      <title>[Security] 프론트엔드 보안 가이드: XSS와 CSRF 완벽 방어하기</title>
      <link>https://minari98.tistory.com/entry/Security-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EB%B3%B4%EC%95%88-%EA%B0%80%EC%9D%B4%EB%93%9C-XSS%EC%99%80-CSRF-%EC%99%84%EB%B2%BD-%EB%B0%A9%EC%96%B4%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹 애플리케이션의 복잡도가 높아지면서 프론트엔드 보안의 중요성도 그 어느 때보다 커졌습니다. 보안 사고는 서비스의 신뢰도를 한순간에 무너뜨릴 뿐만 아니라 사용자의 개인정보 유출로 직결됩니다. 오늘은 프론트엔드 개발자가 반드시 알고 있어야 할 두 가지 핵심 공격 기법인 &lt;b data-index-in-node=&quot;150&quot; data-path-to-node=&quot;4&quot;&gt;XSS&lt;/b&gt;와 &lt;b data-index-in-node=&quot;155&quot; data-path-to-node=&quot;4&quot;&gt;CSRF&lt;/b&gt;의 원리를 파악하고, 이를 방어하기 위한 실무 전략을 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;5&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size26&quot;&gt;1. XSS (Cross-Site Scripting): 내 사이트에 남이 짠 코드가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;XSS는 공격자가 악성 스크립트를 웹 페이지에 삽입하여, 다른 사용자의 브라우저에서 해당 스크립트가 실행되게 만드는 공격입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size23&quot;&gt;1.1 공격의 위험성&lt;/h3&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;스크립트가 실행되면 공격자는 사용자의 세션 쿠키를 가로채거나, 사용자를 대신해 게시글을 작성하고, 민감한 정보를 외부 서버로 전송할 수 있습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size23&quot;&gt;1.2 프론트엔드 방어 전략&lt;/h3&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;React와 같은 최신 프레임워크는 기본적으로 렌더링 시 데이터를 이스케이프(Escape) 처리하여 XSS를 방지합니다. 하지만 다음 상황에서는 위험에 노출됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,0,0&quot;&gt;dangerouslySetInnerHTML 사용 지양:&lt;/b&gt; HTML을 직접 렌더링해야 한다면 반드시 dompurify 같은 라이브러리로 살균(Sanitize) 과정을 거쳐야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,1,0&quot;&gt;사용자 입력값 검증:&lt;/b&gt; URL 쿼리 스트링이나 폼 입력값을 다룰 때 항상 유효성을 검사하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,2,0&quot;&gt;CSP(Content Security Policy) 설정:&lt;/b&gt; 브라우저에게 &quot;이 사이트에서 실행할 수 있는 스크립트 출처는 여기뿐이야&quot;라고 알려주는 보안 헤더를 설정하세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;13&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size26&quot;&gt;2. CSRF (Cross-Site Request Forgery): 내가 안 보낸 요청이 서버로?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;CSRF는 사용자가 자신의 의지와 무관하게 공격자가 의도한 행위(비밀번호 변경, 결제 등)를 특정 웹사이트에 요청하게 만드는 공격입니다. 사용자가 로그인되어 있는 브라우저의 '쿠키'를 이용하는 것이 핵심입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size23&quot;&gt;2.1 공격 원리&lt;/h3&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 은행 사이트에 로그인된 상태에서 공격자가 만든 악성 사이트에 접속하면, 그 사이트 내부의 숨겨진 폼이 은행 서버로 '송금 요청'을 보냅니다. 브라우저는 은행 쿠키를 자동으로 함께 전송하므로, 서버는 사용자의 정상적인 요청으로 착각하게 됩니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size23&quot;&gt;2.2 프론트엔드 방어 전략&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;19&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,0,0&quot;&gt;SameSite 쿠키 속성:&lt;/b&gt; 쿠키 설정 시 SameSite=Lax 또는 Strict를 사용하여 타 도메인에서의 쿠키 전송을 제한하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,1,0&quot;&gt;CSRF 토큰:&lt;/b&gt; 서버에서 발급한 일회용 토큰을 요청 헤더에 담아 보내 서버가 검증하게 하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,2,0&quot;&gt;Custom Header 사용:&lt;/b&gt; X-Requested-With 같은 커스텀 헤더를 요구하면 브라우저의 기본 폼 제출로는 요청을 보낼 수 없어 방어에 유리합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;20&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;3. 안전한 토큰 관리: LocalStorage vs Cookie&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;많은 개발자가 고민하는 주제입니다. &quot;JWT(JSON Web Token)를 어디에 저장해야 할까요?&quot;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size23&quot;&gt;3.1 LocalStorage (XSS에 취약)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;24&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24,0,0&quot;&gt;장점:&lt;/b&gt; 사용이 간편하고 CSRF 공격으로부터 자유롭습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24,1,0&quot;&gt;단점:&lt;/b&gt; 자바스크립트로 접근이 가능하므로 XSS 공격에 노출되면 토큰이 그대로 탈취됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;25&quot; data-ke-size=&quot;size23&quot;&gt;3.2 HttpOnly Cookie (CSRF에 취약)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;26&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;26,0,0&quot;&gt;장점:&lt;/b&gt; 자바스크립트 접근이 불가능하여 XSS로부터 안전합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;26,1,0&quot;&gt;단점:&lt;/b&gt; 브라우저가 자동으로 요청에 포함하므로 CSRF 공격 대상이 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;27&quot;&gt;최선의 선택:&lt;/b&gt; &lt;b data-index-in-node=&quot;8&quot; data-path-to-node=&quot;27&quot;&gt;HttpOnly 및 Secure 속성이 적용된 쿠키&lt;/b&gt;를 사용하되, &lt;b data-index-in-node=&quot;44&quot; data-path-to-node=&quot;27&quot;&gt;SameSite 속성&lt;/b&gt;과 &lt;b data-index-in-node=&quot;57&quot; data-path-to-node=&quot;27&quot;&gt;CSRF 토큰&lt;/b&gt;을 병행하여 CSRF 공격을 방어하는 것이 가장 견고한 보안 모델로 평가받습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;28&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size26&quot;&gt;4. HTTPS와 보안 헤더 설정&lt;/h2&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;네트워크 레벨에서의 보안도 필수입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;31&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,0,0&quot;&gt;HSTS (HTTP Strict Transport Security):&lt;/b&gt; 브라우저가 해당 사이트에 항상 HTTPS로만 접속하도록 강제합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,1,0&quot;&gt;X-Frame-Options:&lt;/b&gt; 내 사이트가 다른 사이트의 &amp;lt;iframe&amp;gt; 안에 들어가는 것을 막아 &lt;b data-index-in-node=&quot;55&quot; data-path-to-node=&quot;31,1,0&quot;&gt;클릭재킹(Clickjacking)&lt;/b&gt; 공격을 방어합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;32&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론: 보안은 체크리스트가 아닌 프로세스입니다&lt;/h2&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드 보안은 단 하나의 라이브러리 설치로 끝나지 않습니다. 코드를 짤 때마다 &quot;이 입력값이 안전한가?&quot;, &quot;이 요청이 신뢰할 수 있는가?&quot;를 끊임없이 질문해야 합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;35&quot;&gt;오늘의 요약:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;36&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,0,0&quot;&gt;XSS 방어:&lt;/b&gt; 데이터 이스케이프를 믿되, HTML 직접 삽입 시에는 반드시 살균하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,1,0&quot;&gt;CSRF 방어:&lt;/b&gt; SameSite 쿠키와 커스텀 헤더/토큰을 활용하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,2,0&quot;&gt;저장소 선택:&lt;/b&gt; 민감한 정보는 자바스크립트가 만질 수 없는 곳에 두세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,3,0&quot;&gt;CSP 도입:&lt;/b&gt; 보안 헤더를 통해 브라우저의 방어 능력을 극대화하세요.&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>개발일기</category>
      <category>ContentSecurityPolicy</category>
      <category>cors</category>
      <category>CSRF</category>
      <category>JWT</category>
      <category>localStorage</category>
      <category>SameSiteCookie</category>
      <category>securityheader</category>
      <category>WebSecurity</category>
      <category>XSS</category>
      <category>프론트엔드보안</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/35</guid>
      <comments>https://minari98.tistory.com/entry/Security-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EB%B3%B4%EC%95%88-%EA%B0%80%EC%9D%B4%EB%93%9C-XSS%EC%99%80-CSRF-%EC%99%84%EB%B2%BD-%EB%B0%A9%EC%96%B4%ED%95%98%EA%B8%B0#entry35comment</comments>
      <pubDate>Fri, 1 May 2026 12:00:05 +0900</pubDate>
    </item>
    <item>
      <title>[Web] 브라우저 렌더링 원리: Critical Rendering Path 최적화 전략</title>
      <link>https://minari98.tistory.com/entry/Web-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%9B%90%EB%A6%AC-Critical-Rendering-Path-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%84%EB%9E%B5</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹사이트의 성능은 단순히 '데이터가 얼마나 빨리 도착하는가'에 달려 있지 않습니다. 더 중요한 것은 &lt;b&gt;'도착한 데이터를 얼마나 빨리 화면에 그려내는가'&lt;/b&gt;입니다. 브라우저가 HTML, CSS, JavaScript를 받아서 화면에 픽셀로 변환하는 일련의 과정을 &lt;b&gt;중요 렌더링 경로(Critical Rendering Path, CRP)&lt;/b&gt;라고 부릅니다. 이 경로를 단축하는 것이 곧 성능 최적화의 정석입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;5&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size26&quot;&gt;1. 렌더링의 5단계 공정 (CRP)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;브라우저는 화면을 그리기 위해 크게 5가지 단계를 거칩니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size23&quot;&gt;1.1 DOM 트리 구축 (Parsing)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;브라우저가 HTML 문서를 읽어 내려가며 태그들을 트리 구조의 노드들로 변환합니다. 이것이 우리가 잘 아는 &lt;b&gt;DOM(Document Object Model)&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size23&quot;&gt;1.2 CSSOM 트리 구축&lt;/h3&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;HTML을 읽다가 &amp;lt;link&amp;gt;나 &amp;lt;style&amp;gt; 태그를 만나면 CSS를 파싱하여 &lt;b data-index-in-node=&quot;44&quot; data-path-to-node=&quot;11&quot;&gt;CSSOM(CSS Object Model)&lt;/b&gt; 트리를 만듭니다. CSS는 상속 구조를 가지기 때문에 이 트리가 완성되어야 다음 단계로 넘어갈 수 있습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;1.3 렌더 트리(Render Tree) 형성&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;DOM과 CSSOM이 결합하여 실제 화면에 &lt;b data-index-in-node=&quot;24&quot; data-path-to-node=&quot;13&quot;&gt;'보이는'&lt;/b&gt; 노드들로만 구성된 &lt;b data-index-in-node=&quot;40&quot; data-path-to-node=&quot;13&quot;&gt;렌더 트리&lt;/b&gt;가 만들어집니다. display: none이 설정된 요소는 이 트리에서 제외됩니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;1.4 레이아웃(Layout / Reflow)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;렌더 트리의 각 노드가 화면의 정확히 어느 위치에, 어느 정도 크기로 배치될지 계산하는 과정입니다. 상대적인 수치(%, em)가 절대적인 픽셀(px)로 변환되는 시점입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size23&quot;&gt;1.5 페인트(Paint)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;계산된 위치를 바탕으로 실제 화면에 픽셀을 채워 넣습니다. 텍스트, 색상, 이미지, 효과 등이 시각적으로 나타나는 단계입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;18&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size26&quot;&gt;2. 성능의 적: 리플로우(Reflow)와 리페인트(Repaint)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;사용자의 인터랙션(클릭, 스크롤, 애니메이션)으로 화면의 요소가 변경되면 위 과정이 다시 발생합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;21&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,0,0&quot;&gt;Reflow:&lt;/b&gt; 요소의 크기나 위치가 바뀌어 &lt;b data-index-in-node=&quot;24&quot; data-path-to-node=&quot;21,0,0&quot;&gt;레이아웃 단계부터 다시 실행&lt;/b&gt;되는 것을 의미합니다. 매우 비용이 많이 드는 작업입니다. (예: width, height, margin 변경)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,1,0&quot;&gt;Repaint:&lt;/b&gt; 위치는 그대로고 색상이나 가시성만 바뀌어 레이아웃 계산 없이 &lt;b data-index-in-node=&quot;43&quot; data-path-to-node=&quot;21,1,0&quot;&gt;페인트만 다시 수행&lt;/b&gt;하는 것입니다. (예: color, background-color 변경)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22&quot;&gt;최적화 팁:&lt;/b&gt; 가능하다면 레이아웃과 페인트를 건너뛰고 &lt;b data-index-in-node=&quot;29&quot; data-path-to-node=&quot;22&quot;&gt;Composite(합성)&lt;/b&gt; 단계만 거치는 속성(transform, opacity)을 사용하여 애니메이션을 구현하세요. 이는 GPU를 활용하므로 훨씬 부드럽습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;23&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size26&quot;&gt;3. CRP를 단축하는 3가지 실무 전략&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;25&quot; data-ke-size=&quot;size23&quot;&gt;3.1 리소스 우선순위 지정 (Preload &amp;amp; Preconnect)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;중요한 리소스(폰트, 메인 이미지)는 브라우저가 먼저 발견하도록 힌트를 주세요.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQggk&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;HTML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;&amp;lt;link rel=&quot;preload&quot; href=&quot;main-font.woff2&quot; as=&quot;font&quot; type=&quot;font/woff2&quot; crossorigin&amp;gt;
&amp;lt;link rel=&quot;preconnect&quot; href=&quot;https://api.your-service.com&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size23&quot;&gt;3.2 렌더링 차단 리소스 제거&lt;/h3&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;CSS는 렌더링을 차단하고, JS는 파싱을 차단합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;30&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;30,0,0&quot;&gt;CSS:&lt;/b&gt; 필요한 최소한의 CSS만 상단에 배치하고 나머지는 비동기로 로드하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;30,1,0&quot;&gt;JS:&lt;/b&gt; &amp;lt;script&amp;gt; 태그에 async나 defer 속성을 사용하여 HTML 파싱이 멈추지 않게 하세요. (일반적으로 defer가 권장됩니다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;31&quot; data-ke-size=&quot;size23&quot;&gt;3.3 콘텐츠 가시성 제어 (content-visibility)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;최신 브라우저에서 지원하는 content-visibility: auto 속성을 사용하면 화면 밖에 있는 요소의 렌더링 계산을 브라우저가 자동으로 생략합니다. 긴 랜딩 페이지에서 성능을 획기적으로 높일 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;33&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;34&quot; data-ke-size=&quot;size26&quot;&gt;4. 실무 트러블슈팅: 레이아웃 스래싱(Layout Thrashing) 방지&lt;/h2&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;JS로 스타일을 변경할 때, 쓰기(Write)와 읽기(Read)를 반복하면 브라우저는 정확한 값을 알려주기 위해 매번 레이아웃을 다시 계산해야 합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQgwk&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;// Bad: 레이아웃 스래싱 발생
for (let i = 0; i &amp;lt; boxes.length; i++) {
  const width = boxes[i].offsetWidth; // 읽기 (Layout 발생)
  boxes[i].style.width = width + 10 + 'px'; // 쓰기 (Invalidate Layout)
}

// Good: 읽기와 쓰기를 분리
const widths = boxes.map(box =&amp;gt; box.offsetWidth); // 한꺼번에 읽기
boxes.forEach((box, i) =&amp;gt; {
  box.style.width = widths[i] + 10 + 'px'; // 한꺼번에 쓰기
});
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;37&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;38&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론: 사용자 경험의 0.1초를 잡는 법&lt;/h2&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;렌더링 원리를 이해하는 것은 &quot;어떻게 화면을 띄울 것인가&quot;를 넘어 &quot;어떻게 하면 사용자가 로딩을 느끼지 못하게 할 것인가&quot;에 대한 답을 찾는 과정입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;40&quot;&gt;오늘의 요약:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;41&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;41,0,0&quot;&gt;DOM + CSSOM = Render Tree&lt;/b&gt;임을 기억하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;41,1,0&quot;&gt;Reflow&lt;/b&gt;를 유발하는 속성 사용을 최소화하고 GPU 가속(transform)을 활용하세요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;defer&lt;/b&gt;와 &lt;b data-index-in-node=&quot;11&quot; data-path-to-node=&quot;41,2,0&quot;&gt;priority&lt;/b&gt; 설정을 통해 중요 경로 리소스를 먼저 로드하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;41,3,0&quot;&gt;Layout Thrashing&lt;/b&gt;을 피하기 위해 DOM 읽기/쓰기 로직을 최적화하세요.&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>개발일기</category>
      <category>BrowserRendering</category>
      <category>CriticalRendering Path</category>
      <category>CRP</category>
      <category>FrontendOptimization</category>
      <category>reflow</category>
      <category>repaint</category>
      <category>webperformance</category>
      <category>브라우저렌더링</category>
      <category>웹성능최적화</category>
      <category>중요렌더링경로</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/34</guid>
      <comments>https://minari98.tistory.com/entry/Web-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%9B%90%EB%A6%AC-Critical-Rendering-Path-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%84%EB%9E%B5#entry34comment</comments>
      <pubDate>Thu, 30 Apr 2026 12:00:14 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] App Router 환경에서 서버 컴포넌트(RSC)와 클라이언트 컴포넌트의 경계 설계하기</title>
      <link>https://minari98.tistory.com/entry/Nextjs-App-Router-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8RSC%EC%99%80-%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%EC%9D%98-%EA%B2%BD%EA%B3%84-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js App Router의 핵심은 &lt;b&gt;&quot;모든 컴포넌트는 기본적으로 서버 컴포넌트(Server Components)다&quot;&lt;/b&gt;라는 선언입니다. 과거 Pages Router 시절에는 모든 컴포넌트가 브라우저로 전송되어 하이드레이션(Hydration) 과정을 거쳐야 했지만, 이제는 서버에서만 실행되고 결과물인 HTML만 브라우저로 전달되는 컴포넌트를 만들 수 있게 되었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;하지만 개발을 하다 보면 인터랙션(클릭, 상태 관리)이 필요한 시점이 오고, 자연스럽게 'use client'를 선언하게 됩니다. 이때 가장 중요한 역량은 &lt;b&gt;&quot;어디까지를 서버 영역으로 두고, 어디서부터 클라이언트 영역으로 나눌 것인가&quot;&lt;/b&gt;를 결정하는 설계 능력입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 서버 컴포넌트(RSC) vs 클라이언트 컴포넌트(RCC)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;먼저 두 컴포넌트의 역할과 제약을 명확히 구분해야 합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;1.1 서버 컴포넌트 (Default)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;10&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0,0&quot;&gt;실행 위치:&lt;/b&gt; 서버에서만 실행됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,1,0&quot;&gt;장점:&lt;/b&gt; 브라우저로 전송되는 자바스크립트 번들 크기가 줄어들며, 서버 자원(DB, 파일 시스템)에 직접 접근할 수 있습니다. 보안에 민감한 API 키 등을 숨기기에도 유리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,2,0&quot;&gt;제약:&lt;/b&gt; useState, useEffect 같은 훅이나 onClick 같은 이벤트 핸들러를 사용할 수 없습니다. 브라우저 API(window, localStorage)에도 접근할 수 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size23&quot;&gt;1.2 클라이언트 컴포넌트 ('use client')&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,0,0&quot;&gt;실행 위치:&lt;/b&gt; 서버에서 프리렌더링된 후 브라우저에서 하이드레이션됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,1,0&quot;&gt;장점:&lt;/b&gt; 사용자와의 인터랙션이 가능하고 브라우저 API를 모두 사용할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,2,0&quot;&gt;제약:&lt;/b&gt; 자바스크립트 번들 용량을 차지하며, 데이터 패칭 시 서버 컴포넌트보다 네트워크 비용이 더 발생할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;13&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size26&quot;&gt;2. 효율적인 경계 설계 전략 (Component Pattern)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;성능을 최적화하기 위해서는 &lt;b&gt;&quot;클라이언트 컴포넌트를 컴포넌트 트리의 가장 말단으로 밀어내는 것&quot;&lt;/b&gt;이 핵심입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size23&quot;&gt;2.1 리프 컴포넌트 패턴 (Leaf Component Pattern)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;페이지 전체를 'use client'로 만드는 대신, 상태가 필요한 작은 조각만 별도의 클라이언트 컴포넌트로 분리하세요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;18&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,0,0&quot;&gt;Bad:&lt;/b&gt; 검색 결과 페이지 전체를 클라이언트 컴포넌트로 선언 (검색 결과 리스트까지 모두 JS 번들에 포함됨)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,1,0&quot;&gt;Good:&lt;/b&gt; 검색어 입력을 받는 SearchBar만 클라이언트 컴포넌트로 만들고, 결과 리스트는 서버 컴포넌트로 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQuAg&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// SearchPage.tsx (Server Component)
import SearchBar from './SearchBar'; // 'use client'
import SearchResults from './SearchResults'; // Server Component

export default function SearchPage() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;검색 페이지&amp;lt;/h1&amp;gt;
      {/* 인터랙션이 필요한 부분만 클라이언트 영역 */}
      &amp;lt;SearchBar /&amp;gt; 
      {/* 데이터 렌더링은 서버 영역 */}
      &amp;lt;SearchResults /&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;20&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;3. 서버 컴포넌트 안에 클라이언트 컴포넌트 넣기 (Composition)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;서버 컴포넌트에서 클라이언트 컴포넌트를 불러오는 것은 쉽지만, 그 반대는 불가능하다고 생각하는 경우가 많습니다. 하지만 &lt;b&gt;컴포넌트 합성(Composition)&lt;/b&gt;을 이용하면 클라이언트 컴포넌트 '내부'에 서버 컴포넌트를 배치할 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQuQg&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ClientWrapper.tsx ('use client')
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; setIsOpen(!isOpen)}&amp;gt;토글&amp;lt;/button&amp;gt;
      {/* children으로 들어온 서버 컴포넌트는 여전히 서버에서 실행됨 */}
      {isOpen &amp;amp;&amp;amp; children}
    &amp;lt;/div&amp;gt;
  );
}

// Page.tsx (Server Component)
export default function Page() {
  return (
    &amp;lt;ClientWrapper&amp;gt;
      &amp;lt;HeavyServerComponent /&amp;gt; {/* 클라이언트 컴포넌트 안에서도 서버 컴포넌트의 이점 유지 */}
    &amp;lt;/ClientWrapper&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;이 패턴을 활용하면 레이아웃이나 상태를 관리하는 래퍼(Wrapper)는 클라이언트 쪽에 두면서도, 실제 무거운 데이터 렌더링은 서버 컴포넌트로 처리하는 고도화된 설계가 가능해집니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;25&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;4. 데이터 패칭의 최적 위치&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;App Router에서는 &lt;b data-index-in-node=&quot;14&quot; data-path-to-node=&quot;27&quot;&gt;데이터 패칭을 서버 컴포넌트에서 수행하는 것&lt;/b&gt;을 강력히 권장합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;28&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,0,0&quot;&gt;이유:&lt;/b&gt; 서버에서 직접 DB에 접근하므로 속도가 빠르고, 클라이언트-서버 간의 Waterfall 현상을 방지할 수 있습니다. 또한 패칭된 데이터를 하위 클라이언트 컴포넌트로 Props를 통해 쉽게 전달할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;29&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;5. 실무 체크리스트: 언제 'use client'를 쓸까?&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-path-to-node=&quot;31&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;필요한 기능&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;서버 컴포넌트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;클라이언트 컴포넌트&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,1,0,0&quot;&gt;데이터 패칭 (Fetching Data)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,1,1,0&quot;&gt;✅&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,1,2,0&quot;&gt;❌ (권장 안 함)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,2,0,0&quot;&gt;백엔드 자원 직접 접근 (DB, API)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,2,1,0&quot;&gt;✅&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,2,2,0&quot;&gt;❌&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,3,0,0&quot;&gt;보안 정보 유지 (API Keys, Tokens)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,3,1,0&quot;&gt;✅&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,3,2,0&quot;&gt;❌&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,4,0,0&quot;&gt;상태 관리 및 생명주기 (useState, useEffect)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,4,1,0&quot;&gt;❌&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,4,2,0&quot;&gt;✅&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,5,0,0&quot;&gt;브라우저 API 사용 (window, localStorage)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,5,1,0&quot;&gt;❌&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,5,2,0&quot;&gt;✅&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,6,0,0&quot;&gt;커스텀 훅 사용 (대부분의 UI 관련 훅)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,6,1,0&quot;&gt;❌&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;31,6,2,0&quot;&gt;✅&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-path-to-node=&quot;32&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size26&quot;&gt;6. 결론: &quot;서버가 먼저, 클라이언트는 나중에&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;Next.js App Router 설계의 미학은 &lt;b&gt;'자바스크립트 다이어트'&lt;/b&gt;에 있습니다. 최대한 많은 로직을 서버로 옮기고, 클라이언트는 오직 사용자의 손가락이 닿는 곳(Click, Type, Swipe)에만 집중하게 하세요.&lt;/p&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;35&quot;&gt;오늘의 요약:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;36&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,0,0&quot;&gt;기본은 서버:&lt;/b&gt; 모든 파일은 일단 서버 컴포넌트로 시작하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,1,0&quot;&gt;말단 분리:&lt;/b&gt; 상태가 필요한 부분만 핀셋으로 집어내듯 클라이언트 컴포넌트로 만드세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,2,0&quot;&gt;합성 활용:&lt;/b&gt; 클라이언트 컴포넌트가 서버 컴포넌트를 children으로 받을 수 있음을 잊지 마세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,3,0&quot;&gt;데이터는 상단 서버에서:&lt;/b&gt; 데이터 패칭은 가능한 트리 상단의 서버 컴포넌트에서 해결하세요.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발일기</category>
      <category>approuter</category>
      <category>ClientComponents</category>
      <category>Hybration</category>
      <category>nextjs</category>
      <category>NextjsOptimization</category>
      <category>RCC</category>
      <category>RSC</category>
      <category>ServerComponents</category>
      <category>웹성능최적화</category>
      <category>프론트엔드설계</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/33</guid>
      <comments>https://minari98.tistory.com/entry/Nextjs-App-Router-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8RSC%EC%99%80-%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%EC%9D%98-%EA%B2%BD%EA%B3%84-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0#entry33comment</comments>
      <pubDate>Wed, 29 Apr 2026 12:00:55 +0900</pubDate>
    </item>
    <item>
      <title>[TS] API 응답 데이터에 타입 안전성 입히기: Zod를 활용한 런타임 유효성 검사</title>
      <link>https://minari98.tistory.com/entry/TS-API-%EC%9D%91%EB%8B%B5-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%97%90-%ED%83%80%EC%9E%85-%EC%95%88%EC%A0%84%EC%84%B1-%EC%9E%85%ED%9E%88%EA%B8%B0-Zod%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%9F%B0%ED%83%80%EC%9E%84-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%82%AC</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;타입스크립트(TypeScript)를 사용하면서 우리는 종종 착각에 빠지곤 합니다. &quot;타입을 정의했으니, 런타임에서도 이 데이터는 안전할 거야&quot;라고 말이죠. 하지만 타입스크립트의 타입 검사는 **빌드 시점(Compile Time)**에만 작동하며, 자바스크립트로 변환되는 순간 모두 사라집니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;특히 외부 API에서 받아오는 데이터는 타입스크립트가 보호해 주지 못하는 '치외법권' 영역입니다. 백엔드에서 예고 없이 필드명을 바꾸거나 null을 내려주면, 우리 앱은 undefined 에러를 뿜으며 멈춰버립니다. 오늘은 이러한 '런타임 불확실성'을 제거해 주는 강력한 스키마 검증 라이브러리, &lt;b data-index-in-node=&quot;166&quot; data-path-to-node=&quot;5&quot;&gt;Zod&lt;/b&gt;를 소개합니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 타입스크립트의 한계: 정적 타입 vs 동적 데이터&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;타입스크립트는 '기대하는 데이터의 형태'를 정의할 뿐, 실제 들어온 데이터가 그 형태인지 확인하지는 않습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ_Ac&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;interface User {
  id: number;
  name: string;
}

// API 응답이 { id: &quot;1&quot;, name: null }로 와도 TS는 에러를 잡지 못함
const user: User = await fetch('/api/user').then(res =&amp;gt; res.json());
console.log(user.name.toUpperCase()); // 런타임 에러 발생!
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;이처럼 외부 데이터에 의존하는 프런트엔드 환경에서는 **런타임에서의 데이터 검증(Runtime Validation)**이 필수적입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;11&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size26&quot;&gt;2. Zod란 무엇인가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;Zod는 &lt;b data-index-in-node=&quot;5&quot; data-path-to-node=&quot;13&quot;&gt;'TypeScript-first'&lt;/b&gt; 스키마 선언 및 유효성 검사 라이브러리입니다. 단순히 데이터가 맞는지 확인하는 것을 넘어, 검증된 데이터로부터 &lt;b data-index-in-node=&quot;86&quot; data-path-to-node=&quot;13&quot;&gt;타입스크립트 타입을 자동으로 추출&lt;/b&gt;해 주는 기능을 제공합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;2.1 Zod 스키마 정의하기&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ_Qc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(), // 이메일 형식까지 검증 가능
  role: z.enum(['admin', 'user']),
});

// 스키마로부터 타입 추출 (중복 정의 필요 없음!)
type User = z.infer&amp;lt;typeof UserSchema&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;16&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;3. 실전 활용: API 응답 안전하게 받기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;Zod의 parse 메서드를 사용하면 데이터가 스키마와 일치하지 않을 때 즉시 에러를 발생시켜, 잘못된 데이터가 앱 내부로 흘러 들어오는 것을 원천 차단합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size23&quot;&gt;3.1 안전한 패칭 로직&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ_gc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;async function fetchUser(id: number) {
  const response = await fetch(`/api/user/${id}`);
  const rawData = await response.json();

  // 데이터 검증 시작
  const result = UserSchema.safeParse(rawData);

  if (!result.success) {
    // 유효성 검사 실패 시 처리 (로깅, 에러 바운더리 등)
    console.error(&quot;데이터 형식이 올바르지 않습니다:&quot;, result.error.format());
    throw new Error(&quot;Invalid Data&quot;);
  }

  return result.data; // 여기서 반환되는 데이터는 완벽하게 타입이 보장됨
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;21&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;4. Zod의 강력한 기능들&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size23&quot;&gt;4.1 기본값 및 변환 (Transform)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;데이터를 검증함과 동시에 원하는 형태로 가공할 수도 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;25&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,0,0&quot;&gt;default&lt;/b&gt;: 데이터가 없을 때 기본값 부여.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,1,0&quot;&gt;transform&lt;/b&gt;: 문자열로 온 날짜 데이터를 Date 객체로 자동 변환.&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ_wc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const ProductSchema = z.object({
  price: z.number(),
  createdAt: z.string().transform((str) =&amp;gt; new Date(str)),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size23&quot;&gt;4.2 조건부 검증&lt;/h3&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;&quot;나이가 19세 이상일 때만 성인 인증 여부가 필수&quot; 같은 복잡한 로직도. refine()을 통해 구현할 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;29&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;5. React Hook Form과의 찰떡궁합&lt;/h2&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드에서 Zod가 가장 많이 쓰이는 곳 중 하나가 바로 &lt;b data-index-in-node=&quot;34&quot; data-path-to-node=&quot;31&quot;&gt;폼(Form) 검증&lt;/b&gt;입니다. react-hook-form의 zodResolver를 사용하면 복잡한 유효성 검사 로직을 선언적으로 관리할 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQgAg&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;const { register, handleSubmit } = useForm({
  resolver: zodResolver(SignUpSchema),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 입력을 마치는 순간 Zod 스키마에 따라 즉시 검증이 이뤄지며, 타입 안전성까지 챙길 수 있어 실무에서 매우 선호되는 조합입니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>APISafety</category>
      <category>cleancode</category>
      <category>ReactHookForm</category>
      <category>RuntimeValidation</category>
      <category>SchemaValidation</category>
      <category>TypeScript</category>
      <category>Zod</category>
      <category>데이터안전성</category>
      <category>런타임검증</category>
      <category>스키마설계</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/32</guid>
      <comments>https://minari98.tistory.com/entry/TS-API-%EC%9D%91%EB%8B%B5-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%97%90-%ED%83%80%EC%9E%85-%EC%95%88%EC%A0%84%EC%84%B1-%EC%9E%85%ED%9E%88%EA%B8%B0-Zod%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%9F%B0%ED%83%80%EC%9E%84-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%82%AC#entry32comment</comments>
      <pubDate>Tue, 28 Apr 2026 12:00:56 +0900</pubDate>
    </item>
    <item>
      <title>[TS] 제네릭(Generic)을 활용한 재사용 가능한 고차 컴포넌트(HOC) 설계</title>
      <link>https://minari98.tistory.com/entry/TS-%EC%A0%9C%EB%84%A4%EB%A6%ADGeneric%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%9E%AC%EC%82%AC%EC%9A%A9-%EA%B0%80%EB%8A%A5%ED%95%9C-%EA%B3%A0%EC%B0%A8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8HOC-%EC%84%A4%EA%B3%84</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프런트엔드 개발을 하다 보면 &quot;로직은 똑같은데 다루는 데이터 타입만 다른&quot; 경우를 자주 만납니다. 예를 들어, API 응답 데이터를 받아서 리스트를 그려주는 컴포넌트가 있다고 합시다. 어떤 곳에서는 User []를 다루고, 어떤 곳에서는 Product []를 다룹니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;이때 타입마다 별도의 컴포넌트를 만드는 것은 비효율적입니다. 그렇다고 any를 쓰자니 타입 안정성이 깨집니다. 이럴 때 필요한 것이 바로 &lt;b&gt;제네릭(Generic)&lt;/b&gt;입니다. 제네릭은 타입을 마치 함수의 &lt;b&gt;'인수(Argument)'&lt;/b&gt;처럼 취급하여, 사용하는 시점에 타입을 결정하게 해 줍니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 제네릭(Generic)이란 무엇인가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;제네릭은 한마디로 &lt;b&gt;'타입의 변수화&lt;/b&gt;'입니다. 컴포넌트나 함수를 정의할 때는 타입을 비워두었다가, 실제로 사용할 때 구체적인 타입을 주입하는 방식입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;1.1 기본적인 제네릭 함수&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQuQc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;function wrapInArray&amp;lt;T&amp;gt;(value: T): T[] {
  return [value];
}

const stringArray = wrapInArray&amp;lt;string&amp;gt;(&quot;Hello&quot;); // T가 string이 됨
const numberArray = wrapInArray&amp;lt;number&amp;gt;(123);     // T가 number가 됨
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;위 코드에서 &amp;lt;T&amp;gt;는 관습적으로 사용하는 타입 변수명입니다. wrapInArray는 호출되는 순간 입력받은 값의 타입을 추론하여 반환 타입까지 완벽하게 맞물리게 합니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;12&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size26&quot;&gt;2. 실전! 제네릭을 활용한 리액트 컴포넌트&lt;/h2&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;제네릭은 리액트 컴포넌트에서 데이터 목록(List)이나 테이블(Table)을 만들 때 가장 빛을 발합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size23&quot;&gt;2.1 Generic List 컴포넌트 설계&lt;/h3&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;다양한 형태의 데이터를 렌더링 할 수 있는 리스트 컴포넌트를 만들어 보겠습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQugc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;interface ListProps&amp;lt;T&amp;gt; {
  items: T[];
  renderItem: (item: T) =&amp;gt; React.ReactNode;
}

function List&amp;lt;T&amp;gt;({ items, renderItem }: ListProps&amp;lt;T&amp;gt;) {
  return (
    &amp;lt;ul&amp;gt;
      {items.map((item, index) =&amp;gt; (
        &amp;lt;li key={index}&amp;gt;{renderItem(item)}&amp;lt;/li&amp;gt;
      ))}
    &amp;lt;/ul&amp;gt;
  );
}

// 사용 예시
&amp;lt;List&amp;lt;User&amp;gt; 
  items={users} 
  renderItem={(user) =&amp;gt; &amp;lt;span&amp;gt;{user.name}&amp;lt;/span&amp;gt;} 
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 renderItem 내부의 user는 자동으로 User 타입이 됩니다. any 없이도 완벽한 자동 완성 기능을 누릴 수 있는 것이죠.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;19&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size26&quot;&gt;3. 고차 컴포넌트(HOC)에서의 제네릭 활용&lt;/h2&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;고차 컴포넌트(HOC)는 컴포넌트를 인자로 받아 기능을 추가한 새 컴포넌트를 반환하는 패턴입니다. 여기에 제네릭을 입히면 어떤 컴포넌트가 들어와도 타입을 유지한 채로 기능을 덧붙일 수 있습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size23&quot;&gt;3.1 로딩 상태를 주입하는 withLoading HOC&lt;/h3&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;컴포넌트에 로딩 스피너 기능을 추가하는 제네릭 HOC 예시입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQuwc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;function withLoading&amp;lt;P extends object&amp;gt;(
  WrappedComponent: React.ComponentType&amp;lt;P&amp;gt;
) {
  return function WithLoadingComponent({ isLoading, ...props }: { isLoading: boolean } &amp;amp; P) {
    if (isLoading) return &amp;lt;Spinner /&amp;gt;;
    return &amp;lt;WrappedComponent {...(props as P)} /&amp;gt;;
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;여기서 P extends object는 &quot;인자로 들어오는 Props 타입은 객체 형태여야 한다&quot;는 제약을 거는 것입니다. 이를 통해 원본 컴포넌트가 가진 Props 타입을 그대로 보존하면서 isLoading이라는 새로운 Prop만 추가할 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;26&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size26&quot;&gt;4. 제네릭 제약 조건 (Constraints)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;제네릭을 무조건 자유롭게 두는 것보다, 특정 조건을 만족할 때만 사용 가능하도록 제한하는 것이 더 안전합니다. extends 키워드를 사용합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQvAc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;interface HasId {
  id: string;
}

// T는 반드시 id를 가진 객체여야 함
function logId&amp;lt;T extends HasId&amp;gt;(item: T) {
  console.log(item.id);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 제약을 걸면 logId({ name: 'Yumina' })는 에러가 발생하지만, logId({ id: '1', name: 'Yumina' })는 통과됩니다. 실무에서 API 응답 형태를 보장할 때 자주 쓰는 패턴입니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>cleancode</category>
      <category>Generic</category>
      <category>GenericConstraing</category>
      <category>HOC</category>
      <category>React</category>
      <category>TypeInference</category>
      <category>TypeScript</category>
      <category>고차컴포넌트</category>
      <category>재사용성</category>
      <category>타입추론</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/31</guid>
      <comments>https://minari98.tistory.com/entry/TS-%EC%A0%9C%EB%84%A4%EB%A6%ADGeneric%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%9E%AC%EC%82%AC%EC%9A%A9-%EA%B0%80%EB%8A%A5%ED%95%9C-%EA%B3%A0%EC%B0%A8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8HOC-%EC%84%A4%EA%B3%84#entry31comment</comments>
      <pubDate>Mon, 27 Apr 2026 14:03:41 +0900</pubDate>
    </item>
    <item>
      <title>[TS] Utility Types 정복하기: Pick, Omit, Partial로 중복 없는 타입 정의</title>
      <link>https://minari98.tistory.com/entry/TS-Utility-Types-%EC%A0%95%EB%B3%B5%ED%95%98%EA%B8%B0-Pick-Omit-Partial%EB%A1%9C-%EC%A4%91%EB%B3%B5-%EC%97%86%EB%8A%94-%ED%83%80%EC%9E%85-%EC%A0%95%EC%9D%98</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;타입스크립트(TypeScript)로 규모가 큰 프로젝트를 진행하다 보면 비슷한 형태의 타입을 반복해서 정의하게 되는 순간이 옵니다. 예를 들어, 전체 사용자 정보를 담은 User 타입이 있는데, 프로필 수정 페이지에서는 일부 정보만 필요하고, 회원가입 페이지에서는 특정 정보가 빠져야 하는 경우입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;이때마다 UpdateUser, RegisterUser 처럼 새로운 인터페이스를 일일이 만드는 것은 매우 비효율적입니다. 원본 타입이 수정되면 연관된 모든 타입을 찾아가서 고쳐야 하기 때문이죠. 리액트의 컴포넌트 재사용처럼, 타입스크립트에도 &lt;b&gt;'타입 재사용'&lt;/b&gt;을 위한 강력한 도구가 있습니다. 바로 &lt;b&gt;유틸리티 타입(Utility Types)&lt;/b&gt;입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 왜 유틸리티 타입인가? (DRY 원칙)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;개발 원칙 중 하나인 &lt;b&gt;DRY(Don't Repeat Yourself, 반복하지 마라)&lt;/b&gt;는 타입 정의에도 적용됩니다. 유틸리티 타입을 사용하면 하나의 'Source of Truth(진실의 근원)' 타입을 두고, 이를 필요에 따라 변형하여 사용할 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;9&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size26&quot;&gt;2. Partial&amp;lt;T&amp;gt;: 모든 속성을 선택 사항으로&lt;/h2&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;Partial은 가장 자주 쓰이는 유틸리티 타입 중 하나입니다. 기존 타입의 모든 속성을 optional(?)로 바꾸어 줍니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;2.1 실무 활용: 데이터 업데이트(Update)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;프로필 수정 기능에서는 사용자가 이름만 바꿀 수도 있고, 이메일만 바꿀 수도 있습니다. 모든 필드를 보낼 필요가 없을 때 Partial이 제격입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQlgc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;interface User {
  id: string;
  name: string;
  email: string;
  age: number;
}

// 모든 필드가 선택 사항이 됨
function updateUser(id: string, fieldsToUpdate: Partial&amp;lt;User&amp;gt;) {
  // ...업데이트 로직
}

updateUser(&quot;123&quot;, { name: &quot;Yumina&quot; }); // email, age가 없어도 에러가 나지 않음
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;15&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size26&quot;&gt;3. Pick&amp;lt;T, K&amp;gt;: 필요한 것만 골라 담기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;Pick은 기존 타입에서 특정 속성(Key)들만 골라내어 새로운 타입을 만듭니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size23&quot;&gt;3.1 실무 활용: 리스트 아이템 UI&lt;/h3&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;사용자 상세 정보는 아주 많지만, 목록 화면(List)에서는 이름과 이메일만 보여주면 될 때 유용합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQlwc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// User 타입에서 'name'과 'email'만 추출
type UserSummary = Pick&amp;lt;User, 'name' | 'email'&amp;gt;;

const summary: UserSummary = {
  name: &quot;Yumina&quot;,
  email: &quot;dev@example.com&quot;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;21&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;4. Omit&amp;lt;T, K&amp;gt;: 필요 없는 것만 빼기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;Omit은 Pick과 반대입니다. 특정 속성들만 제외한 나머지를 모두 가져옵니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size23&quot;&gt;4.1 실무 활용: 회원가입 시 ID 제외&lt;/h3&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;DB에 저장될 때는 id가 자동으로 생성되므로, 회원가입 폼에서 입력받는 데이터 타입에는 id가 없어야 합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQmAc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// User 타입에서 'id'만 제외하고 나머지 모두 사용
type RegisterFormData = Omit&amp;lt;User, 'id'&amp;gt;;

const newUser: RegisterFormData = {
  name: &quot;Yumina&quot;,
  email: &quot;new@example.com&quot;,
  age: 25
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;27&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size26&quot;&gt;5. Readonly&amp;lt;T&amp;gt;: 불변성 유지하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;객체의 속성을 수정할 수 없게 만듭니다. Redux나 Zustand 같은 상태 관리 라이브러리에서 상태의 **불변성(Immutability)**을 강제하고 싶을 때 유용합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQmQc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;const config: Readonly&amp;lt;User&amp;gt; = {
  id: &quot;0&quot;,
  name: &quot;System&quot;,
  email: &quot;admin@system.com&quot;,
  age: 99
};

// config.name = &quot;Hack&quot;; // 에러 발생: 읽기 전용 속성이므로 할당할 수 없습니다.
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;31&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;32&quot; data-ke-size=&quot;size26&quot;&gt;6. 고급 팁: 유틸리티 타입 조합하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;유틸리티 타입은 중첩해서 사용할 수 있습니다. 예를 들어, &quot;업데이트 요청을 보낼 때, ID는 필수이고 나머지 정보는 선택 사항&quot;인 타입을 만들고 싶다면 어떡할까요?&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQmgc&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;type UpdateRequest = Pick&amp;lt;User, 'id'&amp;gt; &amp;amp; Partial&amp;lt;Omit&amp;lt;User, 'id'&amp;gt;&amp;gt;;

const request: UpdateRequest = {
  id: &quot;123&quot;,        // 필수
  name: &quot;Changed&quot;   // 선택
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;이런 식으로 조합하면 극도로 정교하고 안전한 타입 정의가 가능해집니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>cleancode</category>
      <category>DryPrinciple</category>
      <category>omit</category>
      <category>partial</category>
      <category>pick</category>
      <category>readonly</category>
      <category>TypeSafery</category>
      <category>TypeScript</category>
      <category>UtilityTypes</category>
      <category>타입재사용</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/30</guid>
      <comments>https://minari98.tistory.com/entry/TS-Utility-Types-%EC%A0%95%EB%B3%B5%ED%95%98%EA%B8%B0-Pick-Omit-Partial%EB%A1%9C-%EC%A4%91%EB%B3%B5-%EC%97%86%EB%8A%94-%ED%83%80%EC%9E%85-%EC%A0%95%EC%9D%98#entry30comment</comments>
      <pubDate>Mon, 27 Apr 2026 13:53:21 +0900</pubDate>
    </item>
    <item>
      <title>[TS] any 대신 unknown을 써야 하는 이유와 타입 가드 활용법</title>
      <link>https://minari98.tistory.com/entry/TS-any-%EB%8C%80%EC%8B%A0-unknown%EC%9D%84-%EC%8D%A8%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%83%80%EC%9E%85-%EA%B0%80%EB%93%9C-%ED%99%9C%EC%9A%A9%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;타입스크립트(TypeScript)를 처음 접하면 가장 먼저 배우는 단어가 있습니다. 바로 any입니다. 어떤 타입이든 허용한다는 마법 같은 단어지만, 아이러니하게도 any를 남발하는 순간 우리는 타입스크립트를 사용하는 가장 큰 이유인 &lt;b&gt;'타입 안전성'&lt;/b&gt;을 포기하게 됩니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;실무에서 외부 API 응답이나 동적 데이터를 다룰 때, 우리는 데이터의 정확한 형태를 알 수 없는 상황에 직면합니다. 이때 습관적으로 any를 쓰기보다 더 안전하고 견고한 &lt;b data-index-in-node=&quot;96&quot; data-path-to-node=&quot;5&quot;&gt;unknown&lt;/b&gt; 타입을 선택해야 합니다. 오늘은 왜 unknown이 any보다 우월한지, 그리고 unknown 데이터를 안전하게 요리하는 &lt;b data-index-in-node=&quot;172&quot; data-path-to-node=&quot;5&quot;&gt;타입 가드(Type Guard)&lt;/b&gt; 기법을 상세히 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. any: 모든 안전장치를 해제하는 위험한 열쇠&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;any는 타입스크립트의 타입 검사기(Type Checker)를 완전히 무력화합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ0wY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;ceylon&quot;&gt;&lt;code&gt;let value: any = 10;
value.toUpperCase(); // 컴파일 타임에 에러가 발생하지 않음 -&amp;gt; 런타임에 에러 발생!
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;위 코드에서 value는 숫자지만 any 타입이기 때문에 문자열 메서드인 toUpperCase 호출을 허용합니다. 결국 이 오류는 사용자가 앱을 실행하는 도중 &lt;b&gt;&quot;Uncaught TypeError&quot;&lt;/b&gt;라는 이름으로 터지게 됩니다. 사실상 자바스크립트를 쓰는 것과 다를 바 없는 상태가 되는 것이죠.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;11&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size26&quot;&gt;2. unknown: &quot;무엇인지 모르니 확인하고 쓰세요&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;unknown 타입은 any와 마찬가지로 모든 값을 할당받을 수 있습니다. 하지만 결정적인 차이점은 &lt;b data-index-in-node=&quot;56&quot; data-path-to-node=&quot;13&quot;&gt;'사용할 때'&lt;/b&gt; 발생합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;2.1 unknown의 제약 사항&lt;/h3&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;unknown 타입으로 정의된 변수는 타입을 정적으로 확정하기 전까지는 어떠한 동작도 수행할 수 없습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ1AY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;let value: unknown = 10;

// 에러 발생: 'value'의 형식이 'unknown'입니다.
value.toUpperCase(); 

// 타입을 확인해야만 사용 가능
if (typeof value === &quot;string&quot;) {
  console.log(value.toUpperCase()); // 이제 안전하게 사용 가능!
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;이처럼 unknown은 개발자에게 &lt;b&gt;&quot;이 데이터의 타입을 먼저 검증하라&quot;라고&lt;/b&gt; 강제합니다. 컴파일러가 우리 대신 안전장치를 한 번 더 확인해 주는 셈입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;18&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size26&quot;&gt;3. unknown을 안전하게 다루는 법: 타입 가드(Type Guard)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;unknown 데이터의 정체를 밝히기 위해 사용하는 기법이 바로 타입 가드입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size23&quot;&gt;3.1 typeof 연산자 (기본 타입 검사)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;숫자, 문자열, 불리언 등 자바스크립트의 기본 타입을 확인할 때 사용합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size23&quot;&gt;3.2 instanceof 연산자 (클래스/객체 검사)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;특정 클래스의 인스턴스인지 확인할 때 유용합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ1QY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;function processDate(input: unknown) {
  if (input instanceof Date) {
    console.log(input.toISOString()); // Date 객체임이 보장됨
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;3.3 사용자 정의 타입 가드 (Type Predicates)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;가장 강력하고 실무에서 많이 쓰이는 방식입니다. is 키워드를 사용하여 함수가 특정 타입임을 명시적으로 알려줍니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ1gY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;interface User {
  name: string;
  age: number;
}

// User 타입인지 확인하는 가드 함수
function isUser(target: any): target is User {
  return (
    typeof target === &quot;object&quot; &amp;amp;&amp;amp;
    target !== null &amp;amp;&amp;amp;
    &quot;name&quot; in target &amp;amp;&amp;amp;
    &quot;age&quot; in target
  );
}

function greet(input: unknown) {
  if (isUser(input)) {
    console.log(`안녕하세요, ${input.name}님!`); // 여기서 input은 자동으로 User 타입이 됨
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;29&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;4. 실무 예시: API 에러 처리&lt;/h2&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;API 통신 중 발생하는 에러(catch 문의 error 객체)는 기본적으로 unknown 타입(TS 4.4 이상)입니다. 이때 unknown과 타입 가드를 활용하면 매우 견고한 에러 처리가 가능합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ1wY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;try {
  await fetchData();
} catch (error: unknown) {
  if (error instanceof AxiosError) {
    // axios 에러인 경우 처리
    console.error(error.response?.data.message);
  } else if (error instanceof Error) {
    // 일반 에러인 경우 처리
    console.error(error.message);
  } else {
    // 그 외의 경우
    console.error(&quot;알 수 없는 에러 발생&quot;, error);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;이전처럼 (error as any). message라고 썼을 때 발생할 수 있는 런타임 폭탄을 원천 차단할 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;34&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;35&quot; data-ke-size=&quot;size26&quot;&gt;5. any는 절대 쓰면 안 되나요?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;현실적으로 any가 필요한 순간도 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;37&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;p data-path-to-node=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이는 어디까지나 &lt;b&gt;'임시방편'&lt;/b&gt;이어야 합니다. any를 썼다면 반드시 TODO 주석을 남기고 나중에 적절한 타입으로 교체해야 합니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>any</category>
      <category>cleancode</category>
      <category>typeguard</category>
      <category>TypeNarrowing</category>
      <category>TypeSafety</category>
      <category>TypeScript</category>
      <category>UNKNOWN</category>
      <category>런타임에러방지</category>
      <category>타입가드</category>
      <category>타입스크립트</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/29</guid>
      <comments>https://minari98.tistory.com/entry/TS-any-%EB%8C%80%EC%8B%A0-unknown%EC%9D%84-%EC%8D%A8%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%83%80%EC%9E%85-%EA%B0%80%EB%93%9C-%ED%99%9C%EC%9A%A9%EB%B2%95#entry29comment</comments>
      <pubDate>Mon, 27 Apr 2026 13:49:38 +0900</pubDate>
    </item>
    <item>
      <title>[React Query] 데이터 패칭 최적화: staleTime과 cacheTime의 미묘한 차이 이해하기</title>
      <link>https://minari98.tistory.com/entry/React-Query-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%8C%A8%EC%B9%AD-%EC%B5%9C%EC%A0%81%ED%99%94-staleTime%EA%B3%BC-cacheTime%EC%9D%98-%EB%AF%B8%EB%AC%98%ED%95%9C-%EC%B0%A8%EC%9D%B4-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;서버 상태 관리 라이브러리의 표준이 된 &lt;b data-index-in-node=&quot;22&quot; data-path-to-node=&quot;4&quot;&gt;React Query&lt;/b&gt;. 그 핵심 기능은 단연 '캐싱'입니다. 하지만 설정을 들여다보면 staleTime과 cacheTime이라는 비슷해 보이는 두 가지 옵션이 우리를 혼란스럽게 합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&quot;둘 다 시간 설정 같은데, 하나만 쓰면 안 되나?&quot;, &quot;왜 캐시 타임을 늘렸는데 데이터가 계속 새로고침 되지?&quot; 이런 의문을 가져보셨다면 오늘 글이 완벽한 해답이 될 것입니다. 이 두 지표의 메커니즘을 이해하는 것은 효율적인 프런트엔드 성능 최적화의 첫걸음입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 데이터의 상태: Fresh vs Stale&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;본격적인 비교에 앞서, 리액트 쿼리가 데이터를 바라보는 두 가지 상태를 이해해야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;Fresh (신선한 상태):&lt;/b&gt; 서버에서 막 가져온 데이터입니다. 이 상태에서는 컴포넌트가 다시 마운트되어도 추가적인 네트워크 요청을 보내지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;Stale (탁한/상한 상태):&lt;/b&gt; 가져온 지 일정 시간이 지나 '신선하지 않다'고 판단된 데이터입니다. 이 상태의 데이터는 컴포넌트가 마운트 되거나 윈도가 포커스 될 때 &lt;b&gt;백그라운드에서 다시 요청(Refetch)&lt;/b&gt;됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;10&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size26&quot;&gt;2. staleTime: &quot;이 데이터는 언제까지 신선한가?&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;staleTime은 데이터를 &lt;b data-index-in-node=&quot;16&quot; data-path-to-node=&quot;12&quot;&gt;Fresh 상태에서 Stale 상태로 변경하는 데 걸리는 시간&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;2.1 동작 원리&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;14&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터를 처음 가져오면 즉시 Fresh 상태가 됩니다.&lt;/li&gt;
&lt;li&gt;staleTime이 5분(300,000ms)이라면, 5분 동안은 컴포넌트가 몇 번을 리렌더링 되거나 다시 마운트 되어도 서버에 요청을 보내지 않습니다.&lt;/li&gt;
&lt;li&gt;5분이 지나면 데이터는 Stale 상태가 됩니다. 이때부터는 컴포넌트가 마운트될 때마다 서버에 &quot;최신 데이터 맞니?&quot;라고 물어보게 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size23&quot;&gt;2.2 실무 팁&lt;/h3&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;자주 바뀌지 않는 공지사항이나 설정 데이터는 staleTime을 길게(예: 1시간) 잡는 것이 서버 부하를 줄이는 데 매우 효과적입니다. 반면 주식 시세나 실시간 채팅은 0으로 설정하여 항상 최신 상태를 유지해야 합니다. (기본값은 0입니다.)&lt;/p&gt;
&lt;hr data-path-to-node=&quot;17&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size26&quot;&gt;3. gcTime (과거 cacheTime): &quot;메모리에 얼마나 남겨둘 것인가?&quot;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;Next.js나 최신 버전의 React Query(v5)에서는 cacheTime이 &lt;b&gt;gcTime (Garbage Collection Time)&lt;/b&gt;으로 이름이 바뀌었습니다. 이름에서 알 수 있듯, 이는 데이터를 메모리에서 완전히 삭제(청소)하기까지의 시간입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size23&quot;&gt;3.1 동작 원리&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;21&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터를 사용하는 모든 컴포넌트가 언마운트되어 &lt;b data-index-in-node=&quot;26&quot; data-path-to-node=&quot;21,0,0&quot;&gt;'비활성(Inactive)'&lt;/b&gt; 상태가 된 순간부터 타이머가 돌아갑니다.&lt;/li&gt;
&lt;li&gt;기본값인 5분(300,000ms) 동안 사용되지 않으면, 리액트 쿼리는 메모리 절약을 위해 해당 데이터를 삭제합니다.&lt;/li&gt;
&lt;li&gt;데이터가 삭제되기 전에 다시 컴포넌트가 마운트 되면, 서버 응답을 기다리는 동안 메모리에 남아있던 '캐시 데이터'를 먼저 보여주어 사용자 경험을 높입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;22&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;4. 결정적 차이: 쟁점은 '네트워크 요청'인가 '메모리 삭제'인가&lt;/h2&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;두 개념을 한 문장으로 정리하면 다음과 같습니다.&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;25&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;25,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,0&quot;&gt;staleTime은 다시 요청(Refetch)할 것인가를 결정하고, gcTime은 메모리에서 지울 것인가를 결정합니다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;4.1 시나리오로 이해하기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;설정: staleTime: 2분, gcTime: 5분&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;28&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,0,0&quot;&gt;0분:&lt;/b&gt; 데이터 패칭 완료. 데이터는 Fresh 상태.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,1,0&quot;&gt;1분:&lt;/b&gt; 페이지 이동으로 컴포넌트 언마운트. 데이터는 Inactive 상태가 되며 gcTime 타이머 시작.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,2,0&quot;&gt;3분:&lt;/b&gt; 다시 컴포넌트 마운트.
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;28,2,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;staleTime(2분)이 지났으므로 데이터는 Stale 상태입니다. -&amp;gt; &lt;b data-index-in-node=&quot;42&quot; data-path-to-node=&quot;28,2,1,0,0&quot;&gt;네트워크 요청 발생.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;gcTime(5분)은 아직 안 지났으므로 메모리에 데이터가 남아있습니다. -&amp;gt; &lt;b data-index-in-node=&quot;44&quot; data-path-to-node=&quot;28,2,1,1,0&quot;&gt;서버 응답 전까지 이전 데이터를 화면에 즉시 보여줌 (Flash of old content).&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,3,0&quot;&gt;6분:&lt;/b&gt; 컴포넌트 마운트.
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;28,3,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;gcTime이 지났으므로 메모리에 데이터가 없습니다. -&amp;gt; &lt;b data-index-in-node=&quot;33&quot; data-path-to-node=&quot;28,3,1,0,0&quot;&gt;로딩 스피너를 보여주며 새로 가져옴.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;29&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;5. 실무 최적화 전략: 어떻게 설정하는 것이 베스트인가?&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;31&quot; data-ke-size=&quot;size23&quot;&gt;5.1 staleTime &amp;lt; gcTime (권장 패턴)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;가장 일반적인 설정입니다. 메모리에는 데이터를 남겨두어 로딩 속도를 높이되, 일정 시간이 지나면 서버에서 최신화를 시도하는 방식입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size23&quot;&gt;5.2 staleTime &amp;gt; gcTime (의미 없는 설정)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;만약 데이터를 신선하다고 생각해서 요청을 안 하려는데(staleTime 10분), 메모리에서 이미 지워버렸다면(gcTime 2분), 결국 다시 서버에서 가져와야 하므로 최적화 효과가 사라집니다. &lt;b data-index-in-node=&quot;109&quot; data-path-to-node=&quot;34&quot;&gt;항상 gcTime은 staleTime보다 길거나 같아야 합니다.&lt;/b&gt;&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>cacheTime</category>
      <category>CachingStrategy</category>
      <category>gcTime</category>
      <category>reactQuery</category>
      <category>staleTime</category>
      <category>statemanagement</category>
      <category>TanstackQuery</category>
      <category>데이터캐싱</category>
      <category>서버상태관리</category>
      <category>성능최적화</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/28</guid>
      <comments>https://minari98.tistory.com/entry/React-Query-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%8C%A8%EC%B9%AD-%EC%B5%9C%EC%A0%81%ED%99%94-staleTime%EA%B3%BC-cacheTime%EC%9D%98-%EB%AF%B8%EB%AC%98%ED%95%9C-%EC%B0%A8%EC%9D%B4-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0#entry28comment</comments>
      <pubDate>Mon, 27 Apr 2026 13:45:46 +0900</pubDate>
    </item>
    <item>
      <title>[Architecture] 프론트엔드 클린 아키텍처: 도메인 로직과 UI 컴포넌트 분리하기</title>
      <link>https://minari98.tistory.com/entry/Architecture-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%A1%9C%EC%A7%81%EA%B3%BC-UI-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EB%B6%84%EB%A6%AC%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프런트엔드 프로젝트 초기에는 모든 것이 순조롭습니다. 하지만 컴포넌트가 수십 개로 늘어나고 비즈니스 요구사항이 복잡해지면, 하나의 컴포넌트 파일이 500줄을 넘어가기 시작합니다. 그 안에는 API 호출, 데이터 가공, 상태 관리, 그리고 UI 렌더링 로직이 뒤섞여 있어 작은 수정 하나에도 어디가 고장 날지 모르는 '스파게티 코드'가 되어버리곤 합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 백엔드에서 주로 쓰이던 &lt;b data-index-in-node=&quot;24&quot; data-path-to-node=&quot;5&quot;&gt;'클린 아키텍처(Clean Architecture)'&lt;/b&gt; 개념을 프런트엔드에 도입해야 합니다. 핵심은 하나입니다. &lt;b&gt;&quot;UI(어떻게 보여줄 것인가)와 도메인 로직(무엇을 할 것인가)을 철저히 분리하는 것&quot;&lt;/b&gt;입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 왜 프론트엔드에도 아키텍처가 필요한가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;프런트엔드 기술 스택은 매우 빠르게 변합니다. 리액트 버전이 올라가고, 스타일 라이브러리를 Tailwind에서 다른 것으로 바꾸거나, 상태 관리 도구를 교체해야 할 상황이 올 수 있습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;만약 비즈니스 로직(예: 할인율 계산, 장바구니 로직 등)이 특정 UI 컴포넌트나 라이브러리에 강하게 결합되어 있다면, 기술 스택을 바꿀 때 비즈니스 로직까지 모두 새로 짜야합니다. 클린 아키텍처는 &lt;b data-index-in-node=&quot;112&quot; data-path-to-node=&quot;9&quot;&gt;기술적인 세부 사항(UI, 라이브러리)이 비즈니스 핵심 로직을 오염시키지 않도록 방어벽을 세우는 작업&lt;/b&gt;입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;10&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size26&quot;&gt;2. 프론트엔드 레이어링: 3단계 계층 구조&lt;/h2&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;프런트엔드 환경에 맞게 레이어를 크게 세 가지로 나눌 수 있습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;2.1 도메인 레이어 (Domain Layer / Entities)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;가장 핵심이 되는 비즈니스 규칙과 데이터 모델이 위치합니다. 이 레이어는 &lt;b data-index-in-node=&quot;41&quot; data-path-to-node=&quot;14&quot;&gt;리액트(React)나 특정 프레임워크에 전혀 의존하지 않는 순수 자바스크립트/타입스크립트 코드&lt;/b&gt;여야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;15&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,0,0&quot;&gt;예:&lt;/b&gt; calculateDiscount(price, coupon), validateEmail(email) 등&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size23&quot;&gt;2.2 서비스/애플리케이션 레이어 (Service / Use Cases)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;도메인 로직을 조합하여 실제 사용자 시나리오를 구현합니다. API를 호출하고 데이터를 도메인 모델로 변환하는 등의 역할을 수행합니다. 주로 &lt;b data-index-in-node=&quot;78&quot; data-path-to-node=&quot;17&quot;&gt;Custom Hooks&lt;/b&gt;가 이 역할을 담당하게 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;18&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,0,0&quot;&gt;예:&lt;/b&gt; useCart, useAuth 등&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size23&quot;&gt;2.3 프레젠테이션 레이어 (Presentation Layer / UI)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;사용자에게 화면을 보여주고 입력을 받는 역할만 합니다. 로직은 최대한 배제하고, 서비스 레이어에서 제공하는 데이터와 함수를 연결하기만 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;21&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,0,0&quot;&gt;예:&lt;/b&gt; Button, CartList, UserProfile 등&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;22&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;3. 실전 적용: 컴포넌트에서 로직 추출하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;배송비 계산 로직이 들어있는 장바구니 컴포넌트를 예로 들어보겠습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;25&quot; data-ke-size=&quot;size23&quot;&gt;3.1 Bad: 로직과 UI가 뒤섞인 경우&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQvAU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 모든 로직이 컴포넌트 안에 있어 재사용과 테스트가 어려움
function Cart({ items }) {
  const totalPrice = items.reduce((acc, item) =&amp;gt; acc + item.price, 0);
  const deliveryFee = totalPrice &amp;gt; 50000 ? 0 : 3000;

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;p&amp;gt;총 합계: {totalPrice + deliveryFee}원&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size23&quot;&gt;3.2 Good: 도메인 로직과 서비스 레이어 분리&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQvQU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 1. 도메인 로직 (Pure JS)
// /domain/cart.ts
export const calculateDeliveryFee = (totalPrice) =&amp;gt; totalPrice &amp;gt; 50000 ? 0 : 3000;

// 2. 서비스 레이어 (Custom Hook)
// /services/useCart.ts
export function useCart(items) {
  const totalPrice = items.reduce((acc, item) =&amp;gt; acc + item.price, 0);
  const deliveryFee = calculateDeliveryFee(totalPrice);
  return { totalPrice, deliveryFee, finalPrice: totalPrice + deliveryFee };
}

// 3. 프레젠테이션 레이어 (UI)
function Cart({ items }) {
  const { finalPrice } = useCart(items);
  return &amp;lt;p&amp;gt;총 합계: {finalPrice}원&amp;lt;/p&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 분리하면 calculateDeliveryFee는 UI 없이도 단독으로 유닛 테스트를 수행할 수 있고, 나중에 리액트가 아닌 다른 환경에서도 그대로 사용할 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;30&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;31&quot; data-ke-size=&quot;size26&quot;&gt;4. 클린 아키텍처 도입의 이점&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;32&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,0,0&quot;&gt;유닛 테스트의 용이성:&lt;/b&gt; 복잡한 UI 렌더링을 신경 쓰지 않고 순수 함수 형태의 비즈니스 로직만 따로 떼어 테스트할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,1,0&quot;&gt;높은 가독성:&lt;/b&gt; 컴포넌트 파일만 봐도 이 화면이 어떤 구조인지 한눈에 들어옵니다. 세부 로직은 훅이나 도메인 파일에 숨겨져 있기 때문입니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,2,0&quot;&gt;유지보수성 향상:&lt;/b&gt; &quot;배송비 정책이 바뀌었다&quot;면 도메인 파일만 수정하면 됩니다. UI를 건드릴 필요가 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,3,0&quot;&gt;팀 협업:&lt;/b&gt; UI 개발자와 로직 개발자가 역할을 나누어 동시에 작업하기 수월해집니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;33&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;34&quot; data-ke-size=&quot;size26&quot;&gt;5. 주의할 점: 과유불급(過猶不及)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;모든 곳에 클린 아키텍처를 적용할 필요는 없습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;36&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단순히 값을 보여주기만 하는 페이지나, 일회성 이벤트 페이지에 이런 구조를 도입하는 것은 과도한 엔지니어링(Over-engineering) 일 수 있습니다.&lt;/li&gt;
&lt;li&gt;프로젝트의 &lt;b data-index-in-node=&quot;6&quot; data-path-to-node=&quot;36,1,0&quot;&gt;비즈니스 복잡도가 높고&lt;/b&gt;, &lt;b data-index-in-node=&quot;20&quot; data-path-to-node=&quot;36,1,0&quot;&gt;장기적으로 유지보수&lt;/b&gt;해야 하는 핵심 서비스(예: 쇼핑몰 결제, 대시보드 등)부터 단계적으로 적용하는 것을 권장합니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발일기</category>
      <category>CleanArchitecture</category>
      <category>DomainLogic</category>
      <category>ReactHooks</category>
      <category>softwaredesign</category>
      <category>SparationOfConcerns</category>
      <category>관심사분리</category>
      <category>단위테스트</category>
      <category>리액트아키텍처</category>
      <category>유지보수</category>
      <category>클린아키텍처</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/27</guid>
      <comments>https://minari98.tistory.com/entry/Architecture-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%A1%9C%EC%A7%81%EA%B3%BC-UI-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EB%B6%84%EB%A6%AC%ED%95%98%EA%B8%B0#entry27comment</comments>
      <pubDate>Mon, 27 Apr 2026 13:40:15 +0900</pubDate>
    </item>
    <item>
      <title>[React] 선언적 프로그래밍의 정수: Suspense와 ErrorBoundary로 우아한 UI 만들기</title>
      <link>https://minari98.tistory.com/entry/React-%EC%84%A0%EC%96%B8%EC%A0%81-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D%EC%9D%98-%EC%A0%95%EC%88%98-Suspense%EC%99%80-ErrorBoundary%EB%A1%9C-%EC%9A%B0%EC%95%84%ED%95%9C-UI-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;리액트(React)를 사용하여 데이터를 불러오는 컴포넌트를 만들 때, 우리는 보통 다음과 같은 코드를 작성하곤 합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ7gQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;if (isLoading) return &amp;lt;LoadingSpinner /&amp;gt;;
if (isError) return &amp;lt;ErrorMessage /&amp;gt;;
return &amp;lt;DataView data={data} /&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;익숙한 코드지만, 이 방식은 &lt;b&gt;'명령형(Imperative)'&lt;/b&gt;에 가깝습니다. 컴포넌트 하나가 비즈니스 로직뿐만 아니라 로딩 처리, 에러 처리라는 세 가지 책임을 모두 떠안게 되기 때문입니다. 프로젝트가 커질수록 모든 컴포넌트에 이런 중복 코드가 들어가게 되고, UI의 일관성은 떨어집니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 리액트가 지향하는 &lt;b&gt;'선언적 UI'&lt;/b&gt;의 완성형인 Suspense와 ErrorBoundary를 활용해, 비정상적인 상태(로딩, 에러)를 컴포넌트 밖으로 우아하게 밀어내는 전략을 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;8&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;1. 명령형 UI vs 선언적 UI&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size23&quot;&gt;1.1 무엇이 문제인가?&lt;/h3&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;명령형 방식에서는 개발자가 &quot;로딩 중이면 스피너를 보여주고, 아니면 데이터를 보여줘&quot;라고 일일이 로직을 지시해야 합니다. 이는 컴포넌트를 무겁게 만들고, 테스트를 어렵게 하며, 코드의 흐름을 방해합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;1.2 선언적 방식의 지향점&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;선언적 방식은 &quot;이 컴포넌트는 데이터가 있을 때 이렇게 그려져야 해&quot;라고 정의만 합니다. 데이터가 아직 준비되지 않았거나(Loading), 가져오는 데 실패했을 때(Error)의 처리는 컴포넌트를 감싸고 있는 '부모(상위 환경)'에게 맡깁니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;14&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;2. Suspense: 데이터 로딩을 기다리는 우아한 방법&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;Suspense는 하위 컴포넌트가 무언가를 '기다리고 있다'는 것을 리액트에게 알리는 도구입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size23&quot;&gt;2.1 주요 특징&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;18&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,0,0&quot;&gt;관심사 분리:&lt;/b&gt; 컴포넌트 내부에서 isLoading을 체크할 필요가 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,1,0&quot;&gt;로딩 UI의 중앙 집중화:&lt;/b&gt; 여러 개의 컴포넌트가 로딩 중일 때 하나의 스켈레톤 UI로 묶어서 보여줄 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ7wQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { Suspense } from 'react';

function App() {
  return (
    &amp;lt;Layout&amp;gt;
      &amp;lt;Suspense fallback={&amp;lt;SkeletonUI /&amp;gt;}&amp;gt;
        &amp;lt;UserProfile /&amp;gt; {/* 내부에서 데이터 패칭 중이라면 fallback이 보임 */}
      &amp;lt;/Suspense&amp;gt;
    &amp;lt;/Layout&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 작성하면 UserProfile은 데이터가 성공적으로 도착했을 때의 로직만 담으면 됩니다. 데이터가 비어있을 때 발생하는 런타임 에러 걱정에서도 자유로워집니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;21&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;3. ErrorBoundary: 애플리케이션의 안전망&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;에러 바운더리는 하위 컴포넌트 트리 어디에서든 자바스크립트 에러가 발생했을 때, 앱 전체가 하얗게 변하는(White Screen) 것을 방지하고 대신 폴백(Fallback) UI를 보여주는 컴포넌트입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size23&quot;&gt;3.1 왜 필요한가?&lt;/h3&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;리액트에서 렌더링 중 에러가 발생하면 전체 컴포넌트 트리가 언마운트됩니다. 사용자 입장에서는 서비스가 갑자기 꺼지는 것과 같습니다. ErrorBoundary를 사용하면 특정 영역의 에러가 전체 앱으로 전파되는 것을 막을 수 있습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;3.2 선언적 에러 처리 예시&lt;/h3&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;(참고: 현재 에러 바운더리는 클래스 컴포넌트로만 작성이 가능하거나, react-error-boundary 라이브러리를 사용합니다.)&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ8AQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    &amp;lt;div role=&quot;alert&quot;&amp;gt;
      &amp;lt;p&amp;gt;문제가 발생했습니다.&amp;lt;/p&amp;gt;
      &amp;lt;pre&amp;gt;{error.message}&amp;lt;/pre&amp;gt;
      &amp;lt;button onClick={resetErrorBoundary}&amp;gt;다시 시도&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

function App() {
  return (
    &amp;lt;ErrorBoundary FallbackComponent={ErrorFallback} onReset={() =&amp;gt; {/* 상태 초기화 */}}&amp;gt;
      &amp;lt;Suspense fallback={&amp;lt;Loading /&amp;gt;}&amp;gt;
        &amp;lt;DataDependentComponent /&amp;gt;
      &amp;lt;/Suspense&amp;gt;
    &amp;lt;/ErrorBoundary&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;29&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;4. 실무 패턴: 선언적 UI의 중첩 구조&lt;/h2&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;실무에서는 보통 이 두 가지를 겹쳐서 사용합니다. 이를 통해 &lt;b&gt;'로딩 -&amp;gt; 에러 -&amp;gt; 데이터 성공'&lt;/b&gt;이라는 데이터 패칭의 3단계를 완벽하게 제어할 수 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;32&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,0,0&quot;&gt;가장 바깥쪽:&lt;/b&gt; ErrorBoundary (치명적인 에러 방어)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,1,0&quot;&gt;중간:&lt;/b&gt; Suspense (비동기 데이터 로딩 대기)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,2,0&quot;&gt;안쪽:&lt;/b&gt; 실제 비즈니스 로직 컴포넌트&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;이 구조를 사용하면 개발자는 안쪽 컴포넌트에서 데이터가 &lt;b&gt;'무조건 존재한다'&lt;/b&gt;고 가정하고 코드를 짤 수 있습니다. 이를 &lt;b&gt;'Suspense-enabled Data Fetching'&lt;/b&gt;이라고 부르며, React Query(TanStack Query)와 함께 쓸 때 가장 강력한 시너지를 냅니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;34&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;35&quot; data-ke-size=&quot;size26&quot;&gt;5. 선언적 UI 도입의 장점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;36&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,0,0&quot;&gt;가독성 폭발:&lt;/b&gt; 컴포넌트 본연의 목적(UI 정의)에만 집중할 수 있어 코드가 짧아지고 명확해집니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,1,0&quot;&gt;UX 개선:&lt;/b&gt; 로딩과 에러 화면을 디자이너가 의도한 대로 일관성 있게 보여줄 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,2,0&quot;&gt;유지보수 용이:&lt;/b&gt; 로딩 스피너 디자인을 바꾸고 싶다면, 수백 개의 컴포넌트를 수정하는 대신 상위의 Suspense fallback만 고치면 됩니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발일기</category>
      <category>DeclarativeProgramming</category>
      <category>ErrorBoundary</category>
      <category>reactQuery</category>
      <category>Suspense</category>
      <category>uxdesign</category>
      <category>리액트</category>
      <category>서스펜스</category>
      <category>선언적프로그래밍</category>
      <category>에러바운더리</category>
      <category>웹성능최적화</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/26</guid>
      <comments>https://minari98.tistory.com/entry/React-%EC%84%A0%EC%96%B8%EC%A0%81-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D%EC%9D%98-%EC%A0%95%EC%88%98-Suspense%EC%99%80-ErrorBoundary%EB%A1%9C-%EC%9A%B0%EC%95%84%ED%95%9C-UI-%EB%A7%8C%EB%93%A4%EA%B8%B0#entry26comment</comments>
      <pubDate>Mon, 27 Apr 2026 13:27:34 +0900</pubDate>
    </item>
    <item>
      <title>[React] Props Drilling을 피하는 3가지 방법: Composition vs Context vs Zustand</title>
      <link>https://minari98.tistory.com/entry/React-Props-Drilling%EC%9D%84-%ED%94%BC%ED%95%98%EB%8A%94-3%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95-Composition-vs-Context-vs-Zustand</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;리액트(React) 프로젝트의 규모가 커지면 필연적으로 마주하게 되는 현상이 있습니다. 바로 &lt;b data-index-in-node=&quot;52&quot; data-path-to-node=&quot;4&quot;&gt;Props Drilling&lt;/b&gt;입니다. 최상위 컴포넌트에 있는 데이터를 5단계 아래에 있는 자식 컴포넌트로 전달하기 위해, 중간에 있는 컴포넌트들이 그 데이터를 사용하지 않음에도 불구하고 단순히 '전달'만 하는 고통스러운 상황을 말합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;Props Drilling 그 자체는 반드시 나쁜 것은 아니지만, 깊이가 깊어질수록 코드를 추적하기 어렵게 만들고 리팩토링을 불가능하게 만드는 주범이 됩니다. 오늘은 이 문제를 해결하기 위한 세 가지 전략을 단계별로 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 첫 번째 단계: 컴포넌트 합성 (Component Composition)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;많은 개발자가 Props Drilling을 발견하면 즉시 Context API나 상태 관리 라이브러리를 떠올립니다. 하지만 리액트 팀에서 권장하는 가장 첫 번째 해결책은 &lt;b data-index-in-node=&quot;95&quot; data-path-to-node=&quot;8&quot;&gt;컴포넌트 합성&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;1.1 children을 활용한 구조 개선&lt;/h3&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;데이터를 하위로 계속 내려보내는 대신, 컴포넌트를 조립하는 방식으로 구조를 변경해 보세요.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQrQQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;// 기존 방식 (Drilling 발생)
&amp;lt;Parent data={data}&amp;gt;
  &amp;lt;Intermediate data={data}&amp;gt;
    &amp;lt;Child data={data} /&amp;gt;
  &amp;lt;/Intermediate&amp;gt;
&amp;lt;/Parent&amp;gt;

// 컴포넌트 합성 방식
&amp;lt;Parent&amp;gt;
  &amp;lt;Intermediate&amp;gt;
    &amp;lt;Child data={data} /&amp;gt;
  &amp;lt;/Intermediate&amp;gt;
&amp;lt;/Parent&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 Intermediate 컴포넌트는 data를 알 필요가 없습니다. 단순히 children으로 받은 내용을 렌더링 하기만 하면 됩니다. 데이터를 직접 사용하는 컴포넌트를 부모 수준으로 끌어올림으로써 의존성을 끊어내는 것이 핵심입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;13&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size26&quot;&gt;2. 두 번째 단계: Context API (의존성 주입)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;합성만으로 해결하기에 구조가 너무 복잡하거나, 앱 전체에서 공유되어야 하는 데이터(테마, 로그인 유저 정보 등)가 있다면 리액트 내장 기능인 &lt;b data-index-in-node=&quot;79&quot; data-path-to-node=&quot;15&quot;&gt;Context API&lt;/b&gt;를 사용합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size23&quot;&gt;2.1 Context API의 목적&lt;/h3&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;Context는 '전역 상태 관리' 도구라기보다 &lt;b data-index-in-node=&quot;27&quot; data-path-to-node=&quot;17&quot;&gt;'의존성 주입(Dependency Injection)'&lt;/b&gt; 도구에 가깝습니다. 특정 범위(Provider) 내에 있는 모든 컴포넌트가 계층 구조와 상관없이 데이터에 접근할 수 있게 해줍니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size23&quot;&gt;2.2 주의할 점: 리렌더링 최적화&lt;/h3&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;Context의 값이 바뀌면 해당 Provider를 구독하고 있는 모든 하위 컴포넌트가 리렌더링 됩니다. 따라서 자주 바뀌는 동적인 데이터보다는 &lt;b data-index-in-node=&quot;80&quot; data-path-to-node=&quot;19&quot;&gt;변경 빈도가 낮은 설정 데이터&lt;/b&gt;를 관리하는 데 적합합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQrgQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const UserContext = createContext();

function App() {
  return (
    &amp;lt;UserContext.Provider value={user}&amp;gt;
      &amp;lt;Layout /&amp;gt;
    &amp;lt;/UserContext.Provider&amp;gt;
  );
}

function UserProfile() {
  const user = useContext(UserContext); // 중간 단계 생략하고 바로 사용
  return &amp;lt;div&amp;gt;{user.name}&amp;lt;/div&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;21&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;3. 세 번째 단계: 외부 상태 관리 라이브러리 (Zustand)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;복잡하고 빈번하게 변하는 상태(장바구니, 필터링 조건 등)를 관리해야 한다면 전용 라이브러리의 힘을 빌려야 합니다. 과거에는 Redux가 대세였지만, 최근에는 설정이 간편하고 성능이 뛰어난 &lt;b data-index-in-node=&quot;106&quot; data-path-to-node=&quot;23&quot;&gt;Zustand&lt;/b&gt;가 각광받고 있습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size23&quot;&gt;3.1 왜 Zustand인가?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;25&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,0,0&quot;&gt;보일러플레이트 제로:&lt;/b&gt; Redux처럼 복잡한 초기 설정이 필요 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,1,0&quot;&gt;선택적 구독 (Selector):&lt;/b&gt; 컴포넌트가 필요한 상태만 선택해서 구독할 수 있어, 불필요한 리렌더링이 발생하지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,2,0&quot;&gt;리액트 외부에서도 접근 가능:&lt;/b&gt; 훅(Hook) 형태뿐만 아니라 일반 JS 로직에서도 상태를 읽고 쓸 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;3.2 실전 예시&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQrwQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;import { create } from 'zustand';

const useCartStore = create((set) =&amp;gt; ({
  items: [],
  addItem: (item) =&amp;gt; set((state) =&amp;gt; ({ items: [...state.items, item] })),
}));

function AddButton({ product }) {
  const addItem = useCartStore((state) =&amp;gt; state.addItem);
  return &amp;lt;button onClick={() =&amp;gt; addItem(product)}&amp;gt;담기&amp;lt;/button&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;28&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size26&quot;&gt;4. 어떤 상황에서 무엇을 선택해야 할까?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;가장 중요한 것은 상황에 맞는 도구를 선택하는 선구안입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;31&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,0,0&quot;&gt;컴포넌트 합성:&lt;/b&gt; Props 전달 단계가 2~3단계 내외이며, UI 구조를 변경함으로써 해결 가능한 경우. (가장 깔끔한 코드 유지 가능)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,1,0&quot;&gt;Context API:&lt;/b&gt; 다크 모드, 현재 언어(i18n), 로그인 정보처럼 앱 전체가 알아야 하지만 자주 바뀌지 않는 정보를 다룰 때.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,2,0&quot;&gt;Zustand:&lt;/b&gt; 여러 컴포넌트가 복잡한 상태를 서로 공유하고, 업데이트가 매우 빈번하며, 성능 최적화가 필수적인 경우.&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>개발일기</category>
      <category>cleancode</category>
      <category>ComponentComposition</category>
      <category>contextapi</category>
      <category>propsdriling</category>
      <category>React</category>
      <category>statemanagement</category>
      <category>zustand</category>
      <category>상태관리</category>
      <category>주스탄드</category>
      <category>컴포넌트합성</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/25</guid>
      <comments>https://minari98.tistory.com/entry/React-Props-Drilling%EC%9D%84-%ED%94%BC%ED%95%98%EB%8A%94-3%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95-Composition-vs-Context-vs-Zustand#entry25comment</comments>
      <pubDate>Mon, 27 Apr 2026 13:24:06 +0900</pubDate>
    </item>
    <item>
      <title>[JS] 메인 스레드를 비워라! Web Worker를 활용한 무거운 연산 분산 처리</title>
      <link>https://minari98.tistory.com/entry/JS-%EB%A9%94%EC%9D%B8-%EC%8A%A4%EB%A0%88%EB%93%9C%EB%A5%BC-%EB%B9%84%EC%9B%8C%EB%9D%BC-Web-Worker%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%AC%B4%EA%B1%B0%EC%9A%B4-%EC%97%B0%EC%82%B0-%EB%B6%84%EC%82%B0-%EC%B2%98%EB%A6%AC</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프런트엔드 개발자라면 누구나 한 번쯤 겪어봤을 상황이 있습니다. &quot;복잡한 JSON 데이터를 필터링하거나, 큰 이미지 파일을 처리할 때 화면이 잠시 멈춘다&quot;는 사용자 불만 말이죠. 리액트나 뷰(Vue)를 아무리 잘 다뤄도, 메인 스레드(Main Thread)가 막히면 사용자에게는 '고장 난 사이트'로 보일 뿐입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;자바스크립트의 실행 환경인 브라우저는 기본적으로 &lt;b&gt;싱글 스레드(Single-threaded)&lt;/b&gt;입니다. 즉, 한 번에 하나의 작업만 처리할 수 있습니다. 메인 스레드가 무거운 연산에 잡혀 있으면 화면을 그리는 일(렌더링)을 하지 못하게 되고, 이것이 바로 '화면 프리징'의 원인입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;오늘 소개할 &lt;b&gt;Web Worker(웹 워커)&lt;/b&gt;는 브라우저의 이 한계를 돌파하는 강력한 도구입니다. 메인 스레드와 별개의 '백그라운드 스레드'에서 작업을 처리하여, 복잡한 연산 중에도 화면은 매끄럽게 움직이게 만들 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;7&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size26&quot;&gt;1. 메인 스레드와 워커 스레드의 이해&lt;/h2&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;웹 워커의 핵심은 &lt;b&gt;'분리'&lt;/b&gt;입니다. 브라우저는 하나의 메인 스레드와 여러 개의 워커 스레드를 가질 수 있습니다. 메인 스레드는 UI를 그리고, 사용자의 입력을 받고, 네트워크 통신을 관리합니다. 반면, 워커 스레드는 순수하게 '계산'만 담당합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size23&quot;&gt;1.1 왜 분리해야 하는가?&lt;/h3&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;메인 스레드에서 for문을 1억 번 돌린다면? 브라우저는 그 1억 번의 루프가 끝날 때까지 아무것도 하지 못합니다. 버튼을 눌러도 반응이 없고, 애니메이션도 멈춥니다. 하지만 이 작업을 워커 스레드로 넘기면, 워커가 연산하는 동안 메인 스레드는 유유히 사용자의 마우스 클릭을 처리하고 애니메이션을 그릴 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;12&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size26&quot;&gt;2. Web Worker 구현하기: 3단계 프로세스&lt;/h2&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;웹 워커는 메인 스레드와 직접적인 메모리 공유를 하지 않습니다. 대신 &lt;b&gt;'메시지(Message)'&lt;/b&gt;를 주고받는 방식으로 데이터를 전달합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size23&quot;&gt;2.1 워커 파일 생성 (worker.js)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;먼저, 별도의 파일에 무거운 작업 내용을 작성합니다.&lt;/p&gt;
&lt;div data-hveid=&quot;4&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;// worker.js
self.onmessage = function(e) {
  const data = e.data;
  
  // 여기서 아주 무거운 연산 수행
  const result = heavyCalculation(data);
  
  // 결과를 메인 스레드로 반환
  self.postMessage(result);
};

function heavyCalculation(data) {
  // 예: 수만 개의 데이터를 처리하는 복잡한 알고리즘
  return data.map(item =&amp;gt; item * 2);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size23&quot;&gt;2.2 메인 스레드에서 워커 호출하기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;이제 메인 스레드에서 이 워커를 실행하고 메시지를 보냅니다.&lt;/p&gt;
&lt;div data-hveid=&quot;6&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// main.js
const worker = new Worker('worker.js');

// 연산 시작
worker.postMessage([1, 2, 3, 4, 5]);

// 결과 받기
worker.onmessage = function(e) {
  console.log('연산 결과:', e.data);
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;이 간단한 코드로 복잡한 로직을 메인 스레드 밖으로 안전하게 격리할 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;22&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;3. 웹 워커가 가지는 제약 사항과 주의점&lt;/h2&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;웹 워커는 매우 강력하지만, 모든 곳에 사용할 수는 없습니다. 반드시 알아야 할 한계점이 존재합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;25&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,0,0&quot;&gt;DOM 접근 불가:&lt;/b&gt; 웹 워커는 &lt;b data-index-in-node=&quot;17&quot; data-path-to-node=&quot;25,0,0&quot;&gt;DOM을 전혀 조작할 수 없습니다.&lt;/b&gt; document.querySelector나 window에 접근하려고 하면 에러가 발생합니다. 오직 연산(CPU 집약적 작업)만 가능합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,1,0&quot;&gt;메모리 격리:&lt;/b&gt; 메인 스레드와 메모리를 공유하지 않습니다. 데이터를 전달할 때마다 데이터를 '복사'해서 보냅니다. (아주 큰 데이터를 주고받을 경우 복사 비용이 발생할 수 있음)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,2,0&quot;&gt;파일 경로 제약:&lt;/b&gt; 워커 파일은 반드시 메인 스레드와 동일한 오리진(Origin)에 있어야 합니다. (CORS 정책 적용)&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;26&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size26&quot;&gt;4. 실전 활용 사례: 언제 도입해야 할까?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;모든 함수를 웹 워커로 옮기는 것은 낭비입니다. 다음 상황일 때 도입을 강력히 권장합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;29&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,0,0&quot;&gt;이미지/비디오 처리:&lt;/b&gt; Canvas API를 활용한 필터 적용이나 압축 작업.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,1,0&quot;&gt;복잡한 데이터 정제:&lt;/b&gt; 수만 개의 JSON 데이터를 필터링하거나 변환해야 하는 대시보드.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,2,0&quot;&gt;암호화/복호화:&lt;/b&gt; JWT 토큰 암호화나 파일 암호화 작업 등 CPU를 많이 쓰는 작업.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,3,0&quot;&gt;실시간 텍스트 분석:&lt;/b&gt; 사용자가 입력하는 텍스트의 맞춤법 검사나 복잡한 정규식 검색.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;30&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;31&quot; data-ke-size=&quot;size26&quot;&gt;5. 생산성을 높이는 도구: Comlink&lt;/h2&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;순수하게 웹 워커를 다루면 postMessage와 onmessage를 매번 작성해야 해서 코드가 다소 지저분해집니다. 이때 구글에서 만든 &lt;b data-index-in-node=&quot;77&quot; data-path-to-node=&quot;32&quot;&gt;Comlink&lt;/b&gt; 라이브러리를 사용하면 마치 일반 함수를 호출하듯 웹 워커를 사용할 수 있습니다.&lt;/p&gt;
&lt;div data-hveid=&quot;8&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// worker.js
import * as Comlink from 'comlink';

const obj = {
  doWork(a, b) { return a + b; }
};

Comlink.expose(obj);

// main.js
import * as Comlink from 'comlink';

async function init() {
  const worker = new Worker('worker.js');
  const api = Comlink.wrap(worker);
  
  // 그냥 함수처럼 호출하면 끝!
  const result = await api.doWork(10, 20);
  console.log(result);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 복잡한 메시지 핸들링 로직을 추상화하여 코드의 가독성을 크게 높일 수 있습니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>FrontendArchitecture</category>
      <category>JavascriptConcurrency</category>
      <category>mainthread</category>
      <category>PerformanceOptimazation</category>
      <category>webworker</category>
      <category>성능개선</category>
      <category>싱글스레드</category>
      <category>웹성능</category>
      <category>웹워커</category>
      <category>프론트엔드최적화</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/24</guid>
      <comments>https://minari98.tistory.com/entry/JS-%EB%A9%94%EC%9D%B8-%EC%8A%A4%EB%A0%88%EB%93%9C%EB%A5%BC-%EB%B9%84%EC%9B%8C%EB%9D%BC-Web-Worker%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%AC%B4%EA%B1%B0%EC%9A%B4-%EC%97%B0%EC%82%B0-%EB%B6%84%EC%82%B0-%EC%B2%98%EB%A6%AC#entry24comment</comments>
      <pubDate>Mon, 27 Apr 2026 13:21:18 +0900</pubDate>
    </item>
    <item>
      <title>[Web] 웹 폰트로 인한 레이아웃 시프트(CLS) 해결하기: font-display와 가상 폰트</title>
      <link>https://minari98.tistory.com/entry/Web-%EC%9B%B9-%ED%8F%B0%ED%8A%B8%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EC%8B%9C%ED%94%84%ED%8A%B8CLS-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-font-display%EC%99%80-%EA%B0%80%EC%83%81-%ED%8F%B0%ED%8A%B8</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹사이트에 접속했을 때, 처음에는 기본 폰트로 텍스트가 보이다가 갑자기 예쁜 웹 폰트로 바뀌면서 줄 바꿈이 변하거나 문단이 아래로 툭 떨어지는 현상을 겪어보신 적이 있나요?&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;사용자를 당황하게 만드는 이 현상을 &lt;b&gt;FOUT(Flash of Unstyled Text)&lt;/b&gt;라고 하며, 이는 구글의 핵심 성능 지표 중 하나인 &lt;b data-index-in-node=&quot;81&quot; data-path-to-node=&quot;5&quot;&gt;CLS(Cumulative Layout Shift)&lt;/b&gt; 점수를 크게 갉아먹는 주범입니다. 오늘은 웹 폰트 로딩 최적화를 통해 시각적 안정성을 확보하는 실무 전략을 깊이 있게 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. CLS의 적, 웹 폰트 로딩의 이해&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;웹 폰트는 용량이 크기 때문에 브라우저가 HTML과 CSS를 파싱하는 속도보다 늦게 다운로드되는 경우가 많습니다. 이때 브라우저는 두 가지 방식 중 하나를 선택합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;FOIT (Flash of Invisible Text):&lt;/b&gt; 폰트가 다운로드될 때까지 텍스트를 아예 보여주지 않음. (사용자는 빈 화면을 보게 됨)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;FOUT (Flash of Unstyled Text):&lt;/b&gt; 시스템 기본 폰트로 먼저 보여준 뒤, 웹 폰트가 로드되면 교체함. (이 과정에서 레이아웃이 어긋남)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;구글은 사용자 경험을 위해 텍스트가 아예 안 보이는 것보다 레이아웃이 조금 흔들리더라도 빨리 보이는 것을 권장하지만, 이 &lt;b&gt;'흔들림'&lt;/b&gt;을 최소화하는 것이 프론트엔드 개발자의 실력입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;11&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size26&quot;&gt;2. font-display 속성으로 로딩 제어하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;가장 쉽고 필수적인 해결책은 CSS의 @font-face에서 font-display 속성을 사용하는 것입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ0AM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;CSS&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/Pretendard.woff2') format('woff2');
  font-display: swap; /* 핵심 설정 */
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size23&quot;&gt;2.1 주요 옵션 차이점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;16&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16,0,0&quot;&gt;block:&lt;/b&gt; 폰트가 로드될 때까지 텍스트를 숨깁니다(최대 3초).&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16,1,0&quot;&gt;swap (권장):&lt;/b&gt; 시스템 폰트로 즉시 텍스트를 보여주고, 웹 폰트 로드가 완료되면 교체합니다. LCP 점수 유지에 유리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16,2,0&quot;&gt;fallback:&lt;/b&gt; 아주 짧은 시간(약 0.1초) 동안 텍스트를 숨긴 후 로드되지 않으면 기본 폰트를 보여줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16,3,0&quot;&gt;optional:&lt;/b&gt; 네트워크 상태에 따라 웹 폰트를 쓸지 말지 브라우저가 결정합니다. 성능이 최우선일 때 사용합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;17&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size26&quot;&gt;3. 가상 폰트(Fallback Font) 최적화: 사이즈 매칭&lt;/h2&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;font-display: swap을 사용하더라도 기본 폰트와 웹 폰트의 글자 크기, 자간, 높이가 다르면 여전히 레이아웃 시프트가 발생합니다. 이를 해결하기 위해 최근에는 &lt;b data-index-in-node=&quot;96&quot; data-path-to-node=&quot;19&quot;&gt;가상 폰트(Size Adjust)&lt;/b&gt; 기법을 사용합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size23&quot;&gt;3.1 size-adjust와 ascent-override&lt;/h3&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;웹 폰트와 최대한 비슷한 부피를 가지도록 시스템 폰트의 수치를 강제로 조정하는 방식입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ0QM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;CSS&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;/* 기본 폰트(Arial)를 웹 폰트와 크기가 비슷하게 조정 */
@font-face {
  font-family: 'Pretendard-Fallback';
  src: local('Arial');
  size-adjust: 95%;        /* 전체 크기 조정 */
  ascent-override: 90%;    /* 글자 윗부분 높이 조정 */
  descent-override: 10%;   /* 글자 아랫부분 높이 조정 */
}

body {
  font-family: 'Pretendard', 'Pretendard-Fallback', sans-serif;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 폰트가 교체될 때 텍스트의 부피 변화가 거의 없어 CLS 점수를 0에 가깝게 유지할 수 있습니다. 최근에는 &lt;a href=&quot;https://seek-oss.github.io/capsize/&quot; data-ved=&quot;0CAAQ_4QMahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ0gM&quot; data-hveid=&quot;0&quot;&gt;Capsize&lt;/a&gt; 같은 도구를 통해 이 수치를 정교하게 계산할 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;24&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;25&quot; data-ke-size=&quot;size26&quot;&gt;4. Next.js를 사용한다면? next/font 활용&lt;/h2&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;Next.js (v13+) 환경에서 개발 중이라면 위 복잡한 과정이 대폭 자동화됩니다. next/font는 빌드 시점에 폰트를 최적화하고 가상 폰트를 자동으로 생성해 줍니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ0wM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/layout.tsx
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  adjustFontFallback: true, // 가상 폰트 자동 생성 옵션
});

export default function RootLayout({ children }) {
  return (
    &amp;lt;html lang=&quot;ko&quot; className={inter.className}&amp;gt;
      &amp;lt;body&amp;gt;{children}&amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;next/font를 사용하면 폰트 파일을 구글 서버가 아닌 우리 서버(Self-hosting)에서 직접 제공하므로 개인정보 보호와 성능 면에서 모두 유리합니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;29&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;5. 실무 체크리스트: 웹 폰트 100% 활용법&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;31&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,0,0&quot;&gt;포맷 확인:&lt;/b&gt; 가장 압축률이 좋은 woff2 포맷을 우선적으로 사용하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,1,0&quot;&gt;Subset 활용:&lt;/b&gt; 한글 폰트는 용량이 매우 큽니다. 자주 쓰는 2,350자만 추린 '서브셋(Subset)' 폰트를 사용해 용량을 80% 이상 줄이세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,2,0&quot;&gt;Preload:&lt;/b&gt; 중요한 폰트는 &amp;lt;link rel=&quot;preload&quot;&amp;gt;를 통해 미리 로드하세요.&lt;/li&gt;
&lt;li data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQ1AM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;HTML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;&amp;lt;link rel=&quot;preload&quot; href=&quot;/fonts/main-font.woff2&quot; as=&quot;font&quot; type=&quot;font/woff2&quot; crossorigin&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,3,0&quot;&gt;로컬 폰트 활용:&lt;/b&gt; 사용자의 기기에 이미 설치된 폰트가 있다면 그것을 우선 사용하게 설정하세요. (src: local('Pretendard'), url(...))&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>개발일기</category>
      <category>CLS</category>
      <category>CoreWebVitals</category>
      <category>css</category>
      <category>FontDisplay</category>
      <category>LayoutShift</category>
      <category>NextjsFont</category>
      <category>webfont</category>
      <category>webperformance</category>
      <category>레이아웃시프트</category>
      <category>웹폰트</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/23</guid>
      <comments>https://minari98.tistory.com/entry/Web-%EC%9B%B9-%ED%8F%B0%ED%8A%B8%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EC%8B%9C%ED%94%84%ED%8A%B8CLS-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-font-display%EC%99%80-%EA%B0%80%EC%83%81-%ED%8F%B0%ED%8A%B8#entry23comment</comments>
      <pubDate>Mon, 27 Apr 2026 13:17:15 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] LCP 점수를 높이는 이미지 최적화 전략: next/image 완벽 활용법</title>
      <link>https://minari98.tistory.com/entry/Nextjs-LCP-%EC%A0%90%EC%88%98%EB%A5%BC-%EB%86%92%EC%9D%B4%EB%8A%94-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%84%EB%9E%B5-nextimage-%EC%99%84%EB%B2%BD-%ED%99%9C%EC%9A%A9%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹사이트의 첫인상을 결정짓는 가장 중요한 요소 중 하나는 &lt;b&gt;'로딩 속도'&lt;/b&gt;입니다. 구글은 이를 정량적으로 측정하기 위해 &lt;b data-index-in-node=&quot;69&quot; data-path-to-node=&quot;4&quot;&gt;Core Web Vitals&lt;/b&gt;라는 지표를 도입했으며, 그중에서도 가장 큰 비중을 차지하는 것이 바로 &lt;b&gt;LCP(Largest Contentful Paint)&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;LCP는 페이지에서 가장 큰 콘텐츠(보통 히어로 이미지나 큰 텍스트 블록)가 화면에 렌더링 되는 시간을 의미합니다. 놀랍게도 대부분의 웹사이트에서 LCP 점수를 갉아먹는 주범은 바로 '최적화되지 않은 이미지'입니다. 오늘은 Next.js가 제공하는 강력한 도구인 next/image를 활용하여 LCP 점수를 획기적으로 개선하는 전략을 심층 분석해 보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 왜 일반 &amp;lt;img&amp;gt; 태그보다 next/image인가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;전통적인 &amp;lt;img&amp;gt; 태그는 단순히 이미지를 불러올 뿐, 성능 최적화에 대한 어떠한 책임도 지지 않습니다. 반면 Next.js의 Image 컴포넌트는 다음과 같은 작업을 자동으로 수행합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;이미지 포맷 최적화:&lt;/b&gt; 브라우저가 지원한다면 WebP나 AVIF 같이 용량이 훨씬 작은 최신 포맷으로 자동 변환합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;리사이징 (Resizing):&lt;/b&gt; 디바이스 크기에 맞춰 최적화된 크기의 이미지를 생성하여 전송합니다. 4K 이미지를 모바일 사용자에게 전송하는 낭비를 막아줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,2,0&quot;&gt;Lazy Loading:&lt;/b&gt; 화면에 보이지 않는 이미지는 로딩을 뒤로 미뤄 초기 로딩 속도를 높입니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,3,0&quot;&gt;Placeholder 제공:&lt;/b&gt; 이미지가 로드되기 전 저해상도 이미지나 블러(Blur) 효과를 보여주어 사용자 경험을 개선합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;10&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size26&quot;&gt;2. LCP 개선의 핵심: priority 속성&lt;/h2&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;LCP 점수를 높이는 가장 쉽고 강력한 방법은 &lt;b data-index-in-node=&quot;26&quot; data-path-to-node=&quot;12&quot;&gt;'가장 중요한 이미지'를 먼저 로드하는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;보통 페이지 상단의 히어로 이미지나 배너가 LCP 요소가 됩니다. next/image는 기본적으로 레이지 로딩(Lazy Loading)이 적용되는데, LCP 요소에 레이지 로딩이 걸려 있으면 오히려 렌더링이 늦어져 LCP 점수가 나빠집니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQsAM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// LCP 대상이 되는 히어로 이미지 예시
import Image from 'next/image';

export default function Hero() {
  return (
    &amp;lt;section&amp;gt;
      &amp;lt;Image
        src=&quot;/hero-banner.png&quot;
        alt=&quot;메인 배너 이미지&quot;
        width={1200}
        height={600}
        priority // 이 속성이 LCP 개선의 핵심입니다!
        className=&quot;object-cover&quot;
      /&amp;gt;
    &amp;lt;/section&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;priority 속성을 추가하면 Next.js는 해당 이미지를 &lt;b&gt;높은 우선순위(Fetch Priority)&lt;/b&gt;로 처리하고 프리로드(Preload) 태그를 삽입합니다. 이는 브라우저가 HTML을 파싱 할 때 해당 이미지를 가장 먼저 다운로드하도록 유도하여 LCP 시간을 단축시킵니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;16&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;3. 레이아웃 시프트 방지와 sizes 속성 활용&lt;/h2&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;이미지 로딩 중 화면이 덜컥거리는 현상인 &lt;b data-index-in-node=&quot;23&quot; data-path-to-node=&quot;18&quot;&gt;CLS(Cumulative Layout Shift)&lt;/b&gt; 역시 성능 점수에 큰 영향을 줍니다. 이를 방지하려면 이미지의 비율을 미리 브라우저에 알려줘야 합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size23&quot;&gt;3.1 정적 이미지와 고정 비율&lt;/h3&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;width와 height를 명시하면 Next.js는 이미지 비율에 맞춰 공간을 미리 확보합니다. 만약 반응형 이미지를 구현해야 한다면 sizes 속성을 반드시 사용해야 합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size23&quot;&gt;3.2 sizes 속성 최적화&lt;/h3&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;sizes는 브라우저에게 &quot;이 이미지는 뷰포트 크기에 따라 이 정도 너비를 차지할 거야&quot;라고 미리 알려주는 힌트입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQsQM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;smali&quot;&gt;&lt;code&gt;&amp;lt;Image
  src=&quot;/product.jpg&quot;
  alt=&quot;상품 이미지&quot;
  fill
  sizes=&quot;(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw&quot;
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;위 설정은 모바일에서는 화면 꽉 차게(100vw), 태블릿에서는 절반(50vw), 데스크탑에서는 1/3(33vw) 크기의 이미지만 요청하도록 만듭니다. 불필요하게 큰 이미지를 다운로드하지 않게 되어 로딩 속도가 비약적으로 향상됩니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;25&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;4. 이미지 포맷과 퀄리티 조정 (next.config.js)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;더 극단적인 최적화를 원한다면 설정 파일을 통해 이미지 포맷을 제어할 수 있습니다. WebP보다 압축률이 높은 AVIF를 지원하도록 설정해 보세요.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQsgM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// next.config.js
module.exports = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;또한, 기본 퀄리티(75)를 프로젝트 성격에 맞춰 조정하여 용량과 화질 사이의 균형을 맞출 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;30&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;31&quot; data-ke-size=&quot;size26&quot;&gt;5. 실무 트러블슈팅: 외부 도메인 이미지 처리&lt;/h2&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;많은 실무 프로젝트(예: CMS, 쇼핑몰 가비아 이미지 서버 등)에서 외부 URL의 이미지를 가져옵니다. 이때 next/image를 쓰려면 반드시 허용된 도메인 설정을 거쳐야 합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQswM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// next.config.js
images: {
  remotePatterns: [
    {
      protocol: 'https',
      hostname: 'your-cdn-server.com',
      pathname: '/images/**',
    },
  ],
},
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;이 설정을 통해 외부 서버의 이미지도 Next.js 서버가 대신 최적화(Optimization API)하여 사용자에게 전달할 수 있게 됩니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>CoreWebVitals</category>
      <category>FrontendPerformance</category>
      <category>ImageOptimization</category>
      <category>LCP</category>
      <category>nextimage</category>
      <category>nextjs</category>
      <category>SEO</category>
      <category>WebPerf</category>
      <category>넥스트js</category>
      <category>이미지최적화</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/22</guid>
      <comments>https://minari98.tistory.com/entry/Nextjs-LCP-%EC%A0%90%EC%88%98%EB%A5%BC-%EB%86%92%EC%9D%B4%EB%8A%94-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%84%EB%9E%B5-nextimage-%EC%99%84%EB%B2%BD-%ED%99%9C%EC%9A%A9%EB%B2%95#entry22comment</comments>
      <pubDate>Mon, 27 Apr 2026 13:14:56 +0900</pubDate>
    </item>
    <item>
      <title>[React] useMemo와 useCallback은 언제 정말로 성능을 개선할까? (측정 기준)</title>
      <link>https://minari98.tistory.com/entry/React-useMemo%EC%99%80-useCallback%EC%9D%80-%EC%96%B8%EC%A0%9C-%EC%A0%95%EB%A7%90%EB%A1%9C-%EC%84%B1%EB%8A%A5%EC%9D%84-%EA%B0%9C%EC%84%A0%ED%95%A0%EA%B9%8C-%EC%B8%A1%EC%A0%95-%EA%B8%B0%EC%A4%80</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;리액트(React) 생태계에서 성능 최적화는 언제나 뜨거운 감자입니다. 특히 useMemo와 useCallback은 리액트 개발자라면 반드시 마주하게 되는 도구이지만, 동시에 가장 오용되기 쉬운 도구이기도 합니다. 많은 개발자가 &quot;성능에 좋겠지&quot;라는 막연한 추측으로 모든 함수와 연산에 이 훅들을 적용하곤 합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;하지만 리액트 팀의 댄 아브라모프(Dan Abramov)를 비롯한 수많은 시니어 엔지니어들은 &lt;b&gt;&quot;섣부른 최적화(Premature Optimization)는 만악의 근원&quot;&lt;/b&gt;이라고 경고합니다. 오늘은 useMemo와 useCallback의 내부 메커니즘을 낱낱이 파헤치고, 실무에서 성능 이득을 정량적으로 측정하여 적용하는 기준을 제시하겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 메모이제이션(Memoization)의 배신: 공짜 점심은 없다&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;우선 우리가 사용하는 이 훅들이 공짜가 아니라는 점을 명확히 인지해야 합니다. useMemo나 useCallback을 호출할 때마다 리액트 내부에서는 다음과 같은 비용이 발생합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;1.1 메모리 비용 (Memory Overhead)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;메모이제이션은 기본적으로 &lt;b data-index-in-node=&quot;14&quot; data-path-to-node=&quot;10&quot;&gt;'메모리를 써서 시간을 사는'&lt;/b&gt; 행위입니다. 이전 계산 결과나 함수 참조를 메모리에 유지해야 하므로, 앱의 전체적인 메모리 사용량이 늘어납니다. 아주 미미해 보일 수 있지만, 수백 개의 컴포넌트에서 무분별하게 사용될 경우 가비지 컬렉션(GC)에 부담을 줄 수 있습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size23&quot;&gt;1.2 비교 비용 (Comparison Overhead)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;리액트는 매 렌더링마다 의존성 배열(deps)의 요소들을 하나씩 꺼내 이전 값과 비교하는 &lt;b&gt;얕은 비교(Shallow Compare)&lt;/b&gt;를 수행합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;13&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;만약 연산 자체가 매우 단순하다면(예: 두 숫자의 합), 그 연산을 다시 하는 것보다 의존성 배열을 순회하며 비교하는 비용이 더 클 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;14&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;2. useMemo: 무거운 연산의 기준과 참조 동일성&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;useMemo는 특정 연산의 결괏값을 저장합니다. 이 훅의 적용 기준은 크게 두 가지로 나뉩니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size23&quot;&gt;2.1 연산의 복잡도 (Computational Expense)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;가장 흔한 오해는 모든 배열 처리(filter, map, reduce)에 useMemo를 써야 한다는 생각입니다. 하지만 자바스크립트 엔진은 생각보다 훨씬 빠릅니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19&quot;&gt;성능 측정 기법: performance.now()&lt;/b&gt; 어떤 연산이 useMemo를 쓸 만큼 무거운지 알고 싶다면, 브라우저 콘솔에서 직접 측정해 보세요.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQhAM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const startTime = performance.now();
doSomethingExpensive(data);
const endTime = performance.now();
console.log(`연산 소요 시간: ${endTime - startTime}ms`);
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;일반적으로 &lt;b data-index-in-node=&quot;6&quot; data-path-to-node=&quot;21&quot;&gt;1ms 이상&lt;/b&gt; 소요되는 연산은 useMemo를 고려할 만한 '유의미한' 비용으로 간주합니다. 만약 0.1ms 미만의 연산이라면 useMemo를 사용하는 것이 오히려 손해일 가능성이 높습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size23&quot;&gt;2.2 참조 동일성 유지 (Referential Identity)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;연산 속도보다 실무에서 더 중요한 이유는 &lt;b data-index-in-node=&quot;23&quot; data-path-to-node=&quot;23&quot;&gt;참조값 고정&lt;/b&gt;입니다. 리액트에서 객체({})나 배열([])은 내용이 같아도 매번 새로운 참조를 가집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;24&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 값이 useEffect의 의존성 배열에 들어간다면? -&amp;gt; 무한 루프나 불필요한 이펙트 실행의 원인이 됩니다.&lt;/li&gt;
&lt;li&gt;이 값이 React.memo로 감싸진 자식 컴포넌트의 Props로 전달된다면? -&amp;gt; 자식은 내용이 변하지 않았음에도 리렌더링 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;이런 경우, 연산의 복잡도와 상관없이 &lt;b data-index-in-node=&quot;21&quot; data-path-to-node=&quot;25&quot;&gt;참조를 일관되게 유지하기 위해&lt;/b&gt; useMemo를 반드시 사용해야 합니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;26&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size26&quot;&gt;3. useCallback: 함수 리렌더링과 자식 컴포넌트의 관계&lt;/h2&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;useCallback은 함수 참조를 고정합니다. 많은 초보자가 &quot;함수 생성을 방지해서 성능을 높인다&quot;라고 생각하지만, 자바스크립트에서 함수를 새로 생성하는 비용은 현대 브라우저에서 거의 무시해도 될 수준입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size23&quot;&gt;3.1 React.memo와의 환상적인 조합&lt;/h3&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;useCallback이 효과를 발휘하는 유일한 시점은 &lt;b data-index-in-node=&quot;30&quot; data-path-to-node=&quot;30&quot;&gt;자식 컴포넌트가 리렌더링을 방어하고 있을 때&lt;/b&gt;입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiTnP-AgfyTAxUAAAAAHQAAAAAQhQM&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;JavaScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 자식 컴포넌트가 memo로 최적화되어 있음
const ExpensiveList = React.memo(({ onItemClick }) =&amp;gt; {
  console.log(&quot;목록 리렌더링 중...&quot;);
  return (
    &amp;lt;ul&amp;gt;
      {/* 복잡한 리스트 아이템들 */}
    &amp;lt;/ul&amp;gt;
  );
});

const Parent = () =&amp;gt; {
  const [text, setText] = useState(&quot;&quot;);

  // 이 함수가 useCallback으로 감싸져 있지 않다면,
  // 부모가 글자를 입력할 때마다 참조가 바뀌어 ExpensiveList가 리렌더링됨
  const handleClick = useCallback((id) =&amp;gt; {
    console.log(id);
  }, []); // 의존성이 없으므로 단 한 번만 생성됨

  return (
    &amp;lt;&amp;gt;
      &amp;lt;input value={text} onChange={(e) =&amp;gt; setText(e.target.value)} /&amp;gt;
      &amp;lt;ExpensiveList onItemClick={handleClick} /&amp;gt;
    &amp;lt;/&amp;gt;
  );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;만약 ExpensiveList가 React.memo로 감싸져 있지 않다면, 부모가 렌더링 될 때 자식도 어차피 렌더링 되므로 useCallback은 메모리만 낭비하는 꼴이 됩니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;33&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;34&quot; data-ke-size=&quot;size26&quot;&gt;4. 실무 트러블슈팅: 최적화가 오히려 성능을 해치는 신호&lt;/h2&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;블로그 독자들에게 실질적인 도움을 주기 위해, 최적화 훅을 &lt;b data-index-in-node=&quot;33&quot; data-path-to-node=&quot;35&quot;&gt;제거해야 할 때&lt;/b&gt;의 신호를 정리해 봅니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;36&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,0,0&quot;&gt;의존성 배열이 너무 빈번하게 바뀜:&lt;/b&gt; deps에 포함된 값이 렌더링마다 바뀐다면 메모이제이션은 작동하지 않고 비교 연산만 추가될 뿐입니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,1,0&quot;&gt;기본형 데이터 타입에 사용:&lt;/b&gt; 문자열, 숫자, 불리언은 값을 직접 비교하므로 참조 고정이 필요 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,2,0&quot;&gt;컴포넌트 하위 계층이 단순함:&lt;/b&gt; 자식 컴포넌트들이 매우 가볍다면, 리렌더링 되는 비용이 최적화 로직을 타는 비용보다 저렴합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;37&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;38&quot; data-ke-size=&quot;size26&quot;&gt;5. 정량적 측정: React Profiler 활용하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&quot;느낌적인 느낌&quot;으로 코드를 짜지 마세요. 리액트 개발자 도구의 &lt;b data-index-in-node=&quot;36&quot; data-path-to-node=&quot;39&quot;&gt;Profiler&lt;/b&gt; 탭은 가장 강력한 무기입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;40&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;40,0,0&quot;&gt;Record 버튼&lt;/b&gt;을 누르고 서비스의 주요 동작(클릭, 입력 등)을 수행합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;40,1,0&quot;&gt;Flamegraph&lt;/b&gt; 차트를 확인합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;40,2,0&quot;&gt;&quot;Why did this render?&quot;&lt;/b&gt; 섹션을 통해 특정 컴포넌트가 리렌더링 된 이유가 단순히 Props의 참조값 변화 때문인지 확인합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;만약 참조값 변화가 원인이라면, 그때가 바로 useMemo나 useCallback을 투입할 &lt;b&gt;&quot;골든 타임&quot;&lt;/b&gt;입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;42&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;43&quot; data-ke-size=&quot;size26&quot;&gt;6. 결론: 유연하고 영리한 최적화 전략&lt;/h2&gt;
&lt;p data-path-to-node=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;프런트엔드 성능 최적화는 기술이라기보다 &lt;b&gt;'트레이드오프(Trade-off)'에&lt;/b&gt; 가깝습니다. 가독성을 희생하고 메모리를 더 써서 렌더링 성능을 얻을 것인가, 아니면 약간의 렌더링을 허용하고 깔끔한 코드를 유지할 것인가의 선택이죠.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>FrontendArchitecture</category>
      <category>javascript</category>
      <category>React</category>
      <category>reactmemo</category>
      <category>useCallback</category>
      <category>Webvitals</category>
      <category>리액트기초</category>
      <category>리액트심화</category>
      <category>성능최적화 useMemo</category>
      <category>웹성능측정</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/21</guid>
      <comments>https://minari98.tistory.com/entry/React-useMemo%EC%99%80-useCallback%EC%9D%80-%EC%96%B8%EC%A0%9C-%EC%A0%95%EB%A7%90%EB%A1%9C-%EC%84%B1%EB%8A%A5%EC%9D%84-%EA%B0%9C%EC%84%A0%ED%95%A0%EA%B9%8C-%EC%B8%A1%EC%A0%95-%EA%B8%B0%EC%A4%80#entry21comment</comments>
      <pubDate>Mon, 27 Apr 2026 13:11:53 +0900</pubDate>
    </item>
    <item>
      <title>[CI/CD] 개발 효율의 정점: GitHub Actions를 활용한 서버 자동 배포 환경 구축</title>
      <link>https://minari98.tistory.com/entry/CICD-%EA%B0%9C%EB%B0%9C-%ED%9A%A8%EC%9C%A8%EC%9D%98-%EC%A0%95%EC%A0%90-GitHub-Actions%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%84%9C%EB%B2%84-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;매번 코드를 수정할 때마다 서버에 접속해서 git pull을 받고, 빌드를 다시 하고, 프로세스를 재시작하는 과정은 번거롭고 실수하기 쉽습니다. 이러한 반복 작업을 자동화하여 개발자가 오직 '코드'에만 집중할 수 있게 해주는 기술이 바로 &lt;b data-index-in-node=&quot;133&quot; data-path-to-node=&quot;4&quot;&gt;CI/CD&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 별도의 서버 설치 없이 GitHub에서 제공하는 강력한 자동화 도구인 &lt;b data-index-in-node=&quot;43&quot; data-path-to-node=&quot;5&quot;&gt;GitHub Actions&lt;/b&gt;를 사용하여, 코드를 푸시하면 내 EC2 서버에 즉시 배포되는 환경을 만들어보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. CI/CD란 무엇인가?&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;8&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,0,0&quot;&gt;CI (Continuous Integration):&lt;/b&gt; 지속적 통합. 개발자들이 작업한 코드를 자주 병합하고, 그때마다 자동으로 테스트와 빌드를 수행하여 코드의 품질을 검증하는 과정입니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,1,0&quot;&gt;CD (Continuous Deployment):&lt;/b&gt; 지속적 배포. 검증된 코드를 실제 운영 서버에 자동으로 반영하는 과정입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;9&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size26&quot;&gt;2. GitHub Actions의 핵심: Workflow 파일&lt;/h2&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;GitHub Actions는 프로젝트 루트의 .github/workflows/ 디렉토리에 있는 YAML 파일을 통해 동작합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;2.1 배포 시나리오 (Workflow)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;13&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;개발자가 main 브랜치에 코드를 &lt;b data-index-in-node=&quot;19&quot; data-path-to-node=&quot;13,0,0&quot;&gt;Push&lt;/b&gt;합니다.&lt;/li&gt;
&lt;li&gt;GitHub Actions가 가상 환경을 실행하여 코드를 **빌드(Build)**합니다.&lt;/li&gt;
&lt;li&gt;빌드가 성공하면 &lt;b data-index-in-node=&quot;9&quot; data-path-to-node=&quot;13,2,0&quot;&gt;SSH&lt;/b&gt;를 통해 내 EC2 서버에 접속합니다.&lt;/li&gt;
&lt;li&gt;서버에서 최신 코드를 받고 서비스를 &lt;b data-index-in-node=&quot;20&quot; data-path-to-node=&quot;13,3,0&quot;&gt;재시작&lt;/b&gt;합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;14&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;3. 실전! 자동 배포 스크립트 작성하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;먼저 GitHub 저장소의 &lt;b data-index-in-node=&quot;15&quot; data-path-to-node=&quot;16&quot;&gt;Settings &amp;gt; Secrets and variables &amp;gt; Actions&lt;/b&gt;에서 서버 접속 정보(IP, Username, SSH Key)를 등록해야 안전합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size23&quot;&gt;3.1 deploy.yml 예시&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ1QY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;YAML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;name: Deploy to EC2

on:
  push:
    branches: [ main ] # main 브랜치에 푸시될 때 실행

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: 저장소 코드 가져오기
        uses: actions/checkout@v3

      - name: SSH 접속 후 배포 명령 실행
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ${{ secrets.EC2_USERNAME }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            cd /home/ubuntu/my-project
            git pull origin main
            npm install
            pm2 restart all # Node.js 서비스 재시작 예시
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;19&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size26&quot;&gt;4. CI/CD 도입 시 얻게 되는 효과&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;21&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,0,0&quot;&gt;빠른 피드백:&lt;/b&gt; 코드를 올리자마자 빌드 오류나 테스트 실패 여부를 알 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,1,0&quot;&gt;배포 실수 방지:&lt;/b&gt; 수동으로 명령어를 입력하다 발생할 수 있는 오타나 누락을 원천 차단합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,2,0&quot;&gt;생산성 향상:&lt;/b&gt; 배포에 소요되는 시간을 줄여 핵심 로직 개발에 더 많은 시간을 투자할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,3,0&quot;&gt;안정적인 릴리즈:&lt;/b&gt; 정해진 절차에 따라 기계적으로 배포되므로 서비스의 안정성이 높아집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;22&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;5. 실무 꿀팁: Slack/Discord 알림 연동&lt;/h2&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;배포가 성공했는지 실패했는지 매번 GitHub 사이트에서 확인할 수는 없습니다. GitHub Actions에 알림 스텝을 추가하면 배포 결과를 실시간으로 메신저로 받아볼 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;25&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;6. 결론: 자동화는 선택이 아닌 필수입니다&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;스무 차례에 걸쳐 리눅스 서버 기초부터 보안, 모니터링, 그리고 자동 배포까지 달려왔습니다. CI/CD 구축은 이 모든 여정의 마침표이자, 진정한 엔지니어로 거듭나는 시작점입니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>Automation</category>
      <category>cicd</category>
      <category>Deployment</category>
      <category>EC2</category>
      <category>GithubActions</category>
      <category>workflow</category>
      <category>서버자동화</category>
      <category>자동배포</category>
      <category>지속적배포</category>
      <category>지속적통합</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/20</guid>
      <comments>https://minari98.tistory.com/entry/CICD-%EA%B0%9C%EB%B0%9C-%ED%9A%A8%EC%9C%A8%EC%9D%98-%EC%A0%95%EC%A0%90-GitHub-Actions%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%84%9C%EB%B2%84-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95#entry20comment</comments>
      <pubDate>Fri, 17 Apr 2026 18:50:57 +0900</pubDate>
    </item>
    <item>
      <title>[Monitoring] 서버 상태를 한눈에: Prometheus와 Grafana로 구축하는 시각화 대시보드</title>
      <link>https://minari98.tistory.com/entry/Monitoring-%EC%84%9C%EB%B2%84-%EC%83%81%ED%83%9C%EB%A5%BC-%ED%95%9C%EB%88%88%EC%97%90-Prometheus%EC%99%80-Grafana%EB%A1%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EB%8A%94-%EC%8B%9C%EA%B0%81%ED%99%94-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 운영하다 보면 &quot;지금 CPU 점유율이 얼마지?&quot;, &quot;어제 밤에 트래픽이 얼마나 몰렸을까?&quot;와 같은 질문에 답해야 할 때가 많습니다. 매번 터미널에 접속해 htop을 입력하는 것은 번거로울 뿐만 아니라 과거의 데이터를 추적하기도 어렵습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 오픈소스 모니터링의 표준이라 불리는 &lt;b&gt;Prometheus(프로메테우스)&lt;/b&gt;와 시각화 도구인 &lt;b&gt;Grafana(그라파나)&lt;/b&gt;를 연동하여, 누구나 한눈에 이해할 수 있는 멋진 서버 대시보드를 만드는 방법을 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 모니터링 시스템의 두 주인공&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size23&quot;&gt;1.1 Prometheus (데이터 수집기)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;서버의 메트릭(Metric, 수치 데이터)을 수집하고 저장하는 역할을 합니다. &quot;서버의 현재 CPU는 20%다&quot;라는 정보를 일정 시간마다 가져와서 자신의 데이터베이스에 쌓아둡니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size23&quot;&gt;1.2 Grafana (데이터 시각화)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;Prometheus가 수집한 딱딱한 숫자 데이터를 가져와 예쁜 그래프, 게이지, 차트로 그려주는 도구입니다. 커스터마이징이 매우 자유로워 나만의 관제 센터를 만들 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;12&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size26&quot;&gt;2. 데이터의 원천: Node Exporter 설치&lt;/h2&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;Prometheus가 서버의 하드웨어 정보를 읽어가려면, 서버 안에 정보를 내보내 주는 &lt;b data-index-in-node=&quot;49&quot; data-path-to-node=&quot;14&quot;&gt;Node Exporter&lt;/b&gt;라는 작은 프로그램이 떠 있어야 합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;15&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,0,0&quot;&gt;Node Exporter 설치:&lt;/b&gt;&lt;/li&gt;
&lt;li data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQwgY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;sudo apt update
sudo apt install prometheus-node-exporter -y
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;이제 9100번 포트를 통해 서버의 실시간 상태가 텍스트 형태로 출력되기 시작합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;16&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;3. Prometheus 및 Grafana 구축 (Docker 활용)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;설치 과정을 단순화하기 위해 Docker Compose를 사용하는 것이 가장 효율적입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size23&quot;&gt;3.1 docker-compose.yml 설정 예시&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQwwY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;YAML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;version: '3.8'
services:
  prometheus:
    image: prom/prometheus
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - &quot;9090:9090&quot;

  grafana:
    image: grafana/grafana
    ports:
      - &quot;3000:3000&quot;
    depends_on:
      - prometheus
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;21&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;4. Grafana에서 대시보드 구성하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;설치가 완료되었다면 http://서버IP:3000으로 접속합니다. (기본 계정: admin / admin)&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size23&quot;&gt;4.1 데이터 소스 연결&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;25&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,0,0&quot;&gt;Configuration &amp;gt; Data Sources&lt;/b&gt;에서 &lt;b data-index-in-node=&quot;31&quot; data-path-to-node=&quot;25,0,0&quot;&gt;Prometheus&lt;/b&gt;를 선택합니다.&lt;/li&gt;
&lt;li&gt;URL에 http://prometheus:9090을 입력하고 저장합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;4.2 대시보드 템플릿 가져오기 (꿀팁)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;처음부터 그래프를 하나씩 그리는 것은 매우 어렵습니다. Grafana 커뮤니티에는 이미 잘 만들어진 템플릿이 많습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;28&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,0,0&quot;&gt;Create &amp;gt; Import&lt;/b&gt;로 이동합니다.&lt;/li&gt;
&lt;li&gt;유명한 Linux 모니터링 템플릿 번호인 &lt;b&gt;1860&lt;/b&gt;을 입력하고 로드합니다.&lt;/li&gt;
&lt;li&gt;순식간에 CPU 사용량, 메모리, 디스크 I/O, 네트워크 트래픽이 포함된 전문가용 대시보드가 완성됩니다!&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;29&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;5. 모니터링 시스템 도입의 효과&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;31&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,0,0&quot;&gt;장애 선제 대응:&lt;/b&gt; 트래픽 증가 추이를 보고 서버 증설 시점을 미리 파악할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,1,0&quot;&gt;원인 분석:&lt;/b&gt; 장애가 발생했던 시점의 그래프를 되돌려 보며 무엇이 문제였는지 명확히 짚어낼 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,2,0&quot;&gt;보고 및 공유:&lt;/b&gt; 복잡한 로그 대신 시각화된 그래프를 통해 팀원이나 클라이언트에게 시스템 상태를 직관적으로 설명할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;32&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size26&quot;&gt;6. 결론: 데이터 기반의 서버 운영&lt;/h2&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;&quot;측정할 수 없으면 관리할 수 없다&quot;는 경영학의 격언은 서버 운영에도 그대로 적용됩니다. Prometheus와 Grafana는 여러분의 서버를 투명하게 들여다볼 수 있는 강력한 돋보기가 되어줄 것입니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>Dashboad</category>
      <category>DevOps</category>
      <category>Grafana</category>
      <category>Metrics</category>
      <category>Monitoring</category>
      <category>NodeExporter</category>
      <category>Prometheus</category>
      <category>그라파나</category>
      <category>서버모니터링</category>
      <category>프로메테우스</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/19</guid>
      <comments>https://minari98.tistory.com/entry/Monitoring-%EC%84%9C%EB%B2%84-%EC%83%81%ED%83%9C%EB%A5%BC-%ED%95%9C%EB%88%88%EC%97%90-Prometheus%EC%99%80-Grafana%EB%A1%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EB%8A%94-%EC%8B%9C%EA%B0%81%ED%99%94-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C#entry19comment</comments>
      <pubDate>Thu, 16 Apr 2026 18:49:43 +0900</pubDate>
    </item>
    <item>
      <title>[Load Testing] 서비스 출시 전 필수 코스: 서버 부하 테스트로 한계 측정하기</title>
      <link>https://minari98.tistory.com/entry/Load-Testing-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%B6%9C%EC%8B%9C-%EC%A0%84-%ED%95%84%EC%88%98-%EC%BD%94%EC%8A%A4-%EC%84%9C%EB%B2%84-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A1%9C-%ED%95%9C%EA%B3%84-%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;공들여 만든 웹 서비스가 출시 당일, 예상보다 많은 사용자가 몰려 서버가 다운된다면 그보다 아찔한 상황은 없을 것입니다. 서버가 한 번에 얼마나 많은 요청을 처리할 수 있는지, 어느 정도의 동시 접속자 수에서 속도가 느려지는지 미리 파악하는 과정이 바로 **부하 테스트(Load Testing)**입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 별도의 복잡한 설치 없이 Node.js 환경에서 간편하게 사용할 수 있는 부하 테스트 도구인 &lt;b data-index-in-node=&quot;56&quot; data-path-to-node=&quot;5&quot;&gt;Artillery&lt;/b&gt;를 활용하여 내 서버의 체력을 측정하는 방법을 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 부하 테스트(Load Testing)는 왜 필요한가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;부하 테스트는 단순히 서버를 괴롭히는 것이 아니라, 시스템의 **병목 지점(Bottleneck)**을 찾아내기 위해 수행합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;최대 수용 인원 파악:&lt;/b&gt; 현재 서버 사양(EC2 인스턴스 등)에서 동시 접속자 몇 명까지 안정적인지 확인합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;리소스 부족 지점 발견:&lt;/b&gt; CPU가 먼저 차오르는지, 메모리가 부족한지, 혹은 DB 연결 개수가 모자란지 파악합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,2,0&quot;&gt;Auto Scaling 기준 수립:&lt;/b&gt; 서버가 자동으로 늘어나야 하는 기준(CPU 70% 등)을 정하는 근거가 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;10&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size26&quot;&gt;2. Artillery 설치 및 첫 테스트 시작&lt;/h2&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;Artillery는 YAML 파일로 테스트 시나리오를 작성할 수 있어 매우 직관적입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;2.1 설치하기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;npm을 통해 전역으로 설치하거나 npx로 바로 실행할 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQqgY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;npm install -g artillery
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size23&quot;&gt;2.2 간단한 명령어로 테스트하기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;가장 기본적인 테스트는 명령어 한 줄로 가능합니다. 아래는 10명의 가상 사용자가 1초에 1번씩 총 100번의 요청을 보내는 예시입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQqwY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;artillery quick --count 10 -n 100 https://your-api-domain.com
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;19&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size26&quot;&gt;3. 시나리오 기반의 정밀 테스트 (YAML 설정)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;실제 서비스와 유사한 환경을 만들기 위해 script.yml 파일을 작성하여 테스트를 수행하는 것이 좋습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQrAY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;YAML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;config:
  target: &quot;https://api.example.com&quot;
  phases:
    - duration: 60      # 60초 동안 테스트 진행
      arrivalRate: 5    # 초당 5명의 가상 사용자 투입 (평상시 트래픽)
    - duration: 120
      arrivalRate: 5
      rampTo: 50        # 2분 동안 초당 접속자를 50명까지 점진적으로 증가 (피크 타임)
  
scenarios:
  - name: &quot;메인 페이지 접속 및 상품 상세 조회&quot;
    flow:
      - get:
          url: &quot;/&quot;
      - think: 1        # 1초 대기 (사용자 행동 모의)
      - get:
          url: &quot;/products/1&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size23&quot;&gt;3.3 테스트 실행 및 리포트 생성&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQrQY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;elm&quot;&gt;&lt;code&gt;artillery run -o report.json script.yml
# 결과를 HTML 리포트로 변환
artillery report report.json
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;25&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;4. 테스트 결과 해석하기 (지표 읽는 법)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;테스트가 끝나면 터미널이나 HTML 리포트에 여러 수치가 나타납니다. 무엇을 중요하게 봐야 할까요?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;28&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,0,0&quot;&gt;Scenarios created/completed:&lt;/b&gt; 생성된 가상 사용자가 요청을 모두 마치고 성공적으로 종료되었는지 확인합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,1,0&quot;&gt;RPS (Requests Per Second):&lt;/b&gt; 초당 처리량입니다. 서버가 초당 몇 개의 요청을 소화하는지 보여줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,2,0&quot;&gt;Latency (p95, p99):&lt;/b&gt; 응답 속도입니다. p95가 500ms라면 전체 사용자 중 95%가 0.5초 이내에 응답을 받았음을 의미합니다. 이 수치가 급격히 올라가면 서버가 힘겨워하고 있다는 신호입니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,3,0&quot;&gt;HTTP 2xx vs 5xx:&lt;/b&gt; 500번대 에러가 발생하기 시작하는 시점이 바로 내 서버의 &lt;b data-index-in-node=&quot;50&quot; data-path-to-node=&quot;28,3,0&quot;&gt;한계치&lt;/b&gt;입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;29&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론: 부하 테스트는 '신뢰'를 만드는 과정입니다&lt;/h2&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;부하 테스트를 마쳤다면, 이제 &quot;우리 서버는 동시 접속자 500명까지는 응답 속도 0.3초를 유지할 수 있습니다&quot;라고 자신 있게 말할 수 있습니다. 수치화된 데이터는 인프라 증설이나 코드 최적화의 명확한 기준이 됩니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>artillery</category>
      <category>backend</category>
      <category>DevOps</category>
      <category>LoadTesting</category>
      <category>nodejs</category>
      <category>PerformanceTest</category>
      <category>부하테스트</category>
      <category>서버성능</category>
      <category>성능측정</category>
      <category>아틸러리</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/18</guid>
      <comments>https://minari98.tistory.com/entry/Load-Testing-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%B6%9C%EC%8B%9C-%EC%A0%84-%ED%95%84%EC%88%98-%EC%BD%94%EC%8A%A4-%EC%84%9C%EB%B2%84-%EB%B6%80%ED%95%98-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A1%9C-%ED%95%9C%EA%B3%84-%EC%B8%A1%EC%A0%95%ED%95%98%EA%B8%B0#entry18comment</comments>
      <pubDate>Wed, 15 Apr 2026 18:47:25 +0900</pubDate>
    </item>
    <item>
      <title>[CloudFront] 전 세계 어디서나 빠른 웹사이트: AWS CDN 도입 및 캐싱 전략</title>
      <link>https://minari98.tistory.com/entry/CloudFront-%EC%A0%84-%EC%84%B8%EA%B3%84-%EC%96%B4%EB%94%94%EC%84%9C%EB%82%98-%EB%B9%A0%EB%A5%B8-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8-AWS-CDN-%EB%8F%84%EC%9E%85-%EB%B0%8F-%EC%BA%90%EC%8B%B1-%EC%A0%84%EB%9E%B5</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;내 웹사이트의 서버가 한국에 있다면, 미국이나 유럽에 있는 사용자는 사이트 로딩 속도가 현저히 느려질 수밖에 없습니다. 물리적인 거리가 멀수록 데이터가 오가는 시간이 길어지기 때문입니다. 이러한 지연 시간(Latency)을 획기적으로 줄여주는 마법 같은 기술이 바로 **CDN(Content Delivery Network)**입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 AWS의 강력한 CDN 서비스인 &lt;b data-index-in-node=&quot;22&quot; data-path-to-node=&quot;5&quot;&gt;CloudFront&lt;/b&gt;를 도입하여 전 세계 어디서나 쾌적한 속도를 보장하고, 서버의 부하를 줄이는 캐싱 전략을 상세히 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. CloudFront(CDN)란 무엇인가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;CloudFront는 전 세계 곳곳에 흩어져 있는 **엣지 로케이션(Edge Location)**이라는 캐시 서버망을 활용합니다. 사용자가 웹사이트에 접속하면, 한국 서버까지 올 필요 없이 가장 가까운 엣지 로케이션에서 미리 복사해둔 콘텐츠(이미지, JS, CSS 등)를 전달합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;1.1 CDN 도입의 주요 이점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;10&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0,0&quot;&gt;로딩 속도 개선:&lt;/b&gt; 사용자 근처에서 데이터를 전달하므로 물리적 거리로 인한 지연이 사라집니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,1,0&quot;&gt;원본 서버 부하 감소:&lt;/b&gt; 대부분의 요청을 CDN이 처리하므로 실제 EC2 서버의 CPU와 대역폭 사용량이 줄어듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,2,0&quot;&gt;보안 강화:&lt;/b&gt; AWS Shield와 연동되어 DDoS 공격을 1차적으로 방어하며, SSL 인증서 적용이 매우 간편합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;11&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size26&quot;&gt;2. CloudFront 배포(Distribution) 생성하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;이제 내 EC2 서버나 S3 버킷을 원본(Origin)으로 설정하여 CloudFront를 구축해 보겠습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;14&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,0,0&quot;&gt;AWS 콘솔&lt;/b&gt;에서 &lt;b data-index-in-node=&quot;9&quot; data-path-to-node=&quot;14,0,0&quot;&gt;CloudFront&lt;/b&gt; 서비스로 접속합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,1,0&quot;&gt;[배포 생성]&lt;/b&gt; 버튼을 클릭합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,2,0&quot;&gt;원본 도메인(Origin Domain):&lt;/b&gt; 내 EC2의 도메인 주소나 S3 버킷 주소를 선택합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,3,0&quot;&gt;뷰어 프로토콜 정책:&lt;/b&gt; 보안을 위해 Redirect HTTP to HTTPS를 권장합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,4,0&quot;&gt;캐시 설정:&lt;/b&gt; 기본값인 CachingOptimized를 선택하면 AWS에서 권장하는 최적의 캐싱 정책이 적용됩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;15&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size26&quot;&gt;3. 핵심 캐싱 전략: TTL(Time To Live) 관리&lt;/h2&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;CDN의 핵심은 **&quot;얼마나 오랫동안 데이터를 보관할 것인가?&quot;**입니다. 이를 제어하는 수치가 바로 TTL입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;18&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,0,0&quot;&gt;정적 콘텐츠 (이미지, 폰트 등):&lt;/b&gt; 내용이 거의 바뀌지 않으므로 TTL을 길게(예: 1년) 설정하여 캐시 효율을 극대화합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,1,0&quot;&gt;동적 콘텐츠 (HTML, JSON 등):&lt;/b&gt; 실시간 데이터가 중요하므로 TTL을 짧게 설정하거나, 캐싱을 아예 하지 않도록 설정합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size23&quot;&gt;3.1 캐시 무효화 (Invalidation)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;만약 이미지를 수정했는데 CDN에 이전 이미지가 남아있다면 어떻게 할까요? 이때 사용하는 기능이 **'무효화(Invalidation)'**입니다. 특정 경로(예: /images/*)를 무효화하면 엣지 로케이션의 데이터를 즉시 삭제하고 원본에서 새 데이터를 가져오게 합니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;21&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;4. Route 53과 CloudFront 연결하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;이제 내가 가진 멋진 도메인으로 접속했을 때 CDN을 거치도록 설정해야 합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;24&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24,0,0&quot;&gt;Route 53&lt;/b&gt; 호스팅 영역으로 이동합니다.&lt;/li&gt;
&lt;li&gt;기존의 A 레코드를 수정하거나 새로 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24,2,0&quot;&gt;별칭(Alias)&lt;/b&gt; 스위치를 켭니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24,3,0&quot;&gt;트래픽 라우팅 대상:&lt;/b&gt; CloudFront 배포에 대한 별칭을 선택하고 생성한 배포 주소를 지정합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;25&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론: 진정한 글로벌 서비스로의 진화&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;CloudFront 도입은 웹 서비스의 품질을 한 단계 격상시키는 중요한 결정입니다. 단순히 '속도'뿐만 아니라 '비용 절감'과 '보안'까지 챙길 수 있는 일석삼조의 전략이기 때문입니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>AWS</category>
      <category>cache</category>
      <category>CDN</category>
      <category>cloudfront</category>
      <category>edgelocation</category>
      <category>Latency</category>
      <category>Route53</category>
      <category>서버성능</category>
      <category>인프라최적화</category>
      <category>전송속도개선</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/17</guid>
      <comments>https://minari98.tistory.com/entry/CloudFront-%EC%A0%84-%EC%84%B8%EA%B3%84-%EC%96%B4%EB%94%94%EC%84%9C%EB%82%98-%EB%B9%A0%EB%A5%B8-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8-AWS-CDN-%EB%8F%84%EC%9E%85-%EB%B0%8F-%EC%BA%90%EC%8B%B1-%EC%A0%84%EB%9E%B5#entry17comment</comments>
      <pubDate>Tue, 14 Apr 2026 18:46:07 +0900</pubDate>
    </item>
    <item>
      <title>[Subdomain] 하나의 도메인으로 여러 서비스 운영하기: 개발 및 운영 서버 분리 전략</title>
      <link>https://minari98.tistory.com/entry/Subdomain-%ED%95%98%EB%82%98%EC%9D%98-%EB%8F%84%EB%A9%94%EC%9D%B8%EC%9C%BC%EB%A1%9C-%EC%97%AC%EB%9F%AC-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9A%B4%EC%98%81%ED%95%98%EA%B8%B0-%EA%B0%9C%EB%B0%9C-%EB%B0%8F-%EC%9A%B4%EC%98%81-%EC%84%9C%EB%B2%84-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹 서비스를 개발하다 보면, 새로운 기능을 배포하기 전에 실제 서버와 동일한 환경에서 미리 테스트해 볼 공간이 필요합니다. 이때 기존 도메인을 활용하여 dev나 test 같은 머리말을 붙인 &lt;b data-index-in-node=&quot;106&quot; data-path-to-node=&quot;4&quot;&gt;서브도메인&lt;/b&gt;을 생성하면 비용 추가 없이 독립적인 환경을 구축할 수 있습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 Route 53과 Nginx를 활용하여 하나의 도메인으로 여러 개의 서비스를 스마트하게 관리하는 방법을 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 서브도메인이란 무엇인가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;서브도메인은 루트 도메인(Root Domain, 예: example.com)의 왼쪽에 마침표로 구분하여 추가하는 이름입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;운영 서버 (Production):&lt;/b&gt; www.example.com 또는 example.com&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;개발 서버 (Development):&lt;/b&gt; dev.example.com&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,2,0&quot;&gt;API 서버 (API):&lt;/b&gt; api.example.com&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;이들은 각각 서로 다른 IP 주소(다른 EC2 인스턴스)를 가리키거나, 한 서버 내에서 서로 다른 포트로 연결될 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;11&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size26&quot;&gt;2. AWS Route 53에서 서브도메인 레코드 추가하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;가장 먼저 해야 할 일은 DNS 설정에서 서브도메인이 어디로 가야 할지 이정표를 세워주는 것입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;14&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,0,0&quot;&gt;Route 53 호스팅 영역&lt;/b&gt;으로 이동합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,1,0&quot;&gt;[레코드 생성]&lt;/b&gt; 버튼을 클릭합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,2,0&quot;&gt;레코드 이름:&lt;/b&gt; dev (원하는 서브도메인 이름)를 입력합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,3,0&quot;&gt;레코드 유형:&lt;/b&gt; A - IPv4 주소를 선택합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,4,0&quot;&gt;값:&lt;/b&gt; 개발용으로 사용할 서버의 &lt;b data-index-in-node=&quot;17&quot; data-path-to-node=&quot;14,4,0&quot;&gt;IP 주소&lt;/b&gt;를 입력합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;이제 dev.your-domain.com을 입력하면 해당 IP의 서버로 접속 신호가 전달됩니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;16&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size26&quot;&gt;3. Nginx 서버 블록(Virtual Host) 설정&lt;/h2&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;서버에 신호가 도착했다면, Nginx가 &quot;이건 dev로 들어온 요청이니 이 폴더를 보여줘!&quot;라고 판단할 수 있게 설정해야 합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size23&quot;&gt;3.1 운영 서버와 개발 서버 설정 분리&lt;/h3&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;/etc/nginx/sites-available/ 디렉토리에 각각의 설정 파일을 만듭니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21&quot;&gt;운영 서버 설정 (my-app-prod):&lt;/b&gt;&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQhgY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Nginx&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;server {
    listen 80;
    server_name your-domain.com www.your-domain.com;

    location / {
        proxy_pass http://localhost:3000; # 운영 포트
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23&quot;&gt;개발 서버 설정 (my-app-dev):&lt;/b&gt;&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQhwY&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Nginx&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;server {
    listen 80;
    server_name dev.your-domain.com;

    location / {
        proxy_pass http://localhost:4000; # 개발용 포트 또는 다른 폴더
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 server_name을 기반으로 요청을 분기하면 하나의 서버(Nginx) 안에서도 여러 서비스를 완벽히 분리하여 운영할 수 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;26&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size26&quot;&gt;4. 서브도메인 활용의 장점&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;28&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,0,0&quot;&gt;비용 절감:&lt;/b&gt; 도메인을 추가로 구매할 필요가 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,1,0&quot;&gt;독립적인 테스트:&lt;/b&gt; 운영 중인 서비스에 영향을 주지 않고 새로운 기능을 마음껏 테스트할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,2,0&quot;&gt;브랜드 통일성:&lt;/b&gt; 서비스의 성격에 따라 주소를 나누더라도 메인 도메인의 브랜드 가치를 유지할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,3,0&quot;&gt;SSL 통합 관리:&lt;/b&gt; Wildcard SSL 인증서(*.example.com)를 사용하면 모든 서브도메인에 한 번에 HTTPS를 적용할 수 있어 관리가 편리합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;29&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;5. 실무 꿀팁: 개발 서버 보안 강화&lt;/h2&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;개발 서버(dev.)는 외부인에게 노출되어서는 안 되는 공간입니다. 서브도메인을 만들었다면 반드시 아래 설정을 추가하세요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;32&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,0,0&quot;&gt;IP 제한:&lt;/b&gt; 사내 IP나 본인의 IP에서만 접속할 수 있도록 Nginx 설정에서 allow 기능을 사용하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,1,0&quot;&gt;검색 엔진 차단:&lt;/b&gt; 구글 봇이 개발 서버의 내용을 수집하지 않도록 robots.txt에 Disallow: / 설정을 추가하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,2,0&quot;&gt;기본 인증:&lt;/b&gt; ID/PW를 입력해야 접속할 수 있는 &lt;b data-index-in-node=&quot;28&quot; data-path-to-node=&quot;32,2,0&quot;&gt;HTTP Basic Auth&lt;/b&gt;를 걸어두는 것도 좋은 방법입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;33&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;34&quot; data-ke-size=&quot;size26&quot;&gt;6. 결론: 체계적인 배포 프로세스의 완성&lt;/h2&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;서브도메인을 활용한 환경 분리는 단순한 기술을 넘어 배포의 안전성을 책임지는 중요한 전략입니다. 이제 dev 서버에서 충분히 테스트하고 확신이 섰을 때 www 서버로 코드를 옮기는 &lt;b data-index-in-node=&quot;101&quot; data-path-to-node=&quot;35&quot;&gt;안전한 개발 문화&lt;/b&gt;를 시작해 보세요.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>AWS</category>
      <category>DevelopmentEnvironment</category>
      <category>DevOps</category>
      <category>nginx</category>
      <category>Route53</category>
      <category>ServerConfig</category>
      <category>subdomain</category>
      <category>개발환경</category>
      <category>서버분리</category>
      <category>서브도메인</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/16</guid>
      <comments>https://minari98.tistory.com/entry/Subdomain-%ED%95%98%EB%82%98%EC%9D%98-%EB%8F%84%EB%A9%94%EC%9D%B8%EC%9C%BC%EB%A1%9C-%EC%97%AC%EB%9F%AC-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9A%B4%EC%98%81%ED%95%98%EA%B8%B0-%EA%B0%9C%EB%B0%9C-%EB%B0%8F-%EC%9A%B4%EC%98%81-%EC%84%9C%EB%B2%84-%EB%B6%84%EB%A6%AC-%EC%A0%84%EB%9E%B5#entry16comment</comments>
      <pubDate>Mon, 13 Apr 2026 18:44:56 +0900</pubDate>
    </item>
    <item>
      <title>[Route 53] 외부 도메인 연결 및 AWS 호스팅 영역 설정 완벽 가이드</title>
      <link>https://minari98.tistory.com/entry/Route-53-%EC%99%B8%EB%B6%80-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%97%B0%EA%B2%B0-%EB%B0%8F-AWS-%ED%98%B8%EC%8A%A4%ED%8C%85-%EC%98%81%EC%97%AD-%EC%84%A4%EC%A0%95-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;도메인을 구매하는 곳(가비아, 고대디 등)과 서버가 위치한 곳(AWS)이 다를 때, 초보 개발자들은 어떻게 이 둘을 연결해야 할지 막막해하곤 합니다. 단순히 IP만 연결할 수도 있지만, AWS의 &lt;b data-index-in-node=&quot;109&quot; data-path-to-node=&quot;4&quot;&gt;Route 53&lt;/b&gt;을 활용하면 DNS 응답 속도가 빨라질 뿐만 아니라 SSL 인증서 발급이나 로드 밸런싱 연동이 훨씬 간편해집니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 외부에서 구매한 도메인을 AWS Route 53으로 가져와 내 EC2 서버와 연결하는 전 과정을 상세히 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. Route 53 호스팅 영역(Hosted Zone) 생성&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;먼저 AWS가 내 도메인 정보를 관리할 수 있도록 '방'을 만들어주어야 합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;AWS 콘솔에서 &lt;b data-index-in-node=&quot;9&quot; data-path-to-node=&quot;9,0,0&quot;&gt;Route 53&lt;/b&gt; 서비스로 접속합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;[호스팅 영역 생성]&lt;/b&gt; 버튼을 클릭합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,2,0&quot;&gt;도메인 이름:&lt;/b&gt; 본인이 구매한 도메인(예: yumina-dev.com)을 입력합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,3,0&quot;&gt;유형:&lt;/b&gt; '퍼블릭 호스팅 영역'을 선택하고 생성을 완료합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;생성이 완료되면 &lt;b data-index-in-node=&quot;9&quot; data-path-to-node=&quot;10&quot;&gt;NS(네임서버)&lt;/b&gt; 유형의 레코드 4개가 나타납니다. 이 주소들이 바로 외부 업체에 알려줘야 할 AWS의 주소입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;11&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size26&quot;&gt;2. 외부 업체(가비아 등)에서 네임서버 변경하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;이제 도메인 구매 업체 사이트로 이동하여 &quot;이 도메인의 관리 권한을 AWS에 넘기겠다&quot;고 설정해야 합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;14&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;가비아(또는 해당 업체) 로그인 후 **[도메인 관리] -&amp;gt; [네임서버 설정]**으로 이동합니다.&lt;/li&gt;
&lt;li&gt;기존에 적혀 있던 네임서버를 지우고, Route 53에서 확인한 &lt;b data-index-in-node=&quot;36&quot; data-path-to-node=&quot;14,1,0&quot;&gt;4개의 NS 레코드&lt;/b&gt;를 차례대로 입력합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;14,1,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,1,1,0,0&quot;&gt;주의:&lt;/b&gt; 주소 끝에 있는 마침표(.)는 제외하고 입력해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;변경 내용을 저장합니다. (네임서버 정보가 전 세계로 전파되는 데는 최소 5분에서 최대 48시간이 소요될 수 있습니다.)&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;15&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size26&quot;&gt;3. 레코드 생성을 통한 EC2 IP 연결 (A 레코드)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;네임서버 연결이 끝났다면, 이제 도메인 주소를 입력했을 때 내 EC2 서버로 접속되도록 설정해야 합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;18&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Route 53 호스팅 영역 안에서 **[레코드 생성]**을 누릅니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,1,0&quot;&gt;레코드 이름:&lt;/b&gt; 비워두면 루트 도메인(example.com), www를 입력하면 www.example.com이 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,2,0&quot;&gt;레코드 유형:&lt;/b&gt; A - IPv4 주소 및 일부 AWS 리소스에 트래픽 라우팅을 선택합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,3,0&quot;&gt;값:&lt;/b&gt; 내 EC2 인스턴스의 &lt;b data-index-in-node=&quot;15&quot; data-path-to-node=&quot;18,3,0&quot;&gt;탄력적 IP(Elastic IP)&lt;/b&gt; 주소를 입력합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;이제 잠시 후 브라우저에 도메인을 입력하면 내 서버의 페이지가 뜨는 것을 확인할 수 있습니다!&lt;/p&gt;
&lt;hr data-path-to-node=&quot;20&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;4. Route 53 활용의 장점: CNAME과 Alias&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;단순히 IP만 연결하는 것이 아니라, Route 53은 더 스마트한 기능을 제공합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;23&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,0,0&quot;&gt;CNAME 레코드:&lt;/b&gt; 도메인을 다른 도메인 이름으로 연결할 때 사용합니다. (예: blog.example.com을 tistory.com으로 연결)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,1,0&quot;&gt;별칭(Alias):&lt;/b&gt; AWS의 고유 기능으로, 로드 밸런서(ALB)나 CloudFront 같은 서비스의 주소가 바뀌더라도 Route 53이 알아서 추적하여 연결해 줍니다. 별도의 쿼리 비용이 들지 않아 경제적입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;24&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;25&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론: 전문적인 도메인 관리의 시작&lt;/h2&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;Route 53은 단순한 주소 연결 도구를 넘어, 전 세계 사용자에게 가장 빠른 경로로 서비스를 안내하는 길잡이 역할을 합니다. 가비아에서 네임서버를 한 번만 옮겨두면, 이후에는 AWS 콘솔 안에서 모든 네트워크 설정을 제어할 수 있어 관리 효율성이 극대화됩니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>AWS</category>
      <category>DNS</category>
      <category>Domain</category>
      <category>EC2</category>
      <category>gabia</category>
      <category>nameserver</category>
      <category>Route53</category>
      <category>가비아도메인</category>
      <category>네임서버변경</category>
      <category>도메인연결</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/15</guid>
      <comments>https://minari98.tistory.com/entry/Route-53-%EC%99%B8%EB%B6%80-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%97%B0%EA%B2%B0-%EB%B0%8F-AWS-%ED%98%B8%EC%8A%A4%ED%8C%85-%EC%98%81%EC%97%AD-%EC%84%A4%EC%A0%95-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C#entry15comment</comments>
      <pubDate>Sun, 12 Apr 2026 18:43:41 +0900</pubDate>
    </item>
    <item>
      <title>[AWS] 프리티어 유저를 위한 EC2 인스턴스 과금 방지 및 효율적 관리법</title>
      <link>https://minari98.tistory.com/entry/AWS-%ED%94%84%EB%A6%AC%ED%8B%B0%EC%96%B4-%EC%9C%A0%EC%A0%80%EB%A5%BC-%EC%9C%84%ED%95%9C-EC2-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EA%B3%BC%EA%B8%88-%EB%B0%A9%EC%A7%80-%EB%B0%8F-%ED%9A%A8%EC%9C%A8%EC%A0%81-%EA%B4%80%EB%A6%AC%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;클라우드 서비스의 대명사인 AWS(Amazon Web Services)는 신규 가입자에게 12개월간 &lt;b data-index-in-node=&quot;56&quot; data-path-to-node=&quot;4&quot;&gt;프리티어(Free Tier)&lt;/b&gt; 혜택을 제공합니다. 하지만 '무료'라는 말만 믿고 설정을 소홀히 했다가는 예상치 못한 과금 고지서를 받을 수 있습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 내 소중한 지갑을 지키면서 AWS EC2 인스턴스를 스마트하게 운영하는 5가지 핵심 전략을 정리해 보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 프리티어 한도 정확히 이해하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;가장 먼저 내가 쓸 수 있는 자원의 양을 알아야 합니다. EC2 프리티어의 핵심 조건은 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;인스턴스 사용 시간:&lt;/b&gt; 매달 &lt;b data-index-in-node=&quot;15&quot; data-path-to-node=&quot;9,0,0&quot;&gt;750시간&lt;/b&gt; 무료 (t2.micro 또는 t3.micro 기준).&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;저장 공간(EBS):&lt;/b&gt; 총 &lt;b data-index-in-node=&quot;14&quot; data-path-to-node=&quot;9,1,0&quot;&gt;30GB&lt;/b&gt;까지 무료.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,2,0&quot;&gt;데이터 전송(Outbound):&lt;/b&gt; 인터넷으로 나가는 데이터 매달 &lt;b data-index-in-node=&quot;35&quot; data-path-to-node=&quot;9,2,0&quot;&gt;100GB&lt;/b&gt;까지 무료.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;10&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size26&quot;&gt;2. 요금 폭탄 방지의 핵심: '결제 대시보드'와 '예산 알림'&lt;/h2&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;과금을 방지하는 가장 강력한 방법은 미리 **알람(Alarm)**을 설정하는 것입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;2.1 Billing Alert 설정&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;14&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;AWS 콘솔에서 **[Billing &amp;amp; Cost Management]**로 이동합니다.&lt;/li&gt;
&lt;li&gt;**[Billing preferences]**에서 'Receive Free Tier Usage Alerts'와 'Receive Billing Alerts'를 체크합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size23&quot;&gt;2.2 AWS Budgets 활용&lt;/h3&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;특정 금액(예: $1) 이상 요금이 발생할 기미가 보이면 내 이메일로 즉시 알림이 오도록 설정하세요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;17&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,0,0&quot;&gt;설정 방법:&lt;/b&gt; [Budgets] -&amp;gt; [Create budget] -&amp;gt; [Cost budget] 선택 -&amp;gt; 한도 금액 설정 후 알림 수신 이메일 등록.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;18&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size26&quot;&gt;3. 리소스 관리의 기술: 쓰지 않는 자원 정리&lt;/h2&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;의외로 많은 과금이 '인스턴스' 자체가 아닌 &lt;b data-index-in-node=&quot;25&quot; data-path-to-node=&quot;20&quot;&gt;부수적인 자원&lt;/b&gt;에서 발생합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;21&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,0,0&quot;&gt;Elastic IP (탄력적 IP):&lt;/b&gt; 인스턴스에 연결되지 않은 채 할당만 되어 있는 고정 IP는 &lt;b data-index-in-node=&quot;54&quot; data-path-to-node=&quot;21,0,0&quot;&gt;시간당 요금&lt;/b&gt;이 부과됩니다. 인스턴스를 삭제했다면 반드시 탄력적 IP도 '릴리스(Release)' 하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,1,0&quot;&gt;EBS 스냅샷:&lt;/b&gt; 백업을 위해 만든 스냅샷도 용량에 따라 비용이 발생합니다. 오래된 스냅샷은 주기적으로 정리하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,2,0&quot;&gt;미사용 볼륨:&lt;/b&gt; 인스턴스를 삭제할 때 '종료 시 삭제' 옵션을 체크하지 않으면, 인스턴스는 사라져도 저장 장치(EBS)는 남아 계속 과금됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;22&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;4. 인스턴스 유형 선택 시 주의사항&lt;/h2&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;프리티어 대상은 보통 &lt;b data-index-in-node=&quot;12&quot; data-path-to-node=&quot;24&quot;&gt;t2.micro&lt;/b&gt; (일부 지역은 &lt;b data-index-in-node=&quot;29&quot; data-path-to-node=&quot;24&quot;&gt;t3.micro&lt;/b&gt;)입니다. 인스턴스를 생성할 때 반드시 아래와 같이 녹색으로 표시된 &lt;b data-index-in-node=&quot;75&quot; data-path-to-node=&quot;24&quot;&gt;'Free tier eligible'&lt;/b&gt; 문구를 확인하세요. 실수로 다른 사양을 선택하면 생성 직후부터 요금이 발생합니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;25&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;5. 보안 그룹(Security Group) 최적화로 트래픽 보호&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;악의적인 사용자가 내 서버에 무차별적인 요청을 보내 트래픽(Outbound Data Transfer)을 과도하게 발생시키면 이 또한 요금으로 이어집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;28&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,0,0&quot;&gt;불필요한 포트 폐쇄:&lt;/b&gt; 앞서 배운 UFW 설정이나 AWS 보안 그룹 설정을 통해 꼭 필요한 포트만 개방하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,1,0&quot;&gt;IP 제한:&lt;/b&gt; 관리자 페이지나 특정 서비스는 화이트리스트 IP만 허용하여 불필요한 트래픽 낭비를 막으세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;29&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;6. 결론: 클라우드 관리는 모니터링에서 시작됩니다&lt;/h2&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;AWS 프리티어는 공부와 테스트를 위한 아주 좋은 환경이지만, 관리에 소홀하면 독이 될 수 있습니다. 매주 한 번은 &lt;b data-index-in-node=&quot;65&quot; data-path-to-node=&quot;31&quot;&gt;Billing Dashboard&lt;/b&gt;에 접속하여 사용량을 체크하는 습관을 들이세요.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>AWS</category>
      <category>AWS프리티어</category>
      <category>BillingAlert</category>
      <category>cloudcost</category>
      <category>EC2</category>
      <category>freetier</category>
      <category>ServerAdmin</category>
      <category>요금폭탄방지</category>
      <category>인프라관리</category>
      <category>클라우드보안</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/14</guid>
      <comments>https://minari98.tistory.com/entry/AWS-%ED%94%84%EB%A6%AC%ED%8B%B0%EC%96%B4-%EC%9C%A0%EC%A0%80%EB%A5%BC-%EC%9C%84%ED%95%9C-EC2-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EA%B3%BC%EA%B8%88-%EB%B0%A9%EC%A7%80-%EB%B0%8F-%ED%9A%A8%EC%9C%A8%EC%A0%81-%EA%B4%80%EB%A6%AC%EB%B2%95#entry14comment</comments>
      <pubDate>Sat, 11 Apr 2026 18:42:30 +0900</pubDate>
    </item>
    <item>
      <title>[Fail2Ban] 무차별 대입 공격(Brute-force)을 자동으로 차단하는 보안 도구 설치</title>
      <link>https://minari98.tistory.com/entry/Fail2Ban-%EB%AC%B4%EC%B0%A8%EB%B3%84-%EB%8C%80%EC%9E%85-%EA%B3%B5%EA%B2%A9Brute-force%EC%9D%84-%EC%9E%90%EB%8F%99%EC%9C%BC%EB%A1%9C-%EC%B0%A8%EB%8B%A8%ED%95%98%EB%8A%94-%EB%B3%B4%EC%95%88-%EB%8F%84%EA%B5%AC-%EC%84%A4%EC%B9%98</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;리눅스 서버를 운영하며 로그를 살펴보면, 전 세계에서 수많은 IP가 내 서버의 SSH 포트(22번 등)로 접속하기 위해 끊임없이 로그인을 시도하는 것을 볼 수 있습니다. 이를 **무차별 대입 공격(Brute-force Attack)**이라고 합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;비밀번호를 아무리 어렵게 설정해도 수만 번의 시도가 반복되면 서버 자원이 낭비되고 보안 위협이 커집니다. 오늘은 이러한 공격을 실시간으로 감시하고, 일정 횟수 이상 실패하면 해당 IP를 자동으로 차단해 주는 든든한 파수꾼, &lt;b data-index-in-node=&quot;125&quot; data-path-to-node=&quot;5&quot;&gt;Fail2Ban&lt;/b&gt;의 설치 및 설정법을 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. Fail2Ban이란 무엇인가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8&quot;&gt;Fail2Ban&lt;/b&gt;은 로그 파일을 분석하여 악의적인 활동을 하는 IP를 찾아내고, 방화벽(UFW, iptables 등)을 통해 해당 IP의 접속을 일정 시간 동안 금지(Ban)시키는 보안 프레임워크입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;1.1 주요 작동 방식&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;10&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0,0&quot;&gt;로그 모니터링:&lt;/b&gt; SSH, Nginx, MySQL 등 서비스의 로그 파일을 실시간으로 읽습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,1,0&quot;&gt;패턴 매칭:&lt;/b&gt; 'Password fail' 같은 특정 실패 패턴이 반복되는지 확인합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,2,0&quot;&gt;자동 차단:&lt;/b&gt; 설정된 횟수(예: 5회)를 초과하면 해당 IP를 방화벽 규칙에 추가하여 차단합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;11&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size26&quot;&gt;2. Fail2Ban 설치 및 기본 설정&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;2.1 설치하기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;우분투 환경에서는 패키지 매니저를 통해 간단히 설치할 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ0wU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;sudo apt update
sudo apt install fail2ban -y
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size23&quot;&gt;2.2 설정 파일 관리 (.local 파일 생성)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;Fail2Ban의 기본 설정 파일은 /etc/fail2ban/jail.conf입니다. 하지만 업데이트 시 초기화될 수 있으므로, 복사본인 .local 파일을 만들어 수정하는 것이 관례입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ1AU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;19&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size26&quot;&gt;3. 핵심 보안 정책 설정 (jail.local)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;설정 파일 내의 [DEFAULT] 섹션에서 전역 정책을 결정합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;22&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,0,0&quot;&gt;ignoreip:&lt;/b&gt; 차단에서 제외할 IP (본인의 PC IP를 등록하여 본인이 차단되는 것을 방지하세요).&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,1,0&quot;&gt;bantime:&lt;/b&gt; 차단할 시간 (예: 1h는 1시간, -1은 영구 차단).&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,2,0&quot;&gt;findtime:&lt;/b&gt; 실패 횟수를 계산할 시간 범위 (예: 10m 동안 5번 실패 시 차단).&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,3,0&quot;&gt;maxretry:&lt;/b&gt; 허용되는 최대 실패 횟수.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size23&quot;&gt;3.1 SSH 보호 활성화 예시&lt;/h3&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;파일 하단의 [sshd] 섹션을 찾아 아래와 같이 활성화합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ1QU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Plaintext&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;[sshd]
enabled = true
port    = ssh
filter  = sshd
logpath = %(sshd_log)s
maxretry = 5
bantime = 1d
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&lt;i data-index-in-node=&quot;0&quot; data-path-to-node=&quot;26&quot;&gt;(위 설정은 10분 내에 5번 로그인 실패 시 해당 IP를 하루 동안 차단합니다.)&lt;/i&gt;&lt;/p&gt;
&lt;hr data-path-to-node=&quot;27&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size26&quot;&gt;4. 서비스 실행 및 상태 확인&lt;/h2&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;설정을 마쳤다면 서비스를 재시작하여 적용합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ1gU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;sudo systemctl restart fail2ban
sudo systemctl enable fail2ban
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;31&quot; data-ke-size=&quot;size23&quot;&gt;4.1 현재 차단된 IP 목록 확인&lt;/h3&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;Fail2Ban이 열일하고 있는지 확인해 봅시다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ1wU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;# SSH 서비스에 대한 차단 상태 확인
sudo fail2ban-client status sshd
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;명령어를 실행하면 현재 감시 중인 IP 수와 실제로 차단된(Banned) IP 목록이 나타납니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;35&quot; data-ke-size=&quot;size23&quot;&gt;4.2 실수로 차단된 내 IP 해제하기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;만약 본인이 차단되었다면 다른 네트워크에서 접속하여 아래 명령어로 해제할 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ2AU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;sudo fail2ban-client set sshd unbanip [내IP주소]
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;38&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;39&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론: 자동화된 보안의 힘&lt;/h2&gt;
&lt;p data-path-to-node=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;Fail2Ban은 관리자가 잠든 사이에도 서버를 공격으로부터 지켜주는 가장 효율적인 보안 도구입니다. &lt;b data-index-in-node=&quot;57&quot; data-path-to-node=&quot;40&quot;&gt;UFW 방화벽&lt;/b&gt;이 성벽이라면, &lt;b data-index-in-node=&quot;73&quot; data-path-to-node=&quot;40&quot;&gt;Fail2Ban&lt;/b&gt;은 성벽을 넘으려는 침입자를 감시하고 포획하는 경비병과 같습니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>bruteforce</category>
      <category>fail2ban</category>
      <category>linux</category>
      <category>ServerSecurity</category>
      <category>ssh</category>
      <category>ufw</category>
      <category>리눅스보안</category>
      <category>무차별대입공격</category>
      <category>서버보안</category>
      <category>침입차단</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/13</guid>
      <comments>https://minari98.tistory.com/entry/Fail2Ban-%EB%AC%B4%EC%B0%A8%EB%B3%84-%EB%8C%80%EC%9E%85-%EA%B3%B5%EA%B2%A9Brute-force%EC%9D%84-%EC%9E%90%EB%8F%99%EC%9C%BC%EB%A1%9C-%EC%B0%A8%EB%8B%A8%ED%95%98%EB%8A%94-%EB%B3%B4%EC%95%88-%EB%8F%84%EA%B5%AC-%EC%84%A4%EC%B9%98#entry13comment</comments>
      <pubDate>Fri, 10 Apr 2026 18:41:17 +0900</pubDate>
    </item>
    <item>
      <title>[Cron] 서버 자동화를 위한 크론탭(Crontab) 사용법과 주기 설정 팁</title>
      <link>https://minari98.tistory.com/entry/Cron-%EC%84%9C%EB%B2%84-%EC%9E%90%EB%8F%99%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-%ED%81%AC%EB%A1%A0%ED%83%ADCrontab-%EC%82%AC%EC%9A%A9%EB%B2%95%EA%B3%BC-%EC%A3%BC%EA%B8%B0-%EC%84%A4%EC%A0%95-%ED%8C%81</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 운영하다 보면 매일 특정 시간에 데이터베이스를 백업하거나, 매주 월요일마다 임시 파일을 정리해야 하는 상황이 생깁니다. 이런 반복적인 작업을 사람이 일일이 수동으로 하는 것은 비효율적일 뿐만 아니라 실수할 위험도 큽니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;리눅스 시스템에는 이러한 작업을 예약하고 자동으로 실행해 주는 **크론(Cron)**이라는 강력한 도구가 있습니다. 오늘은 크론의 설정 파일인 **크론탭(Crontab)**의 문법부터 실전 활용 사례까지 상세히 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 크론탭(Crontab)이란 무엇인가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;**크론(Cron)**은 리눅스용 시간 기반 잡 스케줄러입니다. 그리고 **크론탭(Crontab)**은 크론 작업(Cron Job)을 설정하는 테이블(Table) 형태의 파일을 말합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 크론탭에 특정 시간과 실행할 명령어를 등록해 두면, 시스템은 백그라운드에서 대기하다가 정해진 시간에 맞춰 해당 명령을 자동으로 수행합니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;10&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size26&quot;&gt;2. 크론탭 기본 명령어&lt;/h2&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;터미널에서 크론탭을 관리하기 위한 기본 명령어 3가지는 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;13&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13,0,0&quot;&gt;crontab -e:&lt;/b&gt; 크론탭 설정을 편집합니다. (가장 많이 사용)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13,1,0&quot;&gt;crontab -l:&lt;/b&gt; 현재 예약된 크론 작업 목록을 출력합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13,2,0&quot;&gt;crontab -r:&lt;/b&gt; 예약된 모든 크론 작업을 삭제합니다. (주의해서 사용)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;14&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;3. 크론탭 주기 설정 문법 (별 5개의 의미)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;크론탭 설정은 한 줄에 &lt;b data-index-in-node=&quot;13&quot; data-path-to-node=&quot;16&quot;&gt;5개의 시간 단위&lt;/b&gt;와 &lt;b data-index-in-node=&quot;24&quot; data-path-to-node=&quot;16&quot;&gt;실행할 명령어&lt;/b&gt;를 공백으로 구분하여 작성합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQtAU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Plaintext&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;* * * * * [실행할 명령어]
┬  ┬  ┬  ┬  ┬
│  │  │  │  └─ 요일 (0-6, 0:일요일)
│  │  │  └─ 월 (1-12)
│  │  └─ 일 (1-31)
│  └─ 시 (0-23)
└─ 분 (0-59)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size23&quot;&gt;3.1 실전 설정 예시&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;19&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,0,0&quot;&gt;매분 실행:&lt;/b&gt; * * * * * /path/to/script.sh&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,1,0&quot;&gt;매일 새벽 3시에 실행:&lt;/b&gt; 0 3 * * * /path/to/backup.sh&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,2,0&quot;&gt;매주 월요일 오전 9시에 실행:&lt;/b&gt; 0 9 * * 1 /path/to/report.sh&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,3,0&quot;&gt;10분마다 실행:&lt;/b&gt; */10 * * * * /path/to/check.sh&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,4,0&quot;&gt;평일(월~금) 오후 6시에 실행:&lt;/b&gt; 0 18 * * 1-5 /path/to/work.sh&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;20&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;4. 크론탭 작성 시 주의사항 (트러블슈팅)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;초보자들이 크론탭을 설정하고 가장 당황하는 순간은 &quot;명령어가 제대로 실행되지 않을 때&quot;입니다. 이를 방지하기 위한 팁입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size23&quot;&gt;4.1 절대 경로 사용&lt;/h3&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;크론은 일반적인 터미널 환경과 환경 변수(PATH)가 다릅니다. 따라서 python이나 node 같은 명령어를 쓸 때도 반드시 &lt;b data-index-in-node=&quot;71&quot; data-path-to-node=&quot;24&quot;&gt;절대 경로&lt;/b&gt;를 사용해야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;25&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,0,0&quot;&gt;잘못된 예:&lt;/b&gt; python3 script.py&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,1,0&quot;&gt;올바른 예:&lt;/b&gt; /usr/bin/python3 /home/user/script.py&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;4.2 실행 로그 남기기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;크론은 백그라운드에서 돌아가기 때문에 성공 여부를 알기 어렵습니다. 리다이렉션을 사용해 로그를 남기는 습관을 들이세요.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQtQU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;0 3 * * * /home/user/backup.sh &amp;gt;&amp;gt; /home/user/logs/cron.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size23&quot;&gt;4.3 권한 확인&lt;/h3&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;실행하려는 스크립트 파일(.sh 등)에 실행 권한(chmod +x)이 있는지 확인해야 합니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;31&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;32&quot; data-ke-size=&quot;size26&quot;&gt;5. 실전 활용 사례: DB 자동 백업 및 로그 정리&lt;/h2&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;서버 관리자가 가장 흔히 사용하는 자동화 조합입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQtgU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Plaintext&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 매일 밤 12시에 DB 백업 스크립트 실행
0 0 * * * /home/user/scripts/db_backup.sh

# 매주 일요일 새벽 4시에 7일 지난 임시 파일 삭제
0 4 * * 0 find /tmp -mtime +7 -delete
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;35&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;36&quot; data-ke-size=&quot;size26&quot;&gt;6. 결론: 자동화로 서버 관리의 자유를 찾으세요&lt;/h2&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;크론탭은 한 번 제대로 설정해 두면 서버가 돌아가는 동안 묵묵히 제 역할을 수행하는 든든한 조수입니다. 반복 업무를 자동화함으로써 여러분은 더 중요한 개발 업무에 집중할 수 있는 시간을 벌게 됩니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>Automation</category>
      <category>cron</category>
      <category>crontab</category>
      <category>DevOps</category>
      <category>linux</category>
      <category>ServerAdmin</category>
      <category>리눅스</category>
      <category>백업스케줄링</category>
      <category>서버자동화</category>
      <category>크론탭</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/12</guid>
      <comments>https://minari98.tistory.com/entry/Cron-%EC%84%9C%EB%B2%84-%EC%9E%90%EB%8F%99%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-%ED%81%AC%EB%A1%A0%ED%83%ADCrontab-%EC%82%AC%EC%9A%A9%EB%B2%95%EA%B3%BC-%EC%A3%BC%EA%B8%B0-%EC%84%A4%EC%A0%95-%ED%8C%81#entry12comment</comments>
      <pubDate>Thu, 9 Apr 2026 18:40:07 +0900</pubDate>
    </item>
    <item>
      <title>[Database] 외부 접속 차단 및 안전한 원격 DB 연결을 위한 SSH 터널링 기법</title>
      <link>https://minari98.tistory.com/entry/Database-%EC%99%B8%EB%B6%80-%EC%A0%91%EC%86%8D-%EC%B0%A8%EB%8B%A8-%EB%B0%8F-%EC%95%88%EC%A0%84%ED%95%9C-%EC%9B%90%EA%B2%A9-DB-%EC%97%B0%EA%B2%B0%EC%9D%84-%EC%9C%84%ED%95%9C-SSH-%ED%84%B0%EB%84%90%EB%A7%81-%EA%B8%B0%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스(DB)에는 서비스의 가장 소중한 자산인 사용자 정보와 비즈니스 데이터가 담겨 있습니다. 하지만 로컬 개발 환경에서 서버의 DB에 접속하기 위해 3306(MySQL)이나 5432(PostgreSQL) 포트를 전 세계에 개방하는 것은 매우 위험한 행동입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 DB 포트를 외부에 노출하지 않고도, 마치 내 컴퓨터에 있는 DB처럼 안전하게 접속할 수 있게 해주는 &lt;b data-index-in-node=&quot;61&quot; data-path-to-node=&quot;5&quot;&gt;SSH 터널링(SSH Tunneling)&lt;/b&gt; 기법을 상세히 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 왜 DB 포트를 직접 열면 안 되는가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;대부분의 DB 엔진은 보안 취약점이 발견될 수 있으며, 기본 포트가 널리 알려져 있어 무차별 대입 공격(Brute-force)의 주 타겟이 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;보안 위험:&lt;/b&gt; 포트가 열려 있다는 것만으로도 해커는 공격의 실마리를 찾습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;데이터 유출:&lt;/b&gt; 암호화되지 않은 연결을 사용할 경우 중간에서 데이터가 탈취될 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;따라서 가장 권장되는 방식은 &lt;b data-index-in-node=&quot;16&quot; data-path-to-node=&quot;10&quot;&gt;방화벽에서 DB 포트를 완전히 차단&lt;/b&gt;하고, 인증된 사용자만 &lt;b data-index-in-node=&quot;48&quot; data-path-to-node=&quot;10&quot;&gt;SSH라는 보안 통로&lt;/b&gt;를 통해 우회 접속하는 것입니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;11&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size26&quot;&gt;2. SSH 터널링(포트 포워딩)의 원리&lt;/h2&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;SSH 터널링은 내 PC(Local)와 서버(Remote) 사이에 암호화된 '터널'을 뚫는 기술입니다. 내 PC의 특정 포트로 보내는 신호를 SSH 터널을 통해 서버 내부의 DB 포트로 전달하는 방식입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;사용자 입장에서는 localhost:3307로 접속하지만, 실제로는 서버의 3306 포트에 안전하게 연결되는 마법 같은 일이 일어납니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;15&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size26&quot;&gt;3. 터미널 명령어로 SSH 터널 뚫기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;별도의 도구 없이 터미널(Terminal)이나 CMD에서 아래 명령어로 터널을 생성할 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQoQU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 기본 명령어 구조
ssh -L [로컬포트]:localhost:[서버DB포트] [서버계정]@[서버IP]

# 예시: 내 PC의 3307 포트를 서버의 3306(MySQL) 포트와 연결
ssh -L 3307:localhost:3306 yumina@1.23.456.78
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;19&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,0,0&quot;&gt;-L:&lt;/b&gt; 로컬 포트 포워딩을 의미합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,1,0&quot;&gt;3307:&lt;/b&gt; 내 PC에서 사용할 임시 포트입니다. (기존 DB와 충돌을 피하기 위해 보통 다른 번호를 씁니다.)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,2,0&quot;&gt;localhost:3306:&lt;/b&gt; 서버 입장에서 본 DB의 주소와 포트입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;이 명령어를 실행한 채로 터미널을 열어두면 터널이 유지됩니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;21&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;4. GUI 도구(DBeaver, MySQL Workbench) 설정법&lt;/h2&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;명령어가 익숙하지 않다면, 자주 사용하는 DB 관리 도구에서도 클릭 몇 번으로 설정이 가능합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size23&quot;&gt;4.1 DBeaver 기준 설정 단계&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;25&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,0,0&quot;&gt;Connection Settings&lt;/b&gt;에서 상단 탭의 &lt;b data-index-in-node=&quot;28&quot; data-path-to-node=&quot;25,0,0&quot;&gt;SSH&lt;/b&gt;를 선택합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,1,0&quot;&gt;Use SSH Tunnel&lt;/b&gt; 체크박스를 활성화합니다.&lt;/li&gt;
&lt;li&gt;서버의 IP, 사용자 이름, 인증 방식(비밀번호 또는 SSH Key)을 입력합니다.&lt;/li&gt;
&lt;li&gt;다시 &lt;b data-index-in-node=&quot;3&quot; data-path-to-node=&quot;25,3,0&quot;&gt;Main&lt;/b&gt; 탭으로 돌아와서 Host는 localhost, Port는 서버 내부의 포트(3306 등)를 입력합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 설정하면 연결 버튼을 누를 때마다 DBeaver가 자동으로 터널을 생성하고 접속해 줍니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;27&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size26&quot;&gt;5. 실무 적용 팁: 보안 강화하기&lt;/h2&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;SSH 터널링을 사용할 때 방화벽(UFW) 설정을 함께 챙겨야 보안이 완성됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;30&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;30,0,0&quot;&gt;DB 포트 차단:&lt;/b&gt; sudo ufw deny 3306 명령어로 외부에서의 직접 접속을 원천 차단하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;30,1,0&quot;&gt;SSH 전용 계정:&lt;/b&gt; DB 접속 전용 SSH 계정을 만들고 권한을 최소화하면 더욱 안전합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;30,2,0&quot;&gt;SSH 키 인증:&lt;/b&gt; 비밀번호 대신 SSH 키(Key) 방식을 사용하면 터널링의 보안 수준이 비약적으로 상승합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;31&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;32&quot; data-ke-size=&quot;size26&quot;&gt;6. 결론: 보안과 편리함은 공존할 수 있습니다&lt;/h2&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;SSH 터널링은 인프라 보안의 정석과도 같은 기술입니다. 처음에는 조금 번거롭게 느껴질 수 있지만, 소중한 데이터를 보호하기 위해 반드시 익혀야 할 필수 스킬입니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>DATABASE</category>
      <category>dbeaver</category>
      <category>DB보안</category>
      <category>mysql</category>
      <category>portforwarding</category>
      <category>PostgreSQL</category>
      <category>ServerSecurity</category>
      <category>ssh</category>
      <category>sshtunneling</category>
      <category>SSH터널링</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/11</guid>
      <comments>https://minari98.tistory.com/entry/Database-%EC%99%B8%EB%B6%80-%EC%A0%91%EC%86%8D-%EC%B0%A8%EB%8B%A8-%EB%B0%8F-%EC%95%88%EC%A0%84%ED%95%9C-%EC%9B%90%EA%B2%A9-DB-%EC%97%B0%EA%B2%B0%EC%9D%84-%EC%9C%84%ED%95%9C-SSH-%ED%84%B0%EB%84%90%EB%A7%81-%EA%B8%B0%EB%B2%95#entry11comment</comments>
      <pubDate>Wed, 8 Apr 2026 18:38:51 +0900</pubDate>
    </item>
    <item>
      <title>[Firewall] 리눅스 서버 필수 방화벽 UFW 설정 및 특정 IP 허용 방법</title>
      <link>https://minari98.tistory.com/entry/Firewall-%EB%A6%AC%EB%88%85%EC%8A%A4-%EC%84%9C%EB%B2%84-%ED%95%84%EC%88%98-%EB%B0%A9%ED%99%94%EB%B2%BD-UFW-%EC%84%A4%EC%A0%95-%EB%B0%8F-%ED%8A%B9%EC%A0%95-IP-%ED%97%88%EC%9A%A9-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;인터넷에 연결된 모든 서버는 24시간 내내 수많은 침입 시도에 노출되어 있습니다. 불필요한 포트를 열어두는 것은 마치 대문을 열어두고 외출하는 것과 같습니다. 리눅스 서버 보안의 첫걸음은 바로 **방화벽(Firewall)**을 통해 꼭 필요한 트래픽만 허용하고 나머지는 모두 차단하는 것입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 우분투(Ubuntu)에서 가장 쉽고 직관적으로 사용할 수 있는 방화벽 도구인 **UFW(Uncomplicated Firewall)**의 설정법을 완벽하게 정리해 보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. UFW란 무엇인가?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;UFW는 이름 그대로 '복잡하지 않은 방화벽'입니다. 리눅스 커널의 패킷 필터링 기능을 담당하는 iptables를 사용자가 더 쉽게 다룰 수 있도록 만든 인터페이스입니다. 복잡한 명령어 대신 단순한 규칙(Allow/Deny)만으로 서버를 보호할 수 있다는 장점이 있습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;9&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size26&quot;&gt;2. UFW 시작하기: 기본 설정&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size23&quot;&gt;2.1 UFW 상태 확인&lt;/h3&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;현재 방화벽이 활성화되어 있는지 확인합니다. 처음에는 보통 inactive 상태입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQhwU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;sudo ufw status
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;2.2 기본 정책 설정 (Default Policy)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;가장 안전한 방화벽 전략은 **&quot;들어오는 것은 모두 막고, 나가는 것은 허용하는 것&quot;**입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQiAU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 모든 들어오는 접속 차단
sudo ufw default deny incoming

# 모든 나가는 접속 허용
sudo ufw default allow outgoing
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;17&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size26&quot;&gt;3. 필수 서비스 허용하기 (주의사항)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19&quot;&gt;중요:&lt;/b&gt; 방화벽을 활성화하기 전에 반드시 **SSH(22번 포트)**를 허용해야 합니다. 그렇지 않으면 본인도 서버에 접속하지 못하는 '락아웃(Lock-out)' 현상이 발생합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size23&quot;&gt;3.1 SSH 및 웹 서비스 허용&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQiQU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# SSH 허용 (기본 22번 포트)
sudo ufw allow ssh

# HTTP(80) 및 HTTPS(443) 허용
sudo ufw allow http
sudo ufw allow https
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size23&quot;&gt;3.2 특정 포트 범위 허용&lt;/h3&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;특정 범위의 포트를 한꺼번에 열어야 할 때 사용합니다. (예: 8000~8100번)&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQigU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;sudo ufw allow 8000:8100/tcp
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;25&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;4. 특정 IP 주소 관리하기 (화이트리스트)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;보안을 더욱 강화하려면, 관리자 페이지나 DB 접속 포트 등은 &lt;b data-index-in-node=&quot;35&quot; data-path-to-node=&quot;27&quot;&gt;특정 IP&lt;/b&gt;에서만 접속할 수 있도록 제한하는 것이 좋습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size23&quot;&gt;4.1 특정 IP 허용&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQiwU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 특정 IP(예: 123.123.123.123)에서의 모든 접속 허용
sudo ufw allow from 123.123.123.123

# 특정 IP가 특정 포트(예: 3306 DB 포트)에만 접속하게 허용
sudo ufw allow from 123.123.123.123 to any port 3306
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size23&quot;&gt;4.2 특정 IP 차단 (Blacklist)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;공격이 의심되는 특정 IP를 즉시 차단할 때 유용합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQjAU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;sudo ufw deny from 123.123.123.123
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;33&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;34&quot; data-ke-size=&quot;size26&quot;&gt;5. 방화벽 활성화 및 규칙 삭제&lt;/h2&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;모든 규칙을 설정했다면 이제 방화벽을 가동합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;36&quot; data-ke-size=&quot;size23&quot;&gt;5.1 활성화 및 비활성화&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQjQU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 방화벽 활성화 (SSH 허용 여부 재차 확인!)
sudo ufw enable

# 방화벽 비활성화
sudo ufw disable
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;38&quot; data-ke-size=&quot;size23&quot;&gt;5.2 규칙 삭제&lt;/h3&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;설정된 규칙을 삭제하고 싶을 때는 번호를 확인한 뒤 삭제하는 것이 가장 정확합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQjgU&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# 규칙 번호 확인
sudo ufw status numbered

# 2번 규칙 삭제
sudo ufw delete 2
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;41&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;42&quot; data-ke-size=&quot;size26&quot;&gt;6. 결론: 보안은 겹겹이 쌓는 층(Layer)입니다&lt;/h2&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;UFW는 서버를 지키는 든든한 성벽입니다. 하지만 방화벽 하나만으로 모든 공격을 막을 수는 없습니다. 앞서 다뤘던 &lt;b data-index-in-node=&quot;64&quot; data-path-to-node=&quot;43&quot;&gt;SSH 포트 변경&lt;/b&gt;, &lt;b data-index-in-node=&quot;75&quot; data-path-to-node=&quot;43&quot;&gt;Root 로그인 차단&lt;/b&gt; 등과 함께 적용할 때 비로소 강력한 보안 체계가 완성됩니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>Firewall</category>
      <category>LinuxAdmin</category>
      <category>NetworkSecurity</category>
      <category>ServerSecurity</category>
      <category>ubuntu</category>
      <category>ufw</category>
      <category>리눅스기초</category>
      <category>서버보안</category>
      <category>우분투방화벽</category>
      <category>포트허용</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/10</guid>
      <comments>https://minari98.tistory.com/entry/Firewall-%EB%A6%AC%EB%88%85%EC%8A%A4-%EC%84%9C%EB%B2%84-%ED%95%84%EC%88%98-%EB%B0%A9%ED%99%94%EB%B2%BD-UFW-%EC%84%A4%EC%A0%95-%EB%B0%8F-%ED%8A%B9%EC%A0%95-IP-%ED%97%88%EC%9A%A9-%EB%B0%A9%EB%B2%95#entry10comment</comments>
      <pubDate>Tue, 7 Apr 2026 17:37:30 +0900</pubDate>
    </item>
    <item>
      <title>[Logging] 서버 에러 로그 확인 및 효율적인 로그 로테이션(logrotate) 관리</title>
      <link>https://minari98.tistory.com/entry/Logging-%EC%84%9C%EB%B2%84-%EC%97%90%EB%9F%AC-%EB%A1%9C%EA%B7%B8-%ED%99%95%EC%9D%B8-%EB%B0%8F-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9D%B8-%EB%A1%9C%EA%B7%B8-%EB%A1%9C%ED%85%8C%EC%9D%B4%EC%85%98logrotate-%EA%B4%80%EB%A6%AC</link>
      <description>&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;서버를 운영하다 보면 원인 모를 에러로 서비스가 중단되는 상황을 맞이하게 됩니다. 이때 당황하지 않고 가장 먼저 확인해야 할 것이 바로 **로그(Log)**입니다. 하지만 로그를 단순히 쌓아두기만 하면, 어느덧 수십 GB로 커진 로그 파일이 디스크 용량을 점유하여 오히려 서버를 멈추게 하는 원인이 되기도 합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 리눅스 서버에서 에러 로그를 스마트하게 확인하는 방법과, 로그 파일을 효율적으로 관리해 주는 &lt;b data-index-in-node=&quot;56&quot; data-path-to-node=&quot;5&quot;&gt;logrotate&lt;/b&gt; 설정법을 상세히 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 실시간으로 에러 로그 추적하기: tail 명령어&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;문제가 발생한 순간, 실시간으로 어떤 메시지가 찍히는지 확인하는 것이 급선무입니다. 이때 가장 유용한 명령어가 tail -f입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;1.1 Nginx 에러 로그 확인 예시&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ5wQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;sudo tail -f /var/log/nginx/error.log
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;11&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,0,0&quot;&gt;-f (follow):&lt;/b&gt; 파일의 끝부분을 실시간으로 계속 보여줍니다. 브라우저에서 새로고침을 할 때마다 찍히는 에러를 즉각 확인할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;1.2 특정 키워드로 필터링하기 (grep)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;로그 양이 너무 많을 때는 grep을 조합하여 필요한 정보만 골라냅니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ6AQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;sudo tail -f /var/log/nginx/error.log | grep &quot;Critical&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;15&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size26&quot;&gt;2. 로그 관리의 핵심: logrotate란?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;로그는 매일 생성되지만, 모든 과거 로그를 하나의 파일에 보관하는 것은 비효율적입니다. &lt;b data-index-in-node=&quot;49&quot; data-path-to-node=&quot;17&quot;&gt;logrotate&lt;/b&gt;는 리눅스 시스템 도구로, 로그 파일을 주기적으로 분할(Rotation), 압축(Compression), 삭제(Removal)하여 디스크 공간을 확보해 줍니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size23&quot;&gt;2.1 logrotate 작동 원리&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;19&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,0,0&quot;&gt;로그 분할:&lt;/b&gt; error.log를 error.log.1로 이름을 바꾸고 새 파일을 만듭니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,1,0&quot;&gt;압축:&lt;/b&gt; error.log.1을 error.log.2.gz 형태로 압축하여 용량을 줄입니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,2,0&quot;&gt;순환:&lt;/b&gt; 설정된 개수(예: 7개)가 넘어가면 가장 오래된 로그를 자동으로 삭제합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;20&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;3. logrotate 실전 설정 가이드&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;대부분의 주요 서비스(Nginx, MySQL 등)는 설치 시 logrotate 설정이 자동으로 생성됩니다. 하지만 본인이 만든 애플리케이션 로그를 관리하려면 직접 설정 파일을 만들어야 합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size23&quot;&gt;3.1 설정 파일 생성&lt;/h3&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;/etc/logrotate.d/ 디렉토리에 서비스 이름으로 파일을 생성합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ6QQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;sudo nano /etc/logrotate.d/my-app
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;3.2 설정 내용 예시&lt;/h3&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ6gQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Plaintext&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;/var/www/my-app/logs/*.log {
    daily                   # 매일 실행
    missingok               # 로그 파일이 없어도 에러 내지 않음
    rotate 14               # 최대 14일치 로그 보관
    compress                # 이전 로그 압축(.gz)
    delaycompress           # 현재 분할된 파일은 다음 주기에 압축
    notifempty              # 로그가 비어있으면 로테이트 하지 않음
    create 0640 www-data adm # 새 로그 파일 권한 설정
    sharedscripts
    postrotate
        # 로그 분할 후 서비스 재시작 명령 (필요 시)
        /usr/bin/systemctl reload nginx &amp;gt; /dev/null 2&amp;gt;&amp;amp;1
    endscript
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;28&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size26&quot;&gt;4. 설정 테스트 및 강제 실행&lt;/h2&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;설정이 올바른지 확인하고 싶다면 아래 명령어로 테스트해 볼 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQ6wQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;# 설정 파일 문법 체크 (드라이 런)
sudo logrotate -d /etc/logrotate.d/my-app

# 조건에 상관없이 즉시 로테이트 실행
sudo logrotate -f /etc/logrotate.d/my-app
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;32&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론: 로그는 읽기 위해 존재한다&lt;/h2&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;로그 관리는 단순히 디스크 용량을 아끼는 기술이 아닙니다. 깨끗하게 정리된 로그는 장애 발생 시 **평균 복구 시간(MTTR)**을 획기적으로 줄여줍니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>linux</category>
      <category>Logging</category>
      <category>logrotate</category>
      <category>ServerMaintenance</category>
      <category>TailCommand</category>
      <category>Troubleshooting</category>
      <category>로그관리</category>
      <category>리눅스</category>
      <category>서버운영</category>
      <category>에러로그</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/9</guid>
      <comments>https://minari98.tistory.com/entry/Logging-%EC%84%9C%EB%B2%84-%EC%97%90%EB%9F%AC-%EB%A1%9C%EA%B7%B8-%ED%99%95%EC%9D%B8-%EB%B0%8F-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9D%B8-%EB%A1%9C%EA%B7%B8-%EB%A1%9C%ED%85%8C%EC%9D%B4%EC%85%98logrotate-%EA%B4%80%EB%A6%AC#entry9comment</comments>
      <pubDate>Tue, 7 Apr 2026 17:36:11 +0900</pubDate>
    </item>
    <item>
      <title>[Nginx] 대규모 트래픽 처리를 위한 로드 밸런싱(Load Balancing) 핵심 설정</title>
      <link>https://minari98.tistory.com/entry/Nginx-%EB%8C%80%EA%B7%9C%EB%AA%A8-%ED%8A%B8%EB%9E%98%ED%94%BD-%EC%B2%98%EB%A6%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-%EB%A1%9C%EB%93%9C-%EB%B0%B8%EB%9F%B0%EC%8B%B1Load-Balancing-%ED%95%B5%EC%8B%AC-%EC%84%A4%EC%A0%95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹 서비스가 성장하여 방문자가 급증하면, 아무리 고성능인 단일 서버라도 물리적인 한계에 부딪히게 됩니다. 서버가 느려지거나 다운되는 것을 방지하기 위해 가장 먼저 고려해야 할 기술이 바로 **로드 밸런싱(Load Balancing)**입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 오픈소스 웹 서버인 &lt;b data-index-in-node=&quot;15&quot; data-path-to-node=&quot;5&quot;&gt;Nginx&lt;/b&gt;를 활용하여 여러 대의 서버로 부하를 분산하고, 서비스의 가용성을 극대화하는 방법을 상세히 알아보겠습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;1. 로드 밸런싱(Load Balancing)이란?&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;로드 밸런싱은 말 그대로 '부하(Load)'를 여러 대의 서버에 '균형 있게(Balancing)' 나누는 기술입니다. 사용자의 요청이 집중될 때, 앞단에 위치한 로드 밸런서가 뒤에 대기 중인 여러 대의 애플리케이션 서버로 요청을 배분합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;1.1 로드 밸런싱의 주요 목적&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;10&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0,0&quot;&gt;고가용성(High Availability):&lt;/b&gt; 특정 서버에 장애가 발생해도 다른 서버가 요청을 처리하여 중단 없는 서비스를 제공합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,1,0&quot;&gt;확장성(Scalability):&lt;/b&gt; 트래픽 증가 시 서버 대수만 늘리면 되므로 유연한 대응이 가능합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,2,0&quot;&gt;성능 최적화:&lt;/b&gt; 서버 한 대에 가해지는 부담을 줄여 응답 속도를 향상시킵니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;11&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size26&quot;&gt;2. Nginx 로드 밸런싱 핵심 설정법&lt;/h2&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;Nginx에서는 upstream 블록을 사용하여 로드 밸런싱 그룹을 정의합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;2.1 기본 설정 예시 (Round Robin)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;가장 기본적인 방식으로, 서버들에 순차적으로 요청을 배분합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQywQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Nginx&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;http {
    # 백엔드 서버 그룹 정의
    upstream my_backend_servers {
        server 10.0.0.1:3000;
        server 10.0.0.2:3000;
        server 10.0.0.3:3000;
    }

    server {
        listen 80;
        server_name your-domain.com;

        location / {
            # 위에서 정의한 upstream 그룹으로 요청 전달
            proxy_pass http://my_backend_servers;
            
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size23&quot;&gt;2.2 주요 부하 분산 알고리즘&lt;/h3&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;서비스의 성격에 따라 적절한 알고리즘을 선택할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;19&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,0,0&quot;&gt;Round Robin (기본값):&lt;/b&gt; 순서대로 균등 배분.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,1,0&quot;&gt;Least Connections (least_conn):&lt;/b&gt; 현재 연결 수가 가장 적은 서버로 요청을 보냅니다. 작업 시간이 긴 요청이 많을 때 유리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,2,0&quot;&gt;IP Hash (ip_hash):&lt;/b&gt; 사용자의 IP를 해싱하여 항상 동일한 서버로 연결합니다. 세션 유지가 필요한 서비스에 적합합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,3,0&quot;&gt;Weight (가중치):&lt;/b&gt; 성능이 더 좋은 서버에 더 많은 요청을 보내도록 설정합니다. (server 10.0.0.1:3000 weight=3;)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;20&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;3. 장애 감지와 복구 (Health Check)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;Nginx 로드 밸런서의 강력한 기능 중 하나는 백엔드 서버의 상태를 체크하는 것입니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiX2JeGscmTAxUAAAAAHQAAAAAQzAQ&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Nginx&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;upstream my_backend_servers {
    # 3번 실패하면 30초 동안 해당 서버를 제외함
    server 10.0.0.1:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.2:3000 max_fails=3 fail_timeout=30s;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;이 설정을 통해 특정 서버가 다운되었을 때 사용자가 에러 페이지를 보는 대신, 정상 작동하는 다른 서버의 응답을 받을 수 있도록 자동 제어가 가능합니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;25&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;4. 실무 도입 시 고려사항: 세션 동기화&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;로드 밸런싱을 도입할 때 가장 주의해야 할 점은 &lt;b data-index-in-node=&quot;27&quot; data-path-to-node=&quot;27&quot;&gt;세션(Session)&lt;/b&gt; 관리입니다. 사용자가 A 서버에서 로그인했는데, 다음 클릭 시 B 서버로 연결되면 로그인이 풀리는 현상이 발생합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 다음과 같은 방법을 사용합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;29&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,0,0&quot;&gt;Sticky Session:&lt;/b&gt; ip_hash를 사용하여 사용자를 특정 서버에 고정시킵니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,1,0&quot;&gt;External Session Store:&lt;/b&gt; 세션 정보를 서버 메모리가 아닌 외부 저장소(예: &lt;b data-index-in-node=&quot;52&quot; data-path-to-node=&quot;29,1,0&quot;&gt;Redis&lt;/b&gt;)에 저장하여 모든 서버가 공유하게 합니다. (권장 방식)&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;30&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;31&quot; data-ke-size=&quot;size26&quot;&gt;5. 결론: 더 큰 서비스를 향한 도약&lt;/h2&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;Nginx 로드 밸런싱 설정은 단순한 서버 운영을 넘어 '시스템 아키텍처'를 설계하는 첫걸음입니다. 초기 비용 없이 소프트웨어 설정만으로 대규모 트래픽에 대응할 수 있다는 점은 큰 매력입니다.&lt;/p&gt;</description>
      <category>개발일기</category>
      <category>DevOps</category>
      <category>HighAvailability</category>
      <category>loadbalancing</category>
      <category>nginx</category>
      <category>scalability</category>
      <category>WebArchitecture</category>
      <category>로드밸런싱</category>
      <category>백엔드개발</category>
      <category>서버분산</category>
      <category>엔진엑스</category>
      <author>돌미나리는야생미나리</author>
      <guid isPermaLink="true">https://minari98.tistory.com/8</guid>
      <comments>https://minari98.tistory.com/entry/Nginx-%EB%8C%80%EA%B7%9C%EB%AA%A8-%ED%8A%B8%EB%9E%98%ED%94%BD-%EC%B2%98%EB%A6%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-%EB%A1%9C%EB%93%9C-%EB%B0%B8%EB%9F%B0%EC%8B%B1Load-Balancing-%ED%95%B5%EC%8B%AC-%EC%84%A4%EC%A0%95#entry8comment</comments>
      <pubDate>Tue, 7 Apr 2026 17:35:02 +0900</pubDate>
    </item>
  </channel>
</rss>