CI는 초록인데 테스트가 한 번도 돈 적이 없었다 — 검사 도구가 스스로 고장난 경우

사내 Rails 스타터킷을 정비했다. 발단은 기능 개발이 아니라 버그 재현이었다. 그 킷에서 클론한 프로젝트에서 라이트모드 가독성 버그가 나왔는데, 같은 문제를 이미 해결한 프로덕션 앱이 하나 있었다. 그 해법을 킷으로 되돌리는(back-port) 작업이었다.

스타터킷은 고치면 이후 클론 전체가 혜택을 받는다. 반대로 고장난 채로 두면 클론마다 그 고장이 복제된다. 이 비대칭 때문에 킷 작업은 우선순위가 높다.

그 김에 bin/ci를 처음으로 끝까지 돌려봤다. 그동안은 rails test만 돌렸지 전체 파이프라인을 완주시킨 적이 없었다. 거기서 나온 게 이 글의 본론이다.


1. brakeman이 스캔 결과를 아무에게도 안 보여주고 있었다

CI 스텝은 이렇게 돼 있었다.

bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error

직접 돌려보니 출력이 딱 한 줄이었다.

Brakeman 8.0.2 is not the latest version 8.0.6

그리고 exit 5. 경고가 없어서 조용한 게 아니라, 버전 체크에서 죽어서 스캔 결과 자체를 못 보고 있던 것이다. 젬을 올리자 실제 경고가 3건 나왔다.

그중 하나는 진짜였다. 알림 URL을 검증하는 정규식에 \z 앵커가 없어서 //evil.com 같은 프로토콜 상대 URL이 통과했고, 그게 link_to로 렌더돼 외부 링크가 됐다. 넉 달 동안 아무도 몰랐다.

“CI에 brakeman이 있다”는 사실만 있었고, 그게 무엇을 말하고 있는지는 아무도 확인하지 않았다.

2. GitHub Actions의 테스트 job은 실행조차 안 되고 있었다

워크플로우에 이게 남아 있었다.

services:
  postgres:
    image: postgres
...
env:
  DATABASE_URL: postgres://postgres:postgres@localhost:5432

킷은 한참 전에 SQLite 전용으로 정리하면서 pg 젬을 뺐다. 그런데 워크플로우의 postgres 잔재는 남았다. DATABASE_URL이 멀티 DB 설정의 primary를 덮어쓰기 때문에 db:test:prepare 단계에서 이렇게 죽는다.

Error loading the 'postgresql' Active Record adapter... pg is not part of the bundle

즉 이 리포는 GitHub Actions에서 테스트가 한 번도 돈 적이 없었다. 로컬에서는 초록이니 아무도 눈치채지 못했다.

여기서 한 번 헛다리를 짚었다. 수정 후 gh run view --log로 확인했더니 test job이 6개(= 시스템 테스트 수)만 돌린 것처럼 보였고, “아직도 안 고쳐졌다”고 보고할 뻔했다. API로 job 로그를 직접 받아보니 실제로는 126개가 정상 실행됐고, gh가 두 job의 로그를 섞어 보여준 것이었다.

도구의 출력도 소스가 아니라 증거일 뿐이다. 이상하면 한 겹 아래에서 확인해야 한다.

3. 시스템 테스트 job은 인프라 자체가 없었다

워크플로우가 bin/rails test:system을 부르는데 test/system 디렉터리도 application_system_test_case.rb도 없었다. capybara와 selenium은 Gemfile에 들어 있었다. 그 job은 LoadError로 계속 실패하고 있었다.


그래서 결함이 조용히 쌓여 있었다

검사가 전부 무력화된 상태였으니 당연한 결과였다. 이번에 한꺼번에 나온 것들:

결함 왜 아무도 몰랐나
production 환경에서 앱이 부팅 안 됨 config/environments/production.rb가 오토로딩 전에 평가되는데 거기서 앱 상수를 require 없이 참조. dev/test는 그 줄을 안 탄다
db:seed가 죽어 있음 멀티테넌시를 fail-closed로 바꾼 뒤 시드가 테넌트 없이 레코드를 생성
감사 로그 정리 잡이 한 번도 실행 안 됨 스케줄을 queue.yml에 적었는데 잡 러너는 별도 파일만 읽는다. 문서엔 “매일 03:00 실행”이라고 적혀 있었다
젬 CVE 누적 스타터킷이라 클론마다 취약한 핀을 그대로 물려주는 구조

특히 앞의 세 개는 “배포하면 즉시 발견”이 아니라 “배포해도 조용히 아무 일도 안 일어나는” 종류라 더 나쁘다. 감사 로그 정리 잡은 문서상으로는 넉 달째 매일 돌고 있었다.

킷의 결함은 클론에도 복제돼 있었다

킷을 고친 뒤, 같은 결함이 파생 프로젝트에도 있는지 grep으로 실측했다. 추정하지 않고 파일을 직접 확인했다.

  • production 부팅을 막는 require 누락: 파생 6개 중 3개에서 재현
  • 스케줄이 잘못된 파일에 있어 잡이 안 도는 상태: 4개에서 재현
  • GitHub Actions의 postgres 잔재: 5개에서 재현

