Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
20 changes: 20 additions & 0 deletions docker/docker-compose-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
9 changes: 9 additions & 0 deletions docker/monitoring/prometheus/prometheus.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
12 changes: 11 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
spring:
application:
name: ticket-service
name: ticket-service

management:
endpoints:
web:
exposure:
include: health,metrics,prometheus

ticket:
lock:
strategy: pessimistic
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -29,9 +32,14 @@ class TicketStockServiceIntegrationTest {

private static final int threadPoolSize = 32;

private final Set<Long> createdTicketStockIds = ConcurrentHashMap.newKeySet();

@AfterEach
void tearDown() {
ticketStockRepository.deleteAllInBatch();
if (!createdTicketStockIds.isEmpty()) {
ticketStockRepository.deleteAllByIdInBatch(createdTicketStockIds);
createdTicketStockIds.clear();
}
}

@DisplayName("100개 재고에 100개 요청 - 모두 성공")
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down
Loading