NestJS 파일 선업로드 구현 가이드
1. 파일 선업로드(Pre-upload) 개념
1.1 선업로드란?
파일 선업로드(Pre-upload)는 실제 파일 데이터를 전송하기 전에 파일에 대한 메타데이터를 먼저 서버에 전송하여 업로드 준비를 하는 방식입니다. 이를 통해 파일 업로드 프로세스를 더욱 효율적이고 안전하게 관리할 수 있습니다.
1.2 선업로드의 장점
성능 최적화
- 대용량 파일 업로드 전 사전 검증으로 불필요한 데이터 전송 방지
- 청크(chunk) 단위 업로드를 통한 메모리 효율성 증대
- 업로드 중단 시 재개 기능 제공
보안 강화
- 파일 타입, 크기 등을 사전 검증하여 악성 파일 차단
- 업로드 권한 사전 확인
- 바이러스 스캔 등 보안 검사 수행
사용자 경험 개선
- 업로드 진행률 표시
- 에러 발생 시 빠른 피드백 제공
- 대용량 파일 업로드 시 안정성 확보
1.3 선업로드 프로세스
1. 클라이언트 → 서버: 파일 메타데이터 전송 (파일명, 크기, 타입 등)
2. 서버: 메타데이터 검증 및 업로드 세션 생성
3. 서버 → 클라이언트: 업로드 토큰 및 업로드 URL 반환
4. 클라이언트 → 서버: 실제 파일 데이터 업로드
5. 서버: 파일 저장 및 처리 완료 응답
2. NestJS 파일 선업로드 구현
2.1 필요한 패키지 설치
npm install @nestjs/platform-express multer
npm install --save-dev @types/multer
# 추가 유틸리티 패키지
npm install uuid crypto-js
npm install --save-dev @types/uuid2.2 파일 업로드 DTO 정의
// dto/file-preupload.dto.ts
import { IsString, IsNumber, IsOptional, Min, Max } from 'class-validator';
export class FilePreUploadDto {
@IsString()
fileName: string;
@IsNumber()
@Min(1)
@Max(100 * 1024 * 1024) // 100MB 제한
fileSize: number;
@IsString()
fileType: string;
@IsOptional()
@IsString()
description?: string;
}
export class FileUploadResponseDto {
uploadToken: string;
uploadUrl: string;
expiresAt: Date;
maxChunkSize: number;
}2.3 파일 업로드 엔티티
// entities/file-upload.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
export enum UploadStatus {
PENDING = 'pending',
UPLOADING = 'uploading',
COMPLETED = 'completed',
FAILED = 'failed',
EXPIRED = 'expired'
}
@Entity('file_uploads')
export class FileUpload {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
fileName: string;
@Column()
fileSize: number;
@Column()
fileType: string;
@Column({ nullable: true })
description?: string;
@Column()
uploadToken: string;
@Column({
type: 'enum',
enum: UploadStatus,
default: UploadStatus.PENDING
})
status: UploadStatus;
@Column({ nullable: true })
filePath?: string;
@Column({ nullable: true })
uploadedSize?: number;
@Column()
expiresAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}2.4 파일 업로드 서비스
// services/file-upload.service.ts
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto-js';
import * as fs from 'fs';
import * as path from 'path';
import { FileUpload, UploadStatus } from '../entities/file-upload.entity';
import { FilePreUploadDto, FileUploadResponseDto } from '../dto/file-preupload.dto';
@Injectable()
export class FileUploadService {
private readonly UPLOAD_DIR = './uploads';
private readonly MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
private readonly ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf', 'text/plain'];
private readonly TOKEN_EXPIRY = 60 * 60 * 1000; // 1시간
constructor(
@InjectRepository(FileUpload)
private fileUploadRepository: Repository<FileUpload>,
) {
// 업로드 디렉토리 생성
if (!fs.existsSync(this.UPLOAD_DIR)) {
fs.mkdirSync(this.UPLOAD_DIR, { recursive: true });
}
}
async preUpload(preUploadDto: FilePreUploadDto): Promise<FileUploadResponseDto> {
// 파일 크기 검증
if (preUploadDto.fileSize > this.MAX_FILE_SIZE) {
throw new BadRequestException('파일 크기가 너무 큽니다.');
}
// 파일 타입 검증
if (!this.ALLOWED_TYPES.includes(preUploadDto.fileType)) {
throw new BadRequestException('지원하지 않는 파일 타입입니다.');
}
// 업로드 토큰 생성
const uploadToken = this.generateUploadToken();
const expiresAt = new Date(Date.now() + this.TOKEN_EXPIRY);
// 파일 업로드 레코드 생성
const fileUpload = this.fileUploadRepository.create({
fileName: preUploadDto.fileName,
fileSize: preUploadDto.fileSize,
fileType: preUploadDto.fileType,
description: preUploadDto.description,
uploadToken,
expiresAt,
status: UploadStatus.PENDING,
});
await this.fileUploadRepository.save(fileUpload);
return {
uploadToken,
uploadUrl: `/api/files/upload/${uploadToken}`,
expiresAt,
maxChunkSize: 1024 * 1024, // 1MB 청크
};
}
async uploadFile(uploadToken: string, file: Express.Multer.File): Promise<{ message: string; fileId: string }> {
// 업로드 레코드 조회
const fileUpload = await this.fileUploadRepository.findOne({
where: { uploadToken }
});
if (!fileUpload) {
throw new NotFoundException('업로드 토큰을 찾을 수 없습니다.');
}
// 토큰 만료 확인
if (fileUpload.expiresAt < new Date()) {
await this.fileUploadRepository.update(fileUpload.id, {
status: UploadStatus.EXPIRED
});
throw new BadRequestException('업로드 토큰이 만료되었습니다.');
}
// 파일 크기 검증
if (file.size !== fileUpload.fileSize) {
throw new BadRequestException('파일 크기가 일치하지 않습니다.');
}
try {
// 업로드 상태 변경
await this.fileUploadRepository.update(fileUpload.id, {
status: UploadStatus.UPLOADING
});
// 파일 저장
const fileName = `${fileUpload.id}_${fileUpload.fileName}`;
const filePath = path.join(this.UPLOAD_DIR, fileName);
fs.writeFileSync(filePath, file.buffer);
// 업로드 완료 처리
await this.fileUploadRepository.update(fileUpload.id, {
status: UploadStatus.COMPLETED,
filePath,
uploadedSize: file.size,
});
return {
message: '파일 업로드가 완료되었습니다.',
fileId: fileUpload.id,
};
} catch (error) {
// 업로드 실패 처리
await this.fileUploadRepository.update(fileUpload.id, {
status: UploadStatus.FAILED
});
throw new BadRequestException('파일 업로드에 실패했습니다.');
}
}
async getUploadStatus(uploadToken: string): Promise<FileUpload> {
const fileUpload = await this.fileUploadRepository.findOne({
where: { uploadToken }
});
if (!fileUpload) {
throw new NotFoundException('업로드 정보를 찾을 수 없습니다.');
}
return fileUpload;
}
private generateUploadToken(): string {
const timestamp = Date.now().toString();
const random = uuidv4();
return crypto.SHA256(`${timestamp}-${random}`).toString();
}
// 만료된 업로드 정리 (크론 작업으로 실행)
async cleanupExpiredUploads(): Promise<void> {
const expiredUploads = await this.fileUploadRepository.find({
where: [
{
status: UploadStatus.PENDING,
expiresAt: new Date()
},
{
status: UploadStatus.UPLOADING,
expiresAt: new Date()
}
]
});
for (const upload of expiredUploads) {
// 파일이 존재하면 삭제
if (upload.filePath && fs.existsSync(upload.filePath)) {
fs.unlinkSync(upload.filePath);
}
// DB에서 상태 업데이트
await this.fileUploadRepository.update(upload.id, {
status: UploadStatus.EXPIRED
});
}
}
}2.5 파일 업로드 컨트롤러
// controllers/file-upload.controller.ts
import {
Controller,
Post,
Get,
Param,
Body,
UseInterceptors,
UploadedFile,
BadRequestException
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiConsumes, ApiBody } from '@nestjs/swagger';
import { FileUploadService } from '../services/file-upload.service';
import { FilePreUploadDto, FileUploadResponseDto } from '../dto/file-preupload.dto';
@ApiTags('파일 업로드')
@Controller('api/files')
export class FileUploadController {
constructor(private readonly fileUploadService: FileUploadService) {}
@Post('preupload')
@ApiOperation({ summary: '파일 선업로드 준비' })
async preUpload(@Body() preUploadDto: FilePreUploadDto): Promise<FileUploadResponseDto> {
return this.fileUploadService.preUpload(preUploadDto);
}
@Post('upload/:token')
@ApiOperation({ summary: '파일 업로드 실행' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
},
},
},
})
@UseInterceptors(FileInterceptor('file'))
async uploadFile(
@Param('token') token: string,
@UploadedFile() file: Express.Multer.File,
) {
if (!file) {
throw new BadRequestException('파일이 제공되지 않았습니다.');
}
return this.fileUploadService.uploadFile(token, file);
}
@Get('status/:token')
@ApiOperation({ summary: '업로드 상태 조회' })
async getUploadStatus(@Param('token') token: string) {
return this.fileUploadService.getUploadStatus(token);
}
}2.6 모듈 설정
// modules/file-upload.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MulterModule } from '@nestjs/platform-express';
import { FileUpload } from '../entities/file-upload.entity';
import { FileUploadService } from '../services/file-upload.service';
import { FileUploadController } from '../controllers/file-upload.controller';
@Module({
imports: [
TypeOrmModule.forFeature([FileUpload]),
MulterModule.register({
limits: {
fileSize: 100 * 1024 * 1024, // 100MB
},
}),
],
controllers: [FileUploadController],
providers: [FileUploadService],
exports: [FileUploadService],
})
export class FileUploadModule {}3. 사용 예시
3.1 클라이언트 구현 예시 (JavaScript)
// 파일 선업로드 함수
async function preUploadFile(fileInfo) {
const response = await fetch('/api/files/preupload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: fileInfo.name,
fileSize: fileInfo.size,
fileType: fileInfo.type,
description: fileInfo.description || null
})
});
if (!response.ok) {
throw new Error('파일 선업로드 실패');
}
return response.json();
}
// 실제 파일 업로드 함수
async function uploadFile(uploadToken, file) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`/api/files/upload/${uploadToken}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('파일 업로드 실패');
}
return response.json();
}
// 업로드 상태 확인 함수
async function checkUploadStatus(uploadToken) {
const response = await fetch(`/api/files/status/${uploadToken}`);
return response.json();
}
// 전체 업로드 프로세스
async function handleFileUpload(fileInput) {
try {
const file = fileInput.files[0];
// 1. 선업로드
const preUploadResult = await preUploadFile({
name: file.name,
size: file.size,
type: file.type
});
console.log('선업로드 완료:', preUploadResult);
// 2. 실제 파일 업로드
const uploadResult = await uploadFile(preUploadResult.uploadToken, file);
console.log('파일 업로드 완료:', uploadResult);
} catch (error) {
console.error('업로드 실패:', error);
}
}4. 고급 기능 확장
4.1 청크 업로드 지원
대용량 파일의 경우 청크 단위로 나누어 업로드하는 기능을 추가할 수 있습니다:
// 청크 업로드 DTO
export class ChunkUploadDto {
@IsString()
uploadToken: string;
@IsNumber()
chunkIndex: number;
@IsNumber()
totalChunks: number;
@IsString()
chunkHash: string; // 청크 무결성 검증용
}4.2 업로드 진행률 추적
WebSocket을 이용하여 실시간 업로드 진행률을 클라이언트에 전송할 수 있습니다.
4.3 클라우드 스토리지 연동
AWS S3, Google Cloud Storage 등과 연동하여 파일을 클라우드에 저장할 수 있습니다.
5. 보안 고려사항
- 파일 타입 검증: MIME 타입뿐만 아니라 파일 헤더도 검증
- 바이러스 스캔: ClamAV 등을 이용한 악성코드 검사
- 업로드 제한: 사용자별, 시간당 업로드 제한 설정
- 토큰 보안: 업로드 토큰의 적절한 만료 시간 설정
6. 성능 최적화
- 메모리 관리: 스트림 기반 파일 처리로 메모리 사용량 최적화
- 임시 파일 정리: 정기적인 임시 파일 및 만료된 업로드 정리
- 캐싱: 업로드 메타데이터 캐싱으로 성능 향상
이 문서를 통해 NestJS에서 안전하고 효율적인 파일 선업로드 시스템을 구현할 수 있습니다.