2025년 10월 08일·7분 읽기

중복과 공백을 피하는 동시성 안전 청구서 번호 지정

여러 사용자가 동시에 청구서나 티켓을 생성해도 중복이나 예기치 않은 공백 없이 동작하는 실용적인 패턴을 알아보세요.

중복과 공백을 피하는 동시성 안전 청구서 번호 지정

두 사람이 동시에 레코드를 생성하면 어떤 문제가 발생할까

바쁜 사무실에서 오후 4시 55분을 상상해보세요. 두 사람이 거의 같은 순간에 청구서를 마치고 저장을 누릅니다. 두 화면 모두 잠깐 "청구서 #1042"를 보여줍니다. 한 레코드는 저장되고, 다른 하나는 실패하거나, 더 나쁘게는 둘 다 같은 번호로 저장될 수 있습니다. 이는 실제 환경에서 가장 흔히 보이는 증상입니다: 부하가 걸릴 때만 나타나는 중복 번호.

티켓도 마찬가지입니다. 두 상담사가 동시에 같은 고객의 새 티켓을 생성하고 시스템이 가장 최신 레코드를 보고 "다음 번호"를 선택하려 하면, 둘 다 같은 최신 값을 읽고 같은 다음 번호를 선택할 수 있습니다.

두 번째 증상은 더 미묘합니다: 번호가 건너뛰어 보이는 경우입니다. 예를 들어 #1042 다음에 #1044가 있고 #1043이 빠져 있는 식입니다. 이는 오류나 재시도 후에 종종 발생합니다. 한 요청이 번호를 예약했지만 저장이 검증 오류, 타임아웃, 또는 사용자가 탭을 닫아서 실패할 수 있습니다. 또는 백그라운드 작업이 네트워크 문제로 재시도하면서 첫 번째 시도가 이미 번호를 소비했음에도 새 번호를 가져갈 수 있습니다.

청구서의 경우 번호 지정은 감사 추적의 일부라 중요합니다. 회계담당자는 각 청구서가 고유하게 식별되길 기대하고, 고객은 결제나 문의에서 청구서 번호를 참조할 수 있습니다. 티켓에서는 번호가 대화, 보고서, 내보내기 등에서 모든 사람이 사용하는 식별자입니다. 중복은 혼란을 만들고, 누락된 번호는 부정행위가 없어도 검토 시 의문을 불러일으킬 수 있습니다.

초기에 분명히 해둘 핵심 기대치는 다음과 같습니다: 모든 번호 지정 방법이 동시성 안전성과 공백 없음(gapless)을 동시에 만족시킬 수는 없습니다. 동시성 안전한 번호 지정(많은 사용자가 있어도 중복이 발생하지 않음)은 달성 가능하며 필수적입니다. 공백 없는 번호도 가능하지만 추가 규칙이 필요하고 초안, 실패, 취소를 다루는 방식을 바꾸는 경우가 많습니다.

문제를 정의하는 좋은 방법은 번호가 어떤 보장을 해야 하는지 묻는 것입니다:

  • 절대 반복되면 안 된다(항상 고유)
  • 대부분 증가하면 좋다(있으면 좋음)
  • 절대 건너뛰면 안 된다(설계할 경우에만)

룰을 정하면 기술적 솔루션 선택이 훨씬 쉬워집니다.

중복과 공백이 발생하는 이유

대부분의 앱은 간단한 패턴을 따릅니다: 사용자가 저장을 누르면 앱이 다음 청구서나 티켓 번호를 요청하고, 그 번호로 새 레코드를 삽입합니다. 한 사람만 작업할 때는 완벽하게 작동하므로 안전해 보입니다.

문제는 두 개의 저장이 거의 동시에 발생할 때 시작됩니다. 두 요청이 어느 하나도 삽입을 마치기 전에 "다음 번호 가져오기" 단계에 도달할 수 있습니다. 둘 다 같은 "다음" 값을 보면 같은 번호를 쓰려고 합니다. 이것이 경쟁 상태(race condition)입니다: 결과가 로직이 아니라 타이밍에 달려 있습니다.

