데이터베이스 기본 키 선택을 위한 설계 방법

데이터베이스 기본 키 선택을 위한 설계 방법
it-postingPosted On Aug 3, 202417 min read

최근에 간단한 채팅 시스템을 설계하고 통합할 기회가 있었어요. 아키텍처를 스케치할 때, 주요 키 선택에 대해 고민한 시간을 많이 보냈어요. 이 주제는 여러 번 다시 나타났는데, 다른 기사를 읽으면서 이것이 이전에 생각했던 것보다 더 깊다는 것을 깨달았어요. 데이터 디자인과 여러 문제의 함정에 대해 완전히 낯설지는 않았지만, 주요 키 선택이 시스템 성능과 확장성에 미치는 깊은 영향을 완전히 이해하지 못했어요. 이 기사에서는 내가 한 선택과 그 뒤에 숨은 이유에 대해 공유하고 싶어요.

Postgres에서 작업 중인 것을 유념해야 해요. 대부분의 이 기사는 다른 데이터베이스에도 적용될 수 있다고 믿지만, 때로는 이 중요한 세부 사항을 무시하고 논의를 높은 수준에서 진행할 거에요. 그러나 Postgres 바깥에서는 적용되지 않을 수도 있는 일부 논거를 염두에 두세요.

내 작업은 기존 성숙한 시스템에 새로운 기능을 통합하는 데 초점을 맞추었어요. 즉, 이미 연관 엔티티에 대한 중요한 맥락이 이미 많이 존재해요. 다음 그림과 가정은 설계 과정 중 사용한 간소화된 모델의 스냅샷을 제공해요. 주요 키를 선택하는 데 고려한 사고 과정과 고려 사항을 공유함으로써 데이터베이스 디자인의 이 측면에 대한 지속적인 토론을 개요화하고자 해요.

Exploring the Right Fit: Choosing Primary Keys for Your Database

다음은 기본 키 선택에 영향을 미치는 주요 요소입니다:

  • 이벤트의 높은 양: 시스템은 예측할 수 없는 폭발적인 이벤트의 높은 양을 처리할 것으로 예상됩니다.
  • 이벤트 순서: 소비자들은 특정한 순서로 이벤트를 받기를 기대합니다.
  • 방 기반 검색: 대부분의 소비자들은 하나 이상의 특정 방에 대한 이벤트를 검색하려고 시도합니다.
  • 부분적 데이터 검색: 시간에 따라 데이터를 부분적으로 검색하는 것이 일반적입니다.
  • 분산 시스템: 시스템은 완전한 이벤트들이 데이터베이스 외부에서 구성되는 분산 환경에서 작동합니다.
  • 액세스 빈도: 오래된 이벤트는 새로운 이벤트와 비교하여 덜 자주 액세스될 것입니다.

이러한 요소를 기반으로 다음과 같은 가정을 도출했습니다:

  • 테이블 분할: 인프라 제약조건에 따라 테이블은 수직 또는 수평으로 분할될 것입니다.
  • 테이블 클러스터링: 테이블은 읽기/쓰기 메트릭에 기반하여 클러스터링될 수 있으며 성능을 최적화할 수 있습니다.
  • 시간 기준 주요 키: 주요 키는 시간에 따라 정렬될 수 있어야 하며 효율적인 쿼리 및 이벤트 정렬을 용이하게 해야 합니다.
  • 외부 주요 키 생성: 주요 키는 (일부) 분산 시스템에서 이벤트가 구성되는 것을 고려하여 데이터베이스 수준에서 생성되지 않을 것입니다.
  • 색인화 성능: 색인화 성능은 고조파 기간 동안에도 수용 가능해야 하며 저장 공간 낭비를 최소화하는 것이 바람직합니다.

주요 키 데이터 유형에 대한 회고: 기준 형식화

위 설계를 위한 일반적인 주요 키 데이터 유형에 대해 생각하고 정보를 모은 후, 문제를 다시 살펴보고 내재적으로 고려한 기준을 형식화하려고 노력했습니다. 이 회고는 내가 주요 키에서 어떤 것을 찾고 있었는지 명확히하기 위한 것입니다. 고유성과 개략성과 같은 필수 기준 대신 충분한 기준에 특히 초점을 맞추고 있음을 유념해 주세요.

간결함

기본 키의 복잡성은 관리 가능하고 대상을 고려해야 합니다. 복잡성은 숨겨질 수 있지만, 기본 키가 데이터베이스 관리자와 개발자에게 이해 가능하고 사용 가능하도록 보장하는 것이 중요합니다. 이 특성의 하위 집합은 기본 키의 외형인데, 특정한 경우에 관련이 있습니다. 예를 들어 URL 단축기의 경우, 128비트 UUID는 하이픈 때문에 여러 행에 걸쳐 나타날 수 있고 복사 및 붙여넣기가 어려울 수 있지만, 단순한 정수는 이 문제가 없습니다.

예측 가능성

