diff --git a/backend-config b/backend-config index 44d0d8d9..8d1e4757 160000 --- a/backend-config +++ b/backend-config @@ -1 +1 @@ -Subproject commit 44d0d8d9302141589cae777b567a3eab6fd9a68f +Subproject commit 8d1e4757aca6c549cde6585dbe6128d0b069913b diff --git a/src/main/java/com/thirdparty/ticketing/domain/common/ErrorCode.java b/src/main/java/com/thirdparty/ticketing/domain/common/ErrorCode.java index d3bcb61f..9e9fb8a0 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/common/ErrorCode.java +++ b/src/main/java/com/thirdparty/ticketing/domain/common/ErrorCode.java @@ -42,6 +42,13 @@ public enum ErrorCode { */ NOT_FOUND_SEAT_GRADE(HttpStatus.NOT_FOUND, "SG404-1", "존재하지 않는 구역입니다."), + /* + Seat Error + */ + NOT_FOUND_SEAT(HttpStatus.NOT_FOUND, "S404-1", "존재하지 않는 좌석입니다."), + NOT_SELECTABLE_SEAT(HttpStatus.FORBIDDEN, "S403-1", "이미 선택된 좌석입니다."), + INVALID_SEAT_STATUS(HttpStatus.FORBIDDEN, "S403-2", "해당 좌석에는 접근할 수 없습니다."), + /* Payment Error */ @@ -51,7 +58,8 @@ public enum ErrorCode { Waiting Error */ WAITING_WRITE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "W500-1", "대기열 쓰기에 실패했습니다."), - WAITING_READ_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "W500-2", "대기열 읽기에 실패했습니다."); + WAITING_READ_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "W500-2", "대기열 읽기에 실패했습니다."), + ; ErrorCode(HttpStatus httpStatus, String code, String message) { this.httpStatus = httpStatus; diff --git a/src/main/java/com/thirdparty/ticketing/domain/common/Event.java b/src/main/java/com/thirdparty/ticketing/domain/common/Event.java new file mode 100644 index 00000000..80124581 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/common/Event.java @@ -0,0 +1,3 @@ +package com.thirdparty.ticketing.domain.common; + +public interface Event {} diff --git a/src/main/java/com/thirdparty/ticketing/domain/common/EventPublisher.java b/src/main/java/com/thirdparty/ticketing/domain/common/EventPublisher.java new file mode 100644 index 00000000..d8ea5ffa --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/common/EventPublisher.java @@ -0,0 +1,6 @@ +package com.thirdparty.ticketing.domain.common; + +public interface EventPublisher { + + void publish(Event event); +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/common/LettuceRepository.java b/src/main/java/com/thirdparty/ticketing/domain/common/LettuceRepository.java new file mode 100644 index 00000000..d1fe6d3a --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/common/LettuceRepository.java @@ -0,0 +1,24 @@ +package com.thirdparty.ticketing.domain.common; + +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class LettuceRepository { + private final StringRedisTemplate redisTemplate; + + // 1. lock을 생성 + // 2. 60초가 유지되는 key는 자리번호 value는 유저 id를 생성 + public Boolean seatLock(String key) { + return redisTemplate.opsForValue().setIfAbsent(key, "lock", Duration.ofMinutes(1)); + } + + public void unlock(String string) { + redisTemplate.delete(string); + } +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/member/Member.java b/src/main/java/com/thirdparty/ticketing/domain/member/Member.java index 2e4b4075..034cf35b 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/member/Member.java +++ b/src/main/java/com/thirdparty/ticketing/domain/member/Member.java @@ -1,6 +1,7 @@ package com.thirdparty.ticketing.domain.member; import java.time.ZonedDateTime; +import java.util.Objects; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -53,4 +54,17 @@ public Member(String email, String password, MemberRole memberRole, ZonedDateTim this.password = password; this.memberRole = memberRole; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Member member = (Member) o; + return Objects.equals(memberId, member.memberId); + } + + @Override + public int hashCode() { + return Objects.hashCode(memberId); + } } diff --git a/src/main/java/com/thirdparty/ticketing/domain/seat/Seat.java b/src/main/java/com/thirdparty/ticketing/domain/seat/Seat.java index 67f1ae66..4f418c2a 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/seat/Seat.java +++ b/src/main/java/com/thirdparty/ticketing/domain/seat/Seat.java @@ -3,6 +3,8 @@ import jakarta.persistence.*; import com.thirdparty.ticketing.domain.BaseEntity; +import com.thirdparty.ticketing.domain.common.ErrorCode; +import com.thirdparty.ticketing.domain.common.TicketingException; import com.thirdparty.ticketing.domain.member.Member; import com.thirdparty.ticketing.domain.zone.Zone; @@ -11,6 +13,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Entity @Table(name = "seat") @@ -18,6 +21,7 @@ @Builder @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Slf4j public class Seat extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -43,6 +47,8 @@ public class Seat extends BaseEntity { @Column(length = 16, nullable = false) private SeatStatus seatStatus = SeatStatus.SELECTABLE; + @Version private Long version; + public Seat(String seatCode, SeatStatus seatStatus) { this.seatCode = seatCode; this.seatStatus = seatStatus; @@ -51,4 +57,31 @@ public Seat(String seatCode, SeatStatus seatStatus) { public boolean isSelectable() { return seatStatus.isSelectable(); } + + public void assignByMember(Member member) { + if (!isSelectable()) { + throw new TicketingException(ErrorCode.NOT_SELECTABLE_SEAT); + } + log.info("seat occupied by {}", member.getEmail()); + this.member = member; + this.seatStatus = SeatStatus.SELECTED; + } + + public void markAsPendingPayment() { + if (!seatStatus.isSelected()) { + throw new TicketingException(ErrorCode.INVALID_SEAT_STATUS); + } + this.seatStatus = SeatStatus.PENDING_PAYMENT; + } + + public void markAsPaid() { + if (!seatStatus.isPendingPayment()) { + throw new TicketingException(ErrorCode.INVALID_SEAT_STATUS); + } + this.seatStatus = SeatStatus.PAID; + } + + public boolean isAssignedByMember(Member loginMember) { + return loginMember.equals(member); + } } diff --git a/src/main/java/com/thirdparty/ticketing/domain/seat/SeatStatus.java b/src/main/java/com/thirdparty/ticketing/domain/seat/SeatStatus.java index a0b26463..a3e9d0d3 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/seat/SeatStatus.java +++ b/src/main/java/com/thirdparty/ticketing/domain/seat/SeatStatus.java @@ -9,4 +9,16 @@ public enum SeatStatus { public boolean isSelectable() { return this == SELECTABLE; } + + public boolean isSelected() { + return this == SELECTED; + } + + public boolean isPendingPayment() { + return this == PENDING_PAYMENT; + } + + public boolean isPaid() { + return this == PAID; + } } diff --git a/src/main/java/com/thirdparty/ticketing/domain/seat/repository/SeatRepository.java b/src/main/java/com/thirdparty/ticketing/domain/seat/repository/SeatRepository.java index 5a24b9dc..069dbcba 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/seat/repository/SeatRepository.java +++ b/src/main/java/com/thirdparty/ticketing/domain/seat/repository/SeatRepository.java @@ -1,14 +1,33 @@ package com.thirdparty.ticketing.domain.seat.repository; import java.util.List; +import java.util.Optional; + +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import com.thirdparty.ticketing.domain.seat.Seat; import com.thirdparty.ticketing.domain.zone.Zone; +import io.lettuce.core.dynamic.annotation.Param; + @Repository public interface SeatRepository extends JpaRepository { List findByZone(Zone zone); + + @Query("SELECT s FROM Seat as s WHERE s.id = :seatId") + @Lock(LockModeType.NONE) + Optional findById(@Param("seatId") Long seatId); + + @Query("SELECT s FROM Seat as s WHERE s.id = :seatId") + @Lock(LockModeType.OPTIMISTIC) + Optional findByIdWithOptimistic(@Param("seatId") Long seatId); + + @Query("SELECT s FROM Seat as s WHERE s.id = :seatId") + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findByIdWithPessimistic(@Param("seatId") Long seatId); } diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/controller/TicketController.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/controller/TicketController.java index 37b40eca..9e2878e1 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/ticket/controller/TicketController.java +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/controller/TicketController.java @@ -1,45 +1,50 @@ package com.thirdparty.ticketing.domain.ticket.controller; +import java.util.List; + import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import com.thirdparty.ticketing.domain.ItemResult; import com.thirdparty.ticketing.domain.common.LoginMember; import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; import com.thirdparty.ticketing.domain.ticket.dto.TicketElement; import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; +import com.thirdparty.ticketing.domain.ticket.service.ReservationService; import com.thirdparty.ticketing.domain.ticket.service.TicketService; import lombok.RequiredArgsConstructor; -@RestController("/api") +@RestController +@RequestMapping("/api") @RequiredArgsConstructor public class TicketController { private final TicketService ticketService; + private final ReservationService reservationService; @GetMapping("/members/tickets") public ResponseEntity> selectMyTickets( @LoginMember String memberEmail) { - ItemResult tickets = ticketService.selectMyTicket(memberEmail); + ItemResult tickets = ItemResult.of(List.of()); + ticketService.selectMyTicket(memberEmail); return ResponseEntity.ok().body(tickets); } @PostMapping("/seats/select") public ResponseEntity selectSeat( + @LoginMember String memberEmail, @RequestBody @Valid SeatSelectionRequest seatSelectionRequest) { - ticketService.selectSeat(seatSelectionRequest); + reservationService.selectSeat(memberEmail, seatSelectionRequest); return ResponseEntity.ok().build(); } @PostMapping("/tickets") - public ResponseEntity payTicket( + public ResponseEntity reservationTicket( + @LoginMember String memberEmail, @RequestBody @Valid TicketPaymentRequest ticketPaymentRequest) { - ticketService.reservationTicket(ticketPaymentRequest); + reservationService.reservationTicket(memberEmail, ticketPaymentRequest); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/dto/SeatSelectionRequest.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/dto/SeatSelectionRequest.java index c4f6e2ce..91ca7d1d 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/ticket/dto/SeatSelectionRequest.java +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/dto/SeatSelectionRequest.java @@ -9,5 +9,5 @@ public class SeatSelectionRequest { @NotNull(message = "좌석 ID를 요청하지 않았습니다.") @Min(value = 1, message = "좌석 ID는 1 이상이어야 합니다.") - private Long seatId; + private final Long seatId; } diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/dto/TicketPaymentRequest.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/dto/TicketPaymentRequest.java index b812c0e0..1ca75a61 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/ticket/dto/TicketPaymentRequest.java +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/dto/TicketPaymentRequest.java @@ -9,5 +9,5 @@ public class TicketPaymentRequest { @NotNull(message = "좌석 ID를 요청하지 않았습니다.") @Min(value = 1, message = "좌석 ID는 1 이상이어야 합니다.") - private Long seatId; + private final Long seatId; } diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/CacheTicketService.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/CacheTicketService.java deleted file mode 100644 index 01b721be..00000000 --- a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/CacheTicketService.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.thirdparty.ticketing.domain.ticket.service; - -import com.thirdparty.ticketing.domain.member.repository.MemberRepository; -import com.thirdparty.ticketing.domain.payment.PaymentProcessor; -import com.thirdparty.ticketing.domain.seat.repository.SeatRepository; -import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; -import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; -import com.thirdparty.ticketing.domain.ticket.repository.TicketRepository; - -public class CacheTicketService extends TicketService { - public CacheTicketService( - MemberRepository memberRepository, - TicketRepository ticketRepository, - SeatRepository seatRepository, - PaymentProcessor paymentProcessor) { - super(memberRepository, ticketRepository, seatRepository, paymentProcessor); - } - - @Override - public void selectSeat(SeatSelectionRequest seatSelectionRequest) {} - - @Override - public void reservationTicket(TicketPaymentRequest ticketPaymentRequest) {} -} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/PersistenceTicketService.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/PersistenceTicketService.java deleted file mode 100644 index 820bfec6..00000000 --- a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/PersistenceTicketService.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.thirdparty.ticketing.domain.ticket.service; - -import org.springframework.stereotype.Service; - -import com.thirdparty.ticketing.domain.member.repository.MemberRepository; -import com.thirdparty.ticketing.domain.payment.PaymentProcessor; -import com.thirdparty.ticketing.domain.seat.repository.SeatRepository; -import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; -import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; -import com.thirdparty.ticketing.domain.ticket.repository.TicketRepository; - -@Service -public class PersistenceTicketService extends TicketService { - public PersistenceTicketService( - MemberRepository memberRepository, - TicketRepository ticketRepository, - SeatRepository seatRepository, - PaymentProcessor paymentProcessor) { - super(memberRepository, ticketRepository, seatRepository, paymentProcessor); - } - - @Override - public void selectSeat(SeatSelectionRequest seatSelectionRequest) {} - - @Override - public void reservationTicket(TicketPaymentRequest ticketPaymentRequest) {} -} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/ReservationService.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/ReservationService.java new file mode 100644 index 00000000..3067e4f5 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/ReservationService.java @@ -0,0 +1,10 @@ +package com.thirdparty.ticketing.domain.ticket.service; + +import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; +import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; + +public interface ReservationService { + void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest); + + void reservationTicket(String memberEmail, TicketPaymentRequest ticketPaymentRequest); +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/ReservationTransactionService.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/ReservationTransactionService.java new file mode 100644 index 00000000..e4eb007b --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/ReservationTransactionService.java @@ -0,0 +1,66 @@ +package com.thirdparty.ticketing.domain.ticket.service; + +import org.springframework.transaction.annotation.Transactional; + +import com.thirdparty.ticketing.domain.common.ErrorCode; +import com.thirdparty.ticketing.domain.common.TicketingException; +import com.thirdparty.ticketing.domain.member.Member; +import com.thirdparty.ticketing.domain.member.repository.MemberRepository; +import com.thirdparty.ticketing.domain.payment.PaymentProcessor; +import com.thirdparty.ticketing.domain.payment.dto.PaymentRequest; +import com.thirdparty.ticketing.domain.seat.Seat; +import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; +import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; +import com.thirdparty.ticketing.domain.ticket.service.strategy.LockSeatStrategy; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class ReservationTransactionService implements ReservationService { + private final MemberRepository memberRepository; + private final PaymentProcessor paymentProcessor; + private final LockSeatStrategy lockSeatStrategy; + + @Override + @Transactional + public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) { + Long seatId = seatSelectionRequest.getSeatId(); + + Seat seat = + lockSeatStrategy + .getSeatWithLock(seatId) + .orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_SEAT)); + + Member member = + memberRepository + .findByEmail(memberEmail) + .orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_MEMBER)); + + seat.assignByMember(member); + } + + @Override + @Transactional + public void reservationTicket(String memberEmail, TicketPaymentRequest ticketPaymentRequest) { + Long seatId = ticketPaymentRequest.getSeatId(); + Seat seat = + lockSeatStrategy + .getSeatWithLock(seatId) + .orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_SEAT)); + + Member loginMember = + memberRepository + .findByEmail(memberEmail) + .orElseThrow(() -> new TicketingException(ErrorCode.NOT_FOUND_MEMBER)); + + seat.markAsPendingPayment(); + paymentProcessor.processPayment(new PaymentRequest()); + seat.markAsPaid(); + + if (seat.isAssignedByMember(loginMember)) { + throw new TicketingException(ErrorCode.NOT_SELECTABLE_SEAT); + } + } +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/TicketService.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/TicketService.java index 9db29be7..4c5ae0a7 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/TicketService.java +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/TicketService.java @@ -2,26 +2,23 @@ import java.util.List; +import org.springframework.stereotype.Service; + import com.thirdparty.ticketing.domain.ItemResult; import com.thirdparty.ticketing.domain.common.ErrorCode; import com.thirdparty.ticketing.domain.common.TicketingException; import com.thirdparty.ticketing.domain.member.Member; import com.thirdparty.ticketing.domain.member.repository.MemberRepository; -import com.thirdparty.ticketing.domain.payment.PaymentProcessor; -import com.thirdparty.ticketing.domain.seat.repository.SeatRepository; -import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; import com.thirdparty.ticketing.domain.ticket.dto.TicketElement; -import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; import com.thirdparty.ticketing.domain.ticket.repository.TicketRepository; import lombok.RequiredArgsConstructor; +@Service @RequiredArgsConstructor -public abstract class TicketService { - private final MemberRepository memberRepository; - private final TicketRepository ticketRepository; - private final SeatRepository seatRepository; - private final PaymentProcessor paymentProcessor; +public class TicketService { + protected final MemberRepository memberRepository; + protected final TicketRepository ticketRepository; public ItemResult selectMyTicket(String memberEmail) { Member member = @@ -34,8 +31,4 @@ public ItemResult selectMyTicket(String memberEmail) { return ItemResult.of(tickets); } - - public abstract void selectSeat(SeatSelectionRequest seatSelectionRequest); - - public abstract void reservationTicket(TicketPaymentRequest ticketPaymentRequest); } diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/LettuceReservationServiceProxy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/LettuceReservationServiceProxy.java new file mode 100644 index 00000000..b77a90f4 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/LettuceReservationServiceProxy.java @@ -0,0 +1,57 @@ +package com.thirdparty.ticketing.domain.ticket.service.proxy; + +import com.thirdparty.ticketing.domain.common.ErrorCode; +import com.thirdparty.ticketing.domain.common.LettuceRepository; +import com.thirdparty.ticketing.domain.common.TicketingException; +import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; +import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; +import com.thirdparty.ticketing.domain.ticket.service.ReservationTransactionService; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class LettuceReservationServiceProxy implements ReservationServiceProxy { + private final LettuceRepository lettuceRepository; + private final ReservationTransactionService reservationTransactionService; + + @Override + public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) { + int limit = 5; + try { + while (limit > 0 + && !lettuceRepository.seatLock(seatSelectionRequest.getSeatId().toString())) { + limit -= 1; + Thread.sleep(200); + } + + if (limit > 0) { + reservationTransactionService.selectSeat(memberEmail, seatSelectionRequest); + } + + } catch (InterruptedException e) { + throw new TicketingException(ErrorCode.NOT_SELECTABLE_SEAT); + } finally { + lettuceRepository.unlock(seatSelectionRequest.getSeatId().toString()); + } + } + + @Override + public void reservationTicket(String memberEmail, TicketPaymentRequest ticketPaymentRequest) { + int limit = 5; + try { + while (limit > 0 + && !lettuceRepository.seatLock(ticketPaymentRequest.getSeatId().toString())) { + limit -= 1; + Thread.sleep(300); + } + + if (limit > 0) { + reservationTransactionService.reservationTicket(memberEmail, ticketPaymentRequest); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + lettuceRepository.unlock(ticketPaymentRequest.getSeatId().toString()); + } + } +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/OptimisticReservationServiceProxy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/OptimisticReservationServiceProxy.java new file mode 100644 index 00000000..0268ac76 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/OptimisticReservationServiceProxy.java @@ -0,0 +1,39 @@ +package com.thirdparty.ticketing.domain.ticket.service.proxy; + +import org.hibernate.StaleObjectStateException; +import org.springframework.dao.OptimisticLockingFailureException; + +import com.thirdparty.ticketing.domain.common.ErrorCode; +import com.thirdparty.ticketing.domain.common.TicketingException; +import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; +import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; +import com.thirdparty.ticketing.domain.ticket.service.ReservationTransactionService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class OptimisticReservationServiceProxy implements ReservationServiceProxy { + private final ReservationTransactionService reservationTransactionService; + + @Override + public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) { + try { + reservationTransactionService.selectSeat(memberEmail, seatSelectionRequest); + } catch (OptimisticLockingFailureException | StaleObjectStateException e) { + log.error(e.getMessage(), e); + throw new TicketingException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void reservationTicket(String memberEmail, TicketPaymentRequest ticketPaymentRequest) { + try { + reservationTransactionService.reservationTicket(memberEmail, ticketPaymentRequest); + } catch (OptimisticLockingFailureException | StaleObjectStateException e) { + log.error(e.getMessage(), e); + throw new TicketingException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/PessimisticReservationServiceProxy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/PessimisticReservationServiceProxy.java new file mode 100644 index 00000000..243ae15f --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/PessimisticReservationServiceProxy.java @@ -0,0 +1,40 @@ +package com.thirdparty.ticketing.domain.ticket.service.proxy; + +import jakarta.persistence.LockTimeoutException; + +import org.hibernate.PessimisticLockException; + +import com.thirdparty.ticketing.domain.common.ErrorCode; +import com.thirdparty.ticketing.domain.common.TicketingException; +import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; +import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; +import com.thirdparty.ticketing.domain.ticket.service.ReservationTransactionService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class PessimisticReservationServiceProxy implements ReservationServiceProxy { + private final ReservationTransactionService reservationTransactionService; + + @Override + public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) { + try { + reservationTransactionService.selectSeat(memberEmail, seatSelectionRequest); + } catch (PessimisticLockException | LockTimeoutException e) { + log.error(e.getMessage(), e); + throw new TicketingException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void reservationTicket(String memberEmail, TicketPaymentRequest ticketPaymentRequest) { + try { + reservationTransactionService.reservationTicket(memberEmail, ticketPaymentRequest); + } catch (PessimisticLockException | LockTimeoutException e) { + log.error(e.getMessage(), e); + throw new TicketingException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/RedissonReservationServiceProxy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/RedissonReservationServiceProxy.java new file mode 100644 index 00000000..4d06b3ca --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/RedissonReservationServiceProxy.java @@ -0,0 +1,63 @@ +package com.thirdparty.ticketing.domain.ticket.service.proxy; + +import java.util.concurrent.TimeUnit; + +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.transaction.annotation.Transactional; + +import com.thirdparty.ticketing.domain.common.ErrorCode; +import com.thirdparty.ticketing.domain.common.TicketingException; +import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; +import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; +import com.thirdparty.ticketing.domain.ticket.service.ReservationTransactionService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class RedissonReservationServiceProxy implements ReservationServiceProxy { + private final RedissonClient redissonClient; + private final ReservationTransactionService reservationTransactionService; + + @Override + public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) { + RLock lock = redissonClient.getLock(seatSelectionRequest.getSeatId().toString()); + + try { + if (!lock.tryLock(1, 60, TimeUnit.SECONDS)) { + return; + } + reservationTransactionService.selectSeat(memberEmail, seatSelectionRequest); + } catch (InterruptedException e) { + throw new TicketingException(ErrorCode.NOT_SELECTABLE_SEAT); + } finally { + try { + lock.unlock(); + } catch (IllegalMonitorStateException e) { + log.info("Redisson Lock Already UnLock"); + } + log.info("finished lock on {}", seatSelectionRequest.getSeatId().toString()); + } + } + + @Override + @Transactional + public void reservationTicket(String memberEmail, TicketPaymentRequest ticketPaymentRequest) { + RLock lock = redissonClient.getLock(ticketPaymentRequest.getSeatId().toString()); + + try { + boolean available = lock.tryLock(5, 300, TimeUnit.MICROSECONDS); + if (!available) { + return; + } + + reservationTransactionService.reservationTicket(memberEmail, ticketPaymentRequest); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + lock.unlock(); + } + } +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/ReservationServiceProxy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/ReservationServiceProxy.java new file mode 100644 index 00000000..98810b05 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/proxy/ReservationServiceProxy.java @@ -0,0 +1,5 @@ +package com.thirdparty.ticketing.domain.ticket.service.proxy; + +import com.thirdparty.ticketing.domain.ticket.service.ReservationService; + +public interface ReservationServiceProxy extends ReservationService {} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/LockSeatStrategy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/LockSeatStrategy.java new file mode 100644 index 00000000..5c3679dd --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/LockSeatStrategy.java @@ -0,0 +1,9 @@ +package com.thirdparty.ticketing.domain.ticket.service.strategy; + +import java.util.Optional; + +import com.thirdparty.ticketing.domain.seat.Seat; + +public interface LockSeatStrategy { + Optional getSeatWithLock(Long seatId); +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/NaiveSeatStrategy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/NaiveSeatStrategy.java new file mode 100644 index 00000000..4b7c8b62 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/NaiveSeatStrategy.java @@ -0,0 +1,18 @@ +package com.thirdparty.ticketing.domain.ticket.service.strategy; + +import java.util.Optional; + +import com.thirdparty.ticketing.domain.seat.Seat; +import com.thirdparty.ticketing.domain.seat.repository.SeatRepository; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class NaiveSeatStrategy implements LockSeatStrategy { + private final SeatRepository seatRepository; + + @Override + public Optional getSeatWithLock(Long seatId) { + return seatRepository.findById(seatId); + } +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/OptimisticLockSeatStrategy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/OptimisticLockSeatStrategy.java new file mode 100644 index 00000000..ce16d0bf --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/OptimisticLockSeatStrategy.java @@ -0,0 +1,18 @@ +package com.thirdparty.ticketing.domain.ticket.service.strategy; + +import java.util.Optional; + +import com.thirdparty.ticketing.domain.seat.Seat; +import com.thirdparty.ticketing.domain.seat.repository.SeatRepository; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class OptimisticLockSeatStrategy implements LockSeatStrategy { + private final SeatRepository seatRepository; + + @Override + public Optional getSeatWithLock(Long seatId) { + return seatRepository.findByIdWithOptimistic(seatId); + } +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/PessimisticLockSeatStrategy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/PessimisticLockSeatStrategy.java new file mode 100644 index 00000000..446d8285 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/strategy/PessimisticLockSeatStrategy.java @@ -0,0 +1,18 @@ +package com.thirdparty.ticketing.domain.ticket.service.strategy; + +import java.util.Optional; + +import com.thirdparty.ticketing.domain.seat.Seat; +import com.thirdparty.ticketing.domain.seat.repository.SeatRepository; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class PessimisticLockSeatStrategy implements LockSeatStrategy { + private final SeatRepository seatRepository; + + @Override + public Optional getSeatWithLock(Long seatId) { + return seatRepository.findByIdWithPessimistic(seatId); + } +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/manager/DefaultWaitingManager.java b/src/main/java/com/thirdparty/ticketing/domain/waiting/manager/DefaultWaitingManager.java index 4a516e86..9a815317 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/manager/DefaultWaitingManager.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waiting/manager/DefaultWaitingManager.java @@ -4,9 +4,9 @@ import java.util.List; import java.util.Map; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; import com.thirdparty.ticketing.domain.waiting.room.RunningRoom; import com.thirdparty.ticketing.domain.waiting.room.WaitingRoom; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; public class DefaultWaitingManager extends WaitingManager { diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/manager/WaitingManager.java b/src/main/java/com/thirdparty/ticketing/domain/waiting/manager/WaitingManager.java index bbaff374..18511c54 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/manager/WaitingManager.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waiting/manager/WaitingManager.java @@ -1,8 +1,8 @@ package com.thirdparty.ticketing.domain.waiting.manager; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; import com.thirdparty.ticketing.domain.waiting.room.RunningRoom; import com.thirdparty.ticketing.domain.waiting.room.WaitingRoom; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultRunningRoom.java b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultRunningRoom.java index 4c291a98..8a9cfe51 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultRunningRoom.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultRunningRoom.java @@ -5,7 +5,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; public class DefaultRunningRoom implements RunningRoom { diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingCounter.java b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingCounter.java index 11e20b03..9a17847d 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingCounter.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingCounter.java @@ -4,7 +4,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicLong; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; public class DefaultWaitingCounter implements WaitingCounter { diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingLine.java b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingLine.java index 7fe1a637..f38b8d58 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingLine.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingLine.java @@ -3,7 +3,7 @@ import java.util.*; import java.util.concurrent.ConcurrentLinkedQueue; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; public class DefaultWaitingLine implements WaitingLine { diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingRoom.java b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingRoom.java index bafafd23..c0a66d49 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingRoom.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/DefaultWaitingRoom.java @@ -5,7 +5,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; public class DefaultWaitingRoom extends WaitingRoom { diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/RunningRoom.java b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/RunningRoom.java index 76620cb4..c33a8e5d 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/RunningRoom.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/RunningRoom.java @@ -2,7 +2,7 @@ import java.util.List; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; public interface RunningRoom { boolean contains(WaitingMember waitingMember); diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingCounter.java b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingCounter.java index 8e8c43d4..c386d5ef 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingCounter.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingCounter.java @@ -1,6 +1,6 @@ package com.thirdparty.ticketing.domain.waiting.room; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; public interface WaitingCounter { diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingLine.java b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingLine.java index 072eab48..60b266d4 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingLine.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingLine.java @@ -2,7 +2,7 @@ import java.util.List; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; public interface WaitingLine { diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingRoom.java b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingRoom.java index f9012aae..d02019e6 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingRoom.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waiting/room/WaitingRoom.java @@ -2,7 +2,7 @@ import java.util.List; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/PollingEvent.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/PollingEvent.java new file mode 100644 index 00000000..5278fa6f --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/PollingEvent.java @@ -0,0 +1,11 @@ +package com.thirdparty.ticketing.domain.waitingsystem; + +import com.thirdparty.ticketing.domain.common.Event; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class PollingEvent implements Event { + + private final long performanceId; +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/WaitingAspect.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/WaitingAspect.java similarity index 92% rename from src/main/java/com/thirdparty/ticketing/domain/waiting/WaitingAspect.java rename to src/main/java/com/thirdparty/ticketing/domain/waitingsystem/WaitingAspect.java index 03cdfefb..d10b4d5a 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/WaitingAspect.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/WaitingAspect.java @@ -1,4 +1,4 @@ -package com.thirdparty.ticketing.domain.waiting; +package com.thirdparty.ticketing.domain.waitingsystem; import jakarta.servlet.http.HttpServletRequest; @@ -11,6 +11,7 @@ import org.springframework.web.context.request.ServletRequestAttributes; import com.thirdparty.ticketing.domain.waiting.manager.WaitingManager; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/WaitingController.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/WaitingController.java similarity index 68% rename from src/main/java/com/thirdparty/ticketing/domain/waiting/WaitingController.java rename to src/main/java/com/thirdparty/ticketing/domain/waitingsystem/WaitingController.java index bc4437f4..fdd10ae7 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/WaitingController.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/WaitingController.java @@ -1,4 +1,4 @@ -package com.thirdparty.ticketing.domain.waiting; +package com.thirdparty.ticketing.domain.waitingsystem; import java.util.Map; @@ -6,24 +6,21 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; import com.thirdparty.ticketing.domain.common.LoginMember; -import com.thirdparty.ticketing.domain.waiting.manager.WaitingManager; import lombok.RequiredArgsConstructor; -@RestController @RequiredArgsConstructor @RequestMapping("/api") public class WaitingController { - private final WaitingManager waitingManager; + private final WaitingSystem waitingSystem; @GetMapping("/performances/{performanceId}/wait") public ResponseEntity> getCounts( @LoginMember String email, @PathVariable("performanceId") Long performanceId) { - long remainingCount = waitingManager.getRemainingCount(email, performanceId); + long remainingCount = waitingSystem.getRemainingCount(email, performanceId); return ResponseEntity.ok(Map.of("remainingCount", remainingCount)); } } diff --git a/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/WaitingSystem.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/WaitingSystem.java new file mode 100644 index 00000000..2a6cf6fc --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/WaitingSystem.java @@ -0,0 +1,40 @@ +package com.thirdparty.ticketing.domain.waitingsystem; + +import java.util.Set; + +import com.thirdparty.ticketing.domain.common.EventPublisher; +import com.thirdparty.ticketing.domain.waitingsystem.running.RunningManager; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingManager; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class WaitingSystem { + + private final WaitingManager waitingManager; + private final RunningManager runningManager; + private final EventPublisher eventPublisher; + + public boolean isReadyToHandle(String email, long performanceId) { + return runningManager.isReadyToHandle(email, performanceId); + } + + public void enterWaitingRoom(String email, long performanceId) { + waitingManager.enterWaitingRoom(email, performanceId); + } + + public long getRemainingCount(String email, long performanceId) { + WaitingMember waitingMember = waitingManager.findWaitingMember(email, performanceId); + long runningCount = runningManager.getRunningCount(performanceId); + eventPublisher.publish(new PollingEvent(performanceId)); + return waitingMember.getWaitingCount() - runningCount; + } + + public void moveUserToRunning(long performanceId) { + long availableToRunning = runningManager.getAvailableToRunning(performanceId); + Set waitingMembers = + waitingManager.pullOutMembers(performanceId, availableToRunning); + runningManager.enterRunningRoom(performanceId, waitingMembers); + } +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/running/RunningCounter.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/running/RunningCounter.java new file mode 100644 index 00000000..f760947a --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/running/RunningCounter.java @@ -0,0 +1,3 @@ +package com.thirdparty.ticketing.domain.waitingsystem.running; + +public interface RunningCounter {} diff --git a/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/running/RunningManager.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/running/RunningManager.java new file mode 100644 index 00000000..1b224903 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/running/RunningManager.java @@ -0,0 +1,15 @@ +package com.thirdparty.ticketing.domain.waitingsystem.running; + +import java.util.Set; + +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; + +public interface RunningManager { + boolean isReadyToHandle(String email, long performanceId); + + long getRunningCount(long performanceId); + + long getAvailableToRunning(long performanceId); + + void enterRunningRoom(long performanceId, Set waitingMembers); +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/running/RunningRoom.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/running/RunningRoom.java new file mode 100644 index 00000000..c6990641 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/running/RunningRoom.java @@ -0,0 +1,3 @@ +package com.thirdparty.ticketing.domain.waitingsystem.running; + +public interface RunningRoom {} diff --git a/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingCounter.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingCounter.java new file mode 100644 index 00000000..24646b5a --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingCounter.java @@ -0,0 +1,3 @@ +package com.thirdparty.ticketing.domain.waitingsystem.waiting; + +public interface WaitingCounter {} diff --git a/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingLine.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingLine.java new file mode 100644 index 00000000..3f7dcb1a --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingLine.java @@ -0,0 +1,3 @@ +package com.thirdparty.ticketing.domain.waitingsystem.waiting; + +public interface WaitingLine {} diff --git a/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingManager.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingManager.java new file mode 100644 index 00000000..712de024 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingManager.java @@ -0,0 +1,11 @@ +package com.thirdparty.ticketing.domain.waitingsystem.waiting; + +import java.util.Set; + +public interface WaitingManager { + void enterWaitingRoom(String email, long performanceId); + + WaitingMember findWaitingMember(String email, long performanceId); + + Set pullOutMembers(long performanceId, long availableToRunning); +} diff --git a/src/main/java/com/thirdparty/ticketing/domain/waiting/WaitingMember.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingMember.java similarity index 92% rename from src/main/java/com/thirdparty/ticketing/domain/waiting/WaitingMember.java rename to src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingMember.java index 4ca7ed6f..09af5407 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/waiting/WaitingMember.java +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingMember.java @@ -1,4 +1,4 @@ -package com.thirdparty.ticketing.domain.waiting; +package com.thirdparty.ticketing.domain.waitingsystem.waiting; import java.time.ZonedDateTime; diff --git a/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingRoom.java b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingRoom.java new file mode 100644 index 00000000..8b5e914d --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/waitingsystem/waiting/WaitingRoom.java @@ -0,0 +1,3 @@ +package com.thirdparty.ticketing.domain.waitingsystem.waiting; + +public interface WaitingRoom {} diff --git a/src/main/java/com/thirdparty/ticketing/global/config/RedissonConfig.java b/src/main/java/com/thirdparty/ticketing/global/config/RedissonConfig.java index 7921301c..23ec79bb 100644 --- a/src/main/java/com/thirdparty/ticketing/global/config/RedissonConfig.java +++ b/src/main/java/com/thirdparty/ticketing/global/config/RedissonConfig.java @@ -4,11 +4,9 @@ import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.redisson.config.SingleServerConfig; -import org.redisson.spring.data.connection.RedissonConnectionFactory; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.core.StringRedisTemplate; import lombok.Setter; @@ -26,11 +24,4 @@ public RedissonClient redissonClient() { serverConfig.setAddress("redis://" + host + ":" + port); return Redisson.create(config); } - - @Bean - public StringRedisTemplate redissonRedisTemplate(RedissonClient redissonClient) { - StringRedisTemplate redisTemplate = new StringRedisTemplate(); - redisTemplate.setConnectionFactory(new RedissonConnectionFactory(redissonClient)); - return redisTemplate; - } } diff --git a/src/main/java/com/thirdparty/ticketing/global/config/ReservationServiceContainer.java b/src/main/java/com/thirdparty/ticketing/global/config/ReservationServiceContainer.java new file mode 100644 index 00000000..aa667118 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/global/config/ReservationServiceContainer.java @@ -0,0 +1,79 @@ +package com.thirdparty.ticketing.global.config; + +import org.redisson.api.RedissonClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import com.thirdparty.ticketing.domain.common.LettuceRepository; +import com.thirdparty.ticketing.domain.member.repository.MemberRepository; +import com.thirdparty.ticketing.domain.payment.PaymentProcessor; +import com.thirdparty.ticketing.domain.seat.repository.SeatRepository; +import com.thirdparty.ticketing.domain.ticket.service.*; +import com.thirdparty.ticketing.domain.ticket.service.proxy.*; +import com.thirdparty.ticketing.domain.ticket.service.strategy.LockSeatStrategy; +import com.thirdparty.ticketing.domain.ticket.service.strategy.NaiveSeatStrategy; +import com.thirdparty.ticketing.domain.ticket.service.strategy.OptimisticLockSeatStrategy; +import com.thirdparty.ticketing.domain.ticket.service.strategy.PessimisticLockSeatStrategy; + +@Configuration +public class ReservationServiceContainer { + @Bean + public ReservationService redissonReservationServiceProxy( + RedissonClient redissonClient, + ReservationTransactionService cacheReservationTransactionService) { + return new RedissonReservationServiceProxy( + redissonClient, cacheReservationTransactionService); + } + + @Bean + public ReservationService lettuceReservationServiceProxy( + LettuceRepository lettuceRepository, + ReservationTransactionService cacheReservationTransactionService) { + return new LettuceReservationServiceProxy( + lettuceRepository, cacheReservationTransactionService); + } + + @Primary + @Bean + ReservationService optimisticReservationServiceProxy( + ReservationTransactionService persistenceOptimisticReservationService) { + return new OptimisticReservationServiceProxy(persistenceOptimisticReservationService); + } + + @Bean + ReservationService pessimisticReservationServiceProxy( + ReservationTransactionService persistencePessimisticReservationService) { + return new PessimisticReservationServiceProxy(persistencePessimisticReservationService); + } + + @Bean + public ReservationTransactionService cacheReservationTransactionService( + PaymentProcessor paymentProcessor, + MemberRepository memberRepository, + SeatRepository seatRepository) { + LockSeatStrategy lockSeatStrategy = new NaiveSeatStrategy(seatRepository); + return new ReservationTransactionService( + memberRepository, paymentProcessor, lockSeatStrategy); + } + + @Bean + public ReservationTransactionService persistenceOptimisticReservationService( + PaymentProcessor paymentProcessor, + MemberRepository memberRepository, + SeatRepository seatRepository) { + LockSeatStrategy lockSeatStrategy = new OptimisticLockSeatStrategy(seatRepository); + return new ReservationTransactionService( + memberRepository, paymentProcessor, lockSeatStrategy); + } + + @Bean + public ReservationTransactionService persistencePessimisticReservationService( + PaymentProcessor paymentProcessor, + MemberRepository memberRepository, + SeatRepository seatRepository) { + LockSeatStrategy lockSeatStrategy = new PessimisticLockSeatStrategy(seatRepository); + return new ReservationTransactionService( + memberRepository, paymentProcessor, lockSeatStrategy); + } +} diff --git a/src/main/java/com/thirdparty/ticketing/global/config/WaitingConfig.java b/src/main/java/com/thirdparty/ticketing/global/config/WaitingConfig.java deleted file mode 100644 index 7cb408fe..00000000 --- a/src/main/java/com/thirdparty/ticketing/global/config/WaitingConfig.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.thirdparty.ticketing.global.config; - -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.core.StringRedisTemplate; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.thirdparty.ticketing.domain.waiting.manager.WaitingManager; -import com.thirdparty.ticketing.domain.waiting.room.RunningRoom; -import com.thirdparty.ticketing.domain.waiting.room.WaitingCounter; -import com.thirdparty.ticketing.domain.waiting.room.WaitingLine; -import com.thirdparty.ticketing.domain.waiting.room.WaitingRoom; -import com.thirdparty.ticketing.global.waiting.manager.RedisWaitingManager; -import com.thirdparty.ticketing.global.waiting.room.RedisRunningRoom; -import com.thirdparty.ticketing.global.waiting.room.RedisWaitingCounter; -import com.thirdparty.ticketing.global.waiting.room.RedisWaitingLine; -import com.thirdparty.ticketing.global.waiting.room.RedisWaitingRoom; - -@Configuration -public class WaitingConfig { - - @Bean - public WaitingManager waitingManager( - RunningRoom runningRoom, - WaitingRoom waitingRoom, - @Qualifier("lettuceRedisTemplate") StringRedisTemplate redisTemplate) { - return new RedisWaitingManager(runningRoom, waitingRoom, redisTemplate); - } - - @Bean - public WaitingRoom waitingRoom( - WaitingLine waitingLine, - WaitingCounter waitingCounter, - @Qualifier("lettuceRedisTemplate") StringRedisTemplate redisTemplate, - ObjectMapper objectMapper) { - return new RedisWaitingRoom(waitingLine, waitingCounter, redisTemplate, objectMapper); - } - - @Bean - public WaitingLine waitingLine( - ObjectMapper objectMapper, - @Qualifier("lettuceRedisTemplate") StringRedisTemplate redisTemplate) { - return new RedisWaitingLine(objectMapper, redisTemplate); - } - - @Bean - public WaitingCounter waitingCounter( - @Qualifier("lettuceRedisTemplate") StringRedisTemplate redisTemplate) { - return new RedisWaitingCounter(redisTemplate); - } - - @Bean - public RunningRoom runningRoom( - @Qualifier("lettuceRedisTemplate") StringRedisTemplate redisTemplate) { - return new RedisRunningRoom(redisTemplate); - } -} diff --git a/src/main/java/com/thirdparty/ticketing/global/waiting/manager/RedisWaitingManager.java b/src/main/java/com/thirdparty/ticketing/global/waiting/manager/RedisWaitingManager.java index 8adf95e8..6e12b14f 100644 --- a/src/main/java/com/thirdparty/ticketing/global/waiting/manager/RedisWaitingManager.java +++ b/src/main/java/com/thirdparty/ticketing/global/waiting/manager/RedisWaitingManager.java @@ -3,10 +3,10 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; import com.thirdparty.ticketing.domain.waiting.manager.WaitingManager; import com.thirdparty.ticketing.domain.waiting.room.RunningRoom; import com.thirdparty.ticketing.domain.waiting.room.WaitingRoom; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; public class RedisWaitingManager extends WaitingManager { diff --git a/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisRunningRoom.java b/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisRunningRoom.java index 4c3841aa..da503e59 100644 --- a/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisRunningRoom.java +++ b/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisRunningRoom.java @@ -5,8 +5,8 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.SetOperations; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; import com.thirdparty.ticketing.domain.waiting.room.RunningRoom; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; public class RedisRunningRoom implements RunningRoom { diff --git a/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingCounter.java b/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingCounter.java index 5bb9902d..b54ec958 100644 --- a/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingCounter.java +++ b/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingCounter.java @@ -3,8 +3,8 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; import com.thirdparty.ticketing.domain.waiting.room.WaitingCounter; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; public class RedisWaitingCounter implements WaitingCounter { diff --git a/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingLine.java b/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingLine.java index bcefe4fb..9ada35dd 100644 --- a/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingLine.java +++ b/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingLine.java @@ -6,8 +6,8 @@ import org.springframework.data.redis.core.ZSetOperations; import com.fasterxml.jackson.databind.ObjectMapper; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; import com.thirdparty.ticketing.domain.waiting.room.WaitingLine; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; import com.thirdparty.ticketing.global.waiting.ObjectMapperUtils; public class RedisWaitingLine implements WaitingLine { diff --git a/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingRoom.java b/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingRoom.java index 801cb6b1..23830250 100644 --- a/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingRoom.java +++ b/src/main/java/com/thirdparty/ticketing/global/waiting/room/RedisWaitingRoom.java @@ -7,10 +7,10 @@ import org.springframework.data.redis.core.RedisTemplate; import com.fasterxml.jackson.databind.ObjectMapper; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; import com.thirdparty.ticketing.domain.waiting.room.WaitingCounter; import com.thirdparty.ticketing.domain.waiting.room.WaitingLine; import com.thirdparty.ticketing.domain.waiting.room.WaitingRoom; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; import com.thirdparty.ticketing.global.waiting.ObjectMapperUtils; public class RedisWaitingRoom extends WaitingRoom { diff --git a/src/main/java/com/thirdparty/ticketing/global/waitingsystem/running/RedisRunningManager.java b/src/main/java/com/thirdparty/ticketing/global/waitingsystem/running/RedisRunningManager.java new file mode 100644 index 00000000..5b0cd549 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/global/waitingsystem/running/RedisRunningManager.java @@ -0,0 +1,32 @@ +package com.thirdparty.ticketing.global.waitingsystem.running; + +import java.util.Set; + +import com.thirdparty.ticketing.domain.waitingsystem.running.RunningManager; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class RedisRunningManager implements RunningManager { + + private final RedisRunningRoom runningRoom; + + @Override + public boolean isReadyToHandle(String email, long performanceId) { + return runningRoom.contains(email, performanceId); + } + + @Override + public long getRunningCount(long performanceId) { + return 0; + } + + @Override + public long getAvailableToRunning(long performanceId) { + return 0; + } + + @Override + public void enterRunningRoom(long performanceId, Set waitingMembers) {} +} diff --git a/src/main/java/com/thirdparty/ticketing/global/waitingsystem/running/RedisRunningRoom.java b/src/main/java/com/thirdparty/ticketing/global/waitingsystem/running/RedisRunningRoom.java new file mode 100644 index 00000000..14a66ec1 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/global/waitingsystem/running/RedisRunningRoom.java @@ -0,0 +1,25 @@ +package com.thirdparty.ticketing.global.waitingsystem.running; + +import org.springframework.data.redis.core.SetOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import com.thirdparty.ticketing.domain.waitingsystem.running.RunningRoom; + +public class RedisRunningRoom implements RunningRoom { + + private static final String RUNNING_ROOM_KEY = "running_room:"; + + private final SetOperations runningRoom; + + public RedisRunningRoom(StringRedisTemplate redisTemplate) { + runningRoom = redisTemplate.opsForSet(); + } + + public boolean contains(String email, long performanceId) { + return runningRoom.isMember(getRunningRoomKey(performanceId), email); + } + + private String getRunningRoomKey(long performanceId) { + return RUNNING_ROOM_KEY + performanceId; + } +} diff --git a/src/test/java/com/thirdparty/ticketing/domain/ticket/service/CacheReservationTest.java b/src/test/java/com/thirdparty/ticketing/domain/ticket/service/CacheReservationTest.java new file mode 100644 index 00000000..4b0db60c --- /dev/null +++ b/src/test/java/com/thirdparty/ticketing/domain/ticket/service/CacheReservationTest.java @@ -0,0 +1,157 @@ +package com.thirdparty.ticketing.domain.ticket.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; + +import com.thirdparty.ticketing.domain.common.LettuceRepository; +import com.thirdparty.ticketing.domain.common.TicketingException; +import com.thirdparty.ticketing.domain.member.Member; +import com.thirdparty.ticketing.domain.member.MemberRole; +import com.thirdparty.ticketing.domain.member.repository.MemberRepository; +import com.thirdparty.ticketing.domain.performance.Performance; +import com.thirdparty.ticketing.domain.performance.repository.PerformanceRepository; +import com.thirdparty.ticketing.domain.seat.Seat; +import com.thirdparty.ticketing.domain.seat.SeatGrade; +import com.thirdparty.ticketing.domain.seat.SeatStatus; +import com.thirdparty.ticketing.domain.seat.repository.SeatGradeRepository; +import com.thirdparty.ticketing.domain.seat.repository.SeatRepository; +import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; +import com.thirdparty.ticketing.domain.zone.Zone; +import com.thirdparty.ticketing.domain.zone.repository.ZoneRepository; +import com.thirdparty.ticketing.support.TestContainerStarter; + +@SpringBootTest +public class CacheReservationTest extends TestContainerStarter { + + @Autowired private SeatRepository seatRepository; + + @Autowired private MemberRepository memberRepository; + + @Autowired private ZoneRepository zoneRepository; + + @Autowired private SeatGradeRepository seatGradeRepository; + + @Autowired private PerformanceRepository performanceRepository; + + @Autowired private LettuceRepository lettuceRepository; + + @Autowired private RedissonClient redissonClient; + + @Autowired + @Qualifier("lettuceReservationServiceProxy") + private ReservationService lettuceCacheTicketService; + + @Autowired + @Qualifier("redissonReservationServiceProxy") + private ReservationService redissonReservationServiceProxy; + + private List members; + private Seat seat; + private Zone zone; + private SeatGrade seatGrade; + private Performance performance; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + members = + memberRepository.saveAllAndFlush( + List.of( + new Member("member1@example.com", "password", MemberRole.USER), + new Member("member2@example.com", "password", MemberRole.USER), + new Member("member3@example.com", "password", MemberRole.USER), + new Member("member4@example.com", "password", MemberRole.USER), + new Member("member5@example.com", "password", MemberRole.USER))); + + performance = + performanceRepository.saveAndFlush( + new Performance( + 1L, + "Phantom of the Opera", + "Broadway Theater", + ZonedDateTime.now().plusDays(10))); + + seatGrade = + seatGradeRepository.saveAndFlush(new SeatGrade(1L, performance, 20000L, "Regular")); + zone = zoneRepository.saveAndFlush(new Zone(1L, performance, "R")); + + seat = + seatRepository.saveAndFlush( + Seat.builder() + .zone(zone) + .seatGrade(seatGrade) + .seatCode("R") + .seatStatus(SeatStatus.SELECTABLE) + .build()); + } + + @AfterEach + void breakUp() { + seatRepository.deleteAll(); + zoneRepository.deleteAll(); + seatGradeRepository.deleteAll(); + performanceRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Test + public void testConcurrentSeatSelectionWithLettuce() throws InterruptedException { + runConcurrentSeatSelectionTest(lettuceCacheTicketService); + } + + @Test + public void testConcurrentSeatSelectionWithRedisson() throws InterruptedException { + runConcurrentSeatSelectionTest(redissonReservationServiceProxy); + } + + private void runConcurrentSeatSelectionTest(ReservationService reservationServiceProxy) + throws InterruptedException { + + int threadCount = members.size(); + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successfulSelections = new AtomicInteger(0); + + for (Member member : members) { + // 각 멤버에 대해 작업을 스레드 풀에 제출 + executorService.submit( + () -> { + try { + // 스레드 풀에서 병렬로 실행되는 작업 + SeatSelectionRequest seatSelectionRequest = + new SeatSelectionRequest(seat.getSeatId()); + reservationServiceProxy.selectSeat( + member.getEmail(), seatSelectionRequest); + successfulSelections.incrementAndGet(); + } catch (TicketingException e) { + } catch (Exception e) { + } finally { + // latch 카운트 감소, 스레드 완료 시 호출 + latch.countDown(); + } + }); + } + + latch.await(); + + Seat reservedSeat = seatRepository.findById(seat.getSeatId()).orElseThrow(); + assertThat(reservedSeat.getMember()).isNotNull(); + } +} diff --git a/src/test/java/com/thirdparty/ticketing/domain/ticket/service/PersistenceReservationTest.java b/src/test/java/com/thirdparty/ticketing/domain/ticket/service/PersistenceReservationTest.java new file mode 100644 index 00000000..273728ec --- /dev/null +++ b/src/test/java/com/thirdparty/ticketing/domain/ticket/service/PersistenceReservationTest.java @@ -0,0 +1,220 @@ +package com.thirdparty.ticketing.domain.ticket.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; + +import com.thirdparty.ticketing.domain.common.TicketingException; +import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; +import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; +import com.thirdparty.ticketing.support.TestContainerStarter; + +@SpringBootTest +public class PersistenceReservationTest extends TestContainerStarter { + private static final Logger log = LoggerFactory.getLogger(PersistenceReservationTest.class); + + @Autowired + @Qualifier("optimisticReservationServiceProxy") + private ReservationService optimisticReservationService; + + @Autowired + @Qualifier("pessimisticReservationServiceProxy") + private ReservationService pessimisticReservationService; + + private String memberEmail = "test@gmail.com"; + private Long seatId = 1L; + + @Nested + @DisplayName("티켓 예매를 위해 좌석을 선택할 때") + @Sql( + scripts = "/db/select-seat-test.sql", + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + class SelectSeatTest { + @Nested + @DisplayName("낙관 락을 사용하면") + class OptimisticLockTest { + @Test + @DisplayName("여러개의 동시 요청 중 한 명만 좌석을 성공적으로 선택해야 한다.") + void selectSeat_optimistic() throws InterruptedException { + selectSeat_ConcurrencyTest(optimisticReservationService); + } + } + + @Nested + @DisplayName("비관 락을 사용하면") + class PessimisticLockTest { + @Test + @DisplayName("여러개의 동시 요청 중 한 명만 좌석을 성공적으로 선택해야 한다.") + void selectSeat_optimistic() throws InterruptedException { + selectSeat_ConcurrencyTest(pessimisticReservationService); + } + } + + public void selectSeat_ConcurrencyTest(ReservationService reservationService) + throws InterruptedException { + // Given + int numRequests = 100; + CountDownLatch latch = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(numRequests); + + AtomicInteger successfulSelections = new AtomicInteger(0); + AtomicInteger failedSelections = new AtomicInteger(0); + + // when + IntStream.range(0, numRequests) + .forEach( + i -> + executor.submit( + () -> + selectSeatTask( + reservationService, + latch, + seatId, + successfulSelections, + failedSelections))); + + latch.countDown(); // 모든 스레드가 동시에 실행되도록 설정 + + executor.shutdown(); + executor.awaitTermination(10, TimeUnit.SECONDS); + + // Then + assertThat(successfulSelections.get()).isEqualTo(1); + assertThat(failedSelections.get()).isEqualTo(numRequests - 1); + } + + private void selectSeatTask( + ReservationService reservationService, + CountDownLatch latch, + Long seatId, + AtomicInteger successfulSelections, + AtomicInteger failedSelections) { + + setUpAuthentication(); + try { + latch.await(); + try { + reservationService.selectSeat(memberEmail, new SeatSelectionRequest(seatId)); + successfulSelections.incrementAndGet(); + } catch (TicketingException e) { + log.error(e.getMessage(), e); + failedSelections.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void setUpAuthentication() { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + "test@gmail.com", "testpassword", List.of()); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + } + } + + @Nested + @DisplayName("티켓 예매 할 때 결제 시도 시") + @Sql( + scripts = "/db/reservation-test.sql", + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)) + class reservationTicketTest { + @Nested + @DisplayName("낙관 락을 사용하면") + class OptimisticLockTest { + @Test + @DisplayName("하나의 결제만 성공한다.") + void reservationTicket_optimistic() throws InterruptedException { + reservationTicket_ConcurrencyTest(optimisticReservationService); + } + } + + @Nested + @DisplayName("비관 락을 사용하면") + class PessimisticLockTest { + @Test + @DisplayName("하나의 결제만 성공한다.") + void reservationTicket_pessimistic() throws InterruptedException { + reservationTicket_ConcurrencyTest(pessimisticReservationService); + } + } + + @DisplayName("동시에 여러 요청이 오면 하나의 요청만 성공한다.") + void reservationTicket_ConcurrencyTest(ReservationService reservationService) + throws InterruptedException { + // Given + int numRequests = 100; + CountDownLatch latch = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(numRequests); + + AtomicInteger successfulReservations = new AtomicInteger(0); + AtomicInteger failedReservations = new AtomicInteger(0); + + // When + IntStream.range(0, numRequests) + .forEach( + i -> + executor.submit( + () -> { + try { + latch.await(); // 동기화된 시작 + reservationTicketTask( + reservationService, + memberEmail, + seatId, + successfulReservations, + failedReservations); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + })); + + latch.countDown(); // 모든 스레드가 동시에 실행되도록 설정 + + executor.shutdown(); + executor.awaitTermination(10, TimeUnit.SECONDS); + + // Then + assertThat(successfulReservations.get()).isEqualTo(1); + assertThat(failedReservations.get()).isEqualTo(numRequests - 1); + } + + private void reservationTicketTask( + ReservationService reservationService, + String memberEmail, + Long seatId, + AtomicInteger successfulReservations, + AtomicInteger failedReservations) { + try { + reservationService.reservationTicket(memberEmail, new TicketPaymentRequest(seatId)); + successfulReservations.incrementAndGet(); + } catch (Exception e) { + log.error(e.getMessage(), e); + failedReservations.incrementAndGet(); + } + } + } +} diff --git a/src/test/java/com/thirdparty/ticketing/domain/waiting/manager/DefaultWaitingManagerTest.java b/src/test/java/com/thirdparty/ticketing/domain/waiting/manager/DefaultWaitingManagerTest.java index 6c194d37..73ec8eed 100644 --- a/src/test/java/com/thirdparty/ticketing/domain/waiting/manager/DefaultWaitingManagerTest.java +++ b/src/test/java/com/thirdparty/ticketing/domain/waiting/manager/DefaultWaitingManagerTest.java @@ -9,11 +9,11 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; import com.thirdparty.ticketing.domain.waiting.room.DefaultRunningRoom; import com.thirdparty.ticketing.domain.waiting.room.DefaultWaitingCounter; import com.thirdparty.ticketing.domain.waiting.room.DefaultWaitingLine; import com.thirdparty.ticketing.domain.waiting.room.DefaultWaitingRoom; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; class DefaultWaitingManagerTest { diff --git a/src/test/java/com/thirdparty/ticketing/global/waiting/RedisRunningRoomTest.java b/src/test/java/com/thirdparty/ticketing/global/waiting/RedisRunningRoomTest.java index dd5acc11..4fb4c471 100644 --- a/src/test/java/com/thirdparty/ticketing/global/waiting/RedisRunningRoomTest.java +++ b/src/test/java/com/thirdparty/ticketing/global/waiting/RedisRunningRoomTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -12,11 +13,12 @@ import org.springframework.data.redis.core.SetOperations; import org.springframework.data.redis.core.StringRedisTemplate; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; import com.thirdparty.ticketing.global.waiting.room.RedisRunningRoom; import com.thirdparty.ticketing.support.TestContainerStarter; @SpringBootTest +@Disabled("구조 변경으로 더 이상 사용하지 않음") class RedisRunningRoomTest extends TestContainerStarter { @Autowired private RedisRunningRoom runningRoom; diff --git a/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingCounterTest.java b/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingCounterTest.java index c9aa5d9d..aefcb4d8 100644 --- a/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingCounterTest.java +++ b/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingCounterTest.java @@ -9,6 +9,7 @@ import java.util.concurrent.Executors; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -17,11 +18,12 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.StringRedisTemplate; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; import com.thirdparty.ticketing.global.waiting.room.RedisWaitingCounter; import com.thirdparty.ticketing.support.TestContainerStarter; @SpringBootTest +@Disabled("구조 변경으로 더 이상 사용하지 않음") class RedisWaitingCounterTest extends TestContainerStarter { @Autowired private RedisWaitingCounter waitingCounter; diff --git a/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingLineTest.java b/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingLineTest.java index 91e32867..ea203f63 100644 --- a/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingLineTest.java +++ b/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingLineTest.java @@ -8,6 +8,7 @@ import java.util.Set; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -19,11 +20,12 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; import com.thirdparty.ticketing.global.waiting.room.RedisWaitingLine; import com.thirdparty.ticketing.support.TestContainerStarter; @SpringBootTest +@Disabled("구조 변경으로 더 이상 사용하지 않음") class RedisWaitingLineTest extends TestContainerStarter { private static final String WAITING_LINE_KEY = "waiting_line:"; diff --git a/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingManagerTest.java b/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingManagerTest.java index a7144d84..cbcb5719 100644 --- a/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingManagerTest.java +++ b/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingManagerTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -12,11 +13,12 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; import com.thirdparty.ticketing.global.waiting.manager.RedisWaitingManager; import com.thirdparty.ticketing.support.TestContainerStarter; @SpringBootTest +@Disabled("구조 변경으로 더 이상 사용하지 않음") class RedisWaitingManagerTest extends TestContainerStarter { @Autowired private RedisWaitingManager waitingManager; diff --git a/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingRoomTest.java b/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingRoomTest.java index 227575d0..58cd3a8e 100644 --- a/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingRoomTest.java +++ b/src/test/java/com/thirdparty/ticketing/global/waiting/RedisWaitingRoomTest.java @@ -21,11 +21,12 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.thirdparty.ticketing.domain.waiting.WaitingMember; +import com.thirdparty.ticketing.domain.waitingsystem.waiting.WaitingMember; import com.thirdparty.ticketing.global.waiting.room.RedisWaitingRoom; import com.thirdparty.ticketing.support.TestContainerStarter; @SpringBootTest +@Disabled("구조 변경으로 더 이상 사용하지 않음") class RedisWaitingRoomTest extends TestContainerStarter { @Autowired private RedisWaitingRoom waitingRoom; diff --git a/src/test/java/com/thirdparty/ticketing/global/waitingsystem/TestRedisConfig.java b/src/test/java/com/thirdparty/ticketing/global/waitingsystem/TestRedisConfig.java new file mode 100644 index 00000000..95aaf352 --- /dev/null +++ b/src/test/java/com/thirdparty/ticketing/global/waitingsystem/TestRedisConfig.java @@ -0,0 +1,22 @@ +package com.thirdparty.ticketing.global.waitingsystem; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +import com.thirdparty.ticketing.global.waitingsystem.running.RedisRunningRoom; + +@TestConfiguration +public class TestRedisConfig { + + @Qualifier("lettuceRedisTemplate") + @Autowired + private StringRedisTemplate redisTemplate; + + @Bean + public RedisRunningRoom runningRoom() { + return new RedisRunningRoom(redisTemplate); + } +} diff --git a/src/test/java/com/thirdparty/ticketing/global/waitingsystem/running/RedisRunningRoomTest.java b/src/test/java/com/thirdparty/ticketing/global/waitingsystem/running/RedisRunningRoomTest.java new file mode 100644 index 00000000..3ba96586 --- /dev/null +++ b/src/test/java/com/thirdparty/ticketing/global/waitingsystem/running/RedisRunningRoomTest.java @@ -0,0 +1,94 @@ +package com.thirdparty.ticketing.global.waitingsystem.running; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import com.thirdparty.ticketing.global.waitingsystem.TestRedisConfig; +import com.thirdparty.ticketing.support.TestContainerStarter; + +@SpringBootTest +@Import(TestRedisConfig.class) +class RedisRunningRoomTest extends TestContainerStarter { + + @Autowired private RedisRunningRoom runningRoom; + + @Qualifier("lettuceRedisTemplate") + @Autowired + private StringRedisTemplate redisTemplate; + + @BeforeEach + void setUp() { + redisTemplate.getConnectionFactory().getConnection().serverCommands().flushAll(); + } + + private String getRunningRoomKey(long performanceId) { + return "running_room:" + performanceId; + } + + @Nested + @DisplayName("러닝룸에 사용자가 있는지 확인했을 때") + class ContainsTest { + + private SetOperations rawRunningRoom; + + @BeforeEach + void setUp() { + rawRunningRoom = redisTemplate.opsForSet(); + } + + @Test + @DisplayName("사용자가 포함되어 있다면 true를 반환한다.") + void true_WhenMemberContains() { + // given + long performanceId = 1; + String email = "email@email.com"; + rawRunningRoom.add(getRunningRoomKey(performanceId), email); + + // when + boolean contains = runningRoom.contains(email, performanceId); + + // then + assertThat(contains).isTrue(); + } + + @Test + @DisplayName("사용자가 포함되어 있지 않다면 false를 반환한다.") + void false_WhenMemberDoesNotContain() { + // given + long performanceId = 1; + String email = "email@email.com"; + + // when + boolean contains = runningRoom.contains(email, performanceId); + + // then + assertThat(contains).isFalse(); + } + + @Test + @DisplayName("서로 다른 공연은 러닝룸을 공유하지 않는다.") + void doesNotShareRunningRoom_BetweenPerformances() { + // given + long performanceIdA = 1; + long performanceIdB = 2; + String email = "email@email.com"; + rawRunningRoom.add(getRunningRoomKey(performanceIdA), email); + + // when + boolean contains = runningRoom.contains(email, performanceIdB); + + // then + assertThat(contains).isFalse(); + } + } +} diff --git a/src/test/resources/db/reservation-test.sql b/src/test/resources/db/reservation-test.sql new file mode 100644 index 00000000..86bb902c --- /dev/null +++ b/src/test/resources/db/reservation-test.sql @@ -0,0 +1,31 @@ +DELETE +FROM seat; +DELETE +FROM seat_grade; +DELETE +FROM zone; +DELETE +FROM performance; +DELETE +FROM member; + +-- Member 테이블에 데이터 삽입 +INSERT INTO member (member_id, email, password, member_role, created_at, updated_at) +VALUES (1, 'test@gmail.com', 'testpassword', 'USER', NOW(), NOW()); + +-- Performance 테이블에 데이터 삽입 +INSERT INTO performance (performance_id, performance_name, performance_place, performance_showtime, created_at, + updated_at) +VALUES (1, '공연', '장소', '2024-08-23 14:30:00', NOW(), NOW()); + +-- Zone 테이블에 데이터 삽입 +INSERT INTO zone (zone_id, zone_name, performance_id, created_at, updated_at) +VALUES (1, 'VIP', 1, NOW(), NOW()); + +-- SeatGrade 테이블에 데이터 삽입 +INSERT INTO seat_grade (seat_grade_id, grade_name, price, performance_id, created_at, updated_at) +VALUES (1, 'Grade1', 10000, 1, NOW(), NOW()); + +-- Seat 테이블에 데이터 삽입 +INSERT INTO seat (seat_id, seat_code, seat_status, member_id, zone_id, seat_grade_id, version, created_at, updated_at) +VALUES (1, 'A01', 'SELECTED', 1, 1, 1, 0, NOW(), NOW()); diff --git a/src/test/resources/db/select-seat-test.sql b/src/test/resources/db/select-seat-test.sql new file mode 100644 index 00000000..b75ac6b1 --- /dev/null +++ b/src/test/resources/db/select-seat-test.sql @@ -0,0 +1,31 @@ +DELETE +FROM seat; +DELETE +FROM seat_grade; +DELETE +FROM zone; +DELETE +FROM performance; +DELETE +FROM member; + +-- Member 테이블에 데이터 삽입 +INSERT INTO member (member_id, email, password, member_role, created_at, updated_at) +VALUES (1, 'test@gmail.com', 'testpassword', 'USER', NOW(), NOW()); + +-- Performance 테이블에 데이터 삽입 +INSERT INTO performance (performance_id, performance_name, performance_place, performance_showtime, created_at, + updated_at) +VALUES (1, '공연', '장소', '2024-08-23 14:30:00', NOW(), NOW()); + +-- Zone 테이블에 데이터 삽입 +INSERT INTO zone (zone_id, zone_name, performance_id, created_at, updated_at) +VALUES (1, 'VIP', 1, NOW(), NOW()); + +-- SeatGrade 테이블에 데이터 삽입 +INSERT INTO seat_grade (seat_grade_id, grade_name, price, performance_id, created_at, updated_at) +VALUES (1, 'Grade1', 10000, 1, NOW(), NOW()); + +-- Seat 테이블에 데이터 삽입 +INSERT INTO seat (seat_id, seat_code, seat_status, zone_id, seat_grade_id, version, created_at, updated_at) +VALUES (1, 'A01', 'SELECTABLE', 1, 1, 0, NOW(), NOW());