logo
Published on

Nestjs bundling 삽질 기록

Authors
  • avatar
    Name
    Taehwa Yoon
    Twitter

일반적인 Node.js 백엔드 어플리케이션은 굳이 번들링이 필요하지 않다. 클라이언트 처럼 번들 사이즈를 줄이는것의 이점이 그다지 크지 않고 오히려 번들링 과정에서 외부 라이브러리가 의도치 않은 오류를 발생시키는 경우가 생길수도 있기 때문이다. 성능상의 이점이 약간 있을 수 있겠지만 예기치 못한 번들링 이슈에 스트레스를 받는것에 비해서 그다지 큰 장점이라고 보기는 어렵다.

그럼에도 번들링이 필요한 경우가 있으니 안타깝게도 현재 사내의 인프라 상황이 그런 경우였다.

Serverless 환경에서의 문제

현재 사내 시스템의 백엔드는 어차피 사용자가 모두 내부 고객이고, API 호출 횟수가 많지 않으며, 특별히 인프라 관리를 전담하는 인력이 없는 만큼 API Gateway + Lambda 기반 serverless 환경으로 운영되고 있다. 즉, 상시 실행되는 서버없이 Http request가 발생할 때마다 인스턴스를 구동시켜 Lambda에 배포된 Nestjs app이 구동되는 형태이다. 그리고 잘 알려진대로 lambda는 cold start라는 태생적인 단점이 존재한다. 거기다 매번 lambda 인스턴스가 구동될 때마다 nestjs 앱을 부트스트래핑하는 과정 역시 가벼운 작업은 아니다. 이런 성능상의 이슈로 인해 Nestjs 공식문서에서도 특별히 Serverless 환경에서 webpack bundling을 이용하는 방법을 제시하고 이에 대한 벤치마크를 제공하고 있다.(Nestjs 공식문서 - serverless)

공식문서 및 리포지토리에 공개된 example을 따라 webpack config를 작성하고 nest cli를 통해 nest build --webpack 커맨드를 입력하면 간단하게 번들링된 결과를 얻을 수 있다. 여기까지 큰 문제는 없었다.

Serverless offline과의 호환문제

진행중인 백엔드 프로젝트는 Serverless framework라는 일종의 Serverless 환경에 특화된 IaaS 프레임워크로 구성되어 있는데, 로컬 개발 시 serverless-offline이라는 플러그인을 통해 실제 배포환경과 유사하게 로컬에서 테스트를 수행할 수 있다. 그런데 nest cli로 빌드를 하고 watch모드로 로컬 서버를 띄우는 경우 serverless-offline을 이용한 로컬 서버를 동시에 실행시키기가 어렵다. 억지로 concurrently 같은 라이브러리를 써서 nest를 watch모드로 구동하는 동시에 serverless-offline을 구동할수는 있지만 뭔가 seamless한 개발 경험이라고 하기는 어려웠다. 물론 serverless-offline에서 제공하는 라이프사이클 훅을 이용해 직접 nest cli와 serverless offline을 통합하는 플러그인을 작성할 수도 있겠지만 뭔가 배보다 배꼽이 더 큰 느낌이었다.

Serverless-webpack? Serverless-bundle?

사실 기존 express로 작성된 서비스의 경우 serverless-bundle이란 플러그인을 이용하고 있었고 해당 플러그인은 자체적으로 serverless offline과 잘 연계가 되어 hmr기능을 이용해 개발을 할 수 있었다. 그럼에도 직접 webpack config를 건드릴 수 없고 웬만한 설정은 다 인터페이스를 열어뒀다고 하지만 한두개씩 뭔가 아쉬운게 있었던 차에 결국 nestjs를 적용한 새로운 api에는 serverless-webpack을 적용해 직접 webpack 설정을 하기로 결정했다.

일단 외부 패키지들을 external로 빼지 않고 모두 번들에 포함시켜서 빌드하기로 했다.

