<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Mong dev blog</title>
    <link>https://mong-blog.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 13 Apr 2026 01:24:57 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>GicoMomg</managingEditor>
    <image>
      <title>Mong dev blog</title>
      <url>https://tistory1.daumcdn.net/tistory/4947324/attach/221b483200514c38aefa41e9f0c6d934</url>
      <link>https://mong-blog.tistory.com</link>
    </image>
    <item>
      <title>SVG 아이콘 시스템 설계: Runtime에서 Build Time으로 전환하기</title>
      <link>https://mong-blog.tistory.com/entry/SVG-%EC%95%84%EC%9D%B4%EC%BD%98-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84-Runtime%EC%97%90%EC%84%9C-Build-Time%EC%9C%BC%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</link>
      <description>&lt;h1&gt;0. 들어가며&lt;/h1&gt;
&lt;p&gt;처음에 아이콘 시스템을 설계할 때 “화면에 아이콘이 잘 나타나면 되지 않을까?”라고 생각할 수 있다.&lt;/p&gt;
&lt;p&gt;하지만 실제 서비스에서 아이콘은 생각보다 더 많은 요구사항을 만족해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;색상, 크기, 상태를 자유롭게 제어할 수 있어야 한다.&lt;/li&gt;
&lt;li&gt;다크 모드나 테마에 따라 스타일이 바꿀 수 있어야 한다.&lt;/li&gt;
&lt;li&gt;hover, active, disabled 상태를 반영할 수 있어야 한다.&lt;/li&gt;
&lt;li&gt;번들 크기는 최대한 작게 유지할 수 있어야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;문제는 이 요구사항들이 서로 잘 충돌한다는 점이다.&lt;/p&gt;
&lt;p&gt;스타일 제어를 잘 하려면 SVG를 코드처럼 다뤄야 하고, &lt;/p&gt;
&lt;p&gt;반대로 번들 크기를 줄이려면 정적인 리소스처럼 다루는 게 유리하다. &lt;/p&gt;
&lt;p&gt;즉, 아이콘 시스템을 설계할 때의 핵심 질문은 단순히 ‘아이콘을 어떻게 렌더링할 것인가?’가 아니다.&lt;/p&gt;
&lt;p&gt;실제로 이 질문에 더 가깝다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;스타일 제어를 유지하면서도, 번들 최적화를 어떻게 가져갈 것인가?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이번 시간에는 이 문제를 해결하기 위해 시도했던 두 가지 방식을 정리해보려고 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;첫 번째 시도&lt;/strong&gt;: 런타임에서 SVG 문자열을 DOM으로 변환해서 직접 제어하는 방식&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;두 번째 시도&lt;/strong&gt;: 빌드 타임에서 SVG를 코드로 변환하고, 정적 import로 사용하는 방식&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;1. 아이콘은 보통 어떻게 사용할까?&lt;/h1&gt;
&lt;p&gt;아이콘을 사용하는 방식은 크게 세 가지가 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;img /&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;background-image&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;SVG Component&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;각 방식은 장단점은 분명하다.&lt;/p&gt;
&lt;br/&gt;

&lt;h2&gt;1-1. 가장 단순한 방식: &lt;code&gt;&amp;lt;img /&amp;gt;&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;가장 직관적인 방식이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;img src=&amp;quot;/icons/home.svg&amp;quot;/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 방식은 HTML만으로 아이콘 출력이 가능하고 브라우저 캐싱도 활용할 수 있다.&lt;/p&gt;
&lt;p&gt;하지만 한계도 명확하다. SVG 내부의 &lt;code&gt;fill&lt;/code&gt;, &lt;code&gt;stroke&lt;/code&gt;를 직접 제어하기 어려워&lt;br&gt;상태에 따라 스타일을 바꾸기 어렵다. &lt;/p&gt;
&lt;p&gt;즉, 이 방식은 보여주기에는 좋지만 스타일을 제어하기 어려운 방식이다.&lt;/p&gt;
&lt;br/&gt;

&lt;h2&gt;1-2. CSS 기반 방식: &lt;code&gt;background-image&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;다음은 CSS에 아이콘을 넣는 방식이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.icon {
  background-image: url(&amp;quot;/icons/home.svg&amp;quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 방식은 레이아웃에 큰 영향을 주지 않고 아이콘을 배치할 수 있다.&lt;br&gt;특히 버튼이나 input 같은 UI에서 텍스트와 아이콘을 분리해 다루기 편하다.&lt;/p&gt;
&lt;p&gt;하지만 이 방식도 SVG 내부 노드를 직접 컨트롤할 수 없고, 세밀한 스타일링이 어렵다.&lt;/p&gt;
&lt;br/&gt;


&lt;h2&gt;1-3. SPA에서 자주 쓰는 방식: SVG 컴포넌트&lt;/h2&gt;
&lt;p&gt;상태와 스타일 제어가 중요한 순간, SVG를 컴포넌트처럼 다루게 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;Icon name=&amp;quot;home&amp;quot; size=&amp;quot;24&amp;quot; color=&amp;quot;red&amp;quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 방식은 색상, 크기, 상태를 컴포넌트 props로 제어할 수 있어 매우 유연하다.&lt;br&gt;다만 잘못 설계하면 아이콘 개수만큼 JavaScript 번들 부담이 커진다.&lt;/p&gt;
&lt;p&gt;예를 들어, 아이콘을 모두 컴포넌트로 분리하고 (&lt;code&gt;HomeIcon.vue&lt;/code&gt;, &lt;code&gt;UserIcon.jsx&lt;/code&gt; 등)&lt;br&gt;각 아이콘을 props 기반으로 제어하도록 설계하면, 사용성은 좋아진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;HomeIcon color=&amp;quot;red&amp;quot; /&amp;gt;
&amp;lt;UserIcon size=&amp;quot;24&amp;quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 이 구조는 “사용하기 쉬운 만큼, 그대로 번들에 포함되는 구조”다.&lt;/p&gt;
&lt;p&gt;즉,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;아이콘 개수 증가 → 컴포넌트 파일 증가&lt;/li&gt;
&lt;li&gt;컴포넌트 증가 → 번들 크기 증가&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;결국, 제어를 위해 구조를 확장할수록 번들 비용도 함께 증가하는 구조가 된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;그럼 아이콘을 어떻게 관리해야 번들 비용도 아끼면서 스타일 커스텀을 자유롭게 할 수 있을까?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;2. 첫 번째 시도: SVG를 문자열로 로드해 직접 제어하자&lt;/h1&gt;
&lt;p&gt;첫 번째 시도의 핵심은 단순하다. “&lt;strong&gt;SVG를 “이미지”가 아니라 “DOM”으로 다룬다!”&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;즉, SVG를 파일로 렌더링하는 것이 아니라&lt;/p&gt;
&lt;p&gt;런타임에서 DOM으로 변환하고, 내부 노드를 직접 제어하는 방식이다. (&lt;a href=&quot;https://github.com/KumJungMin/v-simple-svg-icon/tree/release/packages/icon&quot;&gt;원본 코드&lt;/a&gt;)&lt;/p&gt;
&lt;br/&gt;

&lt;h2&gt;2-1. 왜 이런 접근을 했을까?&lt;/h2&gt;
&lt;p&gt;SVG는 XML 기반 구조다.&lt;br&gt;즉, 문자열로 가져온 뒤 DOM으로 파싱하면 내부 노드에 직접 접근할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// SVG 내부 노드에 접근하는 코드 예시

const parser = new DOMParser()
const doc = parser.parseFromString(svgText,&amp;quot;image/svg+xml&amp;quot;)
const svgElement = doc.querySelector(&amp;quot;svg&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 변환하면 SVG는 더 이상 단순한 파일이 아니라, 내부 구조를 직접 조작할 수 있는 DOM 객체가 된다.&lt;br&gt;그래서 fill, stroke 제어, 특정 노드 선택, 상태 기반 스타일 변경과 같은 세밀한 제어를 할 수 있다.&lt;/p&gt;
&lt;br/&gt;

&lt;h2&gt;2-2. 런타임 처리 흐름 보기&lt;/h2&gt;
&lt;p&gt;외부에서 사용하는 API는 매우 단순하게 구성했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;Icon name=&amp;quot;home&amp;quot;/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 내부에서는 다음과 같은 과정이 일어난다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;60%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zu8dQ/dJMcac3CPv6/zdugXngf8KIpf9Fpa0Qzk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zu8dQ/dJMcac3CPv6/zdugXngf8KIpf9Fpa0Qzk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zu8dQ/dJMcac3CPv6/zdugXngf8KIpf9Fpa0Qzk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fzu8dQ%2FdJMcac3CPv6%2FzdugXngf8KIpf9Fpa0Qzk1%2Fimg.png&quot; width=&quot;60%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;동작&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;SVG Raw 로드&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;SVG를 문자열(raw)로 로드한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DOMParser 파싱&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;문자열을 실제 DOM으로 변환한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;노드 탐색 및 주입&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;[fill]&lt;/code&gt;, &lt;code&gt;[stroke]&lt;/code&gt; 노드를 탐색해 class를 주입한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스타일 삽입 &amp;amp; 렌더링&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;을 삽입해 스코프 스타일을 적용한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;즉, &lt;code&gt;&amp;lt;Icon /&amp;gt;&lt;/code&gt; 하나를 렌더링하기 위해&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SVG를 다시 파싱하고&lt;/li&gt;
&lt;li&gt;내부 구조를 탐색하고&lt;/li&gt;
&lt;li&gt;스타일을 주입하는 과정이 진행된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그리고 이 흐름은 크게 두 단계로 나뉜다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;스타일을 적용할 대상 노드를 찾는 단계&lt;/li&gt;
&lt;li&gt;해당 노드에 스타일을 실제로 적용하는 단계&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이제 이 두 과정을 각각 살펴보자.&lt;/p&gt;
&lt;br/&gt;

&lt;h2&gt;2-3. 스타일을 적용할 노드를 어떻게 찾을까?&lt;/h2&gt;
&lt;p&gt;스타일을 적용하려면, 먼저 대상이 되는 노드(&lt;code&gt;[fill]&lt;/code&gt;, &lt;code&gt;[stroke]&lt;/code&gt;)를 찾아야 한다.&lt;/p&gt;
&lt;p&gt;런타임에 파싱된 SVG DOM에서 해당 속성을 가진 노드를 탐색한다.&lt;br&gt;이 노드들은 실제 색상이 적용되는 지점이다.&lt;/p&gt;
&lt;p&gt;이후 &lt;code&gt;class&lt;/code&gt;를 주입해, 클래스만으로 스타일을 제어할 수 있는 구조를 만든다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const paths = svgElement.querySelectorAll(&amp;quot;[stroke], [fill]&amp;quot;)

for (constpathofpaths) {
  // ...속성 확인 로직...
  if (hasStroke) path.classList.add(&amp;quot;svg-stroke&amp;quot;)
  if (props.isActive) path.classList.add(&amp;quot;active&amp;quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;h2&gt;2-4. 스타일은 어떻게 적용했을까?&lt;/h2&gt;
&lt;p&gt;이제 &lt;code&gt;class&lt;/code&gt;로 제어 지점을 만들었으니, 실제 스타일을 적용해보자.&lt;/p&gt;
&lt;p&gt;이 단계에서는 SVG 내부에 &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; 태그를 삽입해, 해당 SVG에만 스타일이 적용되도록 스코프를 격리한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;70%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sZSg2/dJMcabDGluz/tOFerRQuhlT1Lll6m85eX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sZSg2/dJMcabDGluz/tOFerRQuhlT1Lll6m85eX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sZSg2/dJMcabDGluz/tOFerRQuhlT1Lll6m85eX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsZSg2%2FdJMcabDGluz%2FtOFerRQuhlT1Lll6m85eX0%2Fimg.png&quot; width=&quot;70%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;위 이미지는 SVG 내부에 &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;이 삽입된 모습이다.&lt;/p&gt;
&lt;p&gt;이 방식 덕분에 props 값에 따라 색상, 상태(active 등)를 유연하게 변경할 수 있다.&lt;/p&gt;
&lt;p&gt;하지만 이 구조에는 명확한 문제가 있다. 모든 과정이 런타임에 수행된다는 점이다!&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;3. 그런데 왜 이 방식이 최선이 아니었을까?&lt;/h1&gt;
&lt;p&gt;첫 번째 시도는 스타일 제어 측면에서는 이점이 있었다.&lt;br&gt;SVG 내부 노드를 직접 다룰 수 있었고, 상태에 따라 유연하게 스타일을 바꿀 수도 있었다.&lt;/p&gt;
&lt;p&gt;그런데 치명적인 문제가 하나 있었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이 방식은 구조적으로 트리쉐이킹이 불가능했다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이건 구현이 조금 부족한 수준의 문제가 아니다. 구조 자체가 번들러 친화적이지 않은 방식이었다.&lt;/p&gt;
&lt;br/&gt;

&lt;h2&gt;3-1. 문제의 본질: 아이콘 선택이 런타임에 결정된다&lt;/h2&gt;
&lt;p&gt;코드를 보면 아이콘의 이름을 문자열로 받아서 동적으로 로드한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const loader = resolveSvgLoader(name)
// name 변수는 런타임에 결정됨&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 핵심은 &lt;code&gt;name&lt;/code&gt; 값이 빌드 타임이 아니라 런타임에 정해진다는 점이다.&lt;/p&gt;
&lt;p&gt;번들러 입장에서는 어떤 아이콘이 실제로 쓰일지 알 수 없다. &lt;/p&gt;
&lt;p&gt;&lt;code&gt;&amp;quot;home&amp;quot;&lt;/code&gt;이 들어올지, 혹은 다른 어떤 값이 들어올지 빌드 시점에서는 판단할 수 없기 때문이다.&lt;/p&gt;
&lt;p&gt;그 결과 번들러는 모든 아이콘을 포함할 수 밖에 없다.&lt;/p&gt;
&lt;p&gt;즉, 사용한 아이콘이 하나뿐이어도 전체 아이콘 세트가 번들에 남게 된다.&lt;/p&gt;
&lt;br/&gt;

&lt;h2&gt;3-2. 왜 모든 아이콘이 번들에 포함될까?&lt;/h2&gt;
&lt;p&gt;트리쉐이킹은 &amp;#39;사용하지 않는 코드를 제거하는 최적화&amp;#39;다. 그런데 이 최적화가 동작하려면 전제가 하나 있다.&lt;/p&gt;
&lt;p&gt;바로 &lt;strong&gt;의존성이 빌드 타임에 정적으로 분석 가능&lt;/strong&gt;해야 한다.&lt;/p&gt;
&lt;p&gt;하지만 첫 번째 방식은 정반대였다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;참조 방식: 문자열(&lt;code&gt;name&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;선택 시점: 런타임&lt;/li&gt;
&lt;li&gt;번들러 분석: 불가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, 캐시를 추가하거나, 탐색 로직을 조금 더 빠르게 만든다고 해서 해결될 문제가 아니었다. &lt;/p&gt;
&lt;p&gt;문제는 SVG를 어떻게 파싱하느냐가 아니라, &lt;strong&gt;아이콘을 언제 결정하느냐&lt;/strong&gt;에 있었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;문제점&lt;/strong&gt;&lt;br/&gt;&lt;br&gt;첫 번째 시도는 의존성이 런타임에 결정된다.&lt;br&gt;그래서 번들러가 사용 여부를 정적으로 분석할 수 없다.&lt;br&gt;즉, 스타일 제어에는 강하지만 번들 최적화에는 구조적으로 불리하다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;4. 문제의 본질: Runtime이 아니라 Build Time에서 결정해야 한다&lt;/h1&gt;
&lt;p&gt;첫 번째 시도의 한계를 다시 요약하면 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;quot;home&amp;quot; → 런타임에 찾음 → 번들러는 모름&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그렇다면 해결 방향은 자연스럽게 정해진다.&lt;/p&gt;
&lt;p&gt;아이콘을 런타임에 찾지 말고, 빌드 타임에 미리 확정해야 한다&lt;/p&gt;
&lt;p&gt;즉, 문자열 기반 탐색 구조를 버리고, 정적으로 분석 가능한 구조로 바꿔야 한다.&lt;/p&gt;
&lt;br/&gt;

&lt;h2&gt;4-1. 핵심 구조 변경&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bughmt/dJMcaf0lVjr/zrAemKh6aHjFm1JFk73DyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bughmt/dJMcaf0lVjr/zrAemKh6aHjFm1JFk73DyK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bughmt/dJMcaf0lVjr/zrAemKh6aHjFm1JFk73DyK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbughmt%2FdJMcaf0lVjr%2FzrAemKh6aHjFm1JFk73DyK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;문자열 기반 참조를 버리고 아이콘을 모듈로 만들어, 정적 import 구조로 전환해야 한다.&lt;/p&gt;
&lt;p&gt;결국 이 변화는 단순히 “렌더링 구현 방식”을 바꾸는 문제가 아니었다. &lt;strong&gt;의존성을 언제 확정할 것인가&lt;/strong&gt;의 문제였다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;5. 두 번째 시도: SVG를 빌드 타임에 코드로 변환하자&lt;/h1&gt;
&lt;p&gt;이제 방향은 명확하다. SVG 파일 자체를 사용하지 않고, 빌드 스크립트를 통해 컴포넌트 코드로 변환해야 한다.&lt;/p&gt;
&lt;p&gt;즉, 아이콘을 런타임에 찾는 것이 아니라, 빌드 타임에 미리 생성해 두고 앱에서는 정적으로 가져다 쓰는 구조로 바꾸는 것이다. (&lt;a href=&quot;https://github.com/KumJungMin/v-simple-svg-icon/tree/release-v2/packages/icon&quot;&gt;원본 코드&lt;/a&gt;)&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;전체 흐름은 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;40%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHy1ZO/dJMcaare40K/UWmKfojRIJ5WKJkIuu7Im0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHy1ZO/dJMcaare40K/UWmKfojRIJ5WKJkIuu7Im0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHy1ZO/dJMcaare40K/UWmKfojRIJ5WKJkIuu7Im0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcHy1ZO%2FdJMcaare40K%2FUWmKfojRIJ5WKJkIuu7Im0%2Fimg.png&quot; width=&quot;40%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;각 단계는 다음과 같은 역할을 가진다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;SVG 파일 수집 (directory scan)&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;assets 디렉토리를 순회하며 SVG 파일을 수집한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AST 변환 (svg-parser)&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;SVG를 DOM이 아닌 분석 가능한 구조(AST)로 변환한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;메타데이터 생성&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;viewBox, path 등 렌더링에 필요한 정보를 추출한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;컴포넌트 생성&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;메타데이터를 기반으로 Vue/React 컴포넌트 코드를 생성한다 (샘플코드에서는 Vue만 생성)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export 등록 (&lt;code&gt;index.ts&lt;/code&gt;)&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;생성된 컴포넌트를 정적으로 import할 수 있도록 export에 등록한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;이 과정을 거치면 SVG가 빌드타임에 코드로 변환된다. 그리고 이 동작은 &lt;a href=&quot;https://github.com/KumJungMin/v-simple-svg-icon/blob/release-v2/packages/icon/scripts/generate-icons.ts&quot;&gt;&lt;code&gt;generate-icons.ts&lt;/code&gt;&lt;/a&gt;에서 시작한다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;6. generate-icons.ts 기준으로 전체 과정을 따라가보자&lt;/h1&gt;
&lt;p&gt;이제부터는 두 번째 시도의 구현 흐름을 &lt;code&gt;generate-icons.ts&lt;/code&gt;를 기준으로 차근차근 따라가보자.&lt;/p&gt;
&lt;h2&gt;6-1. 1단계: SVG 파일 목록 읽기&lt;/h2&gt;
&lt;p&gt;먼저 변환 대상이 되는 SVG 파일을 수집한다. &lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const svgFiles = fs.readdirSync(ICON_ASSET_PATH);&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;h2&gt;6-2. 2단계: 문자열을 AST로 변환하기&lt;/h2&gt;
&lt;p&gt;빌드 타임 환경은 Node.js다. 즉, 브라우저의 DOM이 없다. &lt;/p&gt;
&lt;p&gt;그래서 첫 번째 방식처럼 &lt;code&gt;DOMParser&lt;/code&gt;를 사용할 수 없다.&lt;/p&gt;
&lt;p&gt;이때 필요한 것이 &lt;code&gt;svg-parser&lt;/code&gt; 같은 도구다. SVG 문자열을 DOM이 아니라 AST로 변환한다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;DOMParser&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;AST (&lt;code&gt;svg-parser&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;실행 시점&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;런타임&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;빌드 타임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;환경&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;브라우저&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;Node.js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;결과&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;DOM 객체&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;순수 데이터 구조&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;즉, 두 번째 방식의 목표는 렌더링 가능한 DOM을 만드는 것이 아니라, &lt;strong&gt;코드 생성이 가능한 구조 데이터&lt;/strong&gt;를 만드는 것이다.&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;이 단계에서 실제로 수행하는 작업은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;/**
 * SVG 문자열을 AST로 변환한 뒤, 루트 &amp;lt;svg&amp;gt; 노드를 추출한다.
 */
export function extractSvgTree(raw: string): SvgAstNode | undefined {
  const parsed = parse(raw) as { children: SvgAstNode[] };

  return parsed.children.find((node) =&amp;gt; node.tagName === &amp;quot;svg&amp;quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 함수의 역할은 단순하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SVG 문자열을 AST로 변환하고&lt;/li&gt;
&lt;li&gt;그 중에서 &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt; 루트 노드만 추출한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이렇게 얻은 AST는 이후 단계에서&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;path 추출&lt;/li&gt;
&lt;li&gt;viewBox 분석&lt;/li&gt;
&lt;li&gt;메타데이터 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;과 같은 작업의 입력 데이터로 사용된다.&lt;/p&gt;
&lt;br/&gt;

&lt;h2&gt;6-3. 3단계: 구조를 평탄화하고 메타데이터를 만든다&lt;/h2&gt;
&lt;p&gt;SVG는 보통 중첩된 트리 구조를 가진다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; * svg
 *  ├─ g
 *  │   └─ path
 *  └─ path&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 구조를 그대로 렌더링 코드에 사용하면, 재귀 처리와 분기 로직이 복잡해진다.&lt;/p&gt;
&lt;p&gt;그래서 이 단계에서는 구조를 한 번 평탄화한다. 중첩 구조를 “렌더링 가능한 단순 리스트”로 변환한다.&lt;/p&gt;
&lt;p&gt;이 과정에서 만들어지는 결과가 바로 메타데이터다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export const HomeMeta = {
  viewBox: &amp;quot;0 0 24 24&amp;quot;,
  nodes: [
    { tag: &amp;quot;path&amp;quot;, attrs: { d: &amp;quot;...&amp;quot;, stroke: &amp;quot;#000&amp;quot; } }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 메타데이터는&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SVG 구조를 데이터로 표현한 결과이며&lt;/li&gt;
&lt;li&gt;이후 렌더링 단계에서 그대로 사용되는 입력값이다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, 이 시점부터 SVG는 “파일”이 아니라 &lt;strong&gt;렌더링 가능한 구조 데이터&lt;/strong&gt;가 된다.&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;이 구조를 만들기 위해 사용하는 핵심 함수는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export function flattenSvg(svg: SvgAstNode) {
  const nodes: Array&amp;lt;{ tag: string; attrs: SvgAttrs }&amp;gt; = [];
  const groups = new Set&amp;lt;string&amp;gt;();

  collectNodes(svg.children ?? [], nodes, groups);

  return {
    nodes,
    groups: [...groups],
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 함수의 역할은 명확하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;중첩된 SVG 트리를 순회하고&lt;/li&gt;
&lt;li&gt;렌더링에 필요한 노드만 추출한 뒤&lt;/li&gt;
&lt;li&gt;평탄한 구조(&lt;code&gt;nodes&lt;/code&gt;)로 변환한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;결과적으로, 이후 단계에서 복잡한 트리를 다루지 않고 단순한 리스트 기반으로 아이콘을 렌더링할 수 있다.&lt;/p&gt;
&lt;br/&gt;

&lt;h2&gt;6-4. 4단계: 아이콘 컴포넌트는 어떻게 생성될까?&lt;/h2&gt;
&lt;p&gt;앞에서 메타데이터를 만들었다면, 이제 이 데이터를 실제 SVG로 렌더링하는 컴포넌트를 만들어야 한다.&lt;/p&gt;
&lt;p&gt;이때 핵심이 되는 함수가 바로 &lt;code&gt;createIconComponent&lt;/code&gt;다.&lt;/p&gt;
&lt;p&gt;이 함수는 메타데이터를 기반으로 VNode를 생성하고, props에 따라 최종 SVG를 렌더링한다.&lt;/p&gt;
&lt;h3&gt;6-4-1. &lt;code&gt;createIconComponent&lt;/code&gt; 내부는 어떻게 동작할까?&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;createIconComponent&lt;/code&gt;의 핵심 로직만 가져와봤다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export function createIconComponent(meta: IconMeta) {
  return defineComponent({
    setup(props) {
      const renderNodes = createNodeRenderer(meta);
      const renderSvg = createSvgRenderer(meta);

      return () =&amp;gt; {
        const ctx = getRenderContext(props);
        const nodes = renderNodes(ctx);

        return renderSvg(props, nodes);
      };
    },
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;


&lt;p&gt;이 구조에서 중요한 점은 두 가지다. 첫째, SVG를 다시 파싱하지 않는다는 점이다.&lt;/p&gt;
&lt;p&gt;첫 번째 방식은 다음 흐름이었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SVG 문자열 → DOMParser → DOM → 조작&lt;/code&gt;&lt;/pre&gt;&lt;br/&gt;

&lt;p&gt;반면 두 번째 방식은 이미 빌드 타임에 만들어 둔 메타데이터를 바탕으로, 렌더링 시점에는 VNode만 생성한다.&lt;/p&gt;
&lt;p&gt;즉, 런타임 파싱 비용이 사라진다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;meta → VNode 생성 → 렌더링&lt;/code&gt;&lt;/pre&gt;&lt;br/&gt;


&lt;p&gt;둘째, 렌더링 방식이 명령형에서 선언형으로 바뀐다는 점이다.&lt;/p&gt;
&lt;p&gt;첫 번째 방식은 DOM을 직접 탐색하고 속성을 하나씩 변경했는데, 어떻게 바꿀지 코드로 일일이 명령했다.&lt;/p&gt;
&lt;p&gt;반면 두 번째 방식은 최종적으로 어떤 형태의 SVG가 되어야 하는지만 정의한다. &lt;/p&gt;
&lt;p&gt;즉, “이 상태라면 이런 결과가 나와야 한다”를 선언하면, 실제 DOM 업데이트는 프레임워크가 알아서 처리한다.&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;6-4-2. props는 어떻게 반영될까?&lt;/h3&gt;
&lt;p&gt;메타데이터에는 기본 SVG 속성이 들어 있다. &lt;/p&gt;
&lt;p&gt;하지만 실제 사용 시에는 &lt;code&gt;fill&lt;/code&gt;, &lt;code&gt;stroke&lt;/code&gt;, &lt;code&gt;size&lt;/code&gt; 같은 props로 값을 덮어써야 한다.&lt;/p&gt;
&lt;p&gt;이를 위해 attrs 레벨에서 merge를 수행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function resolveNodeAttrs(attrs,props) {
return {
    ...attrs,
    fill:props.fill??attrs.fill,
    stroke:props.stroke??attrs.stroke,
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조의 장점은 명확하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;원본 SVG 속성은 기본값으로 유지되고&lt;/li&gt;
&lt;li&gt;필요한 경우 props로 override할 수 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, 첫 번째 방식처럼 DOM을 파싱한 뒤 class를 주입하고 스타일을 덮어쓰는 것이 아니라, &lt;/p&gt;
&lt;p&gt;렌더링 시점에 최종 속성을 직접 결정하는 구조가 된다.&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;6-4-3. 스타일 적용 방식은 어떻게 달라졌을까?&lt;/h3&gt;
&lt;p&gt;첫 번째 방식에서는 &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; 태그를 SVG 내부에 주입하고, class 기반으로 스타일을 제어했다.&lt;/p&gt;
&lt;p&gt;반면 두 번째 방식에서는 렌더링 시점에 attrs로 직접 값을 넣는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;h(&amp;quot;path&amp;quot;, {
  d:&amp;quot;...&amp;quot;,
  fill:props.fill,
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;두 방식을 비교하면 다음과 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;스타일 적용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;첫 번째 방식&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;DOM 조작 + style 주입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;두 번째 방식&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;props 기반 attrs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;즉, 첫 번째 방식은 스타일을 나중에 덮는 구조였다면, 두 번째 방식은 렌더링 시점에 최종 스타일을 결정하는 구조다.&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;6-4-4. 성능 관점에서는 어떤 차이가 있을까?&lt;/h3&gt;
&lt;p&gt;이 구조 변화는 트리쉐이킹만의 문제가 아니다. 런타임 비용에도 차이를 만든다.&lt;/p&gt;
&lt;p&gt;첫 번째 방식에서는 매 렌더마다 다음 작업이 수행될 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;DOMParser 실행&lt;/li&gt;
&lt;li&gt;querySelector 탐색&lt;/li&gt;
&lt;li&gt;class 주입&lt;/li&gt;
&lt;li&gt;style 태그 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;반면 두 번째 방식은 이미 준비된 메타데이터를 바탕으로 VNode를 생성하고, 이후는 프레임워크의 Virtual DOM diff에 맡긴다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;6-5. 5단계: 아이콘 컴포넌트를 export한다&lt;/h2&gt;
&lt;p&gt;메타데이터를 기반으로 실제 Vue 컴포넌트 코드를 생성한 뒤, 각각을 독립된 파일로 저장한다.&lt;/p&gt;
&lt;p&gt;그리고 &lt;code&gt;index.ts&lt;/code&gt;에서 각각을 export한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export { HomeIcon } from &amp;quot;./HomeIcon&amp;quot;;
export { UserIcon } from &amp;quot;./UserIcon&amp;quot;;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 단계가 중요한데, 각 아이콘이 독립된 모듈이 되어야지만 &lt;/p&gt;
&lt;p&gt;앱에서 필요한 것만 정적으로 import할 수 있기 때문이다.&lt;/p&gt;
&lt;br/&gt;


&lt;h2&gt;6-6. 전체 흐름을 다시 정리해보자&lt;/h2&gt;
&lt;p&gt;지금까지의 빌드 타임 구조를 한 번에 정리하면 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SVG 파일
↓
문자열로 읽기
↓
AST 변환
↓
구조 정리 및 메타데이터 생성
↓
아이콘 컴포넌트 생성
↓
export 등록
↓
정적 import 사용&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이제 앱에서는 필요한 아이콘만 명시적으로 가져다 쓰게 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { HomeIcon } from&amp;quot;@v-simple/icon/common&amp;quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;즉, 아이콘은 더 이상 런타임에서 “찾는 대상”이 아니라, 빌드 타임에 생성된 “정적 모듈”이 된다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;7. 그래서 실제로 무엇이 달라졌을까?&lt;/h1&gt;
&lt;p&gt;이제 두 방식을 실제 결과 기준으로 비교해보자.&lt;/p&gt;
&lt;p&gt;아이콘 100개 중 실제로는 1개만 사용하는 극단적인 상황을 만들어보았다.&lt;/p&gt;
&lt;h3&gt;첫 번째 방식 (런타임)&lt;/h3&gt;
&lt;p&gt;사용한 아이콘이 하나뿐이어도, 전체 아이콘 세트가 번들에 포함된다. (193KB)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;1개만 사용해도 100개 전체가 포함됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vTB3h/dJMcacJne0v/yel3jhmvV9k3EI3SQKxr91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vTB3h/dJMcacJne0v/yel3jhmvV9k3EI3SQKxr91/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vTB3h/dJMcacJne0v/yel3jhmvV9k3EI3SQKxr91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvTB3h%2FdJMcacJne0v%2Fyel3jhmvV9k3EI3SQKxr91%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;두 번째 방식 (빌드 타임)&lt;/h3&gt;
&lt;p&gt;사용한 아이콘만 번들에 포함된다. (58KB)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;실제 사용한 1개만 포함됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qsyl8/dJMcaaLxx16/UWlfKP05kQ9RgpSj2iHxiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qsyl8/dJMcaaLxx16/UWlfKP05kQ9RgpSj2iHxiK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qsyl8/dJMcaaLxx16/UWlfKP05kQ9RgpSj2iHxiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fqsyl8%2FdJMcaaLxx16%2FUWlfKP05kQ9RgpSj2iHxiK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;차이&lt;/h3&gt;
&lt;p&gt;첫 번째 방식은 사용 여부와 관계없이 전체가 포함된다.&lt;br&gt;그래서 아이콘 수가 늘어날수록 불필요한 비용이 계속 누적된다.&lt;/p&gt;
&lt;p&gt;반면 두 번째 방식은 사용한 만큼만 포함된다.&lt;/p&gt;
&lt;p&gt;즉, 규모가 커질수록 두 방식의 차이는 더 크게 벌어진다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;8. 마치며&lt;/h1&gt;
&lt;p&gt;이번 글에서는 아이콘 시스템을 설계하며 시도한 두 가지 방식을 살펴보았다.&lt;/p&gt;
&lt;p&gt;첫 번째 방식은 아이콘을 문자열 name으로 찾고, 그 결정이 런타임에 이루어진다.&lt;br&gt;이 구조에서는 번들러가 어떤 아이콘이 사용되는지 알 수 없기 때문에, 모든 아이콘이 번들에 포함된다.&lt;/p&gt;
&lt;p&gt;반면 두 번째 방식은 SVG를 빌드 타임에 코드로 변환하고, import로 명시적으로 사용한다.&lt;br&gt;그 결과 번들러가 의존성을 추적할 수 있고, 실제 사용한 아이콘만 남길 수 있다.&lt;/p&gt;
&lt;p&gt;문제는 SVG가 아니라, 결정 시점이었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;런타임 결정 → 번들러 개입 불가
빌드 타임 결정 → 번들러 최적화 가능&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;아이콘은 화면에서 사소한 요소일 수 있다.&lt;br&gt;하지만 시스템이 커질수록 함께 증가하고,&lt;br&gt;결국 관리와 번들 사이즈 문제로 이어진다.&lt;/p&gt;
&lt;p&gt;결국 더 나은 시스템은, 언제 결정할 것인가를 고민하는 것에서 시작된다.&lt;/p&gt;</description>
      <category>개발 기술/개발 이야기</category>
      <category>JavaScript 번들 크기 줄이기</category>
      <category>svg 아이콘</category>
      <category>SVG 아이콘 최적화</category>
      <category>SVG 컴포넌트 방식</category>
      <category>빌드 타임 vs 런타임</category>
      <category>아이콘</category>
      <category>아이콘 시스템 설계</category>
      <category>웹 성능 최적화</category>
      <category>트리쉐이킹(Tree Shaking)</category>
      <category>프론트엔드 번들 최적화</category>
      <author>GicoMomg</author>
      <guid isPermaLink="true">https://mong-blog.tistory.com/243</guid>
      <comments>https://mong-blog.tistory.com/entry/SVG-%EC%95%84%EC%9D%B4%EC%BD%98-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84-Runtime%EC%97%90%EC%84%9C-Build-Time%EC%9C%BC%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0#entry243comment</comments>
      <pubDate>Tue, 31 Mar 2026 21:15:04 +0900</pubDate>
    </item>
    <item>
      <title>[Webpack] 웹팩 런타임의 정체 &amp;mdash; 브라우저에 모듈 시스템을 심는다고?</title>
      <link>https://mong-blog.tistory.com/entry/Webpack-%EC%9B%B9%ED%8C%A9-%EB%9F%B0%ED%83%80%EC%9E%84%EC%9D%98-%EC%A0%95%EC%B2%B4-%E2%80%94-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80%EC%97%90-%EB%AA%A8%EB%93%88-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9D%84-%EC%8B%AC%EB%8A%94%EB%8B%A4%EA%B3%A0</link>
      <description>&lt;h1&gt;0. 들어가며&amp;hellip;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://mong-blog.tistory.com/entry/Webpack-%EB%B2%88%EB%93%A4%EB%A7%81%EC%9D%98-%EC%9B%90%EB%A6%AC-%EC%88%98%EC%B2%9C-%EA%B0%9C%EC%9D%98-%EB%AA%A8%EB%93%88%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%AC%B6%EC%9D%84%EA%B9%8C&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;지난 시간&lt;/a&gt;에는 웹팩이 여러 파일을 어떻게 &lt;b&gt;청크(Chunk)&lt;/b&gt; 단위로 분리하는지 살펴봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;청크 분리는 &lt;b&gt;빌드 타임(Build Time)&lt;/b&gt; 에 일어나며, 크게 세 단계로 요약할 수 있다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. ModuleGraph 생성&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;import / require&lt;/code&gt; 관계를 분석하여 의존성 그래프를 만든다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. ChunkGraph 생성&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;어떤 모듈을 어떤 청크로 묶을지 결정한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. 최종 산출물 생성&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;설정(&lt;code&gt;splitChunks&lt;/code&gt;, &lt;code&gt;runtimeChunk&lt;/code&gt;)에 따라 여러 JS 파일이 생성된다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 이런 형태다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;dist/
 ├─ main.js
 ├─ runtime.js   (설정에 따라 분리)
 └─ dynamic.[hash].js&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지는 빌드 타임 이야기였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  그런데 여기서 의문 하나!&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나눠진 청크는 브라우저에서 어떻게 실행할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약, 단일 파일이라면 실행은 단순하다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;script src=&quot;main.js&quot;&amp;gt;&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저가 파일을 다운로드하고 실행하면 끝이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제 서비스는 그렇지 않다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 청크가 존재하고&lt;/li&gt;
&lt;li&gt;&lt;code&gt;import()&lt;/code&gt;가 있다.&lt;/li&gt;
&lt;li&gt;CommonJS와 ESM이 섞여 있고&lt;/li&gt;
&lt;li&gt;모듈 간 의존 관계가 복잡하게 얽혀 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 더 근본적인 문제가 하나 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 기본적으로 CommonJS 문법을 이해하지 못한다!&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;require(&quot;./math&quot;)
module.exports= {}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저가 이해하는 것은 ECMAScript 표준 문법(&lt;code&gt;import/export&lt;/code&gt;)과 Web API뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;require&lt;/code&gt;는 Node.js 런타임이 제공하는 함수이기에, 브라우저는 CommonJS 모듈 시스템을 알지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✏️ 그런데 왜 번들은 실행될까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아니 브라우저가 CommonJS 문법을 모르는데, 왜 에러 없이 실행되는 걸까?&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 단순하다. 웹팩이 모듈 시스템 자체를 번들 안에 구현했기 때문이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;번들은 단순히 파일을 합친 결과물이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 코드 + 모듈 실행기(Runtime Engine)이 결합된 하나의 실행 프로그램이다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 시간에는 웹팩이 삽입한 런타임 모듈 시스템이&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떻게 모듈을 등록하고&lt;/li&gt;
&lt;li&gt;어떻게 실행을 통제하며&lt;/li&gt;
&lt;li&gt;어떻게 동적 import까지 확장하는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단계적으로 알아보겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h1&gt;1. 웹팩 런타임은 무엇인가?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서 언급했듯이, 번들은 단순한 파일 묶음이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모듈 실행기를 포함한 하나의 실행 프로그램이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 이 &amp;lsquo;실행기&amp;rsquo;는 어떤 구조로 이루어져 있을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1) 웹팩 런타임의 핵심 구조&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) 구조부터 살펴보자&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹팩이 번들 안에 삽입하는 런타임의 핵심은 세 가지다.&lt;/p&gt;
&lt;pre class=&quot;arcade&quot;&gt;&lt;code&gt;var __webpack_modules__= { ... };
var __webpack_module_cache__= {};
function __webpack_require__(moduleId) { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 세 요소가 합쳐져 하나의 모듈 시스템을 구성한다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;요소&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;__webpack_modules__&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;아직 실행되지 않은 모듈 팩토리 저장소 &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;__webpack_module_cache__&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;실행 완료된 모듈 보관소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;__webpack_require__&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;실행 여부를 판단하고 흐름을 제어하는 엔진&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면, 번들에 포함된 각 소스 파일 모듈은 런타임에서 어떻게 저장될까?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹팩은 모듈을 &amp;lsquo;파일&amp;rsquo; 그대로 보관하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신, 각 파일을 실행 가능한 &lt;b&gt;팩토리 함수&lt;/b&gt;로 변환해 &lt;code&gt;__webpack_modules__&lt;/code&gt; 테이블에 등록한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 변환은 빌드 타임에 이루어지며, 모듈은 다음과 같은 함수 형태로 감싸진다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;function(module,exports,__webpack_require__) {
  // 원래 파일 코드
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;런타임에서는 모듈이 필요해질 때마다 &lt;code&gt;__webpack_require__(moduleId)&lt;/code&gt;가 호출되고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수가 해당 모듈의 팩토리 함수를 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 번들 내부에는 브라우저에서 동작하는 CommonJS 실행 환경이 구현되어 있는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) 왜 이런 구조가 필요한가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 이 구조가 없으면 브라우저는 다음을 보장할 수 없다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;모듈은 최초 한 번만 실행된다.&lt;/li&gt;
&lt;li&gt;실행 결과(&lt;code&gt;exports&lt;/code&gt;)는 공유된다.&lt;/li&gt;
&lt;li&gt;모듈 간 의존성은 런타임에서 동적으로 해석된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 첫 번째, 두 번째 규칙은 CommonJS 모듈 시스템의 중요한 특징이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;한 번 실행된 모듈은 다시 실행되지 않고,&lt;/li&gt;
&lt;li&gt;그 결과는 동일한 인스턴스로 공유된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹팩 런타임은 이 동작을 브라우저 환경에서도 유지하기 위해 이 구조를 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 이 중에서&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시를 확인하고&lt;/li&gt;
&lt;li&gt;팩토리를 호출하고&lt;/li&gt;
&lt;li&gt;실행 흐름을 통제하는 역할은 누가 할까?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 &lt;code&gt;__webpack_require__&lt;/code&gt;다. 이제 이 함수가 내부에서 어떤 과정을 거쳐 모듈을 실행하는지 살펴보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) &lt;b&gt;webpack_require&lt;/b&gt;의 내부 동작&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) 먼저 코드부터 살펴보자&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹팩 번들 내부의 대략적인 구조는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;function __webpack_require__(moduleId) {

  // 1. 캐시 확인
  var cachedModule = __webpack_module_cache__[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }

  // 2. 새 module 객체 생성
  var module = { exports: {} };

  // 3. 캐시에 먼저 등록 (중요)
  __webpack_module_cache__[moduleId] = module;

  // 4. 모듈 팩토리 실행
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__);

  // 5. exports 반환
  return module.exports;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) 실행 흐름을 단계적으로 살펴보자&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 entry 모듈(&lt;code&gt;index.js&lt;/code&gt;)이 &lt;code&gt;math.js&lt;/code&gt;를 불러온다고 가정해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드 타임에 &lt;code&gt;math.js&lt;/code&gt;가 moduleId = 1로 매핑되었다면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;런타임에서는 파일 경로 대신 이 moduleId(1)를 기준으로 모듈을 식별한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 브라우저에서 실행되는 코드는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const add = __webpack_require__(1); // math.js의 moduleId가 1이라고 가정&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;webpack_require_(1)&lt;/code&gt;이 호출되면, 이 함수 내부에서 여러 단계를 진행하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1️⃣ 캐시 확인 단계 &amp;mdash; 이미 실행된 적이 있는가?&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;var cachedModule = __webpack_module_cache__[moduleId];&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;__webpack_require__&lt;/code&gt;이 호출되면 가장 먼저 이 모듈이 이미 실행된 적이 있는지 확인한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 가장 먼저 캐시를 확인할까?&lt;br /&gt;그 이유는 CommonJS에서는 모듈이 한 번만 실행되고,&lt;br /&gt;이후에는 그 결과가 재사용되어야 하기 때문이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 두 번째 &lt;code&gt;require&lt;/code&gt; 호출시 팩토리를 재실행하면 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 캐시에 존재한다면, 저장된 &lt;code&gt;exports&lt;/code&gt;를 그대로 반환하고 팩토리는 호출하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2️⃣ 새 module 객체 생성 단계 &amp;mdash; 실행 컨테이너 준비&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;var module = { exports: {} };&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 모듈의 캐시가 없다면, 웹팩은 빈 module 객체를 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 객체는 실행 결과를 담는 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3️⃣ 캐시에 먼저 등록하는 단계 &amp;mdash; 순환 참조를 막는 장치&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;__webpack_module_cache__[moduleId] = module;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모듈을 실행하기 전에, 생성한 &lt;code&gt;module&lt;/code&gt; 객체를 먼저 캐시에 등록한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 &lt;b&gt;순환 참조(Circular Dependency)&lt;/b&gt; 처리를 위해 필요하다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 실행 전에 모듈 객체를 캐시에 등록해야 할까?&lt;br /&gt;이유는 모듈은 실행 중에도 다시 &lt;code&gt;require&lt;/code&gt;될 수 있기 때문이다.&lt;br /&gt;만약 캐시에 등록되어 있지 않으면 동일 모듈이 새로 생성되어 순환 참조 문제가 발생한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, A가 B를 호출하고, B가 A를 호출하는 구조가 있다고 가정하자.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;A &amp;rarr; B &amp;rarr; A&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 A 실행이 완료된 후에 캐시에 등록된다면,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;두 번째 A &lt;code&gt;require&lt;/code&gt;는 &amp;ldquo;아직 캐시에 없다&amp;rdquo;고 판단하고 새 모듈 객체를 다시 생성한다.&lt;/li&gt;
&lt;li&gt;그 결과, 무한 재귀(A &amp;rarr; B &amp;rarr; A &amp;rarr; B &amp;rarr; A &amp;hellip;)가 발생한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실행 전에 캐시에 등록해두면,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;두 번째 &lt;code&gt;require&lt;/code&gt;는 이미 존재하는 &lt;code&gt;module&lt;/code&gt; 객체를 반환하고&lt;/li&gt;
&lt;li&gt;아직 완성되지 않은 &lt;code&gt;exports&lt;/code&gt;라도 동일 인스턴스를 공유한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 CommonJS의 표준 동작 방식이며, 웹팩 런타임은 이를 그대로 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4️⃣ 모듈 팩토리가 실행되는 단계&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시에 해당 모듈이 없다면, &lt;code&gt;__webpack_modules__&lt;/code&gt; 테이블에 등록된 팩토리 함수를 호출한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;__webpack_modules__[moduleId](module, module.exports, __webpack_require__);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팩토리 함수가 호출되면,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;module&lt;/code&gt;, &lt;code&gt;exports&lt;/code&gt;, &lt;code&gt;__webpack_require__&lt;/code&gt;가 인자로 전달되고&lt;/li&gt;
&lt;li&gt;함수 내부의 원본 코드가 실행되며&lt;/li&gt;
&lt;li&gt;실행 결과가 &lt;code&gt;module.exports&lt;/code&gt;에 기록된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5️⃣ exports를 반환하는 단계&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팩토리 함수 실행이 끝나면,&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;return module.exports;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 통해 &lt;code&gt;module.exports&lt;/code&gt;가 그대로 반환된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;__webpack_require__&lt;/code&gt;의 반환값은 곧 해당 모듈의 &lt;code&gt;module.exports&lt;/code&gt;다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;팩토리 함수 호출

&amp;rarr; module.exports에 값 기록
&amp;rarr; module.exports 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(3) 한눈에 보는 실행 흐름&lt;/h3&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;__webpack_require__(1)
    &amp;darr;
[1] 캐시 확인
    &amp;darr;
[2] module 객체 생성
    &amp;darr;
[3] 캐시 선등록
    &amp;darr;
[4] 팩토리 실행
    &amp;darr;
[5] exports 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름은 CommonJS 모듈 시스템의 실행 규칙을 그대로 따른다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;캐시 확인&lt;/b&gt; &amp;rarr; 모듈의 단일 실행 보장&lt;/li&gt;
&lt;li&gt;&lt;b&gt;객체 생성&lt;/b&gt; &amp;rarr; 실행 결과를 저장할 컨테이너 준비&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 선등록&lt;/b&gt; &amp;rarr; 순환 참조 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;팩토리 실행&lt;/b&gt; &amp;rarr; 실제 코드 평가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;exports 반환&lt;/b&gt; &amp;rarr; 실행 결과 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3) 번들은 실제로 어떻게 실행되는가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 우리는 &lt;code&gt;__webpack_require__&lt;/code&gt;의 내부 동작을 살펴봤다.&lt;br /&gt;그렇다면 이 알고리즘은 언제부터 동작하기 시작할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저가 번들을 로드하는 시점부터 ~ 모듈 실행이 시작되기까지의 흐름을 따라가보자.&lt;br /&gt;그 출발점은 브라우저가 번들을 실행하는 순간이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) 브라우저는 &amp;lsquo;웹팩&amp;rsquo;을 모른다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;code&gt;main.js&lt;/code&gt;라는 번들 파일이 있다고 가정해보자.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;script src=&quot;main.js&quot;&amp;gt;&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 웹팩이라는 개념을 알지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단지 하나의 자바스크립트 파일을 실행할 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 파일이 로드되면 자바스크립트 엔진은 코드 상단부터 순차적으로 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 번들 파일의 가장 위에는 무엇이 있을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) 런타임 구조가 먼저 정의된다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;번들 상단에는 웹팩이 삽입한 런타임 코드가 위치한다.&lt;/p&gt;
&lt;pre class=&quot;arcade&quot;&gt;&lt;code&gt;var __webpack_modules__ = { ... };
var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 이 코드를 일반 자바스크립트처럼 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 시점에서 아직 어떤 모듈도 실행되지 않았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모듈은 함수 형태로 &lt;code&gt;__webpack_modules__&lt;/code&gt;에 등록되어 있고&lt;/li&gt;
&lt;li&gt;캐시는 비어 있으며&lt;/li&gt;
&lt;li&gt;&lt;code&gt;__webpack_require__&lt;/code&gt;는 단지 정의만 되어 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 실제 애플리케이션 로직이 시작되기 전에 모듈 실행 환경이 초기화된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(3) 번들 마지막에서 entry 모듈이 호출된다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;번들 하단에는 entry 모듈을 실행하는 코드가 위치한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// Webpack 4
__webpack_require__(entryModuleId);

// Webpack 5
// __webpack_exec__를 쓰기도 함.
// 단, __webpack_exec__은 내부적으로 __webpack_require__를 사용하므로 동작은 유사함
__webpack_exec__(&quot;./src/index.js&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 애플리케이션 로직은 entry 모듈이 호출되는 순간부터 시작된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 호출이 이루어지면 다음과 같은 흐름이 전개된다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;entry 모듈 실행

&amp;rarr; 내부에서 __webpack_require__ 호출
&amp;rarr; 의존 모듈 require
&amp;rarr; 의존성 트리 재귀 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점부터 &lt;code&gt;__webpack_require__&lt;/code&gt;의 5단계 알고리즘이&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;entry 모듈에서 시작해, 의존성 그래프를 따라 재귀적으로 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(4) 정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;번들의 실행 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;브라우저가 main.js 실행
    &amp;darr;
런타임 구조 정의
    &amp;darr;
entry 모듈 호출
    &amp;darr;
__webpack_require__ 알고리즘 시작
    &amp;darr;
의존성 트리 재귀 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 번들 파일을 로드하는 즉시 상단 코드부터 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 실제 애플리케이션 로직은 entry 모듈이 호출되는 시점부터 시작된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 실행의 출발점은 파일 로드가 아니라 &lt;b&gt;entry 호출&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4) 동적 import는 어디에 연결되는가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서 살펴봤듯이,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 모듈 실행은 &lt;code&gt;__webpack_require__&lt;/code&gt;를 통해 이루어지고&lt;/li&gt;
&lt;li&gt;entry 호출이 실행의 출발점이 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 &lt;code&gt;import()&lt;/code&gt;는 이 구조에서 어떤 위치를 차지할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같은 코드가 있다고 가정해보자.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;import(&quot;./dynamic&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;겉보기에는 비동기 &lt;code&gt;require&lt;/code&gt;처럼 보이지만, 빌드 결과는 다음과 같이 변환된다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;__webpack_require__.e(&quot;dynamic&quot;)
.then(__webpack_require__.bind(__webpack_require__,&quot;./src/dynamic.js&quot;));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 import의 실행은 두 단계로 이루어진다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1단계: 청크 로딩 단계
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;__webpack_require__.e(&quot;dynamic&quot;)&lt;/code&gt;가 호출되어&lt;/li&gt;
&lt;li&gt;해당 청크 파일을 네트워크로 로드한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;2단계: 모듈 실행 단계
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;청크 로딩이 완료되면 &lt;code&gt;__webpack_require__&lt;/code&gt;가 호출되어 해당 모듈을 실행한다.&lt;/li&gt;
&lt;li&gt;즉, 동적 import 역시 &lt;code&gt;__webpack_require__&lt;/code&gt;로 연결된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) 첫 번째 단계 &amp;mdash; 청크 로딩&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;__webpack_require__.e(&quot;dynamic&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;.e&lt;/code&gt;는 지정된 청크를 로드하기 위한 비동기 요청을 시작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 아직 로드되지 않은 청크 파일에 대해 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 태그를 생성하고, 브라우저가 해당 파일을 다운로드하도록 한다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;script src=&quot;dynamic.bundle.js&quot;&amp;gt;&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에서는 모듈 코드가 실행되지 않는다. 단지 청크 파일을 로드하는 단계다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) 두 번째 단계 &amp;mdash; 모듈 실행&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;청크 로딩이 완료되면 Promise가 resolve되고, 다음 코드가 실행된다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;.then(__webpack_require__.bind(__webpack_require__, &quot;./src/dynamic.js&quot;));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 실제로 호출되는 것은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;__webpack_require__(&quot;./src/dynamic.js&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 동적 import도 최종적으로는 &lt;code&gt;__webpack_require__&lt;/code&gt;으로 모듈을 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 로드된 청크는 어디에 등록될까?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;청크 파일 내부에는 다음과 같은 코드가 포함되어 있다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;(self[&quot;webpackChunkapp&quot;] = self[&quot;webpackChunkapp&quot;] || []).push([
  [&quot;dynamic&quot;],
  {
    &quot;./src/dynamic.js&quot;: function(module, exports, __webpack_require__) {
      ...
    }
  }
]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;겉보기에는 단순한 배열 &lt;code&gt;push&lt;/code&gt;처럼 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제로는 다음과 같은 일이 일어난다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;1. push 호출
2. 청크 안에 들어 있던 모듈 정보 전달
3. 해당 모듈이 __webpack_modules__ 객체에 추가됨&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 새로 로드된 모듈의 팩토리 함수가 &lt;code&gt;__webpack_modules__&lt;/code&gt; 객체에 그대로 추가된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이후의 과정은 정적 import와 동일하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(3) 전체 실행 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 import의 실행 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;import()
    &amp;darr;
__webpack_require__.e()  // 청크 로딩
    &amp;darr;
청크 파일 실행 (webpackChunk.push)
    &amp;darr;
__webpack_modules__에 모듈 추가
    &amp;darr;
__webpack_require__(moduleId)
    &amp;darr;
기존 require 알고리즘 수행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 주목할 점은 실행 구조 자체는 변하지 않는다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 import도 최종적으로는 &lt;code&gt;__webpack_require__&lt;/code&gt;를 통해 모듈을 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차이가 있다면, 모듈이 등록되는 시점이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정적 import &amp;rarr; 빌드 시점에 모듈이 &lt;code&gt;__webpack_modules__&lt;/code&gt;에 포함됨&lt;/li&gt;
&lt;li&gt;동적 import &amp;rarr; 런타임에 청크가 로드되면서 모듈이 추가됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 동적 import는 모듈을 &lt;b&gt;나중에 등록&lt;/b&gt;할 뿐, 실행 방식은 기존 require 알고리즘과 동일하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지의 내용을 한 장으로 정리하면 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h1&gt;2. 지금까지의 내용 정리&lt;/h1&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) 빌드 타임 &amp;mdash; 모듈을 함수로 변환하는 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹팩은 빌드 과정에서 다음 작업을 수행한다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;소스 코드 분석
    &amp;darr;
ModuleGraph 생성 (의존성 분석)
    &amp;darr;
ChunkGraph 생성 (출력 전략 결정)
    &amp;darr;
모듈을 팩토리 함수로 변환
    &amp;darr;
__webpack_modules__ 객체 구성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에서 각 파일은 더 이상 &amp;lsquo;파일&amp;rsquo;이 아니다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;function(module,exports,__webpack_require__) {
// 원래 코드
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;형태의 팩토리 함수로 변환되어 &lt;code&gt;__webpack_modules__&lt;/code&gt;에 등록된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) 런타임 초기화 &amp;mdash; 실행 환경 준비&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저가 &lt;code&gt;main.js&lt;/code&gt;를 실행하면 먼저 런타임 구조가 정의된다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;__webpack_modules__ 생성
__webpack_module_cache__ 생성
__webpack_require__ 정의
    &amp;darr;
entry 모듈 호출&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때부터 모듈 실행이 시작된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(3) require 알고리즘 &amp;mdash; 모듈 실행 규칙&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;__webpack_require__(moduleId)&lt;/code&gt;가 호출되면 다음 순서가 적용된다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;[1] 캐시 확인
    &amp;darr;
[2] module 객체 생성
    &amp;darr;
[3] 캐시 선등록
    &amp;darr;
[4] 팩토리 함수 실행
    &amp;darr;
[5] module.exports 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 통해:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모듈은 한 번만 실행되고&lt;/li&gt;
&lt;li&gt;실행 결과는 공유되며&lt;/li&gt;
&lt;li&gt;순환 참조가 안전하게 처리된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(4) 동적 import &amp;mdash; 모듈을 나중에 등록하는 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 import는 실행 구조를 바꾸지 않는다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;import()
    &amp;darr;
__webpack_require__.e()   // 청크 로딩
    &amp;darr;
webpackChunk.push()      // 모듈 추가
    &amp;darr;
__webpack_require__()     // 기존 알고리즘 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차이는 단 하나다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정적 import는 빌드 시점에 모듈이 등록되고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 import는 런타임에 청크가 로드되면서 모듈이 추가된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(5) 전체 구조 한눈에 보기&lt;/h3&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;[빌드 타임]
파일 &amp;rarr; 팩토리 함수 &amp;rarr; __webpack_modules__ 구성

[런타임]
entry 호출 &amp;rarr; __webpack_require__ &amp;rarr; 의존성 트리 재귀 실행

[동적 import]
청크 로딩 &amp;rarr; 모듈 추가 &amp;rarr; 동일 require 알고리즘 적용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h1&gt;3. 마치며&amp;hellip;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 시간에는 웹팩 번들이 런타임에서 어떻게 실행되는지 알아보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹팩은 모듈을 파일 그대로 두지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드 타임에 각 모듈을 &lt;b&gt;팩토리 함수&lt;/b&gt;로 변환하고, 런타임에서는 이를 &lt;code&gt;__webpack_require__&lt;/code&gt;를 통해 실행한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;__webpack_require__(moduleId)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수는&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모듈을 한 번만 실행하고&lt;/li&gt;
&lt;li&gt;실행 결과를 캐시하며&lt;/li&gt;
&lt;li&gt;의존성을 재귀적으로 해결한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 import도 예외는 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;청크를 나중에 로드할 뿐, 실행 구조는 동일하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 번들의 실행 흐름은 하나로 정리된다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;entry 호출
&amp;rarr; require 실행
&amp;rarr; 팩토리 함수 실행
&amp;rarr; module.exports 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹팩 런타임은 이 단순한 구조 위에서 동작하는 걸 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 시간에는 Webpack의 또 다른 핵심 기능인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HMR(Hot Module Replacement)이 어떻게 런타임과 모듈 캐시 위에서 동작하는지 살펴보겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>라이브러리 파헤치기</category>
      <category>esmodule</category>
      <category>moduleBundler</category>
      <category>webpack</category>
      <category>WebpackChunk</category>
      <category>WebpackRuntime</category>
      <category>__webpack_require__</category>
      <category>모듈시스템</category>
      <category>웹팩</category>
      <category>자바스크립트모듈</category>
      <author>GicoMomg</author>
      <guid isPermaLink="true">https://mong-blog.tistory.com/242</guid>
      <comments>https://mong-blog.tistory.com/entry/Webpack-%EC%9B%B9%ED%8C%A9-%EB%9F%B0%ED%83%80%EC%9E%84%EC%9D%98-%EC%A0%95%EC%B2%B4-%E2%80%94-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80%EC%97%90-%EB%AA%A8%EB%93%88-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9D%84-%EC%8B%AC%EB%8A%94%EB%8B%A4%EA%B3%A0#entry242comment</comments>
      <pubDate>Fri, 27 Feb 2026 23:39:01 +0900</pubDate>
    </item>
    <item>
      <title>[Webpack] 번들링의 원리: 수천 개의 모듈은 어떻게 묶을까?</title>
      <link>https://mong-blog.tistory.com/entry/Webpack-%EB%B2%88%EB%93%A4%EB%A7%81%EC%9D%98-%EC%9B%90%EB%A6%AC-%EC%88%98%EC%B2%9C-%EA%B0%9C%EC%9D%98-%EB%AA%A8%EB%93%88%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%AC%B6%EC%9D%84%EA%B9%8C</link>
      <description>&lt;h1&gt;0. 들어가며: 왜 파일을 그냥 올리면 안 될까?&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;60%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dWbUcp/dJMcagj3ugq/mD9zvVMikksVBOlVb7ydck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dWbUcp/dJMcagj3ugq/mD9zvVMikksVBOlVb7ydck/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dWbUcp/dJMcagj3ugq/mD9zvVMikksVBOlVb7ydck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdWbUcp%2FdJMcagj3ugq%2FmD9zvVMikksVBOlVb7ydck%2Fimg.png&quot; width=&quot;60%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;프로젝트를 시작하면 우리는 자연스럽게 webpack이나 vite 같은 빌드 도구를 설정한다.&lt;/p&gt;
&lt;p&gt;너무 당연하게 쓰다 보니, 막상 누군가 근본적인 질문을 던지면 대답이 애매해질 때가 있다.&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;&lt;em&gt;“왜 파일을 그냥 올리면 안 되고, webpack을 써서 ‘번들링’을 거쳐야 하나요?”&lt;/em&gt;&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;이번 시간에는 webpack의 수 많은 기능 중에서도 ‘번들링(Bundling)’을 코드와 함께 살펴보겠다.&lt;/p&gt;
&lt;p&gt;이 글을 읽고나서 webpack의 번들링이 “왜 필요하고” → “내부에서 어떻게 작동하며” → “번들링 결과물의 형태를 이해”할 수 있을 것이다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  핵심 요약&lt;br/&gt;1. webpack은 브라우저가 모르는 모듈 시스템(&lt;code&gt;import&lt;/code&gt;/&lt;code&gt;require&lt;/code&gt;)을 이해시켜준다.&lt;br/&gt;2. webpack은 의존성 그래프를 기반으로 안 쓰는 코드는 버리고 중복은 합친다.&lt;br/&gt;3. webpack은 수천 개의 파일을 보내는 대신 적당한 덩어리로 묶어 네트워크 오버헤드를 줄인다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;1. 왜 번들링이 필요할까?&lt;/h1&gt;
&lt;p&gt;우리는 JS 파일을 역할에 따라 여러 개로 분리하고, 각 파일을 &lt;code&gt;import&lt;/code&gt;/&lt;code&gt;require()&lt;/code&gt;를 사용해 호출한다.&lt;/p&gt;
&lt;p&gt;하지만 이 문법은 브라우저가 “태생부터” 이해하던 문법이 아니다.&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;그래서 이 문법을 이해하기 위해 개발자와 브라우저 사이에 통역 역할이 필요했고, 그 역할을 번들러가 맡았다.&lt;/p&gt;
&lt;p&gt;물론 요즘 브라우저는 &lt;code&gt;import/export&lt;/code&gt;를 네이티브로 지원하고, &lt;code&gt;&amp;lt;script type=&amp;quot;module&amp;quot;&amp;gt;&lt;/code&gt;만 써도 ESM을 그대로 실행할 수 있다.  &lt;/p&gt;
&lt;p&gt;그럼 이렇게 생각할 수 있을 것이다. &lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;❓ 번들러가 import/require 문법을 해석해주는 역할이라면, 요즘에는 필요없지 않나?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;그럼에도 번들러가 “여전히” 필요한 이유는, 번들러의 역할이 문법 해석에 그치지 않기 때문이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;번들러는 구형 브라우저/특수 런타임 호환을 지원한다.&lt;/li&gt;
&lt;li&gt;브라우저가 기본으로 해주지 않는 node_modules 해석(bare specifier), 경로 별칭, 조건부 exports를 처리한다.&lt;/li&gt;
&lt;li&gt;TS/JSX 변환, 폴리필 전략, 그리고 여러 최적화(트리셰이킹/중복 제거/청크/캐시)를 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h2&gt;1) 번들링이 필요한 3가지 이유&lt;/h2&gt;
&lt;h3&gt;(1) 모듈 통역: 브라우저가 못 알아듣는 문법을 ‘실행 가능한 코드’로 바꾼다&lt;/h3&gt;
&lt;p&gt;과거 브라우저의 실행 모델은 단순했다.&lt;/p&gt;
&lt;p&gt;HTML에 적힌 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 태그를 위에서 아래로 실행할 뿐, 파일 내부에서 다른 파일을 가져오는 능력은 없었다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;개발자는 “이 파일은 저 파일이 필요해”라며 &lt;code&gt;import&lt;/code&gt;를 작성한다.&lt;/li&gt;
&lt;li&gt;하지만 (특히 구형 환경에서는) 브라우저가 이 관계를 이해하지 못한다.&lt;/li&gt;
&lt;li&gt;번들러는 모듈 간 연결 관계를 읽고, 브라우저가 실행 가능한 형태로 변환/결합해 결과물을 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, 번들러는 개발자가 쓰는 “모듈 언어”를 브라우저의 “실행 언어”로 바꿔주는 통역기다.&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 스코프 보호: 전역 오염을 막고 각 파일의 공간을 만든다&lt;/h3&gt;
&lt;p&gt;번들링 없이 파일 100개를 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;로 연결하면, 변수들은 어떻게 될까?&lt;/p&gt;
&lt;p&gt;대부분 &lt;code&gt;window&lt;/code&gt;라는 전역 공간에 모이면서 충돌한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A 파일의 &lt;code&gt;const name&lt;/code&gt;과 B 파일의 &lt;code&gt;const name&lt;/code&gt;이 같은 전역에서 만나면&lt;/li&gt;
&lt;li&gt;예상치 못한 덮어쓰기/충돌이 발생하며 전역 오염(Global pollution)이 발생한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;번들러는 이 문제를 구조적으로 막는다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;모듈을 함수 스코프(Function Scope)로 감싸,&lt;/li&gt;
&lt;li&gt;파일별로 독립된 실행 컨텍스트(각자의 공간)를 만들어준다.&lt;/li&gt;
&lt;li&gt;그래서 변수명이 겹쳐도 안전하고, 코드 분리/재사용이 가능해진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(3) 전달 최적화: ‘전체를 보고’ 가볍고 빠르게 보낸다&lt;/h3&gt;
&lt;p&gt;번들러는 엔트리부터 시작해 프로젝트 전체를 순회하며 모듈 간 의존 관계를 의존성 그래프(Dependency Graph) 형태로 구성한다.&lt;/p&gt;
&lt;p&gt;이 그래프는 하나의 빌드 실행 단위인 &lt;code&gt;Compilation&lt;/code&gt;마다 생성된다.&lt;/p&gt;
&lt;p&gt;번들러가 배포 단계에서 수행하는 대부분의 최적화는 이 의존성 그래프를 기반으로 이루어진다.&lt;/p&gt;
&lt;p&gt;즉, 프로젝트 전체 구조를 담은 &lt;strong&gt;“전체 지도”&lt;/strong&gt; 가 있어야 어떤 코드를 제거하고, 어디를 분리하고, 무엇을 공유할지 판단할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;2) 번들링은 어떤 최적화를 할까?&lt;/h2&gt;
&lt;h3&gt;(1) 그래프 기반 최적화: Tree-shaking / Deduplication&lt;/h3&gt;
&lt;h4&gt;1-1. Tree-shaking: 안 쓰는 코드는 제거한다&lt;/h4&gt;
&lt;p&gt;예를 들어 A 라이브러리에서 “캐릭터가 춤추는 기능” 하나만 쓴다고 해보자.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;번들러가 없다면:&lt;/strong&gt; 기능 하나 쓰려고 A 라이브러리 전체를 내려받는다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;번들러가 있다면:&lt;/strong&gt; 그래프를 보고 “이 기능만 쓰네?”라고 판단해 필요한 코드만 남긴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그래서 이를 “나무를 흔들어 마른 잎을 떨군다”는 비유로 &lt;code&gt;Tree-shaking&lt;/code&gt;이라고 부른다.&lt;/p&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;다만 트리 셰이킹은 의존성 그래프가 있다고 해서 자동으로 적용되는 기능은 아니다.&lt;br/&gt;안전한 코드 제거를 위해서는 아래 조건들이 함께 충족되어야 한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;strong&gt;조건1. ESM(&lt;code&gt;import / export&lt;/code&gt;) 기반일수록 코드 제거가 유리하다&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// ❌ Tree-shaking이 어려운 경우 (CommonJS)

const { funcA } =require(&amp;#39;./utils&amp;#39;);&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;CommonJS는 &lt;code&gt;require()&lt;/code&gt;가 실행 시점(runtime) 에 평가된다&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;exports&lt;/code&gt; 객체의 구조도 런타임에 결정되므로&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;번들러는 “어떤 함수가 실제로 쓰이는지”를 빌드 타임에 확정할 수 없다&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;반면 ESM은 다르다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// ✅ Tree-shaking이 유리한 경우 (ESM)

import { funcA }from&amp;#39;./utils&amp;#39;;

// utils.js에서 funcB가 사용되지 않았다면 제거 가능&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;ESM은 import/export 구조가 정적으로 고정되어 있고&lt;/li&gt;
&lt;li&gt;번들러가 “이 파일에서는 funcA만 쓰인다”는 사실을 빌드 타임에 분석할 수 있다&lt;/li&gt;
&lt;li&gt;그래서 Tree-shaking은 ESM 기반일수록 정확하게 동작한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;조건 2. production 모드에서 &lt;code&gt;usedExports&lt;/code&gt; 분석 + minimizer가 함께 동작해야 한다&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tree-shaking은 보통 2단계로 이루어진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;1. usedExports 분석
    - “어떤 export가 실제로 사용되었는지” 표시만 해둔다
    - 이 단계만으로는 코드가 실제로 삭제되지는 않는다
2. minimizer(Terser 등)
    - “사용되지 않는 export”를 실제 코드에서 제거한다&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;그래서 개발 모드에서는 코드가 남아 있고, production 빌드에서만 코드 제거가 일어나는 경우가 많다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;조건 3. 모듈에 부수 효과(side effect)가 많을수록 제거 판단이 어려워진다&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;여기서 부수 효과(side effect) 란, 전역 상태를 변경하거나 외부 환경에 영향을 주는 코드를 의미한다.&lt;/li&gt;
&lt;li&gt;예를 들면 다음과 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// 부수 효과가 있는 코드

console.log(&amp;#39;loaded&amp;#39;);
window.__CONFIG__ = { mode:&amp;#39;prod&amp;#39; };&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 파일은 import되기만 해도 콘솔 출력이 발생하고 전역 값이 변경된다&lt;/li&gt;
&lt;li&gt;즉, “사용하지 않더라도 실행 자체가 의미를 가진다”&lt;/li&gt;
&lt;li&gt;이런 경우 번들러는 “혹시 이 코드가 실행되어야 하는 건 아닐까?” 라고 판단해 안전하게 제거하지 못한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;반대로 아래 코드는 다르다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// 부수 효과가 없는 코드
export function add(a, b) {
return a + b;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;함수가 호출되지 않으면 아무 일도 일어나지 않는다&lt;/li&gt;
&lt;li&gt;그래서 사용되지 않는다면 안전하게 제거할 수 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h4&gt;1-2. Deduplication: 같은 모듈은 한 번만 포함한다&lt;/h4&gt;
&lt;p&gt;여러 파일이 동일한 모듈 B를 필요로 할 때,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;번들러가 없다면:&lt;/strong&gt; 중복 포함이 생겨 사용자는 같은 코드를 여러 번 받는다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;번들러가 있다면:&lt;/strong&gt; ‘B는 한 번만 포함하고, 공통 청크(Shared chunk)로 분리하게’ 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h3&gt;(2) 네트워크 관점 최적화: 택배 1,000개 vs 큰 상자 5개&lt;/h3&gt;
&lt;p&gt;실무에서 번들링이 필요한 이유 중 하나는 네트워크 오버헤드(Overhead) 때문이다.&lt;/p&gt;
&lt;p&gt;요청/응답에는 실제 콘텐츠 외에도 생각보다 많은 고정 비용이 함께 따라온다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;❓ 왜 파일을 잘게 나누면 느려질까?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;파일 요청이 늘어날수록 다음과 같은 비용이 누적된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;핸드셰이크(Handshake)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  요청마다 연결 및 협상 과정이 발생한다. 파일이 1,000개라면, 이 과정도 1,000번 반복된다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;동시 연결 제한(Concurrency limit)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  브라우저는 한 번에 처리할 수 있는 요청 수가 제한되어 있다.&lt;br&gt;  이로 인해 요청이 순차적으로 쌓이는 Waterfall 현상이 발생한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;헤더 오버헤드(Header overhead)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  파일이 작을수록, 실제 데이터 대비 요청·응답 헤더가 차지하는 비중이 상대적으로 커진다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;HTTP/2면 이런 걱정은 안 해도 되지 않나?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;HTTP/2의 멀티플렉싱 덕분에 동시 요청 처리와 병목은 크게 완화되었다.&lt;br&gt;(멀티플렉싱은 하나의 연결 위에서 여러 요청과 응답을 동시에 주고받는 방식임)&lt;/p&gt;
&lt;p&gt;하지만 이는 “번들링이 더 이상 필요 없다”는 뜻은 아니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;각 요청에는 여전히 라운드트립 및 처리 오버헤드가 존재한다&lt;/li&gt;
&lt;li&gt;파일을 내려받은 이후에도 브라우저는 Parse → Compile → Execute 과정을 모두 수행해야 한다&lt;/li&gt;
&lt;li&gt;작은 파일이 지나치게 많아지면 경우에 따라 압축 효율이 오히려 떨어질 수 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;결국 결론은 하나다. 너무 잘게 쪼개도 문제고, 전부 합쳐도 문제다. &lt;/p&gt;
&lt;p&gt;그래서 번들링으로 “적당히 묶는 게 중요”하다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;3) 웹팩이 정의한 ‘적당히’ 묶는 단위는?&lt;/h2&gt;
&lt;p&gt;웹팩은 아무 기준 없이 파일을 묶지 않는다. &lt;/p&gt;
&lt;p&gt;&lt;code&gt;SplitChunksPlugin&lt;/code&gt; 의 기본 설정을 보면, 웹팩이 생각하는 “적당함”의 기준이 드러난다.&lt;/p&gt;
&lt;h3&gt;(1) 웹팩이 청크를 나누는 기준&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DaYEv/dJMcacoqmf7/CSwWlEpIv2OnnvLRqwQdj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DaYEv/dJMcacoqmf7/CSwWlEpIv2OnnvLRqwQdj0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DaYEv/dJMcacoqmf7/CSwWlEpIv2OnnvLRqwQdj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDaYEv%2FdJMcacoqmf7%2FCSwWlEpIv2OnnvLRqwQdj0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;https://webpack.kr/plugins/split-chunks-plugin/&quot;&gt;https://webpack.kr/plugins/split-chunks-plugin/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;용량 기준 (&lt;code&gt;minSize&lt;/code&gt;)&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;너무 작은 파일은 분리할수록 네트워크 오버헤드가 커진다.&lt;/li&gt;
&lt;li&gt;일정 크기(예: 20KB) 미만의 조각은 굳이 나누지 않고 합친다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;개수 제한 (&lt;code&gt;maxRequests&lt;/code&gt;)&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;한 화면에서 동시에 로드되는 파일 수가 과도해지지 않도록&lt;/li&gt;
&lt;li&gt;초기 로딩 및 비동기 요청 개수에 상한을 둔다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;재사용성 (Common Module)&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;여러 페이지에서 공통으로 사용되는 코드는 하나의 청크로 분리해&lt;/li&gt;
&lt;li&gt;중복 다운로드를 방지하고 캐싱 효율을 높인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;변경 빈도 (Vendor 분리)&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;자주 변경되는 애플리케이션 코드와&lt;/li&gt;
&lt;li&gt;거의 변하지 않는 라이브러리(&lt;code&gt;node_modules&lt;/code&gt;)를 분리해 캐시 무효화 범위를 최소화한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  그럼 우리 서비스의 청크 단위가 적절한지 어떻게 알 수 있을까? 4가지를 체크해보면 알 수 있다&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;초기 진입 페이지에서 “지금 안 쓰는 코드”가 같이 내려오지 않는가?&lt;/li&gt;
&lt;li&gt;공통 라이브러리가 페이지마다 중복 다운로드되지 않는가?&lt;/li&gt;
&lt;li&gt;청크가 너무 잘게 쪼개져서 로딩 딜레이가 체감되지는 않는가?&lt;/li&gt;
&lt;li&gt;내 코드 변경이 라이브러리(Vendor) 캐시까지 무효화시키지는 않는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;웹팩은 이처럼 여러 최적화를 위해 파일을 묶는다. &lt;/p&gt;
&lt;p&gt;그렇다면 웹팩은 내부적으로 어떤 순서와 구조로 이 결정을 수행할까? 다음 챕터에서 알아보자!&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;2. 빌드 파이프라인: ‘지도를 그리고, 짐을 싸서, 내보내기’&lt;/h1&gt;
&lt;p&gt;앞서 우리는 번들링이 왜 필요한지를 살펴봤다. &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;모듈을 해석하기 위해서&lt;/li&gt;
&lt;li&gt;스코프를 안전하게 보호하기 위해서&lt;/li&gt;
&lt;li&gt;그리고 네트워크 관점에서 효율적인 전송을 위해서.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;이번 챕터에서는 웹팩이 &lt;code&gt;entry&lt;/code&gt;를 입력으로 받아 &lt;code&gt;dist&lt;/code&gt;에 결과물을 만들기까지, &lt;/p&gt;
&lt;p&gt;어떤 단계와 사고 과정을 거치는지 빌드 파이프라인 관점에서 정리해본다.&lt;/p&gt;
&lt;p&gt;웹팩의 빌드 과정은 크게 &lt;strong&gt;4단계&lt;/strong&gt;로 나눌 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;전체 흐름&lt;/strong&gt;: Entry → ModuleGraph → ChunkGraph → Emit&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;핵심 설계&lt;/strong&gt;: 먼저 “파일 간 연결 관계(논리)”를 확정하고, 그 다음 “배포 단위(물리)”를 만든다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;유연성&lt;/strong&gt;: “사실(코드 구조)”과 “전략(배포 방식)”을 분리했기에, 코드를 수정하지 않고 설정만으로 번들링·코드 분할·캐싱 전략을 자유롭게 바꿀 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h2&gt;1) 먼저, 웹팩 안에서 어떤 객체들이 일을 할까?&lt;/h2&gt;
&lt;p&gt;빌드 파이프라인을 이해할 때 가장 헷갈리는 지점 중 하나는&lt;/p&gt;
&lt;p&gt;우리가 흔히 &lt;strong&gt;“웹팩이 뭔가를 한다”&lt;/strong&gt; 라고 뭉뚱그려 말한다는 점이다.&lt;/p&gt;
&lt;p&gt;실제로는 하나의 주체가 모든 일을 처리하는 것이 아니라, 여러 객체들이 빌드를 완성한다.&lt;/p&gt;
&lt;p&gt;아래는 이번 챕터에서 등장하는 주요 객체들과 그 역할을 정리한 표다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;분류&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compiler&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;웹팩 실행부터 종료까지를 총괄하는 상위 관리자. 전체 프로세스에서 &lt;strong&gt;단 하나만 존재&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compilation&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;“이번 빌드 1회”의 실무 책임자. 빌드(또는 리빌드)마다 &lt;strong&gt;새로 생성&lt;/strong&gt;됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ModuleGraph&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;모듈 간 의존 관계, 즉 &lt;strong&gt;논리적 구조&lt;/strong&gt;를 기록하는 그래프&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ChunkGraph&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;어떤 모듈을 어떤 청크에 담을지 결정하는 &lt;strong&gt;배포 단위 그래프&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chunk&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;최종적으로 생성될 &lt;strong&gt;파일 단위에 대응되는 배포 컨테이너&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;여기서 꼭 짚고 가야 할 핵심은 하나다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;Compilation&lt;/code&gt;은 이번 빌드에서 생성되는 모든 데이터의 중심이다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;즉, “그래프를 만든다”, “청크를 나눈다”, “파일을 만든다”라는 말은&lt;/p&gt;
&lt;p&gt;  대부분 &lt;code&gt;Compilation&lt;/code&gt; 내부 상태를 채워나가는 과정을 의미한다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이제 이 객체들이 어떤 순서로 생성되고, 어떤 정보를 주고받는지 빌드 단계별로 살펴보자.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;2) 번들링의 4단계 흐름&lt;/h2&gt;
&lt;h3&gt;(1) Entry: “어디서부터 출발할지”를 고정한다&lt;/h3&gt;
&lt;p&gt;Entry는 Webpack에게 프로젝트의 시작점을 알려주는 단계다. &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;entry: &amp;quot;./src/index.js&amp;quot;&lt;/code&gt; 설정은 “이 파일을 기준으로, 연결된 모든 모듈을 추적하라”는 의미다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;중요한 점은 이 단계가 파일을 합치기 시작하는 단계가 아니라&lt;/p&gt;
&lt;p&gt;  의존성 추적을 시작할 기준점을 정의하는 단계라는 것이다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) ModuleGraph: “누가 누구를 필요로 하는지”를 기록한다 (논리 지도)&lt;/h3&gt;
&lt;p&gt;웹팩은 엔트리부터 소스 파일을 읽어가며 &lt;code&gt;import&lt;/code&gt; / &lt;code&gt;require&lt;/code&gt; 등을 분석해 모듈 간 관계도를 만든다.&lt;/p&gt;
&lt;p&gt;이 과정은 대략 다음 순서로 진행된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Parsing :&lt;/strong&gt; 소스 코드를 분석해 AST(Abstract Syntax Tree)로 변환한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependency 추출 :&lt;/strong&gt; 어떤 모듈이 무엇을 참조하는지 의존성을 수집한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ModuleGraph 완성 :&lt;/strong&gt; “모듈 노드 + 의존성 엣지”로 구성된 논리 그래프를 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;아직 이 단계에서는 “파일을 몇 개로 나눌지” 같은 배포 전략은 전혀 결정하지 않는다.&lt;/p&gt;
&lt;p&gt;ModuleGraph는 오직 코드 구조가 어떻게 생겼는지만 담는다.&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;(3) ChunkGraph: “어떻게 묶어서 배포할지”를 결정한다 (포장 전략)&lt;/h3&gt;
&lt;p&gt;여기서부터 전략(Strategy) 이 개입된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;웹팩은 ModuleGraph를 바탕으로 “청크(Chunk)라는 배포 상자”를 만들고&lt;/li&gt;
&lt;li&gt;각 모듈을 어느 청크에 담을지에 대한 매핑을 구성한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;ChunkGraph는 단순한 “청크 목록”이 아니라, 다음과 같은 정보들을 함께 가진 구조다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;모듈 → 청크 매핑 (N:M) :&lt;/strong&gt; 하나의 모듈이 여러 청크에 포함될 수 있다 (공유 청크, 코드 스플리팅)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;청크 → 모듈 매핑 :&lt;/strong&gt; 특정 파일(청크)에 어떤 모듈들이 들어가는지&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;청크 간 관계 :&lt;/strong&gt; 부모/자식 구조, 특히 &lt;code&gt;import()&lt;/code&gt;로 생성되는 async 청크 연결&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(4) Emit: “결정된 배치(ChunkGraph)를 실제 파일로 출력한다” (출고)&lt;/h3&gt;
&lt;p&gt;마지막으로 웹팩은 메모리 안에 있던 결과를 실제 파일로 찍어낸다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;dist/main.js&lt;/code&gt;, &lt;code&gt;dist/1.js&lt;/code&gt; 같은 파일이 이 단계에서 만들어진다.&lt;/li&gt;
&lt;li&gt;프로덕션 빌드에서는 &lt;code&gt;main.[contenthash].js&lt;/code&gt; 형태로 해시가 붙어 출력된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;3) ModuleGraph → ChunkGraph 빌드 중간 산출물 살펴보기&lt;/h2&gt;
&lt;p&gt;앞에서 살펴본 개념을 실제 빌드 중간 산출물의 형태로 연결해보자.&lt;/p&gt;
&lt;p&gt;말로만 설명하면 추상적으로 느껴질 수 있기에, 이번에는 단순화한 JSON 데이터로&lt;/p&gt;
&lt;p&gt;ModuleGraph와 ChunkGraph가 각각 무엇을 담는지 확인해본다.&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;(1) 기본 예시로 살펴보기&lt;/h3&gt;
&lt;h4&gt;예시 프로젝트 구조&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;src/
  index.js// entry
  App.js
  util.js&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;1-1. ModuleGraph: “관계 데이터”만 담긴다&lt;/h4&gt;
&lt;p&gt;ModuleGraph는 모듈 간 논리적 관계만을 기록한다.&lt;/p&gt;
&lt;p&gt;아직 배포 단위나 파일 개수에 대한 정보는 없다. 오직 다음 정보만 담는다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;어떤 모듈이 존재하는지&lt;/li&gt;
&lt;li&gt;어떤 모듈이 어떤 모듈을 참조하는지&lt;/li&gt;
&lt;li&gt;그 참조가 동기인지 / 비동기인지&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;modules&amp;quot;: [
    // ModuleGraph에서 index.js를 가리키는 모듈 노드
    { &amp;quot;id&amp;quot;: &amp;quot;M0&amp;quot;, &amp;quot;resource&amp;quot;: &amp;quot;/src/index.js&amp;quot; },
    { &amp;quot;id&amp;quot;: &amp;quot;M1&amp;quot;, &amp;quot;resource&amp;quot;: &amp;quot;/src/App.js&amp;quot; },
    { &amp;quot;id&amp;quot;: &amp;quot;M2&amp;quot;, &amp;quot;resource&amp;quot;: &amp;quot;/src/util.js&amp;quot; }
  ],
  &amp;quot;dependencies&amp;quot;: [
    // index.js(M0)가 App.js(M1)를 동기적으로 import/require 함
    { &amp;quot;from&amp;quot;: &amp;quot;M0&amp;quot;, &amp;quot;to&amp;quot;: &amp;quot;M1&amp;quot;, &amp;quot;request&amp;quot;: &amp;quot;./App&amp;quot;, &amp;quot;async&amp;quot;: false },
    { &amp;quot;from&amp;quot;: &amp;quot;M1&amp;quot;, &amp;quot;to&amp;quot;: &amp;quot;M2&amp;quot;, &amp;quot;request&amp;quot;: &amp;quot;./util&amp;quot;, &amp;quot;async&amp;quot;: false }
  ],
  // ModuleGraph 탐색을 시작하는 진입 모듈(entry)
  &amp;quot;entryModules&amp;quot;: [&amp;quot;M0&amp;quot;]
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;h4&gt;1-2. ChunkGraph: “물리 배치 + 매핑”이 추가된다&lt;/h4&gt;
&lt;p&gt;이제 ModuleGraph를 바탕으로 배포 단위(Chunk) 가 만들어진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  // 생성된 배포 단위(청크) 목록 — 여기서는 main 청크 하나만 존재
  &amp;quot;chunks&amp;quot;: [
    { &amp;quot;id&amp;quot;: &amp;quot;C0&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;main&amp;quot;, &amp;quot;initial&amp;quot;: true }
  ],

  // 각 청크에 어떤 모듈들이 포함되는지에 대한 매핑
  &amp;quot;chunkToModules&amp;quot;: {
    &amp;quot;C0&amp;quot;: [&amp;quot;M0&amp;quot;, &amp;quot;M1&amp;quot;, &amp;quot;M2&amp;quot;]
  },

  // 각 모듈이 어느 청크(파일)에 속해 있는지에 대한 역방향 매핑
  &amp;quot;moduleToChunks&amp;quot;: {
    &amp;quot;M0&amp;quot;: [&amp;quot;C0&amp;quot;],
    &amp;quot;M1&amp;quot;: [&amp;quot;C0&amp;quot;],
    &amp;quot;M2&amp;quot;: [&amp;quot;C0&amp;quot;]
  },

  // 청크 간 부모·자식 관계 — async 청크가 없으므로 비어 있음
  &amp;quot;chunkRelations&amp;quot;: []
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;모든 모듈이 하나의 &lt;code&gt;main&lt;/code&gt; 청크에 묶였고&lt;/li&gt;
&lt;li&gt;어떤 모듈이 어떤 파일로 배포되는지가 명확해졌다&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) import()가 등장한 경우 예시 살펴보기&lt;/h3&gt;
&lt;p&gt;이번에는 entry에서 &lt;code&gt;App&lt;/code&gt;을 동적 import로 바꿔보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// index.js

import(&amp;quot;./App&amp;quot;).then(({ default: App }) =&amp;gt;App())&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2-1. ModuleGraph 변화: 비동기 의존성만 추가된다&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;dependencies&amp;quot;: [
    // M0(index.js)가 M1(App.js)를 비동기 import()로 참조함
    { &amp;quot;from&amp;quot;: &amp;quot;M0&amp;quot;, &amp;quot;to&amp;quot;: &amp;quot;M1&amp;quot;, &amp;quot;request&amp;quot;: &amp;quot;./App&amp;quot;, &amp;quot;async&amp;quot;: true }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ModuleGraph의 변화는 단순하다. “이 의존성은 async다”라는 사실 하나만 추가된다.&lt;/p&gt;
&lt;br/&gt;

&lt;h4&gt;2-2. ChunkGraph 변화: 청크가 분리되고 관계가 생긴다&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;chunks&amp;quot;: [
     // 초기 진입 시 바로 로드되는 main 청크
    { &amp;quot;id&amp;quot;: &amp;quot;C0&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;main&amp;quot;, &amp;quot;initial&amp;quot;: true },

     // import()로 분리된 비동기 청크 (App 관련 코드)
    { &amp;quot;id&amp;quot;: &amp;quot;C1&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;src_App_js&amp;quot;, &amp;quot;initial&amp;quot;: false, &amp;quot;async&amp;quot;: true }
  ],

  &amp;quot;chunkToModules&amp;quot;: {
    // main 청크에는 entry 모듈만 포함
    &amp;quot;C0&amp;quot;: [&amp;quot;M0&amp;quot;],  

    // App 모듈과 그 하위 의존성이 비동기 청크로 이동
    &amp;quot;C1&amp;quot;: [&amp;quot;M1&amp;quot;, &amp;quot;M2&amp;quot;]
  },

  &amp;quot;chunkRelations&amp;quot;: [
     // main 실행 중 import()를 만나면 C1 청크를 로드하라는 관계 정보
    { &amp;quot;parent&amp;quot;: &amp;quot;C0&amp;quot;, &amp;quot;child&amp;quot;: &amp;quot;C1&amp;quot;, &amp;quot;reason&amp;quot;: &amp;quot;import()&amp;quot; }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 구조가 분명해진다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;main&lt;/code&gt; 청크에는 entry 모듈만 남고&lt;/li&gt;
&lt;li&gt;&lt;code&gt;App&lt;/code&gt;과 &lt;code&gt;util&lt;/code&gt;은 비동기 청크로 분리된다&lt;/li&gt;
&lt;li&gt;두 청크 사이에는 &lt;code&gt;import()&lt;/code&gt;로 연결된 부모–자식 관계가 기록된다&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  여기서 한 가지 질문이 떠오른다.&lt;br/&gt;“그냥 파일을 합치면 되는데, 왜 웹팩은 ModuleGraph를 만들고 ChunkGraph를 또 만들까?”&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이유는 하나다. 사실과 전략을 분리하기 위해서다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ModuleGraph(논리 / 사실)&lt;/strong&gt;는 내 코드가 어떤 관계로 연결되는지&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ChunkGraph(물리 / 전략)&lt;/strong&gt;는 이 관계를 어떤 파일 조합으로 사용자에게 전달할지가 중요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;이 분리 덕분에, 코드 구조를 크게 바꾸지 않고도 설정만으로 배포 전략을 바꿀 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;splitChunks.cacheGroups&lt;/code&gt;를 조정하면, vendor / common 분리 기준이 달라지고&lt;/li&gt;
&lt;li&gt;&lt;code&gt;optimization.runtimeChunk: &amp;quot;single&amp;quot;&lt;/code&gt;을 켜면, runtime만 별도 파일로 분리되며&lt;/li&gt;
&lt;li&gt;&lt;code&gt;import()&lt;/code&gt;는 코드 레벨에서, “비동기 경계”를 추가해 청크 분리를 유도한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉 웹팩은 “관계는 먼저 고정하고, 그 위에서 전략을 갈아끼우는 구조”로 설계돼 있다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;4) Emit 단계: ChunkGraph가 “실제 파일 트리”로 떨어진다&lt;/h2&gt;
&lt;p&gt;지금까지 만들어진 모든 그래프와 전략의 결과는 마지막에 실제 파일 형태로 출력된다.&lt;/p&gt;
&lt;h3&gt;(1) 분리 없이 한 덩어리인 경우&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;dist/
main.[contenthash].js
  index.html&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;(2) import()로 async 청크가 생긴 경우&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;dist/
main.[contenthash].js
  src_App_js.[contenthash].js
  index.html&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;(3) SplitChunks까지 적용된 경우&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;dist/
  main.[contenthash].js
  vendors-main.[contenthash].js
  common-home-admin.[contenthash].js   // 멀티 엔트리일 때
  src_App_js.[contenthash].js          // import()가 있을 때
  index.html&lt;/code&gt;&lt;/pre&gt;&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  결국 웹팩은 &lt;br/&gt;ModuleGraph로 사실을 고정하고, ChunkGraph로 전략을 결정한 뒤,&lt;br/&gt;Emit에서 그 결과를 파일로 현실화한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;3. 마치며…&lt;/h1&gt;
&lt;p&gt;이번 시간에는 웹팩의 &lt;strong&gt;번들링 과정&lt;/strong&gt;을 빌드 타임 관점에서 살펴봤다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;웹팩은 먼저 코드를 분석해 의존 관계를 정리하고 ModuleGraph를 만든다.&lt;/li&gt;
&lt;li&gt;그 위에서 “어떻게 나눠서 전달할지”라는 배포 전략을 세워 ChunkGraph를 구성한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, 번들링은 단순한 “파일 합치기”가 아니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;(1) 의존 관계를 파악하는 분석 작업이고,&lt;/li&gt;
&lt;li&gt;(2) 전달 단위를 설계하는 전략 작업이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그리고 이 빌드 타임의 결정은 브라우저에서 실제로 실행될 수 있어야 비로소 완성된다.&lt;/p&gt;
&lt;p&gt;다음 장에서는 이렇게 생성된 번들이 브라우저에서 어떻게 동작하는지 살펴보겠다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;</description>
      <category>라이브러리 파헤치기</category>
      <category>chunk graph</category>
      <category>Code Splitting</category>
      <category>dependency graph</category>
      <category>module graph</category>
      <category>tree shaking</category>
      <category>webpack</category>
      <category>webpack bundling</category>
      <category>번들링</category>
      <category>빌드도구</category>
      <category>웹팩</category>
      <author>GicoMomg</author>
      <guid isPermaLink="true">https://mong-blog.tistory.com/241</guid>
      <comments>https://mong-blog.tistory.com/entry/Webpack-%EB%B2%88%EB%93%A4%EB%A7%81%EC%9D%98-%EC%9B%90%EB%A6%AC-%EC%88%98%EC%B2%9C-%EA%B0%9C%EC%9D%98-%EB%AA%A8%EB%93%88%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%AC%B6%EC%9D%84%EA%B9%8C#entry241comment</comments>
      <pubDate>Sun, 1 Feb 2026 22:08:26 +0900</pubDate>
    </item>
    <item>
      <title>이미지 리스트 성능 문제는 DOM에서 끝나지 않는다</title>
      <link>https://mong-blog.tistory.com/entry/%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%84%B1%EB%8A%A5-%EB%AC%B8%EC%A0%9C%EB%8A%94-DOM%EC%97%90%EC%84%9C-%EB%81%9D%EB%82%98%EC%A7%80-%EC%95%8A%EB%8A%94%EB%8B%A4</link>
      <description>&lt;h1&gt;0. 들어가며…&lt;/h1&gt;
&lt;p&gt;vue, react를 이용해 dom을 렌더링하는 게 자연스러워진 요즘.&lt;br&gt;각 프레임워크에서 리스트를 표현할 때는 Vue에서는 &lt;code&gt;v-for&lt;/code&gt;, React에서는 &lt;code&gt;map&lt;/code&gt;을 사용해 여러 DOM 요소를 렌더링한다.&lt;br&gt;이 방식은 단순하다. 그래서 데이터 배열만 있으면 누구나 손쉽게 리스트 UI를 만들 수 있다.&lt;br&gt;하지만 이 단순한 방식이 언제나 좋은 선택은 아니다.&lt;/p&gt;
&lt;h3&gt;DOM을 계속 늘려도 괜찮을까?&lt;/h3&gt;
&lt;p&gt;만약 다음 조건이라면 큰 문제는 없다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;데이터 개수가 100개 미만이고&lt;/li&gt;
&lt;li&gt;노출되는 리스트 DOM이 많지 않으며&lt;/li&gt;
&lt;li&gt;한 화면에서 무거운 연산을 하지 않는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이런 상황에서는 단순 렌더링 방식이 성능 리스크로 이어질 가능성은 크지 않다.&lt;br&gt;하지만 리스트 DOM이 100개를 넘어 1,000개, 혹은 그 이상까지 늘어날 수 있는 구조라면?&lt;br&gt;이 시점부터는 “DOM을 이렇게 많이 렌더링하는 게 정말 옳은 선택인지” 고민해봐야 한다.&lt;/p&gt;
&lt;h3&gt;그래서 등장한 해결책, 가상 스크롤&lt;/h3&gt;
&lt;p&gt;이 문제를 해결하기 위해 가장 흔히 사용되는 방법이 바로 가상 스크롤(Virtual Scroll)이다.&lt;br&gt;안드로이드 개발에서는 과거 RecyclerView라는 이름으로 널리 사용되던 개념이다.&lt;br&gt;가상 스크롤의 방식은 명확하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;화면에 필요한 정해진 개수의 DOM(n개)만 유지하고&lt;/li&gt;
&lt;li&gt;사용자가 스크롤할 때마다&lt;/li&gt;
&lt;li&gt;DOM은 그대로 두고, 그 안의 데이터만 교체해서 보여준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btNCeQ/dJMcagqGXRx/MPPFaz51UwVnK0hEJd3gY0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btNCeQ/dJMcagqGXRx/MPPFaz51UwVnK0hEJd3gY0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btNCeQ/dJMcagqGXRx/MPPFaz51UwVnK0hEJd3gY0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtNCeQ%2FdJMcagqGXRx%2FMPPFaz51UwVnK0hEJd3gY0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;이렇게 하면 DOM을 무한정 생성하지 않기에 렌더링 비용을 아끼고, 성능 부하를 줄일 수 있다.&lt;/p&gt;
&lt;h3&gt;그럼 가상 스크롤은 만능일까?&lt;/h3&gt;
&lt;p&gt;그럼, 리스트를 표현해야 한다면, 가상 스크롤만 쓰면 된다고 생각할 수 있다.&lt;br&gt;실제로 텍스트 위주의 단순한 리스트라면 가상 스크롤을 적용하는 것만으로도 성능 개선 효과는 크다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;하지만, 이미지 리스트라면?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;문제는 여기서부터다.&lt;br&gt;만약 리스트의 각 아이템이 고화질 이미지를 포함하고 있다면, 가상 스크롤을 “적용하는 것만으로” 충분할까?&lt;br&gt;이미지는 텍스트와 달리, (1) 네트워크 요청 (2) 디코딩 (3) GPU 업로드 (4) 합성 등의 과정을 거쳐야 화면에 노출된다.&lt;br&gt;즉, DOM 개수만 줄인다고 해서 모든 비용이 사라지지는 않는다.&lt;br&gt;가상 스크롤은 분명 좋은 도구지만, 이미지 리스트에서는 추가로 고려해야할 점이 있다.&lt;/p&gt;
&lt;p&gt;이번 시간에는 다음 두 가지를 중심으로 살펴보려 한다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;이미지 요소가 브라우저에서 화면에 노출되기까지의 과정&lt;/li&gt;
&lt;li&gt;이미지 리스트에 가상 스크롤을 적용할 때 반드시 고려해야 할 포인트&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;1. 이미지 리스트와 가상 스크롤&lt;/h1&gt;
&lt;h2&gt;1) 이미지 리스트에 가상 스크롤 말고 고려할 점이 많다.&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  (키 포인트) 이미지 리스트 성능 문제는 DOM 문제로 시작하지만, DOM 문제로 끝나지 않는다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;em&gt;가상 스크롤은 DOM 비용만 줄여준다.&lt;br&gt;이미지 렌더링의 핵심 병목은 그 이후 단계에 있다.&lt;/em&gt;&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;(1) 우리가 흔히 하는 착각&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;성능 최적화를 하다 보면 이런 흐름에 익숙해진다.&lt;ul&gt;
&lt;li&gt;preload를 붙이기&lt;/li&gt;
&lt;li&gt;캐싱 전략을 고민하기&lt;/li&gt;
&lt;li&gt;lazy loading을 적용하기&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;그러다 리스트가 많은 화면을 발견하면, 자연스럽게 이렇게 사고가 이어진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;현상) DOM이 많다
문제) 렌더링이 느리다

해결) DOM을 줄이면 빨라질 것이다&lt;/code&gt;&lt;/pre&gt;&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;그래서 가상 스크롤을 적용하게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;이미지 리스트가 느리다

→ DOM이 많다
→ 가상 스크롤을 적용하자
→ 해결&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 방식이 완전히 틀린 건 아니다. 하지만 이미지 리스트에서는 고려할 사항이 더 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 왜 가상 스크롤만으로 부족할까?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;DOM은 분명 비용이 들기 때문에, 줄이는 게 좋다.&lt;/li&gt;
&lt;li&gt;하지만 이미지 리스트 성능에는 DOM은 물론 복합적으로 고려할 사항이 많다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;이미지 리스트 성능

= DOM 비용
+ 이미지 디코딩(CPU)
+ GPU 업로드 / 합성
+ 레이아웃 안정성(CLS)&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;가상 스크롤은 여기서 &lt;strong&gt;단 하나,&lt;/strong&gt; DOM 비용만 해결해준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;가상 스크롤로 해결되는가?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;DOM 개수&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이미지 다운로드&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이미지 디코딩 (CPU)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU 업로드 / 합성&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;레이아웃 흔들림(CLS)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;DOM은 줄었지만, 이미지는 여전히 새로 디코딩되고 GPU로 올라간다.&lt;/li&gt;
&lt;li&gt;이미지 성능 문제의 대부분은 DOM 이후 단계에서 발생한다.&lt;/li&gt;
&lt;li&gt;따라서 이미지 리스트에서는 렌더링 파이프라인 전체를 기준으로 전략을 세워야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;2) 브라우저는 &lt;img&gt; 하나를 그리기 위해 무슨 일을 할까?&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  이미지는 “다운로드되면 끝”이 아니다.&lt;br&gt;화면에 그려지기까지 CPU와 GPU가 모두 개입한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;이미지를 렌더링할 때 어떤 과정을 거칠까?&lt;/li&gt;
&lt;li&gt;어쩌면 다음과 같이 단순한 형태만 생각할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;img&amp;gt; 발견
→ 다운로드
→ 화면에 표시&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;하지만 실제로 이미지를 렌더링하기 위해 여러 단계를 거치게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;브라우저의 이미지 렌더링 파이프라인&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;브라우저가 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 하나를 화면에 그리기까지 7가지 과정을 거친다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1. &lt;strong&gt;요청 우선순위 판단&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;이 이미지를 지금 당장 받아야 하는지 아니면 나중에 받아야 하는지 판단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. &lt;strong&gt;네트워크 다운로드&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;서버로부터 압축된 바이너리 데이터를 수신&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. &lt;strong&gt;HTTP 캐시 확인&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;이미 받은 적 있다면 로컬 파일로 대체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. &lt;strong&gt;디코딩(Decoding) ⭐&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;압축을 풀어 &amp;#39;픽셀(RGBA)&amp;#39;로 변환. (&lt;strong&gt;CPU 소모&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. &lt;strong&gt;레이아웃 계산&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;이미지의 자리를 확보하고 주변 요소의 위치를 정함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6. &lt;strong&gt;GPU 텍스처 업로드 ⭐&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;픽셀 데이터를 비디오 메모리(VRAM)로 보냄&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7. &lt;strong&gt;합성(Composite) 및 화면 표시&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;레이어들을 겹쳐서 최종 화면을 완성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;여기서 기억해야 할 포인트는 이것이다.&lt;/li&gt;
&lt;li&gt;성능 문제의 대부분은 4번(디코딩)과 6번(GPU 업로드)에서 발생한다는 것!&lt;/li&gt;
&lt;li&gt;이제 각 단계별로 어떤 일을 하는 지 살펴보자!&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(1) 1단계, 요청 우선순위 판단 (Fetch Priority)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;브라우저는 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;를 발견하자마자 다운로드하지 않는다.&lt;/li&gt;
&lt;li&gt;먼저 “이 이미지가 지금 얼마나 급한가?”를 평가한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;- 판단 기준
    : 지금 화면(Viewport) 내부에 있는가?
    : 화면 상단에 위치하는가? (LCP 후보)
    : loading=&amp;quot;lazy&amp;quot;가 지정되어 있는가?
    : viewport와의 거리
    : CSS로 &amp;quot;display: none&amp;quot; 상태는 아닌가?
    : fetchpriority=&amp;quot;high&amp;quot;라고 명시했나?&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 판단 결과에 따라 요청 우선순위(priority) 가 정해진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;만약 가상 스크롤을 쓰게 되면 요청 우선순위가 어떻게 바뀔까?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;일반 리스트는 브라우저가 이미지를 점진적으로 발견하며 요청한다.&lt;/li&gt;
&lt;li&gt;그래서 화면에 노출하는 이미지가 100개라고 한번에 100개를 요청하지 않고 분산 요청한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;90%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bIrH7q/dJMcad1K6Nz/HrqbXuNoIlnRzOtmSqEDhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bIrH7q/dJMcad1K6Nz/HrqbXuNoIlnRzOtmSqEDhK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bIrH7q/dJMcad1K6Nz/HrqbXuNoIlnRzOtmSqEDhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbIrH7q%2FdJMcad1K6Nz%2FHrqbXuNoIlnRzOtmSqEDhK%2Fimg.png&quot; width=&quot;90%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;아래 캡처에서 일반 리스트의 이미지 요청은 &lt;code&gt;i&lt;/code&gt;(idle) 우선순위를 갖는다.&lt;/li&gt;
&lt;li&gt;이는 브라우저가 해당 이미지를 &lt;em&gt;즉시 처리해야 할 렌더링 작업&lt;/em&gt;이 아니라, &lt;strong&gt;주요 작업이 끝난 뒤 여유가 있을 때 처리해도 되는 리소스&lt;/strong&gt;로 분류했음을 보여준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;90%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tsaPA/dJMcadgoZk6/vQWqxNHyDLX2HhICvlnfkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tsaPA/dJMcadgoZk6/vQWqxNHyDLX2HhICvlnfkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tsaPA/dJMcadgoZk6/vQWqxNHyDLX2HhICvlnfkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtsaPA%2FdJMcadgoZk6%2FvQWqxNHyDLX2HhICvlnfkk%2Fimg.png&quot; width=&quot;90%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;그에 비해 가상 스크롤은 스크롤 시점에 필요한 이미지를 DOM에 한꺼번에 주입한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;90%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bD66vb/dJMcag5jQVf/TylkaVFkQusQbhsVzwGa6k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bD66vb/dJMcag5jQVf/TylkaVFkQusQbhsVzwGa6k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bD66vb/dJMcag5jQVf/TylkaVFkQusQbhsVzwGa6k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbD66vb%2FdJMcag5jQVf%2FTylkaVFkQusQbhsVzwGa6k%2Fimg.png&quot; width=&quot;90%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;그 이유는 가상 스크롤로 새롭게 노출되는 이미지들이 &lt;code&gt;Priority: u=1 (urgency level 1)&lt;/code&gt;로 분류되기 때문이다.&lt;/li&gt;
&lt;li&gt;이는 브라우저가 해당 이미지를 지금 즉시 화면에 표시되어야 하는 리소스로 판단했음을 의미한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;90%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cQeqzv/dJMcagxsAzz/aZuWpiOLwkV910KpKZeqd1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cQeqzv/dJMcagxsAzz/aZuWpiOLwkV910KpKZeqd1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cQeqzv/dJMcagxsAzz/aZuWpiOLwkV910KpKZeqd1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcQeqzv%2FdJMcagxsAzz%2FaZuWpiOLwkV910KpKZeqd1%2Fimg.png&quot; width=&quot;90%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;결국, 가상 스크롤 구간에서는 높은 우선순위(&lt;code&gt;u=1&lt;/code&gt;)의 이미지 요청이 동일한 시점에 집중적으로 발생하며, &lt;/li&gt;
&lt;li&gt;요청 수는 줄어들더라도 &lt;strong&gt;요청의 ‘순간 밀도’는 오히려 높아진다.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 2단계, 네트워크 다운로드&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;요청 우선순위 판단이 끝났다면, 이제 네트워크를 통해 이미지를 다운로드한다.&lt;/li&gt;
&lt;li&gt;여기서 또 하나의 착각이 나온다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;“이미지 다운로드가 끝났으니 GPU가 바로 그린다”는 착각!&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;우리가 받은 데이터(JPG, PNG, WebP)는 고도로 압축된 &lt;strong&gt;바이너리 파일&lt;/strong&gt;이다.&lt;/li&gt;
&lt;li&gt;GPU(그래픽 카드)는 이 압축된 상태를 이해하지 못한다.&lt;/li&gt;
&lt;li&gt;즉, 다운로드가 됐다고 해도 렌더링을 할 수 없다. 디코딩/GPU 업로드를 거쳐야 렌더링할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(3) 3단계, HTTP 캐시 확인&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;이미지가 이미 캐시되어 있다면 브라우저는 네트워크 요청 없이 바로 이미지를 가져올 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;70%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c4kd4P/dJMcac9CAsP/ps9QNTpvRGW3MzIBw2bKQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c4kd4P/dJMcac9CAsP/ps9QNTpvRGW3MzIBw2bKQ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c4kd4P/dJMcac9CAsP/ps9QNTpvRGW3MzIBw2bKQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc4kd4P%2FdJMcac9CAsP%2Fps9QNTpvRGW3MzIBw2bKQ0%2Fimg.png&quot; width=&quot;70%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;그래서 일반적으로 화면에 이미지가 더 빨리 노출될 것이라 기대한다.&lt;/li&gt;
&lt;li&gt;하지만 여기서 중요한 사실이 하나 있다.&lt;/li&gt;
&lt;li&gt;바로, “이미지가 캐시되어 있어도 디코딩과 GPU 업로드 과정은 여전히 필요”하다는 것!&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;왜 캐시되어 있는데도 후처리가 필요할까?&lt;br&gt;이 질문의 답은 “브라우저가 무엇을 캐시하는가”를 보면 명확해진다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;브라우저에는 이미지와 관련된 캐시가 두 단계로 존재한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;캐시 종류&lt;/th&gt;
&lt;th&gt;저장 내용&lt;/th&gt;
&lt;th&gt;성능 영향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP 캐시&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;이미지 파일 원본 (압축 데이터)&lt;/td&gt;
&lt;td&gt;네트워크 재다운로드 비용 감소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bitmap 캐시&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;디코딩 완료된 픽셀 데이터&lt;/td&gt;
&lt;td&gt;CPU 디코딩 비용 감소&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;여기서 핵심은 “브라우저는 이미지 원본만 HTTP 캐시에 저장”한다는 것이다.&lt;/li&gt;
&lt;li&gt;즉, JPG / PNG / WebP 같은 압축 파일은 캐시되지만 디코딩이 끝난 픽셀 데이터는 항상 남아 있지 않다.&lt;/li&gt;
&lt;li&gt;그래서 캐시에서 이미지를 가져와도, 디코딩 과정으로 인해 버벅임이 발생할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;그렇다면 디코딩된 이미지를 캐시하면 되지 않을까?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;만약 디코딩된 이미지(Bitmap)를 유지할 수 있다면 렌더링은 훨씬 빨라질 것이다.&lt;/li&gt;
&lt;li&gt;하지만 브라우저는 의도적으로 Bitmap 캐시를 오래 유지하지 않는다.&lt;/li&gt;
&lt;li&gt;그 이유는 단순한데, 메모리 비용이 너무 크기 때문이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;예를 들어 다음 이미지가 몇 장만 있어도 탭 하나가 빠르게 메모리를 잠식할 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;- 1920 × 1080 이미지
- 약 2M 픽셀
  → 약 8MB 메모리 사용&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;그래서 브라우저는 다음과 같은 전략을 취한다.&lt;ul&gt;
&lt;li&gt;Bitmap 캐시는 최소한으로 유지&lt;/li&gt;
&lt;li&gt;메모리 압박이 오면 가장 먼저 제거&lt;/li&gt;
&lt;li&gt;탭 전환, 스크롤 이동 시에도 적극적으로 삭제&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(4) 4단계 , ⭐ 디코딩(Decoding)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;이미지는 압축 파일이며, GPU는 압축된 이미지를 직접 그릴 수 없다.&lt;/li&gt;
&lt;li&gt;그래서 CPU가 압축 이미지를 변환하는 “디코딩”을 수행한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;압축 이미지

→ RGBA 픽셀 배열&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ceQ1Zy/dJMcachwPRD/gksupFYWxe0nkqbfviEmck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ceQ1Zy/dJMcachwPRD/gksupFYWxe0nkqbfviEmck/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ceQ1Zy/dJMcachwPRD/gksupFYWxe0nkqbfviEmck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FceQ1Zy%2FdJMcachwPRD%2FgksupFYWxe0nkqbfviEmck%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div style=&quot;text-align:center; margin-top: 6px; font-size: 0.85rem; color: rgb(77,77,77)&quot;&gt;[. 이미지: 디코딩 전의 압축된 바이너리 데이터(Raw Data)의 모습 ]&lt;/div&gt;


&lt;br/&gt;


&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/n1GbX/dJMcaaRyxwy/Zc0JEgtK44ZZxZRAk63zQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/n1GbX/dJMcaaRyxwy/Zc0JEgtK44ZZxZRAk63zQk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/n1GbX/dJMcaaRyxwy/Zc0JEgtK44ZZxZRAk63zQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fn1GbX%2FdJMcaaRyxwy%2FZc0JEgtK44ZZxZRAk63zQk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div style=&quot;text-align:center; margin-top: 6px; font-size: 0.85rem; color: rgb(77,77,77)&quot;&gt;[. 이미지: 디코딩 후 모습 ]&lt;/div&gt;

&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이 디코딩은 성능에 영향을 주는 요인 중 하나이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;영향을 주는 첫 번째 이유(연산량의 증가)&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;해상도가 가로/세로 2배만 커져도 픽셀 수는 4배가 된다.&lt;/li&gt;
&lt;li&gt;예를 들어, 1000px 이미지는 100만 개의 픽셀 데이터를 처리해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;영향을 주는 두 번째 이유(메인 스레드 점유)&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;브라우저는 보통 메인 스레드에서 이 작업을 처리한다.&lt;/li&gt;
&lt;li&gt;CPU가 디코딩을 처리하는 동안 사용자의 스크롤 이벤트, 버튼 클릭 등은 무시된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;결국, 디코딩이 몰리면 JS 실행, 스크롤, 클릭이 동시에 막힌다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(5) 5단계, 레이아웃(Layout)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;이미지 로드 전, 브라우저는 해당 이미지의 크기를 모른다.&lt;/li&gt;
&lt;li&gt;그래서 일단 높이를 0으로 잡고 나중에 이미지가 로드되면 그 때 공간을 재배열한다.&lt;/li&gt;
&lt;li&gt;이때 발생할 수 있는게 바로 &lt;strong&gt;CLS(Cumulative Layout Shift)&lt;/strong&gt;이다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CLS&lt;/strong&gt;란, 갑자기 이미지가 툭 튀어나오며 아래 요소들이 밀려나는 현상을 말한다.&lt;/li&gt;
&lt;li&gt;그래서 이런 CLS를 예방하기 위해 aspect-ratio나 고정 width/height 속성을 지정하는 게 중요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(6) 6단계, ⭐ GPU 업로드 &amp;amp; 메모리 관리&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;디코딩이 완료된 이미지는 CPU 메모리에 RGBA 픽셀 버퍼 형태로 존재한다.&lt;/li&gt;
&lt;li&gt;이 픽셀 데이터는 화면에 그려지기 위해 GPU 메모리(VRAM)로 복사되어 텍스처(texture)로 변환된다.&lt;/li&gt;
&lt;li&gt;이후 브라우저의 컴포지터는 이 텍스처를 이용해 레이어를 합성하고, 최종 프레임을 생성한다.&lt;/li&gt;
&lt;li&gt;여기서 중요한 점은 GPU 메모리의 수명 주기가 DOM과 다르다는 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;DOM 제거와 GPU 메모리 해제 시점은 같지 않다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;가상 스크롤을 사용하는 경우, 화면에서 벗어난 이미지는 DOM에서 빠르게 제거된다.&lt;/li&gt;
&lt;li&gt;하지만 이는 DOM 트리에서의 제거일 뿐, 다음을 의미하지는 않는다.&lt;ul&gt;
&lt;li&gt;디코딩된 비트맵이 즉시 해제된다&lt;/li&gt;
&lt;li&gt;GPU에 업로드된 텍스처가 바로 반환된다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;브라우저는 성능을 위해 다음과 같은 전략을 취한다.&lt;ul&gt;
&lt;li&gt;최근 사용된 이미지 텍스처를 캐시로 유지&lt;/li&gt;
&lt;li&gt;곧 다시 사용될 가능성이 있으면 GPU 메모리에 잠시 보존&lt;/li&gt;
&lt;li&gt;GC / 메모리 압박 시점까지 해제를 지연&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;그 결과, DOM에서는 사라진 이미지의 픽셀 정보가 GPU 메모리에는 한동안 남아 있는다.&lt;/li&gt;
&lt;li&gt;만약 이 현상이 누적되면, 특정 스크롤 구간에서 메모리 사용량이 순간적으로 튀는 현상(메모리 스파이크)이 발생할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;3) 이미지 리스트에 가상 스크롤 적용시 주의할 점&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;가상 스크롤은 분명 강력하다.&lt;/li&gt;
&lt;li&gt;하지만 &lt;strong&gt;이미지 리스트&lt;/strong&gt;에서는 “어디까지 해결해주고, 무엇은 남는지”를 정확히 알아야 한다.&lt;/li&gt;
&lt;li&gt;먼저 한 눈에 정리해보자.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;가상 스크롤의 효과&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;고려할 점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DOM 노드 관리&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;✅ 완벽 해결&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;네트워크 부하&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;⚠️ 순간적 폭주 위험&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;썸네일 / 요청 분산&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;디코딩 연산(CPU)&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;❌ 해결 못함&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;디코딩 제어 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;메모리(VRAM)&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;❌ 해결 못함&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;overscan 조절&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;레이아웃(CLS)&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;❌ 오히려 더 민감&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;고정 영역 필수&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;가상 스크롤은 “DOM 문제”만 해결한다. 이미지의 병목은 그 이후 단계에 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(1) decoding=&amp;quot;async&amp;quot; 지정하기&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;img src=&amp;quot;image.jpg&amp;quot;decoding=&amp;quot;async&amp;quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;decoding=&amp;quot;async&amp;quot;은 메인 스레드가 여유 있을 때 디코딩을 하도록 지연시킨다.&lt;/li&gt;
&lt;li&gt;디코딩을 렌더링·스크롤과 &lt;strong&gt;경쟁시키지 않도록 힌트 제공한다.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;단, 모든 브라우저에서 항상 비동기로 되는 것은 아니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) img.decode(), 준비된 이미지들만 렌더링&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const img = newImage();
img.src = &amp;quot;cat.jpg&amp;quot;;

img.decode().then(() =&amp;gt; {
  container.appendChild(img);
});&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 방식을 사용하면, 디코딩이 끝난 뒤에만 DOM에 삽입할 수 있다.&lt;/li&gt;
&lt;li&gt;다만 주의할 점도 있다.&lt;/li&gt;
&lt;li&gt;너무 많은 이미지를 한 번에 &lt;code&gt;decode()&lt;/code&gt; 하면 Promise 대기열 + 디코딩 폭주가 발생할 수 있다.&lt;/li&gt;
&lt;li&gt;그래서, overscan / 배치 제어와 함께 사용해야 안전하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(3) overscan 튜닝&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;overscan은 “미리 렌더링해 두는 범위”다.&lt;/li&gt;
&lt;li&gt;이미지 리스트에서는 이 값이 특히 중요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;overscan 너무 작음 → 하얀 빈칸, 체커보드
overscan 너무 큼 → 디코딩 폭주, VRAM 압박&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;overscan은 서비스하는 기기 성능/이미지 해상도에 따라 조정해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(4) 가장 강력한 해결책, 이미지 크기 줄이기&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;가장 효과적인 방법은 이미지 자체를 가볍게 만드는 것이다!&lt;/li&gt;
&lt;li&gt;서버 썸네일을 제공하거나, WebP / AVIF 사용하기, &lt;code&gt;srcset&lt;/code&gt; / &lt;code&gt;sizes&lt;/code&gt;로 해상도 분기하기 등이 있다.&lt;/li&gt;
&lt;li&gt;이 방식을 사용하면, 네트워크, 디코딩, GPU 메모리를 감소시킬 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;3. 마치며…&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;가상 스크롤은 리스트 성능 최적화의 대표 솔루션이다.&lt;/li&gt;
&lt;li&gt;DOM을 필요한 만큼만 유지하고 재사용하는 방식은, 특히 텍스트 중심 리스트에서는 거의 정답에 가깝다.&lt;/li&gt;
&lt;li&gt;하지만 이미지 리스트에서는 이야기가 달라진다. 이미지는 다운로드가 끝났다고 바로 그려지는 자원이 아니다.&lt;/li&gt;
&lt;li&gt;브라우저는 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 하나를 그리기 위해 여러 과정을 거친다.&lt;ul&gt;
&lt;li&gt;디코딩(CPU)&lt;/li&gt;
&lt;li&gt;GPU 텍스처 업로드(VRAM)&lt;/li&gt;
&lt;li&gt;합성(Composite)&lt;/li&gt;
&lt;li&gt;레이아웃 안정성(CLS)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;즉, 가상 스크롤이 DOM을 줄여준다 해도 디코딩과 GPU 업로드라는 핵심 병목은 그대로 남는다.&lt;/li&gt;
&lt;li&gt;그래서 이미지 리스트 최적화의 핵심은 “가상 스크롤을 적용했는가”가 아니다.&lt;/li&gt;
&lt;li&gt;이미지 리스트 최적화는 “DOM을 줄이는 문제”가 아니라 렌더링 파이프라인 전체의 비용을 분산시키는 문제다.&lt;/li&gt;
&lt;li&gt;만약 이미지 리스트의 성능 향상을 위해 가상 스크롤을 적용한다면 렌더링 파이프라인 전체 비용도 고려하는 걸 추천한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;</description>
      <category>개발 기술/사소하지만 놓치기 쉬운 개발 지식</category>
      <category>img.decode</category>
      <category>JS</category>
      <category>lazy loading 이미지</category>
      <category>virtual scroll</category>
      <category>가상 스크롤 (Virtual Scroll)</category>
      <category>대용량 리스트 렌더링</category>
      <category>브라우저 렌더링 파이프라인</category>
      <category>이미지 디코딩</category>
      <category>이미지 리스트 성능 최적화</category>
      <category>이미지 최적화</category>
      <author>GicoMomg</author>
      <guid isPermaLink="true">https://mong-blog.tistory.com/240</guid>
      <comments>https://mong-blog.tistory.com/entry/%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%84%B1%EB%8A%A5-%EB%AC%B8%EC%A0%9C%EB%8A%94-DOM%EC%97%90%EC%84%9C-%EB%81%9D%EB%82%98%EC%A7%80-%EC%95%8A%EB%8A%94%EB%8B%A4#entry240comment</comments>
      <pubDate>Tue, 13 Jan 2026 16:03:42 +0900</pubDate>
    </item>
    <item>
      <title>sourcemap? 운영에서는 그냥 끄는 옵션 아니에요?</title>
      <link>https://mong-blog.tistory.com/entry/sourcemap-%EC%9A%B4%EC%98%81%EC%97%90%EC%84%9C%EB%8A%94-%EA%B7%B8%EB%83%A5-%EB%81%84%EB%8A%94-%EC%98%B5%EC%85%98-%EC%95%84%EB%8B%88%EC%97%90%EC%9A%94</link>
      <description>&lt;h1&gt;0. 들어가며…&lt;/h1&gt;
&lt;h3&gt;이거 무슨 에러지?&lt;/h3&gt;
&lt;p&gt;개발을 하다 보면, 다음과 같은 에러를 마주치게 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;app-abc123.js:1:120
Uncaught Error: Something went wrong&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;에러는 분명히 발생했고 콘솔에도 찍혔다. 그런데 이상하다…?&lt;br&gt;에러 메시지를 가만히 들여다보고 있으면 몇 가지 답답함이 밀려온다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;어떤 파일에서 발생한 에러인지 감이 안 오고&lt;/li&gt;
&lt;li&gt;어떤 코드 줄에서 터졌는지도 모르겠고&lt;/li&gt;
&lt;li&gt;심지어 이게 내가 작성한 코드인지조차 확신이 안 든다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;에러는 났는데 정작 어디를 봐야 할지 모르는 상태. 이쯤 되면 자연스레 이런 생각이 든다.&lt;/p&gt;
&lt;p&gt;*&amp;quot;아… 이거 sourcemap 없어서 그런가?&amp;quot;*&lt;/p&gt;
&lt;h3&gt;sourcemap, 들어는 봤는데 설명은 애매한 그 단어&lt;/h3&gt;
&lt;p&gt;sourcemap. 대부분의 개발자가 한 번쯤은 들어봤을 단어다.&lt;br&gt;하지만 막상 누군가 &amp;quot;sourcemap이 정확히 뭐야?&amp;quot;라고 물어보면 대답이 조금씩 흐려진다.&lt;br&gt;보통은 이런 답이 돌아온다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&amp;quot;난독화된 코드를 원래 코드로 바꿔주는 거 아닌가요?&amp;quot;&lt;/li&gt;
&lt;li&gt;&amp;quot;개발 모드에서는 있고, 운영에서는 끄는 그 옵션이요.&amp;quot;&lt;/li&gt;
&lt;li&gt;&amp;quot;없으면 디버깅이 불편해지는 거?&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;전부 틀린 말은 아니다. 하지만 핵심이 빠져 있다. &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;sourcemap은 단순한 디버깅 옵션이 아니다.&lt;/li&gt;
&lt;li&gt;sourcemap은 빌드 결과물의 일부이며, 보안과 직접적으로 연결되는 파일이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이걸 모른 채 설정을 만지면 운영에서 에러가 나는데 재현이 안 되거나,&lt;br&gt;에러 위치가 엉뚱하게 나오거나, 심지어 의도치 않게 원본 소스 코드가 외부에 노출되는 사고가 발생할 수 있다. &lt;strong&gt;즉, sourcemap은 &amp;quot;있으면 좋은 것&amp;quot;이 아니라 &amp;quot;잘 다뤄야 하는 것&amp;quot;&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h3&gt;이번 글에서는…&lt;/h3&gt;
&lt;p&gt;그래서 이번 시간에는 막연하게 알고 있던 sourcemap에 대해 차근차근 알아보려고 한다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;sourcemap은 정확히 무엇인가?&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;무엇을 해주고, 무엇을 해주지 않는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sourcemap은 언제, 어떻게 만들어지는가?&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;빌드 파이프라인의 어느 지점에서 생성될까?&lt;/li&gt;
&lt;li&gt;왜 빌드 결과물과 항상 한 쌍일까?&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;.map 파일 하나로 어떻게 원본 코드의 에러 위치를 찾을 수 있을까?&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;이게 가능한 원리는 뭘까?&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;왜 .map 파일이 노출되면 위험한가?&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;정말로 .js와 .map만 있으면 원본 소스를 볼 수 있을까?&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;단, VLQ 수학 공식 암기나 스펙 문서 해설 같은 내용은 다루지 않는다. 대신 이 글을 읽으면&lt;br&gt;sourcemap이 왜 필요하며, 운영 환경에서 어떻게 다뤄야 안전한지 알 수 있을 것이다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;1. Source Map이란 무엇인가&lt;/h1&gt;
&lt;h2&gt;1) Source Map이란?&lt;/h2&gt;
&lt;h3&gt;(1) 코드를 복구하는 툴이 아니다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;sourcemap을 처음 접하면 다음과 같이 생각한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;“sourcemap은 난독화된 자바스크립트를 원래 코드로 되돌려주는 파일이지 않나?”&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;반은 맞고 반은 틀린 말이다.&lt;/li&gt;
&lt;li&gt;정확히 말하면, sourcemap은 코드를 변환하거나 로직을 &amp;#39;되돌리는&amp;#39; 도구가 아니다.&lt;/li&gt;
&lt;li&gt;그럼 sourcemap은 정확히 무엇을 할까? 역할은 생각보다 단순하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  빌드된 자바스크립트 코드의 위치를 원본 소스 코드의 위치로 연결(Mapping)해 주는 것&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;상황과 함께 더 자세히 살펴보자.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 빌드가 끝난 코드에 남아 있는 정보&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;에러가 발생했을 때, 빌드된(minified) 자바스크립트 파일 기준으로 얻을 수 있는 정보는 다음과 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;app-abc123.js:1:120&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 로그가 우리에게 알려주는 정보는 딱 두 가지다.&lt;/li&gt;
&lt;li&gt;이 에러는 app-abc123.js 파일에서 발생했고, 1번째 줄 120번째 컬럼에서 터졌다는 사실!&lt;/li&gt;
&lt;li&gt;하지만 여기엔 디버깅에 필요한 결정적인 정보가 빠져 있다.&lt;ul&gt;
&lt;li&gt;이 코드가 원래 어떤 파일에서 왔는지&lt;/li&gt;
&lt;li&gt;내가 작성한 코드인지&lt;/li&gt;
&lt;li&gt;아니면 라이브러리 / 프레임워크 내부 코드인지&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;즉, &amp;quot;어디서 에러가 났는지(물리적 좌표)&amp;quot;는 알 수 있지만&lt;/li&gt;
&lt;li&gt;&amp;quot;무슨 코드에서 에러가 났는지(맥락)&amp;quot;는 알 수 없다. 바로 이 간극을 메워주는 것이 &lt;code&gt;sourcemap&lt;/code&gt;이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(3) 좌표를 바꿔주는 지도 (Coordinate Map)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sourcemap&lt;/code&gt;은 ‘코드 복원기’라기보다‘좌표 변환 지도’에 가깝다.&lt;/li&gt;
&lt;li&gt;만약 다음과 같은 (a) 입력이 있다면, sourcemap은 그에 대응되는 (b) 위치 정보를 찾아준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;(a) 입력: 빌드된 JS 파일의 위치 (line, column)

⬇️

(b) 출력: 원본 소스 파일의 위치 (file, line, column)&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;즉, sourcemap은 “이 코드가 어디서 왔는지”를 계산해내는 도구가 아니라,&lt;br&gt;  빌드 과정에서 미리 기록해 둔 좌표 관계를 조회하기 위한 데이터다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;브라우저는 이 좌표 관계를 이용해, 사람이 이해할 수 있는 에러 위치를 복원한다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그래서 브라우저는 sourcemap에게 이런 질문을 던진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;Q. 브라우저:
&amp;quot;이 번들 파일(app.js)의 1번째 줄, 120번째 컬럼은
원래 어떤 파일의 어디였어?&amp;quot;

A. Source Map:
&amp;quot;원본 코드와 빌드된 코드 사이의 좌표 관계를 기준으로 보면,
그 위치는 ExampleComponent.vue 파일의 15번째 줄이야.&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;h3&gt;(4) .map 파일을 뜯어보자&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;실제 .map 파일을 열어보면 보통 다음과 같은 JSON 구조를 띄고 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;{
  &amp;quot;version&amp;quot;: 3,
  &amp;quot;file&amp;quot;: &amp;quot;app-abc123.js&amp;quot;,
  &amp;quot;sources&amp;quot;: [&amp;quot;ExampleComponent.vue&amp;quot;],
  &amp;quot;sourcesContent&amp;quot;: [&amp;quot;&amp;lt;template&amp;gt;...&amp;lt;/template&amp;gt;&amp;quot;],
  &amp;quot;mappings&amp;quot;: &amp;quot;oGAIA,SAASA,GAAM,...&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;처음 보면 알 수 없는 문자열(mappings) 때문에 이해하기 어렵다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;하지만 여기서 주목해야 할 포인트는 명확하다. &lt;strong&gt;핵심은 &amp;quot;텍스트 치환&amp;quot;이 아니라 &amp;quot;위치 관계&amp;quot;다&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;sourcemap은 전체 코드를 1:1로 치환하는 도구가 아니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;본질은 좌표에 있다. sourcemap은 다음 질문에 답하기 위해 존재한다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이 에러가 발생한 위치(line, column)는 원본 코드 기준으로 어디이며,&lt;br&gt;그 위치에 있던 변수의 원래 이름은 무엇인가?&amp;quot;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;즉, sourcemap이 제공하는 정보의 핵심은 좌표(Coordinate)다.&lt;/li&gt;
&lt;li&gt;빌드된 JS의 몇 번째 줄, 몇 번째 컬럼이 원본 소스의 어떤 파일, 어떤 위치에 대응되는지 그 관계를 기록해 둔 것이 바로 .map 파일이다.&lt;/li&gt;
&lt;li&gt;여기서 &lt;code&gt;sourcesContent&lt;/code&gt; 필드에 원본 코드의 전체 텍스트가 함께 포함되는 경우도 많다.&lt;/li&gt;
&lt;li&gt;브라우저가 원본 파일을 별도로 요청하지 않고도 개발자 도구에서 바로 코드를 보여주기 위해서다.&lt;/li&gt;
&lt;li&gt;하지만 이건 어디까지나 편의 기능이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;sourcemap의 본질적인 역할은 여전히 하나다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;빌드된 코드와 원본 코드 사이의 &amp;quot;위치 관계&amp;quot;를 알려주는 디버깅용 지도!&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;이 구조를 이해하면, 왜 .map 파일이 노출되면 위험한지,&lt;/li&gt;
&lt;li&gt;그리고 왜 운영 환경에서는 이 파일을 함부로 다루면 안 되는지도 알게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;2) sourcemap은 언제, 어떻게 만들어질까&lt;/h2&gt;
&lt;h3&gt;(1) sourcemap은 빌드 과정에서 &amp;#39;필연적으로&amp;#39; 생성된다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;sourcemap은 빌드 도구가 코드를 변환하는 과정에서 자동으로 생성해내는 산출물이다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;그래서 sourcemap의 생성 원리를 이해하려면 빌드 과정을 알아야 한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;우리가 평소에 작성하는 코드는 보통 이런 모습이다. 사람이 읽고 이해하기 좋은 형태다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// TypeScript
function boom() {
  throw new Error(&amp;quot;Test error&amp;quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;


&lt;ul&gt;
&lt;li&gt;혹은 Vue를 쓴다면 이런 형태일 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!-- Vue SFC --&amp;gt;
&amp;lt;script setup lang=&amp;quot;ts&amp;quot;&amp;gt;
function boom() {
  throw new Error(&amp;quot;Test error&amp;quot;);
}
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;하지만 중요한 사실이 하나 있다.&lt;/li&gt;
&lt;li&gt;브라우저는 이 코드를 그대로 실행할 수 없다. (혹은 실행할 수 있어도 매우 비효율적이다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 빌드 도구는 코드를 &amp;#39;실행용 언어&amp;#39;로 번역한다&lt;/h3&gt;
&lt;p&gt;Vite, Webpack, Rollup 같은 빌드 도구들은 브라우저를 대신해 &amp;#39;번역&amp;#39; 역할을 수행한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;변환(Transpile):&lt;/strong&gt; TypeScript나 최신 JS 문법을 구형 브라우저도 이해할 수 있는 JS로 바꾼다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;번들링(Bundling):&lt;/strong&gt; 수백 개의 파일을 로딩하기 좋게 몇 개의 파일로 합친다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;압축(Minify):&lt;/strong&gt; 공백, 줄바꿈을 제거하고 긴 변수명을 &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt;처럼 짧게 줄여 용량을 줄인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 과정을 거치고 나면 우리는 이런 결과물을 얻게 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// ExampleComponent-1B51Wyje.js

function n(){throw new Error(&amp;quot;Test error&amp;quot;)}...&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;h3&gt;(3) 빌드 결과물에는 &amp;quot;맥락(Context)&amp;quot;이 없다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;빌드가 끝난 JS 파일을 들여다보면, 처음에 작성했던 코드를 보기 어렵다.&lt;ul&gt;
&lt;li&gt;이 코드가 원래 어떤 파일이었는지 모름&lt;/li&gt;
&lt;li&gt;원본 코드의 몇 번째 줄이었는지 모름&lt;/li&gt;
&lt;li&gt;어떤 함수나 변수명을 가지고 있었는지 모름&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;빌드 도구의 목표는 단 하나, &amp;quot;브라우저가 가장 빠르고 효율적으로 실행할 수 있는 코드&amp;quot;를 만드는 것이다.&lt;/li&gt;
&lt;li&gt;브라우저 입장에서는 원본 파일명이 무엇인지, 개발자가 주석을 어떻게 달았는지는 전혀 중요하지 않다.&lt;/li&gt;
&lt;li&gt;그래서 빌드 결과물에는 &amp;#39;실행&amp;#39;에 필요 없는 모든 정보가 제거된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(4) 여기서 문제가 발생한다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;문제는 에러가 났을 때다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;ExampleComponent-1B51Wyje.js:1:120&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;p&gt;개발자는 이 로그만 보고는 아무것도 할 수 없다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&amp;quot;120번째 글자에서 에러가 났다&amp;quot;는 사실만으로는 디버깅이 어렵기 때문이다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;바로 이 지점에서 sourcemap이 등장한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;빌드 도구는 코드를 최적화하면서, 원본과의 관계를 기록한 &amp;#39;별도의 설명서&amp;#39;인 .map을 만들어낸다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;즉, 빌드는 실행을 위해 맥락을 버리고, sourcemap은 디버깅을 위해 그 맥락을 따로 보존한다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(5) sourcemap은 빌드의 입력이 아니라 &amp;#39;결과&amp;#39;다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;가장 중요한 포인트는 sourcemap은 빌드 과정이 끝나야 완성되는 결과물이라는 점이다.&lt;/li&gt;
&lt;li&gt;빌드 파이프라인을 단순화하면 다음과 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;60%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/choNnz/dJMcagRzBUl/j3zHtioydVn4pBtlW28Dnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/choNnz/dJMcagRzBUl/j3zHtioydVn4pBtlW28Dnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/choNnz/dJMcagRzBUl/j3zHtioydVn4pBtlW28Dnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FchoNnz%2FdJMcagRzBUl%2Fj3zHtioydVn4pBtlW28Dnk%2Fimg.png&quot; width=&quot;60%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;코드를 변환(Transpile)할 때마다 &amp;quot;이 줄은 원래 저기서 왔다&amp;quot;는 기록을 남기고,&lt;/li&gt;
&lt;li&gt;마지막에 이를 모아 &lt;code&gt;.map&lt;/code&gt; 파일로 내보낸다.&lt;/li&gt;
&lt;li&gt;그래서 sourcemap은 항상 번들된 파일과 &amp;#39;한 쌍(Pair)&amp;#39;으로 존재한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(6) 빌드 옵션의 의미: &amp;quot;기록을 남길 것인가?&amp;quot;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Webpack의 &lt;code&gt;devtool&lt;/code&gt;이나 Vite의 &lt;code&gt;build.sourcemap&lt;/code&gt; 옵션은 결국 빌드 도구에게 이런 지시를 내리는 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;&amp;quot;코드를 변환할 때, 원본 위치 정보도 같이 기록해서 파일로 만들어줄래?&amp;quot;&lt;/em&gt;&lt;/p&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;이 옵션은 크게 세 가지 동작으로 나뉘는데, 이는 뒤에서 다룰 보안 및 운영 전략의 핵심이 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;옵션&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;동작 방식&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;false&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;기록하지 않음&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;- &lt;code&gt;.map&lt;/code&gt; 파일이 생성되지 않는다. &lt;br/&gt;- 디버깅 불가.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;true&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;기록함 + &lt;strong&gt;꼬리표 부착&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;- &lt;code&gt;.map&lt;/code&gt; 파일을 생성한다.&lt;br/&gt;- JS 파일 끝에 &lt;code&gt;//# sourceMappingURL=...&lt;/code&gt; 주석을 달아 브라우저가 원본 파일을 자동으로 찾게 한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;hidden&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;기록함 + &lt;strong&gt;꼬리표 없음&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;- &lt;code&gt;.map&lt;/code&gt; 파일은 생성한다.&lt;br/&gt;- 하지만, JS 파일에 연결 고리(주석)를 남기지 않는다.  그래서 브라우저는 자동으로 원본 파일을 찾을 수 없다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;3) map 파일은 어떻게 원본 위치를 찾아낼까?&lt;/h2&gt;
&lt;h3&gt;(1) 좌표값으로 위치를 찾는다.&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;다시 처음 상황으로 돌아가 보자. 브라우저 콘솔에 다음과 같은 에러가 찍혔다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;ExampleComponent-1B51Wyje.js:1:120&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 로그가 알려주는 정보는 매우 제한적이다.&lt;/li&gt;
&lt;li&gt;파일(ExampleComponent)에서 에러가 났는데, 위치가 1번째 줄, 120번째 컬럼이라는 것만 안다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  이 숫자(1:120)을 보고 브라우저는 어떻게 원본 파일의 정확한 위치를 찾아내는 걸까?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;원리는 생각보다 단순하다. sourcemap의 본질은 좌표 대응표(Lookup Table)다.&lt;/li&gt;
&lt;li&gt;복잡한 알고리즘으로 추론하는 게 아니라, &amp;quot;이 위치는 원래 여기야&amp;quot;라고 기록해 둔 장부(Map)를 펼쳐보는 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;(빌드된 코드의 좌표) ⇄ (원본 코드의 좌표)&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;즉, 디코딩이란 다음 질문의 답을 찾아가는 과정이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;&amp;quot;이 난독화된 JS 파일의 1행 120열은, 원래 어떤 파일의 몇 번째 줄이었어?&amp;quot;&lt;/em&gt;&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;(2) .map 파일의 핵심 3대장&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;실제 .map 파일의 내부를 다시 살펴보자.&lt;/li&gt;
&lt;li&gt;수많은 필드가 있지만, 실제 위치 추적(디코딩)에 사용되는 것은 딱 세 가지다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;{
  &amp;quot;version&amp;quot;: 3,
  &amp;quot;file&amp;quot;: &amp;quot;ExampleComponent-1B51Wyje.js&amp;quot;,
  &amp;quot;sources&amp;quot;: [&amp;quot;../../src/components/ExampleComponent.vue&amp;quot;],
  &amp;quot;names&amp;quot;: [&amp;quot;boom&amp;quot;, &amp;quot;_openBlock&amp;quot;, &amp;quot;_createElementBlock&amp;quot;],
  &amp;quot;mappings&amp;quot;: &amp;quot;oGAIA,SAASA,GAAM,...&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;a. sources: &amp;quot;어디서 왔는가 (원본 파일 목록)&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;quot;sources&amp;quot;: [&amp;quot;../../src/components/ExampleComponent.vue&amp;quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 번들 파일 하나를 만드는 데 사용된 원본 파일들의 경로 리스트다.&lt;/li&gt;
&lt;li&gt;배열의 인덱스로 관리된다. 즉, sources[0]은 ExampleComponent.vue를 가리킨다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;b. names: &amp;quot;무엇이었는가 (이름 사전)&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;quot;names&amp;quot;: [&amp;quot;boom&amp;quot;, &amp;quot;_openBlock&amp;quot;, &amp;quot;_createElementBlock&amp;quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;선택 사항이지만 가독성을 위해 매우 중요한 필드다.&lt;/li&gt;
&lt;li&gt;빌드 과정에서 boom이라는 함수명이 n으로 난독화되었다면, 에러 스택에는 at n (...)이라고 뜰 것이다.&lt;/li&gt;
&lt;li&gt;이때 이 사전을 참조해 n이 원래 boom이었다는 것을 복구해 준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;c. mappings: &amp;quot;어떻게 연결되는가 (압축된 좌표)&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;quot;mappings&amp;quot;: &amp;quot;oGAIA,SAASA,GAAM,...&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;그리고 핵심 중의 핵심.&lt;/li&gt;
&lt;li&gt;이 암호 같은 문자열 속에 빌드된 코드와 원본 코드의 모든 좌표 연결 정보가 압축되어 들어있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(3) mappings는 어떻게 읽히는가?&lt;/h3&gt;
&lt;p&gt;디코딩 라이브러리(브라우저 내장 기능)가 하는 일을 순서대로 풀어보면 다음과 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;입력:&lt;/strong&gt; 에러가 발생한 JS 좌표를 받는다. (Line: 1, Column: 120)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;탐색:&lt;/strong&gt; mappings 문자열을 해석하여 해당 좌표가 속한 구간(Segment)을 찾는다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;해독:&lt;/strong&gt; 그 구간에 저장된 정보를 바탕으로 다음 4가지를 알아낸다.&lt;ul&gt;
&lt;li&gt;Source Index (몇 번째 소스 파일인가?)&lt;/li&gt;
&lt;li&gt;Original Line (원본 몇 번째 줄인가?)&lt;/li&gt;
&lt;li&gt;Original Column (원본 몇 번째 칸인가?)&lt;/li&gt;
&lt;li&gt;Name Index (원래 이름은 무엇인가?)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;출력:&lt;/strong&gt; 최종적으로 우리가 보는 정렬된 에러 로그를 반환한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;// 디코딩 결과
{
  source: &amp;quot;ExampleComponent.vue&amp;quot;,
  line: 4,
  column: 8,
  name: &amp;quot;boom&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;h3&gt;(4) 왜 mappings는 암호처럼 생겼을까?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;여기서 자연스럽게 의문이 생긴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  그냥 JSON으로 { line: 4, column: 8 } 이렇게 저장하면 안 되나? 왜 이상한 문자로 저장하지?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;이유는 &lt;strong&gt;파일 크기&lt;/strong&gt; 때문이다.&lt;/li&gt;
&lt;li&gt;만약 모든 글자마다 매핑 정보를 객체로 저장한다면 .map 파일의 크기는 원본 JS 파일보다 수십 배, 수백 배 커질 것이다.&lt;/li&gt;
&lt;li&gt;그래서 sourcemap은 VLQ(Variable-length quantity)라는 방식을 사용해 이전 값과의 차이(Delta)만을 기록한다.&lt;ul&gt;
&lt;li&gt;&amp;quot;좌표 120번&amp;quot;이라고 쓰는 대신 &amp;quot;아까 거기서 +1칸 옆&amp;quot;&lt;/li&gt;
&lt;li&gt;&amp;quot;4번째 줄&amp;quot;이라고 쓰는 대신 &amp;quot;아까 그 줄 그대로(+0)&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이 방식을 통해 파일 용량을 획기적으로 줄일 수 있었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(5) 효율적이지만, 치명적인 단점: &amp;quot;부서지기 쉽다&amp;quot;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;mappings 구조는 용량 면에서는 효율적이지만, 디버깅 관점에서 &amp;#39;부서지기 쉬운(Fragile)&amp;#39; 구조다.&lt;/li&gt;
&lt;li&gt;모든 위치 정보가 이전 위치에 의존적이기 때문이다.&lt;/li&gt;
&lt;li&gt;즉, 맨 앞의 글자 하나만 달라져도 그 뒤에 오는 모든 좌표 계산이 틀어진다.&lt;/li&gt;
&lt;li&gt;이것이 의미하는 바는 명확하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  sourcemap에는 &amp;quot;비슷한 버전&amp;quot;이란 없다. &amp;quot;정확한 버전&amp;quot; 아니면 &amp;quot;완전한 오답&amp;quot; 뿐이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;빌드 시점이 다르거나, 아주 사소한 플러그인 설정 변경으로 JS 파일의 공백 하나가 달라졌다고 가정해 보자.&lt;/li&gt;
&lt;li&gt;Delta 인코딩 특성상, 그 지점부터 뒤에 있는 모든 에러 스택의 줄 번호가 밀려버린다.&lt;/li&gt;
&lt;li&gt;그래서 Sentry 같은 에러 모니터링 도구는 &lt;strong&gt;릴리즈(Release)&lt;/strong&gt; 버전을  중요하게 여긴다.&lt;/li&gt;
&lt;li&gt;&amp;quot;이 버전의 JS 파일에는 반드시, 정확히 이 버전의 sourcemap만 적용한다&amp;quot;는 원칙이 지켜지지 않으면, 디버깅 자체가 불가능하기 때문이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;4) 왜 .map 파일 노출은 위험한가&lt;/h2&gt;
&lt;h3&gt;(1) .map만 있으면 원본 소스를 볼 수 있다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;여기까지 이해했다면, 자연스럽게 하나의 질문이 떠오를 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  &amp;quot;잠깐… .map 파일에 원본 파일 경로랑 위치 정보가 다 들어 있다면, 이거 위험한 거 아닌가?&amp;quot;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;맞다. .map 파일만 있으면, 제3자가 원본 소스 코드를 복원할 수 있다.&lt;/li&gt;
&lt;li&gt;다시 .map 파일의 구조를 들여다보자.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;{
  &amp;quot;sources&amp;quot;: [&amp;quot;../../src/components/ExampleComponent.vue&amp;quot;],
  &amp;quot;sourcesContent&amp;quot;: [
    &amp;quot;&amp;lt;template&amp;gt;\n  &amp;lt;button @click=\&amp;quot;boom\&amp;quot;&amp;gt;Throw Error&amp;lt;/button&amp;gt;\n&amp;lt;/template&amp;gt;\n...&amp;quot;
  ],
  &amp;quot;mappings&amp;quot;: &amp;quot;oGAIA,SAASA,GAAM,...&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;여기서 가장 치명적인 필드는 바로 &lt;code&gt;sourcesContent&lt;/code&gt;다.&lt;/li&gt;
&lt;li&gt;이 필드에는 원본 파일의 전체 텍스트가 그대로 담겨 있는 경우가 많다.&lt;/li&gt;
&lt;li&gt;브라우저가 원본 파일을 별도로 요청하지 않고도 개발자 도구에서 코드를 보여줄 수 있는 이유가 바로 이 데이터 덕분이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 그럼 진짜로 파일까지 복원할 수 있다는 건가?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;.map 파일 하나만 있으면 된다.&lt;/li&gt;
&lt;li&gt;아래는 .map 파일을 입력으로 받아 원본 소스 파일을 복원하는 스크립트의 핵심 로직이다. (전체 코드는 &lt;a href=&quot;https://www.google.com/url?sa=E&amp;amp;q=https%3A%2F%2Fgithub.com%2FKumJungMin%2Fsourcemap-test&quot;&gt;여기&lt;/a&gt;서 볼 수 있다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import fs from &amp;#39;fs&amp;#39;;
import { SourceMapConsumer } from &amp;#39;source-map&amp;#39;;

async function restoreSource(mapFilePath) {
  const mapContent = fs.readFileSync(mapFilePath, &amp;#39;utf8&amp;#39;);
  const sourceMap = JSON.parse(mapContent);

  const consumer = await new SourceMapConsumer(sourceMap);

  // sources 배열을 순회하며 원본 코드를 꺼냄
  sourceMap.sources.forEach((sourcePath) =&amp;gt; {
    //   핵심: sourceContentFor 메서드가 원본 코드를 리턴함
    const sourceContent = consumer.sourceContentFor(sourcePath);

    if (sourceContent) {
      fs.writeFileSync(`./restored/${path.basename(sourcePath)}`, sourceContent);
      console.log(`✅ 복원 완료: ${sourcePath}`);
    }
  });

  consumer.destroy();
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 스크립트가 하는 일은 단순하다.&lt;/li&gt;
&lt;li&gt;소스맵 내부에 저장된 sourcesContent (원본 코드 텍스트)를 읽어서 파일로 저장하는 것뿐이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;예시로 ExampleComponent.js.map 파일 하나만 스크립트에 넣고 돌려보았다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// ExampleComponent.map.js (입력: .map 파일)
{
  &amp;quot;version&amp;quot;:3,
  &amp;quot;sources&amp;quot;:[&amp;quot;../../src/components/ExampleComponent.vue&amp;quot;],
  &amp;quot;sourcesContent&amp;quot;:[&amp;quot;&amp;lt;template&amp;gt;\n  &amp;lt;button @click=\&amp;quot;boom\&amp;quot;&amp;gt;Throw Error&amp;lt;/button&amp;gt;...&amp;quot;],
  &amp;quot;mappings&amp;quot;:&amp;quot;oGAIA...&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;그러면 다음과 같이 주석, 공백, 변수명(boom)까지 포함괸 원본 파일이 출력된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!-- 복원된 파일: ExampleComponent.vue (출력: 원본 코드) --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;button @click=&amp;quot;boom&amp;quot;&amp;gt;Throw Error&amp;lt;/button&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup lang=&amp;#39;ts&amp;#39;&amp;gt;
function boom(){
  throw new Error(&amp;quot;Test error&amp;quot;);
}
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;h3&gt;(3) 난독화해도 안전하지 않을까?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;여기서 알 수 있는 사실은 명확하다.&lt;/li&gt;
&lt;li&gt;sourcemap이 함께 배포되었다면, 난독화된 JS는 보안상 아무런 의미가 없다.&lt;/li&gt;
&lt;li&gt;왜냐하면 .map 파일과 빌드 파일만 있다면, .map 안에 들어 있는 &amp;quot;원본&amp;quot;을 꺼낼 수 있기 때문이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;5) 그래서 운영 환경에서는 어떻게 해야 할까?&lt;/h2&gt;
&lt;p&gt;이제 다시 빌드 옵션 이야기로 돌아가 보자. 운영(Production) 환경에서 어떤 전략을 취해야 할까?&lt;/p&gt;
&lt;h3&gt;(1) 위험한 설정: sourcemap: true&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;동작:&lt;/strong&gt; .map 파일을 생성하고, JS 파일 맨 끝에 &lt;code&gt;//# sourceMappingURL=app.js.map&lt;/code&gt;이라는 &amp;quot;안내 표지판(주석)&amp;quot;을 달아둔다.&lt;/li&gt;
&lt;li&gt;결과: 브라우저 개발자 도구는 이 주석을 보고 자동으로 .map 파일을 찾아 다운로드한다.&lt;/li&gt;
&lt;li&gt;문제점: 개발자뿐만 아니라, 사용자의 브라우저도 똑같이 소스맵을 다운로드해서 원본 코드를 보여준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(2) 현실적인 절충안: sourcemap: &amp;#39;hidden&amp;#39;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;동작: .map 파일은 똑같이 생성한다. 하지만 JS 파일 끝에 &amp;quot;안내 표지판(주석)&amp;quot;을 달지 않는다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;결과:&lt;/strong&gt; 브라우저는 .map 파일이 존재하는지조차 모른다. 그래서 다운로드를 시도하지 않고, 원본 코드도 노출되지 않는다.&lt;/li&gt;
&lt;li&gt;활용: &amp;quot;연결 고리&amp;quot;가 끊겨 있을 뿐 파일은 존재한다.&lt;ul&gt;
&lt;li&gt;Sentry 같은 도구에 이 .map 파일을 업로드하면, 도구가 내부적으로 매칭해서 에러를 복원해 준다.&lt;/li&gt;
&lt;li&gt;앞서 소개한 로컬 CLI 도구를 사용해 수동으로 복원할 수도 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(3) 가장 안전하지만 불편한 설정: sourcemap: false&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;동작: .map 파일 자체를 아예 만들지 않는다.&lt;/li&gt;
&lt;li&gt;결과: 원본 코드로 돌아갈 방법이 아예 사라진다. 보안은 완벽하지만, 개발자조차 디버깅을 포기해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;6) 그럼 개발자는 어떻게 디버깅할까?&lt;/h2&gt;
&lt;h3&gt;(1) 에러 모니터링 도구를 쓰면 된다! 환경이 허락한다면…&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;여기서 현실적인 문제가 하나 남는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt; sourcemap을 hidden으로 숨겼다면,&lt;br&gt;우리는 운영 에러를 어떻게 &lt;strong&gt;원본 기준&lt;/strong&gt;으로 확인해야 할까?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;일반적인 환경이라면 답은 간단하다.&lt;/li&gt;
&lt;li&gt;Sentry, Datadog 같은 에러 모니터링 서비스(SaaS)를 사용하면 된다.&lt;/li&gt;
&lt;li&gt;우리가 .map 파일을 그쪽 서버로 업로드해주면, 그들이 매칭한 에러 로그를 보여준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;“하지만 모든 환경이 그럴 수 있는 건 아니다.”&lt;/em&gt;&lt;/p&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;금융권, 공공기관, 혹은 엄격한 내부망 환경에서는 다음과 같은 제약이 있을 수 있다.&lt;ul&gt;
&lt;li&gt;❌ .map 파일을 외부 서버로 업로드할 수 없다. (소스코드 유출 간주)&lt;/li&gt;
&lt;li&gt;❌ 외부 SaaS 서비스를 사용할 수 없다. (망분리 정책)&lt;/li&gt;
&lt;li&gt;❌ 프론트엔드 에러 로그를 네트워크 밖으로 전송하는 것 자체가 금지다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이 경우 대게 자체 로그툴이 있으나, 그 도구 역시 직접 로그를 남긴 코드에만 의존하는 경우가 많다.&lt;/li&gt;
&lt;li&gt;즉, 로그를 심어둔 지점에는 추적 가능하지만, 로그가 없는 코드는 난독화된 JS 기준으로만 보인다.&lt;/li&gt;
&lt;li&gt;로그를 심지 않은 경우, 운영 에러가 보통 이런 형태로 남는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;at ExampleComponent-Cq_iF_Ko.js:1:120
at index-CWMXbtJ-.js:13:38&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;결국 sourcemap 기반 디버깅이 필요하지만, 이를 사용할 수 있는 수단이 없는 상태다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) decode-sourcemap-cli, sourcemap을 로컬에서만 디코딩하기&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;이 문제를 해결하기 위해, 나는 로컬에서 sourcemap을 직접 디코딩하는 CLI 도구를 만들었다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;decode-sourcemap-cli&lt;/code&gt;의 핵심은 단순하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  운영 환경의 에러를, 서버 없이, 네트워크 없이, 오직 빌드 산출물만으로 디버깅하자.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;이 도구는 다음을 전제로 한다.&lt;ul&gt;
&lt;li&gt;운영에 배포된 JS 파일과 &lt;code&gt;.map&lt;/code&gt; 파일이 로컬에 존재하고&lt;/li&gt;
&lt;li&gt;해당 파일들은 동일한 릴리즈 기준으로 생성되었으며&lt;/li&gt;
&lt;li&gt;외부 서비스로 어떤 데이터도 업로드하지 않는다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;즉, 디버깅의 기준을 ‘에러 로그’가 아니라 ‘로컬에 보관된 릴리즈 산출물’로 옮기는 전략이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(3) 어떻게 사용하는가?&lt;/h3&gt;
&lt;p&gt;사용 방식은 매우 단순하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 준비:&lt;/strong&gt; 운영 서버에 배포된 것과 동일한 릴리즈 기준으로 로컬 빌드를 수행한다. (&lt;em&gt;로컬에서 .map이 산출되게&lt;/em&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 실행:&lt;/strong&gt; 프로젝트 루트에서 다음 명령어를 입력한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx dsm&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;3. 선택:&lt;/strong&gt; 만약 모노레포 환경이라면, 어떤 앱을 디버깅할지 선택하는 화면이 나온다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Iu0aT/dJMcajnbjJH/eZzkdQbolFx4b0MoATz5z0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Iu0aT/dJMcajnbjJH/eZzkdQbolFx4b0MoATz5z0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Iu0aT/dJMcajnbjJH/eZzkdQbolFx4b0MoATz5z0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIu0aT%2FdJMcajnbjJH%2FeZzkdQbolFx4b0MoATz5z0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;4. 입력:&lt;/strong&gt; 브라우저 콘솔이나 로그 파일에서 에러 스택 트레이스(Minified)를 복사해서 그대로 붙여 넣는다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/x6xO7/dJMcai2SDBF/HsppJ5NbGMgXQOFEK5KN50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/x6xO7/dJMcai2SDBF/HsppJ5NbGMgXQOFEK5KN50/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/x6xO7/dJMcai2SDBF/HsppJ5NbGMgXQOFEK5KN50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fx6xO7%2FdJMcai2SDBF%2FHsppJ5NbGMgXQOFEK5KN50%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IC7rC/dJMcai9EsFX/cJMfok1Og0CzH8OTimu3DK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IC7rC/dJMcai9EsFX/cJMfok1Og0CzH8OTimu3DK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IC7rC/dJMcai9EsFX/cJMfok1Og0CzH8OTimu3DK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIC7rC%2FdJMcai9EsFX%2FcJMfok1Og0CzH8OTimu3DK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;5. 실행:&lt;/strong&gt; 도구는 내부적으로 다음 과정을 수행한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;에러 스택에 등장하는 JS 파일명(index-abc.js)을 파싱하고

로컬 dist 폴더에서 짝이 맞는 .map 파일을 찾아낸 뒤

source-map 라이브러리로 좌표를 역추적한다.&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;6. 결과:&lt;/strong&gt; 잠시 후, 터미널에 &lt;strong&gt;원본 파일 경로와 정확한 라인 넘버&lt;/strong&gt;가 출력된다.  (Cmd+Click로 바로 이동도 가능)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dRFxHJ/dJMcacuSG2S/VXyAZX1BehC6vJBOZYNvJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dRFxHJ/dJMcacuSG2S/VXyAZX1BehC6vJBOZYNvJ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dRFxHJ/dJMcacuSG2S/VXyAZX1BehC6vJBOZYNvJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdRFxHJ%2FdJMcacuSG2S%2FVXyAZX1BehC6vJBOZYNvJ1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h3&gt;(4) 왜 이 도구는 &amp;#39;Strict&amp;#39; 할까?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;이 도구를 만들 때 가장 신경 쓴 부분은 앞서 4번 챕터에서 다룬 &amp;quot;소스맵의 버전 민감성&amp;quot;이다.&lt;/li&gt;
&lt;li&gt;소스맵은 조금만 버전이 달라도 전체 좌표가 어긋나는 &amp;#39;부서지기 쉬운&amp;#39; 구조라고 했다.&lt;/li&gt;
&lt;li&gt;그래서 decode-sourcemap-cli는 기본적으로 &lt;strong&gt;strict 전략&lt;/strong&gt;을 사용한다.&lt;/li&gt;
&lt;li&gt;단순히 파일명(app.js)만 같은 게 아니라, 해시값(app-a1b2c.js)까지 정확히 일치해야 디코딩을 시도한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;물론 예외적인 상황도 고려했다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;코드가 100% 동일하더라도, 로컬 환경(Mac/Windows)과 CI/CD 서버(Linux)의 환경 차이로 인해 빌드 해시값이 미세하게 달라지는 경우가 종종 발생한다.&lt;/li&gt;
&lt;li&gt;이런 상황을 위해, 해시값 검증을 건너뛰고 파일명만으로 매칭하는 &lt;strong&gt;filename 전략&lt;/strong&gt;(--strategy=filename)도 만들었다.&lt;/li&gt;
&lt;li&gt;단, 이 옵션은 정확도를 보장할 수 없기에 &amp;#39;힌트&amp;#39;를 얻는 용도로만 사용하는 게 좋다.&lt;/li&gt;
&lt;li&gt;자세한 사용법과 전략, 단일 앱 / 모노레포 환경에서의 설정 방법은 아래 저장소에 정리해 두었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;  &lt;a href=&quot;https://github.com/KumJungMin/sourcemap-tools/tree/main/packages/decode-sourcemap-cli&quot;&gt;https://github.com/KumJungMin/sourcemap-tools/tree/main/packages/decode-sourcemap-cli&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;2. 마치며…&lt;/h1&gt;
&lt;p&gt;지금까지 sourcemap의 원리부터 보안 이슈, 그리고 Sentry의 철학까지 살펴보았다.&lt;/p&gt;
&lt;p&gt;긴 글을 세 줄로 요약하면 다음과 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;sourcemap은 코드 복원기가 아니라 &amp;#39;좌표 변환 지도&amp;#39;다.&lt;/li&gt;
&lt;li&gt;운영 환경에 sourcemap: true로 배포하는 건 원본 코드 노출의 위험이 크다.&lt;/li&gt;
&lt;li&gt;소스맵은 버전 민감도가 매우 높으므로, CI/CD 파이프라인에서 릴리즈 단위로 철저히 관리해야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;개발자에게 디버깅 경험(DX)은 포기할 수 없는 가치다. 하지만 그것이 서비스와 사용자의 보안을 위협하는 핑계가 되어서는 안 된다.&lt;/p&gt;
&lt;p&gt;가장 이상적인 그림은 &amp;quot;사용자에게는 코드를 숨기되(hidden), 개발자는 언제든 원본을 볼 수 있는&amp;quot; 환경을 만드는 것이다. 상황에 따라 Sentry 같은 SaaS를 쓰든, 오늘 소개한 decode-sourcemap-cli 같은 로컬 도구를 쓰든, 보안과 효율이라는 모두 챙길 수 있는 환경을 구축하길 바란다.&lt;/p&gt;
&lt;br/&gt;</description>
      <category>개발 기술/사소하지만 놓치기 쉬운 개발 지식</category>
      <category>'hidden'</category>
      <category>CLI</category>
      <category>Debug</category>
      <category>decode</category>
      <category>frontend</category>
      <category>javascript</category>
      <category>JS</category>
      <category>source-map-js</category>
      <category>sourcemap</category>
      <category>보안</category>
      <author>GicoMomg</author>
      <guid isPermaLink="true">https://mong-blog.tistory.com/239</guid>
      <comments>https://mong-blog.tistory.com/entry/sourcemap-%EC%9A%B4%EC%98%81%EC%97%90%EC%84%9C%EB%8A%94-%EA%B7%B8%EB%83%A5-%EB%81%84%EB%8A%94-%EC%98%B5%EC%85%98-%EC%95%84%EB%8B%88%EC%97%90%EC%9A%94#entry239comment</comments>
      <pubDate>Sat, 13 Dec 2025 15:40:49 +0900</pubDate>
    </item>
    <item>
      <title>브라우저 렌더링 최적화를 위한 Virtual Scroll - 구현 코드 살펴보기</title>
      <link>https://mong-blog.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-Virtual-Scroll-%EA%B5%AC%ED%98%84-%EC%BD%94%EB%93%9C-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0</link>
      <description>&lt;h1&gt;0. 들어가며&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;1편에서는 Virtual Scroll이 어떤 원리로 렌더링 비용을 줄이는지,&lt;/li&gt;
&lt;li&gt;그리고 실제 Chrome Performance Trace에서 Layout·Paint·GPU Memory가 얼마나 절감되는지를 살펴보았다.&lt;/li&gt;
&lt;li&gt;Virtual Scroll은 얼핏 보면 “DOM을 줄이고, translateY로 위치만 바꾸는” 단순한 구조처럼 보인다.&lt;/li&gt;
&lt;li&gt;하지만 실제로 구현해보면 다음과 같은 고민들이 바로 등장한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;1. 스크롤이 발생할 때 어떤 index부터 렌더링해야 할까?
2. 화면에 필요한 DOM pool 크기는 어떻게 계산할까?
3. Pool DOM은 어떤 방식으로 위치만 이동시켜 재사용할까?
4. 이미지 로딩처럼 아이템 높이가 바뀌면 offset을 어떻게 갱신해야 할까?
5. scroll 이벤트가 아주 자주 발생하는데, render 호출은 어떻게 제어해야 할까?&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;이번 시간(2편)에는 &lt;a href=&quot;https://mong-blog.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-Virtual-Scroll-%EC%9B%90%EB%A6%AC%EC%84%B1%EB%8A%A5-%EB%B9%84%EA%B5%90&quot;&gt;1편&lt;/a&gt;에서 다뤘던 Virtual Scroll의 구조를 기반으로, &lt;/li&gt;
&lt;li&gt;Virtual Scroll이 실제로 어떤 코드로 동작하는지를 단계별로 분석해보았다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;브라우저 렌더링 최적화를 위한 Virtual Scroll - 원리/성능 비교(&lt;a href=&quot;https://mong-blog.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-Virtual-Scroll-%EC%9B%90%EB%A6%AC%EC%84%B1%EB%8A%A5-%EB%B9%84%EA%B5%90&quot;&gt;링크&lt;/a&gt;)&lt;br&gt;브라우저 렌더링 최적화를 위한 Virtual Scroll - 구현 코드 살펴보기  ← 이번 글    &lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;1. Virtual Scroll 구조를 만드는 코드 살펴보기&lt;/h1&gt;
&lt;h2&gt;1) 구성 요소&lt;/h2&gt;
&lt;p&gt;Virtual Scroll의 핵심 구성 요소는 아래 세 가지이다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Item Pool&lt;/strong&gt; — 화면에 실제로 보이는 만큼만 존재하는 재사용 DOM(약 20–40개)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Container&lt;/strong&gt; — 스크롤이 발생하는 영역&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spacer&lt;/strong&gt; — 전체 리스트 높이를 대신 만드는 투명 div&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tnuO0/dJMcafLJZJi/scUCy9kQSVS4ni5INzs1xK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tnuO0/dJMcafLJZJi/scUCy9kQSVS4ni5INzs1xK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tnuO0/dJMcafLJZJi/scUCy9kQSVS4ni5INzs1xK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtnuO0%2FdJMcafLJZJi%2FscUCy9kQSVS4ni5INzs1xK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;br/&gt;


&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;그렇다면 스크롤이 발생했을 때 Virtual Scroll 내부에서는 어떤 일이 벌어질까?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;겉으로 보기엔 “수천 개의 DOM이 한 번에 모두 렌더링된 긴 리스트”처럼 보이지만,&lt;/li&gt;
&lt;li&gt;실제로는 세 요소가 다음 역할을 수행해 긴 리스트를 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;① Spacer — 전체 높이를 만드는 역할&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Spacer는 빈 div이지만, “전체 아이템 개수 × 아이템 높이”만큼의 높이를 가진다.&lt;/li&gt;
&lt;li&gt;브라우저는 이 높이를 보고 스크롤바를 만든다.&lt;/li&gt;
&lt;li&gt;덕분에 사용자는 “정말 긴 리스트를 스크롤하고 있다”고 자연스럽게 인식한다.&lt;/li&gt;
&lt;li&gt;하지만 실제 DOM은 여전히 몇 개뿐이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;② Item Pool — 고정된 수의 DOM만 유지&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;화면에 보이는 영역 + buffer 만큼의 DOM(약 20–40개)만 만든다.&lt;/li&gt;
&lt;li&gt;스크롤해도 DOM을 새로 생성하거나 삭제하지 않는다.&lt;/li&gt;
&lt;li&gt;대신 “이 DOM이 어떤 데이터를 표현할지”만 계속 교체한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;③ 스크롤 위치에 맞춰 Pool DOM의 역할을 교체&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;스크롤 값(&lt;code&gt;scrollTop&lt;/code&gt;)을 기반으로 “현재 어떤 index가 화면에 보여야 하는지” 계산한다.&lt;/li&gt;
&lt;li&gt;그리고 Pool DOM 각각의 &lt;strong&gt;translateY&lt;/strong&gt;로 위치를 옮기고 innerHTML을 사용해 새 데이터로 교체한다.&lt;/li&gt;
&lt;li&gt;DOM 개수는 그대로지만, 사용자 눈에는 “새로운 DOM이 등장한 것처럼” 보이게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;정리하면 Virtual Scroll은 다음 사이클을 계속 반복한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;(1) DOM은 그대로 유지 → (2) translateY로 위치 이동 → (3) 내용만 교체&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;그 결과, 화면에는 실제 DOM이 20~40개뿐이지만, 사용자 눈에는 “수천 개의 아이템이 모두 렌더링된 것처럼 보이게 된다.&lt;/li&gt;
&lt;li&gt;아래는 Virtual Scroll을 구성하는 주요 코드 역할 7가지다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;th&gt;코드&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;전체 높이 만들기&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_createSpacer()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;스크롤바가 생기도록 Spacer div 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DOM 풀 만들기&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_initPool()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;필요한 개수만큼 DOM을 미리 만들고 재사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스크롤 이벤트 최적화&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_onScroll()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rAF로 한 프레임 당 1번만 렌더&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;시작 인덱스 계산&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_findStartIndex()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;스크롤 위치 기반 이진 탐색&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DOM 배치&lt;/td&gt;
&lt;td&gt;&lt;code&gt;render()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;translateY로 DOM을 실제 위치처럼 이동&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;높이 업데이트&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_updateOffsets()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;각 아이템 top offset 및 spacer height 계산&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동적 높이 대응&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_observeResize()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ResizeObserver로 아이템 높이 변화 감지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;이제 다음 문단에서 각 역할이 어떻게 구현되는지 살펴보자. (전체 코드는 &lt;a href=&quot;https://github.com/KumJungMin/recycling-view-performance-check/blob/main/src/core/virtual-scroller.ts&quot;&gt;여기&lt;/a&gt;에서!)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;1) Spacer 생성 (_createSpacer)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Virtual Scroll에서는 화면에 실제로 렌더링되는 DOM이 약 20~40개뿐이다.&lt;/li&gt;
&lt;li&gt;하지만 데이터가 10,000개라면 실제 리스트 높이는 10,000 × 40px = 400,000px에 달한다.&lt;/li&gt;
&lt;li&gt;문제는 여기서 시작된다.&lt;/li&gt;
&lt;li&gt;실제 DOM은 30개뿐이기에, 브라우저 입장에서는 “리스트가 길다” 는 사실을 알 수 없다.&lt;/li&gt;
&lt;li&gt;즉, 스크롤할 수 있는 공간이 없으니 스크롤바도 생성되지 않는다.&lt;/li&gt;
&lt;li&gt;하지만 스크롤바가 없다면 사용자는 리스트 끝까지 탐색할 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) Spacer가 만들어내는 “가짜 전체 높이”&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;아래 그림처럼, 실제 화면에는 약 15개의 DOM만 렌더링된다.&lt;/li&gt;
&lt;li&gt;하지만 Spacer는 &lt;strong&gt;전체 아이템 개수 × 아이템 높이&lt;/strong&gt; 만큼의 커다란 빈 영역을 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bn5iQ0/dJMcaa4JDC1/YnbnYJ9J4kpPcPKCrMykck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bn5iQ0/dJMcaa4JDC1/YnbnYJ9J4kpPcPKCrMykck/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bn5iQ0/dJMcaa4JDC1/YnbnYJ9J4kpPcPKCrMykck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbn5iQ0%2FdJMcaa4JDC1%2FYnbnYJ9J4kpPcPKCrMykck%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;브라우저는 이 height를 기준으로 스크롤바를 만든다.&lt;/li&gt;
&lt;li&gt;사용자는 실제 DOM이 거의 없다는 사실을 모른 채, “정말 긴 리스트를 스크롤하고 있다”는 착각을  경험하게 된다.&lt;/li&gt;
&lt;li&gt;즉, Spacer는 스크롤 가능한 영역을 만드는 핵심 요소다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 코드 살펴보기&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;아래는 &lt;code&gt;_createSpacer()&lt;/code&gt;가 Spacer DOM을 생성하는 코드다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;private _createSpacer() {
  this.spacer = document.createElement(&amp;#39;div&amp;#39;)
  this.spacer.style.cssText = `position: relative; width: 100%;`
  this.container.appendChild(this.spacer)
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;여기서는 단순히 Spacer의 뼈대만 만든다.&lt;/li&gt;
&lt;li&gt;Spacer의 실제 높이(= 리스트 전체 길이)는 이후 &lt;code&gt;_updateOffsets()&lt;/code&gt; 단계에서 계산되어 적용된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;2) Item Pool 생성 (_initPool)&lt;/h2&gt;
&lt;h3&gt;(1) 왜 Pool이 필요한가?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Virtual Scroll의 핵심은 “화면에 보이는 만큼만 DOM을 유지하는 것”이다.&lt;/li&gt;
&lt;li&gt;그렇다면 스크롤할 때마다 화면에 들어온 DOM은 새로 만들고, 화면 밖으로 나간 DOM은 삭제하면 되지 않을까?&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;하지만 이 방식은 오히려 성능을 크게 떨어뜨린다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;그 이유는 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;DOM 생성/삭제는 매우 비싼 연산이다.&lt;br&gt;  &lt;code&gt;createElement&lt;/code&gt;, &lt;code&gt;appendChild&lt;/code&gt;, &lt;code&gt;removeChild&lt;/code&gt; 같은 조작은 브라우저가 많은 내부 작업을 수행해야 한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;DOM 트리가 변할 때마다 Layout → Paint 전체가 다시 계산된다.&lt;br&gt;  연속적인 DOM 추가·삭제는 렌더링 파이프라인 비용을 폭증시킨다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;빠른 스크롤에서 jank(튐)가 발생한다.&lt;br&gt;  스크롤 속도 &amp;gt; DOM 생성/삭제 속도가 되는 순간 화면이 끊긴다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이 문제를 해결하기 위한 구조가 바로 &lt;strong&gt;Item Pool&lt;/strong&gt;이다.&lt;br&gt;Pool은 “필요한 수(20~40개)만큼 DOM을 미리 만들어두고, 이 DOM을 계속 재사용하는 고정된 DOM 묶음”이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;Pool을 사용하면 스크롤 시 DOM을 새로 만들거나 삭제할 필요가 없다.&lt;/li&gt;
&lt;li&gt;대신 두 가지만 바꾸면 된다.&lt;ol&gt;
&lt;li&gt;이 DOM이 어떤 데이터를 표시할지(index) 재할당&lt;/li&gt;
&lt;li&gt;translateY로 DOM의 실제 위치만 이동&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;즉, DOM 자체는 그대로지만 역할만 계속 바뀐다.&lt;/li&gt;
&lt;li&gt;사용자 입장에서는 매번 새로운 아이템이 등장하는 것처럼 보인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;아래 그림은 같은 DOM이 스크롤에 따라 다른 아이템을 표현하는 과정이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sA2TE/dJMcagRpQIm/dpqBGDsnn6JeW96FZLB2IK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sA2TE/dJMcagRpQIm/dpqBGDsnn6JeW96FZLB2IK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sA2TE/dJMcagRpQIm/dpqBGDsnn6JeW96FZLB2IK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsA2TE%2FdJMcagRpQIm%2FdpqBGDsnn6JeW96FZLB2IK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/crAMp8/dJMcahQkeIN/kkMubAI5A0k0j9OKIMmfUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/crAMp8/dJMcahQkeIN/kkMubAI5A0k0j9OKIMmfUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/crAMp8/dJMcahQkeIN/kkMubAI5A0k0j9OKIMmfUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcrAMp8%2FdJMcahQkeIN%2FkkMubAI5A0k0j9OKIMmfUK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/chUDgc/dJMcagRpQIQ/34Cl6kDg1m5FA9K7LKPEV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/chUDgc/dJMcagRpQIQ/34Cl6kDg1m5FA9K7LKPEV1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/chUDgc/dJMcagRpQIQ/34Cl6kDg1m5FA9K7LKPEV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FchUDgc%2FdJMcagRpQIQ%2F34Cl6kDg1m5FA9K7LKPEV1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h3&gt;(2) 코드 살펴보기&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;이제 실제로 Pool DOM을 만드는 코드를 살펴보자.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_initPool()&lt;/code&gt;은 재사용 가능한 DOM 집합을 생성해 Pool에 담아둔다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;private _initPool() {
  const children = document.createDocumentFragment()
  const poolSize = this._calcVisibleCount() + this.config.buffer // [1]

  for (let i = 0; i &amp;lt; poolSize; i++) {
    const el = document.createElement(&amp;#39;div&amp;#39;)
    el.className = this.config.itemClass
    el.style.cssText = `
      position: absolute;
      top: 0;
      width: 100%;
      will-change: transform;
    ` // [2]

    children.appendChild(el)
    this.pool.push(el)

    this._observeResize(el)
  }
  this.container.appendChild(children)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;[1]&lt;/strong&gt; 화면에 보이는 DOM 수 + buffer 만큼만 Pool DOM을 생성한다.&lt;br&gt;  → 데이터가 10,000개여도 실제 DOM 수는 20~40개로 고정된다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;[2]&lt;/strong&gt; 모든 DOM은 &lt;code&gt;position:absolute&lt;/code&gt;로 띄워두고,&lt;br&gt;  &lt;code&gt;will-change: transform&lt;/code&gt;을 통해 GPU transform 최적화를 유도한다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이렇게 만들어진 Pool DOM들은 스크롤 중 절대 제거되지 않는다.&lt;br&gt;대신 &lt;em&gt;innerHTML + translateY&lt;/em&gt;만 바뀌며 매 프레임 “새로운 아이템”으로 보이도록 역할만 교체된다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;3) Scroll 이벤트 처리 (_addScrollEvent / _onScroll)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Virtual Scroll에서는 스크롤이 발생할 때마다&lt;ul&gt;
&lt;li&gt;화면에 어떤 index가 보여야 하는지 계산하고&lt;/li&gt;
&lt;li&gt;Pool DOM의 translateY 위치를 업데이트하며&lt;/li&gt;
&lt;li&gt;필요한 DOM 내용(innerHTML)을 교체하는&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;render()&lt;/code&gt; 작업이 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;문제는 scroll 이벤트가 ms 단위로 매우 빈번하게 발생한다는 점이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;브라우저는 스크롤 중 수십 번의 이벤트를 연속적으로 발행하기에,&lt;/li&gt;
&lt;li&gt;매 호출마다 &lt;code&gt;render()&lt;/code&gt;를 직접 실행하면 Layout → Paint → Composite 사이클이 과도하게 반복되어 성능 저하와 jank로 이어진다.&lt;/li&gt;
&lt;li&gt;따라서 스크롤 이벤트는 반드시 프레임 단위로 제어(throttling) 해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(1) requestAnimationFrame으로 프레임당 1회 렌더링&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;private _onScroll() {
  if (this.rAFId != null) return

  this.rAFId = requestAnimationFrame(() =&amp;gt; {
    this.render()
    this.rAFId = null
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;requestAnimationFrame&lt;/code&gt;은 다음 브라우저 프레임 직전에 콜백을 실행한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_onScroll()&lt;/code&gt;에서 rAF가 이미 예약된 상태라면 추가 호출은 무시하고,&lt;/li&gt;
&lt;li&gt;한 프레임(약 16ms)에 딱 한 번만 &lt;code&gt;render()&lt;/code&gt;가 실행되도록 제한한다.&lt;/li&gt;
&lt;li&gt;그래서 스크롤 이벤트가 수십 번 들어와도, 렌더는 60fps 상한선 내에서만 실행된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) passive: true로 스크롤 반응성 향상&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;private _addScrollEvent() {
  this.scrollTarget.addEventListener(&amp;#39;scroll&amp;#39;, this._onScroll, { passive: true })
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;브라우저는 기본적으로 이벤트 핸들러가 &lt;code&gt;preventDefault()&lt;/code&gt;를 호출할 수 있는지 확인할 때까지 스크롤 동작을 잠시 보류한다.&lt;/li&gt;
&lt;li&gt;이 작은 지연이 쌓이면 스크롤 반응성이 떨어지고, 체감되는 약간의 버벅임이 발생한다.&lt;/li&gt;
&lt;li&gt;하지만 Virtual Scroll에서는 &lt;code&gt;preventDefault()&lt;/code&gt;를 사용할 일이 없다.&lt;/li&gt;
&lt;li&gt;따라서 스크롤 이벤트는 &lt;strong&gt;passive 모드&lt;/strong&gt;로 등록하는 것이 최적이다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;passive: true&lt;/code&gt; 처리를 하면 브라우저가 스크롤을 즉시 처리하고 이벤트를 나중에 전달한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;4) 스크롤 위치로 시작 인덱스 찾기 (_findStartIndex)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Virtual Scroll은 스크롤이 움직일 때마다 &lt;strong&gt;“현재 화면에는 어떤 아이템부터 보여야 하는가?”&lt;/strong&gt; 즉, &lt;code&gt;startIndex&lt;/code&gt;를 계산해야 한다.&lt;/li&gt;
&lt;li&gt;이 값을 빠르게 찾기 위해 각 아이템의 Y좌표(top offset) 를 미리 계산해 &lt;code&gt;itemOffsets&lt;/code&gt; 배열에 저장해둔다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;itemOffsets&lt;/code&gt; 배열은 정렬된 상태이므로, 스크롤 위치(&lt;code&gt;scrollTop&lt;/code&gt;)가 주어지면 그 위치를 넘어서는 첫 번째 아이템 index를 빠르게 찾을 수 있다.&lt;/li&gt;
&lt;li&gt;index를 찾을 때는 이진 탐색(binary search)을 사용해 속도를 보장해준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) 코드 살펴보기&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;private _findStartIndex(scrollTop: number): number { // [1]
  if (this.itemOffsets.length === 0) return 0

  let low = 0
  let high = this.itemOffsets.length - 1 // [2]
  let startIndex = 0

  while (low &amp;lt;= high) {
    const mid = Math.floor((low + high) / 2)
    if (this.itemOffsets[mid] &amp;lt; scrollTop) {
      startIndex = mid + 1
      low = mid + 1
    } else {
      high = mid - 1
    }
  }
  return Math.max(0, startIndex - 1) // [3]
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;[1]&lt;/strong&gt; 스크롤 위치(&lt;code&gt;scrollTop&lt;/code&gt;)를 기준으로 “현재 화면에 노출되어야 하는 첫 번째 index”를 찾는다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;[2]&lt;/strong&gt; &lt;code&gt;itemOffsets&lt;/code&gt;는 모든 아이템의 top 위치를 순서대로 저장한 배열이다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;[3]&lt;/strong&gt; 스크롤이 아이템 경계에 걸쳐 있는 경우를 고려해 &lt;code&gt;startIndex - 1&lt;/code&gt;을 보정해준다.&lt;br&gt;  (스크롤 경계에서 마지막 아이템 일부가 보이는 상황 대비)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이렇게 구한 startIndex는 이후 &lt;code&gt;render()&lt;/code&gt; 단계에서 Pool DOM을 어떤 아이템부터 배정할지 결정하는 기준이 된다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;5) DOM 재배치와 데이터 교체: Virtual Scroll의 핵심 동작 (render)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Virtual Scroll이 “실제 DOM은 20~40개뿐인데도 수천 개의 아이템이 모두 렌더링된 것처럼” 보이는 이유는 바로 이 &lt;code&gt;render()&lt;/code&gt; 함수 때문이다.&lt;/li&gt;
&lt;li&gt;스크롤이 발생하면 Virtual Scroll은 다음 두 가지 작업을 수행한다.&lt;ol&gt;
&lt;li&gt;지금 화면에 어떤 index의 아이템을 보여줘야 하는지 계산하고&lt;/li&gt;
&lt;li&gt;Pool DOM을 재배치(translateY) + 재사용(innerHTML 교체) 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;이 과정에서 DOM 추가/삭제는 한 번도 일어나지 않는다.&lt;/li&gt;
&lt;li&gt;오직 &lt;em&gt;위치 이동 + 내용 교체&lt;/em&gt;만 이뤄지고, 이 덕분에 높은 FPS를 유지할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) 코드 살펴보기&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;for (let i = 0; i &amp;lt; this.pool.length; i++) {
  const itemIndex = startIndex + i
  const el = this.pool[i]

  if (itemIndex &amp;gt;= endIndex) {
    el.style.display = &amp;#39;none&amp;#39;
    continue
  }

  el.style.display = &amp;#39;block&amp;#39;
  ;(el as any).__virtualIndex = itemIndex

  const top =
    this.itemOffsets[itemIndex] ?? (itemIndex * this.config.itemHeight)
  el.style.transform = `translateY(${top}px)`

  const content = this.config.renderItem(this.data[itemIndex])
    if (content instanceof HTMLElement) {
      el.replaceChildren(content)
    } else {
      const contentStr = String(content ?? &amp;#39;&amp;#39;)
      if (el.innerHTML !== contentStr) {
        el.innerHTML = contentStr
      }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;h3&gt;(2) render의 동작 살펴보기&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;① Pool DOM에 “어떤 index의 아이템을 맡길지” 결정한다&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;스크롤로 계산한 &lt;code&gt;startIndex&lt;/code&gt;부터 Pool DOM에 순서대로 데이터를 배정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;if (itemIndex &amp;gt;= endIndex) {
  el.style.display = &amp;#39;none&amp;#39;
  continue
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;화면 밖의 DOM은 &lt;code&gt;display: none&lt;/code&gt; 처리하여 불필요한 페인트를 방지한다.&lt;/li&gt;
&lt;li&gt;이렇게 DOM 개수는 그대로 유지되지만, 각 DOM이 표현하는 “데이터 역할”만 계속 바뀐다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;② translateY로 DOM을 실제 위치처럼 보이게 이동한다&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const top = this.itemOffsets[itemIndex] ?? (itemIndex * this.config.itemHeight)
el.style.transform = `translateY(${top}px)`&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;itemOffsets&lt;/code&gt;에는 각 아이템의 “진짜 리스트에서의 Y좌표”가 들어있다.&lt;/li&gt;
&lt;li&gt;Pool DOM은 &lt;code&gt;position:absolute&lt;/code&gt; 상태이므로, translateY만 바꿔도 마치 해당 위치에 실제 DOM이 있는 것처럼 보인다.&lt;/li&gt;
&lt;li&gt;transform 이동은 GPU가 처리하므로 Layout/Reflow가 발생하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;③ DOM의 내용(innerHTML)만 교체한다&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;...

const content = this.config.renderItem(this.data[itemIndex])

if (content instanceof HTMLElement) {
  el.replaceChildren(content)
} else {
  const contentStr = String(content ?? &amp;#39;&amp;#39;)
  if (el.innerHTML !== contentStr) {
    el.innerHTML = contentStr
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;DOM을 새로 만들지 않고, 내용만 변경한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;innerHTML&lt;/code&gt; 비교까지 하는 이유는 불필요한 DOM 조작을 막기 위해서다.&lt;/li&gt;
&lt;li&gt;결국 DOM의 물리적 개수는 변하지 않고, 사용자 눈에는 매번 새로운 아이템이 등장하는 것처럼 보인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;6) 전체 아이템의 top 위치와 spacer 높이 계산 (_updateOffsets)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Virtual Scroll이 정확하게 동작하려면, 각 아이템이 리스트에서 어느 Y 좌표에 있어야 하는지(top 위치)를 알고 있어야 한다.&lt;/li&gt;
&lt;li&gt;그래야 스크롤 위치에 따라 Pool DOM을 정확한 위치(translateY)로 이동시킬 수 있다&lt;/li&gt;
&lt;li&gt;이 역할을 수행하는 함수가 바로 &lt;code&gt;_updateOffsets()&lt;/code&gt;다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) 코드 살펴보기&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;private _updateOffsets() {
  let offset = 0

  this.itemOffsets = this.data.map((_, i) =&amp;gt; {
    const h = this.itemHeights.get(i) ?? this.config.itemHeight
    const currentOffset = offset
    offset += h
    return currentOffset
  })
  this.spacer.style.height = `${offset}px`
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 각 아이템의 ‘정확한 위치(Y좌표)’를 계산한다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;itemOffsets[i]&lt;/code&gt;는 “i번째 아이템이 리스트 상단으로부터 얼마나 떨어져 있는지”를 의미한다.&lt;/li&gt;
&lt;li&gt;Virtual Scroll은 이 값을 이용해 Pool DOM을 정확한 위치로 translateY로 옮긴다.&lt;/li&gt;
&lt;li&gt;예를 들어 모든 아이템 높이가 40px이라면:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;index 0 → top: 0px  
index 1 → top: 40px  
index 2 → top: 80px  
index 3 → top: 120px …&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이처럼 정렬된 Offset 배열이 있으면:&lt;ul&gt;
&lt;li&gt;&lt;code&gt;findStartIndex()&lt;/code&gt;에서 이진 탐색으로 시작 index를 빠르게 찾고&lt;/li&gt;
&lt;li&gt;&lt;code&gt;render()&lt;/code&gt;에서도 translateY 위치 계산이 매우 단순해진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(3) 동적 높이(dynamic height)까지 대응한다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;리스트의 아이템 높이가 &lt;strong&gt;항상 동일한 것은 아니다.&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;이미지 로딩 후 늘어나는 레이아웃&lt;/li&gt;
&lt;li&gt;“더보기”를 눌러 텍스트 확장&lt;/li&gt;
&lt;li&gt;아코디언 UI처럼 접힘/펼침이 존재하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이런 변화가 발생하면 &lt;code&gt;itemHeights&lt;/code&gt;에 저장된 값이 달라지고,&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_updateOffsets()&lt;/code&gt;가 이를 반영하여 offsets를 다시 계산한다.&lt;/li&gt;
&lt;li&gt;즉, Virtual Scroll은 &lt;strong&gt;고정 높이 + 가변 높이&lt;/strong&gt; 모두를 처리할 수 있도록 설계되어 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;(동적 높이 변화 감지는 아래 &lt;em&gt;ResizeObserver&lt;/em&gt; 단계에서 설명한다.)&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;(4) Spacer의 전체 높이도 여기서 결정된다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;offset&lt;/code&gt;은 모든 아이템 높이를 누적한 값이며, 이는 곧 리스트 전체의 높이가 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;this.spacer.style.height = `${offset}px`&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;브라우저는 이 값으로 스크롤바 길이를 결정한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_createSpacer()&lt;/code&gt;에서는 단순히 div만 만들고, 실제 “긴 리스트의 높이”는 &lt;code&gt;_updateOffsets()&lt;/code&gt;에서 채워진다.&lt;/li&gt;
&lt;li&gt;즉, Spacer는 이 단계에서 실제 스크롤 가능한 공간을 갖게 되며,&lt;/li&gt;
&lt;li&gt;사용자는 만 개 이상의 리스트를 스크롤하는 것처럼 자연스러운 경험을 하게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;7) ResizeObserver로 ‘동적 높이 아이템’ 자동 처리 (_observeResize)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Virtual Scroll은 기본적으로 &lt;code&gt;itemHeight&lt;/code&gt;(고정 높이)를 기준으로 설계되지만,&lt;/li&gt;
&lt;li&gt;실제 서비스에서는 모든 아이템이 고정 높이를 유지하지 않는다.&lt;ul&gt;
&lt;li&gt;이미지 로딩 후 크기가 늘어나는 경우&lt;/li&gt;
&lt;li&gt;긴 텍스트가 줄바꿈되면서 공간이 커진 경우&lt;/li&gt;
&lt;li&gt;아코디언 UI가 펼쳐져 높이가 변하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이처럼 아이템의 실제 높이가 바뀌면 기존에 계산해둔 &lt;code&gt;itemOffsets&lt;/code&gt;가 모두 틀어지게 된다.&lt;/li&gt;
&lt;li&gt;이를 즉시 감지하고 보정해주는 것이 바로 &lt;code&gt;ResizeObserver&lt;/code&gt;다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) 코드 살펴보기&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;private _observeResize(el: HTMLElement) {
  const observer = new ResizeObserver(() =&amp;gt; {
    const index = (el as any).__virtualIndex
    if (index == null) return

    const prev = this.itemHeights.get(index)
    const now = el.offsetHeight

    if (prev !== now) {
      this.itemHeights.set(index, now)
      this._updateOffsetsFrom(index)
      this.render()
    }
  })
  observer.observe(el)
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;ResizeObserver는 DOM의 실제 렌더링된 높이가 변화할 때 자동으로 콜백을 실행한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;현재 Pool DOM이 표현 중인 index 확인&lt;ul&gt;
&lt;li&gt;Pool DOM은 스크롤할 때마다 &lt;code&gt;__virtualIndex&lt;/code&gt;에 “현재 그리는 데이터 index”를 저장해둔다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이전 높이(prev)와 현재 높이(now) 비교&lt;ul&gt;
&lt;li&gt;이미지가 늦게 로드되거나 텍스트가 늘어나는 순간 이 값이 달라진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;변경된 높이를 itemHeights에 반영&lt;/li&gt;
&lt;li&gt;해당 index부터 이후 아이템의 itemOffsets만 부분 업데이트&lt;ul&gt;
&lt;li&gt;전체 오프셋을 전부 다시 계산하지 않고 &lt;strong&gt;변화된 구간만&lt;/strong&gt; 재계산한다.&lt;/li&gt;
&lt;li&gt;긴 리스트에서도 성능을 유지할 수 있는 핵심 설계.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;즉시 render() 호출해 translateY 재배치&lt;ul&gt;
&lt;li&gt;한 프레임 안에서 자연스럽게 위치가 보정된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;8) Virtual Scroll 전체 흐름 정리&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Virtual Scroll은 스크롤 이벤트 → index 계산 → DOM 재배치 → 높이 변화 대응까지 하나의 렌더링 루프가 끊임없이 이어지는 구조다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;scroll → rAF → startIndex 탐색 → Pool 배정 → translateY → 내용 교체
         ↓
   ResizeObserver → 높이 반영 → offsets/spacer 업데이트&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;아래는 Virtual Scroll이 한 프레임(frame) 안에서 수행하는 전체 사이클을 정리한 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(1) 스크롤 이벤트 감지 — rAF로 프레임 단위로 묶기&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;사용자가 스크롤하면 &lt;code&gt;scroll&lt;/code&gt; 이벤트가 수십 번 발생한다.&lt;/li&gt;
&lt;li&gt;Virtual Scroll은 이를 requestAnimationFrame(rAF)로 묶어 “한 프레임당 한 번만” render()가 실행되도록 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;scroll → (여러 번)
    ↓
requestAnimationFrame → render()는 단 한 번&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) 현재 스크롤 위치에서 시작 index 계산&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;_findStartIndex()&lt;/code&gt;는 &lt;code&gt;scrollTop&lt;/code&gt;과 &lt;code&gt;itemOffsets&lt;/code&gt;를 기반으로 지금 화면 상단에 등장해야 하는 첫 번째 아이템의 index를 찾는다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;오프셋 배열이 정렬되어 있으므로 이진 탐색(binary search)가 가능하다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;스크롤 속도와 상관없이 항상 O(log N)으로 빠르게 계산할 수 있다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(3) Pool DOM에 ‘어떤 index를 그릴 것인지’ 배정&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Pool DOM(20~40개)에는 각각 “지금 너는 index #321을 그려라”와 같은 역할이 주어진다.&lt;/li&gt;
&lt;li&gt;인덱스를 벗어나는 DOM은 &lt;code&gt;display:none&lt;/code&gt; 처리해 불필요한 페인트를 막는다.&lt;/li&gt;
&lt;li&gt;DOM 추가·삭제는 일어나지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(4) translateY로 ‘실제 리스트 위치’를 흉내내기&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Pool DOM은 모두 absolute로 떠 있기 때문에, 레이아웃에 영향을 주지 않는다.&lt;/li&gt;
&lt;li&gt;각 DOM은 아래처럼 지정된 위치로 이동한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;el.style.transform = translateY(itemOffsets[itemIndex])&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;transform은 GPU에서 처리되어 Layout/Reflow 없이 즉시 적용된다.&lt;/li&gt;
&lt;li&gt;이 덕분에 긴 리스트가 실제 렌더링된 것처럼 자연스럽게 보인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(5) DOM 내용만 교체해 새로운 데이터로 보이게 만들기&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;DOM 자체를 만들거나 제거하지 않고, innerHTML 또는 replaceChildren()으로 현재 index에 해당하는 데이터를 그린다.&lt;/li&gt;
&lt;li&gt;DOM 개수는 그대로지만, 사용자 눈에는 “새로운 아이템이 등장”한 것처럼 보인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(6) ResizeObserver가 높이 변화를 감지&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;이미지 로드, 텍스트 확장 등으로 DOM 높이가 변하면 즉시 감지한다.&lt;/li&gt;
&lt;li&gt;해당 index의 실제 높이를 갱신하고, 그 지점 이후의 &lt;code&gt;itemOffsets&lt;/code&gt;만 부분적으로 재계산한다.&lt;/li&gt;
&lt;li&gt;안정적인 dynamic height를 제공하는 핵심이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(7) spacer 높이도 함께 업데이트&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;모든 아이템의 누적 높이를 기반으로 spacer의 높이를 갱신한다.&lt;/li&gt;
&lt;li&gt;브라우저는 이 spacer를 기준으로 스크롤바를 유지한다.&lt;/li&gt;
&lt;li&gt;즉, &lt;strong&gt;실제 DOM 개수와 관계없이 스크롤 가능한 긴 리스트&lt;/strong&gt;가 완성된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;앞선 과정을 그림으로 표현하면 다음과 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bz2Yvy/dJMcafkFHnM/ojcmk2qndIRzZPZf6YZKfk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bz2Yvy/dJMcafkFHnM/ojcmk2qndIRzZPZf6YZKfk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bz2Yvy/dJMcafkFHnM/ojcmk2qndIRzZPZf6YZKfk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbz2Yvy%2FdJMcafkFHnM%2Fojcmk2qndIRzZPZf6YZKfk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;3. 마치며…&lt;/h1&gt;
&lt;p&gt;Virtual Scroll은 얼핏 보면 “DOM을 줄이는 간단한 최적화 기법”처럼 보이지만, &lt;/p&gt;
&lt;p&gt;실제로 구현해보면 여러 요소가 촘촘하게 맞물려 동작하는 구조라는 걸 알 수 있다.&lt;/p&gt;
&lt;p&gt;정리하면 Virtual Scroll은 다음 사이클을 지속적으로 반복한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;1. 스크롤 위치로 시작 index 계산
2. Pool DOM에 새로운 역할 배정
3. translateY로 실제 위치 이동
4. ResizeObserver로 동적 높이 감지
5. offsets 및 spacer 높이 재계산&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 흐름이 프레임 단위(≈16ms) 안에서 안정적으로 순환하기에, 실제 DOM은 20~40개에 불과하지만 &lt;/p&gt;
&lt;p&gt;사용자는 수천~수만 개의 리스트가 렌더링된 것처럼 자연스러운 스크롤을 경험하게 된다.&lt;/p&gt;
&lt;p&gt;결국 Virtual Scroll은 “DOM을 줄인다”라는 단순한 아이디어를 넘어서, 브라우저 렌더링 파이프라인 전체를 고려한 최적화 패턴이라고 볼 수 있다.&lt;/p&gt;</description>
      <category>개발 기술/사소하지만 놓치기 쉬운 개발 지식</category>
      <category>infinite scroll</category>
      <category>javascript</category>
      <category>TS</category>
      <category>virtual scroll</category>
      <category>가상</category>
      <category>가상 스크롤</category>
      <category>렌더링</category>
      <category>렌더링 최적화</category>
      <category>스크롤</category>
      <category>최적화</category>
      <author>GicoMomg</author>
      <guid isPermaLink="true">https://mong-blog.tistory.com/238</guid>
      <comments>https://mong-blog.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-Virtual-Scroll-%EA%B5%AC%ED%98%84-%EC%BD%94%EB%93%9C-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0#entry238comment</comments>
      <pubDate>Sun, 16 Nov 2025 23:20:22 +0900</pubDate>
    </item>
    <item>
      <title>브라우저 렌더링 최적화를 위한 Virtual Scroll - 원리/성능 비교</title>
      <link>https://mong-blog.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-Virtual-Scroll-%EC%9B%90%EB%A6%AC%EC%84%B1%EB%8A%A5-%EB%B9%84%EA%B5%90</link>
      <description>&lt;h1&gt;0. 들어가며&amp;hellip;&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;대부분의 서비스는 수 많은 데이터를 스크롤 기반으로 보여준다.&lt;/li&gt;
&lt;li&gt;거래 내역, 로그, 쇼핑 내역처럼 연속된 데이터를 무한 스크롤로 제공하는 방식은 이미 모바일&amp;middot;웹 모두에서 기본 UI 패턴이 되었다.&lt;/li&gt;
&lt;li&gt;그런데 특정 상황에서 이 스크롤 방식이 문제가 될 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;만약 한 번에 보여줘야 하는 아이템이 100개 이상이라면 어떨까?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단순히 100개의 DOM을 렌더링해 스크롤하는 정도는 괜찮아 보이지만, 실제 서비스 환경에서는 상황이 다르다.&lt;/li&gt;
&lt;li&gt;예를 들어, 100개짜리 리스트를 무한 스크롤로 노출한 상태에서 소켓 데이터가 실시간으로 들어오고, 동시에 canvas 기반 그래프까지 그려야 한다면?&lt;/li&gt;
&lt;li&gt;스크롤이 순간적으로 뚝뚝 끊기는 &amp;lsquo;렉&amp;rsquo;이 발생한다.&lt;/li&gt;
&lt;li&gt;이 문제의 핵심 원인은 화면에 쌓여 있는 DOM 수와 그로 인해 반복되는 레이아웃(Layout) 계산이다.&lt;/li&gt;
&lt;li&gt;브라우저의 Reflow/Repaint 비용은 결코 가볍지 않으며, DOM이 많아질수록 이 비용은 기하급수적으로 증가한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;그렇다면 어떻게해야 이 문제를 예방할 수 있을까?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;확실한 방법은 &amp;ldquo;화면에 존재하는 DOM 개수를 제한하는 것&amp;rdquo;이다.&lt;/li&gt;
&lt;li&gt;예를 들어, 리스트가 1,000개여도 실제 DOM을 약 30개만 유지하고,&lt;/li&gt;
&lt;li&gt;스크롤 시 DOM은 그대로 둔 채 &amp;ldquo;데이터만 교체해 재활용한다면&amp;rdquo; 과도한 레이아웃 비용을 크게 줄일 수 있다.&lt;/li&gt;
&lt;li&gt;이는 Android의 &lt;code&gt;RecyclerView(구 ListView)&lt;/code&gt;가 사용하는 방식과 동일하다.&lt;/li&gt;
&lt;li&gt;JavaScript에서도 이 개념을 Virtual Scroll(가상 스크롤)이라고 부른다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 Virtual Scroll이 &lt;b&gt;어떤 원리로 동작하는지&lt;/b&gt;, 그리고 &lt;b&gt;실제로 성능적 이점이 얼마나 있는지&lt;/b&gt;를 실측 데이터를 통해 분석한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 글은 시리즈로 구성되어 있으며, 원리와 성능 비교는 본 글에서, 구현 코드는 다음 글에서 다룬다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저 렌더링 최적화를 위한 Virtual Scroll - 원리/성능 비교 &amp;larr; &lt;i&gt;이번 글&lt;/i&gt;&lt;br /&gt;&lt;a href=&quot;https://mong-blog.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-Virtual-Scroll-%EA%B5%AC%ED%98%84-%EC%BD%94%EB%93%9C-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0?category=965256&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;브라우저 렌더링 최적화를 위한 Virtual Scroll - 구현 코드 살펴보기&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h1&gt;1. Virtual Scroll의 원리를 살펴보자&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1) Virtual Scroll의 개념&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) 정의&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Scroll(가상 스크롤)은 대량의 리스트를 &amp;ldquo;실제 DOM 전체를 만들지 않고&amp;rdquo; 스크롤 가능한 UI로 보여주는 기술이다.&lt;/li&gt;
&lt;li&gt;데이터가 1,000개, 10,000개로 많아질수록 DOM 개수도 증가하기에 브라우저는 금방 느려진다.&lt;/li&gt;
&lt;li&gt;하지만 Virtual Scroll을 사용하면 이 문제를 해결할 수 있다!&lt;/li&gt;
&lt;li&gt;Virtual Scroll은 &lt;b&gt;전체 데이터 개수만큼 DOM을 생성하지 않는다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;대신 화면에 보이는 영역에 필요한 DOM만(보통 20~40개 정도) 유지하고, 스크롤에 따라 &lt;b&gt;DOM의 위치와 내용만 교체하는 방식&lt;/b&gt;이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;439&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dch0Sk/dJMcab3DTuE/kq5KxeHfr2GWXaOVKhQkN1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dch0Sk/dJMcab3DTuE/kq5KxeHfr2GWXaOVKhQkN1/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dch0Sk/dJMcab3DTuE/kq5KxeHfr2GWXaOVKhQkN1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/dch0Sk/dJMcab3DTuE/kq5KxeHfr2GWXaOVKhQkN1/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;439&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;439&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div style=&quot;width: 100%; text-align: center; color: gray; font-size: 0.8rem;&quot;&gt;[스크롤시 각 요소의 Y 값이 변하면서 아이템이 교체되는 모습(DOM 개수는 고정)]&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;즉 사용자에게는 &amp;ldquo;수천 개가 한 번에 렌더링된 긴 리스트&amp;rdquo;처럼 보이지만, 실제로는 극히 소수의 DOM만 계속 재활용된다!&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) Virtual Scroll의 핵심 아이디어&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) DOM을 최소한으로 유지하고(visible only) 재사용한다(pooling)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Scroll의 핵심은 &amp;ldquo;데이터 개수만큼 DOM을 만들지 않는다&amp;rdquo;는 점이다.&lt;/li&gt;
&lt;li&gt;데이터가 10,000개라도 실제 화면에 존재하는 DOM은 약 20~40개에 불과하다.&lt;/li&gt;
&lt;li&gt;아래 그림처럼, Full Render 방식에서는 데이터 개수(10,000개)만큼 DOM이 그대로 생성된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;9737&quot; data-origin-height=&quot;5015&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dHBrJy/dJMcacIfAOM/kGuRZl62bRs7rJ6oyYxzPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dHBrJy/dJMcacIfAOM/kGuRZl62bRs7rJ6oyYxzPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dHBrJy/dJMcacIfAOM/kGuRZl62bRs7rJ6oyYxzPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdHBrJy%2FdJMcacIfAOM%2FkGuRZl62bRs7rJ6oyYxzPK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;9737&quot; height=&quot;5015&quot; data-origin-width=&quot;9737&quot; data-origin-height=&quot;5015&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;반면, Virtual Scroll은 화면에 실제로 보이는 영역을 기준으로 일정 개수만 생성하고, 스크롤 시 이 DOM을 그대로 재활용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;9646&quot; data-origin-height=&quot;5069&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bb7BAS/dJMb99Y3Khw/iIbd0aSTmknWFzmKEkPpJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bb7BAS/dJMb99Y3Khw/iIbd0aSTmknWFzmKEkPpJK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bb7BAS/dJMb99Y3Khw/iIbd0aSTmknWFzmKEkPpJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbb7BAS%2FdJMb99Y3Khw%2FiIbd0aSTmknWFzmKEkPpJK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;9646&quot; height=&quot;5069&quot; data-origin-width=&quot;9646&quot; data-origin-height=&quot;5069&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;보통 다음과 같은 구조로 동작한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;화면에 보이는 아이템 수 + 버퍼(예: 위아래 10개)만 DOM 생성&lt;/li&gt;
&lt;li&gt;스크롤이 내려가도 DOM은 삭제&amp;middot;추가되지 않음&lt;/li&gt;
&lt;li&gt;변경되는 것은 &amp;ldquo;각 div가 어떤 데이터를 표시할지&amp;rdquo;뿐&lt;/li&gt;
&lt;li&gt;즉, div의 &lt;b&gt;역할만 바뀌고&lt;/b&gt;, DOM 자체는 계속 재활용됨&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이러한 구조 덕분에 불필요한 레이아웃(Layout)&amp;middot;리플로우(Reflow)가 크게 줄어든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;(2) translateY로 실제 리스트 위치를 &amp;lsquo;흉내내기&amp;rsquo;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DOM을 재사용하면 자연스럽게 한 가지 의문이 생긴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 div인데 어떻게 데이터 0번, 100번, 10,000번 위치에 있는 것처럼 보일 수 있는지&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;그 이유는 Virtual Scroll은 position:absolute + transform: translateY() 조합을 사용하기 때문이다.&lt;/li&gt;
&lt;li&gt;각 DOM을 (레이아웃 변경 없이)&lt;code&gt;transform&lt;/code&gt;으로 이동시켜, 해당 위치에 있는 것처럼 보이게 한다.&lt;/li&gt;
&lt;li&gt;아래 예시는 첫 번째 div가 translateY를 변경하며 &amp;lsquo;역할&amp;rsquo;이 바뀌는 과정을 단계별로 보여준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A. 초기 상태 &amp;mdash; 첫 번째 div는 데이터[0]을 그린다&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;첫 번째 div는 &lt;code&gt;Item #1&lt;/code&gt;을 표시하며 &lt;code&gt;translateY(0px)&lt;/code&gt; 위치에 배치된다.&lt;/li&gt;
&lt;li&gt;이 시점에서는 화면에 보이는 약 20~40개의 DOM이 각자 초기 데이터 역할을 맡고 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;7399&quot; data-origin-height=&quot;1748&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bt47vP/dJMcabWSpv3/zUFHrLkZAK9oz9CsYbNEZ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bt47vP/dJMcabWSpv3/zUFHrLkZAK9oz9CsYbNEZ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bt47vP/dJMcabWSpv3/zUFHrLkZAK9oz9CsYbNEZ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbt47vP%2FdJMcabWSpv3%2FzUFHrLkZAK9oz9CsYbNEZ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;7399&quot; height=&quot;1748&quot; data-origin-width=&quot;7399&quot; data-origin-height=&quot;1748&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;B. 스크롤이 조금 내려간 상태 (예: 20px 이동)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스크롤 값은 변했지만 DOM 구조는 그대로다.&lt;/li&gt;
&lt;li&gt;첫 번째 div가 아래로 이동한 것처럼 보이지만, 이는 transform 때문이 아니라 &lt;b&gt;viewport 자체가 이동한 것&lt;/b&gt;이다.&lt;/li&gt;
&lt;li&gt;DOM은 아직도 기존 데이터 역할을 유지한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;7392&quot; data-origin-height=&quot;1863&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wt2J5/dJMcafZhdSx/BmoyATMD9gC1HnZeaFywvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wt2J5/dJMcafZhdSx/BmoyATMD9gC1HnZeaFywvK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wt2J5/dJMcafZhdSx/BmoyATMD9gC1HnZeaFywvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fwt2J5%2FdJMcafZhdSx%2FBmoyATMD9gC1HnZeaFywvK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;7392&quot; height=&quot;1863&quot; data-origin-width=&quot;7392&quot; data-origin-height=&quot;1863&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;C. 스크롤이 itemHeight만큼 이동됐을 때 (예: 40px 도달)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;itemHeight&lt;/code&gt; 경계에 도달하면 Virtual Scroll은 다음 작업을 수행한다:
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;첫 번째 div의 데이터 역할을 &lt;code&gt;Item #1&lt;/code&gt; &amp;rarr; &lt;code&gt;Item #2&lt;/code&gt;로 교체&lt;/li&gt;
&lt;li&gt;transform을 &lt;code&gt;translateY(0px &amp;rarr; 40px)&lt;/code&gt;로 업데이트&lt;/li&gt;
&lt;li&gt;내부적으로 데이터 index도 함께 갱신&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;7401&quot; data-origin-height=&quot;2154&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biSBZc/dJMcafrq1Iy/R10nRB9Wv2CgDuKBCZWqQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biSBZc/dJMcafrq1Iy/R10nRB9Wv2CgDuKBCZWqQ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biSBZc/dJMcafrq1Iy/R10nRB9Wv2CgDuKBCZWqQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbiSBZc%2FdJMcafrq1Iy%2FR10nRB9Wv2CgDuKBCZWqQ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;7401&quot; height=&quot;2154&quot; data-origin-width=&quot;7401&quot; data-origin-height=&quot;2154&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;즉, 같은 div지만 translateY 위치와 데이터 index를 바꿔줌으로써 전혀 다른 &amp;ldquo;리스트 요소&amp;rdquo;처럼 보이게 된다.&lt;/li&gt;
&lt;li&gt;이 과정에서 레이아웃(Layout)이나 Reflow는 발생하지 않으며, 모든 이동은 GPU가 처리하는 &lt;code&gt;transform&lt;/code&gt; 기반 애니메이션이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;(3) spacer로 전체 높이를 유지해 &amp;ldquo;진짜 스크롤&amp;rdquo;처럼 보이기&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Scroll에서는 실제로 존재하는 DOM이 20~40개뿐이지만,&lt;/li&gt;
&lt;li&gt;사용자는 &amp;ldquo;10,000개의 아이템이 전부 렌더링된 긴 리스트&amp;rdquo;를 스크롤하고 있다고 느껴야 한다.&lt;/li&gt;
&lt;li&gt;이 착시를 만들어주는 핵심 요소가 바로 spacer(전체 높이를 가진 투명한 div)이다.&lt;/li&gt;
&lt;li&gt;Virtual Scroll의 HTML 구조는 크게 아래 세 가지로 구성된다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Container&lt;/b&gt;: 실제 스크롤이 일어나는 영역&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Spacer&lt;/b&gt;: &lt;code&gt;dataLength &amp;times; itemHeight&lt;/code&gt; 높이를 가진 거대한 빈 div&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Item DOM들&lt;/b&gt;: Spacer 위에 absolute로 떠 있는, 재사용되는 약 20~40개의 DOM 노드&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;14040&quot; data-origin-height=&quot;12690&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dbKdqj/dJMcafEYq5X/Yz3yR9Q50tjq7oE6H5iQmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dbKdqj/dJMcafEYq5X/Yz3yR9Q50tjq7oE6H5iQmk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dbKdqj/dJMcafEYq5X/Yz3yR9Q50tjq7oE6H5iQmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdbKdqj%2FdJMcafEYq5X%2FYz3yR9Q50tjq7oE6H5iQmk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;14040&quot; height=&quot;12690&quot; data-origin-width=&quot;14040&quot; data-origin-height=&quot;12690&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 중에서 Spacer는 아래 두 가지 역할을 담당한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A. 전체 데이터 높이를 &amp;lsquo;가짜로&amp;rsquo; 만든다&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예를 들어, 데이터가 10,000개이고 각 아이템의 높이가 40px이라면:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;전체 높이 = itemHeight(40px) &amp;times; dataLength(10,000) = 400,000px&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spacer는 이 400,000px 높이를 그대로 가진 투명한 div일 뿐이지만, 브라우저는 이 값을 기준으로 스크롤바를 생성한다.&lt;/li&gt;
&lt;li&gt;그 결과, 실제 DOM은 30개만 존재해도 사용자는 &amp;ldquo;엄청 긴 리스트를 스크롤하는 중&amp;rdquo;이라고 자연스럽게 느끼게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;B. 재사용되는 item DOM의 &amp;lsquo;위치 기준점&amp;rsquo; 역할을 한다&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Scroll의 item DOM들은 모두 &lt;code&gt;position:absolute&lt;/code&gt;로 배치된다.&lt;/li&gt;
&lt;li&gt;즉, 문서 흐름에 속하지 않고 원하는 위치에 자유롭게 올릴 수 있다.&lt;/li&gt;
&lt;li&gt;Spacer는 이 item DOM들이 떠 있을 공간을 제공한다.&lt;/li&gt;
&lt;li&gt;재사용되는 각 DOM은 Spacer 위에서 &lt;code&gt;translateY(0px &amp;rarr; 40px &amp;rarr; 80px &amp;rarr; &amp;hellip;)&lt;/code&gt;방식으로 이동하며, 마치 해당 index의 위치에 존재하는 것처럼 보이게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;지금까지 Virtual Scroll이 &lt;i&gt;어떤 구조로 동작하는지&lt;/i&gt;를 살펴보았다.&lt;/li&gt;
&lt;li&gt;핵심은 &amp;ldquo;DOM을 최소한으로 유지하고, 기존 DOM을 재활용하며, transform으로 위치만 바꾼다&amp;rdquo;는 점이다.&lt;/li&gt;
&lt;li&gt;이론적으로만 보면 매우 효율적인 구조지만, &lt;b&gt;실제 브라우저 렌더링 파이프라인에서도 동일한 성능 개선이 보장될까?&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;특히 Layout &amp;rarr; Paint &amp;rarr; Composite 단계에서 Virtual Scroll이 Full Render 대비 &lt;b&gt;얼마나 비용을 줄여주는지&lt;/b&gt;가 중요한 포인트다.&lt;/li&gt;
&lt;li&gt;그래서 이어지는 [&lt;b&gt;2. Virtual Scroll의 성능 측정해보기&lt;/b&gt;] 에서 동일한 조건에서 Full Render와 Virtual Scroll을 비교하며, 성능 차이가 발생하는지 확인해보겠다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h1&gt;2. Virtual Scroll의 성능 측정해보기&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1) 테스트 환경은?&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Scroll의 강점은 DOM 수를 최소화해 Layout 비용과 GPU 메모리를 줄이는 구조적 최적화에 있다.&lt;/li&gt;
&lt;li&gt;즉, &amp;ldquo;DOM을 적게 그리니까 성능이 빨라진다&amp;rdquo;는 것이 이론적 설명이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제 브라우저 환경에서도 정말 그렇게 동작할까?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Scroll이 Layout &amp;rarr; Paint &amp;rarr; Composite 파이프라인에서 실제 성능상 효과가 있는지 확인하기 위해 Chrome Performance 패널로 렌더링 지표를 직접 수집해 비교했다&lt;/li&gt;
&lt;li&gt;하드웨어 성능이나 백그라운드 앱의 영향을 최소화하기 위해 아래 조건을 고정하여 실험을 진행했다.&lt;/li&gt;
&lt;li&gt;성능 테스트에 사용한 코드는 &lt;a href=&quot;https://github.com/KumJungMin/recycling-view-performance-check/blob/main/src/core/virtual-scroller.ts&quot;&gt;이 링크&lt;/a&gt;에서 볼 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;렌더링 아이템 수: 10,000개
CPU Throttling: 4x (저사양 기기 상황 재현)
네트워크 제한: 없음 (순수 렌더링 비용만 측정)
브라우저: Chrome 시크릿 모드 / 142.0.7444.162 (arm64)
테스트 기기: iOS 32G 환경&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2) 측정값 살펴보기&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동일한 테스트 시나리오(페이지 로드 &amp;rarr; 스크롤)에서 관찰된 주요 성능 지표는 아래와 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;Full Render&lt;/th&gt;
&lt;th&gt;Virtual Scroll&lt;/th&gt;
&lt;th&gt;개선율&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UpdateLayoutTree&lt;/td&gt;
&lt;td&gt;&lt;b&gt;52.3 ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;6.3 ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;▲ 88% 빠름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;평균 Paint&lt;/td&gt;
&lt;td&gt;3.8 ms&lt;/td&gt;
&lt;td&gt;1.9 ms&lt;/td&gt;
&lt;td&gt;▲ 50% 빠름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU Memory&lt;/td&gt;
&lt;td&gt;40.9 MB&lt;/td&gt;
&lt;td&gt;6.5 MB&lt;/td&gt;
&lt;td&gt;▲ 84% 절감&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frame Count&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;+18% 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FPS&lt;/td&gt;
&lt;td&gt;~50 fps&lt;/td&gt;
&lt;td&gt;~60 fps&lt;/td&gt;
&lt;td&gt;+20% 향상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LayoutCount&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;&lt;i&gt;(수는&amp;uarr;, 비용은&amp;darr;)&lt;/i&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요약하면, Virtual Scroll은 초기 렌더링에서 약 8배 빠르고, 스크롤 중에도 안정적으로 60fps를 유지한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) Layout / Paint Time&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;아래 그래프는 UpdateLayoutTree(레이아웃)와 Paint 비용이 얼마나 줄어드는지 보여준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1558&quot; data-origin-height=&quot;756&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ccMTSB/dJMcain99ei/pCRNKHPUOhRy1aFuDLBph1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ccMTSB/dJMcain99ei/pCRNKHPUOhRy1aFuDLBph1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ccMTSB/dJMcain99ei/pCRNKHPUOhRy1aFuDLBph1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FccMTSB%2FdJMcain99ei%2FpCRNKHPUOhRy1aFuDLBph1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;660&quot; height=&quot;320&quot; data-origin-width=&quot;1558&quot; data-origin-height=&quot;756&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;① Layout(UpdateLayoutTree) &amp;ndash; 88% 감소&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Full Render: 전체 10,000 DOM의 크기&amp;middot;위치 계산 &amp;rarr; &lt;b&gt;52ms&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Virtual Scroll: 보이는 30~40개만 계산 &amp;rarr; &lt;b&gt;6ms&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;16.7ms(1 frame 예산)을 초과하면 반드시 jank가 발생한다.&lt;br /&gt;Full Render는 이를 넘기지만, Virtual Scroll은 여유로운 수치를 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;② Paint &amp;ndash; 50% 감소&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Scroll은 전체 DOM을 다시 그리지 않음&lt;/li&gt;
&lt;li&gt;화면에 보이는 &amp;ldquo;20~40개의 DOM만&amp;rdquo; 페인트하면 되기 때문&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 모바일(WebView 포함) 환경에서는 Paint 비용이 배터리&amp;middot;발열과 직결되기 때문에 이 차이가 더 크게 느껴진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) GPU Memory / FPS&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다음 그래프는 GPU 메모리 사용량과 FPS를 비교한 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1504&quot; data-origin-height=&quot;766&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cx1Ust/dJMcafEYq6L/k6IaKPMUb1fAPb1huVV5k0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cx1Ust/dJMcafEYq6L/k6IaKPMUb1fAPb1huVV5k0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cx1Ust/dJMcafEYq6L/k6IaKPMUb1fAPb1huVV5k0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcx1Ust%2FdJMcafEYq6L%2Fk6IaKPMUb1fAPb1huVV5k0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;628&quot; height=&quot;320&quot; data-origin-width=&quot;1504&quot; data-origin-height=&quot;766&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;③ GPU 메모리 &amp;ndash; 84% 감소&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Full Render: 10,000 DOM 전체가 레이어로 올라가며 &lt;b&gt;약 40MB&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Virtual Scroll: 재사용되는 pool DOM만 레이어 &amp;rarr; &lt;b&gt;6.5MB&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;④ FPS &amp;ndash; 스크롤 체감 품질 차이를 만드는 지표&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Full Render: 약 &lt;b&gt;50fps&lt;/b&gt; &amp;rarr; 약간의 튐(jank) 존재&lt;/li&gt;
&lt;li&gt;Virtual Scroll: &lt;b&gt;항상 55~60fps&lt;/b&gt; 근처 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크롤 중 Virtual Scroll의 LayoutCount가 더 많음에도 FPS가 높은 이유는 간단하다.&lt;br /&gt;Virtual Scroll은 &amp;ldquo;횟수는 많아도 매우 작은 범위&amp;rdquo;만 Layout 하고,&lt;br /&gt;Full Render는 &amp;ldquo;횟수는 적어도 매우 큰 범위&amp;rdquo;를 Layout 하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(3) Frame &amp;amp; Layout Count&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1572&quot; data-origin-height=&quot;812&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKbxvT/dJMcacha6U7/AAJheTcJXiKBAMXohGYlUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKbxvT/dJMcacha6U7/AAJheTcJXiKBAMXohGYlUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKbxvT/dJMcacha6U7/AAJheTcJXiKBAMXohGYlUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKbxvT%2FdJMcacha6U7%2FAAJheTcJXiKBAMXohGYlUK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;622&quot; height=&quot;321&quot; data-origin-width=&quot;1572&quot; data-origin-height=&quot;812&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Virtual Scroll은 더 많은 프레임을 생성해 스크롤 반응성이 높다.&lt;/li&gt;
&lt;li&gt;Layout 횟수는 Full Render보다 많지만, 각 Layout의 범위가 작아 오히려 전체 비용은 낮다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(4) 결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실험 결과를 요약하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;- LayoutTree 비용: 8배 감소
- Paint 비용: 50% 감소
- GPU Memory: 1/6
- FPS: 60 근처 유지&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이는 Virtual Scroll이 단순히 &amp;ldquo;DOM을 덜 그린다&amp;rdquo;가 아니라,&lt;/li&gt;
&lt;li&gt;브라우저 렌더링 파이프라인 전체를 최적화하는 구조라는 걸 의미한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h1&gt;3. 마치며&amp;hellip;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Virtual Scroll은 레이아웃(Layout) &amp;rarr; 페인트(Paint) &amp;rarr; 컴포지트(Composite)로 이어지는 브라우저 렌더링 파이프라인을 최적화하는 기법이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 Virtual Scroll이 어떤 원리로 동작하는지, 그리고 실제 브라우저 환경에서 얼마나 큰 성능 차이를 만드는지를 실측 데이터를 기반으로 살펴보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 Virtual Scroll의 핵심은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;DOM 수 최소화:&lt;/b&gt; 보이는 영역 + 버퍼만 렌더링&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DOM 재활용(pooling):&lt;/b&gt; 새로 만들지 않고 역할만 교체&lt;/li&gt;
&lt;li&gt;&lt;b&gt;transform 기반 위치 이동:&lt;/b&gt; Layout/Reflow 없이 고속 이동&lt;/li&gt;
&lt;li&gt;&lt;b&gt;spacer로 전체 높이 시뮬레이션:&lt;/b&gt; &amp;ldquo;긴 리스트를 스크롤하는 듯한&amp;rdquo; 착시 유지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;렌더링 파이프라인 최적화:&lt;/b&gt; Layout 8배 &amp;darr;, Paint 50% &amp;darr;, GPU Memory 1/6 &amp;darr;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에 Virtual Scroll은 실서비스에서 즉시 체감되는 성능 개선을 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 작은 화면&amp;middot;제한된 리소스 환경(모바일 WebView, Hybrid App)에서는 그 효과가 더 크게 나타난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 시간에는 이번 원리를 기반으로 Virtual Scroll을 실제 코드로 구현하는 방법을 단계별로 살펴보겠다.&lt;/p&gt;</description>
      <category>개발 기술/사소하지만 놓치기 쉬운 개발 지식</category>
      <category>infinite scroll</category>
      <category>JS</category>
      <category>Virtual Scrolling</category>
      <category>virtual-scroll</category>
      <category>Virtualized List</category>
      <category>가상 스크롤</category>
      <category>렌더링</category>
      <category>브라우저 Layout 비용</category>
      <category>스크롤 성능</category>
      <category>스크롤 퍼포먼스 개선</category>
      <author>GicoMomg</author>
      <guid isPermaLink="true">https://mong-blog.tistory.com/237</guid>
      <comments>https://mong-blog.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%9C%84%ED%95%9C-Virtual-Scroll-%EC%9B%90%EB%A6%AC%EC%84%B1%EB%8A%A5-%EB%B9%84%EA%B5%90#entry237comment</comments>
      <pubDate>Sun, 16 Nov 2025 23:01:33 +0900</pubDate>
    </item>
    <item>
      <title>[Vue] Fragment의 함정: 왜 $el은 Text Node가 될까?</title>
      <link>https://mong-blog.tistory.com/entry/Vue-Fragment%EC%9D%98-%ED%95%A8%EC%A0%95-%EC%99%9C-el%EC%9D%80-Text-Node%EA%B0%80-%EB%90%A0%EA%B9%8C</link>
      <description>&lt;h1&gt;0. 들어가며…&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;Vue에서 자식 컴포넌트의 스타일을 가져올 때 &lt;code&gt;ref.value.$el.{스타일_속성}&lt;/code&gt; 방식을 사용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// 부모 컴포넌트

console.log(childRef.value.$el.style.clientHeight)   // 300&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;여기서 &lt;code&gt;$el&lt;/code&gt;은 “컴포넌트 인스턴스가 관리하는 루트 DOM 노드”를 가리킨다.&lt;/li&gt;
&lt;li&gt;그래서 이 방법을 사용하면 &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; 같은 루트 요소의 스타일을 바로 읽을 수 있다.&lt;/li&gt;
&lt;li&gt;그런데 어느 날, 컴포넌트의 레이아웃 구조를 바꾸자 &lt;code&gt;$el.{스타일_속성}&lt;/code&gt;이 undefined를 반환하기 시작했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt; 문제 상황&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;부모 컴포넌트는 2개의 자식 컴포넌트를 가진다.&lt;/li&gt;
&lt;li&gt;그리고 각 자식 컴포넌트의 높이 값을 &lt;code&gt;$el.clientHeight&lt;/code&gt; 방식으로 가져왔다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;script setup&amp;gt;
import { useTemplateRef, onMounted } from &amp;#39;vue&amp;#39;
import Comp from &amp;#39;./Comp.vue&amp;#39;
import Comp1 from &amp;#39;./Comp1.vue&amp;#39;

const compRef = useTemplateRef(&amp;#39;comp&amp;#39;)
const comp1Ref = useTemplateRef(&amp;#39;comp1&amp;#39;)

onMounted(() =&amp;gt; {
  console.log(&amp;#39;immediate:&amp;#39;, compRef.value?.$el.clientHeight)  // 300
  console.log(&amp;#39;immediate:&amp;#39;, comp1Ref.value?.$el.clientHeight) //  undefined
})
&amp;lt;/script&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;Comp ref=&amp;quot;comp&amp;quot; /&amp;gt;
  &amp;lt;Comp1 ref=&amp;quot;comp1&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;각 자식 컴포넌트의 구성은 다음과 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!-- Comp.vue --&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;div style=&amp;quot;height:300px&amp;quot;&amp;gt;hello&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!-- Comp1.vue --&amp;gt;

&amp;lt;template&amp;gt;
  &amp;lt;div style=&amp;quot;height:300px&amp;quot;&amp;gt;hello1&amp;lt;/div&amp;gt;
  &amp;lt;div style=&amp;quot;height:300px&amp;quot;&amp;gt;hello2&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;h3&gt;  원인 분석&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;$el&lt;/code&gt;의 스타일 속성이 &lt;code&gt;undefined&lt;/code&gt;가 뜨는 이유가 뭘까?&lt;/li&gt;
&lt;li&gt;그 원인을 찾고자 각 컴포넌트의 &lt;code&gt;$el&lt;/code&gt;을 콘솔로 출력했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;onMounted(() =&amp;gt; {
  console.log(&amp;#39;compRef의 $el:&amp;#39;, compRef.value?.$el)
  console.log(&amp;#39;comp1Ref의 $el:&amp;#39;, comp1Ref.value?.$el)
})&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;결과는 다음과 같았다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;// 콘솔 출력 결과

compRef의 $el: &amp;lt;div style=&amp;quot;height:300px&amp;quot;&amp;gt;hello&amp;lt;/div&amp;gt;
comp1Ref의 $el: #text&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;루트 노드가 하나인 컴포넌트(&lt;code&gt;Comp.vue&lt;/code&gt;)에서는 정상적으로 &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;를 가리키지만,&lt;/li&gt;
&lt;li&gt;루트 노드가 두 개인 컴포넌트(&lt;code&gt;Comp1.vue&lt;/code&gt;)에서는 &lt;code&gt;$el&lt;/code&gt;이 &lt;code&gt;#text&lt;/code&gt; 노드를 가리켰다.&lt;/li&gt;
&lt;li&gt;즉, &lt;code&gt;$el.style.clientHeight&lt;/code&gt;에 접근하려해도 텍스트 노드를 가리키기에 &lt;code&gt;undefined&lt;/code&gt;가 뜨는 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;  Vue 공식 문서의 설명&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Vue 공식 문서에서는 &lt;strong&gt;다중 루트 노드(Fragment)&lt;/strong&gt; 에 대해 다음과 같이 설명하고 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KL9JX/dJMb9aX1cCe/0kEc6C0TxflAnAma43SQlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KL9JX/dJMb9aX1cCe/0kEc6C0TxflAnAma43SQlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KL9JX/dJMb9aX1cCe/0kEc6C0TxflAnAma43SQlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKL9JX%2FdJMb9aX1cCe%2F0kEc6C0TxflAnAma43SQlK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;For components with multiple root nodes,&lt;br&gt;&lt;code&gt;$el&lt;/code&gt; will be the placeholder DOM node that Vue uses to keep track of the component&amp;#39;s position in the DOM(a text node, or a comment node in SSR hydration mode).&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;즉, 루트가 여러 개인 컴포넌트는 내부적으로 Fragment로 렌더링되며,&lt;/li&gt;
&lt;li&gt;이때 &lt;code&gt;$el&lt;/code&gt;은 실제 DOM 엘리먼트가 아닌 &lt;strong&gt;“Fragment의 앵커 노드(anchor node)”, 즉 #text를 가리키게&lt;/strong&gt; 된다.&lt;/li&gt;
&lt;li&gt;그렇다면 Vue는 Fragment를 렌더링할 때 어떤 DOM 구조를 만들기에 이런 현상이 발생하는 걸까?&lt;/li&gt;
&lt;li&gt;이번 시간에는 Vue 내부의 DOM 생성 방식과 &lt;code&gt;$el&lt;/code&gt;이 지정되는 방식을 살펴보며 이해해보겠다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;1. DOM 생성 방식 - Vue 내부 구조 분석하기&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Vue 3는 모든 컴포넌트를 Virtual DOM(VNode) 트리 형태로 표현하고,&lt;br&gt;이를 실제 브라우저 DOM으로 &lt;strong&gt;패치(patch)&lt;/strong&gt; 하여 렌더링한다.&lt;br&gt;렌더링 과정은 &lt;code&gt;renderer.ts&lt;/code&gt;의 &lt;code&gt;patch()&lt;/code&gt; 함수를 중심으로 진행된다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;1) Vue의 렌더링 파이프라인 한눈에 보기&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;렌더러(&lt;a href=&quot;https://github.com/vuejs/core/blob/v3.5.22/packages/runtime-core/src/renderer.ts#L374&quot;&gt;&lt;code&gt;runtime-core/renderer.ts&lt;/code&gt;&lt;/a&gt;) 내부에서 모든 DOM 조작의 중심은 &lt;code&gt;patch()&lt;/code&gt;이다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;patch()&lt;/code&gt; 에서는 VNode의 타입에 따라 &lt;code&gt;processElement()&lt;/code&gt;, &lt;code&gt;processComponent()&lt;/code&gt;, &lt;code&gt;processFragment()&lt;/code&gt; 등이 호출되며,&lt;br&gt;이 단계들이 합쳐져 하나의 화면을 완성한다.&lt;/li&gt;
&lt;li&gt;즉, Vue의 렌더러는 VNode의 타입에 따라 DOM 처리 방식을 선택한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(1) patch 함수 — DOM 생성의 중심 루프&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;https://github.com/vuejs/core/blob/v3.5.22/packages/runtime-core/src/renderer.ts#L374&quot;&gt;출처: vuejs/core - renderer.ts#L374&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const patch: PatchFn = (n1, n2, container, anchor, parentComponent) =&amp;gt; {
  const { type, ref, shapeFlag } = n2

  switch (type) {
    case Text: processText(...); break
    case Comment: processCommentNode(...); break
    case Static:
      if (n1 == null) mountStaticNode(...)
      else if (__DEV__) patchStaticNode(...)
      break
    case Fragment:
      processFragment(...)
      break
    default:
      if (shapeFlag &amp;amp; ShapeFlags.ELEMENT) processElement(...)
      else if (shapeFlag &amp;amp; ShapeFlags.COMPONENT) processComponent(...)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;patch()&lt;/code&gt;는 Vue 렌더러의 핵심으로, VNode 타입에 따라 어떤 DOM 생성 전략을 사용할지”결정한다.&lt;/li&gt;
&lt;li&gt;즉, Vue는 엘리먼트인지, 텍스트인지, Fragment인지에 따라 서로 다른 함수를 호출한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;type&lt;/code&gt;&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;호출 함수&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Text&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;processText()&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;텍스트 노드(&lt;code&gt;#text&lt;/code&gt;) 생성/업데이트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Comment&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;processCommentNode()&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;주석 노드 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Static&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;mountStaticNode()&lt;/code&gt; / &lt;code&gt;patchStaticNode()&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;정적 HTML 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Fragment&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;processFragment()&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;다중 루트(Fragment) 처리 — 루트 엘리먼트 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(기본)&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;processElement()&lt;/code&gt; / &lt;code&gt;processComponent()&lt;/code&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;일반 DOM 엘리먼트 혹은 컴포넌트 렌더링&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;h3&gt;(2) processElement() — 단일 루트 렌더링의 흐름&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;https://github.com/vuejs/core/blob/v3.5.22/packages/runtime-core/src/renderer.ts#L595&quot;&gt;출처: vuejs/core - processElement.ts#L595&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const processElement = (...) =&amp;gt; {
  // (a) 네임스페이스(SVG/MathML 등) 구분
  if (n2.type === &amp;#39;svg&amp;#39;) namespace = &amp;#39;svg&amp;#39;
  else if (n2.type === &amp;#39;math&amp;#39;) namespace = &amp;#39;mathml&amp;#39;

  // (b) 최초 렌더링
  if (n1 == null) mountElement(...)

  // (c) 업데이트 렌더링
  else patchElement(...)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;(a) 네임스페이스 구분&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt;나 &lt;code&gt;&amp;lt;math&amp;gt;&lt;/code&gt; 같은 특수 DOM은 별도 네임스페이스에서 생성된다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(b) 최초 렌더링&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;mountElement()&lt;/code&gt;가 &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;를 생성하고 &lt;code&gt;n2.el&lt;/code&gt;에 저장한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(c) 업데이트 렌더링&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;기존 DOM과 새로운 VNode를 diff하여 필요한 부분만 최소 변경한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;h3&gt;(3) publicPropertiesMap - $el은 어떻게 연결되는가&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;https://github.com/vuejs/core/blob/v3.5.22/packages/runtime-core/src/componentPublicInstance.ts#L370&quot;&gt;출처: vuejs/core - componentPublicInstance.ts#L370&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Vue는 &lt;code&gt;$el&lt;/code&gt;, &lt;code&gt;$data&lt;/code&gt;, &lt;code&gt;$props&lt;/code&gt;, &lt;code&gt;$refs&lt;/code&gt; 등의 인스턴스 속성을 &lt;code&gt;publicPropertiesMap&lt;/code&gt; 이라는 매핑 테이블로 관리한다.&lt;/li&gt;
&lt;li&gt;이 매핑은 컴포넌트의 &lt;code&gt;proxy&lt;/code&gt; 객체에서 호출될 때 어떤 내부 필드로 접근할지를 정의한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;$el&lt;/code&gt; 접근 시 내부적으로는 &lt;code&gt;instance.vnode.el&lt;/code&gt;을 반환한다.&lt;/li&gt;
&lt;li&gt;이 구조로 인해 Vue는 컴포넌트 외부에서 내부 DOM에 직접 접근할 수 있게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const publicPropertiesMap: PublicPropertiesMap = extend(Object.create(null), {
  // ...
  $el: i =&amp;gt; i.vnode.el,       // ✅ $el 접근 시 vnode.el을 그대로 반환
  $data: i =&amp;gt; i.data,
  $props: i =&amp;gt; i.props,
  $refs: i =&amp;gt; i.refs,
  // ...
})&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;


&lt;ul&gt;
&lt;li&gt;렌더링 연결 구조는 다음과 같다:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mountElement() → vnode.el = &amp;lt;div&amp;gt;
                 ↓
componentInstance.vnode.el = vnode.el
                 ↓
publicPropertiesMap.$el = i =&amp;gt; i.vnode.el
                 ↓
instance.proxy.$el → &amp;lt;div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;결과적으로 &lt;code&gt;$el&lt;/code&gt;은 컴포넌트의 루트 DOM 엘리먼트를 참조하게 된다.&lt;/li&gt;
&lt;li&gt;즉, &lt;code&gt;$el.style.clientHeight&lt;/code&gt;, &lt;code&gt;$el.offsetWidth&lt;/code&gt; 등의 접근이 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(4) processFragment() — 다중 루트 렌더링의 구조&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;https://github.com/vuejs/core/blob/v3.5.22/packages/runtime-core/src/renderer.ts#L1025&quot;&gt;출처: vuejs/core - renderer.ts#L417&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;그럼 루트가 두 개라면? 즉, Fragment(다중 루트) 컴포넌트에서는 어떤 일이 벌어질까?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!-- Comp1.vue --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;hello1&amp;lt;/div&amp;gt;
  &amp;lt;div&amp;gt;hello2&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;Vue는 자동으로 이 다중루트를 Fragment VNode로 감싸서 렌더링한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;VNode {
  type: Fragment,
  children: [
    VNode({ type: &amp;#39;div&amp;#39;, children: &amp;#39;hello1&amp;#39; }),
    VNode({ type: &amp;#39;div&amp;#39;, children: &amp;#39;hello2&amp;#39; })
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;따라서, 이제 &lt;code&gt;patch()&lt;/code&gt;는 &lt;code&gt;processElement()&lt;/code&gt; 대신 &lt;code&gt;processFragment()&lt;/code&gt;를 호출하게 된다.&lt;/li&gt;
&lt;li&gt;이 지점에서 &lt;code&gt;$el&lt;/code&gt;의 구조가 완전히 달라진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const processFragment = (...) =&amp;gt; {
  // (a) Fragment용 시작/끝 앵커 노드(Text) 생성
  const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(&amp;#39;&amp;#39;))!
  const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(&amp;#39;&amp;#39;))!

  // (b) 최초 렌더링
  if (n1 == null) {
    hostInsert(fragmentStartAnchor, container, anchor)
    hostInsert(fragmentEndAnchor, container, anchor)
    mountChildren((n2.children || []), container, fragmentEndAnchor, ...)
  }
  // (c) 업데이트 렌더링
  else {
    if (patchFlag &amp;gt; 0 &amp;amp;&amp;amp; dynamicChildren) patchBlockChildren(...)
    else patchChildren(...)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;(a) Fragment의 가상 DOM 생성&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fragment는 실제 엘리먼트를 생성하지 않는다.&lt;/li&gt;
&lt;li&gt;대신 시작(&lt;code&gt;fragmentStartAnchor&lt;/code&gt;)과 끝(&lt;code&gt;fragmentEndAnchor&lt;/code&gt;)을 나타내는 빈 텍스트 노드(&lt;code&gt;#text&lt;/code&gt;) 두 개를 만들어 Fragment의 경계를 표시한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n2.el&lt;/code&gt;은 시작 Text Node를 가리키며, 이 값이 &lt;code&gt;$el&lt;/code&gt;로 노출된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;(b) 최초 렌더링&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;hostInsert()&lt;/code&gt;가 두 앵커 텍스트 노드를 container에 삽입한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mountChildren()&lt;/code&gt;이 두 앵커 사이에 실제 children(&lt;code&gt;div&lt;/code&gt;, &lt;code&gt;div&lt;/code&gt;)을 렌더링한다.&lt;/li&gt;
&lt;li&gt;렌더링 결과, &lt;code&gt;$el&lt;/code&gt;은 &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;가 아닌 &lt;strong&gt;Fragment의 시작 텍스트 노드&lt;/strong&gt;를 참조한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;#text(&amp;quot;&amp;quot;)          ← fragmentStartAnchor (이게 $el)
&amp;lt;div&amp;gt;hello1&amp;lt;/div&amp;gt;
&amp;lt;div&amp;gt;hello2&amp;lt;/div&amp;gt;
#text(&amp;quot;&amp;quot;)          ← fragmentEndAnchor&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;(c) 업데이트 렌더링&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fragment 자체는 실제 엘리먼트가 아니므로 patch 대상이 아니다.&lt;/li&gt;
&lt;li&gt;대신 내부 children만 비교(diff)하여 갱신한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;$el&lt;/code&gt;은 최초에 만들어진 &lt;code&gt;#text&lt;/code&gt; 노드를 계속 가리킨다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;2) $el이 다르게 나타나는 이유&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Fragment를 사용하는 순간, &lt;code&gt;$el&lt;/code&gt;은 더 이상 “DOM 엘리먼트”가 아니라 “Fragment의 시작 텍스트 노드”를 가리키게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상황&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;&lt;code&gt;$el&lt;/code&gt; 값&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;원인&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;단일 루트 (&lt;code&gt;&amp;lt;div&amp;gt;...&amp;lt;/div&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;HTMLElement&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;processElement()&lt;/code&gt;가 실제 엘리먼트를 생성함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다중 루트 (&lt;code&gt;&amp;lt;div/&amp;gt;&amp;lt;div/&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;#text&lt;/code&gt; 노드&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;processFragment()&lt;/code&gt;가 시작/끝 Text Node만 생성함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이 구조가 바로 Fragment 환경에서 &lt;code&gt;$el.{스타일 속성}&lt;/code&gt;이 undefined로 나오는 이유다.&lt;br&gt;이를 해결하려면 루트 구조를 명시적으로 정의하거나, 다른 방식으로 ref를 노출해야 한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;2. Fragment 환경에서의 $el 접근, 어떻게 해결할까?&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;앞서 살펴봤듯 &lt;code&gt;$el&lt;/code&gt;이 &lt;code&gt;#text&lt;/code&gt;로 출력되는 이유는 루트가 Fragment로 렌더링되어 실제 DOM 엘리먼트가 존재하지 않기 때문이다.&lt;/li&gt;
&lt;li&gt;이 문제를 해결하려면 “루트의 구조를 명시적으로 정의하거나, DOM 접근의 목적을 명확히 구분하는 것”이 중요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1) 대안 알아보기&lt;/h2&gt;
&lt;h3&gt;(1) 루트 노드 추가하기 — 가장 단순하고 안정적인 해결책&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Fragment는 여러 루트를 허용하지만, DOM 조작이나 스타일 접근이 필요한 컴포넌트라면 하나의 루트를 두는 것이 안전하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!-- 수정 전 예시 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;hello&amp;lt;/div&amp;gt;
  &amp;lt;div&amp;gt;world&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;

&amp;lt;!-- 개선된 예시 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div class=&amp;quot;root&amp;quot;&amp;gt;
    &amp;lt;div&amp;gt;hello&amp;lt;/div&amp;gt;
    &amp;lt;div&amp;gt;world&amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 방식을 사용하면, &lt;code&gt;$el&lt;/code&gt;이 항상 HTMLElement를 가리켜 스타일 접근이 가능하다.&lt;/li&gt;
&lt;li&gt;다만, 마크업 계층이 한 단계 깊어지는 단점이 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) defineExpose()로 내부 엘리먼트 노출하기&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;루트를 Fragment로 유지해야 하지만, 그 내부의 특정 DOM 엘리먼트를 부모에서 제어해야 한다면?&lt;/li&gt;
&lt;li&gt;&lt;code&gt;defineExpose()&lt;/code&gt;를 활용할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!-- Child.vue --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div ref=&amp;quot;rootEl&amp;quot;&amp;gt;hello&amp;lt;/div&amp;gt;
  &amp;lt;div&amp;gt;world&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;

&amp;lt;script setup&amp;gt;
import { ref, defineExpose } from &amp;#39;vue&amp;#39;

const rootEl = ref(null)
defineExpose({ rootEl }) // 부모에서 접근 가능하게 노출
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!-- Parent.vue --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;Child ref=&amp;quot;child&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;

&amp;lt;script setup&amp;gt;
import { useTemplateRef, onMounted } from &amp;#39;vue&amp;#39;
import Child from &amp;#39;./Child.vue&amp;#39;

const child = useTemplateRef(&amp;#39;child&amp;#39;)

onMounted(() =&amp;gt; {
  console.log(child.value.rootEl.style) // ✅ 정상 접근
})
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Fragment 구조를 유지하면서도 부모가 실제 DOM에 접근 가능하다.&lt;/li&gt;
&lt;li&gt;명시적으로 노출된 &lt;code&gt;ref&lt;/code&gt;만 접근하므로, 의도치 않은 의존 관계 방지할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;2) Fragment 사용시 주의할 점&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;$el.{스타일 속성}&lt;/code&gt;이 undefined로 나오는 현상은 Vue의 렌더링 구조에 따른 정상 동작이다.&lt;/li&gt;
&lt;li&gt;Fragment는 “루트 없는 템플릿”을 가능하게 하지만, &lt;code&gt;$el&lt;/code&gt;이 항상 실제 DOM을 보장하지 않는다.&lt;/li&gt;
&lt;li&gt;또한, Fragment는 DOM 구조뿐 아니라 렌더링 타이밍과 부모 &amp;amp; 자식 관계에도 영향을 준다.&lt;/li&gt;
&lt;li&gt;특히 다음과 같은 경우에 주의가 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;케이스&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;영향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Transition / Teleport / KeepAlive&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;내부적으로 “루트 엘리먼트”를 기준으로 작동하므로, 다중 루트일 경우 애니메이션이나 마운트 타이밍이 어긋날 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 UI 라이브러리 (Chart.js, Swiper 등)&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;$el&lt;/code&gt;이 실제 HTMLElement가 아니면 렌더링 실패 또는 초기화 오류가 발생할 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;따라서 루트 구조 변경은 단순한 마크업 수정이 아니라, 렌더링 전반의 동작 흐름에 영향을 미치는 구조적 변경임을 유의해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;3. 마치며…&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;이번 시간에는 &lt;strong&gt;Vue 3의 Fragment 렌더링 구조와 &lt;code&gt;$el&lt;/code&gt; 참조 방식&lt;/strong&gt;을 자세히 살펴보았다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;왜 다중 루트 컴포넌트에서 &lt;code&gt;$el&lt;/code&gt;이 &lt;code&gt;#text&lt;/code&gt;로 나타나는지, 그리고 이를 어떻게 안전하게 제어할 수 있는지를 단계별로 확인했다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Vue의 Fragment는 선언적 UI를 유연하게 구성하기 위한 추상화 계층이지만, &lt;code&gt;$el&lt;/code&gt;은 여전히 “물리적 DOM”에 기반한 속성이다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;이 둘의 차이를 이해하지 못하면 예상치 못한 동작을 경험하게 된다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;결국 중요한 것은 &lt;strong&gt;“DOM을 직접 다뤄야 하는 이유”를 명확히 하는 것&lt;/strong&gt;이다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;그리고 필요하다면, &lt;code&gt;ref&lt;/code&gt;, &lt;code&gt;defineExpose&lt;/code&gt;, 혹은 &lt;strong&gt;Style API&lt;/strong&gt; 등을 활용해 Vue의 렌더링 흐름과 충돌하지 않도록 안전하게 제어해야 한다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;</description>
      <category>개발 기술/사소하지만 놓치기 쉬운 개발 지식</category>
      <category>$el</category>
      <category>defineExpose</category>
      <category>renderer</category>
      <category>virtual dom</category>
      <category>Vue 3</category>
      <category>Vue Fragment</category>
      <category>다중 루트 컴포넌트</category>
      <category>렌더러</category>
      <category>앵커 노드 (#text)</category>
      <category>컴포넌트 스타일 접근</category>
      <author>GicoMomg</author>
      <guid isPermaLink="true">https://mong-blog.tistory.com/236</guid>
      <comments>https://mong-blog.tistory.com/entry/Vue-Fragment%EC%9D%98-%ED%95%A8%EC%A0%95-%EC%99%9C-el%EC%9D%80-Text-Node%EA%B0%80-%EB%90%A0%EA%B9%8C#entry236comment</comments>
      <pubDate>Sun, 26 Oct 2025 20:03:47 +0900</pubDate>
    </item>
    <item>
      <title>[TS &amp;times; 클린 아키텍처] 2편 &amp;mdash; 타입스크립트 한계와 Mapper: AST로 타입 검증하기</title>
      <link>https://mong-blog.tistory.com/entry/TS-%C3%97-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-2%ED%8E%B8-%E2%80%94-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%ED%95%9C%EA%B3%84%EC%99%80-Mapper-AST%EB%A1%9C-%ED%83%80%EC%9E%85-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0</link>
      <description>&lt;h1&gt;0. 들어가며…&lt;/h1&gt;
&lt;p&gt;최근에 클린 아키텍처를 적용하면서, &lt;code&gt;data → domain&lt;/code&gt; 변환을 담당하는 &lt;strong&gt;Mapper 클래스&lt;/strong&gt;를 만들었다.&lt;br&gt;이 과정에서 타입스크립트(TypeScript)를 적극적으로 활용해 타입 안정성을 확보했지만, 하나의 큰 벽에 부딪혔다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;타입스크립트의 타입은 런타임에 존재하지 않는다는 것!&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;즉, 코드 상에서는 안전해 보이지만, 실제 실행 환경(JS 런타임)에서는 그 모든 타입 정보가 사라진다.&lt;br&gt;결국 API나 외부 모듈에서 잘못된 타입의 데이터가 들어오더라도 이를 검증할 방법이 없었다.&lt;br&gt;1편에서는 검증을 위해, Zod 처럼 스키마 기반의 타입 검증기를 만들었으나 다음과 같은 3가지 문제가 있었다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;문제&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;성능&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;매번 스키마를 해석하며 검증함 → 반복 비용 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;안전성&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;타입 정의와 스키마가 불일치할 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DX(개발 경험)&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;타입이 중복 선언됨 (&lt;code&gt;interface&lt;/code&gt; + &lt;code&gt;z.object&lt;/code&gt; 둘 다 작성해야 함)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;그래서 이번 시간에는 &lt;strong&gt;타입 정보를 직접 AST(Abstract Syntax Tree)로 분석&lt;/strong&gt;해서, 빌드 시점에 자동으로 최적화된 검증 함수를 만들어보았다.&lt;br&gt;이번 글은 「TS × 클린 아키텍처」 시리즈의 마지막 편으로, AST를 이용해 런타임 타입 검증을 자동화하는 방법을 다룬다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;1. 타입을 AST로 분석해서 검증 함수를 만들자!&lt;/h1&gt;
&lt;h2&gt;1) 타입스크립트와 AST의 구조&lt;/h2&gt;
&lt;h3&gt;(1) 런타임 타입 검증의 어려움&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;타입스크립트를 처음 배울 때 흔히 이런 착각을 한다. “타입스크립트를 쓰면 타입 검증은 끝난 거 아니야?&lt;/li&gt;
&lt;li&gt;하지만 진짜 현실은 다르다. 타입스크립트는 어디까지나 &lt;strong&gt;정적 분석기(static analyzer)&lt;/strong&gt; 일 뿐이다.&lt;/li&gt;
&lt;li&gt;코드가 실행되는 런타임에는 타입 정보가 남아있지 않다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 타입 정보는 컴파일 이후 완전히 사라진다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;예를 들어 이런 코드가 있다고 해보자.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;interface User {
  id: number;
  name: string;
}

const user: User = { id: 1, name: &amp;quot;Lux&amp;quot; };&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;이 코드를 컴파일하면 다음처럼 변한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const user = { id: 1, name: &amp;quot;Lux&amp;quot; };&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;User&lt;/code&gt;라는 타입 선언은 자취도 없이 사라진다.&lt;/li&gt;
&lt;li&gt;타입스크립트의 타입 시스템은 “코드를 실행하기 전까지”만 존재한다.&lt;/li&gt;
&lt;li&gt;빌드가 끝나면, 모든 타입 정보는 삭제되고 순수한 자바스크립트 객체만 남는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;즉, 우리가 흔히 믿는 “타입 안정성”은 빌드 타임에서 유효하다.&lt;/li&gt;
&lt;li&gt;물론 약속된 인터페이스대로 개발된다면, 빌드 타임 검증으로 충분할 수 있다.&lt;/li&gt;
&lt;li&gt;하지만 외부 연계 작업을 할 경우 이 약속을 보장할 수 없기에, 디음과 같은 현상이 발생할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 외부 API에서 응답이 이렇게 바뀜

{ id: &amp;quot;1&amp;quot;, name: &amp;quot;Lux&amp;quot; } // id가 string!&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;하지만 프론트엔드는 여전히 이렇게 믿고 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;interface User {
  id: number;
  name: string;
}

const user: User = await fetchUser();
console.log(user.id.toFixed(2)); //   런타임 에러&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;타입스크립트는 “id가 number여야 한다”고 알려줬지만, 실제로 들어온 건 string이었고&lt;/li&gt;
&lt;li&gt;결국 런타임에서 &lt;code&gt;toFixed&lt;/code&gt;가 undefined를 호출하며 서비스에 문제를 야기한다.  &lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(3) 그래서 런타임 검증이 필요하다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;런타임에서 실제 값이 타입과 일치하는지를 확인하려면 다음과 같은 &lt;strong&gt;타입 가드(type guard)&lt;/strong&gt; 함수가 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;function isUser(value: unknown): value is User {
  return (
    typeof value === &amp;quot;object&amp;quot; &amp;amp;&amp;amp;
    value !== null &amp;amp;&amp;amp;
    typeof (value as any).id === &amp;quot;number&amp;quot; &amp;amp;&amp;amp;
    typeof (value as any).name === &amp;quot;string&amp;quot;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;그럼 외부 데이터를 받아올 때 이렇게 쓸 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const data = await fetch(&amp;quot;/api/user&amp;quot;).then(res =&amp;gt; res.json());

if (!isUser(data)) {
  throw new Error(&amp;quot;Invalid user data&amp;quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이렇게 하면 런타임에서도 타입 안전성을 확보할 수 있다.&lt;/li&gt;
&lt;li&gt;하지만 문제가 있다 — 바로, 이 함수를 모든 타입마다 수동으로 작성할 수는 없다는 것…!&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(4) 런타임 타입 검증을 자동화하자&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;결국 원하는 건 단 하나다.&lt;/li&gt;
&lt;li&gt;타입 정의 한 번으로, 검증 함수까지 자동으로 생성되는 시스템.&lt;/li&gt;
&lt;li&gt;즉, &lt;code&gt;interface User&lt;/code&gt;를 정의하면, 그 구조를 기반으로 자동으로 타입 검증 함수가 만들어지는 것이다.&lt;/li&gt;
&lt;li&gt;이를 위해선 “코드에서 타입 정보를 읽는 방법”이 필요하고, 그 해답이 바로 &lt;strong&gt;AST(Abstract Syntax Tree)&lt;/strong&gt; 다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;내용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;문제&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;TypeScript 타입은 런타임에서 사라진다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;결과&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;외부 데이터의 타입 불일치가 발생해도 에러가 안 잡힌다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;해결 방향&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;타입 정보를 AST로 분석해, 런타임 타입 검증 코드를 자동 생성한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;2) AST가 무엇인가&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;이제 본격적으로 타입 정보를 “코드로부터” 읽어와야 한다.&lt;/li&gt;
&lt;li&gt;그 시작점이 바로 &lt;strong&gt;AST(Abstract Syntax Tree, 추상 구문 트리)&lt;/strong&gt; 다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(1) 코드가 트리로 변환된다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;TypeScript 컴파일러는 우리가 작성한 코드를 &lt;strong&gt;트리 구조&lt;/strong&gt;로 해석한다.&lt;/li&gt;
&lt;li&gt;이 트리를 AST(Abstract Syntax Tree)라고 부른다.&lt;/li&gt;
&lt;li&gt;예를 들어 다음 코드가 있다고 해보자.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;interface User {
  id: number;
  name: string;
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;이 코드는 컴파일러 내부에서 다음과 같은 트리로 해석된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;SourceFile
 └─ InterfaceDeclaration (name: &amp;quot;User&amp;quot;)
     ├─ PropertySignature (name: &amp;quot;id&amp;quot;)
     │   └─ TypeReference (number)
     └─ PropertySignature (name: &amp;quot;name&amp;quot;)
         └─ TypeReference (string)&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;즉, 코드는 더 이상 “문자열”이 아니라, “의미 단위로 쪼개진 노드들의 트리”로 변환된다.&lt;/li&gt;
&lt;li&gt;이 트리를 이용하면 “이 타입이 어떤 구조인지”를 프로그램적으로 분석할 수 있게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 실제로 AST를 출력해보자&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;TypeScript는 내부 API로 &lt;code&gt;createSourceFile&lt;/code&gt;을 제공한다.&lt;/li&gt;
&lt;li&gt;이걸 이용하면 문자열을 바로 AST로 바꿀 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import ts from &amp;quot;typescript&amp;quot;;

const code = `
interface User {
  id: number;
  name: string;
}
`;

const source = ts.createSourceFile(
  &amp;quot;virtual.ts&amp;quot;,
  code,
  ts.ScriptTarget.ESNext,
  true
);

console.log(source);&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 코드를 실행하면 &lt;code&gt;SoureFileObject&lt;/code&gt; 객체가 출력된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;이 객체는 &lt;code&gt;kind&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;members&lt;/code&gt; 등 여러 속성으로 이루어져있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;60%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKIUiP/btsQ4P8BIBx/46IWjWKdkEIMRO6hfQFEl0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKIUiP/btsQ4P8BIBx/46IWjWKdkEIMRO6hfQFEl0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKIUiP/btsQ4P8BIBx/46IWjWKdkEIMRO6hfQFEl0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKIUiP%2FbtsQ4P8BIBx%2F46IWjWKdkEIMRO6hfQFEl0%2Fimg.png&quot; width=&quot;60%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;즉, &lt;code&gt;User&lt;/code&gt;라는 타입이 &lt;strong&gt;직접 탐색할 수 있는 데이터 구조&lt;/strong&gt;로 바뀐다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(3) AST를 탐색하는 방법&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SoureFileObject&lt;/code&gt; 를 &lt;code&gt;forEachChild(node, callback)&lt;/code&gt;로 순회할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;ts.forEachChild(source, (node) =&amp;gt; {
  if (ts.isInterfaceDeclaration(node)) {
    console.log(&amp;quot;Interface name:&amp;quot;, node.name.text);
    node.members.forEach((m) =&amp;gt; {
      if (ts.isPropertySignature(m)) {
        console.log(&amp;quot;-&amp;quot;, m.name.getText(), &amp;quot;:&amp;quot;, m.type?.getText());
      }
    });
  }
});&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;그럼 다음과 같이 인터페이스 이름, 속성값에 접근할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;// 출력 결과:

Interface name: User
- id : number
- name : string&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;우리가 만드는 런타임 타입 가드는 결국 AST를 해석해서 Type을 얻고,&lt;/li&gt;
&lt;li&gt;그 Type을 기반으로 검증 함수를 생성할 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(4) 결국 AST가  핵심이다.&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;TypeScript 타입은 런타임에 없지만, AST로 타입 구조를 읽어 코드(검증 함수)로 생성할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;내용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AST란?&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;코드의 문법 구조를 표현한 트리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;왜 필요한가&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;런타임에는 타입이 사라지므로, AST로 타입 구조를 추출해야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;결국 목표는&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;AST → Type → Validation Function&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;3) AST로 타입을 분석해보자&lt;/h2&gt;
&lt;p&gt;핵심 개념을 이해했으니, 이제 AST를 이용해 타입 검증 함수를 만드는 과정을 살펴보자.&lt;br&gt;이번에 소개하는 전체 코드는 이 &lt;a href=&quot;https://github.com/KumJungMin/runtypex&quot;&gt;레포지토리&lt;/a&gt;에 정리되어 있다.&lt;/p&gt;
&lt;h3&gt;(1) 설계&lt;/h3&gt;
&lt;p&gt;핵심 아이디어는 매우 단순하다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;빌드 타임에 &lt;code&gt;makeValidate&amp;lt;T&amp;gt;()&lt;/code&gt; 호출을 찾고&lt;br/&gt;-&amp;gt; 제네릭 타입 T를 TypeScript AST로 해석한 뒤&lt;br/&gt;-&amp;gt; 그 타입을 만족하는지 검사하는 검증 함수를 생성한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;즉, &lt;code&gt;makeValidate&amp;lt;T&amp;gt;()&lt;/code&gt;에 타입을 넘겨 호출하면, 런타임에는 이미 생성된 검증 함수가 실행되어야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;interface User {
  id: number;
  name: string;
}

const validate = makeValidate&amp;lt;User&amp;gt;(); // true | false
&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;p&gt;이 동작은 크게 3단계로 이루어진다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;동작&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TypeChecker 단계&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;TS 컴파일러의 &lt;code&gt;TypeChecker&lt;/code&gt;가 &lt;code&gt;T&lt;/code&gt;의 구조를 읽는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;코드 생성 단계 (core 폴더)&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;여러 &lt;code&gt;emitXXX()&lt;/code&gt; 핸들러가 타입 → 조건식 문자열로 변환하고, &lt;br/&gt;&lt;code&gt;GenContext&lt;/code&gt;가 이를 조립해 검증 함수 소스 문자열을 만든다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;치환 단계 (transformer 폴더)&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;소스의 &lt;code&gt;makeValidate&amp;lt;T&amp;gt;()&lt;/code&gt; 호출을 찾아 &lt;br/&gt;&lt;code&gt;input =&amp;gt; /* 조건식 */&lt;/code&gt; 같은 실제 함수 리터럴로 AST 치환한다. &lt;br/&gt;최종 번들에는 “검증 함수”만 남는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;이제 실제 구현 코드를 보면서 각 단계를 자세히 살펴보자.&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;(2) &lt;strong&gt;core/emitXXX.ts — 타입을 검증하는 문자열 생성기&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;핸들러는 “&lt;strong&gt;타입 → 조건식 문자열&lt;/strong&gt;”로 변환하는 역할을 한다.&lt;br&gt;즉, 타입 정보를 받아 해당 값을 검사하는 &lt;strong&gt;JavaScript 조건식 문자열&lt;/strong&gt;을 만들어낸다.&lt;br&gt;여러 핸들러 중 여기서는 대표적으로 &lt;code&gt;emitLiteralOrEnum.ts&lt;/code&gt;와 &lt;code&gt;emitUnionOrIntersection.ts&lt;/code&gt;를 살펴본다.&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;  emitLiteralOrEnum.ts — 리터럴 및 Enum 타입 처리기&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;이 함수는 TypeScript의 타입 객체(&lt;code&gt;ts.Type&lt;/code&gt;)를 분석하여&lt;/li&gt;
&lt;li&gt;주어진 값이 특정 리터럴(literal) 또는 열거형(enum) 값과 일치하는지 검사하는&lt;/li&gt;
&lt;li&gt;조건식 문자열을 생성한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export function emitLiteralOrEnum(
  _ctx: GenContext,
  expr: string,
  t: ts.Type
): string | null {
 // ① 리터럴 타입이면
  if (t.isLiteral()) {
    const value = (t as ts.LiteralType).value;
    const isString = typeof value === &amp;quot;string&amp;quot;;
    const newValue = isString ? JSON.stringify(value) : String(value);

    return `${expr}===${newValue}`;
  }

  // ② Enum 타입이면
  const isEnum = t.flags &amp;amp; ts.TypeFlags.EnumLike;
  if (isEnum) {
    const enumValues = _extractEnumValues(t); // enum 값 배열 추출
    if (enumValues.length) {
      return `(${enumValues.map(v =&amp;gt; `${expr}===${v}`).join(&amp;quot;||&amp;quot;)})`;
    }
  }

  // ③ 두 경우 모두 아니라면 처리하지 않음
  return null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;만약, 다음과 같은 타입이 들어온다고 해보자.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type T = &amp;quot;hello&amp;quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;이 타입이 &lt;code&gt;emitLiteralOrEnum()&lt;/code&gt;에 전달되면,&lt;/li&gt;
&lt;li&gt;함수는 타입 정보를 분석해 다음과 같은 조건식 문자열을 생성한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;&amp;#39;x===&amp;quot;hello&amp;quot;&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;이 문자열은 이후 &lt;strong&gt;core/index.ts&lt;/strong&gt;의 &lt;code&gt;emitGuardFromType()&lt;/code&gt; 를 거쳐&lt;/li&gt;
&lt;li&gt;다음과 같은 실행 가능한 검증 함수가 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 최종 결과 (빌드 시점에 생성)
(input) =&amp;gt; input === &amp;quot;hello&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;p&gt;  &lt;strong&gt;emitUnionOrIntersection.ts — 유니온 / 인터섹션 타입 처리기&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;이 함수는 타입이 유니온(Union) (A | B)인지, 혹은 인터섹션(Intersection) (A &amp;amp; B)인지 판별하여&lt;/li&gt;
&lt;li&gt;그에 맞는 조합 조건식 문자열을 생성한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import ts from &amp;quot;typescript&amp;quot;;
import type { GenContext } from &amp;quot;./index&amp;quot;;

export function emitUnionOrIntersection(
  ctx: GenContext,
  expr: string,
  t: ts.Type
): string | null {
  // ① Union 타입인 경우 (A | B)
  if (t.isUnion()) {
    // 각 구성 타입(tt)에 대해 ctx.emit()으로 조건식을 생성하고, ||로 연결
    return `(${t.types.map(tt =&amp;gt; ctx.emit(expr, tt)).join(&amp;quot;||&amp;quot;)})`;
  }

  // ② Intersection 타입인 경우 (A &amp;amp; B)
  if (t.isIntersection()) {
    // 각 구성 타입(tt)에 대해 ctx.emit()으로 조건식을 생성하고, &amp;amp;&amp;amp;로 연결
    return `(${t.types.map(tt =&amp;gt; ctx.emit(expr, tt)).join(&amp;quot;&amp;amp;&amp;amp;&amp;quot;)})`;
  }

  // ③ 둘 다 아니라면 처리하지 않음
  return null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;만약 다음과 같은 타입이 들어오면&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type U = &amp;quot;yes&amp;quot; | &amp;quot;no&amp;quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;emitUnionOrIntersection()&lt;/code&gt;은 각 구성 타입(&amp;quot;yes&amp;quot;, &amp;quot;no&amp;quot;)을 재귀적으로 검사하여 다음과 같은 문자열을 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;&amp;#39;(input===&amp;quot;yes&amp;quot;||input===&amp;quot;no&amp;quot;)&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;이 역시 &lt;code&gt;emitGuardFromType()&lt;/code&gt;을 거치면 다음과 같은 최종 검증 함수로 바뀐다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;(input) =&amp;gt; (input === &amp;quot;yes&amp;quot; || input === &amp;quot;no&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;h3&gt;(3) transformer/ — “빌드 시점”에 검증 함수로 치환하기&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;AST 기반 검증기는 &lt;strong&gt;빌드 시점&lt;/strong&gt;에 타입 정보를 분석하여, 그 타입을 검사하는 실제 &lt;strong&gt;검증 함수 코드&lt;/strong&gt;를 자동으로 생성한다.&lt;/li&gt;
&lt;li&gt;이를 위해 헬퍼 함수 &lt;code&gt;makeValidate&amp;lt;T&amp;gt;()&lt;/code&gt;를 구현했다.&lt;/li&gt;
&lt;li&gt;이 함수는 코드상에서는 단순히 “검증 함수를 만드는 호출”처럼 보이지만,&lt;/li&gt;
&lt;li&gt;실제로는 빌드 시점에 AST에서 탐색되어 &lt;strong&gt;진짜 함수로 치환되는 마커&lt;/strong&gt; 역할을 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;interface User {
  id: number;
  name: string;
}

const validate = makeValidate&amp;lt;User&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;빌드 과정에서 AST 분석기가 &lt;code&gt;makeValidate&amp;lt;T&amp;gt;()&lt;/code&gt;를 찾아내고,&lt;/li&gt;
&lt;li&gt;그 제네릭 타입 T의 구조를 분석하여 타입에 맞는 검증 함수 코드로 자동 변환한다.&lt;/li&gt;
&lt;li&gt;이 치환 작업은 &lt;strong&gt;Vite 같은 빌드 플러그인(transformer) 내부&lt;/strong&gt;에서 수행된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;⚙️ 작동 단계&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;아래는 &lt;code&gt;vite-plugin-runtypex&lt;/code&gt; 플러그인이 빌드 중 수행하는 절차이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1️⃣&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;vite-plugin-runtypex&lt;/code&gt; 플러그인이 실행된다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2️⃣&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;코드에서 &lt;code&gt;makeValidate&amp;lt;...&amp;gt;()&lt;/code&gt; 패턴을 탐색한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3️⃣&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;TypeScript Compiler API(&lt;code&gt;checker&lt;/code&gt;)를 이용해 타입 구조를 분석한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4️⃣&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;core/emitGuardFromType()&lt;/code&gt;을 호출해 “타입 → 검증식 문자열”을 생성한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5️⃣&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;기존 소스 코드의 &lt;code&gt;makeValidate&amp;lt;T&amp;gt;()&lt;/code&gt; 부분을 생성된 검증 함수로 치환한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6️⃣&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;빌드가 완료되면, 런타임에는 이미 완성된 검증 함수만 남는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;  Vite 플러그인 예시 코드&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;다음은 실제 vite-plugin-runtypex의 주요 구현 부분이다.&lt;/li&gt;
&lt;li&gt;이 플러그인은 &lt;code&gt;makeValidate&amp;lt;T&amp;gt;()&lt;/code&gt;를 AST에서 찾아, 빌드 시점에 검증 함수로 교체한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export default function vitePluginRuntypex({ removeInProd } = {}): Plugin {
  return {
    // (1)
    name: &amp;quot;vite-plugin-runtypex&amp;quot;,
    enforce: &amp;quot;pre&amp;quot;, 

    //(2)
    transform(code, id) {
      if (!/\.tsx?$/.test(id)) return; // TS/TSX 파일만 처리
      if (!/make(?:Validate)&amp;lt;/.test(code)) return; // makeValidate 코드만 탐색

      // (3) TypeScript Program / Checker 생성
      const { program, checker } = createProgramFor(id);
      const sf = program.getSourceFile(id);
      if (!sf) return;

      let out = code;

      // (4) makeValidate&amp;lt;T&amp;gt;() 구문 탐색 및 치환
      out = out.replace(/makeValidate&amp;lt;([^&amp;gt;]+)&amp;gt;\(\)/g, (_m, typeName) =&amp;gt; {

        // (5) 타입 분석 및 검증 함수 생성
        const type = resolveTypeByName(program, sf, checker, typeName.trim());
        return type ? emitGuardFromType(checker, type) : _m;
      });

      // (6) 코드가 변경된 경우에만 결과 반환
      return out === code ? null : { code: out, map: null };
    },
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align=&quot;left&quot;&gt;구분&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;(1) 플러그인 설정&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;name&lt;/code&gt;과 &lt;code&gt;enforce: &amp;quot;pre&amp;quot;&lt;/code&gt;로 Vite 내 실행 순서를 지정한다. &lt;br/&gt;&lt;code&gt;&amp;quot;pre&amp;quot;&lt;/code&gt;는 다른 변환 전에 이 플러그인이 실행되도록 설정한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;(2) transform 훅&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;실제 코드 변환이 일어나는 부분. &lt;br/&gt;Vite는 모든 파일을 &lt;code&gt;transform()&lt;/code&gt; 훅에 전달한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;(3) TypeScript Program / Checker 생성&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;createProgramFor(id)&lt;/code&gt;는 타입 해석을 위해 TS Compiler API의 &lt;code&gt;Program&lt;/code&gt;과 &lt;code&gt;TypeChecker&lt;/code&gt;를 생성한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;(4) makeValidate 탐색&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;정규식을 이용해 &lt;code&gt;makeValidate&amp;lt;...&amp;gt;()&lt;/code&gt; 구문을 찾는다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;(5) 타입 분석 및 함수 생성&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;resolveTypeByName()&lt;/code&gt;으로 타입 정보를 얻고, &lt;br/&gt;core의 &lt;code&gt;emitGuardFromType()&lt;/code&gt;을 호출해 해당 타입의 검증 함수를 문자열 형태로 생성한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;(6) 최종 반환&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;코드가 수정된 경우 &lt;code&gt;{ code, map: null }&lt;/code&gt; 형태로 반환하여 Vite가 변환 결과를 반영한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;  예시 변환 결과&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;플러그인이 실행되면, 다음 코드가&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;interface User {
  id: number;
  name: string;
}

const validate = makeValidate&amp;lt;User&amp;gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;빌드 후에는 자동으로 검증 함수로 변환된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const validate = (input) =&amp;gt; (
  typeof input === &amp;quot;object&amp;quot; &amp;amp;&amp;amp;
  input !== null &amp;amp;&amp;amp;
  typeof input.id === &amp;quot;number&amp;quot; &amp;amp;&amp;amp;
  typeof input.name === &amp;quot;string&amp;quot;
);&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;런타임에서 검증 함수를 실행하면, 타입 일치 여부를 boolean으로 리턴해준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;validate({ id: 1, name: &amp;quot;Lux&amp;quot; });  // ✅ true
validate({ id: &amp;quot;nope&amp;quot; });          // ❌ false
&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;✅ 정리&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;런타임에서 타입을 해석하지 않는다.&lt;/li&gt;
&lt;li&gt;빌드 시점에 타입 정보를 읽고 검증 함수를 자동 생성한다.&lt;/li&gt;
&lt;li&gt;결과적으로 런타임에는 &lt;strong&gt;순수한 자바스크립트 함수만 남는다.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;타입이 변경되면 빌드 시점에 검증 로직도 자동 갱신된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;4) 핵심을 정리해보자&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;앞서 우리는 AST의 개념과, 어떻게 타입 정보를 기반으로 검증 함수를 만들어내는지 살펴보았다.&lt;br&gt;이제 마지막으로, 그 핵심 원리를 간단히 정리해보자.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;(1) 검증기의 동작 원리&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;빌드 타임에 &lt;code&gt;makeValidate&amp;lt;T&amp;gt;()&lt;/code&gt; 같은 헬퍼 함수를 AST에서 탐색한다.&lt;/li&gt;
&lt;li&gt;TypeScript Compiler API(&lt;code&gt;TypeChecker&lt;/code&gt;)로 제네릭 타입 &lt;code&gt;T&lt;/code&gt;의 구조를 정적으로 분석한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;core/emitGuardFromType()&lt;/code&gt;을 호출해 타입 구조를 조건식 문자열로 변환한다.&lt;/li&gt;
&lt;li&gt;변환된 문자열을 &lt;code&gt;(input) =&amp;gt; ...&lt;/code&gt; 형태의 검증 함수 코드로 치환한다.&lt;/li&gt;
&lt;li&gt;결과적으로 런타임에는 완성된 자바스크립트 함수만 남는다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;즉, 타입 검증 로직이 “실행 시점”이 아니라 “빌드 시점”에 미리 만들어진다.&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 핵심 개념&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AST 기반 변환기&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  → 타입 정보를 런타임에서 파싱하지 않고, 컴파일러의 AST 분석으로 해결한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;자동 코드 생성기&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  → &lt;code&gt;makeValidate&amp;lt;T&amp;gt;()&lt;/code&gt; 호출을 실제 타입 검증 함수 코드로 자동 치환한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zero-runtime Reflection&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  → 런타임에는 타입 정보가 전혀 남지 않으며, 순수 자바스크립트 함수만 실행된다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;변경 자동 반영&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  → 타입 정의(&lt;code&gt;interface&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;enum&lt;/code&gt;)가 수정되면, 빌드 시 검증 로직이 자동 갱신된다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(3) 성능상 이점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;지난 1편에 구현했던 스키마 방식과 비교해보면, AST 방식이 성능이 더 좋은 걸 알 수 있다.&lt;/li&gt;
&lt;li&gt;그 이유는 AST 검증기는 스키마처럼 런타임에 타입을 해석하지 않는다. 대신 빌드 시점에 타입 정보를 분석해 검증 함수를 생성하기에 속도면에서 더 빨랐다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;overflow-x: auto; max-width: 60%;&quot;&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;케이스&lt;/th&gt;
&lt;th&gt;스키마 방식&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;AST 검증 방식 (Runtypex)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;차이&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;하드 데이터셋)&lt;/td&gt;
&lt;td&gt;4.569 s&lt;/td&gt;
&lt;td&gt;0.472 s&lt;/td&gt;
&lt;td&gt;약 9.7× 빠름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;간단 데이터셋&lt;/td&gt;
&lt;td&gt;3.183 s&lt;/td&gt;
&lt;td&gt;0.329 s&lt;/td&gt;
&lt;td&gt;약 9.6× 빠름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;혼합 데이터셋&lt;/td&gt;
&lt;td&gt;5.197 s&lt;/td&gt;
&lt;td&gt;0.511 s&lt;/td&gt;
&lt;td&gt;약 10.1× 빠름&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/div&gt;

&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;실제 동작 관점에서 두 방식을 비교하면 다음과 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;overflow-x: auto; max-width: 60%;&quot;&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;비교 항목&lt;/th&gt;
&lt;th&gt;스키마 검증기&lt;/th&gt;
&lt;th&gt;AST 검증기( &lt;code&gt;runtypex&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;타입 해석 시점&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;런타임&lt;/td&gt;
&lt;td&gt;빌드 타임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;검증 로직 생성&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;매번 스키마 해석&lt;/td&gt;
&lt;td&gt;사전 생성된 함수 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;오버헤드&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;높음 (Reflection, 객체 순회)&lt;/td&gt;
&lt;td&gt;거의 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;빌드 후 코드 크기&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;스키마 객체 포함&lt;/td&gt;
&lt;td&gt;순수 함수만 남음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;성능&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;느림 (동적 파싱)&lt;/td&gt;
&lt;td&gt;매우 빠름 (단순 조건문 실행)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
  &lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;결국 차이는 ‘언제 타입을 검증하느냐’에 있다.  &lt;/li&gt;
&lt;li&gt;runtypex는 스키마 방식에 비해 런타임이 아닌 빌드 타임으로 끌어올리기에, 타입 안정성과 실행 성능 모두 확보했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;2. 마치며…&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;처음 이 프로젝트를 시작했을 때의 목표는 단순했다. &lt;strong&gt;“런타임에서도 타입을 지키고 싶다.”&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;하지만 막상 구현을 시작하니, 생각보다 복잡했다.&lt;/li&gt;
&lt;li&gt;가장 먼저 시도한 방법은 Zod였다.&lt;/li&gt;
&lt;li&gt;스키마 기반으로 타입을 정의하고, 런타임에 그 스키마로 값을 검증하는 전형적인 구조였다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;그러나 곧 한계가 드러났다. 스키마 방식은 런타임에서 타입 정보를 다시 정의해야 한다.&lt;/li&gt;
&lt;li&gt;즉, 타입스크립트 타입을 한 번, 스키마를 또 한 번 — 두 번 선언해야 했다.&lt;/li&gt;
&lt;li&gt;이중 선언은 유지보수를 어렵게 만들고, 매번 스키마를 해석해야 하니 성능상 오버헤드도 피할 수 없었다.&lt;/li&gt;
&lt;li&gt;물론 Zod의 체이닝 API는 좋았다. &lt;code&gt;z.object({...}).array().optional()&lt;/code&gt;처럼 선언적이고 읽기 쉬웠다.&lt;/li&gt;
&lt;li&gt;하지만 “개발 경험(DX)”이 아무리 좋아도, 런타임 성능을 대가로 삼는 구조라면 결국 한계가 있었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;그래서 두 번째 방식을 선택했다. 바로 AST(Abstract Syntax Tree) 를 직접 탐색해, 빌드 시점에 검증 함수를 생성하는 방식이다.&lt;/li&gt;
&lt;li&gt;빌드 시점에 타입 정보를 분석해  검증 함수를 코드 형태로 생성해두면, 런타임에는 더 이상 타입스크립트도, 스키마도 필요 없다. 오직 순수한 자바스크립트 함수만 남는다.&lt;/li&gt;
&lt;li&gt;타입 안정성은 유지하면서도, 검증 속도는 지난 번 구현에 비해 10배 이상 빨라졌다.&lt;/li&gt;
&lt;li&gt;현재는 간단한 구조로 구현했지만 추가 테스트를 통해, 구조 개선과 더불어 검증용 헬퍼 함수를 추가로 구현해보고자 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;</description>
      <category>개발 기술/개발 이야기</category>
      <category>AST 기반 코드 생성</category>
      <category>AST 타입 분석</category>
      <category>TypeScript AST</category>
      <category>TypeScript Compiler API</category>
      <category>Zod 대안</category>
      <category>런타임 타입 안전성</category>
      <category>빌드 타임 타입 검증</category>
      <category>타입 검증 성능 비교</category>
      <category>타입스크립트 런타임 검증</category>
      <category>타입스크립트 타입 가드</category>
      <author>GicoMomg</author>
      <guid isPermaLink="true">https://mong-blog.tistory.com/235</guid>
      <comments>https://mong-blog.tistory.com/entry/TS-%C3%97-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-2%ED%8E%B8-%E2%80%94-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%ED%95%9C%EA%B3%84%EC%99%80-Mapper-AST%EB%A1%9C-%ED%83%80%EC%9E%85-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0#entry235comment</comments>
      <pubDate>Sun, 12 Oct 2025 23:40:34 +0900</pubDate>
    </item>
    <item>
      <title>[TS &amp;times; 클린 아키텍처] 1편 &amp;mdash; 타입스크립트 한계와 Mapper: 스키마로 런타임 검증하기</title>
      <link>https://mong-blog.tistory.com/entry/TS-%C3%97-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-1%ED%8E%B8-%E2%80%94-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%ED%95%9C%EA%B3%84%EC%99%80-Mapper-%EC%8A%A4%ED%82%A4%EB%A7%88%EB%A1%9C-%EB%9F%B0%ED%83%80%EC%9E%84-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0</link>
      <description>&lt;h1&gt;0. 들어가며&lt;/h1&gt;
&lt;p&gt;이번에 회사에서 새 레포지토리를 구성하면서 가장 먼저 고민한 것은 &lt;strong&gt;도메인 계층과 UI 계층을 어떻게 분리할 것인가&lt;/strong&gt;였다. &lt;/p&gt;
&lt;p&gt;기존에는 UI 코드 안에 모든 비즈니스 로직이 섞여 있었고, API 응답 객체를 그대로 사용하는 방식이었다. 하지만 이 방법은 다음과 같은 두 가지 문제를 만들었다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;문제 1. API 응답 객체를 그대로 사용&lt;/strong&gt;&lt;br&gt;  → &lt;code&gt;MER_UUID&lt;/code&gt;, &lt;code&gt;USR_NM&lt;/code&gt; 같은 축약 필드명이 코드 전반에 퍼져 가독성이 떨어졌고, 백엔드 필드명이 변경되면 프론트엔드 전체를 수정해야 했다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;문제 2. UI와 비즈니스 로직이 뒤섞임&lt;/strong&gt;&lt;br&gt;  → Vue/React 컴포넌트 파일 안에 UI 코드와 비즈니스 플로우가 함께 들어있어, 코드 변경 시 UI까지 영향을 받는 경우가 많았고 책임 분리가 어려웠다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;클린 아키텍처는 “의존성이 안쪽(도메인)으로만 흐르게 하여, 비즈니스 로직을 외부(UI, DB, 프레임워크) 변화로부터 보호하는 구조”이다.&lt;/p&gt;
&lt;p&gt;그 과정에서 자연스럽게 &lt;strong&gt;데이터 변환 로직&lt;/strong&gt;이 필요해졌다. 즉, 백엔드에서 내려오는 DTO(Data Transfer Object)를 &lt;/p&gt;
&lt;p&gt;프론트엔드의 Entity(도메인 모델)로 변환해 다룰 수 있는 장치가 필요했고, 이를 위해 &lt;code&gt;data → domain&lt;/code&gt; 변환을 담당하는 &lt;strong&gt;Mapper 클래스&lt;/strong&gt;를 선언했다.&lt;/p&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;하지만 여기서 또 다른 문제가 드러났다.&lt;br&gt;Mapper만으로는 구조를 정리할 수 있지만, &lt;strong&gt;타입스크립트는 컴파일 타임에만 타입을 보장&lt;/strong&gt;한다는 것이다!&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;즉, 런타임에 잘못된 데이터 타입이 들어와도 검증할 수 없다는 한계가 있었다.&lt;/p&gt;
&lt;p&gt;이를 보완하기 위해 런타임 검증을 지원하는 두 가지 방식을 참고했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/colinhacks/zod&quot;&gt;Zod&lt;/a&gt;: &lt;strong&gt;스키마 기반 Validator&lt;/strong&gt; (런타임에 스키마를 순회하면서 검증)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/samchon/typia&quot;&gt;typia&lt;/a&gt;: &lt;strong&gt;코드 생성 기반 Validator&lt;/strong&gt; (빌드 타임에 최적화된 검증 코드를 생성 → 런타임에서는 실행만)&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;물론 라이브러리를 그대로 가져다 쓸 수도 있지만, 보안 요건상 직접 구현해야 했고 동시에 학습 차원에서도 이를 시도해보기로 했다.&lt;/p&gt;
&lt;p&gt;이번 글은 &lt;strong&gt;“[TS × 클린 아키텍처] 1편 — 타입스크립트 한계와 Mapper”&lt;/strong&gt; 시리즈의 &lt;strong&gt;1차 글&lt;/strong&gt;이다.&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;시리즈는 총 2편으로 구성된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;이번 글(1차)에서는 &lt;strong&gt;Zod의 스키마 방식을 차용&lt;/strong&gt;해, &lt;strong&gt;Mapper와 스키마 변환 클래스를 직접 구현&lt;/strong&gt;하며 타입스크립트의 한계를 어떻게 극복하려 했는지 다룬다.&lt;/li&gt;
&lt;li&gt;다음 글(2차)에서는 &lt;strong&gt;typia의 방식을 차용&lt;/strong&gt;해, 빌드 타임에 &lt;strong&gt;Validator 함수&lt;/strong&gt;를 만들어 성능까지 보완한 결과를 살펴볼 예정이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;


&lt;hr&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h1&gt;1. Mapper의 필요성과 타입스크립트의 한계&lt;/h1&gt;
&lt;p&gt;본격적으로 구현 얘기로 들어가기 전에,&lt;/p&gt;
&lt;p&gt;먼저 &lt;strong&gt;왜 Mapper가 필요했는지&lt;/strong&gt;, 그리고 &lt;strong&gt;타입스크립트만으로는 왜 부족했는지&lt;/strong&gt;를 짚고 넘어가야 한다.&lt;/p&gt;
&lt;p&gt;이 부분을 이해해야 뒤에서 스키마 기반 접근이 왜 등장했는지 자연스럽게 연결된다.&lt;/p&gt;
&lt;br/&gt;

&lt;h2&gt;1) Mapper가 필요한 이유&lt;/h2&gt;
&lt;h3&gt;(1) Mapper의 역할&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;프론트엔드에서 백엔드 API를 연동하다 보면, 종종 이런 데이터를 그대로 쓰게 되는 경우가 있다:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;// dto/UserDTO.ts
// 백엔드에서 내려오는 원본 데이터 (DTO)

export type UserDTO = {
  MER_UUID: string;  // 사용자 고유 UUID
  USR_NM: string;    // 사용자 이름
  CST_AGE: number;   // 고객 나이
  USR_STS?: string;  // 상태 (ACTIVE, INACTIVE 등)
};&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;얼핏 보면 별문제 없어 보이지만, 실제 코드에서는 두 가지 불편함이 있다.&lt;/li&gt;
&lt;li&gt;MER_UUID, USR_NM 같은 축약 필드가 프론트 전역에 퍼지면 코드 해석에 지연을 준다.&lt;/li&gt;
&lt;li&gt;또한, 만약 백엔드가 MER_UUID → MERCHANT_ID로 바꾼다면? 프론트 전체에서 해당 필드를 쓰는 코드를 전부 수정해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;이 문제를 해결하기 위해, DTO ↔ Domain 변환을 담당하는 Mapper를 둔다.&lt;/li&gt;
&lt;li&gt;즉, 외부에서 내려오는 복잡한 데이터는 Mapper 안에서만 다루고, 도메인 계층에서는 항상 깨끗한 Entity만 쓰도록 한다.&lt;/li&gt;
&lt;li&gt;프론트엔드에서는 다음과 같은 Entity를 선언하면 된다:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;// domain/User.ts
// 프론트엔드에서 실제 사용하는 도메인 모델 (Entity)

export type User = {
  id: string;     //  MER_UUID → id
  name: string;   //  USR_NM → name
  age: number;    //  CST_AGE → age
  status?: string;
};&lt;/code&gt;&lt;/pre&gt;&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;그 다음, Mapper 클래스를 이용해, DTO -&amp;gt; Domain으로 변환한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;// mapper/UserMapper.ts

import type { UserDTO } from &amp;quot;../dto/UserDTO&amp;quot;;
import type { User } from &amp;quot;../domain/User&amp;quot;;

export class UserMapper {
  // DTO -&amp;gt; Domain (백엔드 → 프론트엔드)
  toDomain(dto: UserDTO): User {
    return {
      id: dto.MER_UUID,
      name: dto.USR_NM,
      age: dto.CST_AGE,
      status: dto.USR_STS,
    };
  }

  // Domain -&amp;gt; DTO (프론트엔드 → 백엔드)
  toDTO(entity: User): UserDTO {
    return {
      MER_UUID: entity.id,
      USR_NM: entity.name,
      CST_AGE: entity.age,
      USR_STS: entity.status,
    };
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;결과적으로, 이제 프론트에서는 항상 User Entity만 다루게 되어, 가독성과 더불어 외부 변경에도 안전해진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;사용 예시
const raw: UserDTO = {
  MER_UUID: &amp;quot;ABC-123&amp;quot;,
  USR_NM: &amp;quot;Alice&amp;quot;,
  CST_AGE: 30,
  USR_STS: &amp;quot;ACTIVE&amp;quot;,
};

const user = new UserMapper().toDomain(raw);

console.log(user);
// { id: &amp;quot;ABC-123&amp;quot;, name: &amp;quot;Alice&amp;quot;, age: 30, status: &amp;quot;ACTIVE&amp;quot; }&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;


&lt;h3&gt;(2) Mapper와 타입스크립트 조합의 한계&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Mapper를 도입하면 DTO ↔ Entity 변환이 깔끔해지고, 코드 가독성·유지보수성이 좋아진다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;하지만 여전히 한 가지 치명적인 빈틈이 남는다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;바로 타입스크립트의 타입은 컴파일 타임에만 동작한다는 점이다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;타입스크립트의 타입은 어디까지나 개발자가 코드를 작성할 때만 도움을 준다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;즉, 에디터 자동완성이나 컴파일러의 타입 체크 단계에서는 유효성을 보장하지만,&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;실제로 코드가 런타임에서 실행될 때는 타입 정보가 전부 사라진다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;다시 말해, 개발 중에는 &amp;quot;이 값은 number여야 해요&amp;quot; 라고 알려주지만,&lt;br/&gt;실행 중에는 그냥 자바스크립트 객체로만 존재한다.&lt;br/&gt;  그래서 외부에서 들어오는 값(API 응답, 사용자 입력 등) 은 타입스크립트만으로는 막을 수 없다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;ul&gt;
&lt;li&gt;다음과 같은 예시를 살펴보자.&lt;/li&gt;
&lt;li&gt;겉보기엔 타입이 맞는 것처럼 보이지만, 실제 실행 시점에는 전혀 다른 값이 들어와도 그대로 통과되는 상황이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type User = { id: string; age: number };

// ❌ 잘못된 값
const raw = { id: &amp;quot;123&amp;quot;, age: &amp;quot;스물&amp;quot; };

// any로 들어오면 런타임에서는 검증 불가
function printUser(user: User) {
  console.log(`${user.id} is ${user.age} years old.`);
}

printUser(raw as any);
// 출력: &amp;quot;123 is 스물 years old.&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;즉, 타입스크립트는 &amp;quot;약속&amp;quot;만 지켜줄 뿐, 실제 값이 올바른지는 보장하지 않는다는 게 문제다.&lt;/li&gt;
&lt;li&gt;이 때문에 API 응답이 잘못 내려오거나 사용자 입력이 엉뚱하게 들어와도 코드가 그대로 실행되며, &lt;/li&gt;
&lt;li&gt;결과적으로 잘못된 값이 도메인 로직 안으로 흘러 들어가게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;2) 타입스크립트의 빈틈을 메우는 스키마&lt;/h2&gt;
&lt;h3&gt;(1) 타입 스크립트의 단점, 런타임 데이터 문제&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;앞서 본 것처럼 타입스크립트는 &lt;strong&gt;컴파일 타임&lt;/strong&gt;에는 안전성을 보장해주지만, 실제로 프로그램이 실행되는 &lt;strong&gt;런타임&lt;/strong&gt; 단계에서는 타입 정보가 사라진다.&lt;/li&gt;
&lt;li&gt;이 말은 곧, &lt;strong&gt;API 응답이나 사용자 입력이 잘못 들어와도 그대로 통과할 수 있다&lt;/strong&gt;는 뜻이다.&lt;/li&gt;
&lt;li&gt;예를 들어, 서버에서 다음과 같은 데이터가 내려왔다고 하자.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 서버에서 내려온 잘못된 데이터
const rawUser1 = { id: &amp;quot;123&amp;quot;, age: &amp;quot;20&amp;quot; };   // 숫자 대신 문자열
const rawUser2 = { id: &amp;quot;456&amp;quot;, age: &amp;quot;스물&amp;quot; }; // 완전히 잘못된 값

function printUser(user: { id: string; age: number }) {
  console.log(`${user.id} is ${user.age} years old.`);
}

printUser(rawUser1 as any);
// 출력: 123 is 20 years old. (논리적으로 잘못된 값)

printUser(rawUser2 as any);
// 출력: 456 is 스물 years old. (완전히 잘못된 값)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;rawUser1&lt;/code&gt;의 경우 문자열 &lt;code&gt;&amp;quot;20&amp;quot;&lt;/code&gt;이 그대로 출력되지만, 실제로는 숫자로 계산할 수 없는 값이다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rawUser2&lt;/code&gt;의 경우 &lt;code&gt;&amp;quot;스물&amp;quot;&lt;/code&gt; 같은 한글 문자열이 들어왔음에도 오류 없이 실행된다.&lt;/li&gt;
&lt;li&gt;이처럼 타입스크립트 타입만으로는 &lt;strong&gt;런타임 데이터 유효성 검증&lt;/strong&gt;을 할 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 스키마: 데이터의 설계도&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;이 문제를 해결하기 위해 도입되는 개념이 바로 스키마(schema)다.&lt;/li&gt;
&lt;li&gt;스키마는 한마디로 “데이터의 설계도”다. &lt;/li&gt;
&lt;li&gt;어떤 필드가 존재해야 하는지, 타입은 무엇인지, 값의 범위는 어떻게 되는지, 필수/옵션 여부는 무엇인지 등의 규칙을 코드로 선언해둔 것이다.&lt;/li&gt;
&lt;li&gt;예를 들어, 나이는 &lt;strong&gt;0 이상의 정수&lt;/strong&gt;여야 한다는 스키마를 정의해보자.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const ageSchema = s.number().int().min(0);

const result = ageSchema.safeParse(&amp;quot;스물&amp;quot;);

if (!result.success) {
  console.log(result.error.issues);
  // 출력: &amp;quot;Expected number&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;입력값이 &lt;code&gt;&amp;quot;스물&amp;quot;&lt;/code&gt; 같은 잘못된 값이면 에러가 발생한다.&lt;/li&gt;
&lt;li&gt;올바른 값일 경우에만 변환된 결과를 안전하게 얻을 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(3) 타입 vs 스키마&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;타입스크립트(Type)&lt;/th&gt;
&lt;th&gt;스키마(Schema)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;동작 시점&lt;/td&gt;
&lt;td&gt;컴파일 타임&lt;/td&gt;
&lt;td&gt;런타임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;역할&lt;/td&gt;
&lt;td&gt;코드 작성 시 오류 탐지&lt;/td&gt;
&lt;td&gt;실행 중 실제 값 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;보장 범위&lt;/td&gt;
&lt;td&gt;개발자 코드 내부&lt;/td&gt;
&lt;td&gt;외부 입력(API, 사용자 입력 등)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;한계&lt;/td&gt;
&lt;td&gt;런타임에서는 타입 정보 사라짐&lt;/td&gt;
&lt;td&gt;성능 부담(검증 오버헤드 발생)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;타입스크립트의 타입 시스템만으로 런타임 안전성을 보장할 수 없는데, 잘못된 데이터가 들어와도 그대로 실행되기 때문이다.&lt;/li&gt;
&lt;li&gt;그래서 스키마를 도입하면 실제 기대한 규칙을 따르는지 런타임에서 확인 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;br/&gt;

&lt;h1&gt;2. 스키마 기반 검증기 구현하기&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  구현의 목표는 단순했다. (&lt;a href=&quot;https://github.com/KumJungMin/tiny-ts-mapper/tree/zod-like/src/schema&quot;&gt;구현한 레포지토리 링크&lt;/a&gt;)&lt;br/&gt;- &lt;strong&gt;데이터 구조를 코드로 선언&lt;/strong&gt;할 수 있어야 하고,&lt;br/&gt;- &lt;strong&gt;런타임에도 타입 검증&lt;/strong&gt;을 수행할 수 있어야 하며,&lt;br/&gt;- Mapper와 결합했을 때 안전하게 DTO ↔ Entity 변환이 가능해야 했다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;1) 설계 원칙&lt;/h2&gt;
&lt;p&gt;이를 위해 다음과 같은 원칙으로 설계했다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;BaseSchema 추상 클래스&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;모든 스키마 클래스의 공통 부모&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_parse()&lt;/code&gt; 메서드를 구현해 실제 검증 로직을 정의&lt;/li&gt;
&lt;li&gt;&lt;code&gt;safeParse()&lt;/code&gt;를 통해 검증 성공/실패 결과를 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;단일 타입별 Schema 클래스&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;StringSchema&lt;/code&gt;, &lt;code&gt;NumberSchema&lt;/code&gt;, &lt;code&gt;ObjectSchema&lt;/code&gt; 등&lt;/li&gt;
&lt;li&gt;각 타입에 맞는 제약 조건(길이, 범위, 정규식 등)을 체이닝 방식으로 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;조합 가능한 구조&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;OptionalSchema&lt;/code&gt;, &lt;code&gt;NullableSchema&lt;/code&gt; 등을 통해 값이 없거나 null인 경우 처리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TransformSchema&lt;/code&gt;를 통해 검증 후 변환 로직 적용 (예: 문자열 &lt;code&gt;&amp;quot;20&amp;quot;&lt;/code&gt; → 숫자 &lt;code&gt;20&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;에러 관리&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ValidationError&lt;/code&gt; 객체에 발생한 문제를 누적 저장&lt;/li&gt;
&lt;li&gt;어떤 경로(path)에서 어떤 에러가 났는지 명확하게 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;br/&gt;

&lt;h2&gt;2) 주요 코드&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;추상 클래스(&lt;code&gt;BaseSchema&lt;/code&gt;)로 스키마의 뼈대를 정의하고, 이를 상속받은 구체 스키마들이 각자 &lt;code&gt;_parse&lt;/code&gt;를 구현한다.&lt;br/&gt;모든 결과는 &lt;code&gt;safeParse&lt;/code&gt;로 감싸 동일한 구조로 반환되므로, 외부에서는 일관된 방식으로 검증 결과를 처리할 수 있다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;(1) BaseSchema: 추상 클래스&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;모든 스키마 클래스의 공통 부모로 추상 클래스 &lt;code&gt;BaseSchema&amp;lt;T&amp;gt;&lt;/code&gt;를 정의했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export abstract class BaseSchema&amp;lt;T&amp;gt; {
  readonly _type!: T;

  protected abstract _parse(value: unknown, path: Path): MaybePromise&amp;lt;T&amp;gt;;

  safeParse(value: unknown): SafeParseResult&amp;lt;T&amp;gt; {
    try {
      return { success: true, data: this.parse(value) };
    } catch (e) {
      return this._makeSafeResultError(e);
    }
  }
  ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 클래스는 최소 두 가지를 책임진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;대상&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;추상 메서드 &lt;code&gt;_parse&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;실제 검증 로직을 하위 클래스에서 구현하도록 강제한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;검증 결과 포맷 통일&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;try/catch&lt;/code&gt;로 감싸 검증 성공/실패 여부를 객체 형태로 반환한다.&lt;br/&gt;항상 &lt;code&gt;{ success: boolean, data?: T, error?: ValidationError }&lt;/code&gt; 같은 형태로 리턴하여, 모든 스키마가 동일한 결과 구조를 가진다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;h3&gt;(2) 구체 스키마 클래스: 상속&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;이제 각 타입별 스키마(&lt;code&gt;StringSchema&lt;/code&gt;, &lt;code&gt;NumberSchema&lt;/code&gt;, &lt;code&gt;ObjectSchema&lt;/code&gt; 등)는 &lt;code&gt;BaseSchema&lt;/code&gt;를 상속받아 &lt;code&gt;_parse&lt;/code&gt; 메서드를 구현한다.&lt;/li&gt;
&lt;li&gt;이 중 &lt;code&gt;StringSchema&lt;/code&gt; 구현 예시를 살펴보자.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export class StringSchema extends BaseSchema&amp;lt;string&amp;gt; {
  constructor(private readonly config: StringConfig = {}) {
    super();
  }

  protected override _parse(input: unknown, path: Path): string {
    // [1] 타입 검증
    if (typeof input !== &amp;#39;string&amp;#39;) { 
      this._fail(path, &amp;#39;invalid_type&amp;#39;, &amp;#39;Expected string&amp;#39;);
    }

    const { min, max, re } = this.config;
    const len = input.length;

    // [2] **조건 검증**
    if (min != null &amp;amp;&amp;amp; len &amp;lt; min) {
      this._fail(path, &amp;#39;too_small&amp;#39;, `Min length ${min}`);
    }
    if (max != null &amp;amp;&amp;amp; len &amp;gt; max) {
      this._fail(path, &amp;#39;too_big&amp;#39;, `Max length ${max}`);
    }
    if (re &amp;amp;&amp;amp; !re.test(input)) {
      this._fail(path, &amp;#39;invalid_string&amp;#39;, &amp;#39;Regex mismatch&amp;#39;);
    }

    return input;
  }

  private _fail(
    path: Path,
    code: &amp;#39;invalid_type&amp;#39; | &amp;#39;too_small&amp;#39; | &amp;#39;too_big&amp;#39; | &amp;#39;invalid_string&amp;#39;,
    message: string
  ): never {
    throw new ValidationError([{ path, code, message }]);
  }
  ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;번호&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;[1] 타입 검증&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;입력값이 &lt;code&gt;string&lt;/code&gt;인지 먼저 확인한다. 아니라면 &lt;code&gt;throw&lt;/code&gt;로 에러를 던져 즉시 실패 처리한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;[2] 조건 검증&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;생성자에 넘겨받은 설정(&lt;code&gt;min&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, &lt;code&gt;re&lt;/code&gt;)을 기준으로 길이와 패턴을 검사한다. 모든 조건을 통과하면 입력값을 그대로 반환한다.&lt;br/&gt;&lt;br/&gt;• &lt;code&gt;min&lt;/code&gt;: 최소 길이&lt;br/&gt;• &lt;code&gt;max&lt;/code&gt;: 최대 길이&lt;br/&gt;• &lt;code&gt;re&lt;/code&gt;: 정규식&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export class StringSchema extends BaseSchema&amp;lt;string&amp;gt; {
   ...
  // [3] 체이닝
  min = (n: number) =&amp;gt; new StringSchema({ ...this.config, min: n });
  max = (n: number) =&amp;gt; new StringSchema({ ...this.config, max: n });
  regex = (r: RegExp) =&amp;gt; new StringSchema({ ...this.config, re: r });
  email = () =&amp;gt; this.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;번호&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;[3] 체이닝&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;StringSchema 클래스는 &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, &lt;code&gt;regex&lt;/code&gt; 같은 메서드를 &lt;strong&gt;체이닝 방식&lt;/strong&gt;으로 제공한다. &lt;br/&gt;즉, 기존 설정을 유지하면서 새로운 조건을 추가한 &lt;strong&gt;새로운 StringSchema 인스턴스&lt;/strong&gt;를 반환한다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;체이닝은 다음과 같이 사용할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const schema = new StringSchema()
  .min(3)             // 최소 길이 3자
  .max(10)            // 최대 길이 10자
  .regex(/^[a-z]+$/); // 알파벳 소문자만 허용&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;이 외에도 객체, enum 등 여러 타입에 대해 BaseSchema를 상속받아, 구현하는 방식으로 진행했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;


&lt;h3&gt;(3) Mapper와 결합&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;스키마 클래스는 단독으로도 사용할 수 있지만, Mapper와 함께 쓰면 &lt;strong&gt;DTO → Entity 변환 + 런타임 검증&lt;/strong&gt;을 동시에 처리할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// User 스키마 정의
const UserDTOSchema = new ObjectSchema({
  MER_UUID: new StringSchema(),
  USR_NM: new StringSchema().min(1),
  CST_AGE: new BaseSchema&amp;lt;number&amp;gt;(), // 간단 예시
});

// User 도메인 모델
type User = { id: string; name: string; age: number };

// Mapper with Schema
class UserMapper {
  static toDomain(raw: unknown): User {
    const result = UserDTOSchema.safeParse(raw);
    if (!result.success) throw result.error;

    const dto = result.data;
    return {
      id: dto.MER_UUID,
      name: dto.USR_NM,
      age: dto.CST_AGE,
    };
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;실제 동작 코드를 살펴보자&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 올바른 데이터
const raw1 = { MER_UUID: &amp;quot;ABC-123&amp;quot;, USR_NM: &amp;quot;Alice&amp;quot;, CST_AGE: 25 };
console.log(UserMapper.toDomain(raw1));

// { id: &amp;quot;ABC-123&amp;quot;, name: &amp;quot;Alice&amp;quot;, age: 25 }

// 잘못된 데이터 (age가 문자열)
const raw2 = { MER_UUID: &amp;quot;DEF-456&amp;quot;, USR_NM: &amp;quot;Bob&amp;quot;, CST_AGE: &amp;quot;스물&amp;quot; };
console.log(UserMapper.toDomain(raw2));

// → Error: Expected number at CST_AGE&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;첫 번째 케이스(&lt;code&gt;raw1&lt;/code&gt;)는 &lt;code&gt;CST_AGE&lt;/code&gt;가 숫자 타입이라 스키마 검증을 통과하고, Mapper가 DTO → Domain 변환을 정상적으로 수행한다.&lt;/li&gt;
&lt;li&gt;두 번째 케이스(&lt;code&gt;raw2&lt;/code&gt;)는 &lt;code&gt;CST_AGE&lt;/code&gt;가 문자열 &lt;code&gt;&amp;quot;스물&amp;quot;&lt;/code&gt;이라 &lt;code&gt;NumberSchema&lt;/code&gt; 검증에서 실패한다. 따라서 &lt;strong&gt;스키마가 에러를 던지고, safeParse 결과가 실패&lt;/strong&gt;로 처리된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;h2&gt;3) 실제 적용 예시&lt;/h2&gt;
&lt;h3&gt;(1) 상황 설정&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;API에서 내려오는 데이터는 종종 프론트엔드에서 바로 쓰기 어려운 형태다.&lt;/li&gt;
&lt;li&gt;예를 들어 다음처럼 내려올 수 있다:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;MER_UUID&amp;quot;: &amp;quot;ABC-123&amp;quot;,
  &amp;quot;USR_NM&amp;quot;: &amp;quot;Alice&amp;quot;,
  &amp;quot;CST_AGE&amp;quot;: &amp;quot;25&amp;quot;,
  &amp;quot;USR_STS&amp;quot;: &amp;quot;ACTIVE&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;여기서 &lt;code&gt;CST_AGE&lt;/code&gt;는 문자열 &lt;code&gt;&amp;quot;25&amp;quot;&lt;/code&gt;이므로, 단순 타입스크립트 타입 정의만으로는 안전성을 확보할 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(2) User 스키마 정의&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;런타임에서도 타입 안정성을 보장하기 위해, 스키마를 정의한다.&lt;/li&gt;
&lt;li&gt;우선, 프론트엔드에서 실제로 사용할 도메인 모델(Entity)을 타입으로 정의한다.&lt;/li&gt;
&lt;li&gt;API의 축약 필드명 대신 &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;age&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;처럼 읽기 좋은 camelCase로 정의했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// domain/User.ts

export type User = {
  id: string;
  name: string;
  age: number;
  status?: string;
};&lt;/code&gt;&lt;/pre&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;그 다음으로, User에 대한 DTO 스키마(UserDTOSchema)를 정의했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// schema/UserSchema.ts

import { StringSchema } from &amp;quot;./StringSchema&amp;quot;;
import { TransformSchema } from &amp;quot;./TransformSchema&amp;quot;;
import { ObjectSchema } from &amp;quot;./ObjectSchema&amp;quot;;

export const UserDTOSchema = new ObjectSchema({
  MER_UUID: new StringSchema(),
  USR_NM: new StringSchema().min(1),
  CST_AGE: new TransformSchema(new StringSchema(), (v) =&amp;gt; Number(v)),
  USR_STS: new StringSchema().optional(),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;이 스키마는 API에서 내려오는 원시 데이터를 런타임에서 검증하고, 필요하다면 변환(transform)까지 수행한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ObjectSchema&lt;/code&gt;는 객체 구조를 검증.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MER_UUID&lt;/code&gt;: 문자열로만 허용.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;USR_NM&lt;/code&gt;: 문자열 + 최소 길이 1자.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CST_AGE&lt;/code&gt;: 문자열로 들어오더라도 &lt;code&gt;TransformSchema&lt;/code&gt;를 통해 숫자로 변환.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;USR_STS&lt;/code&gt;: optional → 값이 없어도 통과.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;(3) Mapper 구현&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;앞서 정의한 UserDTOSchema를 Mapper와 결합해,&lt;/li&gt;
&lt;li&gt;DTO → Domain 변환 과정에서 런타임 검증과 변환을 동시에 처리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// mapper/UserMapper.ts
import type { User } from &amp;quot;../domain/User&amp;quot;;
import { UserDTOSchema } from &amp;quot;../schema/UserSchema&amp;quot;;

export class UserMapper {
  static toDomain(raw: unknown): User {
    const result = UserDTOSchema.safeParse(raw);
    if (!result.success) throw result.error;

    const dto = result.data;
    return {
      id: dto.MER_UUID,
      name: dto.USR_NM,
      age: dto.CST_AGE,
      status: dto.USR_STS,
    };
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;safeParse&lt;/code&gt;를 통해 DTO를 검증한다.&lt;/li&gt;
&lt;li&gt;성공하면 &lt;code&gt;{ success: true, data }&lt;/code&gt; 구조를 반환하고,&lt;/li&gt;
&lt;li&gt;실패하면 &lt;code&gt;{ success: false, error }&lt;/code&gt; 객체를 반환한다.&lt;/li&gt;
&lt;li&gt;결과적으로 UI/비즈니스 로직에서는 항상 일관된 User 타입만 다루면 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;(4) 실제 동작&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;먼저, 올바른 데이터가 들어온 경우를 보자.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 올바른 데이터
const raw1 = { MER_UUID: &amp;quot;ABC-123&amp;quot;, USR_NM: &amp;quot;Alice&amp;quot;, CST_AGE: &amp;quot;25&amp;quot; };
const user1 = UserMapper.toDomain(raw1);

console.log(user1);
// { id: &amp;quot;ABC-123&amp;quot;, name: &amp;quot;Alice&amp;quot;, age: 25 }&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CST_AGE&lt;/code&gt;는 문자열 &lt;code&gt;&amp;quot;25&amp;quot;&lt;/code&gt;였지만, &lt;code&gt;TransformSchema&lt;/code&gt;가 숫자 &lt;code&gt;25&lt;/code&gt;로 변환했다.&lt;/li&gt;
&lt;li&gt;Mapper가 변환 결과를 도메인 모델 &lt;code&gt;{ id, name, age, status }&lt;/code&gt;로 리턴한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;이번에는 잘못된 데이터가 들어온 경우를 보자.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 잘못된 데이터 (나이가 &amp;#39;스물&amp;#39;)
const raw2 = { MER_UUID: &amp;quot;DEF-456&amp;quot;, USR_NM: &amp;quot;Bob&amp;quot;, CST_AGE: &amp;quot;스물&amp;quot; };
try {
  UserMapper.toDomain(raw2);
} catch (e) {
  console.error(e);
  // ValidationError: Expected number at CST_AGE
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CST_AGE&lt;/code&gt; 값 &lt;code&gt;&amp;quot;스물&amp;quot;&lt;/code&gt;은 숫자로 변환 불가하다.&lt;/li&gt;
&lt;li&gt;그래서 &lt;code&gt;TransformSchema&lt;/code&gt; 내부에서 변환을 실패해, ValidationError 발생시켰다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;결국 스키마 클래스를 사용하면 런타임에서 DTO를 검증하고,&lt;/li&gt;
&lt;li&gt;Mapper가 안전하게 Domain 모델로 변환한다.&lt;/li&gt;
&lt;li&gt;또한, 올바른 값은 변환되어 통과, 잘못된 값은 즉시 실패시킬 수 있었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;br/&gt;


&lt;h1&gt;3. 마치며&lt;/h1&gt;
&lt;p&gt;이번 시간에는 클린 아키텍처를 도입하는 과정에서 마주한 타입스크립트의 한계와, 이를 해결하기 위해 시도한 방법을 정리했다.&lt;/p&gt;
&lt;p&gt;Mapper는 DTO ↔ Entity 변환(필드명 매핑, 구조 변환)을 하는데, DTO 값 자체가 잘못 들어오면 Mapper만으로는 막을 수 없다.&lt;/p&gt;
&lt;p&gt;따라서 런타임 검증은 &lt;strong&gt;스키마&lt;/strong&gt;가 담당하고, Mapper는 이를 호출해 “검증 + 변환”을 한 번에 처리하도록 설계했다. &lt;/p&gt;
&lt;p&gt;즉, Mapper와 스키마가 결합되면서 DTO → Entity 변환 과정에서 안전성과 일관성을 동시에 확보할 수 있었다.&lt;/p&gt;
&lt;br/&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;하지만 런타임에서 매번 데이터 구조를 순회하며 검증하기에 성능 저하가 발생했다. 실제 벤치마크 결과는 다음과 같다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;details&gt;
  &lt;summary&gt;검증 조건&lt;/summary&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;데이터 크기&lt;/strong&gt;: &lt;code&gt;SIZE = 100,000&lt;/code&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;검증 시나리오&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;하드 케이스&lt;/strong&gt;: nullable, optional, enum, strict, invalid 필드를 섞은 복잡한 데이터&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;간단 케이스&lt;/strong&gt;: 모든 값이 정상, unknown 없음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;혼합 케이스&lt;/strong&gt;: 정상 80%, 실패 20% 비율&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;비교 대상&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;manualToDomain&lt;/code&gt;: 수동 매핑 + 조건문 검증&lt;/li&gt;
&lt;li&gt;&lt;code&gt;UserMapper.toDomain&lt;/code&gt;: 스키마 클래스 검증 + 변환&lt;/details&gt;


&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;결과&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;케이스&lt;/th&gt;
&lt;th&gt;스키마 적용 전 (수동)&lt;/th&gt;
&lt;th&gt;스키마 적용 후&lt;/th&gt;
&lt;th&gt;차이&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;하드 데이터셋&lt;/td&gt;
&lt;td&gt;16.291ms&lt;/td&gt;
&lt;td&gt;4.569s&lt;/td&gt;
&lt;td&gt;약 &lt;strong&gt;280배 느림&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;간단 데이터셋&lt;/td&gt;
&lt;td&gt;65.15ms&lt;/td&gt;
&lt;td&gt;3.183s&lt;/td&gt;
&lt;td&gt;약 &lt;strong&gt;49배 느림&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;혼합 데이터셋&lt;/td&gt;
&lt;td&gt;51.514ms&lt;/td&gt;
&lt;td&gt;5.197s&lt;/td&gt;
&lt;td&gt;약 &lt;strong&gt;100배 느림&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;스키마 클래스는 입력값을 받을 때마다 (1) 객체 필드 순회 → (2) 각 조건 검사(min, max, regex 등) → (3) 실패 시 ValidationError 생성 단계를 반복한다. &lt;/li&gt;
&lt;li&gt;즉, “해석(interpret)” 기반 실행이어서 데이터 크기만 커져도 비용이 급격히 증가한다.&lt;/li&gt;
&lt;li&gt;반대로 수동 매핑은 이미 “컴파일된 조건문”이므로 단순 분기만 실행해서 빠른 것이다.&lt;/li&gt;
&lt;li&gt;결국 스키마 클래스 방식이 안정성과 유지보수성은 확보했지만, 성능 면에서는 손해가 있었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 벤치마크를 통해, 스키마를 런타임마다 해석하는 구조는 본질적으로 성능 한계가 있을 수밖에 없다는 점을 확인했다.&lt;/p&gt;
&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;
&lt;p&gt;추가 리서치를 통해 알게 된 것은, 스키마를 매번 생성·해석하는 방식보다는 typia처럼 &lt;/p&gt;
&lt;p&gt;타입 정보를 AST로 분석하여 빌드 타임에 최적화된 validator 함수를 자동 생성하는 방식이 성능상 훨씬 유리하다는 점이었다.&lt;/p&gt;
&lt;p&gt;typia는 TypeScript Compiler API를 이용해 타입 정보를 AST로 분석하고, 그 결과를 바탕으로 최적화된 validator 함수를 코드 형태로 생성한다.&lt;/p&gt;
&lt;p&gt;따라서 런타임에서는 매번 스키마를 해석할 필요 없이, 이미 생성된 조건문 기반 함수를 실행하기만 하면 된다.&lt;/p&gt;
&lt;p&gt;이 덕분에 “수동 매핑”과 거의 유사한 성능을 유지하면서도 타입 안정성을 동시에 보장할 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  다음 글에서는 typia의 아이디어를 응용해, AST 기반 코드 생성 방식으로 개선하는 방법을 다뤄보겠다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;br/&gt;&lt;br/&gt;&lt;/p&gt;</description>
      <category>개발 기술/개발 이야기</category>
      <category>AST 기반 코드 생성</category>
      <category>DTO Entity Mapper</category>
      <category>TypeScript Validation Best Practices</category>
      <category>TypeScript 런타임 검증</category>
      <category>typia TypeScript</category>
      <category>Zod 타입스크립트</category>
      <category>스키마 기반 검증</category>
      <category>클린 아키텍처 프론트엔드</category>
      <category>타입스크립트 한계</category>
      <category>프론트엔드 데이터 검증</category>
      <author>GicoMomg</author>
      <guid isPermaLink="true">https://mong-blog.tistory.com/234</guid>
      <comments>https://mong-blog.tistory.com/entry/TS-%C3%97-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-1%ED%8E%B8-%E2%80%94-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%ED%95%9C%EA%B3%84%EC%99%80-Mapper-%EC%8A%A4%ED%82%A4%EB%A7%88%EB%A1%9C-%EB%9F%B0%ED%83%80%EC%9E%84-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0#entry234comment</comments>
      <pubDate>Sun, 28 Sep 2025 15:36:10 +0900</pubDate>
    </item>
  </channel>
</rss>