본문 바로가기
JavaScript/React

useDropzone, browser-image-compression [image upload 라이브러리]

by curious week 2025. 7. 10.

react-dropzone

 

react-dropzone

 

react-dropzone.js.org

browser-image-compression

 

GitHub - Donaldcwl/browser-image-compression: Image compression in web browser

Image compression in web browser. Contribute to Donaldcwl/browser-image-compression development by creating an account on GitHub.

github.com


이미지 업로드 전체 흐름

[사용자: 이미지 드래그 앤 드롭]
          │
          ▼
🧩 useDropzone.onDrop()
          │
          ├─▶ 파일 유효성 검사 (용량, 타입 등)
          ▼
[originalFile: File 객체]
          │
          ▼
🧠 browser-image-compression
   compressImage(originalFile, options)
          │
          ▼
[compressedFile: 압축된 File 객체]
          │
          ▼
🖼️ setPreviewUrl(URL.createObjectURL(compressedFile)) ← 미리보기
          │
          ▼
📤 백엔드 전송
fetch('/api/upload', {
  method: 'POST',
  body: FormData.append('file', compressedFile)
})
          │
          ▼
🌐 Spring Boot API (e.g. `/upload`)
@PostMapping("/upload")
public UploadResponse upload(@RequestParam("file") MultipartFile file)
          │
          ▼
🧭 저장 방식 분기
if (saveToS3) {
          │
          ├── ☁️ S3 저장
          │     s3Client.putObject(bucket, key, file.getInputStream(), metadata);
          │     String url = s3Client.getUrl(bucket, key).toString();
          │
          └─▶ return UploadResponse(url);
} else {
          │
          ├── 💾 로컬 저장
          │     String path = "/uploads/" + UUID + file.getOriginalFilename();
          │     file.transferTo(new File(path));
          │
          └─▶ return UploadResponse("http://yourdomain/uploads/filename");
}

 

1. 기본 개념 이해: react-dropzone & useDropzone


react-dropzone 라이브러리란?

  • React 전용 드래그 앤 드롭 파일 업로드 라이브러리
  • <input type="file">와 동일한 기능 + 드래그 앤 드롭 기능을 추상화
  • 완전히 커스터마이징 가능한 스타일 및 DOM 구조 제공
  • 사용처: 프로필 이미지 업로드, 파일 제출, 이미지 미리보기 등

useDropzone 훅의 역할

  • 드래그 영역, 클릭 업로드 버튼, 파일 유효성 검사 등을 설정하는 훅
  • 주요 반환값:
    • getRootProps(): 드래그 영역을 만드는 div에 적용
    • getInputProps(): 숨겨진 파일 input에 연결
    • acceptedFiles: 선택된 파일 목록
    • isDragActive: 현재 드래그 중 여부
    • onDrop(acceptedFiles, rejectedFiles): 콜백

기본 구조 및 사용 방법

import { useDropzone } from 'react-dropzone';

function MyDropzone() {
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop: (acceptedFiles) => {
      console.log(acceptedFiles); // 유효한 파일 목록
    }
  });

  return (
    <div {...getRootProps()} className="border p-4 text-center">
      <input {...getInputProps()} />
      {isDragActive ? (
        <p>Drop the files here ...</p>
      ) : (
        <p>Drag 'n' drop some files here, or click to select files</p>
      )}
    </div>
  );
}

주요 옵션

accept 허용할 파일 형식 (MIME 타입 기준)
multiple 여러 파일을 동시에 업로드 허용 여부
maxSize 허용할 최대 파일 크기 (바이트 단위)
minSize 최소 파일 크기
disabled 드롭존 비활성화
noClick 클릭으로 파일 선택 비활성화
noDrag 드래그앤드롭 비활성화
onDrop 파일을 드롭하거나 선택했을 때 호출되는 콜백
onDropRejected 유효하지 않은 파일이 들어왔을 때 실행되는 콜백

옵션 예시: 이미지 업로드 제한

const { getRootProps, getInputProps } = useDropzone({
  accept: { 'image/*': [] },     // 이미지만 허용
  multiple: false,               // 단일 파일만
  maxSize: 5 * 1024 * 1024,      // 5MB 제한
  onDrop: (files) => {
    console.log('선택된 파일:', files);
  },
});

간단한 상태 확인 요소

{isDragActive && <p className="text-blue-600">여기에 파일을 놓으세요</p>}

 

2. 주요 옵션 상세 학습 – useDropzone


1. onDrop(acceptedFiles, rejectedFiles) 구조 이해

const { getRootProps, getInputProps } = useDropzone({
  onDrop: (acceptedFiles, fileRejections) => {
    console.log('유효한 파일 목록:', acceptedFiles);
    console.log('거부된 파일 목록:', fileRejections);
  },
});
  • acceptedFiles: File[]
    • 조건(accept, size 등)을 만족한 파일 목록
  • fileRejections: FileRejection[]
    • 거부된 파일 정보 (파일 객체와 에러 리스트 포함)
// rejected sample
fileRejections[0] = {
  file: File,
  errors: [
    { code: 'file-too-large', message: 'File is larger than 5MB' },
    { code: 'file-invalid-type', message: 'Only image/* allowed' }
  ]
};

2. accept: MIME 타입 필터링

