개발
home
❄️

Snowflake ID란? Kotlin으로 직접 구현하기

Created
2026/05/11
Tags
Algorithm
Kotlin
Encryption
String
2026-05-11

개요

분산 시스템에서 고유 ID를 어떻게 만들 것인가? 라는 문제를 풀기 위해 Twitter가 만든 알고리즘이 Snowflake ID 입니다.
시간순 정렬이 가능하고, 여러 서버에서 동시에 ID를 생성해도 충돌하지 않으며, 데이터베이스 호출 없이 메모리에서 즉시 ID를 발급할 수 있다는 장점이 있습니다.
이 글에서는 Snowflake ID가 왜 필요한지 살펴보고, Kotlin으로 직접 구현해본 코드를 정리합니다.

본문

왜 분산 시스템에서 고유 ID 생성이 중요한가?

현대의 웹 서비스는 대부분 분산 시스템으로 구성됩니다. 여러 대의 서버가 동시에 데이터를 만들고 처리하기 때문에, 각 데이터에 고유한 식별자(ID)를 부여하는 것이 필수적입니다.
과거에는 데이터베이스의 AUTO_INCREMENT를 사용했지만, 분산 환경에서는 여러 문제가 생깁니다.
병목 현상: 모든 서버가 하나의 DB에 접근해서 ID를 받아야 함
확장성 제약: DB를 샤딩(sharding)할 때 ID 충돌 위험
단일 장애점: ID 발급 서버에 장애가 발생하면 전체 시스템이 멈춤
네트워크 지연: 원격 DB 호출로 인한 지연 시간 증가
분산 ID 생성기는 이런 문제를 다음과 같이 해결합니다.
독립적인 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)
주요 특징은 다음과 같습니다.
시간순 정렬 가능 - ID만으로 생성 시간 순서 파악
분산 환경 지원 - 여러 서버에서 동시에 ID 생성 가능
고성능 - 초당 수백만 개의 ID 생성
충돌 방지 - 64비트 공간으로 중복 없는 ID 보장

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개 초과 시 대기 발생

참고 자료