[식구하자_MSA] 멱등키를 활용한 사용자 중복 요청 방지 (Feat: 따닥 이슈)

이민우·2024년 7월 31일

🍀 식구하자_MSA

목록 보기
18/21

🔎 배경

오늘 포스팅에서는 일명 따닥 이슈를 해결하는 방법, 즉 멱등키를 활용해서 사용자 중복 요청 방지를 하는 방법을 알아보려 합니다!

이런 상황을 한 번 상상해봅시다!!

스마트폰 앱이나 웹 서비스를 통해 물건을 구매하거나 계좌이체를 하려고 합니다. '결제하기' 또는 '송금하기' 버튼을 눌렀는데, 화면에 아무런 변화가 없고 지연 시간만 발생합니다. 이때 많은 사람들이 참지 못하고 버튼을 한 번 더 누르게 되죠.다들 이런 경험 한 번쯤 있으시지 않나요?

이러한 중복 클릭, 일명 '따닥' 문제는 단순한 사용자 경험의 문제를 넘어 심각한 시스템 오류로 이어질 수 있습니다. 예를 들어:

  • 중복 결제: 같은 상품에 대해 두 번 결제가 발생할 수 있습니다.
  • 중복 송금: 의도치 않게 같은 금액을 두 번 보낼 수 있습니다.
  • 데이터 불일치: 서버에 중복된 데이터가 저장되어 시스템 정합성이 깨질 수 있습니다.

이러한 문제들은 사용자의 불만을 야기하게 되고 데이터 불일치가 발생할 수 있습니다.

제가 진행한 식구하자 MSA 프로젝트에서도 이러한 문제가 잠재적으로 존재했습니다. 특히 결제 관련 요청(서비스 내 페이머니 충전, 환불, 사용자가 거래 요청)에서 중복 요청 방지가 필요했지만, 기존에는 이를 구현하지 않았었습니다.

이번 기회에 Redis멱등키를 활용하여 이 문제를 해결하는 방법을 소개하겠습니다.
[👉👉Github 참고]

🤔 문제 상황


위에 짤을 보시면 저는 송금하기 버튼을 동시에 따닥! 두 번 눌렀고 송금이 됐다는 알림 메시지가 두 번 나오고 실제로 두 번 요청이 되는 것을 볼 수 있습니다.

이렇게 중복으로 요청이 들어올 경우에 대비해서 처음 1번의 요청만 성공하도록 하고 이후의 요청들은 모두 실패하도록 해결해 보도록 하겠습니다

🔐 그래서 아까부터 멱등키가 뭔데?

멱등키?

요청이 중복되었음을 판단할 요청의 식별자 키를 의미!
요청동작-유저id-금액 를 기준으로 요청에 키가 존재하는 지 확인

ex) `charge-1-10000`

멱등하다는것은?

멱등하다는 것은 첫 번째 수행을 한 뒤 여러 차례 적용해도 결과를 변경시키지 않는 작업 또는 기능의 속성을 뜻해요. 즉, 멱등한 작업의 결과는 한 번 수행하든 여러 번 수행하든 같습니다.
예를 들어, 어떤 숫자에 1을 곱하는 연산은 여러 번 수행해도 처음 1을 곱한 것과 같은 숫자가 되기 때문에 멱등하다고 볼 수 있습니다.

[토스 Payments 블로그 참조]

그래서 이렇게 멱등성을 보장해서 사용자가 실수든 일부든 일정 시간 간격을 정해 놓고 보내는 중복 요청을 처음 요청만 처리하고 나머지 요청은 처리하지 않도록 하겠습니다.

일정 시간 간격 설정은?

일정 시간 간격 설정은 서버의 최대 지연 시간? 을 고려하고 정말 위에 있는 짤처럼 일부러 그러는거 아니면 일반적으로 사용자가 10초한 번씩 똑같은 금액의 페이 머니 충전과 환불, 똑같은 가격의 중고 식물을 거래하는 일은 없다고 봐도 무방하다 판단하여 10초로 설정하였습니다.

💽 멱등키를 어디다 저장할까?

지금 제 프로젝트의 인프라에서 멱등키를 저장할 선택지 MYSQL, Redis가 있습니다.(둘 다 사용중)

