싱글스레드 상황이 아니라 멀티스레드 환경에서 한정된 자원을 경쟁하는 시뮬레이션을 하기 위해 간단한 예제와 함께 공부해보았다.
https://www.youtube.com/watch?v=LDi5muN2kgI
테코톡에서 '우르'님이 발표해주신 예제 코드와 내용을 바탕으로 따라해보면서 공부했음을 미리 알립니다.
우선, 경쟁 상황을 가정해보면 20명의 사람이 있고 5개의 티켓이 있다고 가정한다. 20명의 사람이 동시에 한정된 자원인 5개의 '티켓'에 접근하고자 하는 것이다. 일반적으로 생각했을 때 티켓이 5개 있기 때문에 5명의 사람이 티켓 획득에 성공하고 15명은 획득에 실패하는 것이다.
이제 예시 코드를 봐보자.
Ticket.java
@Entity @Getter
@NoArgsConstructor
public class Ticket {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int count;
public void issue() {
if(count <= 0) {
throw new IllegalArgumentException("수량 부족");
}
count-=1;
}
}
엔티티 설계는 최대한 간단하게 했다. Ticket이라는 엔티티가 있고 id, count를 컬럼으로 가지고 있다. issue()라는 메소드는 count에 접근해서 수량을 감소시키고 만약 티켓이 0장인 상황에서 감소시키면 예외를 발생시킨다.
TicketService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TicketService {
private final TicketRepository ticketRepository;
@Transactional
public void issueTicket(Long id) {
Ticket ticket = ticketRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("잘못된 티켓"));
ticket.issue();
}
}
다음은 비즈니스 로직을 담은 Service 클래스이다. Service 클래스도 최대한 간단하게 구현했고 티켓 id를 넘기면 간단한 체크 후에 ticket의 수량을 감소시키는 tickt.issue()를 호출한다. TicketRepository는 간단하게 JpaRepository를 상속받았고 생략하도록 하겠다.
이제 시뮬레이션을 하기 전에, 싱글스레드 상황에서의 상황부터 보도록 하겠다.
위 사진을 보면 TICKET 테이블에 ID 1번인 ticket이 COUNT 5개만큼 있는 것을 볼 수 있다.
@Test
@DisplayName("싱글쓰레드 상황에서 티켓 카운팅을 감소시킨다.")
void issue_ticket_in_single_thread() {
ticketService.issueTicket(1L);
}
위 테스트코드는 싱글스레드 상황에서 1번 티켓의 수량을 1장 감소시키는 테스트코드이다.
테스트코드를 돌리면, 쿼리가 2개 나가는데 1번 티켓을 찾는 SELECT 쿼리 1개 / 수량을 감소시키는 UPDATE 쿼리 1개이다.
우리가 예상한대로 쿼리가 잘 나간 것을 볼 수 있고
티켓의 수량도 5->4로 1장 감소한 것을 볼 수 있다.
이정도면 간단하게 비즈니스 로직에 대해 테스트를 마쳤고, 멀티스레드 상황에서 시뮬레이션 해보도록 하자.
@Test
@DisplayName("멀티쓰레드 상황에서 티켓 카운팅을 감소시킨다.")
void issue_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.issueTicket(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());
}
위 코드는 멀티쓰레드 상황에서의 테스트코드인데 간단하게 설명하자면, 32개의 쓰레드풀을 만들고 동시에 20명의 사람이 티켓을 발급하는 코드이다. 이렇게 됐을 때, 우리가 예상한 결과라면 맨밑에 print 문에서 5개의 쿠폰 발급 성공 / 15개의 쿠폰 발급 실패가 떠야할 것이다.
근데 테스트 결과를 보면 발급된 티켓의 개수가 20개이다. 우리는 분명 5개의 티켓밖에 없었는데, 발급된 티켓은 20개 인것이다. 이렇게 되면 비즈니스 로직에 치명적인 이슈가 있는 것이다.
이는 코드에서 동시성 문제에 대해 설계가 안들어가있기 때문인 것이다.
ticketService.issueTicket(1L) 메서드가 티켓 발급 로직을 처리하는 동안, 여러 스레드가 동시에 같은 자원(티켓)을 읽고 수정했기 때문에 발생한 문제이다.
시나리오:
- 5개의 티켓이 있다고 가정.
- 20개의 스레드가 동시에 ticketService.issueTicket(1L) 호출.
- 각 스레드가 현재 티켓 수량을 읽고 발급 후 수량을 업데이트.
- 스레드 1: 티켓 수량 확인 → 5개 → 티켓 발급 → 업데이트(4개)
- 스레드 2: 티켓 수량 확인 → 5개 → 티켓 발급 → 업데이트(4개)
- 이런 식으로 티켓 수량이 올바르게 동기화되지 않음.
- 결과적으로, 5개의 티켓을 초과해서 발급.
이러한 문제를 해결하려면 어떻게 해야할까? 2편에서 동시성 문제를 해결해보도록 하겠다.
'구현' 카테고리의 다른 글
Docker + GitHub Actions로 CI/CD와 배포하기 (Minikube 테스트와 EC2 프리티어 한계) (0) | 2025.01.07 |
---|---|
[구현] 멀티스레드 상황에서의 자원 경쟁 (2) (1) | 2024.12.02 |
[구현] 싱글톤(Singleton) 패턴 직접 적용해보기 (0) | 2023.12.06 |
[구현] BaseTimeEntity로 불필요한 코드 줄이기 (JPA) (1) | 2023.11.08 |
[구현] 애플 소셜로그인 탈퇴 / SpringBoot (0) | 2023.07.28 |