보통 내가 진행했던 프로젝트에서는 단일 데이터베이스를 사용하거나, 단일 시스템에서 동작했기에, auto_increment P.K 전략을 가져가도 중복된 값이 존재하는 경우가 거의 없기 때문에 문제가 되지 않았다. 하지만 대규모 분산 처리 시스템일 때는 이 자동 증가 전략이 문제가 될 수 있다. 여러 노드에서 정말 동시간에 row를 생성했을 대, 각 노드가 독립적으로 데이터를 생성하여 키를 생성하기 때문에, 중복된 키가 생성될 수 있다.
그렇다면, 분산환경일 때는 어떤 id 전략을 선택할 수 있는지 알아보고, 특정 id 전략을 선택했을 때의 성능 측정과 동시성 이슈에 대해서도 한번 살펴보자. 그 전에 우선 내가 주로 사용했던 auto_increment의 문제점에 대해서 먼저 알아보겠다.
대부분의 프로젝트에서 나는 ERD를 설계하고, 이를 Entity로 변환하는 작업에서 P.K(기본키) 전략으로
GenerationType.IDENTITY **로 가져갔다. 이는 auto_increment로 id의 수를 1씩 증가시키는 전략이다.
하지만 이는 여러 문제가 존재한다.
본격적인 설명에 앞서, jakarta 라이브러리에서 제공하는 GenerationType에는 어떤 값들이 있는지 먼저 알아보자.
아래는 GenerationType의 Enum 값 들이다.
GenerationType | 설명 | 지원 DB | 특징 |
---|---|---|---|
TABLE | 별도의 테이블을 사용해 키 값 관리 | 모든 DB | 성능이 낮지만 호환성이 높음 |
SEQUENCE | DB의 SEQUENCE 객체 사용 | Oracle, PostgreSQL 등 | 성능이 좋고 커스터마이징 가능 |
IDENTITY | AUTO_INCREMENT 사용 | MySQL, SQL Server 등 | DB가 키 값을 자동 생성 |
UUID | UUID(랜덤) 값 사용 | 모든 DB | 충돌이 거의 없지만 인덱스 부담이 있음 |
AUTO | JPA가 자동으로 전략 선택 | 모든 DB | DB에 따라 적절한 전략이 자동 선택됨 |
그래서 위의 전략은 다음과 같은 단점이 있다.
하지만 위의 전략은 다음과 같은 문제 혹은 단점이 있다.
ex) UUID value : cc3246d1-01ga-4a90-b237-02a2dc956dkl
하지만 위의 전략도 다음과 같은 단점이 있다.
ִInnoDB는 기본키(PK)의 B+Tree에 테이블 행을 저장한다.이를 클러스터형 인덱스(clustered index)라고 부른다. 클러스터형 인덱스는 기본키를 기준으로 자동으로 행의 순서를 지정한다. 그런데 무작위 UUID를 가진 행을 삽입하면 여러 문제가 발생할 수 있어 성능 저하를 초래한다. 이와 관련된 내용은 아래 참고자료를 살펴보자.
MySQL UUIDs - Bad For Performance
The best UUID type for a database Primary Key - Vlad Mihalcea
ULID는 위의 UUID와 많이 유사하다. ULID도 128 bit의 크기를 가진다.
ULID의 앞의 48 bit 를 차지하는 Timestamp는 시간순의 정렬이 가능하다.
뒤의 80 bit의 크기를 가지는 Randomness가 있다.
밀리 sec내에 동싱 생성되면 무작위의 성질에 따라서 순서가 달라진다.
UUID의 단점을 해결하기 위해 만들어졌으며, 36자인 UUID와 다르게, ULID는 26문자로 인코딩된다.
특수 문자가 없으며, 대소문자를 구분하지 않는다.
시간순 정렬이 가능하다.
효율성과 가독성을 위해 Crockford의 Base32를 사용한다.
하지만 위의 전략도 다음과 같은 단점이 있다.
auto_increment 전략을 갖춘 데이터베이스를 중앙 집중형으로 하나만 사용하는 것이다.
분산환경에서 고유한 식별자를 생성하고 관리하기 위하여 사용되는 서버를 의미하는 것이, 티켓 서버이다.
티켓 서버는 다양한 클라이언트나 서비스가 동시에 접근해, 고유한 티켓(식별자)를 얻을 수 있는 하나의 장소이다.
유일성이 보장되는 숫자로만 구성된 id를 쉽게 구성할 수 있다.
구현하기 쉬우며, 작은 애플리케이션에 적합하다.
하지만 위의 전략도 다음과 같은 단점이 있다.
트위터에서 고유 id를 생성하기 위해 발표한 전략으로, 대규모 분산 환경에서 고유성, 그리고 정렬 가능성을 보장한다.
트위터는 고가용성 방식으로 초당 수만 개의 id를 생성할 수 있는 것이 필요하였고, 이러한 id 전략은 대략적으로 정렬이 가능해야 하며, 64 bit의 용량을 가진다. MySQL 기반의 티켓 서버는 일종의 재 동기화 루틴을 구축해야 했으며, 다양한 UUID는 128 bit가 필요했다. 대략적으로 정렬된 64 bit 용량을 가진 id를 생성하기 위해서 Timestamp, 작업자 번호(worker number) 및 sequece number의 구성으로 결정하였고, sequence number는 thread 별로 지정이 되고, worker number는 시작 시 Zookeeper를 통해 선택이 된다.
트위터가 Open Source System으로 공개한 id generator 이다.
Time-based 기반이 id이다.
확장이 가능하고, 병렬로 유일성을 가진 id를 생성이 가능하다.
생성해야 하는 id 구조를 여러 section으로 분할하여 사용한다.
동일한 시스템에서 동시에 생성이 되는 snowflake id의 중복을 막기 위해 사용되며, 최대 1024개를 구분할 수 있다. → Data Ceneter(32개) * Server ID(32개) = 1024개
밀리초 | Sequence Number | 생성된 Snowflake ID 예시 |
---|---|---|
12:00:00.001 | 0 | 101010001...0000000001-0000 |
12:00:00.001 | 1 | 101010001...0000000001-0001 |
... | ... | ... |
12:00:00.001 | 4095 | 101010001...0000000001-4095 |
12:00:00.002 | 0 | 101010001...0000000002-0000 |
하지만 위의 전략도 다음과 같은 단점이 있다.
트위터의 스노플레이크와 UUID 의 특성을 결합한 라이브러리이다.
시간을 기반으로 고유한 id를 생성하는 방식이다.
64 bit 정수 생성이 가능하며, 상대적으로 적은 용량을 차지한다.
문자열 형식은 Crockford의 base32로 저장이 가능하다.
UUID, ULID 보다 짧다.
13자의 문자로 저장이 가능하다.
TSID는 Time component 42 bit와 Random component 22 bit로 이루어져 있다.
Time component는 부호 있는 64bit 정수 필드에 저장되면 약 69년 동안 사용 가능하고, 부호 없는 64bit 정수 필드에 저장되면 약 139년 동안 사용 가능하다.
데이터베이스에 BigInt로 저장되고, Java에서는 long으로 사용이 가능하다.
시계열 정렬이 가능하며, P.K를 Byte 배열 대신, 읽을 수 있는 정수(64 bit)로 가져온다.
JPA와 Hibernate에서 구현되어 있기 때문에, 쉽게 코드를 작성할 수 있다.
implementation 'io.hypersistence:hypersistence-utils-hibernate-60:3.5.1'
---
import io.hypersistence.utils.hibernate.id.Tsid;
import jakarta.persistence.Id;
public class ArticleEntity {
@Id @Tsid
private Long id;
private String title;
}
목표:
다양한 ID 생성 전략( UUID, Snowflake, TSID)의 성능과 유일성을 비교하고, 실제 프로젝트에 적합한 전략을 선택할 수 있도록 분석한다.
테스트 환경:
전략 | 설명 |
---|---|
UUID | 128 bit 무작위 문자열. 충돌 가능성이 거의 없으며 네트워크 독립적이다. |
Snowflake | 트위터의 고유 ID 전략. 시간 + 노드 ID + 시퀀스 번호로 구성되며 정렬 가능. |
TSID | Snowflake와 유사하지만 랜덤 컴포넌트를 추가해 성능 최적화. 빠르고 간단함. |
package io.soo.sample.codenotesample;
import java.util.UUID;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
public class IdGenerationTest {
private static SnowflakeGenerator snowflakeGenerator;
private static TsidGenerator tsidGenerator;
@BeforeAll
static void setUp() {
snowflakeGenerator = new SnowflakeGenerator(1, 1);
tsidGenerator = new TsidGenerator();
}
@Test
void testIdGenerationPerformance() {
long start, end;
//UUID 테스트
start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
UUID.randomUUID();
}
end = System.currentTimeMillis();
System.out.println("UUID 1,000,000 IDs: " + (end - start) + " ms");
// Snowflake 테스트
start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
snowflakeGenerator.nextId();
}
end = System.currentTimeMillis();
System.out.println("Snowflake 1,000,000 IDs: " + (end - start) + " ms");
// TSID 테스트
start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
tsidGenerator.generate();
}
end = System.currentTimeMillis();
System.out.println("TSID 1,000,000 IDs: " + (end - start) + " ms");
}
}
package io.soo.sample.codenotesample;
public class SnowflakeGenerator {
private final long epoch = 1640995200000L; // Epoch 기준 시간
private final long workerId, datacenterId;
private long sequence = 0L;
private long lastTimeStamp = -1L;
public SnowflakeGenerator(long workerId, long datacenterId) {
this.workerId = workerId;
this.datacenterId = datacenterId;
}
public synchronized long nextId() {
long timeStamp = System.currentTimeMillis();
if (timeStamp == lastTimeStamp) {
sequence = (sequence + 1) & 4095;
if (sequence == 0) {
while (timeStamp <= lastTimeStamp) {
timeStamp = System.currentTimeMillis();
}
}
} else {
sequence = 0;
}
lastTimeStamp = timeStamp;
return ((timeStamp - epoch) << 22) | (datacenterId << 17) | (workerId << 12) | sequence;
}
}
package io.soo.sample.codenotesample;
import java.util.concurrent.atomic.AtomicLong;
public class TsidGenerator {
private final AtomicLong counter = new AtomicLong();
public long generate() {
return (System.currentTimeMillis() << 22) | counter.incrementAndGet();
}
}
package io.soo.sample.codenotesample;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class IdGenerationBenchmark {
private SnowflakeGenerator snowflakeGenerator;
private TsidGenerator tsidGenerator;
@Setup(Level.Trial)
public void setUp() {
snowflakeGenerator = new SnowflakeGenerator(1, 1);
tsidGenerator = new TsidGenerator();
}
@Benchmark
public UUID benchMarkUUID() {
return UUID.randomUUID();
}
@Benchmark
public long benchMarkSnowflake() {
return snowflakeGenerator.nextId();
}
@Benchmark
public long benchMarkTSID() {
return tsidGenerator.generate();
}
}
package io.soo.sample.codenotesample;
import java.util.UUID;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.stream.IntStream;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
public class IdGenerationThreadTest {
private static SnowflakeGenerator snowflakeGenerator;
private static TsidGenerator tsidGenerator;
private static final int THREAD_COUNT = 100;
private static final int IDS_PER_THREAD = 10_000; // 각 쓰레드에서 생성할 ID 수
@BeforeAll
static void setUp() {
snowflakeGenerator = new SnowflakeGenerator(1, 1);
tsidGenerator = new TsidGenerator();
}
@Test
void testUUIDUniquenessInMultithreadedEnvironment() throws InterruptedException {
testIdUniqueness(() -> UUID.randomUUID().toString(), "UUID");
}
@Test
void testSnowflakeUniquenessInMultithreadedEnvironment() throws InterruptedException {
testIdUniqueness(() -> String.valueOf(snowflakeGenerator.nextId()), "Snowflake");
}
@Test
void testTSIDUniquenessInMultithreadedEnvironment() throws InterruptedException {
testIdUniqueness(() -> String.valueOf(tsidGenerator.generate()), "TSID");
}
private void testIdUniqueness(IdSupplier idSupplier, String generatorName) throws InterruptedException {
Set<String> idSet = ConcurrentHashMap.newKeySet();
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
Runnable generateIds = () -> {
IntStream.range(0, IDS_PER_THREAD).forEach(i -> idSet.add(idSupplier.get()));
latch.countDown();
};
// 다중 쓰레드 실행
IntStream.range(0, THREAD_COUNT).forEach(i -> new Thread(generateIds).start());
latch.await(); // 모든 쓰레드의 작업 완료 대기
// 중복 검사
int totalIdsGenerated = THREAD_COUNT * IDS_PER_THREAD;
if (idSet.size() == totalIdsGenerated) {
System.out.println(generatorName + " - All IDs are unique!");
} else {
System.out.println(generatorName + " - Duplicate IDs found! Total: " + totalIdsGenerated +
", Unique: " + idSet.size());
}
}
@FunctionalInterface
interface IdSupplier {
String get();
}
}
TSID가 모든 면에서 가장 우수한 성능을 보였다.
Snowflake는 정렬과 유일성에서 강점을 가지지만 성능 면에서 TSID에 미치지 못한다.
UUID는 간단하지만 성능 저하와 인덱스 비효율성을 해결해야 한다.
성능이 가장 중요한 환경에서는 TSID를 사용하고, 정렬과 확장이 필요한 분산 시스템에서는 Snowflake를 사용하는 것이 적합할 것 같다. UUID는 설정이 간단하지만 성능과 인덱스 효율성을 고려해야 하며, 그래서 내린 결론은 각 전략의 특성을 이해하고 프로젝트 상황에 맞게 선택하는게..!
https://techblog.woowahan.com/17221/
내추럴 ID!!