브라우저 DevTools 가이드 — Performance·Memory·Network·Sources 심화
이 글의 핵심
console.log만으로는 화면이 버벅이는 이유나 메모리가 계속 늘어나는 원인을 찾기 어렵습니다. 샘플링과 계측 방식의 차이, Retainers 경로 읽는 법, TTFB가 낮은데도 느린 요청을 해석하는 법을 짚고, 소스맵 정책과 에러 모니터링, 모바일 실기기 원격 디버깅처럼 프로덕션 문제를 재현하는 패턴까지 연결합니다.
들어가며
브라우저 개발자 도구(DevTools)는 단순히 console.log를 대체하는 도구가 아닙니다. 메인 스레드 스케줄링, 가비지 컬렉션과 힙 구조, HTTP/2·HTTP/3 위에서의 요청 우선순위, V8 디버거가 제공하는 다양한 중단 조건까지 이해해야 실제 사용자 환경에서의 병목을 재현하고 줄일 수 있습니다. 이 글은 UI 버튼 나열이 아니라, 측정값이 의미하는 내부 동작과 실무에서의 해석·대응에 초점을 둡니다. (기준 UI는 Chromium 계열 Chrome/Edge를 가정하며, Firefox/Safari는 이름이 다를 수 있습니다.)
Performance: 플레임 그래프(플레임 차트) 심화
무엇을 그리는가
Performance 패널에서 기록하면 Main 트랙은 브라우저 메인 스레드 위에서 일어나는 작업의 타임라인입니다. 각 막대(bar) 는 작업 단위이며, 세로로 쌓인 플레임(flame) 은 호출 스택을 나타냅니다. 위쪽이 호출자, 아래쪽이 피호출자입니다. 길이 = 시간, 높이 = 스택 깊이로 읽으면 됩니다.
색과 트랙의 의미
- 노란색: JavaScript 실행(파싱 이후 실행, 이벤트 핸들러, 마이크로태스크 등).
- 보라색: 레이아웃·스타일 계산과 연관된 작업(레이아웃 스래싱이 심하면 여기서 붙은 시간이 커짐).
- 초록: 페인트·컴포지트(레이어 합성, GPU 관련).
- 회색: 기타(가비지 컬렉션, 브라우저 내부 등) — 버전에 따라 표기 차이가 있습니다.
Frames 트랙은 목표 프레임 시간(보통 16.6ms @60Hz) 대비 빨간 막대가 있으면 그 구간에서 프레임 드롭이 났다는 뜻입니다.
롱 태스크(Long Task)와 메인 스레드
롱 태스크는 대략 50ms 이상 메인 스레드를 점유하는 작업으로 간주됩니다(측정 도구 기준). 메인 스레드가 막히면 입력 지연, 스크롤 버벅임, 애니메이션 끊김이 같이 옵니다. 플레임 차트에서 빨간 삼각형 또는 경고 표시가 붙은 구간을 찾아, 그 아래로 펼쳐 어느 함수가 긴 막대를 만드는지 확인합니다.
Call Tree와 Bottom-Up
플레임 차트 아래의 하단 탭에서 같은 데이터를 다른 방향으로 집계해 볼 수 있습니다. Call Tree 탭은 루트 활동(이벤트, 타이머 등)에서 시작해 하위 호출로 내려가는 하향식(top-down) 뷰로, “이 이벤트가 무엇을 불렀는지”를 따라갈 때 유리합니다. Bottom-Up 탭은 반대로 실제로 시간을 소비한 함수(Self Time)부터 모아 보여 주므로, 선택한 구간에서 누가 제일 비쌌는지 빠르게 찾을 때 씁니다. 둘 다 플레임 차트에서 선택한 범위에만 적용됩니다.
실무 팁: 한 번의 인터랙션(클릭 한 번, 라우트 전환 한 번)만 재현해 짧게 녹화하면, 불필요한 백그라운드 타이머 노이즈를 줄일 수 있습니다.
샘플링 vs 추적(계측)
샘플링 프로파일러는 일정 간격으로 스택을 찍어 통계적으로 시간을 추정하고, 계측(instrumentation)은 함수 진입·탈출에 훅을 걸어 정확한 시간을 잡습니다. Performance 패널의 JS 호출 스택은 V8 샘플링 프로파일러에서 오므로, 샘플 간격보다 짧은 함수는 아예 보이지 않거나 실제보다 길게 보일 수 있습니다. 그래서 개별 함수의 미세한 시간보다 병목의 방향을 잡는 데 쓰는 것이 안전합니다. 의심이 가면 User Timing API(performance.mark / measure)로 구간을 명시적으로 표시하는 것도 좋습니다.
performance.mark('heavy-start');
// ... 의심 구간 ...
performance.mark('heavy-end');
performance.measure('heavy-work', 'heavy-start', 'heavy-end');
기록 후 Timings 트랙에 표시되면, 플레임 차트에서 해당 구간과 정확히 대응시킬 수 있습니다.
레이아웃 스래싱과 페인트 비용
한 프레임 안에서 읽기(offsetHeight 등)와 쓰기(style 변경)를 번갈아 하면 브라우저가 레이아웃을 반복 계산할 수 있습니다. 플레임 차트에서 Layout / Recalculate Style이 연속으로 길게 보이면, 읽기 배치 → 쓰기 배치로 나누는 리팩터링, transform/opacity 중심 애니메이션, will-change 남용 금지(실제로 애니메이션할 요소에만) 같은 대응을 검토합니다.
Memory: 힙 스냅샷 분석 심화
스냅샷을 찍는 타이밍
- 탐색 전/후: SPA 라우트 전환 전후로 스냅샷을 두 번 찍어 객체 개수·메모리가 단조 증가하는지 봅니다.
- 작업 반복 후: 같은 시나리오를 N번 반복한 뒤 스냅샷 — 메모리 누수 의심 시 유효합니다.
Summary / Containment / Statistics
- Summary: 생성자(타입)별 얕은 크기(Shallow) / 유지 크기(Retained) 합계. “무엇이 많이 쌓였는지” 한눈에 봅니다.
- Shallow size: 객체 자체 필드가 차지하는 크기.
- Retained size: 해당 객체를 삭제했을 때 함께 회수될 수 있는 총 크기(도달 가능한 하위 포함). 리크 탐색에서 Retained가 큰 타입이 우선 후보입니다.
- Distance: GC 루트로부터의 거리. 예상보다 가깝게 붙어 있는 커스텀 객체가 있으면 의심합니다.
Comparison 모드
두 스냅샷 사이에 # Delta로 늘어난 객체를 보면, “이번 작업에서 새로 살아남은 것”이 드러납니다. Array, system / Context 등 브라우저 내부도 보이므로, 자신의 코드와 연결된 경로(리테이너)를 반드시 확인합니다.
Retainers(유지 경로) 읽기
객체를 선택하면 Retainers 패널에 “누가 이 객체를 참조하는가”가 나옵니다. V8의 GC는 루트에서 도달 가능한지로 판단하므로 순환 참조 자체는 회수를 막지 않지만, 루트에서 도달 가능한 경로가 하나라도 있으면 회수되지 않습니다. 여기서 클로저, 전역 캐시, 이벤트 리스너, 타이머, 커스텀 요소의 콜백이 자주 등장합니다.
Detached DOM
Detached HTMLDivElement 같은 항목은 DOM 트리에서는 떨어졌지만 JS가 여전히 참조하는 노드입니다. 흔한 원인은 다음과 같습니다.
- 제거한 요소를 배열/맵에 캐시해 둔 경우
- 서드파티 위젯이 내부적으로 노드를 붙잡는 경우
- MutationObserver·리스너 해제 누락
대응: 해당 노드를 선택하고 Retainers로 어느 변수/클로저가 붙잡는지 역추적한 뒤, WeakRef/WeakMap 적용 여부, 리스너·옵저버의 명시적 해제, 캐시의 크기 상한이나 TTL을 검토합니다.
Allocation instrumentation on timeline
“언제 할당이 급증했는지” 시간축으로 보고 싶다면 Memory 패널의 Allocations on timeline(이전 이름 Allocation instrumentation on timeline)을 사용합니다. 오버헤드가 더 적은 Allocation sampling은 함수별 할당량을 샘플링으로 집계합니다. 스냅샷이 “순간 사진”이라면, 이쪽은 시간에 따른 할당에 유리합니다.
Network: 워터폴 해석
막대 하나의 구조
각 요청 행의 타임라인 막대는 보통 왼쪽부터 다음 단계로 나뉩니다(라벨은 버전별 상이).
- Queueing / Stalled: 브라우저가 연결 슬롯·우선순위·HTTP/2 스트림 때문에 보내기를 기다린 시간. 동시 요청이 많거나 우선순위가 낮은 리소스에서 길어질 수 있습니다.
- DNS: 호스트명 조회. 캐시되면 짧습니다.
- Initial connection / SSL: TCP + TLS 핸드셰이크. TTFB만 줄이려다 여기를 놓치는 경우가 많습니다.
- Waiting (TTFB): 요청을 보낸 뒤 응답 첫 바이트까지. 서버 처리·DB·캐시 미스·지역 지연이 반영됩니다.
- Content Download: 응답 바이트를 받는 시간. 페이로드 크기와 대역폭에 민감합니다.
TTFB는 낮은데 느린 경우
워터폴에서 Waiting은 짧고 Content Download이 긴 형태면, 압축·번들 크기·이미지 최적화·불필요한 JSON을 의심합니다. 반대로 Waiting이 길면 서버/엣지 캐시·쿼리·콜드 스타트·지역 라우팅을 봅니다.
Initiator와 의존성 체인
Initiator 열은 “누가 이 요청을 트리거했는지”를 줍니다. script.js가 다른 스크립트를 로드하는 체인이 길면, 크리티컬 경로가 늘어납니다. async/defer, dynamic import, 프리로드(<link rel="preload">) 로 의존 그래프를 짧게 만드는 전략과 연결해 해석합니다.
우선순위(Priority)와 HTTP/2·HTTP/3
크롬의 Priority Hints나 브라우저가 부여하는 urgency는 워터폴에서 같은 출처의 여러 요청이 어떤 순서로 자원을 받는지에 영향을 줍니다. LCP 이미지가 늦게 오면 fetchpriority="high"(해당 속성 지원 브라우저) 등을 검토합니다 — 단, 과도한 high는 다른 크리티컬 리소스를 밀어낼 수 있어 한 번에 하나씩 검증합니다.
Waterfall만으로는 부족할 때
서버 타이밍(Server-Timing) 헤더로 TTFB 내부를 db;dur=12, app;dur=34처럼 쪼개면, Network의 Waiting이 어느 층에서 커졌는지 네트워크 패널과 합쳐 읽기 좋습니다.
Sources: 브레이크포인트 종류와 사용 맥락
Sources 패널에서 디버깅은 실행을 멈추는 조건을 얼마나 정교하게 걸 수 있는지가 속도를 가릅니다.
Line breakpoint (일반 줄 중단)
특정 줄에서 항상 멈춥니다. 가장 단순하지만 호출 빈도가 높은 경로에 걸면 멈춤이 너무 많습니다.
Conditional breakpoint (조건부)
표현식이 true일 때만 멈춥니다. 예: id === failingId, count > 100. 루프나 이벤트 폭주 구간에서 필수에 가깝습니다.
Logpoint (로그포인트)
실행을 멈추지 않고 콘솔에 로그만 남깁니다. console.log를 코드에 넣었다 뺐다 할 필요가 줄어들고, 배포본을 수정하지 않고도 내 브라우저에서만 값을 찍어 볼 수 있습니다.
Call stack과 비동기
Async 스택 트레이싱이 켜져 있으면 await 이후 스택이 끊기지 않고 이어지는 경우가 많습니다. Promise 체인 버그를 볼 때 Call Stack 패널과 함께 확인합니다.
XHR / Fetch breakpoint
URL 필터에 맞는 XHR/Fetch가 발행될 때 멈춥니다. 특정 API 호출 직전 상태를 잡을 때 빠릅니다.
DOM breakpoint
- Subtree modifications: 하위 DOM이 바뀔 때
- Attribute modifications: 속성 변경 시
- Node removal: 노드 제거 시
“누가 이 요소를 지웠는지 / 바꿨는지” 추적할 때 강력합니다. 프레임워크 가상 DOM과 섞이면 호출 스택이 길어지므로, 조건부 중단과 병행합니다.
Event listener breakpoint
특정 이벤트 타입이 처리되기 직전에 멈춥니다. 전역 이벤트 위임이 많은 코드베이스에서 어느 핸들러까지 오는지 좁힐 때 도움이 됩니다.
Blackbox / Ignore list
번들러·node_modules 안의 프레임은 Blackboxing해 스택을 짧게 보이게 하면, 앱 코드만 따라가기 쉽습니다. 팀 규칙으로 공통 ignore list를 두기도 합니다.
프로덕션 디버깅 패턴
로컬에서만 재현되는 버그와 달리, 프로덕션은 소스가 압축·난독화되어 있고 로그를 마음대로 못 남깁니다. 다음 패턴을 조합하는 것이 일반적입니다.
소스맵(Source maps) 정책
- 내부 빌드: 전체 소스맵을 CI 아티팩트에만 보관하며, 브라우저에는 공개하지 않습니다.
- Sentry 등: 릴리스 업로드 시 소스맵을 올려 스택을 원본 파일/줄으로 복원합니다.
- 공개 서버에
.map노출은 보안·라이선스 이슈가 있으므로 정책으로 막는 경우가 많습니다.
에러·성능 모니터링
- Unhandled rejection, JS 예외는 전역 핸들러 + 수집기로 버전·빌드 ID·사용자 세그먼트와 함께 전송합니다.
- Web Vitals(LCP, INP, CLS)는 실제 기기·네트워크·사용자 입력에 따라 크게 달라집니다. 최신 Performance 패널도 로컬 측정값을 보여 주지만, 실제 분포는 실사용자 모니터링(RUM)으로만 알 수 있으므로 둘의 역할이 다르다는 점을 인지합니다.
재현 가능한 컨텍스트
프로덕션 이슈에는 요청 ID, 세션 ID, feature flag 상태, 앱 버전을 에러 페이로드에 넣어두면, 나중에 Network·로그와 교차 검증하기 쉽습니다.
Feature flag / 원격 설정
치명적 진단 코드를 플래그 뒤에 두면, 특정 테넌트·내부 계정만 켜서 추가 로그를 수집할 수 있습니다. 단, PII(개인정보) 수집은 법무·정책 범위 안에서만.
세션 리플레이
DOM·네트워크를 녹화해 나중에 재생하는 도구는 프라이버시 마스킹이 필수입니다. 입력 필드, 금융 정보 등은 기본적으로 마스킹 규칙을 둡니다.
debug 모드와 debugger 문
debugger문은 DevTools가 열려 있을 때만 실행을 멈추므로 일반 사용자에게는 영향이 없지만, DevTools를 연 사람(고객 지원, 내부 QA)의 화면을 멈추고 코드 의도를 드러내므로 프로덕션 빌드에서 제거(예: esbuild/Terser의 drop 옵션)하는 게 일반적입니다.- 개발자 한정으로 Bookmarklet이나 확장 프로그램으로 진단 스크립트를 주입하는 방식도 쓰이지만, XSS와 구분할 수 있도록 내부 전용으로 제한합니다.
원격 디버깅(모바일·실기기)
Android 기기를 USB로 연결하고 데스크톱 Chrome의 chrome://inspect에서 원격 디버깅을 열면, 데스크톱 DevTools로 실제 단말의 메인 스레드·네트워크를 볼 수 있습니다. “데스크톱에서는 재현 안 되고 모바일만” 같은 케이스에 적합합니다.
같이 보면 좋은 글
- Core Web Vitals 개선 체크리스트 | LCP·CLS 중심 실전 최적화
- 프론트엔드 성능 내부 구조 가이드 — CRP·리소스 우선순위·JS·스래싱·프로덕션
- Chrome 확장 프로그램 가이드 — Manifest V3·서비스 워커·메시징·콘텐츠 스크립트·프로덕션 패턴