제한된 환경에서 Next.js 무중단 배포 구현하기


메인 이미지

지속적인 통합과 배포(CI/CD)는 서비스의 품질을 향상시키지만, 잦은 배포로 사용자 경험(UX)을 저해해서는 안됩니다.
이는 서비스 운영에서 ‘무중단 배포’가 필수적인 이유입니다.
본 글에서는 Next.js를 사용하는 프론트엔드 환경의 여러 제약 조건 속에서, 서비스 중단이 없는(zero-downtime) 무중단 배포를 구현했던 경험을 공유하고자 합니다.


조건


Next.js app router

프로젝트는 모노레포로 구성되어있으며, 서비스는 Next.js app router를 사용하여 구성되어있습니다.

zero-downtime

서비스가 배포되는 동안 단 1초라도 서비스 중단이 없어야하며, 404 500 등의 에러를 포함하여 배포로 인한 운영중인 서비스에 모든 에러가 발생하지 않아야합니다.

배포 환경

로컬 환경에서 개발 후 develop staging production 환경으로 나누어 배포를 진행합니다.

롤백 전략

배포 후 문제가 발생하면 빠른 롤백을 진행할 수 있어야합니다.


제약


On-Premise 환경

서버를 포함한 인프라 구성이 대부분 직접 구축하고 운영하고있습니다.
클라우드 서비스를 사용할 수 없고, 사용 가능한 자원이 한정되어 오토 스케일링(Auto Scaling) 구현이 어렵습니다.
또한 docker와 같은 컨테이너 기술을 사용할 수 없습니다.

서버 이중화 구성

단일 서버가 아닌, 이중화된 서버로 구성되어있습니다.
또한 웹서버로 바로 트래픽이 들어오지않고, L4스위치를 통해 라운드로빈 방식으로 트래픽이 분배됩니다.

CDN 사용 불가

외부 cdn을 사용할 수 없으며 별도의 정적 리소스 서버를 통해 제공하거나, 서비스가 실행되는 웹서버 내부에서 제공해야합니다.


구현


다행히도 github actions self-hosted runner를 활용 할 수 있어서, 이를 활용한 파이프라인을 구축하기로 결정했습니다.
운영환경에서 사용할 가용 자원(VM)은 한정적이기 때문에 rolling update 방식을 적용했으며, workflow 구성은 아래와 같습니다.

workflow 이미지

dev/stg 환경의 경우 단일 서버로 구성되어있으며, 외부에서는 접근이 불가능한 환경입니다.
배포로 인한 리스크가 크지 않았고, 잦은 배포가 이뤄져도 실제 운영중인 서비스만큼 영향력이 크지는 않습니다.
반대로 prod(운영) 환경은 action trigger도 수동으로 진행하게 되었습니다.
이는 검증이 끝나지않은 기능이 실수 혹은 착오로 push/merge가 되었을때 배포가 되지않기 위한 안전장치입니다.
또한 몇가지 이유로 서버에 전달된 빌드 결과물은 서버에 최근 5개 버전을 적재 했습니다.

build

next.config에서 사용한 옵션은 다음과 같습니다.

const commitId = process.env.NEXT_PUBLIC_COMMIT_ID;
const nextConfig = {
  output: 'standalone',
  assetPrefix: commitId ? `/${commitId}` : '',
  ...
};

output: ‘standalone’
Next.js 기반의 서버를 실행하기위해 필요한 최소 파일만으로 빌드하는 방식을 설정하는 옵션입니다.
빌드는 CI에서, 서비스 실행은 서버에서 하기 위한 목적으로 사용되며, node_modules와 빌드 환경을 포함한 의존성 설치도 필요가 없는 경량화된 빌드 결과물을 생성합니다.
이 방식에서 정적 리소스는 보통 cdn에서 처리하는것이 이상적이기때문에 public 디렉토리와 .next/static을 포함하지 않습니다. cdn을 사용할 수 없는 제약상, 빌드 결과물이 서버로 전달되기 전에, 빌드결과물에 public .next/static를 수동으로 copy를 해주는 postbuild script가 필요합니다.

