인프런 커뮤니티 질문&답변

이경진님의 프로필 이미지
이경진

작성한 질문수

실습으로 배우는 선착순 이벤트 시스템

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

작성

·

311

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다

이경진님의 프로필 이미지
이경진
질문자

빠른 답변 감사드립니다!!
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으로 맞추고자 하는 이유가 있으실까요 ?

이경진님의 프로필 이미지
이경진
질문자

아! 그렇군요. 레디스의 값은 100개가 정확히 아니어도 되겠네요 ..!
그럼 여기서 드는 찐막 질문이 있습니다 ㅠㅠㅠ 1번과 같은 상황에서 데이터에 쿠폰은 100개가 생성되는데 왜 테스트 통과는 안 되는지 궁금합니다! 데이터에 쿠폰이 100개 발급되는거 보면 race condition 문제는 해결한 것 같은데, 왜 테스트코드는 통과를 못하는 걸까요?!

최상용님의 프로필 이미지
최상용
지식공유자

본문에 있는 테스트코드를 보면 redis 에 있는값도 체크를 하고있고, redis 에 있는 값은 102,103 이기때문에 발생하는것 같습니다

redis 의 값을 100이 되게 맞추지 않을거라면 db 에 있는 값만 체크하면 되지않을까요?

이경진님의 프로필 이미지
이경진
질문자

넵 말씀듣고 레디스 값을 확인하는 부분은 빼고, db 값만 확인하도록 변경해서 테스트 코드를 돌려본 결과 똑같이 로컬의 db 상에는 발급 갯수가 100개가 찍히는데 테스트 코드는 통과하지 않고 있는 상황입니다 ㅠㅠㅠ😅

최상용님의 프로필 이미지
최상용
지식공유자

테스트코드와 실패하는 원인은 무엇인가요?

이경진님의 프로필 이미지
이경진

작성한 질문수

질문하기