asp.net core를 활용하여 모바일 게임 만들기 (9일차)
어제에 이어서 마저 만들어보겠습니다.
Data 폴더를 만들어줍니다.
그 안에 GameDbContext 클래스를 만들어줍니다.
using GameServer.Entities;
using Microsoft.EntityFrameworkCore;
namespace GameServer.Data
{
public class GameDbContext : DbContext
{
public GameDbContext(
DbContextOptions<GameDbContext> options)
: base(options)
{
}
public DbSet<PlayerEntity> Players =>
Set<PlayerEntity>();
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<PlayerEntity>(entity =>
{
entity.ToTable("Players");
entity.HasKey(player =>
player.PlayerId);
entity.Property(player =>
player.PlayerId)
.ValueGeneratedOnAdd();
entity.Property(player =>
player.Nickname)
.HasMaxLength(12)
.IsRequired();
entity.HasIndex(player =>
player.Nickname)
.IsUnique();
entity.Property(player =>
player.Level)
.IsRequired();
entity.Property(player =>
player.CreatedAtUtc)
.IsRequired();
});
}
}
}
GameDbContext는 DbContext를 상속받은 클래스로 EF Core의 데이터베이스 작업 공간이 됩니다.
public GameDbContext(
DbContextOptions<GameDbContext> options)
: base(options)
DbComtextOptions에는 데이터베이스 연결에 필요한 정보가 들어갑니다.
이 설정은 나중에 Program.cs에서 등록하고 DI가 전달하게 됩니다.
public DbSet<PlayerEntity> Players =>
Set<PlayerEntity>();
이 속성은 나중에 MySQL에 들어가는 Players 테이블과 연결됩니다.
protected override void OnModelCreating(
ModelBuilder modelBuilder)
Entity가 데이터베이스에 어떤 형태로 저장될지 설정하는 메소드입니다.
이를 Fluent API 설정이라고 합니다.
entity.ToTable("Players");
PlayerEntity를 Players라는 테이블로 생성합니다.
entity.HasKey(player =>
player.PlayerId);
PlayerId를 기본 키로 지정합니다.
기본 키는 각 행의 고유한 키로 같은 PlayerId는 없기 때문에 PlayerId를 기본 키로 지정합니다.
entity.Property(player =>
player.PlayerId)
.ValueGeneratedOnAdd();
새 플레이어가 추가될 때 MySQL이 ID를 자동으로 발급하도록 합니다.
entity.Property(player =>
player.Nickname)
.HasMaxLength(12)
.IsRequired();
닉네임의 최대 길이와 빈값 제한을 합니다.
DTO Validation이랑 비슷해보이지만, Validation은 API 요청을 확인하고 제약을 둔다면 이 코드는 데이터베이스의 열 구조 제약을 주기 때문에 다릅니다.
entity.HasIndex(player =>
player.Nickname)
.IsUnique();
같은 닉네임이 여러번 저장되지 않도록 막아주는 코드입니다.
이제 Program.cs에 DbContext를 등록하도록 하겠습니다.
using GameServer.Data;
using GameServer.Services;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
string connectionString =
builder.Configuration.GetConnectionString(
"GameDatabase")
?? throw new InvalidOperationException(
"GameDatabase 연결 문자열을 찾을 수 없습니다.");
builder.Services.AddDbContext<GameDbContext>(
options =>
options.UseMySQL(connectionString));
builder.Services.AddSingleton<
IPlayerService,
PlayerService>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Program.cs 코드를 다음과 같이 수정합니다.
string connectionString =
builder.Configuration.GetConnectionString(
"GameDatabase")
ConnectionStrings 설정 영역에서 해당 이름의 연결 문자열을 가져오는 기능입니다.
연결 문자열은 asp.net Core가 MySQL에 접속하기 위해 필요한 정보를 하나의 문자열로 표현한 것입니다.
?? throw new InvalidOperationException(
"GameDatabase 연결 문자열을 찾을 수 없습니다.");
만약 연결 문자열이 없다면 서버를 종료하는 코드입니다.
builder.Services.AddDbContext<GameDbContext>(
options =>
options.UseMySQL(connectionString));
DbContext를 등록하는 코드로 DI 컨테이너에 GameDbContext가 필요하면 MySQL Provider를 사용하고 연결 문자열을 생성하도록 규칙을 등록합니다.
dotnet ef migrations add InitialCreate
이제 PowerShell에 명령을 입력하여 첫 마이그레이션을 생성하도록 하겠습니다.

그러면 자동으로 마이그레이션 폴더가 생성됩니다.
dotnet ef database update
그 후 명령어를 입력하면 EF Core가 연결 문자열을 가져와 MySQL에 접속하여 적용되지 않는 마이그레이션을 확인하고 Players 테이블을 생성합니다. 명령이 끝나면 데이터베이스 구조가 생성됩니다.
& "C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe" -u game_server_app -p
MySQL에 접속하여 데이터베이스를 선택합니다.
USE game_server;
SHOW TABLES;
테이블 목록을 확인해보면

사진처럼 players 테이블이 있습니다.
DESCRIBE Players;
구조를 확인해보면

