대규모 멀티플레이어 챗봇 NPC 대기열 병목 해소: Queue 시스템 설계와 구현

대규모 멀티플레이어 챗봇 NPC 대기열 병목 해소: Queue 시스템 설계와 구현

동시 접속자가 많은 멀티플레이어 게임에서 챗봇 NPC 요청이 몰릴 때 발생하는 지연·타임아웃·비용 폭증을 막는 Queue 시스템을 다룬다. 우선순위, 백프레셔, 재시도, Redis Streams 기반 구현과 운영 지표를 단계별로 설명한다.

챗봇 NPC 대기열 병목은 LLM 호출 시간이 게임 서버의 처리 시간보다 훨씬 길고 변동 폭도 크기 때문에 발생한다. 해결책은 플레이어 요청을 즉시 외부 모델에 연결하지 않고 입구의 백프레셔·우선순위 큐·독립 워커·멱등 재시도로 분리하는 것이다. 핵심 목표는 대기 시간이 한계를 넘기기 전에 요청을 제어하고 게임 플레이에 중요한 응답부터 예측 가능하게 처리하는 데 있다.

대규모 멀티플레이어에서 챗봇 NPC 병목은 왜 발생하는가?

일반적인 게임 API는 수십 ms 안에 끝나는 조회, 이동 검증, 인벤토리 변경을 전제로 설계한다. 반면 챗봇 NPC는 프롬프트 조립, 안전성 검사, 검색 증강, LLM 추론, 응답 후처리까지 거치므로 수백 ms에서 수 초가 걸릴 수 있다. 제공자 API의 동시성 제한과 분당 토큰 제한도 처리량을 급격히 제한한다.

도착률을 λ\lambda, 워커 한 개의 평균 처리율을 μ\mu, 워커 수를 cc라고 하자. 시스템 이용률은 다음과 같다.

ρ=λcμ\rho = \frac{\lambda}{c\mu}

ρ\rho가 1에 가까워질수록 큐 길이와 대기 시간은 비선형적으로 증가한다. 특히 LLM 응답 시간은 긴 꼬리를 가지므로 평균 응답 시간이 괜찮아 보여도 일부 요청이 워커를 오래 점유해 대기열을 급격히 늘린다.

병목 원인게임에서 보이는 증상Queue 시스템의 대응
LLM 응답 시간 변동NPC 답변이 늦거나 순서가 뒤섞임비동기 작업 분리와 작업 상태 저장
API 동시성·토큰 제한429 오류, 연쇄 재시도전역 속도 제한과 지수 백오프
인기 NPC 또는 이벤트 집중특정 샤드에서만 지연 급증파티션 키 분산과 우선순위 분리
무제한 재시도큐가 오래된 실패 작업으로 포화최대 시도 횟수와 DLQ
동기식 게임 서버 호출틱 지연, 연결 타임아웃즉시 접수 후 폴링 또는 푸시 전달

게임 서버와 챗봇 NPC 워커 사이의 요청 대기열 및 백프레셔 흐름

챗봇 NPC Queue 시스템은 어떻게 구성할까?

게임 클라이언트는 NPC 대화 요청을 보낸 뒤 202 Accepted 성격의 접수 결과와 requestId를 받는다. 채팅 결과는 WebSocket 푸시, 장기 폴링, 다음 대화 요청의 조회 중 하나로 전달한다. 게임 API 서버가 LLM 응답을 기다리지 않는 것이 중요하다.

flowchart LR
  C[Game Client] --> G[Game API]
  G --> V[Validation and Admission Control]
  V -->|accepted| Q[(Redis Stream)]
  V -->|overloaded| R[Fallback Response]
  Q --> W[Chatbot Worker Pool]
  W --> L[LLM Provider]
  W --> S[(Conversation and Job Store)]
  W --> P[WebSocket Gateway]
  P --> C
  W -->|permanent failure| D[(Dead Letter Queue)]

구성 요소와 책임

