서버 대수가 바뀔 때마다 캐시가 전부 재배치되는 걸 막으려고 consistent hashing(일관 해싱)을 넣는 것까지는 흔한 선택이다. 그런데 그 해시링 자체가 배포 하나당 최대 6GB를 먹고 있었다면, 그리고 그 낭비가 “가상 노드는 넉넉히 둬야 한다"는 관행적 설정값 하나에서 비롯된 것이었다면 어떨까. Cloudflare는 2026년 9월 18일 공개한 엔지니어링 블로그 글에서, 자사 로드밸런서 Pingora Backend Router(PBR)의 해시링을 구조체 재설계와 통계적 가지치기 두 가지로 손질해 전 세계 배포분에서 RAM 100TB를 회수한 과정을 공개했다. 같은 달 DNS 캐시 축소로 이미 100TB를 아낀 데 이어 두 번째 100TB라서, 원문 제목도 “Saving another 100TB of RAM"이다.
이 글은 그 과정에서 나온 두 가지 최적화 — 정렬 패딩을 없앤 struct 재설계, 그리고 “얼마나 줄여도 안전한가"를 수학으로 답한 해시 개수 삭감 — 를 코드와 수치로 따라간다.
Pingora Backend Router와 ketama — 왜 해시링이 필요한가
PBR은 캐시 가능한 요청을 URL 기준으로 특정 서버에 고정 라우팅하는 Cloudflare 내부 로드밸런서다. 같은 파일에 대한 요청이 매번 다른 서버로 흩어지면 데이터센터마다 같은 파일의 복사본을 여러 개 들고 있어야 해서 캐시 적중률이 떨어진다. 이 문제를 풀려고 쓰는 게 ketama라는 consistent hashing 알고리즘이다. 서버 목록이 바뀌어도 재배치되는 키의 비율을 최소화하도록, 각 서버를 링 위의 여러 지점(가상 노드)에 매핑해두고 요청의 해시값을 링에서 시계 방향으로 가장 가까운 지점에 연결한다.
문제는 이 링을 메모리에 얼마나 촘촘하게 채워둘 것인가다. 가상 노드를 늘릴수록 부하 분산은 이론적으로 고르게 수렴하지만, 그만큼 링 엔트리 수가 늘어나 메모리를 먹는다. Cloudflare 엔지니어링 팀은 “적당히 넉넉하게"라는 관행 대신, 이 트레이드오프를 직접 계산해서 최적점을 찾았다.
최적화 1 — struct 재포장으로 엔트리 25% 줄이기
해시링의 각 엔트리는 (hash, index) 쌍이다. 기존 구현은 둘 다 u32였다.
| |
서버 대수가 u32 전체 범위(약 43억)를 쓸 일은 없으니, index를 u16으로 줄이면 산술적으로는 6바이트여야 한다. 그런데 단순히 타입만 바꾸면 기대한 효과가 나지 않는다.
| |
Rust의 기본 표현(repr(Rust))은 필드를 재배열해 패딩을 최소화하긴 하지만, 구조체 전체 크기는 가장 큰 필드(u32, 4바이트 정렬)의 배수로 올림된다. 6바이트는 4의 배수가 아니므로 컴파일러가 뒤에 2바이트를 채워 넣어 결국 8바이트가 된다. #[repr(packed)]로 정렬을 강제로 없애는 방법도 있지만, Cloudflare 팀은 이를 “논란의 여지가 있는(controversial)” 선택으로 보고 피했다 — 정렬되지 않은 필드 접근은 특정 아키텍처에서 성능 저하나 미정의 동작의 위험을 안기 때문이다. 대신 구조체를 아예 6바이트 배열로 감싸고, 접근자 메서드에서 from_ne_bytes로 값을 조립하는 방식을 택했다.
| |
바이트 배열에는 정렬 요구가 없으므로 패딩이 끼어들지 않는다. #[repr(packed)]처럼 필드에 직접 정렬되지 않은 접근을 허용하는 대신, 값을 꺼낼 때마다 명시적으로 바이트를 재조립하게 만들어 “덜 읽기 좋지만 더 안전한” 절충을 선택한 셈이다. 이 변경만으로 해시링 메모리 사용량이 25% 줄었다.
최적화 2 — 해시 개수를 90% 줄여도 되는 이유
두 번째 최적화는 더 과감하다. 기존 설정은 서버당 기본 160개 해시에 저장 용량 비례 가중치를 곱해 실제 해시 개수를 정했다. 예를 들어 가중치가 625인 서버는 160 × 625 = 100,000개의 해시를 링에 올렸다. Cloudflare 팀은 이 10만 개 중 마지막 9만 개가 로드 분산 품질에 기여하는 개선폭을 실제로 계산해봤고, 결과는 0.7%였다 — 사실상 없는 것과 같은 수준이었다.
이 결론에 이르기까지는 감이 아니라 통계 모델을 세웠다. 서버가 N대이고 서버당 해시가 k개일 때, 서버별 부하 분포의 변동계수(coefficient of variation, CV)는 이론상 CV ≈ √[(N − 1) / (N·k + 1)]로 근사된다. k가 클수록 CV는 작아지고(부하가 고르게 분산되고), 이 곡선은 k가 커질수록 완만해진다 — 즉 해시를 늘릴수록 얻는 한계 이득이 줄어드는 수확 체감 곡선이다. 극단적으로 서버당 해시를 1개만 두는 경우(N=100일 때) CV는 약 99%에 달한다 — 어떤 서버는 평균보다 99% 더 많은 부하를, 어떤 서버는 훨씬 적은 부하를 받을 수 있다는 뜻이다. 반대로 서버당 1만 개 수준까지만 올려도 이 곡선은 이미 충분히 평평해진다는 게 이번 분석의 핵심이었다.
이론과 현실의 간극: 32비트 해시의 생일 역설
CV 공식은 해시가 링 위에 연속적으로 분포한다고 가정한다. 하지만 실제로는 32비트 정수로 해시를 표현하므로 공간 자체가 유한하고 이산적이다. Cloudflare 팀은 이 지점에서, 연속 링을 가정한 “아름다운 수학"이 들어맞는 건 이론상의 이야기일 뿐이고, 실제로는 32비트 해시가 충돌할 여지가 있으며 해시 개수가 늘어날수록 그 충돌 확률이 생일 역설(birthday paradox) 효과로 예상보다 빠르게 커진다고 밝혔다.
핵심은 방향이다 — 해시 개수가 늘어날수록 32비트 공간 안에서 두 해시가 우연히 같은 값을 가질 확률(충돌)이 생일 역설 효과로 빠르게 커진다. 그래서 서버당 해시가 1만–10만 개인 구간에서는, “연속 링” 가정으로 계산한 이론적 오차와 실제 시뮬레이션 오차가 벌어지기 시작한다. 이 간극을 무시하고 이론값만 믿고 해시 개수를 줄였다면, 실제 배포에서는 모델이 예측한 것보다 로드 불균형이 더 커졌을 위험이 있었다. Cloudflare 팀은 이 구간에서 실측 시뮬레이션으로 오차 곡선을 다시 그려본 뒤에야 “90% 삭감 후 appreciable error(눈에 띄는 오차)가 생기지 않는다"는 결론을 확정했다.
무중단 마이그레이션
해시링 구조를 통째로 바꾸는 변경은 배포 도중 캐시 미스가 폭증할 위험이 있다. PBR은 한동안 기존 ketama 링과 새 축소판 링을 메모리에 동시에 들고 있었고, 요청마다 두 링 중 어느 쪽으로 라우팅할지를 사내 표준 마이그레이션 프레임워크가 결정하게 해서 데이터센터 단위로 점진적으로 전환했다. 새 구조가 문제를 일으키면 트래픽 비율을 다시 낮추기만 하면 되는 구조라, 전 세계 배포 전체를 한 번에 스위치하는 위험을 피했다.
최적화 전후 비교
| 항목 | 최적화 전 | 최적화 후 | 절감 근거 |
|---|---|---|---|
| 엔트리 1개 크기 | 8바이트 (u32 + u32, 패딩 포함) | 6바이트 ([u8; 6] + 접근자) | 정렬 패딩 제거 |
| 서버당 해시 개수(가중치 625 기준) | 100,000개 (160 × 625) | 약 10,000개 (90% 삭감) | 마지막 9만 개 기여도 0.7% |
| 단일 해시(가상 노드 1개) 분배 시 CV | 약 99%(N=100) | 대규모 k에서 유의미한 오차 증가 없음 | CV ≈ √[(N−1)/(N·k+1)] |
| 배포 전체 RAM 절감 | – | 100TB (DNS 캐시 축소 100TB와 별도) | struct 재포장 25% + 해시 개수 90% 삭감의 합산 효과 |
실전 팁: 자주 하는 실수
- 타입만 줄이고 정렬을 확인하지 않는다.
u32를u16으로 바꿨다고 구조체 크기가 그만큼 줄었다고 가정하면 안 된다.size_of::<T>()로 실제 크기를 반드시 확인해야 한다 — Rust는 가장 큰 필드의 정렬 요구에 맞춰 전체 크기를 올림하기 때문에, 필드 하나만 줄이면 패딩만 늘어나고 실제 절감은 없을 수 있다. - 정렬 문제를
#[repr(packed)]로 무조건 해결하려 한다. 정렬되지 않은 메모리 접근은 일부 아키텍처에서 성능 저하나 크래시로 이어질 수 있다. 바이트 배열 + 접근자 메서드처럼, 읽기는 조금 불편해도 안전한 대안을 먼저 검토할 가치가 있다. - “넉넉하게"라는 설정값을 감으로 줄인다. 해시 개수, 캐시 크기, 버퍼 길이 같은 관행적 상수를 줄일 때 한계 이득을 실제로 계산하지 않으면, 얼마나 줄여도 안전한지 알 수 없다. 이번 사례처럼 마지막 구간의 기여도를 먼저 측정하는 게 먼저다.
- 이론 모델의 가정(연속·무한 공간)을 실제 구현(이산·유한 공간)에 그대로 적용한다. CV 공식은 연속 링을 가정하지만 실제 해시는 32비트라는 유한 공간에 갇혀 있다. 이론과 실측이 벌어지는 구간(이번 경우 1만–10만 개)에서는 시뮬레이션으로 반드시 교차 검증해야 한다.
요약
Cloudflare의 100TB 절감은 화려한 신기술이 아니라, 이미 있는 자료구조의 패딩과 관행적 설정값을 의심하고 실제로 계산해본 결과였다. 저자는 마지막 문단에서, 이 글에서 정말 가져갔으면 하는 것은 struct나 ketama 자체가 아니라 “단순하다"거나 “당연하다"고 여겨온 결정 속에 숨어 있는 개선 기회를 직접 수치로 파고들어 볼 용기라고 적었다.
구조체 하나의 패딩 2바이트, 서버당 해시 9만 개의 한계 기여도 0.7% — 둘 다 개별로는 사소해 보이지만, 전 세계 배포 규모로 곱해지면 100TB가 된다. 자신의 시스템에서 “당연히 이만큼은 필요하다"고 여겨온 값이 있다면, 그 값의 한계 이득을 실제로 측정해본 적이 있는지부터 점검해볼 만하다.
![Featured image of post [Rust] Pingora 해시링 재설계: struct 재포장과 통계로 RAM 100TB 절감](/post/2026-09-21-cloudflare-pingora-hashring-memory-optimization/wordcloud_hu_ba159fc3b00c3c2f.webp)
![[Performance 04] Introduction: Low-latency 메모리·할당·레이아웃](/post/memory-optimization/getting-started-memory-allocation-data-layout-tuning/wordcloud_hu_4b3d1ede294a01c.webp)
![[Cpp] 벡터화가 빠른 진짜 이유: SIMD 폭이 아니라 병렬성이다](/post/2026-09-06-vectorization-ilp-mlp-not-simd-width/wordcloud_hu_61f935df987a7d7c.webp)
![[Programming] Napkin Math — 벤치마크 전에 하드웨어 성능 상한 암산하기](/post/2026-08-04-napkin-math-performance-estimation/wordcloud_hu_5ff67cb52f2d80a7.webp)
![[Optimization(Series)] Introduction: Low-latency 최적화 12트랙 로드맵](/post/low-latency-optimization-series/getting-started-low-latency-optimization-series-overview/wordcloud_hu_8a1f40e0771bebe4.webp)
![[Performance 06] Introduction: OS·런타임 Low-latency 운영환경](/post/os-optimization/getting-started-os-runtime-performance-tuning/wordcloud_hu_a9d684b10c323753.webp)