🗓️ 2024. 10. 27
⏱️ 14

Nest.js 정적 파일 서빙의 (거의) 모든 것

에셋, SPA 그리고 스트리밍까지

개요

얼마전, 인트라넷 환경에서 운영되는 서비스를 구축했습니다.
서비스 특성상 트래픽이 현저히 낮으며 비즈니스 로직도 적은 편이었습니다.
그래서 별다른 인프라없이 Nest.js 만으로 구현을 마쳤는데요,
구현을 하면서 정적 파일 서빙도 Nest.js로 할지 아니면 nginx를 사용할지 고민했었습니다.

결론적으로 Nest.js 이외의 도구는 괜히 복잡도만 키우는 일이라고 판단해 모든 기능을 Nest.js로 제공중입니다.
Nest.js에서 정적 파일을 제공하는 방법들을 찾아봤으나 생각보다 전체적으로 정리된 자료는 찾지 못했습니다.
그래서 공식 문서와 제 경험을 엮어 전체적인 내용을 정리하게 됐습니다.

이 글에선 Nest.js에서 정적 파일을 서빙하는 여러 방법을 내부 구현과 함께 알아봅니다.
예제만 필요하신 분들은 내부 구현 설명은 건너뛰고 보셔도 좋습니다.

정적 파일 서빙 방법

Nest.js에서는 세 가지 방법으로 정적 파일을 서빙할 수 있습니다.

  1. useStaticAssets
  2. ServeStaticModule
  3. StreamableFile

'정적 파일 서빙'이라는 하나의 목적을 이루는 데 방법이 왜 세 가지나 되는지 의아할 수 있습니다.
하지만 각각의 방법은 저마다 더 구체적인 사례들을 다룹니다.
각 방법마다 어떤 때 쓰면 적절할지 알아보겠습니다.

useStaticAssets

useStaticAssets는 간단한 프로젝트나 테스트 목적으로 가장 적합한 방식입니다.

예제를 보기 전에 한 가지 짚고 넘어갈 점이 있는데요, 아시다시피 Nest.js는 내부적으로 Express 또는 Fastify를 사용합니다. Nest.js를 일종의 Wrapper로 취급하는 것도 각각의 프레임워크를 AbstractHttpAdapter 클래스로 확장해서 이용하기 때문입니다.

실제로 Express와 Fasitfy의 Adapter는 useStaticAssets 메서드를 서로 다른 방식으로 구현하고 있습니다.
각각의 예제를 내부 구현과 함께 살펴보겠습니다.

먼저 ExpressAdapter입니다.

// express-adapter.ts > ExpressAdapter

class ExpressAdapter extends AbstractHttpAdapter {
    // ...
    public useStaticAssets(path: string, options: ServeStaticOptions) {
        if (options && options.prefix) {
            return this.use(options.prefix, express.static(path, options));
        }
    
        return this.use(express.static(path, options));
    }
}

ExpressAdapter는 Express에 내장된 serve-static을 미들웨어로 등록해 정적 파일을 제공합니다.
따라서 별도의 패키지 설치없이 useStaticAssets 메서드를 호출할 수 있습니다.

import { join } from 'node:path';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';

// Nest.js의 기본 Adapter는 ExpressAdapter이므로 제네릭만 설정해줘도 됩니다.
const app = await NestFactory.create<NestExpressApplication>(
    AppModule,
);

app.useStaticAssets(join(__dirname, '..', 'public'));

다음은 FastifyAdapter입니다.

// fastify-adapter.ts > FastifyAdapter

class FastifyAdapter extends AbstractHttpAdapter {
    // ...
    public useStaticAssets(options: FastifyStaticOptions) {
        return this.register(
            loadPackage('@fastify/static', 'FastifyAdapter.useStaticAssets()', () =>
                require('@fastify/static'),
            ),
            options,
        );
    }
}

