포스트

프로젝트 CD 최적화: 변경된 모듈만 배포하기

이전 글에서 CI 최적화를 통해 변경된 모듈만 테스트하는 방법을 다뤘다면, 이번에는 CD(Continuous Delivery)를 최적화 방법을 정리해보려 한다.


I. 문제 상황: 불필요한 배포

멀티모듈 프로젝트에서 CD를 구성할 때 마주하는 가장 큰 문제는 불필요한 배포가 너무 자주 발생한다는 것이다.

예를 들어 프로젝트 구조가 다음과 같다고 가정해보자.

1
2
3
4
5
6
7
connectors-backend/
├── presentation/
│   ├── api/              # API 서버
│   └── batch/            # Batch 서버
├── user/                 # 사용자 도메인
├── order/                # 주문 도메인
└── payment/              # 결제 도메인

만약 payment 모듈만 수정했는데, api 서버와 batch 서버가 모두 재배포된다면? 혹은 batch 서버 관련 코드만 수정했는데 api 서버까지 배포된다면? 이는 명백한 리소스 낭비이며, 불필요한 다운타임을 발생시킬 수 있다.

또한 개발 환경과 프로덕션 환경의 배포 전략이 동일하다면, 실험적인 기능을 개발 환경에 먼저 배포하고 검증하는 과정이 어려워진다.

II. 해결 방안: Path 기반 배포 + 브랜치 전략

이 문제를 해결하기 위해 다음과 같은 전략을 세웠다.

  1. Path 기반 배포 트리거: 특정 경로의 파일이 변경되었을 때만 배포 실행
  2. 브랜치 기반 환경 분리: dev 브랜치는 개발 서버로, prod 브랜치는 프로덕션 서버로 배포
  3. Docker 이미지 캐싱: GitHub Container Registry와 Docker Buildx 캐시를 활용해 빌드 시간 단축

III. 구현 상세

1. Path 기반 배포 트리거

1
2
3
4
5
6
7
8
9
on:
  push:
    branches: [ dev, prod ]
    paths:
      - 'presentation/api/**'
      - 'user/**'
      - 'common/**'
      - 'setting/**'
      - 'core/**'

워크플로우의 핵심은 paths 필터다. 지정된 경로의 파일이 변경되었을 때만 워크플로우가 실행된다.

1-1. API 서버 배포가 필요한 경우

