2026-05-11
개요
분산 시스템에서 고유 ID를 어떻게 만들 것인가? 라는 문제를 풀기 위해 Twitter가 만든 알고리즘이 Snowflake ID 입니다.
시간순 정렬이 가능하고, 여러 서버에서 동시에 ID를 생성해도 충돌하지 않으며, 데이터베이스 호출 없이 메모리에서 즉시 ID를 발급할 수 있다는 장점이 있습니다.
이 글에서는 Snowflake ID가 왜 필요한지 살펴보고, Kotlin으로 직접 구현해본 코드를 정리합니다.
본문
왜 분산 시스템에서 고유 ID 생성이 중요한가?
현대의 웹 서비스는 대부분 분산 시스템으로 구성됩니다. 여러 대의 서버가 동시에 데이터를 만들고 처리하기 때문에, 각 데이터에 고유한 식별자(ID)를 부여하는 것이 필수적입니다.
과거에는 데이터베이스의 AUTO_INCREMENT를 사용했지만, 분산 환경에서는 여러 문제가 생깁니다.
•
병목 현상: 모든 서버가 하나의 DB에 접근해서 ID를 받아야 함
•
확장성 제약: DB를 샤딩(sharding)할 때 ID 충돌 위험
•
단일 장애점: ID 발급 서버에 장애가 발생하면 전체 시스템이 멈춤
•
네트워크 지연: 원격 DB 호출로 인한 지연 시간 증가
분산 ID 생성기는 이런 문제를 다음과 같이 해결합니다.
•
•
•
•
Snowflake ID는 이러한 요구사항을 모두 만족하는 대표적인 분산 ID 생성 알고리즘으로, Twitter, Discord, Instagram 등 대규모 서비스에서 널리 사용되고 있습니다.
Snowflake ID의 구조
Snowflake ID는 64비트 정수로, 다음과 같이 세 부분으로 구성됩니다.
•
Timestamp (41 bits): 밀리초 단위 시간 (약 69년 사용 가능)
•
Machine ID (10 bits): 서버 식별자 (최대 1,024대)
•
Sequence (12 bits): 같은 밀리초 내 순차 번호 (최대 4,096개/ms)
주요 특징은 다음과 같습니다.
•
•
•
•
Crockford Base32 인코딩으로 표현하기
순수한 64비트 정수를 그대로 클라이언트에 노출하면 한 가지 문제가 있습니다. JavaScript의 Number 타입은 53비트까지만 정확히 표현할 수 있기 때문에, 큰 숫자가 부정확하게 표시될 수 있습니다.
이를 해결하기 위해 ID를 문자열로 변환하는데, 단순히 Long.toString()을 쓰면 "이게 ID인지 그냥 숫자인지" 헷갈리기 쉽습니다. 그래서 ULID(Universally Unique Lexicographically Sortable Identifier)에서 영감을 받아 Crockford Base32 인코딩을 도입했습니다.
Crockford Base32는 알파벳 i, l, o, u 처럼 혼동하기 쉬운 문자를 제외하고,
대소문자를 구분하지 않으며, URL-safe한 인코딩 방식입니다.
•
64비트 ID를 13자리 문자열로 변환
•
혼동하기 쉬운 문자 제외 (i, l, o, u)
•
URL-safe하고 대소문자 구분 없음
•
예시: 3qkpc7gh8n4m2
시간순 정렬이 자동으로 되는 이유
Snowflake ID의 가장 큰 장점 중 하나는 문자열을 정렬하면 자동으로 시간 순서대로 정렬된다는 점입니다.
val id1 = SnowflakeIdGenerator.nextId() // "3qkpc7gh8n4m2"
Thread.sleep(100)
val id2 = SnowflakeIdGenerator.nextId() // "3qkpc7gh8n4m5"
Thread.sleep(100)
val id3 = SnowflakeIdGenerator.nextId() // "3qkpc7gh8n4m8"
println(id1 < id2) // true
println(id2 < id3) // true
val ids = mutableListOf(id3, id1, id2)
ids.sort()
// 결과: [id1, id2, id3] - 생성 시간 순서대로 정렬됨!
Kotlin
복사
왜 이게 가능한가요?
•
ID의 최상위 41비트가 타임스탬프
•
Big-endian 인코딩으로 시간이 문자열 앞부분에 위치
•
따라서 문자열 사전순 정렬 = 시간순 정렬
실제로 데이터베이스 쿼리에서도 활용 가능합니다.
-- 최신 글부터 조회
SELECT * FROM posts ORDER BY id DESC;
-- 특정 시간 이후의 데이터 조회
SELECT * FROM posts WHERE id > '3qkpc7gh8n4m2';
SQL
복사
Machine ID 자동 생성
10비트의 Machine ID는 서버를 식별하는 역할을 합니다. 매번 수동으로 설정하기 번거롭기 때문에 MAC 주소 + 프로세스 ID 조합으로 자동 생성하도록 했습니다.
•
MAC 주소의 앞 3바이트 + Process ID를 XOR 연산
•
MAC 주소를 찾지 못하면 SecureRandom으로 fallback
•
재시작 시에도 같은 머신에서는 일관된 ID 유지 시도
MongoDB 스타일 시퀀스
전통적인 Twitter 스타일 구현은 매 밀리초마다 sequence를 0으로 초기화합니다. 하지만 이렇게 하면 대부분의 ID가 000으로 끝나는 문제가 있습니다 (예: 3qkpc7gh8n000).
MongoDB ObjectId는 sequence를 계속 증가시키는 방식을 사용합니다. 이 방식의 장점은 다음과 같습니다.
•
시퀀스가 매 밀리초마다 리셋되지 않고 계속 증가
•
더 균등한 ID 분포 (끝자리가 다양해짐)
•
캐시 지역성 향상
Kotlin 전체 구현 코드
import java.net.NetworkInterface
import java.security.SecureRandom
import java.nio.ByteBuffer
object SnowflakeIdGenerator {
// 기준 시간 (2010년 11월 4일 01:42:54.657 UTC) - Twitter Snowflake epoch
private const val EPOCH = 1288834974657L
// 비트 할당
private const val TIMESTAMP_BITS = 41
private const val MACHINE_ID_BITS = 10
private const val SEQUENCE_BITS = 12
// 최대값
private const val MAX_MACHINE_ID = (1L shl MACHINE_ID_BITS) - 1
private const val MAX_SEQUENCE = (1L shl SEQUENCE_BITS) - 1
// 비트 시프트
private const val TIMESTAMP_SHIFT = MACHINE_ID_BITS + SEQUENCE_BITS
private const val MACHINE_ID_SHIFT = SEQUENCE_BITS
// Crockford Base32 알파벳 (혼동하기 쉬운 문자 제외)
private const val CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
private val machineId: Long = generateMachineId()
private var sequence: Long = SecureRandom().nextInt(MAX_SEQUENCE.toInt()).toLong()
private var lastTimestamp: Long = -1
private fun generateMachineId(): Long {
return try {
val networkInterface = NetworkInterface.getNetworkInterfaces()
.asSequence()
.filter { !it.isLoopback && it.hardwareAddress != null }
.firstOrNull()
if (networkInterface != null) {
val mac = networkInterface.hardwareAddress
val macValue = mac.take(3).fold(0L) { acc, byte ->
(acc shl 8) or (byte.toInt() and 0xFF).toLong()
}
(macValue xor ProcessHandle.current().pid()) and MAX_MACHINE_ID
} else {
SecureRandom().nextInt(MAX_MACHINE_ID.toInt()).toLong()
}
} catch (e: Exception) {
SecureRandom().nextInt(MAX_MACHINE_ID.toInt()).toLong()
}
}
@Synchronized
fun nextId(): String {
var timestamp = System.currentTimeMillis()
if (timestamp < lastTimestamp) {
throw RuntimeException("Clock moved backwards. Refusing to generate id")
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) and MAX_SEQUENCE
if (sequence == 0L) {
timestamp = waitNextMillis(lastTimestamp)
}
} else {
// MongoDB 스타일: 시퀀스 계속 증가
sequence = (sequence + 1) and MAX_SEQUENCE
}
lastTimestamp = timestamp
val id = ((timestamp - EPOCH) shl TIMESTAMP_SHIFT) or
(machineId shl MACHINE_ID_SHIFT) or
sequence
return encodeCrockfordBase32(id)
}
private fun encodeCrockfordBase32(value: Long): String {
// Big-endian으로 저장하여 시간순 정렬 보장
val bytes = ByteBuffer.allocate(8).putLong(value).array()
val result = StringBuilder()
var buffer = 0
var bitsInBuffer = 0
for (byte in bytes) {
buffer = (buffer shl 8) or (byte.toInt() and 0xFF)
bitsInBuffer += 8
while (bitsInBuffer >= 5) {
val index = (buffer ushr (bitsInBuffer - 5)) and 0x1F
result.append(CROCKFORD_ALPHABET[index])
bitsInBuffer -= 5
}
}
if (bitsInBuffer > 0) {
val index = (buffer shl (5 - bitsInBuffer)) and 0x1F
result.append(CROCKFORD_ALPHABET[index])
}
return result.toString()
}
private fun decodeCrockfordBase32(encoded: String): Long {
var buffer = 0L
var bitsInBuffer = 0
val bytes = ByteArray(8)
var byteIndex = 0
for (char in encoded.lowercase()) {
// 혼동하기 쉬운 문자 자동 변환
val index = when (char) {
'o' -> CROCKFORD_ALPHABET.indexOf('0')
'i', 'l' -> CROCKFORD_ALPHABET.indexOf('1')
else -> CROCKFORD_ALPHABET.indexOf(char)
}
if (index == -1) {
throw IllegalArgumentException("Invalid character: $char")
}
buffer = (buffer shl 5) or index.toLong()
bitsInBuffer += 5
while (bitsInBuffer >= 8 && byteIndex < 8) {
bytes[byteIndex++] = ((buffer ushr (bitsInBuffer - 8)) and 0xFF).toByte()
bitsInBuffer -= 8
}
}
return ByteBuffer.wrap(bytes).getLong()
}
private fun waitNextMillis(lastTimestamp: Long): Long {
var timestamp = System.currentTimeMillis()
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis()
}
return timestamp
}
fun parseId(base32Id: String): SnowflakeIdInfo {
val id = decodeCrockfordBase32(base32Id)
val timestamp = (id shr TIMESTAMP_SHIFT) + EPOCH
val machineId = (id shr MACHINE_ID_SHIFT) and MAX_MACHINE_ID
val sequence = id and MAX_SEQUENCE
return SnowflakeIdInfo(id, base32Id, timestamp, machineId, sequence)
}
fun getMachineId(): Long = machineId
data class SnowflakeIdInfo(
val id: Long,
val base32Id: String,
val timestamp: Long,
val machineId: Long,
val sequence: Long,
)
}
Kotlin
복사
기본 사용 예시
// ID 생성 (초기화 불필요)
val id = SnowflakeIdGenerator.nextId()
// 결과: "3qkpc7gh8n4m2"
// ID 정보 파싱
val info = SnowflakeIdGenerator.parseId(id)
println(info.timestamp) // 생성 시간
println(info.machineId) // 머신 ID
println(info.sequence) // 시퀀스 번호
Kotlin
복사
성능 및 주의사항
•
처리량: 밀리초당 최대 4,096개 ID 생성
•
지연시간: 마이크로초 단위
•
메모리: 싱글톤 패턴으로 최소한의 메모리 사용
주의사항
1. 시계 동기화: NTP를 사용하여 서버 시간 동기화 필수
2. Machine ID 충돌: 같은 네트워크에서 MAC 주소가 같은 경우 주의
3. 처리량 한계: 밀리초당 4,096개 초과 시 대기 발생

