- Published on
Chrome 렌더링 느림 - Long Task 잡는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Chrome에서 “렌더링이 느리다”는 체감은 대개 메인 스레드(Main Thread)가 오래 점유될 때 발생합니다. 화면이 멈춘 듯 보이거나 스크롤이 끊기고, 클릭 반응이 늦고, 입력이 씹히는 현상은 대부분 **Long Task(50ms 이상 메인 스레드 점유 작업)**와 연결됩니다.
Long Task가 문제인 이유는 단순히 “느리다”가 아니라, 그 시간 동안 브라우저가 렌더링(스타일/레이아웃/페인트), 입력 이벤트 처리, JS 실행, GC 등을 적절히 섞어 처리하지 못하기 때문입니다. 즉, 사용자는 “멈춤”을 경험합니다.
이 글에서는 다음을 목표로 합니다.
- DevTools에서 Long Task를 정확히 찾는 절차
- Long Task의 원인을 JS/렌더링/서드파티/네트워크 후처리로 분해하는 관점
- “잡았다”에서 끝나지 않고, 코드로 쪼개고 우회하는 패턴
참고로 INP(Interaction to Next Paint) 관점까지 더 깊게 보고 싶다면 이 글도 함께 보면 좋습니다: Chrome INP 점수 급락? Long Task 추적·해결
Long Task란 무엇이며 왜 렌더링이 느려지나
Long Task의 정의
- 50ms 이상 메인 스레드를 점유한 작업을 Long Task라고 부릅니다.
- 메인 스레드는 한 번에 하나의 일만 처리하므로, Long Task가 실행되는 동안:
- 입력 이벤트(클릭/스크롤/키보드)
- 애니메이션 프레임
- 레이아웃/페인트
- 다음 JS 태스크 를 처리하지 못합니다.
“렌더링 느림”으로 보이는 대표 시나리오
- 대량 DOM 생성/업데이트 → 스타일 계산/레이아웃 비용 급증
- 무거운 루프/정렬/파싱 → JS가 메인 스레드를 독점
- 서드파티 스크립트(태그 매니저, A/B, 광고) → 예측 불가한 Long Task
- 이미지/폰트 로딩 후 재배치 → 레이아웃 스래싱(thrashing)
- GC(가비지 컬렉션) → 객체 폭증 후 일시 정지
핵심은 “CPU가 바쁘다”가 아니라, 메인 스레드가 길게 막혀서 렌더링 타이밍을 놓친다는 점입니다.
DevTools로 Long Task 찾기: 가장 재현성 높은 절차
아래 순서가 가장 빠르고 실수(오탐)를 줄입니다.
1) Performance 패널로 “증거”부터 잡기
- Chrome DevTools → Performance
- 상단 설정 권장:
- Screenshots: ON (프레임/화면 변화 추적)
- Web Vitals: ON (가능하면)
- Network: ON (로딩 이후 후처리까지 보려면)
- Record를 누르고, 문제가 발생하는 사용자 동작을 수행(스크롤, 클릭, 라우팅 등)
- Stop
이후 타임라인에서 확인할 것:
- Main 트랙에서 긴 노란색(스크립트) 덩어리
- Long task 마커(빨간 삼각형/경고 형태로 표시되는 경우)
- Bottom-Up / Call Tree에서 “누가 시간을 먹는지”
팁: 타임라인 상단의 CPU 차트에서 스파이크가 있는 구간을 드래그로 확대하면 원인 함수가 더 명확히 보입니다.
2) Main 트랙에서 “Task” 단위로 자르기
확대한 뒤 Main 트랙을 보면 “Task” 박스들이 보입니다. Long Task는 대개 아래와 같이 분해됩니다.
- Evaluate Script
- Function Call
- Recalculate Style
- Layout
- Update Layer Tree
- Paint/Composite
여기서 중요한 건 “JS가 길다”인지, “JS는 짧은데 Layout이 길다”인지 구분하는 것입니다.
- JS가 길면: 알고리즘/루프/파싱/서드파티 가능성이 큼
- Layout/Style이 길면: DOM 변경 패턴/측정(getBoundingClientRect 등)/CSS 복잡도 가능성이 큼
3) Bottom-Up으로 “가장 비싼 것”부터 제거
Performance 하단 탭에서 Bottom-Up은 “총 소요시간이 큰 순서”로 보여줍니다.
- (self time) 비중이 크면: 해당 함수 자체가 무거움
- (total time) 비중이 크면: 하위 호출 포함해서 무거움
실전에서는 총 소요시간 상위 3개만 줄여도 체감이 크게 바뀌는 경우가 많습니다.
4) Initiator/URL로 서드파티 여부 확인
Call Tree에서 특정 스크립트가 눈에 띄면, 소스 경로(도메인)를 확인하세요.
- 우리 번들(app.bundle.js)인지
- analytics/ads/tag manager인지
서드파티가 원인이면 “코드 최적화”보다 “로딩 전략 변경(지연/조건부/샌드박스)”이 더 효과적입니다.
Long Task를 코드에서 재현/관측하는 방법
DevTools에서 찾는 것만으로는 회귀(Regression) 방지가 어렵습니다. 런타임에서 Long Task를 관측하면 “배포 후에도” 잡을 수 있습니다.
PerformanceObserver로 Long Task 수집
아래 코드는 longtask 엔트리를 관측해, 100ms 이상이면 콘솔에 찍고(또는 서버로 전송) 원인 추적에 활용합니다.
// longtask-observer.js
export function observeLongTasks({ threshold = 100 } = {}) {
if (!('PerformanceObserver' in window)) return;
try {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration >= threshold) {
// entry.attribution은 브라우저/상황에 따라 제한적일 수 있음
console.warn('[LongTask]', {
name: entry.name,
startTime: entry.startTime,
duration: entry.duration,
});
}
}
});
obs.observe({ entryTypes: ['longtask'] });
return () => obs.disconnect();
} catch {
// 일부 환경에서 observe가 실패할 수 있음
}
}
운영 환경에서는 샘플링(예: 1% 사용자)과 개인정보/성능 이슈를 고려해 서버 전송을 설계하세요.
User Timing으로 “내 코드 구간”을 표시
DevTools에서 “어디가 문제인지”가 애매할 때, 코드에 마커를 심으면 탐색 시간이 크게 줄어듭니다.
performance.mark('render:start');
// 예: 대량 데이터 가공
const result = heavyTransform(data);
performance.mark('render:end');
performance.measure('render:heavyTransform', 'render:start', 'render:end');
Performance 패널에서 measure가 이벤트로 보이므로, Long Task 구간과 겹치는지 확인할 수 있습니다.
Long Task를 줄이는 대표 패턴 6가지
여기부터가 “잡는 법”의 핵심입니다. 원인별로 적용할 수 있는 패턴을 정리합니다.
1) 큰 작업을 쪼개기(Chunking) + 프레임 양보
긴 루프를 한 번에 돌리면 Long Task가 됩니다. 일정 단위로 처리하고 이벤트 루프에 제어권을 돌려주세요.
function nextTick() {
return new Promise((r) => setTimeout(r, 0));
}
export async function processInChunks(items, { chunkSize = 200 } = {}) {
const out = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
// chunk 처리
for (const item of chunk) {
out.push(expensiveWork(item));
}
// 메인 스레드 양보
await nextTick();
}
return out;
}
- 장점: 구현 쉬움, 체감 반응성 개선
- 단점: 총 처리 시간은 늘 수 있음(하지만 UX는 좋아짐)
더 정교하게는 requestAnimationFrame(프레임 맞추기), requestIdleCallback(유휴 시간 활용)을 선택합니다.
2) requestIdleCallback로 “지금 당장 필요 없는 일” 미루기
초기 렌더 직후 분석/프리페치/정리 작업은 유휴 시간에 돌리는 편이 낫습니다.
export function runWhenIdle(fn, { timeout = 1000 } = {}) {
if ('requestIdleCallback' in window) {
return requestIdleCallback(fn, { timeout });
}
return setTimeout(() => fn({ didTimeout: true, timeRemaining: () => 0 }), 0);
}
runWhenIdle(() => {
// 예: 큰 캐시 정리, 비필수 데이터 인덱싱
rebuildIndex();
});
주의: 유휴 시간이 충분히 오지 않는 페이지에서는 timeout이 중요합니다.
3) Web Worker로 메인 스레드 밖으로 보내기
CPU 바운드(파싱/압축/정렬/암호화/대량 변환)는 Worker가 정석입니다.
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url));
export function transformAsync(payload) {
return new Promise((resolve, reject) => {
worker.onmessage = (e) => resolve(e.data);
worker.onerror = reject;
worker.postMessage(payload);
});
}
// worker.js
self.onmessage = (e) => {
const payload = e.data;
const result = heavyTransform(payload);
self.postMessage(result);
};
- DOM 접근은 Worker에서 불가 → 순수 계산/변환에 적합
- 데이터 복사 비용이 크면 Transferable(ArrayBuffer 등) 활용 고려
4) 레이아웃 스래싱 제거: 읽기/쓰기 분리
아래 패턴은 자주 Long Task + Layout 폭증을 유발합니다.
- DOM 스타일 변경(write)
- 곧바로 레이아웃 측정(read)
- 다시 변경(write)
이를 “읽기-쓰기” 단계로 모으면 레이아웃 계산 횟수를 줄일 수 있습니다.
function batchUpdate(elements) {
// 1) 읽기 단계
const rects = elements.map((el) => el.getBoundingClientRect());
// 2) 쓰기 단계
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
const { width } = rects[i];
el.style.setProperty('--w', `${width}px`);
}
}
추가 팁:
- 애니메이션은
top/left보다transform을 우선 - 빈번한 DOM 업데이트는 가능하면 가상화(virtualization) 고려
5) 서드파티 스크립트는 “조건부/지연/격리”
광고/분석 스크립트가 Long Task의 상위권을 차지하는 경우가 많습니다.
defer/async로 파싱 차단 최소화- 사용자 동작 이후 로딩(예: 첫 입력/스크롤 이후)
- 특정 라우트에서만 로딩
<script>
// 첫 사용자 상호작용 이후 로딩
const load = () => {
const s = document.createElement('script');
s.src = 'https://example.com/third-party.js';
s.async = true;
document.head.appendChild(s);
window.removeEventListener('pointerdown', load);
};
window.addEventListener('pointerdown', load, { once: true });
</script>
가능하다면 iframe sandbox로 격리하는 전략도 검토하세요(특히 위젯류).
6) React/Vue 등 프레임워크: 렌더 범위 줄이기
프레임워크에서는 “JS 루프”가 아니라 “리렌더 폭발”로 Long Task가 생깁니다.
- 상태 업데이트 빈도 줄이기(디바운스/스로틀)
- 리스트는 가상화
- 메모이제이션(useMemo, memo) 남용보다 “렌더 범위”를 먼저 줄이기
- 이벤트 핸들러에서 무거운 계산 금지 → Worker/idle로 이동
폼 상호작용 지연이 핵심이라면 React 관점의 대응도 참고할 만합니다: React 19 useActionState로 폼 지연·중복 제출 해결
DevTools에서 자주 놓치는 포인트
“Long Task는 JS만이 아니다”: Layout/Style을 같이 봐야 함
Main 트랙에서 노란색만 보고 끝내면, 실제로는 Layout이 지배적인데 JS만 최적화하는 실수를 합니다.
- JS를 줄였는데도 렌더링이 느리면
- Recalculate Style / Layout 블록이 큰지 확인
GC(가비지 컬렉션) 스파이크
Performance에서 GC가 눈에 띄면:
- 대량 객체 생성(특히 배열 map/filter 체인)
- 큰 JSON 파싱 후 중간 객체 남발
- 캐시 무한 증가
대응은 “알고리즘 개선 + 객체 생성 줄이기 + 재사용”이 우선이고, 근본적으로는 Worker로 격리도 도움이 됩니다.
실전 체크리스트: Long Task를 “확실히” 잡는 흐름
- Performance 기록: 문제 동작 1회 재현
- Main 트랙 확대: 50ms+ Task 확인
- Bottom-Up 상위 3개: 가장 비싼 함수/스크립트 식별
- 원인 분류:
- JS 계산 과다 → Chunking/Worker
- Layout/Style 과다 → 읽기/쓰기 분리, DOM 업데이트 축소
- 서드파티 → 지연/조건부/격리
- User Timing 마커 추가: 회귀 방지 및 팀 공유
- PerformanceObserver로 운영 관측: 배포 후에도 탐지
결론
Chrome 렌더링이 느릴 때 가장 먼저 의심해야 할 것은 “추상적인 최적화”가 아니라 Long Task로 메인 스레드가 막히는 구간입니다. DevTools Performance에서 Long Task를 특정하고, Bottom-Up으로 비용 상위 원인을 잡은 뒤, 작업을 쪼개거나(Chunking), 유휴로 미루거나(Idle), 메인 스레드 밖으로 보내는(Worker) 방식으로 해결하면 재현성 있게 개선할 수 있습니다.
추적을 INP까지 연결해 “사용자 입력 지연” 관점으로 더 깊게 보고 싶다면 다음 글을 이어서 읽는 것을 권합니다: Chrome INP 점수 급락? Long Task 추적·해결