[Next.js] Next.js 프로젝트를 위한 Docker 배포 가이드

Docker

Docker는 애플리케이션을 컨테이너라는 격리된 환경에 이미지로 패키징하여, 어떤 리눅스 환경에서든 동일하게 실행할 수 있도록 해주는 기술이다.

Docker 이미지 안에 어떤 운영체제를 사용하고, 어떤 런타임 환경을 사용하며 그 버전은 어떠한지 등을 담아두어 어디에서 실행하든 동일하게 코드가 동작할 수 있도록 한다.

 

Docker의 핵심 구성 요소

Docker로 앱을 배포하기 위해선 세 가지 요소를 알아야한다.

  1. Dockerfile
    이미지를 만들기 위한 설정 파일이다. 어떤 환경을 사용할지, 어떤 명령어를 실행할지 등을 텍스트의 형태로 담아둔 파일이다.

  2. Docker Image
    Dockerfile을 빌드한 결과물이다. 앱 실행에 필요한 모든 것이 담긴 패키지로, 이 이미지를 이용하면 동일한 환경에서 코드를 실행할 수 있다.

  3. Container
    이미지를 실행한 상태를 컨테이너라고 한다. EC2와 같은 서버에서 Docker엔진을 설치하고, 이 엔진 위에서 이미지를 실행하면 컨테이너가 생성된다.

 

코드로 보는 Docker

Dockerfile의 예시를 보며 어떻게 동작하는지 살펴보자.

(이해를 돕기 위해 각 코드별로 주석을 통해 설명하겠다.)

 

전체 Dockerfile

FROM node:20-alpine AS base

RUN corepack enable && corepack prepare pnpm@10.9.0 --activate

WORKDIR /app

FROM base AS deps

COPY package.json pnpm-lock.yaml ./

RUN pnpm install --frozen-lockfile --prod=false

FROM base AS builder

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production

RUN pnpm run build

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

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

USER nextjs

EXPOSE 3000

ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

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

 

Stage1. 기반 환경 설치

FROM node:20-alpine AS base

=> FROM = "node버전 20의 alpine 배포판의 이미지를 기반으로 시작"
=> AS = "이 단계를 'base'라는 이름으로 설정"

RUN corepack enable && corepack prepare pnpm@10.9.0 --activate

=> RUN = "명령어 실행"
=> corepack(패키지 매니저)를 활성화하고, pnpm 10.9.0 버전을 설치 및 활성화

WORKDIR /app

=> WORKDIR = "작업할 디렉토리 설정 (ex. /cd)"
=> 컨테이너 안에서 /app 폴더를 기본 위치로 사용

 

Stage2. 의존성 설치

FROM base AS deps

=> 이 단계를 'deps'라는 이름으로 설정

COPY package.json pnpm-lock.yaml ./

=> COPY = (로컬 파일) -> (컨테이너 위치)
=> package.json과 pnpm-lock.yaml 파일을 './' 하위에 복사

RUN pnpm install --frozen-lockfile --prod=false

=> RUN = package.json에 명시된 라이브러리 설치
=> --frozen-lockfile = 의도치 않은 버전 설치 방지
=> --prod=false = devDependencies 설치

 

Q. 의존성 설치 스테이지가 있는 이유는 무엇일까?

A. Docker는 레이어 캐싱을 사용하는데, 파일이 변경되지 않으면 해당 레이어를 캐시에서 가져와서 사용하는 것을 의미한다. 만약 모든 파일을 한 번에 복사한다면, 코드의 일부만 수정되어도 RUN pnpm install 작업이 다시 실행될 수 밖에 없다. 이걸 방지하기 위해 의존성을 설치하는 스테이지를 만들어 package.json만 따로 카피하는 과정을 가진다. 여기서는 package.json에 변경사항이 없으면 캐시된 파일을 그대로 사용할 수 있다.

 

Stage3. 빌드

