버그 재현 도구에 가장 무서운 거짓말이 하나 있다.

PASS

초록색 글자라서 안심하기 쉽다. 그런데 그 PASS가 방금 실행한 결과가 아니라 지난주에 저장해둔 결과를 읽고 나온 것이라면 어떨까?

자동차 검사소에 차를 가져갔는데, 직원이 오늘 차는 보지도 않고 작년 검사증을 꺼내 이렇게 말하는 셈이다.

“작년에 멀쩡했네요. 오늘도 통과입니다.”

웃기지만 재현 자동화에서는 의외로 자주 생긴다. 실행 환경이 없거나 인증 정보가 빠지면, 친절하게도 예전에 저장한 JSON과 로그를 읽는다. 그리고 파일이 멀쩡하면 verify가 성공한다.

파일 검사는 성공했다. 하지만 오늘의 제품은 한 번도 달리지 않았다.

이번에 QueryPie ReproKit을 손보면서 가장 먼저 고친 건 Docker도, JDBC도, 복잡한 fault injection도 아니었다.

“지금 실행한 검증”과 “보관된 증거 검사”를 서로 다른 말로 부르게 만드는 것.

이 작은 구분에서 꽤 큰 리팩터링이 시작됐다.

verifyverify-package는 다른 직업이다

예전에는 verify가 두 가지 일을 할 수 있었다.

환경이 있으면 실제 재현 결과 검증
환경이 없으면 보관된 결과 파일 검증

겉으로는 편하다. 명령 하나만 기억하면 된다.

하지만 편리함이 검증의 의미를 흐렸다. 실행한 사람은 결과가 어디서 왔는지 매번 보고서 안쪽까지 읽어야 했다. CI에서는 exit code 0만 보고 새 실행이 성공했다고 오해할 수도 있었다.

그래서 역할을 둘로 나눴다.

verify
└─ 현재 setup에서 만든 live 실행만 검증

verify-package
└─ 저장된 JSON, 로그, 보고서의 무결성만 검증

이제 live 환경이 없는데 verify를 실행하면 실패한다. 과거 결과로 몰래 갈아타지 않는다.

반대로 verify-package는 제품 서버가 없어도 실행할 수 있다. 대신 결과에 솔직하게 적는다.

{
  "mode": "evidence-package",
  "freshLiveRerun": false
}

“증거 봉투는 멀쩡합니다. 하지만 오늘 현장에 다시 다녀온 것은 아닙니다.”

좋은 자동화는 실패를 없애는 도구가 아니다. 어디까지 확인했는지 정직하게 말하는 도구다.

증거에도 공연 티켓처럼 날짜와 좌석 번호가 필요하다

명령을 나누고 나니 다음 문제가 보였다.

정상 실행 결과, 장애 주입 결과, 로그 요약이 정말 같은 실행에서 나온 것인지 어떻게 알까?

파일 이름만 같다고 같은 날 찍은 사진은 아니다.

normal.json       지난주 월요일
fault.json        오늘 오전
log-summary.json  다른 테스트 환경

셋을 한 폴더에 넣으면 그럴듯한 보고서가 만들어질 수 있다. 하지만 이건 하나의 실험이 아니라 세 번의 기억을 콜라주한 것이다.

그래서 모든 live 실행에 runId를 발급했다.

20260715T045939Z-1bf064fc

이 ID는 setup부터 정상 경로, fault 경로, 로그, 최종 verdict까지 따라다닌다. 검증기는 모든 증거의 runId가 같은지 확인한다.

여기에 두 종류의 commit도 나눠 기록한다.

sourceCommit   실제 제품 소스 버전
harnessCommit  재현 도구 자체 버전

제품은 그대로인데 테스트 도구가 바뀌었을 수도 있다. 반대로 테스트 도구는 같지만 제품 코드가 달라졌을 수도 있다. 둘을 하나의 commit으로 뭉개면 나중에 “무엇이 달라져서 결과가 바뀌었지?”라는 질문에 답하기 어렵다.

재현 결과는 단순한 출력이 아니다.

어떤 제품을, 어떤 도구로, 언제, 어떤 fixture에서 실행했는지 적힌 실험 기록이다.

Trino를 멈췄다가 다시 살려보자

