- 품목 상세/수정 페이지 파일 다운로드 기능 개선 - 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>
11 KiB
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> {
// ... 기존 코드 유지 ...
}