<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://hyun-git.github.io//feed.xml" rel="self" type="application/atom+xml" /><link href="https://hyun-git.github.io//" rel="alternate" type="text/html" /><updated>2026-05-20T07:41:40+00:00</updated><id>https://hyun-git.github.io//feed.xml</id><title type="html">HyunPang’s blog!</title><subtitle>HyunPang&apos;s IT Blog
</subtitle><author><name>HyunPang</name></author><entry><title type="html">Statsig Session Replay가 SPA를 2GB 먹던 날 — 브라우저 메모리 스냅샷으로 범인 찾기</title><link href="https://hyun-git.github.io//statsig-session-replay-memory-leak.html" rel="alternate" type="text/html" title="Statsig Session Replay가 SPA를 2GB 먹던 날 — 브라우저 메모리 스냅샷으로 범인 찾기" /><published>2026-05-20T03:00:00+00:00</published><updated>2026-05-20T03:00:00+00:00</updated><id>https://hyun-git.github.io//statsig-session-replay-memory-leak</id><content type="html" xml:base="https://hyun-git.github.io//statsig-session-replay-memory-leak.html"><![CDATA[<h2 id="발견">발견</h2>

<p>대시보드에서 인증 팝업창을 띄워놓고 원래 창을 보고 있었다. 로딩 스피너가 돌아가는 걸 기다리는 중이었는데, 시간이 지날수록 스피너 애니메이션이 점점 버벅이기 시작했다. 닫았다가 열어도 마찬가지였다.</p>

<p>Chrome DevTools의 Performance Monitor를 열어보니 JS heap size가 수백 MB에서 내려오지 않고 계속 우상향하고 있었다. 새로고침하면 정상, 탭을 전환하면 다시 폭증. 단순한 렌더링 이슈가 아니었다.</p>

<p><img src="/assets/images/brower_memory.png" alt="Performance Monitor — JS heap size가 수백 MB에서 내려오지 않는다" /></p>

<hr />

<h2 id="step-1--heap-snapshot으로-무엇이-메모리를-먹는지-확인">Step 1 — Heap Snapshot으로 무엇이 메모리를 먹는지 확인</h2>

<p>Chrome DevTools → Memory 탭 → Heap Snapshot을 찍었다.</p>

<p>탭 전환 전과 후, 두 번 스냅샷을 찍고 Comparison 모드로 비교하자 두 개의 항목이 눈에 띄었다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>_saveFailedLogsToStorage    ~401 MB
wrap(e, t)                  ~400 MB
</code></pre></div></div>

<p><img src="/assets/images/brower_memory2.png" alt="Heap Snapshot Comparison — _saveFailedLogsToStorage와 wrap(e, t)가 400MB씩 차지" /></p>

<p>두 항목 모두 Statsig SDK 내부에서 올라오고 있었다. <code class="language-plaintext highlighter-rouge">_saveFailedLogsToStorage</code>는 이름 그대로 “실패한 로그를 스토리지에 저장하는 함수”다.</p>

<p>Statsig SDK 소스를 추적해보니 흐름은 이랬다.</p>

<ol>
  <li>Statsig가 이벤트 로그를 서버로 flush 시도</li>
  <li>flush 실패 시 localStorage에 백업 저장 시도</li>
  <li>localStorage가 꽉 차면 (<code class="language-plaintext highlighter-rouge">QuotaExceededError</code>) → 메모리에 계속 쌓임</li>
  <li>동시에 rrweb이 DOM 변경사항을 끊임없이 캡처 → 버퍼가 계속 증가</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">wrap(e, t)</code>는 rrweb의 이벤트 래핑 함수였다. rrweb은 <code class="language-plaintext highlighter-rouge">StatsigSessionReplayPlugin</code>이 내부적으로 사용하는 DOM 캡처 라이브러리다.</p>

<hr />

<h2 id="step-2--rrweb이-왜-이렇게-많이-쌓이는가">Step 2 — rrweb이 왜 이렇게 많이 쌓이는가</h2>

<p><code class="language-plaintext highlighter-rouge">StatsigSessionReplayPlugin</code>은 초기화되는 순간부터 모든 DOM 변경을 연속으로 캡처한다. 이 서비스는 복잡한 SPA 대시보드로, 탭을 전환할 때마다 수백 개의 컴포넌트가 마운트/언마운트된다. rrweb 입장에서는 매번 거대한 DOM mutation event가 쏟아지는 것이다.</p>

<p>rrweb에는 sampling 옵션이 있다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">rrwebConfig</span><span class="p">:</span> <span class="p">{</span>
  <span class="nl">sampling</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">mousemove</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
    <span class="na">scroll</span><span class="p">:</span> <span class="mi">300</span><span class="p">,</span>
    <span class="na">input</span><span class="p">:</span> <span class="dl">'</span><span class="s1">last</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">},</span>
<span class="p">}</span>
</code></pre></div></div>

<p>하지만 이 옵션들은 포인터/스크롤/입력 이벤트에만 적용된다. DOM mutation 자체에는 throttle이 없다. 탭 전환 시 발생하는 대규모 렌더링은 sampling 설정과 무관하게 전부 캡처된다.</p>

<hr />

<h2 id="step-3--시도한-해결책들과-왜-안-됐는가">Step 3 — 시도한 해결책들과 왜 안 됐는가</h2>

<h3 id="시도-1-production-환경에서만-session-replay-활성화">시도 1: production 환경에서만 Session Replay 활성화</h3>

<p>dev 환경에서는 Statsig 서버와의 통신이 CORS 등의 이유로 실패하는 경우가 많다. 그래서 flush 실패 → localStorage 백업 실패 → 메모리 잔류라는 흐름이 dev에서만 발생하는 문제일 수 있다고 판단했다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">isProduction</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NODE_ENV</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">production</span><span class="dl">'</span><span class="p">;</span>

<span class="nl">plugins</span><span class="p">:</span> <span class="p">[</span>
  <span class="p">...(</span><span class="nx">isProduction</span> <span class="p">?</span> <span class="p">[</span><span class="k">new</span> <span class="nx">StatsigSessionReplayPlugin</span><span class="p">(...)]</span> <span class="p">:</span> <span class="p">[]),</span>
<span class="p">]</span>
</code></pre></div></div>

<p>효과 없음. production 빌드에서도 동일하게 메모리가 폭증했다. 통신 실패가 원인이 아니라 rrweb의 연속 캡처 자체가 문제였다.</p>

<h3 id="시도-2-메모리-임계치-도달-시-clientshutdown--reinit">시도 2: 메모리 임계치 도달 시 <code class="language-plaintext highlighter-rouge">client.shutdown()</code> + reinit</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">checkAndReset</span> <span class="o">=</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="nb">window</span><span class="p">.</span><span class="nx">performance</span><span class="p">.</span><span class="nx">memory</span><span class="p">.</span><span class="nx">usedJSHeapSize</span> <span class="o">&lt;</span> <span class="nx">THRESHOLD</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>

    <span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nx">shutdown</span><span class="p">();</span>
    <span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nx">initializeAsync</span><span class="p">();</span>
  <span class="p">};</span>

  <span class="kd">const</span> <span class="nx">interval</span> <span class="o">=</span> <span class="nx">setInterval</span><span class="p">(</span><span class="nx">checkAndReset</span><span class="p">,</span> <span class="mi">3000</span><span class="p">);</span>
  <span class="k">return</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">clearInterval</span><span class="p">(</span><span class="nx">interval</span><span class="p">);</span>
<span class="p">},</span> <span class="p">[</span><span class="nx">client</span><span class="p">]);</span>
</code></pre></div></div>

<p>200MB 임계치를 설정하고 초과 시 client를 재초기화했다. 로그상으로는 reset이 실행됐지만 결과는 이랬다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Statsig] heap: 2435.5MB → resetting
[Statsig] heap: 2860.0MB → resetting
[Statsig] heap:  573.0MB  ← GC가 한 번 돌았을 뿐
[Statsig] heap:  996.0MB  ← 다시 증가
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">shutdown()</code> 이후에도 rrweb이 재초기화되면서 DOM 캡처를 즉시 재개했고, 탭 전환의 mutation 속도가 reset 속도를 압도했다. 수도꼭지를 잠그지 않고 물을 퍼내는 격이었다.</p>

<hr />

<h2 id="근본-원인">근본 원인</h2>

<p>문제의 구조를 정리하면 이렇다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>탭 전환
  └→ 수백 컴포넌트 마운트/언마운트
       └→ 대규모 DOM mutation
            └→ rrweb이 모두 캡처 (버퍼에 적재)
                 └→ flush 실패 시 localStorage 백업 시도
                      └→ QuotaExceededError → 메모리에 잔류
                           └→ 힙 2GB+
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">StatsigSessionReplayPlugin</code>은 세션 전체를 연속 녹화하는 방식이다. 단순한 정적 페이지에서는 문제가 없지만, 복잡한 SPA에서 빈번한 탭 전환이 발생하면 rrweb 버퍼가 감당할 수 없을 만큼 불어난다.</p>

<p>현재는 rrweb의 DOM mutation 캡처 자체를 제한하거나, 탭 전환 시점에 Session Replay를 일시 중단하는 방향을 검토 중이다. Statsig SDK가 이 수준의 제어를 공식적으로 지원하는지 확인이 필요하다.</p>

<hr />

<h2 id="핵심-교훈">핵심 교훈</h2>

<ol>
  <li>
    <p><strong>“Session Replay” SDK를 붙일 때는 연속 녹화인지 확인하라.</strong> rrweb 계열 라이브러리는 DOM 전체를 실시간으로 직렬화한다. 복잡한 SPA에서 연속 녹화는 생각보다 훨씬 비싸다.</p>
  </li>
  <li>
    <p><strong>rrweb의 sampling 옵션은 DOM mutation에는 적용되지 않는다.</strong> <code class="language-plaintext highlighter-rouge">mousemove: false</code>, <code class="language-plaintext highlighter-rouge">scroll: 300</code> 같은 옵션은 포인터/스크롤/입력 이벤트에만 해당한다. 컴포넌트 마운트/언마운트로 발생하는 DOM 변경은 샘플링 없이 전부 캡처된다.</p>
  </li>
  <li>
    <p><strong>Heap Snapshot Comparison 모드는 강력하다.</strong> “뭔가 메모리를 많이 쓰는 것 같다”는 느낌을 실제 함수 이름과 크기로 특정하는 데 가장 효율적인 방법이었다. 버그 재현 전후로 스냅샷 두 장만 찍으면 범인이 보인다.</p>
  </li>
  <li>
    <p><strong>메모리 누수는 막는 것이 퍼내는 것보다 낫다.</strong> 주기적 reset, 임계치 reset 모두 근본 원인을 두고 증상만 건드린 것이었다.</p>
  </li>
</ol>]]></content><author><name>HyunPang</name></author><summary type="html"><![CDATA[발견]]></summary></entry><entry><title type="html">Next.js 프로덕션 빌드에서만 CSS가 깨지는 이유 — CSS 모듈 선언 순서 함정</title><link href="https://hyun-git.github.io//nextjs-%ED%94%84%EB%A1%9C%EB%8D%95%EC%85%98-%EB%B9%8C%EB%93%9C%EC%97%90%EC%84%9C%EB%A7%8C-css%EA%B0%80-%EA%B9%A8%EC%A7%80%EB%8A%94-%EC%9D%B4%EC%9C%A0.html" rel="alternate" type="text/html" title="Next.js 프로덕션 빌드에서만 CSS가 깨지는 이유 — CSS 모듈 선언 순서 함정" /><published>2026-05-18T09:00:00+00:00</published><updated>2026-05-18T09:00:00+00:00</updated><id>https://hyun-git.github.io//nextjs-%ED%94%84%EB%A1%9C%EB%8D%95%EC%85%98-%EB%B9%8C%EB%93%9C%EC%97%90%EC%84%9C%EB%A7%8C-css%EA%B0%80-%EA%B9%A8%EC%A7%80%EB%8A%94-%EC%9D%B4%EC%9C%A0</id><content type="html" xml:base="https://hyun-git.github.io//nextjs-%ED%94%84%EB%A1%9C%EB%8D%95%EC%85%98-%EB%B9%8C%EB%93%9C%EC%97%90%EC%84%9C%EB%A7%8C-css%EA%B0%80-%EA%B9%A8%EC%A7%80%EB%8A%94-%EC%9D%B4%EC%9C%A0.html"><![CDATA[<h2 id="증상">증상</h2>

