본문 바로가기
Java , Spring/Spring

[Spring] 선착순 쿠폰 동시성 문제 해결하기 (메세지큐 적용)

by 방배킹 2024. 6. 26.

동시성 문제란?

동일한 하나의 데이터에 두개이상의 프로세스나 스레드가 동시에 같은 데이터에 접근 할때 빌생할 수 있는 문제점을 의미한다.

 

동시성 문제 발생

public void assignCoupon(CreateCouponReq createCouponReq) {
        // 쿠폰 발급 로직
        try{
            // 1. 존재하는 쿠폰 이벤트 인지 확인
            if(!mapper.isValidEvent(createCouponReq.getCouponGroupId())){
                log.error("[error] 쿠폰 발급 실패 - 존재하지 않는 이벤트입니다.");
                throw new RuntimeException("존재하지 않는 이벤트입니다.");
            }
            // 2. 이미 발급 받은 쿠폰인지 확인
            if(mapper.checkCouponExist(createCouponReq)){
                log.error("[error] 쿠폰 발급 실패 - 이미 쿠폰을 발급받으셨습니다.");
                throw new RuntimeException("이미 쿠폰을 발급받으셨습니다.");
            }
            // 3. 쿠폰 수량 확인
            if(mapper.getCouponCount(createCouponReq.getCouponGroupId()) <= 0){
                log.error("[error] 쿠폰 발급 실패 - 쿠폰 수량이 부족합니다.");
                throw new RuntimeException("쿠폰 수량이 부족합니다.");
            }

            // 4. 쿠폰 생성
            CouponDto couponDto = generateCouponDto(createCouponReq);

            // 5. 쿠폰 발급
            issueCoupon(createCouponReq, couponDto);

        }catch (Exception e) {
            log.error("[error] assignCoupon - 쿠폰 발급 실패", e);
            throw new RuntimeException(e.getMessage());
        }
    }

 

쿠폰을 생성하는 메인 코드는 위와 같다.

요청이 들어오면 존재하는 쿠폰 이벤트인지, 이미 발급 받은 쿠폰인지, 잔여 수량은 남아있는지를 확인 한뒤 쿠폰을 발급한다. 이런 경우에 멀티쓰레드로 요청으로 하면 동시성 문제가 발생할 수 있다.

 

동시성 테스트

잔여수량이 1개인 쿠폰에 사용자 3명이 동시에 쿠폰 발급 요청을 보냈다.

@Test
public void 동시성_테스트_1() throws InterruptedException {
    log.info("assignCoupon 동시성 테스트_1 시작");

    // Given
    int numberOfThreads = 3;
    ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);

    CreateDto createDto = getCreateDto();

    // When
    log.info("assignCoupon 동시성 테스트_1 진행");
    service.execute(() -> {
        couponService.assignCoupon(createDto.dto1);
        log.info("MEMBER_1 쿠폰 발급 완료");
        latch.countDown();
    });
    service.execute(() -> {
        couponService.assignCoupon(createDto.dto2);
        log.info("MEMBER_2 쿠폰 발급 완료");
        latch.countDown();
    });
    service.execute(() -> {
        couponService.assignCoupon(createDto.dto3);
        log.info("MEMBER_3 쿠폰 발급 완료");
        latch.countDown();
    });
    latch.await();
    log.info("assignCoupon 동시성 테스트_1 종료");
}

 

3명의 사용자 모두 쿠폰 발급에 성공했고 남은 쿠폰의 수량은 -2(1-3)가 되었다.

 

여러 스레드가 공유 자원(변수, 파일, 데이터베이스 등)에 동시에 접근했기 때문에 위와 같은 경쟁조건(Race Condition)이 발생했다.

 

 

Race Condition 해결하기

이러한 경쟁조건(Race Condition) 을 해결 하기 위해서는 여러가지 방법이 있다.

Synchronized

Java의 synchronized는 동기화를 구현할때 사용된다. 여러 스레드가 동시에 공유자원에 접근하는 경쟁조건(Race Condition)과 같은 동시성 문제를 방지할때 사용한다.

하지만 Java의 synchronized는 하나의 프로세스안에서만 보장하기 때문에 여러개의 서버환경에서는 다시 동시성 문제가 발생할 수 있다.

( synchronized는 @Transactional과 함께 사용할 수 없다. synchronized 안에서 @Transactional 메소드를 호출 하는 방식으로 사용해야 한다. )

 

