5 분 소요

들어가며

오늘은 진행 중인 프로젝트에서 쿠폰 등록 유스케이스를 구현하며 겪은 과정을 정리해보려고 합니다.
먼저 프로젝트는 스프링부트와 JPA, H2 DB를 사용하여 구현하고 있습니다.

들어가기 전에 앞서 쿠폰 등록 유스케이스가 어떻게 진행되는지 간단하게 보겠습니다.

쿠폰 등록 유스케이스는 위와 같이 유저가 쿠폰 등록을 요청하면 쿠폰 코드를 조건으로 쿠폰을 조회한 후, 쿠폰이 등록 가능한 상태인지 검증을 하고 쿠폰에 유저 정보를 등록하는 과정을 거치게 됩니다.

위와 같은 상황에서 여러 명이 동시에 쿠폰 등록을 요청하면 어떻게 될까요?

3명의 유저가 동시에 같은 쿠폰을 등록하는 요청을 보냈다면 쿠폰 등록을 처리하는 각각의 스레드에서 쿠폰을 수정하게 되어 가장 마지막으로 처리된 스레드의 값으로 갱신되는 문제가 발생합니다.

따로 동시성 이슈를 방지하기 위한 처리를 하지 않았다면 각각의 유저들은 모두 쿠폰이 등록되었다는 응답을 받게 되겠지만, 실제론 한 유저만 쿠폰이 등록되어 있을 것입니다.

이를 갱신 분실 문제라고 합니다.
이번엔 코드를 살펴보겠습니다.

예제 코드

@Entity
public class Coupon {
    
    ...

    public void register(final Member owner, final LocalDateTime currentTime) {
        verifyIsRegistrable(currentTime);
        this.owner = owner;
        this.status = CouponStatus.REGISTERED;
    }

    private void verifyIsRegistrable(final LocalDateTime currentTime) {
        if (this.status != CouponStatus.UNREGISTERED) {
            throw new AlreadyRegisteredCouponException(ErrorCode.ALREADY_REGISTERED_COUPON);
        }
        if (this.expirationAt.isBefore(currentTime)) {
            throw new ExpiredCouponException(ErrorCode.EXPIRED_COUPON);
        }
    }
}

쿠폰 엔티티는 위와 같이 쿠폰을 등록하는 register 메서드를 가지고 있습니다.
쿠폰 등록 전에 쿠폰이 이미 등록되었는지, 만료되었는지 검증한 후 요청한 유저를 쿠폰 소유자로 등록하는 책임을 지고 있습니다.

@Service
public class RegisterCouponProcessor {

    private final CouponRepository couponRepository;

    @Transactional
    public CouponRegisteredResult registerCoupon(
        final CouponRegisterCommand command,
        final LocalDateTime currentTime
    ) {
        final Coupon coupon = couponRepository.findByCouponCode(command.couponCode())
            .orElseThrow(() -> new NotFoundCouponException(ErrorCode.NOT_FOUND_COUPON));

        coupon.register(command.member(), currentTime);

        return CouponRegisteredResult.from(command.member().getEmail(), coupon);
    }
}

쿠폰 등록을 처리하는 서비스 레이어입니다. 쿠폰을 조회한 후 쿠폰을 등록하는 책임을 Coupon 도메인 객체에 위임하고 있습니다.

@SpringBootTest
class RegisterCouponProcessorTest {
    