전형적인 타임라인은 다음과 같습니다:

  • 요청 A가 다음 번호를 읽음: 1042
  • 요청 B가 다음 번호를 읽음: 1042
  • 요청 A가 청구서 1042 삽입
  • 요청 B가 청구서 1042 삽입(또는 고유 제약이 막아 실패)

데이터베이스에서 두 번째 삽입을 막는 장치가 없으면 중복이 발생합니다. 애플리케이션 코드에서 "이 번호가 사용됐나?"를 검사만 해도, 검사와 삽입 사이의 경쟁에서 질 수 있습니다.

공백은 다른 문제입니다. 시스템이 번호를 "예약"했지만 레코드가 실제로 커밋되지 않을 때 발생합니다. 일반적인 원인은 결제 실패, 나중에 발견된 검증 오류, 타임아웃, 또는 사용자가 번호가 할당된 뒤 탭을 닫는 경우입니다. 삽입이 실패해서 아무 것도 저장되지 않아도 그 번호는 이미 소비됐을 수 있습니다.

보이지 않는 동시성은 이를 더 악화시킵니다. 문제는 단순히 "두 사람이 저장 버튼을 누름"이 아닙니다. 또한 다음과 같은 일이 있을 수 있습니다:

  • 병렬로 레코드를 생성하는 API 클라이언트
  • 배치로 실행되는 임포트
  • 야간에 청구서를 생성하는 백그라운드 작업
  • 연결이 불안정한 모바일 앱의 재시도

따라서 근본 원인은: (1) 여러 요청이 같은 카운터 값을 읽을 때의 타이밍 충돌, 그리고 (2) 트랜잭션이 성공할지 확실하기 전에 번호를 할당하는 것입니다. 동시성 안전한 번호 지정 계획은 어떤 결과를 허용할지(중복 없음, 공백 없음, 또는 둘 다) 그리고 어떤 이벤트(초안, 재시도, 취소)에 대해 허용할지 결정해야 합니다.

솔루션을 고르기 전에 번호 규칙을 정하세요

동시성 안전 번호 지정을 설계하기 전에, 번호가 비즈니스적으로 어떤 의미인지 한 문장으로 적어두세요. 가장 흔한 실수는 기술적 방법을 먼저 선택하고 나중에 회계나 법적 규칙이 다른 기대를 가지고 있음을 발견하는 것입니다.

보통 혼동되는 두 목표를 분리해서 시작하세요:

  • 고유(Unique): 두 청구서나 티켓이 동일한 번호를 절대 공유하지 않음
  • 공백 없음(Gapless): 고유하고 또한 엄격히 연속적(누락 없음)

많은 시스템은 고유성만을 목표로 하고 공백을 허용합니다. 공백은 정상적인 이유로 발생할 수 있습니다: 사용자가 초안을 열고 버림, 결제 실패로 예약된 번호 소모, 또는 레코드 생성 후 무효 처리 등. 헬프데스크 티켓의 경우 공백은 보통 문제되지 않습니다. 청구서의 경우도 감사 추적으로 설명할 수 있다면 공백을 허용하는 팀이 많습니다. 공백 없는 번호를 원하면 추가 규칙이 필요하고 종종 마찰이 생깁니다.

다음으로 카운터의 범위를 결정하세요. 작은 문구 차이가 설계를 많이 바꿉니다:

  • 모든 것에 대해 하나의 글로벌 시퀀스인가, 아니면 회사/테넌트별로 분리할 것인가?
  • 매년 리셋할 것인가(예: 2026-000123), 아니면 절대 리셋하지 않을 것인가?
  • 청구서 vs 신용전표 vs 티켓 등 문서 종류별로 다른 시리즈가 필요한가?
  • 사람이 읽기 쉬운 형식(접두사, 구분자)이 필요한가, 아니면 내부용 숫자만으로 충분한가?

