이슈부터 머지까지 커맨드 하나로 — 체크포인트를 4개 넣고도 명령어는 1개

경험을 토대로 AI를 활용하여 작성한 글 입니다.

취업 준비 플래너 앱을 만들면서 AGENTS.md에 작업 절차 8단계를 적어뒀다. 이슈 선정 → 브랜치 → 재현·계측 → 계획 승인 → 구현 → 검증 → PR → 정리. 규칙은 잘 지켜지고 있다고 생각했는데, 어느 날 문득 궁금해졌다. 이 중에 실제로 자동화된 건 몇 단계지?

세어보니 하나도 없었다. 정확히 말하면 PR을 여는 순간부터만 자동이었다.

1. 이슈 생성      ❌  gh issue create 를 손으로
2. 대상 선정      ❌  gh issue list 를 손으로
3. 재현·계측      ❌  대화로 지시
4. 계획 승인      ❌  순수 대화
5. 브랜치·구현    ❌  git checkout -b 를 손으로
6. 로컬 검증      ❌  pnpm lint && pnpm build 를 손으로
7. PR 생성        ❌  gh pr create 를 손으로
   └ CI 검증      ✅  .github/workflows/ci.yml
   └ 헬스체크     ✅  .github/workflows/health-check.yml
   └ 이슈 닫기    ✅  Fixes #N + 머지
8. 머지 후 정리   ❌  손으로

git hook은 하나도 없었고, .claude/settings.json에는 권한 허용 목록만 있었다. 8단계를 강제하는 유일한 장치가 "AI가 AGENTS.md를 읽고 따르는 것"이었다는 뜻이다. 사람이 "3번 이슈 해줘"라고 말하면 진행되고, 안 하면 아무 일도 일어나지 않는다.

확인하다 발견한 진짜 구멍

자동화 상태를 확인하는 김에 GitHub 설정도 들여다봤는데, 여기서 더 실질적인 문제가 나왔다.

$ gh api repos/:owner/:repo/branches/main/protection --jq '.required_status_checks'
{
  "strict": false,
  "contexts": ["e2e"]
}

main의 required status check가 e2e 하나뿐이었다. CI 워크플로에는 lint-and-build 잡도 있는데 필수 체크에 등록돼 있지 않았다. lint나 build가 빨간불이어도 머지 버튼이 열린다는 뜻이다. strict: false라 브랜치가 main보다 뒤처져 있어도 재검증 없이 들어간다.

이건 커맨드로 때울 게 아니라 설정 두 줄로 고치는 거였다.

gh api -X PUT repos/:owner/:repo/branches/main/protection --input - <<'EOF'
{
  "required_status_checks": { "strict": true, "contexts": ["e2e", "lint-and-build"] },
  ...
}
EOF

자동화를 만들기 전에 자동화가 지켜야 할 게이트부터 제대로 세워둔 셈이 됐다.

체크포인트를 넣으면 명령어가 늘어난다고 생각했다

8단계를 하나의 커맨드로 묶기로 하고 설계를 시작했다. 목표는 최소한의 명령어 실행으로 작업을 끝까지 완주하되, 사람의 판단이 필요한 지점에서는 임의로 처리하지 않고 확인을 받는 것이었다.

처음엔 이 둘이 상충한다고 생각했다. 확인 지점을 4개 넣으면 커맨드가 4번 끊기고, 사용자는 /work를 5번 쳐야 하는 것 아닌가?

아니었다. 확인은 AskUserQuestion으로 같은 세션 안에서 처리된다. 커맨드가 끝나는 게 아니라 대화 중간에 선택지가 뜨고, 사용자가 고르면 그 자리에서 이어진다. 체크포인트를 4개 넣어도 사용자가 치는 명령어는 여전히 하나다.

사람이 멈춰야 하는 지점과 사람이 타이핑해야 하는 지점은 애초에 다른 축이었다.

사실 이건 처음 겪는 게 아니었다. 바로 오늘 아침 사이드 프로젝트 파이프라인(/sp)에서 똑같은 걸 고쳤고, 워크플로우 로그에 이렇게 적어둔 참이었다.

간단한 프로젝트 하나를 끝내는 데 /sp를 6번 넘게 타이핑해야 했음. /sp가 "다음 단계 하나 실행 후 종료"였고, sp-build는 행이 끝날 때마다 "다음 행 진행할까요?"를 물었다. 사용자 판단이 필요한 지점과 타이핑이 필요한 지점을 설계가 혼동하고 있었다 — 6개 개입 지점은 전부 대화 안의 질문이지 재실행 사유가 아니다.