1
2
3
4
5
presentation/api/**    # API 서버 자체 코드 변경
user/**               # 사용자 도메인 변경 (API에서 사용)
common/**             # 공통 모듈 변경 (전체 영향)
setting/**            # 인프라 설정 변경 (DB, Redis 등)
core/**               # 전역 설정 변경

만약 order 모듈만 수정되고 API 서버에서 사용하지 않는다면, 해당 경로는 paths에 포함되지 않아 배포가 트리거되지 않는다. 이를 통해 실제로 영향받는 서버만 선택적으로 배포할 수 있다.

1-2. 여러 서버의 독립적 배포

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# continuous-delivery-api.yml
on:
  push:
    paths:
      - 'presentation/api/**'
      - 'user/**'
      - 'common/**'

# continuous-delivery-batch.yml
on:
  push:
    paths:
      - 'presentation/batch/**'
      - 'order/**'
      - 'common/**'

API 서버와 Batch 서버의 배포 워크플로우를 분리하고, 각각 다른 paths 필터를 적용하면 서로 영향을 주지 않는 독립적인 배포가 가능하다.

2. 브랜치 기반 환경 분리

1
2
3
4
5
6
7
8
9
10
11
12
13
env:
  GCR_PACKAGE_NAME: ${{ github.ref_name == 'prod' && 'prod-api-server' || 'dev-api-server' }}

jobs:
  deploy:
    steps:
      - name: Deploy to DEV EC2
        if: github.ref_name == 'dev'
        # ... DEV 배포 로직
      
      - name: Deploy to PROD EC2
        if: github.ref_name == 'prod'
        # ... PROD 배포 로직

2-1. 브랜치별 Docker 이미지 분리

github.ref_name을 활용해 브랜치에 따라 서로 다른 Docker 이미지 이름을 사용한다.

1
2
dev 브랜치 push  → ghcr.io/organization/dev-api-server:latest
prod 브랜치 push → ghcr.io/organization/prod-api-server:latest

이를 통해 개발 환경과 프로덕션 환경의 이미지가 완전히 분리해, 개발 중인 기능이 프로덕션에 영향을 주는 일을 방지한다.

2-2. 조건부 배포 스텝

1
2
3
4
5
6
7
8
9
10
11
- name: Deploy to DEV EC2
  if: github.ref_name == 'dev'
  with:
    host: ${{ secrets.DEV_EC2_INSTANCE }}
    # ...

- name: Deploy to PROD EC2
  if: github.ref_name == 'prod'
  with:
    host: ${{ secrets.PROD_EC2_INSTANCE }}
    # ...

if 조건을 사용해 현재 브랜치에 맞는 배포 스텝만 실행한다. dev 브랜치에서는 DEV EC2로, prod 브랜치에서는 PROD EC2로 배포된다.

3. 환경 설정 파일 동적 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- name: Create configuration files
  run: |
    create_config() {
      local content="$1"
      local path="$2"
      if [ -z "$content" ]; then
        echo "❌ Error: Content is empty for $path"
        exit 1
      fi
      mkdir -p $(dirname "$path")
      echo "$content" > "$path"
      echo "✅ Created $path"
    }

    create_config "$APPLICATION_YML" "presentation/api/src/main/resources/application.yml"
    create_config "$CACHE_YML" "setting/cache/src/main/resources/cache.yml"
    create_config "$DATABASE_YML" "setting/database/src/main/resources/database.yml"

환경 설정 파일을 GitHub Secrets에 저장하고, 배포 시점에 동적으로 생성한다. 이를 통해 다음과 같은 이점을 얻을 수 있다.

  1. 보안: 민감한 정보(DB 비밀번호, API 키 등)가 코드 저장소에 노출되지 않음
  2. 유연성: 브랜치별로 다른 설정 파일을 주입할 수 있음
  3. 검증: 설정 파일이 비어있는지 확인하는 로직으로 배포 실패 방지

yaml 파일에서 spring.config.activate.on-profile: {env}를 통해 환경을 분리했기 때문에, Docker Image 실행 시점에 docker run -e SPRING_PROFILES_ACTIVE={env}를 통해 프로필을 결정할 수 있다.

4. Gradle 빌드 최적화

1
2
3
4
5
6
7
8
9
10
11
- name: Cache Gradle files
  uses: actions/cache@v3
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}

- name: Build with Gradle
  run: |
    ./gradlew :presentation:api:bootJar --parallel --build-cache -x test

4-1. Gradle 캐시 활용

actions/cache를 사용해 Gradle 의존성과 빌드 캐시를 저장한다. 특히 hashFiles('**/*.gradle*')를 키로 사용하여, build.gradle 파일이 변경되지 않았다면 이전 캐시를 재사용한다.

4-2. 특정 모듈만 빌드

1
./gradlew :presentation:api:bootJar --parallel --build-cache -x test

멀티모듈 프로젝트에서 전체를 빌드하지 않고, 배포가 필요한 모듈만 선택적으로 빌드한다.

  • :presentation:api:bootJar: API 모듈의 실행 가능한 JAR만 생성
  • --parallel: 병렬 빌드로 시간 단축
  • --build-cache: 빌드 캐시 활용
  • -x test: 테스트는 CI 단계에서 이미 완료했으므로 스킵

5. Docker 빌드 및 배포