assetPrefix: commitId
Next.js가 생성하는 정적 리소스를 어떤 URL prefix에서 로드할지 지정하는 설정입니다.
정적리소스를 cdn에 업로드하는 경우 cdn domain 접두사를 사용할 수 있으며, 여러 버전이 동시에 존재하는 서비스 구조 혹은 이전 버전의 리소스를 참조하는것을, path에 버전을 추가하여 방지 할 수 있습니다.

# default
https://a.com/_next/static/chunks/4b9b41aaa062cbbfeff4add70f256968c51ece5d.4d708494b3aed70c04f0.js

# assetPrefix '/a3f9c2e'
https://a.com/a3f9c2e/_next/static/chunks/4b9b41aaa062cbbfeff4add70f256968c51ece5d.4d708494b3aed70c04f0.js

그리고 rolling update와 무중단 서비스 구현을 위해 pm2를 사용했습니다.
pm2는 프로세스 매니저로서 애플리케이션의 재시작과 장애 복구를 안정적으로 처리해주며, cluster 모드를 통해 단일 서버에서도 다중 프로세스를 활용할 수 있습니다.
또한 ecosystem 설정을 활용하면 develop, staging, production과 같은 배포 환경별로 환경 변수를 주입하거나, 실행 옵션을 다르게 구성할 수 있어서 동일한 코드베이스를 환경에 맞게 유연하게 운영할 수 있습니다.

// ecosystem.config.js example
module.exports = {
  apps: [
    {
      name: "next-app",
      script: "server.js",
      cwd: "/app/current",
      instances: 'max',
      exec_mode: "cluster",
      autorestart: true,
      watch: false,
      max_memory_restart: "512M",

      env_develop: {
        NODE_ENV: "development",
        PORT: 3000,
        APP_ENV: "develop",
        LOG_LEVEL: "debug",
      },

      env_staging: {
        NODE_ENV: "production",
        PORT: 3001,
        APP_ENV: "staging",
        LOG_LEVEL: "info",
      },

      env_production: {
        NODE_ENV: "production",
        PORT: 3000,
        APP_ENV: "production",
        LOG_LEVEL: "warn",
      },
    },
  ],
};

delivery

github runner에서 빌드 후 postbuild script 실행까지 완료되어, public 디렉토리와 .next/static까지 포함된 빌드 결과물은 ${ github.sha }.tar.gz로 운영환경의 웹서버로 전달됩니다.
이와 함께 그리고 release 디렉토리 하위에 압축 해제 후 github commit SHA를 기준으로 버전별 디렉토리가 형성됩니다.

/app/releases/
 ├─ a3f9c2e/
 ├─ b71d1aa/
 ├─ cc91e0f/
 ├─ 8d4b2a1/
 └─ f02a9c7/

이후 실제 실행될 현재 버전을 심볼릭링크로 관리를 했습니다. current -> /releases/a3f9c2e
운영환경에서 긴급 롤백이 필요할경우 CI/CD 전체 과정을 수행하지 않고도, 심볼릭링크를 이전 버전 디렉토리로 변경만 해준 뒤 서비스를 reload 해준다면 빌드/배포가 과정이 생략된 빠른 롤백이 가능합니다.

다만, 위와 같은 방식의 배포의 경우 한가지 몇가지 문제가 발생할 수 있습니다.
여러 서버에 순차적으로 배포/재실행 할 경우, 브라우저에서는 a서버에 존재하는 새 릴리즈 버전의 리소스(js,css)를 요청했지만, 라운드로빈 방식의 로드밸런서에 의해 b서버로 요청이 가게되면 404에러가 발생할 수 있습니다.

이를 해결하기 위해 상위 이미지에 기재된대로, prod(운영 환경)에서는 빌드 결과물이 모든 서버에 빌드 결과물이 전달되는것을 대기하는 step이 있습니다.

또한 Next.js는 초기 HTML 응답 시점에 해당 페이지에 필요한 js,css chunk 경로를 결정하고, 이 경로는 이후 클라이언트 내비게이션에서도 그대로 사용되므로, 새 버전이 배포되었더라도 새로고침이 발생하지 않으면 이전 버전의 asset 경로로 요청이 이어집니다.

