본문으로 건너뛰기
yceffort
PostsSeriesTagsAbout🧪 Research
EN

Tweaks

theme
accent palette
film grain
minimal mode
BACK TO INDEX
◆ ESSAY
--min
--year
KOoriginal

mailMail icongithubtwitter
yceffort
•
© 2026
•
https://yceffort.kr
BACK TO INDEX
◆ SERIES · coldpath 제작기

소스맵 없이 토스증권의 JavaScript 추적해보기

avatar
yceffort
2026-09-26 · 41분
41min
2026year
KOoriginal
debuggingweb-performance
시리즈

coldpath 제작기

3 / 3
목록 보기
  1. 01블로그의 JavaScript가 언제 실행되는지 추적해보기
  2. 02V8 커버리지와 소스맵으로 번들 분석기 만들기
  3. 03소스맵 없이 토스증권의 JavaScript 추적해보기
V8 커버리지와 소스맵으로 번들 분석기 만들기

Table of Contents

  • 첫 진입에서 70.3%가 실행되지 않았다
  • snapshot: 브라우저가 받은 코드와 실행 기록
  • modules: 모듈 경계 복원과 Sentry 코드
  • analyze: 모듈별 실행량
  • label: 모델이 붙인 이름
  • modules --graph와 --why: 참조 경로
  • analyze --export와 --replay: 근거 넘기기
  • 명령 밖에서 확인한 것
    • 홈 진입 모듈에서 에디터까지
    • 받기만 한 것이 아니라 실행됐다
    • 에디터를 통해서만 닿는 코드
    • 경로 위 모듈의 방문별 실행량
  • 추론과 개선 가설
    • 가설에 등장하는 모듈
    • 에디터가 첫 화면에 들어온 경위
    • 미리 받은 페이지도 첫 방문에서 평가된다
    • 내 프로젝트였다면 검토할 변경
    • 변경한 뒤 확인할 것

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

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

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

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

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. 이 글에서 다루는 모듈 복원 수정(커밋 7dca3ee)을 포함한 버전이다. 크기는 모두 생성 코드를 UTF-8로 센 값이다. 1KB는 1,000B로 계산했고, 바이트 단위 값은 측정 요약 JSON에 남겼다. 이 값은 압축한 전송량이나 CPU 시간은 아니다.

자료: 재현 절차, 측정 요약 JSON

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

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

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

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

이 명령으로 알아낸 것은 방문마다 받은 파일과 그 로딩 경위다. 첫 진입 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는 생성 코드에 있는 번호 그대로다.

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의 청크 등록 코드를 잇는 방식이었다. 성공한 청크는 ;로 문장을 끊었고, 실패한 청크는 쉼표로 두 식을 한 문장에 이었다.

!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는 최상위 문장의 식이 곧 push(...) 호출일 때만 청크 등록으로 인식했다. 쉼표로 이어진 식은 SequenceExpression(쉼표 연산자로 여러 식을 이은 하나의 식)이라서 검사를 통과하지 못했다. 첫 진입의 저장본에서 대조해 보면 쉼표로 이어진 청크 30개는 모두 실패했고, ;로 끊긴 청크 27개는 모두 성공했다.

커밋 7dca3ee에서 쉼표로 이어진 식을 하나씩 풀어 검사하도록 바꿨고, 0.3.1로 배포했다. 같은 입력에서 결과는 다음처럼 달라졌다.

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

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

analyze: 모듈별 실행량

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

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.4KB2,715.3KB6,424.1KB70.3%
search.json삼성전자 입력 후 005930 결과 대기9,140.5KB2,805.6KB6,335.0KB69.3%
stock.json검색 결과를 눌러 종목 화면으로 이동14,131.6KB4,914.1KB9,217.4KB65.2%
feed.json피드로 이동 후 글 목록 대기9,159.1KB2,787.7KB6,371.5KB69.6%
전체 합집합4개 기록에서 한 번이라도 실행된 구간14,151.3KB4,937.5KB9,213.8KB65.1%

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

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

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

모듈파일로딩 분류크기미실행량
726272627-...jsdynamic533.4KB480.4KB
757458814-...jshtml295.7KB264.0KB
962458814-...jshtml263.7KB251.8KB
55707pages/_app-...jshtml223.8KB177.5KB
59041pages/_app-...jshtml200.3KB161.3KB
954284429-...jsdynamic188.4KB130.7KB
6175031f91cad-...jshtml95.2KB92.7KB

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

토스증권 분석 보고서 새 창에서 열기

토스증권의 코드를 다시 배포하지 않으려고 이 보고서는 --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.2KB2.4KB2.4KB2.4KB2.4KB
73681, @tiptap/core 추정60.2KB5.2KB5.2KB5.2KB5.2KB
42120, tiptap 추정52.3KB5.0KB5.0KB5.0KB5.0KB
33036, prosemirror-model 추정45.3KB1.3KB1.3KB1.3KB1.3KB

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

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

라벨이 얼마나 맞는지 가늠하려고, 공개 소스맵이 있는 GitLab의 탐색 페이지에서 같은 방식으로 라벨을 붙이고 채점한 적이 있다. 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, 항목별 결과는 검증 요약 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을 물으면, 그래프의 진입 모듈에서 출발하는 최단 경로 하나를 알려준다.

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에 남겼다.

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

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 간선을 뺀 최단 경로를 구했다. 그다음 각 간선을 생성 코드에서 확인했다.

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

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

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

    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에 넘긴다.

    n="주문하기__조건주문",...,{tabList:l}=(0,p.Hi)()??{},{targetRef:i}=(0,T.p)(n,{params:{featureTab:t,tabList:l}})
    
  • 96245에서 Hi는 컨텍스트 un을 읽어 돌려주는 한 줄 함수다. 다른 export pF는 panelId, initialTabState를 받는 패널 컴포넌트다.

    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로 했고, 스크립트는 공개하지 않은 증거 묶음에 함께 넣었다.

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

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.0KB12.0KB36.3KB12.0KB
54017, 댓글 컴포넌트2.6KB2.6KB12.2KB13.6KB
에디터 모듈 4개 합계13.9KB13.9KB13.9KB13.9KB

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

