Nest.js에 Yarn Berry + Zero Install 적용하기

Jinseok Eo·2023년 9월 14일
0
post-thumbnail

Yarn Berry를 도입한 이유

고난과 역경 끝에 Yarn PnP + Zero Install 조합을 Nest.js에 적용하는데 성공하였기에 기록으로 남겨둔다. Yarn을 모르는 사람들을 위해 간단하게 설명하자면 Yarn은 NPM과 같은 패키지 관리자이다. NPM은 노드 패키지 관리자의 약자이며 터미널에서 노드 서버 구동에 필요한 패키지를 관리할 수 있게 한다. 일반적으로 node_modules 라는 1GB 가까이 되는 거대한 패키지 폴더를 만든다. NPM과 유사한 구조체를 갖는 것은 Yarn Classic이며 NPM보다는 빠르지만 동일한 구조를 가지고 있다. 이러한 상황에서 획기적인 설치 속도와 압도적인 용량 절감 효과를 가져오는 패키지 관리자가 등장했는데 바로 Yarn Berry였다.

필자는 오래 전부터 이것을 회사 프로젝트에 적용하려고 노력했지만 빈번히 실패했었다. 머신의 사양이 그리 좋지 않기 때문에 빌드 속도를 개선하려면 빌드의 주체가 Github Action이나 별도의 빌드 머신이 있어야 했기 때문이다. 그러나 문제는 AWS S3, AWS ECR, GHCR, 도커 허브 같은 서비스를 이용하는데 제한이 있었고, 쓰자고 설득하는 것도 힘들었다. 결과적으로 느린 서버 머신에서 매 빌드 시 8분 이상을 기다려야 했다.

이에 따라 개발 환경을 3개로 분리하였다. 첫번째는 로컬 환경, 두번째는 DEV 환경, 세번째는 실제 제품이 돌아가는 PROD 환경이다. 로컬 환경은 PC에서 돌아가기 때문에 당연히 사양이 좋고, DEV 환경은 로컬보다는 느리지만 CPU가 4코어로 램도 충분하여 그나마 빨리 돌아간다. PROD 환경은 AWS EC2 환경이며 t3.micro 급이기 때문에 램은 1GB에 불과하고, CPU는 vCPU 2코어로 돌아간다. PROD 환경의 서버 성능이 낮은 이유는 트래픽과 이용자가 많지 않기 때문이다.

하지만 위 제한 사항 때문에 빌드 머신을 쓰지 못하고, 머신에서 자체적으로 빌드해야 했기 때문에 속도 개선이 반드시 필요했다. PROD 환경이 굉장히 느리기 때문에 대부분은 PROD와 거의 동일한 시스템 구성에 사양만 다른 DEV 환경에서 테스트했다.

결과

PROD 환경에서 빌드한 결과, 비약적인 속도 향상이 있었다.

기존Yarn PnP 적용 후
도커 이미지 크기748MB392MB
빌드 시간9~10분3분 6초(깃허브 액션에선 약 2분)

Yarn PnP와 Zero Install을 적용했고, 유령 의존성(Ghost dependencies) 때문에 많은 시행착오가 있었지만 결과를 보니 뿌듯하다

전문 용어 사전

Nest.js와 Docker를 이미 알고 있다고 가정한다. Nest.js를 알고 있다는 것은 Node.js를 이미 안다는 것이다. 따라서 이 글에서는 Docker나 Nest.js에 대한 기초적인 설명은 하지 않는다.

다만 몇몇 전문 용어에 불편함을 느낄 수 있기 때문에 아래에 설명해둔다.

PnP: Plug'n'Play는 Yarn 최신 릴리즈의 기본 설치 전략이다. 실제로 plugin을 만들 수 있다. yarn을 원하는대로 확장할 수 있다.

Zero Install: npm 레지스트리로부터 모듈을 설치하는 것이 아니라, 최초 .yarn/cache에 zip 형태로 보관하여 저장소에 같이 push 하는 형태이다. 깃에 같이 올리기 때문에 단점도 있지만 git pull 하는 순간, 설치가 완료된 것과 다름 없다.

유령 의존성 (Ghost dependencies): 직접 설치하지 않았지만 @nestjs/platform-express 등을 설치하면 express도 같이 import 할 수 있다. 이처럼 package.json에 명시되어있지 않아도 호출할 수 있는 패키지들을 가르켜, 유령 의존성이라고 한다.

AWS ECR: AWS에서 제공하는 Elastic Container Registry의 약자로 머신에서 빌드한 이미지를 올리거나 내려받을 수 있다. 쿠버네티스에서 private한 서버의 이미지를 내려 받을 때 유용하다.

GHCR: Github Container Registry의 약자로 깃허브에서 제공하는 도커 컨테이너 레지스트리이다. public 저장소는 무료이다.

@nestjs/platform-express : NestJS에서 제공하는 express를 추상화한 패키지이다.

기존 프로젝트에 설치 방법

