소스: sql-src/work_07_numbering/01_max_plus_one.sql, 02_seq_table.sql. 주문 헤더 order_h(order_no PK, ord_date, cust, amt)에는 9월 29일 2건, 9월 30일 3건이 있습니다. 채번 테이블 seq_no(biz, ymd, last_no, PK 는 biz 와 ymd 둘)는 02 에서 만듭니다.
H2 는 세션을 하나만 쓰므로 동시 요청 자체는 재현할 수 없습니다. 동시에 같은 번호를 받은 상황은 그 번호를 직접 INSERT 해서 재현하고, 두 세션의 시간 순서는 도식으로 보입니다.
번호 형태가 ORD-20260930- 다음 4자리라서 앞 13글자를 자르면 14번째부터가 일련번호입니다. 그 값의 최댓값에 1 을 더하고, 행이 없으면 NULL 이므로 COALESCE 로 0 을 줍니다. 결과는 4자리로 LPAD 합니다.
SELECT
'ORD-20260930-' || LPAD(CAST(COALESCE(MAX(CAST(SUBSTRING(order_no, 14) AS INT)), 0) + 1 AS VARCHAR), 4, '0') AS next_no
FROM order_h
WHERE order_no LIKE 'ORD-20260930-%';NEXT_NO
-----------------
ORD-20260930-0004
(1행)30일에 이미 0001 부터 0003 까지 있으므로 다음은 0004 입니다. 주문이 하나도 없는 10월 1일로 같은 쿼리를 돌리면 MAX 가 NULL 이라 0 이 되어 ORD-20261001-0001 이 나옵니다(소스 2번).
번호 계산과 INSERT 를 한 문장으로 합치면 코드가 줄어듭니다. 결과를 30일 주문으로 확인합니다.
INSERT INTO order_h
SELECT
'ORD-20260930-' || LPAD(CAST(COALESCE(MAX(CAST(SUBSTRING(order_no, 14) AS INT)), 0) + 1 AS VARCHAR), 4, '0')
, DATE '2026-09-30'
, '사아전자'
, 700
FROM order_h
WHERE order_no LIKE 'ORD-20260930-%';
SELECT
order_no
, cust
FROM order_h
WHERE ord_date = DATE '2026-09-30'
ORDER BY order_no;ORDER_NO | CUST
------------------+---------
ORD-20260930-0001 | 가나상사
ORD-20260930-0002 | 마바유통
ORD-20260930-0003 | 다라물산
ORD-20260930-0004 | 사아전자
(4행)한 세션이면 완벽하게 동작합니다. 문제는 이 문장이 같은 순간에 두 번 실행될 때입니다.
아래 도식은 두 세션이 같은 MAX 를 읽는 상황을 보여 줍니다. 실행 결과가 아니고, 예제 1 의 상태에서 0004 까지 있다고 가정합니다.
시간 순서 도식(설명용). MAX+1 의 충돌입니다.
| 순서 | 세션 A | 세션 B |
|---|---|---|
| 1 | MAX 조회 → 0004, 다음은 0005 | |
| 2 | MAX 조회 → 0004, 다음은 0005 | |
| 3 | 0005 INSERT 성공 | |
| 4 | 0005 INSERT → 중복 키 오류 | |
| 5 | 번호를 다시 구함 → 0006 | |
| 6 | 0006 INSERT 성공 |
H2 에서는 두 세션을 동시에 열 수 없어서 A 가 0005 를 넣은 뒤, B 가 같은 0005 를 넣는 것으로 흉내 냅니다.
INSERT INTO order_h VALUES ('ORD-20260930-0005', DATE '2026-09-30', '자차식품', 900);-- @error
INSERT INTO order_h VALUES ('ORD-20260930-0005', DATE '2026-09-30', '카타상회', 400);예상 오류: Unique index or primary key violation: "PUBLIC.PRIMARY_KEY_E ON PUBLIC.ORDER_H(ORDER_NO) VALUES ( /* 7 */ 'ORD-20260930-0005' )"PK 가 없었다면 이 INSERT 는 성공하고 같은 주문번호가 두 건 남습니다. 실패한 쪽은 번호를 다시 구해 재시도합니다.
SELECT
'ORD-20260930-' || LPAD(CAST(COALESCE(MAX(CAST(SUBSTRING(order_no, 14) AS INT)), 0) + 1 AS VARCHAR), 4, '0') AS next_no
FROM order_h
WHERE order_no LIKE 'ORD-20260930-%';
INSERT INTO order_h VALUES ('ORD-20260930-0006', DATE '2026-09-30', '카타상회', 400);
SELECT COUNT(*) AS cnt FROM order_h WHERE ord_date = DATE '2026-09-30';NEXT_NO
-----------------
ORD-20260930-0006
(1행)CNT
---
6
(1행)재시도 후 30일 주문은 6건입니다. 애플리케이션은 이 재시도를 몇 번까지 할지 정해 두어야 합니다.
02 의 데이터는 9월 29일 행(last_no 2)만 있는 상태로 시작합니다. 30일 행이 없을 때만 만들려고 MERGE 를 씁니다. 일치하는 행이 있으면 아무것도 하지 않습니다.
MERGE INTO seq_no t
USING (SELECT 'ORD' AS biz, '20260930' AS ymd FROM DUAL) s
ON (t.biz = s.biz AND t.ymd = s.ymd)
WHEN NOT MATCHED THEN INSERT (biz, ymd, last_no) VALUES (s.biz, s.ymd, 0);
SELECT
biz
, ymd
, last_no
FROM seq_no
ORDER BY ymd;BIZ | YMD | LAST_NO
----+----------+--------
ORD | 20260929 | 2
ORD | 20260930 | 0
(2행)이제 UPDATE 로 1 을 올리고 그 값을 읽습니다. UPDATE 가 행 잠금을 먼저 잡으므로 두 번째 요청은 여기서 기다립니다.
UPDATE seq_no SET last_no = last_no + 1 WHERE biz = 'ORD' AND ymd = '20260930';
SELECT last_no FROM seq_no WHERE biz = 'ORD' AND ymd = '20260930';LAST_NO
-------
1
(1행)번호 문자열은 세 조각을 이어 붙입니다.
SELECT
biz || '-' || ymd || '-' || LPAD(CAST(last_no AS VARCHAR), 4, '0') AS order_no
FROM seq_no
WHERE biz = 'ORD'
AND ymd = '20260930';ORDER_NO
-----------------
ORD-20260930-0001
(1행)자동 커밋을 끄고 채번 UPDATE 와 주문 INSERT 를 묶습니다. 주문 INSERT 는 방금 올린 채번 행에서 번호를 바로 조립합니다. 다시 값을 조회하는 문장이 필요 없습니다.
SET AUTOCOMMIT OFF;
UPDATE seq_no SET last_no = last_no + 1 WHERE biz = 'ORD' AND ymd = '20260930';
INSERT INTO order_h
SELECT
biz || '-' || ymd || '-' || LPAD(CAST(last_no AS VARCHAR), 4, '0')
, DATE '2026-09-30'
, '가나상사'
, 500
FROM seq_no
WHERE biz = 'ORD'
AND ymd = '20260930';
COMMIT;
SELECT
order_no
, cust
FROM order_h
WHERE ord_date = DATE '2026-09-30';ORDER_NO | CUST
------------------+---------
ORD-20260930-0002 | 가나상사
(1행)앞 예제에서 last_no 를 이미 1 로 올렸으므로 이번 UPDATE 로 2 가 되어 이 주문이 0002 를 받았습니다. 소스는 이어서 last_no 가 2 인 것을 확인합니다.
시간 순서 도식(설명용). 두 요청이 채번 행에서 줄을 서는 모습입니다.
| 순서 | 세션 A | 세션 B |
|---|---|---|
| 1 | UPDATE last_no = last_no + 1 (잠금 획득) | |
| 2 | UPDATE last_no = last_no + 1 (A 가 끝날 때까지 대기) | |
| 3 | 주문 INSERT, COMMIT (잠금 해제) | |
| 4 | 대기 끝, 커밋된 값에서 1 을 더해 다음 번호를 받음 |
같은 행을 UPDATE 하려는 세션은 한 번에 하나만 실행되므로 B 는 A 와 같은 번호를 받을 수 없습니다. 이 잠금은 A 가 COMMIT 할 때 풀리기 때문에 채번과 INSERT 사이가 길수록 B 가 오래 기다립니다.