포스트

선착순 쿠폰 발급을 만들어보며 배우는 동시성 제어 (Select For Update)

본 포스트에선 선착순 쿠폰 발급을 직접 만들어 보며 직면하게 되는 동시성 문제에 대해 설명하고, 해결하는 과정을 담았습니다.

들어가기 앞서

간단히 구현할 웹 사이트에 대해 간단하게 설명드리고 시작하겠습니다.

쿠폰의 종류 마다 한정된 수량 만큼 발급 할 수 있습니다. thymeleaf로 간단히 구성된 화면은 다음과 같습니다.

  • 메인화면
    메인화면
  • 쿠폰 발급 페이지
    쿠폰 발급 페이지

다른 자세한 구현내용은 깃허브를 참고 해 주세요.

일단 선착순 쿠폰을 구현하며 발생 할 문제를 얘기하려면 먼저 레이스 컨디션에 대해 선행되어야 합니다.

레이스 컨디션

레이스 컨디션(Race Condition)은 여러 스레드나 프로세스에서 한개의 공유 자원에 동시 접근할 때 발생하는 문제입니다. 이를 선착순 쿠폰 발급 비지니스 로직으로 설명 해 보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
@Transactional
public GeneratedCoupon couponIssue(String couponUUID) {
  Coupon coupon = couponRepository.findById(UUID.fromString(couponUUID))
    .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다."));

  if (!coupon.isAvailable()) {
    throw new IllegalArgumentException("선착순 쿠폰이 모두 소진되었습니다.");
  }

  coupon.decreaseRemainCount();
  return generatedCouponRepository.save(createGeneratedCoupon(coupon.getId()));
}

보시면 쿠폰 발급 시 findById(uuid)로 쿠폰 객체를 가져와서 coupon.isAvailable()를 통해 쿠폰의 잔여 개수를 확인 후 발급을 진행하고 있습니다.

선착순 쿠폰 발급 이라는 서비스 특성상 사용자가 몰릴 가능성이 농후합니다. 따라서 한번에 수십 수백, 수천명이 쿠폰 발급을 한다면 다음 처럼 문제가 발생 할 수 있습니다.

이런식으로 결국 발급 가능한 쿠폰이 1개인데 총 2개의 쿠폰이 발급될 수 있습니다.

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
@RepeatedTest(3)
@DisplayName("동시성 테스트")
void test() throws InterruptedException {
// given
int couponQuantity = 100; // 최대 발급 개수
int tryCouponIssue = 150; // 시도할 횟수
UUID testCouponId = couponRepository.saveAndFlush(
  Coupon.createNewCoupon("테스트 쿠폰", "테스트 쿠폰입니다.", couponQuantity)
).getId();

ExecutorService executor = Executors.newFixedThreadPool(13);
CountDownLatch latch = new CountDownLatch(tryCouponIssue);

// when
for (int i = 1; i <= tryCouponIssue; i++) {
  executor.submit(() -> {
	try {
	  couponService.couponIssue(testCouponId.toString());
	} catch (Exception e) {
	  e.printStackTrace();
	} finally {
	  latch.countDown();
	}
  });
}
latch.await();

// then
assertThat(generatedCouponRepository.countByCouponOriginId(testCouponId))
  .isEqualTo(couponQuantity);
}

실제로 테스트를 작성해서 실행해 보면,
최대 100개의 쿠폰이 발급되어야 하는데 이를 초과하여 발급된 쿠폰들이 있음을 확인할 수 있습니다.

동시성 테스트 실패

와중에 3번째 테스트는 성공했네요. 운이 좋았나 봅니다. (link)
이런 것 때문에 테스트 메서드에 @RepeatedTest(3)을 추가했습니다.