<p><code class="language-plaintext highlighter-rouge">npm run dev</code>에서는 멀쩡하던 버튼이 프로덕션 빌드 후 실행하면 갑자기 <code class="language-plaintext highlighter-rouge">2rem × 2rem</code> 크기로 쪼그라들며 다른 요소에 덮히는 현상이 발생했다. 더 이상한 건 <strong>항상</strong> 깨지는 게 아니라 특정 페이지 진입 경로에서만 재현된다는 점이었다.</p>

<hr />

<h2 id="원인-분석">원인 분석</h2>

<h3 id="1-해시된-클래스명으로-단서-찾기">1. 해시된 클래스명으로 단서 찾기</h3>

<p>프로덕션 빌드에서 DevTools로 해당 버튼을 확인하니 <code class="language-plaintext highlighter-rouge">.mJbdX2</code>라는 해시된 클래스가 붙어 있었고, 이 스타일이 버튼 크기를 덮어쓰고 있었다.</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.mJbdX2</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="n">flex</span><span class="p">;</span>
    <span class="nl">align-items</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
    <span class="nl">justify-content</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
    <span class="nl">width</span><span class="p">:</span> <span class="m">2rem</span><span class="p">;</span>
    <span class="nl">height</span><span class="p">:</span> <span class="m">2rem</span><span class="p">;</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이 스타일은 개발 환경에서는 <code class="language-plaintext highlighter-rouge">IconButton_IconButton__xxxx</code>로 사람이 읽을 수 있는 이름이지만, 프로덕션에서는 짧게 해시된다. 소스를 추적하니 <code class="language-plaintext highlighter-rouge">IconButton.module.scss</code>의 base 클래스였다.</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* IconButton.module.scss */</span>
<span class="nc">.IconButton</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="n">flex</span><span class="p">;</span>
    <span class="nl">align-items</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
    <span class="nl">justify-content</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
    <span class="nl">width</span><span class="p">:</span> <span class="m">2rem</span><span class="p">;</span>
    <span class="nl">height</span><span class="p">:</span> <span class="m">2rem</span><span class="p">;</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="2-충돌-구조-파악">2. 충돌 구조 파악</h3>

<p>문제의 버튼은 두 개의 CSS 모듈 클래스를 동시에 가지고 있었다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>IconButton_IconButton__7ndGq          → width: 2rem  (IconButton.module.scss)
SettingsLinkCopyAndRefreshRow__copyBtn__img_wrapper__FUSbj → width: 8.75rem (SettingsLinkCopyAndRefreshRow.module.scss)
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">IconButton</code> 컴포넌트 내부에서 <code class="language-plaintext highlighter-rouge">classnames(className, style.IconButton)</code>으로 두 클래스를 합치는 구조였다.</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// IconButton.tsx</span>
<span class="p">&lt;</span><span class="nc">Button</span>
    <span class="na">className</span><span class="p">=</span><span class="si">{</span><span class="nx">cx</span><span class="p">(</span><span class="nx">className</span><span class="p">,</span> <span class="nx">style</span><span class="p">.</span><span class="nx">IconButton</span><span class="p">,</span> <span class="nx">style</span><span class="p">[</span><span class="s2">`IconButton--</span><span class="p">${</span><span class="nx">size</span><span class="p">}</span><span class="s2">`</span><span class="p">])</span><span class="si">}</span>
    <span class="err">...</span>
<span class="p">&gt;</span>
</code></pre></div></div>

<p><strong>두 클래스 모두 단일 클래스 선택자(specificity: 0,1,0)</strong> 로 동일한 우선순위를 가진다. 이 경우 CSS 캐스케이드는 <strong>스타일시트 내 선언 순서</strong>가 우선순위를 결정한다.</p>

<blockquote>
  <p>DOM의 class 속성에서 클래스 순서는 CSS 우선순위에 영향을 주지 않는다. 오직 스타일시트 내 선언 순서만 중요하다.</p>
</blockquote>

<hr />

<h3 id="3-dev-vs-prod-동작-차이">3. dev vs prod 동작 차이</h3>

<table>
  <thead>
    <tr>
      <th>환경</th>
      <th>CSS 처리 방식</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>dev</td>
      <td>모듈별로 <code class="language-plaintext highlighter-rouge">&lt;style&gt;</code> 태그를 순서대로 주입</td>
      <td><code class="language-plaintext highlighter-rouge">SettingsLinkCopyAndRefreshRow.module.scss</code>가 나중에 로드 → <code class="language-plaintext highlighter-rouge">8.75rem</code> 적용 ✅</td>
    </tr>
    <tr>
      <td>prod</td>
      <td>모든 CSS를 하나의 번들로 병합</td>
      <td><code class="language-plaintext highlighter-rouge">IconButton</code> 스타일이 번들 내 더 나중 위치 → <code class="language-plaintext highlighter-rouge">2rem</code>으로 덮어씀 ❌</td>
    </tr>
  </tbody>
</table>

<p>Next.js는 프로덕션 빌드 시 CSS 모듈을 페이지 단위 청크로 추출하고 합친다. 이 과정에서 <strong>CSS 파일 병합 순서</strong>가 개발 환경과 달라질 수 있다.</p>

<hr />

<h3 id="4-왜-특정-시나리오에서만-깨지는가">4. 왜 특정 시나리오에서만 깨지는가?</h3>

<p>Next.js의 클라이언트 사이드 네비게이션(CSN) 방식 때문이다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>시나리오 A: 대시보드 직접 접근 (주소 입력 / 새로고침)
→ 페이지 CSS 번들이 한 번에 로드됨
→ 번들 내 IconButton이 나중에 선언됨 → width: 2rem 덮어씀 ❌

시나리오 B: 다른 페이지 → 대시보드 클라이언트 사이드 네비게이션
→ 이미 로드된 CSS 청크가 존재
→ 새 청크가 append되는 순서에 따라 copyBtn__img_wrapper가 더 나중 선언 → 정상 ✅
</code></pre></div></div>

<p>CSS 청크가 DOM에 주입되는 <strong>시점과 순서가 진입 경로마다 다르기</strong> 때문에 “가끔만” 재현되는 것처럼 보인다. 이것이 Next.js CSS ordering 이슈의 전형적인 패턴이다.</p>

<hr />

<h2 id="해결-방법-검토">해결 방법 검토</h2>

<h3 id="방법-1-important-사용">방법 1: <code class="language-plaintext highlighter-rouge">!important</code> 사용</h3>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">&amp;</span><span class="nt">__img_wrapper</span> <span class="p">{</span>
    <span class="nl">width</span><span class="p">:</span> <span class="m">8</span><span class="mi">.75rem</span> <span class="o">!</span><span class="n">important</span><span class="p">;</span>
    <span class="nl">height</span><span class="p">:</span> <span class="m">100%</span> <span class="o">!</span><span class="n">important</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>간단하지만 CSS 유지보수성이 떨어진다. 추후 다른 재정의가 필요할 때 <code class="language-plaintext highlighter-rouge">!important</code> 연쇄가 생길 수 있다.</p>

<h3 id="방법-2-specificity-올리기">방법 2: specificity 올리기</h3>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">&amp;</span><span class="nt">__img_wrapper</span><span class="nd">:not</span><span class="o">(</span><span class="nn">#_</span><span class="o">)</span> <span class="p">{</span>
    <span class="nl">width</span><span class="p">:</span> <span class="m">8</span><span class="mi">.75rem</span><span class="p">;</span>
    <span class="nl">height</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">:not(#_)</code> 가짜 id 선택자로 specificity를 <code class="language-plaintext highlighter-rouge">(0,2,1)</code>로 높여 <code class="language-plaintext highlighter-rouge">IconButton</code>의 <code class="language-plaintext highlighter-rouge">(0,1,0)</code>을 이기게 한다. 동작은 하지만 의도를 숨기는 트릭이다.</p>

<h3 id="방법-3-올바른-컴포넌트-사용-채택">방법 3: 올바른 컴포넌트 사용 (채택)</h3>

<p>근본 원인을 보면, 이 버튼은 <strong>아이콘 + 텍스트</strong> 조합인데 아이콘 전용 컴포넌트인 <code class="language-plaintext highlighter-rouge">IconButton</code>을 <code class="language-plaintext highlighter-rouge">tag</code>로 사용하고 있었다. 컴포넌트 선택 자체가 잘못된 것이다.</p>

<div class="language-jsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before</span>
<span class="p">&lt;</span><span class="nc">CopyButton</span>
    <span class="na">tag</span><span class="p">=</span><span class="si">{</span><span class="nx">IconButton</span><span class="si">}</span>   <span class="c1">// ← 아이콘 전용 컴포넌트</span>
    <span class="na">color</span><span class="p">=</span><span class="s">"secondary"</span>
    <span class="na">className</span><span class="p">=</span><span class="si">{</span><span class="nx">style</span><span class="p">.</span><span class="nx">SettingsLinkCopyAndRefreshRow__copyBtn__img_wrapper</span><span class="si">}</span>
    <span class="na">size</span><span class="p">=</span><span class="si">{</span><span class="nx">isPc</span> <span class="p">?</span> <span class="dl">'</span><span class="s1">md</span><span class="dl">'</span> <span class="p">:</span> <span class="dl">'</span><span class="s1">lg</span><span class="dl">'</span><span class="si">}</span>
