# Postgres ID가 번호를 건너뛰는 이유와 가져오기 후 "duplicate key" 오류를 고치는 방법

PostgreSQL의 serial 및 identity 열에서 빈 번호가 생기는 원인(롤백, ON CONFLICT, 장애, CACHE), 데이터를 가져온 뒤 삽입이 duplicate key value violates unique constraint로 실패하는 이유, 모든 시퀀스를 한 번에 재설정하는 쿼리, integer ID가 바닥날 때 일어나는 일, 그리고 빈 번호 없는 송장 번호를 다룹니다. 모든 문장은 Postgres 17.11에서 재현했습니다.

**Postgres ID에 관해 반복해서 나오는 질문이 두 가지 있습니다. "왜 ID가 5에서 34로 건너뛰었나요?"와 "데이터를 가져온 직후 왜 모든 삽입이 `duplicate key value violates unique constraint \"..._pkey\"`로 실패하나요?"입니다. 둘 다 같은 객체, 즉 `serial` 또는 identity 열 뒤에 있는 시퀀스에서 비롯됩니다. 시퀀스는 번호가 실제로 쓰이는지 신경 쓰지 않고 번호를 나눠 주므로, 빈 번호가 생기는 것은 정상입니다. 또한 시퀀스는 자기가 직접 나눠 준 번호만 알기 때문에, 명시적인 ID와 함께 적재한 행은 시퀀스를 뒤처지게 만듭니다. 이 글은 각 종류의 빈 번호를 재현하고, 가져오기 오류를 테이블 하나에 대해, 그다음 모든 테이블에 대해 한 번에 고치고, `integer` ID가 바닥나면 어떻게 되는지 보여 주며, 실제로 필요할 수 있는 단 한 곳을 위해 빈 번호 없는 번호 매기기를 만듭니다. 아래의 모든 문장과 출력은 Relyt One이 실행하는 메이저 버전인 로컬 PostgreSQL 17.11에서 나온 것입니다.**

## 요약

| 증상                                                      | 원인                                    | 해결 방법                              |
| ------------------------------------------------------- | ------------------------------------- | ---------------------------------- |
| ID가 몇 개씩 건너뜀                                            | 롤백되었거나 실패한 삽입, `ON CONFLICT`          | 할 일 없음. 시퀀스는 설계상 빈 번호가 없도록 보장하지 않음 |
| 장애 후 ID가 최대 32까지 건너뜀(기본 캐시 기준)                          | 시퀀스가 WAL에 값을 미리 앞당겨 기록함               | 할 일 없음. ID 값에 의미를 부여하지 말 것         |
| 여러 세션의 ID가 뒤섞이거나 캐시 크기만큼 건너뜀                            | `CACHE`가 1보다 큼                        | 순서가 중요하다면 기본값 `CACHE 1` 사용         |
| 가져오기 후 `duplicate key value violates unique constraint` | 행을 명시적인 ID와 함께 적재했고, 시퀀스는 여전히 1에서 시작함 | `setval`로 시퀀스를 `max(id)` 뒤로 이동     |
| `nextval: reached maximum value of sequence`            | `serial` 열은 32비트 `integer`임           | 시퀀스**와** 열을 모두 `bigint`로 변경        |
| 빈 번호가 없는 송장 번호가 필요함                                     | 시퀀스로는 불가능함                            | 같은 트랜잭션에서 업데이트하는 카운터 행             |

## 빈 번호는 어디서 생기는가

PostgreSQL 문서는 이 점을 직설적으로 말합니다. `nextval`이 반환한 값은 "is not reclaimed for re-use if the calling transaction later aborts"(호출한 트랜잭션이 나중에 중단되더라도 재사용을 위해 회수되지 않는다)이며, "sequence objects cannot be used to obtain 'gapless' sequences."(시퀀스 객체로는 '빈 번호 없는' 순번을 얻을 수 없다.) 이것은 의도된 절충입니다. 롤백된 트랜잭션이 번호를 돌려줄 수 있다면, 번호를 요청하는 다른 모든 트랜잭션은 그 트랜잭션이 번호를 돌려주는지 지켜보며 기다려야 합니다. 실제로 빈 번호가 어떻게 생기는지 보겠습니다.

