<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[테드풀의 한 입 지식]]></title><description><![CDATA[가볍게 정리하는 기록들입니다. 피드백 환영합니다❤
⚠글의 내용이 틀릴 수 있습니다! 죄송합니다 😂]]></description><link>https://ted-projects.com</link><generator>RSS for Node</generator><lastBuildDate>Sat, 12 Sep 2026 11:06:38 GMT</lastBuildDate><atom:link href="https://ted-projects.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[프론트엔드는 경계를 다루는 직무가 되어간다]]></title><description><![CDATA[프론트엔드는 원래도 복잡했습니다. 브라우저마다 다른 렌더링, 상태 동기화, 성능, 접근성 — 화면 하나 제대로 만드는 데 신경 쓸 게 늘 많았습니다. 다만 그 복잡함은 대체로 한 방향을 향했습니다. 이 UI를 어떻게 만들까, 상태를 어떻게 관리할까, 클릭하면 무엇이 바뀌어야 할까.
그런데 요즘 질문의 축이 하나 더 늘고 있습니다. 이 UI는 서버에서 먼저 ]]></description><link>https://ted-projects.com/web-frontend-future</link><guid isPermaLink="true">https://ted-projects.com/web-frontend-future</guid><category><![CDATA[front end]]></category><category><![CDATA[Backend for frontend]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Mon, 20 Jul 2026 07:09:41 GMT</pubDate><content:encoded><![CDATA[<p>프론트엔드는 원래도 복잡했습니다. 브라우저마다 다른 렌더링, 상태 동기화, 성능, 접근성 — 화면 하나 제대로 만드는 데 신경 쓸 게 늘 많았습니다. 다만 그 복잡함은 대체로 한 방향을 향했습니다. 이 UI를 어떻게 만들까, 상태를 어떻게 관리할까, 클릭하면 무엇이 바뀌어야 할까.</p>
<p>그런데 요즘 질문의 축이 하나 더 늘고 있습니다. 이 UI는 서버에서 먼저 그릴까? 이 부분은 클라이언트 인터랙션이 꼭 필요한가? 이 데이터는 서버가 가져오는 게 맞나? 이 규칙의 주인은 누구인가? 이 값은 캐시해도 되는가? 이 값이 브라우저에 내려가도 되는가?</p>
<p>David Poblador의 <a href="https://davidpoblador.com/deep-dives/the-descent/">「The Descent — What Happened to the Frontend While You Weren't Watching」</a>을 읽고 꼬리에 꼬리를 문 질문과 답변 끝에 남은 결론은 하나였습니다. 프론트엔드는 화면을 그리는 직무에서 경계(boundary)를 다루는 직무로 바뀌어 가고 있습니다.</p>
<h2>복잡함은 우연이 아니었다</h2>
<p>이 글이 복기하는 역사는 익숙합니다. 페이지 일부만 바꾸고 싶어 jQuery가 나왔고, UI 상태 동기화가 지옥이라 React가 나왔고, JSX 때문에 Babel과 webpack이, 빌드가 느려서 Vite가, SPA의 빈 화면과 SEO 문제 때문에 SSR과 meta-framework가 나왔습니다. 저자의 표현을 빌리면 모든 도구는 "실제 상처 위에 생긴 흉터"입니다.</p>
<p>그리고 저자는 최신 흐름을 이렇게 진단합니다. 이제 업계는 다시 서버에서 HTML을 렌더링하고, 브라우저로 보내는 JS를 최소화하고, 웹 플랫폼 자체를 활용하는 방향으로 움직이고 있다고요. Astro, islands architecture, React Server Components, htmx가 그 증거로 등장합니다.</p>
<p>한 가지 짚고 넘어가야 할 게 있습니다. 요즘 프론트엔드의 변화를 흔히 AI 탓으로 돌리지만, 여기서 AI의 자리는 생각보다 좁습니다. 이제 프론트 코드의 상당 부분을 LLM이 만드는 건 맞습니다. 하지만 그 코드조차 React와 빌드 체인과 렌더링 규칙이라는 앞선 레이어를 그대로 전제합니다. AI는 이 복잡한 스택을 재편한 게 아니라 그 위에 얹혔을 뿐입니다. 그래서 이 글이 다루는 변화의 실제 동력은 AI가 아니라, 렌더링을 어디서 하느냐가 다시 움직이고 있다는 사실입니다. 이 점을 놓치면 다음 이야기가 엉뚱하게 들립니다.</p>
<h2>"회귀"가 아니라 보정 운동이다</h2>
<p>여기서 잠깐 멈춰야 합니다. 이 진단을 "이제 다들 HTML만 쓴다", "클라이언트 프레임워크 시대는 끝났다"로 읽으면 과장입니다. 현업은 여전히 React와 빌드 체인과 meta-framework라는 유산 위에서 돌아갑니다. 지난 20년의 레이어가 사라진 게 아닙니다.</p>
<p>실제로 일어나는 일은 주류 교체가 아니라 <strong>주류 스택 내부의 보정 운동</strong>입니다. 같은 스택 안에서 JS 총량을 줄이고, 서버에서 더 처리하고, 인터랙션이 꼭 필요한 부분에만 JS를 붙이는 쪽으로 무게중심이 옮겨가고 있습니다.</p>
<p>"JS를 최소화한다면서 일부를 hydrate한다는 건 모순 아닌가"라는 의문도 같은 오해에서 나옵니다. 여기서 최소화는 JS를 0으로 만든다는 뜻이 아닙니다. 예전 전체 SPA 방식보다 훨씬 적은 JS만 보내고, 나머지는 서버가 만든 HTML로 처리한다는 뜻입니다. selective hydration은 hydration을 없애는 게 아니라 <strong>hydration 대상을 줄이는</strong> 전략입니다.</p>
<p>물론 이 전략이 어디서나 통하는 건 아닙니다. 블로그, 문서 사이트, 상품 상세 페이지처럼 읽기 중심인 곳에서는 강력하지만, 복잡한 SaaS 대시보드나 드래그 앤 드롭 편집기, 실시간 협업 툴에서는 효과가 제한적입니다. 보편 법칙이 아니라 페이지 성격에 따라 강하게 먹히는 전략입니다.</p>
<h2>백엔드의 대세 전환이 아니라 프론트엔드의 관할 확장이다</h2>
<p>그럼 서버로 무게가 옮겨간다는 건 JS/TS 프레임워크가 백엔드를 접수한다는 뜻일까요? 아닙니다. Next.js가 Go나 Java로 짠 코어 백엔드를 대체하는 일은 일어나지 않고 있습니다.</p>
<p>현실의 많은 서비스는 세 층으로 나뉩니다. 코어 백엔드(Go, Java, Kotlin, Rust 등), UI에 인접한 서버 레이어(Next.js, Nuxt, SvelteKit, Astro가 담당하는 BFF/SSR/렌더링), 그리고 브라우저 레이어. 프론트 서버는 백엔드 전체를 대체하는 계층이 아니라 UI에 가까운 렌더링·조합 계층입니다.</p>
<p>즉 이 흐름의 본질은 백엔드의 주류 전환이 아니라 <strong>프론트엔드의 관할 확장</strong>입니다. 프론트엔드 개발자가 브라우저 코드만 다루던 데서, 렌더링 서버와 데이터 조합 계층과 간단한 서버 오케스트레이션까지 다루게 되는 것입니다.</p>
<p>다만 이건 어디까지나 방향이지 현재의 보편이 아닙니다. BFF나 렌더링 서버를 직접 운영하는 프론트엔드 개발자는 아직 소수고, 대부분의 현업은 여전히 브라우저 코드가 중심입니다. 이 글에서 말하는 변화는 이미 완료된 사실이 아니라, 최전선에서 시작해 서서히 번지고 있는 흐름으로 읽어야 합니다.</p>
<p>예전의 관할과 지금의 관할을 나란히 놓으면 이렇습니다. 달라진 건 상자가 아니라 선입니다. 예전 프론트엔드에게 경계란 API 호출 하나뿐이었지만, 지금은 네 개의 경계선 위에서 판단을 내립니다.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62eb589e16f9480a9dac77d0/5237cb41-38d8-4e0a-882e-b3e0abd62b2c.svg" alt="" style="display:block;margin:0 auto" />

<p>그리고 이 확장된 관할은 실제로 위험합니다. React 팀은 2025년 말 <a href="https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components">React Server Components의 인증 없는 원격 코드 실행 취약점(CVE-2025-55182)</a>을 공지했고, SvelteKit도 <a href="https://svelte.dev/blog/cves-affecting-the-svelte-ecosystem">SSRF와 remote functions 관련 문제</a>를 인정했습니다. 주목할 대목은 깨진 곳이 "일부만 hydrate한다"는 아이디어가 아니라 그걸 구현한 서버 프로토콜 계층이었다는 점입니다. 서버-클라이언트 경계를 자동으로 이어주는 마법 계층은 원래 공격면이 넓습니다. 그래서 partial hydration은 남되, server actions 같은 서버 RPC 계층은 보안에 민감한 조직일수록 더 보수적으로 다뤄질 가능성이 큽니다.</p>
<h2>가장 현실적인 위험: 그림자 백엔드가 된 BFF</h2>
<p>관할 확장에서 가장 먼저 터지는 사고는 보안 취약점이 아니라 이것입니다. <strong>어설픈 BFF가 코어 백엔드 위에 얹힌 그림자 백엔드가 되는 것.</strong></p>
<p>BFF가 잘못 커지면 비즈니스 규칙이 코어 백엔드가 아니라 BFF에 복제됩니다. 할인, 권한, 가격, 주문 가능 여부 같은 규칙이 두 군데로 찢어지고, 화면과 실제 시스템 상태가 어긋나고, 캐시가 정합성과 권한을 깨뜨리고, 장애가 나면 책임 소재가 흐려집니다. 코어 백엔드가 공들여 지켜온 것들이 그 위에 얹힌 어설픈 레이어 하나로 무너질 수 있습니다.</p>
<p>경계선은 명확합니다. BFF는 조립기여야 하고, 판정자가 되면 안 됩니다.</p>
<table>
<thead>
<tr>
<th>BFF가 해도 되는 일 (조립기)</th>
<th>BFF가 하면 위험한 일 (판정자)</th>
</tr>
</thead>
<tbody><tr>
<td>화면용 데이터 조합</td>
<td>결제·주문·환불 규칙 결정</td>
</tr>
<tr>
<td>포맷 변환</td>
<td>최종 권한 판정</td>
</tr>
<tr>
<td>page-level prefetch</td>
<td>재고·가격·한도 계산의 원본 로직</td>
</tr>
<tr>
<td>SEO 메타데이터 조립</td>
<td>감사로그가 필요한 mutation</td>
</tr>
<tr>
<td>세션·locale·A/B 실험 같은 UI 문맥</td>
<td>강한 정합성이 필요한 상태 전이</td>
</tr>
</tbody></table>
<h2>그래서 이제 무엇을 알아야 하나</h2>
<p>이 경계 판단을 하려면 프론트엔드 개발자에게도 최소한의 시스템 감각이 필요해졌습니다. 깊은 백엔드 전문가가 되라는 뜻이 아닙니다. 다음 다섯 영역에 대한 판단력이면 됩니다.</p>
<ul>
<li><p><strong>웹 실행 모델</strong> — 브라우저에서만 가능한 것과 서버에서만 가능한 것, SSR/CSR/hydration의 차이</p>
</li>
<li><p><strong>보안과 신뢰 경계</strong> — 브라우저는 신뢰할 수 없고, 최종 권한 판정은 서버 책임이며, secret은 절대 클라이언트에 내려가면 안 된다</p>
</li>
<li><p><strong>로직 소유권</strong> — 돈·권한·상태 전이는 누가 소유하는가, 화면용 표현 규칙과 비즈니스 규칙의 차이</p>
</li>
<li><p><strong>성능 모델</strong> — 읽기 중심인지 상호작용 중심인지, 캐시 가능한 데이터인지, 서버 조합이 왕복을 줄이는지</p>
</li>
<li><p><strong>API 기본기</strong> — 인증과 인가의 차이, idempotency, stale data와 eventual consistency</p>
</li>
</ul>
<p>UI 구현 능력이 덜 중요해졌다는 말이 아닙니다. 그 위에 이 다섯 가지 판단이 얹혔다는 말입니다.</p>
<p>판단 흐름을 그림으로 정리하면 이렇습니다. 새 기능을 만들 때 신경 써야 할 경계 지점들입니다.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62eb589e16f9480a9dac77d0/025e24d3-d0af-4855-a374-8d2d6ce8366f.svg" alt="" style="display:block;margin:0 auto" />

<h2>배포는 쉬워졌지만 엔지니어링은 쉬워지지 않았다</h2>
<p>마지막 반전이 하나 남아 있습니다. 이렇게 복잡해졌지만 배포는 쉬워지지 않았느냐고 물을 수 있습니다. Git에 푸시하면 preview URL이 나오고, CDN과 HTTPS가 기본으로 따라옵니다.</p>
<p>맞습니다. 하지만 쉬워진 건 정확히 <strong>배포 UX</strong>까지입니다. 빌드 체인, client/server 분리, 캐시와 재검증 판단, 의존성 취약점 관리, 관측 가능성, 프론트 서버와 코어 백엔드 사이의 책임 분리 — 이것들은 여전히 어렵거나 오히려 더 어려워졌습니다. 복잡성은 사라진 게 아니라 다른 위치로 이동했습니다.</p>
<blockquote>
<p>FTP는 불편했지만 단순했고, 지금은 배포는 편하지만 시스템은 훨씬 덜 단순합니다.</p>
</blockquote>
<h2>흉터는 지워지지 않는다, 관할이 넓어질 뿐</h2>
<p>「The Descent」의 핵심 통찰은 유효합니다. 프론트엔드의 복잡함은 우연한 혼돈이 아니라 실제 문제를 해결해온 결과라는 것. 다만 그 끝에 붙은 "옛 웹으로의 회귀"는 절반만 맞습니다. 일어나고 있는 건 회귀가 아니라 보정이고, 축소가 아니라 확장입니다.</p>
<p>그 확장의 무게는 결국 사람에게 옵니다. 프론트엔드 개발자는 이제 렌더링 위치, client/server 경계, 데이터 조합 위치, BFF의 책임 범위, 보안과 정합성의 경계까지 조금씩 판단하기 시작했습니다. 화면을 그리는 직무에서 경계를 다루는 직무로 — 이것이 우리가 보지 않는 사이 프론트엔드에 일어나고 있는 일입니다.</p>
]]></content:encoded></item><item><title><![CDATA[TypeScript 7.0 beta 적용 및 성능 비교]]></title><description><![CDATA[Microsoft가 TypeScript 7.0 beta를 공개했습니다. Go로 포팅한 네이티브 컴파일러고 "약 10배 빠르다"는 게 광고 문구입니다. 그 숫자가 실제 코드베이스에서도 나오는지, 따라붙는 호환성 비용은 뭔지 직접 돌려봤습니다.
대상 프로젝트는 React 19 + Vite 5로 굴리는 SPA고 .ts/.tsx 파일은 604개, composite]]></description><link>https://ted-projects.com/typescript-7-0-beta</link><guid isPermaLink="true">https://ted-projects.com/typescript-7-0-beta</guid><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Mon, 27 Apr 2026 02:20:29 GMT</pubDate><content:encoded><![CDATA[<p>Microsoft가 <a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0-beta/">TypeScript 7.0 beta</a>를 공개했습니다. Go로 포팅한 네이티브 컴파일러고 "약 10배 빠르다"는 게 광고 문구입니다. 그 숫자가 실제 코드베이스에서도 나오는지, 따라붙는 호환성 비용은 뭔지 직접 돌려봤습니다.</p>
<p>대상 프로젝트는 React 19 + Vite 5로 굴리는 SPA고 <code>.ts/.tsx</code> 파일은 604개, <code>composite</code>와 project references 구조입니다. 측정 환경은 Apple M4 10코어, pnpm 10.11입니다.</p>
<h2>설치 - 기존 5.x와 병렬 운영</h2>
<p>7.0 beta는 정식 <code>typescript</code> 패키지가 아니라 별도 패키지로 풀렸습니다.</p>
<pre><code class="language-bash">pnpm add -D @typescript/native-preview@beta
</code></pre>
<p>CLI는 <code>tsgo</code>로 깔립니다. 정식 7.0 GA가 나오면 <code>typescript</code> 패키지로 다시 합쳐지고 CLI도 <code>tsc</code>로 돌아갈 예정입니다. 그때까진 <strong>기존</strong> <code>typescript@5.8.3</code><strong>은 그대로 두고 tsgo만 옆에 깔아 비교</strong>하는 게 가장 안전한 선택입니다.</p>
<h2>호환성 — 무엇이 깨졌나</h2>
<p>tsconfig를 손 안 대고 tsgo로 그냥 돌리면 컴파일러 옵션 에러 두 개가 떨어집니다.</p>
<pre><code class="language-plaintext">tsconfig.app.json(26,5): error TS5102: Option 'baseUrl' has been removed.
  Use '"paths": {"*": ["./src/*"]}' instead.
tsconfig.app.json(28,15): error TS5090: Non-relative paths are not allowed.
  Did you forget a leading './'?
</code></pre>
<p>TS 7은 <code>baseUrl</code>을 깔끔하게 빼버렸습니다. <code>paths</code>의 non-relative 매핑도 함께 막혔습니다. 즉</p>
<pre><code class="language-jsonc">// Before (TS 5)
"baseUrl": "src",
"paths": { "@/*": ["*"] }

