테마 전환

Vercel을 떠나 Next.js를 Docker로 셀프 호스팅하는 방법

Easton editorial illustration: performance inspection lens

지난달 말, 평소처럼 Vercel 청구 페이지를 열었습니다. $47.32였습니다.

머릿속으로 빠르게 계산해 봤습니다. 이번 달 블로그 트래픽은 20% 정도밖에 늘지 않았는데 왜 청구액은 두 배가 됐을까? 상세 내역을 열어 보니 serverless function 호출 횟수가 문제였습니다. 캐시를 제대로 적용하지 않은 API 라우트 하나가 페이지를 새로 고칠 때마다 세 번씩 실행되고 있었습니다.

Vercel의 개발 경험은 분명 매끄럽습니다. git push만 하면 자동으로 배포되고, 글로벌 엣지 네트워크로 가속되며, 바로 쓸 수 있는 기능도 많습니다. 하지만 프로젝트 트래픽이 조금만 늘어도 청구액이 로켓처럼 치솟습니다. $20짜리 Pro 요금제는 시작일 뿐이고, 실제 비용을 키우는 것은 사용량 기반 과금 항목입니다.

그 순간 깨달았습니다. 이 프로젝트를 옮길 때가 됐습니다.

이 글에는 Next.js 프로젝트를 Vercel에서 Docker 셀프 호스팅으로 이전한 전체 과정을 기록했습니다. 직접 겪은 문제, 찾아본 문서, 시험한 설정을 한곳에 정리했습니다. 셀프 호스팅을 고민 중이거나 이미 시도했지만 정적 리소스 404, 스트리밍 렌더링 미작동 같은 이상한 문제를 만났다면 도움이 되기를 바랍니다.

$35-50
Vercel 월 비용
트래픽에 따라 변동
$12
셀프 호스팅 월 비용
고정 비용
$300-500
연간 절감 비용
여러 프로젝트 실행 가능
200MB
Docker 이미지 용량
3단계 빌드 최적화 후
Source: 실제 운영 데이터

Vercel을 떠난 이유

먼저 분명히 해두겠습니다. Vercel을 깎아내리려는 것이 아닙니다. 기업 프로젝트, 글로벌 엣지 네트워크가 필요한 경우, 팀에 운영 역량이 없는 경우처럼 많은 상황에서 여전히 최선의 선택입니다. 다만 개인 프로젝트와 소규모 팀에는 비용이 확실한 부담입니다.

Vercel의 과금 방식

무료 요금제는 100GB 대역폭과 100만 회의 Edge Requests를 제공해 꽤 넉넉해 보입니다. 문제는 트래픽이 조금만 있는 프로젝트라면 이 한도를 쉽게 넘는다는 것입니다. Pro($20/month)로 업그레이드해도 이것은 입장권에 불과합니다.

  • Serverless Function 호출: 100만 회를 넘으면 사용량에 따라 과금
  • 엣지 함수 실행 시간: 100만 GB-s를 넘으면 추가 과금
  • 이미지 최적화: 5,000회를 넘으면 건별 과금
  • 대역폭: 1TB를 넘으면 GB당 과금

가장 까다로운 점은 사용량을 미리 예측하기 어렵다는 것입니다. 캐시가 제대로 적용되지 않은 API 라우트 하나, 크롤러가 집중적으로 방문하는 페이지 하나만 있어도 청구액이 급증합니다.

셀프 호스팅으로 얼마나 절약할 수 있나?

직접 계산해 봤습니다. 제 프로젝트는 Vercel에서 트래픽에 따라 월 $35~50가 들었습니다. DigitalOcean의 $12/month 서버로 옮긴 뒤 비용은 다음과 같았습니다.

  • 서버: $12/month(2코어 4GB, Next.js 애플리케이션 2~3개를 실행하기에 충분)
  • Cloudflare CDN: 무료(이미 사용 중)
  • 추가 스토리지: $0(로컬 디스크로 충분)

월 $2540, 연간 $300500를 절약할 수 있습니다. 더 중요한 점은 비용이 고정되어 있어 트래픽이 갑자기 늘어도 폭등하지 않는다는 것입니다.

셀프 호스팅이 적합한 경우