    @RepeatedTest(30)
    @DisplayName("동시에 쿠폰을 등록할 경우 하나만 성공하고 나머지는 실패한다")
    void registerCoupon_when_concurrency() throws Exception {
        final String couponCode = "coupon";
        final int threadCount = 100;
        final ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        final CountDownLatch latch = new CountDownLatch(threadCount);
        List<Future<Object>> futures = new ArrayList<>();

        for (int i = 0; i < threadCount; i++) {
            final Member member = memberRepository.save(
                Member.of(
                    "user" + i,
                    "user" + i + "@test.com",
                    "1234"));
            final LocalDateTime currentTime = LocalDateTime.of(2023, 6, 30, 0, 0);
            final Future<Object> future = executorService.submit(() -> {
                try {
                    return sut.registerCoupon(
                        new CouponRegisterCommand(member, couponCode),
                        currentTime);
                } catch (Exception e) {
                    return e;
                } finally {
                    latch.countDown();
                }
            });

            futures.add(future);
        }

        latch.await();

        assertThat(futures)
            .extracting(Future::get)
            .filteredOn(o -> o instanceof CouponRegisteredResult)
            .hasSize(1);
    }
}

테스트 코드입니다.
ExecutorServiceCountDownLatch를 이용해 100개의 스레드가 동시에 쿠폰을 등록하는 상황을 만들었습니다.
결과를 저장하는 Future 리스트에서 CouponRegisteredResult가 1개만 존재하는지 검증하고 있습니다.

30번의 반복 테스트 결과 단 1번을 제외하고 모두 실패한 것을 확인할 수 있었습니다.

어떻게 해결할까?

먼저 문제의 원인은 공유 자원에 여러 스레드가 동시에 접근했기 때문에 발생했습니다.
이를 Race Condition이라 하며 반대로 쉽게 해결하는 방법은 공유 자원에 접근하는 스레드를 1개로 제한하는 것입니다.
물론 이 방법은 동시성을 완전히 배제하는 것이기 때문에 동시성을 활용하는 의미가 없어지며, 성능에도 악영향을 미칠 수 있습니다.

그렇다면 동시성을 활용하면서도 문제를 해결할 수 있는 방법은 없을까요?

이번에는 JPA가 제공하는 동시성 제어 기능을 소개해보려고 합니다.

JPA는 동시성 제어 메커니즘을 지원하는 기술을 제공합니다.
JPA가 제공하는 동시성 제어 기능은 크게 비관적 락과 낙관적 락이 있습니다.

일단은 각각의 기능을 사용하여 테스트를 시도해보겠습니다.

낙관적 락 적용해보기

@Entity
public class Coupon {
    
    ...
    
    @Version
    private Long version;
}

우선 낙관적 락을 적용하기 위해선 해당 엔티티에 @Version을 추가해야 합니다. @Version은 javax, jakarta에서 제공하는 어노테이션으로 엔티티의 버전을 관리할 때 사용되며, 해당 어노테이션이 붙은 필드를 가진 엔티티는 자동으로 낙관적 락이 적용됩니다.

여기서 주의할 점은 절대 직접 @Version을 수정하면 안된다는 것입니다.
직접 수정하게 되면 낙관적 락의 메커니즘을 우회하기 때문에 충돌이 발생하지 않았음에도 OptimisticLockException이 발생하는 등 데이터의 일관성을 보장할 수 없게 됩니다.

public interface CouponJpaRepository extends JpaRepository<Coupon, Long> {

    @Lock(LockModeType.OPTIMISTIC)
    @Query("select c from Coupon c where c.code = :couponCode")
    Optional<Coupon> findByCodeWithLock(@Param("couponCode") final String couponCode);
}

위에 썼다시피 @Version만 엔티티에 적용해도 낙관적 락이 적용됩니다. 다만 이 경우에는 엔티티를 수정할 때만 버전을 체크하며 조회할 때는 버전을 체크하지 않습니다.

LockModeType.OPTIMISTIC을 명시하는 경우 조회할 때도 버전을 체크하기 때문에 트랜잭션이 한 번 조회한 순간 끝날 때까지 다른 트랜잭션에서 변경하지 않음을 보장합니다.

이외에도 LockModeType.OPTIMISTIC_FORCE_INCREMENT 옵션이 있는데 이 옵션은 조회할 때는 버전을 1만큼 증가시키고, 수정한 경우 2만큼 증가시킨다는 차이가 있습니다.

우선 낙관적 락을 적용했으니 테스트를 다시 돌려보겠습니다.

