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