

데디케이티드 서버 아키텍처 기반의 오픈월드 MMORPG 프로젝트입니다.
서버 권한 모델, 프로그래밍 패턴, 프레임워크 최적화, 클라우드 호스팅, DB 등 MMORPG에 요구되는 전반적인 시스템을 설계·구현했습니다.

클라이언트와 서버를 완전 분리하여, 서버는 Headless 전용 빌드로만 실행됩니다.
서버 기동 시 MariaDB에 우선 접속하고, 접속 성공 시에만 Mirror Network Server를 기동합니다.
캐릭터 정보, 데미지 판정, 몬스터 스포닝 등 모든 게임 데이터는 서버에서 중앙 처리하며, 클라이언트는 입력 전송과 결과 수신만 담당합니다.
클라이언트 권한을 최소화하고, 모든 데이터와 판정을 서버에서 중앙 집중 관리하는 구조를 설계했습니다.네트워크 메시지, Command, RPC를 활용하여 서버-클라이언트 간 역할을 명확히 분리했습니다.

//캐릭터 생성 코드 조각
#region <Client-side Handling>
private void CreateCharacterRequest(string username, string characterName, Faction faction, Class characterClass, Gender gender)
{
CreateCharacterRequestMessage createCharacterRequestMessage = new CreateCharacterRequestMessage
{
Username = username,
CharacterName = characterName,
Faction = faction,
CharacterClass = characterClass,
Gender = gender,
MapCode = LOAD_DEFAULT_MAP_CODE,
};
NetworkClient.ReplaceHandler<CreateCharacterResponseMessage>(OnCreateCharacterResultReceived);
NetworkClient.Send(createCharacterRequestMessage);
}
private void OnCreateCharacterResultReceived(CreateCharacterResponseMessage msg)
{
switch (msg.Result)
{
case CreateCharacterResult.SUCCESS:
_view.CreateCharacterSuccess();
Singleton.UI.OpenAlert("Success", "Character creation is complete.");
LoadCharacterInfo();
_view.OpenPanel("GameStart");
break;
case CreateCharacterResult.DUPLICATE:
Singleton.UI.OpenAlert("Failed", "This character name already exists.");
break;
case CreateCharacterResult.ERROR:
default:
Singleton.UI.OpenAlert("Failed", "An error has occurred. (Error Code 104)");
break;
}
}
#endregion
#region <Server-side Handling>
private void OnCreateCharacterRequest(NetworkConnectionToClient conn, CreateCharacterRequestMessage msg)
{
CreateCharacterResult result = Singleton.DB.CreateCharacter(
msg.Username,
msg.CharacterName,
msg.Faction,
msg.CharacterClass,
msg.Gender,
msg.MapCode);
CreateCharacterResponseMessage response = new CreateCharacterResponseMessage
{
Result = result
};
conn.Send(response);
}
#endregion
전투·몬스터 처리 등의 핵심 판정은 서버에서 처리되도록 구현했지만, 플레이어 데이터는 처음 로드시 서버에서 받아오고 이후 업데이트가 클라이언트에서 이뤄지고 있습니다. 데이터의 위변조 방지를 위해 중요한 플레이어 데이터는 서버에서 처리돼야 한다는 점을 깨닫게 되었습니다.