accept: {
  'image/*': [],           // 모든 이미지 허용 (jpg, png, webp 등)
  'application/pdf': [],   // PDF만 허용
}
  • 내부적으로 <input type="file" accept="..."> 속성에 적용됨
  • 브라우저 UI에서 파일 선택 시 필터링
  • 드래그 드롭으로 들어온 경우에도 자동 검사됨

3. 용량 & 개수 제한: maxSize, minSize, maxFiles

useDropzone({
  maxSize: 5 * 1024 * 1024,     // 5MB
  minSize: 1 * 1024,            // 1KB
  maxFiles: 2,                  // 최대 2개 파일만
});
  • 파일의 byte 단위 크기를 검사
  • 조건을 만족하지 않으면 fileRejections에 들어감

4. UX 옵션: 드래그 클릭 동작 제어

disabled 전체 드롭존 비활성화
noClick 클릭으로 파일 선택 비활성화 (드래그 전용)
noDrag 드래그 앤 드롭 기능 자체를 비활성화
useDropzone({
  noClick: true,     // 사용자가 클릭해도 파일 선택창이 안 뜸
  noDrag: true,      // 드래그 불가. 클릭 전용
  disabled: true,    // 드롭존 완전히 비활성화
});

5. validator: 커스텀 유효성 검사 함수

validator(file: File): FileError | null | Promise<FileError | null>
  • 반환값이 null이면 유효
  • 반환값이 FileError면 fileRejections에 포함됨
  • async 유효성 검사도 지원
import { FileError } from 'react-dropzone';

const imageOnlyValidator = (file: File) => {
  if (!file.type.startsWith('image/')) {
    return {
      code: 'not-image',
      message: '이미지 파일만 업로드 가능합니다.',
    } satisfies FileError;
  }
  return null;
};

useDropzone({
  validator: imageOnlyValidator,
});

전체 예제: 타입 + 용량 + 커스텀 검사

const { getRootProps, getInputProps, fileRejections, acceptedFiles } = useDropzone({
  accept: { 'image/jpeg': [], 'image/png': [] },
  maxSize: 5 * 1024 * 1024,
  maxFiles: 3,
  validator: (file) => {
    if (file.name.includes('secret')) {
      return { code: 'forbidden', message: '파일 이름에 금지된 단어가 포함되어 있습니다.' };
    }
    return null;
  },
  onDrop: (accepted, rejected) => {
    console.log('✅ Valid:', accepted);
    console.log('❌ Rejected:', rejected);
  },
});

  •  

 

3. 이벤트 및 사용자 인터랙션 처리


1. 드래그 상태에 따른 스타일링

useDropzone() 훅은 내부적으로 드래그 상태를 나타내는 boolean 값을 제공합니다.

isDragActive 사용자가 파일을 드래그 중일 때 true
isDragAccept 드래그 중인 파일이 조건에 적합할 때 true
isDragReject 드래그 중인 파일이 조건에 적합하지 않을 때 true
const {
  getRootProps,
  getInputProps,
  isDragActive,
  isDragAccept,
  isDragReject,
} = useDropzone();

예시: 상태에 따른 배경색 변화

<div
  {...getRootProps()}
  className={clsx(
    'border-2 p-8 rounded transition-colors',
    isDragReject && 'border-red-500 bg-red-50',
    isDragAccept && 'border-green-500 bg-green-50',
    !isDragActive && 'border-gray-300 bg-white'
  )}
>
  <input {...getInputProps()} />
  <p>{isDragActive ? 'Drop it here!' : 'Drag or click to upload'}</p>
</div>

2. getRootProps()의 역할

  • div, section 등의 드래그앤드롭을 처리할 DOM 요소에 부여하는 props
  • 내부적으로 dragenter, dragover, drop 이벤트를 처리함
  • 클릭 시 input을 트리거하기 위한 onClick도 포함됨
<div {...getRootProps()} className="drop-area">
  {/* 이 영역이 드래그&클릭 타겟 */}
</div>

3. getInputProps()의 역할

  • 내부적으로 <input type="file">의 props를 제공
  • 사용자 클릭이 발생하면 이 input이 자동으로 동작함
  • accept, multiple 등 설정이 포함된 input
<input {...getInputProps()} />

기본적으로 이 input은 숨김 처리하거나 visually hidden으로 만들고, 상위 div가 클릭을 감지하도록 구성합니다.


4. <input type="file">와의 연동 방식

  • 일반적인 <input type="file">처럼 동작하지만, 클릭 이벤트를 getRootProps()가 대신 처리
  • 실질적으로는 input.click()이 내부에서 호출됨
  • 브라우저 보안 정책상 dragover, drop 등은 반드시 사용자 인터랙션 기반이어야 작동

 

4. 파일 미리보기와 상태 관리


1. URL.createObjectURL()로 이미지 미리보기

브라우저가 File이나 Blob 객체를 가상 URL로 만들어주는 함수입니다.

const fileUrl = URL.createObjectURL(file);
  • 이 URL은 브라우저가 메모리 상에 만든 임시 URL입니다.
  • <img src={url} /> 형태로 바로 사용 가능
  • 반드시 컴포넌트 unmount 시 URL.revokeObjectURL(url)로 해제해야 메모리 누수 방지

2. useState를 사용한 단일 이미지 상태 관리

const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);

const onDrop = useCallback((acceptedFiles: File[]) => {
  const selected = acceptedFiles[0];
  setFile(selected);
  const url = URL.createObjectURL(selected);
  setPreviewUrl(url);

  return () => URL.revokeObjectURL(url); // 언마운트 시 해제
}, []);

