OffScreenCanvas로 유저 경험을 향상시키자 Feat. Web Worker

hoonsbory·2023년 5월 15일
2
post-thumbnail

OffScreenCanvas란?

간단하게 요약하자면 OffScreenCanvas는 메인스레드가 아닌 백그라운드 스레드에서 canvas를 다루는 기술입니다.

그렇다면 백그라운드 스레드에서 canvas를 다루는 게 무슨 이점이 있을까요?

canvas는 그래픽을 다루기 때문에 대체로 많은 연산을 필요로 합니다.

이미지에 필터를 적용하거나 애니메이션을 구현하는 등의 기능은 매우 많은 픽셀에 대한 계산이 이루어지기 때문이죠.

계산이 많으면 무슨 일이 일어날까요? 바로 메인 스레드가 차단이 됩니다.

쉽게 말해 canvas의 그리기를 기다리는 동안 유저는 아무것도 할 수가 없다는 거죠. (무한 루프에 빠져 브라우저가 멈춘 경험이 다들 있을겁니다.)

반대의 경우도 있습니다.

canvas의 애니메이션이 진행되는 동안 메인스레드에서 무거운 연산이 진행되면, canvas의 애니메이션이 멈출 수 있습니다.

그에 반해 offScreenCanvas는 메인스레드가 아닌 백그라운드 스레드(web worker)에서 렌더링하여 스레드 차단에 대응하기 때문에 유저에게 좋은 경험을 줄 수 있습니다.

offScreenCanvas를 사용하기전에 우선 web worker 에 대한 이해가 필요합니다.


Web Worker

web worker는 백그라운드 스레드에서 작동하기 때문에 전역 객체가 window가 아닙니다.

별도의 DedicatedWorkerGlobalScope라는 객체를 전역으로 가집니다.
이런 특징 때문에 web worker에서의 작업은 기존에 하던 것과는 조금 달라질 수 있습니다.

위에서 말한 offscreenCanvas의 장점은 사실 web worker의 장점이라고 볼 수 있습니다.

바로 무거운 연산을 백그라운드에서 실행하여 메인 스레드의 차단을 최소화 하는 것입니다.

그럼 이제 어떤식으로 web worker와 offscreencanvas를 사용하는지 예제를 통해 알아보겠습니다.

OffScreenCanvas 사용해보기

첫번째로 알아볼 예제는 유저가 업로드한 이미지를 offscreenCanvas로 그리는 로직입니다.

index.html

<!DOCTYPE html>
<html>
  <head>
    <title>Image Drawing</title>
  </head>
  <body>
    <input type="file" id="fileInput" />
    <canvas id="canvas" />
    <script>
      const worker = new Worker('worker.js');

      const handleFile = (event) => {
        const file = event.target.files[0];

        const reader = new FileReader();
        reader.onload = (event) => {
          const canvas = document.getElementById('canvas');
          const offscreenCanvas = canvas.transferControlToOffscreen();
          worker.postMessage(
            { canvas: offscreenCanvas, src: event.target.result },
            [offscreenCanvas],
          );
          document.body.appendChild(canvas);
        };
        reader.readAsDataURL(file);
      }

      const fileInput = document.getElementById('fileInput');
      fileInput.addEventListener('change', handleFile);
    </script>
  </body>
</html>

우선 worker객체를 만들어야합니다.

root디렉토리에 worker.js를 만들고 생성자를 통해 객체를 생성합니다.

그 다음은 유저의 이미지를 핸들링하는 change 이벤트 함수 handleFile입니다.

이미지가 로드되면 canvas엘리먼트에서 offscreen 객체를 생성한 후에 postMessage함수로 worker에 데이터를 전송합니다.

worker.postMessage({ canvas: offscreenCanvas, src: event.target.result }, [offscreenCanvas]);

첫번째 인수는 보내고 싶은 데이터고, 두번째 인수소유권을 백그라운드 스레드로 이전할 transferble한 객체를 배열 형태로 전송합니다.
(당연히 worker에서도 메인 스레드로 데이터를 전송할 수 있습니다.)