<span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="nc">CopyIcon</span> <span class="p">/&gt;</span>
    <span class="si">{</span><span class="o">!</span><span class="nx">isMobile</span> <span class="p">?</span> <span class="nx">intl</span><span class="p">.</span><span class="nx">formatMessage</span><span class="p">({</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">...</span><span class="dl">'</span> <span class="p">})</span> <span class="p">:</span> <span class="dl">''</span><span class="si">}</span>
<span class="p">&lt;/</span><span class="nc">CopyButton</span><span class="p">&gt;</span>

<span class="c1">// After</span>
<span class="p">&lt;</span><span class="nc">CopyButton</span>
    <span class="na">tag</span><span class="p">=</span><span class="si">{</span><span class="nx">Button</span><span class="si">}</span>       <span class="c1">// ← 일반 버튼 컴포넌트</span>
    <span class="na">color</span><span class="p">=</span><span class="s">"secondary"</span>
    <span class="na">className</span><span class="p">=</span><span class="si">{</span><span class="nx">style</span><span class="p">.</span><span class="nx">SettingsLinkCopyAndRefreshRow__copyBtn__img_wrapper</span><span class="si">}</span>
    <span class="na">size</span><span class="p">=</span><span class="si">{</span><span class="nx">isPc</span> <span class="p">?</span> <span class="dl">'</span><span class="s1">md</span><span class="dl">'</span> <span class="p">:</span> <span class="dl">'</span><span class="s1">lg</span><span class="dl">'</span><span class="si">}</span>
<span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="nc">CopyIcon</span> <span class="p">/&gt;</span>
    <span class="si">{</span><span class="o">!</span><span class="nx">isMobile</span> <span class="p">?</span> <span class="nx">intl</span><span class="p">.</span><span class="nx">formatMessage</span><span class="p">({</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">...</span><span class="dl">'</span> <span class="p">})</span> <span class="p">:</span> <span class="dl">''</span><span class="si">}</span>
<span class="p">&lt;/</span><span class="nc">CopyButton</span><span class="p">&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Button</code>은 이미 <code class="language-plaintext highlighter-rouge">@goorm-dev/vapor-components</code>에서 import되어 있었으므로, <code class="language-plaintext highlighter-rouge">IconButton</code> import 제거 + <code class="language-plaintext highlighter-rouge">tag</code> 변경만으로 충돌 원인 자체를 제거했다.</p>

<hr />

<h2 id="핵심-교훈">핵심 교훈</h2>

<ol>
  <li>
    <p><strong>CSS 모듈 클래스 간 specificity가 같으면 번들 순서에 의존하게 된다.</strong> 프로덕션에서 클래스 간 순서는 보장되지 않는다.</p>
  </li>
  <li>
    <p><strong>외부에서 재정의가 필요한 스타일을 컴포넌트 base 클래스에 넣지 말 것.</strong> <code class="language-plaintext highlighter-rouge">IconButton</code>의 <code class="language-plaintext highlighter-rouge">width/height</code>처럼 외부에서 오버라이드해야 하는 값은 modifier 클래스(<code class="language-plaintext highlighter-rouge">--md</code>, <code class="language-plaintext highlighter-rouge">--lg</code>)에 분리하는 것이 안전하다.</p>
  </li>
  <li>
    <p><strong>“dev에서는 되는데 prod에서만 깨진다”는 증상은 CSS 선언 순서 문제일 가능성이 높다.</strong> 특히 진입 경로에 따라 재현 여부가 달라진다면 Next.js CSS 청크 로딩 순서를 의심하라.</p>
  </li>
  <li>
    <p><strong>근본 원인을 먼저 확인하라.</strong> <code class="language-plaintext highlighter-rouge">!important</code>나 specificity 트릭으로 덮기 전에, 컴포넌트 사용 자체가 올바른지 검토하면 더 깔끔한 해결책을 찾을 수 있다.</p>
  </li>
</ol>]]></content><author><name>HyunPang</name></author><summary type="html"><![CDATA[증상]]></summary></entry><entry><title type="html">PNG 압축, quality를 낮춰도 크기가 안 줄었던 이유</title><link href="https://hyun-git.github.io//png-%EC%95%95%EC%B6%95-quality%EB%A5%BC-%EB%82%AE%EC%B6%B0%EB%8F%84-%ED%81%AC%EA%B8%B0%EA%B0%80-%EC%95%88-%EC%A4%84%EC%97%88%EB%8D%98-%EC%9D%B4%EC%9C%A0.html" rel="alternate" type="text/html" title="PNG 압축, quality를 낮춰도 크기가 안 줄었던 이유" /><published>2026-05-18T07:12:00+00:00</published><updated>2026-05-18T07:12:00+00:00</updated><id>https://hyun-git.github.io//png-%EC%95%95%EC%B6%95-quality%EB%A5%BC-%EB%82%AE%EC%B6%B0%EB%8F%84-%ED%81%AC%EA%B8%B0%EA%B0%80-%EC%95%88-%EC%A4%84%EC%97%88%EB%8D%98-%EC%9D%B4%EC%9C%A0</id><content type="html" xml:base="https://hyun-git.github.io//png-%EC%95%95%EC%B6%95-quality%EB%A5%BC-%EB%82%AE%EC%B6%B0%EB%8F%84-%ED%81%AC%EA%B8%B0%EA%B0%80-%EC%95%88-%EC%A4%84%EC%97%88%EB%8D%98-%EC%9D%B4%EC%9C%A0.html"><![CDATA[<h2 id="배경">배경</h2>

<p>Arkain Console에 이미지 첨부 기능을 추가하면서 문제가 생겼다.</p>

<p>사용자가 스냅샷 이미지를 LLM에 함께 전달하는 기능이었는데, LLM API가 5MB를 초과하는 이미지에 에러를 반환했다. 파일 선택 시 허용 최대 크기는 50MB였기 때문에, 업로드 전에 자동으로 압축하는 유틸(compressImage)을 만들기로 했다.</p>

<hr />

<h2 id="처음-만든-로직">처음 만든 로직</h2>

<p>Canvas API를 써서 2단계로 압축했다.</p>

<ol>
  <li>quality를 0.85 → 0.35 순으로 낮춰가며 5MB 이하가 되면 멈춤</li>
  <li>그래도 부족하면 해상도를 <code class="language-plaintext highlighter-rouge">sqrt(maxBytes / blob.size)</code> 비율로 줄인 뒤 quality 0.3으로 재압축</li>
</ol>

<p>왜 <code class="language-plaintext highlighter-rouge">sqrt</code>를 쓰냐면, 이미지 파일 크기는 픽셀 수, 즉 면적에 비례하기 때문이다. 면적 = 가로 × 세로이므로, 파일 크기를 절반으로 줄이려면 가로/세로 각각을 <code class="language-plaintext highlighter-rouge">sqrt(0.5)</code> 배 해야 한다.</p>

<p>로직 자체는 단순했다. 그런데 문제가 생겼다.</p>

<hr />

<h2 id="32mb-png가-54mb로-올라갔다">32MB PNG가 5.4MB로 올라갔다</h2>

<p>목표는 5MB 이하인데 5.4MB로 S3에 올라갔다.</p>

<p>처음엔 계산 실수를 의심했다. 그런데 알고 보니 원인은 다른 데 있었다.</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">canvas.toBlob(callback, 'image/png', quality)</code>에서 PNG는 quality 파라미터를 무시한다.</p>
</blockquote>

<p>PNG는 <strong>무손실(lossless)</strong> 포맷이라 quality 개념 자체가 없다. JPEG나 WebP는 quality로 손실 압축 강도를 조절할 수 있지만, PNG는 해상도를 줄이지 않는 한 canvas로 다시 그려도 파일 크기가 거의 변하지 않는다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// JPEG/WebP는 quality가 효과 있지만</span>
<span class="nx">canvas</span><span class="p">.</span><span class="nx">toBlob</span><span class="p">(</span><span class="nx">resolve</span><span class="p">,</span> <span class="dl">'</span><span class="s1">image/jpeg</span><span class="dl">'</span><span class="p">,</span> <span class="mf">0.3</span><span class="p">);</span> <span class="c1">// ✅ 압축됨</span>

<span class="c1">// PNG는 quality 파라미터 무시</span>
<span class="nx">canvas</span><span class="p">.</span><span class="nx">toBlob</span><span class="p">(</span><span class="nx">resolve</span><span class="p">,</span> <span class="dl">'</span><span class="s1">image/png</span><span class="dl">'</span><span class="p">,</span> <span class="mf">0.3</span><span class="p">);</span>  <span class="c1">// ❌ 해상도만 반영됨</span>
</code></pre></div></div>

<p>그러니까 1단계에서 quality를 0.85부터 0.35까지 6번 시도했지만, PNG는 blob 크기가 그대로였다. 2단계에서 해상도를 한 번 줄이긴 했지만, <code class="language-plaintext highlighter-rouge">sqrt(maxBytes / blob.size)</code> 계산이 정확하지 않아 5.4MB로 살짝 넘겼다.</p>

<hr />

<h2 id="해결">해결</h2>

<p>WebP로 변환하면 간단하겠지만, 확장자를 유지해야 해서 제외했다.</p>

<p>대신 2단계를 while 루프로 바꿔, 5MB 이하가 될 때까지 해상도를 반복 축소하도록 수정했다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 변경 전: 한 번만 시도</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">blob</span> <span class="o">&amp;&amp;</span> <span class="nx">blob</span><span class="p">.</span><span class="nx">size</span> <span class="o">&gt;</span> <span class="nx">maxBytes</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">scale</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">sqrt</span><span class="p">(</span><span class="nx">maxBytes</span> <span class="o">/</span> <span class="nx">blob</span><span class="p">.</span><span class="nx">size</span><span class="p">);</span>
    <span class="c1">// ...한 번 줄이고 끝</span>
<span class="p">}</span>

<span class="c1">// 변경 후: 5MB 이하가 될 때까지 반복</span>
<span class="k">while</span> <span class="p">(</span><span class="nx">blob</span> <span class="o">&amp;&amp;</span> <span class="nx">blob</span><span class="p">.</span><span class="nx">size</span> <span class="o">&gt;</span> <span class="nx">maxBytes</span> <span class="o">&amp;&amp;</span> <span class="nx">width</span> <span class="o">&gt;</span> <span class="mi">1</span> <span class="o">&amp;&amp;</span> <span class="nx">height</span> <span class="o">&gt;</span> <span class="mi">1</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">scale</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">sqrt</span><span class="p">(</span><span class="nx">maxBytes</span> <span class="o">/</span> <span class="nx">blob</span><span class="p">.</span><span class="nx">size</span><span class="p">)</span> <span class="o">*</span> <span class="mf">0.9</span><span class="p">;</span> <span class="c1">// 10% 여유</span>
    <span class="nx">width</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">max</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">width</span> <span class="o">*</span> <span class="nx">scale</span><span class="p">));</span>
    <span class="nx">height</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">max</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">height</span> <span class="o">*</span> <span class="nx">scale</span><span class="p">));</span>
    <span class="kd">const</span> <span class="nx">canvas</span> <span class="o">=</span> <span class="nx">drawToCanvas</span><span class="p">(</span><span class="nx">bitmap</span><span class="p">,</span> <span class="nx">width</span><span class="p">,</span> <span class="nx">height</span><span class="p">);</span>
    <span class="nx">blob</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">toBlob</span><span class="p">(</span><span class="nx">canvas</span><span class="p">,</span> <span class="nx">file</span><span class="p">.</span><span class="nx">type</span><span class="p">,</span> <span class="mf">0.3</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">0.9</code> 여유 계수를 둔 이유가 있다. PNG 압축 결과는 픽셀 내용에 따라 예측하기 어렵다. 계산대로 줄였는데도 목표를 살짝 넘기는 경우가 있어서, 매 반복마다 약간 더 공격적으로 줄이도록 했다. 32MB PNG 기준으로 보통 1~2회 반복으로 해결된다.</p>

<hr />

<h2 id="정리">정리</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌──────┬──────────────┬──────────────────────────────┐
│ 포맷 │ quality 효과 │       크기 줄이는 방법       │
├──────┼──────────────┼──────────────────────────────┤
│ JPEG │ ✅ 있음      │ quality 낮추기 + 해상도 축소 │
├──────┼──────────────┼──────────────────────────────┤
│ WebP │ ✅ 있음      │ quality 낮추기 + 해상도 축소 │
├──────┼──────────────┼──────────────────────────────┤
│ PNG  │ ❌ 없음      │ 해상도 축소만 가능           │
└──────┴──────────────┴──────────────────────────────┘
</code></pre></div></div>

<p>브라우저 Canvas API로 PNG를 압축할 때, quality 파라미터는 아무런 의미가 없다.</p>

