소스코드

[리포지 링크]


들어가기전에…

NextJS와 익스프레스, MongoDB로 이미지 업로드 API를 아주 라이트하게 만들어본적이 있는데, 상당히 고생했던 걸로 기억한다. 특히 Postman으로 파일 업로드 API를 테스트할 때, form-data 형식으로 key, value, contentType을 지정해줘야 하고, 서버에서 캐치할 때, form-data의 키로 레퍼런스가 된다든지 이런 것들을 아무것도 모르는 상태에서 공부하려니 무척 어려웠다. 그나마 지금은 약간 있는 내 지식을 채워 넣을 수 있는 기회라 기대가 된다. 그리고 정적 파일로 서비스하는 방법도 알아본다.

image-20230904170525352


1. 프로젝트 생성 및 의존성 설치하기

순서 1-1. nest-cli를 사용해 실습에 사용할 프로젝트를 생성

npx @nestjs/cli new nest-file-upload

순서 1-2. multer 패키지를 설치한다. multer는 파일 업로드에 사용되는 multipart/form-data를 다루는 Node.js 라이브러리이다.

npm i -D @types/multer

2. 파일 업로드 API를 만들고 테스트하기

순서 2-1. 간단한 파일 업로드 API를 REST 클라이언트로 만들어보자. app.controller.ts 파일에 file-upload 핸들러 함수를 추가한다

// app.controller.ts

import { Controller, Get, Post, UploadedFile } from '@nestjs/common';
import { UseInterceptors } from '@nestjs/common/decorators';
import { AppService } from './app.service';
import { FileInterceptor } from '@nestjs/platform-express';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  ... 생략 ...
  @Post('file-upload') // 1. POST 메서드로 localhost:3000/file-upload 호출 시 동작
  @UseInterceptors(FileInterceptor('file')) // 2. 파일 인터셉터
  // 3. 인터셉터에서 준 파일을 받음
  fileUpload(@UploadedFile() file: Express.Multer.File) {
    console.log(file.buffer.toString('utf-8')); // 4. 텍스트 파일 내용 출력
    return 'File Upload';
  }
}

1번. 파일 업로드는 POST 메서드로만 가능하며, Content-Type을 multipart/form-data로 해야한다

