조용한 폴백이 AI 장애를 40일간 숨긴 이야기: Commit Helper 2.0.2

🪄 Commit Helper는 어떤 도구인가

Commit Helper는 git add로 올린 변경 사항을 분석해 커밋 메시지를 추천해 주는 CLI다. diff를 Groq의 LLM에 보내 feat, fix, refactor 같은 타입과 구체적인 설명을 받아 온다. 사용자는 추천 목록에서 하나를 고르거나, 원하는 메시지를 직접 입력할 수 있다.

@seyun31/commithelper - npmGit의 변경사항을 분석하여 commit 메시지를 자동으로 생성하는 도구www.npmjs.com/package/@seyun31/commithelper

처음 만들 때부터 AI 연결이 끊겨도 동작하는 구조는 갖춰 두었다. AI 호출이 실패하면 diff의 추가·삭제 줄 수를 보고 타입을 고르는 규칙 기반 추천으로 넘어간다. 커밋은 하루에도 여러 번 반복하는 작업이다. 그래서 네트워크나 API에 문제가 생기더라도 추천 목록은 계속 보여줘야 한다고 생각했다.

그런데 폴백을 만들어 두는 것과 폴백을 제대로 처리하는 것은 다른 문제였다. 폴백이 실제로 동작한 뒤에야 그 차이를 알게 됐다. 이 글에서는 그 과정과 2.0.2에서 바꾼 내용을 정리한다.


💥 발견: 폴백은 있었지만 처리는 없었다

오랜만에 Commit Helper로 커밋을 하다가 추천 메시지가 평소와 다르다는 걸 느꼈다. 설명이 generateMessage.ts 버그 수정, generateMessage.ts 리팩토링처럼 파일 이름에 동사만 붙은 모양이었다. 이는 AI가 아니라 규칙 기반 폴백의 출력 형식이다.

원인은 2.0.1의 다음 코드에 있었다.

try {
  const groq = getGroqClient();
  if (!groq) throw new Error("GROQ_API_KEY is not set");
 
  const completion = await groq.chat.completions.create({
    model: "llama-3.3-70b-versatile", // Groq 모델
    // ...
  });
  // ...
} catch {
  // API 호출 실패 시 조용히 fallback으로 넘어감
}

Groq가 2026-08-16에 llama-3.3-70b-versatile 모델을 종료했다. 그 뒤로 모든 요청이 The model ... does not exist 오류로 실패했지만, catch가 오류를 알리지 않은 채 규칙 기반 추천으로 넘어갔다. 화면에는 항상 추천 목록이 나타났기 때문에, 사용자는 물론 만든 나도 문제를 알아채지 못했다. 그렇게 2.0.2를 배포한 9월 25일까지 40일 동안, Groq 키를 설정한 사용자도 AI 추천을 받지 못했다. 그 사이 9월 14일에 배포한 2.0.1도 이미 종료된 모델을 그대로 쓰고 있었다.

"추천 목록은 항상 떠야 한다"는 목표는 지켜졌고, 폴백도 설계한 대로 동작했다. 하지만 폴백이 일어난 뒤의 처리가 하나도 없었다는 것이 문제였다. 다시 살펴보니 크게 세 가지가 빠져 있었다.

  1. 실패를 알리지 않았다. 왜 폴백했는지 아무런 흔적도 남기지 않아, 모델 종료라는 장애가 40일 동안 정상인 상태처럼 보였다.
  2. 키가 없으면 폴백이 기본 경로였다. AI 추천을 받으려면 사용자가 Groq 키를 직접 발급해야 했다. 위 코드의 if (!groq) throw 한 줄이 키가 없는 사용자를 바로 폴백으로 보낸다. 그런 사용자에게는 "만일을 위한" 폴백이 사실상 유일한 경로였다.
  3. 폴백의 품질을 재 본 적이 없었다. 비상용이라고만 생각해, 규칙 기반 추천이 얼마나 정확한지 측정하지 않았다.

2.0.2에서는 이 세 가지 문제를 하나씩 개선했다.

빠져 있던 것2.0.2의 변경
실패를 알리지 않음모델 교체 + 폴백 이유 경고
키가 없으면 늘 폴백키 없이 쓰는 공용 API 서버
폴백 품질을 모름실제 커밋 30개로 측정하고 규칙 기반 추천 v2로 개선

