수 GB 파일 업로드, 타임아웃 없이 이어받기까지 — 청크 업로드 구현 완전 정리
단일 HTTP 요청으로 대용량 파일 업로드 시 발생하는 메모리 부족과 타임아웃 문제를 청크 분할 업로드와 이어받기로 해결한 전체 구현 과정을 클라이언트부터 서버까지 상세히 정리합니다.
참고: 이 글의 코드는 실제 업무에서 경험한 내용을 바탕으로 개념적으로 재작성한 것입니다. 실제 회사 코드와는 무관합니다.
청크 업로드 + 이어받기 흐름
들어가며
미디어 플랫폼에서 파일 업로드는 핵심 기능입니다. 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%
- 네트워크 단절 후 재연결 시 중단 지점부터 자동 이어받기
추가 고려사항
실제 서비스에서는 더 고려해야 할 것들이 있습니다:
- 청크 유효기간 관리: 업로드 중단 후 너무 오래된 임시 청크는 자동 삭제
- 동시 업로드 제한: 같은 사용자가 여러 파일을 동시에 업로드할 때 처리
- S3 Multipart Upload: 서버를 거치지 않고 클라이언트에서 직접 S3에 청크 업로드 (대규모 서비스에 더 적합)
- 청크 암호화: 민감한 콘텐츠는 클라이언트에서 청크 암호화 후 전송