rAF를 이용하여 성능과 UX를 잡아보자!!!

SeongHyeon Bae·2023년 11월 22일
5
post-thumbnail

약 1년전 쯤 위 영상 처럼 순서를 기억하는 게임 프로젝트를 구현한 적이 있습니다. 혹시 위 영상에서 UX쪽으로 불편한 점이 느껴지시나요? 개발 당시 남은 시간을 알려주는 타이머 부분이 뚝뚝 끊기는 것이 불량품처럼 마음에 들지 않았습니다. 그 당시에 임시방편으로 해결한 방식과 rAF(requestAnimationFrame) 기법을 이용하여 학습했던 과정을 작성해보려고 합니다.

왜 불량품일까?

문제를 개선하기 전에는 원인을 분석해야 합니다. 이를 위해 작성했던 코드를 보며 원인을 분석해 봅시다. 문제인 부분만 직관적으로 이해하기 위해 불필요한 로직은 생략했습니다!

// MemoryGameTimer.tsx (타이머 UI 컴포넌트)
import { Progress } from '@chakra-ui/react';

function MemoryGameTimer() {
  const { time, startTimer } = useTimer();

  useEffect(() => {
    startTimer();
  }, [startTimer]);

  return <Progress hasStripe value={time / 600} />;
}

// useTimer.ts (Timer 로직을 담아 둔 hook)
function useTimer() {
  const INITIAL_TIME = 60000; // 60s
  const INTERVAL_MILLISECOND = 1000; // 1s
  const [time, setTime] = useState(INITIAL_TIME);
  const intervalRef: { current: NodeJS.Timeout | null } = useRef(null);

  const startTimer = useCallback(() => {
    if (intervalRef.current !== null) return;

    intervalRef.current = setInterval(
      () => setTime(time => time - INTERVAL_MILLISECOND),
      INTERVAL_MILLISECOND,
    );
  }, []);

  return { time, startTimer };
}

간단하게 코드 흐름을 설명하면 다음과 같습니다.

  1. MemoryGameTimer 컴포넌트는 타이머의 UI 담당을 하고 있습니다. 이 프로젝트에서는 스타일링을 위해 chakraUI 라이브러리를 사용하고 있고 제공되는 타이머UI 컴포넌트를 사용하였습니다. 남은시간인 time 상태에 따라 Progress Bar의 size가 변화하게 됩니다.
  2. useTimer hook은 Timer 로직을 담고 있습니다. 게임의 제한시간은 60s로 설정하였고, 남은시간을 time 상태에 담아 MemoryGameTimer 컴포넌트에 제공합니다.
  3. time 컴포넌트는 설정한 시간 간격 1s 마다 setInterval에 의해 setTime이 반복적으로 호출되며 time 상태가 새롭게 변경됩니다.

여기서 문제가 무엇인지 확인 하셨나요??

네 맞습니다. 60초의 타이머를 1초마다 갱신을 하다보니 60번의 렌더링이 발생하였습니다. 이는 타이머를 60등분으로 나누어서 이동하기에 뚝뚝 끊어진 고장난 타이머가 되어 버린것입니다....

간격을 줄여보자!

그럼 1초의 간격을 더 촘촘하게 만들면 되지 않나요? 라고 생각을 하실 수 있는데요. 작년의 저도 같은 생각으로 1s를 10ms 간격으로 INTERVAL_MILLISECOND 변수를 변경하여 해결을 했었습니다.

으음... 확실히 이전보다 부드러워 진것 같죠?? 타이머를 60등분을 했던 것과 다르게 6000(60s / 0.01s)등분을 하다 보니 확실히 연속된것 처럼 보이네요!

10ms 마다 상태를 변경하여 렌더링 시키는 것이 성능적으로 안좋지 않을까? 라는 생각을 했었습니다.

하지만 일단 해결은 됐으니 급한 다른 부분을 해결하고 돌아오자 라며 방치를 해두었죠... (그렇게 1년이 흐르고 잊혀질때 쯤에...)

requestAnimationFrame

부트캠프 교육을 받던 중 rAF(requestAnimationFrame) 함수를 알게 되었고 애니메이션을 좀 더 부드럽게 하기 위해서 적용을 할 수 있다고 마스터님께서 말씀하셨습니다. 그 때 1년 전 불량품 타이머를 만들었던 기억 갑자기 떠오르면서 이걸 적용해 보면 해결 할 수 있을거 같다! 생각하였습니다.

