개발자
류준열
canvas.toBlob을 이용하여 이미지 압축하기
AI 관상보기는 이미지를 저장하지 않고 메모리로 처리한다. 그 과정에서 이미지를 base64화 하는데 너무 용량이 크다보니 서버에서 튕겨내는 일이 비일비재했다. (413, Request Entity Too Large)
이를 해결하기 위해 찾아보다가 canvas를 이용해서 브라우저에서 직접 압축하는 방법을 알게 되었다.
canvas api를 이용해서 브라우저에서 이미지 압축
before
canvas api를 이용하기 전 쌩 base64로 이미지를 보냈을때는 4.5MB이다.
// 파일을 base64로 변환하여 직접 전송 const arrayBuffer = await selectedFile.arrayBuffer(); const base64 = Buffer.from(arrayBuffer).toString("base64"); const imageUrl = `data:${selectedFile.type};base64,${base64}`;
after
cavas로 이미지를 압축하니 135KB가 되었다.
// 이미지 압축 (800x800, 품질 0.8, JPEG) const compressedFile = await compressImage(selectedFile, { maxWidth: 800, maxHeight: 800, quality: 0.8, format: "image/jpeg", }); // 압축된 이미지를 base64로 변환 const imageUrl = await fileToBase64(compressedFile);
jpeg에서 webp로 한번 더 압축
webp로 한번 더 압축해보았다.
// 이미지 압축 (800x800, 품질 0.8, WEBP) const compressedFile = await compressImage(selectedFile, { maxWidth: 800, maxHeight: 800, quality: 0.8, // format: "image/jpeg", format: "image/webp", });
결과
이미지 약 97% 압축 (4.5MB -> 96.4KB)
전체 코드
코드는 다음과 같다.
/**
* 이미지를 압축하고 리사이징하는 유틸리티 함수들
*/
export interface CompressOptions {
maxWidth?: number;
maxHeight?: number;
quality?: number;
format?: "image/jpeg" | "image/webp";
}
/**
* 이미지 파일을 압축하고 리사이징합니다
*/
export async function compressImage(
file: File,
options: CompressOptions = {}
): Promise<File> {
const {
maxWidth = 800,
maxHeight = 800,
quality = 0.8,
format = "image/jpeg",
} = options;
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
// 비율을 유지하면서 리사이징
const { width, height } = calculateDimensions(
img.width,
img.height,
maxWidth,
maxHeight
);
canvas.width = width;
canvas.height = height;
// 이미지 그리기
ctx?.drawImage(img, 0, 0, width, height);
// 압축된 이미지를 Blob으로 변환
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error("이미지 압축 실패"));
return;
}
// Blob을 File로 변환
const compressedFile = new File([blob], `compressed_${file.name}`, {
type: format,
lastModified: Date.now(),
});
resolve(compressedFile);
},
format,
quality
);
};
img.onerror = () => reject(new Error("이미지 로드 실패"));
img.src = URL.createObjectURL(file);
});
}
/**
* 비율을 유지하면서 최대 크기에 맞게 크기 계산
*/
function calculateDimensions(
originalWidth: number,
originalHeight: number,
maxWidth: number,
maxHeight: number
): { width: number; height: number } {
let { width, height } = { width: originalWidth, height: originalHeight };
// 최대 크기를 초과하는 경우에만 리사이징
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
return { width, height };
}
/**
* 파일을 base64로 변환 (압축된 파일용)
*/
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("FileReader 결과가 문자열이 아닙니다"));
}
};
reader.onerror = () => reject(new Error("파일 읽기 실패"));
reader.readAsDataURL(file);
});
}
/**
* 이미지 파일인지 확인
*/
export function isImageFile(file: File): boolean {
return file.type.startsWith("image/");
}
/**
* 파일 크기를 사람이 읽기 쉬운 형태로 변환
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}