들어가며
나는 평소 Claude Max 플랜을 사용하고 있어서 PR 단위의 AI 코드 리뷰를 따로 자동화할 필요성을 크게 느끼지 못했다. 코드를 작성하는 과정 자체에 AI가 이미 깊이 관여하고 있기 때문이다.
하지만 같은 팀의 프론트엔드 팀원들은 아직 AI를 적극적으로 활용할 환경이 갖춰지지 않은 상황이었다. 그래서 PR이 열리면 자동으로 AI 리뷰가 달리는 워크플로우를 직접 만들어 제공하기로 했다. 팀원들이 별도로 설정할 것 없이, PR만 열면 리뷰를 받을 수 있도록 하는 게 목표였다.
Gemini 2.5 Flash는 무료 티어에서도 사용할 수 있어 비용 부담 없이 도입하기에 적합했고, GitHub Actions와 조합하면 별도의 서버 없이 단일 YAML 파일 하나로 완성할 수 있다.
이 글에서는 워크플로우의 설계부터 트러블슈팅, 그리고 실사용 피드백을 반영한 개선까지의 과정을 다룬다.
전체 구조
워크플로우의 동작 흐름은 다음과 같다.
- 트리거: PR이 열리거나, PR 코멘트에
/gemini를 입력하면 워크플로우가 실행된다. - diff 추출: base 브랜치와의 차이를 프론트엔드 관련 파일(
.ts,.tsx,.css등)에 한정하여 추출한다. - Gemini API 호출: 추출한 diff를 프롬프트와 함께 Gemini API에 전달한다.
- 코멘트 작성: Gemini의 응답을 PR 코멘트로 게시한다.
사전 준비
Gemini API 키 발급
Google AI Studio에서 API 키를 발급받는다. Gemini 2.5 Flash는 무료 티어에 포함되어 있어 별도의 결제 설정 없이 바로 사용할 수 있다.
GitHub Secrets 등록
발급받은 API 키를 GitHub 레포지토리에 등록한다.
-
레포지토리 → Settings → Secrets and variables → Actions
-
New repository secret클릭 -
Name:
GEMINI_API_KEY, Secret: 발급받은 API 키 입력
워크플로우에서
${{ secrets.GEMINI_API_KEY }}로 참조하므로 이름이 정확히 일치해야 한다.워크플로우 구현
트리거 설정
yaml
on:
pull_request:
types: [opened]
issue_comment:
types: [created]
PR이 열릴 때 자동 리뷰가 달리고, PR 코멘트에서
/gemini를 입력하면 수동 리뷰를 요청할 수 있다. issue_comment 이벤트를 사용하는 이유는 GitHub API에서 PR 코멘트가 issue comment로 취급되기 때문이다.job의
if 조건으로 두 케이스를 분기한다.yaml
jobs:
review:
if: |
(github.event_name == 'pull_request') ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(github.event.comment.body, '/gemini'))
issue.pull_request != null 조건이 없으면 일반 이슈의 코멘트에서도 워크플로우가 실행되므로 반드시 필요하다.diff 추출
yaml
jobs:
review:
steps:
...
- name: Get PR diff
id: diff
run: |
git fetch origin ${{ steps.pr.outputs.base_ref }}
git diff origin/${{ steps.pr.outputs.base_ref }}...HEAD \
-- '*.ts' '*.tsx' '*.js' '*.jsx' \
'*.css' '*.scss' '*.sass' \
'*.vue' '*.html' \
':!*.lock' \
':!**/*.min.js' \
':!**/dist/**' \
':!**/.next/**' \
':!**/node_modules/**' \
':!**/generated/**' \
> diff.txt
head -c 30000 diff.txt > diff_trimmed.txt
echo "diff_size=$(wc -c < diff.txt)" >> $GITHUB_OUTPUT
프론트엔드 소스 파일만 필터링하고, lock 파일이나 빌드 산출물은 제외한다.
head -c 30000으로 diff를 30KB로 제한하는데, Gemini API의 입력 토큰 한도와 비용을 고려한 것이다./gemini 커맨드에 추가 요청사항 전달
yaml
jobs:
review:
steps:
...
- name: Get comment instruction
id: instruction
if: github.event_name == 'issue_comment'
uses: actions/github-script@v7
with:
script: |
const body = context.payload.comment?.body ?? '';
const match = body.match(/\/gemini\s*([\s\S]*)/);
const instruction = match?.[1]?.trim() ?? '';
core.setOutput('text', instruction);
/gemini 성능 위주로 봐주세요처럼 커맨드 뒤에 추가 지시를 붙이면, 이를 파싱하여 프롬프트에 삽입한다.프롬프트 설계
yaml
jobs:
review:
steps:
...
- name: Review with Gemini
id: gemini
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
EXTRA_INSTRUCTION: ${{ steps.instruction.outputs.text }}
run: |
...
printf -v PROMPT '%s\n\n%s\n\n%s\n\n%s\n%s\n%s\n%s\n%s\n%s\n\n%s\n\n%s\n\n%s\n%s\n%s\n%s' \
"당신은 시니어 프론트엔드 개발자입니다. 아래 git diff를 리뷰해주세요." \
"[중요] 반드시 한국어로만 작성하세요. 영어 사용 금지." \
"${EXTRA}" \
"다음 항목만 마크다운으로 작성하세요:" \
"1. 🐛 잠재적 버그 / 문제점" \
"2. ♿ 접근성 (a11y) 문제" \
"3. ⚡ 렌더링 성능 (불필요한 리렌더링, 메모이제이션 누락 등)" \
"4. 🎨 CSS / 스타일 개선" \
"5. 💡 코드 품질 개선 (컴포넌트 분리, 타입 누락, 네이밍 등)" \
"문제없는 부분은 언급하지 말고, 각 항목이 없으면 해당 섹션을 생략하세요." \
"[중요] 핵심 내용 위주로 간결하게 작성하되, 지나치게 장황하지 않게 작성하세요." \
"Git Diff:" \
"\`\`\`" \
"${DIFF_CONTENT}" \
"\`\`\`"
프롬프트 설계에서 신경 쓴 부분은 다음과 같다.
- 리뷰 항목을 5가지로 고정: 버그, 접근성, 렌더링 성능, CSS, 코드 품질. 프론트엔드에서 가장 흔히 놓치는 영역들이다.
- 없으면 생략: "문제없는 부분은 언급하지 말고"를 명시하지 않으면, Gemini가 "이 부분은 문제없습니다"를 항목마다 반복한다.
- 간결함 유도: 토큰 제한 대신 프롬프트로 출력 길이를 제어한다. 이유는 뒤에서 다룬다.
Gemini API 호출
yaml
jobs:
review:
steps:
...
- name: Review with Gemini
id: gemini
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
EXTRA_INSTRUCTION: ${{ steps.instruction.outputs.text }}
run: |
DIFF_CONTENT=$(cat diff_trimmed.txt)
...
RESPONSE=$(curl -s \
-H "Content-Type: application/json" \
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}" \
-d "{
\"contents\": [{
\"parts\": [{\"text\": $(echo "$PROMPT" | jq -Rs .)}]
}],
\"generationConfig\": {
\"temperature\": 0.1
}
}")
temperature: 0.1로 낮게 설정하여 일관된 리뷰를 생성하도록 했다. 코드 리뷰는 창의성보다 정확성이 중요하기 때문이다.UX: 리액션으로 처리 상태 표시
/gemini 커맨드를 입력하면 즉시 👀 리액션이 달리고, 리뷰가 완료되면 🚀 리액션이 추가된다. 워크플로우가 돌고 있는지 PR 페이지를 떠나지 않고도 알 수 있다.
yaml
jobs:
review:
steps:
...
- name: 리액션 추가 (처리 중)
if: github.event_name == 'issue_comment'
uses: actions/github-script@v7
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes'
});
...
- name: 리액션 변경 (완료)
if: github.event_name == 'issue_comment'
uses: actions/github-script@v7
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'rocket'
});
트러블슈팅
heredoc과 백틱의 충돌
초기 버전에서는 프롬프트를 heredoc으로 구성했다.
yaml
jobs:
review:
steps:
...
- name: Review with Gemini
id: gemini
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
EXTRA_INSTRUCTION: ${{ steps.instruction.outputs.text }}
run: |
...
PROMPT=$(cat <<PROMPT_EOF
...
\`\`\`
${DIFF_CONTENT}
\`\`\`
PROMPT_EOF
)
이 방식은 diff 내용에 백틱(```)이 포함되면 heredoc 파싱이 깨지는 문제가 있었다. 프론트엔드 코드에는 템플릿 리터럴이 자주 등장하므로 이 문제가 발생했다. 결국
printf -v로 변경하여 해결했다. 이 해결책이 위의 프롬프트 설계 섹션에서 다룬 방식이다.\n 리터럴 문제
프롬프트를 단순 문자열로 구성했을 때,
\n이 개행이 아닌 리터럴 문자로 전달되는 문제가 있었다. 쉘 변수에서 \n은 자동으로 개행으로 해석되지 않기 때문이다. printf의 %s\n\n%s 포맷으로 개행을 명시적으로 처리하여 해결했다.heredoc delimiter 충돌
GitHub Actions의
$GITHUB_OUTPUT에 여러 줄 값을 저장할 때 heredoc을 사용한다.yaml
jobs:
review:
steps:
...
- name: Review with Gemini
id: gemini
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
EXTRA_INSTRUCTION: ${{ steps.instruction.outputs.text }}
run: |
...
{
echo "review<<EOF"
echo "$REVIEW"
echo "EOF"
} >> $GITHUB_OUTPUT
문제는 Gemini의 리뷰 출력에
EOF라는 문자열이 포함될 수 있다는 점이다. 이 경우 heredoc이 의도치 않게 종료되어 리뷰가 잘린다. delimiter를 GEMINI_REVIEW_EOF처럼 충돌 가능성이 낮은 이름으로 변경하여 해결했다.yaml
jobs:
review:
steps:
...
- name: Review with Gemini
id: gemini
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
EXTRA_INSTRUCTION: ${{ steps.instruction.outputs.text }}
run: |
...
{
echo "review<<GEMINI_REVIEW_EOF"
echo "$REVIEW"
echo "GEMINI_REVIEW_EOF"
} >> $GITHUB_OUTPUT
output 토큰 제한과 리뷰 truncation
가장 많은 시행착오를 겪은 부분이다. 처음에는
maxOutputTokens: 1000으로 설정했는데, 리뷰가 중간에 잘리는 현상이 발생했다.
원인을 추적하기 위해 API 응답을 로깅하는 코드를 추가했다.
yaml
jobs:
review:
steps:
...
- name: Review with Gemini
id: gemini
...
run: |
...
RESPONSE=$(curl -s ...)
echo "📋 API Response:"
echo "$RESPONSE" | jq '.'
원인을 추적하기 위해 API 응답을 로깅해보니 다음과 같은 결과가 나왔다.
json
{
"candidates": [
{
"content": {
"parts": [
{
"text": "시니어 프론트엔드 개발자로서 해당 Git Diff를 리뷰합니다.\n\n### 🐛 잠재적 버그 / 문제점\n\n* **페이지네이션 `"
}
]
},
"finishReason": "MAX_TOKENS"
}
],
"usageMetadata": {
"promptTokenCount": 503,
"candidatesTokenCount": 37,
"totalTokenCount": 1499,
"thoughtsTokenCount": 959
}
}
maxOutputTokens: 1000 중 thinking에 959 토큰이 소모되고, 실제 출력에는 37 토큰만 할당된 것이다. Gemini 2.5 Flash는 thinking model이라 내부 추론 과정에도 output 토큰을 사용한다. finishReason: "MAX_TOKENS"가 이를 확인해준다. maxOutputTokens을 4096으로 늘렸지만, 프론트팀으로부터 여전히 출력이 불완전하다는 피드백이 왔다.결국
maxOutputTokens 설정 자체를 제거하고, 프롬프트에서 간결함을 유도하는 방식으로 전환했다. 토큰 제한은 하드 커팅이라 thinking 토큰 사용량에 따라 실제 출력이 예측 불가능하게 잘리지만, 프롬프트 가이드는 모델이 스스로 분량을 조절하므로 자연스러운 종결이 가능하다.diff
jobs:
review:
steps:
...
- name: Review with Gemini
id: gemini
...
run: |
...
printf -v PROMPT '%s\n\n%s\n\n...' \
...
"문제없는 부분은 언급하지 말고, 각 항목이 없으면 해당 섹션을 생략하세요." \
+ "[중요] 핵심 내용 위주로 간결하게 작성하되, 지나치게 장황하지 않게 작성하세요." \
"Git Diff:" \
...
RESPONSE=$(curl -s \
...
-d "{
...
\"generationConfig\": {
- \"maxOutputTokens\": 4096,
\"temperature\": 0.1
}
}")
프론트엔드 팀의 PR 빈도가 높지 않은 상황이었기 때문에, 비용보다 리뷰 품질을 우선시하는 판단이었다.
최종 결과

