Backend

낙관적 잠금과 비관적 잠금으로 알아보는 동시성 처리

호_두씨 2024. 5. 2. 18:02

목적

멤버 쿠폰 발급에 대한 동시성 테스트

테스트할 코드

쿠폰 발급 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

  • 쿼리

 

 

하지만 비관적 잠금을 사용하는 결정을 내리는 것은 상황에 따라 다르며, 분산 데이터베이스 환경에서는 특히 주의해야 한다. 분산 환경에서는 비관적 잠금이 동시성을 제어하는 데 효과가 없을 수 있다.

지금 프로젝트는 단일 데이터베이스 환경이며 몇 만 건의 동시 요청이 되는 상황이 아니기 때문에 비관적 잠금을 사용했다.

결국, 비관적 잠금을 선택하는 것은 데이터베이스 환경과 응용 프로그램의 요구 사항에 따라 달라진다.