// After (TS 7)
"paths": { "@/*": ["./src/*"] }
</code></pre>
<p>이 변경은 TS 5에서도 그대로 동작합니다. 깨질 게 없는 정리 작업입니다.</p>
<p>다만 코드 레벨에서 한 군데 걸렸습니다. <code>baseUrl: "src"</code>에 기대고 있던 bare specifier 임포트가 딱 한 곳 남아 있었습니다.</p>
<pre><code class="language-ts">// src/components/Tooltip/Tooltip.tsx
import Button from 'components/Button/Button';  // ❌ TS 7에서 해결 불가
</code></pre>
<p>다른 곳은 모두 <code>@/components/Button/Button</code> 형태 alias로 통일돼 있는데 이 파일만 옛날 방식이 살아 있었습니다. baseUrl이 빠지면서 그동안 가려져 있던 화석이 튀어나온 셈입니다.</p>
<p>원본 코드는 그대로 두고 비교만 돌릴 거라서 tsgo 전용 tsconfig 쪽에 임시 paths 매핑을 추가했습니다.</p>
<pre><code class="language-jsonc">"paths": {
  "@/*": ["./src/*"],
  "components/*": ["./src/components/*"]  // 임시 우회
}
</code></pre>
<p>그 외엔 다 작동했습니다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td><code>composite</code> + project references (<code>-b</code>)</td>
<td>✅</td>
</tr>
<tr>
<td><code>moduleResolution: "bundler"</code></td>
<td>✅</td>
</tr>
<tr>
<td><code>target: ES2021</code>, <code>jsx: "react-jsx"</code>, <code>strict</code></td>
<td>✅</td>
</tr>
<tr>
<td><code>isolatedModules</code>, <code>allowImportingTsExtensions</code></td>
<td>✅</td>
</tr>
<tr>
<td>전체 src 타입체크 (604 파일)</td>
<td>✅ 0 error</td>
</tr>
<tr>
<td>test config (vitest/jest-dom 타입 포함)</td>
<td>✅ 0 error</td>
</tr>
<tr>
<td><code>tsc -b → tsgo -b</code> 교체 후 <code>vite build</code> 파이프라인</td>
<td>✅</td>
</tr>
</tbody></table>
<h2>성능 — 광고가 거짓은 아니지만</h2>
<p>3회 평균. Cold = <code>node_modules/.tmp</code> (buildinfo) 삭제 후입니다.</p>
<table>
<thead>
<tr>
<th>작업</th>
<th>TSC 5.8.3</th>
<th>tsgo 7.0-dev</th>
<th>배수</th>
</tr>
</thead>
<tbody><tr>
<td><code>tsc -b --force</code> (cold)</td>
<td><strong>3.39s</strong></td>
<td><strong>0.71s</strong></td>
<td><strong>4.8×</strong></td>
</tr>
<tr>
<td><code>tsc -b</code> (warm, no change)</td>
<td>0.20s</td>
<td>0.16s</td>
<td>1.3×</td>
</tr>
<tr>
<td>test types (cold, <code>--noEmit</code>)</td>
<td><strong>2.61s</strong></td>
<td><strong>0.53s</strong></td>
<td><strong>4.9×</strong></td>
</tr>
<tr>
<td>풀 build (<code>tsc -b &amp;&amp; vite build</code>)</td>
<td><strong>10.81s</strong></td>
<td><strong>~4.4s</strong></td>
<td><strong>2.5×</strong></td>
</tr>
</tbody></table>
<p>광고에 적힌 "10배"는 이 규모에선 안 나왔습니다. 5배 정도. 코드베이스가 크고 타입 인스턴스화가 무거울수록 차이가 벌어지는 구조 같습니다. 테스트를 적용해본 프로젝트는 604개 파일이고 라이브러리 타입까지 합쳐도 200K 라인 수준이라 컴파일러 자체의 오버헤드가 작은 편입니다.</p>
<p><code>--extendedDiagnostics</code>를 비교해 보면 재미있는 게 하나 있습니다.</p>
<table>
<thead>
<tr>
<th>메트릭</th>
<th>TSC 5.8.3</th>
<th>tsgo 7.0-dev</th>
</tr>
</thead>
<tbody><tr>
<td>Total</td>
<td>2.31s</td>
<td>0.55s</td>
</tr>
<tr>
<td>Check</td>
<td>1.74s</td>
<td>0.29s</td>
</tr>
<tr>
<td>Memory</td>
<td>431 MB</td>
<td>388 MB</td>
</tr>
<tr>
<td>Types</td>
<td>94,539</td>
<td>205,998</td>
</tr>
</tbody></table>
<p><strong>Types 카운트가 두 배 이상 차이 납니다.</strong> tsgo는 더 많은 타입을 인스턴스화하면서도 메모리는 덜 쓰고 시간은 1/6 수준입니다. Go 런타임의 메모리 공유 병렬화와 GC 특성이 그대로 보입니다. V8 단일 스레드 위에서 돌던 tsc와는 자원 모델 자체가 다릅니다.</p>
<p>병렬화 옵션 <code>--checkers</code> 스케일링도 같이 봤습니다.</p>
<table>
<thead>
<tr>
<th><code>--checkers</code></th>
<th>Cold time</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>1.04s</td>
</tr>
<tr>
<td>4 (기본)</td>
<td>0.68s</td>
</tr>
<tr>
<td>8</td>
<td>0.71s</td>
</tr>
<tr>
<td><code>--singleThreaded</code></td>
<td>1.11s</td>
</tr>
</tbody></table>
<p>M4 10코어에선 4가 sweet spot입니다. 8을 줘도 saturation에 걸려 의미 있는 개선은 안 보입니다. 이 정도 규모에선 4 워커가 분산할 만큼의 작업량이 애초에 안 나옵니다.</p>
<h2>어디에 가장 효과가 있나</h2>
<p>이번 측정에서 가장 의미 있는 숫자는 풀 빌드 파이프라인의 **2.5×**입니다. 4.8×처럼 화려하진 않지만 CI에서 실제로 체감되는 건 이쪽입니다. 타입체크는 풀 빌드의 일부일 뿐이고 vite의 esbuild 단계는 어차피 빠르니까요. CI 빌드 시간을 좌우하는 부분은:</p>
<ol>
<li><p><strong>PR마다 도는 type-check</strong> — 5×. 가장 직접적인 개선</p>
</li>
<li><p><strong>Docker 이미지 빌드의</strong> <code>tsc -b</code> <strong>단계</strong> — 풀 빌드 시간이 60% 줄어듭니다</p>
</li>
<li><p><strong>워치 모드의 cold restart</strong> — IDE 재시작이나 클린 빌드 직후 기다리는 시간이 짧아집니다</p>
</li>
</ol>
<p>반대로 <strong>warm incremental은 차이가 거의 없습니다</strong> (0.20s → 0.16s). <code>.tsbuildinfo</code>가 캐시되어 있으면 tsgo 쪽도 더 줄일 여지 자체가 작습니다. 일상 dev 루프에서 느끼는 차이는 생각보다 미미할 수 있다는 얘기입니다.</p>
<h2>도입할 것인가</h2>
<p>결론은 보류. 이유가 몇 가지 있습니다.</p>
<ol>
<li><p><strong>베타입니다.</strong> 안정 프로그래밍 API는 7.1 이후로 미뤄져 있고 에디터 쪽 의미론적 강조나 임포트 관리도 일부 미완성입니다.</p>
</li>
<li><p><code>@typescript-eslint</code><strong>가 아직 TS 5 기반입니다.</strong> ESLint 파이프라인은 베이스라인을 따라가니까, 컴파일러만 7로 올리면 결국 두 컴파일러를 동시에 들고 가야 합니다.</p>
</li>
<li><p><strong>현재 빌드가 이미 충분히 빠릅니다.</strong> 풀 빌드 10.8s를 4.4s로 줄여도 우리 워크플로의 병목은 거기가 아닙니다.</p>
</li>
</ol>
<p>다만 <strong>준비는 해두는 게 맞습니다.</strong> GA 시점에 무리 없이 넘어가려면 지금부터 할 수 있는 작업이 있습니다.</p>
<ul>
<li><p><code>tsconfig.app.json</code>의 <code>baseUrl</code> 제거, <code>paths</code>를 <code>{ "@/*": ["./src/*"] }</code>로 변경 (TS 5 호환)</p>
</li>
<li><p><code>Tooltip.tsx</code>의 bare specifier 임포트를 <code>@/components/...</code>로 통일</p>
</li>
<li><p>CI 빌드 시간 추적 — GA 직후 효과 측정이 가능하도록 베이스라인 기록</p>
</li>
</ul>
<h2>typescript-eslint가 도대체 무슨 일을 하길래</h2>
<p>여기서 자연스럽게 따라붙는 의문이 있습니다. "린트 패키지 하나 때문에 컴파일러를 못 올리는 건가?" 그래서 typescript-eslint가 우리 파이프라인에서 실제로 뭘 하는지부터 정리해 봅니다.</p>
<p>ESLint 자체는 JS 파서로 코드를 토큰 단위로 봅니다. 타입 정보가 없습니다. 그래서 다음 같은 룰은 <strong>순수 ESLint로는 불가능</strong>합니다.</p>
<ul>
<li><p><code>@typescript-eslint/no-floating-promises</code> — Promise를 await/then 없이 떨궜는지 검출</p>
</li>
<li><p><code>@typescript-eslint/no-misused-promises</code> — <code>if (asyncFn())</code>처럼 Promise를 boolean 자리에 쓰는지</p>
</li>
<li><p><code>@typescript-eslint/no-unsafe-*</code> — <code>any</code>가 흘러다니는 경로 추적</p>
</li>
<li><p><code>@typescript-eslint/await-thenable</code> — Promise 아닌 값에 await 거는지</p>
</li>
<li><p><code>@typescript-eslint/strict-boolean-expressions</code> — <code>if (str)</code>처럼 nullable 문자열을 boolean으로 쓰는지</p>
</li>
</ul>
<p>이런 <strong>type-aware 룰</strong>들은 typescript-eslint가 내부적으로 TypeScript 컴파일러 API(<code>createProgram</code>, <code>getTypeChecker</code>)를 호출해서 타입 정보를 얻은 다음 AST 위에서 판단합니다. 즉 typescript-eslint는 "TS 문법을 ESLint가 읽게 해주는 어댑터"가 아니라 <strong>TypeScript 컴파일러를 ESLint 안에서 호스팅하는 레이어</strong>입니다.</p>
<h3>TS 7이 왜 문제냐면</h3>
<p>tsgo는 Go로 다시 짜서 Node 프로세스 안의 동기 함수가 아니라 <strong>WASM/네이티브 바인딩</strong>으로 노출될 가능성이 큽니다. 그러면 컴파일러 API 호출이 비동기가 됩니다. 그런데 ESLint 코어는 아직 <strong>rule.create()가 동기 함수</strong>라는 전제로 굴러갑니다. 룰 안에서 <code>await checker.getTypeAtLocation(...)</code>을 못 쓴다는 얘기입니다. 이걸 풀려면 ESLint 내부 아키텍처 자체가 async 파서/룰을 지원해야 합니다. typescript-eslint 한 팀이 손쓸 수 있는 범위 밖입니다.</p>
<h3>그럼 TS 7을 못 쓰는 건가? 아닙니다</h3>
<p>여기가 핵심입니다. <strong>컴파일러와 린터를 분리해서 운영하면 됩니다.</strong></p>
<pre><code class="language-plaintext">[빌드/타입체크]  tsgo (TS 7)        ← 빠름
[린트]        typescript-eslint + typescript@5/6  ← 기존대로
</code></pre>
<p>이렇게 깔면:</p>
<ul>
<li><p><code>pnpm build</code>, <code>pnpm type-check</code>는 tsgo로 → 5배 빠름</p>
</li>
<li><p><code>pnpm lint</code>는 기존 typescript-eslint + TS 5/6 그대로 → 영향 없음</p>
</li>
<li><p>node_modules에 <code>typescript</code>(5/6)와 <code>@typescript/native-preview</code>(7)가 공존</p>
</li>
</ul>
<p>실제로 정식 7.0 GA에선 이 시나리오를 위해 <a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0-beta/"><code>@typescript/typescript6</code> 호환성 패키지</a>를 따로 풀 예정입니다. tsgo 쓰면서도 typescript-eslint가 import할 TS 5/6 컴파일러를 옆에 깔아둘 수 있습니다.</p>
<h3>그러면 왜 보류했냐</h3>
<p>기술적으로 못 쓰는 게 아니라 <strong>운영 비용이 늘어나기 때문</strong>입니다.</p>
<ol>
<li><p><strong>두 컴파일러 동기화 부담</strong>: tsgo가 통과시키는 코드를 typescript-eslint(TS 5)가 다른 룰로 잡거나, 반대 상황이 생길 수 있습니다. 7.0의 새 기본값(<code>strict</code>, <code>noUncheckedSideEffectImports</code> 등)과 5.x 동작이 미세하게 어긋나는 케이스가 PR마다 마찰이 됩니다.</p>
</li>
<li><p><strong>type-aware 룰의 성능 이점이 안 살아남</strong>: tsgo가 빨라봤자 린트가 여전히 TS 5 컴파일러로 타입 정보를 만들어내니까, <strong>CI에서 가장 무거운 단계인 lint는 그대로</strong>입니다. tsgo 도입 효과의 절반이 죽습니다.</p>
</li>
<li><p><strong>베타 + 안정 API 부재</strong>: 7.1 이후 안정화될 동안 기다리면, 그 시점엔 typescript-eslint도 일부 호환을 마쳤을 가능성이 있습니다.</p>
</li>
</ol>
<table>
<thead>
<tr>
<th>항목</th>
<th>typescript-eslint 없으면</th>
</tr>
</thead>
<tbody><tr>
<td>TS 문법 ESLint 인식</td>
<td>안 됨 (다른 어댑터로 대체 가능, 예: Oxlint)</td>
</tr>
<tr>
<td>타입 기반 룰 (no-floating-promises 등)</td>
<td><strong>사실상 불가능</strong></td>
</tr>
<tr>
<td>TS 7 컴파일 사용</td>
<td>가능 (린트와 분리만 하면)</td>
</tr>
</tbody></table>
<p>요약하면 typescript-eslint는 <strong>타입 안전성을 코드 리뷰 자동화로 끌어올리는 핵심 도구</strong>입니다. TS 7을 못 쓰게 막는 게 아니라, <strong>TS 7으로 갔을 때 효과가 반감되고 운영이 복잡해진다</strong>는 게 실제 보류 사유입니다. "기술적 호환성"이 아니라 <strong>"가성비"</strong> 의 문제입니다.</p>
<h2>typescript-eslint는 언제 따라오나</h2>
<p>그래서 typescript-eslint가 정확히 어디까지 와 있는지도 짚어봤습니다. 결론부터 쓰면 <strong>TS 6는 이미 따라왔고, TS 7은 아직입니다.</strong></p>
<h3>TS 6: 이미 지원 완료</h3>
<p>2026-04 기준 <code>@typescript-eslint/parser@8.59.0</code>의 peer 범위는 <code>typescript: "&gt;=4.8.4 &lt;6.1.0"</code>입니다. TS 6.0이 이미 들어가 있다는 얘기입니다. 트래킹 이슈 <a href="https://github.com/typescript-eslint/typescript-eslint/issues/12123">#12123</a>도 Closed 상태고, <code>lib.d.ts</code> 재생성과 <code>target: es5</code> 폐지에 따른 fixture 업데이트, JSX 닫기 태그 토큰화 변경, 새 기본 컴파일러 옵션 대응까지 모두 끝났습니다.</p>
<p>우리 프로젝트는 <code>@typescript-eslint/*@^8.15.0</code>을 쓰고 있어서 minor bump만 하면 끝입니다. TS 6로 가는 길에 린트 쪽 장벽은 거의 없다고 봐도 됩니다.</p>
<h3>TS 7: 공식 ETA 없음, 그리고 구조적 블로커가 있다</h3>
<p>핵심은 버전 호환이 아니라 <strong>API 모델이 달라진다</strong>는 데 있습니다. 위에서 짚은 async 이슈가 그대로 작용합니다.</p>
<ol>
<li><p>tsgo는 <strong>WASM/네이티브 바인딩으로 노출될 가능성</strong>이 큽니다. 그러면 API가 비동기로 가게 됩니다.</p>
</li>
<li><p>ESLint는 <strong>async parser를 아직 지원하지 않습니다.</strong> 린터 쪽 인프라부터 손봐야 한다는 뜻입니다.</p>
</li>
<li><p>typescript-eslint 팀은 <a href="https://github.com/typescript-eslint/typescript-eslint/issues/10940">#10940</a>에서 **"tsgolint로 예산을 옮길 계획 없다, 기존 typescript-eslint를 계속 발전시키겠다"**고 못을 박았습니다. 같은 팀이 두 갈래를 동시에 끌고 가진 않겠다는 얘기입니다.</p>
</li>
</ol>
<p>대안 진영도 살펴보면:</p>
<ul>
<li><p><strong>tsgolint</strong> (typescript-eslint 팀이 만든 Go 기반 PoC) — 초기 단계, 적극적으로 개발되고 있지 않고 프로덕션엔 부적합</p>
</li>
<li><p><strong>Oxlint</strong> — typescript-go 통합 type-aware 린팅 프리뷰 중. 다른 베팅입니다.</p>
</li>
</ul>
<p>종합하면 정식 TS 7(tsgo) 지원은 <strong>빨라야 GA + 수개월</strong>, 현실적으론 더 길어질 수도 있습니다. 그 사이엔 <strong>컴파일은 tsgo, 린트는 TS 5/6</strong>으로 컴파일러를 둘 끌고 가는 모델이 일반적인 운영 형태가 될 것 같습니다.</p>
<h3>마이그레이션 계획</h3>
<p>이 정보까지 반영하면 단계가 좀 더 선명해집니다.</p>
<ol>
<li><p><strong>단기 (지금 ~ 정식 7.0 GA 전)</strong>: TS 5.8.3 유지. tsconfig 정리(<code>baseUrl</code> 제거, paths 상대화)와 <code>Tooltip.tsx</code> 임포트 통일만 선반영합니다.</p>
</li>
<li><p><strong>중기 (TS 6.0 stable 안정화 후)</strong>: typescript와 typescript-eslint를 6.x로 minor 업. 린트 깨짐은 거의 없고, CI 빌드 시간 변화를 같이 측정해 두면 됩니다.</p>
</li>
<li><p><strong>장기 (TS 7 GA + typescript-eslint 7 호환 확정 후)</strong>: 컴파일러 7.0 전환. 그전에 들어가면 "린트는 6, 컴파일은 7"이라는 이중 운영을 떠안게 되니까, 충분한 가치가 보일 때만 움직이는 게 맞습니다.</p>
</li>
</ol>
<p>정리하면 TS 6는 자연스럽게 흘러가는 흐름이고 TS 7은 별도 의사결정 게이트가 필요합니다. 이번 측정은 그 게이트를 통과시킬 만큼 데이터가 모이는지 미리 확인해 보는 사전 작업으로 의미가 있습니다.</p>
<h2>남는 질문</h2>
<ul>
<li><p>이 5× 격차가 코드베이스가 커질수록 더 벌어질지가 궁금합니다. 광고된 10×에 닿는 임계점이 어디쯤인지는 별도 측정이 필요할 듯합니다.</p>
</li>
<li><p><code>composite</code> 빌드 그래프가 깊은 monorepo에서 <code>--builders</code> 옵션이 얼마나 먹히는지. 이 프로젝트는 references가 2개뿐이라 측정이 안 됐습니다.</p>
</li>
<li><p>정식 7.0이 <code>typescript</code> 패키지로 들어왔을 때 npm 의존성 트리에 어떤 충돌이 생길지. <code>@typescript/typescript6</code> 호환성 패키지를 쓰는 시나리오가 실제로 어떻게 정착될지도 두고 봐야겠습니다.</p>
</li>
</ul>
<h2>변경된 파일</h2>
<ul>
<li><p><code>package.json</code> — <code>@typescript/native-preview</code> devDep + <code>type-check:tsgo</code>, <code>test:types:tsgo</code> 스크립트</p>
</li>
<li><p><code>tsconfig.tsgo.{json,app.json,node.json,test.json}</code> — 비교용 별도 설정 (원본 tsconfig 보존)</p>
</li>
<li><p>기존 tsconfig 및 소스 코드는 무수정</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Pretext: 브라우저의 텍스트 레이아웃 엔진을 JavaScript로 재구현한다는 것]]></title><description><![CDATA["이 텍스트가 이 너비 안에서 몇 줄이 되는지"를 JavaScript로 알아내 본 적 있으신가요? 채팅 메시지의 높이를 미리 계산해서 가상 스크롤을 구현하거나, 텍스트가 넘치는지 판단해서 말줄임을 걸거나, 멀티라인 텍스트의 정확한 높이로 레이아웃 시프트를 방지하거나. 이런 작업을 해보신 분이라면 그 고통을 아실 겁니다.
방법은 하나뿐이에요. 브라우저한테 물]]></description><link>https://ted-projects.com/pretext-javascript</link><guid isPermaLink="true">https://ted-projects.com/pretext-javascript</guid><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Tue, 31 Mar 2026 13:30:00 GMT</pubDate><content:encoded><![CDATA[<p>"이 텍스트가 이 너비 안에서 몇 줄이 되는지"를 JavaScript로 알아내 본 적 있으신가요? 채팅 메시지의 높이를 미리 계산해서 가상 스크롤을 구현하거나, 텍스트가 넘치는지 판단해서 말줄임을 걸거나, 멀티라인 텍스트의 정확한 높이로 레이아웃 시프트를 방지하거나. 이런 작업을 해보신 분이라면 그 고통을 아실 겁니다.</p>
<p>방법은 하나뿐이에요. 브라우저한테 물어보는 거죠.</p>
<pre><code class="language-javascript">element.textContent = text
element.style.width = containerWidth + 'px'
const height = element.getBoundingClientRect().height // 여기서 reflow 발생
</code></pre>
<p>문제는 <code>getBoundingClientRect()</code>를 호출하는 순간 브라우저가 전체 문서의 레이아웃을 다시 계산한다는 점입니다. "layout reflow"라고 부르는, 웹에서 가장 비싼 연산 중 하나예요.</p>
<p>텍스트 하나를 측정하려고 전체 페이지를 다시 그립니다. 10개를 측정하면 10번 다시 그려요. 채팅 앱에서 메시지 100개를 화면에 뿌리려면? 100번입니다. 메시지마다 <code>offsetHeight</code>를 읽는 루프 안에서, 브라우저는 매번 "이전에 바뀐 게 있으니까 레이아웃 전체를 다시 계산해야겠다"고 판단하거든요.</p>
<p>그래서 개발자들이 온갖 꼼수를 씁니다. 측정을 모아서 한 번에 하거나(batching), 높이를 대충 추정하거나, 한 번 측정한 값을 캐싱하거나. 전부 깨지기 쉽고, 코드가 복잡해지고, 컴포넌트 경계를 망가뜨려요.</p>
<h2>Pretext는 브라우저한테 안 물어봅니다</h2>
<p><a href="https://github.com/chenglou/pretext">Pretext</a>는 Cheng Lou(react-motion, ReasonML)가 만든 텍스트 레이아웃 라이브러리입니다. 접근이 근본적으로 달라요.</p>
<p><strong>브라우저가 내부적으로 하는 텍스트 레이아웃 과정을 JavaScript로 재구현했습니다.</strong></p>
<p>브라우저가 텍스트를 화면에 배치할 때 실제로 하는 일을 분해하면 이렇습니다:</p>
<ol>
<li><p>공백 정규화 — CSS <code>white-space: normal</code> 규칙에 따라 연속 공백을 하나로 합치고, 앞뒤 공백을 제거</p>
</li>
<li><p>단어 분리 — 언어별로 다른 규칙으로 텍스트를 세그먼트로 쪼갬</p>
</li>
<li><p>너비 측정 — 각 세그먼트의 픽셀 너비를 계산</p>
</li>
<li><p>줄바꿈 결정 — 세그먼트 너비를 누적하다 컨테이너를 넘으면 줄바꿈</p>
</li>
<li><p>예외 처리 — trailing whitespace는 넘쳐도 줄바꿈하지 않고, 단어가 컨테이너보다 넓으면 글자 단위로 쪼갬</p>
</li>
</ol>
<p>pretext는 이 다섯 단계를 전부 JavaScript로 구현합니다. 하나씩 코드와 대조해 볼게요.</p>
<h2>1단계: 공백 정규화</h2>
<p>CSS <code>white-space: normal</code>에서 브라우저는 탭, 개행, 연속 공백을 전부 하나의 공백으로 합칩니다. pretext의 <code>normalizeWhitespaceNormal()</code>이 정확히 이 동작을 복제해요.</p>
<pre><code class="language-typescript">// src/analysis.ts
export function normalizeWhitespaceNormal(text: string): string {
  let normalized = text.replace(collapsibleWhitespaceRunRe, ' ')
  if (normalized.charCodeAt(0) === 0x20) {
    normalized = normalized.slice(1)
  }
  if (normalized.length &gt; 0 &amp;&amp; normalized.charCodeAt(normalized.length - 1) === 0x20) {
    normalized = normalized.slice(0, -1)
  }
  return normalized
}
</code></pre>
<p>연속 공백을 하나로 합치고, 앞뒤 공백을 제거합니다. 브라우저의 공백 처리 규칙과 동일해요.</p>
<h2>2단계: 세그먼트 분리 — 여기가 어렵습니다</h2>
<p>영어는 공백으로 단어를 나누면 되지만, 세상의 모든 언어가 그렇지는 않습니다.</p>
<ul>
<li><p><strong>한국어/중국어/일본어</strong>: 글자 단위로 줄바꿈이 가능하지만, 금칙처리(kinsoku)가 있습니다. "。"이나 "？"는 줄 시작에 올 수 없고, 여는 괄호는 줄 끝에 올 수 없어요.</p>
</li>
<li><p><strong>태국어/라오어</strong>: 공백 없이 이어 씁니다. 단어 경계를 알려면 사전이 필요해요.</p>
</li>
<li><p><strong>아랍어</strong>: 오른쪽에서 왼쪽으로 씁니다(RTL). 구두점이 공백 없이 붙기도 하고요.</p>
</li>
</ul>
<p>pretext는 <code>Intl.Segmenter</code>로 언어별 단어 분리를 처리하고, 그 위에 CJK 금칙처리를 직접 구현합니다:</p>
<pre><code class="language-typescript">// src/analysis.ts — CJK 줄 시작 금지 문자
export const kinsokuStart = new Set([
  '\uFF0C', // ，
  '\uFF0E', // ．
  '\uFF01', // ！
  '\uFF1A', // ：
  '\uFF1B', // ；
  '\uFF1F', // ？
  // ...
])
</code></pre>
<p>CJK 텍스트는 글자 단위로 쪼개되, "。"가 다음 줄 시작에 오지 않도록 앞 글자와 병합해요:</p>
<pre><code class="language-typescript">// src/layout.ts — CJK grapheme 분리 + 금칙처리 병합
if (segKind === 'text' &amp;&amp; segMetrics.containsCJK) {
  for (const gs of graphemeSegmenter.segment(segText)) {
    const grapheme = gs.segment
    if (kinsokuStart.has(grapheme)) {
      unitText += grapheme  // 앞 단위에 병합 → 줄 시작 방지
      continue
    }
    // ...
  }
}
</code></pre>
<p>브라우저가 내부에서 하는 금칙처리를 JS로 동일하게 복제한 것입니다.</p>
<h2>3단계: 너비 측정 — Canvas가 핵심입니다</h2>
<p>각 세그먼트의 픽셀 너비를 알아야 줄바꿈 위치를 결정할 수 있겠죠. 여기서 DOM 대신 Canvas API의 <code>measureText()</code>를 씁니다:</p>
<pre><code class="language-typescript">// src/measurement.ts
export function getSegmentMetrics(seg: string, cache: Map&lt;string, SegmentMetrics&gt;): SegmentMetrics {
  let metrics = cache.get(seg)
  if (metrics === undefined) {
    const ctx = getMeasureContext()
    metrics = {
      width: ctx.measureText(seg).width,  // Canvas API — reflow 없음
      containsCJK: isCJK(seg),
    }
    cache.set(seg, metrics)  // 같은 세그먼트는 다시 측정하지 않음
  }
  return metrics
}
</code></pre>
<p><code>canvas.measureText()</code>는 <code>getBoundingClientRect()</code>와 달리 DOM reflow를 일으키지 않습니다. Canvas는 독립된 측정 API이기 때문이에요. 그리고 결과를 <code>Map&lt;font, Map&lt;segment, metrics&gt;&gt;</code> 캐시에 저장하므로, "hello"라는 단어가 100번 나와도 측정은 한 번뿐입니다.</p>
<p>다만 이모지는 Canvas와 DOM 사이에 너비 차이가 있어요(Chrome/Firefox, macOS, font size &lt; 24px). pretext는 이 차이를 자동으로 감지해서 보정합니다:</p>
<pre><code class="language-typescript">// src/measurement.ts — 이모지 보정 (폰트당 최초 1회, DOM read 1회)
const canvasW = ctx.measureText('😀').width
// ...DOM에 span을 넣어 실제 너비 측정...
const domW = span.getBoundingClientRect().width
correction = canvasW - domW  // 이 차이만큼 보정
</code></pre>
<p>이것이 pretext가 DOM을 건드리는 거의 유일한 순간입니다. 폰트당 1회, 캐시되고요.</p>
<h2>4단계: 줄바꿈 — 순수 산술</h2>
<p>세그먼트 너비가 전부 숫자 배열로 준비되면, 줄바꿈은 그냥 덧셈과 비교입니다:</p>
<pre><code class="language-typescript">// src/line-break.ts — layout()의 핵심 루프
for (let i = 0; i &lt; widths.length; i++) {
  const w = widths[i]!
  const kind = kinds[i]!

  const newW = lineW + w                          // 덧셈
  if (newW &gt; maxWidth + lineFitEpsilon) {          // 비교 → 넘치면 줄바꿈
    if (isSimpleCollapsibleSpace(kind)) continue   // trailing space는 무시 (CSS 동작)
    lineW = 0
    placeOnFreshLine(i)
    continue
  }
  lineW = newW                                     // 누적
}
</code></pre>
<p>이 루프에는 DOM 접근이 없습니다. Canvas 호출도 없어요. 문자열 연산도 없고요. <strong>숫자 배열을 순회하며 더하고 비교하는 게 전부입니다.</strong></p>
<p>단어가 컨테이너보다 넓으면(<code>overflow-wrap: break-word</code>) 미리 쪼개 둔 grapheme 너비 배열로 글자 단위 줄바꿈을 합니다. 이것도 같은 덧셈 루프예요.</p>
<h2>두 단계로 나눈 이유</h2>
<p>pretext의 API는 명확하게 두 단계로 나뉩니다:</p>
<pre><code class="language-typescript">// Phase 1: 텍스트 최초 등장 시 1회 — 분석 + 측정
const prepared = prepare(text, font)

// Phase 2: 리사이즈마다 — 순수 산술
const { lineCount, height } = layout(prepared, maxWidth, lineHeight)
</code></pre>
<p><code>prepare()</code>는 비교적 비쌉니다. <code>Intl.Segmenter</code>로 텍스트를 분리하고, Canvas로 세그먼트마다 너비를 측정하거든요. 하지만 한 번만 하면 됩니다. 결과인 <code>PreparedText</code>는 <strong>너비에 독립적</strong>이에요. 같은 텍스트를 300px에서 배치하든 600px에서 배치하든, <code>prepare()</code>를 다시 호출할 필요가 없습니다.</p>
<p><code>layout()</code>은 극도로 쌉니다. 파일 주석에 <strong>~0.0002ms per text block</strong>이라고 적혀 있어요. 숫자 배열의 덧셈이 전부이니 당연하죠.</p>
<p>채팅 앱에서 메시지 500개의 높이를 구하는 상황을 비교하면:</p>
<table>
<thead>
<tr>
<th></th>
<th>전통 방식</th>
<th>pretext</th>
</tr>
</thead>
<tbody><tr>
<td>측정</td>
<td><code>getBoundingClientRect()</code> x 500</td>
<td><code>canvas.measureText()</code> x 세그먼트 수 (캐시)</td>
</tr>
<tr>
<td>계산</td>
<td>브라우저 레이아웃 엔진 x 500</td>
<td>배열 덧셈 x 500</td>
</tr>
<tr>
<td>Reflow</td>
<td>500회</td>
<td>0회</td>
</tr>
<tr>
<td>시간</td>
<td>~30ms+</td>
<td>~0.1ms</td>
</tr>
</tbody></table>
<p>500개 블록 기준 <strong>300배</strong> 이상 차이입니다. 글자 그대로 "불공정한 비교"인데, 그게 포인트예요. 아키텍처를 바꾸면 비교 자체가 불공정해질 정도로 빨라집니다.</p>
<h2>정확도: 7,680개 조합에서 diff 0</h2>
<p>"그래서 결과가 정확한가요?"가 당연한 다음 질문이겠죠.</p>
<p>pretext의 리포에는 <code>accuracy/</code> 폴더가 있습니다. Chrome, Safari, Firefox 각각에 대해 4개 폰트 x 8개 폰트 사이즈 x 8개 컨테이너 너비 x 다국어 테스트 텍스트 = <strong>7,680개 조합</strong>을 브라우저 실제 렌더링과 비교해요.</p>
<pre><code class="language-json">{
  "font": "\"Helvetica Neue\", Helvetica, Arial, sans-serif",
  "fontSize": 12, "lineHeight": 14, "width": 400,
  "actual": 120,
  "predicted": 120,
  "diff": 0
}
</code></pre>
<p>이 수치가 가능한 이유가 있습니다. Cheng Lou는 AI 에이전트(Claude Code, Codex)에게 브라우저의 실제 렌더링 결과를 ground truth로 보여주고, "이거랑 똑같이 계산하는 코드를 만들어"라고 시킨 뒤, 모든 너비에서 비교하고, 틀린 부분을 알려주고, 다시 고치게 하는 루프를 수 주간 돌렸어요. 리포에 <code>CLAUDE.md</code>(Claude Code용)와 <code>AGENTS.md</code>(Codex용)가 나란히 있는 것이 그 증거입니다.</p>
<h2>이게 풀어주는 것들</h2>
<p>"텍스트 높이를 DOM 없이 안다"는 것이 왜 대단한지, 구체적으로 풀리는 문제들이 있습니다.</p>
<p><strong>가상화(Virtualization)</strong>: 채팅 앱에서 메시지 10,000개를 스크롤할 때, 화면에 보이는 메시지만 렌더링합니다. 이를 위해 각 메시지의 높이를 알아야 하는데, 지금까지는 렌더링해서 재거나 추정했어요. pretext로 DOM 없이 정확한 높이를 계산할 수 있습니다.</p>
<p><strong>채팅 버블 Shrinkwrap</strong>: 멀티라인 텍스트에서 "가장 넓은 줄의 너비"를 CSS로는 알 방법이 없습니다. <code>width: fit-content</code>는 줄바꿈 전 전체 너비를 반환할 뿐이에요. pretext는 각 줄의 실제 너비를 산술로 계산하므로, 가장 넓은 줄에 딱 맞는 버블을 만들 수 있습니다.</p>
<p><strong>레이아웃 시프트 방지</strong>: 텍스트가 로딩되면서 페이지가 덜컹거리는 현상이요. 높이를 미리 계산해서 공간을 잡아두면 해결됩니다.</p>
<p><strong>유저랜드 레이아웃</strong>: CSS가 지원하지 않는 레이아웃 — 텍스트가 이미지를 감싸며 흐르는 잡지 스타일, 장애물을 피하는 다단 레이아웃 — 을 JavaScript로 구현할 수 있습니다. pretext의 <code>layoutNextLine()</code> API는 줄마다 다른 너비를 받을 수 있어서, 불규칙한 컨테이너 형태에 텍스트를 흘릴 수 있어요.</p>
<h2>핵심 인사이트</h2>
<p>pretext가 증명한 것은 "Canvas measureText가 빠르다"는 기술적 사실이 아닙니다.</p>
<p><strong>브라우저 레이아웃 엔진의 결과가 필요할 때, 그 엔진을 호출하는 것만이 유일한 방법은 아니라는 거예요.</strong> 엔진이 하는 일을 이해하고, 동일한 결과를 내는 더 가벼운 경로를 만들 수 있습니다. 측정은 Canvas로 한 번, 계산은 산술로, DOM 쓰기는 최종 결과만 한 번에. 이 패턴은 텍스트에만 국한되지 않아요.</p>
<p>프론트엔드에서 <code>offsetWidth</code>, <code>scrollHeight</code>, <code>getBoundingClientRect()</code>를 루프 안에서 호출하고 있는 모든 곳이 같은 구조적 문제를 안고 있습니다. "측정은 미리, 쓰기는 한 번에"라는 원칙을 적용할 수 있는 곳이 텍스트 말고도 분명 있을 겁니다.</p>
]]></content:encoded></item><item><title><![CDATA[나는 호모 루덱서스다]]></title><description><![CDATA[지난글에서 저는 '선택의 자유'를 향해 나를 단련하겠다고 했습니다. 지식을 굴비 엮듯 쌓아두던 습관을 버리고, 멘탈 모델을 쌓아가는 방향으로 삶을 재설계하겠다고요. 그리고 실제로 그 방향으로 움직이기 시작했습니다.
그런데 이상한 일이 벌어졌습니다.
평소의 저라면 마다할 일들이 즐거워지고 있었습니다. 매일 운동하는 게 귀찮지 않고 블로그 글을 쓰는 게 의무감]]></description><link>https://ted-projects.com/homo-ludexers</link><guid isPermaLink="true">https://ted-projects.com/homo-ludexers</guid><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Sun, 22 Mar 2026 01:48:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/62eb589e16f9480a9dac77d0/4d9f2660-0f6b-4627-8f96-b57045512e1c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a href="https://ted-projects.com/tedpool-essay-0">지난글</a>에서 저는 '선택의 자유'를 향해 나를 단련하겠다고 했습니다. 지식을 굴비 엮듯 쌓아두던 습관을 버리고, 멘탈 모델을 쌓아가는 방향으로 삶을 재설계하겠다고요. 그리고 실제로 그 방향으로 움직이기 시작했습니다.</p>
<p>그런데 이상한 일이 벌어졌습니다.</p>
<p>평소의 저라면 마다할 일들이 즐거워지고 있었습니다. 매일 운동하는 게 귀찮지 않고 블로그 글을 쓰는 게 의무감이 아니고 퇴근 후 Obsidian을 열어 노트를 정리하는 게 피곤한 일이 아니었습니다. 오히려 하루 중 그 시간이 기다려질 때가 있엇습니다. 처음에는 이게 일시적인 동기 상승이려니 했는데, 몇 달이 지나도 비슷한 감각이 유지됐습니다.</p>
<p>왜 그럴까 생각하다가 어느 대화에서 하나의 이름을 만났습니다.</p>
<p><strong>호모 루덱서스(Homo Ludexers).</strong></p>
<hr />
<h4>1. 이름의 정체</h4>
<p>라틴어를 뜯어보면 이렇습니다. Homo는 인간, Ludere는 유희(놀다), Exercens는 수련(단련하다). 합치면 '수련을 통해 유희를 전취하는 인간'쯤 됩니다.</p>
<p>단순히 놀기 좋아하는 사람이 아닙니다. 더 잘 놀기 위해 스스로를 갈고 닦는 사람. 압도적인 실력이 뒷받침될 때 비로소 시스템의 제약에서 벗어나 진짜 자유를 누릴 수 있다고 믿는 사람입니다.</p>
<p>이들에게 수련은 놀이의 반대가 아닙니다. 수련 자체가 놀이의 일부입니다. 땀 흘리는 과정, 잘 안 되던 것이 어느 순간 되는 순간, 쌓인 게 연결되는 느낌 — 이게 루덱서스에게는 게임에서 레벨업하는 것만큼 짜릿한 경험입니다.</p>
<hr />
<h4>2. 내 삶에서 발견한 루덱서스</h4>
<p>처음 이 개념을 들었을 때 '나를 설명하는 단어구나' 싶었습니다. 그동안 왜 이 방식으로 살고 있는지 말로 설명하기 어려웠는데, 이름이 생기니 선명해졌습니다.</p>
<p><strong>첫 번째: 유희의 주권을 전취한다</strong></p>
<p>9년 차 프론트엔드 개발자로 일하면서 가장 재밌었던 순간들을 돌이켜보면 공통점이 있습니다. 주어진 스펙을 그대로 구현했을 때가 아니라, 시스템의 틈을 발견했을 때였습니다. "이렇게 하면 더 빠르지 않나?", "이 구조는 왜 이렇게 설계됐을까?"를 파고들어 예상치 못한 해법을 던지는 순간. 그게 재미였습니다.</p>
<p>루덱서스는 장치가 제공하는 기본값(Default)에 만족하지 않습니다. 수련을 통해 획득한 실력으로 게임의 규칙을 흔드는 플레이어입니다. 저는 그 감각이 좋아서 개발을 계속하고 있는 것 같습니다.</p>
<p><strong>두 번째: 수련을 유희로 설계한다</strong></p>
<p>TedPool 시스템을 처음 만든 건 '나쁜 습관을 끊겠다'는 목적이었습니다. 웹소설 금지, 유튜브 제한, 주 7일 운동. 강제와 금지의 언어였죠.</p>
<p>그런데 어느 시점부터 프레임이 바뀌었습니다. 이걸 더 높은 수준의 유희를 위한 준비 운동으로 보기 시작했습니다. 운동을 하면 집중력이 올라가고, 집중력이 올라가면 코드가 더 잘 읽히고, 코드가 잘 읽히면 더 재밌는 문제를 풀 수 있습니다. 지루한 반복이 아니라, 다음 판을 더 잘 즐기기 위한 준비입니다.</p>
<p>수련이 목적지가 아니라 더 좋은 유희를 위한 마중물이라는 걸 알게 되면 고역이 게임이 됩니다.</p>
<p><strong>세 번째: 남들과 다른 수(手)를 만든다</strong></p>
<p>Obsidian으로 노트를 정리하는 사람은 많습니다. 하지만 그 노트를 RAGFlow와 연결하고, Neo4j로 관계 그래프를 만들고, 온톨로지를 직접 설계해서 지식이 자동으로 분류되게 만드는 사람은 많지 않습니다.</p>
<p>단순히 남들과 다른 도구를 쓰는 게 목적이 아닙니다. 시스템이 꿈꾸지 못한 방식으로 쓰는 것, 그게 루덱서스적 창조입니다. 같은 Obsidian을 쓰더라도 자신만의 온톨로지로 재구성할 때 지식의 가치가 달라집니다. 그 과정 자체가 저에게는 가장 즐거운 놀이입니다.</p>
<hr />
<h4>3. 이름을 갖는다는 것</h4>
<p>이름을 갖는다는 건 그 방향으로 살겠다는 선언입니다.</p>
<p>'자유를 향해 나를 단련한다'는 말은 맞지만 좀 무겁습니다. 반면 '루덱서스로 산다'는 말에는 가벼움이 있습니다. 즐기면서 강해진다는 느낌이 있죠. 그 차이가 생각보다 크게 작용합니다. 무거운 언어는 지치게 하고, 가벼운 언어는 지속하게 합니다.</p>
<p>수련이 고역이 되면 지속할 수 없습니다. 수련이 유희가 되면 멈추기가 어렵습니다.</p>
<p>저는 호모 루덱서스입니다.</p>
<p>더 잘 놀기 위해 오늘도 나를 빚습니다.</p>
]]></content:encoded></item><item><title><![CDATA[AI가 코드를 짜는 시대, 나는 리뷰를 못 따라가고 있었다]]></title><description><![CDATA[솔직히 말하면, 요즘 PR을 제때 리뷰하지 못하고 있습니다. 개발 속도는 빨라졌는데 리뷰 속도는 그대로입니다. 예전엔 하루 이틀이면 처리하던 게 이제는 며칠씩 밀립니다. 처음엔 제가 게을러진 탓인가 싶었습니다. 돌아보니 그게 아니었습니다.
AI 도구를 쓰면서 코드가 쏟아지는 속도가 달라졌습니다. 한 사람이 만들어내는 코드 양이 예전과 비교가 안 됩니다. P]]></description><link>https://ted-projects.com/ai</link><guid isPermaLink="true">https://ted-projects.com/ai</guid><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Wed, 18 Mar 2026 13:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/62eb589e16f9480a9dac77d0/e2184c94-7ce0-4db7-853f-64ffb4b594f9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>솔직히 말하면, 요즘 PR을 제때 리뷰하지 못하고 있습니다. 개발 속도는 빨라졌는데 리뷰 속도는 그대로입니다. 예전엔 하루 이틀이면 처리하던 게 이제는 며칠씩 밀립니다. 처음엔 제가 게을러진 탓인가 싶었습니다. 돌아보니 그게 아니었습니다.</p>
<p>AI 도구를 쓰면서 코드가 쏟아지는 속도가 달라졌습니다. 한 사람이 만들어내는 코드 양이 예전과 비교가 안 됩니다. PR 하나에 담긴 변경사항이 500줄, 1000줄을 넘기는 일이 잦아졌습니다. 그걸 제대로 읽으려면 코드 의도를 파악하고 놓친 케이스를 찾아야 합니다. 시간이 두 배로 듭니다. 그런데 PR은 세 배로 늘었습니다. 어디선가 반드시 구멍이 생길 수밖에 없는 구조입니다.</p>
<p>이런 고민을 하던 중에 "<a href="https://news.hada.io/topic?id=27546">코드 리뷰를 없애는 방법</a>"이라는 글을 읽었습니다. 제목만 보면 도발적인데, 핵심은 이겁니다. AI가 코드를 생성하는 시대에 인간이 코드 한 줄 한 줄을 검토하는 건 이미 구조적으로 한계에 왔으니, 리뷰의 무게중심을 코드 아래에서 코드 위로 옮겨야 한다는 것입니다. 즉, 코드 자체보다 코드가 만들어지기 전 단계, 스펙과 수용 기준을 검증하는 쪽으로 인간의 역할을 이동하자는 얘기입니다.</p>
<p>이 글을 읽고 제가 느낀 건 "맞다"가 아니라 "아, 이게 구조적인 문제였구나"였습니다. 리뷰가 힘들어진 게 제 문제가 아니라는 걸 확인한 셈입니다. 동시에 새로운 고민이 생겼습니다. 스펙을 리뷰하는 건 좋은데, 그게 실제로 가능한가요? 코드를 짜면서 비로소 발견되는 예외 상황이나 비즈니스 로직이 더 많은 게 현실입니다. 스펙이 완벽할 수 없다는 건 개발자라면 다 아실겁니다.</p>
<p>그러다 보니 결국 이런 생각까지 왔습니다. AI가 코드를 짜는 시대에 개발자가 해야 할 일은 코드를 검토하는 게 아니라, <strong>코드가 가야 할 방향을 결정하는 것</strong>입니다. 스펙을 쓰는 것도 포함되지만 그보다 더 근본적인 얘기입니다. 시스템이 어떤 구조를 가져야 하는지, 어떤 제약이 중요한지, 어떤 의도로 설계됐는지를 명확하게 유지하는 것. AI는 주어진 맥락 안에서 코드를 잘 짭니다. 그 맥락을 잘 만드는 게 이제 개발자의 핵심 역할이 되고 있습니다.</p>
<p>그 맥락이 어떤 형태여야 하는지에 대해서는 아직 업계에 합의된 답이 없습니다. 하지만 제가 생각하는 방향은 하나입니다. 코드와 함께 존재하는 <strong>구조화된 메타데이터</strong>입니다. 단순한 주석이나 README가 아니라, API 설계, DB 스키마, 핵심 비즈니스 규칙, 모듈 간 의존 관계를 코드와 동기화된 형태로 명시적으로 관리하는 것입니다. 코드가 집이라면 메타데이터는 설계도입니다. 벽지 색깔보다 건물의 하중 구조가 중요하듯, 구현 디테일보다 시스템이 왜 이 모양인지를 데이터로 남기는 것입니다.</p>
<p>이게 갖춰지면 리뷰의 모양도 달라질 수 있습니다. 500줄의 코드를 처음부터 끝까지 읽는 대신, 메타데이터의 변경 diff를 먼저 봅니다. "할인 정책 예외 케이스 하나 추가됨"을 확인하고 그 의도가 타당한지 판단합니다. 코드 리뷰가 아니라 의도 리뷰에 가깝습니다. 특정 모듈이 바뀌었을 때 연쇄적으로 영향받는 곳도 그래프로 파악할 수 있다면, 리뷰어가 시스템 전체를 머릿속에 담고 있어야 하는 부담도 줄어듭니다. 물론 메타데이터를 코드만큼 성실하게 유지해야 한다는 전제가 있고, 그게 쉽지 않다는 것도 압니다. 그래도 방향으로서는 맞다고 생각합니다.</p>
<p>9년 동안 개발하면서 코드를 잘 짜는 게 실력이라고 생각했습니다. 지금도 그 생각이 완전히 바뀐 건 아닙니다. 하지만 AI가 코드를 대신 짜주는 세계에서 "잘 짜는 것"의 의미가 조금씩 달라지고 있다는 건 분명합니다. 코드보다 코드 앞에 오는 것, 의도와 구조와 판단을 잘 다루는 사람이 점점 더 중요해지는 것 같습니다.</p>
<p>쌓이는 PR을 억지로 다 읽으려 하는 건 해법이 아닙니다. 구조를 바꿔야 합니다. 코드를 더 열심히 검토하는 게 아니라, 코드를 둘러싼 정보의 밀도를 높이는 쪽으로. 아직 이걸 제대로 실천하고 있는 팀을 본 적은 없습니다. 저도 마찬가지입니다. 그래서 여전히 고민 중입니다.</p>
]]></content:encoded></item><item><title><![CDATA[Ai는 왜 내 헤더 정렬을 못 맞췄을까?]]></title><description><![CDATA[TL;DR
AI 코딩 어시스턴트에게 "1depth와 2depth 메뉴 정렬 맞춰줘"라고 요청했더니, 여러 번 시도했지만 계속 실패했습니다. 결국 제가 "양쪽 끝을 고정폭으로 만들고, 중앙은 flex로 정렬하면 어때?"라고 구조를 제시하자 바로 해결됐습니다.
핵심 교훈: AI는 CSS 속성은 잘 알지만, 레이아웃 구조 설계에는 약합니다.

문제 상황
React + Tailwind CSS로 만든 관리자 페이지 헤더를 작업하던 중이었습니다.
┌───...]]></description><link>https://ted-projects.com/adjust-html-with-ai</link><guid isPermaLink="true">https://ted-projects.com/adjust-html-with-ai</guid><category><![CDATA[AI]]></category><category><![CDATA[Tailwind CSS]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Wed, 24 Dec 2025 11:33:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1762334372152/9cc42ac3-e609-4514-a4eb-d60d3c9f6122.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-tldr">TL;DR</h2>
<p>AI 코딩 어시스턴트에게 "1depth와 2depth 메뉴 정렬 맞춰줘"라고 요청했더니, 여러 번 시도했지만 계속 실패했습니다. 결국 제가 "양쪽 끝을 고정폭으로 만들고, 중앙은 flex로 정렬하면 어때?"라고 구조를 제시하자 바로 해결됐습니다.</p>
<p><strong>핵심 교훈</strong>: AI는 CSS 속성은 잘 알지만, <strong>레이아웃 구조 설계</strong>에는 약합니다.</p>
<hr />
<h2 id="heading-66y47kccioydge2zqq">문제 상황</h2>
<p>React + Tailwind CSS로 만든 관리자 페이지 헤더를 작업하던 중이었습니다.</p>
<pre><code class="lang-javascript">┌─────────────────────────────────────────────┐
│  [Logo]  [Menu1] [Menu2] [Menu3]  [Profile] │  ← <span class="hljs-number">1</span>depth
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│       [SubMenu1] [SubMenu2] [SubMenu3]      │  ← <span class="hljs-number">2</span>depth
└─────────────────────────────────────────────┘
</code></pre>
<p>1depth 메뉴와 2depth 서브메뉴가 <strong>수직으로 정렬</strong>되어야 하는데, 계속 어긋나 있었습니다.</p>
<p>저는 AI에게 이렇게 요청했습니다:</p>
<blockquote>
<p>"화면 보면 adminHeader가 상위 헤더에 표시되있는데, 1depth의 메뉴와 2depth의 메뉴가 딱 정렬이 맞지 않고 있어. 브라우저를 잘 확인하면서 css를 조정해줄래?"</p>
</blockquote>
<hr />
<h2 id="heading-ai">AI의 시도 (그리고 실패)</h2>
<h3 id="heading-1-margin">1차 시도: margin 조정</h3>
<p>AI가 가장 먼저 한 일은 <code>ml-6</code> (margin-left) 제거였습니다.</p>
<pre><code class="lang-tsx">// Before
&lt;div className="flex gap-8 justify-center py-6 mx-auto ml-6"&gt;

// After
&lt;div className="flex gap-8 justify-center py-6 mx-auto"&gt;
</code></pre>
<p><strong>결과</strong>: 여전히 어긋남</p>
<h3 id="heading-2">2차 시도: 고정폭 추가</h3>
<p>AI가 2depth 메뉴에 고정폭을 추가했습니다.</p>
<pre><code class="lang-tsx">&lt;div className="w-[580px] max-w-[580px] min-w-[580px] justify-center"&gt;
  {/* 2depth 메뉴들 */}
