현재 진행중인 프로젝트에서는 분산환경에서 데이터 정합성을 맞추기 위해, 유저의 결제로직이 중간에 실패할경우, 보상트랜잭션을 발행하도록 구성되어 있는데요.
이때, 보상트랜잭션 처리중 메시지가 유실되면 해당 메시지를 일정 시간후에 재처리 하게 됩니다.
주문의 상태를 "FAILED" 처럼 바꾸는 멱등한 API의 경우 메시지를 재처리해도 문제가 되지 않습니다. 그러나, "유저가 결제한 포인트를 복구" 하는것과 같이 멱등하지 않은 API는 유저에게 포인트가 중복해서 더해질 수 있으므로 재처리 과정중 멱등성을 보장해줘야 합니다.
실제로, 주문 - 결제 로직을 spike test 해보면, 아래 사진과 같이 (서버 다운 등의 이유로) 유실된 메시지를 재처리하게 되어서, 유저가 원래 갖고있던 1000 포인트 보다 더 많은 금액을 보상 받는걸 볼 수 있습니다.
(유저가 501원 결제하는 시나리오라서 1회 롤백당 복구되는 포인트는 501원)
해결법
이러한 문제를 해결하기 위해서, 아래 사진과 같이, 요청에 유니크한 식별자 (이하 uid) 를 포함시키고 uid를 별도의 저장소에 캐시한다음,동일한 uid가 들어올경우 캐시된 응답을 즉시 반환하는 방법을 사용할 수 있는데요.
이때, uid cache로직과 update item로직을 반드시 원자적인 연산으로 만들어줘야 합니다.
그렇지 않고, uid 캐시와 처리를 원자적으로 구성하지 않을경우, 아래와 같은 문제가 발생할 수 있습니다.
1. uid 캐시만 성공하고 처리를 실패하면(예외가 던져지거나 서버가 다운되거나..), 요청은 처리 되지 않았는데, uid는 캐시 되어있는 부정합 문제가 발생 할 수 있음.
2. uid 캐시 - 처리 로직 중간에 다른 스레드가 끼어들어서 부정합 문제가 발생할 수 있음.
따라서, 요청 처리와 캐시 로직은 항상 함께 성공하거나 실패해야하며, 다른 요청에서 끼어들 수 없게 구성되어야 합니다.
이 요구사항은 트랜잭션의 Atomic 특성으로 달성할 수 있는데요, 마침 프로젝트에서 RDBMS를 사용하고 있었기 때문에, uid cache와 update item을 하나의 로컬 트랜잭션으로 묶어줄 수 있었습니다.
이렇게 구성하면, 로직이 굉장히 간단해집니다. 일례로, uid 캐시를 먼저하던 요청 처리를 먼저하던 다른 스레드에서 연산 중간에 끼어들 수 없고 항상 함께 성공하기 때문에 순서와 동시성 문제를 고려하지 않고 편하게 코딩할 수 있게되어요.
구현
최종적으로 구현될 순서도는 아래와 같습니다.
우선, uid 캐시를 로컬트랜잭션으로 묶기 위해서 별도의 idempotent 테이블을 생성해줬습니다.
(Jpa가 아닌 R2dbc를 사용중이라 jpa 매핑과 다를 수 있다는점을 유의해서 봐주세요.)
그리고, 아래 코드와 같이 Point rollback과 Idempotent 복구를 하나의 트랜잭션으로 묶어 주었습니다.
단, 이때 갱신손실 문제를 방지해주기 위해서, 락을 걸어주어야 합니다. 하지만, idempotent는 저장된 칼럼이 존재하지 않으므로, 락을 걸 수 없었고, point엔티티에 낙관적락을 걸어서 갱신손실 문제를 방지해줬습니다.
(idempotent 테이블이 다른 테이블에 의존적이지 않게 만들려면 분산락을 활용할 수도 있습니다.)
아래 코드는 Reactor라서.. 간략히 설명하자면 아래와 같습니다.
1. idempotentKey에 해당하는 id가 DB에 이미 저장되어있다면 -> return
2. 저장되어 있지 않다면 idempotent테이블에 idempotentKey 저장, point 롤백
이렇게 함으로써, 포인트 복구 로직의 멱등성을 보장할 수 있었는데요.
여기서 그치지 않고, 앞단에 cache를 추가해서 이미 처리된 로직인 경우 즉시 응답하도록 구현해 성능을 높여주었습니다.
우선, IdempotentKey를 캐시하기위해서, IdempotentCache라는 인터페이스를 정의하고,
Redis를 이용해서 cache를 구현해주었습니다.
(상황에 따라서, local cache로 구현해주어도 무방합니다.)
또한, "캐시된 uid가 있는지 확인하고 없다면 로직을 진행" 하는것은 공통되는 로직이기 때문에, 재사용성을 위해서 새로운 클래스를 만들어서 정의해주었습니다.
아래 코드의 flow는 다음과 같습니다.
1. uid가 캐시되었다면 -> return
2. uid가 캐시되지 않았는데, DB에는 저장되어 있다면, -> cache하고 return (캐시는 메모리용량에 따라서 삭제될 수 있기때문에, 이 로직이 반드시 필요합니다.)
3. uid가 캐시되지 않았고, DB에도 없다면 새로운 요청이므로 -> 로직 진행
실제 사용은 아래와 같이 할 수 있습니다.
이때, sagaRollbackEvent.id는 snowflake기반으로 항상 유니크한 값이 생성되기때문에, idempotent key로 해당 값을 사용했습니다.
33~45번줄을 참고해주세요.
Idempotent 로직이 잘 동작하는지 확인하기위해서 spike test를 여러번 해본 결과, 이번에는 롤백이 정확히 1번 발생했음을 확인할 수 있습니다.
마치며
모든 코드는 아래 링크에서 볼 수 있습니다.
'끄적끄적' 카테고리의 다른 글
[끄적끄적] 효율적인 키 분배 및 리밸런싱 방식 (1) | 2024.06.16 |
---|---|
[끄적끄적] 주문 Saga Isolation 부족 해결하기 (1) | 2024.05.02 |
[끄적끄적] @Transactional 안에서 retry 사용을 주의하세요 (0) | 2024.02.21 |
[끄적끄적] ProtocolBuffer로 API 문서 작성기 (0) | 2024.01.13 |
[끄적끄적] E2E(API) 테스트 자동화 도입기 (0) | 2023.10.18 |