Docker 없이 배포하던 시절을 떠올리면 아직도 소름이 돋는다. '내 PC에서는 되는데요?'라는 말을 일주일에 세 번은 들었고, 운영 서버에 직접 jar를 올리다가 실수로 운영 DB를 날린 적도 있다(물론 백업이 있어서 살았다). 그때 다짐했다. '다시는 수동 배포 안 한다.'

왜 Docker인가?

전통적인 WAR 배포 방식에서 Docker로 전환한 이유는 명확했습니다. 기존에는 개발 환경과 운영 환경의 차이로 인해 배포 후 예상치 못한 오류가 빈번했습니다. 대표적인 문제들이 있었습니다:

Docker를 사용하면 애플리케이션과 실행 환경을 하나의 이미지로 패키징하여 이런 문제를 원천적으로 차단할 수 있습니다.

Dockerfile 작성: 멀티스테이지 빌드

운영 이미지의 크기를 최소화하기 위해 멀티스테이지 빌드를 사용합니다. 빌드에 필요한 도구들은 최종 이미지에 포함되지 않습니다.

# Stage 1: Build
FROM gradle:8.5-jdk17 AS builder
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts ./
COPY gradle ./gradle
RUN gradle dependencies --no-daemon
COPY src ./src
RUN gradle bootJar --no-daemon

# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY --from=builder /app/build/libs/*.jar app.jar

RUN chown appuser:appgroup app.jar
USER appuser

EXPOSE 8080

ENTRYPOINT ["java", \
  "-XX:+UseContainerSupport", \
  "-XX:MaxRAMPercentage=75.0", \
  "-Djava.security.egd=file:/dev/./urandom", \
  "-jar", "app.jar"]

핵심 포인트를 정리하면 다음과 같습니다:

Docker Compose로 로컬 개발 환경 구성

데이터베이스, Redis, 모니터링 도구까지 포함한 로컬 개발 환경을 Docker Compose로 구성합니다.

version: '3.8'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=local
      - DB_HOST=mssql
      - DB_PORT=1433
      - REDIS_HOST=redis
    depends_on:
      mssql:
        condition: service_healthy
      redis:
        condition: service_started

  mssql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - MSSQL_SA_PASSWORD=${DB_PASSWORD}
    ports:
      - "1433:1433"
    healthcheck:
      test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$${DB_PASSWORD}" -Q "SELECT 1"
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

depends_oncondition: service_healthy를 설정하면, MSSQL이 완전히 기동된 후에 애플리케이션이 시작됩니다. 이를 통해 DB 연결 실패로 인한 애플리케이션 기동 실패를 방지할 수 있습니다.

운영 환경 최적화

1. 이미지 레이어 캐싱 최적화

Docker는 각 명령어를 레이어로 관리하며, 변경되지 않은 레이어는 캐시에서 재사용합니다. 의존성 다운로드 레이어를 소스 코드 복사보다 먼저 배치하면 빌드 시간을 단축할 수 있습니다.

# 의존성 레이어 (잘 변경되지 않음)
COPY build.gradle.kts settings.gradle.kts ./
RUN gradle dependencies --no-daemon

# 소스 코드 레이어 (자주 변경됨)
COPY src ./src
RUN gradle bootJar --no-daemon

이 구조에서는 build.gradle.kts가 변경되지 않는 한, 의존성 다운로드 레이어가 캐시됩니다. 소스 코드만 변경된 경우 빌드 시간이 약 70% 단축됩니다.

2. JVM 메모리 설정

컨테이너 환경에서는 호스트 전체 메모리가 아닌 컨테이너에 할당된 메모리를 기준으로 JVM 설정을 해야 합니다.

# 컨테이너 메모리 제한: 1GB
docker run -m 1g myapp:latest

# JVM은 자동으로 750MB (75%)를 힙으로 사용
# 나머지 250MB는 메타스페이스, 스레드 스택 등에 사용

3. 헬스체크 설정

Spring Boot Actuator와 Docker 헬스체크를 연동하면, 컨테이너 오케스트레이션 도구에서 자동 복구가 가능합니다.

HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1

배포 자동화 스크립트

실제 운영에서 사용하는 무중단 배포 스크립트의 핵심 로직입니다. Blue-Green 배포 패턴을 사용합니다.

#!/bin/bash
set -e

IMAGE_TAG="myapp:$(date +%Y%m%d_%H%M%S)"
CURRENT_COLOR=$(docker ps --filter "name=myapp" --format "{{.Names}}" | grep -o 'blue\|green')

if [ "$CURRENT_COLOR" = "blue" ]; then
  NEW_COLOR="green"
  NEW_PORT="8081"
else
  NEW_COLOR="blue"
  NEW_PORT="8080"
fi

echo "Building new image: $IMAGE_TAG"
docker build -t "$IMAGE_TAG" .

echo "Starting $NEW_COLOR container on port $NEW_PORT"
docker run -d --name "myapp-$NEW_COLOR" \
  -p "$NEW_PORT:8080" \
  --health-cmd="wget -q --spider http://localhost:8080/actuator/health" \
  "$IMAGE_TAG"

echo "Waiting for health check..."
for i in $(seq 1 30); do
  if docker inspect --format='{{.State.Health.Status}}' "myapp-$NEW_COLOR" | grep -q "healthy"; then
    echo "New container is healthy!"
    break
  fi
  sleep 2
done

echo "Switching traffic to $NEW_COLOR"
# nginx 설정 업데이트 또는 로드밸런서 전환

echo "Stopping old $CURRENT_COLOR container"
docker stop "myapp-$CURRENT_COLOR" && docker rm "myapp-$CURRENT_COLOR"

echo "Deployment complete!"
실무 경험 공유: 회사 개발서버에 Docker를 처음 도입했을 때, 컨테이너 메모리 제한을 걸어놨는데 JVM 힙 사이즈를 별도로 지정하지 않아서 OOM(Out of Memory)이 발생한 적이 있습니다. 이후 -XX:MaxRAMPercentage=75 설정의 중요성을 깨달았고, 이 경험이 Docker 환경에서의 JVM 설정을 공부하는 계기가 되었습니다.

실무에서 겪은 트러블슈팅

TimeZone 이슈

alpine 기반 이미지에서는 기본 타임존이 UTC입니다. 한국 시간이 필요한 경우 Dockerfile에 타임존 설정을 추가해야 합니다.

RUN apk add --no-cache tzdata
ENV TZ=Asia/Seoul

파일 인코딩 이슈

MSSQL과 연동할 때 한글 데이터가 깨지는 경우가 있었습니다. JDBC URL에 characterEncoding을 명시하고, 컨테이너의 로케일 설정도 함께 변경해야 합니다.

ENV LANG=ko_KR.UTF-8
ENV LANGUAGE=ko_KR:ko

DNS 해석 지연

컨테이너 내부에서 외부 서비스 호출 시 DNS 해석이 느린 경우, JVM의 DNS 캐싱 설정을 조정합니다.

-Dsun.net.inetaddr.ttl=30
-Dnetworkaddress.cache.ttl=30

Docker 도입 6개월 후 돌아보니

Docker를 활용한 Spring Boot 배포는 초기 학습 비용이 있지만, 한번 구축하면 배포 안정성과 효율성이 크게 향상됩니다. 특히 엔터프라이즈 환경에서는 환경 일관성이 보장되어 운영 이슈가 크게 줄어들었습니다.

다음 글에서는 GitHub Actions와 연동하여 CI/CD 파이프라인을 구축하는 방법을 다루겠습니다.

Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글