같은 함정에 하루 만에 두 번 빠질 뻔한 셈이다. /sp는 다 만들고 여섯 번 타이핑해본 뒤에야 알았고, /work는 설계 단계에서 피했다. 차이를 만든 건 기억이 아니라 적어둔 한 문단이었다.

CP0을 프리플라이트에 둔 이유

체크포인트를 이렇게 배치했다.

지점묻는 것
CP0 프리플라이트(질문 아님 — 더티 트리일 때만 멈춤)
CP1 대상 확정어떤 이슈를 할지 / 새로 만들지
CP2 계획 승인원인·수정 방향·영향 범위·검증 계획
CP3 푸시 직전diff·PR 본문 검토
CP4 CI 결과 후머지할지 / 실패를 고칠지

여기서 고민한 건 8단계 "머지 후 정리"였다. PR을 열고 나서 CI가 돌고 사람이 머지하기까지 몇 분이 걸린다. 그 시간을 세션에서 기다릴 것인가?

기다리기로 했다. 대신 정리는 다음 실행의 CP0가 맡게 했다.

1. 머지된 로컬 브랜치 정리 — gh pr list --head <브랜치> --state merged 로 판정
2. worktree 정리
3. 더티 트리 확인 (여기서만 멈춤)
4. main 최신화

이렇게 하면 정리에 드는 추가 명령어가 0회다. 다음 작업을 시작하는 순간 자동으로 청소된다. 그리고 원격 브랜치는 일부러 남긴다 — 머지 뒤에 문제를 발견할 수 있어서다. 저장소도 delete_branch_on_merge: false로 맞춰뒀다.

머지 판정에 git branch --merged를 쓰지 않은 건 스쿼시 머지된 브랜치가 잡히지 않기 때문이다. PR 상태로 판정해야 정확하다.

이슈 생성을 CP1이 아니라 CP2로 옮긴 이유

"이슈가 없으면 만들고, 있으면 그걸 해결한다"를 CP1에 넣으려다 걸렸다. 이 저장소의 이슈 템플릿은 이렇게 생겼다.

> 파일·줄 번호는 확인한 커밋을 함께 적는다.
 
## 현상
<!-- 무엇이 어떻게 잘못 보이는가. 추측이 아니라 관측된 것만. -->
 
## 원인 / 메커니즘

"추측이 아니라 관측된 것만"과 "원인 / 메커니즘"은 재현을 마치기 전에는 채울 수 없다. 이슈를 먼저 만들면 규칙을 어기게 되고, 규칙을 지키면 이슈를 나중에 만들어야 한다.

그래서 CP1에서는 "새 이슈로 진행할지"만 정하고, 실제 생성은 재현이 끝난 뒤 CP2에서 본문 초안과 함께 승인받도록 순서를 바꿨다. 자동화가 기존 규칙과 충돌하면 자동화 쪽을 굽히는 게 맞다.

만들고 나서 첫 실행에 바로 막혔다

