대규모 단일 통합 MMORPG 서버 아키텍처
Single Unified MMORPG Server Architecture
2009년, 한국인터넷진흥원은 자바/스프링 프레임워크를 기반으로 한 전자정부 표준프레임워크를 발표합니다. 이후 모든 공공기관의 소프트웨어는 이 표준프레임워크를 따라 개발되면서, 국내 IT 업계에서 백엔드 개발은 자연스럽게 자바/스프링 경험으로 모아지는 경향이 생겼습니다. 최근 마이크로서비스 아키텍처의 발전으로 기술 선택의 폭이 넓어지긴 했지만, 여전히 자바/스프링 프레임워크는 백엔드 개발의 대세로 자리 잡고 있습니다.
반면, 게임 업계의 상황은 조금 다릅니다. 90년대 초 ‘바람의나라’가 등장한 이래 30년 가까운 세월이 흘렀지만, MMORPG 서버 기술 스택은 IT 산업만큼 활발하게 공유되거나 발전하지 못했습니다. 저는 가장 큰 이유를 다음 요소로 뽑고 있습니다. 게임은 프로그래밍은 물론 디자인, 아트, 사운드, 기획 등 많은 분야의 전문가들이 적게는 수십명 많게는 수백명이 함께 협업하기 때문에 하나의 게임을 만들기 위해 막대한 자본이 투자됩니다. 이로 인해 새로운 대작 게임이 출시될 기회 자체가 적었고, 자연스럽게 혁신적인 서버 구조를 시도하고 기술을 성숙시킬 자양분도 부족했던 것으로 보입니다.
글을 작성하는 시점에서 최근에 출시한 대형 자본이 투입된 게임들 역시 유저를 여러 서버로 분산시키는 전통적인 방식을 따르고 있습니다. 유저 격차 해소와 랭킹 같은 희소한 자원의 분배 등 여러 기획적인 이유로 서버를 나누기도 하지만, 이 글에서는 순수하게 기술적인 관점에서 단일 통합 MMORPG 서버를 구현하기 위한 아키텍처를 제안해 보고자 합니다.
Disclaimer
제가 이 주제에 대해서 고민해보고 자료를 찾아봤는데요. 2019년에 동일한 주제로 발표해주신 박재완님의 ‘거대한 단일 MMORPG 서버[1]’를 참고했습니다. 해당 발표는 대략적으로 내용을 소개했다면 저는 조금 더 디테일을 넣어서 아키텍처까지 다루어 봤습니다.
참고로 저는 MMORPG 게임 서버에 참여해 본 적이 없습니다. 지금부터 하는 이야기는 순수하게 제 뇌피셜이며, 개인적 기술 선호에 따라 선택한 스택이기 때문에 정답이 아닙니다.
사진 출처 : 동아 사이언스
로켓 연구의 선구자인 로버트 고다드 박사는 인류 최초의 액체연료 로켓을 발사하고, 로켓의 기본 원리에 대한 연구를 진행했지만, 그가 살아있을 때 로켓이 실제로 우주까지 도달하는 것을 직접 보지는 못했습니다. 제가 경험도 없는데 이런 글을 쓰는게 어떻게 보일까 부담스러웠지만, 문제 해결을 위해 사고하고는 활동이 꼭 경험을 필요로 하는 건 아니라고 생각해서 용기내어 글을 적어봅니다.
INDEX
- 전통적인 MMORPG 게임 서버 구조
- 단일 통합 MMORPG 서버
- 프로토콜과 로드밸런서
- Microsoft Orleans : 로직 구현을 위한 프레임워크
- NoSQL 데이터베이스
- NATS : 서버간 통신 Pub/Sub 모델
- 데모 소개 및 마무리
전통적인 MMORPG 게임 서버 구조
게임 서버가 다른 서버와 구분되는 가장 큰 차이는 상태를 다루는 방식입니다. 흔히 HTTP 프로토콜로 만들어진 서버가 요청을 독립적으로 처리하는 비상태(Stateless) 환경으로 운영되는 것과 달리, 게임 서버는 유저의 연결을 유지하고 모든 정보를 실시간으로 처리하는 상태 기반(Stateful) 서버라는 점입니다.
게임 서버는 단위 시간 T 동안 N명의 유저로부터 M개의 액션을 입력받습니다. 서버의 방송 주기를 60Hz로 설정했다면, 약 16.67ms 주기로 로직 스레드에서 유저들의 액션을 처리하고, 그 결과 상태를 다시 클라이언트들에게 전송하게 됩니다. 이 방송 주기(헤르츠)는 게임의 장르나 반응성 요구 수준에 따라 다르게 설정할 수 있습니다. 게임 서버의 동기화 방식과 설계에 대한 더 자세한 내용은 아래 정리된 글을 참고하시면 좋습니다.
멀티-플레이어 게임 서버와 레이턴시 보상 테크닉
medium.com
① 메인 로직 스레드
게임 공간에서 한 유저가 수행하는 행위는 다른 유저에게도 관측 가능해야 합니다. 상호작용과 반응성을 보장하기 위해 같은 공간에 존재하는 유저들은 보통 동일한 서버에 접속하고 해당 서버가 주요 로직을 모두 처리한 뒤 처리 결과를 다시 클라이언트에게 전송합니다.
메이플스토리나 로스트아크처럼 보스 레이드가 핵심 콘텐츠인 게임 혹은 지역 이동 시 로딩이 존재하는 비심리스(non-seamless) 형태의 월드 구조를 가진 게임에서는 여러 전용 서버를 분리해 각 공간의 로직 처리를 분담하기도 합니다.
단순하고 자주 쓰이는 형태의 게임 서버 구조
서버의 유지보수와 생산성을 높이기 위해서 메인 로직은 싱글 스레드에서 다루는 경우가 많습니다. 반면 단일 서버의 성능을 높이기 위해서 로직 처리를 멀티-스레드 형태로 다루는 서버들도 있습니다. 하지만 개인적으로는 멀티-스레드를 통해서 성능을 높인다는 생각은 좋은 접근 방법이 아니라고 생각합니다
- ❶ 암달의 법칙[2]에 의해 아무리 병렬 처리 효율을 높인다고 해도 코어 개수에 따른 성능 증가율은 선형적으로 증가하지 않습니다.
- ❷ 현대 대부분의 서버는 NUMA 아키텍처를 사용하기 때문에 멀티-스레드를 사용해서 확장성과 성능을 높이고자 하는 방식은 너무 단순화된 모델입니다. 게임 플레이를 처리할 때 공유 메모리에 대한 접근이 빈번하기 때문에 멀티-스레드 전략이 단순한 싱글-스레드 전략보다 빠르다고 할 수 없습니다.
분산 시스템 분야에서 COST[3] (Configuration that Outperforms a Single Thread) 라는 개념이 있습니다. COST는 시스템의 확장성을 평가할 때 사용하는 단위로, 단일-코어로만 이루어진 시스템보다 Outperform 하려면 몇 개의 코어가 필요한지를 나타냅니다. 논문에서는 대부분의 병렬 시스템들이 단일 코어 성능을 넘기 위해서 100개 이상의 CPU가 필요했다고 주장합니다.
② Source of the Truth
온라인 게임 비즈니스에서 데이터베이스는 주로 백업의 역할을 하고 데이터의 진실 공급원은 서버의 메모리 상태가 됩니다. 게임 안에서 공격을 통해 다수의 몬스터의 체력을 변경시키고, 보상을 얻어서 인벤토리에 넣는 등 모든 상태 변화가 굉장히 빈번하게 일어납니다.
이런 모든 상태 변화마다 데이터베이스에 저장하고 싶어도 IOPS가 감당이 안 되기 때문에 메모리에서 일정 주기마다 캐시 레이어와 데이터베이스 레이어로 데이터를 보내는파이프라인을 구성합니다.
어떤 분은 서버 장애가 발생했을 때는 어떻게 하나요? 라고 걱정하실 분도 있을 텐데요. 장애가 발생하면 서버 로그를 기준으로 상태를 복원하거나, 복잡한 복원 프로세스를 만들지 않고 고객센터 대응으로 처리하기도 합니다.
많은 RPG 게임들은 특정 게임 서버와 전용 데이터베이스가 일정 수의 유저를 전담해서 처리하는 방식을 선호해왔습니다. 예를 들어, 서버 한 대가 최대 1만 명의 동시 접속자를 수용할 수 있는 상황에서, 출시 이후 예상되는 동시 접속자가 10만 명이라면, 서버 선택 화면에서 보이는 게임 서버와 그에 대응하는 데이터베이스 세트를 10개 세팅해서 물리적으로 격리하는 방식으로 서버를 관리합니다.
마비노기 모바일과 아이온2의 서버 선택 화면
왜 이런 구조로 운영이 가능할까요? 저는 두 가지 이유가 있다고 생각합니다. ❶ 게임이 생각보다 마이너한 취미입니다. 한국갤럽조사연구소가 2024년에 조사한 ‘한국인이 좋아하는 50가지 [문화편][4]’ 자료에 따르면 게임을 가장 많이하는 10대 남성은 44%이고, 이 비율은 나이가 들수록 줄어들며 30대에서는 22%, 40대에서는 14%로 감소했습니다. 여성의 경우에는 게임을 그나마 가장 많이 하는 10대에서는 9% 비율을 차지 했습니다.
❷ 게임 산업은 근본적으로 ‘승자 독식’ 구조가 아닙니다. 온라인 영상 공유 플랫폼 시장 1위 플랫폼인 유튜브는 월간 활성 사용자(MAU)가 약 25억 명에 이릅니다. 플랫폼 산업은 승자만 살아남아 시장의 대부분을 독점하는 경향이 강합니다. 반면, 게임 시장에서 각 유저들은 취향에 따라 선호하는 게임을 선택합니다. 게임 안에서도 AOS, FPS, 배틀로얄, 익스트랙션, 서브컬처 등 다양한 장르가 공존하며, 각 장르 내에서는 현재 탑급게임도 몇 년만 지나면 순위권에서 쫓겨날 정도로 유행이 빠르게 변하기도 하고, 시장 전체를 독식하는 타이틀도 없습니다.
따라서 게임 산업의 패러다임이 바뀌지 않는 한, MMORPG 서버가 수용해야 할 이용자 수의 규모는 예측 가능한 범위 안에 머물게 됩니다. 이로 인해 서버 개수를 수동적으로 늘리거나 통합하는 방식으로도 충분히 운영이 가능합니다. 만약 전 세계 모든 인구가 MMORPG 게임을 즐긴다면 이런 서버 운영 방식은 현실적으로나 비용적으로 바람직 하지 않을겁니다.
단일 통합 MMORPG 서버
레이턴시 때문에 글로벌 원서버는 힘들더라도 동일 국가 안에서는 단일 서버를 운영하면 서버 간 장벽으로 인해 생기는 시장 및 커뮤니티 단절을 줄일 수 있고, 서버가 달라서 같이 즐길 수 없거나, 인구가 적은 시골 서버라서 부족한 유동성 때문에 원하는 세팅을 할 수 없고 나아가 도전 컨텐츠를 즐길 수 없는 등 여러 불편을 해결할 수 있습니다. 저는 지금부터 이 단일 통합 서버를 만들기 위한 아키텍처를 제안해보고자 합니다.
지금부터 하는 이야기는 정답이 아니라
저의 기술 선호도가 많이 들어가 있습니다.
- 최대 동시접속자 수(CCU) : 현재 스팀에서 가장 많이 플레이하는 게임인 카운터 스트라이크의 최대 동접자 수는 대략 150만명입니다. 계산 편의를 위해 게임이 감당해야 하는 최대 동접자 수를 100만명으로 계산하겠습니다.
- 처리량 (QPS) : 유저는 초당 20회의 이동 및 전투 패킷을 전송한다고 가정했습니다. 링크된 자료는 데디케이트 서버 게임 기준의 값이고 MMORPG는 그보다는 적다고 가정해도 괜찮을 것 같습니다.
- 대역폭 수준 (Bandwidth) : 유저 한명의 대역폭은 upstream은 10~15 kbps, downstream은 50 ~ 100 kbps로 가정했습니다[5].
100만 CCU기준을 잡으면 서버는 초당 2000만 건의 요청(Request)을 처리할 수 있어야 하고 upstream 기준에서 필요한 대역폭은 최대 15Gbps, downstream의 경우에는 100Gbps로 생각해봅시다. 이는 단일서버로는 물리적으로 처리가 어렵기 때문에 수평 확장할 수 있는 구조가 필요합니다.
위 계산은 서울대학교에서 발표한 논문[5]인 “2D와 3D에 기반한 MMORPG 트래픽간의 특성 비교”에서 나온 값을 참고했습니다. 위 그림은 엔씨소프트의 유명한 MMORPG인 리니지 1과 리니지2의 upstream 패킷 크기과 downstream 패킷 크기를 실측한 데이터를 CDF로 나타낸 그래프입니다. (패킷 헤더는 제거한 값입니다)
아마 이 값으로 실제 계산해보시면, 위 계산보다는 작게 나오실텐데요. 저는 L4 레벨에서 네트워크 프로토콜을 처음부터 만드는 것 보다는 L7 레벨의 표준 프로토콜(e.g. gRPC)을 사용하는 것을 선호하기 때문에 패킷 크기를 높여서 계산했습니다.
가장 이상적인 모델
이상적인 세계에 존재하는 최고의 게임 서버는 단순히 데이터베이스와 게임 서버 1대씩만 운영하는데, 장애가 절대 발생하지 않으면서 동시접속자 100만명의 요청을 처리할 수 있는 CPU와 메모리, 네트워크 대역폭을 모두 갖춘 상태입니다. 코드의 복잡도가 매우 단순해져서 개발자는 온전히 비즈니스 로직에만 집중하면 됩니다. 하지만, 현실에서는 다양한 이유로 이런 이상적인 시스템은 존재하지 않습니다.
가장 이상적인 형태의 서버 아키테처
게이트웨이 서버와 로직 서버 분리
라이온하트 스튜디오는 GamesOnAWS 2022에서 브로드캐스팅을 전담하는 게이트웨이 서버와 로직을 처리하는 게임 서버를 분리하는 방법을 소개했습니다[6]. 많은 수의 유저 커넥션과 네트워크 I/O 비용이 비싸기 때문에 주요 로직을 처리하는 서버와 분리해서 효율성을 높인 부분이 좋아보였습니다.
MMORPG의 성능 최적화 사례 공유 ‘카카오게임즈 — 오딘: 발할라 라이징’
네트워크 I/O를 전담하는 게이트웨이 서버
이 전략을 사용해서 많은 유저와 커넥션을 연결하고 네트워크 I/O를 담당하는 게이트웨이 서버와 메인 로직을 담당하는 게임 서버를 분리합니다. 이제 N대의 게이트웨이 서버와 M개의 게임 서버가 존재합니다.
이렇게 네트워크 홉이 한 단계 늘어나는 것에 대해서 유저 반응성을 걱정하는 분들도 있을텐데요. 데이터센터내 노드간 왕복 레이턴시는 대략 500 마이크로초 정도로 알려져 있습니다[7].
채널링
특정 지역에 유저가 과도하게 밀집하면 서버의 연산 부하와 클라이언트의 렌더링 부하가 급증하여 원활한 플레이가 어려워집니다. 이를 해결하기 위해 일정 단위로 유저를 그룹화하여 분산 처리하는 과정이 필요한데, 많은 게임들이 하나의 논리적 서버 아래 여러 개의 채널 서버를 두어 로직을 분산하는 방식을 채택하고 있습니다.
대표 MMORPG 게임 메이플스토리의 채널링
쿠버네티스
쿠버네티스를 사용하면 확장성과 회복 탄력성, 비용 효율성을 모두 갖춘 서버 아키텍처를 편리하게 설계할 수 있습니다. 또한, 소규모 인원으로도 고도화된 모니터링 시스템과 CI/CD 파이프라인을 쉽게 구축할 수 있고 Agones, Karpenter, Istio 등 풍부한 생태계를 활용해 운영 효율을 높일 수 있습니다.
그리고 장시간 운영하다 보면 서버 비용 절감이나 여러가지 이유로 클라우드 제공사를 변경하거나 국내 IDC로 이전하게 될 수도 있는데요. 쿠버네티스와 그에 잘맞는 스택을 채용하면 벤더에 종속적이지 않고 이런 이벤트에도 유연하게 대처할 수 있습니다.
아마 온-프레미스 환경에서 베어메탈 서버로 MMORPG 서버를 개발한 경험이 많으신 분들이 쿠버네티스를 보면 생소하다고 느끼시는 분들도 있을 것 같고 한편으로는 걱정하시는 부분들도 있을텐데요. 가상화 계층이 늘어나는게 부담일 수 있지만, 높은 수준의 반응성을 요구하는 세션 기반 게임서버들도 쿠버네티스와 Agones를 사용해서 운영하는 경우가 많습니다.
대표적으로 전세계적인 인기를 누리고 있는 PUBG의 배틀그라운드와 최근 TGA에서 멀티플레이어 부분을 수상했고 높은 동접자 수를 유지하고 있는 아크레이더스가 쿠버네티스로 게임 서버를 운영하고 유저에게 인게임 플레이 경험을 제공하고 있습니다.
프로토콜과 로드밸런서
유저는 게임 서버에 들어오면 하나의 게이트웨이 서버를 할당받아서 패킷을 주고 받아야 합니다. 여기서는 프로토콜 이야기에서 출발해서 로드밸런서 사용에 대해서 다룹니다.
① 프로토콜
유저가 서버와 패킷을 주고 받으려면 프로토콜을 결정해야 하는데요. TCP와 UDP를 같이 쓰는 경우도 있고 TCP만 사용하는 경우도 있습니다.
TCP는 순서 보장을 위해 패킷 유실이 발생하면 재전송하게 됩니다. 이 때, 패킷 유실이 너무 심하면 HOL(Head of Line) blocking 문제로 인해 패킷 처리가 계속 늦어질 수 있습니다. 게임 캐릭터의 현재 위치를 보고하는 이동 패킷의 경우는 과거 정보보다 현재 정보가 더 중요하고 전송 주기가 빠르기 때문에 TCP의 순서제어 같은 매커니즘이 필요하진 않습니다. 실시간성이 강조되는 FPS, 레이싱, 액션 게임들은 대부분 UDP 프로토콜과 UDP에 TCP/IP의 기능을 조금 더한 RUDP를 같이 사용하곤 합니다.
반면 RPG 게임은 상대적으로 호흡이 길다고 생각합니다. 이 부분은 사람마다 주관적인 평가가 가능하기 때문에 다른 생각을 가지신 분들도 계실 것 같습니다. FPS에서는 0.1초의 차이가 생사를 가르기 때문에 즉각적인 반응이 필수적이지만 RPG는 스킬 쿨타임이 존재하고 카메라의 위치가 캐릭터로부터 거리가 좀 있기 때문에 약간의 오차와 레이턴시는 감내할 수 있다고 생각합니다. 대표적으로 엔씨소프트의 「리니지2」, 블리자드의 「월드 오브 워 크래프트」가 TCP/IP를 사용합니다[8].
월드 오브 워 크래프트의 한 장면
20년 정도 전인 2006년도 논문[8]은 MMORPG에서 TCP/IP 프로토콜이 안좋은 이유를 설명하는 논문입니다. 제 주관적인 생각은 TCP/IP로 운영하는 MMORPG 게임이 사례가 없는 것도 아니고, 네트워크 환경도 2026년 기준에서는 더 높은 대역폭과 전송량을 처리할 수 있기 때문에 gPRC (HTTP/2)도 좋다고 생각합니다. 근거는 업고 괜찮지 않을까 정도로 나이브하게 생각했습니다.
번외로 QUIC은 UDP이긴 하지만 본질적으로는 TCP/IP에서 제공하는 매커니즘을 대부분 구현한 프로토콜이라서 별개의 이야기로 생각하고 있습니다. QUIC Datagram을 사용하면 비신뢰 환경으로도 사용할 수 있습니다.
QUIC 프로토콜 | 구글 또 너야?
*QUIC 프로토콜은 2012년 구글이 처음 발표한 범용 전송 계층 네트워크 프로토콜이다. TCP/IP와 TLS레이어가 나누어져 있어서 불필요한 라운드 트립 딜레이(RTT)가 발생했는데, QUIC은 이 과정을 UDP…*medium.com
② 로드밸런서
유저는 게임에 접속하면 게이트웨이 서버 하나를 할당받아 커넥션을 긴 시간동안 유지하게 됩니다. 이 때, 어떤 게이트웨이 서버를 할당받을지 결정해야 하는데요. 로드밸런서를 게이트웨이 앞단에 붙여서 트래픽을 분배할 수 있습니다.
로드밸런서는 크게 L4과 L7로 구분되는데요. AWS에서는 NLB와 ALB라는 이름으로 나누어져 있습니다. L7는 패킷 암호화, 캐싱, 압축 및 라우팅등 다양한 기능을 지원하지만 그만큼 CPU를 더 많이 사용하기 때문에 레이턴시가 소폭 증가합니다.
L4 로드밸런서는 패킷 헤더만 보고 목적지로 보내주기 때문에 처리속도가 더 빠르고 TCP와 UDP 프로토콜 모두 이용할 수 있습니다. 하지만, 패킷 암호화가 필요하면 직접 구현해야 하는데요. 인증서 관리가 번거롭고 코드 복잡성이 높아집니다. 그리고 어차피 모든 패킷을 암호화 하겠다고 생각한다면 반응성은 떨어질 수 밖에 없습니다. 여기서는 L7 로드밸런서는 ALB로 결정하되 나중에 상황에 따라 바꿀 수 있다고 생각해둡시다.
NLB에선 stickiness을 활성화 하고, ALB에서는 gRPC의 streaming RPC을 사용하면 한 번 유저가 커넥션을 맺고 나면 모두 동일한 서버와 통신을 하게 됩니다.
Microsoft Orleans : 로직 구현을 위한 프레임워크
Orleans는 2014년 마이크로소프트 리서치에서 논문[9]으로 처음 발표한 분산 액터 모델 프레임워크입니다. 액터 모델은 Stateful한 연산을 자주 다루는 게임이란 도메인은 잘 맞는 부분이 있습니다. 프로그래머는 추상화된 기반 위에서 로직을 작성하기만 하면 되죠. .NET 기반으로 작성되어 있기 때문에 C#에 익숙한 개발자라면 쉽게 이용 할 수 있습니다.
앞서 COST라는 개념을 언급한 것 처럼 분산 액터 모델은 최고 성능을 내기 위한 최고의 방법은 아니지만, 높은 개발 생산성, 가용성, 확장성을 갖추고 있습니다. 서버 중 일부 장애가 나더라도 빠르게 복원 되며, 높은 트래픽을 요구사항에 대응하려면 단순히 서버의 개수를 늘리는 것으로 해결 됩니다.
앞으로 Orleans 프레임워크의 내부 동작을 이해하는데 필요한 2개의 논문을 다루는 추가 글을 작성할 예정입니다. 여기서는 Orleans에 대한 간단한 소개와 함께 왜 게임이란 도메인과 잘 어울리는지 예시와 함께 설명하겠습니다.
① 분산 액터 모델
액터 모델은 분산 환경에서 사용할 수 있는 객체 지향 프로그래밍(OOP)으로 받아들이는 게 이해하기 쉽습니다. 일반적으로 객체를 하나 생성하면 런타임이 메모리 어딘가에 객체를 저장하죠. 이 객체는 메모리 주소에 의해 메모리 공간이 특정되고 필요시 접근해서 값을 읽을 수 있습니다.
단일 노드만으로는 메모리와 한계로 100만명의 동시접속자를 표현하는 객체를 생성할 수 없습니다. N대의 컴퓨팅 노드를 Orleans 클러스터로 구성하면 객체를 생성할 때 클러스터 내 임의의 노드 중 하나에 객체가 생성됩니다. 개발자는 객체가 어디에 생성되었는지 알 필요없이 추상화된 레벨에서만 코드를 작성하면 됩니다. 이 객체를 ‘액터(Actor)’라고 부릅니다.
아래 그림은 클러스터 안에 3대의 노드와 다수의 Actor가 생성된 모습을 볼 수 있습니다. Orleans는 클러스터를 구성하는 하나의 노드를 Silo라고 부르고 Actor를 Grain이라고 부릅니다. 이제부터 용어 혼동을 방지하기 위해 저도 Grain으로 용어를 통일하겠습니다.
Grain은 객체의 ID와 도메인에 해당하는 behavior를 필수적으로 가져야 하고, 선택적으로 상태값을 가집니다. behavior는 Grain을 정의하는 클래스 이름으로 구분되는데요. 이 값이 동일하더라도 ID값이 다르면 서로 다른 객체로 존재합니다. 예를 들어 동일한 PlayerGrain으로 선언된 객체에 대해서 PlayerGrain("Alice")와 PlayerGrain(“Bob”)은 서로 다른 값입니다.
Actor Model하면 또 떠오르는 프레임워크 중에 Akka가 있습니다. 데브시스터즈가 개발한 쿠키런 킹덤이 Akka[10]로 개발되었습니다. 만약 Java/Kotlin/Scala 에 익숙하시다면 Akka도 좋은 선택지라고 생각합니다.
② 아이템 1:1 교환 예시
Orleans로 코딩하는 게 어떤 느낌인지 체감하기 위해 MMORPG의 핵심 기능중 하나인 1:1 아이템 교환 시스템을 설계하고 만들어보겠습니다. 거래는 두 명의 유저가 서로의 아이템을 올리고, 양쪽 모두 ‘확인(Confirm)’을 눌렀을 때만 성사되어야 합니다. 이 과정에서 아이템 복사나 증발 같은 치명적인 버그가 발생하면 안 되므로 데이터 무결성이 중요합니다.
Grain을 만들기 위해 먼저 두 개의 인터페이스부터 선언해봅니다. Grain은 목적지를 찾기 위한 ID가 존재합니다. IGrainWithStringKey 인터페이스를 상속받음으로써 ID를 String 값으로 사용하겠다고 선언합니다. 거래 세션은 고유 ID(e.g. UUID)를 가질 것입니다.
액터 모델끼리의 상호작용은 네트워크를 타고 이루어지기 때문에 전부 비동기 연산입니다. 그래서 리턴값은 반드시 Task여야 하고요. 값을 반환하고 싶다면 Task<T>를 선언하시면 됩니다.
// 플레이어 Grain 인터페이스
public interface IPlayerGrain : IGrainWithStringKey
{
// 아이템 추가
Task AddItem(int itemId, string itemName);
// 아이템 삭제
Task RemoveItem(int itemId);
// 거래 세션에 참여한다
Task JoinTrade(string tradeId, string partnerId);
// 거래를 그냥 종료한다.
Task LeaveTrade();
}
// 거래 세션 Grain 인터페이스
public interface ITradeGrain : IGrainWithStringKey
{
// 거래 세션 참여
Task Join(string playerId);
// 거래 세션 이탈
Task Leave(string playerId);
// 거래할 아이템 올리기
Task OfferItem(string playerId, int itemId, string itemName);
// 양쪽 다 확정 시 거래 실행
Task Confirm(string playerId);
}
아래는 구현 코드를 간소화해서 표현했습니다. Grain은 반드시 Grain 클래스를 상속받아야 합니다. 그리고 Grain 객체를 획득하려면 멤버 변수 GrainFactory의 GetGrain<TInterface>(ID) 함수를 사용하면 됩니다. OOP로 생각하면 new 키워드로 객체를 생성하는 행위와 같습니다.
Orleans 런타임은 객체의 활성화 및 비활성화를 직접 관리하는데요. 만약 생성된 객체가 없다면 객체를 생성하고 OnActivateAsync 함수를 호출합니다. 거래가 끝나고 일정 시간 동안 요청이 없으면 런타임이 메모리를 정리합니다. 즉, 개발자는 ‘객체를 언제 메모리에서 해제해야 하지?’를 고민할 필요가 없습니다.
// 플레이어를 나타내는 Actor
public class PlayerGrain : Grain, IPlayerGrain
{
private string currentTradeSession = string.Empty;
private readonly Dictionary<int, string> _inventory = new();
public Task AddItem(int itemId, string itemName)
{
_inventory[itemId] = itemName;
return Task.CompletedTask;
}
public Task RemoveItem(int itemId)
{
_inventory.Remove(itemId);
return Task.CompletedTask;
}
public async Task JoinTrade(string tradeId, string partnerId)
{
if (...) ... // 이미 거래 중인 건이 있다면 그냥 리턴 한다.
currentTradeSession = tradeId;
var tradeGrain = GrainFactory.GetGrain<ITradeGrain>(tradeId);
await tradeGrain.Join(this.GetPrimaryKeyString());
}
public async Task LeaveTrade()
{
// ... 생략
}
}
// 거래 로직을 담당하는 Actor
public class TradeGrain : Grain, ITradeGrain
{
// 각 플레이어가 내놓은 아이템 목록 (PlayerId -> {ItemId, ItemName})
private readonly Dictionary<string, Dictionary<int, string>> _offeredItems = new();
// 확정 버튼 누른 플레이어 목록
private readonly HashSet<string> _confirmedPlayers = new();
public Task Join(string playerId)
{
if (!_offeredItems.ContainsKey(playerId))
{
_offeredItems[playerId] = new Dictionary<int, string>();
}
return Task.CompletedTask;
}
public Task Leave(string playerId)
{
if (_confirmedPlayers.Remove(playerId))
{
_offeredItems[playerId].Clear();
}
return Task.CompletedTask;
}
public Task OfferItem(string playerId, int itemId, string itemName)
{
if (_confirmedPlayers.Contains(playerId))
{
throw new InvalidOperationException("확정 이후 아이템 추가 불가능");
}
if (_offeredItems.TryGetValue(playerId, out var items))
{
items[itemId] = itemName;
_confirmedPlayers.Clear(); // 아이템 변경 시 확정 취소
}
return Task.CompletedTask;
}
public async Task Confirm(string playerId)
{
_confirmedPlayers.Add(playerId);
// 둘 다 확정했다면 거래 실행
if (_offeredItems.Count == 2 && _confirmedPlayers.Count == 2)
{
await ExecuteTrade();
}
}
private async Task ExecuteTrade()
{
var players = _offeredItems.Keys.ToList();
var playerA_Id = players[0];
var playerB_Id = players[1];
var playerA = GrainFactory.GetGrain<IPlayerGrain>(playerA_Id);
var playerB = GrainFactory.GetGrain<IPlayerGrain>(playerB_Id);
var itemsFromA = _offeredItems[playerA_Id];
var itemsFromB = _offeredItems[playerB_Id];
// A의 아이템 처리 (A에서 삭제 -> B에 추가)
foreach (var item in itemsFromA)
{
await playerA.RemoveItem(item.Key);
await playerB.AddItem(item.Key, item.Value);
}
// B의 아이템 처리 (B에서 삭제 -> A에 추가)
foreach (var item in itemsFromB)
{
await playerB.RemoveItem(item.Key);
await playerA.AddItem(item.Key, item.Value);
}
}
}
이 과정에서 우리는 복잡한 DB 트랜잭션 격리 수준이나 락을 고민하지 않고, 마치 평범한 객체 지향 프로그래밍 하듯이 분산 서비스를 만들고 있습니다, 또 하나 주목해야 할 점은 TradeGrain은 Thread-safe하지 않은 Dictionary와 List 자료구조를 사용한다는 것입니다.
Grain의 모든 호출은 동일한 스레드에서 순차적으로 호출되는 게 보장되기 때문에 동시성 문제가 발생하지 않습니다[11]. 즉, AddItem과 Confirm 요청이 동시에 들어와도 Orleans는 이를 메시지 큐에 저장해서 줄 세운 뒤 하나씩 처리해 줍니다.
Silo가 하나인 단독 클러스터인 경우 모든 객체가 같은 호스트에 생성되기 때문에 함수 호출은 네트워크 스택을 타지 않아 직렬화 비용 없이 직접 실행됩니다. 만약 Silo가 많아지면 많아질수록 이 세 개의 Grain(플레이어 A, 플레이어 B, 거래 세션)은 서로 다른 호스트에 생성될 확률이 높아지면서 네트워크 호출을 발생시키는데요. 데이터센터 내 통신이기 때문에 레이턴시 손실이 크진 않지만, 여러 서버가 부하를 분담하고 있으며 노드의 대수를 추가함으로써 전체 처리량은 계속 증가합니다.
③ 예외 처리
네트워크는 다양한 이유로 실패할 수 있습니다. 목적지 시스템의 장애가 있거나, 네트워크가 혼잡할 수 있고, 상어가 해저 케이블을 물어뜯는 중일 수도 있죠. 그래서 분산 환경에서 네트워크 패킷이 어떤 형태로 도착할 것 이라는 가정은 최대 한 번(At most once) 또는 적어도 한 번 (At least once)밖에 없습니다.
- 최대 한 번 전송 (At most once delivery) : 수신자 입장에서 메시지가 도착하거나 도착하지 않을 수 있습니다.
- 적어도 한 번 전송 (At least once delivery) : 메시지를 최소 한 번 받습니다. 다만 2번 이상 받을 수도 있습니다.
상어가 해저 케이블을 물어뜯는 모습 : 출처 — 유튜브
Orleans를 비롯한 액터 모델은 최대 한 번(At most once)을 기본 전송 시맨틱으로 가지고 있습니다. 거래를 하는 중간에 하나의 노드에 장애가 발생하면 해당 호출은 Timeout Exception을 발생시키면서 최초로 전송한 쪽으로 예외를 전파시킵니다. Actor의 상태가 지금은 단순한 메모리이기 때문에 거래 확정 당시 네트워크 장애가 발생하면 데이터 무결성을 해치게 됩니다.
private async Task ExecuteTrade()
{
// ...
// A의 아이템 처리 (A에서 삭제 -> B에 추가)
foreach (var item in itemsFromA)
{
await playerA.RemoveItem(item.Key);
// 여기서 장애가 발생하면 A의 아이템만 사라진 셈이다.
await playerB.AddItem(item.Key, item.Value);
}
// ...
}
거래 중 통신 오류로 인해 데이터 무결성이 깨진 상황
분산환경에서도 데이터의 무결성을 지키는 몇 가지 방법이 알려져 있습니다. 대표적으로 2단계 커밋(2 Phase Commit), TCC(Try-confirm/Cancel), Saga Pattern이 있는데요. 무엇하나 직접 구현하려고 하면 굉장히 피곤해집니다. Orleans는 액터의 상태를 데이터베이스에 저장하는 저장소와 2단계 커밋으로 구현된 분산 트랜잭션을 내장된 기능으로 제공합니다.[12][13][14]
이를 사용하면 일부 장애 상황이 나타나면 트랜잭션에 연관된 모든 액터의 상태를 장애 이전으로 상황으로 안전하게 롤백시켜줍니다.
④ 영속성 레이어와 분산 트랜잭션 사용
Orleans에서 트랜잭션 기능을 사용하려면 먼저 저장소 부터 설정해야 합니다. RDBMS 계열은 유명한 것들은 제공하는데요. 여기서는 수평 확장이 가능한 키-밸류 기반 데이터베이스가 잘 어울린다고 생각해서 DynamoDB를 사용합니다. 데이터베이스 선택 이유는 뒤에서 한 번 더 설명하겠습니다. 저장소는 클러스터를 세팅할 때 설정에 추가해주면 됩니다.
siloBuilder.AddDynamoDBGrainStorage(
name: "Default",
configureOptions: options => { /* dynamodb 세팅 */ }
);
siloBuilder.UseTransaction();
이렇게 저장소를 선택했다면 어떤 값을 저장할지 클래스를 선언해야 합니다. 위에서 TradeGrain은 상태를 그냥 메모리에 적어두었는데요. 이제 이 값들을 상태 클래스로 바꿔 봅시다.
[GenerateSerializer]
public class TradeState
{
[Id(0)]
public Dictionary<string, Dictionary<int, string>> OfferedItems { get; set; } = new();
[Id(1)]
public HashSet<string> ConfirmedPlayers { get; set; } = new();
}
[Reentrant]
public class TradeGrain : Grain, ITradeGrain
{
private readonly ITransactionalState<TradeState> _state;
// 생성자 주입을 통해 TransactionalState를 받습니다.
// "trade_data"는 저장소에 저장될 때 사용되는 식별자 이름입니다.
public TradeGrain(
[TransactionalState("trade_data")] ITransactionalState<TradeState> state)
{
_state = state;
}
}
상태 클래스는 일반 Plain Object로 선언해서 다루면 됩니다. 클래스 위에는 GenerateSerializer 속성을 반드시 선언해야 합니다. 객체 데이터를 데이터베이스에 바이너리 형태로 직렬화해서 저장하기 때문에 Orleans에서 직렬화기를 자동으로 만들어주는 기능입니다.
그리고 Orleans는 .NET 프레임워크 기반이기 때문에 의존성 주입(DI) 기능을 기본적으로 사용하는데요. 의존성 주입을 통해 ITransactionalState<T> 객체를 받아서 멤버 변수에 저장해둡니다. TradeGrain위에 Reentrant 속성이 추가된 걸 알 수 있는데요. 트랜잭션 컨텍스트를 Grain 안쪽으로 전달하기 위해서 반드시 사용해야 합니다. 왜 이 속성이 필요한지는 다른 글에서 자세히 다루겠습니다.
다음은 상태를 읽거나 변경하는 모든 메서드에 Transaction속성을 달아서 이함수를 호출하면 분산 트랜잭션을 생성하거나 참여한다는 것을 선언합니다.
using Orleans;
public interface IPlayerGrain : IGrainWithStringKey
{
[Transaction(TransactionOption.CreateOrJoin)]
Task AddItem(int itemId, string itemName);
[Transaction(TransactionOption.CreateOrJoin)]
Task RemoveItem(int itemId);
[Transaction(TransactionOption.CreateOrJoin)]
Task JoinTrade(string tradeId);
}
public interface ITradeGrain : IGrainWithStringKey
{
[Transaction(TransactionOption.CreateOrJoin)]
Task Join(string playerId);
[Transaction(TransactionOption.CreateOrJoin)]
Task OfferItem(string playerId, int itemId, string itemName);
[Transaction(TransactionOption.CreateOrJoin)]
Task Confirm(string playerId);
}
이제 마지막입니다. 데이터를 읽거나 쓸때 PerformRead 또는 PerformUpdate 함수를 사용합니다. 해당 함수는 트랜잭션 처리에 필요한 컨텍스트 정보를 갱신함과 동시에 값을 변경하거나 읽습니다.
public async Task Confirm(string playerId)
{
bool isTradeReady = false;
await _state.PerformUpdate(state =>
{
state.ConfirmedPlayers.Add(playerId);
// 참여자 2명이 모두 확정했는지 확인
if (state.OfferedItems.Count == 2 && state.ConfirmedPlayers.Count == 2)
{
isTradeReady = true;
}
});
// 조건 충족시 거래 실행
// 이 호출은 모두 같은 트랜잭션 컨텍스트에 포함되어 실행됩니다.
if (isTradeReady)
{
await ExecuteTrade();
}
}
트랜잭션의 의미에 맞게 이 연산은 원자성을 보장합니다. 장애로 인한 실패나 혹은 논리적 오류로 인해 개발자가 인위적으로 Exception을 발생시키면 모든 연산을 이전 상태로 복원하는 것을 보장합니다. 또한 현재 트랜잭션에 참여중인 다 수의 Grain에 대한 상태 변경 연산을 안전하게 커밋함으로써 원자성과 무결성을 보장해줍니다.
트랜잭션이 취소되는 과정을 보여준다
게임 로직 서버는 분산 액터 모델인 Orleans 클러스터로 구성한다
NoSQL 데이터베이스
게임 로직 서버를 Orleans로 사용하면서 확장성을 챙겼습니다. 이제는 플레이어의 진행도를 안전하게 보관할 데이터베이스를 고민해야 합니다. 이 데이터베이스 분야를 크게 양분하면 관계형 데이터베이스 (RDBMS)와 비관계형 데이터베이스 (NoSQL)로 나눌 수 있습니다.
데이터베이스를 선택할 때는 각각의 장단점이 있고 어떤 것을 선택해도 구현할 수 있는 방법은 있어 보입니다. 디스코드[15]는 ScyllaDB라는 분산 키-밸류 데이터베이스를 사용하는 반면, 슬랙[16]은 MySQL의 분산 확장 버전인 Vitess를 채택했습니다. 두 회사 모두 성공적으로 채팅 애플리케이션을 구현했지만, 그 기술적 선택은 서로 달랐습니다.
다시 말해 NoSQL과 RDBMS 어떤 것을 선택해도 주어진 문제를 해결할 수 있는 방법은 있다고 생각하기 때문에 결국에는 취향 차이라고 정도라고 생각합니다만, 저는 여기서는 다음 두 가지 이유로 키-밸류 기반의 분산 데이터베이스를 추천합니다. 대표적인 데이터베이스로는 AWS DynamoDB, Azure Table, ScyllaDB가 있습니다.
① 관계형 vs 비관계형
RDBMS와 NoSQL의 가장 큰 차이점 중 하나는 데이터 모델의 표현 방식입니다. 관계형 데이터 모델에서는 데이터를 ‘테이블 단위의 스키마’로 표현하고 정규화를 거듭할 수록 ‘테이블과 테이블 사이의 관계성’이 생깁니다.
하지만 서버는 객체지향 프로그래밍 철학으로 개발하기 때문에 우리가 코드에서 주로 다루게 되는 데이터는 ‘객체’ 입니다. 따라서 테이블과 객체 사이에는 표현의 차이가 존재하기 때문에 RDBMS를 사용할 때 테이블과 객체 사이의 복잡한 전환 계층이 필요합니다.
이렇게 관계형 데이터베이스와 객체지향 프로그래밍의 개념적 차이로 인해 전환 비용이 발생하는 문제를 임피던스 불일치(impedence mismatch)라고 부르며 이게 생각보다 일할 때 체감되는 비용이 큽니다.
임피던스 불일치의 예시
반면 스키마를 제약하지 않는 비관계형(NoSQL)데이터베이스는 유연한 스키마를 제공하여 이러한 불일치를 줄일 수 있습니다. 객체를 JSON 혹은 다른 방식으로 직렬화 해서 데이터를 저장함으로써 더 자연스럽게 표현할 수 있고 코드의 간결성과 유지보수성을 향상시킬 수 있습니다.
객체 데이터는 지역성(locality)이 높아서 상대적인 성능 이점이 있습니다. 데이터가 여러 테이블로 나눠져 있는 관계형 DB에서는 전체 문서를 조회할 때 다수의 인덱스과 디스크 탐색이 필요하기 때문에 레이턴시가 증가하고 처리량이 상대적으로 낮아집니다.
게임의 경우 플레이어 본인의 정보를 조회하고, 본인의 정보만 편집하는 경우가 많고 요청이 매우 빈번하게 이루어지기 때문에 객체를 그대로 직렬화해서 저장할 수 있는 NoSQL이 더 높은 성능과 낮은 레이턴시를 가질 수 있습니다. 아래는 제가 즐겨하는 게임 중 하나의 인벤토리입니다.
RDBMS에서 인벤토리라는 테이블에 808개의 레코드를 기록할 건지, 아니면 인벤토리 객체 전체를 직렬화해서 NoSQL에 저장하는 차이를 상상하면 이해가 쉬울 것 같습니다.
본인의 비틱 디스크
② 확장성
오랜 시간 개발되고 많이 사용하는 MySQL, PostgreSQL, Oracle 등은 본래 단일 서버 환경 기준으로 개발된 제품입니다. 다중-서버를 운영하는 게임에서는 괜찮아도 단일 서버를 운영하는 게임에서는 높은 트래픽에 대응 할 수단이 많지 않습니다.
전통적인 RDBMS는 리더/팔로워 구조로 쓰기 노드는 단일 인스턴스로 제한되어 있고 읽기 성능에 대해서만 비동기 환경에서 확장성을 가집니다. 데이터를 생산하는 사람과 이를 소비하는 사람의 비율이 극명하게 차이나는 플랫폼 서비스는 RDBMS가 좋은 경우가 많습니다. (e.g. 뉴스라는 도메인을 생각하면 글을 생산하는 사람보다 읽는 사람이 훨씬 많습니다.)
반면, 게임이란 도메인은 쓰기 비율이 굉장히 높은 분야입니다. 사용자의 모든 행동이 플레이어 상태 변경을 필요로 하기 때문에 처리해야 하는 요청이 많은 상황이라면 단일 쓰기 인스턴스에 의존하는 전통적인 RDBMS는 아쉬울 수 있습니다.
Primary/Secondary 복제 모델
앞서 슬랙이 MySQL의 분산 확장 버전인 Vitess로 구현되어 있다고 소개한 바 있는데요. 슬랙이 Vitess를 선택한 이유는 도메인을 생각해 볼 필요가 있습니다. 워크스페이스와 멤버, 채널과 채팅은 1:N의 관계성을 가집니다. 만약 N:M의 관계를 주로 다루게 되면 거의 모든 노드에 걸쳐서 원자성을 보장하는 분산 트랜잭션이 필요하기 때문에 단일 데이터베이스보다 성능이 낮을 수 있습니다.
AWS DynamoDB는 키-밸류 기반의 완전 관리형 NoSQL 데이터베이스이며 예측 가능한 레이턴시와 테이블의 무제한 확장성을 제공합니다. 하나의 데이터는 아이템이라고 불리고, 아이템은 테이블에 저장됩니다.
Dynamo DB는 데이터를 파티셔닝 해서 저장하고 있습니다. 시스템은 자동으로 특정 파티션에 트래픽이 많이 몰려서 처리량이 특정 임계값을 넘어가면 이 파티션을 분할돼서 서로 다른 인스턴스에 할당됩니다. 내가 사용하는 데이터에 따라 작게는 2, 3대의 서버로부터 많게는 수천대의 서버로 데이터를 분산 저장합니다. 이런 특징 때문에 AWS 데이터센터가 감당할 수 있는 만큼 확장이 가능하며 일관된 레이턴시를 제공합니다.
RDBMS는 성능에 악영향을 미치는 원인이 많고 내부 시스템이 복잡하기 때문에 개발자가 자기가 작성하고 있는 코드가 운영 환경 에서도 잘 동작할 거라는 확신을 가지기 어려운데요. DynamoDB는 모든 요청에 대해 예측 가능한 수준의 레이턴시를 제공하기 때문에 개발자가 성능을 최적화 할 때, API 호출 횟수와 데이터 크기만 신경 쓰면 될 정도로 단순해집니다.
위에서 아이템 1:1 교환 예시에서 언급했듯이 Orleans 프레임워크는 Grain(=Actor)의 상태를 저장하는 동작을 추상화 시켰기 때문에 개발자는 데이터 저장소만 추가하면 시스템이 자동으로 필요한 순간에 DynamoDB API를 호출해서 데이터를 저장하고 불러옵니다.
게임서버에서 Actor의 상태를 직렬화 해서 DynamoDB에 저장한다.
DynamoDB와 Vitess를 포함한 분산 데이터베이스들은 1:N (One-To-Many) 관계의 데이터를 다룰 때는 높은 지역성을 가지기 때문에 효율적이지만, N:M (Many-To-Many) 관계의 쿼리가 필요할 때는 한 가지 문제점이 있습니다.
예를 들어, 특정 플레이어가 소유한 모든 아이템을 조회해줘 같은 연산은 파티션 키가 유저 ID인 경우 한 대의 인스턴스에만 질의하면 됩니다. 반면, 특정 아이템를 소유한 모든 플레이어를 조회해줘 같은 연산은 거의 모든 인스턴스에 질의를 하기 때문에 오히려 성능에 안좋은 영향을 끼칩니다.
Many-To-Many 관계를 다루는 경우가 빈번한 경우
따라서, 운영 목적으로 인해 OLAP 쿼리가 필요한 경우에는 이를 질의하기 편한 저장소가 필요한데요. 요즘에는 오브젝트 스토리지를 활용해서 비용을 절감하는 Clickhouse가 눈에 좀 들어왔습니다.
DynamoDB는 특정 기간 내 발생한 변경 사항만을 S3로 추출하는 증분 백업 기능을 제공합니다. 백업 작업은 운영 테이블의 성능에 영향을 미치지 않으며 RCU 또한 소모하지 않으므로, 약 15분 주기로 ClickHouse에 데이터를 지속적으로 동기화하여 실시간성 제약이 비교적 완화된 분석 쿼리를 수행합니다.
이 과정에서 Orleans는 Grain의 상태를 직렬화하여 바이너리 타입으로 저장하기에, 해당 데이터를 역직렬화하여 JSON 포맷으로 변환할 필요가 있는데요. Clickhouse의 UDF 기능을 활용하면 구현이 가능해 보였습니다.
추가로 DynamoDB에 대해 더 알고 싶으시다면 아래 글을 참고해주세요. AWS라는 특정 벤더에 종속되는 것이 부담스럽다면, DynamoDB API와 호환되는 ScyllaDB를 고려해보시는 것도 좋습니다.
DynamoDB의 시스템 디자인과 분산 트랜잭션 구현 원리
medium.com
NATS : 서버간 통신 Pub/Sub 모델
로직 처리는 Orleans 클러스터로 구성된 게임 서버에 의해 이루어집니다. 게임 서버는 로직 처리 결과를 주기적으로 게이트웨이 서버로 전달하고 게이트웨이 서버가 다시 이 정보를 유저에게 전달합니다.
이 때, 게임 서버에서 지금 발생시킨 정보를 어떤 게이트웨이 서버에게 전달해야하는지 라우팅 정보를 직접 관리하면 코드가 너무 복잡해지고, 그렇다고 모든 게이트웨이 서버에게 전달해버리면 게이트웨이 서버에 더해지는 부담이 너무 커집니다.
따라서 NATS 라는 경량 메시지 브로커 계층을 추가해서, 서로 원하는 토픽에만 메시지를 발행/구독 하는방식으로 복잡도를 낮추고자 했습니다. 예를 들어 심리스 오픈 월드를 구현한다고 합시다. 전체 지역을 쿼드 트리로 나눈 다음 자신의 AOI(Area Of Interests) 레벨에 따라 주변 필드 ID를 구한 다음 관심있는 필드의 변경 사항을 업데이트 받습니다.
심리스 오픈 월드 구현 예시
메시지 발행/구독을 지원하는 브로커 시스템하면 Redis Pub/Sub은 많이 들어보셨어도 NATS는 생소하신 분들이 많을 것 같습니다. Redis는 다양한 자료구조를 지원하는 범용 시스템이라면 NATS는 메시징을 위해서 최적화된 시스템 입니다. 한 때 65만 동시접속자를 처리했던 에픽게임즈의 폴 가이즈가 NATS를 서버 간 통신에 서비스 디스커버리 용도로 사용하고 있고[17], 국내에서는 토스 증권이 클라이언트 알림 용도로 NATS를 사용합니다[18].
SLASH’24의 발표에 따르면 NATS가 Redis Pub/Sub에 비해 높은 처리량을 가진다고 했는데요. Redis는 7버전부터 Sharded Pub/Sub을 지원하기 때문에 아마 벤치마크 자체는 다시 평가해봐야 할 것 같습니다.
게이트웨이 서버와 게임 서버 사이의 NATS 레이어가 추가됨
데모 소개 및 마무리
앞서 논의된 아키텍처를 바탕으로 간단한 데모 프로젝트(Github)[19]를 구현했습니다. Helm Chart에 NATS와 DynamoDB Local 버전을 포함해 필요한 인프라 구성 요소들을 선언했습니다. 게임 로직은 쿼드트리 기반의 공간 분할을 적용한 심리스 오픈 월드를 구성했습니다. 각 구역을 담당하는 IWorldGrain 은 지역 내 유저 위치 정보를 관리하며 100ms 주기로 최신 상태를 NATS 토픽에 발행(Publish) 합니다.
더미 클라이언트는 무작위 좌표에 스폰되어 한 방향으로 뛰어가도록 구현했고 100ms 마다 게이트웨이 서버 자신의 위치 패킷을 전송합니다. 첨부된 영상은 Minikube로 로컬 쿠버네티스 클러스터를 구동한 다음 게임 서버와 게이트웨이 서버는 1대씩 실행하고, 100개의 더미 클라이언트를 생성한 모습입니다. AOI관리에 따라 인접한 유저 정보는 동기화되고, 범위를 벗어난 유저 객체는 메모리에서 해제되는 모습을 볼 수 있습니다.
데모 게임 — Godot Engine을 사용했고 캐릭터 이동을 구현할 때 이 영상을 참고했습니다.
제가 경험한 것과 생각한 것을 바탕으로 내용을 전개했는데요. 숙련된 엔지니어분들의 다양한 관점이나 최적화 제안은 언제나 환영하며, 댓글로 의견 주시면 살펴보도록 하겠습니다. 긴 글 함께 해주셔서 감사드리며, 2026년 새해 좋은 일만 있으시길 바라겠습니다. ☺️
레퍼런스
- [1] 거대한 단일 MMORPG 서버
- [2] Amdahl’s law — Wikipedia
- [3] Scalability! But at what COST? @ USENIX HOT’OS 2015
- [4] 한국인이 좋아하는 50가지 [그 밖의 것들]
- [5] 2D와 3D에 기반한 MMORPG 트래픽간의 특성 비교
- [6] MMORPG의 성능 최적화 사례 공유 ‘카카오게임즈 — 오딘: 발할라 라이징’
- [7] Latency Numbers Every Programmer Should Know
- [8] An empirical evaluation of TCP performance in online games
- [9] Orleans: Distributed Virtual Actors for Programmability and Scalability
- [10] NDC21 쿠키런: 킹덤 | 서버 아키텍처 뜯어먹기!
- [11] Just how does a grain process a message? - Ledjon Behluli
- [12] Transactions for Distributed Actors in the Cloud
- [13] Cloud Actor-Oriented Database Transactions in Orleans VLDB’24
- [14] Resurrecting Middle-Tier Distributed Transactions
- [15] How Discord Stores Trillions of Messages
- [16] Scaling Datastores at Slack with Vitess
- [17] Lessons Learned from Server Scaling During the Launch of ‘Fall Guys’
- [18] SLASH’24 | SSE 이벤트 푸쉬로 불필요한 Polling 제거하기
- [19] Github | seamless-world-server-with-orleans