플레이어의 위치를 기반으로 동적으로 청크를 로드/ 언로드하는 오픈월드 시스템입니다.각 청크는 별도의 씬으로 관리되며, 플레이어가 이동할 때마다 주변 청크를 자동으로 로드하고 멀리 떨어진 청크는 언로드하여 메모리 효율성을 높입니다.
#region <Chunk Loading>
public void RequireUpdateChunk(Vector3 playerPosition)
{
UpdateChunks(playerPosition).Forget();
}
private async Awaitable UpdateChunks(Vector3 playerPosition)
{
Vector2Int currentChunkCoord = GetChunkCoord(playerPosition);
List<Vector2Int> chunksToLoad = new List<Vector2Int>();
for (int x = -(Singleton.Game.LoadRange); x <= Singleton.Game.LoadRange; x++)
{
for (int z = -(Singleton.Game.LoadRange); z <= Singleton.Game.LoadRange; z++)
{
Vector2Int coord = new Vector2Int(currentChunkCoord.x + x, currentChunkCoord.y + z);
chunksToLoad.Add(coord);
}
}
foreach (var coord in chunksToLoad)
{
if (!chunkStates.ContainsKey(coord) ||
chunkStates[coord] == ChunkLoadState.NONE)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
bool existsInList = chunkList.chunkSceneNames.Any(ci => ci.sceneName == sceneName);
if (!existsInList)
{
chunkStates[coord] = ChunkLoadState.LOADED;
continue;
}
await LoadChunk(sceneName, coord);
}
}
var loadedCoords = new List<Vector2Int>(chunkStates.Keys);
foreach (var coord in loadedCoords)
{
if (!chunksToLoad.Contains(coord))
{
if (chunkStates[coord] == ChunkLoadState.LOADED)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
await UnloadChunk(sceneName, coord);
}
}
}
if (NetworkClient.localPlayer != null)
{
Scene inGameScene = SceneManager.GetSceneByName("InGame");
if (inGameScene.isLoaded)
{
SceneManager.MoveGameObjectToScene(NetworkClient.localPlayer.gameObject, inGameScene);
}
}
string activeSceneName = $"Chunk_{currentChunkCoord.x}_{currentChunkCoord.y}";
SceneManager.SetActiveScene(SceneManager.GetSceneByName(activeSceneName));
var chunkInfo = chunkList.GetChunkInfo(activeSceneName);
if (chunkInfo != null && chunkInfo.bgm != null)
{
PlayBGM(chunkInfo.bgm).Forget();
}
}
private async Awaitable LoadChunk(string sceneName, Vector2Int coord)
{
chunkStates[coord] = ChunkLoadState.LOADING;
var loadOp = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
while (!loadOp.isDone) { await Awaitable.NextFrameAsync(); }
chunkStates[coord] = ChunkLoadState.LOADED;
}
private async Awaitable UnloadChunk(string sceneName, Vector2Int coord)
{
chunkStates[coord] = ChunkLoadState.UNLOADING;
if (true == SceneManager.GetSceneByName(sceneName).isLoaded)
{
var unloadOp = SceneManager.UnloadSceneAsync(sceneName);
while (unloadOp != null && !unloadOp.isDone) { await Awaitable.NextFrameAsync(); }
}
chunkStates[coord] = ChunkLoadState.NONE;
}
public Vector2Int GetChunkCoord(Vector3 position)
{
int x = Mathf.FloorToInt(position.x / Singleton.Game.ChunkSize);
int z = Mathf.FloorToInt(position.z / Singleton.Game.ChunkSize);
return new Vector2Int(x, z);
}
#endregion
#region <Return Title (Chunk Unload)>
public void ReturnTitle() { ReturnTitleCoroutine().Forget(); }
private async Awaitable ReturnTitleCoroutine()
{
StopBGM().Forget();
string inGameScene = "InGame";
if (SceneManager.GetSceneByName(inGameScene).isLoaded)
{
AsyncOperation inGameUnloadOperation = SceneManager.UnloadSceneAsync(inGameScene);
while (!inGameUnloadOperation.isDone) { await Awaitable.NextFrameAsync(); }
}
List<Scene> chunkScenes = new List<Scene>();
for (int i = 0; i < SceneManager.sceneCount; i++)
{
Scene loadedScene = SceneManager.GetSceneAt(i);
if (loadedScene.name.StartsWith("Chunk_")) chunkScenes.Add(loadedScene);
}
foreach (var chunkScene in chunkScenes)
{
if (chunkScene.isLoaded)
{
AsyncOperation unloadOp = SceneManager.UnloadSceneAsync(chunkScene);
while (!unloadOp.isDone) { await Awaitable.NextFrameAsync(); }
}
}
string titleScene = "Title";
AsyncOperation titleLoadOperation = SceneManager.LoadSceneAsync(titleScene, LoadSceneMode.Additive);
while (!titleLoadOperation.isDone) { await Awaitable.NextFrameAsync(); }
SceneManager.SetActiveScene(SceneManager.GetSceneByName(titleScene));
chunkStates.Clear();
Singleton.UI.CloseAlert();
FindAnyObjectByType<TitleUIController>().ReturnBackTitle();
}
#endregion
#region <BGM(청크별 BGM 관리)>
public async Awaitable PlayBGM(AudioClip bgmClip, float fadeDuration = 1.0f)
{
if (bgmClip == null) return;
if (_currentBGMName == bgmClip.name) return;
_currentBGMName = bgmClip.name;
if (_audioSource.isPlaying) { await FadeOut(fadeDuration); }
_audioSource.clip = bgmClip;
_audioSource.Play();
await FadeIn(fadeDuration);
}
public async Awaitable StopBGM(float fadeDuration = 1.0f)
{
if (_audioSource.isPlaying) { await FadeOut(fadeDuration); _audioSource.Stop(); _currentBGMName = null; }
}
private async Awaitable FadeOut(float duration) { /* ... */ }
private async Awaitable FadeIn(float duration) { /* ... */ }
#endregion
스킬 데이터를 ScriptableObject로 관리하고, 게임 시작시 Resources 폴더에서 스킬데이터를 가져와 캐싱합니다.
스킬 데이터에서 스킬을 사용할 수 있는 클래스와 스킬 사용시 실행될 이펙트 및 스크립트 등을 지정할 수 있습니다.
[CreateAssetMenu(fileName = "NewSkill", menuName = "NOLDA/Skill System/SkillData", order = 1)]
public class SkillData : ScriptableObject
{
public int skillID;
public string skillName;
public string description;
public Sprite icon;
public Class classType;
public SkillType skillType;
public GameObject skillEffectPrefab; // 이펙트 프리팹
public float cooldownTime; // 쿨타임
public KeyCode defaultKey;
public float hitRadius = 2f; // 공격 범위
public int baseDamage = 10; // 기본 데미지
[Header("스킬 사용시 실행될 스크립트")]
public SkillExecuter skillExecuter; // 스킬 스크립트
// 스킬 레벨별 정보
public List<SkillLevelData> levelData;
// 현재 레벨의 정보를 가져옴
public SkillLevelData GetSkillLevelData(int level)
{
return levelData.Find(l => l.level == level);
}
}
[System.Serializable]
public class SkillLevelData
{
public int level; // 해당 레벨
public int spCost; // 해당 레벨에서의 SP 소모량
public int requiredLevel; // 스킬을 배우기 위한 요구 레벨
public float effectMultiplier; // 효과 배율
[Header("패시브일 경우")]
public int maxHpIncrease; // MaxHP 증가량
public int maxMpIncrease; // MaxMP 증가량
public int armorIncrease; // 방어력 증가량
public int attackIncrease; // 공격력 증가량
}
스킬은 모든 플레이어에게 보여야하기 때문에, 네트워크 구조를 거칩니다.
스킬 사용시 Command로 서버에 호출하고, 결과와 연출이 ClientRpc로 모든 클라이언트에게 동기화됩니다.
[Command]
private void CmdUseSkillOnTargets(int skillID, NetworkIdentity[] targets, float damage)
{
if (targets == null || targets.Length == 0) return;
foreach (NetworkIdentity target in targets)
{
if (target != null && target.TryGetComponent(out Enemy enemy))
{
enemy.TakeDamage(damage);
}
}
// 첫 번째 타겟 위치에 이펙트 재생
if (targets.Length > 0 && targets[0] != null)
{
RpcPlaySkillEffects(skillID, targets[0].transform.position);
}
}
[Command]
private void CmdRequestPlaySkillEffects(int skillID, Vector3 targetPosition)
{
RpcPlaySkillEffects(skillID, targetPosition);
}
[ClientRpc]
private void RpcPlaySkillEffects(int skillID, Vector3 targetPosition)
{
if (!isLocalPlayer)
{
PlaySkillEffectByID(skillID, targetPosition);
}
}
NPC 데이터를 ScriptableObject로 관리하고, 필드에 배치된 NPC 오브젝트에 할당하여 사용합니다.
[CreateAssetMenu(fileName = "NPC_0", menuName = "NOLDA/NPC/NPC Data")]
public class NPCData : ScriptableObject
{
public string npcName;
public string npcRole;
[TextArea(3, 5)] public string[] dialogueLines;
}