FastifyAdapter는 @fastify/static을 플러그인으로 등록해 정적 파일을 제공합니다.
@fastify/static는 Fastify에 내장된 패키지가 아니기 때문에 추가적인 설치가 필요합니다.

# 먼저 @fastify/static 패키지를 설치합니다
$ npm i --save @fastify/static
import { join } from 'node:path';
import { NestFactory } from '@nestjs/core';
import { NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';

const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
);

app.useStaticAssets({
    root: join(__dirname, '..', 'public'),
    prefix: '/public/',
});

여기까지만 설정해도 이미지, 문서 등의 정적 파일을 서빙할 수 있습니다.
추가적으로 템플릿 엔진을 이용한 MVC(Model-View-Controller) 구현이 필요하다면 공식 문서의 MVC 항목을 참고하시기 바랍니다.

ServeStaticModule

ServeStaticModule은 이미지, 문서 등의 에셋 파일을 넘어 SPA(Single Page Application) 까지 서빙할 수 있습니다.
다음과 같은 목적이 있다면 ServeStaticModule 사용이 권장됩니다.

  • 빌드된 SPA를 Nest.js로 서빙하고 싶을 때
  • 모듈 방식의 더 구조적인 정적 파일 서빙을 원할 때

요컨대, 단순 정적 파일 서빙과 더불어 모듈 단위의 SPA 서빙이 필요한 경우 유용합니다.

@nestjs/serve-static package for Nest, useful to serve static content like Single Page Applications (SPA). However, if you are building MVC application or want to serve assets files (images, docs), use the useStaticAssets() method (read more here) instead.

이처럼 공식 문서에서도 MVC 애플리케이션 구현이나 단순 정적 파일 서빙이 목적이라면 useStaticAssets을 사용할 것을 권합니다.

ServeStaticModule을 사용하려면 @nestjs/serve-static 패키지를 설치해야 합니다.

$ npm i --save @nestjs/serve-static

@nestjs/serve-static에는 아래와 같이 peerDependencies에 정적 파일 서빙을 위한 패키지들이 포함되어있어 @fastify/static같은 추가적인 패키지 설치가 필요하지 않습니다.

{
    "name": "@nestjs/serve-static",
    "peerDependencies": {
        "@nestjs/common": "^9.0.0 || ^10.0.0",
        "@nestjs/core": "^9.0.0 || ^10.0.0",
        "express": "^4.18.1",
        "fastify": "^4.7.0",
        "@fastify/static": "^6.5.0 || ^7.0.0"
    }
    // ...
}

ServeStaticModule도 예제에 앞서 내부 구현 먼저 살펴보겠습니다.

useStaticAssets 파트에서도 언급했듯이 Nest.js는 추상화 레이어 아래에서 Express와 Fastify로 내부 구현이 나뉩니다.
ServeStaticModule에서는 각각의 구현을 AbstractLoader라는 클래스를 확장해 구현합니다.

먼저 ExpressLoader입니다.

// express.loader.ts > ExpressLoader

@Injectable()
export class ExpressLoader extends AbstractLoader {
  public register(
    httpAdapter: AbstractHttpAdapter,
    optionsArr: ServeStaticModuleOptions[]
  ) {
    const app = httpAdapter.getInstance();
    const express = loadPackage('express', 'ServeStaticModule', () =>
      require('express')
    );

    // ...
    const indexFilePath = this.getIndexFilePath(clientPath);

    const renderFn = (req: unknown, res: any, next: Function) => {
        if (!isRouteExcluded(req, options.exclude)) {
            if (
                options.serveStaticOptions &&
                options.serveStaticOptions.setHeaders
            ) {
                const stat = fs.statSync(indexFilePath);
                options.serveStaticOptions.setHeaders(res, indexFilePath, stat);
            }
            res.sendFile(indexFilePath);
        } else {
            next();
        }
    };

    app.use(express.static(clientPath, options.serveStaticOptions));
    app.get(options.renderPath, renderFn);
    // ...
  }
}

