////
Search
Duplicate

선착순 쿠폰 발급

쿠폰을 발급하는데 동시성 문제? 동시성 문제가 뭐지? 먼저 동시성 문제란 두 개 이상의 쓰레드가 동시에 같은 자원에 접근했을 때, 발생할 수 있는 문제이다. 어떤 문제인지 알아보자.
쿠폰의 남은 갯수가 1개이고, 사용자 A와 B가 동시에 쿠폰 발급을 진행하려 한다.(트랜잭션 격리수준은 mysql innoDB의 기본전략인 Repeatable Read를 전제로 진행합니다. 트랜잭션 격리수준에 대해 모른다면 꼭 찾아보세요.)
위 그림을 보면 결국엔 둘 다 쿠폰 발급에 성공하게 됩니다. 이것은 트랜잭션 수준이 Serializable이 아닌 이상 트랜잭션이 관리밖의 문제입니다. 또한 최종 쿠폰 발급 가능 갯수가 0이 아닌 -1이 되어야 할 것 같은데 0입니다. 이것을 두번의 갱실 분실 문제(Second Lost Updates Problem)라고 합니다.
사용자 A의 수정사항은 사라지고 나중에 완료한 사용자 B의 수정사항만 남게 되는 문제인 것이죠.
이 문제를 해결하기 위해서 3가지 선택 방법이 존재합니다.
1.
마지막 커밋만 인정하기
2.
최초 커밋만 인정하기
3.
커밋된 갱신 내용을 병합하기(A와 B의 수정 사항을 병합하여 적용하는 방법)
우리 프로젝트는 여기서 2번째 방법인 최초 커밋만 인정하기 방법은 선택하였다.
기본적으로 우리가 가지는 요구사항은 아래와 같다.
먼저 들어온 요청이 먼저 처리되어야 한다. 즉 먼저 들어온 사람이 먼저 쿠폰 발급에 성공(쿠폰 발급 갯수가 남아있다면)하든 실패(쿠폰 발급 갯수가 모두 소진됬다면)하든 해야한다.
때문에 1번의 해결 방법은 맞지 않고 3번의 방법을 사용하기엔 너무나 복잡하다. 그래서 2번 방법을 선택한 것이다. 2번 방법을 구현하는 여러 방법이 있지만 그 중에 낙관적 락, 비관적 락을 이용해 보았다.

낙관적 락(Optimistic Lock)