<p>크기를 줄이려면 해상도를 줄이는 것만이 방법이고, 목표 크기를 보장하려면 반복 루프가 필요하다.</p>]]></content><author><name>HyunPang</name></author><summary type="html"><![CDATA[배경]]></summary></entry><entry><title type="html">== 는 왜 위험할까 (그리고 &amp;lt;=도 조심해야 하는 이유)</title><link href="https://hyun-git.github.io//%EB%8A%94-%EC%99%9C-%EC%9C%84%ED%97%98%ED%95%A0%EA%B9%8C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%8F%84-%EC%A1%B0%EC%8B%AC%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0.html" rel="alternate" type="text/html" title="== 는 왜 위험할까 (그리고 &amp;lt;=도 조심해야 하는 이유)" /><published>2026-04-09T02:49:00+00:00</published><updated>2026-04-09T02:49:00+00:00</updated><id>https://hyun-git.github.io//%EB%8A%94-%EC%99%9C-%EC%9C%84%ED%97%98%ED%95%A0%EA%B9%8C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%8F%84-%EC%A1%B0%EC%8B%AC%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</id><content type="html" xml:base="https://hyun-git.github.io//%EB%8A%94-%EC%99%9C-%EC%9C%84%ED%97%98%ED%95%A0%EA%B9%8C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%8F%84-%EC%A1%B0%EC%8B%AC%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0.html"><![CDATA[<p>자바스크립트를 처음 배울 때
<code class="language-plaintext highlighter-rouge">==</code> 말고 <code class="language-plaintext highlighter-rouge">===</code> 쓰라는 얘기를 정말 많이 듣는다.</p>

<p>그때는 그냥 “타입까지 비교하니까 더 정확하겠지” 정도로 넘겼다.
그런데 실제로 코드를 읽다 보면, 이게 단순한 취향 문제가 아니라는 걸 알게 된다.</p>

<p>핵심은 하나다.</p>

<blockquote>
  <p><strong>자바스크립트는 필요하면 타입을 바꿔버린다.</strong></p>
</blockquote>

<p>이걸 “타입 강제 변환(Type Coercion)”이라고 부른다. ([MDN Web Docs][1])</p>

<hr />

<h2 id="1--는-값을-비교하기-전에-타입을-바꾼다">1. <code class="language-plaintext highlighter-rouge">==</code> 는 값을 비교하기 전에 타입을 바꾼다</h2>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="mi">1</span> <span class="o">==</span> <span class="dl">"</span><span class="s2">1</span><span class="dl">"</span> <span class="c1">// true</span>
</code></pre></div></div>

<p>이건 많은 사람들이 알고 있다.
문자열 <code class="language-plaintext highlighter-rouge">"1"</code>이 숫자 <code class="language-plaintext highlighter-rouge">1</code>로 변환된 다음 비교되기 때문이다.</p>

<p>자바스크립트는 타입이 다르면
“어떻게든 비교를 성공시키기 위해” 값을 변환한다. ([Stack Overflow][2])</p>

<p>조금 더 보면 이상해진다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="mi">0</span> <span class="o">==</span> <span class="kc">false</span>   <span class="c1">// true</span>
<span class="dl">""</span> <span class="o">==</span> <span class="kc">false</span>  <span class="c1">// true</span>
<span class="kc">null</span> <span class="o">==</span> <span class="kc">undefined</span> <span class="c1">// true</span>
</code></pre></div></div>

<p>여기서 중요한 건
<strong>비교하기 전에 무슨 일이 일어나는지 사람이 직관적으로 이해하기 어렵다는 것</strong>이다.</p>

<hr />

<h2 id="2-그래서--는-타입-변환을-하지-않는다">2. 그래서 <code class="language-plaintext highlighter-rouge">===</code> 는 타입 변환을 하지 않는다</h2>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="mi">1</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">1</span><span class="dl">"</span> <span class="c1">// false</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">===</code>는 단순하다.</p>

<ul>
  <li>타입이 다르면 → 무조건 false</li>
  <li>타입이 같으면 → 값 비교</li>
</ul>

<p>이건 사람이 읽는 방식이랑 거의 동일하다.</p>

<p>그래서 결론은 보통 이렇게 정리된다.</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">==</code> 는 “추측해서 맞추는 비교”
<code class="language-plaintext highlighter-rouge">===</code> 는 “있는 그대로 비교”</p>
</blockquote>

<hr />

<h2 id="3-그런데--도-타입을-바꾼다">3. 그런데 <code class="language-plaintext highlighter-rouge">&lt;=</code> 도 타입을 바꾼다</h2>

<p>여기서 많은 사람들이 놓치는 포인트가 하나 있다.</p>

<p><code class="language-plaintext highlighter-rouge">==</code>만 문제인 게 아니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="dl">"</span><span class="s2">10</span><span class="dl">"</span> <span class="o">&lt;=</span> <span class="mi">2</span>  <span class="c1">// false</span>
<span class="dl">"</span><span class="s2">2</span><span class="dl">"</span> <span class="o">&lt;=</span> <span class="mi">10</span>  <span class="c1">// true</span>
</code></pre></div></div>

<p>이건 왜 이런 결과가 나올까?</p>

<p>자바스크립트는 <code class="language-plaintext highlighter-rouge">&lt;</code>, <code class="language-plaintext highlighter-rouge">&lt;=</code>, <code class="language-plaintext highlighter-rouge">&gt;</code>, <code class="language-plaintext highlighter-rouge">&gt;=</code> 같은 비교 연산을 할 때
<strong>숫자 비교를 하기 위해 타입을 숫자로 바꿔버린다.</strong></p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="dl">"</span><span class="s2">2</span><span class="dl">"</span> <span class="o">&lt;=</span> <span class="mi">10</span>
<span class="c1">// -&gt; 2 &lt;= 10</span>
<span class="c1">// -&gt; true</span>
</code></pre></div></div>

<p>즉, 이 연산도 내부적으로는 coercion이 일어난다.</p>

<p>문제는 여기서 끝이 아니다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="dl">""</span> <span class="o">&lt;=</span> <span class="mi">0</span>  <span class="c1">// true</span>
</code></pre></div></div>

<p>왜냐면</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">Number</span><span class="p">(</span><span class="dl">""</span><span class="p">)</span> <span class="o">===</span> <span class="mi">0</span>
</code></pre></div></div>

<p>이기 때문이다.</p>

<p>이쯤 되면 감이 온다.</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">==</code>만 위험한 게 아니라
<strong>“암묵적으로 타입을 바꾸는 모든 연산자”가 위험하다</strong></p>
</blockquote>

<hr />

<h2 id="4-왜-이런-설계가-되었을까">4. 왜 이런 설계가 되었을까</h2>

<p>자바스크립트는 “유연함”을 목표로 만들어진 언어다.</p>

<ul>
  <li>타입이 달라도 알아서 맞춰주고</li>
  <li>에러 대신 최대한 동작하게 만든다</li>
</ul>

<p>그래서 이런 일이 가능하다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="dl">"</span><span class="s2">5</span><span class="dl">"</span> <span class="o">-</span> <span class="mi">2</span>  <span class="c1">// 3</span>
<span class="dl">"</span><span class="s2">5</span><span class="dl">"</span> <span class="o">+</span> <span class="mi">2</span>  <span class="c1">// "52"</span>
</code></pre></div></div>

<p>같은 연산자라도 상황에 따라 다르게 동작한다.</p>

<p>이게 편할 때도 있지만
예측이 어려워지는 순간부터는 버그가 된다. ([GeeksforGeeks][3])</p>

<hr />

<h2 id="5-그래서-어떻게-써야-할까">5. 그래서 어떻게 써야 할까</h2>

<p>실무에서는 거의 이렇게 정리된다.</p>

<h3 id="1-비교는-무조건-">1) 비교는 무조건 <code class="language-plaintext highlighter-rouge">===</code></h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="nx">value</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</code></pre></div></div>

<h3 id="2-비교-전에-타입을-명시적으로-맞춘다">2) 비교 전에 타입을 명시적으로 맞춘다</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">Number</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="mi">10</span>
</code></pre></div></div>

<h3 id="3-자동-변환에-기대지-않는다">3) “자동 변환”에 기대지 않는다</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ❌</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">value</span> <span class="o">==</span> <span class="kc">false</span><span class="p">)</span>

<span class="c1">// ✅</span>
<span class="k">if</span> <span class="p">(</span><span class="nb">Boolean</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span> <span class="o">===</span> <span class="kc">false</span><span class="p">)</span>
</code></pre></div></div>

<hr />

<h2 id="마무리">마무리</h2>

<p>정리하면 이렇다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">==</code> → 비교 전에 타입을 바꾼다</li>
  <li><code class="language-plaintext highlighter-rouge">===</code> → 타입까지 포함해서 비교한다</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;=</code>, <code class="language-plaintext highlighter-rouge">&lt;</code>, <code class="language-plaintext highlighter-rouge">&gt;</code> → 숫자 비교를 위해 타입을 바꾼다</li>
</ul>

<p>그리고 가장 중요한 한 줄</p>

<blockquote>
  <p>자바스크립트는 생각보다 “많이” 타입을 바꾼다</p>
</blockquote>

<p>이걸 모르면 버그를 만들고
이걸 알면 버그를 줄일 수 있다.</p>]]></content><author><name>HyunPang</name></author><summary type="html"><![CDATA[자바스크립트를 처음 배울 때 == 말고 === 쓰라는 얘기를 정말 많이 듣는다.]]></summary></entry><entry><title type="html">상위 호환성 vs 하위 호환성</title><link href="https://hyun-git.github.io//compatibility.html" rel="alternate" type="text/html" title="상위 호환성 vs 하위 호환성" /><published>2026-02-23T00:00:00+00:00</published><updated>2026-02-23T00:00:00+00:00</updated><id>https://hyun-git.github.io//compatibility</id><content type="html" xml:base="https://hyun-git.github.io//compatibility.html"><![CDATA[<h1 id="상위-호환성-vs-하위-호환성">상위 호환성 vs 하위 호환성</h1>

<p>자바스크립트는 매년 스펙이 추가된다.
하지만 모든 브라우저가 최신 스펙을 바로 지원하지는 않는다.</p>

<p>그래서 항상 등장하는 고민이 있다.</p>

<blockquote>
  <p>“최신 문법을 써도 괜찮을까?”</p>
</blockquote>

<p>이 질문을 이해하려면 먼저 <strong>상위 호환성</strong>과 <strong>하위 호환성</strong>을 알아야 한다.</p>

<hr />

<h2 id="1-상위-호환성-forward-compatibility">1. 상위 호환성 (Forward Compatibility)</h2>

<p>상위 호환성은 <strong>예전 환경에서 작성된 코드가 새로운 환경에서도 동작하는 것</strong>을 의미한다.
자바스크립트는 기본적으로 상위 호환성을 지키려고 설계되었다.
예를 들어 ES5 문법으로 작성한 코드는 최신 브라우저에서도 문제없이 동작한다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">add</span><span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">a</span> <span class="o">+</span> <span class="nx">b</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이 코드는 10년 전에도, 지금도 동일하게 동작한다.
즉, <strong>과거 코드는 미래에서도 살아남는다.</strong></p>

<hr />

<h2 id="2-하위-호환성-backward-compatibility">2. 하위 호환성 (Backward Compatibility)</h2>

<p>하위 호환성은 <strong>새로운 문법이 예전 환경에서도 동작하는 것</strong>을 의미한다.
하지만 이건 자동으로 보장되지 않는다.</p>

<p>예를 들어 ES6의 <code class="language-plaintext highlighter-rouge">let</code> 문법은 구형 브라우저(예: 구형 Internet Explorer)에서는 동작하지 않는다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">let</span> <span class="nx">name</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">hyun</span><span class="dl">"</span><span class="p">;</span>
</code></pre></div></div>

<p>구형 브라우저에서는 문법 자체를 이해하지 못한다.
그래서 등장한 개념이 바로:</p>

<ul>
  <li>트랜스파일</li>
  <li>폴리필</li>
</ul>

<hr />

<h1 id="1-트랜스파일-transpile">1. 트랜스파일 (Transpile)</h1>

<p>트랜스파일은 <strong>새로운 문법을 이전 문법으로 변환하는 것</strong>이다.</p>

