트랜잭션 처리 에러를 겪고 있습니다. (내용 수정)

  • 뒤끝 SDK 버전 : 5.11.3
  • 프로젝트명 : ttheroes

안녕하세요. 비동기 트랜잭션 처리 이슈에 대해 문의드립니다.

트랜잭션 관리 시나리오는 다음과 같습니다.

  1. 쓰기 요청마다 id를 받아 중복 키가 있으면 덮어쓰는 방식으로 딕셔너리에 보존
  2. 해소 요청이 들어오면 딕셔너리를 10개 단위로 묶어 트랜잭션 요청
  3. 요청한 항목들을 딕셔너리에서 제거
  4. 전체 해소 요청이 들어오면 딕셔너리 갯수가 0이 될 때까지 해소 요청을 반복

트랜잭션 해소 시나리오는 다음과 같습니다.

  1. 매 300초 마다 비동기 형식으로 트랜잭션 딕셔너리 전체 해소 요청 (SendQueue 또는 콜백)
  2. 앱이 종료될 때 동기 형식으로 트랜잭션 딕셔너리 전체 해소 요청 (콜백 없이 요청)

시나리오 별 에러는 다음과 같습니다.

  1. 실행 중 매 n초 마다 비동기 트랜잭션
    * 스테이터스 코드 : 400
    * 에러 코드 : TransactionSizeError
    * 에러 메시지 : Not Support Transaction Size: 0
  2. 종료 시 동기 트랜잭션
    * 스테이터스 코드 : 404
    * 에러 코드 : NotFoundException
    * 에러 메시지 : gameinfo not found, gameinfo을(를) 찾을 수 없습니다

관련해서 제가 작성한 트랜잭션 매니저 코드는 다음과 같습니다.

using System;
using System.Collections.Generic;
using UnityEngine;
using Sirenix.OdinInspector;
using BackEnd;

// ReSharper disable once CheckNamespace
public class BackEndTransactionManager : MonoBehaviour
{
    #region Editor
    [TitleGroup("Settings")]
    [SerializeField] private DataHandlerSetContentMethod repeativeFlushMethod;
    [SerializeField] private DataHandlerSetContentMethod closingFlushMethod;
    [SerializeField] private bool flushAll = true;
    [SerializeField] private float initialflushInterval = 60f;
    [SerializeField] private float continuousflushInterval = 300f;
    [SerializeField, ReadOnly] private float flushInterval;
    [SerializeField, ReadOnly] private float flushDelta;
    
    [TitleGroup("Dev")]
    [SerializeField] private bool logAddingJob;
    [SerializeField] private bool logReplacingJob;
    [SerializeField] private bool logQueueingJobs;
    [SerializeField] private bool logQueuedJobs;
    #endregion
    
    #region Lifecycle
    public static BackEndTransactionManager Instance { get; private set; }

    private void Awake()
    {
        Instance = this;
        UnityEngine.Object.DontDestroyOnLoad(this.gameObject);
        this.flushInterval = this.initialflushInterval;
    }

    private void Update()
    {
        if (this.flushDelta < this.flushInterval)
        {
            this.flushDelta += Time.unscaledDeltaTime;
            return;
        }

        this.flushInterval = this.continuousflushInterval;
        this.flushDelta = 0f;
        if (this.flushAll)
            this.FlushAll(this.repeativeFlushMethod);
        else
            this.Flush(this.repeativeFlushMethod);
    }

    private void OnApplicationQuit()
    {
        this.FlushAll(this.closingFlushMethod);
    }
    #endregion

    #region Internal
    private readonly Dictionary<string, TransactionRegistry> queue = new Dictionary<string, TransactionRegistry>();
    private readonly List<string> keyBuffer = new List<string>();
    private readonly List<TransactionValue> valueBuffer = new List<TransactionValue>();
    private readonly List<Action<bool, object>> callbackBuffer = new List<Action<bool, object>>();

    private class TransactionRegistry
    {
        public TransactionValue Value;
        public Action<bool, object> Callback;

        public TransactionRegistry(TransactionValue value, Action<bool, object> callback)
        {
            Value = value;
            Callback = callback;
        }
    }

    private void OnTransactionResult(DataHandlerSetContentMethod method, BackendReturnObject transactionResult, IEnumerable<Action<bool, object>> callbacks)
    {
        var result = transactionResult.IsSuccess();
        var message = transactionResult.ToString();
        foreach (var callback in callbacks)
            callback?.Invoke(result, message);

        if (!this.logQueuedJobs) return;
        if (result)
            UnityEngine.Debug.Log($"[{nameof(BackEndTransactionManager)}.{nameof(Flush)}][{method}] success : {transactionResult}");
        else
            UnityEngine.Debug.LogError($"[{nameof(BackEndTransactionManager)}.{nameof(Flush)}][{method}] fail. {transactionResult}");
    }
    #endregion

    #region API
    public void Enqueue(string id, TransactionValue job, Action<bool, object> callback)
    {
        if (this.queue.TryGetValue(id, out var duplicate))
        {
            duplicate.Value = job;
            duplicate.Callback = callback;
            if (this.logReplacingJob)
                UnityEngine.Debug.Log($"[{nameof(BackEndTransactionManager)}.{nameof(Enqueue)}] replacing job : {id}");
        }
        else
        {
            this.queue.Add(id, new TransactionRegistry(job, callback));
            if (this.logAddingJob)
                UnityEngine.Debug.Log($"[{nameof(BackEndTransactionManager)}.{nameof(Enqueue)}] adding job : {id}");
        }
    }