미리보기 렌더링

{previewUrl && (
  <img src={previewUrl} alt="미리보기" className="w-48 h-48 object-cover" />
)}

3. 여러 이미지 업로드 처리 (multiple: true)

const [files, setFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);

const onDrop = useCallback((acceptedFiles: File[]) => {
  setFiles(acceptedFiles);

  const previewUrls = acceptedFiles.map((file) => URL.createObjectURL(file));
  setPreviews(previewUrls);

  return () => {
    previewUrls.forEach((url) => URL.revokeObjectURL(url));
  };
}, []);

다중 이미지 미리보기

<div className="grid grid-cols-3 gap-4">
  {previews.map((url, idx) => (
    <img key={idx} src={url} alt={`preview-${idx}`} className="w-32 h-32 object-cover" />
  ))}
</div>

4. Zustand를 활용한 외부 상태 관리 (대규모 앱 대응)

store/image.store.ts

import { create } from 'zustand';

interface ImageStore {
  files: File[];
  previews: string[];
  setFiles: (files: File[]) => void;
  clear: () => void;
}

export const useImageStore = create<ImageStore>((set) => ({
  files: [],
  previews: [],
  setFiles: (files) => {
    const previews = files.map((file) => URL.createObjectURL(file));
    set({ files, previews });
  },
  clear: () => set({ files: [], previews: [] }),
}));

컴포넌트에서 사용

const { files, previews, setFiles, clear } = useImageStore();

useDropzone({
  multiple: true,
  onDrop: (acceptedFiles) => {
    setFiles(acceptedFiles);
  },
});

메모리 누수 주의사항: URL.revokeObjectURL

  • 이미지 미리보기 후 컴포넌트가 언마운트될 때 반드시 정리 작업 필요
  • 예: useEffect(() => { return () => revokeAll() }, [])
useEffect(() => {
  return () => {
    previews.forEach((url) => URL.revokeObjectURL(url));
  };
}, [previews]);


5. 압축/변환 등 후처리 연결


1. browser-image-compression을 이용한 업로드 전 압축

설치

npm install browser-image-compression

단일 이미지 압축 예제

import imageCompression from 'browser-image-compression';

const compressImage = async (file: File): Promise<File> => {
  const compressed = await imageCompression(file, {
    maxSizeMB: 1,
    maxWidthOrHeight: 1024,
    useWebWorker: true,
  });
  return compressed;
};

Dropzone 연계

const onDrop = async (acceptedFiles: File[]) => {
  const compressed = await compressImage(acceptedFiles[0]);
  setFile(compressed);
  setPreview(URL.createObjectURL(compressed));
};

❗ Blob은 타입이 image/jpeg 또는 image/webp 형태로 유지되기 때문에 서버 업로드 시 MIME 타입 유지 확인 필요


2. 이미지 리사이징 및 형식 변경 (WebP 등)

WebP로 형식 변경

const compressWebP = async (file: File): Promise<File> => {
  const webpBlob = await imageCompression(file, {
    fileType: 'image/webp',
    maxSizeMB: 1,
    maxWidthOrHeight: 1024,
  });

  return new File([webpBlob], 'converted.webp', { type: 'image/webp' });
};

WebP는 JPEG 대비 파일 크기를 20~30% 줄일 수 있어 모바일에 적합


3. 여러 이미지 파일 일괄 압축 처리

const compressAll = async (files: File[]): Promise<File[]> => {
  const results = await Promise.all(
    files.map((file) =>
      imageCompression(file, {
        maxSizeMB: 1,
        maxWidthOrHeight: 1024,
      })
    )
  );
  return results;
};

4. PDF, Excel 등 다른 형식 업로드 처리

  • browser-image-compression은 이미지 전용
  • PDF, Excel은 일반적으로 압축하지 않고 직접 업로드
  • 대신 유효성 검사만 진행 (accept, maxSize 등)

Dropzone 설정 예

useDropzone({
  accept: {
    'application/pdf': [],
    'application/vnd.ms-excel': [],
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [],
  },
  maxSize: 10 * 1024 * 1024,
});

유효성 검사 예시

const validateFileType = (file: File) => {
  const allowed = ['application/pdf', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'];
  if (!allowed.includes(file.type)) {
    return { code: 'invalid-type', message: '지원되지 않는 파일 형식입니다.' };
  }
  return null;
};

압축 후 업로드 시 주의점

  • 압축된 파일은 Blob이나 File 형태로 변환하여 업로드
  • 서버에서의 파일 처리 방식이 압축된 MIME 타입 (webp, jpeg) 을 인식하는지 확인 필요
  • WebP를 사용하면 구형 브라우저 호환성 체크 필요 (IE는 미지원, 최신 브라우저는 대부분 지원)

 

6. 서버 업로드 연동


1. FormData를 이용한 파일 전송

FormData는 HTML의 <form enctype="multipart/form-data"> 와 동일한 방식으로 데이터를 전송할 수 있는 객체입니다.

const formData = new FormData();
formData.append('file', compressedFile);
  • key 값 'file'은 Spring Boot의 @RequestParam("file") MultipartFile file과 매칭됩니다.
  • FormData는 텍스트 + 파일 데이터를 함께 보낼 수 있어서 유연성이 높습니다.

2. fetch를 사용한 파일 업로드

await fetch('/api/upload', {
  method: 'POST',
  body: formData,
});
  • 가장 기본적인 브라우저 내장 API
  • 진행률(progress) 표시가 불가능하다는 단점

3. axios를 사용한 파일 업로드

import axios from 'axios';

await axios.post('/api/upload', formData, {
  headers: { 'Content-Type': 'multipart/form-data' },
});
  • 자동으로 Content-Type을 세팅해주고
  • 진행률 표시를 위한 onUploadProgress 옵션을 지원합니다

4. 업로드 진행률 표시 (axios + progress bar)

const [progress, setProgress] = useState(0);

const uploadFile = async (file: File) => {
  const formData = new FormData();
  formData.append('file', file);

  await axios.post('/api/upload', formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
    onUploadProgress: (e) => {
      const percent = Math.round((e.loaded * 100) / (e.total || 1));
      setProgress(percent);
    },
  });
};