다음은 FastifyLoader입니다.

// fastify.loader.ts > FastifyLoader

@Injectable()
export class FastifyLoader extends AbstractLoader {
  public register(
    httpAdapter: AbstractHttpAdapter,
    optionsArr: ServeStaticModuleOptions[]
  ) {
    const app = httpAdapter.getInstance();
    const fastifyStatic = loadPackage(
      '@fastify/static',
      'ServeStaticModule',
      () => require('@fastify/static')
    );

    // ...
    const indexFilePath = this.getIndexFilePath(clientPath);

    app.register(fastifyStatic, {
        root: clientPath,
        ...(options.serveStaticOptions || {}),
        wildcard: false
    });

    app.get(options.renderPath, (req: any, res: any) => {
        const stream = fs.createReadStream(indexFilePath);
        if (
            options.serveStaticOptions &&
            options.serveStaticOptions.setHeaders
        ) {
            const stat = fs.statSync(indexFilePath);
            options.serveStaticOptions.setHeaders(res, indexFilePath, stat);
        }
        res.type('text/html').send(stream);
    });
    // ...
  }
}

공통점을 찾으셨나요?

두 Loader는 두 가지 공통된 로직을 갖습니다.

  1. 정적 파일 서빙에 필요한 미들웨어/플러그인 등록 (useStaticAssets의 내부 구현과 동일)
  2. SPA의 인덱스 파일(index.html)을 서빙하는 엔드포인트를 라우터에 직접 등록

첫 번째 로직을 통해 useStaticAssets와 동일한 기능을 갖게 되고, 두 번째 로직을 통해 SPA 서빙 기능을 갖게 됩니다.

이제 원리를 알았으니 예제를 확인해보겠습니다.
예제에서는 React로 만들어진 소비자 앱과 Vue로 만들어진 판매자 앱을 서빙합니다.

  1. 우선 루트 폴더를 생성합니다.
$ mkdir nest-spa-sample
$ cd nest-spa-sample
  1. Nest.js 프로젝트를 생성한 뒤 @nestjs/server-static 패키지를 설치합니다.

  2. SPA 프로젝트를 생성합니다.

# TypeScript React 기반 소비자 앱
$ npm create vite@latest customer -- --template react-ts

# TypeScript Vue 기반 판매자 앱
$ npm create vite@latest seller -- --template vue-ts

프로젝트 생성이 모두 끝나면 루트 폴더는 다음과 같이 구성됩니다.

nest-spa-sample
├── customer
│   ├── index.html
│   ├── package-lock.json
│   ├── package.json
│   ├── public
│   ├── src
│   └── ...
├── seller
│   ├── index.html
│   ├── package-lock.json
│   ├── package.json
│   ├── public
│   ├── src
│   └── ...
└── server
    ├── nest-cli.json
    ├── node_modules
    ├── package.json
    ├── package-lock.yaml
    ├── src
    └── ...

본격적인 SPA 서빙을 위해 SPA 프로젝트를 빌드해야 하는데요, 이때 Vite의 빌드 옵션 중 --base가 필요합니다.
customer와 seller 앱의 package.json에서 build 스크립트를 아래와 같이 수정해주시면 됩니다.

// nest-spa-sample/customer/package.json
{
    // ...
    "build": "tsc -b && vite build --base=./",
}

// nest-spa-sample/seller/package.json
{
    // ...
    "build": "vue-tsc -b && vite build --base=./",
}

만약 --base 옵션이 없으면 dist 폴더에 생성되는 index.html에서 js와 css 파일을 절대 경로(/assets)로 불러오기 때문에 필요한 에셋들을 찾지 못하게 됩니다. 해당 옵션을 적용하면 상대 경로(./assets)로 불러오도록 빌드됩니다.
(다른 빌드 도구를 이용하신다면 이처럼 빌드 결과물이 상대 경로를 불러오도록 설정을 바꿔주세요)

