[.Net Core] MMO 컨텐츠 구현(DB,대형구조,라이브) - 라이브 준비

Yijun Jeon·2022년 12월 25일
0
post-thumbnail

Inflearn - '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part9: MMO 컨텐츠 구현 (DB, 대형구조, 라이브 준비)'를 스터디하며 정리한 글입니다.


AccountServer

계정과 관련된 문지기 역할을 하는 웹서버 구현

  • ASP .Net Core 웹 애플리케이션 API 프로젝트

  • 동일하게 Nuget 패키지로 설치
    Microsoft.EntityFrameworkCore.SqlServer
    Microsoft.EntityFrameworkCore.Design

DB 모델링

기존 Server DB의 Account와 관련된 부분이 모두 이전돼야 함

DataModel.cs

[Table("Account")]
public class AccountDb
{
    // 자동으로 PK로 설정
    public int AccountDbId { get; set; }
    public string AccountName { get; set; }
    public string Password { get; set; }
}

AppDbContext.cs

public class AppDbContext : DbContext
{
    public DbSet<AccountDb> Accounts { get; set; }

    // 웹서버의 OnConfiguring 방식
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {

    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        // 이름을 이용해서 빠르게 찾을 수 있게 인덱스 추가
        builder.Entity<AccountDb>()
            .HasIndex(a => a.AccountName)
            .IsUnique();
    }
}

->

appsettings.json

{
  "ConnectionStrings": {
    "DefaultConnection": "Data Source...."
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

Startup.cs

// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddDbContext<AppDbContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
}

패킷 설계

웹서버의 경우 접속하는 경로 자체가 Protocol이므로 따로 Protocol을 관리해줄 필요가 없음

  • 주고 받고 싶은 정보에 대한 클래스만 짜주고 활용하면 됨

양식 :

  • 클라 -> 서버 : ~~PacketReq
    ex) CreateAccountPacketReq
  • 서버 -> 클라 : ~~~PacketRes
    ex) CreateAccountPacketRes

WebPacket.cs

// 클라 -> 서버
public class CreateAccountPacketReq
{ 
    public string AccountName { get; set; }
    public string Password { get; set; }
}

// 서버 -> 클라
public class CreateAccountPacketRes
{
    public bool CreateOk { get; set; }
}

// 클라 -> 서버
public class LoginAccountPacketReq
{
    public string AccountName { get; set; }
    public string Password { get; set; }
}

public class ServerInfo
{ 
    public string Name { get; set; }
    public string Ip { get; set; }
    // 서버 혼잡 정도
    public int CrowdedLevel { get; set; }
}


// 서버 -> 클라
public class LoginAccountPacketRes
{
    public bool LoginOk { get; set; }
    // 서버 리스트
    public List<ServerInfo> ServerList { get; set; } = new List<ServerInfo>();
}

컨트롤러

정의해놓은 패킷 클래스들에 대해 컨트롤러 클래스의 메소드로 PacketHandler를 구현해주는 느낌

AccountController.cs

// 아파트의 동 주소 느낌
[Route("api/[controller]")]
[ApiController]
public class AccountController : ControllerBase
{
    AppDbContext _context;

    public AccountController(AppDbContext context)
    {
        _context = context;
    }

    [HttpPost]
    [Route("create")]
    public CreateAccountPacketRes CreateAccount([FromBody] CreateAccountPacketReq req)
    {
        CreateAccountPacketRes res = new CreateAccountPacketRes();

        // 이름 중복 확인
        AccountDb account = _context.Accounts
                            .AsNoTracking()
                            .Where(a => a.AccountName == req.AccountName)
                            .FirstOrDefault();

        // 생성 가능
        if(account == null)
        {
            _context.Accounts.Add(new AccountDb()
            { 
                AccountName = req.AccountName,
                Password = req.Password
            });

            bool sucess = _context.SaveChangesEx();
            res.CreateOk = sucess;
        }
        else
        {
            res.CreateOk = false;
        }

        return res;
    }

