Published on

Chrome 렌더링 느림 - Long Task 잡는 법

Authors

서론

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 태스크 를 처리하지 못합니다.

“렌더링 느림”으로 보이는 대표 시나리오

  1. 대량 DOM 생성/업데이트 → 스타일 계산/레이아웃 비용 급증
  2. 무거운 루프/정렬/파싱 → JS가 메인 스레드를 독점
  3. 서드파티 스크립트(태그 매니저, A/B, 광고) → 예측 불가한 Long Task
  4. 이미지/폰트 로딩 후 재배치 → 레이아웃 스래싱(thrashing)
  5. GC(가비지 컬렉션) → 객체 폭증 후 일시 정지

핵심은 “CPU가 바쁘다”가 아니라, 메인 스레드가 길게 막혀서 렌더링 타이밍을 놓친다는 점입니다.

DevTools로 Long Task 찾기: 가장 재현성 높은 절차

아래 순서가 가장 빠르고 실수(오탐)를 줄입니다.

1) Performance 패널로 “증거”부터 잡기

  1. Chrome DevTools → Performance
  2. 상단 설정 권장:
    • Screenshots: ON (프레임/화면 변화 추적)
    • Web Vitals: ON (가능하면)
    • Network: ON (로딩 이후 후처리까지 보려면)
  3. Record를 누르고, 문제가 발생하는 사용자 동작을 수행(스크롤, 클릭, 라우팅 등)
  4. 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를 “확실히” 잡는 흐름

  1. Performance 기록: 문제 동작 1회 재현
  2. Main 트랙 확대: 50ms+ Task 확인
  3. Bottom-Up 상위 3개: 가장 비싼 함수/스크립트 식별
  4. 원인 분류:
    • JS 계산 과다 → Chunking/Worker
    • Layout/Style 과다 → 읽기/쓰기 분리, DOM 업데이트 축소
    • 서드파티 → 지연/조건부/격리
  5. User Timing 마커 추가: 회귀 방지 및 팀 공유
  6. PerformanceObserver로 운영 관측: 배포 후에도 탐지

결론

Chrome 렌더링이 느릴 때 가장 먼저 의심해야 할 것은 “추상적인 최적화”가 아니라 Long Task로 메인 스레드가 막히는 구간입니다. DevTools Performance에서 Long Task를 특정하고, Bottom-Up으로 비용 상위 원인을 잡은 뒤, 작업을 쪼개거나(Chunking), 유휴로 미루거나(Idle), 메인 스레드 밖으로 보내는(Worker) 방식으로 해결하면 재현성 있게 개선할 수 있습니다.

추적을 INP까지 연결해 “사용자 입력 지연” 관점으로 더 깊게 보고 싶다면 다음 글을 이어서 읽는 것을 권합니다: Chrome INP 점수 급락? Long Task 추적·해결