무색
기술블로그
에세이
연구
프로덕트
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

[email protected]

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관
INCHEON, KR
Next.js 15 standalone 앱을 Docker와 K8s로 배포하기
museck 만들기
2026. 1. 6.

Next.js 15 standalone 앱을 Docker와 K8s로 배포하기

DockerKubernetesNext.jsDevOpsPayloadCMS

왜 컨테이너화가 필요했나

앞선 글에서 PayloadCMS와 Next.js 15로 홈페이지 기본 뼈대를 만들었다. 로컬에서 pnpm dev로 돌리면 잘 동작하는데, 문제는 이걸 실제 서버에 어떻게 올리느냐였다. 홈랩에 Kubernetes 클러스터가 돌아가고 있으니 컨테이너 이미지를 만들어야 했다.

Next.js는 output: "standalone"이라는 빌드 옵션을 제공한다. 이걸 쓰면 node_modules 전체를 들고 다닐 필요 없이 server.js 하나와 필요한 의존성만 뽑아낼 수 있다. Docker 이미지 크기를 확 줄일 수 있는 셈이다.

멀티스테이지 빌드로 Dockerfile 짜기

Next.js 공식 예제에 나오는 Dockerfile은 yarn, npm, pnpm을 전부 지원하려고 분기 처리가 복잡하다. 우리 프로젝트는 pnpm만 쓰니까 그 부분을 싹 걷어내고 3단계로 단순화했다.

1단계: deps (의존성 설치)

FROM node:20-alpine AS base

FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile

Alpine 기반 이미지에 libc6-compat를 깔아주는 건 일부 네이티브 모듈 호환성 때문이다. package.json과 락파일만 먼저 복사해서 의존성을 설치하면, 소스 코드가 바뀌어도 이 레이어는 캐시를 탄다. 빌드 시간을 크게 아낄 수 있는 포인트다.

2단계: builder (빌드)

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED=1
ARG PAYLOAD_SECRET=build-time-dummy-secret
ENV PAYLOAD_SECRET=${PAYLOAD_SECRET}
RUN corepack enable pnpm && pnpm build

여기서 눈여겨볼 건 PAYLOAD_SECRET 더미값이다. PayloadCMS는 빌드 타임에도 이 환경변수가 있어야 generateStaticParams 같은 함수가 돌아간다. 실제 시크릿은 런타임에 K8s Secret으로 주입하니까 빌드 시점엔 더미로 충분하다.

3단계: runner (프로덕션 실행)

FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs && \
    mkdir -p ./public /app/media && \
    chown nextjs:nodejs /app/media

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
CMD ["node", "server.js"]

핵심은 .next/standalone 디렉토리만 복사한다는 것이다. Next.js가 빌드할 때 필요한 node_modules를 알아서 추려서 이 폴더에 넣어준다. 그래서 최종 이미지에는 빌드 도구, 소스 코드, 불필요한 패키지가 하나도 안 들어간다.

비특권 사용자(nextjs:nodejs)로 실행하는 것도 빼먹으면 안 된다. 컨테이너가 root로 도는 건 보안상 좋지 않으니까.

Health Check API 만들기

K8s에 앱을 올리려면 컨테이너가 살아있는지 확인할 수단이 필요하다. liveness probe와 readiness probe인데, 가장 간단한 방법은 HTTP 엔드포인트를 하나 만들어주는 거다.

// src/app/api/health/route.ts
import { NextResponse } from next/server

export async function GET() {
  return NextResponse.json({
    status: ok,
    timestamp: new Date().toISOString(),
  })
}

Next.js App Router에서는 src/app/api/health/route.ts 파일 하나면 /api/health 엔드포인트가 생긴다. 200 응답만 돌려주면 probe는 만족한다.

Kubernetes 매니페스트 구성

K8s에 올리려면 여러 리소스를 정의해야 한다. 우리 프로젝트 기준으로 필요한 건 이 정도였다.

  • Namespace: 리소스를 격리할 museck 네임스페이스
  • Deployment 2개: 앱(Next.js)과 데이터베이스(MongoDB)
  • Service 2개: 각 Deployment에 대한 ClusterIP 서비스
  • PVC 2개: MongoDB 데이터와 PayloadCMS 미디어 파일용
  • IngressRoute: Traefik을 통한 외부 접근 (HTTPS)
  • ConfigMap + Secrets: 환경변수와 인증 정보 분리

앱 Deployment와 Health Probe

앱 Deployment에서 가장 신경 쓴 부분은 probe 설정이다.

containers:
  - name: app
    image: ghcr.io/museck/museck:latest
    ports:
      - containerPort: 3000
    volumeMounts:
      - name: media
        mountPath: /app/media
    resources:
      requests:
        memory: "256Mi"
        cpu: "100m"
      limits:
        memory: "1Gi"
        cpu: "1000m"
    livenessProbe:
      httpGet:
        path: /api/health
        port: 3000
      initialDelaySeconds: 30
      periodSeconds: 10
    readinessProbe:
      httpGet:
        path: /api/health
        port: 3000
      initialDelaySeconds: 5
      periodSeconds: 5

liveness probe는 30초 뒤부터 10초마다 체크한다. Next.js 앱이 처음 뜰 때 시간이 좀 걸리니까 initialDelaySeconds를 넉넉하게 잡았다. 이 값이 너무 짧으면 앱이 아직 초기화 중인데 K8s가 죽은 걸로 판단해서 계속 재시작하는 루프에 빠진다.

readiness probe는 5초부터 시작한다. 이건 트래픽을 받을 준비가 됐는지 확인하는 건데, 준비 안 됐으면 서비스에서 빠지기만 할 뿐 컨테이너를 죽이진 않는다. liveness보다 공격적으로 설정해도 괜찮다.

미디어 볼륨과 영속 저장

PayloadCMS로 이미지를 업로드하면 기본적으로 로컬 파일시스템에 저장된다. 컨테이너는 재시작하면 데이터가 날아가니까 PersistentVolumeClaim으로 /app/media를 마운트해야 한다.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: museck-media
  namespace: museck
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi

Dockerfile에서 VOLUME /app/media와 chown nextjs:nodejs /app/media를 해둔 것도 이 때문이다. 볼륨 마운트 포인트의 소유권이 안 맞으면 파일 쓰기가 실패한다.

삽질 기록과 배운 점

이번 작업 자체는 비교적 깔끔하게 끝났다. Dockerfile 짜고 K8s 매니페스트 쓰는 건 패턴이 정해져 있으니까. 하지만 이게 끝이 아니었다.

실제로 docker build를 돌리니 온갖 에러가 쏟아졌다. PayloadCMS가 빌드 타임에 DB 연결을 시도한다든가, public/ 디렉토리가 없어서 COPY 명령이 실패한다든가 하는 것들이었다. 로컬에서 잘 되던 빌드가 Docker 안에서는 안 되는 전형적인 케이스다.

이런 문제는 다음 글에서 CI/CD 파이프라인을 구축하면서 하나씩 해결해 나갈 예정이다. 이번 글에서 가져갈 건 이거다.

  • Next.js standalone 모드를 쓰면 node_modules 없이 경량 이미지를 만들 수 있다
  • 멀티스테이지 빌드는 의존성, 빌드, 런타임을 분리해서 최종 이미지를 깔끔하게 유지한다
  • Health check 엔드포인트는 K8s probe 연동에 필수고, 만들기도 쉽다
  • CMS처럼 외부 의존성이 있는 앱은 빌드 타임 환경에 신경 써야 한다. 로컬에서 되는 게 Docker에서도 된다는 보장은 없다

자주 묻는 질문

Next.js standalone 모드가 Docker 배포에 유리한 이유는?
standalone 출력은 node_modules 없이 실행에 필요한 파일만 포함하므로 Docker 이미지 크기가 대폭 줄어듭니다. 멀티스테이지 빌드와 결합하면 프로덕션 이미지를 수백 MB에서 수십 MB로 경량화할 수 있습니다.
Next.js Docker 멀티스테이지 빌드는 몇 단계로 구성하나요?
deps(의존성 설치), builder(next build), runner(standalone 실행) 3단계로 나눕니다. 각 단계가 필요한 파일만 다음 단계로 복사하므로 최종 이미지에는 빌드 도구나 소스 코드가 포함되지 않습니다.
K8s에 Next.js를 배포할 때 Health Check는 어떻게 설정하나요?
Deployment의 livenessProbe와 readinessProbe에 헬스 체크 엔드포인트를 지정합니다. standalone 모드에서는 server.js가 직접 HTTP를 서빙하므로 httpGet 프로브로 간단히 확인할 수 있습니다.
museck 만들기(2/10)
Prev

PayloadCMS + Next.js 15로 회사 홈페이지 만들기

Next

Gitea Actions + DinD로 셀프 호스팅 CI/CD 삽질기