“내 PC에서는 되는데요.” 이 말을 없애기 위해 Docker가 존재한다. 환경을 코드로 정의하고, 어디서든 동일하게 실행한다. 컨테이너의 원리부터 프로덕션 보안까지 — 한 번에 정리한다.

핵심 요약 (TL;DR)

VM vs Container:

  • VM: 하이퍼바이저가 하드웨어 에뮬레이션 → 수 GB, 수 분 부팅
  • Container: 호스트 커널 공유, 프로세스 격리(네임스페이스+cgroups) → 수 MB, 수 초 실행

Docker 핵심 객체:

  • Image: 불변 읽기 전용 레이어 스택 (실행 가능한 파일 시스템)
  • Container: 이미지를 실행한 인스턴스 (읽기 쓰기 레이어 추가)
  • Volume: 컨테이너 외부 데이터 영속 저장소
  • Network: 컨테이너 간 통신 추상화

Docker 아키텍처

graph TD
    CLI["Docker CLI\n(docker build, run, push)"]
    Daemon["Docker Daemon\n(dockerd)"]
    Registry["Docker Registry\n(Docker Hub, ECR, GCR)"]

    subgraph "Host OS (Linux Kernel)"
        subgraph "Container A"
            App1["Next.js App\n(Process)"]
            Layer1["읽기/쓰기 레이어"]
        end
        subgraph "Container B"
            App2["Spring Boot\n(Process)"]
            Layer2["읽기/쓰기 레이어"]
        end
        Shared["공유 이미지 레이어\n(읽기 전용, COW)"]

        NS["네임스페이스\n(pid, net, mnt, uts)"]
        CG["cgroups\n(CPU, 메모리 제한)"]
    end

    CLI -->|"REST API"| Daemon
    Daemon -->|"pull/push"| Registry
    Daemon --> Container A & Container B
    Layer1 & Layer2 --> Shared
    NS & CG -->|"프로세스 격리"| Container A & Container B

    style Shared fill:#c8e6c9,stroke:#388e3c
    style NS fill:#bbdefb,stroke:#1565c0
    style CG fill:#fff9c4,stroke:#f9a825

레이어 캐싱(Layer Caching): Dockerfile 각 명령이 레이어를 만든다. 내용이 변경되지 않으면 캐시를 재사용 → 빌드 속도 향상. 변경이 적은 레이어를 위에, 자주 변경되는 레이어를 아래에 배치하는 것이 핵심이다.


기본 Docker 명령어

# ── 이미지 관리 ──────────────────────────────────────────
docker pull node:20-alpine               # 이미지 다운로드
docker images                           # 로컬 이미지 목록
docker image rm node:20-alpine          # 이미지 삭제
docker image prune -a                   # 사용하지 않는 이미지 전체 삭제

# ── 컨테이너 실행 ─────────────────────────────────────────
docker run -d \                          # -d: 백그라운드 실행
  --name honey-web \
  -p 3000:3000 \                         # 호스트:컨테이너 포트 매핑
  -e NODE_ENV=production \              # 환경변수
  -v honey-data:/app/data \             # 볼륨 마운트
  --memory=512m \                       # 메모리 제한
  --cpus=1 \                            # CPU 제한
  honey-web:latest

# ── 컨테이너 관리 ─────────────────────────────────────────
docker ps                               # 실행 중인 컨테이너
docker ps -a                            # 모든 컨테이너
docker logs -f honey-web               # 로그 스트리밍
docker exec -it honey-web sh           # 컨테이너 내부 접속
docker stop honey-web                  # 정상 종료 (SIGTERM)
docker rm honey-web                    # 컨테이너 삭제

# ── 정리 ────────────────────────────────────────────────
docker system prune -a --volumes       # 모든 미사용 리소스 삭제

Dockerfile — 레이어 캐싱 최적화

# ❌ 나쁜 예: package.json과 소스코드를 같이 복사
FROM node:20-alpine
WORKDIR /app
COPY . .                    # ← 소스 변경마다 npm install 재실행
RUN npm install
RUN npm run build
CMD ["node", "server.js"]

# ✅ 좋은 예: 의존성과 소스 분리 (레이어 캐싱 활용)
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./       # ← package.json이 변경될 때만 npm install
RUN npm ci --only=production
COPY . .                    # ← 소스 변경은 여기서만
RUN npm run build
CMD ["node", "server.js"]

멀티스테이지 빌드 — 이미지 크기 80% 절감

# Next.js 프로덕션 멀티스테이지 Dockerfile
# 빌드 결과: ~1.2GB(node:20) → ~200MB(node:20-alpine + 빌드 산출물만)

# ── Stage 1: 의존성 설치 ─────────────────────────────────
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat  # native module 빌드에 필요

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci                           # ci: package-lock.json 엄격히 준수

