선착순 쿠폰 발급을 만들어보며 배우는 동시성 제어 (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 {
// ...... (후략)
대책을 생각해보자!
기존 방식은 동시다발적으로 조회가 이루어지기 때문에, 데이터의 일관성이 깨진다고 볼 수 있습니다. 구체적으로 설명해보자면, 사용자 A가 쿠폰 수를 차감하는 행위가 손실이 됩니다. 즉 갱신 손실(Lost update)이 발생하게 됩니다.
하지만, 조회와 수정을 한번에 한명만 가능하게 한다면 어떨까요?나 쿠폰 개수 수정하려고 조회했으니깐 다들 기다려!!!
이런 식으로 구현한다면, 어느 한쪽에서 수정을 완료해야 조회할 수 있기 때문에(Lock
) 쿠폰이 초과 발급되는 일은 없을 것입니다.
MySQL에서는 이러한 방식을SELECT FOR UPDATE
라고 불립니다.- SELECT FOR UPDATE ???
동시성 제어를 위하여 특정 데이터(ROW)에 대해 베타적Lock
을 거는 기능
- SELECT FOR UPDATE ???
이제 방법을 대강 알았으니, 우리의 프로젝트에 적용해보는 일이 남았습니다.
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);
}
Comments powered by Disqus.