Project/MSA 프로젝트

[트러블슈팅] '선착순 쿠폰 발급' 로직 - 동시성 문제 발생 (w/ Race Condition) (1/2)

쉬지마 이굥진 2024. 6. 30. 21:53

일반 쿠폰 발급 로직 설계를 마치고, 선착순 쿠폰 발급 로직 개발에 들어왔다가 동시성 문제에 맞닥뜨렸다. 드디어 여태 들어만 봤던 동시성 문제를 해결할 기회가 왔다. 맞다이로 드루와

 

개요

일단 서비스 및 컨트롤러, 레포 코드를 작성한 후 테스트 케이스를 작성하고 돌려봤을 땐 pass가 떴다. 그 후 postman으로 1차 기능 테스트 까지는 성공.

  • 서비스 레이어 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    /**
     * 선착순 쿠폰 발급
     */
    @Transactional
    public CouponIssuedResponseDto issueFirstComeCoupon(CouponIssuedRequestDto request) {
        
        ... 각종 유효성 검사 ..
        
        // 발급 가능한 쿠폰 수량 확인
        if (coupon.getIssuedQuantity() >= coupon.getMaxQuantity()) {
            throw new CustomException(ErrorCode.COUPON_ISSUE_LIMIT_EXCEEDED);
        }
 
        // 새로운 쿠폰 발급
        CouponIssued issued = CouponIssued.builder()
                .coupon(coupon)
                .userId(request.userId())
                .issuedAt(LocalDateTime.now())
                .build();
 
        CouponIssued saved = couponIssuedRepository.save(issued);
 
        // 발급된 쿠폰 수량 증가
        coupon.incrementIssuedQuantity();
 
        // 쿠폰 업데이트
        couponRepository.save(coupon);
 
        return CouponIssuedResponseDto.fromEntity(saved);
    }
cs
    • 테스트 케이스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@SpringBootTest
class CouponIssuedServiceTest {
 
    @Autowired
    private CouponIssuedService couponIssuedService;
    @Autowired
    private CouponRepository couponRepository;
    @Autowired
    private CouponIssuedRepository couponIssuedRepository;
 
    ...
 
    @Test
    @Transactional
    public void singleUserIssueCoupon() {
        Long couponId = couponRepository.findAll().get(0).getCouponId(); // 쿠폰 ID 가져오기
        Long userId = 1L;
 
        CouponIssuedRequestDto request = new CouponIssuedRequestDto(couponId, userId);
 
        CouponIssuedResponseDto response = couponIssuedService.issueFirstComeCoupon(request);
 
        long count = couponIssuedRepository.count();
 
        assertThat(count).isEqualTo(1);
    }
cs

싱글 유저 대상으로(만) 패스 ㅎ ..

 

잘 만들었나? 했는데 생각해보니 선착순 쿠폰 발급 로직은 한 명의 유저가 시간 널널히 '선착순이니까 쿠폰 발급 신청해야징 ~' 하고 하는게 아니라 여러 명의 유저가 동시에 요청을 여러번 보낼 확률이 절대적으로 큰 로직이었다.

 

그래서 여러 명의 유저가 동시에 요청을 보내는 테스트 케이스도 작성해주기로 했다.

 

문제

실패했다. ㅋㅋ

예상 값은 100이지만 실제 값은 5

      • 실패한 테스트 케이스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Test
    @Transactional
    public void multipleUsersIssueCoupon() throws InterruptedException {
        // Given
        // 쿠폰을 생성
        ...
 
        Long couponId = coupon.getCouponId();
        int threadCount = 1000;
 
        CountDownLatch latch = new CountDownLatch(threadCount);
        ExecutorService executorService = Executors.newFixedThreadPool(35);
 
        // When
        for (int i = 0; i < threadCount; i++) {
            final Long userId = (long) (i + 1);
            executorService.execute(() -> {
                try {
                    CouponIssuedRequestDto request = new CouponIssuedRequestDto(couponId, userId);
                    couponIssuedService.issueFirstComeCoupon(request);
                } finally {
                    latch.countDown();
                }
            });
        }
 
        latch.await();
 
        // Then
        long count = couponIssuedRepository.count();
        Coupon updatedCoupon = couponRepository.findById(couponId).orElseThrow();
 
        assertThat(count).isEqualTo(100);
        assertThat(updatedCoupon.getIssuedQuantity()).isEqualTo(count);
    }
cs
 

이것에 대한 설명을 붙이자면 .. 

  • 동시에 여러 개의 요청을 보내기 위해 멀티 스레드를 사용했고, 요청 수를 1000개로 잡음 (threadCount = 1000;) (이 요청은 for문을 통해서 보내줌)
  • 멀티 스레드를 이용할 것이기 때문에 ExecutorService를 이용
  • 모든 요청이 끝날 때까지 기다려야 하므로 CountDownLatch를 사용
🔹ExecutorService란?
병렬 작업을 간단하게 할 수 있도록 도와주는 Java의 API

🔹 CountDownLatch란?
다른 스레드에서 수행하는 작업을 기다리도록 도와주는 클래스

 

100개 수량이 한정되어있는 쿠폰이기 때문에 최종적으로 100개의 쿠폰이 생성되어야 하는데, 예상치보다 작은 값이 생성됐다. 

 

문제 인식

사실 자꾸 테스트케이스가 실패해서 구글링과 검색 찬스를 이용하다보니 자연스럽게 문제가 뭔지 알게 되었다. 내가 생각한 대로 이 코드가 동작하지 않았던 이유는 레이스 컨디션(Race Condition)이 발생했기 때문이다.

🔹Race Condition이란?
두 개 이상의 스레드가 공유 자원에 access를 하고 동시에 작업을 하려고 할 때 발생할 수 있는 상황으로, 실행 순서에 따라 결과가 달라질 수 있는 경합 조건을 말함

 

내 코드의 경우엔 서비스 로직에서 트랜잭션을 걸어주었기 때문에 (트랜잭션이라도 걸어줬어서 다행) 쿠폰이 100개 이상 발행되지는 않았지만, 예상보다 적게 발행된 이유는 하단과 같은 원인으로 짐작한다.

 

1. 동시성 문제

