다양한 유일 ID 생성 전략 비교 및 Snowflake 기반 기본키 도입하기
Implementing Unique ID Generator Based on Snowflake Algorithm in Java
유일 ID 생성은 데이터 무결성을 유지하고, 분산 환경에서 안정성을 확보하며, 대규모 배치 작업의 효율성을 높이기 위해 필수적인 요소입니다. 따라서 프로젝트의 규모와 요구사항에 따라 적합한 ID 생성 방식을 선택하는 것이 중요합니다. 예를 들어, 자동 증가 키는 단일 노드 환경에서 간단하고 효율적으로 작동하며, 데이터 생성 빈도가 낮은 소규모 프로젝트에서 유용하게 사용될 수 있습니다. 반면, UUID는 분산 환경에서 고유성을 보장하지만, 데이터 저장 공간 요구량이 크고 성능 저하의 원인이 될 수 있습니다.
이 글에서는 다양한 고유 ID 생성 전략을 비교하며 각각의 특징과 활용 사례를 살펴보고, 마지막으로 Snowflake 알고리즘 기반 ID 생성 방식을 프로젝트에 도입하는 과정을 간략히 정리하였습니다.
Various Strategies for Unique ID Generation
고유 ID 생성은 다양한 방식으로 구현될 수 있습니다. 각 방식은 특정 상황에서의 장점과 단점이 있으며, 프로젝트의 규모와 요구 사항에 따라 최적의 방법을 선택해야 합니다. 여기서는 대표적인 고유 ID 생성 방법들을 자세히 살펴보겠습니다.
Auto Increment
데이터베이스에서 기본적으로 제공하는 자동 증가 키(Auto Increment)는 단일 노드 환경에서 간단하고 직관적으로 유용한 방식입니다. 이 방식은 복잡한 설정이 필요 없으며, 데이터베이스가 고유 ID를 자동으로 관리해 주기 때문에 초기 개발 단계에서 특히 효율적입니다. 고유 ID를 자동으로 증가시키는 데이터베이스의 메커니즘 덕분에, 개발자는 별도의 고유 ID 생성 로직을 구현할 필요 없이 이를 간편하게 활용할 수 있습니다.
아래는 간단한 MySQL 테이블을 생성하는 코드입니다:
create table cats
(
id bigint auto_increment primary key,
name varchar(100) not null,
born_at timestamp default current_timestamp not null
);
이 테이블에 새로운 레코드를 추가하면 자동으로 id
값이 1씩 증가합니다. 이를 확인하기 위해 다음과 같이 데이터를 추가할 수 있습니다:
insert into cats (name) values ('Kitty');
insert into cats (name) values ('Tiger');
insert into cats (name) values ('Shadow');
추가된 데이터를 조회해보면, ID 값을 별도로 지정하지 않았지만 자동으로 입력된 것을 확인할 수 있습니다:
ID | NAME | BORN AT |
---|---|---|
1 | Kitty | 2025-01-08 10:00:00 |
2 | Tiger | 2025-01-08 10:05:00 |
3 | Shadow | 2025-01-08 10:10:00 |
자동 증가 키 방식은 효율적이고 직관적이며, 값이 일정하게 증가하기 때문에 정렬 작업이 간단하다는 장점이 있습니다. 그러나 분산 환경에서는 다음과 같은 한계점이 존재합니다:
- 충돌 위험: 여러 데이터베이스 인스턴스가 동일한 자동 증가 키를 생성할 수 있어, 데이터가 중복되거나 충돌이 발생할 가능성이 있습니다. 이는 분산된 시스템에서 데이터 일관성을 유지하는 데 큰 문제를 초래할 수 있습니다.
- 병목 현상: 자동 증가 키는 단일 데이터베이스 노드에 의존하여 생성되므로, 많은 트래픽이 몰리면 데이터베이스가 병목 지점이 되어 시스템 성능이 저하될 수 있습니다.
- 데이터베이스 의존성: 고유 ID 생성을 위해 항상 데이터베이스와 통신해야 하므로, 서비스 설계와 성능 최적화에 제약이 발생합니다. 이는 데이터베이스 연결이 불안정하거나 지연이 큰 환경에서 특히 문제를 일으킬 수 있습니다.
- 배치 작업의 비효율성: 대량의 데이터를 한 번에 삽입하는 배치 작업에서는 자동 증가 키 방식이 비효율적일 수 있습니다. 각 데이터 삽입 시 데이터베이스와 통신해야 하므로 작업 시간이 늘어나고 시스템 부하가 증가할 수 있습니다.
이러한 한계로 인해 자동 증가 키 방식은 대규모 분산 환경이나 데이터 삽입이 빈번한 시스템에는 적합하지 않을 수 있습니다. 그럼에도 불구하고, 그 단순성과 사용의 편리함 덕분에 소규모 프로젝트나 단일 노드 환경에서는 널리 사용되고 있을 것으로 짐작됩니다. 😉
UUID
UUID(Universally Unique Identifier)는 전 세계적으로 고유한 식별자를 생성하기 위한 표준입니다. 이는 128비트 크기의 고유 식별자로, 네트워크 상에서 고유성을 보장할 수 있도록 설계되었습니다. UUID는 주로 분산 시스템에서 독립적으로 ID를 생성하거나 데이터 충돌을 방지하기 위해 사용됩니다.
UUID는 총 128비트(16바이트)로 구성되며, 보통 8-4-4-4-12의 5개 그룹으로 구분된 36자리의 16진수 문자열로 표현됩니다. UUID의 구성 요소는 다음과 같습니다:
구성 요소 | 크기 | 설명 |
---|---|---|
⏰ 시간 스탬프 | 60비트 | UUID가 생성된 시간을 밀리초 단위로 나타냅니다. |
🔢 클럭 시퀀스 | 14비트 | 시간 변경이나 클럭 동기화 오류를 처리하기 위해 사용됩니다. |
🖥 노드 ID | 48비트 | 주로 네트워크 카드의 MAC 주소를 기반으로 고유성을 보장합니다. |
UUID는 여러 버전으로 생성되며, 각 버전은 사용 목적과 생성 방식에 따라 설계되었습니다. 일부 버전은 시간 기반으로 고유성을 보장하고, 다른 버전은 해시 또는 난수를 사용해 고유성을 확보합니다. 이를 통해 다양한 상황에서 UUID를 유연하게 활용할 수 있습니다:
- UUIDv1
- 생성 시점의 시간(timestamp)과 노드(MAC 주소 또는 대체 노드 값)를 기반으로 생성됩니다.
- 고유성이 높고, 네트워크 환경에서 중복되지 않는 ID 생성이 가능합니다.
- 일부 구현에서는 MAC 주소 대신 임의의 노드 값을 사용하여 보안을 강화하기도 합니다.
- 네트워크 시스템 간 고유성 보장이 필요한 환경에 적합하지만, 보안 문제로 최근에는 사용 빈도가 줄어들었습니다.
- UUIDv2
- UUIDv1을 기반으로 사용자 ID와 같은 로컬 정보를 추가하여 생성됩니다.
- POSIX 시스템의 DCE(Security Version) 표준에 정의되어 있으며, 특정 내부 애플리케이션에서 사용되었습니다.
- 대부분의 UUID 라이브러리에서 지원하지 않으며, 보안 취약성으로 인해 현재는 거의 사용되지 않습니다.
- UUIDv3
- 네임스페이스와 입력값의 MD5 해시를 기반으로 생성됩니다.
- 동일한 입력값에 대해 항상 같은 UUID를 생성하므로, 재현 가능한 고유 ID가 필요할 때 적합합니다.
- MD5 알고리즘은 보안상 약점이 있지만, 데이터 일관성과 고유성이 중요한 환경에서는 유용합니다.
- UUIDv4
- 난수 또는 의사 난수를 기반으로 생성됩니다.
- 고유성이 매우 강력하게 보장되며, 네트워크 상태에 의존하지 않아 분산 시스템에서 널리 사용됩니다.
- 시간 기반 정렬이 불가능하므로, 로그 정렬과 같은 특정 사용 사례에서는 단점이 될 수 있습니다.
- 간단하면서도 고유성이 요구되는 대부분의 환경에 적합합니다.
- UUIDv5
- 네임스페이스와 입력값의 SHA-1 해시를 기반으로 생성됩니다.
- UUIDv3과 유사하지만 SHA-1 해시를 사용하여 더 강력한 보안을 제공합니다.
- SHA-1은 암호학적으로 안전하지 않다고 평가받고 있지만, 데이터 기반 고유 ID 생성에 적합합니다.
- 암호화보다는 고유성과 일관성을 보장하는 환경에서 사용됩니다.
이 외에도 UUIDv6, UUIDv7, UUIDv8은 비교적 최근에 정의된 버전으로, 특정 용도에 맞춰 설계되었습니다. 그러나 이러한 버전들은 성능 문제, 시스템 간 호환성, 그리고 설계상의 제약으로 인해 보편적으로 사용되기보다는 특정 환경이나 요구사항에 맞는 제한적인 상황에서 주로 활용됩니다.
Java에서는 java.util
패키지의 UUID 클래스를 사용하여 간단하게 UUID를 생성할 수 있습니다. 아래는 UUID를 생성하고 출력하는 예제입니다:
UUID uuid = UUID.randomUUID();
System.out.println("Generated UUID: " + uuid.toString());
위 코드는 실행 시 전역적으로 고유한 식별자를 생성합니다. 예를 들어, 다음과 같은 결과를 얻을 수 있습니다:
Generated UUID: 550e8400-e29b-41d4-a716-446655440000
Generated UUID: 123e4567-e89b-12d3-a456-426614174000
Generated UUID: 9e107d9d-3721-4a6e-a10a-1ffeddfcbd00
고유 ID를 생성할 때는 고유성이 가장 중요한 요소입니다. 하지만 애플리케이션의 요구사항에 따라 이를 생성하는 속도도 고려해야 합니다. 이를 검증하기 위해 대규모 트래픽 환경을 시뮬레이션하는 테스트 코드를 작성해 보았습니다:
@Test
void shouldGenerateUUID() throws Exception {
// Given
int totalRequests = 50_000;
int threadCount = 10;
int maxElapsedTime = 1_000;
CountDownLatch latch = new CountDownLatch(totalRequests);
AtomicInteger idCount = new AtomicInteger();
Set<String> ids = ConcurrentHashMap.newKeySet();
// When
long startTime = System.nanoTime();
try (ExecutorService executor = Executors.newFixedThreadPool(threadCount)) {
for (int i = 0; i < totalRequests; i++) {
int threadIndex = i % threadCount;
executor.submit(() -> {
try {
String id = UUID.randomUUID().toString();
ids.add(id);
idCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
}
long endTime = System.nanoTime();
// Then
long actualElapsedTime = (endTime - startTime) / 1_000_000;
assertTrue(actualElapsedTime < maxElapsedTime, "Expected: less than" + maxElapsedTime + ", Actual: " + actualElapsedTime + " ms");
assertEquals(totalRequests, idCount.get(), "Expected: " + totalRequests + ", Actual: " + idCount.get());
assertEquals(totalRequests, ids.size(), "Expected: " + totalRequests + ", Actual: " + ids.size());
System.out.println("Time taken to generate IDs with multiple instances: " + actualElapsedTime + " ms");
}
이 테스트에서는 생성된 ID를 해시셋에 저장하여 중복 여부를 확인하는 과정을 거쳤음에도, 5만 건의 요청을 처리하는 데 약 107ms밖에 소요되지 않았습니다. 이는 UUID가 대규모 트래픽 환경에서도 안정적이고 효율적으로 작동함을 입증합니다:
Time taken to generate IDs with multiple instances: 107 ms
이렇게 UUID는 강력한 고유성을 제공하며 분산 시스템과 대규모 애플리케이션에서 중요한 도구로 사용되고 있지만, 다음과 같은 단점도 존재합니다:
- 길이 문제: UUID는 128비트 크기로, 데이터베이스에 저장하거나 네트워크로 전송하기에 비효율적일 수 있습니다.
- 가독성 문제: UUID는 사람이 읽거나 기억하기 어려운 긴 문자열로 구성되어 있어 직관적으로 이해하거나 수동으로 처리하기 어렵습니다. 이러한 특성은 사람 간의 의사소통보다는 시스템 간의 데이터 처리에 적합하게 설계되었기 때문입니다.
- 성능 문제: UUID는 시간순으로 정렬되지 않기 때문에 데이터베이스 인덱싱이나 정렬 작업에서 성능이 저하될 수 있습니다. 이는 특히 대규모 데이터셋을 처리할 때 조회 속도를 저하시킬 수 있습니다.
- 중복 가능성: 이론적으로 중복 가능성이 거의 없지만, 대규모 시스템에서 UUID를 무작위로 생성하는 경우 충돌 가능성이 완전히 배제되지는 않습니다.
그럼에도 불구하고, UUID는 고유성이 보장되고 생성 속도가 빠르다는 장점으로 인해 분산 시스템, 데이터베이스 키, API 토큰 등 다양한 영역에서 널리 사용됩니다. 이러한 특성은 신뢰성과 효율성을 확보하는 데 기여하며, UUID를 중요한 ID 전략으로 자리 잡게 만들었습니다.
Ticket Server
티켓 서버(Ticket Server)는 고유 ID를 생성하기 위해 중앙에서 동작하는 별도의 서버를 말합니다. 이 서버는 데이터베이스에 의존하지 않고 자체적으로 고유 ID를 자동으로 증가시키는 로직을 통해 클라이언트 요청에 대해 ID를 발급합니다.
티켓 서버는 고유성을 보장하는 신뢰할 수 있는 시스템으로, 모든 ID가 중앙 서버에서 생성되기 때문에 중복 가능성이 거의 없습니다. 중앙 서버는 모든 요청을 처리하며, 별도의 중복 검사 로직 없이도 고유성을 유지할 수 있는 메커니즘을 제공합니다. 이 방식은 복잡한 분산 시스템이나 중앙 집중식 데이터베이스를 사용할 필요가 없어서 설정과 운영이 상대적으로 간단하며, 유지보수도 용이합니다. 따라서 적은 리소스로 안정적이고 효율적인 ID 관리를 구현할 수 있습니다.
또한, 생성된 ID는 단순한 숫자 형식으로 이루어져 있어 UUID처럼 복잡한 형식에 비해 저장 공간과 네트워크 전송 비용 측면에서 효율적입니다. 이러한 효율성은 데이터베이스 및 시스템 전반의 성능 최적화에 긍정적인 영향을 미칩니다.
티켓 서버는 아래와 같은 동작 과정을 따릅니다:
- 클라이언트 애플리케이션이 ID가 필요할 때, 티켓 서버에 요청을 보냅니다.
- 티켓 서버는 내부적으로 유지하는 카운터 값을 증가시켜 새로운 고유 ID를 생성합니다.
- 생성된 ID가 클라이언트에게 반환됩니다.
아래는 티켓 서버를 Spring Boot 기반으로 구현한 예제입니다:
@RestController
@RequestMapping("/api/ticket")
public class TicketServerController {
private final AtomicLong counter;
public TicketServerController() {
this.counter = new AtomicLong(1000);
}
@GetMapping("/generate")
public ResponseEntity<Long> generateId() {
long id = counter.incrementAndGet();
return ResponseEntity.ok(id);
}
}
티켓 서버의 구현은 비교적 간단하며, 초기 카운터 값은 서버 구동 시 데이터베이스에서 가져와 설정합니다. 클라이언트 요청이 들어오면 서버는 내부적으로 유지하는 카운터 값을 증가시켜 고유 ID를 생성하고 이를 반환합니다. 이 단순한 로직 덕분에 관리가 용이하며, 효율적인 고유 ID 생성 솔루션으로 널리 활용됩니다.
하지만 이 방식에도 몇 가지 한계가 존재합니다:
- SPOF(Single Point of Failure): 티켓 서버가 장애를 일으키면 모든 ID 요청이 실패하는 치명적인 문제가 발생합니다. 이는 시스템의 신뢰성과 가용성에 심각한 영향을 미치며, 비즈니스 연속성을 저해할 수 있습니다. 따라서 장애 복구를 위한 대체 시스템이나 다중 서버 구성과 같은 대책이 필수적으로 마련되어야 합니다. 예를 들어, 핫스탠바이 서버를 구성하거나 장애 시 요청을 분산 처리할 수 있는 로드 밸런서를 도입하는 방법이 고려될 수 있습니다.
- 확장성 제한: 티켓 서버는 중앙 서버를 중심으로 작동하기 때문에 요청 처리 속도가 빠르더라도 높은 트래픽 환경에서는 병목 현상이 발생할 가능성이 있습니다. 이는 서버가 동시 요청을 처리하는 용량을 초과할 경우 응답 지연이나 요청 실패로 이어질 수 있음을 시사합니다. 이러한 문제를 해결하기 위해 서버 풀 구성이나 로드 밸런싱과 같은 확장성 개선 방안이 필요합니다.
- 네트워크 지연: 네트워크 지연은 티켓 서버의 성능에 심각한 영향을 미칠 수 있는 요인 중 하나입니다. 특히, 클라이언트와 서버 간의 왕복 시간이 증가하면 요청 처리 속도가 느려지고, 응답 대기 시간이 길어질 수 있습니다. 이는 높은 트래픽 상황에서 시스템 병목 현상을 악화시키며, 사용자가 체감하는 서비스 품질을 저하시킬 수 있습니다.
- 데이터 동기화 문제: 여러 티켓 서버를 두어 가용성을 높이려 할 경우, 데이터 동기화와 충돌 관리가 필수적입니다. 각 서버 간에 카운터 값을 일관되게 유지하지 못할 경우 중복된 ID가 생성되거나 고유성이 보장되지 않는 상황이 발생할 수 있습니다. 이는 시스템의 안정성과 신뢰성을 저하시킬 수 있는 심각한 문제로, 이를 해결하기 위해 분산 락이나 중앙화된 데이터 저장소를 활용한 동기화 방안이 필요합니다.
- 관리 부담 증가: 티켓 서버를 도입하면 개발자가 관리해야 할 서버가 추가되므로 시스템 관리의 복잡성이 증가합니다. 특히, 운영 환경에서 장애를 감지하고 복구하는 작업이 더 어려워질 수 있으며, 이를 자동화하기 위한 추가적인 모니터링 및 관리 도구의 필요성이 대두됩니다.
티켓 서버는 구현은 간단하고 고유성도 보장하지만, 개발자가 관리해야 할 서버가 하나 더 늘어난다는 점은 부담으로 작용할 수 있습니다. 특히, 중앙 서버의 장애 관리와 운영 복잡성은 개발자 입장에서 추가적인 작업과 책임을 요구하기 때문에, 이러한 방식은 달가운 선택이 아닐 수 있습니다. 또한, 고가용성, 네트워크 지연, 데이터 동기화 문제 등 인프라적인 요소를 해결해야 하는 과제들이 여전히 남아 있습니다. 세상에 "Never Die(절대 죽지 않는)"한 서버는 없으니까요. 😮💨
Snowflake Algorithm
스노우플레이크 알고리즘(Snowflake Algorithm)은 트위터에서 처음 고안된 방법으로, 분산 시스템에서 고유 ID를 생성하기 위한 고도로 효율적인 방법입니다. 이 방식은 중앙 서버에 의존하지 않고, 각 노드가 독립적으로 고유 ID를 생성할 수 있는 설계를 기반으로 하고 있습니다. 이는 대규모 시스템에서 높은 트래픽을 처리할 때 발생할 수 있는 병목 현상을 완화하고, 시스템의 가용성과 확장성을 크게 향상시킵니다.
스노우플레이크 알고리즘은 64비트의 정수로 이루어진 ID를 생성하며, Java의 long
타입과 데이터베이스의 bigint
타입으로 처리 및 저장이 가능합니다. 이 64비트 중 첫 번째 비트는 부호 비트로 사용되지 않으며, 나머지 63비트가 ID의 각 구성 요소를 정의합니다. 특히, 10비트로 구성된 머신 ID를 통해 각 머신이 고유 식별자를 가지며, 이를 기반으로 분산 환경에서도 ID 중복 가능성을 효과적으로 방지할 수 있습니다. 이러한 구조는 충돌 없는 ID 생성을 보장하며, 효율적인 고유 ID 설계를 가능하게 합니다.
구성 요소 | 크기 | 설명 |
---|---|---|
⏰ 타임스탬프 | 41비트 | 밀리초 단위의 시간을 포함하여 ID가 시간 순서대로 생성되도록 보장합니다. |
🖥️ 머신 ID | 10비트 | 각 노드에 고유 식별자가 할당되어 고유성을 보장합니다. |
🔢 시퀀스 번호 | 12비트 | 동일 밀리초 구간에서 하나의 노드가 최대 4,096개의 ID를 생성할 수 있습니다. |
이론상 스노우플레이크 ID는 각 머신이 밀리초당 최대 4,096개의 고유 ID를 생성할 수 있도록 설계되어 있습니다. 이는 12비트로 구성된 시퀀스 번호를 활용한 결과로, 동일한 밀리초 내에서도 다수의 요청을 구분할 수 있습니다. 분산 환경에서는 노드가 증가함에 따라, 각 노드가 독립적으로 ID를 생성하므로 전체 시스템의 ID 생성 용량은 기하급수적으로 확장됩니다. 또한, 생성된 ID는 순전히 숫자로 구성되며, 타임스탬프로 시작되기 때문에 정렬이 용이하고 시간 추적에도 유리합니다.
아래는 스노우플레이크 알고리즘의 핵심 아이디어를 단순화하여 구현한 코드입니다. 이 코드는 알고리즘의 동작을 쉽게 이해할 수 있도록 작성된 예제로, 실제 구현은 트위터에서 제공한 Snowflake 공식 리포지토리에서 확인할 수 있습니다. 이어지는 다음 섹션에서 스노우플레이크 기반 고유 ID 생성 로직을 실제로 도입하는 방법과 구체적인 세부 사항을 다룰 예정입니다.
public class SnowflakeIdGenerator {
private final long epoch = 1622505600000L;
private final long machineId;
private final long maxSequence = 4095;
private long lastTimestamp = -1L;
private long sequence = 0L;
public SnowflakeIdGenerator(long machineId) {
this.machineId = machineId;
}
public synchronized long generateId() {
long currentTimestamp = System.currentTimeMillis();
if (currentTimestamp < lastTimestamp) {
throw new IllegalStateException("Clock moved backwards. Refusing to generate ID");
}
if (currentTimestamp == lastTimestamp) {
sequence = (sequence + 1) & maxSequence;
if (sequence == 0) {
while (currentTimestamp <= lastTimestamp) {
currentTimestamp = System.currentTimeMillis();
}
}
} else {
sequence = 0;
}
lastTimestamp = currentTimestamp;
return ((currentTimestamp - epoch) << 22) | (machineId << 12) | sequence;
}
}
스노우플레이크 알고리즘은 분산 환경에서도 고유 ID를 생성할 수 있도록 설계되어 있습니다. 특히, 중앙 서버의 의존성을 제거하여 확장성과 가용성을 보장하며, 시간 기반의 정렬된 ID를 제공하여 데이터베이스와 캐싱 시스템에서 효율성을 극대화합니다. 하지만, 도입과 운영 과정에서 다음과 같은 설계 고려사항이 필요합니다:
- 시간 동기화 문제: 각 노드가 독립적으로 동작하기 때문에, 시스템 간의 시간이 정확히 동기화되지 않으면 ID 충돌이나 순서 오류가 발생할 수 있습니다. 이를 해결하기 위해 NTP(Network Time Protocol)를 활용한 시간 동기화가 필요합니다.
- 복잡한 설정: 각 노드에 고유 머신 ID를 설정해야 하며, 이 과정에서 충돌을 방지하기 위한 추가적인 관리가 필요합니다.
- 밀리초 단위의 한계: 각 노드에서 초당 최대 4096개의 고유 ID를 생성할 수 있지만, 동일한 밀리초에 4096개를 초과하는 요청이 들어오면 시퀀스 번호의 최대값을 초과하게 됩니다. 이 경우, 초과된 요청은 다음 밀리초로 이월되며, ID가 생성될 때까지 지연 시간이 발생할 수 있습니다. 이러한 지연은 고부하 상태에서 응답 시간을 늘릴 가능성이 있으므로, 높은 요청량을 처리하기 위한 부하 분산 전략이 필요합니다.
- 최대 유효 기간: 스노우플레이크 알고리즘은 타임스탬프 기반으로 동작하기 때문에, 밀리초 단위의 41비트가 사용됩니다. 이는 약 69년(2^41 밀리초)의 시간을 나타낼 수 있으며, 그 이후에는 타임스탬프가 순환하여 중복된 ID가 생성될 가능성이 있습니다. 따라서, 초기 Epoch 시간 설정 이후 약 69년이 지나면 시간 조정 또는 새로운 Epoch 값을 설정하는 작업이 필요합니다. 이러한 제한은 장기적인 시스템 설계 시 고려해야 할 중요한 요소 중 하나입니다.
스노우플레이크 알고리즘은 도입 과정에서 고려해야 할 요소들이 있지만, 이를 효과적으로 처리할 수 있는 설계를 제공하며, 많은 글로벌 서비스 사례를 통해 그 신뢰성을 입증했습니다. 예를 들어, 트위터, 인스타그램, 우버와 같은 대규모 플랫폼에서 이 알고리즘을 활용해 분산 환경에서도 안정적으로 고유 ID를 생성하고 관리하고 있습니다. 이러한 특성은 대규모 시스템에서 데이터 관리와 분석의 효율성을 높이며, 스노우플레이크 알고리즘은 티켓 서버와 달리 분산 시스템의 특성을 극대화하여 확장성과 성능을 최적화한 솔루션으로 자리 잡았습니다.
Implementing Unique ID Generator
지금까지 다양한 고유 ID 생성 전략에 대해 알아보았습니다. 많은 선배님들께서 이야기하듯, 정답은 없으며 각 전략의 특징을 이해하고 주어진 요구사항에 따라 적절한 방식을 선택하는 것이 중요합니다. 저는 아직 대규모 서비스는 경험해보지 못했지만, 준비하는 자세로 현재 운영 중인 블로그에 스노우플레이크 기반의 고유 ID 생성 전략을 도입하여 실무적인 경험을 쌓고 있습니다.
이번 섹션에서는 스노우플레이크 알고리즘을 확장한 소니플레이크 알고리즘(Sonyflake Algorithm)을 자세히 살펴보고, 이를 도입하는 과정과 그 특징을 분석해 보겠습니다.
Sonyflake Algorithm
소니플레이크 알고리즘은 트위터의 스노우플레이크에서 영감을 받아 Sony에서 개발한 분산 고유 ID 생성 알고리즘입니다. 소니플레이크는 수명과 다수의 인스턴스 환경에서의 성능에 초점을 맞추고 있으며, 스노우플레이크와는 다른 비트 구성을 가지고 있습니다. 소니플레이크의 ID는 다음과 같이 구성됩니다:
구성 요소 | 크기 | 설명 |
---|---|---|
⏰ 타임스탬프 | 39비트 | 10밀리초 단위의 경과 시간을 표현하여 ID가 시간 순서대로 생성됩니다. |
🖥️ 머신 ID | 16비트 | 각 노드에 고유 식별자가 할당되며, 최대 65,536대의 노드를 지원합니다. |
🔢 시퀀스 번호 | 8비트 | 동일 10ms 구간 내에서 한 노드가 최대 256개의 ID를 생성할 수 있습니다. |
소니플레이크 알고리즘은 긴 수명과 더 많은 머신 지원이라는 두 가지 주요 장점을 제공합니다. 우선, 소니플레이크는 39비트 타임스탬프를 사용하여 약 174년 동안 ID를 생성할 수 있는 긴 수명을 자랑합니다. 이는 스노우플레이크의 69년과 비교했을 때 훨씬 긴 시간 동안 안정적으로 작동할 수 있음을 의미합니다. 또한, 16비트로 구성된 머신 ID를 통해 최대 65,536대의 분산 노드를 지원하며, 이는 스노우플레이크가 지원하는 1,024대에 비해 훨씬 많은 분산 환경을 아우를 수 있는 능력을 보여줍니다.
스노우플레이크(Snowflake)와 소니플레이크(Sonyflake)의 주요 차이점은 다음과 같습니다:
항목 | Snowflake | Sonyflake |
---|---|---|
타임스탬프 크기 | 41비트 (밀리초 단위) | 39비트 (10밀리초 단위) |
머신 ID 크기 | 10비트 | 16비트 |
시퀀스 번호 크기 | 12비트 | 8비트 |
수명 | 약 69년 | 약 174년 |
지원 가능한 노드 수 | 최대 1,024대 | 최대 65,536대 |
생성 속도 | 밀리초당 최대 4,096개 | 10밀리초당 최대 256개 |
주요 언어 | Scala | Go |
스노우플레이크는 더 큰 타임스탬프 크기를 사용하지만, 소니플레이크는 타임스탬프 활용 방식을 더욱 효율적으로 설계하여 긴 수명을 제공합니다. 소니플레이크는 10밀리초 단위로 경과 시간을 기록하여 약 174년 동안 ID를 생성할 수 있는 반면, 스노우플레이크는 밀리초 단위를 사용하여 약 69년의 수명을 제공합니다. 또한, 소니플레이크는 16비트의 머신 ID를 사용하여 최대 65,536대의 노드를 지원하며, 이는 스노우플레이크의 1,024대보다 훨씬 많은 분산 환경을 지원할 수 있는 장점을 가지고 있습니다.
하지만 생성 속도 측면에서 소니플레이크는 스노우플레이크에 비해 상대적으로 느린 단점을 가지고 있습니다. 스노우플레이크는 밀리초당 최대 4,096개의 ID를 생성할 수 있는 반면, 소니플레이크는 10밀리초당 최대 256개의 ID를 생성할 수 있습니다. 이러한 차이는 고부하 환경에서 실시간 ID 생성 요구를 처리하는 데 있어 스노우플레이크가 더 유리할 수 있음을 의미합니다. 특히, 대규모 분산 시스템에서 매우 짧은 시간 내에 많은 ID 요청을 처리해야 하는 경우 소니플레이크의 생성 속도는 병목 현상의 원인이 될 가능성이 있습니다. 소니플레이크는 더 많은 노드를 활용해 이러한 병목을 분산 처리할 수 있지만, 동일한 속도를 달성하려면 상대적으로 더 많은 노드가 필요할 수 있습니다.
Sonyflake in Java
소니플레이크는 Go 언어로 작성되어 있으며, 공식적으로 Java 버전은 제공되지 않습니다. 그래서 저는 ChatGPT의 도움을 받아 Java로 변환하였으며, 코드의 핵심 부분은 다음과 같습니다:
package sonyflake.core;
import java.time.Instant;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.LockSupport;
import java.util.concurrent.locks.ReentrantLock;
import sonyflake.config.SonyflakeSettings;
public final class Sonyflake {
private static final int BIT_LEN_TIME = 39;
private static final int BIT_LEN_SEQUENCE = 8;
private static final int BIT_LEN_MACHINE_ID = 16;
private static final int SEQUENCE_MASK = (1 << BIT_LEN_SEQUENCE) - 1;
private static final long MAX_ELAPSED_TIME = (1L << BIT_LEN_TIME);
private static final long SONYFLAKE_TIME_UNIT = 10_000_000L;
private final long startTime;
private final int machineId;
private final Lock mutex = new ReentrantLock();
private long elapsedTime = 0;
private int sequence = 0;
public static Sonyflake of(SonyflakeSettings settings) {
return new Sonyflake(settings);
}
private Sonyflake(SonyflakeSettings settings) {
this.startTime = toSonyflakeTime(settings.startTime());
this.machineId = settings.machineId();
}
public long nextId() {
mutex.lock();
try {
long currentTime = System.currentTimeMillis() / 10;
if (currentTime > MAX_ELAPSED_TIME) {
throw new IllegalStateException("Time overflow");
}
if (currentTime == elapsedTime) {
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
while (currentTime <= elapsedTime) {
currentTime = System.currentTimeMillis() / 10;
}
}
} else {
sequence = 0;
}
elapsedTime = currentTime;
return ((elapsedTime - startTime) << (BIT_LEN_SEQUENCE + BIT_LEN_MACHINE_ID))
| (machineId << BIT_LEN_SEQUENCE)
| sequence;
} finally {
mutex.unlock();
}
}
}
소니플레이크의 주요 로직은 다음과 같습니다:
- 시간 기반의 ID 생성: 10밀리초 단위의 타임스탬프를 사용하여 시간 순서대로 정렬 가능한 ID를 생성합니다. 이로 인해 ID는 생성된 순서를 유지하며, 데이터베이스나 분산 환경에서의 효율적인 정렬과 검색이 가능합니다.
- 시퀀스 번호로 중복 방지: 동일한 타임스탬프(10ms) 내에서 생성된 요청을 구분하기 위해 8비트 시퀀스 번호를 사용합니다. 즉, 이는 하나의 타임스탬프에서 최대 256개의 요청을 처리할 수 있다는 것을 의미합니다. 만약 동일한 타임스탬프 내에서 요청이 256개를 초과하면, 시퀀스 번호가 최대값에 도달하게 되고, 다음 타임스탬프가 활성화될 때까지 대기하여 ID 충돌을 방지합니다.
- 머신 ID를 통한 분산 환경 지원: 16비트 머신 ID를 사용하여 각 노드가 고유하게 식별되도록 합니다. 이를 통해 최대 65,536개의 분산 노드에서 동시에 ID를 생성할 수 있습니다.
- 스레드 안전성 확보: Go 언어에서는
sync.Mutex
를 사용하여 동시성 환경에서 ID 생성 로직의 안전성을 확보합니다.sync.Mutex
는 고루틴 간의 데이터 충돌을 방지하며, Go에서 가장 기본적인 락 메커니즘으로 사용됩니다. Java에서는 이에 대응하는ReentrantLock
을 사용하여 멀티스레드 환경에서도 안전하게 동작하도록 구현하였습니다.ReentrantLock
은 Java에서 동기화를 제공하는 주요 도구 중 하나로, 락을 명시적으로 제어할 수 있는 유연성을 제공합니다. 이를 통해 ID 생성 로직이 충돌하지 않도록 보장하며, 높은 동시성 환경에서도 안전한 ID 생성을 지원합니다. - 효율적인 비트 조합: 타임스탬프, 머신 ID, 시퀀스 번호를 비트 단위로 조합하여 64비트 크기의 고유 ID를 생성합니다. 이 과정은 계산이 단순하여 성능을 극대화할 수 있습니다.
알고리즘에서 머신 ID는 기본적으로 프라이빗 IP 주소를 기반으로 자동 생성되도록 설계되어 있습니다. 네트워크 인터페이스에서 프라이빗 IP 주소를 검색한 후, 해당 IP 주소를 해싱하여 고유한 머신 ID를 생성합니다. 이를 통해 별도의 머신 ID를 명시적으로 설정하지 않아도 기본적인 분산 환경을 지원할 수 있습니다. 아래는 머신 ID를 프라이빗 IP 주소로부터 생성하는 로직입니다:
public final class SonyflakeSettings {
private final Instant startTime;
private final int machineId;
private static InetAddress getPrivateIp() throws Exception {
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
while (interfaces.hasMoreElements()) {
NetworkInterface networkInterface = interfaces.nextElement();
Enumeration<InetAddress> addresses = networkInterface.getInetAddresses();
while (addresses.hasMoreElements()) {
InetAddress address = addresses.nextElement();
if (isPrivateIp(address)) {
return address;
}
}
}
throw new NoPrivateAddressException("No private IP address found.");
}
private static boolean isPrivateIp(InetAddress address) {
if (address.isLoopbackAddress() || !(address instanceof java.net.Inet4Address)) return false;
byte[] ip = address.getAddress();
int first = ip[0] & 0xFF;
int second = ip[1] & 0xFF;
return first == 10 || (first == 172 && second >= 16 && second < 32) || (first == 192 && second == 168);
}
...
}
이와 같이, 소니플레이크는 높은 트래픽 환경에서도 안정적이고 고유한 ID를 제공할 수 있도록 설계되었습니다. Java로 변환한 전체 소스 코드는 저의 🐙 GitHub Repository에서 확인할 수 있습니다.
Sonyflake Integration
현재 이 블로그는 단일 인스턴스 환경에서 구동 중이며, 아쉽게도 스케일아웃이 필요하지 않은 상태입니다. 😔 그래서 타임스탬프 시작 시간과 머신 ID를 하드코딩으로 설정하였으며, 싱글톤 패턴을 통해 프로젝트 전역에서 손쉽게 활용할 수 있도록 구현하였습니다.
@Slf4j
@StandaloneAdapter
public final class Sonyflake {
public static Sonyflake getInstance() {
return Sonyflake.SingletonHolder.INSTANCE;
}
private static class SingletonHolder {
private static final SonyflakeSettings settings = SonyflakeSettings.of(Instant.parse("2020-11-11T00:00:00Z"));
private static final Sonyflake INSTANCE = new Sonyflake(settings);
}
public long nextId() throws SonyflakeException {...}
...
}
싱글톤 패턴을 통해 Sonyflake
인스턴스를 관리하며, getInstance()
메서드를 통해 프로젝트 어디에서나 동일한 인스턴스를 참조할 수 있습니다. 이를 활용한 실제 사용 예시는 다음과 같습니다:
@Slf4j
@Getter
public class PostId extends BaseId<CategoryId, Long> {
public PostId(Long value) {
super(value);
}
public static PostId withId(Long id) {
return new PostId(id);
}
public static PostId withoutId() {
return new PostId(Sonyflake.getInstance().nextId());
}
}
현재 이 글의 고유 ID 값도 소니플레이크 로직을 기반으로 생성되었습니다. 이를 통해 실제로 어떻게 적용되는지 확인할 수 있습니다:
{
"id": "220224905717874690",
"pathname": "implementing-unique-id-generator-based-on-snowflake-algorithm-in-java"
}
참고로, JSON 결과에서 ID 값이 문자열로 표현되어 있는 것을 확인할 수 있습니다. 이는 JavaScript의 Number 타입이 최대 53비트 정밀도만 지원하기 때문입니다. 이 제한으로 인해 64비트 정수를 정확하게 표현할 수 없으므로, 서버에서는 이러한 문제를 방지하기 위해 ID 값을 문자열로 변환하여 응답에 포함합니다. 이 점을 모르고 클라이언트 측에서 다른 ID 값이 계속 바인딩되는 문제로 인해 많은 시간을 허비했었네요. 🫠
소니플레이크가 올바르게 동작하는지 검증하기 위해 다음과 같은 테스트 케이스를 작성했습니다.
@Test
void shouldGenerateId() throws Exception {
// Given
Instant startTime = Instant.parse("2020-11-11T00:00:00Z");
SonyflakeSettings settings = SonyflakeSettings.of(startTime);
Sonyflake sonyflake = Sonyflake.of(settings);
// When
long id = sonyflake.nextId();
// Then
assertTrue(id > 0, "ID should be greater than 0");
System.out.println("Sonyflake.nextId=" + id);
System.out.println("Sonyflake.startTime=" + startTime);
System.out.println("Sonyflake.elapsedTime=" + sonyflake.elapsedTime(id));
System.out.println("Sonyflake.sequenceNumber=" + sonyflake.sequenceNumber(id));
System.out.println("Sonyflake.machineId=" + sonyflake.machineId(id));
System.out.println("Sonyflake.timestamp=" + sonyflake.timestamp(id));
}
위 테스트는 단일 ID가 정상적으로 생성되는지 확인하며, ID의 구성 요소(타임스탬프, 시퀀스 번호, 머신 ID 등)를 검증합니다.
Sonyflake.nextId=220390463486623886
Sonyflake.startTime=2020-11-11T00:00:00Z
Sonyflake.elapsedTime=13136295288
Sonyflake.sequenceNumber=1
Sonyflake.machineId=142
Sonyflake.timestamp=2025-01-09T09:42:32.880Z
이어서 다음 테스트는 생성된 ID들이 시간 순서대로 정렬되어 있는지 확인합니다. 이는 소니플레이크의 주요 특성 중 하나인 시간 기반 ID 정렬을 검증하는 데 유용합니다.
@Test
void shouldGenerateOrderedIds() throws Exception {
// Given
SonyflakeSettings settings = SonyflakeSettings.of(Instant.parse("2025-01-01T00:00:00Z"));
Sonyflake sonyflake = Sonyflake.of(settings);
List<Long> ids = new ArrayList<>();
int totalSize = 1024;
// When
for (int i = 0; i < totalSize; i++) {
ids.add(sonyflake.nextId());
}
// Then
for (int i = 1; i < totalSize; i++) {
long prev = ids.get(i - 1);
long next = ids.get(i);
assertTrue(prev < next, "IDs should be ordered");
}
}
마지막으로, 대규모 트래픽 환경을 시뮬레이션하는 테스트 코드를 통해 소니플레이크의 성능을 검증하겠습니다. 이 테스트는 총 50,000개의 요청을 10개의 인스턴스에서 병렬로 처리하는 환경을 가정하고 진행되었습니다.
@Test
void shouldHandleHighTpsWithMultipleInstances() throws Exception {
// Given
int totalRequests = 50_000; // 50,000 TPS
int threadCount = 10; // 10 machines
int maxElapsedTime = 1_000; // expected elapsed time in millis
AtomicInteger idCount = new AtomicInteger();
CountDownLatch latch = new CountDownLatch(totalRequests);
List<Sonyflake> sonyflakeInstances = new ArrayList<>();
for (int i = 0; i < threadCount; i++) {
SonyflakeSettings settings = SonyflakeSettings.of(Instant.parse("2025-01-01T00:00:00Z"), i + 1);
sonyflakeInstances.add(Sonyflake.of(settings));
}
// When
long startTime = System.nanoTime();
try (ExecutorService executor = Executors.newFixedThreadPool(threadCount)) {
for (int i = 0; i < totalRequests; i++) {
int threadIndex = i % threadCount;
executor.submit(() -> {
try {
sonyflakeInstances.get(threadIndex).nextId();
idCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
}
long endTime = System.nanoTime();
// Then
long actualElapsedTime = (endTime - startTime) / 1_000_000;
assertTrue(actualElapsedTime < maxElapsedTime, "Expected: less than" + maxElapsedTime + ", Actual: " + actualElapsedTime + " ms");
assertEquals(totalRequests, idCount.get(), "Expected: " + totalRequests + ", Actual: " + idCount.get());
System.out.println("Total IDs generated: " + idCount.get());
System.out.println("Time taken to generate IDs with multiple instances: " + actualElapsedTime + " ms");
}
이 테스트 결과, 10개의 쓰레드에서 5만 건의 요청을 처리하는 데 약 368ms가 소요되었습니다. 소니플레이크는 10ms 단위에서 최대 256개의 ID를 생성할 수 있는 구조적 제한으로 인해 대기 시간이 발생했을 것으로 짐작되며, 이로 인해 UUID와 비교했을 때 약간의 추가 시간이 소요되었습니다. 그러나, 이러한 수행 시간은 대부분의 실무 환경에서 충분히 우수한 성능으로 평가할 수 있습니다.
Total IDs generated: 50000
Time taken to generate IDs with multiple instances: 368 ms
또한, 동일한 테스트를 기반으로 더 많은 인스턴스를 병렬적으로 구성할 경우 이러한 대기 시간을 최소화하고, 전체 처리량을 더욱 향상시킬 수 있습니다. 이는 소니플레이크가 단일 머신 환경뿐만 아니라 분산 환경에서도 효율적이고 안정적으로 ID를 생성할 수 있는 기반을 제공함을 보여줍니다.
Warpping It Up with Unique ID
이번 글에서는 다양한 고유 ID 생성 전략을 살펴보고, 특히 스노우플레이크 기반의 ID 생성 로직을 구현하고 테스트해보았습니다. 개인적으로 데이터 생성 빈도가 낮고 충돌 가능성이 적은 경우에는 데이터베이스의 자동 증가 키 전략을 사용하는 것이 편리하다고 생각합니다. 반면, 생성 빈도가 높고 분산 환경이 요구되는 경우에는 UUID나 소니플레이크 기반의 고유 ID 생성 방식을 적절하게 조합하여 사용하는 것이 더 효율적이라고 생각합니다. ☺️
- Java
- Architecture