포스트

프로젝트 CI 최적화: 변경된 모듈만 테스트하기

멀티모듈 프로젝트가 커질수록 CI 파이프라인의 실행 시간이 기하급수적으로 증가한다. 특히 단일 모듈만 수정했을 뿐인데 전체 모듈의 테스트가 실행되면, 개발자는 불필요하게 긴 시간을 기다려야 한다. 이번 글에서는 GitHub Actions를 활용해 변경된 모듈만 감지하고, Matrix Strategy로 병렬 테스트를 수행하는 방법을 정리해보려 한다.


I. 문제 상황

멀티모듈 프로젝트에서 CI를 구성할 때 가장 먼저 마주하는 문제는 테스트 시간이다. 프로젝트가 10개의 모듈로 구성되어 있고, 각 모듈의 테스트가 평균 3분씩 걸린다고 가정해보자. 만약 CI가 모든 모듈을 순차적으로 테스트한다면, PR 하나당 30분을 기다려야 한다.

1
2
3
4
5
6
7
8
9
auth-domain 테스트 (3분)
  ↓
auth-app 테스트 (3분)
  ↓
auth-infra 테스트 (3분)
  ↓
member-domain 테스트 (3분)
  ↓
... (계속)

여기서 더 큰 문제는 단일 모듈만 수정했음에도 불구하고 모든 모듈을 테스트한다는 것이다. auth-domain의 단순한 로직 하나를 수정했는데, 전혀 관련 없는 order, payment 모듈까지 테스트가 실행된다. 이는 명백한 리소스 낭비이며, 개발자 경험을 저해하는 요소다.

II. 해결 방안: 변경 감지 + Matrix Strategy

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

  1. 변경 감지(Change Detection): git diff를 활용해 PR에서 실제로 변경된 모듈만 식별
  2. 동적 Matrix 생성: 변경된 모듈 목록을 JSON 배열로 변환
  3. 병렬 테스트: GitHub Actions의 Matrix Strategy를 활용해 각 모듈을 독립적으로 병렬 실행

이를 구현하기 위해 세 개의 워크플로우 파일을 작성했다.

1
2
3
4
5
continuous-integration.yml     # 메인 워크플로우
  ↓
detect-changes.yml             # 변경된 모듈 감지
  ↓
test-unit.yml                  # Matrix로 병렬 테스트

III. 구현 상세

1. 메인 워크플로우 (continuous-integration.yml)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
name: Continuous Integration

on:
  pull_request:
    branches: [ dev ]

jobs:
  detect-changes:
    uses: ./.github/workflows/detect-changes.yml
  
  test:
    needs: detect-changes
    if: needs.detect-changes.outputs.has-changes == 'true'
    uses: ./.github/workflows/test-unit.yml
    with:
      modules: ${{ needs.detect-changes.outputs.modules }}

메인 워크플로우는 매우 간단하다. detect-changes job에서 변경된 모듈을 감지하고, has-changestrue일 때만 테스트를 실행한다. 변경된 모듈이 없다면 테스트를 건너뛰어 불필요한 CI 실행을 방지한다.

2. 변경 감지 워크플로우 (detect-changes.yml)

변경 감지 워크플로우는 이 구조의 핵심이다. 크게 세 단계로 나뉜다.

2-1. 변경된 파일 목록 추출

1
2
3
4
5
6
7
if [ "${{ github.event_name }}" == "pull_request" ]; then
  BASE_SHA=${{ github.event.pull_request.base.sha }}
else
  BASE_SHA=HEAD~1
fi

CHANGED_FILES=$(git diff --name-only $BASE_SHA HEAD)

PR의 경우 base 브랜치와 비교하고, 그 외의 경우는 직전 커밋과 비교한다. git diff --name-only를 통해 변경된 파일의 경로만 추출한다.

2-2. 파일 경로를 Gradle 모듈로 변환

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
echo "$CHANGED_FILES" | while read -r file; do
  if [ -z "$file" ]; then
    continue
  fi

  dir=$(dirname "$file")
  current_dir="$dir"
  
  # 상위로 올라가면서 build.gradle 찾기
  while [ "$current_dir" != "." ] && [ "$current_dir" != "/" ]; do
    if [ -f "$current_dir/build.gradle" ] || [ -f "$current_dir/build.gradle.kts" ]; then
      gradle_path=":${current_dir//\//:}"
      echo "$gradle_path" >> /tmp/changed_modules.txt
      break
    fi
    current_dir=$(dirname "$current_dir")
  done
done