다행히 실제 배포된 건 한 곳뿐이었고 그 프로젝트는 세 항목 중 두 개가 이미 정리돼 있었다. 나머지는 미배포라 급하지 않다. 하지만 이 스캔의 값어치는 목록 자체가 아니다. 한 번 만든 결함이 몇 벌 복사됐는지를 숫자로 본 것이 값어치였다. 킷 수정의 우선순위를 정하는 근거가 된다.

조치 — 파이프라인에 못 박은 것

교훈을 문서에 적는 대신 CI 스텝으로 만들었다.

step "Boot: Production environment",
  "env RAILS_ENV=production SECRET_KEY_BASE=dummy bin/rails runner 'puts \"ok\"'"

dev/test가 아무리 초록이어도 production 부팅은 별도로 확인해야 한다는 게 이번 버그의 교훈이라, 그걸 파이프라인에 박았다. 같은 방식으로 스케줄 등록 여부·배포 준비 상태·문서와 코드의 불일치를 검사하는 가드 테스트를 11종 추가했다. 문서에 적힌 규칙은 잊히지만 CI 스텝은 잊히지 않는다.

배운 것

“CI가 있다”와 “CI가 무언가를 검사하고 있다”는 다른 상태다. 그리고 후자는 저절로 유지되지 않는다. 스택을 바꾸면(PostgreSQL 제거) 그 흔적이 파이프라인 어딘가에 남고, 젬이 낡으면 검사 도구 자신이 먼저 죽는다. 둘 다 조용하다.

exit code만 보면 “통과”와 “검사를 못 함”이 구분되지 않는다. brakeman은 exit 5로 실패하고 있었지만, 그 5가 “경고 있음”인지 “버전 체크 실패”인지는 로그를 열어야 알 수 있었다. 검사 도구의 출력은 주기적으로 눈으로 봐야 한다.

로컬에서 초록인 것은 CI가 돌았다는 증거가 아니다. 우리는 로컬 rails test만 보고 넉 달을 보냈다.

한계 · 다음 계획

  • 파생 프로젝트 역이식은 아직 안 했다. 결함이 어디에 몇 개 있는지 확인만 했고, 실제 수정은 각 프로젝트 일정에 맡겼다. 미배포라는 이유로 미룬 건데, 이 판단이 맞는지는 다음 배포 때 드러날 것이다.
  • 시스템 테스트는 최소 골격만 세웠다. 헤드리스 브라우저가 CI에서 안정적으로 도는지는 몇 주 더 봐야 한다.
  • “CI가 실제로 무엇을 검사하는가”를 자동으로 확인할 방법은 아직 없다. 이번에도 사람이 파이프라인을 완주시키고 로그를 눈으로 읽어서 찾았다. 정기적으로 강제하는 장치가 필요하다.
  • 다음은 이 킷을 실제로 배포까지 밀어보는 것. 부팅 스텝을 추가했지만, production 부팅과 production 동작은 또 다른 문제다.

FAQ

Q. brakeman이 버전 체크로 죽는 걸 어떻게 막나?
근본 대응은 젬을 최신으로 유지하는 것이다. 다만 CI에서 exit code만 보고 있으면 이 상황이 “경고 발견”과 구분되지 않는다는 게 진짜 문제다. 스캔 결과 요약을 로그에 남기고, 정기적으로 그 로그를 사람이 보는 절차를 함께 두는 게 안전하다.

Q. DATABASE_URL 하나 때문에 멀티 DB 설정이 통째로 깨지는 이유는?
Rails는 DATABASE_URL이 있으면 그것을 primary 연결에 병합한다. 그래서 database.yml에 SQLite로 여러 DB(primary·queue·cache 등)를 선언해 뒀어도 primary가 postgres로 덮어써지고, 어댑터 젬이 없으니 db:test:prepare 단계에서 로딩 에러가 난다. 스택을 바꿀 때는 워크플로우의 services와 env도 같이 지워야 한다.

Q. production 부팅 체크를 CI에 넣으면 시크릿이 필요하지 않나?
부팅만 확인할 거라면 SECRET_KEY_BASE=dummy 같은 더미 값으로 충분하다. 목적은 실제 접속이 아니라 초기화 단계에서 상수 참조·require 순서가 깨지지 않는지 확인하는 것이다. 외부 연결까지 검증하려면 별도 스모크 테스트가 필요하다.

Q. 스타터킷 결함이 클론에 퍼졌는지 어떻게 확인하나?
추정하지 말고 파일을 직접 검사하는 게 빠르다. 우리는 각 리포의 config/environments/production.rb, config/queue.yml, config/recurring.yml, .github/workflows/ci.yml을 grep으로 훑었다. 프로젝트 6개를 확인하는 데 몇 분이면 된다. 이걸 스크립트로 만들어 두면 다음 킷 수정 때 그대로 재사용할 수 있다.

Leave a Comment