카테고리 없음

포인트 사용 기능에 대한 데이터베이스 설계와 플로우를 구상

secretjuju 2024. 5. 16. 01:43

 

들어가며

저번 포스트에서 트랜잭션 격리수준과 락 종류를 알아보고, 다음 포스트 에서 포인트 기능에 대한 구상을 해본다고 한지 벌써 2주가 지났다.

해당 포스트를 작성하고 바로 다음날 1주일동안 호주여행을 갔었고 갔다오고 그동안 회사 업무 팔로업하랴 이거저거 하면서 시간이 많이 지나고 이제서야 글을 올린다.

 

요구사항

우선 가상의 요구사항을 정해보자면 다음과 같다.

  • 유저가 상품을 구매하면 구매한 가격의 특정 비율이 포인트로 전환되어 쌓인다.
  • 포인트는 양의 정수이다.
  • 유저가 상품을 구매할때 포인트를 사용하여 일부 금액을 대신 결제할 수 있다.
  • 포인트를 사용한 결제에선 포인트가 적립되지 않는다.
  • 상품구매와 포인트 사용이 빠르게 여러번 발생하더라도 포인트의 정합성이 깨지면 안된다.
  • 쌓인 포인트는 30일이 지나면 만료된다.
  • 포인트 사용시 예전에 쌓였던 포인트부터 선입선출 되어야 한다.

어디에서나 흔히 볼 수 있는 포인트 시스템 인 것 같다.

 

 

테이블 구상

우선 테이블을 구상 해 보았다.

 

우선 테이블에 대한 설명을 해보자면

 

  • points
    • 현재 사용자가 보유하고있는 포인트를 요약해서 볼 수 있는 테이블이다.
  • point_transactions
    • 사용자의 포인트의 적립, 사용, 만료 에 대한 로그를 남기는 테이블이다.
    • 해당 테이블을 이용해서 포인트가 언제 적립되었고, 언제 사용되었으며, 언제 만료되는지, 만료되었는지 표기한다.

point_transactions 컬럼 설명

컬럼 이름 설명 시나리오
amount 해당 트랜잭션에 의해서 변경된 포인트의 양 ex) 500 point 적립 시 500 으로 기록
type 해당 트랜잭션의 타임 / accumulated - 적립, used - 사용, expired - 만료 ex) 10000 point 사용시 "used"
is_used 적립된 포인트가 모두 사용되었는지 여부 type 이 적립일때 사용, 기본 false
used_amount 적립된 포인트가 얼마나 사용되었는지 기록 type 이 적립 / 포인트 사용시 해당 적립 트랜잭션에서 금액을 차감한다면, amount_to_deduct -= (amount - used_amount) 
used_at is_used 가 true 로 바뀐 시점  
related_transactions 해당 트랜잭션에서 사용/만료 시킨 트랜잭션 목록 타입이 used 일땐 사용한 transaction 들의 id 목록
타입이 expired 일땐 만료시킨 transaction 들의 id 목록
expired_at 해당트랜잭션이 만료되어야하는 날짜 매일 expired_at < now and is_used = true 로 검색하여 만료
created_at 트랜잭션이 생성된 시간  

 

 

포인트 수정시 트랜잭션 플로우

이전 포스트에서 트랜잭션의 격리수준과 락 종류를 살펴보았다.

이번엔 이전에 살펴본 것 들을 갖고 포인트 기능 구현하며 트랜잭션을 어떻게 써야할지 고민 해 보려고 한다.

 

일단 이상적인 상황에선 유저가 포인트를 적립받고, 상품을 구매할 때 적립받은 포인트를 사용하는 로직이 순서대로 천천히 진행되면 너무 좋겠다.

하지만 포인트를 동시에 2번 사용(상품구매)하는 요청을 하는 상황에선 다음과 같은 문제가 발생 할 것 이다.

 

포인트를 동시에 2번 사용

  1. 10000 포인트를 보유하고있는 상태에서
  2. 8000 포인트를 사용하는 구매요청 a(살짝 빠름), 7000 을 사용하는 구매요청 b 동시 발생
  3. 구매요청 a 현재 보유 포인트 조회 -> 10000 포인트 -> 검증통과
  4. 구매요청 b 현재 보유 포인트 조회 -> 10000 포인트 -> 검증통과
  5. 구매요청 a 포인트 차감 -> 포인트 2000
  6. 구매요청 b 포인트 차감 -> -5000 -> 정합성이 안맞음

물론 redis 를 이용해서 동시에 구매가 발생하지 않도록 한다거나 차감 sql 에 where 을 걸어 차감된 금액이 음수면 업데이트 되지 않도록할 순 있겠으나 트랜잭션 락을 통해 이런일이 일어나지 않도록 예방할 수 있다.

따라서 다음이 내가 생각한 방법이다.

 

포인트 사용

우선 트랜잭션 격리수준은 기본값인 Read Committed 를 사용한다.

  1. 10000 포인트를 보유하고있는 상태에서
  2. 8000 포인트를 사용하는 구매요청 a(살짝 빠름), 7000 을 사용하는 구매요청 b 동시 발생
  3. 구매요청 a, 트랜잭션 a 시작.
  4. 트랜잭션 a 에서 사용자의 보유 포인트를 조회한다.
    1. 이때 a 가 해당 사용자의 보유포인트를 다루는 동안 다른 트랜잭션이 사용자 보유 포인트를 select/update 하지 못하도록 read 와 write 에 lock 을 걸 필요가 있다.
    2. select 에서 exclusive lock 을 걸기 위해선 select 문 끝에 for update 를 붙여주어야 한다.
    3. https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS
  5. 구매요청 b, 트랜잭션 b 에서 사용자의 보유 포인트를 조회한다.
    1. 현재 조회하는 row 는 exclusive lock 이 걸려있어 트랜잭션 a 가 끝나기를 기다린다.
  6. 구매요청 a 포인트 차감 -> 포인트 2000 / 트랜잭션 끝
  7. 트랜잭션 b 에서 사용자의 보유 포인트 조회가능 / 조회
    1. 잔액 2000 포인트 이므로 검증실패

 

위 에서 point_transcation 테이블 조회 / used_amount, is_used 등등을 update 하 로직을 적진 않았지만, 그부분도 exclusive lock 을 걸어 사용해야할 것 같다.

 

 

포인트 만료처리

포인트를 만료처리할땐 과연 exclusive lock 이 필요할까?

expired_at 이 지난 포인트 트랜잭션은 포인트사용시 조회되지 않는다. 따라서 락을 걸지 않고 데이터를 읽은 후 points 테이블의 balance 를 업데이트, point_transactions 에 expired 로그를 insert 하면 될 것 같다.

 

 

후기

그동안 이런 동시성을 막기위해서 redis 의 nx 플래그를 이용해 왔는데, 보통 controller 나 service 레이어에서 핸들링을 해 왔다.

하지만 이젠 위 동시성 핸들링 부분을 거치지 않고 바로 레포지토리를 호출하더라도 트랜잭션을 이용해서 데이터의 정합성이 깨지지 않도록 할 수 있다는걸 알았다.