블로그

수 GB 파일 업로드, 타임아웃 없이 이어받기까지 — 청크 업로드 구현 완전 정리

단일 HTTP 요청으로 대용량 파일 업로드 시 발생하는 메모리 부족과 타임아웃 문제를 청크 분할 업로드와 이어받기로 해결한 전체 구현 과정을 클라이언트부터 서버까지 상세히 정리합니다.

Node.jsVue.jsTypeScript파일업로드Catenoid

참고: 이 글의 코드는 실제 업무에서 경험한 내용을 바탕으로 개념적으로 재작성한 것입니다. 실제 회사 코드와는 무관합니다.

청크 업로드 + 이어받기 흐름

이력 있음 이력 없음 아니오 사용자: 대용량 파일 선택 파일 분할5MB 청크 x N개 이어받기 이력 확인GET /upload/chunks/fileId 마지막 성공 청크부터 재전송 처음부터 전송 서버: 청크 임시 저장 모든 청크 수신? 청크 순서대로 병합 임시 디렉토리 삭제 완료

들어가며

미디어 플랫폼에서 파일 업로드는 핵심 기능입니다. Catenoid에서 담당한 VOD 관련 기능 중 가장 까다로웠던 것이 바로 대용량 파일 업로드였습니다.

문제는 단순했습니다. 2GB짜리 영상 파일을 업로드하려고 하면 브라우저가 멈추거나, 서버에서 타임아웃이 발생하거나, 네트워크가 불안정하면 처음부터 다시 시작해야 했습니다.

이 글은 청크(Chunk) 업로드와 이어받기(Resume)를 처음부터 구현한 전체 과정을 정리합니다.


문제 분석: 왜 단일 HTTP 업로드가 실패하는가

브라우저 메모리 문제

브라우저에서 2GB 파일을 그대로 메모리에 올리고 HTTP 요청에 담으면:

// 이렇게 하면 안 됩니다
const file = input.files[0]; // 2GB 파일
const formData = new FormData();
formData.append('file', file);
await axios.post('/upload', formData); // 브라우저 메모리 폭발

JavaScript는 파일 전체를 메모리에 올려야 합니다. 브라우저 탭의 메모리 한계를 초과하면 탭이 크래시됩니다.

서버 타임아웃 문제

2GB를 일반 인터넷 환경에서 업로드하면 수십 분이 걸릴 수 있습니다. 대부분의 웹 서버는 기본적으로 30초~2분의 타임아웃이 설정되어 있습니다.

실패 시 재시도 비용

50%쯤 업로드하다 네트워크가 끊기면? 처음부터 다시 시작해야 합니다. 사용자 입장에서는 지옥입니다.


설계: 청크 분할 업로드

해법은 파일을 작은 조각(청크)으로 나눠서 올리는 것입니다.

[파일: 2GB]
    ↓ 분할
[청크 1: 5MB] [청크 2: 5MB] [청크 3: 5MB] ... [청크 400: 5MB]
    ↓ 순차 또는 병렬 업로드
[서버: 청크 수신 및 임시 저장]
    ↓ 모든 청크 수신 완료
[서버: 청크 병합 → 최종 파일]

각 청크는 5MB 정도의 작은 크기이므로 브라우저 메모리 문제가 없고, 30초 타임아웃 안에 충분히 전송됩니다.


클라이언트 구현 (Vue.js + TypeScript)

파일 분할

// composables/useChunkUpload.ts
export function useChunkUpload() {
  const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB

  function splitFile(file: File): Blob[] {
    const chunks: Blob[] = [];
    let offset = 0;
    
    while (offset < file.size) {
      chunks.push(file.slice(offset, offset + CHUNK_SIZE));
      offset += CHUNK_SIZE;
    }
    
    return chunks;
  }
  
  // 파일 고유 ID 생성 (이어받기를 위해 일관된 ID 필요)
  async function generateFileId(file: File): Promise<string> {
    const buffer = await file.slice(0, 1024).arrayBuffer(); // 첫 1KB로 해시
    const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16)
      + `_${file.size}_${file.name}`;
  }
  
  return { splitFile, generateFileId };
}

이어받기: 어디까지 올렸는지 확인

async function getUploadedChunks(fileId: string): Promise<number[]> {
  const response = await api.get(`/upload/chunks/${fileId}`);
  return response.data.uploadedChunks; // [0, 1, 2, 5, 6] 같은 형태
}

