diff --git a/build.gradle b/build.gradle index 8a4f4f1..e719a8d 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,10 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + implementation 'org.springframework.retry:spring-retry' } tasks.named('test') { diff --git a/docker/docker-compose-local.yml b/docker/docker-compose-local.yml index 490b96f..051a0e6 100644 --- a/docker/docker-compose-local.yml +++ b/docker/docker-compose-local.yml @@ -22,5 +22,25 @@ services: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci + prometheus: + image: prom/prometheus:latest + container_name: ticket-service-prometheus + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + depends_on: + - mysql + + grafana: + image: grafana/grafana:latest + container_name: ticket-service-grafana + ports: + - "3000:3000" + volumes: + - ticket-service-grafana-data:/var/lib/grafana + depends_on: + - prometheus volumes: ticket-service-data: + ticket-service-grafana-data: \ No newline at end of file diff --git a/docker/monitoring/prometheus/prometheus.yml b/docker/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..2594947 --- /dev/null +++ b/docker/monitoring/prometheus/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: "ticket-service" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: + - host.docker.internal:8080 diff --git a/src/main/java/com/ticket_service/common/config/RetryConfig.java b/src/main/java/com/ticket_service/common/config/RetryConfig.java new file mode 100644 index 0000000..a1f283e --- /dev/null +++ b/src/main/java/com/ticket_service/common/config/RetryConfig.java @@ -0,0 +1,9 @@ +package com.ticket_service.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@Configuration +@EnableRetry +public class RetryConfig { +} diff --git a/src/main/java/com/ticket_service/ticket/entity/TicketStock.java b/src/main/java/com/ticket_service/ticket/entity/TicketStock.java index a4231e6..00e057d 100644 --- a/src/main/java/com/ticket_service/ticket/entity/TicketStock.java +++ b/src/main/java/com/ticket_service/ticket/entity/TicketStock.java @@ -16,6 +16,9 @@ public class TicketStock { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Version + private Long version; + @OneToOne(fetch = FetchType.LAZY) private Concert concert; diff --git a/src/main/java/com/ticket_service/ticket/service/OptimisticLockTicketStockService.java b/src/main/java/com/ticket_service/ticket/service/OptimisticLockTicketStockService.java new file mode 100644 index 0000000..e5b8e8e --- /dev/null +++ b/src/main/java/com/ticket_service/ticket/service/OptimisticLockTicketStockService.java @@ -0,0 +1,45 @@ +package com.ticket_service.ticket.service; + +import com.ticket_service.ticket.entity.TicketStock; +import com.ticket_service.ticket.repository.TicketStockRepository; +import jakarta.persistence.OptimisticLockException; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@ConditionalOnProperty(name = "ticket.lock.strategy", havingValue = "optimistic") +@RequiredArgsConstructor +public class OptimisticLockTicketStockService implements TicketStockService { + + private final TicketStockRepository ticketStockRepository; + + @Override + @Retryable( + retryFor = {OptimisticLockException.class, ObjectOptimisticLockingFailureException.class}, + maxAttempts = 50, + backoff = @Backoff(delay = 50) + ) + @Transactional + public void decrease(Long ticketStockId, int requestQuantity) { + TicketStock ticketStock = ticketStockRepository.findById(ticketStockId) + .orElseThrow(() -> new IllegalArgumentException("TicketStock not found")); + + ticketStock.decreaseQuantity(requestQuantity); + } + + @Recover + public void recover(OptimisticLockException e, Long ticketStockId, int requestQuantity) { + throw new IllegalStateException("최대 재시도 횟수를 초과했습니다. ticketStockId: " + ticketStockId); + } + + @Recover + public void recover(ObjectOptimisticLockingFailureException e, Long ticketStockId, int requestQuantity) { + throw new IllegalStateException("최대 재시도 횟수를 초과했습니다. ticketStockId: " + ticketStockId); + } +} diff --git a/src/main/java/com/ticket_service/ticket/service/PessimisticLockTicketStockService.java b/src/main/java/com/ticket_service/ticket/service/PessimisticLockTicketStockService.java new file mode 100644 index 0000000..cc094e7 --- /dev/null +++ b/src/main/java/com/ticket_service/ticket/service/PessimisticLockTicketStockService.java @@ -0,0 +1,23 @@ +package com.ticket_service.ticket.service; + +import com.ticket_service.ticket.entity.TicketStock; +import com.ticket_service.ticket.repository.TicketStockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@ConditionalOnProperty(name = "ticket.lock.strategy", havingValue = "pessimistic", matchIfMissing = true) +@RequiredArgsConstructor +public class PessimisticLockTicketStockService implements TicketStockService { + private final TicketStockRepository ticketStockRepository; + + @Transactional + public void decrease(Long ticketStockId, int requestQuantity) { + TicketStock ticketStock = ticketStockRepository.findByIdWithPessimisticLock(ticketStockId) + .orElseThrow(() -> new IllegalArgumentException("TicketStock not found")); + + ticketStock.decreaseQuantity(requestQuantity); + } +} diff --git a/src/main/java/com/ticket_service/ticket/service/SynchronizedTicketStockService.java b/src/main/java/com/ticket_service/ticket/service/SynchronizedTicketStockService.java new file mode 100644 index 0000000..610f64e --- /dev/null +++ b/src/main/java/com/ticket_service/ticket/service/SynchronizedTicketStockService.java @@ -0,0 +1,18 @@ +package com.ticket_service.ticket.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@Service +@ConditionalOnProperty(name = "ticket.lock.strategy", havingValue = "synchronized") +@RequiredArgsConstructor +public class SynchronizedTicketStockService implements TicketStockService { + + private final TicketStockTransactionalService ticketStockTransactionalService; + + @Override + public synchronized void decrease(Long id, int quantity) { + ticketStockTransactionalService.decrease(id, quantity); + } +} diff --git a/src/main/java/com/ticket_service/ticket/service/TicketStockService.java b/src/main/java/com/ticket_service/ticket/service/TicketStockService.java index 55a73cb..c0d4e13 100644 --- a/src/main/java/com/ticket_service/ticket/service/TicketStockService.java +++ b/src/main/java/com/ticket_service/ticket/service/TicketStockService.java @@ -1,21 +1,5 @@ package com.ticket_service.ticket.service; -import com.ticket_service.ticket.entity.TicketStock; -import com.ticket_service.ticket.repository.TicketStockRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class TicketStockService { - private final TicketStockRepository ticketStockRepository; - - @Transactional - public void decrease(Long ticketStockId, int requestQuantity) { - TicketStock ticketStock = ticketStockRepository.findByIdWithPessimisticLock(ticketStockId) - .orElseThrow(() -> new IllegalArgumentException("TicketStock not found")); - - ticketStock.decreaseQuantity(requestQuantity); - } +public interface TicketStockService { + void decrease(Long id, int quantity); } diff --git a/src/main/java/com/ticket_service/ticket/service/TicketStockTransactionalService.java b/src/main/java/com/ticket_service/ticket/service/TicketStockTransactionalService.java new file mode 100644 index 0000000..74586b6 --- /dev/null +++ b/src/main/java/com/ticket_service/ticket/service/TicketStockTransactionalService.java @@ -0,0 +1,21 @@ +package com.ticket_service.ticket.service; + +import com.ticket_service.ticket.entity.TicketStock; +import com.ticket_service.ticket.repository.TicketStockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TicketStockTransactionalService { + private final TicketStockRepository ticketStockRepository; + + @Transactional + public void decrease(Long ticketStockId, int requestQuantity) { + TicketStock ticketStock = ticketStockRepository.findById(ticketStockId) + .orElseThrow(() -> new IllegalArgumentException("TicketStock not found")); + + ticketStock.decreaseQuantity(requestQuantity); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0c01763..f98c9d2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,13 @@ spring: application: - name: ticket-service \ No newline at end of file + name: ticket-service + +management: + endpoints: + web: + exposure: + include: health,metrics,prometheus + +ticket: + lock: + strategy: pessimistic \ No newline at end of file diff --git a/src/test/java/com/ticket_service/ticket/service/TicketStockServiceIntegrationTest.java b/src/test/java/com/ticket_service/ticket/service/OptimisticLockTicketStockServiceIntegrationTest.java similarity index 88% rename from src/test/java/com/ticket_service/ticket/service/TicketStockServiceIntegrationTest.java rename to src/test/java/com/ticket_service/ticket/service/OptimisticLockTicketStockServiceIntegrationTest.java index 25ad51f..2c854a4 100644 --- a/src/test/java/com/ticket_service/ticket/service/TicketStockServiceIntegrationTest.java +++ b/src/test/java/com/ticket_service/ticket/service/OptimisticLockTicketStockServiceIntegrationTest.java @@ -1,7 +1,6 @@ package com.ticket_service.ticket.service; import com.ticket_service.ticket.entity.TicketStock; -import com.ticket_service.ticket.exception.InsufficientTicketStockException; import com.ticket_service.ticket.repository.TicketStockRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -10,6 +9,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -18,8 +19,10 @@ import static org.assertj.core.api.Assertions.assertThat; @ActiveProfiles("test") -@SpringBootTest -class TicketStockServiceIntegrationTest { +@SpringBootTest(properties = { + "ticket.lock.strategy=optimistic" +}) +class OptimisticLockTicketStockServiceIntegrationTest { @Autowired private TicketStockService ticketStockService; @@ -29,9 +32,14 @@ class TicketStockServiceIntegrationTest { private static final int threadPoolSize = 32; + private final Set createdTicketStockIds = ConcurrentHashMap.newKeySet(); + @AfterEach void tearDown() { - ticketStockRepository.deleteAllInBatch(); + if (!createdTicketStockIds.isEmpty()) { + ticketStockRepository.deleteAllByIdInBatch(createdTicketStockIds); + createdTicketStockIds.clear(); + } } @DisplayName("100개 재고에 100개 요청 - 모두 성공") @@ -96,7 +104,7 @@ void decrease_50_stocks_with_100_requests_only_50_success() throws Exception { try { ticketStockService.decrease(ticketStockId, requestQuantityPerThread); successCount.incrementAndGet(); - } catch (InsufficientTicketStockException e) { + } catch (Exception e) { failCount.incrementAndGet(); } finally { latch.countDown(); @@ -115,12 +123,16 @@ void decrease_50_stocks_with_100_requests_only_50_success() throws Exception { } private TicketStock createTicketStock(int quantity) { - return ticketStockRepository.save( + TicketStock ticketStock = ticketStockRepository.save( TicketStock.builder() .totalQuantity(quantity) .remainingQuantity(quantity) .build() ); + + createdTicketStockIds.add(ticketStock.getId()); + + return ticketStock; } private TicketStock findTicketStock(Long id) { diff --git a/src/test/java/com/ticket_service/ticket/service/PessimisticLockTicketStockServiceIntegrationTest.java b/src/test/java/com/ticket_service/ticket/service/PessimisticLockTicketStockServiceIntegrationTest.java new file mode 100644 index 0000000..cab2401 --- /dev/null +++ b/src/test/java/com/ticket_service/ticket/service/PessimisticLockTicketStockServiceIntegrationTest.java @@ -0,0 +1,161 @@ +package com.ticket_service.ticket.service; + +import com.ticket_service.ticket.entity.TicketStock; +import com.ticket_service.ticket.exception.InsufficientTicketStockException; +import com.ticket_service.ticket.repository.TicketStockRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +@SpringBootTest +class PessimisticLockTicketStockServiceIntegrationTest { + + @Autowired + private PessimisticLockTicketStockService pessimisticLockTicketStockService; + + @Autowired + private TicketStockRepository ticketStockRepository; + + private static final int threadPoolSize = 32; + + private final Set createdTicketStockIds = ConcurrentHashMap.newKeySet(); + + @AfterEach + void tearDown() { + if (!createdTicketStockIds.isEmpty()) { + ticketStockRepository.deleteAllByIdInBatch(createdTicketStockIds); + createdTicketStockIds.clear(); + } + } + + @DisplayName("100개 재고에 100개 요청 - 모두 성공") + @Test + void decrease_100_stocks_with_100_requests_all_success() throws Exception { + // given + int initialQuantity = 100; + int threadCount = 100; + int requestQuantityPerThread = 1; + + TicketStock ticketStock = createTicketStock(initialQuantity); + Long ticketStockId = ticketStock.getId(); + + // when + executeConcurrentDecrease(ticketStockId, threadCount, requestQuantityPerThread); + + // then + TicketStock result = findTicketStock(ticketStockId); + + assertThat(result.getRemainingQuantity()).isEqualTo(0); + } + + @DisplayName("100개 재고에 50명이 각각 2개씩 요청 - 모두 성공") + @Test + void decrease_100_stocks_with_50_requests_of_2_quantity() throws Exception { + // given + int initialQuantity = 100; + int threadCount = 50; + int requestQuantityPerThread = 2; + + TicketStock ticketStock = createTicketStock(initialQuantity); + Long ticketStockId = ticketStock.getId(); + + // when + executeConcurrentDecrease(ticketStockId, threadCount, requestQuantityPerThread); + + // then + TicketStock result = findTicketStock(ticketStockId); + assertThat(result.getRemainingQuantity()).isEqualTo(0); + } + + @DisplayName("50개 재고에 100개 요청 - 50개만 성공") + @Test + void decrease_50_stocks_with_100_requests_only_50_success() throws Exception { + // given + int initialQuantity = 50; + int threadCount = 100; + int requestQuantityPerThread = 1; + + TicketStock ticketStock = createTicketStock(initialQuantity); + Long ticketStockId = ticketStock.getId(); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize); + CountDownLatch latch = new CountDownLatch(threadCount); + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + pessimisticLockTicketStockService.decrease(ticketStockId, requestQuantityPerThread); + successCount.incrementAndGet(); + } catch (InsufficientTicketStockException e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // then + TicketStock result = findTicketStock(ticketStock.getId()); + assertThat(result.getRemainingQuantity()).isEqualTo(0); + assertThat(successCount.get()).isEqualTo(50); + assertThat(failCount.get()).isEqualTo(50); + } + + private TicketStock createTicketStock(int quantity) { + TicketStock ticketStock = ticketStockRepository.save( + TicketStock.builder() + .totalQuantity(quantity) + .remainingQuantity(quantity) + .build() + ); + + createdTicketStockIds.add(ticketStock.getId()); + + return ticketStock; + } + + private TicketStock findTicketStock(Long id) { + return ticketStockRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("TicketStock not found")); + } + + private void executeConcurrentDecrease(Long ticketStockId, int threadCount, int requestQuantityPerThread) + throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize); + CountDownLatch latch = new CountDownLatch(threadCount); + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + pessimisticLockTicketStockService.decrease(ticketStockId, requestQuantityPerThread); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); // 모든 스레드 종료 대기 + executorService.shutdown(); + } +} diff --git a/src/test/java/com/ticket_service/ticket/service/TicketStockServiceTest.java b/src/test/java/com/ticket_service/ticket/service/PessimisticLockTicketStockServiceTest.java similarity index 87% rename from src/test/java/com/ticket_service/ticket/service/TicketStockServiceTest.java rename to src/test/java/com/ticket_service/ticket/service/PessimisticLockTicketStockServiceTest.java index caecd7f..f9b61b5 100644 --- a/src/test/java/com/ticket_service/ticket/service/TicketStockServiceTest.java +++ b/src/test/java/com/ticket_service/ticket/service/PessimisticLockTicketStockServiceTest.java @@ -18,12 +18,12 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) -class TicketStockServiceTest { +class PessimisticLockTicketStockServiceTest { @Mock private TicketStockRepository ticketStockRepository; @InjectMocks - private TicketStockService ticketStockService; + private PessimisticLockTicketStockService pessimisticLockTicketStockService; @DisplayName("재고 차감 성공 - 재고가 충분한 경우") @Test @@ -43,7 +43,7 @@ void decrease_success_when_sufficient_stock() { .willReturn(Optional.of(ticketStock)); // when - ticketStockService.decrease(ticketStockId, requestQuantity); + pessimisticLockTicketStockService.decrease(ticketStockId, requestQuantity); // then assertThat(ticketStock.getRemainingQuantity()).isEqualTo(resultRemainingQuantity); @@ -68,7 +68,7 @@ void decrease_fail_when_insufficient_stock() { .willReturn(Optional.of(ticketStock)); // when & then - assertThatThrownBy(() -> ticketStockService.decrease(ticketStockId, requestQuantity)) + assertThatThrownBy(() -> pessimisticLockTicketStockService.decrease(ticketStockId, requestQuantity)) .isInstanceOf(InsufficientTicketStockException.class); // 재고는 변경되지 않아야 함 @@ -87,7 +87,7 @@ void decrease_fail_when_ticket_stock_not_found() { .willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> ticketStockService.decrease(nonExistentTicketStockId, requestQuantity)) + assertThatThrownBy(() -> pessimisticLockTicketStockService.decrease(nonExistentTicketStockId, requestQuantity)) .isInstanceOf(IllegalArgumentException.class); verify(ticketStockRepository).findByIdWithPessimisticLock(nonExistentTicketStockId); diff --git a/src/test/java/com/ticket_service/ticket/service/SynchronizedTicketStockServiceIntegrationTest.java b/src/test/java/com/ticket_service/ticket/service/SynchronizedTicketStockServiceIntegrationTest.java new file mode 100644 index 0000000..13fbc12 --- /dev/null +++ b/src/test/java/com/ticket_service/ticket/service/SynchronizedTicketStockServiceIntegrationTest.java @@ -0,0 +1,160 @@ +package com.ticket_service.ticket.service; + +import com.ticket_service.ticket.entity.TicketStock; +import com.ticket_service.ticket.exception.InsufficientTicketStockException; +import com.ticket_service.ticket.repository.TicketStockRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +@SpringBootTest(properties = { + "ticket.lock.strategy=synchronized" +}) +class SynchronizedTicketStockServiceIntegrationTest { + @Autowired + private TicketStockService ticketStockService; + + @Autowired + private TicketStockRepository ticketStockRepository; + + private static final int THREAD_POOL_SIZE = 32; + + private final Set createdTicketStockIds = ConcurrentHashMap.newKeySet(); + + @AfterEach + void tearDown() { + if (!createdTicketStockIds.isEmpty()) { + ticketStockRepository.deleteAllByIdInBatch(createdTicketStockIds); + createdTicketStockIds.clear(); + } + } + + @DisplayName("100개 재고에 100개 요청 - 모두 성공 (synchronized)") + @Test + void decrease_100_stocks_with_100_requests_all_success() throws Exception { + // given + int initialQuantity = 100; + int threadCount = 100; + int requestQuantityPerThread = 1; + + TicketStock ticketStock = createTicketStock(initialQuantity); + + // when + executeConcurrentDecrease(ticketStock.getId(), threadCount, requestQuantityPerThread); + + // then + TicketStock result = findTicketStock(ticketStock.getId()); + assertThat(result.getRemainingQuantity()).isEqualTo(0); + } + + @DisplayName("100개 재고에 50명이 각각 2개씩 요청 - 모두 성공 (synchronized)") + @Test + void decrease_100_stocks_with_50_requests_of_2_quantity() throws Exception { + // given + int initialQuantity = 100; + int threadCount = 50; + int requestQuantityPerThread = 2; + + TicketStock ticketStock = createTicketStock(initialQuantity); + Long ticketStockId = ticketStock.getId(); + + // when + executeConcurrentDecrease(ticketStockId, threadCount, requestQuantityPerThread); + + // then + TicketStock result = findTicketStock(ticketStockId); + assertThat(result.getRemainingQuantity()).isEqualTo(0); + } + + @DisplayName("50개 재고에 100개 요청 - 50개만 성공") + @Test + void decrease_50_stocks_with_100_requests_only_50_success() throws Exception { + // given + int initialQuantity = 50; + int threadCount = 100; + int requestQuantityPerThread = 1; + + TicketStock ticketStock = createTicketStock(initialQuantity); + Long ticketStockId = ticketStock.getId(); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); + CountDownLatch latch = new CountDownLatch(threadCount); + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + ticketStockService.decrease(ticketStockId, requestQuantityPerThread); + successCount.incrementAndGet(); + } catch (InsufficientTicketStockException e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // then + TicketStock result = findTicketStock(ticketStock.getId()); + assertThat(result.getRemainingQuantity()).isEqualTo(0); + assertThat(successCount.get()).isEqualTo(50); + assertThat(failCount.get()).isEqualTo(50); + } + + private void executeConcurrentDecrease(Long ticketStockId, int threadCount, int requestQuantity) + throws InterruptedException { + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + ticketStockService.decrease(ticketStockId, requestQuantity); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + } + + private TicketStock createTicketStock(int quantity) { + TicketStock ticketStock = ticketStockRepository.save( + TicketStock.builder() + .totalQuantity(quantity) + .remainingQuantity(quantity) + .build() + ); + + createdTicketStockIds.add(ticketStock.getId()); + + return ticketStock; + } + + private TicketStock findTicketStock(Long id) { + return ticketStockRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("TicketStock not found")); + } +} \ No newline at end of file