이번 표준화의 실전 무대는 Trino와 JDBC를 거치는 DB proxy 경로였다.

정상 경로는 이렇게 달린다.

JDBC client
→ DB proxy
→ Trino

먼저 Trino 478과 JDBC 359를 고정했다. “최신 버전”이라는 움직이는 표적 대신 버전을 명시했다. JDBC 파일의 SHA-256도 기록했다. 같은 이름의 파일이 정말 같은 바이너리인지 확인하기 위해서다.

정상 주행은 direct 경로와 proxy 경로를 나눠 봤다.

Direct JDBC → Trino: PASS
JDBC → Proxy → Trino: 20/20 PASS

그다음 달리는 중간에 Trino 컨테이너를 잠깐 멈췄다.

왜 굳이 멀쩡한 서버를 멈출까?

네트워크 장애는 종종 연결을 시작할 때가 아니라, 결과를 여러 페이지로 받아오는 중간에 발생한다. 첫 페이지는 받았는데 다음 페이지를 요청하는 순간 target이 멈추면, client와 proxy의 실패 처리 방식이 드러난다.

그래서 fault probe는 FIRST_ROW라는 표지판을 출력한다.

첫 번째 row 도착
→ FIRST_ROW marker 확인
→ Trino pause
→ JDBC의 다음 페이지 요청
→ read timeout 관찰
→ Trino unpause

실제로 JDBC의 다음 페이지 진행 구간에서 timeout이 재현됐다.

그런데 여기서 진짜 중요한 건 timeout 자체가 아니었다.

멈춘 Trino가 반드시 다시 살아났는가?

fault injection은 불을 내는 기술이 아니라, 소방 훈련이다. 연기를 만드는 데 성공하고 스프링클러를 원복하지 못하면 좋은 테스트가 아니다.

그래서 fault control은 이런 기록을 남긴다.

{
  "name": "pause:trino",
  "applied": true,
  "restored": true,
  "error": null
}

예외가 나도 finally에서 복구한다. restored=true가 아니면 최종 PASS도 허용하지 않는다.

로그는 창고째 들고 오지 말고 사건 구간만 잘라오자

장애 재현을 한 번 돌리고 전체 로그를 복사하면 마음은 편하다. 하지만 수백 MB 로그 속에서 진짜 사건은 몇 초에 불과할 수 있다.

더 위험한 문제도 있다. 이전 테스트의 예외가 전체 로그에 남아 있으면, 방금 재현에서 발생한 것처럼 집계될 수 있다.

그래서 로그를 “시간순 사진”처럼 잘라냈다.

fault 직전 파일 offset 기록
→ fault 실행
→ 실행 후 추가된 byte만 읽기
→ redaction
→ gzip 저장
→ signature count 생성

로그 파일이 rotate되거나 truncate됐는지도 확인한다. 시작 offset보다 현재 파일이 작다면, 누군가 중간에 새 로그 파일로 갈아 끼운 것이다. 이 상황을 조용히 무시하면 안 된다.

이렇게 잘라낸 log window는 두 가지 면에서 좋다.

첫째, 이번 실행의 사건만 본다.

둘째, 사람이 읽을 수 있다.

전체 CCTV 영상을 제출하는 대신, 사고 전후 30초 영상을 증거로 내는 것과 비슷하다.

gzip 안의 비밀번호도 비밀번호다

로그를 gzip으로 압축하면 파일 탐색기가 조용해진다. 그렇다고 secret까지 사라진 것은 아니다.

기존 secret scanner는 일반 JSON과 Markdown은 잘 읽었지만, 압축 로그는 바이너리 파일로 보고 건너뛸 수 있었다.

이건 서랍에 비밀번호를 넣고 서랍을 잠근 뒤 “방에 비밀번호가 없다”고 말하는 것과 같다.

그래서 scanner가 gzip payload까지 열어 검사하도록 바꿨다.

검사 대상도 넓혔다.

  • password/token 형식
  • Bearer credential
  • 문자열 전체로 남은 JWT
  • 일회성 proxy username
  • 실행 중 등록한 정확한 secret 값

검증 결과는 단순하다.

JSON 8개
gzip 로그 5개
secret hit 0

