콘텐츠로 이동

Global Unique ID 생성기 성능 개선기: Go, Kotlin 중심으로

Go와 Kotlin ID generator들을 같은 benchmark workbench에서 비교하는 3D 일러스트
ID generator 성능은 언어 대결보다 clock, entropy, allocation이 어디서 새는지 찾는 일이었다.

나는 주로 Kotlin으로 backend를 만든다. 그런데 최근에는 Go에도 관심이 생겼다. Go는 보통 가볍고, 개발 속도가 빠르며, runtime 성능도 좋다고 알려져 있다. 이 평가를 내가 만든 코드로 직접 확인해 보고 싶었다.

비교 대상은 Global Unique ID 생성기로 잡았다. 이유는 세 가지다.

  • ID 생성기는 호출 빈도가 높다.
  • 전역에서 고유해야 한다.
  • 동시 생성에서도 깨지면 안 된다.

즉 단순히 빠르기만 해서는 안 된다. 고유성과 동시성까지 같이 만족해야 한다. 겉으로는 작아 보이지만, 막상 구현하면 요구사항이 꽤 까다롭다. 그래서 benchmark하기 좋은 주제라고 생각했다.

처음 목표는 단순했다. bluetape-goidgenerators를 구현하고, 기존 bluetape4k-idgenerators와 비교한다. Go는 primitive와 작은 struct를 많이 쓰니 꽤 앞설 것이라고 예상했다.

그런데 benchmark는 기대를 여지없이 무너뜨렸다. Go도 매번 random entropy를 읽고 있었고, Kotlin도 Snowflake에서 불필요한 중간 객체를 만들고 있었다. ULID, KSUID, KsuidMillis도 각각 다른 병목을 갖고 있었다. 결국 작업은 세 번의 비교로 커졌다.

  1. Go 구현을 만들고 1차 비교를 했다.
  2. Go의 per-call allocation과 entropy 병목을 줄이고 2차 비교를 했다.
  3. Kotlin의 Snowflake, ULID, KSUID, KsuidMillis 병목을 줄이고 3차 비교를 했다.

이 글은 그 과정의 기록이다. 결론부터 말하면 “Go가 빠르다”나 “Kotlin이 이겼다”로 끝낼 수 없는 숫자가 나왔다. generator family마다 병목이 달랐고, 한번 고친 뒤에는 다음 병목이 바로 보였다.

bluetape-go의 구현은 id 패키지에 모았다.

  • UUID v4/v7은 바로 쓰는 함수와 재사용 가능한 generator를 모두 제공한다.
  • ULID를 제공한다.
  • KSUID는 초 단위 timestamp를 쓰는 기본 KSUID와, JVM 구현에 맞춘 millisecond variant를 함께 제공한다.
  • Snowflake는 Long ID를 제공한다.

구현의 출발점은 단순했다. Go API에서는 문자열 ID를 바로 얻는 NextString() 계열 함수와, benchmark에서 allocation이 섞이지 않도록 Long 수형을 바로 돌려주는 함수를 같이 둔다.

id.NewULIDMonotonicGenerator().NextString()
id.NewKSUIDGenerator().NextString()
id.NewKSUIDMillisGenerator().NextString()
id.NewSnowflakeGenerator(...).NextInt64()

소스는 bluetape-go/idid_benchmark_test.go에 있다. 첫 benchmark 보고서는 Issue #168 ID generator benchmark에 남겼다.

1차 비교: 예상보다 단순하지 않았다

섹션 제목: “1차 비교: 예상보다 단순하지 않았다”

1차 Benchmark 결과는 예상보다 복잡했다. Go가 모든 항목에서 앞서지 않았다. Snowflake처럼 synthetic clock으로 핵심 생성 경로를 재는 항목은 Go가 압도적으로 빠르게 나왔다. 반대로 Kotlin의 UUID v7, single-thread monotonic ULID, KSUID seconds는 이미 꽤 강했다.

ID Generator Benchmark Phase 1에서 Go 첫 구현과 기존 Kotlin 구현의 ns/id를 비교한 chart
Phase 1은 “Go가 당연히 이긴다”는 가정을 깨뜨렸다. Snowflake는 Go가 빠르지만, 문자열 ID 계열에서는 Kotlin이 이미 앞선 항목이 많았다.

중요한 점은 측정 방식이었다. Go Snowflake 항목은 synthetic clock hook을 쓰는 핵심 생성 경로였다. Kotlin Snowflake 항목은 real system clock과 kotlinx-benchmark batch 방식을 사용했다. 이 둘을 같은 운영 환경 결론으로 읽으면 안 된다. Snowflake 항목은 “Go 코드가 이 상황에서 빠르다”는 자료이지, “언어 차이만으로 이 정도가 난다”는 자료가 아니다.

