개발

멱등성 키를 UUID v4 대신 MariaDB UUID_v7() 로 만들기

  • 19th October 2026
  • 5 min read

결제·주문 API에서 같은 요청이 두 번 처리되지 않게 하려고 멱등성 키를 UNIQUE 칼럼에 넣는 경우가 많다. 이 키를 UUID v4로 만들면 값이 완전히 무작위라서, 새 키가 인덱스 끝이 아니라 B-트리 곳곳에 끼어든다. MariaDB 11.7부터는 앞부분에 시각이 들어간 UUID v7을 UUID_v7() 함수 하나로 만들 수 있다. 11.8 LTS에도 들어 있다. 도커로 띄운 MariaDB에서 v4와 v7을 500만 건씩 넣어 비교했다.

쓰는 법

SELECT UUID_v7();
-- 01a151b9-14a4-7080-8cad-af746d6067b6
--  └─ 앞 12자리(48비트) = 유닉스 밀리초, 13번째 글자 7 = 버전

앞 48비트가 밀리초 시각이라 SQL로 생성 시각을 꺼낼 수 있다.

SELECT FROM_UNIXTIME(CONV(LEFT(REPLACE(k, '-', ''), 12), 16, 10) / 1000) FROM orders;

함수 지원 여부를 버전별로 확인했다. 11.6.2와 11.4.13은 ERROR 1305 FUNCTION uuid_v7 does not exist였고, 11.7.2와 11.8.9에서는 값이 나왔다. 무작위 버전인 UUID_v4()도 11.7에서 같이 생겼다.

같은 밀리초 안에서도 순서가 지켜진다

한 문장으로 100만 개를 만들었더니 1,489밀리초에 걸쳐 나왔다. 밀리초당 평균 672개, 많게는 826개다. 그래도 만든 순서와 값의 정렬 순서가 한 번도 어긋나지 않았다. 같은 밀리초 안에서는 뒤쪽 비트를 카운터처럼 올려서 순서를 지킨다. 중복도 0개였다.

INSERT INTO g SELECT seq, UUID_v7() FROM seq_1_to_1000000;
SELECT SUM(nxt <= u) FROM (SELECT u, LEAD(u) OVER (ORDER BY n) nxt FROM g) x;  -- 0

500만 건 넣기: v4와 v7 비교

1만 건씩 500번, 모두 500만 건을 넣었다. 표는 두 가지 모양으로 만들었다. 하나는 키를 기본 키로 쓰는 표이고, 다른 하나는 정수 자동 증가 기본 키에 키를 UNIQUE로 붙인 표다. 멱등성 키는 대개 두 번째 모양이다. 칼럼 타입은 MariaDB의 UUID다.

표버퍼 풀 64MB버퍼 풀 1.5GB.ibd 파일 크기
기본 키 = v4310.2초21.1초608MB
기본 키 = v710.3초10.9초428MB
UNIQUE = v4230.1초21.6초700MB (UNIQUE 인덱스 228MB)
UNIQUE = v712.8초13.9초616MB (UNIQUE 인덱스 143MB)

인덱스가 메모리에 다 올라가는 1.5GB에서도 v4가 1.5~2배 느렸다. 인덱스가 버퍼 풀보다 커지는 64MB에서는 차이가 18~30배로 벌어졌다. v4는 넣을 때마다 디스크에 있는 아무 페이지나 다시 읽어 와야 하지만, v7은 늘 인덱스 오른쪽 끝 페이지에만 붙는다. 무작위 삽입은 페이지를 반쯤 채운 채 쪼개기 때문에 파일도 커졌다. UNIQUE 인덱스만 보면 228MB와 143MB다.

v7의 시간은 버퍼 풀 크기와 거의 상관이 없었다. 표가 계속 커지는 주문·로그 테이블이라면 언젠가 인덱스가 메모리보다 커지므로, 64MB 쪽 결과가 먼 미래의 모습에 가깝다.

함정: 멱등성 키를 INSERT 안에서 만들면 재시도를 못 막는다

v4를 만들던 자리를 그대로 UUID_v7()로 바꾸면 이렇게 되기 쉽다. 그런데 이 코드는 네트워크 오류로 같은 요청을 다시 보낼 때 새 키를 또 만들므로, 주문이 두 건 들어간다.

