ReactJS

[React] 무한 렌더링과 메모리 누수 이슈 해결: DOM 직접 조작과 참조값의 위험성

youjeong_choi 2025. 4. 14. 18:51

최근에 정말 중요한 이슈를 하나 마주쳤다. 내가 구현한 컴포넌트 하나가 무한 렌더링에 빠져 성능을 매우 저해하고 있었다. 처음엔 왜 이 컴포넌트가 계속 렌더링되지...? 하고 의아했는데, 파고들수록 리액트의 내부 동작 원리에 대해 알게되어 정리하고자 한다.

문제의 시작

ProSeqViewer라는 시각화 라이브러리를 쓰고 있었는데, 이 라이브러리는 내부적으로 DOM을 직접 조작한다. React스럽지 않지만 Protein sequence를 렌더링하는 라이브러리로는 이것만한 것이 없어 어쩔 수 없이 사용해야만 했다. 이 컴포넌트는 sequences라는 배열 데이터를 받아서 시각화해주는데, 문제는 이 배열이 자꾸 컴포넌트를 리렌더링시키고 있다는 점이었다.

문제의 컴포넌트
무한히 누적되는 reflow warning

const ProSeqViewer = ({ consensus, sequences, options }: ProSeqViewerProps) => {
  const [id] = useState<string>(uuid());
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (ref.current === null) return;
    const viewer = new BaseProSeqViewer(ref.current.id);
    
    if (sequences[sequences.length - 1] === undefined) sequences.pop();
    viewer.draw({ sequences, options: { ...defaultOptions, ...options }, consensus });

  }, [options, consensus, sequences]);

  return (
    <Tooltip title={t('Scroll wheel horizontally to scroll the alignment.')} placement={'top'}>
      <Box sx={{ 'div.crd span.cell': { pointerEvents: 'none' } }}>
        <div
          id={id}
          ref={ref}
        />
      </Box>
    </Tooltip>
  );
};

 

왜 또 렌더링되는가?

처음엔 이유를 몰랐지만 console.log()로 useEffect 안팎을 찍어보니 계속해서 컴포넌트가 마운트 → 클린업 → 또 마운트…

결국 알아낸 건 이거였다:

  • sequences는 배열이라서 참조 타입이다.
  • 외부 라이브러리가 DOM을 직접 조작하면서 그 DOM에 주입한 참조값인 sequences를 바꾼다. 값 자체는 똑같지만 메모리 주소가 달라진다. 만약에 원시값을 주입했다면 발생하지 않았을 문제다. 또한 통상적으로 백엔드로부터 넘어오는 값들은 원시값이든 참조값이든 달라질 가능성이 거의 없으므로, useState useMemo 처리하지 않았기 때문에 값이 바뀐 채로 영향을 미친다.
  • React는 “props가 바뀌었네?” 하고 인식해서 재렌더링.
  • 또 ProSeqViewer가 DOM을 조작하면서 다시 sequences가 바뀜.
  • 결과적으로 무한 렌더링 루프가 발생한다.

그리고 어느 순간 콘솔에 이런 메시지가 무한히 찍히기 시작하며 화면 성능이 매우 떨어지게 되었다. DOM 조작할 밖에 없는 상황으로 이러한 메세지 자체가 뜨는 것은 막을 수는 없지만 무한 렌더링에 빠지면서 해당 경고가 무한히 쌓이게 되고, 이로 인해 해당 상황에서 시간이 조금만 지나도 메모리 부족 문제로 화면이 멈추는 치명적인 문제가 발생하게 된다.

[Violation] Forced reflow while executing JavaScript took 333ms

 

 

근데 왜 지금까지는 다른 곳에서 해당 컴포넌트를 사용했을 때 멀쩡했을까?

