1062 lines
29 KiB
Markdown
1062 lines
29 KiB
Markdown
|
|
# 파일 저장 시스템 구현 가이드
|
||
|
|
|
||
|
|
**작성일:** 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 (1년 후)
|
||
|
|
1. **Object Storage 전환**
|
||
|
|
- AWS S3 / Naver Cloud Object Storage
|
||
|
|
- Laravel Flysystem 드라이버 변경
|
||
|
|
- 기존 파일 마이그레이션
|
||
|
|
|
||
|
|
2. **CDN 연동**
|
||
|
|
- CloudFront / CloudFlare
|
||
|
|
- 이미지 썸네일 자동 생성
|
||
|
|
|
||
|
|
3. **고급 기능**
|
||
|
|
- 파일 버전 관리
|
||
|
|
- 협업 편집
|
||
|
|
- 파일 잠금
|