모두가 셀프 호스팅을 해야 하는 것은 아닙니다. 다음 조건에 해당한다면 고려할 만합니다.

  • ✅ Linux/Docker 기초가 어느 정도 있음
  • ✅ 프로젝트 트래픽이 비교적 안정적이고 글로벌 엣지 네트워크가 필요하지 않음
  • ✅ 5~10분 정도의 수동 배포 과정을 감수할 수 있음
  • ✅ 예산에 민감함(개인 프로젝트, 초기 스타트업)

반대로 다음에 해당한다면 Vercel을 계속 쓰는 편이 낫습니다.

  • ❌ 팀에 운영 역량이 없고 배울 계획도 없음
  • ❌ 트래픽 변동이 매우 커 자동 확장·축소가 필요함
  • ❌ Vercel의 Analytics, Edge Config 같은 전용 기능이 필요함
  • ❌ 예산이 충분하고 개발 효율이 더 중요함

충분히 따져본 뒤 결정해야 합니다. 비용을 아끼려다가 운영 부담만 키울 필요는 없습니다.

Next.js Docker 배포의 핵심 설정

이제 본론으로 들어가겠습니다. Next.js를 Docker로 배포할 때 핵심 설정은 세 가지입니다. 이 세 가지를 제대로 처리하면 큰 문제는 대부분 피할 수 있습니다.

1. Standalone 출력 모드

가장 중요한 단계입니다. 기본적으로 next build는 전체 node_modules를 포함해 많은 파일을 생성합니다. 그대로 Docker에 넣으면 이미지가 매우 커지고 시작도 느려집니다.

next.config.js에 다음 한 줄을 추가합니다.

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
}

module.exports = nextConfig

npm run build를 실행하면 .next/standalone 디렉터리가 생깁니다. 이 디렉터리에는 다음 항목이 들어 있습니다.

  • server.js: 시작 스크립트
  • 간소화된 node_modules: 런타임에 필요한 패키지만 포함
  • 애플리케이션 코드

핵심 사항: standalone 모드는 public.next/static을 자동으로 복사하지 않습니다. 두 디렉터리를 standalone 디렉터리에 직접 복사하지 않으면 모든 정적 리소스에서 404가 발생합니다. 저는 이 문제의 원인을 찾는 데 이틀이 걸렸습니다.

2. 다단계 Dockerfile

제가 사용하는 Dockerfile을 바로 공유하겠습니다. 주석에 각 단계가 적혀 있습니다.

# ============ 1단계: 의존성 설치 ============
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

# 의존성 명세만 복사해 Docker 캐시 활용
COPY package.json package-lock.json ./
RUN npm ci

# ============ 2단계: 애플리케이션 빌드 ============
FROM node:20-alpine AS builder
WORKDIR /app

# 의존성과 소스 코드 복사
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# 빌드 시점 환경 변수(필요한 경우)
ENV NEXT_TELEMETRY_DISABLED=1

# 빌드
RUN npm run build

# ============ 3단계: 프로덕션 실행 ============
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# root가 아닌 사용자 생성(보안 권장 사항)
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# public 폴더 복사(정적 리소스)
COPY --from=builder /app/public ./public

# standalone 출력 복사
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# static 파일 복사(CSS/JS 등 빌드 결과물)
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"]

핵심 설명:

  1. 3단계 빌드: 의존성 설치, 빌드, 실행을 분리합니다. 최종 이미지에는 런타임에 필요한 파일만 남아 용량을 1.5GB에서 200MB까지 줄일 수 있습니다.
  2. COPY --from=builder /app/public: 이 단계를 빼먹으면 favicon과 robots.txt에 접근할 수 없습니다.
  3. COPY ./.next/static: 더 중요합니다. 이 파일이 없으면 모든 JS/CSS에서 404가 발생합니다.
  4. root가 아닌 사용자: 프로덕션 환경에서는 root로 애플리케이션을 실행하지 않는 것이 안전합니다.

3. 환경 변수의 함정

이 문제도 직접 겪었습니다. Next.js 환경 변수는 두 종류로 나뉩니다.

  • 빌드 시점 변수: NEXT_PUBLIC_으로 시작하며 코드에 컴파일됩니다.
  • 런타임 변수: 데이터베이스 주소처럼 서버 측에서 사용합니다.

Standalone 모드에서는 runtimeConfig작동하지 않습니다. 공식 문서는 App Router 방식을 권장합니다.

// app/api/example/route.ts
export async function GET() {
  // process.env에서 직접 읽기
  const dbUrl = process.env.DATABASE_URL
  // ...
}

