# 대용량 파일 처리 전략

> CAD 도면, 이미지 등 100MB 이상 대용량 파일 업로드/다운로드 최적화 가이드

## 현재 방식의 문제점

```typescript
// fileDownload.ts - 현재 코드
const blob = await response.blob();      // ❌ 100MB 전체를 메모리에 올림
const url = URL.createObjectURL(blob);   // ❌ 메모리 추가 사용
```

| 파일 크기 | 예상 메모리 사용 | 문제점 |
|-----------|------------------|--------|
| 10MB | ~20MB | 문제 없음 |
| 50MB | ~100MB | 모바일에서 느려짐 |
| 100MB | ~200MB | 브라우저 경고 |
| 500MB+ | ~1GB | 크래시 가능 |

---

## 다운로드 전략

### 1. 직접 URL 방식 (권장 - 가장 간단)

백엔드가 `Content-Disposition: attachment` 헤더를 제공하면 브라우저가 직접 스트리밍 다운로드 처리.

```typescript
/**
 * 대용량 파일 다운로드 - 직접 URL 방식
 * 메모리 사용: 거의 없음 (브라우저가 스트리밍 처리)
 */
export function downloadLargeFile(fileId: number, fileName?: string): void {
  const downloadUrl = `/api/proxy/files/${fileId}/download`;

  const a = document.createElement('a');
  a.href = downloadUrl;
  a.download = fileName || '';
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
}
```

**장점:**
- 구현 매우 간단
- 메모리 사용 없음
- 브라우저 내장 다운로드 UI 사용

**요구사항:**
- 백엔드 `Content-Disposition: attachment; filename="파일명"` 헤더 필요

---

### 2. iframe 방식 (폴백)

```typescript
/**
 * iframe을 통한 다운로드
 * 새 탭 차단 정책 우회 가능
 */
export function downloadViaIframe(fileId: number): void {
  const downloadUrl = `/api/proxy/files/${fileId}/download`;

  const iframe = document.createElement('iframe');
  iframe.style.display = 'none';
  iframe.src = downloadUrl;
  document.body.appendChild(iframe);

  // 다운로드 시작 후 정리
  setTimeout(() => {
    document.body.removeChild(iframe);
  }, 10000);
}
```

---

### 3. File System Access API (최신 브라우저)

스트리밍으로 직접 디스크에 저장. 메모리 사용 최소화.

```typescript
/**
 * File System Access API를 사용한 스트리밍 다운로드
 * 지원: Chrome 86+, Edge 86+, Opera 72+
 * 미지원: Firefox, Safari
 */
export async function downloadWithStream(
  fileId: number,
  fileName: string
): Promise<void> {
  // 지원 확인
  if (!('showSaveFilePicker' in window)) {
    // 폴백: 직접 URL 방식
    return downloadLargeFile(fileId, fileName);
  }

  try {
    // 저장 위치 선택 다이얼로그
    const handle = await (window as any).showSaveFilePicker({
      suggestedName: fileName,
      types: [{
        description: 'Files',
        accept: { '*/*': [] }
      }]
    });

    const writable = await handle.createWritable();
    const response = await fetch(`/api/proxy/files/${fileId}/download`);

    if (!response.body) throw new Error('No response body');

    // 스트리밍으로 직접 디스크에 저장
    await response.body.pipeTo(writable);

  } catch (error) {
    if ((error as Error).name === 'AbortError') {
      return; // 사용자 취소
    }
    throw error;
  }
}
```

**장점:**
- 메모리 사용 거의 없음
- 사용자가 저장 위치 선택 가능
- 진행률 표시 가능

---

### 4. 진행률 표시가 필요한 경우

```typescript
/**
 * 다운로드 진행률 표시
 * 주의: 전체 파일을 메모리에 올림 (대용량 비권장)
 */
export async function downloadWithProgress(
  fileId: number,
  fileName: string,
  onProgress: (percent: number) => void
): Promise<void> {
  const response = await fetch(`/api/proxy/files/${fileId}/download`);

  const contentLength = response.headers.get('Content-Length');
  const total = contentLength ? parseInt(contentLength, 10) : 0;

  if (!response.body) throw new Error('No response body');

  const reader = response.body.getReader();
  const chunks: Uint8Array[] = [];
  let received = 0;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    chunks.push(value);
    received += value.length;

    if (total > 0) {
      onProgress((received / total) * 100);
    }
  }

  // 청크 병합 → Blob 생성
  const blob = new Blob(chunks);
  const url = URL.createObjectURL(blob);

  const a = document.createElement('a');
  a.href = url;
  a.download = fileName;
  a.click();

  URL.revokeObjectURL(url);
}
```

---

## 업로드 전략

### 1. 기본 업로드 (진행률 표시)

```typescript
/**
 * XMLHttpRequest로 업로드 진행률 표시
 */
export function uploadWithProgress(
  file: File,
  url: string,
  onProgress: (percent: number) => void
): Promise<Response> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append('file', file);

    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        onProgress((e.loaded / e.total) * 100);
      }
    });

    xhr.addEventListener('load', () => {
      resolve(new Response(xhr.response, { status: xhr.status }));
    });

    xhr.addEventListener('error', () => {
      reject(new Error('Upload failed'));
    });

    xhr.open('POST', url);
    xhr.send(formData);
  });
}
```

---

### 2. 청크 업로드 (100MB+ 권장)

대용량 파일을 작은 조각으로 나눠 업로드. 실패 시 해당 청크만 재시도.

