리뷰어가 없는 개발자의 리뷰
AI에게 "꼼꼼하게 리뷰해 줘"라고 부탁하는 것으로는 품질이 지켜지지 않습니다. 저는 규칙을 파일로 고정하고, 지키지 않으면 통과하지 못하게 만드는 쪽을 택했습니다.
저는 CodeIgniter 4로 여러 SaaS 제품을 혼자 만들고 운영합니다. 1년 전 Claude Code로 개발에 복귀한 뒤로 코드의 대부분을 AI가 씁니다. 사람 리뷰어는 없습니다.
처음에는 세션마다 같은 당부를 했습니다. 타입을 정확히 써라, 에러 응답은 이 형식으로 맞춰라. 그런데 세션이 길어지면 당부는 흐려지고, 다음 세션에서는 처음부터 다시 말해야 했습니다.
이 글은 그 당부를 세 개의 규칙으로 바꾼 기록입니다. PHPStan 레벨, @phpstan-ignore 금지, 그리고 에러 응답 규약 스킬입니다.
원칙: 리뷰 기준은 대화가 아니라 저장소에 둔다
리뷰 기준을 저장소의 파일로 옮기면, 세션이 바뀌어도 기준은 바뀌지 않습니다. 기준을 어디에 두느냐에 따라 역할을 나눴습니다.
|
어디에 |
무엇을 |
왜 |
|---|---|---|
|
|
빌드·테스트 명령, 금지 사항 |
세션을 시작할 때마다 읽힌다 |
|
커스텀 스킬 ( |
긴 규약 (에러 응답 형식 등) |
관련 작업에서만 불러와 컨텍스트를 아낀다 |
|
훅 ( |
수정 직후 PHPStan, 금지 패턴 차단 |
AI가 잊어도 기계가 실행한다 |
|
GitHub Actions |
같은 검사를 깨끗한 환경에서 다시 |
로컬을 우회한 코드도 막는다 |
|
테스트 |
규약의 실제 동작 |
문서는 잊혀도 테스트는 실패한다 |
핵심은 '말로 한 것'과 '기계가 확인하는 것'을 짝지어 두는 것입니다. CLAUDE.md에 적은 규칙은 반드시 훅이나 CI 중 하나가 검사합니다. 검사가 없는 규칙은 결국 지켜지지 않았습니다.
규칙 1 — PHPStan 레벨은 숫자로 박는다
제품마다 PHPStan 레벨을 숫자로 정하고, 그 숫자를 CLAUDE.md·phpstan.neon·CI 세 곳에서 같이 지킵니다. 새로 시작하는 AICreo 계열 프로젝트는 레벨 8, 채팅 상담 솔루션처럼 Node 엔진과 섞인 하이브리드 구조는 레벨 6으로 출발했습니다.
# CLAUDE.md (발췌)
## Build/Test
composer test # PHPUnit
composer stan # PHPStan level 8
## 규칙
- PHPStan 레벨을 낮추지 않는다. phpstan.neon의 level 변경 금지.
레벨은 슬금슬금 내려간다
테스트는 통과하는데 타입 검사가 조금씩 느슨해지는 것이 가장 흔한 누수였습니다. AI는 경고를 없애는 가장 짧은 길을 찾습니다. 그 길에 레벨을 한 단계 내리는 선택지가 있으면, 언젠가는 그 길로 갑니다.
그래서 레벨은 AI가 판단할 대상이 아니라 고정값으로 두었습니다. 올리는 것은 제가 결정하고, 내리는 것은 어떤 경우에도 하지 않습니다.
수정 직후에 돌린다
PHPStan은 커밋 전이 아니라 파일을 고친 직후에 돕니다. Claude Code의 PostToolUse 훅에 걸어 두면, AI가 결과를 보고 스스로 고친 뒤에야 다음 단계로 넘어갑니다.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "vendor/bin/phpstan analyse --error-format=table" }
]
}
]
}
}
여러 제품에 공통인 규칙은 ~/.claude/settings.json에, 제품 고유의 규칙은 프로젝트의 .claude/settings.json에 둡니다.
CI에서 레벨 자체를 확인한다
로컬 훅은 AI의 습관을 잡고, CI는 결과를 증명합니다. CI에는 분석과 함께 레벨이 바뀌지 않았는지 확인하는 단계를 넣었습니다.
- name: PHPStan level guard
run: grep -qE '^\s*level:\s*8\s*$' phpstan.neon || { echo "::error::PHPStan 레벨 변경 금지"; exit 1; }
- name: PHPStan
run: vendor/bin/phpstan analyse --no-progress --error-format=github
--error-format=github를 쓰면 경고가 PR의 해당 줄에 바로 표시됩니다. 사람 리뷰어 대신 PHPStan이 첫 번째 리뷰 코멘트를 다는 셈입니다.
규칙 2 — @phpstan-ignore는 쓰지 않는다
레벨을 고정해도 빠져나갈 구멍이 하나 남습니다. 경고가 난 줄 위에 @phpstan-ignore 주석 한 줄을 다는 것입니다. 저는 이 주석을 예외 없이 금지했습니다.
AI에게 무시 주석은 가장 싼 해결책이다
사람은 무시 주석을 달 때 조금이라도 망설입니다. AI는 망설이지 않습니다. "PHPStan 경고를 모두 해결했습니다"라는 보고 뒤에 무시 주석 몇 줄이 숨어 있으면, 검사는 초록불인데 타입 문제는 그대로 남습니다.
주석 하나는 사소해 보입니다. 하지만 하나를 허용하면 기준이 "정말 어쩔 수 없을 때"로 바뀌고, 그 판단을 AI가 하게 됩니다. 기준을 숫자처럼 단순하게 둬야 매번 같은 결과가 나옵니다.
대신 무엇을 하나
금지만 하면 AI가 다른 우회로를 찾습니다. 그래서 CLAUDE.md에 경고를 해결하는 순서를 함께 적었습니다.
## PHPStan 경고 처리
- @phpstan-ignore, @phpstan-ignore-line, @phpstan-ignore-next-line 사용 금지
- phpstan.neon의 ignoreErrors 추가 금지
- 해결 순서: 1) 타입 선언·반환 타입 보강 2) PHPDoc 제네릭/배열 형태 명시
3) null 가능성은 분기로 처리 4) 그래도 안 되면 멈추고 사람에게 보고
4번이 중요합니다. 해결할 수 없는 경고는 숨기지 말고 저에게 넘기게 했습니다. 실제로 넘어오는 경우는 대부분 CI4의 동적 프로퍼티처럼 프레임워크 쪽 타입 정보가 부족한 곳이었고, 이런 곳은 PHPDoc @property나 CI4용 PHPStan 확장으로 근본적으로 풀었습니다.
쓰는 순간 막는다
규칙을 적는 것으로 끝내지 않고, PreToolUse 훅으로 수정 자체를 막습니다. 훅이 종료 코드 2를 돌려주면 Claude Code는 그 수정을 실행하지 않고, 표준 에러로 보낸 메시지를 AI에게 그대로 전달합니다.
#!/usr/bin/env bash
# .claude/hooks/forbid-phpstan-ignore.sh
input=$(cat)
new=$(echo "$input" | jq -r '.tool_input.new_string // .tool_input.content // ""')
if echo "$new" | grep -q '@phpstan-ignore'; then
echo "@phpstan-ignore 사용 금지: 타입을 고쳐서 해결하거나, 해결할 수 없으면 멈추고 보고하세요." >&2
exit 2
fi
"PreToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": ".claude/hooks/forbid-phpstan-ignore.sh" }] }
]
CI에서 한 번 더 막는다
훅은 Claude Code를 거친 수정만 봅니다. 제가 직접 고친 코드나 다른 경로로 들어온 코드는 CI가 잡습니다.
- name: Forbid inline PHPStan ignores
run: |
if grep -rnE '@phpstan-ignore' app/ tests/; then
echo "::error::@phpstan-ignore 사용 금지"; exit 1
fi
이 규칙은 저 자신에게도 똑같이 적용됩니다. AI에게만 엄격하고 사람에게 관대하면, 결국 그 틈으로 기준이 무너집니다.
규칙 3 — 에러 응답 규약을 스킬로 만든다
PHPStan이 타입을 지킨다면, 에러 응답 형식은 스킬이 지킵니다. 여러 제품이 같은 CI4 기반(ci4-board)을 공유하는 구조에서, 제품마다 에러 포맷이 달라지면 나중에 되돌리는 비용이 가장 큽니다. 그래서 직접 만든 첫 번째 커스텀 스킬이 api-error-contract였습니다.
왜 CLAUDE.md가 아니라 스킬인가
에러 규약은 깁니다. 응답 형식, 코드 이름 규칙, 상태 코드 선택 기준, CI4 구현, 코드 목록까지 들어갑니다. 이것을 CLAUDE.md에 다 넣으면 모든 세션의 컨텍스트를 차지합니다.
스킬은 관련 작업에서만 불러옵니다. 그래서 description에 언제 발동해야 하는지를 구체적으로 적었습니다. "API 엔드포인트나 컨트롤러를 만들거나 고칠 때, 예외·유효성 검사·HTTP 상태 코드를 다룰 때"처럼요. "PHP 관련" 같은 넓은 문구로는 잘 발동하지 않습니다.
응답 형식: RFC 9457 Problem Details
형식은 직접 만들지 않고 표준인 RFC 9457을 따랐습니다. 여기에 원인을 구분하는 code와 추적용 traceId를 더했습니다.
{
"type": "https://aivance.kr/errors/auth.token.expired",
"title": "Access token expired",
"status": 401,
"code": "AUTH.TOKEN.EXPIRED",
"detail": "토큰이 만료되었습니다. 다시 로그인해 주세요.",
"instance": "/api/v1/orders",
"traceId": "01J9..."
}
스킬 본문 맨 앞에는 원칙 세 가지만 두었습니다.
- 에러 코드는 영구 계약이다. 한 번 배포한 코드 문자열은 바꾸지 않는다. 메시지는 바꿔도 코드는 바꾸지 않는다.
- HTTP 상태는 분류,
code는 원인이다. 같은 401이라도 토큰 만료와 서명 불일치는 클라이언트가 다르게 처리해야 한다. - 5xx는 내부를 드러내지 않는다. 스택 트레이스나 SQL은 로그로만 보내고, 응답에는
traceId만 남긴다.
코드 이름과 상태 코드 선택
코드는 <SERVICE>.<RESOURCE>.<REASON> 형식으로 짓습니다. AUTH.TOKEN.EXPIRED, ORDER.ITEM.OUT_OF_STOCK처럼 읽기만 해도 어디서 왜 났는지 알 수 있게 했습니다. 공통 코드(COMMON)와 인증 코드(AUTH)는 시드 목록으로 스킬에 함께 넣었습니다.
AI가 가장 자주 헷갈리는 것은 비슷한 상태 코드 사이의 선택이었습니다. 이 기준은 스킬에 표로 고정했습니다.
|
상황 |
상태 |
기준 |
|---|---|---|
|
JSON 문법 오류, 필수 헤더 누락 |
400 |
요청을 해석할 수 없음 |
|
형식은 맞지만 값이 규칙 위반 |
422 |
유효성 검사 실패, |
|
인증 정보 없음·만료·위조 |
401 |
누구인지 모름 |
|
인증은 됐지만 권한 없음 |
403 |
누구인지 알지만 허용 안 됨 |
|
상태 충돌 (중복 생성 등) |
409 |
현재 상태와 맞지 않음 |
CI4 구현은 한 군데로 모은다
스킬은 형식만이 아니라 CI4에서 구현하는 방법도 정합니다. 컨트롤러는 응답 배열을 직접 만들지 않고 예외만 던집니다.
throw new ApiException(
code: 'AUTH.TOKEN.EXPIRED',
status: 401,
detail: '토큰이 만료되었습니다. 다시 로그인해 주세요.',
);
ApiException 계층을 Problem Details로 바꾸는 일은 Config\Exceptions에 등록한 예외 핸들러 한 곳이 맡습니다. traceId는 필터에서 요청마다 발급하고, Validation 실패는 422와 errors 배열로 자동 변환합니다. 형식을 만드는 곳이 한 곳뿐이라, AI가 컨트롤러마다 다른 모양의 응답을 만들 여지가 없습니다.
규약은 테스트로 고정한다
스킬에는 테스트 패턴도 넣었습니다. 실패 경로가 있는 엔드포인트를 만들면 응답의 status, code, Content-Type: application/problem+json을 확인하는 테스트를 함께 쓰게 했습니다. 스킬을 잊은 세션이 있어도, 형식이 어긋나면 테스트가 실패합니다.
세 규칙이 맞물리는 방식
세 규칙은 따로 돌지 않고, 코드 한 줄이 main에 들어가기까지 같은 길 위에서 차례로 검사합니다.
- 작업 시작 —
CLAUDE.md가 PHPStan 레벨과 금지 사항을 알려 주고, API 작업이면api-error-contract스킬이 불려 옵니다. - 수정하는 순간 — PreToolUse 훅이
@phpstan-ignore가 들어간 수정을 거절합니다. - 수정 직후 — PostToolUse 훅이 PHPStan을 돌리고, AI는 결과를 보고 타입을 고칩니다.
- PR — GitHub Actions가 레벨 확인, 무시 주석 검사, PHPStan, PHPUnit을 깨끗한 환경에서 다시 돌립니다.
- 머지 — main 브랜치 보호가 모든 검사의 통과를 요구합니다. 에러 응답 형식은 테스트가 증명합니다.
같은 검사를 로컬과 CI에서 두 번 하는 것은 낭비가 아닙니다. 로컬 훅은 AI가 스스로 고치게 만들고, CI는 그 결과를 증명합니다. 한쪽만 있으면 "고쳤다고 보고했는데 안 고쳐진" 코드가 들어옵니다.
돌아보며
규칙을 고정한 뒤로 저는 같은 지적을 반복하지 않게 됐고, 리뷰 시간을 설계 판단에 쓰게 됐습니다.
배운 것
- AI는 가장 짧은 길로 간다. 레벨 하향과 무시 주석은 그 짧은 길입니다. 길을 막아 두면 AI는 타입을 고치는 쪽으로 갑니다.
- 금지에는 대안을 붙인다. "하지 마라"만 적으면 다른 우회로가 생깁니다. 해결 순서와 "안 되면 멈추고 보고하라"를 함께 적어야 합니다.
- 규칙과 검사는 짝이다. 적어 두기만 한 규칙은 긴 세션에서 흐려집니다. 훅이나 CI가 확인하는 규칙만 끝까지 지켜졌습니다.
- 긴 규약은 스킬로 뺀다.
CLAUDE.md는 짧게 두고, 에러 규약처럼 긴 내용은 필요할 때만 불러옵니다.
처음 시작한다면
CLAUDE.md에 PHPStan 레벨을 숫자로 적고, CI에 레벨 확인 단계를 넣습니다.- PostToolUse 훅으로 수정 직후 PHPStan을 돌립니다.
@phpstan-ignore금지를 훅과 CI 양쪽에 겁니다.- 제품이 둘 이상이 되면, 에러 응답 규약부터 스킬로 만들고 테스트로 고정합니다.
사람 리뷰어가 없어도 리뷰 기준은 둘 수 있습니다. 그 기준을 대화가 아니라 저장소에 두는 것이 핵심이었습니다.
댓글 0개