<p>대표적인 도구는 <code class="language-plaintext highlighter-rouge">Babel</code>이다.
예를 들어 이런 코드가 있다고 해보자.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">add</span> <span class="o">=</span> <span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">a</span> <span class="o">+</span> <span class="nx">b</span><span class="p">;</span>
</code></pre></div></div>

<p>이 코드는 ES6 문법이다. 이를 ES5로 변환하면 다음과 같이 바뀐다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">var</span> <span class="nx">add</span> <span class="o">=</span> <span class="kd">function</span><span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">a</span> <span class="o">+</span> <span class="nx">b</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div></div>

<p>화살표 함수가 일반 함수로 변환되었다.</p>

<p>즉,</p>

<blockquote>
  <p>문법을 “번역”해서 구형 환경에서도 실행 가능하게 만드는 것</p>
</blockquote>

<p>이게 트랜스파일이다.</p>

<hr />

<h3 id="언제-필요한가">언제 필요한가?</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">let</code>, <code class="language-plaintext highlighter-rouge">const</code></li>
  <li>화살표 함수</li>
  <li>클래스</li>
  <li>구조 분해 할당</li>
  <li>optional chaining</li>
</ul>

<p>이런 <strong>문법 레벨 기능</strong>을 사용할 때 필요하다.</p>

<hr />

<h1 id="2-폴리필-polyfill">2. 폴리필 (Polyfill)</h1>

<p>폴리필은 <strong>환경에 없는 기능을 대신 구현해주는 코드</strong>이다.
문법이 아니라 <strong>기능(메서드, API)</strong> 문제다.</p>

<p>예를 들어 <code class="language-plaintext highlighter-rouge">Array.prototype.includes</code>는 ES6에서 추가되었다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">arr</span> <span class="o">=</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">];</span>
<span class="nx">arr</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="mi">2</span><span class="p">);</span> <span class="c1">// true</span>
</code></pre></div></div>

<p>구형 브라우저에는 <code class="language-plaintext highlighter-rouge">includes</code>가 없다.</p>

<p>그래서 이렇게 직접 구현해줄 수 있다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">Array</span><span class="p">.</span><span class="nx">prototype</span><span class="p">.</span><span class="nx">includes</span><span class="p">)</span> <span class="p">{</span>
  <span class="nb">Array</span><span class="p">.</span><span class="nx">prototype</span><span class="p">.</span><span class="nx">includes</span> <span class="o">=</span> <span class="kd">function</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">indexOf</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span> <span class="o">!==</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이 코드가 바로 폴리필이다.</p>

<blockquote>
  <p>없는 기능을 채워 넣는 것</p>
</blockquote>

<hr />

<h3 id="트랜스파일-vs-폴리필-차이-정리">트랜스파일 vs 폴리필 차이 정리</h3>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>대상</th>
      <th>해결 방식</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>트랜스파일</td>
      <td>문법</td>
      <td>코드를 변환</td>
    </tr>
    <tr>
      <td>폴리필</td>
      <td>기능(API)</td>
      <td>기능을 구현</td>
    </tr>
  </tbody>
</table>

<p>예시로 보면 이해가 더 쉽다.</p>

<h3 id="-트랜스파일로-해결-안-되는-경우">❌ 트랜스파일로 해결 안 되는 경우</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">Promise</span><span class="p">.</span><span class="nx">resolve</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>
</code></pre></div></div>

<p>구형 브라우저에는 <code class="language-plaintext highlighter-rouge">Promise</code> 자체가 없다.</p>

<p>이건 문법 문제가 아니라 기능 문제다.</p>

<p>→ 폴리필이 필요하다.</p>

<hr />

<h1 id="정리">정리</h1>

<p>자바스크립트는 상위 호환성은 잘 지키지만, 하위 호환성은 자동으로 해결되지 않는다.</p>

<p>그래서 우리는 두 가지 도구를 사용한다.</p>

<ul>
  <li>문법 문제 → 트랜스파일</li>
  <li>기능 문제 → 폴리필</li>
</ul>

<p>요즘은 대부분 <code class="language-plaintext highlighter-rouge">Babel + core-js</code> 조합을 사용한다.</p>

<p>결국 핵심은 이것이다.</p>

<blockquote>
  <p>최신 문법을 쓰고 싶다면,
실행 환경을 반드시 고려해야 한다.</p>
</blockquote>]]></content><author><name>HyunPang</name></author><summary type="html"><![CDATA[상위 호환성 vs 하위 호환성]]></summary></entry><entry><title type="html">map + filter vs flatMap</title><link href="https://hyun-git.github.io//flat-map.html" rel="alternate" type="text/html" title="map + filter vs flatMap" /><published>2026-02-15T00:00:00+00:00</published><updated>2026-02-15T00:00:00+00:00</updated><id>https://hyun-git.github.io//flat-map</id><content type="html" xml:base="https://hyun-git.github.io//flat-map.html"><![CDATA[<h1 id="map--filter-대신-flatmap을-선택한-이유"><code class="language-plaintext highlighter-rouge">map + filter</code> 대신 <code class="language-plaintext highlighter-rouge">flatMap</code>을 선택한 이유</h1>

<p>실무에서 <code class="language-plaintext highlighter-rouge">Promise.allSettled</code> 결과를 다루다 보면 실패한 케이스만 추려내야 하는 순간이 꽤 자주 나온다.</p>

<p>예를 들면 이런 상황이다.</p>

<ul>
  <li>여러 API를 병렬 호출한다.</li>
  <li>일부는 성공하고 일부는 실패한다.</li>
  <li>실패한 대상만 모아서 후처리하거나 로그를 남겨야 한다.</li>
</ul>

<p>이때 자연스럽게 이런 코드를 작성하게 된다.</p>

<hr />

<h2 id="처음에-썼던-코드--map--filter">처음에 썼던 코드 — <code class="language-plaintext highlighter-rouge">map + filter</code></h2>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">failedTargets</span> <span class="o">=</span> <span class="nx">results</span>
  <span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">result</span><span class="p">,</span> <span class="nx">index</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">result</span><span class="p">.</span><span class="nx">status</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">rejected</span><span class="dl">'</span>
      <span class="p">?</span> <span class="p">{</span>
          <span class="na">error</span><span class="p">:</span> <span class="nx">result</span><span class="p">.</span><span class="nx">reason</span><span class="p">,</span>
          <span class="na">target</span><span class="p">:</span> <span class="nx">targetList</span><span class="p">[</span><span class="nx">index</span><span class="p">],</span>
        <span class="p">}</span>
      <span class="p">:</span> <span class="kc">undefined</span><span class="p">;</span>
  <span class="p">})</span>
  <span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nb">Boolean</span><span class="p">);</span>
</code></pre></div></div>

<p>굉장히 흔한 패턴이다.</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">map</code>으로 변환하고</li>
  <li>필요 없는 값은 <code class="language-plaintext highlighter-rouge">undefined</code>로 만든 뒤</li>
  <li><code class="language-plaintext highlighter-rouge">filter(Boolean)</code>으로 제거한다.</li>
</ol>

<p>처음엔 별 생각 없이 썼다. JS에서 워낙 자주 보던 패턴이니까.</p>

<hr />

<h2 id="그런데-typescript에서-뭔가-찜찜하다">그런데 TypeScript에서 뭔가 찜찜하다</h2>

<p>이 코드가 TS strict 모드에 들어가면 미묘한 문제가 생긴다.</p>

<p><code class="language-plaintext highlighter-rouge">filter(Boolean)</code>은 런타임 동작일 뿐이다.
타입 시스템은 이걸 완전하게 이해하지 못한다.</p>

<p>결과 타입은 여전히 이렇게 나온다:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="kr">any</span><span class="p">;</span> <span class="nl">target</span><span class="p">:</span> <span class="kr">string</span> <span class="p">}</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">)[]</span>
</code></pre></div></div>

<p>즉, <code class="language-plaintext highlighter-rouge">undefined</code>가 남아있다고 판단한다.</p>

<p>그래서 결국 이렇게 타입 가드를 추가하게 된다.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="nx">filter</span><span class="p">(</span>
  <span class="p">(</span><span class="nx">v</span><span class="p">):</span> <span class="nx">v</span> <span class="k">is</span> <span class="p">{</span> <span class="na">error</span><span class="p">:</span> <span class="kr">any</span><span class="p">;</span> <span class="nl">target</span><span class="p">:</span> <span class="kr">string</span> <span class="p">}</span> <span class="o">=&gt;</span> <span class="nx">v</span> <span class="o">!==</span> <span class="kc">undefined</span><span class="p">,</span>
<span class="p">);</span>
</code></pre></div></div>

<p>여기서부터 조금 이상해진다.</p>

<ul>
  <li>애초에 <code class="language-plaintext highlighter-rouge">undefined</code>를 만들지 않으면 되지 않나?</li>
  <li>왜 일부러 만들고 다시 제거하고 있지?</li>
</ul>

<p>코드가 살짝 “절차적으로” 느껴지기 시작한다.</p>

<hr />

<h1 id="그래서-flatmap으로-바꿨다">그래서 <code class="language-plaintext highlighter-rouge">flatMap</code>으로 바꿨다</h1>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">failedTargets</span> <span class="o">=</span> <span class="nx">results</span><span class="p">.</span><span class="nx">flatMap</span><span class="p">((</span><span class="nx">result</span><span class="p">,</span> <span class="nx">index</span><span class="p">)</span> <span class="o">=&gt;</span>
  <span class="nx">result</span><span class="p">.</span><span class="nx">status</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">rejected</span><span class="dl">'</span>
    <span class="p">?</span> <span class="p">[{</span> <span class="na">error</span><span class="p">:</span> <span class="nx">result</span><span class="p">.</span><span class="nx">reason</span><span class="p">,</span> <span class="na">target</span><span class="p">:</span> <span class="nx">targetList</span><span class="p">[</span><span class="nx">index</span><span class="p">]</span> <span class="p">}]</span>
    <span class="p">:</span> <span class="p">[],</span>
<span class="p">);</span>
</code></pre></div></div>

<p>이 코드를 보고 처음 든 생각은 단순했다.</p>

<blockquote>
  <p>“실패한 것만 결과 배열에 넣는다.”</p>
</blockquote>

<p>딱 이 문장이 그대로 코드가 된다.</p>

<hr />

<h2 id="무엇이-달라졌을까">무엇이 달라졌을까?</h2>

<h3 id="1️⃣-순회가-한-번이다">1️⃣ 순회가 한 번이다</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">map + filter</code> → 2번 순회</li>
  <li><code class="language-plaintext highlighter-rouge">flatMap</code> → 1번 순회</li>
</ul>

<p>물론 이 차이는 대부분의 경우 체감 불가다.
여기서 병목이 생길 일은 거의 없다.</p>

<p>하지만 불필요한 중간 상태가 사라진다.</p>

<hr />

<h3 id="2️⃣-undefined가-존재하지-않는다">2️⃣ <code class="language-plaintext highlighter-rouge">undefined</code>가 존재하지 않는다</h3>

<p>이게 가장 중요했다.</p>

<p><code class="language-plaintext highlighter-rouge">map + filter</code>는 이런 사고 흐름이다:</p>

<blockquote>
  <p>일단 다 변환 → 필요 없는 건 undefined → 나중에 제거</p>
</blockquote>

<p><code class="language-plaintext highlighter-rouge">flatMap</code>은 이렇게 생각한다:</p>

<blockquote>
  <p>필요한 것만 결과로 만든다</p>
</blockquote>

<p>중간 쓰레기 값이 없다.</p>

<hr />

<h3 id="3️⃣-typescript-타입이-깔끔해진다">3️⃣ TypeScript 타입이 깔끔해진다</h3>

