개요

AWSKRUG 안건환님의 Next.js와 AWS ECS, CI/CD 그리고 CDN을 곁들인 meet up에 참여했습니다. 비록 클라우드 플랫폼은 다르지만 전반적인 CI/CD 흐름이 거의 동일하여 저희 서비스와 비교하며 유익한 시간을 보낼 수 있었습니다. 이번 글에서는 참여하면서 놓친 부분을 점검하고 개선한 내용을 공유하려 합니다.

node_module 캐시 과정 개선

CI/CD 과정에서 의존성 설치는 많은 시간을 소요합니다. 저희는 의존성 설치 시 npm ci를 하기 때문에, package-lock 파일의 버전을 기준으로 캐시를 하고 있었는데요, 어느 순간부터 되지 않고 있었습니다.

원인

원인은 모노레포 전환에 있었습니다. 모노레포 구조에서는 필요한 모든 node_modules를 가져와야 합니다. 저희는 최대한 작은 사이즈를 가져오기 위해 캐시 된 것들 중 root의 node_modules만 가져오고 있었습니다.

deploy.yml
1- name: Cache node modules
2 id: node-cache
3 uses: actions/cache@v3
4 env:
5 cache-name: cache-node-modules
6 with:
7 path: |
8 // root의 node_modules만 불러온다.
9 node_modules
10 key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
11 restore-keys: ${{ runner.os }}-node-modules-

필요한 node_modules들

하지만 모노레포 방식의 구조에선 root의 node_modules뿐만 아니라, 필요한 모든 node_modules가 필요합니다. 예를 들어 특정 서비스의 node_modules와 사용하고 있는 packages의 node_modules가 필요하겠죠. 아래와 같이 수정하여 필요한 모든 node_modules를 캐시로 관리하도록 합니다.

deploy.yml
1- name: Cache node modules
2 id: node-cache
3 uses: actions/cache@v3
4 env:
5 cache-name: cache-node-modules
6 with:
7 path: |
8 node_modules
9 A/node_modules
10 packages/*/node_modules
11 key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
12 restore-keys: ${{ runner.os }}-node-modules-

claen install 생략

캐시가 존재할 경우 clean install 과정을 생략하는 조건을 추가합니다. 이는 이미 캐시 된 node_modules를 사용하여 불필요한 재설치를 방지합니다.

deploy.yml
1- name: Clean Install
2 if: steps.node-cache.outputs.cache-hit != 'true'
3 run: npm ci -w A -w packages -include-workspace-root=true

Next.js Standalone 모드와 Docker 이미지 크기 최적화

Docker 이미지 사이즈는 저장 공간 절약에도 중요한 의미가 있지만, 특히 GCP의 Cloud Run, AWS의 ECS와 같은 컨테이너 기반 서비스에서 빠른 전송을 통한 배포 속도 향상에 크게 영향을 미칩니다.

NextJS에서 제공하는 standalone 모드를 사용하면 build 시 production에 필요한 파일들만 standalone 폴더에 복사하여 필요 없는 코드들을 제거할 수 있어 Docker 이미지 크기를 줄일 수 있습니다.

그러나 최근에 standalone 모드를 적용했음에도 Docker 이미지 사이즈가 큰 경우가 발생했습니다.

원인 분석

1. node에서 실행하는 dd-trace

원인은 데이터독 APM을 위한 dd-trace 모듈에 있었습니다. 이 패키지는 코드 레벨이 아닌 node에서 실행하기 때문에 서버를 실행시키전에 preload 합니다.

dockerfile
1// docker
2COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
3COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
4
5... 생략
6
7// dd-trace 실행
8ENV NODE_OPTIONS="--require dd-trace/init"
9
10// next 서버 실행
11CMD HOSTNAME="0.0.0.0" node server.js

2. standalone에서 dd-trace 모듈 설치

dd-trace를 실행하려면 먼저 dd-trace 모듈이 설치되어 있어야 합니다. standalone 모드의 작동 방식을 고려할 때, 코드 레벨에서 작성된 모듈만이 standalone 폴더에 복사되기 때문에, dd-trace/init과 같이 코드 레벨에서 직접 작성되지 않은 모듈은 standalone 폴더에 포함되지 않는 문제가 발생합니다. 이를 해결하기 위해 dockerfile에 추가적인 작업이 필요합니다.

다음은 dockerfile에서 standalone 폴더를 복사하고 dd-trace 모듈을 추가로 설치하는 예시입니다.

dockerfile
1// docker
2COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
3COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
4
5... 생략
6
7// dd-trace 패키지 설치, 실행
8RUN npm install dd-trace
9ENV NODE_OPTIONS="--require dd-trace/init"
10
11// next 서버 실행
12CMD HOSTNAME="0.0.0.0" node server.js

3. 도커 파일 사이즈 급증

도커 이미지 크기가 급증한 원인은 이 부분입니다. 멘탈 모델과는 다르게 standalone 폴더에서 필요한 패키지인 dd-trace만을 설치하려고 할 때, 예상과는 달리 모든 node_modules가 재설치된 것입니다. ( 로컬에선 standalone 폴더에서 아무 패키지 설치/제거 시 확인해 볼 수 있습니다. )

production 배포에 포함시키기

이 단계를 피하기 위해선 코드 레벨에서 dd-trace를 실행해 nft 모듈이 dd-trace 모듈을 감지하고 production에 필요하다고 판단시켜 standalone에 복사시켜야 합니다.

server에서 실행되는 코드니 Server Component 안에서 실행시키면 어떨까요?

app.tsx
1// server component
2import 'dd-trace/init'

Server Component 내부의 모듈들은 NextJS가 자동으로 번들링을 합니다. 하지만 dd-trace는 node의 기능을 사용해야 하기 때문에 이 상태로 build 하게 되면 에러가 발생합니다.

native node 사용

이를 해결하기 위해 serverComponentsExternalPackages 옵션을 사용합니다. 별도의 node 기능을 사용하는 모듈이라면 NextJS 번들링을 하지 않고 node 기능을 사용할 수 있도록 합니다.

next.config.js
1/** @type {import('next').NextConfig} */
2const nextConfig = {
3 experimental: {
4 serverComponentsExternalPackages: ['dd-trace'],
5 },
6}

마무리

CI/CD 구조를 점검하면서 발견한 다양한 이슈들을 해결하는 과정이 매우 유익했습니다. 이러한 문제들을 미리 감지하는 것도 CI/CD 파이프라인의 중요한 부분이라고 생각합니다. 이 부분에 대해 공부해서 적용해보려고 합니다.

참고

  1. 랠릿 standalone 적용기
  2. NextJS serverComponentsExternalPackages