leo.dev
backend

비관적 락이 못 막은 write skew

— SERIES 동시성 Part 01 / 03
  1. Part 01 비관적 락이 못 막은 write skew
  2. Part 02 락 없이 안 겹치는 인보이스 채번
  3. Part 03 브로커 없이 만든 트랜잭셔널 아웃박스

연차 잔여 1일인 직원에게 1일짜리 휴가 신청 2건을 동시에 보냈더니, 둘 다 승인됐다. 결과는 사용 2일 / 총 1일. 잔액이 음수다. 차감 코드에는 분명히 pessimistic_write 락이 걸려 있었다. 락을 걸었는데 왜 뚫렸나.

이 글은 연차 원장 글에서 “동시성은 원장이 아니라 락이 막는다”고 했던 그 락이, 처음엔 제대로 막지 못했던 이야기다.

락은 무엇을 직렬화하는가

락을 걸면 동시성이 해결된다는 건 절반만 맞다. 락이 막는 건 lost update. 두 트랜잭션이 같은 행을 동시에 읽고 각자 덮어써서 한쪽 갱신이 사라지는 것이다. usedDays += 1을 두 번 하면 락 덕에 1이 아니라 2가 된다. 증가 연산 자체는 직렬화된다.

그런데 내 문제는 lost update가 아니었다. 갱신은 둘 다 정확히 반영됐다. 0 → 1 → 2로 멀쩡히 더해졌다. 문제는 그 2가 총량 1을 넘었는데도 승인됐다는 것이다. 이건 다른 종류의 이상현상, write skew다.

write skew는 각 트랜잭션이 자기가 읽은 시점엔 맞는 결정을 내리지만, 둘이 합쳐지면 불변식(사용 ≤ 총량)이 깨지는 경우다. 락은 쓰기를 직렬화했지만, 정작 깨진 건 검증과 쓰기 사이의 원자성이었다.

check-then-act 간극

차감 로직은 두 조각으로 나뉘어 있었다.

  1. 사전 검증. 잔액을 조회해 remaining >= 신청일수인지 확인. 단, 락 없는 별도 조회였다.
  2. 차감. pessimistic_write 락으로 행을 잠그고 usedDays += days. 단, 여기서 잔여를 다시 보지 않았다.

동시 요청 둘이 이 간극을 정확히 파고든다.

요청 A ─ 검증(remaining=1, 락 밖) ─┐
요청 B ─ 검증(remaining=1, 락 밖) ─┤ 둘 다 "1일 남았네" 통과
요청 A ─ 락 획득 → used 0→1 → commit
요청 B ─ 락 대기 → used 1→2 → commit ← 총량 1 초과인데 승인

B는 거짓말을 한 게 아니다. B가 검증할 때 잔액은 진짜 1이었다. A가 아직 커밋 전이었으니까. 락은 B의 쓰기를 A 뒤로 줄 세웠지만, B의 검증은 이미 한참 전에 락 밖에서 끝나 있었다. 줄 세워야 했던 건 쓰기가 아니라 “검증→차감” 한 덩어리였다.

락 안에서 다시 검증한다

고치는 방법은 단순하다. 검증을 락 안으로 들여, 락으로 읽은 fresh 잔액 기준으로 한 번 더 본다.

leave-mutation.service.ts
const balance = await manager.findOne(EmployeeLeaveBalance, {
where: { employeeId, companyId, type, year },
lock: { mode: 'pessimistic_write' },
});
// 동시성 안전(over-approval 방지):
// 락으로 읽은 fresh 잔액 기준으로 잔여를 재검증한다.
const remaining = total.minus(used).minus(adjusted);
if (remaining.lessThan(days)) {
throw new BadRequestException('잔여 잔액이 부족합니다.');
}
balance.usedDays = used.plus(days);
await manager.save(balance);

이제 B는 락을 기다린 뒤 이미 1로 증가된 잔액을 읽는다. remaining = 0 < 1이라 정확히 거부된다. 락 밖의 사전 검증은 그대로 둔다. 그건 빠른 거부(UX)용이고, 진짜 가드는 락 안에서 다시 한다. 검증과 차감이 같은 락 구간에 들어오면서 “check-then-act”가 “check-and-act” 한 덩어리가 됐다.

이 버그는 mock 테스트로는 안 잡힌다

더 오래 남은 교훈은 따로 있다. 이 버그를 발견한 건 실제 MySQL을 띄운 동시성 통합 테스트(leave-concurrent-request.int-spec.ts)였다. 같은 직원에게 신청 2건을 동시에 던지고 승인 수사용일을 검증하니 승인=2, used=2, total=1로 빨갛게 떴다.

반면 그 전까지 돌던 mock 기반 유닛 테스트들은 전부 통과였다. 당연하다. 락도 트랜잭션 격리도 mock에는 존재하지 않는다. mock은 “내가 짠 로직이 내 머릿속 순서대로 도는가”를 검증할 뿐, “두 트랜잭션이 실제 DB에서 어떻게 교차하는가”는 검증하지 못한다. 동시성·격리수준·락은 실제 DB에서만 드러난다.

그래서 규칙을 하나 세웠다. 락·멱등성·동시성이 걸린 코드는 mock 유닛 테스트의 green을 “통과”로 인정하지 않는다. 실 DB 통합 테스트로 동시 요청을 실제로 부딪혀 봐야 한다.

그래서

pessimistic_write를 봤다고 안심하면 안 된다. 락을 읽을 때 던질 질문은 “락을 걸었나”가 아니라 **“무엇을 직렬화하고 있나”**다. 증가 연산인가, 아니면 검증→행동 전체인가. 잔액·재고·좌석처럼 “확인하고 차감하는” 모든 자리가 같은 함정을 판다. 검증을 락 밖에 두면, 락은 충실히 일하면서도 불변식을 지키지 못한다.

↑↓ 이동 열기esc 닫기