JavaScript 실행 시간을 줄이려면 비용이 큰 함수를 찾고 불필요한 처리를 제거하며 기능을 필요할 때 로드합니다. 필요한 작업이 여전히 메인 스레드를 오래 점유한다면 여러 작업으로 나누거나 적합한 계산을 웹 워커로 옮깁니다. 다운로드 크기, 전체 CPU 시간, 가장 긴 연속 작업은 서로 다른 문제이므로 해결책을 고르기 전에 각각 측정해야 합니다.
번들을 바꾸기 전에 느린 상호작용을 기록합니다
프로덕션 빌드에서 탐색 메뉴 열기, 상품 필터링, 보고서 펼치기처럼 재현할 수 있는 동작을 선택합니다. Chrome DevTools의 Performance 패널에서 초기 로드와 상호작용을 따로 기록합니다. 기기, CPU 제한, 네트워크, 동의 상태를 일정하게 유지합니다. 확장 프로그램과 개발 도구 계측이 결과를 바꿀 수 있으므로 깨끗한 프로필을 사용하고 환경을 기록합니다.
인터페이스가 멈춘 구간을 선택합니다. Main 트랙을 살핀 뒤 Bottom-up에서 자체 시간이 큰 함수를 찾고 Call tree에서 호출 관계를 확인합니다. 자체 시간은 함수 자체의 시간이고 전체 시간에는 하위 호출이 포함됩니다. 작은 래퍼의 전체 시간이 크다고 그 함수 자체를 다시 작성해야 하는 것은 아닙니다. 소스 맵을 사용하면 배포된 코드와 원래 모듈을 연결할 수 있습니다.
web.dev는 50밀리초를 넘는 작업을 긴 작업으로 설명합니다. 이는 차단 작업을 구분하는 경계이지 모든 작업에 허용되는 목표 시간이 아닙니다. 짧은 작업이 여러 개 이어져도 렌더링이나 서드 파티 스크립트와 경쟁하면 사용성이 나빠질 수 있습니다. 경고 표시 개수보다 실제 상호작용을 살펴야 합니다.
Lighthouse의 JavaScript 진단과 트리맵은 큰 의존성과 비용이 높은 리소스를 찾는 데 도움이 됩니다. 그러나 가장 큰 파일이 관찰된 멈춤의 원인이라는 증거는 아닙니다. 크기와 실행 기록을 비교하고, 입력 대기부터 처리와 다음 페인트까지 느리다면 INP 개선 가이드를 확인합니다.
제거와 지연, 스케줄링을 구분합니다
관찰 결과에 맞는 조치를 선택합니다. 압축은 전송 바이트를 줄이지만 브라우저가 처리하는 프로그램은 압축 해제된 상태입니다. 축소화는 전송 최적화이지 실행 작업을 줄이는 대안이 아닙니다.
다음 조치는 해결하는 문제가 다르므로 따로 평가해야 합니다.
| 관찰 결과 | 먼저 검토할 변경 | 보장하지 않는 결과 |
|---|---|---|
| 초기 경로에서 사용하지 않는 기능 코드 | 제거하거나 별도 청크로 분리 | 해당 기능의 첫 사용 속도 |
| 반복 초기화나 중복 리스너 | 반복 작업 제거 | 전송량 감소 |
| DOM과 무관한 긴 계산 | 워커 또는 알고리즘 개선 | 전체 CPU 사용량 감소 |
| 필요한 순서가 시각적 응답을 차단 | 제한된 단계 사이에 실행권 양보 | 전체 처리의 조기 완료 |
| JavaScript가 큰 스타일 또는 레이아웃 작업 유발 | DOM 변경과 레이아웃 의존성 감소 | 스크립트 속성만으로 해결 |
수정 전 가설을 적습니다. 어떤 함수가 덜 실행되어야 하는지, 어떤 청크가 초기 워터폴에서 사라져야 하는지, 어느 지점에서 렌더링 기회가 생겨야 하는지 명확히 합니다. 그러면 다음 기록이 단순한 점수 비교가 아니라 변경 결과의 설명이 됩니다.
현재 화면에 필요한 코드만 보냅니다
번들러 분석기나 Lighthouse 트리맵으로 프로덕션 출력을 검사합니다. 패키지 버전 중복, 쓰지 않는 언어 데이터, 작은 보조 함수 때문에 가져온 큰 라이브러리, 모든 페이지에 포함된 기능을 찾습니다. 교체하기 전에 의존성이 들어온 이유부터 확인합니다. 작은 대안이 유지보수를 늘리거나 중요한 동작을 바꿀 수도 있습니다.
트리 셰이킹은 빌드가 지원할 때 정적으로 판별 가능한 미사용 내보내기를 제거합니다. ES 모듈과 정확한 부수 효과 선언이 중요합니다. 크기를 줄이려고 모든 패키지를 부수 효과가 없다고 선언하면 안 됩니다. 폴리필 설치, 컴포넌트 등록, 필수 CSS 가져오기는 내보내기를 사용하지 않아도 실행되어야 할 수 있습니다. import 문보다 실제 프로덕션 출력을 확인합니다.
Coverage도 단서지만 기록 중 사용되지 않은 코드가 로그인, 다른 경로, 대화상자에서 필요할 수 있습니다. 삭제 전에 해당 흐름과 오류 처리, 접근성을 확인합니다. 드물게 쓰인다는 이유만으로 불필요한 기능은 아닙니다. 필요한 코드를 불안정하게 변형하기보다 실제로 폐기한 기능을 제거하는 편이 낫습니다.
동적 import로 선택 기능을 로드합니다
빌드 시스템이 코드 분할을 지원하면 동적 import가 로딩 경계를 만듭니다. 내보내기 미리보기처럼 별도로 요청할 수 있는 기능에 적용합니다. 작은 함수마다 분리하면 요청과 조정 비용이 늘고, 전체 경로 단위 분리는 드물게 쓰는 내부 기능에는 너무 클 수 있습니다.
다음 예제는 HTTP로 제공하는 세 파일을 사용하며 버튼을 누른 뒤 모듈을 로드합니다. 표시 문구와 진행 및 오류 메시지는 애플리케이션에서 현지화해야 합니다.
<!-- index.html -->
<button id="preview" type="button">Preview export</button>
<p id="status" role="status"></p>
<pre id="output"></pre>
<script type="module" src="/main.js"></script>진입 모듈은 import에 실패해도 버튼을 다시 활성화합니다.
// main.js
const button = document.querySelector('#preview');
const status = document.querySelector('#status');
const output = document.querySelector('#output');
button.addEventListener('click', async () => {
button.disabled = true;
status.textContent = 'Loading preview';
try {
const { renderPreview } = await import('./preview.js');
renderPreview(output);
status.textContent = 'Preview ready';
} catch {
status.textContent = 'Preview unavailable. Try again.';
} finally {
button.disabled = false;
}
});별도로 로드되는 모듈이 선택 기능을 제공합니다.
// preview.js
export function renderPreview(element) {
element.textContent = JSON.stringify({ format: 'csv', ready: true }, null, 2);
}이는 작동하는 로딩 예제이며 무거운 작업이나 측정된 최적화 사례가 아닙니다. 실제 모듈은 import 시 비싼 초기화를 실행할 수 있습니다. 차가운 캐시에서 첫 사용과 이후 사용을 각각 시험합니다. 첫 사용이 느려지면 기능을 개선하거나 사용자 의도가 분명할 때 신중히 미리 가져옵니다. 시작 문제를 첫 클릭으로 옮기기만 해서는 안 됩니다.
브라우저가 실제로 응답할 기회를 줍니다
작은 함수를 동기적으로 여러 번 호출해도 하나의 연속 작업입니다. 이미 해결된 Promise를 기다리는 것 역시 렌더링 기회를 확실히 만들지 않습니다. Promise의 후속 실행은 마이크로태스크이기 때문입니다. 안전하게 중단할 수 있는 작업에는 나중 태스크에서 이어지는 방법을 사용합니다.
이 함수는 가능한 경우 scheduler.yield()를 사용하고 아니면 타이머를 사용하며, 반복문은 각 묶음의 크기를 제한합니다.
function yieldToBrowser() {
if (globalThis.scheduler?.yield) return globalThis.scheduler.yield();
return new Promise(resolve => setTimeout(resolve, 0));
}
export async function normalizeRows(rows, batchSize = 100) {
if (!Number.isInteger(batchSize) || batchSize < 1) {
throw new RangeError('batchSize must be a positive integer');
}
const normalized = [];
for (let start = 0; start < rows.length; start += batchSize) {
const end = Math.min(start + batchSize, rows.length);
for (let index = start; index < end; index++) {
normalized.push(String(rows[index]).trim());
}
if (end < rows.length) await yieldToBrowser();
}
return normalized;
}묶음 크기는 예시 설정일 뿐 보편적인 안전 기준이 아닙니다. 한 항목만 비싸도 긴 작업이 생길 수 있습니다. 실제 작업과 기기로 조정하고 처리 중 입력이 바뀌면 취소할 방법도 고려합니다. 타이머 대안은 네이티브 API의 우선순위 동작을 재현하지 않으므로 두 경로를 시험합니다. 공식 긴 작업 안내에서 스케줄링 모델을 확인할 수 있습니다.
실행권 양보는 전체 실행 시간을 줄이지 않고도 반응성을 개선할 수 있으며 오히려 부가 비용이 생길 수도 있습니다. 원자적으로 끝나야 하는 처리는 유지합니다. 저장이 완료되기 전에 성공을 표시하지 말고 처리 중처럼 실제 상태를 알려야 합니다.
독립 계산을 웹 워커로 옮깁니다
워커는 창의 메인 스레드 밖에서 JavaScript를 실행하지만 해당 DOM을 직접 조작하지 못합니다. 독립적인 파싱, 데이터 변환, 계산에 적합합니다. 요소 삽입, 레이아웃 측정, 페인트가 대부분인 작업에는 자동으로 도움이 되지 않습니다. 작은 작업은 워커 시작과 메시지 비용 때문에 더 비싸질 수 있습니다.
이 모듈 워커는 처리 경계를 보여 줍니다. 예시 데이터는 성능 향상을 주장할 수 없을 정도로 작습니다.
// totals.worker.js
self.addEventListener('message', event => {
const total = event.data.reduce((sum, value) => sum + value, 0);
self.postMessage(total);
});호출 측은 메인 스레드에서 결과를 받고 워커를 종료합니다.
// totals.js, loaded as a module
const worker = new Worker(new URL('./totals.worker.js', import.meta.url), {
type: 'module'
});
worker.addEventListener('message', event => {
console.log('Total:', event.data);
worker.terminate();
});
worker.addEventListener('error', () => worker.terminate());
worker.postMessage([2, 4, 6]);실제 기능에서는 입력 검증, 오류 보고, 취소를 정의합니다. 반복 작업이 있다면 워커를 재사용할 수 있습니다. 큰 메시지는 복사 비용이 들 수 있고 전송 가능한 버퍼는 일부 복사를 피하지만 소유권을 이전하므로 송신 측의 이후 사용 방식이 바뀝니다. 전체 응답 시간, 메인 스레드 가용성, 메모리 사용을 함께 측정합니다.
초기 작업을 미루되 나중의 병목을 숨기지 않습니다
지연된 스크립트도 메인 스레드에서 실행됩니다. async는 로딩과 실행 시점을 바꾸며 defer는 외부 클래식 스크립트의 문서 파싱 후 실행 순서도 유지합니다. 둘 다 비싼 함수를 나누지는 않습니다. 렌더링 차단 리소스 가이드로 로딩 의존성을 확인한 뒤 실행 기록을 다시 살핍니다.
유휴 시간 스케줄링은 선택 작업에만 사용합니다. requestIdleCallback()은 지원이 제한적이며 호출이 미뤄질 수 있습니다. 지원 감지, 대체 경로, 필요하다면 시간 제한을 준비합니다. 필수 탐색, 검증, 응답은 유휴 시간에 의존하면 안 됩니다. 시간 제한이 큰 콜백을 가볍게 만들지도 않으므로 처리량은 계속 제한해야 합니다.
서버 HTML은 프레임워크가 상호작용을 연결하기 전에 보일 수 있습니다. 하이드레이션이 CPU를 많이 쓰면 조기 표시가 빠른 반응을 의미하지 않습니다. 클라이언트 컴포넌트를 줄이고 중복 초기화를 피하며, 동작을 유지할 수 있을 때만 지원되는 선택적 또는 지연 하이드레이션을 사용합니다. 시작 중 클릭, 키보드, 포커스, 각 영역의 첫 사용을 확인합니다.
서드 파티 코드도 같은 실행 시간을 경쟁합니다. 복잡한 스케줄링 전에 외부 스크립트 비용을 검토하고 중복 태그를 없앱니다. 목적과 활성화 조건을 기록해 이후 추가가 병목을 되살리지 않도록 합니다.
반복 작업과 불필요한 렌더링을 추적합니다
비싼 알고리즘만 문제는 아닙니다. 가벼운 연산도 관계없는 상태 변경, 스크롤 이벤트, 재마운트마다 반복될 수 있습니다. 호출 횟수와 사용자 행동을 연결합니다. 잘못된 구독이나 중복 리스너 수정이 콜백 재작성보다 오래가는 해결책일 수 있습니다.
대화형 목록은 데이터 변환과 표시 갱신을 분리합니다. 입력이 실제로 바뀔 때만 재계산하고 작은 영역 변경 때문에 전체 목록을 만들지 않습니다. 메모이제이션은 절약하는 작업이 메모리와 무효화 복잡성보다 클 때 유효합니다. 의존성을 잘못 지정하면 오래된 결과가 표시되므로 정확성 검사도 필요합니다.
스타일을 쓰고 기하 정보를 읽는 일을 반복하는지 확인합니다. 읽기가 레이아웃 계산을 강제할 수 있습니다. 가능한 경우 필요한 측정을 먼저 모은 뒤 쓰기를 적용합니다. 빠른 JavaScript 함수라도 기록의 다른 위치에서 비싼 스타일 및 레이아웃 작업을 반복 유발할 수 있습니다.
더 이상 필요 없는 타이머, 관찰자, 이벤트 구독을 제거합니다. 로드 직후 기록은 탐색 중 누적되는 작업을 놓칩니다. 새로고침 없이 같은 경로를 반복하고 처리량이 커지는지 확인합니다. 이는 진단 시나리오이며 특정 프레임워크에 누수가 있다는 주장이 아닙니다.
같은 사용자 경로에서 결과를 검증합니다
동일 조건으로 다시 기록하고 기능의 차가운 로드, 재방문, 느린 기기도 시험합니다. 번들 바이트뿐 아니라 긴 작업과 보이는 응답을 비교합니다. 시작이 빨라져도 첫 상호작용은 나빠질 수 있으며 짧아진 콜백이 여전히 비싼 렌더링을 유발할 수 있습니다.
성능 체크리스트에 변경, 근거, 되돌리기 방법을 기록합니다. 후속 작업은 페이지 속도 최적화 모음에서 정리하고 새 기록에서 다음으로 비싼 함수를 고릅니다. 원래 경로가 개선되고 로딩, 오류, 접근성 동작이 유지될 때 변경을 채택합니다.