&lt;/div&gt;
</code></pre>
<p><strong>결과</strong>: 여전히 어긋남</p>
<h3 id="heading-3-flex">3차 시도: flex 속성 조정</h3>
<p>AI가 각종 flex 속성을 조정했습니다.</p>
<pre><code class="lang-tsx">// justify-center 제거, items-center 추가, text-center 추가...
</code></pre>
<p><strong>결과</strong>: 여전히 어긋남</p>
<hr />
<h2 id="heading-ai-1">왜 AI는 계속 실패했을까?</h2>
<p>AI의 접근 방식을 분석해보면:</p>
<pre><code class="lang-plaintext">문제: 정렬이 안 맞음
 ↓
AI 사고: CSS 속성이 잘못됐나?
 ↓
시도 1: margin 조정
시도 2: width 추가
시도 3: justify-content 변경
시도 4: text-align 추가
 ↓
실패...
</code></pre>
<p>AI는 <strong>증상 치료</strong>만 했습니다. 근본적인 구조 문제를 파악하지 못했죠.</p>
<hr />
<h2 id="heading-7kce7zmy7kcqoidsgqzrnozsnzgg6rcc7j6f">전환점: 사람의 개입</h2>
<p>저는 브라우저 개발자 도구로 직접 확인했습니다.</p>
<pre><code class="lang-plaintext">1depth 구조:
[가변 영역 - Logo + Menus + Profile]
        ↓
     시작점: X

2depth 구조:
[가변 영역 - Submenus]
        ↓
     시작점: Y

❌ X ≠ Y
</code></pre>
<p><strong>문제 발견</strong>: 1depth와 2depth의 <strong>시작점이 다름</strong></p>
<p>이유는 간단했습니다:</p>
<ul>
<li><p>1depth: Logo가 왼쪽에 있음 (약 200px)</p>
</li>
<li><p>2depth: Logo가 없음 (0px)</p>
</li>
</ul>
<p>→ 메뉴들의 시작점이 200px 차이!</p>
<p>저는 AI에게 이렇게 말했습니다:</p>
<blockquote>
<p>"그냥 그럼 1depth의 왼쪽 로고랑 오른쪽 프로필 부분의 크기를 고정시키고, 메뉴들은 flex로 가운데 정렬하자. 그리고 2depth는 1depth를 따라서 왼쪽 오른쪽에 고정 width를 부여한 뒤에 flex로 가운데 정렬하면 크기가 잘 맞지 않을까?"</p>
</blockquote>
<hr />
<h2 id="heading-7zw06rkwoidqtazsobdsoieg7kcr6re8">해결: 구조적 접근</h2>
<p>AI는 제가 제시한 구조를 바로 구현했습니다.</p>
<h3 id="heading-1depth">1depth 구조</h3>
<pre><code class="lang-tsx">&lt;div className="flex items-center w-full h-16"&gt;
  {/* 좌측: 로고 - 고정폭 */}
  &lt;div className="flex items-center w-[200px] pl-8"&gt;
    &lt;Link to="/admin"&gt;
      &lt;img src="/logo.svg" alt="logo" /&gt;
    &lt;/Link&gt;
  &lt;/div&gt;

  {/* 중앙: 네비게이션 - 가변폭 + 중앙정렬 */}
  &lt;nav className="flex flex-1 justify-center"&gt;
    &lt;ul className="flex gap-8"&gt;
      {navMenus.map((menu) =&gt; (
        &lt;li key={menu.label} className="w-[110px]"&gt;
          &lt;button className="w-full text-center"&gt;
            {menu.label}
          &lt;/button&gt;
        &lt;/li&gt;
      ))}
    &lt;/ul&gt;
  &lt;/nav&gt;

  {/* 우측: 유저 정보 - 고정폭 */}
  &lt;div className="flex gap-1 items-center w-[200px] justify-end pr-8"&gt;
    {profile &amp;&amp; &lt;Profile {...profile} /&gt;}
  &lt;/div&gt;
&lt;/div&gt;
</code></pre>
<h3 id="heading-2depth-1depth">2depth 구조 (1depth를 그대로 따라함)</h3>
<pre><code class="lang-tsx">&lt;div className="flex items-center w-full py-6"&gt;
  {/* 좌측 여백 - 고정폭 */}
  &lt;div className="w-[200px]" /&gt;

  {/* 중앙: 2depth 메뉴 - 가변폭 + 중앙정렬 */}
  &lt;div className="flex flex-1 justify-center"&gt;
    &lt;div className="flex gap-8"&gt;
      {navMenus.map((menu) =&gt; (
        &lt;nav key={menu.label} className="flex flex-col w-[110px]"&gt;
          &lt;ul className="w-full"&gt;
            {menu.items.map((item) =&gt; (
              &lt;li key={item.href} className="w-full"&gt;
                &lt;NavLink to={item.href} className="block w-full text-center"&gt;
                  {item.label}
                &lt;/NavLink&gt;
              &lt;/li&gt;
            ))}
          &lt;/ul&gt;
        &lt;/nav&gt;
      ))}
    &lt;/div&gt;
  &lt;/div&gt;

  {/* 우측 여백 - 고정폭 */}
  &lt;div className="w-[200px]" /&gt;
&lt;/div&gt;
</code></pre>
<h3 id="heading-7zw17iusioq1royhsa">핵심 구조</h3>
<pre><code class="lang-plaintext">[200px 고정] [flex-1 중앙정렬] [200px 고정]
     ↓              ↓              ↓
   로고           메뉴           프로필    (1depth)
   여백         서브메뉴          여백     (2depth)
</code></pre>
<p><strong>결과</strong>: 정렬 완료!</p>
<hr />
<h2 id="heading-ai-2">회고: AI의 한계와 강점</h2>
<h3 id="heading-ai-3">AI가 못한 것</h3>
<ol>
<li><p><strong>구조적 사고</strong></p>
<ul>
<li><p>"왜 정렬이 안 맞는가?" 본질 파악 실패</p>
</li>
<li><p>인과관계 추적 능력 부족</p>
</li>
</ul>
</li>
<li><p><strong>시각적 디버깅</strong></p>
<ul>
<li><p>브라우저로 실제 렌더링 확인 불가</p>
</li>
<li><p>너비와 위치의 실제 값 파악 어려움</p>
</li>
</ul>
</li>
<li><p><strong>패턴 인식</strong></p>
<ul>
<li><p>"1depth와 2depth는 같은 구조여야 한다"는 원칙 미적용</p>
</li>
<li><p>계층 간 일관성 필요성 인지 실패</p>
</li>
</ul>
</li>
</ol>
<h3 id="heading-ai-4">AI가 잘한 것</h3>
<ol>
<li><p><strong>구현 속도</strong></p>
<ul>
<li><p>구조만 제시하면 즉시 코드 작성</p>
</li>
<li><p>Tailwind 클래스 정확하게 사용</p>
</li>
</ul>
</li>
<li><p><strong>코드 품질</strong></p>
<ul>
<li><p>접근성(aria 속성) 유지</p>
</li>
<li><p>반응형 고려 (w-full, flex 등)</p>
</li>
<li><p>TypeScript 타입 안정성 유지</p>
</li>
</ul>
</li>
<li><p><strong>반복 작업</strong></p>
<ul>
<li><p>동일한 패턴을 1depth, 2depth에 일관되게 적용</p>
</li>
<li><p>리팩토링 시 실수 없음</p>
</li>
</ul>
</li>
</ol>
<hr />
<h2 id="heading-ai-5">교훈: AI와 함께 일하는 법</h2>
<h3 id="heading-1-ai-css">1. AI는 CSS 속성 전문가, 구조 설계자는 아니다</h3>
<pre><code class="lang-tsx">// ❌ AI에게 이렇게 요청하면
"정렬 맞춰줘"

// ✅ 이렇게 요청하라
"양쪽 200px 고정하고, 중앙은 flex-1로 정렬하는 구조로 만들어줘"
</code></pre>
<h3 id="heading-2-1">2. 문제의 본질은 사람이 파악하라</h3>
<p>AI에게 맡길 것</p>
<ul>
<li><p>CSS 속성 작성</p>
</li>
<li><p>코드 반복 작업</p>
</li>
<li><p>타입 정의</p>
</li>
</ul>
<p>사람이 할 것</p>
<ul>
<li><p>구조 설계</p>
</li>
<li><p>문제 원인 분석</p>
</li>
<li><p>패턴 식별</p>
</li>
</ul>
<h3 id="heading-3">3. 구조를 명확히 제시하라</h3>
<pre><code class="lang-plaintext">[A영역: 고정폭] [B영역: 가변폭] [C영역: 고정폭]
</code></pre>
<p>이렇게 <strong>구조를 먼저 설계</strong>하고 AI에게 구현을 맡기면 효율적입니다.</p>
<hr />
<h2 id="heading-6rkw66gg">결론</h2>
<p>AI 코딩 어시스턴트는 강력하지만 만능은 아닙니다.</p>
<p><strong>AI의 역할</strong>: 빠르고 정확한 코드 작성<br /><strong>사람의 역할</strong>: 구조 설계와 문제 본질 파악</p>
<p>둘의 협업이 효과적인 결과를 만듭니다.</p>
<p>이번 경험을 통해 배운 것</p>
<ol>
<li><p>레이아웃 문제는 <strong>구조</strong>로 해결한다</p>
</li>
<li><p>CSS 속성 조정은 <strong>증상 치료</strong>일 뿐이다</p>
</li>
<li><p>AI는 <strong>도구</strong>이지 <strong>설계자</strong>가 아니다</p>
</li>
</ol>
<p>AI와 함께 작업하다가 막힌다면, 한 걸음 물러나서 <strong>구조</strong>를 다시 생각해보면 좋은 문제 해결을 이끌어낼 수 있습니다.</p>
<hr />
<h2 id="heading-67aa66gdoidroijsnbtslytsm4mg7kcv66csioyyto2broumroykpo2kua">부록: 레이아웃 정렬 체크리스트</h2>
<p>비슷한 문제가 생기면 확인해볼 5가지</p>
<ul>
<li><p>[ ] 각 영역의 너비가 명확히 정의되어 있는가?</p>
</li>
<li><p>[ ] 정렬되어야 할 요소들이 같은 시작점을 갖는가?</p>
</li>
<li><p>[ ] 모든 계층에서 동일한 구조 패턴을 사용하는가?</p>
</li>
<li><p>[ ] 양쪽 끝을 고정폭으로 만들었는가?</p>
</li>
<li><p>[ ] 중앙 영역에 <code>flex-1 + justify-center</code>를 사용했는가?</p>
</li>
</ul>
<hr />
<p><strong>기술 스택</strong>: React 19, TypeScript 5, Tailwind CSS 4, Vite 6<br /><strong>AI 도구</strong>: Claude Sonnet (Cursor IDE)<br /><strong>날짜</strong>: 2025년 11월</p>
<hr />
]]></content:encoded></item><item><title><![CDATA[[기술회고] NavLink 스타일링 버그 수정기]]></title><description><![CDATA[TL;DR
2개의 depth를 가진 네비게이션의 활성화(active) 상태를 표현하는 스타일이 사라져버린 버그를 수정하는 과정에서, AI가 디자인 가이드를 잘못 해석해 3번 잘못된 수정을 제안 했습니다. 결국 제가 판단한 추가적인 피드백으로 문제를 해결할 수 있었습니다. 그에 대한 사례를 간단히 정리 해봤습니다.

문제의 시작: 사라진 활성 상태
헤더의 2depth 메뉴에서 현재 페이지를 나타내는 활성 상태 스타일이 작동하지 않는다는 이슈를 할...]]></description><link>https://ted-projects.com/ai-navlink-styling</link><guid isPermaLink="true">https://ted-projects.com/ai-navlink-styling</guid><category><![CDATA[react router]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Tue, 18 Nov 2025 11:38:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1762334499649/85008179-539c-400f-8862-c1648097fd07.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-tldr">TL;DR</h2>
<p>2개의 depth를 가진 네비게이션의 활성화(<code>active</code>) 상태를 표현하는 스타일이 사라져버린 버그를 수정하는 과정에서, AI가 디자인 가이드를 잘못 해석해 3번 잘못된 수정을 제안 했습니다. 결국 제가 판단한 추가적인 피드백으로 문제를 해결할 수 있었습니다. 그에 대한 사례를 간단히 정리 해봤습니다.</p>
<hr />
<h2 id="heading-66y47kcc7j2yioylnoyektog7iks65287keeio2znoyessdsg4htg5w">문제의 시작: 사라진 활성 상태</h2>
<p>헤더의 2depth 메뉴에서 현재 페이지를 나타내는 활성 상태 스타일이 작동하지 않는다는 이슈를 할당 받았습니다.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 문제가 된 코드</span>
&lt;NavLink
  to={item.href}
  className={cn(
    <span class="hljs-string">"block p-0 w-full text-center ..."</span>,
    activeFirstDepthMenu?.label === menu.label
      ? <span class="hljs-string">"text-normal"</span>
      : <span class="hljs-string">"text-placeholder"</span>,
  )}
&gt;
</code></pre>
<p>NavLink의 <code>className</code>이 문자열로 고정되면서 <code>isActive</code> prop을 활용하지 못하고 있었습니다.</p>
<hr />
<h2 id="heading-1">1차 시도: 과도한 수정</h2>
<p>AI는 문제를 파악하고 NavLink의 <code>className</code>을 콜백 함수로 변경했습니다.</p>
<pre><code class="lang-typescript">&lt;NavLink
  className={<span class="hljs-function">(<span class="hljs-params">{ isActive }</span>) =&gt;</span>
    cn(
      <span class="hljs-string">"block p-0 w-full text-center ... hover:text-primary-normal"</span>,
      isActive
        ? <span class="hljs-string">"text-primary-normal font-medium"</span>  <span class="hljs-comment">// 👈 파란색으로 강조</span>
        : activeFirstDepthMenu?.label === menu.label
          ? <span class="hljs-string">"text-normal"</span>
          : <span class="hljs-string">"text-placeholder"</span>,
    )
  }
&gt;
</code></pre>
<p><strong>AI의 판단</strong>: "활성 상태는 primary 색상으로 강조해야지!"</p>
<p><strong>실제 가이드</strong>: 활성 상태도 text-normal + font-medium으로 표시</p>
<hr />
<h2 id="heading-2">2차 시도: 규칙을 오해하다</h2>
<p>개발자가 직접 <code>text-primary-normal</code>을 <code>text-normal</code>로 수정했습니다.</p>
<p>그런데 AI는 이렇게 이해했습니다:</p>
<blockquote>
<p>"아! 2depth 메뉴는 <strong>선택되지 않은 1depth의 하위는 회색</strong>, <strong>마우스 오버만 파란색</strong>이구나!"</p>
</blockquote>
<pre><code class="lang-typescript">className={<span class="hljs-function">(<span class="hljs-params">{ isActive }</span>) =&gt;</span>
  cn(
    <span class="hljs-string">"... hover:text-primary-normal"</span>,
    isActive
      ? <span class="hljs-string">"text-normal font-medium"</span>
      : <span class="hljs-string">"text-placeholder"</span>,  <span class="hljs-comment">// 👈 모두 placeholder</span>
  )
}
</code></pre>
<p><strong>AI의 판단</strong>: "1depth 상관없이 활성 항목만 normal, 나머지는 전부 placeholder!"</p>
<p><strong>문제</strong>: 같은 카테고리 내 다른 메뉴까지 흐리게 표시되어 가독성 저하</p>
<hr />
<h2 id="heading-3">3차 시도: 다시 복잡하게</h2>
<p>개발자로부터 피드백:</p>
<blockquote>
<p>"Line 198의 className 콜백이 isActive가 false일 때 항상 placeholder를 반환하면서, 현재 1depth 아래의 다른 2depth 링크들도 전부 placeholder로 표시됩니다."</p>
</blockquote>
<p>AI는 3단계 분기가 필요하다고 판단했습니다</p>
<pre><code class="lang-typescript">className={<span class="hljs-function">(<span class="hljs-params">{ isActive }</span>) =&gt;</span>
  cn(
    <span class="hljs-string">"... hover:text-primary-normal"</span>,
    isActive
      ? <span class="hljs-string">"text-normal font-medium"</span>
      : activeFirstDepthMenu?.label === menu.label  <span class="hljs-comment">// 👈 다시 추가</span>
        ? <span class="hljs-string">"text-normal"</span>
        : <span class="hljs-string">"text-placeholder"</span>,
  )
}
</code></pre>
<p><strong>AI의 판단</strong>: "같은 1depth 내에서는 읽기 쉽게 normal 색상을 유지해야 해!"</p>
<p><strong>실제 가이드</strong>: 그런 규칙 없음. 활성 항목 외에는 모두 placeholder</p>
<hr />
<h2 id="heading-66ei7lmo64k0oidsgqzrnozsnzgg6rcc7j6f">마침내: 사람의 개입</h2>
<p>개발자의 강력한 피드백:</p>
<blockquote>
<p>"하 진짜 가이드에 맞는 구현이라니까 왜 자꾸 activeFirstDepthMenu 들이대냐고 아니라고!!!"</p>
</blockquote>
<p><strong>최종 정답</strong></p>
<pre><code class="lang-typescript">className={<span class="hljs-function">(<span class="hljs-params">{ isActive }</span>) =&gt;</span>
  cn(
    <span class="hljs-string">"block p-0 w-full text-center whitespace-nowrap rounded transition-all text-body-14 hover:text-primary-normal"</span>,
    isActive
      ? <span class="hljs-string">"text-normal font-medium"</span>
      : <span class="hljs-string">"text-placeholder"</span>,
  )
}
</code></pre>
<p><strong>실제 디자인 가이드</strong>:</p>
<ul>
<li><p>활성화된 2depth 항목: <code>text-normal font-medium</code></p>
</li>
<li><p>나머지 모든 2depth 항목: <code>text-placeholder</code></p>
</li>
<li><p>마우스 오버: <code>hover:text-primary-normal</code></p>
</li>
</ul>
<p>단순하고 명확한 규칙이었습니다.</p>
<hr />
<h2 id="heading-ai">AI는 왜 계속 틀렸을까?</h2>
<h3 id="heading-1-1">1. <strong>암묵적인 디자인 가이드를 추론하려 했다</strong></h3>
<p>AI는 명시되지 않은 "일반적인 UX 패턴"을 적용하려 했습니다</p>
<ul>
<li><p>"활성 항목은 primary 색상으로 강조한다"</p>
</li>
<li><p>"같은 카테고리 내에서는 가독성을 유지한다"</p>
</li>
</ul>
<p>하지만 이 프로젝트에는 <strong>고유한 디자인 시스템</strong>이 있었고, 그것은 일반적인 패턴과 달랐습니다.</p>
<h3 id="heading-2-1">2. <strong>컨텍스트를 과도하게 해석했다</strong></h3>
<p>개발자의 피드백</p>
<blockquote>
<p>"선택되지 않은 1Depth의 하위 메뉴는 회색 비활성 상태로 노출됩니다."</p>
</blockquote>
<p>AI의 해석</p>
<blockquote>
<p>"아! 그럼 선택된 1Depth의 하위는 활성 상태로 봐야겠네!"</p>
</blockquote>
<p><strong>실제 의미</strong>: 모든 2depth는 기본적으로 회색. 오직 현재 페이지만 예외.</p>
<h3 id="heading-3-1">3. <strong>점진적 수정의 함정</strong></h3>
<p>각 피드백에 대해 "기존 코드에 뭔가 추가"하는 방식으로 접근했습니다</p>
<ol>
<li><p><code>isActive</code> 추가</p>
</li>
<li><p><code>activeFirstDepthMenu</code> 체크 제거</p>
</li>
<li><p><code>activeFirstDepthMenu</code> 체크 다시 추가 ← 엉뚱한 방향</p>
</li>
</ol>
<p>처음부터 디자인 가이드를 정확히 이해했다면 2번에서 끝났을 일입니다.</p>
<hr />
<h2 id="heading-ai-1">교훈: AI와 협업할 때</h2>
<h3 id="heading-do">✅ DO</h3>
<ol>
<li><p><strong>명확한 디자인 스펙 제공</strong></p>
<ul>
<li><p>"활성 항목만 X 스타일, 나머지는 Y 스타일"처럼 명확하게</p>
</li>
<li><p>예외 케이스를 구체적으로 명시</p>
</li>
</ul>
</li>
<li><p><strong>잘못된 방향은 즉시 차단</strong></p>
<ul>
<li>"아니야" 보다는 "이건 필요 없어, 실제로는 A와 B 두 가지만 구분하면 돼"</li>
</ul>
</li>
<li><p><strong>실제 가이드 문서 공유</strong></p>
<ul>
<li>디자인 시스템 문서나 Figma 링크 제공</li>
</ul>
</li>
</ol>
<h3 id="heading-dont">❌ DON'T</h3>
<ol>
<li><p><strong>AI가 "일반적인 패턴"을 적용할 거라 기대</strong></p>
<ul>
<li>프로젝트마다 고유한 규칙이 있음</li>
</ul>
</li>
<li><p><strong>모호한 피드백</strong></p>
<ul>
<li><p>"가독성이 떨어진다" → 무엇을 기준으로?</p>
</li>
<li><p>"활성 상태가 표시되지 않는다" → 무엇이 활성 상태인지?</p>
</li>
</ul>
</li>
<li><p><strong>AI의 "논리적 추론"을 과신</strong></p>
<ul>
<li>AI는 컨텍스트를 유추하려 하지만, 틀릴 수 있음</li>
</ul>
</li>
</ol>
<hr />
<h2 id="heading-6rkw66gg">결론</h2>
<p>이번 사례는 <strong>AI가 문법은 완벽하게 처리하지만, 도메인 지식(디자인 가이드)은 여전히 사람의 명확한 전달이 필요</strong>하다는 것을 보여줍니다.</p>
<p>최종 코드는 단 3줄이었지만, 그 3줄에 도달하기까지 AI와 사람 사이의 4번의 대화가 필요했습니다.</p>
<p>AI 도구는 강력하지만, <strong>"무엇이 옳은지"는 아직 사람이 판단해줘야</strong> 했습니다. 특히 프로젝트 고유의 규칙과 컨텍스트가 중요한 영역에서는 더욱 그러리고 예상됩니다.</p>
<p>사실 이슈 자체는 크지 않았고, 문제도 간단히 해결할 수 있었지만, 이러한 사례들을 정리해서 모아둘 수 있다면 개인적으로 좋은 자료가 될 수 있을거 같아 글로 정리해봤습니다. 읽어주셔서 감사합니다.</p>
]]></content:encoded></item><item><title><![CDATA[[기술 회고] Radix UI의 함정]]></title><description><![CDATA[글을 시작하며: 끝나지 않는 삽질의 시작
React와 Radix UI로 복잡한 UI를 개발하다 보면, Dialog(Modal) 내부에 Select 컴포넌트를 넣는 경우는 매우 흔합니다. 저희 팀도 그랬죠. 모든 것이 완벽하게 작동하는 것처럼 보였습니다. 사용자가 Select를 열고, 옵션을 선택하고, 닫는 모든 과정이 매끄러웠습니다.
단 한 가지, ESC 키를 누르기 전까지는요.
Select 드롭다운이 열린 상태에서 ESC 키를 누르자, 닫혀야...]]></description><link>https://ted-projects.com/radix-ui</link><guid isPermaLink="true">https://ted-projects.com/radix-ui</guid><category><![CDATA[RadixUI]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Wed, 05 Nov 2025 09:50:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1762333140249/c5e84f6f-4825-4745-85ff-0d67a89eab2c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h3 id="heading-6ria7j2eioylnoyeke2vmoupsdog64gd64ky7keaioyviuuklcdsgr3sp4jsnzgg7iuc7j6r">글을 시작하며: 끝나지 않는 삽질의 시작</h3>
<p>React와 Radix UI로 복잡한 UI를 개발하다 보면, Dialog(Modal) 내부에 Select 컴포넌트를 넣는 경우는 매우 흔합니다. 저희 팀도 그랬죠. 모든 것이 완벽하게 작동하는 것처럼 보였습니다. 사용자가 Select를 열고, 옵션을 선택하고, 닫는 모든 과정이 매끄러웠습니다.</p>
<p><em>단 한 가지,</em> <code>ESC</code> <em>키를 누르기 전까지는요.</em></p>
<p>Select 드롭다운이 열린 상태에서 <code>ESC</code> 키를 누르자, 닫혀야 할 것은 Select 드롭다운 하나인데 뜬금없이 부모인 Modal 전체가 닫혀버렸습니다. 게다가 페이지 전체가 먹통이 되는 치명적인 버그까지 발생했습니다.</p>
<p>이 글은 이 미스터리한 버그를 잡기 위한 삽질 기록이자, Radix UI를 사용하는 개발자라면 누구나 빠질 수 있는 '보이지 않는 함정'과 문제 해결 과정에서의 '인지적 편향'에 대한 이야기입니다.</p>
<h3 id="heading-7iuc64e7zai642yiouqqoutocdsmktri7xrk6q6iousuoygnoydmcdrs7jsp4jsnyqg6rw65qr7keaiouqu2wiounmcdsnbtsnka">시도했던 모든 오답들: 문제의 본질을 꿰뚫지 못했던 이유</h3>
<p>저와 제 AI 페어 프로그래머(AKA. 커서 with Sonnet 4.5)는 이 문제를 해결하기 위해 다양한 방법을 시도했습니다.</p>
<ol>
<li><p><strong>표면적인 해결책: 이벤트 제어</strong></p>
<ul>
<li><p><strong>시도</strong>: <code>event.stopPropagation()</code>, <code>event.preventDefault()</code></p>
</li>
<li><p><strong>가설</strong>: "부모에게 이벤트가 전파되는 것을 막으면 해결될 것이다."</p>
</li>
<li><p><strong>실패 원인</strong>: Radix UI는 <code>Portal</code>을 통해 Dialog와 Select를 DOM 트리의 다른 위치에 렌더링합니다. 이 때문에 단순한 이벤트 버블링 차단으로는 둘의 상호작용을 제어할 수 없었습니다. <strong>현상에만 집중</strong>한 나머지, 근본적인 렌더링 구조를 간과했습니다.</p>
</li>
</ul>
</li>
<li><p><strong>구조적인 해결책: 상태 관리</strong></p>
<ul>
<li><p><strong>시도</strong>: <code>Select</code>의 열림 상태(<code>open</code>)를 Modal에서 <code>useState</code>로 직접 제어.</p>
</li>
<li><p><strong>가설</strong>: "React의 데이터 흐름에 따라 상태를 명시적으로 제어하면 완벽하게 동작할 것이다."</p>
</li>
<li><p><strong>실패 원인</strong>: Radix UI 내부의 이벤트 처리 로직이 React의 State 업데이트보다 먼저, 그리고 독립적으로 동작하는 것처럼 보였습니다. "버그는 작성한 코드 안에 있다"는 <strong>강력한 전제에 갇혀 있었습니다.</strong></p>
</li>
</ul>
</li>
<li><p><strong>극단적인 해결책: 전역 리스너</strong></p>
<ul>
<li><p><strong>시도</strong>: <code>document</code> 레벨에서 <code>keydown</code> 이벤트를 <code>capture</code> 단계에서 가로채기.</p>
</li>
<li><p><strong>가설</strong>: "가장 먼저 이벤트를 가로채서 막아버리면 다른 누구도 이벤트를 받지 못할 것이다."</p>
</li>
<li><p><strong>실패 원인</strong>: 이 방법은 Radix UI의 내부 포커스 및 이벤트 관리 시스템과 정면으로 충돌하며 예상치 못한 부작용만 낳았습니다.</p>
</li>
</ul>
</li>
</ol>
<h3 id="heading-6rkw7kcv7kcbioulqoyenoydmcdrk7hsnqugkoq3uoumroqzocdqt7jqsopsnyqg64at7lmcioydtoycock">결정적 단서의 등장 (그리고 그것을 놓친 이유)</h3>
<p>수많은 시도가 실패로 돌아가던 중, 문제의 핵심에 가장 가까운 단서가 등장했습니다. <code>Modal</code> 컴포넌트의 타입을 확장하는 과정에서 <code>onPointerDownOutside</code> 이벤트의 타입(<code>PointerDownOutsideEvent</code>)을 찾지 못하는 에러가 발생했습니다.</p>
<p>이때, AI 어시스턴트는 다음과 같은 제안을 했습니다.</p>
<blockquote>
<p>"이 타입은 <code>@radix-ui/react-dismissable-layer</code> 패키지에 있을 수 있습니다. 이 패키지를 직접 설치해서 타입을 가져옵시다."</p>
</blockquote>
<p>이게 결정적 단서였습니다. 하지만 저는 이 단서를 완전히 잘못 해석했습니다.</p>
<ul>
<li><p><strong>AI의 의도</strong>: "타입 에러를 해결하기 위해 의존성을 추가하자."</p>
</li>
<li><p><strong>저의 반응</strong>: "불필요한 의존성이다. <code>Dialog</code>가 이미 타입을 가지고 있을 것이다."</p>
</li>
</ul>
<p><strong>저와 AI 둘 다 이 패키지를 '버전 충돌의 잠재적 용의자'로 보지 못했습니다.</strong> AI는 '타입 소스'로, 저는 '불필요한 의존성'으로만 본 것입니다. <code>dismissable-layer</code>라는 이름이 수면 위로 떠 올랐을 때, "잠깐, Dialog와 Select가 이걸 각자 다른 버전으로 쓰고 있는 거 아닐까?"라는 질문을 던졌어야 했습니다. 하지만 저는 이미 '이벤트 핸들링'이라는 프레임에 갇혀 있었고, 이 중요한 단서를 그대로 흘려보내고 말았습니다.</p>
<h3 id="heading-7kee7kecioybkoydudog67o07j207keaioyviuuklcdsnzjsobtshleg67ke7kceioy2qeupja">진짜 원인: 보이지 않는 의존성 버전 충돌</h3>
<p>결국, Radix UI의 GitHub 이슈 트래커에서 진짜 원인을 찾았습니다.</p>
<ul>
<li><p><a target="_blank" href="https://github.com/radix-ui/primitives/issues/1951">Pressing 'esc' while Select is open inside Dialog closes entire Dialog · Issue #1951</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/radix-ui/primitives/issues/1088">[DismissableLayer] Layering breaks with different component versions · Issue #1088</a></p>
</li>
</ul>
<p><strong>원인은 코드 로직이 아닌, 패키지 의존성 버전의 미세한 불일치였습니다.</strong></p>
<p>Radix UI의 <code>Dialog</code>, <code>Select</code> 등의 컴포넌트들은 내부적으로 <code>@radix-ui/react-dismissable-layer</code> 라는 패키지를 공통으로 사용합니다. 이 패키지는 화면에 열린 "레이어"들의 스택을 관리하며, ESC 키나 외부 클릭 시 가장 위의 레이어부터 순서대로 닫는 역할을 합니다.</p>
<p>문제는, 패키지 매니저가 의존성을 설치할 때, <code>@radix-ui/react-dialog</code>가 의존하는 <code>dismissable-layer</code> 버전과 <code>@radix-ui/react-select</code>가 의존하는 <code>dismissable-layer</code> 버전이 미세하게 다를 수 있다는 점입니다.</p>
<p>이렇게 되면, 애플리케이션 내에 <strong>두 개의 독립적인</strong> <code>DismissableLayer</code> 인스턴스가 공존하게 됩니다. <code>Dialog</code>는 자신만의 레이어 스택을, <code>Select</code>는 또 다른 자신만의 스택을 갖게 되는 것이죠. <code>Dialog</code>의 레이어 관리자는 <code>Select</code>가 열렸다는 사실을 전혀 알지 못합니다. 이 상태에서 <code>ESC</code> 키를 누르면, <code>Dialog</code>는 자신이 최상위 레이어라고 착각하고 스스로를 닫아버리는 것입니다.</p>
<h3 id="heading-7zw06rkw7lgfoidsnzjsobtshleg67ke7kceioqwleygncdthrxsnbw">해결책: 의존성 버전 강제 통일</h3>
<p>원인을 알았으니 해결은 간단합니다. 프로젝트 전체에서 <code>@radix-ui/react-dismissable-layer</code>가 단 하나의 버전만 사용되도록 강제하면 됩니다.</p>
<p><code>package.json</code> 파일에 <code>overrides</code> (pnpm, npm) 또는 <code>resolutions</code> (yarn) 설정을 추가합니다.</p>
<p><code>package.json</code> (pnpm 사용 시)</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"pnpm"</span>: {
    <span class="hljs-attr">"overrides"</span>: {
      <span class="hljs-attr">"@radix-ui/react-dismissable-layer"</span>: <span class="hljs-string">"1.0.5"</span> 
    }
  }
}
</code></pre>
<blockquote>
<p><strong>팁</strong>: 버전은 <code>pnpm list @radix-ui/react-dismissable-layer</code> 명령어로 프로젝트 내에 설치된 버전을 확인하고 가장 최신 버전으로 통일하는 것이 좋습니다.</p>
</blockquote>
<p>설정을 추가한 후, 기존 <code>node_modules</code>와 lock 파일을 삭제하고 다시 설치합니다.</p>
<pre><code class="lang-bash">rm -rf node_modules pnpm-lock.yaml
pnpm install
</code></pre>
<h3 id="heading-6rkw66ggiouwjydqtzdtm4g">결론 및 교훈</h3>
<p>이번 경험은 저에게 몇 가지 중요한 교훈을 남겼습니다.</p>
<ol>
<li><p><strong>가장 깊은 곳을 의심하라</strong>: 설명하기 어려운 UI 버그, 특히 라이브러리 간 상호작용에서 문제가 발생하면 코드 로직뿐만 아니라 <strong>의존성 트리를 먼저 의심</strong>해야 합니다.</p>
</li>
<li><p><strong>단서를 놓치지 마라</strong>: 문제 해결 과정에서 등장하는 예상 밖의 에러나 키워드(이번 경우엔 <code>dismissable-layer</code>)는 때로 문제의 본질을 가리키는 나침반이 될 수 있습니다.</p>
</li>
<li><p><strong>인지적 편향을 경계하라</strong>: "버그는 내 코드에 있다"는 생각, 혹은 하나의 해결책에만 매몰되는 '확증 편향'은 문제의 본질을 보지 못하게 만듭니다. 때로는 한 걸음 물러나 전혀 다른 각도에서 문제를 바라볼 필요가 있습니다.</p>
</li>
</ol>
<p>이 글이 Radix UI의 복잡한 상호작용으로 고통받는 다른 개발자들에게 작은 도움이 되기를 바랍니다.</p>
]]></content:encoded></item><item><title><![CDATA[npm workspaces 모노레포 적용 회고]]></title><description><![CDATA[안녕하세요. 이번 글에서는 지난 4월 모노레포(Monorepo)를 도입하게 된 실제적인 배경과, npm workspaces를 활용하여 점진적으로 적용하는 과정에서 겪었던 시행착오, 그리고 그 해결 과정에 대한 회고를 공유합니다. 이 여정이 모노레포 도입을 고려하는 다른 분들에게 유의미한 경험담이 되기를 바랍니다.
모노레포 도입, 피할 수 없는 선택
제가 일하고 있는 프로젝트는 새로 도입하게 될 서비스를 설계하며 난관에 봉착했습니다. 서로 다른 ...]]></description><link>https://ted-projects.com/npm-workspaces</link><guid isPermaLink="true">https://ted-projects.com/npm-workspaces</guid><category><![CDATA[npm workspaces]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Thu, 25 Sep 2025 11:31:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1758813799639/795a8255-ca5e-4a4a-988a-4eeeffe2ccb2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>안녕하세요. 이번 글에서는 지난 4월 모노레포(Monorepo)를 도입하게 된 실제적인 배경과, npm workspaces를 활용하여 점진적으로 적용하는 과정에서 겪었던 시행착오, 그리고 그 해결 과정에 대한 회고를 공유합니다. 이 여정이 모노레포 도입을 고려하는 다른 분들에게 유의미한 경험담이 되기를 바랍니다.</p>
<h3 id="heading-66qo64w466ci7ysiouphoyehswg7zs87zwgioyimcdsl4bripqg7isg7yod">모노레포 도입, 피할 수 없는 선택</h3>
<p>제가 일하고 있는 프로젝트는 새로 도입하게 될 서비스를 설계하며 난관에 봉착했습니다. 서로 다른 서비스지만 <strong>동일한 UI 컴포넌트를 중복 사용해야만 하는 서비스</strong>를 만들게 된 것입니다. 예를 들어, 핵심 기능은 같지만 헤더 디자인만 다른 페이지를 여러 서비스에 걸쳐 구현해야 하는 경우가 많았습니다. 여러가지 문제가 발생할 수 있겠지만 그 중 컴포넌트의 비효율적인 관리와 개발 공수 증가가 예상됐습니다.</p>
<p>단순히 UI 컴포넌트뿐만이 아니었습니다. 데이터 통신을 담당하는 <strong>서비스 로직, 데이터 쿼리, 그리고 타입 정의까지도 여러 서비스에서 동일하게 사용될 것</strong>이 예상됐습니다. 개별 레포지토리에서 이러한 코드들을 복사-붙여넣기 하거나, 별도의 패키지로 관리하는 것은 매번 동기화 문제와 의존성 관리의 복잡성 등의 부작용을 야기할 수 있습니다.</p>
<p>또한, 현재 회사 인프라 구조상 서비스들의 <strong>배포 파이프라인이 동일한 구조</strong>를 가지고 있었기에, 개별 레포지토리에서 관리하는 것은 비효율적이었습니다. 이러한 문제들을 해결하고, 개발 생산성과 코드 재사용성을 극대화하기 위한 방안으로 모노레포 도입은 피할 수 없는 선택이었습니다.</p>
<h3 id="heading-npm-workspaces">npm workspaces: 익숙함 속의 점진적 변화</h3>
<p>모노레포 도입을 결정한 후, 저희는 기존 패키지 매니저인 npm을 유지하면서 모노레포의 이점을 얻을 수 있는 <strong>npm workspaces</strong>를 선택했습니다. pnpm이나 Yarn Berry와 같은 새로운 패키지 도구로의 전환이 가져올 학습 곡선과 잠재적 리스크를 최소화하고, 익숙한 환경에서 점진적으로 모노레포를 구축하고자 했습니다.</p>
<p>도입은 다음과 같은 점진적인 단계로 진행되었습니다.</p>
<h4 id="heading-1">1. 초기 구조 설계 및 패키지 분리 테스트</h4>
<p>가장 먼저, 모노레포의 핵심인 패키지 분리를 진행했습니다. 저희는 두 개의 주요 리액트 애플리케이션(<code>app</code>)과 이들이 공유할 코드를 담을 <code>shared</code> 패키지로 나누었습니다.</p>
<ul>
<li><p><strong>애플리케이션 (</strong><code>react-app-1</code>, <code>react-app-2</code>): 각 서비스의 고유한 로직과 UI를 담당합니다.</p>
</li>
<li><p><strong>공통 패키지 (</strong><code>shared</code>): 여러 서비스에서 재사용될 UI 컴포넌트, 유틸리티 함수, 타입 정의 등을 포함합니다.</p>
</li>
</ul>
<p>이 과정에서 <code>node_modules</code>의 동작 방식에 대한 이해가 중요했습니다. 각 하위 패키지의 <code>package.json</code>에서 <code>type: module</code>을 명시하거나, 서로 다른 버전의 의존성을 선언할 경우 개별적인 <code>node_modules</code>가 생성될 수 있음을 확인했습니다. 이렇게 생성되는 게 npm workspace의 기본 동작 방식입니다.</p>
<h4 id="heading-2">2. 설정 공유를 통한 개발 환경 통합</h4>
<p>개발 환경의 통일성은 모노레포의 큰 장점 중 하나입니다. 저는 타입스크립트, ESLint, Tailwind CSS와 같은 주요 설정들을 <code>shared</code> 패키지를 통해 공통으로 관리하고자 했습니다.</p>
<ul>
<li><p><strong>타입스크립트 (</strong><code>tsconfig</code>): 초기에는 <code>references</code> 방식을 고려했지만, 이는 주로 프로젝트 간 참조에 사용되는 옵션임을 파악했습니다. 대신 <code>tsconfig.base.json</code>을 만들어 공통 설정을 정의하고, 각 애플리케이션의 <a target="_blank" href="http://tsconfig.app"><code>tsconfig.app</code></a><code>.json</code> 및 <code>tsconfig.node.json</code>에서 이를 확장(extends)하는 방식을 사용했습니다. 이로써 모든 프로젝트가 일관된 타입 검사 규칙을 따르게 되었습니다.</p>
<pre><code class="lang-mermaid">  graph TD
      A[tsconfig.base.json] --&gt; B[tsconfig.app.json]
      A --&gt; C[tsconfig.node.json]
      B --&gt; D[React App 1]
      B --&gt; E[React App 2]
      C --&gt; F[Build Scripts]
      C --&gt; G[Node.js Utilities]