<p><code class="language-plaintext highlighter-rouge">flatMap</code>의 결과 타입은 정확하게:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">error</span><span class="p">:</span> <span class="kr">any</span><span class="p">;</span> <span class="nl">target</span><span class="p">:</span> <span class="kr">string</span> <span class="p">}[]</span>
</code></pre></div></div>

<p>추가 타입 가드가 필요 없다.
strict 모드에서도 아무 경고가 없다.</p>

<p>리팩터링할 때도 더 안전하다.</p>

<hr />

<h1 id="성능은-솔직히-중요하지-않다">성능은 솔직히 중요하지 않다</h1>

<p>이 코드는 대부분 이런 맥락에서 사용된다.</p>

<ul>
  <li>API 병렬 호출</li>
  <li>DB 작업</li>
  <li>네트워크 I/O</li>
</ul>

<p>실제 비용은 Promise 내부 작업에 있기에 배열 한 번 더 도는 건 거의 의미 없다.
그래서 선택 기준은 성능이 아니다.</p>

<hr />

<h1 id="결국-기준은-이것이다">결국 기준은 이것이다</h1>

<h3 id="-의도가-코드에-얼마나-잘-드러나는가">✔ 의도가 코드에 얼마나 잘 드러나는가</h3>

<h3 id="-타입-시스템과-잘-맞는가">✔ 타입 시스템과 잘 맞는가</h3>

<p><code class="language-plaintext highlighter-rouge">flatMap</code>은 불필요한 상태를 만들지 않아 타입 시스템과 자연스럽게 어울린다.</p>

<hr />

<h1 id="언제-어떤-걸-써야-할까">언제 어떤 걸 써야 할까?</h1>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th>추천</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>단순 변환</td>
      <td><code class="language-plaintext highlighter-rouge">map</code></td>
    </tr>
    <tr>
      <td>필터링만 필요</td>
      <td><code class="language-plaintext highlighter-rouge">filter</code></td>
    </tr>
    <tr>
      <td>조건에 맞는 것만 결과로 생성</td>
      <td><code class="language-plaintext highlighter-rouge">flatMap</code></td>
    </tr>
    <tr>
      <td>TS strict 실무 코드</td>
      <td><code class="language-plaintext highlighter-rouge">flatMap</code> 쪽이 더 안정적</td>
    </tr>
  </tbody>
</table>

<hr />

<h1 id="정리">정리</h1>

<p>JavaScript라면 둘 다 괜찮다.
성능 차이도 사실상 무시 가능하다.</p>

<p>하지만 TypeScript 실무 코드라면</p>

<blockquote>
  <p>“필요한 것만 결과로 만든다”</p>
</blockquote>

<p>이 사고 모델을 그대로 표현하는 <code class="language-plaintext highlighter-rouge">flatMap</code>이 더 깔끔하고, 더 안전하고, 더 읽기 좋다.</p>]]></content><author><name>HyunPang</name></author><summary type="html"><![CDATA[map + filter 대신 flatMap을 선택한 이유]]></summary></entry><entry><title type="html">MongoDB 인덱스 정리하다가 알게 된 복합 인덱스와 prefix rule</title><link href="https://hyun-git.github.io//mongo-index.html" rel="alternate" type="text/html" title="MongoDB 인덱스 정리하다가 알게 된 복합 인덱스와 prefix rule" /><published>2026-02-09T00:00:00+00:00</published><updated>2026-02-09T00:00:00+00:00</updated><id>https://hyun-git.github.io//mongo-index</id><content type="html" xml:base="https://hyun-git.github.io//mongo-index.html"><![CDATA[<p>최근 MongoDB 인덱스를 정리하면서 생각보다 많은 인덱스가 <strong>의미 없이 중복</strong>되어 있다는 걸 알게 됐다.</p>

<p>문제의 시작은 대부분 같았다. 복합 인덱스를 만들어두고,  “혹시 몰라서” single 인덱스를 같이 만들어 둔 상태였다.</p>

<hr />

<h3 id="내가-마주했던-인덱스-구조">내가 마주했던 인덱스 구조</h3>

<p>실제로 이런 인덱스들이 있었다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">userId</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span>
<span class="p">{</span> <span class="na">userId</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="na">isActive</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span>
<span class="p">{</span> <span class="na">userId</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">inviteeUsers</span><span class="p">.</span><span class="na">userId</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">inviteeUsers</span><span class="p">.</span><span class="na">firstPurchase</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span>
</code></pre></div></div>

<p>겉으로 보면 문제 없어 보이지만, 여기서 핵심은 <strong>복합 인덱스가 이미 있다는 점</strong>이다.</p>

<hr />

<h3 id="복합-인덱스는-어떻게-동작할까">복합 인덱스는 어떻게 동작할까?</h3>

<p>MongoDB의 복합 인덱스는 <strong>왼쪽 필드부터 순서대로 조건이 맞아야 사용</strong>된다.</p>

<p>예를 들어 인덱스가 아래와 같다면</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">userId</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">isActive</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span>
</code></pre></div></div>

<p>MongoDB는 이 인덱스를 다음 두 가지 형태로 사용할 수 있다.</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">{ userId }</code></li>
  <li><code class="language-plaintext highlighter-rouge">{ userId, isActive }</code></li>
</ol>

<p>이걸 MongoDB에서는 <strong>prefix rule</strong>이라고 부른다.</p>

<hr />

<h3 id="prefix-rule-때문에-생기는-착각">prefix rule 때문에 생기는 착각</h3>

<p>이 규칙 때문에 아래 쿼리는</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">userId</span><span class="p">:</span> <span class="dl">"</span><span class="s2">u1</span><span class="dl">"</span> <span class="p">}</span>
</code></pre></div></div>

<p>이미</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">userId</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">isActive</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span>
</code></pre></div></div>

<p>이 복합 인덱스로 처리된다.</p>

<p>즉,</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">userId</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span>
</code></pre></div></div>

<p>이라는 single 인덱스는 <strong>이미 역할이 겹친 상태</strong>가 된다.</p>

<p>이걸 모르고 있으면 “userId로 자주 조회하니까 인덱스 하나 더 있어야겠지”라는 판단을 하게 된다.</p>

<p>결과는 중복 인덱스다.</p>

<hr />

<h3 id="prefix-rule이-안-먹히는-경우">prefix rule이 안 먹히는 경우</h3>

<p>prefix rule은 <strong>왼쪽부터</strong>라는 조건이 매우 중요하다.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">userId</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">isActive</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span>
</code></pre></div></div>

<p>이 인덱스가 있을 때</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">isActive</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span>
</code></pre></div></div>

<p>이 쿼리는 위의 인덱스를 탈 수 없다.</p>

<p>또한</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">isActive</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">userId</span><span class="p">:</span> <span class="dl">"</span><span class="s2">u1</span><span class="dl">"</span> <span class="p">}</span>
</code></pre></div></div>

<p>처럼 조건이 있어도 <strong>인덱스의 첫 필드가 빠지면 사용 불가</strong>다.</p>

<p>쿼리의 작성 순서가 아니라 <strong>인덱스 정의 순서</strong>가 기준이다.</p>

<hr />

<h3 id="그래서-single-인덱스는-언제-필요할까">그래서 single 인덱스는 언제 필요할까?</h3>

<p>복합인덱스에 설정이 되어있는 상태에서 single인덱스가 또 필요한 경우는 아래와 같다.</p>

<ul>
  <li>복합 인덱스가 아예 없을 때</li>
  <li>복합 인덱스의 첫 필드가 아닐 때</li>
</ul>

<p>그 외에는 대부분 복합 인덱스의 prefix로 충분히 커버된다.</p>

<hr />

<h3 id="index-true가-만든-중복-인덱스">index: true가 만든 중복 인덱스</h3>

<p>이번 정리에서 가장 아쉬웠던 부분은 이거였다.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">@</span><span class="nd">Prop</span><span class="p">({</span> <span class="na">index</span><span class="p">:</span> <span class="kc">true</span> <span class="p">})</span>
<span class="nx">userId</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
</code></pre></div></div>

<p>이 한 줄 때문에 MongoDB는 자동으로</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">userId</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span>
</code></pre></div></div>

<p>single 인덱스를 생성한다.</p>

<p>이 상태에서 schema-level로 복합 인덱스를 추가하면 의도하지 않은 중복 인덱스가 만들어진다.</p>

<hr />

<h3 id="unique-인덱스는-예외">unique 인덱스는 예외</h3>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">code</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">unique</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span>
</code></pre></div></div>

<p>unique 인덱스는 조회 성능을 위한 최적화라기보다, 데이터 무결성을 강제하기 위한 제약이다.</p>

<p>MongoDB에서 unique 제약은 인덱스를 통해서만 보장되기 때문에, 조회 패턴과 상관없이 유지하는 것이 기본이다.</p>

<p>또한 unique는 single 인덱스뿐 아니라 복합 인덱스에도 적용할 수 있다.</p>

<p>예를 들어 아래 인덱스는</p>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nl">userId</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">provider</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">unique</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span>
</code></pre></div></div>

<p>userId와 provider의 조합이 중복되지 않도록 보장한다.</p>

<p>즉, unique 인덱스는 “쿼리에 쓰이냐”로 판단하기보다 도메인 제약(중복 허용 여부) 관점에서 유지 여부를 결정해야 한다.</p>

<hr />

<h3 id="한-줄-결론">한 줄 결론</h3>

<blockquote>
  <p>복합 인덱스는 생각보다 많은 쿼리를 처리한다.
prefix rule을 이해하면 불필요한 인덱스가 보이기 시작한다.</p>
</blockquote>]]></content><author><name>HyunPang</name></author><summary type="html"><![CDATA[최근 MongoDB 인덱스를 정리하면서 생각보다 많은 인덱스가 의미 없이 중복되어 있다는 걸 알게 됐다.]]></summary></entry><entry><title type="html">Rate Limit과 Fail Limit은 어디까지가 적절할까?</title><link href="https://hyun-git.github.io//api-limit.html" rel="alternate" type="text/html" title="Rate Limit과 Fail Limit은 어디까지가 적절할까?" /><published>2026-02-01T00:00:00+00:00</published><updated>2026-02-01T00:00:00+00:00</updated><id>https://hyun-git.github.io//api-limit</id><content type="html" xml:base="https://hyun-git.github.io//api-limit.html"><![CDATA[<p>최근 <strong>account 서비스에 친구 초대 코드 인증 API</strong>가 추가되면서, 해당 API에 대한 <strong>rate limit / fail limit 기준</strong>을 어떻게 가져가야 할지 고민을 했다.</p>

<p>초대 코드 인증은 회원가입 단계에서 호출되며, 특성상 <strong>외부 공격(브루트포스, 봇 트래픽)</strong>에 가장 먼저 노출되는 지점이기에 더 관심 있게 봤어야 했다.
이번 글에서는 실제 논의 과정과 로그 분석을 바탕으로, 어떤 기준으로 제한 정책을 잡았는지 정리해보려 한다.</p>

<hr />

<h2 id="왜-제한이-필요했을까">왜 제한이 필요했을까?</h2>

<p>이번에 방어하고자 했던 케이스는 크게 두 가지였다.</p>

<ol>
  <li><strong>초대를 받지 않은 사용자가 초대 코드를 브루트포스로 시도하는 경우</strong></li>
  <li><strong>회원이 아닌 사용자가 과도하게 API 요청을 보내는 경우</strong></li>
</ol>

<p>이미 account 서비스 전반에는 <code class="language-plaintext highlighter-rouge">5분당 100회</code> 수준의 rate limit이 설정되어 있었지만, 초대 코드 인증 API는 다음과 같은 특징을 가진다.</p>

<ul>
  <li>인증 이전 단계 (userId 없음)</li>
  <li>성공/실패가 명확한 API</li>
  <li>코드 값이 존재 → 브루트포스 타겟이 되기 쉬움</li>
