diff --git a/build.gradle b/build.gradle index 2eb8618..5a2b58e 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,9 @@ 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' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3b36782 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3.9' +services: + redis: + image: 'redis:alpine' + hostname: redis + container_name: redis-dailyProject + ports: + - '6379:6379' + +volumes: + redis: + driver: local diff --git a/src/main/java/com/week/zumgnmarket/config/QueryDslConfig.java b/src/main/java/com/week/zumgnmarket/config/QueryDslConfig.java new file mode 100644 index 0000000..0665aad --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/config/QueryDslConfig.java @@ -0,0 +1,21 @@ +package com.week.zumgnmarket.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} + diff --git a/src/main/java/com/week/zumgnmarket/config/RedissonConfig.java b/src/main/java/com/week/zumgnmarket/config/RedissonConfig.java new file mode 100644 index 0000000..b05c11f --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/config/RedissonConfig.java @@ -0,0 +1,28 @@ +package com.week.zumgnmarket.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + @Value("${spring.redis.host}") + private String redisHost; + + @Value("${spring.redis.port}") + private int redisPort; + + private static final String REDISSON_HOST_PREFIX = "redis://"; + + @Bean + public RedissonClient redissonClient() { + RedissonClient redisson = null; + Config config = new Config(); + config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort); + redisson = Redisson.create(config); + return redisson; + } +} diff --git a/src/main/java/com/week/zumgnmarket/controller/TicketController.java b/src/main/java/com/week/zumgnmarket/controller/TicketController.java new file mode 100644 index 0000000..b84f2b1 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/controller/TicketController.java @@ -0,0 +1,24 @@ +package com.week.zumgnmarket.controller; + +import com.week.zumgnmarket.fecade.TicketFacade; +import com.week.zumgnmarket.fecade.dto.TicketRequest; +import com.week.zumgnmarket.fecade.dto.TicketResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/tickets") +@RequiredArgsConstructor +public class TicketController { + + private final TicketFacade ticketFacade; + + @GetMapping("/buy") + public ResponseEntity buy(@RequestBody TicketRequest ticketRequest) { + return ResponseEntity.ok(ticketFacade.buy(ticketRequest)); + } +} diff --git a/src/main/java/com/week/zumgnmarket/entity/Buyer.java b/src/main/java/com/week/zumgnmarket/entity/Buyer.java new file mode 100644 index 0000000..33a9ecf --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/entity/Buyer.java @@ -0,0 +1,28 @@ +package com.week.zumgnmarket.entity; + +import lombok.*; + +import javax.persistence.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +@Table(name = "buyer") +public class Buyer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "buyer_idx") + private Integer id; + + @Column(name = "nick_name") + private String nickName; + + public static Buyer of(String nickName) { + return Buyer.builder() + .nickName(nickName).build(); + } + +} diff --git a/src/main/java/com/week/zumgnmarket/entity/Musical.java b/src/main/java/com/week/zumgnmarket/entity/Musical.java new file mode 100644 index 0000000..afebf5e --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/entity/Musical.java @@ -0,0 +1,32 @@ +package com.week.zumgnmarket.entity; + +import lombok.*; + +import javax.persistence.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +@Table(name = "musical") +public class Musical { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "musical_idx") + private Integer id; + + @Column(name = "title") + private String title; + + @Column(name = "description") + private String description; + + public static Musical of(String title, String description) { + return Musical.builder() + .title(title) + .description(description) + .build(); + } +} diff --git a/src/main/java/com/week/zumgnmarket/entity/MusicalTicket.java b/src/main/java/com/week/zumgnmarket/entity/MusicalTicket.java new file mode 100644 index 0000000..1596bb3 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/entity/MusicalTicket.java @@ -0,0 +1,59 @@ +package com.week.zumgnmarket.entity; + +import lombok.*; +import lombok.extern.slf4j.Slf4j; + +import javax.persistence.*; +import java.time.LocalDate; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Slf4j +@Builder +@Table(name = "musical_ticket") +public class MusicalTicket { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ticket_idx") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "musicla_idx") + private Musical musical; + + @Column(name = "ticket_count") + private Long ticketCount; + + @Column(name = "ticketing_date") + private LocalDate ticketingDate; + + public static MusicalTicket of(Musical musical, Long ticketCount, LocalDate ticketingDate) { + return MusicalTicket.builder() + .musical(musical) + .ticketCount(ticketCount) + .ticketingDate(ticketingDate) + .build(); + } + + public void decrease() { + final String thread = Thread.currentThread().getName(); + if((this.ticketCount - 1) < 0 ){ + //throw new RuntimeException("오늘자 티켓팅이 마감되었습니다 (재고 부족)"); - 테스트 위해 주석 + log.error("진행중인 사람 : {}, 메세지 : 티켓팅이 마감되었습니다(재고 부족 - {} 개) ", thread, this.ticketCount); + return; + } + this.ticketCount = this.ticketCount - 1; + log.error("진행중인 사람 : {}, 메세지 : 티켓팅을 성공하셨습니다(남은 티켓 갯수 - {} 개) ", thread, this.ticketCount); + } + + public boolean checkTicketingDate(LocalDate localDate) { + return this.ticketingDate.equals(localDate); + } + + public Integer getMusicalId() { + return this.musical.getId(); + } +} diff --git a/src/main/java/com/week/zumgnmarket/entity/Purchase.java b/src/main/java/com/week/zumgnmarket/entity/Purchase.java new file mode 100644 index 0000000..56c8bf1 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/entity/Purchase.java @@ -0,0 +1,41 @@ +package com.week.zumgnmarket.entity; + +import lombok.*; + +import javax.persistence.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +@Table(name = "purchase") +public class Purchase { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "purchase_idx") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "buyer_idx") + private Buyer buyer; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "musical_idx") + private Musical musical; + + public static Purchase of(Buyer buyer, Musical musical) { + return Purchase.builder() + .buyer(buyer) + .musical(musical) + .build(); + } + + public Integer getBuyerId() { + return this.buyer.getId(); + } + + public String getTitle() { + return this.musical.getTitle(); + } +} diff --git a/src/main/java/com/week/zumgnmarket/fecade/TicketFacade.java b/src/main/java/com/week/zumgnmarket/fecade/TicketFacade.java new file mode 100644 index 0000000..15cfb3c --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/fecade/TicketFacade.java @@ -0,0 +1,60 @@ +package com.week.zumgnmarket.fecade; + +import com.week.zumgnmarket.entity.Buyer; +import com.week.zumgnmarket.entity.Musical; +import com.week.zumgnmarket.entity.MusicalTicket; +import com.week.zumgnmarket.entity.Purchase; +import com.week.zumgnmarket.fecade.dto.TicketRequest; +import com.week.zumgnmarket.fecade.dto.TicketResponse; +import com.week.zumgnmarket.service.BuyerService; +import com.week.zumgnmarket.service.MusicalTicketService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TicketFacade { + private final MusicalTicketService musicalTicketService; + private final BuyerService buyerService; + private final RedissonClient redissonClient; + + public TicketResponse buy(TicketRequest ticketRequest) { + Buyer buyer = buyerService.findBuyerById(ticketRequest.getBuyerId()); + Musical musical = musicalTicketService.findMusicalById(ticketRequest.getMusicalId()); + MusicalTicket musicalTicket = musicalTicketService.findTicketByMusical(musical); + if (!musicalTicket.checkTicketingDate(ticketRequest.getTicketingDate())) { + throw new RuntimeException("지금은 티켓팅 기간이 아닙니다."); + } + RLock lock = redissonClient.getLock(musical.getTitle() + ":" + ticketRequest.getTicketingDate()); + try { + if (!lock.tryLock(3, 5, TimeUnit.SECONDS)) { + return new TicketResponse(); + } + Purchase purchase = musicalTicketService.buyTicket(buyer, musicalTicket.getId()); + return TicketResponse.of(purchase); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + //lock.isLocked() && lock.isHeldByCurrentThread() + if (lock != null && lock.isLocked() && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + + public void buyNoLock(TicketRequest ticketRequest) { + Buyer buyer = buyerService.findBuyerById(ticketRequest.getBuyerId()); + Musical musical = musicalTicketService.findMusicalById(ticketRequest.getMusicalId()); + MusicalTicket musicalTicket = musicalTicketService.findTicketByMusical(musical); + if (!musicalTicket.checkTicketingDate(ticketRequest.getTicketingDate())) { + throw new RuntimeException("지금은 티켓팅 기간이 아닙니다."); + } + musicalTicketService.buyTicket(buyer, musicalTicket.getId()); + } +} diff --git a/src/main/java/com/week/zumgnmarket/fecade/dto/TicketRequest.java b/src/main/java/com/week/zumgnmarket/fecade/dto/TicketRequest.java new file mode 100644 index 0000000..4f6ec43 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/fecade/dto/TicketRequest.java @@ -0,0 +1,23 @@ +package com.week.zumgnmarket.fecade.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; + +@Getter +@Setter +@Builder +public class TicketRequest { + private Integer buyerId; + private Integer musicalId; + private LocalDate TicketingDate; + + public static TicketRequest of(Integer buyerId, Integer musicalId, LocalDate TicketingDate) { + return TicketRequest.builder() + .buyerId(buyerId) + .musicalId(musicalId) + .TicketingDate(TicketingDate).build(); + } +} diff --git a/src/main/java/com/week/zumgnmarket/fecade/dto/TicketResponse.java b/src/main/java/com/week/zumgnmarket/fecade/dto/TicketResponse.java new file mode 100644 index 0000000..852fe27 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/fecade/dto/TicketResponse.java @@ -0,0 +1,22 @@ +package com.week.zumgnmarket.fecade.dto; + +import com.week.zumgnmarket.entity.Purchase; +import lombok.*; +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class TicketResponse { + + private Integer buyerId; + private String title; + private boolean isPurchase; + + public static TicketResponse of(Purchase purchase) { + return TicketResponse.builder() + .buyerId(purchase.getBuyerId()) + .title(purchase.getTitle()) + .isPurchase(true).build(); + } +} diff --git a/src/main/java/com/week/zumgnmarket/repository/BuyerJpaRepository.java b/src/main/java/com/week/zumgnmarket/repository/BuyerJpaRepository.java new file mode 100644 index 0000000..efea925 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/repository/BuyerJpaRepository.java @@ -0,0 +1,8 @@ +package com.week.zumgnmarket.repository; + +import com.week.zumgnmarket.entity.Buyer; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BuyerJpaRepository extends JpaRepository { + +} diff --git a/src/main/java/com/week/zumgnmarket/repository/MusicalJpaRepository.java b/src/main/java/com/week/zumgnmarket/repository/MusicalJpaRepository.java new file mode 100644 index 0000000..49fc3c9 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/repository/MusicalJpaRepository.java @@ -0,0 +1,7 @@ +package com.week.zumgnmarket.repository; + +import com.week.zumgnmarket.entity.Musical; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MusicalJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/week/zumgnmarket/repository/PurchaseJpaRepository.java b/src/main/java/com/week/zumgnmarket/repository/PurchaseJpaRepository.java new file mode 100644 index 0000000..7d20539 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/repository/PurchaseJpaRepository.java @@ -0,0 +1,10 @@ +package com.week.zumgnmarket.repository; + +import com.week.zumgnmarket.entity.Buyer; +import com.week.zumgnmarket.entity.Musical; +import com.week.zumgnmarket.entity.Purchase; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PurchaseJpaRepository extends JpaRepository { + boolean existsByBuyerAndMusical(Buyer buyer, Musical musical); +} diff --git a/src/main/java/com/week/zumgnmarket/repository/TicketJpaRepository.java b/src/main/java/com/week/zumgnmarket/repository/TicketJpaRepository.java new file mode 100644 index 0000000..449ff7c --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/repository/TicketJpaRepository.java @@ -0,0 +1,10 @@ +package com.week.zumgnmarket.repository; + +import com.week.zumgnmarket.entity.Musical; +import com.week.zumgnmarket.entity.MusicalTicket; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TicketJpaRepository extends JpaRepository { + MusicalTicket findByMusical(Musical musical); +} diff --git a/src/main/java/com/week/zumgnmarket/service/BuyerService.java b/src/main/java/com/week/zumgnmarket/service/BuyerService.java new file mode 100644 index 0000000..621acb6 --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/service/BuyerService.java @@ -0,0 +1,21 @@ +package com.week.zumgnmarket.service; + +import com.week.zumgnmarket.entity.Buyer; +import com.week.zumgnmarket.repository.BuyerJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class BuyerService { + + private final BuyerJpaRepository buyerJpaRepository; + + @Transactional(readOnly = true) + public Buyer findBuyerById(Integer id) { + return buyerJpaRepository.findById(id).orElseThrow(() -> new RuntimeException("존재하지 않는 회원입니다. idx: " + id)); + } +} diff --git a/src/main/java/com/week/zumgnmarket/service/MusicalTicketService.java b/src/main/java/com/week/zumgnmarket/service/MusicalTicketService.java new file mode 100644 index 0000000..83e6c6b --- /dev/null +++ b/src/main/java/com/week/zumgnmarket/service/MusicalTicketService.java @@ -0,0 +1,55 @@ +package com.week.zumgnmarket.service; + +import com.week.zumgnmarket.entity.Buyer; +import com.week.zumgnmarket.entity.Musical; +import com.week.zumgnmarket.entity.MusicalTicket; +import com.week.zumgnmarket.entity.Purchase; +import com.week.zumgnmarket.repository.MusicalJpaRepository; +import com.week.zumgnmarket.repository.PurchaseJpaRepository; +import com.week.zumgnmarket.repository.TicketJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class MusicalTicketService { + + private final TicketJpaRepository ticketJpaRepository; + + private final MusicalJpaRepository musicalJpaRepository; + + private final PurchaseJpaRepository purchaseJpaRepository; + + public MusicalTicket createTicket(MusicalTicket ticket) { + return ticketJpaRepository.save(ticket); + } + + public Musical createMusical(Musical musical) { + return musicalJpaRepository.save(musical); + } + + public MusicalTicket findTicketByMusical(Musical musical) { + return ticketJpaRepository.findByMusical(musical); + } + + public MusicalTicket findTicketById(Integer ticketId) { + return ticketJpaRepository.findById(ticketId).orElseThrow(() -> new RuntimeException("존재하지 않는 티켓입니다")); + } + public Musical findMusicalById(Integer id) { + return musicalJpaRepository.findById(id).orElseThrow(() -> new RuntimeException("존재하지 않는 뮤지컬 입니다. ")); + } + + public Purchase buyTicket(Buyer buyer, Integer ticketId) { + MusicalTicket musicalTicket = findTicketById(ticketId); + Musical musical = findMusicalById(musicalTicket.getMusicalId()); + if (purchaseJpaRepository.existsByBuyerAndMusical(buyer, musical)) { + throw new RuntimeException("이미 해당 뮤지컬 티켓을 구매하셨습니다."); + } + musicalTicket.decrease(); + return purchaseJpaRepository.save(Purchase.of(buyer, musical)); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e69de29..5cd4959 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -0,0 +1,19 @@ +spring: + redis: + host: localhost + port: 6379 + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://127.0.0.1:3306/zumterpark?useSSL=false&serverTimezone=Asia/Seoul + username: root + password: + jpa: + database: mysql +# show-sql: true + hibernate: + ddl-auto: update +logging: + level: + root: error +# org.hibernate.sql: debug +# org.hibernate.type: trace \ No newline at end of file diff --git a/src/test/java/com/week/zumgnmarket/fecade/TicketFacadeTest.java b/src/test/java/com/week/zumgnmarket/fecade/TicketFacadeTest.java new file mode 100644 index 0000000..f9267b4 --- /dev/null +++ b/src/test/java/com/week/zumgnmarket/fecade/TicketFacadeTest.java @@ -0,0 +1,146 @@ +package com.week.zumgnmarket.fecade; + +import com.week.zumgnmarket.entity.Buyer; +import com.week.zumgnmarket.entity.Musical; +import com.week.zumgnmarket.entity.MusicalTicket; +import com.week.zumgnmarket.fecade.dto.TicketRequest; +import com.week.zumgnmarket.repository.BuyerJpaRepository; +import com.week.zumgnmarket.repository.MusicalJpaRepository; +import com.week.zumgnmarket.repository.PurchaseJpaRepository; +import com.week.zumgnmarket.repository.TicketJpaRepository; +import com.week.zumgnmarket.service.MusicalTicketService; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@SpringBootTest +class TicketFacadeTest { + + @Autowired + private TicketFacade ticketFacade; + + @Autowired + private MusicalTicketService musicalTicketService; + + @Autowired + private MusicalJpaRepository musicalJpaRepository; + + @Autowired + private TicketJpaRepository ticketJpaRepository; + + @Autowired + private BuyerJpaRepository buyerJpaRepository; + + @Autowired + private PurchaseJpaRepository purchaseJpaRepository; + + private static final Long TOTAL_AMOUNT = 100L; + private static final Long SOLD_OUT = 0L; + + private MusicalTicket musicalTicket_; + + private static List buyers = new ArrayList<>(); + + @BeforeEach + void 사용자_티켓_셋팅() { + + for (int i=0; i<150; i++) { + Buyer buyer = Buyer.of("김"+i+"씨"); + buyers.add(buyerJpaRepository.save(buyer)); + } + + Musical musical = Musical.of("캣츠", "1000만 돌파 !!"); + musicalTicketService.createMusical(musical); + MusicalTicket musicalTicket = MusicalTicket.of(musical, TOTAL_AMOUNT, LocalDate.now()); + musicalTicket_ = musicalTicketService.createTicket(musicalTicket); + } + + @AfterEach + void 사용자_티켓_초기화() { + for (int i = 0; i<150; i++) { + buyers.clear(); + } + purchaseJpaRepository.deleteAll(); + buyerJpaRepository.deleteAll(); + ticketJpaRepository.deleteAll(); + musicalJpaRepository.deleteAll(); + } + + @Test + @Order(1) + void 락0_150명이_티켓팅을_실행() throws InterruptedException { + int people = 150; + CountDownLatch countDownLatch = new CountDownLatch(people); + List threads = buyers.stream() + .map(buyer -> { + TicketRequest ticketRequest = TicketRequest.of(buyer.getId(), musicalTicket_.getMusicalId(), LocalDate.now()); + return new Thread(new Buying(ticketRequest, countDownLatch)); + }) + .limit(people).collect(Collectors.toList()); + threads.forEach(Thread::start); + countDownLatch.await(); + MusicalTicket musicalTicket = musicalTicketService.findTicketById(musicalTicket_.getId()); + assertEquals(musicalTicket.getTicketCount(), SOLD_OUT); + } + + @Test + @Order(1) + void 락X_150명이_티켓팅을_실행() throws InterruptedException { + int people = 150; + CountDownLatch countDownLatch = new CountDownLatch(people); + List threads = buyers.stream() + .map(buyer -> { + TicketRequest ticketRequest = TicketRequest.of(buyer.getId(), musicalTicket_.getMusicalId(), LocalDate.now()); + return new Thread(new BuyingNoLock(ticketRequest, countDownLatch)); + }) + .limit(people).collect(Collectors.toList()); + threads.forEach(Thread::start); + countDownLatch.await(); + MusicalTicket musicalTicket = musicalTicketService.findTicketById(musicalTicket_.getId()); + assertNotEquals(musicalTicket.getTicketCount(), SOLD_OUT); + } + + private class Buying implements Runnable { + + private TicketRequest ticketRequest; + private CountDownLatch countDownLatch; + + public Buying(TicketRequest ticketRequest, CountDownLatch countDownLatch) { + this.ticketRequest = ticketRequest; + this.countDownLatch = countDownLatch; + } + + @Override + public void run() { + ticketFacade.buy(ticketRequest); + countDownLatch.countDown(); + } + } + + private class BuyingNoLock implements Runnable { + + private TicketRequest ticketRequest; + private CountDownLatch countDownLatch; + + public BuyingNoLock(TicketRequest ticketRequest, CountDownLatch countDownLatch) { + this.ticketRequest = ticketRequest; + this.countDownLatch = countDownLatch; + } + + @Override + public void run() { + ticketFacade.buyNoLock(ticketRequest); + countDownLatch.countDown(); + } + } +} \ No newline at end of file