Skip to content

Latest commit

 

History

History
199 lines (132 loc) · 10.9 KB

File metadata and controls

199 lines (132 loc) · 10.9 KB

Next.js로 만든 이력서 피드백 시스템에서, 대용량 PDF를 렌더링할 때 체감될 정도의 지연이 발생했다. 원인과 해결 과정을 정리해보았다.


1. 문제 상황

그림 1. 병목 현상

PDF 페이지 렌더링 중 약 4초간 하얀 화면이 고정되는 병목 현상이 발생했습니다. Lighthouse 지표에서는 별다른 문제를 포착하지 못했지만, 실제 사용자 체감 성능은 매우 떨어지는 상황입니다.

그림 2. 렌더링 시간 측정

실제 브라우저에서 확인해보면, PDF 렌더링이 약 4초간 멈춰 있는 구간이 존재했고

이는, Lighthouse지표로는 잡히지 않는 실사용 구간의 병목이었습니다.

2. 문제 분석

그림 3.Chrome DevTools Performance 탭

Performance 탭을 살펴보니, Main Thread가 꽉 차서 한동안 비워지지 않았고, DevTools는 해당 구간을 Long Task로 표시하고 있었다. 즉, 메인 스레드가 50ms 이상 붙잡혀 있는, 명확한 병목 구간이었다.

핵심 원인은 map()으로 모든 페이지를 동시에 순회하며, 보이지 않는 페이지까지 렌더 요청을 발생시킨 점이었고 이로 인해 메인 스레드와 PDF.js Worker의 부하가 동시에 급증했다.

렌더링과정을 정리하자면

  1. 모든 페이지가 한 번에 렌더 파이프라인에 진입함 map()으로 전체 페이지를 즉시 순회하면서,아직 화면에 보이지 않는 페이지까지 모두 DOM이 생성되고 렌더 요청이 발생된다. 예를 들어 100페이지짜리 PDF라면 실제로는 화면에 2~3페이지만 보이지만 100페이지의 DOM이 생성된다.
  2. 각 페이지가 pdf.getPage()render()를 호출이 과정에서 Worker와 다량의 통신(sendWithPromise)이 발생하며, 이 과정이 페이지 수만큼 병렬로 발생.
  3. 각 렌더 완료마다 setState()가 연속 호출페이지별 상태 갱신이 즉시 커밋되어 React의 Commit Phase가 프레임 단위로 폭주.
  4. 메인 스레드 포화 커밋, 렌더, 레이아웃 계산이 한 프레임 내에 겹쳐 스케줄링이 꼬이고 프레임 드롭이 발생
  5. 렌더 순서 불안정일부 페이지는 순서가 어긋나거나 늦게 표시되는 부작용 발생.

즉, “대량 DOM 생성 + Worker 통신(sendWithPromise) + setState()폭주”

이 세 가지가 한 프레임 안에 동시에 몰리며 병목을 유발했다.

이를 해결하기 위해서 감지와 실행 단계를 분리하고 브라우저가 소화할 수 있는 속도로 렌더링을 수행하는 파이프라인을 구축했습니다.


3. 개선 시도

Step 1. 뷰포트 기반 감지 및 프리로딩 (Intersection Observer)

모든 페이지를 그리는것이 아닌 Intersection Observer가 감지한 페이지 번호를 대기열(Queue)에 넣습니다.

이때 rootMargin: 75vh를 설정하여 현재 뷰포트뿐만 아니라 상하 75vh 범위 내의 페이지를 미리 감지(Pre-loading)합니다. 이를 통해 사용자가 스크롤을 멈추기 전 미리 렌더링을 시작하여 빈 화면이 보이는 것을 방지했습니다

Step 2. 페이지 번호 기반 순차 처리와 rAF 스케줄링

감지된 페이지들은 즉시 렌더링되지 않고 큐에 쌓입니다. 큐는 페이지 번호 오름차순으로 정렬되어, 문서의 흐름(위에서 아래)에 따라 자연스럽게 렌더링되도록 보장합니다.