신기한 점은 처음 렌더링할 땐 아무 문제가 없었다는 것이다. 그런데 페이지를 왔다 갔다 하거나, 컴포넌트가 바로 보이는 위치에 있게 되면 무한 루프가 발생하였다. 이유는 아마도 React의 똑똑한 reconciliation(Fiber 아키텍처) 덕분인 것 같다.

  • 초기에 렌더링할 때는 비교할 이전 DOM이 없다.
  • React는 같은 컴포넌트를 3~4번 반복해서 만드는 것처럼 보여도(console로 찍어보면 여러 사이클을 반복하지만 실제로 깜빡거리면서 mount, unmount를 반복하는 것은 아니다.) 정확히 똑같은 것을 렌더링한다고 리액트가 판단하고 더이상 렌더링을 하지 않는다.
  • 하지만 한 번 만들어진 DOM이 그대로 남아있는 상태에서 다른 페이지에서 돌아와 다시 렌더링하면?
  • 과거 DOM과 현재 DOM을 비교해서 다른 점을 찾는다. 컴포넌트의 id 등이 달라지므로 또 렌더링하게 된다.

그래서 맨처음 해당 컴포넌트 렌더링 아무 문제가 없었던 것이고, 이후에 똑같은 것을 렌더링해도 과거에는 해당 컴포넌트가 해당 페이지애 바로 나오지 않고 몇번의 클릭을 거치고 나온 구조라 과거 DOM 사라져 있는 상태에서 새롭게 렌더링하므로 문제가 발생하지 않았던 것이다. 그런데 이번의 경우 해당 페이지에서 바로 나타나는 위치에 컴포넌트가 위치해있는 바람에 과거 DOM 제거되지 않아 리액트 입장에서 비교할 과거의 DOM 남아있었고, sequences 바뀐 새로운 DOM 차이가 있다고 판단하여 재렌더링하고  sequences 달라져서 무한 렌더링에 빠졌던 것으로 보인다.

 

React의 reconciliation(Fiber 아키텍처)에 대해 더 깊게 알아보자면 아래 링크를 참고하면 좋을 것 같다.
https://d2.naver.com/helloworld/2690975

 

해결 방법: 그냥 고정해버리자

이전의 DOM이 남아있는 것을 문제라고 본다면 cleanup 함수를 통해 해당 DOM 지우면 해결될 것이라고 생각했다(예를 들어 cleanup ref.current = null 코드 추가를 있다.) 그러나 DOM 조작하는 순간 sequences 달라져 재렌더링 유발하게 되므로 재렌더링 직전에 cleanup함수로 깨끗하게 비운다고 하여도 다시 렌더링에 걸릴 수밖에 없어서 여러번의 사이클을 또 돌 수 밖에 없다. 그냥 sequences 내부에서 useState 처리하는 것이 올바른 방법이다. 그러하면 DOM 내부에서 sequences 달라져도 리액트 자체에서 백엔드에서 받아온 sequences 그대로 갖고 있으므로 컴포넌트 재렌더링을 유발하지 않게 된다.

const [internalSequences] = useState(sequences);

 

이걸로 sequences는 더 이상 외부에서 바뀌지 않고, 또한 setState로 바꾸지 않으니 내부에서 백엔드에서 넘어온 고정된 값으로 남는다. 그래서 외부 라이브러리가 뭘 하든, 컴포넌트는 "props는 안 바뀌었네?" 하고 넘어가 준다.

 

그리고 든 생각

DOM을 직접 조작할 때는 정말 조심해야 한다. 특히 배열이나 객체 같은 참조 타입 데이터를 props로 넘길 땐, 리액트가 이걸 바뀐 값으로 오해할 수 있다는 점을 항상 염두에 둬야 한다. 이 작은 오해 하나로 앱 전체가 멈춰버릴 수 있다.

 

정리해보면

  • DOM 직접 조작 + 참조값 props → 무한 렌더링
  • 메모리 누수, CPU 폭주, 브라우저 멈춤까지 일어날 수 있음
  • useState()나 useMemo()로 내부에서 참조값 고정하면 해결
  • 리액트는 생각보다 똑똑하지만, 우리가 더 신경 써야 함

 

무한 렌더링이 도는 로그를 해결하는 과정에서 React의 reconciler와 렌더링 동작을 더 깊이 이해하게 됐다. 혹시 내가 겪은 것처럼 외부 라이브러리를 쓰다가 DOM을 직접 만져야 하는 상황이 있다면, 이 경험이 실마리가 되었으면 좋겠다.