런타임이 장벽이었을 때: Rust가 50 ms SLA를 깨고 하루를 구했다

발행: (2026년 5월 27일 AM 07:05 GMT+9)
9 분 소요
원문: Dev.to

Source: Dev.to

표지 이미지: When the Runtime Was the Wall: How Rust Broke a 50 ms SLA and Saved the Day

예쁜 ncube

우리는 Treasure Hunt Engine을 Veltrix에서 실행했습니다—실시간 게임 백엔드로, 플레이어가 50 ms 이내에 보물을 찾지 못하면 분노하여 퇴장하고 환불을 요구하는 상황에서도 15 k QPS를 처리합니다.

성능 목표는 까다롭습니다: 99번째 백분위수 지연 시간이 엔드‑투‑엔드로 50 ms 이하이어야 하며, 여기에는 네트워크 직렬화, 게임 상태 조회, 리더보드 쓰기가 포함됩니다.

2025년 12월, 우리는 한계에 부딪혔습니다. Go 런타임이 단일 c6i.4xlarge 인스턴스에서 2.4 k 동시 연결을 넘어서 확장되지 않았습니다. 부하가 걸렸을 때 67 ms p998 % 할당자 경쟁을 관찰했습니다. 그 3‑9 %는 연결을 얼마나 많이 샤딩하든 움직이지 않았습니다.

플레임 그래프는 **CPU 시간의 32 %**가 스케줄러의 steal 루프 안에 있음을 보여주었습니다; Go GC는 아직 병목이 아니었지만, 스케줄러가 높은 컨텍스트 스위치 비율 하에서 스스로와 싸우고 있었습니다. 팀은 더 많은 스레드를 투입하려 했지만, 이는 대기 지연을 더욱 깊게 만들 뿐이었습니다. 더 근본적인 변화가 필요했습니다.

우리가 처음 시도한 것 (그리고 왜 실패했는지)

단계우리가 한 일결과
1Go 1.22.2 로 시작하고 net/httpfasthttp 사용GC 압력을 약 20 % 감소
2github.com/valyala/fasthttp 로 전환3 k 연결을 초과하자 p99가 다시 상승
3Linux perf 데이터 수집23.45% [kernel] __x86_indirect_thunk_rax 18.72% main runtime.schedule 12.87% main runtime.lock 9.41% main runtime.mallocgc
4GOMAXPROCS 를 4 → 8 로 증가p95에 도움이 되었지만 p99가 60 ms 를 넘으며 (CPU 간 마이그레이션 증가)
5보물‑상태 트리를 위해 Go 1.23 의 새로운 arena 할당자 채택동일한 128‑바이트 구조체에 대해 할당자가 여전히 GC와 경쟁

비즈니스 로직이 실행되기 전, runtime.schedule 내부의 스틸 루프가 CPU의 18 % 를 소모하고 있었습니다. 병목은 메모리가 아니라 300 µs 스팬 압력 하에서 연속성을 충분히 빠르게 스케줄링하는 런타임의 능력이었습니다. 더 깊게 샤딩하면 영역 간 RPC 지연이 추가되어 이득이 사라졌습니다. 이 시점에서 우리는 선택의 기로에 섰습니다: 벽을 받아들일 것인가, 아니면 언어를 바꿀 것인가.

아키텍처 결정

저는 Rust 1.80Tokio 1.40, Hyper 1.0을 사용한 재작성을 제안했습니다. 작업‑스틸링을 제공하면서 차단에 대한 컴파일‑타임 보장을 갖춘 async 런타임을 선택한 것입니다.

  • 범위 – 보물 조회 경로를 4주 동안 재작성하고, Redis‑기반 리더보드 파사드를 2주 동안 포팅합니다.

  • 상태 트리 – GC 일시 정지와 할당자 핫스팟을 피하기 위해 arena‑백엔드 할당(bumpalo)을 사용하는 락‑프리 샤드.

  • 핵심 호출 경로

    arena::Arena → io_uring → tokio::spawn → lock_free_map::Entry
  • 리더보드 – 동일한 Redis 쓰기 경로를 유지하되, 차단 호출을 별도 스레드 풀에서 수행하는 redis-rs 연결 풀로 옮겨 async 런타임 오염을 방지했습니다.

컴파일러는 Go에서 몇 주 걸릴 수 있는 세 가지 데이터 레이스를 잡아냈고, Miri70 k 동시 세션에서만 나타나는 슬라이스‑범위 오류를 발견했습니다.

우리는 동일한 하드웨어 예산으로 c6i.4xlarge 한 대에 배포하고 동일한 부하 테스트를 실행했습니다: 15 k QPS, 30 k 동시 세션, 50 ms SLA.

숫자들이 말해준 내용

perf record를 플레임그래프와 함께 실행한 결과:

31.6% treasure_hunt [kernel.kallsyms] __x86_indirect_thunk_rax
12.4% treasure_hunt tokio::runtime::scheduler::current
 6.8% treasure_hunt lock_free_map::get
 5.2% treasure_hunt hyper::server::conn::http1::keep_alive
 4.1% treasure_hunt redis::cmd
  • 스케줄러 오버헤드가 18 % → 12 % 로 감소했습니다.
  • Tokio의 워크‑스틸링 스케줄러가 더 적은 코어에서 동작하면서 컨텍스트 간 마이그레이션이 사라졌습니다.
  • 락‑프리 맵 비율은 6.8 % 로 유지되었습니다.

레이턴시 히스토그램

PercentileLatency
p5012.3 ms
p9531.8 ms
p9946.2 ms

p99 레이턴시가 30 k 동시 세션에서도 50 ms SLA 안으로 들어왔습니다. 할당자는 GC 일시정지 없이 동작했으며, 샤드당 1분에 한 번씩 아레나가 리셋되었습니다. 메모리 사용량은 512 MB RSS, 38 MB 힙 안에 머물렀는데, 이는 아레나 할당이 비용이 들지 않는 해제 방식을 사용하기 때문입니다. Go 버전은 fasthttp 내부 버퍼 때문에 1 k 세션당 12 MB가 누수되었지만, Rust의 아레나는 버퍼를 한 번에 대량으로 리셋했습니다.

내가 다르게 할 것

  • 스케줄러가 병목임을 인정하기 전에 Go 런타임을 3주간 최적화하는 데 시간을 쓰지 마세요.
  • Go 스케줄러는 훌륭하지만 30 k 동시 세션 하에서 마이크로초 수준 연속성을 위해 설계된 것은 아닙니다.
  • 지금은 Tokio의 io_uring을 피하세요; 안정화된 후에는 이점이 추가된 복잡성을 능가하지 못했습니다.
  • 리더보드 파사드를 Redis에 아웃소싱하는 대신 Rust 프로세스 내부의 락‑프리 샤드로 옮기기 위해 더 힘써야 합니다. 라운드‑트립 Redis 지연 시간이 p95에서 4–6 ms를 추가했으며, 이를 없애면 꼬리 지연을 몇 밀리초 더 줄일 수 있습니다.


Author: built‑from‑africa
Published on Dev.to

리더보드 최적화

  • 샤드된 스킵 리스트를 사용해 리더보드를 메모리로 이동했습니다.
  • 이 단일 변경으로 p99 지연 시간이 5 ms 더 감소했습니다.
  • 우리는 제때 배포했으며, 플레이어들은 여전히 만족했습니다.
0 조회
Back to Blog

관련 글

더 보기 »

Rust 1.96 발표

Rust 1.96.0 Release The Rust team is happy to announce a new version of Rust, 1.96.0. Rust is a programming language empowering everyone to build reliable and...