File Upload
Import
<link rel="stylesheet" href="/guide/css/base/index.css" /> <link rel="stylesheet" href="/guide/css/components/file-upload.css" />
Live
파일 업로드
파일 여러개 / 최대 5MB
파일을 끌어다 놓거나 클릭해서 업로드
PNG, JPG, PDF · 최대 5MB
States
Empty
파일 업로드
파일 여러개 / 최대 5MB
Drag Over
파일 업로드
파일 여러개 / 최대 5MB
With Files
파일 업로드
파일 여러개 / 최대 5MB
- PNG screenshot_2024.png 1.2 MB
- PDF 디자인_가이드_v1.pdf 3.8 MB
- JPG profile_photo.jpg 487 KB
Specs
| 요소 | 크기 | 색상 / 토큰 | 비고 |
|---|---|---|---|
| 컨테이너 | width 100% | bg #ffffff / border --gray-200 | --radius-lg (12px) |
| Header padding | 12px × 20px | border-bottom --gray-200 | — |
| Title | 18px Bold | --gray-900 | — |
| Subtitle | 16px Regular | --gray-500 | — |
| Dropzone | height 153px | border 2px dashed --gray-300 | hover/active: --green-50 bg + --green-500 border |
| Hint Link | 16px Medium | --green-500 | "클릭해서 업로드" 강조 |
| File Item | padding 8px 12px | bg --gray-50 / border --gray-200 | --radius-md (8px) |
| Button — 전체 삭제 | h-32px / min-w-80px | --color-secondary-main | --radius-md (8px), 14px Regular |
| Button — 파일 업로드 | h-32px / min-w-80px | --color-primary-main | --radius-md (8px), 14px Regular |
Code
HTML
<div class="file-upload">
<div class="file-upload__header">
<p class="file-upload__title">파일 업로드</p>
<p class="file-upload__subtitle">파일 여러개 / 최대 5MB</p>
</div>
<div class="file-upload__body">
<div class="file-upload__dropzone"
id="dropzone"
role="button"
tabindex="0"
aria-label="파일을 클릭하거나 끌어다 놓아 업로드하세요">
<input class="file-upload__input" type="file" id="file-input"
multiple accept=".png,.jpg,.jpeg,.pdf"
aria-hidden="true" tabindex="-1" />
<span class="file-upload__icon" aria-hidden="true">🖼️</span>
<div class="file-upload__hint">
<p class="file-upload__hint-main">
파일을 끌어다 놓거나
<span class="file-upload__hint-link">클릭해서 업로드</span>
</p>
<p class="file-upload__hint-sub">PNG, JPG, PDF · 최대 5MB</p>
</div>
</div>
<ul class="file-upload__list" id="file-list" hidden></ul>
<p class="file-upload__status" id="file-status" hidden></p>
<div class="file-upload__actions">
<button class="file-upload__btn file-upload__btn--clear"
id="btn-clear" type="button">전체 삭제</button>
<button class="file-upload__btn file-upload__btn--upload"
id="btn-upload" type="button">파일 업로드</button>
</div>
</div>
</div>
JavaScript
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('file-input');
const fileList = document.getElementById('file-list');
const fileStatus = document.getElementById('file-status');
const btnClear = document.getElementById('btn-clear');
const btnUpload = document.getElementById('btn-upload');
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED = ['image/png', 'image/jpeg', 'application/pdf'];
let files = [];
/* ── 드롭존 클릭 → 파일 선택 ── */
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
});
/* ── 파일 선택 이벤트 ── */
fileInput.addEventListener('change', e => { addFiles(e.target.files); fileInput.value = ''; });
/* ── 드래그앤드롭 ── */
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('file-upload__dropzone--active'); });
dropzone.addEventListener('dragleave', e => { if (!dropzone.contains(e.relatedTarget)) dropzone.classList.remove('file-upload__dropzone--active'); });
dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('file-upload__dropzone--active'); addFiles(e.dataTransfer.files); });
/* ── 파일 추가 ── */
function addFiles(incoming) {
Array.from(incoming).forEach(file => {
if (!ALLOWED.includes(file.type)) return showStatus(`${file.name}: 지원하지 않는 형식입니다.`, true);
if (file.size > MAX_SIZE) return showStatus(`${file.name}: 파일 크기가 5MB를 초과합니다.`, true);
if (!files.find(f => f.name === file.name && f.size === file.size)) files.push(file);
});
renderList();
}
/* ── 파일 목록 렌더링 ── */
function renderList() {
fileList.innerHTML = '';
fileList.hidden = files.length === 0;
files.forEach((file, i) => {
const ext = file.name.split('.').pop().toUpperCase();
const type = file.type.startsWith('image/') ? 'img' : file.type === 'application/pdf' ? 'pdf' : 'default';
const li = document.createElement('li');
li.className = 'file-upload__item';
li.innerHTML = `
<span class="file-upload__item-ext file-upload__item-ext--${type}">${ext}</span>
<span class="file-upload__item-name">${file.name}</span>
<span class="file-upload__item-size">${formatBytes(file.size)}</span>
<button class="file-upload__item-remove" type="button" aria-label="${file.name} 삭제">×</button>`;
li.querySelector('.file-upload__item-remove').addEventListener('click', () => { files.splice(i, 1); renderList(); });
fileList.appendChild(li);
});
}
/* ── 전체 삭제 ── */
btnClear.addEventListener('click', () => { files = []; renderList(); hideStatus(); });
/* ── 파일 업로드 ── */
btnUpload.addEventListener('click', async () => {
if (files.length === 0) return showStatus('업로드할 파일을 선택해주세요.', true);
btnUpload.disabled = true;
btnUpload.textContent = '업로드 중...';
hideStatus();
try {
/* ──────────────────────────────────────────────────────
서버 업로드 (실제 배포 시 아래 주석을 해제하세요)
──────────────────────────────────────────────────────
const formData = new FormData();
files.forEach(file => formData.append('files', file));
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
// headers: { 'Authorization': `Bearer ${YOUR_TOKEN}` },
});
if (!response.ok) throw new Error(`서버 오류: ${response.status}`);
const result = await response.json();
// console.log('업로드 결과:', result);
// ── DB 저장은 서버 사이드에서 처리 (예시) ──
// INSERT INTO uploaded_files (name, size, mime_type, url, created_at)
// VALUES (?, ?, ?, ?, NOW())
────────────────────────────────────────────────────── */
// 데모 시뮬레이션 (실제 배포 시 위 코드로 교체)
await new Promise(resolve => setTimeout(resolve, 1500));
showStatus(`✓ ${files.length}개 파일 업로드 완료`);
files = [];
renderList();
} catch (err) {
showStatus(`✗ 업로드 실패: ${err.message}`, true);
} finally {
btnUpload.disabled = false;
btnUpload.textContent = '파일 업로드';
}
});
/* ── 유틸 ── */
function formatBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
return (b / (1024 * 1024)).toFixed(1) + ' MB';
}
function showStatus(msg, isError = false) {
fileStatus.textContent = msg;
fileStatus.className = 'file-upload__status' + (isError ? ' file-upload__status--error' : '');
fileStatus.hidden = false;
}
function hideStatus() { fileStatus.hidden = true; }
Backend
API 명세
POST /api/upload
Content-Type: multipart/form-data
Request Body (FormData):
files[] File[] 업로드할 파일 배열
Response 200:
{
"success": true,
"uploaded": [
{ "name": "photo.png", "size": 1234567, "url": "/uploads/abc123.png" },
{ "name": "doc.pdf", "size": 3800000, "url": "/uploads/def456.pdf" }
]
}
Response 400 (유효성 실패):
{ "success": false, "error": "허용되지 않는 파일 형식입니다." }
Response 413 (용량 초과):
{ "success": false, "error": "파일 크기가 5MB를 초과합니다." }
Response 500 (서버 오류):
{ "success": false, "error": "파일 저장 중 오류가 발생했습니다." }
Node.js — Express + multer
// npm install multer uuid
const express = require('express');
const multer = require('multer');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
/* ── 저장소 설정 ── */
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/'); // 실제 경로로 변경
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, uuidv4() + ext); // 파일명 UUID로 교체 (경로 탐색 공격 방지)
},
});
/* ── 파일 필터 (서버 측 재검증 필수) ── */
const ALLOWED_MIMES = ['image/png', 'image/jpeg', 'application/pdf'];
const fileFilter = (req, file, cb) => {
if (ALLOWED_MIMES.includes(file.mimetype)) cb(null, true);
else cb(new Error('허용되지 않는 파일 형식입니다.'), false);
};
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
});
/* ── 업로드 엔드포인트 ── */
router.post('/upload', upload.array('files'), async (req, res) => {
try {
const uploaded = req.files.map(file => ({
name: file.originalname,
size: file.size,
url: `/uploads/${file.filename}`,
}));
/* ── DB 저장 ──────────────────────────────────────────
const db = req.app.get('db'); // DB 연결 인스턴스
await Promise.all(
uploaded.map(f =>
db.query(
`INSERT INTO uploaded_files
(original_name, stored_name, size, mime_type, url, uploaded_at)
VALUES (?, ?, ?, ?, ?, NOW())`,
[f.name, f.url.split('/').pop(), f.size, req.files[0].mimetype, f.url]
)
)
);
───────────────────────────────────────────────────── */
res.json({ success: true, uploaded });
} catch (err) {
console.error('[upload error]', err);
res.status(500).json({ success: false, error: '파일 저장 중 오류가 발생했습니다.' });
}
});
/* ── multer 에러 처리 ── */
router.use((err, req, res, next) => {
if (err.code === 'LIMIT_FILE_SIZE')
return res.status(413).json({ success: false, error: '파일 크기가 5MB를 초과합니다.' });
if (err.message)
return res.status(400).json({ success: false, error: err.message });
next(err);
});
module.exports = router;
DB 스키마 (MySQL / MariaDB)
CREATE TABLE uploaded_files ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, original_name VARCHAR(255) NOT NULL COMMENT '원본 파일명', stored_name VARCHAR(255) NOT NULL COMMENT '서버 저장 파일명 (UUID)', size INT UNSIGNED NOT NULL COMMENT '파일 크기 (bytes)', mime_type VARCHAR(100) NOT NULL COMMENT 'MIME 타입', url VARCHAR(500) NOT NULL COMMENT '접근 URL', uploaded_by BIGINT UNSIGNED NULL COMMENT 'users.id FK (로그인 시)', uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_uploaded_by (uploaded_by), INDEX idx_uploaded_at (uploaded_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
보안 체크리스트
| 항목 | 내용 | 적용 위치 |
|---|---|---|
| 파일 형식 검증 | MIME 타입을 클라이언트·서버 양쪽 모두 검증. 확장자만으로 신뢰 금지 | multer fileFilter |
| 파일명 변환 | 원본 파일명 그대로 저장 금지. UUID 또는 해시로 교체해 경로 탐색 공격 차단 | multer filename |
| 파일 크기 제한 | 클라이언트(5MB) + 서버(limits.fileSize) 이중 적용 |
multer limits |
| 저장 경로 격리 | uploads/ 폴더를 웹루트 밖에 두거나 직접 접근 차단. S3 등 오브젝트 스토리지 권장 |
서버 설정 |
| 악성 파일 검사 | 실서비스에서는 ClamAV 등 바이러스 스캔 도구 연동 권장 | 저장 후 비동기 처리 |
| 인증/인가 | 업로드 API에 JWT 또는 세션 인증 적용. 미인증 접근 차단 | 미들웨어 |
| 업로드 수 제한 | 스팸 방지 — rate limiting 또는 사용자당 일일 업로드 횟수 제한 | 미들웨어 / DB |
Classes
| 클래스 | 태그 | 설명 |
|---|---|---|
.file-upload |
루트 | 전체 카드 래퍼 |
.file-upload__header |
요소 | 제목 + 설명 헤더 |
.file-upload__title |
요소 | 제목 텍스트 (18px Bold) |
.file-upload__subtitle |
요소 | 설명 텍스트 (16px Regular, gray-500) |
.file-upload__dropzone |
요소 | 드래그앤드롭 영역 — role="button" tabindex="0" 필수 |
.file-upload__dropzone--active |
수정자 | 드래그 오버 상태 — JS로 토글 |
.file-upload__hint-link |
요소 | "클릭해서 업로드" 강조 span — green-500 |
.file-upload__list |
요소 | 선택 파일 목록 (<ul>) — 빈 상태면 hidden |
.file-upload__item |
요소 | 파일 항목 (<li>) |
.file-upload__item-ext--img |
수정자 | 이미지 확장자 배지 (초록) |
.file-upload__item-ext--pdf |
수정자 | PDF 확장자 배지 (빨간) |
.file-upload__status |
요소 | 결과/안내 메시지 — 기본 초록 배경 |
.file-upload__status--error |
수정자 | 오류 메시지 상태 — 빨간 배경 |
.file-upload__btn--clear |
수정자 | 전체 삭제 버튼 — secondary blue |
.file-upload__btn--upload |
수정자 | 업로드 버튼 — primary green |