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..e721c2dd 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 */ 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..faeacbba 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; @@ -43,6 +45,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 +55,30 @@ 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); + } + 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..3ca6969c 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,8 +1,13 @@ 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; @@ -11,4 +16,12 @@ @Repository public interface SeatRepository extends JpaRepository { List findByZone(Zone zone); + + @Query("SELECT s FROM Seat as s WHERE s.id = :seatId") + @Lock(LockModeType.OPTIMISTIC) + Optional findByIdWithOptimistic(Long seatId); + + @Query("SELECT s FROM Seat as s WHERE s.id = :seatId") + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findByIdWithPessimistic(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..764cd53a 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 @@ -2,6 +2,7 @@ import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -15,13 +16,15 @@ import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; import com.thirdparty.ticketing.domain.ticket.service.TicketService; -import lombok.RequiredArgsConstructor; - @RestController("/api") -@RequiredArgsConstructor public class TicketController { + private final TicketService ticketService; + public TicketController(@Qualifier("lettuceCacheTicketService") TicketService ticketService) { + this.ticketService = ticketService; + } + @GetMapping("/members/tickets") public ResponseEntity> selectMyTickets( @LoginMember String memberEmail) { @@ -31,15 +34,17 @@ public ResponseEntity> selectMyTickets( @PostMapping("/seats/select") public ResponseEntity selectSeat( + @LoginMember String memberEmail, @RequestBody @Valid SeatSelectionRequest seatSelectionRequest) { - ticketService.selectSeat(seatSelectionRequest); + ticketService.selectSeat(memberEmail, seatSelectionRequest); return ResponseEntity.ok().build(); } @PostMapping("/tickets") public ResponseEntity payTicket( + @LoginMember String memberEmail, @RequestBody @Valid TicketPaymentRequest ticketPaymentRequest) { - ticketService.reservationTicket(ticketPaymentRequest); + ticketService.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/policy/LockSeatStrategy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/policy/LockSeatStrategy.java new file mode 100644 index 00000000..1f3914be --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/policy/LockSeatStrategy.java @@ -0,0 +1,9 @@ +package com.thirdparty.ticketing.domain.ticket.policy; + +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/policy/OptimisticLockSeatStrategy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/policy/OptimisticLockSeatStrategy.java new file mode 100644 index 00000000..0ef98b8d --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/policy/OptimisticLockSeatStrategy.java @@ -0,0 +1,23 @@ +package com.thirdparty.ticketing.domain.ticket.policy; + +import java.util.Optional; + +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import com.thirdparty.ticketing.domain.seat.Seat; +import com.thirdparty.ticketing.domain.seat.repository.SeatRepository; + +import lombok.RequiredArgsConstructor; + +@Primary +@Component +@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/policy/PessimisticLockSeatStrategy.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/policy/PessimisticLockSeatStrategy.java new file mode 100644 index 00000000..3a259fc8 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/policy/PessimisticLockSeatStrategy.java @@ -0,0 +1,21 @@ +package com.thirdparty.ticketing.domain.ticket.policy; + +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.thirdparty.ticketing.domain.seat.Seat; +import com.thirdparty.ticketing.domain.seat.repository.SeatRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@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/ticket/service/CacheTicketService.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/CacheTicketService.java index 01b721be..a6500a7f 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/CacheTicketService.java +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/CacheTicketService.java @@ -17,8 +17,8 @@ public CacheTicketService( } @Override - public void selectSeat(SeatSelectionRequest seatSelectionRequest) {} + public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) {} @Override - public void reservationTicket(TicketPaymentRequest ticketPaymentRequest) {} + public void reservationTicket(String memberEmail, TicketPaymentRequest ticketPaymentRequest) {} } diff --git a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/LettuceCacheTicketService.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/LettuceCacheTicketService.java new file mode 100644 index 00000000..cbe986f1 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/LettuceCacheTicketService.java @@ -0,0 +1,69 @@ +package com.thirdparty.ticketing.domain.ticket.service; + +import org.springframework.stereotype.Service; + +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.dto.SeatSelectionRequest; +import com.thirdparty.ticketing.domain.ticket.dto.TicketPaymentRequest; +import com.thirdparty.ticketing.domain.ticket.repository.TicketRepository; + +@Service +public class LettuceCacheTicketService extends TicketService { + private final CacheTicketService cacheTicketService; + private final LettuceRepository lettuceRepository; + + public LettuceCacheTicketService( + MemberRepository memberRepository, + TicketRepository ticketRepository, + SeatRepository seatRepository, + PaymentProcessor paymentProcessor, + CacheTicketService cacheTicketService, + LettuceRepository lettuceRepository) { + super(memberRepository, ticketRepository, seatRepository, paymentProcessor); + this.cacheTicketService = cacheTicketService; + this.lettuceRepository = lettuceRepository; + } + + @Override + public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) { + int limit = 5; + try { + while (limit > 0 + && !lettuceRepository.seatLock(seatSelectionRequest.getSeatId().toString())) { + limit -= 1; + Thread.sleep(300); + } + + if (limit > 0) { + cacheTicketService.selectSeat(memberEmail, seatSelectionRequest); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } 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) { + cacheTicketService.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/PersistenceTicketService.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/PersistenceTicketService.java index 820bfec6..6aa11fc2 100644 --- a/src/main/java/com/thirdparty/ticketing/domain/ticket/service/PersistenceTicketService.java +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/PersistenceTicketService.java @@ -1,27 +1,81 @@ package com.thirdparty.ticketing.domain.ticket.service; +import jakarta.persistence.OptimisticLockException; + import org.springframework.stereotype.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.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.policy.LockSeatStrategy; import com.thirdparty.ticketing.domain.ticket.repository.TicketRepository; +import lombok.extern.slf4j.Slf4j; + @Service +@Slf4j public class PersistenceTicketService extends TicketService { + private final LockSeatStrategy lockSeatStrategy; + public PersistenceTicketService( MemberRepository memberRepository, TicketRepository ticketRepository, SeatRepository seatRepository, - PaymentProcessor paymentProcessor) { + PaymentProcessor paymentProcessor, + LockSeatStrategy lockSeatStrategy) { super(memberRepository, ticketRepository, seatRepository, paymentProcessor); + this.lockSeatStrategy = lockSeatStrategy; } @Override - public void selectSeat(SeatSelectionRequest seatSelectionRequest) {} + @Transactional + public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) { + try { + 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); + } catch (OptimisticLockException e) { + throw new TicketingException(ErrorCode.NOT_SELECTABLE_SEAT); + } + } @Override - public void reservationTicket(TicketPaymentRequest ticketPaymentRequest) {} + @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/RedissonCacheTicketService.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/RedissonCacheTicketService.java new file mode 100644 index 00000000..adb7deb8 --- /dev/null +++ b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/RedissonCacheTicketService.java @@ -0,0 +1,71 @@ +package com.thirdparty.ticketing.domain.ticket.service; + +import java.util.concurrent.TimeUnit; + +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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 RedissonCacheTicketService extends TicketService { + + private final CacheTicketService cacheTicketService; + private final RedissonClient redissonClient; + + public RedissonCacheTicketService( + MemberRepository memberRepository, + TicketRepository ticketRepository, + SeatRepository seatRepository, + CacheTicketService cacheTicketService, + RedissonClient redissonClient, + PaymentProcessor paymentProcessor) { + super(memberRepository, ticketRepository, seatRepository, paymentProcessor); + this.cacheTicketService = cacheTicketService; + this.redissonClient = redissonClient; + } + + @Override + public void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest) { + RLock lock = redissonClient.getLock(seatSelectionRequest.getSeatId().toString()); + + try { + boolean available = lock.tryLock(5, 300, TimeUnit.MICROSECONDS); + if (!available) { + return; + } + + cacheTicketService.selectSeat(memberEmail, seatSelectionRequest); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + lock.unlock(); + } + } + + @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; + } + + cacheTicketService.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/TicketService.java b/src/main/java/com/thirdparty/ticketing/domain/ticket/service/TicketService.java index 9db29be7..ddf7d1c4 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 @@ -18,10 +18,10 @@ @RequiredArgsConstructor public abstract class TicketService { - private final MemberRepository memberRepository; - private final TicketRepository ticketRepository; - private final SeatRepository seatRepository; - private final PaymentProcessor paymentProcessor; + protected final MemberRepository memberRepository; + protected final TicketRepository ticketRepository; + protected final SeatRepository seatRepository; + protected final PaymentProcessor paymentProcessor; public ItemResult selectMyTicket(String memberEmail) { Member member = @@ -35,7 +35,8 @@ public ItemResult selectMyTicket(String memberEmail) { return ItemResult.of(tickets); } - public abstract void selectSeat(SeatSelectionRequest seatSelectionRequest); + public abstract void selectSeat(String memberEmail, SeatSelectionRequest seatSelectionRequest); - public abstract void reservationTicket(TicketPaymentRequest ticketPaymentRequest); + public abstract void reservationTicket( + String memberEmail, TicketPaymentRequest ticketPaymentRequest); } 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/test/java/com/thirdparty/ticketing/domain/ticket/PersistenceTicketServiceTest.java b/src/test/java/com/thirdparty/ticketing/domain/ticket/PersistenceTicketServiceTest.java new file mode 100644 index 00000000..85876570 --- /dev/null +++ b/src/test/java/com/thirdparty/ticketing/domain/ticket/PersistenceTicketServiceTest.java @@ -0,0 +1,198 @@ +package com.thirdparty.ticketing.domain.ticket; + +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.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +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.payment.SimulatedPaymentProcessor; +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.policy.OptimisticLockSeatStrategy; +import com.thirdparty.ticketing.domain.ticket.service.PersistenceTicketService; +import com.thirdparty.ticketing.domain.ticket.service.TicketService; + +@DataJpaTest +@Import({ + PersistenceTicketService.class, + SimulatedPaymentProcessor.class, + OptimisticLockSeatStrategy.class +}) +public class PersistenceTicketServiceTest { + private static final Logger log = LoggerFactory.getLogger(PersistenceTicketServiceTest.class); + @Autowired private SeatRepository seatRepository; + + @Autowired private TicketService ticketService; + + 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 { + + @Test + @DisplayName("다른 스레드에서 테스트 데이터를 볼 수 있는지 확인한다") + void select_otherThread() throws InterruptedException { + Long seatId = 1L; + + ExecutorService executor = Executors.newFixedThreadPool(2); + + executor.execute( + () -> { + seatRepository.findById(seatId).orElseThrow(); + }); + + executor.shutdown(); + executor.awaitTermination(10, TimeUnit.SECONDS); + } + + @Test + @DisplayName("여러개의 동시 요청 중 한 명만 좌석을 성공적으로 선택해야 한다.") + void selectSeat_ConcurrencyTest() throws InterruptedException { + // Given + int numRequests = 2000; + 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( + 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( + CountDownLatch latch, + Long seatId, + AtomicInteger successfulSelections, + AtomicInteger failedSelections) { + + setUpAuthentication(); + try { + latch.await(); + try { + ticketService.selectSeat(memberEmail, new SeatSelectionRequest(seatId)); + successfulSelections.incrementAndGet(); + } catch (Exception 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 { + + @Test + @DisplayName("동시에 여러 요청이 오면 하나의 요청만 성공한다.") + void reservationTicket_ConcurrencyTest() 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( + 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( + String memberEmail, + Long seatId, + AtomicInteger successfulReservations, + AtomicInteger failedReservations) { + try { + ticketService.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/ticket/service/TicketServiceConcurrencyTest.java b/src/test/java/com/thirdparty/ticketing/domain/ticket/service/TicketServiceConcurrencyTest.java new file mode 100644 index 00000000..6cc1c490 --- /dev/null +++ b/src/test/java/com/thirdparty/ticketing/domain/ticket/service/TicketServiceConcurrencyTest.java @@ -0,0 +1,142 @@ +package com.thirdparty.ticketing.domain.ticket.service; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockitoAnnotations; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import com.thirdparty.ticketing.domain.common.LettuceRepository; +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.seat.Seat; +import com.thirdparty.ticketing.domain.seat.SeatGrade; +import com.thirdparty.ticketing.domain.seat.SeatStatus; +import com.thirdparty.ticketing.domain.seat.repository.SeatRepository; +import com.thirdparty.ticketing.domain.ticket.dto.SeatSelectionRequest; +import com.thirdparty.ticketing.domain.zone.Zone; + +@ExtendWith(SpringExtension.class) +@SpringBootTest +public class TicketServiceConcurrencyTest { + + @MockBean private SeatRepository seatRepository; + + @Autowired private MemberRepository memberRepository; + + @Autowired private LettuceRepository lettuceRepository; + + @Autowired private RedissonClient redissonClient; + + @Autowired private LettuceCacheTicketService lettuceCacheTicketService; + + @Autowired private RedissonCacheTicketService redissonCacheTicketService; + + 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 = + new Performance( + 1L, + "Phantom of the Opera", + "Broadway Theater", + ZonedDateTime.now().plusDays(10)); + seatGrade = new SeatGrade(1L, performance, 20000L, "Regular"); + zone = new Zone(1L, performance, "R"); + + seat = spy(new Seat(1L, zone, seatGrade, null, "R", SeatStatus.SELECTABLE)); + + // Repository 모킹 + when(seatRepository.findById(seat.getSeatId())).thenReturn(Optional.of(seat)); + } + + @AfterEach + void breakUp() { + memberRepository.deleteAll(); + } + + @Test + public void testConcurrentSeatSelectionWithLettuce() throws InterruptedException { + runConcurrentSeatSelectionTest(lettuceCacheTicketService); + } + + @Test + public void testConcurrentSeatSelectionWithRedisson() throws InterruptedException { + runConcurrentSeatSelectionTest(redissonCacheTicketService); + } + + private void runConcurrentSeatSelectionTest(TicketService ticketService) + throws InterruptedException { + int threadCount = members.size(); + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (Member member : members) { + // 각 멤버에 대해 작업을 스레드 풀에 제출 + executorService.submit( + () -> { + try { + // 스레드 풀에서 병렬로 실행되는 작업 + SeatSelectionRequest seatSelectionRequest = + new SeatSelectionRequest(seat.getSeatId()); + ticketService.selectSeat(member.getEmail(), seatSelectionRequest); + } catch (RuntimeException e) { + // 예외 발생 시 오류 로그 출력 + System.err.println( + "Exception occurred for member: " + + member.getEmail() + + " - " + + e.getMessage()); + } finally { + // latch 카운트 감소, 스레드 완료 시 호출 + latch.countDown(); + } + }); + } + + latch.await(); + + Seat reservedSeat = seatRepository.findById(seat.getSeatId()).orElseThrow(); + assertNotNull(reservedSeat.getMember(), "Seat should be reserved by one member"); + System.out.println(reservedSeat.getMember().getEmail()); + // designateMember 메서드가 정확히 한 번 호출되었는지 확인 + verify(seat, times(1)).designateMember(any(Member.class)); + } +} 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());