구체적 예: 다수 고객 회사를 가진 SaaS 제품은 회사별로 고유하고 연도별로 리셋되는 청구서 번호를 요구할 수 있으며, 티켓은 전역에서 고유하고 절대 리셋하지 않을 수 있습니다. UI는 비슷해 보여도 서로 다른 규칙을 가진 두 카운터가 필요합니다.

정말 공백 없음을 원한다면 번호가 할당된 뒤 어떤 이벤트를 허용할지 명확히 하세요. 예를 들어 청구서를 삭제할 수 있는가, 아니면 취소만 가능한가? 사용자가 초안을 저장할 때 번호를 부여할 수 있는가, 아니면 최종 승인 시에만 번호를 부여하는가? 이러한 선택이 데이터베이스 기술보다 더 중요할 때가 많습니다.

구현 전에 간단한 규격을 한 줄로 적어두세요:

  • 어떤 레코드 유형이 시퀀스를 사용하는가?
  • 번호가 '사용됨'으로 간주되는 시점은 언제인가(초안, 발행, 결제 등)?
  • 범위는 어떻게 되는가(글로벌, 회사별, 연도별, 시리즈별)?
  • 무효 처리와 수정은 어떻게 다루는가?

AppMaster처럼 노코드 도구에서는 이런 규칙을 데이터 모델과 비즈니스 프로세스 옆에 두어 팀 전체가(웹 UI, API, 모바일) 동일한 동작을 구현하도록 하세요.

흔한 접근법과 각 방식의 보장 사항

사람들이 "청구서 번호 지정"을 말할 때 보통 두 가지 목표를 뒤섞습니다: (1) 같은 번호를 두 번 생성하지 않기, (2) 번호가 건너뛰지 않기. 대부분의 시스템은 첫 번째는 쉽게 보장할 수 있고 두 번째는 훨씬 어렵습니다. 트랜잭션 실패, 초안 포기, 레코드 무효 처리 등이 언제든지 공백을 만들 수 있기 때문입니다.

접근법 1: 데이터베이스 시퀀스 (빠른 고유성)

PostgreSQL 시퀀스는 부하가 걸릴 때도 고유하고 증가하는 값을 제공하는 가장 단순한 방법입니다. 데이터베이스는 많은 동시 생성 요청에 대해 시퀀스 값을 빠르게 발급하도록 설계되어 있습니다.

얻는 것: 고유성 및 대부분 증가하는 순서성. 얻지 못하는 것: 공백 없음. 삽입이 실패한 후 시퀀스 값은 소모되므로 공백이 생깁니다.

접근법 2: 고유 제약 + 재시도 (데이터베이스에 맡기기)

앱 로직에서 후보 번호를 생성하고 저장한 뒤, UNIQUE 제약으로 중복을 거부하면 충돌 시 재시도하는 방식입니다.

작동은 하지만, 높은 동시성에서는 소음이 커질 수 있습니다. 재시도가 많아지고 실패 트랜잭션이 늘어나며 디버깅이 어려워질 수 있습니다. 또한 엄격한 예약 규칙과 결합하지 않으면 공백을 보장하지 못합니다.

접근법 3: 잠금 있는 카운터 행(공백 목표)

정말 공백 없는 번호가 필요하면 전용 카운터 테이블(범위별로 한 행)을 사용해 그 행을 트랜잭션 내에서 잠그고 증가시키는 패턴을 사용합니다.

이는 일반적인 데이터베이스 설계에서 공백에 가장 근접한 방법이지만 비용이 있습니다: 모든 작성자가 대기해야 하는 단일 "핫스팟"을 만들고, 긴 트랜잭션, 타임아웃, 데드락에 취약해집니다.

접근법 4: 별도의 예약 서비스(특수한 경우에만)