### 롤백, 실패한 삽입, ON CONFLICT

```sql
CREATE TABLE orders (
  id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email text NOT NULL UNIQUE
);

INSERT INTO orders (email) VALUES ('a@example.com');           -- 1을 가져감

BEGIN;
INSERT INTO orders (email) VALUES ('b@example.com');           -- 2를 가져감
ROLLBACK;                                                      -- 2는 사라짐

INSERT INTO orders (email) VALUES ('c@example.com');           -- 3을 가져감
INSERT INTO orders (email) VALUES ('a@example.com')
  ON CONFLICT (email) DO NOTHING;                              -- 4를 소모함
INSERT INTO orders (email) VALUES ('a@example.com');           -- 5를 소모하고 실패함
INSERT INTO orders (email) VALUES ('d@example.com');           -- 6을 가져감

SELECT id, email FROM orders ORDER BY id;
```

```text
 id |     email
----+---------------
  1 | a@example.com
  3 | c@example.com
  6 | d@example.com
(3 rows)
```

행 세 개에 빈 번호 세 개입니다. 롤백된 삽입은 2를 가져갔습니다. 고유 `email` 제약 조건에서 실패한 삽입은 실패하기 전에 5를 가져갔습니다. Postgres는 제약 조건을 검사하기 전에 기본 ID를 포함한 행 전체를 만들기 때문입니다. `ON CONFLICT DO NOTHING`은 같은 이유로 아무것도 삽입하지 않고 4를 가져갔습니다. 문서에도 명시되어 있습니다. `INSERT ... ON CONFLICT`는 행을 계산하며, 여기에는 "including doing any required nextval calls, before detecting any conflict."(충돌을 감지하기 전에 필요한 nextval 호출을 수행하는 것까지 포함된다.) 애플리케이션이 같은 행을 계속 upsert한다면, 시퀀스는 행 수보다 훨씬 빠르게 올라갈 수 있습니다. (upsert는 [INSERT ... ON CONFLICT vs MERGE](/blog/ko/postgres-upsert-on-conflict-vs-merge)에서 자세히 다뤘습니다.)

### 장애: 한 번에 최대 32개(캐시가 있으면 더 많이)

`nextval`마다 WAL 레코드를 쓰면 느리므로, Postgres는 시퀀스를 앞당겨 기록합니다. 레코드를 쓸 때 32만큼 앞선 값을 기록하고, 그 사이의 번호는 다시 기록하지 않고 나눠 줍니다. 이 상수는 서버 소스의 `SEQ_LOG_VALS`이며, 그 옆의 주석은 이렇게 말합니다. "In the event of crash we can lose (skip over) as many values as we pre-logged."(장애가 발생하면 미리 기록한 만큼의 값을 잃을(건너뛸) 수 있다.)

![nextval을 다섯 번 호출한 뒤의 시퀀스. 메모리의 페이지에는 마지막 값이 5로 되어 있습니다. 첫 번째 호출이 쓴 WAL 레코드에는 이미 33이 기록되어 있습니다. 장애 후 복구가 WAL을 재생하므로 다음 값은 34가 되고, 6부터 33까지는 결코 사용되지 않습니다.](/blog/assets/ea3c64f358413a0a.svg)

직접 확인할 수 있습니다. `log_cnt`는 미리 기록된 값이 몇 개 남았는지를 나타냅니다.

```sql
CREATE TABLE events (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  note text
);
INSERT INTO events (note) SELECT 'before crash' FROM generate_series(1, 5);
SELECT last_value, log_cnt FROM events_id_seq;
```

```text
 last_value | log_cnt
------------+---------
          5 |      28
(1 row)
```

이제 정상 종료 없이 서버를 강제로 중단하고(`pg_ctl stop -m immediate`), 다시 시작한 다음, 행을 하나 더 삽입합니다.

```text
LOG:  database system was not properly shut down; automatic recovery in progress
```

```sql
INSERT INTO events (note) VALUES ('after crash') RETURNING id;
```

```text
 id
----
 34
(1 row)
```

