이때 dietId는 PK이며 노출되고 있는 상황입니다. 단순히 증가하는 형태인 것을 눈치채고 조회하는 요청에 그럴듯한 id를 넣게 된다면 잘못하다간 접근 권한이 없는 데이터에 접근이 가능해질 수도 있고, 내부 시스템을 어느 정도 유추할 수도 있습니다.
물론 인증 및 인가 처리를 잘하면 괜찮겠지만 실수라도 발생할 수 있고 이러한 가능성을 최대한 없애야겠다는 생각에 다른 생성 전략을 찾게 되었고 결론적으로는 TSID를 도입하게 되었습니다.
PK는 항상 유일해야 한다는 특성이 있습니다. 따라서 어떠한 시스템에서도 유일한 ID를 생성하는 것이 목적이고 그런 ID 생성하는 데에는 몇 가지 방법이 있습니다.
UUID
UUID는 'Universally Unique Identifier'의 약자로 128-bit의 고유 식별자입니다. UUID에는 여러 버전이 있으며 위키피디아를 인용하면 지극히 낮은 충돌 가능성을 가지고 있습니다.
충돌이 한 번이라도 발생할 확률이 50%가 되기 위해 생성해야 하는 version-4 UUID의 개수는 약 2.71경(2.71 quintillion)이며, 이는 다음과 같이 계산된다. https://en.wikipedia.org/wiki/Universally_unique_identifier
UUID의 예시는 다음과 같습니다.
[3f47c96a-7f68-4b6b-a5cc-8cde7acbb63b]
128-bit의 숫자 문자열에 총길이가 36자리입니다. 즉 32개의 16진수 숫자와 4개의 하이픈(-)으로 나누어진 형태입니다. 예시에서 볼드로 처리된 부분은 현재 UUID의 버전을 나타냅니다. 각 하이픈 사이에 있는 16진수 숫자들이 하나의 필드입니다. 각 필드에 대한 설명은 현재 게시글의 목적과는 거리가 멀 기 때문에 해당 부분의 내용은 토스 페이먼츠의 테크 블로그를 참조하면 될 것 같습니다.
UUID 버전 1,2,6은 시간과 MAC address를 이용하여 생성하기 때문에 ID가 만들어지는 시점이나 기기의 정보를 알 수 있습니다. 버전 3과 5는 네임스페이스 기반으로 해싱 알고리즘과 함께 생성됩니다. 위의 예시와 같은 버전 4는 1,2,6 버전과는 다르게 온전히 랜덤 한 값으로 만들어지기 때문에 시간과 기기와는 아무 상관이 없는 값이 나옵니다. 비교적 최근에 나온 버전 7은 시간과 랜덤성을 함께 이용하여 ID를 생성합니다. 따라서 high-load database나 분산 시스템에 적합합니다.
장점과 단점을 정리해 보겠습니다.
장점
대부분의 언어는 UUID 라이브러리를 지원하기 때문에 UUID를 만드는 것은 단순하며 쉽다.
서버 사이의 조율이 필요 없으므로 동기화 이슈도 없다. (분산 시스템)
단점
ID 128-bit로 비교적 길다.
Version 4를 사용하면 시간순으로 정렬할 수 없다. -> INSERT 시마다 인덱스의 B+ Tree 구조가 계속 재배열되어야 한다.
ID에 숫자가 아닌 값이 포함될 수 있다.
이러한 UUID의 특성들을 보아 다양한 용도로 사용할 수 있지만, 현재 데이터베이스의 PK로 사용하는 것에 과연 최선일까라는 고민이 들었습니다. 데이터베이스의 인덱스와 함께 고려를 해본 결과 UUID는 선택하지 않기로 결정했습니다.
현재 TnT는 MySQL을 사용하기 때문에 InnoDB 스토리지 엔진을 사용하고 있어 InnoDB의 특성을 고려했습니다. InnoDB 스토리지 엔진은 디스크에 데이터를 저장하는 가장 기본 단위를 페이지(Page) 또는 블록(Block)이라고 하며, 디스크의 모든 읽기 및 쓰기 작업의 최소 작업 단위가 됩니다. 인덱스 또한 페이지 단위로 관리되며, 루트, 브랜치, 리프 노드를 구분한 기준이 바로 페이지 단위입니다. 인데스의 자료구조인 B-Tree는 자식 노드의 개수가 가변적인 구조입니다. 그리고 인덱스의 페이지 크기와 키 값에 크기에 따라 자식 노드를 몇 개까지 가지는지 결정하죠.
InnoDB 스토리지 엔진의 페이지 크기를 innodb_page_size 시스템 변수를 이용해 4KB ~ 64KB로 설정이 가능하지만 기본적으로는 16KB입니다. 책 Real MySQL 8.0에서 나오는 예시를 보겠습니다. 페이지의 크기가 16KB이며, 인덱스의 키가 16바이트, 자식 노드 주소의 크기는 12바이트라고 가정하겠습니다.
이런 경우 하나의 인덱스 페이지(16KB)에 저장할 수 있는 키의 개수는 다음과 같습니다: 16 * 1024 / (16 + 12) = 585
즉 585개의 자식 노드를 가지는 B-Tree가 되는 것입니다. 하지만 인덱스의 키 값이 커진다면 어떻게 될까요?
키 값의 크기가 두 배(32byte)로 늘어났다고 가정하면: 16 * 1024 / (32 + 12) = 372 이 됩니다.
이렇게 되면은 페이지가 늘어나게 될 수 있고, B-Tree의 깊이까지 늘어나기 때문에 그 결과로 디스크 I/O의 횟수가 증가할 수 있다는 것입니다.
UUID의 단점이 길이가 위와 같은 영향을 줄 것으로 예상되어 보류하게 되었습니다.
TSID
TSID는 "Time-Sorted Unique Identifier"의 약자로 64-bit의 정수 및 13자리의 문자열로 표현됩니다. TSID는 ULID와 트위터의 Snowflake 기법을 결합한 아이디어로 탄생하였고 예시는 다음과 같습니다.
64-bit 중에서 42-bit의 타임스탬프와 22-bit의 랜덤 구성 요소로 이루어져 있습니다.
타임스탬프는 unix epoch와 비슷하게 특정 시점부터 카운트가 시작되었습니다. TSID의 경우에는 2020-01-01 00:00:00 UTC를 시작으로 millisecond의 타임스탬프를 가지고 있습니다.
랜덤 구성 요소는 다시 Node ID(0 to 20 bits)와Counter(2 to 22 bits)로 이루어져 있습니다. Counter bits는 Node bits에 의존적입니다. Node bits가 10이라면, Counter bits는 12 bits가 되는 것이죠. 이 예시대로라면
최대 node 값은 2^10-1 = 1023이고,
최대 counter 값은 2^12-1 = 4095입니다.
따라서 millisecond당 생성할 수 있는 TSID의 개수는 4096입니다.
결국 42-bit가 타임스탬프로 쓰이기 때문에 무한한 유일한 ID를 생성할 수는 없고, SIGNED 64 integer field에 저장 시 대략 69년(2^41), UNSIGNED 64 integer field에 저장 시 139년(2^42) 사용 가능합니다.
@Entity
@Getter
@Table(name = "pt_lesson")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PtLessonJpaEntity extends BaseTimeEntity {
@Id
@Tsid
@Column(name = "id", nullable = false, unique = true)
private Long id;
...
}
이러한 특성들을 보아 TSID는 다음과 같은 요구사항을 만족할 수 있다는 것을 알았습니다.
길이가 128-bit인 UUID 보다 더 짧을 것 (인덱스의 크기가 작음)
특정 순서대로 정렬이 가능할 것 -> Insert 마다 재배열 방지
생성하고 사용하기 쉬울 것
결론
이전에는 AUTO_INCREMENT로 모든 데이터의 PK를 정했던 반면, 이번 기회를 통해 TSID를 도입하여 좀 더 안전하고 효율적인 PK ID 할당에 대해 고민해 볼 수 있었습니다. 또한, 시스템 내부에서만 돌아다니고 절대로 클라이언트에게 노출이 되지 않는 데이터는 기존 AUTO_INCREMENT를 그대로 유지하도록 했습니다.
지금은 단일 서버를 기준으로 TSID를 채택하였지만, 특성들을 보았을 때 분산 시스템에서도 꽤 유용하게 사용될 것 같았습니다. 그렇다고 무조건 TSID가 UUID나 다른 방식보다는 나은 것이 아니라, 상황에 맞게 트레이드 오프를 잘 고려하여 기술을 선택하는 것이 개발자의 덕목이라고 생각합니다. 읽어주셔서 감사합니다.