RSS듀오랩스
데이터베이스 설계

문서 번호 채번: 동시 등록과 카운터 테이블

작성자
듀오랩스 대표·8분 읽기

제조업 ERP 데모의 문서 번호 함수에는 처음부터 이런 주석이 달려 있었습니다.

// 그 연도의 가장 큰 번호를 읽어 하나 더한다. 동시에 둘이 만들면 유일키가 하나를 막는다.
// 데모라 그때는 다시 누르면 된다. 채번 표를 따로 두면 표가 하나 늘고, 그 값이 실제와 어긋나는 날이 온다.

문제를 모르고 고른 방식이 아니었습니다. 동시 등록이 겹치는 것을 알았고, 번호를 세는 표를 따로 두는 쪽의 단점까지 따져서 「마지막 번호 + 1」을 골랐습니다. 이번에 그 판단을 뒤집고 카운터 표를 만들었습니다.

마지막 번호에 1을 더하는 방식

번호 모양은 SH-2026-0012 처럼 접두, 연도, 네 자리입니다. 새 번호는 이렇게 만들었습니다.

const head = `${prefix}-${date.getUTCFullYear()}-`;
const last = await find({ where: { no: { startsWith: head } }, orderBy: { no: "desc" }, select: { no: true } });
const n = last ? Number(last.no.slice(head.length)) + 1 : 1;

표를 하나도 늘리지 않고, 번호는 늘 실제 문서에서 나옵니다. 주석이 말한 장점이 이것입니다. 카운터가 실제 문서와 어긋날 일이 원천적으로 없습니다.

동시에 등록한 둘이 같은 번호를 받는 순간

두 사람이 거의 같은 순간에 출하를 등록하면 순서가 이렇게 됩니다.

순서 요청 A 요청 B
1 마지막 번호 조회 → 0011
2 마지막 번호 조회 → 0011
3 0012 로 저장, 성공
4 0012 로 저장, 유일키 오류

조회와 저장 사이에 다른 요청이 끼어들 틈이 있어서 생기는 일입니다. 번호 칸에 유일 제약이 있으니 같은 번호가 두 번 저장되지는 않습니다. 대신 B 는 실패합니다.

제가 보기에 「다시 누르면 된다」가 버티지 못하는 곳은 실패의 모양입니다. 출하 등록은 번호 하나만 만드는 동작이 아닙니다. 재고 이동을 쓰고, 매출 전표를 만들고, 전표 번호도 같은 방식으로 받습니다. 이 앱은 이 단계들을 한 트랜잭션으로 묶지 않아서, 중간 단계의 번호에서 오류가 나면 앞 단계만 저장된 채로 멈출 수 있습니다. 사용자가 다시 누르면 그 앞 단계가 한 번 더 생깁니다.

카운터 표가 실제와 어긋난다는 걱정

그래도 카운터 표에 대한 원래 걱정은 맞는 말입니다. 문서 표와 카운터 표가 따로 있으면, 누군가 문서를 직접 넣거나 데이터를 옮기면서 카운터를 건드리지 않았을 때 카운터가 뒤처집니다. 뒤처진 카운터는 이미 있는 번호를 다시 내주고, 그때는 모든 등록이 유일키에 걸립니다.

저는 이 걱정을 두 가지로 나눠 봤습니다. 카운터가 앞서는 것은 번호에 빈칸이 생길 뿐이라 동작에 문제가 없습니다. 카운터가 뒤처지는 것만 위험합니다. 그리고 뒤처지는 경로는 앱 밖에서 데이터를 넣는 경우뿐이라, 그 경로를 한 곳으로 모으면 막을 수 있습니다. 반면 동시 등록은 앱이 정상적으로 쓰이기만 해도 생깁니다.

한 문장의 UPSERT 로 번호를 받는 방법

카운터 표는 법인, 접두, 연도마다 한 줄이고 마지막 번호를 가집니다.

model DocSeq {
  id        String  @id @default(cuid())
  companyId String
  company   Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
  prefix String
  year   Int
  last   Int @default(0)

  @@unique([companyId, prefix, year])
}

번호는 조회하고 더하는 두 단계가 아니라 SQL 한 문장으로 받습니다.

