← 글 목록

개발 기록

모노레포에서 변경된 서비스만 배포하는 CI/CD 구성

하나의 저장소에서 여러 서비스를 관리하면서 서비스별 빌드와 배포를 따로 처리해야 했습니다.

처음에는 서비스와 배포 환경마다 워크플로를 직접 작성했습니다. 서비스가 늘어나면서 같은 설정이 반복됐고, 변경되지 않은 서비스까지 함께 빌드되는 문제가 생겼습니다.

이번 글에서는 변경된 서비스만 검증하고 배포할 수 있도록 GitHub Actions와 Turborepo를 이용해 CI/CD를 구성한 과정을 정리해 보려 합니다.

매번 전체를 빌드할 필요가 있을까

Pull Request에서는 변경된 패키지를 검증하고, 배포할 때도 변경된 서비스만 빌드하고 싶었습니다. 다만 공통 패키지가 바뀌면 이를 사용하는 서비스도 함께 확인하고 배포해야 했습니다.

개발·검증·운영 환경은 나누되, 서비스 정보는 한 곳에서 관리하도록 했습니다. 변경 사항에 따른 자동 배포와 서비스를 직접 선택하는 수동 배포도 모두 필요했습니다.

전체 흐름은 다음과 같습니다.

Pull Request
    ↓
변경 범위 확인
    ↓
Lint / Type Check / Build
    ↓
브랜치 병합
    ↓
배포 대상 생성
    ↓
서비스별 병렬 배포

1. 서비스별 워크플로 관리의 문제

초기에는 서비스별 Job을 워크플로에 직접 작성했습니다.

jobs:
  deploy-service-a:
    steps:
      - run: pnpm --filter service-a build
      - run: deploy service-a

  deploy-service-b:
    steps:
      - run: pnpm --filter service-b build
      - run: deploy service-b

개발, 검증, 운영 환경까지 분리하면서 비슷한 워크플로가 여러 개 생겼습니다.

서비스가 추가될 때마다 다음 내용을 반복해서 수정해야 했습니다.

  • 서비스 경로
  • 빌드 명령
  • 빌드 결과물 경로
  • 스토리지 업로드 경로
  • CDN 대상
  • 환경별 활성화 여부

배포 동작은 같고 값만 다른데도 Job 전체를 복사해야 했습니다.

이 구조에서는 일부 워크플로만 수정하거나 서비스 경로를 잘못 입력하기 쉬웠습니다.

2. CI에서 변경된 패키지만 검증

모노레포 전체를 매번 검사할 필요는 없었습니다.

Turborepo의 변경 감지 기능을 이용해 기준 브랜치와 현재 커밋 사이에서 영향을 받은 패키지만 검사했습니다.

- name: Lint
  run: pnpm turbo run lint --affected

- name: Type check
  run: pnpm turbo run typecheck --affected

- name: Build
  run: pnpm turbo run build --affected

비교 기준이 명확하도록 기준과 현재 커밋도 환경 변수로 전달했습니다.

env:
  TURBO_SCM_BASE: origin/${{ github.base_ref }}
  TURBO_SCM_HEAD: ${{ github.sha }}

서비스 자체가 변경되지 않아도 서비스가 사용하는 공통 패키지가 변경되면 영향 범위에 포함됩니다.

이렇게 하면 전체 패키지를 매번 검사하지 않으면서도, 공통 패키지 변경의 영향을 받는 서비스는 함께 확인할 수 있었습니다.

3. Matrix를 이용한 공통 배포 Job

서비스마다 Job을 만들던 구조를 하나의 Matrix Job으로 변경했습니다.

strategy:
  fail-fast: false
  matrix:
    app: ${{ fromJson(needs.prepare.outputs.matrix) }}

배포 단계에서는 Matrix로 전달받은 값만 사용합니다.

- name: Build
  run: pnpm --filter "${{ matrix.app.buildFilter }}" build

- name: Upload
  run: |
    aws s3 sync \
      "${{ matrix.app.output }}" \
      "${{ matrix.app.uploadPath }}" \
      --delete

서비스가 달라도 실제 배포 순서는 같습니다.

