🐟 Project

이미지 업로드 최적화 트러블슈팅 (react-image-file-resizer -> browser-image-compression)

nayah 2025. 4. 30. 22:59

💾 배경

메인서비스에서 사용자가 이미지를 자주 추가하거나 변경합니다.

이미지를 입력받으면, 각 이미지에 대해 Presigned URL을 요청하고, 최적화(용량 압축 및 리사이징) 과정을 거친 후 서버에 업로드합니다. 

서버에 올리기 전에 이미지 최적화가 필요하고, 또한 유저는 업로드한 이미지를 즉시 미리보기로 확인할 수 있어야합니다.

 

⚠️  문제 상황

처음에는 react-image-file-resizer를 사용하여 이미지 최적화를 구현했습니다. (기존에 팀원이 사용하고 있어서)

하지만 이 라이브러리는 리사이징 중심이기 때문에 반드시 파일 이미지의 width, height를 명시해야합니다.

프로필 이미지와 같이 크기가 항상 동일한 파일은 상관이 없지만, 아래와 같이 이미지 파일의 크기가 제각각인 경우에는 이미지 크기를 명확하게 설정하기가 어려웠습니다.

 

그래서 input으로 이미지 파일을 올리면, 파일 객체만 받기 때문에

이미지의 실제 크기를 알기 위해서는 FileReader로 파일 로딩을 하고, Image 객체에 넣어서 디코딩 완료 후 크기를 측정하는 번거로움이 있었습니다.

 

문제의 기능

결과적으로 구현한 코드에는 3가지의 문제가 있었습니다

1. 처리 시간 지연

    선형적으로 파일을 읽고, 크기를 측정하기 때문에 한 장당 평균 2~3초 소요 (3장 시, 7~9초 동안 인터렉션이 멈춤)

2. 복잡한 코드과 콜백구조

     FileReader → Image → Callback 구조

3. 제한적인 압축

     라이브러리 자체가 이미지 압축보다는 이미지 크기에 초첨이 맞춰진 라이브러리로 품질과 크기 축소외에 명확한 용량 제한이 없음

이미지 리사이징 : 가로-세로 해상도를 줄이기
이미지 압축 : 파일 용량 줄이기

 

문제 코드 (react-image-file-resizer 사용)

import Resizer from 'react-image-file-resizer';

/**
 * 이미지를 리사이즈하여 지정된 형식으로 반환하는 함수
 * @param file 리사이즈할 이미지 파일
 * @param width 리사이즈할 너비
 * @param height 리사이즈할 높이
 * @param quality 리사이즈 후 품질 (0 ~ 100)
 * @param outputType 리사이즈 후 출력 형식 (base64, blob, file 중 하나)
 * @returns 리사이즈된 이미지의 지정된 형식 (base64 URL, Blob, 또는 File)
 */
export const resizeImageToWebp = (
  file: File,
  width: number | null = null,
  height: number | null = null,
  quality: number = 80,
  outputType: 'base64' | 'blob' | 'file' = 'base64'
): Promise<string | Blob | File> => {
  return new Promise((resolve, reject) => {
    const img = new Image();
    const reader = new FileReader();

    reader.onloadend = () => {
      if (reader.result) {
        img.src = reader.result as string;

        img.onload = () => {
          const imgWidth = width ?? img.width; // 미지정 시 원본 이미지의 너비
          const imgHeight = height ?? img.height; // 높이

          Resizer.imageFileResizer(
            file,
            imgWidth,
            imgHeight,
            'WEBP',
            quality,
            0,
            (uri) => {
              if (outputType === 'base64' && typeof uri === 'string') {
                resolve(uri); // base64 URL 반환
              } else if (outputType === 'blob' && uri instanceof Blob) {
                resolve(uri); // Blob 반환
              } else if (outputType === 'file' && uri instanceof File) {
                resolve(uri); // File 반환
              } else {
                reject(new Error('이미지 리사이징 실패'));
              }
            },
            outputType
          );
        };

        img.onerror = () => reject(new Error('이미지 로딩 실패'));
      }
    };

    reader.onerror = (error) => reject(error);

    // 파일을 읽어 이미지로 변환
    reader.readAsDataURL(file);
  });
};

🔥 개선 방법

1. browser-image-compression 라이브러리로 교체

2. presignedUrl 요청 로직을 Promise.all로 병렬처리

 

💭 react-image-file-resizer vs browser-image-compression 비교

  browser-image-compression react-image-file-resizer