1
2
3
4
5
6
7
8
9
10
11
12
13
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build, tag, and push image to GHCR
  uses: docker/build-push-action@v5
  with:
    context: .
    file: ./presentation/api/Dockerfile
    push: true
    tags: |
      ghcr.io/goldencompass2022/${{ env.GCR_PACKAGE_NAME }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

5-1. Docker Buildx 캐싱

cache-from: type=ghacache-to: type=gha,mode=max를 사용해 GitHub Actions의 캐시 스토리지에 Docker 레이어를 저장한다.

Base 이미지나 의존성이 변경되지 않았다면, 대부분의 레이어를 캐시에서 복원하여 빌드 시간이 대폭 단축된다.

5-2. EC2로 배포

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- name: Deploy to DEV EC2
  uses: appleboy/ssh-action@master
  if: github.ref_name == 'dev'
  with:
    host: ${{ secrets.DEV_EC2_INSTANCE }}
    script: |
      docker stop $GCR_PACKAGE_NAME || true
      docker rm $GCR_PACKAGE_NAME || true
      docker pull ghcr.io/organization/$GCR_PACKAGE_NAME:latest
      docker run -e SPRING_PROFILES_ACTIVE=dev \
          -d \
          --name $GCR_PACKAGE_NAME \
          -p 8080:8080 \
          --restart unless-stopped \
          ghcr.io/organization/$GCR_PACKAGE_NAME:latest

SSH를 통해 EC2 인스턴스에 접속하고, 다음 순서로 배포를 진행한다.

  1. 기존 컨테이너 중지 및 삭제: docker stop + docker rm
  2. 최신 이미지 풀: GitHub Container Registry에서 이미지 다운로드
  3. 새 컨테이너 실행: 환경 변수(SPRING_PROFILES_ACTIVE)와 함께 실행
  4. 자동 재시작 설정: --restart unless-stopped로 서버 재부팅 시 자동 시작

컨테이너가 존재하지 않을 때 발생하는 에러를 무시하기 위해 || true를 사용한다.

IV. 배포 전략 시각화

1. 전체 배포 플로우

graph LR
    A[코드 Push] --> B{브랜치 확인}
    B -->|dev| C[DEV 배포 트리거]
    B -->|prod| D[PROD 배포 트리거]
    C --> E{Path 필터}
    D --> E
    E -->|매칭| F[환경 설정 생성]
    E -->|불일치| Z[배포 스킵]
    F --> G[Gradle 빌드]
    G --> H[Docker 이미지 빌드]
    H --> I[GHCR에 Push]
    I --> J{브랜치 확인}
    J -->|dev| K[DEV EC2 배포]
    J -->|prod| L[PROD ECS 배포]

2. Path 기반 배포 예시

변경된 파일API 배포Batch 배포
presentation/api/Controller.java
presentation/batch/Job.java
user/domain/User.java
common/util/StringUtil.java
order/domain/Order.java

공통 모듈(common, core)이 변경되면 모든 서버가 재배포되지만, 특정 도메인 모듈만 변경되면 해당 모듈을 사용하는 서버만 배포된다.

V. 브랜치 전략과의 통합

이 배포 전략은 Git Flow 브랜치 전략과 자연스럽게 통합된다.

graph LR
    A[feature/*] -->|PR| B[dev]
    B -->|DEV 서버 자동 배포| C[DEV 환경]
    B -->|테스트 완료| D[prod]
    D -->|PROD 서버 자동 배포| E[PROD 환경]

1. 개발 플로우

1
2
3
4
5
6
1. feature 브랜치에서 개발
2. dev 브랜치로 PR 생성 → CI 실행 (변경된 모듈만 테스트)
3. PR 머지 → CD 실행 (변경된 서버만 DEV 환경에 배포)
4. DEV 환경에서 QA 진행
5. prod 브랜치로 PR 생성 → CI 실행
6. PR 머지 → CD 실행 (변경된 서버만 PROD 환경에 배포)

2. 핫픽스 플로우

1
2
3
4
1. prod 브랜치에서 hotfix 브랜치 생성
2. 버그 수정
3. prod 브랜치로 직접 머지 → PROD 환경 배포
4. dev 브랜치로 백포트 → DEV 환경 동기화

VI. 마치며

사실 CI 때 만들었던 변경 모듈 감지 기능(detect-changes.yml)을 활용해 배포 프로세스를 만드려 했었다. 하지만 모듈간 의존성 처리에 있어서 너무 많은 애를 먹어서, 일단 급한대로 path를 통해 의존성을 관리하는 방향으로 선회했다. 추후 기능 개선을 통해 gradle 의존성 트리를 기준으로 배포할 수 있도록 스크립트를 개선해나갈 예정이다. 또한 해당 프로젝트에서 ECS를 활용할 예정이기에, GHCR이 아닌 ECR을 사용하는 방향으로도 변경 예정이다. 개선에 성공하면 다시 한번 포스트 작성하도록 하겠당…

VII. 참고

  1. GitHub Actions - Workflow syntax
  2. Docker Buildx - Cache backends
  3. GitHub Container Registry
  4. Gradle Build Cache
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.