AI가 쓴 코드도 같은 기준으로: GitHub Actions 검증 파이프라인
· @martin
AI 코드를 믿는 방법은 믿지 않는 것이었다
저는 혼자 개발합니다. PHP를 26년 썼고, 1년 전부터 Claude Code로 개발에 복귀해 AIvance라는 이름으로 여러 SaaS를 만들고 있습니다. 공용 기반인 ci4-board는 AI와 함께 한 달 만에 만들었고, 통합 테스트가 149개 붙어 있습니다.
속도가 빨라지자 질문이 하나 남았습니다. 코드 리뷰를 해 줄 동료가 없는데, AI가 하루에 쏟아내는 코드를 어떻게 믿을 것인가. 제 답은 "믿지 않는다"였습니다. 대신 AI가 쓴 코드든 제가 쓴 코드든 똑같은 관문을 통과해야만 main에 들어가도록 파이프라인을 짰습니다. 이 글은 그 구성을 정리한 기록입니다.
원칙: 작성자를 묻지 않는 세 개의 관문
파이프라인의 규칙은 하나입니다. 코드가 누구 손에서 나왔는지는 묻지 않고, 같은 검사를 같은 순서로 통과시킵니다. AI용 특별 검사도, 사람용 예외도 없습니다.
관문은 세 겹입니다.
- 로컬 관문 — Claude Code가 파일을 고치는 순간 훅이 정적 분석과 테스트를 돌립니다.
- CI 관문 — PR이 올라오면 셀프호스티드 러너에서 같은 검사를 깨끗한 환경으로 다시 돌립니다.
- 배포 관문 — CI가 초록색일 때만 main에 병합되고, 그때만 운영 서버로 배포됩니다.
같은 검사를 두 번 돌리는 이유는 간단합니다. 로컬 훅은 빠른 피드백용이고, CI는 "내 맥에서는 됐는데"를 걸러내는 증거용입니다.
CI에서 실패한 코드는 작성자가 AI든 사람이든 같은 길로 되돌아가고, 초록색일 때만 운영에 닿습니다.
1단계 로컬 관문: AI가 고치는 순간 검사한다
첫 관문은 Claude Code의 훅입니다. 프로젝트의 .claude/settings.json에 PreToolUse와 PostToolUse 훅을 걸어 두었고, 파일이 수정되면 테스트가 자동으로 돌아갑니다. AI는 자기가 고친 코드의 오류를 바로 받아 보고, 제가 끼어들기 전에 스스로 고칩니다.
설정의 뼈대는 이렇습니다(예시).
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "vendor/bin/phpstan analyse --no-progress" },
{ "type": "command", "command": "vendor/bin/phpunit" }
]
}
]
}
}
훅과 짝을 이루는 것이 CLAUDE.md입니다. 채팅으로 한 당부는 세션이 끝나면 사라지지만, 저장소에 적힌 규칙은 다음 세션에도 그대로 남습니다. 저는 사용자 수준과 프로젝트 수준으로 CLAUDE.md를 나눠 두고, 빌드·테스트 명령과 코딩 규약을 여기에 적습니다. 규칙은 문서로 알려 주고, 지켰는지는 훅이 확인하는 구조입니다.
로컬 환경은 macOS와 Apache입니다. 운영 서버는 Ubuntu이니, 로컬에서 통과했다고 끝이 아닙니다. 그래서 두 번째 관문이 필요합니다.
2단계 CI 관문: 셀프호스티드 러너에서 다시 증명한다
두 번째 관문은 GitHub Actions입니다. 러너는 GitHub가 빌려주는 것이 아니라 제가 직접 운영하는 Ubuntu 26.04 서버에서 돌아가는 셀프호스티드 러너입니다. 운영 서버와 같은 계열의 OS에서 검사하니, 로컬과 운영의 차이에서 오는 문제를 여기서 먼저 만납니다.
처음부터 셀프호스티드를 쓴 것은 아닙니다. GitHub 무료 플랜은 Actions 실행 시간을 한 달 2,000분까지 제공합니다. 개발 초창기에 AI와 함께 PR을 쉴 새 없이 올리다 보니 이 2,000분을 넘겼고, CI가 그대로 멈춰 버렸습니다. AI가 코드를 만드는 속도를 검증 파이프라인이 따라가지 못한 셈입니다.
그래서 러너를 직접 띄우기로 했습니다. 지금은 GitHub 조직(Organization) 단위로 셀프호스티드 러너를 등록하고, Docker로 러너 3대를 돌립니다. 조직의 모든 저장소가 이 러너들을 함께 쓰고, PR이 몰려도 3대가 나눠 처리하니 CI가 막힘없이 돌아갑니다.
정리하면 셀프호스티드를 고른 이유는 세 가지입니다.
- 실행 시간 제한 없음 — 무료 플랜의 월 2,000분에 막힐 일이 없습니다. 여러 프로젝트에서 PR마다 전체 테스트를 돌려도 됩니다.
- 병렬 처리 — 조직 단위로 등록한 Docker 러너 3대가 대기열을 나눠 처리합니다.
- 환경 일치와 통제 — 운영과 같은 Ubuntu, 같은 PHP 8.5 계열에서 검사하고, 러너의 패키지와 캐시를 제가 관리합니다.
워크플로의 뼈대는 이렇습니다(예시).
name: verify
on:
pull_request:
push:
branches: [main]
concurrency:
group: verify-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Static analysis
run: vendor/bin/phpstan analyse --no-progress --error-format=github
- name: Tests
run: vendor/bin/phpunit
--error-format=github를 주면 PHPStan 경고가 PR의 해당 줄에 바로 표시됩니다. AI가 만든 PR이든 제 PR이든, 리뷰는 그 표시부터 시작합니다.
한 가지 주의할 점이 있습니다. 셀프호스티드 러너는 제 서버에서 PR의 코드를 그대로 실행합니다. 공개 저장소에서 외부 포크의 PR이 러너에 닿지 않도록, 비공개 저장소에만 연결하거나 포크 PR의 실행을 막아 두는 것이 안전합니다.
3단계 배포 관문: 초록색만 운영에 닿는다
세 번째 관문은 main 브랜치 보호와 배포입니다. main에는 직접 push할 수 없고, CI의 verify 작업이 통과한 PR만 병합됩니다. 혼자 일하니 승인 리뷰어는 없지만, 필수 상태 검사(required status check)가 사람 리뷰어의 자리를 대신합니다.
병합이 되면 배포 워크플로가 이어서 돌아갑니다. 전용 ED25519 배포 키로 SSH 접속해 운영 서버에서 코드를 받고, composer install과 CodeIgniter 4의 php spark migrate 같은 명령을 실행합니다. 키와 접속 정보는 GitHub Secrets에 두고, .env는 저장소에 올리지 않습니다.
deploy:
needs: verify
if: github.ref == 'refs/heads/main'
runs-on: [self-hosted, linux]
steps:
- uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/app
git pull --ff-only
composer install --no-dev --optimize-autoloader
php spark migrate
핵심은 needs: verify 한 줄입니다. 검증이 실패하면 배포 작업은 시작조차 하지 않습니다. 결국 운영 서버에 닿는 코드는 작성자와 상관없이 같은 두 관문을 통과한 코드뿐입니다.
문법 너머: 규약까지 검증 대상으로
정적 분석과 테스트는 "돌아가는가"를 확인합니다. MSA 백엔드에서는 그 다음 질문이 더 중요합니다. "약속한 모양으로 응답하는가."
그래서 제가 처음 만든 커스텀 스킬이 api-error-contract입니다. RFC 9457 Problem Details를 바탕으로 서비스 공통 에러 응답 규약을 정했고, CI4용 ApiException 계층과 .. 형식의 에러 코드 명명 규칙, 초기 코드 카탈로그를 담았습니다. AI는 새 API를 만들 때 이 스킬을 읽고 규약대로 에러를 던집니다.
스킬이 규약을 가르친다면, 테스트는 규약을 지켰는지 확인합니다. 에러 응답의 형식과 코드를 통합 테스트로 고정해 두면, AI가 규약을 어긴 순간 CI가 빨갛게 변합니다. ci4-board의 통합 테스트 149개도 같은 역할을 합니다. 이 테스트들이 사실상 실행 가능한 명세입니다.
정리하면 역할이 이렇게 나뉩니다.
|
도구 |
역할 |
실행 시점 |
|---|---|---|
|
|
규칙을 알려 준다 |
세션 시작 |
|
커스텀 스킬 |
규약과 절차를 가르친다 |
해당 작업 시 |
|
Claude Code 훅 |
고친 즉시 검사한다 |
파일 수정 직후 |
|
GitHub Actions |
깨끗한 환경에서 증명한다 |
PR, main push |
|
브랜치 보호 |
통과한 것만 들인다 |
병합 시 |
돌아보며: 검증은 기계에게, 판단은 사람에게
이 파이프라인이 바꾼 것은 제 시간의 쓰임입니다. AI가 짠 코드를 한 줄씩 읽으며 오타와 타입 오류를 찾는 일은 PHPStan과 PHPUnit이 빠짐없이 해 줍니다. 저는 그 시간에 설계가 맞는지, 경계를 제대로 나눴는지를 봅니다.
이 구성에서 배운 것
- AI의 속도는 검증 인프라의 속도를 먼저 넘는다. 무료 2,000분이 바닥난 날, 병목이 코드를 쓰는 쪽이 아니라 검증하는 쪽으로 옮겨 갔다는 것을 알았습니다. AI로 개발한다면 CI 용량은 처음부터 넉넉하게 잡는 편이 낫습니다. 러너 3대로 늘린 지금은 PR을 기다리는 시간이 사라졌습니다.
- 같은 검사를 두 번 돌리는 것은 낭비가 아니다. 로컬 훅은 AI가 스스로 고치게 만들고, CI는 그 결과를 깨끗한 환경에서 증명합니다. 한쪽만 있으면 "AI가 고쳤다고 했는데 안 고쳐진" 코드가 main에 들어옵니다.
- 규칙은 대화가 아니라 저장소에 남긴다. 세션마다 같은 당부를 반복하는 대신
CLAUDE.md, 스킬, 훅, 워크플로 파일에 적었습니다. 그러자 규칙이 세션과 프로젝트를 넘어 그대로 이어졌습니다. - 테스트가 곧 AI에게 주는 명세다. 규약을 문서로만 두면 AI는 가끔 잊습니다. 테스트로 고정해 두면 잊는 순간 CI가 알려 줍니다. ci4-board의 통합 테스트 149개가 여러 제품의 공통 기준선이 된 이유입니다.
- 혼자여도 리뷰어는 둘 수 있다. 사람 리뷰어는 없지만, 필수 상태 검사와 PR 위의 PHPStan 표시가 매번 같은 기준으로 첫 리뷰를 해 줍니다. 저는 그다음 단계인 설계 리뷰에 집중합니다.
여전히 사람의 몫인 것
파이프라인이 아무리 촘촘해도 사람의 몫은 남습니다.
- 무엇을 검사할지 정하는 일. 테스트가 명세라면, 명세를 쓰는 사람이 품질을 결정합니다. 테스트가 빈 곳은 파이프라인도 지켜 주지 못합니다.
- 되돌리기 어려운 결정. 인증 구조, 데이터 모델, 보안 정책은 초록색 체크가 대신 판단해 주지 않습니다.
- 관문 자체의 유지보수. 러너 서버의 업데이트와 의존성 관리도 결국 제 일입니다.
처음 시작하는 분께
이 구성을 한 번에 갖출 필요는 없습니다. 제가 다시 시작한다면 이 순서로 하겠습니다.
- 저장소에
CLAUDE.md를 두고 빌드·테스트 명령부터 적습니다. - PHPStan과 PHPUnit을 GitHub Actions에 붙이고, main 브랜치 보호를 켭니다.
- Claude Code에 PostToolUse 훅을 걸어 로컬에서 먼저 걸러냅니다.
- 실행 시간이 부족해지면 셀프호스티드 러너로 옮기고, 프로젝트가 늘면 조직 단위로 묶어 러너를 늘립니다.
- 반복되는 규약은 스킬로 만들고, 그 규약을 테스트로 고정합니다.
저는 지금 이 경험을 바탕으로 'AI 개발환경 구축 컨설팅'을 준비하고 있습니다. 혼자서도 팀처럼 검증하는 구조가 필요한 분께 이 기록이 출발점이 되었으면 합니다.
맺으며
AI 시대에 1인 개발자에게 필요한 것은 AI를 더 믿는 능력이 아니라, 믿지 않아도 되는 구조를 만드는 능력이라고 생각합니다. 사람이 쓴 코드도 그렇게 다뤄 왔다면, AI가 쓴 코드라고 기준을 바꿀 이유가 없습니다.
댓글 0개