또한, 동시 실행 개수(MAX_CONCURRENT)를 제한하고 requestAnimationFrame으로 작업을 프레임 단위로 분산시켜 메인 스레드의 부하를 줄였습니다.

rAF Batching : 상태 변경의 배치 처리

문제의 본질을 다시 정의하자면, 비동기 통신 완료 후 setState()가 프레임 경계를 무시하고 개별적으로 발생한다는 점이었다.

React의 setState()는 호출 시마다 Fiber 트리를 재스케줄링하므로, 여러 호출이 한 프레임 안에서 분산 실행되면 Commit Phase가 프레임마다 중첩되어 메인 스레드 부하가 누적된다.

이를 해결하기 위해 렌더링 시점을 브라우저의 프레임 사이클에 맞추어 정렬하기로 했다.

즉, 여러 setState() 호출을 즉시 실행하지 않고,

requestAnimationFrame을 이용해 다음 화면이 그려지기 직전 시점에 한 번에 처리하도록 변경한 것이다.

requestAnimationFrame은 브라우저가 새로운 프레임을 그리기 직전에 콜백을 실행하므로,

이 안에서 상태 업데이트를 모아 실행하면 한 프레임 내에서 단 한 번의 커밋만 발생한다.

이 방식은 호출 횟수를 줄이는 것이 아니라,

여러 개의 상태 변경을 한 프레임 주기에 맞춰 묶어주는 것이다.

결과적으로 각 페이지 렌더링 요청이 개별적으로 실행되던 기존 구조에 비해,

브라우저는 렌더 타이밍을 더 효율적으로 스케줄링할 수 있게 되었고,

불필요한 렌더 반복과 Commit Phase 폭증이 사라지면서 메인 스레드 부하가 크게 줄어들었다.

// rafBatch.ts
export function makeRafBatch<T>(apply: (batch: T[]) => void) {
  let scheduled = false;
  const pending: T[] = [];
  return (update: T) => {
    pending.push(update);
    if (!scheduled) {
      scheduled = true;
      **requestAnimationFrame(() => {
        apply(pending.splice(0));
        scheduled = false;
      });**
    }
  };
}
type PageUpdate = { pageNo: number; status: 'low-done'|'hi-done' };
const rafDispatch = useMemo(
  () => makeRafBatch<PageUpdate>((batch) => {
    // 여기서 한 번만 setState가 진행됨
    setRenderStates(prev => {
      const next = new Map(prev);
      for (const u of batch) next.set(u.pageNo, u.status);
      return next;
    });
  }),
  []
);
const [renderStates, setRenderStates] = useState<Map<number, PageUpdate['status']>>(new Map());
  • *rAF(requestAnimationFrame)**은 “화면이 다시 그려지기 직전”에만 실행되므로,

한 프레임 안에서 여러 setState요청을 모두 모은 뒤 단 한 번만 커밋한다.

  • 페이지별로 분리된 setState → 프레임 단위로 합쳐짐
  • React 커밋 루프(Commit Phase) 횟수가 급감
  • 브라우저 메인 스레드의 작업이 “프레임 경계” 안으로 정렬
  • 결과적으로 CPU 부하·레이아웃 재계산이 모두 감소

즉, 이를 통해서 단순히 “지연 실행”이 아니라, 렌더링 타이밍을 브라우저의 자연스러운 프레임 사이클에 맞춰줄 수 있게 되었다.

4. 성능 측정

이 개선의 목적은 사용자 PDF 확인 속도를 높이는 것이었으며, PDF 렌더링 성능은 Lighthouse에서 직접 측정할 수 없기 때문에, 실제 사용자 시나리오(스크롤, 대기, 확대 등)를 Puppeteer를 활용해 고정된 CPU/네트워크 조건에서 재현하였으며,PDF 페이지의 page.render().promise 완료 시점을 기준으로 렌더링 성능을 분석했다. 추가적으로 web-vitals 스크립트를 이용해 FCP, LCP, INP, TBT등을 실 브라우저 환경에서 자동 수집했다.