출력 지원 Blob/File  base64/blob/file
압축 품질 매우 세밀하게 조절 가능 기본적인 리사이징과 압축만 가능
리사이징 가능 가능
압축 옵션 고급 (quality, maxSizeMB, maxWidthHeight 등) 단순 (maxWidth/Height만 있음)
비동기 처리 Promise 기반 콜백 기반
TypeScript 지원 좋음 부족 (타입 약함)
파일 타입 지원 JPEG, PNG, WebP 등 JPEG, PNG (WebP 미지원)
추가 의존성 없음 react-image-file-resizer 단독
유즈케이스 이미지 업로드 전에 품질 조절, 고화질 압축 빠른 사이즈 조절, 간단한 리사이즈 목적

 

1️⃣ browser-image-compression 적용

- 해당 라이브러리에서 제공하는 다양한 압축 옵션을 통해, 이미지 압축 (리사이징 + 압축 둘다 진행)

- webWorker를 사용해서 이미지 처리 로직을 메인 스레드에서 분리

- async / await 을 통해 코드가 간결하고 가독성 향상

- 일관된 반환 타입으로 File로 통일해서 사용하는 곳에서 바로 URL.createObject 을 통해 미리보기 이미지 제공

/* 
이미지 압축 + webp 변환
*/
import imageCompression, { Options } from 'browser-image-compression';
import { returnFileSize } from './returnFileSize';

export async function compressImageToWebp(file: File, options?: Partial<Options>) {
  const defaultOptions: Partial<Options> = {
    maxSizeMB: 1,
    maxWidthOrHeight: 1024,
    useWebWorker: true,
    fileType: 'image/webp',
    ...options,
  };

  try {
    console.log('전', returnFileSize(file.size));
    const compressedFile = await imageCompression(file, defaultOptions);
    console.log('후', returnFileSize(compressedFile.size));
    return compressedFile;
  } catch (error) {
    console.error('이미지 압축 실패:', error);
  }
}

 

파일 크기를 계산하는 유틸함수를 만들어서 비교했습니다

export function returnFileSize(number: number) {
  if (number < 1024) {
    return number + 'bytes';
  } else if (number >= 1024 && number < 1048576) {
    return (number / 1024).toFixed(1) + 'KB';
  } else if (number >= 1048576) {
    return (number / 1048576).toFixed(1) + 'MB';
  }
}

 

2️⃣ 이미지 업로드: presigned URL 요청 최적화

추가로 이미지 업로드는 AWS S3 presigned URL을 받아 PUT 요청으로 업로드하는 구조입니다.
단일 이미지, 다중 이미지 업로드 로직이 각각 따로 구현되어 있었고, 중복이 있었습니다.

이를 공통 유틸 함수로 분리하고, Promise.all을 사용하여 병렬 처리했습니다

import api from '@/services/apis/api';
import { PresignUrlResponse } from '@/services/apis/types/registerAPI.type';

/** 여러 요청 */
export const getPresignedUrls = async (files: File[]): Promise<PresignUrlResponse[]> => {
  try {
    const responses = await Promise.all(
      files.map((file) => api.register.getPresignUrl({ originalFile: file.name }))
    );
    return responses;
  } catch (error) {
    console.error('Presigned URL 가져오기 실패:', error);
    throw error;
  }
};

/** getPresignedUrls를 사용해서 단일 요청 */
export const getPresignedUrl = async (file: File): Promise<PresignUrlResponse> => {
  const [result] = await getPresignedUrls([file]);
  return result;
};

 

 

결과

1. 이미지 압축률 평균 94% 

전 : 원본 이미지 파일 사이즈 / 후 : 압축+리사이즈 후 파일 사이즈 

1장 / 2장 / 3장 올렸을 때

 

2. 파일 업로드 후, 압축된 파일 받기까지의 시간 

const start = performance.now();
const end = performance.now();
console.log(`기존 코드 실행 시간: ${end - start}ms`);

 

1장 업로드

 

  1. 전 : 3440ms → 후 : 418ms (87% 개선)
  2. 전 : 3178ms → 후 : 395ms (87% 개선)
  3. 전 : 2985ms → 후 : 436ms (85% 개선)
  4. 전 : 3623ms → 후 : 419ms (88% 개선)
  5. 전 : 3299ms → 후 : 461ms (86% 개선)

 

 

3장 업로드

 

  1. 전 : 7225ms → 후 : 667ms (90% 개선)
  2. 전 : 6857ms → 후 : 623ms (90% 개선)
  3. 전 : 7493ms → 후 : 758ms (89% 개선)
  4. 전 : 7098ms → 후 : 669ms (90% 개선)
  5. 전 : 7662ms → 후 : 790ms (89% 개선)