    [HttpPost]
    [Route("login")]
    public LoginAccountPacketRes LoginAccount([FromBody] LoginAccountPacketReq req)
    {
        LoginAccountPacketRes res = new LoginAccountPacketRes();

        AccountDb account = _context.Accounts
            .AsNoTracking()
            .Where(a => a.AccountName == req.AccountName && a.Password == req.Password)
            .FirstOrDefault();

        if(account == null)
        {
            res.LoginOk = false;
        }
        else
        {
            res.LoginOk = true;

            // TODO 서버 목록
            res.ServerList = new List<ServerInfo>()
            { 
                new ServerInfo() {Name = "데포르쥬", Ip = "127.0.0.1", CrowdedLevel = 0},
                new ServerInfo() {Name = "아툰", Ip = "127.0.0.1", CrowdedLevel = 3}
            };
        }

        return res;
    }
}


AccountServer - 2

Client

클라이언트에서 사용하는 웹패킷은 get, set 없이 일반 필드로 관리해야 함

WebPacket.cs

// 클라 -> 서버
public class CreateAccountPacketReq
{
    public string AccountName;
    public string Password;
}

// 서버 -> 클라
public class CreateAccountPacketRes
{
    public bool CreateOk;
}

// 클라 -> 서버
public class LoginAccountPacketReq
{
    public string AccountName;
    public string Password;
}

public class ServerInfo
{
    public string Name;
    public string Ip;
    // 서버 혼잡 정도
    public int CrowdedLevel;
}


// 서버 -> 클라
public class LoginAccountPacketRes
{
    public bool LoginOk;
    // 서버 리스트
    public List<ServerInfo> ServerList = new List<ServerInfo>();
}

req를 json으로 만들어서 보내주고 res를 json으로 받는 작업

WebManager.cs

public string BaseUrl { get; set; } = "https://localhost:5001/api";

[Obsolete]
public void SendPostRequest<T>(string url, object obj, Action<T> res)
{
    Managers.Instance.StartCoroutine(CoSendWebRequest(url,UnityWebRequest.kHttpVerbPOST,obj,res));
}

// obj의 req 패킷을 보내서 res 패킷을 받는 역할
[Obsolete]
IEnumerator CoSendWebRequest<T>(string url, string method, object obj, Action<T> res)
{
    // 보내야 하는 url
    string sendUrl = $"{BaseUrl}/{url}";

    // json 형태로 변환
    byte[] jsonBytes = null;
    if(obj != null)
    {
        string jsonStr = JsonUtility.ToJson(obj);
        jsonBytes = Encoding.UTF8.GetBytes(jsonStr);
    }

    using (var uwr = new UnityWebRequest(sendUrl, method))
    {
        // 보내는 버퍼
        uwr.uploadHandler = new UploadHandlerRaw(jsonBytes);
        // 받는 버퍼
        uwr.downloadHandler = new DownloadHandlerBuffer();
        uwr.SetRequestHeader("Content-Type", "application/json");

        yield return uwr.SendWebRequest();

        if(uwr.isNetworkError || uwr.isHttpError)
        {
            Debug.Log(uwr.error);
        }
        else
        {
            // 응답 패킷 수신
            T resObj = JsonUtility.FromJson<T>(uwr.downloadHandler.text);
            res.Invoke(resObj);
        }
    }
}

GameScene.cs

protected override void Init()
{
    base.Init();

    SceneType = Define.Scene.Game;

    // TODO : 잠시 기생중
    Managers.Web.BaseUrl = "https://localhost:5001/api";
    WebPacket.SendCreateAccount("yijun", "1234");

    Managers.Map.LoadMap(1);

    // 빌드 화면 크기 설정
    Screen.SetResolution(640, 480, false);

    _sceneUI = Managers.UI.ShowSceneUI<UI_GameScene>();
}


Login UI

로그인 창을 위한 클라이언트 UI 작업

구현 기능

  • 계정 생성
  • 로그인

LoginScene.cs

UI_LoginScene _sceneUI;