진행률 표시 컴포넌트 예

<div className="w-full bg-gray-200 h-4 rounded">
  <div
    className="bg-blue-500 h-4 rounded transition-all"
    style={{ width: `${progress}%` }}
  />
</div>

5. 진행률 표시 (XHR 방식) — 커스터마이징이 더 필요한 경우

const uploadWithXHR = (file: File, onProgress: (percent: number) => void) => {
  return new Promise<void>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append('file', file);

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        const percent = Math.round((event.loaded / event.total) * 100);
        onProgress(percent);
      }
    };

    xhr.onload = () => resolve();
    xhr.onerror = reject;
    xhr.open('POST', '/api/upload');
    xhr.send(formData);
  });
};

⚠️ 보통은 axios가 더 간편하지만, 파일을 여러 개 개별 전송하거나 더 세밀한 제어가 필요할 경우 XMLHttpRequest가 유용.


서버(Spring Boot)에서 파일 수신 예제

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {
    String filename = file.getOriginalFilename();
    long size = file.getSize();
    // 저장 또는 처리 로직
    return "success";
}

 

7. 실무 적용: 에러 처리 & 보안


1. 프론트엔드 에러 처리 UI

Dropzone의 fileRejections 처리

const { fileRejections } = useDropzone({
  accept: { 'image/*': [] },
  maxSize: 5 * 1024 * 1024, // 5MB
  onDropRejected: (rejected) => {
    console.warn('거부된 파일:', rejected);
  },
});

에러 메시지 보여주기

{fileRejections.length > 0 && (
  <ul className="text-red-500 text-sm mt-2">
    {fileRejections.map(({ file, errors }) =>
      errors.map((e) => (
        <li key={e.code}>
          ❌ [{file.name}]: {e.message}
        </li>
      ))
    )}
  </ul>
)}

주요 에러 코드

file-too-large maxSize 초과
file-too-small minSize 미만
too-many-files maxFiles 초과
file-invalid-type MIME 타입 거절됨
custom validator에서 직접 반환한 오류

2. Drag & Drop 보안 고려사항

위험 요소: MIME Spoofing

  • 악성 사용자가 file.type = 'image/png' 으로 조작된 .exe 파일을 넣는 경우
  • 드래그한 파일의 이름과 타입은 신뢰할 수 없음

대응 방안

프론트에서는 file.type과 확장자 검사 file.name.endsWith('.png') 같은 단순 검사 가능
백엔드에서는 실제 MIME 유형 검증 파일 내용을 기반으로 magic number 검사 필수
백엔드에서 업로드 후 서버 내에서 안전한 경로로 저장 사용자 입력값으로 경로 결정 ❌

3. 서버 측 파일 검증 포인트 (Spring Boot 기준)

1. 파일 크기 제한 (application.yml)

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 20MB

2. MIME 타입 검사 (Apache Tika 활용 추천)

import org.apache.tika.Tika;

@PostMapping("/upload")
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) throws IOException {
    Tika tika = new Tika();
    String mimeType = tika.detect(file.getInputStream());

    if (!mimeType.startsWith("image/")) {
        return ResponseEntity.badRequest().body("허용되지 않은 파일 유형입니다.");
    }

    // 저장 로직...
}

3. 확장자와 MIME 둘 다 검사

String fileName = file.getOriginalFilename();
if (!fileName.endsWith(".jpg") && !fileName.endsWith(".png")) {
    return ResponseEntity.badRequest().body("허용되지 않은 확장자입니다.");
}

4. 보안 관련 추가 체크리스트

파일명 검증 특수 문자 제거, UUID 등으로 재명명
저장 경로 고정 경로 조작 공격 방지 (절대경로 제한)
MIME 검증 파일 실제 유형 검사 (Tika 등)
썸네일 생성 이미지를 직접 렌더링하지 않고 서버에서 썸네일 처리 권장
업로드 인증 필요 로그인/권한 체크 없이 업로드 가능하면 악용됨
서버 응답 메시지 제한 너무 구체적인 에러 메시지는 공격자에게 정보 제공

크기, 확장자 제한 프론트 & 백엔드 모두 UX 개선 + 보안 필수
MIME 스푸핑 백엔드 반드시 Tika, magic number 등으로 확인
인증/권한 백엔드 로그인된 사용자만 업로드 허용
에러 메시지 프론트 사용자 친화적으로 표시 (i18n도 포함 가능)

 

8. 커스텀 디자인 적용


1. <div {...getRootProps()}> 내부 커스터마이징