의존성 설치
→ 서비스 빌드
→ 결과물 업로드
→ CDN 캐시 무효화

배포 순서가 같으니 워크플로는 하나로 두고, 서비스마다 달라지는 값만 전달하도록 했습니다.

4. 서비스 설정 분리

처음에는 Matrix에 서비스 정보를 직접 작성했습니다.

matrix:
  include:
    - name: service-a
      buildFilter: ./apps/service-a...
      output: ./apps/service-a/dist
    - name: service-b
      buildFilter: ./apps/service-b...
      output: ./apps/service-b/dist

이 방식도 Job 중복은 줄여주지만 환경별 워크플로마다 같은 서비스 정보를 작성해야 했습니다.

서비스 정보를 별도의 설정 파일로 분리했습니다.

{
  "services": [
    {
      "key": "service-a",
      "changePrefixes": ["apps/service-a/"],
      "buildFilter": "./apps/service-a...",
      "output": "./apps/service-a/dist",
      "environments": {
        "development": {
          "enabled": true,
          "uploadPath": "development/service-a",
          "cdnVariable": "CDN_TARGET_SERVICE_A_DEV"
        },
        "staging": {
          "enabled": true,
          "uploadPath": "staging/service-a",
          "cdnVariable": "CDN_TARGET_SERVICE_A_STAGING"
        },
        "production": {
          "enabled": false,
          "uploadPath": "production/service-a",
          "cdnVariable": "CDN_TARGET_SERVICE_A_PROD"
        }
      }
    }
  ]
}

워크플로는 이 설정을 읽어 현재 환경에서 배포 가능한 서비스만 Matrix로 생성합니다.

새로운 서비스를 추가할 때도 워크플로 전체를 수정하지 않고 설정만 추가하면 됩니다.

운영 배포가 준비되지 않은 서비스는 다음과 같이 비활성화할 수 있습니다.

{
  "production": {
    "enabled": false
  }
}

개발과 검증 환경에서 먼저 확인한 뒤 운영 환경만 활성화할 수 있도록 했습니다.

5. 자동 배포 대상 확인

자동 배포에서는 이전 커밋과 현재 커밋 사이의 파일 목록을 확인했습니다.

git diff --name-only "$BASE_SHA" "$HEAD_SHA"

변경된 경로가 서비스의 changePrefixes와 일치하면 해당 서비스를 배포 대상에 추가합니다.

apps/service-a/src/page.tsx
        ↓
service-a 변경으로 판단
        ↓
service-a만 배포

반대로 공통 설정이나 공통 패키지가 변경되면 특정 서비스만 선택하기 어렵습니다.

다음과 같은 파일이 변경되면 활성화된 서비스를 모두 배포하도록 처리했습니다.

공통 패키지
Workspace 설정
Lock 파일
빌드 설정
배포 Workflow

배포 대상을 확실히 판단할 수 없는 경우에는 일부 서비스가 누락되는 것보다 전체 서비스를 배포하는 편이 안전하다고 판단했습니다.

6. 수동 배포와 자동 배포 분리

자동 배포에서는 변경된 경로를 기준으로 대상을 생성했습니다.

수동 배포에서는 사용자가 선택한 서비스만 Matrix에 포함했습니다.

자동 실행
→ 변경 파일 확인
→ 관련 서비스 배포

수동 실행
→ 선택한 서비스 확인
→ 해당 서비스 배포

전체 서비스를 다시 배포해야 하는 경우를 위해 all 옵션도 추가했습니다.

아직 설정되지 않은 서비스를 선택하면 실행을 건너뛰지 않고 명확하게 실패하도록 처리했습니다.

if [[ "$ENABLED" != "true" ]]; then
  echo "Selected service is not configured."
  exit 1
fi

설정이 빠졌는데도 배포가 성공한 것처럼 보이지 않도록 하기 위해서였습니다.

7. 워크플로 출력에 로그가 섞인 문제

배포 Matrix를 만드는 스크립트의 결과를 다음 단계로 전달하기 위해 $GITHUB_OUTPUT을 사용했습니다.

