Gitlab CI 환경에서의 Next.js Standalone 배포 및 캐시 최적화

회사에서 Next.js 프로젝트를 운영하면서, 번들 크기가 무려 1.7GB에 달하고, 모노레포 환경에서 배포 시간이 15분 이상 걸리는 비효율적인 상황이 있었습니다. 이로 인해 빌드 및 배포 효율을 극대화할 수 있는 구조가 필요하다고 느꼈고, 다양한 캐시 전략과 배포 방식을 적용하게 되었습니다. 그 결과, 번들 크기는 1.7GB에서 300MB로 대폭 감소했고, 배포 파이프라인도 캐시를 적극 활용할 경우 5분 이내로 단축되었습니다.

이 글에서는 Next.js standalone 빌드, next/cache, S3 static 서빙, 증분 빌드, 그리고 GitLab CI에서의 캐시 전략까지 실제 적용 사례와 함께, 전체 CI/CD 파이프라인의 흐름을 상세히 정리합니다.


전체 CI/CD 파이프라인 흐름

  1. PR: 개발자가 코드의 PR을 생성하면, 해당 PR 기준으로 CI/CD 파이프라인이 실행됩니다.
  2. 캐시 복원: 빌드 시작 전 S3에서 .next/cache를 복원하고, node_modules는 GitLab CI의 로컬 디스크 캐시를 활용합니다.
  3. 이미지 빌드: Next.js standalone 빌드 및 Docker 이미지 생성
  4. 정적 파일 업로드: 빌드된 .next/static을 S3에 업로드
  5. ECR에 이미지 push: 빌드된 이미지를 ECR에 업로드
  6. 배포: Elastic Beanstalk에서 ECR 이미지를 pull하여 서비스에 반영
  7. 캐시 저장: 빌드 후 .next/cache를 S3에 저장하여 다음 빌드에 활용
  8. 머지: CI/CD가 성공적으로 완료되면 PR을 머지합니다.

이런 구조를 통해, 대용량 번들/느린 배포 문제를 효과적으로 해결할 수 있었습니다.


전체 배포 구조

  • ECR: Next.js standalone 이미지를 빌드하여 ECR에 업로드합니다.
  • Elastic Beanstalk: ECR 이미지를 pull하여 서비스 운영이 가능합니다.
  • S3: 정적 파일(.next/static)과 빌드 캐시(.next/cache)를 STATIC_S3_BUCKET에 업로드/다운로드합니다.
  • GitLab CI: node_modules 캐시를 적극적으로 활용하여 빌드 속도가 최적화됩니다.

1. Next.js Standalone 빌드와 서빙

Next.js 13 이상에서는 output: 'standalone' 옵션을 통해 Node.js 런타임에서 실행 가능한 최소 파일만 추출할 수 있습니다.
이 방식은 컨테이너 이미지 크기를 줄이고, 배포 환경에 Node.js만 있으면 바로 실행이 가능하여 매우 효율적입니다.

// next.config.js
module.exports = {
  output: "standalone",
  assetPrefix: process.env.NEXT_PUBLIC_ASSET_PREFIX, // 예: https://**STATIC_S3_BUCKET**.s3.**AWS_REGION**.amazonaws.com/**STATIC_S3_PREFIX**/**BRANCH_NAME**/**SERVICE**/_next/static/
};

빌드 시에는 다음과 같이 standalone 디렉토리가 생성됩니다.

dist/**SERVICE_PATH**/.next/standalone/

여기에는 서버 실행에 필요한 최소 파일과 server.js가 포함됩니다.

실행은 아래처럼 단순하게 가능합니다.

node standalone/**SERVICE_PATH**/server.js

2. TypeScript incremental 빌드와 캐시 활용

빌드 속도를 높이기 위해 TypeScript의 incremental 옵션을 사용합니다.
증분 빌드는 이전 빌드 정보를 활용해 변경된 부분만 다시 컴파일하므로, 캐시와 함께 사용할 때 빌드 시간이 더욱 단축됩니다.

// tsconfig.json
{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": "./.tsbuildinfo"
    // ...기타 옵션
  }
}

