UUID는 충돌하지 않는가 - 잘못된 운영과 구현으로 중복을 부르는 패턴
· 업데이트: · Go Komura · UUID, 식별자, 분산 시스템, 데이터 설계, 구현
수정 이력(10건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- UUID 중복을 부르는 6가지 패턴과 복구·감사의 생각을 그림으로도 따라갈 수 있게 Mermaid 그림 17점을 추가했습니다(본문 500~750자당 1그림 규약에 맞춘 것입니다). 기존 원인 계통도와 진단 흐름도에도 캡션을 달고, 그림 번호를 일련으로 다시 매겼습니다. 본문 문장은 바꾸지 않았습니다.
- 글 서두에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참고하십시오.
- 유일 제약을 점검하는 SQL에서, 인덱스 쪽 판정이 두 종류의 오탐을 내던 것을 고쳤습니다. 첫째는 `INCLUDE` 열을 키로 세던 점으로, `CREATE UNIQUE INDEX ... ON orders(id) INCLUDE (uuid)`의 `indkey`에는 키가 아닌 `uuid`도 늘어선 탓에, `id`에 건 인덱스가 「`uuid`의 중복을 막고 있다」가 되어 있었습니다. 키는 `indkey`의 앞쪽 `indnkeyatts`개뿐이므로, 그 범위만 대조하도록 했습니다(`indkey` 첨자는 0부터입니다). 둘째는 부분 인덱스를 전체 보장으로 세던 점으로, `WHERE` 조건 밖에서는 같은 UUID가 공존할 수 있습니다. `indpred IS NULL`을 판정에 넣었습니다. 아울러 열 이름을 `single_col`에서 `prevents_dup`으로 바꾸고, 읽는 법 설명도 더했습니다.
- 체크리스트 5번의 감사 SQL을, UUID를 넣는 열을 지정해 조사하는 형태로 고쳤습니다. 테이블 단위로 제약과 인덱스를 세면, 일련번호 `id`의 기본 키나 무관한 열의 유일 인덱스까지 돌아오므로, UUID 열이 제약 없어도 「최종 방어선 있음」으로 잘못 보고합니다. 대상 열을 키에 포함하는 것만 고르고, 그 열만으로 유일한지는 `single_col` 열로 보이도록 했습니다(UUID를 포함하는 복합 키로는 단독 중복을 막지 못하기 때문입니다).
- 체크리스트 5번의 감사 SQL이 `pg_constraint`만 보고, `CREATE UNIQUE INDEX`로 건 유일 인덱스를 놓치고 있었습니다. 단독 인덱스는 `pg_index`에만 나타나므로, 이미 유일성이 있는 테이블에서도 0행이 돌아와 「최종 방어선이 없다」고 잘못 보고합니다. 제약과 유일 인덱스 양쪽을 고르는 SQL로 바꾸고, 결과 읽는 법도 고쳤습니다.
- UUIDv7 카운터에 대해, 12비트이므로 4096건을 쓸 수 있다고 읽히는 쓰기를 고쳤습니다. RFC 9562는 tick마다 카운터를 난수 값으로 초기화하라고 하므로, 1 tick 안에서 쓸 수 있는 것은 「4096 − 초깃값」입니다. 상위 비트를 예약하는 초기화 방법과, 확보 건수가 카운터 폭이 아니라 초기화 허용 범위로 정해진다는 점을 명기하고, 가늠 표도 고쳤습니다.
- 자체 PRNG 생성과 uuid5 오용에 대해, 나쁜 예와 좋은 예 코드를 추가했습니다. 확인 비용이 낮은 순으로 늘어놓은 진단 흐름도, 용어표, 채번 빈도 가늠(rand_a의 12비트로 밀리초당 4096값) 표를 추가하고, 본문 주장에 RFC 9562 섹션 번호를 병기했습니다. 체크리스트는 확인 방법과 합격선 열을 가진 표로 정리했습니다.
- 본문의 관련 글 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을, 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635214)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「UUID는 충돌하지 않는가 - 잘못된 운영과 구현으로 중복을 부르는 패턴」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635214 https://comcomponent.com/ko/blog/uuid-collision-bad-implementation-patterns/
- DOI(최신 버전)
- 10.5281/zenodo.21635214
- DOI(이 버전)
- 10.5281/zenodo.21635215
UUID를 기본 키로 쓰고 있었는데, 어느 날 duplicate key가 나옵니다.
이 순간, 꽤 높은 확률로 「UUID는 결국 부딪히는 것 아닌가」라는 이야기가 됩니다.
다만 실무에서 생기는 UUID 중복의 대부분은, UUID라는 규격 자체의 문제라기보다, 규격이 전제로 하는 생성 조건을 구현이나 운영에서 깨뜨린 경우입니다. RFC 9562에서 UUIDv4는 122비트 난수 영역을 가지며, UUIDv7도 타임스탬프 이외의 74비트를 유일성을 위한 난수나 카운터로 쓰는 전제로 정의되어 있습니다. 한편 UUIDv8은 「구현에 의존하며, 유일성을 전제로 해서는 안 된다」고 명기되어 있습니다.123
또한 Python 표준 라이브러리에서도 uuid4()는 암호학적으로 안전한 방법으로 생성된다고 설명하며, 적어도 「제대로 된 구현을 일반적인 방식으로 쓰는」 한 UUID 쪽 전제는 꽤 강합니다.4
flowchart TB
accTitle: UUID 중복 사고를 보는 관점
accDescr: 실무에서 생기는 UUID 중복의 대부분은 UUID라는 규격 자체의 문제가 아니라, 규격이 전제로 하는 생성 조건을 구현이나 운영에서 깨뜨린 경우이며, 제대로 된 구현을 일반적인 방식으로 쓰는 한 UUID 쪽 전제는 꽤 강하다는 것을 나타내는 그림.
u1["UUID 규격 자체"] -.-> u2["전제는 꽤 강하다 (122비트 난수 등)"]
u3["구현·운영"] --> u4["규격이 전제로 하는 생성 조건을 깨뜨린다"]
u4 --> u5["실무 중복 사고의 대부분은 이쪽"]
그림 1: 의심해야 할 것은 UUID의 수학이 아니라, 전제를 깨뜨리는 구현과 운영.
이 글에서는 잘못된 운영이나 구현으로 UUID가 충돌하는 전형 패턴을 재발 방지책과 함께 정리합니다. 내용은 2026년 3월 시점에서 확인할 수 있는 RFC 9562, Python 공식 문서, PostgreSQL 공식 문서를 바탕으로 합니다.546
이 글에서 쓰는 용어
이후 장에서 영어 그대로 나오는 술어를 먼저 한 줄씩 옮겨 둡니다.
| 용어 | 한 줄 의미 |
|---|---|
| PRNG | pseudo-random number generator, 의사 난수 생성기. seed에서 정해진 계열을 만들므로, 같은 seed면 같은 열이 재현됩니다 |
| CSPRNG | cryptographically secure PRNG, 암호학적으로 안전한 의사 난수 생성기. 다음 값을 예측하기 어렵게 하는 것까지 설계 목표로 한 것입니다 |
| generator state | 생성기의 상태. 난수의 내부 상태, clock sequence, 카운터 등 다음 UUID를 정하는 재료입니다 |
| carefully seeded counter | 신중하게 초기화된 카운터. UUIDv7에서 같은 밀리초 안의 일련번호를 만들기 위해 쓰는 영역을 가리킵니다 |
| clock rollback | 시각 되돌림. NTP 보정이나 수동 변경으로 시스템 시각이 이전 값으로 돌아가는 일입니다 |
| counter rollover | 카운터 자리 넘침. 카운터가 최댓값을 넘어 0으로 돌아가는 일입니다 |
| monotonicity | 단조성. 나중에 만든 UUID가 반드시 더 큰 값이 되는 성질입니다 |
| namespace | 이름 공간. UUIDv3 / v5에서 이름을 해석하는 문맥을 정하는 쪽의 UUID입니다 |
| canonicalization | 정규화. 같은 대상을 가리키는 문자열을 대소문자나 끝 슬래시까지 포함해 하나의 형태로 맞추는 처리입니다 |
1. 먼저 결론
짧게 정리하면, 위험한 것은 이 패턴입니다.
| 패턴 | 무엇이 일어나는가 | 먼저 해야 할 대책 |
|---|---|---|
| 고정 seed나 약한 PRNG로 UUIDv4처럼 보이는 값을 자체 생성한다 | 다른 프로세스나 다른 노드에서 같은 계열이 재현된다 | OS / 런타임 표준 UUID API를 쓴다 |
| fork, VM snapshot, 컨테이너 복제 뒤에 생성 상태를 그대로 이어받는다 | 난수나 카운터 상태가 되감겨 중복이 나온다 | fork 뒤 재 seed, clone 뒤 재초기화, 영속 상태 취급을 다시 본다 |
| UUIDv3 / v5를 「매번 새 ID」로 오해하고 쓴다 | 같은 namespace와 same name에서 같은 UUID가 다시 만들어진다 | 결정론적 ID임을 이해하고 용도를 한정한다 |
| UUIDv1 / v6 / v7 / v8을 자체 구현하고, clock rollback이나 node/counter를 대충 다룬다 | 고빈도 생성이나 여러 노드에서 중복되기 쉬워진다 | 기존 라이브러리를 쓰고, 독자 생성기를 줄인다 |
| UUID를 중간에 잘라 내거나, 다른 형식으로 뭉갠다 | 원래 128비트의 유일성을 스스로 버린다 | 저장·비교는 풀 길이로 한다 |
| DB 쪽에 UNIQUE / PRIMARY KEY를 두지 않는다 | 중복이 조용히 섞여 들고, 원인 조사가 늦어진다 | 스토리지 계층에서 유일 제약을 둔다 |
요컨대 UUID가 충돌했다기보다, UUID에 기대했던 유일성을 중간 설계에서 깎고 있는 경우가 많습니다.
flowchart TB
accTitle: 유일성은 어디서 깎이는가
accDescr: UUID가 충돌했다기보다, 생성·복제나 되돌림·오용·잘라 내기·제약 없음 같은 중간 설계에서 UUID에 기대했던 유일성을 깎고 있는 경우가 많다는 것을 나타내는 그림.
s0["UUID에 기대하는 유일성"] --> s1["생성 방식으로 깎는다 (자체 생성·약한 난수)"]
s0 --> s2["운영에서 깎는다 (snapshot·fork)"]
s1 --> s3["저장에서 깎는다 (잘라 내기·제약 없음)"]
s2 --> s3
s3 --> s4["결과로 중복이 나온다"]
그림 2: 충돌은 일어난다기보다, 중간 설계에서 「만들어지는」 경우가 많다.
이 글의 지식 맵
UUID 충돌 사고의 상당수는 규격 자체가 아니라, 생성이나 운용이 전제를 깨뜨리면서 발생한다고 정리하는 글입니다. RFC 9562가 정하는 UUIDv4는 CSPRNG에 의한 122비트 난수 영역을, UUIDv7은 카운터에 의한 단조성 설계를 전제로 하며, 고정 seed의 약한 PRNG로 직접 만드는 방식이나 fork·snapshot 이후 생성기 상태의 되감기, UUIDv3/v5를 신규 채번 용도로 오용하는 것, UUIDv8의 구현 의존적인 유일성에 대한 과신, 128비트를 깎아내는 잘라내기가 모두 중복을 초래합니다. RFC 스스로 진정한 global uniqueness를 절대 보장하지 않는다고 하는 이상, DB 측 UNIQUE 제약이 중복 삽입을 막는 최종 방어선이 됩니다.
flowchart LR
accTitle: UUID 충돌을 초래하는 구현 패턴의 지식 맵
accDescr: UUIDv4·UUIDv7·UUIDv3/v5·UUIDv8·UUIDv1/v6 각각이 RFC 9562에서 정의된다는 것, 약한 PRNG로 직접 만드는 방식·생성기 상태의 되감기·name-based UUID를 신규 채번에 오용하는 것·카운터 오버플로·시각 되감기·잘라내기가 모두 UUID 충돌을 초래한다는 것, CSPRNG 사용과 fork 후 재시드 및 DB 측 UNIQUE 제약이 그 대책이 된다는 것을 보여주는 그림
uuid["UUID"]
rfc9562["RFC 9562"]
csprng["CSPRNG"]
uuidv4["UUIDv4"]
weak_prng_uuid["약한 PRNG로 UUID 자체 구현"]
new_record_id_issuance["신규 채번"]
name_based_uuid["UUIDv3/v5"]
uuidv8["UUIDv8"]
uuid_collision["UUID 충돌"]
generator_state["생성기 상태"]
counter_rollover["카운터 오버플로"]
clock_rollback["시각 되감기"]
uuid_truncation["UUID 잘라내기"]
db_unique_constraint["DB 측 고유 제약"]
fork_reseed["fork 후 재seed"]
uuidv7["UUIDv7"]
monotonicity["단조성"]
namespace_uuid["namespace"]
canonicalization["정규화"]
uuidv1_v6["UUIDv1/v6"]
csprng -->|"권장되는 대응"| uuidv4
weak_prng_uuid -->|"사용은 비권장"| new_record_id_issuance
name_based_uuid -->|"사용은 비권장"| new_record_id_issuance
uuidv8 -->|"사용은 비권장"| new_record_id_issuance
weak_prng_uuid -.->|"원인이 될 수 있다"| uuid_collision
generator_state -.->|"원인이 될 수 있다"| uuid_collision
name_based_uuid -.->|"원인이 될 수 있다"| uuid_collision
counter_rollover -.->|"원인이 될 수 있다"| uuid_collision
clock_rollback -.->|"원인이 될 수 있다"| uuid_collision
uuid_truncation -->|"원인이 될 수 있다"| uuid_collision
db_unique_constraint -->|"완화한다"| uuid_collision
fork_reseed -->|"완화한다"| uuid_collision
csprng -->|"방지한다"| weak_prng_uuid
uuidv7 -.->|"이용한다"| monotonicity
counter_rollover -.->|"양립하지 않는다"| monotonicity
clock_rollback -.->|"양립하지 않는다"| monotonicity
name_based_uuid -->|"이용한다"| namespace_uuid
name_based_uuid -->|"전제로 한다"| canonicalization
uuidv4 -->|"에서 확인할 수 있다"| rfc9562
uuidv7 -->|"에서 확인할 수 있다"| rfc9562
name_based_uuid -->|"에서 확인할 수 있다"| rfc9562
uuidv8 -->|"에서 확인할 수 있다"| rfc9562
uuidv1_v6 -->|"에서 확인할 수 있다"| rfc9562
uuidv1_v6 -.->|"원인이 될 수 있다"| uuid_collision
uuid -->|"에서 확인할 수 있다"| rfc9562
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 25건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 먼저 의심해야 할 것은 「UUID의 수학」이 아니라 「생성과 운영」
UUID 이야기가 까다로워지는 이유는 버전마다 성질이 다르기 때문입니다.
- UUIDv4는 난수 기반입니다. RFC 9562에서 version / variant를 제외한 122비트가 난수로 채워집니다(Section 5.4).1
- UUIDv7은 시계열 정렬이 쉬운 구조이며, Unix 밀리초 타임스탬프에 더해 나머지를 난수나 carefully seeded counter로 구성합니다(Section 5.7).2
- UUIDv3 / v5는 name-based입니다. 같은 namespace와 같은 canonical name이면 같은 UUID가 나오는 것이 올바른 동작입니다(Section 6.5).7
- UUIDv8은 실험용·벤더 독자용이며, 유일성은 구현에 의존합니다. RFC는 「유일성을 전제로 해서는 안 된다」고 합니다(Section 5.8).3
flowchart TB
accTitle: version마다 성질이 다르다
accDescr: UUIDv4는 난수 기반, UUIDv7은 타임스탬프에 난수나 카운터를 조합한 시계열 구조, UUIDv3과 v5는 같은 입력에서 같은 값이 나오는 name-based, UUIDv8은 유일성이 구현 의존이라는 식으로, version마다 성질이 전혀 다르다는 것을 나타내는 그림.
v0["UUID의 version"] --> v1["v4: 122비트 난수"]
v0 --> v2["v7: 시각+난수·카운터"]
v1 --> v3["v3 / v5: 같은 입력이면 같은 값(name-based)"]
v2 --> v4["v8: 유일성은 구현 의존"]
그림 3: 「UUID를 쓰고 있다」만으로는 정해지지 않으며, version에 따라 성질이 전혀 다르다.
즉 「UUID를 쓰고 있다」고 해도, 그 내용이
- 표준 라이브러리의
uuid4()인지 - 자체의
timestamp + random인지 uuid5(namespace, name)인지- 겉모습만 UUIDv8인 독자 포맷인지
에 따라 이야기가 전혀 달라집니다.
flowchart TD
A[UUID 중복이 발견된다] --> B{정말 어디서 같은 값이 되었는가}
B --> C[생성기가 약하다]
B --> D[상태가 되감겼다]
B --> E[name-based UUID의 오용]
B --> F[저장 시 잘라 냈다]
B --> G[DB 쪽에 유일 제약이 없다]
C --> H[구현 실수]
D --> H
E --> H
F --> H
G --> H
그림 4: 중복 원인은 생성기·상태·오용·잘라 내기·제약 없음의 5계통으로 좁힐 수 있다.
실무에서는 이 그림의 오른쪽부터 보는 편이 빠릅니다.
조금 더 절차로 쓰면, 확인 비용이 낮은 곳부터 순서대로 지우는 것이 현실적입니다. 다음 순서면 필요한 도구가 「SQL 한 줄을 실행한다」「코드를 grep한다」「운영 절차를 확인한다」로 단계적으로 늘어나므로, 중간에 원인이 보이면 거기서 멈출 수 있습니다.
flowchart TD
S["duplicate key나 중복 데이터를 감지"] --> Q1{"1. DB에 UNIQUE / PRIMARY KEY가 있는가(8장)"}
Q1 -- 없다 --> A1["제약 없이 조용히 섞여 들었다.<br/>먼저 제약을 걸고, 중복의 입구를 막는다"]
Q1 -- 있다 --> Q2{"2. 저장과 비교에서 풀 길이 128비트를 유지하는가(7장)"}
Q2 -- 잘라 내고 있다 --> A2["prefix 비교, 64비트로 뭉개기,<br/>컬럼 길이 부족에 의한 끝자리 잘림을 의심한다"]
Q2 -- 유지하고 있다 --> Q3{"3. 생성은 표준 API인가(3장·5장)"}
Q3 -- 자체 구현 --> A3["약한 PRNG로 자체 생성, 또는<br/>name-based UUID의 채번 용도 오용을 의심한다"]
Q3 -- 표준 API --> Q4{"4. fork / snapshot / clone 뒤에 생성기 상태를 이어받지 않았는가(4장)"}
Q4 -- 이어받고 있다 --> A4["생성기 상태가 되감겨 있다"]
Q4 -- 문제 없음 --> A5["남는 것은 독자 시각 계열 구현이나<br/>UUIDv8 설계 그 자체(6장)"]
그림 5: 확인 비용이 낮은 순으로, SQL 한 줄→grep→운영 절차 확인으로 단계적으로 지워 가는 진단 흐름.
3. 패턴1: UUIDv4라고 하면서, 실제로는 약한 PRNG를 쓰고 있다
가장 흔한 것이 이것입니다.
Math.random()에 해당하는 일반 용도 PRNG로 128비트분을 만든다- 시작 시에
time()이나 PID로 seed를 넣는다 - 「UUID 형식처럼 보이는 32 hex 자리」를 직접 조립한다
겉모습은 UUID여도, 난수원이 약하면 같은 계열이 다른 프로세스나 다른 노드에서 재현됩니다.
코드로 보면 분명합니다. 다음은 Python 3 표준 라이브러리만으로 동작하는, 하면 안 되는 쪽의 예입니다.
# 나쁜 예: 일반 용도 PRNG를 seed 고정으로 돌리고, UUID 형식 문자열을 직접 조립한다
import random
def make_pseudo_uuid(seed: int) -> str:
rng = random.Random(seed) # 같은 seed면 매번 전혀 같은 계열이 된다
value = rng.getrandbits(128)
hex_digits = f"{value:032x}"
return "-".join([
hex_digits[0:8],
hex_digits[8:12],
hex_digits[12:16],
hex_digits[16:20],
hex_digits[20:32],
])
first = make_pseudo_uuid(12345)
second = make_pseudo_uuid(12345)
print(first == second) # True: 다른 프로세스에서도 다른 노드에서도, seed가 같으면 같은 값이 나온다
이 예에는 사실 문제가 두 가지 있습니다.
random.Random은 일반 용도 PRNG이므로, seed가 같으면 계열이 그대로 재현됩니다. 시작 시각이나 PID를 seed로 써도, 동시 시작이나 clone에서 충돌할 수 있습니다.- version / variant 비트를 전혀 설정하지 않으므로, 애초에 RFC 9562의 UUIDv4가 아닙니다. UUID 모양을 한 128비트 수일 뿐이라는 것이 정확한 설명입니다.
flowchart TB
accTitle: 자체 의사 UUID의 두 가지 문제
accDescr: 일반 용도 PRNG를 seed 고정으로 돌려 UUID 형식 문자열을 조립하면, 같은 seed면 계열이 그대로 재현되어 다른 프로세스나 다른 노드에서 같은 값이 나오는 데다, version과 variant 비트도 설정되지 않아 RFC 9562의 UUIDv4조차 아니며, UUID 모양을 한 128비트 수일 뿐임을 나타내는 그림.
b1["일반 용도 PRNG+고정 seed 로 자체 생성한다"] --> b2["같은 seed면 같은 계열이 재현된다"]
b1 --> b3["version / variant 비트를 설정하지 않았다"]
b2 --> b4["다른 프로세스·다른 노드에서 같은 값이 나온다"]
b3 --> b5["UUID 모양을 한 128비트 수일 뿐이다"]
그림 6: 나쁜 예의 문제는 재현성과 비트 설정의 두 가지이며, 애초에 UUIDv4가 아니다.
반면 좋은 쪽의 예는 허탈할 만큼 짧아집니다.
# 좋은 예: 표준 API를 그대로 쓴다. seed도 버전 번호도 직접 건드리지 않는다
import uuid
new_id = uuid.uuid4()
print(new_id.version) # 4: version 비트는 API 쪽이 올바르게 채워 준다
print(uuid.uuid4() != uuid.uuid4()) # True: 호출할 때마다 다른 값이 된다
RFC 9562는 UUID의 유일성과 예측 곤란성 양쪽을 위해 CSPRNG를 써야 한다고 합니다(Section 6.9 Unguessability). 이는 권고 요건(SHOULD)이며, 용도에 따라 예외 설계도 있을 수 있지만, 일반 용도 PRNG로 UUID를 자체 생성한다면 그 이유를 설명할 수 있는 상태로 두어야 합니다. 더해 process fork 같은 상태 변화 시에는 CSPRNG 상태를 적절히 재 seed해야 한다고 적혀 있습니다.8
Python의 uuid.uuid4()도 암호학적으로 안전한 방법으로 난수 UUID를 생성한다고 설명합니다.4
여기서 실무상의 결론은 단순합니다.
- UUID를 자체 생성하지 않는다
- 난수 seed를 손으로 만지지 않는다
- 표준 라이브러리나 널리 쓰이는 구현을 그대로 쓴다
「가벼우니까」「예전부터 쓰니까」로 독자 생성기를 계속 들고 있으면, 나중에 가장 비싸게 치릅니다.
flowchart TB
accTitle: 생성 쪽 실무상의 결론
accDescr: RFC 9562는 유일성과 예측 곤란성을 위해 CSPRNG를 써야 한다고 하며 fork 시 재 seed에도 언급하고, 실무상의 결론은 UUID를 자체 생성하지 않는다, 난수 seed를 손으로 만지지 않는다, 표준 라이브러리나 널리 쓰이는 구현을 그대로 쓴다는 세 가지임을 나타내는 그림.
r1["UUID를 자체 생성하지 않는다"] --> r2["난수 seed를 손으로 만지지 않는다"]
r2 --> r3["표준 라이브러리나 널리 쓰이는 구현을 쓴다"]
r3 -.-> r4["CSPRNG 이용과 fork 뒤 재 seed는 RFC의 권고"]
그림 7: 생성의 결론은 단순하며, 자체 생성하지 않고 표준 API에 맞추는 것이다.
4. 패턴2: fork, snapshot, clone으로 생성 상태를 되돌린다
두 번째로 위험한 것은 생성기 상태가 복제·되감기는 운영입니다.
RFC 9562는 fork 뒤 재 seed를 명시적으로 권하고, stable storage가 없는 구현은 clock sequence, counter, random data의 생성 빈도가 늘어나 중복 확률이 올라간다고 설명합니다(Section 6.9, Section 6.3 UUID Generator States).89
여기서 자연스럽게 나오는 실무상의 추론이 있습니다.
- VM snapshot 취득 뒤에 같은 이미지를 여러 개 복원한다
- 컨테이너 이미지 시작 시 같은 초기 상태에서 독자 생성기가 올라온다
- worker fork 뒤에 PRNG 상태나 카운터 상태를 공유해 버린다
이런 운영에서는 UUID 생성 계열이 의도치 않게 재현될 수 있습니다. 이는 RFC가 그대로 「snapshot은 위험하다」고 적은 것은 아니지만, fork 뒤 재 seed와 generator state 취급에 관한 주의에서 이끌어 낼 수 있는, 꽤 실무적인 주의점입니다.89
flowchart TB
accTitle: 상태 복제·되돌림이 중복을 만드는 흐름
accDescr: VM snapshot 복원이나 컨테이너 이미지 복제, worker fork에서 생성기 상태를 그대로 이어받으면, 난수나 카운터 상태가 되감겨 생성 계열이 의도치 않게 재현되고 중복이 나온다는 것과, fork·clone·restore 직후에 재초기화하는 것이 대책임을 나타내는 그림.
c1["snapshot 복원·컨테이너 복제·worker fork"] --> c2["생성기 상태를 그대로 이어받는다"]
c2 --> c3["난수나 카운터 상태가 되감긴다"]
c3 --> c4["생성 계열이 재현되어 중복이 나온다"]
c2 -.-> c5["대책: 직후에 재초기화하고, OS 유래 난수로 맞춘다"]
그림 8: 복제·되돌림 운영은 생성 계열째 복제해 버린다.
대책은 이 부근입니다.
- 독자 UUID 생성 상태를 오래 들고 있지 않는다
- fork / clone / restore 직후에 재초기화한다
- 가능하면 OS 유래 난수를 매번 이용하는 구현에 맞춘다
- 고빈도 생성기라면 상태 관리와 재 seed 사양을 명문화한다
5. 패턴3: UUIDv3 / v5를 「매번 새 ID」로 오해한다
UUIDv3 / v5는 충돌하기 어려운 난수 ID가 아닙니다. 같은 이름에서 같은 ID를 다시 만들 수 있는 결정론적 ID입니다.
RFC 9562는 같은 canonical format의 same name을 same namespace에서 만든 UUID는 같아야 한다고 적습니다(Section 6.5 Name-Based UUID Generation).7 따라서 이런 쓰기를 하면, 중복은 사고가 아니라 사양대로의 동작입니다.
flowchart TB
accTitle: name-based UUID의 올바른 이해
accDescr: UUIDv3과 v5는 충돌하기 어려운 난수 ID가 아니라, 같은 namespace와 같은 canonical name에서 같은 UUID를 다시 만들 수 있는 결정론적 ID이며, 신규 채번으로 썼을 때의 중복은 사고가 아니라 사양대로의 동작임을 나타내는 그림.
n1["같은 namespace+ 같은 canonical name"] --> n2["반드시 같은 UUID가 된다 (사양상의 MUST)"]
n2 --> n3["신규 채번에 쓰면 중복은 사양대로"]
n1 -.-> n4["난수 ID가 아니라 결정론적 ID"]
그림 9: v3 / v5의 중복은 사고가 아니라, 결정론적 ID라는 사양 그 자체.
uuid5(NAMESPACE_URL, "https://example.com/users/42")를 매번 「신규 채번」으로 쓴다- tenant를 namespace에 넣지 않고, 전 고객 공통 namespace + email로 채번한다
- 같은 논리 이름을 retry마다 다시 채번해도 다른 ID가 된다고 착각한다
이것도 짧은 코드로 확인할 수 있습니다. Python 3 표준 라이브러리만으로 동작합니다.
# 오용 예: uuid5를 「신규 채번」이라고 생각하고 쓴다
import uuid
url = "https://example.com/users/42"
first = uuid.uuid5(uuid.NAMESPACE_URL, url)
second = uuid.uuid5(uuid.NAMESPACE_URL, url)
print(first == second) # True: 몇 번 호출해도 같다. 이는 사양대로의 동작
# 게다가, name 정규화가 흔들리면 이번에는 반대로 다른 ID가 된다
with_slash = uuid.uuid5(uuid.NAMESPACE_URL, url + "/")
print(first == with_slash) # False: 끝 슬래시 1개로 다른 UUID
# 신규 채번이 하고 싶다면, 애초에 버전이 다르다
print(uuid.uuid4() != uuid.uuid4()) # True
uuid5를 쓴다면, name을 어떻게 정규화한 뒤 넘길지를 먼저 정하고, 그 정규화 함수를 한곳에 모으는 것이 안전합니다.
# 좋은 예: canonicalization을 함수로 빼 두고, 반드시 그것을 거친 뒤에 uuid5에 넘긴다
import uuid
# tenant마다 namespace를 나눈다. 이 값은 사양으로 고정하고, 나중에 바꾸지 않는다
TENANT_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_DNS, "tenant-a.example.com")
def canonical_user_url(user_id: int) -> str:
# 스킴, 호스트, 끝 슬래시 유무까지 포함해 하나의 형태로 정한다
return f"https://example.com/users/{user_id}"
def user_uuid(user_id: int) -> uuid.UUID:
return uuid.uuid5(TENANT_NAMESPACE, canonical_user_url(user_id))
print(user_uuid(42) == user_uuid(42)) # True: 같은 대상이면 반드시 같은 ID
반대로 name의 canonicalization이 흔들리면, 같은 대상인데 다른 UUID가 됩니다. RFC도 canonical representation 취급을 반복해 강조합니다(Section 5.5, Section 6.5).710
이 계통에서 중요한 것은,
- UUIDv3 / v5는 「중복되지 않는 채번」이 아니라 「같은 입력이면 같은 ID」
- namespace 설계를 애매하게 두지 않는다
- name의 canonicalization을 사양화한다
이 세 가지입니다.
flowchart TB
accTitle: canonicalization이 흔들리면 생기는 일
accDescr: name 정규화가 흔들리면 같은 대상인데 다른 UUID가 되고, 반대로 같은 입력이면 반드시 같은 ID가 되므로, namespace 설계를 애매하게 두지 않고, 정규화 함수를 한곳에 모아 반드시 그것을 거친 뒤에 uuid5에 넘기는 것이 안전함을 나타내는 그림.
m1["name의 정규화"] --> m2{"흔들리지 않는가"}
m2 -->|"정규화 함수를 한곳에 모아 반드시 거친다"| m3["같은 대상은 반드시 같은 ID"]
m2 -.->|"끝 슬래시 등이 흔들린다"| m4["같은 대상인데 다른 UUID"]
m1 -.-> m5["namespace 설계도 사양으로 고정한다"]
그림 10: namespace와 정규화를 사양화하지 않으면, 반대 방향 사고(같은 대상에 다른 ID)도 생긴다.
6. 패턴4: 시각 계열 UUID나 UUIDv8을 자체 구현하고 있다
UUIDv1 / v6 / v7 / v8은 겉모습만 흉내 내면 위험합니다.
6.1 UUIDv1 / v6에서 node나 clock sequence를 대충 다룬다
RFC 9562에서 UUIDv6은 DB locality 개선을 위해 UUIDv1을 재배열한 것이며, clock sequence나 node를 다룹니다(Section 5.6). 더해 분산 환경의 node collision resistance(Section 6.4)나 state 유지(Section 6.3)에 대해 주의가 여러 가지 있습니다.11912
게다가 RFC는 가상 머신이나 컨테이너의 등장으로 MAC address의 유일성은 이제 보장되지 않는다고까지 적습니다.5
그래서,
- MAC 주소이니 유일할 것이라고 단정한다
- node ID를 이미지에 박아 넣어 복제한다
- clock sequence를 재시작마다 고정값으로 되돌린다
같은 설계는 위험합니다.
flowchart TB
accTitle: UUIDv1 / v6에서 위험한 단정
accDescr: 가상 머신이나 컨테이너의 등장으로 MAC 주소의 유일성은 이제 보장되지 않는다고 RFC가 적으며, MAC 주소이니 유일하다고 단정하는 것, node ID를 이미지에 박아 넣어 복제하는 것, clock sequence를 재시작마다 고정값으로 되돌리는 설계는 위험함을 나타내는 그림.
d1["MAC이니 유일하다고 단정한다"] -.-> d4["위험한 설계"]
d2["node ID를 이미지에 박아 넣어 복제"] -.-> d4
d3["clock sequence를 고정값으로 되돌린다"] -.-> d4
d4 --> d5["가상화 환경에서는 MAC의 유일성은 보장되지 않는다"]
그림 11: v1 / v6의 전제였던 MAC 유일성은, 가상화 시대에는 성립하지 않는다.
6.2 UUIDv7을 자체 생성해 counter rollover나 clock rollback을 방치한다
UUIDv7은 꽤 실용적이지만, RFC는 고빈도 생성 시의 monotonicity와 counter handling을 자세히 적습니다(Section 6.2 Monotonicity and Counters). clock rollback이나 counter rollover로 중복을 knowingly return해서는 안 된다고도 명시되어 있습니다.213
즉,
- 같은 밀리초 안에서 대량 채번하는데 counter 설계가 없다
- 시각이 되돌아갔을 때 아무것도 하지 않고 생성을 계속한다
- 여러 프로세스가 같은 internal counter를 따로따로 초기화한다
같은 구현은 위험합니다.
flowchart TB
accTitle: UUIDv7 자체 생성에서 방치하면 위험한 점
accDescr: 같은 밀리초 안에서 대량 채번하는데 카운터 설계가 없는 것, 시각이 되돌아갔을 때 아무것도 하지 않고 생성을 계속하는 것, 여러 프로세스가 같은 내부 카운터를 따로따로 초기화하는 구현은 위험하며, RFC는 clock rollback이나 counter rollover로 중복인 줄 알면서 값을 반환해서는 안 된다고 함을 나타내는 그림.
e1["카운터 설계 없이 대량 채번"] -.-> e4["중복되기 쉬운 자체 v7"]
e2["clock rollback을 방치"] -.-> e4
e3["같은 카운터를 따로따로 초기화"] -.-> e4
e4 --> e5["중복인 줄 알고 반환하는 것은 금지"]
그림 12: v7 자체 생성에서는 카운터와 시각 되돌림 취급이 생명선이 된다.
어느 정도의 채번 빈도부터 신경 써야 하는가
「자기 시스템이 해당하는지」를 판단하려면 비트 할당을 보는 것이 빠릅니다.
RFC 9562의 UUIDv7은 48비트 밀리초 타임스탬프 뒤에 rand_a(12비트), 다시 variant 뒤에 rand_b(62비트)가 이어지는 구조입니다.2
그리고 Section 6.2에서는 단조성을 유지하는 방법으로, rand_a의 12비트를 전용 카운터로 쓰는 방법(Method 1), rand_b 쪽을 「난수로 초기화된 카운터」로 쓰는 방법(Method 2), rand_a의 최대 12비트를 밀리초보다 가는 시각 정밀도로 바꾸는 방법(Method 3)이 나와 있습니다.13
여기서 가늠은 이렇게 읽습니다.
여기서 12비트 = 4096건까지 쓸 수 있다고 단순하게 읽지 마십시오. RFC 9562는 카운터를 예측하기 어렵게 하려고 tick마다 난수 값으로 초기화하라고 합니다.13 초깃값이 4000이면, 그 tick에서 쓸 수 있는 것은 나머지 96건뿐입니다. 1 tick 안에서 쓸 수 있는 건수는 「4096 − 초깃값」이며, 4096이 아닙니다.
RFC는 이 대책으로, 카운터의 상위 비트를 0으로 두고, 초기화하는 것은 하위 쪽만으로 하는 방법을 듭니다.13 예를 들어 최상위 1비트를 0으로 고정하고 하위 11비트만 난수로 하면, 초깃값은 최대여도 2047이므로, 어느 tick에서든 2048건은 반드시 확보할 수 있습니다. 확보할 수 있는 건수는 카운터 폭이 아니라 초기화 허용 범위로 정해집니다.
이를 바탕으로 하면, 가늠은 이렇게 됩니다.
| 생성기 1개·1밀리초당 채번 수 | 어떻게 볼 것인가 |
|---|---|
| 몇 건~수십 건 | 상위 비트를 예약하는 초기화로 두면, 1 tick 안에서 다 쓰지 않습니다 |
| 수백 건 | 초기화 방법에 따라서는 여기서 한 바퀴 돕니다. 초깃값 상한을 정하고, rollover 시 동작(타임스탬프를 진행한다 / 기다린다)을 사양으로 적어 둘 수준입니다 |
| 1000건 이상 | 상위 비트를 예약해도 여유가 줄어듭니다. rand_b 쪽도 쓰는 Method 2나, 타임스탬프를 진행하는 설계를 전제로 하십시오 |
「초당 수백만 건을 내지 않으니 rollover는 무관하다」고는 말할 수 없습니다. 상한은 초기화 설계로 정해지므로, 채번 빈도가 중간이어도, 초깃값을 12비트 전역에서 가져오면 한 바퀴 돌 수 있습니다. rollover했을 때 무엇을 할지 정하지 않은 채로 출하하면, 거기서 중복이 나옵니다. 먼저 초기화 허용 범위를 정하고, 다음에 rollover 시 동작을 정하는 순서로 설계하십시오.
flowchart TB
accTitle: 카운터로 확보할 수 있는 건수가 정해지는 방식
accDescr: RFC 9562는 카운터를 tick마다 난수 값으로 초기화하라고 하므로, 1 tick 안에서 쓸 수 있는 건수는 4096에서 초깃값을 뺀 수이며, 상위 비트를 0으로 예약하고 하위 쪽만 난수로 하면 최소 확보 건수가 정해지므로, 먼저 초기화 허용 범위를 정하고 다음에 rollover 시 동작을 정하는 순서로 설계함을 나타내는 그림.
f1["tick마다 난수 값으로 초기화한다"] --> f2["쓸 수 있는 것은 4096−초깃값"]
f2 --> f3["상위 비트를 예약하면 최소 건수를 확보할 수 있다"]
f3 --> f4["1. 초기화 허용 범위를 정한다"]
f4 --> f5["2. rollover 시 동작을 정한다"]
그림 13: 확보할 수 있는 건수는 카운터 폭이 아니라, 초기화 허용 범위로 정해진다.
다만, 여기서 안심하고 끝내지 않는 편이 좋은 점이 두 가지 있습니다.
- clock rollback은 채번 빈도와 무관하게 일어납니다. NTP 보정, VM 일시 중단 복귀, 수동 시각 변경으로 시각은 흔히 돌아갑니다. 초당 10건만 만드는 시스템이어도 대책은 필요합니다.
- 「생성기 1개당」이므로, 프로세스 수를 곱해야 합니다. 100개 프로세스가 각각 독립 카운터를 들고, 게다가 같은 초깃값에서 시작하면, 프로세스당 채번 수가 적어도 계열은 겹칩니다.
6.3 UUIDv8을 「새로운 UUID 규격」 정도의 가벼운 마음으로 쓴다
UUIDv8은 편리해 보이지만, RFC 9562는 꽤 분명하며, UUIDv8의 유일성은 구현에 의존하고, 전제로 해서는 안 된다고 합니다(Section 5.8).3
그래서,
- timestamp를 넣는다
- shard id를 넣는다
- 어떤 업무 의미를 넣는다
- 나머지는 대충 random을 넣는다
라는 「자사 독자 UUID」는, 그 설계서가 UUID 유일성 사양 그 자체입니다. 리뷰 없이 넣기에는 너무 위험합니다.
flowchart TB
accTitle: UUIDv8을 쓴다는 것의 의미
accDescr: RFC 9562는 UUIDv8의 유일성은 구현에 의존하며 전제로 해서는 안 된다고 하므로, timestamp나 shard id나 업무 의미를 넣는 자사 독자 UUID에서는 그 설계서 자체가 UUID 유일성 사양 그 자체가 되며, 리뷰 없이 도입하기에는 너무 위험함을 나타내는 그림.
g1["UUIDv8로 독자 포맷을 짠다"] --> g2["유일성은 구현 의존이고 규격은 보장하지 않는다"]
g2 --> g3["자사 설계서가 유일성 사양 그 자체가 된다"]
g3 --> g4["리뷰 없는 도입은 너무 위험하다"]
그림 14: v8을 고르는 것은, 유일성 책임을 스스로 맡는 것이다.
7. 패턴5: UUID를 중간에 짧게 만들어 버린다
생성까지는 올바르더라도, 저장이나 비교 단계에서 깨뜨리는 일이 있습니다.
전형 예를 듭니다.
- 앞 8자만 외부 키 대용으로 쓴다
- 128비트 UUID를 64비트 정수로 뭉갠다
- 문자열 컬럼 길이가 부족해 끝이 잘린다
- 로그나 화면 표시의 단축 표현을 그대로 유일 키로 취급한다
여기서 중요한 것은, 표현을 바꾸는 것 자체가 나쁜 것은 아니다라는 점입니다.
- 하이픈을 뺀다
- 소문자 / 대문자를 맞춘다
- 바이너리 16바이트로 둔다
처럼 128비트를 떨어뜨리지 않는 변환은 문제 없습니다. 위험한 것은 유일성의 재료 그 자체를 깎는 변환입니다.
특히 「사람이 보기 쉬운 단축 ID」를 따로 만들었는데, 그것이 어느새 본래 UUID보다 우선되는 설계는 사고로 이어지기 쉽습니다.
flowchart TB
accTitle: 표현 변환과 유일성을 깎는 변환의 차이
accDescr: 하이픈을 빼기, 대소문자를 맞추기, 바이너리 16바이트로 두기처럼 128비트를 떨어뜨리지 않는 변환은 문제없고, 앞 8자만 쓰기, 64비트 정수로 뭉개기, 컬럼 길이 부족으로 끝이 잘리는 것처럼 유일성의 재료 그 자체를 깎는 변환이 위험함을 나타내는 그림.
h0{"그 변환은 128비트를 유지하는가"}
h0 -->|"유지한다"| h1["하이픈 제거·대소문자 통일·바이너리화"]
h0 -.->|"깎는다"| h2["앞 8자·64비트화·끝자리 잘림"]
h1 --> h3["문제 없는 변환"]
h2 --> h4["유일성을 스스로 버리는 위험한 변환"]
그림 15: 나쁜 것은 표현을 바꾸는 것이 아니라, 유일성의 재료를 깎는 것이다.
8. 패턴6: DB 쪽에 유일 제약이 없다
그리고, 이것이 특히 중요합니다.
UUID가 충분히 충돌하기 어렵더라도, 정말로 중복을 허용할 수 없다면, 저장 위치에도 유일 제약을 두어야 합니다.
PostgreSQL 공식 문서에서는 unique constraint가 열이나 열 집합의 값이 테이블 전체에서 유일함을 보장하고, primary key는 unique이며 not null인 행 식별자가 된다고 설명합니다.6
RFC 9562도 UUID는 구현상 충분한 유일성을 제공할 수 있는 한편, 진정한 global uniqueness를 절대 보장할 수는 없다고 합니다. 더해 collision impact가 높은 용도에서는 더 강한 대책을 취해야 한다고 합니다(Section 6.7 Collision Resistance, Section 6.8 Global and Local Uniqueness).14
실무에서는 이 조합이 기본이 됩니다.
- UUID는 충돌하기 어려운 ID로 쓴다
- DB는 UNIQUE / PRIMARY KEY로 최종 방어선을 둔다
- 중복 시의 retry / idempotency / incident logging을 설계한다
UUID를 쓰는 것과 유일 제약을 두지 않는 것은 동의어가 아닙니다.
flowchart TB
accTitle: DB 유일 제약이라는 최종 방어선
accDescr: UUID는 충분히 충돌하기 어려운 ID로 쓰면서도, RFC 스스로 진정한 global uniqueness를 절대 보장할 수 없다고 하는 이상, 정말로 중복을 허용할 수 없다면 DB의 UNIQUE나 PRIMARY KEY로 최종 방어선을 두고, 중복 시의 retry나 idempotency나 incident logging까지 설계하는 것이 기본 조합임을 나타내는 그림.
i1["UUID는 충돌하기 어려운 ID로 쓴다"] --> i2["그래도 절대 보장은 할 수 없다(RFC 명기)"]
i2 --> i3["DB의 UNIQUE / PRIMARY KEY 를 최종 방어선으로 한다"]
i3 --> i4["중복 시의 retry·idempotency·로그까지 설계한다"]
그림 16: UUID를 쓰는 것과, 유일 제약을 두지 않는 것은 동의어가 아니다.
9. 실무용 체크리스트
마지막으로, 도입이나 감사에서 그대로 쓰기 쉬운 형태로 정리합니다. 1장의 결론 표가 「무엇이 위험한가」의 목록이었던 반면, 이쪽은 자기 시스템을 어떻게 확인할 것인가의 목록입니다.
| # | 확인할 것 | 확인 방법 | 합격선 |
|---|---|---|---|
| 1 | UUID를 자체 생성하고 있지 않은가 | 리포지토리 전체에서 getrandbits, Math.random, new Random(, %032x 같은 자체 조립 흔적을 grep한다 |
UUID 생성은 uuid4() / uuid7() 등 표준 API 경유만으로 되어 있다 |
| 2 | UUID version을 사양으로 정했는가 | 저장된 데이터에서 version을 센다. PostgreSQL이면 substring(id::text from 15 for 1)이 version 자리 |
써도 되는 version이 문서화되어 있고, 실데이터도 그것과 일치한다 |
| 3 | seed와 generator state 취급을 점검했는가 | 시작 스크립트, Dockerfile, snapshot / clone 절차서에 「재초기화」 서술이 있는지 읽는다. worker를 fork하는 곳을 grep한다 | fork, worker 재시작, snapshot, clone 직후에 생성기를 다시 만드는 절차가 명기되어 있다 |
| 4 | 저장 시 풀 길이를 유지하는가 | 컬럼 정의를 확인한다. 더해 코드 쪽에서 [:8], substring(, Left(, ToString("N").Substring 같은 잘라 내기를 grep한다 |
저장도 비교도 128비트 그대로. 단축 표현은 표시 전용으로 한정되어 있다 |
| 5 | DB에 UNIQUE / PRIMARY KEY가 있는가 | 아래 SQL로, UUID를 넣는 열 이름을 지정해, 제약과 유일 인덱스 양쪽을 목록한다(테이블 단위로 세면 일련번호 기본 키를 세게 된다) | 그 열에 대해 single_col이 true인 행이 1개 이상 있다 |
| 6 | 중복을 관측할 수 있는가 | duplicate key에 해당하는 예외를 삼키는 곳을 grep한다. 로그에 generator / node / deployment가 나오는지 실물을 본다 | 중복 시 예외가 기록되고, 어디서 나왔는지 추적할 수 있다 |
5번 확인은 PostgreSQL이면 다음 한 줄로 끝납니다.
-- public.orders의 uuid 열에 유일성이 걸려 있는지를 목록한다.
-- 테이블 이름과 열 이름은 대상에 맞춰 바꾼다
WITH target AS (
SELECT attrelid, attnum
FROM pg_attribute
WHERE attrelid = 'public.orders'::regclass
AND attname = 'uuid' -- ← 조사할 열
AND NOT attisdropped
)
SELECT c.conname AS name,
c.contype::text AS kind, -- p = 기본 키 / u = 유일 제약
array_length(c.conkey, 1) = 1 AS prevents_dup, -- 그 열만으로 중복을 막을 수 있는가
pg_get_constraintdef(c.oid) AS definition
FROM pg_constraint AS c JOIN target AS t ON c.conrelid = t.attrelid
WHERE c.contype IN ('p', 'u')
AND t.attnum = ANY (c.conkey) -- 그 열을 키에 포함하는 것만
UNION ALL
SELECT i.relname AS name,
'i' AS kind, -- i = 제약을 동반하지 않는 유일 인덱스
-- 부분 인덱스(indpred가 비NULL)는 조건에 맞는 행 안에서만 유일을 보장한다
x.indnkeyatts = 1 AND x.indpred IS NULL AS prevents_dup,
pg_get_indexdef(x.indexrelid) AS definition
FROM pg_index AS x
JOIN pg_class AS i ON i.oid = x.indexrelid
JOIN target AS t ON x.indrelid = t.attrelid
WHERE x.indisunique
AND EXISTS ( -- INCLUDE 열은 키가 아니므로 세지 않는다.
SELECT 1 -- indkey의 앞쪽 indnkeyatts개만 본다
FROM generate_series(0, x.indnkeyatts - 1) AS k(i) -- indkey는 0부터
WHERE x.indkey[k.i] = t.attnum)
AND NOT EXISTS ( -- 제약에 묶인 인덱스는 위에서 내고 있다
SELECT 1 FROM pg_constraint AS c2 WHERE c2.conindid = x.indexrelid);
읽는 법은 2단계입니다.
kind는 기본 키가p, 유일 제약이u,CREATE UNIQUE INDEX로 건 제약을 동반하지 않는 유일 인덱스가i입니다.15prevents_dup이true인 행이 하나도 없으면, 그 열의 중복은 막히지 않습니다.
flowchart TB
accTitle: 유일성 확인 SQL의 두 계통
accDescr: 유일성 확인은, 기본 키나 유일 제약이 나타나는 pg_constraint 쪽과, 제약을 동반하지 않는 CREATE UNIQUE INDEX가 나타나는 pg_index 쪽 양쪽을 조회하고, 어느 형태든 prevents_dup으로 그 열 단독의 유일성을 판정하는 구성임을 나타낸다.
chk["UUID 열의 유일성을 확인"] --> con["pg_constraint〔p와 u〕"]
chk --> idx["pg_index〔유일 인덱스〕"]
con --> pd["prevents_dup으로 단독 유일인지 판정"]
idx --> pd
그림 17: 제약과 유일 인덱스는 다른 카탈로그에 나타나므로, 양쪽을 조회해 prevents_dup으로 판정한다.
「테이블에 유일 제약이 있는가」로 판정하지 마십시오. 기본 키가 일련번호 id에만 있고 UUID 열은 제약 없음, 이라는 설계는 아주 흔합니다. 테이블 단위로 세면, 그 상태를 「최종 방어선 있음」으로 잘못 보고합니다. 같은 이유로, UUID를 포함하는 복합 키가 있어도 UUID 단독 중복은 통과하므로, prevents_dup을 봐야 합니다.
하나 더, pg_constraint만 보지 마십시오. 유일성을 CREATE UNIQUE INDEX로 넣은 설계도 흔하며, 그쪽은 pg_index에만 나타납니다. 제약만 세어 「최종 방어선이 없다」고 보고하면, 이미 있는 인덱스를 놓치고 불필요한 스키마 변경으로 이야기가 진행됩니다. 위의 한 줄이면 어느 형태든 잡습니다(indnkeyatts는 PostgreSQL 11 이후입니다).
인덱스 쪽 판정은, 단순히 「indkey에 그 열이 들어 있는가」로 쓰면 두 종류의 오탐을 냅니다. 둘 다 「막고 있다」고 잘못 보고하는 쪽으로 기울므로, 그대로 합격 도장을 찍게 됩니다.
INCLUDE열을 키로 센다.CREATE UNIQUE INDEX ... ON orders(id) INCLUDE (uuid)의indkey에는, 키가 아닌uuid도 늘어섭니다.15 한편indnkeyatts는 키 개수(이 예에서는 1)이므로,indnkeyatts = 1과uuid를 포함하는 것이 동시에 성립하고,id에 건 인덱스가 「uuid의 중복을 막고 있다」가 됩니다. 키는indkey의 앞쪽indnkeyatts개뿐이므로, 그 범위만 대조합니다(indkey첨자는 0부터입니다15)- 부분 인덱스를 전체 보장으로 센다.
CREATE UNIQUE INDEX ... ON orders(uuid) WHERE active는, 조건에 맞는 행 안에서만 유일을 보장합니다.active가 아닌 행끼리, 또는 인덱스에 실리는 행과 실리지 않는 행 사이에서, 같은 UUID가 흔히 공존할 수 있습니다.indpred가 비NULL이면 그것이 부분 인덱스이므로,15 전체 중복 방지로는 세지 않습니다(인덱스 자체는 목록에 남으므로,definition의WHERE절을 보고 조건부 방어선으로 다룰 수 있습니다)
flowchart TB
accTitle: 유일성 감사에서 오탐하기 쉬운 두 가지 함정
accDescr: 감사에서 테이블 단위로 제약을 세면 일련번호 기본 키를 최종 방어선으로 오보하고, pg_constraint만 보면 CREATE UNIQUE INDEX로 건 유일 인덱스를 놓치며, 더해 INCLUDE 열을 키로 세는 오류와 부분 인덱스를 전체 보장으로 세는 오류가 둘 다 막고 있다는 쪽으로 기울므로, UUID 열을 지정해 prevents_dup을 봐야 함을 나타내는 그림.
j1["UUID 열을 지정해 조사한다"] --> j2["제약과 인덱스 양쪽을 고른다"]
j2 --> j3["prevents_dup을 본다"]
j1 -.-> j4["테이블 단위면 오보"]
j2 -.-> j5["INCLUDE 열·부분 인덱스의 오탐"]
그림 18: 감사는 「열을 지정해, 그 열만으로 유일한가」를 보지 않으면 오탐한다.
10. 정리
UUID 충돌 사고는 대개 UUID가 약한 것이 아니라, UUID의 전제를 구현이나 운영에서 깨뜨리고 있는 곳에서 시작됩니다.
- 약한 난수로 자체 생성한다
- fork나 snapshot 뒤 상태를 되돌린다
- name-based UUID를 채번 용도로 쓴다
- v7이나 v8을 가볍게 자체 구현한다
- 중간에 짧게 만들어 유일성을 버린다
- DB 쪽 유일 제약을 뺀다
이 부근을 해 버리면, 이쪽에서 충돌하기 쉬운 상황을 만들러 가는 것과 거의 다르지 않습니다.
중복을 찾으면, 먼저 의심해야 할 것은 UUID의 수학보다 생성기, 상태 관리, 저장 형식, 제약 설계입니다. 그 순서로 보면, 대개 원인은 꽤 좁혀집니다.
flowchart TB
accTitle: 중복을 찾았을 때 의심하는 순서
accDescr: 중복을 찾으면, UUID의 수학보다 생성기, 상태 관리, 저장 형식, 제약 설계 순으로 의심하면, 대개 원인은 꽤 좁혀진다는 것을 나타내는 그림.
k0["중복을 찾았다"] --> k1["1. 생성기를 의심한다"]
k1 --> k2["2. 상태 관리를 의심한다"]
k2 --> k3["3. 저장 형식을 의심한다"]
k3 --> k4["4. 제약 설계를 의심한다"]
k0 -.-> k5["UUID의 수학은 마지막이어도 된다"]
그림 19: 이 순서로 보면, UUID 중복의 원인은 거의 좁힐 수 있다.
11. 관련 글
12. 참고 자료
-
IETF RFC 9562, Section 5.4 UUID Version 4. UUIDv4의 122비트 난수 영역에 대해. ↩ ↩2
-
IETF RFC 9562, Section 5.7 UUID Version 7. UUIDv7의 timestamp, random bits, counter의 사고방식에 대해. ↩ ↩2 ↩3 ↩4
-
IETF RFC 9562, Section 5.8 UUID Version 8. UUIDv8의 유일성은 구현에 의존하며, 전제로 해서는 안 된다는 점에 대해. ↩ ↩2 ↩3
-
Python 3.14 documentation,
uuidmodule.uuid4()의 cryptographically-secure generation,uuid5()의 deterministic behavior,uuid7()/uuid8()의 성질에 대해. ↩ ↩2 ↩3 -
IETF RFC 9562, Universally Unique IDentifiers (UUIDs). UUID 형식, 각 version, best practices 전체의 기준 문서입니다. ↩ ↩2
-
PostgreSQL documentation, Constraints. UNIQUE 제약과 PRIMARY KEY에 의한 유일성 보장에 대해. ↩ ↩2
-
IETF RFC 9562, Section 6.5 Name-Based UUID Generation. same namespace + same name이 같은 UUID가 되는 것, canonicalization의 중요성에 대해. ↩ ↩2 ↩3
-
IETF RFC 9562, Section 6.9 Unguessability. CSPRNG 이용과 fork 뒤 재 seed에 대해. ↩ ↩2 ↩3
-
IETF RFC 9562, Section 6.3 UUID Generator States. stable storage나 generator state 취급에 대해. ↩ ↩2 ↩3
-
IETF RFC 9562, Section 5.5 UUID Version 5. namespace + canonical name에 기반한 name-based UUID 사양에 대해. ↩
-
IETF RFC 9562, Section 5.6 UUID Version 6. UUIDv6의 node / clock sequence / DB locality에 대해. ↩
-
IETF RFC 9562, Section 6.4 Distributed UUID Generation. 분산 환경의 node collision resistance에 대해. ↩
-
IETF RFC 9562, Section 6.2 Monotonicity and Counters. clock rollback, counter rollover, batch generation 시의 주의에 대해. ↩ ↩2 ↩3 ↩4
-
IETF RFC 9562, Sections 6.7 and 6.8. collision resistance와 global uniqueness의 사고방식에 대해. ↩
-
PostgreSQL documentation, pg_constraint.
contype이p는 primary key,u는 unique constraint를 나타낸다는 것,conrelid가 제약 대상 테이블을 가리킨다는 것,conindid가 제약을 받치는 인덱스를 가리킨다는 것에 대해. 제약을 동반하지 않는 유일 인덱스는 pg_index 쪽에만 나타납니다(indrelid가 대상 테이블,indisunique가 유일한지 여부). ↩ ↩2 ↩3 ↩4
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows 가상화의 심층(제3회) ── 수초 만에 시작되는 가상 머신: WSL2·Windows Sandbox·컨테이너가 가벼운 이유
WSL2와 Windows Sandbox가 수초 만에 시작되어 가벼운 이유는 무엇인가. 동적 베이스 이미지, 다이렉트 맵, 메모리의 동적 배분, Hyper-V 격리 컨테이너까지 구조부터 설명합니다.
Windows 가상화의 심층(제2회) ── 커널에서도 보이지 않는 메모리: VBS·HVCI·Credential Guard의 구조
대응 하드웨어에 클린 설치하면 기본으로 켜지는 VBS는, 하이퍼바이저와 SLAT로 커널보다 강한 격리를 만듭니다. VTL, 보안 커널, HVCI, Credential Guard의 구조를 설명합니다.
Windows 가상화의 심층(제1회) ── 지금 쓰는 Windows는 어디에서 실행되는가: 하이퍼바이저와 파티션
Hyper-V를 켜면 호스트 Windows 자체가 루트 파티션으로서 하이퍼바이저 위에서 실행됩니다. VT-x, SLAT, VMBus의 역할까지 가상화의 기반을 설명합니다.
Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」 병렬 처리
네이티브 코드에서 CreateThread를 마구 늘리고 있지는 않은가요. Vista에서 개편된 Win32 스레드 풀 API의 work·timer·wait·io 네 객체, 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 정보를 바탕으로 설명합니다.
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows의 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 해설합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .NET NamedPipeStream까지 1차 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
기술 상담 & 설계 리뷰
UUID 충돌 이야기는 규격 이해만이 아니라 난수원, snapshot 운영, DB 제약, idempotency까지 걸치므로, 설계 리뷰나 기술 상담으로 정리할 가치가 있습니다.
장애 조사 & 원인 분석
실제 중복 사고에서는 「UUID가 나쁜지」「구현이나 운영이 나쁜지」를 가릴 필요가 있으며, 조사 관점 정리와 재발 방지책 설계가 중요합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- UUID는 충돌하지 않나요?
- 일반적인 방식으로 쓰면 충분히 충돌하기 어렵습니다. RFC 9562에서 UUIDv4는 122비트 난수 영역을 가지며, UUIDv7도 타임스탬프 이외의 74비트를 유일성을 위한 난수나 카운터로 쓰는 전제로 정의되어 있습니다. Python의 uuid4()처럼 제대로 된 구현을 일반적인 방식으로 쓰는 한 전제는 꽤 강합니다. 다만 RFC 9562 스스로 UUID가 진정한 global uniqueness를 절대 보장할 수는 없다고 하며, 충돌 영향이 큰 용도에서는 더 강한 대책을 취해야 한다고 합니다. 그래서 DB 쪽 유일 제약이 최종 방어선이 됩니다.
- UUID 중복은 왜 생기나요?
- 실무에서 생기는 UUID 중복의 대부분은 규격 자체의 문제가 아니라, 규격이 전제로 하는 생성 조건을 구현이나 운영에서 깨뜨린 경우입니다. 전형 패턴은 고정 seed나 약한 PRNG로 UUID를 자체 생성하는 것, fork·VM snapshot·컨테이너 복제 뒤에 생성기 상태를 되돌리는 것, UUIDv3 / v5를 「매번 새 ID」로 오해하고 쓰는 것, 시각 계열 UUID나 UUIDv8을 자체 구현해 clock rollback이나 counter를 대충 다루는 것, UUID를 중간에 잘라 내 유일성을 버리는 것, DB에 유일 제약을 두지 않아 중복이 조용히 섞여 드는 것, 이 여섯 가지입니다.
- UUIDv3나 UUIDv5를 쓰면 중복하나요?
- UUIDv3 / v5는 충돌하기 어려운 난수 ID가 아니라, 같은 이름에서 같은 ID를 다시 만들 수 있는 결정론적 ID입니다. RFC 9562는 같은 namespace와 같은 canonical name에서 만든 UUID는 같아야 한다고 정하므로, 같은 입력에서 같은 UUID가 나오는 것은 사고가 아니라 사양대로입니다. 따라서 신규 채번 용도로 쓰는 것은 오용입니다. 반대로 name의 canonicalization이 흔들리면, 같은 대상인데 다른 UUID가 됩니다. namespace 설계와 name 정규화 규칙을 사양으로 명문화하는 것이 중요합니다.
- UUID 중복을 막으려면 무엇을 하면 되나요?
- 먼저 UUID를 자체 생성하지 않고, uuid4() / uuid7() 같은 표준 API나 널리 쓰이는 구현에 맞춥니다. 쓸 UUID version을 사양으로 정하고, fork·worker 재시작·snapshot·clone 뒤에 생성기 상태를 이어받지 않게 합니다. 저장과 비교는 풀 길이 128비트를 유지하고, prefix 비교나 단축 표시를 본래 키로 쓰지 않습니다. 그 위에서, 정말로 중복을 허용할 수 없다면 DB에 UNIQUE / PRIMARY KEY 제약을 두고, duplicate key를 삼키지 않으며 어느 generator나 node에서 나왔는지 추적할 수 있게 둡니다.