🔧 변경 1: 모델 교체와 폴백 경고

모델은 Groq에서 현재 제공하는 openai/gpt-oss-120b로 바꿨다. 그리고 다시 같은 일이 생겼을 때 바로 알 수 있도록, 폴백할 때 이유를 한 줄 남기게 했다.

try {
  return await requestAIMessages(files, fullDiff);
} catch (error) {
  // 조용히 넘어가면 모델 종료 같은 장애를 한참 뒤에야 알게 되므로 이유를 한 줄 남긴다.
  // stderr로 출력해서 추천 목록 프롬프트(stdout)와 섞이지 않게 한다.
  console.warn(describeFallbackReason(error));
}

이제 모델이 또 종료되면 이런 경고가 뜬다.

⚠️  AI 추천에 실패해 규칙 기반 추천을 사용합니다: The model ... does not exist

경고 문구를 만드는 describeFallbackReason은 상황별로 메시지를 나눈다.

  • 키도 없고 공용 서버도 끈 경우: 키 설정 방법을 안내한다.
  • 공용 서버 한도 초과(429): 개인 키를 설정하면 한도와 상관없이 쓸 수 있다고 안내한다.
  • 그 밖의 오류: Groq SDK나 서버가 주는 404 {"error":{"message":"..."}} 형태에서 안쪽 message만 꺼내 120자까지 보여 준다.

경고가 추천 목록을 가리지 않도록 console.warn(stderr)으로 출력했다. inquirer 프롬프트는 stdout을 사용하므로 두 출력이 섞이지 않는다.

그래도 만든 사람은 모른다

이 경고는 사용자의 터미널에만 뜬다. 사용자가 경고를 보고 이슈를 남겨 주지 않는 한, 만든 나는 여전히 장애를 모른다. 40일 동안 몰랐던 구조가 절반만 고쳐진 셈이다.

다행히 2.0.2에서 공용 서버(변경 2)를 추가하면서 단서가 하나 생겼다. 서버는 Groq 호출이 실패하면 Groq request failed: 404 같은 상태 코드를 Workers Logs에 남긴다. 다음 단계로는 두 가지를 고려하고 있다.

  • 실패 로그 알림: Workers Logs에서 404(모델 없음)나 429(한도 초과)가 일정 횟수를 넘으면 알림을 받는다.
  • 모델 스모크 테스트: CI에서 주기적으로 GROQ_MODEL로 짧은 요청을 보내, 모델이 아직 살아 있는지 확인한다.

둘 다 아직 구현하지 않았다. 폴백을 사용자에게 보여 주는 것과 운영자에게 알리는 것은 별개의 문제였다.


🛰️ 변경 2: 키 없이 쓰는 공용 API 서버

두 번째 문제는 키가 없으면 폴백이 기본 경로라는 점이었다. npx 한 줄로 바로 사용하는 도구지만, AI 추천을 받으려면 Groq에 가입해 키부터 발급해야 했다. 그렇다고 내 키를 패키지에 넣을 수는 없다. npm 패키지는 누구나 node_modules에서 코드를 확인할 수 있으므로, 키를 넣는 순간 사실상 공개된다.

그래서 키는 Cloudflare Workers 서버에만 두고, CLI는 서버 주소만 알게 했다. 이제 CLI는 세 갈래로 동작한다.

git diff --cachedGROQ_API_KEY 있음?공용 서버 사용?규칙 기반 추천아니오아니오 (off)Groq 직접 호출공용 서버 → Groq예예AI 응답 성공?예실패요청 오류 · 한도 초과 · 빈 응답 · JSON 파싱 실패추천 목록

개인 키가 있으면 Groq를 직접 호출하고, 없으면 공용 서버를 거친다. 어느 쪽이든 실패하면 규칙 기반 추천으로 넘어간다.

이때 실패 판단은 한곳에 모았다. requestAIMessages는 실패하면 예외를 던지기만 하고, 폴백 여부는 호출하는 generateMessages가 정한다. 이 구조 덕분에 경고 문구를 한곳에서 만들 수 있었다. 정확도를 측정할 때도 "AI가 틀린 것"과 "AI가 실패해서 규칙 기반이 답한 것"을 섞지 않고 따로 셀 수 있었다.

