계단식 진행률을 부드럽게, setState 하나로는 안 됐습니다

계단식 진행률을 부드럽게, setState 하나로는 안 됐습니다

August 20, 2026

React

진행률 링을 실데이터에 연결한 날, 링이 46%에서 멈췄습니다.
40초쯤 지나서야 72%로 한 번에 뛰었고 그러고는 또 멈춰 있었어요.
버그는 아니었습니다. 서버가 주는 값이 원래 그렇게 생겼거든요.

제가 붙이던 화면은 이렇게 생겼습니다.
분석 리포트를 여러 건 만드는 게 작업 하나고, 그런 작업이 동시에 여러 개 돌아갑니다.
작업마다 카드가 하나씩 있고 카드에는 진행률 링과 그 안의 퍼센트 숫자가 있어요.
그리고 카드 목록은 진행률 높은 순으로 정렬돼서, 순위가 바뀌면 카드가 자리를 옮깁니다.
작업이 끝나면 그 카드는 완료 연출을 재생하고 목록에서 빠집니다.

응답의 모양과 3초 폴링, 그리고 뒤에 나올 규칙 하나는 제가 정한 게 아니라 받아 온 조건이었고, 제가 정할 수 있는 건 값이 띄엄띄엄 오는 사이를 어떻게 메울지와 그걸 담을 상태 구조뿐이었습니다.

서버는 40초에 한 번만 값을 올려줬습니다

서버는 진행률을 끝난 리포트 개수로 계산했습니다.
리포트 한 건을 만드는 중에는 그 안이 얼마나 진행됐는지 숫자로 알 방법이 없어서, 한 건이 완전히 끝날 때만 퍼센트가 올라갔어요.
리포트 한 건에 중앙값으로 43초, 그러니까 progressPercent는 40초에 한 번꼴로 계단처럼 올라갑니다.
이 계단 하나가 마일스톤입니다.
폴링은 3초라서, 같은 값을 열 번 넘게 받고 나서야 한 번 바뀝니다.

대신 서버는 다음 계단이 어디인지도 같이 알려줬습니다.
지금 만들고 있는 리포트가 끝나면 전체가 몇 %가 되는지는 서버가 미리 계산할 수 있으니까요. 그게 ceilingPercent입니다.

// 3초마다 도착하는 응답. 진행 중인 작업만 담겨 오고, 끝난 작업은 목록에서 빠집니다
[{ "id": 41, "progressPercent": 46, "ceilingPercent": 72 }]

// 그리고 40여 초 동안 같은 값이 반복되다가
[{ "id": 41, "progressPercent": 72, "ceilingPercent": 89 }]

이걸 그대로 그리면 46%에서 한참 멈춰 있다가 72%로 점프합니다.
폴링을 1초로 당겨도 소용이 없어요. 값 자체가 40초에 한 번 변경되니까요.

거슬러 올라가면 반대 방향의 사고도 있었습니다.
제가 붙이던 화면 쪽에는 예전에 진행률이 5% → 30% → 90% → 30%로 오락가락한다는 사용자 제보가 있었고 그 뒤로 “화면에 보이는 값은 절대 뒤로 가지 않는다”는 규칙이 생긴 상태였습니다.
그래서 제가 채워 넣을 값은 연속으로 올라가면서도 절대 내려가면 안 됐습니다.

조건을 정리하고 나서 제일 먼저 떠올린 건 이 계산을 제가 직접 하지 않는 방법이었습니다.

CSS에 맡기면 중간값을 돌려받을 수 없습니다

바 하나의 width를 채우는 거라면 React는 목표값만 넘기고 transition이 나머지를 하면 끝입니다.
중간값을 아무도 몰라도 화면은 부드럽게 움직이죠.
그런데 이 화면에서는 percent를 쓰는 곳이 세 군데였고 세 곳이 요구하는 게 서로 달랐습니다.

