Docker와 CI/CD 완벽 가이드: 컨테이너 원리부터 배포 파이프라인까지

Docker와 CI/CD 완벽 가이드: 컨테이너 원리부터 배포 파이프라인까지

백엔드 개발자에게 “Docker를 왜 사용하나요?”, “CI/CD 파이프라인을 설명하세요”는 이제 기본 면접 질문이다. 단순히 “환경 통일”이라고 답하는 것과, 컨테이너가 VM과 어떻게 다른지, 이미지 레이어의 원리, 멀티스테이지 빌드, 배포 전략의 트레이드오프까지 설명하는 것은 차원이 다르다.

이 글에서는 Docker의 핵심 원리(Linux namespace, cgroup, Union FS), Dockerfile 작성과 최적화, Docker Compose를 활용한 로컬 개발 환경, 그리고 CI/CD 파이프라인 구축(GitHub Actions, Jenkins)과 배포 전략(Blue-Green, Canary, Rolling Update)을 정리한다.


1. 컨테이너의 개념과 원리

1.1 왜 컨테이너가 필요한가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"내 컴퓨터에서는 되는데..." 문제:

  개발자 A:            운영 서버:
  ┌──────────────┐     ┌──────────────┐
  │ Java 17      │     │ Java 11      │  ← 버전 다름!
  │ MySQL 8.0    │     │ MySQL 5.7    │  ← 버전 다름!
  │ Ubuntu 22.04 │     │ CentOS 7     │  ← OS 다름!
  │ lib v2.3     │     │ lib v1.8     │  ← 라이브러리 다름!
  └──────────────┘     └──────────────┘
  "잘 되는데?"           "안 되는데?"

  컨테이너의 해결:
  ┌──────────────────────────────────────┐
  │ 애플리케이션 + 실행 환경(라이브러리, 설정)│
  │ 을 하나의 패키지(이미지)로 묶는다         │
  │                                      │
  │ → 어디서 실행하든 동일한 환경 보장!       │
  │ → "내 컴에서 되면 어디서든 된다"          │
  └──────────────────────────────────────┘

1.2 컨테이너 vs 가상 머신 (VM)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
가상 머신 (VM):
  ┌─────────────────────────────────────────────┐
  │ App A    │  App B    │  App C               │
  ├──────────┼──────────┼──────────┤            │
  │ Libs     │  Libs     │  Libs    │            │
  ├──────────┼──────────┼──────────┤            │
  │ Guest OS │ Guest OS │ Guest OS │  ← 각각 OS! │
  │ (Ubuntu) │ (CentOS) │ (Alpine) │  (수 GB)    │
  ├──────────┴──────────┴──────────┤            │
  │        Hypervisor               │            │
  │    (VMware, VirtualBox, KVM)    │            │
  ├─────────────────────────────────┤            │
  │         Host OS                 │            │
  ├─────────────────────────────────┤            │
  │        Hardware                 │            │
  └─────────────────────────────────┘

컨테이너 (Docker):
  ┌─────────────────────────────────────────────┐
  │ App A    │  App B    │  App C               │
  ├──────────┼──────────┼──────────┤            │
  │ Libs     │  Libs     │  Libs    │            │
  ├──────────┴──────────┴──────────┤            │
  │       Container Engine          │  ← Guest OS│
  │         (Docker)                │    없음!    │
  ├─────────────────────────────────┤            │
  │        Host OS (Linux 커널 공유) │            │
  ├─────────────────────────────────┤            │
  │        Hardware                 │            │
  └─────────────────────────────────┘

비교:
  ┌────────────────┬─────────────┬─────────────────┐
  │                │ VM           │ Container        │
  ├────────────────┼─────────────┼─────────────────┤
  │ 격리 수준       │ 완전 격리    │ 프로세스 수준 격리 │
  │ OS             │ 각각 Guest OS│ Host 커널 공유    │
  │ 크기            │ 수 GB       │ 수십 MB           │
  │ 부팅 시간       │ 수 분       │ 수 초 (밀리초)     │
  │ 성능 오버헤드   │ 높음         │ 거의 없음         │
  │ 밀도 (1서버당)  │ 수십 개      │ 수백~수천 개      │
  │ 보안            │ 더 강함      │ 커널 공유로 취약점 │
  └────────────────┴─────────────┴─────────────────┘

1.3 Linux 컨테이너 기술의 핵심

Docker는 Linux 커널의 세 가지 기능을 활용하여 컨테이너를 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
① Namespace — 격리:
  ┌──────────────────────────────────────────────────┐
  │ 각 컨테이너에 독립적인 "시야"를 제공                  │
  │                                                  │
  │ PID namespace  : 프로세스 ID 격리                  │
  │   → 컨테이너 안의 PID 1 ≠ 호스트의 PID 1           │
  │                                                  │
  │ NET namespace  : 네트워크 격리                     │
  │   → 컨테이너별 독립적인 IP, 포트, 라우팅 테이블       │
  │                                                  │
  │ MNT namespace  : 파일시스템 격리                   │
  │   → 컨테이너별 독립적인 루트 파일시스템               │
  │                                                  │
  │ UTS namespace  : 호스트명 격리                     │
  │ IPC namespace  : 프로세스 간 통신 격리               │
  │ USER namespace : 사용자/그룹 ID 격리                │
  └──────────────────────────────────────────────────┘