이렇게 되면 web worker에 canvas와 이미지 src를 전송한 것입니다.

전송을 했으니 이제 web worker에서 데이터를 수신하는 코드를 보겠습니다.

self.onmessage = async function (event) {
  const { src, canvas } = event.data;
};

web worker에서는 이런식으로 data를 수신합니다.
메인스레드에서 postMessage를 실행하면 web worker의 onmessage가 트리거되는 것이죠.

받아온 데이터를 토대로 이미지를 canvas에 렌더링 해보겠습니다.

worker.js

self.onmessage = function (event) {
  const { src, canvas } = event.data;
  
  const image = new Image();
  
  image.onload = (e) => {
  	const offscreenContext = canvas.getContext('2d');
  	canvas.width = image.width;
  	canvas.height = image.height;
    offscreenContext.drawImage(image, 0, 0);
  }
  
  image.src = src;
};

※ offscreenCanvas는 web worker에서부터 기존 canvas와 같이 사용하면 됩니다.

위 로직은 2d context에 image를 draw하는 방법입니다.

하지만 이 방법은 작동하지 않습니다.

이유는 web worker에서는 element(new Image)를 만들 수 없기 때문이죠. 우리가 흔히 아는 document 객체가 없습니다.

그렇기 때문에 조금 다른 방식으로 image를 핸들링해야 합니다.

self.onmessage = async function (event) {
  const { src, canvas } = event.data;
  const response = await fetch(src);
  const blob = await response.blob();
  const imageBitmap = await createImageBitmap(blob);
  const offscreenContext = canvas.getContext('2d');
  canvas.width = imageBitmap.width;
  canvas.height = imageBitmap.height;

  offscreenContext.drawImage(imageBitmap, 0, 0);
};

넘겨받은 base64데이터를 blob화하여 imageBitmap객체를 생성하고, canvas로 그려내는 방법이 있습니다.

이렇게 되면 web worker에서 이미지를 그리는 것이 가능합니다.

이제 사용법은 알았고, 어떤 상황에 유용한지 예제로 알아보겠습니다.


위 예제는 단순히 이미지를 렌더링하기 때문에 메인스레드가 차단될 일이 없습니다.

스레드에 부하를 주기 위해 복잡한 연산을 추가하겠습니다.

worker.js

self.onmessage = async function (event) {
  const { src, canvas } = event.data;
  const response = await fetch(src);
  const blob = await response.blob();
  const imageBitmap = await createImageBitmap(blob);
  const offscreenContext = canvas.getContext('2d');
  canvas.width = imageBitmap.width;
  canvas.height = imageBitmap.height;

  //복잡한 연산
  const time = new Date();
  while (new Date() - time <= 10000) {}
  
  offscreenContext.drawImage(imageBitmap, 0, 0);

};

복잡한 연산은 while문을 10초간 실행하여 부하를 주는 것으로 대체하겠습니다.

이렇게 되면 복잡한 연산을 10초간 수행해야 이미지가 그려집니다.

지금 이 로직이 web worker가 아닌 메인 스레드에서 실행되었다면, 10초동안 유저는 아무것도 하지 못하겠죠.

web worker를 사용하면 백그라운드 스레드에 10초간 부하를 주는 것이기 때문에, 유저는 기다리는 동안 다른 UI와 상호작용이 가능합니다.



이런식으로 offscreenCanvas와 web worker를 활용하면 아무리 무거운 계산이 있어도 메인스레드 차단없이 원활하게 서비스할 수 있게 됩니다.

canvas가 아니더라도 무거운 계산으로 인해 메인 스레드의 블로킹이 관찰된다면 web worker를 활용해보길 추천드립니다.

추가적으로 web workermain thread간의 데이터 전송 비용도 있기 때문에, 단순한 계산일 경우 오히려 성능저하를 초래할 수도 있습니다.
퍼포먼스를 측정하여 web worker도입해야하는지 면밀히 검토하는 것이 좋습니다.

0개의 댓글