Redis Lock 요청 순서 처리 트러블슈팅

coldrice99·2024년 12월 20일

문제 상황

쿠폰 발급 API에서 다수의 요청이 동시에 들어왔을 때, 동시성 문제요청 순서 처리 문제가 발생했다. 초기 코드는 Redis의 RLock을 사용하여 동시성 문제를 해결했으나, 요청을 순서대로 처리하는 데 실패했다. 이를 해결하기 위해 다양한 방법을 시도했으나, 동시에 들어오는 요청의 순서 처리에 완벽한 보장을 구현하기 어렵다는 결론을 지었다.


트러블슈팅 과정

1. 초기 코드와 문제점

초기 코드

@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();
                }
            }
        });
    }
}

문제점

  1. 동시성 제어: RLock으로 처리되어 문제없음.
  2. 요청 순서 처리 실패: 동시에 여러 요청이 들어왔을 때, 요청 순서대로 처리되지 않음.

2. 요청 순서 처리 시도

2.1 Redis Queue를 활용한 요청 순서 처리

  • 아이디어: Redis List 자료구조에 요청을 순차적으로 추가하고, 본인의 순서가 되면 실행.
  • 코드:
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); // 자신의 순서가 아닐 경우 대기
}
  • 문제점:
    • 요청 간 간격이 매우 짧을 경우 동시에 여러 요청이 추가되어 순서 꼬임.
    • Redis 외부에서의 동시성 문제로 인해 요청 간 지연 발생.

2.2 Lua Script를 활용한 요청 순서 확인

  • 아이디어: Redis Lua Script를 활용해 현재 큐의 첫 번째 요청인지 확인.
  • 코드:
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)
);
  • 문제점:
    • Lua Script는 Redis 서버에서 원자적으로 실행되지만, Redis 외부의 동시성 문제를 해결하지 못함.
    • 요청의 timestamp가 거의 동일하거나 충돌할 경우, 요청 순서가 어긋남.

2.3 Redis Sorted Set을 활용한 요청 순서 처리

  • 아이디어: Redis Sorted Set에 요청을 추가하고, 최소 점수를 가진 요청만 실행.
  • 문제점:
    • System.nanoTime() 기반 timestamp 값이 충돌할 가능성 있음.
    • Redis Sorted Set은 점수가 동일할 경우 memberId의 사전순 정렬을 따르므로, 예상과 다른 순서로 실행.

3. 요청 순서 처리 실패 원인 분석

3.1 Redis와 Lua Script의 한계

  • Redis는 매우 빠른 속도를 제공하지만, 외부의 동시성 문제를 제어하지 못함.
  • Lua Script가 Redis 서버 내에서 원자적으로 실행되더라도, 응답 간 시간 차로 인해 외부 요청의 순서가 엉킬 수 있음.

3.2 점수 충돌로 인한 정렬 실패

  • Redis Sorted Set에서 점수가 동일할 경우, memberId를 기준으로 사전순 정렬이 수행됨.
  • System.nanoTime() 값을 사용해 점수를 생성했지만, 요청 간 간격이 매우 짧아 충돌 발생.

3.3 현실적인 요청 간 간격 문제

  • 테스트 환경에서는 Thread.sleep(20)으로 간격을 강제 조정해 요청 순서를 처리했으나, 이는 실제 서비스 환경에서 비현실적.

4. 최종 결론: 거의 동시에 들어오는 요청에 대한 순서 처리 포기

  1. 이유
  • Redis의 기본 데이터 구조와 Lua Script를 활용해 여러 시도를 했지만, 거의 동시에 들어오는 요청의 순서 보장은 현실적으로 어렵다는 결론에 도달.
  • 요청 간 간격이 거의 없어 점수 충돌과 정렬 문제가 발생.
  1. 현실적인 해결책
    • 테스트에서 요청 간에 약간의 시간 간격(Thread.sleep(20);, 약 0.02초)을 두니 요청 순서가 보장되었음.
    • 따라서 0.02초 이상의 간격을 두고 요청이 들어오는 경우, 요청 순서대로 처리가 되는 것을 확인.

5. 테스트 코드

@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());
}

테스트 결과

요청 순서 처리 실패 (거의 동시에 요청)

0.02초 간격 추가 후 요청 순서 처리 성공



향후 개선 방향

  1. 메시지 큐 도입:
    • 요청 순서가 중요한 경우 Kafka나 RabbitMQ 같은 메시지 큐를 고려.
  2. Redis 활용의 한계 인식:
    • Redis는 주로 간단한 동시성 제어 및 캐싱에 적합하며, 복잡한 요청 순서 처리는 별도의 도구로 해결 필요.

다음 포스트에서 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

profile
서두르지 않으나 쉬지 않고

0개의 댓글