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

**작성일:** 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 (
        <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>
    );
}
```

### 용량 표시 바

```jsx
// 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개월 후)
1. **바이러스 스캔**
   - ClamAV 설치 및 연동
   - 비동기 스캔 (큐 사용)

2. **폴더별 권한**
   - folder_permissions 테이블 활용
   - Middleware로 권한 체크

3. **사용량 분석**
   - 대시보드 추가
   - 차트 라이브러리 (Chart.js, Recharts)

### Phase 3 — ✅ 완료 (2026-03-20)
1. **Object Storage → Cloudflare R2** 전환 완료
2. **이미지 서빙 → R2 Presigned URL** 방식 적용
3. **CDN** — 미적용 (Cloudflare DNS 등록 필요)

---

## ☁️ R2 파일 서빙 정책 (2026-03-20~)

### 핵심 원칙

- **파일 저장**: Cloudflare R2 (S3 호환)
- **이미지 서빙**: API Resource에서 `image_url` (presigned URL) 반환 → 브라우저가 R2 직접 로드
- **프록시 금지**: MNG/React에서 API를 경유한 바이너리 스트리밍 방식 사용하지 않음
- **공개 라우트 금지**: 인증 없는 파일 접근 라우트 생성 금지

### 파일 접근 방식

```
Browser → R2 presigned URL 직접 로드 (1홉)
```

### API Resource 규칙

`image_file_id`를 반환하는 모든 API Resource는 `image_url`도 함께 반환한다.

- `File::presignedUrl()` 메서드 사용 (30분 유효)
- `temporaryUrl()`은 로컬 서명 생성만 수행 (R2 네트워크 호출 없음)

### 소비자(MNG, React) 규칙

- API 응답의 `image_url` 필드를 `<img src>`에 직접 사용
- `image_url`이 없으면 `route('files.view', $id)` fallback (MNG FileViewController redirect)

### 보안

- 서명 기반 접근: 30분 만료, 만료 후 403
- 테넌트 격리: BelongsToTenant 스코프 적용 후 URL 발급
- 인증 없는 파일 공개 라우트 금지

### 향후 개선

- **R2 Custom Domain**: 도메인을 Cloudflare DNS에 등록하면 CDN 캐시 적용 가능 (10~30ms)
- **확대 적용**: 품목, 문서 등 `image_file_id`가 있는 모든 API Resource에 `image_url` 추가