</code></pre>
</li>
<li><p><strong>ESLint:</strong> 모노레포 루트에 단 하나의 <code>.eslintrc.js</code> 파일을 배치하고, <code>tsconfig.eslint.json</code>을 활용하여 모든 자식 패키지에 공통적인 린팅 규칙을 적용했습니다. 초기에는 자식 패키지의 <code>tsconfig</code> 설정과 충돌하여 ESLint가 제대로 동작하지 않는 문제가 있었지만, 루트 설정에서 올바르게 <code>override</code>함으로써 해결했습니다.</p>
</li>
<li><p><strong>Tailwind CSS:</strong> <code>tailwind.config.js</code>는 각 패키지마다 필요했지만, <code>tailwind.config.css</code>는 하나만으로도 충분했습니다. <code>tailwind.config.ts</code>의 <code>content</code> 경로 설정 오류로 스타일이 적용되지 않는 문제가 있었고, <code>shared</code> 패키지의 컴포넌트를 올바르게 참조하도록 경로를 수정하여 해결했습니다.</p>
</li>
<li><p><strong>스토리북:</strong> 공통 컴포넌트를 위한 스토리북을 <code>shared</code> 패키지에 통합하려 했으나, 스타일 독립성과 개발 서버의 캐싱 문제로 인해 통합 구현은 어려웠습니다. 결국 <code>shared</code> 패키지 자체와 각 애플리케이션(<code>learner</code> 등) 하위에 <code>.storybook</code> 설정을 분리하여 관리했습니다. 이로 인해 <code>node_modules/.cache</code> 디렉토리가 각 프로젝트에 생성되는 부작용(side effect)이 있었습니다.</p>
</li>
</ul>
<h4 id="heading-3">3. 점진적 코드 이전과 난관들</h4>
<p>설정 통합을 마친 후, 본격적으로 기존 코드들을 <code>shared</code> 패키지로 이전하는 작업을 시작했습니다. 이 과정은 예상보다 훨씬 험난했습니다.</p>
<ul>
<li><p><strong>타입 시스템과의 씨름:</strong> 타입 선언, 추론 방식이 변경되면서 스토리북 및 <code>useQuery</code> 반환 값의 타입이 <code>any</code>로 추론되는 문제가 발생했습니다. 이는 두 개의 <code>tsconfig</code>에서 <code>paths</code> 설정이 충돌한 것이 주요 원인이었습니다. <code>references</code> 설정을 통해 해결을 시도했으나, 파일 경로 인식 문제와 <code>paths</code> 옵션 조정 과정에서 또 다른 문제가 발생하는 등 많은 시행착오를 겪었습니다.</p>
</li>
<li><p><code>import</code> 경로 문제: <code>package.json</code> 설정을 통해서 파일 확장자를 명시하는 <code>import</code>가 동작하지 않아 기존 코드들을 상당 부분 수정해야 했습니다.(많은 코드들을 배럴파일 화 하는 등 추가 작업이 필요했습니다)</p>
</li>
<li><p><code>shared</code> 패키지의 빌드 의존성: <code>shared</code> 패키지의 코드가 다른 애플리케이션에서 사용되기 전에 먼저 빌드되어야 한다는 것을 깨달았습니다. <code>package.json</code>의 <code>scripts</code> 설정을 통해 <code>shared</code> 패키지의 선 빌드를 자동화하여 해결했습니다.</p>
</li>
<li><p><strong>스타일 깨짐 현상:</strong> <code>shared</code> 패키지로 이전된 컴포넌트의 스타일이 적용되지 않는 문제가 발생했습니다. 이는 <code>tailwind.config.ts</code>의 <code>content</code> 경로가 <code>shared</code> 패키지를 올바르게 참조하도록 설정되지 않았기 때문이었습니다.</p>
</li>
</ul>
<h4 id="heading-4-git">4. 실제 프로젝트 적용과 Git 관리</h4>
<p>모노레포의 실제 프로젝트 적용은 기존 Git 레포지토리의 구조를 변경하는 작업이 포함되었습니다. 기존 운영 PR들을 머지하고, 새로운 모노레포 폴더 구조를 생성한 뒤 코드를 이전했습니다. 이후 모노레포 설정 적용, shared로 코드 이전 및 import 수정, 불필요한 package.json 정리, 그리고 설정을 위한 패키지 분리까지 완료했습니다.</p>
<p>이 과정에서 git-filter-repo와 같은 도구를 사용해야 하는지에 대한 고민이 있었습니다. git-filter-repo는 주로 서로 다른 Git 히스토리를 가진 독립적인 저장소들을 하나의 모노레포로 통합할 때 필요합니다. 하지만 저희 프로젝트의 경우, 기존 프로젝트를 모노레포 구조로 재구성하는 형태였기에 별도의 Git 히스토리 병합이 필요하지 않았고, 결국 이 도구를 사용하지 않고도 원활하게 모노레포를 구축할 수 있었습니다.</p>
<p>마지막으로, 통합된 CI/CD 파이프라인을 구축하여 하나의 파이프라인 파일로 여러 서비스의 빌드 및 배포를 관리할 수 있게 되었습니다. 이는 배포 프로세스의 효율성을 크게 향상시켰습니다.</p>
<h3 id="heading-5">회고: 배운 점과 5개월 후의 개선점들</h3>
<p>모노레포 전환은 기술적인 깊이와 넓은 시야를 요구하는 복잡한 작업이었습니다. npm workspaces의 특성을 이해하고, TypeScript, ESLint, Tailwind CSS 등 다양한 도구의 설정을 통합하는 과정에서 많은 시간을 들였지만, 그만큼 팀원들의 문제 해결 능력과 기술 역량을 크게 향상시키는 계기가 되었습니다.</p>
<p>이번 여정을 통해 얻은 가장 큰 교훈은 <strong>점진적인 접근과 충분한 테스트, 그리고 명확한 의존성 관리의 중요성</strong>이었습니다. 초기에는 예상치 못한 문제들이 속출했지만, 매일매일 해결책을 찾아나가면서 목표를 달성할 수 있었습니다. 특히, 어떤 도구가 어떤 상황에 필요한지에 대한 정확한 이해(예: git-filter-repo 불필요)는 불필요한 공수를 줄이는 데 중요함을 깨달았습니다.</p>
<p>모노레포 도입 후 5개월여가 지났습니다. 큰 구조적 변화는 없었지만 몇 가지 작업이 더 진행 됐습니다.</p>
<ul>
<li><p><code>build</code> 설정을 각 프로젝트에 보다 체계적으로 배치하는 작업</p>
</li>
<li><p><code>shared</code> 컴포넌트들의 폴더 트리를 더욱 효율적으로 구성하는 작업</p>
</li>
<li><p><code>firebase</code>, <code>react-gtm-module</code>, <code>tanstack/react-query</code> 등 주요 라이브러리들을 <code>shared</code> 영역으로 완전히 이전하는 작업</p>
</li>
</ul>
<p>이번 프로젝트에서 모노레포를 적용한 건 서비스 확장 전략에 있어 핵심적인 기술 선택이 됐고 많은 유익이 있었습니다.<br />하지만 이 글에서 언급됐던것처럼 다양한 설정들을 조정해야 될 수 있으니 트레이드 오프를 고려하여 적용되야 할 기술이라고 생각합니다.</p>
]]></content:encoded></item><item><title><![CDATA[간헐적 Api 타임아웃 해결기]]></title><description><![CDATA[1. 개요
간헐적으로 발생하는 API 타임아웃은 원인 파악이 가장 까다로운 장애 유형 중 하나입니다. 이 글에서는 다중 프록시 및 로드밸런서를 거치는 아키텍처의 프론트엔드 서버에서 발생한 간헐적 API 타임아웃의 원인을 분석하고, Nginx의 DNS 해석 방식을 수정하여 문제를 해결한 과정을 회고합니다.
2. 시스템 아키텍처와 문제 현상
장애가 발생한 시스템의 트래픽 흐름은 다음과 같이 다층적으로 구성되어 있습니다.(완전히 정확하진 않고 대략 ...]]></description><link>https://ted-projects.com/api-timeout-error</link><guid isPermaLink="true">https://ted-projects.com/api-timeout-error</guid><category><![CDATA[nginx]]></category><category><![CDATA[aws-waf]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Wed, 24 Sep 2025 14:27:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1758722443388/17d1d4a5-ca6e-4af9-9c41-a3d3ca8011ab.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h4 id="heading-1"><strong>1. 개요</strong></h4>
<p>간헐적으로 발생하는 API 타임아웃은 원인 파악이 가장 까다로운 장애 유형 중 하나입니다. 이 글에서는 다중 프록시 및 로드밸런서를 거치는 아키텍처의 프론트엔드 서버에서 발생한 간헐적 API 타임아웃의 원인을 분석하고, Nginx의 DNS 해석 방식을 수정하여 문제를 해결한 과정을 회고합니다.</p>
<h4 id="heading-2"><strong>2. 시스템 아키텍처와 문제 현상</strong></h4>
<p>장애가 발생한 시스템의 트래픽 흐름은 다음과 같이 다층적으로 구성되어 있습니다.(완전히 정확하진 않고 대략 이렇습니다)</p>
<p><code>Client -&gt; AWS API Gateway -&gt; WAF ALB -&gt; 3rd-party WAF -&gt; ALB -&gt; Frontend Server(Nginx)</code></p>
<p>문제는 이 마지막 단계인 <strong>프론트엔드 서버의 Nginx</strong>에서 발생했습니다. 이 Nginx 서버는 SPA(Single Page Application)의 정적 빌드 파일을 서빙하는 동시에, <code>/api/*</code> 경로로 들어오는 모든 요청을 백엔드 API 게이트웨이(자체 ALB, <a target="_blank" href="http://api.aurora-corp.io"><code>api.aurora-corp.io</code></a> 도메인, 의사 도메인입니다)로 프록시하는 역할을 담당했습니다.</p>
<p>아래는 전체 아키텍처를 도식화한 차트입니다.</p>
<pre><code class="lang-mermaid">graph TD
    %% Node Definitions
    Client([Client])
    APIGW[AWS API Gateway]
    WAF_ALB[WAF + ALB]
    ThirdPartyWAF[3rd-party WAF]
    FE_ALB[ALB for Frontend]
    Nginx[Frontend Server &lt;br&gt; Nginx]
    Backend_GW[Backend API Gateway&lt;br&gt;api.aurora-corp.io via ALB]

    %% Subgraph Grouping
    subgraph "User Zone"
        Client
    end

    subgraph "AWS Infrastructure"
        APIGW
        WAF_ALB
        ThirdPartyWAF
        FE_ALB
        Nginx
    end

    subgraph "Backend Zone"
        Backend_GW
    end

    %% Edge Definitions (Connections)
    Client --&gt; APIGW
    APIGW --&gt; WAF_ALB
    WAF_ALB --&gt; ThirdPartyWAF
    ThirdPartyWAF --&gt; FE_ALB
    FE_ALB --&gt; Nginx
    Nginx -- "/api proxy" --&gt; Backend_GW

    %% Styling
    style Nginx fill:#f9f,stroke:#333,stroke-width:2px,color:black
</code></pre>
<p><strong>주요 현상:</strong></p>
<ul>
<li><p>정적 파일 서빙(<code>index.html</code>, JS, CSS 등)은 항상 정상적으로 동작했습니다.</p>
</li>
<li><p>오직 <code>/api/*</code> 경로로 프록시되는 요청 중 일부만 Connection Timeout 에러를 반환했습니다.</p>
</li>
<li><p>장애는 특정 API에 국한되지 않고 불규칙하게 발생했습니다.</p>
</li>
<li><p>프론트엔드 Nginx 프로세스를 재시작(<code>restart</code> or <code>reload</code>)하면 현상이 해소되었습니다.</p>
</li>
</ul>
<h4 id="heading-3-dns-stale-keep-alive-connection"><strong>3. 원인 분석: 정적 DNS 캐싱과 Stale Keep-alive Connection</strong></h4>
<p>초기 장애 분석 단계에서, 문제의 범위를 좁히기 위해 전체 트래픽 경로에 대한 로그를 면밀히 검토했습니다. AWS API Gateway, 각 단계의 WAF 및 ALB, 그리고 최종 백엔드 애플리케이션의 로그까지 확인했지만, <strong>어떤 컴포넌트에서도 지연 시간 증가, 에러 로그, 슬로우 쿼리 등의 유의미한 흔적을 발견할 수 없었습니다.</strong></p>
<p>모든 백엔드 시스템이 정상적으로 응답하고 있다는 사실은, 장애의 원인이 외부가 아닌 <strong>프론트엔드 Nginx 자체</strong>에 국한되어 있음을 명확히 나타내고 있었습니다. 프론트 재배포로 인한 Nginx 재시작으로 문제가 임시 해결된다는 점을 바탕으로, Nginx의 내부 상태, 특히 백엔드와의 커넥션 관리 방식을 집중적으로 분석했습니다.</p>
<p>핵심 원인은 Nginx의 <code>upstream</code> 블록이 동적 클라우드 리소스(ALB)와 상호작용하는 방식에 있었습니다.</p>
<ol>
<li><p><strong>Nginx</strong> <code>upstream</code>의 DNS 캐싱: 오픈소스 Nginx의 <code>upstream</code> 모듈은 설정이 로드되는 시점(시작 또는 리로드)에 단 한 번만 도메인 이름(<a target="_blank" href="http://api.aurora-corp.io"><code>api.aurora-corp.io</code></a>)을 IP 주소로 확인(resolve)한다. 그리고 이 IP 주소 목록을 내부 메모리에 캐싱합니다.</p>
</li>
<li><p><strong>백엔드 ALB의 동적 IP</strong>: 백엔드 API 게이트웨이 앞단의 ALB는 트래픽, 인스턴스 상태 등에 따라 노드의 IP 주소를 동적으로 변경합니다.</p>
</li>
<li><p><strong>Stale Connection 발생</strong>: 프론트엔드 Nginx는 최초에 확인한 백엔드 ALB의 IP 주소 목록을 기반으로 TCP 커넥션 풀(Keep-alive)을 생성하고 유지한다. 시간이 지나 백엔드 ALB의 IP가 변경되면, Nginx의 커넥션 풀에 남아있는 일부 커넥션은 더 이상 유효하지 않은 과거의 IP(Stale IP)를 가리키게 된다.</p>
</li>
</ol>
<p>새로운 API 요청이 이 Stale IP를 사용하는 커넥션을 재사용하게 되면, Nginx는 존재하지 않는 엔드포인트에 연결을 시도하다가 결국 <code>proxy_connect_timeout</code>에 도달하여 실패합니다. 유효한 커넥션을 할당받은 다른 요청들은 정상 처리되었기 때문에, '간헐적'이고 '일부 API'에서만 타임아웃이 발생하는 것처럼 보인 것입니다.</p>
<h4 id="heading-4-dns-resolution"><strong>4. 해결 방안: 동적 DNS Resolution으로 전환</strong></h4>
<p>이 문제를 해결하기 위해 <code>upstream</code> 블록을 제거하고, Nginx가 요청 시점에 DNS를 동적으로 조회하도록 설정을 변경했습니다.</p>
<pre><code class="lang-nginx"><span class="hljs-comment"># /etc/nginx/conf.d/default.conf</span>

<span class="hljs-section">server</span> {
  <span class="hljs-comment"># 1. Resolver 정의: 신뢰할 수 있는 DNS 서버(VPC 내부 리졸버 등)를 지정.</span>
  <span class="hljs-attribute">resolver</span> <span class="hljs-number">10.110.11.11</span> valid=<span class="hljs-number">30s</span>;

  <span class="hljs-comment"># 2. 백엔드 호스트를 변수로 선언.</span>
  <span class="hljs-attribute">set</span> <span class="hljs-variable">$backend_host</span> <span class="hljs-string">"api.aurora-corp.io"</span>;

  <span class="hljs-attribute">location</span> <span class="hljs-regexp">~ ^/api/</span> {
    <span class="hljs-comment"># 3. proxy_pass에 변수 사용: 변수를 사용하면 Nginx는</span>
    <span class="hljs-comment"># resolver를 통해 TTL(valid=30s)을 존중하며 DNS를 동적으로 조회한다.</span>
    <span class="hljs-attribute">proxy_pass</span> https://<span class="hljs-variable">$backend_host</span><span class="hljs-variable">$request_uri</span>;
    ...
  }
}
</code></pre>
<p><code>proxy_pass</code> 지시어에 변수가 포함되면 Nginx는 <code>upstream</code>의 정적 캐시 메커니즘을 사용하지 않고, <code>resolver</code>를 통해 DNS를 해석합니다. 이로써 백엔드 ALB의 IP 변경에 유연하게 대응할 수 있게 되었다.</p>
<h4 id="heading-5"><strong>5. 파생 문제 해결 및 최종 설정</strong></h4>
<p><code>proxy_pass</code>에 변수를 사용하는 방식은 세 가지 중요한 부가 설정을 요구합니다.</p>
<ol>
<li><p><strong>쿼리스트링 보존</strong>: <code>proxy_pass https://$backend_host/$1$2$is_args$args</code>변수 사용 시 쿼리스트링이 누락되므로, <code>$is_args$args</code>를 명시하여 보존했습니다</p>
</li>
<li><p><strong>SNI(Server Name Indication) 활성화</strong>: <code>proxy_ssl_server_name on;</code></p>
<ul>
<li>HTTPS 백엔드 통신 시 TLS 핸드셰이크 오류를 방지하기 위해 SNI를 활성화했습니다.</li>
</ul>
</li>
<li><p><strong>원본 프로토콜 전달</strong>: <code>proxy_set_header X-Forwarded-Proto $scheme;</code></p>
<ul>
<li>다중 프록시 환경에서 원본 프로토콜을 정확하게 전달하도록 수정했습니다.</li>
</ul>
</li>
</ol>
<h4 id="heading-6"><strong>6. 결론</strong></h4>
<p>복잡한 클라우드 아키텍처에서는 각 컴포넌트의 내부 동작 방식을 정확히 이해하는 것이 중요합니다. 이번 장애는 프론트엔드 서버 Nginx의 DNS 캐싱 정책이 백엔드 ALB의 동적 특성과 맞지 않아 발생한 문제였습니다. 특히, 장애 분석 과정에서 다른 모든 시스템의 정상 동작을 먼저 확인함으로써 문제의 범위를 효과적으로 좁힐 수 있었습니다.</p>
<h3 id="heading-kirrp4jsuzjrqbaqkg"><strong>마치며</strong></h3>
<ul>
<li><p>백엔드 엔드포인트가 ALB 등 동적 IP를 사용하는 서비스일 경우, Nginx 오픈소스 버전의 <code>upstream</code> 블록 사용은 잠재적 장애 요인이 될 수 있습니다.</p>
</li>
<li><p><code>resolver</code>와 변수를 사용한 동적 DNS 조회는 이러한 환경에 대한 표준적인 해결책입니다.</p>
</li>
<li><p>하나의 설정을 변경하면 연쇄적으로 고려해야 할 다른 설정(쿼리스트링, SNI 등)이 있음을 인지하고 종합적으로 접근해야 합니다.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[React와 Chrome 번역 기능의 전쟁]]></title><description><![CDATA[문제의 시작: 유령처럼 나타나는 DOM 오류
개발 후 QA과정에서 재현 조건이 까다로운 오류와 마주쳤습니다.
NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

React 개발자라면 한 번쯤 봤을 법한 이 오류는 일반적으로 React의 가상 DOM 상태와 실제 DOM 상태가 일치하지 않을 때 발생합니...]]></description><link>https://ted-projects.com/conflict-between-react-and-chrome-translate</link><guid isPermaLink="true">https://ted-projects.com/conflict-between-react-and-chrome-translate</guid><category><![CDATA[browser translate]]></category><category><![CDATA[React]]></category><category><![CDATA[RadixUI]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Thu, 11 Sep 2025 11:08:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1756173507851/639f9f86-93d1-414a-bedb-409e2e710675.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h3 id="heading-dom">문제의 시작: 유령처럼 나타나는 DOM 오류</h3>
<p>개발 후 QA과정에서 재현 조건이 까다로운 오류와 마주쳤습니다.</p>
<pre><code class="lang-xml">NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
</code></pre>
<p>React 개발자라면 한 번쯤 봤을 법한 이 오류는 일반적으로 React의 가상 DOM 상태와 실제 DOM 상태가 일치하지 않을 때 발생합니다. 하지만 이번 경우는 달랐습니다. 오류는 특정 사용자 그룹의 특정 상황, 바로 <strong>Chrome의 '이 페이지 번역' 기능을 활성화했을 때만</strong> 발생했습니다.</p>
<h3 id="heading-6riw64ky6ri0iouulouyhoq5hsdsl6zsoju6ioqwgoyepoqzvcdsi6ttjkjsnzgg7jew7ian">기나긴 디버깅 여정: 가설과 실패의 연속</h3>
<p>이 문제를 해결하기 위해 여러 가설을 세우고 다양한 해결책을 시도했습니다.</p>
<ol>
<li><p><strong>가설 1: React 상태 업데이트 경쟁 상태</strong></p>
<ul>
<li><p><strong>시도</strong>: <code>Select</code> 컴포넌트의 <code>onValueChange</code> 핸들러에서 여러 상태가 동시에 업데이트되면서 렌더링 충돌이 발생한다고 추측했습니다. <code>useTransition</code>과 <code>setTimeout</code>을 사용해 상태 업데이트에 지연을 주어 렌더링 타이밍을 분리하고자 했습니다.</p>
</li>
<li><p><strong>결과</strong>: 실패. 오류는 여전히 발생했습니다.</p>
</li>
</ul>
</li>
<li><p><strong>가설 2: 컴포넌트 레벨에서의 번역 차단</strong></p>
<ul>
<li><p><strong>시도</strong>: 문제가 Chrome 번역 기능에 있다는 것을 인지하고, 오류가 발생하는 Radix UI의 <code>SelectItem</code> 컴포넌트에 <code>translate="no"</code> 속성을 추가하여 해당 부분의 번역을 막으려고 시도했습니다.</p>
</li>
<li><p><strong>결과</strong>: 실패. <code>SelectItem</code> 내부의 텍스트 노드는 여전히 번역기에 의해 수정되었고, 문제는 해결되지 않았습니다.</p>
</li>
</ul>
</li>
<li><p><strong>가설 3: Radix UI의 Portal 시스템과 번역기의 충돌</strong></p>
<ul>
<li><p><strong>시도</strong>: Radix UI는 <code>Select</code>의 드롭다운 메뉴를 <code>document.body</code> 끝에 Portal을 사용해 렌더링합니다. 이 Portal 내부의 DOM이 번역기에 의해 조작되면서 충돌이 발생한다고 판단, Portal 기능을 제거하고 컴포넌트 내부에 직접 렌더링하도록 수정했습니다. 또한 <code>requestAnimationFrame</code>과 더 긴 <code>setTimeout</code> 지연(100ms)을 결합하여 타이밍 이슈를 해결하려 했습니다.</p>
</li>
<li><p><strong>결과</strong>: 실패. 오류의 발생 빈도가 줄어드는 것처럼 보였지만, 근본적인 해결책은 아니었습니다. 오류는 결국 다시 발생했습니다.</p>
</li>
</ul>
</li>
</ol>
<h3 id="heading-react">근본 원인 분석: React의 예측을 배신하는 <code>&lt;font&gt;</code> 태그</h3>
<p>모든 시도가 실패로 돌아간 후, 우리는 문제의 본질을 더 깊이 파고들었습니다. 원인은 <strong>React의 가상 DOM</strong>과 <strong>Chrome 번역기가 실제 DOM을 조작하는 방식</strong> 사이의 근본적인 불일치에 있었습니다. 특히, 한 커뮤니티 블로그 포스트(<a target="_blank" href="https://localhost.tistory.com/24#%F0%9F%92%A1%EC%98%A4%EB%A5%98%20%EC%9B%90%EC%9D%B8-1">출처</a>)는 결정적인 단서를 제공했습니다.</p>
<h4 id="heading-react-dom">React: 예측 가능한 가상 DOM</h4>
<p>React는 "가상 DOM"이라는 자신만의 설계도를 가지고 있습니다. React가 렌더링하는 모든 요소는 이 설계도에 기록됩니다. 예를 들어 <code>SelectItem</code>은 내부에 "이름"이라는 순수한 텍스트 노드를 가지고 있습니다.</p>
<pre><code class="lang-xml">// React의 가상 DOM (설계도)
<span class="hljs-tag">&lt;<span class="hljs-name">Item</span>&gt;</span>
  "이름"  <span class="hljs-tag">&lt;<span class="hljs-name">--</span> 텍스트 노드
&lt;/<span class="hljs-attr">Item</span>&gt;</span>
</code></pre>
<h4 id="heading-67ki7jet6riwoidsmijsukeg67ai6rca64ql7zwcigbgio2dnoq3ucdso7zsnou">번역기: 예측 불가능한 <code>&lt;font&gt;</code> 태그 주입</h4>
<p>반면, Chrome 번역기는 React의 설계도를 무시하고 실제 DOM에 직접 개입합니다. 번역기는 "이름" 텍스트 노드를 발견하면, 그 내용을 바꾸는 것을 넘어 <code>&lt;font&gt;</code> 태그로 감싸버립니다.</p>
<pre><code class="lang-html"><span class="hljs-comment">&lt;!-- 번역기가 변경한 실제 DOM --&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">Item</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">font</span>&gt;</span>"Name"<span class="hljs-tag">&lt;/<span class="hljs-name">font</span>&gt;</span>  <span class="hljs-tag">&lt;<span class="hljs-name">--</span> <span class="hljs-attr">font</span> 요소 노드
&lt;/<span class="hljs-attr">Item</span>&gt;</span>
</code></pre>
<p>React가 기억하던 "이름" 텍스트 노드는 사라지고, 그 자리에는 <code>&lt;font&gt;</code>라는 새로운 요소가 들어서게 됩니다.</p>
<h4 id="heading-7lap64m7j2yioyinoqwhcdrtotshj0">충돌의 순간 분석</h4>
<p>이 복잡한 충돌 과정을 순서도로 표현하면 다음과 같습니다.</p>
<pre><code class="lang-mermaid">sequenceDiagram
    participant User
    participant React as React (Virtual DOM)
    participant Translator as Chrome Translator
    participant DOM as Browser (Real DOM)

    User-&gt;&gt;React: Select Trigger 클릭
    React-&gt;&gt;DOM: "이름" 텍스트를 포함한 드롭다운 렌더링
    activate DOM

    Note over Translator, DOM: 번역기가 DOM을 스캔하여 번역할 텍스트 탐색

    Translator-&gt;&gt;DOM: "이름" 텍스트 노드 제거 (React 몰래)
    Translator-&gt;&gt;DOM: "Name" 텍스트 노드 삽입 (React 몰래)

    User-&gt;&gt;React: 번역된 "Name" 아이템 클릭
    React-&gt;&gt;React: 드롭다운 unmount 시작

    Note over React, DOM: React는 자신의 Virtual DOM을 기준으로&lt;br/&gt;원래 있던 "이름" 노드를 제거하려 함

    React-&gt;&gt;DOM: removeChild("이름") 요청
    DOM--&gt;&gt;React: 오류! "이름" 노드를 찾을 수 없음
    deactivate DOM

    React--&gt;&gt;User: "NotFoundError" 오류 발생
</code></pre>
<ol>
<li><p><strong>React</strong>: 사용자가 메뉴를 닫으면, 자신의 설계도에 따라 <code>&lt;Item&gt;</code> 컴포넌트에서 "이름" 텍스트 노드를 제거하라고 지시합니다.</p>
</li>
<li><p><strong>실제 DOM</strong>: <code>&lt;Item&gt;</code> 컴포넌트를 확인하지만, 그 안에는 "이름" 텍스트 노드가 없고 <code>&lt;font&gt;</code> 요소만 존재합니다.</p>
</li>
<li><p><strong>브라우저</strong>: <code>NotFoundError</code> 발생. 제거하려는 "이름" 노드는 <code>&lt;Item&gt;</code>의 자식이 아니기 때문입니다.</p>
</li>
</ol>
<p><strong>Radix UI가 특히 취약했던 이유</strong>는 <code>Select</code>, <code>DropdownMenu</code>, <code>Tooltip</code> 등 텍스트를 포함하고 Portal을 사용하는 복잡한 컴포넌트가 많기 때문입니다. Portal은 DOM 트리 상의 다른 위치에 UI를 렌더링하므로 번역기의 스캔에 더 쉽게 노출되고, 복잡한 상호작용(포커스 관리, 접근성 속성 변경 등) 도중에 DOM이 예기치 않게 변경되면 전체 로직이 깨지기 쉽습니다.</p>
<h3 id="heading-7lwc7kkfio2vtoqysoyxhtog7lap64m7j2yioybkoyyncdsskjri6g">최종 해결책: 충돌의 원천 차단</h3>
<p>결국 저는 충돌을 '해결'하는 것이 아니라, 충돌의 '원인' 자체를 원천적으로 차단하는 방향으로 선회했습니다. <code>index.html</code>에 다층적인 번역 방지 시스템을 구축하여, Chrome 번역기가 우리 애플리케이션의 DOM에 아예 접근하지 못하도록 막았습니다.</p>
<ul>
<li><p><strong>HTML 속성</strong>: <code>&lt;html&gt;</code>, <code>&lt;body&gt;</code>에 <code>translate="no"</code>, <code>class="notranslate"</code> 추가</p>
</li>
<li><p><strong>메타 태그</strong>: <code>&lt;meta name="google" content="notranslate"&gt;</code>로 번역 서비스 비활성화</p>
</li>
<li><p><strong>JavaScript</strong>: <code>MutationObserver</code>를 사용해 React가 동적으로 생성하는 모든 DOM 노드에도 실시간으로 번역 방지 속성을 강제 주입</p>
</li>
</ul>
<p>이 방법은 '번역'이라는 기능을 포기해야 하는 트레이드오프가 있지만, 애플리케이션의 안정성을 보장하는 가장 확실한 해결책이었습니다.</p>
<p>다른 제품에서 ‘번역’이라는 기능을 포기할 수 없는 상황이라면 어떻게 대응할까도 고민해봤습니다. 첫째로 범위를 제한한 번역 금지 기능, 둘째로 Radix UI 포탈처럼 번역에서 안전하지 않은 Portal을 커스텀 포탈로 제한하는 방식을 조합해 적용해볼 수 있을 것 같습니다.</p>
<h3 id="heading-6rkw66gg">결론</h3>
<p>이번 경험을 통해 몇 가지 교훈을 얻을 수 있었습니다.</p>
<ol>
<li><p><strong>React의 통제권은 절대적이지 않다</strong>: 우리는 React가 DOM을 완전히 통제한다고 가정하지만, 브라우저 확장 프로그램이나 외부 스크립트는 언제든 이 가정을 깨뜨릴 수 있습니다.</p>
</li>
<li><p><strong>'불가능한' 버그는 외부 요인을 의심하라</strong>: 코드 상의 로직으로 설명되지 않는 오류가 발생한다면, 브라우저 환경, 확장 프로그램, 네트워크 등 애플리케이션 외부의 요인을 반드시 고려해야 합니다.</p>
</li>
<li><p><strong>커뮤니티를 활용하라</strong>: 비슷한 문제를 겪고 해결한 다른 개발자들의 경험(<a target="_blank" href="https://localhost.tistory.com/24#%F0%9F%92%A1%EC%98%A4%EB%A5%98%20%EC%9B%90%EC%9D%B8-1">참고 블로그</a>)은 문제 해결의 결정적인 실마리가 될 수 있습니다.</p>
</li>
<li><p><strong>때로는 회피가 최선의 해결책이다</strong>: 제어할 수 없는 외부 요인과의 충돌은 정면으로 맞서기보다, 충돌 자체가 발생하지 않도록 원인을 차단하거나 우회하는 것이 더 현실적이고 안정적인 해결책일 수 있습니다.</p>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[React19 Internals 3: Bailout]]></title><description><![CDATA[React를 사용하다 보면 마주치는 신기한 순간이 있습니다. 부모 컴포넌트의 상태가 변경되었는데, 자식 컴포넌트 전체가 아닌 특정 부분만 깜빡이며 업데이트됩니다. 어떻게 이런 일이 가능할까요?
이 현상의 중심에는 React의 성능 최적화 전략인 "Bailout" 이 있습니다. 말 그대로, React가 "이 컴포넌트는 변경점이 없으니, 렌더링 과정을 건너뛰자!"라고 결정하는 스마트한 메커니즘입니다.
이번 글에서는 이 Bailout이 어떤 원리로 ...]]></description><link>https://ted-projects.com/react19-internals-3</link><guid isPermaLink="true">https://ted-projects.com/react19-internals-3</guid><category><![CDATA[React]]></category><category><![CDATA[React 19]]></category><category><![CDATA[bailout]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Fri, 22 Aug 2025 10:00:36 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1755075207638/c9dc104a-326f-455c-a9c9-93f71abb4455.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>React를 사용하다 보면 마주치는 신기한 순간이 있습니다. 부모 컴포넌트의 상태가 변경되었는데, 자식 컴포넌트 전체가 아닌 특정 부분만 깜빡이며 업데이트됩니다. 어떻게 이런 일이 가능할까요?</p>
<p>이 현상의 중심에는 React의 성능 최적화 전략인 <strong>"Bailout"</strong> 이 있습니다. 말 그대로, React가 "이 컴포넌트는 변경점이 없으니, 렌더링 과정을 건너뛰자!"라고 결정하는 스마트한 메커니즘입니다.</p>
<p>이번 글에서는 이 Bailout이 어떤 원리로 동작하는지, 실제 React 소스 코드와 다이어그램을 통해 파헤쳐 보겠습니다.</p>
<h3 id="heading-7iiy7iiy6ruy64g8oidsmzwg7j2867aa66emioumrougjounloungeuqooq5jd8">수수께끼: 왜 일부만 리렌더링될까?</h3>
<p>다음과 같은 컴포넌트 구조를 상상해 보세요.</p>
<pre><code class="lang-xml">// 우리의 컴포넌트 구조
<span class="hljs-tag">&lt;<span class="hljs-name">A</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">B</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">C</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">button</span>/&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">D</span>/&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">C</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">B</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">E</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">F</span>/&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">E</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">A</span>&gt;</span>
</code></pre>
<p>여기서 컴포넌트 C 안의 버튼을 클릭해 상태를 변경하면, 놀랍게도 C와 D만 다시 렌더링됩니다. A, B, E, F는 아무런 변화가 없습니다. React는 어떻게 이 사실을 알고 최소한의 작업만 수행했을까요?</p>
<h3 id="heading-setstate">모든 것의 시작: <code>setState</code> 호출 시퀀스</h3>
<p>사용자가 버튼을 클릭하여 <code>setState</code>를 호출하면 어떤 일이 벌어질까요? 렌더링이 일어나기까지의 과정을 Sequence Diagram으로 먼저 살펴보겠습니다.</p>
<pre><code class="lang-mermaid">sequenceDiagram
    participant User
    participant Component
    participant ReactScheduler as "React 스케줄러"
    participant ReactReconciler as "React 조정자 (Reconciler)"

    User-&gt;&gt;Component: "버튼 클릭 (이벤트 발생)"
    Component-&gt;&gt;Component: "setCount() 호출"
    Component-&gt;&gt;ReactScheduler: "scheduleUpdateOnFiber() (업데이트 예약)"
    Note right of ReactScheduler: "업데이트에 'lane'을 할당하고&lt;br/&gt;루트까지 전파&lt;br/&gt;(markUpdateLaneFromFiberToRoot)"

    ReactScheduler-&gt;&gt;ReactReconciler: "performUnitOfWork() (작업 단위 수행)"
    loop "각 파이버(Fiber)에 대해"
        ReactReconciler-&gt;&gt;ReactReconciler: "beginWork(fiber)"
        Note left of ReactReconciler: "Bailout 여부 결정"
        alt "Bailout 성공 (변경 없음)"
            ReactReconciler--&gt;&gt;ReactReconciler: "작업 중단 (null 반환)"
        else "Bailout 실패 (변경 있음)"
            ReactReconciler--&gt;&gt;ReactReconciler: "자식 파이버 생성/업데이트&lt;br/&gt;(reconcileChildren)"
        end
    end
</code></pre>
<p>이 다이어그램은 업데이트 요청이 어떻게 스케줄러를 통해 조정자(Reconciler)에게 전달되고, <code>beginWork</code> 함수가 각 컴포넌트(파이버)의 운명을 결정하는 핵심 역할을 하는지 보여줍니다.</p>
<h3 id="heading-deep-dive-1-lanes-fiber">Deep Dive 1: 업데이트의 흔적, lanes와 Fiber 노드</h3>
<p>React는 내부적으로 컴포넌트 트리를 <strong>파이버(Fiber) 노드</strong> 트리로 관리합니다. 각 파이버는 컴포넌트의 작업 단위이자 상태를 저장하는 장소입니다. <code>setState</code>가 호출되면 React는 이 파이버 노드에 흔적을 남깁니다.</p>
<ul>
<li><p><code>lanes</code>: 상태 변경이 <strong>직접</strong> 일어난 파이버에 꽂히는 깃발(flag)입니다.</p>
<ul>
<li>lane왈: "나 자신이 업데이트되어야 해!"</li>
</ul>
</li>
<li><p><code>childLanes</code>: 상태 변경이 일어난 파이버의 <strong>모든 부모</strong>에게 꽂히는 깃발입니다.</p>
<ul>
<li>childLane왈: "내 자식들 중에 업데이트가 필요한 녀석이 있어!"</li>
</ul>
</li>
</ul>
<p>이 속성들은 파이버 노드 객체 안에 존재합니다. 파이버 노드의 구조를 간단히 살펴보겠습니다.</p>
<h3 id="heading-deep-dive-2-bailout-beginwork">Deep Dive 2: Bailout의 관문, <code>beginWork</code> 소스 코드 분석</h3>
<p>이제 하이라이트인 <code>beginWork</code> 함수의 실제 코드를 살펴보겠습니다. 이 함수가 어떻게 <code>props</code>와 <code>lanes</code>를 사용해 Bailout을 결정하는지 직접 확인해보겠습니다.</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// source: react-reconciler/src/ReactFiberBeginWork.js (일부 단순화)</span>

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">beginWork</span>(<span class="hljs-params">current, workInProgress, renderLanes</span>) </span>{
  <span class="hljs-comment">// 'current'는 이전 렌더링의 파이버입니다. null이 아니면 업데이트 상황입니다.</span>
  <span class="hljs-keyword">if</span> (current !== <span class="hljs-literal">null</span>) {
    <span class="hljs-keyword">const</span> oldProps = current.memoizedProps;
    <span class="hljs-keyword">const</span> newProps = workInProgress.pendingProps;

    <span class="hljs-comment">// 1. props나 context가 변경되었는가? (참조 비교)</span>
    <span class="hljs-keyword">if</span> (oldProps !== newProps || hasContextChanged()) {
      <span class="hljs-comment">// 변경되었으면 무조건 리렌더링 진행</span>
      didReceiveUpdate = <span class="hljs-literal">true</span>;
    } <span class="hljs-keyword">else</span> {
      <span class="hljs-comment">// 2. props는 같다. 그럼 스케줄된 업데이트(lanes)가 있는가?</span>
      <span class="hljs-keyword">const</span> hasScheduledUpdate = checkScheduledUpdateOrContext(current, renderLanes);
      <span class="hljs-keyword">if</span> (!hasScheduledUpdate) {
        <span class="hljs-comment">// 3. 스케줄된 업데이트도 없다! 최종 Bailout 시도.</span>
        <span class="hljs-comment">// 이 함수 내부에서 childLanes를 확인하고 최종 결정을 내립니다.</span>
        <span class="hljs-keyword">return</span> attemptEarlyBailoutIfNoScheduledUpdate(current, workInProgress, renderLanes);
      }
      <span class="hljs-comment">// ...</span>
    }
  }

  <span class="hljs-comment">// ...</span>
  <span class="hljs-comment">// Bailout 되지 않았다면, 컴포넌트 타입에 따라 렌더링 함수를 실행합니다.</span>
  <span class="hljs-keyword">return</span> updateFunctionComponent(current, workInProgress, ...);
}
</code></pre>
<p>이 코드의 논리를 Flowchart로 시각화하면 더욱 명확해집니다.</p>
<pre><code class="lang-mermaid">flowchart TD
    subgraph "beginWork 함수 로직"
        direction LR
        A["시작"] --&gt; B{"current !== null?&lt;br/&gt;(current: 이전 렌더링 파이버)"};
        B --&gt;|No| R["새로운 컴포넌트 렌더링"];

        subgraph "업데이트 경로"
            direction TB
            B --&gt;|Yes| C{"oldProps !== newProps?"};
            C --&gt;|No| D{context 변경?};
            D --&gt;|No| E{"lanes에 업데이트 존재?"};
            E --&gt;|No| G{"childLanes에 업데이트 존재?"};
        end

        C --&gt;|Yes| Q["didReceiveUpdate = true&lt;br&gt;리렌더링 진행"];
        D --&gt;|Yes| Q;
        E --&gt;|Yes| Q;

        G --&gt;|No| H["최종 Bailout!&lt;br&gt;작업 중단 (null 반환)"];
        G --&gt;|Yes| I["자신은 건너뛰고&lt;br&gt;자식 파이버로 계속 진행"];
    end

    Q --&gt; R;
    I --&gt; R;
    R --&gt; S["완료"];
    H --&gt; S;
</code></pre>
<h3 id="heading-7iio6rko7keeiouyloydudog7jmcioyvhoustcdsg4hqtiag7jeg64quigbg64quioumrougjounloungeuqooq5jd8">숨겨진 범인: 왜 아무 상관 없는 <code>&lt;D/&gt;</code>는 리렌더링될까?</h3>
<p>이제 <code>D</code>가 리렌더링되는 이유를 코드 레벨에서 추적할 수 있습니다. <code>C</code>는 Bailout에 실패했으므로 <code>updateFunctionComponent</code>가 호출됩니다. 이 함수는 <code>C</code>의 렌더링 함수를 실행하여 <code>return &lt;div...&gt;&lt;D/&gt;&lt;/div&gt;</code>를 통해 <strong>새로운</strong> <code>&lt;D/&gt;</code> 엘리먼트 객체를 만듭니다.</p>
<p>이 새 엘리먼트는 <code>D</code> 파이버의 <code>pendingProps</code>가 됩니다. 다음 차례에 <code>D</code>의 <code>beginWork</code>가 실행될 때, <code>oldProps !== newProps</code> 비교에서 두 객체의 메모리 주소가 다르므로 <code>true</code>를 반환하고, 결국 리렌더링으로 이어지는 것입니다.</p>
<h3 id="heading-7jwe66y0ioydgeq0goyxhuuklcdsu7ttj6zrhiztirgg7zw06rkw7lgfoidssljsobdrpbwg64z7j287zwy6rkmioycooyngo2vmoudva">아무 상관없는 컴포넌트 해결책: 참조를 동일하게 유지하라</h3>
<p>이 문제를 해결하기 위해 개발자들은 props의 참조를 동일하게 유지하려는 다양한 <strong>메모이제이션(memoization) 노력</strong>을 해왔습니다. 단순히 자식 컴포넌트 자체를 <code>React.memo</code>로 감싸는 것뿐만 아니라, 자식에게 전달하는 props가 렌더링마다 새로 생성되지 않도록 막는 것이 핵심입니다.<br />위와 같은 기법들의 공통 목표는 <strong>"렌더링 시 불필요한 참조 생성을 막아 React의 Bailout 메커니즘이 효율적으로 동작하도록 돕는 것"</strong> 입니다.</p>
<blockquote>
<p><strong>미래 엿보기: React 19와 React Compiler</strong></p>
<p>React 19부터 선택적으로 도입되는 React Compiler는 이 모든 과정을 자동화하는 것을 목표로 합니다. 컴파일러가 코드를 미리 분석하여 <code>&lt;D /&gt;</code>와 같이 불필요하게 재생성되는 부분을 찾아내고, 자동으로 <code>React.memo</code>로 감싼 것과 같은 효과를 내도록 코드를 변환해줍니다. 아직은 실험적인 기능이지만, React의 개발 경험이 어떻게 진화할지 보여주는 흥미로운 방향입니다.</p>
</blockquote>
<h3 id="heading-6rkw66gg">결론</h3>
<p>React의 "Bailout"은 <code>props</code>의 참조 동일성과 내부적인 <code>lanes</code> 시스템을 기반으로 동작하는 정교한 최적화 메커니즘입니다. <code>beginWork</code>라는 관문을 통해 각 컴포넌트의 운명을 결정하며, 이 과정을 이해하는 것은 React의 성능을 최대로 끌어올리는 데 큰 도움이 됩니다.</p>
<p>우리가 작성하는 코드가 화면 뒤에서 어떻게 동작하는지 알 때, 우리는 더 나은 코드를 작성하고 예측 불가능한 버그를 더 쉽게 해결할 수 있기 때문입니다. 부족한 글 읽어주셔서 감사합니다.</p>
]]></content:encoded></item><item><title><![CDATA[Flexbox와 Truncate의 배신]]></title><description><![CDATA[프론트엔드 개발에서 텍스트를 다루는 것은 기본입니다. 하지만 flex 레이아웃과 truncate 같은 유틸리티가 복합적으로 얽히기 시작하면, 가장 단순해 보이는 텍스트조차 예상과 다르게 동작해서 스타일 버그를 만들게 됩니다.
최근 겪었던 실제 사례를 통해 이 문제를 깊이 파헤쳐 보겠습니다. 상황은 이랬습니다. 헤더에 표시되는 회사 이름 중, 이더리움그룹처럼 긴 이름은 정상 출력되는데, (주)비트처럼 훨씬 짧은 이름이 말줄임표(...) 처리되는 ...]]></description><link>https://ted-projects.com/css-flexbox-truncate</link><guid isPermaLink="true">https://ted-projects.com/css-flexbox-truncate</guid><category><![CDATA[flexbox]]></category><category><![CDATA[CSS]]></category><category><![CDATA[Tailwind CSS]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Thu, 21 Aug 2025 11:11:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1755755844288/9c63d4c7-7b22-4067-9607-a9c99f50fdee.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>프론트엔드 개발에서 텍스트를 다루는 것은 기본입니다. 하지만 <code>flex</code> 레이아웃과 <code>truncate</code> 같은 유틸리티가 복합적으로 얽히기 시작하면, 가장 단순해 보이는 텍스트조차 예상과 다르게 동작해서 스타일 버그를 만들게 됩니다.</p>
<p>최근 겪었던 실제 사례를 통해 이 문제를 깊이 파헤쳐 보겠습니다. 상황은 이랬습니다. 헤더에 표시되는 회사 이름 중, <code>이더리움그룹</code>처럼 긴 이름은 정상 출력되는데, <code>(주)비트</code>처럼 훨씬 짧은 이름이 말줄임표(...) 처리되는 현상이 발생했습니다. 직관에 반하는 이 현상을 마주하고 혼란스러웠고 문제를 해결해도 개운하게 이해가 안됐었습니다. 결론부터 말하면, 범인은 <code>truncate</code>가 아니라 <code>flex-shrink</code>였습니다.</p>
<h3 id="heading-case-study"><strong>Case Study: 왜 짧은 텍스트가 잘리는가?</strong></h3>
<p>문제의 컴포넌트는 다음과 같은 구조를 가졌습니다.(의사 코드입니다)</p>
<pre><code class="lang-xml">// Header Layout
<span class="hljs-tag">&lt;<span class="hljs-name">header</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"flex justify-between items-center"</span>&gt;</span>
  {/* Left Section */}
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"flex items-center gap-3"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"/logo.svg"</span> <span class="hljs-attr">alt</span>=<span class="hljs-string">"logo"</span> /&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">CompanyName</span> <span class="hljs-attr">companyName</span>=<span class="hljs-string">{name}</span> /&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

  {/* Right Section */}
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"flex items-center gap-4"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">span</span>&gt;</span>{user.name}<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">Avatar</span> /&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">header</span>&gt;</span>
</code></pre>
<p><code>CompanyName</code> 컴포넌트는 반응형 분기에 따라 <code>md</code> 이상 스크린에서 <code>truncate</code>가 적용되도록 설정되어 있었습니다. 초기 분석 시에 <code>truncate</code>와 텍스트 줄바꿈(<code>word-break</code>)의 문제에 집중했었죠.</p>
<h3 id="heading-1-word-break-truncate"><strong>1차 분석:</strong> <code>word-break</code>와 <code>truncate</code>의 상호작용</h3>
<p>최초 코드에는 한글 단어가 깨지지 않도록 <code>break-keep</code> (<code>word-break: keep-all</code>)이 적용해둔 상태였습니다. 여기서 첫 번째 단서를 발견했습니다.</p>
<ul>
<li><p><code>이더리움그룹</code>: <code>break-keep</code>에 의해 통짜 단어로 인식된다.</p>
</li>
<li><p><code>(주)비트</code>: 괄호 <code>()</code>는 CSS에서 단어 분리 문자로 취급될 수 있습니다. 이로 인해 브라우저는 <code>(주)</code>와 <code>비트</code>를 별개의 어휘 단위로 판단, 그 사이에서 줄바꿈이 발생할 수 있습니다.</p>
</li>
</ul>
<p>이것이 초기 "깨짐" 현상의 원인이었습니다. 하지만 <code>truncate</code>는 <code>white-space: nowrap</code>을 포함하므로 줄바꿈 자체가 일어나면 안 됩니다. 그런데 왜 <code>(주)비트</code>는 여전히 두 줄로 "깨지면서" 동시에 <code>truncate</code>가 되려고 했을까요?</p>
<p>범인은 <code>md:whitespace-normal</code> 같은 반응형 오버라이드였습니다. 특정 분기점에서 <code>nowrap</code>을 해제하면서 <code>break-keep</code>의 동작과 충돌을 일으킨 것입니다.</p>
<p>이 분석에 따라 <code>break-all</code>을 적용하고 반응형 클래스를 정리했습니다. 줄바꿈 문제는 해결됐지만, 근본적인 <code>truncate</code> 현상은 사라지지 않았습니다.</p>
<h3 id="heading-2-flex-shrink"><strong>2차 분석: 진짜 범인,</strong> <code>flex-shrink</code></h3>
<p>문제의 본질은 <code>CompanyName</code> 컴포넌트 자체에 있지 않았습니다. 헤더의 <code>flex</code> 컨테이너가 원인이었습니다.</p>
<ol>
<li><p><strong>Flexbox의 공간 분배</strong>: <code>justify-between</code>은 자식 요소들을 양 끝으로 밀어냅니다. 왼쪽엔 <code>[로고, 회사이름]</code>, 오른쪽엔 <code>[사용자이름, 아바타]</code>가 위치합니다.</p>
</li>
<li><p><code>flex-shrink</code>의 기본 동작: <code>flex</code> 아이템은 기본적으로 <code>flex-shrink: 1</code> 속성을 가집니다. 이는 컨테이너에 공간이 부족할 경우, 할당된 공간을 양보하며 스스로 "수축(shrink)"할 수 있음을 의미합니다.</p>
</li>
<li><p><strong>시나리오</strong>: 오른쪽의 <code>사용자이름</code>이 길어지면, 오른쪽 섹션은 더 많은 공간을 요구합니다. <code>flex</code> 컨테이너의 전체 너비는 고정되어 있으므로, 왼쪽 섹션은 공간을 양보해야 합니다. 이때 <code>CompanyName</code>이 <code>flex-shrink: 1</code> 속성에 따라 수축하게됩니다.</p>
</li>
</ol>
<p>결국 <code>(주)비트</code>라는 텍스트 길이는 문제가 아니었습니다. 다른 <code>flex</code> 아이템 때문에 <code>CompanyName</code> 컴포넌트 자체가 <strong>자신의 콘텐츠보다 좁은 너비를 할당받았기 때문에</strong> <code>truncate</code>가 발동한 것입니다.</p>
<h3 id="heading-flex-shrink-0"><strong>해결책:</strong> <code>flex-shrink: 0</code></h3>
<p>해결책은 간단했습니다. <code>CompanyName</code> 컴포넌트가 수축하지 않도록 명시하는 것입니다.</p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">CompanyName</span>(<span class="hljs-params">{ className, companyName }</span>) </span>{
  <span class="hljs-keyword">return</span> (
    <span class="hljs-comment">/** 'shrink-0'가 핵심 */</span>
    &lt;div
      className={cn(
        <span class="hljs-string">"shrink-0 px-3 py-1 font-medium rounded-md border md:truncate ..."</span>,
        className
      )}
    &gt;
      {companyName}
    &lt;/div&gt;
  );
}
</code></pre>
<p>Tailwind 유틸리티 <code>shrink-0</code> (<code>flex-shrink: 0</code>)를 추가함으로써, 이 컴포넌트는 더 이상 형제 요소에게 공간을 양보하지 않고 자신의 고유 콘텐츠 너비를 유지하게 됩니다. 이로써 짧은 텍스트가 잘리는 현상은 해결됐습니다.</p>
<h3 id="heading-css"><strong>CSS 텍스트 처리 속성 요약</strong></h3>
<p>이 사례에 등장한 핵심 속성들을 표로 정리하면 다음과 같습니다. 이 표 하나로 대부분의 텍스트 처리 이슈를 진단할 수 있습니다. 텍스트 처리 이슈를 마주할때마다 제가 다시 보기 위해 정리 해뒀습니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>목표</td><td>CSS</td><td>Tailwind 클래스</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td><strong>자연스러운 줄바꿈</strong></td><td><code>white-space: normal;</code></td><td><code>whitespace-normal</code></td><td>띄어쓰기 단위로 줄바꿈 (기본값)</td></tr>
<tr>
<td><strong>강제 한 줄 유지</strong></td><td><code>white-space: nowrap;</code></td><td><code>whitespace-nowrap</code></td><td>절대 줄바꿈 안함. 컨테이너를 뚫고 나감</td></tr>
<tr>
<td><strong>한글 단어 유지</strong></td><td><code>word-break: keep-all;</code></td><td><code>break-keep</code></td><td>한글 등에서 단어 단위 줄바꿈 시도</td></tr>
<tr>
<td><strong>강제 글자 단위 분리</strong></td><td><code>word-break: break-all;</code></td><td><code>break-all</code></td><td>단어 무시하고 아무데서나 줄바꿈</td></tr>
<tr>
<td><strong>말줄임표(...) 처리</strong></td><td><code>nowrap</code> + <code>hidden</code> + <code>ellipsis</code></td><td><code>truncate</code></td><td>3가지 속성을 한 번에 적용</td></tr>
<tr>
<td><strong>Flex 아이템 수축 방지</strong></td><td><code>flex-shrink: 0;</code></td><td><code>shrink-0</code></td><td>Flex 공간이 부족해도 아이템 크기 유지</td></tr>
</tbody>
</table>
</div><h4 id="heading-kirtgqqg7ys7j247yq4kio"><strong>키 포인트</strong></h4>
<ul>
<li><p><code>truncate</code>는 텍스트 길이가 아닌, 할당된 공간의 크기에 따라 동작한다.</p>
</li>
<li><p><code>flex</code> 레이아웃에서 아이템의 크기는 형제 요소에 의해 동적으로 변할 수 있다. <code>flex-shrink</code> 속성을 항상 염두에 두어야 한다.</p>
</li>
<li><p><code>break-keep</code>은 <a target="_blank" href="https://ko.wikipedia.org/wiki/CJK">CJK 텍스트</a> 처리 시 유용하지만, 특수 문자에 의해 의도치 않은 단어 분리가 발생할 수 있다.</p>
</li>
<li><p>문제가 발생하면 해당 요소만 보지 말고, 부모의 레이아웃 시스템(Flex, Grid 등)과 형제 요소와의 상호작용까지 반드시 확인해야 한다.</p>
</li>
</ul>
<p>단순한 UI 버그처럼 보였던 이슈를 통해 CSS의 핵심 원리인 레이아웃, 컨텍스트, 그리고 속성 간의 상호작용을 돌아볼 수 있었습니다. 정리를 안해두면 왠지 계속 비슷한 씨름을 하게 될 것 같아 글로 정리해봤습니다.  </p>
<p>감사합니다.</p>
]]></content:encoded></item><item><title><![CDATA[React19 Internals 2: 렌더링]]></title><description><![CDATA[React 19가 등장하며 많은 개발자들이 'React Compiler'라는 혁신적인 기능에 주목하고 있습니다. 하지만 컴파일러가 정확히 무엇을, 어떻게 최적화하는지 이해하려면 먼저 React 19의 표준 렌더링 파이프라인을 알아야 합니다.
이 글에서는 React 19의 렌더링 과정을 두 단계로 나누어 설명합니다.

1부에서는 모든 React 19 애플리케이션의 기본 동작인 표준 렌더링 파이프라인을 살펴봅니다.

2부에서는 React Compi...]]></description><link>https://ted-projects.com/react19-internals-2</link><guid isPermaLink="true">https://ted-projects.com/react19-internals-2</guid><category><![CDATA[React]]></category><category><![CDATA[React 19]]></category><category><![CDATA[React rendering]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Fri, 08 Aug 2025 14:18:04 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1754538365602/14a38c09-210b-44e2-b4ce-857c3a929554.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>React 19가 등장하며 많은 개발자들이 'React Compiler'라는 혁신적인 기능에 주목하고 있습니다. 하지만 컴파일러가 정확히 무엇을, 어떻게 최적화하는지 이해하려면 먼저 React 19의 <strong>표준 렌더링 파이프라인</strong>을 알아야 합니다.</p>
<p>이 글에서는 React 19의 렌더링 과정을 두 단계로 나누어 설명합니다.</p>
<ul>
<li><p><strong>1부</strong>에서는 모든 React 19 애플리케이션의 기본 동작인 <strong>표준 렌더링 파이프라인</strong>을 살펴봅니다.</p>
</li>
<li><p><strong>2부</strong>에서는 React Compiler가 이 표준 파이프라인 위에서 어떻게 자동으로 작동하여, 우리가 수동으로 하던 최적화 작업을 자동화하는지 보겠습니다.</p>
</li>
</ul>
<hr />
<h2 id="heading-1-react-19"><strong>1부: React 19의 표준 렌더링 파이프라인</strong></h2>
<p>React 19의 기본적인 렌더링 과정은 이전 버전의 핵심 원칙을 계승합니다. 렌더링 과정은 크게 <strong>트리거(Trigger)</strong>, <strong>렌더(Render)</strong>, <strong>커밋(Commit)</strong> 세 단계의 파이프라인으로 이루어집니다.</p>
<blockquote>
<p>React 4가지 핵심 원칙 간단 정리</p>
<ol>
<li><p>상태(state)가 UI를 결정한다: 개발자가 상태(state = data)를 바꾸면 UI는 알아서 바뀐다.</p>
</li>
<li><p>선언적 프로그래밍: UI가 어떤(what) 상태여야 하는지 선언한다.</p>
</li>
<li><p>재조정(Reconciliation): 가상의 UI 구조(virtual DOM)에서 변경 전후 차이점 비교로 재조정한다.</p>
</li>
<li><p>컴포넌트 기반 아키텍쳐: UI를 독립적이며 재사용 가능한 컴포넌트 단위로 조립한다.</p>
</li>
</ol>
</blockquote>
<h3 id="heading-1-trigger"><strong>1. 트리거(Trigger) 단계: 변경의 시작</strong></h3>
<p><strong>"업데이트가 필요하다"는 사실을 인지하고 작업을 예약하는 단계입니다.</strong></p>
<ul>
<li><p><strong>시작점:</strong> 사용자의 클릭 등으로 <code>useState</code>의 세터 함수가 호출되면서 시작됩니다.</p>
</li>
<li><p><strong>작업 예약:</strong> React는 이 업데이트가 얼마나 시급한지 판단하여 <strong>우선순위</strong>(<code>lanes</code>)를 매깁니다. 예를 들어, 버튼 클릭은 입력에 대한 즉각적인 피드백이 중요하므로 높은 우선순위를 갖습니다.</p>
</li>
<li><p><strong>경로 표시:</strong> 업데이트가 필요한 컴포넌트부터 최상위 루트(root)까지의 경로에 있는 모든 Fiber 노드(컴포넌트에 대한 정보 객체)에 <code>lanes</code>와 <code>childLanes</code>라는 플래그를 남겨, "이쪽 라인을 점검해야 해!"라고 표시합니다.</p>
</li>
</ul>
<h3 id="heading-2-render"><strong>2. 렌더(Render) 단계: 변경점 계산</strong></h3>
<p><strong>"실제 DOM을 건드리지 않고, 가상으로 무엇이 변했는지 계산하는 두뇌 역할"</strong> 을 하는 복잡하고 중요한 단계입니다.</p>
<ul>
<li><p><strong>재조정(Reconciliation):</strong> React는 트리거 단계에서 표시된 경로를 따라 Fiber 트리를 순회하며 컴포넌트를 다시 실행합니다. 그리고 이전에 렌더링된 결과와 새로 생성된 결과를 비교합니다.</p>
</li>
<li><p><strong>최적화 (Bailout):</strong> 재조정 과정에서, 만약 어떤 컴포넌트의 props가 이전과 동일하고 상태 변경도 없다면, React는 "이 컴포넌트와 그 아래 자식들은 모두 변경점이 없겠구나"라고 판단합니다. 그리고 해당 컴포넌트와 그 하위 트리 전체의 렌더링 과정을 통째로 <strong>"건너 뛰어(Bailout)"</strong> 버립니다. 이것이 바로 React.memo가 수동으로 이끌어내는 핵심 최적화이며, 불필요한 계산을 막아 성능을 향상시킵니다.</p>
</li>
<li><p><strong>변경 계획 수립 (플래그 표시):</strong> 비교 결과 변경이 감지된 부분은 실제 DOM을 바로 바꾸는 대신, "이 노드는 DOM에 새로 <strong>추가</strong>해야 함(<code>Placement</code>)", "이 노드는 속성을 <strong>수정</strong>해야 함(<code>Update</code>)", "이 노드는 <strong>제거</strong>해야 함(<code>Deletion</code>)" 과 같이 해야 할 일들을 Fiber 노드에 <strong>플래그로 기록</strong>합니다. 이 플래그들이 모여 최종 **'변경 계획서'**가 됩니다. 이 때 변경 계획서는 Fiber 노드들의 집합인 <strong>Fiber 트리</strong>에 기록 되있습니다.</p>
</li>
</ul>
<h3 id="heading-3-commit"><strong>3. 커밋(Commit) 단계: 실제 화면에 반영</strong></h3>
<p><strong>렌더 단계에서 수립된 '변경 계획서'를 실제 DOM에 적용하여 사용자가 변경을 볼 수 있게 하는 마지막 단계입니다.</strong></p>
<ul>
<li><p><strong>계획 실행:</strong> React는 렌더 단계에서 플래그가 표시된 모든 Fiber 노드를 찾아냅니다.</p>
</li>
<li><p><strong>DOM 업데이트:</strong> 이 플래그에 따라 DOM 노드를 추가, 수정, 삭제하는 실제 작업을 실행합니다. 이 과정은 화면 깜빡임 같은 현상을 막기 위해 동기적으로, 한 번에 일괄 처리됩니다.</p>
</li>
<li><p><strong>작업 완료:</strong> 모든 DOM 조작이 끝나면 렌더링 과정이 최종적으로 완료됩니다.</p>
</li>
</ul>
<p>이것이 React 19의 <strong>표준 렌더링 파이프라인</strong>입니다.</p>
<h3 id="heading-react-19"><strong>React 19 표준 렌더링 파이프라인 시각화</strong></h3>
<blockquote>
<p>상태 변경부터 실제 UI 업데이트까지 이어지는 Trigger, Render, Commit 단계의 전체 흐름을 시각화합니다.</p>
</blockquote>
<pre><code class="lang-mermaid">flowchart TD
    subgraph "[1] 트리거 단계"
        A["상태 변경&lt;br /&gt;(예: setState)"]
        B["업데이트 스케줄링"]
        C["Fiber 경로에 lanes 표시&lt;br /&gt;리렌더링 위치 지정"]
    end

    subgraph "[2] 렌더 단계"
        D["루트에서 렌더링 시작"]
        E["Fiber 트리 순회"]
        F["업데이트 필요한가?&lt;br /&gt;(Bailout 로직)"]
        G["컴포넌트 건너뛰기"]
        H["자식 컴포넌트 재조정"]
        I["DOM 변경 플래그 표시&lt;br/&gt;(추가, 업데이트, 삭제)"]
        J["다음 Fiber로 이동"]
        K["순회 완료?"]
        L["작업 완료된 Fiber 트리 생성"]
    end

    subgraph "[3] 커밋 단계"
        M["커밋 단계 시작"]
        N["실제 DOM에 변경사항 적용&lt;br/&gt;(삭제 → 업데이트 → 추가)"]
        O["UI 업데이트 완료"]
    end

    A --&gt; B
    B --&gt; C

    C --&gt; D
    D --&gt; E
    E --&gt; F
    F -- "아니요" --&gt; G
    F -- "예" --&gt; H
    H --&gt; I
    G --&gt; J
    I --&gt; J
    J --&gt; K
    K -- "아니요" --&gt; E
    K -- "예" --&gt; L

    L --&gt; M
    M --&gt; N
    N --&gt; O
