간헐적으로 깨지는 테스트 하나가 배포를 조용히 막고 있었다
다른 작업을 하다가 습관처럼 전체 테스트를 돌렸는데 한 건이 실패했다. 블로그 댓글 컴포넌트 테스트였고, 내가 그 세션에서 건드린 파일과는 아무 관계가 없었다. 그 컴포넌트 근처는 손도 대지 않았는데 실패 로그가 정확히 그 파일을 가리키고 있으니 처음엔 뭘 잘못 건드렸나 싶어 diff를 다시 훑어봤다. 관련 코드는 정말로 아무것도 바뀐 게 없었다.
일단 그 파일만 단독으로 돌려봤다. 통과했다. 혹시나 해서 전체 테스트를 다시 돌렸다. 이번엔 전부 통과했다. 같은 커맨드, 같은 코드, 결과만 달랐다.
간헐적으로 깨지는 테스트였다. 이런 종류의 실패는 재현 스크립트를 만들기가 애매해서 방치되기 쉽다. “어차피 지금은 통과하니까”라며 그냥 다음 커밋으로 넘어가고 싶은 유혹이 컸다. 하지만 방금 두 번 다른 결과가 나온 걸 눈으로 봤기 때문에, 그 유혹을 따라가는 게 오히려 더 찜찜했다.
원인
@testing-library/react의 findBy*와 waitFor는 기본 대기 상한이 1000ms다. DOM에 원하는 요소가 나타나거나 조건이 만족될 때까지 짧은 간격으로 재시도하다가, 그 상한을 넘기면 실패로 처리하는 구조다. 평소엔 이 1000ms가 넉넉해서 문제를 못 느낀다.
그런데 이 파일은 단독으로 돌려도 4.5초가 걸렸다. jsdom 환경을 셋업하는 데만 20초 가까이 잡아먹는, 이 프로젝트에서 유독 무거운 스위트였다. 컴포넌트가 마운트되면서 비동기로 댓글 목록을 fetch하고, 그 응답이 렌더에 반영되기까지의 시간이 평소보다 길게 걸리는 구조였다.
혼자 돌 때는 CPU를 독점하니 그 시간이 1초 안쪽으로 들어온다. 하지만 전체 테스트를 한꺼번에 병렬로 돌리면 여러 파일이 CPU를 나눠 쓰면서 각 파일의 실제 소요 시간이 늘어난다. 그러다 첫 렌더와 비동기 fetch 해소가 우연히 1초를 넘기는 순간, waitFor가 상한에 걸려 실패를 던졌다.
결국 이 테스트 자체는 아무 잘못이 없었다. 테스트가 검증하려는 로직도, 그 로직을 구현한 컴포넌트도 멀쩡했다. 문제는 “1초 안에 끝나야 한다”는 암묵적인 전제와, 그 전제가 실행 환경의 부하에 따라 깨질 수도 있다는 사실 사이의 간극이었다. 코드가 아니라 테스트가 코드의 정상 동작을 판정하는 방식 쪽에 결함이 있었던 셈이다.
단독으로 실행하면 통과하니까 원인을 찾기 어려운 형태다. 에러 메시지도 “요소를 찾지 못했다” 정도로만 나오지, “시스템이 느려서 타이밍을 놓쳤다”고 알려주지는 않는다. “다시 돌리니 되네”로 넘어가기 딱 좋은 증상이었다.
왜 그냥 넘기면 안 되는가
이 프로젝트는 git push 한 번으로 배포되는 구조다. Jenkins가 push를 받아서 코드를 pull하고, 의존성을 install하고, 배포 전에 전체 테스트를 돌리고, 그게 통과해야만 빌드와 pm2 재기동으로 넘어간다. 테스트는 취미로 돌리는 체크리스트가 아니라 배포 파이프라인의 게이트다.
즉 간헐 실패는 배포가 조용히 스킵되는 것과 동의어다. push는 성공했고, 커밋은 master에 정상적으로 올라가 있고, 콘솔에 딱히 붉은 에러가 뜨는 것도 아니다. 그런데 Jenkins 파이프라인은 테스트 단계에서 멈춰 있고, 빌드도 재기동도 일어나지 않았으니 라이브 서버는 예전 코드 그대로다. 이 상태에서 다른 작업을 계속하다가 한참 뒤에야 “분명 고쳤는데 왜 반영이 안 됐지?”하고 되짚어보게 된다.
이게 막연한 걱정이 아닌 이유는, 실제로 이 프로젝트에서 빌드 실패로 배포가 통째로 안 되던 일이 있었기 때문이다. 그 경험이 있었기에 “테스트 하나가 어쩌다 깨진 것뿐”이라고 넘기기가 더 꺼려졌다.
정리하면 이렇다. 로컬 개발 중에 마주치는 flaky 테스트는 재실행 버튼 한 번 누르면 되는 성가심에 가깝다. 하지만 그 테스트가 배포 파이프라인의 게이트에 물려 있는 순간, flaky는 파이프라인이 아무 경고 없이 배포를 건너뛰게 만드는 무증상 장애가 된다. 같은 코드, 같은 결함인데 어디에 놓여 있느냐에 따라 결과의 무게가 완전히 달라진다.
거기다 이 상태를 방치하면 팀 차원에서 CI 결과를 신뢰하지 않게 되는 부작용도 생긴다. “그 테스트는 원래 가끔 저래”라는 말이 한 번 통용되기 시작하면, 진짜 회귀 때문에 그 테스트가 실패했을 때도 사람들은 습관적으로 재실행 버튼부터 누르게 된다. 그 순간부터 배포 게이트는 게이트로서의 의미를 잃는다.
수정
근본적으로 fetch나 렌더 자체를 빠르게 만들 수는 없는 노릇이라, setup 파일에서 대기 상한을 올리는 쪽을 택했다. 해당 테스트 파일 하나에만 개별 타임아웃을 걸어주는 방법도 잠깐 고려했지만, 같은 조건 아래 있는 다른 무거운 스위트에서도 언제든 같은 증상이 재발할 수 있다고 판단해 전역 setup 값을 바꾸는 쪽을 택했다.
import { configure } from '@testing-library/react'
configure({ asyncUtilTimeout: 5000 })
이 수정이 안전한 변경인지는 따로 따져봐야 했다. asyncUtilTimeout은 조건을 기다리다 포기하기까지의 상한이지, 무조건 그 시간만큼 기다리는 고정 지연이 아니다. 조건이 100ms 만에 만족되면 그 즉시 통과하고 끝난다. 원래도 빠르게 통과하던 테스트들은 이 변경 이후에도 똑같이 빠르다.
더 중요한 건 이 변경이 실패해야 할 테스트를 통과로 뒤집지 않는다는 점이다. 어떤 조건이 코드 버그 때문에 영영 만족되지 않는다면, 예전엔 1초 뒤에 실패했던 것이 이제는 5초 뒤에 실패할 뿐이다. 판정까지 걸리는 시간이 늘어날 뿐 판정 결과 자체는 바뀌지 않는다. expect(fn).not.toHaveBeenCalled()처럼 “어떤 일이 일어나지 않았음”을 확인하는 단언들은 대기 없이 즉시 판정되기 때문에, 이 설정 변경과는 아예 무관하다.
검증은 한 번으로 끝내지 않았다. 전체 스위트를 3회 연속 돌려서 모두 통과하는 걸 확인했다. flaky 테스트는 원래 어쩌다 한 번 통과하기도 하는 게 특징이라, 단 한 번의 성공으로는 아무것도 증명하지 못한다고 생각했다.
교훈
- “다시 돌리니 되네”는 결론이 아니라 증상이다. 재현이 안 되는 게 아니라, 재현 조건이 그날의 시스템 부하에 달려 있는 것뿐이다. 원인을 찾았다고 착각하지 않는 게 먼저다.
- 배포 파이프라인이 테스트를 게이트로 쓰는 구조라면, flaky 테스트의 비용은 성가심에서 무증상 배포 실패로 바뀐다. 똑같은 결함이라도 그게 로컬에 있느냐 파이프라인에 있느냐에 따라 심각도가 완전히 다르다.
- 대기 상한을 늘리는 것과 코드에 고정 지연을 넣는 것은 전혀 다른 조치다. 상한을 늘리는 쪽은 통과 속도에 영향을 주지 않고, 실패해야 할 결과를 감추지도 않는다. 이 둘을 구분하지 못하면 “타임아웃을 늘리는 건 문제를 덮는 것”이라는 잘못된 원칙을 스스로 세우게 된다.
- flaky 수정의 검증에는 반복 실행이 필요하다. 문제를 재현하기도 어려웠던 만큼, 고쳤다는 확신도 한 번의 통과가 아니라 여러 번의 통과에서 나온다.
- CI가 “그 테스트는 원래 가끔 저래”를 허용하는 순간 게이트로서의 기능은 끝난다. 진짜 회귀와 환경 탓 실패를 사람이 매번 눈으로 구분해야 한다면, 그건 이미 자동화된 게이트가 아니다.