[서버캠퍼스 1기] 개발 중 고민했던 것들 3 (Rollback)

Oak_Cassia·2023년 5월 22일
0

서버에서 롤백을 하는 이유

진행했던 프로젝트 DungeonWar API는 특정한 환경을 가정하고 있습니다.

  1. Scale Out 가능한 서버
  2. 샤딩 불가능한 데이터베이스

이 때 데이터베이스의 부하가 증가하면 성능 저하가 크게 발생할 수 있습니다. 또한, 데이터베이스 샤딩이 가능하더라도, 한 번의 요청에 서로 다른 데이터베이스를 접근해야 한다면 서버에서 롤백을 수행하는 것이 합리적일 것입니다

데이터베이스의 부하를 줄이기 위해, 서버에서 트랜잭션 처리를 하게 구성하였습니다. 요청 처리 중 오류가 발생하는 경우, 그 동안의 작업은 서버에서 롤백 합니다.

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;
    }

한 번에 여러 요청이 발생하여 사용자의 데이터 일관성이 무너지는 상황도 예측해 볼 수 있습니다.
이 경우는 개발된 게임 서버에서는 자신의 데이터만 접근 가능하며, 자신의 데이터가 아닌 경우 요청이 거부됩니다. 따라서 이 문제에 대해 걱정할 필요는 없습니다.

  1. 인증된 클라이언트만 데이터 수정이 가능합니다. 이를 통해 외부로부터의 무단 수정을 방지하였습니다.

  2. 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();
	}
}
profile
어떻게 살아야 하나

0개의 댓글