</code></pre>
<h2 id="heading-2-react-compiler"><strong>2부: React Compiler, 똑똑한 최적화의 등장</strong></h2>
<p>그렇다면 React Compiler는 이 표준 파이프라인에서 어떤 역할을 할까요? 컴파일러는 <strong>"렌더 단계를 더 똑똑하게 만드는 조력자"</strong> 입니다. 먼저 컴파일러가 어떤 철학으로 설계되었는지 살펴보는 것이 중요합니다.</p>
<h3 id="heading-react-compiler-goals-amp-non-goals"><strong>React Compiler 설계 원칙 (Goals &amp; Non-Goals)</strong></h3>
<blockquote>
<p>React Compiler의 핵심 설계 목표와, 의도적으로 제외된 비-목표를 시각화하여 프로젝트의 명확한 범위와 방향을 제시합니다.</p>
</blockquote>
<pre><code class="lang-mermaid">mindmap
  root((React Compiler&lt;br/&gt;설계 원칙))
    subgraph Goals (핵심 목표)
      id1["자동 및 제한적 리렌더링&lt;br/&gt;(성능 예측성 확보)"]
      id2["시작 시간 성능 유지&lt;br/&gt;(코드 크기 및 오버헤드 최소화)"]
      id3["익숙한 React 모델 유지&lt;br/&gt;(useMemo, useCallback 등 제거)"]
      id4["관용적(Idiomatic) 코드 지원&lt;br/&gt;(React 규칙 준수 코드)"]
      id5["명시적 타입/주석 불필요"]
    subgraph Non-Goals (비-목표)
      id6["완벽하게 최적화된 렌더링&lt;br/&gt;(런타임 오버헤드 방지)"]
      id7["React 규칙을 위반하는 코드 지원"]
      id8["레거시 기능 지원&lt;br/&gt;(클래스 컴포넌트 등)"]
      id9["JavaScript 언어 100% 지원"]
