스타트업 결제 도메인 구축기 (3)
-
1. 결제 도메인 거래·환불의 동시성 제어 전략
서비스에서 수익 창출을 이루려면 결국 결제 도메인을 구축해야합니다. 필자가 극초기 스타트업에서 결제 도메인을 처음부터 구축하고, 25년 9월까지 6억 매출을 처리한 시스템을 개발한 경험을 토대로 결제 도메인에 대해 분석 및 발생할 수 있는 문제상황과 처리 전략에 대해 소개하겠습니다. 결제 도메인에 대한 설명이나 PG사 연동 방법 같은 부분은 소개하지 않겠습니다. 또한 저 역시 스타트업에서 필요한 결제 시스템 구축 경험에서 비롯되어 핸들링을 하기 때문에 큰 규모의 서비스에서 필요한 요구사항과는 다를 수 있습니다. 결제와 같이 실제 통화가 오가는 엄밀한 도메인은, 무조건 AT-LEAST-ONCE를 보장해야 합니다. 따라서 중복 발생 케이스에 대해선 최대한 비관적으로 접근해 재시도나 경합 상황에 대해 엔지니어링 해야 합니다.  결국 경합되는 자원에 대한 동시성 제어와 중복 처리 등을 안전히 처리할 수 있는 멱등성 보장이 굉장히 중요해집니다. 이번 글에선 동시성 제어 상황에 대해 알아보겠씁니다. 동시성 제어 동시성 제어에 대해서만 엄밀히 이야기하기 위해 우선 멱등성이 보장된 상황을 가정하겠습니다. 즉 동일 요청이 여러번 들어오거나, 처리 중 실패하는 등의 문제 상황은 차치하겠습니다. 결국 동시성 제어의 핵심은 경합되는 자원에 대한 락처리 입니다. 결제 도메인의 경우 유관 자원이 되게 많은데, 거래, 쿠폰, 결제 원천(예약이나 구독 등), 상품 등의 정보가 순간순간 얽혀 읽고 써져야합니다. 서비스 로직에서 주로 경합되는 자원은 상품의 재고, 거래 객체, 결제 원천의 상태, 환불 (요청) 객체 정도 될 것 같습니다. 결제의 흐름을 토대로 이 자원들의 동시성 문제를 어떻게 해결하는 지 살펴 보겠습니다. 거래 동시성 제어 거래는 상품 조회 -> 거래 및 결제 원천 생성 -> PG사에 결제 -> 결제 승인 순으로 일어납니다. 이 과정에서 생기는 상황을 하나씩 보겠습니다. 거래 생성에 대한 제어 유저가 상품을 구매하면 결제 원천과 거래 객체를 생성합니다. 생성 부분이라 경합되는 자원은 없습니다. 다만, 이때 상품의 재고라는 관심사가 존재하는데, 재고가 존재하는 상품을 팔기 위해서는 결제 시 재고에 대한 유효성 검사가 들어가야 합니다. 이 유효성 검사를 거래 생성 시에도 할 수 있고, 승인(실제로 돈이 빠져나가는 시점) 시에도 할 수 있는데 코레일톡 처럼 실제 결제 처리 전 특정 시간만큼 자원을 선점해주는 경우 결제 생성 시점에 재고에 대한 검증 및 차감 처리를 해야합니다. 처리 방식은 간단한데, 결제 생성 시 상품 객체를 읽으며 낙관적 쓰기 락을 걸면 됩니다. 상품 객체의 경우 대부분 접근이 잦기에 비관적 쓰기 락을 쓰기엔 부하가 큽니다. 낙관적 쓰기 락을 쓴 경우 (상품 조회 -> 거래 생성 -> 상품 재고 차감) 트랜잭션이 경합되어 로직 중 다른 트랜잭션이 상품에 대해 커밋한 경우 트랜잭션이 실패하고 롤백되게 됩니다. 다만 중요한 건, 이렇게 롤백된 케이스는 비즈니스 상황 상의 에러는 아니기에 처음부터 재처리를 해주셔야합니다. 결제 미처리 시에 대한 제어 거래가 생성 된 이후 PG 통신 부분에서 거래가 이루어지지 않으면 해당 거래는 불발처리 되어야 합니다. 이때 보통 특정 시간이 지난 거래를 불발 시킬 텐데, 불발 로직 처리 중에 유저가 결제를 처리해버리면 동시성 문제가 발생합니다. 불발 처리할 거래를 읽는 시점에 비관적 쓰기 락을 사용합니다. 불발될 거래에 대해 결제 승인 등의 로직에서 접근하는 것을 원천적으로 차단합니다. 다만 이 때 반드시 주의하셔야 할 점은, 비관적 락을 쓰기 때문에 여러 테이블에 동시 접근하면 데드락이 생길 수 있습니다. 가령 거래와 예약 테이블을 동시에 비관적 쓰기 락으로 읽으면 거래 락 취득 - 예약 락 취득 사이에 다른 어딘가에서 예약 락을 얻고 거래 락을 얻으려 하면 데드락이 발생합니다. 솔루션은, 모든 거래에만 비관적 쓰기 락으로 접근을 합니다. 만료될 개별 거래들은 여러 유저가 접근하는 자원이 아니기에 낙관적 락 처리를 하며 복잡성과 위험을 감수할 이유가 적습니다. 그 후 유관 도메인의 상태 전파를 이벤트로 처리하고, 개별 컨슈머 측에서 핸들링합니다. 이때 중요한 건 유관도메인에 대한 상태 변경은 비동기로 이루어진다는 점 입니다. 즉 예약 상태로 처리하는 로직은 불발 로직 커밋 시점에도 일어날 수 있습니다. 따라서 혹여 로직을 설계 하실 때 결제 불발 == 예약 삭제 를 같은 개념으로 취급하는 등의 로직은 지양하셔야 합니다. 또 중요한게 만약 상품 선점 로직이 존재하는 경우 불발과 동시에 재고를 늘려야 합니다. 이 부분은 간단한데 트랜잭션 안에 상품의 재고 업데이트 로직을 원자적 업데이트 연산으로 넣어주시면 됩니다. 결제 승인 시에 대한 제어 우선 거래 자원만을 비관적 쓰기 락으로 읽습니다. 이후 PG 승인 API 성공 시 이벤트를 통해 결제 원천이나 장부 등 유관 도메인에서 성공에 대한 업데이트 쿼리를 수행하여 데드락 발생 경우의 수를 차단합니다. 이 부분은 거래 자원 자체만 다루기에 비관적 쓰기 락으로 깔끔하게 처리가 가능합니다. 단 예외 처리 및 재시도, 멱등성 보장이 더 중요하기에 다음 글에서 더 다뤄 보겠습니다. 환불 동시성 제어 환불에서 생기는 거래는 거래 취소와 (부분) 환불이 존재할 수 있습니다. 100%에 해당하는 자원을 회수하는 경우 거래 취소로 핸들링이 가능합니다. 이외의 금액는 환불 처리가 필요하며 카드사 등의 사정에 따라 딜레이가 존재할 수 있습니다. 유저는 완료된 거래에 대해 환불 요청을 할 수 있을 것이고, 서비스 정책에 따라 일부 환불은 자동으로 승인될 필요가 있습니다. 환불을 통해 결제 원천의 정보 또한 영향을 받을 수도 있고, 환불과 별개로 결제 원천이 제어될 수도 있습니다. 환불 요청에 대한 제어 이 부분은 단순 요청 객체만 생성하면 되기 때문에 동시성 상황은 없습니다. 혹여 필요한 상황이 생긴다면 그건 도메인 간의 결합도가 불필요하게 높게 설계된 것일 수도 있습니다. 아마 서비스 특성에 따라 결제 원천에서 "취소 요청 됨" 과 같은 상태를 식별할 필요가 있을 수도 있는데, 가급적 그냥 조회 시의 쿼리로 해결하시거나 "취소 요청" 과 "환불"의 개념을 분리시키고 싶어 필요한 경우엔 이벤트 기반으로 처리하셔도 됩니다. 만약 환불 처리가 서비스 특성에 따라 자동 승인이 필요한 경우엔 같은 트랜잭션에 아래 환불 처리를 넣으시면 됩니다. 환불 처리에 대한 제어 하나의 거래에 대해 환불은 여럿 요청되고 동시에 처리될 수 있기에 주의해서 처리할 필요가 있습니다. 이때 하나의 결제 거래에 대한 여러 환불 처리 요청을 순차적으로 처리하는 방식이 필요합니다. 이를 위해 환불 처리 시 해당 거래 객체에 대한 락을 획득하는 방법을 고려할 수 있습니다. 이미 다른 환불 처리가 진행 중이라면 락을 획득하지 못해 대기하게 되므로, 여러 환불 요청이 동시다발적으로 처리되는 것을 막고 순차적으로 처리할 수 있습니다. 이 때 거래 객체에 환불 가능한 금액을 칼럼으로 등록해두고, 활용한다면 환불 처리 시점에서 다른 환불의 데이터를 조회하면서 생기는 동시성 문제를 해결할 수 있습니다. 이후 거래 원천 등에 환불 처리가 되었음을 이벤트 기반으로 전파해서 처리하면 됩니다. 이 때 거래 원천이 무작정 취소되는 것이 아닐 수 있기에 페이로드에 최대한 환불이 발생한 경위에 대한 정보를 담아 전파하고 비즈니스 정책에 따라 핸들링합니다. 이렇게 거래 상황에서 생기는 다양한 동시성 문제 상황과 처리 전략들을 알아보았습니다. 다만 견고한 거래 시스템을 만드려면 이것 만으로는 아직 모자란 부분이 많습니다. 다음 편에서 멱등성 처리에 대해서 알아보겠습니다.
2025년 09월 09일 -
2. 결제 도메인에서의 멱등성 보장
결제 도메인과 같이 일관성이 중요하면서도 PG사에 통신이 들어가는 등 일련된 비즈니스 로직을 하나의 트랜잭션에 담기 어려운 경우 멱등성 처리가 굉장히 중요해집니다. 일관성과 안전성을 챙기다 보면 자연스럽게 재시도 혹은 중복 요청 발생에 대한 위험성이 같이 늘어가기 때문이죠. 멱등성(Idempotency)은 동일한 연산을 여러 번 적용해도 그 결과가 최초 한 번 적용했을 때와 달라지지 않는 성질을 뜻합니다. 가령 유저가 PG 통신을 통한 결제 이후 결제 승인 API를 연속적으로 호출해도, 서버 내 결제 승인에 관한 처리는 한번 호출한 것과 동일한 결과를 불러온다면 멱등하다 볼 수 있습니다. 결제 도메인 내 여러 서비스 로직들에서 멱등성이 필요한 곳과 보장하는 방법을 살펴보겠습니다. 결제 승인 / 환불 처리 시 멱등성 보장 결제 승인은 유저가 PG사와 통신하여 실제 결제를 완료한 후 우리 서버 측에 상품 결제 처리를 요청하는 일련의 과정을 뜻합니다. 이 과정에서 네트워크 불안정, 유저의 새로고침 등으로 인해 동일한 승인 요청이 여러 번 들어올 수 있습니다. 이 경우에도 멱등한 응답이 와야 유저가 결제가 중복 처리됐다는 등의 오류 메시지를 보지 않을 수 있습니다. 이를 해결하려면 유저가 결제 승인 요청을 한 후 일정 시간 동안의 멱등성을 보장해 주어야 합니다. 이 때는 아래와 같은 방법을 사용합니다. REDIS 등을 활용해서 결제 당 고유 key를 통해 락을 걸고, 락이 있는 경우 락이 릴리즈될 때 까지 기다린다. 실행 중인 로직은 처리 완료 시점의 결과를 담아 캐싱하고 락을 해제한다. 캐싱된 결과가 있을 경우 비즈니스 로직을 스킵하고 캐시를 리턴한다. 이를 통해 캐시 TTL에 해당하는 기간동안의 응답은 최초의 요청에 대한 값을 그대로 받을 수 있습니다. 또한 TTL 동안 비즈니스 로직은 한번만 실행되도록 보장할 수 있습니다. 캐싱의 TTL을 통해 응답의 멱등함에 대한 기간을 조율합니다. 가령 유저가 최초 승인 API를 성공적으로 호출한 하루 뒤 또 API를 호출하면, 이 때는 멱등하게 결제 성공 응답을 주는게 아니라 오히려 중복 승인 에러를 주는 것이 더 합리적이기 때문입니다. 번외 - 멱등 키를 클라이언트에게 직접 받기 위의 경우 TTL 이후엔 동일 request에 대해서 다른 응답을 주게 설계했습니다. API 자체가 멱등성을 일시적으로만 보장하게 설계되었기 때문입니다. 만약 이렇게가 아니라 동일한 컨디션으로 들어온 요청임을 따로 식별하고 해당 요청에 대해서는 영구적으로 멱등하게 처리하고 싶다면, 멱등 키 자체를 서버가 아니라 클라이언트에서 만들어서 보내면 됩니다. 토스페이먼츠에서는 이 방법을 채택해서 활용하고 있습니다. https://docs.tosspayments.com/blog/what-is-idempotency 이벤트 컨슈머 측 멱등성 보장 결제 원천에게 결제의 결과를 전파하는 등의 경우엔, 메시지 큐 특성 상 성공 했는데 실패로 인식 후 로직 재처리 등이 발생할 수 있습니다. 따라서 이벤트에 대한 로직 처리는 단 한번만 일어나도록 멱등성을 보장해야합니다. 이 부분은 https://astor-dev.com/blog/posts/620239513989419008/ 글에서 다루었으니 참고 바랍니다. 멱등성 보장 시 주의할 점 - 순서 보장 이렇게 멱등성이 되면 조심해야 되는 케이스가 생기는데, 서로 다른 토픽에서 들어오는 이벤트가 동일 관심사(자원의 상태 등)를 수정하려 하는 경우에 순서 보장이 안되는 경우, 오히려 결과가 꼬일 수 있습니다. 따라서 유효성 검증을 주의깊게 하시면 좋습니다. 가령 환불 처리됨과 결제 승인이 동시에 메시지 큐에 담긴 상황에서 예약 도메인의 상태 변경에 대해서 보겠습니다. 환불 완료 시 예약은 취소 상태로 변경된다고 가정 하겠습니다. 업데이트 쿼리는 여러번 이행되어도 결과가 같으니 멱등하죠. 결제 완료 시 예약은 활성 상태로 바뀌며 결제 id라는 논리적 fk가 업데이트 된다고 가정하겠습니다. 이 또한 멱등하죠. 다만 환불 완료가 먼저 소비되어 버리면, 이후에 처리된 결제 완료 로직에 따라 최종적으로는 예약은 활성 상태로 남게 됩니다. 이 경우엔 결제 완료 전에 들어온 환불 처리 이벤트를 유효하지 않은 처리로 인지하여 실패 시켜야 합니다. 이후 실패한 이벤트에 대한 재처리 로직을 통해 일정 기간 뒤 요청 처리가 재시도 될 것이고, 결제 완료 전파가 완료된 후 처리되도록 보장할 수 있습니다. 이를 통해 결제 도메인에서 멱등성이 필요한 상황과 멱등성 보장 방식에 대해 알아봤습니다. 허나 동시성 제어와 멱등성 만으로는 여전히 안정적인 시스템을 보장할 수 없습니다. 다음 글에서는 예외 핸들링 및 재시도에 대해 알아보겠습니다.
2025년 09월 09일 -
3. 결제 도메인의 재시도 상황 및 전략, 멱등성 연계
결제 과정은 금전과 관련된 일이기 때문에 반드시 한번 이상의 처리를 보장해야합니다. 이를 보장하려면 필연적으로 재시도가 생겨납니다.  네트워크 끊김 등 모든 상황에서도 한번 이상 올바르게 처리되도록 하기 위해서는 트랜잭션으로 보장받지 못하는 모든 연결부에서 발생하는 케이스에서 실패 시 재시도가 되어야 하기 때문입니다. 따라서 똑같은 로직이 여러번 발생하는 경우 생기는 문제를 막기 위해 멱등성을 보장했습니다. 시리즈의 전 포스트를 참고해주세요. 이번 글에선 결제 도메인에서 재시도 처리를 해야하는 케이스들을 살펴보고, 함께 활용할 수 있는 재시도 전략들도 살펴보겠습니다. 재시도 전략 고정 간격 재시도 (Fixed Interval Retry) 제일 기본적이고 직관적인 재시도 방식입니다. 특정 고정 간격을 두고 실패 시 해당 간격 후 재시도를 실행하는 방식입니다. 예를 들어, 실패할 때마다 5초 후에 다시 시도하도록 설정하는 방식입니다. 단순한 로직으로 구현이 쉽지만, 여러 요청이 동시에 실패할 경우 같은 시점에 다시 몰려 시스템에 부하를 줄 수 있습니다. 실패의 원인이 치명적인 에러가 아니거나 의도한 경우, 혹은 빠른 응답이 중요한 경우 주로 사용됩니다. 지수 백오프 (Exponential Backoff) 재시도 실패 시 다음 재시도까지의 대기 시간을 점진적으로 늘려나가는 방식입니다. 첫 번째 재시도 시 1초, 두 번째는 2초, 세 번째는 4초와 같이 대기 시간이 기하급수적으로 증가합니다. 결제 시스템에 일시적으로 부하가 걸렸을 때, 재시도 요청이 한꺼번에 몰리는 것을 효과적으로 방지합니다. 실패가 생길 수 있는 원인이 시스템 다운 등 예측 불가능한 상황인 경우, 복구까지 기다릴 수 있기에 유용합니다. 다만 client와 동기적으로 주고받는 API 단에서 사용하기엔 응답을 받는 데 드는 시간이 지수적으로 늘어나서 UX를 해칩니다. 분산 재시도 (Jitter) 지수 백오프나 고정 간격 재시도 등에 임의의 지연 시간을 추가하는 방식입니다. 예를 들어, 2초 후에 재시도해야 할 경우, 1.8초에서 2.2초 사이의 무작위 시간으로 재시도합니다. 이는 여러 요청이 정확히 같은 시간에 재시도되어 다시 충돌하는 '재시도 폭풍'을 방지하는 데 효과적입니다. 실패 로그 / 데드 레터 큐 만약 임계 까지 재처리하여도 성공하지 못해 영구적으로 실패로 처리되거나, 내부 DB 등 영속성 영역에 문제가 생겼을 때는 SW 상에선 즉각적으로 자동화된 대응이 불가능 합니다. 이런 작업이 발생했을 경우, 반드시 다원화된 창구를 통해 이 사항을 기록하여야 합니다. API 단에서 생기는 쓰기 작업은 DB에 에러 테이블을 만들거나, slack에 결제 실패 스레드를 만들고 로그를 쌓는 등을 통해 처리할 수 있습니다. 데드 레터 큐는 메시지 브로커 재처리 쪽에서 주로 쓰이는 개념으로 실패한 메시지만 모아놓는 토픽을 따로 개설하여 재처리 실패시 해당 토픽에 적재하는 방식으로 사용됩니다. 아래 글에서 다뤘는데 흥미 있으시면 참고 바랍니다. Kafka Consumer - 오프셋 커밋과 재처리 전략, Dead Letter Queue(DLQ) 거래 생성 시 재시도 - 낙관적 락 거래 생성이 만약 상품의 재고를 관심사로 두고 있는 경우, 쓰기 정합성 보장 및 조회 시의 오버헤드를 줄이기 위해 낙관적 락을 많이 사용합니다. 이 케이스에선 결제가 생성되는 시점에 재고에 대한 변동이 있을 경우 실패 처리가 아니라 동일 로직을 재시도해주어야 합니다. 재시도 전략을 신중히 고려해야합니다. 만약 한 칼럼에 일 순간에 수많은 거래 생성 요청이 생기는 경우 (ex: 티켓팅 서비스) 바로 재시도 시기는 경우 충돌 빈도가 늘어나고 시스템에 부하가 갈 수 있습니다. 제가 권장하는 방식은 고정 간격 재시도와 지터 추가입니다. 낙관적 락 자체는 굉장히 빈번하게 실패할 수 있는 상황이며 서버 다운같은 상황으로 실패되는 것이 아니기에 오래 기다릴 필요가 없습니다. 다만 어느정도의 지터를 두어 동시에 요청이 쏟아 진 경우 적절히 분산되게 처리합니다. 거래 승인 시 재시도 - 지수적 백오프, 실패 로그 적재 결제 승인 파이프라인을 도식화 했습니다. 보통 실패할 수 있는 상황은 아래와 같습니다. 네트워크 불안정 외부 서비스의 에러 유저 귀책 에러 (미지원 결제 수단, 잔액 부족 등) 의도된 시스템 에러 (은행 점검 등) 의도되지 않은 시스템 에러 (서버 장애 등) DB 연결 실패 외부 시비스 (PG사)에서 정의해둔 에러들을 그대로 처리할 게 아니라, 유저에게 바로 실패로 돌려줄 에러와 재시도해야할 에러를 정의해두어야 합니다. 또한 제일 문제가 생길 수 있는 부분은 승인 API 호출 이후 DB 커넥션 문제로 이 경우 유저 돈은 빠져나갔지만 시스템은 결제 사실을 모르게 됩니다. DB가 터지는 등의 케이스를 대비해서 아무리 재시도해도 문제가 생길 경우 반드시 다른 창구에 로그를 남겨야 합니다. Slack 알림, DB 에러 테이블, 에러용 메시지 토픽 등을 활용할 수 있습니다. 제일 중요한건 멱등성 보장입니다. 지난 글에서 살펴본 멱등성 보장과 재시도가 시너지를 불러  유저가 요청 처리 중 새로고침을 해도 유저가 요청이 완료되고 뒤로가기를 통해 웹훅 url에 접속해도 pg사에 일시적 장애가 발생해도 결제 요청을 안정적으로 처리할 수 있습니다. 번외 - 메시지큐 활용 만약 결제 승인 요청 과정 도중에 서버가 터지면 어떻게 될까요? 이 경우엔 어떤 대응도 받지 못할 것입니다. 가장 유효한 방법은 승인 로직 자체를 메시지큐로 처리하는 것 입니다.  메시지큐를 활용하여, 온전히 로직을 수행했을 때만 커밋하게 한다면 서버가 터지더라도 별도의 워커가 작업을 마저 수행할 수 있습니다. 다만 이를 이용한다면 Kafka 급의 고가용성과 안정성을 챙긴 메시지 큐 운용이 필수적이겠죠. 환불 완료 시 재시도 환불 API 역시 결제 승인과 동일하게 처리하실 수 있습니다.  다만 여기서 중요한 점은 환불은 재시도 시 두번 처리가 될 경우 문제가 생길 수 있다는 점 입니다. 반액 부분 환불을 재시도를 통해 2번 처리한 경우 전액 환불이 생길 수 있습니다. 따라서 환불 API를 재시도 로직을 담을 경우 요청 처리 부분에서 PG API 호출 자체가 반드시 멱등하게 처리되어야 합니다. 이 부분은 TossPayments 같은 PG사에서 대부분 요청 당 멱등 키를 인자로 제공해줍니다. https://docs.tosspayments.com/blog/what-is-idempotency 환불 처리에 활용되는 고유 키 값을 활용해서 PG사에 멱등키로 던져줍니다. 이 경우 재처리 시 외부 통신부 마저 안정적으로 처리할 수 있습니다. 멱등키는 승인부에서도 쓸 수 있으니 활용하면 좋을 것 같네요. 결제 요청 파이프라인에서 주요하게 엔지니어링 해야 할 부분들은 대부분 다 살펴본 것 같습니다. 추가적으로 오늘 다룬 내용처럼 메시지큐를 활용해서 개별 요청 자체도 고가용성을 지닌채 재시도 가능하게 한다면 좋을 것 같네요. 일단 잠시 이정도로 마무리하고 또 추가적으로 다룰 내용이 떠오르면 시리즈 이어 보겠습니다.
2025년 09월 15일