그래도 1차 비교는 쓸모가 있었다. Go profile을 보니, 기대와 달리 generator 재사용만으로는 차이가 크게 줄지 않았다. 병목은 더 아래에 있었다. random entropy read와 문자열 encoding이 눈에 띄었다.

Go 개선: 매번 할당보다 먼저 entropy가 보였다

섹션 제목: “Go 개선: 매번 할당보다 먼저 entropy가 보였다”

Go 개선은 두 갈래였다.

첫째, default entropy source를 바꿨다. UUID, ULID, KSUID가 매 ID마다 crypto/rand를 직접 두드리면 ID 생성이라기보다 syscall 체조에 가깝다. 그래서 package-local locked buffered reader를 두고, 기본 generator들이 이 reader를 공유하도록 했다. entropy source는 여전히 crypto/rand 기반이다. ID는 인증 토큰이 아니라 identifier라는 경계도 보고서에 명시했다.

둘째, KSUIDMillis 문자열 encoding에서 불필요한 임시 slice를 줄였다. 27글자 prefix를 고정 local buffer에 바로 encode해서, 이전처럼 만든 뒤 자르는 경로를 피했다.

최종 benchstat 요약은 꽤 선명했다. geomean latency는 58.34% 줄었다. UUID v4 reused는 224.10 ns/op에서 45.57 ns/op로, KSUID millis는 316.80 ns/op에서 122.80 ns/op로 내려갔다. 반대로 ULID monotonic은 65.80 ns/op에서 65.31 ns/op로 거의 움직이지 않았다.

ULID monotonic이 거의 움직이지 않은 숫자도 눈여겨볼 만하다. 이 항목은 entropy buffering이 아니라 monotonic state 관리와 encoding의 비중이 더 컸다. 즉, “한 가지 최적화가 모든 ID generator를 고친다”는 식의 이야기는 여기서 바로 깨진다.

자세한 Go 개선 기록은 Issue #192 ID generator performance optimization에 있다.

Go 개선 뒤의 2차 비교에서는 양상이 달라졌다. Go는 UUID v4, KSUID millis, concurrent ULID/KSUID 항목에서 강해졌다. Kotlin은 UUID v7과 single-thread monotonic ULID에서 여전히 좋은 숫자를 냈다.

ID Generator Benchmark Phase 2에서 Go 개선 후 Go와 Kotlin의 ns/id를 비교한 chart
Phase 2에서는 Go entropy buffering 효과가 보인다. 특히 concurrent 항목에서 Go가 앞서는 항목이 크게 늘었다.

2차 Chart가 보여 주는 핵심은 “Go가 빨라졌다”보다 “병목이 이동했다”에 가깝다. Go는 random entropy read 비용을 줄이자 UUID v4와 KSUID millis에서 앞서기 시작했다. 반면 UUID v7과 single-thread monotonic ULID는 Kotlin이 계속 빠르다. 어느 한 runtime의 일반 승리로 읽기에는 항목별 성격이 너무 다르다.

이쯤 되면 Kotlin도 그냥 두기 어렵다. Go를 고쳐놓고 보니, Kotlin implementation에도 줄일 수 있는 객체 생성 경로와 entropy 처리 단계가 보였다.

Kotlin 개선: Snowflake, ULID, KSUID, KsuidMillis

섹션 제목: “Kotlin 개선: Snowflake, ULID, KSUID, KsuidMillis”

Kotlin 작업은 bluetape4k-idgenerators에서 했다. 목표는 public API를 흔들지 않고, 생성 경로의 불필요한 객체와 random payload 단계를 줄이는 것이었다.

Snowflake는 SnowflakeId 중간 객체를 만들고 다시 Long으로 꺼내는 경로가 있었다. 그래서 내부 sequencer에 Long 값 생성 경로를 추가했다. public API는 그대로 두고, nextId()nextIds(size)가 가능하면 Long 값을 바로 받도록 했다.

internal interface SnowflakeValueSequencer {
fun nextValue(): Long
}

이 변화는 throughput 표에서 크게 보이지 않는다. 이유가 있다. benchmark가 65,536개 ID를 한 operation에서 만들고, Snowflake sequence는 millisecond마다 4,096개가 한계다. real clock에서는 clock ceiling이 먼저 나온다. 대신 allocation profile에서는 차이가 또렷했다.

Snowflake profileBaselineCandidate 3Delta
gc.alloc.rate.norm14,579,592.553 B/op6,970,033.101 B/op-52.19%
gc.alloc.rate868.813 MB/s415.382 MB/s-52.19%
gc.count63-50.00%

ULID는 ByteArray(10)을 만들고 Random.nextBytes로 채우는 단계를 피했다. 80-bit randomness를 직접 nextBits(16)nextLong()으로 받아 value/string 생성 경로에 넣는다.