percent를 쓰는 곳필요한 형태CSS에 맡길 수 있나
링 SVG의 strokeDashoffset (채워진 호의 길이)CSS 속성가능합니다
링 가운데 {Math.round(percent)}% 텍스트화면에 그릴 숫자안 됩니다. 숫자를 직접 그려야 합니다
카드 목록의 순위 재정렬정렬 기준이 될 숫자안 됩니다. 지금 이 순간의 값을 JS가 알아야 합니다

세 번째가 결정적이었습니다.
카드를 percent 내림차순으로 정렬해 translateY로 자리를 잡는 연출이라, 순위가 바뀌는 순간을 알려면 매 순간의 percent를 JS가 읽을 수 있어야 했어요.
그런데 사이값 채우기를 브라우저에 맡기면 그 중간값을 돌려받을 방법이 없습니다.

링만 CSS에 맡기고 숫자와 정렬은 JS로 계산하는 절충도 생각해 봤습니다.
그런데 그러면 같은 값을 두 군데서 따로 채우게 됩니다.
브라우저가 채우는 속도와 제 계산이 완전히 같을 수는 없어서, 링은 63%까지 찼는데 가운데 숫자는 61%를 보여주는 식으로 어긋나요.
그래서 채우는 곳을 한 군데로 모았습니다.

🤔 requestAnimationFrame(rAF)이란?
브라우저가 다음 화면을 그리기 직전에 콜백을 한 번 불러주는 API입니다. 보통 초당 60번쯤 호출되고, 콜백은 현재 시각을 인자로 받습니다. setInterval과 달리 화면 갱신 주기에 맞춰 돌기 때문에 애니메이션 계산을 여기에 둡니다.

저는 이 화면 이후로 판단 기준을 하나 들고 다닙니다.
중간값을 JS가 읽어야 하는 소비처가 하나라도 있으면, 계산은 JS로 가져오는 편이 낫습니다.

다음 값이 이전 값에 달려 있었습니다

그래서 사이를 채우는 계산을 제가 직접 돌리기로 하고 rAF 루프를 짰습니다.
매 프레임 계산할 식은 이렇게 생겼습니다.

percent(다음 프레임) = min(상한, percent(지금) + 1%/s × 프레임 간격)

다음 값이 이전 값의 함수입니다.
프레임마다 지금 값을 읽고, 더하고, 다시 써야 해요.
그리고 이 읽기가 문제였습니다.

처음엔 그냥 state를 읽었습니다. 이렇게요.

const [items, setItems] = useState<ProgressItem[]>([]);
const DELTA = 1 / 60; // 프레임당 대략 1/60초 × 1%/s

useEffect(() => {
  const tick = () => {
    // items 는 이 effect 가 만들어진 시점의 배열입니다
    setItems(items.map(item => ({ ...item, percent: item.percent + DELTA })));
    requestAnimationFrame(tick);
  };
  requestAnimationFrame(tick);
}, []);

rAF 콜백은 렌더 밖에서 돕니다.
[] 의존성으로 한 번만 등록했으니 이 콜백이 붙잡고 있는 items첫 렌더 당시의 빈 배열에서 영원히 멈춰 있어요.
그래서 진행률이 안 오르는 정도가 아니라, 폴링이 채워 넣은 목록을 매 프레임 빈 배열로 덮어써서 링이 화면에서 사라졌습니다.

🤔 stale closure란?
콜백이 만들어질 때 주변 변수를 함께 붙잡아 두는데(클로저), 이후 리렌더로 그 변수가 새 값을 갖게 되어도 예전 콜백은 옛날 값을 계속 들고 있습니다. 이 상태를 stale closure라고 부릅니다.

그래서 함수형 업데이트로 바꿨습니다.
setItems(prev => ...)를 쓰면 React가 최신 값을 인자로 넘겨주니, rAF 루프만 놓고 보면 이걸로 충분했습니다.