커맨드를 완성하고 실제 이슈(#72)에 돌렸다. 첫 줄에서 멈췄다.

$ gh issue view 72
GraphQL: Projects (classic) is being deprecated in favor of the new
Projects experience. (repository.issue.projectCards)

내 환경의 gh(2.46.0)가 폐기된 Projects classic 필드를 함께 요청해서 GraphQL 에러가 난다. --json을 붙이면 정상 동작한다.

gh issue view 72 --json number,title,state,labels,body \
  --jq '"[\(.state)] #\(.number) \(.title)\n\(.body)"'

CP1의 첫 명령어라 커맨드 전체가 시작하자마자 죽는 결함이었다. 돌려보지 않았으면 몰랐을 종류의 버그다. 문서를 아무리 잘 써놔도 실행 환경이 다르면 그냥 안 된다.

절차가 실제로 건진 것들

여기서부터가 흥미로웠다. 재현·계측을 CP2 앞에 강제해둔 게 두 번 연속으로 값을 했다.

#72 — 이슈 본문의 전제가 틀렸다

완료 토글 진행 중에 편집·삭제 버튼이 눌리는 문제였다. 이슈 본문은 이렇게 적고 있었다.

토글 응답이 오기 전에 삭제가 성공하면, 뒤늦게 도착한 토글 응답이 이미 사라진 항목을 캐시에 되살릴 여지가 있습니다.

코드를 열어보니 성공 경로는 안전했다.

onSuccess: (updated) => {
  queryClient.setQueryData<Task[]>(key, (old) =>
    (old ?? []).map((task) => (task.id === updated.id ? updated : task))
  )
}

map이다. 이미 리스트에서 빠진 항목에는 아무 영향이 없다. 진짜 경로는 실패 롤백이었다.

onMutate: async ({ id, is_completed }) => {
  const previous = queryClient.getQueryData<Task[]>(key)  // ← 리스트 전체 스냅샷
  // ...
  return { previous }
},
onError: (_err, vars, context) => {
  queryClient.setQueryData(key, context.previous)  // ← 통째로 복원
}

스냅샷은 배열 전체다. 토글 대기 중 삭제가 성공해 항목이 빠진 뒤 토글이 실패하면, 삭제 이전 배열이 복원되면서 지워진 항목이 부활한다.

1. 토글 시작 → 스냅샷 [A, B, C] 저장
2. 대기 중 B 삭제 성공 → 캐시 [A, C]
3. 토글 실패 → 스냅샷 복원 → 캐시 [A, B, C]
                                    ↑ 되살아남

같은 결론(막아야 한다)에 도달했지만 경로가 달랐다. 그리고 이슈의 「할 일」 3번은 아예 전제가 틀려 있었다. "프로젝트 표준은 disabled:cursor-not-allowed"라고 적혀 있었는데, 실제 Button.tsx base 클래스는 disabled:opacity-50 disabled:pointer-events-none이었다. pointer-events-none이 걸려 있으면 커서 스타일은 표시되지도 않는다.

이슈에 코멘트로 정정을 남기고 해당 항목은 "해당 없음"으로 닫았다.

#73 — 죽은 코드가 죽지 않았을 수도 있었다

apiClient의 요청 인터셉터가 매 호출마다 Authorization: Bearer를 붙이는데 서버가 그 헤더를 읽지 않는다는 이슈였다. 확인해보니 사실이었다.

$ grep -rn "Authorization\|Bearer\|getSession" src/ | grep -v "src/lib/axios.ts"
(출력 없음)
 
$ grep -rh "^export const \(GET\|POST\|PATCH\|DELETE\)" src/app/api --include=route.ts | wc -l
22
$ grep -rh "^export const \(GET\|POST\|PATCH\|DELETE\) = withAuth" src/app/api --include=route.ts | wc -l
22

핸들러 22개가 전부 withAuth를 거치고, withAuth는 쿠키 세션만 본다. 헤더는 확실히 죽은 코드였다.

그런데 여기서 멈추지 않은 게 다행이었다. 지우려는 코드는 이거였다.

apiClient.interceptors.request.use(async (config) => {
  const supabase = createClient()
  const { data: { session } } = await supabase.auth.getSession()  // ← 부수효과
  if (session?.access_token) {
    config.headers.Authorization = `Bearer ${session.access_token}`
  }
  return config
})

getSession()은 만료된 토큰을 갱신하고 쿠키에 반영하는 부수효과가 있다. 헤더가 안 쓰이는 것과 이 함수 호출이 무의미한 것은 다른 얘기다. 그리고 미들웨어 matcher를 보니 이랬다.

matcher: ['/((?!api|_next/static|...).*)']
//            ↑ /api 제외

API 호출에는 미들웨어 세션 갱신이 걸리지 않는다. 이 인터셉터가 실질적으로 갱신을 떠받치고 있었다면 제거하는 순간 장애다.

결국 갱신 경로가 양쪽에 따로 있는 걸 확인하고서야 지웠다. 서버는 supabase/server.tssetAll이 실제로 쿠키를 쓰므로 Route Handler에서 withAuthgetUser()가 갱신분을 응답에 실어 보낸다. 클라이언트는 useAuth가 마운트 시 띄우는 브라우저 클라이언트가 autoRefreshToken 타이머를 돈다.

"안 쓰이니까 지워도 된다"에서 멈췄으면 인증이 조용히 망가지는 변경이 됐을 것이다.

덤으로 나온 별건

401 리다이렉트를 검증할 방법을 찾다가 하나 더 걸렸다. 응답 인터셉터는 401을 받으면 /login으로 보내는데, 미들웨어는 로그인된 사용자가 /login에 오면 /daily로 되돌린다. 쿠키는 유효한데 개별 API만 401을 주는 상황이면 두 경로를 무한히 왕복한다.

원인이 달라서 이번 PR에 묶지 않고 별도 이슈로 분리했다. 커맨드에 "원인이 여럿으로 갈리면 분리한다"를 적어둔 게 여기서 발동했다.

통과하는 테스트가 아무것도 잡지 못할 때

#72에서 회귀 테스트를 추가하고 돌렸더니 통과했다. 그런데 문득 이상했다. 수정 없이도 통과하는 테스트라면 회귀 방지 가치가 없다.

가드를 잠시 되돌려봤다.

$ perl -pi -e 's/disabled=\{deleting \|\| toggling\}/disabled={deleting}/g' TaskCard.tsx
$ pnpm test:e2e task.spec.ts -g "잠긴다"
 
  1 failed
    [chromium]  완료 토글 응답을 기다리는 동안 편집·삭제가 잠긴다

실패했다. 테스트가 유효하다는 뜻이다. 복구하고 넘어갔다.

이 확인을 매번 손으로 하고 있다는 걸 깨닫고 커맨드 6단계에 명문화했다.

회귀 테스트를 추가했다면 그 테스트가 수정 없이는 실패하는지 확인한다. 수정을 잠시 되돌려 새 테스트가 빨간불인 것을 보고 복구한다. 이 확인을 건너뛰면 아무것도 잡지 못하는 테스트가 통과하는 것만 보고 넘어가게 된다.

#73에서 바로 이 단계가 발동했다. 응답 인터셉터를 지워 401 테스트가 실패하는 걸 확인하고 복구했다.

같은 규칙을 두 곳에 쓰지 않는다

/sp 파이프라인에서 비싸게 배운 게 하나 더 있다. 앞서 인용한 것과 같은 날 로그에 남은 항목이다.

같은 규칙이 커맨드와 에이전트 양쪽에 복사돼 있었음. 기준을 한쪽만 고치면 검사자가 옛 기준으로 검사한다. 실제로 자동 측정 규칙을 추가할 때 두 파일을 동시에 고쳐야 했던 이력이 있다.

거기서는 기준을 traceability-rules.md라는 단일 원본으로 분리하고, 에이전트는 그 기준으로 작성하고 커맨드는 같은 기준으로 독립 검증하도록 바꿨다. 이중 검사는 유지하되 기준은 하나가 된다.

/work를 만들 때는 이 함정을 미리 피했다. 커맨드 맨 끝에 이렇게 적어뒀다.

절차의 단일 원본은 AGENTS.md 「작업 절차」다. 규칙 자체를 바꿀 때는 AGENTS.md를 고치고 이 파일은 실행 방법만 따라가게 한다.

실제로 작업 중에 "커밋 메시지는 무엇을·왜만 남기고 상세는 PR 본문이 맡는다"는 규칙이 새로 생겼는데, 커맨드가 아니라 AGENTS.md 컨벤션 절을 고치고 커맨드는 그걸 가리키게 했다.

커맨드가 자기를 고치게 만들기

첫 실행이 끝나고 개선점 3건이 나왔다.

  • CP0가 머지 안 된 브랜치를 조용히 건너뛰어서, 열린 채 방치된 PR이 로컬에 남아 있는 걸 보고하지 않았다
  • 검증이 pnpm lint && pnpm build뿐이라 타입 오류를 build까지 가서야 잡는다 (tsc --noEmit이 훨씬 빠르다)
  • 회귀 테스트 유효성 확인이 절차에 없었다

셋 다 반영하고 다시 /work 73을 돌렸더니 세 개가 전부 실제로 발동했다. CP0가 chore/claude-tooling-commands-skills (PR #58 OPEN)을 보고했고, tsc --noEmit이 build 앞에서 돌았고, 회귀 테스트 유효성 확인이 절차대로 수행됐다.

/sp에도 같은 장치가 있다. 커맨드를 고치면 workflow-log.md에 "무엇이 막혔고 어떻게 고쳤는지"를 한 줄 남기게 해뒀다. 지금 그 로그가 이 글의 절반쯤을 채우는 재료가 됐다.

남은 것

지금 상태는 이렇다.

✅ #73 머지 완료 (PR #86) — 로컬 브랜치 정리, 원격 유지
   남은 priority: high — #75

세 번의 실행에서 PR 4개가 머지됐고 이슈 2개가 자동으로 닫혔다. 새로 발견해 등록한 이슈가 1개 늘었다.

완벽하진 않다. strict: true를 켠 탓에 PR을 두 개 열어두면 뒤엣것이 매번 재검증을 받아야 한다. 혼자 작업할 땐 거의 안 걸리지만, 체감이 나빠지면 다시 끌 생각이다.

배운 것

자동화의 목표는 사람을 빼는 게 아니라 사람이 빠져도 되는 구간을 정확히 자르는 것이다. 8단계 중 CP2(계획 승인)와 CP3(푸시 전 검토)는 앞으로도 자동화하지 않을 생각이다. 전자는 코드를 고치기 전 마지막 방어선이고, 후자는 원격에 공개되기 전 마지막 되돌릴 수 있는 지점이다. 여기서 사람이 멈추는 건 비효율이 아니라 설계다.

그리고 문서로만 존재하는 절차는 절차가 아니다. AGENTS.md에 8단계를 적어둔 지 꽤 됐지만, 실제로 커맨드로 묶어보니 이슈 생성 시점이 템플릿과 충돌하고, gh issue view가 아예 실행되지 않고, required check가 절반만 걸려 있었다. 글로 적혀 있을 때는 전부 문제없어 보였던 것들이다.