UUIDv7에는 생각하시는 그런 속성이 없습니다

UUIDv7: Timestamps aren’t time-ordered and other oddities

alexsci.com ▲ 159 댓글 130 ibobev

요약

UUIDv7이 확실히 보장하는 것은 고유성뿐이고, 시간순 정렬과 생성 시각은 구현에 따라 깨질 수 있습니다.

로버트 알렉산더가 표준 문서인 RFC 9562와 여러 구현을 직접 살펴보고 쓴 글입니다. 흔히 UUIDv7의 장점으로 꼽는 단조 증가(값이 늘 커지는 성질)와 믿을 만한 생성 시각은 실제로 보장되지 않는다고 지적했습니다.

왜 중요한가

  • UUIDv7에서 꺼낸 시각을 생성 시각 열 대신 쓰거나 엄격한 정렬 키로 믿으면, 시계가 어긋날 때 결과가 틀어질 수 있습니다.
  • 같은 RFC를 지켜도 구현마다 성질이 달라서, 표준 라이브러리 기본값을 쓰는 팀은 남의 설계 선택을 그대로 떠안습니다.
  • 공개 URL에 넣으면 화면에서 숨긴 계정 생성 시각까지 정밀하게 드러날 수 있습니다.

핵심 내용

  • RFC는 구현이 타임스탬프를 실제 시각보다 앞당기는 것을 허용하고, 그 차이에 상한을 두지 않습니다.
  • Python의 uuid.uuid7()은 시계를 1시간 되돌린 뒤 부하를 주자 실제 시각과의 차이가 3,695초에서 3,994초로 계속 벌어졌습니다.
  • 노드끼리 시계를 밀리초 단위로 맞출 수는 없어서, 여러 노드에 걸친 엄격한 시간순은 원리상 보장되지 않습니다.
  • 살펴본 구현 가운데 마지막 타임스탬프를 저장해 두는 곳이 없어, 시계가 뒤로 간 뒤 재시작하면 순서가 뒤바뀔 수 있습니다.
  • 저자는 UUIDv7을 버리라는 대신, 구현을 고를 때 언제 타임스탬프를 바꾸는지 등 여섯 가지를 따져 보라고 권했습니다.

HN 반응

  • 여러 이용자는 UUIDv7의 목적이 엄격한 순서가 아니라 비슷한 시기의 행을 인덱스에 모아 쓰기 성능을 높이는 데 있다며 글이 과녁을 빗나갔다고 했습니다.
  • 정수 일련번호면 충분하다는 의견에는 고객 수가 드러난다는 반론과, 내부 키와 공개 ID를 나누면 된다는 답이 오갔습니다.

댓글

