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

coldrice99·2024년 12월 20일
0

문제 상황

쿠폰 발급 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개의 댓글