결론적으로 Redis를 사용을 했습니다.

그 이유는 크게 두가지가 있습니다

1. TTL(Time To Live) 기능

Redis의 TTL 기능은 키-값 쌍에 유효 기간을 설정할 수 있게 해줍니다. 이는 멱등키 관리에 매우 유용합니다:

  • 자동 만료: 설정한 10초 후에 멱등키가 자동으로 삭제됩니다. 이로 인해 추가적인 삭제 작업이 필요 없어 시스템 부하가 줄어듭니다.
  • 간단한 구현: MYSQL을 사용할 경우, 주기적으로 만료된 키를 삭제하는 별도의 로직이 필요하지만, Redis를 사용하면 이런 복잡성을 피할 수 있습니다.

2. Redis의 싱글스레드 특성

Redis의 싱글스레드 모델은 동시성 처리에 있어 큰 장점을 제공합니다:

  • 원자성 보장: 사용자가 버튼을 빠르게 여러 번 누르는 경우에도, Redis는 각 요청을 순차적으로 실행하고 결과를 전달하기 때문에 동시성 이슈를 해결 가능!
  • DB 차원 락(Lock) 불필요: MYSQL에서는 동시성 문제를 해결하기 위해 데이터베이스 수준의 락이 필요할 수 있습니다. 이는 성능 저하를 일으킬 수 있습니다. 반면 Redis는 싱글스레드로 동작하여 별도의 락 메커니즘 없이도 데이터 일관성을 유지할 수 있습니다.

이러한 특징들 때문에 Redis를 선택하고 데이터를 저장할 자료구조를 중복을 제거하기 위해 Set를 사용해보도록 하겠습니다

여담으로 현재 프로젝트에서 Redis의 활용도가 어마어마한데, 좋아요 기능에 분산락 걸고, 쿠폰 발급할 때 동시성 제어하고, JWT 리프레시 토큰 저장하는 등등... 근데 문제가 있습니다. Redis 인스턴스가 1개뿐이라 이게 터지면 다 날아가게 되서 단일 실패 지점이 됩니다. 😅
그래서 요즘 Redis Sentinel이나 클러스터 구축을 좀 고민 중이고 구축하는 과정도 이후에 포스팅을 해볼까 합니다!

📌 구현

Redis Set 활용

@Repository
@RequiredArgsConstructor
public class IdempotencyKeyRepository {
    private final RedisTemplate<String, String> redisTemplate;

    private static final long EXPIRATION_TIME = 10; // 10초
    /**
     * 결제 요청 멱등성 보장
     * @param : String key(메서드키-유저id-금액)
     * @return True | False
     */
    public boolean addRequest(String key) {
        return redisTemplate.opsForValue().setIfAbsent(key, "success", EXPIRATION_TIME, TimeUnit.SECONDS);
    }
}
  • redisTemplate.opsForValue().setIfAbsent() : SET NX 명령어 수행 후 boolean 반환
  • Redis SET NX 결과 false : 중복 요청
  • Redis SET NX 결과 true : 첫 요청

