Blog

← Back to list

canvas 태그를 반응형으로 구현하면 생기는 문제 및 해결 방법

React|Apr 5, 2025

canvas 태그는 그래픽 콘텐츠를 그릴 때 유용한 태그입니다.

그래서 저는 포트폴리오를 개발하면서 뒷배경에서 반짝이는 별을 그리기 위해 태그를 사용하던 도중 누가 의도적으로 잡아당겨서 화질이 일그러지는 것처럼 보이는 문제점을 발견했습니다.

이 현상은 캔버스를 반응형으로 구현하던 도중 생겼던 문제이며 이 문제에 대해 자세히 다뤄보고 저와 같은 문제를 겪는 사람을 위해 한 가지 해결 방법을 제안해 보려 합니다.


1. 화질 깨짐이 발생하는 이유

이유를 알아보기 전에 먼저 canvas 태그의 작동 방식을 알아야 합니다.

// Canvas.tsx
import React, { useEffect, useRef } from "react";
 
const Canvas: React.FC = () => {
   const canvasRef = useRef<HTMLCanvasElement>(null);
 
   const randomPos = (min: number, max: number) => {
      return Math.random() * (max - min) + min;
   };
 
   useEffect(() => {
      const canvas = canvasRef.current;
      if (!canvas) return;
      const canvasParent = canvas.parentNode;
      if (!canvasParent) return;
      const ctx = canvas.getContext("2d");
      if (!ctx) return;
 
      const canvasWidth = (canvasParent as HTMLElement).clientWidth;
      const canvasHeight = (canvasParent as HTMLElement).clientHeight;
      canvas.width = canvasWidth;
      canvas.height = canvasHeight;
 
      let stars: {
         x: number;
         y: number;
         radius: number;
      }[] = [];
 
      const createStars = (num: number) => {
         stars = [];
 
         for (let i = 0; i < num; i++) {
            const x = randomPos(0, canvasWidth);
            const y = randomPos(0, canvasHeight);
            const radius = randomPos(5, 10);
 
            stars.push({
               x,
               y,
               radius,
            });
         }
      };
 
      const drawStars = () => {
         ctx.clearRect(0, 0, canvas.width, canvas.height);
 
         stars.forEach((star) => {
            ctx.beginPath();
            ctx.arc(star.x, star.y, star.radius, 0, Math.PI * 2);
            ctx.fillStyle = "white";
            ctx.fill();
         });
      };
 
      createStars(100);
      drawStars();
   }, []);
 
   return <canvas ref={canvasRef} />;
};
 
export default Canvas;

무작위로 정의된 위치와 크기로 하얀 작은 원을 그리는 코드 예제입니다.

코드 설명은 생략하고 중요한 부분만 짚자면, canvas 태그는 canvas의 width와 height를 지정하고 요소를 그리는 방식입니다. 또한 canvas 태그는 비트맵으로 그래픽 요소를 그리기 때문에 바탕 크기와 요소의 변형이 자유롭지 못하는 비트맵의 특성도 그대로 물려받습니다.


720x7201000x1000

위 사진은 비트맵 파일인 PNG입니다. 이 파일을 프로그램으로 해상도를 올리면 배경만 커지는 것이 아닌 요소들도 비례하여 커지기 때문에 화면을 모두 채우려다가 화질이 깨집니다.

canvas 태그도 마찬가지입니다. 아까 서술했듯 초기에 캔버스 사이즈를 미리 정의하고, 그 위에 요소를 그리기 때문에 정의된 크기에 알맞게 그려집니다. 하지만 반응형 웹은 뷰포트의 사이즈가 조절되더라도 조절된 크기에 맞춰 UI를 정리하기 때문에 캔버스 크기가 초기 사이즈와 달라지게 되고, 그 크기에 맞춰 채우려 하면서 그림이 깨지게 되는 것입니다.


2. 현상 해결 방법

// StarCanvas.tsx
import React, { useEffect, useRef } from "react";
import * as Style from "./styled";
 
