[ 롤모임 운영일지 ] - 13. 실시간 채널을 하나로 합쳤더니, 조용히 새고 있던 게 보였다
Supabase 요금이 예상보다 크게 나왔다. 파고들어보니 원인이 두 가지였다 — 하나는 RiotMatch.rawInfo(전적 원본 JSON)가 DB 용량의 대부분을 먹고 있던 것, 다른 하나는 Supabase Realtime이 백그라운드에서 계속 WAL을 폴링하며 리소스를 쓰고 있던 것. 앞의 것은 “Cloudflare R2 같은 데 따로 저장해서 필요할 때만 가져다 쓰면 어떨까”라는 설계만 해두고 아직 실행에 옮기진 않았다(별도 글감이다). 오늘 다룰 건 뒤의 것 — Realtime을 끄기로 한 결정과, 그 결정이 실행되고 나서 며칠 뒤에 발견한 예상 밖의 여파다.
첫 판단은 틀렸다 — “죽은 코드인 줄 알았다”
Realtime을 당장 끄자는 게 처음 방향이었다. 코드를 훑어보니 useRealtimeSession.ts라는 훅이 있긴 한데, 얼핏 보기엔 안 쓰이는 것처럼 보였다. “죽은 코드니까 그냥 끄면 된다”고 결론 내리려던 참이었다.
다시 한 번 페이지 전체를 훑어보고 나서야 틀렸다는 걸 알았다. 이 훅은 실시간 경매 화면(AuctionDetailPage.tsx)에서 실제로 쓰이고 있었다 — 팀 구성 변경, 입찰 상태, 상점 구매 알림까지 이 채널을 타고 있었다. 죽은 코드가 아니라 이미 운영 중인 기능이었던 거다.
여기서 멈추고 정정한 게 다행이었다. 만약 그대로 밀어붙였으면 경매 진행 중에 팀원들 화면이 서로 안 맞는 상태로 방치되는, 사용자 눈에 바로 보이는 사고가 났을 거다.
결정 — 끄기 전에, 있는 채널로 흡수한다
그래서 방향을 바꿨다. Realtime을 그냥 끄는 게 아니라, 이미 떠 있는 Socket.IO WS 서버로 그 역할을 흡수한 다음에 끈다. 이 프로젝트는 실시간 경매 입찰을 위해 별도 GCE VM에서 Socket.IO + Redis 조합의 WS 서버를 이미 운영하고 있었다. Realtime과 WS, 두 개의 실시간 채널을 유지할 이유가 없으니 하나로 합치는 게 맞는 방향이었다.
캐시 무효화용으로 이미 존재하던 헬퍼 하나가 이 통합의 핵심이 됐다.
// invalidate-auction-cache.ts
export async function invalidateAuctionCache(
sessionId: number,
chat?: Record<string, unknown>,
payload?: Record<string, unknown>,
) {
// ... WS 서버의 내부 엔드포인트로 POST, 실패해도 throw 안 함(최대 2회 재시도)
}
payload를 안 주면 방에 있는 모든 클라이언트에게 “너 다시 조회해”(session_invalidated)라고 알리고, payload를 주면 서버가 미리 한 번 읽어온 최신 상태를 그대로 뿌린다(session_state) — 각자 재조회하는 대신 서버가 한 번 읽어서 나눠주는 셈이다. 문제는 이 헬퍼가 낙찰 확정 같은 몇몇 지점에만 붙어 있었고, 팀 참가/팀장 지정/셋업 변경 같은 다른 상태 변경 지점들엔 안 붙어 있었다는 거다. Realtime이 그 빈틈을 postgres_changes로 메워주고 있었으니 지금까진 티가 안 났던 것뿐이다.
빈틈을 찾아서 다 메웠다. 처음 훑어봤을 때 10곳을 찾았는데, 코드리뷰를 한 번 더 돌리고 나니 6곳을 더 찾았다 — 참가자 추가/제거, 코인플립, 픽 순서 결정, MVP 투표 같은 지점들. 총 16개 라우트에 invalidateAuctionCache 호출을 추가하고, 프론트엔드에서 Realtime 채널 구독 코드를 걷어내고 전부 WS 하나로 합쳤다.
부작용 — 알림이 늘어난 만큼, DB로 가는 재조회도 늘었다
메꾸고 나니 새로운 문제가 보였다. session_invalidated는 “다시 조회해”라는 신호다. 팀 참가/팀장 변경처럼 짧은 시간에 여러 번 일어날 수 있는 이벤트마다 이 신호를 쏘기 시작하면, 방에 있는 인원수만큼 재조회 요청이 한꺼번에 몰릴 수 있다. 예전엔(내전 팀원 수 X 각자 접속한 세션 수) 정도였던 부담이, 이번 변경으로 상태 변경이 일어날 때마다 매번 그 규모로 반복될 소지가 생긴 거다.
그래서 세션 조회 엔드포인트에 slim 모드를 추가했다. 최초 1회만 평가 점수·천사카드 개수 같은 부가 쿼리까지 다 긁어오고, 그 이후 재조회는 메인 쿼리만 하고 부가 쿼리 2개는 건너뛰게 했다 — 프론트에서 이전 값을 그대로 이어 붙이는 merge 로직만 추가하면 됐다.
const slim = req.query.slim === "1";
const evalMap = slim ? new Map() : await fetchEvalsByUser(suUserIds);
const angelMap = slim ? new Map() : await fetchAngelCardCounts(suUserIds);
이 최적화를 셋업 화면에는 처음에 넣어놓고, 결과 화면(MVP 투표가 일어나는 곳)엔 빠뜨렸다는 것도 나중에 코드리뷰 과정에서 알아챘다. MVP 투표는 경기 끝나고 참가자 전원이 몰아서 누르는 패턴이라 오히려 이쪽이 더 위험한 지점이었다 — 같은 버그를 다른 화면에 남겨둔 채로 지나칠 뻔했다.
배포하고 나서 — “지금 얼마나 버틸 수 있지?”
여기까지 배포하고 나서 자연스럽게 드는 질문이 있었다. WS 서버가 이제 더 많은 역할을 떠맡게 됐는데, 지금 인스턴스가 몇 대이고 얼마나 버틸 수 있는지 실제로는 모르고 있었다. 확인해보니:
-
API(Cloud Run):
minScale=1, maxScale=10, 인스턴스당 동시 요청 80개까지. 트래픽 따라 자동으로 늘어난다. -
WS(GCE VM):
e2-small(2 vCPU) 한 대, pm2 fork 모드로 프로세스 1개. 오토스케일 없음, 이중화 없음 — 단일 장애점이다.
API는 늘어나는데 WS는 고정이라는 비대칭이 있다는 걸 이때 처음 명확히 확인했다. 다만 실제 부하를 재보니(load average 0.00대, 실제 연결 수 십여 개) 지금 트래픽 규모에선 전혀 임계치 근처가 아니었다. 그래서 “인스턴스를 늘리자”가 아니라 “일단 로그부터 자세히 보자”로 방향을 잡았다.
놀랐다가 — 그냥 인터넷 배경 소음이었다
로그를 보다가 ss -tan으로 연결 상태를 찍어봤는데, SYN-RECV 상태 연결이 200개 넘게 잡혔다. 순간 “이거 뭔가 몰려오고 있나” 싶었다.
까보니 아니었다. 목적지 포트를 찍어보니 전부 443/22였고, 발신지 IP도 제각각이었다 — 공인 IP를 가진 서버라면 늘 붙는 인터넷 스캐너들의 배경 트래픽이었다. 애플리케이션(auction-ws)과는 무관한 노이즈였던 셈이다.
이 해프닝 자체는 별거 아니지만, 짚어둘 교훈은 있다. 지표 하나만 보고 놀라기 전에, 그 지표가 실제로 뭘 재는 건지부터 확인해야 한다. ss가 보여주는 연결 수는 애플리케이션 트래픽과 OS 레벨의 배경 노이즈를 구분해주지 않는다.
진짜 문제는 로그를 더 파고들다가 나왔다
스캐너 노이즈에 낚일 뻔한 김에, 로그를 조금 더 넓게 훑어봤다. 거기서 PayloadTooLargeError가 반복해서 찍히고 있는 걸 발견했다. 최근 로그에서만 52건.
원인은 단순했다. WS 서버의 내부 무효화 엔드포인트가 express.json()을 바디 크기 제한 없이 쓰고 있었다 — Express 기본값인 100kb가 그대로 적용되고 있었던 거다.
// 수정 전
app.use(express.json());
// 수정 후
app.use(express.json({ limit: "3mb" }));
문제가 되는 지점은 낙찰 확정 시 서버가 세션 전체 스냅샷을 만들어서 방에 한 번에 push하는 최적화 경로였다. 참가자·팀·베팅·경기 기록이 많이 쌓인 세션일수록 이 payload가 커지는데, 100kb를 넘는 순간 Express가 바디 파싱 단계에서 요청 자체를 거부해버린다. 라우트 핸들러는 실행조차 안 되니, Redis 캐시 갱신도 브로드캐스트도 둘 다 조용히 안 일어난다. 게다가 invalidateAuctionCache는 실패를 삼키는 설계였다 — 재시도 두 번 하고도 안 되면 그냥 로그만 남기고 넘어간다. 그러니 이 실패는 사용자 리포트가 아니고서는 발견될 방법이 없었다.
역설적인 지점이 여기다. 참가자가 많고 활발한 세션일수록, 다시 말해 실시간 동기화가 가장 중요한 세션일수록, 이 버그에 걸릴 확률이 높았다. 조용한 세션은 애초에 payload가 작아서 안 걸린다.
왜 await을 그대로 뒀는가
이 무효화 호출은 16곳 전부 await로 응답을 막고 있다. WS 서버가 느리거나 다운되면 팀 참가·투표 같은 기본 조작 하나하나가 최대 몇 초씩 지연될 수 있다는 뜻이라, “그냥 fire-and-forget으로 바꾸면 안 되나” 하는 생각이 자연스럽게 들었다.
그런데 Cloud Run 설정을 확인해보니 --no-cpu-throttling이 걸려 있지 않았다 — 기본값(요청 처리가 끝나면 CPU가 스로틀됨)이 그대로 적용 중이었다. 이 상태에서 응답을 먼저 보내고 무효화 호출을 백그라운드로 돌리면, 응답을 보낸 시점에 CPU가 스로틀돼서 그 백그라운드 작업이 끝까지 실행되지 않고 조용히 유실될 수 있다. 방금 겪은 “조용히 실패하는” 버그를 하나 고치자고, 또 다른 조용히 실패하는 경로를 새로 만드는 셈이다. 그래서 지금은 “지연 vs 조용한 유실” 사이에서 지연 쪽을 택한 채로 남겨뒀다 — --no-cpu-throttling을 켜는 게 먼저 검토할 선행 조건이다.
정리
- “죽은 코드처럼 보인다”는 판단은 한 번 더 확인하고 나서 내려야 한다. 첫 판단을 그대로 밀어붙였으면 눈에 보이는 사고로 이어졌을 거다 — 다시 훑어보고 스스로 정정한 게 이번엔 다행이었다.
- 채널을 하나로 합치는 리팩터링은 “빠뜨린 곳이 없는지” 검증까지가 작업의 절반이다. 처음 찾은 10곳에, 리뷰를 한 번 더 돌려서 6곳을 추가로 찾았다. 같은 종류의 최적화(slim 모드)도 화면 하나엔 넣고 다른 화면엔 빠뜨렸었다.
-
지표에 놀라기 전에 그 지표가 뭘 재는지부터 확인하라.
SYN-RECV200개는 공격도 폭주도 아니고 그냥 인터넷 배경 소음이었다. -
가장 활발한 곳에서 가장 조용히 실패하는 버그가 제일 위험하다.
PayloadTooLargeError는 세션이 커질수록, 즉 실시간 동기화가 제일 중요해지는 시점일수록 조용히 걸리는 버그였다. 실패를 삼키도록 설계된 경로는 로그를 일부러 파고들지 않으면 존재 자체를 알 수가 없다. - “지연을 줄이자”가 항상 정답은 아니다. 플랫폼의 다른 설정(CPU 스로틀링)과 맞물리면, 지연을 줄이려던 변경이 실패를 조용하게 만드는 방향으로 갈 수도 있다. 바꾸기 전에 그 변경이 기대는 전제부터 확인해야 한다.