# 파일 저장 시스템 구현 가이드 **작성일:** 2025년 11월 4일 **목적:** Claude Code를 통한 시스템 구현 **기술 스택:** PHP/Laravel, React, MySQL --- ## 📌 시스템 개요 멀티테넌시 SaaS ERP+MES 시스템의 파일 저장 및 관리 시스템 구현 가이드 ### 핵심 요구사항 1. 테넌트별 독립적인 파일 저장소 2. 동적 폴더 구조 관리 3. 문서-파일 첨부 관계 관리 4. 용량 관리 및 경고 시스템 5. 휴지통 및 복구 기능 6. 외부 공유 (임시 링크) 7. 미리보기 기능 --- ## 🗄️ 데이터베이스 설계 ### 1. tenants 테이블 ```sql 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 테이블 (동적 폴더 관리) ```sql 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 테이블 ```sql 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 테이블 (감사 추적) ```sql 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 테이블 (외부 공유) ```sql 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 테이블 (모니터링) ```sql 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 테이블 (향후 확장) ```sql 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) ```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 난수 생성:** ```php function generateStoredName(string $extension): string { // 64bit = 16자 hex $random = bin2hex(random_bytes(8)); return $random . '.' . $extension; } ``` **예시:** ``` 입력: contract.pdf 출력: a1b2c3d4e5f6g7h8.pdf ``` ### 2. 경로 생성 **temp 폴더:** ```php function buildTempPath(int $tenantId, string $storedName): string { $year = date('Y'); $month = date('m'); return "tenants/{$tenantId}/temp/{$year}/{$month}/{$storedName}"; } ``` **폴더 경로:** ```php 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. 용량 체크 로직 ```php 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. 파일 삭제 로직 ```php 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 ```php 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. 완전 삭제 ```php 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. 외부 공유 링크 생성 ```php 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}"); } ``` **공유 링크 다운로드:** ```php 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 폴더 정리 (매일) ```php // 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. 휴지통 정리 (매일) ```php 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. 만료된 공유 링크 정리 (매일) ```php 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. 용량 히스토리 기록 (매일) ```php 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) ```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 # 용량 관리 ``` ### 파일 업로드 예시 ```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 (