1. 개요
이 시리즈 내내 “초록불이 거짓말한다” 는 말이 반복됐다. 워크플로 success 와 이미지 존재가 다르고(2편), PR CI 의 초록이 낡고(3편), 파드 Running 이 서비스 정상을 뜻하지 않았다(5편). 마지막 편은 테스트 자체가 거짓말하는 경우를 모았다.
2. 핵심 내용
2-1. 플래키 테스트의 진범은 ShedLock 이었다
main 게이트의 통합테스트 하나가 간헐적으로 실패했다. 재실행하면 통과. PR 게이트에서는 한 번도 안 잡혔다. expected: EXPIRED but was: WAITLISTED.
처음 세운 가설은 “LIMIT 없는 전역 스캔의 경합” 이었다. 틀렸다. 후보 행은 항상 1건이었다. 진짜 원인은 세 겹이었다.
- 테스트 프로파일에서
@EnableScheduling이 두 곳 에 있었다. 메인 클래스와 별도 설정 클래스. 한쪽만 꺼도 진짜 cron 이 테스트 스위트 도중에 돈다. - 예약 만료 스케줄러에
@SchedulerLock(lockAtLeastFor = "PT30S")가 걸려 있다. 실제 실행은 3ms 지만 공용 Redis 락은 30초 유지된다. - 테스트는 스케줄러 빈 을 직접 호출했다. 그 빈은 ShedLock 프록시다. 진짜 cron 이 방금 돌아 락이 살아 있으면, 프록시는 본문을 조용히 건너뛴다. 예외도 없고,
It's locked로그는 DEBUG 다.
30초 창과 테스트 순서가 겹칠 확률을 계산하니 회차당 약 10% 였다. 딱 “가끔 실패” 다.
해법은 AopTestUtils.getTargetObject() 로 프록시를 벗겨 본문을 직접 호출하는 것. 결정적 재현은 Redis 에 락 키를 미리 심어 두면 된다.
배운 것: 실패하지 않고 실행되지 않는 코드 는 catch 에도 로그에도 안 남는다. 그리고 테스트 파일명과 클래스명이 달라 CI 리포트의 이름과 스택트레이스가 어긋나 있던 것도 시간을 잡아먹었다.
2-2. Gradle 의 초록 거짓말
통합테스트는 integrationTest 라는 별도 태스크다. ./gradlew test 로는 돌지 않는다. 그리고 Gradle 은 입력이 안 바뀌면 UP-TO-DATE 로 0건 실행하고 BUILD SUCCESSFUL 을 준다.
> Task :integrationTest UP-TO-DATE
BUILD SUCCESSFUL
이 출력만 보면 통과한 것 같다. 실제 실행 건수는 build/test-results/integrationTest/*.xml 의 tests= 속성으로 세야 한다. 확실히 돌리려면 --rerun.
Testcontainers 는 도커 소켓이 필요하다. 맥에서 OrbStack 을 쓰면 DOCKER_HOST 를 넘겨야 컨테이너가 뜬다. 안 넘기면 컨테이너 기동 실패로 스킵되는데, 이것도 조용하다.
2-3. 가드가 실제로 빨개지는지 변이로 확인한다
“이 가드가 그 실수를 막는다” 는 주장은 가드가 실제로 실패하는 걸 봐야 믿을 수 있다. 몇 번 속았다.
- 중복 조건이 변이를 가린다. A 와 B 가 둘 다 막고 있으면 A 를 망가뜨려도 B 가 막아 초록이다. A 는 무력한데 있는 것처럼 보인다.
- vitest 는
NEXT_PUBLIC_DOMAIN이 비어 있고NODE_ENV=test다. 운영 환경변수에서 파생되는 가드(예: 쿠키 도메인,secure플래그)는 테스트 환경에서 아예 발화하지 않는다.vi.stubEnv로 prod 세계를 재현해야 한다. 단언이 있어도 치명적 변이가 통과한다.
그래서 가드를 넣을 때는 일부러 망가뜨려 빨간불을 본 뒤 되돌려 머지한다. 라우트 렌더링 불변식 테스트, 마이그레이션 커밋 타입 검사, 네이티브 ENUM 0건 단언 전부 이 절차를 거쳤다.
2-4. e2e 두 계층
Playwright e2e 를 어디서 어떻게 돌릴지 오래 고민했다. 결론은 두 계층이다.
① route-stub 계층 (PR 게이트)
- 백엔드 없이 돈다. 모든 API 를
page.route()로 스텁한다. - 백엔드 주소를 닫힌 포트
127.0.0.1:9로 못박는다. 스텁을 빠뜨린 요청이 실제 서버로 새는 것을 즉시 잡는다. - 프로젝트 네 개(desktop / mobile / fold / tablet). 접기 화면(fold)은 실제 폴더블 기기 이슈에서 나왔다.
- PR 마다, 그리고 매일 새벽에.
② staging 계층 (배포 뒤)
- 배포된 staging 에 실제 로그인한다. 5분 예산.
- main push 가 staging 에 배포된 뒤, 또는 수동으로.
- “매번 새 환경을 띄우는 ephemeral” 안도 있었지만, 홈랩 자원과 시크릿 관리 비용을 따져 이미 배포된 staging 에 얹는 쪽을 택했다.
- 보고는 라벨이 붙은 이슈 하나 에 누적하고 통과하면 닫는다. 실패마다 이슈를 만들면 아무도 안 읽는다.
Turnstile 은 사이트 키가 빌드타임 상수라 staging 빌드에서 끌 수 없다. staging 은 백엔드 검증을 끄고, e2e 는 위젯 스크립트를 route 로 대체한다.
PR CI 는 잡 병합, 캐시 키 정리, vitest maxWorkers 를 CI 에서만 16 으로 올려 15분에서 9.6분으로 줄였다.
2-5. 그래도 안 잡히는 축
PR CI 가 안 보는 것을 알고 있어야 한다.
- 도커 빌드: PR 에서는 이미지를 만들지 않는다. Node 버전 불일치,
.dockerignore회귀는 main 에서만 드러난다. - 실제 오브젝트 스토리지 업로드: 단위테스트는 목이다. AWS SDK 체크섬처럼 “요청 모양” 이 문제인 것은 실서버 통합테스트가 있어야 잡힌다. 그래서 테스트 컨테이너를 운영과 같은 SeaweedFS 로 바꿨다.
- 환경변수 파생 가드: 위 2-3.
이 축의 초록은 보증이 아니다. 알고 있으면 릴리스 전에 그 축만 따로 본다.
3. 마무리
요약
- ShedLock 프록시 빈을 테스트에서 직접 호출하면 락 창에서 본문이 조용히 스킵된다. 프록시를 벗겨라.
UP-TO-DATE는 0건 실행이다. XML 의tests=를 세라.- 가드는 일부러 망가뜨려 빨간불을 본 뒤에 믿는다. 테스트 env 가 운영을 재현하는지 확인한다.
- e2e 는 route-stub(PR) + 배포된 staging(실로그인) 두 계층. 스텁 누락은 닫힌 포트로 잡는다.
- PR CI 가 안 보는 축(도커 빌드·실업로드·env 가드)을 목록으로 갖고 있어라.
시리즈는 여기까지다. 두 달 동안 코드는 거의 그대로였고 코드 바깥이 전부 바뀌었다. 다음에 인프라를 또 옮기게 되면, 이 일곱 편이 체크리스트가 될 것이다.