Files
sam-react-prod/claudedocs/guides/[GUIDE] large-file-handling-strategy.md
byeongcheolryu b1587071f2 feat: 품목관리 기능 개선 및 문서화 업데이트
- 품목 상세/수정 페이지 파일 다운로드 기능 개선
- DynamicItemForm 파일 업로드 UI/UX 개선 (시방서, 인정서)
- BendingDiagramSection 조립/절곡 부품 전개도 통합
- API proxy route 품목 타입별 라우팅 개선
- ItemListClient 파일 다운로드 유틸리티 적용
- 품목코드 중복 체크 및 다이얼로그 추가

문서화:
- DynamicItemForm 훅 분리 계획서 추가 (2161줄 → 900줄 목표)
- 백엔드 API 마이그레이션 문서 추가
- 대용량 파일 처리 전략 가이드 추가
- 테넌트 데이터 격리 감사 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 11:01:25 +09:00

11 KiB

대용량 파일 처리 전략

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

현재 방식의 문제점

// 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 헤더를 제공하면 브라우저가 직접 스트리밍 다운로드 처리.

/**
 * 대용량 파일 다운로드 - 직접 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 방식 (폴백)

/**
 * 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 (최신 브라우저)

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

/**
 * 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. 진행률 표시가 필요한 경우

/**
 * 다운로드 진행률 표시
 * 주의: 전체 파일을 메모리에 올림 (대용량 비권장)
 */
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. 기본 업로드 (진행률 표시)

/**
 * 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+ 권장)

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

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)

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 개선

// 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> {
  // ... 기존 코드 유지 ...
}

참고 자료