Docker 실행 시 환경 변수를 전달합니다.

docker run -p 3000:3000 \
  -e DATABASE_URL="postgres://..." \
  -e API_KEY="xxx" \
  your-image-name

또는 docker-compose.yml을 사용합니다.

version: '3.8'
services:
  nextjs:
    image: your-image-name
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: "postgres://..."
      API_KEY: "xxx"
    restart: unless-stopped

주의: NEXT_PUBLIC_으로 시작하는 변수는 빌드할 때 설정해야 하며 런타임에는 바꿀 수 없습니다. 런타임 동적 설정이 필요하다면 서버 측 환경 변수를 사용해야 합니다.

리버스 프록시 설정의 핵심

Next.js 컨테이너를 인터넷에 직접 노출할 수도 있지만 그렇게 해서는 안 됩니다. 보호되지 않은 Node.js 애플리케이션은 악성 요청과 저속 공격을 오래 견디기 어렵습니다. 리버스 프록시는 선택이 아니라 필수입니다.

리버스 프록시가 필요한 이유

  1. 보안 보호: 악성 요청 차단, 요청 제한, DDoS 방어
  2. HTTPS 지원: SSL 인증서를 한곳에서 관리
  3. 여러 애플리케이션 배포: 한 서버에서 여러 프로젝트를 실행하고 도메인이나 경로로 구분
  4. 정적 리소스 캐시: 애플리케이션 서버 부하 완화

저는 안정적이고 신뢰할 수 있는 Nginx를 사용합니다. 더 간단한 설정을 원한다면 자동 HTTPS를 제공하고 설정 파일도 읽기 쉬운 Caddy가 좋은 선택입니다.

Nginx 설정 예시

server {
    listen 80;
    server_name yourdomain.com;
    
    # HTTPS로 강제 이동(SSL을 설정한 경우)
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com;
    
    # SSL 인증서 설정(Let's Encrypt)
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    
    # Next.js 컨테이너로 리버스 프록시
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        
        # 필수 요청 헤더
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # 핵심: 스트리밍 렌더링을 위해 버퍼링 해제
        proxy_buffering off;
        proxy_cache off;
        proxy_set_header X-Accel-Buffering no;
        
        # WebSocket 지원(필요한 경우)
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
    
    # 정적 리소스 캐시(선택 사항이지만 권장)
    location /_next/static/ {
        proxy_pass http://localhost:3000;
        proxy_cache_valid 200 60m;
        add_header Cache-Control "public, max-age=3600, immutable";
    }
}

세 가지 핵심 설정:

  1. proxy_buffering off: 버퍼링을 끕니다. 그렇지 않으면 스트리밍 출력이 멈춥니다.
  2. X-Accel-Buffering: no: Nginx에 응답 본문을 버퍼링하지 않도록 명시합니다.
  3. WebSocket 지원: Socket.io나 실시간 기능을 사용한다면 Upgrade 헤더를 추가해야 합니다.

Caddy 간단 설정

Nginx 설정이 너무 복잡하게 느껴진다면 Caddy를 사용해 보세요.

yourdomain.com {
    reverse_proxy localhost:3000 {
        # Caddy는 기본적으로 버퍼링하지 않아 별도 설정이 필요 없음
    }
}

이것이 전부입니다. Caddy는 Let’s Encrypt 인증서를 자동으로 발급하고 갱신하므로 설정 파일도 이처럼 간단합니다.

Docker Compose 통합

Nginx도 컨테이너로 실행하면 관리하기 더 편합니다.

version: '3.8'
services:
  nextjs:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: "postgres://..."
    # 호스트에는 노출하지 않고 nginx에서만 접근
    expose:
      - "3000"
    networks:
      - app-network

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - ./certs:/etc/letsencrypt
    depends_on:
      - nextjs
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

nextjs 서비스가 ports 대신 expose를 사용한다는 점에 주목하세요. 이렇게 하면 같은 네트워크의 컨테이너만 접근할 수 있어 더 안전합니다.

스트리밍 렌더링이 작동하지 않을 때

이 문제 때문에 꼬박 하루를 썼습니다. 로컬 개발 환경에서는 정상적으로 작동하던 AI 채팅 기능이 Docker에 배포하자 스트리밍 출력을 전혀 하지 못했습니다. 한참 기다린 뒤 한꺼번에 표시되거나 아예 멈췄습니다.