예측 가능성은 보안 및 비즈니스 측면에서 파생됩니다. 예측 가능한 기본 키는 민감한 정보나 비즈니스 지표를 노출시킬 수 있으며, 이는 바람직하지 않을 수 있습니다. 이것은 종종 자동 증가하는 정수 값과 관련되며, 노출되면 다음 키를 쉽게 추측할 수 있을 뿐만 아니라 이전에 얼마나 많은 키가 있었는지에 대한 정보를 노출합니다.

확장성

스케일링 가능한 주요 키는 조정자가 필요 없어야 하며, 그렇지 않으면 병목 현상이 발생할 수 있습니다. 주요 키는 수평 스케일링을 효율적으로 지원해야 합니다. 스케일링의 더 넓은 측면은 주로 키 생성의 동기화 수단과 함께 논의됩니다. 이는 클라이언트가 다음 키를 받기 위해 기본적으로 대기해야 한다는 것을 의미하며, 이는 경쟁이 치열한 분산 설정에서 스케일링에 문제가 될 수 있습니다.

크기

주요 키의 크기는 성능 및 보조 인덱스의 효율성에 영향을 미칩니다. 보다 작은 키는 일반적으로 성능이 더 좋고 저장 공간을 덜 소비합니다. 저장소는 기본 키가 일정 수의 바이트를 차지한다는 것 외에도 약간의 미묘함이 있습니다. 보조 인덱스가 기본 키를 사용하기 때문에 시스템의 일반적인 성능에 일부 권통 효과가 발생할 것입니다. 일반적으로 크기가 작을수록 좋지만, 충돌과 무작위성을 고려할 때 항상 그렇지는 않습니다.

인덱스 성능

주요 키를 순차적으로 정렬하면 인덱스 성능을 향상시킬 수 있습니다. 그러나 이는 생성 방법에 따라 다릅니다. 예를 들어 순차적이지 않은 순서로 키를 생성하는 경우(예: 1보다 2를 먼저 생성) B-트리의 성능을 저하시킬 수 있습니다. 특히 UUID는 무작위성 때문에 대규모 데이터셋에서 성능이 저하될 수 있습니다.

충돌

충돌의 빈도와 영향은 중요합니다. 빈도가 높은 시스템에서는 충돌 확률이 낮아도 치명적일 수 있습니다. 예를 들어 높은 주파수의 결제 시스템에서 10^99 중 1의 충돌률이라도 하루에 여러 차례 충돌이 발생할 수 있으며, 이는 용인할 수 없습니다.

채택

선택한 기본 키 데이터 유형을 네이티브로 지원해야 합니다. 키를 비효율적인 형식(예: 문자열로)으로 저장하는 것을 피하고, 데이터베이스와 클라이언트 라이브러리가 유효성 검사와 정렬을 올바르게 처리할 수 있도록 보장하고 싶습니다. 또한, 잘 지원되고 검증된 클라이언트 라이브러리를 사용하는 것이 유익합니다.

이러한 기준을 고려하면서, 이제 다양한 종류의 기본 키를 평가하여 논의할 수 있게 되었습니다.

자연식별자: 직관적인 기본 키?

데이터 설계를 시작할 때, 자연식별자가 인위적인 키의 모호성을 도입하지 않으면서 많은 요구 사항을 충족시키기 때문에 유리하다는 것을 배웠습니다. 그러나 제 경험상 자연식별자는 유용한 방식으로 발생하는 경우가 거의 없습니다. 특정 경우에서, 충분한 정확도를 갖춘 타임스탬프를 기본 키로 사용하는 것은 처음에 매력적으로 보였습니다. 그러나 의미론적인 측면에서, 타임스탬프만으로는 이벤트를 고유하게 식별하지 못합니다. 충돌과 충분한 정확성을 고려해도 이것은 항상 사용자와 시간의 조합이 고유한 이벤트를 형성한다는 뜻입니다. 여기서 전제는 사용자가 동시에 여러 이벤트를 발생시키지 않는다는 것입니다. 이는 타당한 가정이며, 발생 가능성이 낮은 충돌의 결과는 무시할 만합니다. 반면, 서로 다른 사용자로부터 '정확히' 동시에 발생하는 두 이벤트의 가능성은 훨씬 높습니다. 수학적 추정에 대해 깊이 파지 않아도 될 만한 타당한 가정 없이 숫자 놀음일 수도 있지만, 타임스탬프만으로는 기본 키로 적합하지 않아 보입니다. 타임스탬프를 완전히 포기하고 복합 키로 이동하기 전에, 타임스탬프만을 사용하는 것에는 다른 어려움도 존재함을 강조해야 합니다. 예를 들어 데이터베이스가 키를 생성하도록 할 때, 트랜잭션이 now()로 설정된 기본값으로 각 행에 대해 동일한 타임스탬프를 생성할 수 있습니다. 이러한 요소들을 고려하면, 타임스탬프만을 기본 키로 의존하는 것은 최선의 방법이 아닐 수 있다는 것이 분명해집니다. 대신, 복합 키를 탐구하거나 사용자 정보와 같은 추가 고유 식별자를 통합한 대안 전략을 탐색하여 시스템에서 이벤트를 고유하게 식별하는 더 견고하고 의미 있는 솔루션을 제공할 수 있을 것입니다.

