챗 서버 분리: 대량의 전체 채널과 귓속말을 처리하는 아키텍처

챗 서버 분리: 대량의 전체 채널과 귓속말을 처리하는 아키텍처

게임 서버의 전투·월드 로직과 채팅을 분리하는 이유부터 전체 채널, 파티 채널, 귓속말의 처리 경로와 장애 대응까지 정리한다. Unity·Unreal 클라이언트에 공통으로 적용할 수 있는 서버 중심 설계다.

왜 채팅 서버를 분리하는가

채팅은 메시지 하나가 작아 보여도 동시 접속자가 늘면 매우 다른 부하 양상을 보인다. 전체 채널은 한 번의 입력이 많은 수신자에게 복제되고 귓속말은 상대방의 접속 위치를 찾아 정확히 한 명에게 전달해야 한다. 이 일을 월드 서버가 함께 맡으면 전투 틱, NPC AI, 인스턴스 처리와 경쟁하면서 지연 시간이 흔들릴 수 있다.

채팅 서버를 별도 서비스로 두면 채팅의 연결 수, 팬아웃, 욕설 필터, 메시지 저장 정책을 월드 서버와 독립적으로 조정할 수 있다. 월드 서버는 캐릭터의 현재 위치나 파티 변경처럼 채팅 권한에 필요한 이벤트만 전달하면 된다.

먼저 구분할 메시지 경로

채널 채팅과 귓속말은 데이터 구조와 확장 방식이 다르므로 같은 큐에 넣고 동일하게 처리하면 병목을 만들기 쉽다.

  • 전체·지역·길드·파티 채널: 한 메시지를 구독자 집합에 배포하는 팬아웃 작업이다.
  • 귓속말: 발신자와 수신자 식별자를 기준으로 온라인 위치를 조회한 뒤 단일 대상에게 라우팅하는 작업이다.
  • 시스템 공지: 권한 있는 발신자만 작성하며 범위가 매우 큰 채널에 배포될 수 있으므로 별도 속도 제한이 필요하다.

아래처럼 게임 게이트웨이는 클라이언트 연결을 유지하고 채팅 서비스가 채널 상태와 전달 정책을 맡는 구성이 일반적이다.

flowchart LR
    C[게임 클라이언트] --> G[게임 게이트웨이]
    G --> CS[채팅 인그레스]
    CS --> RL[인증·속도 제한·필터]
    RL --> CH[채널 샤드]
    RL --> PM[귓속말 라우터]
    CH --> G
    PM --> PR[접속 위치 저장소]
    PM --> G
    W[월드·파티·길드 서버] --> EV[권한 이벤트]
    EV --> CH
    EV --> PR

클라이언트가 채팅 서버에 직접 연결하는 방식도 가능하다. 다만 게임 세션의 인증과 재접속 처리를 한 곳에 모으고 싶다면 게이트웨이가 채팅 프레임을 중계하는 편이 운영하기 단순하다. 직접 연결을 택했다면 게임 로그인 토큰과 별도의 짧은 수명 채팅 토큰을 교환해 채팅 서버가 인증 상태를 검증하도록 만든다.

채널은 샤드 소유권으로 나눈다

채널의 핵심 상태는 channelId -> 구독자 목록이다. 모든 채팅 서버가 같은 채널의 구독자 목록을 수정하면 락 경합과 중복 전송이 생긴다. 따라서 하나의 채널은 특정 채널 샤드가 소유하도록 정한다.

예를 들어 hash(channelId) % shardCount로 샤드를 선택할 수 있다. 이후 채널 입장, 퇴장, 메시지 전송 요청은 모두 같은 샤드로 보낸다. 샤드 내부에서는 단일 이벤트 루프 또는 채널별 직렬 실행을 사용하면 구독자 집합을 잠금 없이 일관되게 갱신할 수 있다.

struct ChatMessage {
    uint64_t messageId;
    uint64_t senderId;
    uint64_t channelId;
    std::string text;
    uint64_t sentAtMs;
};

void ChannelShard::Publish(const ChatMessage& message) {
    auto found = channels_.find(message.channelId);
    if (found == channels_.end()) return;

    for (const SessionId sessionId : found->second.members) {
        gatewayPublisher_.Send(sessionId, message);
    }
}

이 예제의 반복 자체는 피할 수 없다. 수신자가 10만 명인 채널에 메시지를 보내면 논리적으로 10만 번의 전달이 필요하다. 중요한 점은 이 비용을 전투 서버에서 발생시키지 않고 채널 샤드에서 예측 가능하게 처리하는 것이다.

메시지당 네트워크 송신량은 대략 다음처럼 생각할 수 있다.

egress_bytesmessage_bytes×recipient_countegress\_bytes \approx message\_bytes \times recipient\_count

