JavaScript로 이미지 리사이징(압축) 하기

김병훈·2024년 4월 14일
1
post-thumbnail

맥락

인스타그램 레이아웃의 모바일 청첩장에는 스토리/게시물에 이미지가 필수였다.
이미지의 용량이 크면 청첩장을 만들 때에도 사용자 경험이 좋지 않고, 청첩장을 볼 때에도 경험이 좋지 않을 것이기에 이미지의 용량을 줄이는 과정이 필요했다.

이미지 압축을 어디에서 해야 할까?

프로젝트는 NextJS와 supabase로만 구성되어 있기에 이미지 압축을 어디에서 진행할 것인지에 대한 선택지는 두개 였다.
1. 클라이언트
2. 서버(NextJS)

용량이 큰 이미지가 네트워크 통신을 통해 전달될 때의 시간을 줄이고 싶었던 것이기 때문에 클라이언트에서 이미지 업로드 직후 '이미지 압축' 작업을 하고, 이후에 압축한 이미지를 서버로 전달하도록 하고자 했다.

이미지 압축은 어떻게 할까?

어떻게 이미지의 용량을 줄일 수 있을까?
1. 확장자를 변경한다.
2. 이미지 크기를 줄인다.

이미지 확장자에 따른 차이

ChatGPT에게 이미지 확장자에 따른 차이를 물어봤고, WebP 방식으로 결정했다.


이미지 포맷마다 고유의 특성과 압축 방식을 가지고 있으며, 이는 파일 크기와 이미지 품질에 직접적인 영향을 미칩니다.
주로 고려할 수 있는 확장자는 JPEG, PNG, WebP, AVIF입니다.

  1. JPEG
    손실 압축 방식
    투명도 미지원

  2. PNG
    비손실 압축 방식
    투명도 지원

  3. WebP
    손실 압축 방식 (JPEG에 비해 높은 압축 효율)
    투명도 지원
    브라우저 호환성 이슈 (대부분의 현대 브라우저는 가능)

  4. AVIF
    손실, 비손실 압축 방식 (WebP에 비해 높은 압축 효율)
    투명도 지원
    브라우저 호환성 이슈 (WebP에 비해 지원하는 브라우저가 적음)

  • 선택 가이드
    웹 호환성 중요: JPEG 또는 PNG
    호환성과 압축률의 균형: WebP
    최고의 압축률: AVIF

해보자

압축 로직 플로우

input=file 에 파일을 올렸을 때, 이미지 압축을 수행한다.
이미지 압축은 canvas를 이용한다.
이미지 압축이 완료되면, 압축한 이미지를 supbase에 업로드한다.

압축 과정
1. file을 접근 가능한 dataUrl로 변환한다.
2. dataUrl을 통해 Image 객체를 생성한다.
3. canvas에 Image를 그린다. (이미지 크기 조정)
4. canvas를 blob으로 변환한다. (이미지 확장자 변경 및 품질 조정)
5. blob을 supabase에 업로드한다.

코드를 짜보자

const compressImageAndUploadFile = (file: File) => {
  // 1. file을 접근 가능한 dataUrl로 변환한다.
  const fileReader = new FileReader();
  fileReader.onload = () => {
    const dataUrl = reader.result;
    
    if (!dataUrl || typeof dataUrl !== 'string') {
      return;
    }
    
    // 2. dataUrl을 이용해, image 객체를 생성한다.
    const image = new Image();
    image.onload = () => {
      // 3. canvas를 이용해, 이미지를 압축한다.
      const canvas = document.createElement("canvas");
      
      // 3-1. 이미지의 최대 크기를 제한한다.
      const MAX_HEIGHT = 1024;
      const MAX_WIDTH = 1024;
      // 3-2. 이미지 크기를 줄일 때, 비율을 계산한다.
      const ratio = Math.min(MAX_WIDTH/image.width, MAX_HEIGHT/image.height, 1);
      
      const newWidth = image.width * ratio;
      const newHeight = image.height * ratio;
      canvas.width = newWidth;
      canvas.height = newHeight;
      
      const canvasContext = canvas.getContext("2d");
      if (!canvasContext) {
     	throw new Error("Cannot get canvas context");
      }
      
      canvasContext.drawImage(image, 0, 0, newWidth, newHeight);
      
      // 4. canvas를 blob으로 변환한다.
      // 확장자: webp
      // 품질: 0.7
      canvas.toBlob(blob => {
        // 5. 변환한 blob을 file로 변환한 후 supabase에 업로드한다.
        if (!blob) {
          return;
        }
        const compressedFile = new File([blob], 'fileName', {
          type: blob.type,
        });
        uploadFile(compressedFile);
      }, 'image/webp', 0.7)
    };
    image.src = dataUrl;
  };
  
  // 1-1. fileReader로 file을 읽는다.
  fileReader.readAsDataURL(file);
};

