
가드레일(Guardrails) 연결하기
요약
GitHub Actions를 활용하여 CI 파이프라인에 가드레일을 구축하고, 코드 변경 시 사양 스택을 자동으로 검증하는 과정을 다룹니다. Claude Code를 사용하여 기존 테스트 실패 사례를 해결하고 하위 호환성을 유지하며 파이프라인을 안정화하는 실무적 경험을 공유합니다.
핵심 포인트
- GitHub Actions를 통한 CI 파이프라인 기반의 가드레일 구축
- 의존성 순서에 따른 전체 사양 스택 자동 실행 및 머지 차단
- Claude Code를 활용한 파이프라인 설정 및 테스트 오류 수정
- 하위 호환성 별칭(aliases)을 통한 기존 명세와의 충돌 방지
A Level 5 Engineer — Issue #6
서문
본론으로 들어가기에 앞서 솔직하게 말씀드리고 싶은 것이 있습니다. 이 글에 등장하는 프레임워크 중 그 어떤 것도 제 것이 아닙니다. 여기에 담긴 아이디어들은 저보다 훨씬 더 깊고 오래 이 문제를 고민해 온 두 분으로부터 왔으며, 제가 한 마디 더 하기 전에 그분들에게 온전한 공로를 돌려야 마땅합니다.
Dan Shapiro — Glowforge의 CEO이자 Wharton Research Fellow이며, 이 모든 대화에 어휘를 제공한 분입니다. 그의 블로그 포스트인 “The Five Levels: from Spicy Autocomplete to the Dark Factory”는 제가 하려는 모든 말의 개념적 중추입니다. 원문을 읽어보세요. 짧고 날카로우며, 가장 좋은 방식으로 당신을 불편하게 만들 것입니다. danshapiro.com
5회차 이슈까지, 우리가 구축한 모든 것은 단 한 대의 머신 위에서만 살아있었습니다. Gherkin 시나리오, WireMock 스텁 (stubs), Pact 계약 (contracts), can-i-deploy 스크립트 — 이 모든 것이 로컬에서 실행되고 로컬에서 통과되었으며, 다른 누군가가 코드베이스 (codebase)를 건드리는 순간 아무런 의미가 없었습니다.
Issue #6은 이를 해결합니다. 이제 모든 푸시 (push) 시 GitHub Actions 파이프라인 (pipeline)이 실행되어, 의존성 순서에 따라 전체 사양 스택 (specification stack)을 실행하며, 무언가 깨질 경우 main 브랜치로의 머지 (merge)를 차단합니다. 이 파이프라인이 바로 가드레일 (guardrail)입니다. 이 시점부터는 깨진 계약이나 실패하는 시나리오가 감지되지 않은 채 main에 도달할 수 없습니다.
여기에 도달하기까지 90분이 소요되었고, 계획에 없던 두 번의 개입이 있었습니다. 두 가지 모두 기록할 가치가 있습니다.
YAML 이전 단계: "그린 (green)"의 의미 결정하기
Claude Code가 파이프라인 설정 (pipeline config)에 손을 대기 전 가장 먼저 한 일은 기준점 (baseline)을 설정하기 위해 전체 테스트 스위트 (test suite)를 실행하는 것이었습니다. 지침은 명확했습니다: 단 한 줄의 YAML이 작성되기 전에 모든 것이 통과되어야 합니다.
Claude Code는 즉시 실패를 발견했습니다 — 그리고 그것은 변경 사항 실험 (breaking change experiment) 때문이 아니었습니다. 바로 Issue #5 때문이었습니다.
잘못된 명세 테스트(test_order_spec_bad.py::test_retrieving_status_for_a_confirmed_order)는 여전히 응답 본문(response body)에서 db_status를 단언(asserting)하고 있었습니다. 이는 Issue #5에서 의도된 것이었습니다. 즉, 실패 자체가 발견 사항(finding)이었습니다. 해당 세션이 빨간색(실패)으로 종료된 이유는 잘못된 명세가 무엇을 만들어내는지 보여주는 것이 목적이었기 때문입니다. 하지만 CI(지속적 통합)가 도입되는 main 브랜치에서는, 단 하나의 기능 변경이 일어나기도 전에 파이프라인이 첫날부터 빨간색이 된다는 것을 의미했습니다.
해결책은 응답에 하위 호환성 별칭(backward-compat aliases)을 추가하는 것이었습니다:
return {
"order_id": order_id,
"status": order["db_status"], # 올바른 명세 필드
...
어떠한 테스트 파일도 수정되지 않았습니다. 기능 파일(feature files)도 건드리지 않았습니다. 이 별칭(aliases) 덕분에 올바른 명세 테스트와 잘못된 명세 테스트 모두 동일한 엔드포인트(endpoint)에 대해 통과 상태를 유지할 수 있었습니다.
파이프라인이 존재하기 전에 이 점이 중요한 이유는 다음과 같습니다. 이미 알려진 실패가 있는 상태에서 CI를 시작하는 팀은 빨간색(실패)을 무시하도록 스스로를 훈련시키게 됩니다. 빨간색 CI를 정상화하는 비용은 기준점(baseline)을 먼저 수정하는 비용보다 훨씬 높습니다. Claude Code는 다음 단계로 넘어가기 전에 올바른 결정을 내리고 이를 문서화했습니다.
파이프라인 구조
의존성 순서에 따른 4개의 작업(jobs):
test → pact-consumer → pact-verify → can-i-deploy
각 작업은 이전 작업이 통과했을 때만 실행됩니다. Gherkin이 깨지면 Pact는 실행되지 않습니다. 컨슈머(consumer) 테스트가 실패하면 검증(verification)은 실행되지 않습니다. 검증이 실패하면 can-i-deploy는 건너뜁니다. 파이프라인은 빠르게 실패(fail fast)하며 정확히 어느 계층이 깨졌는지 알려줍니다.
아티팩트 체인(artifact chain)이야말로 이것을 네 개의 병렬 스크립트가 아닌 파이프라인으로 만드는 핵심입니다. pact-consumer 작업은 .pact 파일을 생성하고 이를 GitHub Actions 아티팩트로 업로드합니다. pact-verify 작업은 해당 아티팩트를 다운로드하여 검증합니다. 새로 생성된 파일이 아니라 바로 그 파일을 사용하는 것입니다. 이것이 없다면 각 작업은 처음부터 자신만의 컨슈머 계약(consumer contract)을 빌드할 것이고, 검증 단계는 pact-consumer가 실제로 생성한 것과 일치하는지를 증명하는 것이 아니라 계약이 코드와 일치하는지를 증명하는 것에 그치게 될 것입니다.
한 가지 눈에 띄지 않는 부분은 mock_server.py가 명령줄 엔트리 포인트 (command-line entry point)가 없는 라이브러리 모듈이라는 점입니다. 파이프라인 (pipeline)은 서버가 백그라운드 프로세스 (background processes)로 실행될 필요가 있었습니다. 해결책은 인라인 Python 호출 (inline Python invocation)이었습니다.
- name: Start mock servers
run: |
. .venv/bin/activate
...
time.sleep(86400)은 작업 (job)이 지속되는 동안 프로세스를 계속 살아있게 유지합니다. 세련되지는 않았지만 기능적으로는 작동합니다. argparse를 포함한 적절한 if __name__ == "__main__" 엔트리 포인트 (entry point)를 만드는 것이 향후 세션을 위한 명확한 정리 작업이 될 것입니다.
첫 번째 CI 실행 — 그리고 왜 제가 수동으로 개입해야 했는가
YAML이 커밋(commit)되어 main 브랜치로 푸시(push)되었고, 파이프라인이 실행되었습니다. 세 번의 실행 모두 test 작업에서 실패했습니다:
OSError: [Errno 98] Address already in use
포트(Ports) 8091과 8092입니다. test_order_creation.py에 있는 모든 테스트가 설정(setup) 단계에서 에러가 발생했습니다. Mock 서버를 사용하지 않는 주문 상태(order status) 테스트들은 문제없이 통과했습니다.
Claude Code는 이를 스스로 잡아내지 못했습니다. 왜 그런지 설명할 가치가 있습니다.
Claude Code가 파이프라인을 작성할 때, 그것은 코드베이스와 GitHub Actions 패턴에 대한 자체적인 지식을 바탕으로 작업했습니다. Claude Code는 pytest가 시작되기 전에 Mock 서버가 실행 중이어야 한다는 것을 알고 있었기에, YAML에 명시적인 서버 시작(start-servers) 단계를 추가했습니다. 이는 Claude Code가 가진 정보에 기반한 합리적인 결정이었습니다. Claude Code가 볼 수 없었던 것은 해당 YAML 단계와 pytest의 세션 범위 피스처 (session-scoped fixtures) 사이의 런타임 상호작용 (runtime interaction)이었습니다. 왜냐하면 그 상호작용은 로컬 (locally)이 아닌 CI 환경에서만 나타나기 때문입니다.
로컬에서 pytest tests/steps/ -v를 실행하는 것은 항상 올바르게 작동해 왔습니다. 세션 피스처 (session fixture)가 서버를 시작하고 다른 경쟁 요소가 없었기 때문입니다. Claude Code는 로컬 실행이 성공하는 모습만을 보아왔습니다. YAML 단계가 충돌을 일으키고 있다는 신호는 전혀 없었습니다. 왜냐하면 그 충돌은 로컬에는 존재하지 않기 때문입니다.
이것은 로컬(local) 환경과 원격(remote) 환경 사이의 경계에서 발생하는 "복사해서 붙여넣고 떠나기(paste and walk away)" 방식의 근본적인 한계입니다. 에이전트(agent)는 코드베이스(codebase)와 CI 패턴에 대해 추론할 수는 있지만, CI 실행 자체를 관찰할 수는 없습니다. 실패는 GitHub에서 발생했습니다. Claude Code는 터미널(terminal)에 있었습니다. 이 두 가지는 서로 연결되어 있지 않았습니다.
저는 GitHub Actions 로그를 통해 오류를 진단하고, 근본 원인을 설명한 뒤 새로운 지침을 붙여넣었습니다. Claude Code는 불필요한 YAML 단계(steps)를 완전히 제거함으로써 단 한 번의 단계로 이를 해결했습니다:
# test 및 pact-verify 작업(jobs) 모두에서 제거됨:
- name: Start mock servers
run: |
...
pytest 세션 픽스처(session fixtures)가 이미 서버 생명주기(lifecycle)를 올바르게 관리하고 있었습니다. scope="session"은 pytest가 테스트 실행당 한 번 서버를 시작하고 이를 계속 유지함을 의미합니다. YAML 단계는 이미 처리된 책임을 중복해서 수행하고 있었습니다. 이 수정은 임시방편(workaround)이 아니라, 잘못된 계층(layer)을 제거한 것이었습니다.
쉬운 말로 설명하자면 근본 원인은 다음과 같습니다: YAML 단계와 pytest 픽스처가 모두 자신들이 서버를 시작할 책임이 있다고 생각한 것입니다. 픽스처가 포트(port)를 다시 바인딩(bind)하려고 시도했을 때, 포트는 이미 바인딩되어 있었습니다. 내 컴퓨터에서는 잘 되지만, CI에서는 깨집니다. 전형적인 사례입니다.
파이프라인에서의 변경 사항(breaking change) 실험
파이프라인이 정상(green) 상태가 되자, 변경 사항(breaking change) 테스트가 설계된 대로 실행되었습니다.
브랜치 test/breaking-change-pipeline, 커밋 76c0d89: wiremock/payment-mappings/payment-success.json에서 "status"를 "result"로 이름을 변경했습니다. Issue #4와 동일한 변경 사항이며, 이제 로컬 검증 대신 CI를 통해 실행됩니다.
예상되는 실패:
a successful payment charge (FAILED)
Failures:
...
pact-verify가 실패합니다. can-i-deploy는 건너뜁니다(skipped). 머지(merge)가 차단됩니다.
그리고 Issue #4의 핵심 포인트는 파이프라인(pipeline) 수준에서도 유효합니다. 즉, test 작업(job) — Gherkin 스위트(suite) — 는 파괴적 변경(breaking change)이 적용된 상태에서도 통과될 것입니다. 주문 생성 시나리오(scenarios)는 HTTP 상태 코드(status codes)와 비즈니스 결과(business outcomes)를 확인합니다. 이들은 pay_resp.json()["status"]를 전혀 읽지 않습니다. status 대신 result를 반환하는 스텁(stub)은 여전히 HTTP 200을 반환합니다. Gherkin은 통과합니다. Pact가 이를 잡아냅니다.
이것이 역할 분담입니다. Gherkin은 시스템이 올바른 일을 수행함을 증명합니다. Pact는 계약(contracts)이 어긋나지 않았음을 증명합니다. 두 가지 모두가 필요하며, 이제 두 가지 모두 모든 푸시(push) 시 자동으로 실행됩니다.
GitHub UI가 필요한 단 한 단계
Claude Code는 브랜치 보호 규칙(branch protection rules)을 설정할 수 없습니다. 이를 위해서는 GitHub 웹 UI 또는 관리자 API(admin API)가 필요합니다. 이 단계는 협상의 여지가 없으며 반드시 수동으로 수행해야 합니다:
- Repo → Settings → Branches → Add branch protection rule
- Branch name pattern:
main - Require status checks to pass before merging 활성화
- 네 가지 상태 체크(status checks)를 모두 추가:
test,pact-consumer,pact-verify,can-i-deploy - Require branches to be up to date before merging 활성화
- Save
이 설정이 없다면 파이프라인은 권고 사항(advisory)에 불과합니다. 네 가지 작업이 모두 실패(red)하더라도 main으로의 푸시가 여전히 발생할 수 있습니다. 파이프라인은 대시보드(dashboard)가 되어 버립니다 — 문제를 보여주기는 하지만 아무것도 막지 못합니다. 브랜치 보호(Branch protection)야말로 "CI 실패"를 단순한 알림에서 강제 집행(enforcement)으로 바꾸는 요소입니다. 파이프라인은 당신이 우회하는 것을 무언가가 막아줄 때에만 가드레일(guardrail)이 됩니다.
솔직한 이야기
YAML을 작성하는 데 약 20분이 걸렸습니다. 전체 세션은 총 90분이 소요되었습니다 — 베이스라인 수정(baseline fix)과 포트 충돌(port conflict) 해결에 나머지 시간을 다 썼기 때문입니다.
베이스라인 감사(baseline audit) 과정에서의 본능은 이미 알고 있는 실패를 그냥 건너뛰는 것이었습니다. '이건 데모 테스트일 뿐이고, 왜 실패하는지 이미 알고 있으니, CI가 해당 파일을 건너뛰도록 설정하고 넘어가자.' 그렇게 했다면 30초면 충분했을 것입니다. 하지만 그것은 잘못된 판단이었을 것입니다. 예외 사항이 문서화된 파이프라인(pipeline)은 사람들이 우회하게 만드는 파이프라인이 되기 때문입니다.
포트 충돌(port conflict)이 발생했을 때의 본능은 CI 환경을 탓하는 것이었습니다. 'Ubuntu는 다르게 동작하고, 포트 작동 방식도 다르고, 이건 플랫폼의 특이점(platform quirk)이야.'라고 생각하는 것이죠. 그런 프레임(framing)은 디버깅(debugging)을 잘못된 방향으로 이끌었을 것입니다. 실제 원인은 더 단순했습니다. 두 개의 레이어(layer)가 모두 동일한 책임을 자신이 가지고 있다고 생각했고, 그중 실제로 누가 담당인지 아무도 기록해 두지 않았던 것입니다.
이 두 순간 모두가 J-커브(J-curve)입니다. YAML이 아니라, 건너뛰지 않고 환경을 탓하지 않는 규율(discipline) 말입니다. CI의 오버헤드(overhead)는 설정 파일이 아닙니다. '그린(green, 통과)'이 실제로 무엇을 의미하는지, 그리고 무엇에 대해 누가 책임이 있는지에 대한 모든 결정이 바로 오버헤드입니다.
이제 파이프라인은 실제 인프라(infrastructure)입니다. 파괴적 변경(breaking change)이 메인(main) 브랜치에 도달할 수 없게 만드는 것, 그것은 90분의 가치가 있습니다.
다음 이슈: 범위 문제(The Scope Problem) — 멀티 서비스 시스템 전반에 걸쳐 Gherkin을 확장하는 법. 하나의 스펙(spec) 파일만으로는 부족할 때 어떤 일이 발생하는지, 그리고 스펙 부채(spec debt)가 어떻게 형성되는지에 대하여.
출처 및 추가 읽을거리
- GitHub Actions documentation
- Pact documentation
- Dan Shapiro — The Five Levels: from Spicy Autocomplete to the Dark Factory
- Nate B. Jones — natebjones.com
- Project repository
- Session findings — Issue #6
이 기사는 AI 도구의 도움을 받아 작성되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기