다음 단계는 일반적으로 복합 키라고 하는 고유 기본 키를 만들기 위해 열을 결합하는 것인 것 같아요. 이 접근 방식은 의미론적으로 자연 키의 간단함을 유지하면서 기본 키를 처리할 때 상당한 복잡성을 도입하게 됩니다. 저는 개인적으로 합성 키를 다루는 것이 항상 즐거운 경험은 아니었어요. 때로는 성능에 대한 영향을 완전히 무시하더라도요. 그래서 전체적으로 그들을 거의 사용하지 않아요. 그럼에도 불구하고 저는 개인적인 취향을 기반으로 옵션을 단순히 배제하고 싶지 않아요.

제 경우에는 사용자, 방, 타임스탬프의 조합이 가능한 합성 키일 수 있어요. 이 조합은 사용자가 한 번에 한 방에만 있을 수 있고, 그리고 앞서 언급한대로 단일 사용자가 동시에 여러 이벤트를 생성할 수 없는 가정하에 이루어진 거예요. 하지만 결과적인 데이터 처리 시에 심플한 작업조차도 복잡해질 수 있어요. 대표적인 예는 외래 키 관계인데요, 해당하는 테이블이 합성 키의 모든 열을 포함해야만 해요. 게다가, 합성 키 유지는 시간이 많이 걸릴 수 있어요. 새로운 열을 추가하는 경우 주 키를 수정해야 할 수도 있는데, 이는 직접적으로는 불가능한 일이죠. 저는 이미 왜 개인적으로 합성 키를 선호하지 않는지 아주 명백하다고 믿어요.

마지막으로, 하이브리드 대체 키라고 불릴 수 있는 것을 고려해봅시다. 이는 자연 키와 인공 키의 혼합물이에요. 해당 도메인 외부에 열을 추가하되, 자연 열의 구성 요소를 사용하여 인공 키를 구축한다는 것을 의미해요. 이러한 키를 만난 적이 있지만, 그것들이 양쪽 세계의 단점을 모두 가지고 있다고 생각해요. 질의 의미론이 손실되고 자연 키와 대체 키의 단점이 상속되기 때문이죠. 유일한 즉각적인 이점은 크기 축소 가능성뿐이에요. 이 점은 필수적인 것은 아니며, 키가 어떻게 구성되고 저장되는지에 따라 달라질 수 있어요. 이러한 키와 관련하여 깊은 경험을 가지고 있지는 않으므로, 제가 알지 못하는 이점이 있을 수 있어요.

Auto-Increments에 대한 사례

자동 증가 정수는 인공 기본 키의 잘 알려진 소스입니다. 나는 이것이 관계형 데이터를 다룰 때 가장 흔히 사용하는 방법 중 하나라는 것을 내 경력에서 가장 많이 접했습니다. 그러나 최근 몇 년간 나의 작업은 더 분산된 아키텍처로 이동했기 때문에 다른 유형의 키가 점점 더 많이 사용되고 있습니다.

나는 Postgres에서 BIGSERIAL을 사용하여 자동 증가 키를 구현하는 경향이 있지만 시퀀스를 사용하는 다른 기술도 있습니다. 내가 위에서 언급한 기준에 따라 정수를 선택하는 명백한 이유는 정수가 구현하기 매우 간단하고 이해하기 쉽다는 것입니다. 양립성은 최선이며, 자연수에 대한 개념이 없는 데이터베이스는 적어도 내가 알기로는 없습니다. 더불어 이들은 정렬할 수 있으며 일반적으로 정수를 생성하거나 두 정수를 비교하는 데 큰 작업을 해야 하는 경우는 없습니다. 사이즈 측면에서 64 비트 BIGINT만 사용하는 것은 오버플로우 및 32 비트 정수를 사용할 때 마이그레이션으로부터 발생하는 부담 때문에 좋은 규칙입니다. 예를 들어, 시스템이 초당 256개의 이벤트를 받는다고 가정해 보겠습니다. 이는 32 비트를 사용할 경우 1년 내에 키가 소진됨을 의미하나, 64 비트를 사용할 경우 5850 억 년 후에 키가 소진됩니다. 여기에서 충돌은 크게 적용되지 않는다고 볼 수 있습니다. 왜냐하면 일련번호는 보통 데이터베이스에서 생성되기 때문입니다. 이것은 이들이 단조롭다는 것을 보장하기 위해 조정되어야 함을 의미합니다. 흥미로운 사실로, 일련번호가 항상 한 번에 한 단계씩 증가하는 것이 보장되지 않습니다. 예를 들어 롤백 트랜잭션과 같이 간격을 도입하는 경우가 있을 수 있습니다.