보안에서 “압축했으니 괜찮겠지”는 꽤 위험한 주문이다. 압축은 보안 기능이 아니다.

거대한 만능 프레임워크 대신 공구함을 만들었다

이번 리팩터링에서 유혹이 하나 있었다.

“모든 DB와 모든 장애를 YAML로 표현하는 범용 재현 DSL을 만들까?”

멋있다. 그리고 높은 확률로 다음 버그를 재현하기 전에 DSL 버그부터 고치게 된다.

그래서 반복되는 기계적인 부분만 모듈로 뺐다.

Trino fixture
- Docker lifecycle
- TLS
- readiness
- cleanup

JDBC probe
- driver download와 hash
- Java compile/run
- stdout/stderr/exitCode/elapsedMs

Fault control
- marker 대기
- pause/unpause
- process abort
- 복구 기록

Evidence
- runId와 provenance
- log window
- gzip secret scan

반면 사건의 의미는 시나리오에 남겼다.

  • 어떤 SQL을 실행할지
  • 어떤 timeout을 기대할지
  • 어떤 예외가 독립 신호인지
  • 정상과 fault를 어떻게 비교할지
  • 어떤 문장으로 결과를 설명할지

공구는 공용이지만 사건 기록은 구체적이어야 한다.

망치는 재사용할 수 있다. 그렇다고 모든 사건을 “망치 사건”이라고 부르면 안 된다.

manifest는 시나리오의 입국 신고서가 됐다

코드만 읽지 않고도 시나리오의 경계를 알 수 있도록 manifest도 강화했다.

이제 시나리오는 다음을 선언한다.

fixture는 누가 소유하는가?
필수 환경 변수 중 secret은 무엇인가?
어떤 evidence가 반드시 있어야 하는가?
package 검증을 지원하는가?
cleanup은 무엇을 지우고 무엇을 건드리지 않는가?
fault control이 모두 복구돼야 하는가?

특히 ownership이 중요하다.

scenario-owned
external-owned

재현 도구가 만든 컨테이너는 재현 도구가 치운다. 사용자가 이미 운영하던 Connection과 runtime은 함부로 삭제하지 않는다.

cleanup은 “주변을 깨끗하게”가 아니다.

내가 만든 것만 정확하게 되돌리는 것이다.

마지막 PASS가 조금 더 무거워졌다

표준화 후 전체 lifecycle을 실제로 다시 달렸다.

setup       PASS
reproduce   PASS
verify      PASS
report      PASS
cleanup     PASS

정상 proxy 경로는 20번 모두 성공했다. target pause에서는 의도한 JDBC timeout이 발생했다. target은 다시 unpause됐고, 테스트 컨테이너와 프로세스는 제거됐다. 임시 포트는 닫혔고, 원래 설정은 복구됐다.

보관된 package도 별도로 검사했다.

verify-package: PASS
freshLiveRerun: false
secretHits: 0
sameRun: true

이제 두 PASS는 같은 초록색이어도 의미가 다르다.

live PASS
오늘 차를 실제로 달려봤다.

package PASS
보관된 검사 기록의 봉인이 멀쩡하다.

둘 다 가치 있다. 단지 같은 말인 척하지 않을 뿐이다.

좋은 재현 자동화는 기억력이 아니라 정직함이다

이번 작업에서 얻은 원칙은 다섯 가지다.

첫째, live 검증과 보관 증거 검증을 다른 명령으로 분리한다.

둘째, 모든 증거를 runId로 묶는다.

셋째, fault를 적용했다면 복구도 증거로 남긴다.

넷째, 전체 로그보다 이번 실행의 log window를 보존한다.

다섯째, 압축 파일 안까지 secret을 검사한다.

버그 재현 도구는 과거를 잘 기억해야 한다. 하지만 과거의 기억을 오늘의 실행으로 소개하면 안 된다.

어제의 영수증은 유용하다.

무엇을 샀는지, 얼마를 냈는지, 그날 무슨 일이 있었는지 알려준다.

하지만 오늘 결제가 성공했는지는 오늘 카드 단말기를 찍어봐야 한다.

좋은 ReproKit은 그 차이를 알고 있다.

그리고 이제 PASS라고 말하기 전에 먼저 묻는다.

“이 초록불은 언제 켜진 초록불이지?”