<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>WhiteMouseDev</title>
    <link>https://white-mouse-dev.tistory.com/</link>
    <description>안녕하세요, 김건우입니다! 웹과 앱 개발에 열정적인 전문가로, React, TypeScript, Next.js, Node.js, Express, Flutter 등을 활용한 프로젝트를 다룹니다. 제 블로그에서는 개발 여정, 기술 분석, 실용적 코딩 팁을 공유합니다. 창의적인 솔루션을 실제로 적용하는 과정의 통찰도 나눌 예정이니, 궁금한 점이나 상담은 언제든 환영합니다.</description>
    <language>ko</language>
    <pubDate>Sun, 28 Jun 2026 03:18:36 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Kun Woo Kim</managingEditor>
    <image>
      <title>WhiteMouseDev</title>
      <url>https://tistory1.daumcdn.net/tistory/8017640/attach/09f9f24d3ba04547a23a867d9c2ddd5c</url>
      <link>https://white-mouse-dev.tistory.com</link>
    </image>
    <item>
      <title>React Compiler를 켰는데 왜 아직 useCallback과 memo를 쓰고 있을까?</title>
      <link>https://white-mouse-dev.tistory.com/entry/React-Compiler%EB%A5%BC-%EC%BC%B0%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%95%84%EC%A7%81-useCallback%EA%B3%BC-memo%EB%A5%BC-%EC%93%B0%EA%B3%A0-%EC%9E%88%EC%9D%84%EA%B9%8C</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/71954fa6-8195-4cba-89d5-0a965b17fe73/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&amp;quot;React Compiler 켜면 useCallback이랑 memo 다 필요 없는 거 아니야?&amp;quot;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;최근 프로젝트 코드 리뷰에서 이런 질문을 받았다. 충분히 나올 수 있는 질문이다. 2025년 10월 React Conf 2025에서 React Compiler 1.0이 정식 릴리스되면서, 빌드 타임에 컴포넌트와 훅을 자동으로 메모이제이션해주는 시대가 열렸기 때문이다. &lt;code&gt;useMemo&lt;/code&gt;, &lt;code&gt;useCallback&lt;/code&gt;, &lt;code&gt;React.memo&lt;/code&gt;를 손으로 붙이던 노동에서 어느 정도 해방된다는 것이 이 도구의 핵심 약속이다.&lt;/p&gt;
&lt;p&gt;그런데 내 프로젝트에는 컴파일러가 켜져 있는데도 &lt;code&gt;useCallback&lt;/code&gt;과 &lt;code&gt;memo&lt;/code&gt;가 꽤 남아 있었다. 모순처럼 보이지만, 사실 여기엔 &amp;quot;성능 최적화&amp;quot;라는 단어 하나로 뭉뚱그릴 수 없는 이야기가 있다.&lt;/p&gt;
&lt;p&gt;이 글은 React Compiler를 켠 코드베이스에서 &lt;strong&gt;어떤 수동 메모이제이션은 제거하고, 어떤 것은 남길 수 있는지&lt;/strong&gt;에 대한 판단 기록이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;먼저, 컴파일러는 진짜 켜져 있다&lt;/h2&gt;
&lt;p&gt;오해를 막기 위해 상태부터 확인하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// next.config.ts
const nextConfig: NextConfig = {
  reactCompiler: true,
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// package.json
&amp;quot;babel-plugin-react-compiler&amp;quot;: &amp;quot;1.0.0&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;reactCompiler: true&lt;/code&gt;로 프로젝트 차원에서 React Compiler를 활성화했다. 다만 여기서 짚어둘 점이 있다. 이 설정이 있다고 해서 모든 파일과 모든 함수가 무조건 최적화되는 것은 아니다. 실제 최적화 대상은 Next.js와 Compiler의 추론 규칙에 따라 React 컴포넌트와 훅으로 판단되는 코드다. &amp;quot;프로젝트에서 컴파일러가 켜져 있다&amp;quot;와 &amp;quot;모든 코드가 컴파일러에 의해 최적화된다&amp;quot;는 같은 말이 아니다.&lt;/p&gt;
&lt;p&gt;그래도 이 프로젝트의 전제는 분명하다. &lt;strong&gt;컴파일러를 안 써서 어쩔 수 없이 수동 메모이제이션을 한 것이 아니라, 컴파일러를 켠 상태에서 의도적으로 일부 코드를 남긴 것이다.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;컴파일러와 수동 메모이제이션은 충돌하지 않는가&lt;/h2&gt;
&lt;p&gt;본론에 들어가기 전에 자주 받는 질문 하나를 짚고 가자.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&amp;quot;컴파일러가 자동으로 메모이제이션하는데 &lt;code&gt;useCallback&lt;/code&gt;도 같이 쓰면 이중으로 감싸지는 거 아닌가?&amp;quot;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;핵심은 &amp;quot;같은 대상을 단순히 두 번 감싸서 성능이 망가진다&amp;quot;가 아니다. React Compiler는 기존의 &lt;code&gt;useMemo&lt;/code&gt;, &lt;code&gt;useCallback&lt;/code&gt;, &lt;code&gt;React.memo&lt;/code&gt; 호출을 보존하려고 한다. 수동 메모이제이션이 있다면, 컴파일러는 그 코드에 이유가 있다고 보고 함부로 제거하지 않는다.&lt;/p&gt;
&lt;p&gt;다만 한 가지 주의할 점이 있다. 부정확한 dependency 배열은 컴파일러가 코드의 데이터 흐름을 이해하는 것을 방해할 수 있다. dependency가 빠진 &lt;code&gt;useCallback&lt;/code&gt;이나 &lt;code&gt;useMemo&lt;/code&gt;가 있으면, 컴파일러가 해당 컴포넌트나 훅에 추가 최적화를 적용하지 못할 수 있다.&lt;/p&gt;
&lt;p&gt;그래서 수동 메모이제이션을 남길 때 기준은 더 엄격해진다. &lt;strong&gt;남길 거라면 dependency가 정확해야 하고, 왜 남겼는지 설명할 수 있어야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;여기서부터가 본론이다. 왜 자동 메모이제이션이 돌아가는 환경에서 수동 메모이제이션을 남겼는가?&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;useCallback이 전부 &amp;quot;성능&amp;quot;을 위한 건 아니다&lt;/h2&gt;
&lt;p&gt;핵심 통찰부터 말하면 이렇다. 현재 프로젝트의 &lt;code&gt;useCallback&lt;/code&gt;에는 성격이 다른 세 종류가 섞여 있었다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;effect / event listener의 참조 안정성&lt;/li&gt;
&lt;li&gt;custom hook 사이의 콜백 계약&lt;/li&gt;
&lt;li&gt;비싼 렌더링 경계에 대한 명시적 방어&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이 중 컴파일러가 깔끔하게 대체해줄 수 있는 것은 일부다. 나머지는 단순히 렌더 횟수를 줄이기 위한 코드라기보다, &lt;strong&gt;effect 경계와 생명주기 의도를 드러내는 장치&lt;/strong&gt;에 가까웠다. 하나씩 보자.&lt;/p&gt;
&lt;h3&gt;1. effect와 event listener의 참조 안정성&lt;/h3&gt;
&lt;p&gt;채팅 워크스페이스에는 스크롤 상태를 추적하는 리스너가 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;useEffect(() =&amp;gt; {
  const element = scrollRef.current;
  if (!element) return;

  element.addEventListener(&amp;quot;scroll&amp;quot;, updateScrollState, { passive: true });
  return () =&amp;gt; element.removeEventListener(&amp;quot;scroll&amp;quot;, updateScrollState);
}, [updateScrollState]);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 &lt;code&gt;updateScrollState&lt;/code&gt;가 매 렌더마다 새 함수로 생성되면, effect의 dependency가 매번 바뀌면서 리스너를 떼고 다시 붙이는 일이 반복될 수 있다.&lt;/p&gt;
&lt;p&gt;이때 &lt;code&gt;useCallback&lt;/code&gt;을 붙이는 이유는 단순히 &amp;quot;자식 리렌더링을 줄이기 위해서&amp;quot;가 아니다. 이 함수는 effect의 dependency로 쓰이고, event listener의 setup/cleanup과 같은 identity를 공유해야 한다. 즉 이 함수의 참조 안정성은 렌더링 비용뿐 아니라 외부 시스템과의 동기화 방식에 영향을 준다.&lt;/p&gt;
&lt;p&gt;물론 여기에도 선택지는 있다. 어떤 함수가 effect 내부에서만 쓰인다면, 굳이 &lt;code&gt;useCallback&lt;/code&gt;을 쓰기보다 함수를 effect 안으로 옮겨 dependency 자체를 줄이는 편이 더 단순할 수 있다. 하지만 이 프로젝트의 경우 &lt;code&gt;updateScrollState&lt;/code&gt;는 effect 경계 밖에서도 의미가 있고, 스크롤 상태 관리 로직의 일부로 명시적으로 분리되어 있었다. 그래서 이 경우에는 &lt;code&gt;useCallback&lt;/code&gt;을 남겨두는 쪽이 더 읽기 쉬웠다.&lt;/p&gt;
&lt;p&gt;이 함수는 단순한 렌더 최적화용 콜백이 아니다. &lt;strong&gt;DOM event listener의 생명주기와 연결된 콜백이다.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;2. custom hook 사이의 콜백 계약&lt;/h3&gt;
&lt;p&gt;스트리밍 로직은 책임별로 훅을 나눠 두었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ChatApp
  └─ useChatStream
       └─ useChatSessionMessages&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;useChatStream&lt;/code&gt;은 아래 콜백들을 인자로 받는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;useChatStream({
  appendAssistantChunk,
  completeAssistantMessage,
  failAssistantMessage,
  startOptimisticTurn,
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 함수들은 스트림 reader, 에러 복구, cleanup 로직과 맞물려 동작한다. 예를 들어 &lt;code&gt;appendAssistantChunk&lt;/code&gt;는 스트림 reader가 살아 있는 동안 반복적으로 호출되는 콜백이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// useChatStream 내부 (개념도)
const reader = response.body.getReader();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value);
  appendAssistantChunk(chunk);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 주의할 점이 있다. &lt;code&gt;appendAssistantChunk&lt;/code&gt;의 참조가 바뀐다고 해서 이미 실행 중인 async loop의 클로저가 자동으로 새 함수로 갈아끼워지는 것은 아니다. JavaScript는 그렇게 동작하지 않는다.&lt;/p&gt;
&lt;p&gt;실제 문제는 다른 곳에 있다. 이 콜백이 effect dependency에 포함되어 있다면, 콜백 참조 변경이 cleanup, abort, stream restart를 유발할 수 있다. 반대로 dependency에서 빼버리면 오래된 콜백을 캡처하는 stale closure 문제가 생길 수 있다.&lt;/p&gt;
&lt;p&gt;즉 스트리밍 훅에서 중요한 것은 &amp;quot;함수 하나를 메모이제이션했는가&amp;quot;가 아니라, &lt;strong&gt;스트림 생명주기 동안 콜백 참조를 어떤 전략으로 다룰 것인가&lt;/strong&gt;다. 호출자 쪽에서 &lt;code&gt;useCallback&lt;/code&gt;으로 안정화할 수도 있고, 훅 내부에서 ref를 사용해 최신 콜백을 읽도록 설계할 수도 있다. 중요한 것은 호출자와 훅 구현자가 이 전략을 공유해야 한다는 점이다.&lt;/p&gt;
&lt;p&gt;이 프로젝트에서는 호출자 쪽 콜백을 &lt;code&gt;useCallback&lt;/code&gt;으로 안정화하는 방식을 택했다. &lt;code&gt;useCallback&lt;/code&gt;은 그 계약을 런타임에서 강제하는 도구는 아니다. 하지만 코드를 읽는 사람에게 &amp;quot;이 콜백들은 단순 이벤트 핸들러가 아니라 생명주기 경계에 있는 함수&amp;quot;라는 의도를 드러낸다.&lt;/p&gt;
&lt;p&gt;이런 경우의 &lt;code&gt;useCallback&lt;/code&gt;은 단순 성능 최적화라기보다, &lt;strong&gt;custom hook API의 사용 의도를 표현하는 장치&lt;/strong&gt;에 가깝다.&lt;/p&gt;
&lt;h3&gt;3. 비싼 렌더링 경계에 대한 명시적 방어&lt;/h3&gt;
&lt;p&gt;메시지 렌더링 컴포넌트에는 &lt;code&gt;memo&lt;/code&gt;를 붙였다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export const MessageBubble = memo(MessageBubbleBase);
export const MarkdownMessage = memo(MarkdownMessageBase);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특히 마크다운 렌더링은 일반 텍스트보다 파싱과 변환 비용이 클 수 있다. 메시지 리스트처럼 같은 화면에 여러 개가 쌓이는 UI에서는 부모 상태 변화가 전체 메시지 렌더링으로 번지는 것을 조심해야 한다.&lt;/p&gt;
&lt;p&gt;다만 여기서는 표현을 정확히 해야 한다. &lt;code&gt;memo&lt;/code&gt;는 &amp;quot;props가 같으면 절대 다시 렌더링하지 않는다&amp;quot;는 보장이 아니다. React 공식 문서도 &lt;code&gt;memo&lt;/code&gt;는 성능 최적화이지 보장이 아니며, React가 여전히 리렌더링할 수 있다고 설명한다. 부모의 state 변경, context 변경, key 변경 등으로 memo 컴포넌트도 다시 렌더링될 수 있다.&lt;/p&gt;
&lt;p&gt;따라서 이 코드의 의미는 이렇게 보는 것이 맞다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이 컴포넌트는 props가 같을 때 가능한 한 리렌더링을 건너뛰는 것이 중요하다는 의도를 남긴다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;솔직하게 말하면, 이 영역이 React Compiler와 가장 많이 겹친다. 단순 props 전달 경계에서의 &lt;code&gt;memo&lt;/code&gt;는 컴파일러가 충분히 처리할 수 있는 영역이다. 그래서 &lt;code&gt;MessageBubble&lt;/code&gt;과 &lt;code&gt;MarkdownMessage&lt;/code&gt;의 &lt;code&gt;memo&lt;/code&gt;는 영구적으로 남겨야 할 코드라기보다, 후속 리팩토링 후보에 가깝다.&lt;/p&gt;
&lt;p&gt;다만 제거할 때는 감으로 지우지 않는다. 메시지 리스트와 마크다운 렌더링은 실제 사용자 체감 성능에 영향을 줄 수 있는 영역이므로, React Profiler로 제거 전후를 확인한 뒤 판단하는 것이 맞다.&lt;/p&gt;
&lt;p&gt;즉 이 부분의 결론은 이렇다. &lt;strong&gt;&lt;code&gt;memo&lt;/code&gt;를 남길 수는 있지만, Compiler 시대에는 가장 먼저 검증하고 줄여볼 후보이기도 하다.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;컴파일러가 자동으로 해결해주지 않는 것들&lt;/h2&gt;
&lt;p&gt;React Compiler는 강력하지만, 모든 문제를 해결해주는 도구는 아니다.&lt;/p&gt;
&lt;p&gt;컴파일러는 React 렌더링 경로의 메모이제이션을 자동화해준다. 하지만 WebSocket, stream reader, DOM event listener, timer 같은 외부 시스템의 생명주기를 대신 설계해주지는 않는다. 다음과 같은 문제는 여전히 개발자가 직접 다뤄야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;언제 구독을 시작할 것인가&lt;/li&gt;
&lt;li&gt;언제 cleanup할 것인가&lt;/li&gt;
&lt;li&gt;abort는 어떤 타이밍에 발생해야 하는가&lt;/li&gt;
&lt;li&gt;오래된 closure를 어떻게 피할 것인가&lt;/li&gt;
&lt;li&gt;외부 listener에 같은 함수 identity를 넘겨야 하는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이런 문제는 React Compiler가 &amp;quot;자동 메모이제이션&amp;quot;으로 해결해주는 영역이 아니다. 컴파일러는 렌더링 과정에서 불필요한 재계산과 리렌더링을 줄여줄 수 있지만, 외부 시스템과의 연결·해제·중단 타이밍까지 대신 결정하지 않는다.&lt;/p&gt;
&lt;p&gt;또한 React Compiler는 정적 분석 기반이므로 Rules of React를 지키는 코드일수록 잘 동작한다. React 공식 문서도 Compiler가 지원하지 않는 패턴이나 Rules of React 위반을 감지하면 해당 컴포넌트와 훅을 건너뛸 수 있다고 설명한다.&lt;/p&gt;
&lt;p&gt;그래서 수동 메모이제이션을 남길지 판단할 때는 질문을 바꿔야 한다.&lt;/p&gt;
&lt;p&gt;예전 질문은 이랬다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이걸 메모이제이션하면 렌더링이 줄어드나?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;React Compiler를 켠 뒤의 질문은 이쪽에 가깝다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이 메모이제이션은 Compiler가 대체 가능한 단순 렌더 최적화인가, 아니면 외부 생명주기와 의도를 표현하는 코드인가?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;그래서, 뭘 줄일 수 있나&lt;/h2&gt;
&lt;p&gt;균형이 중요하다. &amp;quot;컴파일러를 켰으니 &lt;code&gt;useCallback&lt;/code&gt;은 전부 필요 없다&amp;quot;도 틀렸고, &amp;quot;그래도 다 남겨두는 게 맞다&amp;quot;도 틀렸다.&lt;/p&gt;
&lt;p&gt;컴파일러가 켜진 환경에서 &lt;strong&gt;줄일 수 있는&lt;/strong&gt; 후보는 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;단순히 자식 컴포넌트에 prop으로 넘기기만 하는 &lt;code&gt;useCallback&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;effect 내부로 옮기면 사라질 수 있는 함수 dependency&lt;/li&gt;
&lt;li&gt;비용이 크지 않은 컴포넌트의 &lt;code&gt;React.memo&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;렌더 타이밍과 무관한 순수 계산용 &lt;code&gt;useMemo&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;이유를 설명할 수 없는 관성적인 메모이제이션&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;반대로 &lt;strong&gt;남겨둘 가치가 있는&lt;/strong&gt; 영역은 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;effect dependency로 쓰이며, effect 내부로 이동하기 어렵고 참조 변경이 실제 재구독 비용에 영향을 주는 콜백&lt;/li&gt;
&lt;li&gt;DOM event listener, subscription, stream cleanup처럼 setup/cleanup과 같은 identity를 공유해야 하는 함수&lt;/li&gt;
&lt;li&gt;custom hook 경계를 넘고, 호출자와 훅 구현자가 안정성 전략을 공유해야 하는 생명주기 콜백&lt;/li&gt;
&lt;li&gt;Profiler로 비용이 확인된 렌더링 경계의 &lt;code&gt;memo&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Compiler가 분석하기 어려운 패턴 주변에서 명시적 제어가 필요한 코드&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;판단 기준을 한 문장으로 줄이면 이렇다. &lt;strong&gt;이 메모이제이션이 단지 렌더 횟수를 줄이는 용도인가, 아니면 동작의 정합성이나 의도를 표현하는 용도인가.&lt;/strong&gt; 전자라면 컴파일러에 맡기고, 후자라면 남길 수 있다.&lt;/p&gt;
&lt;p&gt;단, 남기는 순간 책임도 생긴다. dependency 배열은 정확해야 하고, 왜 필요한지 설명할 수 있어야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;도입할 때 알아두면 좋은 것&lt;/h2&gt;
&lt;p&gt;컴파일러를 켤 때 한 가지 운영상 주의점이 있다. React Compiler는 자동 메모이제이션을 수행하는 빌드 타임 도구다. 버전 변화에 따라 최적화 결과가 달라질 수 있으므로, 자동 업그레이드보다는 버전을 고정(&lt;code&gt;--save-exact&lt;/code&gt;)하고 변경 시 직접 검증하는 쪽이 안전하다.&lt;/p&gt;
&lt;p&gt;도입 전에는 &lt;code&gt;eslint-plugin-react-hooks&lt;/code&gt;의 recommended 프리셋으로 Rules of React 위반과 dependency 문제를 먼저 점검하는 것을 권한다. 컴파일러는 규칙을 잘 지킨 코드일수록 더 안전하게 최적화할 수 있다.&lt;/p&gt;
&lt;p&gt;자동 메모이제이션은 &amp;quot;이제 아무것도 신경 쓰지 않아도 된다&amp;quot;는 뜻이 아니다. 오히려 반대에 가깝다. &lt;strong&gt;컴파일러가 많은 메모이제이션을 대신해주기 때문에, 남겨둔 수동 메모이제이션은 더 분명한 이유를 가져야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;이번 프로젝트를 한 줄로 요약하면 이렇다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;React Compiler&lt;/strong&gt;: 프로젝트 차원에서 활성화되어 있음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;useCallback / memo&lt;/strong&gt;: 일부는 참조 안정성·의도 표현용으로 유지&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;후속 리팩토링 대상&lt;/strong&gt;: 단순 props 전달 경계의 &lt;code&gt;useCallback&lt;/code&gt;, 비용이 검증되지 않은 &lt;code&gt;memo&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;판단의 축&lt;/strong&gt;: 스트리밍·스크롤·타이머처럼 외부 생명주기와 묶인 로직은 명시성을 우선&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;React Compiler는 분명 수동 메모이제이션의 상당 부분을 줄여준다. 하지만 effect, 외부 구독, custom hook 경계, 스트림 생명주기처럼 렌더링 바깥의 세계와 연결되는 지점에서는 &lt;code&gt;useCallback&lt;/code&gt;과 &lt;code&gt;memo&lt;/code&gt;가 여전히 의미를 가질 수 있다.&lt;/p&gt;
&lt;p&gt;다만 그 의미는 예전과 달라졌다. React Compiler 이전에는 수동 메모이제이션이 성능 최적화의 기본 도구처럼 쓰였다. React Compiler 이후에는 기본값이 아니라 예외에 가까워진다.&lt;/p&gt;
&lt;p&gt;내가 가져갈 판단 기준은 명확하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;React Compiler 시대의 수동 메모이제이션은 기본값이 아니라 예외여야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;effect dependency, 외부 구독, 스트림 생명주기, custom hook API, Profiler로 확인된 고비용 렌더링 경계에서는 여전히 수동 메모이제이션이 의도를 표현하는 도구가 될 수 있다. 하지만 중요한 건 남겼다는 사실이 아니다. &lt;strong&gt;왜 남겼는지 설명할 수 있느냐이다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;컴파일러를 켜면 &amp;quot;왜 이걸 메모이제이션했지?&amp;quot;라는 질문에 답할 수 없는 &lt;code&gt;useCallback&lt;/code&gt;과 &lt;code&gt;memo&lt;/code&gt;는 사라져야 한다. 답할 수 있는 것만 남는다. 그게 컴파일러가 켜진 코드베이스에서 수동 메모이제이션이 가지는 새로운 의미라고 본다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/163</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/React-Compiler%EB%A5%BC-%EC%BC%B0%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%95%84%EC%A7%81-useCallback%EA%B3%BC-memo%EB%A5%BC-%EC%93%B0%EA%B3%A0-%EC%9E%88%EC%9D%84%EA%B9%8C#entry163comment</comments>
      <pubDate>Fri, 5 Jun 2026 17:44:03 +0900</pubDate>
    </item>
    <item>
      <title>65줄의 CLAUDE.md가 AI 코딩을 바꾼다: Karpathy-inspired 가이드라인을 읽고 내 경험과 겹쳐본 기록</title>
      <link>https://white-mouse-dev.tistory.com/entry/65%EC%A4%84%EC%9D%98-CLAUDEmd%EA%B0%80-AI-%EC%BD%94%EB%94%A9%EC%9D%84-%EB%B0%94%EA%BE%BC%EB%8B%A4-Karpathy-inspired-%EA%B0%80%EC%9D%B4%EB%93%9C%EB%9D%BC%EC%9D%B8%EC%9D%84-%EC%9D%BD%EA%B3%A0-%EB%82%B4-%EA%B2%BD%ED%97%98%EA%B3%BC-%EA%B2%B9%EC%B3%90%EB%B3%B8-%EA%B8%B0%EB%A1%9D</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/480684dd-3fc3-4fc8-8737-13241842fa61/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;GitHub 스타 10만 개를 넘긴 &lt;code&gt;andrej-karpathy-skills&lt;/code&gt; 저장소의 CLAUDE.md.&lt;br&gt;단 65줄의 마크다운 파일이 왜 그렇게 화제가 됐을까.&lt;br&gt;Claude Code를 메인 코딩 에이전트로 쓰는 입장에서, 이 문서가 짚은 문제들이 너무 익숙해서 정리해두기로 했다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;CLAUDE.md가 던진 메시지&lt;/h2&gt;
&lt;p&gt;OpenAI 창립 멤버이자 전 Tesla AI Director였던 Andrej Karpathy는 LLM 코딩 에이전트가 자주 보이는 실패 패턴을 지적해왔다. 그 관찰에서 영감을 받아 만들어진 GitHub 저장소 &lt;code&gt;andrej-karpathy-skills&lt;/code&gt;의 &lt;code&gt;CLAUDE.md&lt;/code&gt;는 Claude Code가 작업할 때 참고할 수 있는 4가지 행동 원칙을 담고 있다.&lt;/p&gt;
&lt;p&gt;길이는 단 65줄. 화려한 기법도, 새로운 알고리즘도 없다. 그런데 이 저장소는 GitHub에서 10만 개가 넘는 스타를 받으며 큰 화제가 됐다.&lt;/p&gt;
&lt;p&gt;이유는 단순하다. &lt;strong&gt;AI 코딩 에이전트를 써본 사람이라면 누구나 겪은 답답함&lt;/strong&gt;을 정확히 짚었기 때문이다.&lt;/p&gt;
&lt;p&gt;이 가이드라인이 겨냥하는 문제는 크게 네 가지다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;확인 없는 가정&lt;/strong&gt;: 모호한 부분이 있어도 묻지 않고 자의적으로 판단한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;과도한 복잡성&lt;/strong&gt;: 간단한 일에 불필요한 추상화와 미래 대비용 코드를 붙인다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;무관한 변경&lt;/strong&gt;: 요청과 상관없는 코드, 주석, 포맷까지 건드린다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;검증 없는 완료 선언&lt;/strong&gt;: 성공 기준이나 테스트 없이 &amp;quot;수정했다&amp;quot;고 말한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 문제들을 줄이기 위해 CLAUDE.md는 네 가지 원칙을 제시한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;CLAUDE.md의 4가지 원칙&lt;/h2&gt;
&lt;h3&gt;제1원칙: Think Before Coding&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;성급하게 타이핑하지 말고, 모르면 물어보라.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;요청이 애매할 때 AI가 멋대로 판단하고 구현하는 것을 줄인다. &amp;quot;로그인 기능 추가해줘&amp;quot;라는 요청에 AI가 JWT, 세션, OAuth 중 하나를 임의로 골라 구현해버리는 대신, 옵션의 장단점을 설명하고 사용자에게 선택을 요청하도록 유도한다.&lt;/p&gt;
&lt;h3&gt;제2원칙: Simplicity First&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;과설계(Over-engineering)를 피하고, 필요한 만큼만 작성하라.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;AI는 종종 간단한 함수로 끝날 일을 추상 클래스로 빼고, &amp;quot;미래에 확장 가능하도록&amp;quot; 인터페이스를 만들고, &amp;quot;혹시 모를&amp;quot; 에러 핸들링을 잔뜩 추가한다. 똑똑해 보이는 코드가 아니라, 지금 당장 필요한 만큼의 단순한 코드를 작성하라는 원칙이다.&lt;/p&gt;
&lt;h3&gt;제3원칙: Surgical Changes&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;요청받은 부분만, 최소한의 범위로 수정하라.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;버그 하나 고치라고 했더니 주변 코드의 포맷, import 순서, 변수명, 주석까지 모두 바꿔놓으면 코드 리뷰가 지옥이 된다. 마치 외과 수술처럼 딱 필요한 부분만 수정하도록 유도한다.&lt;/p&gt;
&lt;h3&gt;제4원칙: Goal-Driven Execution&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;스스로 검증 가능한 목표를 세우고 실행하라.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;quot;버그 고쳐줘&amp;quot;라는 모호한 요청에 코드를 눈대중으로 고치고 끝내는 일을 줄인다. 버그를 재현하는 테스트 작성 → Red 확인 → 수정 → Green 확인이라는 명확한 검증 루프를 기본 행동으로 유도한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;내가 Claude Code를 쓰면서 겪은 사례들&lt;/h2&gt;
&lt;p&gt;이 원칙들을 읽으면서 &amp;quot;이건 내 얘기다&amp;quot; 싶었던 경험들이 떠올랐다. 하나씩 풀어본다.&lt;/p&gt;
&lt;h3&gt;사례 1: 묻지 않고 폭주하는 AI (제1원칙 위반)&lt;/h3&gt;
&lt;p&gt;React 컴포넌트의 상태 관리를 손봐달라고 요청한 적이 있다. &amp;quot;이 사이드바 상태가 새로고침할 때마다 초기화되는데, 유지되게 해줘&amp;quot;라고 던졌다. 의도는 가벼웠다 — 어떤 방식이 좋을지 의논하면서 결정하고 싶었다.&lt;/p&gt;
&lt;p&gt;Claude Code는 묻지 않았다. 곧바로 Zustand에 &lt;code&gt;persist&lt;/code&gt; 미들웨어를 붙이고, localStorage에 저장하는 코드를 만들어냈다. 동작은 했다. 하지만 그 프로젝트는 Next.js App Router 기반이었고, localStorage 값이 초기 UI 렌더링에 관여할 경우 서버 렌더 결과와 클라이언트 첫 렌더 결과가 달라져 hydration mismatch가 발생할 수 있다. AI는 그걸 신경 쓰지 않았다.&lt;/p&gt;
&lt;p&gt;내가 원했던 답변은 이거였다. &amp;quot;사이드바 상태는 클라이언트에서만 필요한가요, 아니면 서버 SSR에서도 알아야 하나요? 전자라면 localStorage 같은 클라이언트 저장소를 쓸 수 있고, 후자라면 cookie처럼 서버에서 읽을 수 있는 상태 저장 방식이 더 적합합니다.&amp;quot; 이 한 줄을 묻지 않고 직진한 결과, 코드를 받고 나서 &amp;quot;아 이거 SSR에서 깨질 텐데&amp;quot; 하고 다시 갈아엎어야 했다.&lt;/p&gt;
&lt;p&gt;CLAUDE.md의 1원칙이 정확히 짚는 지점이다. &lt;strong&gt;모르면 묻는다는 단순한 규칙이, 한 시간짜리 삽질을 막아준다.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;사례 2: 추상화 중독 (제2원칙 위반)&lt;/h3&gt;
&lt;p&gt;간단한 유틸 함수 하나가 필요했다. &amp;quot;객체에서 특정 키들만 골라서 새 객체로 만들어줘.&amp;quot; 한 줄이면 끝날 일이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const pick = &amp;lt;T, K extends keyof T&amp;gt;(obj: T, keys: K[]): Pick&amp;lt;T, K&amp;gt; =&amp;gt;
  Object.fromEntries(keys.map(k =&amp;gt; [k, obj[k]])) as Pick&amp;lt;T, K&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude Code가 만든 코드는 다음과 같았다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;interface Picker&amp;lt;T&amp;gt; {
  pick&amp;lt;K extends keyof T&amp;gt;(keys: K[]): Pick&amp;lt;T, K&amp;gt;;
  omit&amp;lt;K extends keyof T&amp;gt;(keys: K[]): Omit&amp;lt;T, K&amp;gt;;
}

class ObjectPicker&amp;lt;T extends Record&amp;lt;string, unknown&amp;gt;&amp;gt; implements Picker&amp;lt;T&amp;gt; {
  constructor(private readonly source: T) {}

  pick&amp;lt;K extends keyof T&amp;gt;(keys: K[]): Pick&amp;lt;T, K&amp;gt; {
    return keys.reduce(/* ... */);
  }

  omit&amp;lt;K extends keyof T&amp;gt;(keys: K[]): Omit&amp;lt;T, K&amp;gt; {
    /* ... */
  }

  static from&amp;lt;T extends Record&amp;lt;string, unknown&amp;gt;&amp;gt;(obj: T): ObjectPicker&amp;lt;T&amp;gt; {
    return new ObjectPicker(obj);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;요청하지도 않은 &lt;code&gt;omit&lt;/code&gt;이 따라왔고, 클래스로 감쌌고, 정적 팩토리 메서드까지 붙었다. 한 번 쓰고 말 함수에 빌더 패턴이 들어갈 이유가 없다. &lt;strong&gt;AI는 &amp;quot;혹시 모르니까&amp;quot;라는 명목으로 미래의 가상 요구사항에 대비한다.&lt;/strong&gt; 그 대비가 지금의 가독성과 유지보수성을 갉아먹는다는 게 함정이다.&lt;/p&gt;
&lt;h3&gt;사례 3: 외과 수술이 아니라 전신 마취 (제3원칙 위반)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;useEffect&lt;/code&gt;의 의존성 배열에 빠진 값을 하나 추가해달라고 했다. 정말 그것만 필요했다.&lt;/p&gt;
&lt;p&gt;Claude Code가 반환한 diff는 30줄이 넘었다. 의존성 추가는 한 줄이었지만, 같은 파일의 다른 함수들이 화살표 함수에서 일반 함수로 바뀌어 있었고, import 순서가 알파벳 순으로 재정렬되었고, 주석 스타일이 &lt;code&gt;//&lt;/code&gt;에서 &lt;code&gt;/** */&lt;/code&gt;로 통일되었고, 사용하지 않는 import 두 개가 제거되어 있었다.&lt;/p&gt;
&lt;p&gt;각각의 변경은 모두 &amp;quot;더 나은&amp;quot; 것일 수 있다. 하지만 PR 리뷰어 입장에서는 &amp;quot;의존성 배열 하나 고친다더니 왜 이걸 다 건드렸지?&amp;quot;가 된다. 진짜 변경이 무관한 변경 사이에 묻혀서, 리뷰에 들이는 시간이 5배가 된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;의도하지 않은 변경은 친절이 아니라 잡음이다.&lt;/strong&gt; CLAUDE.md의 3원칙은 이 잡음을 막는다.&lt;/p&gt;
&lt;h3&gt;사례 4: 테스트 없이 &amp;quot;고쳤다&amp;quot;고 우기는 AI (제4원칙 위반)&lt;/h3&gt;
&lt;p&gt;폼 유효성 검사 로직에 버그가 있었다. 이메일 형식이 잘못됐는데도 통과되는 케이스가 있다고 알려줬다. Claude Code는 정규식을 수정하고 &amp;quot;수정되었습니다&amp;quot;라고 답했다.&lt;/p&gt;
&lt;p&gt;수정된 정규식이 진짜로 그 케이스를 잡는지 확인하지 않았다. 내가 직접 테스트를 돌려보니 여전히 통과되고 있었다. AI는 &amp;quot;수정한 것 같다&amp;quot;는 자신감만 있을 뿐, &amp;quot;수정되었다&amp;quot;는 검증을 하지 않았다.&lt;/p&gt;
&lt;p&gt;이후로는 이렇게 요청한다. &amp;quot;이 버그를 재현하는 테스트를 먼저 작성해줘. 그 테스트가 실패하는 것을 확인한 다음 수정해줘. 수정 후 테스트가 통과하는 것까지 확인해줘.&amp;quot; 이 한 단락이 들어가면 AI의 정확도가 체감상 두 배는 올라간다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;수정했다&amp;quot;와 &amp;quot;검증된 수정&amp;quot;은 완전히 다른 결과물이다.&lt;/strong&gt; 4원칙이 짚는 게 정확히 이 차이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;하네스 엔지니어링이라는 관점&lt;/h2&gt;
&lt;p&gt;CLAUDE.md가 화제가 된 진짜 이유는 65줄의 텍스트 자체가 아니라, 그 텍스트가 던지는 &lt;strong&gt;관점의 전환&lt;/strong&gt;에 있다고 본다.&lt;/p&gt;
&lt;p&gt;AI는 코딩을 못 하지 않는다. 오히려 너무 빠르고, 너무 자신 있게 짠다. 문제는 그 빠름과 자신감이 잘못된 방향으로 갈 때다. 그래서 필요한 건 더 똑똑한 AI가 아니라, &lt;strong&gt;AI가 폭주하지 않도록 잡아주는 고삐&lt;/strong&gt;다.&lt;/p&gt;
&lt;p&gt;이걸 영어로 &amp;quot;Harness Engineering&amp;quot;이라고 부른다. 말 그대로 마구(harness)를 채우는 일이다. 야생마는 빠르지만 어디로 갈지 모른다. 마구를 채우면 빠른 속도를 유지하면서도 원하는 방향으로 달리게 할 수 있다.&lt;/p&gt;
&lt;p&gt;CLAUDE.md는 그 마구의 한 형태다. 4가지 원칙은 AI의 출력 방향을 좁히는 가드레일이다. &amp;quot;묻지 않고 직진하지 마라&amp;quot;, &amp;quot;필요한 만큼만 짜라&amp;quot;, &amp;quot;딱 거기만 고쳐라&amp;quot;, &amp;quot;검증 가능한 목표로 일해라&amp;quot; — 이 가드레일이 AI를 더 똑똑하게 만드는 게 아니라, AI의 실수 확률을 낮춘다.&lt;/p&gt;
&lt;p&gt;엄밀히 말하면 CLAUDE.md 하나가 하네스 엔지니어링의 전부는 아니다. 하네스 엔지니어링은 에이전트가 어떤 도구(MCP, hooks, skills)를 쓸 수 있는지, 어떤 컨텍스트를 읽는지, 어떤 테스트와 리뷰 루프를 통과해야 하는지까지 설계하는 더 넓은 개념이다. CLAUDE.md/AGENTS.md 같은 가이드라인 문서는 그중에서 &lt;strong&gt;가장 작고 즉시 적용 가능한 형태의 하네스&lt;/strong&gt;라고 볼 수 있다. 진입 장벽이 낮고, 효과가 즉각적이고, 65줄짜리 마크다운으로도 큰 변화를 만든다는 점에서.&lt;/p&gt;
&lt;p&gt;내가 Claude Code를 쓰면서 점진적으로 깨달은 것도 같은 방향이었다. 처음에는 프롬프트를 잘 쓰는 데 집중했다. 그러다가 컨텍스트를 잘 제공하는 데 집중했다. 그리고 지금은 &lt;strong&gt;AI가 일하는 환경 자체를 설계하는 데&lt;/strong&gt; 시간을 쓴다. CLAUDE.md를 정성껏 작성하고, 검증 루프를 미리 설계하고, &amp;quot;이런 경우에는 반드시 물어봐달라&amp;quot;는 규칙을 명시한다.&lt;/p&gt;
&lt;p&gt;이런 작업은 코드를 한 줄도 짜지 않는다. 그래도 결과물의 품질이 확연히 달라진다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;마무리: 딸깍이를 넘어서&lt;/h2&gt;
&lt;p&gt;AI 코딩 시대에 개발자의 역할이 줄어드는 게 아니라 바뀌고 있다. 한 줄 한 줄 타이핑하는 사람에서, AI가 일할 수 있는 환경을 설계하고 검증 루프를 붙이는 사람으로.&lt;/p&gt;
&lt;p&gt;65줄짜리 CLAUDE.md가 GitHub에서 폭발적인 반응을 얻은 건, 단순히 그 내용이 유용해서가 아니라, &lt;strong&gt;AI 시대에 개발자가 해야 할 일의 본질을 압축해서 보여줬기 때문&lt;/strong&gt;이라고 생각한다.&lt;/p&gt;
&lt;p&gt;코드를 짜는 게 아니라, 코드가 짜질 환경을 설계하는 것. 답을 주는 게 아니라, 답이 검증될 루프를 만드는 것. 이게 다가오는 시대의 개발자상이다.&lt;/p&gt;
&lt;p&gt;오늘부터 내 프로젝트의 &lt;code&gt;CLAUDE.md&lt;/code&gt;를 다시 손봐야겠다.&lt;/p&gt;</description>
      <category>AI &amp;middot; ML</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/162</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/65%EC%A4%84%EC%9D%98-CLAUDEmd%EA%B0%80-AI-%EC%BD%94%EB%94%A9%EC%9D%84-%EB%B0%94%EA%BE%BC%EB%8B%A4-Karpathy-inspired-%EA%B0%80%EC%9D%B4%EB%93%9C%EB%9D%BC%EC%9D%B8%EC%9D%84-%EC%9D%BD%EA%B3%A0-%EB%82%B4-%EA%B2%BD%ED%97%98%EA%B3%BC-%EA%B2%B9%EC%B3%90%EB%B3%B8-%EA%B8%B0%EB%A1%9D#entry162comment</comments>
      <pubDate>Thu, 14 May 2026 11:01:29 +0900</pubDate>
    </item>
    <item>
      <title>몬티 홀 문제, 직관을 의심하고 코드로 증명하기</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EB%AA%AC%ED%8B%B0-%ED%99%80-%EB%AC%B8%EC%A0%9C-%EC%A7%81%EA%B4%80%EC%9D%84-%EC%9D%98%EC%8B%AC%ED%95%98%EA%B3%A0-%EC%BD%94%EB%93%9C%EB%A1%9C-%EC%A6%9D%EB%AA%85%ED%95%98%EA%B8%B0</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;학생 때 확률과 통계 수업에서 처음 접했던 몬티 홀 문제.&lt;br&gt;당시에도 &amp;quot;바꾸는 게 유리하다&amp;quot;는 결론은 머리로 받아들였지만,&lt;br&gt;직관적으로는 끝까지 와닿지 않았다.&lt;br&gt;오랜만에 생각나서 Python으로 시뮬레이션을 짜봤는데,&lt;br&gt;단순한 검증을 넘어서 흥미로운 사실 하나를 더 발견했다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;몬티 홀 문제란&lt;/h2&gt;
&lt;p&gt;미국의 게임쇼 &lt;em&gt;Let&amp;#39;s Make a Deal&lt;/em&gt;에서 나온 유명한 확률 퍼즐이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;참가자 앞에 세 개의 문이 있다. 한 문 뒤에는 자동차가, 나머지 두 문 뒤에는 염소가 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;참가자가 문 하나를 고른다.&lt;/li&gt;
&lt;li&gt;진행자(몬티 홀)는 &lt;strong&gt;남은 두 문 중 염소가 있는 문 하나를 열어 보여준다.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;참가자에게 묻는다. &amp;quot;고른 문을 유지할래요, 아니면 다른 문으로 바꿀래요?&amp;quot;&lt;/li&gt;
&lt;/ol&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;직관적으로는 &amp;quot;이제 문이 두 개 남았으니 50:50 아닌가?&amp;quot;라고 생각하기 쉽다. 하지만 정답은 &lt;strong&gt;바꾸는 것이 유리하다.&lt;/strong&gt; 바꾸면 승률이 2/3, 유지하면 1/3이다.&lt;/p&gt;
&lt;p&gt;처음 들으면 받아들이기 어렵다. 1990년 메릴린 보스 사반트가 이 답을 잡지에 발표했을 때, 박사 학위를 가진 수학자들조차 &amp;quot;틀렸다&amp;quot;고 항의 편지를 보냈다고 한다. 그만큼 직관에 반하는 문제다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;왜 바꾸는 게 유리한가: 조건부 확률로 보기&lt;/h2&gt;
&lt;p&gt;핵심은 &lt;strong&gt;진행자가 무작위로 문을 여는 게 아니라는 점&lt;/strong&gt;이다. 진행자는 자동차의 위치를 알고, 의도적으로 염소가 있는 문을 연다. 이 &amp;quot;정보&amp;quot;가 확률을 비대칭으로 만든다.&lt;/p&gt;
&lt;p&gt;처음 문을 골랐을 때의 확률을 보자.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;내가 고른 문에 자동차가 있을 확률: &lt;strong&gt;1/3&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;내가 고른 문에 자동차가 없을 확률 (= 나머지 두 문 중 하나에 자동차가 있을 확률): &lt;strong&gt;2/3&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;진행자가 염소 문을 열어준 뒤에도 이 확률은 변하지 않는다. 내가 처음 고른 문이 자동차일 확률은 여전히 1/3이고, &amp;quot;내가 고르지 않은 두 문 중 어딘가에 자동차가 있을 확률&amp;quot;은 여전히 2/3다. 다만 진행자가 그 두 문 중 염소가 있는 쪽을 알려줬으므로, 이제 그 2/3 확률이 &lt;strong&gt;남은 한 문에 모두 집중된다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;조건부 확률로 표현하면 이렇다. 자동차가 1번 문 뒤에 있을 사건을 $C_1$, 진행자가 3번 문을 여는 사건을 $H_3$이라 하자. 내가 1번 문을 골랐을 때, 진행자가 3번 문을 연 조건에서 자동차가 2번 문 뒤에 있을 확률은:&lt;/p&gt;
&lt;p&gt;$$P(C_2 | H_3) = \frac{P(H_3 | C_2) \cdot P(C_2)}{P(H_3)} = \frac{1 \cdot \frac{1}{3}}{\frac{1}{2}} = \frac{2}{3}$$&lt;/p&gt;
&lt;p&gt;자동차가 2번 문에 있다면 진행자는 무조건 3번 문을 열어야 하므로 $P(H_3 | C_2) = 1$이다. 반면 자동차가 내가 고른 1번 문에 있다면 진행자는 2번이나 3번 중 무작위로 선택하므로 $P(H_3 | C_1) = 1/2$다. 이 비대칭이 2/3이라는 결과를 만든다.&lt;/p&gt;
&lt;p&gt;수식으로 보면 명확하지만, 그래도 직관적으로는 여전히 어색할 수 있다. 그래서 시뮬레이션이 필요하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;큰 수의 법칙으로 검증하기&lt;/h2&gt;
&lt;p&gt;이론은 이론이고, 직접 돌려보고 싶었다. &lt;strong&gt;큰 수의 법칙&lt;/strong&gt;(Law of Large Numbers)에 따르면, 시행 횟수가 충분히 많아지면 실험적 확률은 이론적 확률에 수렴한다. 10만 번 정도 돌려보면 결과가 명확하게 보일 것이다.&lt;/p&gt;
&lt;p&gt;Python으로 짠 코드의 핵심 함수는 이렇다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def play_once(switch: bool, smart_host: bool, rng: random.Random):
    &amp;quot;&amp;quot;&amp;quot;Returns (won, valid). valid=False only for dumb host who reveals the car.&amp;quot;&amp;quot;&amp;quot;
    car = rng.randrange(3)        # 자동차 위치 무작위
    choice = rng.randrange(3)     # 참가자 선택 무작위

    if smart_host:
        # 똑똑한 진행자: 염소 있는 문만 연다
        host_options = [d for d in range(3) if d != choice and d != car]
        host_opens = rng.choice(host_options)
    else:
        # 멍청한 진행자: 무작위로 연다 (자동차를 열어버릴 수도 있음)
        host_options = [d for d in range(3) if d != choice]
        host_opens = rng.choice(host_options)
        if host_opens == car:
            return False, False   # 자동차를 공개해버린 라운드는 무효

    if switch:
        choice = next(d for d in range(3) if d != choice and d != host_opens)

    return choice == car, True&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 &lt;code&gt;smart_host=True&lt;/code&gt;인 경우가 일반적인 몬티 홀 문제다. 그런데 코드를 짜다 보니 자연스럽게 한 가지 의문이 생겼다. &lt;strong&gt;만약 진행자가 자동차의 위치를 모르고 무작위로 문을 연다면? 그래도 바꾸는 게 유리할까?&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Smart host vs Dumb host: 진행자의 지식이 만드는 차이&lt;/h2&gt;
&lt;p&gt;10만 번씩 돌려본 결과는 이렇다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/3cdc0da8-e6d2-4318-8107-31cfefae5cbf/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;시나리오&lt;/th&gt;
&lt;th&gt;승률&lt;/th&gt;
&lt;th&gt;이론값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Smart host + Switch&lt;/td&gt;
&lt;td&gt;0.6675&lt;/td&gt;
&lt;td&gt;2/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smart host + Stay&lt;/td&gt;
&lt;td&gt;0.3325&lt;/td&gt;
&lt;td&gt;1/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dumb host + Switch&lt;/td&gt;
&lt;td&gt;0.4998&lt;/td&gt;
&lt;td&gt;1/2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dumb host + Stay&lt;/td&gt;
&lt;td&gt;0.5002&lt;/td&gt;
&lt;td&gt;1/2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;흥미로운 결과가 두 가지 보인다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, 일반적인 몬티 홀(Smart host)은 이론대로 2/3 vs 1/3이다.&lt;/strong&gt; 시행이 100번을 넘어가면서부터 빠르게 수렴하는 게 그래프에서 보인다. 큰 수의 법칙이 깔끔하게 작동했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, 진행자가 무작위로 문을 여는 경우(Dumb host)에는 바꾸든 유지하든 50:50이다.&lt;/strong&gt; 이 부분이 핵심이다.&lt;/p&gt;
&lt;p&gt;왜 이런 차이가 날까? Smart host의 경우 진행자의 행동이 &lt;strong&gt;정보&lt;/strong&gt;를 담고 있다. &amp;quot;염소가 있는 문을 의도적으로 골라서 열었다&amp;quot;는 사실 자체가 확률 분포에 영향을 준다. 반면 Dumb host는 무작위로 행동하므로 새로운 정보가 없다. 진행자가 우연히 염소 문을 열었을 뿐, 그 행동에 의미가 없다.&lt;/p&gt;
&lt;p&gt;이게 몬티 홀 문제의 진짜 핵심이다. &lt;strong&gt;&amp;quot;문이 두 개 남았다&amp;quot;는 사실 자체가 50:50을 만드는 게 아니라, &amp;quot;진행자가 어떤 규칙으로 문을 열었느냐&amp;quot;가 확률을 결정한다.&lt;/strong&gt; 같은 결과(염소가 있는 문이 열림)를 봤더라도, 그것이 어떤 과정에서 나왔는지에 따라 사후 확률이 완전히 달라진다.&lt;/p&gt;
&lt;p&gt;참고로 코드에서 Dumb host가 자동차를 공개해버린 라운드(전체의 약 1/3)는 무효로 처리했다. 게임이 성립하지 않으니까. 이 무효 라운드를 빼고 &amp;quot;게임이 끝까지 진행된&amp;quot; 라운드만 집계해도 50:50이 나온다는 게 흥미로운 점이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;코드 작성 시 신경 쓴 부분&lt;/h2&gt;
&lt;p&gt;시뮬레이션 코드를 짤 때 두 가지를 신경 썼다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;재현 가능성.&lt;/strong&gt; &lt;code&gt;random.Random(seed)&lt;/code&gt; 인스턴스를 시나리오마다 따로 만들었다. 전역 &lt;code&gt;random&lt;/code&gt;을 쓰면 다른 시나리오가 영향을 주고받기 때문에, 같은 seed로 돌렸을 때 결과가 일정하지 않다. 시나리오별로 독립된 RNG를 쓰면 디버깅도 쉽고, 결과 비교도 정확하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;수렴 과정 시각화.&lt;/strong&gt; 단순히 최종 승률만 출력하는 게 아니라, 매 시행마다의 누적 승률을 기록해서 plot으로 그렸다. 100번에서는 노이즈가 크지만 1만 번을 넘으면 이론값에 딱 붙는 모습이 보이는데, 큰 수의 법칙을 시각적으로 체감할 수 있다. x축을 로그 스케일로 잡은 것도 초기 변동성과 후기 수렴을 한 그래프에서 같이 보기 위해서였다.&lt;/p&gt;
&lt;p&gt;전체 코드는 GitHub에 올려뒀다. &lt;code&gt;python monty_hall.py -n 100000&lt;/code&gt; 으로 바로 돌려볼 수 있고, &lt;code&gt;--seed 42&lt;/code&gt; 같은 식으로 seed를 고정할 수도 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;몬티 홀 문제를 다시 풀어보면서 두 가지를 다시 확인했다.&lt;/p&gt;
&lt;p&gt;하나는 &lt;strong&gt;확률에서 직관은 자주 틀린다&lt;/strong&gt;는 것. 우리 뇌는 &amp;quot;남은 선택지가 두 개니까 50:50&amp;quot;이라는 단순한 휴리스틱에 끌리지만, 실제 확률은 정보가 어떻게 들어왔는지에 따라 달라진다. 박사들도 틀렸던 문제니까, 처음에 헷갈리는 게 당연하다.&lt;/p&gt;
&lt;p&gt;다른 하나는 &lt;strong&gt;시뮬레이션의 가치&lt;/strong&gt;다. 수학적으로 증명된 결과라도 직접 돌려보면 이해의 깊이가 다르다. 특히 Smart vs Dumb host처럼 변형을 만들어보면서, &amp;quot;진행자의 지식이 확률에 어떻게 영향을 주는가&amp;quot;를 체감할 수 있었다. 이건 수식만 봐서는 잘 안 와닿는 부분이다.&lt;/p&gt;
&lt;p&gt;수업 시간에 외운 결론을 10년 만에 코드로 증명해본 셈인데, 의외로 재밌었다. 다음에 비슷하게 직관에 반하는 확률 문제(생일 문제, 두 봉투 역설 같은)를 만나면 또 시뮬레이션을 짜볼 것 같다.&lt;/p&gt;</description>
      <category>Algorithm</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/161</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EB%AA%AC%ED%8B%B0-%ED%99%80-%EB%AC%B8%EC%A0%9C-%EC%A7%81%EA%B4%80%EC%9D%84-%EC%9D%98%EC%8B%AC%ED%95%98%EA%B3%A0-%EC%BD%94%EB%93%9C%EB%A1%9C-%EC%A6%9D%EB%AA%85%ED%95%98%EA%B8%B0#entry161comment</comments>
      <pubDate>Wed, 6 May 2026 17:46:41 +0900</pubDate>
    </item>
    <item>
      <title>AI가 코드를 짜주는 시대(AI Agentic Coding)에, 개발자는 프로그래밍 언어를 배워야 할까?</title>
      <link>https://white-mouse-dev.tistory.com/entry/AI%EA%B0%80-%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%A7%9C%EC%A3%BC%EB%8A%94-%EC%8B%9C%EB%8C%80AI-Agentic-Coding%EC%97%90-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%8A%94-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%96%B8%EC%96%B4%EB%A5%BC-%EB%B0%B0%EC%9B%8C%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/bcf58972-c4e7-4052-96b6-3e9d621de705/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;면접에서 이 질문을 받았다. 알고 있다고 생각했는데, 막상 말로 풀어내려니 생각보다 정리가 안 됐다.&lt;br&gt;면접이 끝난 뒤 다시 정리한 기록.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;질문의 진짜 의도&lt;/h2&gt;
&lt;p&gt;&amp;quot;AI Agentic Coding 시대에 개발자가 프로그래밍 언어를 배워야 할까요?&amp;quot;&lt;/p&gt;
&lt;p&gt;이 질문은 단순히 &amp;quot;배워야 한다 / 안 배워도 된다&amp;quot;를 묻는 게 아니다. 면접관이 보고 싶은 건 &lt;strong&gt;AI 시대에 개발자로서 본인의 가치를 어떻게 재정의하고 있는가&lt;/strong&gt;에 대한 사고 프레임이다.&lt;/p&gt;
&lt;p&gt;&amp;quot;그래도 기본은 알아야죠&amp;quot;라고 답하면 무난하지만 인상에 남지 않는다. &amp;quot;AI가 다 해주니까 안 배워도 됩니다&amp;quot;는 위험한 사람으로 보인다. 이 질문에는 확실한 입장 + 그 입장을 뒷받침하는 구조화된 논거가 필요하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;내 결론: 반드시 배워야 한다&lt;/h2&gt;
&lt;p&gt;다만 &lt;strong&gt;배우는 이유&lt;/strong&gt;가 달라진다.&lt;/p&gt;
&lt;p&gt;기존에는 &amp;quot;코드를 작성하기 위해&amp;quot; 언어를 배웠다. 이제는 &amp;quot;AI가 생성한 코드를 판단하고 검증하기 위해&amp;quot; 배워야 한다. 역할이 &lt;strong&gt;코드 작성자(Coder)에서 코드 판단자이자 시스템 설계자(Judge &amp;amp; Architect)로&lt;/strong&gt; 이동하고 있다.&lt;/p&gt;
&lt;p&gt;자율주행에 비유하면 이해가 빠르다. 자율주행 기술이 발전했다고 해서 운전면허가 지금 당장 필요 없어지는 건 아니다. 자율주행 차량이 고속도로에서는 잘 달리지만, 공사 구간이나 비포장도로에서는 운전자가 개입해야 한다. 마찬가지로 AI가 함수 하나는 잘 짜지만, 프로덕션 장애 상황에서 AI에게 &amp;quot;고쳐줘&amp;quot;만 외칠 수는 없다. 핸들을 직접 잡을 수 있는 능력이 없으면, 자율주행이 풀지 못하는 상황에서 속수무책이 된다.&lt;/p&gt;
&lt;p&gt;10년 뒤에는 정말 필요 없어질 수도 있다. 하지만 그건 10년 뒤의 이야기이고, 지금 이 전환기에는 오히려 언어와 프레임워크에 대한 깊이 있는 이해가 &lt;strong&gt;더&lt;/strong&gt; 중요해지고 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;왜 여전히 필요한가: 세 가지 레이어&lt;/h2&gt;
&lt;h3&gt;1. 검증과 디버깅의 책임&lt;/h3&gt;
&lt;p&gt;AI가 생성한 코드의 옳고 그름을 판단할 사람은 결국 개발자다.&lt;/p&gt;
&lt;p&gt;나는 Claude Code를 메인 코딩 에이전트로 쓰고 있는데, AI가 제안한 코드를 그대로 머지한 적은 거의 없다. 예를 들어 AI가 만든 React 컴포넌트가 불필요한 리렌더링을 유발하는 경우가 잦다. &lt;code&gt;useEffect&lt;/code&gt;의 의존성 배열에 매 렌더마다 새로 생성되는 객체를 넣는다거나, &lt;code&gt;useMemo&lt;/code&gt; 없이 무거운 계산을 인라인으로 처리한다거나. 이런 문제는 React의 렌더링 모델을 이해하지 않으면 &amp;quot;코드가 돌아가니까 괜찮다&amp;quot;고 넘어가게 된다.&lt;/p&gt;
&lt;p&gt;언어를 모르면 환각(hallucination)된 API 호출, 미묘한 메모리 누수, 비효율적인 알고리즘을 잡아낼 수 없다. AI가 생성한 코드를 프로덕션에 올리는 순간, 그 코드의 책임은 전적으로 개발자에게 있다.&lt;/p&gt;
&lt;h3&gt;2. 좋은 프롬프트는 도메인 지식에서 나온다&lt;/h3&gt;
&lt;p&gt;같은 AI를 써도 결과물의 차이가 압도적으로 갈리는 건, 프롬프트의 구체성 때문이다.&lt;/p&gt;
&lt;p&gt;&amp;quot;React 컴포넌트 만들어줘&amp;quot;라고 하면 범용적이지만 맥락 없는 코드가 나온다. 반면 &amp;quot;이 컴포넌트는 Suspense 경계 안에서 동작해야 하고, TanStack Query의 &lt;code&gt;useSuspenseQuery&lt;/code&gt;로 데이터를 페칭하되 에러 바운더리는 상위에서 처리해줘&amp;quot;라고 하면 프로덕션에 바로 쓸 수 있는 코드가 나온다.&lt;/p&gt;
&lt;p&gt;이 차이는 언어와 생태계를 깊이 아는가 아닌가에서 온다. AI를 잘 부리려면 AI보다 도메인을 잘 알아야 한다.&lt;/p&gt;
&lt;h3&gt;3. 아키텍처 설계는 위임 불가 영역&lt;/h3&gt;
&lt;p&gt;함수 하나, 컴포넌트 하나는 AI가 잘 짠다. 하지만 시스템 수준의 의사결정은 여전히 사람의 영역이다.&lt;/p&gt;
&lt;p&gt;&amp;quot;이 시스템을 MSA로 갈지 모놀리스로 갈지&amp;quot;, &amp;quot;이 도메인 경계를 어디서 자를지&amp;quot;, &amp;quot;상태 관리를 서버 상태와 클라이언트 상태로 어떻게 나눌지&amp;quot; — 이런 결정은 비즈니스 컨텍스트, 팀 규모, 언어/프레임워크의 특성을 모두 이해해야 내릴 수 있다. AI는 선택지를 제시해줄 수 있지만, 어떤 선택이 이 상황에 맞는지 판단하는 건 개발자의 몫이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;그러면 뭐가 달라지는가&lt;/h2&gt;
&lt;p&gt;배워야 한다는 결론은 같지만, &lt;strong&gt;학습의 무게중심&lt;/strong&gt;이 달라진다.&lt;/p&gt;
&lt;p&gt;문법 암기나 단순 API 사용법은 AI에게 물어보면 된다. &lt;code&gt;Array.prototype.reduce()&lt;/code&gt;의 시그니처를 외우고 있을 필요는 없다. 대신 더 중요해지는 것들이 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;언어의 설계 철학과 런타임 동작 원리.&lt;/strong&gt; JavaScript의 이벤트 루프, 클로저, 프로토타입 체인을 이해하면 AI가 생성한 코드의 잠재적 문제를 직감적으로 잡아낼 수 있다. &amp;quot;이 코드가 왜 동작하는지&amp;quot;뿐 아니라 &amp;quot;이 코드가 언제 깨지는지&amp;quot;를 아는 것이 핵심이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;트레이드오프를 이해하는 깊이.&lt;/strong&gt; 성능 vs 가독성, 유연성 vs 단순함, 추상화 vs 명시성 — 이런 판단은 언어와 프레임워크의 특성을 깊이 이해해야 내릴 수 있다. AI는 &amp;quot;How&amp;quot;를 잘 풀지만, &amp;quot;Why&amp;quot;와 &amp;quot;What if&amp;quot;는 결국 사람이 답해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;시스템 사고.&lt;/strong&gt; 개별 함수가 아니라, 함수들이 모여 만드는 시스템의 흐름을 설계하는 능력. 데이터가 어디서 시작해서 어디로 흘러가는지, 에러가 발생했을 때 어디서 잡히는지, 병목이 어디에 생기는지를 구조적으로 파악하는 역량.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;AI가 코드를 짜주는 시대에 프로그래밍 언어를 배워야 하느냐고 묻는다면, 나는 &amp;quot;오히려 지금이 더 깊이 배워야 할 때&amp;quot;라고 답하겠다.&lt;/p&gt;
&lt;p&gt;AI는 코딩의 진입 장벽을 낮췄지만, 동시에 &amp;quot;진짜 개발자&amp;quot;와 &amp;quot;코드를 붙여넣는 사람&amp;quot;의 격차를 벌려놓고 있다. AI가 생성한 코드를 읽고, 판단하고, 책임질 수 있는 사람과 그렇지 못한 사람의 차이가 점점 선명해지고 있다.&lt;/p&gt;
&lt;p&gt;자율주행이 보편화되더라도 자동차의 구조를 이해하는 엔지니어는 사라지지 않는다. 오히려 시스템이 복잡해질수록, 그 시스템을 이해하고 판단할 수 있는 사람의 가치는 올라간다.&lt;/p&gt;</description>
      <category>AI</category>
      <category>AI Agentic Coding</category>
      <category>ai 코딩</category>
      <category>vibe coding</category>
      <category>바이브 코딩</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/160</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/AI%EA%B0%80-%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%A7%9C%EC%A3%BC%EB%8A%94-%EC%8B%9C%EB%8C%80AI-Agentic-Coding%EC%97%90-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%8A%94-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%96%B8%EC%96%B4%EB%A5%BC-%EB%B0%B0%EC%9B%8C%EC%95%BC-%ED%95%A0%EA%B9%8C#entry160comment</comments>
      <pubDate>Mon, 20 Apr 2026 12:01:43 +0900</pubDate>
    </item>
    <item>
      <title>면접에서 SOLID 원칙을 못 대답한 뒤에 다시 정리한 글</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EB%A9%B4%EC%A0%91%EC%97%90%EC%84%9C-SOLID-%EC%9B%90%EC%B9%99%EC%9D%84-%EB%AA%BB-%EB%8C%80%EB%8B%B5%ED%95%9C-%EB%92%A4%EC%97%90-%EB%8B%A4%EC%8B%9C-%EC%A0%95%EB%A6%AC%ED%95%9C-%EA%B8%80</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;오래전에 알고 있었던 개념이지만, 막상 면접에서 질문을 받으니 입이 안 떨어졌다.&lt;br&gt;&amp;quot;알고 있다&amp;quot;와 &amp;quot;설명할 수 있다&amp;quot;는 완전히 다른 레벨이라는 걸 체감한 뒤, 다시 정리한 기록.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;SOLID란&lt;/h2&gt;
&lt;p&gt;SOLID는 객체지향 설계에서 지켜야 할 5가지 원칙의 앞글자를 따서 만든 이름이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;S&lt;/strong&gt; — Single Responsibility Principle (단일 책임 원칙)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;O&lt;/strong&gt; — Open-Closed Principle (개방-폐쇄 원칙)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;L&lt;/strong&gt; — Liskov Substitution Principle (리스코프 치환 원칙)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;I&lt;/strong&gt; — Interface Segregation Principle (인터페이스 분리 원칙)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;D&lt;/strong&gt; — Dependency Inversion Principle (의존 역전 원칙)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 원칙들의 공통 목표는 하나다. &lt;strong&gt;변경에 유연하고 확장하기 쉬운 코드 구조를 만드는 것.&lt;/strong&gt; 새로운 요구사항이 들어왔을 때 영향 범위가 작고, 기존 코드를 건드리지 않고도 기능을 추가할 수 있는 설계를 지향한다.&lt;/p&gt;
&lt;p&gt;SOLID는 특정 언어나 프레임워크에 종속되지 않는다. Java에서 나온 개념이지만 TypeScript, Python, Go 어디서든 적용할 수 있다. 다만 프론트엔드에서는 클래스보다 함수와 컴포넌트를 더 많이 쓰기 때문에, 원칙의 &amp;quot;정신&amp;quot;을 컴포넌트 설계에 맞게 해석하는 것이 중요하다.&lt;/p&gt;
&lt;p&gt;5가지 원칙은 서로 독립된 개별 개념이 아니라, 서로 연결되어 있다. SRP를 지키다 보면 자연스럽게 ISP를 지키게 되고, DIP를 적용하면 OCP가 따라온다. 이 점을 염두에 두고 읽으면 이해가 훨씬 쉬워진다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;SRP — 단일 책임 원칙&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;하나의 모듈(클래스, 함수, 컴포넌트)은 하나의 책임만 가져야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&amp;quot;책임&amp;quot;이란 &amp;quot;변경의 이유&amp;quot;로 바꿔 읽으면 더 명확하다. 하나의 모듈을 수정해야 하는 이유가 두 가지 이상이라면, 그 모듈은 책임이 과하다.&lt;/p&gt;
&lt;h3&gt;위반 사례&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState&amp;lt;User | null&amp;gt;(null);

  useEffect(() =&amp;gt; {
    fetch(`/api/users/${userId}`)
      .then(res =&amp;gt; res.json())
      .then(setUser);
  }, [userId]);

  if (!user) return &amp;lt;Skeleton /&amp;gt;;

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;{user.name}&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;{user.email}&amp;lt;/p&amp;gt;
      &amp;lt;p&amp;gt;{user.createdAt.toLocaleDateString(&amp;#39;ko-KR&amp;#39;)}&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 컴포넌트는 데이터 페칭, 로딩 상태 처리, 날짜 포맷팅, UI 렌더링을 전부 하고 있다. API 응답 구조가 바뀌어도, 날짜 표시 형식이 바뀌어도, UI 디자인이 바뀌어도 이 컴포넌트를 수정해야 한다.&lt;/p&gt;
&lt;h3&gt;개선&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// 데이터 페칭 책임
function useUser(userId: string) {
  return useQuery({ queryKey: [&amp;#39;user&amp;#39;, userId], queryFn: () =&amp;gt; fetchUser(userId) });
}

// 포맷팅 책임
function formatDate(date: Date): string {
  return date.toLocaleDateString(&amp;#39;ko-KR&amp;#39;);
}

// UI 렌더링 책임
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading } = useUser(userId);
  if (isLoading) return &amp;lt;Skeleton /&amp;gt;;
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;{user.name}&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;{user.email}&amp;lt;/p&amp;gt;
      &amp;lt;p&amp;gt;{formatDate(user.createdAt)}&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;API가 바뀌면 &lt;code&gt;useUser&lt;/code&gt;만, 날짜 형식이 바뀌면 &lt;code&gt;formatDate&lt;/code&gt;만, UI가 바뀌면 &lt;code&gt;UserProfile&lt;/code&gt;만 수정하면 된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;OCP — 개방-폐쇄 원칙&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;확장에는 열려 있고, 수정에는 닫혀 있어야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있는 구조를 만들라는 뜻이다.&lt;/p&gt;
&lt;h3&gt;위반 사례&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;function NotificationBanner({ type }: { type: string }) {
  if (type === &amp;#39;success&amp;#39;) return &amp;lt;div className=&amp;quot;bg-green-500&amp;quot;&amp;gt;성공!&amp;lt;/div&amp;gt;;
  if (type === &amp;#39;error&amp;#39;) return &amp;lt;div className=&amp;quot;bg-red-500&amp;quot;&amp;gt;에러 발생&amp;lt;/div&amp;gt;;
  if (type === &amp;#39;warning&amp;#39;) return &amp;lt;div className=&amp;quot;bg-yellow-500&amp;quot;&amp;gt;주의&amp;lt;/div&amp;gt;;
  // info 타입이 추가되면? 이 함수를 직접 수정해야 한다.
  return null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;알림 타입이 추가될 때마다 이 컴포넌트 내부를 수정해야 한다. 타입이 10개가 되면 if문이 10개가 된다.&lt;/p&gt;
&lt;h3&gt;개선&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const notificationStyles: Record&amp;lt;string, { className: string; message: string }&amp;gt; = {
  success: { className: &amp;#39;bg-green-500&amp;#39;, message: &amp;#39;성공!&amp;#39; },
  error:   { className: &amp;#39;bg-red-500&amp;#39;,   message: &amp;#39;에러 발생&amp;#39; },
  warning: { className: &amp;#39;bg-yellow-500&amp;#39;, message: &amp;#39;주의&amp;#39; },
};

function NotificationBanner({ type }: { type: string }) {
  const config = notificationStyles[type];
  if (!config) return null;
  return &amp;lt;div className={config.className}&amp;gt;{config.message}&amp;lt;/div&amp;gt;;
}

// info 타입 추가 시: notificationStyles에 한 줄만 추가하면 됨
// NotificationBanner 컴포넌트는 수정하지 않는다&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;설정 객체(또는 map)로 분리하면, 새로운 타입 추가는 데이터 확장이지 코드 수정이 아니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;LSP — 리스코프 치환 원칙&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;자식 타입은 부모 타입을 대체할 수 있어야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;부모 타입을 기대하는 자리에 자식 타입을 넣어도 프로그램이 의도대로 동작해야 한다는 뜻이다. TypeScript에서는 인터페이스나 타입을 구현할 때 이 원칙이 적용된다.&lt;/p&gt;
&lt;h3&gt;위반 사례&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;interface InputProps {
  value: string;
  onChange: (value: string) =&amp;gt; void;
}

// 일반 텍스트 입력 — InputProps 계약대로 동작
function TextInput({ value, onChange }: InputProps) {
  return &amp;lt;input value={value} onChange={(e) =&amp;gt; onChange(e.target.value)} /&amp;gt;;
}

// 읽기 전용 입력 — onChange를 무시해버린다
function ReadOnlyInput({ value, onChange }: InputProps) {
  // onChange를 받아놓고 아무것도 하지 않는다
  return &amp;lt;input value={value} readOnly /&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ReadOnlyInput&lt;/code&gt;은 &lt;code&gt;InputProps&lt;/code&gt;를 구현했지만 &lt;code&gt;onChange&lt;/code&gt;를 무시한다. &lt;code&gt;InputProps&lt;/code&gt;를 기대하고 &lt;code&gt;onChange&lt;/code&gt;를 호출하는 부모 컴포넌트에서 예기치 않은 동작이 발생한다. 값이 바뀌지 않는데 에러도 안 나니까, 디버깅하기 더 어렵다.&lt;/p&gt;
&lt;h3&gt;개선&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;interface ReadableInputProps {
  value: string;
}

interface EditableInputProps extends ReadableInputProps {
  onChange: (value: string) =&amp;gt; void;
}

function TextInput({ value, onChange }: EditableInputProps) {
  return &amp;lt;input value={value} onChange={(e) =&amp;gt; onChange(e.target.value)} /&amp;gt;;
}

function ReadOnlyInput({ value }: ReadableInputProps) {
  return &amp;lt;input value={value} readOnly /&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;읽기 전용과 편집 가능한 입력의 인터페이스를 분리하면, 각 컴포넌트가 자기 계약을 온전히 이행한다. 이렇게 하면 ISP(인터페이스 분리 원칙)도 자연스럽게 지켜진다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;ISP — 인터페이스 분리 원칙&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;클라이언트가 사용하지 않는 인터페이스에 의존하지 않아야 한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;하나의 거대한 인터페이스보다, 용도에 맞게 분리된 작은 인터페이스 여러 개가 낫다.&lt;/p&gt;
&lt;h3&gt;위반 사례&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;interface UserData {
  id: string;
  name: string;
  email: string;
  avatar: string;
  address: string;
  phoneNumber: string;
  creditCard: string;
  orderHistory: Order[];
}

// 헤더에서는 name과 avatar만 필요한데, UserData 전체를 요구한다
function Header({ user }: { user: UserData }) {
  return (
    &amp;lt;header&amp;gt;
      &amp;lt;img src={user.avatar} /&amp;gt;
      &amp;lt;span&amp;gt;{user.name}&amp;lt;/span&amp;gt;
    &amp;lt;/header&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Header&lt;/code&gt;는 &lt;code&gt;name&lt;/code&gt;과 &lt;code&gt;avatar&lt;/code&gt;만 필요하지만, &lt;code&gt;UserData&lt;/code&gt; 전체를 prop으로 받고 있다. &lt;code&gt;creditCard&lt;/code&gt;나 &lt;code&gt;orderHistory&lt;/code&gt;가 바뀔 때 &lt;code&gt;Header&lt;/code&gt;의 타입 정의도 영향을 받고, 테스트할 때 불필요한 mock 데이터를 만들어야 한다.&lt;/p&gt;
&lt;h3&gt;개선&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;interface UserProfile {
  name: string;
  avatar: string;
}

interface UserContact {
  email: string;
  phoneNumber: string;
  address: string;
}

interface UserBilling {
  creditCard: string;
  orderHistory: Order[];
}

function Header({ user }: { user: UserProfile }) {
  return (
    &amp;lt;header&amp;gt;
      &amp;lt;img src={user.avatar} /&amp;gt;
      &amp;lt;span&amp;gt;{user.name}&amp;lt;/span&amp;gt;
    &amp;lt;/header&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Header&lt;/code&gt;는 &lt;code&gt;UserProfile&lt;/code&gt;만 알면 된다. 결제 정보가 바뀌어도 &lt;code&gt;Header&lt;/code&gt;는 영향을 받지 않는다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;DIP — 의존 역전 원칙&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;구체적인 구현에 의존하지 말고, 추상(인터페이스)에 의존하라.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;상위 모듈이 하위 모듈의 구현 세부사항을 직접 알면 안 된다. 둘 다 추상에 의존해야 한다.&lt;/p&gt;
&lt;h3&gt;위반 사례&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// 컴포넌트가 localStorage를 직접 참조한다
function useTheme() {
  const [theme, setTheme] = useState(() =&amp;gt; localStorage.getItem(&amp;#39;theme&amp;#39;) ?? &amp;#39;light&amp;#39;);

  const toggle = () =&amp;gt; {
    const next = theme === &amp;#39;light&amp;#39; ? &amp;#39;dark&amp;#39; : &amp;#39;light&amp;#39;;
    localStorage.setItem(&amp;#39;theme&amp;#39;, next);
    setTheme(next);
  };

  return { theme, toggle };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 훅은 &lt;code&gt;localStorage&lt;/code&gt;에 직접 의존한다. 테스트할 때 &lt;code&gt;localStorage&lt;/code&gt;를 모킹해야 하고, 나중에 쿠키나 DB로 저장소를 바꾸려면 훅 내부를 수정해야 한다.&lt;/p&gt;
&lt;h3&gt;개선&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// 저장소 추상화
interface StorageAdapter {
  get(key: string): string | null;
  set(key: string, value: string): void;
}

const localStorageAdapter: StorageAdapter = {
  get: (key) =&amp;gt; localStorage.getItem(key),
  set: (key, value) =&amp;gt; localStorage.setItem(key, value),
};

const cookieAdapter: StorageAdapter = {
  get: (key) =&amp;gt; { /* cookie에서 읽기 */ },
  set: (key, value) =&amp;gt; { document.cookie = `${key}=${value}; path=/`; },
};

function useTheme(storage: StorageAdapter = localStorageAdapter) {
  const [theme, setTheme] = useState(() =&amp;gt; storage.get(&amp;#39;theme&amp;#39;) ?? &amp;#39;light&amp;#39;);

  const toggle = () =&amp;gt; {
    const next = theme === &amp;#39;light&amp;#39; ? &amp;#39;dark&amp;#39; : &amp;#39;light&amp;#39;;
    storage.set(&amp;#39;theme&amp;#39;, next);
    setTheme(next);
  };

  return { theme, toggle };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;useTheme&lt;/code&gt;은 &lt;code&gt;StorageAdapter&lt;/code&gt; 인터페이스에만 의존한다. &lt;code&gt;localStorage&lt;/code&gt;든 쿠키든 테스트용 인메모리든, 인터페이스만 맞으면 갈아 끼울 수 있다. 테스트에서 모킹도 간단해진다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;원칙들은 어떻게 연결되는가&lt;/h2&gt;
&lt;p&gt;5가지 원칙은 독립적이지 않다. 실제로 적용하다 보면 하나를 지키면 다른 것도 자연스럽게 따라오는 경우가 많다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SRP를 지키면 ISP가 따라온다.&lt;/strong&gt; 컴포넌트의 책임을 하나로 좁히면, 그 컴포넌트가 필요로 하는 props(인터페이스)도 자연스럽게 작아진다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DIP를 적용하면 OCP가 가능해진다.&lt;/strong&gt; 구현이 아닌 추상에 의존하면, 새로운 구현을 추가할 때 기존 코드를 수정하지 않아도 된다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LSP는 OCP의 전제 조건이다.&lt;/strong&gt; 자식 타입이 부모 타입을 안전하게 대체할 수 없으면, 확장을 위해 기존 코드를 수정해야 하는 상황이 생긴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;SOLID는 &amp;quot;반드시 5개를 다 지켜야 하는 체크리스트&amp;quot;가 아니다. 각 원칙은 특정 문제를 해결하기 위한 지침이고, 코드에 해당 문제가 없으면 억지로 적용할 필요도 없다.&lt;/p&gt;
&lt;p&gt;하지만 &amp;quot;이 컴포넌트가 너무 많은 일을 하고 있지 않나?&amp;quot;, &amp;quot;이 인터페이스가 너무 뚱뚱하지 않나?&amp;quot;, &amp;quot;이 모듈이 특정 구현에 너무 묶여 있지 않나?&amp;quot;라는 질문을 습관적으로 던질 수 있다면, 코드의 유연성과 유지보수성은 자연스럽게 올라간다.&lt;/p&gt;
&lt;p&gt;면접에서 못 대답한 경험이 결국 이 글을 쓰게 만들었다. 다음에는 &amp;quot;예시 하나만 들어주세요&amp;quot;라는 꼬리 질문에도 막힘 없이 답할 수 있을 것 같다.&lt;/p&gt;</description>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/159</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EB%A9%B4%EC%A0%91%EC%97%90%EC%84%9C-SOLID-%EC%9B%90%EC%B9%99%EC%9D%84-%EB%AA%BB-%EB%8C%80%EB%8B%B5%ED%95%9C-%EB%92%A4%EC%97%90-%EB%8B%A4%EC%8B%9C-%EC%A0%95%EB%A6%AC%ED%95%9C-%EA%B8%80#entry159comment</comments>
      <pubDate>Wed, 15 Apr 2026 15:22:59 +0900</pubDate>
    </item>
    <item>
      <title>macOS 홈서버에서 Docker Desktop을 버리고 Colima로 전환한 이유</title>
      <link>https://white-mouse-dev.tistory.com/entry/macOS-%ED%99%88%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-Docker-Desktop%EC%9D%84-%EB%B2%84%EB%A6%AC%EA%B3%A0-Colima%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/ae80cf6d-7307-4720-92ce-b8022ba3584a/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;위 이미지는 Gemini Nano Banana를 통해 제작했습니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Mac mini를 홈서버로 쓰면서, SSH 비대화형 세션에서 &lt;code&gt;docker compose up -d --build&lt;/code&gt;를 실행하려 했다.&lt;br&gt;Docker Desktop의 macOS Keychain 의존성에 막혔고, Colima 전환으로 해결했다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;배경: SSH로 Mac mini를 원격 관리하는 환경&lt;/h2&gt;
&lt;p&gt;Mac mini를 홈서버로 쓰고 있다. 초기 세팅 때만 키보드와 모니터를 연결했고, 이후 모든 작업은 맥북에서 SSH로 접속해서 처리하는 headless 운용 환경이다.&lt;/p&gt;
&lt;p&gt;이 환경에서 자동화하고 싶었던 흐름은 단순했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SSH 접속 → git pull → docker compose up -d --build → 배포 완료&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;CI/CD 파이프라인이든, 자동화 스크립트든, 터미널에서 직접 치든 결국 같은 흐름이다. git fetch, health check까지는 문제없이 동작했다. 그런데 &lt;code&gt;docker compose up -d --build&lt;/code&gt; 단계에서 멈췄다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;문제: Keychain이 SSH 세션을 차단한다&lt;/h2&gt;
&lt;p&gt;에러 메시지는 이랬다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;keychain cannot be accessed because the current session does not allow user interaction.
The keychain may be locked; unlock it by running
&amp;quot;security -v unlock-keychain ~/Library/Keychains/login.keychain-db&amp;quot; and try again&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;원인을 추적하니 이런 구조였다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Docker Desktop은 이미지를 pull할 때 &lt;strong&gt;credential helper&lt;/strong&gt;를 호출한다.&lt;/li&gt;
&lt;li&gt;macOS의 credential helper(&lt;code&gt;docker-credential-osxkeychain&lt;/code&gt;)는 &lt;strong&gt;macOS Keychain&lt;/strong&gt;에 접근한다.&lt;/li&gt;
&lt;li&gt;SSH 비대화형 세션에서는 Keychain UI 상호작용이 &lt;strong&gt;차단&lt;/strong&gt;된다.&lt;/li&gt;
&lt;li&gt;public 이미지(&lt;code&gt;node:22-alpine&lt;/code&gt; 등)를 pull할 때조차 credential helper가 호출된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Docker Desktop은 GUI 세션이 있는 개발자 워크스테이션을 전제로 설계되어 있다. headless SSH 환경은 고려 대상이 아닌 것이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;왜 단순 설정 변경으로는 안 됐나&lt;/h2&gt;
&lt;p&gt;처음엔 간단히 해결할 수 있을 거라 생각했다.&lt;/p&gt;
&lt;h3&gt;시도 0: &lt;code&gt;security unlock-keychain&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;에러 메시지가 직접 제안하는 방법이다. SSH 세션에서 Keychain을 먼저 언락하고 docker를 실행하는 방식.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;security -v unlock-keychain ~/Library/Keychains/login.keychain-db
docker compose up -d --build&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;동작은 한다. 하지만 근본적인 해결이 아니다. 매 SSH 세션마다, 혹은 자동화 스크립트 앞에 매번 keychain 비밀번호를 넘겨야 한다. 비밀번호를 스크립트에 하드코딩하거나 별도 시크릿으로 관리해야 하는데, 그 자체가 보안 리스크이고 불필요한 복잡성이다. Docker를 쓰기 위해 Keychain을 여는 건 본말이 전도된 느낌이다.&lt;/p&gt;
&lt;h3&gt;시도 1: &lt;code&gt;~/.docker/config.json&lt;/code&gt;에서 &lt;code&gt;credsStore&lt;/code&gt; 제거&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;auths&amp;quot;: {},
  &amp;quot;currentContext&amp;quot;: &amp;quot;desktop-linux&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결과: 실패.&lt;/strong&gt; Docker Desktop이 종료/재시작 시 &lt;code&gt;credsStore: &amp;quot;osxkeychain&amp;quot;&lt;/code&gt;을 다시 써넣었다. 설정 파일의 소유권이 사실상 Docker Desktop에 있는 셈이다.&lt;/p&gt;
&lt;h3&gt;시도 2: 격리된 &lt;code&gt;DOCKER_CONFIG&lt;/code&gt;로 우회&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;DOCKER_CONFIG=/tmp/docker-test docker pull node:22-alpine&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결과: 실패.&lt;/strong&gt; Docker Desktop 번들 바이너리(&lt;code&gt;/Applications/Docker.app/.../docker&lt;/code&gt;)가 config와 무관하게 credential helper를 호출했다.&lt;/p&gt;
&lt;h3&gt;시도 3: Homebrew docker CLI 직접 사용&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;/opt/homebrew/bin/docker pull node:22-alpine&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결과: 실패.&lt;/strong&gt; &lt;code&gt;~/.zshenv&lt;/code&gt;에서 Docker Desktop의 PATH가 최상위에 설정되어 있어서 &lt;code&gt;docker-credential-osxkeychain&lt;/code&gt;이 여전히 PATH에서 발견되고 호출되었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# ~/.zshenv 내용
export PATH=&amp;quot;/Applications/Docker.app/Contents/Resources/bin:$PATH&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;문제의 근본은 Docker Desktop이 &lt;strong&gt;세 겹으로&lt;/strong&gt; keychain 의존성을 심어놓는다는 것이었다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;config.json&lt;/code&gt;의 &lt;code&gt;credsStore&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Docker CLI 바이너리 자체&lt;/li&gt;
&lt;li&gt;PATH에 주입된 credential helper 바이너리&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;하나만 우회해서는 다른 경로로 keychain 호출이 발생한다. Docker Desktop을 유지한 채로 keychain 의존성만 끊는 건 사실상 불가능했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;해결: Docker Desktop → Colima 전환&lt;/h2&gt;
&lt;h3&gt;Colima란?&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/abiosoft/colima&quot;&gt;Colima&lt;/a&gt;는 macOS에서 Docker 컨테이너를 실행하기 위한 경량 런타임이다.&lt;/p&gt;
&lt;p&gt;macOS에서는 Docker 컨테이너를 네이티브로 실행할 수 없다 — Linux 커널이 필요하기 때문이다. 그래서 반드시 Linux VM이 필요하고, 그 VM을 누가 관리하느냐의 차이다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Docker Desktop&lt;/th&gt;
&lt;th&gt;Colima&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;VM 관리&lt;/td&gt;
&lt;td&gt;Docker사 자체 VM + GUI&lt;/td&gt;
&lt;td&gt;Lima 기반 경량 VM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GUI&lt;/td&gt;
&lt;td&gt;메뉴바 아이콘, 대시보드&lt;/td&gt;
&lt;td&gt;없음 (CLI only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keychain 의존&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;없음&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH 비대화형 자동화&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;가능&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;호스트 오버헤드&lt;/td&gt;
&lt;td&gt;~1-2GB (Electron GUI + 부가 서비스)&lt;/td&gt;
&lt;td&gt;최소 (CLI 프로세스만)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docker CLI 호환&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100% (동일 CLI)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;라이선스&lt;/td&gt;
&lt;td&gt;대규모 기업 유료&lt;/td&gt;
&lt;td&gt;오픈소스 무료&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;호스트 오버헤드는 VM에 할당하는 메모리와 별개다. Docker Desktop은 Electron 기반 GUI, 자동 업데이트, 확장 기능 등 부가 서비스가 백그라운드에서 돌아가면서 추가 메모리를 소비한다. Colima는 VM과 Docker 데몬만 실행하므로 호스트 측 오버헤드가 훨씬 적다.&lt;/p&gt;
&lt;p&gt;핵심: &lt;strong&gt;기존 &lt;code&gt;docker compose&lt;/code&gt; 명령어가 100% 그대로 동작한다.&lt;/strong&gt; 앱 코드 변경이 필요 없다.&lt;/p&gt;
&lt;h3&gt;왜 OrbStack이 아니라 Colima인가&lt;/h3&gt;
&lt;p&gt;macOS Docker 런타임으로 &lt;a href=&quot;https://orbstack.dev/&quot;&gt;OrbStack&lt;/a&gt;도 좋은 선택지다. GUI가 있으면서도 가볍고, 특히 macOS ↔ 컨테이너 간 파일 시스템 마운트 성능이 Docker Desktop이나 Colima 대비 눈에 띄게 빠르다. 볼륨 마운트가 많은 개발 환경이라면 체감 차이가 크다.&lt;/p&gt;
&lt;p&gt;이번에 Colima를 선택한 이유는 단순하다. headless 홈서버에서 GUI가 필요 없고, 오픈소스 무료이며, &lt;code&gt;brew services&lt;/code&gt;로 부팅 시 자동 시작이 간단하다. OrbStack은 개인 무료이지만 상용 라이선스 구조가 있고, GUI 앱 기반이라 headless 환경에서는 Colima가 더 자연스럽다.&lt;/p&gt;
&lt;h3&gt;전환 과정&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1단계: Colima 및 docker CLI 설치&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;brew install colima docker docker-compose&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2단계: Docker Desktop PATH 제거&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# ~/.zshenv에서 이 줄 제거:
# export PATH=&amp;quot;/Applications/Docker.app/Contents/Resources/bin:$PATH&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;3단계: &lt;code&gt;~/.docker/config.json&lt;/code&gt; 정리&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;auths&amp;quot;: {},
  &amp;quot;currentContext&amp;quot;: &amp;quot;colima&amp;quot;,
  &amp;quot;cliPluginsExtraDirs&amp;quot;: [
    &amp;quot;/opt/homebrew/lib/docker/cli-plugins&amp;quot;
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;credsStore&lt;/code&gt; 항목을 완전히 제거한다. Docker Desktop이 다시 써넣는 것을 방지하기 위해 Docker Desktop 자체를 더 이상 실행하지 않는다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4단계: Docker Desktop 중지, Colima 시작&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;osascript -e &amp;#39;quit app &amp;quot;Docker Desktop&amp;quot;&amp;#39;
colima start --cpu 4 --memory 4 --disk 60&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--cpu&lt;/code&gt;와 &lt;code&gt;--memory&lt;/code&gt;는 Linux VM에 할당할 리소스다. Mac mini의 전체 자원에서 호스트 OS 몫을 남기고 배분하면 된다. 예를 들어 8GB Mac mini라면 VM에 4GB 정도를 주고 나머지를 macOS에 남겨두는 식이다. &lt;code&gt;--disk&lt;/code&gt;는 컨테이너 이미지와 볼륨이 쌓일 공간이므로, 이미지를 여러 개 쓴다면 넉넉하게 잡는 게 좋다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5단계: 부팅 시 자동 시작 설정&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;brew services start colima&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;6단계: 검증&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# SSH 세션에서:
which docker
# /opt/homebrew/bin/docker  (Docker Desktop이 아님)

docker pull node:22-alpine
# 성공 — keychain 에러 없음

docker compose version
# Docker Compose version v2.x.x&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;Ubuntu 서버에서는 왜 이 문제가 없는가&lt;/h2&gt;
&lt;p&gt;Ubuntu(Linux)에서는 Docker Engine이 &lt;strong&gt;OS 커널 위에서 직접&lt;/strong&gt; 실행된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[컨테이너] → Docker Engine → Linux 커널&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;VM도 없고, GUI도 없고, Keychain도 없다. SSH 비대화형 세션에서 &lt;code&gt;docker compose up -d --build&lt;/code&gt;가 아무 문제 없이 동작하는 이유다.&lt;/p&gt;
&lt;p&gt;macOS에서는 구조가 다르다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[컨테이너] → Docker Engine → Linux VM → macOS&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;VM 관리자(Docker Desktop)가 macOS의 GUI/Keychain에 의존하면 SSH 자동화가 막힌다. Colima는 이 의존성 없이 VM을 관리하기 때문에 Linux 서버와 동일한 자동화 경험을 제공한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;런타임&lt;/td&gt;
&lt;td&gt;Docker Desktop&lt;/td&gt;
&lt;td&gt;Colima&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH &lt;code&gt;docker pull&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;keychain 에러&lt;/td&gt;
&lt;td&gt;정상 동작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH &lt;code&gt;docker compose build&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자동화 스크립트/CI 배포&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;부팅 시 자동 시작&lt;/td&gt;
&lt;td&gt;Docker Desktop (GUI 필요)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;brew services&lt;/code&gt; (headless)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;</description>
      <category>Infra</category>
      <category>colima</category>
      <category>docker</category>
      <category>docker desktop</category>
      <category>홈서버</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/158</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/macOS-%ED%99%88%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-Docker-Desktop%EC%9D%84-%EB%B2%84%EB%A6%AC%EA%B3%A0-Colima%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%9C-%EC%9D%B4%EC%9C%A0#entry158comment</comments>
      <pubDate>Thu, 9 Apr 2026 10:59:37 +0900</pubDate>
    </item>
    <item>
      <title>강제 새로고침해도 사이드바가 안 깜빡이게 만들기: Next.js 앱 셸 상태 저장 전략</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EA%B0%95%EC%A0%9C-%EC%83%88%EB%A1%9C%EA%B3%A0%EC%B9%A8%ED%95%B4%EB%8F%84-%EC%82%AC%EC%9D%B4%EB%93%9C%EB%B0%94%EA%B0%80-%EC%95%88-%EA%B9%9C%EB%B9%A1%EC%9D%B4%EA%B2%8C-%EB%A7%8C%EB%93%A4%EA%B8%B0-Nextjs-%EC%95%B1-%EC%85%B8-%EC%83%81%ED%83%9C-%EC%A0%80%EC%9E%A5-%EC%A0%84%EB%9E%B5</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/cb020f12-63a6-4ea4-920a-c8170ac39f03/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;위 이미지는 Gemini Nano Banana를 통해 제작했습니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;사이드바가 잠깐 사라졌다가 다시 나타나는 현상은 작은 디테일처럼 보이지만,&lt;br&gt;앱 셸의 완성도를 크게 떨어뜨린다.&lt;br&gt;이 글은 &amp;quot;사이드바 접힘 상태를 어디에 저장하는 것이 맞는가&amp;quot;를 정리한 기록이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;문제 상황&lt;/h2&gt;
&lt;p&gt;ChatGPT나 Claude처럼 사이드바가 앱의 기본 크롬(chrome) 역할을 하는 서비스에서는, 첫 페인트부터 접힘/펼침 상태가 안정적으로 유지되어야 한다.&lt;/p&gt;
&lt;p&gt;사이드바를 구현하면서 핵심적으로 다룬 문제는 두 가지였다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;강제 새로고침 시 사이드바 상태가 유지되어야 한다.&lt;/li&gt;
&lt;li&gt;첫 페인트에서 사이드바가 안 보였다가 나타나는 hydration flicker가 없어야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;기존 구현에서 생긴 문제&lt;/h2&gt;
&lt;p&gt;처음 구현은 클라이언트 상태 저장 관점에서는 자연스러웠다. &lt;code&gt;Zustand persist&lt;/code&gt;를 써서 &lt;code&gt;localStorage&lt;/code&gt;에 &lt;code&gt;isCollapsed&lt;/code&gt;를 저장하고, 앱이 뜨면 이 값을 복원하는 방식이었다.&lt;/p&gt;
&lt;p&gt;하지만 이 방식에는 구조적인 한계가 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;서버는 &lt;code&gt;localStorage&lt;/code&gt;를 읽을 수 없다.&lt;/li&gt;
&lt;li&gt;따라서 서버는 항상 기본값으로 HTML을 만든다.&lt;/li&gt;
&lt;li&gt;브라우저가 JS를 실행한 뒤에야 실제 저장값이 적용된다.&lt;/li&gt;
&lt;li&gt;그 사이에 서버 HTML과 클라이언트 상태가 어긋나면서 사이드바가 잠깐 숨겨지거나 튀는 현상이 생긴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 문제를 억지로 막으려고 hydration 전까지 사이드바를 &lt;code&gt;visibility: hidden&lt;/code&gt; 처리하면, mismatch warning은 피할 수 있어도 UX는 나빠진다. 결국 &amp;quot;안 보였다가 보이는&amp;quot; 이펙트가 생기기 때문이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;검토했던 선택지&lt;/h2&gt;
&lt;p&gt;사이드바 상태를 어디에 저장할지 고민하면서 떠올릴 수 있는 선택지는 대체로 세 가지다.&lt;/p&gt;
&lt;h3&gt;1. localStorage&lt;/h3&gt;
&lt;p&gt;장점은 구현이 가장 쉽다는 점이다. 서버와 분리된 순수 클라이언트 UI 상태라면 충분히 쓸 만하다.&lt;/p&gt;
&lt;p&gt;하지만 이번 요구사항에는 맞지 않았다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;서버 첫 렌더에서 값을 모른다.&lt;/li&gt;
&lt;li&gt;첫 페인트 일관성을 보장할 수 없다.&lt;/li&gt;
&lt;li&gt;결국 hydration 이후 보정이 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&amp;quot;그러면 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;에서 인라인 스크립트로 &lt;code&gt;localStorage&lt;/code&gt;를 미리 읽어 class를 박으면 되지 않느냐&amp;quot;는 생각도 할 수 있다. 실제로 CSR 기반 앱에서는 유효한 방법이다. 하지만 Next.js App Router 환경에서는 몇 가지 이유로 정석이라고 보기 어렵다.&lt;/p&gt;
&lt;p&gt;App Router는 React Server Components + Streaming SSR을 기본으로 사용한다. 서버에서 HTML이 청크 단위로 스트리밍되는 구조에서, &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;에 삽입한 인라인 스크립트가 Suspense 바운더리보다 먼저 실행되는지 보장하기 어렵다. &lt;code&gt;next/script&lt;/code&gt;의 &lt;code&gt;beforeInteractive&lt;/code&gt; 전략을 사용할 수도 있지만, 이 역시 Pages Router 시절의 설계이고 App Router에서는 root layout의 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;에 직접 넣는 방식과 동작이 미묘하게 다르다. 결과적으로 &amp;quot;DOM이 그려지기 전에 확실히 실행된다&amp;quot;는 보장이 흔들리면, 인라인 스크립트가 flicker를 막을 수도, 못 막을 수도 있는 불안정한 상태가 된다.&lt;/p&gt;
&lt;p&gt;가능한 트릭이지만, 서버가 이미 올바른 HTML을 그려주는 편이 훨씬 안정적이다.&lt;/p&gt;
&lt;h3&gt;2. DB 저장&lt;/h3&gt;
&lt;p&gt;처음에는 너무 과한 선택처럼 보였다. 그런데 이건 요구사항에 따라 평가가 달라진다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;사용자가 어떤 기기에서 접속해도 같은 대시보드 설정을 유지해야 한다.&lt;/li&gt;
&lt;li&gt;폰트, 테마, 위젯 순서, 숨김 상태처럼 계정 자산에 가까운 설정이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이런 종류의 preference는 DB가 맞다. 실제로 대시보드 위젯 배치나 사용자별 설정은 DB 저장이 정당하다.&lt;/p&gt;
&lt;p&gt;다만 사이드바 접힘/펼침은 성격이 조금 다르다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;계정 자산이라기보다 현재 브라우저의 UI chrome state에 가깝다.&lt;/li&gt;
&lt;li&gt;제품 가치보다 편의 상태에 가깝다.&lt;/li&gt;
&lt;li&gt;DB까지 끌어들이기엔 비용 대비 이득이 작다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그래서 이번 요구에는 DB가 1차 해법이 아니었다.&lt;/p&gt;
&lt;h3&gt;3. Cookie&lt;/h3&gt;
&lt;p&gt;결국 이번 문제에는 쿠키가 가장 적절했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;서버가 첫 요청에서 바로 읽을 수 있다.&lt;/li&gt;
&lt;li&gt;서버 HTML을 접힘/펼침 상태에 맞춰 렌더할 수 있다.&lt;/li&gt;
&lt;li&gt;클라이언트는 같은 초기값으로 hydrate하면 된다.&lt;/li&gt;
&lt;li&gt;별도 DB나 Redis 같은 서버 자원을 소비하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, 쿠키는 &amp;quot;서버가 알아야 하지만 굳이 영구 사용자 자산으로 저장할 필요는 없는 UI 상태&amp;quot;에 가장 잘 맞는 저장소였다.&lt;/p&gt;
&lt;p&gt;다만 쿠키도 만능은 아니므로 주의할 점이 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;모든 HTTP 요청에 딸려간다.&lt;/strong&gt; 사이드바 하나는 수 바이트라 문제없지만, 이런 UI 상태를 여러 개 쿠키에 담기 시작하면 매 요청의 헤더 크기가 불필요하게 커진다. API 라우트에까지 전송된다면 낭비다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;4KB 크기 제한.&lt;/strong&gt; 단순 boolean 값은 괜찮지만, 복잡한 UI 설정을 JSON으로 담기에는 한계가 있다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;보안 속성 설정이 필요하다.&lt;/strong&gt; UI 상태라도 &lt;code&gt;SameSite=Lax&lt;/code&gt;, &lt;code&gt;Path=/&lt;/code&gt; 정도는 설정해두는 것이 좋다. 사이드바 상태는 민감 정보가 아니므로 &lt;code&gt;HttpOnly&lt;/code&gt;는 불필요하다 — 클라이언트 JS에서 읽고 써야 하기 때문이다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;다중 탭 동기화가 안 된다.&lt;/strong&gt; &lt;code&gt;localStorage&lt;/code&gt;는 값이 바뀔 때 &lt;code&gt;storage&lt;/code&gt; 이벤트를 발생시켜 다른 브라우저 탭의 상태를 즉시 동기화할 수 있지만, 쿠키에는 이런 메커니즘이 없다. 사이드바 접힘/펼침에서 다중 탭 동기화가 크리티컬한 요구사항인 경우는 드물지만, 만약 필요하다면 &lt;code&gt;BroadcastChannel&lt;/code&gt; API를 병행하여 해결할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;최종 구현&lt;/h2&gt;
&lt;h3&gt;전체 흐름&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;[사용자 토글] → cookie 갱신 + state 갱신
       ↓
[새로고침/재방문] → 서버가 cookie 읽음 → 올바른 HTML 렌더
       ↓
[클라이언트] → 서버와 같은 초기값으로 hydrate → flicker 없음&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;서버: 쿠키를 읽어 초기값 결정&lt;/h3&gt;
&lt;p&gt;Next.js App Router에서는 서버 컴포넌트에서 &lt;code&gt;cookies()&lt;/code&gt;로 바로 읽을 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/layout.tsx (Server Component)
import { cookies } from &amp;#39;next/headers&amp;#39;;

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const sidebarCollapsed = cookieStore.get(&amp;#39;sidebar-collapsed&amp;#39;)?.value === &amp;#39;true&amp;#39;;

  return (
    &amp;lt;html lang=&amp;quot;ko&amp;quot;&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;AppShell initialCollapsed={sidebarCollapsed}&amp;gt;
          {children}
        &amp;lt;/AppShell&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;한 가지 주의할 점이 있다. Next.js App Router에서 &lt;code&gt;cookies()&lt;/code&gt; 함수를 호출하는 순간, 해당 라우트는 정적 렌더링(Static Generation)에서 &lt;strong&gt;동적 렌더링(Dynamic Rendering)으로 강제 전환&lt;/strong&gt;된다. 로그인한 사용자만 접근하는 앱 셸이라면 어차피 동적 렌더링이 필요하므로 문제가 없지만, 랜딩 페이지나 블로그처럼 CDN 캐싱(SSG/ISR)이 필수적인 퍼블릭 페이지의 레이아웃에 이 방식을 적용하면 캐싱의 이점이 사라지고 매 요청마다 서버가 HTML을 새로 그려야 한다. 서비스 성격에 따라 이 trade-off를 반드시 고려해야 한다.&lt;/p&gt;
&lt;h3&gt;클라이언트: 토글 시 state와 cookie를 같이 갱신&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/AppShell.tsx
&amp;#39;use client&amp;#39;;

import { useState, useCallback } from &amp;#39;react&amp;#39;;

interface AppShellProps {
  initialCollapsed: boolean;
  children: React.ReactNode;
}

export function AppShell({ initialCollapsed, children }: AppShellProps) {
  const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);

  const toggleSidebar = useCallback(() =&amp;gt; {
    setIsCollapsed((prev) =&amp;gt; {
      const next = !prev;
      // cookie 갱신 — 다음 SSR에서 서버가 이 값을 읽는다
      document.cookie = `sidebar-collapsed=${next}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
      return next;
    });
  }, []);

  return (
    &amp;lt;div className=&amp;quot;flex h-screen&amp;quot;&amp;gt;
      &amp;lt;aside
        className={`transition-all duration-200 ${
          isCollapsed ? &amp;#39;w-0 overflow-hidden&amp;#39; : &amp;#39;w-64&amp;#39;
        }`}
      &amp;gt;
        {/* 사이드바 내용 */}
      &amp;lt;/aside&amp;gt;

      &amp;lt;div className=&amp;quot;flex-1 flex flex-col&amp;quot;&amp;gt;
        &amp;lt;header&amp;gt;
          &amp;lt;button onClick={toggleSidebar}&amp;gt;
            {isCollapsed ? &amp;#39;☰&amp;#39; : &amp;#39;✕&amp;#39;}
          &amp;lt;/button&amp;gt;
        &amp;lt;/header&amp;gt;
        &amp;lt;main className=&amp;quot;flex-1 overflow-auto&amp;quot;&amp;gt;
          {children}
        &amp;lt;/main&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;핵심은 &lt;code&gt;initialCollapsed&lt;/code&gt;를 서버에서 내려주고, 클라이언트가 이 값을 그대로 초기 state로 사용한다는 점이다. 서버 HTML과 클라이언트 초기값이 동일하므로 hydration mismatch가 발생하지 않는다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;visibility: hidden&lt;/code&gt; 같은 우회 로직도 필요 없다. 서버가 처음부터 올바른 HTML을 그리기 때문이다.&lt;/p&gt;
&lt;h3&gt;왜 Server Actions 대신 &lt;code&gt;document.cookie&lt;/code&gt;를 직접 조작했나&lt;/h3&gt;
&lt;p&gt;Next.js App Router에서는 Server Actions로 서버 측에서 &lt;code&gt;cookies().set()&lt;/code&gt;을 호출하는 방식도 가능하다. 쿠키 조작 코드를 캡슐화할 수 있고, 필요시 &lt;code&gt;revalidatePath&lt;/code&gt;와 자연스럽게 연계할 수 있다는 장점이 있다.&lt;/p&gt;
&lt;p&gt;하지만 사이드바 토글에는 적합하지 않다고 판단했다. Server Action은 서버 왕복이 발생하는데, 사이드바 토글은 사용자가 즉각적인 UI 반응을 기대하는 인터랙션이다. 클릭할 때마다 서버를 다녀오면 체감 latency가 생긴다. 복잡한 데이터 갱신이 필요하거나, 쿠키 변경 후 서버 캐시를 무효화해야 하는 상황이라면 Server Action이 맞지만, 단순한 UI boolean 상태는 클라이언트에서 &lt;code&gt;document.cookie&lt;/code&gt;로 즉시 처리하는 것이 UX 측면에서 훨씬 유리하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;왜 이 방식이 정석에 가깝나&lt;/h2&gt;
&lt;p&gt;이 패턴이 좋은 이유는 상태의 책임이 분리되기 때문이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;서버 책임&lt;/strong&gt;: 첫 렌더를 올바르게 그린다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;클라이언트 책임&lt;/strong&gt;: 사용자 인터랙션에 즉시 반응한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;쿠키 책임&lt;/strong&gt;: 서버와 클라이언트가 공유할 최소한의 preference를 전달한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;반면 &lt;code&gt;localStorage only&lt;/code&gt; 구조는 서버가 모르는 상태를 클라이언트가 나중에 덮어쓰는 방식이라, 앱 셸처럼 레이아웃 크롬이 중요한 영역에는 불리하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;저장소 선택 기준 정리&lt;/h2&gt;
&lt;p&gt;이번 작업을 하면서 기준도 더 명확해졌다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;사이드바 접힘/펼침: &lt;strong&gt;cookie&lt;/strong&gt; — 서버가 알아야 하지만 계정 자산은 아닌 상태&lt;/li&gt;
&lt;li&gt;채팅 입력 draft 같은 일시 상태: &lt;strong&gt;localStorage&lt;/strong&gt; — 서버가 몰라도 되는 상태&lt;/li&gt;
&lt;li&gt;테마, 폰트, 대시보드 위젯 배치/숨김, 기본 워크스페이스: &lt;strong&gt;DB&lt;/strong&gt; — 기기 간 동기화가 필요한 계정 자산&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그리고 첫 페인트가 중요한 DB 기반 설정이라면 실무에서는 &lt;strong&gt;DB + cookie mirror&lt;/strong&gt;도 자주 쓴다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;DB는 source of truth&lt;/li&gt;
&lt;li&gt;cookie는 SSR first paint 최적화&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 조합이 가장 안정적이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;이번 사이드바 개선은 단순히 flicker를 없애는 작업이 아니라, &amp;quot;이 상태를 어디에 저장하는 것이 맞는가&amp;quot;를 다시 정리하는 작업이었다.&lt;/p&gt;
&lt;p&gt;결론은 명확했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;localStorage&lt;/code&gt;는 이 문제의 정답이 아니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DB&lt;/code&gt;는 지금 요구에는 과하다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cookie&lt;/code&gt;가 가장 단순하고, 가장 실용적이며, Next.js 앱 셸 기준으로도 가장 정석적인 선택이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;사이드바 같은 앱 크롬은 사소해 보여도 첫 인상과 체감 품질을 좌우한다. 이런 부분일수록 서버 첫 렌더와 클라이언트 상태 복원이 정확히 맞물리도록 설계하는 것이 중요하다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <category>Flicker</category>
      <category>Next.js</category>
      <category>Sidebar</category>
      <category>사이드바</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/157</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EA%B0%95%EC%A0%9C-%EC%83%88%EB%A1%9C%EA%B3%A0%EC%B9%A8%ED%95%B4%EB%8F%84-%EC%82%AC%EC%9D%B4%EB%93%9C%EB%B0%94%EA%B0%80-%EC%95%88-%EA%B9%9C%EB%B9%A1%EC%9D%B4%EA%B2%8C-%EB%A7%8C%EB%93%A4%EA%B8%B0-Nextjs-%EC%95%B1-%EC%85%B8-%EC%83%81%ED%83%9C-%EC%A0%80%EC%9E%A5-%EC%A0%84%EB%9E%B5#entry157comment</comments>
      <pubDate>Wed, 8 Apr 2026 14:42:35 +0900</pubDate>
    </item>
    <item>
      <title>OSI 7계층, 택배 한 번 시켜보면 이해됩니다!!!</title>
      <link>https://white-mouse-dev.tistory.com/entry/OSI-7%EA%B3%84%EC%B8%B5-%ED%83%9D%EB%B0%B0-%ED%95%9C-%EB%B2%88-%EC%8B%9C%EC%BC%9C%EB%B3%B4%EB%A9%B4-%EC%9D%B4%ED%95%B4%EB%90%A9%EB%8B%88%EB%8B%A4</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/eb06d6cf-e376-4693-9fc3-1cab7bc713c9/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;위 이미지는 Gemini Nano Banana를 통해 제작했습니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;면접에서 &amp;quot;OSI 7계층 설명해주세요&amp;quot;라고 물으면, 많은 사람이 1계층부터 순서대로 외운 내용을 말합니다.&lt;br&gt;그런데 &amp;quot;그래서 개발할 때 왜 알아야 하나요?&amp;quot;라고 묻는 순간 답이 흐려지는 경우가 많습니다.&lt;br&gt;중요한 건 계층 이름을 암기하는 것이 아니라, &lt;strong&gt;내가 겪는 문제가 어느 층위의 문제인지 구분할 수 있는 감각&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;OSI 7계층이란?&lt;/h2&gt;
&lt;p&gt;OSI(Open Systems Interconnection) 7계층은 국제표준화기구(ISO)에서 제시한 &lt;strong&gt;네트워크 통신 참조 모델&lt;/strong&gt;입니다.&lt;br&gt;네트워크 통신 과정을 7개의 역할로 나누어 바라보는 틀이라고 보면 됩니다.&lt;/p&gt;
&lt;p&gt;이 모델의 핵심은 “현실의 인터넷이 정확히 이렇게 구현되어 있다”가 아니라,&lt;br&gt;&lt;strong&gt;통신 과정에서 어떤 책임과 역할들이 존재하는지 구조적으로 이해하도록 돕는 것&lt;/strong&gt;에 있습니다.&lt;/p&gt;
&lt;p&gt;말이 추상적으로 들릴 수 있으니, 해외 직구 택배를 예로 들어 보겠습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;해외 직구로 보는 7계층&lt;/h2&gt;
&lt;p&gt;미국 Amazon에서 키보드를 하나 주문한다고 상상해봅시다.&lt;br&gt;&amp;quot;주문하기&amp;quot; 버튼을 누른 순간부터 물건이 현관 앞에 도착하기까지의 흐름은, OSI 7계층을 이해하는 데 꽤 좋은 비유가 됩니다.&lt;/p&gt;
&lt;h3&gt;7계층 — 응용 계층 (Application Layer)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;택배 비유&lt;/strong&gt;: 쇼핑몰 앱을 열고 상품을 고른 뒤 &amp;quot;주문하기&amp;quot; 버튼을 누르는 단계&lt;/p&gt;
&lt;p&gt;사용자가 직접 접하는 계층입니다.&lt;br&gt;브라우저, 메신저, 이메일 클라이언트 같은 애플리케이션이 여기서 동작하며,&lt;br&gt;&lt;code&gt;HTTP&lt;/code&gt;, &lt;code&gt;SMTP&lt;/code&gt;, &lt;code&gt;FTP&lt;/code&gt; 같은 프로토콜도 보통 이 계층에서 다룹니다.&lt;/p&gt;
&lt;p&gt;즉, “사용자가 무엇을 하려는가”가 가장 직접적으로 드러나는 층입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;6계층 — 표현 계층 (Presentation Layer)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;택배 비유&lt;/strong&gt;: 주문서를 상대가 이해할 수 있는 형식으로 바꾸고, 필요하면 내용을 암호화하거나 압축하는 단계&lt;/p&gt;
&lt;p&gt;이 계층은 데이터의 &lt;strong&gt;표현 방식&lt;/strong&gt;을 다룹니다.&lt;br&gt;예를 들어 문자열 인코딩, 직렬화, 암호화, 압축 같은 작업이 여기에 해당합니다.&lt;/p&gt;
&lt;p&gt;예시:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;UTF-8&lt;/code&gt; 인코딩&lt;/li&gt;
&lt;li&gt;JSON 직렬화&lt;/li&gt;
&lt;li&gt;압축&lt;/li&gt;
&lt;li&gt;암호화/복호화&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;다만 실무에서는 이 계층이 독립적으로 드러나기보다, 애플리케이션 내부 책임으로 함께 처리되는 경우가 많습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;5계층 — 세션 계층 (Session Layer)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;택배 비유&lt;/strong&gt;: 판매자와 구매자 사이에 거래 흐름을 유지하고, 중간에 끊기면 다시 이어갈 수 있도록 관리하는 단계&lt;/p&gt;
&lt;p&gt;세션 계층은 통신의 &lt;strong&gt;시작, 유지, 종료&lt;/strong&gt;와 관련된 책임을 다룹니다.&lt;br&gt;“대화가 어디까지 진행되었는지”, “끊겼을 때 다시 이어갈 수 있는지” 같은 맥락입니다.&lt;/p&gt;
&lt;p&gt;예를 들어 장시간 유지되는 연결, 세션 관리, 대화 상태 유지 같은 개념을 이해할 때 도움이 됩니다.&lt;/p&gt;
&lt;p&gt;다만 현실의 웹 개발에서는 이 역할도 별도 계층으로 분리되기보다,&lt;br&gt;프레임워크, 애플리케이션 로직, 프록시 설정, 인증 체계 등 여러 레벨에 나뉘어 구현되는 경우가 많습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;4계층 — 전송 계층 (Transport Layer)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;택배 비유&lt;/strong&gt;: 물건을 여러 박스로 나누고, 각 박스가 제대로 도착했는지 확인하는 단계&lt;/p&gt;
&lt;p&gt;전송 계층은 양 끝단 사이의 데이터 전달을 담당합니다.&lt;br&gt;신뢰성, 순서 보장, 흐름 제어, 재전송 같은 개념이 여기서 중요합니다.&lt;/p&gt;
&lt;p&gt;대표적으로:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;TCP&lt;/code&gt;: 순서 보장, 재전송, 흐름 제어&lt;/li&gt;
&lt;li&gt;&lt;code&gt;UDP&lt;/code&gt;: 연결 설정과 보장 기능이 단순하고 가벼움&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;흔히 TCP는 “확실하게 전달하는 방식”, UDP는 “가볍게 빠르게 전달하는 방식”으로 이해하면 입문에는 충분합니다.&lt;/p&gt;
&lt;p&gt;다만 여기서 중요한 점이 하나 있습니다.&lt;br&gt;&lt;code&gt;UDP = 신뢰성 없음&lt;/code&gt;, &lt;code&gt;TCP = 무조건 정답&lt;/code&gt;으로만 외우면 현대 웹을 설명하기 어렵습니다.&lt;/p&gt;
&lt;p&gt;예를 들어 HTTP/3는 UDP 기반의 &lt;code&gt;QUIC&lt;/code&gt; 위에서 동작합니다.&lt;br&gt;QUIC은 UDP 위에 신뢰성, 흐름 제어, 연결 관리, 멀티플렉싱 등의 기능을 구현하여 TCP 기반 HTTP/2의 일부 한계를 줄였습니다.&lt;br&gt;즉, UDP 자체는 단순하지만 그 위에 어떤 메커니즘을 쌓느냐에 따라 충분히 고도화된 통신을 만들 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;3계층 — 네트워크 계층 (Network Layer)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;택배 비유&lt;/strong&gt;: 미국 → 인천공항 → 서울 → 우리 동네처럼, 목적지까지 어떤 경로로 보낼지 결정하는 단계&lt;/p&gt;
&lt;p&gt;네트워크 계층은 목적지까지 데이터를 보내기 위한 &lt;strong&gt;경로 선택과 라우팅&lt;/strong&gt;을 담당합니다.&lt;br&gt;&lt;code&gt;IP 주소&lt;/code&gt;가 목적지 주소 역할을 하고, 라우터가 중간 경로를 결정합니다.&lt;/p&gt;
&lt;p&gt;즉, “어디로 보내야 하는가”의 문제를 다루는 계층입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;2계층 — 데이터 링크 계층 (Data Link Layer)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;택배 비유&lt;/strong&gt;: 물류 허브 내부에서 어떤 트럭으로 박스를 옮길지 정하는 단계&lt;/p&gt;
&lt;p&gt;데이터 링크 계층은 같은 네트워크 구간 안에서 데이터를 프레임 단위로 전달하고,&lt;br&gt;장비 간 식별과 오류 검출 같은 역할을 담당합니다.&lt;/p&gt;
&lt;p&gt;예를 들어:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;MAC 주소&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;스위치&lt;/li&gt;
&lt;li&gt;이더넷 프레임&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;같은 건 이 계층의 감각으로 이해할 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;1계층 — 물리 계층 (Physical Layer)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;택배 비유&lt;/strong&gt;: 실제로 트럭이 도로를 달리고, 비행기가 바다를 건너는 단계&lt;/p&gt;
&lt;p&gt;물리 계층은 데이터를 실제 신호로 바꾸어 전달하는 계층입니다.&lt;br&gt;전기 신호, 광 신호, 무선 주파수, 케이블, 광섬유, 안테나 등이 여기에 해당합니다.&lt;/p&gt;
&lt;p&gt;아무리 애플리케이션 코드가 완벽해도, 물리적인 연결이 끊겨 있으면 통신은 성립하지 않습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;OSI 7계층 vs TCP/IP 4계층: 이론과 현실&lt;/h2&gt;
&lt;p&gt;여기서 꼭 짚고 넘어가야 할 점이 있습니다.&lt;/p&gt;
&lt;p&gt;OSI 7계층은 &lt;strong&gt;역할을 세분화한 참조 모델&lt;/strong&gt;이고,&lt;br&gt;실제 인터넷은 보통 &lt;strong&gt;TCP/IP 4계층 모델&lt;/strong&gt;로 설명합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;OSI 7계층              TCP/IP 4계층
─────────────────────────────────────
7. 응용 (Application)  ┐
6. 표현 (Presentation) ├→ Application
5. 세션 (Session)      ┘
4. 전송 (Transport)    → Transport
3. 네트워크 (Network)  → Internet
2. 데이터 링크         ┐
1. 물리               ┘→ Network Access&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, TCP/IP 모델에서는 OSI의 5·6·7계층을 엄격히 따로 나누기보다&lt;br&gt;보통 애플리케이션 계층에서 함께 다룹니다.&lt;/p&gt;
&lt;p&gt;하지만 그렇다고 해서 세션 관리, 데이터 표현, 애플리케이션 프로토콜이라는 &lt;strong&gt;책임 자체가 사라지는 것은 아닙니다.&lt;/strong&gt;&lt;br&gt;단지 현실의 구현에서는 그것들이 별도 계층으로 분리되어 드러나지 않을 뿐입니다.&lt;/p&gt;
&lt;p&gt;이 차이를 이해하면,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;OSI는 “역할을 정리하는 사고 틀”&lt;/li&gt;
&lt;li&gt;TCP/IP는 “현실 구현을 설명하는 모델”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이라고 받아들이기 쉬워집니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;왜 계층을 나누는가&lt;/h2&gt;
&lt;p&gt;OSI 7계층을 배우는 이유는 크게 세 가지입니다.&lt;/p&gt;
&lt;h3&gt;1. 표준화&lt;/h3&gt;
&lt;p&gt;서로 다른 시스템과 장비가 공통 규칙 위에서 통신할 수 있도록 하기 위해서입니다.&lt;br&gt;운영체제나 제조사가 달라도 통신이 가능한 이유는, 각자 비슷한 계층적 규약을 따르기 때문입니다.&lt;/p&gt;
&lt;h3&gt;2. 관심사의 분리&lt;/h3&gt;
&lt;p&gt;각 계층이 자기 역할에 집중하면 변경의 영향 범위를 줄일 수 있습니다.&lt;br&gt;예를 들어 유선 네트워크를 무선으로 바꿔도, 애플리케이션의 HTTP 로직 자체는 그대로 유지될 수 있습니다.&lt;/p&gt;
&lt;h3&gt;3. 문제 해결 범위 축소&lt;/h3&gt;
&lt;p&gt;장애가 생겼을 때 “이게 라우팅 문제인지, 포트 문제인지, 애플리케이션 설정 문제인지”를 나눠 생각할 수 있습니다.&lt;br&gt;실무에서 OSI 모델의 가장 큰 가치는 사실 여기에 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;개발자에게 왜 중요한가&lt;/h2&gt;
&lt;p&gt;OSI 7계층은 면접용 암기 주제가 아니라,&lt;br&gt;&lt;strong&gt;문제를 어디서부터 의심해야 하는지 판단하는 기준&lt;/strong&gt;을 줍니다.&lt;/p&gt;
&lt;p&gt;다만 주의할 점이 있습니다.&lt;br&gt;현대 웹 개발에서는 모든 문제를 OSI에 1:1로 깔끔하게 매핑하기 어렵습니다.&lt;br&gt;따라서 아래 예시들은 “정답 분류표”라기보다,&lt;br&gt;&lt;strong&gt;이 문제가 어떤 성격의 문제인지 감을 잡기 위한 실무형 가이드&lt;/strong&gt;로 보는 편이 더 정확합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;프론트엔드에서 마주치는 문제들&lt;/h2&gt;
&lt;p&gt;브라우저에서 &lt;code&gt;fetch()&lt;/code&gt;를 호출한다고 해봅시다.&lt;br&gt;겉으로는 함수 한 줄이지만, 내부적으로는 HTTP 요청, 연결 수립, 암호화, 라우팅, 물리적 전달까지 여러 층위를 거칩니다.&lt;/p&gt;
&lt;h3&gt;CORS 에러 — 애플리케이션/브라우저 정책 레벨의 문제&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; 헤더가 맞지 않아 브라우저가 응답 접근을 차단하는 경우입니다.&lt;/p&gt;
&lt;p&gt;중요한 포인트는, 이건 보통 &lt;strong&gt;네트워크 통신 실패 자체가 아니라는 점&lt;/strong&gt;입니다.&lt;br&gt;서버는 정상적으로 응답했을 수도 있고, TCP 연결도 성공했을 수 있습니다.&lt;br&gt;다만 브라우저라는 애플리케이션이 보안 정책에 따라 응답 접근을 막는 것입니다.&lt;/p&gt;
&lt;p&gt;그래서 이런 문제는 “네트워크가 안 된다”보다,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;서버의 CORS 헤더&lt;/li&gt;
&lt;li&gt;API Gateway 응답 정책&lt;/li&gt;
&lt;li&gt;프록시 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;같은 부분을 먼저 보는 것이 맞습니다.&lt;/p&gt;
&lt;h3&gt;TLS 인증서 오류 — 보안 계층 또는 암호화 처리 문제&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;https://&lt;/code&gt; 접속 시 인증서 경고가 뜨는 경우입니다.&lt;/p&gt;
&lt;p&gt;교과서적으로는 표현 계층이나 그 인접 영역으로 설명하기도 하지만,&lt;br&gt;실무에서는 TLS/SSL을 별도의 보안 레이어처럼 다루는 경우가 많습니다.&lt;/p&gt;
&lt;p&gt;따라서 실제 디버깅에서는&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;인증서 만료 여부&lt;/li&gt;
&lt;li&gt;체인 인증서 누락&lt;/li&gt;
&lt;li&gt;로드밸런서 또는 웹 서버 설정&lt;/li&gt;
&lt;li&gt;클라이언트의 신뢰 루트 CA&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;같은 항목을 확인하게 됩니다.&lt;/p&gt;
&lt;h3&gt;WebSocket 연결 끊김 — 연결 유지와 인프라 설정의 경계 문제&lt;/h3&gt;
&lt;p&gt;실시간 채팅이나 알림 서비스에서 일정 시간 후 연결이 끊기는 경우가 있습니다.&lt;/p&gt;
&lt;p&gt;이 문제는 세션 유지 관점으로 설명할 수 있지만, 실무에서는 주로&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;프록시 idle timeout&lt;/li&gt;
&lt;li&gt;로드밸런서 설정&lt;/li&gt;
&lt;li&gt;서버 keepalive 정책&lt;/li&gt;
&lt;li&gt;재연결 로직 누락&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;같은 식으로 접근합니다.&lt;/p&gt;
&lt;p&gt;즉, 개념적으로는 세션의 감각과 닿아 있지만, 실제 해결은 애플리케이션과 인프라의 경계에서 이뤄지는 경우가 많습니다.&lt;/p&gt;
&lt;h3&gt;연결 자체가 안 되는 경우 — 전송 계층 문제를 먼저 의심&lt;/h3&gt;
&lt;p&gt;예를 들어:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;net::ERR_CONNECTION_REFUSED&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;특정 포트에 연결 자체가 되지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이런 상황은 애플리케이션 로직 이전에 &lt;strong&gt;TCP 연결 성립 여부&lt;/strong&gt;를 먼저 봐야 합니다.&lt;br&gt;서버가 해당 포트에서 리슨 중인지, 방화벽이나 보안 설정이 막고 있지는 않은지 확인해야 합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;백엔드에서 마주치는 문제들&lt;/h2&gt;
&lt;p&gt;백엔드는 프론트엔드보다 더 넓은 범위의 계층과 직접 맞닿아 있습니다.&lt;/p&gt;
&lt;h3&gt;502 Bad Gateway — “응용 계층”으로 단정하면 위험한 문제&lt;/h3&gt;
&lt;p&gt;502는 프록시나 로드밸런서가 업스트림으로부터 올바른 응답을 받지 못했을 때 발생합니다.&lt;/p&gt;
&lt;p&gt;여기서 중요한 건, 이를 단순히 “L7 문제”라고 단정하면 시야가 좁아질 수 있다는 점입니다.&lt;br&gt;실제 원인은 다양합니다.&lt;/p&gt;
&lt;p&gt;예를 들어:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;업스트림 서버 프로세스 비정상 종료&lt;/li&gt;
&lt;li&gt;포트 미오픈&lt;/li&gt;
&lt;li&gt;타임아웃 불일치&lt;/li&gt;
&lt;li&gt;잘못된 프록시 설정&lt;/li&gt;
&lt;li&gt;헬스체크 실패&lt;/li&gt;
&lt;li&gt;TLS 핸드셰이크 문제&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, 502는 애플리케이션과 인프라의 경계에서 자주 발생하는 대표적인 장애입니다.&lt;br&gt;“HTTP 에러니까 L7”이라고만 보면 원인을 놓치기 쉽습니다.&lt;/p&gt;
&lt;h3&gt;DB 커넥션 풀 고갈 — 애플리케이션 자원 관리 문제&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Too many connections&lt;/code&gt;, 커넥션 풀 고갈, 커넥션 leak 같은 문제는&lt;br&gt;겉으로 보면 “세션”처럼 느껴질 수 있지만, 보통은 애플리케이션의 자원 관리 문제에 가깝습니다.&lt;/p&gt;
&lt;p&gt;즉,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;커넥션 풀 크기&lt;/li&gt;
&lt;li&gt;idle timeout&lt;/li&gt;
&lt;li&gt;leak detection&lt;/li&gt;
&lt;li&gt;트랜잭션 종료 누락&lt;/li&gt;
&lt;li&gt;커넥션 반환 누락&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;같은 항목을 먼저 점검해야 합니다.&lt;/p&gt;
&lt;p&gt;네트워크 개념과 닿아 있긴 하지만, 실무에서는 보통 프레임워크와 런타임 설정 문제로 다루게 됩니다.&lt;/p&gt;
&lt;h3&gt;L4 / L7 로드밸런서 — 어느 레벨에서 분산하는지의 차이&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;L4 로드밸런서: TCP/UDP 수준에서 분산&lt;/li&gt;
&lt;li&gt;L7 로드밸런서: HTTP 헤더, 경로, 호스트 기반 분산&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 차이를 이해하면 장애를 볼 때도 도움이 됩니다.&lt;/p&gt;
&lt;p&gt;예를 들어 헬스체크가 실패했을 때,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TCP 연결 자체가 안 되는 문제인지&lt;/li&gt;
&lt;li&gt;HTTP 응답이 기대 형식과 달라 실패하는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;를 구분해서 볼 수 있기 때문입니다.&lt;/p&gt;
&lt;h3&gt;서버 간 통신 불가 — 네트워크 계층과 클라우드 설정 문제&lt;/h3&gt;
&lt;p&gt;마이크로서비스 환경에서 서비스 A가 서비스 B를 호출하지 못하는 경우,&lt;br&gt;실무에서는 주로 다음을 봅니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;보안 그룹(Security Group)&lt;/li&gt;
&lt;li&gt;NACL&lt;/li&gt;
&lt;li&gt;라우팅 테이블&lt;/li&gt;
&lt;li&gt;서브넷 구성&lt;/li&gt;
&lt;li&gt;서비스 디스커버리&lt;/li&gt;
&lt;li&gt;내부 DNS&lt;/li&gt;
&lt;li&gt;포트 바인딩 상태&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이건 전형적으로 “목적지까지 도달 가능한가”의 문제이므로 네트워크 계층 감각이 중요합니다.&lt;/p&gt;
&lt;p&gt;다만 클라우드에서는 전통적인 물리 네트워크보다 더 많은 부분이 추상화되어 있기 때문에,&lt;br&gt;온프레미스에서 말하던 L2/L3 감각을 그대로 가져오기보다 &lt;strong&gt;클라우드가 제공하는 논리 네트워크 구성 요소&lt;/strong&gt;로 해석하는 편이 실무적입니다.&lt;/p&gt;
&lt;h3&gt;같은 네트워크 구간 문제 — 온프레미스에서는 L2 이슈도 의미가 있음&lt;/h3&gt;
&lt;p&gt;온프레미스 환경에서는&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ARP&lt;/li&gt;
&lt;li&gt;VLAN&lt;/li&gt;
&lt;li&gt;스위치 설정&lt;/li&gt;
&lt;li&gt;NIC 이슈&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;같은 데이터 링크 계층 문제가 실제 장애 원인이 되기도 합니다.&lt;/p&gt;
&lt;p&gt;반면 클라우드 VPC 환경에서는 사용자가 이런 L2 문제를 직접 다루는 경우가 드물기 때문에,&lt;br&gt;같은 “통신 불가”라도 온프레미스와 클라우드는 접근 방식이 달라질 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;실전 디버깅 흐름: &amp;quot;API 호출이 안 돼요&amp;quot;&lt;/h2&gt;
&lt;p&gt;누군가 “API 호출이 안 됩니다”라고 말했을 때,&lt;br&gt;계층을 아래에서 위로 올라가며 확인하면 문제 범위를 빠르게 줄일 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1계층: 물리적 연결은 살아 있나?
       - 네트워크 연결 자체는 정상인가?

2~3계층: 목적지까지 도달 가능한가?
       - IP/라우팅 문제는 없는가?
       - 클라우드라면 SG, NACL, Route Table은 정상인가?
       - DNS 이름 해석은 정상인가?

4계층: 포트 연결이 가능한가?
       - 해당 포트가 열려 있는가?
       - 서버가 실제로 리슨 중인가?

5~7계층 또는 애플리케이션 레벨:
       - HTTP 요청/응답은 정상인가?
       - 상태 코드는 무엇인가?
       - 인증, CORS, 헤더, 쿠키, 세션은 정상인가?
       - TLS 인증서는 유효한가?&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실무에서는 이 과정을 꼭 OSI 교과서처럼 부르지 않더라도,&lt;br&gt;대체로 다음 순서로 생각하게 됩니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;도달 자체가 되나?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;포트가 열려 있나?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;프로토콜 레벨 응답이 오나?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;애플리케이션 정책이나 인증 문제는 없나?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;같이 보면 좋은 기본 도구&lt;/h3&gt;
&lt;p&gt;입문자에게는 &lt;code&gt;ping&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt; 정도만 알아도 큰 도움이 되지만,&lt;br&gt;실무에서는 아래 도구들도 자주 사용합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ping&lt;/code&gt;: ICMP 응답 확인&lt;br&gt;※ 단, 클라우드에서는 기본 차단되어 있을 수 있으므로 실패만으로 단정하면 안 됨&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nc&lt;/code&gt; 또는 &lt;code&gt;telnet&lt;/code&gt;: 포트 연결 확인&lt;/li&gt;
&lt;li&gt;&lt;code&gt;curl -v&lt;/code&gt;: HTTP 요청/응답, 헤더, TLS 흐름 확인&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dig&lt;/code&gt;, &lt;code&gt;nslookup&lt;/code&gt;: DNS 확인&lt;/li&gt;
&lt;li&gt;&lt;code&gt;openssl s_client&lt;/code&gt;: 인증서 및 TLS 핸드셰이크 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;핵심은 도구를 많이 아는 것이 아니라,&lt;br&gt;&lt;strong&gt;지금 내가 확인하려는 것이 어느 층위의 문제인지 알고 적절한 도구를 고르는 것&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;OSI 7계층은 시험용 암기 목록이 아닙니다.&lt;br&gt;실제 인터넷은 TCP/IP 모델과 각종 구현체 위에서 돌아가지만,&lt;br&gt;OSI 모델은 여전히 문제를 구조적으로 바라보는 데 유용한 사고 틀을 제공합니다.&lt;/p&gt;
&lt;p&gt;개발자에게 정말 중요한 건,&lt;br&gt;각 계층을 완벽하게 외우는 것이 아니라 다음 두 가지입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;내 코드가 어느 층위에서 동작하는지 아는 것&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;문제가 생겼을 때 어느 층위부터 의심해야 하는지 아는 것&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;택배가 늦게 도착했을 때도 생각은 비슷합니다.&lt;br&gt;주문이 누락된 건지, 주소가 잘못된 건지, 중간 허브에서 꼬인 건지, 배송 차량이 멈춘 건지를 구분해야 원인을 찾을 수 있습니다.&lt;/p&gt;
&lt;p&gt;네트워크도 마찬가지입니다.&lt;br&gt;문제를 계층적으로 나눠 생각할 수 있으면, 디버깅 속도와 정확도는 확실히 달라집니다.&lt;/p&gt;</description>
      <category>Infra</category>
      <category>OSI</category>
      <category>OSI 7계층</category>
      <category>TCP/IP</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/156</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/OSI-7%EA%B3%84%EC%B8%B5-%ED%83%9D%EB%B0%B0-%ED%95%9C-%EB%B2%88-%EC%8B%9C%EC%BC%9C%EB%B3%B4%EB%A9%B4-%EC%9D%B4%ED%95%B4%EB%90%A9%EB%8B%88%EB%8B%A4#entry156comment</comments>
      <pubDate>Tue, 7 Apr 2026 17:45:35 +0900</pubDate>
    </item>
    <item>
      <title>AI 데이터 처리 용어 정리: &amp;quot;증강? 합성? 오버샘플링? 다 뭐가 다른 거야?&amp;quot;</title>
      <link>https://white-mouse-dev.tistory.com/entry/AI-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%B2%98%EB%A6%AC-%EC%9A%A9%EC%96%B4-%EC%A0%95%EB%A6%AC-%EC%A6%9D%EA%B0%95-%ED%95%A9%EC%84%B1-%EC%98%A4%EB%B2%84%EC%83%98%ED%94%8C%EB%A7%81-%EB%8B%A4-%EB%AD%90%EA%B0%80-%EB%8B%A4%EB%A5%B8-%EA%B1%B0%EC%95%BC</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/40cd12c5-ee4b-44c1-ab17-6e611e984091/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;※ 본 썸네일은 나노바나나 AI를 통해 생성된 합성 데이터입니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;quot;손상된 옷 이미지가 100장밖에 없는데, 어떻게 학습시키지?&amp;quot;&lt;/p&gt;
&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;의류 품질 검사 AI 프로젝트를 진행하면서 만난 현실적인 문제다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;데이터 현황:
- 정상 의류: 10,000장
- 손상 의류: 100장
- 오염 의류: 50장

문제: 극심한 클래스 불균형&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;팀 회의에서 나온 해결책들:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A: &amp;quot;회전시키고 노이즈 주면 되지 않나요?&amp;quot;
B: &amp;quot;나노바나나로 생성하면 되잖아요.&amp;quot;
C: &amp;quot;그냥 복사해서 늘리면 안 돼요?&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;다들 맞는 말인데, &lt;strong&gt;정확한 용어를 몰라서&lt;/strong&gt; 소통이 어려웠다.&lt;/p&gt;
&lt;p&gt;오늘은 AI 데이터 처리에서 가장 헷갈리는 3가지 개념을 정리한다.&lt;/p&gt;
&lt;h2&gt;핵심 용어 3가지&lt;/h2&gt;
&lt;h3&gt;1. Data Augmentation (데이터 증강)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;정의:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;기존 데이터를 &amp;quot;변형&amp;quot;해서 다양성을 늘리는 기법&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;핵심:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;❌ 새로운 데이터 생성 (X)
✅ 기존 데이터 변형 (O)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;예시:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 원본 이미지 1장
original_image.jpg

# Augmentation 적용
↓
rotated_15deg.jpg       # 15도 회전
flipped_horizontal.jpg  # 좌우 반전
with_noise.jpg          # 노이즈 추가
brightness_+20.jpg      # 밝기 조정

결과: 1장 → 5가지 변형
(하지만 모두 같은 원본에서 파생)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;구현 예시:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# PyTorch
from torchvision import transforms

augmentation = transforms.Compose([
    transforms.RandomRotation(15),           # 랜덤 회전
    transforms.RandomHorizontalFlip(0.5),    # 50% 확률 좌우 반전
    transforms.ColorJitter(                  # 색상 변형
        brightness=0.2,
        contrast=0.2,
        saturation=0.2
    ),
    transforms.GaussianBlur(3),              # 가우시안 블러
])

augmented_image = augmentation(original_image)&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Ultralytics YOLO
# augment.yaml
augmentation:
  hsv_h: 0.015    # Hue (색조)
  hsv_s: 0.7      # Saturation (채도)
  hsv_v: 0.4      # Value (명도)
  degrees: 15.0   # 회전 각도
  translate: 0.1  # 이동
  scale: 0.5      # 크기 변경
  shear: 0.0      # 전단 변환
  perspective: 0.0
  flipud: 0.5     # 상하 반전 확률
  fliplr: 0.5     # 좌우 반전 확률
  mosaic: 1.0     # 모자이크 증강
  mixup: 0.0      # Mixup 증강&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2가지 방식:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Online Augmentation (실시간 증강)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 학습 중 매 에폭마다 실시간 적용
for epoch in range(100):
    for batch in dataloader:
        # 매번 다른 변형 적용
        augmented_batch = augment(batch)
        loss = model.train(augmented_batch)&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;장점:
✅ 디스크 공간 절약
✅ 무한한 변형 (에폭마다 다름)
✅ 메모리 효율적

단점:
❌ 학습 속도 약간 느림 (변형 오버헤드)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Offline Augmentation (사전 증강)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 학습 전에 미리 변형 이미지 생성
for image in original_images:
    for i in range(5):  # 이미지당 5개 변형
        augmented = augment(image)
        save(f&amp;quot;{image_name}_aug_{i}.jpg&amp;quot;)

# 학습 시에는 변형된 이미지 사용
model.train(augmented_images)&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;장점:
✅ 학습 속도 빠름 (변형 미리 완료)
✅ 재현 가능 (같은 변형)

단점:
❌ 디스크 공간 많이 사용
❌ 변형 개수 고정&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Augmentation의 목적:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 과적합 방지
   - 훈련 데이터만 외우는 것 방지
   - 일반화 성능 향상

2. 데이터 다양성 증가
   - 다양한 각도, 조명 학습
   - 실전 환경 대응력 향상

3. 모델 강건성 (Robustness)
   - 노이즈에 강한 모델
   - 변형에 덜 민감한 예측&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;주의사항:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# ❌ 나쁜 예: 과도한 증강
augmentation = transforms.Compose([
    transforms.RandomRotation(180),  # 180도 회전 (상하 뒤집힘)
    transforms.ColorJitter(brightness=0.9),  # 너무 밝게
    transforms.GaussianNoise(std=0.5)  # 노이즈 과다
])
# → 원본과 너무 달라져서 오히려 성능 저하

# ✅ 좋은 예: 적절한 수준
augmentation = transforms.Compose([
    transforms.RandomRotation(15),   # 자연스러운 범위
    transforms.ColorJitter(brightness=0.2),  # 미세 조정
    transforms.GaussianNoise(std=0.01)  # 미세 노이즈
])&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;2. Synthetic Data Generation (합성 데이터 생성)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;정의:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;실제로 존재하지 않는 &amp;quot;새로운&amp;quot; 데이터를 생성하는 기법&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;핵심:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;❌ 기존 데이터 변형 (X)
✅ 완전히 새로운 데이터 생성 (O)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Augmentation과의 차이:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Augmentation (증강):
원본: 고양이 사진
결과: 회전된 고양이, 밝은 고양이, 노이즈 낀 고양이
→ 같은 고양이의 변형

Synthesis (합성):
입력: &amp;quot;고양이 사진&amp;quot;
결과: AI가 생성한 전혀 새로운 고양이
→ 다른 고양이&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;생성 방법 3가지:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1) Generative AI (생성형 AI)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Stable Diffusion
from diffusers import StableDiffusionPipeline

pipe = StableDiffusionPipeline.from_pretrained(
    &amp;quot;stabilityai/stable-diffusion-2-1&amp;quot;
)

# 텍스트로 이미지 생성
prompt = &amp;quot;damaged denim jeans with torn hole on knee&amp;quot;
image = pipe(prompt).images[0]&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# DALL-E API
import openai

response = openai.Image.create(
    prompt=&amp;quot;stained white t-shirt with coffee spill&amp;quot;,
    n=10,
    size=&amp;quot;1024x1024&amp;quot;
)

for i, image_url in enumerate(response[&amp;#39;data&amp;#39;]):
    download(image_url, f&amp;quot;synthetic_stain_{i}.jpg&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2) 3D Rendering (3D 렌더링)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Blender Python API
import bpy

# 3D 모델 로드
bpy.ops.import_scene.obj(filepath=&amp;quot;tshirt.obj&amp;quot;)

# 손상 텍스처 적용
damage_texture = bpy.data.images.load(&amp;quot;damage_pattern.png&amp;quot;)

# 다양한 각도에서 렌더링
for angle in range(0, 360, 30):
    camera.rotation_euler[2] = angle
    bpy.ops.render.render(write_still=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;3) Cut-and-Paste (잘라붙이기)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import cv2
import numpy as np

# 정상 의류 이미지
normal_image = cv2.imread(&amp;quot;normal_shirt.jpg&amp;quot;)

# 손상 패턴 (실제 손상 부분만 추출)
damage_patch = cv2.imread(&amp;quot;damage_pattern.png&amp;quot;, cv2.IMREAD_UNCHANGED)

# 랜덤 위치에 붙이기
x = np.random.randint(0, normal_image.shape[1] - damage_patch.shape[1])
y = np.random.randint(0, normal_image.shape[0] - damage_patch.shape[0])

# 알파 블렌딩
alpha = damage_patch[:, :, 3] / 255.0
for c in range(3):
    normal_image[y:y+h, x:x+w, c] = (
        alpha * damage_patch[:, :, c] +
        (1 - alpha) * normal_image[y:y+h, x:x+w, c]
    )

cv2.imwrite(&amp;quot;synthetic_damaged_shirt.jpg&amp;quot;, normal_image)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;실전 구현 예시:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# workers/synthesis/synthesizer.py
class ImageSynthesizer:
    def __init__(self, model_name=&amp;quot;nanonana/damage-generator&amp;quot;):
        self.pipeline = StableDiffusionPipeline.from_pretrained(model_name)

    def generate_damaged_images(
        self,
        garment_type: str,
        damage_type: str,
        num_images: int = 100
    ):
        &amp;quot;&amp;quot;&amp;quot;
        손상 의류 합성 이미지 생성
        &amp;quot;&amp;quot;&amp;quot;
        prompts = [
            f&amp;quot;{damage_type} {garment_type}, realistic photo&amp;quot;,
            f&amp;quot;{garment_type} with {damage_type}, high quality&amp;quot;,
            f&amp;quot;damaged {garment_type}, {damage_type} visible&amp;quot;
        ]

        generated_images = []
        for i in range(num_images):
            prompt = np.random.choice(prompts)
            image = self.pipeline(
                prompt,
                num_inference_steps=50,
                guidance_scale=7.5
            ).images[0]

            generated_images.append(image)

        return generated_images

# 사용
synthesizer = ImageSynthesizer()
damaged_jeans = synthesizer.generate_damaged_images(
    garment_type=&amp;quot;denim jeans&amp;quot;,
    damage_type=&amp;quot;torn hole&amp;quot;,
    num_images=1000
)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Synthesis의 장점:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;✅ 데이터 부족 문제 해결
   - 희귀 케이스 생성 (손상, 오염)

✅ 무한한 다양성
   - 다양한 손상 패턴
   - 다양한 각도, 조명

✅ 레이블링 자동화
   - 생성 시 레이블 알고 있음
   - 바운딩 박스 자동 생성&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Synthesis의 단점:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;❌ 생성 품질 불안정
   - 이상한 이미지 생성 가능
   - 수동 필터링 필요

❌ 실제와 차이 (Domain Gap)
   - AI 생성 이미지 ≠ 실제 사진
   - 모델이 합성 데이터만 학습하면 실전 성능 저하

❌ 계산 비용
   - GPU 필요
   - 생성 시간 오래 걸림&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h3&gt;3. Oversampling (오버샘플링)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;정의:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;소수 클래스의 데이터를 늘려서 클래스 불균형을 해소하는 기법&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;클래스 불균형 문제:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;데이터:
- 정상: 10,000장 (99%)
- 손상: 100장 (1%)

학습 결과:
모델: &amp;quot;다 정상이야!&amp;quot;
정확도: 99% (하지만 손상은 하나도 못 찾음)

문제: 소수 클래스 무시&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Oversampling 방법 4가지:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1) Naive Oversampling (단순 복제)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 소수 클래스 단순 복제
damaged_images = [img1, img2, img3]  # 3장

# 100번 복제
oversampled = damaged_images * 100

# 결과: 300장 (하지만 모두 같은 이미지 반복)&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;장점:
✅ 구현 간단
✅ 빠름

단점:
❌ 과적합 위험 (같은 이미지 반복 학습)
❌ 다양성 없음&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;2) Random Oversampling with Augmentation&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sklearn.utils import resample
import albumentations as A

# Augmentation 정의
augment = A.Compose([
    A.RandomRotate90(),
    A.HorizontalFlip(p=0.5),
    A.ColorJitter(0.2, 0.2, 0.2),
    A.GaussianBlur(p=0.3)
])

# 소수 클래스 오버샘플링
damaged_images = [img1, img2, img3]
target_count = 1000

oversampled = []
while len(oversampled) &amp;lt; target_count:
    # 랜덤 선택
    img = np.random.choice(damaged_images)
    # Augmentation 적용
    augmented = augment(image=img)[&amp;#39;image&amp;#39;]
    oversampled.append(augmented)&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;장점:
✅ 다양성 증가
✅ 과적합 완화

단점:
❌ 여전히 원본 데이터 기반&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;3) SMOTE (Synthetic Minority Oversampling Technique)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from imblearn.over_sampling import SMOTE

# 특징 공간에서 보간
# (주로 tabular 데이터에 사용)
smote = SMOTE(sampling_strategy=&amp;#39;auto&amp;#39;)
X_resampled, y_resampled = smote.fit_resample(X, y)&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;원리:
1. 소수 클래스 샘플 선택
2. k-최근접 이웃 찾기
3. 이웃 사이를 보간해서 새 샘플 생성

예시:
샘플 A: [0.1, 0.2, 0.5]
샘플 B: [0.2, 0.3, 0.6]
새 샘플: [0.15, 0.25, 0.55] (중간값)&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code&gt;장점:
✅ 새로운 샘플 생성
✅ 특징 분포 유지

단점:
❌ 이미지에는 비효율적
❌ 픽셀 보간이 의미 없을 수 있음&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;4) Generative Oversampling (생성형 오버샘플링)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Stable Diffusion으로 소수 클래스 생성
class GenerativeOversampler:
    def __init__(self, model):
        self.model = model

    def oversample(self, minority_class, target_count):
        &amp;quot;&amp;quot;&amp;quot;
        소수 클래스를 생성형 AI로 오버샘플링
        &amp;quot;&amp;quot;&amp;quot;
        synthetic_images = []

        while len(synthetic_images) &amp;lt; target_count:
            # AI로 새 이미지 생성
            prompt = f&amp;quot;{minority_class} realistic photo&amp;quot;
            image = self.model.generate(prompt)

            # 품질 검증 (선택적)
            if self.quality_check(image):
                synthetic_images.append(image)

        return synthetic_images

# 사용 예시
oversampler = GenerativeOversampler(nanonana_model)
damaged_images = oversampler.oversample(
    minority_class=&amp;quot;damaged denim jeans&amp;quot;,
    target_count=5000
)&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;장점:
✅ 완전히 새로운 이미지
✅ 높은 다양성
✅ 과적합 최소화

단점:
❌ 생성 비용 높음
❌ Domain Gap 위험
❌ 품질 검증 필요&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;3가지 접근법 비교&lt;/h2&gt;
&lt;h3&gt;클래스 불균형 해결 전략&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;┌──────────────┬────────────────────────┬────────────────────────────┐
│   접근법     │          방법          │            예시            │
├──────────────┼────────────────────────┼────────────────────────────┤
│ Data-level   │ 데이터 자체 조정       │ Oversampling               │
│              │                        │ Undersampling              │
│              │                        │ Synthetic Generation       │
├──────────────┼────────────────────────┼────────────────────────────┤
│ Algorithm-   │ 학습 알고리즘 조정     │ Class Weights              │
│ level        │                        │ Focal Loss                 │
│              │                        │ Cost-sensitive Learning    │
├──────────────┼────────────────────────┼────────────────────────────┤
│ Hybrid       │ 둘 다 병행             │ Synthesis + Focal Loss     │
│              │                        │ Oversampling + Weights     │
└──────────────┴────────────────────────┴────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Data-level 접근:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Oversampling (소수 클래스 늘림)
damaged_images = damaged_images * 100

# Undersampling (다수 클래스 줄임)
normal_images = random.sample(normal_images, 1000)

# 결과: 균형 잡힌 데이터셋&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Algorithm-level 접근:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Class Weights
from torch.nn import CrossEntropyLoss

# 소수 클래스에 높은 가중치
loss_fn = CrossEntropyLoss(
    weight=torch.tensor([1.0, 100.0])  # [정상, 손상]
)

# Focal Loss (어려운 샘플에 집중)
class FocalLoss(nn.Module):
    def __init__(self, alpha=0.25, gamma=2):
        super().__init__()
        self.alpha = alpha
        self.gamma = gamma

    def forward(self, inputs, targets):
        # 잘못 예측한 샘플에 높은 가중치
        pt = torch.exp(-ce_loss)
        focal_loss = self.alpha * (1 - pt) ** self.gamma * ce_loss
        return focal_loss.mean()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Hybrid 접근 (권장):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 1. Data-level: Generative Oversampling
synthetic_damaged = synthesizer.generate(count=5000)

# 2. Algorithm-level: Class Weights
model.train(
    data=augmented_data,
    class_weights=&amp;#39;auto&amp;#39;,  # 자동 계산
    loss=&amp;#39;focal&amp;#39;  # Focal Loss
)

# 결과: 가장 강력한 성능&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;우리 프로젝트 적용 사례&lt;/h2&gt;
&lt;h3&gt;문제 상황&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;의류 품질 검사 데이터:
- 정상: 10,000장
- 손상: 100장 (구멍, 찢어짐)
- 오염: 50장 (얼룩)

클래스 비율: 200:2:1 (극심한 불균형)&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;해결 전략&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Phase 1: Augmentation (빠른 개선)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# configs/augment.yaml
augmentation:
  degrees: 15.0       # 회전
  translate: 0.1      # 이동
  scale: 0.5          # 크기
  flipud: 0.5         # 상하 반전
  fliplr: 0.5         # 좌우 반전
  mosaic: 1.0         # 모자이크&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Ultralytics 자동 적용
from ultralytics import YOLO

model = YOLO(&amp;#39;yolov8n.pt&amp;#39;)
model.train(
    data=&amp;#39;dataset.yaml&amp;#39;,
    cfg=&amp;#39;augment.yaml&amp;#39;,  # Augmentation 설정
    epochs=100
)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결과:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Before: mAP 0.45
After: mAP 0.62 (38% 향상)

분석: 정상 이미지는 잘 찾지만
손상/오염은 여전히 부족&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Phase 2: Synthesis (근본 해결)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# workers/synthesis/synthesizer.py
class DamageSynthesizer:
    def __init__(self):
        self.pipeline = StableDiffusionPipeline.from_pretrained(
            &amp;quot;nanonana/garment-damage-v2&amp;quot;
        )

    def generate_damaged_garments(
        self,
        garment_types=[&amp;#39;jeans&amp;#39;, &amp;#39;t-shirt&amp;#39;, &amp;#39;jacket&amp;#39;],
        damage_types=[&amp;#39;hole&amp;#39;, &amp;#39;tear&amp;#39;, &amp;#39;stain&amp;#39;],
        num_per_combination=100
    ):
        &amp;quot;&amp;quot;&amp;quot;
        손상 의류 합성 이미지 대량 생성
        &amp;quot;&amp;quot;&amp;quot;
        synthetic_dataset = []

        for garment in garment_types:
            for damage in damage_types:
                prompt = f&amp;quot;{damage} on {garment}, realistic product photo&amp;quot;

                for i in range(num_per_combination):
                    image = self.pipeline(
                        prompt,
                        num_inference_steps=50,
                        guidance_scale=7.5
                    ).images[0]

                    # 품질 검증
                    if self.validate_quality(image):
                        synthetic_dataset.append({
                            &amp;#39;image&amp;#39;: image,
                            &amp;#39;label&amp;#39;: damage,
                            &amp;#39;garment&amp;#39;: garment
                        })

        return synthetic_dataset

# 실행
synthesizer = DamageSynthesizer()
synthetic_images = synthesizer.generate_damaged_garments(
    num_per_combination=500  # 조합당 500장
)

# 3 garment × 3 damage × 500 = 4,500장 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결과:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Before: 
- 손상: 100장
- 오염: 50장

After:
- 손상: 4,600장 (실제 100 + 합성 4,500)
- 오염: 4,550장 (실제 50 + 합성 4,500)

mAP: 0.62 → 0.78 (26% 향상)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Phase 3: Hybrid (최종)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# train.yaml
model_config:
  # Algorithm-level
  class_weights: &amp;#39;auto&amp;#39;  # 자동 가중치 계산
  loss: &amp;#39;focal&amp;#39;          # Focal Loss

  # Data-level은 이미 적용됨
  # - Augmentation (online)
  # - Synthesis (offline)

# 최종 데이터셋
dataset:
  train:
    - 정상: 10,000장 (원본)
    - 손상: 4,600장 (원본 100 + 합성 4,500)
    - 오염: 4,550장 (원본 50 + 합성 4,500)

  strategy:
    - Augmentation: Online (학습 중)
    - Class Weights: Auto
    - Focal Loss: γ=2, α=0.25&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;최종 결과:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mAP: 0.78 → 0.86 (10% 향상)

클래스별 성능:
- 정상: 0.95 (변동 없음)
- 손상: 0.72 → 0.84 (17% 향상)
- 오염: 0.65 → 0.80 (23% 향상)

총 개선: 91% (0.45 → 0.86)&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;용어 정리표&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────┬────────────────┬──────────────────────────────┐
│     우리가 하는 것  │   정확한 용어  │            설명              │
├─────────────────────┼────────────────┼──────────────────────────────┤
│ 회전, 노이즈 추가   │ Augmentation   │ 기존 데이터 변형             │
├─────────────────────┼────────────────┼──────────────────────────────┤
│ 나노바나나로 생성   │ Synthesis      │ 새 데이터 생성 (Gen AI)      │
├─────────────────────┼────────────────┼──────────────────────────────┤
│ 소수 클래스 늘림    │ Oversampling   │ 불균형 해소                  │
├─────────────────────┼────────────────┼──────────────────────────────┤
│ 합성으로 소수 늘림  │ Generative     │ Synthesis + Oversampling     │
│                     │ Oversampling   │                              │
├─────────────────────┼────────────────┼──────────────────────────────┤
│ 학습 중 실시간 증강 │ Online         │ 매 에폭마다 변형             │
│                     │ Augmentation   │                              │
├─────────────────────┼────────────────┼──────────────────────────────┤
│ 미리 증강 저장      │ Offline        │ 학습 전 디스크 저장          │
│                     │ Augmentation   │                              │
├─────────────────────┼────────────────┼──────────────────────────────┤
│ 클래스별 가중치     │ Cost-sensitive │ 알고리즘 레벨 해결           │
│                     │ Learning       │                              │
└─────────────────────┴────────────────┴──────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;선택 가이드&lt;/h2&gt;
&lt;h3&gt;데이터 부족 정도별 전략&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def choose_strategy(data_count, imbalance_ratio):
    &amp;quot;&amp;quot;&amp;quot;
    데이터 양과 불균형 정도에 따른 전략 선택
    &amp;quot;&amp;quot;&amp;quot;
    if data_count &amp;gt; 10000:
        if imbalance_ratio &amp;lt; 10:
            return &amp;quot;Augmentation만으로 충분&amp;quot;
        else:
            return &amp;quot;Augmentation + Class Weights&amp;quot;

    elif data_count &amp;gt; 1000:
        if imbalance_ratio &amp;lt; 50:
            return &amp;quot;Augmentation + Oversampling&amp;quot;
        else:
            return &amp;quot;Augmentation + Synthesis + Weights&amp;quot;

    else:  # data_count &amp;lt; 1000
        return &amp;quot;Synthesis 필수 + Hybrid 전략&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;예시:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;케이스 1: 충분한 데이터
- 정상: 50,000장
- 손상: 10,000장 (5:1)
→ 전략: Augmentation만

케이스 2: 중간 불균형
- 정상: 10,000장
- 손상: 500장 (20:1)
→ 전략: Augmentation + Class Weights

케이스 3: 심한 불균형
- 정상: 10,000장
- 손상: 100장 (100:1)
→ 전략: Augmentation + Synthesis + Focal Loss

케이스 4: 극심한 불균형 (우리 케이스)
- 정상: 10,000장
- 손상: 100장, 오염: 50장 (200:1)
→ 전략: Full Hybrid (모두 사용)&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;구현 체크리스트&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;□ Augmentation 설정
  - 적절한 변형 강도
  - 도메인 특성 고려 (의류: 상하 반전 X)

□ Synthesis 품질 검증
  - 이상한 이미지 필터링
  - Domain Gap 확인

□ Oversampling 비율
  - 목표 비율 설정 (1:1 ~ 3:1)
  - 과도한 복제 방지

□ 알고리즘 설정
  - Class Weights 계산
  - Focal Loss 하이퍼파라미터

□ 검증
  - Validation Set은 실제 데이터만
  - Synthetic 데이터는 Train만&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;h3&gt;핵심 원칙&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;1. &amp;quot;변형부터, 생성은 나중에&amp;quot;
   - Augmentation으로 시작
   - 부족하면 Synthesis 추가

2. &amp;quot;실제 데이터가 최고&amp;quot;
   - 합성 데이터는 보조 수단
   - 실제 수집이 우선

3. &amp;quot;검증은 실제 데이터로&amp;quot;
   - Train: 실제 + 합성
   - Validation: 실제만
   - Test: 실제만

4. &amp;quot;Hybrid가 최강&amp;quot;
   - Data-level + Algorithm-level
   - 복합 전략이 효과적&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;용어 외우기&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Augmentation = 변형
Synthesis = 생성
Oversampling = 늘림

Online = 실시간
Offline = 미리

SMOTE = 보간
Generative = AI 생성

Class Weights = 가중치
Focal Loss = 어려운 것 집중&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;데이터가 부족하다고? 방법은 있다.&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Phase 1: Augmentation (회전, 노이즈)
Phase 2: Synthesis (AI 생성)
Phase 3: Hybrid (알고리즘 조정)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;올바른 용어를 알면 팀 소통이 명확해진다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;회전시키고 노이즈 주는 건 Augmentation이야!&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;</description>
      <category>AI &amp;middot; ML/Computer Vision</category>
      <category>AI</category>
      <category>Class-Imbalance</category>
      <category>Computer-Vision</category>
      <category>Data-Augmentation</category>
      <category>deep-learning</category>
      <category>Machine-learning</category>
      <category>oversampling</category>
      <category>Synthetic-Data</category>
      <category>데이터증강</category>
      <category>합성데이터</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/155</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/AI-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%B2%98%EB%A6%AC-%EC%9A%A9%EC%96%B4-%EC%A0%95%EB%A6%AC-%EC%A6%9D%EA%B0%95-%ED%95%A9%EC%84%B1-%EC%98%A4%EB%B2%84%EC%83%98%ED%94%8C%EB%A7%81-%EB%8B%A4-%EB%AD%90%EA%B0%80-%EB%8B%A4%EB%A5%B8-%EA%B1%B0%EC%95%BC#entry155comment</comments>
      <pubDate>Wed, 4 Mar 2026 11:22:31 +0900</pubDate>
    </item>
    <item>
      <title>[실시간 트레이딩 대시보드 만들기 - 04] 회고 &amp;mdash; 연습용 프로젝트와 실전의 거리</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%94%A9-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-04-%ED%9A%8C%EA%B3%A0-%E2%80%94-%EC%97%B0%EC%8A%B5%EC%9A%A9-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%99%80-%EC%8B%A4%EC%A0%84%EC%9D%98-%EA%B1%B0%EB%A6%AC</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/b550715a-d09f-442a-a9c3-947911c7c67c/image.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;시리즈 마지막 편입니다. 이번에는 코드 이야기가 아니라 프로젝트를 만들면서 느낀 점, 그리고 실제 프로덕션 트레이딩 플랫폼을 만든다면 어떤 점들을 더 고려해야 하는지 정리해봤습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;전체 소스코드는 GitHub에 공개되어 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/kimkuns91/upbit-realtime-dashboard.git
cd upbit-realtime-dashboard
npm install &amp;amp;&amp;amp; npm run dev&lt;/code&gt;&lt;/pre&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;1. 왜 이 프로젝트를 만들었는가&lt;/h2&gt;
&lt;p&gt;평소에 궁금했던 게 있었습니다. 주식이든 코인이든, 트레이딩 화면을 보면 숫자가 쉴 새 없이 바뀝니다. 호가창, 체결 내역, 캔들 차트가 동시에 움직입니다. &lt;strong&gt;이걸 프론트엔드에서 어떻게 처리하는 걸까?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;React로 일반적인 CRUD 앱을 만들 때는 상태 관리가 크게 문제 되지 않습니다. 사용자가 버튼을 클릭하면 상태가 바뀌고, 화면이 업데이트됩니다. 하지만 초당 10~20회씩 외부에서 데이터가 밀려들어오는 환경은 완전히 다릅니다.&lt;/p&gt;
&lt;p&gt;직접 부딪혀보고 싶었습니다. 그래서 업비트 Public API를 가지고 실시간 대시보드를 만들어봤습니다.&lt;/p&gt;
&lt;h2&gt;2. 만들면서 배운 것&lt;/h2&gt;
&lt;h3&gt;React의 경계를 알게 되었습니다&lt;/h3&gt;
&lt;p&gt;React는 선언적 UI를 만드는 데 훌륭한 도구입니다. 하지만 모든 상황에 맞지는 않습니다. 호가창 벤치마크에서 useState로 처리했을 때 렌더링이 13,800회까지 올라간 걸 보고, React 렌더링 사이클의 비용을 체감했습니다.&lt;/p&gt;
&lt;p&gt;중요한 건 &amp;quot;React가 느리다&amp;quot;가 아닙니다. &lt;strong&gt;어디에 React를 쓰고 어디에 쓰지 않을지 판단하는 것&lt;/strong&gt;이 핵심이었습니다. 사용자 인터랙션은 React가 잘 처리합니다. 고빈도 데이터 스트림은 React를 우회하는 게 맞습니다.&lt;/p&gt;
&lt;h3&gt;WebSocket은 생각보다 손이 많이 갑니다&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;new WebSocket(url)&lt;/code&gt; 한 줄로 연결은 됩니다. 하지만 실제로 쓸 만한 수준으로 만들려면 생각해야 할 게 많았습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;연결이 끊어지면 어떻게 할 것인가 (재연결 전략)&lt;/li&gt;
&lt;li&gt;여러 컴포넌트가 같은 데이터를 필요로 하면 어떻게 공유할 것인가 (싱글톤)&lt;/li&gt;
&lt;li&gt;React lifecycle과 WebSocket lifecycle이 다른데 어떻게 맞출 것인가 (cleanup)&lt;/li&gt;
&lt;li&gt;구독 종목이 바뀌면 기존 구독은 어떻게 정리할 것인가&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;연결 하나 만드는 건 쉽지만, 안정적으로 유지하는 건 다른 차원의 문제입니다.&lt;/p&gt;
&lt;h3&gt;브라우저가 생각보다 강력합니다&lt;/h3&gt;
&lt;p&gt;벤치마크를 돌려보면 FPS가 셋 다 120으로 나옵니다. 최적화를 안 해도 화면이 멈추지는 않습니다. 최신 브라우저 엔진과 하드웨어가 그만큼 발전했습니다.&lt;/p&gt;
&lt;p&gt;그렇다고 최적화가 의미 없는 건 아닙니다. 렌더링 13,800회와 2회는 CPU 점유율에서 차이가 납니다. 노트북 배터리가 빨리 닳고, 팬이 돌아가기 시작합니다. 모바일 기기에서는 체감 차이가 더 큽니다.&lt;/p&gt;
&lt;h2&gt;3. 실제 트레이딩 플랫폼이라면&lt;/h2&gt;
&lt;p&gt;이 프로젝트는 Public API만 사용하는 읽기 전용 대시보드입니다. 실제 트레이딩 플랫폼을 만든다면 고려해야 할 것들이 훨씬 많습니다. 만들면서 &amp;quot;실전에서는 이렇게 하면 안 되겠다&amp;quot; 싶은 것들을 정리해봤습니다.&lt;/p&gt;
&lt;h3&gt;인증과 보안&lt;/h3&gt;
&lt;p&gt;이 프로젝트는 Public API라서 인증이 없습니다. 실제 매매 기능을 넣으려면 API 키 관리, JWT 토큰, 세션 관리가 필요합니다. WebSocket 연결에도 인증 토큰을 실어야 하고, 토큰이 만료되면 재인증 후 재연결하는 로직이 필요합니다.&lt;/p&gt;
&lt;p&gt;특히 금융 데이터를 다루므로 XSS, CSRF 같은 기본적인 보안은 물론이고, API 키가 클라이언트에 노출되지 않도록 서버 사이드에서 중계하는 구조가 필수입니다.&lt;/p&gt;
&lt;h3&gt;주문 실행과 데이터 정합성&lt;/h3&gt;
&lt;p&gt;읽기 전용 대시보드에서는 데이터가 1~2초 지연돼도 크게 문제가 없습니다. 하지만 실제 매매에서는 이야기가 다릅니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;호가창에 보이는 가격으로 주문을 넣었는데, 실제 체결가가 다르면 문제입니다&lt;/li&gt;
&lt;li&gt;네트워크 지연으로 주문이 중복 전송되면 안 됩니다&lt;/li&gt;
&lt;li&gt;주문 상태(대기 → 체결 → 취소)를 실시간으로 정확하게 반영해야 합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;클라이언트에서 보여주는 가격과 서버에서 처리하는 가격 사이의 갭을 어떻게 관리할 것인지가 핵심입니다. Optimistic UI를 쓸 것인지, 서버 확인을 기다릴 것인지에 대한 판단도 필요합니다.&lt;/p&gt;
&lt;h3&gt;다중 종목과 멀티 커넥션&lt;/h3&gt;
&lt;p&gt;이 프로젝트는 한 번에 1개 종목만 봅니다. 실제 트레이딩 플랫폼에서는 관심 종목 여러 개를 동시에 모니터링합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;종목마다 WebSocket 연결을 따로 만들 것인지, 하나의 연결에서 멀티플렉싱할 것인지&lt;/li&gt;
&lt;li&gt;구독 종목이 50개, 100개가 되면 데이터 처리량을 어떻게 감당할 것인지&lt;/li&gt;
&lt;li&gt;필요 없는 종목은 자동으로 구독 해제하는 GC 로직이 필요합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;업비트 WebSocket은 하나의 연결에서 여러 종목을 구독할 수 있지만, 종목이 많아지면 메시지 처리량이 기하급수적으로 늘어납니다. Web Worker로 파싱을 분리하는 것도 고려해야 합니다.&lt;/p&gt;
&lt;h3&gt;에러 처리와 모니터링&lt;/h3&gt;
&lt;p&gt;이 프로젝트에서는 에러가 나면 콘솔에 찍히는 게 전부입니다. 프로덕션에서는 그렇게 할 수 없습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;WebSocket 연결 실패, 파싱 에러, API 응답 이상 등을 구분해서 처리해야 합니다&lt;/li&gt;
&lt;li&gt;사용자에게 &amp;quot;현재 데이터가 지연되고 있습니다&amp;quot; 같은 안내를 보여야 합니다&lt;/li&gt;
&lt;li&gt;Sentry 같은 에러 트래킹 도구로 문제를 수집하고 분석해야 합니다&lt;/li&gt;
&lt;li&gt;데이터가 일정 시간 안 들어오면 stale 상태로 표시하는 로직도 필요합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;금융 서비스에서 &amp;quot;조용히 실패하는 것&amp;quot;은 가장 위험한 버그입니다.&lt;/p&gt;
&lt;h3&gt;테스트&lt;/h3&gt;
&lt;p&gt;이 프로젝트에는 테스트 코드가 없습니다. 연습용이니까 넘어갔지만, 프로덕션에서는 그럴 수 없습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;WebSocket 메시지 파싱 로직은 단위 테스트가 필수입니다&lt;/li&gt;
&lt;li&gt;재연결, 구독 변경 같은 시나리오는 통합 테스트가 필요합니다&lt;/li&gt;
&lt;li&gt;캔들 차트의 시간 범위 계산이 틀리면 잘못된 차트가 그려집니다&lt;/li&gt;
&lt;li&gt;호가창 정렬이 잘못되면 매수/매도가 뒤바뀝니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;특히 금융 데이터 처리에서 off-by-one 에러는 치명적입니다. 타임스탬프 계산, 가격 반올림, 수량 합산 같은 부분은 꼼꼼한 테스트가 필요합니다.&lt;/p&gt;
&lt;h3&gt;접근성과 국제화&lt;/h3&gt;
&lt;p&gt;트레이딩 플랫폼은 긴 시간 동안 화면을 응시하는 특성이 있습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;다크 모드뿐 아니라 고대비 모드도 고려해야 합니다&lt;/li&gt;
&lt;li&gt;색각 이상이 있는 사용자를 위해 빨강/파랑 외에 다른 시각적 구분 수단이 필요합니다&lt;/li&gt;
&lt;li&gt;스크린 리더가 가격 변동을 읽어줄 수 있어야 합니다&lt;/li&gt;
&lt;li&gt;키보드만으로 주문을 넣을 수 있어야 합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;국제화도 중요합니다. 가격 포맷(소수점 vs 쉼표), 시간대, 통화 단위가 사용자마다 다릅니다.&lt;/p&gt;
&lt;h2&gt;4. 기술 선택을 다시 한다면&lt;/h2&gt;
&lt;p&gt;프로젝트를 처음부터 다시 만든다면 몇 가지 다르게 할 것 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Web Worker 도입&lt;/strong&gt;: WebSocket 메시지 파싱을 메인 스레드에서 분리하면 UI가 더 부드러워집니다. 특히 다중 종목을 처리할 때 효과가 클 것 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SharedArrayBuffer 검토&lt;/strong&gt;: 고빈도 데이터를 Worker와 메인 스레드 사이에서 효율적으로 공유하는 방법입니다. 다만 보안 헤더(COOP, COEP) 설정이 필요해서 배포 환경에 따라 제약이 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Canvas 기반 호가창&lt;/strong&gt;: DOM 조작 대신 Canvas로 호가창을 그리면 렌더링 비용을 더 줄일 수 있습니다. TradingView 같은 전문 플랫폼이 차트를 Canvas로 그리는 이유가 있습니다.&lt;/p&gt;
&lt;h2&gt;5. 시리즈를 마치며&lt;/h2&gt;
&lt;p&gt;3편의 기술 글과 이번 회고까지, 실시간 트레이딩 대시보드를 만드는 과정을 기록해봤습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;[01] WebSocket 파이프라인&lt;/strong&gt; — 싱글톤 매니저, Blob 파싱, 재연결 전략&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;[02] 캔들 차트&lt;/strong&gt; — TradingView Lightweight Charts, REST + WS 조합&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;[03] 호가창 최적화&lt;/strong&gt; — useState → throttle → useRef+rAF, 렌더링 6,900배 차이&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;[04] 회고&lt;/strong&gt; — 배운 것과 실전과의 거리&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이 프로젝트는 &amp;quot;고빈도 실시간 데이터를 프론트엔드에서 어떻게 처리하는가&amp;quot;에 대한 연습이었습니다. 실제 프로덕션과는 거리가 있지만, 핵심 패턴을 직접 구현해보면서 감을 잡을 수 있었습니다.&lt;/p&gt;
&lt;p&gt;가장 큰 교훈은 하나입니다. &lt;strong&gt;도구의 한계를 알아야 도구를 제대로 쓸 수 있습니다.&lt;/strong&gt; React가 만능이 아니라는 걸 알아야, 적절한 지점에서 React를 우회하는 판단을 내릴 수 있습니다.&lt;/p&gt;
&lt;p&gt;전체 소스코드는 &lt;a href=&quot;https://github.com/kimkuns91/upbit-realtime-dashboard&quot;&gt;GitHub&lt;/a&gt;에 공개되어 있습니다.&lt;/p&gt;</description>
      <category>Frontend Development/[실습] 실시간 트레이딩 대시보드 만들기</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/154</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%94%A9-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-04-%ED%9A%8C%EA%B3%A0-%E2%80%94-%EC%97%B0%EC%8A%B5%EC%9A%A9-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%99%80-%EC%8B%A4%EC%A0%84%EC%9D%98-%EA%B1%B0%EB%A6%AC#entry154comment</comments>
      <pubDate>Thu, 12 Feb 2026 12:14:15 +0900</pubDate>
    </item>
    <item>
      <title>[실시간 트레이딩 대시보드 만들기 - 03] 초당 20회 업데이트되는 호가창, 렌더링 횟수를 6,900배 줄인 방법</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%94%A9-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-03-%EC%B4%88%EB%8B%B9-20%ED%9A%8C-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%EB%90%98%EB%8A%94-%ED%98%B8%EA%B0%80%EC%B0%BD-%EB%A0%8C%EB%8D%94%EB%A7%81-%ED%9A%9F%EC%88%98%EB%A5%BC-6900%EB%B0%B0-%EC%A4%84%EC%9D%B8-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/154d6234-3331-43f1-abc7-99a8e2ddbac0/image.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;업비트 실시간 대시보드 시리즈 마지막 편입니다. 호가창(Order Book)을 3가지 방식으로 구현하고 실제 성능을 비교해봤습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;전체 소스코드는 GitHub에 공개되어 있습니다. &lt;code&gt;/benchmark&lt;/code&gt; 페이지에서 3가지 방식의 성능 차이를 직접 확인할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/kimkuns91/upbit-realtime-dashboard.git
cd upbit-realtime-dashboard
npm install &amp;amp;&amp;amp; npm run dev
# http://localhost:3000/benchmark 접속&lt;/code&gt;&lt;/pre&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;1. 왜 호가창이 어려운가?&lt;/h2&gt;
&lt;p&gt;호가창은 프론트엔드에서 구현 난이도가 가장 높은 컴포넌트 중 하나입니다. 이유는 단순합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;업비트 WebSocket으로 &lt;strong&gt;초당 10~20회&lt;/strong&gt; 호가 데이터가 들어옵니다&lt;/li&gt;
&lt;li&gt;매수 15호가 + 매도 15호가 = &lt;strong&gt;30개 행&lt;/strong&gt;이 동시에 바뀝니다&lt;/li&gt;
&lt;li&gt;잔량 바 너비, 가격, 수량이 매번 달라집니다&lt;/li&gt;
&lt;li&gt;이 모든 게 &lt;strong&gt;버벅임 없이&lt;/strong&gt; 부드럽게 보여야 합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이걸 어떻게 처리하느냐에 따라 성능 차이가 극적으로 벌어집니다. 직접 3가지 방식을 구현해서 비교해봤습니다.&lt;/p&gt;
&lt;h2&gt;2. 방식 1 — Naive (useState)&lt;/h2&gt;
&lt;p&gt;가장 직관적인 방법입니다. WebSocket 메시지가 올 때마다 &lt;code&gt;setState&lt;/code&gt;를 호출합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const NaiveOrderBook = ({ market }: { market: string }) =&amp;gt; {
  const [orderbook, setOrderbook] = useState&amp;lt;UpbitOrderbook | null&amp;gt;(null);

  useEffect(() =&amp;gt; {
    const callbacks: SocketCallbacks = {
      onOrderbook: (data) =&amp;gt; {
        if (data.code === market) {
          setOrderbook({ ...data }); // 매번 새 객체 → 매번 리렌더링
        }
      },
    };
    upbitSocket.addListener(callbacks);
    return () =&amp;gt; upbitSocket.removeListener(callbacks);
  }, [market]);

  // 매 렌더마다 30개 행 전부 VDOM diffing...
  return (
    &amp;lt;&amp;gt;
      {units.map((u, i) =&amp;gt; (
        &amp;lt;BenchmarkRow key={i} price={u.ask_price} size={u.ask_size} ... /&amp;gt;
      ))}
    &amp;lt;/&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결과&lt;/strong&gt;: 매 WS 메시지마다 setState가 호출되고, 매번 30개 행에 대한 VDOM diffing이 발생합니다. 실제로 벤치마크를 돌려보니 렌더링 횟수가 &lt;strong&gt;13,800회&lt;/strong&gt;까지 올라갔습니다. React DevTools Profiler를 열면 렌더링이 쉬지 않고 찍힙니다.&lt;/p&gt;
&lt;h2&gt;3. 방식 2 — Throttle (lodash throttle + useState)&lt;/h2&gt;
&lt;p&gt;업데이트 빈도를 줄이면 되지 않을까 하는 아이디어입니다. &lt;code&gt;lodash.throttle&lt;/code&gt;로 setState 호출을 100ms마다 제한합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const ThrottledOrderBook = ({ market }: { market: string }) =&amp;gt; {
  const [orderbook, setOrderbook] = useState&amp;lt;UpbitOrderbook | null&amp;gt;(null);

  const throttledSet = useMemo(
    () =&amp;gt; throttle((data: UpbitOrderbook) =&amp;gt; {
      setOrderbook({ ...data });
    }, 100),
    []
  );

  useEffect(() =&amp;gt; {
    const callbacks: SocketCallbacks = {
      onOrderbook: (data) =&amp;gt; {
        if (data.code === market) throttledSet(data);
      },
    };
    upbitSocket.addListener(callbacks);
    return () =&amp;gt; {
      upbitSocket.removeListener(callbacks);
      throttledSet.cancel(); // 메모리 릭 방지
    };
  }, [market, throttledSet]);
  // ...
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결과&lt;/strong&gt;: throttle 덕분에 렌더링 횟수가 &lt;strong&gt;10,616회&lt;/strong&gt;로 줄어듭니다. Naive보다 약 23% 줄었지만, 여전히 만 단위의 React 리렌더링이 발생합니다. VDOM diffing 비용 자체는 그대로입니다.&lt;/p&gt;
&lt;h2&gt;4. 방식 3 — Optimized (useRef + requestAnimationFrame)&lt;/h2&gt;
&lt;p&gt;근본적인 해결책은 &lt;strong&gt;React 렌더링 사이클을 우회&lt;/strong&gt;하는 것입니다.&lt;/p&gt;
&lt;p&gt;핵심 아이디어는 이렇습니다. 데이터를 &lt;code&gt;useState&lt;/code&gt;가 아닌 &lt;code&gt;useRef&lt;/code&gt;에 저장하고, &lt;code&gt;requestAnimationFrame&lt;/code&gt; 주기에 맞춰 DOM을 직접 업데이트합니다.&lt;/p&gt;
&lt;p&gt;먼저 이 패턴을 범용 훅으로 추상화했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;export const useThrottledRef = &amp;lt;T&amp;gt;({ onUpdate }: { onUpdate: (data: T) =&amp;gt; void }) =&amp;gt; {
  const dataRef = useRef&amp;lt;T | null&amp;gt;(null);
  const hasNewDataRef = useRef(false);
  const onUpdateRef = useRef(onUpdate);
  onUpdateRef.current = onUpdate;

  useEffect(() =&amp;gt; {
    const tick = () =&amp;gt; {
      // 새 데이터가 있을 때만 콜백 호출
      if (hasNewDataRef.current &amp;amp;&amp;amp; dataRef.current !== null) {
        onUpdateRef.current(dataRef.current);
        hasNewDataRef.current = false;
      }
      requestAnimationFrame(tick);
    };
    const id = requestAnimationFrame(tick);
    return () =&amp;gt; cancelAnimationFrame(id);
  }, []);

  // setState 대신 이걸 호출 → 리렌더링 없음
  const updateData = useCallback((newData: T) =&amp;gt; {
    dataRef.current = newData;
    hasNewDataRef.current = true;
  }, []);

  return { updateData };
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 호가창에서 DOM을 직접 조작합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const OptimizedOrderBook = ({ market }: { market: string }) =&amp;gt; {
  const containerRef = useRef&amp;lt;HTMLDivElement&amp;gt;(null);

  const updateDOM = useCallback((data: UpbitOrderbook) =&amp;gt; {
    const container = containerRef.current;
    if (!container) return;

    const askRows = container.querySelectorAll(&amp;#39;[data-row=&amp;quot;ask&amp;quot;]&amp;#39;);
    askRows.forEach((row, i) =&amp;gt; {
      const priceEl = row.querySelector(&amp;#39;[data-field=&amp;quot;price&amp;quot;]&amp;#39;);
      const barEl = row.querySelector(&amp;#39;[data-field=&amp;quot;bar&amp;quot;]&amp;#39;) as HTMLElement;
      if (priceEl) priceEl.textContent = formatPrice(units[i].ask_price);
      if (barEl) barEl.style.width = `${(units[i].ask_size / maxSize) * 100}%`;
    });
  }, []);

  const { updateData } = useThrottledRef&amp;lt;UpbitOrderbook&amp;gt;({
    onUpdate: updateDOM,
  });

  useEffect(() =&amp;gt; {
    const callbacks: SocketCallbacks = {
      onOrderbook: (data) =&amp;gt; {
        if (data.code === market) updateData(data);
      },
    };
    upbitSocket.addListener(callbacks);
    return () =&amp;gt; upbitSocket.removeListener(callbacks);
  }, [market, updateData]);
  // ...
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결과&lt;/strong&gt;: React 리렌더링은 마운트 시 1~2회뿐입니다. 이후 모든 업데이트는 rAF 콜백에서 DOM을 직접 수정하므로 VDOM diffing 비용이 0입니다.&lt;/p&gt;
&lt;h2&gt;5. CSS 플래싱 효과&lt;/h2&gt;
&lt;p&gt;가격이 바뀌었을 때 배경색이 잠깐 번쩍이는 효과도 넣었습니다. JavaScript로 스타일을 제어하면 리플로우가 발생하지만, CSS class 토글 방식은 GPU 가속을 탈 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@keyframes flash-bid {
  0% { background-color: rgba(239, 68, 68, 0.3); }
  100% { background-color: transparent; }
}

.flash-bid {
  animation: flash-bid 0.5s ease-out;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 새로운 가격이 감지되면 CSS class 토글
row.classList.remove(&amp;#39;flash-bid&amp;#39;);
void row.offsetWidth; // reflow 트리거 (애니메이션 재시작)
row.classList.add(&amp;#39;flash-bid&amp;#39;);&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. 벤치마크 결과&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/1da55615-a72d-4744-96de-e55c024cb0d4/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;프로젝트의 &lt;code&gt;/benchmark&lt;/code&gt; 페이지에서 3가지 방식을 동시에 돌린 결과입니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;FPS&lt;/th&gt;
&lt;th&gt;렌더링 횟수&lt;/th&gt;
&lt;th&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Naive (useState)&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;td&gt;13,800회&lt;/td&gt;
&lt;td&gt;매 WS 메시지마다 리렌더링&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throttle (100ms)&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;td&gt;10,616회&lt;/td&gt;
&lt;td&gt;줄었지만 여전히 만 단위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optimized (useRef+rAF)&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;td&gt;2회&lt;/td&gt;
&lt;td&gt;마운트 시에만 렌더링&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;흥미로운 점은 FPS가 셋 다 120으로 동일하다는 것입니다. 최신 브라우저와 하드웨어가 좋아져서 FPS 자체는 잘 떨어지지 않습니다.&lt;/p&gt;
&lt;p&gt;그렇다면 왜 최적화가 필요할까요? &lt;strong&gt;렌더링 횟수&lt;/strong&gt;를 보면 답이 나옵니다. Naive는 13,800회, Optimized는 단 2회. &lt;strong&gt;6,900배&lt;/strong&gt; 차이입니다. FPS가 동일하더라도 불필요한 리렌더링은 CPU 자원을 낭비하고, 다른 탭이나 인터랙션에 영향을 줍니다. 저사양 기기나 모바일에서는 이 차이가 FPS 드랍으로 직결됩니다.&lt;/p&gt;
&lt;p&gt;Chrome DevTools Performance 탭에서 직접 확인해보시는 것을 추천합니다.&lt;/p&gt;
&lt;h2&gt;7. 언제 React 상태를 우회해야 하는가?&lt;/h2&gt;
&lt;p&gt;모든 곳에 useRef+rAF를 쓰라는 이야기는 아닙니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;useState가 적합한 경우:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;사용자 인터랙션 기반 업데이트 (클릭, 입력)&lt;/li&gt;
&lt;li&gt;초당 1~2회 이하의 저빈도 업데이트&lt;/li&gt;
&lt;li&gt;여러 자식 컴포넌트에 데이터를 props로 내려야 할 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;useRef+rAF가 적합한 경우:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;초당 10회 이상의 고빈도 데이터 스트림&lt;/li&gt;
&lt;li&gt;업데이트할 DOM 노드가 많을 때 (호가창 30행 등)&lt;/li&gt;
&lt;li&gt;화면 표시만 하고 다른 로직에 영향을 주지 않는 데이터&lt;/li&gt;
&lt;li&gt;불필요한 리렌더링을 최소화해야 하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;트레이드오프는 분명합니다. React의 선언적 모델을 포기하는 대신 성능을 얻습니다. DOM을 직접 조작하므로 코드가 복잡해지고, React DevTools에서 상태를 추적하기 어려워집니다. 하지만 호가창처럼 &lt;strong&gt;고빈도 + 다수 DOM 노드&lt;/strong&gt; 조합에서는 이 패턴이 거의 유일한 해답입니다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;핵심 교훈은 하나입니다. &lt;strong&gt;고빈도 실시간 데이터에서는 React 렌더링 사이클이 병목이 됩니다.&lt;/strong&gt; 이를 인지하고, 적절한 지점에서 React를 우회하는 판단이 프론트엔드 성능 최적화의 핵심입니다.&lt;/p&gt;
&lt;p&gt;다음 글에서는 이 프로젝트를 만들면서 느낀 점과, 실제 프로덕션 트레이딩 플랫폼을 만든다면 어떤 점들을 더 신경써야 하는지 정리해봤습니다.&lt;/p&gt;</description>
      <category>Frontend Development/[실습] 실시간 트레이딩 대시보드 만들기</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/153</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%94%A9-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-03-%EC%B4%88%EB%8B%B9-20%ED%9A%8C-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%EB%90%98%EB%8A%94-%ED%98%B8%EA%B0%80%EC%B0%BD-%EB%A0%8C%EB%8D%94%EB%A7%81-%ED%9A%9F%EC%88%98%EB%A5%BC-6900%EB%B0%B0-%EC%A4%84%EC%9D%B8-%EB%B0%A9%EB%B2%95#entry153comment</comments>
      <pubDate>Thu, 12 Feb 2026 12:10:36 +0900</pubDate>
    </item>
    <item>
      <title>[실시간 트레이딩 대시보드 만들기 - 02] TradingView Charts로 실시간 캔들 차트 구현</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%94%A9-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-02-TradingView-Charts%EB%A1%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%BA%94%EB%93%A4-%EC%B0%A8%ED%8A%B8-%EA%B5%AC%ED%98%84</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/cc4bd042-a1b6-451b-9c5a-1dd410f3627f/image.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;업비트 실시간 대시보드 시리즈 2편입니다. 이전 글에서 WebSocket 파이프라인을 구축했고, 이번에는 그 위에 캔들 차트를 올립니다. REST API로 과거 200개 캔들을 불러오고, WebSocket 체결 데이터로 마지막 캔들을 실시간 업데이트하는 구조입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;전체 소스코드는 GitHub에 공개되어 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/kimkuns91/upbit-realtime-dashboard.git
cd upbit-realtime-dashboard
npm install &amp;amp;&amp;amp; npm run dev&lt;/code&gt;&lt;/pre&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;1. 왜 TradingView Lightweight Charts인가?&lt;/h2&gt;
&lt;p&gt;금융 차트 라이브러리를 고를 때 D3.js, Recharts, ECharts 등을 검토했습니다. 결론은 Lightweight Charts 일택이었습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Canvas 기반&lt;/strong&gt;: SVG 기반 라이브러리(D3, Recharts)와 달리 수천 개 데이터 포인트에서도 성능 문제가 없습니다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;금융 특화&lt;/strong&gt;: 캔들스틱, 볼륨 히스토그램, 크로스헤어 등이 내장되어 있습니다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;가볍다&lt;/strong&gt;: gzip 기준 약 45KB. TradingView 풀 라이브러리의 1/10 수준입니다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript 지원&lt;/strong&gt;: v5부터 타입이 더 정교해졌습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;npm install로 바로 들어가는 금융 차트 라이브러리 중에서는 가장 실용적인 선택이었습니다.&lt;/p&gt;
&lt;h2&gt;2. CORS 프록시: Next.js Route Handler&lt;/h2&gt;
&lt;p&gt;업비트 REST API는 브라우저에서 직접 호출하면 CORS 에러가 납니다. Next.js의 Route Handler로 서버 사이드 프록시를 만들었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/app/api/candles/route.ts
export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  const market = searchParams.get(&amp;#39;market&amp;#39;);
  const unit = searchParams.get(&amp;#39;unit&amp;#39;); // 분봉 단위 또는 &amp;#39;days&amp;#39;

  // 분봉/일봉에 따라 업비트 API 엔드포인트 결정
  let url: string;
  if (unit === &amp;#39;days&amp;#39;) {
    url = `${UPBIT_API_BASE}/candles/days?market=${market}&amp;amp;count=${count}`;
  } else {
    url = `${UPBIT_API_BASE}/candles/minutes/${unit}?market=${market}&amp;amp;count=${count}`;
  }

  const response = await fetch(url, {
    headers: { Accept: &amp;#39;application/json&amp;#39; },
  });
  const data = await response.json();
  return NextResponse.json(data);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;클라이언트에서는 &lt;code&gt;/api/candles?market=KRW-BTC&amp;amp;unit=1&amp;amp;count=200&lt;/code&gt;으로 호출하면 됩니다. 서버 사이드에서 업비트 API를 호출하므로 CORS 이슈가 없습니다.&lt;/p&gt;
&lt;h2&gt;3. 차트 컴포넌트 구현&lt;/h2&gt;
&lt;p&gt;Lightweight Charts v5에서는 시리즈 추가 방식이 바뀌었습니다. 문자열 대신 &lt;strong&gt;SeriesDefinition 객체&lt;/strong&gt;를 import해서 사용합니다. 처음에 v4 문법으로 작성했다가 타입 에러를 만났습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import {
  createChart,
  CandlestickSeries,  // v5: SeriesDefinition 객체
  HistogramSeries,
  ColorType,
} from &amp;#39;lightweight-charts&amp;#39;;

// 차트 인스턴스 생성
const chart = createChart(containerRef.current, {
  layout: {
    background: { type: ColorType.Solid, color: &amp;#39;#1a1a2e&amp;#39; },
    textColor: &amp;#39;#a0aec0&amp;#39;,
  },
  grid: {
    vertLines: { color: &amp;#39;#2a2a4a&amp;#39; },
    horzLines: { color: &amp;#39;#2a2a4a&amp;#39; },
  },
});

// v5 방식: addSeries(SeriesDefinition, options)
const candleSeries = chart.addSeries(CandlestickSeries, {
  upColor: &amp;#39;#ef4444&amp;#39;,     // 상승: 빨강 (한국 기준)
  downColor: &amp;#39;#3b82f6&amp;#39;,   // 하락: 파랑
  wickUpColor: &amp;#39;#ef4444&amp;#39;,
  wickDownColor: &amp;#39;#3b82f6&amp;#39;,
});

// 볼륨 히스토그램 (차트 하단 20%)
const volumeSeries = chart.addSeries(HistogramSeries, {
  priceFormat: { type: &amp;#39;volume&amp;#39; },
  priceScaleId: &amp;#39;volume&amp;#39;,
});
chart.priceScale(&amp;#39;volume&amp;#39;).applyOptions({
  scaleMargins: { top: 0.8, bottom: 0 },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;반응형은 ResizeObserver로 처리했습니다. 컨테이너 크기가 변하면 차트도 따라 리사이즈됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const resizeObserver = new ResizeObserver((entries) =&amp;gt; {
  const { width, height } = entries[0].contentRect;
  chart.applyOptions({ width, height });
});
resizeObserver.observe(containerRef.current);&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 실시간 캔들 업데이트 로직&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/32140475-9591-4525-94f2-85b6a510ce9d/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;이 부분이 가장 까다로웠습니다. WebSocket으로 들어오는 체결(trade) 데이터를 받아서 두 가지 케이스를 처리해야 합니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;현재 캔들 시간 범위 안이면 → 기존 캔들의 high/low/close 업데이트&lt;/li&gt;
&lt;li&gt;새로운 시간 범위에 진입하면 → 새 캔들 생성&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const callbacks: SocketCallbacks = {
  onTrade: (trade: UpbitTrade) =&amp;gt; {
    if (trade.code !== market) return;

    const lastCandle = candlesRef.current[candlesRef.current.length - 1];

    // 체결 시각을 캔들 간격에 맞춰 계산
    const tradeTimestamp = Math.floor(trade.trade_timestamp / 1000);
    const candleTime = Math.floor(tradeTimestamp / intervalSeconds) * intervalSeconds;

    if (candleTime === lastCandle.time) {
      // 기존 캔들 업데이트
      lastCandle.high = Math.max(lastCandle.high, trade.trade_price);
      lastCandle.low = Math.min(lastCandle.low, trade.trade_price);
      lastCandle.close = trade.trade_price;

      // 차트에 직접 업데이트 전달 (리렌더링 없음)
      onCandleUpdateRef.current?.({ ...lastCandle }, { ...lastVolume });
    } else if (candleTime &amp;gt; lastCandle.time) {
      // 새 캔들 생성
      const newCandle: CandleData = {
        time: candleTime,
        open: trade.trade_price,
        high: trade.trade_price,
        low: trade.trade_price,
        close: trade.trade_price,
      };
      candlesRef.current.push(newCandle);
      onCandleUpdateRef.current?.(newCandle, newVolume);
    }
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 중요한 설계 포인트가 세 가지 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;캔들 데이터는 &lt;code&gt;useRef&lt;/code&gt;에 저장합니다.&lt;/strong&gt; &lt;code&gt;useState&lt;/code&gt;에 넣으면 매 체결마다 리렌더링이 일어나서 차트가 버벅입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;차트 업데이트는 &lt;code&gt;series.update()&lt;/code&gt; 한 번으로 끝냅니다.&lt;/strong&gt; &lt;code&gt;setData()&lt;/code&gt;로 전체를 다시 넣지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;콜백 ref 패턴으로 훅과 차트 컴포넌트를 연결합니다.&lt;/strong&gt; 차트 컴포넌트가 마운트되면 콜백을 등록하고, 언마운트되면 null로 초기화합니다.&lt;/p&gt;
&lt;h2&gt;5. 타임프레임 전환&lt;/h2&gt;
&lt;p&gt;TanStack Query의 queryKey에 타임프레임을 포함하면 자연스럽게 처리됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const queryKey = [&amp;#39;candles&amp;#39;, market, timeframe];

const { data, isLoading } = useQuery&amp;lt;CandleChartData&amp;gt;({
  queryKey,
  queryFn: () =&amp;gt; fetchCandles(market, timeframe),
  staleTime: 1000 * 60, // 1분간 fresh
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;1분봉 → 5분봉으로 전환하면 queryKey가 바뀌고, 캐시에 없으면 자동으로 새 데이터를 fetch합니다. 이미 캐시에 있으면 즉시 보여줍니다. TanStack Query가 이런 부분을 정말 잘 해줍니다.&lt;/p&gt;
&lt;h2&gt;6. useEffect cleanup 주의점&lt;/h2&gt;
&lt;p&gt;차트 라이브러리를 React에서 쓸 때 가장 흔한 실수가 cleanup을 빼먹는 것입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;useEffect(() =&amp;gt; {
  const chart = createChart(containerRef.current, { ... });
  // ...시리즈 추가

  return () =&amp;gt; {
    resizeObserver.disconnect();  // 옵저버 해제
    chart.remove();               // 차트 인스턴스 제거 (DOM 정리)
    chartRef.current = null;      // ref 초기화
  };
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;chart.remove()&lt;/code&gt;를 빼먹으면 Hot Reload 때마다 차트가 겹쳐서 생깁니다. ResizeObserver도 해제하지 않으면 메모리 릭이 발생합니다. 실제로 개발 중에 이 문제를 겪었습니다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;이번 글에서 구현한 것은 다음과 같습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js Route Handler&lt;/strong&gt;로 업비트 REST API CORS 프록시&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TradingView Lightweight Charts v5&lt;/strong&gt; 래퍼 컴포넌트 (다크 테마, 반응형)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TanStack Query&lt;/strong&gt;로 초기 캔들 데이터 로딩 + 캐싱&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WebSocket trade → 실시간 캔들 업데이트&lt;/strong&gt; (기존 캔들 수정 + 새 캔들 생성)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ref 기반 업데이트&lt;/strong&gt;로 불필요한 리렌더링 방지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;다음 글에서는 프론트엔드에서 가장 까다로운 컴포넌트라고 할 수 있는 호가창을 다룹니다. useState, throttle, useRef+rAF 세 가지 방식의 렌더링 성능을 직접 비교해봤습니다.&lt;/p&gt;</description>
      <category>Frontend Development/[실습] 실시간 트레이딩 대시보드 만들기</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/152</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%94%A9-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-02-TradingView-Charts%EB%A1%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%BA%94%EB%93%A4-%EC%B0%A8%ED%8A%B8-%EA%B5%AC%ED%98%84#entry152comment</comments>
      <pubDate>Thu, 12 Feb 2026 11:55:57 +0900</pubDate>
    </item>
    <item>
      <title>[실시간 트레이딩 대시보드 만들기 - 01] WebSocket 파이프라인 설계와 Blob 파싱</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%94%A9-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-01-WebSocket-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EC%84%A4%EA%B3%84%EC%99%80-Blob-%ED%8C%8C%EC%8B%B1</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/4349cff5-b8a3-4cf4-a31b-f907e16f336e/image.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;업비트 Public WebSocket API를 사용해서 실시간 암호화폐 트레이딩 대시보드를 만들어본 기록입니다. 이번 1편에서는 WebSocket 연결부터 React 훅으로 래핑하는 과정까지 다룹니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;전체 소스코드는 GitHub에 공개되어 있습니다. 직접 클론해서 돌려보실 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/kimkuns91/upbit-realtime-dashboard.git
cd upbit-realtime-dashboard
npm install &amp;amp;&amp;amp; npm run dev&lt;/code&gt;&lt;/pre&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;1. 왜 WebSocket인가?&lt;/h2&gt;
&lt;p&gt;실시간 암호화폐 데이터를 프론트엔드에서 보여주려면 선택지가 몇 가지 있습니다. Polling, SSE(Server-Sent Events), WebSocket.&lt;/p&gt;
&lt;p&gt;호가창은 초당 10~20회 업데이트됩니다. 이걸 polling으로 처리하면 서버와 클라이언트 모두 힘들어집니다.&lt;/p&gt;
&lt;p&gt;SSE도 고려했지만, 트레이딩 대시보드 특성상 &lt;strong&gt;양방향 통신&lt;/strong&gt;이 필요합니다. 사용자가 구독 종목을 바꾸거나, 차트 타임프레임을 전환하거나, 호가창 필터를 변경하면 클라이언트에서 서버로 메시지를 보내야 합니다. SSE는 서버→클라이언트 단방향이라 이런 요청을 별도의 HTTP 호출로 처리해야 합니다. WebSocket은 하나의 연결에서 양방향 메시지를 주고받을 수 있으므로 이런 구조에 훨씬 자연스럽습니다.&lt;/p&gt;
&lt;p&gt;다만 업비트 WebSocket에는 한 가지 특이한 점이 있습니다.&lt;/p&gt;
&lt;h2&gt;2. 업비트 WebSocket의 특이점: Blob 응답&lt;/h2&gt;
&lt;p&gt;대부분의 WebSocket은 텍스트 형태로 데이터를 보내줍니다. &lt;code&gt;JSON.parse(event.data)&lt;/code&gt; 하나면 끝납니다. 그런데 업비트는 &lt;strong&gt;Blob&lt;/strong&gt; 형태로 보내줍니다.&lt;/p&gt;
&lt;p&gt;처음에 이걸 몰라서 &lt;code&gt;JSON.parse(event.data)&lt;/code&gt;가 왜 안 되는지 한참 찾았습니다. 실제로 필요한 파이프라인은 이렇습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Blob → .text() → JSON.parse()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;구현 코드입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Blob → Text → JSON 파싱 파이프라인
private async handleMessage(data: unknown): Promise&amp;lt;void&amp;gt; {
  try {
    let jsonStr: string;

    if (data instanceof Blob) {
      // 업비트 WS 응답은 Blob으로 온다
      jsonStr = await data.text();
    } else if (typeof data === &amp;#39;string&amp;#39;) {
      jsonStr = data;
    } else {
      return;
    }

    const parsed: UpbitSocketResponse = JSON.parse(jsonStr);
    this.dispatchMessage(parsed);
  } catch {
    // 파싱 실패 시 무시
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Blob.text()&lt;/code&gt;는 비동기입니다. 그래서 &lt;code&gt;handleMessage&lt;/code&gt; 자체가 &lt;code&gt;async&lt;/code&gt;여야 합니다. 이 부분을 동기로 처리하려다 삽질하는 경우가 꽤 있습니다.&lt;/p&gt;
&lt;h2&gt;3. 싱글톤 매니저 설계&lt;/h2&gt;
&lt;p&gt;WebSocket 연결을 어디에 둘 것인가가 중요한 설계 포인트입니다. 컴포넌트 안에서 &lt;code&gt;new WebSocket()&lt;/code&gt;을 직접 호출하면 문제가 생깁니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;React StrictMode에서 &lt;code&gt;useEffect&lt;/code&gt;가 두 번 실행되면 연결이 2개 생깁니다&lt;/li&gt;
&lt;li&gt;종목을 바꿀 때마다 연결을 끊고 새로 만들면 비용이 큽니다&lt;/li&gt;
&lt;li&gt;차트, 호가창, 현재가 컴포넌트가 같은 데이터를 구독하는데 연결을 따로 만들 이유가 없습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그래서 &lt;strong&gt;React 렌더링 사이클 바깥에서 살아가는 싱글톤 클래스&lt;/strong&gt;로 설계했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;class UpbitSocketManager {
  private static instance: UpbitSocketManager | null = null;
  private ws: WebSocket | null = null;
  private listeners: Set&amp;lt;SocketCallbacks&amp;gt; = new Set();

  private constructor() {} // 외부 생성 차단

  static getInstance(): UpbitSocketManager {
    if (!UpbitSocketManager.instance) {
      UpbitSocketManager.instance = new UpbitSocketManager();
    }
    return UpbitSocketManager.instance;
  }

  // 여러 컴포넌트가 각자 콜백을 등록
  addListener(callbacks: SocketCallbacks): void {
    this.listeners.add(callbacks);
  }

  removeListener(callbacks: SocketCallbacks): void {
    this.listeners.delete(callbacks);
  }
  // ...
}

export const upbitSocket = UpbitSocketManager.getInstance();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하나의 WebSocket 연결을 여러 컴포넌트가 공유합니다. 차트 컴포넌트는 &lt;code&gt;onTrade&lt;/code&gt;만, 호가창은 &lt;code&gt;onOrderbook&lt;/code&gt;만 받으면 됩니다.&lt;/p&gt;
&lt;h2&gt;4. 재연결 전략: Exponential Backoff&lt;/h2&gt;
&lt;p&gt;네트워크가 불안정하면 WebSocket은 끊어집니다. 이때 즉시 재연결을 시도하면 서버에 부담을 주고, 한 번만 시도하면 복구가 안 됩니다.&lt;/p&gt;
&lt;p&gt;Exponential backoff를 적용했습니다. 1초 → 2초 → 4초 → 8초 → ... → 최대 30초까지 간격을 늘려가며 재연결을 시도합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const INITIAL_RECONNECT_DELAY = 1000;  // 1초
const MAX_RECONNECT_DELAY = 30000;     // 최대 30초
const RECONNECT_MULTIPLIER = 2;

private scheduleReconnect(): void {
  this.setStatus(&amp;#39;reconnecting&amp;#39;);

  this.reconnectTimer = setTimeout(() =&amp;gt; {
    this.createConnection();
  }, this.reconnectDelay);

  // 1초 → 2초 → 4초 → 8초 → ... → 최대 30초
  this.reconnectDelay = Math.min(
    this.reconnectDelay * RECONNECT_MULTIPLIER,
    MAX_RECONNECT_DELAY
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;연결에 성공하면 딜레이를 1초로 리셋합니다. 그리고 이전에 구독하던 종목을 자동으로 다시 구독합니다. 사용자 입장에서는 끊겼다 붙었을 때 자연스럽게 복구되어야 하니까요.&lt;/p&gt;
&lt;h2&gt;5. React 커스텀 훅으로 래핑&lt;/h2&gt;
&lt;p&gt;싱글톤 매니저를 React에서 쓸 때는 lifecycle 관리가 핵심입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;export const useUpbitSocket = ({ market, types, enabled = true }: UseUpbitSocketOptions) =&amp;gt; {
  const { setTicker, setOrderbook, setConnectionStatus } = useRealtimeStore();
  const callbacksRef = useRef&amp;lt;SocketCallbacks | null&amp;gt;(null);

  useEffect(() =&amp;gt; {
    if (!enabled) return;

    const callbacks: SocketCallbacks = {
      onTicker: (data) =&amp;gt; {
        if (data.code === market) setTicker(data);
      },
      onOrderbook: (data) =&amp;gt; {
        if (data.code === market) setOrderbook(data);
      },
      onStatusChange: (status) =&amp;gt; setConnectionStatus(status),
    };

    callbacksRef.current = callbacks;

    // 리스너 등록 → 연결 → 구독
    upbitSocket.addListener(callbacks);
    upbitSocket.connect();
    upbitSocket.subscribe([market], types);

    // cleanup: 리스너만 해제 (연결은 유지)
    return () =&amp;gt; {
      if (callbacksRef.current) {
        upbitSocket.removeListener(callbacksRef.current);
        callbacksRef.current = null;
      }
    };
  }, [market, enabled]);
  // ...
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 주의할 점이 두 가지 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;cleanup에서 &lt;code&gt;disconnect()&lt;/code&gt;를 호출하지 않습니다.&lt;/strong&gt; 컴포넌트가 언마운트돼도 다른 컴포넌트가 같은 연결을 쓰고 있을 수 있습니다. cleanup에서는 리스너만 해제합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;callbacksRef&lt;/code&gt;로 콜백 참조를 관리합니다.&lt;/strong&gt; cleanup 시점에 등록한 콜백과 동일한 참조를 제거해야 하므로 ref에 저장해둡니다.&lt;/p&gt;
&lt;h2&gt;6. Next.js SSR 트러블슈팅&lt;/h2&gt;
&lt;p&gt;Next.js는 서버에서 먼저 렌더링합니다. WebSocket이나 &lt;code&gt;window&lt;/code&gt; 객체에 접근하는 코드가 서버에서 실행되면 에러가 납니다.&lt;/p&gt;
&lt;p&gt;해결법은 간단합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;WebSocket을 사용하는 컴포넌트에 &lt;code&gt;&amp;#39;use client&amp;#39;&lt;/code&gt; 디렉티브를 붙입니다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;useEffect&lt;/code&gt; 안에서만 WebSocket 관련 로직을 실행합니다&lt;/li&gt;
&lt;li&gt;싱글톤 매니저는 &lt;code&gt;import&lt;/code&gt;만으로는 WebSocket 인스턴스를 만들지 않습니다. &lt;code&gt;connect()&lt;/code&gt;를 호출해야 생성되므로 안전합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;이번 글에서 구현한 것은 다음과 같습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;UpbitSocketManager&lt;/strong&gt;: React 외부의 싱글톤 WebSocket 매니저&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blob → JSON 파싱&lt;/strong&gt;: 업비트 특유의 응답 포맷 처리&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exponential backoff&lt;/strong&gt;: 안정적인 재연결 전략&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;useUpbitSocket&lt;/strong&gt;: React lifecycle에 맞춘 커스텀 훅&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;다음 글에서는 이 파이프라인 위에 TradingView Lightweight Charts로 실시간 캔들 차트를 올리는 과정을 다룹니다.&lt;/p&gt;</description>
      <category>Frontend Development/[실습] 실시간 트레이딩 대시보드 만들기</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/151</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%94%A9-%EB%8C%80%EC%8B%9C%EB%B3%B4%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-01-WebSocket-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EC%84%A4%EA%B3%84%EC%99%80-Blob-%ED%8C%8C%EC%8B%B1#entry151comment</comments>
      <pubDate>Thu, 12 Feb 2026 11:52:47 +0900</pubDate>
    </item>
    <item>
      <title>&amp;quot;혼자인데 팀인 것처럼&amp;quot; &amp;mdash; Claude Code Agent Teams로 AI 개발팀 꾸려본 후기 (Next.js)</title>
      <link>https://white-mouse-dev.tistory.com/entry/%ED%98%BC%EC%9E%90%EC%9D%B8%EB%8D%B0-%ED%8C%80%EC%9D%B8-%EA%B2%83%EC%B2%98%EB%9F%BC-%E2%80%94-Claude-Code-Agent-Teams%EB%A1%9C-AI-%EA%B0%9C%EB%B0%9C%ED%8C%80-%EA%BE%B8%EB%A0%A4%EB%B3%B8-%ED%9B%84%EA%B8%B0-Nextjs</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/62042834-d048-482d-b66e-831bfd18f8ae/image.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Claude Code의 실험 기능인 Agent Teams를 Next.js 프로젝트에서 직접 돌려본 후기입니다. 팀 리드가 태스크를 분배하고, 3명의 팀메이트가 병렬로 코드를 작성하는 과정을 확인했습니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;1. 왜 해봤나?&lt;/h2&gt;
&lt;p&gt;Claude Code는 원래 &amp;quot;하나의 AI가 하나의 세션에서 일하는&amp;quot; 구조입니다. 하지만 실제 개발은 그렇지 않습니다. 프론트 따로, 백엔드 따로, 테스트 따로 — 사람이 팀으로 일하듯이 AI도 팀으로 일하면 어떨까요?&lt;/p&gt;
&lt;p&gt;Anthropic이 Opus 4.6 출시와 함께 &lt;strong&gt;Agent Teams&lt;/strong&gt;라는 실험 기능을 내놓았습니다. 여러 Claude Code 세션이 팀처럼 협업하는 기능입니다. 한 세션이 리드가 되고, 나머지가 팀메이트로 각자 독립된 컨텍스트에서 작업하면서 서로 메시지를 주고받습니다.&lt;/p&gt;
&lt;p&gt;기존 subagent와의 차이점은 구조에 있습니다. subagent는 메인 에이전트에게만 결과를 보고하는 수직 구조입니다. 반면 Agent Teams는 &lt;strong&gt;팀메이트끼리 직접 소통&lt;/strong&gt;합니다. 실제 개발팀이 슬랙으로 대화하면서 일하는 것과 비슷한 방식입니다.&lt;/p&gt;
&lt;p&gt;이 기능이 얼마나 실용적인지 궁금해서 직접 Next.js 프로젝트를 하나 만들어서 돌려보았습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. 데모 프로젝트: AI 북마크 대시보드&lt;/h2&gt;
&lt;p&gt;테스트용으로 만든 프로젝트는 &amp;quot;AI 북마크 대시보드&amp;quot;입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;URL을 입력하면 도메인 기반으로 자동 태깅 (github.com → &amp;quot;dev&amp;quot;, youtube.com → &amp;quot;video&amp;quot;)&lt;/li&gt;
&lt;li&gt;북마크 목록 표시&lt;/li&gt;
&lt;li&gt;대시보드에서 태그별 통계 차트&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 프로젝트를 선택한 이유는 &lt;strong&gt;레이어가 명확히 분리&lt;/strong&gt;되기 때문입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;UI 레이어&lt;/strong&gt; — 랜딩 페이지 + 대시보드&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;API 레이어&lt;/strong&gt; — CRUD + 자동 태깅 로직&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;테스트 레이어&lt;/strong&gt; — 유닛 + 통합 테스트&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;서로 다른 파일을 건드리기 때문에 팀메이트들이 충돌 없이 병렬로 작업할 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. 환경 세팅&lt;/h2&gt;
&lt;h3&gt;3-1. 프로젝트 생성&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm create next-app@latest bookmark-dashboard \
  --typescript --tailwind --eslint --app --src-dir&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;React Compiler도 활성화했습니다. Next.js 16, React 19, TypeScript 5.9 — 최신 스택입니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/69e27117-f52e-4c09-8159-a08aa7934d2d/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3&gt;3-2. Agent Teams 활성화&lt;/h3&gt;
&lt;p&gt;실험 기능이라 기본 비활성화 상태입니다. 프로젝트 단위로 켜는 것이 깔끔합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p .claude
echo &amp;#39;{&amp;quot;env&amp;quot;:{&amp;quot;CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS&amp;quot;:&amp;quot;1&amp;quot;}}&amp;#39; &amp;gt; .claude/settings.json&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.zshrc&lt;/code&gt;에 넣으면 모든 프로젝트에 적용되고, &lt;code&gt;~/.claude/settings.json&lt;/code&gt;에 넣으면 Claude Code 전역 설정이 됩니다. 이번에는 데모 프로젝트에서만 사용할 것이기 때문에 프로젝트 로컬 설정으로 진행했습니다.&lt;/p&gt;
&lt;h3&gt;3-3. CLAUDE.md 작성&lt;/h3&gt;
&lt;p&gt;이 부분이 중요합니다. &lt;strong&gt;팀메이트들이 이 파일을 읽고 프로젝트 컨텍스트를 파악합니다.&lt;/strong&gt; 기술 스택, 디렉토리 구조, 컨벤션을 여기에 적어두면 팀메이트마다 일일이 설명할 필요가 없습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;# Bookmark Dashboard

## Tech Stack
- Next.js 16 (App Router)
- React 19 + React Compiler
- TypeScript 5.9 (strict mode)
- Tailwind CSS
- Zustand (client state)
- TanStack Query (server state)

## Conventions
- 컴포넌트는 named export
- API response: { data, error }
- 타입은 @/lib/types에서 import
- 테스트는 __tests__/ 하위에 배치&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3-4. 공유 타입 먼저 만들기&lt;/h3&gt;
&lt;p&gt;Agent Teams 실행 &lt;strong&gt;전에&lt;/strong&gt; 반드시 해야 할 것이 있습니다. 팀메이트들이 공유할 타입을 미리 정의해두는 것입니다. 이 작업을 생략하면 각 팀메이트가 제각각 타입을 만들어서 나중에 충돌이 발생합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/lib/types.ts
export interface Bookmark {
  id: string;
  url: string;
  title: string;
  description: string;
  tags: string[];
  favicon: string;
  createdAt: string;
}

export interface Tag {
  id: string;
  name: string;
  count: number;
}

export interface ApiResponse&amp;lt;T&amp;gt; {
  data: T | null;
  error: string | null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;4. tmux 설치 (split-pane 모드 필수)&lt;/h2&gt;
&lt;p&gt;Agent Teams의 split-pane 모드를 사용하려면 &lt;strong&gt;tmux가 필요합니다.&lt;/strong&gt; 팀메이트별로 별도 터미널 패널이 생기는데, 이를 tmux가 관리합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;brew install tmux&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;참고로 iTerm2에서도 Agent Teams 자체는 동작합니다. 하지만 split-pane 없이 in-process 모드로 실행되면 리드 화면 하나에서만 로그가 출력됩니다. 팀메이트들이 동시에 작업하는 과정을 시각적으로 확인하려면 tmux가 필수입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;VS Code 통합 터미널, Windows Terminal, Ghostty에서는 split-pane이 지원되지 않습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5. Agent Teams 실행&lt;/h2&gt;
&lt;h3&gt;5-1. tmux 세션 안에서 Claude Code 실행&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tmux
cd bookmark-dashboard
claude&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5-2. 프롬프트 입력&lt;/h3&gt;
&lt;p&gt;Claude Code 채팅창에 다음을 입력합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Create an agent team in split-pane mode for this bookmark-dashboard project.
Spawn three teammates:

1. &amp;quot;ui-dev&amp;quot;: Build the landing page (src/app/page.tsx) with a 
   bookmark input form (URL 입력 → 자동 태깅) and bookmark list display.
   Also build the dashboard page (src/app/dashboard/page.tsx) with 
   tag statistics using simple bar chart (CSS-only, no chart library).
   Use Tailwind CSS. Import types from @/lib/types.

2. &amp;quot;api-dev&amp;quot;: Build all API routes:
   - src/app/api/bookmarks/route.ts (GET, POST)
   - src/app/api/bookmarks/[id]/route.ts (PATCH, DELETE)
   - src/app/api/tags/route.ts (GET)
   Use in-memory array storage. Implement auto-tagging by domain 
   (github.com→&amp;quot;dev&amp;quot;, youtube.com→&amp;quot;video&amp;quot;, etc) and title keywords.
   Import types from @/lib/types.

3. &amp;quot;test-dev&amp;quot;: Write Vitest unit tests for API route handlers 
   in src/__tests__/. Start with test scaffolding immediately,
   then write integration tests after ui-dev and api-dev report 
   their completed file paths.

ui-dev and api-dev work in parallel first.
Coordinate through the shared task list.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;CLAUDE.md는 프로젝트 컨텍스트(이 프로젝트는 이런 구조다)이고, 이 프롬프트는 작업 지시(팀을 구성하고 이렇게 일해라)입니다. 역할이 다릅니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/62b8cec4-9ab8-49ed-a5c8-b5398e9a44f9/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. 실행 과정 관찰&lt;/h2&gt;
&lt;h3&gt;리드가 먼저 움직인다&lt;/h3&gt;
&lt;p&gt;프롬프트를 입력하면 리드가 먼저 프로젝트 상태를 확인합니다. 기존 파일을 읽고, Zustand store를 만들고, 디렉토리 구조를 세팅한 다음 팀메이트를 spawn합니다.&lt;/p&gt;
&lt;h3&gt;split-pane으로 팀메이트 등장&lt;/h3&gt;
&lt;p&gt;리드가 3명의 팀메이트를 spawn하면 tmux 패널이 자동으로 분할됩니다. 왼쪽에 리드, 오른쪽에 팀메이트들이 각각의 패널에서 동시에 작업을 시작합니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/a065044d-ad34-4742-b020-0287f19270c7/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;이 부분이 가장 인상적이었습니다. 리드 패널에는 다음과 같은 테이블이 표시됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Team: bookmark-team — 3 agents spawned in parallel

| Agent    | Task                                          | Status  |
|----------|-----------------------------------------------|---------|
| api-dev  | Task #1: API routes (bookmarks CRUD, tags)    | Running |
| ui-dev   | Task #2: Dashboard page (tag stats, CSS chart)| Running |
| test-dev | Task #3: Vitest tests (blocked by Task #1)    | Running |&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;팀메이트 간 조율&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;api-dev&lt;/strong&gt;와 &lt;strong&gt;ui-dev&lt;/strong&gt;는 즉시 병렬로 작업을 시작합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;test-dev&lt;/strong&gt;는 scaffolding을 먼저 만들면서 api-dev 완료를 대기합니다.&lt;/li&gt;
&lt;li&gt;팀메이트가 파일 시스템 접근 같은 권한이 필요하면 리드에게 승인 요청을 보냅니다. (&amp;quot;Waiting for team lead approval&amp;quot;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;권한 승인&lt;/h3&gt;
&lt;p&gt;팀메이트가 파일을 생성하거나 bash 명령을 실행할 때 리드에게 permission request가 전달됩니다. 리드 패널에서 &lt;code&gt;1&lt;/code&gt; (Yes)을 눌러서 승인하면 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;주의:&lt;/strong&gt; tmux에서는 마우스 클릭이 아니라 키보드로 조작합니다. 패널 간 이동은 &lt;code&gt;Ctrl+b → 방향키&lt;/code&gt;, 승인은 &lt;code&gt;1&lt;/code&gt; + &lt;code&gt;Enter&lt;/code&gt;입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7. 결과물&lt;/h2&gt;
&lt;p&gt;약 7분 정도 지나면 팀메이트들이 각자의 작업을 완료합니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/06bab75f-1646-468f-8494-22abe17e0252/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3&gt;생성된 파일 구조&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;src/
  app/
    page.tsx                    ← ui-dev가 만든 랜딩 페이지
    dashboard/
      page.tsx                  ← ui-dev가 만든 대시보드
    api/
      bookmarks/
        route.ts                ← api-dev가 만든 CRUD
        [id]/
          route.ts              ← api-dev가 만든 개별 북마크 API
      tags/
        route.ts                ← api-dev가 만든 태그 API
  lib/
    types.ts                    ← 미리 만들어둔 공유 타입
    store.ts                    ← 리드가 만든 Zustand store
  __tests__/
    bookmarks.test.ts           ← test-dev가 만든 유닛 테스트&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;실행 확인&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm dev&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/30206de0-8f2a-4d0c-939b-dce7ca704cb6/image.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/1e0a842f-1a57-488b-bcad-5b55641d2536/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;8. 핵심 메커니즘 정리&lt;/h2&gt;
&lt;p&gt;실제로 사용하면서 파악한 Agent Teams의 내부 동작입니다.&lt;/p&gt;
&lt;h3&gt;공유 태스크 리스트&lt;/h3&gt;
&lt;p&gt;리드가 태스크를 만들면 &lt;code&gt;~/.claude/teams/{팀명}/&lt;/code&gt; 하위에 태스크 파일이 생성됩니다. 팀메이트는 자기가 수행할 수 있는 태스크를 &lt;strong&gt;자동으로 claim(자기 할당)&lt;/strong&gt; 합니다. 파일 락킹으로 race condition을 방지합니다.&lt;/p&gt;
&lt;h3&gt;메일박스 시스템&lt;/h3&gt;
&lt;p&gt;각 에이전트별로 메일박스가 있어서 서로 메시지를 주고받습니다. test-dev가 api-dev에게 &amp;quot;어떤 파일을 만들었는지 알려달라&amp;quot;고 요청하고, api-dev가 완료 후 파일 경로를 보내주는 방식입니다.&lt;/p&gt;
&lt;h3&gt;라이프사이클 관리&lt;/h3&gt;
&lt;p&gt;작업이 끝나면 리드에서 shutdown + cleanup을 해야 합니다. &lt;strong&gt;cleanup은 반드시 리드에서 실행해야 합니다.&lt;/strong&gt; 팀메이트에서 cleanup을 돌리면 리소스가 꼬일 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Shut down all teammates and clean up the team.&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;9. 느낀 점&lt;/h2&gt;
&lt;h3&gt;좋았던 것&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;병렬 작업이 실제로 동작합니다.&lt;/strong&gt; api-dev가 route를 만드는 동안 ui-dev가 페이지를 만듭니다. 순차적으로 했으면 2배 이상 걸렸을 작업이 동시에 진행됩니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CLAUDE.md의 위력을 체감했습니다.&lt;/strong&gt; 팀메이트별로 일일이 컨텍스트를 설명할 필요가 없습니다. CLAUDE.md에 컨벤션과 구조를 잘 적어두면 모든 팀메이트가 같은 규칙을 따릅니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;태스크 의존성 관리가 자연스럽습니다.&lt;/strong&gt; test-dev가 api-dev 완료를 기다렸다가 통합 테스트를 작성하는 흐름이 자동으로 동작합니다.&lt;/p&gt;
&lt;h3&gt;아쉬운 점&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;토큰 소비가 큽니다.&lt;/strong&gt; 팀메이트 하나당 독립 컨텍스트를 사용하기 때문에 3명이면 최소 3배입니다. Claude Max 요금제도 빠르게 한도에 도달할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;권한 승인이 번거롭습니다.&lt;/strong&gt; 팀메이트마다 파일 생성할 때 리드에서 승인해줘야 합니다. tmux 패널을 왔다 갔다 하면서 &lt;code&gt;1&lt;/code&gt; + &lt;code&gt;Enter&lt;/code&gt;를 반복하게 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;아직 실험 기능입니다.&lt;/strong&gt; 세션 재개, 종료 동작에 알려진 제한이 있습니다. 프로덕션 워크플로우로 사용하기에는 이른 단계입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;10. 언제 쓰면 좋을까?&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상황&lt;/th&gt;
&lt;th&gt;추천&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;서로 다른 레이어 동시 개발 (프론트/백/테스트)&lt;/td&gt;
&lt;td&gt;✅ Agent Teams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PR 리뷰 (보안/성능/테스트 관점 병렬 검토)&lt;/td&gt;
&lt;td&gt;✅ Agent Teams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;디버깅 — 여러 가설 병렬 검증&lt;/td&gt;
&lt;td&gt;✅ Agent Teams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MSA 크로스 서비스 리팩토링&lt;/td&gt;
&lt;td&gt;✅ Agent Teams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;같은 파일 수정&lt;/td&gt;
&lt;td&gt;❌ 단일 세션&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;순차적 작업 (A 끝나야 B 시작)&lt;/td&gt;
&lt;td&gt;❌ 단일 세션&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;간단한 버그 수정&lt;/td&gt;
&lt;td&gt;❌ 단일 세션&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;판단 기준은 간단합니다.&lt;/strong&gt; *&amp;quot;이 작업을 사람 3명에게 각각 맡기면 서로 안 부딪히고 병렬로 할 수 있는가?&amp;quot;* Yes라면 Agent Teams, No라면 단일 세션입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;Claude Code Agent Teams는 &amp;quot;AI가 팀으로 일한다&amp;quot;는 개념을 실제로 체험할 수 있는 기능입니다. 아직 실험 단계라 거친 부분이 있지만, 병렬 작업이 가능한 프로젝트에서는 확실히 효율적입니다.&lt;/p&gt;
&lt;p&gt;특히 MSA 같은 멀티 서비스 프로젝트에서 크로스 서비스 리팩토링이나 새 모듈 추가 같은 작업에 적합할 것으로 보입니다. 토큰 비용과 안정성이 개선되면 실무 워크플로우에도 충분히 도입할 수 있을 것이라 생각합니다.&lt;/p&gt;
&lt;p&gt;사용해보고 싶다면 간단한 프로젝트부터 시작하는 것을 추천합니다. PR 리뷰에 3명의 리뷰어를 붙여보는 것도 좋은 시작점입니다.&lt;/p&gt;</description>
      <category>Tools</category>
      <category>AgentTeams</category>
      <category>AI개발</category>
      <category>claudecode</category>
      <category>multiagent</category>
      <category>Nextjs</category>
      <category>개발자도구</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/150</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%ED%98%BC%EC%9E%90%EC%9D%B8%EB%8D%B0-%ED%8C%80%EC%9D%B8-%EA%B2%83%EC%B2%98%EB%9F%BC-%E2%80%94-Claude-Code-Agent-Teams%EB%A1%9C-AI-%EA%B0%9C%EB%B0%9C%ED%8C%80-%EA%BE%B8%EB%A0%A4%EB%B3%B8-%ED%9B%84%EA%B8%B0-Nextjs#entry150comment</comments>
      <pubDate>Tue, 10 Feb 2026 17:50:42 +0900</pubDate>
    </item>
    <item>
      <title>MSA 초기 단계, 굳이 클라우드 API Gateway가 필요할까?</title>
      <link>https://white-mouse-dev.tistory.com/entry/MSA-%EC%B4%88%EA%B8%B0-%EB%8B%A8%EA%B3%84-%EA%B5%B3%EC%9D%B4-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-API-Gateway%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%A0%EA%B9%8C</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/875b5207-b8c5-4b0e-91e5-2f67dc3d6496/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Nginx vs AWS API Gateway vs GCP API Gateway — 우리 팀이 Nginx를 선택한 이유&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;최근 물류 센터 자동화 프로젝트를 진행하면서, &lt;strong&gt;AI 추론(Inference) 서버&lt;/strong&gt;와 &lt;strong&gt;일반 백엔드(Data Ops/Orchestrator) 서버&lt;/strong&gt;를 물리적으로 분리하는 작업을 진행했습니다. GPU가 필요한 무거운 작업과 일반적인 CPU 작업을 나누어 자원 효율을 극대화하기 위함입니다.&lt;/p&gt;
&lt;p&gt;하지만 이렇게 서버를 분리하고 나니 &lt;strong&gt;진입점(Entry Point) 문제&lt;/strong&gt;가 발생했습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. 직면한 문제: 클라이언트가 너무 많은 주소를 알아야 한다&lt;/h2&gt;
&lt;p&gt;서버를 기능별로 분리하니 네트워크 구성이 아래와 같이 되었습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;서버&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;th&gt;주소&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;GPU 서버&lt;/td&gt;
&lt;td&gt;Inference&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.1.101:8001&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU 서버&lt;/td&gt;
&lt;td&gt;Data Ops&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.1.100:8002&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU 서버&lt;/td&gt;
&lt;td&gt;Orchestrator&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.1.100:8003&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;이 상태로 백엔드(WMS)나 프론트엔드가 각 서버에 직접 요청을 보내게 되면 다음과 같은 문제들이 생깁니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CORS Hell&lt;/strong&gt; — 포트가 다르니 브라우저는 다른 출처로 인식합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;보안 인증서 관리&lt;/strong&gt; — 각 포트/서버마다 SSL 인증서를 따로 관리해야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;결합도 증가&lt;/strong&gt; — 서버 IP가 바뀌거나 구조가 변경되면 클라이언트 코드도 전부 수정해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;결국 &lt;strong&gt;단일 진입점(Single Entry Point)&lt;/strong&gt; 이 필요했습니다. 이때 고민하게 되는 것이 *&amp;quot;Nginx로 직접 구축할 것이냐, 클라우드 벤더의 API Gateway를 쓸 것이냐&amp;quot;* 입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. 선택지 비교: Nginx vs AWS API Gateway vs GCP API Gateway&lt;/h2&gt;
&lt;p&gt;단일 진입점 역할을 수행할 수 있는 대표적인 3가지 방법을 비교해 보았습니다.&lt;/p&gt;
&lt;h3&gt;① Nginx (Reverse Proxy)&lt;/h3&gt;
&lt;p&gt;가장 전통적이고 강력한 웹 서버이자 프록시 서버입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;장점&lt;/strong&gt;: 가볍고 빠르며 무료(오픈소스). 설정 파일(&lt;code&gt;nginx.conf&lt;/code&gt;) 하나로 라우팅, 로드밸런싱, SSL 종료 처리가 가능합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;단점&lt;/strong&gt;: 직접 서버에 설치하고 관리해야 합니다. 인증이나 복잡한 트래픽 제어(Throttling)를 구현하려면 손이 많이 갑니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;② AWS API Gateway&lt;/h3&gt;
&lt;p&gt;AWS의 완전 관리형 서비스입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;장점&lt;/strong&gt;: 서버 관리가 필요 없음(Serverless). AWS Lambda와 찰떡궁합이며, API 키 발급·요청량 제한(Throttling)·모니터링 대시보드 등을 클릭 몇 번으로 구성할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;단점&lt;/strong&gt;: 트래픽이 많아지면 비용이 꽤 나옵니다. Nginx에 비해 단순 프록시 속도는 미세하게 느릴 수 있습니다(Latency).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;③ GCP API Gateway&lt;/h3&gt;
&lt;p&gt;구글 클라우드의 관리형 서비스입니다. 내부적으로는 Envoy Proxy 기반입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;장점&lt;/strong&gt;: Cloud Run, App Engine, Cloud Functions와 연동이 매우 쉽습니다. OpenAPI 사양(Swagger)을 업로드하여 설정을 관리하므로 문서화와 설정이 일원화됩니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;단점&lt;/strong&gt;: AWS API Gateway에 비해 기능적 성숙도가 조금 낮고 커뮤니티 레퍼런스가 적은 편입니다. 엔터프라이즈 급에서는 Apigee를 쓰라고 유도하는 경향이 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;3. 한눈에 보는 비교표&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;th&gt;Nginx&lt;/th&gt;
&lt;th&gt;AWS API Gateway&lt;/th&gt;
&lt;th&gt;GCP API Gateway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;유형&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Self-Hosted (직접 설치)&lt;/td&gt;
&lt;td&gt;Fully Managed (완전 관리형)&lt;/td&gt;
&lt;td&gt;Fully Managed (완전 관리형)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;주요 역할&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reverse Proxy, Load Balancer&lt;/td&gt;
&lt;td&gt;API 관리, 인증, 트래픽 제어&lt;/td&gt;
&lt;td&gt;API 관리, Serverless 게이트웨이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;비용&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;무료 (기존 서버 자원 사용)&lt;/td&gt;
&lt;td&gt;호출 횟수당 과금 (은근 비쌈)&lt;/td&gt;
&lt;td&gt;호출 횟수당 과금&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;인증 처리&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;직접 구현 필요&lt;/td&gt;
&lt;td&gt;Cognito, Lambda Authorizer 연동&lt;/td&gt;
&lt;td&gt;Firebase Auth, Service Account 연동&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;설정 방식&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;conf&lt;/code&gt; 파일 수정&lt;/td&gt;
&lt;td&gt;AWS 콘솔 / Terraform&lt;/td&gt;
&lt;td&gt;OpenAPI Spec (YAML)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;추천 대상&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;초기 스타트업, 비용 민감, 단순 라우팅&lt;/td&gt;
&lt;td&gt;Serverless 중심, 복잡한 API 관리&lt;/td&gt;
&lt;td&gt;GCP 생태계, Cloud Run 중심&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;4. 우리의 선택: &amp;quot;일단 Nginx로 충분하다&amp;quot;&lt;/h2&gt;
&lt;p&gt;저희 팀은 현재 단계에서 &lt;strong&gt;Nginx를 선택&lt;/strong&gt;했습니다. 이유는 명확합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Over-Engineering 방지&lt;/strong&gt; — 현재 우리에게 필요한 건 거창한 API 관리(키 발급, 사용량 제한)가 아니라, 단순히 요청 경로에 따라 다른 포트로 넘겨주는 라우팅 기능뿐입니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;비용 효율&lt;/strong&gt; — 이미 ECS 인스턴스가 떠 있는 상태에서 Nginx 컨테이너 하나 더 띄우는 건 추가 비용이 &lt;strong&gt;0원&lt;/strong&gt;입니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;속도&lt;/strong&gt; — 내부망에서의 단순 프록시 처리는 Nginx가 가장 빠르고 효율적입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;적용한 아키텍처&lt;/h3&gt;
&lt;p&gt;경로(Path) 기반 라우팅을 아래와 같이 구성했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://api.xxx.com/api/v1/inspection/*  → GPU 서버 (8001)
https://api.xxx.com/api/v1/images/*      → Data Ops (8002)
https://api.xxx.com/api/v1/jobs/*        → Orchestrator (8003)&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;실제 적용한 Nginx 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;server {
    listen 443 ssl;
    server_name api.cv-inspection.example.com;

    # SSL 설정 생략...

    # 1. AI 추론 요청 → GPU 서버
    location /api/v1/inspection {
        proxy_pass http://192.168.1.101:8001;
        proxy_read_timeout 300s;  # AI 모델 추론 시간 고려
    }

    # 2. 이미지 데이터 조회 → Data Ops 서버
    location /api/v1/images {
        proxy_pass http://127.0.0.1:8002;
    }

    # 3. 작업 상태 관리 → Orchestrator 서버
    location /api/v1/jobs {
        proxy_pass http://127.0.0.1:8003;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;5. 결론&lt;/h2&gt;
&lt;p&gt;*&amp;quot;AWS나 GCP의 API Gateway가 더 최신 기술 아니야?&amp;quot;* 라고 생각할 수 있지만, 기술 선택의 기준은 언제나 &lt;strong&gt;&amp;quot;현재 우리의 문제 해결에 적합한가?&amp;quot;&lt;/strong&gt; 여야 합니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상황&lt;/th&gt;
&lt;th&gt;추천&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;서버리스(Lambda/Cloud Functions)를 적극적으로 쓴다면&lt;/td&gt;
&lt;td&gt;☁️ Cloud API Gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 파트너에게 API를 유료로 제공하고 트래픽 제한이 필요하다면&lt;/td&gt;
&lt;td&gt;☁️ Cloud API Gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;내부 서비스 간 라우팅과 단일 진입점이 목적이라면&lt;/td&gt;
&lt;td&gt;  Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;기술 부채는 나쁜 코드를 짤 때만 쌓이는 게 아닙니다. &lt;strong&gt;필요 이상으로 복잡한 인프라를 도입하는 것 또한 미래의 부채가 될 수 있습니다.&lt;/strong&gt; 우리는 가장 심플하고 확실한 방법인 Nginx로 이 문제를 해결했습니다.&lt;/p&gt;</description>
      <category>자격증 공부/GCP Developer</category>
      <category>API Gateway</category>
      <category>Infra</category>
      <category>MSA</category>
      <category>Nginx</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/149</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/MSA-%EC%B4%88%EA%B8%B0-%EB%8B%A8%EA%B3%84-%EA%B5%B3%EC%9D%B4-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-API-Gateway%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%A0%EA%B9%8C#entry149comment</comments>
      <pubDate>Thu, 5 Feb 2026 13:59:56 +0900</pubDate>
    </item>
    <item>
      <title>packages/common/common/ 폴더 구조가 이상한가요? Python Src Layout 완벽 가이드</title>
      <link>https://white-mouse-dev.tistory.com/entry/packagescommoncommon-%ED%8F%B4%EB%8D%94-%EA%B5%AC%EC%A1%B0%EA%B0%80-%EC%9D%B4%EC%83%81%ED%95%9C%EA%B0%80%EC%9A%94-Python-Src-Layout-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/cd56f710-d782-454d-af78-75a538a3d72f/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&amp;quot;이거... 폴더 이름 실수로 두 번 쓴 거 아닌가?&amp;quot;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;사내 물류 자동화 시스템을 Turborepo 모노레포로 구축하던 중이었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;packages/
└── common/
    ├── pyproject.toml
    └── common/          ← 이게 뭐야?
        ├── __init__.py
        └── utils.py&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;처음 보는 사람은 십중팔구 &amp;quot;폴더명 중복 아니냐&amp;quot;고 묻는다. 나도 그랬다.&lt;/p&gt;
&lt;p&gt;&amp;quot;Hatchling 설정을 잘못 건드린 건가?&amp;quot;&lt;br&gt;&amp;quot;common 하나만 있으면 되는 거 아닌가?&amp;quot;&lt;/p&gt;
&lt;p&gt;결론부터 말하면, &lt;strong&gt;이건 실수가 아니다.&lt;/strong&gt; Python 패키징의 정석 중 하나인 &lt;strong&gt;Flat Layout&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;하지만 팩트 체크를 하다가 충격적인 사실을 알았다. requests, 그 유명한 requests도 이 구조를 버렸다.&lt;/p&gt;
&lt;p&gt;오늘은 이 &amp;quot;못생긴&amp;quot; 구조가 왜 필요한지, 그리고 왜 모던 프로젝트들이 떠나고 있는지 정리한다.&lt;/p&gt;
&lt;h2&gt;Flat Layout: 왜 폴더가 두 번인가?&lt;/h2&gt;
&lt;h3&gt;문제 상황&lt;/h3&gt;
&lt;p&gt;이렇게 하면 안 되는 걸까?&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;packages/common/
├── pyproject.toml
├── __init__.py
├── config.py
└── utils.py&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;깔끔해 보인다. 하지만 pip install 하면 터진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ pip install -e packages/common
$ python
&amp;gt;&amp;gt;&amp;gt; import common
ModuleNotFoundError: No module named &amp;#39;common&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;왜 안 되는가?&lt;/h3&gt;
&lt;p&gt;Python 빌드 도구(Hatchling, Setuptools 등)는 &amp;quot;프로젝트 루트&amp;quot;와 &amp;quot;패키지 소스&amp;quot;를 구분해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;프로젝트 루트 (Project Root):
- pyproject.toml
- README.md
- tests/
- 기타 설정 파일들

패키지 소스 (Package Source):
- 실제로 import할 Python 코드
- __init__.py가 있는 디렉토리&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위 구조대로 하면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Python이 보는 것
packages/common/  ← 이게 패키지인가?
├── pyproject.toml  ← 이건 Python 모듈이 아닌데?
├── __init__.py     ← 어? 이건 모듈이네?&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python이 혼란스러워한다. &lt;code&gt;packages/common&lt;/code&gt;을 패키지로 볼지, 프로젝트 루트로 볼지 모호하다.&lt;/p&gt;
&lt;h3&gt;Flat Layout의 해결책&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;packages/common/            ← 프로젝트 루트 (설정 파일들)
├── pyproject.toml
├── README.md
├── tests/
└── common/                 ← 패키지 소스 (실제 코드)
    ├── __init__.py
    ├── config.py
    └── utils.py&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;역할 분리:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;outer common/: &amp;quot;패키지 프로젝트 폴더&amp;quot; (빌드 설정)
inner common/: &amp;quot;패키지 소스 코드&amp;quot; (import 대상)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;동작:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ pip install -e packages/common
$ python
&amp;gt;&amp;gt;&amp;gt; import common  # inner common을 import
&amp;gt;&amp;gt;&amp;gt; common.config
&amp;lt;module &amp;#39;common.config&amp;#39; from &amp;#39;.../common/config.py&amp;#39;&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;pyproject.toml 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[build-system]
requires = [&amp;quot;hatchling&amp;quot;]
build-backend = &amp;quot;hatchling.build&amp;quot;

[project]
name = &amp;quot;common&amp;quot;
version = &amp;quot;0.1.0&amp;quot;

[tool.hatch.build.targets.wheel]
packages = [&amp;quot;common&amp;quot;]  # inner common 폴더를 패키징&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Hatchling은 &lt;code&gt;packages = [&amp;quot;common&amp;quot;]&lt;/code&gt;을 보고 &amp;quot;아, 프로젝트 루트 바로 아래 &lt;code&gt;common/&lt;/code&gt; 디렉토리를 패키지로 만들어야겠구나&amp;quot;라고 이해한다.&lt;/p&gt;
&lt;h2&gt;Src Layout: 더 명확한 대안&lt;/h2&gt;
&lt;p&gt;&amp;quot;폴더 이름 반복이 너무 별로다&amp;quot;는 개발자들을 위한 대안이 있다.&lt;/p&gt;
&lt;h3&gt;구조&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;packages/common/
├── pyproject.toml
├── tests/
└── src/                    ← 중간 계층 추가
    └── common/
        ├── __init__.py
        ├── config.py
        └── utils.py&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;pyproject.toml 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[build-system]
requires = [&amp;quot;hatchling&amp;quot;]
build-backend = &amp;quot;hatchling.build&amp;quot;

[project]
name = &amp;quot;common&amp;quot;
version = &amp;quot;0.1.0&amp;quot;

[tool.hatch.build.targets.wheel]
packages = [&amp;quot;src/common&amp;quot;]  # src 아래 common을 패키징&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Flat vs Src의 결정적 차이 3가지&lt;/h3&gt;
&lt;p&gt;공식 문서에서 강조하는 핵심 차이점이 있다.&lt;/p&gt;
&lt;h4&gt;1. Editable Install 필수 여부&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Flat Layout:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;packages/common/
├── pyproject.toml
└── common/
    └── __init__.py

# 설치 없이도 실행 가능
$ cd packages/common
$ python
&amp;gt;&amp;gt;&amp;gt; import common  # 작동함 (현재 디렉토리에 있으니까)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Src Layout:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;packages/common/
├── pyproject.toml
└── src/
    └── common/
        └── __init__.py

# 반드시 설치 필요
$ cd packages/common
$ python
&amp;gt;&amp;gt;&amp;gt; import common  # ModuleNotFoundError!

# Editable install 필요
$ pip install -e .
&amp;gt;&amp;gt;&amp;gt; import common  # 이제 작동&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;의미:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Flat: 개발 중에 &amp;quot;그냥 실행&amp;quot;하면 됨
Src: 개발 중에도 &amp;quot;설치하고 실행&amp;quot;해야 함

장점: 배포 환경과 동일하게 테스트 가능
단점: 개발 워크플로우에 단계 추가&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;2. 현재 디렉토리 우선순위 문제 방지&lt;/h4&gt;
&lt;p&gt;Python의 치명적 함정이 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Python의 import 우선순위
sys.path = [
    &amp;#39;&amp;#39;,  # ← 1순위: 현재 디렉토리!
    &amp;#39;/usr/lib/python3.11/site-packages&amp;#39;,  # 2순위: 설치된 패키지
    # ...
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Flat Layout의 위험:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 시나리오: pip install common 했다고 가정
$ cd ~/projects/myapp

# 실수로 common.py 파일 생성
$ touch common.py

$ python
&amp;gt;&amp;gt;&amp;gt; import common
# 어떤 common이 import될까?

# 답: ~/projects/myapp/common.py (현재 디렉토리)
# 설치한 패키지가 아니라 빈 파일!&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Src Layout으로 방지:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;packages/common/
├── pyproject.toml
└── src/
    └── common/

# src/ 안에 격리됨
$ cd packages/common
$ python
&amp;gt;&amp;gt;&amp;gt; import common
# 현재 디렉토리에 common/이 없음
# → 무조건 설치된 패키지만 import&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;실전 사례:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 우리 팀에서 겪은 실제 버그
packages/worker/
├── pyproject.toml
├── worker/
│   └── __init__.py
└── run.py  # 실행 스크립트

# run.py 내용
from worker import process_task

# 로컬 테스트: 작동 ✅
$ python run.py
# worker/ 폴더가 바로 옆에 있으니 import 성공

# Docker 배포: 실패 ❌
COPY run.py /app/
RUN pip install worker
CMD [&amp;quot;python&amp;quot;, &amp;quot;/app/run.py&amp;quot;]

# /app/에는 worker/ 폴더가 없음!
# ModuleNotFoundError: No module named &amp;#39;worker&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Src Layout이었다면 처음부터 이 문제를 발견했을 것이다.&lt;/p&gt;
&lt;h4&gt;3. Editable Install 시 불필요한 파일 격리&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Flat Layout의 함정:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;packages/common/
├── pyproject.toml
├── setup.py
├── README.md
├── tox.ini
└── common/
    └── __init__.py

# Editable install
$ pip install -e packages/common

# Python의 sys.path에 추가되는 것:
sys.path.append(&amp;#39;/path/to/packages/common&amp;#39;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 무슨 일이 벌어지는가?&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 이상한 import가 가능해짐
import pyproject  # ❌ pyproject.toml을 import?
import README     # ❌ README.md를 import?
import tox        # ❌ tox.ini를 import?

# 개발 환경에서만 작동하는 버그 코드
from setup import version  # setup.py에서 import
# → 로컬: 작동
# → 배포: ModuleNotFoundError (setup.py는 패키징 안 됨)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Src Layout으로 완벽 격리:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;packages/common/
├── pyproject.toml  ← sys.path에 안 들어감
├── setup.py        ← sys.path에 안 들어감
├── README.md       ← sys.path에 안 들어감
└── src/            ← 여기만 sys.path에 추가
    └── common/
        └── __init__.py

# Editable install
$ pip install -e packages/common

# Python의 sys.path에 추가되는 것:
sys.path.append(&amp;#39;/path/to/packages/common/src&amp;#39;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import common       # ✅ 가능 (src/common/)
import pyproject    # ❌ 불가능 (src/ 밖)
import setup        # ❌ 불가능 (src/ 밖)

# 오직 의도한 패키지만 import 가능
# → 개발과 배포 환경이 동일&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Src Layout의 Tradeoff&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;장점:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;✅ 강제 격리: 의도한 것만 import
✅ 개발 = 배포 환경 (일관성)
✅ 현재 디렉토리 오염 방지
✅ 실수로 설정 파일 import 불가능
✅ 테스트가 실제 설치된 패키지 사용&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;단점:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;❌ Editable install 필수 (개발 워크플로우 +1단계)
❌ CLI 직접 실행 불가 (python src/package)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;CLI 실행 Workaround:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;공식 문서에서 제시하는 해결책이 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# src/common/__main__.py
import os
import sys

if not __package__:
    # CLI를 소스에서 직접 실행 가능하게
    # python src/common
    package_source_path = os.path.dirname(os.path.dirname(__file__))
    sys.path.insert(0, package_source_path)

# 실제 CLI 코드
from common.cli import main

if __name__ == &amp;#39;__main__&amp;#39;:
    main()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 하면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Editable install 없이도 실행 가능
$ python src/common --help

# 하지만 권장 방법은 여전히:
$ pip install -e .
$ common --help&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;1. 테스트 격리&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Flat Layout:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;packages/common/
├── common/
│   └── utils.py
└── tests/
    └── test_utils.py

$ cd packages/common
$ python -m pytest tests/
# 문제: import common이 설치된 패키지를 쓰는지,
#       로컬 폴더를 쓰는지 모호함&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Src Layout:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;packages/common/
├── src/
│   └── common/
│       └── utils.py
└── tests/
    └── test_utils.py

$ cd packages/common
$ python -m pytest tests/
# import common은 무조건 설치된 패키지만 참조
# (로컬 src/는 PYTHONPATH에 없음)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2. 명확한 구조&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Flat: &amp;quot;common이 두 번? 헷갈려&amp;quot;
Src: &amp;quot;src 안에 있으니 당연히 소스코드겠지&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;3. 실수 방지&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Flat Layout에서 실수
packages/common/
├── common/
│   └── __init__.py
└── legacy_code.py  ← 실수로 여기 둠

# pip install 하면 legacy_code.py도 패키징될 수 있음!

# Src Layout
packages/common/
├── src/common/
│   └── __init__.py
└── legacy_code.py  ← src 밖이라 패키징 안 됨&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;팩트 체크: 오픈소스 트렌드&lt;/h2&gt;
&lt;p&gt;처음에는 &amp;quot;Django도 Flat Layout 쓰니까 우리도 괜찮다&amp;quot;고 생각했다.&lt;/p&gt;
&lt;p&gt;하지만 직접 확인해보니 충격적이었다.&lt;/p&gt;
&lt;h3&gt;Django: Flat Layout 유지&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ git clone https://github.com/django/django
$ tree -L 2 -I &amp;#39;__pycache__|*.pyc&amp;#39;

django/
├── pyproject.toml
├── django/              ← Flat Layout
│   ├── __init__.py
│   ├── conf/
│   ├── contrib/
│   └── ...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;2005년부터 이 구조. 안 바꾼다.&lt;/p&gt;
&lt;h3&gt;Flask: Src Layout 사용&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ git clone https://github.com/pallets/flask
$ tree -L 3 -I &amp;#39;__pycache__|*.pyc&amp;#39;

flask/
├── pyproject.toml
└── src/                 ← Src Layout
    └── flask/
        ├── __init__.py
        ├── app.py
        └── ...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;2010년대 중반부터 src 도입.&lt;/p&gt;
&lt;h3&gt;Requests: Flat → Src 전환&lt;/h3&gt;
&lt;p&gt;충격적이었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 2023년 이전 (v2.28.x)
requests/
├── setup.py
└── requests/            ← Flat Layout
    ├── __init__.py
    └── ...

# 2024년 현재 (v3.0.0-dev)
requests/
├── pyproject.toml
└── src/                 ← Src Layout으로 전환!
    └── requests/
        ├── __init__.py
        └── ...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;왜 바꿨을까?&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Requests 메인테이너 코멘트 (GitHub Issue):

&amp;quot;We&amp;#39;ve moved to src layout for better test isolation 
and to follow modern Python packaging best practices.&amp;quot;

번역:
- 테스트 격리 개선
- 모던 Python 패키징 표준 따르기&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Requests가 바꿨다는 건 생태계의 신호다. &amp;quot;앞으로는 src가 표준&amp;quot;이라는.&lt;/p&gt;
&lt;h2&gt;비교표&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────┬─────────────────┬─────────────────┐
│     항목        │  Flat Layout    │   Src Layout    │
├─────────────────┼─────────────────┼─────────────────┤
│ 구조            │ common/common/  │ common/src/     │
│                 │                 │   common/       │
├─────────────────┼─────────────────┼─────────────────┤
│ 설정 복잡도     │ 간단            │ 간단            │
├─────────────────┼─────────────────┼─────────────────┤
│ 테스트 격리     │ 약함            │ 강함            │
├─────────────────┼─────────────────┼─────────────────┤
│ 실수 방지       │ 보통            │ 좋음            │
├─────────────────┼─────────────────┼─────────────────┤
│ 레거시 호환     │ 높음            │ 낮음            │
├─────────────────┼─────────────────┼─────────────────┤
│ 모던 트렌드     │ 감소 추세       │ 증가 추세       │
├─────────────────┼─────────────────┼─────────────────┤
│ 대표 프로젝트   │ Django          │ Flask, Requests │
│                 │ (구형 requests) │ (신형 requests) │
└─────────────────┴─────────────────┴─────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;실전: 우리 팀 결정&lt;/h2&gt;
&lt;p&gt;팀 리더로서 결정해야 했다. &amp;quot;트렌드가 Src니까 당장 구조 다 뜯어고칠까?&amp;quot;&lt;/p&gt;
&lt;h3&gt;초기 판단: 일단 유지하자&lt;/h3&gt;
&lt;p&gt;처음엔 보수적으로 접근했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;유지 이유:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 동작에 문제없음&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 현재 구조
import common.config
import common.utils

# 잘 돌아간다. 버그 없다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2. Django 사례&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Django: 20년째 Flat Layout
→ 거대 프로젝트도 이 방식

&amp;quot;못생김&amp;quot; 때문에 잘 돌아가는 인프라 건드리기?
→ ROI 낮음&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;결정적 계기: Requests의 전환&lt;/h3&gt;
&lt;p&gt;그런데 Requests 저장소를 다시 확인하다가 생각이 바뀌었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Requests v3.0 PR 확인
https://github.com/psf/requests/pull/6377

메인테이너 코멘트:
&amp;quot;Moving to src layout provides better test isolation 
and follows modern Python packaging best practices.&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Requests가 바꿨다는 건:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- 10년 넘게 유지한 구조를 버림
- 마이그레이션 비용을 감수함
- 그만큼 Src Layout의 가치가 크다는 의미&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;특히 이 문장이 와닿았다:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&amp;quot;better test isolation&amp;quot;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;우리 물류 시스템도 테스트 커버리지를 높이는 중이었다. 테스트 격리가 약해서 고생했던 경험이 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Flat Layout에서 겪은 문제
$ cd packages/common
$ pytest tests/

# import common이 어디서 오는지 모호
# 1. 설치된 패키지? 
# 2. 로컬 폴더?
# → 테스트 통과했는데 배포하면 터짐&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;최종 결정: Src Layout 전환&lt;/h3&gt;
&lt;p&gt;결국 리팩토링하기로 했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;전환 이유:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 트렌드가 명확해졌다&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;과거: Django = Flat, Flask = Src (양분)
현재: Django만 Flat (고립)
     Flask, Requests, FastAPI = Src (대세)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;2. 테스트 신뢰도&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Src Layout 전환 후
$ cd packages/common
$ pytest tests/

# import common은 무조건 설치된 패키지
# 로컬 src/는 PYTHONPATH에 없음
# → 배포 환경과 동일하게 테스트&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;3. 신규 팀원 혼란 감소&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;신규 입사자 질문 (Before):
&amp;quot;왜 common이 두 번이에요?&amp;quot;
&amp;quot;어느 common에 코드 넣어야 해요?&amp;quot;

신규 입사자 반응 (After):
&amp;quot;src 안에 있으니 당연히 소스코드겠죠&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;4. 실수 방지&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Before: 실수로 루트에 파일 생성
packages/common/
├── common/
│   └── __init__.py
└── temp_debug.py  ← 이거 패키징되면 안 되는데...

# After: src 밖은 자동으로 제외
packages/common/
├── src/common/
│   └── __init__.py
└── temp_debug.py  ← src 밖이라 안전&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;마이그레이션 과정&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1주차: 구조 변경&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 작업 내용
- 폴더 재배치 (common/ → src/common/)
- pyproject.toml 수정
- CI/CD 스크립트 경로 업데이트&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2주차: 테스트 검증&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 모든 패키지 재설치
pip uninstall -y common api-client worker
pip install -e packages/common
pip install -e packages/api-client  
pip install -e packages/worker

# 전체 테스트
pytest packages/*/tests/

# 결과: 모든 테스트 통과 ✅&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;3주차: 팀 공유&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- 위키에 새 구조 문서화
- 팀 미팅에서 변경사항 공유
- &amp;quot;왜 바꿨는지&amp;quot; 근거 설명

팀원 반응:
&amp;quot;아, Requests도 바꿨다고요? 그럼 우리도 맞는 것 같네요&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;전환 후 체감 효과&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;정량적:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;마이그레이션 소요: 3일
테스트 실패 건수: 0건
배포 이슈: 0건&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;정성적:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;✅ 테스트 격리 확실해짐
✅ 신규 입사자 온보딩 질문 감소
✅ &amp;quot;더 모던한 프로젝트&amp;quot;라는 인식
✅ 실수로 불필요한 파일 패키징 위험 제거&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;예상 밖 장점:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Monorepo에서 패키지 구분이 더 명확해짐

packages/
├── common/
│   └── src/common/      ← 여기가 소스
├── api-client/
│   └── src/api_client/  ← 여기가 소스
└── worker/
    └── src/worker/      ← 여기가 소스

# 일관성 있는 구조 → 팀 생산성 ↑&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;마이그레이션 가이드&lt;/h2&gt;
&lt;p&gt;혹시 Flat → Src로 전환하고 싶다면?&lt;/p&gt;
&lt;h3&gt;1단계: 폴더 재구성&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Before
packages/common/
├── pyproject.toml
└── common/
    └── __init__.py

# After
packages/common/
├── pyproject.toml
└── src/
    └── common/
        └── __init__.py&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2단계: pyproject.toml 수정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;# Before
[tool.hatch.build.targets.wheel]
packages = [&amp;quot;common&amp;quot;]

# After
[tool.hatch.build.targets.wheel]
packages = [&amp;quot;src/common&amp;quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3단계: editable install 재실행&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pip uninstall common
pip install -e .&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4단계: import 경로는 변경 없음&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 구조가 바뀌어도 import는 동일
import common.config  # 여전히 작동&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5단계: 테스트&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest tests/
# src 밖에서 실행하면 무조건 설치된 패키지 import&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;h3&gt;Flat Layout (common/common/)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;✅ 장점:
- 간단한 구조
- 레거시 호환성 높음
- Django 같은 대형 프로젝트도 사용

❌ 단점:
- 폴더 이름 중복 (미관상)
- 테스트 격리 약함
- 실수로 불필요한 파일 패키징 가능&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;Src Layout (common/src/common/)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;✅ 장점:
- 테스트 격리 강함
- 실수 방지
- 모던 트렌드
- Requests가 전환함

❌ 단점:
- src 폴더 추가 (depth +1)
- 기존 프로젝트 마이그레이션 비용&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;권장 사항&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;if 신규_프로젝트:
    return &amp;quot;Src Layout&amp;quot;

elif 기존_프로젝트 and 잘_돌아감:
    return &amp;quot;유지 (Flat Layout)&amp;quot;

elif 대규모_리팩토링_기회:
    return &amp;quot;Src Layout 전환 검토&amp;quot;

else:
    return &amp;quot;건드리지 마라&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;packages/common/common/&lt;/code&gt;은 &lt;strong&gt;실수가 아니라 표준&lt;/strong&gt;이다. 과거에는.&lt;/p&gt;
&lt;p&gt;하지만 Python 생태계는 변했다. Requests조차 src로 갔다. 우리도 따라갔다.&lt;/p&gt;
&lt;h3&gt;언제 전환해야 하는가?&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;if 신규_프로젝트:
    return &amp;quot;무조건 Src Layout&amp;quot;

elif Requests_같은_대형_프로젝트가_전환:
    return &amp;quot;트렌드가 명확해짐 → 전환 검토&amp;quot;

elif 테스트_격리가_중요한_프로젝트:
    return &amp;quot;Src Layout 전환 강력 추천&amp;quot;

elif 팀원_온보딩_자주_발생:
    return &amp;quot;Src Layout이 설명 쉬움&amp;quot;

else:
    return &amp;quot;잘 돌아가면 유지도 괜찮음&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;우리의 선택&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;전환 전 (Flat Layout):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;✅ 동작함
❌ 테스트 격리 약함
❌ 신규 입사자 혼란
❌ &amp;quot;왜 두 번?&amp;quot; 질문 반복&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;전환 후 (Src Layout):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;✅ 동작함
✅ 테스트 신뢰도 ↑
✅ 온보딩 질문 ↓
✅ 모던한 구조
✅ Monorepo 일관성&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;결론:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;마이그레이션 비용: 3일
얻은 가치: 장기적 생산성 향상

ROI: 충분히 가치 있었음&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;핵심 교훈&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. 트렌드를 무시하지 마라&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;quot;Django도 Flat이니까 괜찮아&amp;quot;
→ 위험한 생각

&amp;quot;Requests가 왜 바꿨을까?&amp;quot;
→ 올바른 질문&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;2. 테스트 격리는 중요하다&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;로컬에서 통과 → 배포 후 실패
이런 경험 한 번이면 Src Layout 전환 각오함&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;3. 팀 생산성을 고려하라&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;quot;왜 폴더가 두 번이에요?&amp;quot; (월 2회)
→ 3개월이면 설명 6번
→ 1년이면 24번
→ src/로 바꾸면 질문 0번&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;4. 신규 프로젝트는 망설이지 마라&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;기존 프로젝트: 신중하게 판단
신규 프로젝트: 무조건 Src Layout&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;p&gt;중요한 건 &lt;strong&gt;&amp;quot;왜 이 구조인지 이해하는 것&amp;quot;&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;이해하고 나면:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;못생겨 보이던 &lt;code&gt;common/common/&lt;/code&gt;도 납득되고&lt;/li&gt;
&lt;li&gt;Requests가 왜 바꿨는지 공감되고&lt;/li&gt;
&lt;li&gt;우리 팀도 언제 바꿔야 할지 판단할 수 있다&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Backend Development</category>
      <category>backend</category>
      <category>flat layout</category>
      <category>Python</category>
      <category>SRC</category>
      <category>src layout</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/148</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/packagescommoncommon-%ED%8F%B4%EB%8D%94-%EA%B5%AC%EC%A1%B0%EA%B0%80-%EC%9D%B4%EC%83%81%ED%95%9C%EA%B0%80%EC%9A%94-Python-Src-Layout-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C#entry148comment</comments>
      <pubDate>Wed, 4 Feb 2026 17:01:21 +0900</pubDate>
    </item>
    <item>
      <title>AI가 100% 정확하지 않아도 괜찮다: Human-in-the-Loop로 만드는 의류 불량 검수 시스템</title>
      <link>https://white-mouse-dev.tistory.com/entry/AI%EA%B0%80-100-%EC%A0%95%ED%99%95%ED%95%98%EC%A7%80-%EC%95%8A%EC%95%84%EB%8F%84-%EA%B4%9C%EC%B0%AE%EB%8B%A4-Human-in-the-Loop%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-%EC%9D%98%EB%A5%98-%EB%B6%88%EB%9F%89-%EA%B2%80%EC%88%98-%EC%8B%9C%EC%8A%A4%ED%85%9C</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/37474ef5-6cd0-4113-b979-83af53b1cd74/image.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Image Source: Generated by Nano Banana&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;quot;AI 정확도가 100%가 아니면 현업에서 못 쓰는 거 아니에요?&amp;quot;라는 질문을 받았다. 답은 &amp;quot;아니다&amp;quot;였다.&lt;/p&gt;
&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;의류 반품 검수 현장은 생각보다 복잡하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;하루 반품량: 5,000벌
검수 항목: 오염, 찢어짐, 변색, 보풀, 단추 이탈 등 12가지
작업자 1명당 처리량: 200벌/일
소요 시간: 1벌당 평균 2분&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;문제는 검수 품질이다. 사람이 하루 종일 옷을 보면 집중력이 떨어진다. 저녁 6시쯤 되면 오탐률이 30%까지 치솟는다.&lt;/p&gt;
&lt;p&gt;&amp;quot;AI로 자동화하면 되지 않나요?&amp;quot;&lt;/p&gt;
&lt;p&gt;시도해봤다. YOLOv8 기반 불량 검출 모델을 만들어서 파일럿 테스트를 돌렸다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;결과:
- mAP50: 0.83 (83%)
- Precision: 0.79
- Recall: 0.85&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;83%면 나쁘지 않다. 하지만 현장 반응은 싸늘했다.&lt;/p&gt;
&lt;p&gt;&amp;quot;AI가 놓친 불량은 누가 책임지나요?&amp;quot;&lt;br&gt;&amp;quot;오탐이 17%면 직원이 다시 확인해야 해서 오히려 일이 늘어요.&amp;quot;&lt;/p&gt;
&lt;p&gt;그래서 우리는 방향을 바꿨다. &lt;strong&gt;AI가 모든 걸 하게 하지 말고, 사람과 협업하게 하자.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Human-in-the-Loop란?&lt;/h2&gt;
&lt;p&gt;AI가 판단하고, 애매한 케이스만 사람에게 물어보는 구조다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;전통적인 AI 시스템:
AI 판단 → 끝
(틀려도 그대로 진행)

Human-in-the-Loop:
AI 판단 → 신뢰도 낮으면 → 사람 확인 → 피드백 → AI 학습&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;의료 영상 진단, 자율주행, 콘텐츠 검열 등 &amp;quot;실수가 치명적인&amp;quot; 분야에서 많이 쓴다.&lt;/p&gt;
&lt;p&gt;우리 케이스에서는:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AI가 확실한 것(신뢰도 95% 이상): 자동 통과/거부&lt;/li&gt;
&lt;li&gt;AI가 애매한 것(신뢰도 70~95%): 작업자에게 전달&lt;/li&gt;
&lt;li&gt;작업자 판단 → 다시 AI 학습에 활용&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;시스템 아키텍처&lt;/h2&gt;
&lt;h3&gt;2단계 파이프라인: Detection + Classification&lt;/h3&gt;
&lt;p&gt;우리는 단순한 객체 탐지를 넘어, &lt;strong&gt;탐지와 분류를 분리한 2단계 파이프라인&lt;/strong&gt;을 구축했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;이미지 입력
    ↓
[1단계] RT-DETR-L
    - 불량 영역 탐지 (Bounding Box)
    - 빠른 추론 속도 (40ms)
    ↓
불량 후보 영역 추출
    ↓
[2단계] Qwen2.5-VL 7B Instruct
    - 불량 유형 분류
    - 심각도 판단
    - 자연어로 설명 생성
    ↓
최종 판정 (통과/재검토/거부)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;왜 2단계로 나눴는가?&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;단일 모델 (YOLOv8):
✅ 간단한 구조
❌ 불량 유형 12가지를 동시에 학습하면 성능 저하
❌ 미세한 차이 구분 어려움 (오염 vs 변색)

2단계 파이프라인:
✅ RT-DETR: &amp;quot;어디에 뭔가 있다&amp;quot; 빠르게 찾기
✅ Qwen2.5-VL: &amp;quot;그게 정확히 무엇인지&amp;quot; 정밀 분석
✅ 각 모델이 자기 역할에만 집중 → 성능 향상&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;1단계: RT-DETR로 불량 영역 탐지&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class DefectDetector:
    def __init__(self):
        self.model = RTDETRForObjectDetection.from_pretrained(
            &amp;#39;apparel-rtdetr-v1.2&amp;#39;,
            num_classes=1,  # 불량 영역만 탐지 (유형 구분 안 함)
        )
        self.processor = RTDETRImageProcessor.from_pretrained(&amp;#39;rtdetr&amp;#39;)

    async def detect(self, image: Image) -&amp;gt; List[BoundingBox]:
        # 1. 이미지 전처리
        inputs = self.processor(images=image, return_tensors=&amp;quot;pt&amp;quot;)

        # 2. 추론
        with torch.no_grad():
            outputs = self.model(**inputs)

        # 3. NMS 및 필터링
        results = self.processor.post_process_object_detection(
            outputs,
            threshold=0.25,  # 낮은 임계값 (Recall 우선)
            target_sizes=[(image.height, image.width)]
        )[0]

        # 4. Bounding Box 추출
        boxes = []
        for score, label, box in zip(
            results[&amp;quot;scores&amp;quot;],
            results[&amp;quot;labels&amp;quot;],
            results[&amp;quot;boxes&amp;quot;]
        ):
            if score &amp;gt;= 0.25:
                boxes.append({
                    &amp;#39;bbox&amp;#39;: box.tolist(),
                    &amp;#39;confidence&amp;#39;: score.item(),
                    &amp;#39;area&amp;#39;: (box[2] - box[0]) * (box[3] - box[1])
                })

        return boxes&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;RT-DETR 선택 이유:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;YOLOv8 vs RT-DETR:

YOLOv8:
- 추론 속도: 30ms
- mAP50: 0.83
- NMS 필요 (후처리 복잡)

RT-DETR-L:
- 추론 속도: 40ms (조금 느림)
- mAP50: 0.87 (더 정확)
- NMS 불필요 (Transformer 기반)
- 작은 불량도 잘 탐지

결론: 10ms 느린 것보다 정확도 4%p 향상이 더 중요&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;2단계: Qwen2.5-VL 7B로 불량 분류 및 판단&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from transformers import Qwen2VLForConditionalGeneration, AutoProcessor

class DefectClassifier:
    def __init__(self):
        self.model = Qwen2VLForConditionalGeneration.from_pretrained(
            &amp;quot;Qwen/Qwen2.5-VL-7B-Instruct&amp;quot;,
            torch_dtype=torch.float16,
            device_map=&amp;quot;auto&amp;quot;
        )
        self.processor = AutoProcessor.from_pretrained(
            &amp;quot;Qwen/Qwen2.5-VL-7B-Instruct&amp;quot;
        )

        # 불량 유형 정의
        self.defect_types = [
            &amp;quot;오염 (stain)&amp;quot;,
            &amp;quot;찢어짐 (tear)&amp;quot;, 
            &amp;quot;변색 (discoloration)&amp;quot;,
            &amp;quot;보풀 (pilling)&amp;quot;,
            &amp;quot;구멍 (hole)&amp;quot;,
            &amp;quot;단추 이탈 (missing_button)&amp;quot;,
            # ... 12가지
        ]

    async def classify(
        self, 
        image: Image, 
        bbox: dict
    ) -&amp;gt; dict:
        # 1. 불량 영역 크롭
        x1, y1, x2, y2 = bbox[&amp;#39;bbox&amp;#39;]
        cropped = image.crop((x1, y1, x2, y2))

        # 2. 프롬프트 구성
        prompt = f&amp;quot;&amp;quot;&amp;quot;당신은 의류 품질 검수 전문가입니다.
이미지를 보고 다음을 판단하세요:

1. 불량 유형: {&amp;#39;, &amp;#39;.join(self.defect_types)} 중 하나
2. 심각도: 경미(minor) / 중간(moderate) / 심각(severe)
3. 판정: 통과(pass) / 재검토(review) / 거부(reject)
4. 근거: 왜 그렇게 판단했는지 한 문장으로

반드시 JSON 형식으로 답변하세요:
{{
  &amp;quot;defect_type&amp;quot;: &amp;quot;...&amp;quot;,
  &amp;quot;severity&amp;quot;: &amp;quot;...&amp;quot;,
  &amp;quot;decision&amp;quot;: &amp;quot;...&amp;quot;,
  &amp;quot;reason&amp;quot;: &amp;quot;...&amp;quot;
}}&amp;quot;&amp;quot;&amp;quot;

        # 3. VLM 추론
        messages = [
            {
                &amp;quot;role&amp;quot;: &amp;quot;user&amp;quot;,
                &amp;quot;content&amp;quot;: [
                    {&amp;quot;type&amp;quot;: &amp;quot;image&amp;quot;, &amp;quot;image&amp;quot;: cropped},
                    {&amp;quot;type&amp;quot;: &amp;quot;text&amp;quot;, &amp;quot;text&amp;quot;: prompt}
                ]
            }
        ]

        text = self.processor.apply_chat_template(
            messages, 
            tokenize=False, 
            add_generation_prompt=True
        )

        inputs = self.processor(
            text=[text],
            images=[cropped],
            return_tensors=&amp;quot;pt&amp;quot;
        ).to(self.model.device)

        # 4. 생성
        with torch.no_grad():
            outputs = self.model.generate(
                **inputs,
                max_new_tokens=256,
                temperature=0.1,  # 낮은 temperature (일관성)
            )

        response = self.processor.decode(
            outputs[0][inputs[&amp;#39;input_ids&amp;#39;].shape[1]:],
            skip_special_tokens=True
        )

        # 5. JSON 파싱
        try:
            result = json.loads(response)
            result[&amp;#39;confidence&amp;#39;] = bbox[&amp;#39;confidence&amp;#39;]
            result[&amp;#39;bbox&amp;#39;] = bbox[&amp;#39;bbox&amp;#39;]
            return result
        except json.JSONDecodeError:
            # VLM이 JSON 형식 안 지킬 때 대비
            return {
                &amp;#39;defect_type&amp;#39;: &amp;#39;unknown&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;review&amp;#39;,
                &amp;#39;decision&amp;#39;: &amp;#39;review&amp;#39;,  # 안전하게 사람 확인
                &amp;#39;reason&amp;#39;: &amp;#39;Parse error&amp;#39;,
                &amp;#39;raw_response&amp;#39;: response
            }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Qwen2.5-VL 선택 이유:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;왜 VLM(Vision-Language Model)인가?

기존 분류 모델 (ResNet, EfficientNet):
❌ 12가지 불량 유형을 하드코딩
❌ 새 불량 유형 추가 시 재학습 필요
❌ &amp;quot;왜 그렇게 판단했는지&amp;quot; 설명 불가

Qwen2.5-VL 7B:
✅ 자연어 프롬프트로 유연하게 제어
✅ 새 불량 유형 추가 시 프롬프트만 수정
✅ 판단 근거를 자연어로 설명 (작업자가 이해 가능)
✅ Few-shot learning 가능&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;실제 추론 예시:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// Input: 흰 셔츠에 커피 얼룩
{
  &amp;quot;bbox&amp;quot;: [120, 340, 280, 480],
  &amp;quot;confidence&amp;quot;: 0.87
}

// Qwen2.5-VL Output:
{
  &amp;quot;defect_type&amp;quot;: &amp;quot;오염 (stain)&amp;quot;,
  &amp;quot;severity&amp;quot;: &amp;quot;moderate&amp;quot;,
  &amp;quot;decision&amp;quot;: &amp;quot;review&amp;quot;,
  &amp;quot;reason&amp;quot;: &amp;quot;갈색 액체 얼룩이 약 3cm 크기로 앞면에 있으며, 세탁으로 제거 가능 여부 불확실&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;통합 파이프라인&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class QualityInspectionPipeline:
    def __init__(self):
        self.detector = DefectDetector()  # RT-DETR
        self.classifier = DefectClassifier()  # Qwen2.5-VL

        self.high_confidence_threshold = 0.95
        self.low_confidence_threshold = 0.70

    async def inspect(self, image_path: str):
        image = Image.open(image_path)

        # 1단계: RT-DETR로 불량 영역 탐지
        detections = await self.detector.detect(image)

        if not detections:
            # 불량 없음
            return {
                &amp;#39;decision&amp;#39;: &amp;#39;pass&amp;#39;,
                &amp;#39;confidence&amp;#39;: 1.0,
                &amp;#39;route&amp;#39;: &amp;#39;auto&amp;#39;,
                &amp;#39;defects&amp;#39;: []
            }

        # 2단계: 각 영역을 Qwen2.5-VL로 분석
        classifications = []
        for bbox in detections:
            result = await self.classifier.classify(image, bbox)
            classifications.append(result)

        # 3단계: 최종 판정
        return self._make_final_decision(classifications)

    def _make_final_decision(self, classifications: List[dict]):
        # 심각도별 가중치
        severity_weights = {
            &amp;#39;minor&amp;#39;: 1,
            &amp;#39;moderate&amp;#39;: 2,
            &amp;#39;severe&amp;#39;: 3
        }

        # 가장 심각한 불량 찾기
        max_severity = max(
            (severity_weights[c[&amp;#39;severity&amp;#39;]] for c in classifications),
            default=0
        )

        # VLM이 이미 판정을 내린 경우
        reject_count = sum(
            1 for c in classifications 
            if c[&amp;#39;decision&amp;#39;] == &amp;#39;reject&amp;#39;
        )

        if reject_count &amp;gt; 0:
            # 하나라도 reject면 사람 확인
            return {
                &amp;#39;decision&amp;#39;: &amp;#39;review&amp;#39;,
                &amp;#39;route&amp;#39;: &amp;#39;human&amp;#39;,
                &amp;#39;priority&amp;#39;: &amp;#39;high&amp;#39;,
                &amp;#39;defects&amp;#39;: classifications,
                &amp;#39;reason&amp;#39;: f&amp;#39;{reject_count}개 불량이 심각함&amp;#39;
            }

        # 전체 신뢰도 계산
        avg_confidence = sum(
            c[&amp;#39;confidence&amp;#39;] for c in classifications
        ) / len(classifications)

        if avg_confidence &amp;gt;= self.high_confidence_threshold:
            # 확실한 판정
            if max_severity &amp;gt;= 2:  # moderate 이상
                return {
                    &amp;#39;decision&amp;#39;: &amp;#39;reject&amp;#39;,
                    &amp;#39;route&amp;#39;: &amp;#39;auto&amp;#39;,
                    &amp;#39;confidence&amp;#39;: avg_confidence,
                    &amp;#39;defects&amp;#39;: classifications
                }
            else:
                return {
                    &amp;#39;decision&amp;#39;: &amp;#39;review&amp;#39;,
                    &amp;#39;route&amp;#39;: &amp;#39;human&amp;#39;,
                    &amp;#39;priority&amp;#39;: &amp;#39;medium&amp;#39;,
                    &amp;#39;defects&amp;#39;: classifications
                }

        else:
            # 애매함 → 사람 확인
            return {
                &amp;#39;decision&amp;#39;: &amp;#39;review&amp;#39;,
                &amp;#39;route&amp;#39;: &amp;#39;human&amp;#39;,
                &amp;#39;priority&amp;#39;: self._calculate_priority(avg_confidence),
                &amp;#39;defects&amp;#39;: classifications
            }&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;성능 최적화: 배치 처리&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class BatchInspectionPipeline:
    async def inspect_batch(
        self, 
        image_paths: List[str],
        batch_size: int = 8
    ):
        # 1단계: RT-DETR 배치 추론
        all_detections = await self.detector.detect_batch(
            image_paths,
            batch_size=batch_size
        )

        # 2단계: Qwen2.5-VL은 개별 처리
        # (VLM은 배치 처리 시 GPU 메모리 부족)
        results = []
        for image_path, detections in zip(image_paths, all_detections):
            if not detections:
                results.append({
                    &amp;#39;image_path&amp;#39;: image_path,
                    &amp;#39;decision&amp;#39;: &amp;#39;pass&amp;#39;,
                    &amp;#39;route&amp;#39;: &amp;#39;auto&amp;#39;
                })
                continue

            image = Image.open(image_path)
            classifications = []

            for bbox in detections:
                result = await self.classifier.classify(image, bbox)
                classifications.append(result)

            final = self._make_final_decision(classifications)
            final[&amp;#39;image_path&amp;#39;] = image_path
            results.append(final)

        return results&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;실제 성능&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;RT-DETR-L:
- 추론 속도: 40ms/이미지
- mAP50: 0.87
- mAP50-95: 0.64
- GPU: RTX 4060 Ti (16GB)

Qwen2.5-VL 7B Instruct:
- 추론 속도: 1.2초/영역 (FP16)
- 정확도: 92% (사람과 비교)
- GPU: RTX 4090 (24GB)

통합 파이프라인:
- 평균 불량 영역: 1.3개/이미지
- 총 처리 시간: 1.6초/이미지
- 일일 처리량: 약 3만 건 (16시간 운영)&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;1. 모델 평가 기준 설계&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;RT-DETR (Detection 모델) 기준:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# RT-DETR 평가 기준
detection_model:
  pass_criteria:
    mAP50:
      min: 0.85
      description: 불량 영역 탐지 정확도

    recall:
      min: 0.88
      description: 불량을 놓치지 않는 것이 중요 (FN 최소화)

    precision:
      min: 0.75
      description: 오탐은 VLM이 걸러주므로 다소 관대&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Qwen2.5-VL (Classification 모델) 기준:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# VLM 평가 기준  
classification_model:
  pass_criteria:
    accuracy:
      min: 0.90
      description: 불량 유형 분류 정확도

    human_agreement:
      min: 0.88
      description: 전문가와 판단 일치율

    explanation_quality:
      min: 0.85
      description: 설명의 적절성 (사람이 평가)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;왜 RT-DETR은 Recall을, VLM은 Accuracy를 보는가?&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RT-DETR 역할:
- &amp;quot;불량이 있을 수도 있는 영역&amp;quot; 모두 찾기
- 오탐(FP)은 괜찮음 → VLM이 나중에 걸러냄
- 미탐(FN)은 치명적 → 놓치면 불량품 유출

Qwen2.5-VL 역할:
- RT-DETR이 찾은 영역을 정밀 분석
- &amp;quot;이게 진짜 불량인지, 그림자인지&amp;quot; 구분
- 정확한 유형 분류 + 판단 근거 제시&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;2. 메트릭 해석: 우리가 실제로 보는 것&lt;/h3&gt;
&lt;h4&gt;mAP50 vs mAP50-95&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;metrics:
  primary:
    - name: mAP50
      description: Mean Average Precision @ IoU 0.5

    - name: mAP50-95
      description: Mean Average Precision @ IoU 0.5:0.95&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;mAP50&lt;/strong&gt;: &amp;quot;불량 위치를 대충이라도 맞췄나?&amp;quot;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;예측 박스와 실제 박스가 50% 이상 겹치면 정답 인정
의류 불량 검수에서는 이 정도면 충분 (정확한 픽셀 위치보다 부위 파악이 중요)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;mAP50-95&lt;/strong&gt;: &amp;quot;불량 위치를 정교하게 맞췄나?&amp;quot;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;IoU 50%, 55%, 60%, ..., 95%까지 모두 측정
달성하기 훨씬 어려움 (우리는 0.62 정도)
로봇 팔 제어처럼 정밀한 좌표가 필요할 때 중요&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;우리의 선택&lt;/strong&gt;: mAP50 중심&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;이유:
- 불량 &amp;quot;존재 여부&amp;quot;가 더 중요
- 정확한 픽셀 위치는 작업자가 눈으로 확인하면 됨
- mAP50-95를 높이려면 데이터 라벨링 비용이 10배 증가&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;Precision vs Recall의 Trade-off&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;secondary:
  - name: precision
    description: 정밀도 (TP / (TP + FP))

  - name: recall
    description: 재현율 (TP / (TP + FN))&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;실전 예시:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;테스트 데이터: 100벌
실제 불량: 20벌
AI 예측: 불량 25벌

분석:
- TP (진짜 불량을 불량으로): 18벌
- FP (정상을 불량으로): 7벌
- FN (불량을 정상으로): 2벌

Precision = 18 / (18 + 7) = 0.72 (72%)
Recall = 18 / (18 + 2) = 0.90 (90%)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;의미:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Precision 72%:
- AI가 &amp;quot;불량&amp;quot;이라고 한 것 중 실제로는 28%가 정상
- 작업자가 오탐 7건을 다시 확인해야 함

Recall 90%:
- 실제 불량 20건 중 18건을 찾음
- 2건을 놓쳤음 (위험!)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;우리의 우선순위: Recall &amp;gt; Precision&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 불량을 놓치는 것(FN)이 더 위험
# 오탐(FP)은 작업자가 다시 보면 되지만
# 미탐(FN)은 불량품이 고객에게 간다

config = {
    &amp;#39;confidence_threshold&amp;#39;: 0.25,  # 낮게 설정 (Recall 높이기)
    &amp;#39;human_review_threshold&amp;#39;: 0.70  # 애매한 것은 사람이 확인
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 신뢰도 기반 라우팅&lt;/h3&gt;
&lt;p&gt;핵심 로직은 간단하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class QualityInspectionPipeline:
    def __init__(self):
        self.model = load_model(&amp;#39;apparel-defect-v2.3&amp;#39;)
        self.high_confidence_threshold = 0.95
        self.low_confidence_threshold = 0.70

    async def inspect(self, image_path: str):
        # 1. AI 예측
        result = self.model.predict(image_path)

        # 2. 최고 신뢰도 스코어 확인
        max_confidence = max([det.confidence for det in result.detections])

        # 3. 라우팅
        if max_confidence &amp;gt;= self.high_confidence_threshold:
            # 확실한 불량 → 자동 거부
            return {
                &amp;#39;decision&amp;#39;: &amp;#39;reject&amp;#39;,
                &amp;#39;confidence&amp;#39;: max_confidence,
                &amp;#39;route&amp;#39;: &amp;#39;auto&amp;#39;,
                &amp;#39;defects&amp;#39;: result.detections
            }

        elif max_confidence &amp;gt;= self.low_confidence_threshold:
            # 애매함 → 사람 확인 필요
            return {
                &amp;#39;decision&amp;#39;: &amp;#39;review_required&amp;#39;,
                &amp;#39;confidence&amp;#39;: max_confidence,
                &amp;#39;route&amp;#39;: &amp;#39;human&amp;#39;,
                &amp;#39;defects&amp;#39;: result.detections,
                &amp;#39;priority&amp;#39;: self._calculate_priority(max_confidence)
            }

        else:
            # 불량 없음 → 자동 통과
            return {
                &amp;#39;decision&amp;#39;: &amp;#39;pass&amp;#39;,
                &amp;#39;confidence&amp;#39;: 1 - max_confidence,
                &amp;#39;route&amp;#39;: &amp;#39;auto&amp;#39;
            }

    def _calculate_priority(self, confidence: float):
        # 신뢰도가 낮을수록 우선순위 높음
        if confidence &amp;lt; 0.75:
            return &amp;#39;high&amp;#39;
        elif confidence &amp;lt; 0.85:
            return &amp;#39;medium&amp;#39;
        else:
            return &amp;#39;low&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;실제 운영 결과:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;일일 검수량: 5,000벌

자동 처리 (신뢰도 95% 이상):
- 3,500벌 (70%)
- 작업자 개입 불필요

사람 확인 필요 (신뢰도 70~95%):
- 1,200벌 (24%)
- 우선순위 큐로 관리

자동 통과 (불량 없음):
- 300벌 (6%)

효과:
- 작업자 업무량 70% 감소
- 검수 정확도 향상 (사람이 애매한 것만 집중)&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;4. 오분류 케이스 수집 및 재학습&lt;/h3&gt;
&lt;p&gt;Human-in-the-Loop의 핵심은 &lt;strong&gt;피드백 루프&lt;/strong&gt;다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class FeedbackCollector:
    async def collect_human_decision(
        self, 
        image_id: str,
        ai_prediction: dict,
        human_decision: dict
    ):
        # 1. AI와 사람의 판단 비교
        is_disagreement = (
            ai_prediction[&amp;#39;decision&amp;#39;] != human_decision[&amp;#39;decision&amp;#39;]
        )

        if is_disagreement:
            # 2. 오분류 케이스 저장
            await self.save_error_case({
                &amp;#39;image_id&amp;#39;: image_id,
                &amp;#39;ai_prediction&amp;#39;: ai_prediction,
                &amp;#39;human_decision&amp;#39;: human_decision,
                &amp;#39;error_type&amp;#39;: self._classify_error(
                    ai_prediction, 
                    human_decision
                ),
                &amp;#39;timestamp&amp;#39;: datetime.now()
            })

        # 3. 사람 판단을 Ground Truth로 저장
        await self.update_training_data(
            image_id=image_id,
            labels=human_decision[&amp;#39;labels&amp;#39;],
            verified=True,
            verified_by=human_decision[&amp;#39;operator_id&amp;#39;]
        )

    def _classify_error(self, ai_pred, human_dec):
        if ai_pred[&amp;#39;decision&amp;#39;] == &amp;#39;reject&amp;#39; and human_dec[&amp;#39;decision&amp;#39;] == &amp;#39;pass&amp;#39;:
            return &amp;#39;false_positive&amp;#39;  # 오탐
        elif ai_pred[&amp;#39;decision&amp;#39;] == &amp;#39;pass&amp;#39; and human_dec[&amp;#39;decision&amp;#39;] == &amp;#39;reject&amp;#39;:
            return &amp;#39;false_negative&amp;#39;  # 미탐
        else:
            return &amp;#39;classification_error&amp;#39;  # 불량 유형 오분류&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;오분류 케이스 분석 대시보드:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 주간 리포트
{
  &amp;quot;week&amp;quot;: &amp;quot;2024-W05&amp;quot;,
  &amp;quot;total_reviews&amp;quot;: 8400,
  &amp;quot;ai_human_agreement&amp;quot;: 0.87,

  &amp;quot;error_breakdown&amp;quot;: {
    &amp;quot;false_positive&amp;quot;: 680,  // AI가 불량이라 했는데 정상
    &amp;quot;false_negative&amp;quot;: 412,  // AI가 정상이라 했는데 불량
    &amp;quot;classification_error&amp;quot;: 100  // 불량 유형 오분류
  },

  &amp;quot;top_error_patterns&amp;quot;: [
    {
      &amp;quot;type&amp;quot;: &amp;quot;light_stain_on_white&amp;quot;,
      &amp;quot;count&amp;quot;: 156,
      &amp;quot;action&amp;quot;: &amp;quot;재학습 데이터에 추가&amp;quot;
    },
    {
      &amp;quot;type&amp;quot;: &amp;quot;shadow_misdetection&amp;quot;,
      &amp;quot;count&amp;quot;: 98,
      &amp;quot;action&amp;quot;: &amp;quot;데이터 증강 강화&amp;quot;
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5. 지속적 재학습 파이프라인&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 재학습 트리거 조건
class RetrainingTrigger:
    def should_retrain(self) -&amp;gt; bool:
        metrics = self.get_recent_metrics()

        # 조건 1: 신규 검증 데이터 1,000건 이상
        if metrics[&amp;#39;verified_samples&amp;#39;] &amp;gt;= 1000:
            return True

        # 조건 2: AI-Human 불일치율 15% 이상
        if metrics[&amp;#39;disagreement_rate&amp;#39;] &amp;gt;= 0.15:
            return True

        # 조건 3: 특정 클래스 성능 하락
        for class_name, perf in metrics[&amp;#39;per_class&amp;#39;].items():
            if perf[&amp;#39;precision&amp;#39;] &amp;lt; 0.70:
                return True

        return False

# 재학습 프로세스
async def retrain_pipeline():
    # 1. 신규 데이터 수집
    new_data = await collect_verified_samples(min_count=1000)

    # 2. 데이터 증강
    augmented_data = augment_dataset(
        new_data,
        augmentation_config={
            &amp;#39;rotation&amp;#39;: [-15, 15],
            &amp;#39;brightness&amp;#39;: [0.8, 1.2],
            &amp;#39;contrast&amp;#39;: [0.9, 1.1],
        }
    )

    # 3. 모델 학습
    new_model = train_model(
        base_model=&amp;#39;apparel-defect-v2.3&amp;#39;,
        train_data=augmented_data,
        val_split=0.2,
        epochs=50
    )

    # 4. 평가
    eval_results = evaluate_model(
        new_model,
        test_dataset=&amp;#39;test-v3-latest&amp;#39;
    )

    # 5. 배포 기준 검증
    if passes_criteria(eval_results):
        # 6. 스테이징 배포 (10% 트래픽)
        await deploy_to_staging(new_model, traffic_ratio=0.1)

        # 7. A/B 테스트 (7일)
        ab_results = await run_ab_test(
            model_a=&amp;#39;apparel-defect-v2.3&amp;#39;,
            model_b=new_model,
            duration_days=7
        )

        # 8. 승자 결정
        if ab_results[&amp;#39;model_b_better&amp;#39;]:
            await promote_to_production(new_model)
            logger.info(f&amp;quot;Model upgraded: v2.3 → v2.4&amp;quot;)
    else:
        logger.warning(&amp;quot;New model did not pass criteria&amp;quot;)
        await notify_ml_team(eval_results)&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;실전 운영 경험&lt;/h2&gt;
&lt;h3&gt;배포 기준: 까다롭게 vs 유연하게?&lt;/h3&gt;
&lt;p&gt;초기에는 기준을 너무 빡빡하게 잡았다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# v1.0 기준 (너무 엄격)
pass_criteria:
  mAP50: 0.90
  precision: 0.85
  recall: 0.90&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과: 3개월 동안 재학습 8번 했는데 배포는 0번.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;문제:
- 신규 데이터로 학습하면 기존 클래스 성능이 조금씩 떨어짐
- 완벽한 모델을 기다리느라 현장은 여전히 수작업&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;기준을 현실적으로 조정했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# v2.0 기준 (실용적)
pass_criteria:
  mAP50: 0.85
  precision: 0.80

  regression_check:
    max_degradation: 0.02  # 2% 하락까지 허용&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과: 한 달에 1~2번 안정적으로 배포.&lt;/p&gt;
&lt;h3&gt;VLM 프롬프트 엔지니어링&lt;/h3&gt;
&lt;p&gt;초기 프롬프트는 단순했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# v1.0 프롬프트 (너무 단순)
prompt = &amp;quot;이 이미지의 불량 유형을 분류하세요.&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과: VLM이 일관성 없는 답변 생성.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// 같은 오염 사진인데
Image 1: &amp;quot;커피 얼룩&amp;quot;
Image 2: &amp;quot;갈색 오염&amp;quot;
Image 3: &amp;quot;액체 자국&amp;quot;
// → 분류 불가능&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;개선된 프롬프트:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# v2.0 프롬프트 (구조화)
prompt = f&amp;quot;&amp;quot;&amp;quot;당신은 의류 품질 검수 전문가입니다.
이미지를 보고 다음을 판단하세요:

1. 불량 유형: {&amp;#39;, &amp;#39;.join(self.defect_types)} 중 **정확히 하나**만 선택
2. 심각도: 
   - minor: 세탁으로 제거 가능하거나 거의 눈에 띄지 않음
   - moderate: 눈에 띄지만 착용 가능할 수 있음
   - severe: 판매 불가능, 즉시 거부해야 함
3. 판정: pass(통과) / review(재검토) / reject(거부)
4. 근거: 판단 이유를 구체적으로 한 문장으로

반드시 아래 JSON 형식으로만 답변하세요:
{{
  &amp;quot;defect_type&amp;quot;: &amp;quot;오염 (stain)&amp;quot;,
  &amp;quot;severity&amp;quot;: &amp;quot;moderate&amp;quot;,
  &amp;quot;decision&amp;quot;: &amp;quot;review&amp;quot;,
  &amp;quot;reason&amp;quot;: &amp;quot;앞면 중앙에 약 3cm 크기의 갈색 액체 얼룩. 세탁으로 제거 가능 여부 불확실.&amp;quot;
}}&amp;quot;&amp;quot;&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Few-shot Learning 추가:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# v3.0 프롬프트 (Few-shot)
examples = &amp;quot;&amp;quot;&amp;quot;
예시 1:
입력: [흰 셔츠에 작은 먼지]
출력: {{&amp;quot;defect_type&amp;quot;: &amp;quot;오염 (stain)&amp;quot;, &amp;quot;severity&amp;quot;: &amp;quot;minor&amp;quot;, &amp;quot;decision&amp;quot;: &amp;quot;pass&amp;quot;, &amp;quot;reason&amp;quot;: &amp;quot;작은 먼지로 세탁 시 제거 가능&amp;quot;}}

예시 2:
입력: [청바지 무릎 부분 5cm 찢어짐]
출력: {{&amp;quot;defect_type&amp;quot;: &amp;quot;찢어짐 (tear)&amp;quot;, &amp;quot;severity&amp;quot;: &amp;quot;severe&amp;quot;, &amp;quot;decision&amp;quot;: &amp;quot;reject&amp;quot;, &amp;quot;reason&amp;quot;: &amp;quot;무릎 부분 5cm 찢어짐으로 수선 불가&amp;quot;}}

예시 3:
입력: [그림자가 진 부분]
출력: {{&amp;quot;defect_type&amp;quot;: &amp;quot;정상 (normal)&amp;quot;, &amp;quot;severity&amp;quot;: &amp;quot;none&amp;quot;, &amp;quot;decision&amp;quot;: &amp;quot;pass&amp;quot;, &amp;quot;reason&amp;quot;: &amp;quot;조명에 의한 그림자로 실제 불량 아님&amp;quot;}}

이제 아래 이미지를 판단하세요:
&amp;quot;&amp;quot;&amp;quot;

prompt = examples + base_prompt&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과: 정확도 78% → 92%&lt;/p&gt;
&lt;h3&gt;데이터 불균형 해결&lt;/h3&gt;
&lt;p&gt;초기 RT-DETR 학습 데이터:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;오염 (stain): 5,000장
찢어짐 (tear): 3,200장
보풀 (pilling): 800장  ← 문제
변색 (discoloration): 1,200장&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;보풀 검출 성능이 형편없었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;보풀 클래스:
- Recall: 0.52 (놓침이 너무 많음)
- Precision: 0.61&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;RT-DETR 레벨 해결책:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 1. 클래스별 가중치 손실 함수
criterion = FocalLoss(
    alpha=[1.0, 1.0, 3.0, 1.5],  # 보풀에 3배 가중치
    gamma=2.0
)

# 2. 데이터 증강 강화 (보풀만)
if class_name == &amp;#39;pilling&amp;#39;:
    augmentations = [
        A.RandomBrightnessContrast(p=0.8),
        A.GaussNoise(p=0.5),
        A.Blur(blur_limit=3, p=0.3),
        # 보풀은 조명에 따라 잘 안 보임
    ]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;VLM 레벨 해결책:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Few-shot 예시에 보풀 케이스 많이 추가
few_shot_examples = {
    &amp;#39;pilling&amp;#39;: 5,  # 보풀 예시 5개
    &amp;#39;stain&amp;#39;: 2,
    &amp;#39;tear&amp;#39;: 2,
    # ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;보풀 클래스:
- Recall: 0.84 (↑ 0.32)
- Precision: 0.81 (↑ 0.20)&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;신뢰도 임계값 튜닝&lt;/h3&gt;
&lt;p&gt;처음에는 임계값을 임의로 정했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;high_confidence = 0.90
low_confidence = 0.60&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;2주 후 현장 피드백:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;quot;AI가 확신 없다고 너무 많이 떠넘겨요&amp;quot;
- 사람 확인 필요: 40% (너무 많음)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;실험:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 임계값 후보
thresholds = [
    (0.95, 0.70),
    (0.93, 0.75),
    (0.90, 0.80),
]

# 실제 데이터로 시뮬레이션
for high, low in thresholds:
    result = simulate_routing(
        test_data,
        high_threshold=high,
        low_threshold=low
    )

    print(f&amp;quot;High={high}, Low={low}&amp;quot;)
    print(f&amp;quot;  Auto: {result[&amp;#39;auto_ratio&amp;#39;]:.1%}&amp;quot;)
    print(f&amp;quot;  Human: {result[&amp;#39;human_ratio&amp;#39;]:.1%}&amp;quot;)
    print(f&amp;quot;  Accuracy: {result[&amp;#39;accuracy&amp;#39;]:.3f}&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;최종 선택: (0.95, 0.70)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Auto: 70%
Human: 24%
Accuracy: 0.91

작업자 만족도: 높음&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;성과&lt;/h2&gt;
&lt;h3&gt;정량적 지표&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Before (수작업):
- 일일 처리량: 200벌/인
- 검수 정확도: 82%
- 작업자 피로도: 높음

After (AI + Human):
- 일일 처리량: 680벌/인 (↑ 240%)
- 검수 정확도: 91% (↑ 9%p)
- 작업자 피로도: 중간
- AI 자동 처리: 70%&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;비용 절감&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;인건비:
- 작업자 10명 → 4명
- 월 절감액: 약 1,800만 원

불량품 유출 감소:
- 유출률: 3.2% → 0.8%
- 고객 불만 감소: 72%&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;정성적 효과&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;작업자 피드백:&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&amp;quot;이제 AI가 확실한 건 다 걸러주니까 저는 애매한 것만 집중해서 볼 수 있어요.&lt;br&gt;예전에는 하루 종일 옷만 보다가 집에 가면 눈이 침침했는데 지금은 한결 낫습니다.&amp;quot;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&amp;quot;처음엔 AI가 제 일자리 뺏는 줄 알았는데, 오히려 단순 반복 작업을 덜어주니 좋네요.&amp;quot;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;strong&gt;ML팀 입장:&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&amp;quot;처음엔 mAP 90% 넘기려고 몇 달을 삽질했는데,&lt;br&gt;85%에서 멈추고 Human-in-the-Loop로 전환하니까 오히려 현장에서 더 만족해요.&amp;quot;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;배운 점&lt;/h2&gt;
&lt;h3&gt;1. 완벽한 AI는 없다&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;목표가 &amp;quot;100% 자동화&amp;quot;면 실패한다.
목표가 &amp;quot;사람을 80% 도와주기&amp;quot;면 성공한다.&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;2. 메트릭은 현장 맥락에 맞춰라&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;논문: mAP50-95가 높아야 좋은 모델
현장: mAP50만 괜찮아도 충분히 유용

중요한 건 &amp;quot;어떤 지표&amp;quot;가 아니라
&amp;quot;이 지표가 실제 업무에 무엇을 의미하는가&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;3. 빠른 피드백 루프가 핵심&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;수동 재학습 (3개월 주기):
- 성능 정체
- 현장 불만 누적

자동 재학습 (신규 데이터 1,000건):
- 지속적 개선
- 오류 패턴 빠르게 해결&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;4. 사람과 AI의 역할 분담&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;AI가 잘하는 것:
- 반복 작업
- 일관성 유지
- 빠른 처리

사람이 잘하는 것:
- 애매한 케이스 판단
- 맥락 이해
- 새로운 불량 유형 발견

둘을 섞으면 1+1=3&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;기술 스택&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// ML Pipeline
Detection: RT-DETR-L (Transformer 기반 객체 탐지)
Classification: Qwen2.5-VL 7B Instruct (Vision-Language Model)
Training: PyTorch 2.1 + Hugging Face Accelerate
Deployment: FastAPI + Ray Serve (모델 서빙)
Monitoring: Weights &amp;amp; Biases + Custom Dashboard

// GPU Infrastructure
RT-DETR 추론: RTX 4060 Ti 16GB
Qwen2.5-VL 추론: RTX 4090 24GB (FP16 양자화)
학습: A100 40GB (Cloud)

// Backend
API: FastAPI 0.104
Queue: Redis + Celery (비동기 작업)
Database: PostgreSQL 15 (메타데이터) + S3 (이미지)
Caching: Redis (신뢰도 스코어 캐싱)

// Frontend (검수 작업자용 대시보드)
Framework: Next.js 14 App Router
UI: Tailwind CSS + shadcn/ui
Real-time: Server-Sent Events (SSE) - 실시간 작업 큐 업데이트
Visualization: Recharts (메트릭 대시보드)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;왜 이 조합인가?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;RT-DETR vs YOLOv8&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;초기: YOLOv8
- mAP50: 0.83
- 작은 불량 놓침 (보풀, 미세 찢어짐)
- NMS 후처리 필요

현재: RT-DETR-L
- mAP50: 0.87 (↑ 4%p)
- Transformer 기반 → 전역적 맥락 이해
- End-to-end → NMS 불필요
- 10ms 더 느리지만 정확도 trade-off 가치 있음&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Qwen2.5-VL vs 전통적 분류 모델&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;시도했던 것들:
1. EfficientNet + 12-class Classifier
   - 정확도: 78%
   - 새 불량 유형 추가 시 재학습 필요
   - 판단 근거 없음

2. CLIP + Few-shot
   - 정확도: 82%
   - 빠르지만 섬세한 구분 어려움

3. Qwen2.5-VL 7B Instruct (최종)
   - 정확도: 92%
   - 프롬프트로 유연하게 제어
   - 자연어 설명 생성 → 작업자 신뢰도 ↑&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;다음 단계&lt;/h2&gt;
&lt;h3&gt;1. Qwen2.5-VL 추론 속도 개선&lt;/h3&gt;
&lt;p&gt;현재 병목: VLM 추론이 1.2초로 느림.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 시도 중인 최적화
optimizations = [
    {
        &amp;#39;method&amp;#39;: &amp;#39;vLLM 통합&amp;#39;,
        &amp;#39;expected&amp;#39;: &amp;#39;1.2s → 0.4s (3배 개선)&amp;#39;,
        &amp;#39;status&amp;#39;: &amp;#39;testing&amp;#39;
    },
    {
        &amp;#39;method&amp;#39;: &amp;#39;FP8 양자화&amp;#39;,
        &amp;#39;expected&amp;#39;: &amp;#39;GPU 메모리 24GB → 12GB&amp;#39;,
        &amp;#39;status&amp;#39;: &amp;#39;testing&amp;#39;
    },
    {
        &amp;#39;method&amp;#39;: &amp;#39;Speculative Decoding&amp;#39;,
        &amp;#39;expected&amp;#39;: &amp;#39;Token 생성 속도 2배&amp;#39;,
        &amp;#39;status&amp;#39;: &amp;#39;research&amp;#39;
    }
]&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 멀티모달 확장&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;현재: 이미지만
추가 예정:
- 촉감 센서 데이터 (질감 불량)
  → &amp;quot;이 보풀은 얼마나 거칠까?&amp;quot;

- 작업자 음성 피드백
  → &amp;quot;이건 세탁 냄새가 나요&amp;quot;
  → Whisper로 음성 인식 → VLM에 텍스트로 전달

- 다각도 촬영
  → RT-DETR로 여러 각도 이미지 통합 분석&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;프로토타입:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class MultiModalInspection:
    async def inspect(
        self,
        images: List[Image],  # 4방향 촬영
        texture_data: Optional[dict] = None,
        voice_note: Optional[str] = None
    ):
        # 1. RT-DETR로 모든 각도 분석
        all_detections = []
        for img in images:
            detections = await self.detector.detect(img)
            all_detections.extend(detections)

        # 2. VLM에 모든 정보 통합 전달
        prompt = f&amp;quot;&amp;quot;&amp;quot;
이미지: {len(images)}장 (전면, 후면, 좌측, 우측)
&amp;quot;&amp;quot;&amp;quot;
        if texture_data:
            prompt += f&amp;quot;촉감: 거칠기 {texture_data[&amp;#39;roughness&amp;#39;]}\n&amp;quot;

        if voice_note:
            prompt += f&amp;quot;작업자 메모: {voice_note}\n&amp;quot;

        # VLM 추론...&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Active Learning 고도화&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# VLM의 불확실성 측정
class UncertaintyEstimator:
    async def estimate(self, image, bbox):
        # 같은 입력에 여러 번 추론 (temperature 조정)
        predictions = []
        for temp in [0.1, 0.3, 0.5, 0.7]:
            result = await self.vlm.generate(
                image, 
                temperature=temp
            )
            predictions.append(result)

        # 예측 일관성 측정
        consistency = self._calculate_agreement(predictions)

        if consistency &amp;lt; 0.7:
            # 불확실함 → 학습 데이터로 우선 추가
            return {
                &amp;#39;uncertain&amp;#39;: True,
                &amp;#39;priority&amp;#39;: &amp;#39;high&amp;#39;,
                &amp;#39;predictions&amp;#39;: predictions
            }&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. Edge Deployment&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;현재: 클라우드 GPU 서버
문제: 네트워크 지연 (평균 200ms)

계획: 온프레미스 추론 서버
- RT-DETR: Jetson AGX Orin (40W)
- Qwen2.5-VL: 서버급 GPU는 여전히 필요
  → Hybrid: Detection은 Edge, Classification은 Cloud

목표: 
- RT-DETR 추론: 40ms (현장)
- VLM 추론: 400ms (클라우드)
- 총 지연: 450ms (기존 1.6s에서 개선)&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;5. 다국어 지원&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 현재: 한국어만
prompt = &amp;quot;이미지를 보고 불량을 판단하세요&amp;quot;

# 계획: Qwen2.5-VL은 다국어 지원
prompts = {
    &amp;#39;ko&amp;#39;: &amp;quot;이미지를 보고 불량을 판단하세요&amp;quot;,
    &amp;#39;en&amp;#39;: &amp;quot;Analyze the image for defects&amp;quot;,
    &amp;#39;zh&amp;#39;: &amp;quot;分析图像中的缺陷&amp;quot;,
    &amp;#39;ja&amp;#39;: &amp;quot;画像の欠陥を分析してください&amp;quot;
}

# 작업자 언어 설정에 따라 자동 전환&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;AI가 모든 걸 할 필요는 없다. 사람과 협업하면 된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Human-in-the-Loop의 핵심:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. AI가 확실한 것만 자동화
2. 애매한 것은 사람에게
3. 사람의 판단을 다시 학습에 활용
4. 지속적으로 개선&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;우리의 교훈:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;처음 목표: AI 정확도 95%
실제 달성: AI 정확도 85% + Human 협업

결과:
- 현장 만족도: 훨씬 높음
- 처리 속도: 3배 빠름
- 정확도: 오히려 더 높음

결론: AI는 도구다. 
      사람을 대체하는 게 아니라 증강(Augment)하는 것이다.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;완벽한 AI를 기다리지 말라. 80% 정도면 충분하다. 나머지는 사람이 채운다. 그리고 그 과정에서 AI는 계속 똑똑해진다.&lt;/p&gt;</description>
      <category>AI &amp;middot; ML/Computer Vision</category>
      <category>Human-in-the-Loop</category>
      <category>mlops</category>
      <category>Qwen2.5-VL</category>
      <category>rt-detr</category>
      <category>Vision-Language-Model</category>
      <category>객체탐지</category>
      <category>딥러닝</category>
      <category>컴퓨터비전</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/147</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/AI%EA%B0%80-100-%EC%A0%95%ED%99%95%ED%95%98%EC%A7%80-%EC%95%8A%EC%95%84%EB%8F%84-%EA%B4%9C%EC%B0%AE%EB%8B%A4-Human-in-the-Loop%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-%EC%9D%98%EB%A5%98-%EB%B6%88%EB%9F%89-%EA%B2%80%EC%88%98-%EC%8B%9C%EC%8A%A4%ED%85%9C#entry147comment</comments>
      <pubDate>Wed, 4 Feb 2026 13:51:45 +0900</pubDate>
    </item>
    <item>
      <title>PyTorch 하드웨어 의존성 제거하기: Hugging Face Accelerate로 갈아타야 하는 이유</title>
      <link>https://white-mouse-dev.tistory.com/entry/PyTorch-%ED%95%98%EB%93%9C%EC%9B%A8%EC%96%B4-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A0%9C%EA%B1%B0%ED%95%98%EA%B8%B0-Hugging-Face-Accelerate%EB%A1%9C-%EA%B0%88%EC%95%84%ED%83%80%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/1cd093f3-6da4-4427-97ca-7ac1c8fd19fb/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&amp;quot;로컬에서 잘 돌던 코드가 GPU 서버에 올리니 터진다&amp;quot;는 경험, 한 번쯤 있지 않은가?&lt;/p&gt;
&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;PyTorch로 딥러닝 모델을 개발하다 보면, 모델 아키텍처 자체보다 &amp;#39;학습 환경 설정(Boilerplate Code)&amp;#39; 때문에 스트레스를 받는 순간이 반드시 온다.&lt;/p&gt;
&lt;p&gt;&amp;quot;로컬(CPU)에서 짤 때는 잘 돌아갔는데, 서버(GPU)에 올리니 에러가 나네?&amp;quot;&lt;br&gt;&amp;quot;단일 GPU 코드를 멀티 GPU(DDP)로 바꾸려니 코드를 다 뜯어고쳐야 하네?&amp;quot;&lt;/p&gt;
&lt;p&gt;이런 하드웨어 의존적인 코드를 획기적으로 줄여주는 Hugging Face Accelerate 라이브러리를 소개한다. 기존 PyTorch 코드와 비교하여 얼마나 생산성이 높아지는지 살펴보자.&lt;/p&gt;
&lt;h2&gt;The &amp;quot;Before&amp;quot;: 순수 PyTorch의 고통&lt;/h2&gt;
&lt;p&gt;PyTorch만 사용하여 멀티 GPU 환경과 Mixed Precision(FP16) 학습을 구현하려면, 우리는 비즈니스 로직(모델 학습)과 상관없는 코드를 덕지덕지 붙여야 한다.&lt;/p&gt;
&lt;h3&gt;문제점&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Device 관리의 귀찮음&lt;/strong&gt;&lt;br&gt;모든 텐서와 모델에 &lt;code&gt;.to(device)&lt;/code&gt;를 명시해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;복잡한 DDP 설정&lt;/strong&gt;&lt;br&gt;DistributedDataParallel 래핑, Sampler 설정, 프로세스 그룹 초기화 등 설정이 복잡하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AMP(Mixed Precision) 코드&lt;/strong&gt;&lt;br&gt;GradScaler, autocast 등을 직접 관리해야 한다.&lt;/p&gt;
&lt;h3&gt;  기존 코드 예시 (Boilerplate의 늪)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
import torch.nn as nn
import torch.optim as optim
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data.distributed import DistributedSampler

def train():
    # 1. 하드웨어 설정 (복잡함)
    local_rank = int(os.environ[&amp;quot;LOCAL_RANK&amp;quot;])
    torch.cuda.set_device(local_rank)
    dist.init_process_group(backend=&amp;#39;nccl&amp;#39;)
    device = torch.device(&amp;quot;cuda&amp;quot;, local_rank)

    # 2. 모델을 GPU로 이동 후 DDP 래핑
    model = MyModel().to(device)
    model = DDP(model, device_ids=[local_rank])

    # 3. 데이터 로더에 Sampler 필수
    sampler = DistributedSampler(dataset)
    dataloader = DataLoader(dataset, sampler=sampler)

    optimizer = optim.Adam(model.parameters())

    # 4. Mixed Precision을 위한 Scaler 준비
    scaler = torch.cuda.amp.GradScaler()

    for batch in dataloader:
        optimizer.zero_grad()

        # 5. 데이터도 일일이 device로 이동
        inputs, targets = batch
        inputs, targets = inputs.to(device), targets.to(device)

        # 6. Autocast 컨텍스트 매니저 사용
        with torch.cuda.amp.autocast():
            outputs = model(inputs)
            loss = criterion(outputs, targets)

        # 7. Scaler를 통한 역전파 및 스텝
        scaler.scale(loss).backward()
        scaler.step(optimizer)
        scaler.update()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;보시다시피 학습 로직보다 환경 설정 코드가 더 길다. 만약 이 코드를 다시 CPU 환경에서 테스트하려면 &lt;code&gt;if torch.cuda.is_available():&lt;/code&gt; 같은 분기문을 수없이 넣어야 한다.&lt;/p&gt;
&lt;h2&gt;The &amp;quot;After&amp;quot;: Accelerate의 우아함&lt;/h2&gt;
&lt;p&gt;Accelerate는 하드웨어 설정을 추상화한다. 개발자는 &amp;quot;어디서 실행될지&amp;quot; 고민하지 않고 &amp;quot;무엇을 실행할지&amp;quot;에만 집중하면 된다.&lt;/p&gt;
&lt;h3&gt;✨ Accelerate 코드 예시&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from accelerate import Accelerator

def train():
    # 1. Accelerator 객체 생성
    # fp16, cpu, multi-gpu 등 환경을 알아서 감지
    accelerator = Accelerator(mixed_precision=&amp;quot;fp16&amp;quot;)

    device = accelerator.device  # device 할당도 알아서

    model = MyModel()
    optimizer = optim.Adam(model.parameters())
    dataloader = DataLoader(dataset)  # Sampler 안 넣어도 됨!

    # 2. Prepare: 마법의 메서드
    # 모델, 옵티마이저, 데이터로더를 현재 하드웨어에 맞게 자동 변환
    model, optimizer, dataloader = accelerator.prepare(
        model, optimizer, dataloader
    )

    for batch in dataloader:
        optimizer.zero_grad()

        # 3. .to(device) 불필요 (자동 처리됨)
        inputs, targets = batch

        outputs = model(inputs)
        loss = criterion(outputs, targets)

        # 4. 역전파: 문법 통일
        accelerator.backward(loss)

        optimizer.step()&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;무엇이 바뀌었나?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;.to(device)&lt;/code&gt; 삭제&lt;/strong&gt;&lt;br&gt;&lt;code&gt;prepare()&lt;/code&gt; 메서드를 통과한 DataLoader는 배치 데이터를 자동으로 올바른 장치(GPU/TPU)에 올려준다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;scaler&lt;/code&gt; 삭제&lt;/strong&gt;&lt;br&gt;&lt;code&gt;accelerator.backward(loss)&lt;/code&gt;가 내부적으로 Mixed Precision scaling을 알아서 처리한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;단일 코드베이스&lt;/strong&gt;&lt;br&gt;위 코드는 수정 없이 CPU, 싱글 GPU, 멀티 GPU, 심지어 TPU에서도 그대로 돌아간다.&lt;/p&gt;
&lt;h2&gt;실행 방법 (CLI)&lt;/h2&gt;
&lt;p&gt;코드 내에서 하드웨어를 지정하지 않았기 때문에, 실행 시점에 CLI로 환경을 주입한다.&lt;/p&gt;
&lt;h3&gt;설정 마법사 실행 (최초 1회)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;accelerate config
# 질문: GPU 몇 개 쓸 거야? FP16 쓸 거야? 
# → 답변하면 config 파일 생성됨&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;학습 실행&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;accelerate launch train.py&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 파이썬 스크립트는 &lt;code&gt;accelerate launch&lt;/code&gt;가 던져주는 환경 설정에 맞춰 유연하게 동작한다.&lt;/p&gt;
&lt;h2&gt;실전 사례: 팀 마이그레이션 경험&lt;/h2&gt;
&lt;p&gt;우리 팀에서 RT-DETR 학습 코드를 Accelerate로 마이그레이션한 경험을 공유한다.&lt;/p&gt;
&lt;h3&gt;Before: 하드웨어별 코드 분기&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 로컬 개발용
if torch.cuda.is_available():
    device = torch.device(&amp;quot;cuda&amp;quot;)
    model = model.to(device)
else:
    device = torch.device(&amp;quot;cpu&amp;quot;)

# 멀티 GPU용 (별도 파일)
model = DDP(model, device_ids=[local_rank])
sampler = DistributedSampler(train_dataset)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;로컬에서 테스트하고, 서버에 배포할 때마다 코드를 바꿔야 했다.&lt;/p&gt;
&lt;h3&gt;After: 통합 코드베이스&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from accelerate import Accelerator

accelerator = Accelerator(
    mixed_precision=&amp;quot;fp16&amp;quot;,
    gradient_accumulation_steps=4
)

model = RTDETRModel()
optimizer = AdamW(model.parameters(), lr=1e-4)
train_loader = DataLoader(train_dataset, batch_size=8)

# 마법의 prepare
model, optimizer, train_loader = accelerator.prepare(
    model, optimizer, train_loader
)

for epoch in range(num_epochs):
    for batch in train_loader:
        with accelerator.accumulate(model):
            outputs = model(batch[&amp;#39;images&amp;#39;])
            loss = criterion(outputs, batch[&amp;#39;targets&amp;#39;])
            accelerator.backward(loss)
            optimizer.step()
            optimizer.zero_grad()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결과:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;로컬(Mac M2): 그대로 실행 ✅&lt;/li&gt;
&lt;li&gt;서버(RTX 4060 1장): 그대로 실행 ✅&lt;/li&gt;
&lt;li&gt;서버(A100 4장): 그대로 실행 ✅&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;코드 한 줄도 안 바꿨다.&lt;/p&gt;
&lt;h2&gt;알아두면 좋은 기능들&lt;/h2&gt;
&lt;h3&gt;Gradient Accumulation&lt;/h3&gt;
&lt;p&gt;배치 크기를 늘리지 않고 그래디언트만 누적할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;accelerator = Accelerator(gradient_accumulation_steps=4)

for batch in train_loader:
    with accelerator.accumulate(model):  # 4번에 1번만 업데이트
        outputs = model(batch)
        loss = criterion(outputs, targets)
        accelerator.backward(loss)
        optimizer.step()
        optimizer.zero_grad()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GPU 메모리 부족할 때 유용하다.&lt;/p&gt;
&lt;h3&gt;체크포인트 저장/로드&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 저장
accelerator.save_state(output_dir=&amp;quot;./checkpoints&amp;quot;)

# 불러오기
accelerator.load_state(input_dir=&amp;quot;./checkpoints&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DDP 상태까지 알아서 저장해준다.&lt;/p&gt;
&lt;h3&gt;로깅&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 메인 프로세스에서만 로그 출력
if accelerator.is_main_process:
    print(f&amp;quot;Epoch {epoch}, Loss: {loss.item()}&amp;quot;)

# 또는
accelerator.print(f&amp;quot;Epoch {epoch}, Loss: {loss.item()}&amp;quot;)
# 이건 자동으로 메인 프로세스에서만 출력됨&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;멀티 GPU 학습할 때 로그가 중복으로 찍히는 거 방지할 수 있다.&lt;/p&gt;
&lt;h2&gt;실제 성능 비교&lt;/h2&gt;
&lt;p&gt;우리 팀에서 RT-DETR 학습할 때 측정한 수치다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;개발 시간:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Before: 로컬/서버 코드 분기 때문에 2~3시간 소요&lt;/li&gt;
&lt;li&gt;After: 통합 코드로 30분 단축&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;디버깅 시간:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Before: DDP 에러 디버깅에 하루 날림&lt;/li&gt;
&lt;li&gt;After: 에러 없음 (Accelerate가 알아서 처리)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;코드 라인 수:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Before: 하드웨어 설정 코드 ~100줄&lt;/li&gt;
&lt;li&gt;After: Accelerator 설정 ~10줄&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;주의사항&lt;/h2&gt;
&lt;h3&gt;커스텀 학습 루프에서만 사용&lt;/h3&gt;
&lt;p&gt;Trainer API(Hugging Face Transformers)를 쓰면 Accelerate가 이미 내장돼 있다. 커스텀 학습 루프를 짤 때만 직접 써야 한다.&lt;/p&gt;
&lt;h3&gt;디버깅 시&lt;/h3&gt;
&lt;p&gt;가끔 Accelerate 내부에서 에러가 나면 스택 트레이스가 복잡할 수 있다. 그럴 땐 &lt;code&gt;ACCELERATE_DEBUG_MODE=1&lt;/code&gt;로 실행하면 자세한 로그를 볼 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ACCELERATE_DEBUG_MODE=1 accelerate launch train.py&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;기존 코드 마이그레이션&lt;/h3&gt;
&lt;p&gt;한 번에 다 바꾸려고 하지 말고, 단계적으로 마이그레이션하라.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1단계: Accelerator 생성 + prepare만 적용
2단계: .to(device) 제거
3단계: scaler 제거
4단계: DDP 코드 제거&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;결론: 왜 도입해야 하는가?&lt;/h2&gt;
&lt;p&gt;개발 팀 리더로서 Accelerate 도입을 추천하는 이유는 단순한 &amp;#39;편리함&amp;#39; 때문만은 아니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;유지보수성 향상&lt;/strong&gt;&lt;br&gt;하드웨어 종속 코드가 사라져 비즈니스 로직(모델링)이 명확해진다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;실험 속도 가속화&lt;/strong&gt;&lt;br&gt;로컬(Mac/CPU)에서 짠 코드를 배포(A100/Multi-GPU) 할 때 코드를 수정할 필요가 없다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;실용적인 접근&lt;/strong&gt;&lt;br&gt;Hugging Face 생태계의 도구지만, PyTorch의 네이티브 기능을 해치지 않고 얇은 래퍼(Wrapper)로 동작하여 디버깅도 용이하다.&lt;/p&gt;
&lt;p&gt;복잡한 &lt;code&gt;torch.distributed&lt;/code&gt; 문서와 씨름하는 시간을 줄이고, 모델 성능 최적화에 그 시간을 투자하라.&lt;/p&gt;</description>
      <category>AI &amp;middot; ML/Computer Vision</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/146</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/PyTorch-%ED%95%98%EB%93%9C%EC%9B%A8%EC%96%B4-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A0%9C%EA%B1%B0%ED%95%98%EA%B8%B0-Hugging-Face-Accelerate%EB%A1%9C-%EA%B0%88%EC%95%84%ED%83%80%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0#entry146comment</comments>
      <pubDate>Wed, 28 Jan 2026 14:51:14 +0900</pubDate>
    </item>
    <item>
      <title>CI가 5분씩 걸려서 뜯어봤더니... 린트(Lint)에 무거운 의존성을 태우고 있었다</title>
      <link>https://white-mouse-dev.tistory.com/entry/CI%EA%B0%80-5%EB%B6%84%EC%94%A9-%EA%B1%B8%EB%A0%A4%EC%84%9C-%EB%9C%AF%EC%96%B4%EB%B4%A4%EB%8D%94%EB%8B%88-%EB%A6%B0%ED%8A%B8Lint%EC%97%90-%EB%AC%B4%EA%B1%B0%EC%9A%B4-%EC%9D%98%EC%A1%B4%EC%84%B1%EC%9D%84-%ED%83%9C%EC%9A%B0%EA%B3%A0-%EC%9E%88%EC%97%88%EB%8B%A4</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Lint에 PyTorch를 설치하고 있었다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;발단&lt;/h2&gt;
&lt;p&gt;PR 올릴 때마다 CI가 5분씩 걸렸다. 코드 한 줄 고쳐서 올렸는데 초록불 보려면 5분. 커피 타 오기엔 애매하고, 가만히 기다리기엔 긴 시간이다.&lt;/p&gt;
&lt;p&gt;&amp;quot;원래 CI가 이렇게 오래 걸리나?&amp;quot;&lt;/p&gt;
&lt;p&gt;아니었다. 뜯어보니 &lt;strong&gt;Lint job에서 PyTorch, transformers, ultralytics를 설치&lt;/strong&gt;하고 있었다. ruff 돌리려고.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;문제의 워크플로우&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Before: ci.yml
jobs:
  lint:
    name: Lint &amp;amp; Type Check
    steps:
      - name: Install Poetry
        uses: snok/install-poetry@v1
      - name: Install dependencies
        run: poetry install --no-interaction --no-root  # 여기서 PyTorch 설치
      - name: Run Ruff
        run: poetry run ruff check app/

  test:
    name: Test
    needs: lint  # Lint 끝나야 Test 시작&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조의 문제점:&lt;br&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/6158001f-981b-45ac-b005-ae39186499b0/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;총 시간: 3분 51초 (직렬 실행)&lt;/code&gt;&lt;/pre&gt;&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Lint에 전체 의존성 설치&lt;/strong&gt; - ruff 돌리는 데 PyTorch가 필요 없다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;순차 실행&lt;/strong&gt; - Lint 끝날 때까지 Test가 대기&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;시간 낭비&lt;/strong&gt; - 둘이 독립적인데 왜 기다리나?&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;수정 1: Lint에서 불필요한 의존성 제거&lt;/h2&gt;
&lt;p&gt;ruff는 정적 분석 도구다. 코드를 실행하지 않는다. 그런데 왜 &lt;code&gt;poetry install&lt;/code&gt;로 런타임 의존성을 전부 설치하고 있었을까?&lt;/p&gt;
&lt;p&gt;그냥 관성이었다. &amp;quot;Lint job이니까 Poetry 셋업하고 의존성 설치하고...&amp;quot; 복붙의 폐해다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# After: Lint job
- name: Install ruff
  run: pip install ruff  # 3초

- name: Run Ruff
  run: ruff check app/&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결과: 1분 41초 → 10초&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;수정 2: Lint와 Test 병렬 실행&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Before
test:
  needs: lint  # Lint 완료 후 Test 시작

# After
test:
  # needs 제거 → Lint와 동시 시작

build:
  needs: [lint, test]  # 둘 다 성공해야 Build&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;needs&lt;/code&gt;를 제거하면 GitHub Actions가 두 job을 &lt;strong&gt;병렬로 실행&lt;/strong&gt;한다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/92bf09f6-46ea-4122-a6c0-71b8ea1af63c/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Before:
[Lint] ──────→ [Test]
 1m 41s         2m 10s
              총 3분 51초

After:
[Lint]  ────────┐
  10s           ├──→ [Build]
[Test]  ────────┘       42s
 2m 21s
              총 3분 3초 (Test 기준)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Lint가 10초면 끝나니까, 사실상 &lt;strong&gt;Test 시간 + Build 시간&lt;/strong&gt;이 전체 CI 시간이 된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;최종 구조&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: CI

on: push

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install ruff
        run: pip install ruff
      - name: Run Ruff
        run: ruff check app/

  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: &amp;quot;3.11&amp;quot;
      - name: Install Poetry
        uses: snok/install-poetry@v1
      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: .venv
          key: venv-${{ runner.os }}-${{ hashFiles(&amp;#39;**/poetry.lock&amp;#39;) }}
      - name: Install dependencies
        run: poetry install --no-interaction --no-root
      - name: Run tests
        run: poetry run pytest

  build:
    name: Build Docker Image
    needs: [lint, test]  # 둘 다 성공해야 실행
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: docker build -t app .&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;결과 비교&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Lint 의존성&lt;/td&gt;
&lt;td&gt;Poetry 전체 설치&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pip install ruff&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lint 시간&lt;/td&gt;
&lt;td&gt;1m 41s&lt;/td&gt;
&lt;td&gt;10s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 방식&lt;/td&gt;
&lt;td&gt;순차 (Lint → Test)&lt;/td&gt;
&lt;td&gt;병렬 (Lint ∥ Test)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전체 CI&lt;/td&gt;
&lt;td&gt;~4분+&lt;/td&gt;
&lt;td&gt;~3분&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;체감상 &lt;strong&gt;40초 정도 단축&lt;/strong&gt;. 숫자로 보면 대단해 보이진 않지만, PR 10번 올리면 7분 절약이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;추가로 할 수 있는 것들&lt;/h2&gt;
&lt;h3&gt;1. Docker layer caching&lt;/h3&gt;
&lt;p&gt;현재는 &lt;code&gt;docker build&lt;/code&gt;만 하고 있다. &lt;code&gt;docker/build-push-action&lt;/code&gt;의 &lt;code&gt;cache-from/cache-to&lt;/code&gt; 옵션을 쓰면 레이어 캐싱이 가능하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    cache-from: type=gha
    cache-to: type=gha,mode=max&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. poetry.lock 커밋 확인&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;key: venv-${{ runner.os }}-${{ hashFiles(&amp;#39;**/poetry.lock&amp;#39;) }}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 캐시 키가 제대로 작동하려면 &lt;strong&gt;poetry.lock 파일이 레포에 커밋되어 있어야 한다&lt;/strong&gt;. 없으면 매번 캐시 미스.&lt;/p&gt;
&lt;h3&gt;3. Test 분할 실행&lt;/h3&gt;
&lt;p&gt;테스트가 많아지면 &lt;code&gt;pytest-split&lt;/code&gt;으로 병렬화할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;test:
  strategy:
    matrix:
      group: [1, 2, 3]
  steps:
    - run: poetry run pytest --splits 3 --group ${{ matrix.group }}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;교훈&lt;/h2&gt;
&lt;p&gt;CI 최적화의 핵심은 &lt;strong&gt;&amp;quot;이 job에 이게 진짜 필요한가?&amp;quot;&lt;/strong&gt;를 묻는 것이다.&lt;/p&gt;
&lt;p&gt;Lint에 PyTorch가 필요한가? 아니다.&lt;br&gt;Lint가 끝나야 Test를 시작할 수 있나? 아니다.&lt;/p&gt;
&lt;p&gt;당연하다고 생각했던 것들을 의심하면 의외로 쉽게 시간을 줄일 수 있다.&lt;/p&gt;</description>
      <category>Backend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/145</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/CI%EA%B0%80-5%EB%B6%84%EC%94%A9-%EA%B1%B8%EB%A0%A4%EC%84%9C-%EB%9C%AF%EC%96%B4%EB%B4%A4%EB%8D%94%EB%8B%88-%EB%A6%B0%ED%8A%B8Lint%EC%97%90-%EB%AC%B4%EA%B1%B0%EC%9A%B4-%EC%9D%98%EC%A1%B4%EC%84%B1%EC%9D%84-%ED%83%9C%EC%9A%B0%EA%B3%A0-%EC%9E%88%EC%97%88%EB%8B%A4#entry145comment</comments>
      <pubDate>Fri, 23 Jan 2026 16:05:42 +0900</pubDate>
    </item>
    <item>
      <title>Next.js 16 릴리즈: 캐싱, 드디어 명시적으로 바뀌다</title>
      <link>https://white-mouse-dev.tistory.com/entry/Nextjs-16-%EB%A6%B4%EB%A6%AC%EC%A6%88-%EC%BA%90%EC%8B%B1-%EB%93%9C%EB%94%94%EC%96%B4-%EB%AA%85%EC%8B%9C%EC%A0%81%EC%9C%BC%EB%A1%9C-%EB%B0%94%EB%80%8C%EB%8B%A4</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/07b7f6f4-c5f0-45c7-a0fe-d4f2b77522bc/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;v15에서 &amp;quot;왜 자꾸 캐싱 안 돼?&amp;quot;라고 당황했던 개발자라면, v16의 변화가 반가울 것이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;2025년 10월 21일, Next.js 16이 정식 출시됐다.&lt;/p&gt;
&lt;p&gt;릴리즈 노트를 읽다가 눈이 멈춘 부분이 있었다. &lt;code&gt;revalidateTag()&lt;/code&gt; 시그니처 변경. 이거 기존 코드 전부 깨지는 거 아닌가? 확인해보니 맞았다. 그것도 &lt;strong&gt;두 번째 인자가 필수&lt;/strong&gt;로 바뀌는 Breaking Change였다.&lt;/p&gt;
&lt;p&gt;단순히 API 하나 바뀐 게 아니다. v14 → v15 → v16으로 이어지는 &lt;strong&gt;캐싱 철학의 변화&lt;/strong&gt;가 이번 버전에서 완성됐다. 이 흐름을 이해하지 않으면 마이그레이션할 때 &amp;quot;왜 이렇게 바꿨지?&amp;quot;라는 의문만 남는다.&lt;/p&gt;
&lt;p&gt;정리해봤다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;버전별 캐싱 정책의 변화&lt;/h2&gt;
&lt;p&gt;먼저 세 버전의 캐싱 정책을 한눈에 보자.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;버전&lt;/th&gt;
&lt;th&gt;캐싱 기본값&lt;/th&gt;
&lt;th&gt;개발자 반응&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;v14&lt;/td&gt;
&lt;td&gt;Cached by Default&lt;/td&gt;
&lt;td&gt;&amp;quot;왜 자꾸 캐싱돼?&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v15&lt;/td&gt;
&lt;td&gt;Uncached by Default&lt;/td&gt;
&lt;td&gt;&amp;quot;왜 갑자기 캐싱 안 돼?&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v16&lt;/td&gt;
&lt;td&gt;Explicit &lt;code&gt;&amp;quot;use cache&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&amp;quot;아, 내가 명시하면 되는구나&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;마치 롤러코스터다. v14에서 과도하게 캐싱하다가, v15에서 갑자기 캐싱을 끄고, v16에서 &amp;quot;너희가 직접 정해라&amp;quot;로 결론 난 것이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;v14 → v15: &amp;quot;과잉 친절한 집사&amp;quot; 해고&lt;/h2&gt;
&lt;h3&gt;v14의 문제: 시키지도 않은 캐싱&lt;/h3&gt;
&lt;p&gt;v14의 App Router는 &lt;strong&gt;모든 걸 캐싱하려고 했다&lt;/strong&gt;. fetch 요청, GET 핸들러, 페이지 데이터까지.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// v14에서 이 코드는 자동으로 캐싱됨
async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;문제는 이게 &lt;strong&gt;의도치 않은 동작&lt;/strong&gt;을 만들었다는 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;사용자 A가 프로필 수정
    ↓
DB에는 새 데이터 저장됨
    ↓
페이지 새로고침
    ↓
??? 여전히 옛날 데이터가 보임 ???
    ↓
&amp;quot;아... 캐싱 때문이구나&amp;quot;
    ↓
revalidate 옵션 찾아서 추가&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이런 경험 한 번쯤 있지 않은가?&lt;/p&gt;
&lt;h3&gt;v15의 해결책: 일단 다 끄자&lt;/h3&gt;
&lt;p&gt;v15는 과감하게 &lt;strong&gt;기본값을 뒤집었다&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// v15부터 기본값이 no-store
const res = await fetch(`/api/users/${id}`);
// ↑ 캐싱 안 됨

// 캐싱하려면 명시적으로
const res = await fetch(`/api/users/${id}`, { 
  cache: &amp;#39;force-cache&amp;#39; 
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 동기 API들이 &lt;strong&gt;비동기로 바뀌었다&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// v14
const cookieStore = cookies();

// v15
const cookieStore = await cookies();&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;v16의 접근: &amp;quot;네가 직접 말해&amp;quot;&lt;/h2&gt;
&lt;p&gt;v16은 한 발 더 나아갔다. &lt;strong&gt;&lt;code&gt;&amp;quot;use cache&amp;quot;&lt;/code&gt; 디렉티브&lt;/strong&gt;를 도입해서 캐싱을 완전히 선언적으로 만들었다.&lt;/p&gt;
&lt;h3&gt;Cache Components 활성화&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// next.config.ts
const nextConfig = {
  cacheComponents: true,
};

export default nextConfig;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;사용 방식&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────┐
│                                                 │
│  v14: &amp;quot;기본적으로 캐싱할게. 싫으면 말해&amp;quot;         │
│       → 개발자: &amp;quot;왜 자꾸 캐싱돼?&amp;quot;               │
│                                                 │
│  v15: &amp;quot;기본적으로 캐싱 안 할게. 필요하면 말해&amp;quot;   │
│       → 개발자: &amp;quot;매번 옵션 넣기 귀찮은데...&amp;quot;    │
│                                                 │
│  v16: &amp;quot;캐싱할 곳에 use cache 써. 거기만 캐싱함&amp;quot; │
│       → 개발자: &amp;quot;오, 명확하네&amp;quot;                  │
│                                                 │
└─────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이게 &lt;strong&gt;PPR(Partial Prerendering)&lt;/strong&gt;의 완성형이다. 2023년에 &amp;quot;정적 셸은 즉시, 동적 콘텐츠는 스트리밍&amp;quot;이라는 개념으로 시작했는데, v16에서 프로그래밍 모델로 완성된 것이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;새로운 Caching API 삼총사&lt;/h2&gt;
&lt;p&gt;v16에서는 캐시 제어 API도 정비됐다.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;revalidateTag()&lt;/code&gt; - 시그니처가 바뀌었다&lt;/h3&gt;
&lt;p&gt;이게 &lt;strong&gt;Breaking Change&lt;/strong&gt;다. 기존 코드 전부 수정해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { revalidateTag } from &amp;#39;next/cache&amp;#39;;

// ❌ v15 이전 - 더 이상 안 됨
revalidateTag(&amp;#39;blog-posts&amp;#39;);

// ✅ v16 - 두 번째 인자 필수
revalidateTag(&amp;#39;blog-posts&amp;#39;, &amp;#39;max&amp;#39;);
revalidateTag(&amp;#39;products&amp;#39;, { expire: 3600 });&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;두 번째 인자는 &lt;code&gt;cacheLife&lt;/code&gt; 프로필이다. &lt;code&gt;&amp;#39;max&amp;#39;&lt;/code&gt;, &lt;code&gt;&amp;#39;hours&amp;#39;&lt;/code&gt;, &lt;code&gt;&amp;#39;days&amp;#39;&lt;/code&gt; 같은 빌트인 값을 쓰거나, &lt;code&gt;{ expire: number }&lt;/code&gt;로 직접 지정한다.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;updateTag()&lt;/code&gt; - 새로 추가됨&lt;/h3&gt;
&lt;p&gt;Server Actions 전용. &lt;strong&gt;즉시 갱신&lt;/strong&gt;이 필요할 때 쓴다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;&amp;#39;use server&amp;#39;;

import { updateTag } from &amp;#39;next/cache&amp;#39;;

export async function updateUserProfile(userId: string, data: Profile) {
  await db.users.update(userId, data);

  // 캐시 만료 + 즉시 새 데이터 로드
  updateTag(`user-${userId}`);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;revalidateTag()&lt;/code&gt;와 차이점:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;revalidateTag()&lt;/code&gt;: SWR 방식 (일단 캐시 보여주고 백그라운드에서 갱신)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;updateTag()&lt;/code&gt;: 즉시 갱신 (사용자가 바로 변경 확인 가능)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;code&gt;refresh()&lt;/code&gt; - 라우터 새로고침 (캐시된 건 유지)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;&amp;#39;use server&amp;#39;;

import { refresh } from &amp;#39;next/cache&amp;#39;;

export async function markNotificationAsRead(id: string) {
  await db.notifications.markAsRead(id);

  // 현재 라우트 다시 렌더링 트리거
  refresh();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;알림 카운트처럼 fetch 시 &lt;code&gt;no-store&lt;/code&gt;로 설정한 데이터만 새로 받아온다. 캐싱된 컴포넌트나 데이터는 그대로 유지된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Turbopack: 이제 진짜 기본값&lt;/h2&gt;
&lt;p&gt;Turbopack이 드디어 &lt;strong&gt;개발과 빌드 모두에서 기본&lt;/strong&gt;이 됐다.&lt;/p&gt;
&lt;h3&gt;체감 성능&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;개선폭&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Fast Refresh&lt;/td&gt;
&lt;td&gt;최대 10배&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;프로덕션 빌드&lt;/td&gt;
&lt;td&gt;2~5배&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;Webpack으로 돌아가고 싶다면?&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;next dev --webpack
next build --webpack&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;커스텀 Webpack 설정이 복잡하면 당분간 이렇게 쓸 수 있다.&lt;/p&gt;
&lt;h3&gt;File System Caching (Beta)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// next.config.ts
const nextConfig = {
  experimental: {
    turbopackFileSystemCacheForDev: true,
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;컴파일 결과를 디스크에 저장해서 &lt;strong&gt;재시작 시 컴파일 시간을 단축&lt;/strong&gt;한다. 모노레포 같은 대규모 프로젝트에서 체감된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;&lt;code&gt;middleware.ts&lt;/code&gt; → &lt;code&gt;proxy.ts&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;미들웨어 파일명이 바뀌었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// proxy.ts (구 middleware.ts)
export default function proxy(request: NextRequest) {
  return NextResponse.redirect(new URL(&amp;#39;/home&amp;#39;, request.url));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;왜 바꿨을까?&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────┐
│                                                 │
│  &amp;quot;middleware&amp;quot;라는 이름이 모호했다               │
│                                                 │
│  실제 역할: 네트워크 경계에서 요청 가로채기     │
│  새 이름: proxy                                │
│  런타임: Node.js (Edge 아님)                   │
│                                                 │
└─────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;middleware.ts&lt;/code&gt;는 Edge 런타임 케이스를 위해 아직 동작하지만, deprecated다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Enhanced Routing: 프리페칭이 똑똑해졌다&lt;/h2&gt;
&lt;h3&gt;Layout Deduplication&lt;/h3&gt;
&lt;p&gt;50개의 상품 링크가 있는 페이지를 생각해보자.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;v15까지:
  Link 1 프리페칭 → 레이아웃 + 페이지 데이터
  Link 2 프리페칭 → 레이아웃 + 페이지 데이터
  ...
  Link 50 프리페칭 → 레이아웃 + 페이지 데이터

  결과: 레이아웃 50번 다운로드 (중복!)

v16:
  Link 1 프리페칭 → 레이아웃 + 페이지 데이터
  Link 2 프리페칭 → 페이지 데이터만 (레이아웃은 캐시됨)
  ...
  Link 50 프리페칭 → 페이지 데이터만

  결과: 레이아웃 1번만 다운로드&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;Incremental Prefetching&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;이미 캐시된 부분은 건너뜀&lt;/li&gt;
&lt;li&gt;뷰포트를 벗어나면 프리페칭 취소&lt;/li&gt;
&lt;li&gt;hover 시 우선순위 상승&lt;/li&gt;
&lt;li&gt;데이터 무효화되면 자동 재프리페칭&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;개별 요청 수는 늘어날 수 있지만, &lt;strong&gt;총 전송량은 크게 줄어든다&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;React 19.2 Canary 기능들&lt;/h2&gt;
&lt;p&gt;v16은 React 최신 Canary를 사용한다.&lt;/p&gt;
&lt;h3&gt;View Transitions&lt;/h3&gt;
&lt;p&gt;트랜지션/네비게이션 시 요소에 애니메이션 적용.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;useEffectEvent()&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Effect에서 비반응형 로직을 분리.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;&amp;lt;Activity/&amp;gt;&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// display: none으로 숨기면서 상태 유지
&amp;lt;Activity mode=&amp;quot;hidden&amp;quot;&amp;gt;
  &amp;lt;HeavyComponent /&amp;gt;
&amp;lt;/Activity&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;탭 전환 같은 시나리오에서 유용하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Breaking Changes 체크리스트&lt;/h2&gt;
&lt;h3&gt;버전 요구사항&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;최소 버전&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;20.9+ (18 지원 종료)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;5.1.0+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome/Edge&lt;/td&gt;
&lt;td&gt;111+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;111+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari&lt;/td&gt;
&lt;td&gt;16.4+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;제거된 것들&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;제거됨&lt;/th&gt;
&lt;th&gt;대체 방법&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;AMP 지원&lt;/td&gt;
&lt;td&gt;완전 삭제됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;next lint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Biome 또는 ESLint CLI 직접 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverRuntimeConfig&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.env&lt;/code&gt; 환경변수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;experimental.ppr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cacheComponents&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동기 &lt;code&gt;cookies()&lt;/code&gt;, &lt;code&gt;headers()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;await&lt;/code&gt; 필수&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;동작 변경&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;images.minimumCacheTTL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60초&lt;/td&gt;
&lt;td&gt;4시간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;images.qualities&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[1..100]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[75]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parallel routes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;default.js&lt;/code&gt; 선택&lt;/td&gt;
&lt;td&gt;&lt;code&gt;default.js&lt;/code&gt; 필수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;revalidateTag()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;인자 1개&lt;/td&gt;
&lt;td&gt;인자 2개 필수&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;마이그레이션 가이드&lt;/h2&gt;
&lt;h3&gt;자동 마이그레이션&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx @next/codemod@canary upgrade latest&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;대부분 자동 처리된다.&lt;/p&gt;
&lt;h3&gt;수동으로 확인할 것&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;revalidateTag()&lt;/code&gt; 전부 수정&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Before
revalidateTag(&amp;#39;posts&amp;#39;);

// After
revalidateTag(&amp;#39;posts&amp;#39;, &amp;#39;max&amp;#39;);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2. Parallel Routes에 &lt;code&gt;default.js&lt;/code&gt; 추가&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;없으면 빌드가 실패한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// app/@modal/default.js
export default function Default() {
  return null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;3. Node.js 버전 확인&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;node -v  # 20.9.0 이상인지 확인&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;정리: 버전별 비교&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;v14&lt;/th&gt;
&lt;th&gt;v15&lt;/th&gt;
&lt;th&gt;v16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;캐싱 모델&lt;/td&gt;
&lt;td&gt;Cached by Default&lt;/td&gt;
&lt;td&gt;Uncached by Default&lt;/td&gt;
&lt;td&gt;Explicit &lt;code&gt;&amp;quot;use cache&amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;번들러&lt;/td&gt;
&lt;td&gt;Webpack&lt;/td&gt;
&lt;td&gt;Turbopack (Dev)&lt;/td&gt;
&lt;td&gt;Turbopack (Full)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터 API&lt;/td&gt;
&lt;td&gt;동기&lt;/td&gt;
&lt;td&gt;비동기 필수&lt;/td&gt;
&lt;td&gt;비동기 + 새 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;19.2 Canary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;미들웨어&lt;/td&gt;
&lt;td&gt;&lt;code&gt;middleware.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;middleware.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;proxy.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;Next.js의 캐싱 정책 변화는 &lt;strong&gt;&amp;quot;좋은 기본값이 뭔가?&amp;quot;&lt;/strong&gt;에 대한 Vercel의 고민을 보여준다.&lt;/p&gt;
&lt;p&gt;v14: &amp;quot;다 캐싱하면 빠르겠지&amp;quot; → 예측 불가능한 동작&lt;br&gt;v15: &amp;quot;일단 다 끄자&amp;quot; → 매번 명시하기 귀찮음&lt;br&gt;v16: &amp;quot;필요한 곳에 선언해라&amp;quot; → 명확하고 예측 가능&lt;/p&gt;
&lt;p&gt;결국 &lt;strong&gt;&amp;quot;명시적인 게 암묵적인 것보다 낫다&amp;quot;&lt;/strong&gt;는 결론에 도달한 것 같다.&lt;/p&gt;
&lt;p&gt;마이그레이션 시 &lt;code&gt;revalidateTag()&lt;/code&gt; 시그니처 변경과 Parallel Routes의 &lt;code&gt;default.js&lt;/code&gt; 필수화를 특히 주의하자. 나머지는 codemod가 대부분 처리해준다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/144</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/Nextjs-16-%EB%A6%B4%EB%A6%AC%EC%A6%88-%EC%BA%90%EC%8B%B1-%EB%93%9C%EB%94%94%EC%96%B4-%EB%AA%85%EC%8B%9C%EC%A0%81%EC%9C%BC%EB%A1%9C-%EB%B0%94%EB%80%8C%EB%8B%A4#entry144comment</comments>
      <pubDate>Thu, 22 Jan 2026 17:40:34 +0900</pubDate>
    </item>
    <item>
      <title>YOLO만 쓰던 개발자가 RT-DETR을 선택한 이유</title>
      <link>https://white-mouse-dev.tistory.com/entry/YOLO%EB%A7%8C-%EC%93%B0%EB%8D%98-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-RT-DETR%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/7a852afe-fa1e-4110-a35f-81a0adc86141/image.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;의류 검수 AI 시스템을 설계하면서 깨달은 Object Detection 모델 선택의 기준&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;&amp;quot;객체 탐지? 그럼 YOLO지.&amp;quot;&lt;/p&gt;
&lt;p&gt;솔직히 이게 그동안 내 접근 방식이었다. 차량 번호판 인식 프로젝트에서 YOLO를 써본 이후로, Object Detection이 필요하면 자연스럽게 YOLO를 꺼내 들었다. 빠르고, 정확하고, 레퍼런스도 많으니까.&lt;/p&gt;
&lt;p&gt;그런데 최근 의류 품질 검수 AI 시스템을 설계하면서 생각이 바뀌었다. 이 글에서는 왜 YOLO 대신 RT-DETR을 선택했는지, 그 과정에서 알게 된 두 모델의 근본적인 차이를 정리해보려 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;과거 경험: 번호판 인식에서의 YOLO&lt;/h2&gt;
&lt;p&gt;이전에 차량 번호판 인식 시스템을 개발한 적이 있다. 당시 YOLO를 선택했고, 결과는 대만족이었다.&lt;/p&gt;
&lt;h3&gt;번호판 인식은 &amp;quot;쉬운&amp;quot; 문제다&lt;/h3&gt;
&lt;p&gt;번호판 검출은 Object Detection 관점에서 상대적으로 단순한 문제다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;특성&lt;/th&gt;
&lt;th&gt;번호판&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;형태&lt;/td&gt;
&lt;td&gt;직사각형으로 고정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;색상&lt;/td&gt;
&lt;td&gt;명확함 (흰색, 노란색, 녹색)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;위치&lt;/td&gt;
&lt;td&gt;예측 가능 (차량 전후면)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;크기&lt;/td&gt;
&lt;td&gt;상대적으로 일정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;겹침&lt;/td&gt;
&lt;td&gt;거의 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;이런 특성의 문제에는 &lt;strong&gt;빠르고 단순한 모델이 정답&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h3&gt;YOLO가 번호판에 최적인 이유&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;정확도&lt;/th&gt;
&lt;th&gt;추론 속도&lt;/th&gt;
&lt;th&gt;적합성&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;YOLOv8n&lt;/td&gt;
&lt;td&gt;95%+&lt;/td&gt;
&lt;td&gt;~5ms&lt;/td&gt;
&lt;td&gt;✅ 최적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RT-DETR&lt;/td&gt;
&lt;td&gt;96%+&lt;/td&gt;
&lt;td&gt;~15ms&lt;/td&gt;
&lt;td&gt;과잉 스펙&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;정확도 차이는 1% 미만인데, 속도는 3배 차이. 실시간 처리가 중요한 번호판 인식에서 RT-DETR을 쓰는 건 &lt;strong&gt;못 박는데 메스를 쓰는 격&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;그래서 나도 &amp;quot;Object Detection = YOLO&amp;quot;라는 공식이 자연스럽게 굳어졌다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;새로운 프로젝트: 의류 검수 AI 시스템&lt;/h2&gt;
&lt;p&gt;그러다 새로운 프로젝트를 맡게 됐다. 중고 의류의 품질을 자동으로 검수하는 AI 시스템이다.&lt;/p&gt;
&lt;h3&gt;요구사항&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;3000×4000 고해상도 이미지에서 결함 탐지&lt;/li&gt;
&lt;li&gt;탐지 대상: 오염(얼룩, 때), 손상(구멍, 찢어짐), 부착물(택, 라벨)&lt;/li&gt;
&lt;li&gt;결함의 &lt;strong&gt;정확한 위치와 개수&lt;/strong&gt; 파악&lt;/li&gt;
&lt;li&gt;S/A/B/F 4단계 등급 판정의 근거 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;당연히 YOLO로 시작했다. 그런데 AIHub의 &amp;quot;폐의류 재활용 분류 및 선별 데이터&amp;quot;를 분석하다가 흥미로운 걸 발견했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AIHub는 결함 탐지에 YOLO가 아닌 RT-DETR을 사용하고 있었다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;왜일까? 이 의문에서 시작된 탐구가 꽤 깊어졌다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;번호판 vs 의류 결함: 문제의 본질이 다르다&lt;/h2&gt;
&lt;p&gt;두 프로젝트의 탐지 대상을 비교해보면 차이가 명확하다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;특성&lt;/th&gt;
&lt;th&gt;번호판&lt;/th&gt;
&lt;th&gt;의류 결함&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;형태&lt;/td&gt;
&lt;td&gt;직사각형 고정&lt;/td&gt;
&lt;td&gt;불규칙 (얼룩, 찢어짐)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;크기&lt;/td&gt;
&lt;td&gt;일정함&lt;/td&gt;
&lt;td&gt;천차만별 (1cm 얼룩 ~ 20cm 손상)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;위치&lt;/td&gt;
&lt;td&gt;예측 가능&lt;/td&gt;
&lt;td&gt;어디든 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;겹침&lt;/td&gt;
&lt;td&gt;거의 없음&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;자주 겹침&lt;/strong&gt; (오염 위에 손상)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;개수&lt;/td&gt;
&lt;td&gt;1~2개&lt;/td&gt;
&lt;td&gt;여러 개 밀집 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;특히 &lt;strong&gt;&amp;quot;겹침&amp;quot;&lt;/strong&gt; 부분이 핵심이었다. 실제 의류 결함은 이런 식으로 나타난다:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────┐
│      ┌─────┐            │
│      │오염 │            │
│      │ ┌───┴──┐         │  ← 얼룩 위에 찢어진 부분
│      └─┤ 손상 │         │
│        └─────┘          │
└─────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 상황에서 YOLO와 RT-DETR의 동작이 완전히 달라진다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;YOLO의 동작 원리: CNN + Anchor + NMS&lt;/h2&gt;
&lt;p&gt;YOLO가 어떻게 객체를 탐지하는지 이해하면, 왜 겹친 객체에서 문제가 생기는지 알 수 있다.&lt;/p&gt;
&lt;h3&gt;1단계: CNN으로 특징 추출&lt;/h3&gt;
&lt;p&gt;CNN(Convolutional Neural Network)은 작은 필터가 이미지 위를 슬라이딩하면서 특징을 추출한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;원본 이미지
    ↓
[3×3 필터가 슬라이딩]
    ↓
Layer 1: edges, corners (저수준)
    ↓
Layer 2: textures, patterns (중수준)
    ↓
Layer 3+: objects (고수준)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;CNN의 강점은 &lt;strong&gt;지역적 패턴&lt;/strong&gt;을 잘 잡는다는 것이다. 하지만 이미지 전체의 맥락을 파악하려면 레이어를 많이 쌓아야 한다.&lt;/p&gt;
&lt;h3&gt;2단계: Anchor로 위치 제안&lt;/h3&gt;
&lt;p&gt;YOLO는 이미지를 그리드로 나누고, 각 셀마다 여러 개의 &lt;strong&gt;Anchor(기준 박스)&lt;/strong&gt;를 배치한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;이미지를 13×13 그리드로 분할
    ↓
각 셀에 9개 앵커 배치
    ↓
총 1,521개 박스에서 객체 여부 예측&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;각 앵커에 대해 예측하는 것:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;이 앵커에 객체가 있나? (objectness)&lt;/li&gt;
&lt;li&gt;앵커를 얼마나 조정해야 하나? (Δx, Δy, Δw, Δh)&lt;/li&gt;
&lt;li&gt;무슨 객체인가? (class)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;문제는 &lt;strong&gt;하나의 객체에 여러 앵커가 반응&lt;/strong&gt;한다는 것이다.&lt;/p&gt;
&lt;h3&gt;3단계: NMS로 중복 제거&lt;/h3&gt;
&lt;p&gt;NMS(Non-Maximum Suppression)는 중복된 박스를 제거한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def nms(boxes, scores, iou_threshold=0.5):
    # 1. 신뢰도 순으로 정렬
    # 2. 가장 높은 신뢰도 박스 선택
    # 3. 나머지와 IoU(겹침 정도) 계산
    # 4. IoU가 임계값 이상이면 제거 (중복으로 간주)
    # 5. 반복&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 &lt;strong&gt;IoU(Intersection over Union)&lt;/strong&gt;는 두 박스가 얼마나 겹치는지를 나타낸다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;IoU = 교집합 면적 / 합집합 면적&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;NMS의 치명적 문제: 겹친 객체 제거&lt;/h3&gt;
&lt;p&gt;여기서 문제가 발생한다. &lt;strong&gt;실제로 겹쳐 있는 서로 다른 객체&lt;/strong&gt;도 NMS가 하나를 제거해버린다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;두 결함이 겹쳐 있는 경우:

YOLO 예측: bbox 2개 (오염, 손상)
    ↓
IoU 계산: 0.6 (60% 겹침)
    ↓
NMS 임계값: 0.5
    ↓
결과: IoU &amp;gt; 임계값이므로 하나 제거!
    ↓
최종 출력: 1개만 남음 (오염 OR 손상)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이게 번호판에서는 문제가 안 된다. 번호판이 겹칠 일이 없으니까. 하지만 의류 결함에서는 &lt;strong&gt;치명적&lt;/strong&gt;이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;RT-DETR의 동작 원리: Transformer + Query&lt;/h2&gt;
&lt;p&gt;RT-DETR은 완전히 다른 방식으로 접근한다.&lt;/p&gt;
&lt;h3&gt;Transformer: 전체를 한 번에 본다&lt;/h3&gt;
&lt;p&gt;Transformer는 원래 자연어 처리(NLP)를 위해 만들어졌다. 핵심은 &lt;strong&gt;Self-Attention&lt;/strong&gt; 메커니즘이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CNN: 필터가 지역적으로 슬라이딩 → 전체 맥락 파악 어려움
Transformer: 모든 위치가 다른 모든 위치와 관계 계산 → 전역적 이해&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이미지에 적용하면:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;이미지를 패치로 분할
┌────┬────┬────┬────┐
│ P1 │ P2 │ P3 │ P4 │
├────┼────┼────┼────┤
│ P5 │ P6 │ P7 │ P8 │     → Self-Attention으로
├────┼────┼────┼────┤        모든 패치 간 관계 학습
│ P9 │P10 │P11 │P12 │
├────┼────┼────┼────┤
│P13 │P14 │P15 │P16 │
└────┴────┴────┴────┘&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;Query: Anchor 대신 &amp;quot;질문&amp;quot;&lt;/h3&gt;
&lt;p&gt;RT-DETR의 혁신은 &lt;strong&gt;Object Query&lt;/strong&gt;다. Anchor 대신 &lt;strong&gt;학습 가능한 Query 벡터&lt;/strong&gt;를 사용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;YOLO (Anchor 기반):
&amp;quot;이 위치에 이 크기의 박스가 있나?&amp;quot; × 1,521번

RT-DETR (Query 기반):
&amp;quot;이미지에서 객체 100개 찾아줘&amp;quot;
각 Query가 하나의 객체에 매칭&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;Hungarian Matching: 1:1 매칭&lt;/h3&gt;
&lt;p&gt;학습 시 Query와 실제 객체를 &lt;strong&gt;1:1로 매칭&lt;/strong&gt;한다. 이게 핵심이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;예측 (Queries)          Ground Truth
  ┌─────────┐             ┌─────────┐
  │ Query 1 │─────────────│  오염   │
  │ Query 2 │─────────────│  손상   │
  │ Query 3 │─────────────│ (없음)  │
  │   ...   │             │         │
  └─────────┘             └─────────┘

각 Query가 최대 1개 객체만 담당
→ 중복 예측 자체가 발생하지 않음
→ NMS 불필요!&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;겹친 객체도 분리&lt;/h3&gt;
&lt;p&gt;앞서 문제가 됐던 &amp;quot;겹친 결함&amp;quot; 상황을 다시 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;오염과 손상이 겹쳐 있는 경우:

RT-DETR:
  Query 1 → &amp;quot;나는 오염 담당&amp;quot; → 오염 bbox
  Query 2 → &amp;quot;나는 손상 담당&amp;quot; → 손상 bbox

  각 Query가 독립적으로 예측
  NMS 없음
  → 둘 다 정확히 탐지! ✅&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;해상도와 메모리: 현실적인 고려&lt;/h2&gt;
&lt;p&gt;&amp;quot;그럼 RT-DETR이 무조건 좋은 거 아냐?&amp;quot;&lt;/p&gt;
&lt;p&gt;아니다. &lt;strong&gt;인프라 요구사항&lt;/strong&gt;이 다르다.&lt;/p&gt;
&lt;h3&gt;Self-Attention의 계산 복잡도: O(n²)&lt;/h3&gt;
&lt;p&gt;Transformer의 Attention 연산은 입력 길이의 &lt;strong&gt;제곱&lt;/strong&gt;에 비례한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;입력 해상도별 연산량:

640px  → Feature map 20×20 = 400 토큰
         Attention: 400² = 160,000 연산

1920px → Feature map 60×60 = 3,600 토큰
         Attention: 3,600² = 12,960,000 연산
         (81배 증가!)&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;메모리 사용량 비교&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;입력 크기&lt;/th&gt;
&lt;th&gt;YOLO&lt;/th&gt;
&lt;th&gt;RT-DETR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;640px&lt;/td&gt;
&lt;td&gt;~2GB&lt;/td&gt;
&lt;td&gt;~4GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1920px&lt;/td&gt;
&lt;td&gt;~6GB&lt;/td&gt;
&lt;td&gt;~12GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3000×4000&lt;/td&gt;
&lt;td&gt;~15GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~80-100GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;우리 프로젝트의 원본 이미지(3000×4000)를 RT-DETR에 그대로 넣으면 A100 80GB로도 빠듯하다.&lt;/p&gt;
&lt;h3&gt;현실적인 해결책: SAHI 타일링&lt;/h3&gt;
&lt;p&gt;원본을 그대로 쓰는 대신, &lt;strong&gt;타일로 분할&lt;/strong&gt;해서 처리한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;3000×4000 원본을 1500×2000 타일 6개로 분할
각 타일 → 1920×1920 resize
메모리: ~12GB (타일당)

┌────────┬────────┐
│ Tile 1 │ Tile 2 │
├────────┼────────┤
│ Tile 3 │ Tile 4 │
├────────┼────────┤
│ Tile 5 │ Tile 6 │
└────────┴────────┘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;SAHI(Slicing Aided Hyper Inference)&lt;/strong&gt; 라이브러리가 이를 자동화해준다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sahi import AutoDetectionModel
from sahi.predict import get_sliced_prediction

model = AutoDetectionModel.from_pretrained(
    model_type=&amp;quot;rtdetr&amp;quot;,
    model_path=&amp;quot;rtdetr-defect.pt&amp;quot;
)

result = get_sliced_prediction(
    image=&amp;quot;clothing_3000x4000.jpg&amp;quot;,
    detection_model=model,
    slice_height=1920,
    slice_width=1920,
    overlap_height_ratio=0.2,
    overlap_width_ratio=0.2
)&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;최종 비교: 언제 뭘 써야 하나&lt;/h2&gt;
&lt;h3&gt;정리표&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;YOLO&lt;/th&gt;
&lt;th&gt;RT-DETR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;아키텍처&lt;/td&gt;
&lt;td&gt;CNN + Anchor&lt;/td&gt;
&lt;td&gt;Transformer + Query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;후처리&lt;/td&gt;
&lt;td&gt;NMS 필수&lt;/td&gt;
&lt;td&gt;NMS 불필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End-to-End&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;겹친 객체&lt;/td&gt;
&lt;td&gt;일부 제거 위험&lt;/td&gt;
&lt;td&gt;✅ 잘 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;밀집 객체&lt;/td&gt;
&lt;td&gt;일부 누락 가능&lt;/td&gt;
&lt;td&gt;✅ 각 Query가 담당&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;추론 속도&lt;/td&gt;
&lt;td&gt;빠름&lt;/td&gt;
&lt;td&gt;상대적으로 느림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU 메모리&lt;/td&gt;
&lt;td&gt;적음&lt;/td&gt;
&lt;td&gt;많음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;선택 가이드&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────┐
│                                                         │
│  &amp;quot;형태가 고정되고, 겹치지 않는 객체&amp;quot;                     │
│       └──▶ YOLO                                        │
│       예: 번호판, 바코드, 로고                          │
│                                                         │
│  &amp;quot;불규칙하고, 겹치거나 밀집된 객체&amp;quot;                      │
│       └──▶ RT-DETR                                     │
│       예: 의류 결함, 군중, 의료 이미지                   │
│                                                         │
│  &amp;quot;실시간 처리가 최우선&amp;quot;                                 │
│       └──▶ YOLO                                        │
│                                                         │
│  &amp;quot;정확도가 최우선, 10 FPS면 충분&amp;quot;                       │
│       └──▶ RT-DETR                                     │
│                                                         │
└─────────────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;우리 프로젝트의 최종 설계&lt;/h2&gt;
&lt;p&gt;결국 우리 팀은 &lt;strong&gt;하이브리드 파이프라인&lt;/strong&gt;을 채택했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────┐
│              의류 검수 AI 파이프라인              │
├─────────────────────────────────────────────────┤
│                                                 │
│  1단계: RT-DETR (SAHI 타일링)                   │
│         → 결함 위치/종류 탐지                   │
│         → ~150ms (6타일 기준)                   │
│                                                 │
│  2단계: Qwen2-VL (필요시)                       │
│         → 오탐 필터링 + 상세 설명 생성          │
│                                                 │
│  3단계: 규칙 엔진                               │
│         → 메타데이터 + AI 결과 → S/A/B/F 등급   │
│                                                 │
└─────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;AIHub 모델 분석에서 RT-DETR 테스트 결과, 의류 장식을 결함으로 오인하는 경우가 있었다. 그래서 VLM(Vision Language Model)을 2차 검증으로 넣어 오탐을 필터링하기로 했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;결론: 도구는 문제에 맞게&lt;/h2&gt;
&lt;p&gt;&amp;quot;Object Detection = YOLO&amp;quot;라는 공식은 &lt;strong&gt;많은 경우에 맞지만, 전부는 아니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;번호판처럼 형태가 고정되고 겹치지 않는 문제에는 YOLO가 최적이다. 하지만 의류 결함처럼 불규칙하고 겹치는 문제에는 RT-DETR이 더 나은 선택일 수 있다.&lt;/p&gt;
&lt;p&gt;결국 중요한 건 &lt;strong&gt;&amp;quot;이 문제의 본질이 뭔가?&amp;quot;&lt;/strong&gt;를 먼저 파악하는 것이다. 모델은 그 다음이다.&lt;/p&gt;</description>
      <category>AI &amp;middot; ML/Computer Vision</category>
      <category>AI</category>
      <category>CNN</category>
      <category>Object Detection</category>
      <category>rt-detr</category>
      <category>transformer</category>
      <category>YOLO</category>
      <category>객체탐지</category>
      <category>딥러닝</category>
      <category>모델비교</category>
      <category>컴퓨터비전</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/143</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/YOLO%EB%A7%8C-%EC%93%B0%EB%8D%98-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-RT-DETR%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0#entry143comment</comments>
      <pubDate>Wed, 21 Jan 2026 14:40:17 +0900</pubDate>
    </item>
    <item>
      <title>YOLO26: 엣지 디바이스를 위한 차세대 객체 탐지 모델</title>
      <link>https://white-mouse-dev.tistory.com/entry/YOLO26-%EC%97%A3%EC%A7%80-%EB%94%94%EB%B0%94%EC%9D%B4%EC%8A%A4%EB%A5%BC-%EC%9C%84%ED%95%9C-%EC%B0%A8%EC%84%B8%EB%8C%80-%EA%B0%9D%EC%B2%B4-%ED%83%90%EC%A7%80-%EB%AA%A8%EB%8D%B8</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/9b938ff1-1f53-43e1-849c-1106954d3c78/image.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;목차&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#1-%EB%93%A4%EC%96%B4%EA%B0%80%EB%A9%B0&quot;&gt;들어가며&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#11-yolo26%EC%9D%84-%EA%B2%80%ED%86%A0%ED%95%98%EA%B2%8C-%EB%90%9C-%EA%B3%84%EA%B8%B0&quot;&gt;YOLO26을 검토하게 된 계기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#12-%EC%99%9C-yolo26%EC%9D%B8%EA%B0%80&quot;&gt;왜 YOLO26인가?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-yolo26%EC%9D%B4%EB%9E%80&quot;&gt;YOLO26이란?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-%ED%95%B5%EC%8B%AC-%EA%B0%9C%EC%84%A0%EC%82%AC%ED%95%AD&quot;&gt;핵심 개선사항&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4-%EC%A7%80%EC%9B%90-%ED%83%9C%EC%8A%A4%ED%81%AC&quot;&gt;지원 태스크&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#5-%EC%84%B1%EB%8A%A5-%EB%B2%A4%EC%B9%98%EB%A7%88%ED%81%AC&quot;&gt;성능 벤치마크&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#6-%EC%82%AC%EC%9A%A9-%EB%B0%A9%EB%B2%95&quot;&gt;사용 방법&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#7-yoloe-26-%EA%B0%9C%EB%B0%A9%ED%98%95-%EC%96%B4%ED%9C%98-%EC%A7%80%EC%9B%90&quot;&gt;YOLOE-26: 개방형 어휘 지원&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#8-%EC%9D%B4%EC%A0%84-%EB%B2%84%EC%A0%84%EA%B3%BC%EC%9D%98-%EB%B9%84%EA%B5%90&quot;&gt;이전 버전과의 비교&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#9-%EC%8B%A4%EB%AC%B4-%EC%A0%81%EC%9A%A9-%EA%B0%80%EC%9D%B4%EB%93%9C&quot;&gt;실무 적용 가이드&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#91-%EC%8B%A4%EC%A0%9C-%EB%8F%84%EC%9E%85-%EC%82%AC%EB%A1%80-%EC%A4%91%EA%B3%A0-%EC%9D%98%EB%A5%98-%EC%9E%90%EB%8F%99-%EA%B2%80%EC%88%98-%EC%8B%9C%EC%8A%A4%ED%85%9C&quot;&gt;실제 도입 사례: 중고 의류 자동 검수 시스템&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#10-%EB%A7%88%EC%B9%98%EB%A9%B0&quot;&gt;마치며&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;1. 들어가며&lt;/h2&gt;
&lt;h3&gt;1.1 YOLO26을 검토하게 된 계기&lt;/h3&gt;
&lt;p&gt;최근 &lt;strong&gt;중고 의류 자동 검수 AI 시스템&lt;/strong&gt;을 설계하면서 객체 탐지 모델을 검토하던 중이었습니다. 반품된 중고 의류의 결함(오염, 손상, 변색 등)을 자동으로 탐지하고, S/A/B/F 등급을 판정하는 시스템인데요. 처리 속도 목표가 &lt;strong&gt;의류 1벌당 1초 이내&lt;/strong&gt;였고, 향후 물류센터 현장의 엣지 디바이스 배포도 고려해야 했습니다.&lt;/p&gt;
&lt;p&gt;마침 2025년 1월 14일, Ultralytics에서 YOLO 시리즈의 최신 버전인 &lt;strong&gt;YOLO26&lt;/strong&gt;을 공식 출시했다는 소식을 접했습니다. 릴리스 노트를 보자마자 눈에 들어온 키워드들이 있었습니다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;End-to-End NMS-Free&lt;/strong&gt;: 후처리 파이프라인 단순화&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CPU에서 최대 43% 빠른 추론&lt;/strong&gt;: 엣지 환경에 적합&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DFL 제거&lt;/strong&gt;: 내보내기 간소화, 하드웨어 호환성 향상&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;기존에 YOLO11을 검토하고 있었는데, YOLO26의 이런 특징들이 제가 설계 중인 시스템의 요구사항과 정확히 맞아떨어졌습니다.&lt;/p&gt;
&lt;h3&gt;1.2 왜 YOLO26인가?&lt;/h3&gt;
&lt;p&gt;제 프로젝트 기준으로 YOLO26을 선택한 이유를 정리하면:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;요구사항&lt;/th&gt;
&lt;th&gt;YOLO26 해결책&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1초 이내 3장 처리&lt;/td&gt;
&lt;td&gt;CPU 추론 43% 개선 → 3장 ~117ms (39ms × 3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;배포 파이프라인 간소화&lt;/td&gt;
&lt;td&gt;NMS-Free End-to-End → 후처리 코드 제거&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;엣지 디바이스 대응&lt;/td&gt;
&lt;td&gt;DFL 제거 → TensorRT, ONNX 내보내기 용이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;작은 결함 탐지&lt;/td&gt;
&lt;td&gt;ProgLoss + STAL → 소형 객체 정확도 향상&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;이 글에서는 YOLO26의 핵심 특징, 성능 지표, 실무 적용 방법까지 상세히 다뤄보겠습니다. 저처럼 실시간 객체 탐지가 필요한 프로젝트를 진행 중이시라면 도움이 되실 겁니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. YOLO26이란?&lt;/h2&gt;
&lt;p&gt;YOLO26은 실시간 객체 탐지기 YOLO 시리즈의 최신 진화 버전으로, 세 가지 핵심 원칙에 따라 설계되었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────────────┐
│                         YOLO26 설계 철학                                 │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   ┌─────────────────┐                                                   │
│   │    단순성       │  NMS 없이 직접 예측 생성 (End-to-End)             │
│   │  (Simplicity)   │  → 추론 파이프라인 단순화                         │
│   └────────┬────────┘                                                   │
│            │                                                            │
│            ▼                                                            │
│   ┌─────────────────┐                                                   │
│   │  배포 효율성     │  후처리 단계 제거로 통합 간소화                   │
│   │ (Deployability) │  → 다양한 환경에서 안정적 배포                    │
│   └────────┬────────┘                                                   │
│            │                                                            │
│            ▼                                                            │
│   ┌─────────────────┐                                                   │
│   │   훈련 혁신     │  MuSGD Optimizer 도입                             │
│   │  (Innovation)   │  → 안정적 훈련 + 빠른 수렴                        │
│   └─────────────────┘                                                   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;2.1 핵심 목표&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;목표&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;엣지 최적화&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;저전력 디바이스에서 효율적 동작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;배포 간소화&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;NMS 제거로 프로덕션 통합 용이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;성능 향상&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CPU에서 최대 43% 빠른 추론&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;3. 핵심 개선사항&lt;/h2&gt;
&lt;h3&gt;3.1 DFL(Distribution Focal Loss) 제거&lt;/h3&gt;
&lt;p&gt;기존 YOLO 모델의 DFL 모듈은 효과적이지만, 내보내기가 복잡하고 하드웨어 호환성이 제한적이었습니다. YOLO26은 DFL을 완전히 제거하여 추론을 간소화했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;기존 YOLO (DFL 포함)                    YOLO26 (DFL 제거)
┌─────────────────────┐                ┌─────────────────────┐
│   Backbone          │                │   Backbone          │
│        ↓            │                │        ↓            │
│   Neck (FPN)        │                │   Neck (FPN)        │
│        ↓            │                │        ↓            │
│   Head + DFL        │  ─────────►    │   Head (Simple)     │
│        ↓            │   DFL 제거     │        ↓            │
│   NMS 후처리        │                │   직접 출력         │
│        ↓            │                │        ↓            │
│   최종 결과         │                │   최종 결과         │
└─────────────────────┘                └─────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;3.2 End-to-End NMS-Free 추론&lt;/h3&gt;
&lt;p&gt;YOLO26의 가장 큰 특징은 &lt;strong&gt;네이티브 End-to-End 모델&lt;/strong&gt;이라는 점입니다. NMS(Non-Maximum Suppression)를 별도 후처리 단계로 사용하지 않고, 예측이 직접 생성됩니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;기존 YOLO&lt;/th&gt;
&lt;th&gt;YOLO26&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;NMS 필요 여부&lt;/td&gt;
&lt;td&gt;필요&lt;/td&gt;
&lt;td&gt;불필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;후처리 단계&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;배포 복잡도&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;추론 지연시간&lt;/td&gt;
&lt;td&gt;상대적으로 높음&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;3.3 MuSGD Optimizer&lt;/h3&gt;
&lt;p&gt;YOLO26은 &lt;strong&gt;MuSGD&lt;/strong&gt;라는 새로운 하이브리드 Optimizer를 도입했습니다. Moonshot AI의 Kimi K2 LLM 훈련에서 영감을 받아 SGD와 Muon을 결합한 방식입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌───────────────────────────────────────────────────────────────┐
│                      MuSGD Optimizer                          │
├───────────────────────────────────────────────────────────────┤
│                                                               │
│   ┌─────────┐         ┌─────────┐         ┌─────────────┐    │
│   │   SGD   │    +    │  Muon   │    =    │   MuSGD     │    │
│   │ (안정성) │         │ (효율성) │         │ (하이브리드) │    │
│   └─────────┘         └─────────┘         └─────────────┘    │
│                                                               │
│   특징:                                                       │
│   • LLM 훈련 최적화 기법을 CV에 적용                           │
│   • 더 안정적인 훈련                                          │
│   • 더 빠른 수렴                                              │
│                                                               │
└───────────────────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;3.4 태스크별 최적화&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;태스크&lt;/th&gt;
&lt;th&gt;개선 내용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Segmentation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;의미론적 분할 손실 + 다중 스케일 프로토 모듈&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pose&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;잔차 로그-우도 추정(RLE)으로 고정밀 추정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OBB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;각도 손실 함수로 경계 불연속성 문제 해결&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;4. 지원 태스크&lt;/h2&gt;
&lt;p&gt;YOLO26은 5가지 주요 컴퓨터 비전 태스크를 지원합니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;파일명&lt;/th&gt;
&lt;th&gt;태스크&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;추론&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;검증&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;훈련&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;내보내기&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;YOLO26&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yolo26n/s/m/l/x.pt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;객체 탐지&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26-seg&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yolo26n/s/m/l/x-seg.pt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;인스턴스 분할&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26-pose&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yolo26n/s/m/l/x-pose.pt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;포즈 추정&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26-obb&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yolo26n/s/m/l/x-obb.pt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;방향 감지&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26-cls&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yolo26n/s/m/l/x-cls.pt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;분류&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;5. 성능 벤치마크&lt;/h2&gt;
&lt;h3&gt;5.1 객체 탐지 (COCO Dataset)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;크기&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;mAP 50-95&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;mAP (E2E)&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;CPU ONNX (ms)&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;T4 TensorRT (ms)&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;파라미터 (M)&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;FLOPs (B)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;YOLO26n&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;640&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;40.9&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;40.1&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;strong&gt;38.9&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;1.7&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;2.4&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;5.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26s&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;640&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;48.6&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;47.8&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;87.2&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;2.5&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;9.5&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;20.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26m&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;640&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;53.1&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;52.5&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;220.0&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;4.7&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;20.4&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;68.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26l&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;640&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;55.0&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;54.4&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;286.2&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;6.2&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;24.8&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;86.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26x&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;640&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;strong&gt;57.5&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;56.9&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;525.8&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;11.8&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;55.7&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;193.9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;5.2 인스턴스 분할 (COCO Dataset)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;mAP Box (E2E)&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;mAP Mask (E2E)&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;CPU ONNX (ms)&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;파라미터 (M)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;YOLO26n-seg&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;39.6&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;33.9&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;53.3&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;2.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26s-seg&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;47.3&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;40.0&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;118.4&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;10.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26m-seg&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;52.5&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;44.1&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;328.2&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;23.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26l-seg&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;54.4&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;45.5&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;387.0&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;28.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26x-seg&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;strong&gt;56.5&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;strong&gt;47.0&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;787.0&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;62.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;5.3 포즈 추정 (COCO Dataset)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;mAP Pose (E2E)&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;mAP 50 (E2E)&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;CPU ONNX (ms)&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;파라미터 (M)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;YOLO26n-pose&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;57.2&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;83.3&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;40.3&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;2.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26s-pose&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;63.0&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;86.6&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;85.3&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;10.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26m-pose&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;68.8&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;89.6&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;218.0&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;21.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26l-pose&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;70.4&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;90.5&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;275.4&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;25.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO26x-pose&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;strong&gt;71.6&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;strong&gt;91.6&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;565.4&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;57.6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;6. 사용 방법&lt;/h2&gt;
&lt;h3&gt;6.1 설치&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# ultralytics 패키지 설치/업데이트
pip install ultralytics --upgrade&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.2 Python 코드&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from ultralytics import YOLO

# 1. 사전 훈련된 모델 로드
model = YOLO(&amp;quot;yolo26n.pt&amp;quot;)

# 2. 이미지 추론
results = model(&amp;quot;path/to/image.jpg&amp;quot;)

# 3. 결과 시각화
results[0].show()

# 4. 결과 저장
results[0].save(&amp;quot;output.jpg&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.3 커스텀 데이터셋 훈련&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from ultralytics import YOLO

# 모델 로드
model = YOLO(&amp;quot;yolo26n.pt&amp;quot;)

# 훈련 실행
results = model.train(
    data=&amp;quot;custom_dataset.yaml&amp;quot;,  # 데이터셋 설정 파일
    epochs=100,                   # 훈련 에폭 수
    imgsz=640,                    # 입력 이미지 크기
    batch=16,                     # 배치 크기
    device=0                      # GPU 디바이스 (0, 1, ... 또는 &amp;#39;cpu&amp;#39;)
)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.4 CLI 사용법&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 추론
yolo predict model=yolo26n.pt source=path/to/image.jpg

# 훈련
yolo train model=yolo26n.pt data=coco8.yaml epochs=100 imgsz=640

# 검증
yolo val model=yolo26n.pt data=coco.yaml

# 모델 내보내기 (ONNX)
yolo export model=yolo26n.pt format=onnx&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.5 내보내기 지원 형식&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;형식&lt;/th&gt;
&lt;th&gt;명령어&lt;/th&gt;
&lt;th&gt;용도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;ONNX&lt;/td&gt;
&lt;td&gt;&lt;code&gt;format=onnx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;범용 배포&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TensorRT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;format=engine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NVIDIA GPU 최적화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CoreML&lt;/td&gt;
&lt;td&gt;&lt;code&gt;format=coreml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Apple 디바이스&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TFLite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;format=tflite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;모바일/엣지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenVINO&lt;/td&gt;
&lt;td&gt;&lt;code&gt;format=openvino&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Intel 디바이스&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;7. YOLOE-26: 개방형 어휘 지원&lt;/h2&gt;
&lt;p&gt;YOLO26은 &lt;strong&gt;YOLOE-26&lt;/strong&gt;이라는 개방형 어휘(Open-Vocabulary) 버전도 제공합니다. 텍스트 프롬프트, 시각적 프롬프트, 또는 프롬프트 없이도 객체를 탐지할 수 있습니다.&lt;/p&gt;
&lt;h3&gt;7.1 프롬프팅 방식 비교&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────────────┐
│                    YOLOE-26 프롬프팅 방식                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   ┌─────────────────┐                                                   │
│   │  텍스트 프롬프트 │  &amp;quot;person&amp;quot;, &amp;quot;bus&amp;quot; 등 클래스명 지정                 │
│   └─────────────────┘                                                   │
│                                                                         │
│   ┌─────────────────┐                                                   │
│   │  시각적 프롬프트 │  바운딩 박스로 대상 객체 예시 제공                │
│   └─────────────────┘                                                   │
│                                                                         │
│   ┌─────────────────┐                                                   │
│   │ 프롬프트 없음    │  4,585개 사전 정의 클래스 자동 탐지 (RAM++ 기반)  │
│   └─────────────────┘                                                   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;7.2 텍스트 프롬프트 사용 예시&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from ultralytics import YOLO

# 모델 로드
model = YOLO(&amp;quot;yoloe-26l-seg.pt&amp;quot;)

# 탐지할 클래스 설정 (한 번만 실행)
names = [&amp;quot;person&amp;quot;, &amp;quot;bus&amp;quot;, &amp;quot;car&amp;quot;]
model.set_classes(names, model.get_text_pe(names))

# 추론 실행
results = model.predict(&amp;quot;path/to/image.jpg&amp;quot;)
results[0].show()&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.3 시각적 프롬프트 사용 예시&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from ultralytics import YOLO
from ultralytics.models.yolo.yoloe import YOLOEVPSegPredictor

# 모델 로드
model = YOLO(&amp;quot;yoloe-26l-seg.pt&amp;quot;)

# 시각적 프롬프트 정의 (바운딩 박스 + 클래스 ID)
visual_prompts = dict(
    bboxes=np.array([
        [221.52, 405.8, 344.98, 857.54],  # 사람 영역
        [120, 425, 160, 445],              # 안경 영역
    ]),
    cls=np.array([0, 1])  # 각각의 클래스 ID
)

# 추론 실행
results = model.predict(
    &amp;quot;path/to/image.jpg&amp;quot;,
    visual_prompts=visual_prompts,
    predictor=YOLOEVPSegPredictor,
)
results[0].show()&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;8. 이전 버전과의 비교&lt;/h2&gt;
&lt;h3&gt;8.1 YOLO26 vs YOLO11 핵심 차이점&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;YOLO11&lt;/th&gt;
&lt;th&gt;YOLO26&lt;/th&gt;
&lt;th&gt;개선 효과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NMS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;필요&lt;/td&gt;
&lt;td&gt;불필요 (End-to-End)&lt;/td&gt;
&lt;td&gt;배포 간소화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DFL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;포함&lt;/td&gt;
&lt;td&gt;제거&lt;/td&gt;
&lt;td&gt;엣지 호환성 향상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Optimizer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SGD/Adam&lt;/td&gt;
&lt;td&gt;MuSGD&lt;/td&gt;
&lt;td&gt;훈련 안정성/수렴 속도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU 추론&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;기준&lt;/td&gt;
&lt;td&gt;최대 43% 빠름&lt;/td&gt;
&lt;td&gt;엣지 디바이스 적합&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;8.2 성능 비교 (Detection, COCO val)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;mAP 50-95&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;CPU (ms)&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;파라미터 (M)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;YOLO11n&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;39.5&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;56.1&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;2.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;YOLO26n&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;strong&gt;40.9&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;strong&gt;38.9&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;2.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YOLO11s&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;47.0&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;90.0&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;9.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;YOLO26s&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;strong&gt;48.6&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;strong&gt;87.2&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;9.5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;주목할 점:&lt;/strong&gt; YOLO26n은 YOLO11n 대비 mAP 1.4 향상, CPU 추론 속도 31% 개선&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;9. 실무 적용 가이드&lt;/h2&gt;
&lt;h3&gt;9.1 실제 도입 사례: 중고 의류 자동 검수 시스템&lt;/h3&gt;
&lt;p&gt;제가 현재 설계 중인 &lt;strong&gt;중고 의류 자동 검수 AI 시스템&lt;/strong&gt;에서 YOLO26n을 어떻게 활용하려는지 공유합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────────────┐
│                   중고 의류 자동 검수 시스템 아키텍처                      │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   ┌─────────┐   ┌─────────┐   ┌─────────┐                              │
│   │ 카메라1  │   │ 카메라2  │   │ 카메라3  │    ← 다각도 촬영 (3대)       │
│   │  전면   │   │  후면   │   │  측면   │                              │
│   └────┬────┘   └────┬────┘   └────┬────┘                              │
│        │             │             │                                    │
│        └─────────────┼─────────────┘                                    │
│                      ▼                                                  │
│              ┌─────────────┐                                            │
│              │  YOLO26n    │    ← 1차 탐지: 의류/결함 검출 (~117ms)      │
│              │  (E2E)      │       NMS 불필요, 직접 결과 출력            │
│              └──────┬──────┘                                            │
│                     ▼                                                   │
│              ┌─────────────┐                                            │
│              │  규칙 엔진   │    ← 2차 판정: S/F 확정, A/B 후보 선별     │
│              └──────┬──────┘                                            │
│                     ▼                                                   │
│              ┌─────────────┐                                            │
│              │  Qwen2-VL   │    ← 3차 분석: 경계 케이스만 VLM 검토       │
│              └──────┬──────┘                                            │
│                     ▼                                                   │
│              ┌─────────────┐                                            │
│              │ 최종 등급    │    → S / A / B / F                         │
│              └─────────────┘                                            │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;YOLO26n 선택 이유&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;기존 YOLO11n&lt;/th&gt;
&lt;th&gt;YOLO26n&lt;/th&gt;
&lt;th&gt;프로젝트 이점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;NMS 후처리 필요&lt;/td&gt;
&lt;td&gt;NMS-Free (E2E)&lt;/td&gt;
&lt;td&gt;파이프라인 코드 간소화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU 56.1ms&lt;/td&gt;
&lt;td&gt;CPU 38.9ms&lt;/td&gt;
&lt;td&gt;3장 처리 시 51ms 절약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DFL 포함&lt;/td&gt;
&lt;td&gt;DFL 제거&lt;/td&gt;
&lt;td&gt;TensorRT 내보내기 용이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mAP 39.5&lt;/td&gt;
&lt;td&gt;mAP 40.9&lt;/td&gt;
&lt;td&gt;작은 결함 탐지율 향상&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h4&gt;예상 처리 시간&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;┌────────────────────────────────────────────────────────────┐
│                    처리 시간 분석                           │
├────────────────────────────────────────────────────────────┤
│                                                            │
│   이미지 전처리        : ~15ms                             │
│   YOLO26n 탐지 (3장)   : ~117ms (39ms × 3)                │
│   규칙 기반 판정       : ~5ms                              │
│   ─────────────────────────────────────                    │
│   S/F 확정 케이스 합계 : ~137ms ✓ (목표 1초 이내 달성)     │
│                                                            │
│   + VLM 호출 시 (A/B 경계): ~400ms 추가                    │
│   VLM 호출 케이스 합계    : ~537ms ✓                       │
│                                                            │
└────────────────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;탐지 대상 클래스 설계&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 의류 검수용 커스텀 클래스 정의
CLOTHING_CLASSES = [
    # 의류 카테고리 (8종)
    &amp;quot;top&amp;quot;, &amp;quot;bottom&amp;quot;, &amp;quot;dress&amp;quot;, &amp;quot;outerwear&amp;quot;, 
    &amp;quot;shoes&amp;quot;, &amp;quot;bag&amp;quot;, &amp;quot;accessory&amp;quot;, &amp;quot;other&amp;quot;,

    # 결함 유형 (9종)
    &amp;quot;stain&amp;quot;, &amp;quot;tear&amp;quot;, &amp;quot;hole&amp;quot;, &amp;quot;discoloration&amp;quot;,
    &amp;quot;pilling&amp;quot;, &amp;quot;fading&amp;quot;, &amp;quot;scratch&amp;quot;, &amp;quot;deformation&amp;quot;, &amp;quot;other_defect&amp;quot;
]

# YOLO26n 기반 커스텀 훈련
from ultralytics import YOLO

model = YOLO(&amp;quot;yolo26n.pt&amp;quot;)
model.train(
    data=&amp;quot;clothing_defect.yaml&amp;quot;,
    epochs=100,
    imgsz=640,
    batch=16,
    # Recall 우선 (결함 누락 방지)
    conf=0.3  # 결함 탐지 시 낮은 threshold
)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;9.2 모델 선택 가이드&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;사용 환경&lt;/th&gt;
&lt;th&gt;권장 모델&lt;/th&gt;
&lt;th&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;라즈베리파이/엣지&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;YOLO26n&lt;/td&gt;
&lt;td&gt;최소 파라미터, 빠른 CPU 추론&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;모바일 앱&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;YOLO26n/s&lt;/td&gt;
&lt;td&gt;경량 + 적절한 정확도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;실시간 서버&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;YOLO26s/m&lt;/td&gt;
&lt;td&gt;속도-정확도 균형&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;고정밀 분석&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;YOLO26l/x&lt;/td&gt;
&lt;td&gt;최고 정확도&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;9.2 프로젝트별 태스크 선택&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────────────┐
│                         태스크 선택 가이드                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   [객체 탐지]                                                           │
│   └── 사용 케이스: 재고 관리, 불량품 탐지, 자율주행                     │
│   └── 모델: yolo26n.pt ~ yolo26x.pt                                    │
│                                                                         │
│   [인스턴스 분할]                                                       │
│   └── 사용 케이스: 의료 영상, 자율주행 세그멘테이션                     │
│   └── 모델: yolo26n-seg.pt ~ yolo26x-seg.pt                            │
│                                                                         │
│   [포즈 추정]                                                           │
│   └── 사용 케이스: 피트니스 앱, 동작 분석, AR/VR                        │
│   └── 모델: yolo26n-pose.pt ~ yolo26x-pose.pt                          │
│                                                                         │
│   [방향 감지 (OBB)]                                                     │
│   └── 사용 케이스: 항공 영상, 위성 이미지, 문서 탐지                    │
│   └── 모델: yolo26n-obb.pt ~ yolo26x-obb.pt                            │
│                                                                         │
│   [분류]                                                                │
│   └── 사용 케이스: 품질 분류, 제품 카테고리화                           │
│   └── 모델: yolo26n-cls.pt ~ yolo26x-cls.pt                            │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;9.3 배포 환경별 내보내기 전략&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;타겟 환경&lt;/th&gt;
&lt;th&gt;내보내기 형식&lt;/th&gt;
&lt;th&gt;명령어&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;NVIDIA Jetson&lt;/td&gt;
&lt;td&gt;TensorRT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yolo export model=yolo26n.pt format=engine&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apple M1/M2&lt;/td&gt;
&lt;td&gt;CoreML&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yolo export model=yolo26n.pt format=coreml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android/iOS&lt;/td&gt;
&lt;td&gt;TFLite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yolo export model=yolo26n.pt format=tflite&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intel NUC&lt;/td&gt;
&lt;td&gt;OpenVINO&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yolo export model=yolo26n.pt format=openvino&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;웹 브라우저&lt;/td&gt;
&lt;td&gt;ONNX + ONNX.js&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yolo export model=yolo26n.pt format=onnx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;10. 마치며&lt;/h2&gt;
&lt;h3&gt;10.1 YOLO26의 의의&lt;/h3&gt;
&lt;p&gt;YOLO26은 단순한 버전 업데이트가 아닌, &lt;strong&gt;엣지 AI 시대를 위한 패러다임 전환&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;혁신 포인트&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;End-to-End 설계&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;복잡한 후처리 파이프라인 제거&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MuSGD Optimizer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;LLM 훈련 기법의 CV 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU 최적화&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;저전력 디바이스 대중화 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;10.2 중고 의류 검수 프로젝트를 통해 느낀 점&lt;/h3&gt;
&lt;p&gt;실제로 YOLO26을 프로젝트에 적용하려고 검토하면서 느낀 점들입니다:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;좋았던 점:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;NMS 제거로 추론 코드가 깔끔해짐 (후처리 함수 불필요)&lt;/li&gt;
&lt;li&gt;CPU 성능 향상이 체감됨 (3장 처리 시 50ms+ 절약)&lt;/li&gt;
&lt;li&gt;Ultralytics의 통일된 API로 기존 코드 마이그레이션 용이&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;고려할 점:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;2025년 1월 출시라 아직 커뮤니티 레퍼런스가 적음&lt;/li&gt;
&lt;li&gt;커스텀 데이터셋 Fine-tuning 시 MuSGD 하이퍼파라미터 튜닝 필요&lt;/li&gt;
&lt;li&gt;YOLOE-26 (Open Vocabulary)은 아직 실험적 단계&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;결론:&lt;/strong&gt;&lt;br&gt;엣지 배포가 목표이거나, 실시간 처리가 중요한 프로젝트라면 &lt;strong&gt;YOLO26은 현재 최선의 선택지&lt;/strong&gt; 중 하나입니다. 특히 NMS-Free 설계는 프로덕션 파이프라인을 크게 단순화해줍니다.&lt;/p&gt;
&lt;h3&gt;10.3 활용 추천&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;스타트업/PoC&lt;/strong&gt;: YOLO26n으로 빠른 프로토타이핑&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;프로덕션&lt;/strong&gt;: YOLO26s/m으로 속도-정확도 균형&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;연구/고정밀&lt;/strong&gt;: YOLO26x로 SOTA 성능 달성&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;오픈 월드&lt;/strong&gt;: YOLOE-26으로 새로운 클래스 대응&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;10.3 참고 자료&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;공식 문서&lt;/strong&gt;: &lt;a href=&quot;https://docs.ultralytics.com/models/yolo26/&quot;&gt;https://docs.ultralytics.com/models/yolo26/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href=&quot;https://github.com/ultralytics/ultralytics&quot;&gt;https://github.com/ultralytics/ultralytics&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>AI &amp;middot; ML/Computer Vision</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/142</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/YOLO26-%EC%97%A3%EC%A7%80-%EB%94%94%EB%B0%94%EC%9D%B4%EC%8A%A4%EB%A5%BC-%EC%9C%84%ED%95%9C-%EC%B0%A8%EC%84%B8%EB%8C%80-%EA%B0%9D%EC%B2%B4-%ED%83%90%EC%A7%80-%EB%AA%A8%EB%8D%B8#entry142comment</comments>
      <pubDate>Mon, 19 Jan 2026 16:06:42 +0900</pubDate>
    </item>
    <item>
      <title>Vercel KV로 배너 클릭 추적 시스템 만들기: Redis를 서버리스에서 사용하는 법</title>
      <link>https://white-mouse-dev.tistory.com/entry/Vercel-KV%EB%A1%9C-%EB%B0%B0%EB%84%88-%ED%81%B4%EB%A6%AD-%EC%B6%94%EC%A0%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-Redis%EB%A5%BC-%EC%84%9C%EB%B2%84%EB%A6%AC%EC%8A%A4%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B2%95</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/111b78d3-2ccb-49d7-8c2b-e286b498827e/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;블로그에 광고 배너를 달면서 이런 요구사항이 생겼습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;요구사항:
- 좌측/우측 배너 각각 클릭 수 추적
- 일별, 시간대별 통계 확인
- 관리자 대시보드에서 시각화
- 서버리스 환경 (Next.js on Vercel)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;처음엔 간단할 줄 알았습니다. &amp;quot;그냥 데이터베이스에 저장하면 되지 않나?&amp;quot; 하고요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// ❌ 순진한 첫 시도
async function trackClick(bannerId: string) {
  await prisma.bannerClick.create({
    data: { bannerId, timestamp: new Date() }
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;문제:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;매 클릭마다 DB 쓰기 → 비용 증가&lt;/li&gt;
&lt;li&gt;통계 조회 시 전체 레코드 스캔 → 느림&lt;/li&gt;
&lt;li&gt;시간대별 집계를 매번 계산 → 비효율&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그래서 선택한 것이 &lt;strong&gt;Vercel KV (Upstash Redis)&lt;/strong&gt;입니다. 오늘은 이 시스템을 어떻게 설계하고 구현했는지 공유하겠습니다.&lt;/p&gt;
&lt;h2&gt;Redis? Vercel KV? Upstash? 뭐가 다른가요?&lt;/h2&gt;
&lt;p&gt;먼저 용어부터 정리하겠습니다.&lt;/p&gt;
&lt;h3&gt;Redis (일반)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────┐
│  전통적인 Redis 서버             │
│                                 │
│  ┌─────────────┐                │
│  │   Memory    │  ← 빠른 읽기   │
│  └─────────────┘                │
│                                 │
│  ⚠️ 서버 재시작 시 데이터 손실   │
│  ⚠️ TCP 연결 필요                │
│  ⚠️ 서버 관리 필요                │
└─────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;Upstash Redis&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────┐
│  서버리스 Redis                  │
│                                 │
│  ┌─────────┐    ┌──────────┐   │
│  │ Memory  │ ─► │   Disk   │   │
│  │ (빠름)  │    │ (영구저장)│   │
│  └─────────┘    └──────────┘   │
│                                 │
│  ✅ HTTP REST API               │
│  ✅ 자동 백업                    │
│  ✅ 관리 불필요                  │
└─────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;Vercel KV&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Vercel KV = Upstash Redis + Vercel 통합

┌────────────────────────────────────┐
│  Vercel 프로젝트                   │
│  ┌──────────────────────────┐     │
│  │  자동 환경변수 설정       │     │
│  │  KV_REST_API_URL         │     │
│  │  KV_REST_API_TOKEN       │     │
│  └──────────────────────────┘     │
│           ↓                        │
│  ┌──────────────────────────┐     │
│  │  @vercel/kv 패키지        │     │
│  │  (자동으로 연결)          │     │
│  └──────────────────────────┘     │
└────────────────────────────────────┘
           ↓
  Upstash Redis (도쿄)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;간단히 말하면:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Redis&lt;/strong&gt;: 인메모리 데이터베이스 기술&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Upstash&lt;/strong&gt;: 서버리스 Redis 서비스 제공자&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vercel KV&lt;/strong&gt;: Vercel에서 Upstash를 쉽게 쓰게 만든 것&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;왜 일반 데이터베이스가 아닌 Redis인가?&lt;/h2&gt;
&lt;h3&gt;시나리오: 하루 1,000번 클릭&lt;/h3&gt;
&lt;h4&gt;PostgreSQL 방식&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 매 클릭마다 INSERT
await prisma.bannerClick.create({
  data: { bannerId: &amp;#39;left&amp;#39;, timestamp: new Date() }
});

// 통계 조회 시
const stats = await prisma.bannerClick.groupBy({
  by: [&amp;#39;bannerId&amp;#39;, &amp;#39;hour&amp;#39;],
  where: {
    timestamp: { gte: last7Days }
  },
  _count: true
});

// 문제:
// - 1,000개 레코드 INSERT
// - 통계 조회마다 전체 스캔
// - 시간대별 집계 계산 필요&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;비용:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Vercel Postgres 무료 티어: 256MB 저장, 256MB 전송&lt;/li&gt;
&lt;li&gt;클릭 데이터가 쌓이면 곧 유료&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Redis 방식&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 클릭 기록 - 단 3개 명령어
await kv.incr(&amp;#39;banner:clicks:left&amp;#39;);              // 총 클릭 수 +1
await kv.hincrby(&amp;#39;banner:daily:left:2026-01-01&amp;#39;, &amp;#39;09&amp;#39;, 1); // 9시 클릭 +1
await kv.expire(&amp;#39;banner:daily:left:2026-01-01&amp;#39;, 7776000);  // 90일 TTL

// 통계 조회 - 이미 집계된 데이터
const total = await kv.get(&amp;#39;banner:clicks:left&amp;#39;);
const hourly = await kv.hgetall(&amp;#39;banner:daily:left:2026-01-01&amp;#39;);

// 장점:
// - 클릭마다 3개 명령어로 끝
// - 통계는 이미 계산되어 있음
// - 빠른 응답 (&amp;lt; 10ms)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;비용:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Upstash 무료 티어: 월 500,000 커맨드&lt;/li&gt;
&lt;li&gt;1,000 클릭 × 3 커맨드 = 3,000 (충분!)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;시스템 아키텍처&lt;/h2&gt;
&lt;p&gt;전체 흐름을 먼저 보겠습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌──────────────────────────────────────────────────┐
│          사용자가 배너 클릭                       │
└──────────────────────────────────────────────────┘
                    ↓
┌──────────────────────────────────────────────────┐
│  SideAd.tsx                                      │
│  ┌────────────────────────────────────────────┐  │
│  │ onClick = () =&amp;gt; {                          │  │
│  │   fetch(&amp;#39;/api/banner/click&amp;#39;, {             │  │
│  │     method: &amp;#39;POST&amp;#39;,                        │  │
│  │     body: { bannerId: &amp;#39;left&amp;#39; }             │  │
│  │   }).catch(() =&amp;gt; {});                      │  │
│  │                                            │  │
│  │   // 응답 안 기다리고 바로 이동 (중요!)     │  │
│  │   window.open(bannerUrl);                  │  │
│  │ }                                          │  │
│  └────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────┘
                    ↓
┌──────────────────────────────────────────────────┐
│  POST /api/banner/click                          │
│  ┌────────────────────────────────────────────┐  │
│  │ 1. bannerId 검증 (&amp;#39;left&amp;#39; | &amp;#39;right&amp;#39;)        │  │
│  │ 2. recordBannerClick(bannerId) 호출        │  │
│  │ 3. 총 클릭 수 반환                          │  │
│  └────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────┘
                    ↓
┌──────────────────────────────────────────────────┐
│  Vercel KV (Upstash Redis - 도쿄)                │
│                                                  │
│  banner:clicks:left = 12345                      │
│  banner:daily:left:2026-01-01 = {                │
│    &amp;quot;00&amp;quot;: 5, &amp;quot;01&amp;quot;: 3, &amp;quot;09&amp;quot;: 45, &amp;quot;10&amp;quot;: 67, ...     │
│  }                                               │
└──────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;핵심 1: 데이터 구조 설계&lt;/h2&gt;
&lt;p&gt;Redis는 다양한 자료구조를 지원합니다. 어떤 것을 선택할지가 중요합니다.&lt;/p&gt;
&lt;h3&gt;Key 네이밍 규칙&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;banner:clicks:{bannerId}              # 총 클릭 수 (String, 영구)
banner:daily:{bannerId}:{YYYY-MM-DD}  # 일별 시간대 데이터 (Hash, 90일 TTL)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;왜 이렇게?&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// ✅ 채택한 방식
banner:clicks:left              → 12345 (Integer)
banner:daily:left:2026-01-01    → { &amp;quot;09&amp;quot;: 45, &amp;quot;10&amp;quot;: 67, ... }

// ❌ 대안 1: 모든 데이터를 하나의 Hash에
banner:left → { 
  &amp;quot;total&amp;quot;: 12345, 
  &amp;quot;2026-01-01:09&amp;quot;: 45, 
  &amp;quot;2026-01-01:10&amp;quot;: 67,
  ... 
}
// 문제: TTL을 개별 필드에 설정 불가 (전체 Hash에만 가능)
//      → 총 클릭 수도 같이 사라짐

// ❌ 대안 2: 각 시간대별 별도 키
banner:hourly:left:2026-01-01:09 → 45
banner:hourly:left:2026-01-01:10 → 67
// 문제: 키가 너무 많아짐 (24시간 × 90일 × 2배너 = 4,320개)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Hash 타입 사용 이유&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Hash: 하나의 키 아래 여러 필드 저장
HSET banner:daily:left:2026-01-01 &amp;quot;09&amp;quot; 45
HSET banner:daily:left:2026-01-01 &amp;quot;10&amp;quot; 67

// 조회
HGETALL banner:daily:left:2026-01-01
→ { &amp;quot;09&amp;quot;: 45, &amp;quot;10&amp;quot;: 67, &amp;quot;11&amp;quot;: 34, ... }

// 장점:
// 1. 하루치 데이터가 하나의 키로 관리됨
// 2. 특정 시간대만 조회 가능 (HGET)
// 3. 시간대별 증가 원자적 (HINCRBY)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;TTL 전략&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 총 클릭 수: TTL 없음 (영구 보관)
await kv.incr(&amp;#39;banner:clicks:left&amp;#39;);

// 일별 데이터: 90일 후 자동 삭제
await kv.expire(&amp;#39;banner:daily:left:2026-01-01&amp;#39;, 60 * 60 * 24 * 90);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;왜 90일?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;3개월 이상 된 상세 데이터는 분석 가치 감소&lt;/li&gt;
&lt;li&gt;저장 공간 절약 (자동 정리)&lt;/li&gt;
&lt;li&gt;총 클릭 수는 영구 보관으로 히스토리 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;핵심 2: 클릭 기록 구현&lt;/h2&gt;
&lt;h3&gt;비즈니스 로직&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// lib/banner-tracking.ts
import { kv } from &amp;#39;@vercel/kv&amp;#39;;

export type BannerId = &amp;#39;left&amp;#39; | &amp;#39;right&amp;#39;;

export async function recordBannerClick(bannerId: BannerId): Promise&amp;lt;number&amp;gt; {
  const now = new Date();
  const dateStr = now.toISOString().split(&amp;#39;T&amp;#39;)[0]; // &amp;quot;2026-01-01&amp;quot;
  const hour = now.getHours().toString().padStart(2, &amp;#39;0&amp;#39;); // &amp;quot;09&amp;quot;

  const totalKey = `banner:clicks:${bannerId}`;
  const dailyKey = `banner:daily:${bannerId}:${dateStr}`;

  try {
    // 병렬 실행으로 성능 최적화
    const [totalClicks] = await Promise.all([
      kv.incr(totalKey),                  // 총 클릭 수 +1
      kv.hincrby(dailyKey, hour, 1),      // 해당 시간대 +1
      kv.expire(dailyKey, 60 * 60 * 24 * 90), // 90일 TTL
    ]);

    return totalClicks;
  } catch (error) {
    console.error(&amp;#39;Failed to record banner click:&amp;#39;, error);
    throw error;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;왜 Promise.all?&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// ❌ 순차 실행: 느림
const totalClicks = await kv.incr(totalKey);      // 30ms
await kv.hincrby(dailyKey, hour, 1);              // 30ms
await kv.expire(dailyKey, TTL);                   // 30ms
// 총 90ms

// ✅ 병렬 실행: 빠름
const [totalClicks] = await Promise.all([
  kv.incr(totalKey),
  kv.hincrby(dailyKey, hour, 1),
  kv.expire(dailyKey, TTL),
]);
// 총 30ms (가장 느린 것 기준)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;API Route&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// app/api/banner/click/route.ts
import { NextRequest, NextResponse } from &amp;#39;next/server&amp;#39;;
import { recordBannerClick, type BannerId } from &amp;#39;@/lib/banner-tracking&amp;#39;;

const VALID_BANNER_IDS: BannerId[] = [&amp;#39;left&amp;#39;, &amp;#39;right&amp;#39;];

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { bannerId } = body;

    // 검증
    if (!VALID_BANNER_IDS.includes(bannerId)) {
      return NextResponse.json(
        { error: &amp;#39;Invalid bannerId&amp;#39; },
        { status: 400 }
      );
    }

    // 클릭 기록
    const totalClicks = await recordBannerClick(bannerId);

    return NextResponse.json({
      success: true,
      totalClicks,
    });
  } catch (error) {
    console.error(&amp;#39;Banner click API error:&amp;#39;, error);
    return NextResponse.json(
      { error: &amp;#39;Failed to record click&amp;#39; },
      { status: 500 }
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;클라이언트 구현 (중요!)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// components/ads/SideAd.tsx
&amp;#39;use client&amp;#39;;

import { useState } from &amp;#39;react&amp;#39;;

interface SideAdProps {
  position: &amp;#39;left&amp;#39; | &amp;#39;right&amp;#39;;
  imageUrl: string;
  linkUrl: string;
  alt: string;
}

export function SideAd({ position, imageUrl, linkUrl, alt }: SideAdProps) {
  const [isTracking, setIsTracking] = useState(false);

  const handleClick = async (e: React.MouseEvent) =&amp;gt; {
    e.preventDefault();

    // 중복 클릭 방지
    if (isTracking) return;
    setIsTracking(true);

    // Fire-and-forget: 응답 안 기다림
    fetch(&amp;#39;/api/banner/click&amp;#39;, {
      method: &amp;#39;POST&amp;#39;,
      headers: { &amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39; },
      body: JSON.stringify({ bannerId: position }),
    }).catch((error) =&amp;gt; {
      // 실패해도 사용자 경험에 영향 없음
      console.error(&amp;#39;Failed to track click:&amp;#39;, error);
    });

    // 바로 링크로 이동 (새 탭)
    window.open(linkUrl, &amp;#39;_blank&amp;#39;, &amp;#39;noopener,noreferrer&amp;#39;);

    setIsTracking(false);
  };

  return (
    &amp;lt;a
      href={linkUrl}
      onClick={handleClick}
      className=&amp;quot;block hover:opacity-80 transition-opacity&amp;quot;
      rel=&amp;quot;noopener noreferrer sponsored&amp;quot;
    &amp;gt;
      &amp;lt;img src={imageUrl} alt={alt} className=&amp;quot;w-full h-auto&amp;quot; /&amp;gt;
    &amp;lt;/a&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Fire-and-forget 패턴의 장점:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// ❌ Bad: 응답을 기다림
const handleClick = async () =&amp;gt; {
  await fetch(&amp;#39;/api/banner/click&amp;#39;, ...);  // 30ms 대기
  window.open(linkUrl);  // 30ms 후에야 이동
};
// 사용자: &amp;quot;왜 클릭이 느리지?&amp;quot;  

// ✅ Good: 응답 안 기다림
const handleClick = () =&amp;gt; {
  fetch(&amp;#39;/api/banner/click&amp;#39;, ...).catch(() =&amp;gt; {});
  window.open(linkUrl);  // 즉시 이동!
};
// 사용자: &amp;quot;반응이 빠르네!&amp;quot; ✨&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;핵심 3: 통계 조회 구현&lt;/h2&gt;
&lt;h3&gt;비즈니스 로직&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// lib/banner-tracking.ts (계속)

interface HourlyStats {
  [hour: string]: number;
}

interface DailyStats {
  date: string;
  clicks: number;
  hourly: HourlyStats;
}

interface BannerStats {
  bannerId: BannerId;
  totalClicks: number;
  daily: DailyStats[];
}

export async function getBannerStats(
  bannerId: BannerId,
  days: number = 7
): Promise&amp;lt;BannerStats&amp;gt; {
  const totalKey = `banner:clicks:${bannerId}`;

  // 조회할 날짜 범위 생성
  const dates: string[] = [];
  for (let i = 0; i &amp;lt; days; i++) {
    const date = new Date();
    date.setDate(date.getDate() - i);
    dates.push(date.toISOString().split(&amp;#39;T&amp;#39;)[0]);
  }

  try {
    // 총 클릭 수 조회
    const totalClicks = (await kv.get&amp;lt;number&amp;gt;(totalKey)) || 0;

    // 일별 데이터 병렬 조회
    const dailyStatsPromises = dates.map(async (date) =&amp;gt; {
      const dailyKey = `banner:daily:${bannerId}:${date}`;
      const hourly = (await kv.hgetall&amp;lt;HourlyStats&amp;gt;(dailyKey)) || {};

      // 하루 총 클릭 수 계산
      const clicks = Object.values(hourly).reduce(
        (sum, count) =&amp;gt; sum + (count || 0),
        0
      );

      return { date, clicks, hourly };
    });

    const daily = await Promise.all(dailyStatsPromises);

    // 날짜 역순 정렬 (최신순)
    daily.sort((a, b) =&amp;gt; b.date.localeCompare(a.date));

    return {
      bannerId,
      totalClicks,
      daily,
    };
  } catch (error) {
    console.error(&amp;#39;Failed to get banner stats:&amp;#39;, error);
    throw error;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;병렬 조회의 힘:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// ❌ 순차 조회: 7일 = 7 × 30ms = 210ms
for (const date of dates) {
  const data = await kv.hgetall(`banner:daily:${bannerId}:${date}`);
}

// ✅ 병렬 조회: 7일 = 30ms (동시 실행)
const promises = dates.map(date =&amp;gt; 
  kv.hgetall(`banner:daily:${bannerId}:${date}`)
);
const results = await Promise.all(promises);&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;API Route&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// app/api/banner/stats/route.ts
import { NextRequest, NextResponse } from &amp;#39;next/server&amp;#39;;
import { getBannerStats, type BannerId } from &amp;#39;@/lib/banner-tracking&amp;#39;;

const VALID_BANNER_IDS: BannerId[] = [&amp;#39;left&amp;#39;, &amp;#39;right&amp;#39;];
const MAX_DAYS = 90;

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    const bannerIdParam = searchParams.get(&amp;#39;bannerId&amp;#39;);
    const daysParam = searchParams.get(&amp;#39;days&amp;#39;);

    // days 파라미터 파싱
    const days = Math.min(
      parseInt(daysParam || &amp;#39;7&amp;#39;, 10),
      MAX_DAYS
    );

    // 특정 배너만 조회
    if (bannerIdParam) {
      if (!VALID_BANNER_IDS.includes(bannerIdParam as BannerId)) {
        return NextResponse.json(
          { error: &amp;#39;Invalid bannerId&amp;#39; },
          { status: 400 }
        );
      }

      const stats = await getBannerStats(bannerIdParam as BannerId, days);
      return NextResponse.json({ [bannerIdParam]: stats });
    }

    // 모든 배너 조회
    const [leftStats, rightStats] = await Promise.all([
      getBannerStats(&amp;#39;left&amp;#39;, days),
      getBannerStats(&amp;#39;right&amp;#39;, days),
    ]);

    return NextResponse.json({
      left: leftStats,
      right: rightStats,
    });
  } catch (error) {
    console.error(&amp;#39;Banner stats API error:&amp;#39;, error);
    return NextResponse.json(
      { error: &amp;#39;Failed to fetch stats&amp;#39; },
      { status: 500 }
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;핵심 4: 관리자 대시보드&lt;/h2&gt;
&lt;h3&gt;통계 페이지&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// app/admin/banner-stats/page.tsx
import { BannerStatsChart } from &amp;#39;@/components/admin/BannerStatsChart&amp;#39;;

export default async function BannerStatsPage() {
  return (
    &amp;lt;div className=&amp;quot;container mx-auto py-8&amp;quot;&amp;gt;
      &amp;lt;h1 className=&amp;quot;text-3xl font-bold mb-8&amp;quot;&amp;gt;배너 클릭 통계&amp;lt;/h1&amp;gt;
      &amp;lt;BannerStatsChart /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;차트 컴포넌트&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// components/admin/BannerStatsChart.tsx
&amp;#39;use client&amp;#39;;

import { useEffect, useState } from &amp;#39;react&amp;#39;;
import {
  LineChart,
  Line,
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  ResponsiveContainer,
} from &amp;#39;recharts&amp;#39;;

interface BannerStats {
  bannerId: string;
  totalClicks: number;
  daily: Array&amp;lt;{
    date: string;
    clicks: number;
    hourly: { [hour: string]: number };
  }&amp;gt;;
}

interface StatsResponse {
  left: BannerStats;
  right: BannerStats;
}

export function BannerStatsChart() {
  const [stats, setStats] = useState&amp;lt;StatsResponse | null&amp;gt;(null);
  const [days, setDays] = useState(7);
  const [loading, setLoading] = useState(true);

  useEffect(() =&amp;gt; {
    async function fetchStats() {
      setLoading(true);
      try {
        const response = await fetch(`/api/banner/stats?days=${days}`);
        const data = await response.json();
        setStats(data);
      } catch (error) {
        console.error(&amp;#39;Failed to fetch stats:&amp;#39;, error);
      } finally {
        setLoading(false);
      }
    }

    fetchStats();
  }, [days]);

  if (loading) {
    return &amp;lt;div className=&amp;quot;text-center py-8&amp;quot;&amp;gt;Loading...&amp;lt;/div&amp;gt;;
  }

  if (!stats) {
    return &amp;lt;div className=&amp;quot;text-center py-8&amp;quot;&amp;gt;No data available&amp;lt;/div&amp;gt;;
  }

  // 일별 클릭 추이 데이터
  const dailyData = stats.left.daily.map((leftDay, index) =&amp;gt; ({
    date: leftDay.date,
    left: leftDay.clicks,
    right: stats.right.daily[index]?.clicks || 0,
  }));

  // 시간대별 평균 데이터 (최근 7일)
  const hourlyData = Array.from({ length: 24 }, (_, hour) =&amp;gt; {
    const hourStr = hour.toString().padStart(2, &amp;#39;0&amp;#39;);
    const leftTotal = stats.left.daily
      .slice(0, 7)
      .reduce((sum, day) =&amp;gt; sum + (day.hourly[hourStr] || 0), 0);
    const rightTotal = stats.right.daily
      .slice(0, 7)
      .reduce((sum, day) =&amp;gt; sum + (day.hourly[hourStr] || 0), 0);

    return {
      hour: `${hour}시`,
      left: Math.round(leftTotal / 7),
      right: Math.round(rightTotal / 7),
    };
  });

  return (
    &amp;lt;div className=&amp;quot;space-y-8&amp;quot;&amp;gt;
      {/* 기간 선택 */}
      &amp;lt;div className=&amp;quot;flex gap-2&amp;quot;&amp;gt;
        {[7, 30, 90].map((d) =&amp;gt; (
          &amp;lt;button
            key={d}
            onClick={() =&amp;gt; setDays(d)}
            className={`px-4 py-2 rounded ${
              days === d
                ? &amp;#39;bg-blue-500 text-white&amp;#39;
                : &amp;#39;bg-gray-200 hover:bg-gray-300&amp;#39;
            }`}
          &amp;gt;
            {d}일
          &amp;lt;/button&amp;gt;
        ))}
      &amp;lt;/div&amp;gt;

      {/* 총 클릭 수 */}
      &amp;lt;div className=&amp;quot;grid grid-cols-2 gap-4&amp;quot;&amp;gt;
        &amp;lt;div className=&amp;quot;p-6 bg-blue-50 rounded-lg&amp;quot;&amp;gt;
          &amp;lt;h3 className=&amp;quot;text-lg font-semibold text-blue-900&amp;quot;&amp;gt;좌측 배너&amp;lt;/h3&amp;gt;
          &amp;lt;p className=&amp;quot;text-3xl font-bold text-blue-600 mt-2&amp;quot;&amp;gt;
            {stats.left.totalClicks.toLocaleString()}
          &amp;lt;/p&amp;gt;
          &amp;lt;p className=&amp;quot;text-sm text-gray-600 mt-1&amp;quot;&amp;gt;총 클릭 수&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div className=&amp;quot;p-6 bg-green-50 rounded-lg&amp;quot;&amp;gt;
          &amp;lt;h3 className=&amp;quot;text-lg font-semibold text-green-900&amp;quot;&amp;gt;우측 배너&amp;lt;/h3&amp;gt;
          &amp;lt;p className=&amp;quot;text-3xl font-bold text-green-600 mt-2&amp;quot;&amp;gt;
            {stats.right.totalClicks.toLocaleString()}
          &amp;lt;/p&amp;gt;
          &amp;lt;p className=&amp;quot;text-sm text-gray-600 mt-1&amp;quot;&amp;gt;총 클릭 수&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      {/* 일별 클릭 추이 */}
      &amp;lt;div className=&amp;quot;bg-white p-6 rounded-lg shadow&amp;quot;&amp;gt;
        &amp;lt;h3 className=&amp;quot;text-xl font-semibold mb-4&amp;quot;&amp;gt;일별 클릭 추이&amp;lt;/h3&amp;gt;
        &amp;lt;ResponsiveContainer width=&amp;quot;100%&amp;quot; height={300}&amp;gt;
          &amp;lt;LineChart data={dailyData}&amp;gt;
            &amp;lt;CartesianGrid strokeDasharray=&amp;quot;3 3&amp;quot; /&amp;gt;
            &amp;lt;XAxis
              dataKey=&amp;quot;date&amp;quot;
              tickFormatter={(date) =&amp;gt; {
                const [, month, day] = date.split(&amp;#39;-&amp;#39;);
                return `${month}/${day}`;
              }}
            /&amp;gt;
            &amp;lt;YAxis /&amp;gt;
            &amp;lt;Tooltip /&amp;gt;
            &amp;lt;Legend /&amp;gt;
            &amp;lt;Line
              type=&amp;quot;monotone&amp;quot;
              dataKey=&amp;quot;left&amp;quot;
              stroke=&amp;quot;#3b82f6&amp;quot;
              name=&amp;quot;좌측 배너&amp;quot;
              strokeWidth={2}
            /&amp;gt;
            &amp;lt;Line
              type=&amp;quot;monotone&amp;quot;
              dataKey=&amp;quot;right&amp;quot;
              stroke=&amp;quot;#10b981&amp;quot;
              name=&amp;quot;우측 배너&amp;quot;
              strokeWidth={2}
            /&amp;gt;
          &amp;lt;/LineChart&amp;gt;
        &amp;lt;/ResponsiveContainer&amp;gt;
      &amp;lt;/div&amp;gt;

      {/* 시간대별 평균 클릭 */}
      &amp;lt;div className=&amp;quot;bg-white p-6 rounded-lg shadow&amp;quot;&amp;gt;
        &amp;lt;h3 className=&amp;quot;text-xl font-semibold mb-4&amp;quot;&amp;gt;
          시간대별 평균 클릭 (최근 7일)
        &amp;lt;/h3&amp;gt;
        &amp;lt;ResponsiveContainer width=&amp;quot;100%&amp;quot; height={300}&amp;gt;
          &amp;lt;BarChart data={hourlyData}&amp;gt;
            &amp;lt;CartesianGrid strokeDasharray=&amp;quot;3 3&amp;quot; /&amp;gt;
            &amp;lt;XAxis dataKey=&amp;quot;hour&amp;quot; /&amp;gt;
            &amp;lt;YAxis /&amp;gt;
            &amp;lt;Tooltip /&amp;gt;
            &amp;lt;Legend /&amp;gt;
            &amp;lt;Bar dataKey=&amp;quot;left&amp;quot; fill=&amp;quot;#3b82f6&amp;quot; name=&amp;quot;좌측 배너&amp;quot; /&amp;gt;
            &amp;lt;Bar dataKey=&amp;quot;right&amp;quot; fill=&amp;quot;#10b981&amp;quot; name=&amp;quot;우측 배너&amp;quot; /&amp;gt;
          &amp;lt;/BarChart&amp;gt;
        &amp;lt;/ResponsiveContainer&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;차트 라이브러리 선택:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Recharts 사용 이유:
// ✅ React 친화적 (컴포넌트 기반)
// ✅ 간단한 데이터 (일별 90개, 시간대 24개)
// ✅ 반응형 차트 쉬움
// ✅ 커스터마이징 용이

// Chart.js는 안 쓴 이유:
// - 데이터가 적어서 성능 이슈 없음
// - Recharts가 더 React스러움&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;실전 문제 해결&lt;/h2&gt;
&lt;h3&gt;문제 1: TTL이 갱신 안 됨&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// ❌ 문제: 첫 클릭 시에만 TTL 설정
const exists = await kv.exists(dailyKey);
if (!exists) {
  await kv.expire(dailyKey, TTL);
}

// 문제: 첫 클릭 이후 TTL이 갱신되지 않음
// → 하루 중 첫 클릭 시점부터 90일 후 삭제
// → 마지막 클릭 시점이 아님!

// ✅ 해결: 매번 TTL 갱신
await kv.hincrby(dailyKey, hour, 1);
await kv.expire(dailyKey, TTL);  // 매번 90일로 리셋&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;문제 2: 시간대 문자열 일관성&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// ❌ 문제: 시간대 형식 불일치
const hour1 = new Date().getHours();  // 9
const hour2 = new Date().getHours().toString();  // &amp;quot;9&amp;quot;
const hour3 = new Date().getHours().toString().padStart(2, &amp;#39;0&amp;#39;);  // &amp;quot;09&amp;quot;

// Hash에 &amp;quot;9&amp;quot;와 &amp;quot;09&amp;quot;가 섞여서 저장됨!

// ✅ 해결: 항상 padStart 사용
const hour = now.getHours().toString().padStart(2, &amp;#39;0&amp;#39;);&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;문제 3: 날짜 기준 (시간대)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// ❌ 문제: 서버 시간대와 사용자 시간대 불일치
const dateStr = new Date().toISOString().split(&amp;#39;T&amp;#39;)[0];
// 한국 시간 01:00 = UTC 16:00 (전날)
// → 날짜가 하루 밀림!

// ✅ 해결 1: 서버를 한국 시간대로 설정
// Vercel 환경변수: TZ=Asia/Seoul

// ✅ 해결 2: 로컬 시간 사용
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, &amp;#39;0&amp;#39;);
const day = String(now.getDate()).padStart(2, &amp;#39;0&amp;#39;);
const dateStr = `${year}-${month}-${day}`;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;문제 4: Redis 연결 에러 핸들링&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// ❌ 문제: Redis 장애 시 클릭 자체가 실패
try {
  await recordBannerClick(bannerId);
} catch (error) {
  return NextResponse.json(
    { error: &amp;#39;Failed&amp;#39; },
    { status: 500 }
  );
  // 사용자는 배너 클릭이 안 됨!
}

// ✅ 해결: 클라이언트에서 fire-and-forget
fetch(&amp;#39;/api/banner/click&amp;#39;, ...)
  .catch(() =&amp;gt; {}); // 실패해도 무시

window.open(linkUrl); // 링크는 무조건 열림&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;성능 최적화&lt;/h2&gt;
&lt;h3&gt;1. 병렬 실행&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 클릭 기록 시
const [totalClicks] = await Promise.all([
  kv.incr(totalKey),
  kv.hincrby(dailyKey, hour, 1),
  kv.expire(dailyKey, TTL),
]);

// 통계 조회 시
const [totalClicks, ...dailyStats] = await Promise.all([
  kv.get(totalKey),
  ...dates.map(date =&amp;gt; kv.hgetall(`banner:daily:${bannerId}:${date}`))
]);&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 불필요한 데이터 제외&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// ❌ Bad: 모든 시간대 데이터 전송
const hourly = { &amp;quot;00&amp;quot;: 0, &amp;quot;01&amp;quot;: 0, ..., &amp;quot;23&amp;quot;: 0 };

// ✅ Good: 클릭이 있는 시간대만
const hourly = { &amp;quot;09&amp;quot;: 45, &amp;quot;10&amp;quot;: 67, &amp;quot;14&amp;quot;: 23 };&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 응답 캐싱 (선택사항)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// app/api/banner/stats/route.ts
export const revalidate = 60; // 60초 캐싱

export async function GET(request: NextRequest) {
  // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;비용 분석&lt;/h2&gt;
&lt;h3&gt;Upstash 무료 티어&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;월간 한도:
- 500,000 커맨드
- 256MB 저장
- 무제한 대역폭&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;예상 사용량&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 하루 1,000번 클릭
const clicksPerDay = 1000;
const commandsPerClick = 3; // incr, hincrby, expire
const dailyCommands = clicksPerDay * commandsPerClick; // 3,000

// 한 달
const monthlyCommands = dailyCommands * 30; // 90,000

// 통계 조회 (하루 10번)
const statsPerDay = 10;
const commandsPerStats = 2 + 7; // get(total) + hgetall × 7일
const statsCommands = statsPerDay * commandsPerStats * 30; // 2,700

// 총합
const totalCommands = monthlyCommands + statsCommands; // 92,700&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결론&lt;/strong&gt;: 무료 티어로 충분! (500,000 중 92,700 사용)&lt;/p&gt;
&lt;h3&gt;저장 공간&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 데이터 크기 추정
const totalKeys = 2; // left, right
const dailyKeys = 90 * 2; // 90일 × 2배너 = 180개

// 각 키 크기
const totalKeySize = 20; // &amp;quot;banner:clicks:left&amp;quot; + Integer
const dailyKeySize = 100; // Hash with 24 fields

const totalSize = 
  (totalKeys * totalKeySize) + 
  (dailyKeys * dailyKeySize);
// = 40 + 18,000 = 18,040 bytes ≈ 18KB&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;결론&lt;/strong&gt;: 256MB 중 18KB만 사용 (0.007%)&lt;/p&gt;
&lt;h2&gt;모니터링과 디버깅&lt;/h2&gt;
&lt;h3&gt;Upstash 콘솔&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Dashboard에서 확인 가능:
- 실시간 커맨드 수
- 메모리 사용량
- 평균 응답 시간
- 에러 로그&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;Vercel 로그&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// API Route에서 로깅
console.log(&amp;#39;Banner click:&amp;#39;, {
  bannerId,
  timestamp: new Date().toISOString(),
  totalClicks,
});

// Vercel Dashboard &amp;gt; Logs에서 확인 가능&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;간단한 헬스 체크&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// app/api/banner/health/route.ts
import { kv } from &amp;#39;@vercel/kv&amp;#39;;

export async function GET() {
  try {
    // PING 테스트
    const result = await kv.ping();

    return Response.json({
      status: &amp;#39;healthy&amp;#39;,
      redis: result === &amp;#39;PONG&amp;#39; ? &amp;#39;connected&amp;#39; : &amp;#39;error&amp;#39;,
    });
  } catch (error) {
    return Response.json(
      { status: &amp;#39;unhealthy&amp;#39;, error: String(error) },
      { status: 500 }
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Vercel KV 설정하기&lt;/h2&gt;
&lt;h3&gt;1. Vercel Dashboard에서 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;1. Vercel 프로젝트 선택
2. Storage 탭 클릭
3. &amp;quot;Create Database&amp;quot; 클릭
4. &amp;quot;KV&amp;quot; 선택
5. 리전 선택 (Tokyo 추천)
6. &amp;quot;Create&amp;quot; 클릭&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;2. 환경변수 자동 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# Vercel이 자동으로 설정해줌
KV_REST_API_URL=https://xxx.upstash.io
KV_REST_API_TOKEN=AxxxYYY...
KV_REST_API_READ_ONLY_TOKEN=...
KV_URL=redis://...&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 로컬 개발 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 환경변수 다운로드
vercel env pull .env.local

# 또는 수동으로 .env.local 생성
# Vercel Dashboard &amp;gt; Settings &amp;gt; Environment Variables에서 복사&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. 패키지 설치&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @vercel/kv&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5. 사용&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// @vercel/kv가 환경변수를 자동으로 읽음
import { kv } from &amp;#39;@vercel/kv&amp;#39;;

// 바로 사용 가능!
await kv.set(&amp;#39;key&amp;#39;, &amp;#39;value&amp;#39;);&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;h3&gt;배운 점&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;서버리스 환경에서 Redis&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Upstash는 HTTP 기반이라 서버리스 친화적&lt;/li&gt;
&lt;li&gt;연결 풀 관리 불필요&lt;/li&gt;
&lt;li&gt;자동 백업과 영구 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;데이터 구조 설계의 중요성&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Key 네이밍 규칙 정하기&lt;/li&gt;
&lt;li&gt;적절한 자료형 선택 (Hash, String 등)&lt;/li&gt;
&lt;li&gt;TTL 전략 미리 고민&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;성능 최적화&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Promise.all로 병렬 실행&lt;/li&gt;
&lt;li&gt;Fire-and-forget 패턴&lt;/li&gt;
&lt;li&gt;불필요한 데이터 제거&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;비용 효율&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;무료 티어로 충분한 서비스 가능&lt;/li&gt;
&lt;li&gt;TTL로 자동 정리&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;전체 파일 구조&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;project/
├── src/
│   ├── lib/
│   │   └── banner-tracking.ts      # 핵심 로직
│   │
│   ├── app/
│   │   ├── api/
│   │   │   └── banner/
│   │   │       ├── click/
│   │   │       │   └── route.ts    # POST - 클릭 기록
│   │   │       └── stats/
│   │   │           └── route.ts    # GET - 통계 조회
│   │   │
│   │   └── admin/
│   │       └── banner-stats/
│   │           └── page.tsx        # 관리자 페이지
│   │
│   └── components/
│       ├── ads/
│       │   └── SideAd.tsx          # 배너 컴포넌트
│       │
│       └── admin/
│           └── BannerStatsChart.tsx # 차트
│
├── .env.local                       # 로컬 환경변수
└── package.json&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;다음 단계&lt;/h3&gt;
&lt;p&gt;이 시스템을 확장한다면:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 1. A/B 테스트
interface ABTest {
  bannerId: string;
  variant: &amp;#39;A&amp;#39; | &amp;#39;B&amp;#39;;
  imageUrl: string;
}

// 2. 전환율 추적
await kv.hincrby(&amp;#39;banner:conversions:left&amp;#39;, dateStr, 1);

// 3. 리퍼러 분석
await kv.zincrby(&amp;#39;banner:referrers:left&amp;#39;, 1, referrer);

// 4. 디바이스별 통계
await kv.hincrby(`banner:device:${device}:${bannerId}`, dateStr, 1);

// 5. 실시간 알림
if (clicksPerHour &amp;gt; threshold) {
  await sendSlackNotification(`배너 ${bannerId} 급증!`);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Vercel KV로 간단하면서도 강력한 클릭 추적 시스템을 만들 수 있었습니다. 서버리스 환경에서 Redis를 쓰고 싶다면 Upstash를 적극 추천합니다!&lt;/p&gt;</description>
      <category>Backend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/141</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/Vercel-KV%EB%A1%9C-%EB%B0%B0%EB%84%88-%ED%81%B4%EB%A6%AD-%EC%B6%94%EC%A0%81-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0-Redis%EB%A5%BC-%EC%84%9C%EB%B2%84%EB%A6%AC%EC%8A%A4%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B2%95#entry141comment</comments>
      <pubDate>Thu, 1 Jan 2026 18:10:07 +0900</pubDate>
    </item>
    <item>
      <title>[프로그래머스 / JavaScript] 2021 KAKAO BLIND RECRUITMENT / 숫자 문자열과 영단어</title>
      <link>https://white-mouse-dev.tistory.com/entry/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-JavaScript-2021-KAKAO-BLIND-RECRUITMENT-%EC%88%AB%EC%9E%90-%EB%AC%B8%EC%9E%90%EC%97%B4%EA%B3%BC-%EC%98%81%EB%8B%A8%EC%96%B4</link>
      <description>&lt;h2&gt;문제 출처&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/81301&quot;&gt;문제 보러가기&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;문제 설명&lt;/h2&gt;
&lt;p&gt;네오와 프로도가 숫자놀이를 하고 있습니다. 네오가 프로도에게 숫자를 건넬 때 일부 자릿수를 영단어로 바꾼 카드를 건네주면 프로도는 원래 숫자를 찾는 게임입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;영단어 변환 예시:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;1478 → &amp;quot;one4seveneight&amp;quot;&lt;/li&gt;
&lt;li&gt;234567 → &amp;quot;23four5six7&amp;quot;&lt;/li&gt;
&lt;li&gt;10203 → &amp;quot;1zerotwozero3&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이렇게 숫자의 일부 자릿수가 영단어로 바뀌어졌거나, 혹은 바뀌지 않고 그대로인 문자열 &lt;code&gt;s&lt;/code&gt;가 매개변수로 주어집니다. &lt;code&gt;s&lt;/code&gt;가 의미하는 원래 숫자를 return 하도록 solution 함수를 완성해주세요.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;숫자-영단어 대응표:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;숫자&lt;/th&gt;
&lt;th&gt;영단어&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;two&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;three&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;four&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;five&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;six&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;seven&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;eight&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;nine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;제한사항&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;1 ≤ &lt;code&gt;s&lt;/code&gt;의 길이 ≤ 50&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt;가 &amp;quot;zero&amp;quot; 또는 &amp;quot;0&amp;quot;으로 시작하는 경우는 주어지지 않습니다.&lt;/li&gt;
&lt;li&gt;return 값이 1 이상 2,000,000,000 이하의 정수가 되는 올바른 입력만 &lt;code&gt;s&lt;/code&gt;로 주어집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;입출력 예&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;s&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&amp;quot;one4seveneight&amp;quot;&lt;/td&gt;
&lt;td&gt;1478&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;quot;23four5six7&amp;quot;&lt;/td&gt;
&lt;td&gt;234567&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;quot;2three45sixseven&amp;quot;&lt;/td&gt;
&lt;td&gt;234567&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;quot;123&amp;quot;&lt;/td&gt;
&lt;td&gt;123&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;내 답안&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;function solution(s) {
    const nums = [&amp;quot;zero&amp;quot;, &amp;quot;one&amp;quot;, &amp;quot;two&amp;quot;, &amp;quot;three&amp;quot;, &amp;quot;four&amp;quot;, &amp;quot;five&amp;quot;, &amp;quot;six&amp;quot;, &amp;quot;seven&amp;quot;, &amp;quot;eight&amp;quot;, &amp;quot;nine&amp;quot;]

    nums.forEach((num) =&amp;gt; {
        s = s.split(num).join(nums.indexOf(num))
    })

    return Number(s)
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;다른 풀이&lt;/h2&gt;
&lt;h3&gt;1. 정규표현식을 활용한 풀이&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;function solution(s) {
    const words = [&amp;quot;zero&amp;quot;, &amp;quot;one&amp;quot;, &amp;quot;two&amp;quot;, &amp;quot;three&amp;quot;, &amp;quot;four&amp;quot;, &amp;quot;five&amp;quot;, &amp;quot;six&amp;quot;, &amp;quot;seven&amp;quot;, &amp;quot;eight&amp;quot;, &amp;quot;nine&amp;quot;]

    words.forEach((word, index) =&amp;gt; {
        s = s.replaceAll(word, index)
    })

    return Number(s)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. reduce를 활용한 함수형 풀이&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;function solution(s) {
    const words = [&amp;quot;zero&amp;quot;, &amp;quot;one&amp;quot;, &amp;quot;two&amp;quot;, &amp;quot;three&amp;quot;, &amp;quot;four&amp;quot;, &amp;quot;five&amp;quot;, &amp;quot;six&amp;quot;, &amp;quot;seven&amp;quot;, &amp;quot;eight&amp;quot;, &amp;quot;nine&amp;quot;]

    return Number(
        words.reduce((acc, word, index) =&amp;gt; 
            acc.split(word).join(index), s
        )
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Map 객체를 활용한 풀이&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;function solution(s) {
    const wordToNum = {
        zero: 0, one: 1, two: 2, three: 3, four: 4,
        five: 5, six: 6, seven: 7, eight: 8, nine: 9
    }

    for (const [word, num] of Object.entries(wordToNum)) {
        s = s.split(word).join(num)
    }

    return Number(s)
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;결론 및 느낀점&lt;/h2&gt;
&lt;p&gt;이 문제는 문자열 치환을 다루는 기본적인 문제로, 여러 가지 접근 방법이 가능합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;내가 선택한 방법의 장점:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;split().join()&lt;/code&gt; 조합은 모든 일치 항목을 한 번에 치환할 수 있어 효율적입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;replaceAll()&lt;/code&gt;이 지원되지 않는 구형 환경에서도 안정적으로 동작합니다.&lt;/li&gt;
&lt;li&gt;코드가 직관적이고 이해하기 쉽습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;시간 복잡도:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;영단어 배열을 순회: O(10)&lt;/li&gt;
&lt;li&gt;각 영단어에 대해 문자열 치환: O(n) (n은 문자열 길이)&lt;/li&gt;
&lt;li&gt;전체: O(10 × n) = O(n)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;개선 가능한 점:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ES2021부터 지원되는 &lt;code&gt;replaceAll()&lt;/code&gt; 메서드를 사용하면 코드가 더 간결해집니다.&lt;/li&gt;
&lt;li&gt;reduce를 활용하면 함수형 프로그래밍 스타일로 작성할 수 있지만, 가독성 측면에서는 forEach가 더 명확합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;문자열 치환 문제는 실무에서도 자주 마주치는 패턴이므로, &lt;code&gt;split().join()&lt;/code&gt; 조합과 &lt;code&gt;replaceAll()&lt;/code&gt; 메서드를 상황에 맞게 선택하여 사용하는 것이 좋습니다. 특히 브라우저 호환성을 고려해야 하는 프로젝트라면 &lt;code&gt;split().join()&lt;/code&gt; 방식이 더 안전한 선택이 될 수 있습니다.&lt;/p&gt;</description>
      <category>Algorithm/프로그래머스</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/140</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-JavaScript-2021-KAKAO-BLIND-RECRUITMENT-%EC%88%AB%EC%9E%90-%EB%AC%B8%EC%9E%90%EC%97%B4%EA%B3%BC-%EC%98%81%EB%8B%A8%EC%96%B4#entry140comment</comments>
      <pubDate>Tue, 25 Nov 2025 15:26:04 +0900</pubDate>
    </item>
    <item>
      <title>자료구조, 왜 배워야 할까? 스택부터 해시테이블까지 핵심 정리</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-%EC%99%9C-%EB%B0%B0%EC%9B%8C%EC%95%BC-%ED%95%A0%EA%B9%8C-%EC%8A%A4%ED%83%9D%EB%B6%80%ED%84%B0-%ED%95%B4%EC%8B%9C%ED%85%8C%EC%9D%B4%EB%B8%94%EA%B9%8C%EC%A7%80-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/08c86651-e519-407b-af40-b266ce4999a6/image.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;&amp;quot;자료구조? 그거 학교에서나 배우는 거 아니야?&amp;quot;&lt;/p&gt;
&lt;p&gt;실무에서 코딩만 하면 되지, 왜 자료구조를 알아야 하는지 의문을 가진 적 있으신가요? 저도 처음에는 그랬습니다. 하지만 자료구조는 단순히 이론이 아닙니다. 우리가 매일 작성하는 코드의 효율성과 직결되는 실전 개념입니다.&lt;/p&gt;
&lt;p&gt;컴퓨터의 메모리는 무한해 보이지만, 사실은 매우 한정적입니다. 이 한정된 공간에서 데이터를 &lt;strong&gt;어떻게 효율적으로 저장하고 관리할 것인가&lt;/strong&gt;. 이것이 바로 자료구조를 배우는 이유입니다.&lt;/p&gt;
&lt;p&gt;오늘은 실무에서 가장 많이 사용되는 4가지 핵심 자료구조를 알아보겠습니다.&lt;/p&gt;
&lt;h2&gt;1. 스택 (Stack): 드럼통처럼 쌓이는 구조&lt;/h2&gt;
&lt;h3&gt;스택이란?&lt;/h3&gt;
&lt;p&gt;스택을 이해하는 가장 쉬운 방법은 &lt;strong&gt;드럼통&lt;/strong&gt;을 떠올리는 것입니다. 드럼통에 물건을 하나씩 넣으면 위로 쌓이죠? 꺼낼 때는 가장 나중에 넣은 것부터 꺼내게 됩니다. &lt;/p&gt;
&lt;p&gt;이것이 바로 스택의 핵심 원리입니다: &lt;strong&gt;LIFO (Last In, First Out)&lt;/strong&gt; - 마지막에 들어간 것이 먼저 나옵니다.&lt;/p&gt;
&lt;h3&gt;스택의 기본 연산&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 스택의 주요 연산
interface Stack&amp;lt;T&amp;gt; {
    push(item: T): void;    // 삽입: 스택에 아이템 추가
    pop(): T | undefined;   // 삭제: 스택에서 아이템 제거
    peek(): T | undefined;  // 조회: 최상단 아이템 확인 (제거 X)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;동작 방식 예시:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. push(&amp;#39;A&amp;#39;) → [A]
2. push(&amp;#39;B&amp;#39;) → [A, B]
3. push(&amp;#39;C&amp;#39;) → [A, B, C]
4. pop()     → [A, B]     // C 반환
5. pop()     → [A]        // B 반환
6. pop()     → []         // A 반환&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;스택, 실제로 어디에 쓰일까?&lt;/h3&gt;
&lt;h4&gt;1) 함수 호출 (Call Stack)&lt;/h4&gt;
&lt;p&gt;우리가 매일 사용하는 함수 호출이 바로 스택 구조입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;function main() {
    console.log(bar(6));
}

function bar(num: number) {
    return foo(num);
}

function foo(num: number) {
    return num * 2 + 16;
}

main();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;실행 과정:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. [main] 실행 시작
2. [main, console.log] console.log 호출
3. [main, console.log, bar] bar(6) 호출
4. [main, console.log, bar, foo] foo(6) 호출
5. [main, console.log, bar] foo 종료, 28 반환
6. [main, console.log] bar 종료, 28 반환
7. [main] console.log 종료, 28 출력
8. [] main 종료&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;2) 브라우저의 뒤로 가기&lt;/h4&gt;
&lt;p&gt;브라우저에서 뒤로 가기를 누르면 가장 최근에 방문한 페이지가 나옵니다. 이것도 스택 구조입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;방문 순서: 페이지A → 페이지B → 페이지C
뒤로 가기: 페이지C → 페이지B → 페이지A&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;3) 계산기 프로그램&lt;/h4&gt;
&lt;p&gt;수식을 계산할 때 괄호 처리도 스택으로 구현됩니다. 괄호를 만나면 스택에 저장하고, 닫는 괄호를 만나면 꺼내서 계산하는 방식이죠.&lt;/p&gt;
&lt;h3&gt;Stack Overflow란?&lt;/h3&gt;
&lt;p&gt;개발자라면 한 번쯤 들어본 &amp;quot;Stack Overflow&amp;quot; 웹사이트. 로고를 자세히 보면 스택 모양에 여러 블록이 쌓여있는 걸 볼 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stack Overflow 에러는 언제 발생할까?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;프로그램 실행 시 할당된 스택 공간을 초과하면 발생합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 종료 조건이 없는 재귀 함수
function infiniteRecursion() {
    infiniteRecursion(); // Stack Overflow!
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;함수가 무한정 호출되면 콜 스택이 계속 쌓이다가 결국 메모리 한계를 넘어 에러가 발생합니다.&lt;/p&gt;
&lt;h3&gt;면접 팁&lt;/h3&gt;
&lt;p&gt;스택은 면접에서 자주 나오는 주제입니다. &amp;quot;스택을 코드로 구현해보세요&amp;quot;라는 문제가 단골 질문이니, 배열이나 연결 리스트로 스택을 직접 구현해보는 연습을 추천합니다.&lt;/p&gt;
&lt;h2&gt;2. 큐 (Queue): 줄 서는 사람들처럼&lt;/h2&gt;
&lt;h3&gt;큐란?&lt;/h3&gt;
&lt;p&gt;큐는 &lt;strong&gt;줄을 서서 기다리는 사람들&lt;/strong&gt;을 떠올리면 됩니다. 먼저 온 사람이 먼저 나가는 구조죠.&lt;/p&gt;
&lt;p&gt;이것이 큐의 핵심 원리입니다: &lt;strong&gt;FIFO (First In, First Out)&lt;/strong&gt; - 먼저 들어간 것이 먼저 나옵니다.&lt;/p&gt;
&lt;h3&gt;큐의 기본 연산&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;interface Queue&amp;lt;T&amp;gt; {
    enqueue(item: T): void;  // 삽입: 큐의 뒤에 아이템 추가
    dequeue(): T | undefined; // 삭제: 큐의 앞에서 아이템 제거
    front(): T | undefined;   // 맨 앞 아이템 확인
    rear(): T | undefined;    // 맨 뒤 아이템 확인
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;동작 방식 예시:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;enqueue → [1] → [1, 2] → [1, 2, 3]
               ↓
            dequeue
               ↓
            [2, 3] → [3] → []&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Front&lt;/strong&gt;: 다음에 나갈 아이템 (맨 앞)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rear&lt;/strong&gt;: 방금 들어온 아이템 (맨 뒤)&lt;/li&gt;
&lt;li&gt;Front와 Rear가 같으면 → 큐가 비어있는 상태&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;큐, 실제로 어디에 쓰일까?&lt;/h3&gt;
&lt;h4&gt;1) JavaScript 이벤트 루프 (Callback Queue)&lt;/h4&gt;
&lt;p&gt;JavaScript 엔진은 내부적으로 큐를 사용합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Call Stack: 함수 실행
    ↓
Web API: 비동기 작업 처리
    ↓
Callback Queue: 완료된 작업 대기 (큐!)
    ↓
Event Loop: 큐에서 하나씩 꺼내 실행&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;console.log(&amp;#39;1&amp;#39;);

setTimeout(() =&amp;gt; {
    console.log(&amp;#39;2&amp;#39;); // Callback Queue에 등록
}, 0);

console.log(&amp;#39;3&amp;#39;);

// 출력 순서: 1 → 3 → 2
// setTimeout의 콜백은 큐에서 대기했다가 실행됨&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2) 티켓팅 시스템&lt;/h4&gt;
&lt;p&gt;인터파크 같은 티켓 예매 사이트도 큐를 사용합니다. 먼저 클릭한 사람이 먼저 티켓을 구매할 수 있어야 하니까요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 티켓 예매 시스템 (의사 코드)
const ticketQueue = new Queue&amp;lt;User&amp;gt;();

// 사용자 클릭 순서대로 큐에 추가
ticketQueue.enqueue(user1);
ticketQueue.enqueue(user2);
ticketQueue.enqueue(user3);

// 순서대로 처리
while (!ticketQueue.isEmpty()) {
    const user = ticketQueue.dequeue();
    processTicket(user);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3) 프린터 대기열&lt;/h4&gt;
&lt;p&gt;여러 문서를 출력할 때도 큐가 사용됩니다. 먼저 요청한 문서부터 인쇄되죠.&lt;/p&gt;
&lt;h3&gt;실습 추천&lt;/h3&gt;
&lt;p&gt;큐도 배열과 연결 리스트 두 가지 방법으로 구현해보세요. 각각의 장단점을 직접 느낄 수 있습니다.&lt;/p&gt;
&lt;h2&gt;3. 연결 리스트 (Linked List): 손으로 연결된 사람들&lt;/h2&gt;
&lt;h3&gt;배열의 한계&lt;/h3&gt;
&lt;p&gt;큐와 스택을 설명하면서 &amp;quot;배열로도 만들 수 있고, 연결 리스트로도 만들 수 있다&amp;quot;고 했는데요. 그럼 연결 리스트는 뭘까요?&lt;/p&gt;
&lt;p&gt;먼저 배열의 문제점을 봅시다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;배열의 메모리 구조:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;메모리: [A][B][C][D][E] - 연속된 공간
인덱스:  0  1  2  3  4&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;배열은 메모리상에서 &lt;strong&gt;연속적으로&lt;/strong&gt; 저장됩니다. 이게 문제가 되는 순간이 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;상황: 배열에 100개 요소를 추가하고 싶은데,
     뒤쪽 메모리 공간이 이미 다른 데이터로 차있음

해결: 1. 더 큰 연속된 공간 찾기
     2. 기존 데이터 전부 복사
     3. 새 위치에 저장

→ 비효율적!&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;연결 리스트란?&lt;/h3&gt;
&lt;p&gt;연결 리스트는 &lt;strong&gt;손으로 연결된 사람들&lt;/strong&gt;처럼 각 데이터가 다음 데이터의 위치를 기억하는 구조입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 노드 구조
interface Node&amp;lt;T&amp;gt; {
    data: T;           // 실제 데이터
    next: Node&amp;lt;T&amp;gt;;     // 다음 노드의 위치 (포인터)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;메모리 구조 비교:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;배열:
메모리 주소: [100][101][102][103] - 연속적
데이터:      [A]  [B]  [C]  [D]

연결 리스트:
메모리 주소: [100]    [305]    [152]    [200] - 불연속
데이터:      [A|305] [B|152] [C|200] [D|null]
              ↓        ↓        ↓
           다음 위치  다음 위치  다음 위치&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;연결 리스트의 장점: 삽입과 삭제&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;중간에 데이터 추가하기:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 배열: O(n) - 뒤의 모든 요소를 이동해야 함
const arr = [1, 2, 3, 4];
arr.splice(2, 0, 99); // [1, 2, 99, 3, 4]

// 연결 리스트: O(1) - 포인터만 수정
// B와 C 사이에 X 추가
// 기존: A → B → C → D
// 1. B의 next를 X로 변경
// 2. X의 next를 C로 설정
// 결과: A → B → X → C → D&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;데이터 삭제하기:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 연결 리스트에서 B 삭제
// 기존: A → B → C → D
// A의 next를 C로 변경
// 결과: A → C → D (B는 참조가 끊겨 자동 삭제)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;단순 vs 이중 연결 리스트&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;단순 연결 리스트 (Singly Linked List):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A → B → C → D → null
(한 방향으로만 이동 가능)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;이중 연결 리스트 (Doubly Linked List):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;null ← A ⇄ B ⇄ C ⇄ D → null
(양방향으로 이동 가능)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이중 연결 리스트는 양방향 탐색이 가능하지만, 각 노드가 이전/다음 두 개의 포인터를 가져야 하므로 메모리를 더 사용합니다.&lt;/p&gt;
&lt;h3&gt;배열 vs 연결 리스트 비교&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;특성&lt;/th&gt;
&lt;th&gt;배열&lt;/th&gt;
&lt;th&gt;연결 리스트&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;메모리 할당&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;연속적 (고정 크기)&lt;/td&gt;
&lt;td&gt;비연속적 (동적 크기)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;삽입/삭제&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;느림 O(n)&lt;/td&gt;
&lt;td&gt;빠름 O(1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;접근/탐색&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;빠름 O(1)&lt;/td&gt;
&lt;td&gt;느림 O(n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;메모리 효율&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;높음 (데이터만)&lt;/td&gt;
&lt;td&gt;낮음 (포인터 추가)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;구현 복잡도&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;간단&lt;/td&gt;
&lt;td&gt;복잡 (포인터 관리)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;언제 무엇을 사용할까?&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 배열이 좋은 경우
const users = [user1, user2, user3];
const thirdUser = users[2]; // 인덱스로 즉시 접근
// → 탐색이 많고, 크기가 고정적일 때

// 연결 리스트가 좋은 경우
// → 삽입/삭제가 빈번하고, 크기가 동적으로 변할 때
// 예: 음악 재생 목록, 브라우저 히스토리&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;실무에서의 연결 리스트&lt;/h3&gt;
&lt;p&gt;솔직히 말하면, 실무에서 직접 연결 리스트를 구현할 일은 드뭅니다. 대부분 배열로 해결되고, 언어별로 제공하는 자료구조(JavaScript의 Array, Python의 List 등)가 내부적으로 최적화되어 있기 때문입니다.&lt;/p&gt;
&lt;p&gt;하지만 연결 리스트의 개념을 이해하는 것은 중요합니다. 다른 자료구조(스택, 큐, 해시테이블)를 구현할 때 기반이 되기 때문이죠.&lt;/p&gt;
&lt;h2&gt;4. 해시 테이블 (Hash Table): 사전처럼 빠른 검색&lt;/h2&gt;
&lt;h3&gt;선형 자료구조의 한계&lt;/h3&gt;
&lt;p&gt;배열이나 연결 리스트의 치명적인 단점이 있습니다. &lt;strong&gt;데이터가 많아질수록 검색이 느려진다&lt;/strong&gt;는 것입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 100만 개의 사용자 중 특정 사용자 찾기
const users = [...]; // 100만 개
const target = users.find(u =&amp;gt; u.id === &amp;#39;12345&amp;#39;);
// 최악의 경우 100만 번 비교 필요  &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 문제를 해결하는 것이 바로 &lt;strong&gt;해시 테이블&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;h3&gt;해시 테이블이란?&lt;/h3&gt;
&lt;p&gt;해시 테이블은 &lt;strong&gt;사전&lt;/strong&gt;과 비슷합니다. 사전에서 단어를 찾을 때, A부터 Z까지 전부 뒤지지 않죠? &amp;#39;M&amp;#39;으로 시작하면 바로 중간쯤으로 건너뛰잖아요.&lt;/p&gt;
&lt;p&gt;해시 테이블도 같은 원리입니다. &lt;strong&gt;키(Key)를 해시 함수에 넣어 인덱스를 얻고, 그 위치에 바로 접근&lt;/strong&gt;합니다.&lt;/p&gt;
&lt;h3&gt;해시 테이블의 구조&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;interface HashTable&amp;lt;K, V&amp;gt; {
    set(key: K, value: V): void;  // 저장
    get(key: K): V | undefined;   // 조회
    delete(key: K): boolean;      // 삭제
}

// 내부 구조
// Key → Hash Function → Index → Value&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;동작 원리:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. Key: &amp;quot;yuna&amp;quot;
   ↓
2. Hash Function: hash(&amp;quot;yuna&amp;quot;) → 2
   ↓
3. 테이블 인덱스 2에 저장: [&amp;quot;yuna&amp;quot;, 15]

검색할 때:
1. hash(&amp;quot;yuna&amp;quot;) → 2
2. 인덱스 2 확인 → 즉시 찾음! O(1)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;예시 코드:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 간단한 해시 테이블 구현
class SimpleHashTable {
    private table: Array&amp;lt;[string, any][]&amp;gt;;
    private size: number = 10;

    constructor() {
        this.table = new Array(this.size).fill(null).map(() =&amp;gt; []);
    }

    // 해시 함수
    private hash(key: string): number {
        let hash = 0;
        for (let i = 0; i &amp;lt; key.length; i++) {
            hash = (hash + key.charCodeAt(i)) % this.size;
        }
        return hash;
    }

    set(key: string, value: any): void {
        const index = this.hash(key);
        this.table[index].push([key, value]);
    }

    get(key: string): any {
        const index = this.hash(key);
        const bucket = this.table[index];

        for (const [k, v] of bucket) {
            if (k === key) return v;
        }
        return undefined;
    }
}

// 사용
const hashTable = new SimpleHashTable();
hashTable.set(&amp;quot;yuna&amp;quot;, 15);
hashTable.set(&amp;quot;yuri&amp;quot;, 20);

console.log(hashTable.get(&amp;quot;yuna&amp;quot;)); // 15 - 즉시 찾음!&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;충돌(Collision) 문제&lt;/h3&gt;
&lt;p&gt;해시 테이블의 가장 큰 문제는 &lt;strong&gt;충돌&lt;/strong&gt;입니다. 서로 다른 키가 같은 인덱스를 가리킬 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash(&amp;quot;john&amp;quot;) → 2
hash(&amp;quot;sandra&amp;quot;) → 2  // 충돌!&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;충돌 해결 방법&lt;/h3&gt;
&lt;h4&gt;1) 체이닝 (Chaining)&lt;/h4&gt;
&lt;p&gt;같은 인덱스에 &lt;strong&gt;연결 리스트로 연결&lt;/strong&gt;합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;인덱스 2: [john, 123] → [sandra, 456] → [ted, 789]
           ↓
        연결 리스트&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;class HashTableWithChaining {
    private table: Array&amp;lt;Array&amp;lt;[string, any]&amp;gt;&amp;gt;;

    set(key: string, value: any): void {
        const index = this.hash(key);

        // 같은 인덱스에 배열로 저장 (체이닝)
        if (!this.table[index]) {
            this.table[index] = [];
        }

        this.table[index].push([key, value]);
    }

    get(key: string): any {
        const index = this.hash(key);
        const bucket = this.table[index];

        if (!bucket) return undefined;

        // 연결된 리스트를 순회하며 찾기
        for (const [k, v] of bucket) {
            if (k === key) return v;
        }

        return undefined;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2) 오픈 어드레싱 (Open Addressing)&lt;/h4&gt;
&lt;p&gt;충돌이 나면 &lt;strong&gt;다음 빈 공간을 찾아&lt;/strong&gt; 저장합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;인덱스 1: [apple, 100]
인덱스 2: [banana, 200]
인덱스 3: [cherry, 300]
인덱스 4: [dragon, 400]  ← 원래 인덱스는 1이었지만, 
                           충돌로 인해 4에 저장&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;class HashTableWithOpenAddressing {
    private table: Array&amp;lt;[string, any] | null&amp;gt;;

    set(key: string, value: any): void {
        let index = this.hash(key);

        // 빈 공간을 찾을 때까지 다음 인덱스 확인
        while (this.table[index] !== null) {
            index = (index + 1) % this.size;
        }

        this.table[index] = [key, value];
    }

    get(key: string): any {
        let index = this.hash(key);

        // 키를 찾을 때까지 순회
        while (this.table[index] !== null) {
            const [k, v] = this.table[index];
            if (k === key) return v;
            index = (index + 1) % this.size;
        }

        return undefined;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;해시 테이블, 어디에 쓰일까?&lt;/h3&gt;
&lt;h4&gt;1) 신원 조회 시스템&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 대한민국 전체 국민 정보
const citizenDB = new Map&amp;lt;string, Citizen&amp;gt;();

// 주민등록번호로 즉시 검색
const person = citizenDB.get(&amp;quot;123456-1234567&amp;quot;); // O(1)
// 100만 명이든 1억 명이든 속도 동일!&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2) 자동차 번호판 조회&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const vehicleDB = new Map&amp;lt;string, Vehicle&amp;gt;();
vehicleDB.set(&amp;quot;12가3456&amp;quot;, { owner: &amp;quot;김철수&amp;quot;, model: &amp;quot;소나타&amp;quot; });

// 번호판으로 즉시 조회
const car = vehicleDB.get(&amp;quot;12가3456&amp;quot;); // O(1)&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3) 캐싱&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const cache = new Map&amp;lt;string, string&amp;gt;();

function fetchData(url: string): string {
    // 캐시에 있으면 즉시 반환
    if (cache.has(url)) {
        return cache.get(url)!;
    }

    // 없으면 가져와서 저장
    const data = expensiveFetch(url);
    cache.set(url, data);
    return data;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;왜 데이터베이스는 해시 테이블을 안 쓸까?&lt;/h3&gt;
&lt;p&gt;해시 테이블이 이렇게 빠른데, 데이터베이스 인덱스로 쓰면 좋지 않을까요?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;안타깝게도 아닙니다.&lt;/strong&gt; 이유는 &lt;strong&gt;범위 검색&lt;/strong&gt; 때문입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 해시 테이블은 정확한 값 검색에 최적화
userTable.get(&amp;quot;user123&amp;quot;); // ✅ 빠름

// 범위 검색은 비효율적
// &amp;quot;2023-01-01부터 2023-12-31까지의 주문&amp;quot;
// → 모든 날짜를 하나씩 해시해서 찾아야 함 ❌
for (let date = start; date &amp;lt;= end; date++) {
    const orders = orderTable.get(date.toString());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;데이터베이스는 범위 검색이 많기 때문에, 해시 테이블 대신 &lt;strong&gt;B-Tree&lt;/strong&gt;라는 자료구조를 주로 사용합니다.&lt;/p&gt;
&lt;h3&gt;JavaScript의 Map과 Object&lt;/h3&gt;
&lt;p&gt;JavaScript에서는 해시 테이블이 이미 구현되어 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Object (제한적인 해시 테이블)
const obj = {
    name: &amp;quot;John&amp;quot;,
    age: 30
};

// Map (완전한 해시 테이블)
const map = new Map&amp;lt;string, number&amp;gt;();
map.set(&amp;quot;apple&amp;quot;, 100);
map.set(&amp;quot;banana&amp;quot;, 200);

console.log(map.get(&amp;quot;apple&amp;quot;)); // 100 - O(1)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Map vs Object:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Object의 한계
const obj = {};
obj[123] = &amp;quot;숫자 키&amp;quot;; // 문자열로 변환됨
obj[{ id: 1 }] = &amp;quot;객체 키&amp;quot;; // &amp;quot;[object Object]&amp;quot;로 변환

// Map은 모든 타입 가능
const map = new Map();
map.set(123, &amp;quot;숫자 키&amp;quot;);
map.set({ id: 1 }, &amp;quot;객체 키&amp;quot;); // 객체를 키로 사용 가능!&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;오늘 살펴본 4가지 자료구조는 각각 고유한 특성과 용도가 있습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;자료구조&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;th&gt;주요 용도&lt;/th&gt;
&lt;th&gt;시간 복잡도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;스택&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;LIFO&lt;/td&gt;
&lt;td&gt;함수 호출, 실행 취소&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;큐&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;FIFO&lt;/td&gt;
&lt;td&gt;이벤트 처리, 대기열&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;연결 리스트&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;동적 크기&lt;/td&gt;
&lt;td&gt;삽입/삭제 빈번한 경우&lt;/td&gt;
&lt;td&gt;삽입: O(1), 탐색: O(n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;해시 테이블&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Key-Value&lt;/td&gt;
&lt;td&gt;빠른 검색&lt;/td&gt;
&lt;td&gt;O(1) 평균&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;실무 팁:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;대부분의 경우 배열로 충분합니다.&lt;/strong&gt; JavaScript의 Array는 매우 최적화되어 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;빠른 검색이 필요하면 Map을 사용하세요.&lt;/strong&gt; Object보다 더 강력합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;면접 준비는 직접 구현해보세요.&lt;/strong&gt; 각 자료구조를 TypeScript로 구현하는 연습이 도움됩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;자료구조는 단순히 이론이 아닙니다. 우리가 매일 작성하는 코드 속에 숨어있고, 성능을 좌우하는 핵심 요소입니다. &lt;/p&gt;
&lt;p&gt;다음 포스트에서는 &lt;strong&gt;트리(Tree)&lt;/strong&gt; 구조에 대해 알아보겠습니다. B-Tree가 왜 데이터베이스에서 사용되는지, 이진 탐색 트리는 무엇인지 자세히 다룰 예정입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;관련 글:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;./big-o-notation-guide.md&quot;&gt;Big O 표기법 완벽 가이드: 알고리즘 성능을 측정하는 개발자 필수 개념&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;트리 자료구조 완벽 정리 (다음 편 예고)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>자료구조</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/139</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-%EC%99%9C-%EB%B0%B0%EC%9B%8C%EC%95%BC-%ED%95%A0%EA%B9%8C-%EC%8A%A4%ED%83%9D%EB%B6%80%ED%84%B0-%ED%95%B4%EC%8B%9C%ED%85%8C%EC%9D%B4%EB%B8%94%EA%B9%8C%EC%A7%80-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC#entry139comment</comments>
      <pubDate>Wed, 12 Nov 2025 12:27:33 +0900</pubDate>
    </item>
    <item>
      <title>&amp;quot;이 알고리즘이 더 빠른가요?&amp;quot; Big O 표기법으로 정확하게 답하는 법</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EC%9D%B4-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98%EC%9D%B4-%EB%8D%94-%EB%B9%A0%EB%A5%B8%EA%B0%80%EC%9A%94-Big-O-%ED%91%9C%EA%B8%B0%EB%B2%95%EC%9C%BC%EB%A1%9C-%EC%A0%95%ED%99%95%ED%95%98%EA%B2%8C-%EB%8B%B5%ED%95%98%EB%8A%94-%EB%B2%95</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/bf3aa699-cdd3-43ac-bf5c-849ae9042a05/image.webp&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이 알고리즘이 더 빠른데?&quot; vs &quot;저 알고리즘이 더 빠른데?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자들 사이에서 흔히 일어나는 논쟁입니다. 하지만 '빠르다'는 표현은 생각보다 애매합니다. 내 컴퓨터에서는 0.5초 걸렸는데, 동료 컴퓨터에서는 0.2초 걸렸다면? 과연 어느 알고리즘이 더 효율적인 걸까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 모호함을 해결하기 위해 컴퓨터 과학에서는 &lt;b&gt;Big O 표기법&lt;/b&gt;이라는 표준화된 방법을 사용합니다. 오늘은 이 Big O 표기법을 통해 알고리즘의 성능을 제대로 평가하는 방법을 알아보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 시간이 아닌 단계(Step)로 측정할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알고리즘의 성능을 초 단위로 측정하지 않는 이유는 명확합니다. 같은 알고리즘이라도 하드웨어 성능에 따라 실행 시간이 달라지기 때문입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최신 M4 맥북에서 돌린 알고리즘: 0.1초&lt;/li&gt;
&lt;li&gt;10년 된 노트북에서 돌린 같은 알고리즘: 1초&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 되면 알고리즘 자체의 효율성을 객관적으로 평가할 수 없습니다. 그래서 우리는 &lt;b&gt;알고리즘이 수행하는 단계(step)의 수&lt;/b&gt;로 성능을 측정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;5단계로 끝나는 알고리즘 A&lt;/li&gt;
&lt;li&gt;10단계로 끝나는 알고리즘 B&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 컴퓨터에서 실행하든 A가 B보다 효율적이라는 것은 변하지 않습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Big O 표기법이란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Big O 표기법은 &lt;b&gt;입력 크기(n)에 따라 알고리즘이 몇 단계를 수행하는지를 표현하는 방법&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 선형 탐색(Linear Search) 알고리즘을 생각해봅시다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;크기 10인 배열: 최악의 경우 10단계&lt;/li&gt;
&lt;li&gt;크기 20인 배열: 최악의 경우 20단계&lt;/li&gt;
&lt;li&gt;크기 n인 배열: 최악의 경우 n단계&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 간단하게 &lt;b&gt;O(n)&lt;/b&gt;이라고 표현합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;입력 크기가 n일 때 n단계가 필요합니다&quot;라는 긴 문장 대신, 단순히 &quot;시간 복잡도가 O(n)입니다&quot;라고 말할 수 있게 된 것이죠. 훨씬 깔끔하고 전문적이지 않나요?&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;주요 시간 복잡도 유형&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. O(1) - 상수 시간 (Constant Time)&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def print_first_element(array):
    print(array[0])  # 1단계&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력 크기와 관계없이 항상 &lt;b&gt;일정한 단계&lt;/b&gt;만 수행하는 알고리즘입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;배열 크기가 10이든 100이든 1,000이든: 항상 1단계&lt;/li&gt;
&lt;li&gt;가장 이상적인 시간 복잡도&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;중요한 규칙&lt;/b&gt;: Big O는 상수를 무시합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;def print_first_element_twice(array):
    print(array[0])  # 1단계
    print(array[0])  # 1단계&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 함수는 기술적으로 2단계지만, O(2)가 아닌 &lt;b&gt;O(1)&lt;/b&gt;로 표기합니다. Big O는 세부사항보다는 전체적인 경향성에 집중하기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 어떤 함수가 항상 200단계를 수행한다 해도 O(200)이 아닌 O(1)입니다. 입력 크기가 증가해도 단계 수가 증가하지 않는다는 본질이 중요하기 때문이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래프로 보면 수평선처럼 평평한 모양입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. O(n) - 선형 시간 (Linear Time)&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def print_all_elements(array):
    for element in array:
        print(element)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력 크기에 &lt;b&gt;비례&lt;/b&gt;하여 단계가 증가하는 알고리즘입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;배열 크기가 10: 10단계&lt;/li&gt;
&lt;li&gt;배열 크기가 100: 100단계&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여기서도 상수는 무시됩니다&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;golo&quot;&gt;&lt;code&gt;def print_all_elements_twice(array):
    for element in array:
        print(element)
    for element in array:
        print(element)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술적으로는 O(2n)이지만, &lt;b&gt;O(n)&lt;/b&gt;으로 표기합니다. 2배든 3배든 입력이 증가하면 단계도 선형적으로 증가한다는 본질은 같기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래프로 보면 대각선 모양의 직선입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. O(n&amp;sup2;) - 이차 시간 (Quadratic Time)&lt;/h3&gt;
&lt;pre class=&quot;golo&quot;&gt;&lt;code&gt;def print_all_pairs(array):
    for element1 in array:
        for element2 in array:
            print(element1, element2)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;중첩 반복문&lt;/b&gt;이 있을 때 주로 발생합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;배열 크기가 10: 100단계 (10 &amp;times; 10)&lt;/li&gt;
&lt;li&gt;배열 크기가 20: 400단계 (20 &amp;times; 20)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력이 2배 증가하면 실행 단계는 4배 증가합니다. 입력이 커질수록 급격하게 비효율적이 되는 전형적인 패턴이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버블 정렬, 선택 정렬 같은 단순한 정렬 알고리즘들이 이런 시간 복잡도를 가집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. O(log n) - 로그 시간 (Logarithmic Time)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이진 탐색(Binary Search)처럼 &lt;b&gt;데이터를 반씩 줄여나가는&lt;/b&gt; 알고리즘입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그(logarithm)를 간단히 복습하자면:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;2⁵ = 32  &amp;rarr;  log₂(32) = 5&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;2를 몇 번 곱해야 32가 될까?&quot; (답: 5번) 이것이 지수(exponent)고,&lt;br /&gt;&quot;32를 2로 몇 번 나눠야 1이 될까?&quot; (답: 5번) 이것이 로그입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이진 탐색에서는:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;32개 요소 &amp;rarr; 16개 &amp;rarr; 8개 &amp;rarr; 4개 &amp;rarr; 2개 &amp;rarr; 1개 (5단계)
64개 요소 &amp;rarr; 32개 &amp;rarr; 16개 &amp;rarr; 8개 &amp;rarr; 4개 &amp;rarr; 2개 &amp;rarr; 1개 (6단계)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력이 &lt;b&gt;2배 증가해도 단계는 1개만 증가&lt;/b&gt;합니다. 매우 효율적이죠!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Big O 표기법에서는 밑(base)을 생략하고 그냥 &lt;b&gt;O(log n)&lt;/b&gt;으로 씁니다. 밑이 2든 10이든 전체적인 경향성은 같기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래프로 보면 처음에는 급격히 증가하다가 점점 완만해지는 곡선 모양입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시간 복잡도 비교&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;효율성 순서로 나열하면:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;O(1)&lt;/b&gt; - 상수 시간 ⭐⭐⭐⭐⭐ (최고)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;O(log n)&lt;/b&gt; - 로그 시간 ⭐⭐⭐⭐&lt;/li&gt;
&lt;li&gt;&lt;b&gt;O(n)&lt;/b&gt; - 선형 시간 ⭐⭐⭐&lt;/li&gt;
&lt;li&gt;&lt;b&gt;O(n log n)&lt;/b&gt; - 선형 로그 시간 ⭐⭐&lt;/li&gt;
&lt;li&gt;&lt;b&gt;O(n&amp;sup2;)&lt;/b&gt; - 이차 시간 ⭐&lt;/li&gt;
&lt;li&gt;&lt;b&gt;O(2ⁿ)&lt;/b&gt; - 지수 시간   (최악)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;O(1), O(log n), O(n): 좋은 알고리즘&lt;/li&gt;
&lt;li&gt;O(n log n): 합병 정렬, 퀵 정렬 등 실용적인 정렬 알고리즘&lt;/li&gt;
&lt;li&gt;O(n&amp;sup2;) 이상: 입력 크기가 작을 때만 사용 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 예제로 이해하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배열의 첫 번째 요소 출력&lt;/h3&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;function printFirst(arr: number[]): void {
    console.log(arr[0]);
}
// 시간 복잡도: O(1)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력 크기와 무관하게 항상 1단계입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배열의 모든 요소 출력&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;function printAll(arr: number[]): void {
    arr.forEach(item =&amp;gt; console.log(item));
}
// 시간 복잡도: O(n)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열 크기만큼 반복하므로 O(n)입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이진 탐색&lt;/h3&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;function binarySearch(sortedArr: number[], target: number): number {
    let left = 0;
    let right = sortedArr.length - 1;

    while (left &amp;lt;= right) {
        const mid = Math.floor((left + right) / 2);

        if (sortedArr[mid] === target) return mid;
        if (sortedArr[mid] &amp;lt; target) left = mid + 1;
        else right = mid - 1;
    }

    return -1;
}
// 시간 복잡도: O(log n)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매 단계마다 탐색 범위를 반으로 줄이므로 O(log n)입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;버블 정렬&lt;/h3&gt;
&lt;pre class=&quot;inform7&quot;&gt;&lt;code&gt;function bubbleSort(arr: number[]): number[] {
    const n = arr.length;

    for (let i = 0; i &amp;lt; n; i++) {
        for (let j = 0; j &amp;lt; n - i - 1; j++) {
            if (arr[j] &amp;gt; arr[j + 1]) {
                [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
            }
        }
    }

    return arr;
}
// 시간 복잡도: O(n&amp;sup2;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중첩 반복문으로 인해 O(n&amp;sup2;)입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Big O 표기법의 실용적 가치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Big O 표기법을 이해하면:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;알고리즘 선택&lt;/b&gt;: 상황에 맞는 최적의 알고리즘을 선택할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;코드 리뷰&lt;/b&gt;: 동료의 코드에서 비효율적인 부분을 발견할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능 예측&lt;/b&gt;: 사용자가 늘어났을 때 시스템이 어떻게 동작할지 예측 가능합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기술 면접&lt;/b&gt;: 면접에서 알고리즘 문제를 풀 때 필수적인 개념입니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 100개 요소를 처리하는 O(n&amp;sup2;) 알고리즘은 10,000단계가 필요하지만, O(n log n) 알고리즘은 약 664단계만 필요합니다. 데이터가 많아질수록 이 차이는 더욱 극명해집니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;주의사항&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Big O는 &lt;b&gt;최악의 경우(worst case)&lt;/b&gt;를 나타냅니다. 실제로는 더 빠르게 동작할 수 있지만, 보장할 수 있는 최악의 시나리오를 기준으로 평가합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 Big O만으로 모든 것을 판단할 수는 없습니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상수 계수가 클 수도 있습니다 (O(1)이지만 100만 단계일 수도)&lt;/li&gt;
&lt;li&gt;실제 메모리 사용량은 별개입니다&lt;/li&gt;
&lt;li&gt;입력이 작으면 O(n&amp;sup2;)이 O(n log n)보다 빠를 수도 있습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Big O 표기법은 알고리즘의 효율성을 표현하는 표준 언어입니다. 처음에는 낯설 수 있지만, 익숙해지면 알고리즘을 평가하고 개선하는 데 강력한 도구가 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음번에 코드를 작성할 때, 한 번쯤 생각해보세요. &quot;이 코드의 시간 복잡도는 얼마나 될까?&quot; 이런 질문을 던지는 습관만으로도 더 나은 개발자가 될 수 있습니다.&lt;/p&gt;</description>
      <category>Algorithm</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/138</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EC%9D%B4-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98%EC%9D%B4-%EB%8D%94-%EB%B9%A0%EB%A5%B8%EA%B0%80%EC%9A%94-Big-O-%ED%91%9C%EA%B8%B0%EB%B2%95%EC%9C%BC%EB%A1%9C-%EC%A0%95%ED%99%95%ED%95%98%EA%B2%8C-%EB%8B%B5%ED%95%98%EB%8A%94-%EB%B2%95#entry138comment</comments>
      <pubDate>Tue, 11 Nov 2025 10:49:09 +0900</pubDate>
    </item>
    <item>
      <title>React의 Error Boundary와 비동기 오류 처리</title>
      <link>https://white-mouse-dev.tistory.com/entry/React%EC%9D%98-Error-Boundary%EC%99%80-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%98%A4%EB%A5%98-%EC%B2%98%EB%A6%AC</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/c3990827-f9e1-4332-8e63-5e99845e4c0e/image.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;React의 &lt;strong&gt;Error Boundary&lt;/strong&gt;는 컴포넌트 렌더링 도중 발생하는 오류를 포착하여 앱이 완전히 중단되지 않도록 돕는 강력한 기능입니다.&lt;br&gt;하지만 한 가지 중요한 한계가 있습니다 — &lt;strong&gt;비동기 코드에서 발생한 오류는 Error Boundary가 잡을 수 없습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;##&lt;br&gt; 왜 Error Boundary는 비동기 에러를 잡지 못할까?&lt;/p&gt;
&lt;p&gt;그 이유는 &lt;strong&gt;비동기 에러가 렌더링 시점의 콜스택이 모두 비워진 후에 발생하기 때문&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;p&gt;React는 컴포넌트를 렌더링할 때 하나의 연속된 콜스택 안에서 작업을 수행하며, Error Boundary 또한 이 흐름 안에서만 동작합니다.&lt;br&gt;즉, &lt;strong&gt;동기적인 렌더링 과정 중에 발생한 오류&lt;/strong&gt;만 감지할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;class MyComponent extends React.Component {
  render() {
    throw new Error(&amp;quot;렌더링 중 에러 발생!&amp;quot;);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위 코드는 렌더링 시점에 오류가 발생하므로 Error Boundary가 감지할 수 있습니다.&lt;/p&gt;
&lt;p&gt;하지만 다음과 같은 코드는 다릅니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;class MyComponent extends React.Component {
  componentDidMount() {
    setTimeout(() =&amp;gt; {
      throw new Error(&amp;quot;비동기 에러!&amp;quot;);
    }, 1000);
  }
  render() {
    return &amp;lt;div&amp;gt;비동기 테스트&amp;lt;/div&amp;gt;;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우, &lt;code&gt;setTimeout&lt;/code&gt; 내부에서 발생한 오류는 &lt;strong&gt;렌더링이 완료된 후 콜스택이 비워진 뒤에 실행되므로&lt;/strong&gt;,&lt;br&gt;Error Boundary는 이를 감지하지 못합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;비동기 오류를 처리하는 방법&lt;/h2&gt;
&lt;p&gt;비동기 오류는 &lt;strong&gt;직접적으로 처리해야&lt;/strong&gt; 합니다. 대표적인 방법은 다음과 같습니다.&lt;/p&gt;
&lt;h3&gt;1. &lt;code&gt;try...catch&lt;/code&gt;로 감싸기&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;async function fetchData() {
  try {
    const res = await fetch(&amp;quot;/api/data&amp;quot;);
    const data = await res.json();
  } catch (err) {
    console.error(&amp;quot;비동기 에러 발생:&amp;quot;, err);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;try...catch&lt;/code&gt;를 사용하면 비동기 코드 내에서 발생하는 예외를 직접 잡을 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;2. 상태(&lt;code&gt;state&lt;/code&gt;)로 에러를 관리하기&lt;/h3&gt;
&lt;p&gt;비동기 에러를 감지하면 &lt;code&gt;setState&lt;/code&gt;로 에러 상태를 저장하고, 이를 렌더링 시점에 반영할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;function AsyncComponent() {
  const [error, setError] = useState(null);

  useEffect(() =&amp;gt; {
    async function run() {
      try {
        const res = await fetch(&amp;quot;/api/data&amp;quot;);
        if (!res.ok) throw new Error(&amp;quot;서버 응답 오류&amp;quot;);
      } catch (err) {
        setError(err);
      }
    }
    run();
  }, []);

  if (error) {
    throw error; // 동기 에러로 재던짐 → Error Boundary가 감지
  }

  return &amp;lt;div&amp;gt;데이터 로딩 중...&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 방식은 비동기 에러를 &lt;code&gt;Error Boundary&lt;/code&gt;로 전달하기 위해 &lt;strong&gt;의도적으로 동기 에러로 변환&lt;/strong&gt;하는 기법입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;Error Boundary로 감지 가능 여부&lt;/th&gt;
&lt;th&gt;예시&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;렌더링 중 에러&lt;/td&gt;
&lt;td&gt;✅ 가능&lt;/td&gt;
&lt;td&gt;&lt;code&gt;render()&lt;/code&gt; 내부에서 &lt;code&gt;throw&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;생명주기 메서드 동기 에러&lt;/td&gt;
&lt;td&gt;✅ 가능&lt;/td&gt;
&lt;td&gt;&lt;code&gt;componentDidMount&lt;/code&gt; 내부의 &lt;code&gt;throw&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비동기 함수 내부 에러&lt;/td&gt;
&lt;td&gt;❌ 불가능&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Promise&lt;/code&gt;, &lt;code&gt;setTimeout&lt;/code&gt;, &lt;code&gt;fetch&lt;/code&gt; 등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비동기 → 동기로 변환&lt;/td&gt;
&lt;td&gt;✅ 가능&lt;/td&gt;
&lt;td&gt;&lt;code&gt;setState&lt;/code&gt; 후 &lt;code&gt;throw error&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;Error Boundary는 렌더링 시점에 발생하는 동기 오류만 잡을 수 있다.&lt;/strong&gt;&lt;br&gt;비동기 에러는 직접 핸들링하거나, 상태를 통해 동기 에러로 전환해야 한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이 원리를 이해하면 React 애플리케이션의 안정성을 훨씬 높일 수 있습니다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/137</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/React%EC%9D%98-Error-Boundary%EC%99%80-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%98%A4%EB%A5%98-%EC%B2%98%EB%A6%AC#entry137comment</comments>
      <pubDate>Mon, 13 Oct 2025 10:29:36 +0900</pubDate>
    </item>
    <item>
      <title>React 리렌더링(Re-rendering): Trigger &amp;rarr; Render &amp;rarr; Commit</title>
      <link>https://white-mouse-dev.tistory.com/entry/React-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81Re-rendering-Trigger-%E2%86%92-Render-%E2%86%92-Commit</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/e3820d68-9f23-4070-a3f5-25c59e6dd1a2/image.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;React에서 “언제, 왜, 어떻게” 리렌더링이 일어나는지 정확히 이해하면 성능 최적화와 불필요한 복잡도 감소에 큰 도움을 줍니다. 이 글은 리렌더링의 이론을 체계적으로 정리하고, 실행 가능한 예시와 실무 체크리스트로 마무리합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;핵심 개념: 렌더링 파이프라인 3단계&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Trigger&lt;/strong&gt;: 상태(&lt;code&gt;state&lt;/code&gt;) 변경, 상위 컴포넌트 렌더, &lt;code&gt;context&lt;/code&gt; 값 변경, &lt;code&gt;key&lt;/code&gt; 변경, 외부 스토어 구독 변경 등으로 “업데이트 필요”가 발생합니다. React는 내부 업데이트 큐에 변경을 기록합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Render&lt;/strong&gt;: 변경된 상태로 함수 컴포넌트를 다시 호출하여 새로운 VDOM(Fiber 트리)을 만듭니다. 이전 트리와 비교(diff)하지만, 이 단계에서는 실제 DOM 조작이 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Commit&lt;/strong&gt;: diff 결과를 실제 DOM에 최소 변경으로 반영합니다. 레이아웃/페인팅이 발생하고 사용자가 변화를 보게 됩니다. 이때 &lt;code&gt;useLayoutEffect&lt;/code&gt;/ref 효과가 동기적으로, &lt;code&gt;useEffect&lt;/code&gt;는 비동기적으로 실행됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;언제 리렌더링이 발생하는가&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;자신의 &lt;code&gt;setState&lt;/code&gt; 호출&lt;/strong&gt;: 해당 컴포넌트가 다시 렌더됩니다. 동일 값으로 설정하면 &lt;code&gt;Object.is&lt;/code&gt; 비교로 건너뜁니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;부모가 렌더되면 자식도 기본적으로 렌더&lt;/strong&gt;: 단, &lt;code&gt;React.memo&lt;/code&gt;로 자식을 메모이즈하고 props가 같으면 건너뜁니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;context&lt;/code&gt; 값 변경&lt;/strong&gt;: 해당 컨텍스트를 구독(&lt;code&gt;useContext&lt;/code&gt;)하는 모든 소비자가 다시 렌더됩니다. Provider의 &lt;code&gt;value&lt;/code&gt; 참조가 안정적이어야 불필요한 렌더를 줄일 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;외부 스토어 변경&lt;/strong&gt;: Redux &lt;code&gt;useSelector&lt;/code&gt;, Zustand &lt;code&gt;useStore&lt;/code&gt; 등은 선택자 비교 결과가 달라질 때만 리렌더됩니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;key&lt;/code&gt; 변경&lt;/strong&gt;: React가 다른 노드로 인식하여 마운트/언마운트가 일어납니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;발생하지 않는 경우&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;useRef&lt;/code&gt; 변경&lt;/strong&gt;: &lt;code&gt;.current&lt;/code&gt;를 변경해도 렌더를 트리거하지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;변이만 하고 &lt;code&gt;setState&lt;/code&gt;를 호출하지 않음&lt;/strong&gt;: 참조 동일성이 유지되면 React는 변경을 모릅니다. 항상 불변 업데이트를 사용하세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;React 18+ Auto Batching: 여러 상태 변경을 1번 렌더로&lt;/h3&gt;
&lt;p&gt;React 18부터는 이벤트 핸들러뿐 아니라 &lt;code&gt;setTimeout&lt;/code&gt;, &lt;code&gt;Promise&lt;/code&gt;, 네이티브 이벤트 등 대부분의 비동기 컨텍스트에서도 상태 변경이 자동으로 배치됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { useState } from &amp;#39;react&amp;#39;

export function App() {
  const [a, setA] = useState(0)
  const [b, setB] = useState(0)

  const handleClick = () =&amp;gt; {
    // 둘 다 하나의 렌더로 합쳐짐 (자동 배칭)
    setA(v =&amp;gt; v + 1)
    setB(v =&amp;gt; v + 1)
  }

  setTimeout(() =&amp;gt; {
    // 비동기 컨텍스트에서도 자동 배칭
    setA(v =&amp;gt; v + 1)
    setB(v =&amp;gt; v + 1)
  }, 1000)

  return (
    &amp;lt;button onClick={handleClick}&amp;gt;a: {a}, b: {b}&amp;lt;/button&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;필요 시 렌더를 강제로 분리하려면 &lt;code&gt;flushSync&lt;/code&gt;를 사용할 수 있습니다(과용 금지).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { flushSync } from &amp;#39;react-dom&amp;#39;

flushSync(() =&amp;gt; setA(v =&amp;gt; v + 1))
setB(v =&amp;gt; v + 1) // 별도의 렌더&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;불필요한 리렌더 줄이기: 실전 패턴&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;React.memo&lt;/code&gt;로 자식 컴포넌트 메모이즈&lt;/strong&gt;: 부모 렌더 시에도 props가 같으면 자식 렌더를 건너뜁니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;핸들러/객체의 참조 안정화&lt;/strong&gt;: &lt;code&gt;useCallback&lt;/code&gt;, &lt;code&gt;useMemo&lt;/code&gt;로 콜백과 계산 결과의 참조를 고정하여 memo 비교를 돕습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;컨텍스트 분할&lt;/strong&gt;: 넓은 Provider 하나보다는, 변경 빈도에 따라 Context를 분리합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;무거운 계산 메모이즈&lt;/strong&gt;: 비용 큰 계산은 &lt;code&gt;useMemo&lt;/code&gt;로, 입력이 바뀔 때만 다시 수행합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;리스트 최적화&lt;/strong&gt;: 안정적인 &lt;code&gt;key&lt;/code&gt;, 가상 스크롤(virtualization), &lt;code&gt;useDeferredValue&lt;/code&gt;로 입력 지연 처리.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;실행 가능한 예시: 부모 렌더가 잦아도 자식 렌더 막기&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { memo, useCallback, useState } from &amp;#39;react&amp;#39;

type ChildProps = {
  onAdd: () =&amp;gt; void
  value: number
}

const Child = memo(function Child({ onAdd, value }: ChildProps) {
  console.log(&amp;#39;Child render&amp;#39;)
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;p&amp;gt;value: {value}&amp;lt;/p&amp;gt;
      &amp;lt;button onClick={onAdd}&amp;gt;Add&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  )
})

export function Parent() {
  const [count, setCount] = useState(0)
  const [value, setValue] = useState(0)

  // 참조가 안정적이어야 Child가 매 렌더마다 다시 그려지지 않음
  const handleAdd = useCallback(() =&amp;gt; setValue(v =&amp;gt; v + 1), [])

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; setCount(c =&amp;gt; c + 1)}&amp;gt;Re-render parent: {count}&amp;lt;/button&amp;gt;
      &amp;lt;Child onAdd={handleAdd} value={value} /&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;설명&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Parent&lt;/code&gt;가 &lt;code&gt;count&lt;/code&gt;로 자주 렌더되어도, &lt;code&gt;Child&lt;/code&gt;의 props 참조가 동일하면 &lt;code&gt;React.memo&lt;/code&gt;가 비교를 통과해 렌더를 생략합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;handleAdd&lt;/code&gt;를 &lt;code&gt;useCallback&lt;/code&gt;으로 고정하지 않으면 매번 새로운 함수 참조가 전달되어 &lt;code&gt;Child&lt;/code&gt;가 다시 렌더됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;실무 주의사항과 흔한 실수&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;불변 업데이트 확보&lt;/strong&gt;: 배열/객체는 반드시 새 참조로 갱신하세요. 예: &lt;code&gt;setItems(prev =&amp;gt; prev.map(...))&lt;/code&gt;, &lt;code&gt;setObj(prev =&amp;gt; ({ ...prev, a: 1 }))&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;과도한 메모이제이션 금지&lt;/strong&gt;: &lt;code&gt;React.memo&lt;/code&gt;, &lt;code&gt;useMemo&lt;/code&gt;, &lt;code&gt;useCallback&lt;/code&gt;의 비교/저장 비용도 존재합니다. 핫스팟에만 적용하세요.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context &lt;code&gt;value&lt;/code&gt;의 참조 안정화&lt;/strong&gt;: &lt;code&gt;value={{ a, b }}&lt;/code&gt;는 부모 렌더마다 새 객체가 됩니다. &lt;code&gt;useMemo(() =&amp;gt; ({ a, b }), [a, b])&lt;/code&gt;로 고정하세요.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;동일 값 setState는 렌더 생략&lt;/strong&gt;: &lt;code&gt;setCount(c =&amp;gt; c)&lt;/code&gt;는 의미 없습니다. bail-out을 신뢰하되 의도치 않은 동일성 유지 버그에 주의하세요.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Profiler로 실측&lt;/strong&gt;: 추측 대신 React DevTools Profiler로 실제 렌더 비용을 측정하고 개선을 검증하세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;체크리스트&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;상태는 최소화했는가? 파생 가능한 값은 계산으로 대체했는가?&lt;/li&gt;
&lt;li&gt;부모 렌더가 자식 렌더를 유발하지 않도록 메모이제이션이 필요한가?&lt;/li&gt;
&lt;li&gt;컨텍스트 범위를 적절히 분할했는가? &lt;code&gt;value&lt;/code&gt; 참조는 안정적인가?&lt;/li&gt;
&lt;li&gt;비싼 연산은 &lt;code&gt;useMemo&lt;/code&gt;로 감싸졌는가? 이벤트 핸들러 참조는 &lt;code&gt;useCallback&lt;/code&gt;인가?&lt;/li&gt;
&lt;li&gt;리스트 렌더에 안정적인 &lt;code&gt;key&lt;/code&gt;와 가상화가 적용되어 있는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;결론: 개념 정리 + 적용 가이드&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;리렌더는 Trigger → Render → Commit의 파이프라인으로 진행됩니다. 실제 DOM 변경은 Commit에서만 일어납니다.&lt;/li&gt;
&lt;li&gt;React 18의 자동 배칭으로 대부분의 상태 변경은 1번 렌더로 합쳐집니다. 필요할 때만 &lt;code&gt;flushSync&lt;/code&gt;로 분리하세요.&lt;/li&gt;
&lt;li&gt;성능 최적화의 핵심은 “불필요한 리렌더 원인 제거”입니다. &lt;code&gt;React.memo&lt;/code&gt;, 참조 안정화, 컨텍스트 분할, 비용 큰 계산의 메모이즈를 상황에 맞게 적용하고 Profiler로 검증하세요.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/136</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/React-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81Re-rendering-Trigger-%E2%86%92-Render-%E2%86%92-Commit#entry136comment</comments>
      <pubDate>Fri, 3 Oct 2025 11:05:07 +0900</pubDate>
    </item>
    <item>
      <title>실무에서 꼭 알아야 할 JWT 저장소 보안 패턴과 공격 탐지 방법</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EC%8B%A4%EB%AC%B4%EC%97%90%EC%84%9C-%EA%BC%AD-%EC%95%8C%EC%95%84%EC%95%BC-%ED%95%A0-JWT-%EC%A0%80%EC%9E%A5%EC%86%8C-%EB%B3%B4%EC%95%88-%ED%8C%A8%ED%84%B4%EA%B3%BC-%EA%B3%B5%EA%B2%A9-%ED%83%90%EC%A7%80-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/b199c519-c53b-4ebc-9920-c87b814bff02/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;안녕하세요! 프론트엔드/풀스택 실무에서 자주 부딪히는 “JWT를 어디에 보관할 것인가” 문제를 정리했습니다. 저장 위치별 보안/UX 트레이드오프, 실제로 일어나는 탈취(steal) 시나리오, 그리고 탈취 되었을 때 어떻게 눈치채고 대응할지까지 바로 적용 가능한 체크리스트로 담았습니다.&lt;/p&gt;
&lt;h2&gt;저장 위치별 비교&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;localStorage&lt;/th&gt;
&lt;th&gt;sessionStorage&lt;/th&gt;
&lt;th&gt;Cookie (HttpOnly 권장)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;접근성&lt;/td&gt;
&lt;td&gt;JS로 &lt;code&gt;window.localStorage&lt;/code&gt; 읽기/쓰기&lt;/td&gt;
&lt;td&gt;탭 수명(탭 닫히면 소멸)&lt;/td&gt;
&lt;td&gt;JS 접근 불가(HttpOnly)·자동 전송(도메인/경로 일치 시)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;지속성&lt;/td&gt;
&lt;td&gt;브라우저 종료 후에도 유지&lt;/td&gt;
&lt;td&gt;탭 살아있는 동안만&lt;/td&gt;
&lt;td&gt;만료 시각/세션/영구 선택 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XSS에 대한 노출&lt;/td&gt;
&lt;td&gt;높음(스크립트가 읽을 수 있음)&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;낮음(HttpOnly면 읽기 불가)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSRF 위험&lt;/td&gt;
&lt;td&gt;낮음(자동 전송 안 함)&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;높음(자동 전송) → SameSite/CSRF 토큰 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도메인/경로 스코프&lt;/td&gt;
&lt;td&gt;없음(도메인 전역에서 JS로 접근)&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;세밀하게 설정 가능(domain/path)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;구현 난이도&lt;/td&gt;
&lt;td&gt;쉬움&lt;/td&gt;
&lt;td&gt;쉬움&lt;/td&gt;
&lt;td&gt;쿠키 옵션/CSRF 방어 설계 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;요약 권장 패턴(현업 다수 채택)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Access Token: 메모리에만 보관(새로고침 때 사라져도 OK)&lt;/li&gt;
&lt;li&gt;Refresh Token: HttpOnly + Secure + SameSite 쿠키로 저장 + Refresh Token Rotation&lt;/li&gt;
&lt;li&gt;이유: XSS로부터 Access Token 노출을 줄이고, CSRF는 쿠키 보안옵션과 토큰 방식으로 방어&lt;/li&gt;
&lt;li&gt;참고: local/sessionStorage를 꼭 써야 한다면, 짧은 만료 + 철저한 XSS 방어(CSP/Trusted Types)를 전제로만 고려&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;대표적 탈취 공격 시나리오&lt;/h2&gt;
&lt;h3&gt;A. XSS(교차 사이트 스크립팅)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;대상: localStorage / sessionStorage / 비-HttpOnly 쿠키&lt;/li&gt;
&lt;li&gt;방법: 취약 페이지에 삽입된 스크립트가 &lt;code&gt;localStorage.getItem(&amp;#39;token&amp;#39;)&lt;/code&gt; 등으로 탈취&lt;/li&gt;
&lt;li&gt;완화: 입력 검증/인코딩, CSP(스크립트 소스 화이트리스트), Trusted Types, 라이브러리 취약점 패치, HttpOnly 쿠키 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;B. CSRF(사이트 간 요청 위조)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;대상: 쿠키 기반 인증(자동 전송)&lt;/li&gt;
&lt;li&gt;방법: 피해자 브라우저가 공격자 페이지에서 의도치 않은 인증 요청을 보냄&lt;/li&gt;
&lt;li&gt;완화: SameSite=Lax/Strict, CSRF 토큰(Double Submit / Synchronizer Token), 쿠키에 SameSite·Secure·HttpOnly, 쿠키 경로/도메인 최소화&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;C. 전송 구간 탈취(MITM)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;대상: 모든 저장 방식 (전송 시)&lt;/li&gt;
&lt;li&gt;방법: HTTPS 미사용, 중간자 공격으로 Authorization 헤더/쿠키 가로채기&lt;/li&gt;
&lt;li&gt;완화: HTTPS 강제(HSTS), TLS 최신 설정, 공용망 환경 주의&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;D. Refresh Token 재사용/재발급 악용&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;대상: 장기 수명 Refresh Token&lt;/li&gt;
&lt;li&gt;방법: 한 번 유출되면 지속 액세스 토큰 발급 남용&lt;/li&gt;
&lt;li&gt;완화: Refresh Token Rotation(매번 교체 + 재사용 감지 시 즉시 무효), 짧은 수명 + 기기 바인딩(JTI/디바이스 ID), IP/디바이스 변동 감시&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;E. 로그·리퍼러·서드파티 유출&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;대상: 토큰을 URL 쿼리/프래그먼트로 주고받을 때&lt;/li&gt;
&lt;li&gt;방법: 서버/클라이언트 로그에 남거나 Referer 헤더로 외부 도메인에 유출&lt;/li&gt;
&lt;li&gt;완화: 토큰을 URL에 절대 넣지 않기, 민감 값은 헤더/바디로만, 로깅 필터링&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;F. 브라우저 확장 프로그램/악성 SW&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;대상: local/sessionStorage·비-HttpOnly 쿠키&lt;/li&gt;
&lt;li&gt;방법: 권한 과도한 확장/악성코드가 DOM/Storage 읽기&lt;/li&gt;
&lt;li&gt;완화: HttpOnly 쿠키, 보안 교육/정책, EDR/안티바이러스&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;G. 클릭재킹/XS-Leaks(간접 유출)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;대상: 쿠키 세션/특정 뷰&lt;/li&gt;
&lt;li&gt;완화: X-Frame-Options/frame-ancestors(CSP), 숨김 필드/타이밍·상태 유출 방어&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;탈취되었는지 어떻게 알까? (실무 탐지 시그널)&lt;/h2&gt;
&lt;p&gt;서버·백엔드 관점의 행동 기반 탐지가 핵심입니다.&lt;/p&gt;
&lt;h3&gt;액세스/리프레시 토큰 레벨&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Refresh Token Rotation 재사용 감지: RT는 매 재발급마다 새 토큰으로 교체. 이전 RT가 다시 쓰이면 탈취로 간주 → 전체 세션 무효화 + 계정 보호 플로우 발동&lt;/li&gt;
&lt;li&gt;JTI(토큰 고유 ID)·세션 테이블: 각 RT/세션에 고유 식별자 저장, 만료 전이라도 서버측 블랙리스트/리보크 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;세션/행동 이상치&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;불가능한 이동(Impossible Travel): 짧은 시간 내 물리적으로 불가능한 Geo-IP 이동, 이례적 ASN·프록시 사용&lt;/li&gt;
&lt;li&gt;디바이스/브라우저 지문 급변: User-Agent, 클라이언트 힌트, 해시된 지문 값 급변&lt;/li&gt;
&lt;li&gt;동시 사용 패턴: 동일 계정이 다중 지역/기기에서 동시에 액세스 토큰 재발급 시도&lt;/li&gt;
&lt;li&gt;이상 트래픽/레이트 초과: 비정상적인 로그인 시도, 재발급 엔드포인트로의 급증&lt;/li&gt;
&lt;li&gt;서명/클레임 무결성 실패: 서명 검증 실패, iss/aud/exp/nbf 등 클레임 이상치&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;클라이언트/프론트 관점(보조적)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;“기기 로그아웃됨” 빈번, 사용자 알림/2FA 푸시가 계속 뜸&lt;/li&gt;
&lt;li&gt;저장소에 있던 토큰이 예상치 못하게 무효화(서버가 강제 리보크했을 가능성)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;안전한 설계 패턴(권장 레시피)&lt;/h2&gt;
&lt;h3&gt;패턴 A: “메모리 AT + HttpOnly RT”&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Access Token(짧은 수명): 앱 메모리만 저장&lt;/li&gt;
&lt;li&gt;Refresh Token(길게, Rotation): HttpOnly + Secure + SameSite=Lax/Strict 쿠키&lt;/li&gt;
&lt;li&gt;이점: XSS로 AT 직접 유출 어려움, CSRF는 쿠키 보안옵션+CSRF 토큰으로 방어&lt;/li&gt;
&lt;li&gt;주의: 새로고침 시 AT 사라지므로 Silent Refresh 흐름 구현 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;패턴 B: 전부 쿠키 기반(AT/RT 둘 다 쿠키, AT는 짧게)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;전달: 쿠키 자동 전송&lt;/li&gt;
&lt;li&gt;필수: SameSite + CSRF 토큰(폼/헤더-기반 대조), 경로/도메인 최소화&lt;/li&gt;
&lt;li&gt;장점: SSR/MPA 친화&lt;/li&gt;
&lt;li&gt;단점: CSRF 설계 복잡도↑&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(선택) 쿠키 하드닝 팁&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;HttpOnly, Secure, SameSite=Lax/Strict&lt;/li&gt;
&lt;li&gt;__Host- 프리픽스(도메인 지정 금지+Secure+Path=/ 조합)&lt;/li&gt;
&lt;li&gt;민감 엔드포인트는 CORS 엄격·프리플라이트 필수&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;실전 방어 체크리스트&lt;/h2&gt;
&lt;h3&gt;앱/프론트&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;토큰을 URL에 절대 담지 않기&lt;/li&gt;
&lt;li&gt;CSP(스크립트 소스 화이트리스트) + Trusted Types&lt;/li&gt;
&lt;li&gt;라이브러리/빌드 체인 취약점 정기 패치&lt;/li&gt;
&lt;li&gt;서드파티 스크립트·확장 권한 최소화&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;쿠키/세션&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;HttpOnly / Secure / SameSite 필수&lt;/li&gt;
&lt;li&gt;__Host- 프리픽스 고려, 도메인/경로 최소 권한&lt;/li&gt;
&lt;li&gt;CSRF 토큰(Double Submit / 상태값-검증형) + Referrer/Origin 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;토큰 수명/회전&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;AT 매우 짧게(분 단위), RT는 Rotation + 재사용 탐지&lt;/li&gt;
&lt;li&gt;jti/세션 테이블로 서버측 리보크 가능하게&lt;/li&gt;
&lt;li&gt;고위험 액션은 재인증/2FA 요구&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;네트워크/플랫폼&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;HTTPS 강제(HSTS), 최신 TLS&lt;/li&gt;
&lt;li&gt;레이트리밋·IP/ASN 평판 기반 차단&lt;/li&gt;
&lt;li&gt;보안 로깅(개인정보 마스킹), SIEM 연동&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;탈취 의심 시 “대응 프로토콜”&lt;/h2&gt;
&lt;h3&gt;즉시 리스크 차단&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;해당 계정의 모든 Refresh Token 무효화(세션 테이블/블랙리스트)&lt;/li&gt;
&lt;li&gt;재사용된 RT 발견 시 계정 보호 모드(강제 로그아웃, 비밀번호/2FA 재설정)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;조사 &amp;amp; 범위 파악&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;로그로 유출 시점·경로 추정(XSS, URL 누출, RT 재사용 IP 등)&lt;/li&gt;
&lt;li&gt;동일 패턴 다른 계정 영향 검사&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;취약점 핫픽스 + 재발 방지&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;CSP/TT 강화, 취약 입력 지점 패치, 토큰 수명·회전 정책 재점검&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;사용자 커뮤니케이션&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;안전한 공지·가이드(의심 로그인 내역, 재인증 안내)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;코드 스니펫 모음&lt;/h2&gt;
&lt;h3&gt;(서버) 쿠키 설정 예시 (Node/Express)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;res.cookie(&amp;#39;__Host-rt&amp;#39;, refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: &amp;#39;lax&amp;#39;,   // 민감도에 따라 &amp;#39;strict&amp;#39; 고려
  path: &amp;#39;/&amp;#39;,         // __Host- 프리픽스는 path=/ 필수
  maxAge: 1000 * 60 * 60 * 24 * 7, // 7일 등
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(서버) CSRF Double-Submit 패턴 핵심&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 1) 로그인/초기 로드 시 CSRF 토큰 발급(쿠키와 응답 바디/헤더에 동시에)
res.cookie(&amp;#39;csrf&amp;#39;, csrfToken, { httpOnly: false, sameSite: &amp;#39;lax&amp;#39;, secure: true });

// 2) 클라이언트는 민감요청에 헤더로 전송
fetch(&amp;#39;/api/transfer&amp;#39;, {
  method: &amp;#39;POST&amp;#39;,
  headers: { &amp;#39;X-CSRF-Token&amp;#39;: readFromCookie(&amp;#39;csrf&amp;#39;) },
  body: JSON.stringify(payload),
});

// 3) 서버에서 쿠키값과 헤더값 일치 확인 + Origin/Referer 체크&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(서버) Refresh Token Rotation 감지(개념)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// RT 테이블: { userId, tokenHash, revoked:boolean, createdAt, replacedById, lastUsedAt }
function rotateRefreshToken(oldRtId, userId) {
  // 1) oldRtId 사용해 새 RT 발급 + oldRt.replacedById = newRtId
  // 2) 요청 시 전달된 RT가 이미 replacedById를 가진 토큰이면 -&amp;gt; 재사용 탐지
  //    =&amp;gt; 계정 보호 모드 + 전체 세션 리보크
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;결론: “완벽한 곳은 없다, 조합이 답”&lt;/h2&gt;
&lt;p&gt;XSS vs CSRF는 보안 축이 다릅니다. 대다수 SPA/SSR 서비스는 “AT는 메모리”, “RT는 HttpOnly 쿠키 + Rotation”으로 XSS·CSRF를 동시에 관리합니다. 여기에 짧은 토큰 수명, CSP/Trusted Types, SIEM 기반 이상행동 탐지를 결합할 때 탈취 확률은 낮아지고, 탈취 시에도 빠르게 감지·차단할 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;핵심 요약&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;보관 위치 결론: AT는 메모리, RT는 HttpOnly 쿠키(+Rotation)&lt;/li&gt;
&lt;li&gt;XSS vs CSRF: 서로 다른 축이므로 함께 고려&lt;/li&gt;
&lt;li&gt;핵심 방어: SameSite/CSRF 토큰, 짧은 수명, Rotation, CSP/TT, HSTS/TLS&lt;/li&gt;
&lt;li&gt;탐지 시그널: RT 재사용, 불가능한 이동, 지문 급변, 레이트 초과&lt;/li&gt;
&lt;li&gt;대응 프로토콜: 전 계정 RT 무효화 → 조사 → 핫픽스 → 사용자 안내&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/135</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EC%8B%A4%EB%AC%B4%EC%97%90%EC%84%9C-%EA%BC%AD-%EC%95%8C%EC%95%84%EC%95%BC-%ED%95%A0-JWT-%EC%A0%80%EC%9E%A5%EC%86%8C-%EB%B3%B4%EC%95%88-%ED%8C%A8%ED%84%B4%EA%B3%BC-%EA%B3%B5%EA%B2%A9-%ED%83%90%EC%A7%80-%EB%B0%A9%EB%B2%95#entry135comment</comments>
      <pubDate>Sat, 13 Sep 2025 11:00:40 +0900</pubDate>
    </item>
    <item>
      <title>Next.js SSR 페이지 풀 페이지 캐싱</title>
      <link>https://white-mouse-dev.tistory.com/entry/Nextjs-SSR-%ED%8E%98%EC%9D%B4%EC%A7%80-%ED%92%80-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%BA%90%EC%8B%B1</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/66df2eb9-20e1-4a98-a005-82a0f586fa82/image.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;서론&lt;/h2&gt;
&lt;p&gt;이번 글에서는 아직 다루지 않은 &lt;strong&gt;SSR(Server-Side Rendering) 페이지의 풀 페이지 캐싱&lt;/strong&gt;에 대해 이야기합니다.&lt;/p&gt;
&lt;p&gt;SSR은 매 요청마다 서버에서 HTML을 생성하므로 항상 최신 데이터를 보장하지만, 그만큼 성능 부담이 큽니다.&lt;br&gt;그렇다면 &lt;strong&gt;SSR 페이지를 캐싱하면서도 신선함을 유지할 수 있는 방법&lt;/strong&gt;은 무엇일까요?&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;SSR의 본질 (Next.js 15 기준)&lt;/h2&gt;
&lt;h3&gt;SSR이 하는 일&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;매 요청마다 서버에서 HTML 생성&lt;/li&gt;
&lt;li&gt;데이터는 요청 시점에 가져옴&lt;/li&gt;
&lt;li&gt;SEO 친화적, 항상 최신 상태 반영&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;적합한 경우&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;자주 변하는 데이터&lt;/li&gt;
&lt;li&gt;사용자별 맞춤형 페이지&lt;/li&gt;
&lt;li&gt;인증 필요 페이지&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;성능 문제가 발생하는 경우&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;모든 요청이 DB/API 호출을 동반&lt;/li&gt;
&lt;li&gt;고트래픽 시 서버 부하 급증&lt;/li&gt;
&lt;li&gt;TTFB(Time to First Byte) 지연&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;따라서 풀 페이지 캐싱은 필수적인 최적화 전략이 됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;풀 페이지 캐싱이 필요한 경우&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;데이터 갱신 주기가 짧지만 실시간은 아님&lt;/strong&gt;&lt;br&gt;예: 상품 상세 페이지, 블로그 상세, 마케팅 페이지&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;비로그인 대량 트래픽&lt;/strong&gt;&lt;br&gt;로그인 상태가 필요 없는 페이지는 동일 캐시를 공유 가능&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;복잡한 백엔드 로직을 거치는 경우&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;글로벌 서비스&lt;/strong&gt;&lt;br&gt;CDN과 엣지 캐싱을 활용해 전 세계 어디서나 빠른 응답 제공&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;캐싱하면 안 되는 경우:&lt;/strong&gt;&lt;br&gt;실시간 주식 시세, 관리자 대시보드처럼 매 요청마다 다른 데이터가 필요한 경우&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;SSR 캐싱 전략&lt;/h2&gt;
&lt;h3&gt;1. CDN + Cache-Control 헤더&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/product/[slug]/page.tsx
import { headers } from &amp;#39;next/headers&amp;#39;;
import { fetchProductBySlug } from &amp;#39;@/lib/data&amp;#39;;

export const dynamic = &amp;#39;force-dynamic&amp;#39;;

export default async function ProductPage({ params }) {
  const { slug } = params;
  const product = await fetchProductBySlug(slug);

  const responseHeaders = headers();
  responseHeaders.set(
    &amp;#39;Cache-Control&amp;#39;,
    &amp;#39;public, s-maxage=60, stale-while-revalidate=120&amp;#39;
  );

  return (
    &amp;lt;main&amp;gt;
      &amp;lt;h1&amp;gt;{product.title}&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;{product.description}&amp;lt;/p&amp;gt;
      &amp;lt;p&amp;gt;Price: ${product.price}&amp;lt;/p&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;s-maxage=60&lt;/code&gt;: CDN에서 60초 동안 캐시 유지  &lt;/li&gt;
&lt;li&gt;&lt;code&gt;stale-while-revalidate=120&lt;/code&gt;: 만료 후에도 최대 120초 동안은 오래된 버전을 즉시 제공하며 백그라운드에서 새로 갱신&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;  Vercel, Netlify 등 CDN 기반 배포 환경에서 가장 간단하고 효과적인 방법&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;2. Redis 등 외부 저장소 활용&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;요청 URL을 키로 사용&lt;/li&gt;
&lt;li&gt;첫 요청 시 SSR 결과를 Redis에 저장&lt;/li&gt;
&lt;li&gt;TTL(Time to Live) 설정으로 신선도 유지&lt;/li&gt;
&lt;li&gt;주로 &lt;strong&gt;커스텀 서버 환경&lt;/strong&gt;에서 활용&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;3. Edge Middleware + 변형된 캐시 키&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// middleware.ts
import { NextResponse } from &amp;#39;next/server&amp;#39;;
import type { NextRequest } from &amp;#39;next/server&amp;#39;;

export const config = { matcher: [&amp;#39;/product/:path*&amp;#39;] };

export function middleware(request: NextRequest) {
  const geo = request.geo?.country || &amp;#39;US&amp;#39;;
  const url = request.nextUrl.clone();
  url.pathname = `/${geo.toLowerCase()}${url.pathname}`;

  return NextResponse.rewrite(url, {
    headers: {
      &amp;#39;Cache-Control&amp;#39;: &amp;#39;public, s-maxage=60, stale-while-revalidate=120&amp;#39;,
    },
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  국가별, A/B 테스트, 언어별 변형된 페이지를 캐싱할 때 활용&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;4. ISR (Incremental Static Regeneration) 조합&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/[slug]/page.tsx
import { getPost } from &amp;#39;@/lib/posts&amp;#39;;

export const revalidate = 60; // 60초마다 재생성

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  return (
    &amp;lt;article&amp;gt;
      &amp;lt;h1&amp;gt;{post.title}&amp;lt;/h1&amp;gt;
      &amp;lt;div dangerouslySetInnerHTML={{ __html: post.content }} /&amp;gt;
    &amp;lt;/article&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;최초 요청 시 정적 페이지 제공&lt;/li&gt;
&lt;li&gt;만료 후 첫 요청에서 새 버전 재생성 (백그라운드에서 처리)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;  빠른 응답 + 일정 수준의 신선도 유지 가능&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;디버깅 방법 (Vercel 기준)&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;헤더 확인&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -I https://your-domain.com/page&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;x-vercel-cache: HIT | MISS | STALE&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;지역별 테스트&lt;/strong&gt;&lt;br&gt;VPN 또는 Vercel CLI 사용&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;캐시 우회 요소 확인&lt;/strong&gt;  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;세션 쿠키, force-dynamic 등&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Analytics 활용&lt;/strong&gt;  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;캐시 적중률, TTFB, 지역별 지연 시간 확인 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;Best Practice&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;no-store&lt;/code&gt;는 진짜 필요할 때만 사용&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stale-while-revalidate&lt;/code&gt; 적극 활용&lt;/li&gt;
&lt;li&gt;불필요한 쿠키 제거&lt;/li&gt;
&lt;li&gt;캐시 주기는 실제 데이터 갱신 주기 기반으로 설정&lt;/li&gt;
&lt;li&gt;캐시 변형은 꼭 필요한 수준까지만 (geo, 언어 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;SSR 페이지를 캐싱한다는 것은 모순처럼 보일 수 있지만, 사실은 대규모 트래픽을 안정적으로 처리하기 위한 핵심 전략입니다.  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cache-Control 헤더  &lt;/li&gt;
&lt;li&gt;Redis 등 외부 캐시  &lt;/li&gt;
&lt;li&gt;Edge Middleware  &lt;/li&gt;
&lt;li&gt;ISR 조합  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;을 적절히 활용하면, &lt;strong&gt;최신성과 성능을 모두 잡는 SSR 페이지&lt;/strong&gt;를 구현할 수 있습니다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/134</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/Nextjs-SSR-%ED%8E%98%EC%9D%B4%EC%A7%80-%ED%92%80-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%BA%90%EC%8B%B1#entry134comment</comments>
      <pubDate>Mon, 8 Sep 2025 12:57:26 +0900</pubDate>
    </item>
    <item>
      <title>useEffect에서 setInterval이 상태를 못 따라오는 이유 (stale closure)</title>
      <link>https://white-mouse-dev.tistory.com/entry/useEffect%EC%97%90%EC%84%9C-setInterval%EC%9D%B4-%EC%83%81%ED%83%9C%EB%A5%BC-%EB%AA%BB-%EB%94%B0%EB%9D%BC%EC%98%A4%EB%8A%94-%EC%9D%B4%EC%9C%A0-stale-closure</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/82c70370-195f-4263-b31b-36de4fc59808/image.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;리액트에서 &lt;code&gt;useEffect&lt;/code&gt;와 &lt;code&gt;setInterval&lt;/code&gt;을 함께 쓰다 보면, 분명 1초마다 증가시키라고 했는데 상태가 갱신되지 않거나 0에 멈춰 있는 현상을 자주 만납니다. 원인은 대부분 “stale closure(오래된 클로저)” 입니다. 핵심만 간단히 정리합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;TL;DR&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;문제 원인&lt;/strong&gt;: 빈 의존성 배열 &lt;code&gt;[]&lt;/code&gt;로 등록한 이펙트는 최초 렌더의 &lt;code&gt;count&lt;/code&gt;를 클로저로 캡처합니다. 그 뒤 타이머 콜백은 계속 “초기값”만 봅니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;정석 해결&lt;/strong&gt;: 다음 상태가 이전 상태에 의존하면, 항상 함수형 업데이트 &lt;code&gt;setState(prev =&amp;gt; ...)&lt;/code&gt;를 사용하세요.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;필수&lt;/strong&gt;: 타이머는 반드시 정리(cleanup)합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;문제 코드&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;  import { useEffect, useState } from &amp;#39;react&amp;#39;

  export default function CounterBroken() {
    const [count, setCount] = useState(0)

    useEffect(() =&amp;gt; {
      const id = setInterval(() =&amp;gt; {
        setCount(count + 1) // 초기 렌더의 count(=0)만 본 채 고정
      }, 1000)
      return () =&amp;gt; clearInterval(id)
    }, [])

    return &amp;lt;div&amp;gt;Count: {count}&amp;lt;/div&amp;gt;
  }&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;원인: stale closure&lt;/h3&gt;
&lt;p&gt;의존성 배열에 &lt;code&gt;count&lt;/code&gt;를 넣지 않으면, 이펙트 안의 콜백은 최초 렌더 시점의 &lt;code&gt;count&lt;/code&gt;를 닫아 캡처합니다. 이후 재렌더가 일어나도 콜백이 참조하는 &lt;code&gt;count&lt;/code&gt;는 업데이트되지 않습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;해결 1: 함수형 업데이트 사용(권장)&lt;/h3&gt;
&lt;p&gt;다음 상태가 이전 상태에 기반하면 함수형 업데이트가 가장 간결하고 안전합니다. 의존성 배열을 &lt;code&gt;[]&lt;/code&gt;로 두어도 안전합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;  import { useEffect, useState } from &amp;#39;react&amp;#39;

  export default function Counter() {
    const [count, setCount] = useState(0)

    useEffect(() =&amp;gt; {
      const id = setInterval(() =&amp;gt; {
        setCount(prev =&amp;gt; prev + 1)
      }, 1000)
      return () =&amp;gt; clearInterval(id)
    }, [])

    return &amp;lt;div&amp;gt;Count: {count}&amp;lt;/div&amp;gt;
  }&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;해결 2: 의존성에 상태를 넣고 정리하기(대안)&lt;/h3&gt;
&lt;p&gt;상태를 의존성에 포함하면, 이펙트가 매 업데이트마다 재등록되며 “최신 상태”를 참조합니다. 단, 매번 타이머가 재생성되니 필요에 따라 &lt;code&gt;setTimeout&lt;/code&gt; 패턴이 더 적합할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;  import { useEffect, useState } from &amp;#39;react&amp;#39;

  export default function CounterWithDeps() {
    const [count, setCount] = useState(0)

    useEffect(() =&amp;gt; {
      const id = setTimeout(() =&amp;gt; setCount(count + 1), 1000)
      return () =&amp;gt; clearTimeout(id)
    }, [count])

    return &amp;lt;div&amp;gt;Count: {count}&amp;lt;/div&amp;gt;
  }&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;Best Practice&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;이전 상태 의존 시 함수형 업데이트&lt;/strong&gt;: &lt;code&gt;setX(prev =&amp;gt; ...)&lt;/code&gt;는 stale closure 문제를 깔끔히 해결합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;항상 정리&lt;/strong&gt;: &lt;code&gt;setInterval&lt;/code&gt;/&lt;code&gt;setTimeout&lt;/code&gt;은 반드시 cleanup으로 해제합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;불필요한 재생성 피하기&lt;/strong&gt;: 단순 누적 카운트에는 해결 1이 더 단순하고 정확합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;제어가 필요한 경우 ref 활용&lt;/strong&gt;: 일시정지/재개 등 제어가 필요하면 &lt;code&gt;useRef&lt;/code&gt;에 타이머 id를 보관하세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;한 줄 요약&lt;/h3&gt;
&lt;p&gt;“상태가 이전 값에 의존하면 함수형 업데이트를 쓰고, 타이머는 반드시 정리한다.” 이것만 지키면 &lt;code&gt;useEffect + 타이머&lt;/code&gt;의 대부분 문제를 피할 수 있습니다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/133</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/useEffect%EC%97%90%EC%84%9C-setInterval%EC%9D%B4-%EC%83%81%ED%83%9C%EB%A5%BC-%EB%AA%BB-%EB%94%B0%EB%9D%BC%EC%98%A4%EB%8A%94-%EC%9D%B4%EC%9C%A0-stale-closure#entry133comment</comments>
      <pubDate>Sun, 7 Sep 2025 20:56:00 +0900</pubDate>
    </item>
    <item>
      <title>React Error Boundary: 왜 아직도 클래스일까?</title>
      <link>https://white-mouse-dev.tistory.com/entry/React-Error-Boundary-%EC%99%9C-%EC%95%84%EC%A7%81%EB%8F%84-%ED%81%B4%EB%9E%98%EC%8A%A4%EC%9D%BC%EA%B9%8C</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/22ae4cf0-2276-48e3-b24d-c9fe8572f9b1/image.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;리액트 프로젝트를 하다 보면 꼭 한 번은 만나게 되는 상황이 있습니다.&lt;br&gt;컴포넌트 트리 어딘가에서 에러가 터지면, 앱 전체가 그대로 하얀 화면(white&lt;br&gt;screen of death)이 되어버리는 순간이죠.&lt;br&gt;이럴 때 사용자를 보호해주는 최후의 안전망이 바로 &lt;strong&gt;Error&lt;br&gt;Boundary&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;p&gt;그런데 재미있는 사실 하나. 함수 컴포넌트 전성시대인 지금도, Error&lt;br&gt;Boundary만은 &lt;strong&gt;클래스 컴포넌트로만 작성&lt;/strong&gt;해야 합니다.&lt;br&gt;&amp;quot;왜 아직도 클래스일까?&amp;quot; 오늘은 그 이유와 한계, 그리고 실전 적용 팁을&lt;br&gt;정리해봅니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;TL;DR (Too Long; Didn’t Read)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Error Boundary는 여전히 &lt;strong&gt;클래스 전용&lt;br&gt;라이프사이클&lt;/strong&gt;(&lt;code&gt;getDerivedStateFromError&lt;/code&gt;, &lt;code&gt;componentDidCatch&lt;/code&gt;)로만&lt;br&gt;지원됩니다.&lt;/li&gt;
&lt;li&gt;함수형 훅으로 대체할 수 없고, 필요하다면 &lt;code&gt;react-error-boundary&lt;/code&gt; 같은&lt;br&gt;라이브러리를 사용하세요.&lt;/li&gt;
&lt;li&gt;잡을 수 있는 에러 범위는 제한적입니다: 렌더링/라이프사이클/자식&lt;br&gt;생성자.&lt;br&gt;이벤트 핸들러, 비동기 콜백, SSR 단계 오류는 잡지 못합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Error Boundary란 무엇인가&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;문제&lt;/strong&gt;: 자식 컴포넌트에서 발생한 에러 하나 때문에 앱 전체가 무너질&lt;br&gt;수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;해결&lt;/strong&gt;: Error Boundary는 하위 트리에서 발생한 렌더링 에러를&lt;br&gt;포착해, &lt;strong&gt;Fallback UI&lt;/strong&gt;를 보여줍니다.&lt;br&gt;→ 사용자에게 최소한 &amp;quot;앗, 오류가 발생했어요&amp;quot; 같은 화면을 보장해 주는&lt;br&gt;셈이죠.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;왜 클래스여야 할까?&lt;/h2&gt;
&lt;p&gt;React는 에러 처리를 위해 두 가지 클래스 전용 메서드를 제공합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;class ErrorBoundary extends Component&amp;lt;Props, State&amp;gt; {
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.error(&amp;#39;Error caught by boundary:&amp;#39;, error, errorInfo);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;getDerivedStateFromError&lt;/code&gt;&lt;/strong&gt;: 렌더 단계에서 에러 발생 시 호출 →&lt;br&gt;상태를 에러 모드로 전환 → fallback UI 렌더링&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;componentDidCatch&lt;/code&gt;&lt;/strong&gt;: 커밋 이후 호출 → 로깅/리포팅에 활용(Sentry&lt;br&gt;등)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;함수형 컴포넌트에서는 이 과정을 대체할 &lt;strong&gt;공식 훅이 없습니다.&lt;/strong&gt;&lt;br&gt;&lt;code&gt;try/catch&lt;/code&gt;나 &lt;code&gt;useEffect&lt;/code&gt;로는 렌더 단계 예외를 잡을 수 없고, Suspense도&lt;br&gt;목적이 다르죠.&lt;br&gt;그래서 지금도 Error Boundary는 &lt;strong&gt;클래스&lt;/strong&gt;가 정석입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;실전 구현 포인트&lt;/h2&gt;
&lt;p&gt;아래는 가장 단순한 형태의 Error Boundary입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { Component, type ErrorInfo, type ReactNode } from &amp;#39;react&amp;#39;;

class ErrorBoundary extends Component&amp;lt;{ children: ReactNode; fallback?: ReactNode }, { hasError: boolean; error: Error | null }&amp;gt; {
  state = { hasError: false, error: null as Error | null };

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error(&amp;#39;Error caught by boundary:&amp;#39;, error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? (
        &amp;lt;div&amp;gt;
          &amp;lt;h2&amp;gt;오류가 발생했습니다&amp;lt;/h2&amp;gt;
          &amp;lt;p&amp;gt;{this.state.error?.message ?? &amp;#39;알 수 없는 오류&amp;#39;}&amp;lt;/p&amp;gt;
          &amp;lt;button onClick={() =&amp;gt; this.setState({ hasError: false, error: null })}&amp;gt;다시 시도&amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      );
    }
    return this.props.children;
  }
}

export default ErrorBoundary;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;특징&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;hasError&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt; 상태를 로컬에서 관리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;props.fallback&lt;/code&gt;이 있으면 우선 사용&lt;/li&gt;
&lt;li&gt;&amp;quot;다시 시도&amp;quot; 버튼 → 상태 초기화 후 재렌더링 시도&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;어떻게 쓰나?&lt;/h2&gt;
&lt;p&gt;일반적으로는 &lt;strong&gt;페이지나 큰 섹션 단위&lt;/strong&gt;로 감싸는 방식이 가장&lt;br&gt;효과적입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import ErrorBoundary from &amp;#39;@/components/ErrorBoundary&amp;#39;;
import Dashboard from &amp;#39;@/pages/Dashboard&amp;#39;;

export default function App() {
  return (
    &amp;lt;ErrorBoundary&amp;gt;
      &amp;lt;Dashboard /&amp;gt;
    &amp;lt;/ErrorBoundary&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 하면 특정 페이지에서만 오류가 발생했을 때, 다른 페이지로 전파되지&lt;br&gt;않도록 막을 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;함수형으로 쓰고 싶다면?&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;react-error-boundary&lt;/code&gt;라는 라이브러리를 추천합니다.&lt;br&gt;내부적으로는 여전히 클래스 기반 Error Boundary를 사용하지만, &lt;strong&gt;함수형&lt;br&gt;API&lt;/strong&gt;를 제공합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { ErrorBoundary } from &amp;#39;react-error-boundary&amp;#39;;

function Fallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () =&amp;gt; void }) {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h2&amp;gt;오류가 발생했습니다&amp;lt;/h2&amp;gt;
      &amp;lt;p&amp;gt;{error.message}&amp;lt;/p&amp;gt;
      &amp;lt;button onClick={resetErrorBoundary}&amp;gt;다시 시도&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default function App() {
  return (
    &amp;lt;ErrorBoundary
      FallbackComponent={Fallback}
      onReset={() =&amp;gt; {
        // 상태 초기화, 캐시 정리 등 복구 로직
      }}
    &amp;gt;
      &amp;lt;Dashboard /&amp;gt;
    &amp;lt;/ErrorBoundary&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;Error Boundary가 못 잡는 것들&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;이벤트 핸들러 내부 에러 → 직접 &lt;code&gt;try/catch&lt;/code&gt; 필요&lt;/li&gt;
&lt;li&gt;비동기 콜백(setTimeout, Promise) → 바운더리 밖&lt;/li&gt;
&lt;li&gt;SSR 단계 오류 → 서버 측 처리 필요&lt;/li&gt;
&lt;li&gt;자기 자신 내부의 에러 → 자식 트리만 보호 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Best Practice&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;전역 + 로컬 레벨링&lt;/strong&gt;: 앱 전체를 감싸는 글로벌 바운더리 +&lt;br&gt;페이지/피처 단위 로컬 바운더리 함께 사용&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;안정적인 Fallback&lt;/strong&gt;: 의존성 적고 실패 확률이 낮은 UI 사용&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;로깅 연동&lt;/strong&gt;: &lt;code&gt;componentDidCatch&lt;/code&gt;에서 사용자 컨텍스트, 라우트,&lt;br&gt;릴리즈 버전 등 함께 전송&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;복구 전략&lt;/strong&gt;: &amp;quot;다시 시도&amp;quot; 시 캐시 초기화, 상태 리셋, 데이터&lt;br&gt;재요청까지 고려&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;Error Boundary는 &lt;strong&gt;여전히 클래스 전용 기능&lt;/strong&gt;입니다.&lt;br&gt;&amp;quot;왜 클래스냐?&amp;quot;라는 질문에 대한 답은 단순합니다:&lt;br&gt;React가 제공하는 에러 복구 메커니즘이 클래스 라이프사이클에만 존재하기&lt;br&gt;때문입니다.&lt;/p&gt;
&lt;p&gt;함수형 API를 쓰고 싶다면 &lt;code&gt;react-error-boundary&lt;/code&gt; 같은 라이브러리를&lt;br&gt;활용하세요.&lt;br&gt;중요한 건 &lt;strong&gt;클래스 기반이라는 원리를 이해하고&lt;/strong&gt;, 실제 서비스에서는&lt;br&gt;올바르게 배치하고 로깅/복구 전략까지 함께 세우는 것입니다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/132</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/React-Error-Boundary-%EC%99%9C-%EC%95%84%EC%A7%81%EB%8F%84-%ED%81%B4%EB%9E%98%EC%8A%A4%EC%9D%BC%EA%B9%8C#entry132comment</comments>
      <pubDate>Sun, 7 Sep 2025 20:49:09 +0900</pubDate>
    </item>
    <item>
      <title>setTimeout vs Promise.then vs queueMicrotask</title>
      <link>https://white-mouse-dev.tistory.com/entry/setTimeout-vs-Promisethen-vs-queueMicrotask</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/1dbbcd39-456d-4322-8d15-06bc8716a7f8/image.gif&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;브라우저/Node.js 런타임에서 비동기 코드의 실행 순서를 정확히 이해하는 것은 디버깅과 성능 최적화의 출발점입니다. 특히 마이크로태스크 큐는 렌더링 타이밍과 상태 일관성에 직접적인 영향을 주므로, 실무에서 정확한 모델을 갖추는 것이 중요합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;핵심 개념 정리&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Call Stack: 자바스크립트가 함수를 동기적으로 실행하는 스택.&lt;/li&gt;
&lt;li&gt;Web APIs(환경): 타이머, DOM, Fetch 등 비동기 작업이 대기하는 영역.&lt;/li&gt;
&lt;li&gt;Task Queue(매크로태스크 큐): &lt;code&gt;setTimeout&lt;/code&gt;, &lt;code&gt;setInterval&lt;/code&gt;, &lt;code&gt;setImmediate(Node)&lt;/code&gt; 등이 들어가는 큐.&lt;/li&gt;
&lt;li&gt;Microtask Queue(마이크로태스크 큐): &lt;code&gt;Promise.then/catch/finally&lt;/code&gt;, &lt;code&gt;queueMicrotask&lt;/code&gt;, &lt;code&gt;MutationObserver&lt;/code&gt; 등이 들어가는 큐.&lt;/li&gt;
&lt;li&gt;Event Loop 규칙(중요):&lt;br&gt;1) 콜스택이 빌 때까지 동기 코드 실행&lt;br&gt;2) Microtask Queue를 “완전히” 비울 때까지 실행&lt;br&gt;3) 그 다음 Task Queue에서 작업 1개를 실행(그리고 다시 2로)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;요약하면, “마이크로태스크는 같은 틱에서 모두 비워진 뒤에야 다음 매크로태스크로 넘어간다”가 핵심입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;예제 코드&lt;/h2&gt;
&lt;p&gt;아래 코드는 &lt;code&gt;test.js&lt;/code&gt;와 동일합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;setTimeout(() =&amp;gt; {
    console.log(&amp;#39;A&amp;#39;);
}, 0);

console.log(&amp;#39;B&amp;#39;);

Promise.resolve().then(() =&amp;gt; {
    console.log(&amp;#39;C&amp;#39;);
});

queueMicrotask(() =&amp;gt; {
    console.log(&amp;#39;D&amp;#39;);
});

console.log(&amp;#39;E&amp;#39;);&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;기대 출력(브라우저 기준)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;B
E
C
D
A&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;단계별 실행 타임라인&lt;/h2&gt;
&lt;p&gt;1) &lt;code&gt;setTimeout(..., 0)&lt;/code&gt; 등록 → Web APIs로 타이머 위임 → 만료 후 Task Queue에 콜백(&lt;code&gt;A&lt;/code&gt;) 대기&lt;/p&gt;
&lt;p&gt;2) 동기 코드 실행: &lt;code&gt;console.log(&amp;#39;B&amp;#39;)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;3) &lt;code&gt;Promise.resolve().then(...)&lt;/code&gt; → Microtask Queue에 콜백(&lt;code&gt;C&lt;/code&gt;) 등록&lt;/p&gt;
&lt;p&gt;4) &lt;code&gt;queueMicrotask(...)&lt;/code&gt; → Microtask Queue에 콜백(&lt;code&gt;D&lt;/code&gt;) 등록&lt;/p&gt;
&lt;p&gt;5) 동기 코드 실행: &lt;code&gt;console.log(&amp;#39;E&amp;#39;)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;6) 콜스택이 비면 Event Loop가 Microtask Queue를 “모두” 비움 → &lt;code&gt;C&lt;/code&gt; → &lt;code&gt;D&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;7) 다음 틱에서 Task Queue의 &lt;code&gt;A&lt;/code&gt; 실행 → &lt;code&gt;console.log(&amp;#39;A&amp;#39;)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;정리: 동기(B, E) → 마이크로태스크(C, D) → 매크로태스크(A)&lt;/p&gt;
&lt;p&gt;마이크로태스크 내부 순서는 “등록된 순서”입니다. 위 코드에서는 &lt;code&gt;Promise.then&lt;/code&gt;이 먼저, &lt;code&gt;queueMicrotask&lt;/code&gt;가 나중에 등록되어 &lt;code&gt;C&lt;/code&gt; → &lt;code&gt;D&lt;/code&gt; 순으로 실행됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;비교 표: 어떤 큐로 가는가, 언제 실행되는가&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;setTimeout&lt;/th&gt;
&lt;th&gt;Promise.then&lt;/th&gt;
&lt;th&gt;queueMicrotask&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;큐 유형&lt;/td&gt;
&lt;td&gt;Task(매크로태스크)&lt;/td&gt;
&lt;td&gt;Microtask&lt;/td&gt;
&lt;td&gt;Microtask&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 시점&lt;/td&gt;
&lt;td&gt;다음 틱 이후&lt;/td&gt;
&lt;td&gt;현재 틱의 콜스택 종료 직후&lt;/td&gt;
&lt;td&gt;현재 틱의 콜스택 종료 직후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;순서 보장&lt;/td&gt;
&lt;td&gt;동일 지연이면 상대적&lt;/td&gt;
&lt;td&gt;FIFO(등록 순)&lt;/td&gt;
&lt;td&gt;FIFO(등록 순)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;렌더링 영향&lt;/td&gt;
&lt;td&gt;보통 렌더 후 실행&lt;/td&gt;
&lt;td&gt;렌더 전 마이크로태스크가 모두 비워짐&lt;/td&gt;
&lt;td&gt;렌더 전 마이크로태스크가 모두 비워짐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;에러 전파&lt;/td&gt;
&lt;td&gt;타이머 콜백에서 throw 시 비동기 에러&lt;/td&gt;
&lt;td&gt;잡히지 않으면 전역 Unhandled Rejection&lt;/td&gt;
&lt;td&gt;잡히지 않으면 전역 에러&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;취소&lt;/td&gt;
&lt;td&gt;&lt;code&gt;clearTimeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;불가(체인 취소는 별도 로직)&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대표 용도&lt;/td&gt;
&lt;td&gt;지연 실행, 렌더 이후 작업&lt;/td&gt;
&lt;td&gt;비동기 후크/체인&lt;/td&gt;
&lt;td&gt;매우 짧은 후처리, 동 틱 내 정합 보장&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;실무 팁과 주의사항&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;마이크로태스크 “고갈” 규칙을 이용해 일관성 보장&lt;ul&gt;
&lt;li&gt;동일 틱 내 후처리는 &lt;code&gt;queueMicrotask&lt;/code&gt;가 가장 명확합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;과도한 마이크로태스크 생성은 이벤트 루프 기아를 유발&lt;ul&gt;
&lt;li&gt;무한 재귀적 마이크로태스크는 렌더링을 막습니다. 반드시 종료 조건을 두세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Promise.then&lt;/code&gt; vs &lt;code&gt;queueMicrotask&lt;/code&gt;&lt;ul&gt;
&lt;li&gt;둘 다 마이크로태스크이지만, 체인 작성과 에러 핸들링이 필요하면 &lt;code&gt;Promise&lt;/code&gt;가 편리합니다. 아주 경량 후처리는 &lt;code&gt;queueMicrotask&lt;/code&gt;가 적합합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;브라우저 vs Node.js 차이 인지&lt;ul&gt;
&lt;li&gt;Node.js에서는 &lt;code&gt;process.nextTick&lt;/code&gt;이 마이크로태스크보다 우선 실행되어 기아를 유발하기 쉽습니다. 범용적으로는 &lt;code&gt;queueMicrotask&lt;/code&gt;/&lt;code&gt;setImmediate&lt;/code&gt; 사용을 권장합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;테스트에서 플러시하기&lt;ul&gt;
&lt;li&gt;마이크로태스크만 비우고 싶다면 &lt;code&gt;await Promise.resolve()&lt;/code&gt;로 한 틱을 양보하는 패턴을 사용할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;변형 실험으로 개념 고정&lt;/h2&gt;
&lt;p&gt;1) 타이머 내부의 마이크로태스크는 “다음 매크로태스크 안”에서 먼저 비워집니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;setTimeout(() =&amp;gt; {
    console.log(&amp;#39;A1&amp;#39;);
    queueMicrotask(() =&amp;gt; console.log(&amp;#39;A2&amp;#39;)); // 같은 틱에서 A2가 A1 바로 뒤에 실행
}, 0);

console.log(&amp;#39;B1&amp;#39;);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예상: &lt;code&gt;B1&lt;/code&gt; → (다음 틱) &lt;code&gt;A1&lt;/code&gt; → &lt;code&gt;A2&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;2) 등록 순서가 곧 마이크로태스크 실행 순서입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;queueMicrotask(() =&amp;gt; console.log(&amp;#39;M1&amp;#39;));
Promise.resolve().then(() =&amp;gt; console.log(&amp;#39;M2&amp;#39;));
queueMicrotask(() =&amp;gt; console.log(&amp;#39;M3&amp;#39;));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예상: &lt;code&gt;M1&lt;/code&gt; → &lt;code&gt;M2&lt;/code&gt; → &lt;code&gt;M3&lt;/code&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;상태 정합이 중요한 “동 틱 내 후처리”는 &lt;code&gt;queueMicrotask&lt;/code&gt;로, 체인/에러 전파가 필요하면 &lt;code&gt;Promise&lt;/code&gt;로.&lt;/li&gt;
&lt;li&gt;렌더 이후로 미루고 싶다면 &lt;code&gt;setTimeout(0)&lt;/code&gt;이 아니라, 의도에 맞는 API(&lt;code&gt;requestAnimationFrame&lt;/code&gt;, &lt;code&gt;requestIdleCallback&lt;/code&gt;)를 고려하세요.&lt;/li&gt;
&lt;li&gt;Node.js에서는 &lt;code&gt;process.nextTick&lt;/code&gt; 남용을 지양하고 &lt;code&gt;queueMicrotask&lt;/code&gt;/&lt;code&gt;setImmediate&lt;/code&gt;를 선택하세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;핵심 공식: 동기(B/E) → 마이크로태스크(C/D 전체 소진) → 매크로태스크(A). 이 규칙으로 대부분의 실행 순서를 안정적으로 예측할 수 있습니다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/131</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/setTimeout-vs-Promisethen-vs-queueMicrotask#entry131comment</comments>
      <pubDate>Fri, 5 Sep 2025 10:37:15 +0900</pubDate>
    </item>
    <item>
      <title>Next.js 최신 캐싱 전략 총정리</title>
      <link>https://white-mouse-dev.tistory.com/entry/Nextjs-%EC%B5%9C%EC%8B%A0-%EC%BA%90%EC%8B%B1-%EC%A0%84%EB%9E%B5-%EC%B4%9D%EC%A0%95%EB%A6%AC</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cTYTqf/dJMb9WFkqRw/apbsTW8am7pzoFRKLPAll1/tfile.avif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cTYTqf/dJMb9WFkqRw/apbsTW8am7pzoFRKLPAll1/tfile.avif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cTYTqf/dJMb9WFkqRw/apbsTW8am7pzoFRKLPAll1/tfile.avif&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcTYTqf%2FdJMb9WFkqRw%2FapbsTW8am7pzoFRKLPAll1%2Ftfile.avif&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Next.js는 App Router 기반으로 서버 컴포넌트와 데이터 페칭을 통합하면서&lt;br&gt;캐싱 체계를 전면 재설계하였습니다. 본 글에서는 실무에서 가장 자주&lt;br&gt;사용되는 네 가지 축, 즉 &lt;strong&gt;Request Memoization&lt;/strong&gt;, &lt;strong&gt;Client Router&lt;br&gt;Cache&lt;/strong&gt;, &lt;strong&gt;Data Cache&lt;/strong&gt;, &lt;strong&gt;Full Route Cache&lt;/strong&gt;를 중심으로 원리를&lt;br&gt;정리하고, 실행 가능한 예제와 함께 권장되는 베스트 프랙티스를 제시합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;목차&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Request Memoization: 동일 요청 중복 제거&lt;/li&gt;
&lt;li&gt;Client Router Cache: 탐색 성능 최적화&lt;/li&gt;
&lt;li&gt;Data Cache: fetch 응답의 서버 캐싱 및 검증&lt;/li&gt;
&lt;li&gt;Full Route Cache: 페이지 단위 정적 캐싱(ISR 포함)&lt;/li&gt;
&lt;li&gt;실무 팁/주의사항, 비교 표, 참고 자료&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;캐싱 전반 개념 맵&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;서버 단계&lt;/strong&gt;: Request Memoization, Data Cache, Full Route Cache&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;클라이언트 단계&lt;/strong&gt;: Client Router Cache(RSC Payload/라우팅)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;동적 여부 판별 기준&lt;/strong&gt;: &lt;code&gt;cookies()&lt;/code&gt;, &lt;code&gt;headers()&lt;/code&gt;, &lt;code&gt;searchParams&lt;/code&gt;,&lt;br&gt;&lt;code&gt;dynamic&lt;/code&gt;/&lt;code&gt;revalidate&lt;/code&gt; 설정&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;무효화 수단&lt;/strong&gt;: &lt;code&gt;revalidate&lt;/code&gt;, &lt;code&gt;revalidatePath&lt;/code&gt;, &lt;code&gt;revalidateTag&lt;/code&gt;,&lt;br&gt;&lt;code&gt;router.refresh()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;1) Request Memoization (요청 중복 제거)&lt;/h2&gt;
&lt;p&gt;동일한 입력으로 &lt;code&gt;fetch&lt;/code&gt;를 여러 컴포넌트에서 호출하더라도 서버 렌더링 한&lt;br&gt;사이클 내에서는 단 한 번만 네트워크 요청이 발생하며, 그 결과가&lt;br&gt;공유됩니다. 이는 네트워크 비용과 백엔드 부하를 크게 줄여줍니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;범위&lt;/strong&gt;: 단일 서버 렌더(요청) 사이클&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;효과&lt;/strong&gt;: 동일 URL+옵션의 &lt;code&gt;fetch&lt;/code&gt; 중복 제거&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;주의사항&lt;/strong&gt;: &lt;code&gt;cache: &amp;#39;no-store&amp;#39;&lt;/code&gt;일 경우에도 동일 렌더 내에서는 중복&lt;br&gt;제거가 적용됨 (요청 간 캐싱은 아님)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;실행 예시&lt;/h3&gt;
&lt;p&gt;동일 데이터를 여러 컴포넌트에서 사용하더라도 네트워크는 1회만&lt;br&gt;발생합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/posts/[id]/page.tsx
import { Suspense } from &amp;#39;react&amp;#39;;

async function getPost(id: string) {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
    cache: &amp;#39;no-store&amp;#39;
  });
  return res.json() as Promise&amp;lt;{ id: number; title: string; body: string }&amp;gt;
}

async function PostTitle({ id }: { id: string }) {
  const post = await getPost(id)
  return &amp;lt;h2&amp;gt;{post.title}&amp;lt;/h2&amp;gt;
}

async function PostBody({ id }: { id: string }) {
  const post = await getPost(id)
  return &amp;lt;p&amp;gt;{post.body}&amp;lt;/p&amp;gt;
}

export default async function Page({ params }: { params: { id: string } }) {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;Suspense fallback={&amp;lt;div&amp;gt;Loading...&amp;lt;/div&amp;gt;}&amp;gt;
        &amp;lt;PostTitle id={params.id} /&amp;gt;
        &amp;lt;PostBody id={params.id} /&amp;gt;
      &amp;lt;/Suspense&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;2) Client Router Cache (클라이언트 라우터 캐시)&lt;/h2&gt;
&lt;p&gt;브라우저 메모리에 RSC Payload와 레이아웃/로딩 상태 등을 캐싱하여 탐색&lt;br&gt;속도를 향상시킵니다. &lt;code&gt;next/link&lt;/code&gt;의 사전 불러오기(prefetch) 기능과&lt;br&gt;연계되어 페이지 전환이 즉각적으로 이루어집니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;저장 위치&lt;/strong&gt;: 브라우저 메모리(세션 범위와 유사)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;채워지는 시점&lt;/strong&gt;: 링크 마우스 오버, 뷰포트 노출, 명시적&lt;br&gt;&lt;code&gt;router.prefetch()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;무효화 조건&lt;/strong&gt;: 서버에서 경로/태그 재검증, 클라이언트의&lt;br&gt;&lt;code&gt;router.refresh()&lt;/code&gt; 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;실행 예시&lt;/h3&gt;
&lt;p&gt;목록 페이지에서 상세 링크를 사전 불러오고, 변경 시 캐시를 무효화합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/articles/page.tsx
import Link from &amp;#39;next/link&amp;#39;

export default function ArticlesPage() {
  const items = Array.from({ length: 5 }).map((_, i) =&amp;gt; ({ id: i + 1, title: `Post ${i + 1}` }))
  return (
    &amp;lt;ul&amp;gt;
      {items.map((item) =&amp;gt; (
        &amp;lt;li key={item.id}&amp;gt;
          &amp;lt;Link href={`/articles/${item.id}`}&amp;gt;{item.title}&amp;lt;/Link&amp;gt;
        &amp;lt;/li&amp;gt;
      ))}
    &amp;lt;/ul&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;변경 후에는 서버 액션과 함께 &lt;code&gt;revalidatePath&lt;/code&gt; 또는 클라이언트의&lt;br&gt;&lt;code&gt;router.refresh()&lt;/code&gt;를 호출하여 동기화를 보장합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3) Data Cache (데이터 응답 캐시)&lt;/h2&gt;
&lt;p&gt;서버에서 실행되는 &lt;code&gt;fetch&lt;/code&gt; 응답을 캐싱합니다. 기본 동작은 렌더링 모드 및&lt;br&gt;옵션에 따라 달라집니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;기본 규칙 요약&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;정적 렌더링 경로에서는 &lt;code&gt;GET&lt;/code&gt; 요청이 기본적으로 캐싱&lt;br&gt;대상(&lt;code&gt;force-cache&lt;/code&gt; 유사)&lt;/li&gt;
&lt;li&gt;동적 렌더링을 유발하는 경우에는 기본값이 &lt;code&gt;no-store&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;명시적으로 &lt;code&gt;next: { revalidate: number }&lt;/code&gt;를 지정하면 ISR처럼&lt;br&gt;특정 주기로 재검증&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;실행 예시&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/products/page.tsx
export const revalidate = 60

async function getProducts() {
  const res = await fetch(&amp;#39;https://api.example.com/products&amp;#39;, {
    cache: &amp;#39;force-cache&amp;#39;,
    next: { revalidate: 120, tags: [&amp;#39;products&amp;#39;] }
  })
  if (!res.ok) throw new Error(&amp;#39;Failed to load&amp;#39;)
  return res.json() as Promise&amp;lt;Array&amp;lt;{ id: string; name: string }&amp;gt;&amp;gt;
}

export default async function ProductsPage() {
  const products = await getProducts()
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h2&amp;gt;Products&amp;lt;/h2&amp;gt;
      &amp;lt;ul&amp;gt;
        {products.map((p) =&amp;gt; (
          &amp;lt;li key={p.id}&amp;gt;{p.name}&amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ul&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;태그 단위 무효화:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/admin/actions.ts
&amp;#39;use server&amp;#39;
import { revalidateTag } from &amp;#39;next/cache&amp;#39;

export async function createProduct(name: string) {
  await fetch(&amp;#39;https://api.example.com/products&amp;#39;, {
    method: &amp;#39;POST&amp;#39;,
    body: JSON.stringify({ name })
  })
  revalidateTag(&amp;#39;products&amp;#39;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;4) Full Route Cache (페이지 단위 캐싱, ISR 포함)&lt;/h2&gt;
&lt;p&gt;페이지 전체(HTML + RSC Payload)를 캐싱합니다. 이는 빌드 타임 또는 최초&lt;br&gt;요청 시 생성되며, &lt;code&gt;revalidate&lt;/code&gt;에 따라 주기적으로 갱신됩니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;활성 조건&lt;/strong&gt;: 동적 함수 미사용 + 정적 렌더 경로 + 데이터 캐시 가능&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;구성 방법&lt;/strong&gt;: &lt;code&gt;export const revalidate = number&lt;/code&gt; 또는&lt;br&gt;&lt;code&gt;dynamic = &amp;#39;force-static&amp;#39;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;비활성 조건&lt;/strong&gt;: &lt;code&gt;dynamic = &amp;#39;force-dynamic&amp;#39;&lt;/code&gt;, &lt;code&gt;cache: &amp;#39;no-store&amp;#39;&lt;/code&gt;,&lt;br&gt;동적 함수 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;비교 표&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;  구분          범위         저장 위치   기본 수명    명시 제어          대표 사용처&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;  Request       단일 서버    서버 메모리 요청         내장 기능          동일 데이터&lt;br&gt;  Memoization   렌더 사이클              종료까지                        중복 호출&lt;br&gt;                                                                         제거&lt;/p&gt;
&lt;p&gt;  Client Router 클라이언트   브라우저    세션 유사    router.refresh()   빠른 페이지&lt;br&gt;  Cache         탐색         메모리                                      전환&lt;/p&gt;
&lt;p&gt;  Data Cache    개별 fetch   서버 캐시   옵션에 따름  cache/no-store,    API 응답&lt;br&gt;                응답                                  revalidate, tags   캐싱&lt;/p&gt;
&lt;p&gt;  Full Route    페이지 전체  서버 캐시   revalidate   revalidate,        마케팅,&lt;br&gt;  Cache                                  주기         dynamic            블로그,&lt;br&gt;                                                                         카탈로그&lt;/p&gt;
&lt;hr&gt;
&lt;hr&gt;
&lt;h2&gt;베스트 프랙티스 및 주의사항&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;개인화/민감 데이터는 반드시 &lt;code&gt;cache: &amp;#39;no-store&amp;#39;&lt;/code&gt; 설정&lt;/li&gt;
&lt;li&gt;공용 데이터는 &lt;code&gt;revalidate&lt;/code&gt; 또는 &lt;code&gt;force-cache&lt;/code&gt; 전략으로 관리&lt;/li&gt;
&lt;li&gt;태그 기반 무효화를 일관되게 활용 (&lt;code&gt;revalidateTag&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;동적 함수 사용 여부를 명확히 구분하여 Full Route Cache가 불필요하게&lt;br&gt;깨지지 않도록 관리&lt;/li&gt;
&lt;li&gt;서버 액션 이후에는 &lt;code&gt;revalidatePath&lt;/code&gt; 및 &lt;code&gt;router.refresh()&lt;/code&gt;로 캐시&lt;br&gt;동기화 보장&lt;/li&gt;
&lt;li&gt;트래픽 상황에 따라 &lt;code&gt;prefetch={false}&lt;/code&gt;로 과도한 사전 요청 제어&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;결론: 적용 가이드&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;페이지 특성을 파악하여 개인화 여부, 변경 주기, 트래픽 패턴을 분석&lt;/li&gt;
&lt;li&gt;가능한 경우 Full Route Cache와 &lt;code&gt;revalidate&lt;/code&gt;를 활용&lt;/li&gt;
&lt;li&gt;데이터 단위로 &lt;code&gt;force-cache&lt;/code&gt;/&lt;code&gt;no-store&lt;/code&gt;/&lt;code&gt;next.revalidate&lt;/code&gt;/태그 전략을&lt;br&gt;수립&lt;/li&gt;
&lt;li&gt;&lt;code&gt;revalidatePath&lt;/code&gt;, &lt;code&gt;revalidateTag&lt;/code&gt;, &lt;code&gt;router.refresh()&lt;/code&gt;를 통해 무효화&lt;br&gt;경로를 설계&lt;/li&gt;
&lt;li&gt;실제 트래픽에서 TTFB, 탐색 속도, 백엔드 QPS를 모니터링하여 검증&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;핵심은 &lt;strong&gt;&amp;quot;정적 가능 영역을 최대화하고, 동적 처리는 최소화&amp;quot;&lt;/strong&gt;하는&lt;br&gt;것입니다. Next.js의 네 가지 캐시 축을 적절히 조합하면 성능과 일관성을&lt;br&gt;동시에 달성할 수 있습니다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/130</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/Nextjs-%EC%B5%9C%EC%8B%A0-%EC%BA%90%EC%8B%B1-%EC%A0%84%EB%9E%B5-%EC%B4%9D%EC%A0%95%EB%A6%AC#entry130comment</comments>
      <pubDate>Wed, 3 Sep 2025 15:17:44 +0900</pubDate>
    </item>
    <item>
      <title>면접에서 묻는 &amp;quot;의존성 주입 경험이 있나요?&amp;quot;의 의미</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EB%A9%B4%EC%A0%91%EC%97%90%EC%84%9C-%EB%AC%BB%EB%8A%94-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A3%BC%EC%9E%85-%EA%B2%BD%ED%97%98%EC%9D%B4-%EC%9E%88%EB%82%98%EC%9A%94%EC%9D%98-%EC%9D%98%EB%AF%B8</link>
      <description>&lt;p&gt;프론트엔드 면접에서 자주 듣는 질문 중 하나가 &lt;strong&gt;&amp;quot;의존성 주입(Dependency Injection, DI) 경험이 있나요?&amp;quot;&lt;/strong&gt;입니다.&lt;br&gt;이 질문은 단순히 특정 DI 프레임워크를 써봤냐를 묻는 게 아니라, &lt;strong&gt;코드 구조와 의존성 관리 문제를 이해하고 있는지&lt;/strong&gt;를 확인하는 의도에 가깝습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. 의존성 문제(Dependency Problem)란?&lt;/h2&gt;
&lt;p&gt;소프트웨어에서 어떤 클래스나 모듈이 다른 객체를 &lt;strong&gt;직접 생성&lt;/strong&gt;하거나 강하게 참조하면 문제가 발생합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;class UserService {
  private api = new ApiClient(); // 직접 생성 → 강한 결합
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;테스트하기 어려움 (ApiClient를 Mock으로 교체 불가)&lt;/li&gt;
&lt;li&gt;재사용성이 낮음 (다른 구현체로 교체하기 힘듦)&lt;/li&gt;
&lt;li&gt;결합도가 높아 유지보수가 어려움&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, &lt;strong&gt;의존성을 직접 관리하면 코드가 유연하지 않고 테스트/확장이 어렵다&lt;/strong&gt;는 게 핵심 문제입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. 의존성 주입(DI)으로 해결하는 방법&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;DI&lt;/strong&gt;는 외부에서 필요한 의존성을 “주입”받아 사용하는 패턴입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;class UserService {
  constructor(private api: ApiClient) {} // 외부에서 주입
}

// 실제 사용 시
const api = new ApiClient();
const userService = new UserService(api);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;장점:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;테스트 시 Mock 객체를 쉽게 주입 가능&lt;/li&gt;
&lt;li&gt;결합도 감소 → 유지보수 용이&lt;/li&gt;
&lt;li&gt;환경별 구현체 교체 가능 (예: Dev API vs Prod API)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;3. 프론트엔드에서의 DI 예시&lt;/h2&gt;
&lt;p&gt;프론트엔드에서는 Angular처럼 전용 DI 프레임워크를 쓰지 않아도, 다양한 형태로 &lt;strong&gt;DI 개념&lt;/strong&gt;을 적용할 수 있습니다.&lt;/p&gt;
&lt;h3&gt;(예시) TypeScript 인터페이스 기반 서비스 주입&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// contracts.ts
export interface UserApi {
  getUser(id: string): Promise&amp;lt;{ id: string; name: string }&amp;gt;
}

export class FetchUserApi implements UserApi {
  async getUser(id: string) {
    const res = await fetch(`/api/users/${id}`);
    return res.json();
  }
}

export class UserService {
  constructor(private readonly api: UserApi) {}
  load(id: string) {
    return this.api.getUser(id);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(예시) React Context로 API 클라이언트 주입&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// App.tsx
import React from &amp;#39;react&amp;#39;;
import type { UserApi } from &amp;#39;./contracts&amp;#39;;

export const UserApiContext = React.createContext&amp;lt;UserApi | null&amp;gt;(null);

export function App({ api, children }: { api: UserApi; children: React.ReactNode }) {
  return &amp;lt;UserApiContext.Provider value={api}&amp;gt;{children}&amp;lt;/UserApiContext.Provider&amp;gt;;
}

export function useUserApi(): UserApi {
  const api = React.useContext(UserApiContext);
  if (!api) throw new Error(&amp;#39;UserApiProvider가 필요합니다&amp;#39;);
  return api;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(예시) Next.js(App Router)에서 환경별 구현 주입&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/_lib/serverUserApi.ts
import type { UserApi } from &amp;#39;@/contracts&amp;#39;;

export class ServerUserApi implements UserApi {
  async getUser(id: string) {
    const res = await fetch(`${process.env.API_BASE}/users/${id}`, {
      headers: { Authorization: `Bearer ${process.env.SERVICE_TOKEN}` },
      cache: &amp;#39;no-store&amp;#39;,
    });
    return res.json();
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/users/[id]/page.tsx
import { ServerUserApi } from &amp;#39;@/app/_lib/serverUserApi&amp;#39;;
import { App } from &amp;#39;@/app/_components/App&amp;#39;;

export default function Page({ params }: { params: { id: string } }) {
  const api = new ServerUserApi();
  return (
    &amp;lt;App api={api}&amp;gt;
      {/* 클라이언트 컴포넌트 내에서 useUserApi() 사용 */}
      {/* ... */}
    &amp;lt;/App&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(예시) 테스트에서 Mock 주입&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// userService.test.ts
import { UserService } from &amp;#39;./contracts&amp;#39;;

const mockApi = { getUser: async (id: string) =&amp;gt; ({ id, name: &amp;#39;Mock&amp;#39; }) };

test(&amp;#39;UserService는 API 결과를 그대로 반환한다&amp;#39;, async () =&amp;gt; {
  const service = new UserService(mockApi);
  await expect(service.load(&amp;#39;1&amp;#39;)).resolves.toEqual({ id: &amp;#39;1&amp;#39;, name: &amp;#39;Mock&amp;#39; });
});&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// UserView.test.tsx
import { render, screen } from &amp;#39;@testing-library/react&amp;#39;;
import React from &amp;#39;react&amp;#39;;
import { App } from &amp;#39;./App&amp;#39;;

const mockApi = { getUser: async () =&amp;gt; ({ id: &amp;#39;1&amp;#39;, name: &amp;#39;Mock&amp;#39; }) };

test(&amp;#39;mock API를 주입해 렌더링 결과를 검증한다&amp;#39;, async () =&amp;gt; {
  render(
    &amp;lt;App api={mockApi as any}&amp;gt;
      &amp;lt;div&amp;gt;Mount component using useUserApi()&amp;lt;/div&amp;gt;
    &amp;lt;/App&amp;gt;
  );
  // 실제 컴포넌트에서는 screen.findByText(&amp;#39;Mock&amp;#39;) 등으로 검증
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(권장) 베스트 프랙티스와 흔한 실수&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;인터페이스 우선: 구현보다 계약을 먼저 정의한다&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;구성 루트 명확화: 의존성 생성·주입 지점을 한곳에 모은다&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;환경 분리: SSR/CSR, Dev/Prod별 구현을 분리하고 주입으로 선택한다&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;전역 싱글톤 남용 금지: 테스트 격리를 방해하고 숨은 결합을 만든다&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Context 남용 주의: 재렌더 비용을 고려해 분리/메모화한다&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;React props 전달&lt;/strong&gt;&lt;br&gt;부모 컴포넌트에서 자식으로 props를 넘기는 것은 DI의 가장 기본적인 형태입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Context API, Zustand, Redux&lt;/strong&gt;&lt;br&gt;전역 상태(store)를 컴포넌트에 직접 생성하지 않고 외부에서 주입받아 사용하는 것.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;서비스 레이어 분리&lt;/strong&gt;&lt;br&gt;API 호출을 &lt;code&gt;fetchClient&lt;/code&gt;, &lt;code&gt;apiService&lt;/code&gt; 같은 모듈로 분리하고, 훅이나 컴포넌트에서는 이를 주입받아 사용.&lt;br&gt;→ 테스트 시 Mock API로 교체 가능.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;테스트 코드에서 Mock 주입&lt;/strong&gt;&lt;br&gt;Jest/RTL 등을 사용해 실제 API 모듈 대신 Mock 객체를 주입해 테스트.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;4. 면접 답변 포인트&lt;/h2&gt;
&lt;p&gt;면접관이 궁금한 건 다음과 같습니다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;의존성 관리 문제를 이해하는지&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;테스트 가능하고 유연한 구조를 고민해본 경험이 있는지&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React/Next.js에서도 DI 개념을 적용해봤는지&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;  답변 예시:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;“프론트엔드에서는 Angular처럼 DI 프레임워크를 직접 쓰진 않았지만,&lt;br&gt;React에서는 props 전달이나 Context, Zustand 같은 상태 관리 도구를 통해 의존성을 주입해본 경험이 있습니다.&lt;br&gt;특히 API 클라이언트를 서비스 레이어로 분리하고 외부에서 주입받도록 설계해,&lt;br&gt;테스트 시 Mock 객체로 교체 가능하게 한 경험이 있습니다.”&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;5. 결론&lt;/h2&gt;
&lt;p&gt;의존성 주입은 단순히 기술 스택 문제가 아니라 &lt;strong&gt;코드 아키텍처와 유지보수성&lt;/strong&gt;에 대한 이야기입니다.&lt;br&gt;프론트엔드에서도 DI 개념을 적용하면 테스트 용이성, 유연한 확장성, 낮은 결합도를 달성할 수 있습니다.  &lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;결국 면접에서 “DI 경험이 있나요?”라는 질문은 &lt;strong&gt;&amp;quot;당신은 코드 구조와 테스트 가능성을 고려해서 개발해본 경험이 있나요?&amp;quot;&lt;/strong&gt;라는 의미로 이해하면 됩니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/129</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EB%A9%B4%EC%A0%91%EC%97%90%EC%84%9C-%EB%AC%BB%EB%8A%94-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A3%BC%EC%9E%85-%EA%B2%BD%ED%97%98%EC%9D%B4-%EC%9E%88%EB%82%98%EC%9A%94%EC%9D%98-%EC%9D%98%EB%AF%B8#entry129comment</comments>
      <pubDate>Tue, 26 Aug 2025 17:14:14 +0900</pubDate>
    </item>
    <item>
      <title>useState vs useRef vs let: 언제 무엇을 써야 할까?</title>
      <link>https://white-mouse-dev.tistory.com/entry/useState-vs-useRef-vs-let-%EC%96%B8%EC%A0%9C-%EB%AC%B4%EC%97%87%EC%9D%84-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
      <description>&lt;h3&gt;의의와 배경&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;useRef&lt;/code&gt;는 리렌더링 간에도 유지되는 가변 저장소를 제공한다. 값 변경이 렌더를 유발하지 않는다는 점에서 &lt;code&gt;useState&lt;/code&gt;와 구분되며, 컴포넌트 인스턴스마다 독립적으로 유지된다는 점에서 단순 &lt;code&gt;let&lt;/code&gt;과도 다르다. 주로 DOM 접근, 타이머/외부 핸들 저장, 최신 값 보관 등 렌더와 무관한 정보를 관리할 때 사용한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;목차&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;개념 정리: 렌더링 모델과 &lt;code&gt;ref.current&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;비교: &lt;code&gt;useState&lt;/code&gt; vs &lt;code&gt;useRef&lt;/code&gt; vs &lt;code&gt;let&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;사용 시나리오와 코드 예시&lt;/li&gt;
&lt;li&gt;주의사항(안티패턴)&lt;/li&gt;
&lt;li&gt;체크리스트&lt;/li&gt;
&lt;li&gt;결론&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;개념 정리&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;useRef&amp;lt;T&amp;gt;(initial)&lt;/code&gt;는 &lt;code&gt;{ current: T }&lt;/code&gt; 형태의 객체를 반환한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref.current&lt;/code&gt;를 변경해도 컴포넌트는 리렌더링되지 않는다.&lt;/li&gt;
&lt;li&gt;같은 컴포넌트 인스턴스에서 렌더 간 동일한 &lt;code&gt;ref&lt;/code&gt; 객체가 유지된다.&lt;/li&gt;
&lt;li&gt;의도: “렌더 출력에 직접 참여하지 않는, 그러나 렌더 사이에 유지돼야 하는 값”을 담는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;비교: 언제 무엇을 쓰나&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;useState&lt;/th&gt;
&lt;th&gt;useRef&lt;/th&gt;
&lt;th&gt;let(컴포넌트 내부)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;렌더 트리거&lt;/td&gt;
&lt;td&gt;값 변경 시 렌더&lt;/td&gt;
&lt;td&gt;렌더 없음&lt;/td&gt;
&lt;td&gt;렌더마다 초기화됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;값 유지(렌더 간)&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;인스턴스별 독립성&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O(내부이지만 매 렌더 초기화)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;주 용도&lt;/td&gt;
&lt;td&gt;UI에 반영돼야 하는 상태&lt;/td&gt;
&lt;td&gt;렌더와 무관한 가변 값, DOM 핸들&lt;/td&gt;
&lt;td&gt;순수 계산용 임시 변수&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;주의: 컴포넌트 “외부 모듈 스코프”에 &lt;code&gt;let&lt;/code&gt;을 두면 모든 인스턴스가 공유하므로 권장하지 않는다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;사용 시나리오와 코드 예시&lt;/h3&gt;
&lt;h4&gt;1) DOM 접근과 제어&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { useRef } from &amp;#39;react&amp;#39;;

export default function FocusInput() {
  const inputRef = useRef&amp;lt;HTMLInputElement | null&amp;gt;(null);
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;input ref={inputRef} /&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; inputRef.current?.focus()}&amp;gt;포커스&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2) 타이머/외부 핸들 저장(클린업 용이)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { useEffect, useRef } from &amp;#39;react&amp;#39;;

export default function Ticker() {
  const intervalId = useRef&amp;lt;ReturnType&amp;lt;typeof setInterval&amp;gt; | null&amp;gt;(null);
  useEffect(() =&amp;gt; {
    intervalId.current = setInterval(() =&amp;gt; {
      console.log(&amp;#39;tick&amp;#39;);
    }, 1000);
    return () =&amp;gt; {
      if (intervalId.current) clearInterval(intervalId.current);
    };
  }, []);
  return &amp;lt;div&amp;gt;running...&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3) 렌더를 유발하지 않는 카운터/측정값&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { useRef } from &amp;#39;react&amp;#39;;

export default function ClickCounter() {
  const clicks = useRef(0);
  return (
    &amp;lt;button onClick={() =&amp;gt; { clicks.current += 1; console.log(clicks.current); }}&amp;gt;
      클릭(화면 숫자는 안 바뀜)
    &amp;lt;/button&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;4) 최신 값 보관으로 stale closure 방지&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { useEffect, useRef } from &amp;#39;react&amp;#39;;

export function useLatest&amp;lt;T&amp;gt;(value: T) {
  const ref = useRef(value);
  ref.current = value;
  return ref; // 항상 최신 값을 가리킴
}

export default function Worker({ onMessage }: { onMessage: (s: string) =&amp;gt; void }) {
  const latestHandler = useLatest(onMessage);
  useEffect(() =&amp;gt; {
    const id = setInterval(() =&amp;gt; {
      latestHandler.current(&amp;#39;ping&amp;#39;); // 최신 핸들러 호출
    }, 1000);
    return () =&amp;gt; clearInterval(id);
  }, []);
  return null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;주의사항(안티패턴)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ref.current&lt;/code&gt;로 뷰를 업데이트하려 하지 말 것. 뷰에 반영돼야 하는 값은 &lt;code&gt;useState&lt;/code&gt;로 관리한다.&lt;/li&gt;
&lt;li&gt;렌더 중 &lt;code&gt;ref.current&lt;/code&gt;를 변경해 렌더 결과에 의존하는 로직을 만들지 말 것. 사이드이펙트는 이펙트 훅에서 처리한다.&lt;/li&gt;
&lt;li&gt;모듈 스코프의 &lt;code&gt;let&lt;/code&gt;으로 컴포넌트 간 상태를 공유하지 말 것(인스턴스 간 간섭 발생).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ref&lt;/code&gt; 남용으로 상태 추적이 어려워지지 않도록, “UI에 영향”이면 &lt;code&gt;state&lt;/code&gt;, “렌더 무관”이면 &lt;code&gt;ref&lt;/code&gt;를 기본 규칙으로 삼는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;체크리스트&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 이 값이 화면에 반영돼야 하는가? 그렇다면 &lt;code&gt;useState&lt;/code&gt;를 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 렌더 사이에 유지돼야 하지만 화면에 직접 반영되지 않는가? &lt;code&gt;useRef&lt;/code&gt;를 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 외부 자원 핸들(타이머/소켓/DOM)을 저장하는가? &lt;code&gt;useRef&lt;/code&gt;로 보관하고 이펙트에서 관리한다.&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 모듈 전역 &lt;code&gt;let&lt;/code&gt;로 상태를 공유하고 있지 않은가? 인스턴스 격리를 보장하도록 수정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;결론&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;useRef&lt;/code&gt;는 “렌더와 분리된 가변 값”을 안전하게 보존하는 도구다. DOM 접근, 타이머·외부 핸들 저장, 최신 콜백 유지 등에서 특히 유용하다. 반대로 뷰에 영향을 주는 값은 &lt;code&gt;useState&lt;/code&gt;로 관리해 예측 가능한 렌더 사이클을 유지하는 것이 바람직하다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/128</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/useState-vs-useRef-vs-let-%EC%96%B8%EC%A0%9C-%EB%AC%B4%EC%97%87%EC%9D%84-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8C#entry128comment</comments>
      <pubDate>Thu, 21 Aug 2025 14:31:46 +0900</pubDate>
    </item>
    <item>
      <title>Core Web Vitals: LCP, INP, CLS 개념과 개선 방법</title>
      <link>https://white-mouse-dev.tistory.com/entry/Core-Web-Vitals-LCP-INP-CLS-%EA%B0%9C%EB%85%90%EA%B3%BC-%EA%B0%9C%EC%84%A0-%EB%B0%A9%EB%B2%95</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZdA6L/btsP1qfNHUB/CP05kxBO4GEw1gTM4S6B7k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZdA6L/btsP1qfNHUB/CP05kxBO4GEw1gTM4S6B7k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZdA6L/btsP1qfNHUB/CP05kxBO4GEw1gTM4S6B7k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZdA6L%2FbtsP1qfNHUB%2FCP05kxBO4GEw1gTM4S6B7k%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;의의와 배경&lt;/h3&gt;
&lt;p&gt;Core Web Vitals는 구글이 웹 사용자 경험을 정량화하기 위해 정의한 핵심 성능 지표다. 이 지표는 실제 사용자 경험을 직접적으로 반영하며 검색 순위(SEO)에도 영향을 미친다. 프론트엔드 개발자는 필수적으로 이해하고 지속적으로 관리해야 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;목차&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;개요&lt;/strong&gt;: 지표 정의와 목표 기준&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LCP&lt;/strong&gt;: 의미, 원인, 개선 체크리스트&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;INP&lt;/strong&gt;: 의미, 원인, 개선 체크리스트&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CLS&lt;/strong&gt;: 의미, 원인, 개선 체크리스트&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;측정 방법&lt;/strong&gt;: Lab vs Field, 실측 코드 예시&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;실무 팁/흔한 실수&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;체크리스트와 결론&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;개요&lt;/h3&gt;
&lt;p&gt;Core Web Vitals는 세 가지 지표로 구성된다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;권장 기준&lt;/th&gt;
&lt;th&gt;주의 구간&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;LCP (Largest Contentful Paint)&lt;/td&gt;
&lt;td&gt;가장 큰 콘텐츠가 화면에 보이기까지의 시간&lt;/td&gt;
&lt;td&gt;≤ 2.5s&lt;/td&gt;
&lt;td&gt;2.5s ~ 4.0s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INP (Interaction to Next Paint)&lt;/td&gt;
&lt;td&gt;사용자 상호작용 후 다음 페인트까지의 지연&lt;/td&gt;
&lt;td&gt;≤ 200ms&lt;/td&gt;
&lt;td&gt;200ms ~ 500ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLS (Cumulative Layout Shift)&lt;/td&gt;
&lt;td&gt;예기치 않은 레이아웃 이동의 누적량&lt;/td&gt;
&lt;td&gt;≤ 0.1&lt;/td&gt;
&lt;td&gt;0.1 ~ 0.25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h3&gt;LCP: Largest Contentful Paint&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;정의&lt;/strong&gt;: 최초 뷰포트 내 가장 큰 텍스트·이미지·포스터 이미지가 렌더링되기까지의 시간&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;악화 요인&lt;/strong&gt;: 높은 TTFB, 대용량 히어로 이미지, 차단적인 CSS/JS, 비효율적인 폰트 로드&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;개선 체크리스트&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;서버/네트워크&lt;/strong&gt;: CDN 사용, 정적 캐시, 압축(gzip/br) 적용, HTTP/2·3 활성화, 초기 HTML의 TTFB를 낮춘다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;리소스 우선순위&lt;/strong&gt;: 핵심 CSS 인라인, 비핵심 CSS 지연, 크리티컬 이미지 &lt;code&gt;preload&lt;/code&gt;를 적용한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;이미지 최적화&lt;/strong&gt;: 포맷(AVIF/WebP)과 크기를 최적화하고 &lt;code&gt;srcset/sizes&lt;/code&gt;를 사용한다. 히어로 이미지는 지연 로드를 피한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;폰트 전략&lt;/strong&gt;: &lt;code&gt;font-display: swap&lt;/code&gt;을 적용하고 필요한 글자만 서브셋 처리하며 폰트를 &lt;code&gt;preload&lt;/code&gt;한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;link rel=&amp;quot;preload&amp;quot; as=&amp;quot;image&amp;quot; href=&amp;quot;/hero.jpg&amp;quot; imagesrcset=&amp;quot;/hero.jpg 1x, /hero@2x.jpg 2x&amp;quot; imagesizes=&amp;quot;100vw&amp;quot;&amp;gt;
&amp;lt;link rel=&amp;quot;preload&amp;quot; as=&amp;quot;font&amp;quot; href=&amp;quot;/fonts/inter.woff2&amp;quot; type=&amp;quot;font/woff2&amp;quot; crossorigin&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* FOIT 방지용 폰트 표시 전략 */
@font-face {
  font-family: &amp;#39;Inter&amp;#39;;
  src: url(&amp;#39;/fonts/inter.woff2&amp;#39;) format(&amp;#39;woff2&amp;#39;);
  font-display: swap;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;INP: Interaction to Next Paint&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;정의&lt;/strong&gt;: 페이지 체류 동안 발생한 상호작용(클릭·탭·키 입력)의 지연 중 최악값에 가까운 단일 값&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;악화 요인&lt;/strong&gt;: 메인 스레드 장기 점유(Long Task), 동기 JS, 무거운 이벤트 핸들러, 레이아웃 스래싱&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;개선 체크리스트&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;JS 예산 관리&lt;/strong&gt;: 번들을 축소하고 코드 분할과 사용 시점 로딩(온디맨드)을 적용한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;이벤트 핸들러 최적화&lt;/strong&gt;: 작업을 분할하고 우선순위가 낮은 작업은 &lt;code&gt;setTimeout(0)&lt;/code&gt; 또는 &lt;code&gt;requestIdleCallback&lt;/code&gt;로 위임한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;레이아웃 최적화&lt;/strong&gt;: 읽기·쓰기 연산을 배치하고 스타일 계산을 최소화하며 애니메이션에는 &lt;code&gt;transform/opacity&lt;/code&gt;를 우선 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Long Task 감지&lt;/strong&gt;: DevTools Performance로 50ms 초과 작업을 식별하여 분할한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 긴 배열 처리 시 이벤트 반응성을 확보하기 위한 단순 작업 분할 예시
function processInChunks&amp;lt;T&amp;gt;(items: T[], chunkSize: number, handle: (chunk: T[]) =&amp;gt; void) {
  for (let i = 0; i &amp;lt; items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    setTimeout(() =&amp;gt; handle(chunk), 0);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;CLS: Cumulative Layout Shift&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;정의&lt;/strong&gt;: 예기치 못한 레이아웃 이동의 누적 정도&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;악화 요인&lt;/strong&gt;: 크기가 지정되지 않은 이미지·영상, 동적 콘텐츠 삽입, 광고·위젯, 지연된 웹폰트 로드로 인한 폰트 스왑&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;개선 체크리스트&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;고정 공간 확보&lt;/strong&gt;: 이미지·비디오에 &lt;code&gt;width/height&lt;/code&gt; 또는 &lt;code&gt;aspect-ratio&lt;/code&gt;를 명시한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;동적 요소 자리 확보&lt;/strong&gt;: 광고·위젯 컨테이너 높이를 예약하고 콘텐츠 로딩 전 스켈레톤을 제공한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;폰트 로드 전략&lt;/strong&gt;: &lt;code&gt;font-display: swap&lt;/code&gt;을 적용하고 FOUT를 허용하며 폴백 폰트는 메트릭 유사 폰트를 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;애니메이션 가이드&lt;/strong&gt;: 레이아웃을 변경하는 &lt;code&gt;top/left/width/height&lt;/code&gt; 대신 &lt;code&gt;transform&lt;/code&gt;을 사용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* 레이아웃 이동 방지 */
img {
  aspect-ratio: 16 / 9;
  width: 100%;
  height: auto;
  display: block;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;측정 방법: 실험실(Lab) vs 실제(Field)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;실험실(Lab) 데이터&lt;/strong&gt;: Lighthouse, DevTools(성능 탭)로 시뮬레이션 측정한다. 재현성이 높다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;실제(Field) 데이터, RUM&lt;/strong&gt;: 실제 사용자 환경에서 수집한다. 사용자 네트워크와 디바이스 특성을 반영한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;실측 데이터 수집(React 예시)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm i web-vitals&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// vitals.ts
import { onCLS, onINP, onLCP, Metric } from &amp;#39;web-vitals&amp;#39;;

export function initWebVitals(report: (metric: Metric) =&amp;gt; void) {
  onCLS(report);
  onINP(report);
  onLCP(report);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// App.tsx
import { useEffect } from &amp;#39;react&amp;#39;;
import { initWebVitals } from &amp;#39;./vitals&amp;#39;;

export default function App() {
  useEffect(() =&amp;gt; {
    initWebVitals((metric) =&amp;gt; {
      // 서버로 전송하여 대시보드로 집계한다.
      navigator.sendBeacon(&amp;#39;/analytics&amp;#39;, JSON.stringify({
        name: metric.name,
        value: metric.value,
        rating: metric.rating,
      }));
    });
  }, []);

  return (
    &amp;lt;div&amp;gt;...&amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;실무 적용 팁&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;이미지&lt;/strong&gt;: 히어로 이미지는 &lt;code&gt;preload&lt;/code&gt;와 적절한 포맷(AVIF/WebP)을 적용하고, 비핵심 이미지는 지연 로드한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;폰트&lt;/strong&gt;: 핵심 폰트만 선택적으로 &lt;code&gt;preload&lt;/code&gt;하고 &lt;code&gt;font-display: swap&lt;/code&gt;을 적용한다. 폰트 종류와 가중치는 최소화한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;번들&lt;/strong&gt;: 라우트 단위 코드 분할과 사용 시점 로딩을 적용하고, 타사 스크립트 예산을 관리한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;캐싱&lt;/strong&gt;: 정적 에셋은 장기 캐시하고, HTML은 짧은 TTL과 재검증 정책을 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;네트워크 힌트&lt;/strong&gt;: &lt;code&gt;preconnect&lt;/code&gt;와 &lt;code&gt;dns-prefetch&lt;/code&gt;로 외부 리소스 초기 지연을 줄인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;link rel=&amp;quot;preconnect&amp;quot; href=&amp;quot;https://example-cdn.com&amp;quot; crossorigin&amp;gt;
&amp;lt;link rel=&amp;quot;dns-prefetch&amp;quot; href=&amp;quot;https://example-cdn.com&amp;quot;&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;흔한 실수&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;이미지·비디오 크기를 지정하지 않아 CLS가 증가한다.&lt;/li&gt;
&lt;li&gt;초기 렌더에 불필요한 스크립트를 포함해 INP와 LCP가 악화된다.&lt;/li&gt;
&lt;li&gt;모든 폰트를 무분별하게 &lt;code&gt;preload&lt;/code&gt;하여 네트워크 병목을 유발한다.&lt;/li&gt;
&lt;li&gt;크리티컬 CSS 없이 거대한 CSS를 한 번에 로드한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;체크리스트&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 히어로 이미지 포맷·크기를 최적화하고 &lt;code&gt;preload&lt;/code&gt;를 적용했다.&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 핵심 CSS는 인라인하고 비핵심 CSS는 지연 로드했다.&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 폰트에 &lt;code&gt;preload&lt;/code&gt;와 &lt;code&gt;font-display: swap&lt;/code&gt;을 적용하고 서브셋을 구성했다.&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 이미지·동영상에 &lt;code&gt;width/height&lt;/code&gt; 또는 &lt;code&gt;aspect-ratio&lt;/code&gt;를 지정했다.&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; 라우트 단위 코드 분할을 적용하고 타사 스크립트 예산을 수립했다.&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;&gt; RUM 수집(web-vitals)으로 실제 사용자 데이터를 확인했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;결론: 개념 정리와 적용 가이드&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;핵심&lt;/strong&gt;: LCP(로딩), INP(응답성), CLS(시각 안정성)는 서로 다른 관점에서 동시에 관리해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;적용 순서&lt;/strong&gt;:&lt;br&gt;1) TTFB와 히어로 리소스를 최적화하여 LCP를 개선한다.&lt;br&gt;2) 코드 분할과 작업 분할로 INP를 개선한다.&lt;br&gt;3) 크기 예약과 폰트 전략으로 CLS를 개선한다.&lt;br&gt;4) RUM 수집으로 실제 사용자 기준을 확인하고 회귀를 방지한다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/127</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/Core-Web-Vitals-LCP-INP-CLS-%EA%B0%9C%EB%85%90%EA%B3%BC-%EA%B0%9C%EC%84%A0-%EB%B0%A9%EB%B2%95#entry127comment</comments>
      <pubDate>Wed, 20 Aug 2025 17:48:51 +0900</pubDate>
    </item>
    <item>
      <title>코드가 깔끔해지는 비밀: 프론트엔드에서 함수형 프로그래밍 활용하기</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EC%BD%94%EB%93%9C%EA%B0%80-%EA%B9%94%EB%81%94%ED%95%B4%EC%A7%80%EB%8A%94-%EB%B9%84%EB%B0%80-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-%ED%95%A8%EC%88%98%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0</link>
      <description>&lt;p&gt;프론트엔드를 하다 보면 상태 변경과 데이터 변환이 정말 자주 등장한다. 이때 함수형 프로그래밍(FP)은 코드를 더 예측 가능하고 테스트하기 쉽게 만든다. 이번 글은 핵심을 짧게 정리하고, 코드 예제로 바로 확인한다. 실제로 언제 FP가 맞는지도 함께 본다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. 기본 개념 한 줄 요약&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;함수형 프로그래밍은 순수 함수와 불변성에 기반한 선언적 스타일이다.&lt;/li&gt;
&lt;li&gt;동일 입력 → 동일 출력, 외부 상태 변경 없음. 원본은 건드리지 않고 새 값을 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;2. 순수 함수 vs 부수효과, 코드로 비교&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;  // 순수: 동일 입력이면 항상 동일 출력, 외부 상태 변경 없음
  function add(a: number, b: number): number {
    return a + b;
  }

  // 비순수: 외부 상태(total)를 변경 → 테스트와 예측이 어려워짐
  let total = 0;
  function accumulate(xs: number[]) {
    for (const x of xs) total += x; // 외부 변수 수정(부수효과)
  }&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;3. 불변성: 원본을 바꾸지 않고 새 값을 만든다&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;  const numbers = [1, 2, 3];
  const newNumbers = numbers.concat(4); // 또는 [...numbers, 4]
  // numbers는 그대로, newNumbers는 새로운 배열&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;배열/객체 업데이트에서도 동일하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;  type Todo = { id: number; title: string; done: boolean };

  const todos: Todo[] = [
    { id: 1, title: &amp;#39;learn fp&amp;#39;, done: false },
    { id: 2, title: &amp;#39;write code&amp;#39;, done: false },
  ];

  // 항목 토글(원본 유지)
  const toggle = (id: number) =&amp;gt; (t: Todo) =&amp;gt;
    t.id === id ? { ...t, done: !t.done } : t;

  const updated = todos.map(toggle(1));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;안전한 정렬도 복사 후 처리한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;  const xs = [3, 1, 2];
  const sorted = [...xs].sort((a, b) =&amp;gt; a - b); // 원본 보존&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;4. 고차 함수·커링·합성: 작은 함수를 연결해 큰 일을 한다&lt;/h2&gt;
&lt;p&gt;고차 함수는 함수를 인자로 받거나 반환한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;  function withLogging&amp;lt;T&amp;gt;(fn: (x: T) =&amp;gt; T) {
    return (x: T) =&amp;gt; {
      console.log(&amp;#39;input:&amp;#39;, x);
      const result = fn(x);
      console.log(&amp;#39;output:&amp;#39;, result);
      return result;
    };
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;커링/부분 적용은 인자를 부분적으로 고정해 재사용성을 높인다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;  const multiply = (a: number) =&amp;gt; (b: number) =&amp;gt; a * b;
  const double = multiply(2);
  double(10); // 20&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;합성은 여러 함수를 파이프라인으로 잇는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;  const pipe = &amp;lt;T&amp;gt;(...fns: Array&amp;lt;(arg: T) =&amp;gt; T&amp;gt;) =&amp;gt; (initial: T) =&amp;gt;
    fns.reduce((acc, fn) =&amp;gt; fn(acc), initial);

  const trim = (s: string) =&amp;gt; s.trim();
  const toLower = (s: string) =&amp;gt; s.toLowerCase();
  const sanitize = pipe&amp;lt;string&amp;gt;(trim, toLower);
  sanitize(&amp;#39;  HeLLo  &amp;#39;); // &amp;#39;hello&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;선언적 데이터 변환 예시도 비슷하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;  const unique = (xs: number[]) =&amp;gt; Array.from(new Set(xs));
  const removeNeg = (xs: number[]) =&amp;gt; xs.filter((x) =&amp;gt; x &amp;gt;= 0);
  const sortAsc = (xs: number[]) =&amp;gt; [...xs].sort((a, b) =&amp;gt; a - b);

  const normalizeNumbers = pipe&amp;lt;number[]&amp;gt;(removeNeg, unique, sortAsc);
  normalizeNumbers([3, -1, 2, 3, 2]); // [2, 3]&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;5. 실제로 언제 FP가 더 자연스러운가&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;입력이 같으면 결과가 같아야 하는 계산 로직&lt;/li&gt;
&lt;li&gt;외부 의존성을 분리해 단위 테스트를 간단히 하고 싶은 경우&lt;/li&gt;
&lt;li&gt;공유 상태 변경으로 인한 버그를 줄이고 싶은 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;성능은 보통 큰 차이가 없다. 다만, 불변 업데이트가 과도하면 복사 비용이 생긴다. 필요한 곳만 최적화하면 된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. 프론트엔드 적용 시나리오&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;React 상태 업데이트: 불변 업데이트를 기본값으로 둔다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 체크된 ID 집합 토글러(불변 집합)
const toggleChecked = (id: number) =&amp;gt; (set: Set&amp;lt;number&amp;gt;) =&amp;gt; {
  const next = new Set(set);
  next.has(id) ? next.delete(id) : next.add(id);
  return next;
};&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;이벤트 핸들러 구성: 커링으로 인자 고정 후 재사용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const handleChangeFor = (field: &amp;#39;email&amp;#39; | &amp;#39;password&amp;#39;) =&amp;gt; (value: string) =&amp;gt; ({
  field,
  value,
});&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;API 응답 정규화: map/filter/reduce로 선언적으로 변환한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type User = { id: number; name: string; active: boolean };
const activeUserNames = (users: User[]) =&amp;gt;
  users.filter((u) =&amp;gt; u.active).map((u) =&amp;gt; u.name).sort();&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;7. 실무 팁과 흔한 실수&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;팁&lt;ul&gt;
&lt;li&gt;파이프라인 유틸(&lt;code&gt;pipe&lt;/code&gt;)을 모듈화해 중복을 없앤다.&lt;/li&gt;
&lt;li&gt;데이터 변경이 잦다면 구조를 단순화해 얕은 복사 비용을 줄인다.&lt;/li&gt;
&lt;li&gt;타입을 명확히 해 합성 과정에서 타입 안정성을 확보한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;흔한 실수&lt;ul&gt;
&lt;li&gt;원본을 바꾸는 메서드(&lt;code&gt;sort&lt;/code&gt;, &lt;code&gt;splice&lt;/code&gt; 등)를 그대로 사용&lt;/li&gt;
&lt;li&gt;과한 커링/합성으로 가독성 저하&lt;/li&gt;
&lt;li&gt;함수 내부에서 날짜/랜덤/네트워크 호출로 순수성을 깨뜨림&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;핵심은 작고 순수한 함수를 안전하게 조합하는 것이다. 프론트엔드 데이터 처리에 이 원칙을 적용하면, 유지보수성과 품질이 안정적으로 올라간다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/126</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EC%BD%94%EB%93%9C%EA%B0%80-%EA%B9%94%EB%81%94%ED%95%B4%EC%A7%80%EB%8A%94-%EB%B9%84%EB%B0%80-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-%ED%95%A8%EC%88%98%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0#entry126comment</comments>
      <pubDate>Mon, 18 Aug 2025 14:04:25 +0900</pubDate>
    </item>
    <item>
      <title>[JavaScript] while문 vs do-while문 차이점 비교</title>
      <link>https://white-mouse-dev.tistory.com/entry/JavaScript-while%EB%AC%B8-vs-do-while%EB%AC%B8-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EB%B9%84%EA%B5%90</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Hpnx5/btsPJjoelS3/IYiKJKg3kqYd0cu36CDKK1/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Hpnx5/btsPJjoelS3/IYiKJKg3kqYd0cu36CDKK1/img.webp&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Hpnx5/btsPJjoelS3/IYiKJKg3kqYd0cu36CDKK1/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHpnx5%2FbtsPJjoelS3%2FIYiKJKg3kqYd0cu36CDKK1%2Fimg.webp&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;프로그래밍을 하다 보면 반복문을 자주 사용하게 된다. 그중에서도 &lt;code&gt;while문&lt;/code&gt;과 &lt;code&gt;do-while문&lt;/code&gt;은 조건 기반 반복문으로 자주 비교된다. 이번 글에서는 이 두 문법의 차이를 직접 코드 예제를 통해 확인하고, 실제로 어떤 상황에서 어떤 문법이 더 적합한지를 정리해본다.&lt;/p&gt;
&lt;h2&gt;1. 기본적인 차이점&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;while문&lt;/code&gt;은 &lt;strong&gt;조건을 먼저 확인한 후&lt;/strong&gt; 코드 블록을 실행하고,&lt;br&gt;&lt;code&gt;do-while문&lt;/code&gt;은 &lt;strong&gt;코드 블록을 먼저 실행한 후&lt;/strong&gt; 조건을 확인한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// while문 - 조건 먼저 확인
let whileCounter = 0;
while (whileCounter &amp;lt; 3) {
    console.log(whileCounter);
    whileCounter++;
}

// do-while문 - 코드 먼저 실행
let doWhileCounter = 0;
do {
    console.log(doWhileCounter);
    doWhileCounter++;
} while (doWhileCounter &amp;lt; 3);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과적으로 while문은 조건이 false이면 한 번도 실행되지 않지만, do-while문은 조건이 false여도 최소 한 번은 실행된다.&lt;/p&gt;
&lt;h2&gt;2. 조건이 처음부터 false인 경우&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// while문
let whileFlag = false;
while (whileFlag) {
    console.log(&amp;#39;이 메시지는 출력되지 않음&amp;#39;);
}

// do-while문
let doWhileFlag = false;
do {
    console.log(&amp;#39;이 메시지는 출력됨 (최소 1번 실행)&amp;#39;);
} while (doWhileFlag);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;while문은 조건이 false이면 코드 블록이 아예 실행되지 않지만, do-while문은 최소 한 번은 실행되므로 사용자 알림이나 초기 처리 등에 적합하다.&lt;/p&gt;
&lt;h2&gt;3. 실제 사용 시나리오 비교&lt;/h2&gt;
&lt;h3&gt;사용자 입력 시뮬레이션&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// while문 (잘못된 방식)
let userInput = 0;
while (userInput &amp;lt; 1 || userInput &amp;gt; 10) {
    userInput = Math.floor(Math.random() * 15);
}

// do-while문 (권장되는 방식)
let validInput;
do {
    validInput = Math.floor(Math.random() * 15);
} while (validInput &amp;lt; 1 || validInput &amp;gt; 10);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;사용자에게 값을 최소 한 번은 입력받아야 할 때는 do-while문이 훨씬 적절하다.&lt;/p&gt;
&lt;h3&gt;메뉴 시스템&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// while문 (문제 있음)
let menuChoice = 0;
while (menuChoice !== 4) {
    // 메뉴 출력 및 선택
}

// do-while문 (더 자연스러움)
let choice;
do {
    // 메뉴 출력 및 선택
} while (choice !== 4);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;초기 메뉴 출력이 반드시 한 번은 실행되어야 하므로, 메뉴 시스템 구성 시에도 do-while문이 더 적합하다.&lt;/p&gt;
&lt;h2&gt;4. 성능 비교&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// while문 성능
let whileTest = 0;
while (whileTest &amp;lt; 1000000) {
    whileTest++;
}

// do-while문 성능
let doWhileTest = 0;
do {
    doWhileTest++;
} while (doWhileTest &amp;lt; 1000000);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;간단한 반복 작업에서는 성능 차이는 거의 없다. 따라서 성능보다는 코드 흐름의 자연스러움에 따라 선택하는 것이 좋다.&lt;/p&gt;
&lt;h2&gt;5. 요약&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;while문은 조건을 먼저 확인하고 실행한다.&lt;/li&gt;
&lt;li&gt;do-while문은 코드를 먼저 실행한 뒤 조건을 확인한다.&lt;/li&gt;
&lt;li&gt;do-while문은 조건이 false여도 최소 1회 실행된다.&lt;/li&gt;
&lt;li&gt;사용자 입력, 메뉴 시스템 등 초기 1회 실행이 필요한 경우 do-while문이 적합하다.&lt;/li&gt;
&lt;li&gt;성능상 큰 차이는 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;do-while문은 상대적으로 사용 빈도가 낮지만, 특정 상황에서는 매우 유용하게 사용될 수 있다. 따라서 두 문법의 차이를 정확히 이해하고 상황에 맞게 사용하는 것이 중요하다.&lt;/p&gt;</description>
      <category>Language/JavaScript</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/125</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/JavaScript-while%EB%AC%B8-vs-do-while%EB%AC%B8-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EB%B9%84%EA%B5%90#entry125comment</comments>
      <pubDate>Wed, 6 Aug 2025 09:59:16 +0900</pubDate>
    </item>
    <item>
      <title>JavaScript 코딩테스트 대비 정리 (레벨 5)</title>
      <link>https://white-mouse-dev.tistory.com/entry/JavaScript-%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8C%80%EB%B9%84-%EC%A0%95%EB%A6%AC-%EB%A0%88%EB%B2%A8-5</link>
      <description>&lt;p&gt;레벨 5는 알고리즘 문제 풀이에서 가장 높은 난이도를 가진 단계입니다. 복잡한 자료구조와 고급 최적화 기법을 자바스크립트로 구현할 수 있어야 하며, 성능과 메모리 효율을 고려한 코드 작성 능력이 요구됩니다.&lt;/p&gt;
&lt;h2&gt;  핵심 주제&lt;/h2&gt;
&lt;h3&gt;1. 메모이제이션 (Memoization)&lt;/h3&gt;
&lt;p&gt;중복 계산을 피하기 위한 값 저장 기법입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const memo = {};
function fib(n) {
  if (n &amp;lt; 2) return n;
  if (memo[n] !== undefined) return memo[n];
  memo[n] = fib(n - 1) + fib(n - 2);
  return memo[n];
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DFS + DP 유형에서도 자주 사용됩니다. 키로 &lt;code&gt;(node, step)&lt;/code&gt; 형태의 값을 Map에 저장해 중복 탐색을 방지합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;2. 캐싱&lt;/h3&gt;
&lt;p&gt;입력이 동일한 연산 결과를 저장합니다. 메모이제이션과 유사하나 범용적입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const cache = new Map();
function expensiveFn(x) {
  if (cache.has(x)) return cache.get(x);
  const result = complexCalculation(x);
  cache.set(x, result);
  return result;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;3. 동적 프로그래밍 최적화&lt;/h3&gt;
&lt;h4&gt;- 1차원 배열로 메모리 절약&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// 0/1 배낭 문제 1D DP
const dp = Array(W + 1).fill(0);
for (let i = 0; i &amp;lt; N; i++) {
  for (let w = W; w &amp;gt;= weights[i]; w--) {
    dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;- 비트 마스크 + DP&lt;/h4&gt;
&lt;p&gt;2^N 상태에 대해 메모이제이션 + DFS를 조합한 방식입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt; ️ 고급 자료구조&lt;/h2&gt;
&lt;h3&gt;Trie&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;class TrieNode {
  constructor() {
    this.children = {};
    this.isEnd = false;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Union-Find (Disjoint Set)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const parent = Array(n+1).fill().map((_, i) =&amp;gt; i);
function find(x) {
  if (parent[x] === x) return x;
  return parent[x] = find(parent[x]);
}
function union(a, b) {
  const rootA = find(a), rootB = find(b);
  if (rootA !== rootB) parent[rootB] = rootA;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;세그먼트 트리 (Segment Tree)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function build(arr, node, start, end) {
  if (start === end) {
    tree[node] = arr[start];
  } else {
    const mid = Math.floor((start + end) / 2);
    build(arr, 2*node, start, mid);
    build(arr, 2*node+1, mid+1, end);
    tree[node] = tree[2*node] + tree[2*node+1];
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;깊은 재귀에 주의. 입력 10^5 이상이면 JS에서 성능 저하 발생 가능.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;  함수형 스타일 활용&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const newArr = arr.map(x =&amp;gt; x * 2).filter(x =&amp;gt; x % 3 === 0);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;함수형 스타일은 불변성을 유지하고, 부작용을 최소화하지만, 메모리와 성능에 영향이 있을 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;⚙️ 알고리즘 성능 최적화&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Big-O 줄이기&lt;/strong&gt;: O(N^2) → O(N log N) 또는 O(N)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;정렬 + 투 포인터&lt;/strong&gt;, &lt;strong&gt;Map 활용&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;사전 계산 (에라토스테네스의 체 등)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const isPrime = Array(1000001).fill(true);
isPrime[0] = isPrime[1] = false;
for (let i = 2; i * i &amp;lt;= 1000000; i++) {
  if (isPrime[i]) {
    for (let j = i * i; j &amp;lt;= 1000000; j += i) {
      isPrime[j] = false;
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;  테스트 &amp;amp; 디버깅&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;console.time()&lt;/code&gt; / &lt;code&gt;console.timeEnd()&lt;/code&gt;으로 시간 측정&lt;/li&gt;
&lt;li&gt;작은 테스트 케이스로 검증 반복&lt;/li&gt;
&lt;li&gt;경계 조건 확인 (배열 범위, DP 초기값 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 최종 팁 요약&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;재귀 깊이, 객체 참조 등 JS 한계 인식&lt;/li&gt;
&lt;li&gt;불필요한 루프 안 연산 제거&lt;/li&gt;
&lt;li&gt;캐시, Map 적극 활용&lt;/li&gt;
&lt;li&gt;정렬 + 이분탐색, 슬라이딩 윈도우, 해시 맵 등의 기법 숙지&lt;/li&gt;
&lt;li&gt;최종 제출 전 &lt;strong&gt;입력 크기 기준 시간복잡도&lt;/strong&gt; 반드시 계산&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;JavaScript는 자료구조 라이브러리가 부족하지만, 다양한 구현력과 최적화 전략으로 고난이도 문제도 충분히 해결할 수 있습니다. 꾸준한 연습과 사고력 확장을 통해 실전 코딩테스트를 정복합시다  &lt;/p&gt;</description>
      <category>Language/JavaScript</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/124</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/JavaScript-%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8C%80%EB%B9%84-%EC%A0%95%EB%A6%AC-%EB%A0%88%EB%B2%A8-5#entry124comment</comments>
      <pubDate>Mon, 4 Aug 2025 17:21:28 +0900</pubDate>
    </item>
    <item>
      <title>JavaScript 코딩테스트 대비 정리 (레벨 4)</title>
      <link>https://white-mouse-dev.tistory.com/entry/JavaScript-%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8C%80%EB%B9%84-%EC%A0%95%EB%A6%AC-%EB%A0%88%EB%B2%A8-4</link>
      <description>&lt;p&gt;레벨 4에서는 심화된 알고리즘과 자료구조를 JavaScript로 다룰 수 있어야 합니다. 그래프와 트리 등 복잡한 구조의 탐색, 백트래킹, 동적 계획법, 그리고 언어의 한계를 뛰어넘는 최적화 기법 등이 요구됩니다.&lt;/p&gt;
&lt;h2&gt;그래프 탐색 (DFS/BFS 활용)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;그래프 표현&lt;/strong&gt;: 인접 리스트/행렬로 표현&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;방문 처리&lt;/strong&gt;: 배열 또는 &lt;code&gt;Set&lt;/code&gt; 사용&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;다중 컴포넌트&lt;/strong&gt; 탐색 필요&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;가중치 그래프&lt;/strong&gt;: 다익스트라(우선순위 큐 필요), 0-1 BFS, A* 알고리즘&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;트리 구조 및 조작&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;트리 순회&lt;/strong&gt;: 전위, 중위, 후위 (DFS/BFS 활용)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;트라이(Trie)&lt;/strong&gt; 구현:&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;class TrieNode {
constructor() { this.children = {}; this.end = false; }
}
class Trie {
constructor() { this.root = new TrieNode(); }
insert(word) {
  let node = this.root;
  for (const ch of word) {
    if (!node.children[ch]) node.children[ch] = new TrieNode();
    node = node.children[ch];
  }
  node.end = true;
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;세그먼트 트리&lt;/strong&gt;: 배열 기반, 1-based index 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;정렬 커스터마이징&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;다중 기준 정렬:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;arr.sort((a, b) =&amp;gt; {
if (a.key1 !== b.key1) return a.key1 - b.key1;
return b.key2 - a.key2;
});&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;안정 정렬이 필요할 경우 인덱스 추가 또는 직접 stable sort 구현&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;우선순위 큐 (Heap)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MaxHeap 예시&lt;/strong&gt;:&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;class MaxHeap {
constructor() { this.heap = [null]; }
push(value) {
  this.heap.push(value);
  let i = this.heap.length - 1;
  while (i &amp;gt; 1 &amp;amp;&amp;amp; this.heap[Math.floor(i/2)] &amp;lt; this.heap[i]) {
    [this.heap[i], this.heap[Math.floor(i/2)]] = [this.heap[Math.floor(i/2)], this.heap[i]];
    i = Math.floor(i/2);
  }
}
pop() {
  if (this.heap.length === 1) return null;
  const top = this.heap[1];
  this.heap[1] = this.heap.pop();
  let i = 1;
  while (true) {
    let left = 2*i, right = 2*i+1, largest = i;
    if (left &amp;lt; this.heap.length &amp;amp;&amp;amp; this.heap[left] &amp;gt; this.heap[largest]) largest = left;
    if (right &amp;lt; this.heap.length &amp;amp;&amp;amp; this.heap[right] &amp;gt; this.heap[largest]) largest = right;
    if (largest === i) break;
    [this.heap[i], this.heap[largest]] = [this.heap[largest], this.heap[i]];
    i = largest;
  }
  return top;
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;DFS + 백트래킹&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const result = [];
const temp = [];
function backtrack(start) {
  result.push([...temp]);
  for (let i = start; i &amp;lt; arr.length; i++) {
    temp.push(arr[i]);
    backtrack(i + 1);
    temp.pop();
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;활용 예시&lt;/strong&gt;: 순열, 조합, N-Queen, 부분집합, 제약 충족 해 찾기 등&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;동적 계획법 (DP)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;하향식 (memoization)&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const memo = {};
function fib(x) {
if (x &amp;lt; 2) return x;
if (memo[x]) return memo[x];
return memo[x] = fib(x-1) + fib(x-2);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;상향식 (tabulation)&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const dp = [0, 1];
for (let i = 2; i &amp;lt;= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;2차원 DP&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const dp = Array.from({length: N}, () =&amp;gt; Array(M).fill(0));&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;비트마스크 DP&lt;/strong&gt;: &lt;code&gt;dp[mask][i]&lt;/code&gt; 패턴&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;비트 연산 테크닉&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;부분집합 생성&lt;/strong&gt;: &lt;code&gt;for (let mask = 0; mask &amp;lt; (1 &amp;lt;&amp;lt; n); mask++)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;비트카운트&lt;/strong&gt;: &lt;code&gt;x.toString(2).split(&amp;#39;0&amp;#39;).join(&amp;#39;&amp;#39;).length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;XOR 활용&lt;/strong&gt;: 스왑 또는 고유값 추출&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;JS 구현 시 주의사항&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;대용량 처리&lt;/strong&gt;: &lt;code&gt;readFileSync&lt;/code&gt; + 정규식 분할&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;메모리 관리&lt;/strong&gt;: 큰 문자열은 &lt;code&gt;Array.join&lt;/code&gt;, 큰 배열은 복사 자제&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;정수 범위&lt;/strong&gt;: &lt;code&gt;BigInt&lt;/code&gt; 사용 필요시 구분&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;깊은 비교&lt;/strong&gt;: 직렬화 또는 고유 키 활용&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Node.js 특성&lt;/strong&gt;: 싱글스레드 환경&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;레벨 4는 JS로 알고리즘 문제를 풀면서 언어적 한계와 성능 최적화까지 고려하는 수준입니다. 위 기법들을 정확히 이해하고 활용하는 것이 중요합니다.&lt;/p&gt;</description>
      <category>Language/JavaScript</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/123</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/JavaScript-%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8C%80%EB%B9%84-%EC%A0%95%EB%A6%AC-%EB%A0%88%EB%B2%A8-4#entry123comment</comments>
      <pubDate>Mon, 4 Aug 2025 17:21:02 +0900</pubDate>
    </item>
    <item>
      <title>JavaScript 코딩테스트 대비 정리 (레벨 3)</title>
      <link>https://white-mouse-dev.tistory.com/entry/JavaScript-%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8C%80%EB%B9%84-%EC%A0%95%EB%A6%AC-%EB%A0%88%EB%B2%A8-3</link>
      <description>&lt;p&gt;레벨 3에서는 재귀 알고리즘과 자료구조를 직접 구현하거나 활용하는 문제가 등장합니다. 재귀 호출, 그래프 탐색, 큐/스택 구현, 이진 탐색과 같은 알고리즘 패턴을 자바스크립트로 다룰 수 있어야 합니다. 또한 문자열/배열 조작 기법과 일부 유용한 메서드의 심화 사용이 요구됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 재귀 함수&lt;/h2&gt;
&lt;p&gt;재귀는 함수가 자기 자신을 호출하여 문제를 해결하는 기법입니다. 예를 들어 팩토리얼 계산이나 DFS 탐색을 재귀로 구현할 수 있습니다. &lt;strong&gt;종료 조건(base case)&lt;/strong&gt;을 명확히 해야 하며, 너무 깊은 재귀는 &lt;code&gt;Maximum call stack size exceeded&lt;/code&gt; 오류가 발생할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function factorial(n) {
  if (n &amp;lt;= 1) return 1;
  return n * factorial(n - 1);
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;✅ DFS / BFS (깊이/너비 우선 탐색)&lt;/h2&gt;
&lt;h3&gt;DFS&lt;/h3&gt;
&lt;p&gt;재귀 또는 스택으로 구현하며, 깊이 우선으로 탐색합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function dfs(v, visited) {
  visited[v] = true;
  for (const next of graph[v]) {
    if (!visited[next]) dfs(next, visited);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;BFS&lt;/h3&gt;
&lt;p&gt;큐를 이용하여 너비 우선으로 탐색합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function bfs(start) {
  const queue = [start];
  const visited = { [start]: true };
  while (queue.length) {
    const v = queue.shift();
    for (const next of graph[v]) {
      if (!visited[next]) {
        visited[next] = true;
        queue.push(next);
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 큐와 스택 직접 구현&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;class Queue {
  constructor() {
    this.arr = [];
  }
  enqueue(x) { this.arr.push(x); }
  dequeue() { return this.arr.shift(); }
  isEmpty() { return this.arr.length === 0; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 이진 탐색 (Binary Search)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function binarySearch(arr, target) {
  let lo = 0, hi = arr.length - 1;
  while (lo &amp;lt;= hi) {
    const mid = Math.floor((lo + hi) / 2);
    if (arr[mid] === target) return mid;
    if (arr[mid] &amp;lt; target) lo = mid + 1;
    else hi = mid - 1;
  }
  return -1;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 문자열/배열 변형 테크닉&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;투 포인터&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;슬라이딩 윈도우&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;문자열 뒤집기&lt;/strong&gt;: &lt;code&gt;str.split(&amp;#39;&amp;#39;).reverse().join(&amp;#39;&amp;#39;)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;부분 치환&lt;/strong&gt;: &lt;code&gt;str.replaceAll(&amp;quot;a&amp;quot;, &amp;quot;b&amp;quot;)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;배열 삽입/삭제&lt;/strong&gt;: &lt;code&gt;splice()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 문자열 조작&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;정규식 치환: &lt;code&gt;str.replace(/[^a-z]/g, &amp;quot;&amp;quot;)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;중복 문자 압축: &lt;code&gt;(.)\1+&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;split with regex: &lt;code&gt;str.split(/[\s,]+/)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 배열 평탄화: &lt;code&gt;flat&lt;/code&gt;&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;[1, [2, [3, 4]]].flat(2); // [1, 2, 3, 4]&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;✅ reduceRight&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;[&amp;quot;a&amp;quot;, &amp;quot;b&amp;quot;, &amp;quot;c&amp;quot;].reduceRight((acc, x) =&amp;gt; acc + x); // &amp;quot;cba&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;✅ Object.entries &amp;amp; fromEntries&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const count = { a: 3, b: 1 };
const sorted = Object.entries(count).sort((a, b) =&amp;gt; b[1] - a[1]);&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;⚠️ JS 특유의 주의점&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Number 정밀도, BigInt 필요 여부&lt;/li&gt;
&lt;li&gt;문자열 이터레이션 주의 (이모지 등)&lt;/li&gt;
&lt;li&gt;재귀 깊이 제한&lt;/li&gt;
&lt;li&gt;불변성 고려 (slice, spread 등으로 복사)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;이상으로 레벨 3 중급 알고리즘 활용에서 자주 사용되는 JS 문법과 기법들을 정리했습니다. 문제 풀이에 반복적으로 등장하는 테크닉이므로 숙련도를 높여두면 유리합니다.&lt;/p&gt;</description>
      <category>Language/JavaScript</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/122</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/JavaScript-%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8C%80%EB%B9%84-%EC%A0%95%EB%A6%AC-%EB%A0%88%EB%B2%A8-3#entry122comment</comments>
      <pubDate>Mon, 4 Aug 2025 17:20:29 +0900</pubDate>
    </item>
    <item>
      <title>JavaScript 코딩테스트 대비 정리 (레벨 2)</title>
      <link>https://white-mouse-dev.tistory.com/entry/JavaScript-%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8C%80%EB%B9%84-%EC%A0%95%EB%A6%AC-%EB%A0%88%EB%B2%A8-2</link>
      <description>&lt;p&gt;코딩테스트에서 기본적인 문제를 해결하려면 자바스크립트 문법 중에서도 조금 더 실전적인 도구들을 다룰 줄 알아야 합니다.&lt;br&gt;레벨 2에서는 고차 함수, 객체/Set/Map 활용, 정렬과 정규식, 그리고 ES6 이상의 편의 문법들을 중심으로 정리합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 고차 함수 (map, filter, reduce)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;map&lt;/strong&gt;: 각 요소에 함수를 적용해 새로운 배열 생성&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;[1, 4, 9].map(x =&amp;gt; x * 2); // [2, 8, 18]&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;filter&lt;/strong&gt;: 조건에 맞는 요소만 걸러냄&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;[5, 12, 8].filter(x =&amp;gt; x &amp;gt;= 10); // [12]&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;reduce&lt;/strong&gt;: 모든 요소를 누적 계산&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;[2, 4, 6].reduce((sum, num) =&amp;gt; sum + num, 0); // 12&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;※ reduce 사용 시 빈 배열 체크 &amp;amp; 초기값 설정 주의&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 객체 탐색&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;for...in&lt;/code&gt;: 객체 키 순회 (hasOwnProperty로 확인 권장)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;Object.entries(obj)&lt;/code&gt;: 키-값 쌍 배열 반환&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;for (const [k, v] of Object.entries(obj)) { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;Object.keys(obj)&lt;/code&gt; / &lt;code&gt;Object.values(obj)&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ Set / Map 활용&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Set&lt;/strong&gt;: 중복 제거, 값 존재 여부 체크 (O(1))&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const unique = [...new Set(arr)];&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Map&lt;/strong&gt;: 키-값 저장 (모든 타입 키 가능)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const freq = new Map();
for (const ch of &amp;quot;hello&amp;quot;) {
freq.set(ch, (freq.get(ch) || 0) + 1);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 정렬 (Array.sort)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;문자열 기반 정렬: &lt;code&gt;arr.sort()&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;숫자 정렬:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;arr.sort((a, b) =&amp;gt; a - b); // 오름차순&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;객체 정렬:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;people.sort((p, q) =&amp;gt; p.age - q.age);&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;문자열 정렬 (locale 고려):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;arr.sort((a, b) =&amp;gt; a.localeCompare(b));&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 정규 표현식 (RegExp)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const onlyDigits = str.replace(/[0-9]/g, &amp;quot;&amp;quot;);
const isNumeric = /^\d+$/.test(str);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;메서드:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;test()&lt;/code&gt;: 매칭 여부 확인&lt;/li&gt;
&lt;li&gt;&lt;code&gt;match()&lt;/code&gt;: 매칭된 내용 추출&lt;/li&gt;
&lt;li&gt;&lt;code&gt;replace()&lt;/code&gt;: 치환&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 스프레드 &amp;amp; 구조 분해 할당&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;스프레드&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const copy = [...arr];
const merged = [...arr1, ...arr2];
const max = Math.max(...arr);&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;구조 분해&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const [first, second] = arr;
const {name, age} = person;
const [head, ...tail] = [1,2,3]; // head=1, tail=[2,3]&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 기타 유용 메서드&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;arr.indexOf(value)&lt;/code&gt; / &lt;code&gt;arr.includes(value)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;arr.find(fn)&lt;/code&gt; / &lt;code&gt;arr.findIndex(fn)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;quot;str&amp;quot;.startsWith(prefix)&lt;/code&gt; / &lt;code&gt;&amp;quot;str&amp;quot;.endsWith(suffix)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;⚠️ JS 팁 (Level 2)&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;고차 함수 vs for 루프&lt;/strong&gt;: 성능 미세하게 다를 수 있으나 대부분은 고차 함수로 작성해도 문제 없음.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;객체 vs Map&lt;/strong&gt;: 키 타입 다양하거나 많은 경우 Map이 안전하고 성능도 좋음.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;얕은 복사 주의&lt;/strong&gt;: &lt;code&gt;{...obj}&lt;/code&gt;나 &lt;code&gt;[...arr]&lt;/code&gt;는 얕은 복사. 중첩된 값은 별도 처리 필요.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;정규식 활용&lt;/strong&gt;: 간단한 문자열 검사는 정규식으로 짧게 가능. 단, 과한 패턴은 성능 문제 유발 가능.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;</description>
      <category>Language/JavaScript</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/121</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/JavaScript-%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8C%80%EB%B9%84-%EC%A0%95%EB%A6%AC-%EB%A0%88%EB%B2%A8-2#entry121comment</comments>
      <pubDate>Mon, 4 Aug 2025 17:19:50 +0900</pubDate>
    </item>
    <item>
      <title>JavaScript 코딩테스트 대비 정리 (레벨 1)</title>
      <link>https://white-mouse-dev.tistory.com/entry/JavaScript-%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8C%80%EB%B9%84-%EC%A0%95%EB%A6%AC-%EB%A0%88%EB%B2%A8-1</link>
      <description>&lt;p&gt;코딩테스트를 준비할 때 반드시 알고 있어야 할 JavaScript 기초 문법과 함수들을 정리한 문서입니다.&lt;br&gt;이 문서는 레벨 1 (입문) 수준으로 구성되어 있으며, 변수 선언, 조건문, 반복문, 배열/문자열 처리, 내장 함수 사용 등을 포함합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 변수 선언: var, let, const&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;var&lt;/code&gt;: 함수 스코프, 중복 선언 가능, 호이스팅 발생 (초기화 없이 사용 가능). 사용 비추천.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;let&lt;/code&gt;: 블록 스코프, 호이스팅은 되지만 TDZ(일시적 사각지대)로 초기화 전 사용 불가.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;const&lt;/code&gt;: 블록 스코프, 재할당 불가. 기본적으로 상수 선언에 사용.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;일반적으로 &lt;code&gt;const&lt;/code&gt; → 재할당 없는 값, &lt;code&gt;let&lt;/code&gt; → 재할당 가능한 값, &lt;code&gt;var&lt;/code&gt;는 사용 지양.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 조건문 (if / 삼항 연산자)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;if (조건) {
  // 실행 코드
} else if (조건2) {
  // 실행 코드
} else {
  // 실행 코드
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;조건식은 Boolean으로 평가됨&lt;/li&gt;
&lt;li&gt;falsy 값: &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;&amp;quot;&amp;quot;&lt;/code&gt;, &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;undefined&lt;/code&gt;, &lt;code&gt;NaN&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;삼항 연산자: &lt;code&gt;조건 ? 값1 : 값2&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 반복문 (for, while, for...of)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;for (let i = 0; i &amp;lt; arr.length; i++) { ... }
while (조건) { ... }
do { ... } while (조건);&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;for...of&lt;/code&gt;: 배열 순회에 유용&lt;/li&gt;
&lt;li&gt;&lt;code&gt;for...in&lt;/code&gt;: 객체의 열거 가능한 속성 순회 (배열에 비추천)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 배열 순회 방법&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;전통적인 for 루프&lt;/li&gt;
&lt;li&gt;&lt;code&gt;for...of&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;forEach&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;arr.forEach((item, index) =&amp;gt; {
  console.log(item, index);
});&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 문자열 처리&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;인덱스로 접근 가능: &lt;code&gt;str[0]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;길이 확인: &lt;code&gt;str.length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;문자열은 불변(immutable)&lt;/li&gt;
&lt;li&gt;문자열 연결: &lt;code&gt;+&lt;/code&gt;, 템플릿 리터럴(&lt;code&gt;`${}`&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;주요 메서드:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;substring(start, end)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;slice(start, end)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;includes(value)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;split(separator)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;join(separator)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 주요 내장 함수 요약&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;split(&amp;#39;,&amp;#39;)&lt;/code&gt;: 문자열 → 배열&lt;/li&gt;
&lt;li&gt;&lt;code&gt;join(&amp;#39;-&amp;#39;)&lt;/code&gt;: 배열 → 문자열&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sort(compareFn)&lt;/code&gt;: 정렬 (문자열 기반 / 숫자는 &lt;code&gt;compareFn&lt;/code&gt; 필요)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;push&lt;/code&gt;, &lt;code&gt;pop&lt;/code&gt;: 배열 끝 추가/제거 (스택)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unshift&lt;/code&gt;, &lt;code&gt;shift&lt;/code&gt;: 배열 앞 추가/제거 (큐)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;slice(start, end)&lt;/code&gt;: 배열 일부 복사&lt;/li&gt;
&lt;li&gt;&lt;code&gt;substring(start, end)&lt;/code&gt;: 문자열 일부 추출&lt;/li&gt;
&lt;li&gt;&lt;code&gt;includes(value)&lt;/code&gt;: 포함 여부 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;✅ 숫자 변환 및 Math 함수&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;parseInt(str, 10)&lt;/code&gt;: 정수 변환 (문자열 일부만 읽음)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Number(str)&lt;/code&gt;: 전체 문자열을 숫자로 해석 (실패 시 NaN)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Math.min(...arr)&lt;/code&gt;, &lt;code&gt;Math.max(...arr)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;기타: &lt;code&gt;Math.abs&lt;/code&gt;, &lt;code&gt;Math.pow&lt;/code&gt;, &lt;code&gt;Math.floor&lt;/code&gt;, &lt;code&gt;Math.ceil&lt;/code&gt; 등&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;⚠️ 주의 사항&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;문자열은 불변 → 조작 시 새로운 문자열 생성 필요&lt;/li&gt;
&lt;li&gt;숫자 정렬 시 &lt;code&gt;arr.sort((a, b) =&amp;gt; a - b)&lt;/code&gt; 형태로 사용&lt;/li&gt;
&lt;li&gt;&lt;code&gt;parseInt&lt;/code&gt;는 항상 진법(10)을 명시할 것&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>Language/JavaScript</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/120</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/JavaScript-%EC%BD%94%EB%94%A9%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8C%80%EB%B9%84-%EC%A0%95%EB%A6%AC-%EB%A0%88%EB%B2%A8-1#entry120comment</comments>
      <pubDate>Mon, 4 Aug 2025 17:19:08 +0900</pubDate>
    </item>
    <item>
      <title>프론트엔드 개발자를 위한 AI 용어 완전 정리</title>
      <link>https://white-mouse-dev.tistory.com/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-AI-%EC%9A%A9%EC%96%B4-%EC%99%84%EC%A0%84-%EC%A0%95%EB%A6%AC</link>
      <description>&lt;p&gt;최근 웹 개발에서 AI 기능 통합이 급증하면서, 프론트엔드 개발자도 AI 관련 용어를 정확히 이해해야 할 필요성이 커지고 있습니다. ChatGPT API 연동부터 이미지 생성 AI까지, 다양한 AI 서비스를 프론트엔드에 통합하는 프로젝트가 늘어나고 있기 때문입니다.&lt;/p&gt;
&lt;p&gt;이 글에서는 프론트엔드 개발자가 꼭 알아야 할 AI 핵심 용어들을 체계적으로 정리했습니다. 각 용어의 정의와 함께 실제 프론트엔드 개발에서 어떻게 활용되는지 설명하겠습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;기본 AI 개념&lt;/h2&gt;
&lt;h3&gt;AI (Artificial Intelligence, 인공지능)&lt;/h3&gt;
&lt;p&gt;인간의 지능을 모방하여 학습, 추론, 문제 해결 등을 수행하는 컴퓨터 시스템입니다. 프론트엔드에서는 주로 REST API나 WebSocket을 통해 AI 서비스와 통신합니다.&lt;/p&gt;
&lt;h3&gt;머신러닝 (Machine Learning)&lt;/h3&gt;
&lt;p&gt;데이터에서 패턴을 자동으로 학습하여 예측이나 분류를 수행하는 AI의 하위 분야입니다. 프론트엔드에서는 학습된 모델의 추론 결과를 API 응답으로 받아 처리합니다.&lt;/p&gt;
&lt;h3&gt;딥러닝 (Deep Learning)&lt;/h3&gt;
&lt;p&gt;인공신경망을 기반으로 복잡한 패턴을 학습하는 머신러닝 기법입니다. 이미지, 음성, 텍스트 등 비정형 데이터 처리에 특화되어 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;주의할 점은&lt;/strong&gt; 브라우저에서 직접 딥러닝 모델을 실행하는 것은 성능상 제한적이므로, 대부분 서버 API를 통해 결과를 받아옵니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;데이터 관련 용어&lt;/h2&gt;
&lt;h3&gt;정형 데이터 (Structured Data)&lt;/h3&gt;
&lt;p&gt;데이터베이스의 테이블이나 JSON 객체처럼 미리 정의된 구조를 가진 데이터입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 정형 데이터 예시
interface UserData {
  id: number;
  name: string;
  age: number;
  email: string;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;비정형 데이터 (Unstructured Data)&lt;/h3&gt;
&lt;p&gt;텍스트, 이미지, 음성, 동영상 등 미리 정의된 구조가 없는 데이터입니다. AI 모델의 주요 입력 데이터 형태입니다.&lt;/p&gt;
&lt;h3&gt;데이터 라벨링 (Data Labeling)&lt;/h3&gt;
&lt;p&gt;머신러닝 모델 학습을 위해 데이터에 정답을 표시하는 작업입니다. 예를 들어, 이미지에 &amp;#39;고양이&amp;#39;, &amp;#39;개&amp;#39; 등의 태그를 붙이는 것입니다.&lt;/p&gt;
&lt;h3&gt;훈련/검증/테스트 데이터&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;훈련 데이터(Training Data)&lt;/strong&gt;: 모델 학습에 사용&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;검증 데이터(Validation Data)&lt;/strong&gt;: 모델 성능 조정에 사용  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;테스트 데이터(Test Data)&lt;/strong&gt;: 최종 성능 평가에 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;실제로는&lt;/strong&gt; 프론트엔드 개발자가 이러한 데이터 분할을 직접 다룰 일은 드물지만, AI 팀과 협업할 때 이해하고 있으면 유용합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;모델 관련 용어&lt;/h2&gt;
&lt;h3&gt;모델 (Model)&lt;/h3&gt;
&lt;p&gt;학습 데이터를 기반으로 예측이나 분류를 수행하는 수학적 알고리즘입니다. 프론트엔드에서는 API 엔드포인트를 통해 모델과 상호작용합니다.&lt;/p&gt;
&lt;h3&gt;하이퍼파라미터 (Hyperparameter)&lt;/h3&gt;
&lt;p&gt;모델 학습 전에 설정하는 매개변수입니다. 학습률(learning rate), 배치 크기(batch size) 등이 있습니다.&lt;/p&gt;
&lt;h3&gt;과적합 (Overfitting)&lt;/h3&gt;
&lt;p&gt;모델이 학습 데이터에만 과도하게 최적화되어 새로운 데이터에서 성능이 떨어지는 현상입니다.&lt;/p&gt;
&lt;h3&gt;파인튜닝 (Fine-tuning)&lt;/h3&gt;
&lt;p&gt;사전 훈련된 모델을 특정 작업에 맞게 추가 학습시키는 과정입니다. 적은 데이터로도 좋은 성능을 얻을 수 있습니다.&lt;/p&gt;
&lt;h3&gt;프롬프트 엔지니어링 (Prompt Engineering)&lt;/h3&gt;
&lt;p&gt;생성형 AI 모델에서 원하는 결과를 얻기 위해 입력 프롬프트를 설계하고 최적화하는 기법입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 프롬프트 예시
const generatePrompt = (userInput: string) =&amp;gt; {
  return `다음 텍스트를 요약해주세요:

${userInput}

요약:`;
};&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;성능 평가 지표&lt;/h2&gt;
&lt;h3&gt;정확도 (Accuracy)&lt;/h3&gt;
&lt;p&gt;전체 예측 중 올바른 예측의 비율입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;정확도 = (올바른 예측 수) / (전체 예측 수)&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;정밀도 (Precision)&lt;/h3&gt;
&lt;p&gt;모델이 양성으로 예측한 것 중 실제로 양성인 비율입니다.&lt;/p&gt;
&lt;h3&gt;재현율 (Recall)&lt;/h3&gt;
&lt;p&gt;실제 양성 중 모델이 올바르게 양성으로 예측한 비율입니다.&lt;/p&gt;
&lt;h3&gt;F1 Score&lt;/h3&gt;
&lt;p&gt;정밀도와 재현율의 조화평균으로, 두 지표의 균형을 나타냅니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;주의할 점은&lt;/strong&gt; 프론트엔드에서는 이러한 기술적 지표보다 사용자 만족도나 응답 시간 같은 UX 지표가 더 중요할 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;운영 관련 용어&lt;/h2&gt;
&lt;h3&gt;추론 (Inference)&lt;/h3&gt;
&lt;p&gt;학습이 완료된 모델이 새로운 입력 데이터에 대해 예측을 수행하는 과정입니다. 프론트엔드에서 AI API를 호출하는 것이 추론 과정에 해당합니다.&lt;/p&gt;
&lt;h3&gt;MLOps (Machine Learning Operations)&lt;/h3&gt;
&lt;p&gt;머신러닝 모델의 개발, 배포, 모니터링, 유지보수를 자동화하고 체계화하는 방법론입니다.&lt;/p&gt;
&lt;h3&gt;API (Application Programming Interface)&lt;/h3&gt;
&lt;p&gt;프론트엔드와 AI 모델 간의 통신 인터페이스입니다. REST API, GraphQL, WebSocket 등의 형태로 제공됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// AI API 호출 예시
const callAIAPI = async (input: string): Promise&amp;lt;string&amp;gt; =&amp;gt; {
  const response = await fetch(&amp;#39;/api/ai/generate&amp;#39;, {
    method: &amp;#39;POST&amp;#39;,
    headers: {
      &amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39;,
    },
    body: JSON.stringify({ prompt: input }),
  });

  const result = await response.json();
  return result.output;
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;모델 서빙 (Model Serving)&lt;/h3&gt;
&lt;p&gt;훈련된 모델을 실제 서비스 환경에서 사용할 수 있도록 배포하고 운영하는 과정입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;최신 AI 기술 용어&lt;/h2&gt;
&lt;h3&gt;LLM (Large Language Model)&lt;/h3&gt;
&lt;p&gt;대규모 텍스트 데이터로 훈련된 언어 모델입니다. GPT, Claude, Gemini 등이 대표적입니다.&lt;/p&gt;
&lt;h3&gt;생성형 AI (Generative AI)&lt;/h3&gt;
&lt;p&gt;텍스트, 이미지, 음성 등 새로운 콘텐츠를 생성하는 AI입니다. 프론트엔드에서 창작 도구나 개인화 서비스 구현에 활용됩니다.&lt;/p&gt;
&lt;h3&gt;토큰 (Token)&lt;/h3&gt;
&lt;p&gt;AI 모델이 텍스트를 처리하는 최소 단위입니다. 보통 단어의 일부나 전체에 해당합니다.&lt;/p&gt;
&lt;h3&gt;컨텍스트 윈도우 (Context Window)&lt;/h3&gt;
&lt;p&gt;모델이 한 번에 처리할 수 있는 토큰의 최대 개수입니다. API 비용과 성능에 직접적인 영향을 미칩니다.&lt;/p&gt;
&lt;h3&gt;Zero-shot Learning&lt;/h3&gt;
&lt;p&gt;사전 학습 없이 새로운 작업을 수행하는 능력입니다.&lt;/p&gt;
&lt;h3&gt;Few-shot Learning&lt;/h3&gt;
&lt;p&gt;몇 개의 예시만으로 새로운 작업을 학습하는 능력입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Few-shot 예시
const fewShotPrompt = `
다음은 감정 분석 예시입니다:

텍스트: &amp;quot;오늘 날씨가 너무 좋네요!&amp;quot;
감정: 긍정

텍스트: &amp;quot;교통이 너무 막혀서 짜증나요.&amp;quot;
감정: 부정

텍스트: &amp;quot;${userInput}&amp;quot;
감정:`;&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;실무 활용 팁&lt;/h2&gt;
&lt;h3&gt;API 응답 타입 정의&lt;/h3&gt;
&lt;p&gt;TypeScript를 사용하여 AI API 응답 구조를 명확히 정의하는 것이 중요합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;interface AIResponse {
  success: boolean;
  data: {
    result: string;
    confidence: number;
    tokens_used: number;
  };
  error?: string;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;에러 처리 전략&lt;/h3&gt;
&lt;p&gt;AI API는 외부 서비스이므로 적절한 에러 처리와 폴백 전략이 필요합니다.&lt;/p&gt;
&lt;h3&gt;성능 최적화&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;요청 디바운싱으로 불필요한 API 호출 방지&lt;/li&gt;
&lt;li&gt;응답 캐싱으로 비용 절약&lt;/li&gt;
&lt;li&gt;로딩 상태 관리로 사용자 경험 개선&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;실제로는&lt;/strong&gt; AI API의 응답 시간이 길 수 있으므로, 적절한 로딩 인디케이터와 스트리밍 응답 처리가 중요합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;핵심 요약&lt;/h2&gt;
&lt;p&gt;프론트엔드 개발자가 AI 프로젝트에서 성공하려면 다음 용어들을 확실히 이해해야 합니다:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;필수 개념&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AI, 머신러닝, 딥러닝의 차이점&lt;/li&gt;
&lt;li&gt;정형/비정형 데이터의 구분&lt;/li&gt;
&lt;li&gt;추론과 API 통신 방식&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;실무 핵심&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LLM과 생성형 AI의 특성&lt;/li&gt;
&lt;li&gt;토큰과 컨텍스트 윈도우 제한&lt;/li&gt;
&lt;li&gt;프롬프트 엔지니어링 기법&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;성능 관리&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;API 응답 시간과 사용자 경험&lt;/li&gt;
&lt;li&gt;에러 처리와 폴백 전략&lt;/li&gt;
&lt;li&gt;비용 최적화 방법&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이러한 용어들을 정확히 이해하면 AI 팀과의 협업이 훨씬 원활해지고, 더 나은 사용자 경험을 제공하는 AI 기능을 구현할 수 있습니다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/119</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-AI-%EC%9A%A9%EC%96%B4-%EC%99%84%EC%A0%84-%EC%A0%95%EB%A6%AC#entry119comment</comments>
      <pubDate>Tue, 29 Jul 2025 16:00:35 +0900</pubDate>
    </item>
    <item>
      <title>시간복잡도와 공간복잡도: 개발자가 알아야 할 성능 최적화의 기초</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EC%8B%9C%EA%B0%84%EB%B3%B5%EC%9E%A1%EB%8F%84%EC%99%80-%EA%B3%B5%EA%B0%84%EB%B3%B5%EC%9E%A1%EB%8F%84-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EC%95%8C%EC%95%84%EC%95%BC-%ED%95%A0-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EC%9D%98-%EA%B8%B0%EC%B4%88</link>
      <description>&lt;p&gt;프론트엔드 개발에서 성능은 사용자 경험을 좌우하는 핵심 요소입니다. 특히 대용량 데이터 처리, 복잡한 UI 렌더링, 실시간 검색 기능 등을 구현할 때 알고리즘의 효율성이 직접적으로 성능에 영향을 미칩니다. 이 글에서는 시간복잡도와 공간복잡도의 개념부터 실무에서의 활용 방법까지 체계적으로 정리하겠습니다.&lt;/p&gt;
&lt;h2&gt;알고리즘 복잡도란 무엇인가?&lt;/h2&gt;
&lt;h3&gt;성능 평가의 필요성&lt;/h3&gt;
&lt;p&gt;실제 개발에서 동일한 기능을 구현하는 여러 가지 방법이 존재할 때, 어떤 방법이 더 효율적인지 판단해야 합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 배열에서 최댓값 찾기 - 방법 1
function findMaxMethod1(arr) {
  let max = arr[0];
  for (let i = 1; i &amp;lt; arr.length; i++) {
    if (arr[i] &amp;gt; max) {
      max = arr[i];
    }
  }
  return max;
}

// 배열에서 최댓값 찾기 - 방법 2
function findMaxMethod2(arr) {
  return Math.max(...arr);
}

// 배열에서 최댓값 찾기 - 방법 3
function findMaxMethod3(arr) {
  return arr.sort((a, b) =&amp;gt; b - a)[0];
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위의 세 가지 방법 모두 같은 결과를 반환하지만, 성능은 크게 다릅니다. 실행 시간을 직접 측정할 수도 있지만, 이는 다음과 같은 문제가 있습니다:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;실행 시간 측정의 한계:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;하드웨어 환경에 따라 결과가 달라짐&lt;/li&gt;
&lt;li&gt;다른 프로그램의 영향을 받음&lt;/li&gt;
&lt;li&gt;작은 데이터에서는 차이를 감지하기 어려움&lt;/li&gt;
&lt;li&gt;매번 다른 결과가 나올 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;복잡도 분석의 장점&lt;/h3&gt;
&lt;p&gt;복잡도 분석은 이러한 문제를 해결하기 위해 &lt;strong&gt;연산의 횟수&lt;/strong&gt;와 &lt;strong&gt;메모리 사용량&lt;/strong&gt;을 기준으로 알고리즘을 평가합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;주요 장점:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;환경에 독립적인 평가&lt;/li&gt;
&lt;li&gt;입력 크기가 커질 때의 성능 예측 가능&lt;/li&gt;
&lt;li&gt;알고리즘 간 객관적 비교&lt;/li&gt;
&lt;li&gt;병목 지점 식별 용이&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;시간복잡도와 공간복잡도&lt;/h2&gt;
&lt;h3&gt;시간복잡도 (Time Complexity)&lt;/h3&gt;
&lt;p&gt;시간복잡도는 &lt;strong&gt;입력 크기 n에 대해 알고리즘이 수행하는 연산의 횟수&lt;/strong&gt;를 나타냅니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// O(1) - 상수 시간
function getFirstElement(arr) {
  return arr[0]; // 배열 크기와 관계없이 항상 1번의 연산
}

// O(n) - 선형 시간
function sumArray(arr) {
  let sum = 0;
  for (let i = 0; i &amp;lt; arr.length; i++) { // n번 반복
    sum += arr[i];
  }
  return sum;
}

// O(n²) - 이차 시간
function bubbleSort(arr) {
  for (let i = 0; i &amp;lt; arr.length; i++) {     // n번 반복
    for (let j = 0; j &amp;lt; arr.length - 1; j++) { // n번 반복
      if (arr[j] &amp;gt; arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }
  return arr;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;공간복잡도 (Space Complexity)&lt;/h3&gt;
&lt;p&gt;공간복잡도는 &lt;strong&gt;입력 크기 n에 대해 알고리즘이 사용하는 메모리 공간&lt;/strong&gt;을 나타냅니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// O(1) - 상수 공간
function reverseString(str) {
  let result = &amp;#39;&amp;#39;;
  for (let i = str.length - 1; i &amp;gt;= 0; i--) {
    result += str[i];
  }
  return result; // 입력 크기와 관계없이 고정된 메모리 사용
}

// O(n) - 선형 공간
function createArray(n) {
  const arr = new Array(n); // n개의 요소를 저장할 공간 필요
  for (let i = 0; i &amp;lt; n; i++) {
    arr[i] = i;
  }
  return arr;
}

// O(n) - 재귀 호출 스택
function factorial(n) {
  if (n &amp;lt;= 1) return 1;
  return n * factorial(n - 1); // n번의 재귀 호출로 스택에 n개의 프레임
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;시간 vs 공간 트레이드오프&lt;/h3&gt;
&lt;p&gt;때로는 시간복잡도와 공간복잡도 사이에서 선택해야 하는 상황이 발생합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 피보나치 수열 - 시간 우선 (메모이제이션)
function fibonacciMemo(n, memo = {}) {
  if (n in memo) return memo[n];
  if (n &amp;lt;= 2) return 1;

  memo[n] = fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo);
  return memo[n];
}
// 시간: O(n), 공간: O(n)

// 피보나치 수열 - 공간 우선 (반복문)
function fibonacciIterative(n) {
  if (n &amp;lt;= 2) return 1;

  let prev = 1, curr = 1;
  for (let i = 3; i &amp;lt;= n; i++) {
    const next = prev + curr;
    prev = curr;
    curr = next;
  }
  return curr;
}
// 시간: O(n), 공간: O(1)&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;빅오 표기법 완전 정복&lt;/h2&gt;
&lt;h3&gt;빅오 표기법의 기본 원칙&lt;/h3&gt;
&lt;p&gt;빅오 표기법은 &lt;strong&gt;최악의 경우&lt;/strong&gt;를 기준으로 알고리즘의 성장률을 표현합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;핵심 원칙:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;최고차항만 고려&lt;/strong&gt;: O(n² + n + 1) → O(n²)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;계수 무시&lt;/strong&gt;: O(5n) → O(n)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;상수항 무시&lt;/strong&gt;: O(n + 100) → O(n)&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 잘못된 분석 예시와 올바른 분석
function complexFunction(n) {
  // 1. 상수 연산들
  const start = Date.now();
  let result = 0;

  // 2. O(n) 루프
  for (let i = 0; i &amp;lt; n; i++) {
    result += i;
  }

  // 3. O(n²) 중첩 루프
  for (let i = 0; i &amp;lt; n; i++) {
    for (let j = 0; j &amp;lt; n; j++) {
      result += i * j;
    }
  }

  // 4. 또 다른 O(n) 루프
  for (let i = 0; i &amp;lt; n * 3; i++) {
    result += 1;
  }

  return result;
}

// 잘못된 분석: O(1) + O(n) + O(n²) + O(3n) = O(1 + n + n² + 3n)
// 올바른 분석: O(n²) (최고차항만 고려)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;주요 복잡도 클래스&lt;/h3&gt;
&lt;h4&gt;1. O(1) - 상수 시간&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 배열 인덱스 접근
function getElement(arr, index) {
  return arr[index]; // 배열 크기와 무관하게 즉시 접근
}

// 해시맵 조회
const userMap = new Map();
function getUser(id) {
  return userMap.get(id); // 평균적으로 O(1)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2. O(log n) - 로그 시간&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 이진 탐색
function binarySearch(arr, target) {
  let left = 0, right = arr.length - 1;

  while (left &amp;lt;= right) {
    const mid = Math.floor((left + right) / 2);

    if (arr[mid] === target) return mid;
    if (arr[mid] &amp;lt; target) left = mid + 1;
    else right = mid - 1;
  }

  return -1;
}
// 매번 탐색 범위가 절반으로 줄어듦&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3. O(n) - 선형 시간&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 배열 순회
function findElement(arr, target) {
  for (let i = 0; i &amp;lt; arr.length; i++) {
    if (arr[i] === target) return i;
  }
  return -1;
}

// 배열 필터링
function filterEvenNumbers(arr) {
  return arr.filter(num =&amp;gt; num % 2 === 0);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;4. O(n log n) - 선형 로그 시간&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 효율적인 정렬 알고리즘
function mergeSort(arr) {
  if (arr.length &amp;lt;= 1) return arr;

  const mid = Math.floor(arr.length / 2);
  const left = mergeSort(arr.slice(0, mid));
  const right = mergeSort(arr.slice(mid));

  return merge(left, right);
}

function merge(left, right) {
  const result = [];
  let i = 0, j = 0;

  while (i &amp;lt; left.length &amp;amp;&amp;amp; j &amp;lt; right.length) {
    if (left[i] &amp;lt;= right[j]) {
      result.push(left[i++]);
    } else {
      result.push(right[j++]);
    }
  }

  return result.concat(left.slice(i)).concat(right.slice(j));
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;5. O(n²) - 이차 시간&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 중첩 루프
function findPairs(arr) {
  const pairs = [];
  for (let i = 0; i &amp;lt; arr.length; i++) {
    for (let j = i + 1; j &amp;lt; arr.length; j++) {
      pairs.push([arr[i], arr[j]]);
    }
  }
  return pairs;
}

// 비효율적인 정렬
function selectionSort(arr) {
  for (let i = 0; i &amp;lt; arr.length; i++) {
    let minIndex = i;
    for (let j = i + 1; j &amp;lt; arr.length; j++) {
      if (arr[j] &amp;lt; arr[minIndex]) {
        minIndex = j;
      }
    }
    [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
  }
  return arr;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;복잡도 성장률 비교&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 입력 크기별 연산 횟수 비교 (가상의 예시)
const n = 1000;

console.log(&amp;#39;O(1):&amp;#39;, 1);                    // 1
console.log(&amp;#39;O(log n):&amp;#39;, Math.log2(n));     // ~10
console.log(&amp;#39;O(n):&amp;#39;, n);                    // 1,000
console.log(&amp;#39;O(n log n):&amp;#39;, n * Math.log2(n)); // ~10,000
console.log(&amp;#39;O(n²):&amp;#39;, n * n);               // 1,000,000
console.log(&amp;#39;O(2^n):&amp;#39;, Math.pow(2, 20));    // 1,048,576 (n=20일 때)&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;JavaScript에서의 복잡도 분석&lt;/h2&gt;
&lt;h3&gt;내장 메서드의 복잡도&lt;/h3&gt;
&lt;p&gt;JavaScript 내장 메서드들의 시간복잡도를 이해하는 것은 실무에서 매우 중요합니다.&lt;/p&gt;
&lt;h4&gt;배열 메서드&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const arr = [1, 2, 3, 4, 5];

// O(1) - 상수 시간
arr.push(6);           // 끝에 추가
arr.pop();             // 끝에서 제거
arr[0];                // 인덱스 접근
arr.length;            // 길이 조회

// O(n) - 선형 시간
arr.unshift(0);        // 앞에 추가 (모든 요소 이동)
arr.shift();           // 앞에서 제거 (모든 요소 이동)
arr.indexOf(3);        // 요소 찾기
arr.includes(3);       // 요소 포함 확인
arr.slice(1, 3);       // 부분 배열 복사
arr.concat([6, 7]);    // 배열 합치기

// O(n) - 순회 기반
arr.forEach(x =&amp;gt; console.log(x));
arr.map(x =&amp;gt; x * 2);
arr.filter(x =&amp;gt; x &amp;gt; 2);
arr.reduce((sum, x) =&amp;gt; sum + x, 0);

// O(n log n) - 정렬
arr.sort((a, b) =&amp;gt; a - b);&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;객체와 Map 비교&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 객체 - 일반적으로 O(1)이지만 보장되지 않음
const obj = {};
obj.key = &amp;#39;value&amp;#39;;     // O(1) 평균
obj.key;               // O(1) 평균
delete obj.key;        // O(1) 평균

// Map - 모든 연산이 O(1) 보장
const map = new Map();
map.set(&amp;#39;key&amp;#39;, &amp;#39;value&amp;#39;); // O(1) 보장
map.get(&amp;#39;key&amp;#39;);          // O(1) 보장
map.delete(&amp;#39;key&amp;#39;);       // O(1) 보장
map.has(&amp;#39;key&amp;#39;);          // O(1) 보장&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Set을 활용한 최적화&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 배열에서 중복 제거 - 비효율적 방법 O(n²)
function removeDuplicatesArray(arr) {
  const result = [];
  for (const item of arr) {
    if (!result.includes(item)) { // O(n) 연산이 n번 반복
      result.push(item);
    }
  }
  return result;
}

// Set을 활용한 효율적 방법 O(n)
function removeDuplicatesSet(arr) {
  return [...new Set(arr)]; // Set 생성 O(n), 배열 변환 O(n)
}

// 성능 비교
const largeArray = Array(10000).fill().map(() =&amp;gt; Math.floor(Math.random() * 1000));

console.time(&amp;#39;Array method&amp;#39;);
removeDuplicatesArray(largeArray);
console.timeEnd(&amp;#39;Array method&amp;#39;); // 훨씬 오래 걸림

console.time(&amp;#39;Set method&amp;#39;);
removeDuplicatesSet(largeArray);
console.timeEnd(&amp;#39;Set method&amp;#39;); // 빠름&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;TypeScript에서의 복잡도 고려&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 제네릭을 활용한 효율적인 알고리즘
class OptimizedSearch&amp;lt;T&amp;gt; {
  private data: T[];
  private indexMap: Map&amp;lt;T, number&amp;gt;;

  constructor(data: T[]) {
    this.data = data;
    this.buildIndex(); // O(n) 전처리
  }

  private buildIndex(): void {
    this.indexMap = new Map();
    this.data.forEach((item, index) =&amp;gt; {
      this.indexMap.set(item, index);
    });
  }

  // O(1) 탐색
  find(item: T): number {
    return this.indexMap.get(item) ?? -1;
  }

  // O(n) 순차 탐색 (비교용)
  findLinear(item: T): number {
    return this.data.indexOf(item);
  }
}

// 사용 예시
const searcher = new OptimizedSearch([&amp;#39;apple&amp;#39;, &amp;#39;banana&amp;#39;, &amp;#39;cherry&amp;#39;]);
console.log(searcher.find(&amp;#39;banana&amp;#39;)); // O(1)&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;프론트엔드 실무 적용 사례&lt;/h2&gt;
&lt;h3&gt;1. 실시간 검색 최적화&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 비효율적인 실시간 검색 O(n) × 매 입력마다
function inefficientSearch(query, data) {
  return data.filter(item =&amp;gt; 
    item.toLowerCase().includes(query.toLowerCase())
  );
}

// 효율적인 실시간 검색 - 디바운싱 + 인덱싱
class SearchOptimizer {
  constructor(data) {
    this.data = data;
    this.searchIndex = this.buildSearchIndex(data); // O(n) 전처리
    this.debounceTime = 300;
  }

  buildSearchIndex(data) {
    const index = new Map();

    data.forEach((item, idx) =&amp;gt; {
      const words = item.toLowerCase().split(&amp;#39; &amp;#39;);
      words.forEach(word =&amp;gt; {
        for (let i = 1; i &amp;lt;= word.length; i++) {
          const prefix = word.substring(0, i);
          if (!index.has(prefix)) {
            index.set(prefix, new Set());
          }
          index.get(prefix).add(idx);
        }
      });
    });

    return index;
  }

  search(query) {
    if (!query) return [];

    const queryLower = query.toLowerCase();
    const matchingIndices = this.searchIndex.get(queryLower) || new Set();

    return Array.from(matchingIndices).map(idx =&amp;gt; this.data[idx]);
  }

  // 디바운싱을 적용한 검색
  debouncedSearch = this.debounce((query, callback) =&amp;gt; {
    const results = this.search(query);
    callback(results);
  }, this.debounceTime);

  debounce(func, wait) {
    let timeout;
    return function executedFunction(...args) {
      const later = () =&amp;gt; {
        clearTimeout(timeout);
        func(...args);
      };
      clearTimeout(timeout);
      timeout = setTimeout(later, wait);
    };
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 대용량 리스트 가상화 (Virtualization)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// React에서 가상 스크롤 구현
class VirtualList {
  constructor(containerHeight, itemHeight, totalItems) {
    this.containerHeight = containerHeight;
    this.itemHeight = itemHeight;
    this.totalItems = totalItems;
    this.visibleCount = Math.ceil(containerHeight / itemHeight);
    this.buffer = 5; // 버퍼 아이템 수
  }

  // O(1) - 현재 화면에 보여야 할 아이템 계산
  getVisibleRange(scrollTop) {
    const startIndex = Math.floor(scrollTop / this.itemHeight);
    const endIndex = Math.min(
      startIndex + this.visibleCount + this.buffer,
      this.totalItems
    );

    return {
      startIndex: Math.max(0, startIndex - this.buffer),
      endIndex,
      offsetY: startIndex * this.itemHeight
    };
  }
}

// React 컴포넌트에서 사용
function VirtualListComponent({ items }) {
  const [scrollTop, setScrollTop] = useState(0);
  const virtualList = new VirtualList(400, 50, items.length);

  const { startIndex, endIndex, offsetY } = virtualList.getVisibleRange(scrollTop);
  const visibleItems = items.slice(startIndex, endIndex);

  return (
    &amp;lt;div 
      style={{ height: 400, overflow: &amp;#39;auto&amp;#39; }}
      onScroll={(e) =&amp;gt; setScrollTop(e.target.scrollTop)}
    &amp;gt;
      &amp;lt;div style={{ height: items.length * 50, position: &amp;#39;relative&amp;#39; }}&amp;gt;
        &amp;lt;div style={{ transform: `translateY(${offsetY}px)` }}&amp;gt;
          {visibleItems.map((item, index) =&amp;gt; (
            &amp;lt;div key={startIndex + index} style={{ height: 50 }}&amp;gt;
              {item}
            &amp;lt;/div&amp;gt;
          ))}
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 상태 관리 최적화&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// Redux에서 정규화를 통한 O(1) 접근
// 비효율적인 구조 O(n)
const inefficientState = {
  posts: [
    { id: 1, title: &amp;#39;Post 1&amp;#39;, authorId: 1 },
    { id: 2, title: &amp;#39;Post 2&amp;#39;, authorId: 2 },
    // ... 수천 개의 포스트
  ],
  users: [
    { id: 1, name: &amp;#39;User 1&amp;#39; },
    { id: 2, name: &amp;#39;User 2&amp;#39; },
    // ... 수천 명의 사용자
  ]
};

// 포스트 찾기 O(n)
function findPost(state, postId) {
  return state.posts.find(post =&amp;gt; post.id === postId);
}

// 효율적인 정규화된 구조 O(1)
const normalizedState = {
  posts: {
    byId: {
      1: { id: 1, title: &amp;#39;Post 1&amp;#39;, authorId: 1 },
      2: { id: 2, title: &amp;#39;Post 2&amp;#39;, authorId: 2 },
    },
    allIds: [1, 2]
  },
  users: {
    byId: {
      1: { id: 1, name: &amp;#39;User 1&amp;#39; },
      2: { id: 2, name: &amp;#39;User 2&amp;#39; },
    },
    allIds: [1, 2]
  }
};

// 포스트 찾기 O(1)
function findPostOptimized(state, postId) {
  return state.posts.byId[postId];
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. 메모이제이션을 활용한 최적화&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// React.memo와 useMemo를 활용한 복잡도 최적화
import React, { useMemo, useState } from &amp;#39;react&amp;#39;;

function ExpensiveList({ items, filterCriteria }) {
  // O(n) 연산을 메모이제이션으로 최적화
  const filteredItems = useMemo(() =&amp;gt; {
    console.log(&amp;#39;Filtering items...&amp;#39;); // 의존성 변경 시에만 실행
    return items.filter(item =&amp;gt; item.category === filterCriteria);
  }, [items, filterCriteria]);

  // O(n log n) 정렬 연산 메모이제이션
  const sortedItems = useMemo(() =&amp;gt; {
    console.log(&amp;#39;Sorting items...&amp;#39;);
    return [...filteredItems].sort((a, b) =&amp;gt; a.name.localeCompare(b.name));
  }, [filteredItems]);

  return (
    &amp;lt;div&amp;gt;
      {sortedItems.map(item =&amp;gt; (
        &amp;lt;ExpensiveItem key={item.id} item={item} /&amp;gt;
      ))}
    &amp;lt;/div&amp;gt;
  );
}

// React.memo로 불필요한 리렌더링 방지
const ExpensiveItem = React.memo(({ item }) =&amp;gt; {
  console.log(`Rendering item ${item.id}`);
  return &amp;lt;div&amp;gt;{item.name}&amp;lt;/div&amp;gt;;
});&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;성능 측정과 최적화 전략&lt;/h2&gt;
&lt;h3&gt;1. 성능 측정 도구&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// Performance API를 활용한 정확한 측정
class PerformanceMeasurer {
  static measure(name, fn, ...args) {
    const startTime = performance.now();
    const result = fn(...args);
    const endTime = performance.now();

    console.log(`${name}: ${endTime - startTime}ms`);
    return result;
  }

  static async measureAsync(name, fn, ...args) {
    const startTime = performance.now();
    const result = await fn(...args);
    const endTime = performance.now();

    console.log(`${name}: ${endTime - startTime}ms`);
    return result;
  }

  static profile(name, fn, iterations = 1000) {
    const times = [];

    for (let i = 0; i &amp;lt; iterations; i++) {
      const start = performance.now();
      fn();
      const end = performance.now();
      times.push(end - start);
    }

    const avg = times.reduce((sum, time) =&amp;gt; sum + time, 0) / iterations;
    const min = Math.min(...times);
    const max = Math.max(...times);

    console.log(`${name} - Avg: ${avg.toFixed(3)}ms, Min: ${min.toFixed(3)}ms, Max: ${max.toFixed(3)}ms`);
  }
}

// 사용 예시
const largeArray = Array(100000).fill().map(() =&amp;gt; Math.floor(Math.random() * 1000));

PerformanceMeasurer.measure(&amp;#39;Linear Search&amp;#39;, findElement, largeArray, 500);
PerformanceMeasurer.measure(&amp;#39;Binary Search&amp;#39;, binarySearch, largeArray.sort(), 500);&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 브라우저 개발자 도구 활용&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// Performance API 마크 사용
function measureComplexOperation() {
  performance.mark(&amp;#39;operation-start&amp;#39;);

  // 복잡한 연산 수행
  const result = heavyComputation();

  performance.mark(&amp;#39;operation-end&amp;#39;);
  performance.measure(&amp;#39;Complex Operation&amp;#39;, &amp;#39;operation-start&amp;#39;, &amp;#39;operation-end&amp;#39;);

  // 측정 결과 확인
  const measures = performance.getEntriesByType(&amp;#39;measure&amp;#39;);
  console.log(measures[measures.length - 1]);

  return result;
}

// 메모리 사용량 모니터링
function monitorMemoryUsage() {
  if (performance.memory) {
    console.log(&amp;#39;Used Memory:&amp;#39;, performance.memory.usedJSHeapSize / 1024 / 1024, &amp;#39;MB&amp;#39;);
    console.log(&amp;#39;Total Memory:&amp;#39;, performance.memory.totalJSHeapSize / 1024 / 1024, &amp;#39;MB&amp;#39;);
    console.log(&amp;#39;Memory Limit:&amp;#39;, performance.memory.jsHeapSizeLimit / 1024 / 1024, &amp;#39;MB&amp;#39;);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 알고리즘 최적화 패턴&lt;/h3&gt;
&lt;h4&gt;투 포인터 기법&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 정렬된 배열에서 두 수의 합이 target인 쌍 찾기
// 브루트 포스 O(n²)
function twoSumBruteForce(nums, target) {
  for (let i = 0; i &amp;lt; nums.length; i++) {
    for (let j = i + 1; j &amp;lt; nums.length; j++) {
      if (nums[i] + nums[j] === target) {
        return [i, j];
      }
    }
  }
  return null;
}

// 투 포인터 O(n)
function twoSumOptimized(nums, target) {
  let left = 0, right = nums.length - 1;

  while (left &amp;lt; right) {
    const sum = nums[left] + nums[right];
    if (sum === target) {
      return [left, right];
    } else if (sum &amp;lt; target) {
      left++;
    } else {
      right--;
    }
  }
  return null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;슬라이딩 윈도우 기법&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 고정 크기 윈도우의 최대 합 찾기
// 브루트 포스 O(n×k)
function maxSumBruteForce(arr, k) {
  let maxSum = -Infinity;

  for (let i = 0; i &amp;lt;= arr.length - k; i++) {
    let currentSum = 0;
    for (let j = i; j &amp;lt; i + k; j++) {
      currentSum += arr[j];
    }
    maxSum = Math.max(maxSum, currentSum);
  }

  return maxSum;
}

// 슬라이딩 윈도우 O(n)
function maxSumOptimized(arr, k) {
  if (arr.length &amp;lt; k) return null;

  // 첫 번째 윈도우의 합 계산
  let windowSum = arr.slice(0, k).reduce((sum, num) =&amp;gt; sum + num, 0);
  let maxSum = windowSum;

  // 윈도우를 한 칸씩 이동하며 합 업데이트
  for (let i = k; i &amp;lt; arr.length; i++) {
    windowSum = windowSum - arr[i - k] + arr[i];
    maxSum = Math.max(maxSum, windowSum);
  }

  return maxSum;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;결론 및 실무 가이드&lt;/h2&gt;
&lt;p&gt;시간복잡도와 공간복잡도는 단순한 이론이 아니라 실무에서 직접 성능에 영향을 미치는 핵심 개념입니다. 특히 프론트엔드 개발에서는 사용자 경험과 직결되므로 더욱 중요합니다.&lt;/p&gt;
&lt;h3&gt;핵심 요약&lt;/h3&gt;
&lt;h4&gt;1. 복잡도 분석의 핵심&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;시간복잡도&lt;/strong&gt;: 연산 횟수 기준의 효율성 측정&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;공간복잡도&lt;/strong&gt;: 메모리 사용량 기준의 효율성 측정&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;빅오 표기법&lt;/strong&gt;: 최악의 경우 성장률 표현&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;트레이드오프&lt;/strong&gt;: 시간과 공간 간의 균형점 찾기&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;2. 프론트엔드에서의 중요성&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;사용자 경험&lt;/strong&gt;: 빠른 응답 시간과 부드러운 인터랙션&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;메모리 관리&lt;/strong&gt;: 모바일 환경에서의 제한된 리소스&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;확장성&lt;/strong&gt;: 데이터 증가에 따른 성능 유지&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SEO 영향&lt;/strong&gt;: 페이지 로딩 속도와 검색 순위&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;3. 최적화 우선순위&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;알고리즘 선택&lt;/strong&gt;: 적절한 복잡도의 알고리즘 사용&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;자료구조 활용&lt;/strong&gt;: Map, Set 등을 통한 효율적 데이터 접근&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;메모이제이션&lt;/strong&gt;: 중복 계산 방지&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;가상화&lt;/strong&gt;: 대용량 데이터 렌더링 최적화&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;실무 적용 로드맵&lt;/h3&gt;
&lt;h4&gt;1단계: 기본 이해 (주니어 개발자)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 기본적인 복잡도 분석 능력
const isO1 = (arr) =&amp;gt; arr[0];           // O(1)
const isOn = (arr) =&amp;gt; arr.forEach();    // O(n)
const isOn2 = (arr) =&amp;gt; arr.forEach(() =&amp;gt; arr.forEach()); // O(n²)&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2단계: 실무 적용 (중급 개발자)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;프로젝트에서 성능 병목 지점 식별&lt;/li&gt;
&lt;li&gt;적절한 자료구조와 알고리즘 선택&lt;/li&gt;
&lt;li&gt;코드 리뷰 시 복잡도 고려&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;3단계: 최적화 전문가 (시니어 개발자)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;시스템 전체의 성능 아키텍처 설계&lt;/li&gt;
&lt;li&gt;복잡한 최적화 기법 적용&lt;/li&gt;
&lt;li&gt;팀원들에게 성능 최적화 지식 전파&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;실무에서 자주 마주치는 상황들&lt;/h3&gt;
&lt;h4&gt;상황 1: &amp;quot;배열에서 특정 조건의 아이템들을 찾아야 해요&amp;quot;&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ❌ 매번 전체 탐색 O(n)
const badApproach = (items) =&amp;gt; items.filter(item =&amp;gt; item.active);

// ✅ 인덱스 구축 후 O(1) 접근
class ItemManager {
  constructor(items) {
    this.items = items;
    this.activeItems = items.filter(item =&amp;gt; item.active);
    this.activeIndex = new Set(this.activeItems.map(item =&amp;gt; item.id));
  }

  isActive(itemId) {
    return this.activeIndex.has(itemId); // O(1)
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;상황 2: &amp;quot;리스트가 너무 커서 렌더링이 느려요&amp;quot;&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ❌ 모든 아이템 렌더링
const SlowList = ({ items }) =&amp;gt; (
  &amp;lt;div&amp;gt;
    {items.map(item =&amp;gt; &amp;lt;Item key={item.id} item={item} /&amp;gt;)}
  &amp;lt;/div&amp;gt;
);

// ✅ 가상 스크롤링 적용
const FastList = ({ items }) =&amp;gt; {
  // VirtualList 컴포넌트 사용
  return &amp;lt;VirtualList items={items} itemHeight={50} /&amp;gt;;
};&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;상황 3: &amp;quot;검색 기능이 타이핑할 때마다 버벅여요&amp;quot;&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ❌ 매번 전체 데이터 필터링
const slowSearch = (query, data) =&amp;gt; {
  return data.filter(item =&amp;gt; item.name.includes(query));
};

// ✅ 디바운싱 + 인덱싱
const useOptimizedSearch = (data) =&amp;gt; {
  const searchIndex = useMemo(() =&amp;gt; buildSearchIndex(data), [data]);

  return useCallback(
    debounce((query) =&amp;gt; searchWithIndex(query, searchIndex), 300),
    [searchIndex]
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;지속적인 성능 모니터링&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 성능 모니터링 데코레이터
function performanceMonitor(target, propertyName, descriptor) {
  const method = descriptor.value;

  descriptor.value = function(...args) {
    const start = performance.now();
    const result = method.apply(this, args);
    const end = performance.now();

    if (end - start &amp;gt; 16) { // 16ms 이상 걸리면 경고
      console.warn(`${propertyName} took ${end - start}ms`);
    }

    return result;
  };

  return descriptor;
}

class DataProcessor {
  @performanceMonitor
  processLargeDataset(data) {
    // 데이터 처리 로직
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;마무리&lt;/h3&gt;
&lt;p&gt;복잡도 분석은 더 나은 코드를 작성하기 위한 도구입니다. 무조건 최고의 복잡도를 추구하기보다는, &lt;strong&gt;가독성, 유지보수성, 성능의 균형&lt;/strong&gt;을 맞추는 것이 중요합니다. &lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/118</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EC%8B%9C%EA%B0%84%EB%B3%B5%EC%9E%A1%EB%8F%84%EC%99%80-%EA%B3%B5%EA%B0%84%EB%B3%B5%EC%9E%A1%EB%8F%84-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EC%95%8C%EC%95%84%EC%95%BC-%ED%95%A0-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EC%9D%98-%EA%B8%B0%EC%B4%88#entry118comment</comments>
      <pubDate>Wed, 23 Jul 2025 17:16:20 +0900</pubDate>
    </item>
    <item>
      <title>[JWT 가이드] 개념부터 React 실무 구현까지 - Session 비교, 보안, TypeScript 예제 총정리</title>
      <link>https://white-mouse-dev.tistory.com/entry/JWT-%EA%B0%80%EC%9D%B4%EB%93%9C-%EA%B0%9C%EB%85%90%EB%B6%80%ED%84%B0-React-%EC%8B%A4%EB%AC%B4-%EA%B5%AC%ED%98%84%EA%B9%8C%EC%A7%80-Session-%EB%B9%84%EA%B5%90-%EB%B3%B4%EC%95%88-TypeScript-%EC%98%88%EC%A0%9C-%EC%B4%9D%EC%A0%95%EB%A6%AC</link>
      <description>&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/kimkuns/post/faa5691c-652c-4efe-b9ce-d80177b2a9a9/image.gif&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;프론트엔드 개발에서 사용자 인증은 거의 모든 프로젝트의 핵심 요구사항입니다. 전통적인 세션 기반 인증에서 토큰 기반 인증으로의 전환이 가속화되면서, JWT(JSON Web Token)는 현대 웹 애플리케이션의 표준 인증 방식으로 자리잡았습니다. 이 글에서는 JWT의 개념부터 실제 구현까지 체계적으로 정리하겠습니다.&lt;/p&gt;
&lt;h2&gt;목차&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#jwt%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80&quot;&gt;JWT란 무엇인가?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#jwt%EC%9D%98-%EA%B5%AC%EC%A1%B0%EC%99%80-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC&quot;&gt;JWT의 구조와 동작 원리&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#jwt-vs-session-%EC%9D%B8%EC%A6%9D-%EB%B0%A9%EC%8B%9D-%EB%B9%84%EA%B5%90&quot;&gt;JWT vs Session 인증 방식 비교&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#jwt%EC%9D%98-%EC%9E%A5%EC%A0%90%EA%B3%BC-%EB%8B%A8%EC%A0%90&quot;&gt;JWT의 장점과 단점&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%8B%A4%EC%A0%9C-%EA%B5%AC%ED%98%84-%EC%98%88%EC%8B%9C&quot;&gt;실제 구현 예시&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%B3%B4%EC%95%88-%EB%AA%A8%EB%B2%94-%EC%82%AC%EB%A1%80&quot;&gt;보안 모범 사례&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EA%B2%B0%EB%A1%A0-%EB%B0%8F-%EC%8B%A4%EB%AC%B4-%EA%B0%80%EC%9D%B4%EB%93%9C&quot;&gt;결론 및 실무 가이드&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;JWT란 무엇인가?&lt;/h2&gt;
&lt;p&gt;JWT(JSON Web Token)는 당사자 간에 정보를 JSON 객체로 안전하게 전송하기 위한 컴팩트하고 자체 포함된(self-contained) 방식을 정의하는 개방형 표준(RFC 7519)입니다.&lt;/p&gt;
&lt;h3&gt;JWT가 필요한 이유&lt;/h3&gt;
&lt;p&gt;현대 웹 개발에서 JWT가 주목받는 이유는 다음과 같습니다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;분산 시스템 지원&lt;/strong&gt;: 마이크로서비스 아키텍처에서 서비스 간 인증 정보 공유&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;모바일 친화적&lt;/strong&gt;: 네이티브 앱에서 쉬운 토큰 관리&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;확장성&lt;/strong&gt;: 서버 상태 관리 없이도 사용자 인증 처리&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;표준화&lt;/strong&gt;: 업계 표준으로 다양한 플랫폼 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;JWT의 구조와 동작 원리&lt;/h2&gt;
&lt;h3&gt;JWT 토큰 구조&lt;/h3&gt;
&lt;p&gt;JWT는 점(.)으로 구분된 세 부분으로 구성됩니다:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이를 분해하면:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Header&lt;/strong&gt;: &lt;code&gt;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Payload&lt;/strong&gt;: &lt;code&gt;eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Signature&lt;/strong&gt;: &lt;code&gt;SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;1. Header (헤더)&lt;/h4&gt;
&lt;p&gt;토큰의 타입과 해싱 알고리즘을 지정합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;alg&amp;quot;: &amp;quot;HS256&amp;quot;,
  &amp;quot;typ&amp;quot;: &amp;quot;JWT&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2. Payload (페이로드)&lt;/h4&gt;
&lt;p&gt;실제 전송할 데이터(클레임)를 포함합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;sub&amp;quot;: &amp;quot;1234567890&amp;quot;,
  &amp;quot;name&amp;quot;: &amp;quot;John Doe&amp;quot;,
  &amp;quot;role&amp;quot;: &amp;quot;admin&amp;quot;,
  &amp;quot;iat&amp;quot;: 1516239022,
  &amp;quot;exp&amp;quot;: 1516242622
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;표준 클레임 (Registered Claims):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;iss&lt;/code&gt; (issuer): 토큰 발급자&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sub&lt;/code&gt; (subject): 토큰 제목&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aud&lt;/code&gt; (audience): 토큰 대상자&lt;/li&gt;
&lt;li&gt;&lt;code&gt;exp&lt;/code&gt; (expiration): 만료 시간&lt;/li&gt;
&lt;li&gt;&lt;code&gt;iat&lt;/code&gt; (issued at): 발급 시간&lt;/li&gt;
&lt;li&gt;&lt;code&gt;jti&lt;/code&gt; (JWT ID): JWT 고유 식별자&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;3. Signature (서명)&lt;/h4&gt;
&lt;p&gt;헤더와 페이로드의 무결성을 검증하는 서명입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HMACSHA256(
  base64UrlEncode(header) + &amp;quot;.&amp;quot; +
  base64UrlEncode(payload),
  secret
)&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;JWT 동작 흐름&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;1. 클라이언트가 로그인 요청 (username, password)
   ↓
2. 서버가 사용자 인증 후 JWT 토큰 생성
   ↓
3. 서버가 JWT 토큰을 클라이언트에 전송
   ↓
4. 클라이언트가 토큰을 저장 (localStorage, 쿠키 등)
   ↓
5. 이후 API 요청 시 토큰을 Authorization 헤더에 포함
   ↓
6. 서버가 토큰을 검증하고 사용자 정보 추출
   ↓
7. 검증 성공 시 요청 처리, 실패 시 401 에러&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;JWT vs Session 인증 방식 비교&lt;/h2&gt;
&lt;h3&gt;Session 기반 인증 방식&lt;/h3&gt;
&lt;p&gt;전통적인 세션 방식에서는 서버가 사용자 상태를 관리합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;동작 과정:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 사용자 로그인 → 서버에서 세션 생성
2. 세션 ID를 쿠키로 클라이언트에 전송
3. 클라이언트가 요청 시 세션 ID 포함
4. 서버가 세션 스토어에서 사용자 정보 조회
5. 인증 확인 후 요청 처리&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;구현 예시:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 세션 기반 로그인
app.post(&amp;#39;/login&amp;#39;, (req, res) =&amp;gt; {
  // 사용자 인증 후
  req.session.userId = user.id;
  req.session.role = user.role;
  res.json({ message: &amp;#39;로그인 성공&amp;#39; });
});

// 매 요청마다 세션 조회
app.get(&amp;#39;/profile&amp;#39;, (req, res) =&amp;gt; {
  const userId = req.session.userId; // 세션 스토어에서 조회
  // 추가 DB 조회 필요
  const user = await User.findById(userId);
  res.json(user);
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;JWT 기반 인증 방식&lt;/h3&gt;
&lt;p&gt;JWT 방식에서는 클라이언트가 토큰을 보관하고 서버는 상태를 저장하지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;동작 과정:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 사용자 로그인 → 서버에서 JWT 토큰 생성
2. JWT 토큰을 클라이언트에 전송
3. 클라이언트가 요청 시 JWT 토큰 포함
4. 서버가 토큰 서명을 검증하고 정보 추출
5. 추가 조회 없이 바로 요청 처리&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;구현 예시:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// JWT 기반 로그인
app.post(&amp;#39;/login&amp;#39;, (req, res) =&amp;gt; {
  const token = jwt.sign(
    { userId: user.id, role: user.role }, 
    secret, 
    { expiresIn: &amp;#39;1h&amp;#39; }
  );
  res.json({ token });
});

// 토큰에서 직접 정보 추출
app.get(&amp;#39;/profile&amp;#39;, authenticateToken, (req, res) =&amp;gt; {
  const userId = req.user.userId; // JWT에서 직접 추출
  // DB 조회 없이 바로 사용 가능 (선택사항)
  res.json({ userId, role: req.user.role });
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;상세 비교 분석&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;특성&lt;/th&gt;
&lt;th&gt;Session 방식&lt;/th&gt;
&lt;th&gt;JWT 방식&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;상태 관리&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stateful (서버에 상태 저장)&lt;/td&gt;
&lt;td&gt;Stateless (상태 저장 안 함)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;저장 위치&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;서버 (메모리/DB/Redis)&lt;/td&gt;
&lt;td&gt;클라이언트 (브라우저)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;확장성&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;수직 확장 위주&lt;/td&gt;
&lt;td&gt;수평 확장 유리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;서버 부하&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;세션 저장소 I/O 부하&lt;/td&gt;
&lt;td&gt;CPU 부하 (서명 검증)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;네트워크 비용&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;낮음 (세션 ID만 전송)&lt;/td&gt;
&lt;td&gt;높음 (토큰 전체 전송)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;보안 제어&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;서버에서 완전 제어&lt;/td&gt;
&lt;td&gt;토큰 만료까지 유효&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;로그아웃&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;즉시 무효화 가능&lt;/td&gt;
&lt;td&gt;복잡함 (토큰 블랙리스트 필요)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;다중 서비스&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;세션 공유 복잡&lt;/td&gt;
&lt;td&gt;토큰 공유 용이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;모바일 지원&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;쿠키 의존적&lt;/td&gt;
&lt;td&gt;토큰 기반으로 친화적&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;사용 시나리오별 권장사항&lt;/h3&gt;
&lt;h4&gt;Session 방식 선택 기준&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 1. 높은 보안이 요구되는 서비스
app.post(&amp;#39;/admin/force-logout&amp;#39;, (req, res) =&amp;gt; {
  const { userId } = req.body;
  sessionStore.destroy(userId); // 즉시 강제 로그아웃
  res.json({ message: &amp;#39;사용자 로그아웃 완료&amp;#39; });
});

// 2. 전통적인 서버 렌더링 웹사이트
app.get(&amp;#39;/dashboard&amp;#39;, sessionAuth, (req, res) =&amp;gt; {
  const user = req.session.user;
  res.render(&amp;#39;dashboard&amp;#39;, { user });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;적합한 프로젝트:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;금융 서비스, 관리자 시스템&lt;/li&gt;
&lt;li&gt;모놀리식 아키텍처&lt;/li&gt;
&lt;li&gt;서버 사이드 렌더링 위주&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;JWT 방식 선택 기준&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 1. SPA (Single Page Application)
const useAuth = () =&amp;gt; {
  const [user, setUser] = useState(null);

  useEffect(() =&amp;gt; {
    const token = localStorage.getItem(&amp;#39;token&amp;#39;);
    if (token) {
      const payload = parseJWT(token);
      setUser(payload);
    }
  }, []);

  return { user };
};

// 2. 마이크로서비스 아키텍처
// 각 서비스가 동일한 토큰으로 사용자 인증 가능
const validateTokenMiddleware = (req, res, next) =&amp;gt; {
  const token = req.headers.authorization?.split(&amp;#39; &amp;#39;)[1];
  const decoded = jwt.verify(token, process.env.JWT_SECRET);
  req.user = decoded;
  next();
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;적합한 프로젝트:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;React, Vue, Angular 기반 SPA&lt;/li&gt;
&lt;li&gt;모바일 애플리케이션&lt;/li&gt;
&lt;li&gt;마이크로서비스 아키텍처&lt;/li&gt;
&lt;li&gt;RESTful API 서비스&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;JWT의 장점과 단점&lt;/h2&gt;
&lt;h3&gt;주요 장점&lt;/h3&gt;
&lt;h4&gt;1. Stateless 인증의 확장성&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 여러 서버 인스턴스가 있어도 상태 공유 불필요
const server1 = express();
const server2 = express();

// 두 서버 모두 동일한 JWT 검증 로직만 있으면 됨
const jwtMiddleware = (req, res, next) =&amp;gt; {
  const token = req.headers.authorization?.split(&amp;#39; &amp;#39;)[1];
  req.user = jwt.verify(token, SECRET_KEY);
  next();
};

server1.use(jwtMiddleware);
server2.use(jwtMiddleware);&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2. 자체 포함성 (Self-contained)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;userId&amp;quot;: 123,
  &amp;quot;username&amp;quot;: &amp;quot;johndoe&amp;quot;,
  &amp;quot;role&amp;quot;: &amp;quot;admin&amp;quot;,
  &amp;quot;permissions&amp;quot;: [&amp;quot;read&amp;quot;, &amp;quot;write&amp;quot;, &amp;quot;delete&amp;quot;],
  &amp;quot;department&amp;quot;: &amp;quot;engineering&amp;quot;,
  &amp;quot;exp&amp;quot;: 1642680000
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;토큰 자체에 필요한 정보가 포함되어 있어 추가 데이터베이스 조회 없이도 권한 검사가 가능합니다.&lt;/p&gt;
&lt;h4&gt;3. 크로스 도메인 지원&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 다른 도메인의 API 서버도 동일한 토큰으로 인증 가능
const apiCall = async () =&amp;gt; {
  const token = localStorage.getItem(&amp;#39;token&amp;#39;);

  // 메인 서비스
  await fetch(&amp;#39;https://api.example.com/users&amp;#39;, {
    headers: { Authorization: `Bearer ${token}` }
  });

  // 결제 서비스
  await fetch(&amp;#39;https://payment.example.com/process&amp;#39;, {
    headers: { Authorization: `Bearer ${token}` }
  });
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;주요 단점과 해결 방안&lt;/h3&gt;
&lt;h4&gt;1. 토큰 무효화의 어려움&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;문제점:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 사용자가 로그아웃해도 토큰이 여전히 유효
app.post(&amp;#39;/logout&amp;#39;, (req, res) =&amp;gt; {
  // JWT는 서버에서 강제 무효화할 방법이 없음
  res.json({ message: &amp;#39;로그아웃되었지만 토큰은 만료까지 유효&amp;#39; });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;해결 방안:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Access/Refresh Token 패턴&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const generateTokens = (payload) =&amp;gt; ({
accessToken: jwt.sign(payload, ACCESS_SECRET, { expiresIn: &amp;#39;15m&amp;#39; }),
refreshToken: jwt.sign(payload, REFRESH_SECRET, { expiresIn: &amp;#39;7d&amp;#39; })
});&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;블랙리스트 관리&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const blacklistedTokens = new Set();
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;app.post(&amp;#39;/logout&amp;#39;, (req, res) =&amp;gt; {&lt;br&gt;  const token = req.headers.authorization.split(&amp;#39; &amp;#39;)[1];&lt;br&gt;  blacklistedTokens.add(token);&lt;br&gt;  res.json({ message: &amp;#39;로그아웃 완료&amp;#39; });&lt;br&gt;});&lt;/p&gt;
&lt;p&gt;const jwtMiddleware = (req, res, next) =&amp;gt; {&lt;br&gt;  const token = req.headers.authorization?.split(&amp;#39; &amp;#39;)[1];&lt;/p&gt;
&lt;p&gt;  if (blacklistedTokens.has(token)) {&lt;br&gt;    return res.status(401).json({ message: &amp;#39;무효한 토큰&amp;#39; });&lt;br&gt;  }&lt;/p&gt;
&lt;p&gt;  // 토큰 검증...&lt;br&gt;};&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
#### 2. 토큰 크기 문제

**문제점:** JWT는 사용자 정보를 포함하므로 세션 ID(16-32 bytes)보다 훨씬 큽니다(수백 bytes).

**해결 방안:**
```javascript
// 최소한의 정보만 포함
const minimalPayload = {
  userId: user.id,
  role: user.role,
  exp: Math.floor(Date.now() / 1000) + (60 * 60) // 1시간
};

// 상세 정보는 필요시 별도 조회
app.get(&amp;#39;/profile&amp;#39;, authenticateToken, async (req, res) =&amp;gt; {
  const userId = req.user.userId;
  const userDetails = await User.findById(userId);
  res.json(userDetails);
});&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;3. 보안 취약점&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;XSS 공격 위험:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 위험: localStorage에 토큰 저장
localStorage.setItem(&amp;#39;token&amp;#39;, token);

// 더 안전: httpOnly 쿠키 사용
res.cookie(&amp;#39;token&amp;#39;, token, {
  httpOnly: true,
  secure: true,
  sameSite: &amp;#39;strict&amp;#39;
});&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;실제 구현 예시&lt;/h2&gt;
&lt;h3&gt;1. 프론트엔드 구현 (React + TypeScript)&lt;/h3&gt;
&lt;h4&gt;완전한 인증 시스템 Hook&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// hooks/useAuth.ts
import { useState, useEffect, useCallback, useContext, createContext } from &amp;#39;react&amp;#39;;

interface User {
  id: number;
  username: string;
  role: string;
}

interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  login: (username: string, password: string) =&amp;gt; Promise&amp;lt;void&amp;gt;;
  logout: () =&amp;gt; void;
  refreshToken: () =&amp;gt; Promise&amp;lt;boolean&amp;gt;;
}

const AuthContext = createContext&amp;lt;AuthContextType | undefined&amp;gt;(undefined);

// JWT 파싱 유틸리티
const parseJWT = (token: string): any =&amp;gt; {
  try {
    const base64Url = token.split(&amp;#39;.&amp;#39;)[1];
    const base64 = base64Url.replace(/-/g, &amp;#39;+&amp;#39;).replace(/_/g, &amp;#39;/&amp;#39;);
    const jsonPayload = decodeURIComponent(
      atob(base64)
        .split(&amp;#39;&amp;#39;)
        .map(c =&amp;gt; &amp;#39;%&amp;#39; + (&amp;#39;00&amp;#39; + c.charCodeAt(0).toString(16)).slice(-2))
        .join(&amp;#39;&amp;#39;)
    );
    return JSON.parse(jsonPayload);
  } catch (error) {
    console.error(&amp;#39;JWT 파싱 실패:&amp;#39;, error);
    return null;
  }
};

// 토큰 유효성 검사
const isTokenValid = (token: string): boolean =&amp;gt; {
  const payload = parseJWT(token);
  if (!payload) return false;

  const currentTime = Date.now() / 1000;
  return payload.exp &amp;gt; currentTime;
};

export const AuthProvider: React.FC&amp;lt;{ children: React.ReactNode }&amp;gt; = ({ children }) =&amp;gt; {
  const [user, setUser] = useState&amp;lt;User | null&amp;gt;(null);
  const [isLoading, setIsLoading] = useState(true);

  // 토큰 갱신
  const refreshToken = useCallback(async (): Promise&amp;lt;boolean&amp;gt; =&amp;gt; {
    try {
      const refreshToken = localStorage.getItem(&amp;#39;refreshToken&amp;#39;);
      if (!refreshToken) return false;

      const response = await fetch(&amp;#39;/api/auth/refresh&amp;#39;, {
        method: &amp;#39;POST&amp;#39;,
        headers: { &amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39; },
        body: JSON.stringify({ refreshToken }),
      });

      if (!response.ok) throw new Error(&amp;#39;토큰 갱신 실패&amp;#39;);

      const { accessToken, refreshToken: newRefreshToken } = await response.json();

      localStorage.setItem(&amp;#39;accessToken&amp;#39;, accessToken);
      localStorage.setItem(&amp;#39;refreshToken&amp;#39;, newRefreshToken);

      const payload = parseJWT(accessToken);
      setUser({
        id: payload.userId,
        username: payload.username,
        role: payload.role,
      });

      return true;
    } catch (error) {
      console.error(&amp;#39;토큰 갱신 실패:&amp;#39;, error);
      logout();
      return false;
    }
  }, []);

  // 로그인
  const login = useCallback(async (username: string, password: string) =&amp;gt; {
    setIsLoading(true);
    try {
      const response = await fetch(&amp;#39;/api/auth/login&amp;#39;, {
        method: &amp;#39;POST&amp;#39;,
        headers: { &amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39; },
        body: JSON.stringify({ username, password }),
      });

      if (!response.ok) {
        throw new Error(&amp;#39;로그인 실패&amp;#39;);
      }

      const { accessToken, refreshToken } = await response.json();

      localStorage.setItem(&amp;#39;accessToken&amp;#39;, accessToken);
      localStorage.setItem(&amp;#39;refreshToken&amp;#39;, refreshToken);

      const payload = parseJWT(accessToken);
      setUser({
        id: payload.userId,
        username: payload.username,
        role: payload.role,
      });
    } catch (error) {
      console.error(&amp;#39;로그인 에러:&amp;#39;, error);
      throw error;
    } finally {
      setIsLoading(false);
    }
  }, []);

  // 로그아웃
  const logout = useCallback(() =&amp;gt; {
    setUser(null);
    localStorage.removeItem(&amp;#39;accessToken&amp;#39;);
    localStorage.removeItem(&amp;#39;refreshToken&amp;#39;);
  }, []);

  // 초기화 및 자동 토큰 검증
  useEffect(() =&amp;gt; {
    const initializeAuth = async () =&amp;gt; {
      const token = localStorage.getItem(&amp;#39;accessToken&amp;#39;);

      if (!token) {
        setIsLoading(false);
        return;
      }

      if (isTokenValid(token)) {
        const payload = parseJWT(token);
        setUser({
          id: payload.userId,
          username: payload.username,
          role: payload.role,
        });
      } else {
        // 토큰이 만료된 경우 갱신 시도
        const refreshed = await refreshToken();
        if (!refreshed) {
          logout();
        }
      }

      setIsLoading(false);
    };

    initializeAuth();
  }, [refreshToken, logout]);

  const value: AuthContextType = {
    user,
    isAuthenticated: !!user,
    isLoading,
    login,
    logout,
    refreshToken,
  };

  return &amp;lt;AuthContext.Provider value={value}&amp;gt;{children}&amp;lt;/AuthContext.Provider&amp;gt;;
};

// Hook 사용
export const useAuth = (): AuthContextType =&amp;gt; {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error(&amp;#39;useAuth must be used within an AuthProvider&amp;#39;);
  }
  return context;
};&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;API 클라이언트 설정 (Axios)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// api/client.ts
import axios, { AxiosRequestConfig, AxiosResponse } from &amp;#39;axios&amp;#39;;

// Axios 인스턴스 생성
const apiClient = axios.create({
  baseURL: process.env.REACT_APP_API_URL || &amp;#39;/api&amp;#39;,
  timeout: 10000,
});

// 요청 인터셉터: 토큰 자동 첨부
axios.interceptors.request.use(
  (config: AxiosRequestConfig) =&amp;gt; {
    const token = localStorage.getItem(&amp;#39;accessToken&amp;#39;);
    if (token &amp;amp;&amp;amp; config.headers) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) =&amp;gt; Promise.reject(error)
);

// 응답 인터셉터: 토큰 만료 시 자동 갱신
axios.interceptors.response.use(
  (response: AxiosResponse) =&amp;gt; response,
  async (error) =&amp;gt; {
    const originalRequest = error.config;

    if (error.response?.status === 401 &amp;amp;&amp;amp; !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const refreshToken = localStorage.getItem(&amp;#39;refreshToken&amp;#39;);
        if (!refreshToken) {
          throw new Error(&amp;#39;No refresh token&amp;#39;);
        }

        const response = await axios.post(&amp;#39;/api/auth/refresh&amp;#39;, {
          refreshToken,
        });

        const { accessToken } = response.data;
        localStorage.setItem(&amp;#39;accessToken&amp;#39;, accessToken);

        // 원래 요청 재시도
        if (originalRequest.headers) {
          originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        }

        return axios(originalRequest);
      } catch (refreshError) {
        // 갱신 실패 시 로그아웃 처리
        localStorage.clear();
        window.location.href = &amp;#39;/login&amp;#39;;
      }
    }

    return Promise.reject(error);
  }
);

export default apiClient;&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;보호된 라우트 컴포넌트&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// components/ProtectedRoute.tsx
import React from &amp;#39;react&amp;#39;;
import { Navigate, useLocation } from &amp;#39;react-router-dom&amp;#39;;
import { useAuth } from &amp;#39;../hooks/useAuth&amp;#39;;
import LoadingSpinner from &amp;#39;./LoadingSpinner&amp;#39;;

interface ProtectedRouteProps {
  children: React.ReactNode;
  requiredRole?: string;
}

const ProtectedRoute: React.FC&amp;lt;ProtectedRouteProps&amp;gt; = ({ 
  children, 
  requiredRole 
}) =&amp;gt; {
  const { isAuthenticated, isLoading, user } = useAuth();
  const location = useLocation();

  if (isLoading) {
    return &amp;lt;LoadingSpinner /&amp;gt;;
  }

  if (!isAuthenticated) {
    return &amp;lt;Navigate to=&amp;quot;/login&amp;quot; state={{ from: location }} replace /&amp;gt;;
  }

  if (requiredRole &amp;amp;&amp;amp; user?.role !== requiredRole) {
    return &amp;lt;Navigate to=&amp;quot;/unauthorized&amp;quot; replace /&amp;gt;;
  }

  return &amp;lt;&amp;gt;{children}&amp;lt;/&amp;gt;;
};

export default ProtectedRoute;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 백엔드 구현 (Node.js + Express + TypeScript)&lt;/h3&gt;
&lt;h4&gt;JWT 유틸리티 클래스&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// utils/JWTManager.ts
import jwt from &amp;#39;jsonwebtoken&amp;#39;;
import { promisify } from &amp;#39;util&amp;#39;;

interface TokenPayload {
  userId: number;
  username: string;
  role: string;
}

interface TokenPair {
  accessToken: string;
  refreshToken: string;
}

class JWTManager {
  private readonly accessTokenSecret: string;
  private readonly refreshTokenSecret: string;
  private readonly accessTokenExpiry: string;
  private readonly refreshTokenExpiry: string;

  constructor() {
    this.accessTokenSecret = process.env.ACCESS_TOKEN_SECRET!;
    this.refreshTokenSecret = process.env.REFRESH_TOKEN_SECRET!;
    this.accessTokenExpiry = process.env.ACCESS_TOKEN_EXPIRY || &amp;#39;15m&amp;#39;;
    this.refreshTokenExpiry = process.env.REFRESH_TOKEN_EXPIRY || &amp;#39;7d&amp;#39;;

    if (!this.accessTokenSecret || !this.refreshTokenSecret) {
      throw new Error(&amp;#39;JWT secrets must be provided in environment variables&amp;#39;);
    }
  }

  // 토큰 쌍 생성
  generateTokenPair(payload: TokenPayload): TokenPair {
    const accessToken = jwt.sign(payload, this.accessTokenSecret, {
      expiresIn: this.accessTokenExpiry,
      issuer: &amp;#39;your-app-name&amp;#39;,
      audience: &amp;#39;your-app-users&amp;#39;,
    });

    const refreshToken = jwt.sign(
      { userId: payload.userId }, 
      this.refreshTokenSecret, 
      {
        expiresIn: this.refreshTokenExpiry,
        issuer: &amp;#39;your-app-name&amp;#39;,
        audience: &amp;#39;your-app-users&amp;#39;,
      }
    );

    return { accessToken, refreshToken };
  }

  // Access Token 검증
  async verifyAccessToken(token: string): Promise&amp;lt;TokenPayload&amp;gt; {
    try {
      const decoded = await promisify(jwt.verify)(token, this.accessTokenSecret) as any;
      return {
        userId: decoded.userId,
        username: decoded.username,
        role: decoded.role,
      };
    } catch (error) {
      throw new Error(&amp;#39;유효하지 않은 액세스 토큰&amp;#39;);
    }
  }

  // Refresh Token 검증
  async verifyRefreshToken(token: string): Promise&amp;lt;{ userId: number }&amp;gt; {
    try {
      const decoded = await promisify(jwt.verify)(token, this.refreshTokenSecret) as any;
      return { userId: decoded.userId };
    } catch (error) {
      throw new Error(&amp;#39;유효하지 않은 리프레시 토큰&amp;#39;);
    }
  }

  // 토큰에서 페이로드 추출 (검증 없이)
  decodeToken(token: string): any {
    try {
      return jwt.decode(token);
    } catch (error) {
      return null;
    }
  }
}

export default new JWTManager();&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;인증 미들웨어&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// middleware/auth.ts
import { Request, Response, NextFunction } from &amp;#39;express&amp;#39;;
import JWTManager from &amp;#39;../utils/JWTManager&amp;#39;;

// 확장된 Request 타입
export interface AuthenticatedRequest extends Request {
  user?: {
    userId: number;
    username: string;
    role: string;
  };
}

// 기본 인증 미들웨어
export const authenticateToken = async (
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): Promise&amp;lt;void&amp;gt; =&amp;gt; {
  try {
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith(&amp;#39;Bearer &amp;#39;)) {
      res.status(401).json({ 
        error: &amp;#39;UNAUTHORIZED&amp;#39;,
        message: &amp;#39;인증 토큰이 필요합니다.&amp;#39; 
      });
      return;
    }

    const token = authHeader.substring(7);
    const user = await JWTManager.verifyAccessToken(token);

    req.user = user;
    next();
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      res.status(401).json({ 
        error: &amp;#39;TOKEN_EXPIRED&amp;#39;,
        message: &amp;#39;토큰이 만료되었습니다.&amp;#39; 
      });
      return;
    }

    if (error instanceof jwt.JsonWebTokenError) {
      res.status(401).json({ 
        error: &amp;#39;INVALID_TOKEN&amp;#39;,
        message: &amp;#39;유효하지 않은 토큰입니다.&amp;#39; 
      });
      return;
    }

    res.status(500).json({ 
      error: &amp;#39;INTERNAL_ERROR&amp;#39;,
      message: &amp;#39;서버 오류가 발생했습니다.&amp;#39; 
    });
  }
};

// 역할 기반 인증 미들웨어
export const requireRole = (requiredRole: string) =&amp;gt; {
  return (req: AuthenticatedRequest, res: Response, next: NextFunction): void =&amp;gt; {
    if (!req.user) {
      res.status(401).json({ message: &amp;#39;인증이 필요합니다.&amp;#39; });
      return;
    }

    if (req.user.role !== requiredRole) {
      res.status(403).json({ 
        message: `${requiredRole} 권한이 필요합니다.` 
      });
      return;
    }

    next();
  };
};

// 여러 역할 허용 미들웨어
export const requireAnyRole = (allowedRoles: string[]) =&amp;gt; {
  return (req: AuthenticatedRequest, res: Response, next: NextFunction): void =&amp;gt; {
    if (!req.user) {
      res.status(401).json({ message: &amp;#39;인증이 필요합니다.&amp;#39; });
      return;
    }

    if (!allowedRoles.includes(req.user.role)) {
      res.status(403).json({ 
        message: `다음 권한 중 하나가 필요합니다: ${allowedRoles.join(&amp;#39;, &amp;#39;)}` 
      });
      return;
    }

    next();
  };
};&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;인증 컨트롤러&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// controllers/authController.ts
import { Request, Response } from &amp;#39;express&amp;#39;;
import bcrypt from &amp;#39;bcrypt&amp;#39;;
import JWTManager from &amp;#39;../utils/JWTManager&amp;#39;;
import { AuthenticatedRequest } from &amp;#39;../middleware/auth&amp;#39;;
import User from &amp;#39;../models/User&amp;#39;; // 가정된 User 모델

class AuthController {
  // 로그인
  async login(req: Request, res: Response): Promise&amp;lt;void&amp;gt; {
    try {
      const { username, password } = req.body;

      if (!username || !password) {
        res.status(400).json({ 
          error: &amp;#39;MISSING_CREDENTIALS&amp;#39;,
          message: &amp;#39;사용자명과 비밀번호가 필요합니다.&amp;#39; 
        });
        return;
      }

      // 사용자 조회
      const user = await User.findOne({ username });
      if (!user) {
        res.status(401).json({ 
          error: &amp;#39;INVALID_CREDENTIALS&amp;#39;,
          message: &amp;#39;잘못된 사용자명 또는 비밀번호입니다.&amp;#39; 
        });
        return;
      }

      // 비밀번호 검증
      const isValidPassword = await bcrypt.compare(password, user.password);
      if (!isValidPassword) {
        res.status(401).json({ 
          error: &amp;#39;INVALID_CREDENTIALS&amp;#39;,
          message: &amp;#39;잘못된 사용자명 또는 비밀번호입니다.&amp;#39; 
        });
        return;
      }

      // JWT 토큰 생성
      const tokenPair = JWTManager.generateTokenPair({
        userId: user.id,
        username: user.username,
        role: user.role,
      });

      // Refresh Token을 DB에 저장 (선택사항)
      await User.updateOne(
        { _id: user.id },
        { refreshToken: tokenPair.refreshToken }
      );

      res.json({
        message: &amp;#39;로그인 성공&amp;#39;,
        user: {
          id: user.id,
          username: user.username,
          role: user.role,
        },
        ...tokenPair,
      });
    } catch (error) {
      console.error(&amp;#39;로그인 에러:&amp;#39;, error);
      res.status(500).json({ 
        error: &amp;#39;INTERNAL_ERROR&amp;#39;,
        message: &amp;#39;서버 오류가 발생했습니다.&amp;#39; 
      });
    }
  }

  // 토큰 갱신
  async refreshToken(req: Request, res: Response): Promise&amp;lt;void&amp;gt; {
    try {
      const { refreshToken } = req.body;

      if (!refreshToken) {
        res.status(400).json({ 
          error: &amp;#39;MISSING_REFRESH_TOKEN&amp;#39;,
          message: &amp;#39;리프레시 토큰이 필요합니다.&amp;#39; 
        });
        return;
      }

      // 리프레시 토큰 검증
      const { userId } = await JWTManager.verifyRefreshToken(refreshToken);

      // 사용자 조회 및 리프레시 토큰 확인
      const user = await User.findById(userId);
      if (!user || user.refreshToken !== refreshToken) {
        res.status(401).json({ 
          error: &amp;#39;INVALID_REFRESH_TOKEN&amp;#39;,
          message: &amp;#39;유효하지 않은 리프레시 토큰입니다.&amp;#39; 
        });
        return;
      }

      // 새로운 토큰 쌍 생성
      const newTokenPair = JWTManager.generateTokenPair({
        userId: user.id,
        username: user.username,
        role: user.role,
      });

      // 새로운 리프레시 토큰 저장
      await User.updateOne(
        { _id: user.id },
        { refreshToken: newTokenPair.refreshToken }
      );

      res.json({
        message: &amp;#39;토큰 갱신 성공&amp;#39;,
        ...newTokenPair,
      });
    } catch (error) {
      console.error(&amp;#39;토큰 갱신 에러:&amp;#39;, error);
      res.status(401).json({ 
        error: &amp;#39;TOKEN_REFRESH_FAILED&amp;#39;,
        message: &amp;#39;토큰 갱신에 실패했습니다.&amp;#39; 
      });
    }
  }

  // 로그아웃
  async logout(req: AuthenticatedRequest, res: Response): Promise&amp;lt;void&amp;gt; {
    try {
      if (!req.user) {
        res.status(401).json({ message: &amp;#39;인증이 필요합니다.&amp;#39; });
        return;
      }

      // DB에서 리프레시 토큰 제거
      await User.updateOne(
        { _id: req.user.userId },
        { $unset: { refreshToken: 1 } }
      );

      res.json({ message: &amp;#39;로그아웃 성공&amp;#39; });
    } catch (error) {
      console.error(&amp;#39;로그아웃 에러:&amp;#39;, error);
      res.status(500).json({ 
        error: &amp;#39;INTERNAL_ERROR&amp;#39;,
        message: &amp;#39;서버 오류가 발생했습니다.&amp;#39; 
      });
    }
  }

  // 현재 사용자 정보 조회
  async getProfile(req: AuthenticatedRequest, res: Response): Promise&amp;lt;void&amp;gt; {
    try {
      if (!req.user) {
        res.status(401).json({ message: &amp;#39;인증이 필요합니다.&amp;#39; });
        return;
      }

      const user = await User.findById(req.user.userId).select(&amp;#39;-password -refreshToken&amp;#39;);

      res.json({
        user: {
          id: user.id,
          username: user.username,
          email: user.email,
          role: user.role,
          createdAt: user.createdAt,
        },
      });
    } catch (error) {
      console.error(&amp;#39;프로필 조회 에러:&amp;#39;, error);
      res.status(500).json({ 
        error: &amp;#39;INTERNAL_ERROR&amp;#39;,
        message: &amp;#39;서버 오류가 발생했습니다.&amp;#39; 
      });
    }
  }
}

export default new AuthController();&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;라우터 설정&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// routes/auth.ts
import { Router } from &amp;#39;express&amp;#39;;
import AuthController from &amp;#39;../controllers/authController&amp;#39;;
import { authenticateToken } from &amp;#39;../middleware/auth&amp;#39;;
import { validateLogin } from &amp;#39;../middleware/validation&amp;#39;;

const router = Router();

// 공개 라우트
router.post(&amp;#39;/login&amp;#39;, validateLogin, AuthController.login);
router.post(&amp;#39;/refresh&amp;#39;, AuthController.refreshToken);

// 보호된 라우트
router.post(&amp;#39;/logout&amp;#39;, authenticateToken, AuthController.logout);
router.get(&amp;#39;/profile&amp;#39;, authenticateToken, AuthController.getProfile);

export default router;&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;보안 모범 사례&lt;/h2&gt;
&lt;h3&gt;1. 토큰 저장 방식 선택&lt;/h3&gt;
&lt;h4&gt;httpOnly 쿠키 (권장)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 백엔드: 안전한 쿠키 설정
const setSecureTokenCookie = (res: Response, token: string, name: string) =&amp;gt; {
  res.cookie(name, token, {
    httpOnly: true,        // JavaScript 접근 차단 (XSS 방지)
    secure: process.env.NODE_ENV === &amp;#39;production&amp;#39;, // HTTPS에서만 전송
    sameSite: &amp;#39;strict&amp;#39;,    // CSRF 공격 방지
    maxAge: 15 * 60 * 1000, // 15분
    path: &amp;#39;/&amp;#39;,             // 쿠키 경로
  });
};

// 로그인 시 쿠키 설정
app.post(&amp;#39;/api/auth/login&amp;#39;, async (req, res) =&amp;gt; {
  // 인증 로직...
  const { accessToken, refreshToken } = generateTokens(user);

  setSecureTokenCookie(res, accessToken, &amp;#39;accessToken&amp;#39;);
  setSecureTokenCookie(res, refreshToken, &amp;#39;refreshToken&amp;#39;);

  res.json({ message: &amp;#39;로그인 성공&amp;#39;, user });
});&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 프론트엔드: 쿠키에서 토큰 읽기
const getTokenFromCookie = (name: string): string | null =&amp;gt; {
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) {
    return parts.pop()?.split(&amp;#39;;&amp;#39;).shift() || null;
  }
  return null;
};

// API 요청 시 쿠키 자동 포함
const apiRequest = async (url: string, options: RequestInit = {}) =&amp;gt; {
  return fetch(url, {
    ...options,
    credentials: &amp;#39;include&amp;#39;, // 쿠키 포함
    headers: {
      &amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39;,
      ...options.headers,
    },
  });
};&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;localStorage 사용 시 주의사항&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// XSS 공격 방지를 위한 추가 보안 조치
const SecureTokenManager = {
  // 토큰 저장 전 검증
  setToken: (token: string) =&amp;gt; {
    if (!token || typeof token !== &amp;#39;string&amp;#39;) {
      throw new Error(&amp;#39;유효하지 않은 토큰&amp;#39;);
    }

    // 토큰 형식 검증
    const parts = token.split(&amp;#39;.&amp;#39;);
    if (parts.length !== 3) {
      throw new Error(&amp;#39;JWT 형식이 올바르지 않습니다&amp;#39;);
    }

    localStorage.setItem(&amp;#39;accessToken&amp;#39;, token);
  },

  // 토큰 조회 시 검증
  getToken: (): string | null =&amp;gt; {
    const token = localStorage.getItem(&amp;#39;accessToken&amp;#39;);
    if (!token) return null;

    // 기본적인 형식 검증
    const parts = token.split(&amp;#39;.&amp;#39;);
    if (parts.length !== 3) {
      localStorage.removeItem(&amp;#39;accessToken&amp;#39;);
      return null;
    }

    return token;
  },

  // 토큰 제거
  removeToken: () =&amp;gt; {
    localStorage.removeItem(&amp;#39;accessToken&amp;#39;);
    localStorage.removeItem(&amp;#39;refreshToken&amp;#39;);
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. CSRF 공격 방지&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// CSRF 토큰 생성 및 검증
import crypto from &amp;#39;crypto&amp;#39;;

class CSRFProtection {
  private static tokens = new Map&amp;lt;string, number&amp;gt;();

  static generateToken(sessionId: string): string {
    const token = crypto.randomBytes(32).toString(&amp;#39;hex&amp;#39;);
    this.tokens.set(token, Date.now());
    return token;
  }

  static validateToken(token: string): boolean {
    const timestamp = this.tokens.get(token);
    if (!timestamp) return false;

    // 토큰 유효 시간 (5분)
    const VALID_DURATION = 5 * 60 * 1000;
    if (Date.now() - timestamp &amp;gt; VALID_DURATION) {
      this.tokens.delete(token);
      return false;
    }

    return true;
  }

  static middleware = (req: Request, res: Response, next: NextFunction) =&amp;gt; {
    if (req.method === &amp;#39;GET&amp;#39;) {
      next();
      return;
    }

    const csrfToken = req.headers[&amp;#39;x-csrf-token&amp;#39;] as string;
    if (!csrfToken || !CSRFProtection.validateToken(csrfToken)) {
      res.status(403).json({ message: &amp;#39;CSRF 토큰이 유효하지 않습니다.&amp;#39; });
      return;
    }

    next();
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 토큰 생명주기 관리&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 토큰 갱신 전략
class TokenManager {
  private static readonly REFRESH_THRESHOLD = 5 * 60 * 1000; // 5분

  // 토큰 만료 시간까지 남은 시간 계산
  static getTimeUntilExpiry(token: string): number {
    try {
      const payload = JSON.parse(atob(token.split(&amp;#39;.&amp;#39;)[1]));
      const expiryTime = payload.exp * 1000;
      return expiryTime - Date.now();
    } catch {
      return 0;
    }
  }

  // 자동 갱신이 필요한지 확인
  static shouldRefresh(token: string): boolean {
    const timeUntilExpiry = this.getTimeUntilExpiry(token);
    return timeUntilExpiry &amp;gt; 0 &amp;amp;&amp;amp; timeUntilExpiry &amp;lt; this.REFRESH_THRESHOLD;
  }

  // 백그라운드 토큰 갱신
  static startAutoRefresh(refreshCallback: () =&amp;gt; Promise&amp;lt;void&amp;gt;) {
    const interval = setInterval(async () =&amp;gt; {
      const token = localStorage.getItem(&amp;#39;accessToken&amp;#39;);
      if (!token) {
        clearInterval(interval);
        return;
      }

      if (this.shouldRefresh(token)) {
        try {
          await refreshCallback();
        } catch (error) {
          console.error(&amp;#39;자동 토큰 갱신 실패:&amp;#39;, error);
          clearInterval(interval);
        }
      }
    }, 60 * 1000); // 1분마다 체크

    return interval;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. 환경별 보안 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 환경별 설정
const getSecurityConfig = () =&amp;gt; {
  const isProduction = process.env.NODE_ENV === &amp;#39;production&amp;#39;;

  return {
    // JWT 설정
    jwt: {
      accessTokenExpiry: isProduction ? &amp;#39;15m&amp;#39; : &amp;#39;1h&amp;#39;,
      refreshTokenExpiry: isProduction ? &amp;#39;7d&amp;#39; : &amp;#39;30d&amp;#39;,
      algorithm: &amp;#39;HS256&amp;#39;,
      issuer: process.env.JWT_ISSUER || &amp;#39;your-app&amp;#39;,
      audience: process.env.JWT_AUDIENCE || &amp;#39;your-app-users&amp;#39;,
    },

    // 쿠키 설정
    cookie: {
      secure: isProduction,
      sameSite: isProduction ? &amp;#39;strict&amp;#39; : &amp;#39;lax&amp;#39;,
      domain: isProduction ? process.env.COOKIE_DOMAIN : undefined,
    },

    // CORS 설정
    cors: {
      origin: isProduction 
        ? process.env.ALLOWED_ORIGINS?.split(&amp;#39;,&amp;#39;) 
        : [&amp;#39;http://localhost:3000&amp;#39;],
      credentials: true,
    },

    // Rate Limiting
    rateLimit: {
      windowMs: 15 * 60 * 1000, // 15분
      max: isProduction ? 100 : 1000, // 프로덕션에서 더 엄격하게
    },
  };
};&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;결론 및 실무 가이드&lt;/h2&gt;
&lt;p&gt;JWT는 현대 웹 애플리케이션의 핵심 인증 방식으로 자리잡았습니다. 하지만 올바른 이해와 구현이 전제되어야 안전하고 효율적인 시스템을 구축할 수 있습니다.&lt;/p&gt;
&lt;h3&gt;핵심 요약&lt;/h3&gt;
&lt;h4&gt;1. JWT의 핵심 개념&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Self-contained&lt;/strong&gt;: 토큰 자체에 필요한 정보 포함&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stateless&lt;/strong&gt;: 서버에서 상태 관리 불필요&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Portable&lt;/strong&gt;: 다양한 플랫폼과 서비스에서 공유 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;2. 언제 JWT를 선택해야 하는가?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;✅ SPA (Single Page Application) 개발&lt;/li&gt;
&lt;li&gt;✅ 마이크로서비스 아키텍처&lt;/li&gt;
&lt;li&gt;✅ 모바일 애플리케이션&lt;/li&gt;
&lt;li&gt;✅ API 기반 서비스&lt;/li&gt;
&lt;li&gt;❌ 전통적인 서버 렌더링 웹사이트&lt;/li&gt;
&lt;li&gt;❌ 매우 높은 보안이 요구되는 금융 서비스&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;3. 보안 모범 사례&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;토큰 저장&lt;/strong&gt;: httpOnly 쿠키 사용 권장&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTTPS 필수&lt;/strong&gt;: 토큰 전송 시 반드시 사용&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;최소 권한 원칙&lt;/strong&gt;: 토큰에 필요한 최소한의 정보만 포함&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;정기적 갱신&lt;/strong&gt;: Access/Refresh Token 패턴 활용&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;실무 적용 로드맵&lt;/h3&gt;
&lt;h4&gt;1단계: 기본 구현 (소규모 프로젝트)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 단순한 JWT 구현
const token = jwt.sign({ userId: user.id }, secret, { expiresIn: &amp;#39;1h&amp;#39; });&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2단계: 갱신 메커니즘 추가 (중간 규모)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Access/Refresh Token 패턴
const tokens = {
  accessToken: jwt.sign(payload, accessSecret, { expiresIn: &amp;#39;15m&amp;#39; }),
  refreshToken: jwt.sign({ userId }, refreshSecret, { expiresIn: &amp;#39;7d&amp;#39; })
};&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3단계: 고급 보안 기능 (대규모 서비스)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;토큰 블랙리스트 관리&lt;/li&gt;
&lt;li&gt;CSRF 보호&lt;/li&gt;
&lt;li&gt;Rate limiting&lt;/li&gt;
&lt;li&gt;로그 및 모니터링&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;흔한 실수와 해결책&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;실수&lt;/th&gt;
&lt;th&gt;문제점&lt;/th&gt;
&lt;th&gt;해결책&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;민감 정보 포함&lt;/td&gt;
&lt;td&gt;보안 위험&lt;/td&gt;
&lt;td&gt;최소한의 정보만 포함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;긴 만료 시간&lt;/td&gt;
&lt;td&gt;토큰 탈취 위험&lt;/td&gt;
&lt;td&gt;15분 이하 권장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;localStorage 사용&lt;/td&gt;
&lt;td&gt;XSS 취약&lt;/td&gt;
&lt;td&gt;httpOnly 쿠키 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;갱신 로직 부재&lt;/td&gt;
&lt;td&gt;UX 저하&lt;/td&gt;
&lt;td&gt;자동 갱신 구현&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;에러 처리 부족&lt;/td&gt;
&lt;td&gt;디버깅 어려움&lt;/td&gt;
&lt;td&gt;상세한 에러 분류&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;마무리&lt;/h3&gt;
&lt;p&gt;JWT는 올바르게 구현하면 확장 가능하고 안전한 인증 시스템의 기반이 됩니다. 하지만 프로젝트의 특성과 요구사항을 면밀히 분석하여 적절한 구현 방식을 선택하는 것이 중요합니다. &lt;/p&gt;
&lt;p&gt;특히 보안은 구현 단계에서부터 고려되어야 하며, 운영 중에도 지속적인 모니터링과 개선이 필요합니다. JWT의 장점을 최대한 활용하면서도 단점을 보완하는 방향으로 설계하시기 바랍니다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/117</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/JWT-%EA%B0%80%EC%9D%B4%EB%93%9C-%EA%B0%9C%EB%85%90%EB%B6%80%ED%84%B0-React-%EC%8B%A4%EB%AC%B4-%EA%B5%AC%ED%98%84%EA%B9%8C%EC%A7%80-Session-%EB%B9%84%EA%B5%90-%EB%B3%B4%EC%95%88-TypeScript-%EC%98%88%EC%A0%9C-%EC%B4%9D%EC%A0%95%EB%A6%AC#entry117comment</comments>
      <pubDate>Mon, 21 Jul 2025 15:04:07 +0900</pubDate>
    </item>
    <item>
      <title>React Hooks 규칙: useState를 조건문에서 사용하면 안 되는 이유</title>
      <link>https://white-mouse-dev.tistory.com/entry/React-Hooks-%EA%B7%9C%EC%B9%99-useState%EB%A5%BC-%EC%A1%B0%EA%B1%B4%EB%AC%B8%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%A9%B4-%EC%95%88-%EB%90%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
      <description>&lt;p&gt;React Hooks는 함수형 컴포넌트에서 상태 관리와 생명주기를 다루는 혁신적인 기능입니다. 하지만 Hooks를 올바르게 사용하기 위해서는 몇 가지 중요한 규칙을 지켜야 합니다. 특히 useState를 조건문 안에서 사용하는 것은 심각한 버그를 야기할 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;React Hooks의 내부 동작 원리&lt;/h2&gt;
&lt;h3&gt;Fiber와 Hook 연결 리스트&lt;/h3&gt;
&lt;p&gt;React는 내부적으로 Fiber라는 자료구조를 사용하여 컴포넌트를 관리합니다. 각 함수형 컴포넌트는 Hook들의 연결 리스트(Linked List)를 가지고 있으며, 이 리스트는 Hook 호출 순서에 따라 구성됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// React 내부 구조 (단순화된 버전)
function Component() {
  // Hook 1: 첫 번째 useState
  const [name, setName] = useState(&amp;#39;&amp;#39;);

  // Hook 2: 두 번째 useState  
  const [age, setAge] = useState(0);

  // Hook 3: useEffect
  useEffect(() =&amp;gt; {
    // effect logic
  }, []);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Hook 호출 순서의 중요성&lt;/h3&gt;
&lt;p&gt;React는 컴포넌트가 리렌더링될 때마다 Hook 연결 리스트를 순서대로 순회하면서 각 Hook의 상태를 복원합니다. 이때 &lt;strong&gt;Hook의 호출 순서가 변경되면 상태와 Hook이 잘못 매핑&lt;/strong&gt;되어 예측할 수 없는 동작이 발생합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;문제 상황: 조건부 Hook 사용&lt;/h2&gt;
&lt;h3&gt;잘못된 예시&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;function UserProfile({ isLoggedIn }) {
  // ❌ 조건문 안에서 useState 사용
  if (isLoggedIn) {
    const [userInfo, setUserInfo] = useState(null);
  }

  const [theme, setTheme] = useState(&amp;#39;light&amp;#39;);

  return &amp;lt;div&amp;gt;User Profile&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;문제가 발생하는 과정&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;첫 번째 렌더링 (isLoggedIn = true):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Hook 1: userInfo state
Hook 2: theme state&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;두 번째 렌더링 (isLoggedIn = false):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Hook 1: theme state (잘못된 매핑!)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이때 theme state가 Hook 1 위치로 이동하면서 이전에 userInfo가 가지고 있던 값을 받게 되어 오류가 발생합니다.&lt;/p&gt;
&lt;h3&gt;실제 에러 메시지&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;React Hook &amp;quot;useState&amp;quot; is called conditionally. 
React Hooks must be called in the exact same order every time.&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;Hooks의 규칙 (Rules of Hooks)&lt;/h2&gt;
&lt;p&gt;React에서 정의한 Hooks 사용 규칙은 다음과 같습니다:&lt;/p&gt;
&lt;h3&gt;1. 최상위에서만 호출&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;function Component() {
  // ✅ 올바른 사용
  const [state1, setState1] = useState(initialValue);
  const [state2, setState2] = useState(initialValue);

  // ❌ 잘못된 사용들
  if (condition) {
    const [state3, setState3] = useState(initialValue);
  }

  for (let i = 0; i &amp;lt; count; i++) {
    const [state4, setState4] = useState(initialValue);
  }

  function eventHandler() {
    const [state5, setState5] = useState(initialValue);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. React 함수에서만 호출&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ✅ 함수형 컴포넌트에서 사용
function MyComponent() {
  const [state, setState] = useState(0);
}

// ✅ 커스텀 Hook에서 사용
function useCustomHook() {
  const [state, setState] = useState(0);
  return [state, setState];
}

// ❌ 일반 JavaScript 함수에서 사용
function regularFunction() {
  const [state, setState] = useState(0); // 에러!
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;조건부 로직의 올바른 처리 방법&lt;/h2&gt;
&lt;h3&gt;1. 조건부 상태 초기화&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;function UserProfile({ isLoggedIn }) {
  // ✅ 항상 Hook을 호출하고, 초기값으로 조건 처리
  const [userInfo, setUserInfo] = useState(isLoggedIn ? null : undefined);
  const [theme, setTheme] = useState(&amp;#39;light&amp;#39;);

  useEffect(() =&amp;gt; {
    if (isLoggedIn &amp;amp;&amp;amp; userInfo === null) {
      // 사용자 정보 로드
      fetchUserInfo().then(setUserInfo);
    }
  }, [isLoggedIn, userInfo]);

  return &amp;lt;div&amp;gt;User Profile&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 조건부 컴포넌트 분리&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ✅ 컴포넌트를 분리하여 각각에서 Hook 사용
function LoggedInProfile() {
  const [userInfo, setUserInfo] = useState(null);
  // 로그인된 사용자 로직
  return &amp;lt;div&amp;gt;Logged In Content&amp;lt;/div&amp;gt;;
}

function GuestProfile() {
  const [guestPrefs, setGuestPrefs] = useState({});
  // 게스트 사용자 로직
  return &amp;lt;div&amp;gt;Guest Content&amp;lt;/div&amp;gt;;
}

function UserProfile({ isLoggedIn }) {
  const [theme, setTheme] = useState(&amp;#39;light&amp;#39;);

  return (
    &amp;lt;div&amp;gt;
      {isLoggedIn ? &amp;lt;LoggedInProfile /&amp;gt; : &amp;lt;GuestProfile /&amp;gt;}
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 커스텀 Hook 활용&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ✅ 조건부 로직을 커스텀 Hook으로 분리
function useUserData(isLoggedIn) {
  const [userInfo, setUserInfo] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() =&amp;gt; {
    if (isLoggedIn) {
      setLoading(true);
      fetchUserInfo()
        .then(setUserInfo)
        .finally(() =&amp;gt; setLoading(false));
    } else {
      setUserInfo(null);
    }
  }, [isLoggedIn]);

  return { userInfo, loading };
}

function UserProfile({ isLoggedIn }) {
  const { userInfo, loading } = useUserData(isLoggedIn);
  const [theme, setTheme] = useState(&amp;#39;light&amp;#39;);

  return &amp;lt;div&amp;gt;User Profile&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;실무에서 자주 발생하는 실수와 해결법&lt;/h2&gt;
&lt;h3&gt;1. 동적 Hook 개수&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ❌ 배열 길이에 따라 Hook 개수가 변함
function DynamicForm({ fields }) {
  const states = fields.map(field =&amp;gt; useState(&amp;#39;&amp;#39;)); // 에러!

  return &amp;lt;form&amp;gt;...&amp;lt;/form&amp;gt;;
}

// ✅ 단일 객체 상태로 관리
function DynamicForm({ fields }) {
  const [formData, setFormData] = useState({});

  const updateField = (fieldName, value) =&amp;gt; {
    setFormData(prev =&amp;gt; ({ ...prev, [fieldName]: value }));
  };

  return &amp;lt;form&amp;gt;...&amp;lt;/form&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 조건부 useEffect&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// ❌ 조건문 안에서 useEffect 사용
function Component({ shouldTrack }) {
  if (shouldTrack) {
    useEffect(() =&amp;gt; {
      // 트래킹 로직
    }, []);
  }
}

// ✅ useEffect 내부에서 조건 처리
function Component({ shouldTrack }) {
  useEffect(() =&amp;gt; {
    if (shouldTrack) {
      // 트래킹 로직
    }
  }, [shouldTrack]);
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;디버깅과 도구&lt;/h2&gt;
&lt;h3&gt;ESLint Plugin 활용&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;plugins&amp;quot;: [&amp;quot;react-hooks&amp;quot;],
  &amp;quot;rules&amp;quot;: {
    &amp;quot;react-hooks/rules-of-hooks&amp;quot;: &amp;quot;error&amp;quot;,
    &amp;quot;react-hooks/exhaustive-deps&amp;quot;: &amp;quot;warn&amp;quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;React DevTools&lt;/h3&gt;
&lt;p&gt;React DevTools를 사용하면 Hook의 상태와 순서를 시각적으로 확인할 수 있습니다. 주의할 점은 개발 환경에서 Hook 규칙 위반을 조기에 발견하는 것이 중요하다는 것입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;React Hooks의 규칙을 지키는 것은 안정적인 React 애플리케이션을 만드는 기본 조건입니다. 특히 useState를 조건문에서 사용하지 않는 것은 예측 가능한 상태 관리를 위해 반드시 지켜야 할 규칙입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;핵심 원칙:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Hook은 항상 컴포넌트 최상위에서 호출&lt;/li&gt;
&lt;li&gt;조건부 로직은 Hook 내부에서 처리&lt;/li&gt;
&lt;li&gt;복잡한 조건부 로직은 컴포넌트 분리나 커스텀 Hook 활용&lt;/li&gt;
&lt;li&gt;ESLint 규칙을 통한 자동 검증&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;올바른 Hook 사용 패턴을 익히면 React의 강력한 기능을 안전하게 활용할 수 있습니다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/116</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/React-Hooks-%EA%B7%9C%EC%B9%99-useState%EB%A5%BC-%EC%A1%B0%EA%B1%B4%EB%AC%B8%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%A9%B4-%EC%95%88-%EB%90%98%EB%8A%94-%EC%9D%B4%EC%9C%A0#entry116comment</comments>
      <pubDate>Fri, 18 Jul 2025 10:25:47 +0900</pubDate>
    </item>
    <item>
      <title>Git branch 전략</title>
      <link>https://white-mouse-dev.tistory.com/entry/Git-branch-%EC%A0%84%EB%9E%B5</link>
      <description>&lt;p&gt;소프트웨어 개발에서 Git 브랜치 전략은 팀의 개발 효율성과 코드 품질을 결정하는 핵심 요소입니다. 프로젝트의 규모, 팀 구성, 배포 주기에 따라 적절한 브랜치 전략을 선택하는 것이 중요합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. Git Flow&lt;/h2&gt;
&lt;h3&gt;정의와 구조&lt;/h3&gt;
&lt;p&gt;Git Flow는 Vincent Driessen이 제안한 브랜치 전략으로, 대규모 프로젝트에서 안정적인 릴리스 관리를 위해 설계되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;브랜치 구조:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;main&lt;/code&gt;: 프로덕션 배포 브랜치&lt;/li&gt;
&lt;li&gt;&lt;code&gt;develop&lt;/code&gt;: 개발 통합 브랜치&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feature/*&lt;/code&gt;: 기능 개발 브랜치&lt;/li&gt;
&lt;li&gt;&lt;code&gt;release/*&lt;/code&gt;: 릴리스 준비 브랜치&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hotfix/*&lt;/code&gt;: 긴급 수정 브랜치&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;워크플로우&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;1. feature 브랜치에서 기능 개발
2. develop 브랜치에 병합
3. release 브랜치에서 QA 진행
4. main 브랜치에 최종 병합
5. 필요시 hotfix 브랜치로 긴급 수정&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;장점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;안정성&lt;/strong&gt;: 체계적인 QA 프로세스&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;명확한 역할 분리&lt;/strong&gt;: 각 브랜치의 목적이 명확&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;릴리스 관리&lt;/strong&gt;: 버전 관리와 릴리스 준비 단계 분리&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;병렬 개발&lt;/strong&gt;: 여러 기능을 동시에 개발 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;단점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;복잡성&lt;/strong&gt;: 많은 브랜치로 인한 관리 복잡도 증가&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;속도&lt;/strong&gt;: 긴 릴리스 주기&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;충돌 가능성&lt;/strong&gt;: 장기간 브랜치 분리로 인한 merge 충돌&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;적합한 프로젝트&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;릴리스 주기가 긴 프로젝트&lt;/li&gt;
&lt;li&gt;안정성이 중요한 시스템 (금융, 의료 등)&lt;/li&gt;
&lt;li&gt;대규모 팀과 복잡한 프로젝트&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;2. GitHub Flow&lt;/h2&gt;
&lt;h3&gt;정의와 구조&lt;/h3&gt;
&lt;p&gt;GitHub Flow는 GitHub에서 제안한 단순화된 브랜치 전략으로, 지속적 배포(CD)를 지향하는 프로젝트에 적합합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;브랜치 구조:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;main&lt;/code&gt;: 배포 가능한 안정적인 코드&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feature/*&lt;/code&gt;: 기능 개발 브랜치&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;워크플로우&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;1. main에서 feature 브랜치 생성
2. 기능 개발 및 커밋
3. Pull Request 생성
4. 코드 리뷰 진행
5. main에 병합 후 즉시 배포&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;장점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;단순성&lt;/strong&gt;: 간단한 브랜치 구조&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;빠른 배포&lt;/strong&gt;: 짧은 개발 주기&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;협업&lt;/strong&gt;: Pull Request 중심의 코드 리뷰&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CI/CD&lt;/strong&gt;: 지속적 통합/배포에 최적화&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;단점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;안정성 우려&lt;/strong&gt;: 별도의 QA 브랜치 없음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;릴리스 관리&lt;/strong&gt;: 복잡한 릴리스 프로세스 부족&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;롤백&lt;/strong&gt;: 문제 발생 시 롤백 복잡도&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;적합한 프로젝트&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;자주 배포하는 웹 서비스&lt;/li&gt;
&lt;li&gt;스타트업과 애자일 개발 환경&lt;/li&gt;
&lt;li&gt;소규모 팀과 단순한 프로젝트&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;3. Trunk-Based Development&lt;/h2&gt;
&lt;h3&gt;정의와 구조&lt;/h3&gt;
&lt;p&gt;Trunk-Based Development는 모든 개발자가 하나의 main 브랜치(trunk)에서 작업하는 전략입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;특징:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;단일 브랜치 운영&lt;/li&gt;
&lt;li&gt;짧은 주기의 통합 (1-2일)&lt;/li&gt;
&lt;li&gt;Feature Flag 활용&lt;/li&gt;
&lt;li&gt;강력한 자동화 테스트&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;워크플로우&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;1. main에서 직접 작업 또는 짧은 수명의 브랜치 생성
2. 1-2일 내 main에 병합
3. Feature Flag로 기능 on/off 제어
4. 자동화된 테스트와 배포&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;장점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;빠른 통합&lt;/strong&gt;: 코드 충돌 최소화&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;단순성&lt;/strong&gt;: 브랜치 관리 오버헤드 없음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;지속적 배포&lt;/strong&gt;: 높은 배포 빈도&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;팀 협업&lt;/strong&gt;: 코드 공유와 협업 증대&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;단점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;높은 자동화 요구&lt;/strong&gt;: 강력한 CI/CD 필수&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;위험성&lt;/strong&gt;: 불안정한 코드의 main 유입 가능&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;팀 숙련도&lt;/strong&gt;: 높은 개발 숙련도 요구&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;적합한 프로젝트&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;DevOps 문화가 성숙한 조직&lt;/li&gt;
&lt;li&gt;높은 자동화 환경&lt;/li&gt;
&lt;li&gt;숙련된 개발팀&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;브랜치 전략 선택 가이드&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;Git Flow&lt;/th&gt;
&lt;th&gt;GitHub Flow&lt;/th&gt;
&lt;th&gt;Trunk-Based&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;릴리스 주기&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;긴 주기&lt;/td&gt;
&lt;td&gt;짧은 주기&lt;/td&gt;
&lt;td&gt;지속적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;팀 규모&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;대규모&lt;/td&gt;
&lt;td&gt;소-중규모&lt;/td&gt;
&lt;td&gt;소-중규모&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;안정성 요구&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;높음 (자동화)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;복잡도&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;자동화 요구&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;실무 적용 시 고려사항&lt;/h2&gt;
&lt;h3&gt;1. 팀 상황 분석&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;팀 규모와 개발 경험 수준&lt;/li&gt;
&lt;li&gt;기존 개발 프로세스와의 호환성&lt;/li&gt;
&lt;li&gt;코드 리뷰 문화와 자동화 수준&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. 프로젝트 특성 고려&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;배포 빈도와 릴리스 주기&lt;/li&gt;
&lt;li&gt;안정성 요구사항&lt;/li&gt;
&lt;li&gt;사용자 영향도&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3. 하이브리드 접근&lt;/h3&gt;
&lt;p&gt;실제 프로젝트에서는 여러 전략을 조합하여 사용하는 경우가 많습니다. 주의할 점은 팀 전체가 동일한 브랜치 전략을 이해하고 따라야 한다는 것입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;브랜치 전략 선택은 프로젝트의 성공을 좌우하는 중요한 결정입니다. 각 전략의 장단점을 이해하고 프로젝트 상황에 맞는 선택을 하는 것이 핵심입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;권장사항:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;대규모 프로젝트 → Git Flow&lt;/li&gt;
&lt;li&gt;중소규모 웹 서비스 → GitHub Flow  &lt;/li&gt;
&lt;li&gt;고도로 자동화된 환경 → Trunk-Based Development&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;효과적인 브랜치 전략 도입을 위해서는 팀 교육과 점진적 적용이 중요합니다.&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/115</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/Git-branch-%EC%A0%84%EB%9E%B5#entry115comment</comments>
      <pubDate>Mon, 14 Jul 2025 12:31:55 +0900</pubDate>
    </item>
    <item>
      <title>무한 스크롤 vs 페이지네이션, 내가 내린 선택과 후회</title>
      <link>https://white-mouse-dev.tistory.com/entry/%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-vs-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-%EB%82%B4%EA%B0%80-%EB%82%B4%EB%A6%B0-%EC%84%A0%ED%83%9D%EA%B3%BC-%ED%9B%84%ED%9A%8C</link>
      <description>&lt;p&gt;최근 회사에서 관리자 페이지를 리뉴얼하면서 &amp;quot;페이지네이션을 할까, 무한 스크롤을 할까&amp;quot; 고민이 생겼습니다. 처음에는 단순하게 생각했는데, 막상 구현하다 보니 생각보다 복잡한 문제들이 많더라고요. 특히 백엔드 API와의 협업에서 예상치 못한 이슈들을 겪으면서 배운 것들을 공유해봅니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;처음 마주친 상황&lt;/h2&gt;
&lt;p&gt;우리 관리자 페이지에는 주문 목록, 사용자 목록, 상품 목록 등 데이터가 많은 테이블들이 있었어요. 기존에는 단순한 테이블에 20개씩 보여주고 페이지 번호로 넘기는 구조였는데, 새로 디자인된 UI는 좀 더 모던한 느낌이었거든요.&lt;/p&gt;
&lt;p&gt;React로 구현하면서 처음에는 간단하게 생각했어요:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const [currentPage, setCurrentPage] = useState(1);
const [data, setData] = useState([]);
const [totalCount, setTotalCount] = useState(0);

useEffect(() =&amp;gt; {
  fetchData(currentPage);
}, [currentPage]);&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;첫 번째 선택: 전통적인 페이지네이션&lt;/h2&gt;
&lt;p&gt;처음에는 가장 익숙한 페이지네이션을 선택했어요. 백엔드에서 총 개수도 주고, 페이지 정보도 주니까 구현하기 쉬워 보였거든요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;interface PaginationResponse&amp;lt;T&amp;gt; {
  data: T[];
  totalCount: number;
  currentPage: number;
  totalPages: number;
  hasNext: boolean;
  hasPrev: boolean;
}

const OrderList = () =&amp;gt; {
  const [response, setResponse] = useState&amp;lt;PaginationResponse&amp;lt;Order&amp;gt;&amp;gt;();

  const fetchOrders = async (page: number) =&amp;gt; {
    const result = await api.get(`/orders?page=${page}&amp;amp;limit=20`);
    setResponse(result);
  };

  return (
    &amp;lt;div&amp;gt;
      {response?.data.map(order =&amp;gt; &amp;lt;OrderCard key={order.id} order={order} /&amp;gt;)}
      &amp;lt;Pagination 
        current={response?.currentPage} 
        total={response?.totalPages}
        onChange={setCurrentPage} 
      /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;예상치 못한 성능 문제&lt;/h2&gt;
&lt;p&gt;처음에는 잘 돌아갔어요. 그런데 데이터가 늘어나면서 문제가 생기기 시작했습니다. 특히 검색 기능을 추가하고 나서부터요.&lt;/p&gt;
&lt;p&gt;어느 날 백엔드 개발자가 찾아와서 &amp;quot;어드민에서 COUNT 쿼리 때문에 DB 성능이 안 좋다&amp;quot;고 하더라고요. 알고 보니 총 개수를 계산하는 게 엄청 무거운 작업이었던 거죠.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// 이렇게 하면 백엔드에서 COUNT(*) 쿼리가 날아감
const fetchData = (page: number, searchTerm: string) =&amp;gt; {
  return api.get(`/orders?page=${page}&amp;amp;search=${searchTerm}&amp;amp;limit=20`);
  // 뒤에서는 SELECT COUNT(*) FROM orders WHERE ... 이 실행됨
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특히 검색 조건이 복잡할 때 COUNT 쿼리가 몇 초씩 걸리는 경우도 있었어요. 사용자는 그냥 첫 번째 페이지만 보려는 건데 전체 개수를 세느라 기다려야 하는 상황이었죠.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;두 번째 시도: 무한 스크롤&lt;/h2&gt;
&lt;p&gt;그래서 무한 스크롤로 바꿔보기로 했어요. 총 개수를 안 세도 되니까 백엔드 부담도 줄고, UX도 더 좋을 것 같았거든요.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const useInfiniteScroll = (fetchMore: () =&amp;gt; void) =&amp;gt; {
  useEffect(() =&amp;gt; {
    const handleScroll = () =&amp;gt; {
      if (
        window.innerHeight + document.documentElement.scrollTop
        &amp;gt;= document.documentElement.offsetHeight - 1000
      ) {
        fetchMore();
      }
    };

    window.addEventListener(&amp;#39;scroll&amp;#39;, handleScroll);
    return () =&amp;gt; window.removeEventListener(&amp;#39;scroll&amp;#39;, handleScroll);
  }, [fetchMore]);
};

const OrderList = () =&amp;gt; {
  const [orders, setOrders] = useState&amp;lt;Order[]&amp;gt;([]);
  const [hasNext, setHasNext] = useState(true);
  const [loading, setLoading] = useState(false);

  const fetchMore = useCallback(async () =&amp;gt; {
    if (loading || !hasNext) return;

    setLoading(true);
    const lastId = orders[orders.length - 1]?.id;
    const newOrders = await api.get(`/orders?lastId=${lastId}&amp;amp;limit=20`);

    setOrders(prev =&amp;gt; [...prev, ...newOrders.data]);
    setHasNext(newOrders.hasNext);
    setLoading(false);
  }, [orders, loading, hasNext]);

  useInfiniteScroll(fetchMore);

  return (
    &amp;lt;div&amp;gt;
      {orders.map(order =&amp;gt; &amp;lt;OrderCard key={order.id} order={order} /&amp;gt;)}
      {loading &amp;amp;&amp;amp; &amp;lt;Spinner /&amp;gt;}
    &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;무한 스크롤의 함정들&lt;/h2&gt;
&lt;p&gt;처음에는 괜찮아 보였는데, 실제 사용하다 보니 여러 문제들이 생겼어요:&lt;/p&gt;
&lt;h3&gt;1. 검색할 때마다 리셋 문제&lt;/h3&gt;
&lt;p&gt;검색 조건이 바뀔 때마다 기존 데이터를 다 지우고 새로 시작해야 하는데, 이게 생각보다 복잡했어요:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const handleSearch = useCallback((searchTerm: string) =&amp;gt; {
  setOrders([]); // 기존 데이터 클리어
  setHasNext(true);
  // 새로 검색 시작
  fetchOrders(searchTerm);
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 브라우저 뒤로가기 문제&lt;/h3&gt;
&lt;p&gt;사용자가 상세 페이지 갔다가 뒤로 오면 스크롤 위치도 사라지고, 로드했던 데이터도 다 사라져서 처음부터 다시 로딩해야 했어요.&lt;/p&gt;
&lt;h3&gt;3. 특정 항목으로 바로 가기 어려움&lt;/h3&gt;
&lt;p&gt;&amp;quot;100번째 주문을 보여줘&amp;quot; 같은 요구사항이 생겼을 때 무한 스크롤로는 답이 없었어요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;절충안: Cursor 기반 페이지네이션&lt;/h2&gt;
&lt;p&gt;결국 찾은 해답은 cursor 기반 페이지네이션이었어요. 페이지 번호 대신 마지막 아이템의 ID를 기준으로 다음 데이터를 가져오는 방식이죠.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;interface CursorPaginationProps {
  cursor?: string;
  hasNext: boolean;
  hasPrev: boolean;
}

const OrderList = () =&amp;gt; {
  const [orders, setOrders] = useState&amp;lt;Order[]&amp;gt;([]);
  const [pagination, setPagination] = useState&amp;lt;CursorPaginationProps&amp;gt;({
    hasNext: true,
    hasPrev: false
  });

  const fetchPage = async (cursor?: string, direction: &amp;#39;next&amp;#39; | &amp;#39;prev&amp;#39; = &amp;#39;next&amp;#39;) =&amp;gt; {
    const response = await api.get(`/orders?cursor=${cursor}&amp;amp;direction=${direction}&amp;amp;limit=20`);
    setOrders(response.data);
    setPagination({
      cursor: response.cursor,
      hasNext: response.hasNext,
      hasPrev: response.hasPrev
    });
  };

  return (
    &amp;lt;div&amp;gt;
      {orders.map(order =&amp;gt; &amp;lt;OrderCard key={order.id} order={order} /&amp;gt;)}
      &amp;lt;div className=&amp;quot;pagination&amp;quot;&amp;gt;
        &amp;lt;button 
          disabled={!pagination.hasPrev}
          onClick={() =&amp;gt; fetchPage(pagination.cursor, &amp;#39;prev&amp;#39;)}
        &amp;gt;
          이전
        &amp;lt;/button&amp;gt;
        &amp;lt;button 
          disabled={!pagination.hasNext}
          onClick={() =&amp;gt; fetchPage(pagination.cursor, &amp;#39;next&amp;#39;)}
        &amp;gt;
          다음
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;현재 사용하는 하이브리드 방식&lt;/h2&gt;
&lt;p&gt;지금은 상황에 따라 다르게 적용하고 있어요:&lt;/p&gt;
&lt;h3&gt;관리자 페이지&lt;/h3&gt;
&lt;p&gt;정확한 페이지 이동이 필요한 곳은 cursor 페이지네이션 + React Query를 조합해서 사용해요:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const useOrdersPagination = (cursor?: string) =&amp;gt; {
  return useQuery({
    queryKey: [&amp;#39;orders&amp;#39;, cursor],
    queryFn: () =&amp;gt; fetchOrders(cursor),
    keepPreviousData: true,
  });
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;일반 사용자용 목록&lt;/h3&gt;
&lt;p&gt;UX가 중요한 곳은 무한 스크롤 + Intersection Observer API를 사용합니다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const useIntersectionObserver = (
  ref: RefObject&amp;lt;Element&amp;gt;,
  callback: () =&amp;gt; void
) =&amp;gt; {
  useEffect(() =&amp;gt; {
    const observer = new IntersectionObserver(
      ([entry]) =&amp;gt; {
        if (entry.isIntersecting) {
          callback();
        }
      },
      { threshold: 0.1 }
    );

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

    return () =&amp;gt; observer.disconnect();
  }, [ref, callback]);
};&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;백엔드와의 협업에서 배운 점&lt;/h2&gt;
&lt;p&gt;이 과정에서 백엔드 개발자와 많은 대화를 나눴어요:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;COUNT 쿼리는 정말 비싸다&lt;/strong&gt;: 특히 조건이 복잡하거나 JOIN이 많으면 더더욱&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OFFSET은 페이지가 뒤로 갈수록 느려진다&lt;/strong&gt;: &lt;code&gt;OFFSET 10000&lt;/code&gt;은 앞의 10000개를 다 스캔해야 함&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cursor 방식이 성능상 가장 좋다&lt;/strong&gt;: 인덱스를 효율적으로 사용할 수 있음&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;지금 내가 내린 결론&lt;/h2&gt;
&lt;p&gt;완벽한 정답은 없는 것 같아요. 하지만 지금은 이런 기준으로 선택하고 있습니다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;데이터가 많고 성능이 중요하다면&lt;/strong&gt;: Cursor 페이지네이션&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;사용자 경험이 중요하다면&lt;/strong&gt;: 무한 스크롤&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;관리 기능에서 정확한 위치가 필요하다면&lt;/strong&gt;: 전통적 페이지네이션 (단, 총 개수는 캐싱)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그리고 무엇보다 백엔드 개발자와 미리 충분히 논의하는 게 중요하더라고요. 프론트에서는 간단해 보이는 기능이 백엔드에서는 엄청난 부하를 일으킬 수 있거든요.&lt;/p&gt;
&lt;p&gt;혹시 비슷한 고민을 해보신 분들이 있다면, 어떤 방식을 선택하셨나요? 각각의 장단점에 대한 경험도 궁금합니다!&lt;/p&gt;</description>
      <category>Frontend Development</category>
      <author>Kun Woo Kim</author>
      <guid isPermaLink="true">https://white-mouse-dev.tistory.com/114</guid>
      <comments>https://white-mouse-dev.tistory.com/entry/%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-vs-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-%EB%82%B4%EA%B0%80-%EB%82%B4%EB%A6%B0-%EC%84%A0%ED%83%9D%EA%B3%BC-%ED%9B%84%ED%9A%8C#entry114comment</comments>
      <pubDate>Mon, 7 Jul 2025 23:09:42 +0900</pubDate>
    </item>
  </channel>
</rss>