protected override void Init()
{
    base.Init();

    SceneType = Define.Scene.Login;

    Managers.Web.BaseUrl = "https://localhost:5001/api";

    // 빌드 화면 크기 설정
    Screen.SetResolution(640, 480, false);

    _sceneUI = Managers.UI.ShowSceneUI<UI_LoginScene>();
}

public override void Clear()
{
        
}

UI_LoginScene.cs

enum GameObjects
{ 
    AccountName,
    Password
}

enum Images
{ 
    CreateBtn,
    LoginBtn,
}

public override void Init()
{
    base.Init();

    Bind<GameObject>(typeof(GameObjects));
    Bind<Image>(typeof(Images));

    // 클릭 처리 바인딩
    GetImage((int)Images.CreateBtn).gameObject.BindEvent(OnClickCreateButton);
    GetImage((int)Images.LoginBtn).gameObject.BindEvent(OnClickLoginButton);
}

// 계정 생성 버튼 클릭 처리
public void OnClickCreateButton(PointerEventData ext)
{
    string account = Get<GameObject>((int)GameObjects.AccountName).GetComponent<InputField>().text;
    // **** 값이 아닌 실제 값이 가져와짐
    string password = Get<GameObject>((int)GameObjects.Password).GetComponent<InputField>().text;

    CreateAccountPacketReq packet = new CreateAccountPacketReq()
    {
        AccountName = account,
        Password = password
    };

    Managers.Web.SendPostRequest<CreateAccountPacketRes>("account/create", packet, (res) =>
    {
        Debug.Log(res.CreateOk);

        // 계정과 비밀번호 입력 초기화
        Get<GameObject>((int)GameObjects.AccountName).GetComponent<InputField>().text = "";
        Get<GameObject>((int)GameObjects.Password).GetComponent<InputField>().text = "";
    });
}

// 로그인 버튼 클릭 처리
public void OnClickLoginButton(PointerEventData ext)
{
    string account = Get<GameObject>((int)GameObjects.AccountName).GetComponent<InputField>().text;
    // **** 값이 아닌 실제 값이 가져와짐
    string password = Get<GameObject>((int)GameObjects.Password).GetComponent<InputField>().text;

    LoginAccountPacketReq packet = new LoginAccountPacketReq()
    {
        AccountName = account,
        Password = password
    };

    Managers.Web.SendPostRequest<LoginAccountPacketRes>("account/login", packet, (res) =>
    {
        Debug.Log(res.LoginOk);
        // 계정과 비밀번호 입력 초기화
        Get<GameObject>((int)GameObjects.AccountName).GetComponent<InputField>().text = "";
        Get<GameObject>((int)GameObjects.Password).GetComponent<InputField>().text = "";

        if (res.LoginOk)
        {
            // 로그인이 성공했으므로 Scene 변경
            Managers.Scene.LoadScene(Define.Scene.Game);
            // 게임 접속
            Managers.Network.ConnectToGame();
        }
    });
}

연동

이제는 AccountDB에 있는 계정으로 정상적으로 로그인에 성공해야 인게임 화면으로 전환될 수 있게됨

NetworkManager.cs

public void Init() -> Connect()
{
	// DNS (Domain Name System)
	string host = Dns.GetHostName();
	IPHostEntry ipHost = Dns.GetHostEntry(host);
	IPAddress ipAddr = ipHost.AddressList[0];
	IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

	Connector connector = new Connector();

	connector.Connect(endPoint,
		() => { return _session; },
		1);
}



SharedDB

게임 서버의 DB와 로그인 서버의 DB가 서로 정보를 주고 받아서 연동이 돼야 함

  • 공용으로 사용하는 DB를 만들어서 서로에 대한 정보를 꺼내서 쓸 수 있도록 처리

클래스 라이브러리(.NET Core) 프로젝트 템플릿으로 SharedDB 프로젝트 생성

DB 설계

ASP .NET DB 연동 방식(AccountDB)과 일반 콘솔용 DB 연동 방식(GameDB) 양쪽을 모두 지원할 수 있도록 설계