복구가 WAL을 재생했고, WAL에는 33이 기록되어 있었으므로 다음 ID는 34입니다. 6부터 33까지의 번호는 결코 사용되지 않았습니다. Postgres는 각 체크포인트 이후 첫 `nextval`에서도 32개 값을 담은 새 레코드를 쓰므로, 잃는 번호의 개수는 장애가 그 구간의 어디에서 일어나는지에 따라 달라집니다. 32는 캐시에 더해지는 값입니다. `CACHE 20`인 시퀀스(다음 절)는 20 + 32개 값을 앞당겨 기록하고, 그만큼 잃을 수 있습니다.

정상 종료는 실제 상태를 디스크에 쓰며, 저희 테스트에서는 일반적인 재시작 후 다음 번호부터 이어졌습니다. 이는 저희가 직접 중지한 일반 Postgres 서버였습니다. 관리형 서비스에서 재시작이 어떻게 일어나는지는 서비스에 달려 있으므로, 모든 재시작을 번호가 건너뛸 수 있는 상황으로 취급하세요.

### CACHE: 각 세션이 블록을 가져감

`CACHE 20`으로 만든 시퀀스는 각 세션에 번호를 20개씩 줍니다. 세션이 사용하지 않은 번호는 연결이 끊길 때 사라집니다.

```sql
CREATE SEQUENCE ticket_seq CACHE 20;
SELECT nextval('ticket_seq');    -- 세션 1이 1..20을 예약함
\c
SELECT nextval('ticket_seq');    -- 세션 2가 21..40을 예약함
```

```text
 nextval
---------
       1
(1 row)

You are now connected to database "postgres" as user "postgres".
 nextval
---------
      21
(1 row)
```

캐시를 사용하면 서로 다른 세션의 번호가 더 이상 시간 순서대로 나눠지지 않는다고 문서는 경고합니다. 세션 A가 1을 받고, 그다음 세션 B가 21을 받고, 그 이후에야 A가 2를 받습니다. 기본값은 `CACHE 1`이며, 따로 바꾸지 않는 한 `serial`과 identity 열은 이 값을 사용합니다.

이 빈 번호들은 어느 것도 버그가 아니며, 어느 것도 고칠 필요가 없습니다. 고쳐야 할 것은 생각입니다. ID는 "이 행은 저 행과 다르다"를 뜻할 뿐, "이것은 6번째 주문이다"나 "주문이 6개 있다"를 뜻하지 않습니다.

## 가져오기 후 "duplicate key value violates unique constraint"

이것은 실제 버그이며, 보통 이전 작업 후 첫날에 나타납니다. 한 데이터베이스에서 테이블을 내보내 다른 데이터베이스에 적재했는데, 애플리케이션의 첫 삽입이 실패합니다. 전체 상황을 세 문장으로 재현하면 다음과 같습니다.

```sql
CREATE TABLE customers (
  id   serial PRIMARY KEY,
  name text NOT NULL
);

-- CSV 가져오기, ORM 시드, 다른 데이터베이스에서의 복사가 하는 일:
COPY customers (id, name) FROM stdin;
1	Ada
2	Grace
3	Edsger
\.

INSERT INTO customers (name) VALUES ('Barbara');
```

```text
ERROR:  duplicate key value violates unique constraint "customers_pkey"
DETAIL:  Key (id)=(1) already exists.
```

행이 자체 ID를 가지고 있으므로 `COPY`는 `nextval`을 한 번도 호출하지 않았습니다. 시퀀스는 아직 아무것도 나눠 주지 않았다고 생각하고 새 행에 1을 주며, 기본 키가 이를 거부합니다. identity 열도 똑같이 동작합니다. `COPY`는 `GENERATED ALWAYS` 열에도 입력의 ID를 그대로 쓰며("like the INSERT option OVERRIDING SYSTEM VALUE", INSERT의 OVERRIDING SYSTEM VALUE 옵션처럼), 시퀀스는 움직이지 않습니다.

혼란을 더하는 세부 사항이 있습니다. 실패한 삽입도 매번 번호를 하나씩 소모합니다. 세 번 재시도하면 네 번째 삽입은 성공합니다. 시퀀스가 마침내 가져온 ID들을 지나쳤기 때문입니다. 그래서 행 세 개를 가져온 개발 환경에서는 오류가 가끔 "저절로 고쳐지고", 행 300만 개를 가져온 운영 환경에서는 그렇지 않습니다.