getRootProps()는 드래그 앤 드롭 이벤트를 바인딩하는 역할만 합니다.
내부는 완전히 자유롭게 구성 가능하므로, 어떤 컴포넌트든 포함할 수 있습니다.

예시: 카드 스타일 Dropzone

<div {...getRootProps()} className="border-2 border-dashed p-6 rounded-lg text-center cursor-pointer transition hover:shadow-lg">
  <input {...getInputProps()} />
  <UploadIcon className="mx-auto text-gray-400 mb-2" />
  <p className="text-gray-600">클릭하거나 이미지를 끌어다 놓으세요</p>
</div>
  • hover, transition, rounded, shadow 등을 활용한 자연스러운 카드형 UI 가능
  • 내부에 <img>, <Button>, <Icon> 등 어떤 구성도 삽입 가능

2. <input> 숨기기 및 스타일링

<input type="file">은 시각적으로 숨기되, 기능은 그대로 유지해야 합니다.

<input {...getInputProps()} className="hidden" />

또는 Tailwind 기반 커스텀 스타일

<input
  {...getInputProps()}
  className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>

이 방식은 접근성 측면에서도 유리하며, 모바일에서도 작동을 보장합니다.


3. 드래그 상태에 따른 스타일 동적 적용

상태값: isDragActive, isDragAccept, isDragReject

<div
  {...getRootProps()}
  className={clsx(
    'p-6 border-2 border-dashed rounded-md text-center transition-colors',
    isDragAccept && 'border-green-400 bg-green-50',
    isDragReject && 'border-red-400 bg-red-50',
    isDragActive && !isDragReject && 'border-blue-400 bg-blue-50'
  )}
>
  <input {...getInputProps()} />
  <p className="text-gray-600">
    {isDragReject
      ? '지원되지 않는 파일 형식입니다.'
      : isDragActive
      ? '여기에 파일을 놓으세요'
      : '파일을 업로드하려면 클릭 또는 드래그'}
  </p>
</div>

4. 상태 기반 애니메이션 / 피드백 메시지

예: 업로드 완료 시 애니메이션 표시

{uploaded && (
  <motion.div
    className="text-green-600 mt-2"
    initial={{ opacity: 0, y: -10 }}
    animate={{ opacity: 1, y: 0 }}
    exit={{ opacity: 0, y: -10 }}
    transition={{ duration: 0.3 }}
  >
    ✅ 업로드 완료!
  </motion.div>
)}

framer-motion을 쓰면 부드러운 피드백 UI를 구성할 수 있습니다.


5. 업로드 후 미리보기 UI 예시

{previewUrl && (
  <img
    src={previewUrl}
    alt="preview"
    className="mt-4 rounded shadow w-48 h-48 object-cover"
  />
)}

또는 여러 파일일 경우:

<div className="mt-4 grid grid-cols-3 gap-4">
  {previews.map((url, idx) => (
    <img key={idx} src={url} className="w-32 h-32 object-cover rounded" />
  ))}
</div>


 

1. 기본 개념과 사용법 이해: browser-image-compression


무엇을 위한 라이브러리인가?

브라우저에서 직접 이미지(Blob 또는 File)를 압축하거나 리사이즈하는 JavaScript 라이브러리입니다.

  • 업로드 전에 이미지의 용량을 줄이기 위해 사용
  • 서버 트래픽 절감, 업로드 속도 개선, UX 향상
  • React, Vue, Vanilla JS 등 어떤 프레임워크에서도 사용 가능

핵심 동작 요약

[원본 File or Blob]
    │
    ▼
imageCompression(input, options)
    │
    ▼
[압축된 Blob or File 반환]
  • 내부적으로 <canvas>로 이미지를 렌더링 → 압축/리사이징 → 다시 Blob/File로 변환
  • Web Worker를 사용해 메인 스레드 블로킹 없이 처리 (옵션 가능)

Blob/File → 압축된 Blob/File 흐름

  • 입력: File 또는 Blob 객체 (<input type="file"> 또는 Dropzone에서 받은 값)
  • 출력: 압축된 Blob 또는 File 객체 (파일명 유지 가능)
  • FormData.append('file', compressed)를 통해 그대로 업로드 가능

브라우저 환경에서 작동

  • 설치만 하면 클라이언트에서 실행됨 (WebAssembly 필요 없음)
  • IE11 제외 모든 주요 브라우저 지원
  • 모바일 Safari/Chrome에서도 정상 동작

Web Worker 지원

  • useWebWorker: true (기본값)
  • 이미지 크기가 크거나 고화질일 경우, 브라우저 UI 멈춤을 방지

설치

npm install browser-image-compression
# 또는
yarn add browser-image-compression

기본 사용 예제

import imageCompression from 'browser-image-compression';

const compressImage = async (file: File): Promise<File> => {
  const compressedFile = await imageCompression(file, {
    maxSizeMB: 1,               // 압축 목표 용량 (MB)
    maxWidthOrHeight: 1024,     // 최대 가로/세로 크기 (리사이즈)
    useWebWorker: true,         // 백그라운드 처리 (기본값 true)
  });

  return compressedFile;
};

실제 적용 예 (Dropzone과 연결)

const onDrop = async (acceptedFiles: File[]) => {
  const original = acceptedFiles[0];
  const compressed = await compressImage(original);

  setFile(compressed);
  setPreviewUrl(URL.createObjectURL(compressed));
};