누구나 호출할 수 있는 서버에 내 키가 연결되는 만큼, 운영 원칙도 몇 가지 정했다.

  • 실제 한도는 Groq 쪽이다. Workers 무료 플랜은 하루 10만 요청이지만, Groq 키 하나의 무료 한도(gpt-oss-120b 기준 분당 8K 토큰·하루 1,000회)를 모두가 나눠 쓴다. 요청 하나가 약 3K 토큰이라 전체 사용자를 합쳐 분당 2~3건이 한계다. 공용 서버는 키 없이 시작해 보는 경로이고, 자주 쓴다면 개인 키가 필요하다.
  • 아무 LLM 프록시로 쓸 수 없게 막았다. 요청으로 받는 건 files와 diff뿐이고, 시스템 프롬프트·모델·temperature는 서버에 고정했다. IP당 분당 10회, 본문 200KB로 제한한다.
  • 오류는 폴백에 필요한 만큼만 전달한다. Groq 한도 초과(429)는 CLI가 "공용 한도 초과" 안내를 띄울 수 있게 그대로 넘기고, 그 밖의 오류는 키나 조직 정보가 섞일 수 있어 502로 감싼다.
  • diff는 저장하지 않는다. CLI는 diff 전체를 서버로 보내고, 서버가 앞 4000자와 파일 목록만 Groq로 넘긴다. 서버는 요청 내용을 저장하거나 로그로 남기지 않는다. 외부로 보내면 안 되는 저장소라면 COMMITHELPER_API_URL=off로 아예 보내지 않을 수 있다.
# 외부로 diff를 보내지 않고 규칙 기반만 사용
COMMITHELPER_API_URL=off npx @seyun31/commithelper

모델, 프롬프트 조립, 응답 파싱은 src/logic/aiRequest.ts 하나를 CLI와 서버가 함께 쓴다. 다음에 모델이 또 종료되면 GROQ_MODEL 한 줄을 바꾸고 서버와 npm 패키지를 다시 배포하면 된다.


📊 변경 3: 규칙 기반 추천 v2

폴백 경고를 추가하고 나니 한 가지 질문이 생겼다. AI가 실패했을 때 사용자가 보는 규칙 기반 추천은 얼마나 정확할까? 40일 동안 모든 사용자가 이 추천만 보고 있었다.

측정 방법

팀 프로젝트 저장소에서 사람이 직접 타입을 붙인 커밋을 골라 정답으로 삼았다. Commit Helper에는 docs·chore 타입이 없기 때문에, etc를 추천하면 정답으로 처리했다.

  • 조정용과 채점용을 나눴다. 규칙의 임계값과 패턴은 dev 커밋 56개만 보면서 고쳤고, 결과는 그와 겹치지 않는 채점용 커밋 30개로만 쟀다. 채점용 30개는 규칙을 고치는 동안 열어 보지 않았다.
  • 타입별 개수를 정해 뽑았다. 저장소에 test·docs 커밋이 적어서 비율대로 뽑으면 0개가 되기 때문에, 채점용은 feat 7, fix 7, refactor 6, chore 4, test 3, docs 3개로 맞췄다(층화 추출). 그래서 실제 커밋 분포와는 다르다.
  • AI는 같은 30개로 3번 실행해 평균을 냈다. temperature가 0.3이라 같은 diff에도 답이 조금씩 달라지기 때문이다.

지표는 두 가지다.

  • top-1 일치율: 첫 번째 추천의 타입이 정답과 같은 비율
  • 목록 안 포함률: 추천 목록 어딘가에 정답 타입이 있는 비율

2.0.1 규칙 기반: 30개 중 23개를 fix로 예측했다

예측 타입예측 횟수그중 정답
fix237
feat62
etc11

top-1 일치율은 33.3% (10/30) 였다. 원인은 두 가지였다.

  1. 파일마다 따로 판정했다. 커밋 전체가 아니라 파일별로 타입을 정했기 때문에, 알파벳 순서상 첫 파일의 판정이 그대로 첫 추천이 됐다.
  2. 추가와 삭제가 섞이면 무조건 fix였다. 기존 파일을 고치면 거의 항상 추가·삭제가 함께 생긴다. 그러니 대부분의 커밋이 fix가 됐다.