독립적인 "번호 발급 서비스"를 두면 여러 앱이나 데이터베이스에 걸친 규칙을 중앙화할 수 있습니다. 여러 시스템에서 번호를 발급해야 하거나 쓰기를 통합할 수 없을 때만 대개 가치가 있습니다.

대가로 운영 리스크가 늘어납니다: 추가 서비스가 정확하고 고가용성, 일관성을 유지해야 합니다.

요약해서 생각하기 좋은 방식은 다음과 같습니다:

  • 시퀀스: 고유, 빠름, 공백 허용
  • 고유 + 재시도: 고유, 낮은 부하에서 단순, 높은 부하에서 문제 발생 가능
  • 잠금 카운터 행: 공백 가능, 고동시성에서 느림
  • 별도 서비스: 시스템 간 유연성, 복잡도와 실패 모드 증가

AppMaster 같은 노코드 도구에서도 선택지는 동일합니다: 최종 보장은 데이터베이스에 있어야 합니다. 앱 로직은 재시도와 명확한 오류 메시지를 돕지만, 최종 보증은 제약과 트랜잭션에서 나와야 합니다.

단계별: 시퀀스와 고유 제약으로 중복 방지하기

팀에 맞는 배포
완성된 앱을 AppMaster Cloud나 자체 AWS, Azure, GCP에 배포하세요.
AppMaster 사용해보기

주 목표가 중복 방지(공백 보장은 아님)라면 가장 단순하면서 신뢰할 수 있는 패턴은: 데이터베이스가 내부 ID를 생성하게 하고, 사용자에게 보이는 번호는 별도 열에 고유 제약을 두는 것입니다.

두 개념을 분리하세요. 조인, 수정, 내보내기에는 데이터베이스 생성 내부 값(identity/sequence)을 기본 키로 사용하고, invoice_no나 ticket_no는 사람에게 보여주는 별도 열로 둡니다.

PostgreSQL에서의 실용적 설정

다음은 동시성을 데이터베이스 안에 두는 일반적인 PostgreSQL 접근입니다. 코드 블록은 변경하지 마세요.

-- Internal, never-shown primary key
create table invoices (
  id bigint generated always as identity primary key,
  invoice_no text not null,
  created_at timestamptz not null default now()
);

-- Business-facing uniqueness guarantee
create unique index invoices_invoice_no_uniq on invoices (invoice_no);

-- Sequence for the visible number
create sequence invoice_no_seq;

이제 표시 번호는 INSERT 시점에 생성하세요(절대로 select max(invoice_no) + 1처럼 하지 마세요). 한 가지 간단한 패턴은 INSERT 안에서 시퀀스 값을 포맷하는 것입니다:

insert into invoices (invoice_no)
values (
  'INV-' || lpad(nextval('invoice_no_seq')::text, 8, '0')
)
returning id, invoice_no;

50명의 사용자가 동시에 "청구서 생성"을 클릭해도 각 INSERT는 다른 시퀀스 값을 받고, 고유 인덱스가 우연한 중복을 막아줍니다.

충돌이 발생하면 어떻게 할까

일반 시퀀스에서는 충돌이 드뭅니다. 연도별 리셋, 테넌트별, 사용자 편집 가능한 번호 등 추가 규칙을 도입하면 충돌이 생길 수 있으므로 고유 제약은 여전히 중요합니다.

애플리케이션에서는 고유 제약 위반 시 작은 재시도 루프를 구현하세요. 단순하고 제한적으로 유지합니다:

  • 삽입 시도
  • invoice_no에 대한 고유 제약 위반 오류가 나면 재시도
  • 시도 횟수 제한 후 명확한 오류 표시

이 방법은 재시도가 비정상적 상황(예: 포맷 충돌)에서만 발생하므로 잘 동작합니다.

경쟁 창(race window)을 줄이세요

