useEffect와 무한 랜더링

이영섭·2025년 4월 13일

소음 프로젝트에서 소음 측정 과정 중 다음과 같은 에러가 발생하였다.

직역하자면 update depth가 최대치를 초과했고, 이는 컴포넌틍에서 useEffect내부의 setState를 호출할 때 발생할 수 있다고 한다. 해당 에러를 자세히 살펴본바에 따르면 Noise.tsx의 133번째 줄에서 에러가 발생했다. 그 코드는 아래와 같다.
useEffect(() => {
      if (isRecording) {
        const current = decibel === -Infinity ? 0 : decibel;
        setCurrentDecibel(current);
    
        // 로컬 최대 데시벨 업데이트
        if (current > measuredMaxDecibel) {
          setMeasuredMaxDecibel(current);
        }
    
        // 누적 합계 및 평균 계산
        const newTotalDecibelSum = totalDecibelSum + current;
        const newTotalDataPoints = totalDataPoints + 1;
    
        setTotalDecibelSum(newTotalDecibelSum); // 누적 합계 업데이트
        setTotalDataPoints(newTotalDataPoints); // 데이터 포인트 수 업데이트
        setMeasuredAverageDecibel(newTotalDecibelSum / newTotalDataPoints); // 평균 계산 및 업데이트
    
        // 데이터 포인트 추가
        setDataPoints((prevDataPoints) => [
          ...prevDataPoints,
          { x: new Date().toISOString(), y: current },
        ]);
      }
    }, [decibel, isRecording, totalDecibelSum, totalDataPoints, measuredMaxDecibel]);

위 useEffect 내 로직에 대해 설명하자면 데시벨 측정시 최대 데시벨, 평균 데시벨과 함께 그래프를 표기할 dataPoint 값들을 준비하기 위한 것이다.

해당 에러 문제의 원인

직관적으로 해당 로직을 살펴보았을 때는 의존성 배열에 너무 많은 값들이 들어가 있어서 에러가 발생한 것으로 생각했다. useEffect 훅은 기본적으로 컴포넌트가 처음 렌더링될 때와 의존성 배열에 있는 값이 변경될 때마다 실행되기 때문이다.
이에 따라 로직을 살펴보면, totalDecibelSum을 기준으로 에러의 발생원인을 생각할 수 있다. totalDecibelSum은 측정된 decibel값을 모두 더한 값으로 초기 새로운 decibel이 추가될 때마다 setTotalDecibelSum이 호출되어 totalDecibelSum의 값이 바뀌게 되고, 이에 따라 리랜더링이 발생된다. 리랜더링이 발생됨에 따라 useEffect가 다시 실행되고 또 다시 setTotalDecibelSum이 재호출되면서 무한 루프에 갇히게 되어 에러가 발생된 것으로 이해했다.

1번째 문제 해결 시도

따라서 의존성 배열을 최소화하기 위해

useEffect(() => {
      // 실행 로직 
    }, [decibel, isRecording ]);

으로 해주면 어떨까?
decibel은 측정될 때마다 maxDecibel과 totalDecibelSum, dataPoint 추가가 될 것이라 생각했다.

1회차 시도 결과 및 2회차 문제 원인 분석

해당 코드로 실행해본 결과 이전 코드와 달라진 점은 이전에는 "측정 시작"버튼을 클릭하자마자 에러가 발생했지만, 현재는 대략 15초 측정 후부터 다음과 같은 에러가 발생한다는 점이다.

나는 이것이 decibel이 실시간으로 너무 많은 값이 추가되기 때문에 벌어진 현상이라 생각했다.
아래 코드는 decibel을 측정하는 훅함수이다.

  • useRecordWithDecibel.ts
import { useEffect, useRef, useState } from 'react';