1
2
3
@RepeatedTest(3) // 만일의 경우를 위해 테스트를 3번 반복
void test() throws InterruptedException {
// ...... (후략)

대책을 생각해보자!

  • Before
    before

    기존 방식은 동시다발적으로 조회가 이루어지기 때문에, 데이터의 일관성이 깨진다고 볼 수 있습니다. 구체적으로 설명해보자면, 사용자 A가 쿠폰 수를 차감하는 행위가 손실이 됩니다. 즉 갱신 손실(Lost update)이 발생하게 됩니다.
    하지만, 조회와 수정을 한번에 한명만 가능하게 한다면 어떨까요?

  • After
    after

    나 쿠폰 개수 수정하려고 조회했으니깐 다들 기다려!!!
    이런 식으로 구현한다면, 어느 한쪽에서 수정을 완료해야 조회할 수 있기 때문에(Lock) 쿠폰이 초과 발급되는 일은 없을 것입니다.
    MySQL에서는 이러한 방식을 SELECT FOR UPDATE라고 불립니다.

    • SELECT FOR UPDATE ???
      동시성 제어를 위하여 특정 데이터(ROW)에 대해 베타적 Lock을 거는 기능

이제 방법을 대강 알았으니, 우리의 프로젝트에 적용해보는 일이 남았습니다.

JPA에서 지원하는 Lock

  • 비관적 락(Pessimistic Lock)

    비관적 = 앞으로의 일이 잘 안될 것이라고 보는 것.

    • 트랜잭션 충돌이 발생한다는 가정하에 우선 락을 걸고 보는 방식
    • 데이터베이스에서 사용하는 락 사용 (PESSIMISTIC_FORCE_INCREMENT 모드가 아니라면, 버전 정보 사용안함)
      • SELECT ~ FOR UPDATE 구문 사용
  • 낙관적 락

    낙관적 = 앞으로의 일 따위가 잘되어 갈 것으로 여기는 것.

    • 트랜잭션 충돌이 발생하지 않는다는 가정하에 진행
    • JPA가 제공하는 버전관리 기능을 사용
    • 트랜잭션 최종 커밋 전까지는 충돌을 알 수 없음 (최종 커밋하면서 버전을 비교하기 때문??)
    • 성능이 비관적 락보다 좋음

여기서 비관적 락을 사용하여 쿠폰 초과 발급을 방지해 보겠습니다.
다음과 같이 조회 쿼리 메서드 위에 @Lock(옵션 값)을 입력해 줍니다.
본 예시에서는 LockModeType.PESSIMISTIC_WRITE옵션을 사용했습니다.

1
2
3
4
5
6
7
public interface CouponRepository extends JpaRepository < Coupon, UUID > {

  @Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 추가
  @Query("select c from Coupon c where c.id = :id") // 기존 쿼리메소드 이름을 안쓰려고 직접 쿼리 생성
  Optional < Coupon > findCouponForUpdate(UUID id);

}

이후 테스트 코드를 다시 실행해 보면, 성공하는 것을 볼 수 있습니다.

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
  @RepeatedTest(3)
  @DisplayName("쿠폰은 동시성 제어를 통해 정해진 수량만큼 발급됩니다.")
  void test() throws InterruptedException {
    // given
    int couponQuantity = 100; // 최대 발급 개수
	int tryCouponIssue = 150; // 시도할 횟수
    UUID testCouponId = couponRepository.saveAndFlush(
      Coupon.createNewCoupon("테스트 쿠폰", "테스트 쿠폰입니다.", couponQuantity)
    ).getId();

    ExecutorService executor = Executors.newFixedThreadPool(13);
    CountDownLatch latch = new CountDownLatch(tryCouponIssue);

    // when
    for (int i = 1; i <= tryCouponIssue; i++) {
      executor.submit(() -> {
        try {
          couponService.couponIssue(testCouponId.toString());
        } catch (Exception e) {
          e.printStackTrace();
        } finally {
          latch.countDown();
        }
      });
    }
    latch.await();

    // then
    assertThat(generatedCouponRepository.countByCouponOriginId(testCouponId))
      .isEqualTo(couponQuantity);
  }
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

Comments powered by Disqus.