번호를 UI에서 계산하지 말고, 읽고 나서 삽입하는 방식으로 예약하지 마세요. 삽입과 가능한 한 가깝게 번호를 생성하세요.

AppMaster와 PostgreSQL을 함께 사용한다면, Data Designer에서 id를 identity PK로 모델링하고 invoice_no에 고유 제약을 추가한 뒤 생성 흐름에서 invoice_no를 생성하여 삽입과 함께 실행되게 하세요. 이렇게 하면 PostgreSQL이 진실의 근원이 되고 동시성 문제는 PostgreSQL이 가장 잘 처리하도록 두게 됩니다.

단계별: 행 잠금으로 공백 없는 카운터 만들기

번호 정책에 맞추기
테넌트별, 연도별, 시리즈별 번호 규칙을 데이터 모델에 바로 반영하세요.
앱 생성

정말로 공백 없는 번호(번호 누락 없음)가 필요하면 트랜잭션 카운터 테이블과 행 잠금을 사용하세요. 아이디어는 단순합니다: 주어진 범위에서 다음 번호를 가져오는 동안 한 트랜잭션만 허용하면 번호는 순서대로 배부됩니다.

먼저 범위를 결정하세요. 많은 팀이 회사별, 연도별, 또는 시리즈별로 분리된 시퀀스를 필요로 합니다. 카운터 테이블은 각 범위의 마지막 사용 번호를 저장합니다.

PostgreSQL 행 잠금을 사용하는 실용적 패턴은 다음과 같습니다:

  1. number_counters 같은 테이블을 만들고 company_id, year, series, last_number 같은 열과 (company_id, year, series)에 대한 고유 키를 둡니다.
  2. 데이터베이스 트랜잭션을 시작합니다.
  3. SELECT last_number FROM number_counters WHERE ... FOR UPDATE로 해당 범위의 카운터 행을 잠급니다.
  4. next_number = last_number + 1을 계산하고 카운터 행을 last_number = next_number로 업데이트합니다.
  5. next_number를 사용해 인보이스나 티켓 행을 삽입한 뒤 커밋합니다.

핵심은 FOR UPDATE입니다. 부하가 걸리면 중복이 발생하지 않습니다. 또한 두 사용자가 같은 번호를 얻는 일로 인한 공백도 발생하지 않습니다. 두 번째 트랜잭션은 첫 번째가 커밋하거나 롤백할 때까지 해당 카운터 행을 읽고 증가시키지 못하고 잠시 기다립니다. 그 기다림이 공백 없음의 대가입니다.

새로운 범위 초기화

새 회사나 새 연도, 새 시리즈가 생길 때 초기화 계획이 필요합니다. 흔한 두 옵션은:

  • 미리 카운터 행을 생성해두기(예: 12월에 다음 연도 행 생성)
  • 온디맨드로 생성: last_number = 0으로 카운터 행 삽입을 시도하고 이미 존재하면 정상적인 잠금-증가 흐름으로 대체

노코드 도구에서 만들면 "잠금, 증가, 삽입" 순서를 하나의 트랜잭션 안에 두어 모두 실행되거나 전혀 실행되지 않게 하세요.

예외 상황: 초안, 실패한 저장, 취소, 수정

대부분의 번호 문제는 지저분한 부분에서 나타납니다: 게시되지 않은 초안, 저장 실패, 청구서 무효 처리, 누군가 번호를 본 뒤 행해진 수정 등입니다. 동시성 안전한 번호를 원한다면 번호가 언제 "실제"가 되는지에 대한 명확한 규칙이 필요합니다.

가장 큰 결정은 타이밍입니다. 사용자가 "새 청구서"를 클릭하는 순간 번호를 할당하면 버려진 초안 때문에 공백이 생깁니다. 청구서를 최종화(post/issue)할 때만 할당하면 번호가 더 촘촘해지고 설명하기 쉬워집니다.