INSERT INTO "DocSeq" ("id", "companyId", "prefix", "year", "last")
VALUES (gen_random_uuid()::text, $1, $2, $3, 1)
ON CONFLICT ("companyId", "prefix", "year") DO UPDATE SET "last" = "DocSeq"."last" + 1
RETURNING "last"

그 연도의 첫 번호면 1 로 줄을 만들고, 줄이 있으면 1 을 더하고, 어느 쪽이든 결과 번호를 돌려줍니다. PostgreSQL 문서는 이 형태를 이렇게 설명합니다.

ON CONFLICT DO UPDATE guarantees an atomic INSERT or UPDATE outcome; provided there is no independent error, one of those two outcomes is guaranteed, even under high concurrency.

두 요청이 같은 줄을 동시에 올리려 하면 한쪽이 행 잠금을 기다렸다가 먼저 올린 값에 1 을 더합니다. 조회와 저장 사이의 틈이 없어졌습니다. 고친 뒤 동시에 등록한 4건이 모두 다른 번호를 받는 것을 확인했습니다.

기존 데이터에서는 카운터를 각 문서 표의 가장 큰 번호로 맞춰 두었습니다. 원래 걱정했던 「실제와 어긋나는 날」을 이 한 번의 맞춤으로 시작점에서 막은 셈입니다.

연도 칸을 카운터의 키에 넣은 이유

카운터의 유일 키에는 연도가 들어 있습니다. 번호가 해마다 1 부터 다시 시작하기 때문입니다. 연도를 빼고 접두만으로 세면 해가 바뀔 때 누군가 카운터를 0 으로 되돌려야 하고, 그 일은 1월 1일 새벽에 빠지기 쉽습니다. 연도를 키에 넣으면 새해 첫 등록이 새 줄을 만들면서 알아서 1 부터 시작합니다.

이 연도는 문서 날짜에서 옵니다. 서버 시계의 UTC 연도로 구하면 1월 1일 아침 9시 전에 등록한 문서가 작년 줄에서 번호를 받습니다. 같은 데모에서 「오늘」을 한국 달력으로 고친 작업이 이 함수에도 들어간 이유입니다.

빈 번호와 법인 조건이라는 남은 약속

이 방식으로 번호가 겹치지는 않지만, 빈 번호는 생깁니다. 번호를 받은 뒤 문서 저장이 실패하면 카운터는 이미 올라가 있습니다. 앱이 번호 받기와 문서 저장을 한 트랜잭션으로 묶지 않기 때문입니다. PostgreSQL 의 시퀀스도 같은 이유로 빈칸을 허용한다고 문서에 적혀 있습니다. 동시에 번호를 받는 다른 트랜잭션을 막지 않으려고, 중단된 트랜잭션이 받은 값을 돌려받지 않습니다.

업무 문서 번호에 빈칸이 허용되는지는 문서 종류마다 다르게 봐야 한다고 생각합니다. 출하나 입고 번호라면 문제 되지 않습니다. 빈칸 없는 연번이 꼭 필요한 문서라면 번호 받기와 저장을 한 트랜잭션에 넣어야 하고, 그 대가로 같은 카운터를 쓰는 등록들이 트랜잭션이 끝날 때까지 줄을 섭니다.

하나 더 있습니다. 이 함수는 원시 SQL 이라서, 이 데모가 모든 조회에 법인 조건을 자동으로 붙이는 장치를 거치지 않습니다. 그래서 법인 id 를 함수 안에서 손으로 넣습니다. 원시 SQL 을 쓰는 곳은 이런 약속을 코드 주석에 적어 두지 않으면 다음 사람이 모릅니다.

뒤집힌 판단에서 남긴 기준

처음의 판단이 틀렸다고 생각하지는 않습니다. 사용자가 한 명인 데모라면 「다시 누르면 된다」가 맞습니다. 다만 등록 한 번이 여러 문서를 만드는 구조에서는 실패의 비용이 재시도 한 번보다 큽니다.

그래서 제가 남긴 기준은 이렇습니다. 번호 실패가 사용자의 재시도로 끝나는가, 반쯤 저장된 데이터를 남기는가. 앞쪽이면 마지막 번호 + 1 로 충분합니다. 뒤쪽이면 카운터 표로 옮길 때입니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.