일련번호에 대한 널리 알려진 반대 주장은 반기술적인 측면 또는 적어도 두 가지 주장으로 볼 수 있습니다. 보다 기술적인 측면은 식별자의 예측 가능성에 관한 보안 주장입니다. 누군가가 가시적인 데이터를 기반으로 식별자를 추측할 수 있다면, 추상적이고 상상의 세계에서도 더 많은 사람들이 앎에 따라 시스템을 더욱 점성할 수 있다는 것이 분명합니다. 그러나 가장 강력한 방어가 모호성인 경우, 아마도 여러분은 이미 문제가 있을 것입니다. 이것은 유효한 주장이라고 생각하지만 확실히 펜터스터들은 이러한 식별자를 좋아하며 방어는 데이터 설계가 아닌 다른 영역으로 완화될 수 있습니다.

이로부터 유래된 다른 주장은 비즈니스 관점이며 암묵적인 정보 누출과 함께 간주됩니다. 식별자가 데이터와 함께 증가할 때 데이터셋의 특성을 자연스럽게 노출시킬 때, 행을 전달할 때 데이터 세트의 특성을 노출시킵니다. 예를 들어, 블로그 API에서 950번 댓글을 받았을 때, 최소한 949개의 댓글을 받은 것으로 추론할 수 있습니다. 이는 특정 비즈니스에서는 염치 없는 일입니다. 그러나 고객이 볼 수 있는 식별자에서 아무 것도 유추할 수 없도록 숫자를 가리기 위한 기술이 있습니다. 지금까지 이러한 전략이 잘 맞는 상황을 기다리고 있지만 언급할 가치가 있는 것들입니다. Knuth의 곱셈 정수 해시 사용 등 몰입할 수 있는 기술 중 하나는 무작위 정수에 정수 키를 매핑하여 도메인 정보를 유지하거나 노출하지 않는 방법입니다.

채팅 디자인에 대한 제 주요 관심사 중 하나는 확장성 특성과 기타 요구 사항이었습니다. 먼저, 분산 시스템에서 기본 키의 필요성이 중앙 조정자 하나인 상황에서 문제가 발생했습니다. 고유성을 달성하기 위해 숫자를 증가시키는데, 이는 상태 유지 작업입니다. 증가시키려면 이전 단계를 알아야 하며 동시에 여러 경쟁 주체에 대해 동시에 증가시키기 어렵습니다. 따라서 식별자 생성은 데이터베이스에서 중앙적으로 이뤄지거나 다른 복잡한 조율이 필요합니다. 특히 여러 다른 부분이 서로를 차단하지 않고 유효한 행을 구성할 수 있어야 하는 경우 이런 문제가 발생합니다.

무작위 식별자의 사용 사례

무작위 식별자에 대한 주장을 좋아하지만, 가장 대표적인 예로 직접 언급하면 더 이해하기 쉬울 것 같습니다. 바로 Universal Unique Identifier (UUID) 또는 RFC 9562에 정의된 Global Unique Identifier (GUID)이 있습니다. 이 섹션에서는 UUID의 버전 네에 해당하는 내용이 주요하므로 이를 참조합니다. 표준의 다른 버전은 다른 개념을 적용합니다. 예를 들어, 제일 비웃받는 버전인 버전 1을 사용하면 식별자 충돌 없이 MAC 주소를 포함하는 것으로, 다른 수준의 정보 노출을 논의하게 됩니다.

일반적으로 UUID는 중괄호나 하이픈으로 된 16진수로 표시되며, 세 번째 그룹의 첫 번째 번호는 버전 번호를 나타냅니다. Postgres 출력 함수는 하이픈이 있는 레이아웃을 사용하지만 기술적으로 UUID는 단지 128비트 레이블일 뿐입니다.

Exploring the Right Fit: Choosing Primary Keys for Your Database

저의 경우에는 UUID를 사용하는 것에는 몇 가지 장점이 있습니다. 먼저, 이는 인기가 많고 오랫동안 사용되어온 표준이기 때문에 널리 받아들여지고 잘 알려져 있습니다. RFC 9562에는 사양을 준비할 때 고려된 16가지 유사한 대안 목록이 포함되어 있습니다. 이러한 식별자 대부분은 저장 공간을 줄이려고 하거나 합리적인 충돌 확률을 유지하거나 의미 있는 순서, 또는 더 나은 무작위성을 유지하려고 하는 특정 문제에 관련되어 있습니다. 이 글의 크기를 초과하지 않으려고 하여, "무언가를 개선한다"는 것이 무엇인지 약간의 대체 방안에 대해 간략히 소개하고자 합니다. 웹 개발 분야에서 대중적인 대안인 Cuid와 Nanoid부터 시작해보겠습니다. 한편, Cuid는 식별자를 생성할 때 높은 엔트로피를 갖고 있어 충돌 확률을 줄이고, Nanoid는 서로 다른 레이아웃과 문자 알파벳을 사용하여 형식화 및 무작위 생성에 초점을 맞추고 있습니다. 이로 인해 충돌 가능성을 줄이고, 특수 문자 없이 간단한 형식으로 만드는 것으로 특정 사용 사례에 더 적합한 식별자를 만들어냅니다. Nanoid의 레이아웃은 URL에 포함되었을 때 잘 작동하도록 설계되었으며, 알파벳은 사용 사례에 따라 사용자 정의할 수 있습니다. 이 유연성을 통해 생성된 식별자를 최적화하여 URL 호환성 보장, 사람이 읽기 쉽게 만들기, 또는 대시와 같은 문자를 생략하여 식별자를 복사하고 붙여넣기를 쉽게 만드는 등에 특정 요구 사항을 충족시킬 수 있습니다. 고유 식별자의 레이아웃이 중요하며 어떻게 특정 사용 사례에 맞게 설계할 수 있는지에 대해 논의한 흥미로운 글이 있습니다. 자세히 읽고 싶다면 여기를 클릭해주세요.

