현재 운영중인 서비스가 배포되기 QA를 하던 중에 결제 시스템에서 동시에 결제 요청을 할 시, 중복 결제가 되는 문제가 발생하였고 이 문제를 풀어보기로 했다.
고민한 방법은 총 3가지이며 비즈니스 로직 변경, 트랜잭션 격리수준 향상, 데이터베이스 락 적용 이렇게 방안이 나왔다.
실질적으로는 비즈니스 로직으로만 설정하였지만 다른 방법을 선택할 시 고민할 부분과 왜 비즈니스 로직으로만 설정했는지도 같이 풀어보려고 한다.
근본적인 문제를 가장 간단하게 해결할 수 있는 방법이 아닐까 생각한다.
결제 프로세스에 대해서 예외처리를 강하게 두는 방법이다. 기능 개발을 하면서 동시성을 고려하지 않았고 이 부분을 QA하면서 찾았었다.
서비스 로직에서도 고민했던 부분은 "어떻게 하면 결제가 완료되는지 알 수 있을까"였고 결제 상태를 주문번호로 가져오기로 했다
먼저 이미 결제가 된 상품에 대해서는 주문번호로 payment테이블을 가져와 결제 상태를 가지고 이미 결제된 상품인지 판별한다.
그리고 payment테이블이 생성이 되면 article의 상태는 sold로 바뀌기 때문에 결제를 하기전에 article의 상태가 sold인지 판별하고 예외를 발생시키도록 추가해주었다.
async def update_order(
request: CreateOrderRequest,
usecase: OrderUseCase = Depends(Provide[Container.order_service]),
toss_payment_service: TossPaymentService = Depends(Provide[Container.toss_payment_service]),
alimtalk_sender: AlimtalkSenderPort = Depends(Provide[Container.alimtalk_service]),
):
try:
verified_payment = await usecase.verify_payment(request=request)
if verified_payment:
raise Exception(verified_payment)
if (request.payment_type == PaymentType.NORMAL and
request.payment_status == PaymentStatus.PAYMENT_COMPLETED and
request.payment_key):
response = await toss_payment_service.confirm_payment(
payment_key=request.payment_key,
order_id=request.origin_order_id,
amount=request.amount
)
request = _handle_payment_response(request, response)
~~~
@Transactional()
async def verify_payment(self, *, request: "CreateOrderRequest") -> Optional[str]:
payment = await self.repository.get_payment_by_origin_order_id(origin_order_id=request.origin_order_id, payment_status=request.payment_status)
if payment:
return "이미 결제가 완료된 주문입니다."
article = await self.article_repository.get_article_by_id(article_id=request.article_id)
if article is None:
return "상품이 존재하지 않습니다."
if article.article_status == ArticleStatus.SOLD:
return "이미 판매된 상품입니다."
return None
하지만 이 방법만으로는 동시성을 해결할 수 없다. 그 이유는 article의 상태가 sold로 바뀌기전에 다른 트랜잭션이 상태를 읽고 결제를 진행시킬 수 있기 때문이다. 그리고 결제는 실패되더라도 Order테이블은 만들어지고 Article에 매핑되는 Order가 업데이트 되기 때문에 실제로 결제한 사람은 구매내역에서 구매한 것을 보지 못하고 이후 실패한 사람의 구매내역에 구매했다고 뜨게된다.
추가적으로 아티클의 상태는 결제 테이블을 만들 때가 아니라 먼저 Order테이블을 만들 때 검증을 해야한다고 깨달았다.
@Transactional()
async def create_order(self, *, command: CreateOrderCommand) -> Optional["Order"]:
try:
article = await self.article_repository.get_article_by_id(article_id=command.article_id)
if article is None:
raise Exception("상품이 존재하지 않습니다.")
if article.article_status == ArticleStatus.SOLD:
raise Exception("이미 판매된 상품입니다.")
order = await self.repository.find_by_origin_order_id(origin_order_id=command.origin_order_id)
~~~
@Transactional()
async def verify_payment(self, *, request: "CreateOrderRequest") -> Optional[str]:
payment = await self.repository.get_payment_by_origin_order_id(origin_order_id=request.origin_order_id, payment_status=request.payment_status)
order = await self.repository.get_order_by_origin_order_id(origin_order_id=request.origin_order_id)
if payment:
return "이미 결제가 완료된 주문입니다."
이러한 방법도 완전한 방법은 아니다 그 이유는 아티클의 상태가 바뀌지 않는다면? 동시 결제가 이뤄지기 때문이다. 그래서 다른 방법을 찾아보았다.
두번째 방법은 트랜잭션의 격리 수준을 상향하는 방법이다.
트랜잭션 격리 수준의 종류에 대해서는 아래 링크를 참고하였다.
트랜잭션 격리 수준
현재 문제를 해결하기 위해서는 격리 수준을 최소 Repetable Read 이상이 필요하다는 것을 알았다. 하지만 이 방법을 사용하면 팬텀리드가 발생한다고 하여 더 높은 격리수준이 필요한가?라는 생각을 가지게 되었다. 이 다음으로 높은 격리수준은 Serializable으로 최상의 격리수준을 가진다. 하지만 내 서비스는 대규모 트래픽까지 고려해야했고 대규모 트래픽에서는 성능 저하로 부적합하다는 것을 깨달았다. Repetable Read에서 팬텀리드가 발생하기 때문에 당연히 최상의 격리수준을 설정해야한다고 생각했지만, 서비스 특성에 따라 적합성이 달라진다는 것을 깨달았다. 그러면 이것을 보완하기 위한 방법은 없을까?
마지막으로는 데이터베이스에 락을 거는 것이다. 락의 종류는 크게 3가지로 있다.
비관적 락
낙관적 락
분산 락
락의 설명또한 위의 링크를 참고하였다.
트랜잭션의 격리수준을 보완하기 위해서는 비관적 락을 적용하는 것이 옳은 방법이라고 생각했고, 찾아보니 실무에서도 쓰이는 방법이라고 나와있었다. 하지만 알아볼수록 비관적락도 완전하지 않다는 것을 깨달았다. 그 이유는 바로 데드락때문이다. 대규모 트래픽 환경에서는 비관적 락을 적용했을 시, 락 대기 시간이 길어져 데드락의 위험이 있다는 것을 알게되었다. 이를 보완하기 위한 락 방법이 바로 Redis를 활용한 분산락과 큐이다. 분산락은 데이터베이스 부하를 줄이고 확장성을 확보하는 장점이 있고, 큐 기반 처리는 비동기 처리로 사용자에게 빠른 응답 제공이 가능하며 오버셀링이 방지되는 장점이 있다.
분산락과 큐도 단점이 있지만 이 부분은 다음 포스트에 작성해보려고 한다.
일단 작은 스타트업이고 처음 목표로 했던 것이 한달의 천명 방문이였다. 현재 상황에서는 고도의 기술을 도입하면서 시간을 할애하는 것보다는 서비스의 전반적인 안정성이 중요했고 개발 인원도 적었고 더군다나 모바일 앱까지 만드는 상황이였기 때문에 환경이 좋지 않았다. 그래서 결국에는 첫번째 방법을 적용하면서 후에 고객응대 CS로 처리하자고 이야기가 매듭지어졌다.
이번에 동시성 문제를 해결하려고 많은 고민을하고 기술을 찾아보았지만, 완벽하게 해결되지 못한 점과 새로운 기술을 도입하지 못한 점이 아쉬움으로 남는다. 하지만 주어진 환경과 서비스의 크기에 따라서 기술 도입의 필요성을 체크할 필요가 있다는 것을 배우는 계기가 되었다. 후에는 서비스가 더 커져서 공부했던 부분을 적용해보는 날이 왔으면 좋을 것 같다.