leo.dev
backend

락 없이 안 겹치는 인보이스 채번

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

인보이스 번호 INV-2026-00001은 유일하고 연속이어야 한다. 결제 두 건이 동시에 발급되며 같은 번호를 받으면 회계가 깨진다. 흔한 첫 답은 SELECT MAX(seq)+1인데, 동시 요청 둘이 같은 MAX를 읽고 같은 +1을 쓰는 전형적인 시퀀스 race에 그대로 노출된다.

연차 차감에선 비관적 락으로 풀었다. 잔액을 잠그고 트랜잭션 안에서 재검증. 채번에도 그게 되지만, 여기엔 더 싼 길이 있다. 락도 트랜잭션 선언도 없이, 단일 SQL statement 하나로 끝낸다.

단일 statement가 곧 직렬점

연도별 시퀀스 행을 두고, 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)

같은 year 행에 대한 이 upsert는 행 단위로 원자적이다. 동시 요청이 12개 몰려도 MySQL이 같은 행의 쓰기를 직렬화하므로 last_seq는 정확히 1, 2, …, 12로 갈린다. 내가 SELECT FOR UPDATE로 명시적 락을 잡지 않아도, statement 자체가 직렬점이다. “읽고 → 결정하고 → 쓰는” 세 박자를 한 박자로 접었으니 그 사이를 파고들 틈이 없다.

증가시킨 값을 어떻게 돌려받나

문제는 방금 만든 번호를 회수하는 것이다. 별도로 SELECT last_seq를 또 하면 그새 다른 요청이 증가시켜 엉뚱한 값을 읽는다. 여기서 LAST_INSERT_ID(expr)의 트릭이 쓰인다.

LAST_INSERT_ID(expr)expr를 반환하면서 동시에 그 값을 현재 커넥션의 세션에 저장한다. 그래서 다음 줄에서 인자 없이 LAST_INSERT_ID()를 부르면 방금 그 값이 그대로 나온다.

await qr.query(
`INSERT INTO invoice_sequences (year, last_seq)
VALUES (?, LAST_INSERT_ID(1))
ON DUPLICATE KEY UPDATE last_seq = LAST_INSERT_ID(last_seq + 1)`,
[year],
);
const rows = await qr.query('SELECT LAST_INSERT_ID() AS seq'); // 방금 내가 쓴 값

증가와 회수가 같은 커넥션의 세션 변수를 통해 이어지니, 잠금 건 재조회가 필요 없다.

함정 — 반드시 같은 커넥션이어야 한다

LAST_INSERT_ID()가 커넥션 세션 변수라는 게 그대로 함정이 된다. TypeORM의 기본 쿼리는 풀에서 매번 커넥션을 빌려오는데, upsert와 회수 SELECT가 다른 커넥션으로 나가면 회수 쪽 세션엔 그 값이 없어 0이 돌아온다.

그래서 createQueryRunner로 커넥션 하나를 고정하고, upsert와 SELECT를 같은 러너에서 돌린다.

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();
}

그래서

락이 답일 때와 원자 카운터가 답일 때는 다르다. 무언가를 확인하고 차감하는 자리(잔액·재고)는 검증과 쓰기를 한 락 구간에 묶어야 하고, 순수하게 하나씩 증가시키는 자리(채번)는 단일 원자 statement가 더 싸고 정확하다. 어느 쪽이든 검증은 mock으로 안 된다. 12개를 실제로 동시에 던져 1..12가 빠짐없이 나오는지 실 DB 통합 테스트로 확인했을 때 비로소 “안 겹친다”가 증명된다.

↑↓ 이동 열기esc 닫기