안 되는 건 폴링 쪽이었습니다.
응답이 도착하면 지금 화면의 목록과 응답의 목록을 비교해서 응답에서 사라진 작업에 완료 모션을 태워야 했는데, 이건 setState를 부르기 전에 현재 값을 알아야 분기가 되는 읽기 전용 경로입니다.
업데이터 안에서 비교하고 타이머까지 예약하는 방법이 떠오르긴 했지만 쓰면 안 되는 형태예요. React는 업데이터를 여러 번 실행할 수 있고 StrictMode에서는 의도적으로 두 번 부르니, 완료 모션이 두 번 걸립니다.

세 가지 시도가 이렇게 갈렸습니다.

시도어디까지 되나왜 부족했나
콜백에서 state 직접 읽기목록이 화면에서 사라짐stale closure로 첫 렌더의 빈 배열에 고정
setItems(prev => ...)rAF 누적 계산은 해결쓰기 없이 읽기만 하는 분기를 못 만듦
업데이터 안에서 부수효과코드는 짧아짐업데이터 재실행 시 모션·타이머가 중복

읽기가 필요한 시점이 쓰기 시점과 다르다는 게 핵심이었습니다.
그러면 값을 언제든 동기적으로 읽을 수 있는 자리가 따로 있어야 합니다.

ref를 원본으로, state를 사본으로 나눴습니다

ref로 상태를 들고 있는 코드를 보면 저는 보통 리팩터링 대상이라고 생각해 왔습니다.
그런데 여기서는 같은 값을 두 곳에 두는 게 답이었어요.
ref가 진짜 값이고, state는 화면을 다시 그리게 하려고 존재하는 사본입니다.
쓰는 쪽이 폴링과 rAF 루프 둘, 읽는 쪽이 링·숫자·순위 셋인데 그 사이에 commit 하나만 둡니다.

flowchart LR
  SRV["서버"] -->|"progressPercent · ceilingPercent"| P["폴링 응답<br/>3초마다"]
  P --> C
  R["rAF 루프<br/>이전 값 + 초당 1%"] --> C
  C["commit()<br/>단일 쓰기 통로"] --> REF["itemsRef · 원본<br/>콜백이 동기적으로 읽음"]
  C --> ST["items state<br/>렌더용 사본"]
  ST --> D["링 SVG"]
  ST --> T["가운데 % 텍스트"]
  ST --> S["순위 재정렬"]
const [items, setItems] = useState<ProgressItem[]>([]);
// 매 프레임 읽고 쓰므로 ref 가 원본이고 state 는 렌더용 사본입니다
const itemsRef = useRef<ProgressItem[]>([]);

const commit = useCallback((next: ProgressItem[]) => {
  itemsRef.current = next; // 어느 콜백에서든 동기적으로 최신값
  setItems(next);          // React 에게는 스냅샷만 통보
}, []);

commit이 이 구조의 전부인데, ref와 state를 항상 같이 쓰게 만드는 단일 통로입니다.
ref만 갱신하면 화면이 안 바뀌고, state만 갱신하면 다음 프레임 계산이 과거 값을 기준으로 돌아갑니다.
둘 다 조용히 어긋나는 종류의 버그라, 쓰는 자리를 하나로 막아두는 편이 마음이 편했습니다.

앞에서 걸어둔 “표시값은 뒤로 가지 않는다”는 규칙도 여기서 지킵니다.
폴링 응답이 와도 서버 값을 그대로 쓰지 않고 Math.max(지금 표시값, 서버값)으로 앞으로만 당깁니다.
서버가 재기동 같은 이유로 낮은 값을 보내도 링은 안 내려갑니다.

핵심만 남긴 훅 (약 60줄, import와 fetch 함수는 생략)
type ProgressItem = { id: number; percent: number; ceiling: number };

const RATE = 1; // 초당 1%
const MAX_FRAME_MS = 100; // 탭 복귀 시 한 프레임이 튀지 않도록