</code></pre>
<p>차트에서 볼 수 있듯이, 컴파일러의 핵심 목표는 개발자가 <code>useMemo</code>, <code>useCallback</code> 같은 API를 직접 사용하지 않아도, <strong>자동으로 코드를 분석하여 불필요한 리렌더링을 방지</strong>하는 것입니다.</p>
<h4 id="heading-kirslrtrlrvqsowg6rca64ql7zwg6rmm7jqupyoq"><strong>어떻게 가능할까요?</strong></h4>
<p>컴파일러는 빌드 시점에 코드를 미리 분석하여, <strong>어떤 값이나 함수가 리렌더링 사이에 변하지 않을지 예측</strong>합니다.</p>
<ul>
<li><p><strong>반응형 스코프(Reactive Scopes) 분석:</strong> 컴파일러는 어떤 값들이 서로 의존하는지, 어떤 상태(state)가 변할 때 어떤 부분만 다시 계산하면 되는지를 파악하여 '반응형 스코프'라는 그룹으로 묶습니다.</p>
</li>
<li><p><strong>자동 메모이제이션:</strong> 분석이 끝나면, 컴파일러는 마치 개발자가 <code>useMemo</code>나 <code>useCallback</code>을 직접 추가한 것처럼 코드를 변환합니다. 이 변환된 코드는 렌더 단계에서 React의 <strong>Bailout 최적화</strong>를 극대화하여, 불필요한 리렌더링을 원천적으로 차단합니다.</p>
</li>
</ul>
<h3 id="heading-kirqsjzrsjzsnpdsnzgg7lwc7kcb7zmuioyekeyxhsdrs4dtmzqg7iuc6rcb7zmukio"><strong>개발자의 최적화 작업 변화 시각화</strong></h3>
<blockquote>
<p>React Compiler가 도입되면서, 개발자가 수동으로 하던 최적화 작업을 컴파일러가 대신해주는 과정을 보여줍니다.</p>
</blockquote>
<pre><code class="lang-mermaid">graph TD
    subgraph "과거 (Compiler 이전)"
        A[개발자] --&gt; B("불필요한 리렌더링 확인");
        B --&gt; C{"최적화 필요한가?"};
        C -- Yes --&gt; D["useMemo, useCallback 등&lt;br&gt;API 직접 적용"];
        C -- No --&gt; E[코드 완료];
        D --&gt; E;
    end

    subgraph "현재 (React 19 + Compiler)"
        F[개발자] --&gt; G("React 규칙에 맞춰&lt;br&gt;컴포넌트/로직 작성");
        G --&gt; H("React Compiler가&lt;br&gt;자동으로 메모이제이션");
        H --&gt; I[코드 완료];
    end
</code></pre>
<h2 id="heading-react"><strong>결론: 개발자는 로직에, 최적화는 React에 맡기자</strong></h2>
<p>정리하자면, React 19의 렌더링 방식은 다음과 같습니다.</p>
<ol>
<li><p><strong>기본적으로는 Trigger → Render → Commit 파이프라인을 따릅니다.</strong></p>
</li>
<li><p><strong>React Compiler는 이 파이프라인의 '렌더' 단계를 자동 최적화하여, 개발자가 수동으로 개입할 필요 없이도 최고의 성능을 내도록 돕습니다.</strong></p>
</li>
</ol>
<p>이는 React의 핵심 철학인 'UI를 선언적으로 만드는 것'에 더 깊이 다가가는 중요한 변화입니다. React Compiler는 단순히 편의성을 높이는 것을 넘어, React 생태계 전체의 성능 기준을 한 단계 끌어올리는 혁신을 목표로 하고 있습니다. 만약 이 새로운 기능이 제대로 자리 잡게 된다면, 이제 React를 사용하는 개발자들은 최적화에 대한 고민을 React에 맡기고, 다른 영역에 좀 더 집중할 수 있는 여유(slack)을 가질 수 있게 될 것입니다.</p>
]]></content:encoded></item><item><title><![CDATA[React19 Internals 1: 초기 마운트]]></title><description><![CDATA[React는 어떻게 우리가 작성한 코드를 실제 눈에 보이는 DOM 요소로 변환할까요? React 18.2.0을 기준으로 내부 동작을 설명한 훌륭한 아티클이 있지만, React 19가 릴리스되면서 많은 부분이 변경되었습니다. 특히 React Compiler의 도입과 같은 큰 변화가 있었죠.
이 글에서는 최신 React 19.1.0 버전의 소스 코드를 기반으로, React 애플리케이션이 최초로 화면을 그리는 '초기 마운트(Initial Mount)...]]></description><link>https://ted-projects.com/react19-internals-1</link><guid isPermaLink="true">https://ted-projects.com/react19-internals-1</guid><category><![CDATA[React 19]]></category><category><![CDATA[React]]></category><category><![CDATA[react internals]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Fri, 01 Aug 2025 11:00:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1753943146993/407f3f8c-41c2-482d-af03-f396a0c2ac03.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>React는 어떻게 우리가 작성한 코드를 실제 눈에 보이는 DOM 요소로 변환할까요? React 18.2.0을 기준으로 내부 동작을 설명한 훌륭한 <a target="_blank" href="https://ted-projects.com/react-internals-deep-dive-2">아티클</a>이 있지만, React 19가 릴리스되면서 많은 부분이 변경되었습니다. 특히 React Compiler의 도입과 같은 큰 변화가 있었죠.</p>
<p>이 글에서는 최신 React 19.1.0 버전의 소스 코드를 기반으로, React 애플리케이션이 최초로 화면을 그리는 <strong>'초기 마운트(Initial Mount)'</strong> 과정을 다이어그램과 함께 자세히 살펴보겠습니다.</p>
<h2 id="heading-1-createroot-render">1. 개발자 관점의 시작: <code>createRoot</code>에서 <code>render</code>까지</h2>
<p>모든 것은 개발자가 작성하는 몇 줄의 코드에서 시작됩니다. React가 내부적으로 복잡한 일을 하기 전에, 개발자는 어떤 순서로 React와 상호작용할까요? 다음 순서도는 개발자 입장에서의 전체적인 흐름을 보여줍니다.</p>
<pre><code class="lang-mermaid">flowchart TD
    subgraph "개발자 코드 (index.js)"
        A["react-dom/client 에서&lt;br/&gt;import { createRoot }"] --&gt; B["렌더링할 DOM 노드 가져오기&lt;br/&gt;(예시: &lt;br /&gt;document.getElementById)"]
        B --&gt; C["createRoot(container)를 &lt;br/&gt;호출하여 root 객체 생성"]
        C --&gt; D["root.render(&amp;lt;App /&amp;gt;)를 호출하여&lt;br/&gt;렌더링 요청"]
    end

    subgraph "React 시스템"
        E["React Root가 생성되고&lt;br/&gt;렌더링 준비 완료"]
        F["&amp;lt;App /&amp;gt; 컴포넌트 트리가&lt;br/&gt;실제 DOM으로 렌더링됨"]
    end

    C -- "결과" --&gt; E
    D -- "결과" --&gt; F
</code></pre>
<p>이처럼 개발자는 단지 <code>createRoot</code>로 렌더링의 뿌리를 만들고, <code>render</code> 함수로 무엇을 그릴지 알려주기만 하면 됩니다. 이제부터 이 단순한 함수 호출 뒤에서 React가 어떤 일을 하는지 내부로 깊이 들어가 보겠습니다.</p>
<h2 id="heading-2-react-createroot-fiberroot">2. React 내부 여정의 첫발: <code>createRoot</code>에서 <code>FiberRoot</code>까지</h2>
<p>사용자가 <code>createRoot</code>를 호출하면, React는 렌더링 파이프라인을 시작할 준비에 들어갑니다.</p>
<h3 id="heading-1-createroot-in-react-dom"><strong>1단계:</strong> <code>createRoot</code> (in <code>react-dom</code>)</h3>
<p><code>packages/react-dom/src/client/ReactDOMRoot.js</code>에 위치한 <code>createRoot</code> 함수는 내부적으로 <code>createContainer</code> 함수를 호출하며, 각종 옵션을 설정하고 경고 메시지를 처리하는 역할을 합니다.</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// packages/react-dom/src/client/ReactDOMRoot.js</span>
<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">createRoot</span>(<span class="hljs-params">container, options</span>) </span>{
  <span class="hljs-comment">// ... 다양한 옵션 및 경고 처리 ...</span>
  <span class="hljs-keyword">const</span> root = createContainer(container, ConcurrentRoot, ...);
  <span class="hljs-comment">// ... 이벤트 리스너 설정 등 ...</span>
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> ReactDOMRoot(root);
}
</code></pre>
<h3 id="heading-2-createcontainer-amp-createfiberroot-in-react-reconciler"><strong>2단계:</strong> <code>createContainer</code> &amp; <code>createFiberRoot</code> (in <code>react-reconciler</code>)</h3>
<p>핵심 로직은 <code>react-reconciler</code> 패키지에 있습니다. "Reconciler(조정자)"는 가상 DOM과 실제 DOM의 차이를 계산하고 업데이트를 관장하는 React의 core입니다.</p>
<p><code>createContainer</code>는 내부에서 <code>createFiberRoot</code> 함수를 호출하여, React 애플리케이션의 근간이 되는 두 가지 핵심 객체를 생성합니다.</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// packages/react-reconciler/src/ReactFiberReconciler.js</span>
<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">createContainer</span>(<span class="hljs-params">containerInfo, tag, ...</span>) </span>{
  <span class="hljs-keyword">const</span> hydrate = <span class="hljs-literal">false</span>;
  <span class="hljs-keyword">const</span> initialChildren = <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">const</span> root = createFiberRoot(
    containerInfo,
    tag,
    hydrate,
    initialChildren,
    ...
  );
  <span class="hljs-keyword">return</span> root;
}
</code></pre>
<ul>
<li><p><code>FiberRootNode</code>: 전체 애플리케이션 인스턴스에 대한 최상위 객체입니다. 렌더링할 DOM 컨테이너 정보, 현재 렌더링된 트리(<code>current</code>), 업데이트 큐(queue) 등 모든 상태를 총괄합니다. 앱 하나당 <strong>단 하나만 존재</strong>합니다.</p>
</li>
<li><p><code>HostRoot</code> FiberNode: <code>FiberRootNode</code>가 관리하는 Fiber Tree의 실제 시작점이 되는 특별한 종류의 <code>FiberNode</code>입니다.</p>
</li>
</ul>
<pre><code class="lang-javascript"><span class="hljs-comment">// packages/react-reconciler/src/ReactFiberReconciler.js</span>
<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">createFiberRoot</span>(<span class="hljs-params">...</span>) </span>{
  <span class="hljs-keyword">const</span> root = <span class="hljs-keyword">new</span> FiberRootNode(containerInfo, tag, ...);
  <span class="hljs-comment">// 👇 바로 이 부분에서 HostRoot 타입의 Fiber를 생성합니다.</span>
  <span class="hljs-keyword">const</span> uninitializedFiber = createHostRootFiber(tag, isStrictMode);
  <span class="hljs-comment">// FiberRootNode가 자신의 'current' 속성으로 HostRoot Fiber를 가리키게 합니다.</span>
  root.current = uninitializedFiber;
  <span class="hljs-comment">// HostRoot Fiber는 자신의 'stateNode' 속성으로 FiberRootNode를 가리켜, 서로 참조하게 됩니다.</span>
  uninitializedFiber.stateNode = root;
  <span class="hljs-comment">// ...</span>
  <span class="hljs-keyword">return</span> root;
}
</code></pre>
<p>이 두 객체가 생성되고 서로 연결되면, React는 렌더링할 준비를 마치게 됩니다.</p>
<h2 id="heading-3">3. 핵심 객체들의 관계 시각화하기</h2>
<p>초기 마운트 과정에서 생성되는 주요 객체들의 관계를 클래스 다이어그램으로 살펴보면 구조를 더 명확하게 이해할 수 있습니다.</p>
<pre><code class="lang-mermaid">classDiagram
    class ReactDOMRoot {
        -_internalRoot: FiberRootNode
        +render(children) void
        +unmount() void
    }

    class FiberRootNode {
        +current: FiberNode
        +containerInfo: HTMLElement
        +pendingLanes: Lanes
        +...
    }

    class FiberNode {
        +tag: number
        +return: FiberNode | null
        +child: FiberNode | null
        +sibling: FiberNode | null
        +stateNode: any
        +...
    }

    ReactDOMRoot "1" *-- "1" FiberRootNode : _internalRoot
    FiberRootNode "1" o-- "1" FiberNode : current (HostRoot)
    FiberNode "1" --* "0..*" FiberNode : child/sibling/return
</code></pre>
<ul>
<li><p>개발자가 받는 <code>ReactDOMRoot</code> 객체는 <code>_internalRoot</code> 속성을 통해 <code>FiberRootNode</code>를 소유합니다.</p>
</li>
<li><p><code>FiberRootNode</code>는 <code>current</code> 속성을 통해 현재 화면에 그려진 Fiber Tree의 최상단 <code>FiberNode</code>(즉, <code>HostRoot</code>)를 가리킵니다.</p>
</li>
<li><p>각 <code>FiberNode</code>는 <code>child</code>, <code>sibling</code>, <code>return</code> 포인터를 통해 트리 구조를 형성합니다.</p>
</li>
</ul>
<h2 id="heading-4">4. 함수 호출의 흐름 살펴보기</h2>
<p><code>createRoot</code> 호출부터 <code>root.render</code> 직전까지, 각 모듈이 어떤 순서로 통신하는지 시퀀스 다이어그램으로 확인해 보겠습니다.</p>
<pre><code class="lang-mermaid">sequenceDiagram
    participant User as 사용자 코드
    participant DOM as react-dom
    participant Rec as react-reconciler

    User-&gt;&gt;DOM: "createRoot(container)"
    activate DOM

    DOM-&gt;&gt;Rec: "createContainer(container, ...)"
    activate Rec

    Rec-&gt;&gt;Rec: "createFiberRoot(...)"
    activate Rec

    Note right of Rec: FiberRootNode 인스턴스화

    Rec-&gt;&gt;Rec: "createHostRootFiber(...)"
    Note right of Rec: HostRoot FiberNode 생성

    Note right of Rec: 두 노드를 서로 연결

    Rec--&gt;&gt;Rec: "root"
    deactivate Rec

    Rec--&gt;&gt;DOM: "root"
    deactivate Rec

    DOM--&gt;&gt;User: "ReactDOMRoot 인스턴스"
    deactivate DOM

    User-&gt;&gt;DOM: "root.render(&lt;App /&gt;)"
    activate DOM

    Note right of DOM: updateContainer 호출,&lt;br&gt;본격적인 렌더링 파이프라인 시작

    DOM-&gt;&gt;Rec: "updateContainer(&lt;App /&gt;, root, ...)"
    deactivate DOM
</code></pre>
<p><code>createRoot</code> 호출이 <code>react-dom</code>을 거쳐 <code>react-reconciler</code>에게 위임되고, 핵심 객체들이 생성된 후 다시 <code>ReactDOMRoot</code> 인스턴스로 포장되어 사용자에게 돌아오는 전 과정을 한눈에 볼 수 있습니다.</p>
<h2 id="heading-5">5. 요약 및 다음 단계</h2>
<p>지금까지 React 19에서 애플리케이션이 렌더링을 시작하기 전, 즉 <strong>준비 단계</strong>를 살펴보았습니다.</p>
<ol>
<li><p>개발자가 <code>createRoot</code>를 호출합니다.</p>
</li>
<li><p><code>react-dom</code>은 <code>createContainer</code>를 통해 <code>react-reconciler</code>에게 루트 생성을 요청합니다.</p>
</li>
<li><p><code>react-reconciler</code>는 <code>createFiberRoot</code>를 통해 앱의 상태를 총괄할 <code>FiberRootNode</code>와 Fiber Tree의 시작점인 <code>HostRoot FiberNode</code>를 생성합니다.</p>
</li>
<li><p>모든 준비를 마친 <code>ReactDOMRoot</code> 인스턴스가 반환됩니다.</p>
</li>
</ol>
<p>이 모든 과정은 <code>root.render(&lt;App /&gt;)</code>가 호출되는 순간, 본격적인 <strong>Render 단계</strong>와 <strong>Commit 단계</strong>를 통해 우리가 작성한 컴포넌트를 실제 DOM에 그려주기 위한 빌드업이었습니다.</p>
<p>다음 글에서는 <code>render</code> 함수 호출 이후, React가 어떻게 Fiber Tree를 구축하고 DOM을 업데이트하는지에 대해 더 깊이 알아보겠습니다.</p>
]]></content:encoded></item><item><title><![CDATA[React19 Internals 0: 어떻게 동작할까?]]></title><description><![CDATA[많은 개발자분들이 React를 사용하여 우아하고 동적인 사용자 인터페이스(UI)를 구현하지만, 정작 React가 내부적으로 어떻게 작동하는지에 대해서는 모르는 경우가 있습니다. React의 내부 동작 원리를 이해하면, 동작하는 코드를 작성하는 것을 넘어 더 효율적이고 최적화된 애플리케이션을 만들 수 있는 무기를 얻을 수 있습니다.
이 글에서는 React의 복잡한 내부 세계를 서버 컴포넌트와 클라이언트 컴포넌트의 상호작용을 중심으로 살펴보고, 특...]]></description><link>https://ted-projects.com/react19-internals-0</link><guid isPermaLink="true">https://ted-projects.com/react19-internals-0</guid><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Thu, 31 Jul 2025 11:24:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1753859510227/db215f33-a3b0-489d-9fa0-0494e96f792e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>많은 개발자분들이 React를 사용하여 우아하고 동적인 사용자 인터페이스(UI)를 구현하지만, 정작 React가 내부적으로 어떻게 작동하는지에 대해서는 모르는 경우가 있습니다. React의 내부 동작 원리를 이해하면, 동작하는 코드를 작성하는 것을 넘어 더 효율적이고 최적화된 애플리케이션을 만들 수 있는 무기를 얻을 수 있습니다.</p>
<p>이 글에서는 React의 복잡한 내부 세계를 <strong>서버 컴포넌트</strong>와 <strong>클라이언트 컴포넌트</strong>의 상호작용을 중심으로 살펴보고, 특히 클라이언트에서의 동적 업데이트가 일어나는 과정을 트리거(Trigger), 스케줄(Schedule), 렌더(Render), 커밋(Commit)이라는 네 가지 핵심 단계로 나누어 설명합니다. 복잡도를 조금이나마 낮춰보기 위해 다양한 다이어그램을 활용해서 이해를 도왔습니다.</p>
<h2 id="heading-react">React 내부, 어떻게 학습해야 할까?</h2>
<p>React 내부 세계에 대해 설명하기 전에, React를 학습하는 방식에 대해 간단하게 정리해보고자 합니다. React의 코드베이스는 방대하고 복잡해서 어디서부터 시작해야 할지 막막할 수 있습니다. 바로 코드베이스를 보며 학습하기 보다 다음과 같은 효과적인 방식을 시도해보길 권장합니다.</p>
<ol>
<li><p><strong>공식 문서 깊이 파기</strong>: <a target="_blank" href="http://React.dev">React.dev</a> 공식 문서(+블로그 포함)와 <a target="_blank" href="https://github.com/reactjs/rfcs">React Working Group</a> 토론에는 React 핵심 팀의 철학과 의사결정 과정이 담겨있습니다. API 사용법을 넘어 그들의 생각을 읽는 것이 중요합니다.</p>
</li>
<li><p><strong>React 팀 팔로우하기</strong>: React 팀원들의 소셜 미디어나 블로그를 팔로우하면 코드에는 드러나지 않는 귀중한 정보와 토론의 흐름을 파악할 수 있습니다. (트위터가 없어지며 팀원들이 다들 흩어져서 이 방식은 조금 어려워지긴 했습니다)</p>
</li>
<li><p><strong>React 레포지토리 탐색하기</strong>: <a target="_blank" href="https://github.com/facebook/react">React GitHub 레포지토리</a>의 코드뿐만 아니라, Pull Request(PR)와 코드 리뷰를 살펴보세요. 코드 주석(리액트 코드에는 설명용 주석이 많습니다)보다 더 상세한 구현 배경과 논의를 엿볼 수 있습니다.</p>
</li>
<li><p><strong>단순 블로그 글이 아닌 코드를 신뢰하기</strong>: 인터넷의 수많은 글은 개념적인 설명에 그치는 경우가 많습니다.(이 글 포함) 실제 소스 코드를 직접 디버깅하고 분석하는 것이 가장 정확하게 이해하는 방법입니다.</p>
</li>
<li><p><strong>핵심 경로(Critical Path)부터 찾기</strong>: 학습에 있어서 큰 덩어리로 시작하는건 효율적이지 않습니다. <code>useState</code>가 호출될 때부터 화면에 그려지기까지의 핵심적인 흐름을 먼저 파악한 뒤, 점진적으로 분할 정복을 하며 지식을 확장해 나가는 것이 효과적입니다.</p>
</li>
</ol>
<h2 id="heading-react-1">React 패키지 구조 엿보기</h2>
<p>React 패키지 내부를 들여다보면, 최신 React가 어떻게 구성되어 있는지 명확하게 알 수 있습니다.</p>
<ul>
<li><p><strong>서버와 클라이언트의 분리</strong>: react.react-server.js와 같은 파일들은 <strong>서버 환경</strong> 전용 빌드입니다. 반면, index.js 등은 <strong>클라이언트(브라우저) 환경</strong>을 위한 것입니다. 이는 React가 서버와 클라이언트에서 각기 다른 역할을 수행하도록 설계되었음을 보여줍니다.</p>
</li>
<li><p><strong>클라이언트 렌더링 엔진</strong>: useState, useEffect와 같은 훅(Hook)과 4단계 업데이트 로직은 클라이언트용 빌드에 포함된 Reconciler와 Scheduler가 담당합니다.</p>
</li>
<li><p><strong>React 컴파일러 지원</strong>: compiler-runtime.js 파일은 React 컴파일러(코드명: Forget)를 위한 런타임 코드로, 빌드 시점 최적화를 지원합니다.</p>
</li>
</ul>
<p>이처럼 실제 패키지 구조는 우리가 논의할 서버/클라이언트 모델과 최적화 전략이 실제 코드 레벨에서 구현되어 있음을 증명합니다.</p>
<h2 id="heading-react-2">React 업데이트는 누가, 어떻게 시작할까요?</h2>
<p>React 업데이트는 <strong>개발자</strong>, <strong>서버</strong>, 그리고 <strong>사용자</strong>라는 세 주체에 의해 시작됩니다. 각 주체는 애플리케이션 생명주기의 다른 시점에서 업데이트를 유발합니다.</p>
<ol>
<li><p><strong>개발자 (Developer)</strong>: 모든 것의 시작점입니다. 개발자는 컴포넌트 구조와 렌더링 로직을 코드로 작성합니다. 특히 전통적인 클라이언트 사이드 렌더링(CSR)에서는 개발자가 작성한 <code>ReactDOM.createRoot().render()</code> 코드가 직접적으로 애플리케이션의 <strong>첫 렌더링을 시작</strong>시킵니다.</p>
</li>
<li><p><strong>서버 (Server)</strong>: React 서버 컴포넌트(RSC) 환경에서는, 사용자의 페이지 요청을 받은 서버가 개발자가 작성한 코드를 실행하여 <strong>초기 UI를 렌더링</strong>합니다. 이는 서버에 의해 시작되는 업데이트입니다.</p>
</li>
<li><p><strong>사용자 (User)</strong>: 애플리케이션이 로드된 후, 사용자는 버튼 클릭이나 입력 같은 상호작용을 통해 클라이언트 컴포넌트의 상태를 변경합니다. 이는 <strong>후속 리렌더링을 유발(trigger)</strong>합니다.</p>
</li>
</ol>
<p>이 관계는 아래 유즈케이스 다이어그램으로 명확히 표현할 수 있습니다.</p>
<pre><code class="lang-mermaid">flowchart TD
    subgraph "외부 액터(Actor)"
        DEV["👨‍💻 개발자"]
        SERVER["🌐 서버"]
        USER["👤 사용자"]
    end

    subgraph "React App 유즈케이스"
        UC1["초기 렌더링 시작&lt;br/&gt;(CSR: render() 호출)&lt;br /&gt;(RSC: 페이지 요청)"]
        UC2["상태 변경에 의한&lt;br/&gt;리렌더링 (클라이언트)"]
        UC3["컴포넌트 트리&lt;br/&gt;렌더링/업데이트"]
        UC4["DOM 업데이트"]
        UC5["Effect 실행&lt;br/&gt;(useEffect, useLayoutEffect)"]
    end

    DEV -- "코드 작성 및 render() 호출" --&gt; UC1
    SERVER -- "RSC 요청 처리" --&gt; UC1
    USER -- "UI 상호작용" --&gt; UC2

    UC1 -.-&gt;|include| UC3
    UC2 -.-&gt;|include| UC3
    UC3 -.-&gt;|include| UC4
    UC4 -.-&gt;|include| UC5

    style DEV fill:#e1f5fe
    style SERVER fill:#d1c4e9
    style USER fill:#f3e5f5
    style UC1 fill:#fff3e0
    style UC2 fill:#fff3e0
    style UC3 fill:#e8f5e8
    style UC4 fill:#e8f5e8
    style UC5 fill:#e8f5e8
