Project - TnT

[TnT] TSID 도입 - DB PK를 위한 ID 생성

김앵맹 - Backend 2025. 2. 21. 01:30
본 게시글은 IT 동아리 YAPP 25기에서 활동하면서 론칭한 앱 TnT를 개발하며 생각과 고민을 적은 게시글입니다.
 

GitHub - YAPP-Github/TnT-Server: 🧨 Trainer & Trainee [YAPP 25th App-2 Team]

🧨 Trainer & Trainee [YAPP 25th App-2 Team]. Contribute to YAPP-Github/TnT-Server development by creating an account on GitHub.

github.com

 

들어가며

TnT 서비스의 기초가 될 데이터베이스를 설계하면서 각 엔티티에 대한 PK를 어떻게 생성하면 좋을지 고민하게 되었습니다.

이전까지는 DB에서 자동으로 생성해 주는 (MySQL의 Auto Increment) 값을 PK로 설정했습니다.

그러나 해당 서비스는 사용자들의 개인정보인 이름, 나이, 사진, 일정 등이 있고, 이는 꽤 민감한 정보이기 때문에 보안적인 요소도 고민하게 되었습니다.

흔히 사용하는 Auto Increment는 1부터 시작하여 데이터 생성 순서대로 올라가면서 PK를 설정하기 때문에, 외부에서 어느 정도 추측할 수 있게 된다는 것을 느꼈습니다.

서버는 REST API로 요청 및 응답을 주고받기 때문에 응답 바디를 보면 다음과 같이 나오기 때문입니다.

{
	"dietId": 12345,
	"date": "2025-01-01T11:00:00",
	"dietImageUrl": "https://images.xxxx.co.kr/a3hf2.jpg",
	"dietType": "BREAKFAST",
	"memo": "아 배부르다."
    	...
}

 

이때 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 기법을 결합한 아이디어로 탄생하였고 예시는 다음과 같습니다.

38352658567418869 (Long)
01226N0640J7S (String)

TSID-creator Github: https://github.com/f4b6a3/tsid-creator

 

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) 사용 가능합니다.

 

TnT에서 사용된 Java에서는 다음 라이브러리를 이용하여 쉽게 사용 가능합니다.

implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0'

 

@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나 다른 방식보다는 나은 것이 아니라, 상황에 맞게 트레이드 오프를 잘 고려하여 기술을 선택하는 것이 개발자의 덕목이라고 생각합니다. 읽어주셔서 감사합니다.

 

 

 

Appstore:

 

‎TnT - 트레이너와 회원의 PT 관리

‎TnT - 지속 가능한 PT를 위한 트레이너와 회원의 PT 관리 서비스 트레이너와 트레이니를 연결하는 맞춤형 PT 관리 앱! [ TnT 주요 기능 ] 1. 트레이너를 위한 PT 관리 시스템 - 홈 화면에서 간편하게 P

apps.apple.com

Google Play:

 

TnT - 트레이너와 회원의 PT 관리 - Google Play 앱

트레이너와 트레이니의 케미 터트리기! PT를 더욱 지속 가능하게 돕는 트레이너와 트레이니의 네트워크 서비스

play.google.com

 

Reference