Hive는 GBaaS(Game Backend as a Service) 플랫폼으로서 로그인, 결제, 푸시 등의 기능을 제공하여 게임사가 게임 콘텐츠 개발에만 집중할 수 있는 환경을 지원한다. 우리 팀은 2024년 한 해 동안 리더보드, 매치메이킹, 리모트플레이 서비스를 연이어 오픈했으며, 12월에는 채팅 서비스까지 출시하여 Hive의 기능적 폭을 더욱 확장했다. 본 기사에서는 채팅 서버를 구축하며 체득한 내용을 공유하고, 개발 과정에서의 주요 고민들을 나눠보고자 한다.
채팅 서비스는 기본적으로 ‘실시간으로 메시지를 수신한다’는 조건을 충족해야 한다. 단순해 보이는 이 조건을 만족시키기 위해서는 다음과 같은 요소들이 갖춰져야 한다.
커넥션 관리
프로토콜 설계
라우팅
실시간 메시지 수신을 위해서는 클라이언트와 서버 간 네트워크 커넥션이 항상 열린 상태여야 한다. 여기서 ‘열려 있다’는 것은 양방향으로 데이터를 송수신할 수 있는 상태를 의미한다. 하지만 네트워크 상태는 언제든 변할 수 있기 때문에, 커넥션 상태를 효율적으로 관리하는 것이 중요하다.
예를 들어 커넥션이 비정상적으로 종료되었을 경우 이를 감지하여 사용자 접속 정보를 삭제해야 하며, 유휴 시간이 지나치게 긴 커넥션은 해제해 시스템 자원을 절약해야 한다.
이러한 커넥션 관리를 위해 우리는 Netty를 도입했다. Netty는 비동기 이벤트 기반 네트워크 프레임워크로 커넥션을 효과적으로 관리할 수 있도록 지원한다.
ServerChannelInitializer 클래스는 Netty 서버에서 클라이언트의 SocketChannel을 초기화하는 역할을 한다. 특히 HTTP 요청 처리부터 WebSocket 업그레이드, 그리고 이후의 실시간 통신까지 지원하기 위해 다양한 핸들러를 ChannelPipeline에 순차적으로 추가했다. 추가하는 순서대로 inbound, outbound 흐름에 적용되기 때문에 각 핸들러의 역할을 이해해야 한다. 예를 들어 IdleStateHandler를WebSocketServerProtocolHandler보다 뒤에 등록한다면 웹소켓 표준 ping / pong 을 이용한 유휴 커넥션 처리를 할 수 없다. WebSocketServerProtocolHandler에서 웹소켓 표준 ping / pong 처리를 하고 있기 때문이다.
이러한 초기화 과정은 WebSocket 기반의 실시간 통신 서버를 구축할 때 필수적인 절차이며, 마지막에 추가한 WebSocketRequestHandler를 통해 비즈니스 로직으로 흐름이 넘어가게 된다.
publicclassWebSocketRequestHandlerextendsSimpleChannelInboundHandler<WebSocketFrame> { ... @OverrideprotectedvoidchannelRead0(ChannelHandlerContextctx, WebSocketFrameframe) throwsException {Channelchannel = ctx.channel();if(frame instanceof TextWebSocketFrame) {TextWebSocketFrametextFrame = (TextWebSocketFrame) frame;ChatPakcetchatPacket = mapper.readValue(textFrame.text(), ChatPacket.class);// business layer 호출 } } @OverridepublicvoidchannelInactive(ChannelHandlerContextctx) throwsException {// 연결 해제 시 처리 로직 } @OverridepublicvoiduserEventTriggered(ChannelHandlerContextctx, Objectevt) throwsException {if (evt instanceof IdleStateEvent) {// 유휴 상태의 연결에 대한 처리 로직 }super.userEventTriggered(ctx, evt); }}
WebSocketRequestHandler 클래스는 클라이언트로부터 수신한 메시지를 처리하는 핵심 핸들러다. WebSocketFrame을 수신한 뒤, 해당 프레임 내 텍스트 메시지를 비즈니스 요청 객체로 변환하여 처리하는 역할을 수행한다. 또한, 커넥션이 끊겼을 때 이를 정리하는 로직과, 커넥션의 Idle 상태를 감지하여 타임아웃 처리를 수행하는 기능도 포함되어 있다.
WebSocketRequestHandler가 상속받은 SimpleChannelInboundHandler는 앞서 설명한 ChannelInboundHandler를 상속한 추상 클래스이며, ChannelInboundHandler의 모든 기능을 보다 간편하게 사용할 수 있도록 도와주는 구현체다. 이제 커맨드 구조를 살펴보겠다.
enum으로 정의한 PacketType의 모습이다. PacketType의 수가 늘어날수록 코드 내 분기 처리도 증가하게 되어 복잡도가 높아진다. 이에 따라 우리는 WebSocket을 실시간 메시지 송수신에만 활용하고, 그 외의 기능은 REST API를 통해 처리하는 방식으로 개발했다.
RESPONSE라는 접두어로 시작하는 패킷은 요청에 대한 응답임을 나타낸다. WebSocket은 비동기 통신 방식이기 때문에, 클라이언트는 요청을 보낸 후 결과를 즉시 확인할 수 없다. 따라서 요청과 응답을 구분하고 매칭하기 위해 응답 패킷에 RESPONSE 접두어를 붙여 요청의 결과임을 식별할 수 있도록 했다.
이처럼 LOW 레벨의 통신 프로토콜을 설계할 때는 메시지 포맷, 핸드셰이크 절차 등 다양한 요소들을 종합적으로 고려해야 한다.
채팅방에 참여한 사용자들에게 메시지를 전달하려면 어떻게 해야 할까?
예를 들어 A 사용자가 1번 서버, B 사용자가 3번 서버, C 사용자가 5번 서버에 접속한 상태에서 A 사용자가 채팅방에 메시지를 전송했다면, 채팅 서버는 각 사용자가 접속해 있는 서버를 찾아 해당 메시지를 전달해야 한다. 즉, 사용자의 커넥션이 유지되고 있는 서버를 식별하고, 그 위치에 맞춰 데이터를 전송할 수 있어야 한다.
많은 기술 블로그에서는 이 기능을 Redis Pub/Sub으로 구현하지만, 우리는 Akka를 활용하여 개발했다. Akka의 ActorSystem을 이용하면 특정 Actor가 어느 서버에 생성됐는지 알지 못해도 Actor 호출이 가능하다. 우리는 사용자 정보를 표현하는 UserActor를 정의하고, 채팅 메시지를 전송할 때 ActorSystem이 라우팅을 전담하는 구조를 설계했다.
사용자(UserActor)에게 데이터를 전송하는 개념을 나타낸 그림이다. ChatRoomActor는 채팅방에 참여한 사용자들에게 메시지를 전달하는 역할만을 수행한다. 각 UserActor는 서로 다른 서버에 위치할 수 있지만, ActorSystem에 등록된 식별자를 통해 위치와 관계없이 호출할 수 있다.
Akka의 Receptionist를 활용하면 ActorSystem에 ServiceKey(식별자)와 Actor를 등록할 수 있으며, 이후 해당 ServiceKey를 통해 등록된 Actor를 호출할 수 있다. 이를 응용하면 사용자가 접속한 서버 정보를 별도로 데이터 저장소에서 조회할 필요가 없어 로직이 단순해진다.
또한 Akka는 분산 처리, 확장성, 비동기 처리에 강점을 지니고 있어, 트래픽이 증가하더라도 일관된 설계 구조를 유지할 수 있다.
‘실시간으로 메시지를 수신한다.’ 이 단순한 문장을 만족시키기 위해 Socket 서버는 다양한 요소들을 갖추고 있어야 한다. 이처럼 겉으로 드러나지 않는 조건들이 유기적으로 맞물려야만 사용자는 자연스럽고 끊김 없는 메시지 경험을 누릴 수 있다.
다음으로는 백그라운드 처리를 전담하는 Worker 서버에 대해 이야기해보겠다.
채팅 메시지 외에도 비속어 필터링, 채팅 로그 저장 등 다양한 부가기능이 존재한다. 이러한 기능들을 빠르게 처리하기 위해 백그라운드 기능을 전담하는 별도의 Worker 서버를 구축했다. 그 중 비속어 필터링에 대해서 자세히 이야기해 보겠다.
실시간 채팅 서비스를 설계할 때 가장 먼저 고민하게 되는 요소 중 하나는 바로 ‘비속어 필터링’이다. 기능을 검토한 결과, 비속어 필터링은 단순한 기능을 넘어 하나의 독립된 서비스 수준의 범위를 갖고 있다는 결론에 이르렀다.
마침 사내에서 유사한 기능의 시스템을 이미 운영 중이었기에, 해당 시스템을 우선 도입 대상으로 검토했다. 다만, 해당 시스템은 실시간 환경에서 적용된 사례가 없어, 우리가 개발하는 채팅 서비스에 적합한지에 대한 충분한 검토가 필요했다.
여러 차례 논의와 담당자분들의 적극적인 협조를 통해, AI 개발팀의 ‘텍스트 어뷰징 탐지 시스템(이하 TADS)’을 연동하여 비속어 필터링을 적용하게 됐다. 이 자리를 빌려 적극적으로 지원해주신 AI 개발팀에 다시 한번 감사의 마음을 전해본다.
gRPC는 Google에서 개발한 고성능 원격 프로시저 호출(RPC) 프레임워크다. 바이너리 포맷인 Protocol Buffers(Protobuf)를 사용하기 때문에 데이터 직렬화 속도가 빠르고, 메시지 크기가 작아 네트워크 비용 절감에 유리하다. 또한 양방향 스트리밍을 지원하여 실시간 메시지 전송이나 대용량 데이터 처리에 최적화되어 있다.
이러한 gRPC의 특성은 우리가 지향하는 Async – Non Blocking 아키텍처와 잘 부합한다고 판단해, TADS와의 통신에도 gRPC를 적용했다. 이를 통해 지연 시간을 최소화하면서도 높은 응답성과 안정성을 확보할 수 있었다.
Armeria
Armeria는 Line에서 개발한 고성능 비동기 마이크로서비스 프레임워크다. gRPC를 포함한 다양한 통신 방식을 지원하며, Spring과의 연동도 자연스럽게 처리할 수 있는 장점을 갖고 있다.
우리는 gRPC 기반의 실시간 통신을 보다 유연하고 안정적으로 처리하기 위해 Armeria를 도입했다. Armeria는 gRPC 통신을 담당하고, 전반적인 구성과 의존성 관리는 Spring에 위임함으로써 Spring 생태계를 그대로 활용하면서도 Armeria의 장점을 살린 안정적인 gRPC 통신 구조를 구축할 수 있다.
이제 Armeria를 활용한 gRPC 클라이언트 설정에 대해 간단히 설명하고, Worker 서버에 대한 내용을 마무리하고자 한다.
Endpoint 설정
요청을 여러 서버에 분산할 수 있도록, 선택 전략과 요청 대상 서버의 URL 및 port를 그룹화해 관리할 수 있다.
외부 네트워크 통신에 이상이 발생했을 때, 더 이상 호출을 수행하지 않도록 하여 장애의 영향 범위를 줄이기 위한 설정이다. Builder를 통해 실패율, 최소 실패 횟수 등의 조건을 지정할 수 있으며, Rule을 통해 어떤 응답을 실패로 간주할지에 대한 기준도 설정할 수 있다.
// CircuitBreakerBuilderfinalFunction<String, CircuitBreaker> factory = key ->CircuitBreaker.builder() .failureRateThreshold(0.5) .minimumRequestThreshold(10) .build();// CircuitBreakerRule finalCircuitBreakerRulecbRule = CircuitBreakerRule.builder()// A failure if the response is 5xx. .onServerErrorStatus()// A failure if an Exception is raised. .onException()// A failure if the grpc-status is not 0 .onGrpcTrailers((ctx, tr) ->tr.getInt("grpc-status", -1) != 0) .thenFailure();
Retry 설정
오류 응답을 수신했을 때 응답 내용에 따라 자동으로 재시도할 수 있도록 하는 설정이다. Rule을 통해 어떤 응답에 대해 재시도를 수행할지 여부를 정의할 수 있으며 재시도 간의 지연 시간이나 최대 재시도 횟수 등의 규칙도 함께 설정할 수 있다.
그 외에도 Logging 설정, Metric 연동, 기본적인 Request/Response 설정 등도 손쉽게 구성할 수 있다. 이러한 설정들은 decorator를 통해 유연하게 적용할 수 있으며 아래와 같이 gRPC 클라이언트 설정을 마친 뒤에는 필요한 곳에서 의존성을 주입받아 gRPC를 사용할 수 있다.
채팅 서비스는 이미 경쟁이 치열하고 기능적, 기술적으로 성숙된 시장이다. 이러한 상황 속에서 ‘Hive 채팅’이 단순한 채팅 기능을 넘어서 ‘채팅’과 ‘메신저’의 경계에서 어떤 모습을 갖춰야 하는지 많은 고민을 할 수밖에 없었다.
우리는 이러한 고민을 추상적인 수준에만 그치는 게 아니라, 하나씩 구체화해 가며 ‘Hive 채팅’의 모습을 갖춰나가고 있다. 어느 하나 쉬운 내용이 없지만 개발자로서의 호기심과 도전의식을 불러일으키고 있어 즐겁게 개발하고 있다. 이러한 노력을 통해 많은 사용자들이 서로 연결되고 즐거운 소통의 순간이 만들어지기를 기대해 본다.
라문규, 김재현 기자
부족한 부분이 많지만, 이 글이 채팅 시스템을 구축하는 분들과 Hive 채팅에 관심이 있으신 분들께 작은 도움이 되기를 바랍니다.