하루 17개의 PR, 단 한 명의 QA: E2E 실패 분류(Triage)를 자동화한 방법
요약
QA 엔지니어 한 명이 대규모 PR을 관리하기 위해 GitHub Actions와 Claude를 활용하여 E2E 테스트 실패 사례를 자동 분류(Triage)하는 시스템을 구축한 사례를 소개합니다. 테스트 실패 원인을 분석하고 담당자를 지정하는 과정을 자동화하여 운영 비용을 획기적으로 절감했습니다.
핵심 포인트
- GitHub Actions와 Claude를 결합한 E2E 테스트 실패 자동 분류 시스템 구축
- 테스트 실패 원인을 분석하여 적절한 담당자에게 티켓을 할당하는 워크플로우 자동화
- 수동 조사 시간을 줄이고 엔지니어의 주의력을 핵심 개발 업무에 집중
- LLM을 활용해 구조화된 로그와 diff 데이터를 기반으로 실패 원인 식별
이런 상황을 상상해 보세요: 18명의 AI 보조를 받는 개발자들이 **매 영업일 평균 17개의 풀 리퀘스트(Pull Requests, PRs)**를 머지합니다. 피크 데이에는 33개까지 올라가죠. 그리고 그 시점에, 정확히 단 한 명의 QA 엔지니어가 이 모든 과정을 지켜보고 있었습니다. 매일 상황이 엉망이 되지 않았다는 사실은 기적처럼 보입니다. 스포일러를 하자면, 그것은 기적이 아닙니다. 스테이징(Staging) 환경에서의 E2E 테스트, 그리고 사람이 직접 할 필요가 없도록 실패 사례를 분류(Triage)해 주는 봇 덕분입니다.
제가 바로 그 QA 엔지니어입니다. 저희 pdf.net이 GitHub Action과 Claude를 활용해 모든 실패한 실행(Red run)을 분류하게 만든 과정—아키텍처, 코드, 그리고 저희가 겪었던 시행착오(무고한 사람을 비난하거나, 티켓을 분실하거나, 침묵이 최악의 선택일 때조차 침묵해 버리는 봇 등)—를 공유합니다. 결론부터 말씀드리자면, 이 시스템 전체를 운영하는 데 드는 월 비용은 엔지니어의 한 시간 인건비보다 적습니다.
우리가 여기까지 온 과정
저희 팀은 설계 단계부터 AI-first를 지향합니다. 개발자들은 AI 어시스턴트와 함께 작업하며, 인원수에 맞춰 수동 테스트를 확장하는 대신 자동화에 승부수를 던졌습니다. 자동화 테스트(Autotests)는 단순히 "혹시 모를 상황"을 위한 안전망이 아닙니다. 그것은 **우리가 무언가 잘못하고 있다는 것을 알려주는 주요 트리거(Trigger)**입니다.
저희는 하루에 한 번 배포하며, 원래는 배포 시점에 모든 검증이 이루어졌습니다. 15개 이상의 PR이 모이고, 테스트가 실행되며, 만약 무언가 실패(Red)하면 단 한 명의 QA 엔지니어가 조사해야 했습니다. 단 한 번의 배포에 여러 개의 고장 난 PR이 포함될 수 있었고, 배포를 기다리는 동안 17개 이상의 용의자 중에서 각각의 원인을 찾아내야 했습니다. 아침 시간은 탐정 업무로 변했습니다.
그래서 저희는 루프를 왼쪽으로 이동(Shift left)시켰습니다. 인프라에 대해 짧게 언급하자면, 모든 PR은 Vercel 프리뷰(Preview)(개발 환경)를 생성합니다. (여기서 전체 E2E 범위를 실행하는 것은 의미가 없습니다. 속도가 느리고, 기능의 절반이 프로덕션과 일치하지 않는 플래그(Flags) 뒤에 숨겨져 있기 때문입니다). 반면 **스테이징(Staging)**은 사실상 프리-프로덕션(Pre-prod) 역할을 합니다. 모든 머지(Merge)는 스테이징에 반영되며, 코드는 거기서 프로덕션으로 배포됩니다. 이제 E2E는 스테이징으로 머지될 때마다 실행됩니다. 이로 인해 조사 대상 범위가 하루치 묶음이 아닌 하나의 최신 머지로 좁혀졌으며, 배포는 "조립된 상태에서 작동하는지"를 확인하는 최종 점검과 자동화가 커버할 수 없는 수동 테스트 단계로 변모했습니다.
그 대가는 다음과 같았습니다. 이제 17개의 머지(merge)가 일어날 때마다 빨간색(실패) 실행이 발생할 가능성이 있으며, 각각에 대해 빠른 조사가 필요하고, 조사가 완료될 때까지 릴리스(release) 여부가 불확실해집니다. "실패한 실행을 보고 누구에게 전달할지 결정하는" 업무를 위해 사람을 채용하는 것은 과잉 대응입니다. 그것은 역할이 아니라 주의력을 분산시키는 요소일 뿐입니다. 논리는 명확했습니다. 우리의 테스트가 자동화라면, 그 실패를 분류(triage)하는 것 또한 자동화되어야 합니다.
다행히 실패 원인은 정해진 목록 안에 있습니다. 메인 리포지토리(main repo)의 PR 회귀(regression), 인접한 백엔드 리포지토리(그곳의 배포가 우리의 E2E도 트리거함), 외부 서비스, CI 인프라, 또는 플레이크(flake, 일시적 오류)입니다. 수동 분류는 항상 구조화된 입력값에 따른 동일한 기계적 과정을 거쳤습니다: 실패한 테스트, 디프(diffs), 로그 → 3자 통화. 이는 정확히 LLM(Large Language Models)이 잘할 수 있는 종류의 작업입니다.
아키텍처: 판정(verdict) → 라우팅(routing)
분류(triage)는 재사용 가능한 복합 GitHub Action으로, 모든 E2E 실행(성공 및 실패 모두) 후에 호출됩니다. 이를 중심으로 Allure TestOps(테스트 결과), Linear(티켓), Slack(알림), Claude API(두뇌)가 연결되어 있습니다.
staging에서의 e2e 실행
│
빨간색(실패) ──────────┴────────── 초록색(성공)
...
실패(red run) 시, 봇은 Allure에서 단계별 정보가 포함된 실패 테스트, 작업 로그(job logs), 배포 커밋 범위, 그리고 반영된 PR들의 디프(diffs)를 수집하여 Claude(Opus 4.8, 폴백(fallback)으로 Sonnet 5 사용)에게 **판정(verdict)**과 수정 가설(fix hypothesis)을 요청합니다:
const verdict = await askClaude({ failedTests, jobLogs, deployRange, prDiffs })
// => 'likely-pr-related' (PR 관련 가능성 높음) | 'likely-not-pr-related' (PR 관련 아닐 가능성 높음) | 'insufficient-data' (데이터 부족)
...
핵심 결정 사항은 다음과 같습니다: 판정(verdict)은 단순히 텍스트를 쓰는 것이 아니라 경로(route)를 선택합니다. PR 관련일 경우 → 가설이 포함된 티켓 생성 및 Slack 스레드 생성. 그 외의 경우 → 아직 티켓을 생성하지 않음: 알림을 보내고 자동 재실행(auto-rerun) 1회 수행. 초록색(성공) 재실행 시 — 플레이크(flake)였으므로 소음 없이 종료. 빨간색(실패) 재실행 시 — 티켓 생성 및 온콜(on-call) 담당자 언급.
🔴 staging에서 e2e 실패 — 3개 테스트
판정: likely-pr-related (PR 관련 가능성 높음)
가설: PR #1234의 회귀(regression) — 파일 업로드 핸들러
...
티켓은 하나의 Linear 큐에 쌓이며, 라벨을 통해 프론트엔드 배포와 백엔드 배포를 구분합니다. 한 가지 수동 습관이 모든 비용을 상쇄합니다. 확인된 실제 버그에는 bug 라벨이 붙습니다. 따라서 "자동 테스트가 얼마나 많은 버그를 잡아냈고, 노이즈(noise)는 얼마나 되었는가"는 단순한 큐 필터링만으로 확인할 수 있습니다.
노이즈 제어를 위한 네 가지 메커니즘: 중복 제거 (dedup) (반복되는 실패는 새로운 티켓을 생성하는 대신 기존에 열려 있는 자동 티켓과 매칭됨), 자동 종료 (auto-close) (테스트가 통과(green run)되면 티켓을 종료하고, 장애 지속 시간과 함께 복구 사항을 게시하며, 열려 있는 PR들에 리베이스(rebase)를 권고함), 두 개의 채널 (two channels) (티켓 기반 게시물은 중요 채널로 가고, 재실행 관련 잡담은 작업 채널에 머무름), 그리고 수동 뮤팅 (manual muting) (수정 없이 종료된 티켓은 'Done'이 아닌 'Canceled'로 이동하며, 복구 시에는 그대로 둡니다).
서류상으로는 완벽합니다. 이제 네 가지 문제점(rake)을 살펴보겠습니다.
문제점 #1: 영원히 열려 있는 티켓
복구(Recovery) 프로세스는 세 곳에 기록을 남깁니다: Slack 게시물, 'Done' 상태 전환, 그리고 다음 통과(green run) 시 해당 티켓을 건너뛸 수 있도록 하는 recovered-at: <sha> 마커 댓글입니다. 첫 번째 버전은 마커를 가장 먼저 작성했습니다. Slack 게시물은 성공했지만, 네트워크 문제로 'Done' 상태 전환이 실패하면 어떻게 될까요? 마커는 이미 작성되어 있고, 이후의 모든 실행은 "이미 처리됨"이라고 판단합니다. 영원히 말이죠.
해결책은 전형적인 멱등적(idempotent) 시퀀스입니다. 각 단계는 자체 마커로 보호되며, "완전 처리됨(fully processed)"은 가장 마지막에 작성됩니다:
if (!hasSlackPostedComment(issue.comments)) {
const resp = await slack.postMessage(buildRecovery({ issue, attribution, cause }))
await linear.addComment(issue.id, `slack-recovery-posted:${resp.ts}`)
...
중간에 실패하더라도, 다음 실행 시 아무것도 중복 생성하지 않고 나머지 단계를 완료합니다. 보너스: 상태 정보가 티켓 자체의 댓글에 저장되므로 별도의 외부 데이터베이스가 필요 없고, 사람이 읽을 수 있으며, 티켓과 함께 자동으로 정리(garbage-collected)됩니다.
문제점 #2: 무고한 이를 비난하는 봇
복구 게시물(Recovery posts)은 "수정해 주셔서 감사합니다, @author"로 끝났습니다. 여기서 @author는 첨부된 수정 PR(fix-PR)로부터 계산되거나, 실패한 경우(fallback) 녹색 실행(green run)의 배포 범위(deploy range)에 포함된 커밋으로부터 추출되었습니다. 하지만 외부 서비스가 다시 정상화되어 티켓이 종료된 경우에도, fallback 방식은 여전히 "작성자(author)"를 찾아냈습니다. 즉, 해당 배포와 무관하게 단순히 같은 배포 범위에 포함되었던 무고한 사람의 PR이 작성자로 지목된 것입니다. 존재하지도 않는 수정에 대해 공개적으로 감사 인사를 받는 상황이 발생한 것입니다. 이런 일이 몇 번 반복되면 봇에 대한 신뢰는 사라집니다.
해결책은 복구 원인 분류기(recovery-cause classifier)를 도입하는 것이었습니다. 이는 AI를 사용하지 않는 순수 함수(pure function)입니다:
type RecoveryCause = 'code-fix' | 'external' | 'ambiguous'
const classifyFixCause = ({ deployRange, originalVerdict }): RecoveryCause => {
...
이제 기여 표시(Attribution)는 code-fix인 경우에만 실행됩니다. external과 ambiguous는 이름을 표기하지 않는 템플릿을 사용합니다. ambiguous는 텔레메트리(telemetry) 역할도 겸합니다. 즉, 모델의 판정(verdict)이 실제 원인과 얼마나 자주 어긋났는지를 나타냅니다.
문제점 #3: 혼합된 실행(mixed runs)과 잘못된 "재실행이 도움이 됨" 메시지
첫 번째 디스패처(dispatcher)는 if (hasFailures) runAnalysis() else runRecovery() 형식이었습니다. 이는 혼합된 실행(mixed run) 상황에서 문제가 발생합니다. 즉, 이전 티켓의 테스트는 수정되었지만 새로운 무언가가 실패한 경우입니다. 이 경우 실행 결과는 빨간색(red)이 되고, 복구(recovery)는 실행되지 않으며, 수정된 티켓은 열린 상태로 방치됩니다. 해결책은 모든 호출 시 두 경로를 모두 실행하는 것입니다. 복구를 먼저 실행하며, 각 경로는 작업할 내용이 없으면 아무 작업도 하지 않습니다(no-oping):
await runRecovery(ctx) // 복구된 모든 항목을 종료
await runAnalysis(ctx) // 실패한 모든 항목을 분류(triage)
즉각적인 영향 범위(blast radius): 기존의 "재실행이 도움이 됨(rerun helped)" 신호는 단순히 runAttempt >= 2였으며, 복구가 녹색 실행(green run)에서만 작동할 때는 안전했습니다. 하지만 복구가 항상 실행되도록 변경되자, 봇은 빨간색(red) 재실행 결과에 대해서도 "✅ 복구됨 — 자동 재실행이 도움이 되었습니다"라는 메시지를 즐겁게 게시했습니다. 따라서 명시적인 가드(guard) 조건이 필요했습니다: runAttempt >= 2 && failureSet.size === 0. 이전의 호출 순서에 암묵적으로 의존하던 모든 체크 로직은 이제 명시적으로 변경되어야 합니다.
문제점 #4: 타인의 배포에 의해 트리거된 실행
우리의 E2E (End-to-End) 테스트는 프론트엔드 리포지토리(frontend repo)에 존재하며 UI를 통해 전체 사용자 시나리오를 수행합니다. 따라서 백엔드 결함도 잡아낼 수 있으며, 백엔드의 스테이징 배포(staging deploy)는 workflow_dispatch를 통해 동일한 워크플로우를 트리거합니다. 직접 겪으며 배우게 된 GitHub Actions의 기묘한 특징은, 디스패치된 실행(dispatched run)에는 푸시 컨텍스트(push context)가 없다는 점입니다. GITHUB_EVENT_BEFORE는 비어 있고, 배포 범위(deploy range)도 존재하지 않으며, 봇은 소리 없이 죽어버립니다. 티켓도 생성되지 않고, 포스트도 올라오지 않습니다. 레드 스테이징(Red staging), 그리고 침묵. 트라이아지(triage) 시스템에서 가장 최악의 실패 모드는 이것입니다: 모두가 봇의 침묵을 '모든 것이 정상적으로 제어되고 있음'으로 간주하는 상황 말입니다.
해결책은 배포 범위(deploy range)를 명시적인 입력값으로 만드는 것이었습니다:
- uses: our-org/staging-triage@v1.6
with:
deploy-repo: our-org/backend # 누구의 배포가 e2e를 트리거했는가
...
판정을 위한 디프(diffs), 귀속 및 멘션을 위한 작성자(authors) 등 모든 다운스트림(downstream) 정보는 이제 실제로 배포를 수행한 리포지토리로부터 가져옵니다. 한 번의 트라이아지로 두 리포지토리를 모두 처리합니다.
테스트 방법
CI, Linear, Slack, 그리고 Allure에 걸쳐 동작하는 시스템은 유닛 테스트(unit-test)를 할 수 없습니다. 대신 두 가지 방법이 효과적이었습니다: 첫째, **스모크 PR (smoke PR)**을 통해 전체 상태 시리즈를 커밋 단위로 수행했습니다. 첫 번째 실패(티켓이 생성되는가?), 반복되는 실패(중복 제거가 조용히 작동하는가?), 혼합된 실행(두 경로가 모두 실행되는가?), 성공적인 실행(종료 및 복구 포스트가 올라오는가?) — 즉, 모킹(mock) 없이 실제 실행과 실제 API를 사용했습니다. 둘째, **킬 스위치 (kill switch)**입니다. STAGING_TRIAGE_SIMULATED=true가 아닌 한 수동 workflow_dispatch는 억제되므로, Linear에 스팸을 뿌리지 않고도 워크플로우를 건드려 볼 수 있습니다.
봇이 할 수 없는 것
LLM 분류기(classifier)의 가장 위험한 실패 모드는 잘못된 판정이 아닙니다. 그것은 바로 아무 근거 없는 자신감 넘치는 텍스트입니다: "아마도 플레이키(flake)일 가능성 있음", "환경을 확인하세요", "아마도 콜드 스타트(cold start)인 듯함". 그래서 프롬프트(prompt)는 희망 사항이 아닌 금지 사항을 기반으로 구축되었습니다:
- 결론 우선 (Verdict first): 구체적인 신호(diff 파일, trace 인용구, locator)를 바탕으로 한 문장으로 정당화합니다. 가설(hypothesis) 형식은 결론에 맞춰 조정됩니다:
likely-pr-related(PR 관련 가능성 높음) → 파일:라인 및 변경 사항;likely-not-pr-related(PR 관련 가능성 낮음) → 인용구를 포함한 사실 관계, 누락된 정보, 사람이 확인해야 할 위치. - 엄격한 금지 사항 (Hard bans): 제공된 컨텍스트에 없는 파일이나 locator 사용 금지; 인용구 없는 일반적인 문구 사용 금지;
insufficient-data(데이터 부족)는 정확하고 유용한 답변으로 선언됨 — 그렇지 않으면 모델은 보기 좋은 텍스트를 만들기 위해 억지로 가설을 지어냅니다. - 예산이 책정된 컨텍스트 (Budgeted context): trace, 로그, 커밋 범위, 전체 PR diff 및 영향받는 파일들을 1.5 MB 예산 내로 유지합니다. 용량이 초과되면 파일을 먼저 제외하고, diff는 끝부분부터 잘라내며, 절단(truncation) 노트를 통해 모델이 누락된 데이터로부터 결론을 내리지 않도록 안내합니다.
여전히 발생하는 현상: 영역은 맞지만 세부 사항이 틀린 가설 — 봇이 처음으로 잡아낸 결정적인 버그의 경우, 올바른 위치를 지목했지만 실제 수정은 사람이 더 빠르게 작성했습니다. 괜찮습니다. 봇은 엔지니어를 대체하는 것이 아니라, 엔지니어 업무의 첫 15~30분을 대체하는 것입니다.
수치와 직관
솔직히 말씀드리면, 엄격한 지표를 수집하지는 않았고 2개월은 작은 표본입니다. 하지만 릴리스 속도가 눈에 띄게 빨라졌습니다. 버그가 릴리스 당일 아침이 아니라 코드가 스테이징(staging)에 반영될 때 국소화(localized)되며, 17개의 의심 사례를 추적하는 탐정 놀이도 사라졌습니다. bug 레이블을 다지는 기초 작업이 완료되었으므로, 6개월 뒤에는 실제 수치가 나올 것입니다.
비용: 실패한 실행(red run)당 모델 호출 1회(복구(recovery)에는 AI가 필요 없음), 일반적으로 30~80K 입력 토큰 — Opus 4.8 가격 기준 실패 실행당 $0.3–0.5, 최악의 경우 약 $2.5, 총 월 $30–70 수준입니다. 그사이 QA 팀은 성장했습니다 — 저는 유일한 QA에서 팀을 이끄는 역할이 되었습니다 — 하지만 분류(triage) 작업은 여전히 봇이 담당합니다. 사람들은 자동화와 에이전트(agent) 실험을 수행하지, 실패한 실행을 처리하는 업무를 하지 않습니다. 이 자동화는 인력 부족에서 탄생했지만, 단순히 더 효율적이기 때문에 유지되고 있습니다.
"하지만 기성 도구(off-the-shelf tools)들이 이미 존재하는데요!"
맞습니다. Cognition은 Devin Auto-Triage를 판매하고 있고, GitHub은 실패한 워크플로 실행에 대해 “Fix with Copilot” 버튼을 출시했습니다. 저희는 이 모든 것을 지켜보았고, 의도적으로 우리만의 도구를 직접 구축했습니다. 왜냐하면 우리의 과업은 단순히 "빨간색 CI(실패한 CI)를 고치는 것"이 아니기 때문입니다. 그것은 릴리스(release)의 운명에 관한 결정입니다. 원인을 분류하고, 경로(티켓 생성 / 재실행 / 에스컬레이션)를 선택하며, 중복을 제거하고, 자동으로 종료하며, 이유 없이 아무도 깨우지 않는 것 — 즉, 우리의 정확한 스택(Allure TestOps, Linear, Slack, 두 개의 리포지토리)과 프로세스에 밀착되어야 합니다.
그리고 그 대가(비용/시간)는 이렇습니다. AI 우선(AI-first) 팀에게 이 작업은 몇 주가 아니라 며칠 만에 끝납니다. 코드는 거의 전적으로 Claude Code로 작성되었습니다. 솔직한 역할 분담을 말씀드리자면, 인간의 역할은 프로토타입과 아키텍처(architecture)를 설계하는 것입니다. 즉, 판결(verdict)과 라우팅(routing)을 분리하고, 멱등성(idempotent)을 가진 복구 단계, 순수 함수(pure-function) 분류기, 데이터베이스 대신 티켓 댓글에 상태를 저장하는 방식 등을 설계하는 것입니다. AI는 주어진 구조 안에서 훌륭한 코드를 작성하고 반복(iteration) 속도를 한 자릿수(order of magnitude)만큼 높여주지만, 두 달 동안 6번의 릴리스를 견뎌내는 구조를 설정하는 것은 인간의 몫입니다.
그렇다면 왜 오픈 소스로 공개하지 않느냐고요? 유사한 "AI가 실패한 CI를 분류(triage)해주는" 액션(actions)들은 GitHub 별점(stars)이 3~11개에 머물러 있으며, 저희의 도구는 정확히 Linear + Slack + Allure TestOps + Anthropic의 조합이 필요합니다. 이러한 타겟 관객은 극히 적은 반면, 기본적인 "AI가 실패 원인을 설명해주는" 기능은 GitHub에 기본적으로 탑재되고 있습니다. 여기서 오픈 소스로 공개할 가치가 있는 것은 코드가 아니라 아이디어이며, 이것이 바로 이 글이 전달하고자 하는 핵심입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기