KSUID와 KsuidMillis는 timestamp를 명시적으로 기록한 뒤 random payload 부분만 채우도록 바꿨다. 초 단위 KSUID는 4바이트 timestamp 뒤의 16바이트 payload만, millisecond variant는 8바이트 timestamp 뒤의 12바이트 payload만 randomize한다.

Kotlin candidate 3에서 가장 크게 움직인 항목은 concurrent ULID였다. concurrent.ulidMonotonicString은 18.48%, uniqueness를 같이 보는 항목은 29.02% 좋아졌다. KSUID와 KsuidMillis는 payload randomization을 줄인 만큼 제한적으로 움직였다. 이 변화는 최종 비교 Chart인 Phase 3에서 다시 확인할 수 있다.

여기서도 숫자를 너무 예쁘게 포장하면 안 된다. single.ksuidMillisDefaultString은 candidate 3 전체 run에서 잠깐 낮게 나왔지만, 같은 JVM에서 반복한 targeted run에서는 baseline 대비 -1.74% 수준이었다. KSUID millis 코드 경로가 candidate 2와 candidate 3 사이에서 바뀌지 않았기 때문에, 보고서에는 전체 run에서 보인 하락을 benchmark noise로 남겼다. 성능 글에서 안 좋은 항목을 지우기 시작하면 그때부터 benchmark가 아니라 광고가 된다.

3차 비교: Go와 Kotlin이 각자 다른 곳에서 앞선다

섹션 제목: “3차 비교: Go와 Kotlin이 각자 다른 곳에서 앞선다”

마지막으로 현재 Go 구현과 Kotlin candidate 3을 다시 비교했다. Kotlin throughput 항목은 batch size 65,536 기준 1e9 / (ops/s * 65_536)으로 ns/id로 정규화했다.

ID Generator Benchmark Phase 3에서 최종 Go와 Kotlin candidate 3의 ns/id를 비교한 chart
3차 비교는 어느 한쪽의 완승이 아니다. Snowflake는 clock model caveat가 크고, ULID와 KSUID는 single/concurrent 항목이 갈린다.

KsuidMillis는 KSUID seconds와 timestamp precision이 다른 별도 variant다. 그래서 KSUID seconds 결과로 KsuidMillis 성능을 대충 추측해버리면 안 된다. Go 개선 뒤 2차 Benchmark에서는 KSUID millis는 Go가 압승이었는데, Kotlin payload-only randomization 이후 3차 Benchmark에서는 동시 생성 시나리오에서 동률까지 왔다. single-thread 항목에서는 Go의 ns/id가 더 낮아 더 빠르다. 이 차이는 timestamp 정밀도가 다른 variant가 실제 서비스에서 어떤 형태로 쓰이는지에 따라 다시 재야 한다.

ULID도 한 줄로 말하기 어렵다. single-thread monotonic 항목은 Kotlin의 ns/id가 낮아 더 빠르고, concurrent monotonic 항목은 Go의 ns/id가 낮아 더 빠르다. monotonic state를 어떻게 보호하고, entropy와 encoding 중 어디가 더 크게 드러나는지가 workload별로 갈린다.

KSUID seconds는 Kotlin 개선 뒤 single/concurrent 모두 Kotlin의 ns/id가 낮아 더 빠르다. 반대로 Snowflake는 여전히 Go 숫자가 낮지만, 이 항목은 clock model 차이가 너무 크다. Kotlin에서 이번에 얻은 실질적 개선은 throughput 순위가 아니라 allocation 절감이다.

이번 비교에서 제일 조심해야 할 결론은 “Go와 Kotlin 중 무엇이 더 빠른가”다. 이 질문만으로는 benchmark 결과를 제대로 설명하기 어렵다.

결론은 다음과 같다.

  • Snowflake는 clock model과 sequence ceiling을 분리해서 봐야 한다.
  • Go의 UUID/ULID/KSUID 초기 병목은 generator 재사용보다 entropy read에 가까웠다.
  • Kotlin Snowflake는 throughput보다 allocation profile에서 개선이 크게 보였다.
  • Kotlin ULID/KSUID/KsuidMillis는 payload 단계를 줄이면 concurrent 항목에서 성능이 달라진다.
  • KsuidMillis는 별도 variant로 봐야 한다. KSUID seconds 결과로 대충 추측해버리면 안 된다.

ID generator는 작아 보이는 코드다. 그래서 benchmark도 간단할 것 같지만, 실제로는 clock, random, encoding, uniqueness set, batch size가 서로 다른 방향으로 끌어당긴다. 이번 작업의 수확은 빠른 항목 몇 개가 아니다. 다음에 ID generator를 고칠 때 “어디부터 의심해야 하는지”를 알게 된 점이다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.