일반적으로 무작위 식별자의 가장 큰 장점은 어디서든 생성할 수 있고 조정이 필요하지 않다는 점입니다. 이에 대한 직접적인 결과로 UUID는 더 많은 공간을 차지합니다. 충분한 엔트로피를 얻기 위해서는 단순히 순차적으로 세는 것보다 더 많은 공간이 필요합니다. 그러나 대부분의 사용 사례에서는 이는 주로 이론적인 논점입니다. 제 마이크로 벤치마크를 조사한 결과, 대규모, 수십억 달러의 데이터 컨테이너를 디자인하지 않는 한, 심각한 성능 문제 없이 안전하게 UUID를 사용할 수 있다고 결론 내렸습니다.

데이터베이스에서 순전히 무작위 식별자를 사용하는 것에 대한 주된 반대 의견은 데이터베이스가 데이터를 구성하고 무작위 값의 국소성과 관련이 있습니다. 주요 키는 데이터베이스가 해당 데이터를 "다루는"데 있어서 결정적인 요소입니다. MySQL (InnoDB)와 같은 일부 데이터베이스는 주요 키를 기준으로 데이터를 순서대로 저장합니다. Postgres에서 해당하는 것은 클러스터 인덱스입니다. 순서대로 저장된 데이터를 다루면서 무작위 값이 삽입되면 대규모에서 문제가 발생합니다. 무작위 값이 저장되면 조각화, 페이지의 지속적인 재정렬 및 상당한 I/O 오버헤드로 이어집니다. 이는 데이터베이스 엔진이 주요 키가 지시하는 순서를 유지하기 위해 데이터를 지속적으로 재배치해야 하기 때문입니다. 결과적으로 데이터베이스는 더 많은 디스크 탐색 및 읽기/쓰기 작업을 수행해야 하며, 특히 데이터 세트가 커지면 성능에 상당한 영향을 줄 수 있습니다.

데이터가 순서대로 저장되지 않더라도 무작위 삽입은 인덱싱에 최적이 아닙니다. 인덱스를 관리하는 데 흔히 사용되는 B-트리 데이터 구조는 유익한 특성을 유지하기 위해 계속 리밸런싱하는 그래프 구조입니다. 트리를 자주 재밸런싱하는 것을 피하기 위해 단조로운 삽입이 선호됩니다. 이러한 종류의 삽입은 트리 구조의 자연 순서를 유지하기 때문에 B-트리를 자주 리밸런싱하게 하지 않습니다. 단조로 증가하거나 감소하는 순서로 키를 삽입할 때 새 키는 각각 B-트리의 오른쪽 또는 왼쪽 최하위 노드에 항상 추가됩니다. 이는 새 키가 트리의 기존 키보다 항상 크기 때문입니다. 결과적으로 B-트리 구조는 대부분 변경되지 않고 트리는 잎 노드가 가득 차며 분할된 경우에만 리밸런싱해야 합니다. 이 리밸런싱 및 노드 분할 과정은 비교적 드물며 트리의 작은 부분에만 영향을 미칩니다. 반면, 무작위 삽입은 거의 모든 삽입 시에 트리를 리밸런싱하게 합니다. 각 삽입마다 새 키를 위한 적절한 위치를 찾기 위해 트리를 탐색해야 하며, 노드가 가득 차면 분할되고 키가 재분배되어야 합니다. 이 리밸런싱 및 노드 분할 과정은 성능 저하로 이어집니다. 기본 키에 B-트리 인덱스를 사용해야만 하는 것은 아니지만 종종 기본 선택지인 이유가 있습니다. B-트리 인덱스는 효율적인 범위 쿼리와 고유 제약 조건 강제 등 기본 키에 중요한 유익한 특성을 제공합니다.