export function useSmoothProgress(pollMs = 3000) {
  const [items, setItems] = useState<ProgressItem[]>([]);
  const itemsRef = useRef<ProgressItem[]>([]);
  const lastFrameRef = useRef(0);

  const commit = useCallback((next: ProgressItem[]) => {
    itemsRef.current = next;
    setItems(next);
  }, []);

  // 폴링. 쓰기 전에 현재 값을 한 번 읽어 두고 시작합니다
  useEffect(() => {
    const timer = setInterval(async () => {
      const rows = await fetchProgress();
      const prev = itemsRef.current;

      const liveIds = new Set(rows.map(row => row.id));
      prev
        .filter(item => !liveIds.has(item.id)) // 읽기만 하는 분기
        .forEach(item => playCompleteMotion(item.id));

      commit(
        rows.map(row => {
          const before = prev.find(item => item.id === row.id);
          return {
            id: row.id,
            percent: Math.max(before?.percent ?? 0, row.progressPercent), // 뒤로 가지 않습니다
            ceiling: row.ceilingPercent,
          };
        })
      );
    }, pollMs);
    return () => clearInterval(timer);
  }, [commit, pollMs]);

  // rAF 크롤. 매 프레임 현재값을 읽어 델타를 더합니다
  useEffect(() => {
    let raf = 0;
    const tick = (now: number) => {
      const dt = Math.min(now - (lastFrameRef.current || now), MAX_FRAME_MS) / 1000;
      lastFrameRef.current = now;

      let advanced = false;
      const next = itemsRef.current.map(item => {
        const cap = item.ceiling - 1;
        if (item.percent >= cap) return item;
        advanced = true;
        return { ...item, percent: Math.min(cap, item.percent + RATE * dt) };
      });
      if (advanced) commit(next);

      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => {
      cancelAnimationFrame(raf);
      lastFrameRef.current = 0;
    };
  }, [commit]);

  return items;
}

붙이고 나서 확인하거나 조정한 것들입니다.

확인한 것어떻게 했나결과
매 프레임 setState가 괜찮은가새 배열은 참조가 매번 달라져 렌더가 계속 돕니다. 전진할 항목이 없으면 commit을 아예 안 부르게 했습니다모든 항목이 상한에 닿아 다음 마일스톤을 기다리는 구간에서는 루프가 돌아도 렌더가 멈춥니다
백그라운드 탭rAF가 알아서 멈춰줍니다. 돌아왔을 때 밀린 시간이 한 번에 더해지지 않도록 프레임 간격에 100ms 상한을 뒀습니다벌어진 차이는 다음 폴링의 서버 마일스톤이 따라잡습니다

그래서 링은 46%에 멈춰 있는 대신 차오르고, 가운데 숫자와 카드 순위가 그 값을 그대로 따라갑니다.
세 곳이 같은 한 값을 보고 있어서 서로 어긋나지 않습니다.

이 구조를 쓴 건 제가 이 패턴을 좋아해서가 아니고, 스펙이 하나씩 요구할 때마다 하나씩 늘려 온 결과였어요.
“서버 값 그대로, 점프해도 됨”이었다면 rAF 루프도 ref 이중화도 필요 없었습니다.
그래서 저는 이런 화면에서 패턴부터 고르지 않고 중간값을 JS가 알아야 하는지부터 확인합니다.
“ref는 최후의 수단”이라는 말을 오래 믿었는데, 매 프레임 누적되는 값에서는 그게 오히려 정공법이라고 지금은 생각합니다.

다만 링이 멈추는 구간을 없애지는 못했습니다.
앞에서 본 46%에서 72%까지가 26%p이고 그 구간이 40초니까, 딱 맞게 채우려면 초당 0.65%여야 했거든요.
초당 1%로는 상한인 71%까지 25%p를 25초 만에 채우고, 남은 15초는 다시 멈춰 있습니다. 40초짜리 정지가 15초로 줄어든 것이지 사라진 게 아니에요.
구간 폭으로 속도를 계산하는 쪽이 맞다고 지금은 보는데, 아직 바꾸진 못했습니다.

  • 진행률 애니메이션
  • requestAnimationFrame
  • React ref
  • stale closure
  • 함수형 업데이트
  • 폴링
  • 보간
  • useRef
  • 원형 프로그레스바
  • 진행률 링
  • 부드러운 진행률
On this page