진행했던 프로젝트 DungeonWar API
는 특정한 환경을 가정하고 있습니다.
이 때 데이터베이스의 부하가 증가하면 성능 저하가 크게 발생할 수 있습니다. 또한, 데이터베이스 샤딩이 가능하더라도, 한 번의 요청에 서로 다른 데이터베이스를 접근해야 한다면 서버에서 롤백을 수행하는 것이 합리적일 것입니다
데이터베이스의 부하를 줄이기 위해, 서버에서 트랜잭션 처리를 하게 구성하였습니다. 요청 처리 중 오류가 발생하는 경우, 그 동안의 작업은 서버에서 롤백 합니다.
public async Task<ReceiveMailItemResponse> Post(ReceiveMailItemRequest request)
{
var authenticatedUserState = HttpContext.Items[nameof(AuthenticatedUserState)] as AuthenticatedUserState;
var response = new ReceiveMailItemResponse();
if (authenticatedUserState == null)
{
response.Error = ErrorCode.WrongAuthenticatedUserState;
return response;
}
var gameUserId = authenticatedUserState.GameUserId;
var mailId = request.MailId;
// 메일 상태를 '받음'으로 바꾸고
var errorCode = await _mailDataCRUD.UpdateMailStatusToReceivedAsync(gameUserId, mailId);
if (errorCode != ErrorCode.None)
{
response.Error = errorCode;
return response;
}
(errorCode, var items) = await _mailDataCRUD.LoadMailItemsAsync(gameUserId, mailId);
if (errorCode != ErrorCode.None)
{
// 원상태로 롤백
await _mailDataCRUD.RollbackMailStatusAsync(gameUserId, mailId);
response.Error = errorCode;
return response;
}
//아래에서 살펴볼 함수
errorCode = await _itemDataCRUD.InsertItemsAsync(gameUserId, items);
if (errorCode != ErrorCode.None)
{
// 원상태로 롤백
await _mailDataCRUD.RollbackMailStatusAsync(gameUserId, mailId);
response.Error = errorCode;
return response;
}
_logger.ZLogInformationWithPayload(new { GameUserId = gameUserId, MailId=mailId }, "ReceiveMailItem Success");
response.Error = errorCode;
return response;
}
한 번에 여러 요청이 발생하여 사용자의 데이터 일관성이 무너지는 상황도 예측해 볼 수 있습니다.
이 경우는 개발된 게임 서버에서는 자신의 데이터만 접근 가능하며, 자신의 데이터가 아닌 경우 요청이 거부됩니다. 따라서 이 문제에 대해 걱정할 필요는 없습니다.
인증된 클라이언트만 데이터 수정이 가능합니다. 이를 통해 외부로부터의 무단 수정을 방지하였습니다.
redis에 요청 처리 시작 시 키값을 저장하고 응답 후 해제하는 방식의 락을 활용하여 한 번에 하나의 사용자 요청만 처리하도록 설정했습니다.
앞서 본 코드로 일반적인 상황에서의 롤백을 수행하였습니다. 하지만 개발 중 더 복잡한 상황에 직면하였습니다.
// InsertItemsAsync 코드 중 일부
List<Func<Task>> rollbackActions = new List<Func<Task>>();
foreach (var item in items)
{
ErrorCode errorCode= await AddItemBasedOnCodeAsync(gameUserId,item.ItemCode,item.ItemCount,rollbackActions);
if (errorCode != ErrorCode.None)
{
await RollbackReceiveItemAsync(rollbackActions);
//로깅 생략
return ErrorCode.InsertItemFailInsert;
}
}
여러 아이템을 동시에 수령해야 할 때 ItemCode
에 따라 동작이 달라집니다. ItemCode
는 동적인 상황에서 결정되므로 예측이 불가능하며, 이 때문에 하나의 동일한 Rollback
으로 처리할 수 없습니다.
private async Task<ErrorCode> AddItemBasedOnCodeAsync(Int32 gameUserId, Int32 itemCode ,Int32 itemCount, List<Func<Task>> rollbackActions)
{
ErrorCode errorCode= ErrorCode.None;
if (itemCode == (int)ItemCode.Gold)
{
errorCode = await IncreaseGoldAsync(gameUserId, itemCount,rollbackActions);
}
else if (itemCode == (int)ItemCode.Potion)
{
errorCode = await IncreasePotionAsync(gameUserId, itemCount,rollbackActions);
}
else
{
errorCode = await InsertOwnedItemAsync(gameUserId, itemCode, itemCount, 0,rollbackActions);
}
return errorCode;
}
따라서, 각 함수가 성공적으로 동작했을 때 롤백을 리스트에 등록하는 방식을 사용했습니다.
private async Task<ErrorCode> IncreaseGoldAsync(Int32 gameUserId, Int32 itemCount, List<Func<Task>> rollbackActions)
{
var count = await _queryFactory.Query("user_data").Where("GameUserId", "=", gameUserId)
.IncrementAsync("Gold", itemCount);
if (count != 1)
{
// 로깅, 반환
}
// 성공 시 롤백 등록
rollbackActions.Add(async () =>
{
var rollbackCount = await _queryFactory.Query("user_data").Where("GameUserId", "=", gameUserId)
.DecrementAsync("Gold", itemCount);
if (rollbackCount != 1)
{
_logger.ZLogErrorWithPayload(
new
{
ErrorCode = ErrorCode.RollbackIncreaseGoldFail,
GameUserId = gameUserId,
ItemCount = itemCount
}, "RollbackIncreaseGoldFail");
}
});
return ErrorCode.None;
}
Insert
과정 중 오류가 발생하더라도, 현재까지 성공적으로 수행된 작업에 대응하는 롤백이 이미 등록되어 있으므로, 다음과 같은 방법으로 처리할 수 있습니다.
private async Task RollbackReceiveItemAsync(List<Func<Task>> rollbackActions)
{
// 필요 시 역순
foreach (var action in rollbackActions)
{
await action();
}
}