주요 용도

  • 모바일 사진 업로드 최적화
  • 서버 트래픽 절감 및 S3 비용 절감
  • 미리보기 + 업로드 UX 개선 (파일 업로드 전에 압축 완료)

 

 

주요 옵션 – imageCompression(input, options)

input: File 또는 Blob / options: 아래에서 설명할 설정 객체


1. 압축 품질 제어

maxSizeMB: number

  • 압축 목표 용량(MB)
  • 출력 파일이 이 값을 넘지 않도록 압축 수행
  • 실제 출력 용량은 약간 더 커질 수 있음
maxSizeMB: 1   // 최대 1MB까지 줄이기

initialQuality: number

  • 수동으로 압축 품질을 지정 (0.0 ~ 1.0)
  • maxSizeMB와 함께 사용 가능하지만, 상호 영향이 있음
  • maxSizeMB보다 우선 적용되지는 않음
initialQuality: 0.7  // 70% 품질 유지

 initialQuality는 실험적이며 실제 용량 제어는 maxSizeMB로 하는 것이 더 정확합니다.


useWebWorker: boolean

  • Web Worker를 이용해 메인 스레드 블로킹 없이 처리
  • 기본값: true
  • 압축에 몇 초 이상 걸릴 경우 반드시 사용 권장
useWebWorker: true

2. 크기 리사이징

maxWidthOrHeight: number

  • 가로 또는 세로 중 더 큰 값이 이 최대값을 넘지 않도록 자동 리사이징
  • 비율을 유지한 채 리사이즈됨
maxWidthOrHeight: 1024

예: 4000x3000 이미지는 → 1024x768로 줄어듬


resizeIfSizeExceeds: number (선택적)

  • 입력 파일의 크기가 이 값(MB)을 초과하면 리사이징 수행
  • 이 값을 넘지 않으면 리사이징 생략
resizeIfSizeExceeds: 2  // 2MB 이상일 경우에만 리사이즈

3. 기타 설정

fileType: string

  • 압축된 파일의 MIME 타입을 변경
  • 형식 변환 가능 (예: JPEG → WebP)
fileType: 'image/webp'
  • image/jpeg, image/png, image/webp 지원
  • WebP는 JPEG보다 더 높은 압축률을 보이는 경우가 많음

파일 확장자는 자동 변경되지 않기 때문에 직접 File 객체를 새로 만들어주는 게 안전!

const compressedBlob = await imageCompression(file, { fileType: 'image/webp' });
const webpFile = new File([compressedBlob], 'converted.webp', { type: 'image/webp' });

signal: AbortSignal

  • 압축 작업 중 중단(취소) 처리를 위해 AbortController를 연결할 수 있음
const controller = new AbortController();

imageCompression(file, {
  maxSizeMB: 1,
  signal: controller.signal,
});

// 중단할 때
controller.abort();

예제: 옵션 조합

const options = {
  maxSizeMB: 1,
  maxWidthOrHeight: 1024,
  initialQuality: 0.75,
  fileType: 'image/webp',
  resizeIfSizeExceeds: 2,
  useWebWorker: true,
};

const compressedBlob = await imageCompression(file, options);
const finalFile = new File([compressedBlob], 'compressed.webp', { type: 'image/webp' });

 

4. 이미지 품질 및 성능 실험


1. 동일한 이미지에 대해 옵션별 압축률 비교

다음은 세 가지 다른 옵션을 적용한 비교 예제입니다.

const compressVariants = async (file: File) => {
  const results = [];

  const variants = [
    { label: 'JPEG-1MB', options: { maxSizeMB: 1, fileType: 'image/jpeg' } },
    { label: 'WebP-0.5MB', options: { maxSizeMB: 0.5, fileType: 'image/webp' } },
    { label: 'JPEG-1024px', options: { maxWidthOrHeight: 1024, fileType: 'image/jpeg' } },
  ];

  for (const variant of variants) {
    const blob = await imageCompression(file, variant.options);
    const newFile = new File([blob], `${variant.label}.${blob.type.split('/')[1]}`, { type: blob.type });

    results.push({
      label: variant.label,
      file: newFile,
      url: URL.createObjectURL(blob),
      size: newFile.size,
      type: newFile.type,
    });
  }

  return results;
};

2. WebP, JPEG, PNG 압축 성능 차이

포맷 | 특징 | 압축 효율 | 투명도 지원 | 브라우저 지원

JPEG 손실 압축 보통
PNG 무손실 압축 낮음 (용량 큼)
WebP 손실/무손실 모두 지원 가장 우수 ✅ (IE 제외)
  • 일반적으로 WebP가 가장 작은 용량을 생성함
  • PNG는 압축률이 낮지만 투명도가 필요한 UI에서는 유용
  • JPEG은 압축률과 속도 균형이 좋고 구형 브라우저도 지원

3. 압축 후 파일 용량 및 해상도 측정

const getImageInfo = async (file: File): Promise<{ width: number; height: number }> => {
  return new Promise((resolve) => {
    const img = new Image();
    img.src = URL.createObjectURL(file);
    img.onload = () => {
      resolve({ width: img.width, height: img.height });
      URL.revokeObjectURL(img.src);
    };
  });
};
for (const variant of results) {
  const dimensions = await getImageInfo(variant.file);
  console.log(`▶ ${variant.label}:`, variant.size, dimensions.width, dimensions.height);
}

4. 압축 전/후 비교 미리보기 UI

