Files
sam-docs/guides/file-storage-guide.md
hskwon 08a8259313 docs: 5130 레거시 분석 문서 및 기존 문서 초기 커밋
- 5130 레거시 시스템 분석 (00_OVERVIEW ~ 08_SAM_COMPARISON)
- MES 프로젝트 문서
- API/프론트엔드 스펙 문서
- 가이드 및 레퍼런스 문서
2025-12-04 18:47:19 +09:00

29 KiB

파일 저장 시스템 구현 가이드

작성일: 2025년 11월 4일
목적: Claude Code를 통한 시스템 구현
기술 스택: PHP/Laravel, React, MySQL


📌 시스템 개요

멀티테넌시 SaaS ERP+MES 시스템의 파일 저장 및 관리 시스템 구현 가이드

핵심 요구사항

  1. 테넌트별 독립적인 파일 저장소
  2. 동적 폴더 구조 관리
  3. 문서-파일 첨부 관계 관리
  4. 용량 관리 및 경고 시스템
  5. 휴지통 및 복구 기능
  6. 외부 공유 (임시 링크)
  7. 미리보기 기능

🗄️ 데이터베이스 설계

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;
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 공식 문서

보안

성능 최적화

  • 대용량 파일 업로드: chunked upload
  • CDN 연동 (Phase 3)

문서 끝

추가 구현 시 고려사항

Phase 2 (3~6개월 후)

  1. 바이러스 스캔

    • ClamAV 설치 및 연동
    • 비동기 스캔 (큐 사용)
  2. 폴더별 권한

    • folder_permissions 테이블 활용
    • Middleware로 권한 체크
  3. 사용량 분석

    • 대시보드 추가
    • 차트 라이브러리 (Chart.js, Recharts)

Phase 3 (1년 후)

  1. Object Storage 전환

    • AWS S3 / Naver Cloud Object Storage
    • Laravel Flysystem 드라이버 변경
    • 기존 파일 마이그레이션
  2. CDN 연동

    • CloudFront / CloudFlare
    • 이미지 썸네일 자동 생성
  3. 고급 기능

    • 파일 버전 관리
    • 협업 편집
    • 파일 잠금