const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  
  if (!file) {
    return;
  }

  compressImageAndUploadFile(file);
}

코드 리팩토링하기

리팩토링 기준
1. 이미지 압축 로직과 파일 업로드 관련 로직을 분리하기
2. 콜백 지옥에 빠지지 않도록 만들기

const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  
  if (!file) {
    return;
  }
  
  const dataUrl = await readFileAsDataURL(file);
  const image = await loadImage(dataUrl);
  const compressedBlob = await convertImageToCompressedBlob(image);
  const compressedFile = new File([blob], 'fileName', { type: blob.type });
  
  await uploadFile(compressedFile);
}
const readFileAsDataURL = (file: File): Promise<string> => {
  return new Promise<string>((resolve, reject) => {
    const fileReader = new FileReader();
    
    reader.onload = () => {
      const dataUrl = reader.result;
      
      if (!dataUrl || typeof dataUrl !== 'string') {
        reject(new Error('Invalid data URL'));
        return;
      }
      
      resolve(dataUrl);
    };
    reader.onerror = reject;
    reader.readAsDataURL(file);
  })
};

const loadImage = (url: string): Promise<HTMLImageElement> => {
  return new Promise<HTMLImageElement>((resolve, reject) => {
    const image = new Image();
    
    image.onload = () => reslove(image);
    image.onerror = reject;
    image.src = url;
  })
};

const convertImageToCompressedBlob = (image: HTMLImageElement): Promise<Blob> => {
  const MAX_WIDTH = 1024;
  const MAX_HEIGHT = 1024;
  const BLOB_TYPE = "image/webp";
  const BLOB_QUALITY = 0.7;

  return new Promise((resolve, reject) => {
    const canvas = document.createElement("canvas");
  
    const ratio = Math.min(MAX_WIDTH/image.width, MAX_HEIGHT/image.height, 1);
  
    const newWidth = image.width * ratio;
    const newHeight = image.height * ratio;
    canvas.width = newWidth;
    canvas.height = newHeight;
  
    const canvasContext = canvas.getContext("2d");
    if (!canvasContext) {
      throw new Error("Cannot get canvas context");
    }
  
    canvasContext.drawImage(image, 0, 0, newWidth, newHeight);
    canvas.toBlob(blob => {
      if (!blob) {
        reject(new Error("Cannot convert canvas to Blob"));
        return;
      }
      
      resolve(blob);
    }, BLOB_TYPE, BLOB_QUALITY);
  })
};

Problem

1. 확장자만 바꾼다고 해서, 용량이 줄어드는 건 아니다.

압축률이 좋은 확장자라고 했으니, 확장자만 바꿨을 때에도 용량이 줄어들 것이라고 생각했다.

하.지.만
canvas에 이미지를 로드한 후, blob으로 변환할 때 quality를 1로 설정했더니 용량이 오히려 커지는 경우가 생겼다.

canvas에서 이미지를 변환할 때, quality 매개변수는 0부터 1까지 입력이 가능하고 1은 최고 품질을 의미한다. 따라서, 최고 품질로 설정했을 때에는 파일 크기가 오히려 커질 수 있다.

quality를 1.0으로 설정했을 때,
원본 파일 크기: 16,387,072
변환한 파일 크기: 29,319,412

quality를 0.7로 설정했을 때,
변환한 파일 크기: 917,310

profile
재밌는 걸 만드는 것을 좋아하는 메이커

0개의 댓글