<div className="grid grid-cols-2 gap-6">
  {compressedImages.map((item, index) => (
    <div key={index} className="border rounded p-4 text-center">
      <img src={item.url} alt={item.label} className="w-48 h-48 object-contain mx-auto" />
      <p className="text-sm mt-2 font-semibold">{item.label}</p>
      <p className="text-xs text-gray-500">
        {(item.size / 1024).toFixed(1)} KB • {item.type}
      </p>
    </div>
  ))}
</div>

압축 전 원본 파일도 같이 보여주면 좋습니다.


결과 예시

변형 | 용량 | 해상도

JPEG-1MB 980KB 3000x2000
WebP-0.5MB 480KB 3000x2000
JPEG-1024px 220KB 1024x683

 

 

5. 업로드 흐름 통합


1. 전체 흐름 요약

[useDropzone → File 선택]
       ↓
[imageCompression 압축]
       ↓
[setPreviewUrl / 상태 저장]
       ↓
[FormData.append → 서버 전송]
       ↓
[성공 시 후처리 / 실패 시 오류 메시지 표시]

2. React 코드 통합 예시 (단일 이미지 업로드)

import { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import imageCompression from 'browser-image-compression';
import { useForm, Controller } from 'react-hook-form';
import axios from 'axios';

export const UploadForm = () => {
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
  const [uploading, setUploading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const { control, handleSubmit } = useForm<{ file: File }>({
    defaultValues: { file: undefined as any },
  });

  const onDrop = useCallback(async (acceptedFiles: File[], onChange: (file: File) => void) => {
    try {
      const file = acceptedFiles[0];
      if (!file) return;

      // 압축
      const compressed = await imageCompression(file, {
        maxSizeMB: 1,
        maxWidthOrHeight: 1024,
        useWebWorker: true,
      });

      const preview = URL.createObjectURL(compressed);
      setPreviewUrl(preview);
      onChange(compressed); // react-hook-form에 전달
    } catch (err) {
      console.error(err);
      setError('이미지 압축에 실패했습니다.');
    }
  }, []);

  const onSubmit = async (data: { file: File }) => {
    try {
      setUploading(true);
      setError(null);

      const formData = new FormData();
      formData.append('file', data.file);

      await axios.post('/api/upload', formData);
      alert('업로드 성공!');
    } catch (err) {
      console.error(err);
      setError('업로드 중 오류가 발생했습니다.');
    } finally {
      setUploading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <Controller
        name="file"
        control={control}
        rules={{ required: '파일을 선택해주세요.' }}
        render={({ field: { onChange }, fieldState: { error } }) => {
          const { getRootProps, getInputProps, isDragActive } = useDropzone({
            accept: { 'image/*': [] },
            maxSize: 5 * 1024 * 1024,
            multiple: false,
            onDrop: (files) => onDrop(files, onChange),
          });

          return (
            <div>
              <div
                {...getRootProps()}
                className={`border-dashed border-2 rounded-md p-6 text-center cursor-pointer transition ${
                  isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
                }`}
              >
                <input {...getInputProps()} />
                <p>{isDragActive ? '여기에 놓으세요' : '클릭하거나 드래그해서 업로드'}</p>
              </div>
              {error?.message && <p className="text-red-500 text-sm mt-2">{error.message}</p>}
              {previewUrl && (
                <img
                  src={previewUrl}
                  alt="미리보기"
                  className="mt-4 w-48 h-48 object-cover rounded shadow"
                />
              )}
            </div>
          );
        }}
      />

      <button
        type="submit"
        disabled={uploading}
        className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
      >
        {uploading ? '업로드 중...' : '제출'}
      </button>

      {error && <p className="text-red-600 text-sm">{error}</p>}
    </form>
  );
};

주요 포인트

useDropzone 드래그/클릭으로 이미지 수신
imageCompression 서버 전송 전에 용량 압축
URL.createObjectURL() 미리보기 표시
react-hook-form + Controller 파일 상태를 폼 내부에서 관리 가능
FormData 서버에 파일 전송
try-catch 압축 실패, 업로드 실패 모두 잡아냄

file.name 유지 여부 압축 후 File로 변환 시 이름 변경 가능성 고려
FormData 전송 시 키 이름 주의 백엔드 파라미터 이름과 일치해야 함 (file)
useEffect로 preview URL 정리 URL.revokeObjectURL() 사용
multiple: true 여러 파일 업로드로 확장 가능 (배열 상태 관리 필요)

 

6. 고급 사용법


1. 이미지 여러 개 압축 처리 (Promise.all 활용)

여러 개의 파일을 한꺼번에 압축하려면 Promise.all()을 사용하면 깔끔하고 빠릅니다.

const compressMultipleFiles = async (files: File[]) => {
  const compressed = await Promise.all(
    files.map((file) =>
      imageCompression(file, {
        maxSizeMB: 1,
        maxWidthOrHeight: 1024,
      }).then((blob) => new File([blob], file.name, { type: blob.type }))
    )
  );

  return compressed; // File[]
};
  • 이미지 미리보기는 URL.createObjectURL(file)로 각각 생성
  • 오류가 발생하면 try-catch 또는 Promise.allSettled()로 대체 가능

2. 모바일 대응 (HEIC → JPEG 변환)

문제:

  • iPhone 등에서 촬영한 사진은 .heic 포맷일 수 있음
  • browser-image-compression은 HEIC 직접 지원하지 않음

대응 방법:

① 서버에서 변환 백엔드에서 HEIC → JPEG 변환 처리 (libheif, ImageMagick 등)
② 사용자 안내 HEIC 업로드 불가 메시지 표시
③ 타사 라이브러리 사용 heic2any, heic-convert (브라우저에서 가능하나 무겁고 느림)
import heic2any from 'heic2any';

const convertHeicToJpeg = async (file: File): Promise<File> => {
  const blob = await heic2any({ blob: file, toType: 'image/jpeg' });
  return new File([blob as Blob], file.name.replace('.heic', '.jpg'), { type: 'image/jpeg' });
};

성능 문제로 인해 보통은 서버에서 처리하거나 사용자에게 안내하는 게 안전합니다.


3. 압축 시간이 오래 걸리는 문제 대응 (로딩 상태 + 스피너)

이미지가 크거나 다수일 경우, 압축에 수 초 이상 걸릴 수 있습니다.

로딩 상태 처리

const [loading, setLoading] = useState(false);

const handleCompress = async (files: File[]) => {
  try {
    setLoading(true);
    const compressed = await compressMultipleFiles(files);
    setFiles(compressed);
  } catch (err) {
    setError('압축에 실패했습니다.');
  } finally {
    setLoading(false);
  }
};

로딩 UI 표시

{loading && (
  <div className="text-blue-600 text-sm animate-pulse">압축 중입니다. 잠시만 기다려주세요...</div>
)}

4. 압축 실패 대비 fallback 로직 작성

  • 압축 중 에러가 발생해도 원본 파일로 업로드 가능하도록 구성
  • try-catch 문과 조건 분기로 처리
const safeCompress = async (file: File): Promise<File> => {
  try {
    const blob = await imageCompression(file, { maxSizeMB: 1 });
    return new File([blob], file.name, { type: blob.type });
  } catch (err) {
    console.warn('압축 실패, 원본 사용');
    return file; // fallback: 압축 없이 원본 업로드
  }
};
  • 이 로직을 다중 처리에도 적용 가능:
const compressedFiles = await Promise.all(
  files.map((file) => safeCompress(file))
);

7. 에러 처리 및 안정성


1. 자주 발생하는 예외 케이스

Invalid image format 이미지가 아닌 파일이 들어왔을 때 (HEIC, PDF, 기타)
Quality too low 지나치게 압축해도 maxSizeMB 이하가 안 되는 경우
imageCompression() 내부 오류 canvas 변환 실패, 메모리 부족 등

예외 처리 예시

try {
  const compressedBlob = await imageCompression(file, {
    maxSizeMB: 1,
    maxWidthOrHeight: 1024,
    useWebWorker: true,
  });

  const compressedFile = new File([compressedBlob], file.name, { type: compressedBlob.type });
  return compressedFile;
} catch (err) {
  console.error('압축 중 오류 발생:', err);
  throw new Error('압축에 실패했습니다. 원본으로 업로드합니다.');
}

imageCompression()은 내부적으로 Promise.reject()를 던지므로 반드시 try-catch로 감싸야 합니다.


2. 압축 실패 시 원본 업로드 fallback

압축에 실패했을 경우에도 업로드를 중단하지 않고 원본 파일로 대체 업로드하도록 처리합니다.

const compressWithFallback = async (file: File): Promise<File> => {
  try {
    const blob = await imageCompression(file, { maxSizeMB: 1 });
    return new File([blob], file.name, { type: blob.type });
  } catch (err) {
    console.warn('압축 실패, 원본으로 대체');
    return file;
  }
};
  • 여러 파일에도 적용 가능:
const compressedFiles = await Promise.all(files.map(compressWithFallback));

3. 압축 중 로딩 상태 표시

상태 선언

const [isCompressing, setIsCompressing] = useState(false);

사용 예시

const onDrop = async (acceptedFiles: File[]) => {
  setIsCompressing(true);
  try {
    const compressed = await Promise.all(acceptedFiles.map(compressWithFallback));
    setFiles(compressed);
  } finally {
    setIsCompressing(false);
  }
};

UI 적용

{isCompressing && (
  <div className="text-blue-600 animate-pulse text-sm mt-2">
    이미지 압축 중입니다... ⏳
  </div>
)}

압축 중 업로드 버튼을 비활성화하거나 스피너 표시도 고려하세요.


추가 실무 팁

file.type 검사 image/jpeg, image/png, image/webp 외 거부
file.name 검사 .jpg, .png 여부만 믿지 말고 MIME으로 검증
압축 불가한 파일 제한 용량이 작으면 압축 생략 or 스킵 처리도 가능
사용자의 원본 업로드 여부 안내 압축 실패 시 명확한 메시지로 UX 보완

압축 실패 메시지 표시 예시

if (err instanceof Error) {
  setErrorMessage(err.message);
} else {
  setErrorMessage('알 수 없는 오류가 발생했습니다.');
}
{errorMessage && (
  <p className="text-red-500 text-sm mt-2">{errorMessage}</p>
)}

'JavaScript > React' 카테고리의 다른 글

forwardRef  (4) 2025.08.18
Tiptap Editor  (7) 2025.06.26
파일 네이밍 컨벤션(File Naming Convention) - React Feature Folder  (1) 2025.06.14
npm React 라이브러리 배포  (0) 2025.06.13
template 자동화  (0) 2025.06.12