w99hyun
프로필프로젝트포스트연락처
목록
w99hyun

본 사이트는 개인 포트폴리오 게시용으로 제작되었습니다.

ⓒ 2026. w99hyun(Woohyun Cho) All rights reserved.

GitHubSTOVE 스토어
게임
개발기간2024. 12. 25. ㅡ 2025. 12. 31.
역할1인 개발
사용 기술
UnityC#MariaDBMirror Network

Embers

시연 영상

스크린샷

개요

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

데디케이티드 서버

Dedicated Server
클라이언트와 서버를 완전 분리하여, 서버는 Headless 전용 빌드로만 실행됩니다.
서버 기동 시 MariaDB에 우선 접속하고, 접속 성공 시에만 Mirror Network Server를 기동합니다.
캐릭터 정보, 데미지 판정, 몬스터 스포닝 등 모든 게임 데이터는 서버에서 중앙 처리하며, 클라이언트는 입력 전송과 결과 수신만 담당합니다.

서버 권한 아키텍처

클라이언트 권한을 최소화하고, 모든 데이터와 판정을 서버에서 중앙 집중 관리하는 구조를 설계했습니다.
네트워크 메시지, Command, RPC를 활용하여 서버-클라이언트 간 역할을 명확히 분리했습니다.
Network

회원가입 네트워크 통신 구조

Create ID

  • 서버-클라이언트 간 네트워크 메시지 통신
  • 서버에서 DB와 실시간 데이터 동기화 후 클라이언트로 전송
  • 클라이언트의 DB 직접 접근 차단으로 보안 강화
  • 필요 데이터만 선별 전송하여 대규모 접속 시 트래픽 절감
코드 보기코드 닫기
C#
//캐릭터 생성 코드 조각
#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

개선 방향

전투·몬스터 처리 등의 핵심 판정은 서버에서 처리되도록 구현했지만, 플레이어 데이터는 처음 로드시 서버에서 받아오고 이후 업데이트가 클라이언트에서 이뤄지고 있습니다. 데이터의 위변조 방지를 위해 중요한 플레이어 데이터는 서버에서 처리돼야 한다는 점을 깨닫게 되었습니다.

오픈월드

Chunk System
플레이어의 위치를 기반으로 동적으로 청크를 로드/ 언로드하는 오픈월드 시스템입니다.각 청크는 별도의 씬으로 관리되며, 플레이어가 이동할 때마다 주변 청크를 자동으로 로드하고 멀리 떨어진 청크는 언로드하여 메모리 효율성을 높입니다.

  • 플레이어 위치 기반 동적 청크 로딩(플레이어 위치 주위 1칸 청크만 로드)
  • 청크별 BGM 자동 전환
  • 메모리 효율적인 씬 관리
  • 비동기 청크 로딩 / 언로딩
코드 보기코드 닫기
C#
#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 폴더에서 스킬데이터를 가져와 캐싱합니다.
스킬 데이터에서 스킬을 사용할 수 있는 클래스와 스킬 사용시 실행될 이펙트 및 스크립트 등을 지정할 수 있습니다.

코드 보기코드 닫기
C#
[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로 모든 클라이언트에게 동기화됩니다.

코드 보기코드 닫기
C#
[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

NPC 데이터를 ScriptableObject로 관리하고, 필드에 배치된 NPC 오브젝트에 할당하여 사용합니다.

코드 보기코드 닫기
C#
[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;
}

몬스터 (적)

BT
몬스터 에이전트 구현에서는 Behavior Tree를 활용하였습니다.
Behavior Tree에서 몬스터의 동작을 결정하는 트리 구조를 만들어 몬스터의 동작을 구현했고, 각 액션은 커스텀 액션을 사용합니다.

코드 보기코드 닫기
C#
//몬스터 공격 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;
    }
}

Enemy
EnemySO
몬스터를 처치하면 몬스터별로 설정돼있는 일정 확률의 아이템이 드롭되어, 몬스터를 처치한 플레이어의 인벤토리로 귀속됩니다.
몬스터 관련한 모든 로직은 서버에서 처리됩니다.

코드 보기코드 닫기
C#
//몬스터 관련 처리 로직 일부
[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);
}

인벤토리 / 장비

Inventory
Ability
몬스터를 처치하여 얻은 장비를 장착하고, 장착한 아이템의 능력치에 따라 플레이어의 능력치가 상승합니다.

코드 보기코드 닫기
C#
//장비 관련 처리를 위한 플레이어 데이터 일부
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;
}

목차

개요데디케이티드 서버서버 권한 아키텍처회원가입 네트워크 통신 구조개선 방향오픈월드스킬 시스템NPC몬스터 (적)인벤토리 / 장비
이전 프로젝트

라스트 오브 펭귄

모바일 MOBA 게임

다음 프로젝트

Orbit

싱글 / 멀티플레이 FPS 게임