② cgroup (Control Group) — 자원 제한:
  ┌──────────────────────────────────────────────────┐
  │ 컨테이너가 사용할 수 있는 자원의 상한을 설정           │
  │                                                  │
  │ CPU    : 컨테이너 A에 CPU 2코어까지                │
  │ Memory : 컨테이너 B에 메모리 512MB까지              │
  │ Disk I/O: 디스크 읽기/쓰기 속도 제한                │
  │ Network : 네트워크 대역폭 제한                      │
  │                                                  │
  │ → 하나의 컨테이너가 전체 자원을 독점하는 것을 방지     │
  └──────────────────────────────────────────────────┘

③ Union File System (OverlayFS) — 이미지 레이어:
  ┌──────────────────────────────────────────────────┐
  │ 여러 레이어를 겹쳐서 하나의 파일시스템으로 보이게 함   │
  │                                                  │
  │ ┌───────────────────────┐ ← 컨테이너 레이어 (R/W) │
  │ ├───────────────────────┤                        │
  │ │ Layer 4: COPY app.jar │ ← 이미지 레이어 (R/O)   │
  │ ├───────────────────────┤                        │
  │ │ Layer 3: RUN mvn build│                        │
  │ ├───────────────────────┤                        │
  │ │ Layer 2: COPY pom.xml │                        │
  │ ├───────────────────────┤                        │
  │ │ Layer 1: FROM openjdk │  ← 베이스 이미지        │
  │ └───────────────────────┘                        │
  │                                                  │
  │ 장점:                                              │
  │ - 레이어 공유 → 디스크 절약, 빌드 속도 ↑             │
  │ - 이미지는 읽기 전용 → 여러 컨테이너가 공유 가능      │
  │ - 컨테이너별 쓰기는 최상단 레이어에만 (COW)           │
  └──────────────────────────────────────────────────┘

2. Docker 핵심 개념

2.1 Image vs Container

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Image (이미지):
  ┌──────────────────────────────────────────────┐
  │ 컨테이너를 만들기 위한 "설계도" / "템플릿"       │
  │ - 읽기 전용 (Immutable)                       │
  │ - 레이어 구조                                  │
  │ - Docker Hub 등 Registry에 저장/공유            │
  │ - Dockerfile로부터 빌드                        │
  │                                              │
  │ 비유: 붕어빵 틀                                 │
  └──────────────────────────────────────────────┘

Container (컨테이너):
  ┌──────────────────────────────────────────────┐
  │ 이미지를 실행한 "인스턴스"                       │
  │ - 읽기/쓰기 가능 (최상단 레이어)                 │
  │ - 격리된 프로세스                               │
  │ - 생성/시작/중지/삭제 가능                       │
  │ - 하나의 이미지로 여러 컨테이너 생성 가능          │
  │                                              │
  │ 비유: 붕어빵 (틀에서 찍어낸 것)                   │
  └──────────────────────────────────────────────┘

  이미지 1개 → 컨테이너 N개:
  ┌──────────┐   run   ┌─────────────┐
  │  Image   │ ──────→ │ Container 1 │
  │ (openjdk │ ──────→ │ Container 2 │
  │  +app)   │ ──────→ │ Container 3 │
  └──────────┘         └─────────────┘

2.2 Dockerfile 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# === Spring Boot 애플리케이션 Dockerfile ===

# 1. 베이스 이미지 선택
FROM eclipse-temurin:21-jre-alpine

# 2. 메타데이터
LABEL maintainer="developer@example.com"

# 3. 작업 디렉토리 설정
WORKDIR /app

