CI로 코드 변동 사이즈 강제하기: PR 양이 너무 방대해서 리뷰하기 힘드나요?

0. 협업하며 생긴 일..
혹시 이런 고민을 가지고 있지는 않나요?
"협업할 때, PR 리뷰 요청이 들어와 확인해보니 코드 변경 사항이 너무 많아서 리뷰하기 힘들어요. 꼼꼼히 리뷰해야 하는데 너무 방대해서 리뷰도 제대로 못하는 것 같고... 제가 코드를 읽고 이해하는 역량이 부족한 걸까요?"
이에 대한 답은 '반은 맞고 반은 틀렸습니다' 입니다.
물론 경험이 많고 시니어일수록 긴 코드도 잘 읽고 이해하여 리뷰도 잘 할 수 있겠죠. 하지만 엄청난 변동사항(파일이 100개가 바뀌었다던가..)이 담긴 PR은 아무리 시니어라도 리뷰하는 데에 꽤나 오랜 시간이 걸릴 것입니다. 결국 저는
- 변동사항에 대한 코드를 읽고 빠르게 이해하는 능력
- 작업의 단위를 적절히 쪼개어 PR의 단위를 작게 하여 협업하는 팀원이 리뷰하기 용이하도록 하는 능력
이 두 가지 능력이 필요하다고 생각합니다.
특히 이 글은 둘 중 후자에 집중하고자 하며, 그 중 어떻게 잘 쪼개야 할지 알아보기 보다는 잘 쪼갰음을 CI 환경에서 검증하는 자동화에 대해 다룰 예정입니다. 상세 목표는 다음과 같습니다.
- 제가 진행 중인 프로젝트에서는 이를 PR 생성 시 검사하는 하나의 CI 워크플로우로 도입한다.
- PR의 파일 변동 사항이 얼마나 큰지 체크하고 너무 큰 경우 경고를 띄우거나 PR을 block한다.
- 코드 변동 사이즈에 따라 자동 라벨링한다.
1. Github에서의 CI 환경 활용하기: .github/workflows/ 디렉토리와 .yml 파일
보통 협업할 때 코드 원격 저장소로 Github를 선택할 것이니, 이를 기준으로 설명하겠습니다.
.github/workflows/: 프로젝트 루트 디렉토리에 생성하면, github에 푸시 시, github CI 환경에서는 이 디렉토리에 있는 파일을 검토합니다.[파일명].yml: YML(YAML, YAML Ain't Markup Language)은 사람이 읽기 쉽도록 설계된, 계층 구조를 가진 데이터 직렬화 언어입니다. 이 내부에서 CI 워크플로우를 구체적으로 작성할 수 있으며,.github/workflows/에 위치시키면 Github CI 환경에서, YAML 파일마다 작성된 트리거 조건에 맞게 Github가 실행시킵니다.
그러므로, 우리가 Pull Request 생성 시 자동으로 무언가를 검사하고 싶다면 이 YAML 파일을 잘 작성하면 되겠죠?
2. 작성 전, YAML 파일의 기본 구조 알아보기
YAML의 문법을 완전 알려 하기 보다는, 기본적인 개념을 이해합시다. 우리는 AI와 함께하니까요!
YAML 파일의 기본 구조는 다음과 같습니다.
name(선택): 워크플로우의 이름으로, Github 액션 탭에 표시됩니다.on(필수): 위에서 언급했듯, 이 워크플로우를 실행할 트리거 이벤트(예: push, pull_request)를 정의합니다.jobs(필수):실행할 작업들의 집합을 정의합니다. 여러 작업을 정의하면 병렬로 실행됩니다.runs-on(필수): 작업을 실행할 가상 머신 환경을 지정합니다. Docker를 사용해보신 분들은 이해가 편할 것입니다.steps(필수): 하나의jobs에서 순차적으로 실행되는 단계를 정의합니다.uses(핵심 동작): 미리 정의된 다른 사람의 액션을 가져와 사용합니다.runs(명령어): 러너 환경에서 직접 쉘 명령어(예: npm install)를 실행합니다.- 그 외
with등등...
3. 코드 변동 사항을 제한하는 워크플로우를 작성하기
위 기본 구조를 바탕으로 하나씩 작성하고 마지막으로 최종 코드를 확인해보겠습니다. 제가 도입한 YAML 파일을 기준으로 설명해볼게요.
3-1) Name
저는 PR Size Guard로 명명했습니다.
yamlname: PR Size Guard
3-2) On: 어떤 조건에 이 워크플로우를 트리거할지 결정합니다.
우리는 Pull Request에 한해서만 활성화 할 것입니다. 보통 이 경우, 세가지 타입을 모두 켜주게 될 것입니다.
opened: 첫 PR 생성 시synchronize: PR은 열려있지만 해당 브랜치에서 새로운 커밋이 push될 때reopened: PR이 닫혔다 다시 열렸을 때
yamlon: pull_request: types: [opened, synchronize, reopened]
그리고 특정 브랜치를 on 트리거의 예외로 설정할 수 있는데요. branches-ignore로 작성합니다.
이 부분은 진행하는 프로젝트에서 따르는 git 전략에 따라 자유롭게 설정하면 됩니다.
제가 진행한 프로젝트에서는 main 브랜치를 특정하여 예외 처리했습니다.
mermaid
위처럼 보통 구현 브랜치들은 develop으로 하나씩 머지되기에, 각 PR마다 코드 변동사항이 크지 않지만,
main으로 머지될 때는 이 변동사항을 모두 모아 한 번에 적용시키는 경우가 많기 때문입니다.
최종 On 구조는 다음과 같습니다.
yamlon: pull_request: types: [opened, synchronize, reopened] # develop 등 통합 브랜치는 검사 유지, 배포용 main 대상 PR만 제외 branches-ignore: - main
3-3) Jobs
목적은 '파일 변동사항 체크' 하나이기에 Jobs는 하나만 필요합니다. 병렬적으로 다른 처리를 해줄 필요는 없었습니다.
물론 이 또한 협업 방식에 따라 추가할 수도 있곘습니다.
'코드 변동 사이즈에 따른 라벨링'을 해야 하기에 기본 설정보다 추가될 수 있음을 고려하세요.
job:size-check로 명명했습니다.runs-on: 기본적으로 사용되는 우분투를 사용했습니다.permissionpull-requests: write: PR에 대한 쓰기 작업(예: 코멘트, 라벨 작성) 권한 허용issues: write: 라벨 관련 API를 사용하기 위해서 허용contents: read: 저장소의 코드를 읽기 위해 서용
만약 라벨링이 동작하지 않는다면 리포지토리 설정(Settings > Actions > General)에서 Workflow permissions가 Read and write permissions로 되어 있는지 확인하세요
yamljobs: size-check: # 실행 환경 runs-on: ubuntu-latest # Github API로 할 수 있는 최소 권한 설정 permissions: pull-requests: write issues: write contents: read
3-4) (핵심) 하나의 Job -> Steps 작성하기
저희는 Jobs 내 하나의 Job을 가지며(사이즈 체크 잡), 이 하나의 Job에 대한 실행 환경, Github API의 권한 설정을 해두었습니다. 이제 Steps를 작성해봅시다.
하나의 Job은 여러 Steps를 가질 수 있지만, 다행히도 저희 목표를 달성하는 데에는 하나의 step만 있으면 됩니다.
이제 실제적인 스크립트를 작성해볼 차례입니다.
yamljobs: size-check: runs-on: ubuntu-latest permissions: pull-requests: write issues: write contents: read steps: - name: Check PR Size and Label uses: actions/github-script@v7 with: script: | # '|'는 여러 줄 텍스트를 그대로 하나의 문자열로 보관하라는 '블록 스칼라 문법' # ...뒤에 공개
하나의 job에 하나의 step만 존재합니다.
name: 해당 step 이름은Check PR Size and Label로 선언하였습니다.uses: Github 공식 액션인github-script를 가져와 실행합니다. 보통 Github에서 기본적으로 제공되는 쉘 스크립트가 많으니 가져와 사용하면 좋습니다.with: 사용하기로 한 액션(이 경우에는github-script)에 전달하는 입력값(input)입니다. 입력값은 위에서 작성된script뿐이 아니라 여러 옵션을 전달할 수 있으나, 현 목표에서는script만 필요합니다.
3-5) Script 작성하기
주의: 각 프로젝트마다 원하는 코드 변동 사이즈 제한이 있을 것이니 아래 코드를 무조건 따르지 않고 유동적으로 변경해도 됩니다.
javascript 문법으로 입력해보겠습니다.
코드가 굉장히 깁니다. 하지만 따로 파일을 두고 import해서 가져오려면 추가적인 설정이 필요하므로 이번에는 YAML 파일에 직접 작성하겠습니다.
아래는 script 코드만 발췌하였습니다. 다음 기능을 수행합니다.
- PR의 변경량을 단순 line 수가 아닌 가중치 점수로 계산하여 검사
file.additions + file.deletions * 0.3- 단순히 라인 수만 세면 코드 정렬이나 단순 삭제로 인해 수치가 뻥튀기될 수 있습니다. 이를 방지하기 위해 삭제는 0.3점의 가중치만 부여하여 실제 리뷰가 필요한 양을 더 정확히 측정했습니다.
- 문서/이미지/락파일/생성코드 같은 파일은 제외해서, 리뷰에 실제 부담이 되는 코드 변경만 보게 함
- 점수와 파일 수를 기준으로 PR을 size/S, size/M, size/L, size/XL로 분류
- 라벨이 저장소에 없으면 자동 생성하고, 기존 size/* 라벨은 정리한 뒤 현재 라벨만 남김
- 마지막에 요약 로그(파일 수, 점수, 상위 무거운 파일)를 남겨서 리뷰어가 어디를 먼저 볼지 빠르게 파악할 수 있게 함
- 한도를 넘으면 실패시키지 않고 warning만 띄워서, 개발 흐름은 막지 않되 “PR 분할 필요” 신호를 주는 방식입니다. (이 부분은 상황에 따라 강제로 머지를 막게 할 수도 있습니다.)
js// 0. 파일 변화 제한, 코드 변동 점수 제한 const FILE_LIMIT = 20; const SCORE_LIMIT = 500; // 1. Next.js 프로젝트 및 일반 프로젝트 제외 패턴 const excludePatterns = [ /package-lock\.json$/, /yarn\.lock$/, /pnpm-lock\.yaml$/, // Locks /\.md$/, /\.mdx$/, // Docs /\.(png|jpe?g|gif|svg|ico|webp|woff2?|otf|ttf)$/, // Assets /^public\//, // Next.js Public folder /next\.config\.(js|mjs|ts)$/, /tailwind\.config\.(js|ts)$/, // Configs /\.d\.ts$/, // TS Definitions /(__generated__|generated)\//, // Generated code /\.snap$/, // Jest Snapshots ]; // 2. 전체 파일 목록 가져오기 (Pagination 적용) const files = await github.paginate(github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number, }); // 3. 필터링 및 점수 계산 let totalScore = 0; let filteredFilesCount = 0; const heavyFiles = []; for (const file of files) { const isExcluded = excludePatterns.some((p) => p.test(file.filename)); if (!isExcluded) { filteredFilesCount++; // 가중치: 추가 1점, 삭제 0.3점 (단, 파일 전체 삭제나 이름 변경은 점수 낮게 책정) const fileScore = file.status === "removed" ? 0 : file.additions + file.deletions * 0.3; totalScore += fileScore; heavyFiles.push({ name: file.filename, score: fileScore }); } } // 4. 사이즈 라벨 정의 (색상 포함) const LABEL_CONFIG = { "size/S": { color: "0E8A16", description: "Small PR: Quick to review" }, "size/M": { color: "FBCA04", description: "Medium PR: Needs focused review" }, "size/L": { color: "E99695", description: "Large PR: Consider splitting" }, "size/XL": { color: "D93F0B", description: "Extra Large PR: Must be split" }, }; // 라벨 구간: 권장 한도(FILE_LIMIT / SCORE_LIMIT)와 맞춤. M/XL은 한도 대비 비율로 유지. const SCORE_M = Math.round(SCORE_LIMIT * 0.4); const FILES_M = Math.round(FILE_LIMIT * 0.5); const SCORE_XL = SCORE_LIMIT * 2; const FILES_XL = Math.round(FILE_LIMIT * 2.5); let sizeLabel = "size/S"; if (totalScore > SCORE_XL || filteredFilesCount > FILES_XL) sizeLabel = "size/XL"; else if (totalScore > SCORE_LIMIT || filteredFilesCount > FILE_LIMIT) sizeLabel = "size/L"; else if (totalScore > SCORE_M || filteredFilesCount > FILES_M) sizeLabel = "size/M"; // 5. 라벨이 리포지토리에 존재하는지 확인하고, 없으면 자동 생성 try { await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sizeLabel, }); } catch (e) { if (e.status !== 404) throw e; core.info(`Creating label: ${sizeLabel}`); await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sizeLabel, color: LABEL_CONFIG[sizeLabel].color, description: LABEL_CONFIG[sizeLabel].description, }); } // 6. 기존 'size/' 라벨 제거 및 새 라벨 추가 const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); const labelsToRemove = currentLabels .filter((l) => l.name.startsWith("size/") && l.name !== sizeLabel) .map((l) => l.name); await Promise.all( labelsToRemove.map((label) => github.rest.issues .removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: label, }) .catch(() => {}) ) ); if (!currentLabels.some((l) => l.name === sizeLabel)) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, labels: [sizeLabel], }); } // 7. 결과 리포팅 및 분석 로그 const finalScore = Math.round(totalScore); core.info(`📊 Summary: ${filteredFilesCount} files, ${finalScore} score points.`); // 점수가 높은 상위 3개 파일 출력 (개발자 가이드용) heavyFiles.sort((a, b) => b.score - a.score); if (heavyFiles.length > 0) { core.info("🔝 Top heavy files:"); heavyFiles.slice(0, 3).forEach((f) => core.info(` - ${f.name} (${Math.round(f.score)} pts)`)); } // 8. 최종 안내 (한도 초과 시 경고만 — 워크플로는 성공) if (filteredFilesCount > FILE_LIMIT || totalScore > SCORE_LIMIT) { core.warning( `⚠️ PR이 권장 한도를 넘었습니다. 분할을 검토해 주세요. (Files: ${filteredFilesCount}/${FILE_LIMIT}, Score: ${finalScore}/${SCORE_LIMIT})` ); } else { core.notice(`✅ PR size is appropriate.`); }
최종 YAML 코드 전체는 글 맨 마지막에 적어둘게요.
4. (스크린샷)Pull Request 작성 시 일어나는 일들
해당 YAML 파일을 가진 PR이 생성되는 순간 자동으로 워크플로우가 실행되고 성공한다면 다음과 같이 보이게 됩니다.

분석해보니 코드 변동 사이즈가 작았나보네요. size/S 라벨을 자동으로 붙여줬습니다.

PR Size Guard워크플로우의size-checkjob을 성공했네요

클릭해서 들어가볼까요?

워크플로우가 어떻게 동작하는지 자세히 확인할 수 있습니다!
5. 최종 코드(확인용)
yamlname: PR Size Guard on: pull_request: types: [opened, synchronize, reopened] # develop 등 통합 브랜치는 검사 유지, 배포용 main 대상 PR만 제외 branches-ignore: - main jobs: size-check: runs-on: ubuntu-latest permissions: pull-requests: write issues: write contents: read steps: - name: Check PR Size and Label uses: actions/github-script@v7 with: script: | const FILE_LIMIT = 20; const SCORE_LIMIT = 500; // 1. Next.js 및 일반 프로젝트 제외 패턴 const excludePatterns = [ /package-lock\.json$/, /yarn\.lock$/, /pnpm-lock\.yaml$/, // Locks /\.md$/, /\.mdx$/, // Docs /\.(png|jpe?g|gif|svg|ico|webp|woff2?|otf|ttf)$/, // Assets /^public\//, // Next.js Public folder /next\.config\.(js|mjs|ts)$/, /tailwind\.config\.(js|ts)$/, // Configs /\.d\.ts$/, // TS Definitions /(__generated__|generated)\//, // Generated code /\.snap$/, // Jest Snapshots ]; // 2. 전체 파일 목록 가져오기 (Pagination 적용) const files = await github.paginate(github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number, }); // 3. 필터링 및 점수 계산 let totalScore = 0; let filteredFilesCount = 0; const heavyFiles = []; for (const file of files) { const isExcluded = excludePatterns.some(p => p.test(file.filename)); if (!isExcluded) { filteredFilesCount++; // 가중치: 추가 1점, 삭제 0.3점 (단, 파일 전체 삭제나 이름 변경은 점수 낮게 책정) const fileScore = file.status === 'removed' ? 0 : file.additions + (file.deletions * 0.3); totalScore += fileScore; heavyFiles.push({ name: file.filename, score: fileScore }); } } // 4. 사이즈 라벨 정의 (색상 포함) const LABEL_CONFIG = { 'size/S': { color: '0E8A16', description: 'Small PR: Quick to review' }, 'size/M': { color: 'FBCA04', description: 'Medium PR: Needs focused review' }, 'size/L': { color: 'E99695', description: 'Large PR: Consider splitting' }, 'size/XL': { color: 'D93F0B', description: 'Extra Large PR: Must be split' }, }; // 라벨 구간: 권장 한도(FILE_LIMIT / SCORE_LIMIT)와 맞춤. M/XL은 한도 대비 비율로 유지. const SCORE_M = Math.round(SCORE_LIMIT * 0.4); const FILES_M = Math.round(FILE_LIMIT * 0.5); const SCORE_XL = SCORE_LIMIT * 2; const FILES_XL = Math.round(FILE_LIMIT * 2.5); let sizeLabel = 'size/S'; if (totalScore > SCORE_XL || filteredFilesCount > FILES_XL) sizeLabel = 'size/XL'; else if (totalScore > SCORE_LIMIT || filteredFilesCount > FILE_LIMIT) sizeLabel = 'size/L'; else if (totalScore > SCORE_M || filteredFilesCount > FILES_M) sizeLabel = 'size/M'; // 5. 라벨이 리포지토리에 존재하는지 확인하고, 없으면 자동 생성 try { await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sizeLabel, }); } catch (e) { if (e.status !== 404) throw e; core.info(`Creating label: ${sizeLabel}`); await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sizeLabel, color: LABEL_CONFIG[sizeLabel].color, description: LABEL_CONFIG[sizeLabel].description, }); } // 6. 기존 'size/' 라벨 제거 및 새 라벨 추가 const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); const labelsToRemove = currentLabels .filter(l => l.name.startsWith('size/') && l.name !== sizeLabel) .map(l => l.name); await Promise.all(labelsToRemove.map(label => github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: label, }).catch(() => {}) )); if (!currentLabels.some(l => l.name === sizeLabel)) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, labels: [sizeLabel], }); } // 7. 결과 리포팅 및 분석 로그 const finalScore = Math.round(totalScore); core.info(`📊 Summary: ${filteredFilesCount} files, ${finalScore} score points.`); // 점수가 높은 상위 3개 파일 출력 (개발자 가이드용) heavyFiles.sort((a, b) => b.score - a.score); if (heavyFiles.length > 0) { core.info("🔝 Top heavy files:"); heavyFiles.slice(0, 3).forEach(f => core.info(` - ${f.name} (${Math.round(f.score)} pts)`)); } // 8. 최종 안내 (한도 초과 시 경고만 — 워크플로는 성공) if (filteredFilesCount > FILE_LIMIT || totalScore > SCORE_LIMIT) { core.warning(`⚠️ PR이 권장 한도를 넘었습니다. 분할을 검토해 주세요. (Files: ${filteredFilesCount}/${FILE_LIMIT}, Score: ${finalScore}/${SCORE_LIMIT})`); } else { core.notice(`✅ PR size is appropriate.`); }
아직 댓글이 없습니다
첫 번째 댓글을 작성해보세요!