멱등키 적용할 메서드

	/**
     * 식구페이 머니 충전 메서드
     * iamport로 결제 완료 되면 페이 머니로 충전
     * 비즈니스 로직을 진행하고 멱등키 확인
     * @param : PaymentRequestDto paymentRequestDto, String key
     * @return StatusResponseDto
     */
    @Transactional
    public StatusResponseDto chargePayMoney(PaymentRequestDto paymentRequestDto, String key) {
        if (!paymentRepository.existsByMemberNo(paymentRequestDto.getMemberNo())) {
            Payment payment=Payment.builder()
                    .payMoney(paymentRequestDto.getPayMoney())
                    .memberNo(paymentRequestDto.getMemberNo())
                    .build();

            paymentRepository.save(payment);
        }
        else{
            paymentRepository.existsByMemberNoUpdatePayMoney(paymentRequestDto);
        }
        // 비즈니스 로직 수행 후 멱등성 키 저장
        isIdempotent(key);
        return StatusResponseDto.success();
    }
    /**
     * 식구페이 머니 환불 메서드
     * 원하는 금액 환불후 계좌 송금(실제로 계좌로 이체되진 않음)
     * 환불할 금액이 없을 경우 예외 처리
     * synchronized를 통해 동시성 제어!
     * @param : UpdatePaymentRequestDto paymentRequestDto
     */
    @Transactional
    public StatusResponseDto refundPayMoney(PaymentRequestDto paymentRequestDto, String key) {
        // memberNo로 보유 페이머니 조회
        Payment payment = paymentRepository.findByMemberNo(paymentRequestDto.getMemberNo());
        //보유 페이 머니보다 입력한 환불할 금액이 많으면 예외 처리
        if (payment.getPayMoney()- paymentRequestDto.getPayMoney() < 0) {
            throw ErrorCode.throwInsufficientRefundPayMoney();
        }
        payment.decreasePayMoney(paymentRequestDto.getPayMoney());
        paymentRepository.save(payment);
        // 비즈니스 로직 수행 후 멱등성 키 저장
        isIdempotent(key);
        return StatusResponseDto.success();
    }
     /**
     *
     * 식구페이 거래 메서드
     * 판매자 상대 멤버 번호를 통해 해당 조회 후
     * 판매자 paymoney += 거래할 금액
     * 구매자 Paymoney -= 거래할 금액
     * 분산 트랜잭션 Saga Pattern 적용
     * 쿠폰 사용 + 결제(사용자간의 거래) 워크플로우에 신뢰성을 보장하기 위해 Transactional outbox pattern + CDC 적용
     * 에러 발생시 쿠폰 마이크로서비스로 보상 트랜잭션 시작, Rollback
     * @param : PaymentRequestDto paymentRequestDto, Integer sellerNo
     */
    @Transactional
    public void tradePayMoney(PaymentRequestDto paymentRequestDto, String key) throws JsonProcessingException {
        OutboxEvent outboxEvent = parsingEvent(paymentRequestDto);
        isIdempotent(key);
        try {
            Payment buyerPayment = paymentRepository.findByMemberNo(paymentRequestDto.getMemberNo());
            Integer buyerPayMoney = paymentRequestDto.getPayMoney();

            if (paymentRequestDto.getCouponStatus() == CouponStatus.쿠폰사용) {
                //쿠폰 사용시 구매자 결제정보만 쿠폰금액 차감
                buyerPayMoney += paymentRequestDto.getDiscountPrice();
            }
//            errorPerHalf();
            paymentRepository.tradePayMoney(paymentRequestDto.getSellerNo(), buyerPayment.getMemberNo(), paymentRequestDto, buyerPayMoney);
        } catch (Exception e) {
            // 결제 실패 시 보상 트랜잭션 발행을 위한 outboxEvent
            log.error("===[결제 요청 오류] -> coupon-rollback ,  쿠폰 번호 :{} / {}====", paymentRequestDto.getCouponNo(), e.getMessage());
            outboxEventRepository.save(outboxEvent);
        }
    }
    private void isIdempotent(String key) {
        if (!idempotencyKeyRepository.addRequest(key)) {
            log.error("결제 관련 요청중 중복 감지: {}", key);
            throw ErrorCode.throwIdempotencyKeyExists();
        }
    }

💡 여기서 주목할 점!

여기서 주목할 점은 chargePayMoneyrefundPayMoney 메서드에서 비즈니스 로직 수행 이후에 멱등성을 확인하는 isIdempotent 메서드를 호출한다는 것입니다. 반면 tradePayMoney 메서드는 다르게 동작하고 있죠. 이렇게 구현한 이유가 있습니다:

  1. 비즈니스 로직 내 검증 우선:
    • 충전과 환불 로직에는 여러 검증 단계가 포함되어 있습니다. 예를 들어, 환불 시 보유 금액 확인 등이 있죠.
    • 이러한 검증을 통과하지 못하면 예외가 발생하고 트랜잭션이 롤백됩니다.
  2. 멱등키 저장 타이밍:
    • 만약 멱등키를 비즈니스 로직 전에 저장한다면 어떻게 될까요?
    • 멱등키는 저장되어 10초간 중복 요청을 막겠지만, 이후 비즈니스 로직에서 예외가 발생하면 실제 데이터는 저장되지 않습니다.
    • 결과적으로 사용자는 10초 동안 재시도를 못하고, 그 후에도 실패한 요청을 다시 보내야 하는 불편함이 생깁니다.

