스피너 좀 돌리게 프레임 하나만 빌려줘

houndhollis·5일 전
4
post-thumbnail

동기 함수와 로딩 스피너의 기묘한 관계

CSV를 만들어야 할 일이 있어 Papa.unparse를 사용했는데, 문제 하나를 마주쳤다

setIsLoading(true)
try {
  const csv = Papa.unparse({ fields: csvTitle, data: csvData });
  const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
  // 다운로드 로직
} catch {
  // 에러 로직
} finally {
  setIsLoading(false)
}

<button loading={isLoading}>생성해줫!</button>

여기서 문제점은 단순했다

  • CSV 생성이 동기적이라 무겁다.
  • setIsLoading(true)로 로딩 상태를 바꿔도 UI에 스피너가 안 보인다.

왜 이런 문제가 생길까?

  1. Js 실행 (이벤트 핸들러, 함수 호출 등)
  2. 모든 Js가 끝날 때까지 기다림
  3. 그 다음 렌더 트리를 업데이트하고 레이아웃/페인트 실행

여기서 문제가 되는 건 "Js가 끝날 때까지"라는 조건이다.

API 요청과 비교해보면 차이가 뚜렷하다.

  • fetch() 같은 I/O 비동기 작업await 하는 순간 JS 실행이 멈추고 브라우저에 제어권이 넘어간다.
    → 이 타이밍에 React가 그린 로딩 UI가 페인트될 수 있음

  • 반면 Papa.unparse동기 CPU 작업이다.
    setIsLoading(true) 직후에 곧바로 CSV 생성 코드가 실행된다.

    Js가 계속 돌아가니 브라우저가 페인트할 틈이 없음
    → 로딩 UI가 안 보이거나 "가짜"처럼 느껴진다.


해결책: 한 프레임 양보하기

이럴 때는 브라우저에게 잠깐 숨 쉴 시간을 주면 된다.
바로 requestAnimationFrame(rAF)을 활용하는 것이다.

export const nextFrame = () =>
  new Promise<number>((resolve) => requestAnimationFrame(resolve));

// 사용 예시
setIsLoading(true);
await nextFrame();
// 무거운 작업 동기적

이렇게 하면 흐름이 바뀐다:

  1. setIsLoading(true)
  2. React가 "스피너"를 렌더 준비
  3. await nextFrame()JS 실행을 한 프레임 동안 멈춤
  4. 브라우저가 그 사이에 UI를 페인트
  5. 그다음에 무거운 CSV 생성 시작

requestAnimationFrame일까?

1. Promise.resolve()는 왜 안 될까?

await Promise.resolve();
  • 마이크로태스크 큐만 비우고 끝.
  • 페인트가 보장되지 않음.
  • 스피너가 여전히 안 보일 수 있음.

2. setTimeout 0은 왜 불완전할까?

await new Promise(r => setTimeout(r, 0));
  • 대부분 한 틱 양보는 되지만, 페인트 직전 보장은 없음.
  • 브라우저가 원하는 타이밍에 페인트할 수도, 안 할 수도 있음.

3. requestAnimationFrame은 다릅니다.

await new Promise(requestAnimationFrame);
  • rAF는 다음 페인트 직전에 콜백을 실행.
  • 따라서 await하면 확실하게 한 프레임을 양보할 수 있음.
  • 스피너가 보장되게 등장!

작동 원리 (조금 더 깊게)

new Promise(requestAnimationFrame) 은 다음처럼 동작한다:

  1. new Promise는 executor 함수를 즉시 실행.
    → 이때 rAF(resolve) 호출.
  2. rAF는 다음 페인트 직전에 resolve를 실행.
  3. 프로미스가 그제서야 resolve되고, await가 풀림.

await new Promise(requestAnimationFrame)다음 프레임까지 대기하는 효과를 가지며 쓰는 순간,
브라우저에게 다음과 같이 말하는 것과 같다

"잠깐, 나 지금 스피너 세팅했으니까 이번 프레임에 꼭 그려줘.
그다음에 내가 CSV 뽑을게."

Js는 싱글스레드라 "다음 프레임" 같은 구체적인 양보 지점 없이는
브라우저가 그림을 그릴 수 없기 때문에 rAF가 사실상 페인트를 강제로 보장하는 수단이 된다.


주의할 점

  • Node/SSR 환경에는 requestAnimationFrame이 없다 (브라우저 전용).
  • 비가시 탭에선 rAF가 스로틀링되어 프레임레이트가 떨어질 수 있다.
  • CSV가 진짜 거대하다면, 아무리 한 프레임 양보해도 결국 메인 스레드를 오래 붙잡는다.
    → 이 경우엔 Web Worker로 분리하는 게 베스트!

요약

  • API 요청은 await fetch() 시 자연스럽게 브라우저가 페인트 기회를 가짐.
  • 동기 CPU 작업(Papa.unparse)은 그런 기회가 없음.
  • 따라서 명시적으로 한 프레임 양보해야 스피너가 보인다.

👉 패턴으로 정리하면:

setIsLoading(true);
await nextFrame(); // 한 프레임 양보
// 동기(=CPU 바운드) 작업 시작

이렇게 하면 한 프레임을 가져오고 UI반영 성공!😊

마무리

다만 이 방법은 어디까지나 짧은 동기 작업에 적합하기 때문에 CSV 데이터가 수십 MB 이상처럼 정말 무거워지면, 한 프레임 양보만으로는 부족하다,
결국 몇 ms 단위로 작업을 청크 단위로 나누거나,
아예 Web Worker로 분리해서 메인 스레드와 UI를 지켜주는 게 더 안전할 듯 하다.

profile
한 줄 소개

1개의 댓글

comment-user-thumbnail
4일 전

저는 requestAnimationFrame을 사용할 정도로 무거운 작업을 진행해본 적이 없어서, Promise로 항상 해결이 되었던 것 같습니다.
이번 기회에 동기 작업과 페인트 타이밍 문제에 대해 잘 알게 되었네요. 글을 명확하게 작성해주셔서 읽기 편했고, 나중에 비슷한 상황이 오면 허둥지둥거리지 않고 requestAnimationFrame에 대해 찾아볼 수 있을 것 같습니다. 좋은 글 감사합니다!!!

답글 달기