JAVA/프로젝트

쿠폰 선착순 발급 이벤트 - 동시성 이슈 해결 synchronized

whyHbr 2024. 11. 30. 15:28
728x90
반응형

문제점: 쿠폰 발급 수량이 정확히 제어되지 않았음. 의도한 것보다 많은 수량이 발급됨

원인: 동시성 문제

해결 방법: 쿠폰 발급 과정에 Lock을 걸어 해결.

 

어떤 락을 사용할 것인가?

 


1.

synchronized ()

궁극적인 해결 방법은 되지 못한다.

하지만 단일 애플리케이션을 사용하고 있기 때문에.

하지만 동기화를 걸어줬을때, 쿠폰 발급이 잘 제어되는지 확인 목적차 사용.  

 

public class CouponIssueService {

    private final CouponJpaRepository couponJpaRepository;
    private final CouponIssueJpaRepository couponIssueJpaRepository;
    private final CouponIssueRepository couponIssueRepository;

    @Transactional
    public void issue(long couponId, long userId) {
        Coupon coupon = findCoupon(couponId);
        coupon.issue();
        saveCouponIssue(couponId, userId);
    }

적용 전

@Transactional
public void issue(long couponId, long userId) {
    synchronized (this){
        Coupon coupon = findCoupon(couponId);
        coupon.issue();
        saveCouponIssue(couponId, userId);   
    }
}

 

적용 후. 

요청이 들어오면 락을 획득하고, 로직을 실행한다. 

두번째 요청(두번째 스레드)이 들어오면, syncronized 블록을 만난다. 하지만 락을 획득한 첫번째 요청이 로직을 실행하고 있어 두번째 요청은 락을 획득하기 까지 대기한다. 

첫번째 요청에서 로직 실행이 끝나면 락을 반납 -> 두번째 요청이 락 획득, 로직 실행. 

순차적 실행이 가능해진다. 


테스트 실행:

(조건: 발급될 쿠폰의 수량 300

총 유저 수: 600

초당 상승할 유저의 수: 100

테스트의 정확도를 위해 이 조건은 항상 같을 것.)

락을걸기전 worker의 cpu 사용량은 100을 넘었는데, 지금은 30대인 것을 볼 수 있다.

락을 걸어  처리량이 낮아지면서 트래픽이 낮아져 cpu 사용량이 낮아졌다.

 

결과:

coupon 은 300개가 발급 되었으나 

 

쿠폰 이슈는 1800개 넘게 발급 된 것을 볼 수 있다. 

-> 로직에 문제가 있다. 

 

@Transactional
public void issue(long couponId, long userId) {
    synchronized (this){
        Coupon coupon = findCoupon(couponId);
        coupon.issue();
        saveCouponIssue(couponId, userId);
    }
}

구조: 상위에 트랜잭션이 걸려있고, 하위에서 락을 거는 형태이다. 

실행 순서:

트랜잭션 시작

락 획득
Coupon coupon = findCoupon(couponId);
coupon.issue();
saveCouponIssue(couponId, userId);
락 반납

트랜잭션 커밋

첫번째 실행이 락을 획득하고 있는데, 두번째 요청이 오면 두번째 요청은 대기한다. 

첫번째 요청이 로직을 마치고 락을 반납하면  두번째 요청이 락을 획득, 로직을 실행한다. 

 

하지만! 이때는 아직 트랜잭션이 커밋되기 전이다. 

트랜잭션 시작

락 획득
Coupon coupon = findCoupon(couponId);
coupon.issue();
saveCouponIssue(couponId, userId);
락 반납

1번 요청

트랜잭션 커밋

두번째 요청이 락을 획득하고 로직을 실행,

쿠폰을 조회 할 때 문제점이 생긴다.

아직 트랜잭션이 커밋되지 않았는데, 조회를 한다면

coupon issue 0 인 상태를 조회 - 발급 하게 된다. 


해결 방법: 

순서를 바꿔주면 된다. 

락 획득

트랜잭션 시작
Coupon coupon = findCoupon(couponId);
coupon.issue();
saveCouponIssue(couponId, userId);
트랜잭션 커밋

락 반납

첫번째 요청이 락을 획득하고, 트랜잭션을 시작하고 커밋, DB 반영 후 반납하는 것으로. 

이렇게 한다면 두번째 요청이 커밋된 내용을 읽을 수 있다. 

 

트랜잭션 내에서 락을 사용하는 행위는 주의해서 사용해야 한다. 

 


코드 수정: 

public class CouponIssueRequestService {

    private final CouponIssueService couponIssueService;

    public void issueRequestV1(CouponIssueRequestDto requestDto) {
        synchronized (this) {
            couponIssueService.issue(requestDto.couponId(), requestDto.userId());
        }
        log.info("쿠폰 발급 완료. couponId: {}, userId: {}", requestDto.couponId(), requestDto.userId());
    }

상위에서 락을 거는 형태로 변경한다.

(현 코드 흐름: CouponIssueController -  CouponIssueRequestService - CouponIssueService )

요청이 오면 락 획득 - issue 메서드 호출 - 트랜잭션 커밋 - 락 반납. 


수정 후 테스트

 

락을 상위에 걸어 RPS 가 낮아졌고 이에 따라 workers의 cpu 사용량도 낮아졌다. 

 

발급은 잘 되었고, 

coupon issue 도 300개 발급 잘 되었다. 


 

coupon issue id 값 설정:

TRUNCATE TABLE coupon_issues; //테이블 내 모든 데이터 삭제

ALTER TABLE coupon_issues AUTO_INCREMENT = 1; //id 값을 1부터 시작

 

728x90