</ul>

<p>즉, 과도하게 사용될 수 있는 API라고 판단을 했고 이에 대한 대비로 rateLimit, failLimit을 걸고자 했다.</p>

<hr />

<h2 id="처음에-생각했던-구조">처음에 생각했던 구조</h2>

<p>가장 먼저 떠올린 방식은 다음과 같은 <strong>2단 방어 구조</strong>였다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[API Rate Limit]
  ├─ IP 기준 (1분 10회)
        ↓ 통과
[Referral Attempt Guard]
  ├─ 실패 카운트 누적 (Redis)
  ├─ 5회 연속 실패 → 10분 쿨다운
  └─ 성공 시 fail_count = 0
        ↓ 통과
[Referral Code Validation]
</code></pre></div></div>

<ul>
  <li><strong>Edge 단에서 Rate Limit</strong></li>
  <li><strong>App 단에서 Fail Limit</strong></li>
  <li>성공 시에는 실패 카운트 초기화</li>
</ul>

<p>구조 자체는 단순하지만, “과연 이 limit과 duration의 수치가 적절한지”에 대한 확신은 없었다.</p>
<ul>
  <li>너무 강하다면 일반 사용자에게 걸림돌이 될 수 있고</li>
  <li>너무 약하다면 악용하는 사용자가 많아질 것이었다.</li>
</ul>

<p>그래서 내부적으로만의 판단이 아닌 보안 팀의 의견을 받아 수치를 정하기로 하였다.</p>

<hr />

<h2 id="실제-트래픽은-어땠을까">실제 트래픽은 어땠을까?</h2>

<p>앱 로그를 충분히 확보하지 못한 상태라, <strong>Cloudflare 로그를 기준으로 과거 데이터를 확인</strong>해봤다.</p>

<p>특히 <strong>21일 이후 가입자가 급증했던 시점</strong>의 데이터를 살펴보면,</p>

<ul>
  <li>봇으로 보이는 IP들은 <code class="language-plaintext highlighter-rouge">signup</code> 경로를 <strong>1분에 약 5~6회</strong> 정도 반복 호출하고 있었다.</li>
</ul>

<p>유저 플로우상 <code class="language-plaintext highlighter-rouge">signup</code>과 <code class="language-plaintext highlighter-rouge">친구 초대 인증</code>은 호출 빈도가 비슷하다고 가정하면,</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">1분 10회</code> 제한은  <strong>사람 기준으로는 충분히 여유롭고, 봇 기준으로는 애매한 수치</strong>라는 판단이 들었다.</li>
</ul>

<p>또 흥미로웠던 점은 시계열을 늘려서 보면 더 명확했다.</p>

<ul>
  <li><strong>10분 기준</strong> <code class="language-plaintext highlighter-rouge">50회 이상</code> 호출하는 IP는 Cloudflare 기준에서도 거의 봇 트래픽으로 분류된다고 했다.</li>
</ul>

<hr />

<h2 id="추가의견">추가의견</h2>
<p>다른 보안엔지니어 분의 말씀으로는 아래와 같은기준으로 가져가면 좋을 것 같다고 했다.</p>

<h4 id="1-api-rate-limit">1. API Rate Limit</h4>
<ul>
  <li><code class="language-plaintext highlighter-rouge">IP: 1분 10회(유지 가능) + 초당 burst 제한 추가</code></li>
</ul>

<h4 id="2-referral-attempt-guard-app">2. Referral Attempt Guard (App)</h4>
<ul>
  <li>키: ip + device_id 우선, 가능하면 account_id도 추가</li>
  <li>실패 판단: 10분 윈도우 내 실패 5회 → 10분 쿨다운</li>
  <li>성공 처리: fail_count = max(0, fail_count - 1) 또는 TTL 기반 감쇠</li>
  <li>단계형 백오프(가능하면)</li>
</ul>

<h4 id="3-referral-code-validation">3. Referral Code Validation</h4>
<ul>
  <li>외부 응답은 실패 사유 통일</li>
  <li>내부적으로만 실패 사유 로깅/지표화 (편집됨)</li>
</ul>

<hr />

<h2 id="최종-결정">최종 결정</h2>
<p>위와 같은 내용을 토대로, 최종적으로 아래 기준으로 진행하기로 결정했다.</p>

<h3 id="1-rate-limit">1. Rate Limit</h3>

<ul>
  <li><strong>IP 기준</strong></li>
  <li><strong>10분 50회</strong></li>
</ul>

<h3 id="2-fail-limit">2. Fail Limit</h3>

<ul>
  <li>
    <p>키: <code class="language-plaintext highlighter-rouge">ip</code> 또는 <code class="language-plaintext highlighter-rouge">email</code></p>

    <ul>
      <li>(userId 생성 이전 단계이므로)</li>
    </ul>
  </li>
  <li>
    <p>기준:</p>

    <ul>
      <li><strong>10분(마지막 시도 기준) 내 5회 연속 실패</strong> → <strong>10분 쿨다운</strong></li>
    </ul>
  </li>
  <li>
    <p>성공 시:</p>

    <ul>
      <li>실패 카운트 <strong>0으로 초기화</strong></li>
    </ul>
  </li>
</ul>

<h3 id="3-에러-로깅">3. 에러 로깅</h3>

<ul>
  <li>외부 응답: 동일한 에러 메시지 (외부에서 정확한 기준을 알지 못하게 하기 위해)</li>
  <li>내부 로깅: 실패 사유 분리 기록</li>
</ul>

<h3 id="4-burst-limit-검토-중">4. Burst Limit (검토 중)</h3>

<ul>
  <li><strong>초당 2~3회</strong> 현재 앱에서 적용 가능한지 확인 후</li>
</ul>

<hr />

<h2 id="마무리하며">마무리하며</h2>

<p>Rate limit과 fail limit은 단순히 “얼마로 걸까?”의 문제가 아니라,</p>

<ul>
  <li><strong>유저 플로우</strong></li>
  <li><strong>실제 트래픽 패턴</strong></li>
  <li><strong>공격자가 어떤 식으로 시도할지</strong></li>
</ul>

<p>를 함께 고려해야 한다는 점을 다시 한 번 느꼈습니다.</p>

<p>특히 이번처럼 <strong>인증 전 단계 API</strong>에서는 단순한 Rate limit만이 아닌 Fail limit도 함께 추가하여 <strong>역할을 나눠 방어하는 구조</strong>가 꽤 효과적이라는 인상을 받았습니다.</p>]]></content><author><name>HyunPang</name></author><summary type="html"><![CDATA[최근 account 서비스에 친구 초대 코드 인증 API가 추가되면서, 해당 API에 대한 rate limit / fail limit 기준을 어떻게 가져가야 할지 고민을 했다.]]></summary></entry><entry><title type="html">NestJS에서 파라미터 검증 순서 이해하기</title><link href="https://hyun-git.github.io//nestjs-request-lifecycle.html" rel="alternate" type="text/html" title="NestJS에서 파라미터 검증 순서 이해하기" /><published>2026-01-25T00:00:00+00:00</published><updated>2026-01-25T00:00:00+00:00</updated><id>https://hyun-git.github.io//NestJS-request-lifecycle</id><content type="html" xml:base="https://hyun-git.github.io//nestjs-request-lifecycle.html"><![CDATA[<h3 id="global-pipe가-먼저-실행된다는-사실이-만드는-차이">Global Pipe가 먼저 실행된다는 사실이 만드는 차이</h3>

<p>NestJS에서 요청 파라미터를 검증할 때 우리는 보통 <code class="language-plaintext highlighter-rouge">Pipe</code>를 사용한다.
하지만 <strong>Pipe가 어떤 순서로 실행되는지</strong>를 정확히 이해하지 못하면,
불필요한 로직이 실행되거나 성능상 손해를 보는 구조가 될 수 있다.</p>

<p>이번 글에서는 실제 코드 리뷰에서 받은 피드백을 계기로,
<strong>NestJS의 파라미터 검증 순서(Request Lifecycle)</strong>와
<strong>Global Pipe와 Route Param Pipe의 차이</strong>를 정리해본다.</p>

<hr />

<h2 id="문제의-시작-param-pipe에-모든-책임을-맡긴-구조">문제의 시작: Param Pipe에 모든 책임을 맡긴 구조</h2>

