-
Notifications
You must be signed in to change notification settings - Fork 0
설민의 고민과 해결
우리 프로젝트에서 결제 취소가 필요한 주요 상황은 두가지입니다.
첫 번째는 사용자가 결제 완료 후 직접 결제를 취소한 경우입니다.
두 번째는 결제는 정상적으로 승인되었지만, DB 저장에 실패한 경우입니다.
예를 들어 Toss 결제 승인은 정상적으로 완료되었지만, 서버에서 DB 저장 중 오류가 발생할 수 있습니다. 이 경우 우리는 해당 결제를 실패로 간주하고 Toss의 Cancel API를 호출해 결제 취소를 시도합니다.
취소 요청이 성공하면, 사용자 입장에서는 해당 결제가 정상적으로 취소된 것으로 확인됩니다. 취소 요청이 실패할 경우에는 수동환불 프로세스로 전환됩니다.
이 상황에서 외부 결제까지 되돌리는 보상 로직이 필요했고, 이를 안정적이고 효율적으로 처리하기 위한 설계에 대한 고민이 있었습니다.
결제 승인과 DB 저장 간의 불일치 문제를 예방하고, 트랜잭션의 안정성을 높이기 위해 우리는 **트랜잭션 격리 수준(Isolation Level)**에 대해 고민했습니다.
실제 서비스에서는 여러 사용자가 동시에 같은 데이터를 다루기 때문에
커밋되지 않은 데이터를 다른 트랜잭션이 읽게 되는 문제가 발생할 수 있습니다.
예를 들어, 트랜잭션2에서 이찬 계좌에 1,000원 입금을 시도하고, 아직 커밋되지 않은 상태에서 트랜잭션1이 잔액 1,000원을 조회한 후 출금을 시도합니다. 이후 트랜잭션2가 롤백되면서 입금이 무효가 되더라도, 트랜잭션1의 출금은 이미 완료되어 존재하지 않는 돈이 인출되는 문제가 발생합니다. 이는 데이터 무결성을 해치는 심각한 오류(돈 복사 버그) 를 유발할 수 있습니다.
이러한 이상 현상을 방지하기 위해 트랜잭션 격리 수준이 존재합니다.
- Read Uncommited : 커밋되지 않은 데이터도 읽을 수 있음 (Dirty Read)
- Read Commited : 커밋된 데이터만 읽음 (Dirty Read 방지)
- Reapeatable Read : 트랜잭션 내 동일 쿼리 결과 일관성 보장 (Non-Repeatable Read 방지 / MySQL 기본 값)
- Serializable : 트랜잭션을 순차 처리 (모든 이상 현상 방지, 성능 저하)
격리 수준은 위와 같이 4가지로 나뉩니다. 아래로 갈수록 데이터 정합성은 높아지지만 그만큼 시스템의 성능 부담도 커집니다.
MySQL InnoDB의 기본 격리 수준은 Repeatable Read입니다. 하지만 우리는 의도적으로 Read Commited로 격리 수준을 낮췄습니다.
그 이유는 이러합니다.
- 서비스 특성상, 동시성 이슈가 발생할 가능성이 매우 낮습니다.
- 챌린지 참가 결제는 1인 1건 단위로 처리
- 항공권 예매, 좌석 선점처럼 다수가 경쟁하는 구조가 아님
- 높은 격리 수준은 트랜잭션 지연을 초래할 수 있고, 시스템 성능에 부담을 줍니다.
- 우리 서비스에선 결제 시스템은 Dirty Read만 방지하면 안정성 확보가 가능했습니다.
- 성능과 안정성의 균형을 고려해 Read Committed를 선택했습니다.
결과적으로 Dirty Read를 방지하면서 결제 처리 성능을 개선할 수 있었습니다.
트랜잭션이 실패했을 때, 이미 진행된 작업을 원래 상태로 되돌리는 것을 보상 트랜잭션이라고 합니다. 결제 승인 후 DB 저장 실패시 외부 결제 서비스와 내부 시스템 상태를 일치시켜야 하기 때문에 보상 트랜잭션 설계는 필수적입니다.
이런 상황에 대비해 우리는 다음과 같은 보상 트랜잭션 플로우를 설계했습니다.
- DB 저장 실패 감지
- Toss Cancel API 호출 → 결제 취소 시도
- 취소 요청 실패시 재시도 (최대 3회)
- 3회 실패시 DLQ로 이관
- DLQ에 적재된 데이터는 수동 환불 프로세스로 전환
우리는 결제 취소 재시도 로직을 Redis 기반으로 설계했습니다.
재시도가 필요한 결제 건에 대해 결제 정보, 시도 회차 등의 정보를 Redis에 저장하고, 최대 3회까지 재시도할 수 있도록 구성했습니다.
재시도 간격은 5초 → 10초 → 15초로 점진적으로 증가하는 전략을 적용했습니다.
이는 짧은 시간 내에 과도한 재시도를 방지하고, 서버 부하를 최소화하기 위한 조치입니다.
또한 재시도 회차에 대한 고민도 있었습니다. 결론적으로 3회로 설정한 이유는 네트워크 지연이나 일시적 장애는 대부분 몇 번의 재시도로 복구가 가능합니다. 즉 3회를 초과하면 일시적 장애가 아니라 지속적 장애로 판단해 서버 리소스 낭비를 방지하기 위해 3회까지만 재시도하는 로직을 구현했습니다. 실무에서도 이와 같은 이유로 보통 3~5회만 재시도하는 방식을 저희 서비스에 반영했습니다.
DLQ는 재시도에도 최종적으로 실패한 요청을 별도로 보관하는 큐입니다. 우리 서비스에서는 Redis를 사용해 DLQ를 구현했습니다.
- 실패 요청 정보를 JSON 형태로 직렬화
- 간단하고 빠른 구현 가능
DLQ에 적재된 결제건은 수동 환불 프로세스로 이관되어 운영자가 직접 처리하도록 합니다.
현재 우리 프로젝트에선 Redis를 기반으로 보상 로직을 구현했습니다.
Redis 기반의 DLQ는 가볍고 빠르다는 장점이 있지만,
장기적으로 봤을 때 redis는 메모리 기반 특성상 대규모 데이터처리에 한계가 있습니다. 또한 장애 복구나 이력관리 측면에서도 제약이 존재합니다.
이와 같은 이유로 Kafka 기반 DLQ 시스템으로의 전환을 고려하고 있습니다.
Kafka는 대량 데이터 처리에 유리하다는 장점이 있습니다.
또한 재시도 로직을 별도로 스케줄링하지 않고, 이벤트 기반으로 처리할 수 있다는 장점이 있습니다.
추후 kafka를 이용해 우리 서비스의 확정성과 안전성을 개선시킬 계획입니다.
-
- 실시간 채팅, 성능 최적화, 캐싱 구조 설계 등
-
- 결제 취소 시스템 설계: 트랜잭션과 보상 전략
-
- PG 솔루션 선택과 데이터 저장 전략