요약
UUIDv7이 확실히 보장하는 것은 고유성뿐이고, 시간순 정렬과 생성 시각은 구현에 따라 깨질 수 있습니다.
로버트 알렉산더가 표준 문서인 RFC 9562와 여러 구현을 직접 살펴보고 쓴 글입니다. 흔히 UUIDv7의 장점으로 꼽는 단조 증가(값이 늘 커지는 성질)와 믿을 만한 생성 시각은 실제로 보장되지 않는다고 지적했습니다.
왜 중요한가
- UUIDv7에서 꺼낸 시각을 생성 시각 열 대신 쓰거나 엄격한 정렬 키로 믿으면, 시계가 어긋날 때 결과가 틀어질 수 있습니다.
- 같은 RFC를 지켜도 구현마다 성질이 달라서, 표준 라이브러리 기본값을 쓰는 팀은 남의 설계 선택을 그대로 떠안습니다.
- 공개 URL에 넣으면 화면에서 숨긴 계정 생성 시각까지 정밀하게 드러날 수 있습니다.
핵심 내용
- RFC는 구현이 타임스탬프를 실제 시각보다 앞당기는 것을 허용하고, 그 차이에 상한을 두지 않습니다.
- Python의 uuid.uuid7()은 시계를 1시간 되돌린 뒤 부하를 주자 실제 시각과의 차이가 3,695초에서 3,994초로 계속 벌어졌습니다.
- 노드끼리 시계를 밀리초 단위로 맞출 수는 없어서, 여러 노드에 걸친 엄격한 시간순은 원리상 보장되지 않습니다.
- 살펴본 구현 가운데 마지막 타임스탬프를 저장해 두는 곳이 없어, 시계가 뒤로 간 뒤 재시작하면 순서가 뒤바뀔 수 있습니다.
- 저자는 UUIDv7을 버리라는 대신, 구현을 고를 때 언제 타임스탬프를 바꾸는지 등 여섯 가지를 따져 보라고 권했습니다.
HN 반응
- 여러 이용자는 UUIDv7의 목적이 엄격한 순서가 아니라 비슷한 시기의 행을 인덱스에 모아 쓰기 성능을 높이는 데 있다며 글이 과녁을 빗나갔다고 했습니다.
- 정수 일련번호면 충분하다는 의견에는 고객 수가 드러난다는 반론과, 내부 키와 공개 ID를 나누면 된다는 답이 오갔습니다.
UUIDv7의 목적을 잘못 이해하신 겁니다. 핵심은 행을 물리적으로 가까이 붙여 두는 것입니다. 그러면 여러 데이터베이스 샤드에서 특정 시간 범위 전체를 최소한의 읽기와 연산으로 아주 빠르게 뽑아낼 수 있습니다. 게다가 중앙 서버 없이도 생성할 수 있고요. 전부 이게 다이고, 매시간 수십억 건의 레코드를 처리할 정도가 되어서야 신경 쓸 문제입니다.
또한 UUIDv7은 공개 ID로 쓰라고 만든 것이 아닙니다. 자동 증가 ID를 그렇게 쓰지 않는 것과 같습니다.
(저는 13년 전에 이런 용도로 라이브러리를 하나 만들었습니다 [1]. 트위터나 인스타그램 같은 곳들이 이런 최적화를 고민할 만큼 규모가 커지기 시작하던 무렵이었습니다.)
[1] https://github.com/ricardobeat/unik
(> 생성 시각순으로 목록을 뽑아야 할 일이 아주 잦다면, 기본 인덱스가 그 순서를 그대로 제공해서 별도 인덱스가 필요 없어지는 편이 유용합니다.)
UUIDv7에 시간 요소를 넣은 가장 큰 이유는 시간상 관련된 행들을 인덱스 페이지 안에 모아 두려는 것이라고 생각했습니다. 행 ID가 무작위로 흩어져 있어도 인덱스 전체가 RAM에 들어가지 않게 되기 전까지는 괜찮아 보이거든요.
인덱스 페이지만의 문제가 아닙니다. 보통 데이터베이스는 레코드를 페이지에 쓰고, 그 페이지들을 다시 익스텐트로 묶습니다. 기본 키가 순서를 제공하면 새 페이지를 만들어 차례대로 채워 나갈 수 있습니다. 반면 무작위라면 항상 여기저기 페이지에 무작위로 삽입하게 되고, 페이지에 공간이 모자라면 분할을 시작해야 합니다. UUIDv7은 이 문제에서 가장 골치 아픈 부분을 대체로 해결해 주기 때문에, 기본 키로는 UUIDv1~4보다 훨씬 나은 선택입니다.
포스트그레스만 빼고요.
네, 저한테는 이게 이점의 99%입니다. 이론상의 얘기도 아니에요. 저희 회사는 2015년에 MySQL에서 완전 무작위 ID를 썼다가 성능 문제를 겪었고, 그래서 UUIDv7을 직접 만들어 지금까지 쓰고 있습니다.
네, 저도 같습니다.
스펙에 단조 증가한다고 적혀 있다면 문제이긴 하겠죠. 보장되지 않는 게 분명하니까요. 그래도 저는 원래 그런 성질을 바라거나 기대한 적이 없습니다.
여기 나온 나머지 문제들은 제 생각에는 전부 ID를 잘못 이해했거나 잘못 쓴 경우로 보입니다.
UUIDv7과 관련해 제가 제일 좋아하는 점 하나. pypi.org/project/uuid7/ 패키지는 규격에 맞는 UUIDv7을 구현하지 않습니다. 완전히 호환되지 않는 초안 형식을 따르고 있거든요. 디버깅하면서 정말 즐겁지 않았습니다.
https://pypi.org/project/uuid-utils/ 가 여러분이 원하시는 겁니다 :)
https://github.com/stevesimmons/uuid7/issues/1
128비트짜리 객체에 그건 정말 심한 낭비네요!
진짜 원하시는 건 파이썬이 아닌 것이겠죠.
제목이 좀 웃기네요. 여기서 반박하는 성질들은 UUIDv7이 갖고 있다고 생각한 적이 없거든요. UUIDv7을 쓰는 이유는 특정 시점에 UUID가 퍼지는 범위를 줄여서 쓰기 작업의 군집도를 높이기 위해서입니다. 이를 위해 엄밀하게 정렬될 필요는 없고 "충분히 괜찮은" 정도면 됩니다.
제발 누구도 타임스탬프 부분을 "updated at" 타임스탬프 대신 쓰고 있지 않기를 바랍니다만, 아마 실망하게 되겠죠.
맞아요, 이건 픽업 "아티스트"들이 "네그"라고 부르는 수법이에요. 상대가 어느 정도는 자기 신뢰도가 걸려 있다고 느끼게 하려고 일부러 던지는 부정적 평가요. 여기서는 낚시성 제목이고요. 저는 이런 글은 아예 안 읽거나, 여기 와서 댓글만 읽어요.
아마 UUID는 필요 없을 겁니다: https://ssg.dev/you-probably-dont-need-a-uuid/
저도 동의합니다. KSUID용 SQLite 플러그인을 만들었지만 결국 한 번도 쓰지 않았습니다. 제가 하는 일은 전부 정수 기본 키로 처리되거든요. SQLite는 정수 기본 키를 자동으로 rowid에 대응시켜 줍니다. autoincrement는 삭제된 행의 ID가 재사용되는 것을 막고 싶을 때만 필요합니다.
데이터베이스 기본 키라면 그렇죠. 그냥 serial 쓰세요.
경쟁사가 우리 고객 수 같은 걸 알게 되는 건 싫거든요.
PK를 노출하지 마세요.
그러면 프론트엔드가 조회할 때 쓰는, 겉으로는 의미를 알 수 없는 고유 식별자를 따로 하나 더 둔다는 말씀이죠? 그럴 거면 그냥 UUID 하나만 쓰면 되지 않나요?
조인에 쓰는 인덱스의 밀도가 더 높아집니다. 그리고 생성 시각까지 민감하다면 공개용으로 UUIDv4를 써서 숨길 수도 있고요.
네, 이게 표준적인 방식이에요. 이유는 성능이고요. 사용자에게 보이는 ID에 필요한 조건(짧은 영숫자, 무작위 순서)은 내부 ID에 필요한 조건(원시 비트, 단조 증가, 어쩌면 타임스탬프 데이터)과 거의 무관한 경우가 많아요.
맞아요, 공개 UUID 하나를 조회할 때마다 그런 걸 20개쯤 조인하게 될 거예요. 이 방식을 쓰지 말아야 할 이유가 있을 수도 있지만, 기본값으로는 좋습니다.
사람이 코드를 짜던 때에도 이걸 지키게 하기가 충분히 어려웠는데, 그 멍청한 증가 정수 ID가 공개 API나 중요한 코드 경로에 한 번 들어가면 걷어내기가 정말 고통스러워질 수 있습니다.
데이터베이스에 따라 다릅니다. 분산 데이터베이스에서는 serial 키가 핫스팟을 만들 수 있습니다. 새 쓰기가 계속 같은 파티션으로만 몰리니까요.
그렇긴 한데, 거의 모든 사람은 분산 데이터베이스를 쓰지 않는 쪽이 성능이 더 좋을 거고, "핫스팟", 즉 데이터 지역성의 덕도 볼 겁니다.
이건 근본적인 이유가 없습니다. CPU별 메모리 할당처럼, 샤드가 다음 serial 공간의 덩어리를 미리 확보해 두고 새 덩어리가 필요할 때만 조율하면 됩니다.
많은 방식이 정확히 그렇게 합니다. 단일 테이블에 삽입만 한다면 꽤 잘 동작하고요.
하지만 외래 키로 연관 레코드를 만드는 작업이 들어가면 락 경합이 생기기 시작합니다. 예를 들어 이렇습니다.
삽입에 부모 레코드 하나와 자식 레코드 하나가 필요하다면, 다음 순차 ID를 받아와야 합니다. 이때 선점 불가능한 단일 스레드 락을 잡고, 값을 증가시키고, 요청한 커넥션으로 되돌려 보내야 하죠. 대부분의 데이터베이스에서는 부모 레코드를 먼저 쓰고 그 순차 ID를 돌려받아야 자식 레코드의 외래 키 참조를 설정할 수 있다는 뜻입니다. 저장 프로시저를 쓰면 네트워크 전송 시간은 줄일 수 있지만, 단일 스레드 락을 얻는 시간은 해결되지 않습니다.
분산 시스템 대부분이 UUID나 다른 종류의 분산 ID를 쓰는 이유는, 부모 레코드의 ID를 클라이언트에서 생성하고 외래 키도 거기서 설정한 다음 두 레코드를 단순한 삽입으로 보낼 수 있기 때문입니다. 우회하거나 완화할 방법이 없다는 얘기가 아닙니다. 조율이 필요 없는 ID를 쓰는 편이 실제로 다루고 확장하기가 훨씬 쉽다는 겁니다.
(> 삽입에 부모 레코드 하나와 자식 레코드 하나가 필요하다면, 다음 순차 ID를 받아와야 합니다)
왜 다음 순차 ID가 필요하죠? 그냥 고유한 ID면 되는 것 아닌가요? 제가 설명한 것처럼 샤드별로 미리 할당받는 serial 컬럼을 쓰면 두 레코드의 ID를 모두 할당할 수 있습니다.
조율하지 않아도 되면 훨씬 쉽습니다. 랙에 원자시계를 들여놓을 필요까지 없다면 보너스고요.
분산 DB를 설계하는 거라면 그렇겠죠. 이미 있는 걸 가져다 쓰는 거라면 그쪽이 원하는 대로 해야 합니다. Spanner는 무작위 키를 권장하고, Citus는 serial을 씁니다.
제가 테스트해 본 바로는 주된 병목은 같은 파티션에 쓴다는 점이 아니었습니다. 진짜 원인은 serial 키를 증가시키기 위한 락 조율과, 그 값을 커넥션으로 되돌려 보내는 과정이었습니다. 사실상 모든 데이터베이스는 업데이트를 RAM과 선행 기록 로그(WAL)에 먼저 반영하고, 디스크에는 백그라운드로 나중에 내려씁니다. 어떤 페이지를 쓰거나 갱신하든 변경 사항은 모두 순서대로 WAL에 기록됩니다. 저한테는 Serial 키를 없앤 것이 처리 과정에서 가장 큰 핫스팟 해소였습니다. 사람마다 다를 수 있습니다.
데이터베이스를 만져 본 지 좀 됐는데, pg도 커밋이 끝나기 전에 인덱스를 써야 하지 않나요? 그러면 어쨌든 엄청나게 많은 페이지를 읽어 와서 써야 하는 거잖아요. 그리고 ZFS처럼 파일시스템이 원자적 페이지 쓰기를 지원하지 않는 한, 찢어진 쓰기(torn write)를 막으려고 페이지 전체를 WAL에도 쓰고요. 체크포인트 안에서 같은 페이지에 쓰는 경우는 전체 페이지 쓰기가 필요 없어서 이득을 보긴 하고요.