구성 요소책임구현 예시
Admission Control큐 적재 전 요청 수락 여부 결정Redis 카운터, 토큰 버킷
Priority Queue게임 영향도에 따라 처리 순서 결정Stream 분리 또는 점수 기반 Sorted Set
Worker Pool작업 소비, 프롬프트 생성, 모델 호출Node.js, Go, Java 워커
Job Store상태·결과·멱등성 키 보관PostgreSQL 또는 Redis
Rate Limiter제공자별 요청·토큰 예산 통제Redis Lua 스크립트
DLQ반복 실패 작업의 격리와 분석Redis Stream chat:dlq

Queue 시스템 구현 절차: 1. 작업 계약을 먼저 고정한다

대기열 메시지는 가능한 한 작고 불변이어야 한다. 대화 전문이나 플레이어의 민감한 정보 전체를 넣지 말고 대화 세션 ID와 필요한 참조 키만 보낸다. 워커가 작업을 시작할 때 저장소에서 최신 컨텍스트를 조회하면 메시지 크기와 개인정보 노출 범위를 줄일 수 있다.

export type ChatNpcJob = {
  jobId: string;
  idempotencyKey: string;
  playerId: string;
  npcId: string;
  conversationId: string;
  shardId: string;
  priority: "critical" | "normal" | "ambient";
  promptVersion: "npc-v3";
  createdAt: string;
  attempt: number;
};

idempotencyKeyplayerId, conversationId, 클라이언트 메시지 ID를 결합해 생성한다. 동일한 패킷이 재전송되어도 이미 완료된 결과를 반환하거나 하나의 작업만 생성해야 한다. 실제 작업 상태는 queued, processing, completed, failed, expired처럼 명시적으로 관리한다.

Queue 시스템 구현 절차: 2. 우선순위와 백프레셔를 적용한다

모든 NPC 대화를 하나의 FIFO 큐에 넣으면 길드 전쟁 중인 퀘스트 NPC와 주변 행인의 잡담이 같은 자리를 차지한다. 게임 규칙상 중요한 요청을 정의하고 큐를 분리하는 편이 운영하기 쉽다.

  • critical: 퀘스트 진행, 거래 확인, 서버 운영 공지와 연결된 대화
  • normal: 일반 NPC 대화
  • ambient: 감정 표현, 배경 대사, 재생성 가능한 잡담

가장 단순한 정책은 chat:critical, chat:normal, chat:ambient Stream을 별도로 두고 워커가 6:3:1 비율로 폴링하는 방식이다. 높은 우선순위만 계속 유입되는 상황에서는 낮은 우선순위가 굶지 않도록 최소 처리 비율을 보장한다.

백프레셔는 큐가 이미 과부하일 때 새 요청을 계속 쌓지 않게 하는 장치다. 아래처럼 큐 길이와 예상 대기 시간을 함께 사용한다.

Westimated=LcμW_{estimated} = \frac{L}{c\mu}

여기서 LL은 해당 우선순위 큐의 대기 작업 수다. 예를 들어 일반 대화의 최대 허용 대기 시간을 8초로 정했다면 W_estimated > 8일 때 새 요청을 거절하거나 짧은 규칙 기반 응답으로 전환한다. 단순한 거절 메시지보다 “지금 NPC가 다른 모험가와 대화 중입니다. 잠시 후 다시 시도해 주세요.” 같은 게임 내 대체 응답이 낫다.

Queue 시스템 구현 절차: 3. Redis Streams Consumer Group으로 소비한다

Redis Streams는 소비자 그룹과 미확인 메시지 목록(Pending Entries List)을 제공하므로 다수 워커가 작업을 나눠 처리하는 구조에 적합하다. 아래 예시는 TypeScript와 redis 클라이언트에서 사용하는 핵심 흐름이다.

const stream = "chat:normal";
const group = "npc-workers";
const consumer = `worker-${process.env.HOSTNAME}`;

