같은 SaaS에서 동시성 사고를 두 군데서 만났다. 연차 차감은 pessimistic_write 락을 걸어뒀는데 뚫렸고, 인보이스 채번은 락을 한 줄도 선언하지 않았는데 안 겹쳤다.
처방이 정반대로 나뉜 이유는 직렬화해야 하는 단위가 달랐기 때문이다. 한쪽은 “확인하고 차감”이라는 두 단계를 통째로 묶어야 했고, 다른 쪽은 “하나 증가”라는 한 단계라 묶을 게 없었다.
락을 걸었는데 뚫렸다
연차 잔여 1일인 직원에게 1일짜리 휴가 신청 2건을 동시에 보냈더니 둘 다 승인됐다. 결과는 사용 2일 / 총 1일로 잔액이 음수다. 차감 코드에는 분명히 pessimistic_write 락이 걸려 있었다.
행 락이 막아주는 것은 딱 하나다. lost update, 즉 두 트랜잭션이 같은 행을 동시에 읽고 각자 덮어써서 한쪽 갱신이 사라지는 것이다. usedDays += 1을 두 번 하면 락 덕에 1이 아니라 2가 된다. 증가 연산 자체는 직렬화된다.
그런데 이 문제는 lost update가 아니었다. 갱신은 둘 다 정확히 반영됐고 0 → 1 → 2로 멀쩡히 더해졌다. 그 2가 총량 1을 넘었는데도 승인이 났다.
각 트랜잭션은 자기가 읽은 시점엔 맞는 결정을 내렸는데, 두 트랜잭션이 합쳐지자 불변식(사용 ≤ 총량)을 어겼다. 락은 쓰기를 직렬화했고 정작 어긋난 건 검증과 쓰기 사이였다.
write skew를 찾아봤는데 정확히 그건 아니었다
이 모양에 이름이 있나 싶어 뒤지다 write skew를 만났다. 1995년 Berenson 등이 쓴 A Critique of ANSI SQL Isolation Levels가 ANSI SQL 격리수준으로는 잡히지 않는 이상현상들을 정리했고, 그 논문이 A5B라는 기호로 묶은 것이 write skew다. 표준이 이걸 이상현상으로 이름조차 붙이지 않았다는 게 논문의 논점 중 하나다.
정의는 두 트랜잭션이 같은 데이터를 읽고 서로 다른 데이터를 쓸 때다. 그래서 행 단위 락으로는 막히지 않고 SERIALIZABLE이나 충돌 물리화가 필요하다. 당직 의사 두 명이 각자 “나 말고 한 명 더 있네” 확인하고 동시에 퇴근하는 예가 그것인데, 각자 자기 행만 쓴다.
이 사건은 여기 정확히 들어맞지 않는다. 이 사건의 두 트랜잭션(이하 A·B)이 쓴 건 같은 행의 같은 컬럼(balance.usedDays)이라 서로 다른 데이터를 쓰지 않았다. 그래서 행 락으로 고쳐진 것이고, 실제로 격리수준도 락 범위도 안 건드렸다.
겹치는 건 형태다. 먼저 한 검사의 전제를 나중의 쓰기가 무효화한다. 그 모양이 같아서 문헌이 도움이 됐지만 처방은 나뉜다.
| 쓰는 대상 | 처방 | |
|---|---|---|
| A5B write skew | 서로 다른 행 | SERIALIZABLE · 충돌 물리화 |
| 이 사건 | 같은 행 | 검증을 락 안으로 |
같은 행이라 처방이 훨씬 간단했다. write skew라고 단정했으면 격리수준을 올리거나 더미 행을 만드는 쪽을 봤을 것이다. 실제로 필요했던 건 SELECT 한 줄을 락 뒤로 옮기는 일이었다.
check-then-act 간극
차감 로직은 두 조각으로 나뉘어 있었다. 먼저 잔액을 조회해 remaining >= 신청일수인지 확인하는데 이게 락 없는 별도 조회였고, 그 다음 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 잔액 기준으로 한 번 더 본다.
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로 정확히 거부된다. 락 밖의 사전 검증은 그대로 뒀는데 그건 빠른 거부용이고, 진짜 가드는 락 안에서 다시 한다. 검증과 차감이 같은 락 구간에 들어오면서 “check-then-act”가 “check-and-act” 한 덩어리가 됐다.
채번을 SQL 한 문장으로 접기
인보이스 번호 INV-2026-00001은 유일하고 연속이어야 한다. 결제 두 건이 동시에 발급되며 같은 번호를 받으면 회계가 안 맞는다. 흔한 첫 답은 SELECT MAX(seq)+1인데, 동시 요청 두 개가 같은 MAX를 읽고 같은 +1을 쓰는 전형적인 시퀀스 race에 그대로 노출된다.
연차처럼 비관적 락으로 풀 수도 있었지만, 여기엔 애플리케이션이 락을 잡지 않고 끝내는 방법이 있었다. 연도별 시퀀스 행을 두고 upsert 한 방으로 증가시킨다.
INSERT INTO invoice_sequences (year, last_seq)VALUES (?, LAST_INSERT_ID(1))ON DUPLICATE KEY UPDATE last_seq = LAST_INSERT_ID(last_seq + 1)“읽고 → 결정하고 → 쓰는” 세 단계가 SQL 한 문장이 됐으니 다른 트랜잭션이 그 사이에 끼어들 수 없다. 동시 요청이 12개 몰려도 last_seq는 정확히 1, 2, …, 12로 나온다.
InnoDB가 그 문장에 거는 락
처음엔 이걸 “락 없이 푼 것”으로 이해했는데 정확하지 않다. MySQL 문서에 이렇게 적혀 있다.
INSERT ... ON DUPLICATE KEY UPDATEdiffers from a simpleINSERTin that an exclusive lock rather than a shared lock is placed on the row to be updated when a duplicate-key error occurs. An exclusive index-record lock is taken for a duplicate primary key value. An exclusive next-key lock is taken for a duplicate unique key value.— Locks Set by Different SQL Statements in InnoDB, MySQL 8.0 Reference Manual
락은 있다. InnoDB가 statement를 실행하면서 그 행에 배타 락을 잡고 문장이 끝나면 놓는다. 내가 안 잡을 뿐이다.
그래서 진짜로 사라진 건 락이 아니라 애플리케이션이 끼어드는 구간이다. 명시적 락은 SELECT ... FOR UPDATE로 잡고 트랜잭션이 끝날 때까지 들고 있는데, 그동안 애플리케이션 코드가 값을 읽고 판단하고 쓴다. 사고는 그 구간에서 난다. 단일 statement는 판단이 SQL 안에 들어가 있어 애플리케이션이 낄 틈이 아예 없다.
증가시킨 값을 어떻게 돌려받나
여기서 걸리는 건 방금 만든 번호를 회수하는 일이다. 별도로 SELECT last_seq를 또 하면 그새 다른 요청이 증가시켜 엉뚱한 값을 읽는다. 그래서 LAST_INSERT_ID(expr)를 쓴다.
이 함수는 expr를 반환하면서 동시에 그 값을 현재 커넥션의 세션에 저장한다. 다음 줄에서 인자 없이 LAST_INSERT_ID()를 부르면 방금 그 값이 나온다. MySQL 문서가 규정하는 그대로인데, 문장 안에 LAST_INSERT_ID(expr)가 있었다면 그 expr를 돌려주고 그 값은 현재 클라이언트 커넥션에서 실행된 문장에만 영향을 받는다.
동시 채번에서 필요한 성질이 정확히 이것이다. 12개 요청이 같은 행을 두고 다투는 와중에도 내가 받아갈 값은 내 커넥션 안에 격리돼 있다.
함정 — 커넥션 고정
세션 변수라는 성질이 그대로 함정이 된다. TypeORM의 기본 쿼리는 풀에서 매번 커넥션을 빌려오는데, upsert와 회수 SELECT가 다른 커넥션으로 나가면 회수 쪽 세션엔 그 값이 없어 0이 돌아온다.
const qr = this.dataSource.createQueryRunner();await qr.connect();try { await qr.query(/* upsert */); const rows = await qr.query('SELECT LAST_INSERT_ID() AS seq'); return `INV-${year}-${String(Number(rows[0].seq)).padStart(5, '0')}`;} finally { await qr.release();}커넥션을 고정해야 증가와 회수가 같은 세션에서 이어진다. 나중에 결재 문서 채번에도 같은 패턴을 그대로 썼는데, 이 패턴은 재사용 단위가 함수가 아니라 SQL 한 문장이다. 시퀀스 테이블 이름과 접두사 포맷이 달라 공통 유틸로 뽑으면 인자만 늘어나서, 문장 세 줄을 복사하고 마이그레이션 주석으로 두 곳을 이어놨다.
두 처방을 나누는 기준
| 직렬화해야 하는 것 | 처방 | |
|---|---|---|
| 잔액 차감 | 검증 → 차감 두 단계 | 명시적 락으로 둘을 한 구간에 |
| 채번 | 증가 한 단계 | 단일 statement. 엔진 락이 알아서 |
기준은 “동시성이 걸렸나”가 아니라 “애플리케이션이 값을 보고 판단하는 구간이 있나”다. 있으면 그 구간 전체를 락이 감싸야 하고, 없으면 SQL 한 문장으로 접을 수 있다.
잔액 차감도 단일 statement로 접을 수는 있다. UPDATE ... SET used = used + ? WHERE total - used >= ? 같은 형태로 조건을 SQL에 넣으면 판단이 엔진 안으로 들어간다. 그렇게 안 한 건 거절 사유를 사용자에게 구체적으로 돌려줘야 해서다. 영향 행 수가 0이라는 사실만으로는 “잔여 부족”인지 “행이 없음”인지 구분이 안 된다.
mock으로 바꿔 끼우면 사라지는 결함
이 버그를 발견한 건 코드 리뷰도 mock 유닛 테스트도 아니었다. 그 버그가 났을 때 mock 테스트는 전부 초록이었다.
가짜로 바꿔 끼우는 순간 사라지는 것들이 있다.
- 락.
SELECT ... FOR UPDATE가 두 번째 트랜잭션을 대기시키는 건 DB 엔진의 일이다. mock repository는 즉시 답을 돌려준다. - 트랜잭션 격리. 두 트랜잭션이 같은 행을 어떤 순서로 보고 쓰는지는 격리수준이 정한다.
- 원자성. “이 두 변경이 한 statement로 묶여 함께 커밋되는가”는 DB가 보장하는데, mock은 메서드 호출 횟수만 센다.
- DB 고유 동작.
LAST_INSERT_ID같은 커넥션 세션 변수나 유니크 제약 충돌은 실제 엔진에서만 일어난다.
그래서 이런 코드엔 실 DB를 띄운 통합 테스트를 둔다. 도커로 MySQL을 올리고 같은 자원에 동시 요청을 던져 결과를 단언한다. Testcontainers가 내세우는 게 정확히 이거다. 목이나 인메모리 대체물이 아니라 운영에서 쓰는 것과 같은 서비스에 대고 테스트를 짠다.
// 잔여 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로 빨갛게 떴다. 채번도 같은 방식으로 못박았는데, 12개를 실제로 동시에 던져 1..12가 빠짐없이 나오는지 확인했을 때 비로소 “안 겹친다”가 증명된다.
통합 테스트를 붙이는 기준이 바뀌었다
처음엔 실 DB 통합 테스트를 붙일 기준을 이렇게 적었다. “이 코드가 틀리면 돈이 새거나 데이터 정합성이 어긋나는가, 그리고 그 틀림이 동시성·트랜잭션에서 오는가.”
그때 다섯 개였던 통합 테스트가 이 글을 쓰는 시점에 21개다. 늘어난 걸 훑어보니 동시성·트랜잭션이라는 조건을 만족하지 않는 것이 여럿이었다.
| 테스트 | 무엇을 단언하나 | 동시성인가 |
|---|---|---|
| 타임스탬프 스큐 | created_at과 updated_at이 같은 행에서 9시간 어긋나던 것 | 아니다 |
| 마이그레이션 백필 | 실 MySQL에서 돌려 백필 결과와 재실행 안전성 | 아니다 |
| 아웃박스 payload 소각 | DB에 실제로 무엇이 남는가 | 아니다 |
| 알림 배치 쿼리 수 | 쿼리가 행 수에 비례하지 않는가 | 아니다 |
| 테넌트 격리 | 남의 회사 데이터가 새는가 | 아니다 |
기준을 어긴 게 아니라 기준이 틀렸던 것이다. 소각 테스트에 달아둔 주석에 실제 판별식이 정확히 적혀 있다.
단위 테스트는 “소각 함수가 값을 지우는가”만 본다. 여기서 확인하는 것은 DB에 실제로 무엇이 남는가다.
기준은 중요도가 아니라 재현 가능성이었다. 물어보려는 걸 mock이 구조적으로 흉내낼 수 없으면 실 DB로 간다. 돈이 걸렸는지는 우선순위를 정할 때나 쓰는 기준이지, 통합 테스트가 필요한지를 정하는 기준이 아니었다. 처음 다섯 개가 전부 동시성이었던 건 동시성이 특별해서가 아니라, mock으로 못 하는 것 중 제일 먼저 아프게 터진 항목이었기 때문이다.
두 경로가 지금 받는 검증
차감 경로는 잔액 조회가 락 뒤에 있고 그 값으로 다시 검증한 뒤에만 usedDays를 올린다. 잔여 1일에 1일짜리 신청 2건을 동시에 던지면 하나는 승인, 하나는 400이다. 채번은 statement 하나로 끝나고 커넥션을 고정해 값을 회수한다.
실 DB 통합 테스트는 그 시점에 21개였고, 새로 짤 때 던지는 질문은 하나로 정해졌다. 이걸 mock으로 물어볼 수 있나. 없으면 컨테이너를 띄운다.
이 21개는 한동안 CI 게이트 밖에 있었다. 파일명이 *.int-spec.ts라 기본 jest 정규식(.*\.spec\.ts$)에 안 걸리고 별도 스텝도 없어서, “락을 빼면 테스트가 빨개진다”는 확인이 그때 한 번 이뤄지고 끝났다. 지금은 전용 잡이 배포마다 그 전부를 돌린다. 락을 지우거나 격리 수준을 낮추는 커밋은 배포가 시작되기 전에 걸린다.