본문 바로가기
카테고리 없음

[React & Canvas] 리엑트와 캔버스 사용시 메모리 누수 방지

by SeanK 2023. 4. 27.

안녕하세요, 개발자 Sean입니다.

 

최근 캔버스를 이용해 어플을 제작하는 재미에 푹 빠져있는데요,

회사 프로젝트에도 한번 사용해 보려고 프리로딩 컴포넌트를 캔버스를 이용해 제작했습니다.

 

웹 어플 라이브러리로 리엑트를 사용하고 있기 때문에 useRef로 캔버스를 제어해 아래와 같이 멋진 다이내믹한 프리로딩 이펙트를 만들었습니다.

원이 돌아가며 작은 입자를 뿌리는 방식의 애니메이션 입니다.

 

캡쳐 도중에 vs코드가 끼어들었네요;;

하지만 오늘 이것저것 테스트 진행 도중 심각한 기능저하 문제를 발견했습니다.

프리로더가 언마운트 된 뒤에 어플의 속도가 급격하게 느려지는 문제가 발생했습니다 :(

우선 개발자 도구를 통해 퍼포먼스를 측정해 보았습니다.

역시나 메모리 누수가 발생하고 있는 것을 확인할 수 있었습니다.

어디가 원인인지 한참을 고민하다가

 

requestAnimationFrame함수가 언마운트 된 이후에도 계속 실행되고 있는 사실을 알게 되었습니다.

콘솔로 확인했습니다

이 문제를 해결하는 방법은 간단합니다.

 

컴포넌트가 언마운트 될 때 (리엑트 훅에서는 useEffect의 리턴) cancleAnimationFrame 함수를 실행시키면 됩니다.

 

cancleAnimationFrame은 인자로 number를 받고 있는데요, requestAnimationFrame이 반환하는 아이디(넘버)를 넣어주면 해당 애니메이션을 종료하게 됩니다.

 

type Props = {
  close: () => void
}

const Preloader = ({ close }: Props) => {

  const ref = useRef<HTMLCanvasElement>(null);
  const dpr = window.devicePixelRatio;
  let canvasWidth = window.innerWidth;
  let canvasHeight = window.innerHeight;
  const interval = 1000 / 60;
  const silverlines: any[] = [];
  const particles: any[] = [];

  const createSilverlines = () => {
    const NUM = 20;
    for (let i = 0; i < NUM; i++) {
      silverlines.push(new SilverLine())
    }
  }
  
  const createParticles = () => {
    const NUM = 40;
    for (let i = 0; i < NUM; i++) {
      particles.push(new Particle())
    }
  }

  const init = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => {
    canvasWidth = window.innerWidth;
    canvasHeight = window.innerHeight;
    canvas.style.width = canvasWidth/2 + 'px';
    canvas.style.height = canvasHeight/2 + 'px';
    canvas.width = canvasWidth * dpr;
    canvas.height = canvasHeight * dpr;
    ctx.scale(dpr, dpr);
    createSilverlines();
  }

  let stage = 0;

  const requestRef = useRef<number>();

  const render = (ctx: CanvasRenderingContext2D) => {
    let now, delta;
    let then = Date.now();
    const frame = () => {
      requestRef.current = requestAnimationFrame(frame);
      now = Date.now();
      delta = now - then;
      if (delta < interval) return;

      ctx.clearRect(0, 0, canvasWidth, canvasHeight);

      if (stage > 0 && stage < 6) createParticles();

      for (let i=0; i < silverlines.length -1; i++) {
        silverlines[i].update(stage)
        silverlines[i].draw(ctx)
      }

      for (let i=0; i < particles.length -1; i++) {
        particles[i].update(stage)
        particles[i].draw(ctx)
      }

      for (let i = particles.length -1; i >=0; i--) {
        if (particles[i].opacity <= 0) particles.splice(i, 1)
      }

      then = now - (delta % interval);
    }
    requestRef.current = requestAnimationFrame(frame);
  }

  useEffect(() => {
    const canvas = ref.current;
    const ctx = canvas?.getContext('2d');
    if (!ctx || !canvas) return;
    init(canvas, ctx);
    render(ctx);

    return () => {
      if (requestRef.current) cancelAnimationFrame(requestRef.current);
    };
  },[])

  const handleClose = () => {
    stage = 8
    setTimeout(() => {
      close()
    }, 1500)
  }

  return (
    <Container onClick={updateStage}>
      <Motion>
        <canvas ref={ref}/>
      </Motion>
      <ButtonWrapper>
        <Button type="primary" leftIcon={<NotAllowedIcon />} onClick={handleClose}>Stop</Button>
      </ButtonWrapper>
    </Container>
  )
}

export default Preloader;