const useRecordWithDecibel = () => {
    const [decibel, setDecibel] = useState<number>(0);
    const mediaStreamRef = useRef<MediaStream | null>(null);
    const audioContextRef = useRef<AudioContext | null>(null);
    const analyserRef = useRef<AnalyserNode | null>(null);
    const animationFrameId = useRef<number | null>(null);
  
    const startMeasuringDecibel = async () => {
      try {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        mediaStreamRef.current = stream;
        audioContextRef.current = new AudioContext();
        const sourceNode = audioContextRef.current.createMediaStreamSource(stream);
        analyserRef.current = audioContextRef.current.createAnalyser();
        sourceNode.connect(analyserRef.current);
        updateDecibel();
      } catch (error) {
        console.error("Failed to start recording:", error);
      }
    };
  
    const stopMeasuringDecibel = () => {
      mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
      audioContextRef.current?.close();
      cancelAnimationFrame(animationFrameId.current!);
    };
  
    const updateDecibel = () => {
      if (!analyserRef.current) return;
      const bufferLength = analyserRef.current.frequencyBinCount;
      const dataArray = new Uint8Array(bufferLength);
      analyserRef.current.getByteFrequencyData(dataArray);
      const sum = dataArray.reduce((acc, value) => acc + value);
      const average = sum / bufferLength;
      const decibel = 20 * Math.log10(average);
  
      setDecibel(Number(decibel.toFixed(2)));
  
      animationFrameId.current = requestAnimationFrame(updateDecibel);
      return;
    };
  
    // Cleanup the media stream when the component is unmounted
  useEffect(() => {
    return () => {
      if (mediaStreamRef.current) {
        mediaStreamRef.current.getTracks().forEach(track => track.stop());
      }
      if (audioContextRef.current) {
        audioContextRef.current.close();
      }
    };
  }, []);
  
    return { startMeasuringDecibel, stopMeasuringDecibel, decibel };
};

export default useRecordWithDecibel;

위 코드는 내가 Web Audio API를 이해한 것을 기반으로 chatgpt를 통해 데시벨 측정을 하는 훅함수로 생성한 것이고, 이에 대해서도 추후 리팩토링을 진행해야 할 것으로 생각된다. 다시 현재 문제로 focus해보았을 때 주목해야되는 것은 requestAnimationFrame이다.

문제의 원인으로 추정되는 requestAnimationFrame 분석

잠시 requestAnimationFrame에 대해 이해해보자면, Web API의 일종으로 브라우저가 애니메이션 프레임을 최적의 시간에 렌더링하도록 스케줄링하는 API라고 생각하면 된다.

이에 대해 이해하자면 우선 브라우저의 랜더링 과정을 이해할 필요가 있다.

  • Javascript - 애니메이션 및 기타 작업 스크립트를 수행 (DOM 생성)
  • Style - CSS 규칙을 어떤 요소에 적용할지 계산하는 프로세스 (CSSOM 생성)
  • Layout - 브라우저는 DOM과 CSSOM을 결합하여 객체들의 위치와 크기 등을 계산하는 렌더 트리(Render Tree)를 생성.
  • Paint(redraw) - 브라우저는 렌더 트리를 사용하여 실제로 화면에 픽셀을 출력 (객체가 실제 화면에 그려지는 것을 의미)
  • Composite - 브라우저는 화면에 출력되는 객체들을 합성하여 최종 화면을 생성

참조 자료 : 웹 애니메이션 최적화 requestAnimationFrame 가이드

이 requestAnimationFrame은 비동기적으로 브라우저에 animation 요청을 보내고 다음 repaint가 발생하기 직전에 지정된 콜백 함수를 호출하여, 이를 통해 브라우저에 최적화된 애니매이션을 실행할 수 있도록 보장한다. 정확히는 애니매이션이 일어나는 각각의 장면을 frame이라 부르는데, 특정 시간 내에 보여지는 frame의 수를 디스플레이의 주사율에 맞게 실행되도록 함으로써 보다 부드러운 animation을 보여줄 수 있도록 한다.

보통 1초당 60개의 장면이 넘어가야 부드럽다고 인식하는데, 만일 해당 decibel 측정이 1초당 60번 측정이 이루어진다 가정했을 때 측정시간에 제한이 없는 우리 프로젝트에서는 decibel 업데이트가 엄청나게 이루어질 것이고, 매우 잦은 측정으로 CPU에 부담이 갈 가능성이 있다.

반면에 requestAnimationFrame을 사용할 경우 그만큼 소리에 대한 측정이 많이 이루어지기 때문에 sampling의 수가 많아 정확도는 올라갈 가능성이 있다.