    public void Flush(DataHandlerSetContentMethod method)
    {
        if (this.logQueueingJobs)
            UnityEngine.Debug.Log($"[{nameof(BackEndTransactionManager)}.{nameof(Flush)}] flushing transactions : {this.queue.Keys.ToArray().Join(",")}");

        foreach (var (id, registry) in this.queue)
        {
            this.keyBuffer.Add(id);
            this.valueBuffer.Add(registry.Value);
            this.callbackBuffer.Add(registry.Callback);
            if (this.valueBuffer.Count >= 10) break;
        }
        
        foreach (var id in this.keyBuffer) this.queue.Remove(id);
        var callbacks = this.callbackBuffer.ToArray();

        switch (method)
        {
            case DataHandlerSetContentMethod.Sync:
                var syncedResult = Backend.GameData.TransactionWriteV2(this.valueBuffer);
                this.OnTransactionResult(method, syncedResult, callbacks);
                break;
            case DataHandlerSetContentMethod.Async:
                Backend.GameData.TransactionWriteV2(
                    this.valueBuffer,
                    (asyncedResult) => this.OnTransactionResult(method, asyncedResult, callbacks));
                break;
            case DataHandlerSetContentMethod.Queue:
                SendQueue.Enqueue(
                    Backend.GameData.TransactionWriteV2,
                    this.valueBuffer,
                    (queuedResult) => this.OnTransactionResult(method, queuedResult, callbacks));
                break;
        }

        this.keyBuffer.Clear();
        this.valueBuffer.Clear();
        this.callbackBuffer.Clear();
    }

    public void FlushAll(DataHandlerSetContentMethod method)
    {
        if (this.queue.Count < 1) return;
        var count = this.queue.Count;
        var i = 0;
        while (this.queue.Count > 0)
        {
            i++;
            this.Flush(method);
            UnityEngine.Debug.LogWarning($"[{nameof(BackEndTransactionManager)}.{nameof(FlushAll)}] flushing loop {i}... ({this.queue.Count}/{count} jobs remaining)");
        }
    }
    #endregion
}

경과 보고:

  1. 400 에러는 Backend.GameData.TransactionWriteV2(this.valueBuffer.ToArray())와 같이 콜렉션을 복제하는 것으로 넘어갈 수 있었습니다. 딕셔너리를 통해 큐를 쌓고 원하는 수 만큼만 빼내는 과정에서 매니저 필드로 선언한 valueBuffer를 사용했는데, 이터레이션 과정에서 해당 버퍼를 비운 것이 영향을 끼친 것으로 보입니다. (관련해서 Backend.GameData.TransactionWriteV2 메서드를 따라가보니 콜렉션의 복제가 await 안쪽 스코프에서 일어나고 있었습니다. BGameData.cs:359에서 복제하는 편이 안전하지 않을까 생각합니다.)
  2. 전항에서의 처리에 따라 동기/비동기를 가리지 않고 404 에러가 떴습니다. 개발문서의 코드 설명(gameinfo not found)에 따르면 스키마가 선언되지 않은 테이블에서 Where() 절을 사용하는 경우에 발생하는 에러라고 나와있고, 현재 저희 상황과 일치합니다.

후속 문의:
스키마를 선언하지 않았으나 내부 구조를 바탕으로 조건 검색을 하고자 할 때 권장되는 패턴이 있는지 궁금합니다.

(추가 : 각 테이블에 스키마를 설정해서 쓰기/읽기가 잘 되는 것을 확인했습니다.)

안녕하세요 개발자님,
Not Support Transaction Size: 0 에러의 경우 트랜잭션 리스트에 작업이 존재하지 않기에 발생하는 에러입니다.
작업 리스트가 없이 요청될 수 있는 상황이 있는지 확인해 주시면 감사하겠습니다.

추가로 문의해주신 gameinfo not found, gameinfo을(를) 찾을 수 없습니다 에러는
owner_inDate key를 제외한 Where 절 사용의 경우, 데이터가 많아지면 해당 데이터를 찾는데 긴 시간이 소요되게 되면서 트랜잭션이 실패할 확률이 높습니다.
데이터가 많으면 많을수록 검색 과정에서 발생되는 DB 처리량으로 요금 또한 부담이 될 수 있는 부분이기에
필요한 데이터를 찾아야 된다면 적절한 상황에 맞추어 랜덤조회와 같은 기능을 복합적으로 사용하여 데이터를 불러오는 것을 추천드립니다.

@Ransiee 앞선 포스트에 적은 대로, 쓰기 요청을 바로 처리하지 않고 딕셔너리에 쌓아둔 후 딕셔너리를 순회하면서 트랜잭션을 요청하는 방식을 구현했던지라, 트랜잭션 요청 이후의 순회 과정에서 버퍼를 비운 것이 트랜잭션 요청 내부에서의 비동기 처리 시점(await 절)에서는 콜렉션이 비워진 것으로 간주된 것 같습니다.

inDate 필드를 이용한 쿼리를 권장하신 것에 대해서는 추가로 질문이 있습니다. owner_inDate 필드가 primary key 역할을 하는 것으로 이해하면 될까요? 중복 위험은 없는지 궁금합니다.

안녕하세요 개발자님.
추가로 질문 주신 사항 확인하여 답변드립니다.

inDate는 유니크한 값을 가지는 데이터로
유저 inDate에 해당하는 owner_inDate는 유니크한 값을 가지며, primary key 역할을 하고 있습니다.
다만, 게임 데이터는 inDate가 중복되는 경우도 드물지만 간혹 존재합니다.
현재는 모든 데이터를 검색할 때에는 유저 inDate와 데이터의 inDate가 필수로 들어가니 이 점 참고해 주시면 감사하겠습니다.