본문 바로가기
프로젝트/Wild Bowl : Zooports

상점 데이터 갱신 방식 개선

by argentdarae 2025. 1. 4.
반응형

개요

 

Wild Bowl : Zooports의 상점 구조를 UniRx를 활용해 리팩토링하고, 데이터 갱신 방식을 개선한 경험을 기록하기 위한 글이다.

 

 

내용

배경

게임 초기에 상품 구성은 고정되어 있었다. 하지만 일정 시간마다 상품이 새로고침되거나 특정 레벨에 도달하는 등 특정 조건에 따라 상품이 교체되는 기능을 구현해야했다.

구 상점 시스템 시퀀스

 

기존의 구조로 요구사항을 구현하는 데에는 여러 문제점이 있었고, 리팩토링할 필요가 있었다.

 

문제점

1. 데이터 처리와 UI가 결합된 구조로 인한 기능 확장 및 수정이 어려움

이전 구조는 데이터를 처리하는 비즈니스 로직과 UI가 강하게 결합되어 있었다. 그래서 새로운 기능을 추가하거나 기존 기능을 수정하기 어려웠다.

2. 불필요한 데이터 요청으로 서버와의 통신량 증가 및 쿼리 비용 증가

모든 상점 데이터를 한꺼번에 요청하는 기존 방식은 필요한 데이터만 선택적으로 갱신하지 못하고 불필요한 데이터를 반복적으로 요청했다. 이는 서버와 클라이언트 간의 통신량을 증가시켰고, 쿼리 비용을 불필요하게 발생시켰다.

3. 갱신 시간이 길어 사용자 경험에 부정적 영향 발생

기존 구조에서는 데이터 갱신이 완료될 때까지 유저들은 상점 페이지를 이용할 수 없었다. 이로 인해 사용자들은 갱신 과정에서 불편함을 겪었고, 상점 이용이 중단되는 시간이 길어질수록 부정적인 사용자 경험으로 이어질 가능성이 높았다.

 

해결 과정

1. MVP 패턴을 이용한 구조 리팩토링으로 결합도 감소

UniRx를 이용하고 MVP 패턴을 참고하여 리팩토링을 진행하였다.

 

Model

데이터를 관리한다. 유저 데이터(예: 유저 재화 등)와 관련된 정보나 앱 내에서 UI를 관리하는 Rx와 관련된 멤버 변수도 관리한다.

 

모델의 특정 멤버 변수를 외부에서 직접 접근하도록 public으로 노출하는 것은 캡슐화에 위배되므로, 이를 안전하게 다룰 수 있도록 CRUD 원칙에 따라 메서드를 추가하였다. 또한, 데이터 처리 외의 로직은 외부에서 처리하거나 별도의 헬퍼 클래스를 통해 구현하였다.

// Model 구현 예시
public class MDL_ExpTokenUI
{
    private readonly LobbyInitializeFlag _flag = LobbyInitializeFlag.UserExpToken;
    private readonly Dictionary<ExpTokenType, int> _tokenDic = new Dictionary<ExpTokenType, int>();
    
    private readonly Subject<bool> _isExpPackPageActive = new Subject<bool>();
    public IObservable<bool> IsExpPackPageActive => _isExpPackPageActive;

    public LobbyInitializeFlag Init(IEnumerable<UserAssetExpTokenEntity> entities)
    {
        foreach (var entity in entities)
        {
            _tokenDic.Add(entity.ExpTokenType, entity.Quantity);
        }

        return _flag;
    }

    public int GetTotalAmount()
    {
        int count = 0; 
        foreach (var elem in _tokenDic)
        {
            count += elem.Value;
        }

        return count;
    }
    
    public IReadOnlyDictionary<ExpTokenType, int> GetTokenDicRef() => _tokenDic;
    
    public void AdjustTokenAmount(ExpTokenType type, int adjustmentAmount)
    {
        int prevAmount = _tokenDic[type];
        _tokenDic[type] = prevAmount + adjustmentAmount;
    }
    
    public void SetActive(bool status) => _isExpPackPageActive.OnNext(status);
}

 

View

View는 전달받은 데이터를 UI에 표시하는 역할을 담당한다. Model에서 처리된 데이터를 바탕으로 사용자와의 상호작용이 이루어지며, UI 요소의 상태를 업데이트하거나 사용자 입력을 Presenter에 전달한다.

// View 구현 예시
public sealed class VW_ExpTokenUIController : View
{
    // ...

    public Button GetHomeButRef() => _home;
    public Button GetSilverUseButRef() => _silverExpGoodsUse;
    public Button GetGoldUseButRef() => _goldExpGoodsUse;
    public Button GetSpecialUseButRef() => _specialExpGoodsUse;
    
