쿠폰 발급 API에서 다수의 요청이 동시에 들어왔을 때, 동시성 문제와 요청 순서 처리 문제가 발생했다. 초기 코드는 Redis의 RLock
을 사용하여 동시성 문제를 해결했으나, 요청을 순서대로 처리하는 데 실패했다. 이를 해결하기 위해 다양한 방법을 시도했으나, 동시에 들어오는 요청의 순서 처리에 완벽한 보장을 구현하기 어렵다는 결론을 지었다.
@Transactional
public void issueCoupon(Long couponId, RequestDto request, Member member) {
// Redis 락
String lockKey = "coupon:" + couponId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 락 획득 시도 (최대 1초 대기, 10초 유지)
if (!lock.tryLock(1, 10, TimeUnit.SECONDS)) {
throw new IllegalStateException("동시에 너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.");
}
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new GlobalException(COUPON_NOT_FOUND));
if (issuanceRepository.findByMemberIdAndCouponId(member.getId(), couponId).isPresent()) {
throw new GlobalException(COUPON_ALREADY_ISSUED);
}
coupon.validateQuantity();
coupon.decreaseQuantity();
Issuance issuance = Issuance.builder()
.member(member)
.coupon(coupon)
.build();
issuanceRepository.save(issuance);
} catch (InterruptedException e) {
throw new IllegalArgumentException("Redis 락을 획득하는데 실패했습니다.", e);
} finally {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
@Override
public void afterCompletion(int status) {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
});
}
}
RLock
으로 처리되어 문제없음.String queueKey = "coupon:queue:" + couponId;
redisTemplate.opsForList().rightPush(queueKey, threadId);
while (true) {
String currentThreadId = redisTemplate.opsForList().index(queueKey, 0);
if (threadId.equals(currentThreadId)) {
break;
}
Thread.sleep(50); // 자신의 순서가 아닐 경우 대기
}
String luaScript = """
local sortedSetKey = KEYS[1]
local memberId = ARGV[1]
local timestamp = ARGV[2]
redis.call('ZADD', sortedSetKey, timestamp, memberId)
local minMember = redis.call('ZRANGE', sortedSetKey, 0, 0)[1]
if minMember == memberId then
return true
else
return false
end
""";
Boolean isEligible = redisTemplate.execute(
script,
Collections.singletonList(sortedSetKey),
String.valueOf(member.getId()), String.valueOf(uniqueTimestamp)
);
timestamp
가 거의 동일하거나 충돌할 경우, 요청 순서가 어긋남.System.nanoTime()
기반 timestamp
값이 충돌할 가능성 있음.System.nanoTime()
값을 사용해 점수를 생성했지만, 요청 간 간격이 매우 짧아 충돌 발생.Thread.sleep(20)
으로 간격을 강제 조정해 요청 순서를 처리했으나, 이는 실제 서비스 환경에서 비현실적.Thread.sleep(20);
, 약 0.02초)을 두니 요청 순서가 보장되었음.@Test
@DisplayName("Redis 락 동시성 제어 & 요청 순서 처리 테스트")
void redisLockConcurrencyTest() throws InterruptedException {
for (int i = 0; i < threadCount; i++) {
int threadId = i;
Thread.sleep(20); // 20ms 간격
executorService.submit(() -> {
try {
System.out.println("Thread 시작: " + Thread.currentThread().getId());
Member threadMember = new Member("Test User" + threadId, "test" + threadId + "@example.com",
"password", MemberRole.USER);
memberRepository.save(threadMember);
couponService.issueCoupon(coupon.getId(), new RequestDto(threadMember.getId()), threadMember);
} catch (GlobalException e) {
System.out.println("Thread 예외: " + Thread.currentThread().getId() + ", " + e.getMessage());
} finally {
latch.countDown();
System.out.println("Thread 완료: " + Thread.currentThread().getId());
}
});
}
latch.await();
// 결과 검증
Coupon updatedCoupon = couponRepository.findById(coupon.getId()).orElseThrow();
System.out.println("남은 쿠폰 수량: " + updatedCoupon.getQuantity());
System.out.println("발급된 쿠폰 수량: " + issuanceRepository.count());
assertEquals(0, updatedCoupon.getQuantity());
assertEquals(100, issuanceRepository.count());
}
다음 포스트에서 FairLock을 사용하여 간단하게 순서가 보장하는 것을 성공
https://velog.io/@happy_code/FairLock%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%88%9C%EC%84%9C-%EB%B3%B4%EC%9E%A5-%ED%99%95%EC%9D%B8