🤷‍♂️ tradePayMoney(거래) 메서드의 멱등키 확인 순서만 다른 이유?!

tradePayMoney 메서드는 다른 메서드들과 달리 멱등키 확인을 비즈니스 로직 이전에 수행합니다. 이에는 중요한 이유가 있습니다:

  1. 분산 트랜잭션 제어:
    • 이 메서드는 *SAGA 패턴을 사용하여 분산 트랜잭션을 제어합니다.
    • 추가적으로 *CDC(Change Data Capture) + Outbox 패턴을 적용하여 이벤트 유실을 방지합니다.
      [👉 이전 포스팅 참고]
  2. 예외 처리와 롤백 관계:
    • 만약 멱등키 확인을 try 블록 마지막에 수행한다면, 중복 요청 시 발생하는 예외가 catch 블록에 잡히게 됩니다.
    • 이 경우, 앞서 수행된 비즈니스 로직이 롤백되지 않고, 대신 보상 트랜잭션을 위한 outbox에 저장되어 버립니다.
    • 결과적으로 중복 요청을 제대로 처리하지 못하는 문제가 발생합니다.
  3. SAGA 패턴과의 조화:
    • SAGA 패턴을 사용하는 이 메서드의 특성상, 멱등키 확인을 비즈니스 로직 이전에 수행하는 것이 더 적합하다고 판단했습니다
    • 이를 통해 중복 요청을 먼저 필터링하고, 유효한 요청에 대해서만 복잡한 분산 트랜잭션 로직을 수행할 수 있습니다.
  4. trade-off:
    • 물론 멱등키 확인을 비즈니스 로직 이전에 수행하면 앞서 언급한 문제(검증 실패 시 10초간 재시도 불가)가 여전히 존재합니다.
    • 하지만 SAGA 패턴의 정확한 수행중복 요청 방지의 중요성을 고려할 때, 이 방식이 tradePayMoney 메서드에서는 더 적절한 선택이라고 판단했습니다

테스트

중복 요청이 여러 번(1000번) 동시에 들어왔을 때 CountDownLatch 와 ExecutorService 를 통해서 잘 동작하는지 테스트해보도록 하겠습니다!

