<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Frontend Lab | 프론트엔드 기술 실험과 깊이 있는 학습 내용을 공유하는 공간입니다.</title>
    <link>https://blog.sangwook.dev</link>
    <description>프론트엔드 기술 실험과 깊이 있는 학습 내용을 공유하는 공간입니다.</description>
    <language>ko</language>
    <lastBuildDate>Sun, 06 Sep 2026 21:57:11 GMT</lastBuildDate>
    <atom:link href="https://blog.sangwook.dev/rss.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>캐시가 hit인데 매번 콜드 빌드였습니다 — GitHub Actions Next.js 캐시 키가 죽어 있던 이유</title>
      <link>https://blog.sangwook.dev/posts/cache-hit-cold-build/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/cache-hit-cold-build/</guid>
      <pubDate>Sat, 15 Aug 2026 15:00:00 GMT</pubDate>
      <description>캐시 복원은 초록불이었고 67MB가 정상적으로 내려왔습니다. 그런데 빌드는 캐시가 아예 없을 때와 같은 4분이었습니다. hit 로그가 보증하지 않는 것 — 캐시가 거치는 두 단계, 그리고 캐시 키에서 구조적으로 빠지는 입력을 대조군 실측으로 확인합니다.</description>
      <content:encoded><![CDATA[<h2>이 글을 읽고 나면</h2>
<ul>
<li>&quot;캐시 hit&quot;이 성공 신호가 아닐 수 있다는 것과, 그것을 대조군으로 증명하는 방법을 배웁니다</li>
<li>Next.js 빌드 캐시가 basePath 같은 config 변화에 어떻게 반응하는지 이해합니다</li>
<li>파일이 아닌 입력(환경변수)이 왜 캐시 키에서 <strong>구조적으로</strong> 누락되는지 알게 됩니다</li>
</ul>
<h2>들어가며</h2>
<p>CI 로그의 캐시 복원 스텝은 초록불이었습니다. 캐시 hit, 67MB 복원 완료. 그런데 빌드 시간이 전혀 줄지 않았습니다. 캐시가 있는 빌드가 4.0분, 캐시가 아예 없는 빌드가 3.9분이었습니다.</p>
<p>캐시는 매번 내려왔고, 매번 통째로 버려지고 있었습니다. 저는 이 두 줄을 한참 그냥 지나쳤습니다.</p>
<p>이 글은 그 원인을 좁혀 캐시 키 한 조각으로 고치기까지의 기록입니다(facebook/astryx <a href="https://github.com/facebook/astryx/pull/3864">#3864</a>).</p>
<h2>제일 느린 job이 이미 캐시를 쓰고 있었습니다</h2>
<p>문제의 job은 <code>sandbox</code>라는 Next.js 앱을 정적 export하는 빌드입니다. PR CI에서 제일 느렸고, 약 4분이 걸렸습니다. 이걸 줄이는 게 과제였습니다.</p>
<p>그런데 이 job은 이미 캐시를 쓰고 있었습니다. <code>apps/sandbox/.next/cache</code>를 <code>actions/cache</code>로 복원했고, 복원은 매번 <strong>성공</strong>했습니다. 캐시가 있는데, 왜 매번 4분인가.</p>
<h2>&quot;캐시 hit&quot;은 성공 신호가 아닙니다</h2>
<p>가설을 세우기 전에 숫자부터 모았습니다. 세 개의 실제 run을 비교했습니다.</p>





























<table><thead><tr><th style="text-align:left">맥락</th><th style="text-align:left">캐시</th><th style="text-align:left">sandbox 컴파일</th><th style="text-align:left">뜻</th></tr></thead><tbody><tr><td style="text-align:left"><a href="https://github.com/facebook/astryx/actions/runs/29181173652">main Deploy</a></td><td style="text-align:left">hit</td><td style="text-align:left"><strong>64초</strong></td><td style="text-align:left">복원된 캐시가 실제로 쓰였다</td></tr><tr><td style="text-align:left"><a href="https://github.com/facebook/astryx/actions/runs/29181246330">PR build</a></td><td style="text-align:left">hit (main과 <strong>같은 키</strong>, 67MB 복원)</td><td style="text-align:left"><strong>4.0분</strong></td><td style="text-align:left">hit인데 콜드와 같다</td></tr><tr><td style="text-align:left"><a href="https://github.com/facebook/astryx/actions/runs/29180552092">대조군</a></td><td style="text-align:left">miss (콜드)</td><td style="text-align:left">3.9분</td><td style="text-align:left">캐시 없는 기준선</td></tr></tbody></table>
<p>같은 캐시 키에 같은 hit인데 main은 64초, PR은 4.0분입니다. 그리고 PR의 4.0분은 <strong>캐시가 아예 없는 대조군 3.9분과 구분되지 않습니다</strong>.</p>
<blockquote>
<p>캐시가 죽었는지 살았는지는 hit/miss 로그로 알 수 없습니다. hit 이후의 실제 컴파일 시간을 콜드 run과 비교해야 알 수 있습니다.</p>
</blockquote>
<p>main과 대조군은 비교의 양 끝입니다. 64초는 복원된 캐시가 <strong>실제로 재사용된</strong> 시간이고, 3.9분은 캐시 없이 <strong>콜드로 컴파일한</strong> 시간입니다. PR의 4.0분이 어느 쪽에 붙는지만 보면 됩니다. 둘 중 하나만 있었다면 진단이 안 됐을 겁니다. 콜드만 있으면 &quot;이 프로젝트는 원래 4분짜리군&quot;으로 끝나고, main만 있으면 &quot;PR은 뭔가 더 하니까 느리겠지&quot;로 끝납니다.</p>
<h2>캐시는 두 단계를 거칩니다</h2>
<p>여기서 &quot;캐시 키가 어긋났다&quot;고 읽으면 방향이 반대가 됩니다. 키는 정확히 맞았습니다. <strong>맞은 것이 문제였습니다.</strong></p>
<p>캐시를 확인하는 곳이 두 군데이기 때문입니다.</p>























<table><thead><tr><th style="text-align:left">단계</th><th style="text-align:left">주체</th><th style="text-align:left">판단 기준</th><th style="text-align:left">이번 결과</th></tr></thead><tbody><tr><td style="text-align:left">1단계</td><td style="text-align:left"><code>actions/cache</code></td><td style="text-align:left">키 문자열이 같은가</td><td style="text-align:left">같음 → hit, 67MB 복원</td></tr><tr><td style="text-align:left">2단계</td><td style="text-align:left">Next.js(webpack)</td><td style="text-align:left">복원된 <code>.next/cache</code>의 config 지문이 지금과 같은가</td><td style="text-align:left">다름 → 통째로 폐기</td></tr></tbody></table>
<p><code>actions/cache</code>는 tarball을 풀어주는 일만 합니다. 그 안의 내용이 이번 빌드에 쓸모 있는지는 알지 못하고, 알 방법도 없습니다. 유효성 판단은 전적으로 <strong>키를 짠 사람의 몫</strong>입니다.</p>
<p>Next.js는 압니다. webpack의 persistent cache에는 그 캐시를 구울 때의 resolved config(설정 파일이 아니라, 환경변수까지 반영된 빌드 시점의 최종 설정값) 지문이 함께 저장되고, 복원본을 열 때 지금 값과 대조합니다.</p>
<p>그래서 로그에 에러가 한 줄도 없었던 겁니다. 두 단계 모두 명세대로 정확히 동작했습니다. 고장난 부품은 하나도 없고, 문제는 <strong>두 단계 사이</strong>에 있었습니다.</p>
<diagram label="복원된 캐시가 두 단계를 거쳐 폐기되는 흐름" caption="1단계 통과, 2단계 폐기 — 로그에 에러는 없다">
  <diagram-node id="cache" title="actions/cache" desc="1단계 · 키 문자열 같음"></diagram-node>
  <diagram-node id="restore" title="67MB 복원" desc="hit 기록"></diagram-node>
  <diagram-node id="next" title="Next.js" desc="2단계 · config 지문 다름"></diagram-node>
  <diagram-node id="discard" title="통째로 폐기" desc="콜드 컴파일 4.0분" tone="accent"></diagram-node>
</diagram>
<h3>basePath가 다르면 Next.js는 캐시를 버립니다</h3>
<p>캐시 키는 이랬습니다.</p>
<pre><code class="language-text">nextjs-sandbox-&lt;pnpm-lock 해시&gt;-&lt;packages/core/dist 해시&gt;
</code></pre>
<p>빌드 입력을 잘 반영한 키처럼 보입니다. 의존성이 바뀌면 갈리고, core 산출물이 바뀌면 갈립니다. 그런데 키에 안 들어간 입력이 하나 있었습니다. PR 빌드와 main 빌드는 <strong>basePath가 다릅니다</strong>.</p>
<ul>
<li>PR: <code>SANDBOX_BASE_PATH=/&lt;repo&gt;/pr/&lt;n&gt;/sandbox</code></li>
<li>main: <code>/&lt;repo&gt;/sandbox</code></li>
</ul>
<p>이건 실수가 아니라 필연입니다. PR 프리뷰는 GitHub Pages의 <code>&lt;owner&gt;.github.io/&lt;repo&gt;/pr/&lt;n&gt;/sandbox/</code> 경로에 배포됩니다. 그 prefix 없이 빌드하면 루트 절대 경로로 나가는 에셋 URL이 전부 404가 나서 프리뷰의 스타일과 JS가 통째로 깨집니다.</p>
<p>앞에서 말한 &quot;지문&quot;의 실체가 이것입니다. Next.js는 config 전체가 아니라 <strong>컴파일 결과를 바꾸는 값들만 골라</strong> 버전 문자열로 이어 붙이는데, basePath가 그 한 항목입니다.</p>
<pre><code class="language-text">cache.version = &lt;Next.js 버전&gt;|{&quot;basePath&quot;:&quot;/&lt;repo&gt;/sandbox&quot;,&quot;trailingSlash&quot;:true,…}
</code></pre>
<p>main이 구운 캐시에는 main의 basePath가 박힌 문자열이 기록돼 있으니, PR이 여는 순간 대조가 어긋납니다 — <strong>한 글자라도 다르면 통째로 버리고, 부분 재사용은 없습니다.</strong> 복원한 67MB는 처음부터 순수한 다운로드 낭비였습니다.</p>
<blockquote>
<p>이 폐기는 버그가 아니라 안전장치입니다. Next.js가 config 지문을 대조하지 않았다면 결과는 느린 빌드가 아니라 <strong>잘못된 배포</strong>였을 겁니다. main용 basePath로 컴파일된 에셋이 PR 프리뷰로 나가 링크가 전부 404가 나는데, CI는 4분이 아니라 40초에 끝나서 &quot;최적화가 잘 됐다&quot;는 인상까지 줬을 겁니다. 느린 쪽이 오히려 다행이었습니다.</p>
</blockquote>
<h3>hit으로 기록되면 저장도 못 합니다</h3>
<p>여기서 끝이었으면 &quot;쓸데없이 67MB 받는 낭비&quot; 정도였을 겁니다. 진짜 문제는 두 번째였습니다. <code>actions/cache</code>는 primary key에 hit이 나면 이 로그를 남기고 저장 단계를 건너뜁니다.</p>
<pre><code class="language-text">Cache hit occurred on the primary key, not saving.
</code></pre>
<p>캐시 액션 입장에선 합리적입니다. 이미 그 키로 캐시가 있으니 다시 올릴 이유가 없으니까요. 그런데 이 상황에서는 결과가 최악입니다.</p>
<p>PR은 main의 죽은 캐시로 &quot;hit&quot;을 받습니다. → 저장 단계가 건너뛰어집니다. → <strong>PR은 자기 basePath에 맞는 캐시를 영원히 만들 수 없습니다.</strong> → 다음 푸시에서도 똑같이 죽은 캐시를 받고 콜드 컴파일을 합니다.</p>
<p>CI 러너는 매 run 새 기계라, run 사이에 살아남는 유일한 통로는 캐시 업로드뿐입니다. 콜드 컴파일의 부산물로 러너 디스크에는 PR basePath에 맞는 진짜 캐시가 생기지만, 저장이 스킵되니 러너와 함께 증발합니다. 다음 푸시는 빈 디스크에서 또 main의 캐시를 받습니다.</p>
<blockquote>
<p>캐시가 있는데 없는 것보다 나쁜 상태가 됐습니다. 내려받는 시간은 쓰고, 빌드는 매번 콜드로 돌고, 올바른 캐시가 생기는 것까지 막습니다.</p>
</blockquote>
<p>main이 멀쩡했던 이유도 여기서 자명해집니다. main은 <strong>자기가 구운 것을 자기가 꺼내 씁니다.</strong> basePath가 같으니 Next.js의 대조를 통과하고, 그래서 64초입니다. 뒤집어 말하면 miss를 내고 저장할 기회는 사실상 main에게만 있었으니, <strong>main만 쓸 수 있는 캐시가 공유 키라는 공용 이름으로 모두에게 배포되고 있던 셈입니다.</strong></p>
<p>예외는 있었습니다. PR이 의존성이나 core 산출물을 바꾸면 키가 main이 점유하지 않은 새 값이 돼 저장이 실행됩니다. 그래서 core를 건드리는 PR은 다음 푸시부터 웜이었고, sandbox나 문서만 고치는 PR만 영원히 콜드였습니다 — 증상이 PR마다 달라 이 버그가 오래 눈에 띄지 않은 이유입니다.</p>
<h2>파일이 아닌 입력은 키에서 저절로 빠집니다</h2>
<p>그런데 왜 basePath는 키에 없었을까요. 부주의라기보다, 키를 짜는 기본 방식이 원래 놓치는 입력이기 때문입니다.</p>
<pre><code class="language-yaml">key: nextjs-sandbox-${{ hashFiles(&#x27;pnpm-lock.yaml&#x27;) }}-${{ hashFiles(&#x27;packages/core/dist/**&#x27;) }}
</code></pre>
<p>캐시 키는 관습적으로 <code>hashFiles()</code>로 짭니다. 그런데 <code>hashFiles()</code>는 <strong>파일 시스템만 봅니다.</strong> <code>SANDBOX_BASE_PATH</code>처럼 환경변수로 들어오는 입력은 빌드 결과를 아무리 좌우해도 여기 잡히지 않습니다. 사람이 손으로 적어 넣지 않는 한 구조적으로 누락되고, 그 누락은 눈에 보이지도 않습니다 — 키 문자열을 아무리 들여다봐도 &quot;빠진 것이 있다&quot;는 사실 자체를 알 수 없기 때문입니다.</p>
<p>같은 이유로, 이 문제는 의존성 캐시에서는 생기지 않습니다. pnpm store의 내용을 정하는 입력은 lockfile 하나뿐이고 그건 파일이니, <code>hashFiles()</code>만으로 충분합니다. 키가 좀 헐거워도 패키지는 content-addressed라 잘못된 것을 집어올 수 없습니다. 문제가 생기는 쪽은 <strong>빌드 산출물 캐시</strong>입니다. 컴파일 결과에는 그때의 config·환경변수·플래그가 전부 반영돼 있는데, 키에는 보통 lockfile과 소스 해시만 들어갑니다. 이 버그는 그 차이에서 생깁니다.</p>
<h2>빠진 입력을 키에 넣습니다</h2>
<p>고치는 diff는 허무할 정도로 짧습니다. +17/−1인데 그중 16줄은 근거를 적은 주석이고, 실제로 바뀐 건 이 한 줄입니다.</p>
<pre><code class="language-diff">- key: nextjs-sandbox-${{ hashFiles(&#x27;pnpm-lock.yaml&#x27;) }}-${{ hashFiles(&#x27;packages/core/dist/**&#x27;) }}
+ key: nextjs-sandbox-pr${{ github.event.pull_request.number }}-${{ hashFiles(&#x27;pnpm-lock.yaml&#x27;) }}-${{ hashFiles(&#x27;packages/core/dist/**&#x27;) }}
</code></pre>
<p><code>pr&lt;n&gt;</code> 한 조각을 넣은 게 전부입니다. PR 번호는 basePath를 결정하는 값입니다. 키에 저절로 들어가지 않던 입력(환경변수)을, 키에 직접 적을 수 있는 대리 값으로 바꿔 넣은 셈입니다. 이제 각 PR은 첫 푸시에서 자기 캐시를 굽고, 이후 푸시에서 진짜 워밍 캐시를 씁니다. main은 원래부터 자기가 구운 캐시를 자기가 꺼내 쓰고 있었으니 그대로 둡니다.</p>
<h3>restore-keys는 넣지 않았습니다</h3>
<p>키를 세분화하면 자연스럽게 &quot;그럼 <code>restore-keys</code>로 접두사만 맞는 캐시라도 복원하자&quot;는 생각이 따라옵니다. 일부러 넣지 않았습니다. 이 저장소에는 이력이 있습니다 — Next.js 모듈 캐시에는 <code>@astryxdesign/core</code>의 resolved export graph가 함께 구워지는데, 접두사만 맞는 캐시를 복원하면 다른 export 형태로 빌드된 것이 들어와 조용히 잘못된 빌드가 나옵니다(#2941의 머지 후 배포가 이 방식으로 깨졌습니다). 이 저장소의 정책은 &quot;캐시 hit률 최대화&quot;가 아니라 <strong>&quot;틀린 빌드를 절대 만들지 않기&quot;</strong> 입니다. 키가 바뀌면 콜드 리빌드가 나는 편이 안전합니다.</p>
<h3>고쳤더니 hit률이 떨어집니다</h3>
<p>이 수정이 제대로 됐다는 신호는 <strong>miss</strong>입니다. 각 PR의 첫 푸시는 이제 반드시 miss니까요. 그래서 테스트 플랜에 일부러 체크하지 않은 항목을 하나 남겼습니다 — &quot;첫 푸시의 miss가 수정이 작동한다는 증거다. 재푸시에서 컴파일이 64초 수준으로 떨어지는지 확인할 것.&quot; 이걸 적어두지 않으면 다음 사람이 hit률 그래프만 보고 &quot;캐시가 안 먹네&quot; 하며 되돌려 놓습니다.</p>
<p>그 항목은 이 PR 자신에서 닫혔습니다. 첫 푸시(<a href="https://github.com/facebook/astryx/actions/runs/29182165678">run 29182165678</a>)는 <code>nextjs-sandbox-pr3864-…</code> 키로 miss가 나서 4.2분을 컴파일하고 캐시를 저장했고, 28분 뒤 재푸시(<a href="https://github.com/facebook/astryx/actions/runs/29182864149">run 29182864149</a>)는 그 키를 복원해 <strong>60초</strong>에 컴파일했습니다 — main의 64초와 같은 수준입니다.</p>
<p>머지 후, 캐시가 걸린 재푸시 기준으로 PR CI 전체 시간은 8분 56초에서 5분 10초가 됐습니다. 다만 23분 간격으로 머지된 병렬화 PR(<a href="https://github.com/facebook/astryx/pull/3811">#3811</a>)의 효과가 섞여 있어, 캐시 수정만의 몫은 따로 떼어 말할 수 없습니다.</p>
<h3>남은 한계: 첫 푸시는 여전히 콜드입니다</h3>
<p>pr 키에도 한계가 남습니다. 두 번째 푸시부터는 다시 primary hit이라 저장이 스킵되고, 그 PR의 캐시는 첫 푸시 때 만든 것에 머뭅니다. 흔한 해법은 키에 커밋 SHA를 붙여 hit을 없애고(그러면 매번 저장됩니다) 복원은 <code>restore-keys</code>에 맡기는 것입니다. 접두사에 <code>pr&lt;n&gt;</code>과 core 해시를 남기면(<code>nextjs-sandbox-pr&lt;n&gt;-&lt;lock&gt;-&lt;core&gt;-</code>) 여기서도 쓸 수 있고, #2941 사고와도 부딪히지 않습니다. 이번 PR은 저장소에 적힌 no-restore-keys 정책을 열지 않고 그 안에서 고치는 쪽을 택했습니다.</p>
<p>SHA를 붙였더라도 달라지지 않는 것이 하나 있습니다. <strong>basePath가 PR마다 다른 한, 첫 푸시는 어떤 키로도 웜이 될 수 없습니다.</strong> GitHub Actions 캐시는 브랜치 단위라 PR run이 읽을 수 있는 건 자기 브랜치와 base 브랜치(main)의 캐시뿐입니다. 첫 푸시에는 자기 캐시가 없으니 남는 건 main 캐시 하나인데, 그건 basePath가 달라 쓸 수 없습니다.</p>
<p>그러니 근본 해법은 키 바깥에 있습니다. <strong>main 캐시를 PR 빌드에서도 쓸 수 있게 만드는 것</strong> — basePath를 빌드 입력에서 빼는 것입니다. 에셋을 상대 경로로 내보내거나 프리뷰를 서브도메인으로 배포하면 basePath가 어디서나 같아지고, 키를 가를 이유가 사라집니다. 이번 PR은 워크플로 파일만 고치는 키 수정을 택했고, 첫 푸시의 콜드는 고친 게 아니라 <strong>감수한</strong> 것입니다. 순서만 기억해 두면 됩니다 — &quot;캐시 키를 어떻게 짤까&quot;보다 &quot;이 입력이 정말 달라야 하나&quot;를 먼저 묻는 것입니다.</p>
<h2>이 패턴을 다른 저장소에 옮긴다면</h2>
<p>캐시 스텝마다 이 네 가지를 확인하면 됩니다.</p>
<ul class="contains-task-list">
<li class="task-list-item"><input type="checkbox" disabled=""/> <strong>키에 넣을 것</strong> — 산출물을 바꾸는 입력이 전부 들어갔나. <code>hashFiles()</code>는 파일만 보므로 환경변수·플래그·툴체인 버전은 손으로 넣어야 한다</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> <strong>같은 캐시를 쓰면 안 되는 빌드</strong> — PR과 main, OS, 런타임 버전, matrix 조합처럼 서로 캐시를 나눠 써야 하는 빌드가 키 접두사로 구분되나</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> <strong><code>restore-keys</code></strong> — 웜을 최대화할지, 정확성을 위해 콜드를 감수할지. 쓴다면 위의 구분이 접두사에도 남아 있나</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> <strong>살아 있는지</strong> — <code>Cache restored successfully</code> 다음 줄의 소요 시간이 그 캐시를 지웠을 때와 유의미하게 다른가. 같으면 그 캐시는 죽어 있다</li>
</ul>
<p>앞의 셋은 키를 읽어서 확인할 수 있지만, 빠진 입력은 키 문자열을 아무리 들여다봐도 보이지 않습니다. 그래서 마지막 항목이 필요합니다.</p>
<h2>마치며</h2>
<p>캐시 hit은 &quot;키 문자열이 같았다&quot;는 뜻일 뿐이라, 캐시의 생사는 hit 이후의 컴파일 시간을 콜드 대조군과 비교해야 알 수 있습니다. 이번 사례에서 키는 정확히 맞았지만, Next.js가 basePath 다른 캐시를 열어보고 통째로 버리고 있었고, hit으로 기록된 탓에 올바른 캐시가 저장될 기회조차 없었습니다. basePath가 키에서 빠진 것은 부주의가 아니라 <code>hashFiles()</code>가 파일만 보기 때문입니다. 그래서 키에 PR 번호를 넣어, 각 PR이 자기 캐시를 굽게 했습니다.</p>
<p>여러분의 CI에도 캐시 스텝이 있다면, 지금 가장 최근 run의 로그를 열어보세요. 확인은 5분이면 끝납니다. <code>Cache restored successfully</code> 다음 줄의 빌드 시간이 캐시가 없던 시절과 정말로 다른가요. 비슷하게 &quot;hit인데 안 빨라지는&quot; 캐시를 만나신 적 있다면 댓글로 공유해 주세요.</p>
<ul>
<li>근거 PR: <a href="https://github.com/facebook/astryx/pull/3864">facebook/astryx#3864 — perf(ci): key sandbox next cache by PR to stop cross-basepath invalidation</a> · 배경이 된 병렬화 PR: <a href="https://github.com/facebook/astryx/pull/3811">#3811 — perf(ci): build sandbox preview in parallel with storybook</a> (둘 다 2026-07-12 머지)</li>
<li>참고 문서: <a href="https://webpack.js.org/configuration/cache/#cacheversion">webpack <code>cache.version</code> — 버전이 다르면 캐시를 재사용하지 않는다</a> · <a href="https://github.com/vercel/next.js/blob/v16.3.1/packages/next/src/build/webpack-config.ts#L2380-L2423">Next.js가 config 값들로 캐시 버전 문자열을 만드는 곳 — <code>build/webpack-config.ts#L2380-L2423</code> (v16.3.1)</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>매일 쓰던 getByRole 때문에 테스트가 26배 느렸습니다 — RTL·jsdom 성능 분석</title>
      <link>https://blog.sangwook.dev/posts/getbyrole-performance/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/getbyrole-performance/</guid>
      <pubDate>Thu, 30 Jul 2026 15:00:00 GMT</pubDate>
      <description>매일 쓰던 getByRole이 사실은 트리 전체를 훑고 있었습니다. Meta 디자인 시스템에서 34초 걸리던 테스트 파일의 원인을 찾아 1.3초로 줄이기까지, RTL과 jsdom 내부에서 실제로 벌어지는 일을 따라가 봤습니다.</description>
      <content:encoded><![CDATA[<h2>이 글을 읽고 나면</h2>
<ul>
<li>매일 쓰는 <code>getByRole(&#x27;button&#x27;, {name})</code>이 내부에서 무슨 일을 하는지 알게 됩니다</li>
<li>&quot;느리다&quot;에서 &quot;무엇이 느린가&quot;로 좁혀가는 방법을 배웁니다</li>
<li>jsdom의 <code>getComputedStyle</code>이 왜 브라우저와 다르게 비싼지 이해합니다</li>
<li>내 프로젝트에서 언제 이 문제를 의심해야 하는지 판단 기준을 얻습니다</li>
</ul>
<hr/>
<h2>병목 좁히기 — 렌더가 아니라 getByRole 쿼리였다</h2>
<h3>1. 유독 느린 파일 하나</h3>
<p>Meta의 디자인 시스템 <a href="https://github.com/facebook/astryx">astryx</a>에서 CI 최적화 작업을 하고 있었습니다.<br/>
파이프라인이 오래 걸려서 이것저것 줄여보던 중이었습니다.</p>
<blockquote>
<p>이 글의 근거 PR은 <a href="https://github.com/facebook/astryx/pull/3816">facebook/astryx#3816</a>이고, 2026년 7월 12일에 머지되었습니다.<br/>
아래 나오는 코드와 수치는 전부 그 PR에서 직접 확인하실 수 있습니다.</p>
</blockquote>
<p>그러다 테스트 파일별 실행 시간을 훑어보다가 이상한 걸 발견했습니다.<br/>
가장 느린 파일 네 개가 <strong>전부 날짜 컴포넌트</strong>였습니다.</p>



































<table><thead><tr><th style="text-align:left">파일</th><th style="text-align:right">테스트 수</th><th style="text-align:right">시간</th><th style="text-align:right">테스트 1개당</th></tr></thead><tbody><tr><td style="text-align:left"><strong>DateRangeInput.test.tsx</strong></td><td style="text-align:right">34</td><td style="text-align:right"><strong>34.3s</strong></td><td style="text-align:right"><strong>약 1초</strong></td></tr><tr><td style="text-align:left">Calendar.test.tsx</td><td style="text-align:right">45</td><td style="text-align:right">7.2s</td><td style="text-align:right">0.16s</td></tr><tr><td style="text-align:left">DateInput.test.tsx</td><td style="text-align:right">68</td><td style="text-align:right">5.1s</td><td style="text-align:right">0.075s</td></tr><tr><td style="text-align:left">DateTimeInput.test.tsx</td><td style="text-align:right">64</td><td style="text-align:right">4.1s</td><td style="text-align:right">0.064s</td></tr></tbody></table>
<p>네 개가 한자리에 몰려 있다는 것부터 우연이 아닌데, 맨 위 파일은 아예 다른 세상에 있습니다. DateInput은 테스트 68개에 5.1초, DateRangeInput은 34개에 34.3초. <strong>테스트 수는 절반인데 시간은 7배</strong>입니다.</p>
<p>같은 폴더의, 같은 날짜 입력 컴포넌트입니다. 게다가 이 테스트들은 <strong>화면을 그리지 않습니다.</strong> jsdom 위에서 마운트하고, 클릭하고, 텍스트를 확인하는 게 전부죠.</p>
<blockquote>
<p>그런 일에 1초가 걸릴 이유가 있을까요?</p>
</blockquote>
<p>원인은 제가 거의 매일 쓰던 한 줄이었습니다.</p>
<hr/>
<h3>2. 렌더 20ms, 쿼리 450ms</h3>
<p>느리다는 건 알았지만 <strong>무엇이</strong> 느린지는 모릅니다. 그래서 테스트 하나를 구간별로 쪼개서 쟀습니다.</p>
<pre><code class="language-js">const t0 = performance.now();
render(&lt;DateRangeInput /&gt;);

const t1 = performance.now();
screen.getByRole(&#x27;button&#x27;, {name: &#x27;Open calendar&#x27;});

const t2 = performance.now();
console.log(&#x27;render :&#x27;, (t1 - t0).toFixed(0), &#x27;ms&#x27;);
console.log(&#x27;query  :&#x27;, (t2 - t1).toFixed(0), &#x27;ms&#x27;);
</code></pre>
<pre><code>render :  20 ms
query  : 450 ms
</code></pre>
<p>렌더는 20ms인데 버튼 <strong>하나</strong>를 찾는 데 450ms. 22배입니다.</p>
<blockquote>
<p>컴포넌트를 통째로 만드는 것보다,<br/>
이미 만들어진 것 중에서 하나 찾는 게 22배 비싸다?</p>
</blockquote>
<p>보통은 반대입니다. 만드는 게 비싸고 찾는 건 쌉니다. 뒤집혔다면 이유가 있습니다. 그 이유는 6장에서 다루고, 지금 확실한 건 하나입니다.</p>
<p><strong>느린 쪽은 렌더가 아니라 쿼리다.</strong></p>
<hr/>
<h3>3. 소스를 열어보다</h3>
<p><code>node_modules</code>를 뒤져서 실제 구현을 열었습니다.<br/>
<code>@testing-library/dom</code>의 <code>dist/queries/role.js</code>입니다.<br/>
다만 아래에는 읽기 편하도록 같은 코드의 원본인 <code>src/queries/role.ts</code>를 인용하겠습니다.</p>
<p>놀랄 만큼 단순했습니다. 그냥 <strong><code>.filter()</code> 체인</strong>이었습니다.</p>
<pre><code class="language-js">// @testing-library/dom v10.4.1 — src/queries/role.ts
Array.from(container.querySelectorAll(makeRoleSelector(role)))
  .filter(/* role 필터 — 이 노드의 role이 정말 맞는가 */)
  .filter(/* aria 필터 — checked, expanded, ... */)
  .filter(element =&gt; {
    if (name === undefined) return true;
    return matches(computeAccessibleName(element), element, name, text =&gt; text);
    //              ^^^^^^^^^^^^^^^^^^^^ name 필터 — 이름을 계산해서 비교
  })
  .filter(/* description 필터 */)
  .filter(element =&gt; {
    return hidden === false ? isInaccessible(element) === false : true;
    //                        ^^^^^^^^^^^^^^ hidden 필터 — 화면에 보이는가
  });
</code></pre>
<p>실제 체인은 필터 5개고, 위는 필요한 부분만 남긴 것입니다.</p>
<details>
<summary>출처 — @testing-library/dom v10.4.1 <code>role.ts</code> (펼치기)</summary>
<p><a href="https://github.com/testing-library/dom-testing-library/blob/v10.4.1/src/queries/role.ts#L186-L304"><code>role.ts</code> L186–L304</a> · <a href="https://github.com/testing-library/dom-testing-library/blob/v10.4.1/src/queries/role.ts#L266-L281">name 필터</a> · <a href="https://github.com/testing-library/dom-testing-library/blob/v10.4.1/src/queries/role.ts#L298-L304">hidden 필터</a></p>
</details>
<p>제가 무심코 쓰던 한 줄이 사실은 <strong>세 가지 일</strong>을 하고 있었습니다.</p>
<pre><code class="language-js">screen.getByRole(&#x27;button&#x27;, {name: &#x27;Open calendar&#x27;});
//                ^^^^^^^   ^^^^^^^^^^^^^^^^^^^^^^   + 기본값 hidden: false
//                role 필터  name 필터                hidden 필터
//                후보 수집  이름으로 걸러냄          안 보이는 것 제외
</code></pre>
<p>여기서 <code>computeAccessibleName</code>은 스크린리더가 읽어주는 그 <strong>접근성 이름</strong>을 구하는 함수입니다.</p>
<h4>그런데 순서가 이상합니다</h4>
<p><strong>name 필터가 hidden 필터보다 먼저 옵니다.</strong> <code>.filter()</code>는 앞에서부터 실행되니 이런 일이 벌어지죠.</p>
<blockquote>
<p>화면에 안 보여서 <strong>어차피 hidden 필터에서 걸러질 노드들</strong>의 이름을,<br/>
안 보인다는 걸 알아내기도 <strong>전에</strong> 전부 계산한다.</p>
</blockquote>
<p>하나 더. <code>.filter()</code>니까 <strong>원하는 걸 찾아도 멈추지 않습니다.</strong></p>
<h4>그래서 DateRangeInput은요</h4>
<pre><code>DateRangeInput
├── 트리거 버튼          ← 내가 찾으려던 것
└── 팝오버 (닫힘)
    └── 2개월치 달력
        └── 날짜 버튼 약 85개   ← 화면에 안 보이지만 DOM에는 그대로
</code></pre>
<p>팝오버가 닫혀 있어도 <strong>달력은 언마운트되지 않습니다.</strong> 그러니 트리거 하나를 찾으려고 <code>getByRole(&#x27;button&#x27;, {name})</code>을 부르면, 후보 <strong>86개</strong>를 모으고 → <strong>전부</strong>의 이름을 계산하고 → 이름이 다른 85개를 떨어뜨립니다. 그 85개가 애초에 화면에 없었다는 사실은 어디에도 쓰이지 않습니다.</p>
<p><strong>보이지도 않을 버튼 85개의 이름을, 안 보인다는 걸 알기도 전에 전부 계산하고 있었던 겁니다.</strong></p>
<diagram label="role 필터로 후보 86개를 모은 뒤 name 필터가 전부의 이름을 계산하고, hidden 필터가 마지막에 85개를 제외하는 순서" caption="hidden이 먼저였다면 85개는 계산조차 없다">
  <diagram-node id="role" title="role 필터" desc="후보 86개 수집"></diagram-node>
  <diagram-node id="name" title="name 필터" desc="86개 전부 이름 계산" tone="accent"></diagram-node>
  <diagram-node id="hidden" title="hidden 필터" desc="85개 뒤늦게 제외"></diagram-node>
  <diagram-edge from="role" to="name" emphasis="true"></diagram-edge>
  <diagram-edge from="name" to="hidden"></diagram-edge>
</diagram>
<p>다만 순서가 문제의 전부는 아닙니다. hidden 필터도 결국 후보 하나하나의 스타일을 봐야 하거든요. 이건 4장에서 다시 짚겠습니다.</p>
<p>그럼 450ms를 먹은 건 이름 계산일까요? 확인해봐야죠.</p>
<hr/>
<h3>4. 옵션 하나를 뺐더니 450ms가 29ms가 됐다</h3>
<p>의심은 의심일 뿐입니다. 재봐야 압니다.</p>
<p>문제는 세 필터가 한 덩어리로 돈다는 겁니다. 프로파일러는 &quot;이 함수가 느리다&quot;까지만 알려주지 <strong>셋 중 어느 것인지</strong>는 안 갈라줍니다. 방법은 하나뿐입니다. <strong>떼어내고 다시 재는 것.</strong></p>
<p>그래서 <strong>하한선</strong>부터 만들었습니다. name도 hidden도 없이 후보만 모으면 얼마나 걸리나?</p>
<pre><code class="language-js">screen.getByRole(&#x27;button&#x27;, {name: &#x27;Open calendar&#x27;});   // → 450ms
screen.getAllByRole(&#x27;button&#x27;, {hidden: true});         // → 29ms  (두 필터 다 끔)
</code></pre>
<p>두 옵션이 어떻게 필터를 끄는지는 3장에서 본 소스에 그대로 있습니다. <code>name</code>을 안 주면 이름 계산을 건너뛰고, <code>hidden: true</code>면 가시성 검사를 건너뜁니다. hidden 필터도 결국 스타일을 들여다보는 일이라, 이것까지 꺼야 순수한 바닥값이 나옵니다.</p>

























<table><thead><tr><th style="text-align:left">무엇을 쟀나</th><th style="text-align:right">시간</th><th style="text-align:right">비중</th></tr></thead><tbody><tr><td style="text-align:left">role 필터 (후보 수집만)</td><td style="text-align:right">29ms</td><td style="text-align:right">6%</td></tr><tr><td style="text-align:left"><strong>name 필터 + hidden 필터</strong></td><td style="text-align:right"><strong>421ms</strong></td><td style="text-align:right"><strong>94%</strong></td></tr><tr><td style="text-align:left">합계</td><td style="text-align:right">450ms</td><td style="text-align:right">100%</td></tr></tbody></table>
<p>후보 86개를 긁어모으는 것 자체는 29ms면 끝납니다. <strong>나머지 94%는 &quot;이름이 맞는지&quot;와 &quot;보이는지&quot;를 따지는 데 쓰였습니다.</strong></p>
<blockquote>
<p>성능 문제를 좁힐 때 제가 배운 게 이겁니다.<br/>
<strong>한 덩어리로 보이는 것을 쪼개서, 가장 싼 조합의 바닥값부터 만든다.</strong><br/>
그 바닥에서 얼마나 올라가는지를 보면 어디가 비싼지 드러납니다.</p>
</blockquote>
<h4>94%를 한 번 더 쪼개면</h4>
<p>방금 그 말대로라면 여기서 멈추면 안 됩니다. 94%를 name과 hidden이 나눠 가졌는데 <strong>누가 얼마나 가져갔는지를 아직 모릅니다.</strong></p>
<p>두 옵션이 필터를 따로 켜고 끄니 가를 수 있습니다.</p>
<pre><code class="language-js">screen.getAllByRole(&#x27;button&#x27;, {hidden: true});                        // role만
screen.getAllByRole(&#x27;button&#x27;, {name: &#x27;Open calendar&#x27;, hidden: true}); // role + name
screen.getAllByRole(&#x27;button&#x27;);                                        // role + hidden
screen.getByRole(&#x27;button&#x27;, {name: &#x27;Open calendar&#x27;});                  // 셋 다
</code></pre>
<p>같은 구조를 jsdom 27.4.0 · <code>@testing-library/dom</code> 10.4.1로 재현해 넷을 각각 쟀습니다.</p>

























<table><thead><tr><th style="text-align:left">조합</th><th style="text-align:right"><code>getComputedStyle</code> 호출</th></tr></thead><tbody><tr><td style="text-align:left">role만</td><td style="text-align:right"><strong>0회</strong></td></tr><tr><td style="text-align:left">role + <strong>name</strong></td><td style="text-align:right"><strong>256회</strong></td></tr><tr><td style="text-align:left">role + <strong>hidden</strong></td><td style="text-align:right"><strong>176회</strong></td></tr><tr><td style="text-align:left">셋 다</td><td style="text-align:right"><strong>261회</strong></td></tr></tbody></table>
<details>
<summary>이 표는 재현 환경 값입니다 — 시간이 아니라 호출 횟수로 잰 이유 (펼치기)</summary>
<p>시간도 쟀지만 표에서 뺐습니다. 같은 코드가 실행마다 1.4초와 2.4초 사이를 오가서, 호출이 더 많은 쪽이 더 빠르게 찍히는 일까지 생기더군요. 반면 호출 횟수는 몇 번을 돌려도 그대로였습니다. 이 절은 호출 횟수에만 기댑니다. (astryx CI가 아니라 제가 따로 만든 재현 환경입니다.)</p>
</details>
<p>이상한 게 보입니다. <strong>셋 다 돌린 261회가 role + name의 256회와 거의 같습니다.</strong> 혼자면 176회를 부르는 hidden 필터가 여기선 5회로 끝났다는 뜻이죠.</p>
<p><code>.filter()</code> 체인이라서 그렇습니다. hidden 필터는 <strong>name 필터가 걸러낸 결과</strong>를 받는데, 이름이 <code>Open calendar</code>인 버튼은 하나뿐입니다. 86개가 아니라 <strong>1개</strong>만 검사하는 겁니다.</p>
<p>그 5회는 버튼의 <code>visibility</code> 조기 종료 검사 1회 + 버튼부터 <code>DIV → BODY → HTML</code>까지의 <code>display</code> 4회입니다.</p>
<details>
<summary>출처 — @testing-library/dom v10.4.1 <code>role-helpers.js</code> (펼치기)</summary>
<p><a href="https://github.com/testing-library/dom-testing-library/blob/v10.4.1/src/role-helpers.js#L46-L66"><code>role-helpers.js</code> L46–L66</a> · <a href="https://github.com/testing-library/dom-testing-library/blob/v10.4.1/src/role-helpers.js#L15-L30">L15–L30</a></p>
</details>
<p>여기서 규칙이 드러납니다.</p>
<blockquote>
<p><strong>먼저 오는 필터가 86개 값을 다 치릅니다.</strong> 뒤에 오는 필터는 살아남은 것만 봅니다.</p>
</blockquote>
<p>그래서 3장의 &quot;순서가 이상하다&quot;에 답이 나옵니다. 순서를 뒤집어봤습니다.</p>























<table><thead><tr><th style="text-align:left">순서</th><th style="text-align:right">먼저 (86개)</th><th style="text-align:right">뒤에 (1개)</th><th style="text-align:right">합계</th></tr></thead><tbody><tr><td style="text-align:left">name → hidden (RTL 기본)</td><td style="text-align:right">256회</td><td style="text-align:right">5회</td><td style="text-align:right"><strong>261회</strong></td></tr><tr><td style="text-align:left">hidden → name (가정)</td><td style="text-align:right">176회</td><td style="text-align:right">1회</td><td style="text-align:right"><strong>177회</strong></td></tr></tbody></table>
<p>84회가 줄어듭니다. <strong>그런데 3분의 2는 그대로 남습니다.</strong> 누가 먼저 오든 86개를 다 훑어야 하니까요.</p>
<blockquote>
<p><strong>순서는 낭비를 만들지만, 비용을 만들지는 않습니다.</strong><br/>
비용은 &quot;스타일을 봐야 한다&quot;는 사실에서 나옵니다.<br/>
그래서 7장의 해법은 순서를 고치지 않습니다. 스타일 조회를 아예 없앱니다.</p>
</blockquote>
<p>이름 계산이 비싸다는 것까지는 알았습니다. 그런데 여기서 새로운 질문이 생깁니다.</p>
<blockquote>
<p>이름을 구하는 게 왜 그렇게 비싸지?<br/>
버튼 안에 있는 글자를 읽어오면 되는 거 아닌가?</p>
</blockquote>
<p>저도 그렇게 생각했습니다. 아니었습니다.</p>
<hr/>
<h2>원인 — 접근성 이름 계산과 jsdom의 getComputedStyle</h2>
<h3>5. 접근성 이름은 &quot;보이는 대로 읽은 텍스트&quot;입니다</h3>
<p>접근성 이름이 그냥 버튼 안의 글자라면 <code>textContent</code>와 같아야 합니다. 버튼을 하나 만들되 <strong>자식 하나를 <code>display: none</code>으로 숨겨두고</strong> 확인해봤습니다. <code>jsdom</code>과 <code>dom-accessibility-api</code>(RTL이 내부에서 쓰는 바로 그 라이브러리)만 있으면 직접 돌려볼 수 있습니다.</p>
<pre><code class="language-js">// npm i jsdom dom-accessibility-api
const {JSDOM} = require(&#x27;jsdom&#x27;);
const {computeAccessibleName} = require(&#x27;dom-accessibility-api&#x27;);

const dom = new JSDOM(`&lt;!doctype html&gt;&lt;body&gt;
  &lt;button id=&quot;a&quot;&gt;&lt;span style=&quot;display: none&quot;&gt;지난달&lt;/span&gt; &lt;span&gt;15일&lt;/span&gt;&lt;/button&gt;
&lt;/body&gt;`);

const button = dom.window.document.getElementById(&#x27;a&#x27;);

console.log(&#x27;textContent     :&#x27;, JSON.stringify(button.textContent.trim()));
console.log(&#x27;accessible name :&#x27;, JSON.stringify(computeAccessibleName(button)));
</code></pre>
<pre><code>textContent     : &quot;지난달 15일&quot;
accessible name : &quot;15일&quot;
</code></pre>
<p><strong>둘이 다릅니다.</strong> 숨겨둔 <code>&quot;지난달&quot;</code>이 접근성 이름에서만 빠졌습니다. 당연한 일이죠. 화면엔 <code>15일</code>만 보이는데 스크린리더가 &quot;지난달 15일&quot;이라 읽으면 틀린 정보니까요.</p>
<p>여기서 하나가 결정됩니다.</p>
<blockquote>
<p>접근성 이름을 구하려면 <strong>&quot;이 노드가 화면에 보이는가&quot;를 반드시 확인해야 한다.</strong><br/>
그리고 그걸 아는 방법은 CSS를 계산해보는 것뿐이다.</p>
</blockquote>
<p><code>display: none</code>이 인라인으로 왔는지, 클래스로 왔는지, 부모에게서 상속됐는지 알 수 없으니까요.</p>
<h4>게다가 자식 하나하나를 봐야 합니다</h4>
<p>확인 대상은 버튼 자신만이 아닙니다.<br/>
<code>dom-accessibility-api</code> 소스를 열어보면 그대로 있습니다.</p>
<pre><code class="language-js">// dom-accessibility-api v0.6.3 — sources/accessible-name-and-description.ts
function isHidden(node, getComputedStyleImplementation) {
  // ...
  const style = getComputedStyleImplementation(node);
  return (
    style.getPropertyValue(&#x27;display&#x27;) === &#x27;none&#x27; ||
    style.getPropertyValue(&#x27;visibility&#x27;) === &#x27;hidden&#x27;
  );
}

// 자식을 순회하면서
const display = isElement(child)
  ? getComputedStyle(child).getPropertyValue(&#x27;display&#x27;)
  : &#x27;inline&#x27;;
</code></pre>
<p><code>isHidden</code>이 스타일 조회를 건너뛰는 건 <code>hidden</code>·<code>aria-hidden</code> <strong>속성이 붙은 노드</strong>에서뿐입니다. 그 밖의 노드는 — 멀쩡히 보이는 노드까지 — CSS로 숨겨졌는지 알아내려고 <code>getComputedStyle</code>을 한 번씩 부릅니다. 4장의 256회가 전부 여기서 나옵니다. 다만 8장의 트레이드오프는 CSS로 숨긴 경우에만 발생합니다.</p>
<details>
<summary>출처 — dom-accessibility-api v0.6.3 (펼치기)</summary>
<p><a href="https://github.com/eps1lon/dom-accessibility-api/blob/v0.6.3/sources/accessible-name-and-description.ts#L70-L90"><code>isHidden</code> L70–L90</a> · <a href="https://github.com/eps1lon/dom-accessibility-api/blob/v0.6.3/sources/accessible-name-and-description.ts#L380-L382">자식 <code>display</code> L380–L382</a></p>
</details>
<p>두 번째 조각이 중요합니다. <strong>자식의 <code>display</code>까지</strong> 확인하죠. <code>block</code>이면 화면에서 줄이 바뀌니 단어 사이에 공백을 넣어야 하거든요.</p>
<p>즉 이름 계산은 버튼 하나가 아니라 <strong>그 안의 모든 노드에게</strong> 물어봅니다. 그래서 곱셈이 됩니다.</p>
<pre><code>&lt;button&gt;&lt;span&gt;15&lt;/span&gt;&lt;/button&gt;     ← 후보 하나에 3회
   │        │      └─ 자식 display 확인
   │        └─ 자식 isHidden
   └─ 버튼 자신 isHidden
</code></pre>
<p>4장에서 쟀던 256회가 이렇게 나온 겁니다. 후보 86개 중 트리거 1개는 자식이 텍스트뿐이라 1회, 나머지 날짜 버튼 85개가 3회씩. <strong>후보 하나당 약 3회</strong>입니다. 여기에 hidden 필터가 마지막에 부르는 5회를 더하면 261회가 됩니다.</p>
<p>그런데 아직 이상합니다.<br/>
호출이 좀 많다고 450ms가 나올까요? <code>getComputedStyle</code>이 그렇게 비싼 함수였나요?</p>
<p>네. jsdom에서는요.</p>
<hr/>
<h3>6. jsdom은 물어볼 때마다 스타일을 새로 조립합니다</h3>
<p>먼저 알아둘 게 하나 있습니다. <code>getComputedStyle</code>은 <strong>부분적으로 못 만듭니다.</strong></p>
<pre><code class="language-js">const display = getComputedStyle(el).display;
//              ^^^^^^^^^^^^^^^^^^^^ 표 전체를 만든 다음, 거기서 한 칸을 꺼낸다
</code></pre>
<p>개발자도구 Computed 탭에서 &quot;Show all&quot;을 켜면 건드린 적 없는 프로퍼티가 수백 개 나오죠. <code>display</code> 하나가 궁금해도 그 표 전체가 만들어집니다.</p>
<h4>jsdom은 그 표를 호출할 때마다 다시 만듭니다</h4>
<p>jsdom 구현을 열어봤습니다.</p>
<pre><code class="language-js">window.getComputedStyle = function (elt) {
  const declaration = new CSSStyleDeclaration();            // 1. 빈 객체를 새로 만들고
  const elementDeclaration = getDeclarationForElement(elt);  // 2. 캐스케이드를 계산해서
  forEach.call(elementDeclaration, p =&gt; declaration.setProperty(...));  // 3. 옮겨 담고
  forEach.call(Object.keys(propertiesWithResolvedValueImplemented),
    p =&gt; declaration.setProperty(p, getResolvedValue(elt, p)));         // 4. 최종값 계산
  return declaration;
};
</code></pre>
<p>2번의 캐스케이드에는 캐시가 있습니다. 그런데 그 바로 옆에 이런 코드가 있습니다.</p>
<pre><code class="language-js">exports.invalidateStyleCache = elementImpl =&gt; {
  if (elementImpl._attached) {
    elementImpl._ownerDocument._styleCache = null;   // 문서 전체 캐시를 통째로 버린다
  }
};
</code></pre>
<details>
<summary>출처 — jsdom v27.4.0 (펼치기 · jsdom 29에서는 이 코드가 옮겨졌습니다)</summary>
<p><a href="https://github.com/jsdom/jsdom/blob/v27.4.0/lib/jsdom/browser/Window.js#L908-L944"><code>Window.js</code> L908–L944</a> · <a href="https://github.com/jsdom/jsdom/blob/v27.4.0/lib/jsdom/living/helpers/style-rules.js#L149-L153"><code>style-rules.js</code> L149–L153</a></p>
<p>jsdom 29부터 이 둘은 <code>computed-style.js</code>와 <code>Document-impl.js</code>로 옮겨졌으니, 최신 버전을 열면 이 코드가 없습니다.</p>
</details>
<p><strong>DOM이 바뀌면 문서 전체의 스타일 캐시가 날아갑니다.</strong></p>
<p>테스트는 클릭하고, 입력하고, 리렌더하는 게 일입니다. 그러니까 테스트는 <strong>캐시를 계속 부수면서 진행됩니다.</strong></p>
<h4>여기서 2장의 의문이 풀립니다</h4>
<p>렌더는 20ms인데 쿼리는 450ms. 만드는 것보다 찾는 게 22배 비쌌던 그 이상함이요.</p>
<p>달력과 같은 규모로 버튼 85개를 두고, 스타일시트 규칙 2,000개를 얹어 네 구간을 재봤습니다.</p>
<pre><code>1. 렌더                    9.7 ms
2. 스타일 첫 조회 (콜드)  463.4 ms   ← 렌더의 47배
3. 재조회 (캐시 히트)       8.8 ms   ← 2번의 1/53
4. DOM 한 번 건드린 뒤    266.5 ms   ← 속성 하나 바꿨을 뿐인데 다시 비싸진다
</code></pre>
<p>이유는 이겁니다.</p>
<blockquote>
<p><strong>브라우저</strong>는 화면을 그려야 하니 <strong>렌더할 때</strong> 스타일을 계산해둡니다.<br/>
그래서 나중에 <code>getComputedStyle</code>을 부르면 이미 있는 걸 읽기만 합니다. 거의 공짜죠.</p>
<p><strong>jsdom은 화면을 안 그립니다.</strong> 렌더할 때 스타일을 계산할 이유가 없습니다.<br/>
그러다 누가 <code>getComputedStyle</code>을 부르면 <strong>그제야 처음으로</strong> 계산합니다.</p>
</blockquote>
<p><strong>비용 청구서가 반대편에 도착하는 겁니다.</strong></p>




















<table><thead><tr><th style="text-align:left"></th><th style="text-align:left">렌더</th><th style="text-align:left"><code>getComputedStyle</code></th></tr></thead><tbody><tr><td style="text-align:left">브라우저</td><td style="text-align:left">비용 지불</td><td style="text-align:left">거의 공짜</td></tr><tr><td style="text-align:left"><strong>jsdom</strong></td><td style="text-align:left"><strong>거의 공짜</strong></td><td style="text-align:left"><strong>비용 지불</strong></td></tr></tbody></table>
<p>그래서 2장에서 <code>render()</code>가 20ms로 찍힌 건 사실이었습니다. 달력 버튼 85개의 스타일 비용이 렌더 청구서에 실리지 않았을 뿐이고, 그 청구서는 처음으로 스타일을 물어본 쪽, 즉 <code>getByRole</code>에게 날아왔습니다.</p>
<h4>숫자를 맞춰봅시다</h4>
<p>astryx 환경에서 후보 버튼 하나를 처리하는 데 대략 <strong>5ms</strong>가 들었습니다. 5장에서 셌듯 후보 하나에 <code>getComputedStyle</code>이 약 세 번 불리니, 이 5ms는 그 세 번을 합친 값입니다.</p>
<pre><code>후보 86개 × 후보당 약 5ms ≈ 430ms ≈ 측정값 450ms
</code></pre>
<p>다른 걸 고쳐놓고 우연히 빨라지는 일은 생깁니다. 하지만 후보 하나의 비용에서 쌓아올린 값이 전체 측정치와 자릿수까지 맞으면, <strong>그건 우연으로 설명되지 않습니다.</strong></p>
<details>
<summary>위 4구간도 재현 환경 값입니다 — astryx 실측치와의 구분 (펼치기)</summary>
<p>원인을 눈으로 확인하려고 제가 따로 만든 재현 환경의 값입니다. astryx 실측치는 <code>450ms → 29ms</code>와 뒤에 나올 파일 단위 수치입니다.</p>
</details>
<hr/>
<h2>해결 — getComputedStyle을 걷어낸 헬퍼와 그 트레이드오프</h2>
<h3>7. 같은 알고리즘, 스타일 계산만 제거</h3>
<p>원인을 알았으니 방향은 정해집니다. <strong><code>getComputedStyle</code>을 안 부르면 됩니다.</strong> 문제는 어떻게 안 부르느냐입니다.</p>
<p>선택지를 셋 놓고 봤습니다.</p>
<ol>
<li><strong><code>getByTestId</code>로 갈아탄다</strong> — 빨라지지만 접근성 검증을 통째로 포기합니다. 디자인 시스템 테스트에서 이건 후퇴입니다. <strong>탈락.</strong></li>
<li><strong>이름 비교를 직접 구현한다</strong> — 5장에서 봤듯 접근성 이름은 <code>textContent</code>가 아닙니다. <code>aria-label</code>, <code>aria-labelledby</code>, 숨은 자식까지 얹히면 금세 스펙과 어긋납니다. RTL과 다른 답을 내는 헬퍼는 통과해도 믿을 수가 없습니다. <strong>탈락.</strong></li>
<li><strong>RTL이 쓰는 라이브러리를 그대로 쓰되 비용만 없앤다</strong> — <strong>채택.</strong></li>
</ol>
<p>그래서 조건을 이렇게 잡았습니다.</p>
<blockquote>
<p><strong>이름을 구하는 알고리즘은 RTL과 똑같이 유지한다.</strong><br/>
대신 그 알고리즘이 참조하는 <strong>스타일 계산만</strong> 갈아끼운다.</p>
</blockquote>
<p>다행히 <code>computeAccessibleName</code>은 <code>getComputedStyle</code> 구현을 <strong>주입받을 수 있게</strong> 열려 있었습니다.</p>
<p>실제로 머지된 코드입니다. (<a href="https://github.com/facebook/astryx/blob/6cfb542/packages/core/src/__tests__/fastRoleQueries.ts"><code>6cfb542</code>에서 보기</a>)</p>
<pre><code class="language-ts">// packages/core/src/__tests__/fastRoleQueries.ts — 핵심만 추린 것

// &quot;전부 다 보인다&quot;고만 대답하는 상수 스텁
const visibleStyleStub: Pick&lt;CSSStyleDeclaration, &#x27;getPropertyValue&#x27;&gt; = {
  getPropertyValue: prop =&gt;
    prop === &#x27;display&#x27; ? &#x27;block&#x27; : prop === &#x27;visibility&#x27; ? &#x27;visible&#x27; : &#x27;&#x27;,
};

function matchesName(el: Element, name: string | RegExp): boolean {
  const accessibleName = computeAccessibleName(el, {
    getComputedStyle: () =&gt; visibleStyleStub as CSSStyleDeclaration,
  });
  return typeof name === &#x27;string&#x27; ? accessibleName === name : name.test(accessibleName);
}

export function queryButton(name: string | RegExp): HTMLElement | null {
  return (
    screen
      .queryAllByRole(&#x27;button&#x27;, {hidden: true})
      .find(el =&gt; matchesName(el, name)) ?? null
  );
}

export function getButton(name: string | RegExp): HTMLElement {
  const el = queryButton(name);
  if (!el) {
    throw new Error(`Unable to find a role=button named ${String(name)}`);
  }
  return el;
}
</code></pre>
<p>장치는 셋입니다.</p>
<ol>
<li><strong><code>{hidden: true}</code></strong> — hidden 필터를 통째로 건너뜁니다. 가시성 검사용 <code>getComputedStyle</code>이 사라집니다.</li>
<li><strong>스타일 스텁 주입</strong> — 이름 계산 안의 <code>getComputedStyle</code>을 상수 응답으로 바꿉니다. 캐스케이드를 계산하는 대신 <code>&quot;block&quot;</code>을 즉시 돌려주니 비용이 0에 수렴합니다.</li>
<li><strong><code>filter</code>가 아니라 <code>find</code></strong> — RTL은 끝까지 다 돌지만 <code>.find()</code>는 찾으면 거기서 멈춥니다. 후보 86개를 끝까지 훑는 대신 첫 매치까지만 봅니다.</li>
</ol>
<p>호출부는 이렇게 바뀝니다.</p>
<pre><code class="language-diff">- const trigger = screen.getByRole(&#x27;button&#x27;, {name: /Range:/});
+ const trigger = getButton(/Range:/);
</code></pre>
<p>날짜 컴포넌트 4개 파일에서 <strong>41개 호출부</strong>를 교체했습니다. 호출부가 짧아진 덕에 테스트 파일 자체는 64줄을 더하고 96줄을 덜어 <strong>32줄 줄었지만</strong>, 헬퍼 66줄이 새로 생겼으니 저장소 전체로는 늘었습니다(<code>+134 −96</code>). (<a href="https://github.com/facebook/astryx/pull/3816/files">전체 diff</a>)</p>
<p>같은 파일의 <code>combobox</code>·<code>tooltip</code>·<code>grid</code> 쿼리는 <strong>그대로 뒀습니다.</strong> 후보가 몇 개 안 되거나 <code>name</code>을 안 쓰면 애초에 싸거든요. 비싼 곳만 바꿔야 팀이 표준 API 대신 사내 헬퍼를 익혀야 하는 부담이 줄어듭니다.</p>
<hr/>
<h3>8. 무엇을 포기했나</h3>
<p>공짜는 없습니다. 이 헬퍼는 <strong>두 가지</strong>를 포기했습니다.</p>
<p><strong>포기 1 — 유일성 검사.</strong> RTL의 <code>getByRole</code>은 조건에 맞는 요소가 두 개 이상이면 에러를 던집니다. 성가신 기능 같지만 &quot;이 이름의 버튼은 화면에 하나뿐이다&quot;를 테스트가 대신 확인해주는 안전장치죠. <code>.find()</code>는 첫 매치에서 멈추므로 그게 사라집니다. <strong>중복이 생겨도 테스트가 알려주지 않습니다.</strong></p>
<p><strong>포기 2 — 이름의 정확도.</strong> 스텁은 <code>display</code>에 항상 <code>&quot;block&quot;</code>이라 대답하니 CSS로 숨긴 노드도 &quot;보인다&quot;고 판정됩니다. 앞서 <code>textContent</code>와 갈렸던 그 버튼을 다시 보죠.</p>
<pre><code>&lt;button&gt;&lt;span style=&quot;display: none&quot;&gt;지난달&lt;/span&gt; &lt;span&gt;15일&lt;/span&gt;&lt;/button&gt;

RTL     : &quot;15일&quot;
이 헬퍼 : &quot;지난달 15일&quot;   ← 숨긴 텍스트가 이름에 섞여 들어온다
</code></pre>
<p>&quot;접근성 이름은 보이는 대로 읽은 텍스트&quot;라는 원칙이 여기서 깨집니다. <code>getComputedStyle</code> 비용을 없앤 대가로 <strong>&quot;보이는 대로&quot;를 포기한 셈</strong>입니다.</p>
<p>날짜 버튼은 <code>&lt;button&gt;&lt;span&gt;15&lt;/span&gt;&lt;/button&gt;</code> 정도로 단순해서 문제가 없었지만, <strong>다른 코드베이스에 옮길 때는 반드시 확인해야 합니다.</strong></p>
<p>그래서 헬퍼 파일 맨 위 주석에 트레이드오프를 명시해뒀습니다.</p>
<pre><code>Trade-off vs getByRole: first match wins — no tree-wide uniqueness check.
</code></pre>
<p>정리하면 이 헬퍼는 <strong>&quot;이 파일들에서, 이 조건에서&quot;</strong> 안전한 도구입니다.<br/>
범용 유틸리티가 아니라 국소 최적화죠. 그래서 테스트 폴더 안에만 두었습니다.</p>
<hr/>
<h2>성과와 적용 기준 — 34.3초에서 1.3초로</h2>
<h3>9. 결과</h3>
<p>날짜 컴포넌트 4개 파일, 211개 테스트의 실행 시간입니다.<br/>
아래 수치는 <a href="https://github.com/facebook/astryx/pull/3816">PR 본문</a>에 그대로 기록돼 있습니다.</p>















































<table><thead><tr><th style="text-align:left">파일</th><th style="text-align:right">테스트 수</th><th style="text-align:right">before</th><th style="text-align:right">after</th><th style="text-align:right">변화</th></tr></thead><tbody><tr><td style="text-align:left"><strong>DateRangeInput.test.tsx</strong></td><td style="text-align:right">34</td><td style="text-align:right">34.3s</td><td style="text-align:right"><strong>1.3s</strong></td><td style="text-align:right">−96%</td></tr><tr><td style="text-align:left">Calendar.test.tsx</td><td style="text-align:right">45</td><td style="text-align:right">7.2s</td><td style="text-align:right"><strong>1.9s</strong></td><td style="text-align:right">−74%</td></tr><tr><td style="text-align:left">DateInput.test.tsx</td><td style="text-align:right">68</td><td style="text-align:right">5.1s</td><td style="text-align:right"><strong>2.2s</strong></td><td style="text-align:right">−57%</td></tr><tr><td style="text-align:left">DateTimeInput.test.tsx</td><td style="text-align:right">64</td><td style="text-align:right">4.1s</td><td style="text-align:right"><strong>1.8s</strong></td><td style="text-align:right">−56%</td></tr><tr><td style="text-align:left"><strong>합계</strong></td><td style="text-align:right"><strong>211</strong></td><td style="text-align:right"><strong>50.7s</strong></td><td style="text-align:right"><strong>7.2s</strong></td><td style="text-align:right"><strong>−86%</strong></td></tr></tbody></table>
<p>처음의 그 파일은 <strong>34.3초에서 1.3초</strong>가 됐습니다. 약 <strong>26배</strong>입니다.</p>
<h4>그런데 분모를 봐야 합니다</h4>
<p>26배는 <strong>파일 하나</strong> 이야기입니다. 전체 스위트는 이렇습니다.</p>





























<table><thead><tr><th style="text-align:left">무엇</th><th style="text-align:right">before</th><th style="text-align:right">after</th><th style="text-align:right">변화</th></tr></thead><tbody><tr><td style="text-align:left">날짜 파일 4개</td><td style="text-align:right">50.7s</td><td style="text-align:right">7.2s</td><td style="text-align:right">−86%</td></tr><tr><td style="text-align:left">워커별 테스트 시간 합계</td><td style="text-align:right">297.7s</td><td style="text-align:right">235.1s</td><td style="text-align:right">−21%</td></tr><tr><td style="text-align:left"><strong>전체 벽시계 시간</strong></td><td style="text-align:right"><strong>105.9s</strong></td><td style="text-align:right"><strong>92.8s</strong></td><td style="text-align:right"><strong>−12%</strong></td></tr></tbody></table>
<p><strong>26배를 줄였는데 벽시계는 12%만 줄었습니다.</strong></p>
<p>테스트가 여러 워커에 나뉘어 병렬로 돌기 때문입니다. 느린 파일 하나를 34초에서 1.3초로 줄여도, 그 워커가 남는 동안 다른 워커가 여전히 자기 몫을 돌고 있으면 전체는 그만큼 안 줄어듭니다. 가장 오래 걸리는 경로가 따로 있는 거죠.</p>
<p>그러니 이 글의 26배는 <strong>&quot;이 병목은 확실히 제거됐다&quot;</strong> 는 뜻이지 <strong>&quot;CI가 26배 빨라졌다&quot;</strong> 는 뜻이 아닙니다.</p>
<p>여기서 강조하고 싶은 게 있습니다.</p>
<blockquote>
<p><strong>테스트가 확인하는 동작은 바뀌지 않았습니다.</strong><br/>
컴포넌트 코드도 각 테스트의 단언도 손대지 않았고, 전체 5,893개 테스트가 그대로 통과합니다.</p>
</blockquote>
<p>바꾼 건 <strong>요소를 찾는 방법</strong>입니다.<br/>
다만 8장에서 적었듯 그 방법에는 대가가 있으니, &quot;검증이 하나도 안 줄었다&quot;고까지 말하면 과장입니다. 유일성 검사가 쿼리에서 빠졌습니다.</p>
<p>그럼 남은 시간은 어디에 있을까요. 그건 쿼리가 아니라 워커에 일을 어떻게 나누느냐의 문제라, 이 글의 범위 밖입니다.</p>
<hr/>
<h3>10. 그래서 언제 이걸 의심해야 할까요</h3>
<p>여기까지 읽고 <code>getByRole</code>을 걷어내지는 마세요. 대부분의 경우 몇 밀리초면 끝납니다. <strong>문제가 되는 건 특정 조건이 겹칠 때뿐입니다.</strong></p>
<blockquote>
<p><strong>RTL이 잘못한 게 아닙니다.</strong><br/>
접근성 이름을 스펙대로 정확히 계산하는 것이 RTL의 존재 이유입니다.<br/>
비싼 쪽은 jsdom의 <code>getComputedStyle</code>이고, <strong>실제 브라우저에서는 이 비용이 거의 없습니다.</strong></p>
</blockquote>
<p>우리가 한 일은 RTL을 고친 게 아니라, <code>{hidden: true}</code>가 허용하는 범위 안에서 <strong>테스트 환경에서만 발생하는 비용</strong>을 걷어낸 것입니다.</p>
<h4>의심 조건</h4>
<p>이 네 가지가 동시에 성립하면 확인해볼 만합니다.</p>
<ol>
<li><strong>role 후보가 수십 개 이상 마운트되어 있다</strong> — 달력 그리드, 긴 목록, 데이터 테이블</li>
<li><strong>그중 상당수가 화면에 안 보인다</strong> — 닫힌 팝오버, 접힌 아코디언, 숨긴 탭 패널</li>
<li><strong><code>{name}</code> 옵션을 쓴다</strong> — 이름 계산이 켜지는 스위치입니다</li>
<li><strong>테스트가 자주 리렌더한다</strong> — jsdom의 스타일 캐시가 계속 무효화됩니다</li>
</ol>
<p>날짜 컴포넌트가 느렸던 건 이 넷이 <strong>전부</strong> 겹쳤기 때문입니다.<br/>
버튼이 다섯 개인 평범한 컴포넌트라면 아무 문제 없습니다.</p>
<h4>확인하는 법</h4>
<p>두 쿼리를 나란히 재보면 됩니다. 복사해서 느린 테스트에 붙여넣으면 바로 나옵니다.</p>
<pre><code class="language-js">const t0 = performance.now();
screen.getByRole(&#x27;button&#x27;, {name: &#x27;내가 찾는 버튼&#x27;});
const t1 = performance.now();
screen.getAllByRole(&#x27;button&#x27;, {hidden: true});
const t2 = performance.now();

console.log(&#x27;name 포함 :&#x27;, (t1 - t0).toFixed(0), &#x27;ms&#x27;);
console.log(&#x27;name 제외 :&#x27;, (t2 - t1).toFixed(0), &#x27;ms&#x27;);
</code></pre>
<p>두 숫자가 비슷하면 이 글과 무관한 문제입니다. <strong>차이가 10배 이상 나면</strong> 이름 계산이 원인입니다.</p>
<h4>전용 헬퍼는 마지막 선택지입니다</h4>

























<table><thead><tr><th style="text-align:left">순서</th><th style="text-align:left">방법</th><th style="text-align:left">언제</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left"><strong>안 보이는 DOM을 애초에 안 그린다</strong></td><td style="text-align:left">팝오버가 닫혔을 때 언마운트할 수 있다면. 가장 근본적입니다</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left"><strong><code>within()</code>으로 탐색 범위를 좁힌다</strong></td><td style="text-align:left">구조를 못 바꿀 때. 후보 수 자체가 줄어듭니다</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left"><strong>전용 헬퍼를 만든다</strong></td><td style="text-align:left">위 둘이 불가능할 때. 이 글의 방법입니다</td></tr></tbody></table>
<p>이번에는 1번을 고르지 않았습니다. 컴포넌트 동작을 바꾸는 일이라 테스트만 손보려던 범위를 넘었거든요. 2번은 찾으려는 요소와 숨은 요소가 같은 컨테이너에 있으면 효과가 없습니다.</p>
<p><strong>3번이 첫 번째 선택지가 되면 안 됩니다.</strong> 표준 API를 떠나는 건 팀이 치르는 비용이니까요.</p>
<hr/>
<h2>마치며</h2>
<p><code>getByRole(&#x27;button&#x27;, {name})</code>은 제게 &quot;이름이 이런 버튼을 찾아줘&quot;라는 한 문장이었습니다.<br/>
열어보니 <code>.filter()</code> 다섯 개가 줄줄이 있었고, 그중 하나가 트리 전체를 훑고 있었습니다.</p>
<p>편의는 공짜가 아니라 <strong>누군가 대신 내주고 있는 비용</strong>이었습니다.<br/>
평소에는 그 비용이 충분히 작아서 안 보일 뿐이고요.</p>
<p>그러니 도구가 이상하게 느리다면, 한 번쯤 열어보시길 권합니다.<br/>
<code>node_modules</code> 안에 답이 있는 경우가 생각보다 많습니다.</p>
<hr/>
<p><strong>이 글의 근거</strong></p>
<ul>
<li>PR: <a href="https://github.com/facebook/astryx/pull/3816">facebook/astryx#3816</a> (2026-07-12 머지) · <a href="https://github.com/facebook/astryx/pull/3816/files">전체 diff</a></li>
<li>머지된 헬퍼: <a href="https://github.com/facebook/astryx/blob/6cfb542/packages/core/src/__tests__/fastRoleQueries.ts"><code>fastRoleQueries.ts</code></a> (<code>6cfb542</code> — 이 글이 인용한 버전)</li>
<li>인용한 라이브러리 소스는 본문 각 코드블록 아래 출처에 버전과 줄 범위를 고정해 달아두었습니다.</li>
<li>더 읽을거리: <a href="https://www.w3.org/TR/accname/">Accessible Name and Description Computation (W3C)</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>TypeScript 6 업그레이드인 줄 알았는데, 문제는 &quot;설정&quot;이었습니다</title>
      <link>https://blog.sangwook.dev/posts/typescript-6-migration-troubleshooting/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/typescript-6-migration-troubleshooting/</guid>
      <pubDate>Wed, 01 Jul 2026 15:00:00 GMT</pubDate>
      <description>TypeScript 6으로 올리자 baseUrl·rootDir·types 기본값이 차례로 빌드를 깨뜨렸다. 각 변경의 &quot;왜&quot;를 microsoft/TypeScript PR diff까지 추적해 보니, 버전을 올리는 이야기가 아니라 &quot;올바른 설정&quot;에 도달하는 이야기였다.</description>
      <content:encoded><![CDATA[<p>TypeScript 6 릴리스 노트의 breaking change 목록을 여는데, 매일 tsconfig에 적던 이름들이 줄줄이 눈에 들어왔다 — <code>baseUrl</code>, <code>types</code>, <code>rootDir</code>. 멀쩡히 쓰던 옵션들을 6은 왜 굳이 건드리는 걸까?</p>
<p>그 <code>왜</code>가 궁금해서, 릴리스 노트만 훑고 넘기는 대신 마침 굴리던 개인 모노레포에 6을 먼저 올려 하나씩 따라 바꿔봤다.</p>
<p>따라가 보니 이건 단순한 &#x27;6의 새 규칙들&#x27;이 아니었다. 세 변경을 관통하는 줄기는 하나 — 비싸거나 모호한 기본 동작을 걷어내는 &#x27;청소&#x27;. 다만 그 청소가 향하는 곳은 셋이 제각각이었다.</p>
<p>그래서 &#x27;6 업그레이드 기록&#x27;으로 시작한 이 글은, 따라가다 보니 정체가 달라졌다. 손에 남은 건 버전 숫자가 아니라 <em>&#x27;그래서 무엇이 올바른 설정인가&#x27;</em> 라는 질문이었다.</p>
<h2>0. TL;DR</h2>
<p>이 글은 Turborepo + pnpm 워크스페이스 모노레포에서 TypeScript <code>5.8.3 → 6.0.3</code>으로 올리며 만난 breaking change들을, <strong>&quot;왜 그렇게
바뀌었는지&quot;를 릴리스 노트 → 이슈 → 실제 PR diff까지 따라가며</strong> 정리한 기록이다. 아래 표가 그 전부다 — 그 변경의 이유, 해결책 한 줄씩. 바쁘면 표만
보고 가도 된다.</p>



































<table><thead><tr><th style="text-align:left">#</th><th style="text-align:left">에러</th><th style="text-align:left">왜 TS6가 이렇게 바꿨나</th><th style="text-align:left">해결</th></tr></thead><tbody><tr><td style="text-align:left">#1</td><td style="text-align:left"><code>TS5101: &#x27;baseUrl&#x27; is deprecated</code></td><td style="text-align:left">baseUrl의 숨은 2번째 역할(bare specifier look-up root)이 런타임에 안 맞는 import를 통과시킴 → deprecate (<a href="https://github.com/microsoft/TypeScript/pull/62509">#62509</a>)</td><td style="text-align:left">직접 쓴 곳(react 앱)의 <code>baseUrl</code> 제거 (paths는 4.1부터 baseUrl 불필요)</td></tr><tr><td style="text-align:left">#2</td><td style="text-align:left"><code>TS5011: &#x27;rootDir&#x27; must be set</code></td><td style="text-align:left">추론된 <code>rootDir</code>는 입력 파일 집합에 따라 흔들려 출력 레이아웃이 비결정적 → tsconfig 디렉터리로 고정 (<a href="https://github.com/microsoft/TypeScript/pull/62418">#62418</a>)</td><td style="text-align:left"><code>&quot;rootDir&quot;: &quot;./src&quot;</code> 한 줄</td></tr><tr><td style="text-align:left">#3</td><td style="text-align:left">&quot;빌드는 되는데 타입이 빠진 것 같은데?&quot;</td><td style="text-align:left"><code>types</code> 기본값이 &quot;모든 <code>@types</code> 자동 포함&quot; → <code>[]</code>. flattened <code>node_modules</code>에서 수백 개가 전이로 끌려와 빌드 20–50% 낭비 (<a href="https://github.com/microsoft/TypeScript/pull/63054">#63054</a>)</td><td style="text-align:left">전역이 필요한 곳은 이미 <code>types</code> 명시돼 있었음</td></tr><tr><td style="text-align:left">보너스</td><td style="text-align:left">빌드 때 <strong>안 쓴</strong> baseUrl로 또 <code>TS5101</code></td><td style="text-align:left">tsup이 dts 빌드에 <code>baseUrl || &#x27;.&#x27;</code>를 <strong>주입</strong>하는데, tsup은 이미 유지보수 중단</td><td style="text-align:left"><strong>tsup → tsdown 전환</strong></td></tr></tbody></table>
<p>먼저 한 가지 정정부터. 위 표의 <code>TS5101</code>은 baseUrl <strong>전용 에러가 아니다.</strong> TypeScript의 <strong>범용 &quot;deprecated option&quot; 진단</strong>이고,
6.0이 baseUrl을 그 경로에 태웠을 뿐이다. 이 디테일이 왜 중요한지는 #1에서 PR diff로 확인한다.</p>
<hr/>
<h2>1. baseUrl은 왜 deprecated 되었나 (TS5101)</h2>
<h3>마주침: 이미 알던 손님</h3>
<p><code>baseUrl</code>이 deprecated된다는 건 이미 알고 있었다. 내 코드에서 <code>baseUrl</code>을 실제로 쓰는 곳은 React 앱 하나뿐이었고, <code>paths</code>의 접두사 용도였다.</p>
<pre><code class="language-json">// apps/react/tsconfig.app.json
{
  &quot;compilerOptions&quot;: {
    &quot;moduleResolution&quot;: &quot;bundler&quot;,
    // &quot;baseUrl&quot;: &quot;.&quot;,   ← 삭제
    &quot;paths&quot;: {
      &quot;@/*&quot;: [&quot;./src/*&quot;],
      &quot;@pages/*&quot;: [&quot;./src/pages/*&quot;]
      // ...
    }
  }
}
</code></pre>
<p>그냥 지웠다. <code>paths</code>는 <a href="https://www.typescriptlang.org/tsconfig/baseUrl.html">TypeScript 4.1부터<code>baseUrl</code> 없이 동작</a>한다.</p>
<blockquote>
<p>&quot;As of TypeScript 4.1, <code>baseUrl</code> is no longer required to be set when using <code>paths</code>.&quot;</p>
</blockquote>
<h3>왜 deprecated 됐나: 아무도 모르던 baseUrl의 두 번째 역할</h3>
<p>해결은 쉬웠지만, <strong>왜</strong> 멀쩡히 동작하던 옵션을 6.0이 하드 에러로 막는지가 궁금했다. <a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html">릴리스 노트</a>와 도입 이슈 <a href="https://github.com/microsoft/TypeScript/issues/62207">#62207</a>을 보면 이유가 명확하다. baseUrl은 두 가지 역할을 한다.</p>
<blockquote>
<p>&quot;Today, <code>baseUrl</code> performs two functions:</p>
<ul>
<li>it acts as a prefix for all entries in <code>paths</code></li>
<li>it acts as a potential resolution point for all bare paths</li>
</ul>
<p>But almost nobody realizes that last
part.&quot; — <a href="https://github.com/microsoft/TypeScript/issues/62207">issue #62207</a></p>
</blockquote>
<p>문제는 두 번째, 인용문이 말한 _&quot;bare paths&quot;_다. baseUrl이 켜져 있으면 <code>./</code>로 시작하지 않는 import까지 — 예컨대 <code>import { Button } from &quot;components/Button&quot;</code>처럼 <strong>패키지인지 내 파일인지 모호한 경로</strong>까지 — baseUrl 폴더 안에서 찾아준다. 그 바람에 <strong>번들러/런타임에선 절대 동작하지
않을 import를 타입체커만 &quot;괜찮다&quot;고 통과</strong>시킨다.</p>
<blockquote>
<p>&quot;...it often meant that many import paths that would never have worked at runtime are considered
&quot;just fine&quot; by TypeScript.&quot; — 릴리스 노트</p>
</blockquote>
<p>즉 첫 번째 역할(paths 접두사)은 4.1 이후 <code>paths</code>가 직접 대체할 수 있으니, 위험한 두 번째 역할을 없애기 위해 baseUrl 자체를 걷어내는 것이다. 7.0에서는
아예 제거된다.</p>
<blockquote>
<p>&quot;In TypeScript 7.0, we are not reimplementing <code>baseUrl</code>. ... In TypeScript 6.0, we will be
deprecating this behavior. Using <code>baseUrl</code> will lead to an error which can only be resolved by
applying one of the above fixes, or using
<code>--ignoreDeprecations</code>.&quot; — <a href="https://github.com/microsoft/TypeScript/issues/62207">issue #62207</a></p>
</blockquote>
<h3>사실 이 분리는 6년 전에 시작됐다</h3>
<p>그런데 &quot;두 역할을 떼어낸다&quot;는 발상은 6.0이 처음이 아니다. 이미 2019년, 한 사용자가 이슈 <a href="https://github.com/microsoft/TypeScript/issues/31869">#31869</a>에서 똑같은 불편을 제기했다 — <code>paths</code>는 타입체킹용 별칭으로만 쓰고 싶은데, baseUrl이 딸려 보내는 <em>두 번째 역할(bare 이름의 암묵적 해석)</em> 까지 떠안긴 싫다는 것이었다.</p>
<p>이 요청은 PR <a href="https://github.com/microsoft/TypeScript/pull/40101">#40101</a>로 구현돼 <strong>4.1(2020년)</strong> 에 출시됐고, 그때부터 baseUrl 없이도 <code>paths</code>가 동작한다.</p>
<p>그러니 6.0의 deprecation은 갑자기 튀어나온 breaking change가 아니다. <strong>2019년 문제 제기(#31869) → 2020년 4.1 구현(#40101) → 2026년 6.0 정리(#62207·#62509)</strong> — baseUrl의 두 역할을 떼어내는 일은 6년에 걸친 청소의 마지막 단계인 셈이다.</p>
<h3>구현 레벨: TS5101은 baseUrl 전용이 아니다</h3>
<p>여기서 PR <a href="https://github.com/microsoft/TypeScript/pull/62509">#62509 &quot;Deprecate baseUrl&quot;</a>의 실제 diff를 보면
재미있는 사실이 드러난다. baseUrl을 위한 <strong>새 에러 코드는 만들어지지 않았다.</strong> 기존 범용 deprecation 진단(우리가 보는 <code>TS5101</code>)을 재사용하고, 거기에
마이그레이션 안내용 신규 메시지(코드 <code>5111</code>)만 체이닝한다.</p>
<p>먼저 타입 선언에 <code>@deprecated</code>를 단다.</p>
<pre><code class="language-diff">// src/compiler/types.ts
   alwaysStrict?: boolean;
+  /** @deprecated */
   baseUrl?: string;
</code></pre>
<p>그리고 핵심은 <code>program.ts</code>의 <code>checkDeprecations(&quot;6.0&quot;, &quot;7.0&quot;, ...)</code> 블록이다. 여기에 baseUrl 분기가 추가됐다.</p>
<pre><code class="language-diff">// src/compiler/program.ts
 checkDeprecations(&quot;6.0&quot;, &quot;7.0&quot;, createDiagnostic, createDeprecatedDiagnostic =&gt; {
     if (options.moduleResolution === ModuleResolutionKind.Node10) {
-        createDeprecatedDiagnostic(&quot;moduleResolution&quot;, &quot;node10&quot;);
+        createDeprecatedDiagnostic(&quot;moduleResolution&quot;, &quot;node10&quot;, /*useInstead*/ undefined,
+            Diagnostics.Visit_https_aka_ms_ts6_for_migration_information);
+    }
+    if (options.baseUrl !== undefined) {
+        createDeprecatedDiagnostic(&quot;baseUrl&quot;, /*value*/ undefined, /*useInstead*/ undefined,
+            Diagnostics.Visit_https_aka_ms_ts6_for_migration_information);
     }
 });
</code></pre>
<p><code>checkDeprecations(&quot;6.0&quot;, &quot;7.0&quot;, ...)</code> — 6.0에서 deprecate, 7.0에서 제거라는 의미가 함수 인자에 그대로 박혀 있다. 그래서 우리가
보는 에러는 이렇게 생겼다.</p>
<pre><code class="language-text">error TS5101: Option &#x27;baseUrl&#x27; is deprecated and will stop functioning in TypeScript 7.0.
  Specify compilerOption &#x27;&quot;ignoreDeprecations&quot;: &quot;6.0&quot;&#x27; to silence this error.
  Visit https://aka.ms/ts6 for migration information.
</code></pre>
<p><code>TS5101</code>은 &quot;Option &#x27;{0}&#x27; is deprecated...&quot;라는 <strong>범용 메시지</strong>의 코드고, <code>{0}</code>에 <code>baseUrl</code>이, 안내 링크(
<code>Visit https://aka.ms/ts6 ...</code>)에 신규 코드 <code>5111</code>이 related로 붙은 것이다. 그러니 <code>ignoreDeprecations: &quot;6.0&quot;</code>으로
한시적으로 끌 수는 있지만, 그건 그저 문제를 7.0으로 미루는 일이다.</p>
<p>참고로 에러가 가리키는 <code><a href="https://aka.ms/ts6">https://aka.ms/ts6</a></code>은 마이그레이션 가이드
이슈(<a href="https://github.com/microsoft/TypeScript/issues/62508">#62508</a>)로 리다이렉트되는데, 이 글을 쓰는 시점엔
본문이 아직 <code>Placeholder</code>만 들어 있는 빈 페이지다. 에러는 친절하게 안내하지만 정작 안내처는 공사 중인 셈.</p>
<h3><code>paths</code>도 끝이 아니다: 진짜 대안은 package.json <code>imports</code></h3>
<p><code>baseUrl</code>을 지우고 <code>paths</code>로 갈아탔지만, <code>paths</code>도 완전한 답은 아니다. <strong><code>paths</code>는 타입체커 전용</strong>이라 <code>tsc</code>가 내보내는 JS엔 반영되지 않아,
<code>baseUrl</code>이 6.0에서 막힌 그 이유(런타임에 안 맞는 import를 타입체커만 통과)와 정도만 다를 뿐 같은 괴리를 안는다. 그 괴리가 구조적으로 없는 대안이 *
<em>Node가 런타임에 직접 읽는 package.json <code>imports</code></em>* (<code>#</code> subpath)다 — 타입과 런타임이 같은 매핑을 보니 어긋날 수 없고, TS 핸드북도
이를 <em>&quot;a standard replacement for convenience <code>paths</code> aliases&quot;</em> 로 부른다.</p>
<details>
<summary>옮길 때 알아둘 것 — 설정·버전·번들러 (펼치기)</summary>
<pre><code class="language-json">// package.json — tsconfig paths 대신 여기에
{
  &quot;imports&quot;: {
	&quot;#/*&quot;: &quot;./src/*&quot;
  }
}
</code></pre>
<pre><code class="language-ts">import { foo } from &#x27;#/utils/foo&#x27;; // 타입도, Node 런타임도 같은 매핑으로 해석
</code></pre>
<ul>
<li><strong>TypeScript는 4.7부터</strong> <code>imports</code>를 해석하고, <code>moduleResolution</code>이 <code>node16</code>·<code>nodenext</code>·<code>bundler</code>일 때 동작한다(
레거시 <code>node</code>는 안 된다).</li>
<li><strong><code>#</code>는 강제다.</strong> <code>@/</code> 같은 임의 별칭은 못 쓴다. 다만 <strong><code>#/</code> prefix는 TS 6.0 + Node 20부터</strong> 허용돼 <code>@/</code>에 가까운 컨벤션을 쓸 수
있다 — 단 <code>#/</code> 패턴은 <code>nodenext</code>·<code>bundler</code>에서만이고 <code>node16</code>은 제외다.</li>
<li><strong>번들러 지원은 제각각.</strong> Vite 4.2+, esbuild 0.13.9+는 지원하고, Jest는 네이티브로 안 돼 <code>moduleNameMapper</code>가 필요하다.</li>
<li>참고로 <code>baseUrl</code> deprecation 이슈(#62207·#62508) 자체는 <code>imports</code>가 아니라 &quot;prefix를 <code>paths</code>에 직접 박기&quot;를 권한다.
<code>imports</code>를 표준 대체재로 부르는 건 핸드북·릴리스 노트 쪽 라인이다.</li>
</ul>
<blockquote>
</blockquote>
<p>출처: <a href="https://www.typescriptlang.org/docs/handbook/modules/reference.html">TS 핸드북 Modules Reference</a> · <a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html">TS 4.7</a> · <a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html">TS 6.0 릴리스 노트</a></p>
</details>
<hr/>
<h2>2. rootDir를 명시하라 (TS5011)</h2>
<h3>마주침: 빌드의 dts emit 단계</h3>
<p><code>@package/sample-lib</code>의 빌드는 <code>minibundler &amp;&amp; tsc --emitDeclarationOnly</code> 두 단계다. 앞 단계(번들)는 통과했는데 <code>tsc</code>가
깨졌다.</p>
<pre><code class="language-text">tsconfig.json:10:5 - error TS5011: The common source directory of &#x27;tsconfig.json&#x27;
  is &#x27;./src&#x27;. The &#x27;rootDir&#x27; setting must be explicitly set to this or another path
  to adjust your output&#x27;s file layout.
  Visit https://aka.ms/ts6 for migration information.
</code></pre>
<p>해결은 한 줄.</p>
<pre><code class="language-json">// packages/@package/sample-lib/tsconfig.json
{
  &quot;compilerOptions&quot;: {
    &quot;emitDeclarationOnly&quot;: true,
    &quot;outDir&quot;: &quot;./dist&quot;,
    &quot;rootDir&quot;: &quot;./src&quot;, // ← 추가
  }
}
</code></pre>
<p>그런데 방금 추가한 <code>rootDir: &quot;./src&quot;</code> 한 줄은 정확히 뭘 할까? <code>rootDir</code>은 <strong>출력 폴더(<code>dist</code>)의 모양을 어디서부터 베낄지</strong> 정하는 &#x27;깃발&#x27;이라
보면 된다. tsc는 깃발 <strong>아래</strong>의 폴더 구조를 그대로 <code>dist</code>에 복제한다.</p>
<pre>

my-lib/
  tsconfig.json
  src/
    index.ts
    utils.ts
</pre>
<p>깃발을 <code>src/</code>에 꽂으면 → <code>dist/index.js</code>, <code>dist/utils.js</code>. 한 칸 위(<code>my-lib/</code>)에 꽂으면 → <code>dist/src/index.js</code>로 <code>src/</code>가 딸려
들어온다. <strong>위치 한 끗이 산출물 모양을 가른다.</strong></p>
<h3>왜 추론하던 걸 이제 명시하라고 하나</h3>
<p><a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html">릴리스 노트</a>와
이슈 <a href="https://github.com/microsoft/TypeScript/issues/62194">#62194</a>의 논거는 두 가지다.</p>
<blockquote>
<p>&quot;Previously, if you did not specify a <code>rootDir</code>, it was inferred based on the common directory of
all non-declaration input files. But this often meant that it was impossible to know if a file
belonged to a project without trying to load and parse that project. It also meant that TypeScript
had to spend more time inferring that common source directory by analyzing every file path in the
program.&quot; — 릴리스 노트</p>
</blockquote>
<p>요점은 <strong>결정성</strong>이다. 옛 TS는 깃발을 &quot;내 파일을 전부 담는 가장 깊은 공통 폴더&quot;에 <strong>자동으로</strong> 꽂았다. 위 예제는 파일이 다 <code>src/</code>에 있으니
깃발은 <code>src/</code>. 그런데 루트에 파일 하나(<code>my-lib/build.ts</code>)만 더하면 공통 폴더가 <code>my-lib/</code>로 <strong>점프</strong>하고, <code>dist/index.js</code>였던 출력이
통째로 <code>dist/src/index.js</code>로 밀린다. <strong>파일 하나 더했을 뿐인데 산출물 구조가 바뀐다</strong> — 이 들쭉날쭉함이 비결정성이다.</p>
<p>비용은 하나 더 있다. &quot;이 파일이 어느 <code>tsconfig</code> 소속인가&quot;를 알려면 그 공통 폴더부터 계산하느라 프로젝트를 통째로 로드·파싱해야 했다. 깃발을
<code>tsconfig.json</code> 폴더(configDir)에 못 박으면 이걸 <strong>경로만 보고 즉답</strong>한다 — 제안 이슈의 표현 그대로 <em>&quot;trivially determine whether
a file could belong to another <code>tsconfig.json</code>&quot;</em>
(<a href="https://github.com/microsoft/TypeScript/issues/62194">#62194</a>). 1장 baseUrl이 7.0에서 <em>아예 사라지는</em> 동작이었던 것과
달리, rootDir은 동작은 남고 그 <strong>계산만 입력 파일 목록에서 떼어낸</strong> 것이다. 그래도 향하는 곳은 같다 — Go로 다시 쓴 7.0(<code>tsgo</code>)의 빠른 언어
서비스다.</p>
<p>그럼 왜 기본값만 슬쩍 바꾸지 않고 굳이 <strong>에러</strong>를 던질까? 기본 위치를 말없이 옮기면 빌드는 성공하는데 산출물이 <code>dist/file.js</code>에서
<code>dist/src/file.js</code>로 소리 없이 밀리고, 그걸 <code>import</code>하던 패키지가 영문도 모르고 깨진다. 조용한 사고보다 시끄러운 멈춤이 낫다 — 그래서 옛 추론값과
configDir이 어긋나면 <strong>멈추고 명시를 요구한다</strong>(그 비교 로직이 다음 절의 <code>TS5011</code>이다).</p>
<p>충격은 모노레포에서 더 컸다. 구현 PR의 생태계 테스트에서 mui-docs는 에러가 <strong>0 → 11,385개</strong>로 튀었고, TS 팀의 jakebailey도 <em>&quot;Ouch,
looking bad for pyright and mui-docs&quot;</em> 라 적었다. 원인은 rootDir을 추론에 맡긴 채 프로젝트 참조 대신 <em>&quot;every project emit each
other&#x27;s files into their own dist&quot;</em> 하던 구조였다(<a href="https://github.com/microsoft/TypeScript/pull/62418">#62418</a>).</p>
<p>여기서 증상이 갈린다. <strong>같은 변경(기본 rootDir = <code>configDir</code>)인데, 깃발이 어디로 가느냐에 따라 두 얼굴</strong>로 나타난다.</p>

























<table><thead><tr><th style="text-align:left"></th><th style="text-align:left">내 경우 (작은 라이브러리)</th><th style="text-align:left">모노레포 (mui 같은)</th></tr></thead><tbody><tr><td style="text-align:left">소스 위치</td><td style="text-align:left">전부 <code>src/</code> <strong>안</strong></td><td style="text-align:left">다른 패키지(루트 <strong>밖</strong>) 파일을 끌어다 씀</td></tr><tr><td style="text-align:left">깃발을 옮기면</td><td style="text-align:left">출력 위치만 밀림</td><td style="text-align:left">파일이 새 깃발 <strong>바깥</strong>에 놓임</td></tr><tr><td style="text-align:left">터지는 에러</td><td style="text-align:left"><strong><code>TS5011</code></strong> (&quot;rootDir 명시해&quot;)</td><td style="text-align:left"><strong><code>TS6059</code></strong> (&quot;이 파일 rootDir 밖이야&quot;)</td></tr></tbody></table>
<p>뿌리는 하나 — 깃발을 자동 추측에서 <code>tsconfig</code> 폴더로 못 박은 것이다. 내 프로젝트는 &#x27;출력이 밀리는&#x27; 얼굴로, 큰 모노레포는 &#x27;파일이 루트 밖으로 튕겨나가는&#x27;
얼굴로 나타났을 뿐이다.</p>
<h3>구현 레벨: 한 줄짜리 조건 완화 + 신·구 비교</h3>
<p>PR <a href="https://github.com/microsoft/TypeScript/pull/62418">#62418 &quot;Assume rootDir is the current configuration directory&quot;</a>
은 81개 파일을 건드린 큰 PR이지만, 핵심 로직은 <strong>동일한 한 줄이 세 군데에서 완화된 것</strong>이다.</p>
<pre><code class="language-diff">// src/compiler/emitter.ts — getCommonSourceDirectory()
-    else if (options.composite &amp;&amp; options.configFilePath) {
+    else if (options.configFilePath) {
         // Project compilations never infer their root from the input source paths
         commonSourceDirectory = getDirectoryPath(normalizeSlashes(options.configFilePath));
</code></pre>
<p>예전엔 <code>composite</code> 프로젝트만 &quot;configDir를 공통 소스 디렉터리로&quot; 썼는데, 이제 <code>configFilePath</code>만 있으면(=tsconfig 기반 빌드면) 항상
그렇게 한다. 같은 패턴이 <code>utilities.ts</code>, <code>moduleNameResolver.ts</code>에도 동일하게 적용됐다.</p>
<p>그럼 TS5011은 언제 던지나? <code>program.ts</code>에 새로 추가된 블록이 <strong>옛 방식과 새 방식의 공통 디렉터리를 비교</strong>해서, 출력 레이아웃이 달라질 때만 에러를 낸다(
아래는 핵심만 발췌·정리).</p>
<pre><code class="language-diff">// src/compiler/program.ts
+if (!options.noEmit &amp;&amp; !options.composite &amp;&amp; !options.rootDir &amp;&amp; options.configFilePath &amp;&amp;
+    (options.outDir || (getEmitDeclarations(options) &amp;&amp; options.declarationDir) || options.outFile)) {
+    const dir   = getCommonSourceDirectory();            // 새 방식: configDir 기준
+    const files = mapDefined(/* 실제 emit될 입력 파일들 */);
+    const dir59 = getComputedCommonSourceDirectory(files, /* ... */);  // 옛 방식: 입력 파일 기준
+    if (dir59 !== &quot;&quot; &amp;&amp; getCanonicalFileName(dir) !== getCanonicalFileName(dir59)) {
+        // 레이아웃이 바뀐다 → TS5011
+        createDiagnosticForOption(/* ... */
+            Diagnostics.The_common_source_directory_of_0_is_1_The_rootDir_setting_must_be_explicitly_set_...);
+    }
+}
</code></pre>
<p>여기서 에러 메시지의 친절함이 설명된다. 메시지의 <code>&#x27;{1}&#x27;</code>(=<code>&#x27;./src&#x27;</code>)은 바로 <code>dir59</code> — <strong>옛 방식으로 계산한 공통 디렉터리</strong>다. 우리
sample-lib는 소스가 <code>./src</code>에 있어 옛 추론값이 <code>./src</code>였는데, 새 기본값은 tsconfig 위치(<code>.</code>)라 둘이 어긋난다. 그래서 6.0은 &quot;전엔
<code>./src</code>로 잡혔으니, 그 값을 <code>rootDir</code>에 명시하라&quot;고 <strong>정답을 알려주며</strong> 멈춘 것이다. <code>rootDir: &quot;./src&quot;</code>는 그 안내를 그대로 따른 것뿐이다.</p>
<hr/>
<h2>3. types 기본값이 []가 된 이유 (그리고 왜 우리 빌드는 멀쩡했나)</h2>
<h3>의심: 타입이 다 빠졌어야 하는 거 아닌가?</h3>
<p>baseUrl도 rootDir도 빨간 에러로 멈춰 세웠는데, types는 이상하리만치 조용했다. 그래서 오히려 의문이 들었다. &quot;TS6에선 <code>types: []</code>가 기본이라던데, 그럼 전역 타입이 다 빠졌을 텐데 왜 빌드가 다 되지?&quot; 이건 에러가 아니라 <strong>에러가 안 난 게 더
수상한</strong> 경우였다.</p>
<h3>무엇이, 왜 바뀌었나</h3>
<p>먼저 결을 분명히 하자. 1장(<code>baseUrl</code>)·2장(<code>rootDir</code>)이 7.0(<code>tsgo</code>)을 앞둔 정리였다면, <strong>이 <code>types</code> 변경의 동기는 순수하게 빌드 성능</strong>
이다 —
제안 이슈 <a href="https://github.com/microsoft/TypeScript/issues/62195">#62195</a>도 이 변경을 네이티브 컴파일러(7.0)와 묶이지 않은,
6.0에
독립적으로 들어가는 성능 개선으로 다룬다. 그 이슈와
<a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html">릴리스 노트</a>를 보면 이유가
명확하다:
기존 <code>types</code>의 기본값은 사실상 &quot;<code>node_modules/@types</code>를 전부 열거&quot;였다.</p>
<blockquote>
<p>&quot;for convenience, TypeScript would also include all packages in <code>node_modules/@types</code> by
default... This can be <em>very</em> expensive, as a normal repository setup these days might
transitively
pull in hundreds of <code>@types</code> packages, especially in multi-project workspaces with flattened
<code>node_modules</code>.&quot; — 릴리스 노트</p>
</blockquote>
<p>flattened <code>node_modules</code>를 쓰는 모노레포에서는 이게 특히 치명적이다. 수백 개의 불필요한 <code>.d.ts</code>가 전이적으로 프로그램에 끌려와 파싱/체크 비용을
잡아먹는다. 그래서 기본값을 <code>[]</code>로 바꿨고, 효과는 수치로 제시돼 있다.</p>
<blockquote>
<p>&quot;Many projects we&#x27;ve looked at have improved their build time anywhere from 20-50% just by setting
<code>types</code> appropriately.&quot; — 릴리스
노트 (<a href="https://github.com/microsoft/TypeScript/issues/54500">원 출처는 #54500</a>)</p>
</blockquote>
<h3>구현 레벨: &quot;기본값 = []&quot; 대입문은 없다</h3>
<p>PR <a href="https://github.com/microsoft/TypeScript/pull/63054">#63054 &quot;Set default <code>types</code> array to <code>[]</code>; support
<code>\&quot;*\&quot;</code> wildcard&quot;</a>을 까보면, 흥미롭게도
<code>options.types = []</code> 같은 <strong>명시적 기본값 대입은 어디에도 없다.</strong> 자동 열거를 담당하던 <code>getAutomaticTypeDirectiveNames</code>의
early-return 조건이 뒤집힌 게 전부다(아래 diff는 핵심만 발췌·정리).</p>
<pre><code class="language-diff">// src/compiler/moduleNameResolver.ts
 export function getAutomaticTypeDirectiveNames(options, host): string[] {
-    // Use explicit type list from tsconfig.json
-    if (options.types) {
-        return options.types;
+    if (!usesWildcardTypes(options)) {
+        return options.types ?? [];
     }
     // ... 여기 아래(typeRoots 열거)는 이제 &quot;*&quot;가 있을 때만 실행된다
-    return result;
+    return deduplicate(flatten(options.types.map(t =&gt; t === &quot;*&quot; ? wildcardMatches : t)), equateValues);
 }
</code></pre>
<p>핵심은 <code>if (options.types)</code> → <code>if (!usesWildcardTypes(options))</code>로 바뀐 한 줄이다. 예전엔 &quot;<code>types</code>를 명시 안 하면(
undefined) typeRoots를 뒤져 모든 <code>@types</code>를 자동 포함&quot;했는데, 이제는 <strong><code>types</code> 배열에 <code>&quot;*&quot;</code>가 있을 때만</strong> 열거하고, 그 외에는
<code>options.types ?? []</code>를 그대로 돌려준다. <code>[]</code> 기본값은 별도 코드가 아니라 이 early-return의 자연스러운 결과다.</p>
<p>판정 헬퍼도 새로 추가됐다.</p>
<pre><code class="language-diff">// src/compiler/utilities.ts
+export function usesWildcardTypes(options: CompilerOptions): options is CompilerOptions &amp; { types: string[] } {
+    return some(options.types, t =&gt; t === &quot;*&quot;);
+}
</code></pre>
<p>옛 동작(모든 <code>@types</code> 자동 포함)으로 되돌리고 싶으면 <code>&quot;types&quot;: [&quot;*&quot;]</code>를 쓰면 된다. 위 diff에서
<code>options.types.map(t =&gt; t === &quot;*&quot; ? wildcardMatches : t)</code> — <code>&quot;*&quot;</code>가 있던 <strong>위치에</strong> 열거 결과를 펼쳐 넣어 순서까지
보존한다.</p>
<h3>핵심: 사라지는 건 import 안 한 &#x27;전역&#x27; 타입뿐</h3>
<p>기본값이 <code>[]</code>로 바뀌었다니, 내 타입이 우수수 빠지는 건 아닐까? 다행히 <strong>영향 범위는 훨씬 좁다.</strong> 이 변경은 <code>node_modules/@types</code> <strong>읽기를 멈추는
게 아니다.</strong> 내가 직접 <code>import</code>해서 쓰는 타입은 전혀 영향이 없고, 오직 <strong>import 없이 전역(global)으로 깔리던 <code>@types</code></strong> — 예컨대
<code>@types/node</code>의 <code>process</code>·<code>Buffer</code>, 테스트 프레임워크의 <code>describe</code>/<code>expect</code> 같은 것 — 만 끊긴다.</p>
<blockquote>
<p>&quot;...this does not mean we will stop reading from <code>node_modules/@types</code>, just that the files won&#x27;t
be brought in unless imported, explicitly listed in your <code>tsconfig.json</code>&#x27;s <code>types</code> array...
Typically this will only affect users relying on global values and module names, like those
brought in from <code>@types/node</code> (e.g. the <code>&quot;fs&quot;</code> module is globally defined), or a testing
framework.&quot; — <a href="https://github.com/microsoft/TypeScript/issues/62195">issue #62195</a></p>
</blockquote>
<pre><code class="language-ts">import { foo } from &#x27;some-pkg&#x27;;   // ← types 설정과 무관하게 타입 붙음
process.env.NODE_ENV;             // ← @types/node 전역. types에 &#x27;node&#x27; 없으면 에러
</code></pre>
<h3>답: 빠질 게 없었다 — 이미 명시돼 있어서</h3>
<p>답은 싱겁게도 &quot;이미 잘 명시돼 있어서&quot;였다. 전역이 필요한 패키지는 전부 <code>types</code>를 명시하고 있었다.</p>






























<table><thead><tr><th style="text-align:left">패키지</th><th style="text-align:left"><code>types</code></th><th style="text-align:left">TS6 영향</th></tr></thead><tbody><tr><td style="text-align:left">next.js</td><td style="text-align:left"><code>[&quot;node&quot;, &quot;vitest/globals&quot;]</code></td><td style="text-align:left">없음 (명시)</td></tr><tr><td style="text-align:left">socket-server / bundler</td><td style="text-align:left"><code>[&quot;node&quot;]</code></td><td style="text-align:left">없음 (명시)</td></tr><tr><td style="text-align:left">react / typescript</td><td style="text-align:left"><code>[&quot;vitest/globals&quot;]</code></td><td style="text-align:left">없음 (명시)</td></tr><tr><td style="text-align:left">ui / 공유 base config</td><td style="text-align:left">미지정</td><td style="text-align:left">이제 <code>[]</code> (그러나 ambient 전역을 안 씀)</td></tr></tbody></table>
<p><code>types</code>를 명시 안 한 곳(ui, 공유 base)은 애초에 ambient 전역에 의존하지 않거나, Next.js처럼 <code>next-env.d.ts</code>의
<code>/// &lt;reference&gt;</code>로 충당돼서 통과했다. 즉 &quot;에러가 안 난 것&quot;은 운이 아니라, <strong>TS6가 강제하기 전부터 옳게 적혀 있었던</strong> 덕이다.</p>
<p><code>ignoreDeprecations</code>는 <code>baseUrl</code> 같은 <strong>deprecated 옵션</strong>용이지, <code>
types</code> 기본값 변경을 되돌리는 옵션이 아니다. 옛 동작이 필요하면 <code>&quot;types&quot;: [&quot;*&quot;]</code>를 쓰면 된다 — 이건 glob이 아니라 &quot;전부
열거&quot;를 뜻하는 special token이다.</p>
<hr/>
<h2>4. 1차 검증: 여기까지 check-types는 통과</h2>
<p>세 가지 규칙을 다 맞추고 나니 타입 체크는 깨끗했다.</p>
<ul>
<li><code>pnpm check-types</code> → <strong>5/5 통과</strong></li>
</ul>
<p>자, TypeScript 자체가 바꾼 규칙들은 다 막아냈다. 그런데 전체 빌드(<code>pnpm build</code>)를 돌리자, <strong>정작 내가 손대지도 않은 곳</strong>에서 다시 터졌다. 그것도 방금
작별했다고 생각한 그 이름, <code>baseUrl</code>로.</p>
<hr/>
<h2>5. 보너스: 다시 튀어나온 baseUrl — 이번엔 내 코드가 아니었다</h2>
<p><code>pnpm build</code>가 디자인 시스템 패키지의 dts 빌드에 다다르자 <strong>또 <code>TS5101</code></strong> 이 떴다. 그런데 <code>@design-system/ui</code>의 tsconfig엔
<code>baseUrl</code>이 <strong>없다.</strong> #1에서 봤듯 TS5101은 범용 진단이니, 누군가 내 빌드에 baseUrl을 <strong>주입</strong>하고 있다는 뜻이다.</p>
<p>범인은 dts 번들러로 쓰던 <strong>tsup</strong>이었다. <a href="https://github.com/egoist/tsup/blob/main/src/rollup.ts">소스</a>의 dts 빌드 옵션
구성부에 이 한 줄이 있다.</p>
<pre><code class="language-ts">baseUrl: compilerOptions.baseUrl || &#x27;.&#x27;, // ← 내 tsconfig에 없어도 &#x27;.&#x27;를 강제 주입
</code></pre>
<p>TS 5.x에선 무해했지만 TS6에선 이 주입이 곧장 하드 에러다(<a href="https://github.com/egoist/tsup/issues/1388">tsup #1388</a>, 재현 환경
tsup 8.5.1 + TS 6.0.2). 게다가 tsup은 <strong>이미 유지보수 중단</strong> — README 최상단에 박혀 있다.</p>
<blockquote>
<p>&quot;This project is not actively maintained anymore. Please consider using tsdown
instead.&quot; — <a href="https://github.com/egoist/tsup/blob/main/README.md">egoist/tsup README</a></p>
</blockquote>
<p>baseUrl 주입은 내 코드가 아니라 도구의 문제이고, 그 도구가 더는 고쳐지지 않으니 답은 정해져 있었다 — 후속 도구 <a href="https://tsdown.dev">tsdown</a>으로
갈아탔다. <a href="https://rolldown.rs">Rolldown</a> 기반이라 <strong>baseUrl을 주입하지 않고</strong>, peer로
<code>typescript: &quot;^5.0.0 || ^6.0.0&quot;</code>을 선언해 <strong>TS6를 공식 지원</strong>한다. 마이그레이션 도구(<code>npx tsdown-migrate</code>)로 config도 거의
그대로 옮겨졌고, 전환 직후 <code>TS5101</code>은 사라졌다.</p>
<hr/>
<h2>6. 정리: TypeScript 6 마이그레이션 체크리스트</h2>
<p>이번 삽질을 한 줄짜리 체크리스트로 압축하면(1~4는 앞에서 다룬 것, 5는 버전을 일괄 갱신할 때 함께 챙기는 곁가지다):</p>
<ol>
<li><strong><code>baseUrl</code>을 쓰는가?</strong> → 직접 쓰면 제거(<code>paths</code>는 4.1부터 baseUrl 불필요). 빌드 도구가 주입한다면 도구를 점검하라. <code>TS5101</code>은
baseUrl 전용이 아니라 <strong>범용 deprecated-option 진단</strong>임을 기억할 것.</li>
<li><strong><code>emitDeclarationOnly</code>/<code>outDir</code>로 emit하는데 소스가 tsconfig보다 깊은가?</strong> → <code>rootDir</code>을 명시. 에러 메시지의 <code>&#x27;{1}&#x27;</code>이
곧 넣어야 할 값이다.</li>
<li><strong><code>@types/node</code> 같은 전역에 의존하는가?</strong> → <code>types: [&quot;node&quot;, ...]</code>로 명시. TS6 기본은 <code>[]</code>이고, 이는 <strong>ambient 전역에만</strong>
영향을 준다(import 타입은 무관). 옛 동작은 <code>[&quot;*&quot;]</code>.</li>
</ol>
<hr/>
<h2>7. 정작 가장 큰 수확: 6을 올리지 않아도 됐다</h2>
<p>표의 #1~#3, 그리고 보너스까지 따라오며 깨달은 게 있다. 이 변화들의 공통점은 결국 &#x27;비싸거나 모호한 기본값을 걷어내는 청소&#x27;였다 — baseUrl·rootDir은 TS7(Go)을 향한 정리, types는 순수 성능 개선. 그리고 <strong>그 청소는 6을 올려야만 할 수 있는 게 아니다.</strong>
baseUrl·rootDir·types 셋 다 5에서도 오늘 당장 적용할 수 있는 모범 설정이다. 그러니 정작 올릴 첫 커밋은 &quot;6 업그레이드&quot;가 아니라 이 정리다.</p>
<p><strong>6을 안 올려도, 5에서 지금 할 일:</strong></p>
<ul>
<li><strong><code>baseUrl</code> 제거</strong> → <code>paths</code>만 남기거나, 런타임까지 일치하는 package.json <code>imports</code>로.</li>
<li><strong><code>rootDir</code> 고정</strong> → 명시해 출력 레이아웃을 결정적으로 묶는다.</li>
<li><strong><code>types</code> 좁히기</strong> → <code>types: [&quot;node&quot;, ...]</code>로 명시해 자동 <code>@types</code> 열거 비용을 미리 던다.</li>
</ul>
<p>세 줄 다 5.x tsconfig에서 오늘 커밋할 수 있다. 6은 이걸 <strong>&#x27;강제&#x27;</strong> 했을 뿐 <strong>&#x27;개발&#x27;</strong> 한 게 아니다.</p>
<hr/>
<h2>그래서, 오늘 당신의 tsconfig는?</h2>
<p>이 글을 닫기 전에 딱 하나만 하자 — <code>tsconfig.json</code>을 여는 것. <code>baseUrl</code>이 아직 남아 있는지, <code>rootDir</code>이 비어 있는지, <code>types</code>가 통째로 열려 있는지. 셋 다 6을 기다릴 것 없이 오늘 5.x에서 고치고, 그 커밋 하나로 빌드를 더 빠르게 만들 수 있다. 미뤄도 빌드는 돌지만, 미룬 만큼 7.0에서 한꺼번에 청구된다.</p>
<p>그리고 정말 궁금하다 — <strong>여러분의 프로젝트는 이 셋 중 몇 개를 이미 지키고 있었나?</strong> 셋 다 비어 있었다면 무엇부터 손볼지, 아니면 &quot;우린 진작 package.json <code>imports</code>로 넘어갔다&quot; 같은 이야기가 있다면 댓글로 남겨 달라. 남의 tsconfig가 어디서 새는지는, 의외로 서로의 댓글에서 가장 빨리 배운다.</p>
<hr/>
<h2>참고 링크</h2>
<ul>
<li>TypeScript 6.0 릴리스 노트 — <a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html">https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html</a></li>
<li>baseUrl deprecation: <a href="https://github.com/microsoft/TypeScript/issues/62207">issue #62207</a> · <a href="https://github.com/microsoft/TypeScript/pull/62509">PR #62509</a></li>
<li><code>baseUrl</code>/<code>paths</code>의 런타임 안전 대안 package.json <code>imports</code>: <a href="https://www.typescriptlang.org/docs/handbook/modules/reference.html">TS 핸드북 Modules Reference</a> · <a href="https://nodejs.org/api/packages.html">Node.js<code>imports</code> 필드</a> · <a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html">TS 4.7 릴리스 노트(imports 지원)</a></li>
<li>types 기본값 <code>[]</code>: <a href="https://github.com/microsoft/TypeScript/issues/62195">issue #62195</a> · <a href="https://github.com/microsoft/TypeScript/pull/63054">PR #63054</a> · <a href="https://github.com/microsoft/TypeScript/issues/54500">원 rationale #54500</a></li>
<li>rootDir 기본값: <a href="https://github.com/microsoft/TypeScript/issues/62194">issue #62194</a> · <a href="https://github.com/microsoft/TypeScript/pull/62418">PR #62418</a></li>
<li>tsup: <a href="https://github.com/egoist/tsup/blob/main/README.md">README</a> · <a href="https://github.com/egoist/tsup/issues/1388">TS5101 이슈 #1388</a> · <a href="https://github.com/egoist/tsup/issues/1389">TS6 지원 #1389</a></li>
<li>tsdown: <a href="https://tsdown.dev">공식 문서</a> · <a href="https://tsdown.dev/guide/migrate-from-tsup">tsup → tsdown 마이그레이션</a> · <a href="https://github.com/sxzz/rolldown-plugin-dts">rolldown-plugin-dts</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>[Typescript로 설계하는 프로젝트] id?: string을 버려라. 상태가 다르면 타입도 달라야 한다.</title>
      <link>https://blog.sangwook.dev/posts/typescript-domain-modeling-without-optional/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/typescript-domain-modeling-without-optional/</guid>
      <pubDate>Sun, 15 Mar 2026 15:00:00 GMT</pubDate>
      <description>id?: string 하나가 코드 전체에 옵셔널 체이닝과 느낌표를 퍼뜨립니다. 무분별한 옵셔널이 낳은 안티 패턴에서 벗어나, Tagged Type(Discriminated Union)과 도메인 모델링으로 상태마다 다른 타입을 주는 설계를 알아봅니다.</description>
      <content:encoded><![CDATA[<h2>1. 프롤로그: 우리가 숨 쉬듯 작성하는 &quot;거짓말&quot;</h2>
<blockquote>
<p><strong>팀원</strong>: &quot;OO님, <code>Post</code> 타입에서 <code>id</code>가 없다고 에디터에서 빨간 줄이 뜨는데, 새 글 작성할 땐 서버 id가 없으니까 그냥 <code>?</code> 붙여도 될까요?&quot;</p>
</blockquote>
<p>프론트엔드 개발을 하다 보면 슬랙이나 코드 리뷰에서 심심치 않게 보게 되는 질문입니다. 저 역시 예전에는 빠른 기능 구현을 위해 무심코 <code>?</code>를 붙이며 타입 에러를 &#x27;지우는 데&#x27; 급급했던 시절이 있었습니다. 십중팔구 이런 타입을 작성해 보셨을 겁니다.</p>
<pre><code class="language-typescript">interface Post {
  id?: string; // 새로 작성 중일 땐 서버 id가 없으니까 옵셔널 처리
  title: string;
  content: string;
}
</code></pre>
<p>당장 에러는 안 나니 편합니다. <strong>하지만 이 작은 <code>?</code> 하나 때문에 우리는 프로젝트 내내 고통받습니다.</strong></p>
<p>분명히 DB에서 방금 조회해 온 글이라 100% ID가 존재하는데도, 타입스크립트는 <strong><em>&quot;ID가 없을 수도 있잖아?&quot;</em></strong> 라며 끊임없이 딴지를 겁니다. 결국 우리는 울며 겨자 먹기로 컴포넌트 곳곳에 <code>if (post.id)</code>나 <code>post.id!</code> 같은 무의미한 방어 코드를 도배하게 되죠. 코드는 지저분해지고, 개발자의 피로도는 급증합니다.</p>
<p>왜 이런 일이 발생할까요? 우리가 타입스크립트에게 <strong>&quot;도메인의 현실&quot;을 제대로 알려주지 않고 거짓말을 했기 때문</strong>입니다.</p>
<hr/>
<h2>2. 왜 프론트엔드에 도메인 모델링이 필요한가</h2>
<p>&#x27;도메인 모델링&#x27;이라고 하면 보통 백엔드나 DDD의 영역으로 생각하기 쉽습니다. 하지만 잠깐 생각해 봅시다. <strong>사용자의 행동 흐름을 가장 충실하게 코드로 표현해야 하는 곳이 어디인가요?</strong> 바로 프론트엔드입니다.</p>
<p>사용자는 글을 &#x27;작성&#x27;하고, &#x27;저장&#x27;하고, &#x27;수정&#x27;하고, &#x27;삭제&#x27;합니다. 이 모든 흐름에서 데이터의 <strong>상태</strong>는 끊임없이 변합니다. 그런데 우리가 이 상태 변화를 타입으로 제대로 표현하지 않으면, 결국 런타임에서 <code>undefined</code> 에러로 돌아옵니다.</p>
<p>도메인 모델링은 유비쿼터스 언어, 엔티티 설계, 바운디드 컨텍스트 정의 등 넓은 영역을 포괄합니다. 하지만 그 첫 번째 원칙은 <strong>상태를 타입으로 정확히 표현하는 것</strong> 입니다. 이 글에서는 바로 그 첫걸음에 집중합니다.</p>
<p>우리의 비즈니스 규칙을 가만히 들여다봅시다.</p>
<ol>
<li><strong>작성 중인 글</strong>: 제목과 내용만 있습니다. ID는 절대 없습니다.</li>
<li><strong>발행된 글</strong>: 서버에 안전하게 저장되었습니다. <strong>반드시 고유한 ID를 가집니다.</strong></li>
</ol>
<p>즉, 이 도메인에서 <em>&quot;ID가 생겼다&quot;</em> 는 것은 단순히 데이터에 문자열 필드 하나가 추가된 것이 아닙니다. <strong>객체의 신분이 &#x27;임시&#x27;에서 &#x27;정식&#x27;으로 완전히 바뀌었음</strong>을 의미합니다.</p>
<p>그런데 우리는 귀찮다는 이유로 이 완전히 다른 두 가지 상태를 <code>Post</code>라는 하나의 타입에 억지로 욱여넣고, <code>id?: string</code>으로 퉁쳐버린 겁니다.</p>
<hr/>
<h2>3. Tagged Type: 상태를 타입으로 증명하기</h2>
<p>이 문제를 해결하는 타입스크립트의 공식 패턴이 있습니다. 바로 <strong>Tagged Type</strong>, 또는 <strong>Discriminated Union</strong> 이라고 불리는 패턴입니다.</p>
<p>핵심 아이디어는 간단합니다.</p>
<blockquote>
<p>💡 모든 상태가 공유하는 <strong>하나의 리터럴 속성(Tag)</strong> 을 만들고, 그 값으로 상태를 식별하자.</p>
</blockquote>
<p>중복되는 코드는 <code>BasePost</code>로 깔끔하게 묶어내고, <code>status</code>라는 <strong>태그(Tag)</strong> 속성으로 상태를 명확히 분리해 봅시다.</p>
<p>먼저, 글이라면 무조건 가져야 하는 공통 속성을 정의합니다.</p>
<pre><code class="language-typescript">interface BasePost {
  title: string;
  content: string;
}
</code></pre>
<p>그 다음, <code>status</code> 태그로 두 가지 상태를 분리합니다.</p>
<pre><code class="language-typescript">// 아직 식별자가 없는 &#x27;작성 중&#x27; 상태
interface DraftPost extends BasePost {
  status: &#x27;draft&#x27;;
}

// 서버에 저장되어 식별자가 존재하는 &#x27;발행 완료&#x27; 상태
interface PublishedPost extends BasePost {
  status: &#x27;published&#x27;;
  id: string; // 발행되었으니 반드시 존재!
}
</code></pre>
<p>마지막으로, 이 둘을 합쳐서 완전한 <code>Post</code> 도메인을 만듭니다.</p>
<pre><code class="language-typescript">type Post = DraftPost | PublishedPost;
</code></pre>
<p>여기서 <code>status</code>가 바로 <strong>Tag</strong> 입니다. <code>&#x27;draft&#x27;</code>와 <code>&#x27;published&#x27;</code>라는 리터럴 값이 각 상태의 이름표 역할을 하죠. 이제 타입스크립트는 <code>status</code> 값만 확인하면 나머지 데이터의 형태를 <strong>자동으로</strong> 추론할 수 있게 됩니다.</p>
<blockquote>
<h3>🤔 <code>id</code> 유무로 구분하면 안 되나요?</h3>
<p>여기서 한 가지 의문이 떠오를 수 있습니다. <em>&quot;굳이 <code>status</code>를 만들지 않아도, <code>id</code>가 있으면 &gt;<code>PublishedPost</code>고 없으면 <code>DraftPost</code> 아닌가?&quot;</em></p>
<p>기술적으로는 가능합니다. <code>id?: never</code>와 <code>id: string</code>으로도 Discriminated Union을 만들 수 있거든요. 하지만 <strong><code>id</code>의 유무는 기술적 사실이고, <code>status</code>는 도메인 개념</strong> 입니다. 글 전체의 주장이 <em>&quot;타입으로 도메인을 표현하라&quot;</em> 인데, <code>id</code>의 유무로 상태를 구분하면 그 주장과 어긋납니다. <code>id</code>가 없다는 건 우연히 <code>draft</code>와 일치하는 거지, <code>draft</code>라는 <strong>개념 자체</strong> 를 표현하는 게 아니기 때문입니다.</p>
<p>그리고 도메인이 성장하면 <code>archived</code>, <code>scheduled</code>, <code>under_review</code> 같은 상태가 생깁니다. 이때 <code>id</code>의 유무만으로는 이 상태들을 구분할 수 없습니다. 확장성은 <code>status</code> 태그를 선택했을 때 자연스럽게 &gt;따라오는 부산물입니다.</p>
</blockquote>
<hr/>
<h2>4. Before / After: 타입으로 좁히기</h2>
<p>이론만으로는 감이 잘 안 올 수 있습니다. 같은 비즈니스 로직을 옵셔널 방식과 Tagged Type 방식으로 나란히 비교해 봅시다.</p>
<h3>Before: 옵셔널 타입</h3>
<pre><code class="language-typescript">function handlePost(post: Post) {
  // post.id가 있는지 확신할 수 없습니다.
  if (!post.id) {
    // 여기가 &quot;새 글 작성&quot;인지 &quot;에러 상황&quot;인지 알 수 없습니다.
    console.log(&#x27;ID가 없습니다... 새 글인가? 에러인가?&#x27;);
    api.create(post);
    return;
  }

  // 여기서도 타입스크립트는 post.id가 string | undefined라고 불평합니다.
  api.update(post.id, post);
}
</code></pre>
<ul>
<li><code>post.id</code>가 없는 상황이 &quot;새 글 작성&quot;인지 &quot;진짜 에러&quot;인지 구분이 안 됩니다.</li>
<li>개발자가 매번 <code>if (post.id)</code> 같은 방어 코드를 직접 작성해야 합니다.</li>
</ul>
<h3>After: Tagged Type</h3>
<pre><code class="language-typescript">function handlePost(post: Post) {
  if (post.status === &#x27;draft&#x27;) {
    // 이 블록 안에서 post는 DraftPost입니다.
    // id 필드 자체가 존재하지 않으므로 실수할 여지가 없습니다.
    console.log(`임시저장 글을 새로 생성합니다: ${post.title}`);
    api.create(post);
    return;
  }

  // 여기서 post는 자동으로 PublishedPost!
  // post.id는 무조건 string. 옵셔널 체이닝도 느낌표도 필요 없습니다.
  console.log(`기존 글(${post.id})을 업데이트합니다.`);
  api.update(post.id, post);
}
</code></pre>
<p><code>post.status === &#x27;draft&#x27;</code>라는 조건 하나로, 타입스크립트가 <strong>나머지 모든 타입을 자동으로 좁혀냅니다.</strong> 개발자는 더 이상 <code>id</code>가 있는지 없는지 런타임에서 의심할 필요가 없습니다. <em>&quot;발행된 글은 id가 반드시 있다&quot;</em> 는 도메인의 규칙이 타입 시스템에 완벽하게 녹아들었기 때문입니다.</p>
<hr/>
<h2>5. 한 걸음 더: 타입 가드로 의도를 명시적으로 드러내기</h2>
<p>한 가지 더 욕심을 부려봅시다. 컴포넌트 곳곳에 <code>if (post.status === &#x27;published&#x27;)</code>가 반복되고 있지 않나요? <strong>타입 좁힘 의도를 함수로 빼내세요.</strong> 조건의 의미가 명시적으로 드러나고, 변경 포인트가 한 곳에 모입니다.</p>
<pre><code class="language-typescript">// 타입 좁힘 의도를 명시적으로 드러내는 타입 가드 함수
function isPublished(post: Post): post is PublishedPost {
  return post.status === &#x27;published&#x27;;
}
</code></pre>
<p>여기서 반환 타입이 <code>boolean</code>이 아니라 <code>post is PublishedPost</code>인 점이 눈에 띌 겁니다. 이것은 타입스크립트의 <strong>타입 서술어</strong> 문법입니다. 의미는 간단합니다. <em>&quot;이 함수가 <code>true</code>를 반환하면, 매개변수 <code>post</code>를 <code>PublishedPost</code> 타입으로 좁혀도 안전하다&quot;</em> 고 컴파일러에게 알려주는 것이죠.</p>
<p>만약 반환 타입을 그냥 <code>boolean</code>으로 쓰면, 함수 호출 후에도 타입스크립트는 여전히 <code>post</code>를 <code>DraftPost | PublishedPost</code>로 인식합니다. <code>post is PublishedPost</code>라는 한 마디가 <code>if</code> 블록 안에서 자동 타입 좁혀짐을 가능하게 하는 열쇠입니다.</p>
<p>이렇게 하면 <code>post.status === &#x27;published&#x27;</code>라는 기술적 조건이 <code>isPublished</code>라는 도메인 언어로 바뀌고, 타입 좁힘 의도가 코드에 명확히 드러납니다.</p>
<pre><code class="language-typescript">function updatePost(post: Post) {
  if (isPublished(post)) {
    // isPublished를 통과했으니 post.id는 무조건 존재!
    api.patch(`/posts/${post.id}`, post);
  }
}
</code></pre>
<h3>그래서 DraftPost는 언제 PublishedPost가 되는가?</h3>
<p><strong>&quot;타입을 분리한 건 알겠는데, 실제 코드에서 <code>DraftPost</code>가 <code>PublishedPost</code>로 바뀌는 시점은 언제야?&quot;</strong> 라는 의문이 남을 수 있습니다. 답은 명확합니다. <strong>서버가 ID를 부여해서 응답을 돌려주는 바로 그 순간</strong> 입니다.</p>
<pre><code class="language-typescript">// API 응답 타입: 서버가 ID를 부여한 결과
interface CreatePostResponse {
  id: string;
  title: string;
  content: string;
}

async function createPost(draft: DraftPost): Promise&lt;PublishedPost&gt; {
  const response = await api.post&lt;CreatePostResponse&gt;(&#x27;/posts&#x27;, draft);

  // 서버가 부여한 id와 함께, 상태 태그를 &#x27;published&#x27;로 확정합니다.
  // 이 순간 DraftPost는 사라지고, PublishedPost가 탄생합니다.
  return {
    ...draft,
    status: &#x27;published&#x27;,
    id: response.id,
  };
}
</code></pre>
<p>핵심은 <strong>객체를 직접 변환하지 않는다</strong> 는 것입니다. 기존 <code>DraftPost</code>의 <code>status</code>를 바꾸는 게 아니라, 서버 응답 데이터를 기반으로 <strong>완전히 새로운 <code>PublishedPost</code> 객체를 생성</strong> 합니다. <code>status: &#x27;published&#x27;</code>와 <code>id</code>가 함께 들어가는 순간, 타입스크립트는 이 객체가 <code>PublishedPost</code>임을 자동으로 인식합니다. 타입의 전환이 곧 도메인 상태의 전환이 되는 셈이죠.</p>
<hr/>
<h2>6. 마무리</h2>
<p>처음에 우리는 타입스크립트에게 거짓말을 했습니다. <code>id?: string</code>으로 현실을 왜곡했죠.
<code>Tagged Type</code>은 그 거짓말을 걷어내는 작업입니다. 도메인의 규칙을 있는 그대로 코드에 옮기면, 타입스크립트는 더 이상 적이 아닙니다.</p>
<p>타입은 단순한 에러 방지용 도구가 아닙니다. 비즈니스의 흐름과 상태의 변화를 코드에 <strong>증명</strong> 하는 수단입니다.</p>
<hr/>
<p><strong>💡 내 프로젝트에 당장 적용하기 체크리스트</strong></p>
<ul class="contains-task-list">
<li class="task-list-item"><input type="checkbox" disabled=""/> 내 코드에 무의미한 <code>if (data.id)</code> 체크가 너무 많지 않은가?</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> <code>?</code> 옵셔널 필드들이 사실 특정 &#x27;상태&#x27;를 대변하고 있진 않은가?</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 상태를 식별할 수 있는 공통 Tag 속성(<code>status</code>, <code>type</code>, <code>kind</code>)을 추가할 수 있는가?</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 상태별로 타입을 명확히 분리하여 유니온(<code>|</code>)으로 묶었는가?</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 타입 가드 함수로 타입 좁힘 의도를 명시적으로 드러냈는가?</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>[누가 시키지도 않았는데 번들러 만들기] 4. 소스맵: 번들된 코드에서 원본의 흔적 찾기</title>
      <link>https://blog.sangwook.dev/posts/no-one-asked-library-bundler-04-sourcemap/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/no-one-asked-library-bundler-04-sourcemap/</guid>
      <pubDate>Mon, 02 Mar 2026 15:00:00 GMT</pubDate>
      <description>수천 줄로 합쳐진 번들 파일에서 에러가 나면 어떻게 원본의 그 줄을 찾아갈까요? 소스맵의 VLQ 인코딩 원리와 magic-string이 변환 이력을 관리하는 방식을 직접 구현해 보며 이해합니다. 번들러를 손으로 만들어 본 시리즈의 마지막 편.</description>
      <content:encoded><![CDATA[<blockquote>
<p><strong>&quot;에러 위치: bundle.js 2847번째 줄&quot;</strong></p>
</blockquote>
<p>지난 편(<a href="https://blog.sangwook.dev/posts/no-one-asked-library-bundler-03-bundling-scope/">Step 3. 번들링과 스코프</a>)에서 우리는 드디어 파일을 하나로 합치는 데 성공했습니다. 수십 개의 파일이 IIFE 안에 질서 정연하게 들어가고, 브라우저에서도 정상적으로 실행됩니다.<br/>
그런데 잠깐. 실제 프로젝트에서 이 번들 파일을 배포했다고 상상해 봅시다. 운영 중에 에러가 납니다. 브라우저 콘솔이 이렇게 말합니다.</p>
<pre><code class="language-text">Uncaught TypeError: Cannot read properties of undefined at bundle.js:2847:23
</code></pre>
<p><code>bundle.js</code> 2847번째 줄. 여러분이 작성한 수십 개의 원본 파일 중 <strong>어디</strong>에 해당하는 코드일까요? 그 파일의 몇 번째 줄일까요?<br/>
번들러가 파일을 합치는 과정에서 원본의 행과 열 정보는 완전히 뒤섞입니다. 소스맵이 없다면, 이 에러를 디버깅하는 것은 사실상 불가능에 가깝습니다.<br/>
이번 편에서는 <strong>소스맵</strong>의 원리를 파헤치고, 우리 번들러에 직접 구현해보겠습니다.</p>
<hr/>
<h2>1. 소스맵이란: &quot;번들 좌표 → 원본 좌표&quot; 변환표</h2>
<p>소스맵을 가장 쉽게 설명하자면 <strong>좌표 변환표</strong>입니다.</p>
<pre><code class="language-mermaid">flowchart LR
    A[&quot;📦 bundle.js&lt;br/&gt;2847번째 줄 · 23번째 열&quot;]
    B[(&quot;🗺️ index.js.map&lt;br/&gt;소스맵 조회&quot;)]
    C[&quot;📄 src/components/Button.js&lt;br/&gt;15번째 줄 · 8번째 열&quot;]

    A --&gt;|에러 발생| B
    B --&gt;|원본 위치 반환| C
</code></pre>
<p>이 변환표가 <code>.map</code> 파일 형태로 번들 파일과 함께 배포됩니다.<br/>
브라우저 DevTools는 이 파일을 읽어서, 에러 위치를 자동으로 원본 파일 기준으로 표시해 줍니다.<br/>
실제 <code>.map</code> 파일을 열어보면 이런 모양입니다.</p>
<pre><code class="language-json">{
  &quot;version&quot;: 3,
  &quot;file&quot;: &quot;index.js&quot;,
  &quot;sources&quot;: [&quot;src/index.js&quot;, &quot;src/components/Button.js&quot;],
  &quot;sourcesContent&quot;: [&quot;...&quot;, &quot;...&quot;],
  &quot;mappings&quot;: &quot;AAAA,SAAS;AACT,IAAIA...&quot;
}
</code></pre>
<p><code>sources</code>는 원본 파일 목록입니다. <code>sourcesContent</code>에는 원본 파일의 코드가 통째로 들어갑니다.<br/>
구현 코드의 <code>includeContent: true</code> 옵션이 켜지면 채워지는 필드입니다. 소스 파일이 없는 환경에서도 디버깅이 가능해지지만, <code>.map</code> 파일에 소스 코드 전체가 포함되어 파일 크기가 크게 늘고 소스가 그대로 노출된다는 트레이드오프가 있습니다.<br/>
그리고 <code>mappings</code>는... 외계어처럼 보이는 암호문입니다. 이 <code>mappings</code> 필드가 소스맵의 핵심입니다. 여기에 모든 좌표 변환 정보가 압축되어 있습니다. 이걸 이해하려면 <strong>VLQ</strong>를 알아야 합니다.</p>
<hr/>
<h2>2. magic-string의 진짜 목적</h2>
<p>잠깐, 본론으로 들어가기 전에 한 가지 물어보겠습니다.<br/>
우리는 1편부터 <code>magic-string</code>이라는 라이브러리를 써왔습니다. 단순히 <strong>&quot;문자열을 덮어쓰는 도구&quot;</strong> 로만 알고 있었죠. 그런데 왜 굳이 <code>String.replace()</code>나 정규식을 쓰지 않고 이 라이브러리를 선택했을까요?</p>
<p><strong>magic-string의 진짜 목적은 소스맵 생성입니다.</strong><br/>
일반 문자열 치환은 <strong>&quot;무엇이 바뀌었는지&quot;</strong> 를 기억하지 못합니다. 하지만 <code>magic-string</code>은 모든 변환 작업의 <strong>이력</strong>을 내부에 쌓아둡니다.</p>
<pre><code class="language-typescript">// Module.ts (line 198)
// import { Button } from &#x27;./Button.js&#x27; → const { Button } = require(1)
this.magicString.overwrite(node.start, node.end, replacement);
//                          ^^^^^^^^^^  ^^^^^^^^
//                          원본에서 어디부터 어디까지를 바꿨는지 기록!
</code></pre>
<p><code>overwrite(start, end, newText)</code>를 호출할 때마다, <code>magic-string</code>은 내부적으로 이런 기록을 남깁니다.</p>
<blockquote>
<p><code>&quot;원본 코드의 0번 인덱스부터 32번 인덱스까지가 &#x27;const { Button } = require(1);&#x27;로 교체되었다&quot;</code></p>
</blockquote>
<p>이 이력이 쌓이면 쌓일수록, <code>magic-string</code>은 나중에 <strong>&quot;원본의 어느 위치가 결과물의 어느 위치에 해당하는지&quot;</strong> 역산할 수 있게 됩니다.</p>
<pre><code class="language-mermaid">flowchart TD
    A[&quot;🔧 Module.ts MagicString.overwrite()&quot;]
    B[&quot;📦 Graph.ts Bundle.addSource()&quot;]
    C[&quot;🗺️ Graph.ts Bundle.generateMap()&quot;]
    D[&quot;📄 index.js.map VLQ 인코딩된 소스맵&quot;]

    A --&gt;|변환 이력 기록| B
    B --&gt;|각 모듈의 이력 통합| C
    C --&gt;|생성| D
</code></pre>
<hr/>
<h2>3. VLQ: mappings 가 외계어인 이유</h2>
<p>이제 그 암호문으로 돌아올 차례입니다.</p>
<pre><code>&quot;mappings&quot;: &quot;AAAA,SAAS&quot;
</code></pre>
<p>이게 뭘까요? 이건 <strong>VLQ(Variable Length Quantity)</strong> 인코딩으로 압축된 좌표 데이터입니다.</p>
<h3>왜 이렇게 압축하나요?</h3>
<p>소스맵은 원본 코드의 모든 토큰(변수명, 괄호, 세미콜론 하나하나)에 대한 위치 정보를 담아야 합니다. 수천 개의 좌표를 그냥 숫자로 저장하면 <code>.map</code> 파일이 번들 파일보다 훨씬 커질 수 있습니다.
VLQ는 두 가지 트릭으로 이를 해결합니다.</p>
<ul>
<li>
<p><strong>트릭 1. 상대 좌표</strong>
절대 좌표(100번째 줄) 대신 이전 항목으로부터의 거리(+2줄)를 저장합니다. 대부분의 연속된 코드는 서로 가깝기 때문에, 숫자가 훨씬 작아집니다.</p>
</li>
<li>
<p><strong>트릭 2. 가변 길이 인코딩 + Base64</strong>
작은 숫자(<code>0</code>)는 1글자(<code>A</code>)로, 큰 숫자는 여러 글자로 표현합니다. <code>Base64(A-Z, a-z, 0-9, +, /)</code>를 사용해 바이너리 없이 텍스트 파일에 저장합니다.</p>
</li>
</ul>
<p><strong>그런데 왜 굳이 알파벳으로 바꾸나요?</strong></p>
<p>숫자를 줄였으면 됐지, 왜 또 <code>A</code>나 <code>S</code> 같은 글자로 바꾸는 걸까요?</p>
<p>이유는 단 하나입니다. <strong>쉼표나 마이너스 기호 없이 데이터를 다닥다닥 붙여 써서 용량을 극한으로 줄이기 위해서</strong> 입니다.</p>
<p>만약 숫자를 그대로 텍스트 파일에 쓴다면 <code>9,0,0,9</code> = 7글자가 필요합니다. 숫자가 커져서 <code>15,0,-10,5</code>가 되면 11글자입니다. 소스맵에는 이런 좌표가 수십만 개가 들어갑니다. 쉼표나 마이너스 기호조차도 용량 낭비인 셈입니다.</p>
<p>그래서 숫자 여러 개를 알파벳 하나에 욱여넣습니다. <code>9,0,0,9</code>라는 7글자짜리 데이터가 <code>SAAS</code>라는 딱 4글자로 압축됩니다.</p>
<p><code>A</code>나 <code>S</code> 자체에는 아무런 특별한 의미가 없습니다. Base64라는 전 세계 공통 암호표에 적힌 기호일 뿐입니다.</p>
<p><code>A = 0번, B = 1번, C = 2번 ... S = 18번 ... a = 26번 ...</code></p>
<p>컴퓨터가 소스맵에서 <code>S</code>를 발견하면 <strong>&quot;Base64 18번 값이구나!&quot;</strong> 하고 읽어들입니다.<br/>
그 18이라는 숫자를 6비트 이진수로 펼치면 실제 좌표 값이 나오는 구조입니다.</p>
<h3>VLQ 비트 구조</h3>
<p>암호문을 해독하기 전에, 각 Base64 문자 안에 담긴 6비트의 역할부터 파악해야 합니다.</p>
<pre><code class="language-text">[ bit5 | bit4  bit3  bit2  bit1 | bit0 ]
    ↑    ←—— 값 비트 (4비트) ——→    ↑
 연속 비트                       부호 비트
(0=끝, 1=다음 문자도 같은 숫자)  (첫 번째 그룹만)
                                (0=양수, 1=음수)
</code></pre>
<ul>
<li><strong>bit 5 (연속 비트)</strong>: <code>1</code>이면 다음 Base64 문자도 같은 숫자의 일부입니다. <code>0</code>이면 이 문자가 마지막입니다. 15보다 큰 값을 여러 글자에 걸쳐 표현할 수 있는 핵심 장치입니다.</li>
<li><strong>bit 4~1 (값 비트)</strong>: 4비트로 0~15를 담습니다.</li>
<li><strong>bit 0 (부호 비트)</strong>: <strong>오직 첫 번째 그룹에만</strong> 적용됩니다. <code>0</code> = 양수, <code>1</code> = 음수. 상대 좌표는 음수가 될 수 있으므로 부호가 필요합니다.</li>
</ul>
<p>이걸 기차 화물칸으로 비유하면 이렇습니다. 각 Base64 문자는 6칸짜리 기차 화물칸입니다.</p>
<ul>
<li><strong>맨 앞칸(연속 비트)</strong>: &quot;뒤에 기차칸이 더 연결되어 있나요?&quot; (1이면 연결됨, 0이면 끝)</li>
<li><strong>가운데 4칸(값 비트)</strong>: 실제 짐(숫자)을 싣는 공간 (0~15)</li>
<li><strong>맨 뒤칸(부호 비트)</strong>: &quot;양수인가요, 음수인가요?&quot; (0=양수, 1=음수, 첫 번째 칸에만 적용)</li>
</ul>
<h3>AAAA 직접 해독해보기</h3>
<p><code>mappings</code>의 각 세그먼트는 쉼표(<code>,</code>)로 구분되고, 세미콜론(<code>;</code>)은 번들 파일의 줄 바꿈을 의미합니다.</p>
<p><code>AAAA</code>라는 가장 단순한 세그먼트를 직접 해독해봅시다.</p>
<p><code>&#x27;A&#x27;</code>는 Base64 값 0 = <code>000000</code>(2진수)입니다. 비트 구조를 적용하면 bit 5 = <code>0</code>(연속 없음), bit 4~1 = <code>0000</code>(값 0), bit 0 = <code>0</code>(양수)이므로 값은 <strong>0</strong>입니다.</p>
<pre><code class="language-mermaid">flowchart LR
    S([&quot;AAAA&quot;])

    S --&gt;|&quot;1번째 A&quot;| C1[&quot;000000₂ = 0&quot;]
    S --&gt;|&quot;2번째 A&quot;| C2[&quot;000000₂ = 0&quot;]
    S --&gt;|&quot;3번째 A&quot;| C3[&quot;000000₂ = 0&quot;]
    S --&gt;|&quot;4번째 A&quot;| C4[&quot;000000₂ = 0&quot;]

    C1 --&gt; R1[&quot;① 생성파일 열 = 0&quot;]
    C2 --&gt; R2[&quot;② 소스파일 인덱스 = 0&quot;]
    C3 --&gt; R3[&quot;③ 소스 행 = 0&quot;]
    C4 --&gt; R4[&quot;④ 소스 열 = 0&quot;]
</code></pre>
<p>이 4개의 숫자는 각각 다음을 의미합니다.</p>






























<table><thead><tr><th>순서</th><th>의미</th><th>값</th></tr></thead><tbody><tr><td>1번째</td><td>번들 파일 열</td><td>0 (0번째 열)</td></tr><tr><td>2번째</td><td>sources 배열 인덱스</td><td>0 (첫 번째 파일)</td></tr><tr><td>3번째</td><td>원본 파일 행</td><td>0 (0번째 줄)</td></tr><tr><td>4번째</td><td>원본 파일 열</td><td>0 (0번째 열)</td></tr></tbody></table>
<p>따라서 <code>AAAA</code>는 <strong>&quot;번들 파일의 0번째 열 → 소스 0번 파일(src/index.js)의 0행 0열&quot;</strong> 을 의미합니다.</p>
<p>이제 <code>S</code>가 왜 9인지도 추적해봅시다. <code>&#x27;S&#x27;</code>는 Base64 값 18 = <code>010010</code>(2진수)입니다.</p>
<ul>
<li>bit 5 = <code>0</code> → 연속 없음</li>
<li>bit 4~1 = <code>1001</code> → 값 = 9</li>
<li>bit 0 = <code>0</code> → 양수</li>
</ul>
<p>따라서 <code>S</code>는 값 <strong>9</strong>입니다. <code>SAAS</code>는 <code>[9, 0, 0, 9]</code>이고, 상대 좌표이므로 앞 세그먼트 <code>[0,0,0,0]</code>에 더해 <strong>번들 9번째 열 → src/index.js 0행 9열</strong>을 가리킵니다.</p>
<pre><code class="language-mermaid">flowchart LR
    subgraph seg1[&quot;세그먼트 ① AAAA&quot;]
        direction TB
        D1[&quot;[0, 0, 0, 0]&quot;]
        M1[&quot;번들 0열&lt;br&gt;→ src/index.js 0행 0열&quot;]
        D1 --&gt; M1
    end

    subgraph seg2[&quot;세그먼트 ② SAAS&lt;br&gt; (상대 좌표 +9, +0, +0, +9)&quot;]
        direction TB
        D2[&quot;[9, 0, 0, 9]&quot;]
        M2[&quot;번들 9열&lt;br&gt;→ src/index.js 0행 9열&quot;]
        D2 --&gt; M2
    end

    seg1 --&gt;|&quot;다음 세그먼트&quot;| seg2
</code></pre>
<h3>15를 초과하는 값 — 연속 비트 동작 예시</h3>
<p>지금까지는 9 이하 값들만 등장했습니다. 첫 번째 그룹의 4비트 값(0~15)으로 충분했기 때문입니다. 값이 16 이상이 되면 처음으로 연속 비트(bit 5)가 실제로 켜집니다.</p>
<p>값 <strong>20</strong>을 인코딩해봅시다.</p>
<ul>
<li><strong>하위 4비트</strong> = <code>0100</code> (= 4), 남은 값 = <code>20 &gt;&gt; 4</code> = <strong>1</strong> → 연속 비트 = <code>1</code></li>
<li><strong>부호</strong> = <code>0</code> (양수)</li>
<li>첫 번째 문자: <code>1|0100|0</code> = <code>101000₂</code> = 40 → Base64[40] = <strong><code>o</code></strong></li>
</ul>
<p>남은 값 1을 두 번째 문자로:</p>
<ul>
<li>두 번째 문자부터는 <strong>부호 비트 없이 5비트 전체</strong>가 값 → <code>0|00001</code> = <code>000001₂</code> = 1 → Base64[1] = <strong><code>B</code></strong></li>
</ul>
<p><strong>값 20 = <code>oB</code></strong> (두 글자). 복원 시: 4 + 1 × 16 = 20.</p>
<p>두 번째 글자의 bit 0은 더 이상 부호 비트가 아닙니다. 두 번째 글자부터는 bit 5만 연속 비트로 쓰고, 나머지 5비트(bit 4~0) 전체가 값을 담습니다. 값이 커질수록 세 번째, 네 번째 글자로 이어지는 구조입니다.</p>
<h3>상대 좌표의 기준점은 어떻게 정해지나요?</h3>
<p>한 가지 궁금증이 남을 수 있습니다. 상대 좌표라면 기준점이 있어야 하는데, 그 기준점은 어떻게 정해지는 걸까요?</p>
<p>답은 간단합니다. <strong>맨 처음 <code>[0, 0, 0, 0]</code>에서 출발해서, 방금 전까지 계산해 둔 최종 위치가 다음 이동의 기준점</strong>이 됩니다. 꼬리에 꼬리를 무는 릴레이 달리기입니다.</p>
<p>세 번째 세그먼트가 있고 해독해보니 <code>[+5, +0, +1, -3]</code>이라면?</p>
<ul>
<li>직전 도착지점: <code>[9, 0, 0, 9]</code></li>
<li>이동 거리: <code>[+5, +0, +1, -3]</code></li>
<li>새 도착지점: <code>[14, 0, 1, 6]</code></li>
</ul>
<p>이런 식으로 계속 누적됩니다. 외부에서 기준점을 정해주는 것이 아니라, 항상 직전 세그먼트에서 이어받는 구조입니다.</p>
<p>그리고 세미콜론(<code>;</code>)이 등장하면 번들 파일에서 다음 줄로 넘어갔다는 뜻입니다. 이때 <strong>번들 파일의 열(첫 번째 값)만 0으로 초기화</strong>되고, 나머지 원본 파일의 위치(파일 번호, 행, 열)는 그대로 유지됩니다. 번들 파일에서 줄이 바뀌어도 원본 코드 기준으로는 아까 읽던 위치 근처일 가능성이 높기 때문입니다.</p>
<p>이 복잡한 비트 연산을 직접 구현하는 건 매우 어렵습니다. 그래서 우리는 <code>magic-string</code>이 이걸 대신해 줍니다. 우리가 할 일은 단지 <code>overwrite()</code>로 이력을 남기고, <code>generateMap()</code>을 호출하는 것뿐입니다.</p>
<hr/>
<h2>4. 구현: Graph.ts와 Module.ts</h2>
<p>이제 실제 코드를 봅시다. 소스맵 생성은 <code>Graph.ts</code>의 <code>generate()</code> 메서드에서 이루어집니다.</p>
<h3>Module.ts: 변환 이력 기록</h3>
<p><code>Module</code> 클래스는 생성 시점에 <code>MagicString</code>을 초기화합니다.</p>
<pre><code class="language-typescript">// Module.ts (line 65)
this.magicString = new MagicString(this.content);
// 원본 코드 내용을 MagicString으로 감쌉니다.
// 이후 모든 변환 작업은 이 객체를 통해 이루어집니다.
</code></pre>
<p><code>transform()</code> 메서드에서 ESM → CJS 변환이 이루어질 때마다 이력이 쌓입니다.</p>
<pre><code class="language-typescript">// Module.ts (line 198): ImportDeclaration 변환 시
// &quot;import { Button } from &#x27;./Button.js&#x27;&quot; 구간을 교체
this.magicString.overwrite(node.start, node.end, replacement);
// node.start, node.end: AST가 알려준 원본 코드의 정확한 위치
// replacement: &quot;const { Button } = require(1);&quot;
</code></pre>
<p>AST가 <strong>&quot;어디를 바꿔야 하는지&quot;</strong> 를 알려주고, <code>magic-string</code>이 <strong>&quot;어디가 어떻게 바뀌었는지&quot;</strong> 를 기억합니다. 이 두 도구의 역할 분리가 핵심입니다.</p>
<blockquote>
<p><strong><a href="https://github.com/Han5991/fe-lab/blob/main/packages/@package/bundler/src/Module.ts#L65">MagicString 초기화 코드 보기 (Module.ts:65)</a></strong></p>
</blockquote>
<blockquote>
<p><strong><a href="https://github.com/Han5991/fe-lab/blob/main/packages/@package/bundler/src/Module.ts#L198">overwrite() 이력 기록 전체 보기 (Module.ts:198)</a></strong></p>
</blockquote>
<h3>Graph.ts: 소스맵 통합 생성</h3>
<p><code>generate()</code> 메서드에서 모든 모듈의 이력을 <code>Bundle</code>로 통합하고 소스맵을 생성합니다.</p>
<pre><code class="language-typescript">// Graph.ts (line 97~)
const bundle = new Bundle({
  separator: &#x27;\n&#x27;, // 각 모듈 사이의 구분자
});

// 각 모듈을 Bundle에 추가 (line 155~158)
bundle.addSource({
  filename: path.relative(process.cwd(), module.filePath), // ⭐ 보안 핵심 (아래 설명)
  content: module.magicString, // 변환 이력이 담긴 객체를 통째로 전달
});

// VLQ 인코딩된 소스맵 생성 (line 178~182)
const map = bundle.generateMap({
  file: &#x27;index.js.map&#x27;, // 소스맵 파일명
  includeContent: true, // 원본 코드도 .map에 포함
  hires: true, // 고해상도 (열 단위 매핑)
});
</code></pre>
<p><code>bundle.generateMap()</code>이 호출되는 순간, <code>magic-string</code>은:</p>
<ol>
<li>모든 <code>addSource()</code>로 추가된 모듈들의 변환 이력을 수집합니다.</li>
<li>각 변환 이력을 VLQ 인코딩으로 변환합니다.</li>
<li><code>mappings</code> 문자열을 생성해서 <code>.map</code> 파일로 저장합니다.</li>
</ol>
<blockquote>
<p><strong><a href="https://github.com/Han5991/fe-lab/blob/main/packages/@package/bundler/src/Graph.ts#L92">소스맵 생성 전체 코드 보기 (Graph.ts:92)</a></strong></p>
</blockquote>
<hr/>
<h2>5. 보안: 절대 경로 노출 문제</h2>
<p><code>filename</code> 옵션에 주목하세요. 여기에 무심코 절대 경로를 넣으면 보안 문제가 생깁니다.</p>
<pre><code class="language-typescript">// ❌ 위험: 절대 경로 그대로 사용
bundle.addSource({
  filename: module.filePath,
  // → &quot;/Users/han/repository/my-company/secret-project/src/index.js&quot;
  content: module.magicString,
});
</code></pre>
<p>소스맵의 <code>sources</code> 배열에 이 경로가 그대로 들어갑니다.</p>
<pre><code class="language-json">{
  &quot;sources&quot;: [&quot;/Users/han/repository/my-company/secret-project/src/index.js&quot;]
}
</code></pre>
<p>이 <code>.map</code> 파일이 프로덕션 서버에 배포된다면:</p>
<ul>
<li>개발자의 <strong>로컬 시스템 구조</strong>가 노출됩니다.</li>
<li><strong>프로젝트 내부 경로</strong>가 외부에 공개됩니다.</li>
<li>보안 감사에서 지적 사항이 될 수 있습니다.</li>
</ul>
<h3>해결책: path.relative()</h3>
<pre><code class="language-typescript">// ✅ 안전: 프로젝트 루트 기준 상대 경로 사용 (Graph.ts line 156)
bundle.addSource({
  filename: path.relative(process.cwd(), module.filePath),
  // → &quot;src/index.js&quot; (프로젝트 루트 기준)
  content: module.magicString,
});
</code></pre>
<p><code>path.relative(process.cwd(), module.filePath)</code>는:</p>
<ul>
<li><code>process.cwd()</code>: 번들러가 실행되는 디렉토리 (보통 프로젝트 루트)</li>
<li><code>module.filePath</code>: 모듈의 절대 경로</li>
<li>결과: 프로젝트 루트 기준의 상대 경로</li>
</ul>
<p>소스맵 <code>sources</code> 배열에는 깔끔한 상대 경로만 남습니다.</p>
<pre><code class="language-json">{
  &quot;sources&quot;: [&quot;src/index.js&quot;, &quot;src/components/Button.js&quot;]
}
</code></pre>
<blockquote>
<p><strong><a href="https://github.com/Han5991/fe-lab/blob/main/packages/@package/bundler/src/Graph.ts#L156">path.relative() 적용 코드 보기 (Graph.ts:156)</a></strong></p>
</blockquote>
<h3>더 큰 위험: 소스 코드 자체의 유출</h3>
<p>절대 경로 노출보다 더 치명적인 문제가 있습니다. 소스맵 파일 하나만 있어도 <strong>원본 코드를 통째로 복구할 수 있습니다.</strong></p>
<p><code>includeContent: true</code>로 생성된 소스맵에는 <code>sourcesContent</code> 배열에 원본 파일의 코드가 텍스트 그대로 들어갑니다. 누군가 <code>.map</code> 파일을 다운로드하기만 하면 주석 하나까지 완벽하게 원본을 살려낼 수 있습니다. (<code>reverse-sourcemap</code> 같은 도구가 이를 자동화해 줍니다.)</p>
<p><code>sourcesContent</code>가 없는 경우에도 마찬가지입니다. <code>mappings</code>가 &quot;번들의 이 글자는 원본의 저 변수명이다&quot;를 기억하고 있기 때문에, 번들된 코드를 역추적해서 원래 변수명, 함수명, 파일 구조를 그대로 조립해낼 수 있습니다.</p>
<p><strong>그래서 실무에서는 이렇게 대처합니다.</strong></p>

























<table><thead><tr><th>전략</th><th>방법</th><th>적합한 경우</th></tr></thead><tbody><tr><td>소스맵 생성 안 함</td><td>빌드 옵션에서 소스맵 비활성화</td><td>에러 추적 도구를 쓰지 않는 경우</td></tr><tr><td>배포 서버에 올리지 않음</td><td><code>.map</code> 파일은 빌드하되 서버 업로드에서 제외</td><td>Sentry 같은 에러 수집 서버에만 업로드</td></tr><tr><td>Hidden 소스맵</td><td>JS 파일 맨 아래 <code>//# sourceMappingURL=...</code> 주석만 제거</td><td>브라우저가 소스맵을 찾지 못하도록 숨김</td></tr></tbody></table>
<hr/>
<h2>6. 라이브러리 배포와 소스맵 전략</h2>
<p>우리가 만들고 있는 것은 웹사이트가 아니라 <strong>라이브러리</strong>입니다. 라이브러리 배포 맥락에서 소스맵을 어떻게 다뤄야 할지 살펴봅니다.</p>
<h3>sourcesContent의 용량 문제</h3>
<p>일반적으로 <code>.map</code> 파일의 크기는 번들된 <code>.js</code> 파일보다 2~3배 더 큽니다. 원본 코드가 텍스트로 전부 들어가기 때문입니다.</p>
<p>그런데도 이 거대한 파일을 만드는 이유는, <strong>일반 사용자는 이 파일을 다운로드하지 않기 때문</strong>입니다.</p>
<ul>
<li><strong>일반 사용자</strong>: 웹사이트에 접속하면 <code>bundle.js</code>만 다운로드합니다. <code>.map</code> 파일은 전혀 신경 쓰지 않습니다.</li>
<li><strong>개발자(디버깅)</strong>: F12를 눌러 DevTools를 여는 순간, 브라우저가 <code>//# sourceMappingURL=...</code> 주석을 보고 그때서야 <code>.map</code> 파일을 백그라운드에서 다운로드하기 시작합니다.</li>
</ul>
<p>사용자들의 로딩 속도에는 전혀 영향을 주지 않습니다.</p>
<h3>라이브러리 사용자는 원본 코드가 없습니다</h3>
<p>npm에 라이브러리를 배포할 때 보통 <code>dist/</code> 폴더만 올립니다. 라이브러리 사용자의 <code>node_modules/my-lib/</code> 안에는 <code>dist/index.js</code>와 <code>dist/index.js.map</code>만 존재합니다. 원본이 담긴 <code>src/</code> 폴더는 없습니다.</p>
<p>소스맵에 <code>sourcesContent</code>가 없다면, 라이브러리 사용자가 디버깅하려고 에러를 클릭해도 브라우저는 원본 코드 파일을 찾지 못합니다. <code>sourcesContent</code>에 원본 코드를 통째로 넣어줘야만 라이브러리 사용자의 DevTools에서 원본 코드를 볼 수 있습니다.</p>
<h3>소스맵 체이닝</h3>
<p>라이브러리 사용자는 보통 Webpack이나 Vite 같은 자신만의 번들러를 사용합니다. 이 번들러가 <code>node_modules/my-lib/dist/index.js</code>를 가져와 자신의 앱에 합칠 때, 우리의 <code>index.js.map</code>을 발견하고 자신의 소스맵과 연결합니다.</p>
<p>결과적으로 최종 웹앱에서 에러가 나면, DevTools는 수많은 빌드 과정을 역추적해서 &quot;이 에러는 설치한 라이브러리의 <code>src/components/Button.js</code> 15번째 줄에서 났네요!&quot; 라고 정확히 짚어줍니다.</p>
<h3>어떤 전략을 선택해야 할까요?</h3>

















<table><thead><tr><th>상황</th><th>권장 전략</th></tr></thead><tbody><tr><td><strong>오픈소스 라이브러리</strong></td><td><code>includeContent: true</code>로 소스맵 포함 배포. 어차피 코드가 공개되어 있고 DX가 압도적으로 좋아집니다.</td></tr><tr><td><strong>사내용/비공개 라이브러리</strong></td><td>소스맵을 npm에 올리지 않거나, <code>sourcesContent</code> 없이 배포. 에러 수집 서버(Sentry 등)에만 소스맵을 별도 업로드합니다.</td></tr></tbody></table>
<p>소스맵을 아예 배포하지 않으면? 라이브러리를 설치한 개발자가 에러를 만났을 때, 글 맨 첫 줄의 그 상황이 그대로 재현됩니다.</p>
<pre><code class="language-text">Uncaught TypeError: Cannot read properties of undefined at node_modules/my-lib/dist/index.js:2847:23
</code></pre>
<p>디버깅을 포기하고 GitHub 이슈 탭에 불친절한 리포트를 남기게 됩니다.</p>
<hr/>
<h2>7. 결과물 확인</h2>
<p>이론은 충분히 다뤘습니다. 실제로 빌드해서 소스맵이 제대로 생성되는지 확인해봅시다.</p>
<pre><code class="language-bash">$ pnpm --filter @package/sample-lib run build

📦 Minibundler started...
📂 Processing: /Users/han/.../src/index.js
📂 Processing: /Users/han/.../src/components/Button.js
🛠️  Generating bundle...
📦 Generated CJS Bundle: dist/index.js
🗺️  Generated SourceMap: dist/index.js.map
✨ Generated Standalone ESM: dist/index.mjs
✅ Bundle built successfully!
</code></pre>
<p><code>dist/</code> 디렉토리에 세 개의 파일이 생성됩니다.</p>
<pre><code class="language-text">dist/
├── index.js        ← CJS 번들 (맨 아래에 sourceMappingURL 주석 포함)
├── index.js.map    ← 소스맵
└── index.mjs       ← ESM 번들
</code></pre>
<p><code>index.js</code> 맨 마지막 줄을 확인해보면 소스맵 참조 주석이 붙어 있습니다.</p>
<pre><code class="language-javascript">// dist/index.js 마지막 줄
//# sourceMappingURL=index.js.map
</code></pre>
<p>브라우저와 Node.js는 이 주석을 읽고 자동으로 소스맵을 불러옵니다.</p>
<h3>왜 JS 파일명과 똑같이 만들까요? 다르면 안 되나요?</h3>
<p><code>index.js</code> → <code>index.js.map</code>. 이 패턴이 너무 당연하게 느껴지지 않나요?</p>
<p>사실 <strong>이름은 달라도 됩니다.</strong> JS 파일과 소스맵 파일을 연결하는 건 파일명이 아니라, JS 파일 맨 아래의 주석 한 줄입니다.</p>
<pre><code class="language-javascript">//# sourceMappingURL=index.js.map
//                   ^^^^^^^^^^^^
//                   여기에 적힌 경로로 소스맵을 찾아감
</code></pre>
<p>브라우저는 오직 이 주석만 봅니다. 따라서 이렇게 써도 동작합니다.</p>
<pre><code class="language-javascript">//# sourceMappingURL=debug-info.map       // 완전히 다른 이름
//# sourceMappingURL=sourcemaps/main.map  // 하위 디렉토리
//# sourceMappingURL=data:application/json;base64,eyJ2Z... // 파일 없이 인라인으로 embed
</code></pre>
<p>그렇다면 이름을 바꾸려면 코드의 어디를 수정해야 할까요? 우리 코드에서 <code>index.js.map</code>이 <strong>세 군데</strong> 등장합니다.</p>
<pre><code class="language-typescript">// Graph.ts (line 178~192): 세 곳이 모두 맞물려야 합니다
const map = bundle.generateMap({
  file: &#x27;index.js.map&#x27;,  // ① 소스맵 내부의 자기 파일명
  ...
});

fs.writeFileSync(path.join(distDir, &#x27;index.js.map&#x27;), map.toString()); // ② 실제 저장 파일명

fs.appendFileSync(
  path.join(distDir, &#x27;index.js&#x27;),
  &#x27;\n//# sourceMappingURL=index.js.map&#x27;,  // ③ JS 파일에 심는 참조 경로
);
</code></pre>
<p>세 곳 중 하나라도 어긋나면 소스맵 연결이 끊깁니다. 예를 들어 ③만 바꾸면 브라우저가 없는 파일을 찾으러 가고, ②만 바꾸면 브라우저가 여전히 <code>index.js.map</code>을 찾으려다 404를 냅니다.</p>
<p>그럼에도 <code>&lt;파일명&gt;.map</code> 관행이 굳어진 이유는 실용적인 이유 때문입니다.</p>





















<table><thead><tr><th>이유</th><th>설명</th></tr></thead><tbody><tr><td><strong>가독성</strong></td><td><code>main.js.map</code>, <code>vendor.js.map</code>처럼 번들이 여러 개일 때 어떤 맵인지 이름만 봐도 알 수 있다</td></tr><tr><td><strong>일괄 처리</strong></td><td>배포 스크립트에서 <code>*.map</code> 패턴으로 소스맵만 골라 서버에서 제외하거나 별도 보관하기 쉽다</td></tr><tr><td><strong>도구 호환성</strong></td><td>일부 도구가 <code>&lt;name&gt;.map</code> 패턴을 기반으로 소스맵을 자동 탐색한다</td></tr></tbody></table>
<p>결론적으로, 파일명은 <strong>관행이지 규칙이 아닙니다.</strong> 세 곳을 일관되게 맞추기만 하면 어떤 이름이든 동작합니다.</p>
<h3>sources 상대 경로 확인</h3>
<p>소스맵에 절대 경로가 포함되지 않았는지 확인해봅시다.</p>
<pre><code class="language-bash">node -e &quot;
  const fs = require(&#x27;node:fs&#x27;);
  const map = JSON.parse(fs.readFileSync(&#x27;packages/@package/sample-lib/dist/index.js.map&#x27;, &#x27;utf-8&#x27;));
  console.log(&#x27;sources:&#x27;, map.sources);
&quot;
</code></pre>
<p>정상이라면 아래처럼 상대 경로만 출력됩니다.</p>
<pre><code class="language-bash">sources: [ &#x27;src/index.js&#x27;, &#x27;src/components/Button.js&#x27; ]
</code></pre>
<p>절대 경로(<code>/Users/han/...</code>)가 보인다면 <code>path.relative()</code> 처리가 빠진 것입니다.</p>
<hr/>
<h2>8. 마무리</h2>
<p>이번 편에서 우리가 만든 것을 정리해봅시다.</p>
<ol>
<li><strong>Module 생성 시</strong> — <code>new MagicString(content)</code>으로 원본 코드를 변환 이력 저장소에 담습니다.</li>
<li><strong>ESM → CJS 변환 시</strong> — <code>magicString.overwrite(start, end, replacement)</code>로 &quot;원본 N번째 ~ M번째 자리가 새 코드로 바뀌었다&quot;를 기록합니다.</li>
<li><strong>번들 조합 시</strong> — <code>bundle.addSource({ filename: 상대경로, content: magicString })</code>으로 각 모듈의 이력을 통합합니다.</li>
<li><strong>소스맵 생성 시</strong> — <code>bundle.generateMap({ includeContent: true, hires: true })</code>로 이력을 VLQ 인코딩 → <code>mappings</code> 문자열 → <code>.map</code> 파일로 저장합니다.</li>
</ol>
<p>1편에서 처음 <code>MagicString</code>을 소개했을 때, 단순한 문자열 치환 도구처럼 보였습니다. 하지만 이제는 그 진짜 목적이 보입니다. <strong>변환 이력을 기록하기 위한 장치</strong>였던 것입니다. 소스맵을 위해 처음부터 설계된 선택이었습니다.</p>
<hr/>
<h2>에필로그: 여기서 더 나아간다면</h2>
<p>소스맵까지 완성한 지금, 우리 번들러는 <strong>&quot;번들러가 어떻게 동작하는가&quot;</strong> 라는 질문에 충분히 답할 수 있는 도구가 됐습니다.<br/>
<code>acorn</code>으로 AST를 파싱하고, 모듈 그래프를 따라 의존성을 추적하고, <code>MagicString</code>으로 코드를 합치고, 소스맵으로 원본을 추적하는 것까지.</p>
<p>실제 번들러들은 여기에 더 많은 것을 올려놓습니다.</p>
<ul>
<li><strong>Externals</strong> — <code>react</code> 같은 peer dependency를 번들에서 제외하고 런타임에 외부에서 주입받는 전략. 라이브러리 배포에 필수적입니다.</li>
<li><strong>Tree Shaking</strong> — ESM의 정적 구조를 분석해 실제로 사용되지 않는 export를 번들에서 제거합니다. <code>import { one } from &#x27;./math&#x27;</code>만 썼다면 <code>two</code>, <code>three</code>는 번들에 들어가지 않습니다.</li>
<li><strong>Code Splitting</strong> — 번들을 여러 청크로 나눠 초기 로딩에 필요한 코드만 먼저 내려받게 합니다. 동적 <code>import()</code>가 분기점이 됩니다.</li>
<li><strong>Plugin System</strong> — webpack의 탭어블 훅, rollup의 플러그인 API처럼, 번들링 파이프라인의 각 단계에 외부 코드가 끼어들 수 있는 확장 지점을 만드는 구조입니다.</li>
<li><strong>Scope Hoisting</strong> — 모듈 경계를 제거해 함수 래퍼를 줄이고 런타임 오버헤드를 낮춥니다.</li>
<li><strong>HMR</strong> — 전체 페이지 리로드 없이 변경된 모듈만 런타임에 교체합니다.</li>
</ul>
<p>이 중 어느 것을 열어봐도, 우리가 Step 1부터 다뤄온 개념들이 그 안에 있습니다.<br/>
acorn, AST, 모듈 그래프, 코드 생성. 실제 도구의 코드베이스가 낯설지 않을 겁니다. <a href="https://github.com/rollup/rollup">rollup</a>이 비교적 읽기 쉽고, <a href="https://github.com/evanw/esbuild">esbuild</a>는 성능 관점에서 흥미롭습니다.</p>
<hr/>
<h2>체크리스트 (Step 4)</h2>
<ul class="contains-task-list">
<li class="task-list-item"><input type="checkbox" disabled=""/> 소스맵이 &quot;번들 좌표 → 원본 좌표&quot; 변환표라는 개념을 이해했다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> <code>MagicString.overwrite()</code>가 단순 치환이 아니라 <strong>이력 기록</strong>임을 이해했다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> <code>Bundle.addSource()</code> → <code>Bundle.generateMap()</code> 흐름을 설명할 수 있다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> <code>AAAA</code> VLQ 세그먼트가 <code>[0, 0, 0, 0]</code>을 의미함을 이해했다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> VLQ가 숫자 대신 Base64 문자를 쓰는 이유(용량 절감)를 설명할 수 있다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 상대 좌표가 <code>[0,0,0,0]</code>에서 출발해 누적되는 릴레이 방식임을 이해했다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 소스맵을 통한 원본 코드 복구 가능성과 보안 위험을 설명할 수 있다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 오픈소스/비공개 라이브러리의 소스맵 배포 전략 차이를 설명할 수 있다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> <code>path.relative()</code>를 써야 하는 <strong>보안 이유</strong>를 설명할 수 있다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> <code>dist/index.js.map</code>의 <code>sources</code>가 상대 경로인지 직접 확인했다.</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>[누가 시키지도 않았는데 번들러 만들기] 3. 번들링과 스코프: 파일 합치기의 기술</title>
      <link>https://blog.sangwook.dev/posts/no-one-asked-library-bundler-03-bundling-scope/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/no-one-asked-library-bundler-03-bundling-scope/</guid>
      <pubDate>Sun, 22 Feb 2026 15:00:00 GMT</pubDate>
      <description>단순히 파일을 이어붙이는 것과 번들링은 다릅니다. 같은 이름의 변수가 서로를 덮어쓰지 않도록 IIFE로 스코프를 가두는 전략과, 합쳐진 결과물이 브라우저에서 제대로 실행되게 만드는 런타임 구현 과정을 깊이 있게 다룹니다.</description>
      <content:encoded><![CDATA[<blockquote>
<p><strong>&quot;지도는 완성됐다. 이제 길을 닦을 차례다.&quot;</strong></p>
</blockquote>
<p>지난 시간(<a href="https://blog.sangwook.dev/posts/no-one-asked-library-bundler-02-ast-graph/">Step 2. 코드를 데이터로 보는 법</a>), 우리는 코드를 <strong>데이터</strong> 로 변환하고, 파일들 사이의 복잡한 관계를 <strong>그래프</strong> 로 그려냈습니다. 이제 우리 손에는 프로젝트의 모든 파일 정보가 담긴 <strong>&#x27;지도&#x27;</strong> 가 쥐어져 있습니다.<br/>
이제 남은 건 하나입니다. 이 흩어진 파일 조각들을 모아서, 브라우저가 실행할 수 있는 <strong>단 하나의 결과물</strong> 로 만드는 것입니다.</p>
<hr/>
<h2>1. 그냥 텍스트로 합치면 안 되나요?</h2>
<p>가장 단순하게 생각해봅시다. 번들링이란 결국 파일을 합치는 것이니, 리눅스의 <code>cat</code> 명령어처럼 파일 내용을 순서대로 이어 붙이면 되지 않을까요?</p>
<pre><code class="language-javascript">// a.js
const name = &#x27;Module A&#x27;;
console.log(name);

// b.js
const name = &#x27;Module B&#x27;;
console.log(name);
</code></pre>
<p>이 두 파일을 합쳐서 브라우저에서 실행해 봅니다. 결과는 어떨까요?</p>
<blockquote>
<p>❌ <strong>Uncaught SyntaxError: Identifier &#x27;name&#x27; has already been declared</strong></p>
</blockquote>
<p><strong>처참한 실패입니다.</strong>
파일이 나뉘어 있을 때는 각자의 스코프가 보장되었지만, 하나로 합쳐지는 순간 <strong>모든 변수가 전역 스코프에 노출되면서 충돌이 발생</strong>하기 때문입니다.<br/>
이것이 바로 번들러가 단순히 파일을 이어 붙이는 것과 다른 점입니다. 번들러의 첫 번째 임무는 <strong>&quot;각 모듈의 독립적인 스코프를 지켜주는 것&quot;</strong> 입니다.</p>
<hr/>
<h2>2. 해결책: 함수로 벽 세우기</h2>
<p>자바스크립트에서 변수를 가두는 가장 확실한 방법은 무엇일까요? 바로 <strong>함수</strong> 입니다.<br/>
우리는 모듈의 코드를 <strong>즉시 실행 함수</strong> 로 감싸서, 외부와 격리된 스코프를 만들어줄 것입니다.</p>
<h3>🏗️ Before &amp; After</h3>
<p><strong>[Before: 충돌하는 코드]</strong></p>
<pre><code class="language-javascript">const name = &#x27;Module A&#x27;; // 💥 전역 변수 충돌!
const name = &#x27;Module B&#x27;;
</code></pre>
<p><strong>[After: 평화를 되찾은 코드]</strong></p>
<pre><code class="language-javascript">(function () {
  const name = &#x27;Module A&#x27;; // ✅ 이 함수 안에서만 유효함
})();

(function () {
  const name = &#x27;Module B&#x27;; // ✅ 안전함
})();
</code></pre>
<p>이제 변수 <code>name</code>은 각자의 함수 안에 갇혔습니다. 아무리 많은 파일을 합쳐도 변수 이름이 겹칠 걱정은 없습니다.</p>
<blockquote>
<p><strong>💡 기술적 배경</strong></p>
<p>이 방식은 <strong>Webpack 1~3 버전</strong> 시절의 &quot;고전적인&quot; 방식입니다. 최신 도구들(Rollup, Vite)은 성능을 위해 <strong>Scope Hoisting</strong> 기술을 써서 함수 없이 합치기도 하지만, 모듈 시스템의 본질인 <strong>&#x27;격리&#x27;</strong> 를 이해하기에는 이 방식이 가장 직관적입니다.</p>
</blockquote>
<hr/>
<h2>3. 그래서 전체 모양은 어떻게 생겼나요?</h2>
<p>개별 파일을 함수로 감싸는 건 알겠습니다. 그럼 이 수십 개의 함수들을 어떻게 엮을까요?<br/>
우리는 이 함수들을 <strong>객체</strong>에 담고, 그 위에서 <strong>관리자</strong>가 하나씩 꺼내 쓰는 구조를 만들 겁니다.</p>
<pre><code class="language-javascript">// ✅ 우리가 만들 최종 번들 파일의 구조 (Blueprint)
(function (modules) {
  // 1. 관리자 (Runtime): 모듈을 꺼내서 실행해주는 역할 (다음 섹션에서 구현)
  function require(id) {
    modules[id]();
  }
  require(0); // 시작!
})({
  // 2. 창고 (Module Map): 파일들이 ID표를 달고 대기하는 곳
  0: function () {
    console.log(&#x27;I am Entry&#x27;);
  },
  1: function () {
    console.log(&#x27;I am Button&#x27;);
  },
});
</code></pre>
<p>이제 우리의 목표가 명확해졌습니다. 위쪽의 <strong><code>require</code> 함수</strong>를 구현하고, 아래쪽의 <strong>모듈</strong>을 채워 넣으면 됩니다.</p>
<hr/>
<h2>4. 브라우저엔 require가 없다</h2>
<p>이제 우리는 각 파일을 함수로 안전하게 감쌌습니다. 그런데 문제가 하나 있습니다. 함수 안에서 다른 모듈을 불러오려면 <code>require</code>가 필요한데, <strong>브라우저에는 <code>require</code>가 없습니다.</strong></p>
<pre><code class="language-javascript">// 브라우저 콘솔
require(1); // 🚨 Uncaught ReferenceError: require is not defined
</code></pre>
<p>따라서 번들러는 단순히 파일을 합치는 것뿐만 아니라, <strong>Node.js의 모듈 시스템(<code>require</code>, <code>module</code>, <code>exports</code>)을 브라우저에서 흉내 내는 런타임</strong> 을 직접 주입해 줘야 합니다.</p>
<h3>미션: 런타임 심 만들기</h3>
<p>우리가 구현해야 할 런타임 함수는 크게 3가지 미션을 수행해야 합니다.</p>
<h4>Mission 1. 모듈을 실행하고 값을 반환하라</h4>
<p>가장 기본적인 역할입니다. 모듈 ID를 받아서 해당 함수를 실행하고, 그 결과(<code>exports</code>)를 내뱉습니다.</p>
<pre><code class="language-javascript">function require(id) {
  const module = { exports: {} };

  // 모듈 함수 실행 (의존성 주입!)
  // 여기서 require, module, exports를 인자로 넘겨줍니다.
  modules[id](require, module, module.exports);

  return module.exports;
}
</code></pre>
<h4>Mission 2. 두 번 실행하지 마라</h4>
<p>이게 핵심입니다. 모듈은 <code>import</code> 할 때마다 실행되는 게 아닙니다. <strong>최초 한 번만</strong> 실행되고, 그 뒤로는 저장된 값을 써야 합니다.<br/>
약 모듈이 호출될 때마다 매번 새로 실행된다면, 전역 설정이나 상태값들이 계속 초기화되어 버립니다. <code>import</code>는 <strong>싱글톤</strong>처럼 한 번 만든 객체를 계속 돌려써야 합니다.</p>
<pre><code class="language-javascript">const cache = {}; // ✅ 실행 결과를 저장할 메모리

function require(id) {
  // 1. 이미 실행한 적 있나요? 그럼 그거 씁시다.
  if (cache[id]) return cache[id].exports;

  // ... (실행 로직) ...
}
</code></pre>
<h4>Mission 3. 순환 참조를 막아라</h4>
<p><code>A -&gt; B -&gt; A</code> 구조에서 캐시가 없다면, <code>require</code>가 무한히 호출되어 <strong>Stack Overflow</strong>가 발생합니다.</p>
<p>빈 객체라도 먼저 캐시에 등록해두면, 두 번째 <code>A</code>를 불렀을 때 루프를 끊고 실행을 이어갈 수 있습니다. 이것이 Node.js가 순환 참조를 버티는 원리입니다.</p>
<blockquote>
<p><strong><a href="https://github.com/Han5991/fe-lab/blob/main/packages/@package/bundler/src/Graph.ts#L107">실제 런타임 구현 코드 보기 (Graph.ts:107)</a></strong></p>
</blockquote>
<hr/>
<h2>5. 실행 흐름</h2>
<p>번들 코드가 실행될 때, <code>require</code>가 어떻게 모듈을 넘나드는지 시각적으로 확인해 봅시다.</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant Runtime as Bundler Runtime
    participant Entry as Entry Module (ID: 0)
    participant Dep as Dependency (ID: 1)

    Runtime-&gt;&gt;Entry: 1. execute(0) 호출
    activate Entry
    Note over Entry: 코드 실행 중...&lt;br/&gt;require(1) 발견
    Entry-&gt;&gt;Runtime: 2. require(1) 요청
    activate Runtime
    Runtime-&gt;&gt;Dep: 3. execute(1) 실행
    activate Dep
    Note over Dep: exports.data = &#x27;value&#x27;
    Dep--&gt;&gt;Runtime: 4. module.exports 반환
    deactivate Dep
    Runtime--&gt;&gt;Entry: 5. 결과(&#x27;value&#x27;) 전달
    deactivate Runtime
    Note over Entry: 나머지 코드 실행
    Entry--&gt;&gt;Runtime: 6. 실행 완료
    deactivate Entry
</code></pre>
<hr/>
<h2>6. 최종 모습: ESM을 CJS로 통역하기</h2>
<p>마지막 퍼즐 조각은 <strong>문법 변환</strong>입니다.</p>
<p>우리가 작성한 코드는 최신 <strong>ESM(<code>import/export</code>)</strong> 이지만, 방금 만든 런타임은 <strong>CJS(<code>require/exports</code>)</strong> 기반입니다. 번들러는 코드를 합치기 전에, <strong><code>import</code> 문을 <code>require</code> 함수 호출로 바꿔치기</strong> 해야 합니다.<br/>
이 과정은 우리가 지난 시간에 만든 <code>Module.ts</code>가 AST를 분석해서 수행합니다.</p>
<h4>Case 1. 가져오기</h4>
<p>모듈 ID를 기반으로 값을 가져옵니다.</p>
<pre><code class="language-javascript">// Input (ESM)
import { a } from &#x27;./a&#x27;;

// Output (CJS)
const { a } = require(1);
</code></pre>
<h4>Case 2. 이름 내보내기</h4>
<p><code>exports</code> 객체에 속성을 추가합니다.</p>
<pre><code class="language-javascript">// Input (ESM)
export const b = 1;

// Output (CJS)
const b = 1;
exports.b = b;
</code></pre>
<h4>Case 3. 기본 내보내기</h4>
<p><code>default</code>라는 이름의 속성으로 할당합니다.</p>
<pre><code class="language-javascript">// Input (ESM)
export default c;

// Output (CJS)
exports.default = c;
</code></pre>
<h4>Case 4. 전체 내보내기</h4>
<p>대상 모듈의 모든 수출항을 복사합니다.</p>
<pre><code class="language-javascript">// Input (ESM)
export * from &#x27;./a&#x27;;

// Output (CJS)
Object.assign(exports, require(1));
</code></pre>
<h3>AST 적용하기: <code>import</code>가 <code>require</code>로 바뀌는 순간</h3>
<p><strong>&quot;AST를 분석해서 코드를 바꾼다&quot;</strong> 는 말이 너무 추상적인가요? 실제 코드 레벨에서 어떤 일이 일어나는지 현미경으로 들여다보겠습니다.</p>
<p>우리의 목표는 아래의 <strong>ESM 코드</strong>를 <strong>CJS 코드</strong>로 성형수술하는 것입니다.</p>
<pre><code class="language-typescript">// 📄 원본 (src/index.js)
import { sum } from &#x27;./math.js&#x27;;
</code></pre>
<p>👇 <strong>변환 목표</strong></p>
<pre><code class="language-javascript">// 📄 결과물 (dist/bundle.js 내부)
const { sum } = require(1); // &#x27;./math.js&#x27;가 ID: 1로 변환됨
</code></pre>
<p>이 과정은 <code>Module.ts</code> 내부의 <code>transform</code> 메서드에서 일어납니다. 핵심 로직을 3단계로 쪼개 봅시다.</p>
<h4>1단계: AST 탐색</h4>
<p><code>acorn</code>이 파싱한 AST에서 <code>ImportDeclaration</code> 타입을 가진 노드를 찾습니다.</p>
<pre><code class="language-json">// AST Node (ImportDeclaration)
{
  &quot;type&quot;: &quot;ImportDeclaration&quot;,
  &quot;start&quot;: 0, // &quot;import&quot;가 시작되는 인덱스
  &quot;end&quot;: 32, // 세미콜론(;)이 끝나는 인덱스
  &quot;source&quot;: {
    &quot;type&quot;: &quot;Literal&quot;,
    &quot;value&quot;: &quot;./math.js&quot; // 👈 우리가 필요한 경로!
  },
  &quot;specifiers&quot;: [...]
}
</code></pre>
<h4>2단계: ID 치환</h4>
<p>번들러는 이미 <code>Graph</code>를 만들면서 <code>&#x27;./math.js&#x27;</code>에게 주민등록번호(ID) <code>1</code>번을 부여했습니다.</p>
<pre><code class="language-typescript">// Module.ts의 transform 메서드 내부
const sourcePath = node.source.value; // &#x27;./math.js&#x27;
const moduleId = this.mapping.get(sourcePath); // 1 (Graph가 미리 매핑해둠)

// 이제 우리는 require(&#x27;./math.js&#x27;) 대신 require(1)을 쓸 수 있습니다.
</code></pre>
<h4>3단계: MagicString 조작</h4>
<p>이제 <code>magic-string</code>이라는 메스를 들 차례입니다. 원본 코드를 건드리지 않고, 기존 <code>import</code> 문을 덮어쓰기 합니다.</p>
<pre><code class="language-typescript">// 실제 구현 로직 (의사 코드)

// 1. 가져올 변수들 문자열 만들기: &quot;{ sum }&quot;
const imported = node.specifiers
  .map(s =&gt; `${s.imported.name}: ${s.local.name}`)
  .join(&#x27;, &#x27;);

// 2. require 문 만들기: &quot;const { sum } = require(1);&quot;
const newCode = `const { ${imported} } = require(${moduleId});`;

// 3. 덮어쓰기! (수술 집도)
// 원본 코드의 start(0)부터 end(32)까지를 newCode로 교체합니다.
this.magicString.overwrite(node.start, node.end, newCode);
</code></pre>
<blockquote>
<p><strong><a href="https://github.com/Han5991/fe-lab/blob/main/packages/@package/bundler/src/Module.ts#L121">AST 변환 로직 전체 보기 (Module.ts:121)</a></strong></p>
</blockquote>
<hr/>
<blockquote>
<p><strong>💡 참고: ESM 번들링은 안 하나요?</strong></p>
<p>최신 라이브러리들은 <strong>CJS</strong>뿐만 아니라 <strong>ESM</strong> 포맷도 함께 지원하는 <strong>Dual Package</strong> 전략을 사용합니다. 하지만 우리는 번들러의 동작 원리(스코프 격리, 런타임 구현)를 깊이 이해하기 위해, 로직이 가장 명확하게 드러나는 <strong>CJS 번들링</strong> 구현에 집중했습니다.</p>
</blockquote>
<h2>7. 직접 돌려봅시다</h2>
<p>백문이 불여일견입니다. 우리가 만든 번들러가 실제로 잘 동작하는지 확인해 볼까요?</p>
<pre><code class="language-bash">$ pnpm --filter @package/sample-lib run build

📦 Minibundler started...
🛠️  Generating bundle...
📦 Generated CJS Bundle: dist/index.js
✨ Bundle built successfully!
</code></pre>
<p>생성된 <code>dist/index.js</code>를 열어보면, 수십 개의 파일이 하나의 <strong>IIFE</strong> 안에 질서 정연하게 들어있는 모습을 볼 수 있습니다. 우리는 브라우저 안에 <strong>작은 Node.js</strong>를 만들어낸 것입니다.</p>
<pre><code class="language-javascript">// dist/index.js (생성된 번들 결과물)
(function (modules) {
  // 우리가 만든 런타임 함수 (require, cache 등)
  function require(id) { ... }
  require(0); // 엔트리 실행
})({
  0: function (require, module, exports) { /* ... */ },
  1: function (require, module, exports) { /* ... */ },
});
</code></pre>
<p>이제 이 파일이 정말로 동작하는지 실행해 볼까요?</p>
<pre><code class="language-bash"># 2. 결과물 실행 (Node.js 환경에서 테스트)
$ node -e &quot;
  const lib = require(&#x27;./packages/@package/sample-lib/dist/index.js&#x27;);
  console.log(&#x27;1 + 2 =&#x27;, lib.sum(1, 2));
&quot;

# 1 + 2 = 3 (정상 실행!)
</code></pre>
<hr/>
<h2>Next Step</h2>
<p>이제 파일은 합쳐졌고, 브라우저에서도 잘 돌아갑니다. (<code>bundle.js</code>)
하지만 치명적인 문제가 하나 남았습니다.</p>
<p><strong>에러가 나면 무조건 <code>bundle.js</code> 1번째 줄이라고 뜹니다.</strong>
수천 줄로 합쳐진 코드 뭉치 속에서, 도대체 <strong>원본 소스 코드의 몇 번째 줄</strong>이 문제인지 어떻게 알 수 있을까요?</p>
<p>다음 편, <strong>[Step 4. 디버깅의 마법, 소스맵]</strong> 에서 번들러가 망가뜨린 코드를 다시 원본으로 되돌리는 기술을 다뤄보겠습니다.</p>
<hr/>
<h2>체크리스트 (Step 3)</h2>
<ul class="contains-task-list">
<li class="task-list-item"><input type="checkbox" disabled=""/> 단순히 파일을 합치면 전역 변수 충돌이 일어남을 이해했다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> <strong>IIFE</strong>가 어떻게 스코프를 격리하는지 설명할 수 있다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 런타임 구현에서 <strong>캐시</strong> 가 왜 필수적인지(싱글톤, 순환 참조) 이해했다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> ESM 문법이 CJS 런타임(<code>require</code>)으로 변환되는 과정을 확인했다.</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>[누가 시키지도 않았는데 번들러 만들기] 2. 코드를 데이터로 보는 법 (AST Graph)</title>
      <link>https://blog.sangwook.dev/posts/no-one-asked-library-bundler-02-ast-graph/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/no-one-asked-library-bundler-02-ast-graph/</guid>
      <pubDate>Mon, 16 Feb 2026 15:00:00 GMT</pubDate>
      <description>정규표현식으로 import 문을 찾으려다 실패한 경험, 그리고 AST(추상 구문 트리)를 통해 코드를 문자열이 아닌 데이터로 바라보는 관점을 소개합니다. 흩어진 파일들을 하나의 의존성 그래프로 바꾸는 과정을 직접 구현해봅니다.</description>
      <content:encoded><![CDATA[<blockquote>
<p><strong>&quot;코드는 텍스트가 아니다. 트리다.&quot;</strong></p>
</blockquote>
<p><a href="https://blog.sangwook.dev/posts/no-one-asked-library-bundler-01-concept/">1편에서는 번들러</a>를 왜 만들어야 하는지, 그리고 <code>magic-string</code>이라는 도구를 맛봤습니다. 이제 본격적으로 코드를 분석하고, 여러 파일을 하나로 묶기 위한 준비를 시작해 봅시다.</p>
<p>이번 편의 목표는 <strong>&quot;흩어진 파일들을 찾아내어 하나의 지도로 그리는 것&quot;</strong> 입니다.</p>
<hr/>
<h2>1. 어디서부터 묶어야 할까?</h2>
<p>번들러가 가장 먼저 해야 할 일은 무엇일까요? 바로 <strong>&quot;진입점&quot;</strong> 을 찾는 것입니다.</p>
<p>우리가 작성한 수많은 파일들을 하나로 묶으려면 어떻게 해야 할까요? 무턱대고 폴더의 모든 파일을 합치는 것은 좋은 방법이 아닙니다. <strong>시작점이 되는 파일(보통 <code>index.js</code>나 <code>main.js</code>)</strong> 을 읽고, 그 파일이 어떤 다른 파일들을 필요로 하는지(<code>import</code>) 추적해 나가야 합니다. 이것이 바로 <strong>의존성 그래프</strong>를 그리기 위한 첫 단추입니다.</p>
<hr/>
<h2>2. 코드는 단순한 텍스트가 아니다</h2>
<p>자, 이제 엔트리 파일의 내용을 읽어왔습니다. 그 다음은 파일 내용에서 <code>import ... from ...</code> 구문을 찾아내야 합니다.</p>
<h3>왜 정규표현식을 썼을까?</h3>
<p>문자열에서 특정 패턴을 찾는 것. 개발자라면 누구나 가장 먼저 떠올리는 도구가 있죠. 바로 <strong>정규표현식</strong> 입니다.</p>
<p>직관적으로 생각해보면, <code>import</code> 문은 꽤 단순한 패턴을 가지고 있어 보입니다.</p>
<pre><code class="language-javascript">import { a } from &#x27;./a.js&#x27;;
import b from &#x27;./b.js&#x27;;
</code></pre>
<p>&quot;어? 이거 그냥 문자열 검색으로 찾으면 되는 거 아냐?&quot;</p>
<p>그래서 처음에는 <strong>정규표현식</strong>을 떠올리게 됩니다. 정규표현식은 강력하고, 빠르고, 무엇보다 <strong>구현하기 쉽습니다.</strong> 복잡한 파서를 도입하는 것보다 <code>String.match()</code> 한 방이면 끝날 것 같았죠.</p>
<pre><code class="language-javascript">const regex = /import\s+.*\s+from\s+[&#x27;&quot;](.*)[&#x27;&quot;];?/g;
const code = &quot;import { a } from &#x27;./a.js&#x27;;&quot;;
const result = code.match(regex);
// 오! 찾아졌다! ./a.js
</code></pre>
<hr/>
<h2>3. 정규표현식은 파서가 아니다</h2>
<h3>예외 케이스</h3>
<p>하지만 코드가 조금만 복잡해져도 정규표현식은 무력해집니다.</p>
<pre><code class="language-javascript">// 1. 주석 처리된 코드
// import { a } from &#x27;./a.js&#x27;;

// 2. 문자열 내부의 텍스트
const message = &quot;Please import &#x27;./config.js&#x27; first.&quot;;

// 3. 줄바꿈이 포함된 import
import { somethingLong } from &#x27;./utils.js&#x27;;
</code></pre>
<p>정규표현식은 <strong>문맥</strong>을 모릅니다. 주석인지, 문자열인지 구분하지 못하고 <strong>&quot;패턴이 맞네? 가져와!&quot;</strong> 라고 외칠 뿐입니다. 결국 우리는 엉뚱한 파일을 <code>import</code> 하려고 시도하다가 에러를 뿜어내는 번들러를 마주하게 됩니다.</p>
<p><strong>결론</strong>: 코드를 단순한 텍스트로 처리하려다간 큰코다칩니다. <strong>구조</strong>로 봐야 합니다.</p>
<hr/>
<h2>4. 도구 1: AST - 코드를 해부하다</h2>
<p>그래서 등장하는 도구가 바로 <strong>AST(Abstract Syntax Tree, 추상 구문 트리)</strong> 입니다.</p>
<p>이름을 하나씩 뜯어보면 이렇습니다.</p>
<ul>
<li><strong>Abstract</strong>: 세미콜론, 괄호, 공백 같은 표면적인 문법 요소는 버리고, <strong>의미만 남긴다</strong>는 뜻입니다.</li>
<li><strong>Syntax</strong>: 코드의 <strong>문법적 구조</strong> — 변수 선언인지, 함수 호출인지, import 문인지를 구분합니다.</li>
<li><strong>Tree</strong>: 결과물이 <strong>부모-자식 관계를 가진 트리 구조</strong>라는 뜻입니다.</li>
</ul>
<p>결국 AST란, 우리가 작성한 코드를 컴퓨터가 이해하기 쉬운 <strong>트리 형태의 데이터 구조</strong>로 바꿔놓은 것입니다.</p>
<p>사실 AST는 번들러만의 도구가 아닙니다. <strong>ESLint</strong>가 코드 규칙을 검사할 때, <strong>Prettier</strong>가 코드를 포맷팅할 때, <strong>Babel</strong>이 최신 문법을 변환할 때 — 모두 AST를 기반으로 동작합니다. 이번 편에서 AST를 다루는 관점을 익히면, 이 도구들이 <strong>내부에서 뭘 하는지</strong>도 보이기 시작합니다.</p>
<p>자바스크립트 파서인 <code>acorn</code>을 사용하면 코드를 다음과 같이 분석해줍니다.</p>
<h3>코드가 데이터가 되는 과정</h3>
<pre><code class="language-javascript">// 입력 코드
import { name } from &#x27;./name.js&#x27;;
</code></pre>
<p>이 한 줄의 코드는 파서를 거치면 거대한 JSON 객체가 됩니다. 핵심만 요약했습니다.</p>
<pre><code class="language-json">{
  &quot;type&quot;: &quot;Program&quot;,
  &quot;body&quot;: [
    {
      &quot;type&quot;: &quot;ImportDeclaration&quot;,
      // &quot;이건 import 선언문이야!&quot;
      &quot;source&quot;: {
        &quot;type&quot;: &quot;Literal&quot;,
        &quot;value&quot;: &quot;./name.js&quot;
        // &quot;경로는 이거야!&quot;
      },
      &quot;specifiers&quot;: [
        {
          &quot;type&quot;: &quot;ImportSpecifier&quot;,
          &quot;imported&quot;: {
            &quot;name&quot;: &quot;name&quot;
          }
          // &quot;name을 가져왔어!&quot;
        }
      ]
    }
  ]
}
</code></pre>
<p>이제 우리는 <code>type === &#x27;ImportDeclaration&#x27;</code>인 노드만 골라내면 됩니다. 주석이나 문자열은 <code>ImportDeclaration</code>이 아니므로 자연스럽게 무시됩니다. <strong>정확도 100%의 import 탐지기</strong>가 생긴 셈입니다.</p>
<h3>Code Module.ts 구현</h3>
<p>우리의 번들러에서 파일 하나를 담당하는 <code>Module</code> 클래스는 이렇게 AST를 생성합니다.</p>
<pre><code class="language-typescript">// packages/@package/bundler/src/Module.ts

import * as fs from &#x27;fs&#x27;;
import * as acorn from &#x27;acorn&#x27;;

export class Module {
  constructor(filePath: string) {
    this.content = fs.readFileSync(filePath, &#x27;utf-8&#x27;);
    // 1. 코드를 AST로 변환
    this.ast = acorn.parse(this.content, {
      ecmaVersion: &#x27;latest&#x27;,
      sourceType: &#x27;module&#x27;,
    });
  }

  init() {
    // 2. AST를 순회하며 import 찾기
    this.ast.body.forEach(node =&gt; {
      if (
        node.type === &#x27;ImportDeclaration&#x27; &amp;&amp;
        typeof node.source.value === &#x27;string&#x27;
      ) {
        // 찾았다! 의존성 추가
        this.dependencies.push(node.source.value);
      }
    });
  }
}
</code></pre>
<blockquote>
<p><strong>전체 코드 보기</strong>: <a href="https://github.com/Han5991/fe-lab/blob/1f0ff742d5c057b891b22871588cdd8e2101802b/packages/@package/bundler/src/Module.ts#L69-L118">Module.ts GitHub</a></p>
</blockquote>
<hr/>
<h2>5. 도구 2: Resolve - 진짜 파일 찾기</h2>
<p>AST를 통해 <code>&#x27;./utils&#x27;</code>라는 문자열을 찾아냈습니다. 하지만 이건 파일 경로의 <strong>힌트</strong>일 뿐, 실제 파일 시스템상의 위치는 아닙니다.</p>
<ul>
<li><code>./utils</code>가 <code>utils.js</code>인가? <code>utils.ts</code>인가?</li>
<li>아니면 <code>utils/index.js</code>인가?</li>
</ul>
<p>이 힌트를 가지고 실제 파일을 찾아내는 과정을 <strong>Resolve</strong>라고 합니다. Node.js는 꽤 복잡한 Resolve 규칙을 가지고 있지만, 우리는 핵심적인 부분만 구현해 봅시다.</p>
<h3>Code 확장자 추론하기</h3>
<pre><code class="language-typescript">// packages/@package/bundler/src/Graph.ts

resolve(importPath: string, importer: string): string {
  // 1. 현재 파일(importer)이 있는 폴더 기준
  const baseDir = path.dirname(importer);
  let fullPath = path.resolve(baseDir, importPath);

  // 2. 확장자가 없으면 .js 붙여보기 (간단한 버전)
  // (실제로는 .mjs, .json, 디렉토리 내 index.js 등도 고려해야 합니다)
  if (!fullPath.endsWith(&#x27;.js&#x27;)) {
    fullPath += &#x27;.js&#x27;;
  }

  return fullPath;
}
</code></pre>
<p>이제 <code>&#x27;./utils&#x27;</code>를 요청하면 <code>/Users/.../src/utils.js</code>라는 <strong>절대 경로</strong>를 얻을 수 있습니다.</p>
<hr/>
<h2>6. 도구 3: DFS &amp; Graph - 지도를 그리자</h2>
<p>이제 재료는 다 모였습니다.</p>
<ol>
<li>파일 내용을 읽어서 <code>fs</code></li>
<li><code>import</code> 문을 찾아내고 <code>AST</code></li>
<li>그 파일의 절대 경로를 알아냈습니다. <code>Resolve</code></li>
</ol>
<p>이제 이 과정을 <strong>모든 파일</strong>에 대해 반복하면 됩니다. <code>index.js</code>에서 시작해서 꼬리에 꼬리를 물고 찾아 들어가는 방식, 바로 <strong>DFS 깊이 우선 탐색</strong>입니다.</p>
<diagram label="엔트리 파일에서 시작해 AST 파싱과 resolve를 거쳐 모듈 그래프를 완성하는 과정" caption="↻ import를 만날 때마다 이 한 바퀴를 반복한다">
  <diagram-node id="entry" title="엔트리" desc="index.js"></diagram-node>
  <diagram-node id="ast" title="AST 파싱" desc="import 노드 추출"></diagram-node>
  <diagram-node id="resolve" title="resolve" desc="절대 경로 확정"></diagram-node>
  <diagram-node id="graph" title="Graph" desc="modules 맵 등록" tone="accent"></diagram-node>
  <diagram-edge from="entry" to="ast"></diagram-edge>
  <diagram-edge from="ast" to="resolve"></diagram-edge>
  <diagram-edge from="resolve" to="graph" emphasis="true"></diagram-edge>
</diagram>
<h3>그래프 구축 과정 Graph.ts</h3>
<p>우리의 <code>Graph</code> 클래스는 <code>Map</code> 자료구조를 사용하여 전체 파일들의 관계를 저장합니다.</p>
<pre><code class="language-typescript">// packages/@package/bundler/src/Graph.ts

export class Graph {
  // 방문한 모듈을 저장하는 저장소 (Visited Map)
  modules = new Map&lt;string, Module&gt;();

  createModule(filePath: string): Module {
    // 1. 모듈 생성 및 AST 분석
    const module = new Module(this.nextId++, filePath);
    module.init(); // import 구문 추출

    // 2. 저장소에 등록
    this.modules.set(filePath, module);

    // 3. 의존성 재귀 탐색 (DFS)
    module.dependencies.forEach(importPath =&gt; {
      const absolutePath = this.resolve(importPath, filePath);
      const childModule = this.createModule(absolutePath);

      // 4. 부모-자식 연결 (Mapping)
      module.mapping.set(importPath, childModule.id);
    });

    return module;
  }
}
</code></pre>
<p>이렇게 하면 <code>index.js</code> 하나만 넣어도 연결된 모든 파일이 <code>modules</code> 맵에 담기게 됩니다. 파일 시스템이라는 거대한 미로를 탐험해서 지도를 완성하는 것이죠.</p>
<hr/>
<h2>7. 하이라이트: &quot;순환 참조&quot;</h2>
<p>하지만 DFS에는 치명적인 약점이 있습니다. 바로 <strong>순환 참조</strong>입니다.</p>
<h3>A -&gt; B -&gt; A 무한 루프</h3>
<p>만약 <code>a.js</code>가 <code>b.js</code>를 부르고, <code>b.js</code>가 다시 <code>a.js</code>를 부른다면 번들러 내부에서는 <strong>호출 스택</strong> 이 계속 쌓이게 됩니다.</p>
<p>이 과정을 <strong>시퀀스 다이어그램</strong> 으로 보면, 함수가 끝나지 않고 계속 깊어지는 것을 확인할 수 있습니다.</p>
<pre><code class="language-mermaid">sequenceDiagram
    autonumber
    participant Main as 번들러 (Main)
    participant A as a.js
    participant B as b.js

    Main-&gt;&gt;A: createModule(&#x27;a.js&#x27;)
    activate A
    Note right of A: &quot;어? b.js가 필요하네?&quot;

    A-&gt;&gt;B: createModule(&#x27;b.js&#x27;)
    activate B
    Note right of B: &quot;어? a.js가 필요하네?&quot;

    B-&gt;&gt;A: createModule(&#x27;a.js&#x27;)
    activate A
    Note left of A: ❌ 또 호출됨 (재귀)

    A-&gt;&gt;B: createModule(&#x27;b.js&#x27;)
    activate B
    Note right of B: ⚠️ 또 호출됨 (재귀)

    Note over A,B: ❌ Call Stack 한계 도달... ❌
    B--x Main: ❌ Stack Overflow Error!
    deactivate B
    deactivate A
    deactivate B
    deactivate A
</code></pre>
<p>결국 <strong>Stack Overflow</strong>가 발생하며 번들러는 장렬히 전사합니다.</p>
<h3>해결책: 방명록 Visited Map</h3>
<p>이 문제를 해결하려면 <strong>&quot;어? 너 아까 왔던 앤데?&quot;</strong> 라고 알아볼 수 있어야 합니다. 우리가 <code>Graph</code> 클래스에 만들어둔 <code>modules</code> 맵이 바로 그 <strong>방명록</strong> 역할을 합니다.</p>
<pre><code class="language-typescript">createModule(filePath: string): Module {
  // 0. 방명록 확인: 이미 왔던 파일이면, 기존 모듈 반환하고 끝!
  if (this.modules.has(filePath)) {
    return this.modules.get(filePath)!;
  }

  // ... (새 모듈 생성 로직)
}
</code></pre>
<p>이 간단한 체크 하나로 번들러는 무한 루프의 늪에서 탈출할 수 있습니다.</p>
<h3>실전 bundler-playground 예제</h3>
<p>실제 예제 코드 <code>packages/@package/bundler-playground</code> 에는 <code>a.js -&gt; c.js -&gt; a.js</code> 순환 참조 케이스가 들어있습니다.</p>
<pre><code class="language-javascript">// a.js
import { c } from &#x27;./c.js&#x27;;

// c.js
import { a } from &#x27;./a.js&#x27;; // 범인!
</code></pre>
<p>우리의 번들러는 <code>c.js</code>를 처리할 때 <code>a.js</code>를 다시 만나지만, <code>modules.has()</code> 덕분에 쿨하게 지나갑니다. 덕분에 그래프는 안전하게 완성됩니다.</p>
<hr/>
<h2>8. 에필로그: 이제 지도는 완성됐다</h2>
<p>우리는 이제 코드를 <strong>텍스트</strong>가 아닌 <strong>데이터 AST</strong>로 바라보게 되었습니다.
그리고 그 데이터를 바탕으로 파일들의 복잡한 관계를 <strong>그래프 Graph</strong>로 그려냈습니다.</p>
<p>하지만 아직 파일들은 제각각 떨어져 있습니다. 다음 단계에서는 이 그래프를 바탕으로 파일들을 <strong>하나의 파일로 합치는 Bundling</strong> 과정을 다룹니다.</p>
<p>이때 가장 골치 아픈 문제는 <strong>&quot;변수 이름 충돌&quot;</strong> 입니다.
<code>a.js</code>의 <code>const name</code>과 <code>b.js</code>의 <code>const name</code>이 만나면 어떻게 될까요?</p>
<p>다음 편, <strong>Step 3. 번들링과 스코프 격리</strong>에서 계속됩니다.</p>
<hr/>
<h3>🔗 참고 자료</h3>
<ul>
<li><a href="https://github.com/Han5991/fe-lab/blob/1f0ff742d5c057b891b22871588cdd8e2101802b/packages/@package/bundler/src/Graph.ts">전체 소스 코드 Graph.ts</a></li>
<li><a href="https://astexplorer.net/">AST Explorer</a> AST 구조를 눈으로 확인해보세요!</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>[누가 시키지도 않았는데 번들러 만들기] 0. 프롤로그: 질문이 구현이 되는 순간</title>
      <link>https://blog.sangwook.dev/posts/no-one-asked-library-bundler-00-prologue/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/no-one-asked-library-bundler-00-prologue/</guid>
      <pubDate>Sun, 01 Feb 2026 15:00:00 GMT</pubDate>
      <description>수많은 소스 코드가 어떻게 하나의 결과물로 합쳐질까요? 매일 쓰면서도 정작 안을 들여다본 적 없던 번들러의 블랙박스를 열고, 라이브러리 없이 직접 손으로 구현해 보며 그 답을 찾아가는 시리즈를 시작합니다. 첫 편은 왜 이걸 만들기로 했는지에 대한 이야기입니다.</description>
      <content:encoded><![CDATA[<blockquote>
<p>이전에 작성한 <strong><a href="https://blog.sangwook.dev/posts/npm-deploy-series-0-prologue/">하루 만에 끝날 줄 알았던 디자인 시스템 배포가 3주 걸린 이유</a></strong> 에서는 &quot;패키지를 배포하는 과정&quot; 그 자체에 집중했습니다.<br/>
이번에는 시선을 조금 더 안쪽으로 돌려보려 합니다. 바로 배포물이 만들어지는 <strong>생성 과정</strong> 입니다.</p>
</blockquote>
<p>배포는 성공적으로 마쳤지만, 정작 결과물이 어떤 원리로 만들어지는지 제대로 이해하지 못한 채 도구에만 의존하고 있다는 의문이 들었습니다. &quot;남들이 다 쓰니까&quot;, &quot;이게 표준이라니까&quot; 라며 무심코 넘겼던 설정값들이 실제로는 번들러 내부에서 어떤 판단을 내리게 만드는 걸까요?</p>
<hr/>
<h2>STEP 1: 당연한 것에 던지는 질문</h2>
<p>npm 배포 과정을 정리하면서 계속해서 들었던 질문들이 있습니다. 이 질문들이 이번 시리즈의 기준이 됩니다.</p>
<ul>
<li><strong>결합의 원리</strong>: 소스 코드는 수십 개인데, 왜 결과물은 하나로 합쳐질까요? 그 경계는 누가, 어떤 기준으로 정하는 걸까요?</li>
<li><strong>설정의 실체</strong>: <code>package.json</code>의 <code>main</code>, <code>module</code>, <code>exports</code>, <code>sideEffects</code> 값들은 번들러의 동작에 어떤 영향을 줄까요?</li>
<li><strong>의존성의 선별</strong>: 모든 의존성을 번들에 포함해야 할까요? <code>external</code>과 <code>peerDependencies</code>는 어떤 기준으로 분류하는 것일까요?</li>
<li><strong>트리 쉐이킹</strong>: 코드를 직접 실행해보지도 않고, 어떻게 사용하지 않는 코드라고 판단하여 제거할 수 있을까요?</li>
</ul>
<p>이 시리즈는 이러한 질문들에 대해 이론적인 답변을 찾는 대신, 직접 구현하며 답을 찾아가는 기록입니다. 라이브러리 번들러를 직접 만들어보며 그 블랙박스를 열어보려 합니다.</p>
<hr/>
<h2>STEP 2: &quot;Tsup 쓰면 되는데요?&quot;</h2>
<p>맞습니다. <code>Tsup</code>, <code>Vite</code>, <code>Turbopack</code> 같은 좋은 도구가 이미 존재합니다. 그런데 굳이 직접 번들러를 만들어보는 이유는 <strong>도구의 원리를 이해하기 위해서</strong>입니다.</p>
<p>이 과정을 통해 다음 3가지를 얻을 수 있습니다.</p>
<h3>1. 에러 메시지 해석 능력</h3>
<p><code>Uncaught ReferenceError</code>, <code>Module not found</code> 에러를 만났을 때, 번들러가 그래프를 그리는 과정을 알면 원인을 파악하기 쉽습니다.</p>
<h3>2. 도구에 대한 적응력</h3>
<p><code>Webpack</code>, <code>Rollup</code>, <code>Esbuild</code>, <code>Turbopack</code> 등 도구의 이름은 바뀌어도 Parsing, Graph, Linking이라는 본질적인 아키텍처는 변하지 않습니다. 원리를 알면 새로운 도구를 익히는 데 도움이 됩니다.</p>
<hr/>
<h2>STEP 3: 범위 설정</h2>
<p>&quot;프로덕션 레벨의 번들러&quot;를 만드는 것이 목표가 아닙니다. 복잡한 기능은 제외하고, 번들러의 본질적인 역할에만 집중합니다.</p>
<h3>다루는 것</h3>
<p><strong>&#x27;간단한 번들러&#x27;</strong> 를 목표로 다음 5가지를 직접 구현합니다.</p>
<ol>
<li><strong>Resolve</strong>: <code>import</code> 경로를 찾아내는 Node.js의 규칙</li>
<li><strong>Dependency Graph</strong>: 파일을 읽어 AST로 변환하고, 파일 간 관계 연결</li>
<li><strong>Transform</strong>: <code>magic-string</code>을 활용한 코드 조작</li>
<li><strong>Generate</strong>: IIFE로 스코프를 격리하여 하나로 병합</li>
<li><strong>Sourcemap</strong>: 디버깅을 위해 원본과 번들 연결</li>
</ol>
<h3>다루지 않는 것</h3>
<p>번들러의 본질을 이해하는 데 불필요한 기능은 과감히 생략합니다.</p>
<ul>
<li>HMR, Dev Server</li>
<li>Circular Dependency</li>
<li>JS 이외의 에셋 처리</li>
<li>고도화된 Minification</li>
</ul>
<hr/>
<h2>STEP 4: 로드맵</h2>
<p>오직 <strong>&#x27;모듈을 찾아 하나로 합친다&#x27;</strong> 는 본질에 집중합니다.</p>
<h3><a href="https://blog.sangwook.dev/posts/no-one-asked-library-bundler-01-concept/">Step 1. 개념과 도구</a></h3>
<ul>
<li>번들러의 역사적 맥락</li>
<li>ESM이 번들링에 유리한 이유</li>
<li>코드 조작 도구 Magic String</li>
</ul>
<h3><a href="https://blog.sangwook.dev/posts/no-one-asked-library-bundler-02-ast-graph/">Step 2. 그래프 그리기</a></h3>
<ul>
<li>파일을 읽어 AST로 변환</li>
<li><code>import</code>를 추적해 의존성 그래프 구축</li>
<li>파일을 찾는 과정 구현</li>
</ul>
<h3><a href="https://blog.sangwook.dev/posts/no-one-asked-library-bundler-03-bundling-scope/">Step 3. 번들링과 스코프</a></h3>
<ul>
<li>그래프를 순회하며 코드 병합</li>
<li>IIFE로 스코프 격리</li>
<li>브라우저용 <code>require</code> 함수 구현</li>
</ul>
<h3><a href="https://blog.sangwook.dev/posts/no-one-asked-library-bundler-04-sourcemap/">Step 4. 완성도 높이기</a></h3>
<ul>
<li>소스맵 원리 파악 및 구현</li>
<li><code>react</code> 같은 외부 라이브러리 제외 처리</li>
</ul>
<hr/>
<h2>마치며: 이런 분들께 추천합니다</h2>
<p>아래 항목 중 하나라도 해당한다면, 이 시리즈가 도움이 될 것입니다.</p>
<ul class="contains-task-list">
<li class="task-list-item"><input type="checkbox" disabled=""/> <code>tsup.config.ts</code> 설정이 꼬일 때마다 새로 만들고 싶다는 생각이 든다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 배포 후 모듈을 찾을 수 없다는 연락을 받으면 당황스럽다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> CJS와 ESM이 섞여 있을 때 발생하는 에러를 명확히 설명하기 어렵다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 단순히 도구를 사용하는 단계를 넘어 원리를 이해하고 싶다.</li>
</ul>
<p>번들러라는 블랙박스를 함께 열어보겠습니다.</p>]]></content:encoded>
    </item>
    <item>
      <title>[누가 시키지도 않았는데 번들러 만들기] 1. 개념과 도구: 번들러의 등장 배경</title>
      <link>https://blog.sangwook.dev/posts/no-one-asked-library-bundler-01-concept/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/no-one-asked-library-bundler-01-concept/</guid>
      <pubDate>Sun, 01 Feb 2026 15:00:00 GMT</pubDate>
      <description>script 태그를 나열하던 시절부터 ESM까지, 코드를 번들링해야 하는 기술적 이유와 자바스크립트 모듈 시스템의 발전 과정을 살펴봅니다. 그리고 앞으로 만들 번들러의 기반이 될 Magic String 라이브러리를 소개합니다.</description>
      <content:encoded><![CDATA[<p>웹 개발 초기에는 HTML에 여러 개의 <code>&lt;script&gt;</code> 태그를 나열하여 자바스크립트를 로드했습니다. 애플리케이션의 규모가 커짐에 따라 이러한 방식은 관리의 어려움과 성능 저하를 야기했고, 이는 &#x27;빌드&#x27; 과정이 필요한 주된 이유가 되었습니다.</p>
<p>이 글에서는 자바스크립트 모듈 시스템의 발전 과정과 번들러가 필수적인 이유, 그리고 우리가 구현할 번들러의 주요 도구들을 살펴봅니다.</p>
<hr/>
<h2>모듈 시스템의 발전</h2>
<h3>1. 전역 스코프 문제</h3>
<p>초기 자바스크립트는 파일 간 스코프 구분이 없었습니다. <code>a.js</code>에서 선언한 변수는 전역 객체 window에 할당되어 <code>b.js</code>에서도 접근 및 수정이 가능했습니다.</p>
<pre><code class="language-html">&lt;script src=&quot;jquery.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;slider.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;main.js&quot;&gt;&lt;/script&gt;
</code></pre>
<p>서로 다른 파일에서 동일한 변수명을 사용할 경우, 나중에 로드된 스크립트가 이전 값을 덮어쓰는 문제가 발생했습니다. 이를 방지하기 위해 개발자들은 네임스페이스 객체를 사용하거나 복잡한 변수명 규칙을 적용해야 했습니다.</p>
<h3>2. IIFE 패턴</h3>
<p>전역 스코프 오염을 막기 위해 <strong>IIFE</strong> 패턴이 널리 사용되었습니다.</p>
<pre><code class="language-javascript">(function () {
  var privateVar = &#x27;I am safe&#x27;;
  window.MyModule = {
    sayHello: function () {
      console.log(privateVar);
    },
  };
})();
</code></pre>
<p>함수 스코프를 활용하여 내부 변수를 격리하고, 외부에서 필요한 기능만 명시적으로 노출하는 방식입니다. 우리가 구현할 번들러의 결과물 또한 이 패턴을 사용하여 모듈 간 격리를 보장합니다.</p>
<h3>3. CommonJS</h3>
<p>2009년 Node.js의 등장과 함께 <strong>CommonJS</strong> 시스템이 도입되었습니다.</p>
<pre><code class="language-javascript">// math.js
const add = (a, b) =&gt; a + b;
module.exports = add;

// main.js
const add = require(&#x27;./math&#x27;);
</code></pre>
<p>파일 단위의 모듈 개념이 확립되었으나, 브라우저 환경에서는 <code>require</code> 함수가 존재하지 않고 동기 로딩 방식으로 인한 성능 문제가 있어 클라이언트 사이드에서 직접 사용하기에는 제약이 있었습니다.</p>
<h3>4. ESM</h3>
<p>2015년 ES6, 자바스크립트 공식 모듈 시스템인 <strong>ESM</strong>이 표준화되었습니다. 브라우저에서도 <code>&lt;script type=&quot;module&quot;&gt;</code>을 통해 모듈 시스템을 네이티브로 지원하게 되었습니다.</p>
<hr/>
<h2>번들러의 필요성</h2>
<p>최신 브라우저가 ESM을 지원함에도 불구하고 Vite나 Webpack 같은 번들러가 필요한 이유는 다음과 같습니다.</p>
<ol>
<li><strong>성능 최적화</strong>: 수많은 모듈 파일을 개별적으로 로드하면 네트워크 요청 오버헤드가 발생합니다. 번들링을 통해 파일을 하나 또는 소수 로 병합하여 로딩 성능을 개선할 수 있습니다.</li>
<li><strong>생태계 호환성</strong>: npm 생태계의 많은 라이브러리는 여전히 CommonJS로 작성되어 있습니다. 번들러는 이를 브라우저가 이해할 수 있는 형태로 변환하고 통합하는 역할을 수행합니다.</li>
</ol>
<p>우리가 구현할 번들러는 <strong>모듈 탐색, 의존성 그래프 구성, 파일 병합</strong> 과정을 수행합니다.</p>
<hr/>
<h2>기반 기술 1: ESM과 정적 분석</h2>
<p>우리가 만들 번들러는 <strong>ESM 문법</strong>을 기반으로 동작합니다.</p>
<h3>정적 분석</h3>
<p>CommonJS의 <code>require</code>는 런타임에 실행되는 함수이므로 조건부 호출이 가능합니다. 이는 코드를 실행하기 전에는 의존성을 완벽히 파악하기 어렵게 만듭니다.</p>
<p>반면 <strong>ESM의 <code>import</code> 구문은 정적</strong>입니다. 파일 최상단에 위치해야 하며, 코드를 실행하지 않고도 의존성 관계를 파악할 수 있습니다. 이는 번들러가 의존성 그래프를 정확하게 구성하고, 사용하지 않는 코드를 제거하는 <strong>트리 쉐이킹</strong>을 구현하는 기반이 됩니다.</p>
<h3>Live Binding</h3>
<p>ESM은 값을 복사하지 않고 <strong>참조</strong>합니다.</p>
<ul>
<li><strong>CJS</strong>: 모듈 호출 시점의 값을 복사합니다.</li>
<li><strong>ESM</strong>: 원본 변수에 대한 참조를 유지합니다.</li>
</ul>
<p>이러한 특성은 <strong>순환 참조</strong> 해결에 유리합니다. CJS에서는 실행 순서에 따라 미완성된 객체를 참조하여 에러가 발생할 수 있지만, ESM은 참조가 유지되므로 모듈 실행이 완료되면 올바른 값에 접근할 수 있습니다.</p>
<hr/>
<h2>기반 기술 2: Magic String</h2>
<p>번들러 구현의 가장 중요한 작업은 <strong>소스 코드 문자열 조작</strong>입니다. <code>import</code> 구문을 제거하거나 변환하고, 코드를 하나의 파일로 합치는 작업이 필요합니다.</p>
<p>단순한 문자열 치환은 원본 코드의 인덱스를 변경시키므로, 소스맵 생성이나 추가적인 코드 분석을 어렵게 만듭니다.</p>
<h3>Magic String 라이브러리 활용</h3>
<p><code>magic-string</code> 라이브러리는 원본 문자열의 인덱스를 유지하면서 변경 사항을 관리합니다.</p>
<pre><code class="language-typescript">// packages/@package/bundler/src/Module.ts 예시

private transformImportDeclaration(node) {
  // 1. 모듈 ID 조회
  const depId = this.mapping.get(node.source.value);
  const requireCall = `require(&#x27;${depId}&#x27;)`;

  // 2. 변환 코드 생성
  // import { a } from &#x27;./file&#x27; -&gt; const { a } = require(&#x27;file&#x27;)
  const replacement = `const { ${specifierStr} } = ${requireCall};\n`;

  // 3. 원본 인덱스 기반으로 덮어쓰기
  this.magicString.overwrite(node.start, node.end, replacement);
}
</code></pre>
<p>이 방식을 사용하면 여러 변환 작업이 중첩되더라도 원본 소스 코드의 위치 정보를 정확하게 추적하여 신뢰성 있는 소스맵을 생성할 수 있습니다.</p>
<hr/>
<h2>실습 구조</h2>
<p>이 시리즈의 실습 코드는 다음 경로에서 확인할 수 있습니다.</p>
<pre>

packages/@package/bundler/
    src/index.ts   # 진입점: 설정 -&gt; 빌드 -&gt; 생성
    src/Graph.ts   # 의존성 그래프 구축 및 번들 생성
    src/Module.ts  # 개별 파일 AST 파싱 및 변환
</pre>
<h3>단계별 참조 파일</h3>
<ul>
<li><strong>Step 1</strong>: <code>src/index.ts</code> 전체 파이프라인 이해</li>
<li><strong>Step 2</strong>: <code>src/Module.ts</code> 파일 분석, <code>src/Graph.ts</code> 관계 연결</li>
<li><strong>Step 3</strong>: <code>Graph.generate()</code> 번들링, <code>Module.transform()</code> 코드 변환</li>
<li><strong>Step 4</strong>: <code>dist/index.js.map</code> 소스맵 확인</li>
</ul>
<hr/>
<h2>실습: 최소 기능 번들러 구현</h2>
<p>번들러의 기본 구조를 이해하기 위해, 파일을 읽고 출력하는 간단한 코드를 작성해봅니다.</p>
<pre><code class="language-javascript">import fs from &#x27;node:fs&#x27;;
import MagicString from &#x27;magic-string&#x27;;

// 1. 파일 읽기
const content = fs.readFileSync(&#x27;./src/index.js&#x27;, &#x27;utf-8&#x27;);

// 2. MagicString 인스턴스 생성
const bundle = new MagicString(content);

// 3. 코드 변환 및 추가
bundle.prepend(&#x27;/* Bundled by Custom Bundler */\n&#x27;);

// 4. 결과물 출력
fs.writeFileSync(&#x27;./dist/bundle.js&#x27;, bundle.toString());
console.log(&#x27;Build completed.&#x27;);
</code></pre>
<p>이 코드는 단순한 파일 복사에 가깝지만, 추후 3번 단계에 <strong>AST 분석, 의존성 그래프 탐색, 코드 변환 로직</strong>을 추가하여 완전한 번들러로 발전시킬 것입니다.</p>
<hr/>
<h2>다음 단계</h2>
<p>다음 글에서는 코드를 데이터로 변환하여 분석하는 <strong>AST 분석과 의존성 그래프 구현</strong>에 대해 다룹니다.</p>
<p><strong>[Step 2. 그래프 그리기 - Resolve &amp; AST 로 이동]</strong> <code>Coming Soon</code></p>]]></content:encoded>
    </item>
    <item>
      <title>결제 시스템 리팩토링: if문 지옥에서 디자인 패턴(Factory, Adapter)으로 탈출하기</title>
      <link>https://blog.sangwook.dev/posts/payment-system-architecture/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/payment-system-architecture/</guid>
      <pubDate>Fri, 23 Jan 2026 15:00:00 GMT</pubDate>
      <description>국가마다 결제 수단이 다른 시스템의 분기가 if문 지옥으로 자라났습니다. Adapter·Factory·Strategy 패턴으로 그 분기를 걷어낸 적용기와, Zod로 타입 안전성을 확보하고 Code Splitting까지 챙긴 방법.</description>
      <content:encoded><![CDATA[<blockquote>
<p><strong>&quot;만약 내일 당장 &#x27;프랑스&#x27;가 서비스 국가로 추가된다면? 그리고 한국에서 &#x27;사업자 유형&#x27;에 따라 결제 수단이 달라져야 한다면?&quot;</strong>
이 질문들이 우리 팀의 결제 시스템 설계를 완전히 바꿔놓았습니다.</p>
</blockquote>
<hr/>
<h2>🎭 Prologue: &quot;팀장님의 무리한(?) 요구&quot;</h2>
<p>어느 날 오후, 코드 리뷰 중에 팀장님이 툭 던지신 질문 하나가 뇌리를 스쳤습니다.</p>
<p><em>&quot;이번에 프랑스(France) 런칭 결정됐어. 기존 <strong>PayPal</strong>이랑 <strong>무통장 입금</strong> 은 그대로 쓰고, 프랑스 국민 결제인 <strong>&#x27;Carte Bancaire&#x27;</strong> 도 추가해야 돼.&quot;</em></p>
<p>여기까진 괜찮았습니다. 그런데 덧붙이시더군요.</p>
<p><em>&quot;아, 그리고 한국(KR) 쪽 로직도 바꿔야 해. <strong>사업자 유형</strong>이 추가됐거든.
&#x27;해당 없음&#x27;이면 그냥 기존처럼 <strong>Toss</strong> 쓰면 되는데,
<strong>&#x27;개인/법인 사업자&#x27;</strong> 를 선택하면 세금계산서 때문에 <strong>&#x27;신용카드(Toss)&#x27;</strong> 랑 <strong>&#x27;법인계좌 송금(무통장)&#x27;</strong> 만 허용해야 돼.&quot;</em></p>
<p>순간 머릿속이 하얘졌습니다.
&#x27;국가별 분기도 벅찬데, 국가 안에서 또 조건부 분기라고? <code>if (country === &#x27;KR&#x27; &amp;&amp; businessType === &#x27;CORPORATE&#x27;)</code>...&#x27;</p>
<p>하지만 대답을 주저했습니다. 직감적으로 알았거든요. <strong>&quot;if문 몇 개 추가&quot;</strong> 가 쌓이고 쌓여서, 나중엔 <strong>&quot;건드리기만 하면 터지는 레거시 코드&quot;</strong> 가 된다는 것을요.</p>
<p>그래서 결심했습니다. <strong>&quot;복잡한 요구사항? 설정과 규칙만 정의하면 끝납니다.&quot;</strong> 라고 자신 있게 말할 수 있는 구조를 만들어보자고요.</p>
<hr/>
<h2>🛑 1. 평범한 일상: &quot;if문의 지옥&quot; (Before)</h2>
<p>초기 결제 시스템은 단순했습니다. 하지만 요구사항이 늘어날수록 코드는 스파게티가 되어갔습니다.</p>
<pre><code class="language-typescript">// 😱 흔한_결제_로직.ts
const handlePayment = async (country, businessType, paymentMethod) =&gt; {
  if (country === &#x27;KR&#x27;) {
    if (businessType === &#x27;NONE&#x27;) {
      // 다 됨 (Toss)
    } else {
      // 사업자는 무통장이랑 카드만 됨
      if (paymentMethod === &#x27;VIRTUAL_ACCOUNT&#x27;)
        throw new Error(&#x27;사업자는 가상계좌 안됨&#x27;);
      // ...복잡한 검증 로직...
    }
    // 토스 호출...
  } else if (country === &#x27;FR&#x27;) {
    // 프랑스 로직...
  }
};
</code></pre>
<p><strong>문제점 분석:</strong></p>
<ol>
<li><strong>관심사의 혼재</strong>: &#x27;누가 결제 가능한가(Business Rule)&#x27;와 &#x27;어떻게 결제하는가(Implementation)&#x27;가 한 함수에 뒤섞여 있습니다.</li>
<li><strong>높은 결합도</strong>: PG사 API가 변경되면 비즈니스 로직까지 영향을 받고, 반대로 비즈니스 규칙이 바뀌면 결제 호출 코드도 수정해야 합니다.</li>
<li><strong>확장성 부족</strong>: 조건이 추가될 때마다 <code>if-else</code> 블록을 찾아 헤매야 합니다.</li>
</ol>
<hr/>
<h2>💡 2. 해결책: &quot;변하는 것과 변하지 않는 것을 분리하라&quot;</h2>
<p>무작정 디자인 패턴을 도입하기보다, 문제의 본질을 먼저 파악했습니다.
우리가 겪는 고통의 원인은 <strong>&quot;변하는 것(국가별 규칙, PG사 API)&quot;</strong> 과 <strong>&quot;변하지 않는 것(결제 요청 행위)&quot;</strong> 이 섞여 있기 때문이었습니다.</p>
<p>이를 분리하기 위해 우리는 다음 세 가지 패턴을 적재적소에 배치했습니다.</p>
<h3>🛠️ 우리가 선택한 도구들</h3>

























<table><thead><tr><th style="text-align:left">패턴</th><th style="text-align:left">핵심 역할</th><th style="text-align:left">선택 이유</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Adapter</strong></td><td style="text-align:left"><strong>호환성(Compatibility)</strong></td><td style="text-align:left">서로 다른 PG사 API를 <strong>하나의 인터페이스로 통일</strong>하여, 클라이언트 코드가 PG사 구현 내용을 몰라도 되게 합니다.</td></tr><tr><td style="text-align:left"><strong>Factory</strong></td><td style="text-align:left"><strong>생성 위임(Delegation)</strong></td><td style="text-align:left">어떤 결제 수단을 쓸지 결정하는 <strong>복잡한 분기 로직(if-else)</strong> 을 별도 객체로 분리하여 관리합니다.</td></tr><tr><td style="text-align:left"><strong>Strategy</strong></td><td style="text-align:left"><strong>유연성(Flexibility)</strong></td><td style="text-align:left">실행 시점(Runtime)에 상황에 맞는 <strong>알고리즘(UI/로직)을 동적으로 교체</strong>할 수 있게 합니다.</td></tr></tbody></table>
<h3>🔌 Step 1: Adapter Pattern (통역사 두기)</h3>
<p><strong>Why?</strong>
Toss는 <code>requestPayment()</code>, PayPal은 <code>createOrder()</code>... PG사마다 메서드명도, 파라미터도 다릅니다. 우리 비즈니스 로직이 이 차이를 다 알 필요가 있을까요?</p>
<p><strong>Solution</strong>
우리 시스템은 <code>requestPayment</code>라는 하나의 언어만 쓰기로 하고, 중간에 <strong>통역사(Adapter)</strong> 를 두어 변환하게 했습니다.</p>
<pre><code class="language-typescript">// IPaymentAdapter.ts
export interface IPaymentAdapter {
  requestPayment(params: PaymentRequestParams): Promise&lt;void&gt;;
}
</code></pre>
<p>이제 <strong>Toss</strong>, <strong>PayPal</strong>, <strong>General(무통장)</strong> 각각의 어댑터가 내부적으로 지지고 볶든 말든, 우리 코드는 깔끔해집니다.</p>
<blockquote>
<p><strong>⚖️ Trade-off</strong>:</p>
<ul>
<li>(👍 장점) PG사가 변경되거나 추가되어도 클라이언트 코드는 수정할 필요가 없습니다. (OCP)</li>
<li>(👎 단점) <strong>간접 계층의 복잡성</strong>: 단순 함수 호출이면 될 것을 <code>Interface</code> -&gt; <code>Adapter</code> -&gt; <code>Real Implementation</code>으로 거쳐 가야 하므로, 코드 추적이 한 단계 더 깊어지고 초기 구조 잡는 비용이 듭니다.</li>
</ul>
</blockquote>
<h3>🏗️ Step 2: Factory Pattern (규칙 자판기)</h3>
<p><strong>Why?</strong>
&quot;한국 사업자는 무통장만 됨&quot; 같은 규칙은 어디에 둬야 할까요? UI 컴포넌트? 훅? 아닙니다. 이런 규칙이 여기저기 흩어지면 유지보수 지옥이 열립니다.</p>
<p><strong>Solution</strong>
규칙을 한곳에 몰아넣고, 입력(Context)만 넣으면 결과(Available Providers)를 뱉어주는 <strong>자판기(Factory)</strong> 를 만들었습니다.</p>
<pre><code class="language-typescript">// PaymentFactory.ts
export const PaymentFactory = {
  getAvailableProviders(context: { country: string; businessType?: string }) {
    // 🇰🇷 한국: 사업자 유형에 따른 동적 필터링 (이곳에 분기 로직이 캡슐화됩니다!)
    if (context.country === &#x27;KR&#x27;) {
      if ([&#x27;CORPORATE&#x27;, &#x27;INDIVIDUAL&#x27;].includes(context.businessType)) {
        return [PaymentProviderType.TOSS, PaymentProviderType.GENERAL];
      }
      return [PaymentProviderType.TOSS];
    }
    // ...
  },

  getAdapter(providerType: PaymentProviderType): IPaymentAdapter {
    const adapters: Partial&lt;
      Record&lt;PaymentProviderType, new () =&gt; IPaymentAdapter&gt;
    &gt; = {
      [PaymentProviderType.TOSS]: TossAdapter,
    };

    const AdapterClass = adapters[providerType];
    if (!AdapterClass) {
      throw new Error(`${providerType}에 대한 어댑터를 찾을 수 없습니다.`);
    }
    return new AdapterClass();
  },
};
</code></pre>
<blockquote>
<p><strong>⚖️ Trade-off</strong>:</p>
<ul>
<li>(👍 장점) 복잡한 <code>if-else</code> 로직이 Factory 한곳으로 격리되어 관리가 쉬워집니다.</li>
<li>(👎 단점) 클래스나 객체가 늘어나고, 코드의 흐름을 따라가려면(Factory -&gt; Adapter) 파일을 이동해야 하는 번거로움이 생깁니다.</li>
</ul>
</blockquote>
<hr/>
<h2>🔗 3. 통합: &quot;이 모든 것을 하나로 묶는 마법, usePayment Hook&quot;</h2>
<p>제아무리 좋은 설계도 사용하기 불편하면 실패합니다. <code>usePayment</code> 훅은 이 모든 복잡성을 감추고 <strong>단일 인터페이스</strong>를 제공합니다.</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant UI as Component
    participant Hook as usePayment
    participant Factory as PaymentFactory
    participant Adapter as PaymentAdapter

    UI-&gt;&gt;Hook: requestPayment(KR, TOSS, params)
    Hook-&gt;&gt;Factory: getAvailableProviders(context)
    Factory--&gt;&gt;Hook: [TOSS, GENERAL]
    Hook-&gt;&gt;Hook: Validate Provider (Is TOSS allowed?)
    Hook-&gt;&gt;Factory: getAdapter(TOSS)
    Factory--&gt;&gt;Hook: TossAdapter
    Hook-&gt;&gt;Adapter: requestPayment(params)
    Adapter--&gt;&gt;Hook: Success
</code></pre>
<blockquote>
<p><strong>요약</strong>: UI가 훅을 호출하면, 훅은 팩토리에 생성을 위임하고, 반환된 어댑터를 실행합니다.</p>
</blockquote>
<pre><code class="language-typescript">// usePayment.ts
export const usePayment = () =&gt; {
  const requestPayment = async (
    context: { country: string; businessType?: string },
    providerType: PaymentProviderType,
    params: PaymentRequestParams,
  ) =&gt; {
    // 1. 비즈니스 로직 검증 (Factory에게 위임)
    const allowed = PaymentFactory.getAvailableProviders(context);
    if (!allowed.includes(providerType)) {
      throw new Error(&#x27;지원하지 않는 결제 수단입니다.&#x27;);
    }

    // 2. 어댑터 획득 및 실행
    const adapter = PaymentFactory.getAdapter(providerType);
    await adapter.requestPayment(params);
  };

  return { requestPayment };
};
</code></pre>
<hr/>
<h2>🛡️ 4. 디테일: Zod로 데이터 무결성 철통 방어</h2>
<p>설계가 유연해진 만큼, 입력값에 대한 엄격한 검증이 중요해졌습니다. 팩토리가 올바른 객체를 생성하려면 입력값(Context)의 무결성이 보장되어야 하기 때문입니다.</p>
<p>&quot;사업자일 때만 무통장 입금이 가능하다&quot;는 규칙을 UI뿐만 아니라 <strong>데이터 스키마 레벨</strong>에서도 검증하고 싶었습니다. Zod의 <code>discriminatedUnion</code>이 빛을 발하는 순간입니다.</p>
<pre><code class="language-typescript">// schemas.ts

// 1. 일반 유저 (해당 없음)
const KrNormalSchema = z.object({
  businessType: z.literal(&#x27;NONE&#x27;),
  paymentMethod: z.literal(PaymentProviderType.TOSS), // 문자열 대신 enum 사용
});

// 2. 사업자 (개인/법인)
const KrBusinessSchema = z.object({
  businessType: z.enum([&#x27;INDIVIDUAL&#x27;, &#x27;CORPORATE&#x27;]),
  paymentMethod: z.enum([
    PaymentProviderType.TOSS,
    PaymentProviderType.GENERAL,
  ]), // 문자열 대신 enum 사용
  registrationFile: z.instanceof(File), // 사업자등록증 필수
});

// 3. 통합 스키마
export const KrPurchaseSchema = z.discriminatedUnion(&#x27;businessType&#x27;, [
  KrNormalSchema,
  KrBusinessSchema,
]);
</code></pre>
<p>이제 잘못된 조합(예: <code>NONE</code>인데 <code>GENERAL</code> 선택)은 아예 타입 에러가 나거나 검증을 통과할 수 없습니다.</p>
<h3>⚡️ Strategy Pattern을 활용한 &#x27;Lazy Loading UI&#x27;</h3>
<p>로직만 분리한다고 끝이 아닙니다. UI도 전략 패턴으로 분리할 수 있습니다.
특히 React의 <code>lazy</code>를 활용하면, <strong>한국 유저는 프랑스 결제 폼 코드를 다운로드할 필요가 없게 되어(Code Splitting)</strong> 초기 로딩 속도까지 잡을 수 있습니다.</p>
<pre><code class="language-typescript">// SelfPurchaseOrder/Create/index.tsx
import { Suspense, lazy } from &#x27;react&#x27;;

// 1. 전략 정의 (Code Splitting)
const UI_STRATEGIES = {
  KR: lazy(() =&gt; import(&#x27;./KrCreate&#x27;)), // 한국: 복잡한 사업자 로직 포함
  FR: lazy(() =&gt; import(&#x27;./FrCreate&#x27;)), // 프랑스: 간소화된 UI
  DEFAULT: lazy(() =&gt; import(&#x27;./GlobalCreate&#x27;)),
};

// 2. 전략 선택 (Strategy Selection)
const PurchaseFormStrategy = ({ country }: { country: string }) =&gt; {
  // UI_STRATEGIES의 키에 대한 타입 가드 함수
  const isUIStrategyKey = (key: string): key is keyof typeof UI_STRATEGIES =&gt; key in UI_STRATEGIES;

  const TargetForm = isUIStrategyKey(country)
    ? UI_STRATEGIES[country]
    : UI_STRATEGIES.DEFAULT;
  return &lt;TargetForm /&gt;;
};

// 3. 상위 컴포넌트에서 우아한 로딩 처리 (Suspense at Page Level)
export const PurchasePage = ({ country }: { country: string }) =&gt; {
  return (
    &lt;Suspense fallback={&lt;PageSkeleton /&gt;}&gt;
      &lt;PurchaseFormStrategy country={country} /&gt;
    &lt;/Suspense&gt;
  );
};
</code></pre>
<hr/>
<h2>🎉 5. 결론: &quot;복잡성은 숨기고, 유연함은 챙기고&quot;</h2>
<p>이제 팀장님의 질문에 자신 있게 대답할 수 있습니다.</p>
<blockquote>
<p><strong>&quot;팀장님, 프랑스 추가든 한국 사업자 조건 변경이든 걱정 마세요. 비즈니스 규칙은 Factory에 모아뒀고, 실행은 Adapter가 알아서 하고, 데이터는 Zod가 지키고 있습니다. 요구사항만 말씀해 주세요.&quot;</strong></p>
</blockquote>
<h3>✅ 이번 리팩토링으로 얻은 것</h3>
<ol>
<li><strong>비즈니스 로직 격리</strong>: &#x27;사업자 유형별 제한&#x27; 같은 규칙이 <code>PaymentFactory</code> 한곳에 모였습니다.</li>
<li><strong>안전한 확장</strong>: 새 국가나 결제 수단 추가 시 기존 코드를 건드리지 않아도 됩니다. (OCP)</li>
<li><strong>타입 안전성</strong>: Zod를 통해 복잡한 조건부 유효성 검사를 선언적으로 해결했습니다.</li>
</ol>
<h3>📝 바로 적용해보기 체크리스트</h3>
<ul class="contains-task-list">
<li class="task-list-item"><input type="checkbox" disabled=""/> 비즈니스 규칙(<code>if</code>)이 UI 컴포넌트에 흩어져 있지 않은지 확인한다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 조건에 따라 객체 생성이 달라진다면 <code>Factory Pattern</code>을 도입한다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 실행 로직이 다양하다면 <code>Adapter</code>로 인터페이스를 통일한다.</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> 복잡한 폼 검증은 <code>Zod Discriminated Union</code>으로 해결해본다.</li>
</ul>
<blockquote>
<p><strong>여러분은 어떠신가요?</strong></p>
<p>혹시 지금 보고 계신 코드에 <strong>&quot;국가 코드&quot;나 &quot;유저 타입&quot;을 체크하는 <code>if</code>문이 화면 곳곳에 흩어져 있나요?</strong> 그곳이 바로 팩토리 패턴이 필요한 지점일 수 있습니다. 댓글로 경험을 공유해주세요!</p>
</blockquote>
<br/>
<p><strong>태그</strong>: #TypeScript #DesignPattern #FactoryPattern #AdapterPattern #Zod #Refactoring #결제시스템</p>]]></content:encoded>
    </item>
    <item>
      <title>2025 회고: 글 33개, PR 38회, 발표 3회로 &apos;보여주는 개발자&apos;가 되다</title>
      <link>https://blog.sangwook.dev/posts/2025-retrospect/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/2025-retrospect/</guid>
      <pubDate>Sat, 20 Dec 2025 15:00:00 GMT</pubDate>
      <description>글 33개, PR 38회, 발표 3회. &apos;숨어서 잘하는 개발자&apos;에서 &apos;보여주고 나누는 개발자&apos;로 방향을 튼 한 해를 숫자로 정리했습니다. 조회수 상위 글들의 공통점, 오픈소스 기여가 커리어에 남긴 것, 다음 해에 이어갈 것까지.</description>
      <content:encoded><![CDATA[<p>2025년은 <strong>&quot;숨어서 잘하는 개발자&quot;</strong> 에서 <strong>&quot;보여주고 나누는 개발자&quot;</strong> 로 방향을 튼 해였습니다. 시작점은 2024년 12월 23일, 한 개발자의 피드백이었습니다.</p>
<blockquote>
<p><strong>&quot;평소 기술 관련 답변을 잘 해주시는데, 회고에는 기술적인 내용이 하나도 없네요.&quot;</strong></p>
</blockquote>
<p>그때 깨달았습니다. 개인브랜딩은 말을 잘하는 게 아니라, <strong>내가 어떤 문제를 어떤 방식으로 해결하는지</strong>를 <strong>검증 가능한 산출물(글/PR/발표)</strong> 로 남기는 일이라는 것. 이 글은 그 전환의 기록입니다.</p>
<hr/>
<h2>📊 한눈에 보는 2025</h2>

























<table><thead><tr><th>활동</th><th>실적</th><th>주요 성과</th></tr></thead><tbody><tr><td>📝 블로그</td><td>33개 포스트</td><td>누적 35,000+ 조회수</td></tr><tr><td>🔧 오픈소스</td><td>38회 PR 승인</td><td>Mantine 27회, Node.js 4회</td></tr><tr><td>🎤 발표</td><td>3회</td><td>쑥떡콘, FEConf 2025, TeoConf</td></tr></tbody></table>
<hr/>
<h2>블로그: “어떤 글이 읽히는지”를 확인하다</h2>
<p>처음엔 <strong>&quot;내가 이런 글을 써도 될까?&quot;</strong> 가 제일 컸습니다. 하지만 첫 글이 일주일 만에 <code>500회</code> 조회수를 넘기면서(초기 지표), <strong>“내가 어떤 고민으로 개발하는지”</strong> 를 글로 증명할 수 있겠다는 확신이 생겼습니다.</p>
<p>연말에 조회수 TOP3를 뜯어봤습니다.</p>
<ul>
<li><code>4시간 만에 Node.js PR 승인받기</code></li>
<li><code>15줄에서 2줄로: useSyncExternalStore 기반 React Toast 시스템 설계법</code></li>
<li><code>React 에러 구조 설계: throw만으로 선언적 에러 핸들링 하기</code></li>
</ul>
<p>세 글의 공통점을 분석해보니, 반응 좋은 글에는 패턴이 있었습니다.</p>
<ul>
<li>제목은 과정이 아니라 <strong>결과가 먼저 보인다.</strong> (숫자/시간/변화량처럼 “얼마나?”가 즉시 답이 나는 형태)</li>
<li><strong>권위 있는 대상 + 나의 실전 경험</strong>이 같이 있다. (튜토리얼/이론이 아니라 “충돌/기여/실패/통과”의 신뢰 신호)</li>
<li>문제 상황이 <strong>한 줄로 선명하다.</strong> (“이 상황에서 어떻게 했는지”가 제목만 봐도 보인다)</li>
<li>본문은 <strong>왜 쓰게 됐는지 → 기존 방식의 한계 → 선택 → 결과</strong> 흐름을 가진다.</li>
</ul>
<p>개념 중심/정리형 글은 검색 유입은 되지만 반응 지표는 약한 편이었습니다. 그래서 앞으로는 제목을 만들 때 <code>숫자 / 실패·충돌 / 특정 대상 / 변화 한 문장</code> 중 3개 이상을 기본 체크리스트로 두려고 합니다.</p>
<hr/>
<h2>오픈소스: 레퍼런스를 “외부 검증”으로 만들다</h2>
<p>처음 오픈소스에 기여할 때는 <strong>&quot;이런 사소한 것을 PR로 올려도 될까?&quot;</strong> 망설였습니다. 그런데 한 번 머지/승인을 경험하니 관점이 바뀌었습니다. 개인 프로젝트의 결과는 “내가 잘 됐다고 느끼는 것”에 머물 수 있는데, 오픈소스는 <strong>외부 리뷰와 릴리스라는 검증</strong>이 붙습니다.</p>
<p>2025년에는 특히 아래 경험이 글/발표의 근거가 됐습니다. (링크는 글 끝에 정리했습니다.)</p>
<ul>
<li><strong>Node.js</strong>: <code>util.inspect</code> 숫자 포맷팅 버그 재현 → 테스트 작성 → 수정 → <strong>4시간 만에 PR 승인</strong></li>
<li><strong>Mantine</strong>: Slider의 <code>onChangeEnd</code> 동기화 이슈를 <code>useRef</code>로 해결, 머지 후 릴리스 포함</li>
<li><strong>Next.js</strong>: 2번 실패 후, 원인/의도 파악을 거쳐 <strong>최종 PR 성공</strong></li>
<li><strong>gemini-cli</strong>: <code>Promise.allSettled()</code> 병렬 처리로 전환해 <strong>74% 성능 개선(408ms→107ms)</strong></li>
</ul>
<p>오픈소스 기여는 단순히 <code>횟수</code>가 아니라, 글과 발표의 밀도를 높여주는 재료가 됐습니다. 특히 실패/거절/리뷰 대응은 문서로 배울 수 없는 실전 경험이었습니다.</p>
<hr/>
<h2>컨퍼런스: 무대 위에 서다</h2>
<h3>쑥떡콘: Panda CSS 1년 사용기</h3>
<p><strong>&quot;Panda CSS 1년 사용기&quot;</strong> 로 첫 발표를 했습니다. 10건 이상의 질의응답을 주고받으며 사람들이 무엇을 궁금해하는지 알게 되었고, 이 경험이 더 나은 콘텐츠를 만드는 밑거름이 되었습니다.</p>
<h3>FEConf 2025: 오픈소스 밥상차리기</h3>
<p>그리고 FEConf 2025. <code>내가 해도 되는 건가?</code> 하는 마음으로 라이트닝 토크에 신청했고, 선정되었습니다. 3개월간 준비해서 35장의 슬라이드를 11장으로 압축하며 핵심만 남겼습니다.</p>
<p><strong>&quot;오픈소스 밥상차리기: 환경설정과 디버깅편&quot;</strong> 이라는 주제로 무대에 섰을 때, 다리가 떨렸지만 이 생각을 했습니다. <strong>&quot;나는 이미 충분히 준비됐어. 이제 내 이야기를 들려주기만 하면 되는 거야.&quot;</strong></p>
<p>발표 후 가장 기뻤던 피드백은 <strong>&quot;오늘 집에 가서 바로 PR 하나 올려볼게요!&quot;</strong> 였습니다. 누군가의 첫 걸음을 도왔다는 것, 그것이 발표자로서 가장 큰 보람이었습니다.</p>
<h3>TeoConf: 3주간의 디자인 시스템 배포 삽질기</h3>
<p>세 번째 컨퍼런스인 TeoConf에서는 디자인 시스템 배포 이야기를 꺼냈습니다. 하루면 끝낼 줄 알았던 배포가 3주로 늘어나고, 300KB를 목표로 했던 번들이 20MB까지 불어나는 등 소재는 차고 넘쳤습니다. 문제는 <strong>“할 이야기가 많다”는 게 곧 좋은 발표가 아니라는 것</strong>이었습니다.</p>
<p>사전 피드백에서 가장 뼈아팠던 말은 이거였습니다.</p>
<ul>
<li>핵심 메시지가 보이지 않는다</li>
<li>설명 흐름이 너무 기술적이다</li>
<li>청중과의 연결고리가 약하다</li>
</ul>
<p>리허설에서 <strong>“그래서 제일 어려웠던 게 뭐였나요?”</strong> 라는 질문에 답을 못한 순간, 내가 발표를 <code>정리</code>하지 못했다는 걸 인정하게 됐습니다. 결국 TeoConf가 내게 남긴 건 번들링/빌드 삽질의 해결책보다, <strong>발표는 기술을 나열하는 자리가 아니라 청중이 가져갈 한 문장을 만드는 과정</strong>이라는 깨달음이었습니다.</p>
<hr/>
<h2>선순환 구조의 형성</h2>
<p>2025년 가장 큰 성과는 개인 브랜딩을 <code>의지</code>가 아니라 <code>구조</code>로 만든 것입니다.</p>
<p>블로그 글은 꾸준히 쓰고 있었고, 그 글을 기반으로 FEConf 같은 곳에 신청하며 발표도 함께 늘려갔습니다. 여기에 오픈소스 기여가 더해지면서 실전 경험과 레퍼런스가 폭발적으로 쌓였고, 글과 발표의 밀도가 올라가며 네트워크가 확장되고 더 많은 기회가 열렸습니다.</p>
<blockquote>
<p>(블로그: 기록/정리) ↔ (컨퍼런스: 신청/발표) + (오픈소스: 기여) → 실전 경험·레퍼런스 → 신뢰·네트워크 → 더 많은 기회 → 다시 글/발표/기여</p>
</blockquote>
<p>이 선순환이 작동하기 시작하면서, 더 이상 <strong>&quot;브랜딩을 위해 무언가를 해야 한다&quot;</strong> 는 부담이 사라졌습니다. 그냥 좋아서 하는 일들이 자연스럽게 브랜드를 만들어갔습니다.</p>
<hr/>
<h2>배운 것들</h2>
<ul>
<li><strong>브랜딩은 ‘자기소개’가 아니라 ‘증명’이다.</strong> 신뢰는 “잘할 것 같다”가 아니라 “이미 해본 흔적(글/PR/발표)”에서 나온다.</li>
<li><strong>작은 기여도 쌓이면 흔적이 남는다.</strong> 27회 기여하니 내 PR 히스토리가 곧 신뢰 지표가 됐다.</li>
<li><strong>발표는 청중 중심이다.</strong> “할 이야기가 많다”를 버리고 “청중이 가져갈 한 문장”을 먼저 만든다.</li>
<li><strong>실패를 숨기지 않을수록 설득력이 커진다.</strong> 실패→수정→통과의 과정이 재현 가능한 학습 재료가 됐다.</li>
</ul>
<hr/>
<h2>마치며</h2>
<p>처음에는 <strong>&quot;내가 공유할 만한 가치가 있을까?&quot;</strong> 라고 의심했습니다. 하지만 작은 용기를 내어 글을 쓰고, PR을 올리고, 발표를 하다 보니 생각보다 많은 사람들이 “경험의 맥락”에서 가치를 발견했습니다.</p>
<p>개인 브랜딩의 핵심은 <strong>&quot;대단한 무언가&quot;</strong> 가 아니라 <strong>&quot;진정성 있는 공유&quot;</strong> 였고, 시행착오와 꾸준함이 전문성을 만든다는 것을 배웠습니다.</p>
<p>2026년에는 이 선순환 구조를 더 단단하게 만들어, 한국 프론트엔드 커뮤니티에 긍정적인 영향을 주는 개발자가 되고 싶습니다.</p>
<h3>💬 여러분의 이야기를 들려주세요</h3>
<ul>
<li>2025년 여러분의 가장 큰 성장은 무엇이었나요?</li>
<li>오픈소스 기여를 시작하고 싶은데 망설여지시나요?</li>
<li>블로그를 시작하려는데 주저하고 계신가요?</li>
</ul>
<p>댓글로 여러분의 경험과 고민을 나눠주세요! 🙌</p>
<hr/>
<h2>링크 모음</h2>
<ul>
<li>Node.js PR: <a href="https://github.com/nodejs/node/pull/59379">https://github.com/nodejs/node/pull/59379</a></li>
<li>Node.js 글: <a href="https://blog.sangwook.dev/posts/nodejs-contribution/">https://blog.sangwook.dev/posts/nodejs-contribution/</a></li>
<li>Mantine PR: <a href="https://github.com/mantinedev/mantine/pull/7660">https://github.com/mantinedev/mantine/pull/7660</a></li>
<li>Mantine Release: <a href="https://github.com/mantinedev/mantine/releases/tag/7.17.4">https://github.com/mantinedev/mantine/releases/tag/7.17.4</a></li>
<li>Mantine 글: <a href="https://blog.sangwook.dev/posts/first-open-source-contribution/">https://blog.sangwook.dev/posts/first-open-source-contribution/</a></li>
<li>Next.js 실패 PR: <a href="https://github.com/vercel/next.js/pull/80428">https://github.com/vercel/next.js/pull/80428</a></li>
<li>Next.js 성공 PR: <a href="https://github.com/vercel/next.js/pull/81146">https://github.com/vercel/next.js/pull/81146</a></li>
<li>Next.js 글: <a href="https://blog.sangwook.dev/posts/nextjs-contributor/">https://blog.sangwook.dev/posts/nextjs-contributor/</a></li>
<li>gemini-cli PR #1: <a href="https://github.com/google-gemini/gemini-cli/pull/3288">https://github.com/google-gemini/gemini-cli/pull/3288</a></li>
<li>gemini-cli PR #2: <a href="https://github.com/google-gemini/gemini-cli/pull/4763">https://github.com/google-gemini/gemini-cli/pull/4763</a></li>
<li>gemini-cli 글: <a href="https://blog.sangwook.dev/posts/ai-opensource-contribution/">https://blog.sangwook.dev/posts/ai-opensource-contribution/</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>2025 Teoconf 발표 후기</title>
      <link>https://blog.sangwook.dev/posts/2025-teoconf-presentation/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/2025-teoconf-presentation/</guid>
      <pubDate>Wed, 10 Dec 2025 15:00:00 GMT</pubDate>
      <description>40페이지짜리 슬라이드를 다 만들고 나서야 알았습니다. 발표는 기술을 나열하는 자리가 아니라 청중이 가져갈 한 문장을 만드는 과정이라는 것을. 테오콘 신청과 사전 미팅, 혹독했던 사전 녹화 피드백, 그리고 무대 위까지의 기록입니다.</description>
      <content:encoded><![CDATA[<h2>🎤 발표 후기: ‘내가 하고 싶은 말’ 대신 ‘청중이 가져갈 한 문장’을 찾기까지</h2>
<p>이번 테오콘 발표는 내게 하나의 도전이었다.<br/>
디자인 시스템 배포라는, 나에게 깊은 경험이지만 청중에게는 다소 생소할 수 있는 주제를 어떻게 풀어낼 것인가.<br/>
그리고 그 과정에서 **“발표는 기술을 나열하는 자리가 아니라, 청중이 가져갈 한 문장을 만드는 과정”**임을 뒤늦게 깨닫게 되었다.</p>
<hr/>
<h2>1. 신청 — 아쉬움에서 출발한 도전</h2>
<p>FEConf가 끝나고 나에게 큰 아쉬움이 남았다.<br/>
준비 과정에서는 20분 넘게 하고도 모자랐던 내용이 실제 발표에서는 10분으로 끝났다.</p>
<p>“좀 더 깊게 파고들 수 있었는데…”<br/>
이 아쉬움을 해결하고 싶어 <strong>테오콘</strong>에 신청했다.<br/>
그리고 이번에는 디자인 시스템 배포라는, 오랫동안 정리하고 싶던 주제를 발표로 풀어보기로 했다.</p>
<hr/>
<h2>2. 사전 미팅 — ‘할 이야기가 많다’는 것이 함정</h2>
<p>테오와 1:1 사전 미팅을 하며 내 경험을 시간순으로 설명했다.<br/>
삽질도 많았고 배운 것도 많았기에 테오도 “이야기 자체는 좋다”고 했다.<br/>
문제는 너무 많다는 것.<br/>
이때부터 슬슬 불안함이 스며들기 시작했다.</p>
<p>나는 이 경험을 온전히 전달하고 싶었는데,<br/>
<strong>그 욕심이 발표의 범위를 걷잡을 수 없이 넓히고 있다는 걸 그때는 몰랐다.</strong></p>
<hr/>
<h2>3. 발표 자료 만들기 — 내용은 40페이지, 피드백은 생각보다 혹독했다</h2>
<p>먼저 글을 쓰고 그 글을 슬라이드로 옮기는 방식으로 준비했다.<br/>
자연스레 슬라이드는 40페이지가 넘어갔다.</p>
<p>사전 녹화를 제출하고 받은 피드백은 예상보다 훨씬 날카로웠다.</p>
<ul>
<li>핵심 메시지가 보이지 않는다</li>
<li>설명 흐름이 너무 기술적이다</li>
<li>목소리가 너무 차분하다</li>
<li>청중과의 연결고리가 약하다</li>
</ul>
<p>하나하나 곱씹으며 수정했지만 사실 이때도 <strong>“어디를 수정해야 하는지”</strong> 명확하게 알지 못했다.</p>
<hr/>
<h2>4. 리허설 — 무엇이 어렵냐는 질문에 답하지 못했다</h2>
<p>사전 리허설에서 테오가 물었다.</p>
<p><strong>“그럼, 이 발표에서 제일 어려운 부분이 뭐였나요?”</strong></p>
<p>그 질문에 나는 정확하게 답하지 못했다.<br/>
그 순간 하나를 깨달았다.</p>
<blockquote>
<p><strong>“나는 지금 무엇이 문제인지도 모르는 상태로 발표를 준비하고 있었구나.”</strong></p>
</blockquote>
<p>다른 스피커들의 발표를 보며 그들은 논리 흐름이 깔끔했고,<br/>
청중 집중을 유지하기 위한 장치도 자연스러웠다.<br/>
나는 그걸 잘 못하고 있다는 사실만 명확해졌다.</p>
<p>슬라이드는 수정하다보니 어느새 70페이지를 넘어갔고,<br/>
당일 아침까지도 수정이 계속됐다.</p>
<p>레이니가 1:1로 피드백을 주었고<br/>
그제야 슬라이드의 ‘정렬’이 잡히기 시작했다.</p>
<hr/>
<h2>5. 발표 당일 — 슬라이드는 일방향이지만, Q&amp;A는 나를 드러낼 수 있었다</h2>
<p>발표 당일 리허설을 한 번 더 했다.<br/>
긴장은 되었지만 견딜 만했다.<br/>
다른 스피커들과 인사하며 분위기도 좋았다.</p>
<p>하지만 세션 2가 시작되자 압박감이 올라왔다.<br/>
앞 발표자가 짤로 분위기를 띄우는 걸 보며<br/>
“나도 이런 걸 넣을걸…” 하는 생각이 들었다.</p>
<p>그리고 내 순서가 되었다.</p>
<p>준비한 대로 발표를 진행했지만, 솔직히 <strong>내가 무슨 말을 했는지 기억이 잘 나지 않는다.</strong><br/>
발표가 약 22분 정도로 끝나고 Q&amp;A 시간이 되자 그제야 정신이 돌아왔다.</p>
<p>놀랍게도 발표보다 Q&amp;A가 훨씬 편했다.</p>
<ul>
<li>서비스와 패키지 분리를 어떻게 보나요?</li>
<li>디자인 시스템 적용 시 CSS override 전략은?</li>
<li>Todo list가 많은데 현재 진행 상황은 어떻게 되었나요?</li>
<li>20mb 중에 결국 무엇이 문제였나요?</li>
<li>예측 불가능한 문제는 어떻게 해결하고 레퍼런스를 찾으시나요?</li>
</ul>
<p>이 질문들은 발표에서 다 담지 못한 부분들이었고,<br/>
내가 평소 고민하던 문제들이었다.<br/>
단방향 발표보다 상호작용 속에서<br/>
내 경험의 진짜 맥락을 더 잘 전달할 수 있었다.</p>
<hr/>
<h2>6. 뒷풀이 — 해방감, 그리고 다시 생각하게 된 ‘발표의 본질’</h2>
<p>발표가 끝나자 안도감이 밀려왔다.<br/>
우리 조 사람들과 연락처를 주고받고, 60명 가까이 모인 뒷풀이도 갔다.</p>
<p>운영진분들이 직접 서빙까지 하며 분위기를 살려주었고,<br/>
테이블마다 이직 이야기, 회사 이야기, 각자의 고민이 오갔다.</p>
<p>행사가 마무리되고 테오와 잠시 이야기를 나눴다.<br/>
나는 “좀 아쉽다, 제대로 전달 못한 것 같다”고 말했다.<br/>
그러자 테오는 혼도 내고, 격려도 해주었다.<br/>
그리고 “내년에 또 발표하라”고 했다.</p>
<p>그 말을 듣고 생각했다.</p>
<blockquote>
<p><strong>다음 발표는 ‘내가 하고 싶은 이야기’가 아니라<br/>
‘청중이 한 문장이라도 가져갈 이야기’를 해야겠다.</strong></p>
</blockquote>
<p>레이니가 나를 “아픈 손가락”이라 말했다는 이야기를 들었을 때,<br/>
미안함과 고마움이 동시에 밀려왔다.</p>
<hr/>
<h2>🔍 이번 발표에서 내가 얻은 것, 놓친 것, 다음에 할 것</h2>
<h3>내가 얻은 것</h3>
<ul>
<li>오프라인에서 대중 앞에 서본 경험 자체가 큰 성장이었다.</li>
<li>기술적 내용을 발표 형태로 풀어보는 시도가 좋았다.</li>
<li>청중과의 Q&amp;A에서 내가 가진 경험의 ‘진짜 가치’를 전달할 수 있었다.</li>
</ul>
<h3>아쉬웠던 점</h3>
<ul>
<li>욕심이 많아 발표 범위를 좁히지 못했다.</li>
<li>내가 하고 싶은 말이 우선이었고, 청중 메시지가 정리되지 않았다.</li>
<li>기술 설명과 스토리텔링의 균형이 어려웠다.</li>
<li>슬라이드에 집중하느라 청중과 눈을 잘 맞추지 못했다.</li>
</ul>
<h3>다음 발표에서는 이렇게 할 것</h3>
<ul>
<li><strong>청중이 무엇을 가져갈지 먼저 정의하기.</strong></li>
<li>전달 범위를 과감하게 줄이기.</li>
<li>발표 자료 제작을 더 효율적으로 할 수 있는 도구나 방식을 찾기.</li>
</ul>
<hr/>
<h2>🎯 마지막 한 문장</h2>
<p>이번 발표는 완벽하지 않았다.<br/>
하지만 덕분에 발표의 본질을 알게 됐다.</p>
<blockquote>
<p><strong>좋은 발표는 기술을 나열하는 것이 아니라, 청중이 가져갈 ‘단 하나의 문장’을 찾아가는 과정이다.</strong></p>
</blockquote>
<p>이 한 문장을 배웠다는 것만으로도,<br/>
이번 발표는 충분히 의미 있었다.</p>]]></content:encoded>
    </item>
    <item>
      <title>하루 만에 끝날 줄 알았던 디자인 시스템 배포가 3주 걸린 이유</title>
      <link>https://blog.sangwook.dev/posts/npm-deploy-series-0-prologue/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/npm-deploy-series-0-prologue/</guid>
      <pubDate>Sat, 29 Nov 2025 15:00:00 GMT</pubDate>
      <description>&apos;별거 아니겠지&apos;라고 생각했던 디자인 시스템 NPM 배포가 어떻게 3주간의 삽질로 이어졌는지, 그리고 그 과정에서 배운 타협의 기술. 사내에서만 쓰던 패키지를 세상에 내놓기까지를 다루는 배포 시리즈의 프롤로그입니다.</description>
      <content:encoded><![CDATA[<h2>프롤로그: &quot;별거 아니겠지&quot;의 착각</h2>
<blockquote>
<p>타팀 기획자님이 내 자리로 다가왔다.</p>
<p>&quot;개발자님, 저희 팀에서도 그 디자인 시스템 쓸 수 있을까요? 버튼이랑 인풋 컴포넌트가 정말 예쁘던데...&quot;</p>
<p>당시 디자인 시스템은 완성해둔 참이었다.
모노레포 안에서는 잘 돌아가고 있었다.
Button, Input, Card... 약 60개 정도?</p>
<p>&quot;아, 그거요? 별거 아니에요.&quot;</p>
<p>나는 자신만만하게 대답했다.</p>
<p>&quot;NPM에 올리기만 하면 되니까 하루면 될 것 같은데요?&quot;</p>
<p>기획자님의 환한 미소가 아직도 기억난다.
&quot;정말요? 그럼 이번 주 안에 가능할까요?&quot;</p>
<p>&quot;네, 금방이에요!&quot;</p>
</blockquote>
<hr/>
<p>그리고 3주가 지났다.</p>
<p>하루라고 했던 작업은 21일이 걸렸다.
그 사이에 나는:</p>
<ul>
<li>빌드 도구와 10일을 싸웠고</li>
<li>20MB 괴물 패키지를 만들어냈고</li>
<li>CSS 충돌로 머리를 쥐어뜯었고</li>
<li>무한 재귀 배포를 중단시켰다</li>
</ul>
<p>지금 돌아보면 <strong>그날의 나에게 말해주고 싶다.</strong></p>
<p><strong>&quot;별거 아닌 일은 없어. 특히 배포는.&quot;</strong></p>
<h2>그땐 좋아 보였는데...</h2>
<h3>완벽한 계획 (예상 기간: 1일)</h3>
<p>머릿속으로 그린 계획은 완벽했다.</p>
<ul>
<li>트리셰이킹</li>
<li>CSS 번들</li>
<li>Type 지원</li>
<li>배포 문서</li>
<li>자동 배포 시스템</li>
</ul>
<p><strong>&quot;배포 말고는 이미 잘 되어 있는거니 별 문제 없겠지?&quot;</strong></p>
<p><strong>과연 이 계획대로 될까?</strong></p>
<h3>우리 프로젝트 상황</h3>
<p><strong>모노레포 구성</strong></p>
<ul>
<li>신규 프로젝트와 디자인 시스템을 동시에 구현</li>
<li>코드 공유와 의존성 관리의 효율성 고려</li>
<li>향후 다른 팀으로의 확장 가능성</li>
</ul>
<p><strong>기술 스택 선택: Mantine + Panda CSS</strong></p>
<p>당시 팀 상황을 고려하면 현실적인 선택이었다:</p>
<ul>
<li>개발/디자인 리소스 부족</li>
<li>디자인 시스템 구축 경험 부족</li>
<li>Tailwind 문법에 대한 팀 내 선호도 문제</li>
</ul>
<p>Mantine의 높은 완성도와 Panda CSS의 유연성이 우리 팀에 적합해 보였다.</p>
<p><strong>현재 프로젝트 구조</strong></p>
<pre><code>monorepo/
├── design-system          # 메인 디자인 시스템
└── packages/
    ├── @package/core      # HTTP 클라이언트, 상태 코드
    ├── @package/constants
    ├── @package/entities
    └── @package/service
</code></pre>
<p>디자인 시스템 패키지와 4개의 공통 내부 패키지로 구성.</p>
<p><strong>배포 요구사항</strong></p>
<p>타팀에서 디자인 시스템을 사용하기 위해 충족해야 할 조건들:</p>
<ol>
<li><strong>NPM 패키지 배포</strong>: GitHub Packages, 버전 관리, 문서화</li>
<li><strong>외부 설치 가능</strong>: 모노레포 외부 서비스에서 손쉬운 설치</li>
<li><strong>환경 호환성</strong>:
<ul>
<li>타팀 환경: Remix + Tailwind</li>
<li>우리 환경: Mantine + Panda CSS</li>
</ul>
</li>
<li><strong>내부 패키지 의존성 처리</strong>: 4개 내부 패키지 의존성 관리</li>
</ol>
<p><strong>&quot;별거 아니겠지&quot;라고 생각한 1일 예상 작업이 3주간의 여정으로 시작됐다.</strong></p>
<h2>1주차: 빌드 지옥편 - &quot;나는 라이브러리 번들을 몰랐다&quot;</h2>
<h3>일단 빌드 부터 해보자!</h3>
<p><strong>빌드 도구 선택: tsup</strong></p>
<p>여러 빌드 도구를 검토한 끝에 <strong>tsup</strong>을 선택했다.</p>
<p><strong>tsup을 선택한 이유:</strong></p>
<ul>
<li>본인이 라이브러리 배포에 대한 지식이 전무</li>
<li>간편한 config → 러닝 커브 낮음</li>
<li>TypeScript 타입 자동 생성 지원 (.d.ts 파일 자동 생성)</li>
<li>esbuild 기반 → go 기반 가볍고 빠름</li>
<li>후처리 파이프라인과 통합 용이 → onSuccess 훅으로 다양하고 편리한 통합 가능</li>
</ul>
<p>첫 번째 config는 심플했다:</p>
<pre><code class="language-typescript">export default defineConfig({
  entry: [&#x27;src/index.ts&#x27;, &#x27;preset.ts&#x27;],
  splitting: true,
  sourcemap: true,
  clean: true,
  dts: true,
  format: [&#x27;esm&#x27;],
  outDir: &#x27;dist&#x27;,
  minify: true,
});
</code></pre>
<h3>첫 번째 벽: Panda styled-system</h3>
<p>&quot;자, 이제 빌드만 하면 끝이다.&quot;</p>
<p>tsup 설정도 했고, entry point도 지정했고.
터미널에 <code>pnpm build</code>를 입력했다.</p>
<p>엔터.</p>
<p>그리고 <strong>1초 만에.</strong></p>
<pre><code>[ERROR] Could not resolve &#x27;@styles/patterns&#x27;
</code></pre>
<p>&quot;...뭐?&quot;</p>
<p>빌드가 실패했다. 첫 시도부터 바로 실패.</p>
<p>에러 메시지를 다시 읽어봤다. &#x27;@styles/patterns&#x27;를 찾을 수 없다고?
분명 개발 환경에서는 잘 동작하고 있었는데...</p>
<p>&quot;아, 빌드 환경에서는 경로가 다른가?&quot;</p>
<p>구글링을 시작했다. <code>tsup styled-system error</code>, <code>panda css build</code>...</p>
<p><strong>30분간의 검색 끝에 알게 된 것:</strong></p>
<ol>
<li>Panda CSS의 <code>styled-system</code>은 인스톨 시점에 생성되는 폴더다</li>
<li>tsup이 빌드를 실행할 때는 아직 존재하지 않는다</li>
<li><code>@styles</code> alias도 tsup이 모른다</li>
</ol>
<p><strong>해결책:</strong></p>
<ol>
<li>빌드 전에 <code>panda prepare</code>를 먼저 실행 → styled-system 폴더 생성</li>
<li>esbuildOptions에 alias 경로 직접 지정</li>
</ol>
<p>첫 번째 벽은 넘었다. 그런데 이게 끝이 아니었다.</p>
<h3>예상하지 못한 SVG 문제</h3>
<p>우리는 SVGR을 사용해서 SVG를 리액트 컴포넌트로 변환하고 있었다.</p>
<pre><code class="language-typescript">// common
export { default as AddPerson } from &#x27;../common/AddPerson.svg&#x27;;
</code></pre>
<p>tsup이 JS 모듈을 기대했지만 svg가 들어있어서 에러 발생.</p>
<pre><code>RollupError: &quot;default&quot; is not exported by &quot;src/assets/svgs/common/AddPerson.svg&quot;
</code></pre>
<h3>SVG 지옥으로의 입장</h3>
<p>Panda 문제를 해결하고 다시 빌드를 돌렸다.</p>
<p>이번엔 다른 에러가 떴다.</p>
<pre><code>RollupError: &quot;default&quot; is not exported by &quot;src/assets/svgs/common/AddPerson.svg&quot;
</code></pre>
<p>&quot;이번엔 SVG?&quot;</p>
<p>우리는 SVGR을 사용해서 SVG를 리액트 컴포넌트로 변환하고 있었다.
개발 환경에서는 Vite가 알아서 처리해줬는데, tsup은 몰랐던 것이다.</p>
<p>&quot;이것도 설정 문제겠지. 금방 해결되겠네.&quot;</p>
<hr/>
<p><strong>1일차: 타입 설정으로 해결하려 함</strong></p>
<p><code>svg.d.ts</code> 파일을 만들고, 모듈 타입을 선언했다.</p>
<p>빌드 다시 실행.</p>
<p>같은 에러.</p>
<p>&quot;타입만으로는 안 되나보네...&quot;</p>
<hr/>
<p><strong>2일차: GitHub 이슈 탐색</strong></p>
<p><code>tsup svg error</code>, <code>rollup svg not exported</code>...</p>
<p>관련 이슈들을 10개쯤 읽었다.</p>
<ul>
<li>&quot;이거 써보세요&quot; → 우리 상황이랑 다름</li>
<li>&quot;플러그인 쓰세요&quot; → 버전이 안 맞음</li>
<li>&quot;그냥 파일로 처리하세요&quot; → 그럼 컴포넌트는?</li>
</ul>
<p>&quot;나만 이런 문제 겪는 건가...?&quot;</p>
<hr/>
<p><strong>3일차: esbuild 플러그인 직접 작성</strong></p>
<p>esbuild-plugin-svgr도 써보고,
직접 플러그인도 만들어봤다.</p>
<pre><code class="language-typescript">// 이것도 해보고, 저것도 해보고...
</code></pre>
<p>결과: 복잡성만 증가하고 새로운 에러만 발생.</p>
<p>&quot;이거... 완벽하게는 안 되는 거 아닌가?&quot;</p>
<p>3일이 지났다. SVG 타입 하나 때문에.</p>
<h3>타협 #1: 완벽을 포기하는 순간</h3>
<p>3일이 지났다.</p>
<p>SVG 타입 문제는 해결되지 않았다.
더 이상 방법이 떠오르지 않았다.</p>
<p>슬랙 메시지가 왔다.
&quot;진행 어떻게 되고 있어요?&quot;</p>
<p>&quot;아... 이거 언제까지 붙잡고 있을 건가?&quot;</p>
<hr/>
<p><strong>선택의 순간</strong></p>
<p>완벽한 타입 지원 vs 일단 돌아가는 빌드</p>
<p>머릿속으로 따져봤다:</p>
<ul>
<li>현재 우리 모노레포에서는 타입이 이미 잘 동작하고 있다</li>
<li>외부 팀도 IDE에서 타입 추론은 될 것이다</li>
<li>SVG 타입 몇 개 누락되는 게 치명적인가?</li>
</ul>
<p>&quot;일단... 돌아가게 만들자.&quot;</p>
<p><strong>결정: 이중 빌드 시스템</strong></p>
<ul>
<li>tsup: 번들링만 담당 (dts: false)</li>
<li>tsc: 타입 정의 파일만 별도 생성</li>
</ul>
<pre><code class="language-typescript">// tsup.config.ts
onSuccess: &#x27;npx tsc --project tsconfig.build.json&#x27;;
</code></pre>
<p>빌드를 다시 실행했다.</p>
<pre><code>✓ Build success
✓ Type declaration files created
</code></pre>
<p>&quot;...된다!&quot;</p>
<p><strong>결과 확인:</strong></p>
<ul>
<li>✅ 빌드 성공</li>
<li>⚠️ SVG 관련 타입 일부 누락</li>
<li>⚠️ 몇몇 컴포넌트 타입 미지원</li>
<li>✅ 하지만 일단 돌아간다</li>
</ul>
<p>완벽하진 않았다.
하지만 완벽을 기다리다간 영영 배포 못 할 것 같았다.</p>
<p>&quot;첫 번째 타협이었다.&quot;</p>
<h2>2주차: 로컬 테스트와 실제 적용 - &quot;20MB... 하지만 일단 돌아간다&quot;</h2>
<h3>이젠! 설치 테스트를 해보자!</h3>
<p>빌드는 성공했다. 이제 실제로 설치해서 테스트해봐야 했다.</p>
<p><strong>배포 사이클:</strong></p>
<pre><code>로컬 테스트
    ↓
실 서비스 설치
    ↓
코드 수정 및 push
    ↓
GitHub Actions CI &amp; CD
</code></pre>
<h3>pnpm pack으로 빠른 테스트 환경 구축</h3>
<p><strong>배포 전에 로컬에서 테스트하고 싶었지만, npm publish는 번거롭고 시간이 오래 걸렸다. 빠른 피드백 루프가 필요했다.</strong></p>
<p><strong>pnpm pack을 선택한 이유:</strong></p>
<ul>
<li>실제 배포와 동일한 형태의 .tgz 파일 생성</li>
<li>빠른 반복 테스트 가능</li>
</ul>
<pre><code class="language-bash"># 디자인 시스템 패키지에서
pnpm pack

# 다른 서비스에서
pnpm install ../design-system/design-system-0.0.2.tgz
</code></pre>
<p><strong>피드백 루프:</strong></p>
<ol>
<li>코드 수정</li>
<li>pnpm pack으로 패키지 생성</li>
<li>테스트 환경에 재설치</li>
<li>테스트 및 문제 발견</li>
<li>반복</li>
</ol>
<p>실제 배포 환경과 유사한 테스트 환경이 구축됐다.</p>
<h3>dist 폴더가 없네?!?!!? 왜지?!?!!?</h3>
<p>패키지를 설치하고 확인했더니... dist 폴더가 보이지 않았다.</p>
<p><strong>package.json 스펙 공부 시작:</strong></p>
<ul>
<li><code>exports</code>: 어떤 파일을 외부에 노출할지</li>
<li><code>types</code>: 타입 선언 경로</li>
<li><code>files</code>: npm publish 시 포함할 파일</li>
<li><code>publishConfig</code>: 배포 레지스트리·접근 설정</li>
</ul>
<p>&quot;dist가 안 보이는 이유는 files 설정 때문일 수도 있다.&quot;</p>
<p>결국 package.json을 수정:</p>
<pre><code class="language-json">{
  &quot;name&quot;: &quot;@kit-design/ui&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;files&quot;: [&quot;dist&quot;],
  &quot;exports&quot;: {
    &quot;.&quot;: {
      &quot;types&quot;: &quot;./dist/src/index.d.ts&quot;,
      &quot;import&quot;: &quot;./dist/index.js&quot;
    },
    &quot;./preset&quot;: {
      &quot;types&quot;: &quot;./dist/preset.d.ts&quot;,
      &quot;import&quot;: &quot;./dist/preset.js&quot;
    }
  },
  &quot;publishConfig&quot;: {
    &quot;exports&quot;: {
      &quot;.&quot;: {
        &quot;types&quot;: &quot;./dist/src/index.d.ts&quot;,
        &quot;import&quot;: &quot;./dist/index.js&quot;
      }
    }
  }
}
</code></pre>
<h3>이젠! 실서비스 설치 테스트를 해보자!</h3>
<h3>CSS 문제: 스타일 적용이 안 됨</h3>
<p>디자인 시스템을 다른 서비스에 설치하고 첫 테스트를 진행했다. 모든 CSS를 지우고 시작했다.</p>
<p><strong>컴포넌트 렌더링 상태:</strong></p>
<ul>
<li>컴포넌트는 정상적으로 렌더링됨</li>
<li>버튼, 카드 등 모든 컴포넌트가 Mantine 스타일로 렌더링됨</li>
<li>개발자 도구로 확인하니 class는 있지만 전역 CSS class가 없음</li>
</ul>
<p><strong>문제 원인:</strong>
Panda CSS의 스타일 시트가 빌드 과정에서 미포함. Panda CSS 함수를 찾을 수 없었다.</p>
<h3>Panda CSS 전달 문제 해결 시도</h3>
<p>세 가지 방법을 시도했다:</p>
<p><strong>1. 스타일 시트 생성</strong></p>
<ul>
<li>공식문서 따라함</li>
<li><code>panda codegen --outfile styles.css</code></li>
<li>결과: 필요한 클래스가 전부 생성되지 않음</li>
</ul>
<p><strong>2. buildInfo 활용</strong></p>
<ul>
<li>공식문서 따라함</li>
<li>Panda의 buildInfo</li>
<li>클라이언트 설정 필요</li>
<li>결과: 원인 불명으로 미동작</li>
</ul>
<p><strong>3. Styled-system 참조</strong></p>
<ul>
<li>미리 만든 styled-system을 클라이언트에 연결</li>
<li>결과: node_modules 참조 문제</li>
</ul>
<p>모두 실패했다.</p>
<h3>타협 #2: &quot;일단 돌아가게&quot;</h3>
<p>Panda CSS 스타일이 적용되지 않는 문제.
3가지 방법을 시도했지만 모두 실패했다.</p>
<p>더 이상 방법이 떠오르지 않았다.</p>
<p>&quot;빌드 파이프라인을 처음부터 다시 공부해야 하나...?&quot;</p>
<p>시간과 리소스. 둘 다 부족했다.</p>
<p><strong>결정: 소스코드를 통째로 넣자</strong></p>
<pre><code class="language-json">{
  &quot;files&quot;: [&quot;src&quot;]
}
</code></pre>
<p>styled-system도, 소스코드도, 전부.
최적화는 포기하고 일단 돌아가게.</p>
<p>빌드 성공. 스타일도 적용됨.</p>
<p>&quot;무겁지만... 작동한다.&quot;</p>
<p>💡 <strong>&quot;일단 돌아가게 만들자&quot;</strong></p>
<p>두 번째 타협이었다.</p>
<h3>20MB 괴물의 탄생</h3>
<p>빌드도 됐고, 타입도 생성됐다.</p>
<p>&quot;이제 진짜 거의 다 왔다.&quot;</p>
<p><code>pnpm pack</code>으로 최종 패키지를 만들었다.</p>
<pre><code>✓ Tarball created: design-system-0.0.2.tgz
</code></pre>
<p>&quot;자, 사이즈나 한번 볼까?&quot;</p>
<p>터미널에 <code>ls -lh</code>를 쳤다.</p>
<pre><code>20M design-system-0.0.2.tgz
</code></pre>
<p>&quot;...20MB?&quot;</p>
<p>눈을 의심했다.</p>
<p>다시 확인했다. 20MB가 맞았다.</p>
<p>&quot;이게... 정상인가?&quot;</p>
<hr/>
<p><strong>비교해보기</strong></p>
<p>급하게 다른 UI 라이브러리들을 확인했다.</p>
<ul>
<li>Mantine (약 120개 컴포넌트): 12MB</li>
<li>MUI: 5.6MB</li>
<li>우리 디자인 시스템 (60개 컴포넌트): <strong>20MB</strong></li>
</ul>
<p>&quot;...두 배네.&quot;</p>
<p><strong>패키지 내용 확인:</strong></p>
<p>압축을 풀어서 들여다봤다.</p>
<pre><code>node_modules/
├── src/ (전체 소스코드)
├── styled-system/ (Panda CSS 전체)
├── assets/ (폰트, SVG 전부)
└── ...
</code></pre>
<p>&quot;아... 소스코드를 통째로 넣어서 그런 거구나.&quot;</p>
<hr/>
<p><strong>다시 선택의 순간</strong></p>
<p>최적화를 다시 시도할까?
아니면 일단 이대로 진행할까?</p>
<p>시계를 봤다. 벌써 2주가 지났다.
슬랙 메시지도 계속 오고 있었다.</p>
<p>&quot;일단... 돌아가는 게 먼저다.&quot;</p>
<p>20MB.
무겁지만, 작동은 한다.</p>
<p>&quot;최적화는... 나중에.&quot;</p>
<p>두 번째 타협이었다.</p>
<h2>3주차-1: 실제 서비스 적용 - &quot;CSS 충돌은 예상했지만 이 정도일 줄은...&quot;</h2>
<h3>이제 기존 CSS 통합해보자!</h3>
<h3>불가능한 조합의 현실</h3>
<p>&quot;이제 진짜 거의 다 왔다.&quot;</p>
<p>로컬 테스트도 성공했다.
이제 실제 서비스에 적용만 하면 된다.</p>
<p>타팀 환경:</p>
<ul>
<li>Remix</li>
<li>Tailwind CSS</li>
<li>레거시 전역 CSS</li>
</ul>
<p>&quot;환경이 좀 다르긴 한데... 뭐 CSS니까 괜찮겠지?&quot;</p>
<hr/>
<p><strong>기존 CSS 파일을 열어본 순간</strong></p>
<pre><code class="language-css">@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .button {
    background: red !important;
    padding: 10px !important;
  }
}

/* layer 없이 직접 작성된 스타일들 */
.some-legacy-class {
  margin: 20px !important;
  color: blue !important;
}
</code></pre>
<p>&quot;...!important 지옥이네.&quot;</p>
<p>온갖 하드코딩 CSS가 <code>!important</code>로 도배되어 있었다.
우리 디자인 시스템 스타일이 전부 덮어씌워지고 있었다.</p>
<p>&quot;이거... 어떻게 하지?&quot;</p>
<p>Tailwind의 <code>@layer</code> 문법도 섞여있고,
레거시 스타일도 있고,
우리 Mantine + Panda CSS까지.</p>
<p><strong>세 개의 CSS 시스템이 충돌하고 있었다.</strong></p>
<h3>@layer로 우선순위 제어</h3>
<p>CSS <code>@layer</code> 문법을 알게 됐다.</p>
<pre><code class="language-css">/* 레이어 우선순위 설정 - Mantine을 낮은 우선순위로 배치 */
@layer reset, base, tokens, recipes, utilities, tailwind, mantine,
       panda-reset, panda-base, panda-tokens, panda-recipes, panda-utilities;

/* Mantine CSS를 낮은 우선순위 layer에 배치 */
@import &#x27;@mantine/core/styles.css&#x27; layer(mantine);

/* Tailwind도 별도 layer로 관리 */
@layer tailwind {
  @tailwind base;
  @tailwind components;
  @tailwind utilities;
}
</code></pre>
<p>이 방법이 유일하게 원하던 대로 된 해결책이었다.</p>
<p><strong>결과:</strong></p>
<ul>
<li>✅ <code>!important</code> 없이도 스타일 제어 가능</li>
<li>✅ Mantine, Tailwind, Panda CSS 모두 공존 가능</li>
<li>✅ 우선순위 디버깅이 훨씬 쉬워짐</li>
</ul>
<h3>이러면 전부 다 잘 되나?</h3>
<h3>타협 #3: 완벽한 호환</h3>
<p><strong>현실:</strong> 다른 프레임워크 + 다른 CSS 라이브러리 + !important</p>
<p><strong>근거:</strong> 서비스가 운영 중이어서 단번에 구조를 뒤엎는 것이 위험. 어쩔 수 없는 레거시는 점진적으로 수정하기로.</p>
<p><strong>결정:</strong> 점진적 마이그레이션</p>
<p>디자인 시스템을 적용하면서 수동으로 수정해야 했다.</p>
<p><strong>타협안:</strong></p>
<ul>
<li>⚠️ <code>!important</code> 문제가 남음</li>
<li>⚠️ 기존 스타일 일부 깨짐</li>
</ul>
<p><strong>실용적 결정:</strong></p>
<ul>
<li>✅ 실 서비스에 적용은 가능</li>
<li>어쩔 수 없는 레거시 청산으로 하나씩 수정하기로</li>
</ul>
<p>&quot;완벽하진 않지만, 돌아간다.&quot;</p>
<h2>3주차-2: 배포 자동화 - &quot;마지막까지 순탄하지 않았다&quot;</h2>
<h3>이제 배포 파이프라인을 만들자!</h3>
<p>로컬 테스트도 성공했다. 실 서비스 적용도 가능해졌다. 이제 자동 배포 시스템을 만들 차례였다.</p>
<p><strong>목표:</strong></p>
<ol>
<li>GitHub Actions 워크플로우가 develop 브랜치 PR 이벤트에 각 패키지의 버전 체크</li>
<li>변경할 버전이 있다면 version update 할 정보를 담아 json에 저장</li>
<li>머지(push) 할 때 version update commit 생성</li>
</ol>
<h3>npm scope 문제 - 대참사의 시작</h3>
<p>GitHub npm에 배포를 시도했을 때 예상치 못한 문제가 발생했다.</p>
<pre><code>403 error
</code></pre>
<p>권한 부족?</p>
<p><strong>원인:</strong>
패키지 이름(name)이 npm scope 규칙에 맞지 않음. <code>@org/packagename</code> 형식이어야 했다.</p>
<p>당당하게 지었던 패키지 이름이 발목을 잡았다.</p>
<p><strong>해결 방법 고민:</strong></p>
<ol>
<li><strong>GitHub 조직 새로 만들기</strong> → 복잡하고 시간 소요</li>
<li><strong>사설 서버 설치</strong> ex) Nexus, etc</li>
<li><strong>패키지 이름 변경</strong> ✅ → 관련 파일의 import 구문 변경</li>
</ol>
<p>결국 패키지 이름을 scope 규칙에 맞게 변경했다. 모든 import 문을 수정해야 했다.</p>
<h3>GitHub Actions 무한 재귀의 공포</h3>
<p>자동 배포 시스템도 거의 다 만들었다.</p>
<p>Changeset으로 버전 관리도 하고,
GitHub Actions로 자동 배포도 설정했다.</p>
<p>&quot;이제 진짜 끝이다!&quot;</p>
<p>워크플로우 파일을 커밋하고 push했다.</p>
<hr/>
<p><strong>10초 후</strong></p>
<p>GitHub Actions 탭을 열어봤다.</p>
<pre><code>✓ Deploy #1
⏳ Deploy #2
⏳ Deploy #3
⏳ Deploy #4
...
</code></pre>
<p>&quot;...뭐지?&quot;</p>
<p>배포가 계속 실행되고 있었다.
멈출 기미가 없었다.</p>
<p><strong>20초 후</strong></p>
<pre><code>✓ Deploy #1
✓ Deploy #2
✓ Deploy #3
⏳ Deploy #4
⏳ Deploy #5
⏳ Deploy #6
⏳ Deploy #7
...
</code></pre>
<p>&quot;미치겠다.&quot;</p>
<p>급하게 워크플로우를 수동으로 취소했다.</p>
<hr/>
<p><strong>원인 파악</strong></p>
<p>로그를 들여다봤다.</p>
<ol>
<li>Actions가 develop 브랜치 push 이벤트에 반응</li>
<li>package.json 버전을 자동으로 수정</li>
<li>수정된 파일을 자동 커밋</li>
<li>새 커밋이 다시 push 이벤트 발생</li>
<li>1번부터 다시 반복</li>
</ol>
<p><strong>무한 재귀였다.</strong></p>
<p>&quot;아... 자동 커밋이 다시 push 이벤트를 트리거하는구나.&quot;</p>
<p><strong>해결:</strong></p>
<p>워크플로우 트리거 조건에 추가:</p>
<ul>
<li>Auth가 <code>github[bot]</code>이면 바로 종료</li>
</ul>
<p>다시 push했다.</p>
<p>이번엔 한 번만 실행됐다.</p>
<p>&quot;...드디어.&quot;</p>
<h3>드디어 성공한 배포 자동화 시스템!</h3>
<p>무한 재귀를 막고 다시 push했다.</p>
<p>GitHub Actions 탭을 열었다.
이번엔 조심스럽게.</p>
<pre><code>⏳ Deploy workflow running...
</code></pre>
<p>&quot;제발...&quot;</p>
<pre><code>✓ Build success
✓ Type check passed
✓ Package published to npm
</code></pre>
<p><strong>&quot;...드디어.&quot;</strong></p>
<p>3주 만이었다.</p>
<hr/>
<p><strong>완성된 시스템:</strong></p>
<p>✅ <strong>버전 관리 자동화</strong></p>
<ul>
<li>PR 머지 시 Changeset이 버전 업데이트</li>
</ul>
<p>✅ <strong>배포 파이프라인</strong></p>
<ul>
<li>develop 브랜치 push 시 자동 npm 배포</li>
</ul>
<p>✅ <strong>문서 자동화</strong></p>
<ul>
<li>CHANGELOG 자동 생성</li>
<li>PR 링크 자동 추가</li>
</ul>
<p>타팀에 메시지를 보냈다.</p>
<p>&quot;배포 완료했습니다. <code>npm install @org/design-system</code>으로 설치 가능합니다.&quot;</p>
<p>기획자님의 답장:
&quot;오! 드디어! 고생하셨어요~&quot;</p>
<p>&quot;...하루 걸린다더니 3주 걸렸지만요.&quot;</p>
<p>그래도 작동한다.
완벽하진 않지만, 작동한다.</p>
<h2>에필로그: 3주간의 타협이 가르쳐 준 것</h2>
<h3>&quot;별거 아니에요&quot;에서 &quot;해냈습니다&quot;까지</h3>
<p>3주 전, 나는 말했다.
&quot;별거 아니에요. 하루면 될 것 같은데요?&quot;</p>
<p>3주 후, 나는 완성했다.
완벽하지 않지만, 작동하는 배포 시스템을.</p>
<p>그 사이에 있었던 것:</p>
<ul>
<li>3번의 타협</li>
<li>수십 번의 빌드 실패</li>
<li>20MB 괴물 패키지</li>
<li>무한 재귀 배포</li>
</ul>
<p><strong>그리고 하나의 깨달음.</strong></p>
<hr/>
<h3>타협한 것들 vs 그래도 성공한 것</h3>





















<table><thead><tr><th>타협한 것들</th><th>그래도 성공한 것</th></tr></thead><tbody><tr><td><strong>타입 지원</strong><br/>- tsup + tsc 이중빌드<br/>- SVG 타입 누락</td><td><strong>실제 사용가능</strong><br/>- remix + tailwind 환경 지원</td></tr><tr><td><strong>CSS 번들</strong><br/>- 소스코드 통째로</td><td><strong>자동 배포 시스템</strong><br/>- changeset + github actions</td></tr><tr><td><strong>가벼운 번들 크기</strong><br/>- 20MB 괴물</td><td><strong>npm 배포 완료</strong></td></tr></tbody></table>
<p>완벽하지 않다.
하지만 <strong>작동한다.</strong></p>
<hr/>
<h3>이 시리즈에서 다룰 이야기들</h3>
<p>3주간의 여정에서 부딪힌 문제들을 하나씩 깊게 파헤칠 예정입니다:</p>
<p><strong>🔧 빌드 &amp; 번들링</strong></p>
<ul>
<li>tsup으로 라이브러리 번들링하기</li>
<li>SVG 타입 문제 해결기</li>
<li>이중 빌드 시스템 구축</li>
</ul>
<p><strong>📦 패키지 &amp; 배포</strong></p>
<ul>
<li>pnpm pack으로 로컬 테스트</li>
<li>package.json exports 가이드</li>
<li>번들 사이즈 최적화</li>
</ul>
<p><strong>🎨 스타일링 &amp; CSS</strong></p>
<ul>
<li>Panda CSS 빌드 결과물 배포</li>
<li>@layer로 CSS 우선순위 제어</li>
<li>세 개의 CSS 시스템 공존시키기</li>
</ul>
<p><strong>🚀 자동화 &amp; CI/CD</strong></p>
<ul>
<li>Changeset + GitHub Actions</li>
<li>npm 배포 자동화</li>
<li>무한 재귀 워크플로우 해결</li>
</ul>
<p>어떤 주제가 먼저 나올지는... 제 기분 따라 정하겠습니다. (아니면 댓글로 요청해주세요!)</p>
<hr/>
<h3>3주간의 타협이 가르쳐 준 것</h3>
<p><strong>타협 #1: 완벽한 타입 → 일단 작동하는 타입</strong></p>
<ul>
<li>현실: SVG 타입 생성 불가능</li>
<li>근거: 모노레포에서는 이미 타입 지원됨</li>
<li>결정: tsc로 타입만 별도 생성</li>
</ul>
<p><strong>타협 #2: 최적화된 번들 → 일단 작동하는 번들</strong></p>
<ul>
<li>현실: 빌드 최적화 시간 vs 배포 일정</li>
<li>근거: 초기 목표는 &quot;작동하는 배포&quot;</li>
<li>결정: 최적화는 나중에</li>
</ul>
<p><strong>타협 #3: 완벽한 호환 → 점진적 마이그레이션</strong></p>
<ul>
<li>현실: 다른 프레임워크 + CSS 라이브러리 + !important</li>
<li>근거: 운영 중인 서비스, 한번에 뒤엎기엔 위험</li>
<li>결정: 천천히 하나씩 수정</li>
</ul>
<p><strong>세 번의 타협 덕분에 배포는 완성될 수 있었습니다.</strong></p>
<hr/>
<h3>마지막으로</h3>
<p>의사결정에서 중요한 건 <strong>&#x27;정답&#x27;</strong> 이 아니라 <strong>&#x27;왜 그렇게 결정했는가&#x27;</strong> 라는 근거입니다.</p>
<p>&quot;별거 아니에요&quot;라고 말했던 그날로부터 3주.</p>
<p>완벽하지는 않다. 20MB짜리 패키지, 누락된 타입, 수동으로 고쳐야 할 CSS들.</p>
<p>하지만 <strong>작동한다.</strong> 다른 팀이 사용할 수 있고, 자동으로 배포되고, 점진적으로 개선할 수 있다.</p>
<p>이 과정에서 배운 가장 중요한 것:</p>
<p><strong>&quot;별거 아닌 일은 없다. 하지만 완벽하지 않아도 배포할 수 있다.&quot;</strong></p>
<hr/>
<p>누군가는 이 글을 보고 같은 삽질을 하지 않기를,
그리고 혹시 삽질 중이라면 &quot;나만 이런 게 아니구나&quot;라고 위로받기를 바랍니다.</p>]]></content:encoded>
    </item>
    <item>
      <title>[Typescript로 설계하는 프로젝트] 타입 한 줄로 552개 파일을 2주 만에 안전하게 수정한 방법</title>
      <link>https://blog.sangwook.dev/posts/typescript-project-type-safe-refactor-with-typescript/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/typescript-project-type-safe-refactor-with-typescript/</guid>
      <pubDate>Wed, 05 Nov 2025 15:00:00 GMT</pubDate>
      <description>회원 구조가 바뀌어 552개 파일을 고쳐야 했지만 2주 만에 사이드 이펙트 없이 끝냈습니다. 1년 전에 심어둔 브랜디드 타입 한 줄이 컴파일러를 리팩토링 체크리스트로 바꿔준 이야기와, 같은 안전장치를 지금 프로젝트에 심는 방법.</description>
      <content:encoded><![CDATA[<p><strong>&quot;회원 구조가 바뀌었습니다. 552개 파일을 수정해야 합니다.&quot;</strong></p>
<p>보통은 이렇게 됩니다</p>
<ul>
<li>어디를 수정해야 하는지 찾느라 1주</li>
<li>수정하다가 놓친 곳 때문에 버그 발생</li>
<li>회귀 테스트에 또 1주</li>
<li>QA에서 엣지 케이스 발견</li>
<li>결국 한 달...</li>
</ul>
<p>하지만 우리는 <strong>2주 만에, 사이드 이펙트 없이</strong> 끝냈습니다.</p>
<p>비결은 1년 전 작성한 이 한 줄이었습니다.</p>
<blockquote>
<pre><code class="language-typescript">type ProfileId = string;
</code></pre>
</blockquote>
<hr/>
<h2>문제 상황</h2>
<p>회원 구조가 바뀌었습니다.</p>
<pre><code>[Before]
1 Account → 1 ProfileId (필수)

[After]
1 Account → N ProfileId (옵셔널)
</code></pre>
<p>예전에는 항상 ProfileId가 선택되어 있었지만, 이제는 선택하지 않을 수도 있게 되었습니다.</p>
<hr/>
<h2>문제의 크기</h2>
<p>거의 모든 서비스 기능이 영향을 받았습니다.</p>
<p><strong>영향받은 주요 기능</strong></p>
<ul>
<li><strong>콘텐츠 관리</strong> (업로드, 수정, 삭제)</li>
<li><strong>데이터 조회</strong> (통계, 수익, 리포트)</li>
<li><strong>권한 관리</strong> (접근 제어, 팀 관리)</li>
</ul>
<p>이런 코드가 곳곳에 있었습니다:</p>
<pre><code class="language-typescript">const profileId = getProfileId(); // 항상 string이라고 가정
api.fetchData(profileId);
</code></pre>
<p>기존엔 <code>getProfileId()</code>가 항상 <code>string</code>을 보장했습니다. 하지만 이제는 <code>undefined</code>일 수 있게 되었습니다. 옵셔널이 되어야 합니다.</p>
<p><strong>552개 파일을 수정해야 하는 상황.</strong> 어떻게 안전하게 해낼 수 있을까요?</p>
<hr/>
<h2>왜 가능했을까? #1: 도메인 분리의 시작점</h2>
<p>처음 코드를 작성할 때, 이렇게 할 수도 있었습니다:</p>
<pre><code class="language-typescript">// ❌ 의미 없는 원시 타입
const id = cookies.get(&#x27;ProfileId&#x27;); // any 타입
api.fetchData(id); // 타입 체크 없음
</code></pre>
<p>하지만 우리는 이렇게 했습니다:</p>
<pre><code class="language-typescript">// ✅ 도메인 개념을 표현하는 타입
type ProfileId = string;

const id = cookies.get&lt;ProfileId&gt;(&#x27;ProfileId&#x27;); // ProfileId 타입
api.fetchData(id); // 타입 안전
</code></pre>
<p><strong>무엇이 달라졌을까요?</strong></p>
<p><code>string</code>은 그냥 원시 타입입니다. <strong>&quot;문자열&quot;</strong> 이라는 것 외에 아무 의미가 없습니다.</p>
<p>하지만 <code>ProfileId</code>는 다릅니다:</p>
<ul>
<li><strong>&quot;이 값은 프로필을 식별하는 ID다&quot;</strong></li>
<li><strong>&quot;이 값은 특정 도메인에 속한다&quot;</strong></li>
</ul>
<p><strong>코드에 의미 계층을 추가한 것</strong>입니다.</p>
<p>이 작은 차이가 대규모 변경을 가능하게 만들었습니다.</p>
<hr/>
<h2>해결 전략</h2>
<p><strong>핵심은 타입 추상화에 있었습니다.</strong></p>
<p>코드 곳곳에 <code>ProfileId</code> 타입이 명시되어 있었기 때문에, 반환 타입만 바꾸면 컴파일러가 수정이 필요한 모든 곳을 알려줄 수 있었습니다.</p>
<pre><code class="language-typescript">// 변경 전
const getProfileId = () =&gt; cookies.get&lt;ProfileId&gt;(&#x27;ProfileId&#x27;); // ProfileId 반환

// 변경 후
const getProfileId = () =&gt; cookies.get&lt;ProfileId | undefined&gt;(&#x27;ProfileId&#x27;); // ProfileId | undefined 반환
</code></pre>
<p>타입 하나만 바꿨을 뿐인데, 타입스크립트 컴파일러가 수정이 필요한 모든 곳을 찾아냈습니다.</p>
<hr/>
<h2>왜 가능했을까? #2: 변경 추적 가능한 단위 생성</h2>
<p>타입 별칭이 없었다면 어땠을까요?</p>
<pre><code class="language-typescript">// 만약 string을 직접 사용했다면
const getProfileId = () =&gt; cookies.get&lt;string&gt;(&#x27;ProfileId&#x27;);
const fetchData = (id: string) =&gt; api.fetch(id);
const userId: string = &#x27;123&#x27;;
const profileId: string = &#x27;456&#x27;;
</code></pre>
<p>이 코드의 문제는 <strong>의미가 없다</strong>는 것입니다.</p>
<p><code>string</code>만 봐서는:</p>
<ul>
<li>어떤 <code>string</code>이 ProfileId인지 알 수 없음</li>
<li>어떤 <code>string</code>이 UserId인지 알 수 없음</li>
<li>나중에 코드를 읽을 때 매번 변수명을 확인해야 함</li>
</ul>
<hr/>
<p>하지만 <code>ProfileId</code>라는 타입을 만들면:</p>
<pre><code class="language-typescript">type ProfileId = string;

const getProfileId = (): ProfileId =&gt; cookies.get&lt;ProfileId&gt;(&#x27;ProfileId&#x27;);
const fetchData = (id: ProfileId) =&gt; api.fetch(id);
const profileId: ProfileId = &#x27;456&#x27;;
</code></pre>
<p><strong>무엇이 달라지나?</strong></p>
<h3>1. 코드 자체가 문서가 됩니다</h3>
<pre><code class="language-typescript">// Before: 이게 뭔지 알 수 없음
function fetchData(id: string) {}

// After: ProfileId를 받는구나! 명확함
function fetchData(id: ProfileId) {}
</code></pre>
<h3>2. 변경 추적이 가능해집니다</h3>
<p>이제 <code>getProfileId()</code>의 반환 타입만 바꾸면:</p>
<pre><code class="language-typescript">// 이 한 줄만 수정
export const getProfileId = () =&gt;
  cookies.get&lt;ProfileId | undefined&gt;(&#x27;ProfileId&#x27;);
</code></pre>
<p><strong>TypeScript 컴파일러가 영향받는 모든 곳을 찾아냅니다:</strong></p>
<pre><code>❌ Type &#x27;string | undefined&#x27; is not assignable to type &#x27;string&#x27;
❌ Argument of type &#x27;string | undefined&#x27; is not assignable to parameter
❌ Object is possibly &#x27;undefined&#x27;
</code></pre>
<p>이 에러들이 <strong>수정이 필요한 모든 파일의 위치</strong>를 정확히 알려줬습니다.</p>
<hr/>
<p><strong>이것이 &quot;변경 추적 가능한 단위&quot;를 만든 것입니다.</strong></p>
<p><code>ProfileId</code> 타입 덕분에:</p>
<ol>
<li><strong>검색</strong>: 정확히 관련 코드만 찾음</li>
<li><strong>문서화</strong>: 코드 읽을 때 의미가 즉시 파악됨</li>
</ol>
<p>타입 하나만 바꾸면, 그 변경이 자동으로 전체에 전파되고, 컴파일러가 수정이 필요한 모든 곳을 알려줍니다.</p>
<p><strong>단순히 string이 아니라, ProfileId라는 의미를 부여한 것</strong> - 이것이 안전하게 수정할 수 있었습니다.</p>
<hr/>
<h2>수정 과정</h2>
<p><strong>우선순위별로 분류했습니다:</strong></p>
<ol>
<li><strong>기획 확인 필요</strong> - ProfileId가 없는 상황을 고려하지 않은 케이스</li>
<li><strong>단순 null 체크</strong> - 옵셔널 체이닝이나 조건문 추가</li>
<li><strong>로직 변경</strong> - 흐름 자체를 수정해야 하는 케이스</li>
</ol>
<hr/>
<p><strong>패턴 1: 옵셔널 체이닝</strong></p>
<pre><code class="language-typescript">// Before
const data = profileId.something;

// After
const data = profileId?.something;
</code></pre>
<p><strong>패턴 2: 필수값 보장하기</strong></p>
<p>path parameter처럼 &#x27;반드시 있어야 하는&#x27; 경우를 위한 훅을 만들었습니다:</p>
<pre><code class="language-typescript">const useProfileIdByPath = (): ProfileId =&gt; {
  const { profileId } = useParams&lt;{ profileId: ProfileId }&gt;();

  if (!profileId)
    throw new Error(
      &#x27;useProfileIdByPath hook must be used within an profileId path&#x27;,
    );

  return profileId;
};
</code></pre>
<hr/>
<p><strong>기획 논의는 이런 식이었습니다:</strong></p>
<blockquote>
<p>나: &quot;컴파일 오류를 해결하다 보니 이 상황에서는 추가적인 화면 기획이 필요해요! 에러 화면이나 비어 있는 화면이 필요합니다.&quot;</p>
<p>기획: &quot;아, 그 부분은 생각 못 했네요. 로그인 페이지로 보내주세요.&quot;</p>
</blockquote>
<p>또는</p>
<blockquote>
<p>나: &quot;이 기능 자체에 대한 기획이 빠져 있는 거 같아요! 이 부분 추가로 필요합니다.&quot;</p>
</blockquote>
<p>하나씩 수정하다 보니 <strong>총 552개 파일이 변경</strong>되었습니다.</p>
<hr/>
<h2>결과</h2>
<p><strong>552개 파일. 2주. 사이드 이펙트 0건.</strong></p>
<p>타입 시스템이 QA 역할까지 해주었습니다. 컴파일러가 알려주는 에러를 하나씩 해결하다 보니, 놓칠 수 있었던 엣지 케이스까지 모두 처리할 수 있었습니다.</p>
<p>심지어 기획에서 빠진 부분까지 발견했습니다.</p>
<hr/>
<h2>추가 효과: 협업 속도 향상</h2>
<p>타입 별칭의 효과는 여기서 끝나지 않습니다.</p>
<p><strong>협업할 때도 차이가 납니다.</strong></p>
<pre><code class="language-typescript">// ❌ 의미 없는 원시 타입
function updateProfile(id: string, name: string) {
  // id가 UserId인가? ProfileId인가? TeamId인가?
  // name이 displayName인가? userName인가?
}

// ✅ 의미 있는 타입
function updateProfile(id: ProfileId, name: DisplayName) {
  // 명확합니다
}
</code></pre>
<p><strong>코드 자체가 문서 역할</strong>을 합니다. 주석이나 별도 문서 없이도 <strong>&quot;이 함수는 ProfileId를 받는구나&quot;</strong> 를 즉시 알 수 있습니다.</p>
<hr/>
<p><strong>하지만 주의하세요</strong></p>
<p>모든 <code>string</code>을 타입으로 만들 필요는 없습니다.</p>
<p>❌ 과도한 적용</p>
<pre><code class="language-typescript">type UserName = string;
type ButtonLabel = string;
type ErrorMessage = string;
</code></pre>
<p>✅ 의미 있는 적용</p>
<pre><code class="language-typescript">type UserId = string; // 식별자
type ProfileId = string; // 도메인 핵심 개념
type ApiKey = string; // 보안/검증 필요
</code></pre>
<p><strong>도메인의 핵심 개념이나, 추적이 필요한 식별자</strong>처럼 꼭 필요해 보이는 곳에만 적용하세요.</p>
<hr/>
<h2>배운 점</h2>
<p><strong>타입 설계의 중요성</strong></p>
<p><code>string</code> 대신 <code>ProfileId</code>를 썼던 과거의 선택이 모든 차이를 만들었습니다.</p>
<p><strong>&quot;나중에 필요할지도 몰라&quot;</strong> 하는 막연한 기대가 아니라, <strong>지금 당장 코드의 의미를 명확하게 만들기 위해</strong> 타입을 만들었던 것이 1년 후 대규모 변경을 가능하게 만들었습니다.</p>
<p>타입은 단순히 에러를 잡는 도구가 아닙니다. <strong>코드에 의미를 부여하고, 변경을 추적 가능하게 만드는 설계 도구</strong>입니다.</p>
<hr/>
<h2>당신의 프로젝트에도</h2>
<p>지금 바로 확인해보세요.</p>
<p><strong>체크리스트:</strong></p>
<pre><code>□ string, number 같은 원시 타입을 직접 사용하고 있나요?
□ UserId, ProductId, Price 같은 의미 있는 타입으로 바꿀 수 있나요?
□ 도메인 핵심 개념을 타입으로 표현하고 있나요?
□ 제네릭을 활용해서 타입 정보를 유지하고 있나요?
</code></pre>
<p><strong>지금 당장 시작하세요:</strong></p>
<ol>
<li>가장 중요한 식별자 하나를 골라보세요 (userId, orderId, productId...)</li>
<li><code>type UserId = string</code> 한 줄을 추가하세요</li>
<li>해당 타입을 사용하는 모든 곳에 타입을 명시하세요</li>
<li>다음에 변경이 필요할 때, 그 위력을 경험하게 될 것입니다</li>
</ol>
<hr/>
<h2>3개월 후 이 글을 다시 읽는 당신에게</h2>
<p><strong>&quot;그때 ProfileId 타입을 만들어둬서 다행이야.&quot;</strong></p>
<p>이 문장을 하게 될 순간이 반드시 옵니다.</p>
<ul>
<li>API 응답 구조가 바뀔 때</li>
<li>권한 시스템을 추가할 때</li>
</ul>
<p><strong>타입 한 줄은 미래의 당신에게 보내는 선물입니다.</strong></p>]]></content:encoded>
    </item>
    <item>
      <title>React 에러 구조 설계: throw만으로 선언적 에러 핸들링 하기</title>
      <link>https://blog.sangwook.dev/posts/react-error-deign/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/react-error-deign/</guid>
      <pubDate>Mon, 13 Oct 2025 15:00:00 GMT</pubDate>
      <description>에러가 계층을 따라 전파된다면 에러 클래스도 계층으로 설계할 수 있지 않을까. 커스텀 에러 클래스 상속과 throw만으로 ErrorBoundary가 섹션마다 다르게 반응하는 구조를 만들고, throw와 return 중 무엇을 고를지도 정리했습니다.</description>
      <content:encoded><![CDATA[<h2>0. 프롤로그</h2>
<blockquote>
<p><code>throw</code>를 보던중 문득 깨달았다.</p>
<pre><code class="language-typescript">try {
  throw new Error(&#x27;error&#x27;);
} catch (error) {
  throw error; // 상위로 전파된다
}
</code></pre>
<p>에러는 계층을 따라 전파된다.
그럼 에러 클래스도 계층 구조로 설계할 수 있지 않을까?</p>
<pre><code>깊게: Error → ApiError → HttpError → NotFoundError → StatsNotFoundError
넓게: Error → ApiError → StatsError, ChartError, ActivityError
</code></pre>
<p>ErrorBoundary에서 <code>instanceof</code>로 잡으려면 되지 않을까?</p>
</blockquote>
<h2>1. 에러를 어떻게 설계 할 것인가?</h2>
<h3>1.1. 문제: 모든 에러가 똑같이 보인다</h3>
<pre><code class="language-typescript">// 세 개의 API - 각각 다른 섹션 데이터
const getDashboardStats = async () =&gt; {
  /* ... */
};
const getChartData = async () =&gt; {
  /* ... */
};
const getActivities = async () =&gt; {
  /* ... */
};
</code></pre>
<p>에러가 발생하면?</p>
<pre><code class="language-tsx">componentDidCatch(error: Error) {
  // 😰 이게 통계 에러야? 차트 에러야? 활동 에러야?
  // instanceof Error 하면 전부 true - 구분 불가능
}
</code></pre>
<p><strong>문제점</strong>: ErrorBoundary에서 섹션별 처리 불가, 디버깅 어려움</p>
<h3>1.2. 왜 클래스 설계인가?</h3>
<p>프롤로그에서 본 계층 구조를 실제로 구현하려면 <strong>클래스 상속</strong>이 필요하다.</p>
<pre><code>Error (JavaScript 내장)
  ↓
ApiError (Base)
  ↓
├─ StatsError
├─ ChartError
└─ ActivityError
</code></pre>
<p><strong>상속 구조의 장점</strong></p>
<ol>
<li>
<p><strong>instanceof로 타입 체크</strong></p>
<pre><code class="language-typescript">if (error instanceof StatsError) {
  // StatsError만 처리
}
</code></pre>
</li>
<li>
<p><strong>계층적 ErrorBoundary 설계</strong></p>
<pre><code>StatsErrorBoundary: StatsError만 캐치
GlobalErrorBoundary: ApiError 전체 캐치
</code></pre>
</li>
<li>
<p><strong>공통 속성 관리</strong></p>
<pre><code>ApiError에 statusCode, code 정의
하위 클래스들이 자동으로 상속
</code></pre>
</li>
</ol>
<p><strong>클래스 상속이 있어야 얕고 넓은 구조를 만들 수 있다.</strong></p>
<pre><code class="language-typescript">const getDashboardStats = async () =&gt; {
  try {
    const response = await instance.get(&#x27;/api/dashboard/stats&#x27;);
    return response.data;
  } catch (error: unknown) {
    if (isHttpError(error)) {
      throw new StatsError(
        error.response?.data?.message ||
          &#x27;통계 데이터를 불러오는데 실패했습니다&#x27;,
        error.response?.data?.error,
      );
    }
    throw error;
  }
};
</code></pre>
<p><strong>개선점</strong>: <code>instanceof StatsError</code>로 타입 구분 가능, ErrorBoundary 선택적 처리</p>
<p><strong>핵심</strong>: try-catch로 Error을 잡아서, 커스텀 에러 클래스로 변환해서 throw</p>
<h3>1.3. throw vs return?</h3>
<p>에러를 변환하기로 했다. 그런데 질문이 하나 생긴다.</p>
<p><strong>&quot;변환한 에러를 throw 해야 할까, return 해야 할까?&quot;</strong></p>
<blockquote>
<p><strong>나는 throw를 선호한다.</strong><br/>
코드 리뷰를 하다 보면, return error 방식에서 자잘한 버그를 너무 자주 봤다.<br/>
에러를 return 값으로 넘겼는데 중간에 체크를 빠뜨리거나, 조건문 분기가 복잡해지는 경우가 많았다.</p>
<p>throw는 명료하다. 에러가 발생하면 자동으로 전파되고, 중간 계층은 신경 쓸 필요가 없다.</p>
</blockquote>
<p><strong>두 가지 선택지</strong></p>
<p><strong>방식 1: throw</strong></p>
<pre><code class="language-typescript">const getDashboardStats = async () =&gt; {
  try {
    const response = await instance.get(&#x27;/api/dashboard/stats&#x27;);
    return response.data;
  } catch (error) {
    throw new StatsError(/*...*/); // 👈 throw
  }
};
</code></pre>
<p><strong>방식 2: return error</strong></p>
<pre><code class="language-typescript">const getDashboardStats = async () =&gt; {
  try {
    const response = await instance.get(&#x27;/api/dashboard/stats&#x27;);
    return { data: response.data, error: null };
  } catch (error) {
    return { data: null, error: new StatsError(/*...*/) }; // 👈 return
  }
};
</code></pre>
<p>두 방식 모두 가능하다. 선언적으로 Error을 핸들링하기 위해선 <strong>throw가 더 적합</strong>하다. 왜 그럴까?</p>
<h3>1. 흐름 제어가 간단하다</h3>
<p><strong>return error 방식의 문제:</strong></p>
<pre><code class="language-typescript">const useStats = async () =&gt; {
  const result = await getDashboardStats();
  if (result.error) return result; // 매번 체크

  const processed = processData(result.data);
  if (processed.error) return processed; // 또 체크

  return processed;
};
</code></pre>
<p>함수 체인의 모든 단계에서 에러를 체크해야 한다. 코드가 길어지고, 실수하기 쉽다.</p>
<p><strong>throw 방식의 장점:</strong></p>
<pre><code class="language-typescript">const useStats = async () =&gt; {
  const result = await getDashboardStats(); // 에러나면 자동으로 throw
  return processData(result); // 깔끔!
};
</code></pre>
<p>에러가 발생하면 자동으로 상위로 전파된다. 일일이 <code>if (error)</code> 체크할 필요 없다.</p>
<h3>2. 계층 간 유연한 통합</h3>
<p>React 애플리케이션의 계층 구조를 보자:</p>
<pre><code>Component (ErrorDesignPage)
    ↓
Hook (useDashboardStats)
    ↓
API (getDashboardStats)
    ↓
SectionErrorBoundary
    ↓
ErrorBoundary
</code></pre>
<p><strong>throw를 사용하면:</strong></p>
<ul>
<li>Component는 에러를 몰라도 된다 (선언적으로 사용)</li>
<li>Hook도 에러 처리 코드가 필요 없다</li>
<li>API 레이어에서만 에러를 변환</li>
<li>ErrorBoundary가 자동으로 캐치</li>
</ul>
<p><strong>핵심</strong>: 중간 계층들이 에러를 props나 return으로 전달할 필요 없이, throw만 하면 ErrorBoundary까지 자동으로 전파된다.</p>
<h3>3. React ErrorBoundary와의 시너지</h3>
<p>React Query + Suspense + ErrorBoundary 조합을 보자:</p>
<pre><code class="language-tsx">// API → Hook → Component → ErrorBoundary 자동 전파
export const useDashboardStats = () =&gt;
  useSuspenseQuery({ queryFn: getDashboardStats });

&lt;StatsErrorBoundary&gt;
  &lt;Suspense fallback={&lt;Loading /&gt;}&gt;
    &lt;StatsSection /&gt;
  &lt;/Suspense&gt;
&lt;/StatsErrorBoundary&gt;;
</code></pre>
<h2>2. 계층 구조 설계하기</h2>
<p>커스텀 에러 클래스를 만들기로 했다. 그럼 어떻게 설계해야 할까?</p>
<h3>2.1. Base 클래스: 왜 ApiError인가?</h3>
<p>먼저 base 클래스가 필요하다.</p>
<pre><code class="language-typescript">export class ApiError extends Error {
  code?: string;
  status?: number;

  constructor(
    message: string,
    opts?: { code?: string; status?: number; cause?: unknown },
  ) {
    super(message, { cause: opts?.cause });
    this.name = new.target.name; // 정확한 클래스명
    this.code = opts?.code;
    this.status = opts?.status;
    if (Error.captureStackTrace) Error.captureStackTrace(this, new.target);
  }
}
</code></pre>
<p><strong>왜 <code>Error</code>를 직접 상속하지 않고 <code>ApiError</code>를 만들었을까?</strong></p>
<p>JavaScript의 기본 <code>Error</code> 클래스는 <code>message</code>만 있다.
하지만 API 에러는 더 많은 정보가 필요하다:</p>
<ul>
<li><strong>statusCode</strong>: HTTP 상태 코드 (500, 404, 401 등)</li>
<li><strong>code</strong>: 비즈니스 에러 코드 (&#x27;STATS_ERROR&#x27;, &#x27;AUTH_ERROR&#x27; 등)</li>
</ul>
<p>이 정보들이 있어야:</p>
<ul>
<li>에러별로 다른 처리를 할 수 있고</li>
<li>디버깅할 때 원인을 빠르게 파악할 수 있다</li>
</ul>
<h3>2.2 상속 구조와 ErrorBoundary 전략</h3>
<p>이제 핵심이다. 앞서 그렸던 계층을 <strong>책임 관점</strong>으로 정리하면 다음과 같다.</p>





























<table><thead><tr><th>계층</th><th>예시 타입</th><th>담당 Boundary</th><th>주 역할</th></tr></thead><tbody><tr><td>최상위</td><td><code>Error</code></td><td>최상위 <code>ErrorBoundary</code></td><td>예상 밖 에러 안전망</td></tr><tr><td>공통 API</td><td><code>ApiError</code></td><td><code>GlobalErrorBoundary</code> (필요시 사용)</td><td>ApiError 공통 처리</td></tr><tr><td>도메인</td><td><code>StatsError</code>, <code>ChartError</code>, <code>ActivityError</code></td><td>각 섹션 Boundary</td><td>섹션별 복구 UI, UX 유지</td></tr></tbody></table>
<p><strong>이 구조 덕분에 ErrorBoundary를 설계할 수 있다:</strong></p>
<h4>패턴 1: 전역 ErrorBoundary (모든 API 에러 캐치)</h4>
<pre><code class="language-typescript">if (error instanceof ApiError) {
  // 모든 API 에러를 여기서 처리
  // 공통 로깅, 공통 fallback UI
}
</code></pre>
<h4>패턴 2: 섹션별 ErrorBoundary (특정 에러만 캐치)</h4>
<pre><code class="language-typescript">// StatsErrorBoundary
if (error instanceof StatsError) {
  // 통계 섹션만 fallback
  // 다른 섹션은 정상 동작
} else {
  throw error; // 다른 에러는 상위로 전파
}
</code></pre>
<h4>패턴 3: 계층적 ErrorBoundary</h4>
<pre><code>AppErrorBoundary (Error 캐치)
  └─ GlobalErrorBoundary (ApiError 캐치, 선택)
        └─ StatsErrorBoundary (StatsError만 캐치)
             └─ Component
</code></pre>
<p><strong>핵심</strong>: 상속 구조 = ErrorBoundary 전략. <code>instanceof</code>로 에러 레벨 구분, 특정 에러만 잡고 나머지는 상위로 전파. 통계 섹션이 터져도 다른 섹션은 정상 동작한다.</p>
<h2>3. 도메인별 에러 클래스 구현</h2>
<p>설계는 끝났다. 이제 구현해보자.</p>
<h3>3.1. 간단한 구현</h3>
<p><code>ApiError</code>를 상속받기만 하면 된다.</p>
<pre><code class="language-typescript">// 통계 데이터 에러
export class StatsError extends ApiError {
  constructor(message: string, code?: string) {
    super(message, { code, status: 500 });
    this.name = &#x27;StatsError&#x27;;
  }
}

// 차트 데이터 에러
export class ChartError extends ApiError {
  constructor(message: string, code?: string) {
    super(message, { code, status: 500 });
    this.name = &#x27;ChartError&#x27;;
  }
}

// 활동 데이터 에러
export class ActivityError extends ApiError {
  constructor(message: string, code?: string) {
    super(message, { code, status: 500 });
    this.name = &#x27;ActivityError&#x27;;
  }
}
</code></pre>
<p><strong>포인트</strong>:</p>
<ul>
<li><code>ApiError</code> 상속으로 계층 구조 구축</li>
<li><code>super(message, { code, status })</code> 형태로 옵션 객체 전달</li>
<li><code>name</code> 속성으로 에러 타입 구분</li>
</ul>
<h3>3.2. 실제 사용</h3>
<p>앞서 본 <code>getDashboardStats</code> 예제에서 <strong>catch 블록</strong>이 담당하는 역할만 발췌하면 다음과 같다.</p>
<pre><code class="language-typescript">catch (error: unknown) {
  if (isHttpError(error)) { // ⭐ HTTP 에러만 StatsError로 변환
    throw new StatsError(
      error.response?.data?.message ||
        &#x27;통계 데이터를 불러오는데 실패했습니다&#x27;,
      error.response?.data?.error,
    );
  }
  throw error; // 예상치 못한 에러는 그대로 던짐
}
</code></pre>
<p><strong>흐름</strong>: HTTP 에러 발생 → <code>isHttpError</code> 타입 체크 → 커스텀 에러로 변환 → throw</p>
<p>이제 ErrorBoundary에서 <code>instanceof StatsError</code>로 정확히 구분 가능</p>
<h3>전체 코드</h3>
<p>전체 구현은 GitHub에서 확인할 수 있다:</p>
<ul>
<li><a href="https://github.com/Han5991/fe-lab/blob/c1c3e26/apps/react/src/shared/lib/errors/index.ts">에러 클래스 정의</a></li>
<li><a href="https://github.com/Han5991/fe-lab/blob/c1c3e26/apps/react/src/api/dashboard.ts">API 레이어 적용</a></li>
<li><a href="https://github.com/Han5991/fe-lab/blob/c1c3e26/packages/@package/core/src/errors/index.ts">Core 패키지 ApiError</a></li>
</ul>
<h2>4. ErrorBoundary와 연결하기</h2>
<p>에러 클래스를 만들었다. 이제 ErrorBoundary와 연결해보자.</p>
<h3>4.1. SectionErrorBoundary: 선택적 에러 캐치</h3>
<p>핵심 아이디어는 <code>특정 에러만 캐치하고, 나머지는 상위로 전파</code> 하는 것이다.</p>
<pre><code class="language-tsx">type ErrorCtor&lt;T extends Error = Error&gt; = new (...args: any[]) =&gt; T;

interface Props&lt;T extends Error = Error&gt; {
  children: ReactNode;
  sectionName: string;
  errorType: ErrorCtor&lt;T&gt;; // 제네릭 생성자 시그니처
}

export class SectionErrorBoundary&lt;T extends Error = Error&gt; extends Component&lt;
  Props&lt;T&gt;,
  State
&gt; {
  state: State = { error: null };

  static getDerivedStateFromError(error: Error): Partial&lt;State&gt; {
    return { error }; // 에러 저장만
  }

  componentDidCatch(error: Error, _info: ErrorInfo) {
    console.error(`[${this.props.sectionName}]`, error, _info); // 로깅만
  }

  private isHandled(error: Error | null): error is T {
    if (!error) return false;
    return error instanceof this.props.errorType;
  }

  render() {
    const { error } = this.state;

    if (error) {
      if (this.isHandled(error)) {
        return &lt;ErrorFallbackUI /&gt;; // 담당 에러: 처리
      }
      // 👇 반드시 render에서 재-throw 해야 상위 Boundary로 전파
      throw error; // 담당 아님: 상위로 전파
    }

    return this.props.children;
  }
}
</code></pre>
<p><strong>핵심 로직</strong></p>
<pre><code class="language-typescript">if (error) {
  if (this.isHandled(error)) {
    return &lt;ErrorFallbackUI /&gt;; // 담당 에러만 처리
  }
  throw error; // 다른 에러는 상위로!
}
</code></pre>
<ul>
<li><code>StatsErrorBoundary</code>는 <code>StatsError</code>만 캐치</li>
<li><code>ChartError</code>가 발생하면? → throw로 상위로 전파</li>
<li>담당하지 않는 에러는 관여하지 않는다</li>
<li><strong>중요</strong>: <code>render</code>에서 throw해야 상위로 전파됨 (<code>componentDidCatch</code>에서는 안됨)</li>
</ul>
<h3>4.2. 에러 전파 전략: 상속 구조 따라가기</h3>
<blockquote>
<p><strong>⚠️ 중요: 에러 전파는 render()에서만 일어난다</strong></p>
<p><code>componentDidCatch</code>에서 <code>throw error</code>를 해도 부모 ErrorBoundary로 전파되지 않습니다.
에러를 상위로 전파하려면 <strong>반드시 <code>render()</code> 메서드에서 <code>throw</code></strong> 해야 합니다.</p>
</blockquote>
<p><strong>왜 다시 throw 하는가?</strong></p>
<p>에러 상속 구조를 따라가기 위해서다</p>
<pre><code>GlobalErrorBoundary (ApiError 캐치)
  └─ StatsErrorBoundary (StatsError만 캐치)
       └─ Component
</code></pre>
<p><strong>시나리오 1: StatsError 발생</strong></p>
<pre><code class="language-typescript">// Component에서 StatsError 발생
throw new StatsError(&#x27;통계 실패&#x27;);

// StatsErrorBoundary에서
if (!(error instanceof StatsError)) {
  // false
  throw error;
}
// 👉 여기서 캐치! fallback UI 표시
</code></pre>
<p><strong>시나리오 2: ChartError 발생</strong></p>
<pre><code class="language-typescript">// Component에서 ChartError 발생
throw new ChartError(&#x27;차트 실패&#x27;);

// StatsErrorBoundary에서
if (!(error instanceof StatsError)) {
  // true!
  throw error; // 👈 상위로 전파
}

// GlobalErrorBoundary에서
if (error instanceof ApiError) {
  // true (ChartError는 ApiError 상속)
  // 👉 여기서 캐치!
}
</code></pre>
<p><strong>시나리오 3: 일반 Error 발생 (테스트용)</strong></p>
<pre><code class="language-typescript">export const getActivities = async (): Promise&lt;Activity[]&gt; =&gt; {
  try {
    // 임시 에러 계층 테스트용 코드 - 이걸 풀면 더 상위 에러 바운더리로 전파됨
    throw new Error(&#x27;test&#x27;);

    const response = await instance.get(&#x27;/api/dashboard/activities&#x27;);
    return response.data;
  } catch (error: unknown) {
    if (isHttpError(error)) {
      throw new ActivityError(/*...*/);
    }
    throw error; // 👈 일반 Error는 그대로 throw
  }
};

// ActivityErrorBoundary에서
if (!(error instanceof ActivityError)) {
  // true! (일반 Error는 ActivityError가 아님)
  throw error; // 👈 상위로 전파
}

// GlobalErrorBoundary에서
if (error instanceof ApiError) {
  // false (일반 Error는 ApiError가 아님)
  throw error; // 👈 더 상위로 전파
}

// 최상위 ErrorBoundary에서
if (error instanceof Error) {
  // true
  // 👉 여기서 최종 캐치!
}
</code></pre>
<p>이렇게 <strong>예상치 못한 에러</strong>도 계층을 따라 올라가면서 적절한 곳에서 처리된다.</p>
<p><strong>핵심</strong>:</p>
<ul>
<li>담당 에러만 처리</li>
<li>나머지는 상속 구조를 따라 상위로</li>
<li>계층적 에러 처리 가능</li>
<li>예상치 못한 에러는 최상위 ErrorBoundary까지 전파</li>
</ul>
<h3>4.3. 실제 사용</h3>
<p>각 섹션별 ErrorBoundary wrapper를 만든다:</p>
<pre><code class="language-tsx">export const StatsErrorBoundary = ({ children }: { children: ReactNode }) =&gt; (
  &lt;SectionErrorBoundary sectionName=&quot;통계&quot; errorType={StatsError}&gt;
    {children}
  &lt;/SectionErrorBoundary&gt;
);

// ChartErrorBoundary, ActivityErrorBoundary도 동일한 패턴
</code></pre>
<p><strong>컴포넌트에서 사용:</strong></p>
<pre><code class="language-tsx">&lt;StatsErrorBoundary&gt;
  &lt;Suspense fallback={&lt;Loading /&gt;}&gt;
    &lt;StatsSection /&gt;
  &lt;/Suspense&gt;
&lt;/StatsErrorBoundary&gt;
</code></pre>
<p>각 섹션이 독립적으로 에러를 처리한다. 한 섹션이 터져도 전체 앱이 죽지 않는다.</p>
<h3>전체 코드</h3>
<p>전체 구현은 GitHub에서 확인할 수 있다:</p>
<ul>
<li><a href="https://github.com/Han5991/fe-lab/blob/c1c3e26/apps/react/src/components/SectionErrorBoundary/index.tsx">SectionErrorBoundary 구현</a></li>
<li><a href="https://github.com/Han5991/fe-lab/blob/c1c3e26/apps/react/src/pages/error-design/index.tsx">실제 사용 예시</a></li>
</ul>
<h2>5. 실전 활용</h2>
<p>이제 모든 조각을 조합해보자.
React Query + Suspense + ErrorBoundary로 완성된 에러 처리 시스템을 만들어본다.</p>
<h3>5.1. 계층별 역할 분리</h3>
<p>전체 흐름을 보자:</p>
<pre><code class="language-tsx">// 1. API 레이어: 앞서 정의한 getDashboardStats가 StatsError를 throw

// 2. Hook 레이어: React Query
export const useDashboardStats = () =&gt;
  useSuspenseQuery({
    queryKey: [&#x27;dashboardStats&#x27;],
    queryFn: getDashboardStats, // StatsError를 throw
  });

// 3. Component 레이어: 선언적 사용
const StatsSection = () =&gt; {
  const { data: stats } = useDashboardStats();
  return &lt;StatCard title=&quot;총 방문자&quot; value={stats.visitors.total} /&gt;;
};

// 4. Suspense + ErrorBoundary
&lt;StatsErrorBoundary&gt;
  &lt;Suspense fallback={&lt;Loading /&gt;}&gt;
    &lt;StatsSection /&gt;
  &lt;/Suspense&gt;
&lt;/StatsErrorBoundary&gt;;
</code></pre>
<p><strong>각 계층의 역할</strong>:</p>
<ul>
<li><strong>API</strong>: 에러 변환 (generic Error → StatsError)</li>
<li><strong>Hook</strong>: 데이터 페칭</li>
<li><strong>Component</strong>: 데이터 렌더링 (에러 처리 코드 없음!)</li>
<li><strong>Boundary</strong>: 에러/로딩 처리 (선언적)</li>
</ul>
<p>우리가 <code>StatsError</code>로 변환했기 때문에:</p>
<ol>
<li><code>getDashboardStats</code>에서 <code>StatsError</code> throw</li>
<li>React Query가 받아서 다시 throw</li>
<li>ErrorBoundary가 캐치</li>
<li><code>instanceof StatsError</code>로 정확히 구분! ✅</li>
</ol>
<h3>5.2. 독립적인 섹션 에러 처리</h3>
<p>대시보드 전체 구조:</p>
<pre><code class="language-tsx">const ErrorDesignPage = () =&gt; (
  &lt;Grid&gt;
    {/* 헤더 */}
    &lt;Header /&gt;

    {/* 통계 섹션 - 독립적 */}
    &lt;StatsErrorBoundary&gt;
      &lt;Suspense fallback={&lt;LoadingCard /&gt;}&gt;
        &lt;StatsSection /&gt;
      &lt;/Suspense&gt;
    &lt;/StatsErrorBoundary&gt;

    {/* 차트 섹션 - 독립적 */}
    &lt;ChartErrorBoundary&gt;
      &lt;Suspense fallback={&lt;LoadingChart /&gt;}&gt;
        &lt;ChartWidget /&gt;
      &lt;/Suspense&gt;
    &lt;/ChartErrorBoundary&gt;

    {/* 활동 섹션 - 독립적 */}
    &lt;ActivityErrorBoundary&gt;
      &lt;Suspense fallback={&lt;LoadingFeed /&gt;}&gt;
        &lt;ActivityFeed /&gt;
      &lt;/Suspense&gt;
    &lt;/ActivityErrorBoundary&gt;
  &lt;/Grid&gt;
);
</code></pre>
<p><strong>결과</strong>: 한 섹션이 실패해도 다른 섹션은 정상 동작. 완전히 독립적인 에러 처리.</p>
<h3>5.3. 이것이 가능한 이유</h3>
<p>앞서 정리한 책임 표 그대로 흐름이 흘러가기 때문이다.</p>
<ul>
<li><code>StatsError</code> · <code>ChartError</code> · <code>ActivityError</code>는 각 섹션 Boundary에서 복구 UI를 보여 준다.</li>
<li><code>ApiError</code>는 전역 Boundary가 공통 로깅과 가드 처리를 맡는다.</li>
<li>예상 밖 <code>Error</code>는 최상위 Boundary까지 전파되어 전체 보호막이 된다.</li>
</ul>
<p>설계 단계에서 경계가 명확해졌기에 런타임에서도 자연스럽게 분리된다.</p>
<h3>전체 코드</h3>
<p>전체 구현은 GitHub에서 확인할 수 있다.</p>
<ul>
<li><a href="https://github.com/Han5991/fe-lab/blob/c1c3e26/apps/react/src/pages/error-design/index.tsx">대시보드 페이지</a></li>
<li><a href="https://github.com/Han5991/fe-lab/blob/c1c3e26/apps/react/src/hooks/useDashboard.ts">React Query 훅</a></li>
<li><a href="https://github.com/Han5991/fe-lab/blob/c1c3e26/apps/react/src/pages/error-design/index.test.tsx">테스트 코드</a></li>
</ul>
<h2>6. 결론</h2>
<h3>계층 구조의 핵심 가치</h3>
<p>에러 클래스 계층 구조가 가져다준 것들</p>
<p><strong>1. 타입 안전성과 ErrorBoundary 전략</strong></p>
<ul>
<li><code>instanceof</code>로 런타임 타입 체크</li>
<li>에러 상속 구조 = ErrorBoundary 계층 구조</li>
<li>선택적 캐치와 전파로 유연한 에러 격리</li>
</ul>
<p><strong>2. 독립적 에러 처리와 디버깅</strong></p>
<ul>
<li>섹션별 독립적 처리: 통계 섹션이 터져도 차트는 정상</li>
<li>로그만 봐도 어느 도메인 에러인지 즉시 파악</li>
<li>사용자 경험과 개발자 경험 모두 향상</li>
</ul>
<h3>확장 가능한 설계</h3>
<p>새로운 섹션 추가는 간단하다.</p>
<pre><code class="language-tsx">// 1. 에러 클래스 추가
export class NotificationError extends ApiError {
  constructor(message: string, code?: string) {
    super(message, { code, status: 500 });
    this.name = &#x27;NotificationError&#x27;;
  }
}

// 2. ErrorBoundary 추가
export const NotificationErrorBoundary = ({ children }) =&gt; (
  &lt;SectionErrorBoundary sectionName=&quot;알림&quot; errorType={NotificationError}&gt;
    {children}
  &lt;/SectionErrorBoundary&gt;
);

// 3. 사용
&lt;NotificationErrorBoundary&gt;
  &lt;Suspense fallback={&lt;Loading /&gt;}&gt;
    &lt;NotificationSection /&gt;
  &lt;/Suspense&gt;
&lt;/NotificationErrorBoundary&gt;;
</code></pre>
<p>일관된 패턴으로 확장 가능하다.</p>
<hr/>
<h3>정리하면</h3>
<p>프롤로그의 질문으로 돌아가보자. &quot;에러는 계층을 따라 전파된다. 그렇다면 에러 클래스도 계층 구조로 설계할 수 있지 않을까?&quot;</p>
<p>그 답은 명확하다. 에러의 구조화가 곧 처리 전략이다.</p>
<ul>
<li>Error → 최상위 ErrorBoundary</li>
<li>ApiError → 전역 GlobalErrorBoundary</li>
<li>StatsError → 섹션별 StatsErrorBoundary</li>
</ul>
<p>각 에러는 자신이 어디까지 올라가야 하는지 알고, 각 Boundary는 자신의 책임을 안다. HTTP 에러는 도메인 에러로 변환되고, 도메인 에러는 섹션 Boundary가 받아 처리한다. 예외가 예상 범위를 벗어나면 더 높은 Boundary로 전파된다.</p>
<p>이 구조 덕분에 “이 에러는 누가 처리해야 하지?”라는 고민은 사라진다. 타입이 곧 처리 위치이기 때문이다. 한 섹션이 실패해도, 다른 섹션은 영향 없이
정상적으로 동작한다.</p>]]></content:encoded>
    </item>
    <item>
      <title>구글 개발자가 인정한 성능 최적화 기여 후기 - AI와 함께한 gemini-cli 오픈소스 여정</title>
      <link>https://blog.sangwook.dev/posts/ai-opensource-contribution/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/ai-opensource-contribution/</guid>
      <pubDate>Sat, 06 Sep 2025 15:00:00 GMT</pubDate>
      <description>출시 직후의 gemini-cli 코드베이스를 AI와 함께 뜯어 동기 파일 처리를 병렬로 바꿨고, 408ms를 107ms로 줄였습니다. 기여할 지점을 찾는 프롬프트부터 구글 개발자에게 리뷰받으며 PR을 머지하기까지의 전략.</description>
      <content:encoded><![CDATA[<link rel="preload" as="image" href="https://blog.sangwook.dev/posts/open-source/gemini-cli.img.png"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/open-source/gemini-cli.img_1.png"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/open-source/gemini-cli.img_2.png"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/open-source/gemini-cli.img_3.png"/><h2>이 글을 읽고 나면</h2>
<ul>
<li>AI와 협업해서 오픈소스 기여하는 방법을 알게 됩니다</li>
<li>성능 최적화 PR이 더 좋은 평가를 받는 이유를 이해합니다</li>
<li>구글같은 대기업 프로젝트에 기여하는 전략을 배웁니다</li>
<li>실제 머지된 PR 사례를 통해 구체적인 노하우를 얻습니다</li>
</ul>
<h2>들어가며</h2>
<blockquote>
<p>&quot;<strong>내 코드를 구글의 프로젝트에 넣을 수 있다니!</strong>&quot;</p>
<p>2025년 6월 25일 gemini-cli가 출시되었습니다. CLI 환경의 AI 도구들이 쏟아져 나오는 요즘, 이 프로젝트가 특별했던 이유는:</p>
<ul>
<li><strong>JavaScript 기반</strong>: 익숙한 기술 스택</li>
<li><strong>화제성</strong>: 출시 직후부터 관심 집중</li>
<li><strong>작은 코드베이스</strong>: 아직 분석 가능한 규모</li>
<li><strong>개선 여지</strong>: 급하게 만들어진 티가 여기저기 보임</li>
<li><strong>적극적인 오픈소스</strong>: 전세계 개발자들의 참여를 적극 유도</li>
</ul>
<p>무엇보다 <strong>구글러들에게 직접 코드리뷰를 받을 수 있다</strong>는 점이 가장 매력적이었습니다.</p>
</blockquote>
<h2>기여할 것을 찾아보자</h2>
<p>설레는 마음으로 코드베이스를 뜯어보기 시작했습니다.<br/>
ai와 함께 코드베이스를 분석하고 기여할 점을 부탁 하였습니다.</p>
<p>ex) 사용했던 프롬프트<br/>
<strong>User</strong></p>
<blockquote>
<p>여기는 gemini-cli 프로젝트예요. 함께 개선할 점을 찾아봐요!</p>
</blockquote>
<hr/>
<p><strong>AI Assistant</strong></p>
<blockquote>
<p>read-many-files.ts에서 파일 처리가 동기적으로 이뤄지고 있어요. 개선 방향은 아래와 같아요:</p>
<ul>
<li>for...of 순회를 Promise 목록 기반 병렬 처리로 전환</li>
<li>detectFileType 함수를 비동기로 변경</li>
</ul>
</blockquote>
<hr/>
<p><strong>User</strong></p>
<blockquote>
<p>개선할 코드 제안해주고 이게 어떤 이점을 가져오는지 설명해줘</p>
</blockquote>
<hr/>
<p><strong>AI Assistant</strong></p>
<blockquote>
<p>(변경 코드 보여주고 설명 하는 중)</p>
</blockquote>
<p>이 내용을 보았을 <code>성능관련 기여</code>를 할 수 있다고 생각을 했고 메인테이너의 입장에서는<br/>
새로운 기능을 추가하는 PR은 메인테이너 입장에서 고려할 게 많습니다.<br/>
프로젝트의 방향성과 맞는지, 다른 기능과 충돌은 없는지, 앞으로 유지보수 비용은 어떨지 등등... 하지만 성능 개선 PR은 <code>빚을 갚아주는</code> 것과 같습니다.<br/>
누구도 마다할 이유가 없죠. 프로젝트를 더 건강하게 만드는, 가장 환영받는 기여 중 하나입니다.<br/>
하지만 너무 바꿀 범위가 많다고 생각을 했습니다. 그래서 pr을 2개 쪼개어 날리기로 했습니다.<br/>
두 번이나 pr을 날릴수도 있고 계획적으로 나만의 로드맵을 그려갈 수 있다고 생각을 했습니다.</p>
<ol>
<li>fileUtils.ts -&gt; 개선</li>
<li>read-many-files.ts -&gt; 병렬처리로 전환</li>
</ol>
<h2>기여 시작</h2>
<h3>1차 기여: fileUtils.ts 비동기 전환</h3>
<p>저 파일안에 있는 <code>detectFileType</code> 만 비동기로 만들어도 되었지만
ai는 나에게 좀 더 몇 가지 개선점을 알려주었습니다.</p>
<pre><code class="language-javascript">// AS-IS
export function isBinaryFile(filePath: string): boolean {
  try {
    const fd = fs.openSync(filePath, &#x27;r&#x27;);
    // Read up to 4KB or file size, whichever is smaller
    const fileSize = fs.fstatSync(fd).size;
    if (fileSize === 0) {
      // Empty file is not considered binary for content checking
      fs.closeSync(fd);
      return false;
    }
    const bufferSize = Math.min(4096, fileSize);
    const buffer = Buffer.alloc(bufferSize);
    const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
    fs.closeSync(fd);

    if (bytesRead === 0) return false;

    let nonPrintableCount = 0;
    for (let i = 0; i &lt; bytesRead; i++) {
      if (buffer[i] === 0) return true; // Null byte is a strong indicator
      if (buffer[i] &lt; 9 || (buffer[i] &gt; 13 &amp;&amp; buffer[i] &lt; 32)) {
        nonPrintableCount++;
      }
    }
    // If &gt;30% non-printable characters, consider it binary
    return nonPrintableCount / bytesRead &gt; 0.3;
  } catch {
    // If any error occurs (e.g. file not found, permissions),
    // treat as not binary here; let higher-level functions handle existence/access errors.
    return false;
  }
}

// TO-BE
export async function isBinaryFile(filePath: string): Promise&lt;boolean&gt; {
  let fileHandle: fs.promises.FileHandle | undefined;
  try {
    fileHandle = await fs.promises.open(filePath, &#x27;r&#x27;);

    // Read up to 4KB or file size, whichever is smaller
    const stats = await fileHandle.stat();
    const fileSize = stats.size;
    if (fileSize === 0) {
      // Empty file is not considered binary for content checking
      return false;
    }
    const bufferSize = Math.min(4096, fileSize);
    const buffer = Buffer.alloc(bufferSize);
    const result = await fileHandle.read(buffer, 0, buffer.length, 0);
    const bytesRead = result.bytesRead;

    if (bytesRead === 0) return false;

    let nonPrintableCount = 0;
    for (let i = 0; i &lt; bytesRead; i++) {
      if (buffer[i] === 0) return true; // Null byte is a strong indicator
      if (buffer[i] &lt; 9 || (buffer[i] &gt; 13 &amp;&amp; buffer[i] &lt; 32)) {
        nonPrintableCount++;
      }
    }
    // If &gt;30% non-printable characters, consider it binary
    return nonPrintableCount / bytesRead &gt; 0.3;
  } catch (error) {
    // Log error for debugging while maintaining existing behavior
    console.warn(
      `Failed to check if file is binary: ${filePath}`,
      error instanceof Error ? error.message : String(error),
    );
    // If any error occurs (e.g. file not found, permissions),
    // treat as not binary here; let higher-level functions handle existence/access errors.
    return false;
  } finally {
    // Safely close the file handle if it was successfully opened
    if (fileHandle) {
      try {
        await fileHandle.close();
      } catch (closeError) {
        // Log close errors for debugging while continuing with cleanup
        console.warn(
          `Failed to close file handle for: ${filePath}`,
          closeError instanceof Error ? closeError.message : String(closeError),
        );
        // The important thing is that we attempted to clean up
      }
    }
  }
}
</code></pre>
<blockquote>
<p>fs.promises 를 붙인 것만으로도 아래와 개선 효과를 볼 수 있다는 걸 ai와 함께 정리 했습니다.</p>
</blockquote>
<h3>주요 차이점</h3>
<h4>1. <strong>동기 vs 비동기 처리</strong></h4>
<ul>
<li><strong>AS-IS</strong>: 동기 함수 (<code>function</code>)</li>
<li><strong>TO-BE</strong>: 비동기 함수 (<code>async function</code>)</li>
</ul>
<h4>2. <strong>파일 시스템 API</strong></h4>
<ul>
<li><strong>AS-IS</strong>: 동기 메서드들
<ul>
<li><code>fs.openSync()</code>, <code>fs.fstatSync()</code>, <code>fs.readSync()</code>, <code>fs.closeSync()</code></li>
</ul>
</li>
<li><strong>TO-BE</strong>: 비동기 Promise 기반 메서드들
<ul>
<li><code>fs.promises.open()</code>, <code>fileHandle.stat()</code>, <code>fileHandle.read()</code>, <code>fileHandle.close()</code></li>
</ul>
</li>
</ul>
<h4>3. <strong>에러 처리</strong></h4>
<ul>
<li><strong>AS-IS</strong>: 간단한 <code>catch</code> 블록</li>
<li><strong>TO-BE</strong>: 상세한 에러 로깅 + <code>finally</code> 블록으로 리소스 정리</li>
</ul>
<h4>4. <strong>리소스 관리</strong></h4>
<ul>
<li><strong>AS-IS</strong>: 명시적 <code>fs.closeSync(fd)</code> 호출</li>
<li><strong>TO-BE</strong>: <code>finally</code> 블록에서 안전한 파일 핸들 정리</li>
</ul>
<h3>장단점 비교</h3>
<h4>AS-IS 장점</h4>
<p>✅ <strong>간단함</strong>: 코드가 직관적이고 이해하기 쉬움<br/>
✅ <strong>동기 처리</strong>: 순차적 실행으로 디버깅이 용이<br/>
✅ <strong>가벼움</strong>: 추가 Promise 오버헤드 없음</p>
<h4>AS-IS 단점</h4>
<p>❌ <strong>블로킹</strong>: 파일 읽기 중 다른 작업 불가<br/>
❌ <strong>에러 정보 부족</strong>: 디버깅 시 구체적 오류 정보 없음<br/>
❌ <strong>리소스 누수 위험</strong>: 예외 발생 시 파일 디스크립터 정리 불완전</p>
<h4>TO-BE 장점</h4>
<p>✅ <strong>논블로킹</strong>: 다른 작업과 병렬 처리 가능<br/>
✅ <strong>상세한 에러 처리</strong>: <code>console.warn</code>으로 디버깅 정보 제공<br/>
✅ <strong>안전한 리소스 관리</strong>: <code>finally</code> 블록으로 확실한 정리<br/>
✅ <strong>확장성</strong>: 비동기 생태계와 잘 맞음</p>
<h4>TO-BE 단점</h4>
<p>❌ <strong>복잡성</strong>: 코드가 길어지고 이해하기 어려움<br/>
❌ <strong>Promise 오버헤드</strong>: 작은 성능 비용 발생<br/>
❌ <strong>호출부 변경 필요</strong>: <code>await</code> 키워드 추가 필요</p>
<h3>권장사항</h3>
<p><strong>대량 파일 처리</strong>나 <strong>웹 서버 환경</strong>에서는 <strong>TO-BE</strong> 방식을, <strong>간단한 CLI 도구</strong>나 <strong>소규모 스크립트</strong>에서는 <strong>AS-IS</strong> 방식이 적합합니다.</p>
<h3>fs.promises API 설명</h3>
<h4>기본 개념</h4>
<p><strong><code>fs.promises</code></strong> 는 Node.js의 파일 시스템 모듈에서 <strong>Promise 기반의 비동기 API</strong>를 제공하는 네임스페이스입니다.</p>
<h4>주요 차이점</h4>
<h5><code>fs.openSync()</code> - 동기 방식</h5>
<pre><code class="language-javascript">const fd = fs.openSync(&#x27;/path/file&#x27;, &#x27;r&#x27;);
// ✅ 즉시 파일 디스크립터 반환
// ❌ 파일이 열릴 때까지 코드 실행 중단 (블로킹)
// ❌ 이벤트 루프 차단
</code></pre>
<h5><code>fs.promises.open()</code> - Promise 방식</h5>
<pre><code class="language-javascript">const fileHandle = await fs.promises.open(&#x27;/path/file&#x27;, &#x27;r&#x27;);
// ✅ Promise 반환으로 논블로킹
// ✅ 다른 작업과 병렬 처리 가능
// ✅ FileHandle 객체 반환 (더 안전한 API)
</code></pre>
<h4>fs.promises API의 핵심 특징</h4>
<h5>1. <strong>FileHandle 객체</strong></h5>
<pre><code class="language-javascript">// 기존: 단순 숫자 파일 디스크립터
const fd = fs.openSync(&#x27;file.txt&#x27;, &#x27;r&#x27;); // 3 (숫자)

// fs.promises: FileHandle 객체
const fileHandle = await fs.promises.open(&#x27;file.txt&#x27;, &#x27;r&#x27;);
// { fd: 3, read: Function, write: Function, close: Function, ... }
</code></pre>
<h5>2. <strong>자동 리소스 관리</strong></h5>
<pre><code class="language-javascript">// 위험한 패턴 (동기)
const fd = fs.openSync(&#x27;file.txt&#x27;, &#x27;r&#x27;);
// 에러 발생 시 close 안됨
const data = fs.readSync(fd, buffer, 0, buffer.length, 0);
fs.closeSync(fd);

// 안전한 패턴 (Promise + try/finally)
let fileHandle;
try {
  fileHandle = await fs.promises.open(&#x27;file.txt&#x27;, &#x27;r&#x27;);
  const data = await fileHandle.read(buffer, 0, buffer.length, 0);
} finally {
  await fileHandle?.close(); // 항상 정리됨
}
</code></pre>
<h5>3. <strong>병렬 처리 지원</strong></h5>
<pre><code class="language-javascript">// 동기: 순차 처리만 가능
const file1 = fs.readFileSync(&#x27;file1.txt&#x27;);
const file2 = fs.readFileSync(&#x27;file2.txt&#x27;);
const file3 = fs.readFileSync(&#x27;file3.txt&#x27;);

// Promise: 병렬 처리 가능
const [file1, file2, file3] = await Promise.all([
  fs.promises.readFile(&#x27;file1.txt&#x27;),
  fs.promises.readFile(&#x27;file2.txt&#x27;),
  fs.promises.readFile(&#x27;file3.txt&#x27;),
]);
</code></pre>
<h4>언제 사용해야 할까?</h4>
<p><strong><code>fs.promises</code> 사용 권장</strong></p>
<ul>
<li>웹 서버, API 서버</li>
<li>대량 파일 처리</li>
<li>사용자 상호작용이 있는 앱</li>
<li>다른 비동기 작업과 함께 사용</li>
</ul>
<p><strong><code>fs.openSync</code> 사용 권장</strong></p>
<ul>
<li>빌드 스크립트, CLI 도구</li>
<li>간단한 일회성 작업</li>
<li>성능보다 단순함이 중요한 경우</li>
</ul>
<blockquote>
<p>이런 내용을 기반으로 공부도 되었고 다음 작업을 위해서 1차 <a href="https://github.com/google-gemini/gemini-cli/issues/3286">이슈</a>와 <a href="https://github.com/google-gemini/gemini-cli/pull/3288">PR</a>을 작성했습니다.</p>
</blockquote>
<h3>실제 이슈 &amp; PR 작성법 공개</h3>
<p><strong>구글러들을 설득한 실제 문서</strong>를 보여드리겠습니다:</p>
<h4><a href="https://github.com/google-gemini/gemini-cli/issues/3286">이슈 #3286</a></h4>
<pre><code class="language-markdown"># Convert synchronous file binary detection to async to eliminate event loop blocking

## 🚨 Problem

Current file binary detection uses synchronous file operations that:

- Block Node.js event loop causing UI freezing
- Prevent concurrent file processing
- Create poor scalability for large projects

## 📊 Expected Benefits

- 20-80% performance improvement depending on project size
- Improved UI responsiveness
- Enables concurrent file processing foundation

## 🎯 Technical Solution

- Convert `isBinaryFile()` and `detectFileType()` to async
- Use `fs.promises` API for non-blocking operations
- Update all callers to use `await`
</code></pre>
<h4><a href="https://github.com/google-gemini/gemini-cli/pull/3288">PR #3288</a></h4>
<pre><code class="language-markdown"># feat: Make file type detection and binary checks asynchronous (#3286)

## 🔧 Changes Made

- Converted sync file operations to async implementations
- Used `fs.promises` for non-blocking file I/O
- Enhanced resource management with proper cleanup
- Updated test cases for async compatibility

## 💡 Why This Matters

&quot;The original sync file operations were blocking the Node.js event loop,
causing UI freezes and poor performance when processing multiple files.&quot;

## 🎯 Next Steps

This lays the foundation for parallel file processing (coming in next PR)
</code></pre>
<p><strong>리뷰어 반응:</strong></p>
<ul>
<li><strong>Gemini Code Assist</strong>: <em>&quot;clean and thorough implementation&quot;</em></li>
<li><strong>NTaylorMullen</strong>: <em>&quot;Thanked for the contribution&quot;</em> ✅ <strong>승인</strong></li>
</ul>
<p><img src="https://blog.sangwook.dev/posts/open-source/gemini-cli.img.png" alt="img.png"/></p>
<p>첫 PR이 성공적으로 머지되었을 때의 기쁨은 정말 컸습니다.<br/>
이슈 등록부터 시작해 제 코드로 직접 성능 개선에 기여했다는 성취감, 그리고 다음 기여를 위한 발판까지 마련했다는 생각에 뿌듯했습니다.</p>
<h3>2차 기여: 병렬 처리로 성능 개선</h3>
<p>1차 PR 머지 후, 바로 2차 <a href="https://github.com/google-gemini/gemini-cli/issues/4712">이슈</a>와 <a href="https://github.com/google-gemini/gemini-cli/pull/4763">PR</a>을 진행했습니다.</p>
<h3>📈 2차 작업: 74% 성능 개선</h3>
<h4>🎯 <a href="https://github.com/google-gemini/gemini-cli/issues/4712">이슈 #4712</a></h4>
<pre><code class="language-markdown"># Implement batch/parallel file processing for performance optimization

## 🚀 Goal

Improve file processing performance for large projects by implementing parallel processing

## 📊 Expected Impact

- 20-80% performance improvement
- Better UX for large projects
- Leverage async foundation from previous PR #3288
</code></pre>
<h4>💥 <a href="https://github.com/google-gemini/gemini-cli/pull/4763">PR #4763</a></h4>
<pre><code class="language-markdown"># perf(core): implement parallel file processing for 74% performance improvement

## 🔥 Performance Results

- **Before**: 408ms (sequential processing)
- **After**: 107ms (parallel processing)
- **Improvement**: 74% faster! 🚀

## 🛠️ Technical Implementation

- Replaced sequential for-loop with Promise.allSettled()
- Maintained error isolation for individual files
- Added comprehensive performance test coverage
</code></pre>
<p><strong>리뷰어들의 극찬:</strong></p>
<ul>
<li><strong>jacob314</strong>: <em>&quot;Praised the performance optimization and test coverage&quot;</em> ✅ <strong>승인</strong></li>
<li><strong>SandyTao520</strong>: <strong>머지 완료</strong></li>
</ul>
<p>이과정에서 약간의 코드리뷰가 있었는대
<img src="https://blog.sangwook.dev/posts/open-source/gemini-cli.img_1.png" alt="img_1.png"/></p>
<p>리뷰어는 ! non-null assertion이 잠재적 버그를 가릴 수 있다고 지적했습니다.<br/>
파일 처리 중 실패하는 엣지 케이스에서 에러를 던지는 대신 undefined를 반환하며 조용히 넘어가버릴 수 있기 때문입니다.<br/>
그의 지적에 따라, 성공과 실패 케이스를 명확히 구분하는 Result 타입을 도입하여 코드의 안정성을 한층 높일 수 있었습니다.<br/>
작은 기호 하나에도 깊은 뜻이 있다는 것을 배운 순간이었습니다.<br/>
그래서 이렇게 타입을 만들어 성공과 실패 케이스를 나누어서 처리를 하였습니다.</p>
<pre><code class="language-typescript">/**
 * Result type for file processing operations
 */
type FileProcessingResult =
  | {
      success: true;
      filePath: string;
      relativePathForDisplay: string;
      fileReadResult: NonNullable&lt;
        Awaited&lt;ReturnType&lt;typeof processSingleFileContent&gt;&gt;
      &gt;;
      reason?: undefined;
    }
  | {
      success: false;
      filePath: string;
      relativePathForDisplay: string;
      fileReadResult?: undefined;
      reason: string;
    };
</code></pre>
<p>그리고 성능이 중요하다고 생각 하여 병렬 처리에대한 속도 처리 테스트를 만들어 넣었습니다.</p>
<pre><code class="language-typescript">it(&#x27;should process files in parallel for performance&#x27;, async () =&gt; {
  // Mock detectFileType to add artificial delay to simulate I/O
  const detectFileTypeSpy = vi.spyOn(
    await import(&#x27;../utils/fileUtils.js&#x27;),
    &#x27;detectFileType&#x27;,
  );

  // Create files
  const fileCount = 4;
  const files = createMultipleFiles(fileCount, &#x27;Batch test&#x27;);

  // Mock with 100ms delay per file to simulate I/O operations
  detectFileTypeSpy.mockImplementation(async (_filePath: string) =&gt; {
    await new Promise(resolve =&gt; setTimeout(resolve, 100));
    return &#x27;text&#x27;;
  });

  const startTime = Date.now();
  const params = { paths: files };
  const result = await tool.execute(params, new AbortController().signal);
  const endTime = Date.now();

  const processingTime = endTime - startTime;

  console.log(`Processing time: ${processingTime}ms for ${fileCount} files`);

  // Verify parallel processing performance improvement
  // Parallel processing should complete in ~100ms (single file time)
  // Sequential would take ~400ms (4 files × 100ms each)
  expect(processingTime).toBeLessThan(200); // Should PASS with parallel implementation

  // Verify all files were processed
  const content = result.llmContent as string[];
  expect(content).toHaveLength(fileCount);

  // Cleanup mock
  detectFileTypeSpy.mockRestore();
});
</code></pre>
<h3>성능 테스트가 게임 체인저였다!</h3>
<p>구글러들이 특히 좋아한 부분은 <strong>구체적인 성능 측정 테스트</strong>였습니다.</p>
<pre><code class="language-typescript">// 실제 성능 개선을 증명하는 테스트
it(&#x27;should process files in parallel for performance&#x27;, async () =&gt; {
  // 4개 파일 처리 시간 측정
  const startTime = Date.now();
  const result = await tool.execute(params, signal);
  const endTime = Date.now();

  const processingTime = endTime - startTime;

  // 🎯 병렬 처리 효과 검증: 400ms → 200ms 이하
  expect(processingTime).toBeLessThan(200); // ✅ PASS!
});
</code></pre>
<p><strong>jacob314의 극찬:</strong>
<em>&quot;Praised the performance optimization and test coverage&quot;</em></p>
<p>** 결과:**</p>
<ul>
<li><strong>74% 성능 향상</strong>: 408ms → 107ms</li>
<li><strong>구체적 증거</strong>: 테스트 코드로 성능 개선 입증</li>
<li><strong>빠른 승인</strong>: 성능 데이터 덕분에 논의 없이 바로 머지</li>
</ul>
<p><img src="https://blog.sangwook.dev/posts/open-source/gemini-cli.img_2.png" alt="img_2.png"/></p>
<p><strong>이번 PR은 이전 작업과의 연관성을 명시해서 승인이 매우 빠르게 났습니다.</strong></p>
<p><img src="https://blog.sangwook.dev/posts/open-source/gemini-cli.img_3.png" alt="img_3.png"/></p>
<h2>여러분도 구글 프로젝트에 기여해보세요!</h2>
<p>이번 경험을 통해 확실히 알게 된 것들:</p>
<ul>
<li><strong>AI와 함께하면 코드베이스 분석이 훨씬 수월함</strong></li>
<li><strong>작은 PR부터 시작하면 리뷰어의 신뢰를 얻을 수 있음</strong></li>
<li><strong>성능 테스트까지 포함하면 더 좋은 평가를 받음</strong></li>
<li><strong>이전 작업과의 연관성을 명시하면 승인이 빨라짐</strong></li>
</ul>
<h3>다음 액션 플랜</h3>
<p>이 성공적인 기여 경험을 바탕으로, <strong>더 큰 임팩트를 만들어갈 계획</strong>입니다.</p>
<p><strong>단기 목표</strong></p>
<ul>
<li>gemini-cli의 TODO 이슈들을 지속적으로 해결하며 <strong>핵심 컨트리뷰터</strong> 지위 확보</li>
<li>성능 최적화, 에러 핸들링, 사용자 경험 개선 등 <strong>고부가가치 기여</strong> 집중</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>feconf 2025 발표 후기</title>
      <link>https://blog.sangwook.dev/posts/feconf-2025-lightning-speaker/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/feconf-2025-lightning-speaker/</guid>
      <pubDate>Mon, 01 Sep 2025 15:00:00 GMT</pubDate>
      <description>마이크를 잡는 순간까지 &apos;내가 말해도 되나&apos; 싶었습니다. FEConf 2025 라이트닝 토크 신청부터 선정, 슬라이드 초안과 세 번의 리허설, 그리고 무대 위까지 — 발표가 처음인 개발자가 남긴 3개월치 준비 기록입니다.</description>
      <content:encoded><![CDATA[<link rel="preload" as="image" href="https://blog.sangwook.dev/posts/feconf/img/start.png"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/feconf/img/result.png"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-23-39-53.png"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-19-43-27%20029.jpeg"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-19-43-17%20009.png"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-19-43-15%20001.jpeg"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-19-43-17%20008.jpeg"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-19-43-23%20021.jpeg"/><h2>시작</h2>
<p>마이크를 잡는 순간 정말 내가 말해도 되는건가? 나 해도되는건가?</p>
<p>이 생각이 든 건 몇 달 전부터였다. 어느 모임에서 feconf 2024 라이트닝 연사자 분을 만나게 되었다. 재미있게 이야기 하던 도중 나에게 날아든 질문이 있었다.</p>
<p><strong>&quot;상욱님도 이거 잘 하실 수 있을 것 같아요! 너무 좋은 경험이었어서 꼭 해보셨으면 좋겠어요!&quot;</strong></p>
<h2>신청</h2>
<p><img src="https://blog.sangwook.dev/posts/feconf/img/start.png" alt="start.png"/></p>
<p>마침 스피커 모집이 있었고 크게 고민하지 않고 신청을 했다. 마침 오픈소스의 즐거움을 알아가던 참이었다. 이 메시지를 전달하면 된다는 생각을 가졌기 때문이다.</p>
<p>평소에 글쓰기도 자주 하고 있어 요약본으로 form을 제출했다. 상세 내역은 github 주소에 md 파일로 올려 신청을 했다.</p>
<h2>선정</h2>
<p><img src="https://blog.sangwook.dev/posts/feconf/img/result.png" alt="result.png"/></p>
<p><strong>&quot;됐네?!?!?&quot;</strong> 심장이 떨렸다. 스크롤을 다시 올렸다 내렸다를 반복했다. 하지만 카운트 다운이 시작된 것처럼 바쁘게 움직였다.</p>
<h2>준비</h2>
<h3>일정</h3>
<p>5/27일에 선정 결과를 받았고 8/23일에 발표였다. 3번 정도의 사전 리허설이 있었다. 나의 목표는 첫 리허설까지 발표 슬라이드 완성이었다.</p>
<p>그렇게 해야 발표일 전까지 피드백을 받아 수정도 가능할 것 같았다.</p>
<h3>초안 작성</h3>
<p><a href="https://github.com/Han5991/fe-lab/tree/main/apps/blog/posts/feconf">관련링크</a></p>
<p>내용에 대한 고민은 많이 하지 않았다. 이미 전달하고 싶은 이야기는 정했기 때문이다. 가장 큰 걱정은 생각보다 양이 많다는 것이었다.</p>
<p>그래도 열심히 적었다. 정말 하고 싶은 이야기 다 적었다. 일단 적고 줄이는 전략으로 만들기 시작한 것이었다.</p>
<h3>슬라이드 작성</h3>
<p>이렇게 만든 초안을 슬라이드로 옮기는 것이 문제였다. 나는 정말 오랫동안 슬라이드를 만들어본 적이 없기 때문이다.<br/>
그래서 여러 플랫폼을 찾아 다녔다. md 파일을 바로 슬라이드로 만들어주는 플랫폼 여러곳이 있었지만 <code>미리캠버스</code>가 가장 원하는 형태로 잘 만들어 주었다.
pdf가 필요하긴 했지만 md를 pdf로 바꾸는 건 쉽기 때문에 괜찮았다.</p>
<p>슬라이드로 만드니 대략 나오는 장수는 35장 많이 줄이는 것이 필요 했다. 정말 핵심적인 슬라이드 한장을 고르고 그걸 위주로 내용을 재편집 했다.<br/>
그 한 장이 &#x27;오픈 소스 코드 읽기로 얻는 것들&#x27;이었다.</p>
<p>나도 이력서에 한 줄 넣기 혹은 회사 일 하다가 넣은 것이지만 이것보다는 직접 소스코드를 분석하고 오픈소스를 어떻게 운영하는지에 대해 많이 배웠기 때문이다.</p>
<h3>발표 연습</h3>
<p>가깝게는 아는 지인들을 총동원 했다. (글쓰기 모임, 아는 오픈 톡방, 회사) 이중에 회사는 사내 발표로 까지 이어져 용돈까지 벌었다. 매우 뜻하지 않게 금전적 이득이 있어 기분이 들떴다.<br/>
이때 대략 15분 정도의 분량으로 준비를 해갔다. 남은 5분은 사전리허설의 피드백을 받아 줄여볼 생각이었기 때문이었다.</p>
<p>반응이 좋았다. 내용이 알차다고 피드백을 들었고 전부터 우려했던 목소리 톤이나 속도도 괜찮다는 피드백을 받았다.<br/>
하지만 피드백 중에 성향이 갈리는 듯 하였다. 누군가는 <strong>&quot;그래서 오픈 소스 어떻게 기여할 수 있는건대?&quot;</strong> 또 누군가는 <strong>&quot;오픈 소스를 기여 해야하는 이유를 알 수 있어 좋았어요!&quot;</strong> 라고 했기 때문이다.<br/>
둘다 공감 되는 내용이었고 어느정도 상충이 되는 내용이었다. 상충이 되는 이유는 이미 나에게 주어진 시간이 짧기 때문이었다.<br/>
하지만 난 나의 메시지를 전하는 것이 중요하기에 현재 구성 그대로 가기로 했다.</p>
<p>이제 발표 분량을 10분으로 줄일 때가 됐다.</p>
<p>상세하게 넣은 내용들을 다 지우기 시작 했고 큰 뼈대만 남기기 시작 했다.<br/>
그리고 내 소개도 지웠다. 내가 다니는 회사 연차 보다는 <code>난 이렇게 오픈소스에 기여 하는걸 즐기는 사람</code> 이라는 내용이 더 청자에게 신뢰감을 줄 수 있다는 판단이었다.<br/>
그래서 기여 했던 유명한 오픈소스들을 넣었고 횟수까지 넣었다. 멘트까지 준비 했다. <code>짧은 기간동안 30회 정도 기여한 사람</code>이라고 이 내용이 현재 오픈 소스를 주제로 발표하는 나를 가장 잘 표현한 문장이라고 생각 했다.</p>
<p>이렇게 줄이기 시작하니 장표는 33장에서 11장으로 줄었다.</p>
<h3>사전 리허설</h3>
<p>각자의 시간을 투표 받아 사전 리허설을 했다. 정해진 순서에따라 발표를 해보는 건 아니었고 서로의 발표 자료를 보며 피드백을 하는 자리였다.<br/>
오거나이저인 재엽님과 동준님이 분위기를 풀어주었고 덕분에 즐겁고 하하 호호 웃으면서 서로의 발표를 들을 수 있었다.<br/>
나도 발표를 했고 <strong>구성이 너무 좋다</strong>, <strong>오픈소스 기여하는 이유가 너무 좋다</strong> 등의 좋은 피드백을 들을 수 있었고<br/>
추가로 다른 곳에서도 발표 해달라는 제의(?)도 받았다. 기회가 된다면 다른 곳에 가서도 좀 더 내용을 채워서 해보고 싶다는 생각이 들었다.<br/>
how에 대한 내용을 조금 줄이고 그 내용은 글을 통해서 남기는 것이 어떻겠냐는 피드백을 들어 이 부분을 좀 수정하기로 했다.<br/>
피드백 받은 내용을 수정하기도 하고 발표 준비도 하면서 하루 하루를 보냈다.</p>
<h2>발표</h2>
<h3>당일 도착 하기전</h3>
<p><img src="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-23-39-53.png" alt="KakaoTalk_Photo_2025-08-30-23-39-53.png"/></p>
<p>발표날 아침 동준님이 저 사진을 올리면서 라이트닝 스피커가 발표 하는 장소라고 알려주셨다.<br/>
순간 당황했다. 하지만 이왕 하는 거 멋진 곳에서 하게 되어서 오히려 흥분됐다. 아드레날린이 손끝까지 전해져 왔다.</p>
<h3>컨퍼런스장 도착</h3>
<p><img src="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-19-43-27%20029.jpeg" alt="KakaoTalk_Photo_2025-08-30-19-43-27 029.jpeg"/>
<img src="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-19-43-17%20009.png" alt="KakaoTalk_Photo_2025-08-30-19-43-17 009.png"/>
<img src="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-19-43-15%20001.jpeg" alt="KakaoTalk_Photo_2025-08-30-19-43-15 001.jpeg"/></p>
<p>단장을 하느라 조금 늦게 갔다.<br/>
가서 아는 사람들을 만났다. 오히려 더 많이 이야기를 나누었다. 글쓰기 모임 사람들과 이야기 하고 평소 아는 사람들과도 이야기를 많이 나누었다.<br/>
긴장을 풀기 위한 나의 방법 중 하나였다. 하지만 긴장은 쉽게 풀리지 않았다. 다른 세션도 들으러 가보았지만 마음이 둥둥 떠다니다 보니 전혀 들리지 않았다. 리더 토크를 들을 수도 있었는데 전혀 집중하지 못한 것이 아쉽다.</p>
<h3>실전</h3>
<p><img src="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-19-43-17%20008.jpeg" alt="KakaoTalk_Photo_2025-08-30-19-43-17 008.jpeg"/></p>
<p>내 앞에 멋지게 킵올빌런님께서 발표 해주셨다. 본인의 은퇴식이라고 멋지게 트위터 계정을 비활성화 하는 퍼포먼스까지 해주셨다. ㅠㅠ<br/>
마침내 내 차례가 됐다.<br/>
마이크를 잡는 순간 정말 내가 말해도 되는건가? 나 해도되는건가?<br/>
마이크는 생각보다 앞선 발표자로 인해 따뜻 했으며 앞에 놓인 노트북은 내가 만들어놓은 발표 자료가 놓여있었다. 관중은 화면을 집중하고 있었고 부모님도 앞아 앉아서 나와 내 발표자료를 바라 보았다.<br/>
이런 생각을 2초동안 하고 눈을 질끈 감고 발표를 시작 했다.<br/>
연습했던 내용들이 자연스럽게 흘러나왔고 생각보다 나의 발표 속도도 괜찮았다. 그러다 어느 순간 나의 숨쉬는 소리와 침 삼키는 소리가 내 귀에 들렸다.<br/>
<code>듣는 순간 난 2초 동안 머리와 입이 멈추었다.</code>(생각해보니 그냥 말하면 되는건대 긴장을 많이 한 탓이다.)<br/>
그래도 다행히 정신줄을 붙잡고 다시 발표를 이어나갔다. 그 순간 기묘한 깨달음이 왔다. &#x27;아, 나는 이미 충분히 준비됐어. 이제 내 이야기를 들려주기만 하면 되는거야.&#x27;</p>
<p>평소에 많이 연습을 한 탓인지 생각하면서 말을 하는 게 아니라 그냥 머슬메모리로 발표를 이어나갔다. 신기하게도 내 목소리가 점점 더 자연스러워지고 있었다.</p>
<p>발표가 끝났다. 마지막 슬라이드를 넘기는 순간, 본능적으로 숨을 깊게 들이마셨다. 박수 소리가 울리니까 비로소 속에서 그제서야 실감이 났다. &#x27;나, 해냈다!&#x27;</p>
<p>다리가 아직도 떨리고 있었지만, 이상하게 모든 게 선명해 보였다. 아들이 큰 곳에서 발표한다고 여기까지 와서 날 촬영해준 부모님의 웃음, 그동안 흐리게만 보였던 컨퍼런스장의 모습, 대기실에서 나를 지켜보던 다른 연사자들의 따뜻한 시선까지.</p>
<p>그제야 컨퍼런스장이 눈에 들어오기 시작했다. 네트워킹 장도 눈에 들어오기 시작했다.</p>
<h3>컨퍼런스 즐기기</h3>
<p>네트워크장은 매우 뜨거웠으며 다들 열띤 토론을 이어나갔다. 아쉽게도 내 발표가 뒷쪽이라 네트워크에 어울리지는 못 했지만 다들 비슷한 도메인 같은 직업 같은 고민을 하는 사람들이라 정말 쉼없이 토론을 하는 것을 보고 매우 흥미로웠다.<br/>
네트워킹을 나도 나중에 꼭 즐길 수 있었으면 좋겠다는 생각을 했다.<br/>
단 하나의 세션만 들었는대 그것은 flex 김종혁 님의 <strong>모노레포 절망편, 14개 레포로 부활하기까지 걸린 1년</strong> 이었다 나도 개인적으로 모노레포 떄문에 고통을 많이 받고 있어 이야기를 들으러 갔고 내가 얻은 인사이트는 결국 모노레포의 이야기 보다는<br/>
각 레이어의 역할이 충실하지 않아 발생하는 문제라고 생각을 했고 여기선 그 문제를 코드를 물리적으로 떼어놓는 것으로 해결 했다는 것이었다. 매우 흥미로웠고 현재 회사의 프로젝트에 패키지를 하나 만들 때마다 어떻게 적용 시킬 수 있을 지 고민을 하게 되었다.</p>
<h3>뒷풀이</h3>
<p><img src="https://blog.sangwook.dev/posts/feconf/img/KakaoTalk_Photo_2025-08-30-19-43-23%20021.jpeg" alt="KakaoTalk_Photo_2025-08-30-19-43-23 021.jpeg"/></p>
<p>발표가 끝난 뒤에는 토스에서 주최하는 프라이빗 파티에 왔다.</p>
<p>음식은 굉장히 맛있었고 몇가지 주제로 각 테이블에서 열띈 토론을 하였는대 우리테이블은</p>
<ol>
<li>빠르게 vs 잘하게</li>
<li>코드 리뷰 문화 개선</li>
</ol>
<p>각 회사의 사정이 다르고 상황이 달라 의견이 조금씩 차이가 났다 그래도 공감 하는 부분은
각 각의 트레이드 오프를 생각해서 상황에 맞게 잘 적용 하는 것이라는 것이다.<br/>
사업의 생존이 걸릴 때는 빠르게 만들어서 반응을 보는 편이 좋고, 잘하게가 중요한 부분은 시스템을 설계하거나 기술 부채를 해결 할 때는 잘하게가 중요 했다.</p>
<p>코드리뷰 문화 개선은 소통 문제를 쿠션어나 영어만을 사용하여 말투를 바꾸어보는 사례가 있었고 병목이 되는 지점을 자동화나 프로세스 개선을 통해 해결 하는 모습이 있다로 정리 하였다.</p>
<h2>마무리</h2>
<p>한번도 컨퍼런스에 참여를 해보지 않았다. 선정에서 떨어지거나 표를 구하기도 어려웠고 시간을 내지 못 해 못 간 것도 있었다.<br/>
하지만 이렇게 오프라인 네트워크가 짜릿한 즐거움을 줄 수 있다는 걸 처음 느꼈다. 그리고 그걸 컨퍼런스에서 할 수 있다는 것도 처음 알았다.<br/>
그리고 feconf에서 발표를 하면서 내 안에 숨어있던 다른 모습을 발견했다. 예전 같으면 두려워서 피했을 일들에 도전하고 싶어하는 나. 완벽하지 않아도 일단 도전하고 나서 개선해 나가는 나. 그런 나를 처음 만났다.<br/>
네트워킹 및 세션에는 적극적으로 참여하지 못 한건 많이 아쉽지만 그건 다음 feconf 에선 꼭 열심히 참여하겠다고 생각을 했다.</p>
<p>아직 하고 싶은 이야기가 많으니 내년엔 feconf 메인세션으로 올라가 발표를 한번 더 해보고 싶다.</p>]]></content:encoded>
    </item>
    <item>
      <title>4시간 만에 Node.js PR 승인받기</title>
      <link>https://blog.sangwook.dev/posts/nodejs-contribution/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/nodejs-contribution/</guid>
      <pubDate>Sat, 09 Aug 2025 15:00:00 GMT</pubDate>
      <description>util.inspect가 음수 소수점을 잘못 포매팅하는 버그를 AI와 페어 프로그래밍으로 파고들어 4시간 만에 PR 승인까지 받았습니다. 이슈를 고르는 기준부터 메인테이너와 주고받은 과정까지, Node.js 코어 기여의 전 과정.</description>
      <content:encoded><![CDATA[<link rel="preload" as="image" href="https://blog.sangwook.dev/posts/open-source/nodejs.img.png"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/open-source/nodejs.img_2.png"/><link rel="preload" as="image" href="https://blog.sangwook.dev/posts/open-source/nodejs.img_3.png"/><h2>AI 페어 프로그래밍으로 오픈소스 진입 장벽 낮추기</h2>
<p><img src="https://blog.sangwook.dev/posts/open-source/nodejs.img.png" alt="img.png"/></p>
<blockquote>
<p>“무조건 Node.js에 내 코드를 넣고 말겠다!”<br/>
오픈소스 기여가 점점 재미있어지고, 자신감도 차오르던 시기였다.<br/>
하지만 매일같이 이슈를 살펴봐도 여전히 모르는 부분이 많았다. C++ 코드가 보이기도 했고, <code>fs</code> 같은 코어 모듈이 대부분이었기 때문이다. (게다가 이슈를 완벽히 이해한 것도 아니었다.)<br/>
그러던 중 <strong>AI를 활용한 오픈소스 기여 모임</strong>을 알게 되었고, 거기서 본 한 링크가 나에게 혁신을 가져다주었다.</p>
</blockquote>
<hr/>
<h2>1. 이슈 찾기: AI로 시작하는 빠른 탐색</h2>
<p>이슈 분석에 어려움을 느끼던 내게 구세주가 나타났다.<br/>
바로 <a href="https://contribution-issue-collector.streamlit.app/">이슈 수집기</a>였다.<br/>
곧장 <code>nodejs/node</code> 저장소와 내 토큰을 입력하고 모든 이슈를 긁어왔다. (지금도 매일 한다.)</p>
<p>그중 눈에 들어온 이슈가 있었다.</p>
<blockquote>
<p><strong>Issue 23: util.inspect incorrectly formats negative fractional numbers</strong></p>
<ul>
<li><strong>라벨:</strong> 없음</li>
<li><strong>내용:</strong> <code>numericSeparator: true</code> 옵션 사용 시 음수 소수점 숫자 포맷팅 버그</li>
<li><strong>원인:</strong> <code>formatNumber</code> 함수에서 -0 처리 시 부호 손실</li>
<li><strong>해결 방향:</strong> 없음</li>
<li><strong>적합도:</strong> 상 (재현 코드와 명확한 문제 설명)</li>
<li><strong>기술 난이도:</strong> 중</li>
</ul>
</blockquote>
<p>C++도 없고, 복잡한 코어 모듈도 아닌, 숫자 유틸 함수 하나를 수정하는 일이었다.<br/>
게다가 이슈가 올라온 지 5시간밖에 되지 않아 바로 댓글을 달고 선점했다.</p>
<p>곧 메인테이너가 해결 방향과 최적화 아이디어를 제시해주었고, 나는 지체 없이 코드를 수정하러 들어갔다.</p>
<hr/>
<h2>AI 활용 팁: 이슈 선택 시 체크리스트</h2>
<ul>
<li><strong>난이도</strong>: 너무 어려운 영역(C++, 네이티브 모듈 등)은 피하고, 변경 범위가 명확한 이슈를 선택합니다.</li>
<li><strong>재현 가능성</strong>: 이슈 설명에 재현 코드나 명확한 조건이 포함되어 있는지 확인합니다.</li>
<li><strong>라벨 여부</strong>: &#x27;good first issue&#x27;, &#x27;help wanted&#x27; 라벨은 초심자에게 좋은 출발점입니다.</li>
</ul>
<hr/>
<h2>2. 문제 분석: 버그 원인 파악하기</h2>
<p>문제는 간단했다.<br/>
<code>util.inspect</code>에서 <code>numericSeparator</code> 옵션을 켜면, 0과 1 사이의 음수 값에서 부호가 사라지는 현상이 있었다.</p>
<pre><code class="language-typescript">import { inspect } from &#x27;util&#x27;;

const values = [0.1234, -0.12, -0.123, -0.1234, -1.234];
const text = inspect(values, { numericSeparator: true });
console.log(text);

// 기대 출력
[ 0.123_4, -0.12, -0.123, -0.123_4, -1.234 ]

// 실제 출력
[ 0.123_4, 0..12, 0..12_3, 0..12_34, -1.234 ]
</code></pre>
<p>문제의 원인은 <code>lib/internal/util/inspect.js</code>에서 숫자를 문자열로 변환할 때, <code>-0</code>이 <code>&quot;0&quot;</code>으로 변환되어 부호가 사라지는 것이었다.</p>
<hr/>
<h2>3. 개발 환경 구축: 로컬에서 Node.js 빌드하기</h2>
<p>Node.js 저장소를 포크 후 클론 받아 AI CLI 환경에서 코드베이스를 학습시켰다.</p>
<p><img src="https://blog.sangwook.dev/posts/open-source/nodejs.img_2.png" alt="img_2.png"/></p>
<p>처음에는 C++이 없어서 빌드 에러가 발생했다. AI와 함께 원인을 찾아보니 Command Line Tools 설치가 필요했다. 설치 후 다시 빌드하니 약 30분 만에 성공.</p>
<p>처음 직접 빌드한 Node.js를 실행했을 때, 테스트 코드가 정상 동작하는 것을 보고 꽤 뿌듯했다.</p>
<hr/>
<h2>AI 활용 팁: 빌드 에러 대처</h2>
<ul>
<li><strong>환경 의존성</strong>: C++ 컴파일러, Python 버전 등 필수 의존성을 먼저 확인합니다.</li>
<li><strong>빌드 로그 분석</strong>: 에러 메시지를 그대로 AI에게 전달하면 빠르게 해결책을 제시해줍니다.</li>
<li><strong>부분 빌드</strong>: 전체 빌드가 오래 걸린다면, 변경된 부분만 빌드하는 방법을 찾아보세요.</li>
</ul>
<hr/>
<h2>4. 테스트 작성: 버그 재현부터 시작</h2>
<p>다음 단계는 재현 테스트 작성이었다.<br/>
이슈(#59376) 내용을 기반으로 아직 통과하지 못하는 테스트를 만들고, 단일 파일 테스트를 실행했다.</p>
<p><img src="https://blog.sangwook.dev/posts/open-source/nodejs.img_3.png" alt="img_3.png"/></p>
<pre><code class="language-bash">./out/Release/node test/parallel/test-util-inspect.js
</code></pre>
<p>이슈 상황이 정상적으로 재현되는 것을 확인하고 수정 작업에 착수했다.</p>
<hr/>
<h2>5. 버그 수정: 부호 보존 로직 추가</h2>
<p>수정 전 <code>formatNumber</code></p>
<pre><code class="language-javascript">// 수정 전 (버그가 있던 코드)
const integer = MathTrunc(number);
const string = String(integer);

// 소수점 처리
return fn(
  `${
    addNumericSeparator(string) // &lt;- 여기서 음수 부호 손실
  }.${addNumericSeparatorEnd(
    StringPrototypeSlice(String(number), string.length + 1),
  )}`,
  &#x27;number&#x27;,
);
</code></pre>
<p>수정 후 <code>formatNumber</code></p>
<pre><code class="language-javascript">function formatNumber(fn, number, numericSeparator) {
  if (!numericSeparator) {
    if (ObjectIs(number, -0)) return fn(&#x27;-0&#x27;, &#x27;number&#x27;);
    return fn(`${number}`, &#x27;number&#x27;);
  }

  const numberString = String(number);
  const integer = MathTrunc(number);

  if (integer === number) {
    if (!NumberIsFinite(number) || StringPrototypeIncludes(numberString, &#x27;e&#x27;)) {
      return fn(numberString, &#x27;number&#x27;);
    }
    return fn(addNumericSeparator(numberString), &#x27;number&#x27;);
  }

  if (NumberIsNaN(number)) {
    return fn(numberString, &#x27;number&#x27;);
  }

  // 원본 문자열에서 직접 소수점 위치 찾기
  const decimalIndex = StringPrototypeIndexOf(numberString, &#x27;.&#x27;);
  // 방어적 주석: IEEE-754에서 이 분기까지 오는 경우는 거의 없음
  if (decimalIndex === -1) return fn(numberString, &#x27;number&#x27;);

  // 원본 문자열에서 정수/소수 부분 분리
  const integerPart = StringPrototypeSlice(numberString, 0, decimalIndex);
  const fractionalPart = StringPrototypeSlice(numberString, decimalIndex + 1);

  return fn(
     return fn(`${
    addNumericSeparator(integerPart)  // &lt;- 음수 부호 보존
  }.${
    addNumericSeparatorEnd(fractionalPart)
  }`, &#x27;number&#x27;);
}
</code></pre>
<p>이유</p>
<ul>
<li>문제: -0.1234에서 Math.trunc(-0.1234) = -0, String(-0) = &quot;0&quot;이 되어 음수 기호가 사라짐</li>
<li>해결: 원본 문자열 -0.1234에서 직접 . 앞부분(-0)과 뒷부분(1234)을 분리</li>
</ul>
<p>메인테이너가 제안한 방향(string 한번만 사용)을 반영하고, 이슈 제기자가 지적한 잠재적 케이스에 대해 방어적 주석을 남겼다.</p>
<hr/>
<h2>6. Pull Request</h2>
<p>AI에게 요약을 맡겨 PR 메시지를 작성하고, 로컬 테스트 통과 스크린샷과 함께 Push했다.<br/>
메인테이너는 <a href="https://github.com/nodejs/node/pull/59379">빠르게 승인</a>을 줬지만, 병합은 다른 PR과 함께 진행하는 듯했다.</p>
<p>이슈 제기자는 내 코드에 특정 케이스에서 버그가 날 수 있다고 했지만, 해당 케이스가 이미 테스트 코드에 포함되어 있었기에 문제없음을 설명했다.</p>
<hr/>
<h2>7. 회고: AI와 함께한 오픈소스 첫걸음</h2>
<p>이번 경험에서 느낀 점은 다음과 같다.</p>
<ol>
<li><strong>AI는 오픈소스 진입 장벽을 크게 낮춰준다.</strong><br/>
빌드·테스트 환경 설정, 코드 구조 파악 등에서 큰 도움을 받았다.</li>
<li><strong>작은 이슈도 충분히 가치 있다.</strong><br/>
코어 모듈이 아니더라도 유지보수와 품질 개선에 기여할 수 있다.</li>
<li><strong>테스트 코드 작성이 핵심이다.</strong><br/>
버그를 명확히 재현하고, 수정 후에도 안정성을 보장할 수 있었다.</li>
</ol>
<p>앞으로도 AI를 적극적으로 활용해 더 많은 오픈소스 기여와 학습을 이어가고 싶다.</p>]]></content:encoded>
    </item>
    <item>
      <title>Next.js 오픈소스 기여: 2번 실패 후 성공 후기</title>
      <link>https://blog.sangwook.dev/posts/nextjs-contributor/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/nextjs-contributor/</guid>
      <pubDate>Mon, 04 Aug 2025 15:00:00 GMT</pubDate>
      <description>새벽 2시에 날아온 Closed 알림을 두 번 받고서야 Next.js Contributors 목록에 이름을 올렸습니다. 두 번의 실패에서 무엇을 잘못 골랐는지, 세 번째에 머지된 PR은 무엇이 달랐는지 솔직하게 적었습니다.</description>
      <content:encoded><![CDATA[<link rel="preload" as="image" href="https://blog.sangwook.dev/posts/open-source/next-js-pr-img.png"/><h2>들어가며</h2>
<p>&quot;띠링&quot; 새벽 2시, 내 핸드폰에 또 다른 GitHub 알림이 울렸다.</p>
<blockquote>
<p><strong>발신자: vercel/next.js</strong><br/>
<a href="https://github.com/vercel/next.js/pull/80428">[제목] : Re:[vercel/next.js] Fix remote pattern (#80294) (PR #80428)</a><br/>
<img src="https://blog.sangwook.dev/posts/open-source/next-js-pr-img.png" alt="img.png"/></p>
</blockquote>
<p>아, 이건 하면 안 되는 거였구나...</p>
<p>빨간색 &quot;Closed&quot; 라벨이 붙은 내 PR을 멍하니 바라보며, 가슴이 철렁 내려앉았다.</p>
<p>잠은 이미 다 깼고, 구글링으로 찾은 &quot;How to contribute to Next.js&quot; 글들은 아무런 도움이 되지 않았다. 마치 미로에 갇힌 기분이었다.</p>
<p><strong>&quot;생각보다 많이 어렵네...&quot;</strong> 내적으로 혼잣말을 중얼거렸다.</p>
<p>이게 나의 두 번째 실패였다. 하지만 포기하고 싶지 않았다. 어딘가에서 &#x27;너도 할 수 있어&#x27;라는 목소리가 들리는 것 같았다.</p>
<p>한 달 후, 나는 Next.js Contributors 목록에 이름을 올렸다. 그 순간의 짜릿함과 벅참은 지금도 잊을 수 없다.</p>
<blockquote>
<p>여러분도 오픈소스 기여에 도전하다가 좌절한 경험이 있으신가요? 실패가 두려워서 시도조차 못하고 계신가요?<br/>
그 과정에서 느꼈던 막막함과 불안감, 그리고 다시 일어서고 싶은 마음... 모두 이해합니다. 그 사이에 무슨 일이 있었는지, 지금부터 솔직하게 털어놓겠습니다.</p>
</blockquote>
<h2>첫 번째 실패: &quot;이런 게 개발자의 현실인가?&quot;</h2>
<h3><a href="https://github.com/vercel/next.js/issues/80014#issuecomment-2927911102">Fix segment prefetches fail with HTTP 400</a></h3>
<p>3개월 전, Next.js 이슈 페이지를 뒤적이던 나는 마치 <strong>보물을 발견한 듯 가슴이 뛰었다.</strong></p>
<p>&quot;이거다! 예제 코드도 있고, 문제 코드 추정 부분까지 친절하게 써있어. 이 정도면 금방 할 수 있겠지?&quot;</p>
<p>당시 내 마음은 설렘이 90%, 불안감이 10% 정도였다. 솔직히 말하면 <strong>&quot;이거 성공하면 이력서에 써도 되겠는걸?&quot;</strong> 이런 생각까지 들었다. 오픈소스 기여가 Next.js라니, 생각만 해도 가슴이 뛰었다.</p>
<p>실제로는 아무것도 모르는 상태로 무작정 도전했다. 지금 생각해보니 정말 용감했다. App Router의 prefetch라는 실험적인 기능에 덤벼들었는데, 이게 얼마나 복잡한 기능인지도 모르고 말이다.</p>
<p>로컬 환경 설정부터 막혀서 3일 동안 삽질했지만, 그래도 <strong>테스트 코드 구조만큼은 이해할 수 있었다.</strong> <code>nextTestSetup()</code>이라는 특별한 함수를 사용한다는 것도 이때 처음 알았다.</p>
<pre><code class="language-typescript">/**
 * 당시 내가 만든 테스트 코드
 * 시작점을 여기로 설정하면 같은 경로의 next.js 코드가 들어가게 된다(신기)
 */
import { nextTestSetup } from &#x27;e2e-utils&#x27;;
import { fetchViaHTTP } from &#x27;next-test-utils&#x27;;

describe(&#x27;Segment prefetches with rewritten paths&#x27;, () =&gt; {
  const { next } = nextTestSetup({
    files: __dirname,
  });

  // 필요한 부분만 이렇게 셋팅해서 테스트가 가능 하다.
  it(&#x27;should fail with HTTP 400 when path is rewritten but hash is incorrect (reproducing issue #80014)&#x27;, async () =&gt; {
    const res = await fetchViaHTTP(next.url, &#x27;/test&#x27;, undefined, {
      headers: {
        RSC: &#x27;1&#x27;,
        &#x27;Next-Router-Prefetch&#x27;: &#x27;1&#x27;,
        &#x27;Next-Router-Segment-Prefetch&#x27;: &#x27;/_tree&#x27;,
      },
    });

    expect(res.status).toBe(200);
  });
});
</code></pre>
<p>하지만 <strong>진짜 문제는 그 다음이었다.</strong> 테스트는 돌릴 수 있게 되었지만, 정작 <strong>실제 이슈를 어떻게 해결해야 할지 전혀 감이 오지 않았다.</strong> 코드베이스가 너무 복잡했고, 어디서부터 손을 대야 할지 막막했다.</p>
<p>결국 <strong>PR을 올리지도 못하고 포기했다.</strong> 그 순간의 감정: 허탈함과 함께 &quot;내가 너무 어려운 걸 골랐구나&quot;라는 뼈아픈 깨달음이었다.</p>
<h3>💡 Next.js 테스트 환경 제대로 구축하기</h3>
<p>일반적인 React 테스트와 달리 Next.js는 프레임워크 차원의 특수한 구조를 가지고 있다. 이를 모르면 며칠을 헤맬 수 있다.</p>
<p><strong>왜 Next.js 테스트가 까다로운가?</strong></p>
<ul>
<li>서버사이드 렌더링, 라우팅, 빌드 과정이 모두 연결되어 있음</li>
<li>단순한 컴포넌트 테스트로는 실제 동작을 재현할 수 없음</li>
</ul>
<p><strong>Next.js만의 특별한 테스트 구조</strong></p>
<ul>
<li><code>nextTestSetup()</code>: Next.js 전용 테스트 유틸리티 활용</li>
<li>E2E 테스트 환경에서 실제 서버 동작 재현</li>
<li><code>fetchViaHTTP()</code> 같은 Next.js 전용 헬퍼 함수들 이해하기</li>
</ul>
<p><strong>로컬에서 제대로 실행하기</strong></p>
<ul>
<li>의존성 설치 후 반드시 전체 빌드 먼저 실행</li>
<li>특정 테스트만 실행: <code>npm test -- --testPathPattern=&quot;특정키워드&quot;</code></li>
</ul>
<p>그날 밤, 침대에 누워서 생각했다. &quot;다음엔 좀 더 쉬운 걸로 해야겠어.&quot;</p>
<h2>두 번째 실패: &quot;자신감이 독이 될 줄이야&quot;</h2>
<h3><a href="https://github.com/vercel/next.js/pull/80428">Fix remote pattern</a></h3>
<p>첫 번째 실패 후 한 달 뒤, 나는 다시 도전장을 내밀었다.</p>
<p><strong>&quot;이번엔 다르다. <code>next/image</code>는 내가 매일 쓰는 컴포넌트잖아. 이정도는 할 수 있어!&quot;</strong></p>
<p>이번엔 자신감이 70%, 조심스러움이 30% 정도였다. &quot;이번엔 확실히 쉬운 걸로 골랐어&quot;라고 생각했다. <code>next/image</code>는 내가 프로젝트에서 매일 사용하는 컴포넌트니까 말이다.</p>
<p><code>next/image</code> 컴포넌트의 쿼리스트링 와일드카드 문제를 선택했다. 이슈를 보면서 &quot;아, 이미지 src에 <code>?*</code> 같은 와일드카드가 안 들어가는 거네?&quot;라고 파악했다. 이번엔 테스트 코드도 자신 있게 작성했는데, dist가 있다는 걸 모르고 며칠을 고생했다.</p>
<pre><code class="language-typescript">/**
 * next/image remote pattern 확인하는 코드
 * 결국 여기도 읽어보면 별거 없다
 * 별거 없는 것 처럼 보이게 코드를 잘 나눈 것 같기도 하다.
 */
import type { RemotePattern } from &#x27;./image-config&#x27;;
import { makeRe } from &#x27;next/dist/compiled/picomatch&#x27;;

export function matchRemotePattern(
  pattern: RemotePattern | URL,
  url: URL,
): boolean {
  if (pattern.protocol !== undefined) {
    if (pattern.protocol.replace(/:$/, &#x27;&#x27;) !== url.protocol.replace(/:$/, &#x27;&#x27;)) {
      return false;
    }
  }
  if (pattern.port !== undefined) {
    if (pattern.port !== url.port) {
      return false;
    }
  }

  if (pattern.hostname === undefined) {
    throw new Error(
      `Pattern should define hostname but found\n${JSON.stringify(pattern)}`,
    );
  } else {
    if (!makeRe(pattern.hostname).test(url.hostname)) {
      return false;
    }
  }

  /**
   *  이 부분을 새로 썻다 기존 테스트 코드도 통과 시키면서 와일드 카드를 허용 시키는 코드였다.
   */
  const patternSearch = pattern.search ?? &#x27;&#x27;;
  const patternPath = pattern.pathname ?? &#x27;**&#x27;;
  const isSpecificWildcardPath =
    patternPath.includes(&#x27;**&#x27;) &amp;&amp; patternPath !== &#x27;**&#x27; &amp;&amp; patternPath !== &#x27;/**&#x27;;

  if (patternSearch !== &#x27;&#x27;) {
    if (patternSearch !== url.search) {
      return false;
    }
  } else if (url.search &amp;&amp; !isSpecificWildcardPath) {
    return false;
  }

  // Should be the same as writeImagesManifest()
  if (!makeRe(pattern.pathname ?? &#x27;**&#x27;, { dot: true }).test(url.pathname)) {
    return false;
  }

  return true;
}
</code></pre>
<pre><code class="language-typescript">// 그 당시 적었던 테스트 코드
it(&#x27;should match URLs with wildcard patterns&#x27;, () =&gt; {
  // 허용되는 패턴
  const p = &#x27;https://example.com/act123/**&#x27;;
  expect(m(new URL(p), new URL(&#x27;https://example.com/act123/usr4?v=4&#x27;))).toBe(
    true,
  );
  expect(
    m(new URL(`${p}/*`), new URL(&#x27;https://example.com/act123/u/74867549?v=4&#x27;)),
  ).toBe(true);
});
</code></pre>
<p>2주 후, 또다시 내 PR이 닫혔다. 이번엔 더 상세한 설명과 함께.</p>
<p><strong>메인테이너: &quot;이 동작은 예상된 동작입니다. 이 코드가 머지되면 안 돼요.&quot;</strong></p>
<p><strong>그 순간 나는 뒤통수를 맞은 기분이었다.</strong></p>
<p>&quot;아... 이게 버그가 아니라 의도적인 거였구나. 나는 왜 이걸 미리 확인 안 했지?&quot;</p>
<p>내가 버그라고 생각한 것이 사실은 <strong>보안상의 이유로 의도적으로 막아놓은 기능</strong>이었다. 그날은 정말 자존심이 상했다. <strong>카페에서 앉아서 노트북을 덮고 한참을 멍때렸다.</strong> &quot;내가 개발을 잘못 배운 건가?&quot;</p>
<h3>💡 이슈 분석과 문서 읽기</h3>
<p>&quot;이건 분명 버그야!&quot;라고 성급하게 판단하기 전에, 의도된 동작일 가능성을 먼저 체크해야 한다.</p>
<p><strong>1단계: 공식 문서 우선 탐색</strong></p>
<ul>
<li>해당 기능의 공식 문서에서 제약사항이나 보안 관련 언급 확인</li>
<li>API 레퍼런스에서 예외 케이스나 주의사항 섹션 정독</li>
</ul>
<p><strong>2단계: 이슈 판별 기준 적용</strong></p>
<ul>
<li>기능이 완전히 작동 안 하는가? vs 특정 상황에서만 제한되는가?</li>
<li>보안, 성능상 이유로 의도적 제한일 가능성 검토</li>
<li>GitHub Issues에서 similar한 토론이나 closed 이슈 검색</li>
</ul>
<h2>세 번째 도전: &quot;AI와 함께라면 다를 거야&quot;</h2>
<h3><a href="https://github.com/vercel/next.js/pull/81146">Fix before interactive incorrectly render css</a></h3>
<p>두 번의 실패 후 2개월이 지났다. 솔직히 말하면 조금 겁이 났다.</p>
<p>어느 날, AI와 코드 리뷰를 하다가 문득 생각이 들었다. <strong>&quot;이번엔 혼자 하지 말고 AI와 함께 해볼까?&quot;</strong></p>
<p>이번엔 마음가짐부터 달랐다. 겸손함이 50%, 신중함이 40%, 기대감이 10% 정도였다. &quot;이번엔 정말 확실하게 하자&quot;는 다짐을 했다. 두 번의 실패를 겪으니 자연스럽게 조심스러워졌다.</p>
<p><strong>AI와 함께한 과정:</strong></p>
<p><strong>나:</strong> &quot;이 이슈 어떻게 생각해?&quot;<br/>
<strong>AI:</strong> &quot;script 컴포넌트의 다른 옵션들은 모두 파싱 함수를 거치는데, 이 부분만 빠져있네요.&quot;<br/>
<strong>나:</strong> &quot;맞네! 다른 옵션들은 잘 동작하는데 이것만 안 되고 있어.&quot;</p>
<p><strong>이때의 감정은 정말 특별했다.</strong> 마치 퍼즐의 마지막 조각을 맞춘 기분이었달까. AI의 객관적인 시각이 내가 놓친 부분들을 하나씩 짚어주었다.</p>
<pre><code class="language-typescript">/**
 * Before starting the Next.js runtime and requiring any module, we need to make
 * sure the following scripts are executed in the correct order:
 * - Polyfills
 * - next/script with `beforeInteractive` strategy
 */

import { setAttributesFromProps } from &#x27;./set-attributes-from-props&#x27;;

function loadScriptsInSequence(
  scripts: [src: string, props: { [prop: string]: any }][],
  hydrate: () =&gt; void,
) {
  if (!scripts || !scripts.length) {
    return hydrate();
  }

  return scripts
    .reduce((promise, [src, props]) =&gt; {
      return promise.then(() =&gt; {
        return new Promise&lt;void&gt;((resolve, reject) =&gt; {
          const el = document.createElement(&#x27;script&#x27;);

          if (props) {
            // 여기 하나 추가 해줬다. 하지만 엄청 짜릿했다.
            setAttributesFromProps(el, props);
          }

          if (src) {
            el.src = src;
            el.onload = () =&gt; resolve();
            el.onerror = reject;
          } else if (props) {
            el.innerHTML = props.children;
            setTimeout(resolve);
          }

          document.head.appendChild(el);
        });
      });
    }, Promise.resolve())
    .catch((err: Error) =&gt; {
      console.error(err);
      // Still try to hydrate even if there&#x27;s an error.
    })
    .then(() =&gt; {
      hydrate();
    });
}
</code></pre>
<p>이번엔 정말 간단하고 명확한 이슈를 선정했다. script 컴포넌트에서 누락된 유틸 함수를 찾는 것이었다. AI가 수정이 필요한 부분을 제시해주었고, 나는 그걸 꼼꼼히 검토했다. 테스트 코드는 이슈의 예제 코드를 기준으로 회귀 테스트를 작성해달라고 요청했다.</p>
<pre><code class="language-typescript">// 그때 보다 훨씬 풍부해지고 깔끔한 테스트 코드를 작성할 수 있게 되었다.
import { nextTestSetup } from &#x27;e2e-utils&#x27;;

describe(&#x27;Script component with beforeInteractive strategy CSS class rendering&#x27;, () =&gt; {
  const { next } = nextTestSetup({
    files: __dirname,
  });

  it(&#x27;should render script tag with correct class attribute instead of classname&#x27;, async () =&gt; {
    const browser = await next.browser(&#x27;/&#x27;);

    // Wait for the page to fully load
    await browser.waitForElementByCss(&#x27;#example-script&#x27;);

    // Get the HTML content to check the actual rendered attributes
    const html = await browser.eval(() =&gt; document.documentElement.innerHTML);

    // Check that the script tag has &#x27;class&#x27; attribute, not &#x27;classname&#x27;
    expect(html).toContain(&#x27;class=&quot;example-class&quot;&#x27;);
    expect(html).not.toContain(&#x27;classname=&quot;example-class&quot;&#x27;);

    // Also verify the script element directly
    const scriptElement = await browser.elementByCss(&#x27;#example-script&#x27;);
    const className = await scriptElement.getAttribute(&#x27;class&#x27;);

    expect(className).toBe(&#x27;example-class&#x27;);
  });

  it(&#x27;should execute beforeInteractive script correctly&#x27;, async () =&gt; {
    const browser = await next.browser(&#x27;/&#x27;);

    // Check that the script executed by looking for its side effects
    const hasExecuted = await browser.eval(() =&gt; {
      return (window as any).beforeInteractiveExecuted === true;
    });

    expect(hasExecuted).toBe(true);
  });

  it(&#x27;should render script in document head with beforeInteractive strategy&#x27;, async () =&gt; {
    const browser = await next.browser(&#x27;/&#x27;);

    // Check that the script is in the head section
    const scriptInHead = await browser.eval(() =&gt; {
      return document.head.querySelector(&#x27;#example-script&#x27;) !== null;
    });

    expect(scriptInHead).toBe(true);
  });

  it(&#x27;should render multiple beforeInteractive scripts with correct class attributes&#x27;, async () =&gt; {
    const browser = await next.browser(&#x27;/multiple&#x27;);

    const html = await browser.eval(() =&gt; document.documentElement.innerHTML);

    // Check that both scripts have correct class attributes
    expect(html).toContain(&#x27;class=&quot;first-script&quot;&#x27;);
    expect(html).toContain(&#x27;class=&quot;second-script&quot;&#x27;);
    expect(html).not.toContain(&#x27;classname=&quot;first-script&quot;&#x27;);
    expect(html).not.toContain(&#x27;classname=&quot;second-script&quot;&#x27;);
  });
});
</code></pre>
<p>3주 후...</p>
<p><strong>GitHub 알림:</strong> &quot;✅ Merged into main branch&quot;</p>
<p><strong>그 순간을 지금도 생생히 기억한다. 손이 떨렸다. 정말로.</strong> 가슴이 벅차올랐고, 눈물까지 날 뻔했다. &quot;드디어 해냈구나!&quot;</p>
<h3>💡 AI 활용 코드베이스 정복법</h3>
<p>거대한 Next.js 코드베이스 앞에서 막막할 때, AI 도구를 제대로 활용하면 혼자 며칠 걸릴 일을 몇 시간 만에 해결할 수 있다.</p>
<p><strong>1단계: 코드베이스 학습시키기</strong></p>
<ul>
<li>Claude Code의 <code>/init</code> 명령어로 전체 프로젝트 학습</li>
<li>SuperClaude의 커스텀 명령어로 추가 분석</li>
</ul>
<p><strong>2단계: 이슈 함께 파악하기</strong></p>
<ul>
<li>이슈를 AI에게 공유하고 &quot;함께 파악해보자&quot; 요청</li>
<li>AI가 제시하는 수정 포인트를 꼼꼼히 검토</li>
</ul>
<p><strong>3단계: 테스트 코드 협업 작성</strong></p>
<ul>
<li>이슈의 예제 코드를 기준으로 회귀 테스트 작성 요청</li>
<li>AI가 작성한 테스트를 검증하고 보완</li>
</ul>
<h2>이 경험이 내게 남긴 것들</h2>
<p><strong>감정적으로 많이 성장했다.</strong> 무엇보다 실패에 대한 두려움이 줄어들었다. 예전 같으면 한 번 실패하고 나서 주저했을 텐데, 이제는 <strong>실패도 성장의 과정</strong>이라고 받아들이게 되었다. 혼자서 모든 걸 해결하려는 고집 대신 <strong>협력의 힘</strong>을 배웠다.</p>
<p><strong>기술적으로도 확실히 늘었다.</strong> 복잡한 코드베이스를 읽는 능력이 향상되었고, 테스트 코드 작성 스킬도 많이 늘었다. 무엇보다 <strong>이슈를 제대로 분석하는 방법</strong>을 익혔다. 단순히 코드만 보는 것이 아니라, 그 이슈가 왜 생겼는지, 어떤 맥락에서 발생한 건지를 파악하는 능력 말이다.</p>
<p><strong>가장 중요한 깨달음: 실패에서 나오는 배움이 더 크다는 것.</strong></p>
<p>첫 번째 실패에서 Next.js 테스트 환경의 복잡함을, 두 번째 실패에서 문서 정독의 중요성을 배웠다. 이런 교훈들이 없었다면 세 번째 도전에서 AI와 협력하는 전략도 떠올리지 못했을 것이다.</p>
<p><strong>Next.js 기여를 꿈꾸는 여러분께:</strong></p>
<p>실패해도 괜찮습니다. <strong>그 실패가 더 큰 성공의 발판이 될 테니까요.</strong> 저의 경험이 조금이라도 도움이 되었으면 좋겠습니다.</p>
<p><strong>결국 성공했다. 다음에 또 하라고 하면? 당연히 하고 싶다.</strong><br/>
<strong>그리고 AI와 함께 하는 이런 협력 방식, 정말 추천하고 싶다.</strong></p>]]></content:encoded>
    </item>
    <item>
      <title>Context API를 활용한 DropZone 구현하기</title>
      <link>https://blog.sangwook.dev/posts/react-component-context-api-dropzone/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/react-component-context-api-dropzone/</guid>
      <pubDate>Tue, 29 Jul 2025 15:00:00 GMT</pubDate>
      <description>Mantine Dropzone의 내부를 뜯어보니 Context API와 컴파운드 컴포넌트 패턴의 조합이 있었습니다. 같은 패턴으로 기본·허용·거부·로딩 상태별 UI를 자유롭게 갈아끼울 수 있는 DropZone을 직접 만들어봅니다.</description>
      <content:encoded><![CDATA[<blockquote>
<p>평소처럼 Mantine의 Dropzone을 사용하고 있었는데, 문득 내부 구현이 궁금해졌습니다. 어떻게 이렇게 깔끔한 API를 제공할 수 있을까?</p>
<pre><code class="language-tsx">// Mantine Dropzone 사용 중...
&lt;Dropzone onDrop={handleDrop}&gt;
  &lt;Dropzone.Idle&gt;기본 상태&lt;/Dropzone.Idle&gt;
  &lt;Dropzone.Accept&gt;허용 상태&lt;/Dropzone.Accept&gt;
  &lt;Dropzone.Reject&gt;거부 상태&lt;/Dropzone.Reject&gt;
&lt;/Dropzone&gt;
</code></pre>
<p>마침 그때 디자인 팀에서 연락이 왔습니다.</p>
<p><strong>[디자이너]</strong>: &quot;파일 업로드 컴포넌트를 우리 디자인 시스템에 맞게 다시 만들어야 할 것 같아요. Mantine 스타일이 브랜드와 안 맞아서...&quot;</p>
<p><strong>[나]</strong>: &quot;아, 그럼 Dropzone도 자체 구현해야 하는 건가요?&quot;</p>
<p><strong>[디자이너]</strong>: &quot;네, 그리고 상태별로 다른 애니메이션이랑 아이콘도 써야 하고... 기본, 허용, 거부, 로딩 상태 모두 다르게 보여주세요!&quot;</p>
</blockquote>
<p>순간 두 가지 생각이 스쳐갔습니다. <strong>&quot;어차피 다시 만들어야 한다면, Mantine의 내부 구현을 분석해서 더 나은 방식으로 만들어보자!&quot;</strong></p>
<p>그렇게 Mantine의 Dropzone 컴포넌트를 뜯어보다가 <strong>Context API + 컴파운드 컴포넌트 패턴</strong>의 놀라운 조합을 발견했습니다.</p>
<h2>1부: Mantine 분석과 자체 구현의 필요성</h2>
<h3>Mantine Dropzone 분석하기</h3>
<p>먼저 Mantine의 Dropzone이 어떻게 동작하는지 살펴봤습니다. 정말 간단한 API로 복잡한 상태 관리를 숨기고 있더군요.</p>
<pre><code class="language-tsx">// Mantine의 놀라운 API
&lt;Dropzone onDrop={handleDrop}&gt;
  &lt;Dropzone.Idle&gt;파일을 드래그하세요&lt;/Dropzone.Idle&gt;
  &lt;Dropzone.Accept&gt;파일을 놓으세요&lt;/Dropzone.Accept&gt;
  &lt;Dropzone.Reject&gt;지원하지 않는 파일입니다&lt;/Dropzone.Reject&gt;
&lt;/Dropzone&gt;
</code></pre>
<blockquote>
<p><strong>[나]</strong>: &quot;어떻게 이렇게 간단한 API로 복잡한 상태 관리를 할 수 있지?&quot;</p>
</blockquote>
<h3>자체 구현 시 고려사항</h3>
<p>디자인 시스템에 맞게 자체 구현하면서도 Mantine의 좋은 패턴은 그대로 가져가고 싶었습니다. 하지만 처음엔 단순하게 접근했죠.</p>
<pre><code class="language-tsx">// 처음에 생각한 방식 (문제가 많음)
const DropZone = () =&gt; {
  const [dragState, setDragState] = useState&lt;&#x27;idle&#x27; | &#x27;accept&#x27; | &#x27;reject&#x27;&gt;(
    &#x27;idle&#x27;,
  );
  const [loading, setLoading] = useState(false);

  const renderContent = () =&gt; {
    if (loading) {
      return &lt;LoadingSpinner /&gt;;
    }

    if (dragState === &#x27;accept&#x27;) {
      return (
        &lt;div&gt;
          &lt;CheckIcon /&gt;
          &lt;span&gt;파일을 놓으세요&lt;/span&gt;
        &lt;/div&gt;
      );
    }

    if (dragState === &#x27;reject&#x27;) {
      return (
        &lt;div&gt;
          &lt;WarningIcon /&gt;
          &lt;span&gt;지원하지 않는 파일 형식입니다&lt;/span&gt;
        &lt;/div&gt;
      );
    }

    return (
      &lt;div&gt;
        &lt;UploadIcon /&gt;
        &lt;span&gt;파일을 드래그하거나 클릭하세요&lt;/span&gt;
      &lt;/div&gt;
    );
  };

  return (
    &lt;div onDragOver={handleDragOver} onDrop={handleDrop}&gt;
      {renderContent()}
    &lt;/div&gt;
  );
};
</code></pre>
<h2>2부: Context API를 활용한 컴파운드 컴포넌트 설계</h2>
<h3>컴파운드 컴포넌트 패턴이란?</h3>
<p><strong>컴파운드 컴포넌트 패턴</strong>은 여러 컴포넌트가 협력해서 하나의 기능을 구현하는 패턴입니다. HTML의 <code>&lt;select&gt;</code>와 <code>&lt;option&gt;</code>의 관계와 비슷해요.</p>
<pre><code class="language-tsx">// 우리가 목표로 하는 API
&lt;DropZone onDrop={handleDrop}&gt;
  &lt;DropZone.Idle
    icon=&quot;Upload&quot;
    mainMessage=&quot;파일을 드래그하거나 클릭하세요&quot;
    supportMessage=&quot;최대 10MB, JPG, PNG, PDF 지원&quot;
  /&gt;
  &lt;DropZone.Accept mainMessage=&quot;파일을 놓으세요&quot; /&gt;
  &lt;DropZone.Reject
    mainMessage=&quot;지원하지 않는 파일입니다&quot;
    supportMessage=&quot;JPG, PNG, PDF만 업로드 가능합니다&quot;
  /&gt;
&lt;/DropZone&gt;
</code></pre>
<h3>Context API를 통한 상태 공유</h3>
<p><strong>핵심 아이디어는 Context API로 DropZone의 모든 상태를 중앙 관리하고, 컴파운드 컴포넌트들이 이 상태를 구독하는 것입니다.</strong></p>
<pre><code class="language-tsx">import {
  createContext,
  useContext,
  useCallback,
  useState,
  type ReactNode,
} from &#x27;react&#x27;;

type DropzoneStatus = &#x27;idle&#x27; | &#x27;accept&#x27; | &#x27;reject&#x27; | &#x27;loading&#x27;;

type DropzoneContextValue = {
  // 상태
  status: DropzoneStatus;
  files: File[];
  error: string | null;

  // 액션
  setStatus: (status: DropzoneStatus) =&gt; void;
  setFiles: (files: File[]) =&gt; void;
  setError: (error: string | null) =&gt; void;
  resetState: () =&gt; void;

  // 드래그 이벤트 핸들러
  handleDragEnter: (e: DragEvent) =&gt; void;
  handleDragLeave: (e: DragEvent) =&gt; void;
  handleDragOver: (e: DragEvent) =&gt; void;
  handleDrop: (e: DragEvent) =&gt; void;
};

const DropzoneContext = createContext&lt;DropzoneContextValue | null&gt;(null);

export const useDropzoneContext = () =&gt; {
  const context = useContext(DropzoneContext);
  if (!context) {
    throw new Error(&#x27;Dropzone 컴포넌트 내에서만 사용할 수 있습니다.&#x27;);
  }
  return context;
};

type DropzoneProviderProps = {
  children: ReactNode;
  onDrop?: (files: File[]) =&gt; void;
  accept?: string[];
  maxSize?: number;
};

export const DropzoneProvider = ({
  children,
  onDrop,
  accept = [],
  maxSize = 10 * 1024 * 1024, // 10MB
}: DropzoneProviderProps) =&gt; {
  const [status, setStatus] = useState&lt;DropzoneStatus&gt;(&#x27;idle&#x27;);
  const [files, setFiles] = useState&lt;File[]&gt;([]);
  const [error, setError] = useState&lt;string | null&gt;(null);

  const validateFiles = useCallback(
    (fileList: File[]) =&gt; {
      const validFiles: File[] = [];
      let hasError = false;

      fileList.forEach(file =&gt; {
        // 파일 타입 검증
        if (accept.length &gt; 0) {
          const isAccepted = accept.some(acceptType =&gt; {
            if (acceptType.endsWith(&#x27;/*&#x27;)) {
              return file.type.startsWith(acceptType.slice(0, -1));
            }
            return file.type === acceptType;
          });

          if (!isAccepted) {
            setError(`지원하지 않는 파일 형식입니다: ${file.name}`);
            hasError = true;
            return;
          }
        }

        // 파일 크기 검증
        if (file.size &gt; maxSize) {
          setError(`파일 크기가 너무 큽니다: ${file.name}`);
          hasError = true;
          return;
        }

        validFiles.push(file);
      });

      return { validFiles, hasError };
    },
    [accept, maxSize],
  );

  const handleDragEnter = useCallback((e: DragEvent) =&gt; {
    e.preventDefault();
    e.stopPropagation();
    setStatus(&#x27;accept&#x27;);
    setError(null);
  }, []);

  const handleDragLeave = useCallback((e: DragEvent) =&gt; {
    e.preventDefault();
    e.stopPropagation();
    setStatus(&#x27;idle&#x27;);
  }, []);

  const handleDragOver = useCallback((e: DragEvent) =&gt; {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDrop = useCallback(
    (e: DragEvent) =&gt; {
      e.preventDefault();
      e.stopPropagation();

      const droppedFiles = Array.from(e.dataTransfer?.files || []);
      const { validFiles, hasError } = validateFiles(droppedFiles);

      if (hasError) {
        setStatus(&#x27;reject&#x27;);
        return;
      }

      setFiles(validFiles);
      setStatus(&#x27;loading&#x27;);
      onDrop?.(validFiles);
    },
    [validateFiles, onDrop],
  );

  const resetState = useCallback(() =&gt; {
    setStatus(&#x27;idle&#x27;);
    setFiles([]);
    setError(null);
  }, []);

  const value: DropzoneContextValue = {
    status,
    files,
    error,
    setStatus,
    setFiles,
    setError,
    resetState,
    handleDragEnter,
    handleDragLeave,
    handleDragOver,
    handleDrop,
  };

  return (
    &lt;DropzoneContext.Provider value={value}&gt;
      {children}
    &lt;/DropzoneContext.Provider&gt;
  );
};
</code></pre>
<h3>메인 DropZone 컴포넌트 구현</h3>
<pre><code class="language-tsx">import type { ReactNode, HTMLAttributes } from &#x27;react&#x27;;
import { DropzoneProvider, useDropzoneContext } from &#x27;./context&#x27;;
import { Accept, Idle, Reject, Loading } from &#x27;./status&#x27;;
import { dropzoneContainer } from &#x27;./styles&#x27;;

type DropzoneProps = {
  children: ReactNode;
  onDrop?: (files: File[]) =&gt; void;
  accept?: string[];
  maxSize?: number;
  className?: string;
} &amp; Omit&lt;HTMLAttributes&lt;HTMLDivElement&gt;, &#x27;onDrop&#x27;&gt;;

// 내부 컨테이너 컴포넌트 - Context를 사용
const DropzoneContainer = ({
  children,
  className,
  ...restProps
}: Omit&lt;DropzoneProps, &#x27;onDrop&#x27; | &#x27;accept&#x27; | &#x27;maxSize&#x27;&gt;) =&gt; {
  const {
    handleDragEnter,
    handleDragLeave,
    handleDragOver,
    handleDrop,
    status,
    error,
  } = useDropzoneContext();

  const { container } = dropzoneContainer({
    status,
    error: !!error,
  });

  return (
    &lt;div
      className={`${container} ${className || &#x27;&#x27;}`}
      onDragEnter={handleDragEnter}
      onDragLeave={handleDragLeave}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
      {...restProps}
    &gt;
      {children}
    &lt;/div&gt;
  );
};

// 메인 Dropzone 컴포넌트
const Dropzone = ({
  children,
  onDrop,
  accept,
  maxSize,
  ...containerProps
}: DropzoneProps) =&gt; {
  return (
    &lt;DropzoneProvider onDrop={onDrop} accept={accept} maxSize={maxSize}&gt;
      &lt;DropzoneContainer {...containerProps}&gt;{children}&lt;/DropzoneContainer&gt;
    &lt;/DropzoneProvider&gt;
  );
};

// 컴파운드 컴포넌트 연결
Dropzone.Accept = Accept;
Dropzone.Idle = Idle;
Dropzone.Reject = Reject;
Dropzone.Loading = Loading;

export default Dropzone;
</code></pre>
<h3>각 상태별 컴포넌트 구현</h3>
<h4>Idle 상태 컴포넌트</h4>
<pre><code class="language-tsx">import type { ReactNode } from &#x27;react&#x27;;
import { useDropzoneContext } from &#x27;../context&#x27;;
import { statusBaseClasses } from &#x27;../styles&#x27;;
import Icon from &#x27;../../Icon&#x27;;

type IdleProps = {
  icon?: string;
  mainMessage: ReactNode;
  supportMessage?: ReactNode;
  children?: ReactNode;
};

const Idle = ({
  mainMessage,
  supportMessage,
  icon = &#x27;Upload&#x27;,
  children,
}: IdleProps) =&gt; {
  const { status } = useDropzoneContext();

  // idle 상태일 때만 렌더링
  if (status !== &#x27;idle&#x27;) return null;

  const { container, mainText, supportText } = statusBaseClasses({
    status: &#x27;idle&#x27;,
  });
  const IconComponent = Icon[icon as keyof typeof Icon];

  return (
    &lt;div className={container}&gt;
      {children || (
        &lt;&gt;
          &lt;IconComponent className=&quot;dropzone-icon&quot; /&gt;
          &lt;span className={mainText}&gt;{mainMessage}&lt;/span&gt;
          {supportMessage &amp;&amp; (
            &lt;span className={supportText}&gt;{supportMessage}&lt;/span&gt;
          )}
        &lt;/&gt;
      )}
    &lt;/div&gt;
  );
};

export default Idle;
</code></pre>
<h4>Accept 상태 컴포넌트</h4>
<pre><code class="language-tsx">import type { ReactNode } from &#x27;react&#x27;;
import { useDropzoneContext } from &#x27;../context&#x27;;
import { statusBaseClasses } from &#x27;../styles&#x27;;
import Icon from &#x27;../../Icon&#x27;;

type AcceptProps = {
  mainMessage: ReactNode;
  children?: ReactNode;
};

const Accept = ({ mainMessage, children }: AcceptProps) =&gt; {
  const { status } = useDropzoneContext();

  // accept 상태일 때만 렌더링
  if (status !== &#x27;accept&#x27;) return null;

  const { container, mainText } = statusBaseClasses({ status: &#x27;accept&#x27; });

  return (
    &lt;div className={container}&gt;
      {children || (
        &lt;&gt;
          &lt;Icon.CircleCheckFill className=&quot;dropzone-icon&quot; /&gt;
          &lt;span className={mainText}&gt;{mainMessage}&lt;/span&gt;
        &lt;/&gt;
      )}
    &lt;/div&gt;
  );
};

export default Accept;
</code></pre>
<h4>Reject 상태 컴포넌트</h4>
<pre><code class="language-tsx">import type { ReactNode } from &#x27;react&#x27;;
import { useDropzoneContext } from &#x27;../context&#x27;;
import { statusBaseClasses } from &#x27;../styles&#x27;;
import Icon from &#x27;../../Icon&#x27;;

type RejectProps = {
  mainMessage: ReactNode;
  supportMessage?: ReactNode;
  children?: ReactNode;
};

const Reject = ({ mainMessage, supportMessage, children }: RejectProps) =&gt; {
  const { status, error } = useDropzoneContext();

  // reject 상태일 때만 렌더링
  if (status !== &#x27;reject&#x27;) return null;

  const { container, mainText, supportText } = statusBaseClasses({
    status: &#x27;reject&#x27;,
  });

  return (
    &lt;div className={container}&gt;
      {children || (
        &lt;&gt;
          &lt;Icon.CircleWarningFill className=&quot;dropzone-icon&quot; /&gt;
          &lt;span className={mainText}&gt;{error || mainMessage}&lt;/span&gt;
          {supportMessage &amp;&amp; (
            &lt;span className={supportText}&gt;{supportMessage}&lt;/span&gt;
          )}
        &lt;/&gt;
      )}
    &lt;/div&gt;
  );
};

export default Reject;
</code></pre>
<h4>Loading 상태 컴포넌트</h4>
<pre><code class="language-tsx">import type { ReactNode } from &#x27;react&#x27;;
import { useDropzoneContext } from &#x27;../context&#x27;;
import { statusBaseClasses } from &#x27;../styles&#x27;;
import Loader from &#x27;../../Loader&#x27;;

type LoadingProps = {
  mainMessage: ReactNode;
  supportMessage?: ReactNode;
  children?: ReactNode;
};

const Loading = ({ mainMessage, supportMessage, children }: LoadingProps) =&gt; {
  const { status } = useDropzoneContext();

  // loading 상태일 때만 렌더링
  if (status !== &#x27;loading&#x27;) return null;

  const { container, mainText, supportText } = statusBaseClasses({
    status: &#x27;loading&#x27;,
  });

  return (
    &lt;div className={container}&gt;
      {children || (
        &lt;&gt;
          &lt;Loader /&gt;
          &lt;span className={mainText}&gt;{mainMessage}&lt;/span&gt;
          {supportMessage &amp;&amp; (
            &lt;span className={supportText}&gt;{supportMessage}&lt;/span&gt;
          )}
        &lt;/&gt;
      )}
    &lt;/div&gt;
  );
};

export default Loading;
</code></pre>
<h3>Context API + 컴파운드 컴포넌트의 핵심 패턴</h3>
<p>여기서 <strong>가장 중요한 패턴</strong>은 각 컴포넌트가 Context를 구독해서 <strong>자신이 렌더링될 조건을 스스로 판단한다는 것</strong>입니다.</p>
<pre><code class="language-tsx">// 🎯 핵심 패턴: 조건부 렌더링의 분산
const Idle = ({ mainMessage, supportMessage }: IdleProps) =&gt; {
  const { status } = useDropzoneContext(); // Context 구독

  if (status !== &#x27;idle&#x27;) return null; // 자체 조건 판단

  return &lt;div&gt;{/* Idle UI */}&lt;/div&gt;;
};

const Accept = ({ mainMessage }: AcceptProps) =&gt; {
  const { status } = useDropzoneContext(); // Context 구독

  if (status !== &#x27;accept&#x27;) return null; // 자체 조건 판단

  return &lt;div&gt;{/* Accept UI */}&lt;/div&gt;;
};
</code></pre>
<h3>이 패턴의 혁신적인 점</h3>
<h4>1️⃣ 중앙 집중식 상태, 분산형 렌더링</h4>
<pre><code class="language-tsx">// ❌ 기존 방식: 중앙에서 모든 조건 처리
const BadDropzone = () =&gt; {
  const [status, setStatus] = useState(&#x27;idle&#x27;);

  const renderContent = () =&gt; {
    if (status === &#x27;idle&#x27;) return &lt;IdleUI /&gt;;
    if (status === &#x27;accept&#x27;) return &lt;AcceptUI /&gt;;
    if (status === &#x27;reject&#x27;) return &lt;RejectUI /&gt;;
    if (status === &#x27;loading&#x27;) return &lt;LoadingUI /&gt;;
  };

  return &lt;div&gt;{renderContent()}&lt;/div&gt;; // 모든 판단을 여기서!
};

// ✅ 새로운 방식: 각자가 자신의 조건 판단
const GoodDropzone = ({ children }) =&gt; {
  return (
    &lt;DropzoneProvider&gt;
      &lt;div&gt;{children}&lt;/div&gt;
      {/* 조건 판단은 각 컴포넌트가! */}
    &lt;/DropzoneProvider&gt;
  );
};
</code></pre>
<h4>2️⃣ 선언적 API로 의도 명확화</h4>
<pre><code class="language-tsx">// 사용하는 쪽에서 보면 각 상태가 무엇을 하는지 명확
&lt;Dropzone onDrop={handleDrop}&gt;
  &lt;Dropzone.Idle mainMessage=&quot;파일을 드래그하세요&quot; supportMessage=&quot;최대 10MB&quot; /&gt;
  &lt;Dropzone.Accept mainMessage=&quot;파일을 놓으세요&quot; /&gt;
  &lt;Dropzone.Reject mainMessage=&quot;잘못된 파일입니다&quot; /&gt;
  &lt;Dropzone.Loading mainMessage=&quot;업로드 중...&quot; /&gt;
&lt;/Dropzone&gt;
</code></pre>
<p>이런 방식의 핵심 장점:</p>
<ol>
<li><strong>상태 추가 용이성</strong>: 새 상태 컴포넌트만 추가하면 됨</li>
<li><strong>독립적 테스트</strong>: 각 상태별 컴포넌트를 독립적으로 테스트</li>
<li><strong>코드 분할</strong>: 각 상태의 로직이 완전히 분리됨</li>
<li><strong>타입 안전성</strong>: 각 상태별로 다른 props 타입 적용 가능</li>
<li><strong>재사용성</strong>: 특정 상태만 필요한 곳에서 선택적 사용</li>
</ol>
<h2>3부: Panda CSS와 타입 안전성</h2>
<h3>Panda CSS를 활용한 스타일 시스템</h3>
<pre><code class="language-tsx">import { sva } from &#x27;@styles/css&#x27;;

export const dropzoneContainer = sva({
  slots: [&#x27;container&#x27;],
  base: {
    container: {
      position: &#x27;relative&#x27;,
      display: &#x27;flex&#x27;,
      alignItems: &#x27;center&#x27;,
      justifyContent: &#x27;center&#x27;,
      border: &#x27;2px dashed {colors.border.normal}&#x27;,
      rounded: &#x27;md&#x27;,
      minHeight: 200,
      cursor: &#x27;pointer&#x27;,
      bg: &#x27;bg.surface&#x27;,
      transition: &#x27;all 0.2s ease&#x27;,

      &#x27;&amp;:hover&#x27;: {
        borderColor: &#x27;border.hover&#x27;,
        bg: &#x27;bg.hover&#x27;,
      },
    },
  },
  variants: {
    status: {
      idle: {
        container: {
          borderColor: &#x27;border.normal&#x27;,
          bg: &#x27;bg.surface&#x27;,
        },
      },
      accept: {
        container: {
          borderColor: &#x27;status.success&#x27;,
          bg: &#x27;success.light&#x27;,
          &#x27;--icon-color&#x27;: &#x27;{colors.status.success}&#x27;,
        },
      },
      reject: {
        container: {
          borderColor: &#x27;status.error&#x27;,
          bg: &#x27;error.light&#x27;,
          &#x27;--icon-color&#x27;: &#x27;{colors.status.error}&#x27;,
        },
      },
      loading: {
        container: {
          borderColor: &#x27;primary.500&#x27;,
          bg: &#x27;primary.light&#x27;,
          &#x27;--icon-color&#x27;: &#x27;{colors.primary.500}&#x27;,
        },
      },
    },
    error: {
      true: {
        container: {
          borderColor: &#x27;status.error&#x27;,
          bg: &#x27;error.light&#x27;,
        },
      },
    },
  },
  defaultVariants: {
    status: &#x27;idle&#x27;,
    error: false,
  },
});

export const statusBaseClasses = sva({
  slots: [&#x27;container&#x27;, &#x27;mainText&#x27;, &#x27;supportText&#x27;],
  base: {
    container: {
      display: &#x27;flex&#x27;,
      flexDirection: &#x27;column&#x27;,
      alignItems: &#x27;center&#x27;,
      justifyContent: &#x27;center&#x27;,
      textAlign: &#x27;center&#x27;,
      gap: 2,

      &#x27;&amp; .dropzone-icon&#x27;: {
        width: 12,
        height: 12,
        fill: &#x27;var(--icon-color, {colors.text.secondary})&#x27;,
        mb: 3,
      },
    },
    mainText: {
      fontSize: &#x27;lg&#x27;,
      fontWeight: &#x27;semibold&#x27;,
      color: &#x27;text.primary&#x27;,
    },
    supportText: {
      fontSize: &#x27;sm&#x27;,
      color: &#x27;text.secondary&#x27;,
      mt: 1,
    },
  },
  variants: {
    status: {
      idle: {
        container: { color: &#x27;text.secondary&#x27; },
      },
      accept: {
        container: { color: &#x27;status.success&#x27; },
        mainText: { color: &#x27;status.success&#x27; },
      },
      reject: {
        container: { color: &#x27;status.error&#x27; },
        mainText: { color: &#x27;status.error&#x27; },
      },
      loading: {
        container: { color: &#x27;primary.500&#x27; },
        mainText: { color: &#x27;primary.500&#x27; },
      },
    },
  },
});
</code></pre>
<h3>타입 안전성 보장</h3>
<pre><code class="language-tsx">export type BaseProps = {
  mainMessage: ReactNode;
  supportMessage?: ReactNode;
};

// 각 상태별 컴포넌트는 필요한 props만 받도록 제한
type IdleProps = BaseProps &amp; {
  icon: SvgNames; // 아이콘 타입도 제한
  children?: ReactNode;
  status?: &#x27;idle&#x27; | &#x27;reject&#x27;;
};

type AcceptProps = Omit&lt;BaseProps, &#x27;supportMessage&#x27;&gt;; // supportMessage 불필요

type RejectProps = BaseProps; // 모든 메시지 필요
</code></pre>
<h3>실제 사용법</h3>
<pre><code class="language-tsx">const FileUploadPage = () =&gt; {
  const [uploadedFiles, setUploadedFiles] = useState&lt;File[]&gt;([]);

  const handleDrop = async (files: File[]) =&gt; {
    console.log(&#x27;업로드할 파일:&#x27;, files);

    // 파일 업로드 로직
    try {
      await uploadFiles(files);
      setUploadedFiles(files);
    } catch (error) {
      console.error(&#x27;업로드 실패:&#x27;, error);
    }
  };

  return (
    &lt;div className=&quot;upload-container&quot;&gt;
      &lt;Dropzone
        onDrop={handleDrop}
        accept={[&#x27;image/*&#x27;, &#x27;application/pdf&#x27;]}
        maxSize={10 * 1024 * 1024} // 10MB
        className=&quot;custom-dropzone&quot;
      &gt;
        &lt;Dropzone.Idle
          icon=&quot;Upload&quot;
          mainMessage=&quot;파일을 드래그하거나 클릭하세요&quot;
          supportMessage=&quot;최대 10MB, JPG, PNG, PDF 지원&quot;
        /&gt;
        &lt;Dropzone.Accept mainMessage=&quot;파일을 놓으세요!&quot; /&gt;
        &lt;Dropzone.Reject
          mainMessage=&quot;지원하지 않는 파일입니다&quot;
          supportMessage=&quot;JPG, PNG, PDF만 업로드 가능합니다&quot;
        /&gt;
        &lt;Dropzone.Loading
          mainMessage=&quot;파일 업로드 중...&quot;
          supportMessage=&quot;잠시만 기다려주세요&quot;
        /&gt;
      &lt;/Dropzone&gt;

      {uploadedFiles.length &gt; 0 &amp;&amp; (
        &lt;div className=&quot;uploaded-files&quot;&gt;
          &lt;h3&gt;업로드된 파일&lt;/h3&gt;
          {uploadedFiles.map(file =&gt; (
            &lt;div key={file.name}&gt;{file.name}&lt;/div&gt;
          ))}
        &lt;/div&gt;
      )}
    &lt;/div&gt;
  );
};
</code></pre>
<h3>Context 기반 고급 활용</h3>
<pre><code class="language-tsx">const AdvancedFileUpload = () =&gt; {
  const handleDrop = async (files: File[]) =&gt; {
    // Context의 setStatus를 이용해서 수동 상태 제어도 가능
    try {
      await processFiles(files);
    } catch (error) {
      // 에러 발생 시 reject 상태로 수동 변경
    }
  };

  return (
    &lt;Dropzone onDrop={handleDrop}&gt;
      {/* 커스텀 UI도 Context 상태를 활용 가능 */}
      &lt;CustomDropzoneStatus /&gt;

      &lt;Dropzone.Idle mainMessage=&quot;기본 상태&quot; /&gt;
      &lt;Dropzone.Accept mainMessage=&quot;받을 준비 완료&quot; /&gt;
      &lt;Dropzone.Reject mainMessage=&quot;거부&quot; /&gt;
      &lt;Dropzone.Loading mainMessage=&quot;처리 중&quot; /&gt;
    &lt;/Dropzone&gt;
  );
};

// Context를 활용한 커스텀 컴포넌트
const CustomDropzoneStatus = () =&gt; {
  const { status, files, error } = useDropzoneContext();

  return (
    &lt;div className=&quot;custom-status&quot;&gt;
      현재 상태: {status}
      {files.length &gt; 0 &amp;&amp; &lt;span&gt;파일 개수: {files.length}&lt;/span&gt;}
      {error &amp;&amp; &lt;span className=&quot;error&quot;&gt;{error}&lt;/span&gt;}
    &lt;/div&gt;
  );
};
</code></pre>
<h3>실무에서의 Context API 활용의 핵심</h3>
<pre><code class="language-tsx">const FileUploadWithProgress = () =&gt; {
  return (
    &lt;Dropzone onDrop={handleUpload}&gt;
      {/* 여러 컴포넌트가 동일한 상태를 공유 */}
      &lt;UploadProgressBar /&gt; {/* Context에서 files 정보 구독 */}
      &lt;FilePreviewList /&gt; {/* Context에서 files 정보 구독 */}
      &lt;DropzoneStatusDisplay /&gt; {/* Context에서 status 정보 구독 */}
      {/* 기본 상태 컴포넌트들 */}
      &lt;Dropzone.Idle mainMessage=&quot;파일 드래그&quot; /&gt;
      &lt;Dropzone.Accept mainMessage=&quot;놓으세요&quot; /&gt;
      &lt;Dropzone.Reject mainMessage=&quot;잘못된 파일&quot; /&gt;
      &lt;Dropzone.Loading mainMessage=&quot;업로드 중&quot; /&gt;
    &lt;/Dropzone&gt;
  );
};

// 모든 컴포넌트가 Context를 통해 동일한 상태에 접근
const UploadProgressBar = () =&gt; {
  const { files, status } = useDropzoneContext();

  if (status !== &#x27;loading&#x27; || files.length === 0) return null;

  return (
    &lt;div className=&quot;progress-bar&quot;&gt;
      업로드 중: {files.map(f =&gt; f.name).join(&#x27;, &#x27;)}
    &lt;/div&gt;
  );
};

const FilePreviewList = () =&gt; {
  const { files } = useDropzoneContext();

  return (
    &lt;div className=&quot;file-preview&quot;&gt;
      {files.map(file =&gt; (
        &lt;FilePreview key={file.name} file={file} /&gt;
      ))}
    &lt;/div&gt;
  );
};
</code></pre>
<h2>정리하며</h2>
<h3>Keep</h3>
<ul>
<li><strong>Context API 활용</strong>: 상태를 중앙 집중식으로 관리하면서 컴포넌트 간 공유</li>
<li><strong>관심사 분리</strong>: 각 상태별 UI 로직이 독립적인 컴포넌트로 완벽 분리</li>
<li><strong>조건부 렌더링 분산</strong>: 각 컴포넌트가 자신의 표시 조건을 스스로 판단</li>
<li><strong>확장성</strong>: 새로운 상태 추가 시 새 컴포넌트만 만들면 됨</li>
<li><strong>타입 안전성</strong>: TypeScript로 각 상태에 맞는 props와 Context 타입 강제</li>
<li><strong>재사용성</strong>: Context 기반으로 어떤 조합이든 자유롭게 구성 가능</li>
</ul>
<h3>Problem</h3>
<ul>
<li><strong>학습 곡선</strong>: Context API + 컴파운드 컴포넌트 패턴을 팀원들이 이해해야 함</li>
<li><strong>초기 설계 비용</strong>: 단순한 DropZone에 비해 초기 구축 시간이 더 필요</li>
<li><strong>Context Provider 필수</strong>: 반드시 Provider로 감싸야 하는 구조적 제약</li>
<li><strong>성능 고려사항</strong>: Context 값 변경 시 모든 구독 컴포넌트 리렌더링</li>
</ul>
<h3>Try</h3>
<ul>
<li><strong>Context 최적화</strong>: useMemo, useCallback을 활용한 불필요한 리렌더링 방지</li>
<li><strong>Progress Context 추가</strong>: 업로드 진행률을 별도 Context로 분리하여 성능 최적화</li>
<li><strong>애니메이션 통합</strong>: 상태 전환 시 자연스러운 애니메이션 효과</li>
<li><strong>테스트 전략</strong>: Context Provider를 활용한 독립적인 단위 테스트</li>
<li><strong>DevTools 연동</strong>: React DevTools에서 Context 상태 추적 개선</li>
</ul>
<hr/>
<h3>실무에서의 Context API + 컴파운드 컴포넌트 위력</h3>
<blockquote>
<p><strong>[기획자]</strong>: &quot;이제 드래그 상태에서 파일 개수도 보여주면 안 될까요? 그리고 업로드 진행률도...&quot;</p>
<p><strong>[나]</strong>: (예전 같으면 벙쪘을 상황) &quot;네! Context에 files 정보가 있으니까 FileCountDisplay 컴포넌트만 추가하면 됩니다!&quot;</p>
<p><strong>[기획자]</strong>: &quot;오, 그럼 기존 상태들은 영향 없나요?&quot;</p>
<p><strong>[나]</strong>: &quot;전혀 없어요. Context를 구독하는 새 컴포넌트만 추가하면 끝이에요!&quot;</p>
</blockquote>
<pre><code class="language-tsx">// 기획 변경 대응: 단순히 컴포넌트 하나만 추가
&lt;Dropzone onDrop={handleDrop}&gt;
  &lt;FileCountDisplay /&gt; {/* 새로 추가! */}
  &lt;UploadProgressBar /&gt; {/* 새로 추가! */}
  &lt;Dropzone.Idle mainMessage=&quot;드래그하세요&quot; /&gt;
  &lt;Dropzone.Accept mainMessage=&quot;놓으세요&quot; /&gt;
  &lt;Dropzone.Reject mainMessage=&quot;잘못된 파일&quot; /&gt;
  &lt;Dropzone.Loading mainMessage=&quot;업로드 중&quot; /&gt;
&lt;/Dropzone&gt;;

// 새 컴포넌트는 Context만 구독하면 끝
const FileCountDisplay = () =&gt; {
  const { files } = useDropzoneContext();
  return files.length &gt; 0 ? &lt;span&gt;파일 {files.length}개&lt;/span&gt; : null;
};
</code></pre>
<h3>Context API + 컴파운드 컴포넌트 패턴의 진정한 가치</h3>
<p><strong>이건 단순히 코드 분리 문제가 아닙니다.</strong> 이 패턴의 핵심은:</p>
<h4>1. <strong>상태 공유의 투명성</strong></h4>
<ul>
<li>모든 컴포넌트가 동일한 상태를 실시간으로 공유</li>
<li>Props drilling 없이 깊은 계층의 컴포넌트도 상태 접근 가능</li>
<li>상태 변경 시 관련된 모든 UI가 자동으로 동기화</li>
</ul>
<h4>2. <strong>조합형 개발 방식</strong></h4>
<ul>
<li>레고 블록처럼 필요한 컴포넌트만 조합해서 사용</li>
<li>새로운 요구사항 = 새로운 컴포넌트 추가</li>
<li>기존 컴포넌트 수정 없이 기능 확장</li>
</ul>
<hr/>
<p><strong>이제 복잡한 상태 관리가 필요한 모든 컴포넌트에 이 패턴을 적용할 수 있습니다!</strong></p>
<p>Context API의 상태 공유 능력과 컴파운드 컴포넌트의 조합형 개발 방식을 결합하면, 확장 가능하고 유지보수하기 쉬운 컴포넌트 시스템을 구축할 수 있거든요.</p>
<p>특히 파일 업로드처럼 <strong>복잡한 상태 변화와 다양한 UI 조합이 필요한 경우</strong>에는 이런 접근법이 정말 빛을 발합니다!</p>]]></content:encoded>
    </item>
    <item>
      <title>Type Guard를 활용한 여러가지 Button 구현하기</title>
      <link>https://blog.sangwook.dev/posts/react-component-type-guard-button/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/react-component-type-guard-button/</guid>
      <pubDate>Sun, 29 Jun 2025 15:00:00 GMT</pubDate>
      <description>solid에만 있는 assistive, primary만 있는 text. 피그마의 이 불규칙한 조합을 Button 하나로 받으면서도 잘못된 variant는 컴파일 단계에서 막는 Type Guard 설계를 정리했습니다.</description>
    </item>
    <item>
      <title>15줄에서 2줄로: useSyncExternalStore 기반 React Toast 시스템 설계법</title>
      <link>https://blog.sangwook.dev/posts/react-component-toast/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/react-component-toast/</guid>
      <pubDate>Sun, 29 Jun 2025 15:00:00 GMT</pubDate>
      <description>useState와 useEffect로 15줄씩 반복되던 Toast 호출을 2줄로 줄였습니다. useSyncExternalStore로 상태를 컴포넌트 밖 스토어로 빼내, 앱 어디에서든 타입 안전하게 부를 수 있게 만드는 설계.</description>
    </item>
    <item>
      <title>개발자에서 기여자로: 기술 블로그와 오픈소스가 바꾼 나의 2025년 상반기</title>
      <link>https://blog.sangwook.dev/posts/2025-mid-year-review/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/2025-mid-year-review/</guid>
      <pubDate>Mon, 16 Jun 2025 15:00:00 GMT</pubDate>
      <description>나만 알던 지식을 밖으로 꺼내기 시작한 반 년. 첫 글이 일주일 만에 500회를 넘던 순간부터 Mantine에 PR을 보내기까지, 개발자에서 기여자로 옮겨간 2025년 상반기를 글과 오픈소스 두 축으로 정리했습니다.</description>
    </item>
    <item>
      <title>[Typescript로 설계하는 프로젝트] &quot;같은 로직 또 복사했어요?&quot; Domain 모델로 책임 분리하기</title>
      <link>https://blog.sangwook.dev/posts/typescript-project-domain-design/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/typescript-project-domain-design/</guid>
      <pubDate>Sat, 14 Jun 2025 15:00:00 GMT</pubDate>
      <description>Service에 사용자 관련 함수가 수백 줄씩 쌓이기 시작하면 그게 신호입니다. Type에서 출발해 Domain 모델로 점진적으로 키워가는 과정과, 함수형과 객체지향 중 무엇을 고를지 판단하는 기준을 예제 코드와 함께 정리했습니다.</description>
    </item>
    <item>
      <title>[Typescript로 설계하는 프로젝트] &quot;원래 있던 기능이니 금방 하시죠?&quot; 당하지 않는 Service Layer 설계 전략</title>
      <link>https://blog.sangwook.dev/posts/typescript-project-service-design/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/typescript-project-service-design/</guid>
      <pubDate>Sat, 07 Jun 2025 15:00:00 GMT</pubDate>
      <description>&apos;원래 있던 기능이니 금방 하시죠?&apos; UI 컴포넌트에 눌어붙은 비즈니스 로직을 Service Layer로 떼어내는 이야기입니다. 기획이 갑자기 바뀌어도 화면을 뜯지 않고 재사용으로 대응할 수 있는 구조를 단계별로 만들어봅니다.</description>
    </item>
    <item>
      <title>[Typescript로 설계하는 프로젝트] &quot;원래 있던 기능이니 금방 하시죠?&quot; 당하지 않는 Service Layer 설계 전략 With Di</title>
      <link>https://blog.sangwook.dev/posts/typescript-project-service-di-design/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/typescript-project-service-di-design/</guid>
      <pubDate>Sat, 07 Jun 2025 15:00:00 GMT</pubDate>
      <description>같은 Service Layer를 이번엔 의존성 주입으로 다시 짭니다. 서비스가 HTTP 클라이언트를 직접 만들지 않고 밖에서 주입받게 하면 결합도가 내려가고 목 객체만 갈아끼워 서버 없이도 테스트할 수 있습니다. 본편과 같은 문제를 다른 도구로 푸는 편.</description>
    </item>
    <item>
      <title>[Typescript로 설계하는 프로젝트] Type 설계의 시작: 견고한 서버 API Type 설계하기</title>
      <link>https://blog.sangwook.dev/posts/typescript-project-api-design/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/typescript-project-api-design/</guid>
      <pubDate>Sat, 31 May 2025 15:00:00 GMT</pubDate>
      <description>&apos;User 스키마에서 name이 빠질 것 같아요.&apos; 이 한마디에 흩어진 타입을 전부 찾아 헤매지 않으려면 서버 API 응답 스키마부터 구조적으로 설계해야 합니다. 타입 설계의 첫 단계를 실제 코드로 하나씩 따라가 봅니다.</description>
    </item>
    <item>
      <title>[Typescript로 설계하는 프로젝트] Type 설계의 시작: 견고한 서버 API Type 설계하기 With DI</title>
      <link>https://blog.sangwook.dev/posts/typescript-project-api-di-design/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/typescript-project-api-di-design/</guid>
      <pubDate>Sat, 31 May 2025 15:00:00 GMT</pubDate>
      <description>제네릭 HTTP 클래스를 인터페이스 뒤로 감추고 밖에서 주입합니다. API 레이어가 통신 구현을 모르게 되면 fetch든 axios든 갈아끼울 수 있고, 서버가 없어도 목 하나로 테스트가 끝납니다. DI 관점의 API 설계.</description>
    </item>
    <item>
      <title>[Typescript로 설계하는 프로젝트] 당신의 Type, 어디까지 연결되어 있나요?</title>
      <link>https://blog.sangwook.dev/posts/typescript-project-design/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/typescript-project-design/</guid>
      <pubDate>Sun, 04 May 2025 15:00:00 GMT</pubDate>
      <description>타입스크립트로 옮겼는데도 undefined is not a function이 끊이지 않는다면 문제는 문법이 아니라 설계입니다. 서버 응답부터 화면까지 타입이 끊기지 않고 이어지는 구조를, 실제로 겪은 버그와 함께 이야기합니다.</description>
    </item>
    <item>
      <title>pnpm 10 업그레이드 후 ESLint 설정이 사라졌어요?! (feat. 호이스팅)</title>
      <link>https://blog.sangwook.dev/posts/pnpm-eslint-not-working/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/pnpm-eslint-not-working/</guid>
      <pubDate>Wed, 30 Apr 2025 15:00:00 GMT</pubDate>
      <description>pnpm 9에서 10으로 올린 뒤 모노레포의 ESLint가 조용히 멈췄습니다. 원인은 설정 문법이 아니라 호이스팅 정책 변경이었습니다. pnpm 이슈 트래커를 따라가 원인을 확인하고, 설정 한 줄로 되돌리기까지의 기록입니다.</description>
    </item>
    <item>
      <title>오픈소스 첫 발걸음: Mantine PR 기여 후기</title>
      <link>https://blog.sangwook.dev/posts/first-open-source-contribution/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/first-open-source-contribution/</guid>
      <pubDate>Mon, 21 Apr 2025 15:00:00 GMT</pubDate>
      <description>디자이너 시안에 필요한 옵션이 Mantine에 없었습니다. 라이브러리 코드를 통째로 내려받아 Slider를 분석하고 직접 기능을 구현해 PR을 보내고 머지되기까지, 첫 오픈소스 기여의 막막함과 그 과정을 그대로 적었습니다.</description>
    </item>
    <item>
      <title>🚨3분만에 Next.js 서비스 장애 대응 대응하기: GitHub Actions + AWS ECS + CodeDeploy 롤백 시스템</title>
      <link>https://blog.sangwook.dev/posts/aws-ecs-rollback/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/aws-ecs-rollback/</guid>
      <pubDate>Wed, 02 Apr 2025 15:00:00 GMT</pubDate>
      <description>배포 두 시간 뒤 장애 신고가 들어왔고 되돌리는 데 30분이 걸렸습니다. GitHub Actions와 AWS CodeDeploy로 배포 버전을 골라 3분 만에 롤백하는 파이프라인을 만들어 그 30분을 없앤 과정입니다.</description>
    </item>
    <item>
      <title>ecs와 code deploy를 활용한 next.js 배포하기</title>
      <link>https://blog.sangwook.dev/posts/next-js-ecs-deploy/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/next-js-ecs-deploy/</guid>
      <pubDate>Mon, 31 Mar 2025 15:00:00 GMT</pubDate>
      <description>도커라이징한 Next.js를 AWS ECS에 올리고 CodeDeploy로 배포합니다. GitHub Actions 워크플로우의 실행 조건부터 태스크 정의와 ECR 푸시까지, 실제로 쓰는 설정을 하나씩 따라가며 정리했습니다.</description>
    </item>
    <item>
      <title>Turborepo 에서 next.js 도커라이징 하기</title>
      <link>https://blog.sangwook.dev/posts/turborepo-next.js-docker/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/turborepo-next.js-docker/</guid>
      <pubDate>Fri, 21 Mar 2025 15:00:00 GMT</pubDate>
      <description>모노레포의 웹 서비스 세 개를 각각의 이미지로 굽습니다. Next.js standalone 모드와 Turborepo의 --docker 옵션을 조합해 node_modules를 걷어내고 이미지를 가볍게 만드는 방법을 정리했습니다.</description>
    </item>
    <item>
      <title>Next.js로 우아하게 Error 핸들링하기</title>
      <link>https://blog.sangwook.dev/posts/nextjs-error/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/nextjs-error/</guid>
      <pubDate>Sun, 02 Mar 2025 15:00:00 GMT</pubDate>
      <description>try-catch와 ErrorBoundary 다음은 Next.js의 error.js입니다. App Router가 경계마다 error.tsx를 심는 방식을 실제 코드로 따라가며 global-error와의 역할 차이를 정리했습니다.</description>
    </item>
    <item>
      <title>React로 우아하게 Error 핸들링 하기</title>
      <link>https://blog.sangwook.dev/posts/react-error/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/react-error/</guid>
      <pubDate>Sat, 01 Mar 2025 15:00:00 GMT</pubDate>
      <description>ErrorBoundary는 렌더링 중에 난 에러만 잡습니다. 클릭 핸들러와 비동기 요청에서 난 에러를 경계까지 끌어올리는 방법, 그리고 의도한 에러와 의도치 않은 에러를 나누어 다루는 기준을 예제 코드와 함께 정리했습니다.</description>
    </item>
    <item>
      <title>javascript로 우아하게 Error 핸들링하기</title>
      <link>https://blog.sangwook.dev/posts/javascript-error/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/javascript-error/</guid>
      <pubDate>Wed, 26 Feb 2025 15:00:00 GMT</pubDate>
      <description>try-catch부터 async/await, 에러 래핑과 커스텀 에러 클래스까지. 에러를 여기서 잡을지 상위로 넘길지를 매번 감으로 정하지 않도록, 동기와 비동기 상황별 처리 패턴을 테스트 코드와 함께 정리했습니다.</description>
    </item>
    <item>
      <title>미들웨어에서 auth 적용하기</title>
      <link>https://blog.sangwook.dev/posts/next-middleware/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/next-middleware/</guid>
      <pubDate>Tue, 25 Feb 2025 15:00:00 GMT</pubDate>
      <description>레이아웃에서 인증을 확인하면 이미 늦습니다. Next.js Middleware로 요청 단계에서 로그인 여부와 역할을 판별해, 프라이빗 페이지가 잠깐 보였다 사라지는 깜빡임을 없앤 과정과, 쿠키·캐싱 전략까지 정리했습니다.</description>
    </item>
    <item>
      <title>Panda CSS 1년 사용기</title>
      <link>https://blog.sangwook.dev/posts/panda-css-1year-review/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/panda-css-1year-review/</guid>
      <pubDate>Sun, 16 Feb 2025 15:00:00 GMT</pubDate>
      <description>빌드 타임에 CSS를 정적으로 추출하는 Panda CSS를 1년 썼습니다. Tailwind·styled-components와 무엇이 어떻게 다른지, 모노레포 디자인 시스템에 쓸 때의 장단점과 효율적인 학습 순서를 정리했습니다.</description>
    </item>
    <item>
      <title>컴포넌트에 인터렉션 레이어 이쁘게 씌우기</title>
      <link>https://blog.sangwook.dev/posts/adding-interaction-layers-to-components/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/adding-interaction-layers-to-components/</guid>
      <pubDate>Sun, 09 Feb 2025 15:00:00 GMT</pubDate>
      <description>hover·focus·press마다 색이 정확히 달라져야 하는데 opacity로는 의도한 색이 안 나오고, div를 덮으면 클릭 이벤트가 죽습니다. 가상 요소 ::after로 두 문제를 한 번에 푼 방법을 정리했습니다.</description>
    </item>
    <item>
      <title>디자인 시스템 시작해보기</title>
      <link>https://blog.sangwook.dev/posts/design-system-start/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/design-system-start/</guid>
      <pubDate>Sat, 01 Feb 2025 15:00:00 GMT</pubDate>
      <description>디자이너가 제각각이던 다섯 개 서비스의 디자인을 한 벌로 모았습니다. Turborepo·Mantine·Panda CSS로 시스템을 세우고 디자인 토큰부터 컴포넌트 제작, 문서화와 협업 프로세스 확립까지 하나씩 만들어간 기록입니다.</description>
    </item>
    <item>
      <title>FSD 간단하게 시작해보기</title>
      <link>https://blog.sangwook.dev/posts/simple-fsd-start/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/simple-fsd-start/</guid>
      <pubDate>Mon, 20 Jan 2025 15:00:00 GMT</pubDate>
      <description>components, apis, utils로 나눈 폴더는 프로젝트가 커지면 서로 엉킵니다. 레이어·슬라이스·세그먼트로 기능 중심으로 다시 나누는 FSD를, 실제 팀 프로젝트에 적용해 본 사람의 눈높이에서 정리했습니다.</description>
    </item>
    <item>
      <title>서버 의존성을 줄여보자 (Feat : 클린 아키텍처)</title>
      <link>https://blog.sangwook.dev/posts/reduce-server-dependency-clean-architecture/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/reduce-server-dependency-clean-architecture/</guid>
      <pubDate>Sun, 12 Jan 2025 15:00:00 GMT</pubDate>
      <description>페이지 맞춤형 API에 화면이 묶이면 요구사항이 바뀔 때마다 뷰까지 흔들립니다. 클린 아키텍처를 팀이 감당할 만큼만 덜어내 우리 팀이 쓸 수 있는 형태로 다시 설계하고, 실제 코드에 적용해 팀을 설득한 경험을 정리했습니다.</description>
    </item>
    <item>
      <title>도전과 성장, 그리고 배움으로 가득 찬 2024년 회고</title>
      <link>https://blog.sangwook.dev/posts/2024-retrospect/</link>
      <guid isPermaLink="true">https://blog.sangwook.dev/posts/2024-retrospect/</guid>
      <pubDate>Sun, 22 Dec 2024 15:00:00 GMT</pubDate>
      <description>동료들의 공백으로 역할이 커진 1분기부터 연말까지, 2024년을 분기별 Keep·Problem·Try로 돌아봤습니다. TypeScript 전환과 배포 시스템 리팩터링에서 배운 것, 그리고 소통이 없어 놓쳐서 아팠던 것들.</description>
    </item>
  </channel>
</rss>