파일 업로드는 웹 개발에서 흔히 접하는 요구사항입니다. content-type을 multipart/form-data로 지정하면 데이터가 다음과 같은 형태로 서버에 전달됩니다:
서버는 multipart/form-data 형식을 파싱하여 파일 데이터를 추출할 수 있습니다. 그러나 대용량 파일의 경우 상황이 달라집니다. 100MB 파일 업로드에 3분이 소요된다면, 1GB 파일은 약 30분이 걸립니다. 이러한 대용량 파일 업로드를 최적화하기 위해 파일을 작은 조각으로 분할하여 병렬 업로드한 후 서버에서 재조합하는 방식인 청크 업로드를 사용합니다.
파일 분할 및 병합 방법
브라우저에서는 Blob 객체의 slice 메서드를 사용하여 파일을 분할할 수 있습니다. File 객체는 Blob을 상속하므로 동일한 방법으로 분할이 가능합니다.
Node.js 환경에서는 fs.createWriteStream의 start 옵션을 활용하여 각 청크를 파일의 특정 위치에 기록함으로써 원본 파일로 병합할 수 있습니다.
Nest.js 프로젝트 설정
먼저 Nest.js 프로젝트를 생성합니다:
nest new chunk-file-upload
Multer 타입 정의를 설치합니다:
npm install -D @types/multer
AppController에 파일 업로드를 처리할 라우트를 추가합니다. POST 엔드포인트로 전송된 파일을 처리하며, Multer 인터셉터를 사용합니다:
import { Controller, Post, UseInterceptors, UploadedFiles, Body } from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import * as fs from 'fs';
@Controller()
export class AppController {
@Post('upload')
@UseInterceptors(FilesInterceptor('chunks', 10, { dest: 'temp' }))
async receiveChunks(@UploadedFiles() chunks: Express.Multer.File[], @Body() body: { chunkName: string }) {
const originalName = body.chunkName.replace(/-\d+$/, '');
const chunkStorageDir = `temp/${originalName}_chunks`;
if (!fs.existsSync(chunkStorageDir)) {
fs.mkdirSync(chunkStorageDir);
}
fs.renameSync(chunks[0].path, `${chunkStorageDir}/${body.chunkName}`);
}
}
메인 애플리케이션 파일에서 CORS를 활성화합니다:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(3000);
}
bootstrap();
클라이언트 구현
다음 HTML 파일은 파일 입력을 받아 청크로 분할하고 서버에 전송합니다:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Chunk Upload Demo</title>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<input type="file" id="fileSelector" />
<script>
document.getElementById('fileSelector').onchange = async function(e) {
const selectedFile = e.target.files[0];
const segmentSize = 20 * 1024; // 20KB 단위로 분할
const segments = [];
let currentOffset = 0;
while (currentOffset < selectedFile.size) {
const segment = selectedFile.slice(currentOffset, currentOffset + segmentSize);
segments.push(segment);
currentOffset += segmentSize;
}
segments.forEach(async (seg, idx) => {
const formPayload = new FormData();
formPayload.append('chunks', seg);
formPayload.set('chunkName', `${selectedFile.name}-${idx}`);
await axios.post('http://localhost:3000/upload', formPayload);
});
// 모든 청크 업로드 후 병합 요청
await axios.get(`http://localhost:3000/merge?filename=${selectedFile.name}`);
};
</script>
</body>
</html>
파일 병합 처리
모든 청크 업로드가 완료되면 서버에서 병합을 수행합니다:
@Get('merge')
mergeFile(@Query('filename') filename: string) {
const chunkDirectory = `temp/${filename}_chunks`;
const chunkFiles = fs.readdirSync(chunkDirectory);
let writePosition = 0;
chunkFiles.forEach(chunkFile => {
const chunkPath = `${chunkDirectory}/${chunkFile}`;
const readStream = fs.createReadStream(chunkPath);
readStream.pipe(fs.createWriteStream(`uploads/${filename}`, { start: writePosition }));
writePosition += fs.statSync(chunkPath).size;
});
// 임시 청크 디렉토리 정리
fs.rmSync(chunkDirectory, { recursive: true });
}