• 카테고리

    질문 & 답변
  • 세부 분야

    백엔드

  • 해결 여부

    미해결

Redis의 INCR 사용 해도 Race condition이 잡히지 않는 문제

24.07.01 19:12 작성 조회수 50

0

안녕하세요! 강사님 덕분에 현재 진행중인 프로젝트 코드에 동시성 문제 잘 적용하고 있습니다.

 

다름이 아니라, 현재 MSA 프로젝트를 하고 있어 쿠폰 쪽 DB는 쿠폰만 쓰기 때문에 부하에 따른 걱정은 덜어도 될 것 같아 카프카가 아닌 Redis의 INCR 명령어를 통해서 선착순 쿠폰 로직의 Race condition 문제를 잡아보려고 하고 있는데요.

질문에 앞서 프로젝트 환경을 말씀드리자면 강의와 동일하게 docker pull redis 해서 이미지로 다운받아 실행했고, 6380 포트로 연결해줘서 yml 설정과 Config 설정 또한 해줬습니다. INCR 키 값을 살짝 바꿔서 coupon_count:{couponId} 가 되도록 해줬습니다.

 

문제는 1000개의 쓰레드로 요청을 날렸을 때 여러번의 요청 테스트는 통과가 안된다는 점입니다.. 쿠폰 발급이 100개 되어야하는데 102, 103번 애매하게 되고 있습니다. (postman으로 API 요청 날릴 때마다 해당 키 값의 value가 1씩 잘 증가하는 것은 확인했습니다) RDB 상에서도 100개의 발급 내역이 잘 들어오고 있구요. (물론 테스트가 DB에 영향을 미치면 안되지만요ㅠㅠ)

이런 경우는 왜 그런건가요?? 코드도 첨부 하겠습니다. 감사합니다!!

/*
 * 선착순 쿠폰 발급
 */
@Transactional
public CouponIssuedResponseDto issueFirstComeCoupon(CouponIssuedRequestDto request) {
    // 쿠폰 ID로 쿠폰을 찾고, 존재하지 않으면 예외 처리
    Coupon coupon = couponRepository.findById(request.couponId())
            .orElseThrow(() -> new CustomException(ErrorCode.COUPON_NOT_FOUND));

    // 발급 가능한 쿠폰 수량 확인 -> Redis
    Long currentCount = couponCountRepository.getCount(request.couponId());
    if (currentCount > coupon.getMaxQuantity()) {
        throw new CustomException(ErrorCode.COUPON_ISSUE_LIMIT_EXCEEDED);
    }

    // 새로운 쿠폰 발급 -> Redis에서 수량 증가
    Long newCount = couponCountRepository.increment(request.couponId());
    if (newCount > coupon.getMaxQuantity()) {
        throw new CustomException(ErrorCode.COUPON_ISSUE_LIMIT_EXCEEDED);
    }

    CouponIssued issued = CouponIssued.builder()
            .coupon(coupon)
            .userId(request.userId())
            .issuedAt(LocalDateTime.now())
            .build();

    CouponIssued saved = couponIssuedRepository.save(issued);

    return CouponIssuedResponseDto.fromEntity(saved);
}
@Repository
public class CouponCountRepository {
    private final RedisTemplate<String, Long> redisTemplate;

    public CouponCountRepository(RedisTemplate<String, Long> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public Long increment(Long couponId) {
        String key = "coupon_count:" + couponId;
        return redisTemplate.opsForValue().increment(key, 1);
    }

    public Long getCount(Long couponId) {
        String key = "coupon_count:" + couponId;
        Long count = redisTemplate.opsForValue().get(key);
        return count != null ? count : 0L;
    }
}
@BeforeEach
public void setUp() {
    // Redis 초기화
    redisTemplate.opsForValue().set("coupon_count:10", 0L);
}

@AfterEach
public void tearDown() {
    couponRepository.deleteAll();
    couponIssuedRepository.deleteAll();
    redisTemplate.delete("coupon_count:10");  // 테스트 끝난 후 Redis 데이터 삭제
}

@Test
public void multipleUserIssueCoupon() throws InterruptedException {
    // Given
    // 쿠폰을 생성
    Coupon coupon = Coupon.builder()
            .name("Test Coupon")
            .couponCode("TEST100")
            .discountRate(10)
            .maxQuantity(100L)
            .issuedQuantity(0L)
            .expiresAt(LocalDateTime.now().plusDays(30))
            .couponType(CouponTypeEnum.FIRST_COME)
            .build();
    couponRepository.save(coupon);

    Long couponId = coupon.getCouponId();  // 고정된 couponId 사용
    Long userId = 1L;  // 고정된 userId 사용

    // When
    int threadCount = 1000;
    ExecutorService executorService = Executors.newFixedThreadPool(50);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                CouponIssuedRequestDto request = new CouponIssuedRequestDto(10L, userId);
                couponIssuedService.issueFirstComeCoupon(request);
            } catch (CustomException e) {
                // Expected exception when limit is exceeded
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await(20, TimeUnit.SECONDS);

    // Then
    Long count = redisTemplate.opsForValue().get("coupon_count:10");
    System.out.println("coupon_count:10 = " + count);  // 디버깅용 로그 추가
    assertThat(count).isNotNull();  // count가 null이 아닌지 확인
    assertThat(count).isEqualTo(100);

    // 발급된 쿠폰의 수를 확인
    long issuedCount = couponIssuedRepository.count();
    assertThat(issuedCount).isEqualTo(100);
}

 

답변 1

답변을 작성해보세요.

0

이경진님 안녕하세요.
아래의 부분에서 이해가 되지않는 부분이 있어서 질문을 남깁니다.

쿠폰 발급이 100개 되어야하는데 102, 103번 애매하게 되고 있습니다. (postman으로 API 요청 날릴 때마다 해당 키 값의 value가 1씩 잘 증가하는 것은 확인했습니다) RDB 상에서도 100개의 발급 내역이 잘 들어오고 있구요. (물론 테스트가 DB에 영향을 미치면 안되지만요ㅠㅠ)

위의 말이 아래의 케이스중에 어떤케이스인가요 ?
두케이스 모두 해당이 안된다면 어떤케이스인지 말씀해주실 수 있으실까요 ?