<p>초기 구현은 아래와 같았다.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">@</span><span class="nd">Post</span><span class="p">(</span><span class="dl">'</span><span class="s1">:userId</span><span class="dl">'</span><span class="p">)</span>
<span class="k">async</span> <span class="nx">getOrCreateReferralCode</span><span class="p">(</span>
  <span class="p">@</span><span class="nd">Param</span><span class="p">(</span><span class="dl">'</span><span class="s1">userId</span><span class="dl">'</span><span class="p">,</span> <span class="nx">UserExistsPipe</span><span class="p">)</span> <span class="nx">userId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ReferralCodeResponse</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">referralUser</span> <span class="o">=</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">referralService</span><span class="p">.</span><span class="nx">getOrCreateReferralCode</span><span class="p">(</span><span class="nx">userId</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">UserExistsPipe</code>는 다음과 같은 책임을 가지고 있었다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">userId</code>가 유효한 형식인지 확인</li>
  <li>실제로 해당 유저가 존재하는지 DB 조회</li>
</ul>

<p>겉보기에는 문제 없어 보이지만, 코드 리뷰에서 다음과 같은 코멘트를 받았다.</p>

<hr />

<h2 id="코드-리뷰-피드백-핵심">코드 리뷰 피드백 핵심</h2>

<blockquote>
  <p>global pipe가 route params pipe 보다 더 먼저 동작하기 때문에
params를 dto로 만들어 기본적인 것들을 미리 검증하면
global pipe에서 먼저 걸러져서 불필요한 UserExistsPipe 로직 실행을 막을 수 있을 것 같아요.</p>
</blockquote>

<p>이 코멘트의 핵심은 <strong>NestJS의 Pipe 실행 순서</strong>에 있다.</p>

<hr />

<h2 id="nestjs-request-lifecycle과-pipe-실행-순서">NestJS Request Lifecycle과 Pipe 실행 순서</h2>

<p>NestJS에서 요청이 들어오면, 대략 아래 순서로 처리된다.</p>

<ol>
  <li><strong>Middleware</strong></li>
  <li><strong>Guard</strong></li>
  <li><strong>Interceptor (before)</strong></li>
  <li>
    <p><strong>Pipe</strong></p>

    <ul>
      <li>Global Pipe</li>
      <li>Controller</li>
      <li>Route Pipe</li>
      <li>Param Pipe</li>
    </ul>
  </li>
  <li><strong>Controller Handler 실행</strong></li>
  <li><strong>Interceptor (after)</strong></li>
</ol>

<p><strong>중요한 포인트</strong></p>

<blockquote>
  <p><strong>Global Pipe는 Param Pipe보다 먼저 실행된다</strong></p>
</blockquote>

<p>즉,</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">@Param('userId', UserExistsPipe)</code> → <strong>Global ValidationPipe 이후에 실행</strong></li>
  <li><code class="language-plaintext highlighter-rouge">@Param() DTO</code>→ <strong>Global ValidationPipe 단계에서 검증 가능</strong></li>
</ul>

<hr />

<h2 id="기존-코드의-문제점">기존 코드의 문제점</h2>

<p>기존 구조에서는 이런 일이 발생한다.</p>

<p><code class="language-plaintext highlighter-rouge">userId</code>가 숫자가 아닌 문자열이거나 아예 형식이 잘못된 값이 와도 <strong>UserExistsPipe가 무조건 실행됨</strong>
→ DB 조회 발생</p>

<p>즉, <strong>형식 검증과 비즈니스 검증이 섞여 있고</strong>,
DB 조회가 필요 없는 경우에도 실행이 된다고 생각이 되었다.</p>

<hr />

<h2 id="해결-전략-param을-dto로-만들고-검증-책임-분리">해결 전략: Param을 DTO로 만들고 검증 책임 분리</h2>

<p>리뷰를 반영해, 파라미터를 DTO로 분리했다.</p>

<h3 id="param-dto">Param DTO</h3>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nx">ReferralUserParamDto</span> <span class="p">{</span>
  <span class="p">@</span><span class="nd">IsString</span><span class="p">()</span>
  <span class="p">@</span><span class="nd">IsNotEmpty</span><span class="p">()</span>
  <span class="nx">userId</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="변경된-컨트롤러-코드">변경된 컨트롤러 코드</h3>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">@</span><span class="nd">Post</span><span class="p">(</span><span class="dl">'</span><span class="s1">:userId</span><span class="dl">'</span><span class="p">)</span>
<span class="p">@</span><span class="nd">UsePipes</span><span class="p">(</span><span class="nx">UserExistsPipe</span><span class="p">)</span>
<span class="k">async</span> <span class="nx">getOrCreateReferralCode</span><span class="p">(</span>
  <span class="p">@</span><span class="nd">Param</span><span class="p">()</span> <span class="nx">params</span><span class="p">:</span> <span class="nx">ReferralUserParamDto</span><span class="p">,</span>
<span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ReferralCodeResponse</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">referralUser</span> <span class="o">=</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">referralService</span><span class="p">.</span><span class="nx">getOrCreateReferralCode</span><span class="p">(</span><span class="nx">params</span><span class="p">.</span><span class="nx">userId</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="이-구조의-실행-흐름">이 구조의 실행 흐름</h2>

<p>요청이 들어오면 다음 순서로 동작한다.</p>

<ol>
  <li>
    <p><strong>Global ValidationPipe</strong></p>

    <ul>
      <li><code class="language-plaintext highlighter-rouge">ReferralUserParamDto</code> 검증</li>
      <li><code class="language-plaintext highlighter-rouge">userId</code> 타입 / 빈 값 여부 확인</li>
      <li>실패 시 여기서 바로 400 응답</li>
    </ul>
  </li>
  <li>
    <p><strong>UserExistsPipe 실행</strong></p>

    <ul>
      <li>DTO 검증을 통과한 경우에만 실행</li>
      <li>실제 유저 존재 여부 확인</li>
    </ul>
  </li>
  <li>
    <p><strong>Controller Handler 실행</strong></p>
  </li>
</ol>

<p>결과적으로,</p>

<ul>
  <li>잘못된 요청은 <strong>DB 조회 없이 차단</strong></li>
  <li>
    <p>Pipe의 역할이 명확해짐</p>

    <ul>
      <li>DTO: 구조·형식 검증</li>
      <li>UserExistsPipe: 비즈니스 검증</li>
    </ul>
  </li>
</ul>

<hr />

<h2 id="얻은-인사이트">얻은 인사이트</h2>

<h3 id="1-pipe는-무엇을-검증하는가보다-언제-실행되는가가-중요하다">1. Pipe는 “무엇을 검증하는가”보다 “언제 실행되는가”가 중요하다</h3>

<p>NestJS에서는 <strong>실행 순서를 모르면 설계가 꼬이기 쉽다.</strong></p>

<h3 id="2-param-pipe에-모든-검증을-몰아넣지-말자">2. Param Pipe에 모든 검증을 몰아넣지 말자</h3>

<ul>
  <li>형식 검증 → DTO + Global Pipe</li>
  <li>비즈니스 검증 → Custom Pipe</li>
</ul>

<h3 id="3-dto는-body뿐-아니라-param에서도-적극적으로-쓰자">3. DTO는 Body뿐 아니라 Param에서도 적극적으로 쓰자</h3>

<p>특히 <strong>트래픽이 많거나 DB 조회가 포함된 Pipe</strong>라면 필수에 가깝다.</p>]]></content><author><name>HyunPang</name></author><summary type="html"><![CDATA[Global Pipe가 먼저 실행된다는 사실이 만드는 차이]]></summary></entry><entry><title type="html">에러 테스트 범위</title><link href="https://hyun-git.github.io//error-test.html" rel="alternate" type="text/html" title="에러 테스트 범위" /><published>2026-01-18T00:00:00+00:00</published><updated>2026-01-18T00:00:00+00:00</updated><id>https://hyun-git.github.io//error-test</id><content type="html" xml:base="https://hyun-git.github.io//error-test.html"><![CDATA[<h2 id="이번에-비어있던-테스트-코드를-작성을-하면서-마주친-질문이-있다">이번에 비어있던 테스트 코드를 작성을 하면서 마주친 질문이 있다.</h2>
<blockquote>
  <p>“에러에 대한 테스트는 어디까지 작성해야 할까?”</p>
</blockquote>

<p>특히 서비스 로직이 복잡해질수록 에러는 늘어나고, 그만큼 테스트 범위에 대한 기준이 없으면 테스트 코드가 쉽게 과해지거나, 반대로 의미 없어지기도 한다.</p>

<p>이번에 테스트 코드 작성 중 에러 검증 범위에 대한 논의가 필요했고, 몇 가지 선택지 중 하나를 정해야 했다. 이 글은 그 고민 과정과 최종적으로 정리한 에러 테스트 컨벤션을 정리해 보았다.</p>

<hr />

<h2 id="에러-테스트의-목적부터-다시-생각해보기">에러 테스트의 목적부터 다시 생각해보기</h2>

<p>우선 에러를 던졌을 때 테스트의 목적을 명확히 할 필요가 있었다.</p>

<blockquote>
  <p>에러 테스트의 핵심 목적은
“의도한 위치에서 에러가 발생했는지”를 검증하는 것이다.</p>
</blockquote>

<p>에러가 발생했다는 사실 자체보다,</p>

<ul>
  <li>어디서</li>
  <li>어떤 이유로</li>
</ul>

<p>에러가 발생했는지가 더 중요하다.</p>

<p>이 기준을 가지고 에러 테스트의 범위를 고민했다.</p>

<h4 id="고려했던-세-가지-선택지">고려했던 세 가지 선택지</h4>

<ol>
  <li>에러 타입만 검증
    <div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">fn</span><span class="p">()).</span><span class="nx">rejects</span><span class="p">.</span><span class="nx">toThrow</span><span class="p">(</span><span class="nx">CustomError</span><span class="p">);</span>
</code></pre></div>    </div>

    <ul>
      <li>장점
        <ul>
          <li>가장 단순하고 작성 비용이 낮다.</li>
          <li>리팩토링에도 비교적 안전하다.</li>
          <li>실제로 한 함수에서 여러 에러 케이스를 던지는 경우가 적다.</li>
        </ul>
      </li>
      <li>단점
        <ul>
          <li>한 함수에서 같은 타입의 에러를 여러 곳에서 던질 경우</li>
          <li>정확히 어느 위치에서 발생했는지 알기 어렵다.</li>
          <li>결국 “에러가 났다”는 것만 확인하게 된다.</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>에러 메시지 or ERROR_CODE까지 검증
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> await expect(fn()).rejects.toThrow('user not found');
 또는
 expect(error.errorCode).toBe(ERROR_CODE.USER_NOT_FOUND);
</code></pre></div>    </div>

    <ul>
      <li>장점
        <ul>
          <li>에러 발생 위치를 구분할 수 있다.</li>
          <li>에러 메시지는 모든 에러가 기본적으로 가진 값이라 일관성을 유지하기 쉽다.</li>
          <li>ERROR_CODE가 있다면 더 명확한 의도 표현이 가능하다.</li>
        </ul>
      </li>
      <li>단점
        <ul>
          <li>메시지 전체를 비교하면 테스트가 깨지기 쉬워질 수 있다.</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>에러 내부 객체 전체를 검증
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>expect(error).toEqual({
    errorCode: ...,
    message: ...,
    data: ...
});
</code></pre></div>    </div>

    <ul>
      <li>장점
        <ul>
          <li>가장 명확하고 구체적인 검증</li>
        </ul>
      </li>
      <li>단점
        <ul>
          <li>대부분의 에러 객체는 로깅용 정보에 가깝다.</li>
          <li>서비스 로직에 직접적인 영향을 주지 않는 필드까지 검증하게 된다.</li>
          <li>테스트 오버헤드와 복잡성이 급격히 증가한다.</li>
        </ul>
      </li>
    </ul>
  </li>
</ol>

<h2 id="결론-어디까지-검증할-것인가">결론: 어디까지 검증할 것인가?</h2>

<p>논의 끝에 아래와 같은 방향으로 정리했다.</p>
<ul>
  <li>에러 타입만으로는 부족하다 -&gt; 동일 타입의 에러가 여러 곳에서 발생할 수 있기 때문</li>
  <li>에러 객체 전체 검증은 과하다 -&gt; 테스트 유지 비용이 높고 실질적인 가치가 낮다</li>
</ul>

<h4 id="error_code가-있다면-error_code를-검증한다">ERROR_CODE가 있다면 ERROR_CODE를 검증한다</h4>

<p>우리 서비스에서는 CustomError에 ERROR_CODE로 에러를 구분하고 있다.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>expect(error.errorCode).toBe(ERROR_CODE.INVALID_TOKEN);
</code></pre></div></div>
<p>메시지보다 명확하고 문자열 변경에 덜 민감하다</p>

<h4 id="error_code가-없다면-메시지를-검증한다">ERROR_CODE가 없다면 메시지를 검증한다</h4>

<p>단, 전체 일치가 아니라 포함 여부만 확인</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>await expect(fn()).rejects.toThrow(/token/i);
</code></pre></div></div>

<p>에러 메시지의 핵심 의미만 검증 전체를 검사하게 되면 같은 의미상의 문구도 테스트까지 수정해야하는 오버해드가 생긴다.</p>
<h4 id="에러-수신-측에서-data를-사용한다면-그때만-객체-검증을-추가한다">에러 수신 측에서 data를 사용한다면, 그때만 객체 검증을 추가한다</h4>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>expect(error.data).toEqual({
  retryAfter: 30,
});
</code></pre></div></div>

<p>에러 객체의 data가 수신하는 측에서 비지니스 로직으로 사용이 된다면 검사하도록 추가하였다.
반환이 정확하게 되지 않았을 경우 문제가 생기는 경우에만.</p>

<h2 id="마무리">마무리</h2>
<p>테스트 코드의 범위를 정하는 일은 항상 어렵다고 느낀다.
어디까지 테스트해야 하는지, 어떤 코드까지 검증해야 하는지 매번 작성할 때마다 고민하게 된다.</p>

<p>테스트가 많고 자세하다고 해서 항상 좋은 것만은 아니라는 생각도 들었다.
오히려 중요한 건 이 테스트를 통해 무엇을 얻고 싶은지, 그리고 이 테스트가 어떤 가치를 주는지를 먼저 고민하는 것이라는 점을 다시 한 번 느끼게 되었다.</p>

<p>이번 논의를 통해 단순히 “테스트를 더 작성하자”가 아니라,
테스트의 목적과 유지 비용 사이의 균형을 생각해보는 계기가 되었던 것 같다.</p>

<p>비슷한 고민을 하고 있다면,
이 글에서 정리한 기준을 그대로 적용하기보다는
각 팀과 서비스의 특성에 맞게 참고하여 조정해보는 것도 좋은 방법일 것 같다.</p>]]></content><author><name>HyunPang</name></author><summary type="html"><![CDATA[이번에 비어있던 테스트 코드를 작성을 하면서 마주친 질문이 있다. “에러에 대한 테스트는 어디까지 작성해야 할까?”]]></summary></entry></feed>