실패한 저장과 롤백은 데이터베이스 동작과 기대치가 충돌하는 곳입니다. 일반 시퀀스에서는 한 번 번호가 소비되면 트랜잭션이 나중에 실패하더라도 그 번호는 소모됩니다. 이것은 정상적이고 안전하지만 공백을 만듭니다. 공백 없는 정책을 원하면 번호는 최종 단계에서만, 그리고 트랜잭션이 커밋될 때만 할당해야 합니다. 보통 단일 카운터 행을 잠그고 최종 번호를 쓰고 커밋하는 방식이 필요합니다. 어떤 단계가 실패하면 아무 것도 할당되지 않습니다.

취소와 무효 처리는 번호를 재사용하지 않는 것이 거의 불변의 원칙입니다. 히스토리는 일관성을 유지해야 하므로 번호는 남겨두고 상태와 이유를 기록하세요.

수정은 더 간단합니다: 번호가 외부에 노출된 뒤에는 영구적이라고 취급하세요. 공유되거나 내보내지거나 인쇄된 번호를 다시 매기지 마세요. 수정이 필요하면 새 문서를 만들고 이전 것을 참조(예: 신용 전표 또는 교체 문서)하세요.

많은 팀이 채택하는 실용적 규칙:

  • 초안에는 최종 번호 없음(내부 ID 또는 "DRAFT" 사용)
  • "게시/발행" 시에만 번호를 할당, 상태 변경과 같은 트랜잭션 안에서 수행
  • 무효 처리/취소는 번호를 유지하되 명확한 상태와 이유를 부여
  • 인쇄/이메일로 전달된 번호는 변경하지 않음
  • 임포트는 원본 번호 보존 후 카운터를 최대값 이후로 설정

마이그레이션과 임포트는 주의가 필요합니다. 다른 시스템에서 옮긴다면 기존 번호를 그대로 들여오고 카운터는 최대 가져온 값 이후부터 시작하세요. 여러 포맷(연도별 접두사 등)이 혼재하면 표시 번호(display number)는 원본 그대로 보관하고 별도의 내부 PK를 유지하는 편이 낫습니다.

예: 헬프데스크는 티켓을 빠르게 생성하지만 초안이 많은 경우가 있습니다. 상담사가 "고객에게 전송"을 클릭할 때만 티켓 번호를 할당하면 버려진 초안 때문에 번호를 낭비하지 않습니다. 노코드 도구에서도 동일한 아이디어를 적용하세요: 초안은 공개 번호 없이 레코드로 유지하고, 최종 제출 단계에서 트랜잭션 안에 최종 번호를 생성하세요.

중복이나 뜻밖의 공백을 일으키는 흔한 실수

중복 청구서 번호 방지
현실적 동시성에서도 유지되는 데이터베이스 수준 규칙으로 청구서와 티켓 앱을 구축하세요.
AppMaster 사용해보기

대부분의 번호 문제는 번호를 표시값(display value)처럼 취급하고 공유 상태(shared state)로 보지 않는 데서 옵니다. 여러 사람이 동시에 저장하면 다음 번호를 결정할 한 곳, 실패 시 어떻게 처리할지에 대한 한 규칙이 필요합니다.

고전적 실수는 애플리케이션 코드에서 SELECT MAX(number) + 1을 사용하는 것입니다. 단일 사용자 테스트에서는 괜찮아 보이지만 두 요청이 어느 하나도 커밋하기 전에 같은 MAX를 읽을 수 있습니다. 둘 다 같은 다음 값을 생성하고 중복이 생깁니다. "확인 후 재시도"를 추가해도 피크 트래픽에서는 부하와 이상한 스파이크를 만들 수 있습니다.

또 다른 중복 원인은 클라이언트 측(브라우저나 모바일)에서 번호를 생성하는 것입니다. 클라이언트는 다른 사용자가 무엇을 하고 있는지 알 수 없고, 저장에 실패할 경우 안전하게 번호를 예약할 수 없습니다. 클라이언트 생성 번호는 "임시 라벨(예: Draft 12)"에는 괜찮지만 공식 청구서나 티켓 ID에는 적합하지 않습니다.