2번. 인터셉터(@UseInterceptors(FileInterceptor())는 클라이언트와 서버 간의 요청과 응답 간에 로직을 추가하는 미들웨어이다. FileIntercepter()는 클라이언트의 요청에 따라 파일명이 file인 파일이 있는지 확인한다.

image-20230904171722890

3번. @UploadedFile() 데코레이터는 핸들러 함수의 매개변수 데코레이터이다. 인수로 넘겨진 값 중 file 객체를 지정해 꺼내는 역할을 한다. 여러 파일을 업로드할 때 사용하는 @UploadedFiles() 데코레이터도 있다. 각 파일의 타입은 Express.Multer.File 타입이다.

4번. (설명 생략)


순서 2-2. Postman으로 테스트

image-20230904172444683

image-20230904172448457

1번. Content-Type은 multipart/form-data로 설정한다. 파일과 각종 데이터를 동시에 보낼 때 사용한다

2~3번. form-data로 key의 이름을 file로 value에는 파일을 업로드한다

4번. 파일에 저장한 텍스트이다

image-20230904172659863


3. 업로드한 파일을 특정한 경로에 저장하기

앞에서 FileInterceptor에 첫번째 인수로 폼 필드의 이름을 넣었다. 이번에는 두 번째 인수를 활용하여, 어떤 파일의 형식을 허용할지, 파일명은 변경할지, 크기는 얼마까지 허용할지 등의 옵션을 추가한다. 해당 옵션은 NestJS의 기능이 아닌 multer에서 제공하는 기능이다.

옵션명 설명
storage 파일이 저장 될 위치 및 파일 이름 제어
fileFilter 어떤 형식의 파일을 허용할지 제어
limits 필드명, 값, 파일 개수, 파일 용량, multipart 폼의 인수 개수, 헤더 개수 제한 설정
preservePath 파일의 전체 경로를 유지할지 여부

순서 3-1. 디스크에 파일을 저장해야 하므로 storage를 사용한다. storage의 타입에는 디스크에 저장하는 diskStorage()와 메모리에 저장하는 memoryStorage()가 있다. 아무것도 설정하지 않은 상태에서는 memoryStorage()를 사용하며 이때 파일 속성은 buffer이다.

따라서 메모리가 기본값이다. 디스크를 사용하는 diskStorage()를 사용해야 한다. diskStorage()는 buffer 속성을 가지고 있지 않으므로 제거해야 한다. 디스크에 저장하는 옵션을 활성화하고 메모리를 사용하는 코드를 핸들러 함수를 app.controller.ts에 추가하자

// app.controller.ts

... 생략 ...
import { multerOption } from './multer.options'; // 뒤에서 추가할 예정

@Controller()
export class AppController {

... 생략 ...
  @Post('file-upload')
  @UseInterceptors(FileInterceptor('file', multerOption))
  fileUpload(@UploadedFile() file: Express.Multer.File) {
    console.log(file);
    return 'File Upload';
  }
}

순서 3-2. diskStorage()의 옵션으로는 destination(문자열 속성)과 filename(함수)가 있다.

  • destination은 파일이 저장될 장소를 지정하는 것
  • filename은 destination에 저장될 때의 파일명

보안을 위해서 파일을 저장할 때는 랜덤한 값으로 변경해 저장하는 것이 좋다. src 디렉터리 아래에 multer.option.ts라는 파일을 생성하고 다음과 같이 옵션을 설정하자

// src > multer.options.ts

import { randomUUID } from 'crypto';
import { diskStorage } from 'multer';
import { extname, join } from 'path';

export const multerOption = {
  // 1. multerOption 객체 선언
  storage: diskStorage({
    // 2. 디스크 스토리지 사용
    destination: join(__dirname, '..', 'uploads'), // 3. 파일 저장 경로 설정
    filename: (req, file, cb) => {
      // 4. 파일명 설정
      cb(null, randomUUID() + extname(file.originalname));
    },
  }),
};

1번. multer 옵션은 객체 형태로 구성된다

2번. 디스크에 파일을 저장하도록 diksStorage 타입의 객체를 만든다

3번. 파일의 저장 경로는 최상단 경로의 [uploads] 디렉터리다.

4번. 파일명은 randomUUI() 함수로 랜덤한 이름을 지어주고, extname() 함수를 사용해 파일의 확장자를 붙인다


순서 3-3. 서버 기동 시, Can’t find ‘multer’라고 한다면, multer를 설치한다

npm i multer

순서 3-4. 테스트

로컬 파일을 POST 하면, root > uploads 경로에 파일이 저장된다

image-20230905090043944

image-20230905090148186


4. 정적 파일 서비스하기

정적 파일(static file)이란?

텍스트, 이미지, 동영상 같은 파일은 한 번 저장되면 변경되지 않으므로 정적 파일(static file)이라 부른다.

순서 4-1. NestJS에서 정적 파일을 서비스할 수 있게 아래 패키지를 설치한다

npm i @nestjs/serve-static

순서 4-2. 설치한 @nestjs/server-static에는 ServerStaticModule이 있다. 해당 모듈을 초기화하면 정적 파일 서비스가 가능하다. 초기화 시에는 forRoot() 함수를 사용해 초기화하면 된다. app.module.ts에 설정을 추가하자

// app.module.ts

... 생략 ...
import { ServeStaticModule } from '@nestjs/serve-static'; // 추가
import { join } from 'path'; // 추가

@Module({
  imports: [
    ServeStaticModule.forRoot({
      // 1. 초기화 함수 실행
      rootPath: join(__dirname, '..', 'uploads'), // 2. 실제 파일이 있는 디렉터리 지정
      serveRoot: '/uploads', // 3. url 뒤에 붙을 경로를 지정
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

1번. ServeStaticModule.forRoot() 함수를 실행하면 정적 파일을 서비스하는 ServeStaticModule이 초기화된다. app.module.ts의 @Module 데코레이터 내의 imports 옵션에 추가한다

2번. rootPath 옵션은 업로드한 파일이 저장되어 있는 경로다. 프로젝트의 최상위 디렉터리 바로 아래에 있는 [uploads] 디렉터리가 된다

3번. serveRoot 옵션이 없으면 업로드한 파일에 localhost:3000/{파일명}으로 접근할 수 있다. 이 경우 파일명과 API의 경로가 같다면 문제가 발생할 수 있다. serverRoot 옵션에 경로명을 추가해 localhost:3000/uploads/{파일명}으로 접근할 수 있다


순서 4-3. 테스트

이미지 파일을 업로드해보겠다

image-20230905092422406

image-20230905092427891


5. HTML 폼으로 업로드하기

앞에서 REST 클라이언트로 파일 업로드를 하고 테스트했다. 하지만, 실제로 서비스 사용자가 사용할 수 있게 하려면 HTML 파일에 폼을 만들어 업로드 기능을 제공해야 한다.

NestJS에서 HTML 파일을 볼 수 있게 하려면 스태틱 에셋 설정이 필요하다. main.ts 파일을 수정해 정적 파일을 서비스할 수 있게 하자. 그리고 HTML 파일을 생성 후 컨트롤러를 약간만 더 수정한 후 테스트해보자

순서 5-1. main.ts 파일을 아래와 같이 수정한다

// main.ts

... 생략 ...
import { join } from 'path';
import { NestExpressApplication } from '@nestjs/platform-express';

async function bootstrap() {
  // const app = await NestFactory.create(AppModule);
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.useStaticAssets(join(__dirname, '..', 'static')); // 1. 정적 파일 경로 지정
  await app.listen(3000);
}
bootstrap();

1번. useStaticAssets()에 경로만 지정해주면 NestJS에서 정적 파일 서비스가 가능하다. useStaticAssets() 미들웨어는 익스프레스에 있기 때문에 NestExpressApplication 타입으로 app 인스턴스를 생성한다. 익스프레스의 미들웨어를 사용하려면 app 인스턴스를 만들 때 제네릭 타입으로 NestExpressApplication을 선언해 준다. nest-file-upload 바로 아래의 [static] 디렉터리에 정적 파일을 위치시킨다


순서 5-2. 테스트를 위한 프론트 코드를 작성한다

// root > static > form.html

<html>
    <body>
        <form action="file-upload" method="post" enctype="multipart/form-data">
            <!-- 파일 타입 및 파일 변수명 지정 -->
            <input type="file" name="file" /> 
            <!-- 업로드 버튼 -->
            <input type="submit" value="Upload" />
        </form>
    </body>
</html>

순서 5-3. 컨트롤러(app.controller.ts)를 수정한다

// app.controller.ts

import { Controller, Get, Post, UploadedFile } from '@nestjs/common';
import { UseInterceptors } from '@nestjs/common/decorators';
import { AppService } from './app.service';
import { FileInterceptor } from '@nestjs/platform-express';
import { multerOption } from './multer.options';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  ... 생략 ...
  @Post('file-upload')
  @UseInterceptors(FileInterceptor('file', multerOption))
  fileUpload(@UploadedFile() file: Express.Multer.File) {
    console.log(file);
    // 1. 업로드한 파일명과 경로 반환
    return `${file.originalname} File Uploaded check http://localhost:3000/uploads/${file.filename}`;
  }
}

1번. file.originalname은 업로드된 파일의 원래 파일명이며 http://localhost:3000/uploads/${file.filename} 파일이 업로드된 경로이다


순서 5-4. http://localhost:3000/form.html에 접속하여, 파일을 업로드해보고, 파일이 저장된 경로로 들어가 확인해보자

image-20230905100226048

image-20230905100309725

image-20230905100359589

정리

  • NestJS는 정적 파일을 서비스하기 위해, @nestjs/server-static 패키지를 사용하자
  • multer는 파일 업로드에 사용되는 multipart/form-data를 다루는 Node.js 라이브러리다
  • 인터셉터는 클라이언트와 서버 사이의 통신에 로직을 추가할 수 있는 미들웨어다
  • Content-Disposition은 multipart/form-data로 데이터를 전송할 때 각 하위 부분에 대한 정보를 제공하는 헤더값이다
  • 리프레시 토큰을 사용해 인가 서버에서 새로운 액세스 토큰을 발급받아야 한다. 선택적으로 리프레시 토큰도 재발급받을 수 있다.

참고

마무리하며…

백엔드 기초 지식과 Express부터 NestJS까지 꼼꼼히 다뤄볼 수 있어 의미있는 스터디였다. 이러한 배경 지식으로 내 개발 업무에 날개를 더할 수 있어 기쁘다. 회원가입과 파일 업로드 API는 소스코드를 잘 보관하여 실무에서 사용해보는 것도 좋을 것 같다.

무엇보다 이렇게 좋은 책을 제작해주신, 박승규님에게 진심을 담아 감사를 표하는 바이다.

댓글남기기