요약하면, UUID와 같은 무작위 키는 데이터베이스에서 성능 영향을 줄 수 있습니다. 그러나 이러한 영향을 자세히 살펴보기 전에 시스템이 잠재적인 성능 문제를 처리할 수 있는지, 특히 삽입 성능이 전혀 문제가 되는지 평가하는 것이 중요합니다. 제 경우에는 시스템의 규모가 더 정교한 분석을 정당화하지 않습니다. UUID를 사용하여 성능을 어디까지 향상시킬 수 있는지 실험하고 벤치마킹한 결과, 의도한 사용 사례에 대한 성능이 충분하다는 결론을 내렸습니다. 또한, 제 경우에는 시스템이 웹을 넘어 확장되어 충돌이 일어날 수 있으며 Postgres에는 이미 UUID를 지원하는 내장 지원이 있습니다.

무작위 값 넘어서기

무작위성은 순서, 정렬 및 값 비교를 의미 있는 방식으로 보장할 수 없다는 것을 의미합니다. UUID의 텍스트 표현을 고려할 때는 사전적으로 정렬할 수 있지만 시간이나 공간과 같은 특성으로는 불가능합니다. 따라서 보조 인덱스 없이 주 키는 쿼리 요구 사항을 고려했을 때 매우 유연하지 않습니다. 이러한 제한 사항들은 RFC 9562에서 잘 알려져 있으며 요약되어 있습니다. RFC는 이 식별자들의 진화에 대한 아이디어를 얻기에 좋은 자료입니다. 제공된 문제와 다른 UUID 특정 측면들이 이전 문제들의 일부를 해결하는 새로운 버전의 명세로 이어지게 됩니다. Universally Unique Lexicographically Sortable Identifier (ULID)와 같은 명세가 RFC 9562의 개발에 고려되었습니다. UUID의 제 7 버전은 명시적으로 순서와 무작위성을 결합하는 식별자의 좋은 예시로, 두 특성이 모두 필요한 사용 사례에 적합합니다.

이전에 식별자 변형에 대해 탐구하기 전에, 이 글에서 주로 사용하는 용어인 "순서" 또는 "정렬"이라는 용어가 매우 포괄적으로 사용된다는 점을 지적하고 싶습니다. 그러나 세부 사항을 살펴보면, 이 문제 도메인은 더 정확한 정렬 개념인 k-정렬이라는 용어를 가지고 있습니다. 시퀀스가 k-정렬된 것으로 간주되는 경우, 각 요소는 올바르게 정렬된 위치에서 최대 k 거리 이내에 있습니다. 다시 말해, k-정렬된 시퀀스의 요소를 정렬하려면 해당 요소가 최종 정렬된 위치에 도달하기 위해 왼쪽이나 오른쪽으로 최대 k 위치 이동해야 합니다. 이는 식별자가 정렬 가능하거나 정렬되어 있다고 주장할 때 종종 "대략적으로 정렬 가능하다"는 것을 의미합니다. 식별자에 타임스탬프 구성 요소를 포함하는 식별자를 고려할 때 k-정렬의 개념이 특히 관련이 있습니다. 시계 드리프트와 제한된 정밀도로 인해 식별자의 순서가 완벽하지 않을 수 있지만, 여전히 대략적으로 정렬되어 있어 각 요소가 시퀀스에서 올바른 위치에 근접해 있습니다. 이는 엄격한 순서가 필요하지 않은 많은 사용 사례에 충분하며, 일반적인 순서 개념은 유익합니다.

이 섹션에서 제시되는 개념은 식별자가 분산되어 있으면서 무작위성과 관련된 문제를 다루는 것입니다. 간단한 초기 접근 방식은 "분산" 결정론적 값(예: 생성 타임스탬프)을 선행하여 식별자를 정렬 가능하고 단조롭게 만드는 것입니다. 이 접두사는 식별자를 정렬 가능하고 준단조롭게 만듭니다. 레이아웃 세부 사항을 무시하면, 아래 그림에서 설명된 것처럼 UUID 버전 7은 이 개념의 인기 있는 변형입니다.

그림

재미있게도, 해당 명세는 토론할 가치가 있는 몇 가지 매개변수를 설명합니다. 첫 48 비트는 Unix Epoch Timestamp이며, 나머지 74 비트는 무작위 값으로 채워집니다. 그러나 명세는 경우에 따라 다른 방법을 제안합니다. 단조성이 더 중요한지 또는 무작위성이 더 중요한지. 사용 사례가 더 많은 엔트로피를 요구하는지, 무작위성을 위해 더 많은 공간을 사용해야 하는지 또는 강력한 알고리즘 보증서가 필요한지. 사용 사례가 더 강력한 단조성을 요구하는지, 무작위로 시드가 이용된 카운터를 사용하세요. 이전에 토론한 단점을 고려할 때, 트레이드오프가 반복됩니다. 무작위성이 늘어날수록 성능이 떨어지지만, 점점 정확한 단조성은 성능을 높이지만 예측 가능성을 도입합니다. 이것은 Cuid2와 같은 식별자를 살펴볼 때 토론의 중요한 부분으로 보입니다. 그들의 GitHub 저장소에서는 단조성에 반대하는 주장을 강조하고 있습니다. 이에 대한 가까운 친척인 ULID는 UUID의 제7 버전을 일부 영감을 받은 것으로 보입니다. ULID와 UUID를 비교할 때, 두 가지 모두 시간 기반 구성 요소를 도입한다는 개념을 가지고 있지만, UUID는 사양에서 더 엄격하려고 합니다. ULID는 분산 생성 기능과 사양의 간격에 대한 몇 가지 개방된 이슈가 있습니다. 따라서 일반적으로 생각해 볼 때, 다른 ULID 구현보다 UUID를 사용하는 것이 좋다고 생각합니다.