성능 측정 결과 (Puppeteer + web-vitals 기반)

그림 5. 성능 개선 측정 그래프

버전 첫페이지 평균 (ms) 최소 (ms) 최대 (ms) TBT 평균 (ms) 첫페이지 개선율 TBT 개선율
Basic (개선 전) 3,957.7 3,556.7 5,162.8 2,287 - -
IntersectionObsever 3,528.6 3,325.5 4,015.2 1,334 10.84% 개선 41.67% 개선
IntersectionObsever + rAF 3,480.1 3,294.9 3,802.4 1,271 12.07% 개선 44.43% 개선

그림 6. 성능 개선 측정 표

표에서 보듯, IntersectionObserver 버전의 첫 페이지 렌더링 시간은 1262ms로 오히려 21% 증가,

반면 rAF Batch952ms(-8.6%), TBT **135ms(-58%)**로 명확히 개선되었다.

IntersectionObserver가 상태 업데이트를 자주 발생시키면서 오히려 초기 렌더링 병목을 유발한 것 으로 판단된다.

5. 결론

IntersectionObserver만으로는 렌더링 병목을 근본적으로 해결하기 어려웠다.

화면에 보이는 페이지를 감지해 렌더링을 지연시키는 효과는 있었지만, 상태 변경이 과도하게 발생하면서 메인 스레드 커밋 병목이 새롭게 형성되었다.

이에 따라 requestAnimationFrame 기반의 렌더링 배치방식을 도입하였다.

이 방식은 여러 렌더 요청을 한 프레임 내에서 묶어 처리함으로써 불필요한 리렌더링과 상태 커밋을 줄였고, 초기 렌더링 부하와 커밋 병목이 모두 완화되었다.

그 결과, PDF 첫 페이지 렌더링 시간은 약 9% 단축, Total Blocking Time(TBT)은 약 58% 감소하며

사용자 체감 렌더링 안정성이 크게 향상되었다.

Basic (개선 전) 3,957.7 3,556.7 5,162.8 2,287
Simple (IntersectionObserver) 4,409.3 4,156.3 5,060.9 2,197
Simple (No Track) 3,528.6 3,325.5 4,015.2 1,334
Simple 75vh + rAF 3,480.1 3,294.9 3,802.4 1,271
RAF (requestAnimationFrame) 3,536.3 3,246.6 4,286.2 1,330
Lazy (지연된 getPage) 3,274.6 3,123.5 3,661.0 1,248
Opt9: RAF 페인트 제거 3,417.1 3,150.3 3,830.5 1,273
Opt9B: RAF 배칭 제거 3,467.7 3,160.4 3,870.1 1,250
Opt9C: Scheduler 제거 3,555.1 3,209.0 4,057.9 1,302

📊 버전별 성능 비교 (평균 기준)

버전 첫페이지 평균 (ms) 최소 (ms) 최대 (ms) TBT 평균 (ms) TBT 최소 TBT 최대 측정수
Basic (개선 전) 3753.7 3100.7 5578.6 1657 1498 2190 10
Simple (IntersectionObserver) 3832.2 3583.9 4794.5 1889 1807 2171 10
Simple (No Track) 2998.8 2856.7 3524.8 1151 1085 1426 10
Simple 75vh + rAF 2896.5 2754.2 3203.8 1092 1057 1189 10
RAF (requestAnimationFrame) 2941.3 2830.5 3111.1 1100 1053 1139 10
Lazy (지연된 getPage) 2799.8 2726.2 2916.5 1066 1039 1098 10
Lazy + Pure rAF (Lean) 2974.7 2894.6 3096.8 1197 1163 1248 10
Opt9: RAF 페인트 제거 2876.3 2833.4 2925.3 1090 956 1127 10
Opt9B: RAF 배칭 제거 2941.8 2820.0 3280.7 1124 1045 1322 10
Opt9C: Scheduler 제거 2902.6 2791.2 3007.1 1113 1071 1157 10