-- 잘못된 예: 재시도할 때마다 새 키
INSERT INTO orders (idem, item, qty) VALUES (UUID_v7(), '사과', 3);
INSERT INTO orders (idem, item, qty) VALUES (UUID_v7(), '사과', 3);  -- 두 번째 행이 들어간다

멱등성 키는 요청을 처음 시작할 때 한 번 만들어 보관하고, 재시도할 때는 그 값을 그대로 보내야 한다. DB에서 만들고 싶다면 먼저 받아 둔다.

SELECT UUID_v7() INTO @k;   -- 애플리케이션이라면 SELECT 로 받아 요청 상태에 저장
INSERT INTO orders (idem, item, qty) VALUES (@k, '배', 1);
INSERT INTO orders (idem, item, qty) VALUES (@k, '배', 1);
-- ERROR 1062 (23000): Duplicate entry '01a151c4-9136-7dd8-…' for key 'idem'

실제로 돌려 보니 앞의 방식은 '사과'가 2행이 됐고, 받아 둔 키를 쓴 '배'는 1행에서 멈췄다. 키를 받으려고 DB를 한 번 더 왕복하는 게 아깝다면, 아래처럼 애플리케이션에서 v7을 만드는 편이 낫다.

복제: 기본값 MIXED라면 따로 할 일이 없다

UUID_v7()는 복제 서버에서 다른 값을 낼 수 있어서 문장 기반 복제에 안전하지 않은 함수로 분류된다. binlog_format=STATEMENT에서 넣어 보니 Note 1592 Unsafe statement written to the binary log using statement format가 나왔다. 기존 UUID()도 똑같은 경고를 낸다.

MariaDB의 기본값인 MIXED에서는 바이너리 로그에 Write_rows가 남았다. 안전하지 않은 문장을 자동으로 행 형식으로 바꿔 기록한다는 뜻이다. STATEMENT로 바꿔 둔 서버만 손보면 된다.

11.6 이하라면 PHP에서 만들기

v7은 앞 48비트에 밀리초를 넣고, 버전 비트와 변형 비트를 맞추고, 나머지는 무작위로 채우면 된다. 라이브러리 없이 이렇게 만들 수 있다.

function uuid7(): string {
    $ms = (int) (microtime(true) * 1000);
    $b = substr(pack('J', $ms << 16), 0, 6) . random_bytes(10);
    $b[6] = chr((ord($b[6]) & 0x0f) | 0x70);  // 버전 7
    $b[8] = chr((ord($b[8]) & 0x3f) | 0x80);  // 변형 10xx
    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
}

이 함수로 2만 개를 만들어 MariaDB 11.4의 UUID 칼럼에 넣었다. 207개 밀리초에 걸쳐 만들어졌고, ORDER BY로 정렬했을 때 밀리초 순서가 뒤집힌 곳은 0곳이었다. 다만 이 함수에는 카운터가 없어서 같은 밀리초 안에서는 순서가 무작위다. 인덱스 끝쪽 같은 페이지 안에서 섞이는 정도라 위 실측의 효과는 거의 그대로일 것으로 보지만, 이 부분은 따로 재지 않았다.

v7을 쓰면 안 되는 자리

v7 키에서는 생성 시각을 밀리초 단위로 읽어 낼 수 있다. 주소창이나 응답으로 바깥에 보이는 ID라면 가입 시각이나 주문 시각이 드러나도 되는지 먼저 따져 본다. 드러나면 안 된다면 바깥에 보이는 ID는 v4로 두고, 내부 키만 v7로 쓴다.

측정 환경

  • MariaDB 공식 도커 이미지 11.4.13·11.6.2·11.7.2·11.8.9, 맥 (Apple Silicon) 도커 데스크톱, innodb_flush_log_at_trx_commit=2, 바이너리 로그 켬.
  • 삽입 표의 행은 UUID 키, 정수 하나, CHAR(40) 하나로 이뤄졌다. 넣기는 INSERT … SELECT … FROM seq_1_to_10000을 500번 돌렸다. 단건 INSERT를 동시에 여러 연결에서 넣는 상황은 재지 않았다.
  • 시간은 한 번씩만 쟀다. 절대값보다 v4와 v7의 비율을 보면 된다.

함께 읽으면 좋은 글