낙관적 락은 어플리케이션 레벨에서 사용할 수 있는 락입니다. 낙관적 락은 말그대로 낙관적인 상황에 사용합니다. 충돌이 일어나지 않을 거라 예상하고 사용하다가 충돌이 나면 그제서야 예외를 발생시키는 것이지요. 엔티티에 Version 필드를 추가하고 엔티티 조회 시, 엔티티의 버전을 함께 들고오고 수정할 때 버전을 업데이트 합니다.(Version 1 → 2). 만약 수정 시, 이전에 조회했던 버전과 다르다면 예외를 발생시킵니다.(update Coupon Where version = ?조회한 버전)
@Entity public class Coupon { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String couponName; @Version private Long version; }
JavaScript
복사
우리 서비스에서의 쿠폰 발급 코드
@Retry @SwapLog @Override @Transactional public void issueEventCoupon(Long couponId, Member member) { // 쿠폰 조회 시, 버전 정보도 함께 들고옴 Coupon coupon = couponRepository.findByIdWithOptimisticLock(couponId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_COUPON_EXCEPTION)); if (issueCouponPossible(coupon)) {// 쿠폰 발급이 가능한지 체크 //쿠폰 발급이 가능하다면 발급하는 코드 memberCouponService.saveMemberCoupon(member, coupon); coupon.issueCoupon(); } }
JavaScript
복사
업데이트 쿼리를 실행한 후에야 데이터의 충돌을 알 수 있기 때문에 충돌이 거의 일어나지 않는 경우에 사용하는 것이 좋다. 또한 우리 서비스 특성 상, 충돌이 발생한 후에 계속해서 쿠폰 발급을 위해 재시도를 해야 하기 때문에 별도의 재시도 로직을 작성해 주어야 한다. 그래서 @Retry라는 재시도 AOP를 달아주었다.

비관적 락(Pessimistic Lock)

데이터베이스 레벨에 직접 락을 건다. MySQL에서 어떤 레코드에 락을 걸면 해당 레코드에 락을 거는게 아닌 인덱스 레코드에 락을 건다. 자세한 내용은 따로 찾아보자.
어찌됐든 데이터베이스 레벨에 락을 걸기 때문에 동시성은 많이 떨어질 수 있지만 확실하게 트랜잭션간의 격리성을 지켜 데이터의 무결성을 확실하게 지켜줄 수 있다.
일반적으로 충돌이 많이 발생하지 않는 경우에는 데이버테이스에 락을 거는 비관적 락에 비해 낙관적 락이 더 빠르지만 충돌이 많이 발생하는 경우에는 비슷하거나 낙관적 락이 오히려 성능이 더 안좋을 수도 있다. 그래서 MVP 스펙의 최종 락은 비관적 락으로 선택하였다.

하지만 해결하지 못한 요구사항

위에서 언급했듯 위 두가지 락으론 우리의 요구사항(빨간색 글)을 해결할 수 없다.
낙관적 락의 경우에는 동시에 요청이 들어오면 한 요청은 성공하고, 그 다음 성공하지 못한 요청이 언제 성공할지 모르기 때문이다.
비관적 락의 경우 처음에 락을 얻은 요청이외에 동시에 요청한 여러 쓰레드들이 해당 락을 얻기 위해 락 경합을 벌이기 때문에 락이 걸린 상태에서 여러 요청이 들어온다면 먼저 들어온 요청에 대해서 먼저 처리할 수 없는 상태가 되어버린다.
때문에 우리는 비관적 락 사용에 대한 성능 문제를 해결하고 순서대로 요청을 처리해줘야 한다는 요구사항을 만족시키기 위해 다른 방법을 선택할 수 밖에 없었다.
분산서버, 분산 DB에서도 사용할 수 있는 Redis의 Redisson 구현체를 사용한 Pub/Sub 구조의 분산 락도 있지만 이 또한, 먼저 들어온 요청에 대해 먼저 처리한다는 보장을 할 수가 없다.
그래서 Redis의 Increment를 이용해 해결하였다. 레디스의 Increment는 조회와 쓰기를 원자단위로 가져가기 때문에 쓰기 도중에 다른 쓰레드가 조회를 하는 등의 동시성 문제가 발생하지 않는다. 그리고 레디스는 인메모리 저장소이기 때문에 엄청나게 빠른 속도를 자랑한다. Increment 메서드를 사용하여 해결한 방법은 다음과 같다.
Increment 메서드는 해당 키값의 Value를 증가시키고 해당 value값을 반환해준다. 우리는 쿠폰 하나만 발급하는 것이어서 value+1한 값을 반환해준다.
레디스의 Key: 쿠폰 이름, Value: 쿠폰을 발급한 갯수(Value 값 계속 변화 O)
RDB의 쿠폰의 Count 필드: 발급 가능한 쿠폰 갯수(변화 X)
1.
쿠폰 발급 요청이 들어오면 레디스에서 Increment메서드를 요청하고 해당 쿠폰 이름으로 된 Key 값으로된 Value+1값을 들고온다.
2.
해당 쿠폰 ID로된 쿠폰을 RDB에서 조회해와 쿠폰이 발급 가능한 상태인지 확인한다.
3.
만약 RDB에서 조회해온 Count가 100이라면 레디스에서 조회해온 Value가 100일때까지 쿠폰 발급이 가능하고 그 이상이면 팅겨낸다.
하나의 서버에서 쿠폰 발급에 대한 요청을 받을 수도 있지만 더 많은 요청을 받기 위해서 여러 분산 서버를 두고 레디스에 요청을 한다면 더 많은 요청도 한꺼번에 받을 수 있다.
하지만 단일 DB라면 Select나 Insert가 한 DB에만 연산이 집중되기 때문에 단일 DB가 부하가 많이 일어날 수 있어 DB의 성능에 따라 처리할 수 있는 요청이 정해질 것 같다.