Web Worker 사용해 이미지 압축 및 포멧 변환 최적화

JunSeok·6일 전
0

지식 기록

목록 보기
16/16

문제

사용자가 이미지를 업로드하면 preview 이미지로 drag and drop할 수 있다.
최대 5장, 10mb 이하로 제한했지만 버범임이 발생하는 것을 볼 수 있다.

최적화 이전

처음에는 Canvas API 활용해서 이미지 압축하고, jpeg 또는 png 이미지를 webp로 변환했다.

하지만 해당 작업을 메인 스레드에서 작업하다보니, 속도가 느렸다.

웹 브라우저에서 메인 스레드는 UI 렌더링, 이벤트 처리, DOM 조작 등의 작업을 담당한다. Canvas API를 활용한 이미지 압축은 데이터를 변환하는 과정에서 CPU 사용량이 증가한다. 특히 고해상도 이미지를 처리할 경우 연산 부담이 커져 UI가 멈추거나 렌더링 지연이 발생할 수 있다.

최적화 이후

Web Worker를 사용해 이미지 압축(메인 스레드에서 실행되는 연산)을 백그라운드에서 병렬 처리함으로써 최적화할 수 있다.

Web Worker는 스크립트 연산을 웹 메인 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술이다.

Web Worker 종류

Dedicated Worker

  • 메인 스레드와 독립적으로 실행.
  • 특정 페이지(스코프 내)에서만 사용 가능.
  • postMessage로 메인 스레드와 통신.
  • 이미지 압축, 데이터 처리, CPU 집약적 작업.

Shared Worker

  • 여러 브라우저 탭에서 공유 가능.
  • 같은 도메인 내에서만 사용 가능.
  • MessagePort로 통신.
  • 여러 탭에서 동일한 상태 유지.

Service Worker

  • 백그라운드에서 실행되며 네트워크 요청 가로챌 수 있음.
  • 브라우저 종료 후에도 동작 가능.
  • HTTPS 환경에서만 작동
  • 캐싱, 오프라인 지원, API 요청 가로채기 (MSW)

Dedicated Worker 특징

  • 별도의 스레드에서 동작하며 메인 스레드와 독립적으로 실행된다.
  • 비동기적으로 동작하여 메인 스레드가 작업 완료를 기다리지 않아도 된다.
  • DOM에 직접 접근할 수 없다. 대신 메인 스레드에서 DOM 작업을 처리해야 한다.
  • Web Worker와 메인 스레드는 postMessage와 onmessage 이벤트 핸들러를 통해 데이터를 교환한다.

과정

포멧 변환

사용자로부터 JPEG, PNG, WebP 등 다양한 포멧을 가진 이미지를 받는데 이를 압축 효율이 높은 WebP로 변환한다.

WebP는 2010년에 구글이 개발한 이미지 포멧으로 손실, 무손실 압축과 투명도를 모두 지원하고, GIF와 동일하게 애니메이션을 지원한다. 이미지 내에서 인접한 픽셀들은 대개 유사한 색상을 가지는 경향이 있는데, WebP는 이러한 상관관계를 활용하여 픽셀 색상을 인접 픽셀 색상에 기반하여 예측할 수 있는 효율적인 예측 알고리즘이 있다.

WebP는 2024년 12월 기준 전체 사용자의 약 97퍼를 지원하고 있고, 아직은 문제가 없기 때문에 호환성 측면에서 현재 프로젝트에서는 별다른 조치를 하고 있지 않다.

하지만 WebP를 지원하지 않는 브라우저를 사용하는 사용자가 있을 경우, 사용자로부터 원본 이미지를 받고 CDN에 이미지 요청할 때 유저의 user-agent 확인하여 webp 지원하면 변환하고 아니면 원본이미지를 보내는 방법으로 바꿔 문제를 해결하고자 한다.

worker script 작성

  • /// <reference lib="webworker" /> 지시자(Triple-Slash Directive) 사용
    • 타입스크립트 환경에서 Web Worker 파일 작성할 때 사용한다.
    • 기본적으로 타입스크립트는 브라우저 환경에서 사용되는 DOM API 타입(window, document 등)을 참조한다. 하지만 Web Worker는 DOM이 없고, Worker 전용 API만 사용 가능하기 때문에, TypeScript가 이를 정확히 이해하려면 Worker 전용 타입 선언이 필요하다.
  • OffscreenCanvas
    • Web Worker에서는 document, window와 같은 DOM API에 직접 접근할 수 없다.
    • OffscreenCanvas는 이러한 제약을 해결하기 위해 도입된 API로, 메인 스레드가 아닌 Worker 스레드에서 Canvas 렌더링을 가능하게 한다.
// imageCompressionWorker.ts

/// <reference lib="webworker" />

import { toast } from 'sonner';

interface WorkerMessage {
  fileData: ArrayBuffer;
  fileName: string;
}

self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
  const { fileData, fileName } = e.data;

  try {
    const blob = new Blob([fileData]);
    const imageBitmap = await createImageBitmap(blob);

    const maxWidth = imageBitmap.width > 750 ? 750 : imageBitmap.width;
    const scale = maxWidth / imageBitmap.width;
    const targetWidth = maxWidth;
    const targetHeight = imageBitmap.height * scale;

    const offscreen = new OffscreenCanvas(targetWidth, targetHeight);
    const ctx = offscreen.getContext('2d');

    if (!ctx) {
      toast.error('이미지 업로드에 실패했습니다.');
      return;
    }

    ctx.drawImage(imageBitmap, 0, 0, targetWidth, targetHeight);

    // quality 설정
    const quality = 0.9;
    const webpBlob = await offscreen.convertToBlob({
      type: 'image/webp',
      quality
    });

    // 변환된 Blob을 소유권 이전 형태로 메인 스레드로 전송
    const arrayBuffer = await webpBlob.arrayBuffer();
    self.postMessage({ arrayBuffer, fileName }, [arrayBuffer]);
  } catch (error) {
    // 에러 발생 시 메인 스레드에 알림
    self.postMessage({ error: (error as Error).message });
  }
};

이미지 압축 및 포멧 변환 함수 작성

  • promise를 리턴해서 여러 개의 이미지를 병렬 처리하도록 한다.
  • ArrayBuffer 사용
    • 메인 스레드와 Worker는 별개의 실행 환경을 갖는다. 두 환경은 서로 전역 변수를 공유하거나 DOM 객체를 직접 주고받을 수 없으며, postMessage를 통해서만 통신한다. postMessage를 통해 객체를 Worker로 보내면 브라우저는 Structured Clone을 사용해 객체를 복제한다.
    • 단순 숫자나 문자열, JSON으로 표현 가능한 객체는 쉽게 복제되지만, 대용량 바이너리 데이터(File, Blob, ArrayBuffer)를 다룰 때는 효율적인 전송 방법이 필요하다.
    • File 객체는 브라우저에서 입력받은 파일이며, 메타데이터(파일명, 수정 시간, 타입)와 실제 바이너리 데이터가 함께 담겨 있다. 이를 Worker에 바로 넘길 수도 있지만, Worker 쪽으로 복사(copy)되는 형태로 전달되어 대용량 파일일수록 복사 과정이 비용이 많이 들 수 있다.
    • ArrayBuffer 객체는 순수 바이너리 데이터를 담는 컨테이너이다. 어떤 파일이든 file.arrayBuffer()를 통해 순수한 이진 데이터로 읽어낼 수 있다. 이 ArrayBufferpostMessage에 전달할 때, 두 번째 인자로 ArrayBuffer를 Transferable로 지정하면, 복사가 아닌 소유권 이전(transfer)이 일어난다.
    • 소유권 이전이란 메모리를 복사하지 않고, 메인 스레드에 있던 ArrayBuffer의 소유권을 Worker에게 넘겨버리는 것이다. 이로써 메인 스레드 쪽 arrayBuffer는 더 이상 유효하지 않고(길이 0짜리로 보이거나 사용 불가), Worker 측에서 해당 바이너리 데이터를 바로 사용할 수 있게 된다.
// compressAndConvertToWebP.ts
import { toast } from 'sonner';

export const compressAndConvertToWebP = async (file: File): Promise<File> => {
  try {
    const fileArrayBuffer = await file.arrayBuffer();

    const worker = new Worker(
      new URL('../lib/imageCompressionWorker.ts', import.meta.url),
      { type: 'module' }
    );

    return new Promise<File>((resolve, reject) => {
      worker.onmessage = (e: MessageEvent) => {
        const { arrayBuffer, fileName } = e.data;

        const blob = new Blob([arrayBuffer], { type: 'image/webp' });
        const webpFile = new File([blob], fileName, { type: 'image/webp' });
        resolve(webpFile);
        worker.terminate();
      };

      worker.onerror = (err) => {
        toast.error('이미지 압축 worker error!');
        reject(err);
        worker.terminate();
      };

      // 소유권 이전 형태로 work로 전송.
      worker.postMessage({ fileData: fileArrayBuffer, fileName: file.name }, [
        fileArrayBuffer
      ]);
    });
  } catch {
    toast.error('이미지를 읽지 못했습니다.');
    throw new Error('이미지를 읽지 못했습니다.');
  }
};

이미지 병렬 처리

이미지 압축 및 포멧 변환 작업을 병렬 처리함으로써 처리 속도를 유의미하게 개선할 수 있다.

const addImages = async (newFiles: File[]) => {
    const compressedAllFiles = await Promise.all(
      newFiles.map(async (file) => {
        const compressed = await compressAndConvertToWebP(file);
        return compressed;
      })
    );
    setState(compressedAllFiles);
  };

결과

성능 개선

버벅임 제거

profile
최선을 다한다는 것은 할 수 있는 한 가장 핵심을 향한다는 것

0개의 댓글

관련 채용 정보