목차
배경: 왜 파일 변경 범위를 제한해야 할까?
여러 서비스가 하나의 레포지토리에 공존하는 모노레포 구조에서는, 한 서비스의 변경이 다른 서비스에 의도치 않은 영향을 줄 수 있습니다. 예를 들어 다음과 같은 구조를 가진 레포지토리를 생각해 봅시다.
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 수준에서 가드레일을 세워두면 실수가 코드베이스에 반영되기 전에 잡아낼 수 있습니다.
모노레포를 운영하면서 서비스 간 경계를 명확히 하고 싶다면, 이런 파일 변경 범위 검증을 도입해 보는 것을 추천합니다.
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku가 개발한 앱을 한번 사용해보세요.Deku가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.