공백은 시퀀스가 공백 없다고 가정하는 팀을 놀라게 합니다. PostgreSQL 시퀀스는 고유성을 위해 설계되었고 완벽한 연속성을 보장하지 않습니다. 트랜잭션 롤백, ID 선할당(prefetch), 데이터베이스 재시작 등으로 번호가 건너뛸 수 있습니다. 만약 진짜 요구사항이 "중복 없음"이라면 시퀀스와 고유 제약의 조합이 보통 올바른 답입니다. 진짜로 "공백 없음"이 필요하면 다른 패턴(보통 행 잠금)이 필요하고 처리량에 대한 트레이드오프를 받아들여야 합니다.

잠금도 너무 광범위하게 하면 역효과가 날 수 있습니다. 모든 것에 대해 단일 글로벌 락을 걸면 모든 생성 작업이 직렬화되어 회사, 위치, 문서 유형별로 분리할 수 있는 경우도 대기하게 됩니다. 이로 인해 저장이 "임의로" 지체된다고 느낄 수 있습니다.

구현 시 점검할 실수 목록:

  • 데이터베이스 수준 고유 제약 없이 MAX + 1 사용
  • 최종 번호를 클라이언트에서 생성하고 나중에 충돌을 "수정"하려 함
  • PostgreSQL 시퀀스를 공백 없음으로 기대하고 공백을 오류로 취급
  • 모든 것에 대해 하나의 공유 카운터를 잠금 대신 필요에 따라 분할하지 않음
  • 한 사용자 환경에서만 테스트하여 경쟁 상태가 출시 후에야 드러남

실용적 테스트 팁: 100~1,000개의 레코드를 병렬로 생성하는 간단한 동시성 테스트를 실행해 중복과 예기치 않은 공백을 확인하세요. 노코드 도구에서도 같은 규칙이 적용됩니다: 최종 번호는 UI가 아닌 서버 측 단일 트랜잭션에서 할당하세요.

출시 전 빠른 점검 목록

생성 흐름을 스트레스 테스트하세요
병렬 생성 테스트를 실행해 사용자보다 먼저 경쟁 상태를 발견하세요.
빌드 시작

출시 전에 자주 실패하는 부분을 빠르게 점검하세요. 목표는 간단합니다: 각 레코드가 정확히 하나의 비즈니스 번호를 받고, 50명이 동시에 "생성"을 눌러도 규칙이 유지되는지 확인하는 것입니다.

사전 점검 체크리스트:

  • 비즈니스 번호 필드에 데이터베이스 수준의 고유 제약이 있는지 확인하세요(단순 UI 검사만으로는 부족). 충돌 시 이것이 마지막 방어선입니다.
  • 번호 할당이 레코드를 저장하는 동일한 데이터베이스 트랜잭션 안에서 이루어지는지 확인하세요. 번호 할당과 저장이 요청 간에 분리되면 결국 중복이 발생합니다.
  • 공백 없는 번호를 요구하면 레코드를 최종화할 때만 번호를 할당하세요(예: 초안 생성 시가 아님). 초안, 버려진 폼, 실패한 결제가 공백의 가장 흔한 원인입니다.
  • 희귀한 충돌에 대비한 재시도 전략을 추가하세요. 행 잠금이나 시퀀스에서도 직렬화 오류, 데드락, 고유 위반이 날 수 있습니다. 짧은 백오프를 둔 간단한 재시도가 충분한 경우가 많습니다.
  • UI, 공개 API, 대량 임포트 등 모든 진입점에서 20~100개의 동시 생성을 스트레스 테스트하세요. 폭발적 요청, 느린 네트워크, 이중 제출 같은 현실적 혼합을 시험하세요.

