
위와 같은 이미지 엘리먼트에 다음과 같이 해상도가 높은 이미지 파일을 업로드해본다고 가정해보겠습니다.

실제로 화면에 보여지는 크기에 비해 실제 해상도는 10배나 큽니다.
이런 이미지가 그대로 서버에 업로드되면 서버의 저장공간도 금방 차고, 클라이언트 측에서는 이미지를 로드하는 시간이 길어질 것입니다.
이번엔 이미지 최적화를 통해 이러한 문제를 해결한 과정을 공유하고자 합니다.
우선 이미지 최적화 로직 코드부터 보겠습니다.
export const optimizeImage = async (file: File): Promise<File> => {
const reader = new FileReader();
const dataUrl: string = await new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(null);
// File을 Base64 로 인코딩
reader.readAsDataURL(file);
});
if (!dataUrl) throw new Error("File 읽기 실패");
// 이미지 객체 생성 및 로드
const img = new Image();
img.src = dataUrl;
await img.decode();
// 캔버스 설정
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas context를 가져올 수 없음");
// 비율 유지하며 크기 조정
const TARGET_WIDTH = 590;
const TARGET_HEIGHT = 331.88;
let originalWidth = img.width;
let originalHeight = img.height;
const originalAspectRatio = originalWidth / originalHeight;
let newHeight = TARGET_HEIGHT;
let newWidth = originalAspectRatio * newHeight;
// 비율에 맞춘 width 가 TARGET_WIDTH 보다 클 경우 TARGET_HEIGHT에 다시 한번 맞추기
if (newWidth > TARGET_WIDTH) {
newWidth = TARGET_WIDTH;
newHeight = newWidth / originalAspectRatio;
}
canvas.width = TARGET_WIDTH;
canvas.height = TARGET_HEIGHT;
// 배경을 검은색으로 채움
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 이미지 중앙 배치
const offsetX = (TARGET_WIDTH - newWidth) / 2;
const offsetY = (TARGET_HEIGHT - newHeight) / 2;
ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
// Blob 변환
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/webp", 0.8)
);
if (!blob) throw new Error("Blob 변환 실패");
// 최적화된 File 객체 생성
return new File([blob], "optimized.webp", {
type: "image/webp",
lastModified: Date.now(),
});
};
이제 위 코드를 통해 어떻게 최적화가 이루어지는지 차근차근 알아봅시다.
export const optimizeImage = async (file: File): Promise<File> => {
const reader = new FileReader();
const dataUrl: string = await new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(null);
// File을 Base64 로 인코딩
reader.readAsDataURL(file);
});
if (!dataUrl) throw new Error("File 읽기 실패");
// 이미지 객체 생성 및 로드
const img = new Image();
img.src = dataUrl;
await img.decode();
// ...
FileReader API를 이용하면 비동기적으로 파일의 내용을 읽어올 수 있습니다. FileReader 인스턴스의 readAsDataURL메서드는 File 이나 Blob 을 읽어들여 result 속성에 Base64로 인코딩된 문자열 데이터를 담습니다. 성공적으로 읽어들였다면 onload 이벤트 리스너에 등록한 이벤트 핸들러가 실행됩니다.
readAsDataUrl 메서드는 비동기적으로 동작하기 때문에 Promise 로 감싼 뒤 await 을 통해 파일을 다 읽을 때 까지 기다려줍니다.
파일을 다 읽어들였으면 dataUrl 에 값이 할당되고 img 엘리먼트의 src 속성에 dataUrl 을 할당해줍니다.
decode 메서드는 이미지가 모두 디코딩되고, DOM에 추가될 준비가 완료되면 resolve 되는 Promise를 반환한다. 이를 await 해주지 않으면 이미지가 로딩되는 동안 이미 렌더링이 수행돼서 이미지가 화면에 표시되지 않습니다.
// 캔버스 설정
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas context를 가져올 수 없음");
캔버스 엘리먼트를 생성하고 2d 작업을 수행할 것이기 때문에
CanvasRenderingContext2D 인스턴스를 얻어줍니다.
캔버스에 그릴 이미지의 크기를 조정하는 부분입니다.
// 비율 유지하며 크기 조정
const TARGET_WIDTH = 이미지 너비;
const TARGET_HEIGHT = 이미지 높이;
let originalWidth = img.width;
let originalHeight = img.height;
const originalAspectRatio = originalWidth / originalHeight;
let newHeight = TARGET_HEIGHT;
let newWidth = originalAspectRatio * newHeight;
// 비율에 맞춘 width 가 TARGET_WIDTH 보다 클 경우 TARGET_HEIGHT에 다시 한번 맞추기
if (newWidth > TARGET_WIDTH) {
newWidth = TARGET_WIDTH;
newHeight = newWidth / originalAspectRatio;
}
let originalWidth = img.width;
let originalHeight = img.height;
const originalAspectRatio = originalWidth / originalHeight;
TARGET_WIDTH, TARGET_HEIGHT, originalAspectRatio 를 이용해 원본 이미지의 비율을 유지하면서 이미지의 사이즈를 조정해줍니다.
TARGET_HEIGHT 에 높이를 맞춥니다.
원본 이미지 비율에 맞게 너비를 조정합니다.