빌드를 실행하면 프로젝트 루트(혹은 지정한 경로)에 .tsbuildinfo 파일이 생성됩니다. 이 파일에는 이전 컴파일 결과와 의존성 정보가 저장되어, 다음 빌드 시 변경된 파일만 빠르게 다시 빌드할 수 있습니다.

  • .tsbuildinfo: 증분 빌드 정보를 담고 있는 파일로, 변경된 부분만 다시 컴파일할 수 있게 도와줍니다.
  • dist/: 실제로 컴파일된 js, d.ts 등 빌드 산출물이 저장되는 디렉토리입니다.

GitLab CI에서 .next/cache를 적극적으로 활용하기 때문에, 증분 빌드의 효과가 극대화됩니다. TypeScript incremental 빌드와 Next.js의 빌드 캐시가 결합되어, 전체 빌드 시간이 크게 단축됩니다.


3. 정적 파일 S3 업로드 및 서빙

Next.js의 정적 파일(.next/static)은 S3에 업로드하여 서빙합니다.
assetPrefix를 S3 주소(또는 S3와 연동된 CDN 주소)로 지정하면, 클라이언트가 정적 리소스를 S3(또는 CDN)에서 직접 받아가므로 서버 부하가 줄고, CDN을 연동할 경우 전 세계 어디서나 빠른 응답이 가능합니다.

// next.config.js
module.exports = {
  assetPrefix: process.env.NEXT_PUBLIC_ASSET_PREFIX, // 예: https://**STATIC_S3_BUCKET**.s3.**AWS_REGION**.amazonaws.com/**STATIC_S3_PREFIX**/**BRANCH_NAME**/**SERVICE**/_next/static/
};

CI에서는 빌드 후 아래처럼 S3에 업로드합니다.

aws s3 sync dist/**SERVICE_PATH**/.next/static s3://**STATIC_S3_BUCKET**/**STATIC_S3_PREFIX**/**BRANCH_NAME**/**SERVICE**/_next/static/ --delete

4. Next.js 빌드 캐시(.next/cache) 활용

빌드 캐시를 적극적으로 활용하면, 빌드 속도가 크게 단축됩니다.
.next/cache를 S3에 저장하고, 빌드 시작 시 S3에서 받아와 복원합니다.

캐시 복원

aws s3 sync s3://**STATIC_S3_BUCKET**/nextjs-cache/**BRANCH_NAME**/ dist/**SERVICE_PATH**/.next/cache/ || true

캐시 저장

aws s3 sync dist/**SERVICE_PATH**/.next/cache/ s3://**STATIC_S3_BUCKET**/nextjs-cache/**BRANCH_NAME**/ --delete

(추가) GitLab 로컬 디스크 캐시

S3 외에도, GitLab CI의 로컬 디스크 캐시를 활용하면 네트워크 비용 없이 더 빠른 캐시 복원이 가능합니다.

cache:
  key:
    files:
      - package-lock.json
      # 필요시 주요 소스 파일, 환경변수 등 추가
  paths:
    - node_modules/

.next/cache는 아래와 같이 S3를 통해서만 관리합니다.

# 캐시 복원
aws s3 sync s3://**STATIC_S3_BUCKET**/nextjs-cache/**BRANCH_NAME**/ dist/**SERVICE_PATH**/.next/cache/ || true
 
# 캐시 저장
aws s3 sync dist/**SERVICE_PATH**/.next/cache/ s3://**STATIC_S3_BUCKET**/nextjs-cache/**BRANCH_NAME**/ --delete

5. GitLab CI 파이프라인 구조 (예시)

아래는 실제 환경에 맞게 변수화한 GitLab CI의 주요 부분 예시입니다.

stages:
  - build_service
  - deploy_service
 
