https://kitaees.tistory.com/94
[구현] 멀티스레드 상황에서의 자원 경쟁 (1)
싱글스레드 상황이 아니라 멀티스레드 환경에서 한정된 자원을 경쟁하는 시뮬레이션을 하기 위해 간단한 예제와 함께 공부해보았다. https://www.youtube.com/watch?v=LDi5muN2kgI 테코톡에서 '우르'님이
kitaees.tistory.com
이번에는 저번 글에 이어 동시성 문제에서 LOCK을 사용해 문제를 해결해보도록 하겠다.
우리는 JPA에서 제공하는 낙관적/비관적 락을 사용해볼 예정이다. 우선 낙관적 락부터 보도록 하자.
OptimisticTicket.java
package jpa.lock;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity @Getter
@NoArgsConstructor
public class OptimisticTicket {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int count;
@Version
private int version;
public void issue() {
if(count <= 0) {
throw new IllegalArgumentException("수량 부족");
}
count-=1;
}
}
낙관적 락을 사용할 엔티티를 새로 만들어보았다. 이전 Ticket과 다른 점은 @Version 어노테이션이 들어간 version 컬럼이 추가된 것이다. 낙관적 락을 따로 설명하진 않지만, 원리에서 알 수 있듯이 트랜잭션을 수행하고 commit 하는 시점에 version을 비교해 만약 version이 이전 버전이 아니면 예외를 발생시킨다.
테이블을 보면 VERSION이 생긴 것을 볼 수 있고 나는 임의로 이전 TICKET과 동일한 데이터에 VERSION 1만 추가시켜놨다. 이제 동시적 상황을 시뮬레이션 해보도록 하자.
@Test
@DisplayName("멀티쓰레드 상황에서 낙관적 티켓 카운팅을 감소시킨다.")
void issue_optimistic_ticket_in_multi_thread() throws InterruptedException {
final int executeNumber = 20;
final ExecutorService executorService = Executors.newFixedThreadPool(32);
final CountDownLatch countDownLatch = new CountDownLatch(executeNumber);
final AtomicInteger successCount = new AtomicInteger();
final AtomicInteger failCount = new AtomicInteger();
for(int i=0; i<executeNumber; i++) {
executorService.execute(() -> {
try {
ticketService.issueOptimisticTicket(1L);
successCount.getAndIncrement();
System.out.println("티켓 발급 완료");
} catch (Exception e) {
failCount.getAndIncrement();
e.printStackTrace();
}
countDownLatch.countDown();
});
}
countDownLatch.await();
System.out.println("발급된 쿠폰의 개수 = " + successCount.get());
System.out.println("실패한 횟수 = " + failCount.get());
}
해당 테스트 코드는 모두 똑같고 ticketService에서 다루는 객체만 Ticket -> OptimisticTicket으로 바꾼 것이다.
쿼리를 보면 update 쿼리에서 특이하게 version=?이 있는 것을 볼 수 있다. 위에서 설명했듯이 트랜잭션을 수행하고 commit 하는 시점에 version을 비교하는 것을 볼 수 있다.
그 다음은 ObjectOptimisticLockingFailureException이 터지는 걸 볼 수 있는데 이것이 version을 비교하고 version이 일치하지 않아 트랜잭션이 실패하고 예외가 터지는 것이다.
결론적으로, 낙관적 락을 사용했을 때 최초의 요청만 commit 하기 때문에 이렇게 5개의 쿠폰을 모두 발급하지 못하는 상황도 있을 수 있다. 하지만, 싱글스레드 상황과 다르게 한정된 자원(5개)를 넘는 경우의 수는 없기에 치명적인 이슈는 피할 수 있는 것이다.
다시 OPTIMISTIC_TICKET 테이블을 봐보면 VERSION이 올라간 것을 알 수 있다. 최초의 요청에 대해서 commit이 완료되면 VERSION을 1개씩 올리는 것이다.
그럼 다음은 비관적 락을 사용해보도록 하자.
@Repository
public interface TicketRepository extends JpaRepository<Ticket, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Ticket> findById(Long id);
}
비관적 락은 JPA에서 @Lock 어노테이션과 함께 LockModeType을 편리하게 지원해주고 있다.
기존에 사용하던 Ticket 객체를 사용하도록 하고 Repository에 있는 메소드만 @Lock을 걸어주었다.
이번엔 SELECT 절을 봐보자. SELECT 끝에 보면 for update가 있는데 비관적 락은 트랜잭션이 자원을 읽는 동시에 잠금을 걸어버리기 때문에 다른 트랜잭션을 대기를 하게 된다.
그리고 드디어 우리가 만들어 놓은 예외가 터지게 된다. 5개보다 수량을 넘어갔을 때 설정해놓은 예외가 터지는 것을 볼 수 있다.
최종적으로 발급된 쿠폰의 개수가 정확히 자원의 갯수만큼 5개 발급한 것을 볼 수 있다.
마지막으로 각 방식의 시간을 비교해보자.
낙관적 락은 395ms, 비관적 락은 26ms인 것을 볼 수 있다. 낙관적 락은 동시에 트랜잭션이 자원에 접근할 수 있지만 version이 달라 충돌이 발생하면 모두 롤백 처리를 하기 때문에 충돌이 많은 상황에서는 시간이 느릴 수 밖에 없다. 본인이 만든 서비스의 특성을 잘 파악해 락을 적절하게 사용하는 것이 좋아보인다.
'구현' 카테고리의 다른 글
실 유저와 가상 유저의 티켓팅 경쟁을 위한 대기열 구현기 (Redis) (0) | 2025.01.26 |
---|---|
Docker + GitHub Actions로 CI/CD와 배포하기 (Minikube 테스트와 EC2 프리티어 한계) (0) | 2025.01.07 |
[구현] 멀티스레드 상황에서의 자원 경쟁 (1) (0) | 2024.12.01 |
[구현] 싱글톤(Singleton) 패턴 직접 적용해보기 (0) | 2023.12.06 |
[구현] BaseTimeEntity로 불필요한 코드 줄이기 (JPA) (1) | 2023.11.08 |