아까 만든 데이터들로 열이 구성되어 있습니다.
보면 데이터가 전부 비어있는 모습을 볼 수 있습니다. 지금은 데이터베이스를 만들었지만 여전히 플레이어 생성은 List를 사용하고 있기 때문입니다.
이제 PlayerService를 MySQL 기반으로 바꿔보겠습니다.
먼저 GameDbContext와 Service의 관계에 대해 알아보겠습니다.
GameDbContext는 EF Core를 활용하여 MySQL과 통신하는 객체이므로 서비스에서 플레이어를 조회한다면 List를 쓰는게 아닌 GameDbContext를 통해 Players 테이블에 정보를 가져와야합니다.
그리고 PlayerService를 이제 Singleton에서 Scoped로 바꿔야합니다.
왜냐하면 DbContext는 수명은 기본적으로 Scoped이기 때문입니다. PlayerService가 데이터베이스 정보를 사용하려면 GameDbContext가 필요한데 Scoped는 HTTP 요청이 들어올때마다 생성되고 요청이 종료되면 삭제됩니다. 이렇게 되면 서비스에게 DbContext를 주입하려고 해도 수명이 맞지 않게됩니다. 따라서 수명을 Scoped로 바꿔야합니다.
이제 IPlayerService부터 수정해보겠습니다.
using GameServer.Contracts;
namespace GameServer.Services;
public interface IPlayerService
{
Task<PlayerSummaryResponse?> GetByIdAsync(
int playerId,
CancellationToken cancellationToken);
Task<PlayerSummaryResponse?> TryCreateAsync(
string nickname,
CancellationToken cancellationToken);
}
메소드가 전부 비동기처리가 되었습니다. 원래는 메소드를 호출하면 바로 결과 반환이 이루어졌지만 이제는 MySQL 응답을 기다려야 하므로 Task를 반환하여 반환이 될때까지 다른 작업을 처리할 수 있도록 비동기로 제작하였습니다.
CancellationToken은 현재 HTTP 요청이 취소되었다면 이를 데이터베이스 작업에 전달하여 불필요하게 데이터베이스 조회를 하지 않도록 하기 위해서입니다.
using GameServer.Contracts;
using GameServer.Data;
using GameServer.Entities;
using Microsoft.EntityFrameworkCore;
namespace GameServer.Services;
public sealed class PlayerService : IPlayerService
{
private readonly GameDbContext _dbContext;
public PlayerService(GameDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<PlayerSummaryResponse?> GetByIdAsync(
int playerId,
CancellationToken cancellationToken)
{
PlayerEntity? entity =
await _dbContext.Players
.FirstOrDefaultAsync(
player => player.PlayerId == playerId,
cancellationToken);
if (entity is null)
{
return null;
}
return ToResponse(entity);
}
public async Task<PlayerSummaryResponse?> TryCreateAsync(
string nickname,
CancellationToken cancellationToken)
{
bool nicknameExists =
await _dbContext.Players.AnyAsync(
player => player.Nickname == nickname,
cancellationToken);
if (nicknameExists)
{
return null;
}
var entity = new PlayerEntity
{
Nickname = nickname,
Level = 1,
CreatedAtUtc = DateTime.UtcNow
};
_dbContext.Players.Add(entity);
await _dbContext.SaveChangesAsync(
cancellationToken);
return ToResponse(entity);
}
private static PlayerSummaryResponse ToResponse(
PlayerEntity entity)
{
return new PlayerSummaryResponse
{
PlayerId = entity.PlayerId,
Nickname = entity.Nickname,
Level = entity.Level
};
}
}
PlayerService도 위에처럼 수정해줍니다.
private readonly GameDbContext _dbContext;
public PlayerService(GameDbContext dbContext)
{
_dbContext = dbContext;
}
dbContext가 필요하기 때문에 DI 컨테이너에게 의존성 주입을 받습니다.
PlayerEntity? entity =
await _dbContext.Players
.FirstOrDefaultAsync(
player => player.PlayerId == playerId,
cancellationToken);
MySQL의 Players 테이블에서 PlayerId가 요청받은 ID와 같은 첫 번째 플레이어를 비동기로 찾는 코드입니다.
_dbContext.Players가 전에 만들었던 DbContext의 DbSet<PlayerEntity>로 MySQL의 Players 테이블입니다.
private static PlayerSummaryResponse ToResponse(
PlayerEntity entity)
{
return new PlayerSummaryResponse
{
PlayerId = entity.PlayerId,
Nickname = entity.Nickname,
Level = entity.Level
};
}
이 메소드는 Entity를 Response로 변환하는 역할을 합니다. 왜 Entity를 Response로 변환할까요?
Entity는 데이터베이스에서 사용하기 위한 객체입니다. 하지만 클라이언트와 서버가 통신하기 위해서는 Entity가 아니라 Response가 필요합니다.따라서 찾은 Entity를 Response로 변환하여 전달하기 위해 만들었습니다.
public async Task<PlayerSummaryResponse?> TryCreateAsync(
string nickname,
CancellationToken cancellationToken)
{
bool nicknameExists =
await _dbContext.Players.AnyAsync(
player => player.Nickname == nickname,
cancellationToken);
if (nicknameExists)
{
return null;
}
var entity = new PlayerEntity
{
Nickname = nickname,
Level = 1,
CreatedAtUtc = DateTime.UtcNow
};
_dbContext.Players.Add(entity);
await _dbContext.SaveChangesAsync(
cancellationToken);
return ToResponse(entity);
}
이 메소드는 새로운 플레이어 생성을 처리하는 기능을 합니다.
bool nicknameExists =
await _dbContext.Players.AnyAsync(
player => player.Nickname == nickname,
cancellationToken);
먼저 중복된 닉네임이 있는지를 확인합니다. AnyAsync()로 조건에 해당하는 데이터가 하나라도 존재하는지 확인합니다.
중복이라면 null을 반환하고 아니라면 새로운 플레이어 생성을 합니다.
var entity = new PlayerEntity
{
Nickname = nickname,
Level = 1,
CreatedAtUtc = DateTime.UtcNow
};
새로운 PlayerEntity를 생성합니다. 이때 PlayerID는 MySQL에서 자동으로 발급하기 때문에 다른것만 추가합니다.
_dbContext.Players.Add(entity);
이후 Add 메소드를 실행하는데 이는 MySQL에 추가한다는 뜻이 아니라 이 Entity가 새로 추가될 데이터라고 DbContext에게 알려주는 기능입니다. EF Core는 이 Entity를 Added 상태로 추적합니다.
await _dbContext.SaveChangesAsync(
cancellationToken);
실제 저장은 SaveChangesAsync()에서 하며 EF Core가 추적중인 변경사항을 실제 데이터베이스에 반영합니다. 지금은 새로 추가된 Entity를 데이터베이스에 추가하는 역할입니다.
return ToResponse(entity);
마지막으로 생성된 Entity를 Response로 변환하여 반환합니다.
기존에는 lock이 필요했지만 이제는 lock이 필요없어지게 되었습니다.
원래 List에서 저장하는 형식에 Singleton 수명이였기에 동시에 요청이 들어오면 먼저 들어온 요청을 처리하도록 해야했지만 Scoped 수명에 MySQL에 데이터를 저장하기 때문에 lock 코드가 필요없어지게 되었습니다.
using GameServer.Contracts;
using GameServer.Services;
using Microsoft.AspNetCore.Mvc;
namespace GameServer.Controllers;
[ApiController]
[Route("api/players")]
public sealed class PlayersController : ControllerBase
{
private readonly IPlayerService _playerService;
public PlayersController(
IPlayerService playerService)
{
_playerService = playerService;
}
[HttpGet("{playerId}")]
public async Task<ActionResult<PlayerSummaryResponse>>
GetById(
[FromRoute] int playerId,
CancellationToken cancellationToken)
{
if (playerId <= 0)
{
var error = new ApiErrorResponse
{
Code = "INVALID_PLAYER_ID",
Message = "플레이어 ID는 1 이상이어야 합니다."
};
return BadRequest(error);
}
PlayerSummaryResponse? player =
await _playerService.GetByIdAsync(
playerId,
cancellationToken);
if (player is null)
{
var error = new ApiErrorResponse
{
Code = "PLAYER_NOT_FOUND",
Message = "플레이어를 찾을 수 없습니다."
};
return NotFound(error);
}
return Ok(player);
}
[HttpPost]
public async Task<ActionResult<PlayerSummaryResponse>>
Create(
[FromBody] CreatePlayerRequest request,
CancellationToken cancellationToken)
{
PlayerSummaryResponse? player =
await _playerService.TryCreateAsync(
request.Nickname,
cancellationToken);
if (player is null)
{
var error = new ApiErrorResponse
{
Code = "NICKNAME_ALREADY_EXISTS",
Message = "이미 사용 중인 닉네임입니다."
};
return Conflict(error);
}
return CreatedAtAction(
nameof(GetById),
new { playerId = player.PlayerId },
player);
}
}
Service가 수정되었기 때문에 Controller도 수정해주도록 하겠습니다. 변경된 점은 Action이 비동기로 변경되고, 서비스 호출에 await이 붙게 되었습니다. 그 외에 코드는 기존과 같습니다.
이제 유니티에서 정상적으로 플레이어가 잘 생성되는지 확인해보겠습니다.




플레이어 생성과 중복닉네임, 공백 처리 모두 잘 됩니다. 이제 데이터베이스 연동을 했으니 DB에 정상적으로 저장이 되었는지도 확인해보겠습니다.
& "C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe" -u game_server_app -p

데이터베이스에도 잘 저장이 되었습니다.
이제 서버를 종료했다가 다시 실행하고
GET http://localhost:5098/api/players/1

서버를 껐다가켜도 플레이어 데이터가 정상적으로 남아있습니다.
데이터베이스 연동 부분이 좀 오래걸리긴 했지만 그래도 완성해서 뿌듯하네요 남은 기한이 별로 없어서 계정 로그인 정도만 완성이 될것 같지만 또 계속 공부하면서 새로운 기능들을 만들어야겠습니다.