async function consumeOnce(redis: RedisClientType) {
  const entries = await redis.xReadGroup(
    group,
    consumer,
    [{ key: stream, id: ">" }],
    { COUNT: 8, BLOCK: 2000 },
  );

  for (const entry of entries ?? []) {
    for (const message of entry.messages) {
      const job = JSON.parse(message.message.job as string) as ChatNpcJob;

      try {
        await processIdempotently(job);
        await redis.xAck(stream, group, message.id);
      } catch (error) {
        await handleJobFailure(job, message.id, error);
      }
    }
  }
}

워커가 모델 호출 도중 종료되면 ACK가 수행되지 않아 메시지가 PEL에 남는다. 별도 복구 프로세스는 XAUTOCLAIM으로 일정 시간 이상 유휴 상태인 메시지를 다른 워커로 넘긴다. 다만 재전송은 at-least-once 전달을 의미하므로 결과 저장과 클라이언트 전송 모두 멱등하게 만들어야 한다.

async function processIdempotently(job: ChatNpcJob) {
  const existing = await jobRepository.findCompleted(job.idempotencyKey);
  if (existing) return existing;

  const lock = await redis.set(`lock:chat:${job.jobId}`, "1", {
    NX: true,
    EX: 45,
  });
  if (!lock) return;

  try {
    const response = await generateNpcReply(job);
    await jobRepository.completeOnce(job.idempotencyKey, response);
    await websocketGateway.publish(job.playerId, response);
  } finally {
    await redis.del(`lock:chat:${job.jobId}`);
  }
}

재시도와 DLQ는 어떻게 설계해야 할까?

재시도 대상은 네트워크 단절, 일시적 429, 5xx처럼 회복 가능성이 있는 오류다. 프롬프트 검증 실패, 금지된 입력, 스키마 오류처럼 영구적인 오류는 바로 실패 처리한다. 모든 오류를 재시도하면 비용과 큐 적체만 커진다.

지수 백오프에 지터를 더하면 다수 워커가 동시에 재시도하는 현상을 줄일 수 있다.

delayn=min(delaymax,delaybase×2n)+random(0,jitter)delay_n = \min(delay_{max}, delay_{base} \times 2^n) + random(0, jitter)

권장 시작값은 delayBase=500ms, delayMax=30s, maxAttempts=3 정도이며 실제 값은 제공자 제한과 플레이어가 수용 가능한 대기 시간에 맞춰 조정한다. 마지막 실패 후에는 원본 작업, 오류 분류, 시도 횟수, 프롬프트 버전을 DLQ에 기록한다. DLQ는 자동으로 무한 재처리하지 말고 운영자가 원인을 확인한 뒤 선별적으로 재주입해야 한다.

파티션 키는 무엇으로 정해야 하는가?

NPC 대화는 세션 내 순서가 중요할 수 있다. 동일한 conversationId의 메시지가 다른 워커에서 역순으로 처리되면 컨텍스트가 어긋난다. 반면 모든 대화를 하나의 파티션에 넣으면 확장성이 사라진다.

파티션 키장점주의점
conversationId대화 순서 보장에 유리인기 대화 하나가 핫 파티션이 될 수 있음
playerId플레이어별 순서 관리가 단순여러 NPC 대화의 불필요한 직렬화
npcIdNPC 상태를 공유할 때 유리인기 NPC 집중 위험이 큼
shardId + conversationId월드 샤드 격리와 순서 보장샤드 이동 시 라우팅 정책 필요

대부분의 경우 shardId + conversationId를 해시해 여러 Stream 또는 파티션에 분배하고 같은 대화에는 한 번에 하나의 활성 작업만 허용하는 방식이 실용적이다. 플레이어가 연속으로 메시지를 보내면 이전 답변이 끝날 때까지 새 메시지를 합쳐 요약하거나 최신 메시지만 남기는 coalescing 정책도 고려할 수 있다.

운영 지표로 병목을 어떻게 조기에 감지할까?

평균 지연 시간만 보면 늦다. 큐 시스템은 처리량보다 대기열의 증가 속도와 상위 백분위 지연 시간을 먼저 봐야 한다.