</code></pre>
<h2 id="heading-4">클라이언트 업데이트의 4단계 동작 원리</h2>
<p><code>'use client'</code>로 명시된 클라이언트 컴포넌트에서 발생하는 모든 업데이트는 아래 플로우차트에 나타난 <strong>트리거 → 스케줄 → 렌더 → 커밋</strong>의 4단계 과정을 거칩니다. 이 흐름은 React가 UI를 업데이트하는 핵심 로직입니다.</p>
<pre><code class="lang-mermaid">graph TD
    subgraph "Trigger"
        A["시작: 초기 렌더링 또는&lt;br/&gt;상태 변경(setState) 호출"] --&gt; B["업데이트 작업 생성&lt;br/&gt;(scheduleUpdateOnFiber)"];
    end

    B --&gt; C["스케줄러에 작업 전달&lt;br/&gt;(scheduleCallback)"];

    subgraph "Schedule"
        C --&gt; D["작업 우선순위 지정 및&lt;br/&gt;대기열 추가"];
        D --&gt; E["스케줄러가 작업 실행&lt;br/&gt;(workLoop)"];
    end

    subgraph "Render"
        E --&gt; F["렌더 단계 시작&lt;br/&gt;(performConcurrentWorkOnRoot)"];
        F --&gt; G["컴포넌트 함수 호출&lt;br/&gt;새 파이버 트리 생성"];
        G --&gt; H["상태 값 비교 (Bailout)&lt;br/&gt;&amp; 기존 트리와 비교 (Diffing)"];
        H --&gt; I{"변경 사항 있음?"};
    end

    I -- "No" --&gt; J["작업 종료&lt;br/&gt;(렌더링 생략)"];

    subgraph "Commit"
        I -- "Yes" --&gt; K["커밋 단계 시작&lt;br/&gt;(commitRoot)"];
        K --&gt; L["DOM 변경 사항 적용&lt;br/&gt;(commitMutationEffects)"];
        L --&gt; M["useLayoutEffect 실행"];
        M --&gt; N["브라우저 렌더링"];
        N --&gt; O["useEffect 실행&lt;br/&gt;(flushPassiveEffects)"];
    end

    O --&gt; P["작업 종료"];

    style Trigger fill:#f9f,stroke:#333,stroke-width:2px
    style Schedule fill:#ccf,stroke:#333,stroke-width:2px
    style Render fill:#cfc,stroke:#333,stroke-width:2px
    style Commit fill:#fca,stroke:#333,stroke-width:2px
    style J fill:#eee,stroke:#333,stroke-width:2px,stroke-dasharray: 5 5
    style P fill:#eee,stroke:#333,stroke-width:2px
</code></pre>
<h4 id="heading-1-trigger">1. Trigger (트리거)</h4>
<p><code>render()</code>나 <code>setState()</code> 같은 이벤트가 발생하면, React는 어떤 컴포넌트를 다시 렌더링해야 할지 결정하고 업데이트 작업을 생성합니다.</p>
<h4 id="heading-2-schedule">2. Schedule (스케줄)</h4>
<p>생성된 업데이트 작업의 우선순위를 정하고, 내부 스케줄러를 통해 언제 실행할지 결정합니다. 긴급한 업데이트는 먼저 처리(우선순위는 lane에 정의 되있습니다)하여 사용자 경험을 최적화합니다.</p>
<h4 id="heading-3-render">3. Render (렌더)</h4>
<p>스케줄링된 컴포넌트를 실제로 렌더링하여 새로운 <strong>파이버 트리(Fiber Tree)</strong>를 구성하고, 기존 트리와 비교하여 변경 사항(diff)을 계산합니다. 이 단계는 실제 DOM을 건드리지 않으며, 더 급한 작업이 들어오면 중단될 수 있습니다.</p>
<h4 id="heading-4-commit">4. Commit (커밋)</h4>
<p>렌더 단계에서 계산된 변경 사항을 실제 DOM에 적용하는 마지막 단계입니다. 이 단계는 중단되지 않으며, DOM 업데이트 후 <code>useLayoutEffect</code>, 그리고 브라우저 페인팅 이후 <code>useEffect</code>를 순차적으로 실행합니다.</p>
<h2 id="heading-66qo65oiioqwhoydmcdsg4htmljsnphsmqk">모듈 간의 상호작용</h2>
<p>React 컴포넌트의 상호작용은 그것이 실행되는 환경(서버 또는 클라이언트)에 따라 크게 달라집니다.</p>
<h3 id="heading-1">1) 클라이언트 컴포넌트의 업데이트 상호작용</h3>
<p>클라이언트 컴포넌트는 사용자의 상호작용(예: 버튼 클릭)에 의해 업데이트됩니다. 이 과정은 이전에 설명한 4단계(트리거 → 스케줄 → 렌더 → 커밋) 모델을 따르며, 최종적으로 실제 DOM을 변경하여 사용자에게 변경된 UI를 보여줍니다.</p>
<pre><code class="lang-mermaid">sequenceDiagram
    actor User
    participant Component
    participant ReactHooks as React Hooks&lt;br/&gt;(useState)
    participant Scheduler
    participant Reconciler as Reconciler&lt;br/&gt;(Render/Commit)
    participant DOM

    User-&gt;&gt;+Component: 1. 버튼 클릭
    Component-&gt;&gt;+ReactHooks: 2. setState() 호출
    ReactHooks-&gt;&gt;+Reconciler: 3. 업데이트 예약&lt;br/&gt;(scheduleUpdateOnFiber)
    Reconciler-&gt;&gt;+Scheduler: 4. 작업 스케줄링 요청&lt;br/&gt;(scheduleCallback)
    Scheduler--&gt;&gt;-Reconciler: 5. 렌더링 작업 시작&lt;br/&gt;(performConcurrentWorkOnRoot)
    loop 렌더(Render) 단계
        Reconciler-&gt;&gt;Component: 6. 컴포넌트 함수 재호출
        Component--&gt;&gt;Reconciler: 7. 새로운 JSX 반환
    end
    Reconciler-&gt;&gt;Reconciler: 8. 변경사항 계산 (Diffing)
    Reconciler-&gt;&gt;+DOM: 9. DOM 업데이트 (Commit)
    Reconciler-&gt;&gt;Component: 10. useEffect 실행
    DOM--&gt;&gt;-User: 11. 변경된 UI 표시
</code></pre>
<h3 id="heading-2">2) 서버 컴포넌트의 초기 렌더링 상호작용</h3>
<p>서버 컴포넌트는 사용자의 최초 페이지 요청이나 내비게이션 요청에 의해 렌더링됩니다. 주된 목적은 데이터를 가져와 UI 구조를 생성하고, 이를 클라이언트로 스트리밍할 수 있는 특별한 형식(RSC Payload)으로 직렬화하는 것입니다.</p>
<pre><code class="lang-mermaid">sequenceDiagram
    participant Browser
    participant FrameworkServer as Framework Server &lt;br /&gt;(e.g., Next.js)
    participant ReactServer
    participant Reconciler

    Browser-&gt;&gt;+FrameworkServer: 1. 페이지 요청 (GET /)
    FrameworkServer-&gt;&gt;+ReactServer: 2. 렌더링 요청
    ReactServer-&gt;&gt;+Reconciler: 3. 서버 렌더링 시작
    loop Render on Server
        Reconciler-&gt;&gt;Reconciler: 4. 서버 컴포넌트 실행 (DB/API 접근)
        Reconciler-&gt;&gt;Reconciler: 5. 클라이언트 컴포넌트는 자리표시자로 처리
    end
    Reconciler--&gt;&gt;-ReactServer: 6. RSC 페이로드 생성
    ReactServer--&gt;&gt;-FrameworkServer: 7. 페이로드 스트림 반환
    FrameworkServer--&gt;&gt;-Browser: 8. UI 스트리밍 응답
</code></pre>
<p>이 과정은 사용자와의 상호작용이 없으며, 최종 결과물은 DOM 변경이 아닌 직렬화된 데이터입니다.</p>
<h2 id="heading-react-3">React를 구성하는 핵심 모듈들</h2>
<p>이 모든 마법은 React 내부의 핵심 모듈들이 유기적으로 협력하기에 가능합니다. 각 모듈의 역할과 관계를 <strong>모듈 클래스 다이어그램</strong>으로 확인 해보겠습니다.</p>
<pre><code class="lang-mermaid">classDiagram
    direction LR
    class ReactDOMClient {
        +createRoot(container)
        +render(component)
    }
    class ReactCore {
        &lt;&lt;Hooks&gt;&gt;
        +useState()
        +useEffect()
        +useContext()
    }
    class Scheduler {
        -PriorityQueue tasks
        +scheduleCallback(callback, priority)
        -workLoop()
    }
    class Reconciler {
        &lt;&lt;Fiber&gt;&gt;
        +scheduleUpdateOnFiber()
        -performConcurrentWorkOnRoot()
        -commitRoot()
    }
    class ReactServer {
         - Renders Server Components
    }
    class DOM {
        &lt;&lt;Host Environment&gt;&gt;
        +createElement()
        +appendChild()
    }

    ReactDOMClient ..&gt; Reconciler : 렌더링 시작 (클라이언트)
    ReactCore ..&gt; Reconciler : 업데이트 요청 (클라이언트)
    Reconciler ..&gt; Scheduler : 작업 스케줄링 (클라이언트)
    Reconciler ..&gt; DOM : DOM 조작 (클라이언트)
    ReactServer ..&gt; Reconciler : 초기 렌더링 (서버)
</code></pre>
<ul>
<li><p><strong>ReactDOMClient</strong>: <code>render()</code>를 통해 React 세계를 시작하는 진입점입니다.</p>
</li>
<li><p><strong>ReactCore</strong>: <code>useState</code>, <code>useEffect</code> 등 우리가 사용하는 훅을 제공합니다.</p>
</li>
<li><p><strong>Scheduler</strong>: 클라이언트 측 업데이트 작업의 우선순위를 관리하고 실행 시점을 조율합니다.</p>
</li>
<li><p><strong>Reconciler</strong>: 파이버(Fiber) 아키텍처를 기반으로 작동하는 React의 핵심 엔진입니다. 서버에서는 초기 렌더링을, 클라이언트에서는 변경 사항을 계산(Render)하고 DOM에 적용(Commit)하는 역할을 모두 수행합니다.</p>
</li>
<li><p><strong>ReactServer</strong>: 서버 환경에서 서버 컴포넌트를 렌더링하는 추상적인 주체입니다. 내부적으로 Reconciler를 사용하지만, Scheduler나 DOM 조작 로직은 사용하지 않습니다.</p>
</li>
</ul>
<h2 id="heading-7kcv66as7zwy66mw">정리하며</h2>
<p>최신 React는 <strong>개발자</strong>가 작성한 코드를 기반으로, <strong>서버</strong>와 <strong>클라이언트</strong>가 각자의 역할을 수행하는 하이브리드 모델을 구현합니다.</p>
<p>서버는 <code>react.react-server.js</code> 빌드를 사용해 초기 로딩과 데이터 페칭을 최적화하고, 클라이언트는 <code>index.js</code> 빌드를 통해 <strong>트리거 → 스케줄 → 렌더 → 커밋</strong> 과정을 거쳐 사용자와의 동적 상호작용을 처리합니다. 여기에 React 컴파일러가 더해져 렌더링 과정을 더욱 최적화합니다.</p>
<p>이 통합적인 동작 원리를 실제 패키지 구조와 연결하여 이해하면, 복잡한 애플리케이션의 성능을 어떻게 최적화할 수 있는지에 대한 깊이 있는 통찰력을 얻을 수 있습니다.</p>
]]></content:encoded></item><item><title><![CDATA[react-router와 UI 컴포넌트의 조용한 충돌]]></title><description><![CDATA[개발을 하다 보면 분명 코드는 멀쩡해 보이는데, 원하는 대로 동작하지 않아 몇 시간을 헤매는 경험, 다들 한 번쯤 있으실거 같습니다. 최근 저와 제 AI 페어 프로그래밍 파트너(AKA. cursor)도 react-router-dom의 navigate 함수가 아무런 오류 메시지 없이 먹통이 되는 현상을 겪었습니다.
오늘은 이 "조용한 버그"의 원인을 파헤치고 해결하는 과정을 공유해보겠습니다. 특히 shadcn/ui와 같은 헤드리스(Headless...]]></description><link>https://ted-projects.com/react-router-ui-race-condition</link><guid isPermaLink="true">https://ted-projects.com/react-router-ui-race-condition</guid><category><![CDATA[react router]]></category><category><![CDATA[race-condition]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Wed, 30 Jul 2025 11:00:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1753781924411/966c2964-25d5-47e7-a8e7-084a64753276.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>개발을 하다 보면 분명 코드는 멀쩡해 보이는데, 원하는 대로 동작하지 않아 몇 시간을 헤매는 경험, 다들 한 번쯤 있으실거 같습니다. 최근 저와 제 AI 페어 프로그래밍 파트너(AKA. cursor)도 <code>react-router-dom</code>의 <code>navigate</code> 함수가 아무런 오류 메시지 없이 먹통이 되는 현상을 겪었습니다.</p>
<p>오늘은 이 "조용한 버그"의 원인을 파헤치고 해결하는 과정을 공유해보겠습니다. 특히 <code>shadcn/ui</code>와 같은 헤드리스(Headless) UI 라이브러리를 사용하신다면 좀 더 흥미롭게 읽으실 수 있으실겁니다.</p>
<h3 id="heading-consolelog">현상: <code>console.log</code>는 찍히는데, 페이지 이동은 안 된다?</h3>
<p>상황은 단순했습니다. 사용자가 드롭다운 메뉴에서 '로그아웃' 버튼을 클릭하면 로그아웃 페이지로 이동시키는 기능이었습니다. 코드는 다음과 같았습니다.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// Profile.tsx</span>
<span class="hljs-keyword">import</span> { useNavigate } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router-dom'</span>;
<span class="hljs-keyword">import</span> { DropdownMenuItem } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/components/ui/DropdownMenu'</span>;

<span class="hljs-comment">// ...</span>

<span class="hljs-keyword">const</span> navigate = useNavigate();

<span class="hljs-keyword">const</span> handleLogout = <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"로그아웃 시도"</span>);
  navigate(<span class="hljs-string">"/logout"</span>); <span class="hljs-comment">// 페이지 이동 실행</span>
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"navigate 함수 호출됨"</span>);
};

<span class="hljs-comment">// ...</span>

&lt;DropdownMenuItem onSelect={handleLogout}&gt;
  로그아웃
&lt;/DropdownMenuItem&gt;
</code></pre>
<p>간단하고 명백해 보이는 코드였습니다. 하지만 실제 동작은 우리를 미궁에 빠뜨렸습니다. '로그아웃' 버튼을 클릭하면 브라우저 콘솔에는 "로그아웃 시도", "navigate 함수 호출됨"이 모두 찍혔지만, 정작 URL은 바뀌지 않고 페이지는 미동도 하지 않았습니다. react-router에 설정해 둔 라우트 로더(<code>loader</code>)도 전혀 호출되지 않았죠. 마치 <code>navigate</code> 함수가 유령처럼 실행만 되고 사라지는 것처럼 느껴졌습니다.</p>
<h3 id="heading-1-usenavigate">1차 삽질: <code>useNavigate</code>의 잘못된 사용?</h3>
<p>처음에는 <code>react-router-dom</code>의 버전업에 따른 사용법 변경을 의심했습니다. 혹시 <code>replace</code> 함수를 직접 <code>import</code>해서 써야 하나? 아니면 <code>Link</code> 컴포넌트만 써야 하나? 여러 가설을 세우고 시도했지만 모두 실패였습니다. 코드는 <code>useNavigate</code>의 표준적인 사용법을 정확히 따르고 있었습니다.</p>
<h3 id="heading-2-useeffect">2차 삽질: <code>useEffect</code>와의 충돌?</h3>
<p>다음 가설은 "상태 변경과의 충돌"이었습니다. 혹시 <code>navigate</code>가 호출된 직후 다른 상태(<code>state</code>)가 변경되면서 컴포넌트가 리렌더링되고, 이 때문에 페이지 이동이 중단되는 것은 아닐까?</p>
<p>이 가설은 상당히 그럴듯했습니다. 실제로 <code>navigate</code>와 상태 변경 함수를 동시에 호출하면 종종 이런 문제가 발생하거든요. 그래서 <code>setTimeout</code>으로 <code>navigate</code> 호출을 살짝 지연시켜 리렌더링이 끝난 후에 실행되도록 하는 꼼수를 써봤습니다.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 임시 해결책 (하지만 좋은 방법은 아닙니다!)</span>
<span class="hljs-keyword">const</span> handleLogout = <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> navigate(<span class="hljs-string">"/logout"</span>), <span class="hljs-number">50</span>);
};
</code></pre>
<p>놀랍게도, 이 방법은 통했습니다! 페이지가 드디어 이동하기 시작했습니다. 하지만 이건 근본적인 해결책아니고 결국 Hack에 불과했습니다. 왜 이런 일이 발생하는지 정확한 원인을 알고 싶었습니다.</p>
<h3 id="heading-ui-race-condition">진짜 원인: UI 컴포넌트의 "기본 동작"과의 경합 조건 (Race Condition)</h3>
<p>문제의 핵심은 <code>DropdownMenuItem</code> 컴포넌트의 내부 동작에 있었습니다.</p>
<p><code>shadcn/ui</code>의 <code>DropdownMenu</code>는 내부적으로 <code>Radix UI</code>를 사용합니다. 이 라이브러리의 <code>DropdownMenuItem</code>은 <code>onSelect</code> 이벤트가 발생하면 (즉, 사용자가 메뉴를 클릭하면) <strong>두 가지 일</strong>을 하도록 설계되어 있습니다.</p>
<ol>
<li><p>우리가 <code>onSelect</code>에 전달한 함수(<code>handleLogout</code>)를 실행한다.</p>
</li>
<li><p><strong>메뉴를 닫는 "기본 동작"을 실행한다.</strong> (내부적으로 <code>open</code> 상태를 <code>false</code>로 바꾼다.)</p>
</li>
</ol>
<p>이 두 가지 동작이 거의 동시에 실행되면서 "경합 조건(Race Condition)"이 발생한 것입니다.</p>
<ol>
<li><p><code>handleLogout</code>이 호출되고, <code>navigate</code> 함수가 페이지 이동 프로세스를 <strong>"시작"</strong> 합니다.</p>
</li>
<li><p><strong>하지만 페이지 이동이 완료되기 전에,</strong> <code>DropdownMenu</code>가 닫히면서 내부 상태가 변경되고, 이로 인해 <code>Profile</code> 컴포넌트가 리렌더링됩니다.</p>
</li>
<li><p>이 갑작스러운 리렌더링이, 이제 막 출발하려던 페이지 이동 프로세스를 <strong>"취소"</strong> 시켜버린 것입니다.</p>
</li>
</ol>
<p>이것이 바로 콘솔은 찍히지만 페이지 이동은 안 되는, 유령 버그의 정체였습니다.</p>
<h3 id="heading-aschild">최종 해결책: 라이브러리가 의도한 방법, <code>asChild</code></h3>
<p><code>setTimeout</code> 같은 임시방편 대신, 라이브러리가 이런 경우를 위해 마련해 둔 우아한 해결책이 있었습니다. 바로 <code>asChild</code> 속성입니다.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 최종 해결책</span>
<span class="hljs-keyword">import</span> { Link } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router-dom'</span>;
<span class="hljs-keyword">import</span> { DropdownMenuItem } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/components/ui/DropdownMenu'</span>;

<span class="hljs-comment">// ...</span>

&lt;DropdownMenuItem asChild&gt;
  &lt;Link to=<span class="hljs-string">"/logout"</span> replace&gt;
    로그아웃
  &lt;/Link&gt;
&lt;/DropdownMenuItem&gt;
</code></pre>
<p><code>asChild</code> 속성은 <code>DropdownMenuItem</code>에게 "너 자신을 렌더링하지 말고, 네 자식 컴포넌트(<code>Link</code>)에게 너의 모든 스타일과 동작을 물려줘"라고 지시합니다.</p>
<p>그 결과, 우리는 <code>react-router-dom</code>의 <code>Link</code> 컴포넌트를 사용하지만, 겉보기에는 완벽한 <code>DropdownMenuItem</code>처럼 보이는 컴포넌트를 얻게 됩니다.</p>
<p>이 방법이 왜 완벽한 해결책일까요?</p>
<ul>
<li><p><strong>진짜 링크(</strong><code>&lt;a&gt;</code> <strong>태그)로 동작:</strong> 이제 메뉴 아이템은 단순한 버튼이 아니라, 페이지 이동을 위한 시맨틱한 링크가 됩니다. 브라우저는 링크 클릭 시 페이지 이동을 다른 UI 상태 변경보다 우선시하므로 충돌 자체가 발생하지 않습니다.</p>
</li>
<li><p><strong>불필요한 핸들러 제거:</strong> 더 이상 <code>navigate</code>나 <code>handleLogout</code> 함수가 필요 없어 코드가 훨씬 간결해집니다.</p>
</li>
<li><p><code>replace</code> <strong>속성 활용:</strong> <code>Link</code> 컴포넌트의 <code>replace</code> 속성을 사용하면, 로그아웃 페이지가 브라우저 히스토리에 쌓이지 않게 하여 뒤로가기 문제를 깔끔하게 방지할 수 있습니다.</p>
</li>
</ul>
<h3 id="heading-66ei66y066asio2ajoqzoa">마무리 회고</h3>
<p>이번 삽질을 통해 몇 가지 중요한 교훈을 얻었습니다.</p>
<ol>
<li><p><code>navigate</code>가 동작하지 않으면, 리렌더링과의 충돌을 가장 먼저 의심하라. 특히 인터랙티브한 UI 컴포넌트 내부에서 <code>navigate</code>를 호출할 때는 더욱 그렇습니다.</p>
</li>
<li><p><strong>사용하는 라이브러리의 문서를 다시 한번 살펴보자.</strong> <code>setTimeout</code> 같은 꼼수가 통하더라도, 분명 더 우아하고 안정적인 "공식적인" 해결책이 존재할 가능성이 높습니다. <code>asChild</code>가 바로 그런 케이스였습니다.</p>
</li>
<li><p><strong>Headless UI 라이브러리는 편리하지만, 내부 동작 원리를 이해하는 것이 중요하다.</strong> 내부적으로 어떤 상태를 관리하고, 어떤 기본 동작이 실행되는지 알면 이런 디버깅 시간을 크게 줄일 수 있습니다.</p>
</li>
</ol>
<p>혹시 저와 비슷한 문제를 겪고 계신 분이 있다면, 이 글이 문제 해결의 실마리가 되기를 바랍니다. 읽어주셔서 감사합니다.</p>
]]></content:encoded></item><item><title><![CDATA[React Query 내부 동작 원리 완벽 분석]]></title><description><![CDATA[React Query v5(TanStack Query)의 내부 구조와 동작 원리를 심층 분석하여, 더 효율적인 서버 상태 관리를 위한 인사이트를 제공합니다.

들어가며
React Query는 비동기 상태 관리 라이브러리로서 리액트 개발에서 서버 상태 관리의 사실상 표준처럼 사용되고 있습니다. 하지만 useQuery()를 사용하다 보면 내부에서 어떤 마법이 일어나는지 궁금해집니다. 김정환님의 블로그를 참고하여 React Query의 내부 동작을 ...]]></description><link>https://ted-projects.com/react-query-internals</link><guid isPermaLink="true">https://ted-projects.com/react-query-internals</guid><category><![CDATA[react-query-internals]]></category><category><![CDATA[react-query]]></category><category><![CDATA[tanstack-query]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Tue, 29 Jul 2025 09:17:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1753780457411/e119493f-f55c-4a1b-979f-c37781c5c520.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>React Query v5(TanStack Query)의 내부 구조와 동작 원리를 심층 분석하여, 더 효율적인 서버 상태 관리를 위한 인사이트를 제공합니다.</p>
</blockquote>
<h2 id="heading-65ok7ja06rca66mw">들어가며</h2>
<p>React Query는 비동기 상태 관리 라이브러리로서 리액트 개발에서 서버 상태 관리의 사실상 표준처럼 사용되고 있습니다. 하지만 <code>useQuery()</code>를 사용하다 보면 내부에서 어떤 마법이 일어나는지 궁금해집니다. <a target="_blank" href="https://jeonghwan-kim.github.io/2025/05/11/how-react-query-works">김정환님의 블로그</a>를 참고하여 React Query의 내부 동작을 체계적으로 분석해봤습니다.</p>
<h2 id="heading-7jwe7ykk7ywn7lkyioqwnoyala">아키텍처 개요</h2>
<p>React Query는 크게 두 개의 패키지로 구성됩니다</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>패키지</td><td>역할</td><td>주요 구성 요소</td></tr>
</thead>
<tbody>
<tr>
<td><strong>react-query</strong></td><td>UI 프레임워크 통합</td><td><code>useQuery</code>, <code>useBaseQuery</code>, React 훅들</td></tr>
<tr>
<td><strong>query-core</strong></td><td>핵심 비즈니스 로직</td><td><code>QueryObserver</code>, <code>Query</code>, <code>QueryCache</code>, <code>QueryClient</code></td></tr>
</tbody>
</table>
</div><p>구조를 시각적으로 나타내 보겠습니다.</p>
<pre><code class="lang-mermaid">graph TB
    subgraph "react-query 패키지"
        A1[useQuery]
        A2[useInfiniteQuery]
        A3[useQueries]
        A4[useBaseQuery]
        A1 --&gt; A4
        A2 --&gt; A4
        A3 --&gt; A4
    end

    subgraph "query-core 패키지"
        B1[QueryObserver]
        B2[Query]
        B3[QueryCache]
        B4[QueryClient]
        B5[notifyManager]

        B1 -.-&gt;|구독| B2
        B2 -.-&gt;|저장| B3
        B4 -.-&gt;|보유| B3
        B1 -.-&gt;|알림| B5
    end

    subgraph "브라우저 이벤트"
        C1[focusManager]
        C2[onlineManager]
    end

    A4 -.-&gt;|생성| B1
    A4 -.-&gt;|useSyncExternalStore| B1
    B4 -.-&gt;|구독| C1
    B4 -.-&gt;|구독| C2
    C1 -.-&gt;|포커스 이벤트| B3
    C2 -.-&gt;|온라인 이벤트| B3
</code></pre>
<h2 id="heading-7zw17iusioq1royessdsmptshowg67ae7isd">핵심 구성 요소 분석</h2>
<h3 id="heading-1-usequery">1. useQuery() 훅의 역할</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>특징</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td><strong>함수 오버로딩</strong></td><td>다양한 타입의 옵션을 받을 수 있도록 3가지 시그니처 제공</td></tr>
<tr>
<td><strong>단순한 구조</strong></td><td>실제로는 <code>useBaseQuery()</code>에 <code>QueryObserver</code> 클래스를 전달하는 역할만</td></tr>
<tr>
<td><strong>코드 라인</strong></td><td>약 50줄의 매우 간결한 구현</td></tr>
</tbody>
</table>
</div><pre><code class="lang-typescript"><span class="hljs-comment">// useQuery의 핵심 구조</span>
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">useQuery</span>(<span class="hljs-params">options, queryClient</span>) </span>{
  <span class="hljs-keyword">return</span> useBaseQuery(options, QueryObserver, queryClient)
}
</code></pre>
<h3 id="heading-2">2. 전체 데이터 흐름 과정</h3>
<p>React Query의 전체 동작 흐름을 한눈 보기</p>
<pre><code class="lang-mermaid">graph TD
    A["컴포넌트에서 useQuery() 호출"]
    B["useBaseQuery 실행"]
    D["QueryObserver 생성"]
    E["useSyncExternalStore로&lt;br&gt;컴포넌트와 상태 동기화"]
    F["QueryCache에서 Query 조회/생성"]
    G{"Query 존재?"}
    H["새 Query 인스턴스 생성"]
    I["기존 Query 사용"]
    I2{"데이터가 Stale?"}
    I3["캐시 데이터로 즉시 렌더링"]
    J["Query.fetch() 실행"]
    K["네트워크 요청"]
    L{"요청 결과"}
    M["Query 상태 업데이트&lt;br/&gt;(data, error 등)"]
    N["Query 에러 상태 업데이트"]
    O["QueryObserver에 변경 알림"]
    P["notifyManager가 알림을 batch 처리"]
    T["컴포넌트 리렌더링"]

    A --&gt; B
    B --&gt; D
    D --&gt; F
    D --&gt; E
    F --&gt; G
    G -- "없음" --&gt; H
    G -- "있음" --&gt; I
    I --&gt; I2
    I2 -- "Yes (Stale)" --&gt; J
    I2 -- "No (Fresh)" --&gt; I3
    H --&gt; J
    J --&gt; K
    K --&gt; L
    L -- "성공" --&gt; M
    L -- "실패" --&gt; N
    M --&gt; O
    N --&gt; O
    O --&gt; P
    P --&gt; E
    E --&gt; T
    I3 --&gt; T

    classDef user fill:#c9d1d9,stroke:#333,stroke-width:1px
    classDef react fill:#61dafb,stroke:#333,stroke-width:2px,color:#000
    classDef core fill:#ff6b6b,stroke:#333,stroke-width:2px,color:#fff
    classDef result fill:#4ecdc4,stroke:#333,stroke-width:2px,color:#000

    class A user
    class B,D,E react
    class F,G,H,I,I2,J,K,L,M,N,O,P core
    class T,I3 result
</code></pre>
<h3 id="heading-3-usebasequery">3. useBaseQuery()의 핵심 역할</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>단계</td><td>작업 내용</td><td>코드 예시</td></tr>
</thead>
<tbody>
<tr>
<td><strong>1. 클라이언트 설정</strong></td><td>QueryClient 획득 및 옵션 병합</td><td><code>const client = useQueryClient(queryClient)</code></td></tr>
<tr>
<td><strong>2. 옵저버 생성</strong></td><td>QueryObserver 인스턴스 생성</td><td><code>const [observer] = useState(() =&gt; new Observer(client, options))</code></td></tr>
<tr>
<td><strong>3. 구독 설정</strong></td><td>외부 스토어 구독으로 리액트와 동기화</td><td><code>useSyncExternalStore(...)</code></td></tr>
<tr>
<td><strong>4. 결과 반환</strong></td><td>최적화된 결과 객체 반환</td><td><code>observer.trackResult(result)</code></td></tr>
</tbody>
</table>
</div><h3 id="heading-4">4. 시퀀스 다이어그램으로 전체 데이터 흐름 다시보기</h3>
<p>컴포넌트부터 네트워크 요청까지의 상세한 상호작용 흐름을 확인해보겠습니다.</p>
<pre><code class="lang-mermaid">sequenceDiagram
    participant Component as 🎯 컴포넌트
    participant useQuery as 🪝 useQuery
    participant Observer as 👁️ QueryObserver
    participant Cache as 💾 QueryCache
    participant Query as 📡 Query
    participant Network as 🌐 네트워크
    participant NotifyMgr as ⚡ notifyManager

    Component-&gt;&gt;useQuery: useQuery(options) 호출
    useQuery-&gt;&gt;Observer: QueryObserver 생성
    Observer-&gt;&gt;Cache: Query 조회/생성 요청

    alt Query가 없는 경우
        Cache-&gt;&gt;Query: 새 Query 인스턴스 생성
    else Query가 있는 경우
        Cache-&gt;&gt;Query: 기존 Query 반환
    end

    Observer-&gt;&gt;Query: 구독 시작
    Query-&gt;&gt;Network: fetch 요청 실행
    Network--&gt;&gt;Query: 응답 데이터

    Query-&gt;&gt;Observer: 상태 변경 알림
    Observer-&gt;&gt;NotifyMgr: batchCalls로 알림
    NotifyMgr-&gt;&gt;NotifyMgr: 배치 처리
    NotifyMgr--&gt;&gt;Component: 리렌더링 트리거

    Note over Component: 최신 데이터로 UI 업데이트
