모노레포에서 브랜치별 파일 변경 범위를 제한하는 GitHub Actions 워크플로우

2026-02-26 hit count image

모노레포에서 브랜치명 기반으로 PR의 변경된 파일이 해당 서비스 디렉토리 범위 내인지 자동으로 검증하는 GitHub Actions 워크플로우를 구현하는 방법을 알아봅니다.

github_actions

배경: 왜 파일 변경 범위를 제한해야 할까?

여러 서비스가 하나의 레포지토리에 공존하는 모노레포 구조에서는, 한 서비스의 변경이 다른 서비스에 의도치 않은 영향을 줄 수 있습니다. 예를 들어 다음과 같은 구조를 가진 레포지토리를 생각해 봅시다.

apps/
├── service-a/
├── service-b/
├── service-c/
└── ...

모노레포에서는 각 서비스 팀은 feature/service-a/..., feature/service-b/...처럼 브랜치명에 서비스 이름을 포함하는 컨벤션을 만들어 사용할 수 있습니다. 이런 컨벤션에 관해서는 이전 블로그 포스트를 참고해 주세요.

브랜치명을 검사하는 워크플로우를 통해 네이밍 컨벤션을 강제할 수는 있지만, 브랜치명 컨벤션만으로는 다른 서비스 코드를 수정하는 실수를 완전히 방지할 수 없습니다. 예를 들어 service-a 브랜치에서 작업하다가 실수로 service-b 디렉토리의 파일을 수정해도 브랜치명 검사 워크플로우는 이를 검출해 낼 수 없습니다.

이 문제를 해결하기 위해 PR에서 변경된 파일이 브랜치에 해당하는 서비스 디렉토리 내의 파일인지 자동으로 검증하는 워크플로우를 만들었습니다. PR 제목 검사와 함께 사용하면 PR의 메타데이터와 변경 내용 모두를 자동으로 검증할 수 있습니다.

워크플로우 전체 구조

name: Validate File Changes by Branch

on:
  pull_request:
    types: [opened, reopened, edited, synchronize]

워크플로우는 PR이 열리거나 업데이트될 때마다 실행됩니다. 전체 흐름은 4단계로 이루어집니다.

Step 1: 브랜치명에서 서비스명 추출

feature/service-a/some-feature  →  SERVICE_NAME = "service-a"
fix/service-b/bug-fix           →  SERVICE_NAME = "service-b"

Composite Action으로 만든 커스텀 액션 extract_branch_and_service_name이 브랜치명을 /로 분리하여 두 번째 부분을 서비스명으로 추출합니다.

.github/actions/extract_branch_and_service_name/action.yml 파일을 만들고 다음과 같이 작성해서 사용하고 있습니다.

name: 'Extract branch and service name'
description: 'Extract branch and service name from branch'

outputs:
  BRANCH_NAME:
    description: 'Branch name'
    value: ${{ steps.extract_branch_and_service_name.outputs.BRANCH_NAME }}
  SERVICE_NAME:
    description: 'Service name'
    value: ${{ steps.extract_branch_and_service_name.outputs.SERVICE_NAME }}
  FULL_BRANCH_NAME:
    description: 'Full branch name'
    value: ${{ steps.extract_branch_and_service_name.outputs.FULL_BRANCH_NAME }}

runs:
  using: 'composite'
  steps:
    - id: extract_branch_and_service_name
      shell: bash
      run: |
        FULL_BRANCH_NAME=${{ github.event.pull_request.head.ref }}

        BRANCH_NAME=""
        SERVICE_NAME=""

        if [[ $FULL_BRANCH_NAME == "main" || $FULL_BRANCH_NAME == "develop" ]]; then
          BRANCH_NAME="$FULL_BRANCH_NAME"
          SERVICE_NAME="$FULL_BRANCH_NAME"
        else
          IFS='/' read -ra BRANCH_PARTS <<< "$FULL_BRANCH_NAME"

          if [ "${#BRANCH_PARTS[@]}" -gt 1 ]; then
            BRANCH_NAME=${BRANCH_PARTS[0]}
            SERVICE_NAME=${BRANCH_PARTS[1]}
          else
            echo "[Error] Branch name does not contain a valid service name."
            exit 1
          fi
        fi

        echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT
        echo "SERVICE_NAME=$SERVICE_NAME" >> $GITHUB_OUTPUT
        echo "FULL_BRANCH_NAME=$FULL_BRANCH_NAME" >> $GITHUB_OUTPUT

예를 들어, feature/service-a/some-feature라는 브랜치에서는 service-a가 서비스명이 됩니다.

