Skip to content

Commit

Permalink
update with develop
Browse files Browse the repository at this point in the history
  • Loading branch information
claycat committed Apr 10, 2024
2 parents 630d03c + a4a646f commit aa45a32
Show file tree
Hide file tree
Showing 8 changed files with 232 additions and 56 deletions.
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ dependencies {
runtimeOnly "io.jsonwebtoken:jjwt-gson:${JJWT_VERSION}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${JJWT_VERSION}"
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation("org.springframework.retry:spring-retry")
implementation("org.springframework:spring-aspects")

// ETC
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'

Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/tiketeer/Tiketeer/TiketeerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.retry.annotation.EnableRetry;

@EnableRetry
@SpringBootApplication
@EnableJpaAuditing
public class TiketeerApplication {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.tiketeer.Tiketeer.configuration;

import com.tiketeer.Tiketeer.domain.ticket.service.concurrency.TicketOptimisticLockConcurrencyService;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
Expand Down Expand Up @@ -37,8 +38,7 @@ public TicketConcurrencyService ticketConcurrencyService(TicketRepository ticket

return switch (concurrencyPolicy) {
case OPTIMISTIC_LOCK ->
// TODO - TicketConcurrencyWithOptimisticLockService로 바꾸기
new TicketPessimisticLockConcurrencyService(ticketRepository, purchaseCrudService);
new TicketOptimisticLockConcurrencyService(ticketRepository, purchaseCrudService);
case PESSIMISTIC_LOCK ->
new TicketPessimisticLockConcurrencyService(ticketRepository, purchaseCrudService);
case DISTRIBUTED_LOCK -> new TicketNonConcurrencyService(ticketRepository, purchaseCrudService);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,47 @@

import java.util.UUID;

import jakarta.persistence.PersistenceContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Service;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;

import com.tiketeer.Tiketeer.domain.purchase.exception.NotEnoughTicketException;
import com.tiketeer.Tiketeer.domain.purchase.service.PurchaseCrudService;
import com.tiketeer.Tiketeer.domain.ticket.repository.TicketRepository;

import jakarta.persistence.OptimisticLockException;
import lombok.extern.slf4j.Slf4j;

@Service
@Slf4j
public class TicketOptimisticLockConcurrencyService implements TicketConcurrencyService {

private final TicketRepository ticketRepository;
private final PurchaseCrudService purchaseCrudService;

@Autowired
public TicketOptimisticLockConcurrencyService(TicketRepository ticketRepository,
PurchaseCrudService purchaseCrudService) {
this.ticketRepository = ticketRepository;
this.purchaseCrudService = purchaseCrudService;
}

@Override
public void assignPurchaseToTicket(UUID ticketingId, UUID purchaseId, int ticketCount) {
var MAX_RETRIES = 100;
var retry = 0;
while (retry++ < MAX_RETRIES) {
try {
var purchase = purchaseCrudService.findById(purchaseId);
var tickets = ticketRepository.findByTicketingIdAndPurchaseIsNullOrderByIdWithOptimisticLock(
ticketingId, Limit.of(ticketCount));

if (tickets.size() < ticketCount) {
throw new NotEnoughTicketException();
}

tickets.forEach(ticket -> {
ticket.setPurchase(purchase);
});
ticketRepository.flush();
break;
} catch (OptimisticLockingFailureException e) {
log.info("Retrying with optimistic lock...");
System.out.println("Retrying with optimistic lock...");
}
@Retryable(
retryFor = OptimisticLockingFailureException.class,
backoff = @Backoff(delay = 100),
maxAttempts = 100
)
public void assignPurchaseToTicket(UUID ticketingId, UUID purchaseId, int ticketCount) throws
ConcurrencyFailureException {
var purchase = purchaseCrudService.findById(purchaseId);
var tickets = ticketRepository.findByTicketingIdAndPurchaseIsNullOrderByIdWithOptimisticLock(
ticketingId, Limit.of(ticketCount));

if (tickets.size() < ticketCount) {
throw new NotEnoughTicketException();
}

tickets.forEach(ticket -> {
ticket.setPurchase(purchase);
});
ticketRepository.flush();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.tiketeer.Tiketeer.domain.purchase.usecase;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
Expand All @@ -10,12 +12,22 @@

import com.tiketeer.Tiketeer.domain.member.Member;
import com.tiketeer.Tiketeer.domain.purchase.usecase.dto.CreatePurchaseCommandDto;
import com.tiketeer.Tiketeer.domain.role.constant.RoleEnum;
import com.tiketeer.Tiketeer.domain.ticketing.Ticketing;
import com.tiketeer.Tiketeer.domain.ticketing.repository.TicketingRepository;
import com.tiketeer.Tiketeer.domain.ticketing.service.TicketingStockService;
import com.tiketeer.Tiketeer.testhelper.TestHelper;

@TestComponent
public class CreatePurchaseConcurrencyTest {
@Autowired
private CreatePurchaseUseCase createPurchaseUseCase;
@Autowired
private TicketingRepository ticketingRepository;
@Autowired
private TicketingStockService ticketingStockService;
@Autowired
private TestHelper testHelper;

public void makeConcurrency(int threadNums, List<Member> buyers, Ticketing ticketing) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(threadNums);
Expand All @@ -42,4 +54,31 @@ public void makeConcurrency(int threadNums, List<Member> buyers, Ticketing ticke
startLatch.countDown();
endLatch.await();
}

public Ticketing createTicketing(Member member, int stock) {
var now = LocalDateTime.now();
var ticketing = ticketingRepository.save(Ticketing.builder()
.member(member)
.title("제목")
.price(10000)
.location("서울")
.category("바자회")
.runningMinutes(100)
.saleStart(now.minusMonths(6))
.saleEnd(now.plusMonths(6))
.eventTime(now.plusYears(1))
.build());
ticketingStockService.createStock(ticketing.getId(), stock);
return ticketing;
}

public List<Member> createBuyers(int buyerNum) {
List<Member> buyers = new ArrayList<>();
for (int i = 0; i < buyerNum; i++) {
var memberEmail = "buyer" + i + "@test.com";
Member buyer = testHelper.createMember(memberEmail, 10000, RoleEnum.BUYER);
buyers.add(buyer);
}
return buyers;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.tiketeer.Tiketeer.domain.purchase.usecase;

import static org.assertj.core.api.Assertions.*;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;

import com.tiketeer.Tiketeer.domain.member.Member;
import com.tiketeer.Tiketeer.domain.member.repository.MemberRepository;
import com.tiketeer.Tiketeer.domain.purchase.service.PurchaseCrudService;
import com.tiketeer.Tiketeer.domain.role.constant.RoleEnum;
import com.tiketeer.Tiketeer.domain.ticket.repository.TicketRepository;
import com.tiketeer.Tiketeer.domain.ticket.service.concurrency.TicketConcurrencyService;
import com.tiketeer.Tiketeer.domain.ticket.service.concurrency.TicketOptimisticLockConcurrencyService;
import com.tiketeer.Tiketeer.domain.ticketing.Ticketing;
import com.tiketeer.Tiketeer.domain.ticketing.repository.TicketingRepository;
import com.tiketeer.Tiketeer.domain.ticketing.service.TicketingStockService;
import com.tiketeer.Tiketeer.testhelper.TestHelper;
import com.tiketeer.Tiketeer.testhelper.Transaction;

@Import({TestHelper.class, CreatePurchaseConcurrencyTest.class, Transaction.class})
@SpringBootTest
class CreatePurchaseUseCaseOptimisticLockConcurrencyTest {

@Autowired
private TestHelper testHelper;
@Autowired
private TicketRepository ticketRepository;
@Autowired
private TicketingRepository ticketingRepository;
@Autowired
private TicketingStockService ticketingStockService;
@Autowired
private MemberRepository memberRepository;
@Autowired
private CreatePurchaseConcurrencyTest createPurchaseConcurrencyTest;
@Autowired
private Transaction transaction;

@BeforeEach
void initTable() {
testHelper.initDB();
}

@AfterEach
void cleanTable() {
testHelper.cleanDB();
}

@Test
@DisplayName("20개의 티켓 생성 > 40명의 구매자가 경쟁 > 20명 구매 성공, 20명 구매 실패")
void createPurchaseWithConcurrency() throws InterruptedException {
//given
var ticketStock = 20;
var seller = testHelper.createMember("[email protected]", RoleEnum.SELLER);
var ticketing = createTicketing(seller, ticketStock);

int threadNums = 40;
var buyers = createBuyers(threadNums);

createPurchaseConcurrencyTest.makeConcurrency(threadNums, buyers, ticketing);

//then
transaction.invoke(() -> {
var tickets = ticketRepository.findAllByPurchase(null);
assertThat(tickets.size()).isEqualTo(0);

var allMembers = memberRepository.findAll();

//assert all ticket owners are unique
var purchasedTickets = ticketRepository.findAllByPurchaseIsNotNull();
assertThat(purchasedTickets.size()).isEqualTo(ticketStock);

var ticketOwnerIdList = purchasedTickets
.stream()
.map(ticket -> ticket.getPurchase().getMember().getId()).toList();

Set<UUID> ticketOwnerIdSet = new HashSet<>(ticketOwnerIdList);
assertThat(ticketOwnerIdSet.size()).isEqualTo(ticketOwnerIdList.size());

//assert one purchase per member
var ticketingSuccessMembers = allMembers.stream()
.filter(member -> member.getPurchases().size() == 1)
.toList();

assertThat(ticketingSuccessMembers.size()).isEqualTo(ticketStock);
return null;
});
}

private Ticketing createTicketing(Member member, int stock) {
var now = LocalDateTime.now();
var ticketing = ticketingRepository.save(Ticketing.builder()
.member(member)
.title("제목")
.price(10000)
.location("서울")
.category("바자회")
.runningMinutes(100)
.saleStart(now.minusMonths(6))
.saleEnd(now.plusMonths(6))
.eventTime(now.plusYears(1))
.build());
ticketingStockService.createStock(ticketing.getId(), stock);
return ticketing;
}

private List<Member> createBuyers(int buyerNum) {
List<Member> buyers = new ArrayList<>();
for (int i = 0; i < buyerNum; i++) {
var memberEmail = "buyer" + i + "@test.com";
Member buyer = testHelper.createMember(memberEmail, 10000, RoleEnum.BUYER);
buyers.add(buyer);
}
return buyers;
}

@TestConfiguration
static class TestConfig {
@Bean
public TicketConcurrencyService ticketConcurrencyService(
TicketRepository ticketRepository,
PurchaseCrudService purchaseCrudService) {
return new TicketOptimisticLockConcurrencyService(ticketRepository, purchaseCrudService);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,16 @@ void createPurchaseWithConcurrency() throws InterruptedException {
assertThat(purchasedTickets.size()).isEqualTo(ticketStock);

var ticketOwnerIdList = purchasedTickets
.stream()
.map(ticket -> ticket.getPurchase().getMember().getId()).toList();
.stream()
.map(ticket -> ticket.getPurchase().getMember().getId()).toList();

Set<UUID> ticketOwnerIdSet = new HashSet<>(ticketOwnerIdList);
assertThat(ticketOwnerIdSet.size()).isEqualTo(ticketOwnerIdList.size());

//assert one purchase per member
var ticketingSuccessMembers = allMembers.stream()
.filter(member -> member.getPurchases().size() == 1)
.toList();
.filter(member -> member.getPurchases().size() == 1)
.toList();

assertThat(ticketingSuccessMembers.size()).isEqualTo(ticketStock);
return null;
Expand All @@ -105,16 +105,16 @@ void createPurchaseWithConcurrency() throws InterruptedException {
private Ticketing createTicketing(Member member, int stock) {
var now = LocalDateTime.now();
var ticketing = ticketingRepository.save(Ticketing.builder()
.member(member)
.title("제목")
.price(10000)
.location("서울")
.category("바자회")
.runningMinutes(100)
.saleStart(now.minusMonths(6))
.saleEnd(now.plusMonths(6))
.eventTime(now.plusYears(1))
.build());
.member(member)
.title("제목")
.price(10000)
.location("서울")
.category("바자회")
.runningMinutes(100)
.saleStart(now.minusMonths(6))
.saleEnd(now.plusMonths(6))
.eventTime(now.plusYears(1))
.build());
ticketingStockService.createStock(ticketing.getId(), stock);
return ticketing;
}
Expand All @@ -133,8 +133,8 @@ private List<Member> createBuyers(int buyerNum) {
static class TestConfig {
@Bean
public TicketConcurrencyService ticketConcurrencyService(
TicketRepository ticketRepository,
PurchaseCrudService purchaseCrudService) {
TicketRepository ticketRepository,
PurchaseCrudService purchaseCrudService) {
return new TicketPessimisticLockConcurrencyService(ticketRepository, purchaseCrudService);
}
}
Expand Down
Loading

0 comments on commit aa45a32

Please sign in to comment.