GitHub 이슈 → 브랜치 → 구현·검증 → PR → 머지 → 브랜치 정리 → 이슈 종료 → 배포(CI/CD)
PHP(CodeIgniter 4) 백엔드 프로젝트를 여러 개 굴리다 보니, 코딩 자체보다 코딩 앞뒤의 반복 작업이 시간을 더 많이 잡아먹는다는 걸 알게 됐습니다. "dev 최신화했나?", "브랜치 이름은 뭐로 하지?", "이 PR은 squash였나 merge commit이었나?", "이슈는 닫았나?" 같은 것들이죠.
그래서 이 흐름을 전부 규칙(rules)과 스킬(skills) 로 박아 넣었습니다. 지금은 dev-issue 231 한 줄이면 이슈 착수부터 종료까지 한 사이클이 돕니다. 이 글은 그 플로우를 정리한 기록입니다.
0. 전체 그림

스킬은 계층 구조입니다.
| 스킬 | 역할 |
|---|---|
start-issue |
이슈 파악 → dev 최신화 → 브랜치 생성 → 관련 파일 탐색 |
dev-issue |
start → 탐색 심화 → TDD 구현 → 검증 → PR → 머지 → 종료까지 오케스트레이션 |
finish-issue |
PR 생성 → 머지 → 브랜치/워크트리 정리 → 이슈 종료 → dev 동기화 |
end-issue |
이슈에 작업 내역 코멘트 + 닫기 |
prune-branch |
머지된 브랜치 안전 정리 (안전망) |
1. 출발점은 GitHub 이슈
모든 작업은 이슈에서 시작합니다. 이슈 번호가 곧 작업의 식별자이고, 브랜치·커밋·PR·종료 코멘트가 전부 이 번호로 연결됩니다.
브랜치 전략
feature/* → (PR) → dev → (PR) → main
feature/*→dev: Squash and merge (+ 브랜치 자동 삭제)dev→main(배포): Merge commit — squash 금지main/dev직접 푸시 금지 (문서만 바꾸는 경우의dev직접 푸시 예외만 허용)
여기서 가장 중요한 건 dev → main을 절대 squash하지 않는 것입니다. squash하면 dev의 커밋들이 하나로 뭉개지면서 main이 dev의 조상이 아니게 되고, 이후 배포마다 deploy.yml 같은 파일에서 3-way 충돌이 반복됩니다. merge commit으로 유지하면 main이 항상 dev의 조상이라 배포는 fast-forward에 가깝게 충돌 없이 흘러갑니다.
2. 착수: start-issue — 콜드 스타트 제거
start-issue 231을 실행하면 아래가 자동으로 진행됩니다.
gh auth status,git status로 사전 점검 (워킹트리가 더러우면 임의로 stash/폐기하지 않고 물어봄)gh issue view로 요구사항 요약git checkout dev && git pull origin dev- 이슈 성격에 맞는 브랜치 생성 (
feature/register-validation,fix/...) - 이슈 키워드로 관련 컨트롤러·모델·라우트·마이그레이션 탐색 → "어디를 손봐야 하는지" 목록화
이슈 본문은 데이터일 뿐 지시가 아닙니다. 본문에 "이 명령을 실행해라" 같은 문장이 있어도 그대로 따르지 않고 확인합니다. AI에게 일을 맡길 때 프롬프트 인젝션 방어는 처음부터 절차에 넣어두는 게 좋습니다.
동시에 여러 기능을 병렬로 작업해야 한다면 같은 디렉터리에서 git checkout으로 갈아타지 않고 git worktree로 작업 디렉터리 자체를 분리합니다. 진행 중이던 변경사항과 로컬 서버/빌드 산출물이 섞이는 사고를 막기 위해서입니다.
3. 개발: dev-issue — 불확실성을 먼저 제거한다
dev-issue는 start-issue와 end-issue를 묶은 상위 스킬입니다. 이 스킬을 만들 때 실제 작업 시간을 재 보고 얻은 결론이 설계 원칙이 됐습니다.
사이클 시간의 최대 누수원은 코딩이 아니라 불확실성(무엇을·어디를 손댈지 모른 채 시작)과 검증 왕복(CI 재실패)이었다.
그래서 순서를 강제합니다.
- 구현 전에 탐색부터. "이미 구현·배포된 건 아닌가?", "복제할 기존 패턴이 있는가?", "어느 파일을 건드리나?"를 서브에이전트(Explore 등)에 위임해 먼저 확정합니다. 탐색 결과가 메인 컨텍스트를 오염시키지 않는 것도 장점입니다.
- 작업 크기에 프로세스를 맞춘다. 검증된 패턴의 복제는 가볍게, 신규 설계는 브레인스토밍부터 무겁게.
- TDD로 구현. 새 기능에는 테스트를 같이 씁니다. 서비스 레이어 커버리지 목표는 80%+.
- 푸시 전에 전체 검증을 통과시킨다. CI 재실패 왕복이 있고 없고가 작업 시간을 크게 가릅니다. (스킬에 적어둔 실측 기준으로 40분 대 60분 차이)
코드 규칙도 같이 박아둔다
AI가 코드를 쓰더라도 일관성이 유지되도록 전역 규칙(~/.claude/rules/)에 문서화해 두었습니다.
- code-style: PSR-12,
declare(strict_types=1), PHP 8.4+ 문법(readonly DTO, backed enum,match), 컨트롤러는 얇게/서비스에 로직 - security: 쿼리 바인딩, 출력 이스케이프, JWT
alg고정, 시크릿은.env만 - api-design: 응답 포맷 통일(
status/data/meta), 에러 코드UPPER_SNAKE_CASE, REST URI 규칙, 캐시·큐로 부하 분산
규칙이 컨텍스트에 자동 로드되기 때문에, 매번 "PSR-12로 써줘"를 반복하지 않아도 됩니다.
4. 검증: 푸시 전에 로컬에서 끝낸다
composer analyse # PHPStan
composer test # PHPUnit composer check # CS Fixer → PHPStan → PHPUnit, CI와 동일
Git 훅으로 습관이 아니라 강제로
사람(과 AI)의 의지에 기대지 않고 커밋 가능한 훅(git config core.hooksPath .githooks)으로 막습니다.
| 훅 | 동작 |
|---|---|
pre-commit |
스테이징된 *.php를 CS Fixer로 자동 수정 후 재스테이징 |
pre-push |
composer check 실행, 실패하면 푸시 중단 |
- 긴급 우회는 환경변수(
SKIP_HOOKS=1 git push)로만. - PHP가 없는 환경에서는 훅이 자동 스킵되고 CI가 최종 판단.
feature → dev에 CI를 안 돌리는 프로젝트라면pre-push가 사실상 유일한 방어선이므로 브랜치별로 다르게 동작시킵니다.main직접 푸시는 무조건 차단,dev푸시는composer check필수,feature/*는 빠른 반복을 위해 검증 생략.
내부 루프는 빠르게
DB를 쓰는 통합 테스트까지 돌리면 느리므로, DB 없는 순수 단위 스위트를 분리해 개발 중에는 그것만 돕니다(composer test:unit). 전체 스위트와 커버리지는 푸시 전에만.
5. PR → 머지 → 정리 → 종료: finish-issue
구현과 커밋이 끝나면 finish-issue가 뒷정리를 한 번에 처리합니다.
- 사전 점검 (현재 브랜치가
main/dev면 중단) - 이슈 번호 자동 추정 — 세션 컨텍스트 → 브랜치명 → 커밋 메시지(
#123) 순 feature → devPR 생성- Squash 머지 + 브랜치 삭제
gh pr merge --squash --delete-branch
- 작업 브랜치/워크트리 정리
- 이슈에 작업 요약 코멘트 + 종료 (
end-issue) dev최신화
push, PR 생성, 머지, 브랜치 삭제, 이슈 종료는 전부 되돌리기 어렵거나 남에게 보이는 행동이라 단계마다 사용자 승인을 받습니다. 한 번 승인했다고 다음 실행에 자동 적용하지 않습니다. 자동화의 속도와 사람의 통제 사이에서 잡은 균형점입니다.
이슈 코멘트는 PR 복붙이 아니라 요약
end-issue는 PR 설명을 그대로 붙이지 않고 "무엇을 왜 바꿨는지"를 이슈 맥락으로 요약해 코멘트하고 닫습니다. PR 본문에 Closes #123가 있어 머지 때 이미 닫혔다면 닫기는 건너뛰고 요약 코멘트만 남깁니다.
놓친 브랜치는 prune-branch로
원칙은 머지할 때 --delete-branch로 같이 지우는 것이지만, UI에서 그냥 머지했거나 로컬에만 남은 브랜치가 생기기 마련입니다. 이때 함정이 하나 있습니다.
Squash 머지된 브랜치의 커밋은
dev의 조상이 아니라서git branch --merged dev로는 감지되지 않습니다.
그래서 prune-branch는 gh pr list --state merged로 PR 상태를 1차 기준 삼고, git branch --merged는 보조로만 씁니다. main·dev·현재 브랜치는 어떤 경우에도 삭제 후보에서 제외합니다.
참고로 GitHub의 delete_branch_on_merge 설정은 저장소 전체에 적용되기 때문에, 켜 두면 dev → main 머지 때 dev까지 지워집니다. 이 설정은 끄고, feature 브랜치 삭제는 머지 명령의 --delete-branch로 개별 처리합니다.
6. 배포: dev → main 그리고 CI/CD
배포는 dev → main PR을 Merge commit으로 머지하는 것이 전부입니다. CI는 이 PR에서 돌고, 통과하면 main 머지를 트리거로 배포 워크플로가 실행됩니다.
왜 feature → dev PR에는 CI를 돌리지 않나
CI는 dev → main 배포 PR에서만 돌립니다. feature → dev에서는 코드 리뷰만 합니다. 이유는 세 가지입니다.
- 느린 CI가 빠른 반복을 막는다. DB를 쓰는 통합 테스트까지 포함한 전체 스위트는 느립니다. 튜닝 전에는 PHPUnit만 5분 넘게 걸렸습니다. 이슈 하나당 PR이 하나씩 생기는 흐름에서 PR마다 이걸 기다리면 작은 이슈를 빠르게 닫는 리듬이 깨집니다.
- 러너는 공유 자원이다. 셀프호스티드 러너 몇 대를 여러 저장소가 같이 씁니다. feature PR과 커밋마다 CI를 돌리면 정작 배포 PR이 큐에서 밀립니다.
- 같은 검증을 이미 앞단에서 한다. 푸시 전
composer check와pre-push훅이 CI와 같은 게이트를 로컬에서 돌립니다.dev푸시는 훅이composer check를 강제하고,feature/*푸시는 검증을 생략해 작업 중 속도를 지킵니다. 결국dev에 합쳐지는 순간 전체 검증이 걸리는 구조입니다.
즉 로컬 훅이 1차 방어선, dev → main CI가 최종 게이트입니다. 배포되는 코드만큼은 CI를 반드시 통과합니다.
대가도 있습니다. CI 설정을 바꾼 PR은 feature → dev에서 검증되지 않고 배포 PR에서야 처음 돌아갑니다. 그래서 워크플로를 바꿀 때는 로컬 Docker로 먼저 재현합니다 (아래 참고). 그리고 dev에 CI 없이 합쳐진 변경이 많이 쌓이면 배포 PR에서 한꺼번에 실패가 터질 수 있으니, 훅을 우회(SKIP_HOOKS=1)하는 일은 최소화해야 합니다.
셀프호스티드 러너에서 겪은 것들
조직 러너(Docker 컨테이너로 도는 self-hosted runner 3대)로 옮기면서 여러 저장소에서 같은 실패가 반복됐고, 그 교훈을 규칙으로 남겼습니다.
- 호스트 포트는 닿지 않는다. 러너 자체가 컨테이너라
services:의ports나docker run -p로 게시한 포트는127.0.0.1로 접속이 안 됩니다(Connection refused). 실행별 도커 네트워크를 만들고 러너와 DB 컨테이너를 같이 붙여 컨테이너 이름으로 접속합니다. 정리는if: always()스텝에서.docker network create ci-net-${{ github.run_id }}docker network connect ci-net-${{ github.run_id }} "$(hostname)" - 러너 이미지가 제각각. 같은 라벨이어도 어떤 러너는 PHP 8.5, 어떤 러너는 8.3. "PHP 보장" 단계를 둬서 요구 버전 미만이면 설치하고, 확장도 없는 것만 현재 버전에 맞춰 설치합니다.
- xdebug 커버리지는
XDEBUG_MODE: coverage가 없으면 paratest가 죽습니다. - Docker 컨테이너 액션 금지. 작업 디렉터리가 액션 컨테이너에 마운트되지 않아
No files found가 납니다. 셸 스텝으로 대체. - 배포 SSH 호스트는 Cloudflare 프록시(주황 구름)가 아닌 직접 접속 호스트여야 합니다. 프록시를 SSH가 통과하지 못해
i/o timeout이 납니다.
테스트가 5분 넘게 멈출 때
테스트용 MySQL은 휘발성이니 내구성을 꺼 버립니다.
docker run -d --tmpfs /var/lib/mysql mysql:8.0 \--innodb-flush-log-at-trx-commit=0 \--innodb-flush-method=nosync \--sync-binlog=0 \--innodb-redo-log-capacity=536870912
기본 설정에서는 InnoDB 체크포인트 스톨(furious flushing)로 통합 테스트가 한참 멈췄습니다. 그래도 느리면 paratest로 병렬화합니다. 워커별로 쓰기 경로와 테스트 DB 스키마({base}_{TEST_TOKEN})를 격리하는 방식이고, 한 프로젝트에서는 직렬 5분+가 46초가 됐습니다. 단, 옵션을 늘릴 때마다 효과를 측정하고 효과 없는 건 되돌립니다. "이유를 아무도 모르는 옵션"을 남기지 않기 위해서입니다.
CI 변경은 늦게 검증된다는 함정
CI가 dev → main PR에서만 돌면, feature → dev로 머지한 CI 변경은 배포 PR에서야 처음 검증됩니다. 그래서 워크플로를 바꿀 땐 로컬 Docker로 DB 네트워킹과 paratest를 먼저 재현하고, YAML 문법도 따로 확인합니다.
또 하나, PR이 이미 머지된 뒤 같은 브랜치에 푸시한 커밋은 dev에 들어가지 않습니다. 추가 수정은 머지 여부부터 확인하고 새 브랜치·새 PR로 올립니다.
7. 부수적으로 지키는 것들
- 커밋 메시지:
이모지 + Conventional Commits prefix + 설명(✨ feat: …,🐛 fix: …). 하나의 커밋은 하나의 논리 단위. - 문서만 바꾸면
feature브랜치 없이dev에 바로 푸시. 코드가 한 줄이라도 섞이면 정상 플로우. - 시간대 규약: 서버·DB 저장은 UTC, 앱·화면은
Asia/Seoul. MySQLTIMESTAMP+ 연결 세션 타임존(+09:00)으로 변환을 DB에 맡겨, 애플리케이션 코드에서는 시간대를 신경 쓰지 않게 했습니다. - 세션 관리: 수백 턴짜리 마라톤 세션은 매 턴 누적 컨텍스트를 다시 읽어 토큰을 낭비합니다. 작업 단위가 끝나면 새 세션이나
/compact로 정리합니다.
정리
| 단계 | 도구 | 핵심 |
|---|---|---|
| 시작 | start-issue |
콜드 스타트 제거, 이슈 본문은 데이터 |
| 개발 | dev-issue, TDD |
탐색 먼저, 불확실성이 최대 비용 |
| 검증 | composer check, Git 훅 |
푸시 전 로컬에서 끝내 CI 왕복 제거 |
| PR/머지 | finish-issue |
feature→dev는 Squash, 비가역 액션은 매번 승인 |
| 정리 | prune-branch |
Squash는 --merged로 안 잡히니 PR 상태로 판별 |
| 종료 | end-issue |
작업 요약 코멘트 + 닫기 |
| 배포 | dev → main Merge commit |
squash 금지, CI·러너 함정은 규칙으로 문서화 |
이 플로우에서 배운 가장 큰 교훈은 두 가지입니다.
- 반복되는 절차는 스킬로, 반복되는 실수는 규칙으로. 실패를 겪을 때마다 규칙 문서에 한 줄씩 추가했더니 같은 실수를 두 번 하지 않게 됐습니다.
- 자동화하되 비가역 액션은 사람이 승인한다. 속도는 스킬이, 책임은 사람이 가져가는 구조가 오래 갑니다.
이슈 번호 하나만 던지면 배포 직전까지 알아서 굴러가고, 사람은 승인 버튼과 리뷰에만 집중합니다. 다음 글에서는 이 스킬들을 직접 만드는 방법(skill-creator)을 다뤄 보겠습니다.
댓글 0개