컴포지션 식별자 개념을 기반으로, 여러 호스트에 걸쳐 분산 동시 생성을 우선시하는 디자인을 탐색해볼 수 있습니다. 이러한 변형에는 타임스탬프 구성요소 대신 또는 함께 기계 식별자를 포함하는 것이 포함됩니다. 예를 들어 UUID는 기계 식별자를 포함하거나 타임스탬프 구성요소와 함께 제공하는 변형을 제공합니다. 이 접근 방식은 Snowflake 식별자로 대표적으로 나타납니다. 이들은 UUID보다는 더 간결하며 다른 구조를 가지며 세 가지 주요 부분으로 구성됩니다:

image

  • 타임스탬프 (41비트): 사용자 지정 에포크(예: 2010년 11월 4일)부터의 밀리초를 나타냅니다. 이를 통해 날짜/시간 기반 구성요소를 제공하여 ID가 대략 정렬됨을 보장하지만 선택한 에포크에 따라 더 간결할 수도 있습니다.
  • 워커 (10비트): 식별자를 생성한 워커(또는 기계)의 고유 식별자를 나타냅니다. 이를 통해 여러 기계가 충돌 없이 동시에 식별자를 생성할 수 있습니다.
  • 일련 번호 (12비트): 같은 워커가 같은 밀리초에 생성한 모든 식별자마다 증가하는 일련 번호를 나타냅니다. 이를 통해 동일한 워커가 동일한 밀리초 내에 만든 여러 식별자가 고유함이 보장됩니다.

이 구조는 UUID와 여러 측면에서 다릅니다. 첫째, Snowflake 식별자에는 순전히 무작위 구성요소가 포함되지 않습니다. 대신, 정렬 가능하고 분산 방식으로 생성 가능한 고유 식별자를 생성하기 위해 타임스탬프, 워커 식별자 및 일련 번호를 결합합니다. 둘째, UUID가 사용하는 128비트 대비 64비트만을 사용하여 더 간결합니다. Snowflake의 타임스탬프, 워커 식별자 및 일련 번호 조합은 여러 이점을 제공합니다:

  • 단조 수열: 타임스탬프 구성 요소는 ID가 단조적으로 증가하는 순서로 생성되므로 대략적으로 정렬 가능하다.
  • 의미론적 순서: 타임스탬프는 엔티티의 생성 시간을 나타내므로 식별자의 순서에 의미를 부여한다.
  • 분산 보증: 워커 구성 요소는 충돌 없이 여러 기기가 동시에 식별자를 생성할 수 있게 한다.

이 간결하고 구조화된 형태는 트위터나 디스코드와 같은 대규모 분산 시스템에서 특히 유용하며 채택되었습니다. 이 문맥에서 기계의 시간 정밀도와 시계 드리프트의 함의를 고려하는 것이 중요할 때가 많습니다. 완벽한 동기화와 무제한 정밀도는 평균 시스템에서 사용 가능하지 않을 수 있습니다. 그러나 대부분의 사용자, 저를 포함하여, 이 시점에 매우 대규모 소프트웨어 시스템에 관여하지는 않습니다.

이 섹션을 마치기 전에, 특정 데이터베이스 관련 문제가 없어도 이전 식별자를 어떻게 확장할 수 있는지에 대한 영감을 제공하고 싶습니다. 많은 독자들이 익숙할 수 있는 잘 알려진 식별자 형식 중 하나는 Stripe에서 사용하는 것입니다. 고객 객체에 대한 문서에서 직접 가져온 예제를 살펴보면 식별자에 특정 주제 접두사가 포함되어 있는 것을 확인할 수 있습니다.

이미지

내가 알기로 이 디자인의 주요 관심사는 식별자의 명확성 또는 투명성입니다. 고객 식별자는 도메인의 깊은 지식 없이 즉시 고객 엔티티와 연관시킬 수 있습니다. 그러나 이 개념은 데이터베이스 레이어에 접두사가 통합되지 않은 경우 UUID 또는 해당 식별자와 호환성을 유지할 수 있습니다. 이 개념의 범용화의 좋은 예는 typeid입니다.

훌륭한 팁을 곁들인 여정

