앞 글에서 결제 원장의 경합 버그를 진단했습니다. POS 취소가 남긴 표시를 승인 결과 저장이 덮어써서, 자동 복구 배치가 그 거래를 영영 보지 못하게 되는 문제였습니다.
이번 글은 그걸 실제로 고친 기록입니다. 그런데 정작 배운 건 결제 도메인이 아니라 테스트에서 나왔습니다.
세 줄 요약
- 구현 계획서를 직접 쓰고 태스크마다 리뷰를 붙여 실행했는데, 리뷰가 잡은 것의 상당수는 코드가 아니라 제가 계획서에 써넣은 테스트였습니다.
- 고친 코드를 되돌려도 초록으로 남는 테스트가 다섯 개 나왔습니다. 전부 다른 방식으로 무력했습니다.
- 손수 뮤테이션(일부러 망가뜨려 red 확인)을 절차로 넣어 그건 막았지만, 태스크 리뷰를 다 통과한 뒤 전체 리뷰에서만 보이는 문제가 또 있었습니다.
계획서를 먼저 쓰고, 태스크마다 리뷰를 붙였다
작업 방식부터 짧게. 설계 문서를 먼저 확정하고, 그걸 8개 태스크로 쪼갠 구현 계획서를 썼습니다. 각 태스크는 파일 목록, 넘겨받는 인터페이스, 그리고 테스트 코드까지 포함한 완결된 지시서입니다.
실행은 태스크당 새 작업자를 붙이고, 끝날 때마다 별도 리뷰어가 그 태스크의 diff만 보고 검증하는 식으로 돌렸습니다. 리뷰가 지적하면 같은 작업자가 고치고, 범위를 좁힌 재리뷰를 거쳐 다음 태스크로 넘어갑니다.
의도는 단순했습니다. 계획서에 테스트까지 적어두면 구현이 흔들리지 않을 테니까요.
실제로는 반대였습니다. 계획서에 적은 테스트가 구멍의 출처였습니다.
다섯 개의 초록 거짓말
전부 통과했고, 전부 아무것도 지키지 않았습니다. 하나씩 보면 실패 방식이 다 다릅니다.
1. 체인을 통째로 스텁해서, 쿼리가 틀려도 통과
조건부 UPDATE를 검증하는 테스트였습니다. 쿼리 빌더의 fluent 체인을 전부 any() 로 스텁하고, 영향 행 수만 조작해 반환값을 확인했습니다.
update(any())
.set(any(), any())
.where(any())
.execute() → 1 or 0
문제는 WHERE 절에 무엇이 들어갔는지 아무도 안 본다는 겁니다. 상태 조건이 빠지든, AND가 OR가 되든, 엉뚱한 값과 비교하든 세 테스트 모두 초록입니다. 검증하는 건 "행 수 → 불리언" 매핑뿐이었습니다.
그리고 실제로 그 사각지대에서 버그가 나왔습니다. 성공 전이는 종료 시각을 채우는데 실패 전이만 빠뜨려져 있었고, 이 테스트들은 그걸 볼 수 없었습니다.
고친 방식은 ArgumentCaptor 로 실제 술어와 SET 필드를 붙잡아 값으로 비교하는 것이었습니다. 그 뒤 종료 시각을 다시 지워보니 테스트가 빨갛게 변했습니다.
2. 잡아야 할 예외 분기를 지워도 통과
상태 충돌 예외를 전용 catch로 분리하는 태스크였습니다. 그 catch가 없으면 예외가 포괄 catch로 떨어져 실패 마킹을 호출하게 되는데, 그게 바로 막아야 할 동작이었습니다.
그런데 테스트는 실패 마킹 메서드를 단독으로 검증하고, 가드 메서드를 리플렉션으로 직접 호출해 검증했습니다. 정작 둘이 만나는 지점을 지나가는 테스트가 없었습니다. 전용 catch를 통째로 지우거나 포괄 catch 뒤로 옮겨도 전부 초록입니다.
3. 대상이 참조하지도 않는 mock에 "호출 안 됨" 단언
이게 제일 부끄럽습니다. 후속 이벤트 분기 태스크에서, "승인 통지에는 절대 상태 조건을 걸지 않는다"는 설계 결정을 지키려고 이런 테스트를 넣었습니다.
handlePostPaymentEvents(payment) 실행
→ then(외부통지서비스).shouldHaveNoInteractions()
handlePostPaymentEvents 는 그 서비스를 아예 참조하지 않습니다. 통지는 그보다 앞선 다른 메서드에서 나갑니다. 그러니 저 단언은 무조건 참입니다. 심지어 테스트 이름과 주석에 "이 테스트가 막는다"고 적어뒀습니다.
없는 보장을 주장하는 테스트는 없는 것보다 나쁩니다. 다음 사람이 그걸 믿고 안 만들 테니까요.
같은 형태가 하나 더 있었습니다. 승인 가드 태스크에서 "차단되면 카드 승인을 요청하지 않는다"를 검증한다며, 가드 메서드가 참조하지 않는 서비스에 no-interaction을 단언했습니다.
4. 조건을 못 넘는 값이라 두 개가 무의미하게 통과
푸시 발송 여부를 검증하는 테스트에서, 제가 파트너 코드로 "SEV" 를 썼습니다. 그런데 이 코드는 실시간 푸시 허용 목록에 없습니다. 허용된 건 다른 두 개였습니다.
그래서 푸시 분기에 애초에 도달하지 못했고, 네 테스트 중 두 개가 아무것도 실행하지 않은 채 통과하고 있었습니다. 이건 리뷰어가 아니라 구현 작업자가 잡았습니다 — 제가 "이 값이 실제로 조건을 통과하는지 확인하라"고 지시에 적어둔 덕이었습니다.
5. 순서를 고정한 테스트가, 순서를 바꾸자 그대로 통과
마지막 태스크에서 경보 판정 순서를 바꿨습니다. "현재 실패를 센 뒤에 판정한다"를 못박으려고 InOrder 로 저장 → 카운트 순서를 검증하는 테스트를 넣어뒀는데, 수정 과정에서 카운트 조회가 저장 앞뒤로 두 번 생겼습니다.
InOrder 는 부분 수열을 찾습니다. 뒤쪽 카운트 하나만 있어도 "저장 → 카운트"가 성립하니, 순서를 되돌려도 통과합니다. 작업자가 이걸 스스로 발견하고 times(2) 를 더해 강화했습니다.
다섯 개의 공통 구조
늘어놓고 보니 형태가 세 가지입니다.
| 형태 | 왜 통과하나 |
|---|---|
| 관측 지점이 대상 밖 | 검증 대상이 그 협력 객체를 아예 안 쓴다 (3, 2) |
| 관측 범위가 너무 넓음 | any() 나 부분 수열이라 틀린 값도 매칭된다 (1, 5) |
| 실행이 도달 못 함 | 앞선 조건에 걸려 검증할 코드가 안 돈다 (4) |
셋 다 "실패하는 경우를 한 번도 안 봤다" 는 한 문장으로 묶입니다. 테스트를 쓰고 초록을 확인하는 순간 일이 끝났다고 느끼는데, 그때 확인한 건 "지금 코드에서 통과한다"뿐입니다. "틀린 코드에서 실패한다"는 별개의 사실이고, 확인하지 않으면 알 수 없습니다.
처방: 손으로 뮤테이션
세 번째 태스크부터 지시에 한 줄을 고정으로 넣었습니다.
커밋 전에 새 동작을 일부러 망가뜨려 테스트가 red가 되는지 확인하고, 되돌린 뒤 무엇을 관찰했는지 보고할 것.
효과는 즉시 나왔습니다. 작업자들이 catch 절을 지워보고, 임계치 비교를 뒤집어보고, 예외 삼킴의 반환값을 바꿔보면서 자기 테스트가 무력한 걸 스스로 발견하기 시작했습니다. 5번 사례가 그렇게 잡혔습니다.
뮤테이션 테스팅 도구를 붙이면 자동화되지만, 지금 환경은 인프라 제약으로 애플리케이션을 띄울 수 없어 도구를 돌리기 어렵습니다. 손으로 한 줄 망가뜨렸다 되돌리는 것만으로도 대부분 걸러졌습니다. 비용이 거의 없는데 효과가 큰 쪽에 속합니다.
한 가지 함정은 있었습니다. 3번 사례를 고칠 때, 작업자가 가드를 지워 테스트가 빨간 걸 확인했는데 — 알고 보니 먼저 걸린 다른 단언 때문이었습니다. 새로 넣은 단언이 독립적으로 실패하는지는 그것만 남기고 다시 돌려봐야 확인됩니다. 작업자가 그 2차 확인까지 하고 보고했습니다.
그런데 태스크 리뷰를 다 통과해도 안 보이는 게 있었다
여기까지가 태스크 단위 이야기입니다. 8개 태스크 전부 리뷰가 깨끗해진 뒤, 브랜치 전체를 한 번에 보는 리뷰를 돌렸습니다. 두 개가 더 나왔습니다. 둘 다 태스크 하나만 봐서는 보일 수 없는 것이었습니다.
하루 144번 울리는 경보
마지막 태스크가 만든 건 "자동 복구를 포기했다"는 경보입니다. 누적 실패가 임계치를 넘으면 고정 마커를 붙여 로그를 남기고, 배포 후 그 마커를 알림 채널에 연결하는 계획이었습니다.
그 자체로는 문제가 없습니다. 문제는 다른 두 사실과 곱해질 때 생깁니다.
- 배치의 두 번째 조회에 날짜 하한이 없습니다. 실패한 건은 조건을 계속 만족하므로 영원히 대상으로 남습니다.
- 배치는 10분 주기입니다.
임계치를 넘긴 거래 하나가 하루 144번, 무한히 경보를 냅니다. 그리고 이번 변경은 그런 행의 개수를 오히려 늘립니다 — 전이가 거부된 거래가 전부 그 상태로 보존되니까요. 알림 채널에 마커를 연결하는 순간 페이지 폭탄이 됩니다.
세 조각이 각각 다른 태스크(혹은 기존 코드)에 있어서, 태스크 리뷰는 어느 것도 볼 수 없었습니다.
고친 방식은 레벨 판정을 엣지 전이 판정으로 바꾸는 것이었습니다.
before: count >= 3 → 넘긴 상태면 매번 발화
after : before < 3 && after >= 3 → 넘는 순간에만 발화
덤으로 하나 더 해결됐습니다. 한 회차에 같은 거래가 두 조회 모두에 걸려 카운트가 2씩 오를 수 있는데, 엣지 판정은 구간이 겹치지 않아 그래도 한 번만 뜹니다.
여기엔 남는 한계가 있습니다. 카운트 조회가 하필 임계치를 넘는 그 회차에 실패하면, 다음 회차는 이미 넘은 값을 보므로 그 교차를 영원히 놓칩니다. 지속되는 "이미 알렸음" 표시 없이는 못 고치는 구조라 주석으로 명시하고 남겼습니다.
catch가 막지 못하는 롤백
두 번째가 더 미묘합니다.
원장 갱신을 조건부로 바꾸면서 준영속 엔티티의 통짜 저장을 없앴습니다. 그런데 그 통짜 저장이 특정 컬럼을 저장하던 유일한 경로이기도 했습니다. 부수효과에 기대던 저장이라 제거 시점에 아무 신호도 없었습니다. 별도 메서드로 복구했습니다.
그 복구가 실패하면 어떻게 될까요? 같은 트랜잭션 안이라 결과 테이블 적재까지 함께 롤백됩니다. 그러면 카드 승인은 살아 있는데 승인번호가 없는 행이 남고, 복구 배치는 승인번호가 있는 거래만 찾으므로 그 거래는 영원히 안 보이게 됩니다. 이 브랜치가 없애려던 결과가 가맹점 테이블 딸꾹질 하나로 재현됩니다.
그래서 그 블록을 try/catch 로 감쌌습니다. 그리고 주석에 이렇게 적었습니다 — "롤백을 막는 것은 순서가 아니라 이 catch 다."
그 주석이 틀렸습니다. 마지막 리뷰가 짚었습니다.
그 블록에서 DB를 건드리는 두 호출은 둘 다 @Transactional 참여자입니다. 참여 중인 내부 호출이 예외를 던지면 스프링이 공유 트랜잭션을 rollback-only로 마킹하고, 바깥에서 잡아도 그 표시는 지워지지 않습니다. 바깥 커밋이 예외를 던지고 결과 테이블은 결국 롤백됩니다.
catch가 실제로 막는 건 그 블록의 순수 자바 실패뿐입니다. 그런데 제가 넣은 테스트는 하필 저장소 커넥션 끊김을 시뮬레이션하고 있었습니다. mock은 스프링 트랜잭션에 참여하지 않으니 테스트는 초록인데, 운영에서는 롤백됩니다. 여섯 번째 초록 거짓말이었던 셈입니다.
동작 자체는 이전보다 나빠지지 않았으니 주석과 테스트 시나리오만 정확하게 고쳤습니다. 완전한 해소는 트랜잭션 분리가 필요해 범위 밖으로 뒀습니다.
그리고 로그도 같은 거짓말을 하고 있었다
여기까지 쓰고 며칠 뒤, 같은 형태를 테스트가 아닌 곳에서 다시 만났습니다.
복구 배치의 요약 로그입니다.
[배치] 처리 완료 - 대상 7건, 성공 5건, 실패 2건
읽으면 "5건 취소됐고 2건 실패했구나" 로 읽힙니다. 실제로는 아니었습니다. 저 성공 은 처리 함수가 예외를 던지지 않은 횟수였습니다.
try { processOne(payment); success++; }
catch { failure++; }
processOne 은 결제사가 취소를 거절해도 정상 종료합니다. 거절을 원장에 기록하는 것까지가 그 함수의 일이니까요. 그러니 결제사가 7건 전부 거절한 날에도 로그는 성공 7건 이라고 찍힙니다.
테스트에서 본 것과 같은 구조입니다. 관측 지점이 관측 대상 밖에 있습니다. 예외 유무를 보면서 취소 성사 여부를 말하고 있었습니다.
그런데 이쪽이 더 나쁩니다. 이유가 둘입니다.
아무도 로그를 뮤테이션 테스트하지 않습니다. 테스트에는 "일부러 망가뜨려 빨간지 본다" 는 절차라도 붙일 수 있었습니다. 로그에는 붙일 자리가 없습니다. 틀린 숫자가 몇 달이고 대시보드에 떠 있어도 그걸 확인할 의식(儀式)이 없습니다.
그리고 로그는 사람의 판단으로 이어집니다. 무력한 테스트는 조용히 통과할 뿐이지만, 거짓말하는 로그는 틀린 방향의 행동을 부릅니다. 취소가 전부 실패하고 있는데 성공 7건 을 보고 "배치는 잘 돌고 있네" 하고 넘어가는 것 — 그게 이 로그가 실제로 만들 결과였습니다.
고친 방식은 테스트 때와 똑같습니다. 관측 지점을 대상 안으로 옮겼습니다. 처리 함수가 결과를 반환하게 하고, 그 결과로 집계합니다.
[배치] 처리 완료 - 대상 7건 | 취소성공 4건 · 취소실패 2건 · 전송 1건 · 처리오류 0건
같은 날 하나가 더 있었습니다. 원장을 조건 없이 덮어쓰던 결함 — 이 글의 출발점이었던 그 결함이 같은 코드베이스의 다른 메서드에 그대로 남아 있었습니다. 8개 태스크와 전체 리뷰를 거치고 병합까지 마친 뒤였습니다.
더 뼈아픈 건, 최종 리뷰가 그 메서드를 이미 지목했었다는 점입니다. 다만 다른 질문을 하고 있었습니다 — "이게 실제로 DB에 쓰긴 하나?" 를 따졌지 "쓸 때 조건이 맞나?" 는 묻지 않았습니다. 파일 이름이 리뷰 결과에 등장했다는 사실이 그 파일이 감사됐다는 뜻은 아니었습니다.
남은 것 — 여기서 검증할 수 없는 것들
인터넷망 환경이라 애플리케이션을 띄울 수 없습니다. 332개 테스트가 통과하지만, 그 숫자가 덮지 못하는 게 셋 있습니다.
조건부 UPDATE가 실제로 도는지. 어댑터 테스트는 쿼리가 구성되는 걸 검증합니다. 술어가 구조적으로 맞으면서 의미가 틀릴 수 있고(enum과 컬럼 변환 같은), 그건 실제 DB 없이는 못 봅니다.
배치가 보존된 행을 실제로 선택하는지. 설계 전체가 이 전제 위에 서 있습니다. 그런데 조회 조건에는 제가 설계서 요약에서 빠뜨린 필터가 두 개 더 있었습니다. 개발망에서 실제 행으로 확인해야 합니다.
경보 마커가 로그에 도달하는지. 마지막 태스크의 유일한 산출물인데, 마커 문자열이 실제로 찍히는 걸 확인하는 테스트가 없습니다.
정리하면 — 일곱 번 다 같은 질문 하나로 걸렸다
계획서에 테스트를 미리 적어두는 건 나쁘지 않았습니다. 구현이 흔들리지 않았고, 리뷰가 무엇과 대조할지도 분명했습니다.
틀린 건 미리 적은 테스트를 검증된 것으로 취급한 것이었습니다. 계획서에 있다는 사실이 그 테스트가 무언가를 지킨다는 보장은 아닙니다. 오히려 코드보다 검증이 덜 된 채로 들어갑니다 — 코드는 리뷰라도 받지만, 테스트는 "초록이니 됐다"로 넘어가기 쉬우니까요.
일곱 사례 — 테스트 다섯, 잘못된 시나리오를 시뮬레이션한 테스트 하나, 그리고 로그 하나 — 를 늘어놓고 보니 전부 한 질문에 걸립니다.
이 신호가 빨개지는 걸 내가 본 적 있는가?
- 체인을 전부
any()로 스텁한 테스트 → 본 적 없음. 쿼리가 틀려도 초록이니까 - 대상이 참조하지 않는 mock 에 건 단언 → 본 적 없음. 구조상 빨개질 수 없으니까
- 조건을 못 넘는 값으로 세운 테스트 → 본 적 없음. 검증할 코드에 도달조차 못 했으니까
성공 N건로그 → 본 적 없음. 전부 거절돼도 같은 숫자를 찍으니까
초록을 확인하는 건 "지금 코드에서 통과한다" 를 확인하는 일입니다. "틀린 코드에서 실패한다" 는 완전히 별개의 사실이고, 확인하지 않으면 알 수 없습니다. 그리고 지키는 힘은 전부 후자에서 나옵니다.
그래서 지금은 네 가지를 기본값으로 씁니다.
- 새 신호는 반드시 한 번 빨갛게 만들어 본다. 무엇을 망가뜨렸고 무엇이 실패했는지를 같이 기록한다. 그 기록이 없으면 "확인했다"가 아니라 "그럴 것 같다"이다.
- 초록 신호는 테스트만이 아니다. 로그·지표·대시보드도 같은 방식으로 거짓말한다. 그리고 그쪽이 더 오래 산다 — 아무도 로그를 뮤테이션 테스트하지 않으니까. 숫자를 하나 만들 때마다 "이게 나쁜 날에 어떤 값이 되는가" 를 묻는다.
- 관측 지점이 관측 대상 안에 있는지 본다. 일곱 사례 전부 여기서 갈렸다. 예외 유무로 취소 성사를 말하고, 호출하지도 않는 협력 객체로 호출 안 함을 증명하려 했다. 무엇을 세고 있는지와 무엇을 말하고 싶은지가 같은 것인지만 확인해도 대부분 걸린다.
- 태스크 단위 리뷰로 끝내지 않는다. 조각들이 곱해져야 보이는 문제가 있다. 하루 144번 울릴 경보와 결과 테이블을 함께 날리는 롤백 경로 — 둘 다 태스크 리뷰를 전부 통과한 뒤 전체 리뷰에서야 보였다.
마지막으로 하나 더. 리뷰 결과에 파일 이름이 등장했다는 사실이 그 파일이 감사됐다는 뜻은 아닙니다. 원장을 조건 없이 덮어쓰던 결함이 다른 메서드에 그대로 남아 있었고, 최종 리뷰는 그 메서드를 이미 지목한 상태였습니다. 다만 다른 질문을 하고 있었을 뿐입니다.
리뷰는 자기가 던진 질문에만 답합니다. 결함을 하나 고쳤으면, 그 결함의 형태를 코드베이스 전체에서 다시 찾아야 합니다 — 앞 글의 마지막 문단에 그렇게 적어두고도 일주일 뒤 같은 자리에서 넘어졌습니다.
Related
- 안전망은 버그가 없었습니다. 이 거래를 못 봤을 뿐입니다 — 이 글의 앞편. 무엇이 왜 깨졌는지, 그리고 그 사건을 반대로 진단했던 과정
- 최종 결제 확정 API로 POS 상태 불일치 해소하기 — 같은 플랫폼의 상태 불일치 개선
- 동작한다는 것이 올바르다는 뜻은 아니다 — 각자 정상 동작하는 코드가 합쳐져 틀리는 경우