적용하는 법은 간단하지만 어떠한 원리로 애니메이션이 부드러워 지는지 알 필요가 있기에 브라우저 렌더링 과정부터 시작하여 좀 더 학습해 봅시다.

렌더링 동작 과정

이 그림을 보며 어떻게 브라우저 화면에 그려지는지 알아봅시다.

  1. HTML 파일을 분석하여 DOM 트리를 구성
  2. CSS 파일을 분석하여 CSSOM 이라는 스타일 트리를 구성
  3. 두 트리를 합쳐서 실제로 브라우저에 렌더링할 렌더 트리를 구성하여 화면에 보여줌
    3-1. 만약 JS로 DOM을 조작하면 (예를들어 버튼을 클릭하여 리스트 추가) Reflow 단계에서 새롭게 구성
    3-2. 만약 CSS를 조작하면 (예를들면 색상을 검정에서 파랑으로 변경) Repaint단계에서 새롭게 구성

우리는 타이머의 스타일이 계속해서 변경되는 것이니 Repaint가 계속해서 일어난다고 생각해도 좋을 것 같습니다.

Frame

우리가 모니터를 살때 60Hz, 144Hz 라는 주사율을 확인할 수가 있습니다. 특히 144Hz는 게이밍 모니터라고도 불리는데 이러한 차이가 무엇일까요?

60Hz란 1초에 60번 렌더링이 될 수 있다고 생각하면 쉽습니다. 예를들어 다음과 같은 타이머의 제한시간이 1초라고 하면

1초전

1초후

모니터가 60Hz 주사율을 가지고 있다면 1초동안 저 타이머 막대기는 60등분되어 60번 줄어들면서 1초후에 없어질 것입니다. 1초 / 60 = 16.66 ms 이므로 16ms 마다 새롭게 렌더링이 될 수 있습니다.

그럼 144HZ 주사율을 가지고 있으면 어떨까? 1초동안 144등분되어 줄어들면서 144번 줄어들고 1초후 사라질것입니다. 1초 / 144 = 6.94 ms 이므로 7ms 마다 새롭게 렌더링이 될 수 있습니다.

그럼 조금 더 잘게 쪼개서 보여준 144HZ가 좀 더 부드럽고 자연스럽게 보이지 않을까요?

이렇게 하나의 새롭게 랜더링 된 하나의 장면을 frame 이라고 합니다.

rAF 동작 이해

그럼 rAF는 어떻게 작동하는 것일까? 먼저 많은 사람들이 60Hz를 사용하므로 주사율이 60Hz라고 가정해봅시다.

rAF를 적용하여 timer의 스타일을 갱신하는 timerAnimation 함수는 주사율에 맞게 브라우저가 적절하게 실행합니다. 그러면 우리는 timerAnimation이 한번 실행될 때 마다 화면에 변경된다고 보장할 수 있습니다. 만약 144Hz 라면? 간격이 7ms 마다 실행되는것 외에는 동일합니다.

setInverval 비교

그럼 이전에 작성한 rAF가 아닌 setInterval로 10ms 마다 실행하면 어떻게 될까요?? 아마 다음과 같이 예상하실 수 있을 겁니다.

이렇게 실행이 되면 무엇이 문제일까요? 우리 화면은 16ms 단위로 화면이 렌더링 되기에 16ms 사이에 함수가 여러번 실행되어도 1번만 적용이 됩니다. 이러면 CPU 입장에서는 의미 없는 일을 하는 격이기에 낭비라고 볼 수 있겠네요. 하지만 더 큰 문제가 있습니다.

과연 setInterval이 우리가 원하는 대로 시간에 맞춰 실행이 될까?

Event Loop

JS에서 작동하는 Timer는 우리가 생각하는대로 딱 정확하게 실행되지는 않습니다.

그러한 이유는 JS는 싱글 쓰레드 언어이기 때문인데요. 싱글 쓰레드 언어는 단순하게 한번에 하나의 작업만을 할 수 있다는 것을 의미합니다. 하지만 구현을 하다보면 I/O나 비동기 이벤트(이 경우에는 타이머)를 처리해야 하는 경우가 종종 생기는데 이는 Event Loop를 통해서 JS엔진이 똑똑하게 처리합니다.