    public void SetExpTokenAmount(ExpTokenType type, int amount)
    {
        string amountText = ZString.Format("{0}", amount);

        switch (type)
        {
            case ExpTokenType.SilverToken:
                _silverAmount.text = amountText;
                break;

            case ExpTokenType.GoldToken:
                _goldAmount.text = amountText;
                break;

            case ExpTokenType.PlatinumToken:
                _specialAmount.text = amountText;
                break;

            case ExpTokenType.All:
            case ExpTokenType.NULL:
            default:
        	Debug.Assert(false, ZString.Format("유효하지 않은 ExpTokenType입니다. 현재 값: {0}. 처리 가능한 값이 아닙니다.", type));
            	break;
        }
    }
    
    // ...
}

 

Presenter

Model과 View 사이의 중개자로서 다음과 같은 역할을 수행하였다.

  1. 데이터 흐름 관리: View에서 발생한 사용자 입력을 처리하여 Model에 전달하고, Model의 데이터를 가공하여 View에 전달한다.
  2. UniRx를 활용한 이벤트 처리: View의 UI 컴포넌트와 Model의 이벤트를 UniRx로 구독하여, 입력 또는 데이터 변경 시 즉각적으로 서로 연동되도록 구현하였다.

이러한 기준을 바탕으로 구현함으로써, Model과 View의 결합을 줄이고 각자의 역할에 집중할 수 있는 구조를 만들었다.

// Presenter 구현 예시
public sealed class PR_ExpTokenUIController : Presenter
{
    // ...
    
    private MDL_ExpTokenUI _expTokenUI;
    
    protected override void InitializeRx()
    {
    	// ...
        
        _view.GetHomeButRef().OnClickAsObservable()
            .Subscribe(_ => OnClickReturnLobby())
            .AddTo(this);

        _view.GetSilverUseButRef().OnClickAsObservable()
            .Subscribe(_ => ActivateExpToken(ExpTokenType.SilverToken))
            .AddTo(this);

        _view.GetGoldUseButRef().OnClickAsObservable()
            .Subscribe(_ => ActivateExpToken(ExpTokenType.GoldToken))
            .AddTo(this);

        _view.GetSpecialUseButRef().OnClickAsObservable()
            .Subscribe(_ => ActivateExpToken(ExpTokenType.PlatinumToken))
            .AddTo(this);

        _expTokenUI.UpdateTokenAmount
            .Subscribe(_ => UpdateTokenAmount())
            .AddTo(this);
    }

    private void OnClickReturnLobby()
    {
        _expTokenUI.SetActive(false);
        LocalModel.GetLockerRoomUI().SetActive(false);
    }

    private void ActivateExpToken(ExpTokenType tokenType)
    {
        _expTokenUI.SetSelectExpToken(tokenType);
        _expTokenUI.SetViewportActive(true);
    }

    private void UpdateTokenAmount()
    {
        var tokenDic = _userExpToken.GetTokenDicRef();
        foreach (var elem in tokenDic)
        {
            ExpTokenType type = elem.Key;
            int amount = elem.Value;

            _view.SetExpTokenAmount(type, amount);
        }
    }
    
    // ...
}

 

이후 완성된 구조는 다음과 같다.

신 상점 시스템 시퀀스

 

2. 데이터 갱신 트리거 발생 시 필요한 데이터만 요청하도록 설계

기존에는 상점의 모든 데이터를 한꺼번에 요청하여 서버와 클라이언트 간의 통신량이 증가하고, 불필요한 데이터 처리로 인해 성능이 저하되는 문제가 있었다.

 

이를 개선하기 위해 데이터 갱신 트리거가 발생했을 때 필요한 데이터만 선택적으로 요청하도록 설계하였다. 

 

그리고 이를 구현한 코드의 핵심적인 부분은 다음과 같다.

// PR_Shop 클래스 내부

// ...

private readonly Dictionary<ShopPannelType, Action> SHOP_DATA_HANDLERS = new Dictionary<ShopPannelType, Action>
{
    { ShopPannelType.CharacterAndSkin, SetCharData },
    { ShopPannelType.RuneAndBallPoint, SetRuneAndBallPointData },
    { ShopPannelType.Package, SetPackageData }
};

private async UniTaskVoid UpdateShopDataAsync(ShopPannelType panelType)
{
	SetDeniedStatusForShopPanels(panelType, true);

    bool isUpdateSuccess = await UserModel.GetDailyShopRef().UpdateShopDataAsync(panelType);
    if (isUpdateSuccess)
    {
        if (isUpdateSuccess)
        {
            Debug.Assert(SHOP_DATA_HANDLERS.ContainsKey(panelType), $"No handler defined for panel type: {panelType}");

            // Live Build에서는 키가 반드시 존재한다 가정하고 바로 호출
            SHOP_DATA_HANDLERS[panelType]?.Invoke();
        }
    }
    else
    {
        const string TEXT_UPDATE_FAIL = "Shop Item Update failed. Please try application restart.";
        LocalModel.GetNoticeUI().SetExplainText(TEXT_UPDATE_FAIL);
        LocalModel.GetNoticeUI().SetPanelActive(true);
    }
}

private void SetCharData()
{
    UserDailyShopCharacterEntity entity = _shopEntities.GetCharacterEntities();
    
    // null인 경우 구매 가능한 캐릭터가 없는 상황이기 때문에 UI를 비활성화
    if (entity == null)
    {
        _character.gameObject.SetActive(false);
        return;
    }

    Sprite charBigFrameSprite = AddressableOutGameSpriteManager.Ins.GetCharFrameSprite(entity.CharacterName);
    _characterPurchaseView.SetFrameSprite(charBigFrameSprite);
    UpdateShopItemData(entity, _characterPurchaseView);
}

// ...

 

코드는 다음의 순서로 동작한다.

  1. 데이터 로드 중 사용자가 상점을 이용하지 못하도록 Denied Panel을 활성화하여 안전한 갱신 과정을 보장
  2. 특정 ShopPannelType에 대한 데이터만 선택적으로 요청하여 통신량과 처리 비용을 최소화
  3. 서버에서 받은 새로운 데이터를 기반으로 UI를 초기화하여 최신 상태를 사용자에게 제공

이를 통해 상점 데이터 갱신이 필요할 때, 필요한 데이터만 요청하고 UI를 초기화하는 구조를 구현할 수 있었다.

 

3. 백엔드와 협업하여 특정 제품 갱신과 관련된 기능 구현

기존에는 모든 데이터를 한꺼번에 가져오는 로직만 존재했지만, 개별 엔티티에 대한 데이터를 선택적으로 가져올 필요성이 생겼다.

 

다만, 네트워크 통신 특성상 DB에서 상점 데이터가 아직 갱신되지 않았을 가능성을 고려해야 했다. 이를 해결하기 위해 상점 데이터를 지속적으로 요청하며, 데이터가 변경된 시점에서만 로직을 실행하도록 설계하였다.

데이터 요청 및 반환 시퀀스

 

이를 구현하기 위해 DB에서 상점 데이터의 버전을 관리하는 해시 값을 상점 초기화 시 저장하고, 요청 시마다 이 해시 값을 점검하였다. 해시 값이 변경될 때까지 데이터를 확인한 뒤 로직을 종료하도록 구성하였다.

 

다음은 이해를 돕기 위한 예제 코드이다.

// ShopUpdateHelper 클래스 내부

// ...

private readonly Dictionary<ShopPannelType, string> _shopHashes
	= new Dictionary<ShopPannelType, string>(INITIAL_SHOP_ENTITY_LIST_SIZE);

public async UniTask<bool> UpdateShopDataAsync(ShopPannelType type)
{
    if (type == ShopPannelType.Package)
    {
        return await UpdateShopPackageDataAsync();
    }

    int retryCount = 0;
    const int MAX_RETRY_COUNT = 20;
    const int DELAY_IN_SECONDS = 20;

    while (retryCount < MAX_RETRY_COUNT)
    {
        var responseResult = await ServerCommunicator.SendRequestAsync
            (APIEndPoint.USER_DAILY_SHOP, RESTApiMode.PATCH);

        if (responseResult.IsSuccess)
        {
            UserDailyShopRefreshQueryResponse response = responseResult.Data;

            _shopHashes.TryGetValue(type, out string currentHash);

            if (response.DailyShopPannelHash != currentHash)
            {
                ProcessGoodsByType(response.UserDailyShopGoodsEntities);
                _shopHashes[type] = response.DailyShopPannelHash;

                return true;
            }
            else
            {
                await UniTask.Delay(TimeSpan.FromSeconds(DELAY_IN_SECONDS));
                ++retryCount;
            }
        }
        else
        {
            string errorMessage = $"Failed to update shop data\n" +
                $"API Endpoint: {APIEndPoint.USER_DAILY_SHOP}\n" +
                $"Error: {responseResult.ErrorMessage}";

            Logger.LogError(errorMessage);

            return false;
        }
    }

    Logger.LogError($"Shop update failed after {MAX_RETRY_COUNT} retries");
    return false;
}

// ...

 

UpdateShopDataAsync 메서드가 false를 반환할 경우, 사용자에게 에러 메시지를 팝업으로 표시하고 게임은 계속 실행된다.

 

이 과정에서 크래시를 발생시켜 게임을 즉시 종료할지, 아니면 작업을 속행할지 고민했으나, 상점 콘텐츠는 게임의 코어 루프에 속하지 않는다고 판단했다. 따라서, 게임 종료 대신 재시작을 권장하는 메시지를 표시하고 진행을 유지하기로 결정하였다.

 

 

마무리

갱신 간격을 5분으로 설정한 테스트 영상

 

이러한 개선 작업을 통해 구조적인 결합도를 낮춰 유지보수성과 확장성을 향상시켰다. 또한, 필요한 데이터만 요청해 통신 속도와 쿼리 비용을 줄이고 갱신 도중에도 다른 상품을 이용할 수 있게 만들어 유저 편의성을 향상시켰다.

'프로젝트 > Wild Bowl : Zooports' 카테고리의 다른 글

WILD BOWL: ZOOPORTS 프로젝트 소개  (0) 2025.04.08
Xcode iOS 빌드 이슈 해결  (0) 2025.01.06
튜토리얼 시스템 구현  (0) 2025.01.05
인앱 결제 복구 이슈 해결  (0) 2025.01.05