iBetter Books
수정

Ch 03. Docker로 자체 서버 배포

Vercel은 편리하지만 모든 상황에 맞지는 않습니다. 서버 비용을 줄이고 싶거나, 데이터를 자체 서버에서 관리해야 하거나, 특수한 인프라 요구사항이 있다면 Docker로 직접 서버를 운영합니다.

Docker는 앱과 실행 환경을 하나의 컨테이너로 묶어 어디서든 같은 방식으로 실행할 수 있게 합니다.

Dockerfile 작성

Next.js 앱을 컨테이너화하는 Dockerfile입니다.

중요한 주의사항이 있습니다. next.config.tsoutput: "standalone"절대 설정하지 않습니다. standalone 빌드는 볼륨 마운트 환경(content/ 디렉토리 등)과 충돌하여 응답 deadlock을 일으킬 수 있습니다. next start -H 0.0.0.0 모드를 사용합니다.

# 파일: DockerfileFROM node:22-alpine AS base# 의존성 설치 단계FROM base AS depsRUN apk add --no-cache libc6-compatWORKDIR /appCOPY package.json package-lock.json* ./RUN npm ci# 빌드 단계FROM base AS builderWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .# Prisma 클라이언트 생성RUN npx prisma generateENV NEXT_TELEMETRY_DISABLED=1RUN npm run build# 실행 단계FROM base AS runnerWORKDIR /appENV NODE_ENV=productionENV NEXT_TELEMETRY_DISABLED=1RUN addgroup --system --gid 1001 nodejsRUN adduser --system --uid 1001 nextjs# 빌드 결과물 복사 (standalone 미사용, 전체 복사)COPY --from=builder /app/public ./publicCOPY --from=builder /app/.next ./.nextCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/package.json ./package.jsonUSER nextjsEXPOSE 3000ENV PORT=3000ENV HOSTNAME="0.0.0.0"# standalone 없이 next start 사용CMD ["npm", "start"]```text`package.json`의 `start` 스크립트가 `next start -H 0.0.0.0`을 실행하도록 합니다.```json{  "scripts": {    "start": "next start -H 0.0.0.0"  }}```text### .dockerignore 설정빌드 컨텍스트에 불필요한 파일을 제외합니다.

파일: .dockerignore

node_modules .next .env*.local .git README.md content uploads

`content/`는 볼륨 마운트로 제공되므로 Docker 이미지에 포함하지 않습니다.### docker-compose.yml 작성앱과 PostgreSQL 데이터베이스를 함께 구성합니다.```yaml# 파일: docker-compose.ymlservices:  app:    build:      context: .      dockerfile: Dockerfile    container_name: my-blog-app    restart: unless-stopped    ports:      - "3000:3000"    environment:      - DATABASE_URL=postgresql://blogs:password@db:5432/myblog      - AUTH_SECRET=${AUTH_SECRET}      - NODE_ENV=production    volumes:      - ./content:/app/content    # 콘텐츠 볼륨 마운트      - ./uploads:/app/public/uploads      - next_cache:/app/.next/cache    depends_on:      db:        condition: service_healthy    networks:      - internal  db:    image: postgres:16-alpine    container_name: my-blog-db    restart: unless-stopped    environment:      POSTGRES_DB: myblog      POSTGRES_USER: blogs      POSTGRES_PASSWORD: password    volumes:      - pg_data:/var/lib/postgresql/data    healthcheck:      test: ["CMD-SHELL", "pg_isready -U blogs -d myblog"]      interval: 10s      timeout: 5s      retries: 5    networks:      - internalvolumes:  pg_data:  next_cache:networks:  internal:    driver: bridge```text### 빌드와 실행```bash# 처음 실행 (이미지 빌드 포함)docker compose up -d --build# 앱 컨테이너만 재빌드docker compose build --no-cache app && docker compose up -d app# 로그 확인docker compose logs app --tail 100 -f# 컨테이너 상태 확인docker compose ps# 중지docker compose down```text### GitHub Webhook으로 자동 배포 구성매번 SSH로 접속해 수동으로 pull하고 재빌드하는 것은 번거롭습니다. GitHub Webhook을 사용하면 push할 때 자동으로 배포됩니다.서버에서 간단한 Webhook 서버를 실행합니다.```python# 파일: webhook-server.py (서버에서 실행)from http.server import HTTPServer, BaseHTTPRequestHandlerimport subprocessimport jsonimport hmacimport hashlibimport osSECRET = os.environ.get("WEBHOOK_SECRET", "")class WebhookHandler(BaseHTTPRequestHandler):    def do_POST(self):        if self.path != "/webhook/deploy":            self.send_response(404)            self.end_headers()            return        content_length = int(self.headers["Content-Length"])        body = self.rfile.read(content_length)        # 서명 검증        signature = self.headers.get("X-Hub-Signature-256", "")        expected = "sha256=" + hmac.new(            SECRET.encode(), body, hashlib.sha256        ).hexdigest()        if not hmac.compare_digest(signature, expected):            self.send_response(403)            self.end_headers()            return        # 자동 배포 실행        subprocess.Popen([            "bash", "-c",            "cd /app && git pull origin main && docker compose build app && docker compose up -d app"        ])        self.send_response(200)        self.end_headers()        self.wfile.write(b"Deploying...")if __name__ == "__main__":    server = HTTPServer(("0.0.0.0", 9000), WebhookHandler)    server.serve_forever()```textGitHub 저장소 → `Settings` → `Webhooks`에서 Webhook URL을 등록합니다.### Dockerfile 또는 package.json 변경 시 주의Webhook은 `docker compose build`를 실행하지만, 이전 빌드 캐시를 재사용할 수 있습니다. `Dockerfile`이나 `package.json`을 변경했다면 캐시를 무시하고 완전히 재빌드해야 합니다.```bash# 캐시 없이 완전 재빌드docker compose build --no-cache app && docker compose up -d app

다음 챕터에서는 Lighthouse로 성능을 측정하는 방법을 알아봅니다.