### 테이블 하나 고치기

시퀀스를 테이블의 가장 큰 ID로 이동합니다. `pg_get_serial_sequence`는 `serial` 또는 identity 열 뒤에 있는 시퀀스를 찾아 주므로, 시퀀스 이름을 추측할 필요가 없습니다.

```sql
SELECT pg_get_serial_sequence('customers', 'id');
SELECT setval(pg_get_serial_sequence('customers', 'id'), max(id)) FROM customers;
INSERT INTO customers (name) VALUES ('Barbara') RETURNING id;
```

```text
 pg_get_serial_sequence
-------------------------
 public.customers_id_seq
(1 row)

 setval
--------
      3
(1 row)

 id
----
  4
(1 row)
```

이 한 줄짜리에는 두 가지 문제가 있습니다. 빈 테이블에서는 `max(id)`가 `NULL`이고, 인수가 `NULL`인 `setval`은 `NULL`을 반환하며 아무 일도 하지 않습니다(경고도 없습니다). 그리고 시퀀스의 기본 최솟값이 1이므로 `setval(seq, 0)`은 실패합니다.

```sql
CREATE TABLE audit_log (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY);
SELECT setval(pg_get_serial_sequence('audit_log', 'id'), max(id)) FROM audit_log;
SELECT setval(pg_get_serial_sequence('audit_log', 'id'), 0);
```

```text
 setval
--------

(1 row)

ERROR:  setval: value 0 is out of bounds for sequence "audit_log_id_seq" (1..9223372036854775807)
```

빈 테이블에서도 동작하는 형태는 `setval(seq, COALESCE(max(id), 0) + 1, false)`입니다. `false`는 "다음 `nextval`이 정확히 이 값을 반환한다"는 뜻이므로, 빈 테이블은 1부터 시작합니다.

또한 `setval`은 트랜잭션의 영향을 받지 않는다는 점에 유의하세요. 문서에 따르면 그 변경은 "immediately visible to other transactions, and \[is] not undone if the calling transaction rolls back."(다른 트랜잭션에 즉시 보이며, 호출한 트랜잭션이 롤백되어도 취소되지 않는다.)

### 모든 시퀀스를 한 번에 고치기

대량 가져오기 후에는 모든 테이블에 이 작업을 하고 싶을 것입니다. 이 쿼리는 `serial` 또는 identity 열에 속한 모든 시퀀스를 찾아 각각에 대한 `setval`을 만들고, `psql`의 `\gexec`가 생성된 문장을 실행합니다. 각 `setval`은 시퀀스를 앞으로만 이동합니다. `max(id) + 1`과 시퀀스가 다음에 나눠 줄 값 중 더 큰 쪽을 택합니다. 이미 사용 중인 데이터베이스에서는 이것이 중요합니다. 테이블의 최신 행들을 삭제했다면 `max(id) + 1`이 시퀀스보다 작을 수 있고, 시퀀스를 뒤로 옮기면 이미 한 번 사용되었고 다른 곳에서 아직 참조하고 있을지도 모르는 ID를 다시 나눠 주게 됩니다.

```sql
SELECT format(
         'SELECT setval(%1$L, GREATEST('
         'COALESCE((SELECT max(%2$I) FROM %3$I.%4$I), 0) + 1, '
         '(SELECT last_value + is_called::int FROM %1$s)), false);',
         s.oid::regclass, a.attname, n.nspname, t.relname)
FROM pg_class s
JOIN pg_depend d    ON d.classid = 'pg_class'::regclass
                   AND d.objid = s.oid
                   AND d.refclassid = 'pg_class'::regclass
                   AND d.deptype IN ('a', 'i')   -- serial / identity
JOIN pg_class t     ON t.oid = d.refobjid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
WHERE s.relkind = 'S'
ORDER BY 1
\gexec
```

저희 테스트 데이터베이스에서는 테이블마다 문장 하나를 생성해 실행했습니다. 그중 두 개, 비어 있는 `audit_log`와 가져온 `customers`를 보면 다음과 같습니다(출력 일부 생략).