OPTIMISTIC

테스트가 통과한 것을 확인할 수 있습니다.

비관적 락 적용해보기

public interface CouponJpaRepository extends JpaRepository<Coupon, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select c from Coupon c where c.code = :couponCode")
    Optional<Coupon> findByCodeWithLock(@Param("couponCode") final String couponCode);
}

비관적 락의 적용은 LockModeType.PESSIMISTIC_WRITE를 명시하면 됩니다.
이 때는 DB의 배타 락 기능을 사용해서 최초의 트랜잭션이 락을 획득하여 작업을 수행하고, 작업이 완료될 때까지 다른 트랜잭션의 접근을 막아 충돌을 방지합니다.
LockModeType.PESSIMISTIC_WRITE는 DB의 배타 락 기능을 사용하기 때문에 DB 벤더에 따라 동작이 다를 수 있습니다.

PESSIMISTIC

비관적 락으로도 문제를 해결한 것을 확인할 수 있습니다.

그럼 둘 중 무엇을 사용해야 할까?

낙관적 락과 비관적 락은 아주 큰 차이가 있으며, 각각의 장단점이 있어 어떤 락을 사용해야할 지는 상황에 따라 다릅니다.

비관적 락은 실제로 데이터에 락을 걸어서 정합성을 보장하는 방법으로 애초에 데이터가 동시에 변경될 수 있다는 것을 전제로 합니다.
트랜잭션의 충돌이 발생할 것을 가정하기 때문에 우선 락을 걸어 트랜잭션이 작업을 마무리할 때까지 다른 트랜잭션의 접근을 막아 정합성을 보장하지만 반대로 충돌이 자주 발생하지 않는 경우 성능에 좋지 않은 영향을 미칠 수 있습니다.

낙관적 락은 데이터에 락을 걸지 않고 트랜잭션이 작업을 마무리할 때까지 다른 트랜잭션의 접근을 막지 않습니다.
이름처럼 트랜잭션 충돌이 발생하지 않을 것이라고 낙관적으로 가정하며 엔티티를 수정한 후 반영할 때 엔티티의 @Version을 비교해 충돌이 발생했는지 확인합니다. 트랜잭션 충돌이 발생하면 OptimisticLockException을 발생시키고 트랜잭션을 롤백하는데 이 때 재시도 로직을 작성해서 처리하는 것이 일반적입니다.
때문에 충돌이 자주 발생한다면 오히려 비관적 락보다 더 많은 성능 저하가 발생할 수 있습니다.

이번의 테스트 케이스에서는 두 락의 성능 차이가 드라마틱하게 발생하지 않았지만, 저는 피드백을 받고 나서 두 가지의 이유로 비관적 락으로 기능을 구현하기로 했습니다.

  1. 낙관적 락의 경우 충돌이 발생할 경우를 위해 재시도 로직을 작성해야 합니다. 조회 후 반영을 할 때, 버전이 다른 경우 다시 조회를 수행해야 하기 때문에 오히려 불필요한 IO가 많이 일어난다고 생각합니다.
  2. 쿠폰 등록의 경우 자주 발생할 이벤트는 아니라고 생각이 들지만, 각각의 요청이 한 번의 조회로 성공 또는 실패로 갈리기 때문에 오히려 성능이 더 좋을 것이라고 생각합니다.

정리하며

이번에는 JPA가 제공하는 락 메커니즘을 이용해 동시성 문제를 해결해보았습니다. 추후에 쿠폰 등록 유스케이스에서 충돌이 자주 발생하는 경우 또는 학습에 중점을 두고 Redis를 이용한 동시성 이슈를 해결해보는 것도 재밌을 것 같습니다.

잘못된 정보나 피드백이 있다면 편하게 댓글로 남겨주세요! 😇

참고
https://www.baeldung.com/jpa-optimistic-locking
재고시스템으로 알아보는 동시성이슈 해결방법
자바 ORM 표준 JPA 프로그래밍

댓글남기기