
오늘 포스팅에서는 일명 따닥 이슈를 해결하는 방법, 즉 멱등키를 활용해서 사용자 중복 요청 방지를 하는 방법을 알아보려 합니다!
이런 상황을 한 번 상상해봅시다!!
스마트폰 앱이나 웹 서비스를 통해 물건을 구매하거나 계좌이체를 하려고 합니다. '결제하기' 또는 '송금하기' 버튼을 눌렀는데, 화면에 아무런 변화가 없고 지연 시간만 발생합니다. 이때 많은 사람들이 참지 못하고 버튼을 한 번 더 누르게 되죠.다들 이런 경험 한 번쯤 있으시지 않나요?
이러한 중복 클릭, 일명 '따닥' 문제는 단순한 사용자 경험의 문제를 넘어 심각한 시스템 오류로 이어질 수 있습니다. 예를 들어:
이러한 문제들은 사용자의 불만을 야기하게 되고 데이터 불일치가 발생할 수 있습니다.
제가 진행한 식구하자 MSA 프로젝트에서도 이러한 문제가 잠재적으로 존재했습니다. 특히 결제 관련 요청(서비스 내 페이머니 충전, 환불, 사용자가 거래 요청)에서 중복 요청 방지가 필요했지만, 기존에는 이를 구현하지 않았었습니다.
이번 기회에 Redis와 멱등키를 활용하여 이 문제를 해결하는 방법을 소개하겠습니다.
[👉👉Github 참고]

위에 짤을 보시면 저는 송금하기 버튼을 동시에 따닥! 두 번 눌렀고 송금이 됐다는 알림 메시지가 두 번 나오고 실제로 두 번 요청이 되는 것을 볼 수 있습니다.
이렇게 중복으로 요청이 들어올 경우에 대비해서 처음 1번의 요청만 성공하도록 하고 이후의 요청들은 모두 실패하도록 해결해 보도록 하겠습니다
요청이 중복되었음을 판단할 요청의 식별자 키를 의미!
요청동작-유저id-금액 를 기준으로 요청에 키가 존재하는 지 확인
ex) `charge-1-10000`
멱등하다는 것은 첫 번째 수행을 한 뒤 여러 차례 적용해도 결과를 변경시키지 않는 작업 또는 기능의 속성을 뜻해요. 즉, 멱등한 작업의 결과는 한 번 수행하든 여러 번 수행하든 같습니다.
예를 들어, 어떤 숫자에 1을 곱하는 연산은 여러 번 수행해도 처음 1을 곱한 것과 같은 숫자가 되기 때문에 멱등하다고 볼 수 있습니다.
그래서 이렇게 멱등성을 보장해서 사용자가 실수든 일부든 일정 시간 간격을 정해 놓고 보내는 중복 요청을 처음 요청만 처리하고 나머지 요청은 처리하지 않도록 하겠습니다.

일정 시간 간격 설정은 서버의 최대 지연 시간? 을 고려하고 정말 위에 있는 짤처럼 일부러 그러는거 아니면 일반적으로 사용자가 10초에 한 번씩 똑같은 금액의 페이 머니 충전과 환불, 똑같은 가격의 중고 식물을 거래하는 일은 없다고 봐도 무방하다 판단하여 10초로 설정하였습니다.
지금 제 프로젝트의 인프라에서 멱등키를 저장할 선택지 MYSQL, Redis가 있습니다.(둘 다 사용중)
그 이유는 크게 두가지가 있습니다
Redis의 TTL 기능은 키-값 쌍에 유효 기간을 설정할 수 있게 해줍니다. 이는 멱등키 관리에 매우 유용합니다:
Redis의 싱글스레드 모델은 동시성 처리에 있어 큰 장점을 제공합니다:
이러한 특징들 때문에 Redis를 선택하고 데이터를 저장할 자료구조를 중복을 제거하기 위해 Set를 사용해보도록 하겠습니다
여담으로 현재 프로젝트에서 Redis의 활용도가 어마어마한데, 좋아요 기능에 분산락 걸고, 쿠폰 발급할 때 동시성 제어하고, JWT 리프레시 토큰 저장하는 등등... 근데 문제가 있습니다. Redis 인스턴스가 1개뿐이라 이게 터지면 다 날아가게 되서 단일 실패 지점이 됩니다. 😅
그래서 요즘 Redis Sentinel이나 클러스터 구축을 좀 고민 중이고 구축하는 과정도 이후에 포스팅을 해볼까 합니다!
@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);
}
}
/**
* 식구페이 머니 충전 메서드
* 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();
}
}
여기서 주목할 점은 chargePayMoney와 refundPayMoney 메서드에서 비즈니스 로직 수행 이후에 멱등성을 확인하는 isIdempotent 메서드를 호출한다는 것입니다. 반면 tradePayMoney 메서드는 다르게 동작하고 있죠. 이렇게 구현한 이유가 있습니다:
tradePayMoney 메서드는 다른 메서드들과 달리 멱등키 확인을 비즈니스 로직 이전에 수행합니다. 이에는 중요한 이유가 있습니다:
SAGA 패턴을 사용하여 분산 트랜잭션을 제어합니다.CDC(Change Data Capture) + Outbox 패턴을 적용하여 이벤트 유실을 방지합니다.try 블록 마지막에 수행한다면, 중복 요청 시 발생하는 예외가 catch 블록에 잡히게 됩니다.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);
}
다른 메서드들도 아래 사진처럼 정상적으로 하나의 요청만 처리하는 것을 확인할 수 있습니다!


이번 포스팅에서는 따닥 이슈, 즉 사용자의 중복 요청 문제를 해결하기 위해 멱등키를 활용하는 방법에 대해 알아보았습니다.
주요 내용을 요약하면
chargePayMoney)과 환불(refundPayMoney) 메서드에서는 비즈니스 로직 후에 멱등성을 확인했습니다.tradePayMoney) 메서드에서는 SAGA 패턴과의 조화를 위해 멱등성 확인을 먼저 수행했습니다.이러한 방식으로 중복 요청으로 인한 데이터 불일치와 시스템 오류를 효과적으로 방지할 수 있습니다. 앞으로도 시스템의 안정성과 신뢰성을 높이기 위한 다양한 기술적 접근을 계속 고민해 봐야겠습니다.
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