여기에 프로토콜 헤더, 암호화 오버헤드, 재전송 버퍼가 추가된다. 전체 채널의 평균 메시지 크기와 초당 메시지 수를 측정해 두면 네트워크와 게이트웨이 증설 시점을 추정할 수 있다.

전체 채널 메시지가 채널 샤드에서 여러 게이트웨이 세션으로 팬아웃되는 구조

전체 채널의 폭주를 제어하는 방법

전체 채널은 인기 시간대에 몇 명의 사용자가 송신량을 지배할 수 있다. 서버 보호와 사용자 경험을 위해 발신자 단위, 채널 단위 두 단계의 제한을 둔다.

발신자 제한에는 토큰 버킷이 잘 맞는다. 평소에는 일정량의 토큰을 채우고 메시지 하나당 토큰 하나를 사용한다. 짧은 연속 입력은 허용하면서도 지속적인 도배는 차단할 수 있다.

bool TokenBucket::TryConsume(uint64_t nowMs, double cost) {
    const double elapsedSec = (nowMs - lastUpdatedMs_) / 1000.0;
    tokens_ = std::min(capacity_, tokens_ + elapsedSec * refillPerSec_);
    lastUpdatedMs_ = nowMs;

    if (tokens_ < cost) return false;
    tokens_ -= cost;
    return true;
}

채널 단위 제한은 샤드의 송신 큐가 무한히 커지는 일을 막는다. 큐가 임계치를 넘으면 오래된 일반 메시지를 버리거나 해당 채널에 잠시 느린 모드를 적용할 수 있다. 메시지를 무한 대기시키는 정책은 좋지 않다. 몇 초 늦게 도착한 채팅은 대화 맥락을 해치고 메모리만 점유한다.

대형 전체 채널은 한 채널을 여러 하위 채널로 나누는 방법도 고려할 수 있다. 다만 같은 채널의 대화가 분할되므로 진짜 전역 공지가 아니라면 월드·언어·매칭 그룹 등 사용자가 이해할 수 있는 기준으로 분할하는 편이 낫다.

귓속말은 온라인 위치 조회가 핵심이다

귓속말은 채널 구독자 목록이 아니라 playerId -> 현재 접속 게이트웨이 조회가 필요하다. 이 정보는 빠르게 바뀌므로 영속 데이터베이스를 매 메시지마다 조회하면 비용과 지연이 커진다.

접속 위치 저장소에는 플레이어 ID, 세션 ID, 게이트웨이 ID, 세션 세대 번호, 만료 시간을 둔다. Redis 같은 인메모리 저장소를 사용할 수 있고 규모가 작다면 게이트웨이에서 발행한 접속 이벤트를 채팅 라우터가 메모리에 유지하는 방식도 가능하다.

struct Presence {
    GatewayId gatewayId;
    SessionId sessionId;
    uint64_t generation;
    uint64_t expiresAtMs;
};

void WhisperRouter::Send(uint64_t fromId, uint64_t toId, std::string_view text) {
    const auto target = presenceStore_.Find(toId);
    if (!target || target->expiresAtMs < Clock::NowMs()) {
        SendFailureToSender(fromId, "상대방이 접속 중이 아닙니다.");
        return;
    }

    gatewayPublisher_.Send(target->gatewayId, target->sessionId,
                           BuildWhisper(fromId, toId, text, target->generation));
}

세션 세대 번호는 재접속 경합을 줄이는 데 도움이 된다. 플레이어가 이전 게이트웨이에서 끊긴 직후 새 게이트웨이에 접속했다면 이전 세션을 대상으로 한 늦은 메시지가 새 세션으로 잘못 전달되지 않도록 게이트웨이가 세대 번호를 검증할 수 있다.

오프라인 귓속말을 지원할지 여부도 초기에 정해야 한다. 지원하지 않는다면 위치를 찾지 못했을 때 즉시 실패를 반환하면 된다. 지원한다면 채팅 로그와 별도로 수신 대기함을 영속 저장하고 로그인 시 읽음 처리와 보관 기간 정책을 구현해야 한다. 이는 단순 라우팅 기능보다 개인정보와 운영 정책의 비중이 커지는 기능이다.

권한 정보는 이벤트로 동기화한다

길드 채널, 파티 채널, GM 채널은 발신자와 수신자의 권한을 확인해야 한다. 채팅 서버가 게임 데이터베이스를 직접 조회하게 만들면 결합도가 높아지고 채팅이 몰릴 때 데이터베이스까지 압박할 수 있다.