FROM base AS builder

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules

=> COPY --from=deps = 'deps' 단계에서 'node_modules'을 복사

COPY . .

=> 프로젝트 모든 파일 복사
=> 캐싱이 되는 점을 이용해 의존성이 설치된 폴더만 가져오는 의미

ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production

RUN pnpm run build

=> package.json의 'build' 스크립트 실행

 

Stage4. 실행 환경

FROM base AS runner

=> base에서 시작하는 이유: 
=> 실행에 필요없는 파일(빌드된 파일, 정적 파일 외)을 제외하기 위해

WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

=> 그룹/사용자 추가 + 그룹/사용자 ID + 그룹/사용자 이름
=> root 권한이 아닌 일반 사용자 권한으로 변경하기 위해 생성

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

=> builder 단계에서 필요한 파일(정적 파일, Next.js 서버, 의존성, 빌드된 파일) 복사
=> --chown=nextjs:nodejs = 복사한 파일의 소유자를 root에서 next.js(일반 사용자)로 넘겨줌

USER nextjs

=> 사용자 전황(root -> nextjs)

ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

=> 3000번 포트에서 실행 & 모든 네트워크 접근 허용

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

=> 컨테이너가 시작되면 '$ node server.js' 명령어 실행

 

Q. 이렇게 여러 스테이지로 나눠서 하는 이유?

A. 여러 스테이지로 나누어서 하는 방식을 멀티스테이지라고 하는데, 최종적으로 생성되는 이미지의 용량을 최소화하기 위해 사용한다. 빌드에 필요한 의존성, 소스 코드 전체 등 최종적으로 실행에는 불필요한 파일을 제거함으로써 용량을 최소화할 수 있고, 부가적으로 소스코드가 노출을 막는 보안적인 부가이득을 볼 수 있다.

 

Docker Compose

Dockerfile을 이용해 이미지를 만들었다면, 이제 이 이미지를 실행해서 컨테이너로 만들어야 한다.

이때 사용되는 것이 Docker Compose이다.

컨테이너를 실행할 때는 포트 매핑, 환경변수, 네트워크 연결등의 다양한 설정이 필요한데, 이를 명령어로 해결하려면 복잡해진다.

docker run -d \
  --name my-app \
  -p 3000:3000 \
  -e NODE_ENV=production \
  -e NEXT_PUBLIC_API_URL=https://api.myapi.com \
  --network my-network \
  my-app:prod

 

Docker Compose는 이런 설정들을 YAML파일로 선언적으로 관리할 수 있게 해준다.

 

docker-compose.yml

services:
  # Next.js 앱 컨테이너
  app:
    image: my-app:prod
    container_name: my-app
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
    networks:
      - my-network
    healthcheck:
      test: ["CMD", "wget", "--spider", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  # Nginx 컨테이너
  nginx:
    image: nginx:alpine
    container_name: my-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    networks:
      - my-network
    depends_on:
      - app
    restart: unless-stopped

networks:
  my-network:
    driver: bridge

 

 

주요 설정

image: my-app:prod

사용할 Docker 이미지를 지정한다.

 

container_name: my-app

생성될 컨테이너의 이름이다. 다른 컨테이너에서 이 이름으로 접근할 수 있다.

 

ports:
- "3000:3000"

포트 매핑 설정이다. 외부에서 호스트의 3000번 포트로 접속하면 컨테이너 내부의 3000번 포트로 전달된다.

해당 포트 매핑을 이용해서 여러 개의 컨테이너를 만들고 외부에서 전달받는 포트의 번호를 다르게 설정해서 관리할 수 있다.

 

environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}

컨테이너 내부에서 사용할 환경변수 설정이다. 로컬의 .env 파일과 같다.

 

networks:
- my-network

컨테이너가 연결될 네트워크를 지정한다. 같은 네트워크 안에 있어서 컨테이너 이름으로 서로 연결될 수 있다.