이 부분이 가장 중요한 로직이다. 변경된 각 파일에 대해 상위 디렉토리로 거슬러 올라가며 build.gradle 파일을 찾는다. Gradle 멀티모듈 프로젝트에서 build.gradle이 있는 디렉토리는 하나의 모듈로 간주할 수 있기 때문이다.

예를 들어 auth/auth-domain/src/main/java/Service.java 파일이 변경되었다면:

  1. auth/auth-domain/src/main/java → build.gradle 없음
  2. auth/auth-domain/src/main → build.gradle 없음
  3. auth/auth-domain/src → build.gradle 없음
  4. auth/auth-domain → build.gradle 발견!

이렇게 찾은 경로를 Gradle 형식(:auth:auth-domain)으로 변환한다.

2-3. JSON 배열로 출력

1
2
3
4
5
6
7
8
9
10
if [ -f /tmp/changed_modules.txt ]; then
  CHANGED_MODULES_UNIQUE=$(cat /tmp/changed_modules.txt | sort -u)
  MODULES_JSON=$(echo "$CHANGED_MODULES_UNIQUE" | jq -R -s -c 'split("\n") | map(select(length > 0))')
  
  echo "has-changes=true" >> $GITHUB_OUTPUT
  echo "modules=$MODULES_JSON" >> $GITHUB_OUTPUT
else
  echo "has-changes=false" >> $GITHUB_OUTPUT
  echo "modules=[]" >> $GITHUB_OUTPUT
fi

중복을 제거한 모듈 목록을 jq를 사용해 JSON 배열로 변환한다. 이렇게 생성된 JSON 배열은 다음 워크플로우의 Matrix 입력으로 사용된다.

1
[":auth:auth-domain", ":auth:auth-app", ":member:member-domain"]

3. 테스트 워크플로우 (test-unit.yml)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
jobs:
  unit-test:
    runs-on: ubuntu-latest
    name: ${{ matrix.module }}

    strategy:
      fail-fast: false
      matrix:
        module: ${{ fromJSON(inputs.modules) }}

    steps:
      - name: Run unit tests for ${{ matrix.module }}
        run: |
          ./gradlew ${{ matrix.module }}:test \
            --build-cache \
            --parallel \
            --no-daemon

Matrix Strategy의 핵심은 fromJSON(inputs.modules)이다. 이전 워크플로우에서 전달받은 JSON 배열을 파싱해 각 모듈별로 독립적인 job을 생성한다.

1
2
3
auth:auth-domain:test     (Job 1) ─┐
member:member-domain:test (Job 2) ─┼─ 병렬 실행
order:order-app:test      (Job 3) ─┘

fail-fast: false는 하나의 모듈 테스트가 실패해도 다른 모듈의 테스트를 계속 진행하도록 한다. 이를 통해 어떤 모듈에서 문제가 발생했는지 명확하게 파악할 수 있다.

3-1. Gradle 캐시 활용

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

Gradle 빌드 캐시를 활용하면 의존성 다운로드 시간을 대폭 줄일 수 있다. 특히 병렬로 실행되는 여러 job이 동일한 캐시를 공유하기 때문에 효율성이 더욱 높아진다.

IV. 예상 효과

위 구조를 적용했을 때, 예상되는 최적화 결과는 아래와 같다. 비교적 모듈의 변화 폭이 작다면, 가장 테스트 실행 시간이 긴 모듈에 맞춰 테스트가 진행되겠지만 만약 변화되는 모듈이 많다면 GitActions 성능에 따른 병목이 남아있기는 할 것으로 예상된다. 정말 아쉽지만 아직 3개 이상 모듈을 수정한 적이 없어서, 추후 그런 케이스가 있다면 실제 결과를 추가할 예정이다.

Before: 전체 모듈 순차 테스트

1
2
총 10개 모듈 × 3분 = 30분
(변경 사항이 1개 모듈뿐이어도 동일)

After: 변경된 모듈만 병렬 테스트

1
2
3
4
5
6
7
8
9
상황 1: 3개 모듈 변경
  - 순차 실행: 9분
  - 병렬 실행: 3분 (각 job이 동시 실행)

상황 2: 10개 모듈 변경
  - 순차 실행: 30분
  - 병렬 실행: 10분 (GitActions 머신 스펙에 따른 병목이 있을 것으로 예상)

평균 개선율 : 30분 → 10분 (66% 감소)

V. 참고

  1. GitHub Actions - Reusing workflows
  2. GitHub Actions - Using a matrix for your jobs
  3. Gradle Build Cache
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.