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/uuid

2.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에서 안전하고 효율적인 파일 선업로드 시스템을 구현할 수 있습니다.