30개 표시 · 전체 130개
  1. ricardobeat HN

    UUIDv7의 목적을 잘못 이해하신 겁니다. 핵심은 행을 물리적으로 가까이 붙여 두는 것입니다. 그러면 여러 데이터베이스 샤드에서 특정 시간 범위 전체를 최소한의 읽기와 연산으로 아주 빠르게 뽑아낼 수 있습니다. 게다가 중앙 서버 없이도 생성할 수 있고요. 전부 이게 다이고, 매시간 수십억 건의 레코드를 처리할 정도가 되어서야 신경 쓸 문제입니다.

    또한 UUIDv7은 공개 ID로 쓰라고 만든 것이 아닙니다. 자동 증가 ID를 그렇게 쓰지 않는 것과 같습니다.

    (저는 13년 전에 이런 용도로 라이브러리를 하나 만들었습니다 [1]. 트위터나 인스타그램 같은 곳들이 이런 최적화를 고민할 만큼 규모가 커지기 시작하던 무렵이었습니다.)

    [1] https://github.com/ricardobeat/unik

  2. grey-area HN

    공개 ID로 쓰는 게 아니라면, 중앙 DB 하나를 쓰는 환경(웹의 대다수가 이런 식으로 돌아갑니다)에서 시리얼 정수에 비해 어떤 이점이 있나요?

  3. tibbar HN

    분산 시스템에서 시리얼 정수가 얼마나 다루기 어려운지는 다른 분들이 이미 말씀하셨습니다. 그런데 전통적인 엔터프라이즈 워크로드에서도 시리얼 ID는 확장성이 그리 좋지 않습니다. 모든 쓰기가 단일 트랜잭션 병목을 통과해야 해서 데이터를 샤딩할 수 없습니다. UUIDv7 같은 방식은 그런 조율 비용 없이도 레코드마다 고유한 식별자를 보장해 줍니다. 데이터베이스에 쓰기가 성공하기 전에 ID를 먼저 알고 있어야 하는 상황도 있고요.

    물론 이벤트나 로그 데이터 중 상당수는 관계형 데이터베이스에 아예 들어가지도 않고 곧장 데이터 레이크로 흘러갑니다. 하지만 그런 레코드에도 ID는 필요합니다.

    여러 해 전에 오라클 데이터베이스를 쓰는 작은 회사에서 일한 적이 있습니다. 고객마다 테넌트가 따로 있었는데, 그래도 테넌트가 다른 레코드끼리 ID가 겹치지 않기를 원했습니다. 그래서 (기억하기로는) 모든 테넌트의 모든 쓰기가 중앙의 시퀀스 생성 함수를 호출했습니다. 이게 지연 시간의 주된 원인이 되었습니다. 시리얼 ID가 골칫거리가 되는 수많은 경우 중 하나일 뿐이고, 1990년대식 RDBMS를 서버 한 대에서 돌리는 게 아니라면 이런 일은 흔합니다.

    어쨌든 이 문제를 풀려고 하면 조율 없이도 (거의 확실하게) 고유한 ID를 주는 방식이 많이 있습니다. UUIDv4를 고를지 v7을 고를지 등은 다른 요소에 달려 있습니다. 다만 UUIDv7의 좋은 점은 어느 정도 시리얼에 가깝다는 것이고, 파티셔닝이 잘 동작하게 하는 데 도움이 됩니다.

  4. ndriscoll HN

    반면에 요즘 서버 한 대는 1990년대 서버보다 1만 배 빠르고 용량도 1만 배입니다. 게다가 요즘 RDBMS는 쿼리도 더 잘 실행하고 인덱스 종류도 더 다양하게 고를 수 있으니, 1990년대식 단일 서버 방식으로도 정말 한참을 갈 수 있습니다.

    앞으로는 데이터 주권 관련 법도 점점 늘어날 텐데, 그러면 파티션 키도 꽤 자연스럽게 생기겠죠. (즉 대부분의 데이터는 전역 시리얼이 필요 없습니다. 충돌을 걱정할 만큼 한 데이터베이스에 몰아넣는 것 자체가 어차피 불법일 테니까요. 그런 조인을 할 수 있는 곳이 있다면 파티션 ID를 덧붙이면 됩니다.)

  5. chaz6 HN

    이런 논의가 나올 때마다 Active Directory에서 쓰는 마이크로소프트의 "RID master"가 떠오릅니다. 중앙 서버가 요청이 올 때마다(보통 하한선에 도달하면) 각 서버에 ID 묶음을 나눠 주는 방식이라, 각 서버에는 항상 쓸 수 있는 ID 풀이 있습니다.

    PostgreSQL에서 이렇게 해 주는 확장 기능이 있나요?

  6. tibbar HN

    이거 정말 흥미로운 질문입니다. 말씀하신 건 Postgres에 샤드를 추가해 주는 확장 기능을 가리키는 거라고 이해했습니다. Postgres는 기본적으로 서버 한 대에서 돌아가니까요. 그런 확장 기능이나 재작성판들이 시퀀스를 어떻게 처리하는지 궁금해져서 찾아봤습니다.

    Citus(샤드를 별도의 워커에서 돌리는 확장 기능 중 하나)가 말씀하신 것과 비슷한 방식을 쓰는 것 같습니다. 노드마다 ID를 받고, 이 ID가 그 노드가 생성하는 시퀀스의 상위 비트가 됩니다. 그래서 시리얼 ID는 샤드 안에서는 단조 증가하지만 샤드 사이에서는 그렇지 않습니다.

    Aurora PostgreSQL Limitless는 다른 추상화를 쓰지만, 결국 라우터마다 서로 다른 ID 구간을 예약해 두고 쓰게 하는 것 같습니다.

  7. e12e HN

    아래 같은 건

    https://dev.to/aws-heroes/scalable-sequence-for-postgresql-34o7

    샤딩과 어떻게 맞물릴지 모르겠네요.

  8. surye HN

    제가 이 용도로 써 본 대표적인 경우는 두 가지입니다. 하나는 분산 데이터베이스로, 시퀀스를 조율할 필요가 없습니다. 다른 하나는 그 특수한 형태로, 오프라인에서 데이터를 만들어 두었다가 나중에 동기화하는 경우입니다.

  9. grey-area HN

    그런 용도라면 합리적으로 들리네요.

    안타깝게도 대다수 사람들은 이걸 불투명한 공개 식별자로, 또 단일 DB의 PK로 잘못 쓰고 있다고 생각합니다. 이 글이 말하는 게 바로 그 문제고요.

  10. nodamage HN

    맞습니다. 이 지구상에는 이게 흔한 요구사항인 기기가 수십억 대나 있습니다.

  11. globular-toast HN

    UUID는 통틀어 가장 오해받는 것 중 하나라고 생각해요. UUID의 존재 이유 자체가 생성하는 데 중앙 DB가 필요 없다는 거거든요.

  12. l5870uoo9y HN

    그럼 공개 ID용으로 UUID를 담은 컬럼을 따로 두시나요?

  13. tibbar HN

    트레이드오프를 잘 다룬 논의가 여기 있습니다 [0]. UUIDv7을 공개해도 괜찮은 경우가 많습니다. 예를 들어 이벤트 시각이 비밀이 아니고 타임스탬프가 아닌 비트에 좋은 엔트로피를 쓴다면요. 하지만 그게 신경 쓰인다고 판단되면 대신 무작위 식별자를 쓰기도 합니다. 그런 경우에는 UUIDv7 식별자를 하나 더 두어도 득을 보기 어렵습니다. 이미 기본 키 인덱스 비용을 내고 있으니까요. 다만 특정 종류의 범위 쿼리를 하고 있고 스토리지 엔진도 특정 종류라면, UUIDv7에서 여전히 얻는 게 있을 수는 있습니다.

    한마디로 상황에 따라 다릅니다. 그리고 데이터가 많지 않거나, 분산 방식으로 생성하지 않거나, 이점을 취할 수 있는 방식으로 범위 쿼리나 파티션을 쓰지 않는다면 전혀 문제가 되지 않을 수도 있습니다.

    [0] https://news.ycombinator.com/item?id=45622045

  14. hackingonempty HN

    If you need to list things ordered by creation time very often, it’s useful if the primary index gives that behavior, removing the need for another index.

    (> 생성 시각순으로 목록을 뽑아야 할 일이 아주 잦다면, 기본 인덱스가 그 순서를 그대로 제공해서 별도 인덱스가 필요 없어지는 편이 유용합니다.)

    UUIDv7에 시간 요소를 넣은 가장 큰 이유는 시간상 관련된 행들을 인덱스 페이지 안에 모아 두려는 것이라고 생각했습니다. 행 ID가 무작위로 흩어져 있어도 인덱스 전체가 RAM에 들어가지 않게 되기 전까지는 괜찮아 보이거든요.

  15. NyxWulf HN

    인덱스 페이지만의 문제가 아닙니다. 보통 데이터베이스는 레코드를 페이지에 쓰고, 그 페이지들을 다시 익스텐트로 묶습니다. 기본 키가 순서를 제공하면 새 페이지를 만들어 차례대로 채워 나갈 수 있습니다. 반면 무작위라면 항상 여기저기 페이지에 무작위로 삽입하게 되고, 페이지에 공간이 모자라면 분할을 시작해야 합니다. UUIDv7은 이 문제에서 가장 골치 아픈 부분을 대체로 해결해 주기 때문에, 기본 키로는 UUIDv1~4보다 훨씬 나은 선택입니다.

  16. srcreigh HN

    보통은

    포스트그레스만 빼고요.

  17. thayne HN

    Postgresql도 페이지를 씁니다. 레코드는 어떤 순서로도 정렬되어 있지 않지만 인덱스는 정렬되어 있죠. 그래서 UUIDv7은 Postgresql에서도 여전히 쓸모가 있습니다. 기본 인덱스에서 삽입이 여러 페이지에 흩어질 가능성이 줄어들거든요.

  18. pkulak HN

    네, 저한테는 이게 이점의 99%입니다. 이론상의 얘기도 아니에요. 저희 회사는 2015년에 MySQL에서 완전 무작위 ID를 썼다가 성능 문제를 겪었고, 그래서 UUIDv7을 직접 만들어 지금까지 쓰고 있습니다.

  19. jrs235 HN

    저도 비슷한 경험이 있습니다. 무작위 GUID를 기본 키로 쓰면 테이블과 인덱스가 금방 단편화됩니다. 자주 조각 모음을 해야 하죠.

  20. zeendo HN

    네, 저도 같습니다.

    스펙에 단조 증가한다고 적혀 있다면 문제이긴 하겠죠. 보장되지 않는 게 분명하니까요. 그래도 저는 원래 그런 성질을 바라거나 기대한 적이 없습니다.

    여기 나온 나머지 문제들은 제 생각에는 전부 ID를 잘못 이해했거나 잘못 쓴 경우로 보입니다.

  21. btown HN

    UUIDv7과 관련해 제가 제일 좋아하는 점 하나. pypi.org/project/uuid7/ 패키지는 규격에 맞는 UUIDv7을 구현하지 않습니다. 완전히 호환되지 않는 초안 형식을 따르고 있거든요. 디버깅하면서 정말 즐겁지 않았습니다.

    https://pypi.org/project/uuid-utils/ 가 여러분이 원하시는 겁니다 :)

    https://github.com/stevesimmons/uuid7/issues/1

  22. nextaccountic HN

    pypi.org/project/uuid7/ 패키지는 규격에 맞는 UUIDv7을 구현하지 않습니다.

    이 패키지는 pypi에서 내리거나, 최소한 경고라도 띄워야 합니다.

  23. cpburns2009 HN

    "... 무작위 48비트"

    128비트짜리 객체에 그건 정말 심한 낭비네요!

  24. 9dev HN

    진짜 원하시는 건 파이썬이 아닌 것이겠죠.

  25. kentm HN

    제목이 좀 웃기네요. 여기서 반박하는 성질들은 UUIDv7이 갖고 있다고 생각한 적이 없거든요. UUIDv7을 쓰는 이유는 특정 시점에 UUID가 퍼지는 범위를 줄여서 쓰기 작업의 군집도를 높이기 위해서입니다. 이를 위해 엄밀하게 정렬될 필요는 없고 "충분히 괜찮은" 정도면 됩니다.

    제발 누구도 타임스탬프 부분을 "updated at" 타임스탬프 대신 쓰고 있지 않기를 바랍니다만, 아마 실망하게 되겠죠.

  26. ajb HN

    맞아요, 이건 픽업 "아티스트"들이 "네그"라고 부르는 수법이에요. 상대가 어느 정도는 자기 신뢰도가 걸려 있다고 느끼게 하려고 일부러 던지는 부정적 평가요. 여기서는 낚시성 제목이고요. 저는 이런 글은 아예 안 읽거나, 여기 와서 댓글만 읽어요.

  27. rcoder HN

    단조 증가하면서 보안상 추측도 불가능한 ID가 정말 필요하다면 CUID2(github.com/paralleldrive/cuid2) 같은 선택지가 있습니다.

    ULID(https://github.com/ulid/spec)나 Snowflake ID(https://en.wikipedia.org/wiki/Snowflake_ID) 같은 더 오래된 설계도 UUIDv7과 성질이 비슷하고, 길이, 인코딩, 런타임 비용 등에서는 유리한 면이 있을 수도 있습니다.

    하지만 UUIDv7에는 이들 중 어느 것도 갖지 못한 결정적인 장점이 하나 있습니다. 손실 변환 없이 UUID 타입 컬럼이나 구조체 필드에 그대로 저장할 수 있다는 점입니다. UUID는 버전이 달라도 크기와 직렬화 형식이 모두 같아서, v1이든 v4든 v7이든 어느 것을 생성하는지는 사실 중요하지 않습니다.

    많은 언어의 표준 라이브러리가 이들을 구분 없이 생성할 수 있고, (예를 들어) Postgres 테이블은 어떤 버전이든 같은 컬럼 타입에 저장할 수 있습니다.

    그러니 글쓴이의 지적대로 UUIDv7은 마법 같은 단조 증가 ID도, 추측 불가능한 ID도 아닙니다. "보안용"이나 엄밀한 정렬용으로 취급해서는 안 됩니다. 그렇다고 쓸모가 없다는 뜻은 아닙니다.

  28. cpburns2009 HN

    저는 ULID를 씁니다. PostgreSQL에 저장하기 전에 UUID로 변환하고요. 엄밀히 말하면 잘못된 직렬화지만 잘 동작합니다. 우리 시스템은 UUIDv7이 나오기 전부터 있었어요. 표현이 더 짧은 것도 ULID의 장점이라 마음에 듭니다.

  29. hdjrudni HN

    Postgres의 컬럼 타입에 뭐가 특별한 게 있나요? 그냥 binary(16)이 아닌가요?

  30. cpburns2009 HN

    PostgreSQL의 UUID 타입은 편리합니다. 하지만 맞아요, 사실상 그냥 16바이트 타입입니다.

Hacker News에서 보기 ↗