검증 방법: 바쁜 헬프데스크 상황을 시뮬레이션하세요. 두 상담사가 "새 티켓" 폼을 열고 한 명이 웹에서 제출하는 동안 임포트 작업이 이메일 인박스에서 티켓을 삽입합니다. 실행 후 모든 번호가 고유하고 형식이 올바르며 실패가 반만 저장된 레코드를 남기지 않는지 확인하세요.

AppMaster로 워크플로를 구축한다면 같은 원칙을 따르세요: 번호 할당을 데이터베이스 트랜잭션 내에 두고 PostgreSQL 제약을 신뢰하며 UI와 API 양쪽을 테스트하세요. 많은 팀이 수동 테스트에서는 안전하다고 느끼지만 실제 사용자가 몰리는 첫날에 놀라는 경우가 많습니다.

예: 분주한 헬프데스크 티켓과 다음 단계

웹 앱에서 상담사가 티켓을 생성하고, 통합은 채팅 도구나 이메일에서 티켓을 생성하는 환경을 상상하세요. 모두 T-2026-000123 같은 티켓 번호를 기대하고, 각 번호가 정확히 하나의 티켓을 가리키길 바랍니다.

순진한 접근은 "마지막 티켓 번호를 읽고 +1을 해서 새 티켓 저장"입니다. 부하가 걸리면 두 요청이 어느 하나도 저장하기 전에 같은 "마지막 번호"를 읽을 수 있습니다. 둘 다 같은 다음 번호를 계산하고 중복이 발생합니다. 실패 후 재시도로 이를 고치려 해도 의미 없는 공백을 만드는 경우가 많습니다.

데이터베이스는 앱 코드가 미숙해도 중복을 막아줄 수 있습니다. ticket_number 열에 고유 제약을 추가하세요. 그런 다음 두 요청이 같은 번호를 시도하면 하나의 삽입이 실패하고 재시도 로직으로 깔끔하게 처리할 수 있습니다. 이것이 동시성 안전한 번호 지정의 핵심입니다: 고유성은 UI가 아니라 데이터베이스에 맡기세요.

공백 없는 번호는 워크플로를 바꿉니다. 공백 없는 번호가 필요하면 티켓이 처음 생성될 때(초안) 최종 번호를 부여할 수 없습니다. 대신 상태를 Draft로 두고 ticket_number는 NULL로 둡니다. 번호는 티켓을 최종화할 때만 할당하세요. 이렇게 하면 실패나 버림으로 번호가 낭비되지 않습니다.

간단한 테이블 설계 예:

  • tickets: id, created_at, status (Draft, Open, Closed), ticket_number (nullable), finalized_at
  • ticket_counters: key (예: "tickets_2026"), next_number

AppMaster에서는 이를 Data Designer에 PostgreSQL 타입으로 모델링한 뒤 Business Process Editor에서 로직을 구축할 수 있습니다:

  • Create Ticket: status=Draft로 티켓 삽입(ticket_number 없음)
  • Finalize Ticket: 트랜잭션 시작, 카운터 행 잠금, ticket_number 설정, next_number 증가, 커밋
  • 테스트: 동시에 두 개의 "Finalize"를 실행해 중복이 발생하지 않는지 확인

다음 단계: 규칙(고유만 허용 vs 진짜 공백 없음)을 정하세요. 공백을 허용할 수 있다면 데이터베이스 시퀀스와 고유 제약의 조합이 보통 충분하고 흐름이 단순합니다. 공백이 반드시 없어야 한다면 번호 할당을 최종화 단계로 옮기고 "초안"을 1차 상태로 취급하세요. 그런 다음 여러 상담사가 동시에 클릭하거나 API 통합이 버스트를 일으킬 때 로드 테스트를 실행해 실제 사용자 전에 동작을 확인하세요.

쉬운 시작
멋진만들기

무료 요금제로 AppMaster를 사용해 보세요.
준비가 되면 적절한 구독을 선택할 수 있습니다.

시작하다