먼저 프로젝트의 루트 디렉토리에서 다음 명령어를 터미널에 입력하여야 한다.

yarn set version berry

설정이 정상적으로 되었다면 yarn --version으로 버전을 확인할 수 있다.

yarn --version

필자의 경우, 3.6.3 버전이라고 표시가 되고 있다.

.gitignore 파일에 다음 라인을 추가한다.

.yarn/*
!.yarn/cache
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

이후, node_modules 폴더를 삭제한다.

yarn install

생성된 파일 중에 .yarnrc.yml 라는 파일이 존재한다. 해당 파일을 열고, nodeLinker 부분이 있는지 확인한다.

nodeLinker: node-modules

nodeLinker가 node-modules면 nodeLinker 라인을 삭제하고, 남아있는 node_modules 폴더를 지워야 한다. Zero Install을 위해서 다시 yarn install을 실행하면 준비가 끝난다.

패키지 설치가 끝났으면 타입스크립트 설정을 약간 해줘야 한다.

ZipFS 설치

Yarn PnP는 노드 모듈을 ZIP로 압축해놓았기 때문에 Visual Studio Code 기준, ZipFS라는 익스텐션을 설치해야 한다.

https://marketplace.visualstudio.com/items?itemName=arcanis.vscode-zipfs

VSCode와 연동

ZipFS와 연동하려면 다음 명령어를 실행해줘야 한다.

yarn dlx @yarnpkg/sdks vscode
yarn plugin import typescript

정상적으로 처리되었다면, 패키지에 마우스를 올렸을 때, 다음과 같이 메모리 상에 존재하는 경로인 .yarn/__virtual__/로 시작되는 경로로 패키지가 잡히게 된다.

이 부분이 잘 되질 않는다면 타입스크립트 버전이 일치하지 않는 것이다. Ctrl + Shift + P를 누르고 Typescript 라고 입력하면 버전을 선택할 수 있는 항목이 보인다.

이때, 하단에 경로가 있는 .yarn/sdks/typescript/lib를 선택해줘야 한다.

활성화가 되어있지 않은 경우, .vscode 폴더의 settings.json의 경로를 확인하자

작업 영역의 루트에 있는 .vscode/settings.json에 다음과 같이 되어있어야 한다. 필자 같은 경우, backend 폴더의 경로에 .vscode가 생성돼 루트로 옮겨주었고 ./backend/를 따로 지정했다.

기타 설정

Webpack

유령 의존성(Ghost dependencies) 문제로 실행 시, expresswebpack 등 몇몇 패키지에 대한 오류를 일으킬 수 있다. 실제로 이러런 유령 의존성 문제를 이야기 하는 패키지는 따로 설치해줘야 한다.

yarn add express webpack ts-loader webpack-node-externals

필자는 웹팩을 통해 서버 파일을 단 하나의 파일로 만드는 옵션을 사용하고 있다. 따라서 위와 같은 명령어를 실행해야 했다.

또한 Yarn PnP을 사용할 경우, 웹팩 구성 파일도 변경해야 할 수 있다. 루트 디렉토리에 webpack.config.js를 생성하면 NestJS에서 이를 인식한다.

const path = require('path')
const nodeExternals = require('webpack-node-externals');

const lazyImports = [
  '@nestjs/microservices/microservices-module',
  '@nestjs/websockets/socket-module',
  '@nestjs/platform-express',
  'swagger-ui-express',
  'class-transformer/storage',
  '@mapbox/node-pre-gyp'
]

module.exports = (options, webpack) => ({
  ...options,
  externals: [
    nodeExternals({
      modulesFromFile: true,
    })
  ],
  plugins: [
    ...options.plugins,
    new webpack.IgnorePlugin({
      checkResource(resource) {
        if (lazyImports.includes(resource)) {
          try {
            require.resolve(resource)
          } catch (err) {
            return true
          }
        }
        return false
      },
    }),
  ],
})

@mapbox/node-pre-gypbcrypt의 의존성 문제를 해결하기 위해 추가한 것이고, 다음 라인을 추가하지 않으면 html 파일 문제가 생긴다. lazyImports 변수가 왜 저렇게 되었는지 의구심이 생길 수 있다. 오류가 나는 부분을 하나하나 추가하면 결국 저렇게 된다.

  externals: [
    nodeExternals({
      modulesFromFile: true,
    })
  ],

@nestjs/terminus 같은 경우에는 많은 유령 의존성 문제를 야기한다.

기본적으로 위 목록에 있는 것을 전부 설치해줘야 했다. 필자는 이러한 모듈은 불필요 했기 때문에 @nestjs/terminusyarn remove를 통해 삭제하였다.

Dockerfile

도커 같은 경우, 실무의 도커 파일을 그대로 올리기는 힘들기 때문에 예제 샘플 파일을 준비했다.

필요한 파일만 복사하기 위해 멀티 스테이지로 구성을 하였다 (Yarn Berry의 경우, 노드 16.14 이상이 필요하다)

FROM node:16.18-alpine AS builder
ENV NODE_ENV production

RUN apk add --no-cache tzdata && \
    echo 'Etc/UTC' > /etc/timezone

WORKDIR /usr/src/app

COPY . .

# .yarn/unplugged에 있는 파일들을 설치하기 위한 코드
RUN yarn install

RUN yarn build

FROM node:16.18-alpine

WORKDIR /app

COPY --from=builder /usr/src/app/dist /app/dist
COPY --from=builder /usr/src/app/.pnp.cjs /app/.pnp.cjs
COPY --from=builder /usr/src/app/.yarnrc.yml /app/.yarnrc.yml
COPY --from=builder /usr/src/app/.yarn /app/.yarn
COPY --from=builder /usr/src/app/package.json /app/package.json
COPY --from=builder /usr/src/app/yarn.lock /app/yarn.lock

EXPOSE 3000
ENV NODE_ENV production
CMD [ "yarn", "start:prod" ]

yarn install.yarn/unplugged에 있는 플랫폼 의존적인 모듈 때문에 존재한다. 압축 파일로 되어있지 않고, 기존의 노드 모듈과 비슷한 형태로 되어있다. 예를 들면, 하단의 @swc-core의 경우, 각 플랫폼에 맞는 러스트의 바이너리를 로드해야 한다. 따라서 최초 한 번, 해당 플랫폼에서 yarn install이 필요하다. yarn install은 내부 알고리즘에 의해 lock 파일을 검증하면서 진행되며 전체 패키지를 다운로드 하진 않는다.

자세한 내용은 yarn install 아키텍쳐에 대한 문서를 참고하기 바란다. 요약하면 메모리에 virtual package tree를 구축한다는 내용이다.

도커 파일에서 yarn install을 빼놓을 경우, Zero Install 시, 일부 패키지가 누락되어 서버 실행이 제대로 되지 않을 수 있다. 전체 내용은 아래 샘플 코드를 참고하기 바란다.

https://github.com/biud436/yarn-berry-pnp-nestjs-example

https://github.com/biud436/blog-api-server

실무에서 쓰는 코드를 그대로 배치할 수는 없기 때문에 비슷한 구성으로 대체하였다.

도커 이미지를 빌드할 때, 어떠한 이유로 제대로 성공하지 못하는 경우가 있다. 이땐, 기존의 캐시를 제거하고 다시 수행하기 바란다.

필자는 로드 밸런싱을 이유로 서버 이미지를 2개 이상 띄우는데, 이 과정에서 서버 하나가 로드되지 않고 1개만 돌고 있는 것을 확인했었다.

기존 캐시가 남아있어 문제가 생겼던 걸로 보여졌다. 필자는 다음 명령어를 호출하여 해결했다.

docker system prune -a -f

다만 위 명령어는 모든 시스템의 불필요한 부분을 날려버리기 때문에 이 명령어를 실행한 적이 거의 없다면 시간이 오래 걸릴 수 있으니 유의해서 사용하기 바란다.

다만 서버 구성과 사용된 패키지에 따라, Yarn PnP를 도저히 사용할 수 없는 경우도 있다. 하단 참고 글에 의하면 Fastify 사용 시, 문제가 있어서 nodeLinker: node_modules로 설정했다는 이야기가 있으니 참고 바란다.

필자 같은 경우, 문제가 생기는 패키지를 삭제하는 방향으로 문제를 해결했었다. 실제로 실무 코드에 적용할 경우 여러 문제가 생길 수 있으니 브랜치를 미리 분리해놓고 안전한 환경에서 테스트를 해야 한다.

디버거 설정

디버거를 사용하려면 기존 launch.json 파일의 runtimeArgs 속성에 "-r","./.pnp.cjs"을 앞에 추가해야 한다. 추가하지 않으면 디버거가 동작하지 않으며 모듈을 찾을 수 없다는 오류가 뜨게 된다.

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Launch Program",
            "runtimeArgs": [
                "--nolazy",
                "-r",
                "./.pnp.cjs",
                "-r",
                "ts-node/register",
                "-r",
                "tsconfig-paths/register"
            ],
            "sourceMaps": true,
            "args": ["${workspaceFolder}/src/main.ts"],
            "envFile": "${workspaceFolder}/.development-local.env",
            "cwd": "${workspaceFolder}",
            "console": "integratedTerminal"
        }
    ]
}

위와 같이 설정했을 경우에도 되지 않는다면 args, envFile, cwd 등의 속성을 알맞게 수정하기 바란다.

참고 문서

node_modules로부터 우리를 구원해 줄 Yarn Berry
리멤버 웹 서비스 좌충우돌 Yarn Berry 도입기
Yarn Berry 적용 1일 차에 느낀 점 (Nest.js, Fastify ⤴️ / TurboRepo ⤵️)
Cannot build with Webpack (@nestjs/mapped-type)

0개의 댓글