Inflearn - '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part9: MMO 컨텐츠 구현 (DB, 대형구조, 라이브 준비)'를 스터디하며 정리한 글입니다.
계정과 관련된 문지기 역할을 하는 웹서버 구현
ASP .Net Core 웹 애플리케이션 API 프로젝트
동일하게 Nuget 패키지로 설치
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Design
기존 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
CreateAccountPacketReq
~~~PacketRes
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;
}
}
클라이언트에서 사용하는 웹패킷은 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>();
}
로그인 창을 위한 클라이언트 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);
}
게임 서버의 DB와 로그인 서버의 DB가 서로 정보를 주고 받아서 연동이 돼야 함
클래스 라이브러리(.NET Core)
프로젝트 템플릿으로 SharedDB
프로젝트 생성
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; }
}
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
}
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;
}
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_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);
}
});
}