PR이 열리면 위와 같이 Gemini가 자동으로 리뷰 코멘트를 작성한다. 버그, 접근성, 성능, 스타일, 코드 품질 5가지 관점에서 해당 사항이 있는 항목만 출력된다.
▶전체 워크플로우 파일
yaml
name: Gemini PR Review
on:
pull_request:
types: [opened]
issue_comment:
types: [created]
jobs:
review:
if: |
(github.event_name == 'pull_request') ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(github.event.comment.body, '/gemini'))
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
issues: write
steps:
- name: Get PR info
id: pr
uses: actions/github-script@v7
with:
script: |
let pr;
if (context.eventName === 'pull_request') {
pr = context.payload.pull_request;
} else {
const res = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
pr = res.data;
}
core.setOutput('head_ref', pr.head.ref);
core.setOutput('base_ref', pr.base.ref);
core.setOutput('head_sha', pr.head.sha);
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ steps.pr.outputs.head_sha }}
fetch-depth: 0
- name: Get PR diff
id: diff
run: |
git fetch origin ${{ steps.pr.outputs.base_ref }}
git diff origin/${{ steps.pr.outputs.base_ref }}...HEAD \
-- '*.ts' '*.tsx' '*.js' '*.jsx' \
'*.css' '*.scss' '*.sass' \
'*.vue' '*.html' \
':!*.lock' \
':!**/*.min.js' \
':!**/dist/**' \
':!**/.next/**' \
':!**/node_modules/**' \
':!**/generated/**' \
> diff.txt
head -c 30000 diff.txt > diff_trimmed.txt
echo "diff_size=$(wc -c < diff.txt)" >> $GITHUB_OUTPUT
- name: Get comment instruction
id: instruction
if: github.event_name == 'issue_comment'
uses: actions/github-script@v7
with:
script: |
const body = context.payload.comment?.body ?? '';
const match = body.match(/\/gemini\s*([\s\S]*)/);
const instruction = match?.[1]?.trim() ?? '';
core.setOutput('text', instruction);
- name: 리액션 추가 (처리 중)
if: github.event_name == 'issue_comment'
uses: actions/github-script@v7
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes'
});
- name: Review with Gemini
id: gemini
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
EXTRA_INSTRUCTION: ${{ steps.instruction.outputs.text }}
run: |
DIFF_CONTENT=$(cat diff_trimmed.txt)
if [ -z "$DIFF_CONTENT" ]; then
echo "review=리뷰할 변경사항이 없습니다." >> $GITHUB_OUTPUT
exit 0
fi
echo "📊 Diff 크기: $(echo "$DIFF_CONTENT" | wc -c) bytes"
EXTRA=""
if [ -n "$EXTRA_INSTRUCTION" ]; then
EXTRA="추가 요청사항: $EXTRA_INSTRUCTION"
fi
printf -v PROMPT '%s\n\n%s\n\n%s\n\n%s\n%s\n%s\n%s\n%s\n%s\n\n%s\n\n%s\n\n%s\n%s\n%s\n%s' \
"당신은 시니어 프론트엔드 개발자입니다. 아래 git diff를 리뷰해주세요." \
"[중요] 반드시 한국어로만 작성하세요. 영어 사용 금지." \
"${EXTRA}" \
"다음 항목만 마크다운으로 작성하세요:" \
"1. 🐛 잠재적 버그 / 문제점" \
"2. ♿ 접근성 (a11y) 문제" \
"3. ⚡ 렌더링 성능 (불필요한 리렌더링, 메모이제이션 누락 등)" \
"4. 🎨 CSS / 스타일 개선" \
"5. 💡 코드 품질 개선 (컴포넌트 분리, 타입 누락, 네이밍 등)" \
"문제없는 부분은 언급하지 말고, 각 항목이 없으면 해당 섹션을 생략하세요." \
"[중요] 핵심 내용 위주로 간결하게 작성하되, 지나치게 장황하지 않게 작성하세요." \
"Git Diff:" \
"\`\`\`" \
"${DIFF_CONTENT}" \
"\`\`\`"
RESPONSE=$(curl -s \
-H "Content-Type: application/json" \
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}" \
-d "{
\"contents\": [{
\"parts\": [{\"text\": $(echo "$PROMPT" | jq -Rs .)}]
}],
\"generationConfig\": {
\"temperature\": 0.1
}
}")
echo "📋 API Response:"
echo "$RESPONSE" | jq '.'
REVIEW=$(echo "$RESPONSE" | jq -r '.candidates[0].content.parts[0].text // "리뷰를 생성할 수 없습니다."')
{
echo "review<<GEMINI_REVIEW_EOF"
echo "$REVIEW"
echo "GEMINI_REVIEW_EOF"
} >> $GITHUB_OUTPUT
- name: Post review comment
uses: actions/github-script@v7
env:
REVIEW_BODY: ${{ steps.gemini.outputs.review }}
with:
script: |
const review = `## 🤖 Gemini AI 코드 리뷰\n\n${process.env.REVIEW_BODY}\n\n---\n*Gemini 2.5 Flash | Diff 크기: ${{ steps.diff.outputs.diff_size }} bytes*`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request?.number ?? context.issue.number,
body: review
});
- name: 리액션 변경 (완료)
if: github.event_name == 'issue_comment'
uses: actions/github-script@v7
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'rocket'
});
돌아보며
약 일주일간 9번의 커밋을 거쳐 워크플로우를 안정화했다. 되돌아보면 대부분의 문제는 쉘 스크립트와 GitHub Actions의 미묘한 동작 차이에서 비롯되었다. heredoc 파싱, 개행 처리, output delimiter 충돌 같은 문제들은 로컬에서는 재현하기 어렵고, CI 환경에서만 나타나기 때문에 디버깅 사이클이 길어질 수밖에 없었다.
토큰 제한 문제를 해결하면서 배운 점이 있다. LLM의 출력 길이는 하드 리밋보다 프롬프트로 제어하는 것이 낫다.
maxOutputTokens로 자르면 문장 중간에서 끊기지만, 프롬프트에 "간결하게"라고 명시하면 모델이 스스로 완결된 문장으로 마무리한다.워크플로우 전체가 단일 YAML 파일 하나로 완성된다는 점이 이 접근의 가장 큰 장점이다. 별도의 서버나 봇 없이, GitHub Actions와 Gemini API 키만으로 팀의 코드 리뷰 프로세스에 AI를 도입할 수 있다.