
쿠폰은 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 를 잘 쓰는 개발자가 진짜 개발자가 아닐까라는 생각을 해본다.
'개발 회고록' 카테고리의 다른 글
[ 개발 회고록 ] Redis Session으로 분산 환경에서 세션 관리하기 (0) | 2024.01.24 |
---|---|
[ 개발 회고록 ] Self - Invocation 에 대한 간단한 이야기 (0) | 2024.01.23 |
[ 개발 회고록 ] 낙관적 Lock 에서 @Transacional 을 사용하면 안되는 이유 + Lock 을 통한 라이더 배차 서비스 개발 (2) | 2024.01.23 |
[ 개발 회고록 ] 테스트 코드 작성 시, @Transactional 어노테이션을 사용하는 것에 대한 짧은 생각 (1) | 2024.01.10 |
[ 개발 회고록 ] Redisson Lock 을 활용한, 적립 포인트 → 사용 가능 포인트 전환 기능 구현. (2) | 2024.01.03 |