Step 2: 변경된 파일 목록 수집

- name: Get changed files
  uses: tj-actions/[email protected]
  with:
    files: |
      **
    write_output_files: true
    output_dir: /tmp

tj-actions/changed-files 액션으로 PR에서 변경된 파일 목록을 가져옵니다. 이 액션은 ESLint를 변경된 파일에만 적용하는 워크플로우에서도 활용한 바 있습니다.

여기서 핵심은 write_output_files: true 옵션입니다. 변경 파일 목록을 환경 변수가 아닌 /tmp/all_changed_files.txt 파일로 저장합니다. 이렇게 파일로 저장하는 이유는 변경 파일이 많은 경우 환경 변수의 길이 제한(ARG_MAX)에 걸릴 수 있기 때문입니다.

Step 3: 서비스명과 디렉토리 매핑

const serviceDirectoryMap = {
  'service-a': 'serviceA',
  'service-b': 'serviceB',
  'service-c': 'serviceC',
  // ...
};

브랜치명의 서비스 식별자와 실제 apps/ 하위 디렉토리명이 다를 수 있으므로, 매핑 테이블을 통해 변환합니다. 예를 들어, 브랜치명에서는 service-a라고 쓰지만 실제 디렉토리명은 serviceA일 수 있습니다.

매핑에 존재하지 않는 서비스명(예: common 브랜치)이면 검증을 건너뜁니다. 서비스가 아닌 공통 모듈은 여러 서비스에 영향을 줄 수 있기 때문에, 파일 변경 범위를 제한할 필요가 없습니다.

Step 4: 파일별 검증

변경된 각 파일에 대해 경로의 두 번째 부분(서비스 디렉토리)이 해당 서비스의 디렉토리와 일치하는지 확인합니다.

for (const filename of files) {
  const pathParts = filename.split('/');
  const fileServiceType = pathParts[1]; // apps/serviceA/... → "serviceA"

  if (fileServiceType !== serviceDirectory) {
    core.setFailed(
      `The file ${filename} is not allowed to be changed in the ${serviceName} branch.`
    );
    return;
  }
}

단, 일부 파일은 예외적으로 허용합니다:

허용 경로이유
packages/lib/shared-api/공유 API 모듈은 프로덕트 간 영향이 없으므로 각 서비스에서 자유롭게 수정 가능
packages/config/cspell/맞춤법 사전은 공통 리소스
yarn.lock어떤 서비스에서든 라이브러리를 추가하면 공통으로 변경됨

실행 조건

모든 PR에서 실행될 필요는 없습니다. 다음과 같이 특정 브랜치(배포용 브랜치, 메인 브랜치 등)은 검증 대상에서 제외됩니다:

if: >
  github.head_ref != 'main' &&
  !startsWith(github.head_ref, 'release/') &&
  !startsWith(github.head_ref, 'merge/')
  • main: 통합 브랜치이므로 모든 디렉토리 변경이 허용
  • release/*: 릴리스 준비 과정에서 여러 서비스의 파일이 변경될 수 있음
  • merge/*: 브랜치 통합 시 여러 디렉토리에 걸친 변경이 예상됨

동작 예시

성공 케이스feature/service-a/add-login 브랜치에서 아래 파일들만 변경:

apps/serviceA/src/pages/Login.tsx          ✅ serviceA 디렉토리
apps/serviceA/src/api/auth.ts              ✅ serviceA 디렉토리
packages/lib/shared-api/src/auth.ts        ✅ 허용 예외
yarn.lock                                  ✅ 허용 예외

실패 케이스feature/service-a/add-login 브랜치에서 다른 서비스 파일 변경:

apps/serviceA/src/pages/Login.tsx    ✅ serviceA 디렉토리
apps/serviceB/src/utils/auth.ts     ❌ service-a 브랜치에서 serviceB 파일 변경 불가!

The file apps/serviceB/src/utils/auth.ts is not allowed to be changed in the service-a branch.

마무리

이 워크플로우는 코드 리뷰 전에 “이 PR이 올바른 범위의 파일만 수정했는가”를 자동으로 확인해 줍니다. 사람의 실수는 완전히 막을 수 없지만, CI 수준에서 가드레일을 세워두면 실수가 코드베이스에 반영되기 전에 잡아낼 수 있습니다.

모노레포를 운영하면서 서비스 간 경계를 명확히 하고 싶다면, 이런 파일 변경 범위 검증을 도입해 보는 것을 추천합니다.

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.



SHARE
Twitter Facebook RSS