커밋 단위로 대표 타입을 먼저 고른다

v2는 커밋 전체(최대 20개 파일)를 보고 대표 타입을 하나 먼저 고른 뒤, 파일별 추천을 서로 다른 타입의 대안으로 덧붙인다. 파일은 경로에 따라 test / docs / config / code로 나누고, 다음 순서대로 판정한다.

  1. 문서·설정 파일만 바뀌었으면 → etc
  2. 코드 파일보다 테스트 파일이 많으면 → test
  3. 코드가 지워지기만 했으면 → remove
  4. 새 코드 파일이 생겼으면 → feat. 단, 기존 파일에서 10줄 이상 더 빠져나갔으면 코드를 옮겨 나눈 것으로 보고 refactor
  5. 기존 파일에 방어 로직(?., ??, try/catch, if (!…), null 비교, typeof 검사)이 추가됐으면 → fix
  6. 거의 추가만 했으면(추가가 삭제의 3배 이상, 10줄 이상 차이) → feat
  7. 나머지는 → fix
// 기존 코드에 방어 로직을 덧붙이는 모양 (옵셔널 체이닝, 기본값, 예외 처리, 빈 값 검사)
const FIX_PATTERN =
  /\?\.|\?\?|\bcatch\b|\btry\s*\{|if\s*\(\s*!|[!=]==?\s*(null|undefined)\b|typeof\s/;

대표 타입 뒤에는 파일별 판정을 추가하되, 같은 타입은 한 번만 넣는다. 이렇게 하면 목록이 fix, fix, fix로 채워지지 않고 서로 다른 대안을 보여 준다.

결과

방식top-1 일치율목록 안 포함률
규칙 기반 2.0.133.3% (10/30)76.7% (23/30)
규칙 기반 2.0.263.3% (19/30)93.3% (28/30)
AI (gpt-oss-120b, 3회 평균)57.8%68.9%

top-1 일치율이 33.3% → 63.3% 로 올랐고, 예측도 fix 하나에 몰리지 않고 feat 10, fix 10, etc 5, test 4, refactor 1로 흩어졌다.

목록 안 포함률은 AI와 단순히 비교하기 어렵다. 규칙 기반은 같은 타입을 한 번씩만 넣기 때문에 목록에 평균 2.6개의 서로 다른 타입이 들어간다. AI는 평균 2.9개를 추천하지만, refactor, refactor, refactor처럼 겹치는 경우가 많아 서로 다른 타입은 평균 1.4개뿐이다. 따라서 93.3%와 68.9%의 차이는 정확도보다 목록이 얼마나 다양한가를 보여 주는 결과에 가깝다.

틀린 사례를 몇 가지 살펴보면 규칙의 한계가 더 분명해진다.

원래 커밋정답규칙 기반 2.0.2왜 틀렸나
페이지에 돌아왔을 때 이전 내용이 남는 버그 해결fixfeat+17/-1로 거의 추가만 했고, 방어 로직 패턴이 없어서
report 컴포넌트 구조 분리refactorfeat새 파일이 13개 생겼는데, 기존 파일에서 빠져나간 줄이 없어서
이미지 추가chorefeat.svg를 코드 파일로 분류해서 "새 파일 = feat"가 됐다

🔍 회고: AI와 규칙은 잘 맞히는 타입이 정반대였다

측정하면서 의외였던 점은 AI의 top-1 일치율(57.8%)이 새 규칙(63.3%)과 비슷하거나 오히려 낮았다는 것이다. 하지만 타입별로 나눠 보면 둘이 강점을 보이는 영역은 정반대였다.

정답 타입표본규칙 기반 2.0.2AI 평균
feat785.7%28.6%
fix771.4%42.9%
refactor60.0%72.2%
test3100.0%77.8%
docs3100.0%100.0%
chore450.0%66.7%
  • 규칙은 refactor를 하나도 못 맞혔다. refactor는 "동작은 그대로인데 구조를 바꿨다"는 의도의 문제라, 줄 수와 패턴만으로는 fix나 feat와 구분되지 않는다.
  • AI는 feat를 자주 refactor로 봤다. 캘린더 데이터 조회 API 연동, 히트맵 인디케이터 구현처럼 사람이 feat로 붙인 커밋을 AI는 세 번 모두 refactor로 답했다. 기존 파일을 많이 고친 diff를 보면 "구조 변경"으로 읽는 경향이 있었다.
  • "정답"도 흔들린다. [CHORE] NotificationPermission 컴포넌트를 화살표 함수로 수정은 사람이 chore로 붙였지만 refactor로 봐도 이상하지 않다. 사람의 라벨도 팀 컨벤션과 그날의 판단이 섞인 값이다.

따라서 이번 결과만으로 "규칙이 AI보다 낫다"고 볼 수는 없다. 30개는 작은 표본이고, 5.5%p 차이는 커밋 2개가 안 되는 차이다. AI 3회 중 가장 잘 나온 회차(19/30)는 규칙과 같았다. 커밋 타입은 원래 정답이 하나로 떨어지지 않는 문제이기도 하다. 다만 두 가지는 분명해졌다.

  1. 폴백은 '아무거나'가 아니라 측정해야 하는 기능이다. 이 표본 기준으로 2.0.1의 폴백은 top-1이 33%였고, 40일 동안 모든 사용자가 그 추천을 보고 있었다. 폴백이 실제로 쓰이는 경로라면 메인 경로만큼 품질을 따져야 한다.
  2. AI와 규칙은 서로를 보완한다. 규칙은 파일 종류와 줄 수처럼 겉으로 드러나는 신호에, AI는 의도에 강하다. AI 목록은 같은 타입이 겹치는 경우가 많으니, 다음에는 AI 추천 뒤에 AI가 고르지 않은 타입의 규칙 기반 대안을 붙여 보는 것도 시도해 볼 만하다.

🤔 트레이드오프

  • 공용 한도를 모두가 나눠 쓴다. 앞서 말한 대로 전체 사용자를 합쳐 분당 2~3건이 한계다. 한도를 넘으면 규칙 기반으로 넘어가며 개인 키 설정을 안내한다.
  • diff가 외부 서버를 거친다. 서버가 저장하지 않더라도 Groq의 데이터 처리 정책은 따로 적용된다. 민감한 저장소에서는 COMMITHELPER_API_URL=off나 개인 키를 써야 한다.
  • 운영할 서버가 생겼다. CLI만 배포하면 끝이던 프로젝트에 Workers 배포, 시크릿 관리, 로그 설정이 붙었다. 모델이 종료되면 이제 서버와 npm 패키지를 둘 다 다시 배포해야 한다.
  • 규칙의 임계값은 한 저장소에서 정해졌다. 조정용과 채점용 커밋을 나누긴 했지만 둘 다 같은 팀 저장소에서 뽑았다. "10줄 이상 빠져나가면 refactor", "추가가 삭제의 3배 이상이면 feat" 같은 기준은 그 팀의 커밋 습관에 영향을 받으니, 다른 저장소에서는 다른 결과가 나올 수 있다.

📌 정리

  • AI 연결이 끊겨도 동작하는 폴백은 처음부터 있었지만, 실패를 알리고, 폴백 빈도를 줄이고, 폴백 품질을 재는 처리는 없었다. 그래서 Groq 모델이 종료된 뒤 40일 동안 모든 AI 요청이 실패했는데도 아무도 몰랐고, 2.0.1은 그 상태로 배포됐다.
  • 2.0.2는 모델을 openai/gpt-oss-120b로 바꾸고, 폴백할 때 이유를 stderr로 한 줄 남긴다. 다만 운영자에게 알리는 장치는 아직 남은 과제다.
  • 키 없이도 AI 추천을 받을 수 있게 Cloudflare Workers 공용 서버를 두었다. 키는 서버에만 있고, 프롬프트·모델 고정과 IP당 요청 제한으로 남용을 막는다.
  • 규칙 기반 추천을 파일 단위에서 커밋 단위 판정으로 바꿔, 조정에 쓰지 않은 실제 커밋 30개 기준 top-1 일치율이 33.3% → 63.3% 로 올랐다.
  • 측정해 보니 AI와 규칙은 잘 맞히는 타입이 정반대였다. 폴백도 측정해야 하는 기능이다.
npx @seyun31/commithelper

본문의 측정은 Commit Helper 2.0.1 → 2.0.2, openai/gpt-oss-120b(temperature 0.3) 기준입니다.