```typescript
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB

interface ChunkUploadOptions {
  file: File;
  itemId: number;
  onProgress?: (percent: number) => void;
  onChunkComplete?: (chunkIndex: number, totalChunks: number) => void;
}

/**
 * 청크 업로드
 * - 5MB 단위로 분할
 * - 실패 시 자동 재시도 (최대 3회)
 * - 네트워크 끊김에 강함
 */
export async function uploadLargeFile({
  file,
  itemId,
  onProgress,
  onChunkComplete,
}: ChunkUploadOptions): Promise<void> {
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
  const uploadId = `upload_${Date.now()}_${crypto.randomUUID()}`;

  for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
    const start = chunkIndex * CHUNK_SIZE;
    const end = Math.min(start + CHUNK_SIZE, file.size);
    const chunk = file.slice(start, end);

    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('chunkIndex', String(chunkIndex));
    formData.append('totalChunks', String(totalChunks));
    formData.append('uploadId', uploadId);
    formData.append('fileName', file.name);
    formData.append('fileSize', String(file.size));

    await uploadChunkWithRetry(itemId, formData);

    onProgress?.(((chunkIndex + 1) / totalChunks) * 100);
    onChunkComplete?.(chunkIndex, totalChunks);
  }

  // 청크 병합 요청
  await fetch(`/api/proxy/items/${itemId}/files/complete`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      uploadId,
      fileName: file.name,
      fileSize: file.size,
    }),
  });
}

async function uploadChunkWithRetry(
  itemId: number,
  formData: FormData,
  maxRetries = 3
): Promise<void> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(`/api/proxy/items/${itemId}/files/chunk`, {
        method: 'POST',
        body: formData,
      });

      if (response.ok) return;
      throw new Error(`Upload failed: ${response.status}`);

    } catch (error) {
      if (attempt === maxRetries - 1) throw error;

      // 지수 백오프: 1초, 2초, 4초...
      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
    }
  }
}
```

**백엔드 요구사항:**
- `POST /api/v1/items/{id}/files/chunk` - 청크 수신
- `POST /api/v1/items/{id}/files/complete` - 청크 병합

---

### 3. 이어받기 지원 (Resumable Upload)

```typescript
interface ResumableUploadState {
  uploadId: string;
  fileName: string;
  fileSize: number;
  completedChunks: number[];
}

/**
 * 업로드 상태 저장 (localStorage)
 */
function saveUploadState(state: ResumableUploadState): void {
  localStorage.setItem(`upload_${state.uploadId}`, JSON.stringify(state));
}

/**
 * 업로드 상태 복원
 */
function getUploadState(uploadId: string): ResumableUploadState | null {
  const saved = localStorage.getItem(`upload_${uploadId}`);
  return saved ? JSON.parse(saved) : null;
}

/**
 * 이어받기 가능한 업로드
 */
export async function resumableUpload(
  file: File,
  itemId: number,
  existingUploadId?: string
): Promise<void> {
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);

  // 기존 상태 복원 또는 새로 시작
  let state: ResumableUploadState;
  if (existingUploadId) {
    const saved = getUploadState(existingUploadId);
    if (saved && saved.fileSize === file.size) {
      state = saved;
    } else {
      state = {
        uploadId: crypto.randomUUID(),
        fileName: file.name,
        fileSize: file.size,
        completedChunks: [],
      };
    }
  } else {
    state = {
      uploadId: crypto.randomUUID(),
      fileName: file.name,
      fileSize: file.size,
      completedChunks: [],
    };
  }

  for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
    // 이미 완료된 청크는 스킵
    if (state.completedChunks.includes(chunkIndex)) {
      continue;
    }

    const start = chunkIndex * CHUNK_SIZE;
    const end = Math.min(start + CHUNK_SIZE, file.size);
    const chunk = file.slice(start, end);

    // ... 업로드 로직 ...

    // 완료 후 상태 저장
    state.completedChunks.push(chunkIndex);
    saveUploadState(state);
  }

  // 완료 후 상태 정리
  localStorage.removeItem(`upload_${state.uploadId}`);
}
```

---

## 파일 크기별 권장 전략

| 파일 크기 | 다운로드 | 업로드 |
|-----------|----------|--------|
| ~10MB | 기존 Blob 방식 OK | 기본 FormData |
| 10-50MB | 직접 URL 방식 | 진행률 표시 |
| 50-100MB | 직접 URL 방식 | 청크 업로드 |
| 100MB+ | File System API | 청크 + 이어받기 |

---

## 구현 우선순위

| 순위 | 기능 | 난이도 | 효과 | 백엔드 수정 |
|------|------|--------|------|-------------|
| 1 | 다운로드 - 직접 URL | 쉬움 | 높음 | 불필요 |
| 2 | 업로드 진행률 표시 | 쉬움 | 중간 | 불필요 |
| 3 | 청크 업로드 | 중간 | 높음 | 필요 |
| 4 | File System API | 중간 | 중간 | 불필요 |
| 5 | 이어받기 | 어려움 | 높음 | 필요 |

---

## 빠른 적용: fileDownload.ts 개선

```typescript
// src/lib/utils/fileDownload.ts

/**
 * 파일 다운로드 (대용량 지원)
 * - 브라우저가 직접 스트리밍 다운로드 처리
 * - 메모리 사용 없음
 */
export function downloadFileById(fileId: number, fileName?: string): void {
  const downloadUrl = `/api/proxy/files/${fileId}/download`;

  const a = document.createElement('a');
  a.href = downloadUrl;
  a.download = fileName || '';
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
}

/**
 * 기존 Blob 방식 (소용량 + 파일명 추출 필요 시)
 */
export async function downloadFileByIdWithBlob(
  fileId: number,
  fileName?: string
): Promise<void> {
  // ... 기존 코드 유지 ...
}
```

---

## 참고 자료

- [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API)
- [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API)
- [tus.io - Resumable Upload Protocol](https://tus.io/)
- [Uppy - JavaScript File Uploader](https://uppy.io/)