# 4. 의존성 파일 먼저 복사 (레이어 캐싱 활용)
COPY build/libs/*.jar app.jar

# 5. 포트 노출 (문서 목적)
EXPOSE 8080

# 6. JVM 옵션과 실행 명령
ENTRYPOINT ["java", \
    "-XX:+UseContainerSupport", \
    "-XX:MaxRAMPercentage=75.0", \
    "-Djava.security.egd=file:/dev/./urandom", \
    "-jar", "app.jar"]

2.3 멀티스테이지 빌드 — 이미지 크기 최적화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# === 멀티스테이지 빌드 ===

# Stage 1: 빌드 단계 (큰 이미지, 빌드 도구 포함)
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build
COPY gradle/ gradle/
COPY gradlew build.gradle settings.gradle ./
# 의존성만 먼저 다운로드 (레이어 캐싱!)
RUN ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew bootJar --no-daemon

# Stage 2: 실행 단계 (작은 이미지, JRE만)
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# 빌드 결과물만 복사
COPY --from=builder /build/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
멀티스테이지 빌드의 효과:

  단일 스테이지:                멀티스테이지:
  ┌──────────────────┐         ┌──────────────────┐
  │ JDK (200MB)      │         │ JRE (100MB)      │
  │ Gradle (100MB)   │         │ app.jar (50MB)   │
  │ 소스 코드 (50MB)  │         │                  │
  │ 의존성 (200MB)    │         │ 총: ~150MB       │
  │ app.jar (50MB)   │         └──────────────────┘
  │                  │
  │ 총: ~600MB       │
  └──────────────────┘

  → JDK, Gradle, 소스코드가 최종 이미지에 포함되지 않음!
  → 이미지 크기 75% 감소
  → 공격 표면(Attack Surface) 감소 → 보안 ↑

2.4 레이어 캐싱 전략

1
2
3
4
5
6
7
8
9
10
# === 비효율적 (캐싱 무력화) ===
COPY . .                    # 코드 한 줄만 바뀌어도 전체 다시 복사
RUN ./gradlew bootJar       # 의존성도 매번 다시 다운로드!

# === 효율적 (캐싱 활용) ===
COPY build.gradle settings.gradle ./   # 의존성 정의 파일 먼저
COPY gradle/ gradle/
RUN ./gradlew dependencies              # 의존성 다운로드 (캐싱!)
COPY src/ src/                          # 소스 코드 (자주 변경)
RUN ./gradlew bootJar                   # 빌드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
레이어 캐싱 원리:

  Dockerfile의 각 명령어 = 1개 레이어
  어떤 레이어가 변경되면 → 그 이후 모든 레이어 재빌드!

  빌드 1 (최초):
  Layer 1: FROM openjdk      (새로 생성)
  Layer 2: COPY build.gradle (새로 생성)
  Layer 3: RUN dependencies  (새로 생성, 5분 소요)
  Layer 4: COPY src/         (새로 생성)
  Layer 5: RUN bootJar       (새로 생성)

  빌드 2 (소스 코드만 변경):
  Layer 1: FROM openjdk      (캐시 ✓)
  Layer 2: COPY build.gradle (캐시 ✓) ← 변경 없으므로
  Layer 3: RUN dependencies  (캐시 ✓) ← 의존성 다시 안 받음!
  Layer 4: COPY src/         (재빌드) ← 소스 변경!
  Layer 5: RUN bootJar       (재빌드)

  → 의존성 다운로드 5분을 매번 절약!

3. Docker Compose — 멀티 컨테이너 관리

3.1 Docker Compose란

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# docker-compose.yml — Spring Boot + MySQL + Redis 개발 환경

version: '3.8'

services:
  # Spring Boot 애플리케이션
  app:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/myapp
      SPRING_DATASOURCE_USERNAME: root
      SPRING_DATASOURCE_PASSWORD: password
      SPRING_DATA_REDIS_HOST: redis
    networks:
      - app-network

  # MySQL 데이터베이스
  db:
    image: mysql:8.0
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: myapp
    volumes:
      - mysql-data:/var/lib/mysql        # 데이터 영속화
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql  # 초기화
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app-network

  # Redis 캐시
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    networks:
      - app-network

volumes:
  mysql-data:    # 컨테이너 삭제해도 DB 데이터 유지

networks:
  app-network:
    driver: bridge
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Docker Compose 실행:

  $ docker-compose up -d      # 백그라운드 실행
  $ docker-compose logs -f    # 로그 확인
  $ docker-compose down       # 종료 및 정리
  $ docker-compose down -v    # 볼륨까지 삭제

  ┌──────────────────────────────────────────────┐
  │ app-network (bridge)                          │
  │                                              │
  │  ┌──────┐    ┌──────┐    ┌──────┐           │
  │  │ app  │───→│  db  │    │redis │           │
  │  │:8080 │    │:3306 │    │:6379 │           │
  │  └──────┘    └──────┘    └──────┘           │
  │                  │                           │
  │              mysql-data                      │
  │              (volume)                        │
  └──────────────────────────────────────────────┘

4. Docker 실무 Best Practices

4.1 이미지 최적화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌──────────────────────────────────────────────────────────┐
│ 1. 경량 베이스 이미지 사용                                 │
│    ✗ FROM openjdk:21        (Eclipse Temurin, ~300MB)    │
│    ✓ FROM eclipse-temurin:21-jre-alpine  (~100MB)       │
│    → Alpine Linux: ~5MB, 필요한 것만 포함                 │
│                                                          │
│ 2. .dockerignore 파일 작성                                │
│    .git                                                  │
│    .gradle                                               │
│    build/                                                │
│    *.md                                                  │
│    → 불필요한 파일이 빌드 컨텍스트에 포함되지 않도록          │
│                                                          │
│ 3. RUN 명령어 합치기                                       │
│    ✗ RUN apt-get update                                  │
│      RUN apt-get install -y curl                         │
│      RUN apt-get clean                                   │
│    → 3개 레이어 생성, 중간 레이어에 불필요한 캐시 남음        │
│                                                          │
│    ✓ RUN apt-get update && \                             │
│        apt-get install -y --no-install-recommends curl && \│
│        apt-get clean && rm -rf /var/lib/apt/lists/*      │
│    → 1개 레이어, 캐시 정리 포함                             │
│                                                          │
│ 4. root가 아닌 사용자로 실행                                │
│    RUN addgroup -S appgroup && adduser -S appuser -G appgroup │
│    USER appuser                                          │
│    → 보안 강화 (컨테이너 탈출 시 root 권한 획득 방지)        │
└──────────────────────────────────────────────────────────┘

4.2 Spring Boot의 컨테이너 최적화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
JVM의 컨테이너 인식:

  ┌──────────────────────────────────────────────────┐
  │ 문제: JVM이 컨테이너의 메모리 제한을 인식 못 하면?    │
  │                                                  │
  │ 컨테이너: 512MB 제한                               │
  │ JVM: "호스트에 16GB 있으니 4GB 힙 잡자"             │
  │ → OOMKilled! 컨테이너가 강제 종료됨                 │
  │                                                  │
  │ 해결: Java 10+ 컨테이너 지원 옵션                   │
  │   -XX:+UseContainerSupport  (기본 활성화)          │
  │   -XX:MaxRAMPercentage=75.0 (컨테이너 메모리의 75%) │
  │                                                  │
  │ 예: 512MB 컨테이너                                 │
  │   → MaxRAMPercentage=75% → 힙 ~384MB             │
  │   → 나머지 128MB는 Metaspace, 스택 등에 사용        │
  └──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Spring Boot Layered Jar — Docker 최적화:

  Spring Boot 2.3+는 jar를 레이어로 분리할 수 있다.

  ┌────────────────────────────────────────────┐
  │ Layer 1: dependencies    (변경 거의 없음)   │  ← 캐싱 효과 큼
  │ Layer 2: spring-boot-loader (변경 거의 없음)│
  │ Layer 3: snapshot-dependencies (가끔 변경)  │
  │ Layer 4: application    (매번 변경)         │  ← 이것만 재빌드
  └────────────────────────────────────────────┘

  Dockerfile:
  FROM eclipse-temurin:21-jre-alpine AS builder
  WORKDIR /build
  COPY build/libs/*.jar app.jar
  RUN java -Djarmode=layertools -jar app.jar extract

  FROM eclipse-temurin:21-jre-alpine
  WORKDIR /app
  COPY --from=builder /build/dependencies/ ./
  COPY --from=builder /build/spring-boot-loader/ ./
  COPY --from=builder /build/snapshot-dependencies/ ./
  COPY --from=builder /build/application/ ./
  ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

  효과:
  - 코드만 변경 시 application 레이어(~수 KB)만 재빌드
  - dependencies 레이어(~수십 MB)는 캐시 활용
  - 푸시/풀 시간 대폭 감소

5. CI/CD 파이프라인

5.1 CI/CD란

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
CI (Continuous Integration, 지속적 통합):
  ┌──────────────────────────────────────────────────┐
  │ 개발자가 코드를 자주(매일) 메인 브랜치에 통합하고,    │
  │ 자동으로 빌드 + 테스트를 실행하는 것                 │
  │                                                  │
  │ git push → 자동 빌드 → 자동 테스트 → 결과 알림      │
  │                                                  │
  │ 목적: 통합 문제를 빠르게 발견, "동작하는 소프트웨어"   │
  └──────────────────────────────────────────────────┘

CD (Continuous Delivery / Deployment):
  ┌──────────────────────────────────────────────────┐
  │ Continuous Delivery (지속적 전달):                  │
  │   CI 통과 → 스테이징 배포 → 수동 승인 → 프로덕션     │
  │   → 언제든 배포 가능한 상태를 유지                    │
  │                                                  │
  │ Continuous Deployment (지속적 배포):                │
  │   CI 통과 → 자동으로 프로덕션 배포                   │
  │   → 완전 자동화, 사람의 개입 없음                    │
  └──────────────────────────────────────────────────┘

전체 파이프라인:
  ┌──────┐  ┌───────┐  ┌──────┐  ┌────────┐  ┌────────┐  ┌──────────┐
  │ Code │→ │ Build │→ │ Test │→ │ Docker │→ │ Deploy │→ │Production│
  │ Push │  │       │  │      │  │ Image  │  │Staging │  │          │
  └──────┘  └───────┘  └──────┘  └────────┘  └────────┘  └──────────┘
   개발자      CI 서버     CI 서버   Registry    CD         CD

5.2 GitHub Actions 파이프라인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# .github/workflows/ci-cd.yml

name: CI/CD Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  # 1단계: 빌드 & 테스트
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Cache Gradle
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-$

      - name: Build & Test
        run: ./gradlew build test

      - name: Upload Test Results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: build/reports/tests/

  # 2단계: Docker 이미지 빌드 & 푸시
  docker-build:
    needs: build-and-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Build JAR
        run: ./gradlew bootJar

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: $
          password: $

      - name: Build & Push Docker Image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            myapp:latest
            myapp:$

  # 3단계: 배포
  deploy:
    needs: docker-build
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to Server
        uses: appleboy/ssh-action@v1
        with:
          host: $
          username: $
          key: $
          script: |
            docker pull myapp:$
            docker stop myapp || true
            docker rm myapp || true
            docker run -d --name myapp \
              -p 8080:8080 \
              --restart unless-stopped \
              myapp:$
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
GitHub Actions 파이프라인 흐름:

  git push (main)
       │
       ▼
  ┌─────────────────────┐
  │ build-and-test       │
  │ ┌─────────────────┐ │
  │ │ Gradle Build    │ │
  │ │ Unit Tests      │ │
  │ │ Integration Tests│ │
  │ └────────┬────────┘ │
  └──────────┼──────────┘
             │ 성공 시
             ▼
  ┌─────────────────────┐
  │ docker-build         │
  │ ┌─────────────────┐ │
  │ │ Docker Build    │ │
  │ │ Docker Push     │ │
  │ │ (Registry)      │ │
  │ └────────┬────────┘ │
  └──────────┼──────────┘
             │ 성공 시
             ▼
  ┌─────────────────────┐
  │ deploy               │
  │ ┌─────────────────┐ │
  │ │ SSH → Server    │ │
  │ │ docker pull     │ │
  │ │ docker run      │ │
  │ └─────────────────┘ │
  └─────────────────────┘

5.3 Jenkins 파이프라인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// Jenkinsfile (Declarative Pipeline)

pipeline {
    agent any

    environment {
        DOCKER_REGISTRY = 'registry.example.com'
        IMAGE_NAME = 'myapp'
        IMAGE_TAG = "${BUILD_NUMBER}"
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }

        stage('Build & Test') {
            steps {
                sh './gradlew clean build test'
            }
            post {
                always {
                    junit 'build/test-results/test/*.xml'
                    jacoco execPattern: 'build/jacoco/test.exec'
                }
            }
        }

        stage('Docker Build & Push') {
            when { branch 'main' }
            steps {
                script {
                    docker.withRegistry("https://${DOCKER_REGISTRY}",
                                        'docker-credentials') {
                        def image = docker.build(
                            "${IMAGE_NAME}:${IMAGE_TAG}")
                        image.push()
                        image.push('latest')
                    }
                }
            }
        }

        stage('Deploy to Staging') {
            when { branch 'main' }
            steps {
                sh """
                    kubectl set image deployment/myapp \
                        myapp=${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
                """
            }
        }

        stage('Approval') {
            when { branch 'main' }
            steps {
                input message: '프로덕션에 배포하시겠습니까?',
                      ok: '배포'
            }
        }

        stage('Deploy to Production') {
            when { branch 'main' }
            steps {
                sh """
                    kubectl --context production \
                        set image deployment/myapp \
                        myapp=${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
                """
            }
        }
    }

    post {
        failure {
            // Slack 알림 등
            echo 'Pipeline failed!'
        }
    }
}

6. 배포 전략

6.1 Rolling Update (순차 배포)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Rolling Update:
  기존 인스턴스를 하나씩 새 버전으로 교체

  단계 1: v1 4개 실행 중
  ┌────┐ ┌────┐ ┌────┐ ┌────┐
  │ v1 │ │ v1 │ │ v1 │ │ v1 │
  └────┘ └────┘ └────┘ └────┘

  단계 2: 1개를 v2로 교체
  ┌────┐ ┌────┐ ┌────┐ ┌────┐
  │ v2 │ │ v1 │ │ v1 │ │ v1 │
  └────┘ └────┘ └────┘ └────┘

  단계 3: 2개째 교체
  ┌────┐ ┌────┐ ┌────┐ ┌────┐
  │ v2 │ │ v2 │ │ v1 │ │ v1 │
  └────┘ └────┘ └────┘ └────┘

  단계 4: 완료
  ┌────┐ ┌────┐ ┌────┐ ┌────┐
  │ v2 │ │ v2 │ │ v2 │ │ v2 │
  └────┘ └────┘ └────┘ └────┘

  장점: 추가 인프라 불필요, 점진적 교체
  단점: 배포 중 v1/v2 혼재 → 호환성 필요
        롤백 시 다시 순차 교체 → 느림

6.2 Blue-Green Deployment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Blue-Green Deployment:
  두 개의 동일한 환경을 유지하고, 트래픽을 한 번에 전환

  배포 전:
  ┌────────────────────────────────────────────────┐
  │                                                │
  │  Load Balancer ──→ Blue (v1) ← 트래픽 처리 중   │
  │                    ┌────┐ ┌────┐ ┌────┐        │
  │                    │ v1 │ │ v1 │ │ v1 │        │
  │                    └────┘ └────┘ └────┘        │
  │                                                │
  │                    Green (유휴)                  │
  │                    ┌────┐ ┌────┐ ┌────┐        │
  │                    │    │ │    │ │    │        │
  │                    └────┘ └────┘ └────┘        │
  └────────────────────────────────────────────────┘

  v2를 Green에 배포:
  ┌────────────────────────────────────────────────┐
  │  Load Balancer ──→ Blue (v1) ← 여전히 트래픽    │
  │                    ┌────┐ ┌────┐ ┌────┐        │
  │                    │ v1 │ │ v1 │ │ v1 │        │
  │                    └────┘ └────┘ └────┘        │
  │                                                │
  │                    Green (v2) ← 테스트 중        │
  │                    ┌────┐ ┌────┐ ┌────┐        │
  │                    │ v2 │ │ v2 │ │ v2 │        │
  │                    └────┘ └────┘ └────┘        │
  └────────────────────────────────────────────────┘

  트래픽 전환 (순간):
  ┌────────────────────────────────────────────────┐
  │  Load Balancer ──→ Green (v2) ← 트래픽 전환!    │
  │                    ┌────┐ ┌────┐ ┌────┐        │
  │                    │ v2 │ │ v2 │ │ v2 │        │
  │                    └────┘ └────┘ └────┘        │
  │                                                │
  │                    Blue (v1) ← 대기 (롤백 대비)  │
  │                    ┌────┐ ┌────┐ ┌────┐        │
  │                    │ v1 │ │ v1 │ │ v1 │        │
  │                    └────┘ └────┘ └────┘        │
  └────────────────────────────────────────────────┘

  장점: 즉각 전환, 즉각 롤백 (다시 Blue로 전환)
        다운타임 제로
  단점: 인프라 비용 2배 (두 환경 유지)

6.3 Canary Deployment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Canary Deployment:
  소수의 인스턴스에만 먼저 배포하여 검증 후 점진적 확대

  단계 1: 전체 트래픽의 5%만 v2로
  ┌────────────────────────────────────────────────┐
  │                                                │
  │  Load Balancer                                 │
  │     ├── 95% ──→ v1 (기존)                       │
  │     │           ┌────┐ ┌────┐ ┌────┐           │
  │     │           │ v1 │ │ v1 │ │ v1 │           │
  │     │           └────┘ └────┘ └────┘           │
  │     │                                          │
  │     └── 5% ───→ v2 (카나리)                     │
  │                 ┌────┐                          │
  │                 │ v2 │  ← 모니터링 중             │
  │                 └────┘                          │
  └────────────────────────────────────────────────┘

  단계 2: 에러율/지연시간 정상 → 25%로 확대
  단계 3: 계속 정상 → 50% → 75% → 100%

  문제 발견 시: v2 즉시 제거, v1으로 100% 복귀

  장점: 최소 위험으로 검증, 점진적 확대
  단점: 구현 복잡 (트래픽 분배, 모니터링 자동화 필요)
        v1/v2 혼재 시간 존재

6.4 배포 전략 비교

1
2
3
4
5
6
7
8
9
10
11
12
13
┌────────────────┬────────────┬──────────────┬──────────────┐
│                │ Rolling    │ Blue-Green   │ Canary       │
├────────────────┼────────────┼──────────────┼──────────────┤
│ 다운타임       │ 없음        │ 없음          │ 없음          │
│ 롤백 속도      │ 느림        │ 즉시          │ 빠름          │
│ 인프라 비용    │ 없음        │ 2배           │ 약간 추가     │
│ 위험도         │ 중간        │ 낮음          │ 가장 낮음     │
│ 버전 혼재      │ 있음        │ 없음          │ 있음          │
│ 구현 복잡도    │ 낮음        │ 중간          │ 높음          │
├────────────────┼────────────┼──────────────┼──────────────┤
│ 적합한 경우    │ 일반적 배포 │ 중요 서비스    │ 대규모 서비스 │
│                │ 소규모 팀  │ 즉각 롤백 필요 │ 점진적 검증   │
└────────────────┴────────────┴──────────────┴──────────────┘

7. Kubernetes 기초 개념

7.1 왜 Kubernetes가 필요한가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Docker만으로 부족한 점:

  ┌──────────────────────────────────────────────────────┐
  │ 컨테이너가 수십~수백 개가 되면?                          │
  │                                                      │
  │ - 어떤 서버에 컨테이너를 배치할지? (스케줄링)             │
  │ - 컨테이너가 죽으면 자동 재시작? (셀프 힐링)              │
  │ - 트래픽 증가 시 자동 확장? (오토스케일링)                │
  │ - 컨테이너 간 네트워크? (서비스 디스커버리)               │
  │ - 무중단 배포? (Rolling Update)                        │
  │ - 설정, 시크릿 관리?                                   │
  │                                                      │
  │ → 컨테이너 오케스트레이션(Orchestration)이 필요!         │
  │ → Kubernetes가 사실상 표준                              │
  └──────────────────────────────────────────────────────┘

7.2 Kubernetes 핵심 오브젝트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
┌──────────────────────────────────────────────────────────┐
│ Pod:                                                     │
│   Kubernetes의 최소 배포 단위                              │
│   1개 이상의 컨테이너를 포함                                │
│   같은 Pod의 컨테이너는 네트워크/스토리지 공유               │
│   ┌─────────────────────────┐                            │
│   │ Pod                     │                            │
│   │  ┌──────┐  ┌──────┐    │                            │
│   │  │App   │  │Sidecar│   │                            │
│   │  │Container│ │(로그 등)│  │                            │
│   │  └──────┘  └──────┘    │                            │
│   └─────────────────────────┘                            │
│                                                          │
│ Deployment:                                              │
│   Pod의 원하는 상태(Desired State)를 선언                   │
│   "이 이미지로 3개의 Pod를 항상 유지해줘"                    │
│   → Pod이 죽으면 자동 재생성                                │
│   → 롤링 업데이트, 롤백 관리                                │
│                                                          │
│ Service:                                                 │
│   Pod 그룹에 대한 안정적인 네트워크 엔드포인트                │
│   Pod은 생성/삭제 시 IP가 변하지만, Service IP는 고정        │
│   → 로드 밸런싱, 서비스 디스커버리                           │
│                                                          │
│ ConfigMap / Secret:                                      │
│   설정 값과 민감 정보를 코드와 분리하여 관리                   │
│   → 이미지 재빌드 없이 설정 변경 가능                       │
└──────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# Kubernetes Deployment + Service 예시

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3               # Pod 3개 유지
  selector:
    matchLabels:
      app: myapp
  strategy:
    type: RollingUpdate      # 배포 전략
    rollingUpdate:
      maxSurge: 1            # 최대 1개 추가 Pod 허용
      maxUnavailable: 0      # 0개 미만 불허 (항상 3개 유지)
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: myapp:1.0.0
          ports:
            - containerPort: 8080
          resources:
            requests:          # 최소 보장 자원
              cpu: "250m"
              memory: "256Mi"
            limits:            # 최대 사용 가능 자원
              cpu: "500m"
              memory: "512Mi"
          readinessProbe:      # 트래픽 수신 가능 여부
            httpGet:
              path: /actuator/health
              port: 8080
            initialDelaySeconds: 30
          livenessProbe:       # 컨테이너 생존 여부
            httpGet:
              path: /actuator/health
              port: 8080
            initialDelaySeconds: 60
---
apiVersion: v1
kind: Service
metadata:
  name: myapp-service
spec:
  type: ClusterIP
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 8080

8. 면접 빈출 질문 정리

Q1. Docker 컨테이너와 VM의 차이를 설명하세요.

VM은 Hypervisor 위에 각각 독립적인 Guest OS를 실행한다. 완전한 격리를 제공하지만, OS별 수 GB의 오버헤드와 수 분의 부팅 시간이 소요된다.

Docker 컨테이너는 Host OS의 Linux 커널을 공유하고, namespace로 프로세스/네트워크/파일시스템을 격리, cgroup으로 자원(CPU, 메모리)을 제한한다. Guest OS가 없으므로 수십 MB 크기에 밀리초 단위로 시작된다. 하나의 서버에 수백~수천 개의 컨테이너를 실행할 수 있다.

보안 관점에서 VM은 하드웨어 수준의 완전 격리를 제공하여 더 안전하지만, 컨테이너는 커널을 공유하므로 커널 취약점이 공격 경로가 될 수 있다. 실무에서는 대부분 컨테이너의 경량성과 이식성이 장점이 크므로 컨테이너를 사용하고, 보안이 특히 중요한 멀티테넌트 환경에서는 VM을 고려한다.

Q2. Docker 이미지의 레이어 구조를 설명하세요.

Docker 이미지는 Union File System(OverlayFS)을 기반으로 여러 읽기 전용 레이어를 겹쳐서 구성된다. Dockerfile의 각 명령어(FROM, COPY, RUN 등)가 하나의 레이어를 생성한다.

레이어 구조의 장점: 1) 여러 이미지가 같은 베이스 레이어를 공유하여 디스크를 절약한다. 2) 빌드 시 변경되지 않은 레이어는 캐시를 사용하여 빌드 속도를 높인다. 3) 이미지 push/pull 시 변경된 레이어만 전송한다.

컨테이너 실행 시 이미지 레이어(읽기 전용) 위에 쓰기 가능한 컨테이너 레이어가 추가된다. 컨테이너 내의 파일 변경은 Copy-on-Write(COW) 방식으로 이 레이어에만 기록된다. 따라서 하나의 이미지에서 여러 컨테이너를 생성해도 이미지 레이어를 공유하므로 효율적이다.

Q3. Dockerfile 최적화 방법을 설명하세요.

1) 멀티스테이지 빌드: 빌드 단계(JDK, Gradle)와 실행 단계(JRE만)를 분리하여 최종 이미지에 빌드 도구를 포함하지 않는다. 이미지 크기를 75% 이상 줄일 수 있다.

2) 레이어 캐싱 활용: 변경이 적은 파일(build.gradle)을 먼저 COPY하고 의존성을 설치한 후, 자주 변경되는 소스 코드를 나중에 COPY한다. 소스만 변경 시 의존성 다운로드 레이어가 캐시되어 빌드 시간이 크게 단축된다.

3) 경량 베이스 이미지: Alpine Linux 기반 이미지를 사용한다. 4) RUN 명령어를 합쳐 레이어 수를 줄이고 불필요한 캐시를 제거한다. 5) .dockerignore로 빌드 컨텍스트에서 불필요한 파일을 제외한다. 6) root가 아닌 사용자로 실행하여 보안을 강화한다.

Q4. CI/CD란 무엇이며, 왜 필요한가요?

CI(Continuous Integration)는 개발자가 코드를 메인 브랜치에 자주 통합하고, 자동으로 빌드와 테스트를 실행하는 관행이다. 통합 문제를 조기에 발견하고, 항상 “동작하는 소프트웨어”를 유지한다.

CD(Continuous Delivery/Deployment)는 CI를 통과한 코드를 자동으로 스테이징/프로덕션에 배포하는 것이다. Delivery는 배포 가능 상태를 유지하되 수동 승인이 필요하고, Deployment는 프로덕션까지 완전 자동화한다.

필요한 이유: 1) 수동 빌드/배포는 실수가 발생하기 쉽다. 2) 통합 문제를 배포 직전이 아닌 커밋 직후 발견한다. 3) 배포 주기를 단축하여(주 1회 → 하루 수 회) 빠르게 가치를 전달한다. 4) 일관된 프로세스로 팀 전체의 생산성을 높인다.

Q5. Blue-Green 배포와 Canary 배포의 차이를 설명하세요.

Blue-Green 배포: 두 개의 동일한 환경(Blue=현재, Green=새 버전)을 유지한다. Green에 새 버전을 배포/테스트한 후 로드 밸런서의 트래픽을 한 번에 Blue→Green으로 전환한다. 문제 시 즉시 Blue로 되돌린다. 장점은 즉각 롤백과 다운타임 제로이고, 단점은 인프라 비용이 2배이다.

Canary 배포: 전체 트래픽의 일부(예: 5%)만 새 버전으로 보내고, 에러율/지연시간을 모니터링한다. 정상이면 점진적으로 10% → 25% → 50% → 100%로 확대한다. 문제 발견 시 소수 사용자만 영향받으므로 위험이 최소화된다. 단점은 트래픽 분배와 모니터링 자동화 구현이 복잡하다.

선택 기준: 중요도가 높고 즉각 롤백이 필요하면 Blue-Green, 대규모 서비스에서 최소 위험으로 검증하려면 Canary를 사용한다.

Q6. Docker Compose는 무엇이며, 언제 사용하나요?

Docker Compose는 여러 컨테이너로 구성된 애플리케이션을 하나의 YAML 파일로 정의하고 관리하는 도구이다. docker-compose.yml에 서비스(컨테이너), 네트워크, 볼륨을 선언하고 docker-compose up 한 번으로 전체 환경을 실행한다.

주요 사용 사례: 1) 로컬 개발 환경 — 애플리케이션 + DB + Redis + 메시지 큐를 한 번에 구성. 2) 통합 테스트 — CI에서 의존 서비스를 포함한 테스트 환경 구축. 3) 데모/프로토타입 — 빠른 환경 셋업.

프로덕션에서는 Docker Compose 대신 Kubernetes를 사용하는 것이 일반적이다. Compose는 단일 호스트, Kubernetes는 클러스터(다중 호스트) 환경을 위한 도구이다.

Q7. Kubernetes의 핵심 개념을 설명하세요.

Pod: 최소 배포 단위. 1개 이상의 컨테이너를 포함하며, 같은 Pod의 컨테이너는 네트워크와 스토리지를 공유한다.

Deployment: Pod의 원하는 상태(replicas, 이미지 버전 등)를 선언한다. Kubernetes가 실제 상태를 원하는 상태로 자동 유지한다. Pod이 죽으면 자동 재생성, 롤링 업데이트와 롤백을 관리한다.

Service: Pod 그룹에 대한 안정적인 네트워크 엔드포인트를 제공한다. Pod은 생성/삭제 시 IP가 변하지만 Service IP는 고정이므로, 다른 서비스가 Service를 통해 접근하면 Pod의 변화에 영향받지 않는다. 로드 밸런싱도 수행한다.

핵심 원리: Kubernetes는 선언적(Declarative) 방식이다. “Pod 3개를 유지하라”고 선언하면, Controller가 현재 상태를 감시하고 원하는 상태와 맞추기 위해 Pod을 생성/삭제한다.

Q8. 컨테이너 환경에서 JVM 설정 시 주의할 점은?

메모리: JVM이 컨테이너의 메모리 제한을 인식하지 못하면, 호스트 메모리 기준으로 힙을 설정하여 OOMKilled가 발생한다. Java 10+에서 -XX:+UseContainerSupport(기본 활성화)와 -XX:MaxRAMPercentage=75.0으로 컨테이너 메모리의 75%를 힙으로 설정한다. 나머지 25%는 Metaspace, 스레드 스택, 네이티브 메모리에 사용된다.

Graceful Shutdown: 컨테이너 종료 시 SIGTERM을 받으면 진행 중인 요청을 처리한 후 종료해야 한다. Spring Boot의 server.shutdown=graceful과 Kubernetes의 terminationGracePeriodSeconds를 설정한다.

Health Check: readinessProbe(트래픽 수신 가능 여부)와 livenessProbe(컨테이너 생존 여부)를 Spring Actuator의 /actuator/health 엔드포인트로 설정한다. initialDelaySeconds를 충분히 주어 JVM 웜업 시간을 확보해야 한다.

Q9. CI/CD 파이프라인의 일반적인 단계를 설명하세요.

1) Code: 개발자가 코드를 커밋하고 PR을 생성한다.

2) Build: 소스 코드를 컴파일한다(Gradle/Maven). 의존성 캐싱으로 빌드 시간을 단축한다.

3) Test: 단위 테스트, 통합 테스트를 자동 실행한다. 테스트 커버리지를 측정하고 기준 미달 시 실패 처리한다.

4) Static Analysis: SonarQube 등으로 코드 품질, 보안 취약점을 검사한다.

5) Docker Build: 애플리케이션을 Docker 이미지로 패키징한다. 멀티스테이지 빌드로 경량 이미지를 생성한다.

6) Push: Docker 이미지를 Registry(Docker Hub, ECR 등)에 푸시한다.

7) Deploy Staging: 스테이징 환경에 배포하여 최종 검증한다.

8) Approval(Delivery) 또는 Auto-Deploy(Deployment): 수동 승인 후 프로덕션 배포 또는 자동 프로덕션 배포.

Q10. Rolling Update 중 호환성 문제를 어떻게 해결하나요?

Rolling Update 중에는 v1과 v2가 동시에 트래픽을 처리한다. 이때 DB 스키마 변경, API 변경 등에서 호환성 문제가 발생할 수 있다.

DB 스키마 변경: 호환성을 유지하는 방향으로 변경한다. 컬럼 추가는 안전(v1은 새 컬럼 무시), 컬럼 삭제는 위험(v1이 아직 사용 중). 해결: Expand-Contract 패턴 — 1차 배포에서 새 컬럼 추가 + 코드에서 새 컬럼 사용, 2차 배포에서 기존 컬럼 제거.

API 변경: 하위 호환을 유지한다. 필드 추가는 안전, 필드 삭제/변경은 위험. API 버저닝(/api/v1, /api/v2)으로 버전별 엔드포인트를 분리한다.

핵심 원칙: “배포는 한 번에 하나의 변경만” — DB 변경과 코드 변경을 동시에 하지 않고, 각각 별도 배포로 분리하면 롤백이 안전해진다.


마무리

Docker와 CI/CD는 현대 백엔드 개발의 기본 인프라이다.

  • Docker: 컨테이너는 VM과 달리 커널을 공유하며, namespace/cgroup/UnionFS로 경량 격리를 구현한다. 멀티스테이지 빌드와 레이어 캐싱으로 이미지를 최적화한다.
  • CI/CD: git push부터 프로덕션 배포까지의 자동화 파이프라인이다. GitHub Actions/Jenkins로 빌드→테스트→이미지 빌드→배포를 자동화한다.
  • 배포 전략: Rolling Update(점진적), Blue-Green(즉시 전환/롤백), Canary(소수 검증 후 확대) — 서비스 특성에 맞는 전략을 선택한다.
  • Kubernetes: 다수의 컨테이너를 관리하는 오케스트레이션 도구. Pod, Deployment, Service로 선언적으로 인프라를 관리한다.

면접에서 “Docker를 왜 사용하나요?”에 단순히 “환경 통일”이 아니라, Linux 커널 기술의 원리, 이미지 레이어의 캐싱 전략, CI/CD 파이프라인과의 연계, 배포 전략의 트레이드오프까지 설명할 수 있어야 한다.