SPA 앱들이 빌드됐으니 이제 Nest.js에서 서빙해보겠습니다.

import { join } from 'node:path';
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';

@Module({
    imports: [
        ServeStaticModule.forRoot(
            {
                // SPA 빌드 결과 Output 폴더
                rootPath: join(__dirname, '..', '..', 'customer', 'dist'),
                // SPA를 서빙할 루트 경로
                serveRoot: '/customer',
                // 헤더, 캐시 등 설정
                serveStaticOptions: {}, 
            }, 
            {
                rootPath: join(__dirname, '..', '..', 'seller', 'dist'),
                serveRoot: '/seller',
            }
        ),
    ],
})
export class AppModule {}

혹은 다음과 같이 나누어 등록할 수도 있습니다.

@Module({
    imports: [
        // CustomerSPAModule 같은 별도의 모듈로 분리할 수도 있습니다.
        ServeStaticModule.forRoot({
            rootPath: join(__dirname, '..', '..', 'customer', 'dist'),
            serveRoot: '/customer',
        }), 
        ServeStaticModule.forRoot({
            rootPath: join(__dirname, '..', '..', 'seller', 'dist'),
            serveRoot: '/seller',
        }),
    ],
})
export class AppModule {}

이제 Nest.js의 /customer와 /seller 엔드포인트에서 각각의 앱을 이용할 수 있습니다!

StreamableFile

정적 파일과 SPA 서빙이 모두 해결됐습니다.
이제 충분해보이는데... 정적 파일을 제공하는 방법이 한 가지 더 있습니다.
바로 REST API에서 정적 파일을 제공할 때 사용되는 StreamableFile입니다.
대표적인 사용 예시는 사용자의 API 요청을 받아 비즈니스 로직을 수행한 뒤에 파일을 제공할 때입니다.

사용법은 아주 단순합니다. 아래 예제처럼 컨트롤러에서 반환해주면 끝입니다.
StreamableFile 객체를 반환하면 컨트롤러 이후의 인터셉터 로직까지 모두 수행됩니다.

import { createReadStream } from 'node:fs';
import { join } from 'node:path';
import { Controller, Get, StreamableFile } from '@nestjs/common';

@Controller('file')
export class FileController {
    @Get()
    getFile(): StreamableFile {
        // 비즈니스 로직 수행
        
        // 파일 제공
        const file = createReadStream(join(process.cwd(), 'package.json'));
        return new StreamableFile(file);
    }
}

앞서 들었던 예시를 조금 더 실전적인 상황으로 바꿔볼까요?
사용자에게 결제 내역을 엑셀 파일로 다운로드할 수 있는 기능을 제공해보겠습니다.

@Controller('file')
export class FileController {
    constructor(private readonly invoiceService: InvoiceService) {}

    @Get()
    async downloadInvoiceExcel(@CurrentUser() user: User): Promise<StreamableFile> {
        // 요청한 사용자의 식별자로 결제 내역을 엑셀 파일로 변환
        const invoiceExcel = await this.invoiceService.toExcel(user.id)

        // 사용자이름_연도_결제내역 양식으로 파일 다운로드
        const filename = `${user.name}_${new Date().getFullYear()}_결제내역.xlsx`
        return new StreamableFile(invoiceExcel, {
            type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
            disposition: `attachment; filename="${filename}"`,
        });
    }
}

이처럼 StreamableFile은 사용자에 맞춤된 파일을 제공하고자 할 때 유용합니다.


이상으로 Nest.js에서 정적 파일을 서빙하는 모든 방법을 알아보았습니다.

사실 실전적인 예시를 더 마련해두었는데요, 글도 너무 길어지는 듯하고...
예시를 만들면서 발견한 Nest.js의 버그도 있어 추후에 이어지는 글을 작성할 예정입니다.
다음 글에서는 심화 주제로 미디어 파일 스트리밍사용자 인증을 다뤄보겠습니다 :)

돌아가기
© 2025 VERYCOSY.