```text
SELECT setval('audit_log_id_seq', GREATEST(COALESCE((SELECT max(id) FROM public.audit_log), 0) + 1, (SELECT last_value + is_called::int FROM audit_log_id_seq)), false);
 setval
--------
      1
(1 row)

SELECT setval('customers_id_seq', GREATEST(COALESCE((SELECT max(id) FROM public.customers), 0) + 1, (SELECT last_value + is_called::int FROM customers_id_seq)), false);
 setval
--------
      5
(1 row)
```

가져오기가 끝난 뒤, 트래픽을 받기 전에 실행하세요. `max(id)`와 `setval` 사이에 삽입된 행은 여전히 충돌할 수 있습니다. 시퀀스 자신의 다음 값이 `GREATEST`에 포함되므로, 사용자 지정 `MINVALUE`나 `START`가 1보다 큰 시퀀스는 빈 테이블에서도 실패하지 않고 그 자리에 머뭅니다. `\gexec`는 오류가 나도 계속 진행하므로, 첫 실패에서 나머지를 멈추고 싶다면 `\set ON_ERROR_STOP on`과 함께 실행하세요. 이 쿼리가 다루지 않는 경우가 두 가지 있습니다. 거꾸로 세는 시퀀스(음수 `INCREMENT`)에는 `min`과 `LEAST`가 필요합니다. 그리고 `OWNED BY` 없이 `DEFAULT nextval('some_seq')`로만 열에서 사용하는 시퀀스는 그 열이 소유하지 않습니다. `pg_get_serial_sequence`는 이런 시퀀스에 대해 `NULL`을 반환하며, 위 쿼리는 이를 건너뜁니다. 이런 시퀀스는 직접 고치세요.

### 어떤 데이터 이동 방식이 시퀀스를 뒤처지게 하는가

| 데이터를 옮긴 방법                                              | 시퀀스                                                                                                                                                                |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 데이터베이스 전체를 `pg_dump`하고 `psql` 또는 `pg_restore`로 복원       | 올바르게 설정됨. 덤프에 모든 시퀀스에 대한 `setval`이 포함됨                                                                                                                             |
| `pg_dump --data-only`                                   | 올바르게 설정됨. 데이터 전용 덤프에도 `setval` 호출이 포함됨                                                                                                                             |
| `pg_dump -t <table>`                                    | 테이블이 **소유한** 시퀀스(`serial`, identity)는 `setval`과 함께 포함됨. 테이블이 `DEFAULT nextval(...)`로 참조만 하는 시퀀스는 포함되지 않음. 그 시퀀스가 없는 데이터베이스에 복원하면 실패하고, 있는 데이터베이스에서는 그 시퀀스가 이동되지 않음 |
| CSV에서 `COPY` 또는 `\copy`, ORM 시드 파일, ID와 함께 행을 삽입하는 스크립트 | **설정되지 않음.** 위의 해결 방법을 실행                                                                                                                                          |
| 논리 복제                                                   | **설정되지 않음.** "Sequence data is not replicated"(시퀀스 데이터는 복제되지 않는다). 구독자의 시퀀스는 전환 전에 직접 업데이트할 때까지 시작 값에 머묾                                                           |
| `TRUNCATE` 후 다시 적재                                      | `RESTART IDENTITY`를 붙이지 않으면 `TRUNCATE`는 시퀀스를 재설정하지 않음. 같은 ID를 다시 적재하는 것은 괜찮지만, 더 큰 새 ID를 적재하면 해결 방법이 필요함                                                           |

각 행은 `pg_dump` 17.11로 확인했습니다. `DEFAULT nextval(...)`의 경우는 문서가 설명합니다. `-t`를 사용하면 "pg_dump makes no attempt to dump any other database objects that the selected table(s) might depend upon."(pg_dump는 선택한 테이블이 의존할 수 있는 다른 데이터베이스 객체를 덤프하려고 시도하지 않는다.)

## 시퀀스가 바닥날 때

`serial`은 `integer` 열과 `integer` 시퀀스를 만들므로 2,147,483,647에서 멈춥니다. 많아 보이지만, 이벤트를 기록하거나 대량으로 upsert하는 테이블이 행을 저장하는 속도보다 빠르게 번호를 소모하면(위의 빈 번호 참고) 이야기가 달라집니다. 끝에 도달하면 다음과 같습니다.