const path = require('path')
const slsw = require('serverless-webpack')
const TerserPlugin = require('terser-webpack-plugin')
const { IgnorePlugin } = require('webpack')
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin')

const lazyImports = [
  'class-transformer/storage',
  '@nestjs/websockets/socket-module',
  '@nestjs/microservices/microservices-module',
  '@nestjs/microservices',
]

module.exports = {
  target: 'node',
  entry: slsw.lib.entries,
  output: {
    libraryTarget: 'commonjs2',
    path: path.join(__dirname, 'build'),
    filename: '[name].js',
  },
  mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: [
          {
            loader: 'ts-loader',
            options: {
              configFile: 'tsconfig.build.json',
              transpileOnly: false,
              getCustomTransformers: (program) ={">"} ({
                before: [require('@nestjs/swagger/plugin').before({}, program)],
              }),
            },
          },
        ],
        exclude: [/node_modules/, /\.serverless/, /\.webpack/],
      },
    ],
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
    plugins: [
      new TsconfigPathsPlugin({
        configFile: 'tsconfig.build.json',
      }),
    ],
  },
  devtool: 'source-map',
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          keep_classnames: true,
        },
      }),
    ],
  },
  plugins: [
    new IgnorePlugin({
      checkResource(resource) {
        if (lazyImports.includes(resource)) {
          try {
            require.resolve(resource)
          } catch (err) {
            return true
          }
        }
        return false
      },
    }),
    new ForkTsCheckerWebpackPlugin(),
  ],
  ignoreWarnings: [/^(?!CriticalDependenciesWarning$)/],
}

대충 위와 같은 설정으로 webpack config를 설정했다. serverless webpack을 이용하면 해당 라이브러리로 부터 import한 slsw 객체를 통해 entry, local여부 등을 확인할 수 있다. 그리고 nestjs swagger plugin을 적용하기 위해 ts-loader의 getCustomTransformers 옵션을 추가해주었다.(관련링크)

그리고 실제 프로젝트에서 사용되지 않음에도 빌드시 모듈을 찾을 수 없다고 에러를 뿜는 nest관련 패키지들을 lazy import하도록 설정을 추가해주었다.

결과적으로 빌드도 잘 되고 구동도 잘되는걸로 보였다. 처음에는...

class-transformer의 매복공격

차라리 빌드 시에 에러를 뿜어주면 얼마나 고마웠을까 싶지만 생각지도 못한 곳에서 조용히 문제가 발생하고 있었고 그 어떠한 에러조차 발생하지 않았다. 그런데 decorator관련된 기이한 현상들이 몇개 있었는데...

  1. swagger cli plugin이 자동으로 schema와 api property데코레이터를 잘 붙여준것 같았지만 몇몇 속성들은 swagger doc에서 누락됨
  2. validation pipe에서 transform: true 옵션으로 인해 자동으로 변환되는 dto들에 일부 필드들이 누락됨

특히 @nestjs/swagger, @nestjs/mapped-types 를 이용하는 쪽에서 유독 문제가 발생하고 있었다. 그리고 string, number 같은 primitive 타입들은 swagger doc이 잘 생성되는데 유독 custom한 class나 enum타입으로 정의된 필드들은 다 똑같이 string으로 타입이 변경되어 있었다.

답답한건 해당 라이브러리들을 뒤져도 딱히 비슷한 증상이 있는 경우가 많지 않았고 비슷하다 싶은거에는 아래처럼 냉혈한 같은 댓글만 달려있었다.

> 여긴 질문게시판 아님. 버그 트래킹하는게 주요 목적이고 질문은 디스코드에다 남겨라. minimum reproducible example 첨부해라 등등

원인을 알 수 없는 기현상에 고통받던 와중 혹시나 해서 들어간 class-transformer 이슈에서 결국 빛을 발견했다.

Singleton이 아닌 singleton

