모바일 게임 스케일링 최적화: 100만 명 이상의 동시 접속자를 위한 도시 아키텍처 설계
핵심 요약
이 가이드는 100만 명 이상의 동시 접속자를 수용하는 대규모 모바일 도시 환경을 구축하기 위한 핵심 최적화 전략을 다룹니다. 서버 측 Spatial Partitioning과 클라이언트의 정교한 메모리 스트리밍, 그리고 분산 Backend 아키텍처를 통한 부하 분산 방법을 상세히 설명합니다. 특히 실무에서 바로 적용 가능한 C# 및 C++ 코드 예제와 함께 Garbage Collection 관리 및 네트워크 대역폭 최적화 등 시니어 개발자 수준의 인사이트를 제공합니다.
모든 Multiplayer 개발자는 모바일 게임 아키텍처가 한계에 부딪혀 균열이 가는 정확한 순간을 알고 있습니다. 여러분은 거대하고 아름다운 도시 환경을 설계합니다. 10개의 시뮬레이션 클라이언트로 로컬 테스트를 진행하면 빌드는 완벽한 60 FPS로 구동됩니다. 하지만 1,000명의 동시 접속자가 중앙 광장으로 몰려드는 라이브 환경에 배포하는 순간, 불과 몇 초 만에 저사양 Android 기기들은 Out-Of-Memory (OOM) 예외로 크래시가 발생하고, iOS Jetsam은 애플리케이션을 강제 종료하며, Dedicated Server의 CPU는 수천 개의 중첩된 엔티티에 대한 네트워크 복제(Replication)를 계산하려다 100%까지 치솟게 됩니다.
수백만 명의 활성 사용자를 지원하도록 설계된 모바일 MMO나 대규모 오픈월드를 구축할 때는 엔진의 기본 설정(Out-of-the-box defaults)에만 의존할 수 없습니다. 모바일 하드웨어는 엄격한 Thermal Throttling과 강력한 메모리 캡(Memory Cap)을 가지고 있습니다(중급 기기에서는 게임에 할당되는 가용 RAM이 2GB 미만인 경우가 많습니다). 동시에 서버는 병목 현상 없이 밀집된 플레이어 클러스터를 처리해야 합니다.
진정한 모바일 게임 스케일링 최적화를 달성하려면 세 가지 핵심 기둥이 필요합니다. 서버에서의 공격적인 Spatial Partitioning, 클라이언트에서의 철저한 메모리 관리, 그리고 방대한 연결량을 처리하기 위한 분산 Backend 아키텍처가 그것입니다. 이 튜토리얼에서는 모바일 플랫폼을 위한 대규모 도시를 설계하는 방법을 단계별로 분석해 보겠습니다.
1단계: 서버 측 Spatial Partitioning
대규모 Multiplayer 게임에서 서버 성능의 근본적인 적은 O(N²) 문제입니다. 서버가 네트워크 업데이트가 필요한 대상을 결정하기 위해 모든 플레이어를 루프하며 다른 모든 플레이어와의 거리를 체크한다면, 계산량은 파멸적인 수준으로 늘어납니다. 100명의 플레이어는 틱당 10,000번의 거리 체크가 필요하지만, 1,000명의 플레이어는 1,000,000번의 체크가 필요합니다. 서버 틱 레이트가 30Hz라면 초당 3,000만 번의 체크가 발생하는 셈입니다.
이를 해결하기 위해 우리는 Spatial Hashing(또는 Grid/Quadtree 시스템)을 구현해야 합니다. 도시를 논리적인 그리드로 나눔으로써, 플레이어는 현재 자신이 속한 셀과 인접한 셀에 있는 엔티티들에 대해서만 네트워크 관련성을 체크하게 됩니다. 이는 O(N²)의 악몽을 O(1) 그리드 조회와 엄격하게 제한된 로컬 체크로 감소시킵니다.
Spatial Hash Grid 구현 (C# 예제)
다음은 Unity, Godot(C# 이용) 또는 커스텀 Backend 서버에서 전체 월드 상태를 루프하지 않고 엔티티 근접도를 관리하기 위해 활용할 수 있는 효율적인 2D Spatial Hash Grid 구현 예시입니다.
using System.Collections.Generic;
using UnityEngine;
public class SpatialHashGrid
{
private readonly float _cellSize;
private readonly Dictionary<Vector2Int, HashSet<uint>> _grid;
public SpatialHashGrid(float cellSize = 50f)
{
_cellSize = cellSize;
_grid = new Dictionary<Vector2Int, HashSet<uint>>();
}
// 월드 포지션을 그리드 좌표로 변환
private Vector2Int GetCellCoordinate(Vector3 position)
{
return new Vector2Int(
Mathf.FloorToInt(position.x / _cellSize),
Mathf.FloorToInt(position.z / _cellSize)
);
}
// 그리드 내 플레이어 위치 추가 또는 업데이트
public void UpdateEntityPosition(uint entityId, Vector3 oldPosition, Vector3 newPosition)
{
Vector2Int oldCell = GetCellCoordinate(oldPosition);
Vector2Int newCell = GetCellCoordinate(newPosition);
if (oldCell != newCell)
{
if (_grid.ContainsKey(oldCell))
{
_grid[oldCell].Remove(entityId);
}
if (!_grid.ContainsKey(newCell))
{
_grid[newCell] = new HashSet<uint>();
}
_grid[newCell].Add(entityId);
}
}
// 인접 구역(9개 셀) 내 모든 엔티티 조회
public List<uint> GetEntitiesInProximity(Vector3 position)
{
List<uint> nearbyEntities = new List<uint>();
Vector2Int centerCell = GetCellCoordinate(position);
// 플레이어 주변 3x3 그리드 루프
for (int x = -1; x <= 1; x++)
{
for (int y = -1; y <= 1; y++)
{
Vector2Int cellToCheck = new Vector2Int(centerCell.x + x, centerCell.y + y);
if (_grid.TryGetValue(cellToCheck, out HashSet<uint> entitiesInCell))
{
nearbyEntities.AddRange(entitiesInCell);
}
}
}
return nearbyEntities;
}
}
네트워크 복제 로직을 GetEntitiesInProximity를 통해 라우팅하면, 서버는 서로 가까이 있는 수십 명의 플레이어에 대해서만 정확한 거리를 계산하게 됩니다. 이를 통해 CPU 부하를 획기적으로 줄이고 서버가 동일 인스턴스에서 수천 명의 동시 접속자를 안정적으로 처리할 수 있게 됩니다.
2단계: Network Interest Management
Spatial Hashing으로 서버의 CPU 병목 현상을 해결하더라도 대역폭(Bandwidth) 문제는 여전히 남습니다. 모바일 네트워크(4G/5G)는 본질적으로 불안정하고 높은 지터(Jitter)가 발생하기 쉬우며 엄격한 대역폭 제한이 있습니다. 매 틱마다 주변 50명의 플레이어 데이터를 전송하면 모바일 클라이언트의 소켓 버퍼가 가득 차게 되어 극심한 데드레코닝(Desync) 현상이 발생합니다.
Interest Management(또는 Network Relevancy)는 네트워크를 통해 전송될 데이터의 우선순위를 정하는 기술입니다. 교전 중인 2미터 거리의 플레이어는 초당 30번의 업데이트가 필요하지만, 다른 거리에서 걷고 있는 40미터 밖의 플레이어는 초당 2번의 업데이트만으로도 충분합니다.
Network Relevancy 오버라이드 (Unreal Engine C++ 예제)
Unreal Engine에서는 IsNetRelevantFor 함수를 오버라이드하여 이를 제어할 수 있습니다. 이를 통해 시야(Line-of-sight)와 거리 계층을 기반으로 네트워크 트래픽을 공격적으로 컬링(Culling)할 수 있습니다.
bool ACityPlayerCharacter::IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget, const FVector& SrcLocation) const
{
// 1. 자기 자신에게는 항상 관련성이 있음
if (RealViewer == this || ViewTarget == this)
{
return true;
}
// 2. 거리 제곱 계산 (정확한 거리 계산보다 빠름)
const float DistanceSquared = FVector::DistSquared(SrcLocation, GetActorLocation());
// 3. 절대 컬링 거리 (예: 10,000 유닛 = 100미터)
const float MaxRelevancyDistSq = 100000000.0f;
if (DistanceSquared > MaxRelevancyDistSq)
{
return false;
}
// 4. 거리에 따른 동적 네트워크 업데이트 빈도 설정
// 거리가 멀어지면 데이터 전송 빈도를 낮춤
if (DistanceSquared > 25000000.0f) // 50미터
{
NetUpdateFrequency = 2.0f; // 초당 2회 업데이트
}
else
{
NetUpdateFrequency = 30.0f; // 초당 30회 업데이트
}
return Super::IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
}
거리에 따라 NetUpdateFrequency를 동적으로 스케일링하면 서버의 아웃바운드 대역폭을 70% 이상 절감할 수 있으며, 플레이어의 모바일 데이터 플랜을 보호하고 레이턴시 스파이크를 방지할 수 있습니다.
3단계: 클라이언트 측 메모리 제한 및 에셋 스트리밍
서버에는 충분한 RAM이 있지만 모바일 폰은 그렇지 않습니다. iPhone 13은 4GB의 통합 메모리를 가지고 있으며, iOS 운영체제는 일반적으로 그중 약 1.5GB에서 2GB를 점유합니다. 게임은 남은 2GB 공간 안에 완전히 들어가야 합니다. 대규모 도시 전체를 한 번에 메모리에 로드하면 OS는 즉시 애플리케이션을 종료할 것입니다.
이 환경에서 살아남으려면 도시는 청크(Chunk) 단위로 나뉘어 비동기적으로 스트리밍되어야 합니다.
- HLODs (Hierarchical Level of Detail): 멀리 떨어진 도시 블록의 50개 개별 건물(약 3,000번의 Draw Call 발생)을 렌더링하는 대신, 해당 도시 블록 전체를 통합된 텍스처 아틀라스를 가진 단일 Static Mesh로 구워야(Bake) 합니다. 이를 통해 원거리 지오메트리에 대한 Draw Call을 수천 개에서 정확히 한 개로 줄일 수 있습니다.
- Addressable Asset Systems: 기본 데이터 에셋에서 하드 레퍼런스(Hard Reference)를 사용하지 마십시오. 플레이어가 A 구역에서 스폰되면 클라이언트는 비동기 로딩(예: Unity의 Addressables 또는 Unreal의 PrimaryAssetLabels)을 사용하여 A 구역에 필요한 텍스처와 메시만 다운로드하거나 로드해야 합니다. B 구역은 RAM에서 철저히 제거되어야 합니다.
- Texture Compression: 모바일에서는 ASTC(Adaptive Scalable Texture Compression)를 전적으로 사용하십시오. 이는 매우 가변적인 블록 풋프린트를 허용하여 텍스처별로 메모리 대 시각적 품질을 세밀하게 제어할 수 있게 해줍니다.
4단계: 분산 Backend 아키텍처 및 서버 샤딩(Server Sharding)
거대한 메트로폴리스는 단일 물리 머신에서 실행될 수 없습니다. MMO 규모의 도시를 설계할 때는 월드를 여러 서버 인스턴스(샤드 또는 노드)로 물리적으로 나누어야 합니다. 플레이어가 다운타운 노드에서 슬럼 노드로 이어지는 다리를 건널 때, 클라이언트 연결과 월드 상태는 완전히 다른 두 서버 프로세스 간에 끊김 없이 핸드오프(Handoff)되어야 합니다.
이를 직접 구축하려면 Agones와 같은 시스템으로 오케스트레이션된 Kubernetes 클러스터를 설정하고, Redis를 이용한 데이터베이스 샤딩으로 서버 노드 간 플레이어 상태를 전달하며, 원활한 연결 핸드오프를 위한 커스텀 UDP Load Balancer를 구축해야 합니다. 플레이어가 전환 중에 아이템을 잃어버리지 않도록 이를 견고하게 설계하는 것은 시니어 엔지니어링 팀에게도 4~6개월의 전담 DevOps 작업이 필요한 방대한 과제입니다.
이러한 핸드오프 과정에서 RPC 큐와 데이터베이스 쓰기를 적절히 처리하지 못하면 필연적으로 상태 오염(State Corruption)이 발생합니다. 우리는 이전에 상태를 깨뜨리는 Unreal Engine RPC 복제 문제를 해결하는 방법에 대해 다룬 바 있으며, 동일한 원칙이 서버 노드 간 공간 핸드오프에도 직접 적용됩니다.
이 지점이 플랫폼 솔루션이 빛을 발하는 부분입니다. horizOn을 사용하면 이러한 고동시성 Backend 서비스, 실시간 데이터베이스 동기화, Dedicated Server 오케스트레이션이 미리 구성된 상태로 제공됩니다. Kubernetes 네트워크 규칙을 설계하고 디버깅하는 데 개발 예산을 소모하는 대신, 도시의 게임플레이 루프와 클라이언트 최적화에만 집중할 수 있습니다.
모바일 도시 월드빌딩 베스트 프랙티스
저사양 기기에서도 높은 프레임 레이트를 유지하면서 수백만 명의 총 사용자를 수용할 수 있도록 다음 아키텍처 규칙을 엄격히 준수하십시오.
- 공격적인 인스턴스 풀링(Instance Pooling): 게임플레이 중 차량, 보행자, 발사체와 같은 일시적인 객체에
Instantiate()나SpawnActor를 절대 사용하지 마십시오. 모바일 CPU는 메모리 할당과 Garbage Collection에 매우 취약합니다. 로딩 화면에서 오브젝트 풀을 미리 생성(Pre-warm)하고 지속적으로 순환시키십시오. - 도시 블록용 Texture Atlasing: Draw Call은 모바일 GPU(타일 기반 지연 렌더링 방식인 Tile-Based Deferred Rendering 사용)의 주된 성능 저하 요인입니다. 쓰레기통, 벤치, 가로등과 같은 모든 일반적인 거리 프롭의 텍스처를 하나의 큰 텍스처 아틀라스로 결합하십시오. 이를 통해 엔진은 수백 개의 프롭 렌더링을 단일 Draw Call로 배치(Batch) 처리할 수 있습니다.
- 청크당 엄격한 폴리곤 예산: 하드 리미트를 적용하십시오. 단일 모바일 도시 청크(예: 100x100미터 영역)는 가시적인 삼각형 수가 300,000개 미만으로 유지되어야 합니다. 건축적 디테일을 시뮬레이션할 때는 원본 지오메트리보다 노멀 맵(Normal Map)에 크게 의존하십시오.
- 서버 측 휴면 상태(Hibernation) 구현: 지도의 80%가 비어 있는 대규모 도시를 위해 Dedicated Server를 계속 구동하는 것은 스튜디오를 파산시키는 지름길입니다. Fortnite 서버 최적화 휴면 제안 분석에서 영감을 얻어, 유휴 상태인 그리드 좌표를 정지시키고 플레이어가 접근할 때 즉시 깨우는 공격적인 인스턴스 관리가 필요합니다.
- 콜리전과 비주얼 메시 분리: 서버 측 콜리전 계산에 복잡한 비주얼 메시를 절대 사용하지 마십시오. 서버는 도시를 일련의 저폴리곤 프리미티브 형태(박스, 캡슐, 구체)로만 인식해야 합니다. 이를 통해 서버 메모리 점유율을 최소화하고 물리 계산을 1밀리초 미만으로 유지할 수 있습니다.
피해야 할 일반적인 함정
- RPC 플러딩(Flooding)의 덫: 개발자들은 종종 차량 충돌 시 발생하는 스파크 같은 시각 효과를 위해 서버-클라이언트 RPC를 호출하곤 합니다. 이렇게 하지 마십시오. 서버는 차량의 상태(예:
bIsCrashed = true)만 복제해야 합니다. 클라이언트는 OnRep/Property Hook을 통해 이 상태 변화를 독립적으로 관찰하고 로컬에서 스파크 VFX를 트리거해야 합니다. 이는 엄청난 양의 네트워크 대역폭을 절약합니다. - 존 전환 시의 메모리 누수: 모바일에서 도시 청크를 스트리밍 아웃할 때, 반드시 Garbage Collection을 강제하거나 에셋 번들을 수동으로 언로드하고 있는지 확인하십시오. 플레이어가 구역 간을 이동할 때마다 몇 메가바이트의 고립된 텍스처라도 메모리에 남게 되면, 결국 20분 정도의 게임플레이 후에는 크래시가 발생하게 됩니다.
결론
진정한 모바일 게임 스케일링 최적화는 균형을 잡는 예술입니다. 클라이언트 RAM의 1MB를 확보하기 위해 싸워야 하며, 네트워크 관련성을 엄격히 규제하고, 확장 가능한 Backend 노드에 서버 부하를 분산해야 합니다. Spatial Hashing, 동적 업데이트 빈도, 비동기 에셋 스트리밍을 구현함으로써 오래된 모바일 하드웨어에서도 부드럽게 돌아가는 거대하고 생동감 넘치는 도시를 구축할 수 있습니다.
하지만 수천 명의 동시 접속자를 라우팅하고 원활한 서버 핸드오프를 관리하는 확장 가능한 인프라를 구축하는 것은 종종 게임 자체를 만드는 것보다 더 어렵습니다. DevOps의 악몽 없이 Multiplayer Backend를 확장할 준비가 되셨나요? horizOn을 무료로 체험해 보거나 API 문서를 통해 당사가 고동시성 아키텍처를 어떻게 기본적으로 처리하는지 확인해 보십시오.
출처: Designing Large-Scale Mobile Game Cities: Production, Optimization, & Worldbuilding Expertise