기록

useEffect와 WAAPI 애니메이션 누적

TL;DR

  • Element.animate()는 호출할 때마다 새 Animation 객체를 만든다.
  • dependency array가 없는 effect 안에서 호출하면 렌더마다 애니메이션이 누적된다.
  • React.memo는 props 참조가 안정적일 때만 렌더를 줄인다. 인라인 children은 매번 새 element가 된다.
  • 명령형 브라우저 API를 effect에서 호출할 때는 호출 조건과 cleanup을 함께 둬야 한다.

무한 슬라이더가 Safari에서 시작 위치로 되돌아가는 문제가 있었다. 같은 로고 목록을 두 벌 이어 붙이고, 전체 너비의 절반만큼 왼쪽으로 이동시키는 슬라이더였다. 이동 거리를 DOM에서 재야 해서 CSS animation 대신 Web Animations API를 썼다. DOM을 잰 값을 state에 넣고, 그 값으로 keyframe을 만들었다.

const [width, setWidth] = useState(0);
const ref = useRef(null);

useEffect(() => { setWidth(ref.current.getBoundingClientRect().width / 2); });

useEffect(() => {
  ref.current.animate(
    [
      { transform: "translate3d(0px, 0, 0)" },
      { transform: `translate3d(-${width}px, 0, 0)` },
    ],
    { duration: 50000, iterations: Infinity },
  );
});

측정값 검증

width가 0이거나 실제 절반보다 작게 잡히면 슬라이더가 움직이지 않는 것처럼 보일 수 있다. 하지만 로그상 width에는 기대한 값이 들어왔고, 그 값으로 만들어지는 keyframe도 정상 범위였다. getAnimations()로 확인해도 요소에는 애니메이션이 붙어 있었다. 문제는 애니메이션 개수였다. 렌더가 들어올수록 같은 요소에 Animation 객체가 추가됐다.

문제는 effect 실행 조건이었다

슬라이더에서 더 이상 쓰지 않는 데이터 prop을 정리하는 과정에서 두 useEffect의 dependency array가 함께 제거됐다. 그 결과 측정 effect와 animate() 호출 effect가 렌더마다 실행됐다.

}, [items]);   // 이렇게 되어 있던 것을
});            // 이렇게 지웠다

배열이 없는 effect는 렌더마다 실행된다. 문제는 effect 안에서 호출하는 API가 element.animate()였다는 점이다. 같은 keyframe과 같은 option으로 animate()를 다시 호출해도 기존 애니메이션은 덮어써지지 않는다. Element.animate()는 호출할 때마다 새 Animation 객체를 만들어 요소에 붙이고 즉시 재생한다. 기존 애니메이션을 멈추거나 대체하지 않는다. 그러니 렌더가 다섯 번 들어오면 같은 transform을 다루는 무한 반복 애니메이션이 다섯 개 붙는다. 새로 만들어진 애니메이션은 항상 처음부터 시작한다. 렌더가 일어날 때마다 슬라이더가 시작 위치로 되돌아간다. 게다가 반환값을 잡지 않았다. animate()가 돌려주는 Animation 객체를 버리면 나중에 cancel()을 호출할 대상도 사라진다.

React.memo가 막을 수 없는 렌더였다

마운트 뒤에도 렌더가 들어온 이유는 상위 컴포넌트였다. 슬라이더가 놓인 화면은 세로 스와이프 구조였고, 현재 구역 index가 상위 state로 관리됐다. 첫 화면에서 타이머로 켜지는 state도 있었다. 상위가 렌더될 때마다 아래 구역들도 다시 렌더됐다. 슬라이더 컴포넌트는 React.memo로 감싸져 있었지만, 이 렌더를 막지 않았다.

<InfiniteSlider flow="left" duration={50000}>
  <SliderItem items={items} />
</InfiniteSlider>

children을 JSX로 인라인해 넘기면, 부모가 렌더될 때마다 <SliderItem /> element가 새로 만들어진다. children도 props이고 React.memo는 props를 얕게 비교한다. 참조가 매번 바뀌면 memo 비교는 실패한다. 실제 호출 흐름은 다음과 같다.

상위 렌더 한 번이 만든 일
  • 상위 state 변경화면 구역이나 타이머 state가 바뀐다
  • 부모 렌더children element가 새로 만들어진다
  • memo 비교 실패InfiniteSlider가 다시 렌더된다
  • effect 재실행의존성 배열이 없어 animate()가 다시 호출된다
  • Animation 누적슬라이더가 다시 시작 위치에서 출발한다

슬라이더는 50초 동안 천천히 이동하도록 되어 있었다. 느린 애니메이션에서 시작 위치로 자주 돌아가면 화면에는 멈춘 것처럼 보인다.

핵심 수정은 animate 호출 횟수를 줄이는 것이었다

수정은 한 번에 들어갔다. 의존성 배열을 다시 넣었고, 측정 전 실행을 막는 guard를 추가했고, translate3dtranslateX로 바꿨고, 폭 기준도 조정했다. 그래서 커밋만 보면 어떤 변경이 직접 원인이었는지 바로 드러나지 않는다. 다만 이후 코드를 기준으로 보면 핵심은 두 번째 effect의 실행 조건을 좁힌 것이다.

useEffect(() => {
  setWidth(ref.current.getBoundingClientRect().width / 2);
}, [children]);

useEffect(() => {
  if (!width) return;
  ref.current.animate(keyframes, options);
}, [flow, duration, keyframes, options, width, ref]);

두 번째 effect의 의존성이 안정된 값으로 묶이면, 부모가 다시 렌더되어도 animate()를 매번 호출하지 않는다. 폭이 정해졌을 때 한 번 호출되고, 폭이나 방향 같은 실제 입력이 바뀔 때만 다시 호출된다. 측정 전 guard도 필요했다. width가 0인 상태에서 animate()를 먼저 호출하면 의미 없는 애니메이션을 하나 만든다. 하지만 guard만으로는 충분하지 않다. 폭이 잡힌 뒤에도 effect가 렌더마다 실행되면 같은 문제가 반복된다. 더 맞는 코드는 반환값을 잡고 cleanup에서 취소하는 형태다.

useEffect(() => {
  if (!width) return;

const animation = ref.current.animate(keyframes, options);

return () => animation.cancel();
}, [flow, duration, keyframes, options, width, ref]);

이렇게 해야 effect가 다시 실행될 때 이전 애니메이션을 명시적으로 제거할 수 있다.

의존성 배열은 호출 조건이다

dependency array는 단순한 성능 옵션이 아니다. effect 안에서 명령형 API를 호출할 때는 해당 API의 호출 조건이 된다. animate()처럼 부를 때마다 결과가 하나씩 쌓이는 명령형 API를 effect 안에서 쓰면 그렇지 않다. 의존성 배열은 이 API를 언제 부를지 정하는 스위치고, 스위치를 빼면 호출 횟수가 곧 상태가 된다. 같이 배운 것이 하나 더 있다. React.memo는 걸어두기만 하면 렌더를 막아주는 장치가 아니다. 넘기는 props의 참조가 안정적이어야 한다. 인라인 children을 넘기면 부모 렌더마다 새 element가 만들어지고, memo 비교는 실패한다. effect 안에서 브라우저 API를 부를 때는 두 가지를 먼저 봐야 한다. 이 호출은 누적되는가. 이전 것을 치울 방법을 잡고 있는가. 반환값이 있는 명령형 API라면 그 반환값을 버리지 않는 쪽이 기본값이어야 한다.

관련 글