class-transformer 라이브러리는 class의 속성에 decorator를 붙여주면 내부적으로 metadata storage라는 객체를 생성하고 내부에 각 class 필드의 메타데이터들을 저장한다. 그리고 metadata storage를 참조하는 부분은 모듈 내에서 모두 같은 객체를 가리키도록 singleton으로 구현되어있다.

그런데 문제는 이게 내부 구현을 살펴보면 실질적으로 완전한 singleton이 아니라 단순히 node의 module caching에 의존하고 있는걸 알 수 있다. 문제는 https://nodejs.org/api/modules.html#modules_module_caching_caveats 여기서 나온 것 처럼 casing이 다르거나 import path가 달라지면 실질적으로 같은 object를 반환하는게 아니라 새로운 모듈 객체를 반환하게 된다. 결론적으로 @nestjs/swagger에서 사용하는 class transformer의 metadata storage와 어플리케이션 코드내에서 참조하는 metadata storage가 전혀 다른 객체였던것이 문제였다.

내부를 확인해보면 class-transformer의 버전에 따라 class-transformer/cjs/storage 또는 class-transformer/storage와 같은 형태로 import를 하고 있다. 당연히 어플리케이션 코드 내에서는 import { ... } from 'class-transformer' 형태로 import를 하고 있으니 서로 다른 metadata storage를 갖게 되었던 것이다.

결국 동일한 metadata storage를 유지하기 위해서 아래의 설정을 webpack config에 추가해야 했다.

(생략)
resolve: {
    extensions: ['.tsx', '.ts', '.js'],
    plugins: [
      new TsconfigPathsPlugin({
        configFile: 'tsconfig.build.json',
      }),
    ],
    alias: {
      'class-transformer/cjs/storage': path.resolve(
        '../../node_modules/class-transformer/cjs/storage', // monorepo하에서 root node_modules 참조
      ),
      'class-transformer': path.resolve(
        '../../node_modules/class-transformer/cjs',
      ),
    },
  },

위 문제를 해결하기 위해 metadata storage에 대한 getter 자체를 export하는 pr이 이미 한참전에 올라가있는데 아직 해결되지 않은 상태다 안타깝게도.

이에 비하면 마이너한 swagger-ui-dist의 정적파일 문제

번들링을 하면서 마주치는 또하나의 문제는 바로 swagger-ui-dist 내부의 정적 html 파일들이다. 이 파일들이 번들에 포함되지 않아서 swagger doc을 열면 제대로 ui가 불러와지지 않는다.

이건 copy-webpack-plugin을 이용해 해당 파일들을 번들이 위치한 곳에 옮겨두면 그만이다.

const CopyPlugin = require('copy-webpack-plugin');

const swaggerUiModulePath = path.dirname(require.resolve('swagger-ui-dist'));

module.exports = {
  ..., // 생략
  plugins: [
  	new CopyPlugin({
      patterns: [
        {
          from: `${swaggerUiModulePath}/swagger-ui.css`,
          to: 'src/swagger-ui.css',
        },
        {
          from: `${swaggerUiModulePath}/swagger-ui-bundle.js`,
          to: 'src/swagger-ui-bundle.js',
        },
        {
          from: `${swaggerUiModulePath}/swagger-ui-standalone-preset.js`,
          to: 'src/swagger-ui-standalone-preset.js',
        },
        {
          from: `${swaggerUiModulePath}/favicon-32x32.png`,
          to: 'src/favicon-32x32.png',
        },
        {
          from: `${swaggerUiModulePath}/favicon-16x16.png`,
          to: 'src/favicon-16x16.png',
        },
        {
          from: `${swaggerUiModulePath}/oauth2-redirect.html`,
          to: 'src/oauth2-redirect.html',
        },
      ],
    }),
  ]
}

이렇게 일단은 잘 마무리 되었다.

번들링 안해도 되면 걍 하지말자