async function uploadWithResume(file: File) {
  const fileId = await generateFileId(file);
  const chunks = splitFile(file);
  
  // 이미 업로드된 청크 확인
  const uploadedChunks = await getUploadedChunks(fileId);
  const uploadedSet = new Set(uploadedChunks);
  
  // 업로드되지 않은 청크만 전송
  // 마지막 성공 청크도 재전송 (네트워크 불안정으로 불완전할 수 있음)
  const lastUploaded = uploadedChunks.length > 0 
    ? Math.max(...uploadedChunks) - 1 
    : -1;
  
  for (let i = 0; i < chunks.length; i++) {
    if (i < lastUploaded && uploadedSet.has(i)) continue; // 확실히 완료된 청크 스킵
    
    const formData = new FormData();
    formData.append('file', chunks[i]);
    formData.append('fileId', fileId);
    formData.append('chunkIndex', String(i));
    formData.append('totalChunks', String(chunks.length));
    formData.append('fileName', file.name);
    
    await api.post('/upload/chunk', formData, {
      headers: { 'Content-Type': 'multipart/form-data' },
      onUploadProgress: (progressEvent) => {
        const chunkProgress = progressEvent.loaded / (progressEvent.total ?? 1);
        const overallProgress = (i + chunkProgress) / chunks.length;
        uploadProgress.value = Math.round(overallProgress * 100);
      }
    });
  }
  
  // 모든 청크 업로드 완료, 병합 요청
  await api.post('/upload/complete', { fileId, fileName: file.name });
}

서버 구현 (Node.js + Express)

청크 수신 API

// routes/upload.ts
router.post('/chunk', upload.single('file'), async (req, res) => {
  const { fileId, chunkIndex, totalChunks, fileName } = req.body;
  const chunkFile = req.file;
  
  if (!chunkFile) {
    return res.status(400).json({ error: '청크 파일 없음' });
  }
  
  // 청크 임시 저장 디렉토리
  const chunkDir = path.join(TEMP_DIR, fileId);
  await fs.mkdir(chunkDir, { recursive: true });
  
  // 청크 저장
  const chunkPath = path.join(chunkDir, `chunk_${chunkIndex.padStart(5, '0')}`);
  await fs.rename(chunkFile.path, chunkPath);
  
  res.json({ 
    success: true, 
    chunkIndex: Number(chunkIndex),
    message: `청크 ${chunkIndex}/${totalChunks} 수신 완료`
  });
});

// 업로드된 청크 목록 조회 (이어받기용)
router.get('/chunks/:fileId', async (req, res) => {
  const { fileId } = req.params;
  const chunkDir = path.join(TEMP_DIR, fileId);
  
  try {
    const files = await fs.readdir(chunkDir);
    const uploadedChunks = files
      .filter(f => f.startsWith('chunk_'))
      .map(f => parseInt(f.replace('chunk_', ''), 10));
    
    res.json({ uploadedChunks });
  } catch {
    res.json({ uploadedChunks: [] }); // 디렉토리가 없으면 빈 배열
  }
});

병합 API

router.post('/complete', async (req, res) => {
  const { fileId, fileName } = req.body;
  const chunkDir = path.join(TEMP_DIR, fileId);
  
  // 모든 청크 파일 정렬
  const chunkFiles = (await fs.readdir(chunkDir))
    .filter(f => f.startsWith('chunk_'))
    .sort(); // 이름 기준 정렬로 순서 보장
  
  // 최종 파일 경로
  const finalPath = path.join(UPLOAD_DIR, `${fileId}_${fileName}`);
  const writeStream = createWriteStream(finalPath);
  
  // 청크들을 순서대로 읽어서 병합
  for (const chunkFile of chunkFiles) {
    const chunkPath = path.join(chunkDir, chunkFile);
    const chunkData = await fs.readFile(chunkPath);
    await new Promise<void>((resolve, reject) => {
      writeStream.write(chunkData, (err) => err ? reject(err) : resolve());
    });
  }
  
  await new Promise<void>((resolve) => writeStream.end(resolve));
  
  // 임시 청크 폴더 삭제
  await fs.rm(chunkDir, { recursive: true });
  
  res.json({ success: true, filePath: finalPath });
});

결과

  • 대용량 파일 업로드 타임아웃 및 메모리 부족 문제 완전 해소
  • 파일 자체에 문제가 없는 한 업로드 성공률 사실상 100%
  • 네트워크 단절 후 재연결 시 중단 지점부터 자동 이어받기

추가 고려사항

실제 서비스에서는 더 고려해야 할 것들이 있습니다:

  1. 청크 유효기간 관리: 업로드 중단 후 너무 오래된 임시 청크는 자동 삭제
  2. 동시 업로드 제한: 같은 사용자가 여러 파일을 동시에 업로드할 때 처리
  3. S3 Multipart Upload: 서버를 거치지 않고 클라이언트에서 직접 S3에 청크 업로드 (대규모 서비스에 더 적합)
  4. 청크 암호화: 민감한 콘텐츠는 클라이언트에서 청크 암호화 후 전송