보기에 정상적이던 Events Table이 우리 Queue를 망친 방법
Source: Dev.to
번역할 텍스트가 제공되지 않았습니다. 번역이 필요한 본문을 알려주시면 한국어로 번역해 드리겠습니다.
실제로 해결하고 있던 문제
우리 기능 팀은 매초 상위 100명의 플레이어를 표시하는 고득점 리더보드를 담당했습니다. 스택은 간단했습니다: Postgres 15, huntcore 라는 Golang 마이크로서비스, 그리고 내부 이벤트 버스로서 Veltrix v2.4.
huntcore는 각 경기 종료 시 events(id, event_type, payload, ts) 테이블에 행을 삽입하고 NOTIFY score_updated를 발행했습니다. 백그라운드 워커가 그 알림을 소비해 events에 윈도우 함수를 적용하고 결과를 leaderboard_1s에 기록했습니다. 교과서적인 흐름이었습니다.
할로윈 보물 드롭 중 발생한 확장성 문제
트래픽이 두 배로 늘었을 때, Postgres는 LISTEN 채널당 8 KB만 버퍼링하기 때문에 NOTIFY 메시지가 백로그되었습니다. 우리는 400 events/s 정도의 속도로 푸시하고 있었습니다. huntcore는 iowait > 40 % 를 보이기 시작했고, 리더보드는 실시간을 따라가지 못했습니다. 우리는 문제가 Postgres에 있다고 판단하고, 분산 버스를 찾아보기 시작했습니다.
첫 번째 시도: Kafka
우리는 Veltrix Kafka Connect 플러그인을 통해 NOTIFY를 Kafka로 교체하고, huntcore.score 토픽을 생성했으며, 순서를 보존하기 위해 linger.ms=0, batch.size=1을 설정했습니다. 한 시간 이내에 Golang 소비자는 PutRecords API에서 TooManyRequests 오류를 발생시켰습니다. 할당량을 늘린 후, 소비자 그룹은 30 s마다 재균형되었고, 그 결과 점수가 1초 동안 사라져—리더보드가 전쟁실 화면에서 실제로 깜빡였습니다.
두 번째 시도: Pulsar
그 다음 우리는 동일한 토폴로지를 사용해 Veltrix의 내장 Pulsar 싱크를 시도했다. Pulsar의 기본 배치 윈도우인 100 ms가 선두 차단을 감소시켰지만, 리밸런스는 여전히 보였다. 더 나아가 managedLedgerCursorMaxLedgerIndex가 조정되지 않아 Pulsar 북키 디스크가 가득 찼다. 컨테이너는 매 20 분마다 OOM‑killing을 발생시켰고, on‑call 로테이션이 레저를 수동으로 정리하도록 강제했다.
Core Mismatch: ACK Semantics
Both Kafka and Pulsar dropped the NOTIFY contract entirely. Huntcore expected an ACK for every score it inserted; the distributed queues only ACKed when the message was durably stored. This mismatch let INSERT succeed while the leaderboard update failed, creating phantom scores. We added a duplicate‑detection CTE in Postgres to drop rows where server_time > leaderboard_time + 1 s, but the late‑arrival gap widened as traffic ramped.
Materialized View를 사용한 Postgres 복귀
우리는 분산 버스를 포기하고 저장 패턴을 바꾸는 대신 Postgres를 유지했습니다.
-- events table (unchanged)
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
ts TIMESTAMPTZ NOT NULL DEFAULT now()
);
우리는 v_leaderboard_1s 라는 materialized view를 추가했습니다:
CREATE MATERIALIZED VIEW v_leaderboard_1s
WITH (timescaledb.refresh_lag = '1 second') AS
SELECT
player_id,
MAX(score) AS best_score,
ts
FROM events
WHERE event_type = 'finish'
GROUP BY player_id
ORDER BY best_score DESC
LIMIT 100;
huntcore 서비스는 이제 events에 삽입하고 바로 뷰를 갱신합니다:
INSERT INTO events (event_type, payload) VALUES ('finish', '{"player_id":123,"score":456}');
REFRESH MATERIALIZED VIEW CONCURRENTLY v_leaderboard_1s;
또한 보존 정책(retention policy)으로 뷰 크기를 제한했습니다:
SELECT add_retention_policy('v_leaderboard_1s', INTERVAL '7 days');
전체 마이그레이션은 45 분이 걸렸습니다. 우리는 Kafka, Pulsar, 혹은 Veltrix 커넥터를 다시 건드리지 않았습니다.
Results
-
p99 latency for the leaderboard dropped from 800 ms to 16 ms.
→ 리더보드의 p99 지연 시간이 800 ms에서 16 ms로 감소했습니다. -
CPU on the Postgres primary fell from 65 % to 28 %.
→ Postgres 기본 서버의 CPU 사용량이 **65 %**에서 **28 %**로 감소했습니다. -
Pods that were OOM‑killing were scaled down to zero.
→ OOM‑killing이 발생하던 Pods가 0으로 스케일 다운되었습니다. -
Huntcore’s
INSERTlatency stayed at 2 ms; the view refresh added 12 ms, well within the 50 ms SLA.
→ Huntcore의INSERT지연 시간이 2 ms를 유지했고, 뷰 새로 고침이 12 ms를 추가했으며, 이는 50 ms SLA 범위 내에 있습니다.
Veltrix remains in use for the audit trail and purple‑team dashboards, but it is disconnected from the real‑time score pipeline. The NOTIFY channel is now strictly for cache invalidation and is tuned with:
listen_addresses='*'
shared_preload_libraries='pg_stat_statements'
# 32 MB ring buffer to avoid the original 8 KB overflow
교훈
- 이벤트 스트림을 과도하게 설계하지 말 것. Kafka 또는 Pulsar는 숨겨진 비용(리밸런스 지연, 디스크 할당량)을 추가하며, 이는 서비스 간 통신에 대한 이점을 능가할 수 있습니다.
- 내구성 의미를 초기에 측정할 것. Postgres
NOTIFY는 fire‑and‑forget이며, 실패한 리스너를 재생하지 않습니다.event_id와player_id에서 파생된 멱등성 키를 추가하면 추가 인프라 없이도 가짜 점수를 제거할 수 있었습니다. - 핵심 변경 사항에 기능 플래그를 사용할 것. 주니어 엔지니어가
CONCURRENTLY없이REFRESH MATERIALIZED VIEW를 실수로 실행해 첫 번째 카나리 단계에서 테이블이 3 초 동안 잠겼습니다. 플래그 덕분에 깔끔하게 롤백할 수 있었습니다. - 현실적인 부하 테스트를 실행할 것. 실제 할로윈 트래픽을 사용한 24 시간 테스트라면 프로덕션 전에 리밸런스 깜빡임을 발견했을 것입니다.
뒤돌아보면, 이벤트 스트림을 Postgres 내부에 유지하고 그 네이티브 물리화 뷰 기능을 활용한 것이 훨씬 간단하고, 더 신뢰성 있으며, 실시간 리더보드에 훨씬 더 높은 성능을 제공했습니다.