- 5130 레거시 시스템 분석 (00_OVERVIEW ~ 08_SAM_COMPARISON) - MES 프로젝트 문서 - API/프론트엔드 스펙 문서 - 가이드 및 레퍼런스 문서
29 KiB
29 KiB
파일 저장 시스템 구현 가이드
작성일: 2025년 11월 4일
목적: Claude Code를 통한 시스템 구현
기술 스택: PHP/Laravel, React, MySQL
📌 시스템 개요
멀티테넌시 SaaS ERP+MES 시스템의 파일 저장 및 관리 시스템 구현 가이드
핵심 요구사항
- 테넌트별 독립적인 파일 저장소
- 동적 폴더 구조 관리
- 문서-파일 첨부 관계 관리
- 용량 관리 및 경고 시스템
- 휴지통 및 복구 기능
- 외부 공유 (임시 링크)
- 미리보기 기능
🗄️ 데이터베이스 설계
1. tenants 테이블
CREATE TABLE tenants (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
uuid CHAR(36) UNIQUE NOT NULL COMMENT 'URL용 UUID',
name VARCHAR(255) NOT NULL,
-- 용량 관리
storage_limit BIGINT DEFAULT 10737418240 COMMENT '10GB',
storage_used BIGINT DEFAULT 0 COMMENT '사용 중인 용량',
storage_warning_sent_at TIMESTAMP NULL COMMENT '90% 경고 발송 시간',
storage_grace_period_until TIMESTAMP NULL COMMENT '유예 기간 종료 시간',
-- 플랜 (향후 확장)
plan_type VARCHAR(50) DEFAULT NULL COMMENT 'null, basic, pro, enterprise',
plan_storage_limit BIGINT NULL COMMENT '플랜별 용량',
status VARCHAR(20) DEFAULT 'active' COMMENT 'active, suspended, terminated',
terminated_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (status),
INDEX idx_storage_used (storage_used)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
2. folders 테이블 (동적 폴더 관리)
CREATE TABLE folders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
-- 폴더 정보
folder_key VARCHAR(50) NOT NULL COMMENT 'product, quality, accounting',
folder_name VARCHAR(100) NOT NULL COMMENT '생산관리, 품질관리, 회계',
description TEXT NULL,
-- 순서 및 표시
display_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
-- UI 커스터마이징 (선택)
icon VARCHAR(50) NULL COMMENT 'icon-production, icon-quality',
color VARCHAR(20) NULL COMMENT '#3B82F6',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
UNIQUE KEY unique_tenant_folder (tenant_id, folder_key),
INDEX idx_active (tenant_id, is_active),
INDEX idx_display_order (tenant_id, display_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
3. files 테이블
CREATE TABLE files (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
-- 파일명 (이중 시스템)
display_name VARCHAR(255) NOT NULL COMMENT '사용자가 보는 이름: 계약서.pdf',
stored_name VARCHAR(255) NOT NULL COMMENT '실제 저장 이름: a1b2c3d4e5f6g7h8.pdf',
-- 폴더 및 경로
folder_id BIGINT NULL COMMENT 'folders 테이블 FK, temp는 NULL',
is_temp BOOLEAN DEFAULT TRUE COMMENT 'temp 폴더 여부',
file_path VARCHAR(1000) NOT NULL COMMENT '전체 경로',
-- 파일 정보
file_size BIGINT NOT NULL COMMENT 'bytes',
mime_type VARCHAR(100) NOT NULL,
file_type ENUM('document', 'image', 'excel', 'archive') NOT NULL,
-- 문서 연결
document_id BIGINT NULL COMMENT '문서(시트)에 첨부 시',
document_type VARCHAR(50) NULL COMMENT 'work_order, quality_check',
-- 업로드 정보
uploaded_by BIGINT NOT NULL,
-- Soft Delete
deleted_at TIMESTAMP NULL,
deleted_by BIGINT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL,
FOREIGN KEY (uploaded_by) REFERENCES users(id),
FOREIGN KEY (deleted_by) REFERENCES users(id),
INDEX idx_tenant_folder (tenant_id, folder_id),
INDEX idx_document (document_id),
INDEX idx_is_temp (is_temp),
INDEX idx_deleted_at (deleted_at),
INDEX idx_stored_name (stored_name),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
4. file_deletion_logs 테이블 (감사 추적)
CREATE TABLE file_deletion_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
file_id BIGINT NOT NULL,
-- 삭제된 파일 정보 (스냅샷)
file_name VARCHAR(255) NOT NULL,
file_path VARCHAR(1000) NOT NULL,
file_size BIGINT NOT NULL,
folder_id BIGINT NULL,
document_id BIGINT NULL,
-- 삭제 정보
deleted_by BIGINT NOT NULL,
deleted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deletion_type ENUM('soft', 'permanent') DEFAULT 'soft',
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (deleted_by) REFERENCES users(id),
INDEX idx_tenant_id (tenant_id),
INDEX idx_deleted_at (deleted_at),
INDEX idx_file_id (file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
5. file_share_links 테이블 (외부 공유)
CREATE TABLE file_share_links (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
file_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
-- 링크 정보
token VARCHAR(64) UNIQUE NOT NULL COMMENT '난수 토큰',
expires_at TIMESTAMP NOT NULL COMMENT '24시간 후',
-- 통계
download_count INT DEFAULT 0,
last_downloaded_at TIMESTAMP NULL,
last_downloaded_ip VARCHAR(45) NULL,
created_by BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (created_by) REFERENCES users(id),
INDEX idx_token (token),
INDEX idx_expires_at (expires_at),
INDEX idx_file_id (file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
6. storage_usage_history 테이블 (모니터링)
CREATE TABLE storage_usage_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
storage_used BIGINT NOT NULL COMMENT '사용량 bytes',
file_count INT NOT NULL COMMENT '파일 개수',
-- 폴더별 상세 (JSON)
folder_usage JSON NULL COMMENT '{"product": 1024000, "quality": 512000}',
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
INDEX idx_tenant_recorded (tenant_id, recorded_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
7. folder_permissions 테이블 (향후 확장)
CREATE TABLE folder_permissions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
folder_id BIGINT NOT NULL,
role_name VARCHAR(50) NOT NULL COMMENT 'admin, manager, user',
-- 권한
can_upload BOOLEAN DEFAULT TRUE,
can_download BOOLEAN DEFAULT TRUE,
can_view BOOLEAN DEFAULT TRUE,
can_delete BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE,
UNIQUE KEY unique_folder_role (folder_id, role_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
📁 파일 시스템 구조
디렉토리 레이아웃
storage/app/tenants/
├── 1/ # tenant_id
│ ├── product/ # 폴더키 (동적)
│ │ └── 2025/
│ │ └── 01/
│ │ ├── a1b2c3d4e5f6g7h8.pdf
│ │ └── i9j0k1l2m3n4o5p6.jpg
│ │
│ ├── quality/
│ │ └── 2025/01/
│ │
│ ├── accounting/
│ │ └── 2025/01/
│ │
│ └── temp/ # 임시 (고정)
│ └── 2025/
│ └── 01/
│ └── q7r8s9t0u1v2w3x4.xlsx
│
└── 2/ # 다른 테넌트
├── fulfillment/ # 유통업 폴더
└── temp/
경로 규칙
형식:
/tenants/{tenant_id}/{folder_key}/{year}/{month}/{stored_name}
예시:
/tenants/1/product/2025/01/a1b2c3d4e5f6g7h8.pdf
/tenants/1/quality/2025/01/i9j0k1l2m3n4o5p6.jpg
/tenants/1/temp/2025/01/q7r8s9t0u1v2w3x4.xlsx
Depth: 5단계
⚙️ 설정 (config/filesystems.php)
'disks' => [
'tenant' => [
'driver' => 'local',
'root' => storage_path('app/tenants'),
'visibility' => 'private',
],
],
'file_constraints' => [
'max_file_size' => 20 * 1024 * 1024, // 20MB
'allowed_mimes' => [
'document' => [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/x-hwp',
],
'image' => [
'image/jpeg',
'image/png',
],
'excel' => [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'text/csv',
],
'archive' => [
'application/zip',
'application/x-rar-compressed',
],
],
'blocked_extensions' => [
'exe', 'sh', 'bat', 'cmd', 'dwg', 'dxf', 'step', 'iges',
],
],
'storage_policies' => [
'default_limit' => 10 * 1024 * 1024 * 1024, // 10GB
'warning_threshold' => 0.9, // 90%
'grace_period_days' => 7,
'temp_cleanup_days' => 7,
'trash_retention_days' => 30,
],
'share_link' => [
'expiry_hours' => 24,
'max_downloads' => null, // 무제한
'require_password' => false,
],
🔧 핵심 로직
1. 파일명 생성
64bit 난수 생성:
function generateStoredName(string $extension): string
{
// 64bit = 16자 hex
$random = bin2hex(random_bytes(8));
return $random . '.' . $extension;
}
예시:
입력: contract.pdf
출력: a1b2c3d4e5f6g7h8.pdf
2. 경로 생성
temp 폴더:
function buildTempPath(int $tenantId, string $storedName): string
{
$year = date('Y');
$month = date('m');
return "tenants/{$tenantId}/temp/{$year}/{$month}/{$storedName}";
}
폴더 경로:
function buildFolderPath(int $tenantId, string $folderKey, string $storedName): string
{
$year = date('Y');
$month = date('m');
return "tenants/{$tenantId}/{$folderKey}/{$year}/{$month}/{$storedName}";
}
3. 파일 업로드 프로세스
[사용자] 파일 선택
↓
[Frontend] multipart/form-data로 전송
↓
[Backend] 파일 검증
- 확장자 체크
- MIME 타입 검증
- 파일 크기 체크
- 용량 체크
↓
[Backend] temp 폴더에 저장
- 난수 파일명 생성
- 경로: /tenants/{id}/temp/{year}/{month}/{random}.{ext}
- DB 저장 (is_temp=true, folder_id=NULL)
↓
[Response] file_id 반환
4. 문서 첨부 프로세스
[사용자] 문서 작성 + 파일 선택 (file_id들)
↓
[Backend] 문서 저장
↓
[Backend] 파일 이동
- temp 파일인지 확인
- 새 경로 생성 (/tenants/{id}/{folder}/...)
- 물리 파일 이동
- DB 업데이트:
* is_temp = false
* folder_id = {folder_id}
* document_id = {document_id}
5. 용량 체크 로직
function checkStorageQuota(Tenant $tenant, int $fileSize): array
{
$newUsage = $tenant->storage_used + $fileSize;
$limit = $tenant->storage_limit;
$percentage = ($newUsage / $limit) * 100;
// 100% 초과
if ($newUsage > $limit) {
// 유예 기간 확인
if ($tenant->storage_grace_period_until &&
now()->lessThan($tenant->storage_grace_period_until)) {
return [
'allowed' => true,
'warning' => true,
'message' => "Storage quota exceeded. Grace period until: {$tenant->storage_grace_period_until}",
];
}
// 유예 만료 - 차단
return [
'allowed' => false,
'message' => 'Storage quota exceeded. Please delete files or contact support.',
];
}
// 90% 경고
if ($percentage >= 90 && !$tenant->storage_warning_sent_at) {
// 경고 발송 (1회만)
$tenant->update([
'storage_warning_sent_at' => now(),
'storage_grace_period_until' => now()->addDays(7),
]);
// 이메일 발송 (큐)
dispatch(new SendStorageWarningEmail($tenant));
}
return ['allowed' => true];
}
6. 파일 삭제 로직
function canDeleteFile(File $file, User $user): bool
{
// 관리자는 모두 삭제 가능
if ($user->isAdmin()) {
return true;
}
// 본인 파일이 아니면 불가
if ($file->uploaded_by !== $user->id) {
return false;
}
// 문서에 첨부된 경우
if ($file->document_id) {
$document = Document::find($file->document_id);
// 승인된 문서면 불가
if ($document && $document->status === 'approved') {
return false;
}
}
return true;
}
7. Soft Delete
function softDeleteFile(File $file, User $user): void
{
DB::transaction(function() use ($file, $user) {
// Soft delete
$file->update([
'deleted_at' => now(),
'deleted_by' => $user->id,
]);
// 로그 기록
DB::table('file_deletion_logs')->insert([
'tenant_id' => $file->tenant_id,
'file_id' => $file->id,
'file_name' => $file->display_name,
'file_path' => $file->file_path,
'file_size' => $file->file_size,
'folder_id' => $file->folder_id,
'document_id' => $file->document_id,
'deleted_by' => $user->id,
'deleted_at' => now(),
'deletion_type' => 'soft',
]);
// 용량은 아직 안 줄임 (휴지통에 있음)
});
}
8. 완전 삭제
function permanentDeleteFile(File $file): void
{
DB::transaction(function() use ($file) {
// 물리 파일 삭제
Storage::disk('tenant')->delete($file->file_path);
// 용량 감소
$tenant = Tenant::find($file->tenant_id);
$tenant->decrement('storage_used', $file->file_size);
// 로그 업데이트
DB::table('file_deletion_logs')
->where('file_id', $file->id)
->where('deletion_type', 'soft')
->update(['deletion_type' => 'permanent']);
// DB에서 완전 삭제
$file->forceDelete();
});
}
9. 외부 공유 링크 생성
function createShareLink(File $file, User $user): string
{
$token = bin2hex(random_bytes(32)); // 64자 토큰
DB::table('file_share_links')->insert([
'file_id' => $file->id,
'tenant_id' => $file->tenant_id,
'token' => $token,
'expires_at' => now()->addHours(24),
'created_by' => $user->id,
'created_at' => now(),
]);
return url("/share/download/{$token}");
}
공유 링크 다운로드:
function downloadSharedFile(string $token): Response
{
$link = DB::table('file_share_links')
->where('token', $token)
->where('expires_at', '>', now())
->first();
if (!$link) {
abort(404, 'Link expired or not found');
}
$file = File::findOrFail($link->file_id);
// 다운로드 횟수 증가
DB::table('file_share_links')
->where('id', $link->id)
->increment('download_count');
DB::table('file_share_links')
->where('id', $link->id)
->update([
'last_downloaded_at' => now(),
'last_downloaded_ip' => request()->ip(),
]);
return response()->download(
Storage::disk('tenant')->path($file->file_path),
$file->display_name
);
}
🤖 배치 작업 (Cron)
1. temp 폴더 정리 (매일)
// app/Console/Commands/CleanupTempFiles.php
class CleanupTempFiles extends Command
{
protected $signature = 'storage:cleanup-temp';
public function handle()
{
$threshold = now()->subDays(7);
$files = File::where('is_temp', true)
->where('created_at', '<', $threshold)
->get();
$this->info("Found {$files->count()} temp files to delete");
foreach ($files as $file) {
Storage::disk('tenant')->delete($file->file_path);
$file->forceDelete();
}
$this->info('Cleanup completed');
}
}
2. 휴지통 정리 (매일)
class CleanupTrash extends Command
{
protected $signature = 'storage:cleanup-trash';
public function handle()
{
$threshold = now()->subDays(30);
$files = File::whereNotNull('deleted_at')
->where('deleted_at', '<', $threshold)
->get();
$this->info("Found {$files->count()} files in trash to permanently delete");
foreach ($files as $file) {
$this->permanentDelete($file);
}
$this->info('Trash cleanup completed');
}
private function permanentDelete(File $file)
{
DB::transaction(function() use ($file) {
Storage::disk('tenant')->delete($file->file_path);
$tenant = Tenant::find($file->tenant_id);
$tenant->decrement('storage_used', $file->file_size);
DB::table('file_deletion_logs')
->where('file_id', $file->id)
->update(['deletion_type' => 'permanent']);
$file->forceDelete();
});
}
}
3. 만료된 공유 링크 정리 (매일)
class CleanupExpiredLinks extends Command
{
protected $signature = 'storage:cleanup-links';
public function handle()
{
$deleted = DB::table('file_share_links')
->where('expires_at', '<', now()->subDays(7))
->delete();
$this->info("Deleted {$deleted} expired share links");
}
}
4. 용량 히스토리 기록 (매일)
class RecordStorageUsage extends Command
{
protected $signature = 'storage:record-usage';
public function handle()
{
$tenants = Tenant::where('status', 'active')->get();
foreach ($tenants as $tenant) {
// 폴더별 사용량
$folderUsage = File::where('tenant_id', $tenant->id)
->whereNull('deleted_at')
->whereNotNull('folder_id')
->selectRaw('folder_id, SUM(file_size) as total')
->groupBy('folder_id')
->get()
->mapWithKeys(function($item) {
$folder = Folder::find($item->folder_id);
return [$folder->folder_key => $item->total];
});
DB::table('storage_usage_history')->insert([
'tenant_id' => $tenant->id,
'storage_used' => $tenant->storage_used,
'file_count' => File::where('tenant_id', $tenant->id)
->whereNull('deleted_at')
->count(),
'folder_usage' => json_encode($folderUsage),
'recorded_at' => now(),
]);
}
$this->info('Storage usage recorded');
}
}
Cron 등록 (app/Console/Kernel.php)
protected function schedule(Schedule $schedule)
{
$schedule->command('storage:cleanup-temp')->daily();
$schedule->command('storage:cleanup-trash')->daily();
$schedule->command('storage:cleanup-links')->daily();
$schedule->command('storage:record-usage')->daily();
}
📡 API 엔드포인트
파일 관리
POST /api/files/upload # 임시 업로드
GET /api/files/{id} # 파일 정보
GET /api/files/{id}/download # 다운로드
DELETE /api/files/{id} # 삭제 (soft)
POST /api/files/{id}/restore # 복구
DELETE /api/files/{id}/permanent # 완전 삭제
GET /api/files # 파일 목록 (폴더별, 페이징)
GET /api/files/trash # 휴지통
POST /api/files/{id}/share # 공유 링크 생성
GET /share/download/{token} # 공유 다운로드 (인증 불필요)
폴더 관리
GET /api/folders # 폴더 목록
POST /api/folders # 폴더 생성
PATCH /api/folders/{id} # 폴더 수정
DELETE /api/folders/{id} # 폴더 비활성화
용량 관리
GET /api/storage/usage # 현재 사용량
GET /api/storage/history # 사용량 히스토리
🎨 프론트엔드 (React)
컴포넌트 구조
src/
├── components/
│ ├── FileUpload/
│ │ ├── FileUploadButton.jsx # 업로드 버튼
│ │ ├── FileUploadModal.jsx # 업로드 모달
│ │ └── FileDropzone.jsx # 드래그앤드롭
│ │
│ ├── FileList/
│ │ ├── FileListTable.jsx # 파일 목록 테이블
│ │ ├── FileListItem.jsx # 파일 항목
│ │ └── FilePreviewModal.jsx # 미리보기 모달
│ │
│ ├── FileActions/
│ │ ├── FileDownload.jsx # 다운로드 버튼
│ │ ├── FileDelete.jsx # 삭제 버튼
│ │ ├── FileShare.jsx # 공유 버튼
│ │ └── FileRestore.jsx # 복구 버튼
│ │
│ ├── StorageQuota/
│ │ ├── StorageQuotaBar.jsx # 용량 표시 바
│ │ └── StorageWarning.jsx # 경고 배너
│ │
│ └── FolderManager/
│ ├── FolderTree.jsx # 폴더 트리
│ └── FolderCreateModal.jsx # 폴더 생성
│
└── pages/
├── FilesPage.jsx # 파일 메인
├── TrashPage.jsx # 휴지통
└── StoragePage.jsx # 용량 관리
파일 업로드 예시
// FileUploadButton.jsx
import { useState } from 'react';
import axios from 'axios';
export default function FileUploadButton({ folderId, onUploadComplete }) {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const handleFileChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
// 파일 크기 체크
if (file.size > 20 * 1024 * 1024) {
alert('파일 크기는 20MB를 초과할 수 없습니다.');
return;
}
const formData = new FormData();
formData.append('file', file);
setUploading(true);
try {
const response = await axios.post('/api/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setProgress(percentCompleted);
}
});
onUploadComplete(response.data);
} catch (error) {
if (error.response?.status === 413) {
alert('용량 초과: ' + error.response.data.message);
} else {
alert('업로드 실패: ' + error.message);
}
} finally {
setUploading(false);
setProgress(0);
}
};
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
style={{ display: 'none' }}
id="file-upload"
/>
<label htmlFor="file-upload">
<button disabled={uploading}>
{uploading ? `업로드 중 ${progress}%` : '파일 업로드'}
</button>
</label>
</div>
);
}
용량 표시 바
// StorageQuotaBar.jsx
export default function StorageQuotaBar({ used, limit }) {
const percentage = (used / limit) * 100;
const usedGB = (used / (1024 ** 3)).toFixed(2);
const limitGB = (limit / (1024 ** 3)).toFixed(2);
const getColor = () => {
if (percentage >= 100) return 'red';
if (percentage >= 90) return 'orange';
return 'blue';
};
return (
<div style={{ padding: '20px' }}>
<div style={{ marginBottom: '8px' }}>
<span>{usedGB}GB / {limitGB}GB 사용 중</span>
<span style={{ float: 'right' }}>{percentage.toFixed(1)}%</span>
</div>
<div style={{
width: '100%',
height: '20px',
backgroundColor: '#eee',
borderRadius: '10px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.min(percentage, 100)}%`,
height: '100%',
backgroundColor: getColor(),
transition: 'width 0.3s'
}} />
</div>
{percentage >= 90 && (
<div style={{ color: 'orange', marginTop: '8px' }}>
⚠️ 용량이 부족합니다. 파일을 정리해주세요.
</div>
)}
</div>
);
}
🧪 테스트 시나리오
1. 파일 업로드 테스트
- 허용 파일 타입 업로드 성공
- 차단 파일 타입 업로드 실패
- 20MB 초과 파일 업로드 실패
- temp 폴더에 정상 저장
- DB에 is_temp=true로 저장
2. 문서 첨부 테스트
- temp 파일 → 폴더로 이동
- DB에 folder_id, document_id 업데이트
- is_temp=false 변경
3. 용량 관리 테스트
- 90% 도달 시 경고 이메일 발송
- 100% 초과 시 유예 기간 설정
- 유예 중 업로드 가능
- 유예 종료 후 업로드 차단
4. 삭제 테스트
- 일반 사용자: 본인 파일 삭제
- 승인된 문서 첨부 파일 삭제 불가
- 관리자: 모든 파일 삭제
- Soft delete로 휴지통 이동
- 삭제 로그 기록
5. 휴지통 테스트
- 휴지통 목록 조회
- 파일 복구
- 30일 후 자동 삭제
- 완전 삭제 시 용량 감소
6. 공유 링크 테스트
- 공유 링크 생성
- 24시간 이내 다운로드 가능
- 만료 후 404 에러
- 다운로드 횟수 기록
7. 배치 작업 테스트
- temp 파일 7일 후 삭제
- 휴지통 30일 후 삭제
- 만료된 공유 링크 삭제
- 용량 히스토리 기록
🚀 배포 체크리스트
Phase 1 (MVP)
- DB 마이그레이션 실행
- 초기 폴더 데이터 생성
- 스토리지 디스크 설정
- Cron 작업 등록
- 백업 스크립트 설정
설정 확인
- 파일 업로드 max size (php.ini)
- 디스크 용량 충분한지
- rsync 백업 서버 접속 확인
- HTTPS 설정
모니터링
- 디스크 사용량 알림
- 에러 로그 모니터링
- 백업 성공 여부 확인
📚 참고 자료
Laravel 공식 문서
- File Storage: https://laravel.com/docs/filesystem
- File Uploads: https://laravel.com/docs/requests#files
- Task Scheduling: https://laravel.com/docs/scheduling
보안
- MIME Type 검증
- 파일 업로드 보안: https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload
성능 최적화
- 대용량 파일 업로드: chunked upload
- CDN 연동 (Phase 3)
문서 끝
추가 구현 시 고려사항
Phase 2 (3~6개월 후)
-
바이러스 스캔
- ClamAV 설치 및 연동
- 비동기 스캔 (큐 사용)
-
폴더별 권한
- folder_permissions 테이블 활용
- Middleware로 권한 체크
-
사용량 분석
- 대시보드 추가
- 차트 라이브러리 (Chart.js, Recharts)
Phase 3 (1년 후)
-
Object Storage 전환
- AWS S3 / Naver Cloud Object Storage
- Laravel Flysystem 드라이버 변경
- 기존 파일 마이그레이션
-
CDN 연동
- CloudFront / CloudFlare
- 이미지 썸네일 자동 생성
-
고급 기능
- 파일 버전 관리
- 협업 편집
- 파일 잠금