# ── Stage 2: 빌드 ────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app

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

# Next.js 출력 모드를 standalone으로 (최소 산출물)
# next.config.ts: output: 'standalone'
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# ── Stage 3: 프로덕션 런타임 ─────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# 🔐 보안: non-root 사용자로 실행
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# standalone 빌드 결과물만 복사
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  # non-root 전환

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

# 헬스체크: 컨테이너 상태 모니터링
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
  CMD wget -qO- http://localhost:3000/api/health || exit 1

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

Spring Boot 멀티스테이지 Dockerfile

# Spring Boot 멀티스테이지 빌드
# Layered JAR: 의존성/스프링부트/앱 레이어 분리 → 캐시 효율 극대화

# ── Stage 1: Gradle 빌드 ─────────────────────────────────
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app

# Gradle wrapper와 빌드 파일 먼저 (의존성 캐싱)
COPY gradlew .
COPY gradle gradle
COPY build.gradle.kts settings.gradle.kts ./
RUN ./gradlew dependencies --no-daemon  # 의존성 다운로드만

# 소스 복사 + 빌드
COPY src src
RUN ./gradlew build -x test --no-daemon  # 테스트 스킵 (CI에서 이미 실행)

# Layered JAR 추출
RUN java -Djarmode=layertools -jar build/libs/*.jar extract

# ── Stage 2: 프로덕션 런타임 ─────────────────────────────
FROM eclipse-temurin:21-jre-alpine AS runner

# 🔐 보안: non-root 사용자
RUN addgroup -S spring && adduser -S spring -G spring
USER spring

WORKDIR /app

# Layered JAR 레이어 순서 (변경 빈도 낮은 순)
COPY --from=builder app/dependencies/ ./
COPY --from=builder app/spring-boot-loader/ ./
COPY --from=builder app/snapshot-dependencies/ ./
COPY --from=builder app/application/ ./

EXPOSE 8080

# JVM 최적화 (컨테이너 메모리 인식)
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom"

HEALTHCHECK --interval=30s --timeout=5s --start-period=60s \
  CMD wget -qO- http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.JarLauncher"]

Docker Compose — 전체 스택 오케스트레이션

# docker-compose.yml — 개발 환경
# HoneyBarrel 전체 스택: Next.js + Spring Boot + PostgreSQL + Redis + Nginx

services:

  # ── 프론트엔드 ────────────────────────────────────────
  frontend:
    build:
      context: ./frontend
      target: runner          # 멀티스테이지: runner 스테이지만 빌드
    container_name: honey-frontend
    environment:
      - NODE_ENV=production
      - NEXT_PUBLIC_API_URL=http://api:8080
    ports:
      - "3000:3000"
    depends_on:
      api:
        condition: service_healthy  # api 헬스체크 통과 후 시작
    networks:
      - honey-net
    restart: unless-stopped

  # ── 백엔드 ───────────────────────────────────────────
  api:
    build:
      context: ./backend
      target: runner
    container_name: honey-api
    environment:
      - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/honeydb
      - SPRING_DATASOURCE_USERNAME=honey
      - SPRING_DATASOURCE_PASSWORD_FILE=/run/secrets/db_password
      - SPRING_REDIS_HOST=cache
      - SPRING_PROFILES_ACTIVE=prod
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    networks:
      - honey-net
    secrets:
      - db_password
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512m
          cpus: '1'

  # ── PostgreSQL ───────────────────────────────────────
  db:
    image: postgres:16-alpine
    container_name: honey-db
    environment:
      POSTGRES_DB: honeydb
      POSTGRES_USER: honey
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    volumes:
      - postgres-data:/var/lib/postgresql/data  # 데이터 영속화
      - ./db/init:/docker-entrypoint-initdb.d   # 초기 SQL 스크립트
    networks:
      - honey-net
    secrets:
      - db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U honey -d honeydb"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  # ── Redis ────────────────────────────────────────────
  cache:
    image: redis:7-alpine
    container_name: honey-cache
    command: >
      redis-server
      --requirepass ${REDIS_PASSWORD}
      --maxmemory 256mb
      --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    networks:
      - honey-net
    healthcheck:
      test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  # ── Nginx 리버스 프록시 ──────────────────────────────
  nginx:
    image: nginx:alpine
    container_name: honey-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    depends_on:
      - frontend
      - api
    networks:
      - honey-net
    restart: unless-stopped

# ── 볼륨 ─────────────────────────────────────────────
volumes:
  postgres-data:      # 명명된 볼륨: Docker가 관리, 컨테이너 삭제해도 유지
  redis-data:

# ── 네트워크 ─────────────────────────────────────────
networks:
  honey-net:
    driver: bridge    # 기본값. 같은 네트워크의 컨테이너는 이름으로 통신

# ── 시크릿 (프로덕션 민감 데이터) ──────────────────────
secrets:
  db_password:
    file: ./secrets/db_password.txt  # 로컬 파일에서 로드
    # 프로덕션: external: true + Docker Swarm/K8s Secret 사용

Nginx 설정

# nginx/nginx.conf
events { worker_connections 1024; }

http {
    upstream frontend { server frontend:3000; }
    upstream api      { server api:8080; }

    server {
        listen 80;
        server_name honeybarrel.co.kr;
        return 301 https://$host$request_uri;  # HTTP → HTTPS 리다이렉트
    }

    server {
        listen 443 ssl;
        server_name honeybarrel.co.kr;

        ssl_certificate     /etc/nginx/certs/fullchain.pem;
        ssl_certificate_key /etc/nginx/certs/privkey.pem;

        # API 요청 → Spring Boot
        location /api/ {
            proxy_pass http://api;
            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;
        }

        # 나머지 → Next.js
        location / {
            proxy_pass http://frontend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
    }
}

볼륨과 네트워크 상세

# ── 볼륨 ─────────────────────────────────────────────
docker volume create honey-data         # 명명된 볼륨 생성
docker volume ls                        # 볼륨 목록
docker volume inspect honey-data        # 볼륨 상세 정보
docker volume rm honey-data             # 볼륨 삭제 (데이터 삭제 주의!)

# 볼륨 타입 비교:
# named volume: Docker 관리, 컨테이너 간 공유, 백업 용이
# bind mount:   호스트 경로 직접 마운트, 개발 시 HMR에 유용
# tmpfs:        메모리 마운트, 임시 데이터, 컨테이너 종료 시 삭제

# ── 네트워크 ─────────────────────────────────────────
docker network create honey-net         # 브리지 네트워크 생성
docker network ls                       # 네트워크 목록
docker network inspect honey-net        # 연결된 컨테이너 확인

# 네트워크 타입:
# bridge:   기본값, 컨테이너 간 격리 + 이름으로 통신
# host:     호스트 네트워크 직접 사용 (성능 최적화, 격리 포기)
# none:     네트워크 완전 격리
# overlay:  Swarm/K8s 멀티호스트 컨테이너 통신

보안 강화

# 🔐 프로덕션 보안 체크리스트

# 1. 특정 버전 고정 (latest 금지)
FROM node:20.18.0-alpine3.20  # ✅
# FROM node:latest             # ❌

# 2. non-root 사용자
USER nextjs   # ✅

# 3. 불필요한 패키지 최소화
# alpine 이미지 사용, 빌드 도구는 멀티스테이지로 분리

# 4. 시크릿을 ENV로 넣지 않기
# ENV DB_PASSWORD=secret123  # ❌ docker inspect로 노출
# Docker Secrets 또는 런타임 주입 사용  # ✅

# 5. .dockerignore
# .dockerignore — 빌드 컨텍스트에서 제외
node_modules
.next
.git
.env*           # 환경변수 파일 제외
*.log
coverage
.nyc_output
Dockerfile*
docker-compose*
README.md
# 6. Trivy로 취약점 스캔
docker pull aquasec/trivy
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy image honey-web:latest

# CI에서 자동 스캔 (GitHub Actions)
- name: Trivy vulnerability scan
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: 'honey-web:latest'
    severity: 'CRITICAL,HIGH'
    exit-code: '1'  # 취약점 발견 시 CI 실패

실행 및 운영

# 개발 환경 (hot reload)
docker compose up --build

# 특정 서비스만 재빌드
docker compose up --build api

# 백그라운드 실행
docker compose up -d

# 로그 모니터링
docker compose logs -f api

# 스케일 아웃 (api 컨테이너 3개)
docker compose up --scale api=3 -d

# 서비스 중지 (데이터 유지)
docker compose stop

# 서비스 + 컨테이너 삭제 (데이터 유지)
docker compose down

# 서비스 + 컨테이너 + 볼륨 전체 삭제
docker compose down -v

이미지 최적화 결과

멀티스테이지 빌드 효과 (Next.js):
  일반 빌드:   node:20 → ~1.2 GB
  멀티스테이지: node:20-alpine (runner only) → ~200 MB
  절감: ~83%

Spring Boot Layered JAR:
  첫 빌드: 전체 다운로드 (느림)
  소스만 변경 후 재빌드: 앱 레이어만 갱신 → 빌드 시간 70% 절감

.dockerignore 효과:
  node_modules 제외: ~300MB 빌드 컨텍스트 절감

레퍼런스

공식 문서

기술 블로그


이 포스트는 HoneyByte 개발 블로그의 일부입니다.