이 사진은 유명한 이벤트 루프 동작을 나타내는 사진 중 하나인데요. 이벤트 루프에 대해서는 다음 기회에 자세하게 다뤄보도록 하고 지금은 간단하게 과정만 설명하겠습니다.

  1. JS에서 실행되는 함수는 CallStack에 들어갑니다.
  2. 만약 즉시 실행 가능한(동기) 함수 일 경우 CallStack에 바로 빠져 나와 실행됩니다.
  3. 타이머나 그 외 비동기적인 작업은 WEB APIs에 넘겨지고 해당 비동기가 끝나면 Callback Queue로 실행 되어야 할 함수가 넘겨집니다.
  4. Event Loop는 CallStack이 비어있을 때 까지 기다린 후 Callback Queue 순서대로 함수를 CallStack으로 옮깁니다.

여기서 위의 10ms Interval을 생각해 봅시다. 만약 10ms가 끝나서 Callback Queue에 timerAnimation 함수가 들어갔는데 CallStack에 아직 작업들이 남아있다면?? EventLoop는 이 작업이 끝날때 까지 기다리게 되고 그럼 우리가 원하는 10ms 마다 실행할 수 없겠죠?

그래서 어떻게 구현하는데?

브라우저에서 정말 고맙게도 frame을 새로 그릴때 실행되어야 할 함수를 등록할 수 있는 window.requestAnimationFrame 이라는 함수를 제공하고 있습니다. 이는 setInteval 처럼 등록하는 것이 아니고 다음 1번의 frame을 그릴때 실행 되는 1회성 함수입니다. 그릴때 마다 실행 되기 위해선 재귀를 사용해서 계속해서 불러야 한다고 MDN 에 나와있네요.

코드

// MemoryGameTimer.tsx (타이머 UI 컴포넌트)
import { Progress } from '@chakra-ui/react';

function MemoryGameTimer() {
  const { time, startTimer } = useTimer();

  useEffect(() => {
    requestAnimationFrame(startTimer);
  }, [startTimer]);

  return <Progress hasStripe value={time / 600} />;
}

// useTimer.ts (Timer 로직을 담아 둔 hook)
function useTimer() {
  const INITIAL_TIME = 60000; // 60s
  const [time, setTime] = useState(INITIAL_TIME);
  const startTime = useRef<number | null>(null);
  
  const startTimer = useCallback((timeStamp: number) => {
    if (!startTime.current) {
      startTime.current = timeStamp;
    }
    const elapsedTime = timeStamp - startTime.current;
    setTime(INITIAL_TIME - elapsedTime);
    if (elapsedTime < INITIAL_TIME) {
      requestAnimationFrame(startTimer);
    }
  }, []);


  return { time, startTimer };
}

결과는 ??

아주 부드러운 UX를 제공하고 있습니다 👍 유저는 만족하면서 게임을 즐길 수 있겠군요 ㅎㅎ 유저에게 좀 더 부드럽고 만족스러운 UX를 제공하고 싶으시면 rAF를 이용해 보시는 것도 좋은 방법이라고 생각이 됩니다!

미해결

하지만 아쉬운점이 몇가지 있었습니다.

1. setState 사용

위 코드는 비동기 함수인 setTime을 통해 적절한 시점에 time의 상태를 변경하고 이를 기반으로 타이머 Progress Bar Animation이 작동합니다. 그런데 브라우저가 Frame을 새로 그리기 전 time 이라는 State가 변경이 반영되었다고 확신할 수 있을까요?? 이 부분에 대해선 좀 더 고민해봐야 할것 같습니다.

2. 성능 관점에서의 비교

블로그를 작성하며 사실 10ms간격rAF 썼을때의 UX측면에서 결과 비교가 잘 안나기 때문에 조금 아쉬운 마음을 가지고 CPU나 Memory에서 어떠한 차이가 있는지 측정을 해보고 싶었습니다. 하지만 어떠한 방식으로 해야할지 잘 알지 못해서 숙제로 남겨두었습니다...😭

혹시 잘못된 부분이나 아쉬운 점을 해결할 방법을 댓글로 알려주시면 감사하겠습니다.🙇🏻‍♂️

웹 애니메이션 최적화
reflow & repaint
How JavaScript works: an overview of the engine, the runtime, and the call stack

profile
FE 개발자

0개의 댓글