  • 여러 스레드가 동시에 쿠폰 발급을 시도할 경우, 동일한 쿠폰의 발급 가능 여부를 확인하고 새로운 발급을 시도할 수 있다.
  • 이로 인해 여러 스레드가 동시에 쿠폰 발급 가능 여부를 체크한 후 모두 발급 가능하다고 판단할 수 있어, 쿠폰 발급 수량이 예상보다 많이 증가할 수 있다.

2.  경쟁 조건

  • 여러 스레드가 동시에 쿠폰 발급 가능 여부를 체크한 후 모두 발급 가능하다고 판단하더라도, 실제 쿠폰 발급 과정에서 데이터베이스 레코드를 변경하는 시점에 충돌이 발생할 수 있다.
  • 예를 들어, 여러 스레드가 동시에 발급 가능 여부를 확인한 후 발급할 수 있는 것으로 판단했지만, 실제로 데이터베이스에서 발급 트랜잭션이 처리되는 동안 충돌이 발생하면 발급이 중단된다.
  • 이 경우 트랜잭션이 롤백되어 발급된 쿠폰 수량이 감소할 수 있다.

3. 읽기-쓰기 충돌

  • 하나의 스레드가 쿠폰 발급 가능 여부를 확인하고 있을 때, 다른 스레드가 동시에 쿠폰 발급을 시도할 수 있다.
  • 이 경우 첫 번째 스레드가 쿠폰 발급 여부를 확인한 이후에, 두 번째 스레드가 이전 상태를 기준으로 쿠폰 발급을 시도하면, 데이터의 일관성이 깨질 것이다.

👉 총체적 난국이라는 뜻

 

문제 해결 방법 모색

 

자 이제 이 문제를 어떻게 해결할 것인가.. 생각을 해봤다.

    1. 싱글 스레드로 작업? (❌)
      • 레이스 컨디션은 일어나지 않을 것이다.
      • 하지만 쿠폰 발급 로직 전체를 싱글 스레드로 작업하면, 먼저 요청한 사람의 쿠폰이 발급 된 이후에 다른 사람들의 쿠폰 발급이 가능해지기 때문에 성능 자체에 아주 악영향을 끼칠 것 같다.
    2. Java에서 지원하는 Synchronized 사용? (❌)
      • 서버가 한 대일 때는 괜찮은데, 여러 대가 된다면 race condition이 다시 발생하므로 적절하지 않을 것이다. (필자의 프로젝트는 MSA로 진행 중)
    3. MySQL, Redis를 활용해 락을 구현해서 해결? (❌)
      • 내가 원하는 건 쿠폰 개수에 대한 정합성인데, 락을 활용해서 구현하면 발급된 쿠폰 개수를 가져오는 것 부터 쿠폰을 생성할 때까지 락을 걸어야 한다.
      • 이렇게 되면 락을 거는 구간이 길어져서 성능에 불이익이 있을 수 있을 거라고 생각했다. (전에 락은 최대한 짧게 거는 게 좋다고 듣기도 했다)
      • 쿠폰 개수에 대한 정합성만 관리할 수는 없을까?
    4. Redis의 INCR 명령어 활용 
      • Redis는 싱글 스레드 기반으로 동작해서, 레이스 컨디션을 해결할 수 있다.
      • INCR  명령어 자체가 성능이 굉장히 빠른 명령어라서, 싱글 스레드에 대한 성능 저하를 걱정하지 않아도 된다.
      • 이 방법으로 발급된 쿠폰 개수를 제어한다면 성능도 빠르고 || 데이터 정합성도 지킬 수 있을 것이라고 판단했다.
🔹Redis의 INCR 명령어
주어진 키에 저장된 숫자 값(value)을 1씩 증가시키는 역할을 함.
주로 카운터나 세션 관리 등에서 사용되고, 원자적(atomic)으로 동작하여 동시에 여러 클라이언트가 접근해도 올바르게 증가된 값을 반환한다.

 

다음 포스팅에서는 Redis의 INCR 명령어를 통해 선착순 쿠폰 발급 로직에서 동시성 문제를 해결하는 과정을 담아보려고 한다. 땡큐포와칭


<다음 글>

 

[트러블슈팅] '선착순 쿠폰 발급' 로직 - Redis를 통한 동시성 문제 해결 (2/2)

[트러블슈팅] '선착순 쿠폰 발급' 로직 - 동시성 문제 발생 (w/ Race Condition) (1/2)일반 쿠폰 발급 로직 설계를 마치고, 선착순 쿠폰 발급 로직 개발에 들어왔다가 동시성 문제에 맞닥뜨렸다. 드디어

developer-jinnie.tistory.com