ASP .NET 방식

// 웹서버의 OnConfiguring 방식
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{

}

protected override void OnModelCreating(ModelBuilder builder)
{
    // 이름을 이용해서 빠르게 찾을 수 있게 인덱스 추가
    builder.Entity<AccountDb>()
        .HasIndex(a => a.AccountName)
        .IsUnique();
}

VS

일반 콘솔 방식

// console 로깅 추가
static readonly ILoggerFactory _logger = LoggerFactory.Create(builder => { builder.AddConsole(); });

string _connectionString = @"Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=GameDB;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False";

protected override void OnConfiguring(DbContextOptionsBuilder options)
{
    options
        //.UseLoggerFactory(_logger)
        // Config 초기화 안됐을 때 오류 방지
        .UseSqlServer(ConfigManager.Config == null ? _connectionString : ConfigManager.Config.connectionString);
}

protected override void OnModelCreating(ModelBuilder builder)
{
    // 이름을 이용해서 빠르게 찾을 수 있게 인덱스 추가
    builder.Entity<AccountDb>()
        .HasIndex(a => a.AccountName)
        .IsUnique();

    // 이름을 이용해서 빠르게 찾을 수 있게 인덱스 추가
    builder.Entity<PlayerDb>()
        .HasIndex(p => p.PlayerName)
        .IsUnique();
}

데이터 설계

DataModel.cs

// Account 서버에서 인증을 받았다는 토큰
[Table("Token")]
public class TokenDb
{
    // PK
    public int TokenDbId { get; set; }
    public int AccountDbId { get; set; }
    // Account 서버에서 랜덤으로 부여받은 토큰
    public int Token { get; set; }
    // 만료 날짜
    public DateTime Expired { get; set; }
}

// 여러 서버의 각각의 정보를 담은 DB
[Table("ServerInfo")]
public class ServerDb
{
    // PK
    public int ServerDbId { get; set; } 
    // 서버 이름
    public string Name { get; set; }
    public string IpAddress { get; set; }
    public int Port { get; set; }
    // 서버 혼잡도
    public int BusyScore { get; set; }
}

DB 연동

SharedDbContext.cs

public class SharedDbContext : DbContext
{
    public DbSet<TokenDb> Tokens { get; set; }
    public DbSet<ServerDb> Servers { get; set; }

    #region GameServer 방식
    public SharedDbContext()
    {

    }

    public static string ConnectionString { get; set; } = @"Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=SharedDB;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False";

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        // ASP .NET은 이미 미리 옵션 설정이 되어있음
        if(options.IsConfigured == false)
        {
            options
            //.UseLoggerFactory(_logger)
            .UseSqlServer(ConnectionString);
        }
    }
    #endregion

    #region ASP .NET 방식
    public SharedDbContext(DbContextOptions<SharedDbContext> options) : base(options)
    {

    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        // 빠르게 찾기 위해 인덱스를 걸어줌
        builder.Entity<TokenDb>()
            .HasIndex(t => t.AccountDbId)
            .IsUnique();

        builder.Entity<ServerDb>()
            .HasIndex(s => s.Name)
            .IsUnique();
    }
    #endregion
}

DB 공유화

AccountServer

AccountServer.Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers().AddJsonOptions(options =>
    {
        // json 옵션 설정 변경
        options.JsonSerializerOptions.PropertyNamingPolicy = null;
        options.JsonSerializerOptions.DictionaryKeyPolicy = null;
    });

    services.AddDbContext<AppDbContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

    // 공유 DB 접근
    services.AddDbContext<SharedDbContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("SharedConnection")));
}

AccountController.cs

AppDbContext _context;
// 공유 DB
SharedDbContext _shared;

public AccountController(AppDbContext context, SharedDbContext shared)
{
    _context = context;
    _shared = shared;
}
  • 로그인 시 서버목록 체크해주는 기능 구현

WebPacket.cs

public class ServerInfo
{ 
    public string Name { get; set; }
    public string IpAddress { get; set; }
    public int Port { get; set; }
    // 서버 혼잡 정도
    public int BusyScore { get; set; }
}