build_service:
  # ...생략
  before_script:
    # S3에서 .next/cache 복원
    - aws s3 sync s3://**STATIC_S3_BUCKET**/nextjs-cache/**BRANCH_NAME**/ dist/**SERVICE_PATH**/.next/cache/ || true
  script:
    # Docker build (standalone)
    # 컨테이너에서 static, cache 추출
    # S3에 static, cache 업로드
    # (아래 실제 예시 참고)
    - |
      docker buildx build \
        --platform=linux/amd64 \
        --memory=4g --memory-swap=5g \
        --build-arg BRANCH_NAME=**BRANCH_NAME**  \
        --build-arg BUILDKIT_PROGRESS=plain \
        --build-arg SERVICE=**SERVICE** \
        -t **AWS_ACCOUNT_ID**.dkr.ecr.**AWS_REGION**.amazonaws.com/**IMAGE_TAG**:**TAG_NAME** \
        --push -f Dockerfile-**SERVICE** .
    # 컨테이너에서 static, cache 추출
    - |
      CONTAINER_ID=$(docker create **AWS_ACCOUNT_ID**.dkr.ecr.**AWS_REGION**.amazonaws.com/**IMAGE_TAG**:**TAG_NAME**)
      mkdir -p dist/**SERVICE_PATH**/.next
      docker cp $CONTAINER_ID:/app/dist/**SERVICE_PATH**/.next/static dist/**SERVICE_PATH**/.next/
      docker cp $CONTAINER_ID:/app/dist/**SERVICE_PATH**/.next/cache dist/**SERVICE_PATH**/.next/
      docker rm $CONTAINER_ID
    # S3에 static, cache 업로드
    - aws s3 sync dist/**SERVICE_PATH**/.next/static s3://**STATIC_S3_BUCKET**/**STATIC_S3_PREFIX**/**BRANCH_NAME**/**SERVICE**/_next/static/ --delete
    - aws s3 sync dist/**SERVICE_PATH**/.next/cache/ s3://**STATIC_S3_BUCKET**/nextjs-cache/**BRANCH_NAME**/ --delete
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
  # ...생략
 
deploy_service:
  # ...생략
  script:
    # S3에 static 업로드
    # ECR 이미지로 Beanstalk 배포

6. Dockerfile에서 node_modules, 캐시, static 복사 동작 상세

Dockerfile에서는 node_modules를 COPY --link로 복사합니다. 이때 GitLab CI에서 node_modules 캐시가 있으면, 아래와 같이 동작합니다.

  • GitLab CI cache에 node_modules가 있을 때: 캐시가 복원되어 워크스페이스에 node_modules가 이미 존재하므로, Dockerfile에서 COPY --link로 빠르게 컨테이너에 복사됩니다. 이때 파일 시스템의 하드링크를 활용해 복사 속도가 매우 빠릅니다.
  • 캐시가 없을 때: node_modules가 없으므로, Dockerfile의 deps 스테이지에서 npm ci로 새로 설치합니다. 이후 COPY --link로 복사됩니다.
FROM node:22.14.0-alpine AS deps
WORKDIR /app
COPY --link package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,id=**CACHE_VERSION**-**SERVICE**-**BRANCH_NAME**-npm-cache \
    npm ci --quiet
 
FROM node:22.14.0-alpine AS builder
WORKDIR /app
COPY --link --from=deps /app/node_modules ./node_modules
# ...

정리:

  • GitLab CI에서 node_modules 캐시가 복원되면, Dockerfile에서 COPY --link로 빠르게 복사됨 (하드링크 방식, 속도 빠름)
  • 캐시가 없으면 deps 스테이지에서 npm ci로 새로 설치 후 복사
  • node_modules는 오직 GitLab CI cache로만 관리하며, S3에는 저장/복원하지 않음

7. GitLab CI에서 static(.next/static), next cache(.next/cache) 활용 구조

  • .next/cache: S3에서 복원 → 빌드 후 S3에 저장 (증분 빌드/캐시 최적화)
  • .next/static: 빌드 후 컨테이너에서 추출 → S3에 업로드 (정적 파일 서빙)
  • node_modules: GitLab CI cache로만 관리, Dockerfile에서 COPY --link로 복사

실제 파이프라인 예시

  1. 캐시 복원 단계
    • S3에서 .next/cache 복원: 빌드 속도 향상
    • GitLab CI cache에서 node_modules 복원: npm install 생략 가능
  2. Docker 빌드 및 이미지 push
    • docker buildx build --push로 ECR에 이미지 업로드
  3. 컨테이너에서 static, cache 추출
    • docker cp로 .next/static, .next/cache 추출
  4. S3 업로드
    • static: S3에 업로드 (assetPrefix로 서빙)
    • cache: S3에 저장 (다음 빌드에 활용)

8. 결론 및 정리 (실제 파이프라인 기준)

  • ECR 이미지 push는 docker buildx build --push로 수행
  • node_modules는 GitLab CI cache가 있으면 COPY --link로 빠르게 복사, 없으면 새로 설치
  • .next/cache는 S3에서만 복원/저장, .next/static은 빌드 후 S3에 업로드(복원은 안 함)
  • 실제 파이프라인 흐름: 캐시 복원 → Docker 빌드/이미지 push → 컨테이너에서 static/cache 추출 → S3 업로드 → 배포