서비스에서 수익 창출을 이루려면 결국 결제 도메인을 구축해야합니다. 필자가 극초기 스타트업에서 결제 도메인을 처음부터 구축하고, 25년 9월까지 6억 매출을 처리한 시스템을 개발한 경험을 토대로 결제 도메인에 대해 분석 및 발생할 수 있는 문제상황과 처리 전략에 대해 소개하겠습니다.
결제 도메인에 대한 설명이나 PG사 연동 방법 같은 부분은 소개하지 않겠습니다. 또한 저 역시 스타트업에서 필요한 결제 시스템 구축 경험에서 비롯되어 핸들링을 하기 때문에 큰 규모의 서비스에서 필요한 요구사항과는 다를 수 있습니다.
결제와 같이 실제 통화가 오가는 엄밀한 도메인은, 무조건 AT-LEAST-ONCE를 보장해야 합니다. 따라서 중복 발생 케이스에 대해선 최대한 비관적으로 접근해 재시도나 경합 상황에 대해 엔지니어링 해야 합니다.
결국 경합되는 자원에 대한 동시성 제어와 중복 처리 등을 안전히 처리할 수 있는 멱등성 보장이 굉장히 중요해집니다. 이번 글에선 동시성 제어 상황에 대해 알아보겠씁니다.
동시성 제어
동시성 제어에 대해서만 엄밀히 이야기하기 위해 우선 멱등성이 보장된 상황을 가정하겠습니다. 즉 동일 요청이 여러번 들어오거나, 처리 중 실패하는 등의 문제 상황은 차치하겠습니다.
결국 동시성 제어의 핵심은 경합되는 자원에 대한 락처리 입니다. 결제 도메인의 경우 유관 자원이 되게 많은데, 거래, 쿠폰, 결제 원천(예약이나 구독 등), 상품 등의 정보가 순간순간 얽혀 읽고 써져야합니다.
서비스 로직에서 주로 경합되는 자원은 상품의 재고, 거래 객체, 결제 원천의 상태, 환불 (요청) 객체 정도 될 것 같습니다. 결제의 흐름을 토대로 이 자원들의 동시성 문제를 어떻게 해결하는 지 살펴 보겠습니다.
거래 동시성 제어
거래는 상품 조회 -> 거래 및 결제 원천 생성 -> PG사에 결제 -> 결제 승인 순으로 일어납니다. 이 과정에서 생기는 상황을 하나씩 보겠습니다.
거래 생성에 대한 제어
유저가 상품을 구매하면 결제 원천과 거래 객체를 생성합니다. 생성 부분이라 경합되는 자원은 없습니다. 다만, 이때 상품의 재고라는 관심사가 존재하는데, 재고가 존재하는 상품을 팔기 위해서는 결제 시 재고에 대한 유효성 검사가 들어가야 합니다.
이 유효성 검사를 거래 생성 시에도 할 수 있고, 승인(실제로 돈이 빠져나가는 시점) 시에도 할 수 있는데 코레일톡 처럼 실제 결제 처리 전 특정 시간만큼 자원을 선점해주는 경우 결제 생성 시점에 재고에 대한 검증 및 차감 처리를 해야합니다.
처리 방식은 간단한데, 결제 생성 시 상품 객체를 읽으며 낙관적 쓰기 락을 걸면 됩니다. 상품 객체의 경우 대부분 접근이 잦기에 비관적 쓰기 락을 쓰기엔 부하가 큽니다. 낙관적 쓰기 락을 쓴 경우 (상품 조회 -> 거래 생성 -> 상품 재고 차감) 트랜잭션이 경합되어 로직 중 다른 트랜잭션이 상품에 대해 커밋한 경우 트랜잭션이 실패하고 롤백되게 됩니다. 다만 중요한 건, 이렇게 롤백된 케이스는 비즈니스 상황 상의 에러는 아니기에 처음부터 재처리를 해주셔야합니다.
결제 미처리 시에 대한 제어
거래가 생성 된 이후 PG 통신 부분에서 거래가 이루어지지 않으면 해당 거래는 불발처리 되어야 합니다. 이때 보통 특정 시간이 지난 거래를 불발 시킬 텐데, 불발 로직 처리 중에 유저가 결제를 처리해버리면 동시성 문제가 발생합니다.
불발 처리할 거래를 읽는 시점에 비관적 쓰기 락을 사용합니다. 불발될 거래에 대해 결제 승인 등의 로직에서 접근하는 것을 원천적으로 차단합니다. 다만 이 때 반드시 주의하셔야 할 점은, 비관적 락을 쓰기 때문에 여러 테이블에 동시 접근하면 데드락이 생길 수 있습니다. 가령 거래와 예약 테이블을 동시에 비관적 쓰기 락으로 읽으면 거래 락 취득 - 예약 락 취득 사이에 다른 어딘가에서 예약 락을 얻고 거래 락을 얻으려 하면 데드락이 발생합니다.
솔루션은, 모든 거래에만 비관적 쓰기 락으로 접근을 합니다. 만료될 개별 거래들은 여러 유저가 접근하는 자원이 아니기에 낙관적 락 처리를 하며 복잡성과 위험을 감수할 이유가 적습니다. 그 후 유관 도메인의 상태 전파를 이벤트로 처리하고, 개별 컨슈머 측에서 핸들링합니다.
이때 중요한 건 유관도메인에 대한 상태 변경은 비동기로 이루어진다는 점 입니다. 즉 예약 상태로 처리하는 로직은 불발 로직 커밋 시점에도 일어날 수 있습니다. 따라서 혹여 로직을 설계 하실 때 결제 불발 == 예약 삭제
를 같은 개념으로 취급하는 등의 로직은 지양하셔야 합니다.
또 중요한게 만약 상품 선점 로직이 존재하는 경우 불발과 동시에 재고를 늘려야 합니다. 이 부분은 간단한데 트랜잭션 안에 상품의 재고 업데이트 로직을 원자적 업데이트 연산으로 넣어주시면 됩니다.
결제 승인 시에 대한 제어
우선 거래 자원만을 비관적 쓰기 락으로 읽습니다. 이후 PG 승인 API 성공 시 이벤트를 통해 결제 원천이나 장부 등 유관 도메인에서 성공에 대한 업데이트 쿼리를 수행하여 데드락 발생 경우의 수를 차단합니다.
이 부분은 거래 자원 자체만 다루기에 비관적 쓰기 락으로 깔끔하게 처리가 가능합니다. 단 예외 처리 및 재시도, 멱등성 보장이 더 중요하기에 다음 글에서 더 다뤄 보겠습니다.
환불 동시성 제어
환불에서 생기는 거래는 거래 취소와 (부분) 환불이 존재할 수 있습니다.
100%에 해당하는 자원을 회수하는 경우 거래 취소로 핸들링이 가능합니다. 이외의 금액는 환불 처리가 필요하며 카드사 등의 사정에 따라 딜레이가 존재할 수 있습니다.
유저는 완료된 거래에 대해 환불 요청을 할 수 있을 것이고, 서비스 정책에 따라 일부 환불은 자동으로 승인될 필요가 있습니다. 환불을 통해 결제 원천의 정보 또한 영향을 받을 수도 있고, 환불과 별개로 결제 원천이 제어될 수도 있습니다.
환불 요청에 대한 제어
이 부분은 단순 요청 객체만 생성하면 되기 때문에 동시성 상황은 없습니다. 혹여 필요한 상황이 생긴다면 그건 도메인 간의 결합도가 불필요하게 높게 설계된 것일 수도 있습니다. 아마 서비스 특성에 따라 결제 원천에서 “취소 요청 됨” 과 같은 상태를 식별할 필요가 있을 수도 있는데, 가급적 그냥 조회 시의 쿼리로 해결하시거나 “취소 요청” 과 “환불”의 개념을 분리시키고 싶어 필요한 경우엔 이벤트 기반으로 처리하셔도 됩니다.
만약 환불 처리가 서비스 특성에 따라 자동 승인이 필요한 경우엔 같은 트랜잭션에 아래 환불 처리를 넣으시면 됩니다.
환불 처리에 대한 제어
하나의 거래에 대해 환불은 여럿 요청되고 동시에 처리될 수 있기에 주의해서 처리할 필요가 있습니다. 이때 하나의 결제 거래에 대한 여러 환불 처리 요청을 순차적으로 처리하는 방식이 필요합니다. 이를 위해 환불 처리 시 해당 거래 객체에 대한 락을 획득하는 방법을 고려할 수 있습니다. 이미 다른 환불 처리가 진행 중이라면 락을 획득하지 못해 대기하게 되므로, 여러 환불 요청이 동시다발적으로 처리되는 것을 막고 순차적으로 처리할 수 있습니다.
이 때 거래 객체에 환불 가능한 금액을 칼럼으로 등록해두고, 활용한다면 환불 처리 시점에서 다른 환불의 데이터를 조회하면서 생기는 동시성 문제를 해결할 수 있습니다.
이후 거래 원천 등에 환불 처리가 되었음을 이벤트 기반으로 전파해서 처리하면 됩니다. 이 때 거래 원천이 무작정 취소되는 것이 아닐 수 있기에 페이로드에 최대한 환불이 발생한 경위에 대한 정보를 담아 전파하고 비즈니스 정책에 따라 핸들링합니다.
이렇게 거래 상황에서 생기는 다양한 동시성 문제 상황과 처리 전략들을 알아보았습니다.
다만 견고한 거래 시스템을 만드려면 이것 만으로는 아직 모자란 부분이 많습니다. 다음 편에서 멱등성 처리에 대해서 알아보겠습니다.