Uket 서비스 MVP 개발이 완료되어, 리팩토링 및 최적화에 관심을 두고 있습니다. 최적화라고 하면 가장 일반적으로 이미지 최적화가 떠오르기도 하고, 기존에 네트워크의 이미지 요청에서 크기가 컸기 때문에 이미지 최적화를 먼저 적용했습니다.
일단, 사용하고 있는 이미지의 유형은 2가지입니다.
- 외부 API 요청으로 받아온 이미지
- 로컬 이미지
WEBP or AVIF
차세대 이미지 형식으로는 avif, webp가 있습니다.
avif는 webp보다 높은 압축 효율성과 적은 손실률을 보여주지만, 미지원 브라우저가 있을 수 있습니다.
그에 반해 webp는 압축 효율성은 낮지만 대부분의 브라우저에서 지원하는 형식입니다. 압축 효율성 측면에서는 JPEG 기준, webp는 20~30% 감소하고 avif는 최대 50% 감소한다고 합니다.
두 방식 모두 적용해봤는데, 압축률이 크게 유의미하게 차이가 나지 않아서 브라우저 호환성을 중점으로 고려하여 webp로 결정했습니다.
PNG to webP
with Canvas
처음에는 플러그인 없이 canvas 객체를 사용해서 변환하려고 했습니다.
const convert = async (source: any) => {
let image = (await loadImage(source)) as any;
if (typeof window.createImageBitmap === "function") {
image = await createImageBitmap(image);
}
let canvas = new OffscreenCanvas(image.width, image.height);
let context = canvas.getContext("2d");
context?.drawImage(image, 0, 0);
let result = await canvas.convertToBlob({ type: "image/webp" });
image.close();
return result;
};
const loadImage = (url: any) => {
return new Promise((res, rej) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = url;
img.onload = () => res(img);
img.onerror = rej;
});
};
// src는 File, Blob, string 등이 될 수 있다.
const webpBlob = await convert(src);
const imageUrl = URL.createObjectURL(webpBlob);
// ... 실제 사용: <img src={imageUrl} ... />
canvas의 문제점
해당 코드는 webp로 변환은 잘 되지만, webp로 변환하는 과정에서 네트워크 요청이 한 번 더 발생하고 이로 인해 이미지를 보여주기까지 시간이 더 걸렸습니다. (로컬, 외부 API 이미지 모두 해당)
이미지 최적화를 하는 목적이 결국 사용자에게 더 빠른 화면을 보여주기 위함인데, 원본 이미지를 먼저 받고 변환을 시도하기 때문에 프론트엔드 측에서 변환하는 과정이 유의미하지 않다고 판단했습니다. (이 부분은 단순하게 서버 측에서 webp로 제공하는 방법이 가장 효율적이라는 의견을 보았습니다.)
그래서 canvas를 사용하지 않고, 플러그인을 사용하여 이미지를 압축 및 변환하기로 결정했습니다.
이때 이미지 최적화는 빌드 타임에 수행되기 때문에, 런타임에 받아오는 외부 이미지는 최적화가 불가능하여 로컬 이미지만 최적화했습니다.
with Plugin
vite-plugin-imagemin을 사용해서 다음의 과정을 거치도록 변경했습니다.
로컬 PNG 이미지 -> PNG 압축 -> WebP 변환
// vite.config.ts
import { defineConfig } from "vite";
import imageminWebp from "imagemin-webp";
import imageminPngQuant from "imagemin-pngquant";
import viteImagemin from "@vheemstra/vite-plugin-imagemin";
export default defineConfig({
plugins: [
react(),
generouted(),
mkcert(),
viteImagemin({
plugins: {
png: imageminPngQuant(),
},
makeWebp: {
plugins: {
png: imageminWebp(),
},
skipIfLargerThan: "original",
},
}),
],
// …
});
파일 크기는 약 74% 정도 압축 및 변환이 되었습니다. (변환된 이미지는 dist
폴더에 저장됨)
변환만 하면 적용이 된 걸까?
프로덕션 환경에서 로컬 이미지의 확장자를 살펴보니 변환을 했음에도 webp
가 아닌 png
를 그대로 사용하고 있었습니다.
해당 문제는 로컬 이미지를 사용할 때, 환경을 고려하지 않고 다음과 같이 png
확장자를 강제하여 사용하고 있기 때문에 발생하는 것으로 확인 및 추측하고 있습니다.
import LogoImage from '/logo.png';
해결 방법
따라서, 이미지 변환 외에도 추가적으로 환경(development, production
)을 구별하여 png
를 그대로 사용할지 webp
를 사용할지 결정하는 방식을 고려했습니다.
여러 영역에서 이미지를 사용하기 때문에, 확장성 및 유지보수를 고려한 Image
컴포넌트를 생성했습니다.
import React from "react";
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {}
const Image = (props: ImageProps) => {
const { src, ...rest } = props;
const isExternalImage =
src!.startsWith("https") ||
src!.startsWith("http") ||
src!.startsWith("blob");
const imgUrl =
import.meta.env.MODE === "development"
? src
: isExternalImage
? src
: `${src}.webp`;
const imgSrc = new URL(imgUrl || src!, import.meta.url).href;
return <img src={imgSrc} {...rest} />;
};
export default Image;
Image
컴포넌트를 구현할 때 고려한 것은 다음과 같습니다.
<img>
태그의 attributes를 그대로 사용할 수 있어야 한다.- 개발(development) 환경 or 배포(production) 환경을 구분해야 한다.
- 이미지의 원천을 구분해야 한다. (로컬 or 외부 API)
어떤 환경인지는 환경변수(import.meta.env.MODE
)를 사용하여 처리합니다.
먼저, 개발 환경에서는 전달된 src
를 이미지의 URL로 그대로 사용합니다.즉, png
형식의 이미지를 사용합니다.
webp
가 아닌 png
형식을 사용한 이유는, 이미지 압축 및 변환은 반드시 빌드가 되어야하기 때문입니다. 이는 새로운 이미지가 필요한 경우 public
폴더에 이미지를 추가하고 매번 빌드를 해야 함을 의미합니다.
개발 환경에 있어서는 이 과정을 거치는 것이 불필요하다고 판단하여, 원본 png
형식의 이미지를 사용합니다.
프로덕션 환경에서의 이미지의 원천은 요청 URL의 프로토콜로 판단하여 처리합니다. 그리고 다음의 2 가지를 고려해야 합니다.
- 로컬 이미지를 사용하는가
- 외부 API에서 이미지를 받아와 사용하는가
이를 구별하기 위해서, src
이 prop을 확인하는 isExternalImage
함수를 생성했습니다. 해당 함수는 src
가 https, http, blob
프로토콜을 포함하는지 확인하여 외부 이미지 URL 인지 로컬 이미지 경로인지 파악합니다.
(이 과정이 생략되면, 외부 이미지의 주소가 https://~~~.webp
로 적용되기 때문에 이미지를 불러올 수가 없습니다.)
마무리
기존과 다르게 webp
형식의 이미지를 정상적으로 불러오는 모습입니다.
크기는 약 최대 8배 정도 감소되었습니다.
요청 시간도 감소되었으나 너무 미세한 차이이고, 매 측정마다 달라져서 크게 유의미한 결과인지는 잘 모르겠습니다.