추론과 개선 가설

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

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

가설에 등장하는 모듈

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

모듈크기export생성 코드에서 보인 것
5099 (레이아웃)1.0KBMsidebar, gnb, main 키를 가진 세 영역을 렌더링. 사이드바 영역에 75745의 V
75745 (사이드바)295.7KBVRemoconProvider 안에서 사용해주세요, MarketTab, AddStockToWatchListButtonComp, 관심종목 그룹과 계좌 전환 UI, 섹션 이름 Popover__EditOrderPopover, “알림을 눌러 토스 앱에서 연금저축계좌를 만들 수 있어요.”
57427 (주문 폼 선택)2.0KBRw, CG, P$, EW, w_BuyOrderForm, SellOrderForm, OcoOrderForm, OtoOrderForm 중 하나를 고르는 switch
70388 (주문 폼)10.7KBH8, xX, _D, bU, lc“구매 조건 주문”, “판매 조건 주문”, “연속주문”, “최대한 빠른 가격”, 주문하기__조건주문, OrderFormHeader
96245 (호가와 주문 패널)263.7KBpF, HicreateContext 10개, tabList 19곳, QuotesTradingFloatingUiProvider, BuyOrderBookKrComp, AveragingCalculatorPanel(물타기 계산기), ProgramTradingPanel, “현재 시간에는 PIP 주문을 할 수 없어요”, 섹션 경로 ["커뮤니티","게시글"]로 댓글 렌더링
54017 (댓글)41.6KBK, LtogglePinComment, CancelEditDialog, “편집중인 내용이 모두 사라지고, 되돌릴 수 없어요”, “하나의 글만 고정할 수 있어요”, “로그인 하고 의견을 남겨보세요”
42120 (tiptap 추정)52.3KBU1, s 등 11개react-renderer, closeHistory, mention, “의견을 남겨주세요.”
75808 (계좌 개설)117.8KBDAccountOpenContext is not initialized, /api/v1/multi-account-open/init, “비대면 계좌개설이 차단되어 있어 계좌를 만들 수 없어요”, 약관 동의와 PIN 확인 단계
72627 (주문 페이지 본문)533.4KBb, _/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이라는 문자열과 함께 다음 코드가 있었다.

...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)를 호출하고, 페이지 청크가 등록되면 onEntrypoint가 execute()로 페이지 모듈을 평가한다. 첫 진입 기록에서 다른 페이지의 진입 모듈이 실행됐다는 관찰과 맞는다.

이 발견은 효과 계산을 두 갈래로 나누게 만들었다. 어떤 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는 같은 경로의 서로 다른 지점을 끊으므로 둘 중 하나만 해도 초기 진입점 기준의 효과는 비슷하다.

나머지 후보는 요약만 남기고 자세한 분석은 별도 문서로 옮겼다. 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였다.

← 이전 편V8 커버리지와 소스맵으로 번들 분석기 만들기

관련 글

  • #web-performance#javascript#browser

    실행하지 않는 JavaScript를 10MiB까지 늘려봤다

    호출하지 않는 함수가 10MiB 들어 있으면 얼마나 손해일까. 바이트가 만든 비용과 코드 형태가 만든 비용을 갈라 855회 측정했다. 미호출 선언은 MiB당 약 21ms로 CPU를 4배 늦춰도 늘지 않았고, 파일을 읽자마자 실행되는 초기화 코드는 MiB당 201ms를 메인 스레드에 얹었다. 실제 라이브러리에서는 import만 해도 모듈 평가가 실행됐다.

    2026-09-16·54분
  • #bundler#web-performance#debugging

    외부 SDK를 뜯어서 내 맘대로 다시 만들기: 단, 로직은 한 줄도 건드리지 않고

    상수 하나를 import 했는데 번들의 97.7%가 따라왔다. 벤더는 고칠 일정이 미정이라기에, 배포된 소스맵을 뜯어 400개가 넘는 TypeScript 파일을 되찾고 빌드와 엔트리와 의존성을 내 맘대로 갈아엎었다. 로직만 빼고. 그래서 /send가 raw -77.5%. 그런데 어려운 건 그다음이었다. 테스트 1932개가 전부 통과했는데, 그중 몇 개는 아무것도 보고 있지 않았다.

    2026-08-31·70분
  • #web-performance#browser#javascript

    웹 애플리케이션에서 자바스크립트 프로파일링 해보기

    해봤지만 해보지 않았습니다

    2022-01-20·24분
  • #bundler#web-performance#javascript

    왜 CommonJS는 번들사이즈를 크게 하는가?

    [How CommonJS is making your bundles larger](https://web.dev/commonjs-larger-bundles/) 를 번역 & 요약한 글입니다. ```toc tight: true, from-heading: 2 to-heading: 3 ``` **요약: 웹 애플리케이션을 확실하게 최적화해서 번들링하기 위해서는, C...

    2020-07-05·12분

새 글을 놓치고 싶지 않으시다면 RSS로 구독해 주세요.

RSS 구독 →

yceffort — 프론트엔드 엔지니어입니다.

← Back to the blogIssue on GitHub →