  1. 쿠폰은 100개만 발급이 되지만 redis 의 값이 102,103이 된다

  2. 쿠폰도 102개가 발급되고 redis 의 값도 102다

이경진님의 프로필

이경진

질문자

2024.07.02

빠른 답변 감사드립니다!!
1번과 같은 상황이었는데, 구글링을 해보다가 루아스크립트란 걸 알게되서 레디스 내에서 원자성을 맞추기 위해 루아스크립트를 사용해봤습니다. 그래서 레디스 내에서는 쿠폰이 100개 발급되는 것을 확인했습니다.

그러나 그 이후 로직(RDB에서 쿠폰값을 저장하는 부분) 에서 race condition이 또 발생하는지 Redis에는 100개가 찍히고 최종 테스트에서는 Actual 값이 102, 103 찍히는 현상이 발생하고 있는 중입니다 ㅠㅠ 그래서 제가 궁금한 점은,

  1. 1번과 같은 상황에서 루아 스크립트를 사용하지 않고도 상황을 해결할 수 있는 방법이 있을까요? 혹은 루아 스크립트가 최선의 방법인가요?

  2. 루아스크립트를 써서 바꾼 코드에서는 어떤 부분이 문제여서 정합성 문제가 생기는 걸까요 ..ㅠㅠㅠㅠㅠㅠ

혹시 몰라 루아스크립트를 사용해 바꾼 로직까지 첨부드리겠습니다. 번거로우실텐데 답변 감사합니다 상용님!!!

@Repository
public class CouponCountRepository {
    private final RedisTemplate<String, Long> redisTemplate;

    public CouponCountRepository(RedisTemplate<String, Long> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    // Lua 스크립트로 쿠폰 발급 수량을 원자적으로 증가시키는 메소드
    public Long incrementIfAllowed(Long couponId, Long maxQuantity) {
        String key = "coupon_count:" + couponId;
        String script = "local currentCount = redis.call('GET', KEYS[1]) " +
                "if tonumber(currentCount) < tonumber(ARGV[1]) then " +
                "return redis.call('INCR', KEYS[1]) " +
                "else " +
                "return -1 " +
                "end";
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        return redisTemplate.execute(redisScript, Collections.singletonList(key), maxQuantity);
    }
}
/*
 * 선착순 쿠폰 발급
 */
@Transactional
public CouponIssuedResponseDto issueFirstComeCoupon(CouponIssuedRequestDto request) {
    // 쿠폰 ID로 쿠폰을 찾고, 존재하지 않으면 예외 처리
    Coupon coupon = couponRepository.findById(request.couponId())
            .orElseThrow(() -> new CustomException(ErrorCode.COUPON_NOT_FOUND));

    // 새로운 쿠폰 발급 -> Redis에서 수량 증가
    Long newCount = couponCountRepository.incrementIfAllowed(request.couponId(), coupon.getMaxQuantity());
    if (newCount == -1) {
        throw new CustomException(ErrorCode.COUPON_ISSUE_LIMIT_EXCEEDED);
    }

    // RDB에 저장
    CouponIssued issued = CouponIssued.builder()
            .coupon(coupon)
            .userId(request.userId())
            .issuedAt(LocalDateTime.now())
            .build();

    CouponIssued saved = couponIssuedRepository.save(issued);

    return CouponIssuedResponseDto.fromEntity(saved);
}

이경진님 안녕하세요.
1번과 같은 상황이면 쿠폰은 정확하게 100개만 발행이 되기때문에 문제가 되는 부분이 없어보이는데 redis 의 값도 100으로 맞추고 싶으신건가요 ?
redis 의 값을 100으로 딱 맞추는건 루아스크립트를 사용하지 않는이상 불가능할 것 같습니다.
개인적인 생각으로는 redis 의 값은 102~103이 되어도 문제가 없다고 생각이 드는데 redis 의 값을 100으로 맞추고자 하는 이유가 있으실까요 ?

채널톡 아이콘