DB 연동
기존에 만들어 뒀던 서버 코드를 DB랑 연동해주자.
C# ORM을 이용해서 DB 사용할 것이다. ORM 사용법은 구글링을 통해 알아두면 좋다.
AppDbContext와 ModelData 클래스를 추가해준다.
ModelData Class 안에 우리가 사용할 테이블들을 넣어준다.
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text;
namespace Server.DB
{
[Table("Account")]
public class AccountDb
{
public int AccountDbId { get; set; }
public string AccountName { get; set; }
public ICollection<PlayerDb> Players { get; set; }
}
[Table("Player")]
public class PlayerDb
{
public int PlayerDbId { get; set; }
public string PlayerName { get; set; }
public AccountDb Account { get; set; }
}
}
사용할 버전에 따라 connectionString이 달라질 수 있다.
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Server.Data;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server.DB
{
public class AppDbContext : DbContext
{
public DbSet<AccountDb> Accounts { get; set; }
public DbSet<PlayerDb> Players { get; set; }
static readonly ILoggerFactory _logger = LoggerFactory.Create(builder => { builder.AddConsole(); });
string _connectionString = @"Data Source=(localdb)\ProjectModels;Initial Catalog=GameDB;Integrated Security=True;Connect Timeout=30;Encrypt=False;";
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options
.UseLoggerFactory(_logger)
.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();
}
}
}
이렇게 해서 우리가 만든 서버에 db 연동을 할 수 있게 됐다.
로그인(접속)
클라이언트가 접속을 한다고 생각해보자. 플레이어가 클라이언트에서 아이디와 비빌번호를 입력하고 그 값을 패킷으로 보낸 뒤 값을 확인한 후 그 결과를 통보해주면된다.
이는 게임 서버를 이용해야할까?가 의문이다. 왜냐하면 게임서버는 실시간으로 계속 구동하고 있기 때문에 어쩌다 한번 하게 되는 로그인 같은 경우는 되게 불필요할 수 있다. 이런 경우는 웹 서버를 구성해 로그인 요청이 왔을 때 웹 서버에서 판독 후 보내주는 것이 일반적이다. 하지만 우리가 이런 소규모 프로젝트의 경우 웹서버까지 구성하기에는 시간이 많이 들고 또 웹 서버가 켜지지 않은 상태라면 다른 기능들을 테스트할 수 없기 때문에 생략하고 게임 서버에 넣어주도록 하겠다.
클라이언트에서 유니크한 값을 서버쪽에 보내준다고 가정하고(실제로 그렇게 작동하게 할 예정) 서버를 작성해보겠다.
먼저 클라이언트가 연결이 되면 연결에 성공했다고 서버가 클라에게 패킷을 전송한다. 그리고 패킷을 전송 받은 클라는 로그인 요청을 하게되고(C_Login) 서버는 확인후 S_Loing을 보내 로그인에 성공했다고 알려준다.
그 과정에서 DB에 유니크한 값이 있다면 로그인을하고 없다면 새로운 계정을 만들게 된다.
이는 코드상에 문제가 없어보이지만 나중에 수정하겠다.
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
{
S_Connected connectedPacket = new S_Connected();
Send(connectedPacket);
}
}
public static void C_LoginHandler(PacketSession session, IMessage packet)
{
C_Login loginPacket = packet as C_Login;
ClientSession clientSession = session as ClientSession;
Console.WriteLine($"UniqueId{loginPacket.UniqueId}");
using (AppDbContext db = new AppDbContext())
{
AccountDb findAccount = db.Accounts
.Where(a => a.AccountName == loginPacket.UniqueId).FirstOrDefault();
if(findAccount != null)
{
S_Login loginOk = new S_Login() { LoginOk = 1 };
clientSession.Send(loginOk);
}
else
{
AccountDb newAccount = new AccountDb() { AccountName = loginPacket.UniqueId };
db.Accounts.Add(newAccount);
db.SaveChanges();
S_Login loginOk = new S_Login() { LoginOk = 1 };
clientSession.Send(loginOk);
}
}
}
- 클라
public static void S_ConnectedHandler(PacketSession session, IMessage packet)
{
Debug.Log("S_ConnectedHandler");
C_Login loginPacket = new C_Login();
loginPacket.UniqueId = SystemInfo.deviceUniqueIdentifier;
Managers.Network.Send(loginPacket);
}
public static void S_LoginHandler(PacketSession session, IMessage packet)
{
S_Login loginPacket = packet as S_Login;
Debug.Log($"LoginOk({loginPacket.LoginOk})");
}
이렇게 하면 정상적으로 작동하는 모습을 볼 수 있다.
Player 연동
위에서 로그인을 할 때 문제가 발생한다고 말했다. 그 이유는 여러가지가 있다.
1. 동시에 다른 사람이 같은 UniqueId를 보낸다면?? 문제가 발생한다. 그 이유는 AccountName이 Unique하기 때문이다.
2. 악의적으로 동일한 Id를 여러번 보낸다면? db로드는 매우 오래걸리기 때문에 누군가 악의적으로 1초에 100번 보낸다면 에러가 날 것이다.
3. 생뚱맞은 타이밍에 그냥 이 패킷을 보낸다면?? 누군가 플레이중 로직적 실수로 C_Login 패킷을 보낸다면 에러가 날 것이다.
이것을 1차적으로 방지하기 위해 상태를 관리할 것이다.(로비에 있다, 맵에 있다 등)
enum PlayerServerState{
SERVER_STATE_LOGIN = 0;
SERBER_STATE_LOBBY = 1;
SERVER_STATE_GAME = 2;
}
패킷에 다음과 같은 정보를 포함하도록 한다.
if (ServerState != PlayerServerState.ServerStateLogin)
return;
로그인 패킷 안에 다음과 같은 부분을 추가해 return 해주도록 한다.
즉
public void HandleLogin(C_Login loginPacket)
{
Console.WriteLine($"UniqueId{loginPacket.UniqueId}");
if (ServerState != PlayerServerState.ServerStateLogin)
return;
LobbyPlayers.Clear();
using (AppDbContext db = new AppDbContext())
{
AccountDb findAccount = db.Accounts
.Include(a => a.Players)
.Where(a => a.AccountName == loginPacket.UniqueId).FirstOrDefault();
if (findAccount != null)
{
// 메모리 기억
AccountDbId = findAccount.AccountDbId;
S_Login loginOk = new S_Login() { LoginOk = 1 };
foreach(PlayerDb playerDb in findAccount.Players)
{
LobbyPlayerInfo lobbyPlayer = new LobbyPlayerInfo()
{
Name = playerDb.PlayerName,
StatInfo = new StatInfo()
{
Level = playerDb.Level,
Hp = playerDb.Hp,
MaxHp = playerDb.MaxHp,
Attack = playerDb.Attack,
Speed = playerDb.Speed,
TotalExp = playerDb.TotalExp,
}
};
LobbyPlayers.Add(lobbyPlayer);
loginOk.Players.Add(lobbyPlayer);
}
Send(loginOk);
ServerState = PlayerServerState.SerberStateLobby;
}
else
{
AccountDb newAccount = new AccountDb() { AccountName = loginPacket.UniqueId };
db.Accounts.Add(newAccount);
db.SaveChanges();
// 메모리 기억
AccountDbId = newAccount.AccountDbId;
S_Login loginOk = new S_Login() { LoginOk = 1 };
Send(loginOk);
ServerState = PlayerServerState.SerberStateLobby;
}
}
}
이렇게 해준다.
흐름은 클라에서 로비에 입장할 때(로그인) 아이디가 있다면 생성되어 있는 캐릭터 목록을 보여준다.
그리고 캐릭터를 생성 요청(C_CreatePlayer)을 하게 되면 캐릭터를 서버쪽에서 생성한 후 S_CreatePlayer를 호출 해 클라에게 그 정보를 전달해 준다. 그리고 캐릭터로 접속을 시도하면 C_EnterGame을 해서 선택한 캐릭터를 맵에 생성한 후에 맵으로 입장해준다.
public void HandleEnterGame(C_EnterGame enterGamePacket)
{
if (ServerState != PlayerServerState.SerberStateLobby)
return;
LobbyPlayerInfo playerInfo = LobbyPlayers.Find(p => p.Name == enterGamePacket.Name);
if (playerInfo == null)
return;
MyPlayer = ObjectManager.Instance.Add<Player>();
{
MyPlayer.Info.Name = playerInfo.Name;
MyPlayer.Info.PosInfo.State = CreatureState.Idle;
MyPlayer.Info.PosInfo.MoveDir = MoveDir.Down;
MyPlayer.Info.PosInfo.PosX = 0;
MyPlayer.Info.PosInfo.PosY = 0;
MyPlayer.Stat.MergeFrom(playerInfo.StatInfo);
MyPlayer.Session = this;
}
ServerState = PlayerServerState.ServerStateGame;
GameRoom room = RoomManager.Instance.Find(1);
room.Push(room.EnterGame, MyPlayer);
}
public void HandleCreatePlayer(C_CreatePlayer createPacket)
{
if (ServerState != PlayerServerState.SerberStateLobby)
return;
using (AppDbContext db = new AppDbContext())
{
PlayerDb findPlayer = db.Players
.Where(p => p.PlayerName == createPacket.Name).FirstOrDefault();
if(findPlayer != null)
{
Send(new S_CreatePlayer());
}
else
{
// 1레벨 스탯 정보 추출
StatInfo stat = null;
DataManager.StatDict.TryGetValue(1, out stat);
PlayerDb newPlayerDb = new PlayerDb()
{
PlayerName = createPacket.Name,
Level = stat.Level,
Hp = stat.Hp,
MaxHp = stat.MaxHp,
Attack = stat.Attack,
TotalExp = 0,
AccountId = AccountDbId,
};
db.Players.Add(newPlayerDb);
db.SaveChanges();
// 메모리에 추가
LobbyPlayerInfo lobbyPlayer = new LobbyPlayerInfo()
{
Name = createPacket.Name,
StatInfo = new StatInfo()
{
Level = stat.Level,
Hp = stat.Hp,
MaxHp = stat.MaxHp,
Attack = stat.Attack,
Speed = stat.Speed,
TotalExp = stat.TotalExp,
}
};
LobbyPlayers.Add(lobbyPlayer);
// 클라에 전송
S_CreatePlayer newPlayer = new S_CreatePlayer() { Player = new LobbyPlayerInfo() };
newPlayer.Player.MergeFrom(lobbyPlayer);
Send(newPlayer);
}
}
}
클라이언트를 작업해보자.
public static void S_ConnectedHandler(PacketSession session, IMessage packet)
{
Debug.Log("S_ConnectedHandler");
C_Login loginPacket = new C_Login();
loginPacket.UniqueId = SystemInfo.deviceUniqueIdentifier;
Managers.Network.Send(loginPacket);
}
// 로그인 OK + 캐릭터 목록
public static void S_LoginHandler(PacketSession session, IMessage packet)
{
S_Login loginPacket = (S_Login)packet;
Debug.Log($"LoginOk({loginPacket.LoginOk})");
// TODO : 로비 UI에서 캐릭터 보여주고, 선택할 수 있도록
if (loginPacket.Players == null || loginPacket.Players.Count == 0)
{
C_CreatePlayer createPacket = new C_CreatePlayer();
createPacket.Name = $"Player_{Random.Range(0, 10000).ToString("0000")}";
Managers.Network.Send(createPacket);
}
else
{
// 무조건 첫번째 로그인
LobbyPlayerInfo info = loginPacket.Players[0];
C_EnterGame enterGamePacket = new C_EnterGame();
enterGamePacket.Name = info.Name;
Managers.Network.Send(enterGamePacket);
}
}
public static void S_CreatePlayerHandler(PacketSession session, IMessage packet)
{
S_CreatePlayer createOkPacket = (S_CreatePlayer)packet;
if (createOkPacket.Player == null)
{
C_CreatePlayer createPacket = new C_CreatePlayer();
createPacket.Name = $"Player_{Random.Range(0, 10000).ToString("0000")}";
Managers.Network.Send(createPacket);
}
else
{
C_EnterGame enterGamePacket = new C_EnterGame();
enterGamePacket.Name = createOkPacket.Player.Name;
Managers.Network.Send(enterGamePacket);
}
}
이렇게 해서 플레이어가 접속할 때 db에서 데이터를 가져오고 생성하는 것 까지 작동하게 됐다.
HP 연동
이제 체력을 잃을 때, 데미지를 입었을 때 db의 값을 수정해보자.
public override void OnDamaged(GameObject attacker, int damage)
{
base.OnDamaged(attacker, damage);
using (AppDbContext db = new AppDbContext())
{
PlayerDb playerDb = db.Players.Find(PlayerDbId);
playerDb.Hp = Stat.Hp;
db.SaveChanges();
}
}
플레이어가 데미지를 입었을 때 위와 같이하게 되면 될거 같다는 느낌이 든다. 그런데 여기서 잘 생각해보자. 문제가 발생한다. hp가 감소되는 상황은 온갖상황에서 발생할 것이다. 그렇게 되면 OnDamaged가 무수히 호출될 것이고 db를 계속 읽고 쓰는 상황이 발생할 것이다.
그러면 피가 깍을 때마다 DB에 접근할 필요가 있을까?
그게 아닌 게임을 종료할 때 Hp를 저장하면 어떨까 생각이든다.
또한 db를 두번 호출하는 위의 방법에서 한번만 호출할 수 있게 밑에 버전으로 바꿔보자.
public void OnLeaveGame()
{
using (AppDbContext db = new AppDbContext())
{
PlayerDb playerDb = new PlayerDb();
playerDb.PlayerDbId = PlayerDbId;
playerDb.Hp = Stat.Hp;
db.Entry(playerDb).State = EntityState.Unchanged;
db.Entry(playerDb).Property(nameof(PlayerDb.Hp)).IsModified = true;
db.SaveChanges();
}
}
이게 그렇다면 올바른 문제해결 방식일까??
만약 서버가 중간에 다운되면 아직 저장되지 않은 정보가 날아갈 것이다.
또한 코드 흐름을 다 막아버릴 수 있다.
컨텐츠 단에서 db에 접근해 수정하는 것은 엄청난 딜레이를 발생할 수 있다.
그렇기에 다른 쓰레드에 DB 일감을 던져버리고 그 일감이 완료 됐을 때 결과를 통보 받는 식으로 코드를 작성해야한다.
using Microsoft.EntityFrameworkCore;
using Server.Game;
using Server.Migrations;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server.DB
{
public class DbTranscation : JobSerializer
{
public static DbTranscation Instance { get; } = new DbTranscation();
public static void SavePlayerStatus_AllInOne(Player player, GameRoom room)
{
if (player == null || room == null) return;
PlayerDb playerDb = new PlayerDb();
playerDb.PlayerDbId = player.PlayerDbId;
playerDb.Hp = player.Stat.Hp;
Instance.Push(() =>
{
using (AppDbContext db = new AppDbContext())
{
db.Entry(playerDb).State = EntityState.Unchanged;
db.Entry(playerDb).Property(nameof(PlayerDb.Hp)).IsModified = true;
bool success = db.SaveChangesEx();
if (success)
{
room.Push(() => Console.WriteLine($"Hp Saved {playerDb.Hp}"));
}
}
});
}
}
}
public void OnLeaveGame()
{
DbTranscation.SavePlayerStatus_AllInOne(this, Room);
}
이렇게 분리해서 다른 쓰레드에서 관리할 수 있게 할 수있다.
'Unity > 온라인 RPG' 카테고리의 다른 글
[Unity 2D] DB 연동 - Reward 와 아이템 착용 (0) | 2024.03.29 |
---|---|
[Unity 2D] Db 연동 - Item과 Inventory (0) | 2024.03.27 |
[Unity 2D] 서버 구조 변경 - Command 패턴과 Job (0) | 2024.03.14 |
[Unity 2D] 서버 연동 - Hp bar와 DieEffect 그리고 몬스터 Ai (0) | 2024.03.13 |
[Unity 2D] 서버 연동 - Data & Config 과 피격 (0) | 2024.03.12 |