현재의 우리 프로젝트의 목적은 주변의 소음이 평균적으로 적은 공간을 찾는 유저를 타겟으로 삼았기 때문에 순간의 시끄러움, 예를 들어 박수소리라든지, 이런 소리보다는 평균적인 소음 데이터가 필요하다고 판단되서 requestAnimationFrame을 사용하기 보다는 setInterval을 사용해서 1초마다 소음이 측정되도록 코드를 수정함으로써 명확한 기준을 세우고, CPU의 부담을 줄이도록 할 것이다.

setInterval을 활용한 2회차 문제 해결 시도

  • useRecordWithDecibel.ts
import { useEffect, useRef, useState } from 'react';

const useRecordWithDecibel = () => {
    const [decibel, setDecibel] = useState<number>(0);
    const mediaStreamRef = useRef<MediaStream | null>(null);
    const audioContextRef = useRef<AudioContext | null>(null);
    const analyserRef = useRef<AnalyserNode | null>(null);
    const intervalIdRef = useRef<NodeJS.Timeout | null>(null); // ✅ 변경된 부분
  
    const startMeasuringDecibel = async () => {
      try {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        mediaStreamRef.current = stream;
        audioContextRef.current = new AudioContext();
        const sourceNode = audioContextRef.current.createMediaStreamSource(stream);
        analyserRef.current = audioContextRef.current.createAnalyser();
        sourceNode.connect(analyserRef.current);
        intervalIdRef.current = setInterval(() => {
          updateDecibel();
        }, 1000); // ✅ 1초마다 측정
      } catch (error) {
        console.error("Failed to start recording:", error);
      }
    };
  
    const stopMeasuringDecibel = () => {
      mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
      audioContextRef.current?.close();
      if (intervalIdRef.current) clearInterval(intervalIdRef.current); // ✅ clearInterval로 정리
    };
  
    const updateDecibel = () => {
      if (!analyserRef.current) return;
      const bufferLength = analyserRef.current.frequencyBinCount;
      const dataArray = new Uint8Array(bufferLength);
      analyserRef.current.getByteFrequencyData(dataArray);
      const sum = dataArray.reduce((acc, value) => acc + value, 0);
      const average = sum / bufferLength;
      const decibel = 20 * Math.log10(average || 1); // 0 방지 위해 || 1 처리

      setDecibel(Number(decibel.toFixed(2)));
    };
  
    // Cleanup the media stream when the component is unmounted
  useEffect(() => {
    return () => {
      if (mediaStreamRef.current) {
        mediaStreamRef.current.getTracks().forEach(track => track.stop());
      }
      if (audioContextRef.current) {
        audioContextRef.current.close();
      }
      if (intervalIdRef.current) {
        clearInterval(intervalIdRef.current);
      }
    };
  }, []);
  
    return { startMeasuringDecibel, stopMeasuringDecibel, decibel };
};

export default useRecordWithDecibel;

소음 측정이 시작되는 startMeasuringDecibel 함수에서 updateDecibel을 setInterval을 활용하여 1000ms, 즉 1s마다 실행되도록 설정했으며, clearInterval을 활용하여 stopMeasuringDecibel이 실행될 때, 즉 intervalIdRef.current에서 interval이 작동되고 있을 때 clear되도록 설정했다.

다만, intervalIdRef에서 type이 NodeJS.Timeout이라 type이 설정되어 있는데, 나는 NodeJS 서버를 사용하는 것이 아니고, 화면에서 ReactJS와 typescript만을 사용하고 있는데 적절한 type인지 의문이 생겼다.

브라우저 환경에서 setInterval이 반환하는 type은 number이고, number로 할당할시

intervalIdRef.current = setInterval(() => {
  updateDecibel();
}, 1000)

이 부분에서 intervalIdRef.current에 "'Timeout' 형식은 'number' 형식에 할당할 수 없습니다."라는 에러가 발생한다.

보다 안정적인 type설정을 위해 다음과 같이 type을 변경하였다.

    const intervalIdRef = useRef<ReturnType<typeof setInterval> | null>(null); // ✅ 변경된 부분

위와 같이 변경한 후 실행해보았을 때 vs code와 브라우저의 console에서 에러가 사라진 것을 확인할 수 있었다.
useEffect의 의존성 배열과, requestAnimationFrame에 대해 알아볼 수 있는 시간이었다.

profile
신입 개발자 지망생

0개의 댓글