매주 화요일 새벽 2시 17분마다 계속해서 다운되던 서버
요약
매주 특정 시간대마다 발생하는 서버 메모리 급증 및 지연 시간 문제를 해결한 기술 사례입니다. 단순 메모리 증설이나 작업 비활성화 대신, 인덱스 재구축 프로세스를 큐 기반의 지역 인지형 작업으로 재설계하여 문제를 근본적으로 해결했습니다.
핵심 포인트
- 메모리 증설은 근본적인 해결책이 아닌 규모만 키우는 임시방편임
- 인덱스 재구축 작업의 비원자적 특성이 시스템 부하의 원인
- 지연 시간이 게임 내 사용자 참여도에 미치는 직접적인 영향 확인
- 큐 기반 및 지역 인지형(region-aware) 아키텍처로 설계 변경
우리가 실제로 해결하고 있었던 문제
사건은 화요일 새벽 2시 17분에 울린 페이저(pager) 알람과 함께 시작되었습니다. 그냥 평범한 화요일이 아니었습니다. 주요 게임 패치가 배포된 직후인 그 달의 두 번째 화요일이었습니다. 경고 메시지는 단도직입적이었습니다. Treasure Hunt Engine의 힙(heap) 사용량이 7분도 채 되지 않아 89%까지 급증했다가, 새벽 2시 24분에 회복되었다는 내용이었습니다. 스택 트레이스(stack traces)도, 로그상의 에러도 없었습니다. 그저 메모리가 로켓처럼 치솟았다가 다시 기본 수치로 돌아왔을 뿐입니다. 우리는 이것을 일회성 사건이라고 생각했지만, 다음 달에 똑같은 일이 다시 발생했습니다. 같은 시간, 같은 조용한 급증, 그리고 같은 유령 같은 회복이었습니다.
우리가 실제로 해결하고 있었던 것은 단순한 메모리 누수(memory leaks)가 아니라, 바로 _시간대별 지연 시간 위장(time-of-day latency camouflage)_이었습니다. 플레이어들은 힙 그래프에는 관심이 없었습니다. 그들은 정확히 새벽 2시 17분에 자신들의 보물찾기가 느려지는 것에 민감했습니다. 더 깊이 파고든 결과, 우리는 이 이상 현상이 게임 코드에 있는 것이 아니라 Veltrix Operators 설정 파일에 있다는 것을 발견했습니다. 서버는 매월 두 번째 화요일 새벽 2시에 전체 인덱스 재구축(index rebuild)을 수행하도록 설정되어 있었습니다. 그 재구축은 원자적(atomic)이지 않았습니다. 스로틀링(throttled) 처리도 되어 있지 않았습니다. 그것은 단순한 유지보수로 위장한 포크 폭탄(fork bomb)이었습니다.
우리가 처음 시도했던 것들 (그리고 실패한 이유)
우리는 가장 뻔한 방법부터 시작했습니다. 힙(heap) 용량을 4 GB에서 8 GB로 늘리는 것이었습니다. 덕분에 한 번의 패치 주기 동안은 버틸 수 있었지만, 다음 화요일이 되자 급증 수치는 89%가 아닌 92%에 도달했습니다. 문제는 해결된 것이 아니라 규모만 커졌습니다.
다음으로 우리는 인덱스 재구축을 완전히 비활성화하는 것을 시도했습니다. 인덱스 재구축 없이 게임을 라이브로 운영했습니다. 하지만 48시간 이내에 전 세계 플레이어의 절반이 보물 검색 시 빈 결과만을 받게 되었습니다. 인덱스 재구축은 선택 사항이 아니었습니다. 검색 인덱스의 지연 시간(latency)을 200 ms 미만으로 유지하기 위해 반드시 필요한 작업이었습니다. 재구축이 없으면 엔진은 3배 더 느리고 쿼리 비용이 2배 더 비싼 보조 인덱스(secondary index)로 전환되었습니다.
그다음 우리는 지역(region)별로 재구축(rebuild) 시간을 분산시키는 방법을 시도했습니다. 약 3주 동안은 효과가 있었지만, 재구축 시간이 특정 지역의 플레이어 피크 시간대(peak hours)와 겹치면서 연쇄적인 타임아웃(timeout)을 유발하기 시작했습니다. 유럽의 플레이어들은 점심시간 동안 3초의 대기 시간이 발생한다고 보고하기 시작했습니다. 우리의 인게임 경제(in-game economy)는 500ms 미만의 응답 속도에 의존합니다. 이를 초과하는 모든 지연은 250ms당 참여도(engagement)를 7%씩 떨어뜨립니다. 우리는 그러한 퇴보를 감당할 여유가 없었습니다.
아키텍처 결정 (The Architecture Decision)
우리는 마침내 문제의 원인이 Veltrix Operators의 rebuild-schedule.json에 있음을 격리(isolate)해냈습니다. 기본 설정은 하드코딩된 크론(cron) 표현식인 0 2 2 * *을 사용하고 있었습니다. 매월 2일 02:00:00에 실행되는 방식이었습니다. 지터(jitter)도, 백오프(backoff)도, 지역별 피크 부하(peak load)에 대한 인지도도 없었습니다.
해결책은 단순히 일정을 변경하는 것이 아니었습니다. 재구축을 큐 기반(queue-driven)의 가중치 적용 및 지역 인지형(region-aware) 작업으로 설계하는 것이었습니다.
우리는 새로운 컴포넌트를 도입했습니다. 바로 지난 30일간의 플레이어 트래픽 곡선(traffic curves)을 입력받는 지역 스케줄러(regional scheduler)입니다. 이 스케줄러는 지역별 중간 플레이 피크(median play peak) 이후 최소 6시간이 지난 시점의 재구축 윈도우(rebuild window)를 계산합니다. 그런 다음 스케줄러는 해당 윈도우 내에서 무작위 30분 지터(jitter)를 포함한 작업을 방출(emit)합니다. 작업 자체는 청크(chunk) 단위로 나뉩니다. 단일 재구축이 128MB의 힙 발자국(heap footprint)을 초과하지 않도록 설계되었습니다. 각 청크는 연쇄적인 지연 스파이크(latency spikes)를 방지하기 위해 초당 50개의 쿼리로 속도 제한(rate-limited)이 걸려 있습니다.
또한 인덱스 재구축을 메인 게임 프로세스에서 분리(decouple)했습니다. 이제 재구축은 자체적인 JVM 및 메타스페이스(Metaspace) 제한을 가진 전용 사이드카 컨테이너(sidecar container)에서 실행됩니다. 사이드카는 gRPC를 통해 완료 신호를 보내며, 메인 프로세스는 전체 인덱스가 일관성(consistent)을 갖추었을 때만 포인터(pointer)를 전환합니다. 이는 재구축 완료 시 80ms의 콜드 스타트 지연(cold-start latency)을 추가하지만, GC 일시 중지(GC pauses)가 플레이어 요청으로 흘러 들어가지 않음을 보장합니다.
이후 수치가 말해준 것 (What The Numbers Said After)
변경 사항을 적용한 후, 새벽 2시 17분의 스파이크(spikes)는 사라졌습니다. 새로운 스케줄러(schedulers)의 평균 재빌드 완료 시간은 4분에서 6분 42초로 증가했습니다. 이는 여전히 10분이라는 SLA(Service Level Agreement) 범위 내에 있지만, 더 이상 피크 시간대(peak hours)와 겹치지 않습니다. 보물 검색(treasure searches)에 대한 99퍼센타일 지연 시간(99th percentile latency)은 재빌드 시간대(rebuild windows) 동안에도 전 세계적으로 200ms 미만을 유지했습니다.
이제 힙 사용량(Heap usage)은 재빌드 중에 90%에 육박하는 대신 45%에서 60% 사이에서 변동합니다. 페이저(pager)는 새벽 2시 17분에 울리는 것을 멈췄습니다. 그 문제의 화요일은 반복되는 워룸(war room) 회의가 아닌, 사후 분석(postmortem) 보고서의 각주로 남게 되었습니다.
내가 다르게 했을 일들
나는 Veltrix Operator의 기본 설정(default config)을 절대 신뢰하지 않았을 것입니다. 문서는 이를 rebuild-schedule.json이라고 부르지만, 실제로는 서비스 거부(denial-of-service) 생성기로 위장한 것처럼 동작합니다. 우리는 운영 환경(production)에 배포하기 전에 부하 테스트(load testing)를 통해 재빌드가 미치는 실제 영향을 프로파일링(profile)했어야 했습니다.
또한, 실제 플레이어의 쿼리(queries)를 모방하는 합성 부하 생성기(synthetic load generator)를 사용하여 재빌드 작업에 계측(instrument)을 수행했을 것입니다. 우리의 프리프로덕션(pre-prod) 테스트는 정적 인덱스(static index)를 대상으로 curl 스크립트를 사용했는데, 이는 무용지물이었습니다. 정적 테스트를 통과한 재빌드라도 CPU가 아닌 JVM GC(Garbage Collection) 압박 때문에 5만 개의 동시 쿼리(concurrent queries) 하에서 녹아내릴 수 있습니다.
마지막으로, 예상치 못한 트래픽 스파이크(traffic spikes) 동안 스케줄러를 완전히 비활성화할 수 있는 피처 플래그(feature flag)를 추가했을 것입니다. 대신 우리는 스케줄러를 베이스 이미지(base image)에 구워 넣었습니다(baked). 마케팅 팀이 새벽 1시 55분에 실수로 30만 명 규모의 이벤트를 트리거했을 때, 우리는 재빌드를 일시 중지할 수 없었고 사이드카(sidecar)는 90초 만에 힙(heap)이 고갈되었습니다. 우리는 이미지를 변경하기 위해 긴급 핫 패치(hot patch)를 수행해야 했습니다. 이는 피할 수 있는 일이었습니다.
Veltrix Operator가 틀린 것은 아닙니다. 단지 연극적일 뿐입니다. 스케줄러의 이름을 딴 설정 파일을 제공하지만, 대규모 환경(scale)에서 필요한 댐핑(damping), 백오프(backoff) 또는 관측성(observability)은 제공하지 않습니다. 우리는 세 번의 패치 사이클을 거치며 그 교훈을 배웠습니다. 다음에는 통합하기 전에 먼저 계측(instrument)할 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기