// 서버 -> 클라
public class LoginAccountPacketRes
{
    public bool LoginOk { get; set; }
    public int AccountId { get; set; }
    public int Token { get; set; }
    // 서버 리스트
    public List<ServerInfo> ServerList { get; set; } = new List<ServerInfo>();

AccountController.cs


[HttpPost]
[Route("login")]
public LoginAccountPacketRes LoginAccount([FromBody] LoginAccountPacketReq req)
{
    LoginAccountPacketRes res = new LoginAccountPacketRes();

    AccountDb account = _context.Accounts
        .AsNoTracking()
        .Where(a => a.AccountName == req.AccountName && a.Password == req.Password)
        .FirstOrDefault();

    if(account == null)
    {
        res.LoginOk = false;
    }
    else
    {
        res.LoginOk = true;

        // 토큰 발급
        DateTime expired = DateTime.UtcNow; // 절대 시간
        expired.AddSeconds(600); // 600초 후 만료

        TokenDb tokenDb = _shared.Tokens.Where(t => t.AccountDbId == account.AccountDbId).FirstOrDefault();
        // 이미 계정에 발급된 토큰이 있다면 토큰 수정
        if(tokenDb != null)
        {
            // 랜덤 값으로 토큰 부여
            tokenDb.Token = new Random().Next(Int32.MinValue, Int32.MaxValue);
            tokenDb.Expired = expired;
            _shared.SaveChangesEx();
        }
        else
        {
            tokenDb = new TokenDb()
            {
                AccountDbId = account.AccountDbId,
                Token = new Random().Next(Int32.MinValue, Int32.MaxValue),
                Expired = expired
            };
            _shared.Add(tokenDb);
            _shared.SaveChangesEx();
        }

        res.AccountId = account.AccountDbId;
        res.Token = tokenDb.Token;
        res.ServerList = new List<ServerInfo>();

        // 서버 목록 정보
        foreach(ServerDb serverDb in _shared.Servers)
        {
            res.ServerList.Add(new ServerInfo()
            {
                Name = serverDb.Name,
                IpAddress = serverDb.IpAddress,
                Port = serverDb.Port,
                BusyScore = serverDb.BusyScore
            });
        }
    }
    return res;
}

Server(Game)

SessionManager.cs

// 연결된 세션들로 몇 백명이 접속중인지 반환
public int GetBusyScore()
{
    int count = 0;

    lock(_lock)
    {
        count = _sessions.Count;
    }

    return count / 100;
}

Prgram.cs

// 서버의 정보를 갱신
public static string Name { get; } = "데포르쥬";
public static int Port { get; } = 7777;
public static string IpAddress { get; set; }

// 주기적으로 서버 정보 갱신
static void StartServerInfoTask()
{
	var t = new System.Timers.Timer();
	t.AutoReset = true;
	t.Elapsed += new System.Timers.ElapsedEventHandler((s, e) =>
	{
		// 공유 DB에 자신의 서버 정보 갱신
		using(SharedDbContext shared = new SharedDbContext())
		{
			ServerDb serverDb = shared.Servers.Where(s => s.Name == Program.Name).FirstOrDefault();
			if(serverDb != null)
			{
				serverDb.IpAddress = IpAddress;
				serverDb.Port = Port;
				serverDb.BusyScore = SessionManager.Instance.GetBusyScore();
				shared.SaveChangesEx();
			}
			else
			{
				serverDb = new ServerDb()
				{
					Name = Program.Name,
					IpAddress = Program.IpAddress,
					Port = Program.Port,
					BusyScore = SessionManager.Instance.GetBusyScore(),
				};
				shared.Servers.Add(serverDb);
				shared.SaveChangesEx();
			}
		}
	});
	// 10 초마다 실행
	t.Interval = 10 * 1000;
	t.Start();
}


서버 선택창

SharedDB 에서 건내받은 서버 리스트 중 서버를 선택할 수 있는 UI 작업

UI