몬스터 에이전트 구현에서는 Behavior Tree를 활용하였습니다.
Behavior Tree에서 몬스터의 동작을 결정하는 트리 구조를 만들어 몬스터의 동작을 구현했고, 각 액션은 커스텀 액션을 사용합니다.
//몬스터 공격 Action Node
[Serializable, GeneratePropertyBag]
[NodeDescription(name: "Attack", story: "[Self] Attack [Target] on [Damage]", category: "Action", id: "b6b7047b7ad3aae775666cf350954980")]
public partial class AttackAction : Action
{
[SerializeReference] public BlackboardVariable<GameObject> Self;
[SerializeReference] public BlackboardVariable<GameObject> Target;
[SerializeReference] public BlackboardVariable<int> Damage;
protected override Status OnStart()
{
if (Target.Value == null)
{
return Status.Failure;
}
if (Target.Value.TryGetComponent<PlayerEnemyHandler>(out var playerEnemyHandler))
{
playerEnemyHandler.TakeDamage(Damage.Value);
}
return Status.Success;
}
}


몬스터를 처치하면 몬스터별로 설정돼있는 일정 확률의 아이템이 드롭되어, 몬스터를 처치한 플레이어의 인벤토리로 귀속됩니다.
몬스터 관련한 모든 로직은 서버에서 처리됩니다.
//몬스터 관련 처리 로직 일부
[Server]
public void TakeDamage(float damage, PlayerEnemyHandler attacker)
{
if (isDead)
{
return;
}
lastAttacker = attacker;
currentHp -= damage;
if (currentHp <= 0)
{
currentHp = 0;
Die();
}
AttackMySelf(currentHp);
}
[ClientRpc]
private void AttackMySelf(float newHp)
{
OnAttacked?.Invoke(newHp);
}
[Server]
private async void Die()
{
isDead = true;
behaviorGraphAgent.SetVariableValue("currentState", EnemyState.Die);
PlayDeathAnimation();
GiveRewardToLastAttacker();
await AwaitDie();
}
[Server]
private void GiveRewardToLastAttacker()
{
lastAttacker.GainEnemyReward(enemyData);
}