```sql
CREATE TABLE clicks (id serial PRIMARY KEY);
SELECT setval('clicks_id_seq', 2147483646);     -- 데모를 위해 빨리 감기
INSERT INTO clicks DEFAULT VALUES RETURNING id;
INSERT INTO clicks DEFAULT VALUES RETURNING id;
```

```text
     id
------------
 2147483647
(1 row)

ERROR:  nextval: reached maximum value of sequence "clicks_id_seq" (2147483647)
```

그런 일이 일어나기 전에 얼마나 가까운지 확인하세요. `pg_sequences`는 모든 시퀀스를 타입 및 한도와 함께 보여 줍니다.

```sql
SELECT schemaname, sequencename, data_type, last_value, max_value,
       round(100.0 * last_value / max_value, 2) AS pct_used
FROM pg_sequences
ORDER BY pct_used DESC NULLS LAST, sequencename
LIMIT 3;
```

```text
 schemaname |   sequencename   | data_type | last_value |      max_value      | pct_used
------------+------------------+-----------+------------+---------------------+----------
 public     | clicks_id_seq    | integer   | 2147483647 |          2147483647 |   100.00
 public     | ticket_seq       | bigint    |         40 | 9223372036854775807 |     0.00
 public     | audit_log_id_seq | bigint    |            | 9223372036854775807 |
(3 rows)
```

40에 있는 `ticket_seq`를 보세요. `CACHE 20`에서는 `last_value`에 세션이 예약했지만 아직 사용하지 않은 번호가 포함됩니다. 그리고 `setval(..., false)` 이후 아직 번호를 하나도 나눠 주지 않은 시퀀스는 `last_value`가 `NULL`입니다. 방금 위의 해결 방법이 건드린 시퀀스들이 그렇습니다. 이런 시퀀스는 맨 뒤에 정렬됩니다.

이 쿼리는 시퀀스 자체의 한도만 압니다. 시퀀스는 `bigint`인데 열이 아직 `integer`라면, 쿼리는 거의 0%를 보여 주는데도 열이 먼저 실패합니다. 이것이 바로 다음 함정입니다.

열이 여전히 `integer`이므로, 시퀀스만 바꾸는 것으로는 충분하지 않습니다.

```sql
ALTER SEQUENCE clicks_id_seq AS bigint;
INSERT INTO clicks DEFAULT VALUES RETURNING id;
```

```text
ERROR:  integer out of range
```

열도 `ALTER TABLE clicks ALTER COLUMN id TYPE bigint`로 바꿔야 합니다. 큰 테이블에서 이 문장은 `ACCESS EXCLUSIVE` 잠금을 잡은 채 테이블 전체를 다시 쓰며, 실행되는 동안 읽기와 쓰기를 모두 막습니다. [테이블을 잠그는 마이그레이션](/blog/ko/postgres-migration-locks)에 관한 글에서 바로 이 변경을 측정했습니다. 저렴한 해결책은 첫날에 하는 것입니다. ID에는 `bigint`를 사용하세요.

## 빈 번호 없는 번호가 정말 필요할 때

업무상 구멍 없는 번호가 필요할 때가 있으며, 송장 번호가 대표적인 예입니다. 시퀀스로는 할 수 없으므로, 삽입과 같은 트랜잭션에서 업데이트하는 행을 사용합니다. 기본 `READ COMMITTED` 격리 수준에서는 행 잠금 때문에 동시 트랜잭션들이 차례로 실행되고, 롤백하면 송장과 함께 증가분도 취소됩니다.

```sql
CREATE TABLE invoice_counters (
  year    int PRIMARY KEY,
  last_no int NOT NULL
);
INSERT INTO invoice_counters VALUES (2026, 0);

CREATE TABLE invoices (
  id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  year   int NOT NULL,
  no     int NOT NULL,
  amount numeric NOT NULL,
  UNIQUE (year, no)
);

BEGIN;
WITH n AS (
  UPDATE invoice_counters SET last_no = last_no + 1
  WHERE year = 2026
  RETURNING last_no
)
INSERT INTO invoices (year, no, amount)
SELECT 2026, last_no, 120 FROM n
RETURNING id, year, no;
ROLLBACK;
```

