leo.dev
backend

실 DB에서만 드러나는 버그들

비관적 락이 못 막은 write skew를 발견한 건 코드 리뷰도, mock 유닛 테스트도 아니었다. 그 버그가 났을 때 mock 테스트는 전부 초록이었다. 당연하다. mock에는 락이 없다.

이 글은 그 사건 뒤에 세운 규칙에 대한 거다. 락·멱등성·트랜잭션·원자성이 걸린 코드는, mock 유닛 테스트의 green을 “통과”로 인정하지 않는다.

mock이 검증하지 못하는 것

mock 유닛 테스트는 “내가 짠 로직이 내 머릿속 순서대로 도는가”를 검증한다. 의존성을 가짜로 바꿔 끼우고, 입력에 대해 기대한 출력이 나오는지 본다. 빠르고, 격리돼 있고, 대부분의 비즈니스 로직에 충분하다.

그런데 가짜로 바꿔 끼우는 순간 사라지는 것들이 있다.

  • 락. SELECT ... FOR UPDATE가 두 번째 트랜잭션을 대기시키는 동작은 실제 DB 엔진의 일이다. mock repository는 그냥 즉시 답을 돌려준다.
  • 트랜잭션 격리. 두 트랜잭션이 같은 행을 어떤 순서로 보고 쓰는지는 격리수준이 정한다. mock에는 트랜잭션도 격리도 없다.
  • 원자성. “이 두 변경이 한 statement로 묶여 함께 커밋되는가”는 DB가 보장한다. mock은 메서드 호출 횟수만 센다.
  • DB 고유 동작. LAST_INSERT_ID 같은 세션 변수나 유니크 제약 충돌은 실제 엔진에서만 일어난다.

즉 동시성·격리·락은 두 트랜잭션이 실제 DB에서 어떻게 교차하는가의 문제고, 그건 mock으로는 재현 자체가 안 된다.

동시 요청을 실제로 부딪혀 본다

그래서 이런 코드엔 실 DB를 띄운 통합 테스트를 둔다. 도커로 MySQL을 올리고, 같은 자원에 동시 요청을 던져 결과를 단언한다. write skew는 이렇게 잡혔다.

leave-concurrent-request.int-spec.ts (요지)
// 잔여 1일인 직원에게 1일 신청 2건을 동시에
const results = await Promise.allSettled([
approve(requestA),
approve(requestB),
]);
const approved = results.filter((r) => r.status === 'fulfilled').length;
expect(approved).toBe(1); // 하나만 승인돼야 한다
expect(await usedDays(employee)).toBe(1); // 총량 초과 금지

mock에선 approved가 2여도 초록이었다. 실 DB에선 승인=2, used=2, total=1로 빨갛게 떴다. 같은 패턴으로 결제 멱등(같은 webhook 2번 → 행 1개), 인보이스 채번(12개 동시 → 1..12 유일), 아웃박스 적재(중복 이벤트 → 1건만)를 전부 실 DB로 못박았다.

전부 통합 테스트로 덮지는 않는다

통합 테스트는 비싸다. DB를 띄우고, 느리고, 격리해 관리해야 한다. 모든 코드를 이걸로 덮으면 테스트 스위트가 무너진다. 그래서 선을 그었다. 돈과 무결성이 직결된 임계점만 실 DB로 덮는다. 결제, 채번, 발송 멱등, 연차 승인, 초대 같은 자리다. 그 외 일반 로직은 mock 유닛 테스트로 충분하다.

판단 기준은 단순하다. “이 코드가 틀리면 돈이 새거나 데이터 정합성이 깨지는가, 그리고 그 틀림이 동시성·트랜잭션에서 오는가.” 둘 다 yes면 mock green을 믿지 않는다.

그래서

테스트의 목적은 초록불이 아니라 “이게 운영에서도 버틸까”에 답하는 거다. mock은 그 질문의 절반(내 로직이 맞나)만 답하고, 동시성·락·원자성이 걸린 절반은 실 DB에서만 답이 나온다. 그 절반을 mock으로 덮고 안심하면, 테스트는 통과하는데 운영에서 잔액이 음수가 된다. 빨간불이 떠야 할 곳에서 초록불이 뜨는 것, 그게 가장 위험한 테스트다.

↑↓ 이동 열기esc 닫기