react-dropzone
react-dropzone
react-dropzone.js.org
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 |