.next build 디렉토리 이미지build-maifest.json 이미지

next.js 요청 리소스 이미지

즉 a버전(1.0.0)으로 화면을 바라보고있는사용자는 새로운 버전(1.0.1)이 배포가 되었을때 여전히 a버전(이전 릴리즈 버전) 내부에 존재하는 chunk를 요청하기 때문에, chunk missmatch error 혹은 404 오류가 발생하게됩니다.

앞서 정적리소스를 웹서버에 전달할 때 이전 릴리즈버전을 5개까지 서버에 적재를 하고 있기 때문에, asset path에 대한 fallback을 설정해주면 이러한 문제를 방지할 수 있습니다.

저는 운영 환경에서는 nginx를 앞단에 두고 Next.js 서버를 reverse proxy 형태로 구성했습니다.
그래서 nginx.config에 다음과 같이 location을 설정하여 이전 버전 리소스에 대한 요청도 정상적으로 응답 할 수 있었습니다.

location ~ ^/([a-z0-9]+)/_next/static/(.*)$ {
  try_files
    /releases/$1/_next/static/$2
    /current/_next/static/$2
    =404;

  root /app;
}
location /_next/static/ {
  root /app/current;
  expires 1y;
  add_header Cache-Control "public, immutable";
}

앞서 빌드/전달 단계에서 github commit SHA를 기반으로 assetPrefix를 설정하고 디렉토리를 생성한 것은 사용자의 현재 세션에서 필요한 정적 리소스에 정확한 위치로 브라우저가 요청하기 위함입니다.

reload

build -> delivery가 끝난 이후에는 새로운 버전으로 서비스를 reload 해주면 됩니다.
모든 서버에 artifact가 전달 된 후, 서버에 생성된 새로운 버전의 release 디렉토리로 심볼릭 링크를 변경한뒤, 환경별로 pm2 reload cli를 통해 프로세스가 순차적으로 재시작되어 downtime이 없는 무중단 배포가 완성됩니다.

# develop
pm2 reload ecosystem.config.js --env develop

# staging
pm2 reload ecosystem.config.js --env staging

# production
pm2 reload ecosystem.config.js --env production

본 글에는 적지 않았지만


인프라 구성이 변경될 수 있고, 현재는 사용할 수 없는 도구들이 차후에는 활용될 수 있습니다.
CDN이 도입 가능할 때 마이그레이션 작업이 공수가 크지 않도록, 정적리소스는 웹서버에서 별도의 경로에 전송했습니다.

배포되는 환경이 변경(추가/제거)되거나, 환경별 작업이 추가될 수 있습니다.
이를 위해 github variables/secret을 활용해 환경별 설정을 분리하고, action trigger는 develop, staging, workflow_dispatch를 환경별 yml에 별도로 작성하고 공통되는 action을 정의했습니다.

rolling update 환경에서는 서버가 이중화 되어있기 때문에 모든 서버에 업데이트가 반영되기 전까지 이전 버전과 최신 버전이 동시에 공존하게 됩니다. 이를 방지하기 위해 배포 시점에 특정 서버의 트래픽을 차단한 뒤 한 대씩 순차적으로 업데이트하는 방식을 고려할 수 있지만, 서비스 처리량을 최대한 유지하는 것을 우선순위로 두었기 때문에 해당 방식을 적용하지 않고 버전 공존을 허용하는 방향을 선택했습니다.


끝으로


현재 구성된 배포 프로세스 역시 완성형이라고 보기는 어렵고, 운영을 통해 발견되는 개선 포인트들을 지속적으로 보완해 나갈 예정입니다.
폐쇄적인 환경이나 다양한 제약이 존재하는 상황에서도, 프론트엔드 애플리케이션의 무중단 배포를 구현할 수 있다는 점은 의미 있는 경험이었습니다.

이 글이 비슷한 환경에서 Next.js를 운영하거나, 클라우드나 CDN 없이 배포 전략을 고민하는 분들에게 참고 자료가 되기를 바랍니다.