기록하며 성장한다 - 개발, 회고

개발 회고록

[ 개발 회고록 ] Redis INCR 를 활용하여 선착순 쿠폰 발급 기능 만들기

전대홍 2024. 1. 23. 22:36

 

쿠폰은 100개인데, 왜 104명한테 발급이 됐지...???

서론

현재 배달과 관련 된 토이프로젝트를 진행하고 있다.

토이 프로젝트 링크 <<<

필자가 개발 한 기능 중, 포인트로 변환할 수 있는 선착순 쿠폰을 제공하는 기능이 있는데, 여기에도 레이스 컨디션에 의한 동시성 이슈가 발생할 수 있다.

재고와 비슷한 느낌인데, 우리가 1개 남은 옷을 2명에서 동시에 구매 요청을 하였을 때, 재고가 -1이 되고 2명에게 모두 주문이 성공하는 경우가 생길 수 있다.

쿠폰도 마찬가지로 선착순 100명을 걸어두었지만, 갑자기 3,000명 ~ 5,000명씩 트래픽이 몰릴 수 있게된다. 그럴 때 중요한 것은 레이스 컨디션을 고려한 로직 작성과, 예기치못한 에러로 인한 쿠폰 소실이 생겨서는 안된다.

 


본론

1. 문제점 & Redis 를 활용

우선 문제점 부터 파악해보자.

@Transactional
public void applyCoupon(Long memberId, CouponType couponType, Capacity capacity) {

    if ( couponRepository.count() > capacity.getDescription() ) throw new ApiException(COUPON_END);

    Coupon coupon = Coupon.builder()
            .memberId(memberId)
            .couponType(couponType)
            .status(Status.DEFAULT)
            .expireDateTime(LocalDateTime.now().plusYears(1)) // 쿠폰은 발급 후 1년 안에 사용해야 하는 정책이 있는 거로 설계.
            .build();

    couponRepository.save(coupon);
    
}

필자가 처음 개발한 코드이다.

해당 코드로 우선 Test 를 진행해보았다.

참고로 capacity.getDescription() 은 100이다.

쿠폰을 100개까지만 발급하겠다는 이야기이다.

테스트 결과이다.

분명 100을 예상했는데, 104개가 발급되었다.

이것이 레이스 컨디션에 의해 데이터 정합성에 문제가 생겨 만들어진 오류이다.

동시에 여러 스레드가 몰리면서 couponRepository.count( ) 가 100보다 큰지 비교할 때 가져온 couponRepository.count( ) 값은 99인데, 다음 로직을 처리하는 동안, 이전에 접근하였던 스레드가 연산을 완료하여 쿠폰 발급이 100개 끝난 것이다.

그러면 지금 처리하고 있는 스레드도 일단은 if ( couponRepository( ) > 100 ) 을 넘겼으니, 다음 로직을 처리할 것이고, 결과적으로 쿠폰을 더 발급하게 된 셈이다.

이를 막기 위해서 필자는 Redis 의 increment 를 활용하였다.

Redis 에서 INCR 이라는 명령어로 쓰이는 이 코드는 원자적으로 카운터를 증가시키므로 여러 스레드가 동시에 접근하더라도 증가된 값을 반드시 정확하게 반환한다.

정해진 갯수를 정확히 지켜야 할 경우 유용하게 사용할 수 있다는 것이다.

 

필자는 그래서 코드를 아래와 같이 고쳤다.

