refactor(rpc): site↔bot 명령 응답을 SETEX+폴링에서 LPUSH+BRPOP 블로킹으로 전환

- bot: reply() 헬퍼(LPUSH+EXPIRE) 추가, 67개 setex 응답 지점을 일괄 교체.
  결과키를 리스트에 push해 사이트가 BRPOP으로 블로킹 수신하도록 함.
- page(api.ts botRpc): short-polling 제거, 요청마다 전용 커넥션(duplicate)으로
  BRPOP 대기 → 응답 즉시 수신(폴링 지연/헛 GET 제거), 전용 커넥션은 finally에서 정리.
- search route: 사라진 pollIntervalMs 옵션 제거.

검증: 테스트봇(.9)+.7 Redis 실측 BRPOP 왕복 10ms, 고아 키 없음. bot tsc/page next build 통과.
This commit is contained in:
2026-09-12 23:58:52 +09:00
parent 221c31bfa1
commit 31b64b73c5
3 changed files with 102 additions and 116 deletions

View File

@@ -20,7 +20,6 @@ export async function GET(request: Request) {
query,
},
timeoutMs: 10000,
pollIntervalMs: 250,
});
return NextResponse.json(body, { status });
} catch (error) {

View File

@@ -25,35 +25,23 @@ export async function requireSession(): Promise<
}
// ========== 봇 RPC 헬퍼 ==========
// site → bot: Redis Pub/Sub 으로 명령 전송
// bot → site: Redis SET 으로 결과 저장 (resultKey)
// 사이트는 resultKey 를 short polling 으로 확인.
// site → bot: Redis Pub/Sub 으로 명령 전송 (채널 "site-bot")
// bot → site: 봇이 결과를 resultKey 리스트에 LPUSH
// 사이트는 resultKey 를 BRPOP 으로 블로킹 대기한다(폴링 없음 → 응답 즉시, 헛 GET 제거).
export interface BotRpcOptions {
/** Redis 결과 키 prefix (e.g. "player:now") */
channel: string;
/** 봇으로 보낼 페이로드. requestId는 자동 주입됨. */
payload: Record<string, unknown>;
/** 폴링 총 타임아웃 (ms). 기본 3000. */
/** 응답 대기 타임아웃 (ms). 기본 3000. */
timeoutMs?: number;
/** 폴링 간격 (ms). 기본 100ms 시작 → 최대 400ms로 백오프. */
pollIntervalMs?: number;
/** Redis 결과 키 만료(초). 기본 5초 — 클라이언트 타임아웃 후에도 키가 남아있는 것을 방지. */
resultTtlSec?: number;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export async function botRpc(
opts: BotRpcOptions,
): Promise<{ status: number; body: Record<string, unknown> }> {
const {
channel,
payload,
timeoutMs = 3000,
pollIntervalMs = 100,
resultTtlSec = 5,
} = opts;
const { channel, payload, timeoutMs = 3000 } = opts;
// CSPRNG 기반 requestId — Date.now() + Math.random() 충돌 가능성 제거
const requestId = `req:${randomUUID()}`;
@@ -65,26 +53,24 @@ export async function botRpc(
JSON.stringify({ ...payload, requestId }),
);
const deadline = Date.now() + timeoutMs;
// 봇은 보통 수십 ms 안에 응답하므로, 먼저 즉시 확인하고 촘촘히 폴링한다(초기 지연 제거).
// (이상적으로는 봇이 reply 채널로 publish → 사이트가 구독하는 pub/sub 방식이나,
// 봇의 30여 개 setex 지점을 모두 바꿔야 해 회귀 위험이 커서 폴링 최적화로 대체.)
let interval = Math.min(pollIntervalMs, 30);
let first = true;
// BRPOP 은 응답이 올 때까지 커넥션을 블로킹하므로, 공유 커넥션을 막지 않도록
// 요청마다 전용 커넥션을 복제해서 쓰고 끝나면 닫는다.
// (봇이 LPUSH 를 이미 끝냈다면 BRPOP 은 즉시 반환하므로 publish→BRPOP 순서 경쟁 문제 없음)
const waiter = Redis.duplicate();
// BRPOP 타임아웃은 초 단위. ms 를 올림(최소 1초)해서 전달.
const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
while (Date.now() < deadline) {
if (!first) {
await sleep(interval);
interval = Math.min(Math.floor(interval * 1.6), 250);
try {
// brpop → [key, value] | null(타임아웃)
const popped = await waiter.brpop(resultKey, timeoutSec);
if (!popped) {
return {
status: 504,
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
};
}
first = false;
const reply = await Redis.get(resultKey);
if (!reply) continue;
// 읽은 즉시 정리 (TTL 도 보험으로 깔려있음)
await Redis.del(resultKey);
const reply = popped[1];
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(reply);
@@ -99,20 +85,10 @@ export async function botRpc(
const hasSuccessField = "success" in parsed;
const ok = !hasSuccessField || parsed.success === true;
return { status: ok ? 200 : 400, body: parsed };
} finally {
// 전용 커넥션 정리 (블로킹 중이면 즉시 끊기)
waiter.disconnect();
}
// 타임아웃 — 봇이 늦게 응답해도 메모리에 쌓이지 않도록 만료 설정
// (resultKey 가 아직 없을 수 있으므로 expire 가 0 을 반환할 수 있음, 무해함)
try {
await Redis.expire(resultKey, resultTtlSec);
} catch {
// 무시: 정리 실패는 치명적 아님
}
return {
status: 504,
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
};
}
// ========== POST 본문 파싱 ==========