몬스터를 처치하여 얻은 장비를 장착하고, 장착한 아이템의 능력치에 따라 플레이어의 능력치가 상승합니다.
//장비 관련 처리를 위한 플레이어 데이터 일부
public WeaponItem EquippedWeapon { get; private set; }
public ArmorItem EquippedArmor { get; private set; }
public int EquipmentAttack => EquippedWeapon?.WeaponData.Damage ?? 0;
public int EquipmentArmor => EquippedArmor?.ArmorData.Defence ?? 0;
public void ToggleEquipment(EquipmentItem equipmentItem)
{
switch (equipmentItem)
{
case WeaponItem weaponItem:
EquippedWeapon = EquippedWeapon == weaponItem ? null : weaponItem;
EquippedWeaponPosition = EquippedWeapon == null ? -1 : Array.IndexOf(Items, EquippedWeapon);
break;
case ArmorItem armorItem:
EquippedArmor = EquippedArmor == armorItem ? null : armorItem;
EquippedArmorPosition = EquippedArmor == null ? -1 : Array.IndexOf(Items, EquippedArmor);
break;
}
OnPassiveSkillsApplied?.Invoke();
}
public void UnequipEquipment(EquipmentItem equipmentItem)
{
if (EquippedWeapon == equipmentItem)
{
EquippedWeapon = null;
EquippedWeaponPosition = -1;
}
if (EquippedArmor == equipmentItem)
{
EquippedArmor = null;
EquippedArmorPosition = -1;
}
OnPassiveSkillsApplied?.Invoke();
}
public void RefreshEquipmentPositions()
{
EquippedWeaponPosition = EquippedWeapon == null ? -1 : Array.IndexOf(Items, EquippedWeapon);
EquippedArmorPosition = EquippedArmor == null ? -1 : Array.IndexOf(Items, EquippedArmor);
OnPassiveSkillsApplied?.Invoke();
}
public bool IsEquippedItem(Item item)
{
return item == EquippedWeapon || item == EquippedArmor;
}
private void ClearEquipment()
{
EquippedWeapon = null;
EquippedArmor = null;
equippedWeaponPosition = -1;
equippedArmorPosition = -1;
}
public void SetEquipmentPositions(int weaponPosition, int armorPosition)
{
equippedWeaponPosition = weaponPosition;
equippedArmorPosition = armorPosition;
}