문제
발급 가능 수량은 100장의 쿠폰이 있다.
10000명이 동시 접근 할때 쿠폰 발급을 정상적으로 수행 가능하게 하려면 어떻게 해야 할까 ?
냅다 코드를 짜버리면 이런식으로 100개 이상의 쿠폰이 발급되곤 한다.
이에 많은 답변으로 싱글스레드인 Redis 분산 락을 사용하는데
필자는 환경에 제한을 두어 In-memory와 RDB를 사용하여 해결하려고 한다.
쿠폰의 갯수 제한을 두는 방법을 2가지로 생각해 보았다.
- 발급된 쿠폰갯수를 카운트 하기
쿠폰을 발급할때마다 데이터 베이스에 조회를 따로 해야 하므로 네트워크 비용 , 부하가 발생할 수 있다.
- 엔터티에 갯수를 넣어 카운트 하기
쿠폰 발급 시 전체 쿠폰 수를 조회하지 않아도 되므로 성능면에서 이점이 있다.
하지만 추가 수량에 대한 대응 관리 기능을 추가해야 하므로 구현이 다소 복잡해질 수 있다.
분산 락 ( distributed Lock )
이 방법을 사용 하진 않지만 이러한 방법도 있다 !! 밑에 더보기 클릭
분산 락 ( distributed Lock ) 이란
자바 스프링 기반의 웹 애플리케이션은 기본적으로 멀티 스레드 환경에서 구동이 된다.
따라서, 여러 스레드가 함께 접근할 수 있는 공유 자원에 대해 Race condition이 발생하지 않도록 별도의 처리가 필요하다.
자바는 synchronized라는 키워드를 언어차원에서 제공하여, 모니터 기반의 상호배제 기능을 제공하게 된다.
하지만, 이런 메커니즘은 같은 프로세스에서만 상호 배제를 보장한다.
웹 애플리케이션 프로세스를 단 하나만 사용하는 서비스라면 상관없지만, 대부분 일반적으로 서버를 다중화하여 부하 분산 처리를 한다.
이러한 분산 환경 속에서 상호 배제를 구현하여 동시성 문제를 다루기 위해 등장한 방법이 바로 분산락이다.
여기서 알아야 할 사항은 분산락은 DB에서 제공하는 락의 종류가 아니다.
일반적으로 웹 애플리케이션에서 공유 자원으로 데이터베이스를 가장 많이 사용하기에, 데이터베이스에 대한 동시성 문제를 분산락으로 풀어내는 사례가 많을 뿐이다.
물론, DB의 락 기능을 활용하여 분산 락을 구현할 수 있지만, 직접적으로 연관 있다고 보기는 어렵다.
분산 락을 구현하기 위해 락에 대한 정보를 ‘어딘가’에 공통적으로 보관하고 있어야 한다. 그리고 분산 환경에서 여러 대의 서버들은 공통된 ‘어딘가’를 바라보며, 자신이 임계 영역(critical section)에 접근할 수 있는지 확인한다.
이렇게 분산 환경에서 원자성(atomic)을 보장할 수 있게 된다. 그리고 그 어딘가로 활용되는 기술은 MySQL의 네임드 락, Redis, Zookeeper 등이 있다.
JPA 비관적 잠금(Pessimistic Lock)
비관적 잠금(Pessimistic Lock) 이란?
- 이름 그대로 선점 잠금이라고 한다
- 트랜잭션끼리의 충돌이 발생한다고 가정하고 우선 락을 거는 방법
- DB에서 제공하는 락기능을 사용
#Repository Code
public interface EventRepository extends JpaRepository<Event,Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM Event e WHERE e.member IS NULL AND e.id = :eventId")
Optional<Event> findByEventIdForUpdate(@Param("eventId") Long eventId);
}
#Service Code
@Transactional
@Override
public void couponIssuance(CouponIssuanceRequestDto requestDto) {
Event event = eventRepository.findByEventIdForUpdate(requestDto.getEventId())
.orElseThrow(() -> new IllegalArgumentException("Event is not found"));
Member member = memberService.getMemberByMemberId(requestDto.getMemberId()) ;
/* 이벤트 종료 시점은 프론트 or 앞단에서 처리 해 준다.
if (event.isDeadlineExpired()) {
System.out.println("이벤트가 종료되었습니다.");
}
*/
event.decreaseCouponQuantity();
event.setMember(member);
eventRepository.save(event);
}
위의 코드에서 findByEventIdForUpdate 메소드는 PESSIMISTIC_WRITE 애너테이션을 사용해 락을 걸면
- Event 객체를 곧!!! 수정 할 것이니 다른 객체는 접근 못하게 막아줘 라고 선언 한 것이다.
PESSIMISTIC LOCK 의 경우 트랜잭션이 종료될때까지 락이 유지 된다. 트랜잭션이 정상종료되거나 , 예외를 발생하여 롤백되면 자동으로 해제가 된다.
하지만 락을 건 상태에서 네트워크 문제 등 불가피한 상황으로 인해 트랜잭션이 롤백되지 않은 상태에서 스레드가 죽으면 , 락은 계속 유지되므로 데드락이 발생될 수 있다. 이런 상황을 방지하기 위해 트랜잭션 타임아웃을 설정해야 한다.
@Transactional(timeout = 5)
LockMode 종류
적용방법
//Repository
public interface EventRepository extends JpaRepository<Event,Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM Event e WHERE e.member IS NULL AND e.id = :eventId")
Optional<Event> findByEventIdForUpdate(@Param("eventId") Long eventId);
}
- LockModeType.PESSIMISTIC_WRITE - 일반적인 옵션. 데이터베이스에 쓰기 락 다른 트랜잭션에서 읽기도 쓰기도 못함. (배타적 잠금)
- LockModeType.PESSIMISTIC_READ - 반복 읽기만하고 수정하지 않는 용도로 락을 걸 때 사용 다른 트랜잭션에서 읽기는 가능함. (공유 잠금)
- LockModeType.PESSINISTIC_FORCE_INCREMENT - Version 정보를 사용하는 비관적 락
Test Code
@Test
@DisplayName("쿠폰 발급 테스트 (멀티 스레드)")
void couponIssuanceForMultiThreadTest() throws InterruptedException {
CouponIssuanceRequestDto couponIssuanceRequestDto = new CouponIssuanceRequestDto(1L,1L,1L);
AtomicInteger successCount = new AtomicInteger();
int numberOfExecute = 1000;
ExecutorService service = Executors.newFixedThreadPool(1000);
CountDownLatch latch = new CountDownLatch(numberOfExecute);
Coupon mockCoupon = mock(Coupon.class);
Event event = Event.of(requestDto, LocalDateTime.now(), mockCoupon);
when(eventRepository.findByEventIdForUpdate(anyLong())).thenReturn(Optional.ofNullable(event));
for (int i = 0; i < numberOfExecute; i++) {
final int threadNumber = i + 1;
service.execute(() -> {
try {
eventService.couponIssuance(couponIssuanceRequestDto);
successCount.getAndIncrement();
System.out.println("Thread " + threadNumber + " - 성공");
} catch (PessimisticLockingFailureException e) {
System.out.println("Thread " + threadNumber + " - 락 충돌 감지");
} catch (Exception e) {
System.out.println("Thread " + threadNumber + " - " + e.getMessage());
}
latch.countDown();
});
}
latch.await();
// 성공한 경우의 수가 10개라고 가정.
assertThat(successCount.get()).isEqualTo(10);
}
여기서 사용된
CountDownLatch 는 모든 스레드가 호출 된 후 assert (종단문) 실행이 되어야 하기 때문에 사용 되었고,
ExecutorService 는 모든 스레드가 생성된 후
각 스레드에 eventService.couponIssuance(); 를 배치하여 병렬 실행 하기 위해 사용 되었고,
for 문은 스레드를 numberOfExecute갯수만큼 생성 해주기 위해 사용 되었다.
Thread 3 - 성공
Thread 8 - 성공
Thread 5 - 성공
Thread 4 - 성공
Thread 1 - 성공
Thread 7 - 성공
Thread 6 - 성공
Thread 2 - 성공
Thread 10 - 성공
Thread 9 - 성공
Thread 19 - 남은 쿠폰 수량이 없습니다.
Thread 14 - 남은 쿠폰 수량이 없습니다.
Thread 12 - 남은 쿠폰 수량이 없습니다.
Thread 16 - 남은 쿠폰 수량이 없습니다.
Thread 13 - 남은 쿠폰 수량이 없습니다.
.
.
.
정상적으로 테스트가 성공 하는 것을 볼 수 있다.
참고문헌
https://isntyet.github.io/jpa/JPA-%EB%B9%84%EA%B4%80%EC%A0%81-%EC%9E%A0%EA%B8%88(Pessimistic-Lock)/
'개-발 > Java + Spring + Kotlin' 카테고리의 다른 글
[Spring Batch] addBatch로 다량 쿼리문 한번에 실행 (bulk insert) (0) | 2024.01.17 |
---|---|
[JAVA] CountDownLatch 스레드 대기 시키기 (2) | 2024.01.05 |
[Spring] @Constraint로 커스텀 Vaildatation 만들기 (0) | 2023.12.27 |
[Spring] AOP 를 활용한 중복요청 방지 (따닥방지) (2) | 2023.12.19 |
[JAVA] Stream 이해 (Parallelism 병렬처리) (0) | 2023.06.12 |