식별자 디자인에 대한 최종적인 반성으로, 나는 처음에 제안한 채팅 시스템에 고유한 고려 사항 몇 가지를 간단히 탐구하고 싶습니다. UUID의 변형을 사용하기로 결정했지만, 낙관적 업데이트를 활성화하는 동안 문제에 직면했습니다. 서버 확인을 기다리지 않고 메시지가 즉시 표시되는 사용자 경험을 제공하기 위해 클라이언트 측에서 식별자를 생성해야 하는 필요성을 마주했습니다. 그러나 이 방식은 잠재적인 복잡성과 안전 문제를 도입합니다.

이 문제에 대처하기 위해, 서버가 별도의 주 키를 생성하되 클라이언트가 낙관적 업데이트에 UUID를 사용할 수 있도록 하는 메커니즘을 고안했습니다. 사용자가 새 메시지를 보낼 때 클라이언트가 UUID를 생성하고 채팅 인터페이스에 메시지를 낙관적으로 표시합니다. 클라이언트는 UUID를 메시지 데이터와 함께 서버로 보냅니다. 그러면 서버가 클라이언트 제공 UUID를 사용하여 메시지 레코드를 생성하고 메시지용 별도 주 키를 생성하여 UUID와 주 키를 모두 데이터베이스 레코드에 저장합니다.

메시지 레코드를 성공적으로 생성한 후, 서버는 생성된 기본 키를 응답의 일부로 클라이언트에게 전송합니다. 클라이언트는 미래 참조를 위해 UUID와 함께 기본 키를 저장합니다. 메시지와 관련된 편집이나 삭제와 같은 후속 작업을 위해 클라이언트는 UUID 또는 기본 키 중 하나를 서버로 전송할 수 있습니다. 서버는 빠르게 메시지 레코드를 데이터베이스에서 찾기 위해 기본 키를 사용하고, 제공된 기본 키가 저장된 UUID와 일치하는지 확인합니다. 일치하는 경우, 서버는 요청된 작업을 계속합니다. 일치하는 항목이 없으면 서버는 요청을 거부하며 잠재적인 데이터 불일치나 변조 시도를 나타냅니다.

UUID에 기반한 빠른 조회를 가능하게 하기 위해, 서버에 UUID와 해당 기본 키를 매핑하는 해시 인덱스를 구현했습니다. 이 해시 인덱스는 효율적인 O(1) 조회를 가능케 하며, 서버가 지정된 UUID에 연관된 기본 키를 빠르게 검색할 수 있습니다. 서버가 이러한 조회를 디스크 접근 없이 수행할 수 있도록 해시 인덱스를 메모리에 유지함으로써 레이턴시를 최소화할 수 있습니다. 그러나 해시 인덱스는 메시지 삽입 시 추가 오버헤드를 도입하는 것을 인지하고 있습니다. 서버는 기본 키를 생성하고 UUID와 함께 저장해야 하며, 이는 삽입 작업의 레이턴시를 약간 증가시킬 수 있습니다. 그렇지만, 데이터 무결성과 효율적인 조회의 이점을 고려할 때 이런 트레이드오프를 수용할 수 있다고 봅니다.

이 접근 방식은 클라이언트에서 UUID를 사용하여 낙관적 업데이트를 도와주는 동시에 서버 측에서 데이터 무결성과 효율적인 조회를 보장합니다. 서버는 별도의 기본 키를 생성하고 빠른 UUID-기본 키 조회를 위한 해시 인덱스를 유지하며, 후속 요청에서 UUID-기본 키 쌍을 유효성 검사합니다. 삽입 성능 영향이 있긴 하지만, 이 접근 방식의 전반적인 이점이 이 문맥에서 단점을 상쇄한다고 생각합니다.

일부 마무리할 생각들

이 글을 쓴 후에는 이 도메인의 콘텐츠가 대부분 표면적이고 모호한 이유를 깨달았어요. 처음 보면 간단해 보이지만, 올바른 기본 키 선택은 상당히 많은 트레이드오프, 변형, 기술적 세부 사항 및 영향을 동반합니다. 하지만 이 글은 이 주제의 내 나뉜 메모의 화려한 버전에 불과하며, 주요 키 선택에 대해 깊이 있는 이해를 얻고자 하는 다른 사람들에게 추가 가치를 제공하기를 바랍니다. 목표는 전형적인 고수준 조언을 넘어서 특정 데이터베이스 테이블이나 엔티티에 적합한 기본 키를 선택하는 데 고려해야 할 세세한 점과 고려 사항에 대해 탐구하는 것이었습니다. 다양한 종류의 키, 그들의 장단점 및 가장 적합한 시나리오를 살펴보면, 독자들은 데이터베이스 설계의 이 기본적인 측면에 대해 보다 포괄적인 시각을 얻을 것입니다.

만약 블로그 글 형식에 대해 가능한 정확하게 하려고 노력했지만 뭔가 잘못된 것을 찾으셨을 때 언제든지 연락 주세요. mastodon이나 x.com에서 저와 연락하실 수 있습니다.

추가로 읽을만한 자료와 자원