대신 월드·파티·길드 서비스가 멤버십 변경 이벤트를 발행하고 채팅 서비스가 이를 소비해 캐시를 갱신한다. 예를 들어 파티 탈퇴 이벤트를 받은 뒤에는 해당 플레이어를 파티 채널 구독자 목록에서 제거한다. 이벤트는 중복 도착할 수 있으므로 가입, 탈퇴, 버전을 포함해 멱등하게 처리한다.

권한 변경 직후 한두 개 메시지가 경계 상태에서 전달될 수 있는지 정책도 명시해야 한다. 강한 즉시 차단이 필요한 제재나 차단 목록은 채팅 인그레스에서 최신 상태를 우선 확인하고 일반 파티 멤버십은 짧은 이벤트 지연을 허용하는 식으로 중요도에 따라 나누는 편이 현실적이다.

전달 보장과 순서의 범위를 좁혀라

모든 채팅에 전역 순서를 보장하려 하면 확장성이 크게 떨어진다. 보통 필요한 것은 같은 채널 안에서의 순서같은 발신자의 귓속말 순서 정도다.

채널 샤드가 메시지 ID를 발급하고 단일 처리 순서를 유지하면 채널 내부 순서를 만들 수 있다. 게이트웨이와 클라이언트는 마지막으로 받은 메시지 ID를 기록해 누락을 감지할 수 있다. 그러나 실시간 채팅에서 모든 메시지를 반드시 한 번만 전달하려는 집착은 재시도 중복, 지연, 복잡도를 키운다.

실무적으로는 다음 조합이 균형이 좋다.

  • 전송은 최소 한 번 전달될 수 있다고 가정한다.
  • 클라이언트는 messageId로 짧은 기간 중복을 제거한다.
  • 채널당 순서는 보장하되 서로 다른 채널 사이 순서는 보장하지 않는다.
  • 중요한 운영 공지는 별도 저장과 재조회 기능을 둔다.

저장은 전달 경로와 분리한다

채팅 로그는 신고 처리, 운영 감사, 최근 대화 복구에 유용하다. 하지만 로그 저장이 느리다고 실시간 전달까지 멈추면 안 된다. 전달 성공 후 로그 이벤트를 비동기로 적재하거나 내구성 있는 메시지 큐에 기록 요청을 넣고 별도 소비자가 저장하도록 분리한다.

로그에는 필요한 최소 정보만 저장한다. 원문, 발신자, 채널 또는 수신자, 시각, 메시지 ID, 제재 판정 정도가 기본이다. 개인정보 보관 기간, 접근 권한, 삭제 요청 처리 방식은 서비스 정책과 법적 요구사항에 맞게 별도로 정해야 한다.

장애 상황에서의 동작을 정한다

채널 샤드가 내려가면 그 샤드가 소유한 채널만 영향을 받도록 격리하는 것이 목표다. 샤드 목록은 서비스 디스커버리나 설정 저장소에서 관리하고 게이트웨이는 최신 소유자를 찾아 재시도한다. 소유권 이동 중에는 같은 채널에 두 리더가 생기지 않도록 임대 시간이나 펜싱 토큰을 사용한다.

접속 위치 저장소가 잠시 불안정할 때는 귓속말을 보류하지 말고 실패로 처리하는 편이 안전하다. 잘못된 세션으로 전달하는 것보다 사용자가 다시 보내게 하는 편이 낫다. 반대로 전체 채널은 샤드가 복구될 때까지 송신을 제한하고 클라이언트에 일시적 오류를 명확히 알려 준다.

관측 지표도 처음부터 넣어야 한다.

  • 채널별 초당 송신 수와 평균·최대 수신자 수
  • 샤드별 팬아웃 지연과 송신 큐 길이
  • 귓속말 위치 조회 성공률과 오프라인 실패율
  • 속도 제한, 금칙어 필터, 제재로 차단된 메시지 수
  • 게이트웨이별 활성 세션 수와 전송 실패율

구현 순서 제안

처음부터 복잡한 분산 시스템을 만들 필요는 없다. 단일 채팅 서비스에서 채널 상태를 메모리에 관리하고 게이트웨이를 통한 전달부터 검증한다. 이후 접속자가 늘어날 때 채널 ID 기반 샤딩과 접속 위치 저장소를 도입한다. 로그 적재와 신고 조회는 실시간 경로와 분리해 추가한다.

가장 중요한 설계 원칙은 전체 채널의 팬아웃과 귓속말의 위치 조회를 다른 문제로 다루는 것이다. 채널은 소유 샤드가 구독자 집합과 순서를 관리하고 귓속말은 최신 접속 위치를 조회해 단일 세션으로 라우팅한다. 이 경계를 분명히 해 두면 기능 추가와 증설, 장애 대응이 훨씬 단순해진다.

#게임 서버#채팅 서버#분산 시스템#C++#네트워크

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs