목적
멤버 쿠폰 발급에 대한 동시성 테스트
테스트할 코드
쿠폰 발급 API 요청 시 호출되는 issueMemberCoupon 메서드
@Transactional
public void issueMemberCoupon(Long couponGroupId, Long memberId) {
//쿠폰 그룹의 발급 가능여부 조회
CouponGroup couponGroup = findCouponGroupById(couponGroupId);
Member member = findMemberById(memberId);
LocalDateTime now = LocalDateTime.now();
//발행가능한지 여부
if (!couponGroup.getIsIssued()) {
throw new BadRequestException(ErrorCode.COUPON_OVER_AMOUNT);
}
//발행가능한 날짜인지
couponGroup.validateIssuedDate(now);
//member가 쿠폰 발급받은지 확인하기
int memberCouponCount = memberCouponRepository.countByMemberIdAndCouponGroupId(memberId, couponGroupId);
if (memberCouponCount > 0) {
throw new BadRequestException(ErrorCode.COUPON_OVER_AMOUNT_PER_MEMBER);
}
//쿠폰의 잔여수량 조회
Coupon coupon = couponRepository.findByCouponGroupId(couponGroupId)
.orElseThrow(() -> new NotFoundException(ErrorCode.COUPON_NOT_FOUND));
coupon.decreaseRemainQuantity();
MemberCoupon memberCoupon = MemberCoupon.builder()
.coupon(coupon)
.couponGroup(couponGroup)
.member(member)
.issuedAt(now)
.state(MemberCouponState.BEFORE_USAGE)
.build();
memberCouponRepository.save(memberCoupon);
}
1) 순차 발급일 경우
given : 멤버쿠폰이 30개일 때 요청한 멤버는 50명
when : 멤버 수만큼 멤버쿠폰 순차적으로 발급
then : 예상 결과로 30개의 멤버쿠폰만 발급
@Test
void 쿠폰_순차발급() {
//given
int memberCount = 50;
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
CouponGroup couponGroup = couponGroupRepository.findById(1L).get();
//when
for (int i = 0; i < memberCount; i++) {
Member member = Member.builder()
.email("test" + i + "@email.com")
.password("123")
.build();
memberRepository.save(member);
try {
couponService.issueMemberCoupon(couponGroup.getId(), member.getId());
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
}
}
//then
System.out.println("successCount = " + successCount);
System.out.println("failCount = " + failCount);
Long memberCouponCnt = memberCouponRepository.countByCouponGroupId(1L);
assertThat(memberCouponCnt)
.isEqualTo(Math.min(memberCount, 30));
}
실제 결과
- 출력
successCount = 30 failCount = 20
- DB
- memberCoupon은 30 row insert
- coupon의 남는 수량은 0
2) 동시발급일 경우
given : 멤버쿠폰이 30개일 때 요청한 멤버는 50명
when : 멤버 수만큼 멤버쿠폰 동시 발급
then : 30개의 멤버쿠폰만 발급
@Test
void 쿠폰_동시_발급() throws InterruptedException {
// given
int memberCount = 50;
int couponAmount = 30;
CouponGroup couponGroup = couponGroupRepository.findById(1L).get();
ExecutorService executorService = Executors.newFixedThreadPool(30);
CountDownLatch latch = new CountDownLatch(memberCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// when
for (int i = 0; i < memberCount; i++) {
executorService.submit(() -> {
try {
Member member = Member.builder()
.email("test" + "@email.com")
.password("123")
.build();
memberRepository.save(member);
couponService.issueMemberCoupon(couponGroup.getId(),member.getId());
successCount.incrementAndGet();
} catch (Exception e) {
System.out.println(e.getMessage());
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
System.out.println("successCount = " + successCount);
System.out.println("failCount = " + failCount);
// then
Long memberCouponCnt = memberCouponRepository.countByCouponGroupId(1L);
assertThat(memberCouponCnt)
.isEqualTo(Math.min(memberCount, couponAmount));
}
실제 결과
- 출력
successCount = 50 failCount = 0
- DB
- memberCoupon은 50 row insert
- coupon의 남는 수량은 24
원인
수량이 줄어들지 않은 coupon을 읽어오기 때문이다.
해결책
1) Sychronized
Synchronized 키워드를 사용한다.
synchronized 블록은 진입할 때, 락이 걸리고 빠져나올 때 락이 풀린다. 그러므로 먼저 synchronized 블록에 진입하는 스레드가 락을 걸어 소유하며, 락을 소유하지 못한 스레드는 락을 소유할 때까지 대기한다.
@Transactional
public synchronized void issueMemberCoupon(Long couponGroupId, Long memberId)
하지만 다른 스레드는 계속해서 락이 풀릴 때까지 기다리므로 성능이 저하를 야기할 수 있다.
실행 결과
- 출력
successCount = 50 failCount = 0
- DB
- memberCoupon은 50 row insert
- coupon의 남는 수량은 5
원인
remainQuantity는 0에 가까워졌지만
@Transactional과 sychronized 를 같이 사용했기 때문에 동시성을 제어하지 못한 것이다.
그 이유는 Spring AOP 때문이다. @Transactional을 사용하면 Spring AOP로 인해 프록시 객체가 만들어지고, 원래 객체인 CouponService의 issueMemberCoupon()의 실행이 끝나고 트랜잭션이 커밋되기 전에 다른 스레드가 데이터를 읽었기 때문이다.
// Proxy class
class CouponServiceProxy extends CouponService{
private CouponService target; // 실제 서비스 객체
private PlatformTransactionManager transactionManager; // 트랜잭션 매니저
public CouponServiceProxy(CouponService target, PlatformTransactionManager transactionManager) {
this.target = target;
this.transactionManager = transactionManager;
}
@Override
public void issueMemberCoupon(Long couponGroupId, Long memberId) {
// 트랜잭션 시작
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
TransactionStatus status = transactionManager.getTransaction(definition);
try {
// 실제 메서드 호출
target.issueMemberCoupon();
// 트랜잭션 커밋
transactionManager.commit(status);
} catch (Exception e) {
// 트랜잭션 롤백
transactionManager.rollback(status);
throw e;
}
}
}
// Origin Class
class CouponService {
public synchronized void issueMemberCoupon(Long couponGroupId, Long memberId) {
// ...
}
}
또한
synchronized는 한 프로세스 내에서만 동시성 제어를 할 수 있다. synchronized를 메서드에 사용한다면, 한 프로세스 내에서 한 번에 하나의 스레드만 해당 메서드에 접근하는 것을 보장할 수 있다. 실제 운영 환경에서는 여러대의 서버를 사용하기 때문에 데이터의 정합성을 보장할 수 없다.
2) 낙관적 잠금
낙관적 잠금은 아래와 같이 @Version 어노테이션을 통해 처리할 수 있다.
@Getter
@Builder
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "coupon")
public class Coupon extends AbstractTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "coupon_group_id")
CouponGroup couponGroup;
private String name;
private Integer initialQuantity;
private Integer remainQuantity;
/***/
@Version
private Integer version;
/***/
}
DB의 coupon 테이블에도 version 칼럼을 추가한다.
실행 결과
- 출력
successCount = 25 failCount = 25
- DB
- memberCoupon은 25 row insert
- coupon의 남는 수량은 5
- 쿼리
- version이 0인걸 조회해서 1로 update한다.
낙관적 잠금은 버전 불일치 시 처리를 어플리케이션 레벨에서 담당하게 된다. 이는 티켓 예매 요청이 버전 충돌로 인해 실패할 경우, 직접 예외를 처리하여 재시도하는 로직을 구현해야 함을 뜻한다.
재시도 로직을 AOP로 구현한 @Retry 어노테이션을 정의한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry { }
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@Aspect
@Component
public class OptimisticLockRetryAspect {
private static final int MAX_RETRIES = 1000;
private static final int RETRY_DELAY_MS = 100;
@Pointcut("@annotation(Retry)")
public void retry() {
}
@Around("retry()")
public Object retryOptimisticLock(ProceedingJoinPoint joinPoint) throws Throwable {
Exception exceptionHolder = null;
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return joinPoint.proceed();
} catch (OptimisticLockException | ObjectOptimisticLockingFailureException | StaleObjectStateException e) {
exceptionHolder = e;
Thread.sleep(RETRY_DELAY_MS);
}
}
throw exceptionHolder;
}
}
@Retry
@Transactional
public void issueMemberCoupon(Long couponGroupId, Long memberId) {
// ...
}
재시도하는 @Retry 추가 후 실행 결과
- 출력
successCount = 30
failCount = 20
- DB
- memberCoupon은 30 row insert
- coupon의 남는 수량은 0
3) Where절 추가
수정하기 이전에 값(remainQuantitu)과 일치할 때만 수정하도록 where절을 추가한다.
@Modifying
@Query("UPDATE Coupon c SET c.remainQuantity = :decRemainQuantity WHERE c.id = :id AND c.remainQuantity = :remainQuantity")
int updateByCouponGroupIdAndRemainQuantity(@Param("id") Long couponGroupId, @Param("remainQuantity") Integer remainQuantity,@Param("decRemainQuantity") Integer decRemainQuantity);
실행 결과
- 출력
successCount = 6
failCount = 44
데이터 정합성을 보장해주지만 요청에 대부분이 실패하게 된다.
낙관적 잠금처럼 재시도해주는 로직인 @Retry를 추가해주면 되지만 똑같이 요청 순서가 아닌 재시도 순서에 따라서 쿠폰이 발급되므로 의도한대로 발급해주는 것이 아니다.
4) 비관적 잠금
@Repository
public interface CouponRepository extends JpaRepository<Coupon, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Coupon c where c.id = :id")
Optional<Coupon> findByIdForUpdate(@Param("id") Long id);
}
실행 결과
- 출력
successCount = 30
failCount = 20
- DB
- memberCoupon은 30 row insert
- coupon의 남는 수량은 0
- 쿼리
하지만 비관적 잠금을 사용하는 결정을 내리는 것은 상황에 따라 다르며, 분산 데이터베이스 환경에서는 특히 주의해야 한다. 분산 환경에서는 비관적 잠금이 동시성을 제어하는 데 효과가 없을 수 있다.
지금 프로젝트는 단일 데이터베이스 환경이며 몇 만 건의 동시 요청이 되는 상황이 아니기 때문에 비관적 잠금을 사용했다.
결국, 비관적 잠금을 선택하는 것은 데이터베이스 환경과 응용 프로그램의 요구 사항에 따라 달라진다.
'Backend' 카테고리의 다른 글
[N+1 문제 , 연관관계] 일대일 관계에서의 N+1문제 (0) | 2023.03.28 |
---|---|
Jmeter을 이용한 성능 테스트 (0) | 2023.03.25 |
[Error] No matching variant of org.springframework.boot:spring-boot-gradle-plugin:3.0.1 was found. (0) | 2023.01.18 |
[개념]DAO, DTO, VO, CRUD (0) | 2020.04.20 |