  • Inventory 와 Item 작업과 동일하게 진행

UI_SelectServerPopup_Item.cs

public ServerInfo Info { get; set; }

enum Buttons
{
	SelectServerButton
}

enum Texts
{
	NameText
}


public override void Init()
{
	Bind<Button>(typeof(Buttons));
	Bind<Text>(typeof(Texts));

	GetButton((int)Buttons.SelectServerButton).gameObject.BindEvent(OnClickButton);
}

// 서버 정보에 따라 UI 갱신
public void RefreshUI()
{
	if (Info == null)
		return;

	GetText((int)Texts.NameText).text = Info.Name;
}

void OnClickButton(PointerEventData evt)
{
	// 해당 서버의 주소로 접속
	Managers.Network.ConnectToGame(Info);
	Managers.Scene.LoadScene(Define.Scene.Game);
	Managers.UI.ClosePopupUI();
}

UI_SelectServerPopup.cs

// 게임 서버 목록
public List<UI_SelectServerPopup_Item> Items { get; } = new List<UI_SelectServerPopup_Item>();
public override void Init()
{
    base.Init();
}

public void SetServers(List<ServerInfo> servers)
{
    // 초기화
    Items.Clear();
    GameObject grid = GetComponentInChildren<GridLayoutGroup>().gameObject;
    foreach (Transform child in grid.transform)
        Destroy(child.gameObject);

    for (int i = 0; i < servers.Count; i++)
    {
        GameObject go = Managers.Resource.Instantiate("UI/Popup/UI_SelectServerPopup_Item", grid.transform);
        UI_SelectServerPopup_Item item = go.GetOrAddComponent<UI_SelectServerPopup_Item>();
        Items.Add(item);

        // 서버 정보 할당
        item.Info = servers[i];
    }

    RefreshUI();
}

public void RefreshUI()
{
    if (Items.Count == 0)
        return;

    foreach(var item in Items)
    {
        // 서버 정보 갱신
        item.RefreshUI();
    }
}

연동

로그인 버튼을 누르면 서버 UI 팝업이 뜨고 서버를 클릭하면 그 서버의 주소로 접속

  • NetworkManager에서 계정과 토큰 정보 저장

NetworkManager.cs

public int AccountId { get; set; }
public int Token { get; set; }

ServerSession _session = new ServerSession();

public void Send(IMessage sendBuff)
{
	_session.Send(sendBuff);
}

public void ConnectToGame(ServerInfo info)
{
	IPAddress ipAddr = IPAddress.Parse(info.IpAddress);
	IPEndPoint endPoint = new IPEndPoint(ipAddr, info.Port);

	Connector connector = new Connector();

	connector.Connect(endPoint,
		() => { return _session; },
		1);
}

UI_LoginScene.cs


// 로그인 버튼 클릭 처리
public void OnClickLoginButton(PointerEventData ext)
{
    string account = Get<GameObject>((int)GameObjects.AccountName).GetComponent<InputField>().text;
    // **** 값이 아닌 실제 값이 가져와짐
    string password = Get<GameObject>((int)GameObjects.Password).GetComponent<InputField>().text;

    LoginAccountPacketReq packet = new LoginAccountPacketReq()
    {
        AccountName = account,
        Password = password
    };

    Managers.Web.SendPostRequest<LoginAccountPacketRes>("account/login", packet, (res) =>
    {
        Debug.Log(res.LoginOk);
        // 계정과 비밀번호 입력 초기화
        Get<GameObject>((int)GameObjects.AccountName).GetComponent<InputField>().text = "";
        Get<GameObject>((int)GameObjects.Password).GetComponent<InputField>().text = "";

        if (res.LoginOk)
        {
            // 계정 정보 저장
            Managers.Network.AccountId = res.AccountId;
            Managers.Network.Token = res.Token;

            UI_SelectServerPopup popup = Managers.UI.ShowPopupUI<UI_SelectServerPopup>();
            popup.SetServers(res.ServerList);
        }
    });
}

0개의 댓글