증상

대표적인 증상은 다음과 같습니다.

  • OpenAI/Anthropic API의 스트리밍 응답이 작동하지 않음
  • Server-Sent Events(SSE)가 실시간으로 전송되지 않음
  • 페이지가 오랫동안 기다린 뒤 갑자기 갱신되고 글자가 순차적으로 표시되지 않음

로컬의 npm run dev에서는 완전히 정상인데 프로덕션 환경에서만 문제가 발생합니다.

근본 원인

이 문제를 일으키는 지점은 두 곳입니다.

  1. 리버스 프록시 버퍼링: Nginx는 기본적으로 응답 본문을 버퍼링하고 전체 내용이 도착한 뒤 클라이언트에 전송합니다.
  2. Next.js Runtime: Edge Runtime이 아닌 API 라우트는 일부 상황에서 스트리밍 출력을 지원하지 않습니다.

해결 방법 1: Nginx 설정

앞의 리버스 프록시 절에서 설명한 세 줄을 다시 강조하겠습니다.

proxy_buffering off;
proxy_cache off;
proxy_set_header X-Accel-Buffering no;

세 줄 모두 location / 설정 블록에 추가해야 합니다. 수정한 뒤 Nginx를 다시 시작합니다.

nginx -t  # 설정 문법 테스트
nginx -s reload  # 설정 다시 불러오기

해결 방법 2: Edge Runtime 사용

API 라우트가 AI 채팅처럼 스트리밍 출력에 사용된다면 파일 맨 위에 다음 한 줄을 추가합니다.

// app/api/chat/route.ts
export const runtime = 'edge'

