사용자가 이미지를 업로드하면 preview 이미지로 drag and drop할 수 있다.
최대 5장, 10mb 이하로 제한했지만 버범임이 발생하는 것을 볼 수 있다.
처음에는 Canvas API 활용해서 이미지 압축하고, jpeg 또는 png 이미지를 webp로 변환했다.
하지만 해당 작업을 메인 스레드에서 작업하다보니, 속도가 느렸다.
웹 브라우저에서 메인 스레드는 UI 렌더링, 이벤트 처리, DOM 조작 등의 작업을 담당한다. Canvas API를 활용한 이미지 압축은 데이터를 변환하는 과정에서 CPU 사용량이 증가한다. 특히 고해상도 이미지를 처리할 경우 연산 부담이 커져 UI가 멈추거나 렌더링 지연이 발생할 수 있다.
Web Worker를 사용해 이미지 압축(메인 스레드에서 실행되는 연산)을 백그라운드에서 병렬 처리함으로써 최적화할 수 있다.
Web Worker는 스크립트 연산을 웹 메인 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술이다.
사용자로부터 JPEG, PNG, WebP 등 다양한 포멧을 가진 이미지를 받는데 이를 압축 효율이 높은 WebP로 변환한다.
WebP는 2010년에 구글이 개발한 이미지 포멧으로 손실, 무손실 압축과 투명도를 모두 지원하고, GIF와 동일하게 애니메이션을 지원한다. 이미지 내에서 인접한 픽셀들은 대개 유사한 색상을 가지는 경향이 있는데, WebP는 이러한 상관관계를 활용하여 픽셀 색상을 인접 픽셀 색상에 기반하여 예측할 수 있는 효율적인 예측 알고리즘이 있다.
WebP는 2024년 12월 기준 전체 사용자의 약 97퍼를 지원하고 있고, 아직은 문제가 없기 때문에 호환성 측면에서 현재 프로젝트에서는 별다른 조치를 하고 있지 않다.
하지만 WebP를 지원하지 않는 브라우저를 사용하는 사용자가 있을 경우, 사용자로부터 원본 이미지를 받고 CDN에 이미지 요청할 때 유저의 user-agent 확인하여 webp 지원하면 변환하고 아니면 원본이미지를 보내는 방법으로 바꿔 문제를 해결하고자 한다.
/// <reference lib="webworker" />
지시자(Triple-Slash Directive) 사용window
, document
등)을 참조한다. 하지만 Web Worker는 DOM이 없고, Worker 전용 API만 사용 가능하기 때문에, TypeScript가 이를 정확히 이해하려면 Worker 전용 타입 선언이 필요하다.// 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 });
}
};
postMessage
를 통해서만 통신한다. postMessage
를 통해 객체를 Worker로 보내면 브라우저는 Structured Clone
을 사용해 객체를 복제한다. File
, Blob
, ArrayBuffer
)를 다룰 때는 효율적인 전송 방법이 필요하다.file.arrayBuffer()
를 통해 순수한 이진 데이터로 읽어낼 수 있다. 이 ArrayBuffer
를 postMessage
에 전달할 때, 두 번째 인자로 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);
};