diff --git a/README.md b/README.md index 7e0414c..5ef04cc 100644 --- a/README.md +++ b/README.md @@ -18,18 +18,4 @@ Special Thanks to : Pir - 구매 또는 판매한 상품 조회 - 전체 판매되고있는 상품 조회 - 상품 구매 - 이미 동일한 거래 시간에 상품 구매 예정이 되어있다면 다른 상품은 구매가 불가 -- 상품 판매 - 상품을 등록, 수정, 삭제 할 수 있음 - - -# 2주차 ---- - -## 단독) 한국에 드디어 상륙한 캣츠! 티켓 1000장을 잡아라! ( 3.10 ~ 3.22 ) [ Feat. 김의빈 ] - -### 요구사항 -- 하나의 예매 서비스에 다량의 유저들이 접속한다는걸 가정합니다. - - 유저들은 접속하는 환경이 다를겁니다 ( ex. 모바일 , 컴퓨터 ) 하나의 서버가 아닌 여러 서버를 가정합니다. -- 뮤지컬의 경우 (날짜는 무관) 3일동안 1000장의 티켓이 발행됩니다. -- 1000장의 수량이 떨어질 경우 1001번째 사람은 티켓을 구매할 수 없습니다. -- 티켓을 이미 구매한 사람은 사제기의 요소를 방지하기 위해 더이상의 티켓을 구매할 순 없습니다. - - 티켓을 구매했을 경우 당일 뿐만이 아닌 다른날도 구매가 불가합니다. +- 상품 판매 - 상품을 등록, 수정, 삭제 할 수 있음 \ No newline at end of file diff --git a/build.gradle b/build.gradle index 2eb8618..7c43456 100644 --- a/build.gradle +++ b/build.gradle @@ -38,11 +38,18 @@ dependencies { // 쿼리 파라미터 로그 implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.5.8' + //Redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.17.4' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + + //test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' } tasks.named('test') { diff --git a/src/main/java/com/week/zumgnmarket/ZumgnmarketApplication.java b/src/main/java/com/week/zumgnmarket/WeeklyStudyProject.java similarity index 68% rename from src/main/java/com/week/zumgnmarket/ZumgnmarketApplication.java rename to src/main/java/com/week/zumgnmarket/WeeklyStudyProject.java index 87f7f1c..1ac5271 100644 --- a/src/main/java/com/week/zumgnmarket/ZumgnmarketApplication.java +++ b/src/main/java/com/week/zumgnmarket/WeeklyStudyProject.java @@ -4,10 +4,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class ZumgnmarketApplication { +public class WeeklyStudyProject { public static void main(String[] args) { - SpringApplication.run(ZumgnmarketApplication.class, args); + SpringApplication.run(WeeklyStudyProject.class, args); } } diff --git a/src/main/java/com/week/zumgnmarket/common/config/JpaConfig.java b/src/main/java/com/week/zumgnmarket/common/config/JpaConfig.java new file mode 100644 index 0000000..dc1ca51 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/common/config/JpaConfig.java @@ -0,0 +1,9 @@ +package com.week.zumgnmarket.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@Configuration +public class JpaConfig { +} diff --git a/src/main/java/com/week/zumgnmarket/common/config/QuerydslConfig.java b/src/main/java/com/week/zumgnmarket/common/config/QuerydslConfig.java new file mode 100644 index 0000000..17ebfab --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/common/config/QuerydslConfig.java @@ -0,0 +1,20 @@ +package com.week.zumgnmarket.common.config; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +@Configuration +public class QuerydslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} \ No newline at end of file diff --git a/src/main/java/com/week/zumgnmarket/common/domain/BaseEntity.java b/src/main/java/com/week/zumgnmarket/common/domain/BaseEntity.java new file mode 100644 index 0000000..1a10e52 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/common/domain/BaseEntity.java @@ -0,0 +1,23 @@ +package com.week.zumgnmarket.common.domain; +import java.time.LocalDateTime; + +import javax.persistence.EntityListeners; +import javax.persistence.MappedSuperclass; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + @CreatedDate + private LocalDateTime createdDate; + + @LastModifiedDate + private LocalDateTime updatedDate; + +} \ No newline at end of file diff --git a/src/main/java/com/week/zumgnmarket/order/controller/OrderController.java b/src/main/java/com/week/zumgnmarket/order/controller/OrderController.java new file mode 100644 index 0000000..b11638a --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/controller/OrderController.java @@ -0,0 +1,26 @@ +package com.week.zumgnmarket.order.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.week.zumgnmarket.order.dto.OrderRequest; +import com.week.zumgnmarket.order.dto.OrderResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/orders") +public class OrderController { + + private final OrderFacade orderFacade; + + @PostMapping("/tickets") + public ResponseEntity orderTickets(@RequestBody OrderRequest request) { + return ResponseEntity.ok(orderFacade.order(request)); + } + +} diff --git a/src/main/java/com/week/zumgnmarket/order/controller/OrderFacade.java b/src/main/java/com/week/zumgnmarket/order/controller/OrderFacade.java new file mode 100644 index 0000000..3522460 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/controller/OrderFacade.java @@ -0,0 +1,79 @@ +package com.week.zumgnmarket.order.controller; + +import java.util.concurrent.TimeUnit; + +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.week.zumgnmarket.order.dto.OrderRequest; +import com.week.zumgnmarket.order.dto.OrderResponse; +import com.week.zumgnmarket.order.entity.Order; +import com.week.zumgnmarket.order.service.OrderService; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.ticket.service.TicketService; +import com.week.zumgnmarket.user.entity.User; +import com.week.zumgnmarket.user.service.UserService; + +import lombok.RequiredArgsConstructor; + +@Component +@Transactional +@RequiredArgsConstructor +public class OrderFacade { + + private final UserService userService; + private final TicketService ticketService; + private final OrderService orderService; + private final RedissonClient redissonClient; + + public OrderResponse order(OrderRequest request) { + User user = userService.getUser(request.getUserId()); + Ticket ticket = ticketService.getTicket(request.getTicketId()); + Order order = orderService.orderTicket(user, ticket, request.getQuantity()); + + return OrderResponse.of(order, user, ticket); + } + + public OrderResponse orderWithPessimisticLock(OrderRequest request) { + User user = userService.getUser(request.getUserId()); + Ticket ticket = ticketService.getTicketWithPessimisticLock(request.getTicketId()); + Order order = orderService.orderTicket(user, ticket, request.getQuantity()); + + return OrderResponse.of(order, user, ticket); + } + + public OrderResponse orderWithOptimisticLock(OrderRequest request) throws InterruptedException { + User user = userService.getUser(request.getUserId()); + + //OptimisticLock 같은 경우 실패했을 때 재시도를 위해 while 을 사용하였습니다. + while (true) { + try { + Ticket ticket = ticketService.getTicketWithOptimisticLock(request.getTicketId()); + Order order = orderService.orderTicket(user, ticket, request.getQuantity()); + return OrderResponse.of(order, user, ticket); + } catch (Exception e) { + Thread.sleep(10); + } + } + } + + public OrderResponse orderWithRedisson(OrderRequest request) { + User user = userService.getUser(request.getUserId()); + RLock lock = redissonClient.getLock(request.getTicketId().toString()); + + try { + if (!lock.tryLock(2, 1, TimeUnit.SECONDS)) { + return new OrderResponse(); + } + Ticket ticket = ticketService.getTicket(request.getTicketId()); + Order order = orderService.orderTicket(user, ticket, request.getQuantity()); + return OrderResponse.of(order, user, ticket); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + lock.unlock(); + } + } +} diff --git a/src/main/java/com/week/zumgnmarket/order/dto/OrderRequest.java b/src/main/java/com/week/zumgnmarket/order/dto/OrderRequest.java new file mode 100644 index 0000000..0089a68 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/dto/OrderRequest.java @@ -0,0 +1,16 @@ +package com.week.zumgnmarket.order.dto; + +import lombok.Getter; + +@Getter +public class OrderRequest { + public Long userId; + public Long ticketId; + public int quantity; + + public OrderRequest(Long userId, Long ticketId, int quantity) { + this.userId = userId; + this.ticketId = ticketId; + this.quantity = quantity; + } +} diff --git a/src/main/java/com/week/zumgnmarket/order/dto/OrderResponse.java b/src/main/java/com/week/zumgnmarket/order/dto/OrderResponse.java new file mode 100644 index 0000000..4b1e9e6 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/dto/OrderResponse.java @@ -0,0 +1,35 @@ +package com.week.zumgnmarket.order.dto; + +import com.week.zumgnmarket.order.entity.Order; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.user.entity.User; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class OrderResponse { + public Long orderId; + public Long userId; + public Long ticketId; + public int quantity; + + @Builder + public OrderResponse(Long orderId, Long userId, Long ticketId, int quantity) { + this.orderId = orderId; + this.userId = userId; + this.ticketId = ticketId; + this.quantity = quantity; + } + + public static OrderResponse of(Order order, User user, Ticket ticket) { + return OrderResponse.builder() + .orderId(order.getId()) + .userId(user.getId()) + .ticketId(ticket.getId()) + .quantity(order.getTicketCount()) + .build(); + } +} diff --git a/src/main/java/com/week/zumgnmarket/order/entity/Order.java b/src/main/java/com/week/zumgnmarket/order/entity/Order.java new file mode 100644 index 0000000..88924eb --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/entity/Order.java @@ -0,0 +1,58 @@ +package com.week.zumgnmarket.order.entity; + +import static javax.persistence.FetchType.*; + +import javax.persistence.Embedded; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; + +import com.week.zumgnmarket.common.domain.BaseEntity; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.user.entity.User; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Entity +@Getter +@ToString +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "orders") +public class Order extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "ticket_id") + private Ticket ticket; + + private int ticketCount; + + @Builder + private Order(User user, Ticket ticket, int ticketCount) { + this.user = user; + this.ticket = ticket; + this.ticketCount = ticketCount; + } + + public static Order of(User user, Ticket ticket, int ticketCount) { + return Order.builder() + .user(user) + .ticket(ticket) + .ticketCount(ticketCount) + .build(); + } +} diff --git a/src/main/java/com/week/zumgnmarket/order/entity/OrderRepository.java b/src/main/java/com/week/zumgnmarket/order/entity/OrderRepository.java new file mode 100644 index 0000000..c049604 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/entity/OrderRepository.java @@ -0,0 +1,6 @@ +package com.week.zumgnmarket.order.entity; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderRepository extends JpaRepository { +} diff --git a/src/main/java/com/week/zumgnmarket/order/entity/Orders.java b/src/main/java/com/week/zumgnmarket/order/entity/Orders.java new file mode 100644 index 0000000..d501a89 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/entity/Orders.java @@ -0,0 +1,18 @@ +package com.week.zumgnmarket.order.entity; + +import java.util.ArrayList; +import java.util.List; + +import javax.persistence.CascadeType; +import javax.persistence.Embeddable; +import javax.persistence.OneToMany; + +import lombok.Getter; + +@Getter +@Embeddable +public class Orders { + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + List orders = new ArrayList<>(); + +} diff --git a/src/main/java/com/week/zumgnmarket/order/service/OptimisticLockOrderService.java b/src/main/java/com/week/zumgnmarket/order/service/OptimisticLockOrderService.java new file mode 100644 index 0000000..7d8f856 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/service/OptimisticLockOrderService.java @@ -0,0 +1,27 @@ +package com.week.zumgnmarket.order.service; + +import org.springframework.stereotype.Service; + +import com.week.zumgnmarket.order.entity.Order; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.user.entity.User; + +import lombok.RequiredArgsConstructor; + +/** + * Optimistic Lock + * Lock 이 아니라 Version 을 통해 정합성을 맞추는 방법. + * 처음에 현재 데이터의 version 을 읽고, + * 이후 update 하는 시점에서 처음 읽은 버전과 동일한지 확인 후 update 수행. + * */ + +@Service +@RequiredArgsConstructor +public class OptimisticLockOrderService implements OrderService{ + + @Override + public Order orderTicket(User user, Ticket ticket, int quantity) { + ticket.decreaseQuantity(quantity); + return Order.of(user, ticket, quantity); + } +} diff --git a/src/main/java/com/week/zumgnmarket/order/service/OrderService.java b/src/main/java/com/week/zumgnmarket/order/service/OrderService.java new file mode 100644 index 0000000..e2b16d2 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/service/OrderService.java @@ -0,0 +1,9 @@ +package com.week.zumgnmarket.order.service; + +import com.week.zumgnmarket.order.entity.Order; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.user.entity.User; + +public interface OrderService { + Order orderTicket(User user, Ticket ticket, int quantity); +} diff --git a/src/main/java/com/week/zumgnmarket/order/service/OrderServiceImpl.java b/src/main/java/com/week/zumgnmarket/order/service/OrderServiceImpl.java new file mode 100644 index 0000000..5106dba --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/service/OrderServiceImpl.java @@ -0,0 +1,27 @@ +package com.week.zumgnmarket.order.service; + +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.week.zumgnmarket.order.entity.Order; +import com.week.zumgnmarket.order.entity.OrderRepository; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.user.entity.User; + +import lombok.RequiredArgsConstructor; + +@Primary +@Service +@Transactional +@RequiredArgsConstructor +public class OrderServiceImpl implements OrderService { + + private final OrderRepository orderRepository; + + @Override + public Order orderTicket(User user, Ticket ticket, int quantity) { + ticket.decreaseQuantity(quantity); + return orderRepository.save(Order.of(user, ticket, quantity)); + } +} diff --git a/src/main/java/com/week/zumgnmarket/order/service/PessimisticLockOrderService.java b/src/main/java/com/week/zumgnmarket/order/service/PessimisticLockOrderService.java new file mode 100644 index 0000000..b2b5787 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/service/PessimisticLockOrderService.java @@ -0,0 +1,32 @@ +package com.week.zumgnmarket.order.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.week.zumgnmarket.order.entity.Order; +import com.week.zumgnmarket.order.entity.OrderRepository; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.user.entity.User; + +import lombok.RequiredArgsConstructor; + +/** + * Pessimistic Lock + * 실제로 데이터에 Lock 을 걸어서 정합성을 맞추는 방법. + * Lock 이 해제되기 전에는 다른 트랜잭션에서 데이터를 가져갈 수 없다. + * */ + +@Service +@Transactional +@RequiredArgsConstructor +public class PessimisticLockOrderService implements OrderService { + + private final OrderRepository orderRepository; + + @Override + public Order orderTicket(User user, Ticket ticket, int quantity) { + ticket.decreaseQuantity(quantity); + return orderRepository.save(Order.of(user, ticket, quantity)); + } + +} diff --git a/src/main/java/com/week/zumgnmarket/order/service/SynchronizedOrderService.java b/src/main/java/com/week/zumgnmarket/order/service/SynchronizedOrderService.java new file mode 100644 index 0000000..e00c2a1 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/order/service/SynchronizedOrderService.java @@ -0,0 +1,30 @@ +package com.week.zumgnmarket.order.service; + +import org.springframework.stereotype.Service; + +import com.week.zumgnmarket.order.entity.Order; +import com.week.zumgnmarket.order.entity.OrderRepository; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.user.entity.User; + +import lombok.RequiredArgsConstructor; + +/** + * synchronized + * 문제) 서버가 한 대일 경우 문제가 없지만 + * synchronized 는 각 프로세스 안에서만 보장이 되기 때문에 + * 여러 서버 스레드에서 접근을 하게 된다면 race condition 이 발생할 수 있다. + * */ + +@Service +@RequiredArgsConstructor +public class SynchronizedOrderService implements OrderService { + + private final OrderRepository orderRepository; + + @Override + public synchronized Order orderTicket(User user, Ticket ticket, int quantity) { + ticket.decreaseQuantity(quantity); + return orderRepository.save(Order.of(user, ticket, quantity)); + } +} \ No newline at end of file diff --git a/src/main/java/com/week/zumgnmarket/ticket/entity/Ticket.java b/src/main/java/com/week/zumgnmarket/ticket/entity/Ticket.java new file mode 100644 index 0000000..5a03eda --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/ticket/entity/Ticket.java @@ -0,0 +1,56 @@ +package com.week.zumgnmarket.ticket.entity; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Version; + +import com.week.zumgnmarket.common.domain.BaseEntity; +import com.week.zumgnmarket.ticket.exception.NotEnoughTicketException; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +@Table(name = "tickets") +public class Ticket extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private int price; + + private int quantity; + + //Optimistic Lock Version + @Version + private Long version; + + @Builder + public Ticket(String name, int price, int quantity) { + this.name = name; + this.price = price; + this.quantity = quantity; + } + + public void decreaseQuantity(int orderQuantity) { + checkQuantity(orderQuantity); + this.quantity -= orderQuantity; + } + + private void checkQuantity(int orderQuantity) { + if (quantity == 0 || quantity < orderQuantity) { + throw new NotEnoughTicketException(); + } + } +} diff --git a/src/main/java/com/week/zumgnmarket/ticket/entity/TicketQueryDslRepository.java b/src/main/java/com/week/zumgnmarket/ticket/entity/TicketQueryDslRepository.java new file mode 100644 index 0000000..d9b8ac2 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/ticket/entity/TicketQueryDslRepository.java @@ -0,0 +1,36 @@ +package com.week.zumgnmarket.ticket.entity; + +import static com.week.zumgnmarket.ticket.entity.QTicket.*; + +import java.util.Optional; + +import javax.persistence.LockModeType; + +import org.springframework.data.jpa.repository.Lock; +import org.springframework.stereotype.Repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class TicketQueryDslRepository { + + private final JPAQueryFactory queryFactory; + + @Lock(value = LockModeType.PESSIMISTIC_WRITE) + public Optional findByIdWithPessimisticLock(Long id) { + return Optional.ofNullable(queryFactory.selectFrom(ticket) + .where(ticket.id.eq(id)) + .fetchOne()); + } + + @Lock(value = LockModeType.OPTIMISTIC) + public Optional findByIdWithOptimisticLock(Long id) { + return Optional.ofNullable(queryFactory.selectFrom(ticket) + .where(ticket.id.eq(id)) + .fetchOne()); + } + +} diff --git a/src/main/java/com/week/zumgnmarket/ticket/entity/TicketRepository.java b/src/main/java/com/week/zumgnmarket/ticket/entity/TicketRepository.java new file mode 100644 index 0000000..94a1a8a --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/ticket/entity/TicketRepository.java @@ -0,0 +1,6 @@ +package com.week.zumgnmarket.ticket.entity; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TicketRepository extends JpaRepository { +} diff --git a/src/main/java/com/week/zumgnmarket/ticket/exception/ErrorMessage.java b/src/main/java/com/week/zumgnmarket/ticket/exception/ErrorMessage.java new file mode 100644 index 0000000..48ed6eb --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/ticket/exception/ErrorMessage.java @@ -0,0 +1,5 @@ +package com.week.zumgnmarket.ticket.exception; + +public class ErrorMessage { + public static final String TICKET_NOT_FOUND_BY_ID = "Ticket not found with id "; +} diff --git a/src/main/java/com/week/zumgnmarket/ticket/exception/NotEnoughTicketException.java b/src/main/java/com/week/zumgnmarket/ticket/exception/NotEnoughTicketException.java new file mode 100644 index 0000000..09445c7 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/ticket/exception/NotEnoughTicketException.java @@ -0,0 +1,21 @@ +package com.week.zumgnmarket.ticket.exception; + +public class NotEnoughTicketException extends RuntimeException { + + public NotEnoughTicketException() { + super(); + } + + public NotEnoughTicketException(String message) { + super(message); + } + + public NotEnoughTicketException(String message, Throwable cause) { + super(message, cause); + } + + public NotEnoughTicketException(Throwable cause) { + super(cause); + } + +} diff --git a/src/main/java/com/week/zumgnmarket/ticket/service/TicketService.java b/src/main/java/com/week/zumgnmarket/ticket/service/TicketService.java new file mode 100644 index 0000000..e6e7bc4 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/ticket/service/TicketService.java @@ -0,0 +1,45 @@ +package com.week.zumgnmarket.ticket.service; + +import javax.persistence.EntityNotFoundException; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.ticket.entity.TicketQueryDslRepository; +import com.week.zumgnmarket.ticket.entity.TicketRepository; +import com.week.zumgnmarket.ticket.exception.ErrorMessage; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class TicketService { + + private final TicketRepository ticketRepository; + + private final TicketQueryDslRepository ticketQueryDslRepository; + + public Ticket save(Ticket ticket){ + return ticketRepository.save(ticket); + } + + @Transactional(readOnly = true) + public Ticket getTicket(Long id) { + return ticketRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException(ErrorMessage.TICKET_NOT_FOUND_BY_ID + id)); + } + + @Transactional(readOnly = true) + public Ticket getTicketWithPessimisticLock(Long id) { + return ticketQueryDslRepository.findByIdWithPessimisticLock(id) + .orElseThrow(() -> new EntityNotFoundException(ErrorMessage.TICKET_NOT_FOUND_BY_ID + id)); + } + + @Transactional(readOnly = true) + public Ticket getTicketWithOptimisticLock(Long id) { + return ticketQueryDslRepository.findByIdWithOptimisticLock(id) + .orElseThrow(() -> new EntityNotFoundException(ErrorMessage.TICKET_NOT_FOUND_BY_ID + id)); + } +} diff --git a/src/main/java/com/week/zumgnmarket/user/entity/User.java b/src/main/java/com/week/zumgnmarket/user/entity/User.java new file mode 100644 index 0000000..40b02f7 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/user/entity/User.java @@ -0,0 +1,33 @@ +package com.week.zumgnmarket.user.entity; + +import javax.persistence.*; + +import com.week.zumgnmarket.common.domain.BaseEntity; +import com.week.zumgnmarket.order.entity.Orders; + +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "users") +public class User extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @Embedded + private UserLogin userLogin; + + @Embedded + private Orders orders; + + @Builder + public User(String name, String email, String password) { + this.name = name; + this.userLogin = new UserLogin(email, password); + } + +} \ No newline at end of file diff --git a/src/main/java/com/week/zumgnmarket/user/entity/UserLogin.java b/src/main/java/com/week/zumgnmarket/user/entity/UserLogin.java new file mode 100644 index 0000000..53811a2 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/user/entity/UserLogin.java @@ -0,0 +1,27 @@ +package com.week.zumgnmarket.user.entity; + +import javax.persistence.Column; +import javax.persistence.Embeddable; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor +public class UserLogin { + + @Column(length = 50, nullable = false) + private String email; + + @Column(length = 20, nullable = false) + private String password; + + public UserLogin(String email, String password) { + this.email = email; + this.password = password; + } + +} \ No newline at end of file diff --git a/src/main/java/com/week/zumgnmarket/user/entity/UserRepository.java b/src/main/java/com/week/zumgnmarket/user/entity/UserRepository.java new file mode 100644 index 0000000..26429cf --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/user/entity/UserRepository.java @@ -0,0 +1,6 @@ +package com.week.zumgnmarket.user.entity; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} diff --git a/src/main/java/com/week/zumgnmarket/user/service/UserService.java b/src/main/java/com/week/zumgnmarket/user/service/UserService.java new file mode 100644 index 0000000..97e8643 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/user/service/UserService.java @@ -0,0 +1,29 @@ +package com.week.zumgnmarket.user.service; + +import javax.persistence.EntityNotFoundException; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.week.zumgnmarket.user.entity.User; +import com.week.zumgnmarket.user.entity.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + public User save(User user){ + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public User getUser(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("User not found with id: " + id)); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e69de29..153d1a3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -0,0 +1,19 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/study?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimeZone=Asia/Seoul + username: study + password: study123!@# + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + show_sql: true + format_sql: true + use_sql_comments: true + +logging.level: + org.hibernate.SQL: debug + org.hibernate.type: trace \ No newline at end of file diff --git a/src/test/java/com/week/zumgnmarket/ZumgnmarketApplicationTests.java b/src/test/java/com/week/zumgnmarket/WeeklyStudyProjectTests.java similarity index 84% rename from src/test/java/com/week/zumgnmarket/ZumgnmarketApplicationTests.java rename to src/test/java/com/week/zumgnmarket/WeeklyStudyProjectTests.java index 2495be3..3d03c13 100644 --- a/src/test/java/com/week/zumgnmarket/ZumgnmarketApplicationTests.java +++ b/src/test/java/com/week/zumgnmarket/WeeklyStudyProjectTests.java @@ -4,7 +4,7 @@ import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -class ZumgnmarketApplicationTests { +class WeeklyStudyProjectTests { @Test void contextLoads() { diff --git a/src/test/java/com/week/zumgnmarket/order/controller/OrderFacadeTest.java b/src/test/java/com/week/zumgnmarket/order/controller/OrderFacadeTest.java new file mode 100644 index 0000000..afdcac8 --- /dev/null +++ b/src/test/java/com/week/zumgnmarket/order/controller/OrderFacadeTest.java @@ -0,0 +1,46 @@ +package com.week.zumgnmarket.order.controller; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.week.zumgnmarket.order.dto.OrderRequest; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.ticket.service.TicketService; +import com.week.zumgnmarket.user.entity.User; +import com.week.zumgnmarket.user.service.UserService; + +@SpringBootTest +public class OrderFacadeTest { + @Autowired + private UserService userService; + @Autowired + private TicketService ticketService; + @Autowired + private OrderFacade orderFacade; + + /** + * 학습 테스트) + * 해당 엔티티가 트랜잭션에 따라 변경 감지가 일어나는 조건을 테스트. + * 만약 orderFacade 에 @Transactional 이 없다면 해당 테스트는 실패합니다. + * 또한 static '티켓'은 order 의 Ticket 과 다른 트랜잭션, 영속성 컨텍스트에 존재하기 때문에 변경감지가 발생하지 않습니다. + * */ + @Test + void 영속성_컨텍스트_변경감지_테스트() { + //given + User 회원 = new User("kim", "email@email.com", "pwd"); + Ticket 티켓 = new Ticket("CAT'S", 10000, 1000); + userService.save(회원); + ticketService.save(티켓); + + //when + orderFacade.order(new OrderRequest(회원.getId(), 티켓.getId(), 1)); + + //then + Ticket result = ticketService.getTicket(티켓.getId()); + assertEquals(result.getQuantity(), 999); + assertNotEquals(티켓.getQuantity(), 999); + } +} diff --git a/src/test/java/com/week/zumgnmarket/order/entity/OrderRepositoryTest.java b/src/test/java/com/week/zumgnmarket/order/entity/OrderRepositoryTest.java new file mode 100644 index 0000000..92073f4 --- /dev/null +++ b/src/test/java/com/week/zumgnmarket/order/entity/OrderRepositoryTest.java @@ -0,0 +1,47 @@ +package com.week.zumgnmarket.order.entity; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.ticket.entity.TicketRepository; +import com.week.zumgnmarket.user.entity.User; +import com.week.zumgnmarket.user.entity.UserRepository; + +@DataJpaTest +public class OrderRepositoryTest { + + @Autowired + private UserRepository userRepository; + @Autowired + private TicketRepository ticketRepository; + @Autowired + private OrderRepository orderRepository; + + User 회원; + Ticket 티켓; + + @BeforeEach + void setup() { + 회원 = new User("kim", "email@email.com", "pwd"); + 티켓 = new Ticket("CAT'S", 10000, 1000); + userRepository.save(회원); + ticketRepository.save(티켓); + } + + @Test + void 연관관계_매핑() { + //given + Order 주문 = Order.of(회원, 티켓, 1); + + //when + Order result = orderRepository.save(주문); + + //then + assertEquals(result.getTicket(), 티켓); + } +} diff --git a/src/test/java/com/week/zumgnmarket/order/service/OptimisticLockOrderServiceTest.java b/src/test/java/com/week/zumgnmarket/order/service/OptimisticLockOrderServiceTest.java new file mode 100644 index 0000000..6d4d319 --- /dev/null +++ b/src/test/java/com/week/zumgnmarket/order/service/OptimisticLockOrderServiceTest.java @@ -0,0 +1,80 @@ +package com.week.zumgnmarket.order.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.week.zumgnmarket.order.controller.OrderFacade; +import com.week.zumgnmarket.order.dto.OrderRequest; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.ticket.entity.TicketRepository; +import com.week.zumgnmarket.ticket.service.TicketService; +import com.week.zumgnmarket.user.entity.User; +import com.week.zumgnmarket.user.entity.UserRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest +public class OptimisticLockOrderServiceTest { + + @Autowired + private UserRepository userRepository; + @Autowired + private TicketRepository ticketRepository; + @Autowired + private TicketService ticketService; + @Autowired + private OrderFacade orderFacade; + + private User 회원; + private Ticket 티켓; + private final int thread = 1000; + + @BeforeEach + void setup() { + 회원 = new User("kim", "email@email.com", "pwd"); + 티켓 = new Ticket("CAT'S", 10000, 1000); + userRepository.save(회원); + ticketRepository.save(티켓); + } + + @AfterEach + void after() { + userRepository.deleteAll(); + ticketRepository.deleteAll(); + } + + @Test + void 동시에_티켓_주문() throws InterruptedException { + //given + ExecutorService executorService = Executors.newFixedThreadPool(123); + CountDownLatch countDownLatch = new CountDownLatch(thread); + + //when + for (int i = 0; i < thread; i++) { + executorService.execute(() -> { + try { + orderFacade.orderWithOptimisticLock(new OrderRequest(회원.getId(), 티켓.getId(), 1)); + log.info("남은 티켓 수량 : " + ticketService.getTicket(티켓.getId()).getQuantity()); + } catch (InterruptedException e) { + e.printStackTrace(); + } + countDownLatch.countDown(); + }); + } + countDownLatch.await(); + + //then + Ticket result = ticketService.getTicket(티켓.getId()); + assertEquals(result.getQuantity(), 0); + } +} diff --git a/src/test/java/com/week/zumgnmarket/order/service/OrderServiceTest.java b/src/test/java/com/week/zumgnmarket/order/service/OrderServiceTest.java new file mode 100644 index 0000000..f8dbda2 --- /dev/null +++ b/src/test/java/com/week/zumgnmarket/order/service/OrderServiceTest.java @@ -0,0 +1,80 @@ +package com.week.zumgnmarket.order.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.week.zumgnmarket.order.entity.Order; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.ticket.entity.TicketRepository; +import com.week.zumgnmarket.user.entity.User; +import com.week.zumgnmarket.user.entity.UserRepository; + +@SpringBootTest +public class OrderServiceTest { + + @Autowired + private UserRepository userRepository; + @Autowired + private TicketRepository ticketRepository; + @Autowired + private OrderService orderService; + + private User 회원; + private Ticket 티켓; + private final int thread = 1000; + + @BeforeEach + void setup() { + 회원 = new User("kim", "email@email.com", "pwd"); + 티켓 = new Ticket("CAT'S", 10000, 1000); + userRepository.save(회원); + ticketRepository.save(티켓); + } + + @AfterEach + void after() { + userRepository.deleteAll(); + ticketRepository.deleteAll(); + } + + @Test + void 티켓_주문() { + //given + int orderQuantity = 2; + int remainingQuantity = 티켓.getQuantity(); + + // when + Order 주문 = orderService.orderTicket(회원, 티켓, orderQuantity); + + //then + assertEquals(티켓.getQuantity(), remainingQuantity - orderQuantity); + } + + @Test + void 동시에_티켓_주문_실패() throws InterruptedException { + //given + ExecutorService executorService = Executors.newFixedThreadPool(123); + CountDownLatch countDownLatch = new CountDownLatch(thread); + + //when + for (int i = 0; i < thread; i++) { + executorService.execute(() -> { + orderService.orderTicket(회원, 티켓, 1); + countDownLatch.countDown(); + }); + } + countDownLatch.await(); + + //then + assertNotEquals(0, 티켓.getQuantity()); + } +} diff --git a/src/test/java/com/week/zumgnmarket/order/service/PessimisticLockOrderServiceTest.java b/src/test/java/com/week/zumgnmarket/order/service/PessimisticLockOrderServiceTest.java new file mode 100644 index 0000000..7e0f60e --- /dev/null +++ b/src/test/java/com/week/zumgnmarket/order/service/PessimisticLockOrderServiceTest.java @@ -0,0 +1,76 @@ +package com.week.zumgnmarket.order.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.week.zumgnmarket.order.controller.OrderFacade; +import com.week.zumgnmarket.order.dto.OrderRequest; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.ticket.entity.TicketRepository; +import com.week.zumgnmarket.ticket.service.TicketService; +import com.week.zumgnmarket.user.entity.User; +import com.week.zumgnmarket.user.entity.UserRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest +public class PessimisticLockOrderServiceTest { + + @Autowired + private UserRepository userRepository; + @Autowired + private TicketRepository ticketRepository; + @Autowired + private TicketService ticketService; + @Autowired + private OrderFacade orderFacade; + + private User 회원; + private Ticket 티켓; + private final int thread = 1000; + + @BeforeEach + void setup() { + 회원 = new User("kim", "email@email.com", "pwd"); + 티켓 = new Ticket("CAT'S", 10000, 1000); + userRepository.save(회원); + ticketRepository.save(티켓); + } + + @AfterEach + void after() { + userRepository.deleteAll(); + ticketRepository.deleteAll(); + } + + @Test + void 동시에_티켓_주문() throws InterruptedException { + //given + ExecutorService executorService = Executors.newFixedThreadPool(123); + CountDownLatch countDownLatch = new CountDownLatch(thread); + + //when + for (int i = 0; i < thread; i++) { + executorService.execute(() -> { + orderFacade.orderWithPessimisticLock(new OrderRequest(회원.getId(), 티켓.getId(), 1)); + log.info("남은 티켓 수량 : " + ticketService.getTicket(티켓.getId()).getQuantity()); + countDownLatch.countDown(); + }); + } + countDownLatch.await(); + + //then + Ticket result = ticketService.getTicket(티켓.getId()); + assertEquals(result.getQuantity(), 0); + } +} diff --git a/src/test/java/com/week/zumgnmarket/order/service/RedissonLockOrderServiceTest.java b/src/test/java/com/week/zumgnmarket/order/service/RedissonLockOrderServiceTest.java new file mode 100644 index 0000000..8ed3f52 --- /dev/null +++ b/src/test/java/com/week/zumgnmarket/order/service/RedissonLockOrderServiceTest.java @@ -0,0 +1,76 @@ +package com.week.zumgnmarket.order.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.week.zumgnmarket.order.controller.OrderFacade; +import com.week.zumgnmarket.order.dto.OrderRequest; +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.ticket.entity.TicketRepository; +import com.week.zumgnmarket.ticket.service.TicketService; +import com.week.zumgnmarket.user.entity.User; +import com.week.zumgnmarket.user.entity.UserRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest +public class RedissonLockOrderServiceTest { + + @Autowired + private UserRepository userRepository; + @Autowired + private TicketRepository ticketRepository; + @Autowired + private TicketService ticketService; + @Autowired + private OrderFacade orderFacade; + + private User 회원; + private Ticket 티켓; + private final int thread = 1000; + + @BeforeEach + void setup() { + 회원 = new User("kim", "email@email.com", "pwd"); + 티켓 = new Ticket("CAT'S", 10000, 1000); + userRepository.save(회원); + ticketRepository.save(티켓); + } + + @AfterEach + void after() { + userRepository.deleteAll(); + ticketRepository.deleteAll(); + } + + @Test + void 동시에_티켓_주문() throws InterruptedException { + //given + ExecutorService executorService = Executors.newFixedThreadPool(123); + CountDownLatch countDownLatch = new CountDownLatch(thread); + + //when + for (int i = 0; i < thread; i++) { + executorService.execute(() -> { + orderFacade.orderWithRedisson(new OrderRequest(회원.getId(), 티켓.getId(), 1)); + log.info("남은 티켓 수량 : " + ticketService.getTicket(티켓.getId()).getQuantity()); + countDownLatch.countDown(); + }); + } + countDownLatch.await(); + + //then + Ticket result = ticketService.getTicket(티켓.getId()); + assertEquals(result.getQuantity(), 0); + } +} diff --git a/src/test/java/com/week/zumgnmarket/order/service/SynchronizedOrderServiceTest.java b/src/test/java/com/week/zumgnmarket/order/service/SynchronizedOrderServiceTest.java new file mode 100644 index 0000000..29206ab --- /dev/null +++ b/src/test/java/com/week/zumgnmarket/order/service/SynchronizedOrderServiceTest.java @@ -0,0 +1,66 @@ +package com.week.zumgnmarket.order.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.week.zumgnmarket.ticket.entity.Ticket; +import com.week.zumgnmarket.ticket.entity.TicketRepository; +import com.week.zumgnmarket.user.entity.User; +import com.week.zumgnmarket.user.entity.UserRepository; + +@SpringBootTest +public class SynchronizedOrderServiceTest { + + @Autowired + private UserRepository userRepository; + @Autowired + private TicketRepository ticketRepository; + @Autowired + private SynchronizedOrderService orderService; + + private User 회원; + private Ticket 티켓; + private final int thread = 1000; + + @BeforeEach + void setup() { + 회원 = new User("kim", "email@email.com", "pwd"); + 티켓 = new Ticket("CAT'S", 10000, 1000); + userRepository.save(회원); + ticketRepository.save(티켓); + } + + @AfterEach + void after() { + userRepository.deleteAll(); + ticketRepository.deleteAll(); + } + + @Test + void 동시에_티켓_주문() throws InterruptedException { + //given + ExecutorService executorService = Executors.newFixedThreadPool(123); + CountDownLatch countDownLatch = new CountDownLatch(thread); + + //when + for (int i = 0; i < thread; i++) { + executorService.execute(() -> { + orderService.orderTicket(회원, 티켓, 1); + countDownLatch.countDown(); + }); + } + countDownLatch.await(); + + //then + assertEquals(0, 티켓.getQuantity()); + } +}