DB Lock

비관적 락(Pessimistic Locking)

트랜잭션이 자원에 접근할 때 바로 락을 걸어 다른 트랜잭션이 접근하지 못하게한다. (접근시)

낙관적 락(Optimistic Locking)

트랜잭션이 끝나기 전까지 자원에 락을 걸지 않지만, 최종 커밋시점에서 자원이 변경되지 않았는지 확인한다. (커밋시)

 

DB Lock을 이용하면 데이터의 무결성과 일관성을 쉽게 보장할 수있다.

하지만 다른 트랜잭션들이 대기하는 시간이 매우 길어 성능저하가 심하다.

 

Redis

redis의 분산락을 통해 동시성을 제어할 수 있다.

위에서 Java의 synchronized는 하나의 프로세스 안에서만 동시성을 제어할수 있고 여러개의 서버 환경에서는 다시 동시성 문제가 발생한다고 말했는데,

Redis의 분산락은 이러한 분산 환경에서 상호 배제를 구현하여 동시성 문제를 해결 할 수 있다.

 

(Redis의 분산락과 MySQL의 네임드락에 대해서는 다음 글에 정리해봐야겠다.)

 

메시지큐

메시지큐는 비동기 메시지 전달을 위해 사용하는 시스템이다. 큐에 들어온 메시지를 순차적으로 처리하며, 이를 통해 동시성 문제를 해결할 수 있다.

큐에 쌓인 메시지를 순차적으로 처리 하기 때문에 트래픽을 분산시킬수 있지만, 큐에 넣고 뺴는 과정에서 오버헤드가 발생할 수 있다.

 

동시성 문제 해결하기

public String  couponReqSender (CreateCouponReq createCouponReq){
        String couponReqRes = null;
        try {
            log.info("[쿠폰 발급 요청 전송] => " + createCouponReq.toString());
            String message = objectMapper.writeValueAsString(createCouponReq);
            couponReqRes = (String) rabbitTemplate.convertSendAndReceive("time", "my-key", message);
        } catch (Exception e) {
            log.error("[메세지큐 발신 오류] ", e);
            couponReqRes = "[error] " + e.getMessage();
        }
        return couponReqRes;
    }


public String couponReqReceiver(@Payload String msg){
        String couponReqRes = null;
        try{
            CreateCouponReq createCouponReq = objectMapper.readValue(msg, CreateCouponReq.class);
            log.info("[쿠폰 발급 요청 수신] <= " + createCouponReq.toString());
            couponService.assignCoupon(createCouponReq);
            couponReqRes = "[success] 쿠폰 발급 성공";
        }
        catch (Exception e){
            log.error("[메세지큐 수신 오류]",e);
            couponReqRes = "[error] " + e.getMessage();
        }
        return couponReqRes;
    }

 

메시지큐를 이용해서 쿠폰 발급 요청이 들어오면 해당 요청을 메시지큐로 전달하고

메시지큐에서 들어온 요청을 순차적으로 처리하도록 구현하였다.

 

동시성 테스트 (메세지큐 적용)

@Test
public void 동시성_테스트_2() throws InterruptedException {
    log.info("assignCoupon 동시성 테스트_2 시작");

    // Given
    int numberOfThreads = 3;
    ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);

    CreateDto createDto = getCreateDto();

    // When
    log.info("assignCoupon 동시성 테스트_2 진행");
    service.execute(() -> {
        String s = couponReqService.couponReqSender(createDto.dto1());
        log.info("MEMBER_1 쿠폰 발급 : {}", s);
        latch.countDown();
    });
    service.execute(() -> {
        String s = couponReqService.couponReqSender(createDto.dto2());
        log.info("MEMBER_2 쿠폰 발급 : {}", s);
        latch.countDown();
    });
    service.execute(() -> {
        String s = couponReqService.couponReqSender(createDto.dto3());
        log.info("MEMBER_3 쿠폰 발급 : {}", s);
        latch.countDown();
    });
    latch.await();
    log.info("assignCoupon 동시성 테스트_2 종료");
}

 

쿠폰 발급 요청이 메세지 큐로 들어가고 해당 요청을 순차적으로 처리하여 첫 테스트와는 다르게 첫 사용자는 쿠폰을 발급 받았지만 나머지 두명의 사용자는 쿠폰 수량 부족 오류로 쿠폰 발급이 되지 않았다.

 

 

 

댓글