버그를 재현하는 도구는 이상하게 살이 잘 찐다.
처음에는 질문 하나로 시작한다.
새 연결에서도 요청이 반드시 프록시를 지나가는가?
그런데 재현을 하다 보면 곁가지가 붙는다. 타임아웃도 재현해볼까. 로그도 더 모아볼까. 취소 요청도 끼워볼까. 성공한 횟수도 스무 번쯤 세어볼까.
모두 나쁜 생각은 아니다. 문제는 어느 순간부터 도구가 원래 질문보다 많은 것을 판정하기 시작한다는 데 있다.
그러면 테스트는 커지는데, 정작 답은 흐려진다.
이번에 얻은 결론은 단순했다.
좋은 재현 도구는 더 많이 재현하는 도구가 아니다.
가장 중요한 가설을 가장 적은 변수로 판정하는 도구다.
초록색 체크에도 두 종류가 있다
재현 도구에서 가장 위험한 단어는 PASS다. 같은 단어라도 뜻이 정반대일 수 있기 때문이다.
예를 들어 프록시가 다음 요청 주소를 잘못 바꿔서 클라이언트가 내부 서버로 직접 연결하는 버그를 생각해보자.
패치 전에는 이 결과가 성공이다.
새 TCP 연결에서
응답 nextUri가 내부 서버 주소로 새어 나간다
→ 버그 재현 PASS
하지만 패치 후에는 같은 결과가 실패여야 한다.
새 TCP 연결에서
응답 nextUri가 프록시 주소로 유지된다
→ 패치 검증 PASS
한 검사에 이 둘을 억지로 넣으면 무슨 일이 생길까.
패치가 잘 동작해서 내부 주소가 더 이상 나오지 않았는데도, “버그가 재현되지 않았다”는 이유로 테스트가 실패할 수 있다. 사람은 그 빨간색 실패를 보고 잠깐 멈춘다.
지금 실패는 나쁜 실패인가, 좋은 실패인가?
이 질문에 바로 답할 수 없다면 테스트 계약이 섞인 것이다.
그래서 재현과 검증은 이름부터 분리하는 편이 좋다.
reproduce
= known-bad 환경에서 문제를 보여주면 성공
verify
= patched 환경에서 문제가 사라졌음을 보여주면 성공
이건 명령어 취향 문제가 아니다. 결과를 해석하는 사람의 시간을 아끼는 설계다.
성공한 쿼리와 올바른 경로는 다르다
분산 시스템에서는 결과가 맞아도 길이 틀릴 수 있다.
긴 쿼리 결과를 페이지로 나눠 보내는 프로토콜을 생각해보자.
POST query
→ nextUri #1
GET nextUri #1
→ nextUri #2
GET nextUri #2
→ ...
처음 요청은 프록시를 통과했다. 쿼리도 성공했다. 결과도 맞다.
그런데 두 번째 GET부터 내부 서버로 직접 갔다면 어떨까.
Client → Proxy → Target 첫 요청
Client → Proxy → Target 첫 continuation
Client ─────────→ Target 다음 continuation
결과만 보는 테스트는 이 상황을 통과시킬 수 있다. 테스트 환경에서 클라이언트가 내부 서버에도 접근할 수 있다면, 우회는 장애가 아니라 평범한 성공처럼 보이기 때문이다.
그래서 이런 문제에는 결과 계약과 경로 계약이 둘 다 필요하다.
결과 계약
- 쿼리가 성공했는가?
- 예상한 데이터를 받았는가?
경로 계약
- 모든 client-facing nextUri가 프록시 주소인가?
- 모든 continuation request의 실제 peer가 프록시인가?
- 새 TCP 연결에서도 같은 경계가 유지되는가?
여기서 중요한 단어는 “실제”다. 응답 문자열에 주소가 프록시처럼 보이는지뿐 아니라, socket이 실제 어디에 연결됐는지도 기록해야 한다.
택배 상자에 물류센터 스티커가 붙어 있다고 해서, 배송 트럭이 정말 물류센터를 거쳤다는 뜻은 아니다.
실험 장치가 가설을 가리기 시작할 때
재현 도구에 타임아웃 주입, 강제 취소, 로그 압축, 여러 종류의 예외 관찰을 한꺼번에 넣으면 처음에는 든든하다.
하지만 질문이 “새 TCP 연결에서도 nextUri가 프록시로 유지되는가?”라면, 그 장치들은 모두 필수일까?
아니다. 오히려 다음과 같은 문제가 생긴다.
- 타임아웃이 나서 실패했다
→ 경로 문제가 실패한 것인가?
→ 장애 주입이 실패한 것인가?
- 로그가 기대와 달랐다
→ 패치가 틀린 것인가?
→ 로그 레벨이 달라진 것인가?
- 취소 요청이 실패했다
→ continuation 주소와 무슨 관계가 있는가?
실험이 많은 사실을 수집한다고 해서 원인 판정이 좋아지는 것은 아니다.
좋은 재현 시나리오는 우선순위를 정한다.
1. 현재 가설을 직접 증명하는 관찰값은 무엇인가?
2. 그 관찰값을 흐리는 장치는 무엇인가?
3. 다음 가설을 위한 장치는 이번 실행에서 빼도 되는가?
이번 경우에는 세 가지면 충분했다.
1. direct / proxy 기본 연결이 모두 건강한가
2. 새 TCP 요청마다 response nextUri가 어디를 가리키는가
3. 실제 follow-up TCP connection이 어디로 갔는가
나머지는 별도 실험으로 분리할 수 있다. 재현 도구가 다이어트에 성공하면, 실패 메시지도 더 솔직해진다.
공통화는 두 번째 반복부터
이런 일을 하다 보면 또 다른 유혹이 온다.
아예 모든 프록시 문제를 위한 범용 프레임워크를 만들자.
멋진 이름도 붙일 수 있다. YAML DSL, PortLease, 거대한 scenario engine, 모든 프로토콜을 품은 추상 클래스.
하지만 첫 사건의 모양만 보고 공통화를 시작하면 다음 사건에서 예외가 튀어나온다. 결국 버그 대신 프레임워크를 디버깅하게 된다.
더 건강한 규칙은 이렇다.
한 번 등장한 복잡성은 scenario 안에 남긴다.
두 번 반복된 절차는 helper 후보로 본다.
세 번 반복된 계약은 공통 모듈로 승격한다.
공통화하기 좋은 것은 보통 이런 것들이다.
- fixture의 시작과 정리
- secret redaction
- run ID와 provenance
- artifact package validation
- 실제 TCP/HTTP 관찰 helper
반대로 티켓 안에 남겨야 하는 것도 있다.
- 이번 버그의 acceptance criteria
- baseline에서 무엇이 재현돼야 하는가
- patch에서 무엇이 사라져야 하는가
- 어떤 증거 조합이 이번 문제를 설명하는가
공통 모듈은 실험을 쉽게 만들고, scenario는 질문을 선명하게 만든다. 둘의 역할을 바꾸면 재사용성도 설명력도 함께 잃는다.
테스트 명령도 제품의 일부다
마지막으로 아주 현실적인 교훈이 하나 있다.
좋은 test suite는 존재하는 것만으로 충분하지 않다. 다른 사람이 같은 명령으로 실행할 수 있어야 한다.
가이드에는 표준 테스트 명령이 적혀 있는데 실제 테스트는 다른 runner와 dependency를 기대한다면, 그 순간부터 품질 게이트는 개인의 개발 환경에 의존한다.
그래서 작은 정리가 필요하다.
- 테스트 runner를 하나로 정한다
- 필요한 개발 dependency를 파일로 선언한다
- 가이드의 명령과 CI의 명령을 같은 것으로 만든다
- 새 환경에서 한 번 실제로 설치하고 실행한다
이 일은 화려하지 않다. 하지만 재현 도구에게는 아주 중요하다.
재현 도구의 목적은 “내 컴퓨터에서 원인을 찾기”가 아니라, “다른 사람도 같은 실험과 같은 판정을 다시 만들기”이기 때문이다.
다이어트 후에 남겨야 할 것
재현 도구를 줄인다고 증거를 버리는 것은 아니다. 질문에 꼭 필요한 증거를 남기는 것이다.
- 이번 실행의 run ID
- 어떤 source / fixture를 썼는지
- baseline 또는 patch 중 무엇을 검증했는지
- 실제 protocol route 관찰값
- artifact가 같은 실행에서 나온 것인지
- temporary fixture가 정리됐는지
이 정도면 충분히 강하다.
버그를 잡을 때는 모든 장비를 실은 트럭보다, 지금 필요한 렌치가 더 빠를 때가 많다.
좋은 ReproKit도 마찬가지다.
더 많은 버튼을 갖는 것이 목표가 아니다.
실패가 나왔을 때 왜 실패했는지, 성공이 나왔을 때 무엇이 정말 성공했는지 바로 설명할 수 있는 것.
그것이 오래 살아남는 재현 도구의 조건이다.