---
title: '소스맵 없이 토스증권의 JavaScript 추적해보기'
tags:
  - debugging
  - web-performance
published: true
date: 2026-09-26 18:00:00
description: '추석맞이 뻘짓 대작전 3탄: 사랑해요 토스증권'
series: 'coldpath 제작기'
seriesOrder: 3
art:
  undraw: file-search
  layout: bands
  hue: cyan
  tone: light
  hero: '70.3%'
---

## Table of Contents

## 첫 진입에서 70.3%가 실행되지 않았다

[토스증권](https://www.tossinvest.com/)의 첫 화면을 열자 브라우저는 JavaScript 61개, 9,139.4KB를 받았다. 이 중 V8이 실행을 기록한 코드는 2,715.3KB였고, 나머지 70.3%는 첫 진입에서 한 번도 실행되지 않았다. 가장 큰 파일인 `pages/_app` 청크는 3,474.2KB였는데, 그 안에서도 2,168.6KB가 실행되지 않았다.

실행되지 않은 코드 중에는 증권 서비스의 첫 화면과 어울리지 않는 리치 텍스트 에디터(tiptap과 ProseMirror)가 있었다. 모듈 사이의 참조를 따라가 보니 홈 페이지에서 레이아웃, 사이드바, 주문 폼을 지나 패널의 탭 컨텍스트를 읽는 한 줄짜리 훅에 닿았고, 그 훅이 든 모듈이 댓글 컴포넌트를, 댓글 컴포넌트가 에디터를 정적으로 불러오고 있었다. 이 글은 소스맵 없이 이 경로를 찾아가는 과정이다.

이번 글은 토스증권을 [coldpath](https://www.npmjs.com/package/@yceffort/coldpath)의 분석 실험 대상으로 삼았다. **소스맵이 없는 실제 서비스에서 도구로 무엇을 관찰하고 어디까지 추적할 수 있는지 확인하려는 글이다.** 토스증권의 구현이 잘못됐다거나 최적화가 부족하다고 평가하려는 목적은 없다. 내부 요구사항과 설계의 배경을 모르는 상태에서, 로그인하지 않은 방문 몇 번의 기록으로 내릴 수 있는 판단도 아니다.

[coldpath](https://github.com/yceffort/coldpath)는 브라우저가 받은 JavaScript와 V8 실행 기록을 연결해, 어떤 모듈에 실행되지 않은 코드가 얼마나 있는지 보여주는 분석기다. 외부 사이트를 분석할 때는 `snapshot`, `modules`, `analyze`, `label` 명령을 차례로 쓴다. 에디터까지 이어진 결론에는 명령이 직접 알려준 것과 내가 명령의 출력 위에서 따로 확인한 것, 그리고 내 추론이 함께 들어 있다. 이 글은 이 세 가지를 섞지 않으려고 명령 순서대로 절을 나눴다.

| 절                    | 근거                                                                    |
| --------------------- | ----------------------------------------------------------------------- |
| `label`을 뺀 명령 절  | coldpath 명령의 출력                                                    |
| `label`               | 모델의 추정. 명령이 근거 문자열의 존재만 검사한다                       |
| 명령 밖에서 확인한 것 | 명령의 출력(JSON, 그래프, 저장한 코드와 V8 기록)을 별도 스크립트로 대조 |
| 추론과 개선 가설      | 위 결과를 바탕으로 한 내 해석. 원본 빌드로 검증하지 않았다              |

> 수집: 2026년 9월 26일, 로그인하지 않은 상태, Playwright 1.63.0의 Chromium, 뷰포트 1280×900. 방문 4개(첫 진입, 검색, 종목 화면, 피드)를 각각 새 브라우저에서 기록했다. 모든 결과는 이날 받은 빌드 기준이며, 이후 배포에서는 모듈 ID와 구조가 달라져 같은 결과가 나오지 않을 수 있다.
>
> 분석: npm의 [`@yceffort/coldpath@0.3.1`](https://www.npmjs.com/package/@yceffort/coldpath/v/0.3.1). 이 글에서 다루는 모듈 복원 수정([커밋 `7dca3ee`](https://github.com/yceffort/coldpath/commit/7dca3ee454c1f2630036ec35f041fbd92363b41e))을 포함한 버전이다. 크기는 모두 생성 코드를 UTF-8로 센 값이다. 1KB는 1,000B로 계산했고, 바이트 단위 값은 측정 요약 JSON에 남겼다. 이 값은 압축한 전송량이나 CPU 시간은 아니다.
>
> 자료: [재현 절차](/demos/coldpath/toss-reproduction-2026-09-26.md), [측정 요약 JSON](/demos/coldpath/toss-summary-2026-09-26.json)

> 이 글의 수치가 말해 주지 않는 것: 미실행은 로그인하지 않은 방문 4개에서 실행되지 않았다는 뜻이며, 로그인한 사용자의 동작은 기록하지 않았다. `Unmeasured: 0`도 저장한 107개 파일 안의 값이다. 모듈 이름은 모델의 추정이거나 내가 생성 코드를 보고 붙인 이름이고, 뒤의 개선 효과는 원본을 고쳐 다시 빌드해 확인한 값이 아니다.

## snapshot: 브라우저가 받은 코드와 실행 기록

외부 사이트에는 비교할 로컬 빌드가 없다. `snapshot`은 Chromium에서 V8의 정밀 커버리지를 켜고 페이지를 연 뒤, 실행 기록에 나타난 외부 스크립트의 본문을 그대로 저장한다. 이후 분석은 이 저장본만 입력으로 쓴다.

첫 진입 외에 동작 3개를 기록했다. 검색은 상단 검색창에 삼성전자를 입력하고 종목 코드 `005930`이 결과에 나타날 때까지 기다렸다. 종목 화면은 같은 검색 결과를 눌러 이동한 뒤 3초를 기다렸고, 피드는 상단의 피드 링크로 이동해 글 목록이 나타날 때까지 기다렸다. 동작 스크립트는 [재현 절차](/demos/coldpath/toss-reproduction-2026-09-26.md)에 있다.

**이 명령으로 알아낸 것은 방문마다 받은 파일과 그 로딩 경위다.** 첫 진입 61개, 검색 62개, 종목 화면 106개, 피드 62개였고, 합치면 107개였다. `loading.json`은 각 파일이 어떻게 요청됐는지를 분류한다. 첫 진입의 61개는 다음과 같았다.

| 로딩 분류 | 분류 기준                                                  | 파일 수 | 생성 코드 크기 |
| --------- | ---------------------------------------------------------- | ------: | -------------: |
| `html`    | 요청한 문서의 HTML에 `<script src>`나 `<link href>`가 있음 |    27개 |      7,229.7KB |
| `inline`  | 태그는 없지만 HTML 안에 파일 이름이 있음                   |     1개 |        353.6KB |
| `dynamic` | 둘 다 아님. 실행 중에 다른 스크립트가 요청                 |    33개 |      1,556.0KB |

`dynamic` 33개에는 `pages/screener`, `pages/feed/[[...menu]]`, `pages/calendar`, `pages/stocks/[symbol-or-stock-code]/order`, `pages/signin` 같은 다른 페이지의 청크와 그 의존 청크가 들어 있었다. 요청 주체는 모두 `script`였고, 첫 요청 뒤 약 0.8초에서 1.7초 사이에 시작됐다. `inline`의 1개는 `gtm.js`다. 이 분류는 요청 경위를 알려줄 뿐, 첫 렌더링에 필요했는지는 알려주지 않는다.

수집 중에는 `snapshot`이 수집을 거부하는 일도 있었다. `www.googletagmanager.com/gtm.js`가 방문마다 353.6KB와 353.6KB의 두 판으로 번갈아 내려왔다. 두 판은 `c=Of(10);`과 `e=Of(10);` 두 곳의 삽입만 달랐다. `snapshot`은 같은 URL의 스크립트가 먼저 저장한 사본과 다르면 실패한다. 서로 다른 코드의 실행 범위를 한 파일로 합치면 위치가 어긋나기 때문이다. 같은 판이 나올 때까지 다시 시도해 검색 방문은 12번째에 저장됐다. 토스증권의 스크립트는 네 방문 모두 같은 빌드였다.

## modules: 모듈 경계 복원과 Sentry 코드

실행 기록만으로는 3,474.2KB 파일 안에 무엇이 들어 있는지 알 수 없다. `modules`는 webpack의 모듈 등록 구조를 파싱해, 각 모듈 함수를 `webpack://inferred/webpackChunk_N_E/73681.js` 같은 가상 소스로 나누는 합성 소스맵을 만든다. 모듈 ID는 생성 코드에 있는 번호 그대로다.

```text
Recovered 6183 modules in 102 chunks, 5 whole-chunk sources
```

**이 명령으로 알아낸 것은 107개 파일 중 102개의 모듈 경계다.** 파일 전체를 하나의 소스로 남긴 5개는 `_buildManifest`, `_ssgManifest`, webpack 런타임, TradingView 차트 라이브러리의 런타임, `gtm.js`로, 모두 webpack 모듈 표를 찾지 못한 파일이다.

처음부터 이 결과가 나온 것은 아니다. 수정 전의 `modules`는 64개 파일에서만 모듈을 찾았고, 43개를 파일 전체 하나로 남겼다. `_app` 청크도 그중 하나였다. 모듈 복원에 성공한 청크와 실패한 청크 모두 앞부분에 Sentry가 넣는 debug-id 코드가 있었는데, 차이는 그 코드와 webpack의 청크 등록 코드를 잇는 방식이었다. 성공한 청크는 `;`로 문장을 끊었고, 실패한 청크는 쉼표로 두 식을 한 문장에 이었다.

```text
!function(){try{var e=...;e._sentryDebugIds=e._sentryDebugIds||{},...}catch(e){}}(),
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[636,1326,2518,5672],{34:(e,t,r)=>{...
```

[lib/modules.mjs의 `chunkModules`](https://github.com/yceffort/coldpath/blob/8fbe871936f685f9a24fc670d86a3152bc5b00f0/lib/modules.mjs#L105-L133)는 최상위 문장의 식이 곧 `push(...)` 호출일 때만 청크 등록으로 인식했다. 쉼표로 이어진 식은 `SequenceExpression`(쉼표 연산자로 여러 식을 이은 하나의 식)이라서 검사를 통과하지 못했다. 첫 진입의 저장본에서 대조해 보면 쉼표로 이어진 청크 30개는 모두 실패했고, `;`로 끊긴 청크 27개는 모두 성공했다.

[커밋 `7dca3ee`](https://github.com/yceffort/coldpath/commit/7dca3ee454c1f2630036ec35f041fbd92363b41e)에서 쉼표로 이어진 식을 하나씩 풀어 검사하도록 바꿨고, 0.3.1로 배포했다. 같은 입력에서 결과는 다음처럼 달라졌다.

| 항목                            | 수정 전 (`8fbe871`) | 수정 후 (`7dca3ee`) |
| ------------------------------- | ------------------: | ------------------: |
| 모듈 경계를 복원한 파일         |                64개 |               102개 |
| 중복을 포함한 모듈 수           |             2,748개 |             6,183개 |
| 파일 전체를 한 소스로 다룬 파일 |                43개 |                 5개 |

바이트 합계는 그대로이고 번들 안을 나누는 방식만 달라졌다. Sentry의 번들러 플러그인을 쓰는 webpack 빌드라면 같은 형태가 나올 수 있고, 축소기가 두 문장을 쉼표로 합치는 것도 흔한 일이다.

## analyze: 모듈별 실행량

`analyze`는 저장한 코드와 실행 기록, `modules`가 만든 합성 소스맵을 받아 모듈별 크기와 실행량을 계산한다. 방문 4개를 합친 출력은 다음과 같았다.

```text
Generated UTF-8 bytes: 14151337 (107 bundles)
Observed: 4937525 | Unobserved: 9213812 | Unmeasured: 0
Unobserved means not executed during the supplied scenarios, not safe to delete.
```

**이 명령으로 알아낸 것은 세 가지다.**

첫째, 방문마다의 실행량이다. 각 방문에는 해당 방문의 초기 로딩도 포함된다.

| 기록           | 수행한 동작                          |  측정 코드 | 실행 관찰량 |  미실행량 | 미실행 비율 |
| -------------- | ------------------------------------ | ---------: | ----------: | --------: | ----------: |
| `initial.json` | 첫 화면 진입                         |  9,139.4KB |   2,715.3KB | 6,424.1KB |       70.3% |
| `search.json`  | 삼성전자 입력 후 `005930` 결과 대기  |  9,140.5KB |   2,805.6KB | 6,335.0KB |       69.3% |
| `stock.json`   | 검색 결과를 눌러 종목 화면으로 이동  | 14,131.6KB |   4,914.1KB | 9,217.4KB |       65.2% |
| `feed.json`    | 피드로 이동 후 글 목록 대기          |  9,159.1KB |   2,787.7KB | 6,371.5KB |       69.6% |
| 전체 합집합    | 4개 기록에서 한 번이라도 실행된 구간 | 14,151.3KB |   4,937.5KB | 9,213.8KB |       65.1% |

종목 화면으로 이동하자 실행 관찰량이 2,198.8KB 늘었지만, 새로 받은 파일 45개의 상당 부분도 실행되지 않아 비율은 5%p 정도만 낮아졌다. 버튼을 더 누르면 비율은 좋아지지만 파일이나 로딩 방식이 바뀐 것은 없다. 미실행률 자체를 목표로 삼기 어려운 이유다.

둘째, 미실행 코드가 어떤 형태로 남아 있는지다. 첫 진입에서 `_app` 청크의 모듈은 1,417개였고, 이 중 모듈 함수가 한 번도 실행되지 않은 모듈은 14개(10.9KB)뿐이었다. 나머지 1,403개는 모두 모듈 함수가 실행됐다. webpack은 모듈을 처음 `require`할 때 모듈 함수를 한 번 실행해 export할 함수와 컴포넌트를 정의한다. 그 함수들의 본문은 누군가 호출하기 전까지 실행되지 않는다. `_app`의 미실행 코드 대부분은 **로드된 뒤 실행되지 않은 모듈이 아니라, 이미 평가된 모듈 안에서 호출되지 않은 함수**였다.

셋째, 조사할 후보다. 첫 진입에서 미실행량이 큰 모듈은 다음과 같았다.

| 모듈    | 파일               | 로딩 분류 |    크기 | 미실행량 |
| ------- | ------------------ | --------- | ------: | -------: |
| `72627` | `2627-...js`       | `dynamic` | 533.4KB |  480.4KB |
| `75745` | `8814-...js`       | `html`    | 295.7KB |  264.0KB |
| `96245` | `8814-...js`       | `html`    | 263.7KB |  251.8KB |
| `55707` | `pages/_app-...js` | `html`    | 223.8KB |  177.5KB |
| `59041` | `pages/_app-...js` | `html`    | 200.3KB |  161.3KB |
| `95428` | `4429-...js`       | `dynamic` | 188.4KB |  130.7KB |
| `61750` | `31f91cad-...js`   | `html`    |  95.2KB |   92.7KB |

`analyze`는 이 결과를 treemap이 들어 있는 HTML 보고서로도 만든다. 아래는 4개 방문을 합친 보고서다.

<iframe src="/demos/coldpath/toss-report-2026-09-26.html" title="토스증권 분석 보고서" width="100%" height="720" loading="lazy" frameBorder="0"></iframe>

[토스증권 분석 보고서 새 창에서 열기](/demos/coldpath/toss-report-2026-09-26.html)

토스증권의 코드를 다시 배포하지 않으려고 이 보고서는 `--details` 없이 만들었다. 모듈별 크기와 실행량, 라벨은 볼 수 있지만 생성 코드와 실행 구간을 펼쳐 보는 기능은 없다. 그래도 HTML 파일이 약 11.5MB여서 화면에 가까워졌을 때 불러오도록 했다. 사각형의 넓이는 코드 크기이고, 색은 처음 실행을 관찰한 시나리오를 구분한다. 파란 영역은 4개 방문 어디에서도 실행되지 않은 코드다. 검색창에 `73681`을 입력하면 뒤에서 살펴볼 모듈을 찾을 수 있다.

## label: 모델이 붙인 이름

모듈 ID만으로는 `61750`이 무엇인지 알 수 없다. `label`은 미실행량이 큰 소스의 코드 일부와 문자열 표본을 모델에 보내 이름과 설명을 추정한다. 이번에는 80개를 `claude-haiku-4-5`에 보냈고, 입력 224,945 토큰과 출력 24,051 토큰을 썼다. 이 단계는 코드를 모델 제공자에게 보낸다. 이름을 붙여도 실행량 집계는 바뀌지 않는다.

**이 명령으로 알아낸 것은 조사할 모듈의 후보 이름이다.** 앞의 표에서 `61750`에는 `prosemirror-view`라는 이름이 붙었다. 그 밖에 `73681`은 `@tiptap/core`, `42120`은 `tiptap`, `33036`은 `prosemirror-model`로 추정됐고, 네 모듈 모두 `html`로 분류된 파일에 있었다. `There is no node type named ...`, `ProseMirror-selectednode` 같은 근거 문자열도 함께 나왔다. 증권 서비스의 첫 화면에서 글을 쓰는 입력창은 보이지 않았으므로, 이 네 모듈을 다음 조사 대상으로 골랐다.

`analyze`의 시나리오별 집계에서 이 네 모듈의 실행량은 한 바이트도 달라지지 않았다.

| 모듈                            |   크기 | 첫 진입 |  검색 | 종목 화면 |  피드 |
| ------------------------------- | -----: | ------: | ----: | --------: | ----: |
| `61750`, prosemirror-view 추정  | 95.2KB |   2.4KB | 2.4KB |     2.4KB | 2.4KB |
| `73681`, @tiptap/core 추정      | 60.2KB |   5.2KB | 5.2KB |     5.2KB | 5.2KB |
| `42120`, tiptap 추정            | 52.3KB |   5.0KB | 5.0KB |     5.0KB | 5.0KB |
| `33036`, prosemirror-model 추정 | 45.3KB |   1.3KB | 1.3KB |     1.3KB | 1.3KB |

라벨이 틀렸을 가능성이 있는 경우도 있었다. `55707`에는 `toss/slash`라는 이름이 붙었다. 근거 문자열 `DatePicker.Content`, `TimePicker.FieldBoxInput`은 코드에 실제로 있어 coldpath의 근거 검사를 통과했다. 하지만 이 문자열은 컴포넌트 이름일 뿐 어떤 패키지에서 왔는지를 알려주지는 않는다. 같은 모듈에는 `data-keen-slider-`와 드롭존 관련 문자열도 있었다. 2개 소스는 근거 문자열이 걸러지거나 모델이 `unknown`으로 답해 이름이 버려지고 요약만 남았다.

근거 문자열 검사는 모델이 문자열을 지어내지 않았다는 보증일 뿐, 이름이 정답이라는 보증은 아니다. 보고서에서 이름 앞의 `≈`는 추정을 뜻한다.

라벨이 얼마나 맞는지 가늠하려고, 공개 소스맵이 있는 [GitLab의 탐색 페이지](https://gitlab.com/explore)에서 같은 방식으로 라벨을 붙이고 채점한 적이 있다. 9월 25일에 수집한 기록을 소스맵 없이 `modules`로 나누고, 미실행량이 큰 50개에 `claude-haiku-4-5`로 이름을 붙였다. 정답은 소스맵의 원본 경로에 있는 `node_modules/<패키지>`로 정했고, 패키지 이름은 정확히 같을 때만 인정했다.

| 항목                                  |  결과 |
| ------------------------------------- | ----: |
| 패키지 코드인지 앱 코드인지 구분      | 43/46 |
| 패키지 이름이 정확히 일치             | 21/27 |
| 패키지가 여럿 섞여 채점하지 않은 모듈 |   4개 |
| 앱 코드라 이름을 채점하지 않은 모듈   |  19개 |

틀린 이름에는 `rails-ujs`와 `@rails/ujs`처럼 표기만 다른 경우도, `@gitlab/ui` 안에 벤더링된 bootstrap-vue를 bootstrap-vue라고 답한 경우도, Sentry의 코드를 `@vercel/ai`라고 답한 명백한 오답도 있었다. 표본이 작고 webpack 4 빌드 하나에서 얻은 값이라 토스증권의 정답률로 옮길 수는 없다. 다만 고유한 오류 메시지나 CSS 클래스가 근거일 때는 대체로 맞았고, 컴포넌트 이름만 근거일 때는 믿기 어렵다는 감각을 얻었다. 채점 기준은 [이슈 #15](https://github.com/yceffort/coldpath/issues/15), 항목별 결과는 [검증 요약 JSON](/demos/coldpath/gitlab-validation-2026-09-25.json)에 있다.

`55707`은 라벨과 별도로 생성 코드를 직접 검색해 보았다. 컴포넌트 이름은 `DatePicker.Calendar`, `DateRangePicker.Trigger`, `Dropzone.ListItem`, `SegmentedControl.Item`, `Tabs.TabItem`처럼 날짜 선택기와 파일 업로드, 탭을 아우르는 UI 컴포넌트 모음이었고, 공개 패키지 이름이나 버전 문자열은 찾지 못했다. `data-keen-slider-` 속성과 `rubberband`, `dragSpeed` 옵션을 다루는 코드는 npm의 keen-slider 6.8.6 배포 파일에도 같은 문자열(`earlyExit` 포함)이 있어 슬라이더 라이브러리 keen-slider에서 온 것으로 보인다. 버전은 확인하지 못했다.

## modules --graph와 --why: 참조 경로

`modules`에 `--graph`를 붙이면 복원한 모듈 함수 안의 `n(id)` 호출을 간선으로 삼는 의존성 그래프를 만든다. 이번 그래프에는 모듈 5,935개와 간선 22,835개(`unknown` 22,741개, `dynamic` 94개)가 있었다. 복원하지 못한 모듈을 가리키는 호출 143개는 빠졌다. webpack의 생성 코드에서는 정적 import와 `require()`가 같은 호출이 되므로 대부분 `unknown`이다.

이 그래프를 `analyze --graph`에 넘기고 `--why`로 `73681`을 물으면, 그래프의 진입 모듈에서 출발하는 최단 경로 하나를 알려준다.

```text
Import path (recovered graph): webpackChunk_N_E/14966.js -> webpackChunk_N_E/79386.js -> webpackChunk_N_E/54017.js -> webpackChunk_N_E/42120.js -> webpackChunk_N_E/73681.js
```

**이 명령으로 알아낸 것은 에디터에 닿는 경로가 있다는 사실과 그 경로가 댓글 모듈 `54017`을 지난다는 점이다.** 다만 출발점 `14966`은 `pages/community/lounges/[subjectId]` 청크에 있다. `analyze`의 집계에서 이 청크는 피드 방문에서만 실행됐고, 첫 진입의 기록에는 아예 없다. `--why`는 그래프에 있는 모든 진입 모듈 중 가장 가까운 곳에서 출발하므로, “이 모듈이 왜 첫 화면에 들어왔는가”에는 답하지 못한다. 출발점을 첫 화면의 진입 모듈로 지정하는 기능은 현재 없다.

## analyze --export와 --replay: 근거 넘기기

보고서만 공유하면 받는 사람은 수치를 다시 확인하기 어렵다. 전체 입력은 107개 파일과 커버리지 4개로 수십 MB이고, 대부분은 에디터 경로와 관계가 없다.

`analyze`에 `--export`와 `--export-select`를 붙이면 고른 파일만 담은 발췌를 만든다. 뒤의 경로에 있는 청크 7개를 골랐다. 커버리지에서는 이 청크들의 항목만 남기되 원래의 offset과 호출 횟수, 소스 본문을 유지한다. 빠진 입력 202개는 역할과 SHA-256으로 manifest에 남는다. 압축한 증거 묶음은 약 4.1MB다. 다만 이 묶음에는 토스증권의 청크 본문과 소스 본문이 든 커버리지가 그대로 들어 있어 공개하지 않았다. 묶음의 SHA-256은 [측정 요약 JSON](/demos/coldpath/toss-summary-2026-09-26.json)에 남겼다.

묶음을 받은 사람은 `--replay`로 확인한다. `--replay`는 이 묶음의 입력 해시를 검사하고 선택한 청크 7개의 바이트 수와 실행 구간을 다시 계산한다.

```text
Replay verified an excerpt, not a complete reproduction: 7 selected bundles reproduced their counts and spans; 202 omitted inputs and the full-analysis totals were not checked.
```

이때 종료 코드는 `4`다. 발췌를 전체 재현으로 오해하지 않도록 성공(`0`)과 구분했다. 70.3% 같은 전체 합계는 이 묶음으로 재현되지 않는다.

## 명령 밖에서 확인한 것

여기까지의 명령은 에디터가 첫 화면의 파일에 들어 있고, 실행량이 방문과 무관하게 같으며, 어떤 경로로든 댓글 모듈을 거쳐 닿는다는 것까지 알려줬다. 에디터가 왜 첫 화면에 들어왔는지는 명령의 출력만으로 알 수 없었다. 이 절의 내용은 `report.json`, `graph.json`, 저장한 코드와 V8 기록을 별도 스크립트로 대조해 얻었다.

### 홈 진입 모듈에서 에디터까지

홈 페이지 청크의 진입 모듈 `3084`로 출발점을 고정하고, `graph.json`에서 `dynamic` 간선을 뺀 최단 경로를 구했다. 그다음 각 간선을 생성 코드에서 확인했다.

```text
3084   홈 페이지 진입
→ 34486  홈 페이지 컴포넌트
→ 5099   레이아웃
→ 75745  사이드바
→ 57427  주문 유형별 주문 폼 선택
→ 70388  주문 폼
→ 96245  export가 pF, Hi 두 개인 263.7KB 모듈
→ 54017  댓글 컴포넌트
→ 42120  tiptap 추정 → 73681 @tiptap/core 추정
```

생성 코드에서 확인한 사실은 다음과 같다.

- `5099`는 사이드바 영역에 `75745`의 `V`를 렌더링한다.

  ```text
  a=(0,r.Y)("div",{className:"_19phvif1",children:(0,r.Y)(f,{children:(0,r.Y)(d.V,{})})},"sidebar")
  ```

- `75745`는 섹션 이름이 `Popover__EditOrderPopover`인 팝오버 안에서 `57427`의 `CG`를 렌더링한다. `57427`은 주문 유형(`buy`, `sell`, `oco`, `oto`)에 따라 `70388`의 폼을 고른다.
- `70388`은 `96245`에서 `Hi` 하나만 쓴다. 읽은 `tabList`를 `주문하기__조건주문`이라는 문자열과 함께 `T.p`에 넘긴다.

  ```text
  n="주문하기__조건주문",...,{tabList:l}=(0,p.Hi)()??{},{targetRef:i}=(0,T.p)(n,{params:{featureTab:t,tabList:l}})
  ```

- `96245`에서 `Hi`는 컨텍스트 `un`을 읽어 돌려주는 한 줄 함수다. 다른 export `pF`는 `panelId`, `initialTabState`를 받는 패널 컴포넌트다.

  ```text
  n.d(t,{pF:()=>uf,Hi:()=>ur})
  function ur(){return(0,r.use)(un)}
  ```

- `96245`는 같은 모듈 안에서 `54017`의 댓글 컴포넌트 `L`을 렌더링한다.
- `54017`은 댓글 입력 컴포넌트에서 `42120`의 훅을 호출한다. 이 컴포넌트는 `mode:"create"`와 `mode:"edit"`로 렌더링된다.

그래프에서 `42120`을 불러오는 모듈은 `54017` 외에도 같은 청크에 2개가 더 있었다.

### 받기만 한 것이 아니라 실행됐다

HTML에 참조가 있다는 것만으로는 `<link rel="preload">`로 받아두기만 했는지, 실제로 실행됐는지 구분할 수 없다. 두 가지를 확인했다.

첫째, 저장한 V8 기록에서 모듈 함수의 시작 위치와 일치하는 함수 항목을 찾아 호출 횟수를 읽었다. 위 경로의 모듈 6개(`5099`, `75745`, `57427`, `70388`, `96245`, `54017`)와 에디터 모듈 4개, 총 10개의 모듈 함수가 네 방문 모두에서 1번씩 호출됐다. V8은 이 함수들에 모듈 ID를 이름으로 붙였다. webpack 모듈 표의 `73681:(e,t,n)=>{...}`처럼 프로퍼티 키가 함수 이름으로 추론되기 때문이다. 이 확인은 스크립트 `verify-toss-factories-2026-09-26.mjs`로 했고, 스크립트는 공개하지 않은 증거 묶음에 함께 넣었다.

둘째, 홈 페이지 청크의 마지막 줄을 읽었다.

```text
e.O(0,[8667,6411,1277,3876,1087,3191,1882,6965,5313,5445,4017,7982,7340,5614,8814,532,8809,4664,636,6593,8792],()=>e(e.s=3084))
```

webpack 런타임의 `e.O`는 목록의 청크가 모두 로드된 뒤에 진입 모듈 `3084`를 실행한다. 목록에는 prosemirror-view가 든 청크 8667(`31f91cad-...js`), @tiptap/core가 든 청크 6411(`17312c0e-...js`), tiptap과 prosemirror-model이 든 청크 3876이 들어 있다.

### 에디터를 통해서만 닿는 코드

첫 진입에서 실행된 모듈 중 다른 모듈이 불러오지 않는 모듈 18개를 출발점으로 두고, `42120`으로 들어가는 간선을 모두 끊었을 때 도달할 수 없게 되는 모듈을 셌다. 13개 모듈, 311.7KB였고, 이 중 첫 진입에서 실행된 것은 18.4KB(18,407B, 5.9%)였다. 압축 전 크기이며, 복원하지 못한 런타임 모듈을 거치는 참조는 그래프에 없으므로 빠져 있다.

### 경로 위 모듈의 방문별 실행량

`analyze`의 시나리오별 집계에서 경로 위 모듈 두 개를 더 뽑았다.

| 모듈                   | 첫 진입 |   검색 | 종목 화면 |   피드 |
| ---------------------- | ------: | -----: | --------: | -----: |
| `96245`, 패널과 댓글   |  12.0KB | 12.0KB |    36.3KB | 12.0KB |
| `54017`, 댓글 컴포넌트 |   2.6KB |  2.6KB |    12.2KB | 13.6KB |
| 에디터 모듈 4개 합계   |  13.9KB | 13.9KB |    13.9KB | 13.9KB |

종목 화면과 피드에서 댓글 컴포넌트의 실행량이 늘었지만 에디터는 그대로였다.

## 추론과 개선 가설

여기부터는 위의 사실을 바탕으로 한 내 해석이며, 가설마다 근거와 반론, 내 프로젝트라면 무엇으로 구분할지를 함께 적었다.

> 이 절의 모듈 ID와 경로는 2026년 9월 26일에 받은 빌드(`_next/static/LCgFIQwFsag3GrRgpJus7`) 기준이며, 이후 배포에서는 맞지 않을 수 있다.

### 가설에 등장하는 모듈

모듈 ID만으로는 무엇을 가리키는지 알기 어려워서, 각 모듈의 생성 코드에서 보인 것을 먼저 정리했다. 괄호 안의 이름은 이 문자열들을 보고 내가 붙인 것이다.

| 모듈                       |    크기 | export                       | 생성 코드에서 보인 것                                                                                                                                                                                                                                     |
| -------------------------- | ------: | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `5099` (레이아웃)          |   1.0KB | `M`                          | `sidebar`, `gnb`, `main` 키를 가진 세 영역을 렌더링. 사이드바 영역에 `75745`의 `V`                                                                                                                                                                        |
| `75745` (사이드바)         | 295.7KB | `V`                          | `RemoconProvider 안에서 사용해주세요`, `MarketTab`, `AddStockToWatchListButtonComp`, 관심종목 그룹과 계좌 전환 UI, 섹션 이름 `Popover__EditOrderPopover`, “알림을 눌러 토스 앱에서 연금저축계좌를 만들 수 있어요.”                                        |
| `57427` (주문 폼 선택)     |   2.0KB | `Rw`, `CG`, `P$`, `EW`, `w_` | `BuyOrderForm`, `SellOrderForm`, `OcoOrderForm`, `OtoOrderForm` 중 하나를 고르는 `switch`                                                                                                                                                                 |
| `70388` (주문 폼)          |  10.7KB | `H8`, `xX`, `_D`, `bU`, `lc` | “구매 조건 주문”, “판매 조건 주문”, “연속주문”, “최대한 빠른 가격”, `주문하기__조건주문`, `OrderFormHeader`                                                                                                                                               |
| `96245` (호가와 주문 패널) | 263.7KB | `pF`, `Hi`                   | `createContext` 10개, `tabList` 19곳, `QuotesTradingFloatingUiProvider`, `BuyOrderBookKrComp`, `AveragingCalculatorPanel`(물타기 계산기), `ProgramTradingPanel`, “현재 시간에는 PIP 주문을 할 수 없어요”, 섹션 경로 `["커뮤니티","게시글"]`로 댓글 렌더링 |
| `54017` (댓글)             |  41.6KB | `K`, `L`                     | `togglePinComment`, `CancelEditDialog`, “편집중인 내용이 모두 사라지고, 되돌릴 수 없어요”, “하나의 글만 고정할 수 있어요”, “로그인 하고 의견을 남겨보세요”                                                                                                |
| `42120` (tiptap 추정)      |  52.3KB | `U1`, `s` 등 11개            | `react-renderer`, `closeHistory`, `mention`, “의견을 남겨주세요.”                                                                                                                                                                                         |
| `75808` (계좌 개설)        | 117.8KB | `D`                          | `AccountOpenContext is not initialized`, `/api/v1/multi-account-open/init`, “비대면 계좌개설이 차단되어 있어 계좌를 만들 수 없어요”, 약관 동의와 PIN 확인 단계                                                                                            |
| `72627` (주문 페이지 본문) | 533.4KB | `b`, `_`                     | `/api/v1/stock-detail/ui/wts/{stockCode}`, “차트, 호가, 옵션목록을 한 화면에서 볼 수 있어요”, 주문 오류 대화상자 여러 개                                                                                                                                  |

크기는 모듈 경계로 복원한 생성 코드의 바이트 수이며, 첫 진입 집계의 값과 몇 바이트 다를 수 있다. 집계에서는 모듈 함수의 헤더를 원본 미지정 영역으로 빼기 때문이다.

### 에디터가 첫 화면에 들어온 경위

경로의 모든 간선은 모듈 함수 최상위의 변수 선언(`p=n(96245)` 같은 형태)에서 나왔다. 8개 간선 모두 함수나 조건문 안의 `require`가 아니므로, 모듈이 평가되면 import한 모듈도 무조건 평가된다. 그렇다면 남는 질문은 “왜 이 import들이 정적으로 연결됐는가”다. 네 가지 가설을 세웠다.

**가설 1. 여러 원본 파일이 모듈 연결로 호가와 주문 패널 `96245` 하나가 됐다.** webpack의 모듈 연결(`concatenateModules`, 여러 ES 모듈을 하나의 모듈 함수로 합치는 최적화)은 합쳐진 파일들의 import를 모두 모듈 함수의 최상위로 올린다. `96245`에는 `createContext`가 10개 있고, 호가 컴포넌트(`BuyOrderBookKrComp`), 물타기 계산기(`AveragingCalculatorPanel`), 프로그램 거래 패널(`ProgramTradingPanel`), 탭 목록(`tabList` 19곳), 커뮤니티 댓글 렌더링이 한 모듈에 들어 있다. 한 파일에 컨텍스트 10개와 이만큼 다른 기능을 썼을 가능성보다, 패널과 그 안의 탭 콘텐츠, 컨텍스트 정의 파일 여러 개가 합쳐졌을 가능성이 높다고 생각한다. 반론도 있다. 한 파일이 원래 이만큼 컸을 수 있고, 생성 코드만으로는 원본 파일 경계가 보이지 않는다. 내 프로젝트라면 webpack의 `stats.json`에서 이 모듈의 `modules` 목록(연결된 원본 파일)을 보면 바로 구분된다.

**가설 2. 배럴 파일(여러 모듈을 다시 export하는 `index.ts`)과 `sideEffects` 설정.** 주문 폼 `70388`이 쓰는 것은 `Hi` 하나다. `Hi`는 `function ur(){return(0,r.use)(un)}`, 즉 패널의 탭 컨텍스트를 읽는 한 줄이다. `Hi`와 패널 컴포넌트 `pF`가 같은 폴더의 `index.ts`에서 함께 export된다면, 훅만 쓰는 쪽도 그 `index.ts`를 import하게 된다. 패키지에 `"sideEffects": false`가 없으면 webpack은 쓰지 않는 re-export의 모듈도 부작용이 있을 수 있다고 보고 평가 순서에 남긴다. 이 가설은 가설 1과 겹칠 수 있다. 배럴 파일이 원인이고, 모듈 연결은 그 결과를 한 모듈로 합친 방식일 수 있다. 다만 `96245`의 export가 `pF`와 `Hi` 두 개뿐이라는 점은 여러 기능을 모은 배럴 파일보다는 패널 하나의 폴더가 합쳐졌다는 쪽에 가깝다. 구분하려면 원본에서 `Hi`를 import하는 경로를 확인해야 한다.

**가설 3. 레이아웃의 사이드바가 주문 수정 팝오버를 정적으로 import했다.** 레이아웃 `5099`는 로그인 여부와 관계없이 사이드바 `75745`를 렌더링한다. 사이드바에는 관심종목, 계좌 전환, 시장 탭 같은 UI와 함께 섹션 이름이 `Popover__EditOrderPopover`인 팝오버가 있고, 이 팝오버를 위해 주문 폼 선택 `57427`을 import한다. 팝오버가 열려야 주문 폼이 렌더링되지만 import는 모듈 평가 시점에 이미 일어났다. 로그인하지 않은 방문자는 수정할 주문이 없으니 이 import가 쓰일 일이 없다. 다만 이번에 찾은 최단 경로가 이 import에서 시작할 뿐, `96245`의 `Hi`를 부르는 모듈은 `70388` 외에도 같은 청크에 5개가 더 있다. 이 import 하나만 바꿔서는 패널이 첫 화면에서 빠지지 않을 수 있다.

**가설 4. 댓글 컴포넌트가 에디터를 정적으로 import했다.** 댓글 모듈 `54017`에는 댓글 고정(`togglePinComment`), 편집 취소 대화상자(`CancelEditDialog`), 답글 필터 같은 목록 기능과 입력 기능이 함께 있다. 입력 컴포넌트는 `mode:"create"`와 `mode:"edit"`로 렌더링되고, 여기서 `42120`의 export `s`를 훅으로 부르며 `U1`로 감싼다. `42120`에는 “의견을 남겨주세요.”라는 placeholder 문자열도 있었다. 댓글 목록을 보여주는 코드와 댓글을 쓰는 코드가 같은 모듈에 있으면, 목록만 보여도 에디터가 평가된다. 피드 방문에서 댓글 모듈의 실행량은 2.6KB에서 13.6KB로 늘었지만 에디터는 그대로였다는 관찰이 이 가설과 맞는다.

네 가설은 서로 배타적이지 않다. 가설 3과 4는 “어디서 import했는가”, 가설 1과 2는 “왜 import 범위가 필요 이상으로 넓어졌는가”에 대한 답이다.

### 미리 받은 페이지도 첫 방문에서 평가된다

개선 효과를 계산하다가 예상하지 못한 점을 발견했다. 첫 진입에서 실행된 진입 모듈(다른 모듈이 불러오지 않는 모듈) 18개 중 12개가 `dynamic`으로 받은 청크에 있었고, 그중 10개는 `pages/screener`, `pages/calendar`, `pages/stocks/[symbol-or-stock-code]/order` 같은 **다른 페이지의 진입 모듈**이었다. 받기만 한 것이 아니라 모듈 함수까지 실행됐다.

토스증권의 `main` 청크에는 Next.js 15.4.6이라는 문자열과 함께 다음 코드가 있었다.

```text
...n.href=t,document.head.appendChild(n)})}):[])).then(()=>{(0,i.requestIdleCallback)(()=>this.loadRoute(t,!0).catch(()=>{}))})
```

Pages Router의 링크 미리 받기가 `<link rel="prefetch">`로 파일을 받은 뒤, 브라우저가 한가할 때 `loadRoute`로 그 페이지를 실제로 불러오는 코드로 보인다. Next.js 15.4.6의 `route-loader.ts`를 보면 미리 받기가 끝난 뒤 [`requestIdleCallback`에서 `loadRoute(route, true)`를 호출하고](https://github.com/vercel/next.js/blob/v15.4.6/packages/next/src/client/route-loader.ts#L445), 페이지 청크가 등록되면 [`onEntrypoint`가 `execute()`로 페이지 모듈을 평가한다](https://github.com/vercel/next.js/blob/v15.4.6/packages/next/src/client/route-loader.ts#L348-L351). 첫 진입 기록에서 다른 페이지의 진입 모듈이 실행됐다는 관찰과 맞는다.

이 발견은 효과 계산을 두 갈래로 나누게 만들었다. 어떤 import를 끊어 초기 HTML의 진입점에서 닿는 코드를 빼더라도, 미리 받은 페이지가 같은 코드를 import하면 유휴 시간에 결국 받아서 평가한다. 줄어드는 것이 “초기 진입점에서 평가되는 JS”인지 “첫 방문 전체의 JS”인지 구분해야 한다.

### 내 프로젝트였다면 검토할 변경

복원한 그래프에서 후보마다 해당 모듈로 들어오는 정적 import를 모두 끊었다고 가정하고, 더 이상 닿지 않게 되는 모듈의 첫 진입 기준 크기를 셌다. 출발점은 두 가지로 잡았다. **초기 HTML 진입점 기준**은 초기 HTML이 요청한 청크의 진입 모듈 6개에서 출발하고, **관찰된 전체 진입점 기준**은 미리 받은 페이지까지 포함해 첫 진입에서 실행된 진입 모듈 18개에서 출발한다. 그래프 도달성으로 센 값이라 이 코드가 화면 표시나 hydration 전후 중 언제 평가되는지는 알려주지 않는다.

| 후보                        | 바꾸는 import               | 초기 HTML 진입점 기준 | 관찰된 전체 진입점 기준 |
| --------------------------- | --------------------------- | --------------------: | ----------------------: |
| E. 에디터 지연 로딩         | `42120`을 import하는 3곳    |         311.7KB, 13개 |           311.7KB, 13개 |
| H. 훅과 컨텍스트 분리       | `96245`에서 `Hi`만 쓰는 6곳 |      1,197.0KB, 411개 |                     0KB |
| O. 주문 폼 지연 로딩        | `57427`을 import하는 3곳    |      1,245.9KB, 436개 |              2.0KB, 1개 |
| M. 댓글 컴포넌트 지연 로딩  | `54017`을 import하는 5곳    |        546.0KB, 170개 |            51.9KB, 32개 |
| A. 계좌 개설 모달 지연 로딩 | `75808`을 import하는 3곳    |         132.2KB, 13개 |           132.2KB, 13개 |
| F. framer-motion 추정 모듈  | `25134`를 import하는 2곳    |           95.0KB, 1개 |             95.0KB, 1개 |
| G. gsap 추정 모듈           | `63575`를 import하는 2곳    |           86.4KB, 1개 |             86.4KB, 1개 |

이 표는 그래프 도달성으로 계산한 상한에 가까운 추정이다. 번들러가 청크를 다시 나누는 방식, 압축 후 크기, 복원하지 못한 런타임 모듈을 거치는 참조, 새로 생길 청크의 요청 비용은 반영하지 않았다. 실제 효과는 원본을 고치고 다시 빌드해야 알 수 있다.

**E. 에디터 지연 로딩.** 두 기준에서 값이 같은 후보(E, A, F, G) 중 가장 크다. 에디터는 미리 받은 페이지에서도 쓰이지만, 그 페이지들 역시 에디터를 정적으로 import하는 댓글 컴포넌트를 거치므로 3곳을 모두 바꾸면 어느 기준에서도 빠진다. `54017`, `5172`, `8351`에서 에디터를 쓰는 입력 컴포넌트를 `next/dynamic`으로 감싸고, 입력창에 포커스가 가거나 수정 버튼을 누를 때 불러오는 방식을 생각할 수 있다. 대가는 처음 댓글을 쓸 때 압축 전 311.7KB의 코드를 받고 평가하는 대기다. 입력창에 마우스를 올렸을 때 미리 불러오면 그 대기를 줄일 수 있다. 영향 범위가 입력 컴포넌트로 좁고 효과가 두 기준에서 모두 확실해서, 내가 먼저 시도한다면 이 후보다.

**H. 훅과 컨텍스트 분리.** `Hi`만 쓰는 6곳이 패널 전체를 끌고 오는 구조를 끊는다. 컨텍스트 정의와 `Hi`를 패널 파일과 분리해, 패널을 import하지 않는 작은 모듈로 옮기는 변경이다. 초기 HTML 진입점 기준으로는 411개 모듈, 1,197.0KB가 빠진다. 에디터와 댓글, 패널, 주문 폼 `7367`, 드래그 앤 드롭 모듈 `9334`가 모두 여기에 들어 있다. 하지만 관찰된 전체 진입점 기준으로는 0B다. 미리 받은 주문 페이지의 `72627`이 패널 `pF`를 직접 쓰기 때문이다. 따라서 H가 줄이는 것은 첫 방문의 전송량이 아니라 **초기 진입점에서 평가되는 코드의 양**이다. 그것도 번들러가 이 모듈들을 홈 페이지의 청크 밖으로 다시 나눠야 생기는 효과이고, 이 변화가 화면 표시나 상호작용을 얼마나 앞당기는지는 이번 자료로 알 수 없어 가설로 남긴다.

**O. 주문 폼 지연 로딩.** 가설 3에 대응한다. 사이드바의 주문 수정 팝오버가 열릴 때 주문 폼을 불러오면, 초기 HTML 진입점 기준으로 H보다 조금 더 많은 1,245.9KB가 빠진다. 관찰된 전체 진입점 기준은 2.0KB다. 주문 폼은 주문 페이지에서 쓰이므로 미리 받기가 결국 가져온다. 로그인하지 않은 방문자에게는 쓰일 일이 없는 경로이므로, 팝오버를 여는 동작에 대기가 조금 생기는 것 외에는 위험이 작다고 생각한다. H와 O는 같은 경로의 서로 다른 지점을 끊으므로 둘 중 하나만 해도 초기 진입점 기준의 효과는 비슷하다.

나머지 후보는 요약만 남기고 자세한 분석은 [별도 문서](/demos/coldpath/toss-candidates-2026-09-26.md)로 옮겼다. M(댓글 컴포넌트)은 E를 먼저 하면 더 얻는 것이 댓글 컴포넌트 자체와 그 의존성 정도다. A(계좌 개설 모달)는 모달을 여는 `initOpenAccount`가 이미 `async function`이어서 `await import()`로 바꾸기 쉬운 구조로 보인다. F와 G(애니메이션 라이브러리로 추정)는 대화상자를 열 때 쓰이는 코드라 효과에 비해 위험이 크다고 본다. H와 O가 관찰된 전체 진입점 기준에서 효과가 없던 원인인 링크 미리 받기(P)도 조정 후보다. 첫 진입에서 미리 받은 파일 33개, 1,556.0KB 중 1,258.9KB가 실행되지 않았지만, 어떤 링크를 미리 받을지는 실제 이동 비율을 알아야 정할 수 있다.

### 변경한 뒤 확인할 것

어느 후보든 변경 후에는 같은 방문 기록을 다시 수집해 다음을 비교하겠다.

- 홈 페이지 청크의 `e.O` 목록에서 에디터나 패널이 든 청크가 빠졌는가
- `html`로 분류된 파일의 크기와 첫 진입의 실행 관찰량이 어떻게 바뀌었는가. 미실행률이 아니라 초기 진입점에서 평가된 양을 본다
- `dynamic`으로 받은 파일과 그 평가량이 늘지는 않았는가. H와 O는 코드를 유휴 시간으로 옮길 뿐일 수 있다
- 지연 로딩한 UI를 처음 열 때의 대기. 댓글 입력, 주문 수정 팝오버, 계좌 개설 모달을 각각 열어 본다

합성 소스맵은 수집 뒤에 만든 것이므로 모든 파일에 `source-map=unverified`가 남는다.

이런 추적이 가능했던 것은 토스증권이 webpack으로 빌드돼 생성 코드에 모듈 함수가 남아 있었기 때문이기도 하다. Rollup 계열 번들러는 모듈을 청크 하나의 스코프에 펼치므로, 소스맵이 없는 Vite 빌드에서는 `modules`가 나눌 경계 자체가 없다. 9월 25일에 저장한 Excalidraw 첫 화면의 JavaScript에 0.3.1의 `modules`를 돌렸을 때도 결과는 `Recovered 0 modules in 0 chunks, 5 whole-chunk sources`였다.