만약 이때 이미지의 너비가 TARGET_WIDTH보다 크다면 이미지의 너비를 TARGET_WIDTH 로 설정합니다.

그리고 너비가 줄어들었으니 원본 이미지 비율에 맞게 이미지의 높이를 조정합니다.

이렇게 하면 원본 이미지의 비율을 유지하면서 이미지의 사이즈를 줄일 수 있습니다.
이제 이미지 사이즈를 조정했으니 캔버스에 해당 이미지를 그려줍시다.
canvas.width = TARGET_WIDTH;
canvas.height = TARGET_HEIGHT;
// 배경을 검은색으로 채움
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 이미지 중앙 배치
const offsetX = (TARGET_WIDTH - newWidth) / 2;
const offsetY = (TARGET_HEIGHT - newHeight) / 2;
ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
캔버스의 너비와 높이를 아까의 TARGET_WIDTH, TARGET_HEIGHT 로 설정해줍니다.
그리고 배경을 검은색으로 채우고 이미지를 캔버스 중앙에 위치시키기 위해 offsetX 와 offsetY 를 구해줍니다. 그런다음 해당 지점부터 시작해서 이미지의 크기만큼의 영역에 이미지를 그려줍니다.
그럼 다음과 같이 빨간색 테두리(캔버스)중앙에 이미지가 위치하게 되고, 나머지 영역은 검정색으로 채워져 보이게 됩니다.

// Blob 변환
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/webp", 0.8));
if (!blob) throw new Error("Blob 변환 실패");
canvas.toBlob() 을 사용해 캔버스에 포함된 이미지를 바탕으로 WebP 형식의 Blob 객체를 생성합니다.
toBlob 메서드는 image/jpeg 또는 image/webp 처럼 손실 압축을 지원하는 파일 포멧의 이미지를 생성할 경우 3번째 인수로 0 ~ 1 사이의 값을 전달하여 품질을 조정할 수 있습니다. 3번째 인수로 아무 값도 전달하지 않을 경우 브라우저의 기본 설정에 따라 기본값이 달라집니다. (크롬 브라우저의 경우 0.8 이 기본값)
위 코드에서는 품질을 0.8로 하여 손실 압축(lossy compression)을 진행합니다. 손실 압축은 간단히 말해 인간이 지각하기 힘든 범위의 데이터를 버리고 압축하는 과정을 말합니다. 이로 인해 데이터가 손실되므로 원본 데이터로 복원할 수는 없지만 높은 압축률을 보여줍니다.
저희가 진행했던 프로젝트는 정확한 원본 이미지가 필요하진 않았기 때문에 손실 압축을 진행했습니다.
손실 압축을 지원하는 포멧은 WebP 형식과 JPEG 형식이 있는데, WebP형식이 JPEG에 비해 동일한 화질 대비 25~34% 정도 적은 용량으로 압축할 수 있기 때문에 WebP 형식으로 압축해줍니다.
// 최적화된 File 객체 생성
return new File([blob], "optimized.webp", {
type: "image/webp",
lastModified: Date.now(),
});
압축한 WebP형식의 blob을 파일로 변환하면 최적화가 완료됩니다.
파일 업로드 시 크기 차이가 얼마나 날지 한 번 비교를 해봤습니다.

이미지의 품질이 약간 떨어진다는 단점이 있지만 그를 상쇄시킬만큼 이미지 파일의 크기가 크게 압축되었음을 볼 수 있습니다.
(5841KB -> 11KB)
이미지 최적화 과정을 다시 한번 간단하게 정리해보겠습니다.
canvas.toBlob() 메서드를 통해 blob 데이터로 변환하는 과정에서 품질을 0.8로 조정함으로써 이미지 크기를 더욱 줄일 수 있었습니다.