지표경고 기준 예시의미
Queue depth5분 연속 증가도착률이 처리율을 초과했을 가능성
Oldest message age우선순위별 SLO 초과플레이어가 실제로 겪는 대기 시간
P95/P99 end-to-end latency목표 지연 초과긴 꼬리 지연 악화
PEL size지속 증가워커 장애 또는 ACK 누락
429 비율제공자별 임계치 초과속도 제한 정책 부족
DLQ 유입률배포 후 급증프롬프트·스키마·연동 회귀

알림은 큐 길이 하나가 아니라 oldest message age, 성공률, 워커 가용 수를 함께 묶어 판단하는 것이 좋다. 예를 들어 큐 길이가 크더라도 빠르게 소진 중이면 즉시 장애는 아닐 수 있지만 가장 오래된 일반 대화가 10초를 넘으면 플레이어 경험은 이미 나빠진 상태다.

보안과 비용 제어에서 놓치기 쉬운 점

대기열 메시지는 운영 도구와 장애 분석 과정에서 넓게 노출될 수 있다. 플레이어 원문, 인증 토큰, 결제 정보, 내부 프롬프트를 메시지에 그대로 넣지 않는다. 필요한 경우 메시지 본문은 암호화된 저장소에 두고 큐에는 참조 ID만 둔다.

또한 플레이어별·IP별·세션별 속도 제한을 입구에서 적용해야 한다. 단일 사용자가 무제한 대화를 보내는 공격은 LLM 비용을 늘릴 뿐 아니라 다른 플레이어의 대기 시간도 악화시킨다. 입력 길이, 일일 토큰 예산, 동시 진행 작업 수를 별도로 제한하면 방어 효과가 크다.

자주 묻는 질문 (FAQ)

Redis Streams 대신 RabbitMQ나 Kafka를 써도 되나요?

가능하다. 작업 재시도와 소비자 그룹을 빠르게 구성하려면 Redis Streams가 단순하고 강한 라우팅·확인·지연 전달 기능이 중요하면 RabbitMQ가 적합하다. 대규모 이벤트 보관과 다수의 독립 소비자가 필요하면 Kafka를 검토한다. 이미 운영 중인 메시지 플랫폼과 팀의 장애 대응 역량도 선택 기준이다.

워커 수만 늘리면 큐 병목이 해결되나요?

아니다. 모델 제공자의 동시성·토큰 제한이 상한선이 될 수 있다. 워커 증설 전에 제공자별 rate limit, 평균 토큰 수, P99 추론 시간, 큐별 우선순위를 함께 측정해야 한다.

NPC 답변을 기다리는 동안 클라이언트는 무엇을 보여줘야 하나요?

접수 상태와 짧은 로딩 표시를 먼저 보여주고 일정 시간 이후에는 취소 또는 재시도 선택지를 제공한다. 게임 디자인상 허용된다면 이전 대화 기록이나 규칙 기반 짧은 반응을 표시해 침묵 시간을 줄일 수 있다.

정리

대규모 멀티플레이어 챗봇 NPC의 안정성은 모델 자체의 추론 속도보다 요청 흐름을 제어하는 큐(Queue) 아키텍처에 달려 있습니다.

비동기 접수, 우선순위 분리, 대기 시간 기반의 백프레셔(Backpressure), 멱등성 보장, 재시도 제한 및 DLQ(Dead Letter Queue)를 유기적으로 결합해야 트래픽 급증 상황에서도 게임 서버와 유저 경험을 온전히 방어할 수 있습니다.

특히 운영 단계의 SLO는 단순 큐 길이가 아닌, 가장 오래된 작업의 체류 시간(Age of Oldest Message)과 P99 지연 시간을 핵심 지표로 관리해야 합니다.

#멀티플레이어 서버#챗봇 NPC#메시지 큐#Redis Streams#백프레셔#게임 백엔드#LLM 인프라

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs