-
회고록 - 서비스 내 재화 시스템 Cash도입나의 개발 이야기 2024. 2. 3. 18:28
Cash 시스템 도입
작년 제가 백엔드를 담당하고 있는 앱에 Cash와 비슷한 개념에 실제 돈을 주고 앱내 제화를 구매하여 사용하는 시스템이 도입되었습니다. 해당 프로젝트를 하면서 고민했던 사항에 대해 공유하는 글을 쓰고자 합니다. 설명의 편의성을 위해 이하 앱내 제화를 Cash라고 통일하여 명명하겠습니다.
어느 정도 돈으로 환원되는 부분이 있기 때문에 처음에는 해당 도메인을 구상할 때 작은 세상의 화폐를 설계한다는 생각으로 접근했습니다.
최초의 데이터 구조
최초의 생각한 데이터의 구조는 위와 같습니다. 통장과 같이 획득 내역과 사용내역을 +,- 연산해서 남는 금액이 최종 잔액이 되는 것입니다.
유효기간이 존재하는 Cash 그리고 우선순위
하지만 이 구조는 실제 화폐와 다르게 무료로 획득한 Cash경우 유효기간을 가진다는 특성 때문에 사용하기에 부적절하다는 판단을 하게 됩니다.
여기에 더해서 설명 하자면 Cash정책은 사용자들의 편의를 위해 Cash를 하나의 단위로 일원화 하려고 하였습니다. 하지만 실제로 Cash는 서버단 에서는 획득한 방법에 따라 3가지로 구분됩니다.
- 무료로 획득한 Cash
- 유료 구매 시 보너스로 획득한 Cash
- 유료 구매를 통해 획득한 Cash
이러한 구분을 하는 이유는 사용 우선순위 때문에 이었습니다. 정책상의 사용 우선 순위는 위 에 나열된 순번과 같았습니다. 하지만 이러한 정책상의 구분은 아직 확정 되지 않은 사항으로 출시 전까지 많은 변동 사항이 있을 것으로 예상 되었습니다. 그래서 CashEarn에 대한 type에 enum을 사용하기는 적절하지 않았습니다. 또 CashEarn에 들어가게 될 type의 경우 type명과 사용 우선순위라는 2가지 속성을 모두 담고 있어야 하기 때문에 따로 분리된 객체로 만드는 것이 좋겠다고 생각 하게 되었습니다.
Cash 만료와 사용
다시 사용Cash 데이터에 대한 문제로 돌아와 글을 이어가 보겠습니다. 가만히 생각해보면 획득 cash가 사라지는 방식에는 2가지가 있습니다.
그것은 유효기간이 다 되어 사라지는 만료(expiration)와 서비스 내에서 유저에 의한 사용(use)입니다.
CashEarn은 스스로 혼자 사라져서 현재의 잔액 계산에서 제외 될 수 있는 것입니다. 위와 같은 구조에서 이때 CashEarn에 is_active 같은 속성을 두어서 유효기간이 있는 캐시를 잔액 연산에서 제외 시키기만 하는 방식으로 Cash 만료를 다루면 문제가 발생합니다.
해당 CashEarn이 잔액이 었을 당시 해당 CashEarn을 대상으로 Cash Usage가 기록된적 있는 경우 is_active가 false처리 되어 계산에서 제외되면 위 그림과 같이 Cash 잔액에 -100이 두번 계산된 것과 같은 효과를 주어 정당하게 100 Cash를 사용한 사용자의 Cash 잔액이 -100이 됩니다.
그럼 CashUsage를 기록할 때마다 대상이 된 Cash Earn의 is_active를 false를 처리 하면 되겠다고 생각 할 수 도 있지만 위 예시는 +획득 금액과 -사용금액 레코드 amount가 동일하지만 실제 운영 상황에서 위 예시와 각각의 레코드가 맞아 떨어지는 경우는 상당히 드물것 입니다.
오히려 2번째 그림과 같이 CashEarn 레코드의 금액이 Cash Usage에서 부분적으로 사용되는 경우가 더 빈번할 것입니다. 이때 is_activate는 부분적으로 사용되었기 때문에 true도 flase도 아닙니다.
더 중요한 것은 사용자가 위 상태에서 Cash를 더 이상 획득도 사용도 하지 않고 그대로 시간이 지나서 2023.03.20이 지났을 때 부분적으로 Cash 50을 정확히 만료처리 해주어야 한다는 것입니다.
하지만 지금과 같은 구조에서 Cash Earn이 자신의 데이터를 근거로 얼마가 사용되었는지를 어떻게 알고 정확히 Cash 50을 만료시킬 수 있을까요? 그래서 저는 고민에 빠지게 됩니다.
선행되는 CashEarn
이때 한가지 깨달은 것이 있는데, 실제 돈의 경우 경우 마이너스 통장이 존재하는 것과 다르게 제한된 재화의 세계인 Cash는 무조건 Cash의 획득이 선행 되어야 CashUsage가 있을 수 있다는 것입니다. (Android 인앱결제로 캐시를 구매할시 환불 확인과 Cash 회수의 시간 차로 마이너스가 발생하는 경우가 발생하지만 이것은 특수한 경우이므로 제외하고 생각합니다.)
그래서 CashUsage는 자신이 어떤 획득을 근거로 사용되었는지 알수 있고, CashUsage는 자신이 획득한 내역에서 얼마를 사용해서 만료기간이 지나면 얼마를 만료해야 하는지 알 수 있도록 CashEarn 객체의 하위에 CashUsage를 기록하는 것입니다.
이때 만료와 사용은 같은 CashUsage 타입으로 관리하여 CashEarn의 유효기간이 지나면 해당 데이터 하위에 있는 Cash Usage의 amount의 값을 모두 하고 이 값을 자신이 소속된 CashEarn의 amout에서 빼기를 해서 나온 만큼은 amount를 새로 CashUsage 생성한 뒤에 is_active를 false 처리하면 됩니다.
위 설명이 다소 직접적으로 다가오지 않을 수도 있다고 생각되어 지금 까지 설명한 데이터 구조를 바탕으로 한 코드를 통해 유효기간이 있는 Cash의 만료처리를 설명해 보겠습니다. 아래 코드는 NesJS 프레임워크에 TypeORM+Mysql을 사용한 조합입니다.
실제 비지니스 로직에서는 CashEarn과 CashUsage에 획득소스(ex.구매,보너스,광고 획득)와 사용소스(ex 대회참가,유효기간 만료) 를 기록해주는 속성들이 존재하지만 이 글에서 설명하고자 하는 것들과는 부차적인 문제라고 생각되어 모두 생략하였다 점을 감안하고 코드를 봐주시길 바랍니다.
async calculateExpiredCash(){ const today = new Date(); // ① Typeorm의 쿼리 빌더를 사용 const cashEarn = await this.cashEarnRepository .createQueryBuilder() .leftJoinAndMapOne('CashEarn.cashUsage',(qb) => { return qb.from(CashUsage,'CashUsage') .select(['CashUsage.earnId as earnId']) .addSelect('SUM(CashUsage.amount)', 'usageAmount') .groupBy('CashUsage.earnId') } ,'usage','usage.earnId = CashEarn.id') .select([ 'CashEarn.id as earnId', 'CashEarn.amount as amount', 'IFNULL(usage.usageAmount,0) as usageAmount' ]) .andWhere('CashEarn.expirationDate < :today', { today }) .andWhere('CashEarn.isActivate = :isActivate', { isActivate: true }) .getRawMany(); //②유효기간 지난 Cash 남은 잔액 계산 const expiredCashList = cashEarn.map((earn) => { const {earnId, amount, usageAmount} = earn return{ earnId, amount: amount - usageAmount } }); //③유효기간 지난 Cash 남은 잔액 만큼 사용처리 return await this.createUsagePastExpirationDate(expiredCashList) }
①에서는 CashEarn객체 하위에 생성된 CashUsage의 amount를 모두 더한 후 별칭을 usageAmount로 해서 가져옵니다.
이때 cashEarn 변수에 담기는 객체의 배열은 다음과 같습니다.
[ { "earnId": 1, "amount": 100, "usageAmount": 70 }, { "earnId": 2, "amount": 150, "usageAmount": 0 } ]
②번에서는 map을 사용해 CashEarn에서 usageAmout를 빼서 차액을 계산하고, 새로 CashUsage에 데이터로 create되 객체들을 생성해 줍니다. 코드의 expiredCashList가 반환하는 객체의 배열은 다음과 같습니다.
[ { "earnId": 1, "amount": 30 }, { "earnId": 2, "amount": 150 } ]
③에서 ②에서 생성한 객체배열들을 한번에 생성해주는 메서드를 불러와서 만료된 만큼의 CashUsage를 데이터를 생성되 만료 처리 합니다.
이상 제가 Cash 도메인을 구축하면서 고민했던 데이터 구조 부분에 대해 공유하는 글을 마칩니다.
'나의 개발 이야기' 카테고리의 다른 글
네트워크의 기초 - 처리량과 지연시간, 토폴로지와 병목 현상 (1) 2024.07.05