run: |
  bash create-deploy-matrix.sh >> "$GITHUB_OUTPUT"

스크립트는 다음 형식만 표준 출력으로 보내야 합니다.

has_targets=true
matrix={"include":[...]}

하지만 변경 파일을 확인하기 위해 출력했던 로그도 표준 출력에 포함돼 있었습니다.

echo "Changed files:"
echo "$changed_files"

리다이렉션은 스크립트의 모든 표준 출력을 $GITHUB_OUTPUT에 기록합니다. 이 때문에 로그가 GitHub Actions 출력 형식으로 해석되면서 Matrix 생성 단계가 실패할 수 있었습니다.

워크플로에서 생성된 JSON만 확인했을 때는 정상으로 보였지만, 실제 출력 파일을 기준으로 확인하니 디버깅 로그까지 함께 들어가 있었습니다.

일반 로그는 표준 오류로 분리했습니다.

echo "Changed files:" >&2
echo "$changed_files" >&2

이후 표준 출력에는 다음 단계로 전달할 값만 남게 됐습니다.

stdout → GitHub Actions 출력값
stderr → 실행 로그

8. CDN 캐시 무효화 경로 문제

같은 스토리지와 CDN을 공유하는 방식에서는 서비스별 경로를 포함해 캐시를 제거해야 했습니다.

/development/service-a/*

하지만 독립된 배포 대상을 사용하는 방식에서는 이미 해당 서비스의 루트를 바라보고 있었습니다.

이 경우 같은 경로를 다시 전달하면 실제 리소스 경로와 일치하지 않습니다.

서비스 전용 배포 대상
    ↓
/*

배포는 성공했는데 변경된 파일이 바로 보이지 않아, CDN 캐시를 지우는 요청을 확인했습니다.

배포 대상 종류에 따라 경로를 분리했습니다.

if [[ "$TARGET_TYPE" == "dedicated" ]]; then
  invalidation_path="/*"
else
  invalidation_path="/${UPLOAD_PREFIX}/*"
fi

파일을 올리는 경로와 CDN이 바라보는 경로를 같다고 생각했던 것이 문제였습니다.

9. 얕은 Checkout으로 변경 감지가 누락된 문제

변경된 서비스를 확인하려면 기준 커밋과 현재 커밋이 모두 로컬 Git 이력에 존재해야 합니다.

처음에는 Checkout 범위를 최근 두 개의 커밋으로 제한했습니다.

with:
  fetch-depth: 2

간단한 변경에서는 정상적으로 동작했지만 Pull Request에 여러 커밋이 포함되거나 병합 이력이 추가되면 기준 커밋이 Checkout 범위 밖에 있을 수 있습니다.

이 경우 애플리케이션 코드가 변경됐는데도 변경 대상이 없다고 판단할 수 있었습니다.

변경 감지에 사용한 기준 SHA를 Checkout된 이력에서 찾아보니, 해당 커밋이 로컬에 없는 경우가 있었습니다.

배포 대상 감지가 정확해야 하는 워크플로에서는 전체 이력을 가져오도록 변경했습니다.

with:
  fetch-depth: 0

Checkout 시간이 조금 늘더라도, 기준 커밋을 찾지 못해 배포가 누락되는 쪽을 먼저 막기로 했습니다.

정리

처음에는 서비스마다 Job을 복사했지만, 실제로 다른 부분은 서비스 경로와 빌드 결과물, 환경별 배포 위치였습니다. 이 값을 설정으로 옮기고 Matrix를 만들면서 기존 Job을 복사하지 않고도 서비스를 추가할 수 있게 됐습니다.

CI에서는 영향받은 패키지를 검증하고, 배포에서는 변경 파일이나 수동으로 선택한 서비스를 기준으로 대상을 정했습니다. 환경별로 활성화된 서비스만 포함한 뒤 빌드와 배포를 병렬로 실행했습니다. 공통 파일이 바뀌어 대상을 확실히 좁히기 어려울 때는 전체 배포로 처리했습니다.

변경 대상을 줄이는 것만큼, 대상을 잘못 판단해 필요한 배포가 빠지지 않도록 하는 것도 중요했습니다.