같은 트랜잭션을 `ROLLBACK` 대신 `COMMIT`으로 다시 실행합니다.

```text
 id | year | no
----+------+----
  2 | 2026 |  1
(1 row)
```

롤백이 카운터를 취소했으므로 송장 번호는 다시 1입니다. `id`는 2인데, identity 열은 여전히 시퀀스이고 롤백으로 1을 잃었기 때문입니다. 둘 다 유지하세요. identity는 기본 키로, 빈 번호 없는 번호는 업무용 필드로 씁니다.

대가는 동시성입니다. 카운터 행은 한 번에 한 트랜잭션만 잡을 수 있습니다. 저희 테스트에서 한 세션이 이 행을 5초 동안 잡고 있었고, 약 1초 뒤에 시작한 두 번째 세션의 `UPDATE`는 첫 번째 세션이 커밋할 때까지 기다렸습니다.

```text
 last_no
---------
       3
(1 row)

UPDATE 1
Time: 3943.462 ms (00:03.943)
```

따라서 이런 트랜잭션은 짧게 유지하세요. 번호는 `COMMIT` 직전, 마지막에 가져오고, 번호를 잡고 있는 동안 다른 서비스를 호출하지 마세요. 연도별(또는 시리즈별, 테넌트별)로 카운터 행을 하나씩 두면 대기도 분산됩니다. `REPEATABLE READ`나 `SERIALIZABLE`에서도 두 번째 트랜잭션은 여전히 기다리지만, 첫 번째 트랜잭션이 커밋되면 `could not serialize access due to concurrent update`로 실패하므로 코드에서 재시도해야 합니다.

## serial인가, identity인가?

새 테이블에는 `bigint GENERATED ALWAYS AS IDENTITY`를 사용하세요. SQL 표준 문법이고, 열과 그 시퀀스가 하나로 묶이며, `GENERATED ALWAYS`는 실수로 넣은 명시적 ID를 거부합니다.

```text
ERROR:  cannot insert a non-DEFAULT value into column "id"
DETAIL:  Column "id" is an identity column defined as GENERATED ALWAYS.
HINT:  Use OVERRIDING SYSTEM VALUE to override.
```

PostgreSQL 커뮤니티 위키의 "Don't Do This" 페이지도 같은 말을 합니다. `serial` 대신 "For new applications, identity columns should be used instead"(새 애플리케이션에서는 identity 열을 대신 사용해야 한다). 이 글에서 빈 번호와 가져오기에 관해 말한 모든 내용은 둘 다에 적용됩니다. 다른 점은 가져오기 보호뿐이며, 위에서 본 것처럼 `COPY`는 어차피 그 보호를 우회합니다.

## 브랜치에서 가져오기를 미리 연습하기

가져오기 버그는 고치기 쉽지만 놓치기도 쉽습니다. 데이터가 들어간 뒤 첫 삽입에서만 나타나기 때문입니다. 사용자보다 먼저 그 삽입을 테스트하세요. Relyt One에서 브랜치는 특정 시점의 부모에서 시작하는 새로운 이력 줄기로, 부모와 페이지를 copy-on-write로 공유하며 자체 컴퓨트를 가집니다. 내보낸 데이터를 브랜치에 적재하고, 모든 시퀀스를 고치는 쿼리를 실행하고, 테이블마다 행을 하나씩 삽입한 다음, `pg_sequences`에서 한도에 가까운 것이 있는지 살펴보세요. 브랜치에서의 쓰기는 부모에 영향을 주지 않으므로 브랜치의 시퀀스는 독립적으로 움직입니다. 문제가 생기면 브랜치를 삭제하고 다시 시작하면 됩니다.

Relyt One은 PostgreSQL 17을 실행하며, 이 글의 SQL에는 확장이 필요하지 않습니다. `\c`와 `\gexec` 줄은 `psql` 명령이고, 장애 테스트에는 직접 중지할 수 있는 Postgres 서버가 필요하므로 그 테스트는 로컬에서 실행하세요.

[무료로 시작하기](/auth/signin?ref=blog-postgres-sequence-gaps-duplicate-key)