export async function POST(req: Request) {
  const stream = new ReadableStream({
    async start(controller) {
      // 스트리밍 처리 로직
      const response = await openai.chat.completions.create({
        model: 'gpt-4',
        messages: [...],
        stream: true,
      })

      for await (const chunk of response) {
        controller.enqueue(chunk.choices[0]?.delta?.content || '')
      }
      
      controller.close()
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  })
}

Edge Runtime은 가벼운 런타임이며 스트리밍 응답에 맞게 최적화되어 있어 Docker 환경에서 더 안정적으로 작동합니다.

수정 여부 확인

curl로 테스트해 줄 단위로 출력되면 문제가 해결된 것입니다.

curl -N http://yourdomain.com/api/chat \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"message": "Hello"}'

-N 옵션은 버퍼링을 끕니다. 한참 기다렸다가 전부 표시되는 대신 내용이 조금씩 출력되어야 합니다.

그래도 안 된다면 확인할 항목

  1. Cloudflare 프록시: CF의 주황색 구름을 사용하면 이곳에서도 응답을 버퍼링합니다. 프록시를 끄고 회색 구름으로 바꾸거나 Streaming을 지원하는 Pro 요금제로 업그레이드합니다.
  2. Docker 헬스 체크: 일부 헬스 체크 설정이 스트리밍 연결을 방해할 수 있으므로 docker-compose.ymlhealthcheck 설정을 확인합니다.
  3. 로드 밸런서: 앞단에 Load Balancer가 있다면 이 장치도 응답을 버퍼링할 수 있으므로 별도로 설정해야 합니다.

자주 발생하는 문제 진단과 해결

제가 직접 겪었거나 커뮤니티에서 자주 나온 문제를 정리했습니다. 배포 실패 사례의 약 80%를 차지합니다.

문제 1: 정적 리소스 404

증상: 페이지는 열리지만 스타일이 모두 깨지고 콘솔에 /_next/static/... 경로의 404 오류가 쏟아집니다.

원인: Dockerfile에서 .next/static 폴더를 올바르게 복사하지 않았습니다.

해결: Dockerfile에 다음 두 줄이 있는지 확인합니다.

COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

이미 있는데도 404가 발생한다면 파일 권한을 확인합니다.

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

문제 2: Docker 빌드 실패

증상: docker build 도중 “Could not find a production build in the ‘.next’ directory” 오류가 발생합니다.

원인: .dockerignore 설정이 잘못되었거나 빌드 순서에 문제가 있습니다.

해결: .dockerignore 파일을 만들고 필요 없는 디렉터리를 제외합니다.

.next
node_modules
.git
.env*.local
out
.DS_Store
*.log

.next는 Docker 컨테이너 안에서 다시 빌드하므로 제외해야 합니다.

문제 3: 환경 변수 미적용

증상: 코드에서 process.env.DATABASE_URL을 읽으면 undefined가 반환됩니다.

원인: 환경 변수 전달 방법이 잘못되었거나 빌드 시점 변수와 런타임 변수를 혼동했습니다.

해결:

  1. 데이터베이스 주소, API 키 같은 런타임 변수docker run -e 또는 docker-compose.yml로 전달합니다.

    docker run -e DATABASE_URL="..." your-image
  2. NEXT_PUBLIC_으로 시작하는 빌드 시점 변수docker build를 실행할 때 전달해야 합니다.

    docker build --build-arg NEXT_PUBLIC_API_URL="https://api.example.com" .

    Dockerfile에도 선언합니다.

    ARG NEXT_PUBLIC_API_URL
    ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL

문제 4: 메모리 부족으로 빌드 실패

증상: 빌드가 도중에 멈추거나 “JavaScript heap out of memory” 오류가 발생합니다.

원인: Node.js 기본 메모리 한도가 부족합니다. 큰 Next.js 프로젝트는 빌드할 때 메모리를 많이 사용합니다.

해결: Dockerfile의 빌드 단계에서 메모리를 늘립니다.

# builder 단계
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN npm run build

또는 Docker BuildKit으로 리소스 한도를 지정합니다.

docker build --memory=8g --memory-swap=8g -t your-image .

문제 5: 컨테이너 시작 후 접속 불가

증상: 컨테이너는 정상적으로 실행되지만 http://localhost:3000에 접속하면 연결이 거부됩니다.

원인: Next.js가 기본적으로 127.0.0.1을 수신하므로 Docker 컨테이너 외부에서 접근할 수 없습니다.

해결: Dockerfile에 다음 설정을 추가합니다.

ENV HOSTNAME="0.0.0.0"
ENV PORT=3000

또는 실행할 때 전달합니다.

docker run -p 3000:3000 -e HOSTNAME="0.0.0.0" your-image

빠른 진단 명령

문제가 생기면 먼저 다음 명령으로 진단합니다.

# 1. 컨테이너 실행 여부 확인
docker ps

# 2. 컨테이너 로그 확인
docker logs <container-id>

# 3. 컨테이너에 들어가 파일 구조 확인
docker exec -it <container-id> sh
ls -la .next/
ls -la public/

# 4. 컨테이너 내부 서비스 정상 여부 테스트
docker exec -it <container-id> wget -O- http://localhost:3000

# 5. 포트 매핑 확인
docker port <container-id>

결론

Vercel에서 Docker 셀프 호스팅으로 옮기는 일은 생각보다 무섭지 않았습니다. 초기 설정에는 시간이 좀 들지만 한 번 제대로 작동하면 이후 유지 관리 부담은 낮습니다. 지금은 월 $12의 고정 서버 비용으로 Next.js 프로젝트 세 개를 실행하고 있으며 청구액이 폭등할 걱정도 없습니다.

핵심 설정 세 가지를 다시 정리하겠습니다.

  1. Standalone 모드 - next.config.js에 한 줄을 추가하고 public.next/static을 직접 복사합니다.
  2. 다단계 Dockerfile - 3단계로 빌드하면 최종 이미지는 약 200MB이고 시작도 빠릅니다.
  3. 리버스 프록시 - Nginx에서 버퍼링을 반드시 꺼야 합니다(proxy_buffering off). 그렇지 않으면 스트리밍 렌더링이 작동하지 않습니다.

스트리밍 렌더링 문제로 막혔다면 99%는 리버스 프록시 버퍼링이 원인입니다. export const runtime = 'edge'를 추가하면 대부분 해결됩니다.

Vercel과 셀프 호스팅 비교

항목VercelDocker 셀프 호스팅
배포 속도⚡️ git push 즉시 배포🐢 5~10분 수동 작업
개발 경험🌟 미리보기 환경, 로그, Analytics🔧 모니터링을 직접 설정해야 함
비용💸 월 $20 이상, 트래픽이 늘면 더 비쌈💰 월 $12 고정(여러 프로젝트 실행 가능)
확장성📈 자동 확장·축소📊 리소스를 직접 조정
제어권⚠️ 플랫폼 규칙의 제약을 받음✅ 완전한 제어
적합한 상황기업 프로젝트, 글로벌 서비스개인 프로젝트, 소규모 팀, 제한된 예산

마지막 조언:

  • 여러 side project를 운영하는 개인 개발자라면 셀프 호스팅으로 상당한 비용을 절약할 수 있습니다.
  • 팀에 운영 역량이 없거나 프로젝트 트래픽 변동이 크다면 Vercel을 사용하는 편이 낫습니다.
  • 기술 선택에는 절대적인 정답이 없으며 상황에 맞는지가 중요합니다.

전체 설정 파일과 자세한 내용은 GitHub 저장소에 올려 두었습니다(저장소 자리 표시자이므로 실제 사용 시 교체해야 합니다). 궁금한 점은 댓글로 남겨 주세요. 제가 겪은 시행착오를 다음 사람이 반복하지 않았으면 합니다.

Next.js를 Docker로 셀프 호스팅하는 전체 배포 과정

standalone 모드 설정부터 실제 배포까지 리버스 프록시와 스트리밍 렌더링 문제 해결을 포함한 전체 절차입니다.

⏱️ Estimated time: 2 hr

  1. 1

    Step 1: Standalone 출력 모드 설정

    next.config.js에서 standalone 모드를 활성화합니다.

    1. next.config.js 파일을 엽니다.
    2. output: 'standalone' 설정을 추가합니다.
    3. npm run build를 실행합니다.
    4. .next/standalone 디렉터리가 생성되었는지 확인합니다.

    핵심 사항:
    • standalone 모드는 public과 .next/static을 자동으로 복사하지 않습니다.
    • 두 디렉터리를 Dockerfile에서 직접 복사해야 합니다.
    • 그렇지 않으면 모든 정적 리소스에서 404가 발생합니다.

    설정 예시:
    ```javascript
    const nextConfig = {
    output: 'standalone',
    }
    module.exports = nextConfig
    ```
  2. 2

    Step 2: 다단계 Dockerfile 작성

    3단계 빌드 Dockerfile을 작성합니다.

    1단계 - 의존성 설치:
    • node:20-alpine을 베이스 이미지로 사용합니다.
    • package.json과 package-lock.json만 복사합니다.
    • npm ci로 의존성을 설치해 Docker 캐시를 활용합니다.

    2단계 - 애플리케이션 빌드:
    • 1단계에서 node_modules를 복사합니다.
    • 모든 소스 코드를 복사합니다.
    • npm run build로 애플리케이션을 빌드합니다.

    3단계 - 프로덕션 실행:
    • 보안을 위해 root가 아닌 사용자를 생성합니다.
    • 정적 리소스인 public 폴더를 복사합니다.
    • .next/standalone 출력을 복사합니다.
    • CSS/JS 빌드 결과물인 .next/static 파일을 복사합니다.
    • HOSTNAME="0.0.0.0"과 PORT=3000을 설정합니다.
    • node server.js 명령으로 시작합니다.

    핵심 사항:
    • 3단계 빌드로 이미지 용량을 1.5GB에서 200MB로 줄일 수 있습니다.
    • public과 .next/static을 복사하지 않으면 정적 리소스에서 404가 발생합니다.
    • 보안을 높이기 위해 root가 아닌 사용자로 실행합니다.
  3. 3

    Step 3: Nginx 리버스 프록시 설정

    Nginx 리버스 프록시를 설정하고 버퍼링을 끕니다.

    1. Nginx를 설치합니다. Caddy를 사용해도 됩니다.
    2. Let's Encrypt로 SSL 인증서를 설정합니다.
    3. Nginx 설정 파일을 만듭니다.

    반드시 추가할 핵심 설정:
    • proxy_buffering off; (버퍼링 해제)
    • proxy_cache off; (캐시 해제)
    • proxy_set_header X-Accel-Buffering no; (Nginx에 버퍼링하지 않도록 명시)

    필수 요청 헤더:
    • proxy_set_header Host $host;
    • proxy_set_header X-Real-IP $remote_addr;
    • proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    • proxy_set_header X-Forwarded-Proto $scheme;

    필요한 경우 WebSocket 지원:
    • proxy_set_header Upgrade $http_upgrade;
    • proxy_set_header Connection "upgrade";

    설정 테스트:
    ```bash
    nginx -t # 문법 테스트
    nginx -s reload # 설정 다시 불러오기
    ```

    주의: 버퍼링을 끄지 않으면 스트리밍 렌더링이 작동하지 않습니다.
  4. 4

    Step 4: 환경 변수 처리

    빌드 시점 환경 변수와 런타임 환경 변수를 구분합니다.

    빌드 시점 변수(NEXT_PUBLIC_으로 시작):
    • docker build를 실행할 때 전달해야 합니다.
    • --build-arg 옵션을 사용합니다.
    • Dockerfile에서 ARG NEXT_PUBLIC_API_URL을 선언합니다.
    • ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL로 환경 변수를 설정합니다.

    런타임 변수(서버 측에서 사용):
    • docker run -e로 전달합니다.
    • 또는 docker-compose.yml에 설정합니다.
    • 코드에서는 process.env에서 직접 읽습니다.

    Standalone 모드:
    • runtimeConfig는 작동하지 않습니다.
    • App Router 방식으로 환경 변수를 읽어야 합니다.
    • 서버 코드: const dbUrl = process.env.DATABASE_URL

    예시:
    ```bash
    # 빌드 시점
    docker build --build-arg NEXT_PUBLIC_API_URL="https://api.example.com" .

    # 런타임
    docker run -e DATABASE_URL="postgres://..." your-image
    ```
  5. 5

    Step 5: 스트리밍 렌더링 문제 해결

    AI 채팅, SSE 등에서 스트리밍 렌더링이 작동하지 않는 문제를 해결합니다.

    증상:
    • 스트리밍 출력이 작동하지 않고 한참 기다린 뒤 한꺼번에 표시됩니다.
    • Server-Sent Events가 실시간으로 전송되지 않습니다.

    해결 방법 1 - Nginx 설정(필수):
    • proxy_buffering off가 추가되어 있는지 확인합니다.
    • X-Accel-Buffering: no가 추가되어 있는지 확인합니다.
    • Nginx 서비스를 다시 시작합니다.

    해결 방법 2 - Edge Runtime 사용:
    • API 라우트 파일 맨 위에 export const runtime = 'edge'를 추가합니다.
    • Edge Runtime은 스트리밍 응답에 맞게 최적화되어 있습니다.
    • Docker 환경에서 더 안정적으로 작동합니다.

    수정 결과 확인:
    ```bash
    curl -N http://yourdomain.com/api/chat \
    -X POST \
    -H "Content-Type: application/json" \
    -d '{"message": "Hello"}'
    ```
    -N 옵션으로 버퍼링을 끄면 줄 단위로 출력되는 것을 확인할 수 있어야 합니다.

    추가 확인 사항:
    • Cloudflare 프록시: 주황색 구름을 사용하면 이곳에서도 버퍼링됩니다. 끄거나 Pro로 업그레이드해야 합니다.
    • Docker 헬스 체크: 스트리밍 연결을 방해할 수 있습니다.
    • 로드 밸런서: 앞단에 LB가 있다면 별도로 설정해야 합니다.
  6. 6

    Step 6: 배포 및 검증

    Docker 이미지를 빌드하고 배포합니다.

    1. Docker 이미지 빌드:
    ```bash
    docker build -t nextjs-app .
    ```

    2. 컨테이너 실행:
    ```bash
    docker run -d \
    -p 3000:3000 \
    -e DATABASE_URL="postgres://..." \
    -e API_KEY="xxx" \
    --name nextjs-app \
    nextjs-app
    ```

    3. 배포 검증:
    • docker ps로 컨테이너 상태를 확인합니다.
    • docker logs nextjs-app으로 로그를 확인합니다.
    • curl http://localhost:3000으로 접속을 테스트합니다.
    • /_next/static/ 경로에 접속해 정적 리소스를 확인합니다.

    4. Nginx 설정 후 재시작:
    • 리버스 프록시 설정이 올바른지 확인합니다.
    • HTTPS 접속을 테스트합니다.
    • 스트리밍 렌더링 기능을 검증합니다.

    5. 모니터링 및 유지 관리:
    • --restart unless-stopped로 컨테이너 자동 재시작을 설정합니다.
    • 정기적으로 로그를 확인해 문제를 진단합니다.
    • 서버 리소스 사용량을 모니터링합니다.

    자주 발생하는 문제:
    • 정적 리소스 404: Dockerfile에서 public과 .next/static을 복사했는지 확인합니다.
    • 환경 변수 미적용: 빌드 시점 변수와 런타임 변수를 구분합니다.
    • 컨테이너 접속 불가: HOSTNAME이 0.0.0.0으로 설정되어 있는지 확인합니다.

FAQ

셀프 호스팅으로 얼마나 절약할 수 있으며 비용 차이는 어느 정도인가요?
Vercel 비용은 트래픽에 따라 월 $35~50이고, Docker 셀프 호스팅은 여러 프로젝트를 실행할 수 있는 서버에 월 $12가 고정적으로 듭니다. 월 $25~40, 연간 $300~500를 절약할 수 있습니다. 더 중요한 점은 셀프 호스팅 비용이 고정되어 트래픽이 갑자기 늘어도 폭등하지 않는다는 것입니다. 개인 프로젝트, 소규모 팀, 예산에 민감한 상황에 적합합니다.
정적 리소스에서 404가 발생하는 이유와 해결 방법은 무엇인가요?
standalone 모드는 public과 .next/static 디렉터리를 자동으로 복사하지 않습니다. Dockerfile의 runner 단계에 다음 두 줄을 추가합니다.
• COPY --from=builder /app/public ./public
• COPY --from=builder /app/.next/static ./.next/static
그래도 404가 발생하면 파일 권한을 확인하고 --chown=nextjs:nodejs로 올바른 소유자를 설정합니다.
스트리밍 렌더링이 작동하지 않으면 어떻게 해야 하나요?
99%는 리버스 프록시 버퍼링이 원인입니다.
1) Nginx 설정에 proxy_buffering off;와 proxy_set_header X-Accel-Buffering no;를 반드시 추가합니다.
2) API 라우트에 Edge Runtime을 사용합니다: export const runtime = 'edge'
3) Cloudflare 프록시를 사용한다면 주황색 구름을 끄거나 Pro 요금제로 업그레이드합니다.
4) curl -N으로 테스트해 줄 단위로 출력되는지 확인합니다.
환경 변수가 적용되지 않으면 어떻게 해야 하나요?
빌드 시점 변수와 런타임 변수를 구분합니다.
• NEXT_PUBLIC_으로 시작하는 변수는 docker build에서 --build-arg로 전달하고 Dockerfile에 ARG와 ENV를 선언해야 합니다.
• 런타임 변수는 docker run -e 또는 docker-compose.yml로 전달하고 코드에서 process.env로 읽습니다.
• Standalone 모드에서는 runtimeConfig가 작동하지 않으므로 App Router 방식으로 환경 변수를 읽어야 합니다.
Docker 이미지가 너무 크면 어떻게 줄일 수 있나요?
다단계 빌드를 사용해 최적화합니다.
• 1단계: Docker 캐시를 활용해 의존성만 설치합니다.
• 2단계: 애플리케이션을 빌드합니다.
• 3단계: standalone 출력, public, static 등 런타임에 필요한 파일만 복사합니다.
• 최종 이미지 용량을 1.5GB에서 200MB로 줄일 수 있습니다.
• node:20-alpine 베이스 이미지를 사용하면 용량을 더 줄일 수 있습니다.
언제 셀프 호스팅을 선택하고 언제 Vercel을 선택해야 하나요?
셀프 호스팅은 Linux/Docker 기초가 있고, 트래픽이 안정적이며, 예산에 민감하고, 수동 배포를 감수할 수 있는 개인 프로젝트나 소규모 팀에 적합합니다.
Vercel은 팀에 운영 역량이 없고, 트래픽 변동이 커 자동 확장·축소가 필요하며, 글로벌 엣지 네트워크나 Vercel 전용 기능(Analytics, Edge Config)이 필요하고, 예산보다 개발 효율이 중요한 경우에 적합합니다.
컨테이너가 시작된 뒤 접속할 수 없으면 어떻게 해야 하나요?
다음 항목을 확인합니다.
1) HOSTNAME은 127.0.0.1이 아니라 0.0.0.0이어야 하므로 Dockerfile에 ENV HOSTNAME="0.0.0.0"을 설정합니다.
2) docker run -p 3000:3000으로 포트가 올바르게 매핑되었는지 확인합니다.
3) docker ps로 컨테이너가 실행 중인지 확인합니다.
4) docker logs <container-id>로 컨테이너 로그를 확인합니다.
5) docker exec -it <container-id> wget -O- http://localhost:3000으로 컨테이너 내부에서 테스트합니다.

4분 읽기 · 게시일: 2025년 12월 20일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog