diff --git a/src/main/java/com/tiketeer/Tiketeer/TiketeerApplication.java b/src/main/java/com/tiketeer/Tiketeer/TiketeerApplication.java index 54a0fd7..2825751 100644 --- a/src/main/java/com/tiketeer/Tiketeer/TiketeerApplication.java +++ b/src/main/java/com/tiketeer/Tiketeer/TiketeerApplication.java @@ -3,9 +3,7 @@ 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 { diff --git a/src/main/java/com/tiketeer/Tiketeer/domain/purchase/controller/PurchaseController.java b/src/main/java/com/tiketeer/Tiketeer/domain/purchase/controller/PurchaseController.java index 9cef699..fb80686 100644 --- a/src/main/java/com/tiketeer/Tiketeer/domain/purchase/controller/PurchaseController.java +++ b/src/main/java/com/tiketeer/Tiketeer/domain/purchase/controller/PurchaseController.java @@ -9,9 +9,11 @@ import org.springframework.web.bind.annotation.RestController; import com.tiketeer.Tiketeer.domain.purchase.controller.dto.PostPurchaseDLockRequestDto; +import com.tiketeer.Tiketeer.domain.purchase.controller.dto.PostPurchaseOLockRequestDto; import com.tiketeer.Tiketeer.domain.purchase.controller.dto.PostPurchasePLockRequestDto; import com.tiketeer.Tiketeer.domain.purchase.controller.dto.PostPurchaseResponseDto; import com.tiketeer.Tiketeer.domain.purchase.usecase.CreatePurchaseDLockUseCase; +import com.tiketeer.Tiketeer.domain.purchase.usecase.CreatePurchaseOLockUseCase; import com.tiketeer.Tiketeer.domain.purchase.usecase.CreatePurchasePLockUseCase; import com.tiketeer.Tiketeer.response.ApiResponse; @@ -23,12 +25,14 @@ public class PurchaseController { private final CreatePurchasePLockUseCase createPurchasePLockUseCase; private final CreatePurchaseDLockUseCase createPurchaseDLockUseCase; + private final CreatePurchaseOLockUseCase createPurchaseOLockUseCase; @Autowired PurchaseController(CreatePurchasePLockUseCase createPurchasePLockUseCase, - CreatePurchaseDLockUseCase createPurchaseDLockUseCase) { + CreatePurchaseDLockUseCase createPurchaseDLockUseCase, CreatePurchaseOLockUseCase createPurchaseOLockUseCase) { this.createPurchasePLockUseCase = createPurchasePLockUseCase; this.createPurchaseDLockUseCase = createPurchaseDLockUseCase; + this.createPurchaseOLockUseCase = createPurchaseOLockUseCase; } @PostMapping("/p-lock") @@ -46,4 +50,12 @@ public ResponseEntity> postPurchaseWithDLoc var responseBody = ApiResponse.wrap(PostPurchaseResponseDto.converFromDto(result)); return ResponseEntity.status(HttpStatus.CREATED).body(responseBody); } + + @PostMapping("/o-lock") + public ResponseEntity> postPurchaseWithOLock( + @Valid @RequestBody PostPurchaseOLockRequestDto request) { + var result = createPurchaseOLockUseCase.createPurchase(request.convertToDto()); + var responseBody = ApiResponse.wrap(PostPurchaseResponseDto.converFromDto(result)); + return ResponseEntity.status(HttpStatus.CREATED).body(responseBody); + } } \ No newline at end of file diff --git a/src/main/java/com/tiketeer/Tiketeer/domain/purchase/controller/dto/PostPurchaseOLockRequestDto.java b/src/main/java/com/tiketeer/Tiketeer/domain/purchase/controller/dto/PostPurchaseOLockRequestDto.java new file mode 100644 index 0000000..6eff254 --- /dev/null +++ b/src/main/java/com/tiketeer/Tiketeer/domain/purchase/controller/dto/PostPurchaseOLockRequestDto.java @@ -0,0 +1,49 @@ +package com.tiketeer.Tiketeer.domain.purchase.controller.dto; + +import java.util.UUID; + +import com.tiketeer.Tiketeer.domain.purchase.usecase.dto.CreatePurchaseOLockCommandDto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@ToString +@NoArgsConstructor(force = true) +public class PostPurchaseOLockRequestDto { + @NotNull + private final UUID ticketingId; + + @NotNull + private final Integer count; + @NotBlank + private final String email; + @NotNull + private final Long backoff; + @NotNull + private final Integer maxAttempts; + + @Builder + public PostPurchaseOLockRequestDto(@NotNull UUID ticketingId, + @NotNull Integer count, @NotBlank String email, @NotNull Long backoff, @NotNull Integer maxAttempts) { + this.ticketingId = ticketingId; + this.count = count; + this.email = email; + this.backoff = backoff; + this.maxAttempts = maxAttempts; + } + + public CreatePurchaseOLockCommandDto convertToDto() { + return CreatePurchaseOLockCommandDto.builder() + .memberEmail(email) + .ticketingId(ticketingId) + .count(count) + .backoff(backoff) + .maxAttempts(maxAttempts) + .build(); + } +} diff --git a/src/main/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseOLockUseCase.java b/src/main/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseOLockUseCase.java index 8ef6895..c0f6042 100644 --- a/src/main/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseOLockUseCase.java +++ b/src/main/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseOLockUseCase.java @@ -5,8 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Limit; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Retryable; +import org.springframework.retry.support.RetryTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,7 +15,7 @@ import com.tiketeer.Tiketeer.domain.purchase.exception.NotEnoughTicketException; import com.tiketeer.Tiketeer.domain.purchase.repository.PurchaseRepository; import com.tiketeer.Tiketeer.domain.purchase.service.PurchaseCrudService; -import com.tiketeer.Tiketeer.domain.purchase.usecase.dto.CreatePurchasePLockCommandDto; +import com.tiketeer.Tiketeer.domain.purchase.usecase.dto.CreatePurchaseOLockCommandDto; import com.tiketeer.Tiketeer.domain.purchase.usecase.dto.CreatePurchaseResultDto; import com.tiketeer.Tiketeer.domain.ticket.repository.TicketRepository; import com.tiketeer.Tiketeer.domain.ticketing.service.TicketingService; @@ -49,14 +48,11 @@ public CreatePurchaseOLockUseCase( } @Transactional - @Retryable( - retryFor = OptimisticLockingFailureException.class, - backoff = @Backoff(delay = 100), - maxAttempts = 100 - ) - public CreatePurchaseResultDto createPurchase(CreatePurchasePLockCommandDto command) { + public CreatePurchaseResultDto createPurchase(CreatePurchaseOLockCommandDto command) { var ticketingId = command.getTicketingId(); var count = command.getCount(); + var backoff = command.getBackoff(); + var maxAttempts = command.getMaxAttempts(); var member = memberCrudService.findByEmail(command.getMemberEmail()); @@ -66,7 +62,15 @@ public CreatePurchaseResultDto createPurchase(CreatePurchasePLockCommandDto comm var newPurchase = purchaseRepository.save(Purchase.builder().member(member).build()); - assignPurchaseToTicket(ticketingId, newPurchase.getId(), count); + RetryTemplate.builder() + .maxAttempts(maxAttempts) + .fixedBackoff(backoff) + .retryOn(OptimisticLockingFailureException.class) + .build() + .execute(context -> { + assignPurchaseToTicket(ticketingId, newPurchase.getId(), count); + return null; + }); return CreatePurchaseResultDto.builder() .purchaseId(newPurchase.getId()) diff --git a/src/main/java/com/tiketeer/Tiketeer/domain/purchase/usecase/dto/CreatePurchaseOLockCommandDto.java b/src/main/java/com/tiketeer/Tiketeer/domain/purchase/usecase/dto/CreatePurchaseOLockCommandDto.java new file mode 100644 index 0000000..15b74b8 --- /dev/null +++ b/src/main/java/com/tiketeer/Tiketeer/domain/purchase/usecase/dto/CreatePurchaseOLockCommandDto.java @@ -0,0 +1,33 @@ +package com.tiketeer.Tiketeer.domain.purchase.usecase.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class CreatePurchaseOLockCommandDto { + private final String memberEmail; + private final UUID ticketingId; + private final Integer count; + private final Long backoff; + private final Integer maxAttempts; + private LocalDateTime commandCreatedAt = LocalDateTime.now(); + + @Builder + public CreatePurchaseOLockCommandDto( + String memberEmail, UUID ticketingId, Integer count, Long backoff, Integer maxAttempts, + LocalDateTime commandCreatedAt) { + this.memberEmail = memberEmail; + this.ticketingId = ticketingId; + this.count = count; + this.backoff = backoff; + this.maxAttempts = maxAttempts; + if (commandCreatedAt != null) { + this.commandCreatedAt = commandCreatedAt; + } + } +} diff --git a/src/test/java/com/tiketeer/Tiketeer/configuration/EmbeddedRedisConfig.java b/src/test/java/com/tiketeer/Tiketeer/configuration/EmbeddedRedisConfig.java index 7550bd4..3d010af 100644 --- a/src/test/java/com/tiketeer/Tiketeer/configuration/EmbeddedRedisConfig.java +++ b/src/test/java/com/tiketeer/Tiketeer/configuration/EmbeddedRedisConfig.java @@ -34,9 +34,9 @@ public class EmbeddedRedisConfig { @PostConstruct public void startRedis() throws IOException { - this.redisServer = RedisServer.builder().port(port).setting("maxmemory " + maxmemorySize + "M").build(); + redisServer = RedisServer.builder().port(port).setting("maxmemory " + maxmemorySize + "M").build(); try { - this.redisServer.start(); + redisServer.start(); log.info("레디스 서버 시작 성공"); } catch (Exception e) { log.error("레디스 서버 시작 실패"); diff --git a/src/test/java/com/tiketeer/Tiketeer/domain/purchase/controller/PurchaseControllerTest.java b/src/test/java/com/tiketeer/Tiketeer/domain/purchase/controller/PurchaseControllerTest.java index 7a13f6b..d4f341e 100644 --- a/src/test/java/com/tiketeer/Tiketeer/domain/purchase/controller/PurchaseControllerTest.java +++ b/src/test/java/com/tiketeer/Tiketeer/domain/purchase/controller/PurchaseControllerTest.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.tiketeer.Tiketeer.domain.purchase.PurchaseTestHelper; import com.tiketeer.Tiketeer.domain.purchase.controller.dto.PostPurchaseDLockRequestDto; +import com.tiketeer.Tiketeer.domain.purchase.controller.dto.PostPurchaseOLockRequestDto; import com.tiketeer.Tiketeer.domain.purchase.controller.dto.PostPurchasePLockRequestDto; import com.tiketeer.Tiketeer.domain.purchase.controller.dto.PostPurchaseResponseDto; import com.tiketeer.Tiketeer.domain.purchase.repository.PurchaseRepository; @@ -130,4 +131,43 @@ void postPurchaseWithDLockSuccess() throws Exception { }); } + @Test + @DisplayName("정상 컨디션 > 낙관적 락 구매 생성 요청 > 성공") + void postPurchaseWithOLockSuccess() throws Exception { + // given + var email = "test@test.com"; + var seller = testHelper.createMember(email); + var ticketing = ticketingTestHelper.createTicketing(seller.getId(), 0, 1000, 5); + + var buyerEmail = "test2@test.com"; + testHelper.createMember(buyerEmail, 100000); + var buyCnt = 2; + var req = PostPurchaseOLockRequestDto.builder() + .ticketingId(ticketing.getId()) + .email(buyerEmail) + .count(buyCnt) + .backoff(100L) + .maxAttempts(100) + .build(); + + // when + mockMvc.perform( + post("/api/purchases/o-lock") + .contextPath("/api") + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .content(objectMapper.writeValueAsString(req)) + ).andExpect(status().isCreated()) + .andDo(response -> { + var result = testHelper.getDeserializedApiResponse(response.getResponse().getContentAsString(), + PostPurchaseResponseDto.class).getData(); + + // then + Assertions.assertThat(result.getPurchaseId()).isNotNull(); + var purchase = purchaseRepository.findById(result.getPurchaseId()); + Assertions.assertThat(purchase.isPresent()).isTrue(); + Assertions.assertThat(ticketRepository.findAllByPurchase(purchase.get()).size()).isEqualTo(buyCnt); + }); + } + } diff --git a/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseDLockUseCaseTest.java b/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseDLockUseCaseTest.java index 5077701..0fb9c15 100644 --- a/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseDLockUseCaseTest.java +++ b/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseDLockUseCaseTest.java @@ -53,7 +53,7 @@ void cleanTable() { void createPurchaseWithConcurrency() throws InterruptedException { //given var ticketStock = 10; - var seller = testHelper.createMember("seller@etest.com"); + var seller = testHelper.createMember("seller@test.com"); var ticketing = createPurchaseConcurrencyTest.createTicketing(seller, ticketStock); int threadNums = 20; diff --git a/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseOLockUseCaseTest.java b/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseOLockUseCaseTest.java index d24eecc..d06ffa3 100644 --- a/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseOLockUseCaseTest.java +++ b/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseOLockUseCaseTest.java @@ -15,7 +15,7 @@ import org.springframework.context.annotation.Import; import com.tiketeer.Tiketeer.domain.member.repository.MemberRepository; -import com.tiketeer.Tiketeer.domain.purchase.usecase.dto.CreatePurchasePLockCommandDto; +import com.tiketeer.Tiketeer.domain.purchase.usecase.dto.CreatePurchaseOLockCommandDto; import com.tiketeer.Tiketeer.domain.ticket.repository.TicketRepository; import com.tiketeer.Tiketeer.testhelper.TestHelper; import com.tiketeer.Tiketeer.testhelper.Transaction; @@ -51,18 +51,21 @@ void cleanTable() { @DisplayName("10개의 티켓 생성 > 20명의 구매자가 경쟁 > 10명 구매 성공, 10명 구매 실패") void createPurchaseWithConcurrency() throws InterruptedException { //given - var seller = testHelper.createMember("seller@etest.com"); - var ticketing = createPurchaseConcurrencyTest.createTicketing(seller, 10); + var ticketStock = 10; + var seller = testHelper.createMember("seller@test.com"); + var ticketing = createPurchaseConcurrencyTest.createTicketing(seller, ticketStock); int threadNums = 20; var buyers = createPurchaseConcurrencyTest.createBuyers(threadNums); createPurchaseConcurrencyTest.makeConcurrency(threadNums, buyers, ticketing, (email) -> createPurchaseOLockUseCase.createPurchase( - CreatePurchasePLockCommandDto.builder() + CreatePurchaseOLockCommandDto.builder() .ticketingId(ticketing.getId()) .memberEmail(email) .count(1) + .backoff(300L) + .maxAttempts(100) .build())); //then @@ -72,7 +75,10 @@ void createPurchaseWithConcurrency() throws InterruptedException { var allMembers = memberRepository.findAll(); //assert all ticket owners are unique - var ticketOwnerIdList = tickets + var purchasedTickets = ticketRepository.findAllByPurchaseIsNotNull(); + assertThat(purchasedTickets.size()).isEqualTo(ticketStock); + + var ticketOwnerIdList = purchasedTickets .stream() .map(ticket -> ticket.getPurchase().getMember().getId()).toList(); @@ -84,7 +90,7 @@ void createPurchaseWithConcurrency() throws InterruptedException { .filter(member -> member.getPurchases().size() == 1) .toList(); - assertThat(ticketingSuccessMembers.size()).isEqualTo(10); + assertThat(ticketingSuccessMembers.size()).isEqualTo(ticketStock); return null; }); } diff --git a/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchasePLockUseCaseTest.java b/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchasePLockUseCaseTest.java index 248d087..d5c7747 100644 --- a/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchasePLockUseCaseTest.java +++ b/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchasePLockUseCaseTest.java @@ -52,7 +52,7 @@ void cleanTable() { void createPurchaseWithConcurrency() throws InterruptedException { //given var ticketStock = 10; - var seller = testHelper.createMember("seller@etest.com"); + var seller = testHelper.createMember("seller@test.com"); var ticketing = createPurchaseConcurrencyTest.createTicketing(seller, ticketStock); int threadNums = 20; diff --git a/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseUseCaseTest.java b/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseUseCaseTest.java index bf8413f..86adb75 100644 --- a/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseUseCaseTest.java +++ b/src/test/java/com/tiketeer/Tiketeer/domain/purchase/usecase/CreatePurchaseUseCaseTest.java @@ -29,7 +29,7 @@ public class CreatePurchaseUseCaseTest { @Autowired private TicketingTestHelper ticketingTestHelper; @Autowired - private CreatePurchaseOLockUseCase createPurchaseUseCase; + private CreatePurchasePLockUseCase createPurchaseUseCase; @Autowired private TicketRepository ticketRepository; @Autowired