</code></pre>
<h3 id="heading-5-queryobserver">5. QueryObserver - 리렌더링의 핵심</h3>
<p>QueryObserver는 Query와 React 컴포넌트 사이의 가교 역할을 합니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>기능</td><td>메서드</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td><strong>구독 관리</strong></td><td><code>onSubscribe()</code>, <code>onUnsubscribe()</code></td><td>구독자 생명주기 관리</td></tr>
<tr>
<td><strong>옵션 설정</strong></td><td><code>setOptions()</code></td><td>쿼리 옵션 변경 및 재구성</td></tr>
<tr>
<td><strong>데이터 패치</strong></td><td><code>#executeFetch()</code></td><td>즉시 데이터 페칭 실행</td></tr>
<tr>
<td><strong>결과 최적화</strong></td><td><code>trackResult()</code></td><td>불필요한 렌더링 방지</td></tr>
<tr>
<td><strong>낙관적 업데이트</strong></td><td><code>getOptimisticResult()</code></td><td>로딩 전 예상 결과 제공</td></tr>
</tbody>
</table>
</div><h3 id="heading-6-query">6. Query - 서버 상태의 단위</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>속성</td><td>타입</td><td>역할</td></tr>
</thead>
<tbody>
<tr>
<td><strong>queryKey</strong></td><td><code>QueryKey</code></td><td>쿼리 식별자</td></tr>
<tr>
<td><strong>queryFn</strong></td><td><code>QueryFunction</code></td><td>실제 데이터 페칭 함수</td></tr>
<tr>
<td><strong>state</strong></td><td><code>QueryState</code></td><td>현재 쿼리 상태 (data, error, status 등)</td></tr>
<tr>
<td><strong>observers</strong></td><td><code>Set&lt;QueryObserver&gt;</code></td><td>구독 중인 옵저버들</td></tr>
<tr>
<td><strong>promise</strong></td><td><code>Promise</code></td><td>진행 중인 요청 프로미스</td></tr>
</tbody>
</table>
</div><h4 id="heading-query">Query의 생명주기</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>단계</td><td>상태</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td><strong>1. 초기화</strong></td><td><code>idle</code></td><td>아직 실행되지 않은 상태</td></tr>
<tr>
<td><strong>2. 로딩</strong></td><td><code>pending</code></td><td>데이터 페칭 중</td></tr>
<tr>
<td><strong>3. 성공</strong></td><td><code>success</code></td><td>데이터 페칭 완료</td></tr>
<tr>
<td><strong>4. 실패</strong></td><td><code>error</code></td><td>에러 발생</td></tr>
</tbody>
</table>
</div><h3 id="heading-7-querycache">7. QueryCache - 중앙 저장소</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>기능</td><td>메서드</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td><strong>저장/조회</strong></td><td><code>get()</code>, <code>getAll()</code></td><td>쿼리 인스턴스 관리</td></tr>
<tr>
<td><strong>검색</strong></td><td><code>find()</code>, <code>findAll()</code></td><td>조건에 맞는 쿼리 검색</td></tr>
<tr>
<td><strong>이벤트 처리</strong></td><td><code>onFocus()</code>, <code>onOnline()</code></td><td>브라우저 이벤트 대응</td></tr>
<tr>
<td><strong>구독 관리</strong></td><td><code>subscribe()</code></td><td>캐시 변경 알림</td></tr>
</tbody>
</table>
</div><h3 id="heading-8-queryclient-api">8. QueryClient - 전역 API 제공자</h3>
<p>QueryClient는 명령형 API를 통해 쿼리를 제어할 수 있게 해줍니다.</p>
<blockquote>
<p>개인적으로 QueryClient가 선언형으로 다룰 수 있게되면 좋겠다는 소박한 희망이 있습니다.</p>
</blockquote>
<h4 id="heading-api">데이터 조작 API</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>메서드</td><td>용도</td><td>사용 시점</td></tr>
</thead>
<tbody>
<tr>
<td><code>getQueryData()</code></td><td>캐시된 데이터 조회</td><td>컴포넌트 외부에서 데이터 접근</td></tr>
<tr>
<td><code>setQueryData()</code></td><td>캐시 데이터 직접 설정</td><td>낙관적 업데이트, 수동 캐시 조작</td></tr>
<tr>
<td><code>invalidateQueries()</code></td><td>쿼리를 stale 상태로 변경</td><td>데이터 새로고침 필요 시</td></tr>
<tr>
<td><code>refetchQueries()</code></td><td>쿼리 재요청</td><td>강제 데이터 갱신</td></tr>
<tr>
<td><code>removeQueries()</code></td><td>캐시에서 쿼리 제거</td><td>메모리 정리, 민감한 데이터 삭제</td></tr>
</tbody>
</table>
</div><h4 id="heading-api-1">프리페칭 API</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>메서드</td><td>설명</td><td>장점</td></tr>
</thead>
<tbody>
<tr>
<td><code>prefetchQuery()</code></td><td>미리 데이터 로드</td><td>사용자 경험 향상</td></tr>
<tr>
<td><code>ensureQueryData()</code></td><td>캐시 확인 후 필요 시 페치</td><td>중복 요청 방지</td></tr>
</tbody>
</table>
</div><h2 id="heading-notifymanager">성능 최적화: notifyManager</h2>
<p>React Query의 성능 최적화 핵심은 <code>notifyManager</code>입니다.</p>
<h3 id="heading-67cw7lmyioyymoumrcdrqztsu6tri4jsppg">배치 처리 메커니즘</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>단계</td><td>함수</td><td>역할</td></tr>
</thead>
<tbody>
<tr>
<td><strong>1. 배치 시작</strong></td><td><code>batch()</code></td><td>트랜잭션 시작</td></tr>
<tr>
<td><strong>2. 알림 큐잉(Queueing)</strong></td><td><code>schedule()</code></td><td>알림을 큐에 추가</td></tr>
<tr>
<td><strong>3. 배치 실행</strong></td><td><code>flush()</code></td><td>큐의 모든 알림을 한 번에 실행</td></tr>
<tr>
<td><strong>4. 다음 틱(Tick) 예약</strong></td><td><code>scheduleFn()</code></td><td><code>setTimeout(callback, 1)</code> 기본값</td></tr>
</tbody>
</table>
</div><h3 id="heading-66cm642u66ebioy1noygge2zloydmcdtmqjqs7w">렌더링 최적화의 효과</h3>
<p>호출이 많을수록 최적화의 효과가 두드러집니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>상황</td><td>배치 처리 없이</td><td>배치 처리 적용</td></tr>
</thead>
<tbody>
<tr>
<td><strong>동시 쿼리 업데이트</strong></td><td>N번 렌더링</td><td>1번 렌더링</td></tr>
<tr>
<td><strong>연속 상태 변경</strong></td><td>각각 렌더링</td><td>마지막 상태만 렌더링</td></tr>
<tr>
<td><strong>성능 영향</strong></td><td>높음</td><td>최소화</td></tr>
</tbody>
</table>
</div><h2 id="heading-6rws7isxioyaloygjouzhcdsl63tlaag7jqu7jw9">구성 요소별 역할 요약</h2>
<p>전체 구조를 마인드맵으로 정리해보겠습니다.</p>
<pre><code class="lang-mermaid">mindmap
  root((React Query))
    [useQuery]
      useBaseQuery
        QueryObserver 생성
        useSyncExternalStore 구독
    [QueryObserver]
      Query 구독
      상태 파생 및 전달
      1:N 구독 관계
    [Query]
      서버 상태 단위
      fetchFn 트리거
      결과 전파
    [QueryCache]
      Query 중앙 저장소
      쿼리 키로 접근
      생명주기 관리
    [QueryClient]
      전역 API 제공
      QueryCache 보유
      명령형 인터페이스
    [notifyManager]
      배치 처리
      성능 최적화
      렌더링 제어
</code></pre>
<h2 id="heading-642w7j207yswio2dkoumhcdri6jqs4trs4qg7kcv66as">데이터 흐름 단계별 정리</h2>
<p>간단한 표를 활용하여 전체 데이터 흐름을 단계별로 정리해보겠습니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>순서</td><td>단계</td><td>주체</td><td>작업</td></tr>
</thead>
<tbody>
<tr>
<td><strong>1</strong></td><td>호출</td><td>컴포넌트</td><td><code>useQuery()</code> 실행</td></tr>
<tr>
<td><strong>2</strong></td><td>초기화</td><td>useBaseQuery</td><td>QueryObserver 생성 및 구독</td></tr>
<tr>
<td><strong>3</strong></td><td>쿼리 조회</td><td>QueryObserver</td><td>QueryCache에서 Query 찾기/생성</td></tr>
<tr>
<td><strong>4</strong></td><td>데이터 페칭</td><td>Query</td><td>네트워크 요청 실행</td></tr>
<tr>
<td><strong>5</strong></td><td>상태 업데이트</td><td>Query</td><td>결과에 따른 상태 변경</td></tr>
<tr>
<td><strong>6</strong></td><td>알림 전파</td><td>QueryObserver</td><td>구독자들에게 변경 알림</td></tr>
<tr>
<td><strong>7</strong></td><td>배치 처리</td><td>notifyManager</td><td>렌더링 최적화</td></tr>
<tr>
<td><strong>8</strong></td><td>컴포넌트 업데이트</td><td>React</td><td>리렌더링 실행</td></tr>
</tbody>
</table>
</div><h2 id="heading-7isx64qliouqqoulio2esoungq">성능 모니터링</h2>
<p>React Query의 성능을 모니터링할 수 있는 지표</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>지표</td><td>확인 방법</td><td>목적</td></tr>
</thead>
<tbody>
<tr>
<td><strong>캐시 히트율</strong></td><td>DevTools의 쿼리 상태</td><td>네트워크 요청 절약</td></tr>
<tr>
<td><strong>렌더링 횟수</strong></td><td>React Profiler</td><td>불필요한 렌더링 탐지</td></tr>
<tr>
<td><strong>메모리 사용량</strong></td><td>브라우저 DevTools</td><td>메모리 누수 방지</td></tr>
<tr>
<td><strong>네트워크 요청</strong></td><td>Network 탭</td><td>중복 요청 확인</td></tr>
</tbody>
</table>
</div><h2 id="heading-6rkw66gg">결론</h2>
<p>React Query의 내부 동작을 이해하면 다음과 같은 이점을 얻을 수 있습니다</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>영역</td><td>개선 효과</td></tr>
</thead>
<tbody>
<tr>
<td><strong>성능</strong></td><td>불필요한 요청과 렌더링 최소화</td></tr>
<tr>
<td><strong>디버깅</strong></td><td>문제 발생 시 정확한 원인 파악</td></tr>
<tr>
<td><strong>최적화</strong></td><td>적절한 옵션 설정으로 앱 성능 향상</td></tr>
<tr>
<td><strong>확장성</strong></td><td>대규모 앱에서도 안정적인 상태 관리</td></tr>
</tbody>
</table>
</div><p><strong>핵심 포인트:</strong></p>
<ul>
<li><p><strong>QueryObserver</strong>가 Query와 컴포넌트를 연결하는 핵심 가교 역할</p>
</li>
<li><p><strong>notifyManager</strong>의 배치 처리로 렌더링 성능 최적화</p>
</li>
<li><p><strong>QueryCache</strong>를 통한 효율적인 중앙 집중식 상태 관리</p>
</li>
<li><p><strong>명령형 API</strong>로 컴포넌트 외부에서도 쿼리 제어 가능</p>
</li>
</ul>
<p>React Query는 데이터 페칭에 많이 쓰이지만 사실 정교하게 설계된 비동기 상태 관리 시스템입니다. 이러한 내부 구조를 이해하고 활용한다면, 더욱 효율적이고 안정적인 React 애플리케이션을 개발할 수 있습니다.</p>
<hr />
<p><em>참고:</em> <a target="_blank" href="https://jeonghwan-kim.github.io/2025/05/11/how-react-query-works"><em>리액트 쿼리, 내부는 이렇게 움직인다 - 김정환 블로그</em></a></p>
]]></content:encoded></item><item><title><![CDATA[Nginx 프록시 설정 문제 해결기]]></title><description><![CDATA[최근 QA 환경에서 API 호출이 제대로 되지 않는 문제를 겪었습니다. 겉보기에는 단순해 보였던 이 문제는 Nginx 프록시 설정의 여러 복잡한 요소들이 얽혀 있는 흥미로운 케이스였습니다. 문제 해결 과정에서 배운 것들을 정리해 보았습니다.
시작: 단순해 보였던 405 에러
QA 환경에서 다음과 같은 API 호출이 실패하고 있었습니다:
POST https://qa.leadershipcube.ai/api/assessment/v1/login
→ 4...]]></description><link>https://ted-projects.com/nginx-405-404-301</link><guid isPermaLink="true">https://ted-projects.com/nginx-405-404-301</guid><category><![CDATA[nginx]]></category><category><![CDATA[frontend]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Thu, 24 Jul 2025 14:15:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1753365020153/18253f91-0a00-4f82-ad77-042160e14912.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>최근 QA 환경에서 API 호출이 제대로 되지 않는 문제를 겪었습니다. 겉보기에는 단순해 보였던 이 문제는 Nginx 프록시 설정의 여러 복잡한 요소들이 얽혀 있는 흥미로운 케이스였습니다. 문제 해결 과정에서 배운 것들을 정리해 보았습니다.</p>
<h2 id="heading-405">시작: 단순해 보였던 405 에러</h2>
<p>QA 환경에서 다음과 같은 API 호출이 실패하고 있었습니다:</p>
<pre><code class="lang-typescript">POST https:<span class="hljs-comment">//qa.leadershipcube.ai/api/assessment/v1/login</span>
→ <span class="hljs-number">405</span> Method Not Allowed
</code></pre>
<p>첫 번째 가설은 "백엔드 서버가 POST 메서드를 지원하지 않나?"였습니다. 하지만 곧 더 깊은 문제가 숨어있다는 것을 깨달았습니다.</p>
<h2 id="heading-curl">🔍 디버깅의 시작: curl로 추적하기</h2>
<p>문제를 정확히 파악하기 위해 <code>curl</code>을 사용해 백엔드 서버에 직접 요청을 보내봤습니다:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># 백엔드 서버 직접 호출</span>
curl -X POST https://gw.qa.hunet.io/assessment/v1/login \
  -H <span class="hljs-string">"Content-Type: application/json"</span> \
  -H <span class="hljs-string">"x-hunet-franchise-type: 271"</span> \
  -d <span class="hljs-string">'{"email": "test@example.com", "employeeCode": "Test"}'</span>
</code></pre>
<p><strong>결과: 200 OK ✅</strong></p>
<p>이상했습니다. 백엔드는 정상적으로 동작하는데, 프록시를 거치면 왜 405 에러가 나는지 알 수 없었죠.</p>
<h2 id="heading-host">첫 번째 발견: Host 헤더 문제</h2>
<p>Nginx 프록시 설정을 자세히 살펴보니 문제를 발견했습니다:</p>
<pre><code class="lang-nginx"><span class="hljs-comment"># 기존 설정</span>
<span class="hljs-attribute">location</span> <span class="hljs-regexp">~ ^/api/assessment</span> {
  <span class="hljs-attribute">rewrite</span><span class="hljs-regexp"> ^/api/assessment(.*)</span> /assessment<span class="hljs-variable">$1</span> <span class="hljs-literal">break</span>;
  <span class="hljs-attribute">proxy_pass</span> https://api_backend;
  <span class="hljs-attribute">proxy_set_header</span> Host <span class="hljs-variable">$host</span>;  <span class="hljs-comment"># 여기가 문제!</span>
}
</code></pre>
<p><code>proxy_set_header Host $host;</code> 설정 때문에 Nginx가 브라우저의 원래 <code>Host</code> 헤더(<code>qa.xxx.ai</code>)를 백엔드로 그대로 전달하고 있었습니다.</p>
<p>하지만 백엔드 서버는 자신의 주소(<code>gw.qa.xxx.io</code>)가 <code>Host</code> 헤더에 있어야만 요청을 처리하도록 설정되어 있었습니다.</p>
<h3 id="heading-1-host">해결책 1: 올바른 Host 헤더 설정</h3>
<pre><code class="lang-nginx"><span class="hljs-attribute">location</span> /api/assessment/ {
  <span class="hljs-attribute">proxy_pass</span> https://api_backend/assessment/;
  <span class="hljs-attribute">proxy_set_header</span> Host gw.qa.xxx.io;  <span class="hljs-comment"># 백엔드가 기대하는 Host로 명시</span>
}
</code></pre>
<h2 id="heading-301">두 번째 문제: 301 리다이렉션 지옥</h2>
<p>Host 헤더 문제를 해결하고 나니, 이번에는 <code>/api/auth</code> 엔드포인트에서 다른 문제가 발생했습니다:</p>
<pre><code class="lang-typescript">PUT https:<span class="hljs-comment">//qa.xxx.ai/api/auth</span>
→ <span class="hljs-number">301</span> Moved Permanently
→ Location: https:<span class="hljs-comment">//qa.xxx.ai/api/auth/</span>
</code></pre>
<h3 id="heading-7juq7j24oidsiqzrnpjsi5wolykg67ai7j287lmy">원인: 슬래시(/) 불일치</h3>
<pre><code class="lang-nginx"><span class="hljs-comment"># 문제가 되는 설정</span>
<span class="hljs-attribute">location</span> /api/auth/ {  <span class="hljs-comment"># 슬래시로 끝남</span>
  <span class="hljs-attribute">proxy_pass</span> ...;
}
</code></pre>
<p>클라이언트는 <code>/api/auth</code> (슬래시 없음)으로 요청했는데, Nginx 설정은 <code>/api/auth/</code> (슬래시 있음)로 되어 있어 Nginx가 "친절하게" 리다이렉션을 보낸 것이었습니다.</p>
<h3 id="heading-2">해결책 2: 정규식으로 유연하게 처리</h3>
<pre><code class="lang-nginx"><span class="hljs-comment"># 시도 1: 정규식 사용</span>
<span class="hljs-attribute">location</span> <span class="hljs-regexp">~ ^/api/auth/?$</span> {
  <span class="hljs-attribute">proxy_pass</span> https://api_backend/auth/;
}

<span class="hljs-comment"># 시도 2: rewrite 사용</span>
<span class="hljs-attribute">location</span> /api/auth {
  <span class="hljs-attribute">rewrite</span><span class="hljs-regexp"> ^/api/auth(.*)$</span> /auth<span class="hljs-variable">$1</span> <span class="hljs-literal">break</span>;
  <span class="hljs-attribute">proxy_pass</span> https://api_backend;
}
</code></pre>
<h2 id="heading-66gc7lusio2fjoykpo2kucdtmzjqsr0g6rws7lav">로컬 테스트 환경 구축</h2>
<p>매번 QA 서버에 배포해서 테스트하는 것은 비효율적이었습니다. Mac북에서 사용할 수 있는 도커 cli 중에 podman을 사용해 로컬 테스트 환경을 구축했습니다.</p>
<pre><code class="lang-bash">podman run --rm -d --name local-nginx-test \
  -p 8080:80 \
  -v ./build/qa/default.conf:/etc/nginx/conf.d/default.conf:ro \
  -v ./public:/usr/share/nginx/html:ro \
  docker.io/nginx:latest
</code></pre>
<h3 id="heading-7jii7iob7lmyiouqu2vncdrrljsojzrk6q">예상치 못한 문제들</h3>
<p>로컬 환경에서 여러 예상치 못한 문제들을 만났습니다:</p>
<ol>
<li><p><strong>DNS resolver 충돌</strong>: 로컬 컨테이너가 사설 DNS 서버에 접근하지 못해 발생</p>
</li>
<li><p><strong>Nginx 설정 문법 오류</strong>: <code>set</code> 지시어를 잘못된 위치에 사용</p>
</li>
<li><p><strong>Podman 네트워킹 문제</strong>: 포트 포워딩이 예상대로 동작하지 않음</p>
</li>
</ol>
<p>각각을 하나씩 해결해 나가면서, 로컬에서 실제 QA 환경과 동일한 동작을 재현할 수 있게 되었습니다.</p>
<h2 id="heading-http">마지막 발견: HTTP 메서드 문제</h2>
<p>모든 설정을 올바르게 고쳤는데도 여전히 405 에러가 발생했습니다. 마지막에 알고 보니</p>
<pre><code class="lang-bash"><span class="hljs-comment"># 실패하는 요청</span>
curl -X POST /api/auth ...
→ 405 Method Not Allowed

<span class="hljs-comment"># 성공하는 요청  </span>
curl -X PUT /api/auth ...
→ 200 OK ✅
</code></pre>
<p>토큰 갱신 API는 <code>POST</code>가 아니라 <code>PUT</code> 메서드를 사용해야 했습니다. 실수도 연속해서 저지르다보면 인지도 못하는 쉬운 실수가 생기더라구요.</p>
<h2 id="heading-7lwc7kkfio2vtoqysoyxhtog64uo7iic7zwo7j2yioykueumra">최종 해결책: 단순함의 승리</h2>
<p>모든 문제를 해결한 후, 복잡했던 설정을 더 간단하고 명확하게 리팩토링했습니다:</p>
<pre><code class="lang-nginx"><span class="hljs-comment"># 최종 설정 - 간단하고 명확함</span>
<span class="hljs-section">server</span> {
  <span class="hljs-attribute">set</span> <span class="hljs-variable">$backend_host</span> <span class="hljs-string">"gw.qa.xxx.io"</span>;

  <span class="hljs-attribute">location</span> /api/assessment/ {
    <span class="hljs-attribute">proxy_pass</span> https://api_backend/assessment/;
    <span class="hljs-attribute">proxy_set_header</span> Host <span class="hljs-variable">$backend_host</span>;
    <span class="hljs-comment"># ... 기타 헤더들</span>
  }

  <span class="hljs-attribute">location</span> /api/auth {
    <span class="hljs-attribute">proxy_pass</span> https://api_backend/auth;
    <span class="hljs-attribute">proxy_set_header</span> Host <span class="hljs-variable">$backend_host</span>;
    <span class="hljs-comment"># ... 기타 헤더들</span>
  }
}
</code></pre>
<h2 id="heading-67cw7jq0ioygkoutpa">배운 점들</h2>
<h3 id="heading-1">1. 체계적인 디버깅의 중요성</h3>
<ul>
<li><p><strong>가설 세우기</strong>: 각 단계에서 명확한 가설을 세우고 검증</p>
</li>
<li><p><strong>격리된 테스트</strong>: <code>curl</code>을 사용해 Nginx를 우회한 직접 테스트</p>
</li>
<li><p><strong>로컬 재현</strong>: 로컬 환경에서 문제를 재현해 빠른 반복 테스트</p>
</li>
</ul>
<h3 id="heading-2-nginx">2. Nginx의 미묘한 동작들</h3>
<ul>
<li><p><strong>Host 헤더의 중요성</strong>: 가상 호스팅 환경에서 Host 헤더가 라우팅에 미치는 영향</p>
</li>
<li><p><strong>경로 매칭의 복잡성</strong>: 슬래시 유무에 따른 리다이렉션 동작</p>
</li>
<li><p><strong>upstream vs 변수</strong>: <code>proxy_pass</code>에서 upstream과 변수 사용의 차이점</p>
</li>
</ul>
<h3 id="heading-3">3. 도구의 활용</h3>
<ul>
<li><p><strong>curl</strong>: API 테스트의 강력한 도구</p>
</li>
<li><p><strong>Podman/Docker</strong>: 로컬 테스트 환경 구축</p>
</li>
<li><p><strong>로그 분석</strong>: 에러 메시지에서 단서 찾기</p>
</li>
</ul>
<h2 id="heading-6rkw66gg">결론</h2>
<p>겉보기에 단순해 보였던 405 에러 하나가 Host 헤더, 경로 매칭, HTTP 메서드, DNS 설정 등 여러 복잡한 요소들이 얽힌 문제였습니다.</p>
<p>이 과정을 통해 Nginx 프록시의 동작 원리를 이전보다 깊이 이해하게 되었고, 무엇보다 <strong>체계적인 디버깅과 로컬 테스트 환경의 중요성</strong>을 다시 한번 깨달았습니다.</p>
<p>가끔 복잡한 해결책보다는 단순하고 명확한 설정이 더 나은 선택일 수 있다는 것도 좋은 교훈이었습니다.</p>
]]></content:encoded></item><item><title><![CDATA[토큰 갱신 로직, 어디에 둬야 할까?]]></title><description><![CDATA[시작하며: 편리함 뒤에 숨은 복잡성
모든 API 요청에 액세스 토큰을 자동으로 주입하고, 토큰이 만료되면 알아서 갱신 후 재요청까지 해주는 로직. 저 역시 이 기능을 구현하여 사용자 경험을 향상시키고자 했습니다.
하지만 이 편리함을 구현하는 과정은 간단하지 않았습니다. 특히 "이 토큰 갱신 코드를 어디에 위치시키고 어떻게 관리할 것인가?" 라는 근본적인 설계 문제와 마주했고, 이는 결국 "API 무한 재요청" 이라는 치명적인 버그로 이어졌습니다...]]></description><link>https://ted-projects.com/jwt-token-refresh</link><guid isPermaLink="true">https://ted-projects.com/jwt-token-refresh</guid><category><![CDATA[Retrospective]]></category><category><![CDATA[access-token]]></category><category><![CDATA[refresh-token]]></category><dc:creator><![CDATA[Ted Lee]]></dc:creator><pubDate>Tue, 22 Jul 2025 11:06:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1753174514486/f2d01f75-0589-4554-ac1d-efdb72dd0281.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-kirsi5zsnphtlzjrqba6io2ououmro2vqcdrkqtsl5ag7iio7j2aiouzteyeoeyessoq"><strong>시작하며: 편리함 뒤에 숨은 복잡성</strong></h2>
<p>모든 API 요청에 액세스 토큰을 자동으로 주입하고, 토큰이 만료되면 알아서 갱신 후 재요청까지 해주는 로직. 저 역시 이 기능을 구현하여 사용자 경험을 향상시키고자 했습니다.</p>
<p>하지만 이 편리함을 구현하는 과정은 간단하지 않았습니다. 특히 <strong>"이 토큰 갱신 코드를 어디에 위치시키고 어떻게 관리할 것인가?"</strong> 라는 근본적인 설계 문제와 마주했고, 이는 결국 <strong>"API 무한 재요청"</strong> 이라는 치명적인 버그로 이어졌습니다. 이 글은 그 문제를 해결하며 얻은 경험에 대한 기록입니다.</p>
<h2 id="heading-kirrrljsojzsnzgg67cc64uooidsnqzsgqzsmqnshlhqs7wg6rsa7ius7iks7j2yiou2houmrcoq"><strong>문제의 발단: 재사용성과 관심사의 분리</strong></h2>
<p>토큰 갱신 로직을 구현할 때, 가장 먼저 고민한 것은 "코드의 위치"였습니다.</p>
<ul>
<li><p><strong>선택지 1: 각 컴포넌트에서 개별 처리?</strong> API를 호출하는 모든 컴포넌트나 커스텀 훅에서 <code>try-catch</code>로 401 에러를 잡고, 직접 갱신 함수를 호출하는 방식입니다. 다만 수십, 수백 개의 API 호출 지점에서 코드가 중복되고, 로직 변경 시 모든 파일을 수정해야 하는 유지보수 지옥이 펼쳐질 가능성이 높아 보였습니다.</p>
</li>
<li><p><strong>선택지 2: API 헬퍼에서 중앙 처리?</strong> 모든 API 통신이 거쳐 가는 <code>api.helper.ts</code>에 로직을 집중시키는 것이 정답이라고 생각했습니다. <code>ky</code> 라이브러리의 <code>hooks</code>를 사용하면, 모든 요청과 응답을 한 곳에서 가로챌 수 있어 '관심사의 분리' 원칙에도 부합했습니다.</p>
</li>
</ul>
<p>저는 2번을 선택했고, <code>afterResponse</code> 훅을 이용해 401 에러 시 토큰을 갱신하고 원래 요청을 재시도하는 코드를 작성했습니다.</p>
<h2 id="heading-kirsuzjrqoxsoihsnbgg7iuk7iiyoidrrlttlzwg7j6s7jqu7lkt7j2yiounqyoq"><strong>치명적인 실수: 무한 재요청의 덫</strong></h2>
<p>중앙 처리 방식은 우아해 보였지만, 저는 한 가지 간과한 사실이 있었습니다. 바로 <strong>"토큰을 갱신하는 API 요청(</strong><code>authService.refresh()</code><strong>) 또한 내가 만든 중앙 처리 로직을 통과한다"</strong> 는 점이었습니다.</p>
<p>이로 인해 다음과 같은 무한 루프 시나리오가 발생했습니다.</p>
<ol>
<li><p><strong>Request A</strong>: 일반 API를 호출했으나, 액세스 토큰이 만료되어 <code>401 Unauthorized</code> 응답을 받습니다.</p>
</li>
<li><p><code>afterResponse</code> 훅 발동: 401 에러를 감지하고, 토큰을 갱신하기 위해 <code>authService.refresh()</code>를 호출합니다.</p>
</li>
<li><p><strong>Request B</strong>: <code>authService.refresh()</code>가 토큰 갱신 API(<code>PUT /api/auth</code>)를 호출합니다. <strong>하지만 사용자의 리프레시 토큰마저 만료된 상태였습니다.</strong></p>
</li>
<li><p><code>afterResponse</code> 훅 또 발동: <code>PUT /api/auth</code> 요청 또한 <code>401</code> 응답을 받습니다. 이 응답 역시 중앙 처리 로직에 의해 감지되고, <strong>또다시 토큰을 갱신하기 위해</strong> <code>authService.refresh()</code>를 호출합니다.</p>
</li>
<li><p><strong>무한 루프</strong>: 3번과 4번 과정이 무한히 반복되며, 브라우저의 네트워크 탭은 순식간에 수많은 실패 요청으로 가득 찼습니다.</p>
</li>
</ol>
<p>중앙 처리 로직이 자기 자신을 처리하려 들면서 생긴 문제였습니다. 이 문제를 해결할 '탈출구'가 필요했습니다.</p>
<h2 id="heading-kirtlbtqsrdsnzgg7iuk66ei66asoidrj4xrpr3soihsnbgg7ya17iugioyxhouekcoq"><strong>해결의 실마리: 독립적인 통신 채널</strong></h2>
<p>해결책은 의외로 간단했습니다. 토큰 갱신을 위한 API 요청은 <strong>"어떠한 훅도 거치지 않는 순수한 통신 채널"</strong> 로 보내야 한다는 것이었습니다.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// src/lib/api.helper.ts</span>

<span class="hljs-comment">// 1. 모든 요청을 가로채는 메인 API 인스턴스</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> api = ky.create({
  hooks: {
    <span class="hljs-comment">// 여기에 afterResponse 등 토큰 갱신 로직이 들어감</span>
  }
});

<span class="hljs-comment">// 2. 토큰 갱신만을 위한 '순수한' API 인스턴스</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> refreshApi = ky.create({
  <span class="hljs-comment">// 훅 없음</span>
});
</code></pre>
<p><code>api.helper.ts</code>에 훅이 없는 새로운 <code>ky</code> 인스턴스(<code>refreshApi</code>)를 만들었습니다. 그리고 <code>authService.refresh()</code> 함수는 이제 <code>api</code>가 아닌 <code>refreshApi</code>를 사용하도록 수정했습니다.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// src/services/auth.service.ts</span>

<span class="hljs-keyword">const</span> refresh = <span class="hljs-keyword">async</span> () =&gt; {
  <span class="hljs-comment">// refreshApi를 사용함으로써 무한 루프의 고리를 끊음</span>
  <span class="hljs-keyword">const</span> accessToken = <span class="hljs-keyword">await</span> refreshApi.put(...).text();
  <span class="hljs-keyword">return</span> accessToken;
};
</code></pre>
<p>이제 토큰 갱신 요청은 <code>afterResponse</code> 훅의 영향을 받지 않으므로, 갱신이 실패하면 그냥 에러를 반환하고 무한 루프 없이 깔끔하게 상황이 종료됩니다.</p>
<h2 id="heading-kirsnbtrsogg7zse66gc7kcd7yq466w8io2gte2vtcdslrvsnyag7kee7kecioq1ko2bicoq"><strong>이번 프로젝트를 통해 얻은 진짜 교훈</strong></h2>
<ul>
<li><p><strong>중앙화된 '마법'은 '탈출구'를 필요로 한다</strong>: 인터셉터나 훅처럼 보이지 않는 곳에서 동작하는 로직은 매우 편리하지만, 그 '마법'이 자기 자신에게도 적용될 때의 부작용을 반드시 고려해야 합니다. 모든 규칙에는 예외가 필요하듯, 중앙 처리 로직에는 그 로직을 우회할 수 있는 독립적인 통로를 마련해두는 설계가 중요합니다.</p>
</li>
<li><p><strong>문제의 원인은 아키텍처에 있다</strong>: "API 요청이 무한히 나간다"는 현상만 보면 당황하기 쉽습니다. 하지만 근본 원인은 코드 한 줄이 아니라, "모든 통신은 단일 인스턴스를 통한다"고 설정한 아키텍처의 허점에 있었습니다. 버그를 잡을 때는 현상 너머의 구조를 보는 시각이 필요하다는 것을 절실히 느꼈습니다.</p>
</li>
</ul>
<h2 id="heading-kirrp4jsuzjrqbaqkg"><strong>마치며</strong></h2>
<p>'토큰 자동 갱신'이라는 비교적 흔한 기능을 구현하면서, 저는 아키텍처 설계의 중요성과 부작용에 대해 경험하며 배울 수 있었습니다. 단순히 기능을 완성하는 것을 넘어, 발생할 수 있는 모든 엣지 케이스를 고려하고 시스템 전체의 안정성을 확보하는 것이 얼마나 중요한지 체감할 수 있었습니다.</p>
]]></content:encoded></item></channel></rss>