@Transactional
public void applyCoupon(Long memberId, CouponType couponType, Capacity capacity) {

    if ( couponRedisRepository.incrementCouponCount(couponType) > capacity.getDescription() ) {
        throw new ApiException(COUPON_END);
    }

    try {

        Coupon coupon = Coupon.builder()
                .memberId(memberId)
                .couponType(couponType)
                .status(Status.DEFAULT)
                .expireDateTime(LocalDateTime.now().plusYears(1)) // 쿠폰은 발급 후 1년 안에 사용해야 하는 정책이 있는 거로 설계.
                .build();

        couponRepository.save(coupon);
        
 }
@Repository
@RequiredArgsConstructor
public class CouponRedisRepository {

    private final RedisTemplate<String, Object> redisTemplate;

    public Long incrementCouponCount(Coupon.CouponType couponType) {
        return redisTemplate.opsForHash()
                .increment(COUPON_COUNT, couponType.toString(), 1);

    }

}

물론, 기존에 사용하던 Redis 가 있기 때문에 이미 RedisConfig 도 다 작성한 상태이다.

이렇게 작성하고 테스트를 진행하였더니

DB에 정확히 100개의 쿠폰이 발급된 것을 확인 할 수 있었다.

여러번 시도하여도 동일하게 100개의 값이 찍혔다.

 

 

2. 값 유실 되는 문제 해결

그런데 2번째 문제가 있다.

바로 수많은 트래픽이 몰리면서 예기치 못한 문제가 발생할 수 있다는 것이다.

필자는 항상 많은 양의 트래픽을 받을 것이라는 것을 가정하고 코드를 짜고 있다.

실제로도 선착순 쿠폰이면, 분명 그 시간대에 수많은 트래픽이 몰리게 될 것이다.

그래서 필자는 try catch 를 적극 활용하기로 하였다.

아래는 개선된 코드이다.

@Transactional
public void applyCoupon(Long memberId, CouponType couponType, Capacity capacity) {

    if ( couponRedisRepository.incrementCouponCount(couponType) > capacity.getDescription() ) {
        throw new ApiException(COUPON_END);
    }

    try {

        Coupon coupon = Coupon.builder()
                .memberId(memberId)
                .couponType(couponType)
                .status(Status.DEFAULT)
                .expireDateTime(LocalDateTime.now().plusYears(1)) // 쿠폰은 발급 후 1년 안에 사용해야 하는 정책이 있는 거로 설계.
                .build();

        couponRepository.save(coupon);

    } catch (Exception e) {

        log.error("Failed to create coupon :: " + memberId);

        ExceptionCoupon exceptionCoupon = ExceptionCoupon.builder()
                .memberId(memberId)
                .couponType(couponType)
                .build();

        exceptionCouponRepository.save(exceptionCoupon); // 에러가 발생하면, 백업 테이블에 저장

    }
}
/**
 * Exception 쿠폰 테이블에 저장된 데이터를
 * 배치 처리를 통하여 쿠폰 데이터로 옮겨줍니다.
 */
@Transactional
public void moveExceptionCouponData() {
    List<ExceptionCoupon> exceptionCouponList = exceptionCouponRepository.findAll();
    LocalDateTime expireDateTime = LocalDateTime.now().plusYears(1); // 쿠폰은 발급 후 1년 안에 사용해야 하는 정책이 있는 거로 설계.

    List<Coupon> coupons = exceptionCouponList.stream()
            .map(exceptionCoupon -> Coupon.builder()
                    .memberId(exceptionCoupon.getMemberId())
                    .couponType(exceptionCoupon.getCouponType())
                    .status(Status.DEFAULT)
                    .expireDateTime(expireDateTime)
                    .build())
            .toList();

    couponRepository.saveAll(coupons);
}

이렇게 try catch 를 통하여,

문제가 생겼을 경우 ExceptionCoupon 이라는 별개의 테이블에 데이터를 저장시켰다.

그리고 배치처리를 통해, 일정 시간마다 ExceptionCoupon 에 있는 데이터를 원래 Coupon 테이블로 이동시켜주는 로직을 만들었다.

이로써 쿠폰의 유실도 걱정할 필요가 없어졌다.

 

 


결론

데이터 정합성과, 레이스 컨디션과 같은 동시성 이슈는 항상 고려하고 있어야한다.

우리가 흔히 생각하는 재고나 쿠폰 뿐 아니라, Lock 을 필요로 하는 로직은 많고, 경우에 따라 다양한 종류의 Lock 을 활용할 수 있다.

또한 예기치못한 오류를 대비하는 코드도 고려할 수 있어야한다.

try catch 를 잘 쓰는 개발자가 진짜 개발자가 아닐까라는 생각을 해본다.