요새 회사 적응하느라 블로그 글을 쓰지 못했다. 앞으로는 배운 것들을 계속 글로 남겨야겠다는 생각을 하게 되었다.
회사에서 들어가며 느낀 점들이 많이 있었다. 혼자 개발할 때는 신경쓰지 않아도 될 것들을 신경써야 한다는 점과 커뮤니케이션의 중요성, 동료 분들이 적극적인 아이디어를 제안하는 모습들을 보며 배울 수 있었다.
최근에는 동적인 Webp를 정적인 Webp로 표현해야하는 요구사항이 존재했었다.
이 과정에서 Blob 데이터의 메모리 관리 문제로 인해 고민을 한 결과 웹워커를 사용하여 해결했는데, 이 과정에서 배운 점들을 남기고자 한다.
[요구사항]
사용자는 동적인 Webp와 정적인 Webp 둘 다 저장 가능하다.
특정 영역에는 동적인 Webp를 정적인 Webp로 표현해야 하는 경우가 발생한다.
webp 확장자 형태의 이미지를 웹 상에서 표현하려면 <img /> 태그를 사용하면 된다. 하지만 동적으로 움직이는 webp를 첫 프레임만 표현되도록 하기 위해서는 데이터를 가공해야 한다.
[Canvas와 Blob을 사용하여 해결하자]
먼저 단순히 Webp가 여러 개의 프레임으로 구분되었는지 아닌지를 판단해서, 여러 개의 프레임으로 나뉘어진 경우에 첫 프레임을 반환하여 정적인 것처럼 보여지도록 구현을 하면 되었다.
먼저 주어진 파일이 webp 포맷인지 체크를 하는 로직이다.
export const checkIsWebpFormat = async (url: string): Promise<boolean> => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failed to fetch the file");
}
const arrayBuffer = await response.arrayBuffer();
const data = new Uint8Array(arrayBuffer);
const riff = String.fromCharCode(...data.slice(0, 4)); // "RIFF" || "GIF8"
const webp = String.fromCharCode(...data.slice(8, 12)); // "WEBP"
return (riff === "RIFF" && webp === "WEBP") || riff === "GIF8";
} catch (error) {
console.error("Error checking WebP format:", error);
return false;
}
};
`arrayBuffer()` 메서드를 통해 응답으로 넘어온 바이너리 데이터를 ArrayBuffer 형태의 데이터로 가져오며, 원시 바이너리 데이터를 저장할 수 있는 고정 크기의 버퍼(메모리 영역)를 나타낸다.
이후, ArrayBuffer를 Uint8Array로 변환하여 데이터에 접근할 수 있도록 한다. Uint8Array 형식화 배열(TypedArray)은 플랫폼의 바이트 순서를 따르는 8비트 부호 없는 정수의 배열을 나타낸다.gif 헤더를 가진 webp도 존재한다는 것을 파악하고, 이에 대한 방어코드도 추가하였다.
이제는 webp가 애니메이션을 가지고 있는지 판단하는 로직이다.
export const checkIsAnimatedWebp = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
return false;
}
const arrayBuffer = await response.arrayBuffer();
const data = new Uint8Array(arrayBuffer);
// Check for the presence of the 'ANIM' chunk in the WebP header
for (let i = 0; i < data.length - 3; i++) {
if (
data[i] === 0x41 && // A
data[i + 1] === 0x4e && // N
data[i + 2] === 0x49 && // I
data[i + 3] === 0x4d // M
) {
return true;
}
}
return false;
} catch (error) {
console.error('Error checking WebP animation:', error);
return false;
}
};
주석에도 적어두었지만 Webp 헤더에 ANIM이라는 청크가 존재하는지를 판단하여 애니메이션 webp인지 판단하였다.
마지막으로 동적인 webp의 첫 프레임만 Blob 형태의 데이터로 추출하여, 반환하는 코드이다.
export const convertAnimatedWebpToStaticWebp = async (
webpURL: string,
options?: {
imageResizeCallback?: (...args: any[]) => any;
}
): Promise<string> => {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const img = new Image();
img.crossOrigin = "anonymous";
img.src = webpURL;
img.onload = () => {
const ctx = canvas.getContext("2d");
if (ctx) {
if (options && options.imageResizeCallback) {
const { newWidth, newHeight } = options.imageResizeCallback(
img.width,
img.height
);
canvas.width = newWidth;
canvas.height = newHeight;
} else {
canvas.width = img.width;
canvas.height = img.height;
}
// ctx.drawImage(img, 0, 0);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// 캔버스 내용을 Blob으로 변환하고 URL로 생성
canvas.toBlob((blob) => {
if (blob) {
const staticWebpUrl = URL.createObjectURL(blob);
resolve(staticWebpUrl);
} else {
reject(new Error("Failed to create blob from canvas"));
}
}, "image/webp");
} else {
reject(new Error("Failed to get canvas context"));
}
};
img.onerror = () => {
reject(new Error("Failed to load WebP image"));
};
});
};
Canvas에는 동적인 webp를 정적인 webp로 표현할 수 있다는 점을 확인하여, canvas에 webp의 첫 프레임을 렌더링하고, 이 이미지를 blob으로 추출한 모습이다.
이 모든 과정을 하나로 담은 코드는 다음과 같다.
export const processWebpForStaticOutput = async (
webpURL: string
): Promise<string | null> => {
try {
const isWebp = await checkIsWebpFormat(webpURL);
console.log(webpURL, isWebp);
if (!isWebp) {
return null;
}
const isAnimatedWebp = await checkIsAnimatedWebp(webpURL);
if (isAnimatedWebp) {
// 애니메이션 WebP인 경우 첫 프레임을 추출
return convertAnimatedWebpToStaticWebp(webpURL);
} else {
return webpURL;
}
} catch (error) {
console.error("Error converting animated WebP to static WebP:", error);
return null;
}
};
const WebPProcessor: React.FC<{ webpUrl: string }> = ({ webpUrl }) => {
const [staticImageUrl, setStaticImageUrl] = useState<string | null>(null);
useEffect(() => {
setTimeout(() => {
processWebpForStaticOutput(webpUrl).then((staticWebpUrl) => {
setStaticImageUrl(staticWebpUrl);
});
}, 100);
}, [webpUrl]);
return (
<div>
{staticImageUrl ? (
<>
<img src={webpUrl} alt="Processed WebP" />
<img src={staticImageUrl} alt="Processed WebP" />
</>
) : (
<p>Loading WebP...</p>
)}
</div>
);
};
이렇게 구현을 했더니 다른 동료분들이 혹시 이미지 용량이 어느 정도인지, 수백 개의 이미지를 동시에 렌더링하게 되면 성능에 문제가 없는지 여쭤보았다.
작은 이미지를 수백 개 렌더링하는 것에는 문제가 없었으나 용량이 큰 이미지(14MB)를 한 개 렌더링을 할 때는 브라우저에 부하가 생기는 것을 확인할 수 있었다.
용량이 매우 적은 webp를 렌더링하는 것에는 시간이 오래 걸리지 않음
용량이 매우 큰 webp를 렌더링하는 것에는 시간이 오래 걸렸음 (2.5배속을 하였고, 원래는 8초 정도 걸렸다.)
이렇게 되면 사용자의 입장에서 대기 시간이 길어지기 때문에 사용자 경험 측면에서 불편함을 이야기할 것이다. 영상을 기다리는 것도 아닌 단순히 사진을 기다리는 것이기 때문에 더욱 불쾌함을 토로할 가능성이 높다.
[웹 워커를 사용해보자]
왜 이런 현상이 발생한 걸까? webp를 분석하고, 정적인 이미지로 변환하는 코드가 동기 함수이므로, 싱글 스레드인 자바스크립트가 이 함수를 처리할 때 동안 다른 함수를 실행하지 못하기 때문이라고 판단하였다.
그렇기에 나는 다른 방법을 찾았어야 했다. 예전에 나중에 공부해야겠다고 생각했던 웹 워커가 순간 떠올랐고, 웹 워커가 방법이 될 수 있다고 생각했다.
웹 워커 코드는 다음과 같다.
self.onmessage = async (event) => {
const { webpUrl } = event.data;
try {
// WebP 이미지를 가져옵니다.
const response = await fetch(webpUrl);
if (!response.ok) {
throw new Error("Failed to fetch the WebP image");
}
const blob = await response.blob();
const imgBitmap = await createImageBitmap(blob);
// OffscreenCanvas를 생성합니다.
const offscreen = new OffscreenCanvas(imgBitmap.width, imgBitmap.height);
const ctx = offscreen.getContext("2d");
if (!ctx) {
throw new Error("Failed to get OffscreenCanvas context");
}
// 첫 번째 프레임을 그립니다.
ctx.drawImage(imgBitmap, 0, 0);
// OffscreenCanvas의 내용을 Blob으로 변환합니다.
const processedBlob = await offscreen.convertToBlob({ type: "image/webp" });
// Blob을 URL로 변환하여 메인 스레드에 전달합니다.
const staticWebpUrl = URL.createObjectURL(processedBlob);
self.postMessage({ staticWebpUrl });
} catch (error: any) {
// 에러 발생 시 에러 메시지를 메인 스레드에 전달합니다.
self.postMessage({ error: error.message });
}
};
메인 스레드에서 이미지 url을 보내주면, 이 url을 기반으로 각각의 웹 워커가 코드를 정적 이미지 blob으로 변환한 url을 메인 스레드에게 전달한다.
import React, { useEffect, useState } from "react";
import WebWorker from "./utils/webworker.ts?worker";
const WebPProcessor: React.FC<{ webpUrl: string }> = ({ webpUrl }) => {
const [staticImageUrl, setStaticImageUrl] = useState<string | null>(null);
useEffect(() => {
const worker = new WebWorker();
worker.postMessage({ webpUrl });
worker.onmessage = (event) => {
const { staticWebpUrl, error } = event.data;
if (error) {
console.error("Web Worker Error:", error);
} else {
setStaticImageUrl(staticWebpUrl);
}
};
return () => {
worker.terminate();
};
}, [webpUrl]);
return (
<div>
{staticImageUrl ? (
<>
<img src={webpUrl} alt="Processed WebP" />
<img src={staticImageUrl} alt="Processed WebP" />
</>
) : (
<p>Loading WebP...</p>
)}
</div>
);
};
export default WebPProcessor;
컴포넌트에서는 이러한 url을 받아서 렌더링하도록 구현하기만 하면 된다.
1개의 대용량 webp 테스트를 해봤을 때 이전 영상에 비해 배우 속도가 매우 빨라졌음을 한 눈으로도 확인할 수 있다.
엄청 빨라졌다..!
300개의 대용량 webp 테스트를 한 결과이다. 생각보다도 빨라서 만든 나도 당황했다.
300개도 문제가 없다.