const StarCanvas: React.FC = () => {
   const canvasRef = useRef<HTMLCanvasElement>(null);
 
   const randomPos = (min: number, max: number) => {
      return Math.random() * (max - min) + min;
   };
 
   useEffect(() => {
      const canvas = canvasRef.current;
      if (!canvas) return;
      const canvasParent = canvas.parentNode;
      if (!canvasParent) return;
      const ctx = canvas.getContext("2d");
      if (!ctx) return;
 
      let canvasWidth = (canvasParent as HTMLElement).clientWidth;
      let canvasHeight = (canvasParent as HTMLElement).clientHeight;
      canvas.width = canvasWidth;
      canvas.height = canvasHeight;
 
      let stars: {
         x: number;
         y: number;
         radius: number;
         dynamicX: number;
         dynamicY: number;
      }[] = [];
 
      const createStars = (num: number) => {
         stars = [];
 
         for (let i = 0; i < num; i++) {
            const x = randomPos(0, canvasWidth);
            const y = randomPos(0, canvasHeight);
            const radius = randomPos(5, 10);
 
            stars.push({
               x,
               y,
               radius,
               dynamicX: x / canvasWidth,
               dynamicY: y / canvasHeight,
            });
         }
      };
 
      const drawStars = () => {
         ctx.clearRect(0, 0, canvas.width, canvas.height);
 
         stars.forEach((star) => {
            ctx.beginPath();
            ctx.arc(star.x, star.y, star.radius, 0, Math.PI * 2);
            ctx.fillStyle = "white";
            ctx.fill();
         });
 
         requestAnimationFrame(drawStars);
      };
 
      const resize = () => {
         canvasWidth = (canvasParent as HTMLElement).clientWidth;
         canvasHeight = (canvasParent as HTMLElement).clientHeight;
         canvas.width = canvasWidth;
         canvas.height = canvasHeight;
 
         stars.forEach((star) => {
            star.x = star.dynamicX * canvasWidth;
            star.y = star.dynamicY * canvasHeight;
         });
      };
 
      resize();
      createStars(100);
      drawStars();
 
      window.addEventListener("resize", resize);
 
 
      return () => window.removeEventListener("resize", resize);  // 메모리 누수 방지
   }, []);
 
   return (
      <Style.Container>
         <Style.Canvas ref={canvasRef} />
      </Style.Container>
   );
};
 
export default StarCanvas;
// styled.ts
import styled from "styled-components";
 
export const Container = styled.div`
  width: 100vw;
  height: 100vh;
  background-color: black;
`;
 
export const Canvas = styled.canvas`
  position: absolute;
`;

이 코드의 역할은 검은색 바탕화면에 100개의 별을 무작위 한 위치에 찍어내고, 뷰포트가 리사이징 될 때마다 별을 같은 위치로 재배치합니다. 코드를 자세히 살펴보겠습니다.

일단 Canvas.tsx와 달라진 점은 stars 객체에 dynamicX, dynamicY 변수가 추가됐고, drawStars 함수에 코드가 추가되었으며 resize 함수가 새로 정의됐습니다.

코드의 중요한 핵심 변경 내용으로는

  1. 전 코드와 같이 createStars 함수로 각 별의 위치, 크기 등이 포함된 객체 배열을 생성해 주되, 별 위치와 뷰포트 크기에 대한 비율도 같이 저장합니다.
  2. resize 함수의 역할은 뷰포트가 리사이징 됐을 때, 캔버스 크기를 바뀐 뷰포트 사이즈에 대해 재조정합니다. 그리고 stars 객체의 x, y 값을 전에 저장했던 위치 비율을 곱해서 다시 전달합니다. 이 과정에서 별의 위치가 바뀐 뷰포트 사이즈에 알맞게 위치가 변경됩니다.
  3. drawStars 함수에 requestAnimationFrame(drawStars); 코드를 추가해 사용자의 주사율에 비례하여 drawStars를 반복 실행하게 합니다.
  4. 이벤트 리스너를 삽입하여 리사이징 될 때마다 resize 함수를 요청합니다.

이제 마지막으로 함수들을 알맞은 순서로 실행시켜 로직을 알맞게 실행하여 완성되는 겁니다. 3번 내용에서 알 수 있듯이 사실 이 코드는 별의 위치를 이동시키는 것이 아닌 프레임 별로 변경된 위치에 계속 그려내기 때문에 별을 이동시킨다는 것은 엄연히 잘못된 얘기입니다. 하지만 사용자 입장에선 브라우저 크기를 바꿔도 위치가 자동으로 바뀌는 것처럼 보이는 것이죠.


3. 마치며

제 코드가 canvas 태그를 사용하는 반응형 웹 개발자들에게 도움이 됐으면 하는 마음으로 글을 작성했습니다. 아직 저는 배울 것이 산더미인 학생이기에 잘못된 정보나 저보다 더 나은 정보가 있을 수 있습니다. 그래서 참고하는 마음으로 읽어주셨으면 합니다.

이상입니다.