@Test
    @DisplayName("충전 요청의 멱등성 테스트")
    void chargePayMoney() throws InterruptedException {
        // given
        int threadCount = 1000;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount = new AtomicInteger(0);

        // when
        for (int i = 0; i < threadCount; i++) {
            PaymentRequestDto paymentRequestDto = new PaymentRequestDto(10000, 1);
            executorService.submit(() -> {
                try {
                    paymentService.chargePayMoney(paymentRequestDto, "charge-1-10000");
                    successCount.incrementAndGet();
                } catch (Exception e) {
                    failCount.incrementAndGet();
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();

        // then
        Payment payment = paymentRepository.findByMemberNo(1);
        assertThat(payment.getPayMoney()).isEqualTo(10000);
        assertThat(successCount.get()).isEqualTo(1);
        assertThat(failCount.get()).isEqualTo(threadCount - 1);

        // 멱등성 키가 저장되었는지 확인
        assertTrue(idempotencyKeyRepository.addRequest("charge-1-10000"));

        // 테스트 후 정리
        paymentRepository.delete(payment);
    }


    @Test
    @DisplayName("환불 요청의 멱등성 테스트")
    void refundPayMoney() throws InterruptedException {
        // given
        int threadCount = 1000;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount = new AtomicInteger(0);

        // 초기 잔액 설정
        Payment initialPayment = new Payment(20000, 1);

        paymentRepository.save(initialPayment);
        // when
        for (int i = 0; i < threadCount; i++) {
            PaymentRequestDto paymentRequestDto = new PaymentRequestDto(10000, 1);
            executorService.submit(() -> {
                try {
                    paymentService.refundPayMoney(paymentRequestDto, "refund-1-10000");
                    successCount.incrementAndGet();
                } catch (Exception e) {
                    failCount.incrementAndGet();
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();

        // then
        Payment payment = paymentRepository.findByMemberNo(1);
        assertThat(payment.getPayMoney()).isEqualTo(10000);
        assertThat(successCount.get()).isEqualTo(1);
        assertThat(failCount.get()).isEqualTo(threadCount - 1);

        // 멱등성 키가 저장되었는지 확인
        assertTrue(!idempotencyKeyRepository.addRequest("refund-1-10000"));
        // 테스트 후 정리
        paymentRepository.delete(payment);
    }

    @Test
    @DisplayName("거래 요청의 멱등성 테스트")
    void tradePayMoney() throws InterruptedException {
        // given
        int threadCount = 1000;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount = new AtomicInteger(0);

        // 초기 잔액 설정 (구매자와 판매자)
        Payment buyerPayment = new Payment(20000, 1);
        paymentRepository.save(buyerPayment);

        Payment sellerPayment = new Payment(0, 2);
        paymentRepository.save(sellerPayment);

        // when
        for (int i = 0; i < threadCount; i++) {
            PaymentRequestDto paymentRequestDto = new PaymentRequestDto(10000, 1);
            paymentRequestDto.setSellerNo(2);
            paymentRequestDto.setCouponNo(1L);
            executorService.submit(() -> {
                try {
                    paymentService.tradePayMoney(paymentRequestDto, "trade-1-10000");
                    successCount.incrementAndGet();
                } catch (Exception e) {
                    failCount.incrementAndGet();
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();

        // then
        Payment updatedBuyerPayment = paymentRepository.findByMemberNo(1);
        Payment updatedSellerPayment = paymentRepository.findByMemberNo(2);
        assertThat(updatedBuyerPayment.getPayMoney()).isEqualTo(10000);
        assertThat(updatedSellerPayment.getPayMoney()).isEqualTo(10000);
        assertThat(successCount.get()).isEqualTo(1);
        assertThat(failCount.get()).isEqualTo(threadCount - 1);

        // 멱등성 키가 저장되었는지 확인
        assertTrue(!idempotencyKeyRepository.addRequest("trade-1-10000"));

        // 테스트 후 정리
        paymentRepository.delete(updatedBuyerPayment);
        paymentRepository.delete(updatedSellerPayment);
    }

결과

다른 메서드들도 아래 사진처럼 정상적으로 하나의 요청만 처리하는 것을 확인할 수 있습니다!

마무리

이번 포스팅에서는 따닥 이슈, 즉 사용자의 중복 요청 문제를 해결하기 위해 멱등키를 활용하는 방법에 대해 알아보았습니다.

주요 내용을 요약하면

  1. Redis의 TTL 기능과 싱글스레드 특성을 활용하여 멱등키를 효과적으로 관리했습니다.
  2. 충전(chargePayMoney)과 환불(refundPayMoney) 메서드에서는 비즈니스 로직 후에 멱등성을 확인했습니다.
  3. 거래(tradePayMoney) 메서드에서는 SAGA 패턴과의 조화를 위해 멱등성 확인을 먼저 수행했습니다.
  4. 테스트를 통해 1000번의 동시 요청 중 단 한 번만 성공적으로 처리되는 것을 확인했습니다.

이러한 방식으로 중복 요청으로 인한 데이터 불일치와 시스템 오류를 효과적으로 방지할 수 있습니다. 앞으로도 시스템의 안정성과 신뢰성을 높이기 위한 다양한 기술적 접근을 계속 고민해 봐야겠습니다.

오늘도 읽어주셔서 감사합니다!🙏🙏

참고

https://docs.tosspayments.com/blog/what-is-idempotency
https://ksh-coding.tistory.com/149#2.%C2%A0%20%EB%A9%B1%EB%93%B1%ED%82%A4%EB%A5%BC%20%EC%96%B4%EB%94%94%EC%97%90%20%EC%A0%80%EC%9E%A5%ED%95%A0%EA%B9%8C%3F-1
https://velog.io/@mohai2618/%EB%8F%99%EC%8B%9C%EC%84%B1-%ED%99%98%EA%B2%BD-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0

profile
백엔드 공부중입니다!

0개의 댓글